glyphdust 0.2.0 → 0.3.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/CHANGELOG.md +64 -0
- package/README.md +82 -6
- package/dist/index.cjs +169 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +222 -144
- package/dist/index.d.ts +222 -144
- package/dist/index.js +169 -29
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to **glyphdust** are documented here.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/), and the
|
|
5
|
+
project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.3.0] — 2026-06-28
|
|
8
|
+
|
|
9
|
+
Non-breaking feature release. Defaults reproduce 0.2.1 exactly.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Mixed fonts in one glyph (`segments`)** — a `TextKeyframe` can now carry a
|
|
14
|
+
`segments: { text, font? }[]` array. Each run is stamped with its own font and
|
|
15
|
+
flows inline (a `\n` inside any run breaks the line; the next run continues on the
|
|
16
|
+
new line), so a single particle glyph can blend, e.g., a bold serif word with a
|
|
17
|
+
light sans one. `text` stays the accessible/`resolveToDom` string; per-run `font`
|
|
18
|
+
falls back to the keyframe `font`. Works on the normal and `dense` sampling paths;
|
|
19
|
+
ignored under `domSelector` (the DOM provides layout). Defaults unchanged —
|
|
20
|
+
omitting `segments` reproduces prior behavior exactly.
|
|
21
|
+
_Why: particles only ride "ink", so the stamp was never font-bound — the limit was
|
|
22
|
+
the API exposing one font. (提案者: 凜さん)_
|
|
23
|
+
|
|
24
|
+
## [0.2.1] — 2026-06-26
|
|
25
|
+
|
|
26
|
+
Flexibility & polish release. Glyphdust is no longer scroll-and-hero only — it now
|
|
27
|
+
drops into any box, plays without scroll, and ships tasteful presets you can override.
|
|
28
|
+
**Defaults reproduce 0.2.0 exactly**, so upgrading is non-breaking.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **`autoplay` driver** — time-based progress with no scroll choreography. Fits its
|
|
33
|
+
parent box and starts when scrolled into view (`playOnView`, default on). Options:
|
|
34
|
+
`duration`, `delay`, `loop`, `pingpong`, `playOnView`. Exposed
|
|
35
|
+
`computeAutoplayProgress()` for custom rigs.
|
|
36
|
+
- **`preset` prop** — `"default" | "minimal" | "lively" | "glow"`: a tasteful bundle
|
|
37
|
+
of look + motion.
|
|
38
|
+
- **`style` prop** — per-field overrides on top of the preset:
|
|
39
|
+
`size`, `blend` (`"normal" | "additive"`), `drift`, `sparkle`. Backed by new shader
|
|
40
|
+
uniforms (`uSizeScale`, `uDrift`, `uSparkle`); `additive` enables glow blending for
|
|
41
|
+
dark backgrounds.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- Particles render finer and crisper on high-DPI screens: point-size base ×0.62,
|
|
46
|
+
clamp lowered to 4–5 px, and `devicePixelRatio` cap raised 2 → 3. (Validated on the
|
|
47
|
+
LINNO corporate site.)
|
|
48
|
+
- Scroll follow no longer lags: stage progress is applied directly instead of an
|
|
49
|
+
internal lerp. Add inertia in your driver (e.g. Lenis) if you want it.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- No more blank gap when the **first keyframe is text** — particles now start in the
|
|
54
|
+
formed glyph and dissolve outward, instead of appearing only after the real text
|
|
55
|
+
fades.
|
|
56
|
+
- `VERSION` export corrected (was a stale `"0.1.0"`).
|
|
57
|
+
|
|
58
|
+
## [0.2.0] — 2026-06-23
|
|
59
|
+
|
|
60
|
+
- Resolve to real DOM elements with pixel alignment; scrollbar & baseline fixes.
|
|
61
|
+
|
|
62
|
+
## [0.1.0]
|
|
63
|
+
|
|
64
|
+
- Initial public release: text → particles → glyph → real-text resolve, scroll-driven.
|
package/README.md
CHANGED
|
@@ -85,7 +85,34 @@ export function Hero() {
|
|
|
85
85
|
- `domSelector` makes particles land exactly on an existing element's box and font — no jump on cross-fade.
|
|
86
86
|
- `resolveToDom` on the final keyframe hands off from particles to crisp real text.
|
|
87
87
|
|
|
88
|
-
###
|
|
88
|
+
### Just drop in text — no scroll choreography
|
|
89
|
+
|
|
90
|
+
For anything that isn't a full-screen scroll hero, use the **`autoplay`** driver. It
|
|
91
|
+
fits its parent box and plays once when it scrolls into view — drop it anywhere and it
|
|
92
|
+
just animates:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
<div style={{ width: 480, height: 220 }}>
|
|
96
|
+
<GlyphDust
|
|
97
|
+
driver={{ type: "autoplay", duration: 3.5 }} // loop / pingpong / delay too
|
|
98
|
+
preset="minimal" // tasteful out of the box
|
|
99
|
+
keyframes={[
|
|
100
|
+
{ type: "scatter" },
|
|
101
|
+
{ type: "text", text: "glyphdust", dense: true },
|
|
102
|
+
]}
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Pick a look with presets (then tweak)
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
<GlyphDust preset="glow" style={{ size: 1.2 }} keyframes={[/* … */]} />
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`preset` is a tasteful starting point; `style` overrides just the fields you name.
|
|
114
|
+
|
|
115
|
+
### Drive it yourself (manual)
|
|
89
116
|
|
|
90
117
|
```tsx
|
|
91
118
|
const [p, setP] = useState(0); // 0 → 1 from time, GSAP, a slider, anything
|
|
@@ -105,7 +132,9 @@ const [p, setP] = useState(0); // 0 → 1 from time, GSAP, a slider, anything
|
|
|
105
132
|
| prop | type | default | description |
|
|
106
133
|
|---|---|---|---|
|
|
107
134
|
| `keyframes` | `Keyframe[]` | — (required) | The animation timeline. Minimum 1; typically `text → scatter → text`. |
|
|
108
|
-
| `driver` | `DriverConfig` | `{ type: "scroll" }` | Progress source: `scroll` or `manual`. |
|
|
135
|
+
| `driver` | `DriverConfig` | `{ type: "scroll" }` | Progress source: `scroll`, `autoplay`, or `manual`. |
|
|
136
|
+
| `preset` | `GlyphPreset` | `"default"` | Look/motion preset: `default`, `minimal`, `lively`, `glow`. |
|
|
137
|
+
| `style` | `GlyphStyle` | — | Per-field overrides on top of `preset` (see below). |
|
|
109
138
|
| `colors` | `GlyphColors` | see below | Particle ink / accent colors. |
|
|
110
139
|
| `count` | `GlyphCount` | `{ desktop: 11000, mobile: 5200 }` | Particle count per device class. |
|
|
111
140
|
| `timing` | `number[]` | even spacing | Normalized time `0..1` per keyframe (interpolation boundaries). Length must match `keyframes`. |
|
|
@@ -126,13 +155,35 @@ type Keyframe = TextKeyframe | ScatterKeyframe;
|
|
|
126
155
|
| field | type | description |
|
|
127
156
|
|---|---|---|
|
|
128
157
|
| `text` | `string` | Text to render. Use `\n` for line breaks. |
|
|
158
|
+
| `segments` | `{ text, font? }[]?` | Mix fonts in one glyph (see below). Particles are stamped from the runs; `text` stays the accessible / `resolveToDom` string. |
|
|
129
159
|
| `domSelector` | `string?` | Selector of a real element; particles align pixel-perfect to its rect & font. |
|
|
130
160
|
| `resolveToDom` | `boolean?` | At the finale, cross-fade particles → real DOM text (usually the last keyframe). |
|
|
131
161
|
| `dense` | `boolean?` | High-density, uniform sampling (best for solid wordmarks). |
|
|
132
|
-
| `font` | `string?` | Canvas2D `font` string. Defaults to a density-appropriate value. |
|
|
162
|
+
| `font` | `string?` | Canvas2D `font` string. Defaults to a density-appropriate value. Also the default for `segments` runs. |
|
|
133
163
|
| `worldW` | `number?` | Visible world width to fit the glyph into. |
|
|
134
164
|
| `offsetX` / `offsetY` | `number?` | World-space offset (right / up are positive). |
|
|
135
165
|
|
|
166
|
+
#### Mix fonts in one glyph (`segments`)
|
|
167
|
+
|
|
168
|
+
Particles just ride wherever there's "ink", so a single glyph isn't bound to one
|
|
169
|
+
typeface. Split a keyframe into `segments` and each run gets its own `font`,
|
|
170
|
+
flowing inline (a `\n` inside a run starts a new line; the next run continues there):
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
{
|
|
174
|
+
type: "text",
|
|
175
|
+
text: "Mix fonts", // accessible / resolve string
|
|
176
|
+
segments: [
|
|
177
|
+
{ text: "Mix ", font: "900 200px Georgia, serif" }, // bold serif
|
|
178
|
+
{ text: "fonts", font: "300 150px 'Helvetica Neue', sans-serif" }, // light sans
|
|
179
|
+
],
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Runs without a `font` fall back to the keyframe `font`. `segments` is ignored when
|
|
184
|
+
`domSelector` is set (the DOM provides the layout there). Particle **color** is still
|
|
185
|
+
governed globally by `colors` (ink/accent ratio), not per segment.
|
|
186
|
+
|
|
136
187
|
**`ScatterKeyframe`** (`type: "scatter"`) — scatters particles into a random cloud:
|
|
137
188
|
|
|
138
189
|
| field | type | description |
|
|
@@ -142,10 +193,35 @@ type Keyframe = TextKeyframe | ScatterKeyframe;
|
|
|
142
193
|
### Drivers
|
|
143
194
|
|
|
144
195
|
```ts
|
|
145
|
-
{ type: "scroll", triggerHeight?: number } // default triggerHeight: 2 (×100vh)
|
|
196
|
+
{ type: "scroll", triggerHeight?: number } // full-screen sticky hero. default triggerHeight: 2 (×100vh)
|
|
146
197
|
{ type: "manual", progress: number } // you supply 0..1
|
|
198
|
+
{ type: "autoplay", // time-based; fits its parent box
|
|
199
|
+
duration?: number, // seconds for 0→1 (default 4)
|
|
200
|
+
delay?: number, // start delay (default 0)
|
|
201
|
+
loop?: boolean, // repeat (default false)
|
|
202
|
+
pingpong?: boolean, // 0→1→0 when looping (default false)
|
|
203
|
+
playOnView?: boolean, // start when scrolled into view (default true)
|
|
204
|
+
}
|
|
147
205
|
```
|
|
148
206
|
|
|
207
|
+
`scroll` builds a tall sticky wrapper for a full-screen hero. `manual` and `autoplay`
|
|
208
|
+
simply **fill their parent**, so you can place them in any sized container.
|
|
209
|
+
|
|
210
|
+
### Presets & style
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
preset: "default" | "minimal" | "lively" | "glow"
|
|
214
|
+
|
|
215
|
+
style: {
|
|
216
|
+
size?: number, // point-size multiplier (default 1)
|
|
217
|
+
blend?: "normal" | "additive", // "additive" = glow, for dark backgrounds
|
|
218
|
+
drift?: number, // idle/scatter wander 0..1 (default 1; 0 = still)
|
|
219
|
+
sparkle?: number, // sparkle strength 0..1 (default 1; 0 = off)
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`style` always wins over `preset`. Defaults reproduce the original look exactly.
|
|
224
|
+
|
|
149
225
|
### Colors
|
|
150
226
|
|
|
151
227
|
```ts
|
|
@@ -156,7 +232,7 @@ type Keyframe = TextKeyframe | ScatterKeyframe;
|
|
|
156
232
|
|
|
157
233
|
### Low-level helpers
|
|
158
234
|
|
|
159
|
-
For custom rigs, the building blocks are exported too: `buildTextTargets`, `buildDenseTextTargets`, `buildVertexShader`, `FRAGMENT_SHADER`, `createScrollProgress`, `useScrollProgress`, `useReducedMotion`, `prefersReducedMotion`, `viewSizeAtZ0`, `buildGlyphFromDOM`, `computeScreenRect`.
|
|
235
|
+
For custom rigs, the building blocks are exported too: `buildTextTargets`, `buildDenseTextTargets`, `buildVertexShader`, `FRAGMENT_SHADER`, `createScrollProgress`, `useScrollProgress`, `computeAutoplayProgress`, `useReducedMotion`, `prefersReducedMotion`, `viewSizeAtZ0`, `buildGlyphFromDOM`, `computeScreenRect`.
|
|
160
236
|
|
|
161
237
|
---
|
|
162
238
|
|
|
@@ -171,7 +247,7 @@ For custom rigs, the building blocks are exported too: `buildTextTargets`, `buil
|
|
|
171
247
|
|
|
172
248
|
## Status
|
|
173
249
|
|
|
174
|
-
`0.1
|
|
250
|
+
`0.2.1` — the component and API above are implemented and demoed (see [`examples/`](./examples)) and [`CHANGELOG.md`](./CHANGELOG.md). Published from [LINNO](https://linno.co.jp). Semantic-versioned; expect minor API polish before `1.0`.
|
|
175
251
|
|
|
176
252
|
## License
|
|
177
253
|
|
package/dist/index.cjs
CHANGED
|
@@ -54,6 +54,38 @@ function fillScatterCluster(out, count, offsetX, offsetY, random) {
|
|
|
54
54
|
out[i * 3 + 2] = (random() - 0.5) * 0.2;
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
+
function segmentsToRunLines(segments, defaultFont) {
|
|
58
|
+
const lines = [[]];
|
|
59
|
+
for (const seg of segments) {
|
|
60
|
+
const font = seg.font ?? defaultFont;
|
|
61
|
+
const parts = seg.text.split("\n");
|
|
62
|
+
parts.forEach((part, i) => {
|
|
63
|
+
if (i > 0) lines.push([]);
|
|
64
|
+
if (part.length > 0) lines[lines.length - 1].push({ text: part, font });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return lines;
|
|
68
|
+
}
|
|
69
|
+
function drawSegmentedLines(ctx, runLines, cw, ch, lineHeight, align, leftPad) {
|
|
70
|
+
ctx.fillStyle = "#000";
|
|
71
|
+
ctx.textBaseline = "middle";
|
|
72
|
+
ctx.textAlign = "left";
|
|
73
|
+
const blockH = lineHeight * (runLines.length - 1);
|
|
74
|
+
runLines.forEach((runs, i) => {
|
|
75
|
+
const y = ch / 2 - blockH / 2 + i * lineHeight;
|
|
76
|
+
let total = 0;
|
|
77
|
+
for (const r of runs) {
|
|
78
|
+
ctx.font = r.font;
|
|
79
|
+
total += ctx.measureText(r.text).width;
|
|
80
|
+
}
|
|
81
|
+
let x = align === "left" ? leftPad : cw / 2 - total / 2;
|
|
82
|
+
for (const r of runs) {
|
|
83
|
+
ctx.font = r.font;
|
|
84
|
+
ctx.fillText(r.text, x, y);
|
|
85
|
+
x += ctx.measureText(r.text).width;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
57
89
|
function buildTextTargets(count, lines, opts) {
|
|
58
90
|
const out = new Float32Array(count * 3);
|
|
59
91
|
const random = opts.random ?? Math.random;
|
|
@@ -62,17 +94,22 @@ function buildTextTargets(count, lines, opts) {
|
|
|
62
94
|
const ctx = createSamplingContext(cw, ch);
|
|
63
95
|
if (!ctx) return out;
|
|
64
96
|
const align = opts.align ?? "center";
|
|
65
|
-
ctx.clearRect(0, 0, cw, ch);
|
|
66
|
-
ctx.fillStyle = "#000";
|
|
67
|
-
ctx.textAlign = align === "left" ? "left" : "center";
|
|
68
|
-
ctx.textBaseline = "middle";
|
|
69
|
-
ctx.font = opts.font;
|
|
70
|
-
const drawX = align === "left" ? cw * 0.04 : cw / 2;
|
|
71
97
|
const lh = opts.lineHeight;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
98
|
+
ctx.clearRect(0, 0, cw, ch);
|
|
99
|
+
if (opts.segments && opts.segments.length > 0) {
|
|
100
|
+
const runLines = segmentsToRunLines(opts.segments, opts.font);
|
|
101
|
+
drawSegmentedLines(ctx, runLines, cw, ch, lh, align, cw * 0.04);
|
|
102
|
+
} else {
|
|
103
|
+
ctx.fillStyle = "#000";
|
|
104
|
+
ctx.textAlign = align === "left" ? "left" : "center";
|
|
105
|
+
ctx.textBaseline = "middle";
|
|
106
|
+
ctx.font = opts.font;
|
|
107
|
+
const drawX = align === "left" ? cw * 0.04 : cw / 2;
|
|
108
|
+
const blockH = lh * (lines.length - 1);
|
|
109
|
+
lines.forEach((line, i) => {
|
|
110
|
+
ctx.fillText(line, drawX, ch / 2 - blockH / 2 + i * lh);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
76
113
|
const step = opts.step ?? 2;
|
|
77
114
|
const pts = collectFilledPixels(ctx, cw, ch, step);
|
|
78
115
|
const filled = pts.length / 2;
|
|
@@ -106,15 +143,20 @@ function buildDenseTextTargets(count, lines, opts) {
|
|
|
106
143
|
const ctx = createSamplingContext(cw, ch);
|
|
107
144
|
if (!ctx) return out;
|
|
108
145
|
ctx.clearRect(0, 0, cw, ch);
|
|
109
|
-
ctx.fillStyle = "#000";
|
|
110
|
-
ctx.textAlign = "center";
|
|
111
|
-
ctx.textBaseline = "middle";
|
|
112
|
-
ctx.font = opts.font;
|
|
113
146
|
const lh = ch * (opts.lineHeightRatio ?? 0.46);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
ctx
|
|
117
|
-
}
|
|
147
|
+
if (opts.segments && opts.segments.length > 0) {
|
|
148
|
+
const runLines = segmentsToRunLines(opts.segments, opts.font);
|
|
149
|
+
drawSegmentedLines(ctx, runLines, cw, ch, lh, "center", cw * 0.04);
|
|
150
|
+
} else {
|
|
151
|
+
ctx.fillStyle = "#000";
|
|
152
|
+
ctx.textAlign = "center";
|
|
153
|
+
ctx.textBaseline = "middle";
|
|
154
|
+
ctx.font = opts.font;
|
|
155
|
+
const blockH = lh * (lines.length - 1);
|
|
156
|
+
lines.forEach((line, i) => {
|
|
157
|
+
ctx.fillText(line, cw / 2, ch / 2 - blockH / 2 + i * lh);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
118
160
|
const step = opts.step ?? 1;
|
|
119
161
|
const pts = collectFilledPixels(ctx, cw, ch, step);
|
|
120
162
|
const filled = pts.length / 2;
|
|
@@ -182,6 +224,8 @@ function buildVertexShader(keyframeCount) {
|
|
|
182
224
|
uniform vec3 uPointer;
|
|
183
225
|
uniform float uPointerActive;
|
|
184
226
|
uniform float uSize;
|
|
227
|
+
uniform float uSizeScale;
|
|
228
|
+
uniform float uDrift;
|
|
185
229
|
uniform float uPixelRatio;
|
|
186
230
|
|
|
187
231
|
${attributeDecls}
|
|
@@ -217,7 +261,7 @@ ${mixChain}
|
|
|
217
261
|
|
|
218
262
|
// \u30A2\u30A4\u30C9\u30EB\u306E\u6F02\u3044\uFF08\u6574\u5217\u6642 settle / \u5B57\u5F62\u6642 form \u3067\u5F31\u3081\u308B\uFF09\u3002
|
|
219
263
|
vSettle = uSettle;
|
|
220
|
-
float drift = (1.0 - uReduced) * (1.0 - uSettle * 0.9) * (1.0 - uForm);
|
|
264
|
+
float drift = (1.0 - uReduced) * (1.0 - uSettle * 0.9) * (1.0 - uForm) * uDrift;
|
|
221
265
|
pos.x += sin(uTime * 0.35 + ph) * 0.06 * drift;
|
|
222
266
|
pos.y += cos(uTime * 0.30 + ph * 1.7) * 0.06 * drift;
|
|
223
267
|
pos.z += sin(uTime * 0.27 + ph * 2.3) * 0.06 * drift;
|
|
@@ -245,9 +289,11 @@ ${mixChain}
|
|
|
245
289
|
float sizeVar = mix(0.55 + aSeed * 0.9, 0.72 + aSeed * 0.35, uSettle);
|
|
246
290
|
// \u5B57\u5F62\u53CE\u675F\u6642\u306F\u96A3\u63A5\u7C92\u5B50\u3067\u9699\u9593\u3092\u57CB\u3081\u308B\u305F\u3081\u308F\u305A\u304B\u306B\u5927\u304D\u3081\uFF06\u5747\u4E00\u306B\u3002
|
|
247
291
|
sizeVar = mix(sizeVar, 0.95 + aSeed * 0.18, uForm);
|
|
248
|
-
|
|
292
|
+
// \u9AD8 dpr \u74B0\u5883\u3067\u306F\u5C0F\u7C92\u30FB\u4E0A\u9650\u4F4E\u3081\u306E\u65B9\u304C\u30A8\u30C3\u30B8\u304C\u7DE0\u307E\u308A\u9AD8\u7CBE\u7D30\u306B\u898B\u3048\u308B
|
|
293
|
+
// \uFF08\u30B3\u30FC\u30DD\u30EC\u30FC\u30C8\u30B5\u30A4\u30C8\u5B9F\u88C5\u3067\u5B9F\u8A3C\u30020.62 \u3068 clamp 4\u301C5 \u304C\u6700\u3082\u300C\u971E\u307E\u306A\u3044\u300D\uFF09\u3002
|
|
294
|
+
float s = uSize * sizeVar * 0.62 * uSizeScale;
|
|
249
295
|
gl_PointSize = s * uPixelRatio * (1.0 / -mvPosition.z);
|
|
250
|
-
gl_PointSize = clamp(gl_PointSize, 1.0, mix(
|
|
296
|
+
gl_PointSize = clamp(gl_PointSize, 1.0, mix(4.0, 5.0, uForm) * uPixelRatio);
|
|
251
297
|
}
|
|
252
298
|
`
|
|
253
299
|
);
|
|
@@ -257,6 +303,7 @@ var FRAGMENT_SHADER = (
|
|
|
257
303
|
`
|
|
258
304
|
uniform vec3 uColorInk;
|
|
259
305
|
uniform vec3 uColorAccent;
|
|
306
|
+
uniform float uSparkle;
|
|
260
307
|
|
|
261
308
|
varying float vSeed;
|
|
262
309
|
varying float vAccent;
|
|
@@ -278,7 +325,7 @@ var FRAGMENT_SHADER = (
|
|
|
278
325
|
|
|
279
326
|
// \u4E00\u90E8\u306E\u7C92\u306B\u660E\u308B\u3044\u304D\u3089\u3081\u304D\uFF08\u98DB\u6563\u6642\u306B\u6620\u3048\u308B\uFF09\u3002\u6574\u5217\u6642\u306F\u63A7\u3048\u3081\u3002
|
|
280
327
|
float spark = step(0.94, vSeed);
|
|
281
|
-
col = mix(col, uColorAccent, spark * mix(0.45, 0.15, vSettle));
|
|
328
|
+
col = mix(col, uColorAccent, spark * mix(0.45, 0.15, vSettle) * uSparkle);
|
|
282
329
|
|
|
283
330
|
// \u5965\u884C\u304D\u3067\u6FC3\u6DE1\uFF08\u660E\u80CC\u666F\u3067\u306E\u8996\u8A8D\u6027\u78BA\u4FDD\u306E\u305F\u3081\u4E0B\u9650\u3092\u6301\u305F\u305B\u308B\uFF09\u3002
|
|
284
331
|
float floorFade = mix(0.45, 0.78, vSettle);
|
|
@@ -412,6 +459,21 @@ function computeScreenRect(targets, viewportW, viewportH, visibleWorldW) {
|
|
|
412
459
|
};
|
|
413
460
|
}
|
|
414
461
|
var DEFAULT_TRIGGER_HEIGHT = 2;
|
|
462
|
+
function triangle(x) {
|
|
463
|
+
const t = x % 2;
|
|
464
|
+
return t <= 1 ? t : 2 - t;
|
|
465
|
+
}
|
|
466
|
+
function computeAutoplayProgress(elapsedSec, cfg) {
|
|
467
|
+
const duration = cfg.duration && cfg.duration > 0 ? cfg.duration : 4;
|
|
468
|
+
const delay = cfg.delay && cfg.delay > 0 ? cfg.delay : 0;
|
|
469
|
+
const t = elapsedSec - delay;
|
|
470
|
+
if (t <= 0) return 0;
|
|
471
|
+
const raw = t / duration;
|
|
472
|
+
if (cfg.loop) {
|
|
473
|
+
return cfg.pingpong ? triangle(raw) : raw % 1;
|
|
474
|
+
}
|
|
475
|
+
return clamp01(raw);
|
|
476
|
+
}
|
|
415
477
|
function clamp01(x) {
|
|
416
478
|
return x < 0 ? 0 : x > 1 ? 1 : x;
|
|
417
479
|
}
|
|
@@ -497,6 +559,7 @@ function buildKeyframeTargets(kf, count, ctx) {
|
|
|
497
559
|
if (kf.dense) {
|
|
498
560
|
return buildDenseTextTargets(count, lines, {
|
|
499
561
|
font: kf.font ?? DEFAULT_DENSE_FONT,
|
|
562
|
+
segments: kf.segments,
|
|
500
563
|
worldW: kf.worldW ?? ctx.visW * (ctx.mobile ? 0.86 : 0.62),
|
|
501
564
|
offsetX: kf.offsetX ?? 0,
|
|
502
565
|
offsetY: kf.offsetY ?? 0,
|
|
@@ -508,6 +571,7 @@ function buildKeyframeTargets(kf, count, ctx) {
|
|
|
508
571
|
}
|
|
509
572
|
return buildTextTargets(count, lines, {
|
|
510
573
|
font: kf.font ?? DEFAULT_TEXT_FONT,
|
|
574
|
+
segments: kf.segments,
|
|
511
575
|
worldW: kf.worldW ?? ctx.visW * 0.7,
|
|
512
576
|
lineHeight: 178,
|
|
513
577
|
offsetX: kf.offsetX ?? 0,
|
|
@@ -523,6 +587,7 @@ function GlyphPoints(props) {
|
|
|
523
587
|
keyframes,
|
|
524
588
|
count,
|
|
525
589
|
colors,
|
|
590
|
+
style,
|
|
526
591
|
cameraZ,
|
|
527
592
|
cameraFov,
|
|
528
593
|
pointer: pointerEnabled,
|
|
@@ -606,6 +671,9 @@ function GlyphPoints(props) {
|
|
|
606
671
|
uPointer: { value: new THREE__namespace.Vector3(0, 0, 0) },
|
|
607
672
|
uPointerActive: { value: 0 },
|
|
608
673
|
uSize: { value: 1 },
|
|
674
|
+
uSizeScale: { value: style.size },
|
|
675
|
+
uDrift: { value: style.drift },
|
|
676
|
+
uSparkle: { value: style.sparkle },
|
|
609
677
|
uPixelRatio: { value: 1 },
|
|
610
678
|
uColorInk: { value: colors.ink.clone() },
|
|
611
679
|
uColorAccent: { value: colors.accent.clone() }
|
|
@@ -803,9 +871,19 @@ function GlyphPoints(props) {
|
|
|
803
871
|
const mat = matRef.current;
|
|
804
872
|
if (!mat) return;
|
|
805
873
|
const u = mat.uniforms;
|
|
806
|
-
u.uPixelRatio.value = Math.min(window.devicePixelRatio || 1,
|
|
874
|
+
u.uPixelRatio.value = Math.min(window.devicePixelRatio || 1, 3);
|
|
807
875
|
u.uSize.value = Math.min(size.height / 18, 26);
|
|
808
876
|
}, [size]);
|
|
877
|
+
react.useEffect(() => {
|
|
878
|
+
const mat = matRef.current;
|
|
879
|
+
if (!mat) return;
|
|
880
|
+
const u = mat.uniforms;
|
|
881
|
+
u.uSizeScale.value = style.size;
|
|
882
|
+
u.uDrift.value = style.drift;
|
|
883
|
+
u.uSparkle.value = style.sparkle;
|
|
884
|
+
mat.blending = style.blend === "additive" ? THREE__namespace.AdditiveBlending : THREE__namespace.NormalBlending;
|
|
885
|
+
mat.needsUpdate = true;
|
|
886
|
+
}, [style.size, style.drift, style.sparkle, style.blend]);
|
|
809
887
|
fiber.useFrame((state, delta) => {
|
|
810
888
|
const p = pointsRef.current;
|
|
811
889
|
const mat = matRef.current;
|
|
@@ -813,7 +891,7 @@ function GlyphPoints(props) {
|
|
|
813
891
|
const u = mat.uniforms;
|
|
814
892
|
const d = Math.min(delta, 0.05);
|
|
815
893
|
const raw = THREE__namespace.MathUtils.clamp(getProgress(), 0, 1);
|
|
816
|
-
stage.current =
|
|
894
|
+
stage.current = raw;
|
|
817
895
|
const s = stage.current;
|
|
818
896
|
let settle = 0;
|
|
819
897
|
let burst = 0;
|
|
@@ -830,6 +908,11 @@ function GlyphPoints(props) {
|
|
|
830
908
|
if (lastIsText && n >= 2) {
|
|
831
909
|
form = smooth(times[n - 2] ?? 0, times[n - 1] ?? 1, s);
|
|
832
910
|
}
|
|
911
|
+
const firstIsText = timeline.isText[0] === true;
|
|
912
|
+
if (firstIsText && n >= 2) {
|
|
913
|
+
const formStart = 1 - smooth(times[0] ?? 0, times[1] ?? 1, s);
|
|
914
|
+
form = Math.max(form, formStart);
|
|
915
|
+
}
|
|
833
916
|
const guard = THREE__namespace.MathUtils.clamp(Math.max(settle, form), 0, 1);
|
|
834
917
|
guardRef.current = guard;
|
|
835
918
|
const swapped = raw >= timeline.swapAt ? 1 : 0;
|
|
@@ -880,7 +963,7 @@ function GlyphPoints(props) {
|
|
|
880
963
|
uniforms,
|
|
881
964
|
transparent: true,
|
|
882
965
|
depthWrite: false,
|
|
883
|
-
blending: THREE__namespace.NormalBlending,
|
|
966
|
+
blending: style.blend === "additive" ? THREE__namespace.AdditiveBlending : THREE__namespace.NormalBlending,
|
|
884
967
|
vertexShader,
|
|
885
968
|
fragmentShader: FRAGMENT_SHADER
|
|
886
969
|
}
|
|
@@ -894,6 +977,12 @@ var DEFAULT_COUNT_MOBILE = 5200;
|
|
|
894
977
|
var DEFAULT_CAMERA_Z = 7;
|
|
895
978
|
var DEFAULT_CAMERA_FOV = 42;
|
|
896
979
|
var DEFAULT_DPR = [1, 1.75];
|
|
980
|
+
var PRESETS = {
|
|
981
|
+
default: { size: 1, blend: "normal", drift: 1, sparkle: 1 },
|
|
982
|
+
minimal: { size: 0.92, blend: "normal", drift: 0.35, sparkle: 0 },
|
|
983
|
+
lively: { size: 1.05, blend: "normal", drift: 1.4, sparkle: 1.4 },
|
|
984
|
+
glow: { size: 1.1, blend: "additive", drift: 1.1, sparkle: 1.5 }
|
|
985
|
+
};
|
|
897
986
|
function clamp012(x) {
|
|
898
987
|
return x < 0 ? 0 : x > 1 ? 1 : x;
|
|
899
988
|
}
|
|
@@ -912,6 +1001,8 @@ function GlyphDust(props) {
|
|
|
912
1001
|
const {
|
|
913
1002
|
keyframes,
|
|
914
1003
|
driver = { type: "scroll" },
|
|
1004
|
+
preset = "default",
|
|
1005
|
+
style,
|
|
915
1006
|
colors,
|
|
916
1007
|
count,
|
|
917
1008
|
dpr = DEFAULT_DPR,
|
|
@@ -935,15 +1026,62 @@ function GlyphDust(props) {
|
|
|
935
1026
|
const resolveRef = react.useRef(null);
|
|
936
1027
|
const manualRef = react.useRef(0);
|
|
937
1028
|
if (driver.type === "manual") manualRef.current = clamp012(driver.progress);
|
|
1029
|
+
const autoplay = driver.type === "autoplay" ? driver : null;
|
|
1030
|
+
const playingRef = react.useRef(false);
|
|
1031
|
+
const startMsRef = react.useRef(null);
|
|
1032
|
+
const lastAutoRef = react.useRef(0);
|
|
1033
|
+
react.useEffect(() => {
|
|
1034
|
+
if (!autoplay) return;
|
|
1035
|
+
if (autoplay.playOnView === false) {
|
|
1036
|
+
playingRef.current = true;
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
const el = wrapperRef.current;
|
|
1040
|
+
if (el === null || typeof IntersectionObserver === "undefined") {
|
|
1041
|
+
playingRef.current = true;
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
const io = new IntersectionObserver(
|
|
1045
|
+
(entries) => {
|
|
1046
|
+
for (const e of entries) {
|
|
1047
|
+
if (e.isIntersecting && !playingRef.current) {
|
|
1048
|
+
playingRef.current = true;
|
|
1049
|
+
startMsRef.current = null;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
},
|
|
1053
|
+
{ threshold: 0.25 }
|
|
1054
|
+
);
|
|
1055
|
+
io.observe(el);
|
|
1056
|
+
return () => io.disconnect();
|
|
1057
|
+
}, [autoplay?.playOnView]);
|
|
938
1058
|
const getProgress = react.useCallback(() => {
|
|
939
1059
|
if (driver.type === "manual") return manualRef.current;
|
|
1060
|
+
if (driver.type === "autoplay") {
|
|
1061
|
+
if (!playingRef.current || typeof performance === "undefined") {
|
|
1062
|
+
return lastAutoRef.current;
|
|
1063
|
+
}
|
|
1064
|
+
if (startMsRef.current === null) startMsRef.current = performance.now();
|
|
1065
|
+
const elapsed = (performance.now() - startMsRef.current) / 1e3;
|
|
1066
|
+
lastAutoRef.current = computeAutoplayProgress(elapsed, driver);
|
|
1067
|
+
return lastAutoRef.current;
|
|
1068
|
+
}
|
|
940
1069
|
const el = wrapperRef.current;
|
|
941
1070
|
if (el === null || typeof window === "undefined") return 0;
|
|
942
1071
|
const rect = el.getBoundingClientRect();
|
|
943
1072
|
const total = rect.height - window.innerHeight;
|
|
944
1073
|
if (total <= 0) return 0;
|
|
945
1074
|
return clamp012(-rect.top / total);
|
|
946
|
-
}, [driver
|
|
1075
|
+
}, [driver]);
|
|
1076
|
+
const resolvedStyle = react.useMemo(() => {
|
|
1077
|
+
const base = PRESETS[preset] ?? PRESETS.default;
|
|
1078
|
+
return {
|
|
1079
|
+
size: style?.size ?? base.size,
|
|
1080
|
+
blend: style?.blend ?? base.blend,
|
|
1081
|
+
drift: style?.drift ?? base.drift,
|
|
1082
|
+
sparkle: style?.sparkle ?? base.sparkle
|
|
1083
|
+
};
|
|
1084
|
+
}, [preset, style?.size, style?.blend, style?.drift, style?.sparkle]);
|
|
947
1085
|
const resolvedColors = react.useMemo(
|
|
948
1086
|
() => ({
|
|
949
1087
|
ink: new THREE__namespace.Color(colors?.ink ?? DEFAULT_INK),
|
|
@@ -980,6 +1118,7 @@ function GlyphDust(props) {
|
|
|
980
1118
|
keyframes,
|
|
981
1119
|
count: particleCount,
|
|
982
1120
|
colors: resolvedColors,
|
|
1121
|
+
style: resolvedStyle,
|
|
983
1122
|
cameraZ,
|
|
984
1123
|
cameraFov,
|
|
985
1124
|
pointer: pointerEnabled,
|
|
@@ -1013,10 +1152,11 @@ function GlyphDust(props) {
|
|
|
1013
1152
|
}
|
|
1014
1153
|
) : null
|
|
1015
1154
|
] });
|
|
1016
|
-
if (driver.type === "manual") {
|
|
1155
|
+
if (driver.type === "manual" || driver.type === "autoplay") {
|
|
1017
1156
|
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
1018
1157
|
"div",
|
|
1019
1158
|
{
|
|
1159
|
+
ref: wrapperRef,
|
|
1020
1160
|
className,
|
|
1021
1161
|
style: { position: "relative", width: "100%", height: "100%" },
|
|
1022
1162
|
children: scene
|
|
@@ -1048,7 +1188,7 @@ function GlyphDust(props) {
|
|
|
1048
1188
|
}
|
|
1049
1189
|
|
|
1050
1190
|
// src/index.ts
|
|
1051
|
-
var VERSION = "0.
|
|
1191
|
+
var VERSION = "0.3.0";
|
|
1052
1192
|
|
|
1053
1193
|
exports.DEFAULT_TRIGGER_HEIGHT = DEFAULT_TRIGGER_HEIGHT;
|
|
1054
1194
|
exports.FRAGMENT_SHADER = FRAGMENT_SHADER;
|
|
@@ -1059,6 +1199,7 @@ exports.buildDenseTextTargets = buildDenseTextTargets;
|
|
|
1059
1199
|
exports.buildGlyphFromDOM = buildGlyphFromDOM;
|
|
1060
1200
|
exports.buildTextTargets = buildTextTargets;
|
|
1061
1201
|
exports.buildVertexShader = buildVertexShader;
|
|
1202
|
+
exports.computeAutoplayProgress = computeAutoplayProgress;
|
|
1062
1203
|
exports.computeScreenRect = computeScreenRect;
|
|
1063
1204
|
exports.createScrollProgress = createScrollProgress;
|
|
1064
1205
|
exports.glyphPositionAttribute = glyphPositionAttribute;
|