@xingwangzhe/tags-cloud 0.5.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/LICENSE +21 -0
- package/README.md +78 -0
- package/dist/index.js +239 -0
- package/dist/index.js.map +1 -0
- package/package.json +31 -0
- package/src/TagCloud.ts +400 -0
- package/src/core/distribution.ts +37 -0
- package/src/core/projection.ts +56 -0
- package/src/core/rotation.ts +59 -0
- package/src/index.ts +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 王兴家
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @xingwangzhe/tags-cloud
|
|
2
|
+
|
|
3
|
+
[中文文档](README_CN.md) | [English](#)
|
|
4
|
+
|
|
5
|
+
> Pure math 3D tag cloud engine
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Zero DOM overhead** — pure math output, render via callback
|
|
10
|
+
- **Fibonacci sphere distribution** — tags evenly placed on 3D sphere
|
|
11
|
+
- **Arcball interaction** — drag to rotate with quaternion-based Shoemake arcball
|
|
12
|
+
- **Auto-spin** — configurable per-axis spin (X/Y) with independent speed and direction
|
|
13
|
+
- **TypeScript** — fully typed
|
|
14
|
+
- **~2.3KB** gzipped
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bun add @xingwangzhe/tags-cloud
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { TagCloud } from "@xingwangzhe/tags-cloud";
|
|
26
|
+
|
|
27
|
+
// Zero config — auto-creates Canvas and renders text
|
|
28
|
+
new TagCloud(document.getElementById("cloud"), {
|
|
29
|
+
tags: ["TypeScript", "Canvas", "3D"],
|
|
30
|
+
radius: 300,
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## API
|
|
35
|
+
|
|
36
|
+
### `new TagCloud(container, options)`
|
|
37
|
+
|
|
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 |
|
|
53
|
+
|
|
54
|
+
### Instance Methods
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
cloud.setTags(["new", "tags"]); // Update tags
|
|
58
|
+
cloud.pause(); // Pause
|
|
59
|
+
cloud.resume(); // Resume
|
|
60
|
+
cloud.destroy(); // Destroy (cleanup events + rAF)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
bun install
|
|
67
|
+
bun run build # vite build
|
|
68
|
+
bun run lint # oxlint
|
|
69
|
+
bun run fmt # oxfmt
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Credits
|
|
73
|
+
|
|
74
|
+
Core algorithm ported from [cong-min/TagCloud](https://github.com/cong-min/TagCloud)
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
//#region src/core/distribution.ts
|
|
2
|
+
function e(e, t) {
|
|
3
|
+
let n = [];
|
|
4
|
+
for (let r = 0; r < e; r++) {
|
|
5
|
+
let i = Math.acos(-1 + (2 * r + 1) / e), a = Math.sqrt(e * Math.PI) * i;
|
|
6
|
+
n.push({
|
|
7
|
+
x: t * Math.cos(a) * Math.sin(i),
|
|
8
|
+
y: t * Math.sin(a) * Math.sin(i),
|
|
9
|
+
z: t * Math.cos(i)
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
return n;
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/TagCloud.ts
|
|
16
|
+
var t = {
|
|
17
|
+
radius: 300,
|
|
18
|
+
spinY: 0,
|
|
19
|
+
spinX: 0,
|
|
20
|
+
reverse: !1,
|
|
21
|
+
reverseX: !1,
|
|
22
|
+
reverseY: !1,
|
|
23
|
+
inertiaDecay: .96,
|
|
24
|
+
dragSensitivity: 3,
|
|
25
|
+
fontFamily: "system-ui, sans-serif",
|
|
26
|
+
fontSize: 14,
|
|
27
|
+
color: "#ffffff"
|
|
28
|
+
}, n = class {
|
|
29
|
+
#e;
|
|
30
|
+
#t = [];
|
|
31
|
+
#n;
|
|
32
|
+
#r;
|
|
33
|
+
#i = {
|
|
34
|
+
w: 1,
|
|
35
|
+
x: 0,
|
|
36
|
+
y: 0,
|
|
37
|
+
z: 0
|
|
38
|
+
};
|
|
39
|
+
#a = {
|
|
40
|
+
w: 1,
|
|
41
|
+
x: 0,
|
|
42
|
+
y: 0,
|
|
43
|
+
z: 0
|
|
44
|
+
};
|
|
45
|
+
#o = 0;
|
|
46
|
+
#s = 0;
|
|
47
|
+
#c = !1;
|
|
48
|
+
#l = !1;
|
|
49
|
+
#u = {
|
|
50
|
+
x: 0,
|
|
51
|
+
y: 0,
|
|
52
|
+
z: 0
|
|
53
|
+
};
|
|
54
|
+
#d = 0;
|
|
55
|
+
#f;
|
|
56
|
+
#p;
|
|
57
|
+
#m;
|
|
58
|
+
#h;
|
|
59
|
+
constructor(e, n) {
|
|
60
|
+
this.#f = e, this.#e = {
|
|
61
|
+
...t,
|
|
62
|
+
...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);
|
|
80
|
+
}
|
|
81
|
+
setTags(e) {
|
|
82
|
+
this.#v(e);
|
|
83
|
+
}
|
|
84
|
+
pause() {
|
|
85
|
+
this.#c = !0;
|
|
86
|
+
}
|
|
87
|
+
resume() {
|
|
88
|
+
this.#c = !1;
|
|
89
|
+
}
|
|
90
|
+
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();
|
|
94
|
+
}
|
|
95
|
+
#v(t) {
|
|
96
|
+
let n = 1.5 * this.#n, r = e(t.length, n / 2);
|
|
97
|
+
this.#t = r.map((e, n) => ({
|
|
98
|
+
...e,
|
|
99
|
+
text: t[n]
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
#y() {
|
|
103
|
+
this.#f.style.cursor = "grab";
|
|
104
|
+
let e = () => this.#f.getBoundingClientRect();
|
|
105
|
+
this.#p = {
|
|
106
|
+
down: ((t) => {
|
|
107
|
+
this.#l = !0, this.#f.style.cursor = "grabbing", this.#a = { ...this.#i };
|
|
108
|
+
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;
|
|
110
|
+
}),
|
|
111
|
+
move: ((t) => {
|
|
112
|
+
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 = {
|
|
114
|
+
w: 1 + a,
|
|
115
|
+
x: (i.y * r.z - i.z * r.y) * o,
|
|
116
|
+
y: (i.x * r.z - i.z * r.x) * s,
|
|
117
|
+
z: (i.x * r.y - i.y * r.x) * o * s
|
|
118
|
+
}, l = Math.sqrt(c.w ** 2 + c.x ** 2 + c.y ** 2 + c.z ** 2);
|
|
119
|
+
c.w /= l, c.x /= l, c.y /= l, c.z /= l;
|
|
120
|
+
let u = this.#a;
|
|
121
|
+
this.#i = {
|
|
122
|
+
w: c.w * u.w - c.x * u.x - c.y * u.y - c.z * u.z,
|
|
123
|
+
x: c.w * u.x + c.x * u.w + c.y * u.z - c.z * u.y,
|
|
124
|
+
y: c.w * u.y - c.x * u.z + c.y * u.w + c.z * u.x,
|
|
125
|
+
z: c.w * u.z + c.x * u.y - c.y * u.x + c.z * u.w
|
|
126
|
+
};
|
|
127
|
+
let d = this.#e.dragSensitivity;
|
|
128
|
+
this.#o = c.y / l * d, this.#s = c.x / l * d;
|
|
129
|
+
}),
|
|
130
|
+
up: () => {
|
|
131
|
+
this.#l = !1, this.#f.style.cursor = "grab";
|
|
132
|
+
}
|
|
133
|
+
}, this.#f.addEventListener("pointerdown", this.#p.down), window.addEventListener("pointermove", this.#p.move), window.addEventListener("pointerup", this.#p.up);
|
|
134
|
+
}
|
|
135
|
+
#b(e, t, n, r) {
|
|
136
|
+
let i = e / n * 2 - 1, a = -(t / r * 2 - 1), o = i * i + a * a;
|
|
137
|
+
if (o > 1) {
|
|
138
|
+
let e = 1 / Math.sqrt(o);
|
|
139
|
+
return {
|
|
140
|
+
x: i * e,
|
|
141
|
+
y: a * e,
|
|
142
|
+
z: 0
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
x: i,
|
|
147
|
+
y: a,
|
|
148
|
+
z: Math.sqrt(1 - o)
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
#x = () => {
|
|
152
|
+
this.#c || this.#w(), this.#d = requestAnimationFrame(this.#x);
|
|
153
|
+
};
|
|
154
|
+
#S(e) {
|
|
155
|
+
let t = e * Math.PI / 360, n = {
|
|
156
|
+
w: Math.cos(t),
|
|
157
|
+
x: 0,
|
|
158
|
+
y: Math.sin(t),
|
|
159
|
+
z: 0
|
|
160
|
+
}, r = this.#i;
|
|
161
|
+
this.#i = {
|
|
162
|
+
w: n.w * r.w - n.y * r.y,
|
|
163
|
+
x: n.w * r.x + n.y * r.z,
|
|
164
|
+
y: n.w * r.y + n.y * r.w,
|
|
165
|
+
z: n.w * r.z - n.y * r.x
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
#C(e) {
|
|
169
|
+
let t = e * Math.PI / 360, n = {
|
|
170
|
+
w: Math.cos(t),
|
|
171
|
+
x: Math.sin(t),
|
|
172
|
+
y: 0,
|
|
173
|
+
z: 0
|
|
174
|
+
}, r = this.#i;
|
|
175
|
+
this.#i = {
|
|
176
|
+
w: n.w * r.w - n.x * r.x,
|
|
177
|
+
x: n.w * r.x + n.x * r.w,
|
|
178
|
+
y: n.w * r.y - n.x * r.z,
|
|
179
|
+
z: n.w * r.z + n.x * r.y
|
|
180
|
+
};
|
|
181
|
+
}
|
|
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);
|
|
185
|
+
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
|
+
for (let e of this.#t) {
|
|
187
|
+
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
|
+
y.push({
|
|
189
|
+
text: e.text,
|
|
190
|
+
x: t + r * o,
|
|
191
|
+
y: n + i * o,
|
|
192
|
+
z: a,
|
|
193
|
+
scale: o,
|
|
194
|
+
alpha: l
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
this.#e.onRender(y.sort((e, t) => t.z - e.z));
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/core/rotation.ts
|
|
202
|
+
function r(e, t, n) {
|
|
203
|
+
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
|
+
return {
|
|
205
|
+
x: e.x * o + c * a,
|
|
206
|
+
y: s,
|
|
207
|
+
z: c * o - e.x * a
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function i(e, t, n) {
|
|
211
|
+
let r = Math.sin(t), i = Math.cos(t), a = Math.sin(n), o = Math.cos(n);
|
|
212
|
+
return e.map((e) => {
|
|
213
|
+
let t = e.y * i + e.z * -r, n = e.y * r + e.z * i;
|
|
214
|
+
return {
|
|
215
|
+
x: e.x * o + n * a,
|
|
216
|
+
y: t,
|
|
217
|
+
z: n * o - e.x * a
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
//#endregion
|
|
222
|
+
//#region src/core/projection.ts
|
|
223
|
+
function a(e, t) {
|
|
224
|
+
let n = 2 * t;
|
|
225
|
+
return e.map((e) => {
|
|
226
|
+
let t = n / (n + e.z), r = Math.min(1, Math.max(0, t * t - .25));
|
|
227
|
+
return {
|
|
228
|
+
x: e.x,
|
|
229
|
+
y: e.y,
|
|
230
|
+
z: e.z,
|
|
231
|
+
scale: t,
|
|
232
|
+
alpha: r
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
//#endregion
|
|
237
|
+
export { n as TagCloud, e as fibonacciSphere, a as project, r as rotatePoint, i as rotatePoints };
|
|
238
|
+
|
|
239
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xingwangzhe/tags-cloud",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Canvas-driven 3D tag cloud engine",
|
|
5
|
+
"keywords": ["3d", "canvas", "sphere", "tag-cloud", "tags"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"files": ["dist", "src/core", "src/index.ts", "src/TagCloud.ts"],
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"module": "./dist/index.js",
|
|
11
|
+
"types": "./src/index.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./src/index.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"dev": "vite",
|
|
20
|
+
"build": "vite build",
|
|
21
|
+
"fmt": "oxfmt",
|
|
22
|
+
"fmt:check": "oxfmt --check",
|
|
23
|
+
"lint": "oxlint",
|
|
24
|
+
"prepublishOnly": "bun run build"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"oxfmt": "^0.54.0",
|
|
28
|
+
"oxlint": "^1.69.0",
|
|
29
|
+
"vite": "^8.0.16"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/TagCloud.ts
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 3D 标签云 — 纯数学引擎
|
|
3
|
+
* 3D Tag Cloud — Pure Math Engine
|
|
4
|
+
*
|
|
5
|
+
* 零 DOM 渲染,每帧通过 onRender 回调输出投影坐标
|
|
6
|
+
* Zero DOM rendering, outputs projected coords via onRender callback each frame
|
|
7
|
+
*
|
|
8
|
+
* 基于 cong-min/TagCloud 算法
|
|
9
|
+
* Based on cong-min/TagCloud algorithm
|
|
10
|
+
*/
|
|
11
|
+
import { fibonacciSphere } from "./core/distribution";
|
|
12
|
+
|
|
13
|
+
// ── 类型
|
|
14
|
+
// ── Types
|
|
15
|
+
|
|
16
|
+
/** 投影后的标签数据 */
|
|
17
|
+
/** Projected tag data */
|
|
18
|
+
export interface TagData {
|
|
19
|
+
text: string;
|
|
20
|
+
/** 容器内 X 坐标(像素) */
|
|
21
|
+
/** X coordinate in container (px) */
|
|
22
|
+
x: number;
|
|
23
|
+
/** 容器内 Y 坐标(像素) */
|
|
24
|
+
/** Y coordinate in container (px) */
|
|
25
|
+
y: number;
|
|
26
|
+
/** Z 深度(-radius ~ +radius) */
|
|
27
|
+
/** Z depth */
|
|
28
|
+
z: number;
|
|
29
|
+
/** 缩放比例 (0 ~ 1+) */
|
|
30
|
+
/** scale factor */
|
|
31
|
+
scale: number;
|
|
32
|
+
/** 透明度 (0 ~ 1) */
|
|
33
|
+
/** opacity */
|
|
34
|
+
alpha: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TagCloudOptions {
|
|
38
|
+
/** 标签文本数组 */
|
|
39
|
+
/** tag text array */
|
|
40
|
+
tags: string[];
|
|
41
|
+
/** 球面半径(px) */
|
|
42
|
+
/** sphere radius (px) (default 300) */
|
|
43
|
+
radius?: number;
|
|
44
|
+
/** 绕 Y 轴自旋速度(°/帧): +右转 -左转 0=关 */
|
|
45
|
+
/** Y-axis auto-spin speed (°/frame): +right -left 0=off (default 0) */
|
|
46
|
+
spinY?: number;
|
|
47
|
+
/** 绕 X 轴自旋速度(°/帧): +下转 -上转 0=关 */
|
|
48
|
+
/** X-axis auto-spin speed (°/frame): +down -up 0=off (default 0) */
|
|
49
|
+
spinX?: number;
|
|
50
|
+
/** 反转方向(X+Y 同时) */
|
|
51
|
+
/** reverse both axes (default false) */
|
|
52
|
+
reverse?: boolean;
|
|
53
|
+
/** 单独反转 X 轴(上下拖拽) */
|
|
54
|
+
/** reverse X-axis drag only (default false) */
|
|
55
|
+
reverseX?: boolean;
|
|
56
|
+
/** 反转 Y 轴拖拽方向 */
|
|
57
|
+
/** reverse Y-axis drag direction (default false) */
|
|
58
|
+
reverseY?: boolean;
|
|
59
|
+
/** 惯性衰减系数(每帧乘以此值) */
|
|
60
|
+
/** inertia decay per frame (default 0.96) */
|
|
61
|
+
inertiaDecay?: number;
|
|
62
|
+
/** 拖拽灵敏度(松手后惯性速度倍率) */
|
|
63
|
+
/** drag sensitivity for release velocity (default 3) */
|
|
64
|
+
dragSensitivity?: number;
|
|
65
|
+
/** 字体 */
|
|
66
|
+
/** font family (default "system-ui, sans-serif") */
|
|
67
|
+
fontFamily?: string;
|
|
68
|
+
/** 基础字号(px) */
|
|
69
|
+
/** base font size in px (default 14) */
|
|
70
|
+
fontSize?: number;
|
|
71
|
+
/** 文字颜色 */
|
|
72
|
+
/** text color (default "#ffffff") */
|
|
73
|
+
color?: string;
|
|
74
|
+
/** 自定义渲染回调(如不提供则用内置 Canvas) */
|
|
75
|
+
/** custom render callback (built-in Canvas if omitted) */
|
|
76
|
+
onRender?: (tags: TagData[]) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── 内部类型
|
|
80
|
+
// ── Internal Types
|
|
81
|
+
|
|
82
|
+
interface SpherePoint {
|
|
83
|
+
x: number;
|
|
84
|
+
y: number;
|
|
85
|
+
z: number;
|
|
86
|
+
text: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type ResolvedOptions = TagCloudOptions & Required<Omit<TagCloudOptions, "onRender">>;
|
|
90
|
+
|
|
91
|
+
const DEFAULTS: Omit<ResolvedOptions, "tags" | "onRender"> = {
|
|
92
|
+
radius: 300,
|
|
93
|
+
spinY: 0,
|
|
94
|
+
spinX: 0,
|
|
95
|
+
reverse: false,
|
|
96
|
+
reverseX: false,
|
|
97
|
+
reverseY: false,
|
|
98
|
+
inertiaDecay: 0.96,
|
|
99
|
+
dragSensitivity: 3,
|
|
100
|
+
fontFamily: "system-ui, sans-serif",
|
|
101
|
+
fontSize: 14,
|
|
102
|
+
color: "#ffffff",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// ── 主类
|
|
106
|
+
// ── Main Class
|
|
107
|
+
|
|
108
|
+
export class TagCloud {
|
|
109
|
+
#opts: ResolvedOptions;
|
|
110
|
+
#points: SpherePoint[] = [];
|
|
111
|
+
#radius: number;
|
|
112
|
+
#depth: number;
|
|
113
|
+
|
|
114
|
+
// 旋转状态 — 四元数
|
|
115
|
+
// rotation state as quaternion
|
|
116
|
+
#qNow = { w: 1, x: 0, y: 0, z: 0 };
|
|
117
|
+
#qDown = { w: 1, x: 0, y: 0, z: 0 };
|
|
118
|
+
#velY = 0;
|
|
119
|
+
#velX = 0;
|
|
120
|
+
#paused = false;
|
|
121
|
+
|
|
122
|
+
// 拖拽状态
|
|
123
|
+
// arcball drag state
|
|
124
|
+
#dragging = false;
|
|
125
|
+
#vDown = { x: 0, y: 0, z: 0 };
|
|
126
|
+
|
|
127
|
+
// 动画
|
|
128
|
+
// animation
|
|
129
|
+
#raf = 0;
|
|
130
|
+
#container: HTMLElement;
|
|
131
|
+
#handlers!: { down: EventListener; move: EventListener; up: EventListener };
|
|
132
|
+
|
|
133
|
+
// 内置 Canvas(仅当 onRender 未提供时创建)
|
|
134
|
+
// built-in Canvas (only when onRender is not provided)
|
|
135
|
+
#canvas?: HTMLCanvasElement;
|
|
136
|
+
#ctx?: CanvasRenderingContext2D;
|
|
137
|
+
|
|
138
|
+
constructor(container: HTMLElement, options: TagCloudOptions) {
|
|
139
|
+
this.#container = container;
|
|
140
|
+
this.#opts = { ...DEFAULTS, ...options } as ResolvedOptions;
|
|
141
|
+
this.#radius = this.#opts.radius;
|
|
142
|
+
this.#depth = 2 * this.#radius;
|
|
143
|
+
|
|
144
|
+
// 内置 Canvas 渲染器
|
|
145
|
+
// built-in Canvas renderer
|
|
146
|
+
if (!this.#opts.onRender) {
|
|
147
|
+
this.#opts.onRender = this.#canvasRender;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this.#initTags(this.#opts.tags);
|
|
151
|
+
this.#bindEvents();
|
|
152
|
+
this.#loop();
|
|
153
|
+
}
|
|
154
|
+
|
|
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
|
+
// ── 公开 API
|
|
194
|
+
// ── Public API
|
|
195
|
+
|
|
196
|
+
setTags(tags: string[]): void {
|
|
197
|
+
this.#initTags(tags);
|
|
198
|
+
}
|
|
199
|
+
pause(): void {
|
|
200
|
+
this.#paused = true;
|
|
201
|
+
}
|
|
202
|
+
resume(): void {
|
|
203
|
+
this.#paused = false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
destroy(): void {
|
|
207
|
+
cancelAnimationFrame(this.#raf);
|
|
208
|
+
const h = this.#handlers;
|
|
209
|
+
this.#container.removeEventListener("pointerdown", h.down);
|
|
210
|
+
window.removeEventListener("pointermove", h.move);
|
|
211
|
+
window.removeEventListener("pointerup", h.up);
|
|
212
|
+
if (this.#canvas) this.#canvas.remove();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── 内部方法
|
|
216
|
+
// ── Internal
|
|
217
|
+
|
|
218
|
+
#initTags(tags: string[]): void {
|
|
219
|
+
const size = 1.5 * this.#radius;
|
|
220
|
+
const positions = fibonacciSphere(tags.length, size / 2);
|
|
221
|
+
this.#points = positions.map((p, i) => ({ ...p, text: tags[i]! }));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#bindEvents(): void {
|
|
225
|
+
this.#container.style.cursor = "grab";
|
|
226
|
+
|
|
227
|
+
const rect = () => this.#container.getBoundingClientRect();
|
|
228
|
+
|
|
229
|
+
this.#handlers = {
|
|
230
|
+
down: ((e: PointerEvent) => {
|
|
231
|
+
this.#dragging = true;
|
|
232
|
+
this.#container.style.cursor = "grabbing";
|
|
233
|
+
this.#qDown = { ...this.#qNow };
|
|
234
|
+
const r = rect();
|
|
235
|
+
this.#vDown = this.#screenToSphere(
|
|
236
|
+
e.clientX - r.left,
|
|
237
|
+
e.clientY - r.top,
|
|
238
|
+
r.width,
|
|
239
|
+
r.height,
|
|
240
|
+
);
|
|
241
|
+
this.#velY = 0;
|
|
242
|
+
this.#velX = 0;
|
|
243
|
+
}) as EventListener,
|
|
244
|
+
move: ((e: PointerEvent) => {
|
|
245
|
+
if (!this.#dragging) return;
|
|
246
|
+
const r = rect();
|
|
247
|
+
const vCur = this.#screenToSphere(e.clientX - r.left, e.clientY - r.top, r.width, r.height);
|
|
248
|
+
const vA = this.#vDown;
|
|
249
|
+
const dot = vA.x * vCur.x + vA.y * vCur.y + vA.z * vCur.z;
|
|
250
|
+
// Shoemake arcball 四元数
|
|
251
|
+
// Shoemake arcball quaternion
|
|
252
|
+
const revX = this.#opts.reverse || this.#opts.reverseX ? -1 : 1;
|
|
253
|
+
const revY = this.#opts.reverse || this.#opts.reverseY ? -1 : 1;
|
|
254
|
+
const qDrag = {
|
|
255
|
+
w: 1 + dot,
|
|
256
|
+
x: (vA.y * vCur.z - vA.z * vCur.y) * revX,
|
|
257
|
+
y: (vA.x * vCur.z - vA.z * vCur.x) * revY,
|
|
258
|
+
z: (vA.x * vCur.y - vA.y * vCur.x) * revX * revY,
|
|
259
|
+
};
|
|
260
|
+
const len = Math.sqrt(qDrag.w ** 2 + qDrag.x ** 2 + qDrag.y ** 2 + qDrag.z ** 2);
|
|
261
|
+
qDrag.w /= len;
|
|
262
|
+
qDrag.x /= len;
|
|
263
|
+
qDrag.y /= len;
|
|
264
|
+
qDrag.z /= len;
|
|
265
|
+
// 组合
|
|
266
|
+
// compose
|
|
267
|
+
const qD = this.#qDown;
|
|
268
|
+
this.#qNow = {
|
|
269
|
+
w: qDrag.w * qD.w - qDrag.x * qD.x - qDrag.y * qD.y - qDrag.z * qD.z,
|
|
270
|
+
x: qDrag.w * qD.x + qDrag.x * qD.w + qDrag.y * qD.z - qDrag.z * qD.y,
|
|
271
|
+
y: qDrag.w * qD.y - qDrag.x * qD.z + qDrag.y * qD.w + qDrag.z * qD.x,
|
|
272
|
+
z: qDrag.w * qD.z + qDrag.x * qD.y - qDrag.y * qD.x + qDrag.z * qD.w,
|
|
273
|
+
};
|
|
274
|
+
// 拖拽速度
|
|
275
|
+
// drag velocity
|
|
276
|
+
const sens = this.#opts.dragSensitivity;
|
|
277
|
+
this.#velY = (qDrag.y / len) * sens;
|
|
278
|
+
this.#velX = (qDrag.x / len) * sens;
|
|
279
|
+
}) as EventListener,
|
|
280
|
+
up: () => {
|
|
281
|
+
this.#dragging = false;
|
|
282
|
+
this.#container.style.cursor = "grab";
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
this.#container.addEventListener("pointerdown", this.#handlers.down);
|
|
287
|
+
window.addEventListener("pointermove", this.#handlers.move);
|
|
288
|
+
window.addEventListener("pointerup", this.#handlers.up);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 屏幕坐标 → 球面 3D 点 */
|
|
292
|
+
/** screen coords → sphere 3D point */
|
|
293
|
+
#screenToSphere(
|
|
294
|
+
sx: number,
|
|
295
|
+
sy: number,
|
|
296
|
+
w: number,
|
|
297
|
+
h: number,
|
|
298
|
+
): { x: number; y: number; z: number } {
|
|
299
|
+
const x = (sx / w) * 2 - 1;
|
|
300
|
+
const y = -((sy / h) * 2 - 1);
|
|
301
|
+
const r2 = x * x + y * y;
|
|
302
|
+
if (r2 > 1) {
|
|
303
|
+
// 球外 → 投影到球边缘
|
|
304
|
+
// outside sphere → project to edge
|
|
305
|
+
const inv = 1 / Math.sqrt(r2);
|
|
306
|
+
return { x: x * inv, y: y * inv, z: 0 };
|
|
307
|
+
}
|
|
308
|
+
return { x, y, z: Math.sqrt(1 - r2) };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
#loop = (): void => {
|
|
312
|
+
if (!this.#paused) this.#tick();
|
|
313
|
+
this.#raf = requestAnimationFrame(this.#loop);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/** 绕 Y 轴旋转 */
|
|
317
|
+
/** rotate around Y axis */
|
|
318
|
+
#rotateY(deg: number): void {
|
|
319
|
+
const half = (deg * Math.PI) / 360;
|
|
320
|
+
const qY = { w: Math.cos(half), x: 0, y: Math.sin(half), z: 0 };
|
|
321
|
+
const q = this.#qNow;
|
|
322
|
+
this.#qNow = {
|
|
323
|
+
w: qY.w * q.w - qY.y * q.y,
|
|
324
|
+
x: qY.w * q.x + qY.y * q.z,
|
|
325
|
+
y: qY.w * q.y + qY.y * q.w,
|
|
326
|
+
z: qY.w * q.z - qY.y * q.x,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 绕 X 轴旋转 */
|
|
331
|
+
/** rotate around X axis */
|
|
332
|
+
#rotateX(deg: number): void {
|
|
333
|
+
const half = (deg * Math.PI) / 360;
|
|
334
|
+
const qX = { w: Math.cos(half), x: Math.sin(half), y: 0, z: 0 };
|
|
335
|
+
const q = this.#qNow;
|
|
336
|
+
this.#qNow = {
|
|
337
|
+
w: qX.w * q.w - qX.x * q.x,
|
|
338
|
+
x: qX.w * q.x + qX.x * q.w,
|
|
339
|
+
y: qX.w * q.y - qX.x * q.z,
|
|
340
|
+
z: qX.w * q.z + qX.x * q.y,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#tick(): void {
|
|
345
|
+
const rect = this.#container.getBoundingClientRect();
|
|
346
|
+
const cx = rect.width / 2;
|
|
347
|
+
const cy = rect.height / 2;
|
|
348
|
+
|
|
349
|
+
// 自旋 + 惯性
|
|
350
|
+
// auto-spin + inertia
|
|
351
|
+
const revY = this.#opts.reverse || this.#opts.reverseY ? -1 : 1;
|
|
352
|
+
const revX = this.#opts.reverse || this.#opts.reverseX ? -1 : 1;
|
|
353
|
+
const decay = this.#opts.inertiaDecay;
|
|
354
|
+
if (!this.#dragging) {
|
|
355
|
+
this.#rotateY((this.#opts.spinY + this.#velY) * revY);
|
|
356
|
+
this.#rotateX((this.#opts.spinX + this.#velX) * revX);
|
|
357
|
+
this.#velY *= decay;
|
|
358
|
+
this.#velX *= decay;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// 四元数构造 3×3 旋转矩阵
|
|
362
|
+
// build 3×3 rotation matrix from quaternion
|
|
363
|
+
const { w, x, y, z } = this.#qNow;
|
|
364
|
+
const m00 = 1 - 2 * (y * y + z * z);
|
|
365
|
+
const m01 = 2 * (x * y - w * z);
|
|
366
|
+
const m02 = 2 * (x * z + w * y);
|
|
367
|
+
const m10 = 2 * (x * y + w * z);
|
|
368
|
+
const m11 = 1 - 2 * (x * x + z * z);
|
|
369
|
+
const m12 = 2 * (y * z - w * x);
|
|
370
|
+
const m20 = 2 * (x * z - w * y);
|
|
371
|
+
const m21 = 2 * (y * z + w * x);
|
|
372
|
+
|
|
373
|
+
const d2 = this.#depth * 2;
|
|
374
|
+
const projected: TagData[] = [];
|
|
375
|
+
|
|
376
|
+
for (const p of this.#points) {
|
|
377
|
+
// 矩阵 × 点
|
|
378
|
+
// matrix × point
|
|
379
|
+
const rx = m00 * p.x + m01 * p.y + m02 * p.z;
|
|
380
|
+
const ry = m10 * p.x + m11 * p.y + m12 * p.z;
|
|
381
|
+
const rz = m20 * p.x + m21 * p.y + (1 - 2 * (x * x + y * y)) * p.z;
|
|
382
|
+
|
|
383
|
+
const per = d2 / (d2 + rz);
|
|
384
|
+
const alpha = Math.min(1, Math.max(0, per * per - 0.25));
|
|
385
|
+
|
|
386
|
+
projected.push({
|
|
387
|
+
text: p.text,
|
|
388
|
+
x: cx + rx * per,
|
|
389
|
+
y: cy + ry * per,
|
|
390
|
+
z: rz,
|
|
391
|
+
scale: per,
|
|
392
|
+
alpha,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
this.#opts.onRender(projected.sort((a, b) => b.z - a.z));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export default TagCloud;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 斐波那契球面分布
|
|
3
|
+
* Fibonacci sphere distribution
|
|
4
|
+
*
|
|
5
|
+
* 将 N 个点均匀分布在球面上,避免两极聚集
|
|
6
|
+
* Evenly distributes N points on a sphere, avoiding polar clustering
|
|
7
|
+
*
|
|
8
|
+
* ported from TagCloud.js _computePosition
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface Vec3 {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
z: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 生成球面上均匀分布的 N 个点
|
|
19
|
+
* Generate N evenly distributed points on a sphere of radius R
|
|
20
|
+
*/
|
|
21
|
+
export function fibonacciSphere(n: number, R: number): Vec3[] {
|
|
22
|
+
const points: Vec3[] = [];
|
|
23
|
+
for (let i = 0; i < n; i++) {
|
|
24
|
+
// φ = acos(1 - 2(i+0.5)/N) — 纬度均匀分布
|
|
25
|
+
// φ = acos(1 - 2(i+0.5)/N) — uniform latitude
|
|
26
|
+
const phi = Math.acos(-1 + (2 * i + 1) / n);
|
|
27
|
+
// θ = √(Nπ) × φ — 经度黄金比例螺旋
|
|
28
|
+
// θ = √(Nπ) × φ — golden ratio spiral for longitude
|
|
29
|
+
const theta = Math.sqrt(n * Math.PI) * phi;
|
|
30
|
+
points.push({
|
|
31
|
+
x: R * Math.cos(theta) * Math.sin(phi),
|
|
32
|
+
y: R * Math.sin(theta) * Math.sin(phi),
|
|
33
|
+
z: R * Math.cos(phi),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return points;
|
|
37
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 透视投影
|
|
3
|
+
* Perspective projection
|
|
4
|
+
*
|
|
5
|
+
* 根据 Z 深度计算每个标签的缩放比例和透明度
|
|
6
|
+
* Calculates scale and alpha for each tag based on Z depth
|
|
7
|
+
*
|
|
8
|
+
* per = (2 × depth) / (2 × depth + z)
|
|
9
|
+
* scale = per
|
|
10
|
+
* alpha = per² − 0.25 → clamped [0, 1]
|
|
11
|
+
*
|
|
12
|
+
* ported from TagCloud.js _next() projection logic
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface ProjectedTag {
|
|
16
|
+
/** 原始 X 坐标 */
|
|
17
|
+
/** original X coordinate */
|
|
18
|
+
x: number;
|
|
19
|
+
/** 原始 Y 坐标 */
|
|
20
|
+
/** original Y coordinate */
|
|
21
|
+
y: number;
|
|
22
|
+
/** 原始 Z 深度 */
|
|
23
|
+
/** original Z depth */
|
|
24
|
+
z: number;
|
|
25
|
+
/** 缩放比例 (1 = 最近, 0 = 最远) */
|
|
26
|
+
/** scale factor (1 = nearest, 0 = farthest) */
|
|
27
|
+
scale: number;
|
|
28
|
+
/** 透明度 (1 = 最前, 0 = 最后) */
|
|
29
|
+
/** opacity (1 = front, 0 = back) */
|
|
30
|
+
alpha: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 对旋转后的点做透视投影
|
|
35
|
+
* Apply perspective projection to rotated points
|
|
36
|
+
*
|
|
37
|
+
* @param points — 旋转后的 3D 点
|
|
38
|
+
* @param points — rotated 3D points
|
|
39
|
+
* @param depth — 透视深度 = 2 × 球半径
|
|
40
|
+
* @param depth — perspective depth = 2 × sphere radius
|
|
41
|
+
*/
|
|
42
|
+
export function project(
|
|
43
|
+
points: { x: number; y: number; z: number }[],
|
|
44
|
+
depth: number,
|
|
45
|
+
): ProjectedTag[] {
|
|
46
|
+
const d2 = 2 * depth;
|
|
47
|
+
return points.map((p) => {
|
|
48
|
+
// 透视缩放
|
|
49
|
+
// perspective scale
|
|
50
|
+
const per = d2 / (d2 + p.z);
|
|
51
|
+
// 透明度从 per² − 0.25 计算
|
|
52
|
+
// alpha derived from per² − 0.25
|
|
53
|
+
const alpha = Math.min(1, Math.max(0, per * per - 0.25));
|
|
54
|
+
return { x: p.x, y: p.y, z: p.z, scale: per, alpha };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 3D 旋转变换
|
|
3
|
+
* 3D rotation transforms
|
|
4
|
+
*
|
|
5
|
+
* 绕 Y 轴和 X 轴旋转球面上的所有点
|
|
6
|
+
* Rotates all points around Y and X axes
|
|
7
|
+
*
|
|
8
|
+
* ported from TagCloud.js _next() rotation logic
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Vec3 } from "./distribution";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 旋转单个 3D 点
|
|
15
|
+
* Rotate a single 3D point
|
|
16
|
+
*
|
|
17
|
+
* @param p - 待旋转的点
|
|
18
|
+
* @param p - point to rotate
|
|
19
|
+
* @param a - 绕 Y 轴的旋转角(弧度)
|
|
20
|
+
* @param a - Y-axis rotation angle (radians)
|
|
21
|
+
* @param b - 绕 X 轴的旋转角(弧度)
|
|
22
|
+
* @param b - X-axis rotation angle (radians)
|
|
23
|
+
*/
|
|
24
|
+
export function rotatePoint(p: Vec3, a: number, b: number): Vec3 {
|
|
25
|
+
const sinA = Math.sin(a);
|
|
26
|
+
const cosA = Math.cos(a);
|
|
27
|
+
const sinB = Math.sin(b);
|
|
28
|
+
const cosB = Math.cos(b);
|
|
29
|
+
|
|
30
|
+
// Y 轴旋转
|
|
31
|
+
// Y-axis rotation
|
|
32
|
+
const y1 = p.y * cosA + p.z * -sinA;
|
|
33
|
+
const z1 = p.y * sinA + p.z * cosA;
|
|
34
|
+
|
|
35
|
+
// X 轴旋转
|
|
36
|
+
// X-axis rotation
|
|
37
|
+
const x2 = p.x * cosB + z1 * sinB;
|
|
38
|
+
const z2 = z1 * cosB - p.x * sinB;
|
|
39
|
+
|
|
40
|
+
return { x: x2, y: y1, z: z2 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 批量旋转所有点
|
|
45
|
+
* Rotate all points in batch
|
|
46
|
+
*/
|
|
47
|
+
export function rotatePoints(points: Vec3[], a: number, b: number): Vec3[] {
|
|
48
|
+
const sinA = Math.sin(a);
|
|
49
|
+
const cosA = Math.cos(a);
|
|
50
|
+
const sinB = Math.sin(b);
|
|
51
|
+
const cosB = Math.cos(b);
|
|
52
|
+
|
|
53
|
+
return points.map((p) => {
|
|
54
|
+
const y1 = p.y * cosA + p.z * -sinA;
|
|
55
|
+
const z1 = p.y * sinA + p.z * cosA;
|
|
56
|
+
const x2 = p.x * cosB + z1 * sinB;
|
|
57
|
+
return { x: x2, y: y1, z: z1 * cosB - p.x * sinB };
|
|
58
|
+
});
|
|
59
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @xingwangzhe/tags-cloud
|
|
3
|
+
*
|
|
4
|
+
* 纯数学 3D 标签云引擎
|
|
5
|
+
* Pure math 3D tag cloud engine
|
|
6
|
+
*
|
|
7
|
+
* 用法
|
|
8
|
+
* Usage:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { TagCloud } from "@xingwangzhe/tags-cloud";
|
|
12
|
+
*
|
|
13
|
+
* const cloud = new TagCloud(container, {
|
|
14
|
+
* tags: ["TS", "Canvas", "3D"],
|
|
15
|
+
* radius: 300,
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* 底层数学模块
|
|
20
|
+
* Low-level math modules:
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* import { fibonacciSphere, rotatePoints, project } from "@xingwangzhe/tags-cloud";
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export { TagCloud } from "./TagCloud";
|
|
28
|
+
export type { TagCloudOptions, TagData } from "./TagCloud";
|
|
29
|
+
export { fibonacciSphere } from "./core/distribution";
|
|
30
|
+
export type { Vec3 } from "./core/distribution";
|
|
31
|
+
export { rotatePoint, rotatePoints } from "./core/rotation";
|
|
32
|
+
export { project } from "./core/projection";
|
|
33
|
+
export type { ProjectedTag } from "./core/projection";
|