@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.
@@ -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
@@ -339,8 +475,7 @@ const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600, height
339
475
  error)));
340
476
  }
341
477
  if (!font) {
342
- return (react_1.default.createElement("svg", { width: width, height: height },
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
- 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" })),
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
- return (react_1.default.createElement("circle", { key: `chart-dot-${index}`, cx: chartPos.x, cy: chartPos.y, r: dotRadius, fill: color, opacity: "0" },
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: `${dotsPhaseEnd + (index + 1) * perItemLinesDuration}s`, fill: "freeze" })));
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: color, opacity: "0" },
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: color, strokeWidth: strokeWidth, strokeLinecap: "round", strokeLinejoin: "round", opacity: shouldDrawLines ? 0 : 1, strokeDasharray: shouldDrawLines ? path.length : undefined, strokeDashoffset: shouldDrawLines ? path.length : undefined },
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
- 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
@@ -300,8 +436,7 @@ export const OpenTypeTextReveal = ({ text, fontUrl, fontSize = 72, width = 600,
300
436
  error)));
301
437
  }
302
438
  if (!font) {
303
- return (React.createElement("svg", { width: width, height: height },
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
- 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" })),
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
- return (React.createElement("circle", { key: `chart-dot-${index}`, cx: chartPos.x, cy: chartPos.y, r: dotRadius, fill: color, opacity: "0" },
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: `${dotsPhaseEnd + (index + 1) * perItemLinesDuration}s`, fill: "freeze" })));
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: color, opacity: "0" },
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: color, strokeWidth: strokeWidth, strokeLinecap: "round", strokeLinejoin: "round", opacity: shouldDrawLines ? 0 : 1, strokeDasharray: shouldDrawLines ? path.length : undefined, strokeDashoffset: shouldDrawLines ? path.length : undefined },
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" }))),
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.8",
4
4
  "description": "Animated wireframe sphere logo component",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.esm.js",