@xingwangzhe/tags-cloud 0.5.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -27
- package/dist/index.js +129 -50
- package/dist/index.js.map +1 -1
- package/package.json +15 -3
- package/src/TagCloud.ts +260 -80
- package/src/index.ts +10 -1
package/README.md
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- **
|
|
9
|
+
- **Multi-modal** — text, images, SVG, HTML, video, Web Components
|
|
10
10
|
- **Fibonacci sphere distribution** — tags evenly placed on 3D sphere
|
|
11
11
|
- **Arcball interaction** — drag to rotate with quaternion-based Shoemake arcball
|
|
12
12
|
- **Auto-spin** — configurable per-axis spin (X/Y) with independent speed and direction
|
|
13
13
|
- **TypeScript** — fully typed
|
|
14
|
-
- **~
|
|
14
|
+
- **~3KB** gzipped
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
|
@@ -24,54 +24,78 @@ bun add @xingwangzhe/tags-cloud
|
|
|
24
24
|
```ts
|
|
25
25
|
import { TagCloud } from "@xingwangzhe/tags-cloud";
|
|
26
26
|
|
|
27
|
-
// Zero config — auto-creates Canvas and renders text
|
|
28
27
|
new TagCloud(document.getElementById("cloud"), {
|
|
29
|
-
tags: [
|
|
28
|
+
tags: [
|
|
29
|
+
"plain text",
|
|
30
|
+
{ type: "image", src: "/avatar.webp", width: 40, height: 40, onClick: () => open("/profile") },
|
|
31
|
+
{ type: "element", element: myComponent, onClick: () => console.log("clicked") },
|
|
32
|
+
{ type: "svg", content: "<svg>...</svg>", width: 48, height: 48 },
|
|
33
|
+
{ type: "html", html: "<b>bold</b>" },
|
|
34
|
+
{ type: "video", src: "/clip.mp4", width: 120, height: 68 },
|
|
35
|
+
],
|
|
30
36
|
radius: 300,
|
|
37
|
+
spinY: 0.15,
|
|
38
|
+
onTagClick(item) { console.log("clicked", item); },
|
|
31
39
|
});
|
|
32
40
|
```
|
|
33
41
|
|
|
42
|
+
## Tag Types
|
|
43
|
+
|
|
44
|
+
| TagItem | Renderer | Example |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `string` | Canvas | `"TypeScript"` |
|
|
47
|
+
| `{ type:"image" }` | Canvas | `{ type:"image", src, width, height, onClick? }` |
|
|
48
|
+
| `{ type:"svg" }` | DOM | `{ type:"svg", content, width, height, onClick? }` |
|
|
49
|
+
| `{ type:"html" }` | DOM | `{ type:"html", html, onClick? }` |
|
|
50
|
+
| `{ type:"video" }` | DOM | `{ type:"video", src, width, height, onClick? }` |
|
|
51
|
+
| `{ type:"element" }` | DOM | `{ type:"element", element, onClick? }` |
|
|
52
|
+
|
|
34
53
|
## API
|
|
35
54
|
|
|
36
55
|
### `new TagCloud(container, options)`
|
|
37
56
|
|
|
38
|
-
| Option
|
|
39
|
-
|
|
40
|
-
| `tags`
|
|
41
|
-
| `radius`
|
|
42
|
-
| `
|
|
43
|
-
| `
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
57
|
+
| Option | Type | Default | Description |
|
|
58
|
+
|---|---|---|---|
|
|
59
|
+
| `tags` | `TagItem[]` | — | Tag list (string or object) |
|
|
60
|
+
| `radius` | `number` | `300` | Sphere radius (px) |
|
|
61
|
+
| `width` | `number` | `0` | Canvas width in px (0 = auto) |
|
|
62
|
+
| `height` | `number` | `0` | Canvas height in px (0 = auto) |
|
|
63
|
+
| `spinY` | `number` | `0` | Y-axis spin: +right -left 0=off |
|
|
64
|
+
| `spinX` | `number` | `0` | X-axis spin: +down -up 0=off |
|
|
65
|
+
| `reverse` | `boolean` | `false` | Reverse both drag axes |
|
|
66
|
+
| `reverseX` | `boolean` | `false` | Reverse X-axis drag only |
|
|
67
|
+
| `reverseY` | `boolean` | `false` | Reverse Y-axis drag only |
|
|
68
|
+
| `inertiaDecay` | `number` | `0.96` | Inertia decay per frame |
|
|
69
|
+
| `dragSensitivity` | `number` | `3` | Drag sensitivity multiplier |
|
|
70
|
+
| `fontFamily` | `string` | `system-ui` | Font family |
|
|
71
|
+
| `fontSize` | `number` | `14` | Base font size (px) |
|
|
72
|
+
| `color` | `string` | `#fff` | Text color |
|
|
73
|
+
| `videoFullscreen` | `boolean` | `true` | Video tags click to fullscreen |
|
|
74
|
+
| `onTagClick` | `(item) => void` | — | Global click callback for all tags |
|
|
75
|
+
| `onRender` | `(tags) => void` | built-in | Custom render callback |
|
|
53
76
|
|
|
54
77
|
### Instance Methods
|
|
55
78
|
|
|
56
79
|
```ts
|
|
57
|
-
cloud.setTags(["new", "tags"]);
|
|
58
|
-
cloud.pause();
|
|
59
|
-
cloud.resume();
|
|
60
|
-
cloud.destroy();
|
|
80
|
+
cloud.setTags(["new", "tags"]);
|
|
81
|
+
cloud.pause();
|
|
82
|
+
cloud.resume();
|
|
83
|
+
cloud.destroy();
|
|
61
84
|
```
|
|
62
85
|
|
|
63
86
|
## Development
|
|
64
87
|
|
|
65
88
|
```bash
|
|
66
89
|
bun install
|
|
67
|
-
bun run build
|
|
68
|
-
bun run
|
|
69
|
-
bun run
|
|
90
|
+
bun run build # vite build → dist/
|
|
91
|
+
bun run build:demo # vite build → out/ (deployable HTML)
|
|
92
|
+
bun run lint # oxlint
|
|
93
|
+
bun run fmt # oxfmt
|
|
70
94
|
```
|
|
71
95
|
|
|
72
96
|
## Credits
|
|
73
97
|
|
|
74
|
-
|
|
98
|
+
**Special thanks to [cong-min/TagCloud](https://github.com/cong-min/TagCloud)** — the original 3D tag cloud library that inspired this project. Core sphere distribution and rotation algorithms are ported from TagCloud.js.
|
|
75
99
|
|
|
76
100
|
## License
|
|
77
101
|
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,8 @@ function e(e, t) {
|
|
|
15
15
|
//#region src/TagCloud.ts
|
|
16
16
|
var t = {
|
|
17
17
|
radius: 300,
|
|
18
|
+
width: 0,
|
|
19
|
+
height: 0,
|
|
18
20
|
spinY: 0,
|
|
19
21
|
spinX: 0,
|
|
20
22
|
reverse: !1,
|
|
@@ -24,8 +26,13 @@ var t = {
|
|
|
24
26
|
dragSensitivity: 3,
|
|
25
27
|
fontFamily: "system-ui, sans-serif",
|
|
26
28
|
fontSize: 14,
|
|
29
|
+
videoFullscreen: !0,
|
|
27
30
|
color: "#ffffff"
|
|
28
|
-
}
|
|
31
|
+
};
|
|
32
|
+
function n(e) {
|
|
33
|
+
return typeof e != "string";
|
|
34
|
+
}
|
|
35
|
+
var r = class {
|
|
29
36
|
#e;
|
|
30
37
|
#t = [];
|
|
31
38
|
#n;
|
|
@@ -46,40 +53,28 @@ var t = {
|
|
|
46
53
|
#s = 0;
|
|
47
54
|
#c = !1;
|
|
48
55
|
#l = !1;
|
|
49
|
-
#u =
|
|
56
|
+
#u = !1;
|
|
57
|
+
#d = {
|
|
50
58
|
x: 0,
|
|
51
59
|
y: 0,
|
|
52
60
|
z: 0
|
|
53
61
|
};
|
|
54
|
-
#
|
|
55
|
-
#f;
|
|
62
|
+
#f = 0;
|
|
56
63
|
#p;
|
|
57
64
|
#m;
|
|
58
65
|
#h;
|
|
66
|
+
#g;
|
|
67
|
+
#_;
|
|
68
|
+
#v = /* @__PURE__ */ new Map();
|
|
69
|
+
#y = [];
|
|
59
70
|
constructor(e, n) {
|
|
60
|
-
this.#
|
|
71
|
+
this.#p = e, this.#e = {
|
|
61
72
|
...t,
|
|
62
73
|
...n
|
|
63
|
-
}, this.#n = this.#e.radius, this.#r = 2 * this.#n, this.#e.onRender || (this.#e.onRender = this.#
|
|
64
|
-
}
|
|
65
|
-
#g = (e) => {
|
|
66
|
-
if (!this.#m) {
|
|
67
|
-
let e = document.createElement("canvas");
|
|
68
|
-
e.style.width = "100%", e.style.height = "100%", this.#f.appendChild(e), this.#m = e, this.#h = e.getContext("2d"), this.#_();
|
|
69
|
-
}
|
|
70
|
-
let { width: t, height: n } = this.#m.getBoundingClientRect(), r = this.#h;
|
|
71
|
-
r.clearRect(0, 0, t, n);
|
|
72
|
-
let { fontFamily: i, fontSize: a, color: o } = this.#e;
|
|
73
|
-
for (let t of e) r.save(), r.globalAlpha = t.alpha, r.font = `${a + t.scale * 5}px ${i}`, r.fillStyle = o, r.textAlign = "center", r.textBaseline = "middle", r.fillText(t.text, t.x, t.y), r.restore();
|
|
74
|
-
};
|
|
75
|
-
#_() {
|
|
76
|
-
let e = this.#m;
|
|
77
|
-
if (!e) return;
|
|
78
|
-
let t = window.devicePixelRatio || 1, { width: n, height: r } = e.getBoundingClientRect();
|
|
79
|
-
e.width = n * t, e.height = r * t, this.#h.setTransform(t, 0, 0, t, 0, 0);
|
|
74
|
+
}, this.#n = this.#e.radius, this.#r = 2 * this.#n, this.#e.onRender || (this.#e.onRender = this.#w), this.#b(this.#e.tags), this.#x(), this.#S(), this.#D();
|
|
80
75
|
}
|
|
81
76
|
setTags(e) {
|
|
82
|
-
this.#
|
|
77
|
+
this.#b(e);
|
|
83
78
|
}
|
|
84
79
|
pause() {
|
|
85
80
|
this.#c = !0;
|
|
@@ -88,29 +83,30 @@ var t = {
|
|
|
88
83
|
this.#c = !1;
|
|
89
84
|
}
|
|
90
85
|
destroy() {
|
|
91
|
-
cancelAnimationFrame(this.#
|
|
92
|
-
let e = this.#
|
|
93
|
-
this.#
|
|
86
|
+
cancelAnimationFrame(this.#f);
|
|
87
|
+
let e = this.#m;
|
|
88
|
+
this.#p.removeEventListener("pointerdown", e.down), window.removeEventListener("pointermove", e.move), window.removeEventListener("pointerup", e.up), this.#h && this.#h.remove(), this.#_ && this.#_.remove();
|
|
94
89
|
}
|
|
95
|
-
#
|
|
90
|
+
#b(t) {
|
|
96
91
|
let n = 1.5 * this.#n, r = e(t.length, n / 2);
|
|
97
92
|
this.#t = r.map((e, n) => ({
|
|
98
93
|
...e,
|
|
99
|
-
|
|
94
|
+
item: t[n]
|
|
100
95
|
}));
|
|
101
96
|
}
|
|
102
|
-
#
|
|
103
|
-
this.#
|
|
104
|
-
let e = () => this.#
|
|
105
|
-
this.#
|
|
97
|
+
#x() {
|
|
98
|
+
this.#p.style.cursor = "grab";
|
|
99
|
+
let e = () => this.#p.getBoundingClientRect();
|
|
100
|
+
this.#m = {
|
|
106
101
|
down: ((t) => {
|
|
107
|
-
this.#l = !0, this.#
|
|
102
|
+
this.#l = !0, this.#p.style.cursor = "grabbing", this.#a = { ...this.#i };
|
|
108
103
|
let n = e();
|
|
109
|
-
this.#
|
|
104
|
+
this.#d = this.#C(t.clientX - n.left, t.clientY - n.top, n.width, n.height), this.#o = 0, this.#s = 0;
|
|
110
105
|
}),
|
|
111
106
|
move: ((t) => {
|
|
112
107
|
if (!this.#l) return;
|
|
113
|
-
|
|
108
|
+
this.#u = !0;
|
|
109
|
+
let n = e(), r = this.#C(t.clientX - n.left, t.clientY - n.top, n.width, n.height), i = this.#d, a = i.x * r.x + i.y * r.y + i.z * r.z, o = this.#e.reverse || this.#e.reverseX ? -1 : 1, s = this.#e.reverse || this.#e.reverseY ? -1 : 1, c = {
|
|
114
110
|
w: 1 + a,
|
|
115
111
|
x: (i.y * r.z - i.z * r.y) * o,
|
|
116
112
|
y: (i.x * r.z - i.z * r.x) * s,
|
|
@@ -128,11 +124,31 @@ var t = {
|
|
|
128
124
|
this.#o = c.y / l * d, this.#s = c.x / l * d;
|
|
129
125
|
}),
|
|
130
126
|
up: () => {
|
|
131
|
-
this.#l = !1, this.#
|
|
127
|
+
this.#l = !1, this.#p.style.cursor = "grab", setTimeout(() => {
|
|
128
|
+
this.#u = !1;
|
|
129
|
+
}, 0);
|
|
132
130
|
}
|
|
133
|
-
}, this.#
|
|
131
|
+
}, this.#p.addEventListener("pointerdown", this.#m.down), window.addEventListener("pointermove", this.#m.move), window.addEventListener("pointerup", this.#m.up);
|
|
132
|
+
}
|
|
133
|
+
#S() {
|
|
134
|
+
this.#p.addEventListener("click", (e) => {
|
|
135
|
+
if (this.#u) return;
|
|
136
|
+
let t = this.#p.getBoundingClientRect(), r = e.clientX - t.left, i = e.clientY - t.top, a = null;
|
|
137
|
+
for (let e of this.#y) {
|
|
138
|
+
if (typeof e.item != "string" && !e.item.onClick) continue;
|
|
139
|
+
let t = r - e.x, n = i - e.y, o = Math.sqrt(t * t + n * n);
|
|
140
|
+
o < (e.item.type === "image" ? e.item.width / 2 : 30) * e.scale && (!a || o < a.dist) && (a = {
|
|
141
|
+
item: e.item,
|
|
142
|
+
dist: o
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (a) {
|
|
146
|
+
let e = a.item;
|
|
147
|
+
n(e) && e.onClick && e.onClick(), this.#e.onTagClick && this.#e.onTagClick(e);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
134
150
|
}
|
|
135
|
-
#
|
|
151
|
+
#C(e, t, n, r) {
|
|
136
152
|
let i = e / n * 2 - 1, a = -(t / r * 2 - 1), o = i * i + a * a;
|
|
137
153
|
if (o > 1) {
|
|
138
154
|
let e = 1 / Math.sqrt(o);
|
|
@@ -148,10 +164,73 @@ var t = {
|
|
|
148
164
|
z: Math.sqrt(1 - o)
|
|
149
165
|
};
|
|
150
166
|
}
|
|
151
|
-
#
|
|
152
|
-
|
|
167
|
+
#w = (e) => {
|
|
168
|
+
if (!this.#h) {
|
|
169
|
+
let e = document.createElement("canvas");
|
|
170
|
+
this.#e.width ? (e.style.width = `${this.#e.width}px`, this.#p.style.width = `${this.#e.width}px`) : e.style.width = "100%", this.#e.height ? (e.style.height = `${this.#e.height}px`, this.#p.style.height = `${this.#e.height}px`) : e.style.height = "100%", this.#p.appendChild(e), this.#h = e, this.#g = e.getContext("2d"), this.#E();
|
|
171
|
+
}
|
|
172
|
+
if (!this.#_) {
|
|
173
|
+
this.#p.style.position = "relative";
|
|
174
|
+
let e = document.createElement("div");
|
|
175
|
+
e.style.position = "absolute", e.style.inset = "0", e.style.pointerEvents = "none", e.style.overflow = "hidden", this.#p.appendChild(e), this.#_ = e;
|
|
176
|
+
}
|
|
177
|
+
let { width: t, height: n } = this.#h.getBoundingClientRect(), r = this.#g;
|
|
178
|
+
r.clearRect(0, 0, t, n);
|
|
179
|
+
let i = [], a = /* @__PURE__ */ new Set(), o = /* @__PURE__ */ new Map(), s = [];
|
|
180
|
+
for (let t of e) if (typeof t.item == "string") {
|
|
181
|
+
let { fontFamily: e, fontSize: n, color: a } = this.#e;
|
|
182
|
+
r.save(), r.globalAlpha = t.alpha, r.font = `${n + t.scale * 5}px ${e}`, r.fillStyle = a, r.textAlign = "center", r.textBaseline = "middle", r.fillText(t.item, t.x, t.y), r.restore(), i.push({
|
|
183
|
+
item: t.item,
|
|
184
|
+
x: t.x,
|
|
185
|
+
y: t.y,
|
|
186
|
+
scale: t.scale
|
|
187
|
+
});
|
|
188
|
+
} else if (t.item.type === "image") {
|
|
189
|
+
let e = o.get(t.item.src);
|
|
190
|
+
e || (e = new Image(), e.src = t.item.src, o.set(t.item.src, e), s.push(new Promise((t) => {
|
|
191
|
+
e.onload = () => t();
|
|
192
|
+
})));
|
|
193
|
+
let { width: n, height: a } = t.item, c = n * t.scale, l = a * t.scale;
|
|
194
|
+
r.save(), r.globalAlpha = t.alpha, r.drawImage(e, t.x - c / 2, t.y - l / 2, c, l), r.restore(), i.push({
|
|
195
|
+
item: t.item,
|
|
196
|
+
x: t.x,
|
|
197
|
+
y: t.y,
|
|
198
|
+
scale: t.scale
|
|
199
|
+
});
|
|
200
|
+
} else {
|
|
201
|
+
a.add(t.item);
|
|
202
|
+
let e = this.#v.get(t.item);
|
|
203
|
+
e || (e = this.#T(t.item), this.#v.set(t.item, e), this.#_.appendChild(e)), e.style.transform = `translate3d(${t.x.toFixed(1)}px, ${t.y.toFixed(1)}px, 0) scale(${t.scale.toFixed(2)})`, e.style.opacity = String(t.alpha), e.style.zIndex = String(Math.round(t.scale * 100)), i.push({
|
|
204
|
+
item: t.item,
|
|
205
|
+
x: t.x,
|
|
206
|
+
y: t.y,
|
|
207
|
+
scale: t.scale
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
for (let [e, t] of this.#v) a.has(e) || (t.remove(), this.#v.delete(e));
|
|
211
|
+
this.#y = i;
|
|
153
212
|
};
|
|
154
|
-
#
|
|
213
|
+
#T(e) {
|
|
214
|
+
let t = document.createElement("div");
|
|
215
|
+
t.style.position = "absolute", t.style.top = "0", t.style.left = "0", t.style.willChange = "transform, opacity";
|
|
216
|
+
let n = !!(e.onClick || e.type === "video" && this.#e.videoFullscreen);
|
|
217
|
+
return t.style.cursor = n ? "pointer" : "default", t.style.pointerEvents = n ? "auto" : "none", e.type === "element" ? t.appendChild(e.element) : e.type === "html" ? t.innerHTML = e.html : e.type === "svg" ? t.innerHTML = e.content : e.type === "video" && (t.innerHTML = `<video src="${e.src}" width="${e.width}" height="${e.height}" autoplay muted loop playsinline></video>`, this.#e.videoFullscreen && t.addEventListener("click", () => {
|
|
218
|
+
let e = t.querySelector("video");
|
|
219
|
+
document.fullscreenElement ? document.exitFullscreen() : (e.play(), e.requestFullscreen());
|
|
220
|
+
})), (e.onClick || this.#e.onTagClick) && t.addEventListener("click", (t) => {
|
|
221
|
+
t.stopPropagation(), e.onClick && e.onClick(), this.#e.onTagClick && this.#e.onTagClick(e);
|
|
222
|
+
}), t;
|
|
223
|
+
}
|
|
224
|
+
#E() {
|
|
225
|
+
let e = this.#h;
|
|
226
|
+
if (!e) return;
|
|
227
|
+
let t = window.devicePixelRatio || 1, { width: n, height: r } = e.getBoundingClientRect();
|
|
228
|
+
e.width = n * t, e.height = r * t, this.#g.setTransform(t, 0, 0, t, 0, 0);
|
|
229
|
+
}
|
|
230
|
+
#D = () => {
|
|
231
|
+
this.#c || this.#A(), this.#f = requestAnimationFrame(this.#D);
|
|
232
|
+
};
|
|
233
|
+
#O(e) {
|
|
155
234
|
let t = e * Math.PI / 360, n = {
|
|
156
235
|
w: Math.cos(t),
|
|
157
236
|
x: 0,
|
|
@@ -165,7 +244,7 @@ var t = {
|
|
|
165
244
|
z: n.w * r.z - n.y * r.x
|
|
166
245
|
};
|
|
167
246
|
}
|
|
168
|
-
#
|
|
247
|
+
#k(e) {
|
|
169
248
|
let t = e * Math.PI / 360, n = {
|
|
170
249
|
w: Math.cos(t),
|
|
171
250
|
x: Math.sin(t),
|
|
@@ -179,14 +258,14 @@ var t = {
|
|
|
179
258
|
z: n.w * r.z + n.x * r.y
|
|
180
259
|
};
|
|
181
260
|
}
|
|
182
|
-
#
|
|
183
|
-
let e = this.#
|
|
184
|
-
this.#l || (this.#
|
|
261
|
+
#A() {
|
|
262
|
+
let e = this.#p.getBoundingClientRect(), t = e.width / 2, n = e.height / 2, r = this.#e.reverse || this.#e.reverseY ? -1 : 1, i = this.#e.reverse || this.#e.reverseX ? -1 : 1, a = this.#e.inertiaDecay;
|
|
263
|
+
this.#l || (this.#O((this.#e.spinY + this.#o) * r), this.#k((this.#e.spinX + this.#s) * i), this.#o *= a, this.#s *= a);
|
|
185
264
|
let { w: o, x: s, y: c, z: l } = this.#i, u = 1 - 2 * (c * c + l * l), d = 2 * (s * c - o * l), f = 2 * (s * l + o * c), p = 2 * (s * c + o * l), m = 1 - 2 * (s * s + l * l), h = 2 * (c * l - o * s), g = 2 * (s * l - o * c), _ = 2 * (c * l + o * s), v = this.#r * 2, y = [];
|
|
186
265
|
for (let e of this.#t) {
|
|
187
266
|
let r = u * e.x + d * e.y + f * e.z, i = p * e.x + m * e.y + h * e.z, a = g * e.x + _ * e.y + (1 - 2 * (s * s + c * c)) * e.z, o = v / (v + a), l = Math.min(1, Math.max(0, o * o - .25));
|
|
188
267
|
y.push({
|
|
189
|
-
|
|
268
|
+
item: e.item,
|
|
190
269
|
x: t + r * o,
|
|
191
270
|
y: n + i * o,
|
|
192
271
|
z: a,
|
|
@@ -199,7 +278,7 @@ var t = {
|
|
|
199
278
|
};
|
|
200
279
|
//#endregion
|
|
201
280
|
//#region src/core/rotation.ts
|
|
202
|
-
function
|
|
281
|
+
function i(e, t, n) {
|
|
203
282
|
let r = Math.sin(t), i = Math.cos(t), a = Math.sin(n), o = Math.cos(n), s = e.y * i + e.z * -r, c = e.y * r + e.z * i;
|
|
204
283
|
return {
|
|
205
284
|
x: e.x * o + c * a,
|
|
@@ -207,7 +286,7 @@ function r(e, t, n) {
|
|
|
207
286
|
z: c * o - e.x * a
|
|
208
287
|
};
|
|
209
288
|
}
|
|
210
|
-
function
|
|
289
|
+
function a(e, t, n) {
|
|
211
290
|
let r = Math.sin(t), i = Math.cos(t), a = Math.sin(n), o = Math.cos(n);
|
|
212
291
|
return e.map((e) => {
|
|
213
292
|
let t = e.y * i + e.z * -r, n = e.y * r + e.z * i;
|
|
@@ -220,7 +299,7 @@ function i(e, t, n) {
|
|
|
220
299
|
}
|
|
221
300
|
//#endregion
|
|
222
301
|
//#region src/core/projection.ts
|
|
223
|
-
function
|
|
302
|
+
function o(e, t) {
|
|
224
303
|
let n = 2 * t;
|
|
225
304
|
return e.map((e) => {
|
|
226
305
|
let t = n / (n + e.z), r = Math.min(1, Math.max(0, t * t - .25));
|
|
@@ -234,6 +313,6 @@ function a(e, t) {
|
|
|
234
313
|
});
|
|
235
314
|
}
|
|
236
315
|
//#endregion
|
|
237
|
-
export {
|
|
316
|
+
export { r as TagCloud, e as fibonacciSphere, o as project, i as rotatePoint, a as rotatePoints };
|
|
238
317
|
|
|
239
318
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["#container","#opts","#radius","#depth","#canvasRender","#initTags","#bindEvents","#loop","#canvas","#ctx","#resizeCanvas","#paused","#raf","#handlers","#points","#dragging","#qDown","#qNow","#vDown","#screenToSphere","#velY","#velX","#tick","#rotateY","#rotateX"],"sources":["../src/core/distribution.ts","../src/TagCloud.ts","../src/core/rotation.ts","../src/core/projection.ts"],"sourcesContent":["/**\n * 斐波那契球面分布\n * Fibonacci sphere distribution\n *\n * 将 N 个点均匀分布在球面上,避免两极聚集\n * Evenly distributes N points on a sphere, avoiding polar clustering\n *\n * ported from TagCloud.js _computePosition\n */\n\nexport interface Vec3 {\n x: number;\n y: number;\n z: number;\n}\n\n/**\n * 生成球面上均匀分布的 N 个点\n * Generate N evenly distributed points on a sphere of radius R\n */\nexport function fibonacciSphere(n: number, R: number): Vec3[] {\n const points: Vec3[] = [];\n for (let i = 0; i < n; i++) {\n // φ = acos(1 - 2(i+0.5)/N) — 纬度均匀分布\n // φ = acos(1 - 2(i+0.5)/N) — uniform latitude\n const phi = Math.acos(-1 + (2 * i + 1) / n);\n // θ = √(Nπ) × φ — 经度黄金比例螺旋\n // θ = √(Nπ) × φ — golden ratio spiral for longitude\n const theta = Math.sqrt(n * Math.PI) * phi;\n points.push({\n x: R * Math.cos(theta) * Math.sin(phi),\n y: R * Math.sin(theta) * Math.sin(phi),\n z: R * Math.cos(phi),\n });\n }\n return points;\n}\n","/**\n * 3D 标签云 — 纯数学引擎\n * 3D Tag Cloud — Pure Math Engine\n *\n * 零 DOM 渲染,每帧通过 onRender 回调输出投影坐标\n * Zero DOM rendering, outputs projected coords via onRender callback each frame\n *\n * 基于 cong-min/TagCloud 算法\n * Based on cong-min/TagCloud algorithm\n */\nimport { fibonacciSphere } from \"./core/distribution\";\n\n// ── 类型\n// ── Types\n\n/** 投影后的标签数据 */\n/** Projected tag data */\nexport interface TagData {\n text: string;\n /** 容器内 X 坐标(像素) */\n /** X coordinate in container (px) */\n x: number;\n /** 容器内 Y 坐标(像素) */\n /** Y coordinate in container (px) */\n y: number;\n /** Z 深度(-radius ~ +radius) */\n /** Z depth */\n z: number;\n /** 缩放比例 (0 ~ 1+) */\n /** scale factor */\n scale: number;\n /** 透明度 (0 ~ 1) */\n /** opacity */\n alpha: number;\n}\n\nexport interface TagCloudOptions {\n /** 标签文本数组 */\n /** tag text array */\n tags: string[];\n /** 球面半径(px) */\n /** sphere radius (px) (default 300) */\n radius?: number;\n /** 绕 Y 轴自旋速度(°/帧): +右转 -左转 0=关 */\n /** Y-axis auto-spin speed (°/frame): +right -left 0=off (default 0) */\n spinY?: number;\n /** 绕 X 轴自旋速度(°/帧): +下转 -上转 0=关 */\n /** X-axis auto-spin speed (°/frame): +down -up 0=off (default 0) */\n spinX?: number;\n /** 反转方向(X+Y 同时) */\n /** reverse both axes (default false) */\n reverse?: boolean;\n /** 单独反转 X 轴(上下拖拽) */\n /** reverse X-axis drag only (default false) */\n reverseX?: boolean;\n /** 反转 Y 轴拖拽方向 */\n /** reverse Y-axis drag direction (default false) */\n reverseY?: boolean;\n /** 惯性衰减系数(每帧乘以此值) */\n /** inertia decay per frame (default 0.96) */\n inertiaDecay?: number;\n /** 拖拽灵敏度(松手后惯性速度倍率) */\n /** drag sensitivity for release velocity (default 3) */\n dragSensitivity?: number;\n /** 字体 */\n /** font family (default \"system-ui, sans-serif\") */\n fontFamily?: string;\n /** 基础字号(px) */\n /** base font size in px (default 14) */\n fontSize?: number;\n /** 文字颜色 */\n /** text color (default \"#ffffff\") */\n color?: string;\n /** 自定义渲染回调(如不提供则用内置 Canvas) */\n /** custom render callback (built-in Canvas if omitted) */\n onRender?: (tags: TagData[]) => void;\n}\n\n// ── 内部类型\n// ── Internal Types\n\ninterface SpherePoint {\n x: number;\n y: number;\n z: number;\n text: string;\n}\n\ntype ResolvedOptions = TagCloudOptions & Required<Omit<TagCloudOptions, \"onRender\">>;\n\nconst DEFAULTS: Omit<ResolvedOptions, \"tags\" | \"onRender\"> = {\n radius: 300,\n spinY: 0,\n spinX: 0,\n reverse: false,\n reverseX: false,\n reverseY: false,\n inertiaDecay: 0.96,\n dragSensitivity: 3,\n fontFamily: \"system-ui, sans-serif\",\n fontSize: 14,\n color: \"#ffffff\",\n};\n\n// ── 主类\n// ── Main Class\n\nexport class TagCloud {\n #opts: ResolvedOptions;\n #points: SpherePoint[] = [];\n #radius: number;\n #depth: number;\n\n // 旋转状态 — 四元数\n // rotation state as quaternion\n #qNow = { w: 1, x: 0, y: 0, z: 0 };\n #qDown = { w: 1, x: 0, y: 0, z: 0 };\n #velY = 0;\n #velX = 0;\n #paused = false;\n\n // 拖拽状态\n // arcball drag state\n #dragging = false;\n #vDown = { x: 0, y: 0, z: 0 };\n\n // 动画\n // animation\n #raf = 0;\n #container: HTMLElement;\n #handlers!: { down: EventListener; move: EventListener; up: EventListener };\n\n // 内置 Canvas(仅当 onRender 未提供时创建)\n // built-in Canvas (only when onRender is not provided)\n #canvas?: HTMLCanvasElement;\n #ctx?: CanvasRenderingContext2D;\n\n constructor(container: HTMLElement, options: TagCloudOptions) {\n this.#container = container;\n this.#opts = { ...DEFAULTS, ...options } as ResolvedOptions;\n this.#radius = this.#opts.radius;\n this.#depth = 2 * this.#radius;\n\n // 内置 Canvas 渲染器\n // built-in Canvas renderer\n if (!this.#opts.onRender) {\n this.#opts.onRender = this.#canvasRender;\n }\n\n this.#initTags(this.#opts.tags);\n this.#bindEvents();\n this.#loop();\n }\n\n /** 内置 Canvas 渲染器 */\n /** Built-in Canvas renderer */\n #canvasRender = (tags: TagData[]): void => {\n if (!this.#canvas) {\n const c = document.createElement(\"canvas\");\n c.style.width = \"100%\";\n c.style.height = \"100%\";\n this.#container.appendChild(c);\n this.#canvas = c;\n this.#ctx = c.getContext(\"2d\")!;\n this.#resizeCanvas();\n }\n const { width, height } = this.#canvas.getBoundingClientRect();\n const ctx = this.#ctx!;\n ctx.clearRect(0, 0, width, height);\n const { fontFamily, fontSize, color } = this.#opts;\n for (const t of tags) {\n ctx.save();\n ctx.globalAlpha = t.alpha;\n ctx.font = `${fontSize + t.scale * 5}px ${fontFamily}`;\n ctx.fillStyle = color;\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillText(t.text, t.x, t.y);\n ctx.restore();\n }\n };\n\n #resizeCanvas(): void {\n const c = this.#canvas;\n if (!c) return;\n const dpr = window.devicePixelRatio || 1;\n const { width, height } = c.getBoundingClientRect();\n c.width = width * dpr;\n c.height = height * dpr;\n this.#ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);\n }\n\n // ── 公开 API\n // ── Public API\n\n setTags(tags: string[]): void {\n this.#initTags(tags);\n }\n pause(): void {\n this.#paused = true;\n }\n resume(): void {\n this.#paused = false;\n }\n\n destroy(): void {\n cancelAnimationFrame(this.#raf);\n const h = this.#handlers;\n this.#container.removeEventListener(\"pointerdown\", h.down);\n window.removeEventListener(\"pointermove\", h.move);\n window.removeEventListener(\"pointerup\", h.up);\n if (this.#canvas) this.#canvas.remove();\n }\n\n // ── 内部方法\n // ── Internal\n\n #initTags(tags: string[]): void {\n const size = 1.5 * this.#radius;\n const positions = fibonacciSphere(tags.length, size / 2);\n this.#points = positions.map((p, i) => ({ ...p, text: tags[i]! }));\n }\n\n #bindEvents(): void {\n this.#container.style.cursor = \"grab\";\n\n const rect = () => this.#container.getBoundingClientRect();\n\n this.#handlers = {\n down: ((e: PointerEvent) => {\n this.#dragging = true;\n this.#container.style.cursor = \"grabbing\";\n this.#qDown = { ...this.#qNow };\n const r = rect();\n this.#vDown = this.#screenToSphere(\n e.clientX - r.left,\n e.clientY - r.top,\n r.width,\n r.height,\n );\n this.#velY = 0;\n this.#velX = 0;\n }) as EventListener,\n move: ((e: PointerEvent) => {\n if (!this.#dragging) return;\n const r = rect();\n const vCur = this.#screenToSphere(e.clientX - r.left, e.clientY - r.top, r.width, r.height);\n const vA = this.#vDown;\n const dot = vA.x * vCur.x + vA.y * vCur.y + vA.z * vCur.z;\n // Shoemake arcball 四元数\n // Shoemake arcball quaternion\n const revX = this.#opts.reverse || this.#opts.reverseX ? -1 : 1;\n const revY = this.#opts.reverse || this.#opts.reverseY ? -1 : 1;\n const qDrag = {\n w: 1 + dot,\n x: (vA.y * vCur.z - vA.z * vCur.y) * revX,\n y: (vA.x * vCur.z - vA.z * vCur.x) * revY,\n z: (vA.x * vCur.y - vA.y * vCur.x) * revX * revY,\n };\n const len = Math.sqrt(qDrag.w ** 2 + qDrag.x ** 2 + qDrag.y ** 2 + qDrag.z ** 2);\n qDrag.w /= len;\n qDrag.x /= len;\n qDrag.y /= len;\n qDrag.z /= len;\n // 组合\n // compose\n const qD = this.#qDown;\n this.#qNow = {\n w: qDrag.w * qD.w - qDrag.x * qD.x - qDrag.y * qD.y - qDrag.z * qD.z,\n x: qDrag.w * qD.x + qDrag.x * qD.w + qDrag.y * qD.z - qDrag.z * qD.y,\n y: qDrag.w * qD.y - qDrag.x * qD.z + qDrag.y * qD.w + qDrag.z * qD.x,\n z: qDrag.w * qD.z + qDrag.x * qD.y - qDrag.y * qD.x + qDrag.z * qD.w,\n };\n // 拖拽速度\n // drag velocity\n const sens = this.#opts.dragSensitivity;\n this.#velY = (qDrag.y / len) * sens;\n this.#velX = (qDrag.x / len) * sens;\n }) as EventListener,\n up: () => {\n this.#dragging = false;\n this.#container.style.cursor = \"grab\";\n },\n };\n\n this.#container.addEventListener(\"pointerdown\", this.#handlers.down);\n window.addEventListener(\"pointermove\", this.#handlers.move);\n window.addEventListener(\"pointerup\", this.#handlers.up);\n }\n\n /** 屏幕坐标 → 球面 3D 点 */\n /** screen coords → sphere 3D point */\n #screenToSphere(\n sx: number,\n sy: number,\n w: number,\n h: number,\n ): { x: number; y: number; z: number } {\n const x = (sx / w) * 2 - 1;\n const y = -((sy / h) * 2 - 1);\n const r2 = x * x + y * y;\n if (r2 > 1) {\n // 球外 → 投影到球边缘\n // outside sphere → project to edge\n const inv = 1 / Math.sqrt(r2);\n return { x: x * inv, y: y * inv, z: 0 };\n }\n return { x, y, z: Math.sqrt(1 - r2) };\n }\n\n #loop = (): void => {\n if (!this.#paused) this.#tick();\n this.#raf = requestAnimationFrame(this.#loop);\n };\n\n /** 绕 Y 轴旋转 */\n /** rotate around Y axis */\n #rotateY(deg: number): void {\n const half = (deg * Math.PI) / 360;\n const qY = { w: Math.cos(half), x: 0, y: Math.sin(half), z: 0 };\n const q = this.#qNow;\n this.#qNow = {\n w: qY.w * q.w - qY.y * q.y,\n x: qY.w * q.x + qY.y * q.z,\n y: qY.w * q.y + qY.y * q.w,\n z: qY.w * q.z - qY.y * q.x,\n };\n }\n\n /** 绕 X 轴旋转 */\n /** rotate around X axis */\n #rotateX(deg: number): void {\n const half = (deg * Math.PI) / 360;\n const qX = { w: Math.cos(half), x: Math.sin(half), y: 0, z: 0 };\n const q = this.#qNow;\n this.#qNow = {\n w: qX.w * q.w - qX.x * q.x,\n x: qX.w * q.x + qX.x * q.w,\n y: qX.w * q.y - qX.x * q.z,\n z: qX.w * q.z + qX.x * q.y,\n };\n }\n\n #tick(): void {\n const rect = this.#container.getBoundingClientRect();\n const cx = rect.width / 2;\n const cy = rect.height / 2;\n\n // 自旋 + 惯性\n // auto-spin + inertia\n const revY = this.#opts.reverse || this.#opts.reverseY ? -1 : 1;\n const revX = this.#opts.reverse || this.#opts.reverseX ? -1 : 1;\n const decay = this.#opts.inertiaDecay;\n if (!this.#dragging) {\n this.#rotateY((this.#opts.spinY + this.#velY) * revY);\n this.#rotateX((this.#opts.spinX + this.#velX) * revX);\n this.#velY *= decay;\n this.#velX *= decay;\n }\n\n // 四元数构造 3×3 旋转矩阵\n // build 3×3 rotation matrix from quaternion\n const { w, x, y, z } = this.#qNow;\n const m00 = 1 - 2 * (y * y + z * z);\n const m01 = 2 * (x * y - w * z);\n const m02 = 2 * (x * z + w * y);\n const m10 = 2 * (x * y + w * z);\n const m11 = 1 - 2 * (x * x + z * z);\n const m12 = 2 * (y * z - w * x);\n const m20 = 2 * (x * z - w * y);\n const m21 = 2 * (y * z + w * x);\n\n const d2 = this.#depth * 2;\n const projected: TagData[] = [];\n\n for (const p of this.#points) {\n // 矩阵 × 点\n // matrix × point\n const rx = m00 * p.x + m01 * p.y + m02 * p.z;\n const ry = m10 * p.x + m11 * p.y + m12 * p.z;\n const rz = m20 * p.x + m21 * p.y + (1 - 2 * (x * x + y * y)) * p.z;\n\n const per = d2 / (d2 + rz);\n const alpha = Math.min(1, Math.max(0, per * per - 0.25));\n\n projected.push({\n text: p.text,\n x: cx + rx * per,\n y: cy + ry * per,\n z: rz,\n scale: per,\n alpha,\n });\n }\n\n this.#opts.onRender(projected.sort((a, b) => b.z - a.z));\n }\n}\n\nexport default TagCloud;\n","/**\n * 3D 旋转变换\n * 3D rotation transforms\n *\n * 绕 Y 轴和 X 轴旋转球面上的所有点\n * Rotates all points around Y and X axes\n *\n * ported from TagCloud.js _next() rotation logic\n */\n\nimport type { Vec3 } from \"./distribution\";\n\n/**\n * 旋转单个 3D 点\n * Rotate a single 3D point\n *\n * @param p - 待旋转的点\n * @param p - point to rotate\n * @param a - 绕 Y 轴的旋转角(弧度)\n * @param a - Y-axis rotation angle (radians)\n * @param b - 绕 X 轴的旋转角(弧度)\n * @param b - X-axis rotation angle (radians)\n */\nexport function rotatePoint(p: Vec3, a: number, b: number): Vec3 {\n const sinA = Math.sin(a);\n const cosA = Math.cos(a);\n const sinB = Math.sin(b);\n const cosB = Math.cos(b);\n\n // Y 轴旋转\n // Y-axis rotation\n const y1 = p.y * cosA + p.z * -sinA;\n const z1 = p.y * sinA + p.z * cosA;\n\n // X 轴旋转\n // X-axis rotation\n const x2 = p.x * cosB + z1 * sinB;\n const z2 = z1 * cosB - p.x * sinB;\n\n return { x: x2, y: y1, z: z2 };\n}\n\n/**\n * 批量旋转所有点\n * Rotate all points in batch\n */\nexport function rotatePoints(points: Vec3[], a: number, b: number): Vec3[] {\n const sinA = Math.sin(a);\n const cosA = Math.cos(a);\n const sinB = Math.sin(b);\n const cosB = Math.cos(b);\n\n return points.map((p) => {\n const y1 = p.y * cosA + p.z * -sinA;\n const z1 = p.y * sinA + p.z * cosA;\n const x2 = p.x * cosB + z1 * sinB;\n return { x: x2, y: y1, z: z1 * cosB - p.x * sinB };\n });\n}\n","/**\n * 透视投影\n * Perspective projection\n *\n * 根据 Z 深度计算每个标签的缩放比例和透明度\n * Calculates scale and alpha for each tag based on Z depth\n *\n * per = (2 × depth) / (2 × depth + z)\n * scale = per\n * alpha = per² − 0.25 → clamped [0, 1]\n *\n * ported from TagCloud.js _next() projection logic\n */\n\nexport interface ProjectedTag {\n /** 原始 X 坐标 */\n /** original X coordinate */\n x: number;\n /** 原始 Y 坐标 */\n /** original Y coordinate */\n y: number;\n /** 原始 Z 深度 */\n /** original Z depth */\n z: number;\n /** 缩放比例 (1 = 最近, 0 = 最远) */\n /** scale factor (1 = nearest, 0 = farthest) */\n scale: number;\n /** 透明度 (1 = 最前, 0 = 最后) */\n /** opacity (1 = front, 0 = back) */\n alpha: number;\n}\n\n/**\n * 对旋转后的点做透视投影\n * Apply perspective projection to rotated points\n *\n * @param points — 旋转后的 3D 点\n * @param points — rotated 3D points\n * @param depth — 透视深度 = 2 × 球半径\n * @param depth — perspective depth = 2 × sphere radius\n */\nexport function project(\n points: { x: number; y: number; z: number }[],\n depth: number,\n): ProjectedTag[] {\n const d2 = 2 * depth;\n return points.map((p) => {\n // 透视缩放\n // perspective scale\n const per = d2 / (d2 + p.z);\n // 透明度从 per² − 0.25 计算\n // alpha derived from per² − 0.25\n const alpha = Math.min(1, Math.max(0, per * per - 0.25));\n return { x: p.x, y: p.y, z: p.z, scale: per, alpha };\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":["#container","#opts","#radius","#depth","#canvasRender","#initTags","#bindEvents","#bindClicks","#loop","#paused","#raf","#handlers","#canvas","#overlay","#points","#dragging","#qDown","#qNow","#vDown","#screenToSphere","#velY","#velX","#dragged","#lastCanvasTags","#ctx","#resizeCanvas","#domEls","#createDomTag","#tick","#rotateY","#rotateX"],"sources":["../src/core/distribution.ts","../src/TagCloud.ts","../src/core/rotation.ts","../src/core/projection.ts"],"sourcesContent":["/**\n * 斐波那契球面分布\n * Fibonacci sphere distribution\n *\n * 将 N 个点均匀分布在球面上,避免两极聚集\n * Evenly distributes N points on a sphere, avoiding polar clustering\n *\n * ported from TagCloud.js _computePosition\n */\n\nexport interface Vec3 {\n x: number;\n y: number;\n z: number;\n}\n\n/**\n * 生成球面上均匀分布的 N 个点\n * Generate N evenly distributed points on a sphere of radius R\n */\nexport function fibonacciSphere(n: number, R: number): Vec3[] {\n const points: Vec3[] = [];\n for (let i = 0; i < n; i++) {\n // φ = acos(1 - 2(i+0.5)/N) — 纬度均匀分布\n // φ = acos(1 - 2(i+0.5)/N) — uniform latitude\n const phi = Math.acos(-1 + (2 * i + 1) / n);\n // θ = √(Nπ) × φ — 经度黄金比例螺旋\n // θ = √(Nπ) × φ — golden ratio spiral for longitude\n const theta = Math.sqrt(n * Math.PI) * phi;\n points.push({\n x: R * Math.cos(theta) * Math.sin(phi),\n y: R * Math.sin(theta) * Math.sin(phi),\n z: R * Math.cos(phi),\n });\n }\n return points;\n}\n","/**\n * 3D 标签云 — 纯数学引擎\n * 3D Tag Cloud — Pure Math Engine\n *\n * 零 DOM 渲染,每帧通过 onRender 回调输出投影坐标\n * Zero DOM rendering, outputs projected coords via onRender callback each frame\n *\n * 基于 cong-min/TagCloud 算法\n * Based on cong-min/TagCloud algorithm\n */\nimport { fibonacciSphere } from \"./core/distribution\";\n\n// ── 标签项类型\n// ── Tag Item Types\n\n/** 图片标签 */\nexport interface ImageTag {\n type: \"image\";\n src: string;\n width: number;\n height: number;\n onClick?: () => void;\n}\n\n/** SVG 标签 */\nexport interface SvgTag {\n type: \"svg\";\n content: string;\n width: number;\n height: number;\n onClick?: () => void;\n}\n\n/** HTML 标签(支持 innerHTML 字符串) */\nexport interface HtmlTag {\n type: \"html\";\n html: string;\n onClick?: () => void;\n}\n\n/** 视频标签 */\nexport interface VideoTag {\n type: \"video\";\n src: string;\n width: number;\n height: number;\n onClick?: () => void;\n}\n\n/** 任意 DOM 元素标签 */\nexport interface ElementTag {\n type: \"element\";\n element: HTMLElement;\n onClick?: () => void;\n}\n\n/** 标签内容:字符串 = 纯文本(Canvas 渲染),对象 = 富媒体 */\n/** Tag content: string = plain text (Canvas), object = rich media */\nexport type TagItem = string | ImageTag | SvgTag | HtmlTag | VideoTag | ElementTag;\n\n// ── 通用类型\n// ── Common Types\n\n/** 投影后的标签数据 */\n/** Projected tag data */\nexport interface TagData {\n /** 原始标签项 */\n item: TagItem;\n /** 容器内 X 坐标(像素) */\n x: number;\n /** 容器内 Y 坐标(像素) */\n y: number;\n /** Z 深度(-radius ~ +radius) */\n z: number;\n /** 缩放比例 (0 ~ 1+) */\n scale: number;\n /** 透明度 (0 ~ 1) */\n alpha: number;\n}\n\nexport interface TagCloudOptions {\n /** 标签列表(字符串 = 纯文本,对象 = 富媒体) */\n /** tag list (string = plain text, object = rich media) */\n tags: TagItem[];\n /** 球面半径(px) */\n /** sphere radius (px) (default 300) */\n radius?: number;\n /** Canvas 宽度(px) */\n /** canvas width in px (default follows container) */\n width?: number;\n /** Canvas 高度(px) */\n /** canvas height in px (default follows container) */\n height?: number;\n /** 绕 Y 轴自旋速度(°/帧): +右转 -左转 0=关 */\n /** Y-axis auto-spin speed (°/frame): +right -left 0=off (default 0) */\n spinY?: number;\n /** 绕 X 轴自旋速度(°/帧): +下转 -上转 0=关 */\n /** X-axis auto-spin speed (°/frame): +down -up 0=off (default 0) */\n spinX?: number;\n /** 反转方向(X+Y 同时) */\n /** reverse both axes (default false) */\n reverse?: boolean;\n /** 单独反转 X 轴(上下拖拽) */\n /** reverse X-axis drag only (default false) */\n reverseX?: boolean;\n /** 反转 Y 轴拖拽方向 */\n /** reverse Y-axis drag direction (default false) */\n reverseY?: boolean;\n /** 惯性衰减系数(每帧乘以此值) */\n /** inertia decay per frame (default 0.96) */\n inertiaDecay?: number;\n /** 拖拽灵敏度(松手后惯性速度倍率) */\n /** drag sensitivity for release velocity (default 3) */\n dragSensitivity?: number;\n /** 字体 */\n /** font family (default \"system-ui, sans-serif\") */\n fontFamily?: string;\n /** 基础字号(px) */\n /** base font size in px (default 14) */\n fontSize?: number;\n /** 文字颜色 */\n /** text color (default \"#ffffff\") */\n color?: string;\n /** 全局标签点击回调(所有标签共用,通过 tag 文本区分) */\n /** global click callback for all tags (distinguish by tag text) */\n onTagClick?: (item: TagItem) => void;\n /** 视频标签点击全屏 / video tags click to fullscreen (default true) */\n videoFullscreen?: boolean;\n /** 自定义渲染回调(如不提供则用内置 Canvas) */\n /** custom render callback (built-in Canvas if omitted) */\n onRender?: (tags: TagData[]) => void;\n}\n\n// ── 内部类型\n// ── Internal Types\n\ninterface SpherePoint {\n x: number;\n y: number;\n z: number;\n item: TagItem;\n}\n\ntype ResolvedOptions = TagCloudOptions & Required<Omit<TagCloudOptions, \"onRender\">>;\n\nconst DEFAULTS: Omit<ResolvedOptions, \"tags\" | \"onRender\"> = {\n radius: 300,\n width: 0,\n height: 0,\n spinY: 0,\n spinX: 0,\n reverse: false,\n reverseX: false,\n reverseY: false,\n inertiaDecay: 0.96,\n dragSensitivity: 3,\n fontFamily: \"system-ui, sans-serif\",\n fontSize: 14,\n videoFullscreen: true,\n color: \"#ffffff\",\n};\n\n/** 判断是否为对象类型的标签 */\nfunction isObjectTag(item: TagItem): item is Exclude<TagItem, string> {\n return typeof item !== \"string\";\n}\n\n// ── 主类\n// ── Main Class\n\nexport class TagCloud {\n #opts: ResolvedOptions;\n #points: SpherePoint[] = [];\n #radius: number;\n #depth: number;\n\n // 旋转状态 — 四元数\n #qNow = { w: 1, x: 0, y: 0, z: 0 };\n #qDown = { w: 1, x: 0, y: 0, z: 0 };\n #velY = 0;\n #velX = 0;\n #paused = false;\n\n // 拖拽状态\n #dragging = false;\n #dragged = false;\n #vDown = { x: 0, y: 0, z: 0 };\n\n // 内存\n #raf = 0;\n #container: HTMLElement;\n #handlers!: { down: EventListener; move: EventListener; up: EventListener };\n\n // 内置 Canvas\n #canvas?: HTMLCanvasElement;\n #ctx?: CanvasRenderingContext2D;\n\n // DOM overlay(渲染 element/html/svg/video 标签)\n #overlay?: HTMLDivElement;\n #domEls: Map<TagItem, HTMLElement> = new Map();\n\n // 点击:存储上一帧的 Canvas 标签投影坐标,供 raycast 查找\n #lastCanvasTags: { item: TagItem; x: number; y: number; scale: number }[] = [];\n\n constructor(container: HTMLElement, options: TagCloudOptions) {\n this.#container = container;\n this.#opts = { ...DEFAULTS, ...options } as ResolvedOptions;\n this.#radius = this.#opts.radius;\n this.#depth = 2 * this.#radius;\n\n if (!this.#opts.onRender) {\n this.#opts.onRender = this.#canvasRender;\n }\n\n this.#initTags(this.#opts.tags);\n this.#bindEvents();\n this.#bindClicks();\n this.#loop();\n }\n\n // ── 公开 API\n // ── Public API\n\n setTags(tags: TagItem[]): void {\n this.#initTags(tags);\n }\n pause(): void {\n this.#paused = true;\n }\n resume(): void {\n this.#paused = false;\n }\n\n destroy(): void {\n cancelAnimationFrame(this.#raf);\n const h = this.#handlers;\n this.#container.removeEventListener(\"pointerdown\", h.down);\n window.removeEventListener(\"pointermove\", h.move);\n window.removeEventListener(\"pointerup\", h.up);\n if (this.#canvas) this.#canvas.remove();\n if (this.#overlay) this.#overlay.remove();\n }\n\n // ── 内部方法\n // ── Internal\n\n #initTags(tags: TagItem[]): void {\n const size = 1.5 * this.#radius;\n const positions = fibonacciSphere(tags.length, size / 2);\n this.#points = positions.map((p, i) => ({ ...p, item: tags[i]! }));\n }\n\n #bindEvents(): void {\n this.#container.style.cursor = \"grab\";\n const rect = () => this.#container.getBoundingClientRect();\n\n this.#handlers = {\n down: ((e: PointerEvent) => {\n this.#dragging = true;\n this.#container.style.cursor = \"grabbing\";\n this.#qDown = { ...this.#qNow };\n const r = rect();\n this.#vDown = this.#screenToSphere(\n e.clientX - r.left,\n e.clientY - r.top,\n r.width,\n r.height,\n );\n this.#velY = 0;\n this.#velX = 0;\n }) as EventListener,\n move: ((e: PointerEvent) => {\n if (!this.#dragging) return;\n this.#dragged = true;\n const r = rect();\n const vCur = this.#screenToSphere(e.clientX - r.left, e.clientY - r.top, r.width, r.height);\n const vA = this.#vDown;\n const dot = vA.x * vCur.x + vA.y * vCur.y + vA.z * vCur.z;\n // Shoemake arcball 四元数\n const revX = this.#opts.reverse || this.#opts.reverseX ? -1 : 1;\n const revY = this.#opts.reverse || this.#opts.reverseY ? -1 : 1;\n const qDrag = {\n w: 1 + dot,\n x: (vA.y * vCur.z - vA.z * vCur.y) * revX,\n y: (vA.x * vCur.z - vA.z * vCur.x) * revY,\n z: (vA.x * vCur.y - vA.y * vCur.x) * revX * revY,\n };\n const len = Math.sqrt(qDrag.w ** 2 + qDrag.x ** 2 + qDrag.y ** 2 + qDrag.z ** 2);\n qDrag.w /= len;\n qDrag.x /= len;\n qDrag.y /= len;\n qDrag.z /= len;\n const qD = this.#qDown;\n this.#qNow = {\n w: qDrag.w * qD.w - qDrag.x * qD.x - qDrag.y * qD.y - qDrag.z * qD.z,\n x: qDrag.w * qD.x + qDrag.x * qD.w + qDrag.y * qD.z - qDrag.z * qD.y,\n y: qDrag.w * qD.y - qDrag.x * qD.z + qDrag.y * qD.w + qDrag.z * qD.x,\n z: qDrag.w * qD.z + qDrag.x * qD.y - qDrag.y * qD.x + qDrag.z * qD.w,\n };\n const sens = this.#opts.dragSensitivity;\n this.#velY = (qDrag.y / len) * sens;\n this.#velX = (qDrag.x / len) * sens;\n }) as EventListener,\n up: () => {\n this.#dragging = false;\n this.#container.style.cursor = \"grab\";\n setTimeout(() => { this.#dragged = false; }, 0);\n },\n };\n\n this.#container.addEventListener(\"pointerdown\", this.#handlers.down);\n window.addEventListener(\"pointermove\", this.#handlers.move);\n window.addEventListener(\"pointerup\", this.#handlers.up);\n }\n\n #bindClicks(): void {\n this.#container.addEventListener(\"click\", (e) => {\n if (this.#dragged) return;\n const r = this.#container.getBoundingClientRect();\n const cx = e.clientX - r.left;\n const cy = e.clientY - r.top;\n let best: { item: TagItem; dist: number } | null = null;\n for (const t of this.#lastCanvasTags) {\n if (typeof t.item !== \"string\" && !t.item.onClick) continue;\n const dx = cx - t.x;\n const dy = cy - t.y;\n const d = Math.sqrt(dx * dx + dy * dy);\n const rw = t.item.type === \"image\" ? t.item.width / 2 : 30;\n const hitRadius = rw * t.scale;\n if (d < hitRadius && (!best || d < best.dist)) {\n best = { item: t.item, dist: d };\n }\n }\n if (best) {\n const item = best.item;\n if (isObjectTag(item) && item.onClick) item.onClick();\n if (this.#opts.onTagClick) this.#opts.onTagClick(item);\n }\n });\n }\n\n /** 屏幕坐标 → 球面 3D 点 */\n #screenToSphere(\n sx: number,\n sy: number,\n w: number,\n h: number,\n ): { x: number; y: number; z: number } {\n const x = (sx / w) * 2 - 1;\n const y = -((sy / h) * 2 - 1);\n const r2 = x * x + y * y;\n if (r2 > 1) {\n const inv = 1 / Math.sqrt(r2);\n return { x: x * inv, y: y * inv, z: 0 };\n }\n return { x, y, z: Math.sqrt(1 - r2) };\n }\n\n /** 内置 Canvas 渲染器(文本 + 图片) */\n #canvasRender = (tags: TagData[]): void => {\n if (!this.#canvas) {\n const c = document.createElement(\"canvas\");\n if (this.#opts.width) { c.style.width = `${this.#opts.width}px`; this.#container.style.width = `${this.#opts.width}px`; }\n else c.style.width = \"100%\";\n if (this.#opts.height) { c.style.height = `${this.#opts.height}px`; this.#container.style.height = `${this.#opts.height}px`; }\n else c.style.height = \"100%\";\n this.#container.appendChild(c);\n this.#canvas = c;\n this.#ctx = c.getContext(\"2d\")!;\n this.#resizeCanvas();\n }\n // 初始化 DOM overlay\n if (!this.#overlay) {\n this.#container.style.position = \"relative\";\n const o = document.createElement(\"div\");\n o.style.position = \"absolute\";\n o.style.inset = \"0\";\n o.style.pointerEvents = \"none\";\n o.style.overflow = \"hidden\";\n this.#container.appendChild(o);\n this.#overlay = o;\n }\n const { width, height } = this.#canvas.getBoundingClientRect();\n const ctx = this.#ctx!;\n ctx.clearRect(0, 0, width, height);\n\n // 保存文本标签坐标(用于点击 raycast)\n const canvasTags: { item: TagItem; x: number; y: number; scale: number }[] = [];\n const currentDoms = new Set<TagItem>();\n\n // 先加载需要的图片\n const imageCache = new Map<string, HTMLImageElement>();\n const pendingImages: Promise<void>[] = [];\n\n for (const t of tags) {\n if (typeof t.item === \"string\") {\n // 文本标签\n const { fontFamily, fontSize, color } = this.#opts;\n ctx.save();\n ctx.globalAlpha = t.alpha;\n ctx.font = `${fontSize + t.scale * 5}px ${fontFamily}`;\n ctx.fillStyle = color;\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillText(t.item, t.x, t.y);\n ctx.restore();\n canvasTags.push({ item: t.item, x: t.x, y: t.y, scale: t.scale });\n } else if (t.item.type === \"image\") {\n // 图片标签 — Canvas drawImage()\n let img = imageCache.get(t.item.src);\n if (!img) {\n img = new Image();\n img.src = t.item.src;\n imageCache.set(t.item.src, img);\n pendingImages.push(\n new Promise((r) => {\n img!.onload = () => r();\n }),\n );\n }\n const { width: iw, height: ih } = t.item;\n const sw = iw * t.scale;\n const sh = ih * t.scale;\n ctx.save();\n ctx.globalAlpha = t.alpha;\n ctx.drawImage(img, t.x - sw / 2, t.y - sh / 2, sw, sh);\n ctx.restore();\n canvasTags.push({ item: t.item, x: t.x, y: t.y, scale: t.scale });\n } else {\n // DOM 标签(element/html/svg/video)→ overlay 渲染\n currentDoms.add(t.item);\n let el = this.#domEls.get(t.item);\n if (!el) {\n el = this.#createDomTag(t.item);\n this.#domEls.set(t.item, el);\n this.#overlay!.appendChild(el);\n }\n el.style.transform = `translate3d(${t.x.toFixed(1)}px, ${t.y.toFixed(1)}px, 0) scale(${t.scale.toFixed(2)})`;\n el.style.opacity = String(t.alpha);\n el.style.zIndex = String(Math.round(t.scale * 100));\n canvasTags.push({ item: t.item, x: t.x, y: t.y, scale: t.scale });\n }\n }\n\n // 清理已移除的 DOM 标签\n for (const [item, el] of this.#domEls) {\n if (!currentDoms.has(item)) {\n el.remove();\n this.#domEls.delete(item);\n }\n }\n\n this.#lastCanvasTags = canvasTags;\n };\n\n /** 为富媒体标签创建 DOM 元素 */\n #createDomTag(item: Exclude<TagItem, string>): HTMLElement {\n const el = document.createElement(\"div\");\n el.style.position = \"absolute\";\n el.style.top = \"0\";\n el.style.left = \"0\";\n el.style.willChange = \"transform, opacity\";\n const clickable = !!(item.onClick || (item.type === \"video\" && this.#opts.videoFullscreen));\n el.style.cursor = clickable ? \"pointer\" : \"default\";\n el.style.pointerEvents = clickable ? \"auto\" : \"none\";\n if (item.type === \"element\") el.appendChild(item.element);\n else if (item.type === \"html\") el.innerHTML = item.html;\n else if (item.type === \"svg\") el.innerHTML = item.content;\n else if (item.type === \"video\") {\n el.innerHTML = `<video src=\"${item.src}\" width=\"${item.width}\" height=\"${item.height}\" autoplay muted loop playsinline></video>`;\n if (this.#opts.videoFullscreen) {\n el.addEventListener(\"click\", () => {\n const v = el.querySelector(\"video\")!;\n if (document.fullscreenElement) { document.exitFullscreen(); }\n else { v.play(); v.requestFullscreen(); }\n });\n }\n }\n if (item.onClick || this.#opts.onTagClick) {\n el.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n if (item.onClick) item.onClick();\n if (this.#opts.onTagClick) this.#opts.onTagClick(item);\n });\n }\n return el;\n }\n\n #resizeCanvas(): void {\n const c = this.#canvas;\n if (!c) return;\n const dpr = window.devicePixelRatio || 1;\n const { width, height } = c.getBoundingClientRect();\n c.width = width * dpr;\n c.height = height * dpr;\n this.#ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);\n }\n\n #loop = (): void => {\n if (!this.#paused) this.#tick();\n this.#raf = requestAnimationFrame(this.#loop);\n };\n\n #rotateY(deg: number): void {\n const half = (deg * Math.PI) / 360;\n const qY = { w: Math.cos(half), x: 0, y: Math.sin(half), z: 0 };\n const q = this.#qNow;\n this.#qNow = {\n w: qY.w * q.w - qY.y * q.y,\n x: qY.w * q.x + qY.y * q.z,\n y: qY.w * q.y + qY.y * q.w,\n z: qY.w * q.z - qY.y * q.x,\n };\n }\n\n #rotateX(deg: number): void {\n const half = (deg * Math.PI) / 360;\n const qX = { w: Math.cos(half), x: Math.sin(half), y: 0, z: 0 };\n const q = this.#qNow;\n this.#qNow = {\n w: qX.w * q.w - qX.x * q.x,\n x: qX.w * q.x + qX.x * q.w,\n y: qX.w * q.y - qX.x * q.z,\n z: qX.w * q.z + qX.x * q.y,\n };\n }\n\n #tick(): void {\n const rect = this.#container.getBoundingClientRect();\n const cx = rect.width / 2;\n const cy = rect.height / 2;\n\n // 自旋 + 惯性\n const revY = this.#opts.reverse || this.#opts.reverseY ? -1 : 1;\n const revX = this.#opts.reverse || this.#opts.reverseX ? -1 : 1;\n const decay = this.#opts.inertiaDecay;\n if (!this.#dragging) {\n this.#rotateY((this.#opts.spinY + this.#velY) * revY);\n this.#rotateX((this.#opts.spinX + this.#velX) * revX);\n this.#velY *= decay;\n this.#velX *= decay;\n }\n\n // 四元数构造旋转矩阵\n const { w, x, y, z } = this.#qNow;\n const m00 = 1 - 2 * (y * y + z * z);\n const m01 = 2 * (x * y - w * z);\n const m02 = 2 * (x * z + w * y);\n const m10 = 2 * (x * y + w * z);\n const m11 = 1 - 2 * (x * x + z * z);\n const m12 = 2 * (y * z - w * x);\n const m20 = 2 * (x * z - w * y);\n const m21 = 2 * (y * z + w * x);\n\n const d2 = this.#depth * 2;\n const projected: TagData[] = [];\n\n for (const p of this.#points) {\n const rx = m00 * p.x + m01 * p.y + m02 * p.z;\n const ry = m10 * p.x + m11 * p.y + m12 * p.z;\n const rz = m20 * p.x + m21 * p.y + (1 - 2 * (x * x + y * y)) * p.z;\n\n const per = d2 / (d2 + rz);\n const alpha = Math.min(1, Math.max(0, per * per - 0.25));\n\n projected.push({\n item: p.item,\n x: cx + rx * per,\n y: cy + ry * per,\n z: rz,\n scale: per,\n alpha,\n });\n }\n\n this.#opts.onRender(projected.sort((a, b) => b.z - a.z));\n }\n}\n\nexport default TagCloud;\n","/**\n * 3D 旋转变换\n * 3D rotation transforms\n *\n * 绕 Y 轴和 X 轴旋转球面上的所有点\n * Rotates all points around Y and X axes\n *\n * ported from TagCloud.js _next() rotation logic\n */\n\nimport type { Vec3 } from \"./distribution\";\n\n/**\n * 旋转单个 3D 点\n * Rotate a single 3D point\n *\n * @param p - 待旋转的点\n * @param p - point to rotate\n * @param a - 绕 Y 轴的旋转角(弧度)\n * @param a - Y-axis rotation angle (radians)\n * @param b - 绕 X 轴的旋转角(弧度)\n * @param b - X-axis rotation angle (radians)\n */\nexport function rotatePoint(p: Vec3, a: number, b: number): Vec3 {\n const sinA = Math.sin(a);\n const cosA = Math.cos(a);\n const sinB = Math.sin(b);\n const cosB = Math.cos(b);\n\n // Y 轴旋转\n // Y-axis rotation\n const y1 = p.y * cosA + p.z * -sinA;\n const z1 = p.y * sinA + p.z * cosA;\n\n // X 轴旋转\n // X-axis rotation\n const x2 = p.x * cosB + z1 * sinB;\n const z2 = z1 * cosB - p.x * sinB;\n\n return { x: x2, y: y1, z: z2 };\n}\n\n/**\n * 批量旋转所有点\n * Rotate all points in batch\n */\nexport function rotatePoints(points: Vec3[], a: number, b: number): Vec3[] {\n const sinA = Math.sin(a);\n const cosA = Math.cos(a);\n const sinB = Math.sin(b);\n const cosB = Math.cos(b);\n\n return points.map((p) => {\n const y1 = p.y * cosA + p.z * -sinA;\n const z1 = p.y * sinA + p.z * cosA;\n const x2 = p.x * cosB + z1 * sinB;\n return { x: x2, y: y1, z: z1 * cosB - p.x * sinB };\n });\n}\n","/**\n * 透视投影\n * Perspective projection\n *\n * 根据 Z 深度计算每个标签的缩放比例和透明度\n * Calculates scale and alpha for each tag based on Z depth\n *\n * per = (2 × depth) / (2 × depth + z)\n * scale = per\n * alpha = per² − 0.25 → clamped [0, 1]\n *\n * ported from TagCloud.js _next() projection logic\n */\n\nexport interface ProjectedTag {\n /** 原始 X 坐标 */\n /** original X coordinate */\n x: number;\n /** 原始 Y 坐标 */\n /** original Y coordinate */\n y: number;\n /** 原始 Z 深度 */\n /** original Z depth */\n z: number;\n /** 缩放比例 (1 = 最近, 0 = 最远) */\n /** scale factor (1 = nearest, 0 = farthest) */\n scale: number;\n /** 透明度 (1 = 最前, 0 = 最后) */\n /** opacity (1 = front, 0 = back) */\n alpha: number;\n}\n\n/**\n * 对旋转后的点做透视投影\n * Apply perspective projection to rotated points\n *\n * @param points — 旋转后的 3D 点\n * @param points — rotated 3D points\n * @param depth — 透视深度 = 2 × 球半径\n * @param depth — perspective depth = 2 × sphere radius\n */\nexport function project(\n points: { x: number; y: number; z: number }[],\n depth: number,\n): ProjectedTag[] {\n const d2 = 2 * depth;\n return points.map((p) => {\n // 透视缩放\n // perspective scale\n const per = d2 / (d2 + p.z);\n // 透明度从 per² − 0.25 计算\n // alpha derived from per² − 0.25\n const alpha = Math.min(1, Math.max(0, per * per - 0.25));\n return { x: p.x, y: p.y, z: p.z, scale: per, alpha };\n });\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingwangzhe/tags-cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Canvas-driven 3D tag cloud engine",
|
|
5
|
-
"keywords": [
|
|
5
|
+
"keywords": [
|
|
6
|
+
"3d",
|
|
7
|
+
"canvas",
|
|
8
|
+
"sphere",
|
|
9
|
+
"tag-cloud",
|
|
10
|
+
"tags"
|
|
11
|
+
],
|
|
6
12
|
"license": "MIT",
|
|
7
|
-
"files": [
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"src/core",
|
|
16
|
+
"src/index.ts",
|
|
17
|
+
"src/TagCloud.ts"
|
|
18
|
+
],
|
|
8
19
|
"type": "module",
|
|
9
20
|
"main": "./dist/index.js",
|
|
10
21
|
"module": "./dist/index.js",
|
|
@@ -18,6 +29,7 @@
|
|
|
18
29
|
"scripts": {
|
|
19
30
|
"dev": "vite",
|
|
20
31
|
"build": "vite build",
|
|
32
|
+
"build:demo": "vite build -c vite.demo.config.ts",
|
|
21
33
|
"fmt": "oxfmt",
|
|
22
34
|
"fmt:check": "oxfmt --check",
|
|
23
35
|
"lint": "oxlint",
|
package/src/TagCloud.ts
CHANGED
|
@@ -10,37 +10,87 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { fibonacciSphere } from "./core/distribution";
|
|
12
12
|
|
|
13
|
-
// ──
|
|
14
|
-
// ── Types
|
|
13
|
+
// ── 标签项类型
|
|
14
|
+
// ── Tag Item Types
|
|
15
|
+
|
|
16
|
+
/** 图片标签 */
|
|
17
|
+
export interface ImageTag {
|
|
18
|
+
type: "image";
|
|
19
|
+
src: string;
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
onClick?: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** SVG 标签 */
|
|
26
|
+
export interface SvgTag {
|
|
27
|
+
type: "svg";
|
|
28
|
+
content: string;
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
onClick?: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** HTML 标签(支持 innerHTML 字符串) */
|
|
35
|
+
export interface HtmlTag {
|
|
36
|
+
type: "html";
|
|
37
|
+
html: string;
|
|
38
|
+
onClick?: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 视频标签 */
|
|
42
|
+
export interface VideoTag {
|
|
43
|
+
type: "video";
|
|
44
|
+
src: string;
|
|
45
|
+
width: number;
|
|
46
|
+
height: number;
|
|
47
|
+
onClick?: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 任意 DOM 元素标签 */
|
|
51
|
+
export interface ElementTag {
|
|
52
|
+
type: "element";
|
|
53
|
+
element: HTMLElement;
|
|
54
|
+
onClick?: () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 标签内容:字符串 = 纯文本(Canvas 渲染),对象 = 富媒体 */
|
|
58
|
+
/** Tag content: string = plain text (Canvas), object = rich media */
|
|
59
|
+
export type TagItem = string | ImageTag | SvgTag | HtmlTag | VideoTag | ElementTag;
|
|
60
|
+
|
|
61
|
+
// ── 通用类型
|
|
62
|
+
// ── Common Types
|
|
15
63
|
|
|
16
64
|
/** 投影后的标签数据 */
|
|
17
65
|
/** Projected tag data */
|
|
18
66
|
export interface TagData {
|
|
19
|
-
|
|
67
|
+
/** 原始标签项 */
|
|
68
|
+
item: TagItem;
|
|
20
69
|
/** 容器内 X 坐标(像素) */
|
|
21
|
-
/** X coordinate in container (px) */
|
|
22
70
|
x: number;
|
|
23
71
|
/** 容器内 Y 坐标(像素) */
|
|
24
|
-
/** Y coordinate in container (px) */
|
|
25
72
|
y: number;
|
|
26
73
|
/** Z 深度(-radius ~ +radius) */
|
|
27
|
-
/** Z depth */
|
|
28
74
|
z: number;
|
|
29
75
|
/** 缩放比例 (0 ~ 1+) */
|
|
30
|
-
/** scale factor */
|
|
31
76
|
scale: number;
|
|
32
77
|
/** 透明度 (0 ~ 1) */
|
|
33
|
-
/** opacity */
|
|
34
78
|
alpha: number;
|
|
35
79
|
}
|
|
36
80
|
|
|
37
81
|
export interface TagCloudOptions {
|
|
38
|
-
/**
|
|
39
|
-
/** tag text
|
|
40
|
-
tags:
|
|
82
|
+
/** 标签列表(字符串 = 纯文本,对象 = 富媒体) */
|
|
83
|
+
/** tag list (string = plain text, object = rich media) */
|
|
84
|
+
tags: TagItem[];
|
|
41
85
|
/** 球面半径(px) */
|
|
42
86
|
/** sphere radius (px) (default 300) */
|
|
43
87
|
radius?: number;
|
|
88
|
+
/** Canvas 宽度(px) */
|
|
89
|
+
/** canvas width in px (default follows container) */
|
|
90
|
+
width?: number;
|
|
91
|
+
/** Canvas 高度(px) */
|
|
92
|
+
/** canvas height in px (default follows container) */
|
|
93
|
+
height?: number;
|
|
44
94
|
/** 绕 Y 轴自旋速度(°/帧): +右转 -左转 0=关 */
|
|
45
95
|
/** Y-axis auto-spin speed (°/frame): +right -left 0=off (default 0) */
|
|
46
96
|
spinY?: number;
|
|
@@ -71,6 +121,11 @@ export interface TagCloudOptions {
|
|
|
71
121
|
/** 文字颜色 */
|
|
72
122
|
/** text color (default "#ffffff") */
|
|
73
123
|
color?: string;
|
|
124
|
+
/** 全局标签点击回调(所有标签共用,通过 tag 文本区分) */
|
|
125
|
+
/** global click callback for all tags (distinguish by tag text) */
|
|
126
|
+
onTagClick?: (item: TagItem) => void;
|
|
127
|
+
/** 视频标签点击全屏 / video tags click to fullscreen (default true) */
|
|
128
|
+
videoFullscreen?: boolean;
|
|
74
129
|
/** 自定义渲染回调(如不提供则用内置 Canvas) */
|
|
75
130
|
/** custom render callback (built-in Canvas if omitted) */
|
|
76
131
|
onRender?: (tags: TagData[]) => void;
|
|
@@ -83,13 +138,15 @@ interface SpherePoint {
|
|
|
83
138
|
x: number;
|
|
84
139
|
y: number;
|
|
85
140
|
z: number;
|
|
86
|
-
|
|
141
|
+
item: TagItem;
|
|
87
142
|
}
|
|
88
143
|
|
|
89
144
|
type ResolvedOptions = TagCloudOptions & Required<Omit<TagCloudOptions, "onRender">>;
|
|
90
145
|
|
|
91
146
|
const DEFAULTS: Omit<ResolvedOptions, "tags" | "onRender"> = {
|
|
92
147
|
radius: 300,
|
|
148
|
+
width: 0,
|
|
149
|
+
height: 0,
|
|
93
150
|
spinY: 0,
|
|
94
151
|
spinX: 0,
|
|
95
152
|
reverse: false,
|
|
@@ -99,9 +156,15 @@ const DEFAULTS: Omit<ResolvedOptions, "tags" | "onRender"> = {
|
|
|
99
156
|
dragSensitivity: 3,
|
|
100
157
|
fontFamily: "system-ui, sans-serif",
|
|
101
158
|
fontSize: 14,
|
|
159
|
+
videoFullscreen: true,
|
|
102
160
|
color: "#ffffff",
|
|
103
161
|
};
|
|
104
162
|
|
|
163
|
+
/** 判断是否为对象类型的标签 */
|
|
164
|
+
function isObjectTag(item: TagItem): item is Exclude<TagItem, string> {
|
|
165
|
+
return typeof item !== "string";
|
|
166
|
+
}
|
|
167
|
+
|
|
105
168
|
// ── 主类
|
|
106
169
|
// ── Main Class
|
|
107
170
|
|
|
@@ -112,7 +175,6 @@ export class TagCloud {
|
|
|
112
175
|
#depth: number;
|
|
113
176
|
|
|
114
177
|
// 旋转状态 — 四元数
|
|
115
|
-
// rotation state as quaternion
|
|
116
178
|
#qNow = { w: 1, x: 0, y: 0, z: 0 };
|
|
117
179
|
#qDown = { w: 1, x: 0, y: 0, z: 0 };
|
|
118
180
|
#velY = 0;
|
|
@@ -120,80 +182,46 @@ export class TagCloud {
|
|
|
120
182
|
#paused = false;
|
|
121
183
|
|
|
122
184
|
// 拖拽状态
|
|
123
|
-
// arcball drag state
|
|
124
185
|
#dragging = false;
|
|
186
|
+
#dragged = false;
|
|
125
187
|
#vDown = { x: 0, y: 0, z: 0 };
|
|
126
188
|
|
|
127
|
-
//
|
|
128
|
-
// animation
|
|
189
|
+
// 内存
|
|
129
190
|
#raf = 0;
|
|
130
191
|
#container: HTMLElement;
|
|
131
192
|
#handlers!: { down: EventListener; move: EventListener; up: EventListener };
|
|
132
193
|
|
|
133
|
-
// 内置 Canvas
|
|
134
|
-
// built-in Canvas (only when onRender is not provided)
|
|
194
|
+
// 内置 Canvas
|
|
135
195
|
#canvas?: HTMLCanvasElement;
|
|
136
196
|
#ctx?: CanvasRenderingContext2D;
|
|
137
197
|
|
|
198
|
+
// DOM overlay(渲染 element/html/svg/video 标签)
|
|
199
|
+
#overlay?: HTMLDivElement;
|
|
200
|
+
#domEls: Map<TagItem, HTMLElement> = new Map();
|
|
201
|
+
|
|
202
|
+
// 点击:存储上一帧的 Canvas 标签投影坐标,供 raycast 查找
|
|
203
|
+
#lastCanvasTags: { item: TagItem; x: number; y: number; scale: number }[] = [];
|
|
204
|
+
|
|
138
205
|
constructor(container: HTMLElement, options: TagCloudOptions) {
|
|
139
206
|
this.#container = container;
|
|
140
207
|
this.#opts = { ...DEFAULTS, ...options } as ResolvedOptions;
|
|
141
208
|
this.#radius = this.#opts.radius;
|
|
142
209
|
this.#depth = 2 * this.#radius;
|
|
143
210
|
|
|
144
|
-
// 内置 Canvas 渲染器
|
|
145
|
-
// built-in Canvas renderer
|
|
146
211
|
if (!this.#opts.onRender) {
|
|
147
212
|
this.#opts.onRender = this.#canvasRender;
|
|
148
213
|
}
|
|
149
214
|
|
|
150
215
|
this.#initTags(this.#opts.tags);
|
|
151
216
|
this.#bindEvents();
|
|
217
|
+
this.#bindClicks();
|
|
152
218
|
this.#loop();
|
|
153
219
|
}
|
|
154
220
|
|
|
155
|
-
/** 内置 Canvas 渲染器 */
|
|
156
|
-
/** Built-in Canvas renderer */
|
|
157
|
-
#canvasRender = (tags: TagData[]): void => {
|
|
158
|
-
if (!this.#canvas) {
|
|
159
|
-
const c = document.createElement("canvas");
|
|
160
|
-
c.style.width = "100%";
|
|
161
|
-
c.style.height = "100%";
|
|
162
|
-
this.#container.appendChild(c);
|
|
163
|
-
this.#canvas = c;
|
|
164
|
-
this.#ctx = c.getContext("2d")!;
|
|
165
|
-
this.#resizeCanvas();
|
|
166
|
-
}
|
|
167
|
-
const { width, height } = this.#canvas.getBoundingClientRect();
|
|
168
|
-
const ctx = this.#ctx!;
|
|
169
|
-
ctx.clearRect(0, 0, width, height);
|
|
170
|
-
const { fontFamily, fontSize, color } = this.#opts;
|
|
171
|
-
for (const t of tags) {
|
|
172
|
-
ctx.save();
|
|
173
|
-
ctx.globalAlpha = t.alpha;
|
|
174
|
-
ctx.font = `${fontSize + t.scale * 5}px ${fontFamily}`;
|
|
175
|
-
ctx.fillStyle = color;
|
|
176
|
-
ctx.textAlign = "center";
|
|
177
|
-
ctx.textBaseline = "middle";
|
|
178
|
-
ctx.fillText(t.text, t.x, t.y);
|
|
179
|
-
ctx.restore();
|
|
180
|
-
}
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
#resizeCanvas(): void {
|
|
184
|
-
const c = this.#canvas;
|
|
185
|
-
if (!c) return;
|
|
186
|
-
const dpr = window.devicePixelRatio || 1;
|
|
187
|
-
const { width, height } = c.getBoundingClientRect();
|
|
188
|
-
c.width = width * dpr;
|
|
189
|
-
c.height = height * dpr;
|
|
190
|
-
this.#ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
221
|
// ── 公开 API
|
|
194
222
|
// ── Public API
|
|
195
223
|
|
|
196
|
-
setTags(tags:
|
|
224
|
+
setTags(tags: TagItem[]): void {
|
|
197
225
|
this.#initTags(tags);
|
|
198
226
|
}
|
|
199
227
|
pause(): void {
|
|
@@ -210,20 +238,20 @@ export class TagCloud {
|
|
|
210
238
|
window.removeEventListener("pointermove", h.move);
|
|
211
239
|
window.removeEventListener("pointerup", h.up);
|
|
212
240
|
if (this.#canvas) this.#canvas.remove();
|
|
241
|
+
if (this.#overlay) this.#overlay.remove();
|
|
213
242
|
}
|
|
214
243
|
|
|
215
244
|
// ── 内部方法
|
|
216
245
|
// ── Internal
|
|
217
246
|
|
|
218
|
-
#initTags(tags:
|
|
247
|
+
#initTags(tags: TagItem[]): void {
|
|
219
248
|
const size = 1.5 * this.#radius;
|
|
220
249
|
const positions = fibonacciSphere(tags.length, size / 2);
|
|
221
|
-
this.#points = positions.map((p, i) => ({ ...p,
|
|
250
|
+
this.#points = positions.map((p, i) => ({ ...p, item: tags[i]! }));
|
|
222
251
|
}
|
|
223
252
|
|
|
224
253
|
#bindEvents(): void {
|
|
225
254
|
this.#container.style.cursor = "grab";
|
|
226
|
-
|
|
227
255
|
const rect = () => this.#container.getBoundingClientRect();
|
|
228
256
|
|
|
229
257
|
this.#handlers = {
|
|
@@ -243,12 +271,12 @@ export class TagCloud {
|
|
|
243
271
|
}) as EventListener,
|
|
244
272
|
move: ((e: PointerEvent) => {
|
|
245
273
|
if (!this.#dragging) return;
|
|
274
|
+
this.#dragged = true;
|
|
246
275
|
const r = rect();
|
|
247
276
|
const vCur = this.#screenToSphere(e.clientX - r.left, e.clientY - r.top, r.width, r.height);
|
|
248
277
|
const vA = this.#vDown;
|
|
249
278
|
const dot = vA.x * vCur.x + vA.y * vCur.y + vA.z * vCur.z;
|
|
250
279
|
// Shoemake arcball 四元数
|
|
251
|
-
// Shoemake arcball quaternion
|
|
252
280
|
const revX = this.#opts.reverse || this.#opts.reverseX ? -1 : 1;
|
|
253
281
|
const revY = this.#opts.reverse || this.#opts.reverseY ? -1 : 1;
|
|
254
282
|
const qDrag = {
|
|
@@ -262,8 +290,6 @@ export class TagCloud {
|
|
|
262
290
|
qDrag.x /= len;
|
|
263
291
|
qDrag.y /= len;
|
|
264
292
|
qDrag.z /= len;
|
|
265
|
-
// 组合
|
|
266
|
-
// compose
|
|
267
293
|
const qD = this.#qDown;
|
|
268
294
|
this.#qNow = {
|
|
269
295
|
w: qDrag.w * qD.w - qDrag.x * qD.x - qDrag.y * qD.y - qDrag.z * qD.z,
|
|
@@ -271,8 +297,6 @@ export class TagCloud {
|
|
|
271
297
|
y: qDrag.w * qD.y - qDrag.x * qD.z + qDrag.y * qD.w + qDrag.z * qD.x,
|
|
272
298
|
z: qDrag.w * qD.z + qDrag.x * qD.y - qDrag.y * qD.x + qDrag.z * qD.w,
|
|
273
299
|
};
|
|
274
|
-
// 拖拽速度
|
|
275
|
-
// drag velocity
|
|
276
300
|
const sens = this.#opts.dragSensitivity;
|
|
277
301
|
this.#velY = (qDrag.y / len) * sens;
|
|
278
302
|
this.#velX = (qDrag.x / len) * sens;
|
|
@@ -280,6 +304,7 @@ export class TagCloud {
|
|
|
280
304
|
up: () => {
|
|
281
305
|
this.#dragging = false;
|
|
282
306
|
this.#container.style.cursor = "grab";
|
|
307
|
+
setTimeout(() => { this.#dragged = false; }, 0);
|
|
283
308
|
},
|
|
284
309
|
};
|
|
285
310
|
|
|
@@ -288,8 +313,33 @@ export class TagCloud {
|
|
|
288
313
|
window.addEventListener("pointerup", this.#handlers.up);
|
|
289
314
|
}
|
|
290
315
|
|
|
316
|
+
#bindClicks(): void {
|
|
317
|
+
this.#container.addEventListener("click", (e) => {
|
|
318
|
+
if (this.#dragged) return;
|
|
319
|
+
const r = this.#container.getBoundingClientRect();
|
|
320
|
+
const cx = e.clientX - r.left;
|
|
321
|
+
const cy = e.clientY - r.top;
|
|
322
|
+
let best: { item: TagItem; dist: number } | null = null;
|
|
323
|
+
for (const t of this.#lastCanvasTags) {
|
|
324
|
+
if (typeof t.item !== "string" && !t.item.onClick) continue;
|
|
325
|
+
const dx = cx - t.x;
|
|
326
|
+
const dy = cy - t.y;
|
|
327
|
+
const d = Math.sqrt(dx * dx + dy * dy);
|
|
328
|
+
const rw = t.item.type === "image" ? t.item.width / 2 : 30;
|
|
329
|
+
const hitRadius = rw * t.scale;
|
|
330
|
+
if (d < hitRadius && (!best || d < best.dist)) {
|
|
331
|
+
best = { item: t.item, dist: d };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (best) {
|
|
335
|
+
const item = best.item;
|
|
336
|
+
if (isObjectTag(item) && item.onClick) item.onClick();
|
|
337
|
+
if (this.#opts.onTagClick) this.#opts.onTagClick(item);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
291
342
|
/** 屏幕坐标 → 球面 3D 点 */
|
|
292
|
-
/** screen coords → sphere 3D point */
|
|
293
343
|
#screenToSphere(
|
|
294
344
|
sx: number,
|
|
295
345
|
sy: number,
|
|
@@ -300,21 +350,157 @@ export class TagCloud {
|
|
|
300
350
|
const y = -((sy / h) * 2 - 1);
|
|
301
351
|
const r2 = x * x + y * y;
|
|
302
352
|
if (r2 > 1) {
|
|
303
|
-
// 球外 → 投影到球边缘
|
|
304
|
-
// outside sphere → project to edge
|
|
305
353
|
const inv = 1 / Math.sqrt(r2);
|
|
306
354
|
return { x: x * inv, y: y * inv, z: 0 };
|
|
307
355
|
}
|
|
308
356
|
return { x, y, z: Math.sqrt(1 - r2) };
|
|
309
357
|
}
|
|
310
358
|
|
|
359
|
+
/** 内置 Canvas 渲染器(文本 + 图片) */
|
|
360
|
+
#canvasRender = (tags: TagData[]): void => {
|
|
361
|
+
if (!this.#canvas) {
|
|
362
|
+
const c = document.createElement("canvas");
|
|
363
|
+
if (this.#opts.width) { c.style.width = `${this.#opts.width}px`; this.#container.style.width = `${this.#opts.width}px`; }
|
|
364
|
+
else c.style.width = "100%";
|
|
365
|
+
if (this.#opts.height) { c.style.height = `${this.#opts.height}px`; this.#container.style.height = `${this.#opts.height}px`; }
|
|
366
|
+
else c.style.height = "100%";
|
|
367
|
+
this.#container.appendChild(c);
|
|
368
|
+
this.#canvas = c;
|
|
369
|
+
this.#ctx = c.getContext("2d")!;
|
|
370
|
+
this.#resizeCanvas();
|
|
371
|
+
}
|
|
372
|
+
// 初始化 DOM overlay
|
|
373
|
+
if (!this.#overlay) {
|
|
374
|
+
this.#container.style.position = "relative";
|
|
375
|
+
const o = document.createElement("div");
|
|
376
|
+
o.style.position = "absolute";
|
|
377
|
+
o.style.inset = "0";
|
|
378
|
+
o.style.pointerEvents = "none";
|
|
379
|
+
o.style.overflow = "hidden";
|
|
380
|
+
this.#container.appendChild(o);
|
|
381
|
+
this.#overlay = o;
|
|
382
|
+
}
|
|
383
|
+
const { width, height } = this.#canvas.getBoundingClientRect();
|
|
384
|
+
const ctx = this.#ctx!;
|
|
385
|
+
ctx.clearRect(0, 0, width, height);
|
|
386
|
+
|
|
387
|
+
// 保存文本标签坐标(用于点击 raycast)
|
|
388
|
+
const canvasTags: { item: TagItem; x: number; y: number; scale: number }[] = [];
|
|
389
|
+
const currentDoms = new Set<TagItem>();
|
|
390
|
+
|
|
391
|
+
// 先加载需要的图片
|
|
392
|
+
const imageCache = new Map<string, HTMLImageElement>();
|
|
393
|
+
const pendingImages: Promise<void>[] = [];
|
|
394
|
+
|
|
395
|
+
for (const t of tags) {
|
|
396
|
+
if (typeof t.item === "string") {
|
|
397
|
+
// 文本标签
|
|
398
|
+
const { fontFamily, fontSize, color } = this.#opts;
|
|
399
|
+
ctx.save();
|
|
400
|
+
ctx.globalAlpha = t.alpha;
|
|
401
|
+
ctx.font = `${fontSize + t.scale * 5}px ${fontFamily}`;
|
|
402
|
+
ctx.fillStyle = color;
|
|
403
|
+
ctx.textAlign = "center";
|
|
404
|
+
ctx.textBaseline = "middle";
|
|
405
|
+
ctx.fillText(t.item, t.x, t.y);
|
|
406
|
+
ctx.restore();
|
|
407
|
+
canvasTags.push({ item: t.item, x: t.x, y: t.y, scale: t.scale });
|
|
408
|
+
} else if (t.item.type === "image") {
|
|
409
|
+
// 图片标签 — Canvas drawImage()
|
|
410
|
+
let img = imageCache.get(t.item.src);
|
|
411
|
+
if (!img) {
|
|
412
|
+
img = new Image();
|
|
413
|
+
img.src = t.item.src;
|
|
414
|
+
imageCache.set(t.item.src, img);
|
|
415
|
+
pendingImages.push(
|
|
416
|
+
new Promise((r) => {
|
|
417
|
+
img!.onload = () => r();
|
|
418
|
+
}),
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
const { width: iw, height: ih } = t.item;
|
|
422
|
+
const sw = iw * t.scale;
|
|
423
|
+
const sh = ih * t.scale;
|
|
424
|
+
ctx.save();
|
|
425
|
+
ctx.globalAlpha = t.alpha;
|
|
426
|
+
ctx.drawImage(img, t.x - sw / 2, t.y - sh / 2, sw, sh);
|
|
427
|
+
ctx.restore();
|
|
428
|
+
canvasTags.push({ item: t.item, x: t.x, y: t.y, scale: t.scale });
|
|
429
|
+
} else {
|
|
430
|
+
// DOM 标签(element/html/svg/video)→ overlay 渲染
|
|
431
|
+
currentDoms.add(t.item);
|
|
432
|
+
let el = this.#domEls.get(t.item);
|
|
433
|
+
if (!el) {
|
|
434
|
+
el = this.#createDomTag(t.item);
|
|
435
|
+
this.#domEls.set(t.item, el);
|
|
436
|
+
this.#overlay!.appendChild(el);
|
|
437
|
+
}
|
|
438
|
+
el.style.transform = `translate3d(${t.x.toFixed(1)}px, ${t.y.toFixed(1)}px, 0) scale(${t.scale.toFixed(2)})`;
|
|
439
|
+
el.style.opacity = String(t.alpha);
|
|
440
|
+
el.style.zIndex = String(Math.round(t.scale * 100));
|
|
441
|
+
canvasTags.push({ item: t.item, x: t.x, y: t.y, scale: t.scale });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// 清理已移除的 DOM 标签
|
|
446
|
+
for (const [item, el] of this.#domEls) {
|
|
447
|
+
if (!currentDoms.has(item)) {
|
|
448
|
+
el.remove();
|
|
449
|
+
this.#domEls.delete(item);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
this.#lastCanvasTags = canvasTags;
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
/** 为富媒体标签创建 DOM 元素 */
|
|
457
|
+
#createDomTag(item: Exclude<TagItem, string>): HTMLElement {
|
|
458
|
+
const el = document.createElement("div");
|
|
459
|
+
el.style.position = "absolute";
|
|
460
|
+
el.style.top = "0";
|
|
461
|
+
el.style.left = "0";
|
|
462
|
+
el.style.willChange = "transform, opacity";
|
|
463
|
+
const clickable = !!(item.onClick || (item.type === "video" && this.#opts.videoFullscreen));
|
|
464
|
+
el.style.cursor = clickable ? "pointer" : "default";
|
|
465
|
+
el.style.pointerEvents = clickable ? "auto" : "none";
|
|
466
|
+
if (item.type === "element") el.appendChild(item.element);
|
|
467
|
+
else if (item.type === "html") el.innerHTML = item.html;
|
|
468
|
+
else if (item.type === "svg") el.innerHTML = item.content;
|
|
469
|
+
else if (item.type === "video") {
|
|
470
|
+
el.innerHTML = `<video src="${item.src}" width="${item.width}" height="${item.height}" autoplay muted loop playsinline></video>`;
|
|
471
|
+
if (this.#opts.videoFullscreen) {
|
|
472
|
+
el.addEventListener("click", () => {
|
|
473
|
+
const v = el.querySelector("video")!;
|
|
474
|
+
if (document.fullscreenElement) { document.exitFullscreen(); }
|
|
475
|
+
else { v.play(); v.requestFullscreen(); }
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (item.onClick || this.#opts.onTagClick) {
|
|
480
|
+
el.addEventListener("click", (e) => {
|
|
481
|
+
e.stopPropagation();
|
|
482
|
+
if (item.onClick) item.onClick();
|
|
483
|
+
if (this.#opts.onTagClick) this.#opts.onTagClick(item);
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
return el;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
#resizeCanvas(): void {
|
|
490
|
+
const c = this.#canvas;
|
|
491
|
+
if (!c) return;
|
|
492
|
+
const dpr = window.devicePixelRatio || 1;
|
|
493
|
+
const { width, height } = c.getBoundingClientRect();
|
|
494
|
+
c.width = width * dpr;
|
|
495
|
+
c.height = height * dpr;
|
|
496
|
+
this.#ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
497
|
+
}
|
|
498
|
+
|
|
311
499
|
#loop = (): void => {
|
|
312
500
|
if (!this.#paused) this.#tick();
|
|
313
501
|
this.#raf = requestAnimationFrame(this.#loop);
|
|
314
502
|
};
|
|
315
503
|
|
|
316
|
-
/** 绕 Y 轴旋转 */
|
|
317
|
-
/** rotate around Y axis */
|
|
318
504
|
#rotateY(deg: number): void {
|
|
319
505
|
const half = (deg * Math.PI) / 360;
|
|
320
506
|
const qY = { w: Math.cos(half), x: 0, y: Math.sin(half), z: 0 };
|
|
@@ -327,8 +513,6 @@ export class TagCloud {
|
|
|
327
513
|
};
|
|
328
514
|
}
|
|
329
515
|
|
|
330
|
-
/** 绕 X 轴旋转 */
|
|
331
|
-
/** rotate around X axis */
|
|
332
516
|
#rotateX(deg: number): void {
|
|
333
517
|
const half = (deg * Math.PI) / 360;
|
|
334
518
|
const qX = { w: Math.cos(half), x: Math.sin(half), y: 0, z: 0 };
|
|
@@ -347,7 +531,6 @@ export class TagCloud {
|
|
|
347
531
|
const cy = rect.height / 2;
|
|
348
532
|
|
|
349
533
|
// 自旋 + 惯性
|
|
350
|
-
// auto-spin + inertia
|
|
351
534
|
const revY = this.#opts.reverse || this.#opts.reverseY ? -1 : 1;
|
|
352
535
|
const revX = this.#opts.reverse || this.#opts.reverseX ? -1 : 1;
|
|
353
536
|
const decay = this.#opts.inertiaDecay;
|
|
@@ -358,8 +541,7 @@ export class TagCloud {
|
|
|
358
541
|
this.#velX *= decay;
|
|
359
542
|
}
|
|
360
543
|
|
|
361
|
-
//
|
|
362
|
-
// build 3×3 rotation matrix from quaternion
|
|
544
|
+
// 四元数构造旋转矩阵
|
|
363
545
|
const { w, x, y, z } = this.#qNow;
|
|
364
546
|
const m00 = 1 - 2 * (y * y + z * z);
|
|
365
547
|
const m01 = 2 * (x * y - w * z);
|
|
@@ -374,8 +556,6 @@ export class TagCloud {
|
|
|
374
556
|
const projected: TagData[] = [];
|
|
375
557
|
|
|
376
558
|
for (const p of this.#points) {
|
|
377
|
-
// 矩阵 × 点
|
|
378
|
-
// matrix × point
|
|
379
559
|
const rx = m00 * p.x + m01 * p.y + m02 * p.z;
|
|
380
560
|
const ry = m10 * p.x + m11 * p.y + m12 * p.z;
|
|
381
561
|
const rz = m20 * p.x + m21 * p.y + (1 - 2 * (x * x + y * y)) * p.z;
|
|
@@ -384,7 +564,7 @@ export class TagCloud {
|
|
|
384
564
|
const alpha = Math.min(1, Math.max(0, per * per - 0.25));
|
|
385
565
|
|
|
386
566
|
projected.push({
|
|
387
|
-
|
|
567
|
+
item: p.item,
|
|
388
568
|
x: cx + rx * per,
|
|
389
569
|
y: cy + ry * per,
|
|
390
570
|
z: rz,
|
package/src/index.ts
CHANGED
|
@@ -25,7 +25,16 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
export { TagCloud } from "./TagCloud";
|
|
28
|
-
export type {
|
|
28
|
+
export type {
|
|
29
|
+
TagCloudOptions,
|
|
30
|
+
TagData,
|
|
31
|
+
TagItem,
|
|
32
|
+
ImageTag,
|
|
33
|
+
SvgTag,
|
|
34
|
+
HtmlTag,
|
|
35
|
+
VideoTag,
|
|
36
|
+
ElementTag,
|
|
37
|
+
} from "./TagCloud";
|
|
29
38
|
export { fibonacciSphere } from "./core/distribution";
|
|
30
39
|
export type { Vec3 } from "./core/distribution";
|
|
31
40
|
export { rotatePoint, rotatePoints } from "./core/rotation";
|