@visualli/react 0.1.1

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 (38) hide show
  1. package/README.md +335 -0
  2. package/dist/index.cjs +3262 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +375 -0
  5. package/dist/index.d.ts +375 -0
  6. package/dist/index.js +3289 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +48 -0
  9. package/src/VisualliCanvas.tsx +1087 -0
  10. package/src/VisualliRenderer.tsx +427 -0
  11. package/src/animations/index.ts +2 -0
  12. package/src/animations/konvaLayerTransition.ts +223 -0
  13. package/src/components/KonvaContainer.tsx +255 -0
  14. package/src/components/KonvaContainerLayer.tsx +63 -0
  15. package/src/components/KonvaEdge.tsx +254 -0
  16. package/src/components/KonvaEdgeLayer.tsx +72 -0
  17. package/src/components/KonvaNode.tsx +177 -0
  18. package/src/components/KonvaNodeLayer.tsx +61 -0
  19. package/src/components/KonvaStage.tsx +80 -0
  20. package/src/components/NavigationStack.tsx +184 -0
  21. package/src/components/SketchyBoxKonva.tsx +121 -0
  22. package/src/components/ZoomControls.tsx +110 -0
  23. package/src/config/blobShapes.ts +204 -0
  24. package/src/config/index.ts +21 -0
  25. package/src/config/textScaling.ts +69 -0
  26. package/src/context/VisualliContext.tsx +50 -0
  27. package/src/hooks/index.ts +7 -0
  28. package/src/hooks/useKonvaLayerTransition.ts +221 -0
  29. package/src/hooks/useKonvaRenderer.ts +185 -0
  30. package/src/hooks/useViewportNodes.ts +59 -0
  31. package/src/index.ts +81 -0
  32. package/src/konvaCompat.ts +29 -0
  33. package/src/stores/index.ts +3 -0
  34. package/src/stores/stores.ts +17 -0
  35. package/src/stores/useNodeStore.ts +101 -0
  36. package/src/stores/useViewportStore.ts +95 -0
  37. package/src/utils/index.ts +1 -0
  38. package/src/utils/layerNavigation.ts +15 -0
@@ -0,0 +1,177 @@
1
+ // ─── KonvaNode ────────────────────────────────────────────────────────────────
2
+ //
3
+ // Renders a single FlatNode as a single organic blob shape with Konva.
4
+ // Matches the visualli.ai rendering:
5
+ // - Centered blob at node (x, y) with radiusX / radiusY scaling
6
+ // - Single blob shape (no background depth layers)
7
+ // - HSL-based border darkening
8
+ // - 'Story Script' cursive font, text clipped to blob silhouette
9
+ // - External hover / pressed state (no Konva hit-canvas dependency)
10
+ // - All shapes are listening=false; hit detection uses spatial index
11
+
12
+ import React, { useMemo } from 'react';
13
+ import { KGroup as Group, KShape as Shape, KText as Text } from '../konvaCompat';
14
+ import type { FlatNode } from '@visualli/core';
15
+ import { CANVAS_COLORS, NODE_HEIGHT, TEXT_LABEL_HIDE_BELOW_ZOOM, darkenHexColor } from '@visualli/core';
16
+ import { getBlobTypeForLayer, BLOB_TEXT_OFFSETS, BLOB_LAYER_CONFIG, NODE_LAYER_CONFIG, drawBlobPath, computeNodeTextWorldScale } from '../config';
17
+
18
+ // ── Component ─────────────────────────────────────────────────────────────────
19
+
20
+ export interface KonvaNodeProps {
21
+ node: FlatNode;
22
+ zoomLevel: number;
23
+ /** True when the pointer is currently over this node (stage-level hit test). */
24
+ isExternallyHovered?: boolean;
25
+ /** True while this node is being pressed/dragged. */
26
+ isExternallyPressed?: boolean;
27
+ /** True while any node is being dragged — disables expensive shadows. */
28
+ isDragging?: boolean;
29
+ }
30
+
31
+ const KonvaNode = React.memo(function KonvaNode({
32
+ node,
33
+ zoomLevel,
34
+ isExternallyHovered = false,
35
+ isExternallyPressed = false,
36
+ isDragging = false,
37
+ }: KonvaNodeProps) {
38
+ // Border: monochromatic HSL-darkened version of the fill color
39
+ const borderColor = useMemo(() => darkenHexColor(node.color, 0.13), [node.color]);
40
+
41
+ // Number of background layers (capped at 3), driven by branchCount
42
+ const layerCount = useMemo(() => {
43
+ const count = node.branchCount ?? 0;
44
+ return Math.min(count, 3);
45
+ }, [node.branchCount]);
46
+
47
+ // Base blob radii (same math as visualli.ai KonvaNode)
48
+ const baseRadiusX = node.width / 2;
49
+ const baseRadiusY = Math.max((NODE_HEIGHT * 1.6) / 2, baseRadiusX * 0.74);
50
+
51
+ // Larger, consistent font size for better readability
52
+ // Text will wrap to multiple lines if needed
53
+ const calculateFontSize = useMemo(() => {
54
+ // Use a larger base font size for better visibility
55
+ return 30;
56
+ }, [node.title]);
57
+
58
+ // Text scale and layout
59
+ const textNaturalWidth = node.width - 40;
60
+ // Increase text height to allow 2-3 lines of wrapped text
61
+ const textNaturalHeight = NODE_HEIGHT * 1.2;
62
+ const maxScale = computeNodeTextWorldScale(node.width, zoomLevel);
63
+
64
+ // Blob type and text offset
65
+ const blobType = getBlobTypeForLayer(node.level);
66
+ const textOffsetX = (BLOB_TEXT_OFFSETS[blobType] ?? 0) * baseRadiusX;
67
+
68
+ // Interaction state (purely driven from stage-level delegation)
69
+ const effectiveHover = isExternallyHovered;
70
+ const isPressed = isExternallyPressed;
71
+
72
+ // Blob fill/stroke:
73
+ // default → fill=node.color, stroke=borderColor
74
+ // hover → fill=borderColor, stroke=borderColor
75
+ // pressed → fill=borderColor, stroke=node.color
76
+ const topFill = effectiveHover || isPressed ? borderColor : node.color;
77
+ const topStroke = isPressed ? node.color : borderColor;
78
+
79
+ // Build the background layers config (outermost first for correct z-order)
80
+ const backgroundLayers = useMemo(() => {
81
+ if (layerCount === 0) return [];
82
+ const startIdx = BLOB_LAYER_CONFIG.length - layerCount;
83
+ const layers = [];
84
+ for (let i = startIdx; i < BLOB_LAYER_CONFIG.length; i++) {
85
+ const config = BLOB_LAYER_CONFIG[i];
86
+ layers.push({
87
+ ...config,
88
+ radiusX: baseRadiusX * config.scaleUp,
89
+ radiusY: baseRadiusY * config.scaleUp,
90
+ });
91
+ }
92
+ return layers;
93
+ }, [layerCount, baseRadiusX, baseRadiusY]);
94
+
95
+ return (
96
+ <Group x={node.x} y={node.y} listening={false}>
97
+ {/* Background blob layers (outermost rendered first, behind the node) */}
98
+ {backgroundLayers.map((layer, idx) => (
99
+ <Shape
100
+ key={`blob-layer-${idx}`}
101
+ rotation={layer.rotation}
102
+ opacity={layer.opacity}
103
+ sceneFunc={(ctx: any, shape: any) => {
104
+ drawBlobPath(ctx, layer.radiusX, layer.radiusY, blobType);
105
+ ctx.fillStrokeShape(shape);
106
+ }}
107
+ fill={node.color}
108
+ stroke={borderColor}
109
+ strokeWidth={layer.strokeWidth}
110
+ dash={layer.dash ? [...layer.dash] : undefined}
111
+ listening={false}
112
+ perfectDrawEnabled={false}
113
+ />
114
+ ))}
115
+
116
+ {/* Node (top layer) - main interactive blob */}
117
+ <Shape
118
+ rotation={NODE_LAYER_CONFIG.rotation}
119
+ opacity={NODE_LAYER_CONFIG.opacity}
120
+ sceneFunc={(ctx: any, shape: any) => {
121
+ drawBlobPath(ctx, baseRadiusX, baseRadiusY, blobType);
122
+ ctx.fillStrokeShape(shape);
123
+ }}
124
+ hitFunc={(ctx: any, shape: any) => {
125
+ drawBlobPath(ctx, baseRadiusX, baseRadiusY, blobType);
126
+ ctx.fillStrokeShape(shape);
127
+ }}
128
+ fill={topFill}
129
+ stroke={topStroke}
130
+ strokeWidth={NODE_LAYER_CONFIG.strokeWidth}
131
+ shadowEnabled={!isDragging}
132
+ shadowColor="rgba(255,255,255,1)"
133
+ shadowBlur={effectiveHover || isPressed ? 40 : 15}
134
+ shadowOpacity={effectiveHover || isPressed ? 0.4 : 0.15}
135
+ shadowOffsetX={0}
136
+ shadowOffsetY={0}
137
+ perfectDrawEnabled={false}
138
+ />
139
+
140
+ {/* Node Title — inverse-scaled so it stays legible at a constant screen size.
141
+ Clipped to the blob silhouette via clipFunc so the growing text never visually
142
+ bleeds outside the first layer boundary at low zoom levels. */}
143
+ <Group
144
+ clipFunc={(ctx: any) => {
145
+ drawBlobPath(ctx, baseRadiusX, baseRadiusY, blobType);
146
+ }}
147
+ visible={zoomLevel >= TEXT_LABEL_HIDE_BELOW_ZOOM}
148
+ listening={false}
149
+ >
150
+ <Text
151
+ x={textOffsetX}
152
+ y={0}
153
+ offsetX={textNaturalWidth / 2}
154
+ offsetY={textNaturalHeight / 2}
155
+ width={textNaturalWidth}
156
+ height={textNaturalHeight}
157
+ scaleX={maxScale}
158
+ scaleY={maxScale}
159
+ text={node.title}
160
+ fontSize={calculateFontSize}
161
+ fontFamily="'Story Script', cursive"
162
+ fontStyle="normal"
163
+ fill={CANVAS_COLORS.node.titleColor}
164
+ align="center"
165
+ verticalAlign="middle"
166
+ wrap="word"
167
+ ellipsis={false}
168
+ listening={false}
169
+ perfectDrawEnabled={false}
170
+ />
171
+ </Group>
172
+ </Group>
173
+ );
174
+ });
175
+
176
+ export default KonvaNode;
177
+
@@ -0,0 +1,61 @@
1
+ // ─── KonvaNodeLayer ───────────────────────────────────────────────────────────
2
+ //
3
+ // Konva <Layer> that renders all visible FlatNodes.
4
+ // Uses viewport culling and passes external hover/pressed state to each node.
5
+
6
+ import React, { useRef, useLayoutEffect, useEffect, useMemo } from 'react';
7
+ import { KLayer as Layer } from '../konvaCompat';
8
+ import type Konva from 'konva';
9
+ import type { FlatNode } from '@visualli/core';
10
+ import { useViewportStore } from '../stores/useViewportStore';
11
+ import { useNodeStore } from '../stores/useNodeStore';
12
+ import { useViewportNodes } from '../hooks/useViewportNodes';
13
+ import KonvaNode from './KonvaNode';
14
+
15
+ export interface KonvaNodeLayerProps {
16
+ nodes?: FlatNode[];
17
+ isTransitioning?: boolean;
18
+ isDragging?: boolean;
19
+ hoveredNodeId?: string | null;
20
+ pressedNodeId?: string | null;
21
+ }
22
+
23
+ export default function KonvaNodeLayer({
24
+ nodes: propNodes,
25
+ isTransitioning = false,
26
+ isDragging = false,
27
+ hoveredNodeId = null,
28
+ pressedNodeId = null,
29
+ }: KonvaNodeLayerProps) {
30
+ const layerRef = useRef<Konva.Layer | null>(null);
31
+ const zoomLevel = useViewportStore(s => s.zoomLevel);
32
+ const storeNodes = useNodeStore(s => s.nodes);
33
+ const allNodes: FlatNode[] = useMemo(
34
+ () => propNodes ?? Array.from(storeNodes.values()),
35
+ [propNodes, storeNodes],
36
+ );
37
+ const visibleNodes = useViewportNodes(allNodes, undefined, isTransitioning, isDragging);
38
+
39
+ // Redraw synchronously before paint whenever visible content changes.
40
+ // Tied to data deps so it does not fire on every parent re-render.
41
+ useLayoutEffect(() => { layerRef.current?.batchDraw(); }, [visibleNodes, hoveredNodeId, pressedNodeId, isDragging, zoomLevel]);
42
+
43
+ // Redraw when transition state toggles (opacity wrapper controls visibility,
44
+ // but the canvas still needs to be current when it fades back in).
45
+ useEffect(() => { layerRef.current?.batchDraw(); }, [isTransitioning]);
46
+
47
+ return (
48
+ <Layer ref={layerRef} name="nodes">
49
+ {visibleNodes.map(n => (
50
+ <KonvaNode
51
+ key={n.id}
52
+ node={n}
53
+ zoomLevel={zoomLevel}
54
+ isExternallyHovered={hoveredNodeId === n.id}
55
+ isExternallyPressed={pressedNodeId === n.id}
56
+ isDragging={isDragging}
57
+ />
58
+ ))}
59
+ </Layer>
60
+ );
61
+ }
@@ -0,0 +1,80 @@
1
+ // ─── KonvaStage ───────────────────────────────────────────────────────────────
2
+ //
3
+ // Thin wrapper around react-konva <Stage> that:
4
+ // • Binds canvas position/scale from the viewport store
5
+ // • Accepts optional event handlers (wheel, mouse, touch, context-menu)
6
+ // • Calls stage.destroy() on unmount (memory hygiene)
7
+
8
+ import React, { useRef, useEffect, forwardRef } from 'react';
9
+ import { KStage as Stage } from '../konvaCompat';
10
+ import type Konva from 'konva';
11
+ import { useViewportStore } from '../stores/useViewportStore';
12
+
13
+ export interface KonvaStageProps {
14
+ children: React.ReactNode;
15
+ onWheel?: (e: Konva.KonvaEventObject<WheelEvent>) => void;
16
+ onMouseDown?: (e: Konva.KonvaEventObject<MouseEvent>) => void;
17
+ onMouseMove?: (e: Konva.KonvaEventObject<MouseEvent>) => void;
18
+ onMouseUp?: (e: Konva.KonvaEventObject<MouseEvent>) => void;
19
+ onContextMenu?: (e: Konva.KonvaEventObject<MouseEvent>) => void;
20
+ onTouchStart?: (e: Konva.KonvaEventObject<TouchEvent>) => void;
21
+ onTouchMove?: (e: Konva.KonvaEventObject<TouchEvent>) => void;
22
+ onTouchEnd?: (e: Konva.KonvaEventObject<TouchEvent>) => void;
23
+ onStageReady?: (stage: Konva.Stage) => void;
24
+ }
25
+
26
+ const KonvaStage = forwardRef<Konva.Stage, KonvaStageProps>(function KonvaStage(
27
+ {
28
+ children,
29
+ onWheel, onMouseDown, onMouseMove, onMouseUp, onContextMenu,
30
+ onTouchStart, onTouchMove, onTouchEnd,
31
+ onStageReady,
32
+ },
33
+ ref,
34
+ ) {
35
+ const internalRef = useRef<Konva.Stage | null>(null);
36
+ const { centerX, centerY, zoomLevel, canvasWidth, canvasHeight } = useViewportStore();
37
+
38
+ useEffect(() => {
39
+ if (internalRef.current) {
40
+ if (typeof ref === 'function') ref(internalRef.current);
41
+ else if (ref) (ref as React.MutableRefObject<Konva.Stage | null>).current = internalRef.current;
42
+ onStageReady?.(internalRef.current);
43
+ }
44
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
45
+
46
+ useEffect(() => {
47
+ return () => { internalRef.current?.destroy(); };
48
+ }, []);
49
+
50
+ const w = canvasWidth || 800;
51
+ const h = canvasHeight || 600;
52
+ const stageX = w / 2 - centerX * zoomLevel;
53
+ const stageY = h / 2 - centerY * zoomLevel;
54
+
55
+ return (
56
+ <Stage
57
+ ref={internalRef}
58
+ width={w}
59
+ height={h}
60
+ x={stageX}
61
+ y={stageY}
62
+ scaleX={zoomLevel}
63
+ scaleY={zoomLevel}
64
+ onWheel={onWheel}
65
+ onMouseDown={onMouseDown}
66
+ onMouseMove={onMouseMove}
67
+ onMouseUp={onMouseUp}
68
+ onContextMenu={onContextMenu}
69
+ onTouchStart={onTouchStart}
70
+ onTouchMove={onTouchMove}
71
+ onTouchEnd={onTouchEnd}
72
+ draggable={false}
73
+ pixelRatio={1}
74
+ >
75
+ {children}
76
+ </Stage>
77
+ );
78
+ });
79
+
80
+ export default KonvaStage;
@@ -0,0 +1,184 @@
1
+ // ─── NavigationStack ─────────────────────────────────────────────────────────
2
+ //
3
+ // Vertical navigation stack rendered as a fixed left-side overlay.
4
+ // Matches the visualli.ai VisualliNavigationStack design:
5
+ // • Organic hand-drawn circle SVGs per level
6
+ // • Vertical connector lines between levels
7
+ // • Active level highlighted, previous levels clickable to navigate back
8
+ // • Font: Nunito
9
+
10
+ import React, { memo, useState } from 'react';
11
+ import type { VisualliLayer } from '@visualli/core';
12
+
13
+ export interface NavStackEntry {
14
+ layerId: string;
15
+ layer: VisualliLayer;
16
+ label: string;
17
+ }
18
+
19
+ export interface NavigationStackProps {
20
+ stack: NavStackEntry[];
21
+ onNavigateBack: (index: number) => void;
22
+ isDark?: boolean;
23
+ top?: string;
24
+ left?: string;
25
+ }
26
+
27
+ // Pastel fills cycled across levels
28
+ const PASTEL_FILLS = [
29
+ '#b6eea7', '#d5bcfe', '#a1c4fc', '#a6f5d8',
30
+ '#ffc9de', '#c4e8ff', '#f0c4ff', '#ffe0a3',
31
+ ];
32
+
33
+ // Organic circle SVG paths (small / active variants matching visualli.ai)
34
+ const ORGANIC_PATHS_SMALL = [
35
+ 'M13.72,6.31 C15.12,6.29 17.96,8.57 17.99,10.60 C18.07,14.62 16.72,17.90 11.12,18.00 C7.26,18.07 4.07,14.86 4.00,10.85 C3.93,6.83 9.44,2.02 13.69,4.85',
36
+ 'M13.88,6.41 C15.28,6.43 18.04,8.82 18.00,10.85 C17.93,14.86 16.48,18.10 10.88,18.00 C7.02,17.93 3.94,14.62 4.01,10.60 C4.08,6.59 9.76,1.97 13.91,4.95',
37
+ ];
38
+ const ORGANIC_PATH_ACTIVE =
39
+ 'M16.55,9.22 C17.95,9.15 20.87,11.33 20.97,13.36 C21.19,17.37 19.96,20.70 14.37,20.99 C10.51,21.19 7.20,18.10 7.00,14.09 C6.78,10.08 12.13,5.08 16.48,7.77';
40
+
41
+ const CIRCLE_SIZE = 14;
42
+
43
+ function hexToRgba(hex: string, opacity: number): string {
44
+ const h = hex.replace('#', '');
45
+ const r = parseInt(h.substring(0, 2), 16);
46
+ const g = parseInt(h.substring(2, 4), 16);
47
+ const b = parseInt(h.substring(4, 6), 16);
48
+ return `rgba(${r},${g},${b},${opacity})`;
49
+ }
50
+
51
+ const OrganicCircle = memo(function OrganicCircle({
52
+ size, fill, stroke, strokeWidth, variant, isActive,
53
+ }: {
54
+ size: number; fill: string; stroke: string; strokeWidth: number; variant: number; isActive?: boolean;
55
+ }) {
56
+ const path = isActive ? ORGANIC_PATH_ACTIVE : ORGANIC_PATHS_SMALL[variant % ORGANIC_PATHS_SMALL.length];
57
+ const vb = isActive ? '4 3 20 20' : '2 1 18 18';
58
+ const vbSz = isActive ? 20 : 18;
59
+ const sw = strokeWidth * vbSz / size;
60
+ return (
61
+ <svg width={size} height={size} viewBox={vb} fill="none" style={{ display: 'block' }}>
62
+ <path d={path} fill={fill} fillOpacity={1} />
63
+ <path d={path} fill="none" stroke={stroke} strokeWidth={sw} strokeLinecap="round" />
64
+ </svg>
65
+ );
66
+ });
67
+
68
+ const NavigationStack = memo(function NavigationStack({
69
+ stack,
70
+ onNavigateBack,
71
+ isDark = false,
72
+ top = '16px',
73
+ left = '16px',
74
+ }: NavigationStackProps) {
75
+ const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
76
+
77
+ // Always render at least the root entry so the user knows where they are.
78
+ if (stack.length === 0) return null;
79
+
80
+ const visitedStroke = isDark ? '#908e8c' : '#747270';
81
+ const visitedText = isDark ? '#908e8c' : '#747270';
82
+ const activeStroke = isDark ? '#e8e6e4' : '#1a1a18';
83
+ const activeText = isDark ? '#e8e6e4' : '#1a1a18';
84
+ const connectorColor = isDark ? '#908e8c' : '#747270';
85
+ const connectorMarginLeft = 10.5; // padding(4) + circleHalf(7) - connectorHalf(0.5)
86
+ const currentIndex = stack.length - 1;
87
+
88
+ return (
89
+ <div
90
+ data-help="navigation-stack"
91
+ style={{
92
+ position: 'absolute',
93
+ top,
94
+ left,
95
+ zIndex: 30,
96
+ pointerEvents: 'auto',
97
+ }}
98
+ >
99
+ <div style={{ display: 'flex', flexDirection: 'column', padding: '8px' }}>
100
+ {stack.flatMap((entry, index) => {
101
+ const isCurrent = index === currentIndex;
102
+ const isClickable = index < currentIndex;
103
+ const isHovered = hoveredIndex === index;
104
+ const pastelFill = PASTEL_FILLS[index % PASTEL_FILLS.length];
105
+ const rowBg = isCurrent
106
+ ? hexToRgba(pastelFill, 0.6)
107
+ : isHovered && isClickable
108
+ ? hexToRgba(pastelFill, 0.3)
109
+ : 'transparent';
110
+
111
+ const items: React.ReactNode[] = [];
112
+
113
+ if (index > 0) {
114
+ items.push(
115
+ <div
116
+ key={`conn-${index}`}
117
+ style={{
118
+ width: '1px',
119
+ height: '20px',
120
+ marginLeft: `${connectorMarginLeft}px`,
121
+ marginTop: '-6px',
122
+ marginBottom: '-6px',
123
+ background: connectorColor,
124
+ alignSelf: 'flex-start',
125
+ flexShrink: 0,
126
+ position: 'relative',
127
+ zIndex: 1,
128
+ }}
129
+ />,
130
+ );
131
+ }
132
+
133
+ items.push(
134
+ <div
135
+ key={`${entry.layerId}-${index}`}
136
+ style={{
137
+ position: 'relative',
138
+ zIndex: 2,
139
+ alignSelf: 'flex-start',
140
+ display: 'flex',
141
+ alignItems: 'center',
142
+ gap: '4px',
143
+ height: '22px',
144
+ padding: '4px',
145
+ borderRadius: '8px',
146
+ cursor: isClickable ? 'pointer' : 'default',
147
+ background: rowBg,
148
+ transition: 'background 0.15s',
149
+ }}
150
+ onMouseEnter={() => setHoveredIndex(index)}
151
+ onMouseLeave={() => setHoveredIndex(null)}
152
+ onClick={e => { e.stopPropagation(); if (isClickable) onNavigateBack(index); }}
153
+ >
154
+ <div style={{ width: CIRCLE_SIZE, height: CIRCLE_SIZE, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, zIndex: 4 }}>
155
+ {isCurrent ? (
156
+ <OrganicCircle size={CIRCLE_SIZE} fill={pastelFill} stroke={activeStroke} strokeWidth={1.5} variant={index} isActive />
157
+ ) : (
158
+ <OrganicCircle size={CIRCLE_SIZE} fill={pastelFill} stroke={visitedStroke} strokeWidth={1} variant={index} />
159
+ )}
160
+ </div>
161
+ <span
162
+ style={{
163
+ fontFamily: "'Nunito', sans-serif",
164
+ fontSize: isCurrent || (isHovered && isClickable) ? '11px' : '9px',
165
+ fontWeight: isCurrent ? 600 : 400,
166
+ color: isCurrent ? activeText : visitedText,
167
+ lineHeight: 1,
168
+ whiteSpace: 'nowrap',
169
+ transition: 'color 0.15s, font-size 0.15s',
170
+ }}
171
+ >
172
+ {entry.label}
173
+ </span>
174
+ </div>,
175
+ );
176
+
177
+ return items;
178
+ })}
179
+ </div>
180
+ </div>
181
+ );
182
+ });
183
+
184
+ export default NavigationStack;
@@ -0,0 +1,121 @@
1
+ // ─── SketchyBoxKonva ─────────────────────────────────────────────────────────
2
+ //
3
+ // Renders the hand-drawn tooltip box using an imperative Konva canvas.
4
+ // Two paths are scaled to fit the container:
5
+ // - BACK_PATH : shadow stroke, offset 8px down
6
+ // - FRONT_PATH: filled main box, offset 5.65px right
7
+ //
8
+ // Matches the visualli.ai SketchyBoxKonva component exactly.
9
+
10
+ import React, { useEffect, useRef } from 'react';
11
+ import Konva from 'konva';
12
+
13
+ const BACK_PATH =
14
+ 'M0.653,0 L485.653,1 C485.653,1,484.782,152.773,' +
15
+ '486.653,165 C413.653,171,14.653,178,5.653,168' +
16
+ 'C2.653,158,0.653,0,0.653,0';
17
+
18
+ const FRONT_PATH =
19
+ 'M485,167.524 L0,166.524' +
20
+ 'C0,166.524,1.870,8.751,0,0' +
21
+ 'C73,0,479,0,487,8' +
22
+ 'C490,18,485,167.524,485,167.524';
23
+
24
+ const BACK_W = 486;
25
+ const BACK_H = 178;
26
+ const FRONT_W = 490;
27
+ const FRONT_H = 168;
28
+
29
+ const BACK_OFFSET_Y = 8;
30
+ const FRONT_OFFSET_X = 5.65;
31
+ const SHADOW_PAD = 12;
32
+
33
+ export interface SketchyBoxKonvaProps {
34
+ children: React.ReactNode;
35
+ fill: string;
36
+ stroke: string;
37
+ backStroke: string;
38
+ padding?: string;
39
+ }
40
+
41
+ const SketchyBoxKonva: React.FC<SketchyBoxKonvaProps> = ({
42
+ children,
43
+ fill,
44
+ stroke,
45
+ backStroke,
46
+ padding = '1rem 1.25rem',
47
+ }) => {
48
+ const wrapperRef = useRef<HTMLDivElement>(null);
49
+ const konvaContainerRef = useRef<HTMLDivElement>(null);
50
+ const stageRef = useRef<Konva.Stage | null>(null);
51
+
52
+ useEffect(() => {
53
+ const wrapper = wrapperRef.current;
54
+ const konvaEl = konvaContainerRef.current;
55
+ if (!wrapper || !konvaEl) return;
56
+
57
+ function draw(W: number, H: number) {
58
+ if (!konvaEl || W <= 0 || H <= 0) return;
59
+ if (stageRef.current) { stageRef.current.destroy(); stageRef.current = null; }
60
+
61
+ const stage = new Konva.Stage({ container: konvaEl, width: W, height: H });
62
+ const layer = new Konva.Layer({ listening: false });
63
+ const contentH = H - SHADOW_PAD;
64
+
65
+ layer.add(new Konva.Path({
66
+ x: 0, y: BACK_OFFSET_Y,
67
+ data: BACK_PATH,
68
+ scaleX: W / BACK_W,
69
+ scaleY: contentH / BACK_H,
70
+ fill: '#ffffff',
71
+ stroke: backStroke,
72
+ strokeWidth: 2,
73
+ strokeScaleEnabled: false,
74
+ listening: false,
75
+ }));
76
+
77
+ layer.add(new Konva.Path({
78
+ x: FRONT_OFFSET_X, y: 0,
79
+ data: FRONT_PATH,
80
+ scaleX: (W - FRONT_OFFSET_X) / FRONT_W,
81
+ scaleY: contentH / FRONT_H,
82
+ fill,
83
+ stroke,
84
+ strokeWidth: 3,
85
+ strokeScaleEnabled: false,
86
+ listening: false,
87
+ }));
88
+
89
+ stage.add(layer);
90
+ stageRef.current = stage;
91
+ }
92
+
93
+ draw(wrapper.offsetWidth, wrapper.offsetHeight);
94
+
95
+ const ro = new ResizeObserver(entries => {
96
+ const el = entries[0].target as HTMLElement;
97
+ draw(el.offsetWidth, el.offsetHeight);
98
+ });
99
+ ro.observe(wrapper);
100
+
101
+ return () => {
102
+ ro.disconnect();
103
+ stageRef.current?.destroy();
104
+ stageRef.current = null;
105
+ };
106
+ }, [fill, stroke, backStroke]);
107
+
108
+ return (
109
+ <div ref={wrapperRef} style={{ position: 'relative', paddingBottom: `${SHADOW_PAD}px` }}>
110
+ <div
111
+ ref={konvaContainerRef}
112
+ style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 0, overflow: 'hidden' }}
113
+ />
114
+ <div style={{ position: 'relative', padding, zIndex: 1 }}>
115
+ {children}
116
+ </div>
117
+ </div>
118
+ );
119
+ };
120
+
121
+ export default SketchyBoxKonva;