@principal-ai/logo-component 0.1.6 → 0.1.8
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 +184 -35
- package/dist/esm/OpenTypeTextReveal.js +184 -35
- 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
|
|
@@ -339,8 +475,7 @@ const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600, height
|
|
|
339
475
|
error)));
|
|
340
476
|
}
|
|
341
477
|
if (!font) {
|
|
342
|
-
return
|
|
343
|
-
react_1.default.createElement("text", { x: 10, y: 30, fill: color, fontSize: 14 }, "Loading font...")));
|
|
478
|
+
return null;
|
|
344
479
|
}
|
|
345
480
|
return (react_1.default.createElement("svg", { key: cycle, width: width, height: height, viewBox: `0 0 ${width} ${height}`, xmlns: "http://www.w3.org/2000/svg", style: { opacity } },
|
|
346
481
|
react_1.default.createElement("defs", null,
|
|
@@ -353,9 +488,19 @@ const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600, height
|
|
|
353
488
|
react_1.default.createElement("stop", { offset: "0%", style: { stopColor: finalParticleColor, stopOpacity: 1 } }),
|
|
354
489
|
react_1.default.createElement("stop", { offset: "100%", style: { stopColor: finalParticleColor, stopOpacity: 0.3 } }))),
|
|
355
490
|
shouldShowChartIntro && chartPositions.length > 0 && (react_1.default.createElement("g", { className: "chart-intro" },
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
491
|
+
(() => {
|
|
492
|
+
// Group chart positions by word (chartPositions now has wordIndex)
|
|
493
|
+
const positionsByWord = new Map();
|
|
494
|
+
chartPositions.forEach((pos) => {
|
|
495
|
+
if (!positionsByWord.has(pos.wordIndex)) {
|
|
496
|
+
positionsByWord.set(pos.wordIndex, []);
|
|
497
|
+
}
|
|
498
|
+
positionsByWord.get(pos.wordIndex).push({ x: pos.x, y: pos.y });
|
|
499
|
+
});
|
|
500
|
+
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" },
|
|
501
|
+
react_1.default.createElement("animate", { attributeName: "opacity", from: "0", to: "0.6", dur: "0.3s", begin: "0s", fill: "freeze" }),
|
|
502
|
+
react_1.default.createElement("animate", { attributeName: "opacity", from: "0.6", to: "0", dur: `${chartLineFadeDuration}s`, begin: `${chartDuration}s`, fill: "freeze" }))));
|
|
503
|
+
})(),
|
|
359
504
|
chartPositions.map((chartPos, index) => {
|
|
360
505
|
const targetPos = dotTargetPositions[index];
|
|
361
506
|
if (!targetPos)
|
|
@@ -363,20 +508,24 @@ const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600, height
|
|
|
363
508
|
const dotRadius = strokeWidth;
|
|
364
509
|
const dotAppearDelay = (index / chartPositions.length) * 0.5; // Stagger appearance
|
|
365
510
|
const transitionBegin = chartDuration + chartLineFadeDuration + chartPauseDuration;
|
|
366
|
-
|
|
511
|
+
// Fade out when this dot's contour starts drawing
|
|
512
|
+
const contourDrawBegin = dotsPhaseEnd + (targetPos.contourIndex + 1) * perItemLinesDuration;
|
|
513
|
+
const dotColor = getWordColor(chartPos.wordIndex);
|
|
514
|
+
return (react_1.default.createElement("circle", { key: `chart-dot-${index}`, cx: chartPos.x, cy: chartPos.y, r: dotRadius, fill: dotColor, opacity: "0" },
|
|
367
515
|
react_1.default.createElement("animate", { attributeName: "opacity", from: "0", to: "1", dur: "0.15s", begin: `${dotAppearDelay}s`, fill: "freeze" }),
|
|
368
516
|
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
517
|
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: `${
|
|
518
|
+
react_1.default.createElement("animate", { attributeName: "opacity", from: "1", to: "0", dur: "0.2s", begin: `${contourDrawBegin}s`, fill: "freeze" })));
|
|
371
519
|
}))),
|
|
372
520
|
react_1.default.createElement("g", { className: "opentype-paths" }, resolvedPaths.map((path, index) => {
|
|
373
521
|
const offset = fragmentOffsets[index];
|
|
374
522
|
const dotRadius = strokeWidth / 2;
|
|
523
|
+
const pathColor = getWordColor(path.wordIndex);
|
|
375
524
|
// Skip fragment offset when chart intro handles the transition
|
|
376
525
|
const useFragmentOffset = isFragmented && !shouldShowChartIntro;
|
|
377
526
|
return (react_1.default.createElement("g", { key: path.id, transform: useFragmentOffset ? `translate(${offset.x}, ${offset.y})` : undefined },
|
|
378
527
|
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:
|
|
528
|
+
react_1.default.createElement("circle", { cx: path.endpoints.start.x, cy: path.endpoints.start.y, r: dotRadius, fill: pathColor, opacity: "0" },
|
|
380
529
|
react_1.default.createElement("animate", { attributeName: "opacity", from: "0", to: "1", dur: "0.15s", begin: `${chartPhaseEndTime + index * perItemDotsDuration}s`, fill: "freeze" }),
|
|
381
530
|
react_1.default.createElement("animate", { attributeName: "opacity", from: "1", to: "0", dur: "0.2s", begin: `${dotsPhaseEnd + (index + 1) * perItemLinesDuration}s`, fill: "freeze" })))),
|
|
382
531
|
(() => {
|
|
@@ -385,7 +534,7 @@ const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600, height
|
|
|
385
534
|
const drawDuration = shouldShowChartIntro && !isFragmented
|
|
386
535
|
? perItemLinesDuration
|
|
387
536
|
: perItemLinesDuration + chaosDuration;
|
|
388
|
-
return (react_1.default.createElement("path", { d: path.d, fill: "none", stroke:
|
|
537
|
+
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
538
|
shouldDrawLines && (react_1.default.createElement(react_1.default.Fragment, null,
|
|
390
539
|
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
540
|
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
|
|
@@ -300,8 +436,7 @@ export const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600,
|
|
|
300
436
|
error)));
|
|
301
437
|
}
|
|
302
438
|
if (!font) {
|
|
303
|
-
return
|
|
304
|
-
React.createElement("text", { x: 10, y: 30, fill: color, fontSize: 14 }, "Loading font...")));
|
|
439
|
+
return null;
|
|
305
440
|
}
|
|
306
441
|
return (React.createElement("svg", { key: cycle, width: width, height: height, viewBox: `0 0 ${width} ${height}`, xmlns: "http://www.w3.org/2000/svg", style: { opacity } },
|
|
307
442
|
React.createElement("defs", null,
|
|
@@ -314,9 +449,19 @@ export const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600,
|
|
|
314
449
|
React.createElement("stop", { offset: "0%", style: { stopColor: finalParticleColor, stopOpacity: 1 } }),
|
|
315
450
|
React.createElement("stop", { offset: "100%", style: { stopColor: finalParticleColor, stopOpacity: 0.3 } }))),
|
|
316
451
|
shouldShowChartIntro && chartPositions.length > 0 && (React.createElement("g", { className: "chart-intro" },
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
452
|
+
(() => {
|
|
453
|
+
// Group chart positions by word (chartPositions now has wordIndex)
|
|
454
|
+
const positionsByWord = new Map();
|
|
455
|
+
chartPositions.forEach((pos) => {
|
|
456
|
+
if (!positionsByWord.has(pos.wordIndex)) {
|
|
457
|
+
positionsByWord.set(pos.wordIndex, []);
|
|
458
|
+
}
|
|
459
|
+
positionsByWord.get(pos.wordIndex).push({ x: pos.x, y: pos.y });
|
|
460
|
+
});
|
|
461
|
+
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" },
|
|
462
|
+
React.createElement("animate", { attributeName: "opacity", from: "0", to: "0.6", dur: "0.3s", begin: "0s", fill: "freeze" }),
|
|
463
|
+
React.createElement("animate", { attributeName: "opacity", from: "0.6", to: "0", dur: `${chartLineFadeDuration}s`, begin: `${chartDuration}s`, fill: "freeze" }))));
|
|
464
|
+
})(),
|
|
320
465
|
chartPositions.map((chartPos, index) => {
|
|
321
466
|
const targetPos = dotTargetPositions[index];
|
|
322
467
|
if (!targetPos)
|
|
@@ -324,20 +469,24 @@ export const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600,
|
|
|
324
469
|
const dotRadius = strokeWidth;
|
|
325
470
|
const dotAppearDelay = (index / chartPositions.length) * 0.5; // Stagger appearance
|
|
326
471
|
const transitionBegin = chartDuration + chartLineFadeDuration + chartPauseDuration;
|
|
327
|
-
|
|
472
|
+
// Fade out when this dot's contour starts drawing
|
|
473
|
+
const contourDrawBegin = dotsPhaseEnd + (targetPos.contourIndex + 1) * perItemLinesDuration;
|
|
474
|
+
const dotColor = getWordColor(chartPos.wordIndex);
|
|
475
|
+
return (React.createElement("circle", { key: `chart-dot-${index}`, cx: chartPos.x, cy: chartPos.y, r: dotRadius, fill: dotColor, opacity: "0" },
|
|
328
476
|
React.createElement("animate", { attributeName: "opacity", from: "0", to: "1", dur: "0.15s", begin: `${dotAppearDelay}s`, fill: "freeze" }),
|
|
329
477
|
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
478
|
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: `${
|
|
479
|
+
React.createElement("animate", { attributeName: "opacity", from: "1", to: "0", dur: "0.2s", begin: `${contourDrawBegin}s`, fill: "freeze" })));
|
|
332
480
|
}))),
|
|
333
481
|
React.createElement("g", { className: "opentype-paths" }, resolvedPaths.map((path, index) => {
|
|
334
482
|
const offset = fragmentOffsets[index];
|
|
335
483
|
const dotRadius = strokeWidth / 2;
|
|
484
|
+
const pathColor = getWordColor(path.wordIndex);
|
|
336
485
|
// Skip fragment offset when chart intro handles the transition
|
|
337
486
|
const useFragmentOffset = isFragmented && !shouldShowChartIntro;
|
|
338
487
|
return (React.createElement("g", { key: path.id, transform: useFragmentOffset ? `translate(${offset.x}, ${offset.y})` : undefined },
|
|
339
488
|
isFragmented && !shouldShowChartIntro && (React.createElement(React.Fragment, null,
|
|
340
|
-
React.createElement("circle", { cx: path.endpoints.start.x, cy: path.endpoints.start.y, r: dotRadius, fill:
|
|
489
|
+
React.createElement("circle", { cx: path.endpoints.start.x, cy: path.endpoints.start.y, r: dotRadius, fill: pathColor, opacity: "0" },
|
|
341
490
|
React.createElement("animate", { attributeName: "opacity", from: "0", to: "1", dur: "0.15s", begin: `${chartPhaseEndTime + index * perItemDotsDuration}s`, fill: "freeze" }),
|
|
342
491
|
React.createElement("animate", { attributeName: "opacity", from: "1", to: "0", dur: "0.2s", begin: `${dotsPhaseEnd + (index + 1) * perItemLinesDuration}s`, fill: "freeze" })))),
|
|
343
492
|
(() => {
|
|
@@ -346,7 +495,7 @@ export const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600,
|
|
|
346
495
|
const drawDuration = shouldShowChartIntro && !isFragmented
|
|
347
496
|
? perItemLinesDuration
|
|
348
497
|
: perItemLinesDuration + chaosDuration;
|
|
349
|
-
return (React.createElement("path", { d: path.d, fill: "none", stroke:
|
|
498
|
+
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
499
|
shouldDrawLines && (React.createElement(React.Fragment, null,
|
|
351
500
|
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
501
|
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" }))),
|