@visualli/react 0.1.7 → 0.2.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 (49) hide show
  1. package/README.md +13 -5
  2. package/dist/index.cjs +1900 -2033
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +354 -97
  5. package/dist/index.d.ts +354 -97
  6. package/dist/index.js +2005 -2095
  7. package/dist/index.js.map +1 -1
  8. package/fonts/Caveat-Variable.ttf +0 -0
  9. package/licenses/visualli-design-system/LICENSE +9 -0
  10. package/licenses/visualli-design-system/NOTICE.md +6 -0
  11. package/package.json +9 -4
  12. package/src/VisualliCanvas.tsx +452 -409
  13. package/src/VisualliRenderer.tsx +90 -112
  14. package/src/components/A11yLayer.tsx +73 -0
  15. package/src/components/KonvaContainer.tsx +28 -237
  16. package/src/components/KonvaContainerLabelLayer.tsx +41 -0
  17. package/src/components/KonvaContainerLayer.tsx +34 -33
  18. package/src/components/KonvaEdge.tsx +25 -240
  19. package/src/components/KonvaEdgeLayer.tsx +69 -49
  20. package/src/components/KonvaNode.tsx +21 -156
  21. package/src/components/KonvaNodeLayer.tsx +102 -34
  22. package/src/components/KonvaStage.tsx +17 -3
  23. package/src/components/NavigationStack.tsx +16 -159
  24. package/src/components/Overlays.tsx +327 -0
  25. package/src/components/ZoomControls.tsx +3 -107
  26. package/src/components/useFrames.ts +30 -0
  27. package/src/context/VisualliContext.tsx +3 -3
  28. package/src/design/choreography.ts +157 -0
  29. package/src/design/containers.ts +150 -0
  30. package/src/design/design.ts +115 -0
  31. package/src/design/drawing.ts +303 -0
  32. package/src/design/measure.ts +34 -0
  33. package/src/design/runtime.ts +154 -0
  34. package/src/design/useDesign.ts +38 -0
  35. package/src/generated/specCss.ts +15 -0
  36. package/src/hooks/index.ts +0 -2
  37. package/src/hooks/useKonvaRenderer.ts +13 -18
  38. package/src/hooks/useLayerChoreography.ts +159 -0
  39. package/src/hooks/useViewportNodes.ts +0 -1
  40. package/src/index.ts +22 -22
  41. package/src/stores/stores.ts +2 -2
  42. package/src/stores/useViewportStore.ts +12 -2
  43. package/src/animations/index.ts +0 -2
  44. package/src/animations/konvaLayerTransition.ts +0 -223
  45. package/src/components/SketchyBoxKonva.tsx +0 -121
  46. package/src/config/blobShapes.ts +0 -204
  47. package/src/config/index.ts +0 -21
  48. package/src/config/textScaling.ts +0 -69
  49. package/src/hooks/useKonvaLayerTransition.ts +0 -221
@@ -0,0 +1,150 @@
1
+ // ─── Containers ──────────────────────────────────────────────────────────────
2
+ //
3
+ // The design system's containers (geometry/container.ts, copied verbatim into
4
+ // @visualli/core): a dashed hull around the member ideas (HULL.padX / HULL.padY
5
+ // beyond their centres, more for large ideas: HULL_CLEAR) with HULL.radius corners, and the group's name as a pill straddling
6
+ // the hull's edge. Where the pill goes is the design system's placeContainerLabel:
7
+ // candidate spots along all four edges, scored against every idea on the layer
8
+ // (with its outermost ring), every connector curve and the names placed before
9
+ // it. The pill's look comes from .vi-map__group-label in css/spec.css
10
+ // (CANVAS_STYLE.group). Hull and pill are painted by the two container layers.
11
+
12
+ import {
13
+ CANVAS_STYLE,
14
+ RINGS,
15
+ connectorGeometry,
16
+ connectorSamples,
17
+ containerHull,
18
+ labelGrowth,
19
+ nodeRadii,
20
+ placeContainerLabel,
21
+ ringsFor,
22
+ type FlatNode,
23
+ type Hull,
24
+ type LabelBox,
25
+ type LabelSide,
26
+ } from '@visualli/core';
27
+ import type { Design } from './design';
28
+
29
+ const PILL = CANVAS_STYLE.group;
30
+
31
+ export interface ContainerInput { id: string; label?: string; nodeIds: string[] }
32
+ export interface ConnectionInput { from: string; to: string }
33
+
34
+ export interface PlacedPill { text: string; x: number; y: number; side: LabelSide; w: number; h: number }
35
+ export interface PlacedContainer { id: string; hull: Hull; pill: PlacedPill | null }
36
+
37
+ /** The pill's font at `px`: the note face (the UI face in readable type, via Design.fontNote). */
38
+ export const pillFont = (d: Design, px: number): string => `${PILL.labelWeight} ${px}px ${d.fontNote}`;
39
+
40
+
41
+ let measureCtx: CanvasRenderingContext2D | null = null;
42
+ function measurer(): CanvasRenderingContext2D | null {
43
+ if (!measureCtx && typeof document !== 'undefined') measureCtx = document.createElement('canvas').getContext('2d');
44
+ return measureCtx;
45
+ }
46
+
47
+ /** The pill's box in world units for `text` (ellipsised past labelMaxWidth), with the text grown by `k`. */
48
+ export function measurePill(text: string, d: Design, k: number, c: CanvasRenderingContext2D | null = measurer()): PlacedPill {
49
+ const px = PILL.labelSize * d.labelScale * k;
50
+ const padX = PILL.labelPadding.x, border = PILL.labelBorderWidth;
51
+ const room = PILL.labelMaxWidth - padX * 2 - border * 2;
52
+ let shown = text, tw = text.length * px * 0.5;
53
+ if (c) {
54
+ c.font = pillFont(d, px);
55
+ tw = c.measureText(text).width;
56
+ if (tw > room) {
57
+ // One-line ellipsis, like text-overflow: ellipsis.
58
+ let lo = 0, hi = text.length;
59
+ while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (c.measureText(text.slice(0, mid).trimEnd() + '…').width <= room) lo = mid; else hi = mid - 1; }
60
+ shown = text.slice(0, lo).trimEnd() + '…';
61
+ tw = c.measureText(shown).width;
62
+ }
63
+ }
64
+ return {
65
+ text: shown, x: 0, y: 0, side: 'bottom',
66
+ w: Math.min(PILL.labelMaxWidth, tw + padX * 2 + border * 2),
67
+ h: px * PILL.labelLineHeight + PILL.labelPadding.top + PILL.labelPadding.bottom + border * 2,
68
+ };
69
+ }
70
+
71
+ /** The box an idea occupies, its outermost ring included (what a container name must not cover). */
72
+ function ideaBox(n: FlatNode): LabelBox {
73
+ const { rx, ry } = nodeRadii(n);
74
+ const rings = ringsFor(n.branchCount);
75
+ const s = rings.length ? RINGS[rings.length - 1]!.scale : 1;
76
+ return { x0: n.x - rx * s, x1: n.x + rx * s, y0: n.y - ry * s, y1: n.y + ry * s };
77
+ }
78
+
79
+ /**
80
+ * Hulls and name positions for a layer's containers, in file order (each name
81
+ * avoids the ones placed before it). Placement uses the names' size at 100%
82
+ * (k = 1), so they don't hop while zooming; they follow ideas that are dragged.
83
+ */
84
+ export function layoutContainers(
85
+ containers: ReadonlyArray<ContainerInput>,
86
+ nodes: ReadonlyMap<string, FlatNode>,
87
+ connections: ReadonlyArray<ConnectionInput>,
88
+ d: Design,
89
+ ): PlacedContainer[] {
90
+ if (containers.length === 0) return [];
91
+ const ideas: LabelBox[] = [];
92
+ for (const n of nodes.values()) ideas.push(ideaBox(n));
93
+ const lines = connections.flatMap((c) => {
94
+ const a = nodes.get(c.from), b = nodes.get(c.to);
95
+ if (!a || !b) return [];
96
+ const g = connectorGeometry(a, b, d.metrics.edgeGap);
97
+ return [connectorSamples(g.a, g.mid, g.b)];
98
+ });
99
+ const placed: LabelBox[] = [];
100
+ const out: PlacedContainer[] = [];
101
+ for (const c of containers) {
102
+ const members = c.nodeIds.map((id) => nodes.get(id)).filter((n): n is FlatNode => !!n);
103
+ const hull = containerHull(members.map((n) => ({ x: n.x, y: n.y, ...nodeRadii(n) })));
104
+ if (!hull) continue;
105
+ let pill: PlacedPill | null = null;
106
+ if (c.label) {
107
+ const size = measurePill(c.label, d, 1);
108
+ const at = placeContainerLabel(hull, size, { ideas, connectors: lines, labels: placed });
109
+ pill = { ...size, x: at.x, y: at.y, side: at.side };
110
+ placed.push({ x0: at.x - size.w / 2, x1: at.x + size.w / 2, y0: at.y - size.h / 2, y1: at.y + size.h / 2 });
111
+ }
112
+ out.push({ id: c.id, hull, pill });
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** The dashed hull (beneath connectors and ideas). */
118
+ export function drawHull(c: CanvasRenderingContext2D, h: Hull, d: Design): void {
119
+ c.save();
120
+ c.strokeStyle = d.tokens['line-strong']!;
121
+ c.lineWidth = PILL.strokeWidth;
122
+ c.setLineDash([...PILL.dash]);
123
+ c.beginPath();
124
+ c.roundRect(h.x0, h.y0, h.x1 - h.x0, h.y1 - h.y0, h.radius);
125
+ c.stroke();
126
+ c.restore();
127
+ }
128
+
129
+ /** The name pill (above connectors and ideas), its text growing like idea labels when zoomed out (labelGrowth). */
130
+ export function drawPill(c: CanvasRenderingContext2D, p: PlacedPill, d: Design, zoom: number): void {
131
+ const k = labelGrowth(zoom).idea;
132
+ const size = k === 1 ? p : measurePill(p.text, d, k, c);
133
+ const contrast = d.theme.startsWith('contrast');
134
+ const bw = contrast ? PILL.contrastLabelBorderWidth : PILL.labelBorderWidth;
135
+ c.save();
136
+ c.beginPath();
137
+ c.roundRect(p.x - size.w / 2 + bw / 2, p.y - size.h / 2 + bw / 2, size.w - bw, size.h - bw, (size.h - bw) / 2);
138
+ c.fillStyle = d.tokens[PILL.labelFill]!;
139
+ c.fill();
140
+ c.lineWidth = bw;
141
+ c.strokeStyle = d.tokens[contrast ? PILL.contrastLabelBorder : PILL.labelBorder]!;
142
+ c.stroke();
143
+ c.font = pillFont(d, PILL.labelSize * d.labelScale * k);
144
+ c.fillStyle = d.tokens[PILL.labelInk]!;
145
+ c.textAlign = 'center';
146
+ c.textBaseline = 'middle';
147
+ // Centre of the text box: the padding is uneven (top < bottom), so nudge by half the difference.
148
+ c.fillText(size.text, p.x, p.y + (PILL.labelPadding.top - PILL.labelPadding.bottom) / 2);
149
+ c.restore();
150
+ }
@@ -0,0 +1,115 @@
1
+ // ─── Design object ───────────────────────────────────────────────────────────
2
+ //
3
+ // Everything the canvas needs for one theme + comfort combination, resolved
4
+ // once from the design-system tokens (no colour or font literals here).
5
+
6
+ import {
7
+ CANVAS_STYLE,
8
+ TYPE_STYLES,
9
+ isContrastTheme,
10
+ isDarkTheme,
11
+ labelScale,
12
+ metricsFor,
13
+ resolveComfort,
14
+ resolveTheme,
15
+ type Comfort,
16
+ type Metrics,
17
+ type ResolvedComfort,
18
+ type ThemeInput,
19
+ type ThemeName,
20
+ TOKENS,
21
+ } from '@visualli/core';
22
+
23
+ export interface NodeShadow { x: number; y: number; blur: number; color: string }
24
+
25
+ export interface Design {
26
+ theme: ThemeName;
27
+ comfort: ResolvedComfort;
28
+ tokens: Readonly<Record<string, string>>;
29
+ metrics: Metrics;
30
+ isDark: boolean;
31
+ /** CSS font stacks, already switched to the readable face when that comfort setting is on. */
32
+ fontHand: string;
33
+ fontNote: string;
34
+ fontUi: string;
35
+ /** Multiplier for label sizes (`--vi-label-scale`). */
36
+ labelScale: number;
37
+ /** Idea drop shadow for this theme; null when the theme turns it off (high contrast). */
38
+ nodeShadow: NodeShadow | null;
39
+ /** Attributes the spec CSS keys off; put them on the renderer's root element. */
40
+ attrs: Record<string, string>;
41
+ /** Canvas weight/size for connector labels (the readable face is smaller and bolder). */
42
+ edgeLabel: { weight: number; size: number };
43
+ }
44
+
45
+ /** Parse a CSS shadow like `0 6px 14px rgba(…)` as written in the design-system CSS. */
46
+ function parseShadow(css: string): NodeShadow | null {
47
+ const m = /^(-?[\d.]+)(?:px)?\s+(-?[\d.]+)(?:px)?\s+([\d.]+)(?:px)?\s+(.+)$/.exec(css.trim());
48
+ return m ? { x: +m[1]!, y: +m[2]!, blur: +m[3]!, color: m[4]!.trim() } : null;
49
+ }
50
+
51
+ const cache = new Map<string, Design>();
52
+
53
+ export function makeDesign(theme: ThemeName, comfortIn: ResolvedComfort): Design {
54
+ const key = `${theme}|${+comfortIn.readableType}${+comfortIn.largerText}${+comfortIn.reducedMotion}`;
55
+ const hit = cache.get(key);
56
+ if (hit) return hit;
57
+
58
+ const tokens = TOKENS[theme];
59
+ const ui = tokens['font-ui']!;
60
+ const d: Design = {
61
+ theme,
62
+ comfort: comfortIn,
63
+ tokens,
64
+ metrics: metricsFor(comfortIn),
65
+ isDark: isDarkTheme(theme),
66
+ fontHand: comfortIn.readableType ? ui : tokens['font-hand']!,
67
+ fontNote: comfortIn.readableType ? ui : tokens['font-note']!,
68
+ fontUi: ui,
69
+ labelScale: labelScale(comfortIn),
70
+ nodeShadow: isContrastTheme(theme) ? null : parseShadow(isDarkTheme(theme) ? CANVAS_STYLE.node.shadowDark : CANVAS_STYLE.node.shadowLight),
71
+ attrs: {
72
+ 'data-theme': theme,
73
+ ...(comfortIn.readableType ? { 'data-type': 'readable' } : {}),
74
+ ...(comfortIn.largerText ? { 'data-scale': 'lg' } : {}),
75
+ ...(comfortIn.reducedMotion ? { 'data-motion': 'reduced' } : {}),
76
+ },
77
+ edgeLabel: comfortIn.readableType ? { weight: CANVAS_STYLE.edge.readableLabelWeight, size: CANVAS_STYLE.edge.readableLabelSize } : { weight: CANVAS_STYLE.edge.labelWeight, size: CANVAS_STYLE.edge.labelSize },
78
+ };
79
+ cache.set(key, d);
80
+ return d;
81
+ }
82
+
83
+ /** Canvas font shorthand for an idea label at its natural (unscaled) size. */
84
+ export function ideaFont(d: Design, px: number): string {
85
+ const weight = TYPE_STYLES['node-label'].weight;
86
+ return `${weight} ${px}px ${d.fontHand}`;
87
+ }
88
+
89
+ export const DEFAULT_DESIGN: Design = makeDesign('light', { readableType: false, largerText: false, reducedMotion: false });
90
+
91
+ export interface DesignProps {
92
+ /**
93
+ * One of the 8 design-system themes ('light', 'dark', 'focus-light', 'focus-dark',
94
+ * 'colorsafe-light', 'colorsafe-dark', 'contrast-light', 'contrast-dark'), a family
95
+ * ('focus' | 'colorsafe' | 'contrast') or 'auto' (follow the reader's colour scheme).
96
+ */
97
+ theme?: ThemeInput;
98
+ /** @deprecated use `theme`. Kept for one release: true -> 'dark', false -> 'light'. */
99
+ isDark?: boolean;
100
+ /** Readable type, larger text, reduced motion. */
101
+ comfort?: Comfort;
102
+ /** Switch to the matching high-contrast theme under forced-colors (Windows high contrast etc.). Default true. */
103
+ respectForcedColors?: boolean;
104
+ }
105
+
106
+ /** Resolve props + reader environment (colour scheme, forced colours, reduced motion) to a Design. */
107
+ export function resolveDesign(props: DesignProps, env: { prefersDark: boolean; forcedColors: boolean; prefersReducedMotion: boolean }): Design {
108
+ const input: ThemeInput | undefined = props.theme ?? (props.isDark === undefined ? undefined : props.isDark ? 'dark' : 'light');
109
+ let theme = resolveTheme(input, { prefersDark: env.prefersDark, forcedColors: env.forcedColors });
110
+ // Forced colours override the reader's palette: honour that with the high-contrast theme of the same scheme.
111
+ if (env.forcedColors && props.respectForcedColors !== false && !isContrastTheme(theme)) {
112
+ theme = (isDarkTheme(theme) ? 'contrast-dark' : 'contrast-light') as ThemeName;
113
+ }
114
+ return makeDesign(theme, resolveComfort(props.comfort, { prefersReducedMotion: env.prefersReducedMotion }));
115
+ }
@@ -0,0 +1,303 @@
1
+ // ─── Canvas drawing ──────────────────────────────────────────────────────────
2
+ //
3
+ // Draws ideas and connectors onto a native 2D context using the
4
+ // design system's geometry and tokens. Konva owns the stage, layers, viewport
5
+ // and layer transitions; these functions only paint.
6
+ //
7
+ // Performance notes:
8
+ // - Outlines are cached as Path2D per (shape, rx, ry): built once, reused every frame.
9
+ // - Label line breaks are cached per (font epoch, font, width, text).
10
+ // - No allocations on the per-idea hot path except the label array on a cache miss.
11
+
12
+ import {
13
+ CANVAS_STYLE,
14
+ IDEA,
15
+ ideaDetail,
16
+ TYPE_STYLES,
17
+ blobPath,
18
+ ideaStyle,
19
+ labelGrowth,
20
+ nodeRadii,
21
+ ringsFor,
22
+ shapeOfLevel,
23
+ type ConnectorGeometry,
24
+ type FlatNode,
25
+ } from '@visualli/core';
26
+ import type { Design } from './design';
27
+ import { fontsEpoch } from './runtime';
28
+ import type { Arrival, EdgeArrival } from './choreography';
29
+
30
+ const DEG = Math.PI / 180;
31
+
32
+ // ── Path2D cache ──────────────────────────────────────────────────────────────
33
+
34
+ const MAX_CACHED_PATHS = 4096;
35
+ const pathCache = new Map<string, Path2D>();
36
+
37
+ /** Outline of a blob centred on the origin, as a cached Path2D (SVG path data from the design system). */
38
+ export function blobPath2D(shape: number, rx: number, ry: number): Path2D {
39
+ const key = `${shape}|${rx}|${ry}`;
40
+ let p = pathCache.get(key);
41
+ if (!p) {
42
+ if (pathCache.size >= MAX_CACHED_PATHS) pathCache.delete(pathCache.keys().next().value as string);
43
+ p = new Path2D(blobPath(shape, rx, ry));
44
+ pathCache.set(key, p);
45
+ }
46
+ return p;
47
+ }
48
+
49
+ /** Test hook. */
50
+ export const _pathCacheSize = (): number => pathCache.size;
51
+
52
+ const NO_DASH: number[] = [];
53
+ const NO_RINGS: ReadonlyArray<never> = [];
54
+ const dashCache = new Map<string, number[]>();
55
+ function dashOf(spec: string): number[] {
56
+ if (!spec) return NO_DASH;
57
+ let d = dashCache.get(spec);
58
+ if (!d) { d = spec.split(/\s+/).map(Number); dashCache.set(spec, d); }
59
+ return d;
60
+ }
61
+
62
+ // ── Label layout ──────────────────────────────────────────────────────────────
63
+
64
+ const lineCache = new Map<string, string[]>();
65
+ const MAX_CACHED_LAYOUTS = 8192;
66
+
67
+ /** Greedy word wrap at `maxWidth` (natural px), clamped to `maxLines` with an ellipsis (ideas pass Infinity: they are
68
+ * sized to hold their whole label). */
69
+ export function layoutLabel(c: CanvasRenderingContext2D, text: string, font: string, maxWidth: number, maxLines: number = Infinity): string[] {
70
+ const key = `${fontsEpoch()}|${font}|${maxWidth}|${text}`;
71
+ const hit = lineCache.get(key);
72
+ if (hit) return hit;
73
+ c.font = font;
74
+ const words = text.split(/\s+/).filter(Boolean);
75
+ const lines: string[] = [];
76
+ let cur = '';
77
+ const fit = (s: string) => c.measureText(s).width <= maxWidth;
78
+ for (const w of words) {
79
+ const next = cur ? `${cur} ${w}` : w;
80
+ if (fit(next)) { cur = next; continue; }
81
+ if (cur) lines.push(cur);
82
+ // A single word wider than the box is broken by characters.
83
+ let rest = w;
84
+ while (!fit(rest) && rest.length > 1) {
85
+ let n = rest.length - 1;
86
+ while (n > 1 && !fit(rest.slice(0, n))) n--;
87
+ lines.push(rest.slice(0, n));
88
+ rest = rest.slice(n);
89
+ }
90
+ cur = rest;
91
+ }
92
+ if (cur) lines.push(cur);
93
+ if (lines.length > maxLines) {
94
+ lines.length = maxLines;
95
+ let last = lines[maxLines - 1]! + '…';
96
+ while (last.length > 1 && !fit(last)) last = last.slice(0, -2) + '…';
97
+ lines[maxLines - 1] = last;
98
+ }
99
+ if (lineCache.size >= MAX_CACHED_LAYOUTS) lineCache.clear();
100
+ lineCache.set(key, lines);
101
+ return lines;
102
+ }
103
+
104
+ // ── Ideas ─────────────────────────────────────────────────────────────────────
105
+
106
+ export interface IdeaState {
107
+ hovered: boolean;
108
+ pressed: boolean;
109
+ selected: boolean;
110
+ /** Keyboard focus (the accessible layer is focused on this idea). */
111
+ focused: boolean;
112
+ dimmed: boolean;
113
+ /** Hover/select progress (0 at rest, 1 raised) from the choreography; defaults to the boolean state when omitted. */
114
+ lift?: number;
115
+ ring?: number;
116
+ /** Arrival look mid-reveal (bloom); null/omitted once arrived. */
117
+ arrival?: Arrival | null;
118
+ }
119
+ export const IDLE: IdeaState = { hovered: false, pressed: false, selected: false, focused: false, dimmed: false };
120
+
121
+ export interface IdeaOptions {
122
+ /** Drop shadows are skipped for big maps / while dragging. */
123
+ shadows: boolean;
124
+ /** Device pixels per world unit (canvas shadows are in device space). Computed once per frame by the caller; derived from the transform when omitted. */
125
+ k?: number;
126
+ /** Opacity of a dimmed idea (the design system's `.vi-node.is-dimmed` for the theme and layout); default the focus themes'. */
127
+ dimOpacity?: number;
128
+ /** The context's transform at the start of the frame. When given, ideas are placed with setTransform (no save/restore); the caller must restore it afterwards. */
129
+ base?: DOMMatrix;
130
+ }
131
+
132
+ /** Fill + ring an idea is drawn with in `d`'s theme: its topic's, or its custom colour as given (the design system's colour rule). */
133
+ export const ideaColors = (d: Design, n: Pick<FlatNode, 'topic' | 'custom'>) => ideaStyle(d.theme, n);
134
+
135
+ /** An idea's label size in natural px: the design system's size for its kind × the comfort label scale. */
136
+ export const ideaLabelPx = (n: Pick<FlatNode, 'kind'>, d: Design): number => IDEA.labelSize[n.kind ?? 'node'] * d.labelScale;
137
+
138
+ /**
139
+ * Paint one idea (rings, body, label) centred on node.x / node.y.
140
+ *
141
+ * With `opt.base` (the context's transform at the start of the frame) the idea is
142
+ * positioned with a single setTransform and no save/restore: the caller restores
143
+ * the base transform after the last idea. Without it, state is saved and restored.
144
+ */
145
+ export function drawIdea(c: CanvasRenderingContext2D, node: FlatNode, d: Design, zoom: number, st: IdeaState, opt: IdeaOptions): void {
146
+ const { rx, ry } = nodeRadii(node);
147
+ const { fill, ring } = ideaColors(d, node);
148
+ const screenW = node.width * zoom;
149
+ const base = opt.base;
150
+
151
+ // Specks: one filled box, no transform changes.
152
+ // Level of detail (geometry/detail.ts): a speck below DETAIL.speckBelow, body only below DETAIL.bodyOnlyBelow.
153
+ const detail = ideaDetail(screenW, st.selected || st.focused || st.hovered);
154
+ if (detail === 'speck') {
155
+ const a = (st.dimmed ? opt.dimOpacity ?? CANVAS_STYLE.node.dimmedOpacityFocus : 1) * (st.arrival ? st.arrival.alpha : 1);
156
+ if (a !== 1) c.globalAlpha = a;
157
+ c.fillStyle = fill;
158
+ c.fillRect(node.x - rx, node.y - ry, rx * 2, ry * 2);
159
+ if (a !== 1) c.globalAlpha = 1;
160
+ return;
161
+ }
162
+
163
+ const detailed = detail === 'full';
164
+ const shape = shapeOfLevel(node.level);
165
+ const rings = detailed ? ringsFor(node.branchCount) : NO_RINGS;
166
+ const raised = st.hovered || st.pressed || st.selected;
167
+ const lift = st.lift ?? (raised ? 1 : 0);
168
+ const ringT = st.ring ?? (raised ? 1 : 0);
169
+ const arr = st.arrival;
170
+ const y = node.y + lift * CANVAS_STYLE.node.hoverLift + (arr ? arr.dy : 0);
171
+ const x = node.x + (arr ? arr.dx : 0);
172
+ let k = opt.k;
173
+ if (k === undefined) { const m = c.getTransform(); k = Math.hypot(m.a, m.b); } // device px per world unit
174
+
175
+ if (base) c.setTransform(base.a, base.b, base.c, base.d, base.e + base.a * x + base.c * y, base.f + base.b * x + base.d * y);
176
+ else { c.save(); c.translate(x, y); }
177
+ if (arr) c.scale(arr.scale, arr.scale);
178
+ c.lineJoin = 'round';
179
+ const baseAlpha = (st.dimmed ? opt.dimOpacity ?? CANVAS_STYLE.node.dimmedOpacityFocus : 1) * (arr ? arr.alpha : 1);
180
+ c.globalAlpha = baseAlpha;
181
+
182
+ // Rings, outermost first so the inner ones sit on top.
183
+ if (rings.length) {
184
+ const ringSpin = ringT * CANVAS_STYLE.node.hoverRingsRotate;
185
+ const ringGrow = 1 + ringT * (CANVAS_STYLE.node.hoverRingsScale - 1);
186
+ c.fillStyle = fill;
187
+ c.strokeStyle = ring;
188
+ let dashed = false;
189
+ for (let i = rings.length - 1; i >= 0; i--) {
190
+ const r = rings[i]!;
191
+ const angle = (r.rotate + ringSpin) * DEG;
192
+ c.rotate(angle);
193
+ c.globalAlpha = baseAlpha * r.opacity;
194
+ const path = blobPath2D(shape, rx * r.scale * ringGrow, ry * r.scale * ringGrow);
195
+ c.fill(path);
196
+ c.lineWidth = r.width;
197
+ if (r.dash) { c.setLineDash(dashOf(r.dash)); dashed = true; } else if (dashed) { c.setLineDash(NO_DASH); dashed = false; }
198
+ c.stroke(path);
199
+ c.rotate(-angle);
200
+ }
201
+ if (dashed) c.setLineDash(NO_DASH);
202
+ c.globalAlpha = baseAlpha;
203
+ }
204
+
205
+ // Body.
206
+ const body = blobPath2D(shape, rx, ry);
207
+ c.fillStyle = fill;
208
+ const shadow = detailed && opt.shadows ? d.nodeShadow : null;
209
+ if (shadow) {
210
+ c.shadowColor = shadow.color;
211
+ c.shadowBlur = shadow.blur * k;
212
+ c.shadowOffsetX = shadow.x * k;
213
+ c.shadowOffsetY = shadow.y * k;
214
+ }
215
+ c.fill(body);
216
+ if (shadow) { c.shadowColor = 'transparent'; c.shadowBlur = 0; c.shadowOffsetX = 0; c.shadowOffsetY = 0; }
217
+ c.strokeStyle = st.focused ? d.tokens['focus']! : st.selected ? d.tokens['ink']! : ring;
218
+ c.lineWidth = st.selected || st.focused ? CANVAS_STYLE.node.selectedStrokeWidth : d.metrics.nodeStroke;
219
+ c.stroke(body);
220
+
221
+ // Label.
222
+ // Label: the whole label (the idea is sized for it, idea.ts), growing up to LABEL_GROWTH.idea when zoomed out.
223
+ if (detailed && node.title) {
224
+ const px = ideaLabelPx(node, d);
225
+ const font = `${TYPE_STYLES['node-label'].weight} ${px}px ${d.fontHand}`;
226
+ const lines = layoutLabel(c, node.title, font, node.width - IDEA.labelInset);
227
+ const s = labelGrowth(zoom).idea;
228
+ const lh = px * IDEA.labelLineHeight;
229
+ c.scale(s, s);
230
+ c.font = font;
231
+ c.fillStyle = d.tokens['node-ink']!;
232
+ c.textAlign = 'center';
233
+ c.textBaseline = 'middle';
234
+ if (d.comfort.readableType && 'letterSpacing' in c) (c as unknown as { letterSpacing: string }).letterSpacing = `${(px * 0.02).toFixed(2)}px`;
235
+ const y0 = -((lines.length - 1) * lh) / 2;
236
+ for (let i = 0; i < lines.length; i++) c.fillText(lines[i]!, 0, y0 + i * lh);
237
+ }
238
+ if (base) c.globalAlpha = 1; else c.restore();
239
+ }
240
+
241
+ // ── Connectors ────────────────────────────────────────────────────────────────
242
+
243
+ export interface PreparedConnector extends ConnectorGeometry {
244
+ path: Path2D;
245
+ arrowPath: Path2D;
246
+ /** Length of the curve in world units (for drawing it from source to target). */
247
+ len: number;
248
+ }
249
+
250
+ /** Length of the connector's cubic ("M x y C x y x y x y"), by sampling. */
251
+ function cubicLength(d: string): number {
252
+ const v = d.match(/-?\d+(?:\.\d+)?/g)?.map(Number);
253
+ if (!v || v.length < 8) return 0;
254
+ const [x0, y0, x1, y1, x2, y2, x3, y3] = v as [number, number, number, number, number, number, number, number];
255
+ let len = 0, px = x0, py = y0;
256
+ for (let i = 1; i <= 16; i++) {
257
+ const t = i / 16, u = 1 - t;
258
+ const x = u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x3;
259
+ const y = u * u * u * y0 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y3;
260
+ len += Math.hypot(x - px, y - py); px = x; py = y;
261
+ }
262
+ return len;
263
+ }
264
+
265
+ export function prepareConnector(g: ConnectorGeometry): PreparedConnector {
266
+ return { ...g, path: new Path2D(g.d), arrowPath: new Path2D(g.arrow), len: cubicLength(g.d) };
267
+ }
268
+
269
+ /** Connectors shorter than this on screen, and labels smaller than LABEL_MIN_SCREEN_PX, are invisible: skipped. */
270
+ const CONNECTOR_MIN_SCREEN_PX = 3;
271
+ const LABEL_MIN_SCREEN_PX = 6;
272
+
273
+ export function drawConnector(c: CanvasRenderingContext2D, g: PreparedConnector, d: Design, zoom: number, dashed: boolean, label: string | undefined, arrival?: EdgeArrival | null): void {
274
+ if (Math.hypot(g.b.x - g.a.x, g.b.y - g.a.y) * zoom < CONNECTOR_MIN_SCREEN_PX) return;
275
+ c.save();
276
+ if (arrival) c.globalAlpha = arrival.alpha;
277
+ c.strokeStyle = d.tokens['edge']!;
278
+ c.lineWidth = d.metrics.edgeWidth;
279
+ c.lineCap = 'round';
280
+ c.lineJoin = 'round';
281
+ if (dashed) c.setLineDash([...CANVAS_STYLE.edge.dash]);
282
+ else if (arrival && arrival.draw < 1) c.setLineDash([g.len * arrival.draw, g.len * 2]); // draws from source to target
283
+ if (!(arrival && arrival.draw <= 0.001)) c.stroke(g.path);
284
+ c.setLineDash([]);
285
+ c.stroke(g.arrowPath);
286
+
287
+ // The design system's connector label (`.vi-edge__label`), growing up to LABEL_GROWTH.connector when zoomed out (`--vi-inv`); the readable face is smaller and bolder.
288
+ const labelPx = label ? d.edgeLabel.size * labelGrowth(zoom).connector : 0;
289
+ if (label && labelPx * zoom >= LABEL_MIN_SCREEN_PX) {
290
+ const px = labelPx;
291
+ c.font = `${d.edgeLabel.weight} ${px}px ${d.fontNote}`;
292
+ c.textAlign = 'center';
293
+ c.textBaseline = 'middle';
294
+ // paint-order: stroke — a halo in the canvas colour keeps the label legible over the line.
295
+ c.lineWidth = CANVAS_STYLE.edge.labelHaloWidth;
296
+ c.strokeStyle = d.tokens['canvas']!;
297
+ c.strokeText(label, g.mid.x, g.mid.y);
298
+ c.fillStyle = d.tokens['edge-label']!;
299
+ c.fillText(label, g.mid.x, g.mid.y);
300
+ }
301
+ c.restore();
302
+ }
303
+
@@ -0,0 +1,34 @@
1
+ // ─── Label measurement ────────────────────────────────────────────────────────
2
+ //
3
+ // The text measurement the design system's ideaSize (geometry/idea.ts) needs:
4
+ // the idea label's width in its own face and weight — Kalam, or the UI face with
5
+ // its .02em tracking in readable type — on a 2D canvas, the same engine that draws it.
6
+
7
+ import { TYPE_STYLES, approximateMeasure, type IdeaMeasure } from '@visualli/core';
8
+ import type { Design } from './design';
9
+ import { fontsEpoch } from './runtime';
10
+
11
+ let ctx: CanvasRenderingContext2D | null | undefined;
12
+ const cache = new Map<string, number>();
13
+ const MAX_CACHED = 8192;
14
+
15
+ /** Width of a label in `d`'s idea face (cached per face, size, text and font load). */
16
+ export function ideaMeasure(d: Pick<Design, 'fontHand' | 'comfort'>): IdeaMeasure {
17
+ if (ctx === undefined) ctx = typeof document !== 'undefined' ? document.createElement('canvas').getContext('2d') : null;
18
+ const c = ctx;
19
+ if (!c) return approximateMeasure;
20
+ const weight = TYPE_STYLES['node-label'].weight;
21
+ const tracking = d.comfort.readableType ? 0.02 : 0;
22
+ return (text, px) => {
23
+ const font = `${weight} ${px}px ${d.fontHand}`;
24
+ const key = `${fontsEpoch()}|${font}|${text}`;
25
+ let w = cache.get(key);
26
+ if (w === undefined) {
27
+ c.font = font;
28
+ w = c.measureText(text).width + text.length * px * tracking;
29
+ if (cache.size >= MAX_CACHED) cache.clear();
30
+ cache.set(key, w);
31
+ }
32
+ return w;
33
+ };
34
+ }