@visualli/core 0.1.7 → 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.
- package/README.md +37 -105
- package/dist/chunk-25AJTPMS.js +68 -0
- package/dist/chunk-25AJTPMS.js.map +1 -0
- package/dist/{chunk-6NXADJK2.js → chunk-7NJWM7ZE.js} +84 -117
- package/dist/chunk-7NJWM7ZE.js.map +1 -0
- package/dist/chunk-KVSM3APM.js +96 -0
- package/dist/chunk-KVSM3APM.js.map +1 -0
- package/dist/{chunk-PDP5CVUL.js → chunk-LOQLNYB3.js} +2 -2
- package/dist/chunk-LV3EXUQY.js +753 -0
- package/dist/chunk-LV3EXUQY.js.map +1 -0
- package/dist/chunk-N7HCYULH.js +12 -0
- package/dist/chunk-N7HCYULH.js.map +1 -0
- package/dist/{chunk-I75TMOE4.js → chunk-P6PKWVYF.js} +2 -2
- package/dist/chunk-TPLJJKAU.js +151 -0
- package/dist/chunk-TPLJJKAU.js.map +1 -0
- package/dist/chunk-UAMEJG2C.js +267 -0
- package/dist/chunk-UAMEJG2C.js.map +1 -0
- package/dist/{chunk-LJYSKSCE.js → chunk-W2CAOOJJ.js} +6 -6
- package/dist/chunk-W2CAOOJJ.js.map +1 -0
- package/dist/config/index.cjs +250 -349
- package/dist/config/index.cjs.map +1 -1
- package/dist/config/index.d.cts +107 -66
- package/dist/config/index.d.ts +107 -66
- package/dist/config/index.js +52 -34
- package/dist/constants/index.cjs +14 -333
- package/dist/constants/index.cjs.map +1 -1
- package/dist/constants/index.d.cts +5 -243
- package/dist/constants/index.d.ts +5 -243
- package/dist/constants/index.js +5 -75
- package/dist/designSystem-BOrZHfEq.d.cts +150 -0
- package/dist/designSystem-BOrZHfEq.d.ts +150 -0
- package/dist/document-BFBwn93X.d.ts +26 -0
- package/dist/document-MEJEfxl3.d.cts +26 -0
- package/dist/ideaSize-CLDKeyqD.d.cts +11 -0
- package/dist/ideaSize-DxxGy2E8.d.ts +11 -0
- package/dist/index-BmILeKj6.d.cts +99 -0
- package/dist/index-CcPRwVUL.d.ts +99 -0
- package/dist/index.cjs +1636 -961
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +350 -27
- package/dist/index.d.ts +350 -27
- package/dist/index.js +434 -179
- package/dist/index.js.map +1 -1
- package/dist/{document-B3hnNr-F.d.cts → layer-B2A3Ki48.d.cts} +2 -16
- package/dist/{document-B3hnNr-F.d.ts → layer-B2A3Ki48.d.ts} +2 -16
- package/dist/layout/index.d.cts +2 -1
- package/dist/layout/index.d.ts +2 -1
- package/dist/mindmap-C3Qr0qKW.d.cts +182 -0
- package/dist/mindmap-DHeVa86G.d.ts +182 -0
- package/dist/parser/index.cjs +745 -179
- package/dist/parser/index.cjs.map +1 -1
- package/dist/parser/index.d.cts +23 -8
- package/dist/parser/index.d.ts +23 -8
- package/dist/parser/index.js +7 -2
- package/dist/performance/index.cjs +13 -0
- package/dist/performance/index.cjs.map +1 -1
- package/dist/performance/index.js +2 -2
- package/dist/theme/index.cjs +884 -0
- package/dist/theme/index.cjs.map +1 -0
- package/dist/theme/index.d.cts +2 -0
- package/dist/theme/index.d.ts +2 -0
- package/dist/theme/index.js +53 -0
- package/dist/types/index.d.cts +4 -2
- package/dist/types/index.d.ts +4 -2
- package/dist/viewport/index.cjs +17 -6
- package/dist/viewport/index.cjs.map +1 -1
- package/dist/viewport/index.d.cts +6 -4
- package/dist/viewport/index.d.ts +6 -4
- package/dist/viewport/index.js +2 -2
- package/licenses/visualli-design-system/LICENSE +9 -0
- package/licenses/visualli-design-system/NOTICE.md +6 -0
- package/package.json +12 -4
- package/src/config/README.md +2 -4
- package/src/config/USAGE_EXAMPLES.md +0 -4
- package/src/config/index.ts +3 -4
- package/src/constants/index.ts +1 -1
- package/src/constants/layout.ts +11 -0
- package/src/constants/performanceConstants.ts +8 -27
- package/src/generated/README.md +12 -0
- package/src/generated/designSystem.ts +758 -0
- package/src/generated/geometry/blob.ts +99 -0
- package/src/generated/geometry/blobShapes.ts +4 -0
- package/src/generated/geometry/color.ts +37 -0
- package/src/generated/geometry/container.ts +84 -0
- package/src/generated/geometry/detail.ts +22 -0
- package/src/generated/geometry/idea.ts +56 -0
- package/src/generated/geometry/interaction.ts +122 -0
- package/src/generated/geometry/motion.ts +140 -0
- package/src/index.ts +18 -1
- package/src/parser/configUtils.ts +12 -19
- package/src/parser/visualliConverter.ts +71 -112
- package/src/parser/visualliParser.ts +16 -1
- package/src/rendering/culling.ts +16 -12
- package/src/rendering/ideaSize.ts +21 -0
- package/src/rendering/nodeGeometry.ts +97 -0
- package/src/theme/index.ts +136 -0
- package/src/types/document.ts +9 -0
- package/src/types/layer.ts +1 -1
- package/src/types/mindmap.ts +13 -0
- package/src/utils/navigation.ts +33 -63
- package/src/viewport/viewportUtils.ts +6 -4
- package/dist/animations/index.cjs +0 -146
- package/dist/animations/index.cjs.map +0 -1
- package/dist/animations/index.d.cts +0 -81
- package/dist/animations/index.d.ts +0 -81
- package/dist/animations/index.js +0 -35
- package/dist/chunk-6NXADJK2.js.map +0 -1
- package/dist/chunk-F36SMC3K.js +0 -106
- package/dist/chunk-F36SMC3K.js.map +0 -1
- package/dist/chunk-G5H3P4YZ.js +0 -273
- package/dist/chunk-G5H3P4YZ.js.map +0 -1
- package/dist/chunk-LDJRGT2Q.js +0 -163
- package/dist/chunk-LDJRGT2Q.js.map +0 -1
- package/dist/chunk-LJYSKSCE.js.map +0 -1
- package/dist/chunk-LNTJTDB2.js +0 -349
- package/dist/chunk-LNTJTDB2.js.map +0 -1
- package/dist/mindmap-DWS0WZT_.d.cts +0 -118
- package/dist/mindmap-DWS0WZT_.d.ts +0 -118
- package/src/animations/constants.ts +0 -72
- package/src/animations/easing.ts +0 -70
- package/src/animations/index.ts +0 -2
- package/src/config/blobShapes.ts +0 -146
- package/src/config/textScaling.ts +0 -54
- package/src/constants/design.ts +0 -281
- /package/dist/{chunk-PDP5CVUL.js.map → chunk-LOQLNYB3.js.map} +0 -0
- /package/dist/{chunk-I75TMOE4.js.map → chunk-P6PKWVYF.js.map} +0 -0
- /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 {
|
|
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:
|
|
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
|
-
// ──
|
|
62
|
+
// ── Options ───────────────────────────────────────────────────────────────────
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
*
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
150
|
-
label: string,
|
|
151
|
-
summary: string,
|
|
139
|
+
node: VisualliLayer['nodes'][number],
|
|
152
140
|
layer: VisualliLayer,
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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:
|
|
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
|
-
|
|
168
|
-
|
|
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
|
|
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
|
|
211
|
-
if (!containerNodeIds.has(
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
|
|
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,
|
package/src/rendering/culling.ts
CHANGED
|
@@ -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
|
+
}
|