@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.
@@ -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
- 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", chaosMode = "none", chaosDuration = 2, dotsDuration = 1.5, flowDuration = 2, flowDelay = 0.3, particlesPerPath = 1, particleRadius = 2, color = "currentColor", particleColor, strokeWidth = 1.5, opacity = 0.9, showGlow = true, fadeAfterAssembly = true, fadeOpacity = 0.5, loop = true, loopDelay = 1, }) => {
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
- // Get the path for the text
261
- const textPath = font.getPath(text, 0, fontSize, fontSize);
262
- const pathData = textPath.toPathData(2); // 2 decimal places
263
- // Split into individual contours (each letter shape)
264
- const contours = splitPathIntoContours(pathData);
265
- const pathInfos = contours.map((d, index) => ({
266
- d,
267
- id: `contour-${index}`,
268
- length: estimatePathLength(d),
269
- endpoints: getPathEndpoints(d),
270
- }));
271
- // Calculate bounds
272
- const bbox = textPath.getBoundingBox();
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: bbox.x2 - bbox.x1,
276
- textHeight: bbox.y2 - bbox.y1,
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
- // Generate chart positions for the intro animation
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
- return generateChartPositions(resolvedPaths.length, width, height, chartPattern, 123);
302
- }, [shouldShowChartIntro, resolvedPaths.length, width, height, chartPattern]);
303
- // Target positions for dots after chart phase
304
- // Always go to final letter positions (path start points)
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
- return resolvedPaths.map((path) => path.endpoints.start);
307
- }, [resolvedPaths]);
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
- react_1.default.createElement("polyline", { points: chartPositions.map(p => `${p.x},${p.y}`).join(' '), fill: "none", stroke: color, strokeWidth: strokeWidth * 0.75, strokeLinecap: "round", strokeLinejoin: "round", opacity: "0" },
357
- react_1.default.createElement("animate", { attributeName: "opacity", from: "0", to: "0.6", dur: "0.3s", begin: "0s", fill: "freeze" }),
358
- react_1.default.createElement("animate", { attributeName: "opacity", from: "0.6", to: "0", dur: `${chartLineFadeDuration}s`, begin: `${chartDuration}s`, fill: "freeze" })),
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
- return (react_1.default.createElement("circle", { key: `chart-dot-${index}`, cx: chartPos.x, cy: chartPos.y, r: dotRadius, fill: color, opacity: "0" },
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: `${dotsPhaseEnd + (index + 1) * perItemLinesDuration}s`, fill: "freeze" })));
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: color, opacity: "0" },
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: color, strokeWidth: strokeWidth, strokeLinecap: "round", strokeLinejoin: "round", opacity: shouldDrawLines ? 0 : 1, strokeDasharray: shouldDrawLines ? path.length : undefined, strokeDashoffset: shouldDrawLines ? path.length : undefined },
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
- 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", chaosMode = "none", chaosDuration = 2, dotsDuration = 1.5, flowDuration = 2, flowDelay = 0.3, particlesPerPath = 1, particleRadius = 2, color = "currentColor", particleColor, strokeWidth = 1.5, opacity = 0.9, showGlow = true, fadeAfterAssembly = true, fadeOpacity = 0.5, loop = true, loopDelay = 1, }) => {
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
- // Get the path for the text
222
- const textPath = font.getPath(text, 0, fontSize, fontSize);
223
- const pathData = textPath.toPathData(2); // 2 decimal places
224
- // Split into individual contours (each letter shape)
225
- const contours = splitPathIntoContours(pathData);
226
- const pathInfos = contours.map((d, index) => ({
227
- d,
228
- id: `contour-${index}`,
229
- length: estimatePathLength(d),
230
- endpoints: getPathEndpoints(d),
231
- }));
232
- // Calculate bounds
233
- const bbox = textPath.getBoundingBox();
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: bbox.x2 - bbox.x1,
237
- textHeight: bbox.y2 - bbox.y1,
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
- // Generate chart positions for the intro animation
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
- return generateChartPositions(resolvedPaths.length, width, height, chartPattern, 123);
263
- }, [shouldShowChartIntro, resolvedPaths.length, width, height, chartPattern]);
264
- // Target positions for dots after chart phase
265
- // Always go to final letter positions (path start points)
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
- return resolvedPaths.map((path) => path.endpoints.start);
268
- }, [resolvedPaths]);
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
- React.createElement("polyline", { points: chartPositions.map(p => `${p.x},${p.y}`).join(' '), fill: "none", stroke: color, strokeWidth: strokeWidth * 0.75, strokeLinecap: "round", strokeLinejoin: "round", opacity: "0" },
318
- React.createElement("animate", { attributeName: "opacity", from: "0", to: "0.6", dur: "0.3s", begin: "0s", fill: "freeze" }),
319
- React.createElement("animate", { attributeName: "opacity", from: "0.6", to: "0", dur: `${chartLineFadeDuration}s`, begin: `${chartDuration}s`, fill: "freeze" })),
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
- return (React.createElement("circle", { key: `chart-dot-${index}`, cx: chartPos.x, cy: chartPos.y, r: dotRadius, fill: color, opacity: "0" },
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: `${dotsPhaseEnd + (index + 1) * perItemLinesDuration}s`, fill: "freeze" })));
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: color, opacity: "0" },
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: color, strokeWidth: strokeWidth, strokeLinecap: "round", strokeLinejoin: "round", opacity: shouldDrawLines ? 0 : 1, strokeDasharray: shouldDrawLines ? path.length : undefined, strokeDashoffset: shouldDrawLines ? path.length : undefined },
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" }))),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principal-ai/logo-component",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Animated wireframe sphere logo component",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.esm.js",