@visualli/core 0.1.6 → 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 (127) hide show
  1. package/README.md +37 -105
  2. package/dist/chunk-25AJTPMS.js +68 -0
  3. package/dist/chunk-25AJTPMS.js.map +1 -0
  4. package/dist/{chunk-6NXADJK2.js → chunk-7NJWM7ZE.js} +84 -117
  5. package/dist/chunk-7NJWM7ZE.js.map +1 -0
  6. package/dist/chunk-KVSM3APM.js +96 -0
  7. package/dist/chunk-KVSM3APM.js.map +1 -0
  8. package/dist/{chunk-PDP5CVUL.js → chunk-LOQLNYB3.js} +2 -2
  9. package/dist/chunk-LV3EXUQY.js +753 -0
  10. package/dist/chunk-LV3EXUQY.js.map +1 -0
  11. package/dist/chunk-N7HCYULH.js +12 -0
  12. package/dist/chunk-N7HCYULH.js.map +1 -0
  13. package/dist/{chunk-I75TMOE4.js → chunk-P6PKWVYF.js} +2 -2
  14. package/dist/chunk-TPLJJKAU.js +151 -0
  15. package/dist/chunk-TPLJJKAU.js.map +1 -0
  16. package/dist/chunk-UAMEJG2C.js +267 -0
  17. package/dist/chunk-UAMEJG2C.js.map +1 -0
  18. package/dist/{chunk-LJYSKSCE.js → chunk-W2CAOOJJ.js} +6 -6
  19. package/dist/chunk-W2CAOOJJ.js.map +1 -0
  20. package/dist/config/index.cjs +250 -349
  21. package/dist/config/index.cjs.map +1 -1
  22. package/dist/config/index.d.cts +107 -66
  23. package/dist/config/index.d.ts +107 -66
  24. package/dist/config/index.js +52 -34
  25. package/dist/constants/index.cjs +14 -333
  26. package/dist/constants/index.cjs.map +1 -1
  27. package/dist/constants/index.d.cts +5 -243
  28. package/dist/constants/index.d.ts +5 -243
  29. package/dist/constants/index.js +5 -75
  30. package/dist/designSystem-BOrZHfEq.d.cts +150 -0
  31. package/dist/designSystem-BOrZHfEq.d.ts +150 -0
  32. package/dist/document-BFBwn93X.d.ts +26 -0
  33. package/dist/document-MEJEfxl3.d.cts +26 -0
  34. package/dist/ideaSize-CLDKeyqD.d.cts +11 -0
  35. package/dist/ideaSize-DxxGy2E8.d.ts +11 -0
  36. package/dist/index-BmILeKj6.d.cts +99 -0
  37. package/dist/index-CcPRwVUL.d.ts +99 -0
  38. package/dist/index.cjs +1636 -961
  39. package/dist/index.cjs.map +1 -1
  40. package/dist/index.d.cts +350 -27
  41. package/dist/index.d.ts +350 -27
  42. package/dist/index.js +445 -190
  43. package/dist/index.js.map +1 -1
  44. package/dist/{document-B3hnNr-F.d.cts → layer-B2A3Ki48.d.cts} +2 -16
  45. package/dist/{document-B3hnNr-F.d.ts → layer-B2A3Ki48.d.ts} +2 -16
  46. package/dist/layout/index.d.cts +2 -1
  47. package/dist/layout/index.d.ts +2 -1
  48. package/dist/mindmap-C3Qr0qKW.d.cts +182 -0
  49. package/dist/mindmap-DHeVa86G.d.ts +182 -0
  50. package/dist/parser/index.cjs +745 -179
  51. package/dist/parser/index.cjs.map +1 -1
  52. package/dist/parser/index.d.cts +23 -8
  53. package/dist/parser/index.d.ts +23 -8
  54. package/dist/parser/index.js +7 -2
  55. package/dist/performance/index.cjs +13 -0
  56. package/dist/performance/index.cjs.map +1 -1
  57. package/dist/performance/index.js +2 -2
  58. package/dist/theme/index.cjs +884 -0
  59. package/dist/theme/index.cjs.map +1 -0
  60. package/dist/theme/index.d.cts +2 -0
  61. package/dist/theme/index.d.ts +2 -0
  62. package/dist/theme/index.js +53 -0
  63. package/dist/types/index.d.cts +4 -2
  64. package/dist/types/index.d.ts +4 -2
  65. package/dist/viewport/index.cjs +17 -6
  66. package/dist/viewport/index.cjs.map +1 -1
  67. package/dist/viewport/index.d.cts +6 -4
  68. package/dist/viewport/index.d.ts +6 -4
  69. package/dist/viewport/index.js +2 -2
  70. package/licenses/visualli-design-system/LICENSE +9 -0
  71. package/licenses/visualli-design-system/NOTICE.md +6 -0
  72. package/package.json +13 -5
  73. package/src/config/README.md +2 -4
  74. package/src/config/USAGE_EXAMPLES.md +0 -4
  75. package/src/config/index.ts +3 -4
  76. package/src/constants/index.ts +1 -1
  77. package/src/constants/layout.ts +11 -0
  78. package/src/constants/performanceConstants.ts +8 -27
  79. package/src/generated/README.md +12 -0
  80. package/src/generated/designSystem.ts +758 -0
  81. package/src/generated/geometry/blob.ts +99 -0
  82. package/src/generated/geometry/blobShapes.ts +4 -0
  83. package/src/generated/geometry/color.ts +37 -0
  84. package/src/generated/geometry/container.ts +84 -0
  85. package/src/generated/geometry/detail.ts +22 -0
  86. package/src/generated/geometry/idea.ts +56 -0
  87. package/src/generated/geometry/interaction.ts +122 -0
  88. package/src/generated/geometry/motion.ts +140 -0
  89. package/src/index.ts +18 -1
  90. package/src/parser/configUtils.ts +12 -19
  91. package/src/parser/visualliConverter.ts +71 -112
  92. package/src/parser/visualliParser.ts +16 -1
  93. package/src/rendering/culling.ts +16 -12
  94. package/src/rendering/ideaSize.ts +21 -0
  95. package/src/rendering/nodeGeometry.ts +97 -0
  96. package/src/theme/index.ts +136 -0
  97. package/src/types/document.ts +9 -0
  98. package/src/types/layer.ts +1 -1
  99. package/src/types/mindmap.ts +13 -0
  100. package/src/utils/navigation.ts +33 -63
  101. package/src/viewport/viewportUtils.ts +6 -4
  102. package/dist/animations/index.cjs +0 -146
  103. package/dist/animations/index.cjs.map +0 -1
  104. package/dist/animations/index.d.cts +0 -81
  105. package/dist/animations/index.d.ts +0 -81
  106. package/dist/animations/index.js +0 -35
  107. package/dist/chunk-6NXADJK2.js.map +0 -1
  108. package/dist/chunk-F36SMC3K.js +0 -106
  109. package/dist/chunk-F36SMC3K.js.map +0 -1
  110. package/dist/chunk-G5H3P4YZ.js +0 -273
  111. package/dist/chunk-G5H3P4YZ.js.map +0 -1
  112. package/dist/chunk-LDJRGT2Q.js +0 -163
  113. package/dist/chunk-LDJRGT2Q.js.map +0 -1
  114. package/dist/chunk-LJYSKSCE.js.map +0 -1
  115. package/dist/chunk-LNTJTDB2.js +0 -349
  116. package/dist/chunk-LNTJTDB2.js.map +0 -1
  117. package/dist/mindmap-DWS0WZT_.d.cts +0 -118
  118. package/dist/mindmap-DWS0WZT_.d.ts +0 -118
  119. package/src/animations/constants.ts +0 -72
  120. package/src/animations/easing.ts +0 -70
  121. package/src/animations/index.ts +0 -2
  122. package/src/config/blobShapes.ts +0 -146
  123. package/src/config/textScaling.ts +0 -54
  124. package/src/constants/design.ts +0 -281
  125. /package/dist/{chunk-PDP5CVUL.js.map → chunk-LOQLNYB3.js.map} +0 -0
  126. /package/dist/{chunk-I75TMOE4.js.map → chunk-P6PKWVYF.js.map} +0 -0
  127. /package/dist/{animations → theme}/index.js.map +0 -0
@@ -3,7 +3,15 @@
3
3
  // Helpers for creating and generating MindMapConfig objects (pure data, no UI).
4
4
 
5
5
  import type { MindMapNode, MindMapConfig, TopLevelConnection } from '../types/mindmap.js';
6
- import { LEVEL_COLORS, LAYOUT_SPACING, getColorForLevel } from '../constants/design.js';
6
+ import { LAYOUT_SPACING } from '../constants/layout.js';
7
+ import { TOKENS, TOPICS } from '../theme/index.js';
8
+
9
+ /** Light-theme fill of the topic used for a level (cycles through the design-system topics). */
10
+ const levelColor = (level: number): string => TOKENS.light[`topic-${TOPICS[level % TOPICS.length]}`]!;
11
+
12
+ type LevelColors = { level0: string; level1: string; level2: string; level3: string; [key: `level${number}`]: string };
13
+ const levelColors = (count: number): LevelColors =>
14
+ Object.fromEntries(Array.from({ length: count }, (_, i) => [`level${i}`, levelColor(i)])) as LevelColors;
7
15
 
8
16
  export function createMindMapConfig(
9
17
  nodes: MindMapNode[],
@@ -22,12 +30,7 @@ export function createMindMapConfig(
22
30
  nodes,
23
31
  topLevelConnections,
24
32
  settings: {
25
- defaultColors: {
26
- level0: LEVEL_COLORS.level0,
27
- level1: LEVEL_COLORS.level1,
28
- level2: LEVEL_COLORS.level2,
29
- level3: LEVEL_COLORS.level3,
30
- },
33
+ defaultColors: levelColors(4),
31
34
  layout: {
32
35
  nodeSpacing: LAYOUT_SPACING.nodeSpacing,
33
36
  levelSpacing: LAYOUT_SPACING.levelSpacing,
@@ -70,7 +73,7 @@ function generateLevelNodes(
70
73
  level,
71
74
  x,
72
75
  y,
73
- color: getColorForLevel(level),
76
+ color: levelColor(level),
74
77
  relationshipLabel: level > 0 ? 'child' : undefined,
75
78
  parent: parentId ?? undefined,
76
79
  };
@@ -109,17 +112,7 @@ export function generateSampleConfig(maxLevels = 9): MindMapConfig {
109
112
  nodes,
110
113
  topLevelConnections,
111
114
  settings: {
112
- defaultColors: {
113
- level0: LEVEL_COLORS.level0,
114
- level1: LEVEL_COLORS.level1,
115
- level2: LEVEL_COLORS.level2,
116
- level3: LEVEL_COLORS.level3,
117
- level4: LEVEL_COLORS.level4,
118
- level5: LEVEL_COLORS.level5,
119
- level6: LEVEL_COLORS.level6,
120
- level7: LEVEL_COLORS.level7,
121
- level8: LEVEL_COLORS.level8,
122
- },
115
+ defaultColors: levelColors(9),
123
116
  layout: {
124
117
  nodeSpacing: LAYOUT_SPACING.nodeSpacing,
125
118
  levelSpacing: LAYOUT_SPACING.levelSpacing,
@@ -7,6 +7,8 @@ import type { VisualliDocument } from '../types/document.js';
7
7
  import type { VisualliLayer } from '../types/layer.js';
8
8
  import type { FlatNode, NodeMap } from '../types/mindmap.js';
9
9
  import { countLayersBeneath } from './visualliParser.js';
10
+ import { ideaColor } from '../theme/index.js';
11
+ import { approximateMeasure, ideaKindOf, ideaSize, type IdeaMeasure } from '../rendering/ideaSize.js';
10
12
  import { applyCircularLayout, calculateOptimalRadiusPercentage } from '../layout/circularLayout.js';
11
13
  import { applyLinearHorizontalLayout, applyLinearVerticalLayout } from '../layout/linearLayout.js';
12
14
 
@@ -57,26 +59,22 @@ export function resolveNodeOverlaps(nodes: FlatNode[], maxIterations = 10): void
57
59
  }
58
60
  }
59
61
 
60
- // ── Color Helpers ─────────────────────────────────────────────────────────────
62
+ // ── Options ───────────────────────────────────────────────────────────────────
61
63
 
62
- const COLOR_PALETTE = [
63
- '#12C7D3', '#325E8C', '#8A70A6', '#F54A57', '#FF6C4D',
64
- '#F28C16', '#FFD347', '#12C7D3', '#7F7F7F', '#8D8D8D', '#12C7D3',
65
- ];
64
+ export interface ConvertOptions {
65
+ /** Measures label text in the idea face (the renderer's font engine); ideas are sized with the design system's
66
+ * ideaSize. Defaults to an approximation (half an em per character) where no font engine exists. */
67
+ measure?: IdeaMeasure;
68
+ /** Label scale (comfort: larger text is 1.15); ideas grow to fit the larger label. */
69
+ labelScale?: number;
70
+ }
66
71
 
67
- /**
68
- * Get a deterministic random color from palette based on node ID
69
- */
70
- function getRandomColorForNode(nodeId: string): string {
71
- // Simple hash function to convert string to number
72
- let hash = 0;
73
- for (let i = 0; i < nodeId.length; i++) {
74
- hash = nodeId.charCodeAt(i) + ((hash << 5) - hash);
75
- }
76
-
77
- // Use absolute value and modulo to get palette index
78
- const index = Math.abs(hash) % COLOR_PALETTE.length;
79
- return COLOR_PALETTE[index];
72
+ /** Whether a layer's ideas carry their own positions: every idea has one, and (with more than one idea) they aren't
73
+ * all on the same spot (a placeholder). Then the file's positions are used as they are. */
74
+ function hasFilePositions(layer: VisualliLayer): boolean {
75
+ const ps = layer.nodes.map((n) => n.position);
76
+ if (!ps.length || !ps.every((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y))) return false;
77
+ return ps.length === 1 || ps.some((p) => p!.x !== ps[0]!.x || p!.y !== ps[0]!.y);
80
78
  }
81
79
 
82
80
  // ── Container-formation Layout ────────────────────────────────────────────────
@@ -137,35 +135,33 @@ function proxyRadial(proxies: Proxy[], radius = 500): void {
137
135
 
138
136
  // ── Layer Conversion ──────────────────────────────────────────────────────────
139
137
 
140
- /**
141
- * Calculate dynamic node width based on title length.
142
- * Formula matches visualli.ai: max(200, title.length * 8 + 40)
143
- */
144
- function calculateNodeWidth(title: string): number {
145
- return Math.max(200, title.length * 8 + 40);
146
- }
147
-
148
138
  function makeFlatNode(
149
- nodeId: string,
150
- label: string,
151
- summary: string,
139
+ node: VisualliLayer['nodes'][number],
152
140
  layer: VisualliLayer,
153
- color: string,
154
- branchCount: number,
155
- x = 0,
156
- y = 0,
141
+ doc: VisualliDocument,
142
+ /** Position among the layer's ideas (file order): missing colours cycle the topics by it, and the first idea of the root radial layer is the root. */
143
+ siblingIndex: number,
144
+ opts: ConvertOptions,
157
145
  ): FlatNode {
146
+ const label = Array.isArray(node.data.label) ? (node.data.label as string[]).join(' ') : (node.data.label || 'Untitled');
147
+ const branchCount = countLayersBeneath(doc, node.id);
148
+ const kind = ideaKindOf(layer, siblingIndex);
149
+ const size = ideaSize(kind, label, opts.measure ?? approximateMeasure, opts.labelScale ?? 1);
150
+ const { topic, custom } = ideaColor(node.data.color, siblingIndex);
158
151
  return {
159
- id: nodeId,
152
+ id: node.id,
160
153
  parentId: layer.parentNodeId ?? null,
161
- x,
162
- y,
154
+ x: node.position?.x ?? 0,
155
+ y: node.position?.y ?? 0,
163
156
  level: layer.level,
164
157
  title: label,
165
- description: summary,
166
- color,
167
- width: calculateNodeWidth(label),
168
- height: 80,
158
+ description: node.data.summary || '',
159
+ color: node.data.color ?? '',
160
+ ...(topic ? { topic } : {}),
161
+ ...(custom ? { custom } : {}),
162
+ kind,
163
+ width: size.width,
164
+ height: size.height,
169
165
  isExpanded: branchCount > 0,
170
166
  branchCount,
171
167
  createdAt: new Date(),
@@ -173,26 +169,8 @@ function makeFlatNode(
173
169
  };
174
170
  }
175
171
 
176
- function convertLayerWithContainers(
177
- layer: VisualliLayer,
178
- doc: VisualliDocument,
179
- ): FlatNode[] {
172
+ function layoutLayerWithContainers(layer: VisualliLayer, flatNodes: FlatNode[]): void {
180
173
  const PADDING = 80;
181
- const flatNodes: FlatNode[] = [];
182
-
183
- for (const node of layer.nodes) {
184
- const label = Array.isArray(node.data.label)
185
- ? (node.data.label as string[]).join(' ')
186
- : (node.data.label || 'Untitled');
187
- const branchCount = countLayersBeneath(doc, node.id);
188
- flatNodes.push(
189
- makeFlatNode(
190
- node.id, label, node.data.summary || '', layer,
191
- node.data.color || getRandomColorForNode(node.id), branchCount,
192
- ),
193
- );
194
- }
195
-
196
174
  const byId = new Map(flatNodes.map((n: FlatNode) => [n.id, n]));
197
175
  const containerNodeIds = new Set<string>();
198
176
  const containerGroups = new Map<string, FlatNode[]>();
@@ -207,11 +185,8 @@ function convertLayerWithContainers(
207
185
  }
208
186
 
209
187
  const proxies: Proxy[] = [];
210
- for (const node of layer.nodes) {
211
- if (!containerNodeIds.has(node.id)) {
212
- const nodeWidth = calculateNodeWidth(node.data.label);
213
- proxies.push({ id: node.id, x: 0, y: 0, width: nodeWidth, height: 80 });
214
- }
188
+ for (const n of flatNodes) {
189
+ if (!containerNodeIds.has(n.id)) proxies.push({ id: n.id, x: 0, y: 0, width: n.width, height: n.height });
215
190
  }
216
191
  for (const container of layer.containers) {
217
192
  const group = containerGroups.get(container.id);
@@ -234,60 +209,43 @@ function convertLayerWithContainers(
234
209
  if (fn) { fn.x = proxy.x; fn.y = proxy.y; }
235
210
  }
236
211
  }
212
+ }
237
213
 
238
- resolveNodeOverlaps(flatNodes);
239
-
240
- return flatNodes;
214
+ function layoutLayer(layer: VisualliLayer, flatNodes: FlatNode[]): void {
215
+ const layout = layer.layout || 'radial';
216
+ if (layout === 'linear-horizontal') {
217
+ applyLinearHorizontalLayout(flatNodes, { centerX: 0, centerY: 0 });
218
+ } else if (layout === 'linear-vertical') {
219
+ applyLinearVerticalLayout(flatNodes, { centerX: 0, centerY: 0 });
220
+ } else {
221
+ const pct = calculateOptimalRadiusPercentage(flatNodes.length, 2000, 2000, 200, 30);
222
+ applyCircularLayout(flatNodes, {
223
+ radiusPercentage: pct,
224
+ containerWidth: 2000,
225
+ containerHeight: 2000,
226
+ centerX: 0,
227
+ centerY: 0,
228
+ });
229
+ }
241
230
  }
242
231
 
243
232
  /**
244
- * Convert a single layer to an array of FlatNode objects.
245
- * Layout algorithm is determined by `layer.layout` (default: radial).
233
+ * Convert a single layer to an array of FlatNode objects, each sized for its label
234
+ * (the design system's idea.ts) and coloured by its colour rule (color.ts).
235
+ * Positions: the file's own when its ideas carry them; otherwise the SDK lays the
236
+ * layer out by `layer.layout` (default radial) and container formations.
246
237
  */
247
238
  export function convertLayerToFlatNodes(
248
239
  layer: VisualliLayer,
249
240
  doc: VisualliDocument,
241
+ opts: ConvertOptions = {},
250
242
  ): FlatNode[] {
251
- if ((layer.containers ?? []).length > 0) {
252
- return convertLayerWithContainers(layer, doc);
253
- }
254
-
255
- const flatNodes: FlatNode[] = [];
256
-
257
- for (const node of layer.nodes) {
258
- const label = Array.isArray(node.data.label)
259
- ? (node.data.label as string[]).join(' ')
260
- : (node.data.label || 'Untitled');
261
- const branchCount = countLayersBeneath(doc, node.id);
262
- flatNodes.push(
263
- makeFlatNode(
264
- node.id, label, node.data.summary || '', layer,
265
- node.data.color || getRandomColorForNode(node.id), branchCount,
266
- 0, 0,
267
- ),
268
- );
269
- }
270
-
271
- if (flatNodes.length > 0) {
272
- const layout = layer.layout || 'radial';
273
- if (layout === 'linear-horizontal') {
274
- applyLinearHorizontalLayout(flatNodes, { centerX: 0, centerY: 0 });
275
- } else if (layout === 'linear-vertical') {
276
- applyLinearVerticalLayout(flatNodes, { centerX: 0, centerY: 0 });
277
- } else {
278
- const pct = calculateOptimalRadiusPercentage(flatNodes.length, 2000, 2000, 200, 30);
279
- applyCircularLayout(flatNodes, {
280
- radiusPercentage: pct,
281
- containerWidth: 2000,
282
- containerHeight: 2000,
283
- centerX: 0,
284
- centerY: 0,
285
- });
286
- }
287
- }
288
-
243
+ const flatNodes = layer.nodes.map((node, i) => makeFlatNode(node, layer, doc, i, opts));
244
+ if (flatNodes.length === 0 || hasFilePositions(layer)) return flatNodes;
245
+ for (const n of flatNodes) { n.x = 0; n.y = 0; }
246
+ if ((layer.containers ?? []).length > 0) layoutLayerWithContainers(layer, flatNodes);
247
+ else layoutLayer(layer, flatNodes);
289
248
  resolveNodeOverlaps(flatNodes);
290
-
291
249
  return flatNodes;
292
250
  }
293
251
 
@@ -295,11 +253,11 @@ export function convertLayerToFlatNodes(
295
253
  * Convert an entire .visualli document to a flat NodeMap.
296
254
  * All layers are converted and a children-index is built for O(1) tree traversal.
297
255
  */
298
- export function convertVisualliToFlatNodes(doc: VisualliDocument): NodeMap {
256
+ export function convertVisualliToFlatNodes(doc: VisualliDocument, opts: ConvertOptions = {}): NodeMap {
299
257
  const map: NodeMap = new Map();
300
258
 
301
259
  for (const layer of doc.layers.values()) {
302
- for (const node of convertLayerToFlatNodes(layer, doc)) {
260
+ for (const node of convertLayerToFlatNodes(layer, doc, opts)) {
303
261
  map.set(node.id, node);
304
262
  }
305
263
  }
@@ -320,9 +278,10 @@ export function convertVisualliToFlatNodes(doc: VisualliDocument): NodeMap {
320
278
  *
321
279
  * @param doc - Parsed .visualli document
322
280
  * @param layerId - Layer ID to extract
281
+ * @param opts - Text measurement and label scale for sizing ideas
323
282
  * @returns FlatNode array, or empty array if layer not found
324
283
  */
325
- export function getNodesForLayer(doc: VisualliDocument, layerId: string): FlatNode[] {
284
+ export function getNodesForLayer(doc: VisualliDocument, layerId: string, opts: ConvertOptions = {}): FlatNode[] {
326
285
  const layer = doc.layers.get(layerId);
327
- return layer ? convertLayerToFlatNodes(layer, doc) : [];
286
+ return layer ? convertLayerToFlatNodes(layer, doc, opts) : [];
328
287
  }
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Reads JSONL (JSON Lines) format and parses into a structured VisualliDocument.
4
4
 
5
- import type { VisualliDocument } from '../types/document.js';
5
+ import type { VisualliDocument, SemanticAnchor } from '../types/document.js';
6
6
  import type { VisualliMeta } from '../types/meta.js';
7
7
  import type { VisualliLayer } from '../types/layer.js';
8
8
  import type { Layer } from '../types/schema.js';
@@ -92,6 +92,14 @@ export function parseVisualliFile(content: string): VisualliDocument {
92
92
  break;
93
93
  }
94
94
 
95
+ case 'extension': {
96
+ const id = obj['id'];
97
+ if (typeof id === 'string') {
98
+ (doc.extensions ??= {})[id] = Array.isArray(obj['data']) ? (obj['data'] as unknown[]) : [];
99
+ }
100
+ break;
101
+ }
102
+
95
103
  default:
96
104
  // Unknown type — silently skip
97
105
  break;
@@ -148,6 +156,13 @@ export function loadVisualliFileFromFile(file: File): Promise<VisualliDocument>
148
156
  // ── Tree Helpers ──────────────────────────────────────────────────────────────
149
157
 
150
158
  /** Return all layers whose `parentNodeId` matches the given node ID. */
159
+ /** Terms defined by the document's `semantic-anchors` extension (empty when absent). */
160
+ export function getSemanticAnchors(doc: VisualliDocument): SemanticAnchor[] {
161
+ const raw = doc.extensions?.['semantic-anchors'];
162
+ if (!Array.isArray(raw)) return [];
163
+ return raw.filter((a): a is SemanticAnchor => !!a && typeof (a as SemanticAnchor).word === 'string' && typeof (a as SemanticAnchor).description === 'string');
164
+ }
165
+
151
166
  export function getChildLayers(
152
167
  doc: VisualliDocument,
153
168
  nodeId: string,
@@ -1,6 +1,7 @@
1
1
  import type { FlatNode, ViewportState } from '../types/index.js';
2
2
  import { RBushSpatialIndex } from '../spatial/spatialIndex.js';
3
3
  import { calculateViewportBounds } from '../viewport/viewportUtils.js';
4
+ import { nodeBounds } from './nodeGeometry.js';
4
5
 
5
6
  export interface CullingOptions {
6
7
  nodes: FlatNode[];
@@ -16,6 +17,19 @@ export interface CullingOptions {
16
17
 
17
18
  const DEFAULT_SPATIAL_INDEX_THRESHOLD = 200;
18
19
 
20
+ /**
21
+ * True when anything drawn for the node touches the viewport. Nodes are drawn
22
+ * CENTRED on (x, y) with rings and stroke around them, so the test uses the
23
+ * node's real visual bounds (not a top-left rectangle).
24
+ */
25
+ export function intersectsViewport(
26
+ n: FlatNode,
27
+ bounds: { minX: number; minY: number; maxX: number; maxY: number },
28
+ ): boolean {
29
+ const b = nodeBounds(n);
30
+ return b.maxX >= bounds.minX && b.minX <= bounds.maxX && b.maxY >= bounds.minY && b.minY <= bounds.maxY;
31
+ }
32
+
19
33
  /**
20
34
  * Returns the subset of nodes currently visible in the viewport.
21
35
  * Framework-agnostic logic for culling and spatial indexing.
@@ -45,12 +59,7 @@ export function getViewportVisibleNodes(options: CullingOptions): FlatNode[] {
45
59
  const bounds = calculateViewportBounds(viewport, canvasWidth, canvasHeight);
46
60
 
47
61
  if (filtered.length < spatialIndexThreshold) {
48
- return filtered.filter(n =>
49
- n.x + n.width >= bounds.minX &&
50
- n.x <= bounds.maxX &&
51
- n.y + n.height >= bounds.minY &&
52
- n.y <= bounds.maxY,
53
- );
62
+ return filtered.filter(n => intersectsViewport(n, bounds));
54
63
  }
55
64
 
56
65
  if (spatialIndex) {
@@ -62,10 +71,5 @@ export function getViewportVisibleNodes(options: CullingOptions): FlatNode[] {
62
71
  }
63
72
 
64
73
  // Fallback to simple filter if no index provided but threshold exceeded
65
- return filtered.filter(n =>
66
- n.x + n.width >= bounds.minX &&
67
- n.x <= bounds.maxX &&
68
- n.y + n.height >= bounds.minY &&
69
- n.y <= bounds.maxY,
70
- );
74
+ return filtered.filter(n => intersectsViewport(n, bounds));
71
75
  }
@@ -0,0 +1,21 @@
1
+ // ─── Idea size and label growth ──────────────────────────────────────────────
2
+ //
3
+ // The design system's idea.ts (copied verbatim into generated/geometry): an idea
4
+ // grows to show its whole label, and labels grow a little when zoomed out. The
5
+ // SDK only supplies the text measurement (its font engine) and picks the kind.
6
+
7
+ import { IDEA, LABEL_GROWTH, ideaSize, labelGrowth, wrapLines, type IdeaKind } from '../generated/geometry/idea.js';
8
+ import type { VisualliLayer } from '../types/layer.js';
9
+
10
+ export { IDEA, LABEL_GROWTH, ideaSize, labelGrowth, wrapLines };
11
+ export type { IdeaKind };
12
+
13
+ /** Width in px of `text` in the idea label's face at `px` (the label's weight; readable type adds its tracking). */
14
+ export type IdeaMeasure = (text: string, px: number) => number;
15
+
16
+ /** Measurement without a font engine (server, tests): half an em per character. Renderers pass the real face. */
17
+ export const approximateMeasure: IdeaMeasure = (text, px) => text.length * px * 0.5;
18
+
19
+ /** The size kind of the `index`-th idea of a layer: the centre of the root radial layer is the root, the rest ideas. */
20
+ export const ideaKindOf = (layer: Pick<VisualliLayer, 'level' | 'layout'>, index: number): IdeaKind =>
21
+ layer.level === 0 && (layer.layout ?? 'radial') === 'radial' && index === 0 ? 'root' : 'node';
@@ -0,0 +1,97 @@
1
+ // ─── Node & connector geometry ───────────────────────────────────────────────
2
+ //
3
+ // Thin layer over the design system's geometry (generated/geometry/blob.ts):
4
+ // it chooses the radii, shapes, rings and endpoints for a FlatNode and never
5
+ // re-implements outline or curve math. Pure functions, no DOM.
6
+
7
+ import { BLOB_SHAPES, RINGS, SHAPE, arrowPath, blobPath, blobRadius, edgePath, shapeForLevel } from '../generated/geometry/blob.js';
8
+ import { METRICS } from '../generated/designSystem.js';
9
+ import type { FlatNode } from '../types/mindmap.js';
10
+
11
+ // The design system's geometry is part of the public API (single source of truth).
12
+ export { BLOB_SHAPES, RINGS, SHAPE, arrowPath, blobPath, blobRadius, edgePath, shapeForLevel };
13
+ export { drawBlob } from '../generated/geometry/blob.js';
14
+
15
+ /** Minimum a node needs to be drawn / connected. */
16
+ export type NodeLike = Pick<FlatNode, 'x' | 'y' | 'width' | 'height' | 'level' | 'branchCount'>;
17
+
18
+ export const MAX_RINGS = RINGS.length;
19
+ const DEG = Math.PI / 180;
20
+
21
+ /** Outline radii of an idea: half its size (sized for its label by the design system's idea.ts). */
22
+ export function nodeRadii(n: Pick<FlatNode, 'width' | 'height'>): { rx: number; ry: number } {
23
+ return { rx: n.width / 2, ry: n.height / 2 };
24
+ }
25
+
26
+ /** Rings an idea shows: one per layer beneath it, at most 3. */
27
+ export const ringCount = (branchCount: number | undefined): number => Math.max(0, Math.min(branchCount ?? 0, MAX_RINGS));
28
+
29
+ /** The design system's ring recipes an idea shows, innermost first (index 0 = scale 1.1). */
30
+ export const ringsFor = (branchCount: number | undefined): typeof RINGS => RINGS.slice(0, ringCount(branchCount));
31
+
32
+ const shapeCache = new Map<number, number>();
33
+ /** Outline shape for a layer level (memoised: shapeForLevel is O(level)). */
34
+ export function shapeOfLevel(level: number): number {
35
+ let s = shapeCache.get(level);
36
+ if (s === undefined) { s = shapeForLevel(level); shapeCache.set(level, s); }
37
+ return s;
38
+ }
39
+
40
+ /** Largest distance from a blob's centre to any control point, in units of max(rx, ry). */
41
+ const BLOB_MAX_NORM = Math.max(...BLOB_SHAPES.flatMap((s) => s.map(([x, y]) => Math.hypot(x!, y!))));
42
+
43
+ /** Visual margin around an idea beyond its outline: stroke, hover lift and the node shadow. */
44
+ const VISUAL_MARGIN = METRICS.nodeStroke * 2 + 24;
45
+
46
+ /**
47
+ * Conservative world-space bounds of everything drawn for an idea (rings and
48
+ * stroke included). Ideas are drawn CENTRED on (x, y), so bounds are centred
49
+ * too. A circle bound is used so ring rotation can never push ink outside it.
50
+ */
51
+ export function nodeBounds(n: NodeLike): { minX: number; minY: number; maxX: number; maxY: number } {
52
+ const { rx, ry } = nodeRadii(n);
53
+ const rings = ringCount(n.branchCount);
54
+ const scale = rings > 0 ? RINGS[rings - 1]!.scale : 1;
55
+ const r = BLOB_MAX_NORM * Math.max(rx, ry) * scale + VISUAL_MARGIN;
56
+ return { minX: n.x - r, minY: n.y - r, maxX: n.x + r, maxY: n.y + r };
57
+ }
58
+
59
+ /**
60
+ * Distance from an idea's centre to the outline connectors must clear, in the
61
+ * world direction `angle` (radians, y down). That is the outermost visible
62
+ * ring (rotated like the ring is drawn), or the body when there are no rings.
63
+ */
64
+ export function outlineRadius(n: NodeLike, angle: number): number {
65
+ const { rx, ry } = nodeRadii(n);
66
+ const shape = shapeOfLevel(n.level);
67
+ const rings = ringCount(n.branchCount);
68
+ if (rings === 0) return blobRadius(shape, rx, ry, angle);
69
+ const ring = RINGS[rings - 1]!;
70
+ // The ring is the same blob scaled and rotated about the centre, so look up
71
+ // the radius in the ring's own frame.
72
+ return blobRadius(shape, rx * ring.scale, ry * ring.scale, angle - ring.rotate * DEG);
73
+ }
74
+
75
+ export interface ConnectorGeometry {
76
+ /** Start / end points: `edge-gap` outside the source / target outline. */
77
+ a: { x: number; y: number };
78
+ b: { x: number; y: number };
79
+ /** Cubic path data (SVG syntax) and the arrowhead direction, from the design system's edgePath. */
80
+ d: string;
81
+ angle: number;
82
+ /** Bow apex returned by edgePath, where the connector label sits. */
83
+ mid: { x: number; y: number };
84
+ /** Open arrowhead path data at b, from the design system's arrowPath. */
85
+ arrow: string;
86
+ }
87
+
88
+ /** Endpoints and curve of a connector between two ideas. */
89
+ export function connectorGeometry(from: NodeLike, to: NodeLike, gap: number = METRICS.edgeGap): ConnectorGeometry {
90
+ const theta = Math.atan2(to.y - from.y, to.x - from.x);
91
+ const ra = outlineRadius(from, theta) + gap;
92
+ const rb = outlineRadius(to, theta + Math.PI) + gap;
93
+ const a = { x: from.x + Math.cos(theta) * ra, y: from.y + Math.sin(theta) * ra };
94
+ const b = { x: to.x - Math.cos(theta) * rb, y: to.y - Math.sin(theta) * rb };
95
+ const e = edgePath(a, b);
96
+ return { a, b, d: e.d, angle: e.angle, mid: e.mid, arrow: arrowPath(b, e.angle) };
97
+ }
@@ -0,0 +1,136 @@
1
+ // ─── Theme resolution ────────────────────────────────────────────────────────
2
+ //
3
+ // Everything visual comes from the generated design-system modules. This file
4
+ // only decides WHICH of the 8 themes applies and resolves document colours with
5
+ // the design system's colour rule. No colour literals live here.
6
+ //
7
+ // Dependencies: ../generated/designSystem.ts (generated from design-system/).
8
+
9
+ import {
10
+ CANVAS_STYLE,
11
+ DESIGN_SYSTEM_VERSION,
12
+ METRICS,
13
+ METRICS_REDUCED_MOTION,
14
+ THEME_NAMES,
15
+ TOKENS,
16
+ TOPICS,
17
+ TYPE_STYLES,
18
+ type ThemeName,
19
+ type TopicName,
20
+ } from '../generated/designSystem.js';
21
+ import { TOPIC_ORDER, customColor, topicFor, topicFromName } from '../generated/geometry/color.js';
22
+
23
+ export { topicFromName, customColor };
24
+
25
+ export { CANVAS_STYLE, DESIGN_SYSTEM_VERSION, METRICS, METRICS_REDUCED_MOTION, THEME_NAMES, TOKENS, TOPICS, TYPE_STYLES };
26
+ export type { ThemeName, TopicName };
27
+
28
+ // ── Theme selection ───────────────────────────────────────────────────────────
29
+
30
+ /** Theme families: each resolves to its light or dark member. */
31
+ export type ThemeFamily = 'standard' | 'focus' | 'colorsafe' | 'contrast';
32
+
33
+ /**
34
+ * Anything a consumer may pass as `theme`:
35
+ * - one of the 8 design-system themes ('light', 'dark', 'focus-light', ...)
36
+ * - a family name ('focus' | 'colorsafe' | 'contrast') -> light/dark per the reader's scheme
37
+ * - 'auto' -> light/dark per prefers-color-scheme (contrast under forced-colors)
38
+ *
39
+ * 'light' | 'dark' | 'auto' are the pre-0.2 values, kept as aliases for one release.
40
+ */
41
+ export type ThemeInput = ThemeName | 'auto' | 'focus' | 'colorsafe' | 'contrast';
42
+
43
+ export interface ThemeEnv {
44
+ /** prefers-color-scheme: dark */
45
+ prefersDark?: boolean;
46
+ /** forced-colors: active (Windows high contrast etc.) */
47
+ forcedColors?: boolean;
48
+ }
49
+
50
+ const FAMILIES: ReadonlySet<string> = new Set(['focus', 'colorsafe', 'contrast']);
51
+
52
+ export function isThemeName(v: unknown): v is ThemeName {
53
+ return typeof v === 'string' && (THEME_NAMES as readonly string[]).includes(v);
54
+ }
55
+
56
+ /** Resolve any accepted `theme` value to one concrete design-system theme. */
57
+ export function resolveTheme(input: ThemeInput | undefined, env: ThemeEnv = {}): ThemeName {
58
+ const scheme = env.prefersDark ? 'dark' : 'light';
59
+ if (input === undefined || input === 'auto') return env.forcedColors ? (`contrast-${scheme}` as ThemeName) : scheme;
60
+ if (FAMILIES.has(input)) return `${input}-${scheme}` as ThemeName;
61
+ return isThemeName(input) ? input : 'light';
62
+ }
63
+
64
+ export const isDarkTheme = (name: ThemeName): boolean => name.endsWith('dark');
65
+ export const isContrastTheme = (name: ThemeName): boolean => name.startsWith('contrast');
66
+ export const isFocusTheme = (name: ThemeName): boolean => name.startsWith('focus');
67
+
68
+ /** All tokens of a theme (already merged over the light defaults). */
69
+ export const getTokens = (name: ThemeName): Readonly<Record<string, string>> => TOKENS[name];
70
+
71
+ /** One token of a theme. Throws on an unknown key so typos fail loudly in tests. */
72
+ export function token(name: ThemeName, key: string): string {
73
+ const v = TOKENS[name][key];
74
+ if (v === undefined) throw new Error(`Unknown design-system token "${key}"`);
75
+ return v;
76
+ }
77
+
78
+ // ── Comfort settings ──────────────────────────────────────────────────────────
79
+
80
+ export interface Comfort {
81
+ /** Atkinson Hyperlegible for map labels instead of the handwritten faces (`data-type="readable"`). */
82
+ readableType?: boolean;
83
+ /** Larger UI and label text (`data-scale="lg"`). */
84
+ largerText?: boolean;
85
+ /** Skip transitions and reveal animations (`data-motion="reduced"`). 'system' follows prefers-reduced-motion. */
86
+ reducedMotion?: boolean | 'system';
87
+ }
88
+
89
+ export interface ResolvedComfort { readableType: boolean; largerText: boolean; reducedMotion: boolean }
90
+
91
+ export function resolveComfort(c: Comfort | undefined, env: { prefersReducedMotion?: boolean } = {}): ResolvedComfort {
92
+ const rm = c?.reducedMotion;
93
+ return {
94
+ readableType: !!c?.readableType,
95
+ largerText: !!c?.largerText,
96
+ reducedMotion: rm === undefined || rm === 'system' ? !!env.prefersReducedMotion : rm,
97
+ };
98
+ }
99
+
100
+ /** Numeric design-system tokens (px / ms stripped). */
101
+ export type Metrics = { readonly [K in keyof typeof METRICS]: number };
102
+
103
+ /** Numeric tokens with the reduced-motion overrides applied when requested. */
104
+ export function metricsFor(comfort: ResolvedComfort): Metrics {
105
+ return comfort.reducedMotion ? { ...METRICS, ...METRICS_REDUCED_MOTION } : METRICS;
106
+ }
107
+
108
+ /** Multiplier the spec applies to label sizes under `data-scale="lg"` (`--vi-label-scale`). */
109
+ export const labelScale = (c: ResolvedComfort): number => (c.largerText ? 1.15 : 1);
110
+
111
+ // ── Topic palette ─────────────────────────────────────────────────────────────
112
+
113
+ export interface TopicStyle { topic: TopicName; fill: string; ring: string }
114
+
115
+ /** Fill + ring colour of a topic in a theme. */
116
+ export function topicStyle(theme: ThemeName, topic: TopicName): TopicStyle {
117
+ const t = TOKENS[theme];
118
+ return { topic, fill: t[`topic-${topic}`]!, ring: t[`topic-${topic}-ring`]! };
119
+ }
120
+
121
+ /** An idea's colour by the design system's colour rule (geometry/color.ts, verbatim): a topic name → that topic; no
122
+ * colour → the topics in sibling order; any other colour → custom, drawn as given (customColor: the colour as the fill,
123
+ * the fill darkened by CUSTOM.ringShade as the ring). A custom colour doesn't follow themes. */
124
+ export interface IdeaColor { topic: TopicName | null; custom: { fill: string; ring: string | null } | null }
125
+
126
+ export function ideaColor(color: string | null | undefined, siblingIndex: number): IdeaColor {
127
+ const topic = topicFor(color, siblingIndex);
128
+ return topic ? { topic, custom: null } : { topic: null, custom: customColor(color!) };
129
+ }
130
+
131
+ /** Fill + ring an idea is drawn with in a theme: its topic's, or its custom colour (ring: the `edge` token when the
132
+ * colour has no darker shade, i.e. isn't a 6-digit hex). */
133
+ export function ideaStyle(theme: ThemeName, c: { topic?: TopicName | null; custom?: IdeaColor['custom'] }): { fill: string; ring: string } {
134
+ if (c.custom) return { fill: c.custom.fill, ring: c.custom.ring ?? TOKENS[theme]['edge']! };
135
+ return topicStyle(theme, c.topic ?? TOPIC_ORDER[0]);
136
+ }