beads-map 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +5 -5
  3. package/.next/app-path-routes-manifest.json +1 -1
  4. package/.next/build-manifest.json +3 -3
  5. package/.next/next-minimal-server.js.nft.json +1 -1
  6. package/.next/next-server.js.nft.json +1 -1
  7. package/.next/prerender-manifest.json +1 -1
  8. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/_not-found.html +1 -1
  10. package/.next/server/app/_not-found.rsc +1 -1
  11. package/.next/server/app/api/beads.body +1 -1
  12. package/.next/server/app/index.html +1 -1
  13. package/.next/server/app/index.rsc +2 -2
  14. package/.next/server/app/page.js +3 -3
  15. package/.next/server/app/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app-paths-manifest.json +4 -4
  17. package/.next/server/functions-config-manifest.json +1 -1
  18. package/.next/server/middleware-build-manifest.js +1 -1
  19. package/.next/server/pages/404.html +1 -1
  20. package/.next/server/pages/500.html +1 -1
  21. package/.next/server/pages-manifest.json +1 -1
  22. package/.next/server/server-reference-manifest.json +1 -1
  23. package/.next/static/chunks/app/page-f36cdcae49f1d2af.js +1 -0
  24. package/.next/static/css/a4e34aaaa51183d9.css +3 -0
  25. package/README.md +44 -20
  26. package/app/page.tsx +313 -90
  27. package/bin/beads-map.mjs +9 -3
  28. package/components/AuthButton.tsx +7 -7
  29. package/components/BeadsGraph.tsx +372 -19
  30. package/components/BeadsLogo.tsx +111 -0
  31. package/components/ContextMenu.tsx +180 -0
  32. package/components/DescriptionModal.tsx +64 -0
  33. package/components/NodeDetail.tsx +18 -3
  34. package/package.json +8 -2
  35. package/.next/static/chunks/app/page-49d569c912d5af9d.js +0 -1
  36. package/.next/static/css/10ef08b24212fe36.css +0 -3
  37. /package/.next/static/{99eOjoTtoO32H-c1faxZ5 → YVdbDxCehgqcYmLncYRFB}/_buildManifest.js +0 -0
  38. /package/.next/static/{99eOjoTtoO32H-c1faxZ5 → YVdbDxCehgqcYmLncYRFB}/_ssgManifest.js +0 -0
  39. /package/.next/static/chunks/{945-bf736d0119e7437b.js → 945-3ff1d381a0af1ecd.js} +0 -0
@@ -35,6 +35,8 @@ interface BeadsGraphProps {
35
35
  onBackgroundClick: () => void;
36
36
  onNodeRightClick?: (node: GraphNode, event: MouseEvent) => void;
37
37
  commentedNodeIds?: Map<string, number>;
38
+ claimedNodeAvatars?: Map<string, { avatar?: string; handle: string; claimedAt: string }>;
39
+ onAvatarHover?: (info: { handle: string; avatar?: string; claimedAt: string; x: number; y: number } | null) => void;
38
40
  timelineActive?: boolean;
39
41
  stats?: { total: number; edges: number; prefixes: string[] };
40
42
  }
@@ -85,6 +87,55 @@ function easeOutQuad(t: number): number {
85
87
  return 1 - (1 - t) * (1 - t);
86
88
  }
87
89
 
90
+ // --- Module-level avatar image cache for canvas rendering ---
91
+ const avatarImageCache = new Map<
92
+ string,
93
+ HTMLImageElement | "loading" | "failed"
94
+ >();
95
+
96
+ function getAvatarImage(
97
+ url: string,
98
+ onLoad: () => void
99
+ ): HTMLImageElement | null {
100
+ const cached = avatarImageCache.get(url);
101
+ if (cached === "loading" || cached === "failed") return null;
102
+ if (cached) return cached;
103
+
104
+ avatarImageCache.set(url, "loading");
105
+ const img = new Image();
106
+ img.onload = () => {
107
+ avatarImageCache.set(url, img);
108
+ onLoad();
109
+ };
110
+ img.onerror = () => {
111
+ avatarImageCache.set(url, "failed");
112
+ };
113
+ img.src = url;
114
+ return null;
115
+ }
116
+
117
+ function drawAvatarFallback(
118
+ ctx: CanvasRenderingContext2D,
119
+ x: number,
120
+ y: number,
121
+ radius: number,
122
+ handle: string,
123
+ globalScale: number
124
+ ) {
125
+ ctx.beginPath();
126
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
127
+ ctx.fillStyle = "#e4e4e7"; // zinc-200
128
+ ctx.fill();
129
+
130
+ const letter = handle.replace("@", "").charAt(0).toUpperCase();
131
+ const fontSize = Math.min(7, Math.max(3, radius * 1.3));
132
+ ctx.font = `600 ${fontSize}px 'Inter', system-ui, sans-serif`;
133
+ ctx.textAlign = "center";
134
+ ctx.textBaseline = "middle";
135
+ ctx.fillStyle = "#71717a"; // zinc-500
136
+ ctx.fillText(letter, x, y + 0.3);
137
+ }
138
+
88
139
  /**
89
140
  * Compute connected subgraph via BFS (depth 2) - pure function, no React state
90
141
  */
@@ -150,6 +201,8 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
150
201
  onBackgroundClick,
151
202
  onNodeRightClick,
152
203
  commentedNodeIds,
204
+ claimedNodeAvatars,
205
+ onAvatarHover,
153
206
  timelineActive,
154
207
  stats,
155
208
  }, ref) {
@@ -161,11 +214,21 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
161
214
  const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
162
215
  const initialLayoutApplied = useRef(false);
163
216
 
164
- // Minimap constants
165
- const MINIMAP_W = 160;
166
- const MINIMAP_H = 120;
217
+ // Minimap dimensions (resizable via drag)
218
+ const [minimapSize, setMinimapSize] = useState({ w: 160, h: 120 });
219
+ const MINIMAP_W = minimapSize.w;
220
+ const MINIMAP_H = minimapSize.h;
167
221
  const MINIMAP_PAD = 8; // internal padding so dots aren't clipped at edges
168
222
 
223
+ // Minimap resize drag state
224
+ const minimapDragRef = useRef<{
225
+ edge: "top" | "right" | "top-right";
226
+ startX: number;
227
+ startY: number;
228
+ startW: number;
229
+ startH: number;
230
+ } | null>(null);
231
+
169
232
  // Lazy-load ForceGraph2D on the client (preserves ref forwarding)
170
233
  const [ForceGraph2D, setForceGraph2D] =
171
234
  useState<React.ComponentType<any> | null>(_ForceGraph2DModule);
@@ -192,6 +255,22 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
192
255
  const hoveredNodeRef = useRef<GraphNode | null>(hoveredNode);
193
256
  const connectedNodesRef = useRef<Set<string>>(new Set());
194
257
  const commentedNodeIdsRef = useRef<Map<string, number>>(commentedNodeIds || new Map());
258
+ const claimedNodeAvatarsRef = useRef<Map<string, { avatar?: string; handle: string; claimedAt: string }>>(
259
+ claimedNodeAvatars || new Map()
260
+ );
261
+ // Callback ref for refreshing graph when avatar images finish loading
262
+ const avatarRefreshRef = useRef<() => void>(() => {});
263
+ avatarRefreshRef.current = () => refreshGraph(graphRef);
264
+
265
+ // Ref for avatar hover callback (avoids stale closures in mousemove handler)
266
+ const onAvatarHoverRef = useRef(onAvatarHover);
267
+ onAvatarHoverRef.current = onAvatarHover;
268
+
269
+ // Track which avatar is currently hovered to avoid redundant callbacks
270
+ const hoveredAvatarNodeRef = useRef<string | null>(null);
271
+
272
+ // Ref for current viewNodes (used by mousemove handler to respect epics view)
273
+ const viewNodesRef = useRef<GraphNode[]>(nodes);
195
274
 
196
275
  // Cluster data ref for semantic zoom: maps epic (parent) IDs to their
197
276
  // member node IDs so we can compute centroids and draw cluster labels
@@ -276,6 +355,9 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
276
355
  return { viewNodes: filteredNodes, viewLinks: remappedLinks };
277
356
  }, [nodes, links, viewMode]);
278
357
 
358
+ // Keep viewNodesRef in sync for mousemove avatar hit-testing
359
+ viewNodesRef.current = viewNodes;
360
+
279
361
  // Build cluster info for semantic zoom: group nodes by parent epic.
280
362
  // This is used by onRenderFramePost to draw cluster labels when zoomed out.
281
363
  useEffect(() => {
@@ -367,6 +449,77 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
367
449
  refreshGraph(graphRef);
368
450
  }, [commentedNodeIds]);
369
451
 
452
+ useEffect(() => {
453
+ claimedNodeAvatarsRef.current = claimedNodeAvatars || new Map();
454
+ refreshGraph(graphRef);
455
+ }, [claimedNodeAvatars]);
456
+
457
+ // Avatar hover detection: mousemove on container, hit-test against avatar positions
458
+ useEffect(() => {
459
+ const container = containerRef.current;
460
+ if (!container) return;
461
+
462
+ const handleMouseMove = (e: MouseEvent) => {
463
+ const fg = graphRef.current;
464
+ const cb = onAvatarHoverRef.current;
465
+ if (!fg || !cb) return;
466
+
467
+ const claimedMap = claimedNodeAvatarsRef.current;
468
+ if (claimedMap.size === 0) {
469
+ if (hoveredAvatarNodeRef.current) {
470
+ hoveredAvatarNodeRef.current = null;
471
+ cb(null);
472
+ }
473
+ return;
474
+ }
475
+
476
+ // Convert screen coords to graph coords
477
+ const rect = container.getBoundingClientRect();
478
+ const screenX = e.clientX - rect.left;
479
+ const screenY = e.clientY - rect.top;
480
+ let graphCoords: { x: number; y: number };
481
+ try {
482
+ graphCoords = fg.screen2GraphCoords(screenX, screenY);
483
+ } catch {
484
+ return;
485
+ }
486
+
487
+ // Hit-test against each claimed node's avatar position
488
+ const globalScale = fg.zoom() || 1;
489
+ const avatarRadius = Math.max(4, 10 / globalScale);
490
+
491
+ for (const node of viewNodesRef.current) {
492
+ const n = node as any;
493
+ if (n.x == null || n.y == null) continue;
494
+ const claim = claimedMap.get(node.id);
495
+ if (!claim) continue;
496
+
497
+ const size = getNodeSize(node);
498
+ const avatarX = n.x + size * 0.7;
499
+ const avatarY = n.y + size * 0.7;
500
+ const dx = graphCoords.x - avatarX;
501
+ const dy = graphCoords.y - avatarY;
502
+
503
+ if (dx * dx + dy * dy <= avatarRadius * avatarRadius) {
504
+ if (hoveredAvatarNodeRef.current !== node.id) {
505
+ hoveredAvatarNodeRef.current = node.id;
506
+ cb({ handle: claim.handle, avatar: claim.avatar, claimedAt: claim.claimedAt, x: e.clientX, y: e.clientY });
507
+ }
508
+ return;
509
+ }
510
+ }
511
+
512
+ // No avatar hit
513
+ if (hoveredAvatarNodeRef.current) {
514
+ hoveredAvatarNodeRef.current = null;
515
+ cb(null);
516
+ }
517
+ };
518
+
519
+ container.addEventListener("mousemove", handleMouseMove);
520
+ return () => container.removeEventListener("mousemove", handleMouseMove);
521
+ }, []);
522
+
370
523
  // Track dimensions
371
524
  useEffect(() => {
372
525
  const updateDimensions = () => {
@@ -745,6 +898,67 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
745
898
  ctx.restore();
746
899
  }
747
900
 
901
+ // Claimant avatar — small circular profile picture at bottom-right
902
+ const claimInfo = claimedNodeAvatarsRef.current.get(graphNode.id);
903
+ if (claimInfo) {
904
+ // Constant screen-space size: divide by globalScale so avatar stays
905
+ // roughly the same pixel size regardless of zoom level
906
+ const avatarSize = Math.max(4, 10 / globalScale);
907
+ const avatarX = node.x + animatedSize * 0.7;
908
+ const avatarY = node.y + animatedSize * 0.7;
909
+
910
+ ctx.save();
911
+ ctx.globalAlpha = 1;
912
+
913
+ if (claimInfo.avatar) {
914
+ const img = getAvatarImage(claimInfo.avatar, () =>
915
+ avatarRefreshRef.current()
916
+ );
917
+ if (img) {
918
+ // Clip to circle and draw image
919
+ ctx.save();
920
+ ctx.beginPath();
921
+ ctx.arc(avatarX, avatarY, avatarSize, 0, Math.PI * 2);
922
+ ctx.clip();
923
+ ctx.drawImage(
924
+ img,
925
+ avatarX - avatarSize,
926
+ avatarY - avatarSize,
927
+ avatarSize * 2,
928
+ avatarSize * 2
929
+ );
930
+ ctx.restore();
931
+ } else {
932
+ drawAvatarFallback(
933
+ ctx,
934
+ avatarX,
935
+ avatarY,
936
+ avatarSize,
937
+ claimInfo.handle,
938
+ globalScale
939
+ );
940
+ }
941
+ } else {
942
+ drawAvatarFallback(
943
+ ctx,
944
+ avatarX,
945
+ avatarY,
946
+ avatarSize,
947
+ claimInfo.handle,
948
+ globalScale
949
+ );
950
+ }
951
+
952
+ // White border ring for contrast
953
+ ctx.beginPath();
954
+ ctx.arc(avatarX, avatarY, avatarSize, 0, Math.PI * 2);
955
+ ctx.strokeStyle = "#ffffff";
956
+ ctx.lineWidth = Math.max(0.8, 1.2 / globalScale);
957
+ ctx.stroke();
958
+
959
+ ctx.restore();
960
+ }
961
+
748
962
  ctx.restore();
749
963
  },
750
964
  [] // No dependencies - reads from refs
@@ -921,7 +1135,10 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
921
1135
  const clusters = clustersRef.current;
922
1136
  if (clusters.length === 0) return;
923
1137
 
924
- // Build a fast lookup from node ID to current position
1138
+ // Build a fast lookup from node ID to current LIVE position.
1139
+ // Only use viewNodes (the nodes actually in the simulation) — in epics
1140
+ // view, child nodes are collapsed into parent epics and their positions
1141
+ // are stale/frozen. Using stale positions causes centroids to drift.
925
1142
  const nodeMap = new Map<string, { x: number; y: number }>();
926
1143
  for (const node of viewNodes) {
927
1144
  const n = node as any;
@@ -929,13 +1146,6 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
929
1146
  nodeMap.set(node.id, { x: n.x, y: n.y });
930
1147
  }
931
1148
  }
932
- // Also check the full nodes array (viewNodes may be filtered in epics mode)
933
- for (const node of nodes) {
934
- const n = node as any;
935
- if (!nodeMap.has(node.id) && n.x != null && n.y != null) {
936
- nodeMap.set(node.id, { x: n.x, y: n.y });
937
- }
938
- }
939
1149
 
940
1150
  ctx.save();
941
1151
 
@@ -1178,6 +1388,58 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1178
1388
  }
1179
1389
  ctx.globalAlpha = 1;
1180
1390
 
1391
+ // Draw claimed avatars on minimap
1392
+ const claimedMap = claimedNodeAvatarsRef.current;
1393
+ if (claimedMap.size > 0) {
1394
+ for (const node of viewNodes) {
1395
+ const n = node as any;
1396
+ if (n.x == null || n.y == null) continue;
1397
+ const claim = claimedMap.get(node.id);
1398
+ if (!claim) continue;
1399
+
1400
+ const mx = offsetX + (n.x - xMin) * scale;
1401
+ const my = offsetY + (n.y - yMin) * scale;
1402
+ const r = 5; // fixed pixel radius on minimap
1403
+
1404
+ ctx.save();
1405
+ ctx.globalAlpha = 1;
1406
+
1407
+ if (claim.avatar) {
1408
+ const img = getAvatarImage(claim.avatar, () =>
1409
+ avatarRefreshRef.current()
1410
+ );
1411
+ if (img) {
1412
+ ctx.save();
1413
+ ctx.beginPath();
1414
+ ctx.arc(mx, my, r, 0, Math.PI * 2);
1415
+ ctx.clip();
1416
+ ctx.drawImage(img, mx - r, my - r, r * 2, r * 2);
1417
+ ctx.restore();
1418
+ } else {
1419
+ // Fallback circle
1420
+ ctx.beginPath();
1421
+ ctx.arc(mx, my, r, 0, Math.PI * 2);
1422
+ ctx.fillStyle = "#d4d4d8";
1423
+ ctx.fill();
1424
+ }
1425
+ } else {
1426
+ ctx.beginPath();
1427
+ ctx.arc(mx, my, r, 0, Math.PI * 2);
1428
+ ctx.fillStyle = "#d4d4d8";
1429
+ ctx.fill();
1430
+ }
1431
+
1432
+ // White border
1433
+ ctx.beginPath();
1434
+ ctx.arc(mx, my, r, 0, Math.PI * 2);
1435
+ ctx.strokeStyle = "#ffffff";
1436
+ ctx.lineWidth = 1;
1437
+ ctx.stroke();
1438
+
1439
+ ctx.restore();
1440
+ }
1441
+ }
1442
+
1181
1443
  // Draw FOV rectangle
1182
1444
  try {
1183
1445
  const tl = fg.screen2GraphCoords(0, 0);
@@ -1466,15 +1728,106 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1466
1728
  </div>
1467
1729
  )}
1468
1730
 
1469
- {/* Minimap — bottom-left, hidden on mobile */}
1470
- <canvas
1471
- ref={minimapCanvasRef}
1472
- width={MINIMAP_W}
1473
- height={MINIMAP_H}
1474
- onClick={handleMinimapClick}
1475
- className="hidden sm:block absolute bottom-4 left-4 z-10 rounded-lg border border-zinc-200 shadow-sm cursor-crosshair"
1731
+ {/* Minimap — bottom-left, hidden on mobile, resizable */}
1732
+ <div
1733
+ className="hidden sm:block absolute bottom-4 left-4 z-10"
1476
1734
  style={{ width: MINIMAP_W, height: MINIMAP_H }}
1477
- />
1735
+ >
1736
+ <canvas
1737
+ ref={minimapCanvasRef}
1738
+ width={MINIMAP_W}
1739
+ height={MINIMAP_H}
1740
+ onClick={handleMinimapClick}
1741
+ className="rounded-lg border border-zinc-200 shadow-sm cursor-crosshair"
1742
+ style={{ width: MINIMAP_W, height: MINIMAP_H }}
1743
+ />
1744
+ {/* Resize handle — top edge */}
1745
+ <div
1746
+ className="absolute top-0 left-2 right-2 h-1.5 cursor-n-resize hover:bg-zinc-300/40 rounded-t-lg transition-colors"
1747
+ onMouseDown={(e) => {
1748
+ e.preventDefault();
1749
+ e.stopPropagation();
1750
+ minimapDragRef.current = {
1751
+ edge: "top",
1752
+ startX: e.clientX,
1753
+ startY: e.clientY,
1754
+ startW: MINIMAP_W,
1755
+ startH: MINIMAP_H,
1756
+ };
1757
+ const onMove = (ev: MouseEvent) => {
1758
+ if (!minimapDragRef.current) return;
1759
+ const dy = minimapDragRef.current.startY - ev.clientY;
1760
+ const newH = Math.max(80, Math.min(400, minimapDragRef.current.startH + dy));
1761
+ setMinimapSize((prev) => ({ ...prev, h: newH }));
1762
+ };
1763
+ const onUp = () => {
1764
+ minimapDragRef.current = null;
1765
+ window.removeEventListener("mousemove", onMove);
1766
+ window.removeEventListener("mouseup", onUp);
1767
+ };
1768
+ window.addEventListener("mousemove", onMove);
1769
+ window.addEventListener("mouseup", onUp);
1770
+ }}
1771
+ />
1772
+ {/* Resize handle — right edge */}
1773
+ <div
1774
+ className="absolute top-2 right-0 bottom-2 w-1.5 cursor-e-resize hover:bg-zinc-300/40 rounded-r-lg transition-colors"
1775
+ onMouseDown={(e) => {
1776
+ e.preventDefault();
1777
+ e.stopPropagation();
1778
+ minimapDragRef.current = {
1779
+ edge: "right",
1780
+ startX: e.clientX,
1781
+ startY: e.clientY,
1782
+ startW: MINIMAP_W,
1783
+ startH: MINIMAP_H,
1784
+ };
1785
+ const onMove = (ev: MouseEvent) => {
1786
+ if (!minimapDragRef.current) return;
1787
+ const dx = ev.clientX - minimapDragRef.current.startX;
1788
+ const newW = Math.max(100, Math.min(500, minimapDragRef.current.startW + dx));
1789
+ setMinimapSize((prev) => ({ ...prev, w: newW }));
1790
+ };
1791
+ const onUp = () => {
1792
+ minimapDragRef.current = null;
1793
+ window.removeEventListener("mousemove", onMove);
1794
+ window.removeEventListener("mouseup", onUp);
1795
+ };
1796
+ window.addEventListener("mousemove", onMove);
1797
+ window.addEventListener("mouseup", onUp);
1798
+ }}
1799
+ />
1800
+ {/* Resize handle — top-right corner */}
1801
+ <div
1802
+ className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize hover:bg-zinc-300/40 rounded-tr-lg transition-colors"
1803
+ onMouseDown={(e) => {
1804
+ e.preventDefault();
1805
+ e.stopPropagation();
1806
+ minimapDragRef.current = {
1807
+ edge: "top-right",
1808
+ startX: e.clientX,
1809
+ startY: e.clientY,
1810
+ startW: MINIMAP_W,
1811
+ startH: MINIMAP_H,
1812
+ };
1813
+ const onMove = (ev: MouseEvent) => {
1814
+ if (!minimapDragRef.current) return;
1815
+ const dx = ev.clientX - minimapDragRef.current.startX;
1816
+ const dy = minimapDragRef.current.startY - ev.clientY;
1817
+ const newW = Math.max(100, Math.min(500, minimapDragRef.current.startW + dx));
1818
+ const newH = Math.max(80, Math.min(400, minimapDragRef.current.startH + dy));
1819
+ setMinimapSize({ w: newW, h: newH });
1820
+ };
1821
+ const onUp = () => {
1822
+ minimapDragRef.current = null;
1823
+ window.removeEventListener("mousemove", onMove);
1824
+ window.removeEventListener("mouseup", onUp);
1825
+ };
1826
+ window.addEventListener("mousemove", onMove);
1827
+ window.addEventListener("mouseup", onUp);
1828
+ }}
1829
+ />
1830
+ </div>
1478
1831
 
1479
1832
  {ForceGraph2D ? (
1480
1833
  <ForceGraph2D
@@ -0,0 +1,111 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Animated geometric logo for beads-map.
5
+ * Inspired by plresearch.org's HexagonIcon — a rotating hexagonal network
6
+ * with pulsing nodes, representing the dependency graph.
7
+ */
8
+ export function BeadsLogo({ className }: { className?: string }) {
9
+ return (
10
+ <svg
11
+ viewBox="0 0 32 32"
12
+ fill="none"
13
+ className={className}
14
+ aria-hidden="true"
15
+ >
16
+ {/* Outer hexagon — slow rotation */}
17
+ <polygon
18
+ points="16,3 27,9.5 27,22.5 16,29 5,22.5 5,9.5"
19
+ stroke="currentColor"
20
+ strokeWidth="1.2"
21
+ fill="none"
22
+ opacity="0.7"
23
+ strokeLinejoin="round"
24
+ >
25
+ <animateTransform
26
+ attributeName="transform"
27
+ type="rotate"
28
+ values="0 16 16;360 16 16"
29
+ dur="40s"
30
+ repeatCount="indefinite"
31
+ />
32
+ </polygon>
33
+
34
+ {/* Inner hexagon — counter-rotation, slightly smaller */}
35
+ <polygon
36
+ points="16,8 22.5,11.5 22.5,20.5 16,24 9.5,20.5 9.5,11.5"
37
+ stroke="currentColor"
38
+ strokeWidth="0.8"
39
+ fill="none"
40
+ opacity="0.35"
41
+ strokeLinejoin="round"
42
+ >
43
+ <animateTransform
44
+ attributeName="transform"
45
+ type="rotate"
46
+ values="360 16 16;0 16 16"
47
+ dur="30s"
48
+ repeatCount="indefinite"
49
+ />
50
+ </polygon>
51
+
52
+ {/* Connection lines from center to outer vertices */}
53
+ <line x1="16" y1="16" x2="16" y2="3" stroke="currentColor" strokeWidth="0.6" opacity="0.2">
54
+ <animate attributeName="opacity" values="0.15;0.35;0.15" dur="3s" repeatCount="indefinite" />
55
+ </line>
56
+ <line x1="16" y1="16" x2="27" y2="9.5" stroke="currentColor" strokeWidth="0.6" opacity="0.2">
57
+ <animate attributeName="opacity" values="0.15;0.35;0.15" dur="3s" repeatCount="indefinite" begin="0.5s" />
58
+ </line>
59
+ <line x1="16" y1="16" x2="27" y2="22.5" stroke="currentColor" strokeWidth="0.6" opacity="0.2">
60
+ <animate attributeName="opacity" values="0.15;0.35;0.15" dur="3s" repeatCount="indefinite" begin="1s" />
61
+ </line>
62
+ <line x1="16" y1="16" x2="16" y2="29" stroke="currentColor" strokeWidth="0.6" opacity="0.2">
63
+ <animate attributeName="opacity" values="0.15;0.35;0.15" dur="3s" repeatCount="indefinite" begin="1.5s" />
64
+ </line>
65
+ <line x1="16" y1="16" x2="5" y2="22.5" stroke="currentColor" strokeWidth="0.6" opacity="0.2">
66
+ <animate attributeName="opacity" values="0.15;0.35;0.15" dur="3s" repeatCount="indefinite" begin="2s" />
67
+ </line>
68
+ <line x1="16" y1="16" x2="5" y2="9.5" stroke="currentColor" strokeWidth="0.6" opacity="0.2">
69
+ <animate attributeName="opacity" values="0.15;0.35;0.15" dur="3s" repeatCount="indefinite" begin="2.5s" />
70
+ </line>
71
+
72
+ {/* Center node — breathing pulse */}
73
+ <circle cx="16" cy="16" r="2.5" fill="currentColor" opacity="0.8">
74
+ <animate attributeName="r" values="2;3;2" dur="3s" repeatCount="indefinite" />
75
+ <animate attributeName="opacity" values="0.7;1;0.7" dur="3s" repeatCount="indefinite" />
76
+ </circle>
77
+
78
+ {/* Vertex nodes — staggered pulse */}
79
+ <circle cx="16" cy="3" r="1.5" fill="currentColor" opacity="0.5">
80
+ <animate attributeName="r" values="1.2;2;1.2" dur="2.5s" repeatCount="indefinite" />
81
+ <animate attributeName="opacity" values="0.4;0.7;0.4" dur="2.5s" repeatCount="indefinite" />
82
+ </circle>
83
+ <circle cx="27" cy="9.5" r="1.5" fill="currentColor" opacity="0.5">
84
+ <animate attributeName="r" values="1.2;2;1.2" dur="2.5s" repeatCount="indefinite" begin="0.4s" />
85
+ <animate attributeName="opacity" values="0.4;0.7;0.4" dur="2.5s" repeatCount="indefinite" begin="0.4s" />
86
+ </circle>
87
+ <circle cx="27" cy="22.5" r="1.5" fill="currentColor" opacity="0.5">
88
+ <animate attributeName="r" values="1.2;2;1.2" dur="2.5s" repeatCount="indefinite" begin="0.8s" />
89
+ <animate attributeName="opacity" values="0.4;0.7;0.4" dur="2.5s" repeatCount="indefinite" begin="0.8s" />
90
+ </circle>
91
+ <circle cx="16" cy="29" r="1.5" fill="currentColor" opacity="0.5">
92
+ <animate attributeName="r" values="1.2;2;1.2" dur="2.5s" repeatCount="indefinite" begin="1.2s" />
93
+ <animate attributeName="opacity" values="0.4;0.7;0.4" dur="2.5s" repeatCount="indefinite" begin="1.2s" />
94
+ </circle>
95
+ <circle cx="5" cy="22.5" r="1.5" fill="currentColor" opacity="0.5">
96
+ <animate attributeName="r" values="1.2;2;1.2" dur="2.5s" repeatCount="indefinite" begin="1.6s" />
97
+ <animate attributeName="opacity" values="0.4;0.7;0.4" dur="2.5s" repeatCount="indefinite" begin="1.6s" />
98
+ </circle>
99
+ <circle cx="5" cy="9.5" r="1.5" fill="currentColor" opacity="0.5">
100
+ <animate attributeName="r" values="1.2;2;1.2" dur="2.5s" repeatCount="indefinite" begin="2s" />
101
+ <animate attributeName="opacity" values="0.4;0.7;0.4" dur="2.5s" repeatCount="indefinite" begin="2s" />
102
+ </circle>
103
+
104
+ {/* Orbiting ring — subtle depth */}
105
+ <circle cx="16" cy="16" r="10" stroke="currentColor" strokeWidth="0.4" fill="none" opacity="0.15">
106
+ <animate attributeName="r" values="9;11;9" dur="4s" repeatCount="indefinite" />
107
+ <animate attributeName="opacity" values="0.1;0.25;0.1" dur="4s" repeatCount="indefinite" />
108
+ </circle>
109
+ </svg>
110
+ );
111
+ }