@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 CHANGED
@@ -6,12 +6,12 @@
6
6
 
7
7
  ## Features
8
8
 
9
- - **Zero DOM overhead** — pure math output, render via callback
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
- - **~2.3KB** gzipped
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: ["TypeScript", "Canvas", "3D"],
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 | Type | Default | Description |
39
- | ----------------- | ---------------- | --------------- | ------------------------------- |
40
- | `tags` | `string[]` | — | Tag text list |
41
- | `radius` | `number` | `300` | Sphere radius (px) |
42
- | `spinY` | `number` | `0` | Y-axis spin: +right -left 0=off |
43
- | `spinX` | `number` | `0` | X-axis spin: +down -up 0=off |
44
- | `reverse` | `boolean` | `false` | Reverse both drag axes |
45
- | `reverseX` | `boolean` | `false` | Reverse X-axis drag only |
46
- | `reverseY` | `boolean` | `false` | Reverse Y-axis drag only |
47
- | `inertiaDecay` | `number` | `0.96` | Inertia decay per frame |
48
- | `dragSensitivity` | `number` | `3` | Drag sensitivity multiplier |
49
- | `fontFamily` | `string` | `system-ui` | Font family |
50
- | `fontSize` | `number` | `14` | Base font size (px) |
51
- | `color` | `string` | `#fff` | Text color |
52
- | `onRender` | `(tags) => void` | built-in Canvas | Custom render callback |
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"]); // Update tags
58
- cloud.pause(); // Pause
59
- cloud.resume(); // Resume
60
- cloud.destroy(); // Destroy (cleanup events + rAF)
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 # vite build
68
- bun run lint # oxlint
69
- bun run fmt # oxfmt
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
- Core algorithm ported from [cong-min/TagCloud](https://github.com/cong-min/TagCloud)
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
- }, n = class {
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
- #d = 0;
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.#f = e, this.#e = {
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.#g), this.#v(this.#e.tags), this.#y(), this.#x();
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.#v(e);
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.#d);
92
- let e = this.#p;
93
- this.#f.removeEventListener("pointerdown", e.down), window.removeEventListener("pointermove", e.move), window.removeEventListener("pointerup", e.up), this.#m && this.#m.remove();
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
- #v(t) {
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
- text: t[n]
94
+ item: t[n]
100
95
  }));
101
96
  }
102
- #y() {
103
- this.#f.style.cursor = "grab";
104
- let e = () => this.#f.getBoundingClientRect();
105
- this.#p = {
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.#f.style.cursor = "grabbing", this.#a = { ...this.#i };
102
+ this.#l = !0, this.#p.style.cursor = "grabbing", this.#a = { ...this.#i };
108
103
  let n = e();
109
- this.#u = this.#b(t.clientX - n.left, t.clientY - n.top, n.width, n.height), this.#o = 0, this.#s = 0;
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
- let n = e(), r = this.#b(t.clientX - n.left, t.clientY - n.top, n.width, n.height), i = this.#u, 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 = {
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.#f.style.cursor = "grab";
127
+ this.#l = !1, this.#p.style.cursor = "grab", setTimeout(() => {
128
+ this.#u = !1;
129
+ }, 0);
132
130
  }
133
- }, this.#f.addEventListener("pointerdown", this.#p.down), window.addEventListener("pointermove", this.#p.move), window.addEventListener("pointerup", this.#p.up);
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
- #b(e, t, n, r) {
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
- #x = () => {
152
- this.#c || this.#w(), this.#d = requestAnimationFrame(this.#x);
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
- #S(e) {
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
- #C(e) {
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
- #w() {
183
- let e = this.#f.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;
184
- this.#l || (this.#S((this.#e.spinY + this.#o) * r), this.#C((this.#e.spinX + this.#s) * i), this.#o *= a, this.#s *= a);
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
- text: e.text,
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 r(e, t, n) {
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 i(e, t, n) {
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 a(e, t) {
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 { n as TagCloud, e as fibonacciSphere, a as project, r as rotatePoint, i as rotatePoints };
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.5.0",
3
+ "version": "0.9.0",
4
4
  "description": "Canvas-driven 3D tag cloud engine",
5
- "keywords": ["3d", "canvas", "sphere", "tag-cloud", "tags"],
5
+ "keywords": [
6
+ "3d",
7
+ "canvas",
8
+ "sphere",
9
+ "tag-cloud",
10
+ "tags"
11
+ ],
6
12
  "license": "MIT",
7
- "files": ["dist", "src/core", "src/index.ts", "src/TagCloud.ts"],
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
- text: string;
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 array */
40
- tags: string[];
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
- text: string;
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(仅当 onRender 未提供时创建)
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: string[]): void {
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: string[]): void {
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, text: tags[i]! }));
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
- // 四元数构造 3×3 旋转矩阵
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
- text: p.text,
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 { TagCloudOptions, TagData } from "./TagCloud";
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";