@principal-ai/logo-component 0.1.6 → 0.1.7
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/dist/OpenTypeTextReveal.d.ts +11 -0
- package/dist/OpenTypeTextReveal.js +183 -33
- package/dist/esm/OpenTypeTextReveal.js +183 -33
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
type ChaosMode = "none" | "fragmented";
|
|
3
3
|
type ChartPattern = "latency" | "cpu" | "noise";
|
|
4
|
+
type DotDistribution = "even" | "endpoints" | "random";
|
|
4
5
|
interface OpenTypeTextRevealProps {
|
|
5
6
|
/** The text to display */
|
|
6
7
|
text: string;
|
|
@@ -26,6 +27,14 @@ interface OpenTypeTextRevealProps {
|
|
|
26
27
|
chartTransitionDuration?: number;
|
|
27
28
|
/** Telemetry pattern style for the chart */
|
|
28
29
|
chartPattern?: ChartPattern;
|
|
30
|
+
/** Pixels of path length per dot (lower = more dots). Default: 30 */
|
|
31
|
+
dotsPerPathUnit?: number;
|
|
32
|
+
/** Minimum dots per contour. Default: 1 */
|
|
33
|
+
minDotsPerContour?: number;
|
|
34
|
+
/** Maximum dots per contour (0 = unlimited). Default: 0 */
|
|
35
|
+
maxDotsPerContour?: number;
|
|
36
|
+
/** How dots are distributed along each contour. Default: "even" */
|
|
37
|
+
dotDistribution?: DotDistribution;
|
|
29
38
|
chaosMode?: ChaosMode;
|
|
30
39
|
chaosDuration?: number;
|
|
31
40
|
dotsDuration?: number;
|
|
@@ -34,6 +43,8 @@ interface OpenTypeTextRevealProps {
|
|
|
34
43
|
particlesPerPath?: number;
|
|
35
44
|
particleRadius?: number;
|
|
36
45
|
color?: string;
|
|
46
|
+
/** Colors for each word (space-separated). Falls back to `color` if not specified. */
|
|
47
|
+
wordColors?: string[];
|
|
37
48
|
particleColor?: string;
|
|
38
49
|
strokeWidth?: number;
|
|
39
50
|
opacity?: number;
|
|
@@ -219,7 +219,69 @@ function splitPathIntoContours(d) {
|
|
|
219
219
|
}
|
|
220
220
|
return contours;
|
|
221
221
|
}
|
|
222
|
-
|
|
222
|
+
/**
|
|
223
|
+
* Sample points along an SVG path using browser's native getPointAtLength
|
|
224
|
+
*/
|
|
225
|
+
function samplePointsAlongPath(d, numPoints, distribution, seed) {
|
|
226
|
+
if (numPoints <= 0)
|
|
227
|
+
return [];
|
|
228
|
+
// SSR fallback: estimate points without DOM
|
|
229
|
+
if (typeof document === 'undefined') {
|
|
230
|
+
const endpoints = getPathEndpoints(d);
|
|
231
|
+
const points = [];
|
|
232
|
+
for (let i = 0; i < numPoints; i++) {
|
|
233
|
+
const t = numPoints === 1 ? 0 : i / (numPoints - 1);
|
|
234
|
+
points.push({
|
|
235
|
+
x: endpoints.start.x + (endpoints.end.x - endpoints.start.x) * t,
|
|
236
|
+
y: endpoints.start.y + (endpoints.end.y - endpoints.start.y) * t,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return points;
|
|
240
|
+
}
|
|
241
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
242
|
+
path.setAttribute('d', d);
|
|
243
|
+
const totalLength = path.getTotalLength();
|
|
244
|
+
if (totalLength === 0) {
|
|
245
|
+
const endpoints = getPathEndpoints(d);
|
|
246
|
+
return [endpoints.start];
|
|
247
|
+
}
|
|
248
|
+
// Generate normalized positions (0-1) based on distribution
|
|
249
|
+
const positions = [];
|
|
250
|
+
switch (distribution) {
|
|
251
|
+
case "even":
|
|
252
|
+
for (let i = 0; i < numPoints; i++) {
|
|
253
|
+
positions.push(numPoints === 1 ? 0 : i / (numPoints - 1));
|
|
254
|
+
}
|
|
255
|
+
break;
|
|
256
|
+
case "endpoints":
|
|
257
|
+
// Concentrate more points near 0 and 1
|
|
258
|
+
for (let i = 0; i < numPoints; i++) {
|
|
259
|
+
const t = numPoints === 1 ? 0 : i / (numPoints - 1);
|
|
260
|
+
// Apply curve to cluster at ends: more samples near 0 and 1
|
|
261
|
+
const weighted = t < 0.5
|
|
262
|
+
? 0.5 * Math.pow(2 * t, 0.5)
|
|
263
|
+
: 1 - 0.5 * Math.pow(2 * (1 - t), 0.5);
|
|
264
|
+
positions.push(weighted);
|
|
265
|
+
}
|
|
266
|
+
break;
|
|
267
|
+
case "random": {
|
|
268
|
+
const random = seededRandom(seed);
|
|
269
|
+
for (let i = 0; i < numPoints; i++) {
|
|
270
|
+
positions.push(random());
|
|
271
|
+
}
|
|
272
|
+
positions.sort((a, b) => a - b); // Sort for visual consistency
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Convert positions to actual points
|
|
277
|
+
const points = [];
|
|
278
|
+
for (const t of positions) {
|
|
279
|
+
const point = path.getPointAtLength(t * totalLength);
|
|
280
|
+
points.push({ x: point.x, y: point.y });
|
|
281
|
+
}
|
|
282
|
+
return points;
|
|
283
|
+
}
|
|
284
|
+
const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600, height = 150, centerText = true, showChartIntro, chartDuration = 1.5, chartLineFadeDuration = 0.5, chartPauseDuration = 0.3, chartTransitionDuration = 0.8, chartPattern = "latency", dotsPerPathUnit = 30, minDotsPerContour = 1, maxDotsPerContour = 0, dotDistribution = "even", chaosMode = "none", chaosDuration = 2, dotsDuration = 1.5, flowDuration = 2, flowDelay = 0.3, particlesPerPath = 1, particleRadius = 2, color = "currentColor", wordColors, particleColor, strokeWidth = 1.5, opacity = 0.9, showGlow = true, fadeAfterAssembly = true, fadeOpacity = 0.5, loop = true, loopDelay = 1, }) => {
|
|
223
285
|
const idRef = (0, react_1.useRef)(null);
|
|
224
286
|
if (idRef.current === null) {
|
|
225
287
|
idRef.current = `otr${globalIdCounter++}`;
|
|
@@ -252,28 +314,48 @@ const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600, height
|
|
|
252
314
|
cancelled = true;
|
|
253
315
|
};
|
|
254
316
|
}, [fontUrl]);
|
|
255
|
-
// Generate paths from font
|
|
256
|
-
const { paths, textWidth, textHeight } = (0, react_1.useMemo)(() => {
|
|
317
|
+
// Generate paths from font, tracking word indices
|
|
318
|
+
const { paths, textWidth, textHeight, wordCount } = (0, react_1.useMemo)(() => {
|
|
257
319
|
if (!font) {
|
|
258
|
-
return { paths: [], textWidth: 0, textHeight: fontSize };
|
|
320
|
+
return { paths: [], textWidth: 0, textHeight: fontSize, wordCount: 0 };
|
|
259
321
|
}
|
|
260
|
-
//
|
|
261
|
-
const
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
322
|
+
// Split text into words and generate paths per word
|
|
323
|
+
const words = text.split(/\s+/).filter(w => w.length > 0);
|
|
324
|
+
const pathInfos = [];
|
|
325
|
+
let currentX = 0;
|
|
326
|
+
let contourIndex = 0;
|
|
327
|
+
// Track overall bounds
|
|
328
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
329
|
+
words.forEach((word, wordIndex) => {
|
|
330
|
+
// Get path for this word
|
|
331
|
+
const wordPath = font.getPath(word, currentX, fontSize, fontSize);
|
|
332
|
+
const pathData = wordPath.toPathData(2);
|
|
333
|
+
const contours = splitPathIntoContours(pathData);
|
|
334
|
+
// Add contours with word index
|
|
335
|
+
contours.forEach((d) => {
|
|
336
|
+
pathInfos.push({
|
|
337
|
+
d,
|
|
338
|
+
id: `contour-${contourIndex++}`,
|
|
339
|
+
length: estimatePathLength(d),
|
|
340
|
+
endpoints: getPathEndpoints(d),
|
|
341
|
+
wordIndex,
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
// Update bounds
|
|
345
|
+
const bbox = wordPath.getBoundingBox();
|
|
346
|
+
minX = Math.min(minX, bbox.x1);
|
|
347
|
+
minY = Math.min(minY, bbox.y1);
|
|
348
|
+
maxX = Math.max(maxX, bbox.x2);
|
|
349
|
+
maxY = Math.max(maxY, bbox.y2);
|
|
350
|
+
// Advance X position for next word (add space width)
|
|
351
|
+
const spaceWidth = font.getAdvanceWidth(' ', fontSize);
|
|
352
|
+
currentX = bbox.x2 + spaceWidth;
|
|
353
|
+
});
|
|
273
354
|
return {
|
|
274
355
|
paths: pathInfos,
|
|
275
|
-
textWidth:
|
|
276
|
-
textHeight:
|
|
356
|
+
textWidth: maxX - minX,
|
|
357
|
+
textHeight: maxY - minY,
|
|
358
|
+
wordCount: words.length,
|
|
277
359
|
};
|
|
278
360
|
}, [font, text, fontSize]);
|
|
279
361
|
// Calculate centering offset
|
|
@@ -294,17 +376,71 @@ const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600, height
|
|
|
294
376
|
y: (random() - 0.5) * 100,
|
|
295
377
|
}));
|
|
296
378
|
}, [resolvedPaths]);
|
|
297
|
-
//
|
|
379
|
+
// Calculate dots per contour based on path length
|
|
380
|
+
const dotsPerContour = (0, react_1.useMemo)(() => {
|
|
381
|
+
return resolvedPaths.map(path => {
|
|
382
|
+
const rawCount = Math.round(path.length / dotsPerPathUnit);
|
|
383
|
+
const bounded = maxDotsPerContour > 0
|
|
384
|
+
? Math.min(rawCount, maxDotsPerContour)
|
|
385
|
+
: rawCount;
|
|
386
|
+
return Math.max(minDotsPerContour, bounded);
|
|
387
|
+
});
|
|
388
|
+
}, [resolvedPaths, dotsPerPathUnit, minDotsPerContour, maxDotsPerContour]);
|
|
389
|
+
const totalDots = (0, react_1.useMemo)(() => {
|
|
390
|
+
return dotsPerContour.reduce((a, b) => a + b, 0);
|
|
391
|
+
}, [dotsPerContour]);
|
|
392
|
+
// Count dots per word for chart generation
|
|
393
|
+
const dotsPerWord = (0, react_1.useMemo)(() => {
|
|
394
|
+
const counts = [];
|
|
395
|
+
resolvedPaths.forEach((path, contourIndex) => {
|
|
396
|
+
const wordIdx = path.wordIndex;
|
|
397
|
+
while (counts.length <= wordIdx)
|
|
398
|
+
counts.push(0);
|
|
399
|
+
counts[wordIdx] += dotsPerContour[contourIndex];
|
|
400
|
+
});
|
|
401
|
+
return counts;
|
|
402
|
+
}, [resolvedPaths, dotsPerContour]);
|
|
403
|
+
// Generate chart positions per word - each word gets its own full-width line
|
|
298
404
|
const chartPositions = (0, react_1.useMemo)(() => {
|
|
299
405
|
if (!shouldShowChartIntro)
|
|
300
406
|
return [];
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
407
|
+
const allPositions = [];
|
|
408
|
+
dotsPerWord.forEach((numDots, wordIndex) => {
|
|
409
|
+
if (numDots === 0)
|
|
410
|
+
return;
|
|
411
|
+
// Each word gets its own time-series spanning the full width
|
|
412
|
+
// Use different seed per word for variation
|
|
413
|
+
const wordPositions = generateChartPositions(numDots, width, height, chartPattern, 123 + wordIndex * 1000);
|
|
414
|
+
wordPositions.forEach(pos => {
|
|
415
|
+
allPositions.push(Object.assign(Object.assign({}, pos), { wordIndex }));
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
return allPositions;
|
|
419
|
+
}, [shouldShowChartIntro, dotsPerWord, width, height, chartPattern]);
|
|
420
|
+
// Target positions for dots after chart phase - sampled along each contour
|
|
305
421
|
const dotTargetPositions = (0, react_1.useMemo)(() => {
|
|
306
|
-
|
|
307
|
-
|
|
422
|
+
// Group by word first to match chart positions order
|
|
423
|
+
const positionsByWord = [];
|
|
424
|
+
resolvedPaths.forEach((path, contourIndex) => {
|
|
425
|
+
const wordIdx = path.wordIndex;
|
|
426
|
+
while (positionsByWord.length <= wordIdx)
|
|
427
|
+
positionsByWord.push([]);
|
|
428
|
+
const numDots = dotsPerContour[contourIndex];
|
|
429
|
+
const sampledPoints = samplePointsAlongPath(path.d, numDots, dotDistribution, contourIndex * 1000);
|
|
430
|
+
sampledPoints.forEach(point => {
|
|
431
|
+
positionsByWord[wordIdx].push(Object.assign(Object.assign({}, point), { contourIndex, wordIndex: wordIdx }));
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
// Flatten in word order to match chartPositions
|
|
435
|
+
return positionsByWord.flat();
|
|
436
|
+
}, [resolvedPaths, dotsPerContour, dotDistribution]);
|
|
437
|
+
// Helper to get color for a word index
|
|
438
|
+
const getWordColor = (wordIndex) => {
|
|
439
|
+
if (wordColors && wordColors.length > 0) {
|
|
440
|
+
return wordColors[wordIndex % wordColors.length];
|
|
441
|
+
}
|
|
442
|
+
return color;
|
|
443
|
+
};
|
|
308
444
|
// Chart intro timing
|
|
309
445
|
const chartPhaseEndTime = shouldShowChartIntro
|
|
310
446
|
? chartDuration + chartLineFadeDuration + chartPauseDuration + chartTransitionDuration
|
|
@@ -353,9 +489,19 @@ const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600, height
|
|
|
353
489
|
react_1.default.createElement("stop", { offset: "0%", style: { stopColor: finalParticleColor, stopOpacity: 1 } }),
|
|
354
490
|
react_1.default.createElement("stop", { offset: "100%", style: { stopColor: finalParticleColor, stopOpacity: 0.3 } }))),
|
|
355
491
|
shouldShowChartIntro && chartPositions.length > 0 && (react_1.default.createElement("g", { className: "chart-intro" },
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
492
|
+
(() => {
|
|
493
|
+
// Group chart positions by word (chartPositions now has wordIndex)
|
|
494
|
+
const positionsByWord = new Map();
|
|
495
|
+
chartPositions.forEach((pos) => {
|
|
496
|
+
if (!positionsByWord.has(pos.wordIndex)) {
|
|
497
|
+
positionsByWord.set(pos.wordIndex, []);
|
|
498
|
+
}
|
|
499
|
+
positionsByWord.get(pos.wordIndex).push({ x: pos.x, y: pos.y });
|
|
500
|
+
});
|
|
501
|
+
return Array.from(positionsByWord.entries()).map(([wordIndex, positions]) => (react_1.default.createElement("polyline", { key: `chart-line-${wordIndex}`, points: positions.map(p => `${p.x},${p.y}`).join(' '), fill: "none", stroke: getWordColor(wordIndex), strokeWidth: strokeWidth * 0.75, strokeLinecap: "round", strokeLinejoin: "round", opacity: "0" },
|
|
502
|
+
react_1.default.createElement("animate", { attributeName: "opacity", from: "0", to: "0.6", dur: "0.3s", begin: "0s", fill: "freeze" }),
|
|
503
|
+
react_1.default.createElement("animate", { attributeName: "opacity", from: "0.6", to: "0", dur: `${chartLineFadeDuration}s`, begin: `${chartDuration}s`, fill: "freeze" }))));
|
|
504
|
+
})(),
|
|
359
505
|
chartPositions.map((chartPos, index) => {
|
|
360
506
|
const targetPos = dotTargetPositions[index];
|
|
361
507
|
if (!targetPos)
|
|
@@ -363,20 +509,24 @@ const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600, height
|
|
|
363
509
|
const dotRadius = strokeWidth;
|
|
364
510
|
const dotAppearDelay = (index / chartPositions.length) * 0.5; // Stagger appearance
|
|
365
511
|
const transitionBegin = chartDuration + chartLineFadeDuration + chartPauseDuration;
|
|
366
|
-
|
|
512
|
+
// Fade out when this dot's contour starts drawing
|
|
513
|
+
const contourDrawBegin = dotsPhaseEnd + (targetPos.contourIndex + 1) * perItemLinesDuration;
|
|
514
|
+
const dotColor = getWordColor(chartPos.wordIndex);
|
|
515
|
+
return (react_1.default.createElement("circle", { key: `chart-dot-${index}`, cx: chartPos.x, cy: chartPos.y, r: dotRadius, fill: dotColor, opacity: "0" },
|
|
367
516
|
react_1.default.createElement("animate", { attributeName: "opacity", from: "0", to: "1", dur: "0.15s", begin: `${dotAppearDelay}s`, fill: "freeze" }),
|
|
368
517
|
react_1.default.createElement("animate", { attributeName: "cx", from: chartPos.x, to: targetPos.x, dur: `${chartTransitionDuration}s`, begin: `${transitionBegin}s`, fill: "freeze", calcMode: "spline", keySplines: "0.4 0 0.2 1", keyTimes: "0;1" }),
|
|
369
518
|
react_1.default.createElement("animate", { attributeName: "cy", from: chartPos.y, to: targetPos.y, dur: `${chartTransitionDuration}s`, begin: `${transitionBegin}s`, fill: "freeze", calcMode: "spline", keySplines: "0.4 0 0.2 1", keyTimes: "0;1" }),
|
|
370
|
-
react_1.default.createElement("animate", { attributeName: "opacity", from: "1", to: "0", dur: "0.2s", begin: `${
|
|
519
|
+
react_1.default.createElement("animate", { attributeName: "opacity", from: "1", to: "0", dur: "0.2s", begin: `${contourDrawBegin}s`, fill: "freeze" })));
|
|
371
520
|
}))),
|
|
372
521
|
react_1.default.createElement("g", { className: "opentype-paths" }, resolvedPaths.map((path, index) => {
|
|
373
522
|
const offset = fragmentOffsets[index];
|
|
374
523
|
const dotRadius = strokeWidth / 2;
|
|
524
|
+
const pathColor = getWordColor(path.wordIndex);
|
|
375
525
|
// Skip fragment offset when chart intro handles the transition
|
|
376
526
|
const useFragmentOffset = isFragmented && !shouldShowChartIntro;
|
|
377
527
|
return (react_1.default.createElement("g", { key: path.id, transform: useFragmentOffset ? `translate(${offset.x}, ${offset.y})` : undefined },
|
|
378
528
|
isFragmented && !shouldShowChartIntro && (react_1.default.createElement(react_1.default.Fragment, null,
|
|
379
|
-
react_1.default.createElement("circle", { cx: path.endpoints.start.x, cy: path.endpoints.start.y, r: dotRadius, fill:
|
|
529
|
+
react_1.default.createElement("circle", { cx: path.endpoints.start.x, cy: path.endpoints.start.y, r: dotRadius, fill: pathColor, opacity: "0" },
|
|
380
530
|
react_1.default.createElement("animate", { attributeName: "opacity", from: "0", to: "1", dur: "0.15s", begin: `${chartPhaseEndTime + index * perItemDotsDuration}s`, fill: "freeze" }),
|
|
381
531
|
react_1.default.createElement("animate", { attributeName: "opacity", from: "1", to: "0", dur: "0.2s", begin: `${dotsPhaseEnd + (index + 1) * perItemLinesDuration}s`, fill: "freeze" })))),
|
|
382
532
|
(() => {
|
|
@@ -385,7 +535,7 @@ const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600, height
|
|
|
385
535
|
const drawDuration = shouldShowChartIntro && !isFragmented
|
|
386
536
|
? perItemLinesDuration
|
|
387
537
|
: perItemLinesDuration + chaosDuration;
|
|
388
|
-
return (react_1.default.createElement("path", { d: path.d, fill: "none", stroke:
|
|
538
|
+
return (react_1.default.createElement("path", { d: path.d, fill: "none", stroke: pathColor, strokeWidth: strokeWidth, strokeLinecap: "round", strokeLinejoin: "round", opacity: shouldDrawLines ? 0 : 1, strokeDasharray: shouldDrawLines ? path.length : undefined, strokeDashoffset: shouldDrawLines ? path.length : undefined },
|
|
389
539
|
shouldDrawLines && (react_1.default.createElement(react_1.default.Fragment, null,
|
|
390
540
|
react_1.default.createElement("animate", { attributeName: "opacity", values: `0;1;1;${fadeAfterAssembly ? fadeOpacity : 1}`, keyTimes: "0;0.1;0.9;1", dur: `${drawDuration}s`, begin: `${dotsPhaseEnd + index * perItemLinesDuration}s`, fill: "freeze" }),
|
|
391
541
|
react_1.default.createElement("animate", { attributeName: "stroke-dashoffset", from: path.length, to: "0", dur: `${perItemLinesDuration}s`, begin: `${dotsPhaseEnd + index * perItemLinesDuration}s`, fill: "freeze", calcMode: "spline", keySplines: "0.4 0 0.2 1", keyTimes: "0;1" }))),
|
|
@@ -180,7 +180,69 @@ function splitPathIntoContours(d) {
|
|
|
180
180
|
}
|
|
181
181
|
return contours;
|
|
182
182
|
}
|
|
183
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Sample points along an SVG path using browser's native getPointAtLength
|
|
185
|
+
*/
|
|
186
|
+
function samplePointsAlongPath(d, numPoints, distribution, seed) {
|
|
187
|
+
if (numPoints <= 0)
|
|
188
|
+
return [];
|
|
189
|
+
// SSR fallback: estimate points without DOM
|
|
190
|
+
if (typeof document === 'undefined') {
|
|
191
|
+
const endpoints = getPathEndpoints(d);
|
|
192
|
+
const points = [];
|
|
193
|
+
for (let i = 0; i < numPoints; i++) {
|
|
194
|
+
const t = numPoints === 1 ? 0 : i / (numPoints - 1);
|
|
195
|
+
points.push({
|
|
196
|
+
x: endpoints.start.x + (endpoints.end.x - endpoints.start.x) * t,
|
|
197
|
+
y: endpoints.start.y + (endpoints.end.y - endpoints.start.y) * t,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return points;
|
|
201
|
+
}
|
|
202
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
203
|
+
path.setAttribute('d', d);
|
|
204
|
+
const totalLength = path.getTotalLength();
|
|
205
|
+
if (totalLength === 0) {
|
|
206
|
+
const endpoints = getPathEndpoints(d);
|
|
207
|
+
return [endpoints.start];
|
|
208
|
+
}
|
|
209
|
+
// Generate normalized positions (0-1) based on distribution
|
|
210
|
+
const positions = [];
|
|
211
|
+
switch (distribution) {
|
|
212
|
+
case "even":
|
|
213
|
+
for (let i = 0; i < numPoints; i++) {
|
|
214
|
+
positions.push(numPoints === 1 ? 0 : i / (numPoints - 1));
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
case "endpoints":
|
|
218
|
+
// Concentrate more points near 0 and 1
|
|
219
|
+
for (let i = 0; i < numPoints; i++) {
|
|
220
|
+
const t = numPoints === 1 ? 0 : i / (numPoints - 1);
|
|
221
|
+
// Apply curve to cluster at ends: more samples near 0 and 1
|
|
222
|
+
const weighted = t < 0.5
|
|
223
|
+
? 0.5 * Math.pow(2 * t, 0.5)
|
|
224
|
+
: 1 - 0.5 * Math.pow(2 * (1 - t), 0.5);
|
|
225
|
+
positions.push(weighted);
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
case "random": {
|
|
229
|
+
const random = seededRandom(seed);
|
|
230
|
+
for (let i = 0; i < numPoints; i++) {
|
|
231
|
+
positions.push(random());
|
|
232
|
+
}
|
|
233
|
+
positions.sort((a, b) => a - b); // Sort for visual consistency
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Convert positions to actual points
|
|
238
|
+
const points = [];
|
|
239
|
+
for (const t of positions) {
|
|
240
|
+
const point = path.getPointAtLength(t * totalLength);
|
|
241
|
+
points.push({ x: point.x, y: point.y });
|
|
242
|
+
}
|
|
243
|
+
return points;
|
|
244
|
+
}
|
|
245
|
+
export const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600, height = 150, centerText = true, showChartIntro, chartDuration = 1.5, chartLineFadeDuration = 0.5, chartPauseDuration = 0.3, chartTransitionDuration = 0.8, chartPattern = "latency", dotsPerPathUnit = 30, minDotsPerContour = 1, maxDotsPerContour = 0, dotDistribution = "even", chaosMode = "none", chaosDuration = 2, dotsDuration = 1.5, flowDuration = 2, flowDelay = 0.3, particlesPerPath = 1, particleRadius = 2, color = "currentColor", wordColors, particleColor, strokeWidth = 1.5, opacity = 0.9, showGlow = true, fadeAfterAssembly = true, fadeOpacity = 0.5, loop = true, loopDelay = 1, }) => {
|
|
184
246
|
const idRef = useRef(null);
|
|
185
247
|
if (idRef.current === null) {
|
|
186
248
|
idRef.current = `otr${globalIdCounter++}`;
|
|
@@ -213,28 +275,48 @@ export const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600,
|
|
|
213
275
|
cancelled = true;
|
|
214
276
|
};
|
|
215
277
|
}, [fontUrl]);
|
|
216
|
-
// Generate paths from font
|
|
217
|
-
const { paths, textWidth, textHeight } = useMemo(() => {
|
|
278
|
+
// Generate paths from font, tracking word indices
|
|
279
|
+
const { paths, textWidth, textHeight, wordCount } = useMemo(() => {
|
|
218
280
|
if (!font) {
|
|
219
|
-
return { paths: [], textWidth: 0, textHeight: fontSize };
|
|
281
|
+
return { paths: [], textWidth: 0, textHeight: fontSize, wordCount: 0 };
|
|
220
282
|
}
|
|
221
|
-
//
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
283
|
+
// Split text into words and generate paths per word
|
|
284
|
+
const words = text.split(/\s+/).filter(w => w.length > 0);
|
|
285
|
+
const pathInfos = [];
|
|
286
|
+
let currentX = 0;
|
|
287
|
+
let contourIndex = 0;
|
|
288
|
+
// Track overall bounds
|
|
289
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
290
|
+
words.forEach((word, wordIndex) => {
|
|
291
|
+
// Get path for this word
|
|
292
|
+
const wordPath = font.getPath(word, currentX, fontSize, fontSize);
|
|
293
|
+
const pathData = wordPath.toPathData(2);
|
|
294
|
+
const contours = splitPathIntoContours(pathData);
|
|
295
|
+
// Add contours with word index
|
|
296
|
+
contours.forEach((d) => {
|
|
297
|
+
pathInfos.push({
|
|
298
|
+
d,
|
|
299
|
+
id: `contour-${contourIndex++}`,
|
|
300
|
+
length: estimatePathLength(d),
|
|
301
|
+
endpoints: getPathEndpoints(d),
|
|
302
|
+
wordIndex,
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
// Update bounds
|
|
306
|
+
const bbox = wordPath.getBoundingBox();
|
|
307
|
+
minX = Math.min(minX, bbox.x1);
|
|
308
|
+
minY = Math.min(minY, bbox.y1);
|
|
309
|
+
maxX = Math.max(maxX, bbox.x2);
|
|
310
|
+
maxY = Math.max(maxY, bbox.y2);
|
|
311
|
+
// Advance X position for next word (add space width)
|
|
312
|
+
const spaceWidth = font.getAdvanceWidth(' ', fontSize);
|
|
313
|
+
currentX = bbox.x2 + spaceWidth;
|
|
314
|
+
});
|
|
234
315
|
return {
|
|
235
316
|
paths: pathInfos,
|
|
236
|
-
textWidth:
|
|
237
|
-
textHeight:
|
|
317
|
+
textWidth: maxX - minX,
|
|
318
|
+
textHeight: maxY - minY,
|
|
319
|
+
wordCount: words.length,
|
|
238
320
|
};
|
|
239
321
|
}, [font, text, fontSize]);
|
|
240
322
|
// Calculate centering offset
|
|
@@ -255,17 +337,71 @@ export const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600,
|
|
|
255
337
|
y: (random() - 0.5) * 100,
|
|
256
338
|
}));
|
|
257
339
|
}, [resolvedPaths]);
|
|
258
|
-
//
|
|
340
|
+
// Calculate dots per contour based on path length
|
|
341
|
+
const dotsPerContour = useMemo(() => {
|
|
342
|
+
return resolvedPaths.map(path => {
|
|
343
|
+
const rawCount = Math.round(path.length / dotsPerPathUnit);
|
|
344
|
+
const bounded = maxDotsPerContour > 0
|
|
345
|
+
? Math.min(rawCount, maxDotsPerContour)
|
|
346
|
+
: rawCount;
|
|
347
|
+
return Math.max(minDotsPerContour, bounded);
|
|
348
|
+
});
|
|
349
|
+
}, [resolvedPaths, dotsPerPathUnit, minDotsPerContour, maxDotsPerContour]);
|
|
350
|
+
const totalDots = useMemo(() => {
|
|
351
|
+
return dotsPerContour.reduce((a, b) => a + b, 0);
|
|
352
|
+
}, [dotsPerContour]);
|
|
353
|
+
// Count dots per word for chart generation
|
|
354
|
+
const dotsPerWord = useMemo(() => {
|
|
355
|
+
const counts = [];
|
|
356
|
+
resolvedPaths.forEach((path, contourIndex) => {
|
|
357
|
+
const wordIdx = path.wordIndex;
|
|
358
|
+
while (counts.length <= wordIdx)
|
|
359
|
+
counts.push(0);
|
|
360
|
+
counts[wordIdx] += dotsPerContour[contourIndex];
|
|
361
|
+
});
|
|
362
|
+
return counts;
|
|
363
|
+
}, [resolvedPaths, dotsPerContour]);
|
|
364
|
+
// Generate chart positions per word - each word gets its own full-width line
|
|
259
365
|
const chartPositions = useMemo(() => {
|
|
260
366
|
if (!shouldShowChartIntro)
|
|
261
367
|
return [];
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
368
|
+
const allPositions = [];
|
|
369
|
+
dotsPerWord.forEach((numDots, wordIndex) => {
|
|
370
|
+
if (numDots === 0)
|
|
371
|
+
return;
|
|
372
|
+
// Each word gets its own time-series spanning the full width
|
|
373
|
+
// Use different seed per word for variation
|
|
374
|
+
const wordPositions = generateChartPositions(numDots, width, height, chartPattern, 123 + wordIndex * 1000);
|
|
375
|
+
wordPositions.forEach(pos => {
|
|
376
|
+
allPositions.push(Object.assign(Object.assign({}, pos), { wordIndex }));
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
return allPositions;
|
|
380
|
+
}, [shouldShowChartIntro, dotsPerWord, width, height, chartPattern]);
|
|
381
|
+
// Target positions for dots after chart phase - sampled along each contour
|
|
266
382
|
const dotTargetPositions = useMemo(() => {
|
|
267
|
-
|
|
268
|
-
|
|
383
|
+
// Group by word first to match chart positions order
|
|
384
|
+
const positionsByWord = [];
|
|
385
|
+
resolvedPaths.forEach((path, contourIndex) => {
|
|
386
|
+
const wordIdx = path.wordIndex;
|
|
387
|
+
while (positionsByWord.length <= wordIdx)
|
|
388
|
+
positionsByWord.push([]);
|
|
389
|
+
const numDots = dotsPerContour[contourIndex];
|
|
390
|
+
const sampledPoints = samplePointsAlongPath(path.d, numDots, dotDistribution, contourIndex * 1000);
|
|
391
|
+
sampledPoints.forEach(point => {
|
|
392
|
+
positionsByWord[wordIdx].push(Object.assign(Object.assign({}, point), { contourIndex, wordIndex: wordIdx }));
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
// Flatten in word order to match chartPositions
|
|
396
|
+
return positionsByWord.flat();
|
|
397
|
+
}, [resolvedPaths, dotsPerContour, dotDistribution]);
|
|
398
|
+
// Helper to get color for a word index
|
|
399
|
+
const getWordColor = (wordIndex) => {
|
|
400
|
+
if (wordColors && wordColors.length > 0) {
|
|
401
|
+
return wordColors[wordIndex % wordColors.length];
|
|
402
|
+
}
|
|
403
|
+
return color;
|
|
404
|
+
};
|
|
269
405
|
// Chart intro timing
|
|
270
406
|
const chartPhaseEndTime = shouldShowChartIntro
|
|
271
407
|
? chartDuration + chartLineFadeDuration + chartPauseDuration + chartTransitionDuration
|
|
@@ -314,9 +450,19 @@ export const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600,
|
|
|
314
450
|
React.createElement("stop", { offset: "0%", style: { stopColor: finalParticleColor, stopOpacity: 1 } }),
|
|
315
451
|
React.createElement("stop", { offset: "100%", style: { stopColor: finalParticleColor, stopOpacity: 0.3 } }))),
|
|
316
452
|
shouldShowChartIntro && chartPositions.length > 0 && (React.createElement("g", { className: "chart-intro" },
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
453
|
+
(() => {
|
|
454
|
+
// Group chart positions by word (chartPositions now has wordIndex)
|
|
455
|
+
const positionsByWord = new Map();
|
|
456
|
+
chartPositions.forEach((pos) => {
|
|
457
|
+
if (!positionsByWord.has(pos.wordIndex)) {
|
|
458
|
+
positionsByWord.set(pos.wordIndex, []);
|
|
459
|
+
}
|
|
460
|
+
positionsByWord.get(pos.wordIndex).push({ x: pos.x, y: pos.y });
|
|
461
|
+
});
|
|
462
|
+
return Array.from(positionsByWord.entries()).map(([wordIndex, positions]) => (React.createElement("polyline", { key: `chart-line-${wordIndex}`, points: positions.map(p => `${p.x},${p.y}`).join(' '), fill: "none", stroke: getWordColor(wordIndex), strokeWidth: strokeWidth * 0.75, strokeLinecap: "round", strokeLinejoin: "round", opacity: "0" },
|
|
463
|
+
React.createElement("animate", { attributeName: "opacity", from: "0", to: "0.6", dur: "0.3s", begin: "0s", fill: "freeze" }),
|
|
464
|
+
React.createElement("animate", { attributeName: "opacity", from: "0.6", to: "0", dur: `${chartLineFadeDuration}s`, begin: `${chartDuration}s`, fill: "freeze" }))));
|
|
465
|
+
})(),
|
|
320
466
|
chartPositions.map((chartPos, index) => {
|
|
321
467
|
const targetPos = dotTargetPositions[index];
|
|
322
468
|
if (!targetPos)
|
|
@@ -324,20 +470,24 @@ export const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600,
|
|
|
324
470
|
const dotRadius = strokeWidth;
|
|
325
471
|
const dotAppearDelay = (index / chartPositions.length) * 0.5; // Stagger appearance
|
|
326
472
|
const transitionBegin = chartDuration + chartLineFadeDuration + chartPauseDuration;
|
|
327
|
-
|
|
473
|
+
// Fade out when this dot's contour starts drawing
|
|
474
|
+
const contourDrawBegin = dotsPhaseEnd + (targetPos.contourIndex + 1) * perItemLinesDuration;
|
|
475
|
+
const dotColor = getWordColor(chartPos.wordIndex);
|
|
476
|
+
return (React.createElement("circle", { key: `chart-dot-${index}`, cx: chartPos.x, cy: chartPos.y, r: dotRadius, fill: dotColor, opacity: "0" },
|
|
328
477
|
React.createElement("animate", { attributeName: "opacity", from: "0", to: "1", dur: "0.15s", begin: `${dotAppearDelay}s`, fill: "freeze" }),
|
|
329
478
|
React.createElement("animate", { attributeName: "cx", from: chartPos.x, to: targetPos.x, dur: `${chartTransitionDuration}s`, begin: `${transitionBegin}s`, fill: "freeze", calcMode: "spline", keySplines: "0.4 0 0.2 1", keyTimes: "0;1" }),
|
|
330
479
|
React.createElement("animate", { attributeName: "cy", from: chartPos.y, to: targetPos.y, dur: `${chartTransitionDuration}s`, begin: `${transitionBegin}s`, fill: "freeze", calcMode: "spline", keySplines: "0.4 0 0.2 1", keyTimes: "0;1" }),
|
|
331
|
-
React.createElement("animate", { attributeName: "opacity", from: "1", to: "0", dur: "0.2s", begin: `${
|
|
480
|
+
React.createElement("animate", { attributeName: "opacity", from: "1", to: "0", dur: "0.2s", begin: `${contourDrawBegin}s`, fill: "freeze" })));
|
|
332
481
|
}))),
|
|
333
482
|
React.createElement("g", { className: "opentype-paths" }, resolvedPaths.map((path, index) => {
|
|
334
483
|
const offset = fragmentOffsets[index];
|
|
335
484
|
const dotRadius = strokeWidth / 2;
|
|
485
|
+
const pathColor = getWordColor(path.wordIndex);
|
|
336
486
|
// Skip fragment offset when chart intro handles the transition
|
|
337
487
|
const useFragmentOffset = isFragmented && !shouldShowChartIntro;
|
|
338
488
|
return (React.createElement("g", { key: path.id, transform: useFragmentOffset ? `translate(${offset.x}, ${offset.y})` : undefined },
|
|
339
489
|
isFragmented && !shouldShowChartIntro && (React.createElement(React.Fragment, null,
|
|
340
|
-
React.createElement("circle", { cx: path.endpoints.start.x, cy: path.endpoints.start.y, r: dotRadius, fill:
|
|
490
|
+
React.createElement("circle", { cx: path.endpoints.start.x, cy: path.endpoints.start.y, r: dotRadius, fill: pathColor, opacity: "0" },
|
|
341
491
|
React.createElement("animate", { attributeName: "opacity", from: "0", to: "1", dur: "0.15s", begin: `${chartPhaseEndTime + index * perItemDotsDuration}s`, fill: "freeze" }),
|
|
342
492
|
React.createElement("animate", { attributeName: "opacity", from: "1", to: "0", dur: "0.2s", begin: `${dotsPhaseEnd + (index + 1) * perItemLinesDuration}s`, fill: "freeze" })))),
|
|
343
493
|
(() => {
|
|
@@ -346,7 +496,7 @@ export const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600,
|
|
|
346
496
|
const drawDuration = shouldShowChartIntro && !isFragmented
|
|
347
497
|
? perItemLinesDuration
|
|
348
498
|
: perItemLinesDuration + chaosDuration;
|
|
349
|
-
return (React.createElement("path", { d: path.d, fill: "none", stroke:
|
|
499
|
+
return (React.createElement("path", { d: path.d, fill: "none", stroke: pathColor, strokeWidth: strokeWidth, strokeLinecap: "round", strokeLinejoin: "round", opacity: shouldDrawLines ? 0 : 1, strokeDasharray: shouldDrawLines ? path.length : undefined, strokeDashoffset: shouldDrawLines ? path.length : undefined },
|
|
350
500
|
shouldDrawLines && (React.createElement(React.Fragment, null,
|
|
351
501
|
React.createElement("animate", { attributeName: "opacity", values: `0;1;1;${fadeAfterAssembly ? fadeOpacity : 1}`, keyTimes: "0;0.1;0.9;1", dur: `${drawDuration}s`, begin: `${dotsPhaseEnd + index * perItemLinesDuration}s`, fill: "freeze" }),
|
|
352
502
|
React.createElement("animate", { attributeName: "stroke-dashoffset", from: path.length, to: "0", dur: `${perItemLinesDuration}s`, begin: `${dotsPhaseEnd + index * perItemLinesDuration}s`, fill: "freeze", calcMode: "spline", keySplines: "0.4 0 0.2 1", keyTimes: "0;1" }))),
|