flowsvg 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +121 -0
- package/LICENSE +21 -0
- package/PATTERNS.md +112 -0
- package/README.md +546 -2
- package/README.zh-CN.md +547 -0
- package/dist/adapters/react.d.ts +48 -0
- package/dist/adapters/react.js +126 -0
- package/dist/adapters/vue.d.ts +132 -0
- package/dist/adapters/vue.js +161 -0
- package/dist/adapters/web-component.d.ts +99 -0
- package/dist/adapters/web-component.js +396 -0
- package/dist/browser/accessibility.d.ts +16 -0
- package/dist/browser/accessibility.js +36 -0
- package/dist/browser/controls.d.ts +41 -0
- package/dist/browser/controls.js +80 -0
- package/dist/browser/lifecycle.d.ts +6 -0
- package/dist/browser/lifecycle.js +31 -0
- package/dist/browser/player.d.ts +69 -0
- package/dist/browser/player.js +353 -0
- package/dist/browser/svg-dom.d.ts +6 -0
- package/dist/browser/svg-dom.js +74 -0
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +2 -0
- package/dist/core/color.d.ts +3 -0
- package/dist/core/color.js +18 -0
- package/dist/core/diagram.d.ts +38 -0
- package/dist/core/diagram.js +391 -0
- package/dist/core/edge-geometry.d.ts +32 -0
- package/dist/core/edge-geometry.js +382 -0
- package/dist/core/model.d.ts +256 -0
- package/dist/core/model.js +1 -0
- package/dist/core/node-content-paint.d.ts +10 -0
- package/dist/core/node-content-paint.js +45 -0
- package/dist/core/node-content.d.ts +184 -0
- package/dist/core/node-content.js +554 -0
- package/dist/core/svg-number.d.ts +5 -0
- package/dist/core/svg-number.js +7 -0
- package/dist/core/theme.d.ts +21 -0
- package/dist/core/theme.js +17 -0
- package/dist/core/timeline.d.ts +56 -0
- package/dist/core/timeline.js +148 -0
- package/dist/core/validation.d.ts +11 -0
- package/dist/core/validation.js +389 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +2 -0
- package/dist/react.d.ts +3 -0
- package/dist/react.js +2 -0
- package/dist/styles.css +47 -0
- package/dist/vue.d.ts +3 -0
- package/dist/vue.js +2 -0
- package/dist/web-component.d.ts +3 -0
- package/dist/web-component.js +2 -0
- package/docs/images/entity-graph.png +0 -0
- package/docs/images/facts-timeline.png +0 -0
- package/docs/images/facts-timeline.zh-CN.png +0 -0
- package/docs/images/node-content.png +0 -0
- package/docs/images/numbered-list.png +0 -0
- package/docs/images/numbered-list.zh-CN.png +0 -0
- package/docs/images/quick-start.png +0 -0
- package/docs/images/record-tags.png +0 -0
- package/package.json +88 -4
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
const DEFAULT_PADDING = 16;
|
|
2
|
+
const ROUNDED_RECT_INSET = 4;
|
|
3
|
+
const MAX_CYLINDER_CAP_RADIUS = 14;
|
|
4
|
+
const DEFAULT_BLOCK_GAP = 14;
|
|
5
|
+
const DEFAULT_ITEM_GAP = 12;
|
|
6
|
+
const DEFAULT_TAG_GAP = 6;
|
|
7
|
+
const TAG_SIZE = 11;
|
|
8
|
+
const TAG_PADDING_X = 6;
|
|
9
|
+
const TAG_PADDING_Y = 2;
|
|
10
|
+
const LIST_MARKER_GAP = 8;
|
|
11
|
+
const OVERFLOW_TOLERANCE = 0.000001;
|
|
12
|
+
const MIN_LINE_HEIGHT = 20;
|
|
13
|
+
const LINE_HEIGHT_RATIO = 1.35;
|
|
14
|
+
const TITLE_SIZE = 20;
|
|
15
|
+
const BODY_SIZE = 14;
|
|
16
|
+
const AUXILIARY_SIZE = 12;
|
|
17
|
+
const TITLE_WEIGHT = 700;
|
|
18
|
+
const NORMAL_WEIGHT = 400;
|
|
19
|
+
const MIN_FONT_WEIGHT = 100;
|
|
20
|
+
const MAX_FONT_WEIGHT = 900;
|
|
21
|
+
const NARROW_GLYPH_WIDTH_RATIO = 0.6;
|
|
22
|
+
const GRAPH_NODE_RADIUS = 4;
|
|
23
|
+
const GRAPH_LABEL_GAP = 7;
|
|
24
|
+
const GRAPH_LABEL_SIZE = 12;
|
|
25
|
+
const GRAPHEME_SEGMENTER = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
26
|
+
/** Supported content shapes; node validators share this restriction. */
|
|
27
|
+
export function supportsNodeContent(kind) {
|
|
28
|
+
return kind === "rect" || kind === "roundedRect" || kind === "cylinder";
|
|
29
|
+
}
|
|
30
|
+
/** Derive the clip from current absolute geometry, reserving shape contours before authored padding. */
|
|
31
|
+
function contentSafeArea(bounds, padding, kind) {
|
|
32
|
+
const cornerInset = kind === "roundedRect" ? ROUNDED_RECT_INSET : 0;
|
|
33
|
+
// Match the current SVG cylinder cap; reserve its full diameter above and below the content.
|
|
34
|
+
const capInset = kind === "cylinder" ? Math.min(MAX_CYLINDER_CAP_RADIUS, bounds.height / 4) * 2 : 0;
|
|
35
|
+
const horizontalInset = padding + cornerInset;
|
|
36
|
+
const verticalInset = horizontalInset + capInset;
|
|
37
|
+
return {
|
|
38
|
+
x: bounds.x + horizontalInset,
|
|
39
|
+
y: bounds.y + verticalInset,
|
|
40
|
+
width: Math.max(0, bounds.width - horizontalInset * 2),
|
|
41
|
+
height: Math.max(0, bounds.height - verticalInset * 2)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Lay out current content from the top left, without DOM font measurement or authored-size changes. */
|
|
45
|
+
export function layoutNodeContent(content, bounds, palette, kind) {
|
|
46
|
+
const safeArea = contentSafeArea(bounds, content.style?.padding ?? DEFAULT_PADDING, kind);
|
|
47
|
+
let cursorY = safeArea.y;
|
|
48
|
+
let widthOverflow = false;
|
|
49
|
+
let heightOverflow = false;
|
|
50
|
+
const tags = [];
|
|
51
|
+
const graphs = [];
|
|
52
|
+
const lines = content.blocks.flatMap((block, blockIndex) => {
|
|
53
|
+
const result = block.type === "graph"
|
|
54
|
+
? layoutContentGraph(block, safeArea, cursorY, palette, blockIndex, graphs)
|
|
55
|
+
: block.type === "list"
|
|
56
|
+
? layoutContentList(block, safeArea, cursorY, palette, blockIndex)
|
|
57
|
+
: layoutTextPart(block.text, block.type === "title" ? "title" : block.role ?? "body", block.style, safeArea, cursorY, palette, blockIndex);
|
|
58
|
+
widthOverflow ||= result.widthOverflow;
|
|
59
|
+
heightOverflow ||= result.heightOverflow ?? false;
|
|
60
|
+
tags.push(...result.tags);
|
|
61
|
+
cursorY = result.bottom + (blockIndex < content.blocks.length - 1 ? content.style?.blockGap ?? DEFAULT_BLOCK_GAP : 0);
|
|
62
|
+
return result.lines;
|
|
63
|
+
});
|
|
64
|
+
return { safeArea, lines, tags, ...(graphs.length ? { graphs } : {}), widthOverflow: widthOverflow || lines.some((line) => contentLineExceedsWidth(line, safeArea)),
|
|
65
|
+
heightOverflow: heightOverflow || cursorY > safeArea.y + safeArea.height };
|
|
66
|
+
}
|
|
67
|
+
/** Graph labels reuse text metrics; authored graph geometry is clipped instead of resized. */
|
|
68
|
+
function layoutContentGraph(block, area, top, palette, blockIndex, graphs) {
|
|
69
|
+
const points = new Map(block.nodes.map((node) => [node.id, { x: area.x + node.x, y: top + node.y }]));
|
|
70
|
+
const nodes = block.nodes.map((node) => ({ ...points.get(node.id), radius: GRAPH_NODE_RADIUS,
|
|
71
|
+
fill: node.highlighted ? palette.accentText : palette.mutedText, highlighted: node.highlighted ?? false }));
|
|
72
|
+
const edges = block.edges.map((edge) => {
|
|
73
|
+
const from = points.get(edge.from), to = points.get(edge.to);
|
|
74
|
+
const highlighted = [edge.from, edge.to].every((id) => block.nodes.find((node) => node.id === id)?.highlighted);
|
|
75
|
+
return { x1: from.x, y1: from.y, x2: to.x, y2: to.y, color: highlighted ? palette.accentText : palette.mutedText, highlighted };
|
|
76
|
+
});
|
|
77
|
+
const parts = block.nodes.map((node) => {
|
|
78
|
+
const halfWidth = Math.max(0, Math.min(node.x, area.width - node.x));
|
|
79
|
+
const labelArea = { ...area, x: area.x + node.x - halfWidth, width: halfWidth * 2 };
|
|
80
|
+
return layoutTextPart(node.label, "body", { fontSize: GRAPH_LABEL_SIZE, fontWeight: node.highlighted ? 700 : 400, align: "center" }, labelArea, top + node.y - GRAPH_NODE_RADIUS - GRAPH_LABEL_GAP - GRAPH_LABEL_SIZE, palette, blockIndex);
|
|
81
|
+
});
|
|
82
|
+
graphs.push({ blockIndex, nodes, edges });
|
|
83
|
+
return { lines: parts.flatMap((part) => part.lines), tags: [],
|
|
84
|
+
bottom: top + block.height,
|
|
85
|
+
heightOverflow: parts.some((part) => part.lines.some((line) => line.y - line.fontSize < top) || part.bottom > top + block.height)
|
|
86
|
+
|| block.nodes.some((node) => node.y - GRAPH_NODE_RADIUS < 0 || node.y + GRAPH_NODE_RADIUS > block.height),
|
|
87
|
+
widthOverflow: parts.some((part) => part.widthOverflow) || block.nodes.some((node) => node.x < GRAPH_NODE_RADIUS
|
|
88
|
+
|| node.x + GRAPH_NODE_RADIUS > area.width) };
|
|
89
|
+
}
|
|
90
|
+
/** Include list indentation when checking the clip; a single oversized grapheme may exceed the body column. */
|
|
91
|
+
function contentLineExceedsWidth(line, area) {
|
|
92
|
+
return line.width > area.width || line.spans.some((span) => span.x < area.x - OVERFLOW_TOLERANCE
|
|
93
|
+
|| span.x + span.width > area.x + area.width + OVERFLOW_TOLERANCE);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Resolve role defaults once and reuse baseline/wrapping rules for every text part.
|
|
97
|
+
* A marked item's first line also reserves its marker size, so smaller source spans cannot lift the marker above the clip.
|
|
98
|
+
*/
|
|
99
|
+
function layoutTextPart(text, role, style, area, top, palette, blockIndex, itemIndex, minimumFirstLineSize = 0) {
|
|
100
|
+
const fontSize = style?.fontSize ?? (role === "title" ? TITLE_SIZE : role === "auxiliary" || role === "status" ? AUXILIARY_SIZE : role === "tag" ? TAG_SIZE : BODY_SIZE);
|
|
101
|
+
const fontWeight = style?.fontWeight ?? (role === "title" ? TITLE_WEIGHT : NORMAL_WEIGHT);
|
|
102
|
+
const color = style?.color ?? (role === "auxiliary" ? palette.mutedText : role === "tag" ? palette.tagText : role === "status" ? palette.accentText : palette.text);
|
|
103
|
+
const align = style?.align ?? "left";
|
|
104
|
+
const x = area.x + (align === "right" ? area.width : align === "center" ? area.width / 2 : 0);
|
|
105
|
+
const wrapped = wrapContentText(text, area.width, { fontSize, fontWeight, color, explicitColor: style?.color !== undefined });
|
|
106
|
+
let cursorY = top;
|
|
107
|
+
const lines = wrapped.map((glyphs, lineIndex) => {
|
|
108
|
+
const textSize = glyphs.length > 0 ? glyphs.reduce((largest, glyph) => Math.max(largest, glyph.style.fontSize), 0) : fontSize;
|
|
109
|
+
const size = Math.max(textSize, lineIndex === 0 ? minimumFirstLineSize : 0);
|
|
110
|
+
const width = glyphs.reduce((sum, glyph) => sum + glyph.width, 0);
|
|
111
|
+
const y = cursorY + size;
|
|
112
|
+
const startX = x - (align === "right" ? width : align === "center" ? width / 2 : 0);
|
|
113
|
+
const spans = collectContentSpans(glyphs, startX, y);
|
|
114
|
+
cursorY += Math.max(MIN_LINE_HEIGHT, size * LINE_HEIGHT_RATIO);
|
|
115
|
+
return { text: glyphs.map((glyph) => glyph.text).join(""), spans, x, y, width,
|
|
116
|
+
fontSize: size, fontWeight, color, align, role, blockIndex, ...(itemIndex === undefined ? {} : { itemIndex }) };
|
|
117
|
+
});
|
|
118
|
+
return { lines, tags: [], bottom: cursorY, widthOverflow: lines.some((line) => line.width > area.width) };
|
|
119
|
+
}
|
|
120
|
+
function layoutContentList(block, area, top, palette, blockIndex) {
|
|
121
|
+
let cursorY = top;
|
|
122
|
+
const lines = [];
|
|
123
|
+
const tags = [];
|
|
124
|
+
let widthOverflow = false;
|
|
125
|
+
const markerWidth = contentMarkerWidth(block);
|
|
126
|
+
const indent = markerWidth > 0 ? markerWidth + LIST_MARKER_GAP : 0;
|
|
127
|
+
const bodyArea = { ...area, x: area.x + indent, width: Math.max(0, area.width - indent) };
|
|
128
|
+
block.items.forEach((item, itemIndex) => {
|
|
129
|
+
if (!hasContentItem(item))
|
|
130
|
+
return;
|
|
131
|
+
if (lines.length > 0)
|
|
132
|
+
cursorY += block.style?.itemGap ?? DEFAULT_ITEM_GAP;
|
|
133
|
+
const itemStart = lines.length;
|
|
134
|
+
const markerSize = markerWidth === 0 ? 0 : item.body === undefined ? item.auxiliaryStyle?.fontSize ?? AUXILIARY_SIZE
|
|
135
|
+
: item.style?.fontSize ?? block.style?.fontSize ?? BODY_SIZE;
|
|
136
|
+
const header = layoutItemHeader(item, bodyArea, cursorY, palette, blockIndex, itemIndex, block.style?.tagGap ?? DEFAULT_TAG_GAP, markerSize);
|
|
137
|
+
lines.push(...header.lines);
|
|
138
|
+
tags.push(...header.tags);
|
|
139
|
+
widthOverflow ||= header.widthOverflow;
|
|
140
|
+
cursorY = header.bottom;
|
|
141
|
+
if (item.body !== undefined) {
|
|
142
|
+
const body = layoutTextPart(item.body, "body", inheritContentStyle(block.style, item.style), bodyArea, cursorY, palette, blockIndex, itemIndex, markerSize);
|
|
143
|
+
lines.push(...body.lines);
|
|
144
|
+
widthOverflow ||= body.widthOverflow;
|
|
145
|
+
cursorY = body.bottom;
|
|
146
|
+
}
|
|
147
|
+
if (item.auxiliary !== undefined) {
|
|
148
|
+
const auxiliary = layoutTextPart(item.auxiliary, "auxiliary", item.auxiliaryStyle, bodyArea, cursorY, palette, blockIndex, itemIndex, item.body === undefined ? markerSize : 0);
|
|
149
|
+
lines.push(...auxiliary.lines);
|
|
150
|
+
widthOverflow ||= auxiliary.widthOverflow;
|
|
151
|
+
cursorY = auxiliary.bottom;
|
|
152
|
+
}
|
|
153
|
+
if (markerWidth > 0) {
|
|
154
|
+
const first = lines[itemStart];
|
|
155
|
+
const style = item.body === undefined ? item.auxiliaryStyle : inheritContentStyle(block.style, item.style);
|
|
156
|
+
const marker = layoutTextPart(block.marker === "bullet" ? "•" : `${itemIndex + 1}.`, "marker", { ...style, fontSize: style?.fontSize ?? (item.body === undefined ? AUXILIARY_SIZE : BODY_SIZE), align: "left" }, { ...area, width: markerWidth }, top, palette, blockIndex, itemIndex).lines[0];
|
|
157
|
+
lines.splice(itemStart, 0, { ...marker, y: first.y, spans: marker.spans.map((span) => ({ ...span, y: first.y })) });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
return { lines, tags, bottom: cursorY, widthOverflow };
|
|
161
|
+
}
|
|
162
|
+
/** Tags occupy rows first. Status shares a single row only when both natural widths fit. */
|
|
163
|
+
function layoutItemHeader(item, area, top, palette, blockIndex, itemIndex, gap, markerSize) {
|
|
164
|
+
const header = layoutContentTags(item.tags ?? [], area, top, palette, blockIndex, itemIndex, gap, markerSize);
|
|
165
|
+
if (item.status === undefined)
|
|
166
|
+
return header;
|
|
167
|
+
const statusWidth = naturalContentWidth(item.status, item.statusStyle, AUXILIARY_SIZE);
|
|
168
|
+
const lastTag = header.tags.at(-1);
|
|
169
|
+
const occupied = lastTag ? lastTag.x + lastTag.width - area.x : 0;
|
|
170
|
+
const singleTagRow = header.tags.every((tag) => tag.y === top) && header.lines.length === header.tags.length;
|
|
171
|
+
const singleStatusLine = !contentTextString(item.status).match(/[\r\n]/);
|
|
172
|
+
const sameRow = singleTagRow && singleStatusLine && occupied + (lastTag ? gap : 0) + statusWidth <= area.width;
|
|
173
|
+
const statusTop = sameRow ? top : header.bottom;
|
|
174
|
+
let status = layoutTextPart(item.status, "status", { ...item.statusStyle, align: "right" }, area, statusTop, palette, blockIndex, itemIndex, markerSize);
|
|
175
|
+
let tags = header;
|
|
176
|
+
if (sameRow && header.lines.length > 0) {
|
|
177
|
+
const baseline = Math.max(...header.lines.map((line) => line.y), status.lines[0].y);
|
|
178
|
+
tags = shiftTagText(header, baseline - header.lines[0].y);
|
|
179
|
+
status = shiftContentPart(status, baseline - status.lines[0].y);
|
|
180
|
+
}
|
|
181
|
+
return { lines: [...tags.lines, ...status.lines], tags: tags.tags,
|
|
182
|
+
bottom: Math.max(tags.bottom, status.bottom), widthOverflow: tags.widthOverflow || status.widthOverflow };
|
|
183
|
+
}
|
|
184
|
+
/** Pack tag boxes in authored order; a long tag wraps inside its own padded full-width box. */
|
|
185
|
+
function layoutContentTags(source, area, top, palette, blockIndex, itemIndex, gap, markerSize) {
|
|
186
|
+
let row = [];
|
|
187
|
+
let right = area.x;
|
|
188
|
+
let result = { lines: [], tags: [], bottom: top, widthOverflow: false };
|
|
189
|
+
const flush = () => {
|
|
190
|
+
if (row.length === 0)
|
|
191
|
+
return;
|
|
192
|
+
const rowTop = result.tags.length > 0 ? result.bottom + gap : top;
|
|
193
|
+
const parts = row.map(({ tag, tagIndex, width, x }) => layoutContentTag(tag, { ...area, x, width }, rowTop, palette, blockIndex, itemIndex, tagIndex, markerSize));
|
|
194
|
+
const baseline = Math.max(...parts.map((part) => part.lines[0].y));
|
|
195
|
+
const aligned = parts.map((part) => shiftTagText(part, baseline - part.lines[0].y));
|
|
196
|
+
result = { lines: [...result.lines, ...aligned.flatMap((part) => part.lines)],
|
|
197
|
+
tags: [...result.tags, ...aligned.flatMap((part) => part.tags)], bottom: Math.max(...aligned.map((part) => part.bottom)),
|
|
198
|
+
widthOverflow: result.widthOverflow || aligned.some((part) => part.widthOverflow) };
|
|
199
|
+
row = [];
|
|
200
|
+
right = area.x;
|
|
201
|
+
};
|
|
202
|
+
source.forEach((tag, tagIndex) => {
|
|
203
|
+
const width = Math.min(area.width, naturalContentWidth(tag.text, tag.style, TAG_SIZE) + TAG_PADDING_X * 2);
|
|
204
|
+
if (row.length > 0 && right + width > area.x + area.width)
|
|
205
|
+
flush();
|
|
206
|
+
row.push({ tag, tagIndex, width, x: right });
|
|
207
|
+
right += width + gap;
|
|
208
|
+
});
|
|
209
|
+
flush();
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
function layoutContentTag(tag, area, top, palette, blockIndex, itemIndex, tagIndex, markerSize) {
|
|
213
|
+
const textArea = { ...area, x: area.x + TAG_PADDING_X, width: Math.max(0, area.width - TAG_PADDING_X * 2) };
|
|
214
|
+
const result = layoutTextPart(tag.text, "tag", tag.style, textArea, top + TAG_PADDING_Y, palette, blockIndex, itemIndex, markerSize);
|
|
215
|
+
return { ...result, widthOverflow: result.widthOverflow || area.width < TAG_PADDING_X * 2,
|
|
216
|
+
lines: result.lines.map((line) => ({ ...line, tagIndex })), bottom: result.bottom + TAG_PADDING_Y,
|
|
217
|
+
tags: [{ x: area.x, y: top, width: area.width, height: result.bottom - top + TAG_PADDING_Y,
|
|
218
|
+
fill: tag.style?.fill ?? palette.tagFill, explicitFill: tag.style?.fill !== undefined, blockIndex, itemIndex, tagIndex }] };
|
|
219
|
+
}
|
|
220
|
+
/** Baseline shifts preserve line spacing and expand tag backgrounds instead of moving the row boundary. */
|
|
221
|
+
function shiftTagText(part, offset) {
|
|
222
|
+
return { ...shiftContentPart(part, offset), tags: part.tags.map((tag) => ({ ...tag, height: tag.height + offset })) };
|
|
223
|
+
}
|
|
224
|
+
function shiftContentPart(part, offset) {
|
|
225
|
+
return { ...part, bottom: part.bottom + offset,
|
|
226
|
+
lines: part.lines.map((line) => ({ ...line, y: line.y + offset, spans: line.spans.map((span) => ({ ...span, y: span.y + offset })) })) };
|
|
227
|
+
}
|
|
228
|
+
function contentTextString(text) {
|
|
229
|
+
return typeof text === "string" ? text : text.map((span) => span.text).join("");
|
|
230
|
+
}
|
|
231
|
+
function naturalContentWidth(text, style, defaultSize) {
|
|
232
|
+
const wrapped = wrapContentText(text, Number.MAX_VALUE, { fontSize: style?.fontSize ?? defaultSize,
|
|
233
|
+
fontWeight: style?.fontWeight ?? NORMAL_WEIGHT, color: style?.color ?? "#000000", explicitColor: style?.color !== undefined });
|
|
234
|
+
return Math.max(0, ...wrapped.map((glyphs) => glyphs.reduce((sum, glyph) => sum + glyph.width, 0)));
|
|
235
|
+
}
|
|
236
|
+
function hasContentItem(item) {
|
|
237
|
+
return item.body !== undefined || item.auxiliary !== undefined || item.status !== undefined || (item.tags?.length ?? 0) > 0;
|
|
238
|
+
}
|
|
239
|
+
/** Every item uses the same marker column, including multi-digit numbers and authored marker sizes. */
|
|
240
|
+
function contentMarkerWidth(block) {
|
|
241
|
+
if (block.marker === undefined || block.marker === "none")
|
|
242
|
+
return 0;
|
|
243
|
+
return block.items.reduce((width, item, index) => {
|
|
244
|
+
if (!hasContentItem(item))
|
|
245
|
+
return width;
|
|
246
|
+
const size = item.body === undefined ? item.auxiliaryStyle?.fontSize ?? AUXILIARY_SIZE
|
|
247
|
+
: item.style?.fontSize ?? block.style?.fontSize ?? BODY_SIZE;
|
|
248
|
+
return Math.max(width, estimateContentWidth(block.marker === "bullet" ? "•" : `${index + 1}.`, size));
|
|
249
|
+
}, 0);
|
|
250
|
+
}
|
|
251
|
+
function inheritContentStyle(base, override) {
|
|
252
|
+
return { fontSize: override?.fontSize ?? base?.fontSize, fontWeight: override?.fontWeight ?? base?.fontWeight,
|
|
253
|
+
color: override?.color ?? base?.color, align: override?.align ?? base?.align };
|
|
254
|
+
}
|
|
255
|
+
/** Wrap combined source text, so a style boundary never creates an extra break opportunity. */
|
|
256
|
+
function wrapContentText(text, maxWidth, baseStyle) {
|
|
257
|
+
const sources = typeof text === "string" ? [{ text }] : text;
|
|
258
|
+
const glyphs = segmentContentText(sources, baseStyle);
|
|
259
|
+
const explicitLines = [[]];
|
|
260
|
+
glyphs.forEach((glyph) => {
|
|
261
|
+
if (/^\r\n$|^[\r\n]$/.test(glyph.text))
|
|
262
|
+
explicitLines.push([]);
|
|
263
|
+
else
|
|
264
|
+
explicitLines.at(-1).push(glyph);
|
|
265
|
+
});
|
|
266
|
+
return explicitLines.flatMap((line) => wrapContentGlyphs(line, maxWidth));
|
|
267
|
+
}
|
|
268
|
+
/** Segment concatenated text before styling: a cluster crossing spans uses its first character's style. */
|
|
269
|
+
function segmentContentText(sources, baseStyle) {
|
|
270
|
+
const combined = sources.map((source) => source.text).join("");
|
|
271
|
+
let sourceIndex = 0;
|
|
272
|
+
let sourceEnd = sources[0]?.text.length ?? 0;
|
|
273
|
+
return Array.from(GRAPHEME_SEGMENTER.segment(combined), ({ segment: text, index }) => {
|
|
274
|
+
while (index >= sourceEnd && sourceIndex < sources.length - 1) {
|
|
275
|
+
sourceIndex += 1;
|
|
276
|
+
sourceEnd += sources[sourceIndex].text.length;
|
|
277
|
+
}
|
|
278
|
+
const sourceStyle = sources[sourceIndex]?.style;
|
|
279
|
+
const style = { ...baseStyle, ...sourceStyle, explicitColor: sourceStyle?.color !== undefined || baseStyle.explicitColor };
|
|
280
|
+
return { text, sourceIndex, style, width: estimateContentWidth(text, style.fontSize) };
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
/** Prefer English spaces; fallback always consumes one character even when the safe area is empty. */
|
|
284
|
+
function wrapContentGlyphs(glyphs, maxWidth) {
|
|
285
|
+
let remaining = glyphs;
|
|
286
|
+
const lines = [];
|
|
287
|
+
while (remaining.length > 0) {
|
|
288
|
+
let count = 0;
|
|
289
|
+
let width = 0;
|
|
290
|
+
while (count < remaining.length && width + remaining[count].width <= maxWidth) {
|
|
291
|
+
width += remaining[count].width;
|
|
292
|
+
count += 1;
|
|
293
|
+
}
|
|
294
|
+
if (count >= remaining.length) {
|
|
295
|
+
lines.push(remaining);
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
const space = remaining.slice(0, count + 1).map((glyph) => glyph.text).lastIndexOf(" ");
|
|
299
|
+
const cut = space > 0 ? space : Math.max(1, count);
|
|
300
|
+
lines.push(remaining.slice(0, cut));
|
|
301
|
+
remaining = remaining.slice(cut);
|
|
302
|
+
while (remaining[0]?.text === " ")
|
|
303
|
+
remaining = remaining.slice(1);
|
|
304
|
+
}
|
|
305
|
+
return lines.length > 0 ? lines : [[]];
|
|
306
|
+
}
|
|
307
|
+
function collectContentSpans(glyphs, x, y) {
|
|
308
|
+
const spans = [];
|
|
309
|
+
let cursorX = x;
|
|
310
|
+
let sourceIndex = -1;
|
|
311
|
+
for (const glyph of glyphs) {
|
|
312
|
+
const previous = spans.at(-1);
|
|
313
|
+
if (previous && sourceIndex === glyph.sourceIndex) {
|
|
314
|
+
spans[spans.length - 1] = { ...previous, text: previous.text + glyph.text, width: previous.width + glyph.width };
|
|
315
|
+
}
|
|
316
|
+
else
|
|
317
|
+
spans.push({ text: glyph.text, x: cursorX, y, width: glyph.width, ...glyph.style });
|
|
318
|
+
cursorX += glyph.width;
|
|
319
|
+
sourceIndex = glyph.sourceIndex;
|
|
320
|
+
}
|
|
321
|
+
return spans;
|
|
322
|
+
}
|
|
323
|
+
/** Emoji sequences occupy one em; combining marks and format controls do not add independent advance. */
|
|
324
|
+
function estimateContentWidth(text, fontSize) {
|
|
325
|
+
if (text === "•")
|
|
326
|
+
return fontSize * NARROW_GLYPH_WIDTH_RATIO;
|
|
327
|
+
if (/\p{Extended_Pictographic}|\p{Regional_Indicator}|\u20e3/u.test(text))
|
|
328
|
+
return fontSize;
|
|
329
|
+
return Array.from(text).filter((character) => !/\p{Mark}|\p{Default_Ignorable_Code_Point}/u.test(character))
|
|
330
|
+
.reduce((width, character) => width + fontSize * (character.codePointAt(0) > 0xff || "MWmw@%".includes(character) ? 1 : NARROW_GLYPH_WIDTH_RATIO), 0);
|
|
331
|
+
}
|
|
332
|
+
/** Validate nested content separately from shape and label-mode checks at the document boundary. */
|
|
333
|
+
export function validateNodeContent(value, path, issues) {
|
|
334
|
+
if (!isRecord(value)) {
|
|
335
|
+
issues.push({ path, message: "must be an object" });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
rejectUnknownFields(value, ["blocks", "style"], path, issues);
|
|
339
|
+
if (!Array.isArray(value.blocks)) {
|
|
340
|
+
issues.push({ path: `${path}.blocks`, message: "must be an array" });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
validateContentSpacing(value.style, `${path}.style`, issues);
|
|
344
|
+
for (let index = 0; index < value.blocks.length; index += 1) {
|
|
345
|
+
if (!(index in value.blocks))
|
|
346
|
+
issues.push({ path: `${path}.blocks[${index}]`, message: "must be a value" });
|
|
347
|
+
}
|
|
348
|
+
value.blocks.forEach((block, index) => {
|
|
349
|
+
const blockPath = `${path}.blocks[${index}]`;
|
|
350
|
+
if (!isRecord(block)) {
|
|
351
|
+
issues.push({ path: blockPath, message: "must be an object" });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (block.type === "list") {
|
|
355
|
+
validateContentList(block, blockPath, issues);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (block.type === "graph") {
|
|
359
|
+
validateContentGraph(block, blockPath, issues);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
rejectUnknownFields(block, block.type === "paragraph" ? ["type", "text", "role", "style"] : ["type", "text", "style"], blockPath, issues);
|
|
363
|
+
if (block.type !== "title" && block.type !== "paragraph")
|
|
364
|
+
issues.push({ path: `${blockPath}.type`, message: "must be title, paragraph, list or graph" });
|
|
365
|
+
validateContentText(block.text, `${blockPath}.text`, issues);
|
|
366
|
+
validateContentTextStyle(block.style, `${blockPath}.style`, issues);
|
|
367
|
+
if (block.role !== undefined && block.role !== "body" && block.role !== "auxiliary")
|
|
368
|
+
issues.push({ path: `${blockPath}.role`, message: "must be body or auxiliary" });
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
function validateContentGraph(block, path, issues) {
|
|
372
|
+
rejectUnknownFields(block, ["type", "height", "nodes", "edges"], path, issues);
|
|
373
|
+
validateSpacingNumber(block.height, `${path}.height`, issues, true);
|
|
374
|
+
const ids = new Set();
|
|
375
|
+
if (!Array.isArray(block.nodes))
|
|
376
|
+
issues.push({ path: `${path}.nodes`, message: "must be an array" });
|
|
377
|
+
else
|
|
378
|
+
for (let index = 0; index < block.nodes.length; index += 1) {
|
|
379
|
+
const node = block.nodes[index];
|
|
380
|
+
const nodePath = `${path}.nodes[${index}]`;
|
|
381
|
+
if (!isRecord(node)) {
|
|
382
|
+
issues.push({ path: nodePath, message: "must be an object" });
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
rejectUnknownFields(node, ["id", "label", "x", "y", "highlighted"], nodePath, issues);
|
|
386
|
+
if (typeof node.id !== "string" || !node.id.trim() || ids.has(node.id))
|
|
387
|
+
issues.push({ path: `${nodePath}.id`, message: "must be a unique nonempty local ID" });
|
|
388
|
+
else
|
|
389
|
+
ids.add(node.id);
|
|
390
|
+
if (typeof node.label !== "string")
|
|
391
|
+
issues.push({ path: `${nodePath}.label`, message: "must be a string" });
|
|
392
|
+
for (const field of ["x", "y"])
|
|
393
|
+
validateSpacingNumber(node[field], `${nodePath}.${field}`, issues);
|
|
394
|
+
if (node.highlighted !== undefined && typeof node.highlighted !== "boolean")
|
|
395
|
+
issues.push({ path: `${nodePath}.highlighted`, message: "must be a boolean" });
|
|
396
|
+
}
|
|
397
|
+
if (!Array.isArray(block.edges)) {
|
|
398
|
+
issues.push({ path: `${path}.edges`, message: "must be an array" });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
for (let index = 0; index < block.edges.length; index += 1) {
|
|
402
|
+
const edge = block.edges[index];
|
|
403
|
+
const edgePath = `${path}.edges[${index}]`;
|
|
404
|
+
if (!isRecord(edge)) {
|
|
405
|
+
issues.push({ path: edgePath, message: "must be an object" });
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
rejectUnknownFields(edge, ["from", "to"], edgePath, issues);
|
|
409
|
+
for (const field of ["from", "to"]) {
|
|
410
|
+
if (typeof edge[field] !== "string" || !ids.has(edge[field]))
|
|
411
|
+
issues.push({ path: `${edgePath}.${field}`, message: "must reference a node in this graph block" });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/** List entries share controlled text validation and preserve the exact nested source path. */
|
|
416
|
+
function validateContentList(block, path, issues) {
|
|
417
|
+
rejectUnknownFields(block, ["type", "marker", "items", "style"], path, issues);
|
|
418
|
+
if (block.marker !== undefined && block.marker !== "none" && block.marker !== "bullet" && block.marker !== "number") {
|
|
419
|
+
issues.push({ path: `${path}.marker`, message: "must be none, bullet or number" });
|
|
420
|
+
}
|
|
421
|
+
if (isRecord(block.style)) {
|
|
422
|
+
const { itemGap, tagGap, ...textStyle } = block.style;
|
|
423
|
+
validateContentTextStyle(textStyle, `${path}.style`, issues);
|
|
424
|
+
if (itemGap !== undefined)
|
|
425
|
+
validateSpacingNumber(itemGap, `${path}.style.itemGap`, issues);
|
|
426
|
+
if (tagGap !== undefined)
|
|
427
|
+
validateSpacingNumber(tagGap, `${path}.style.tagGap`, issues);
|
|
428
|
+
}
|
|
429
|
+
else
|
|
430
|
+
validateContentTextStyle(block.style, `${path}.style`, issues);
|
|
431
|
+
if (!Array.isArray(block.items)) {
|
|
432
|
+
issues.push({ path: `${path}.items`, message: "must be an array" });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
for (let index = 0; index < block.items.length; index += 1) {
|
|
436
|
+
const item = block.items[index];
|
|
437
|
+
const itemPath = `${path}.items[${index}]`;
|
|
438
|
+
if (!isRecord(item)) {
|
|
439
|
+
issues.push({ path: itemPath, message: "must be an object" });
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
rejectUnknownFields(item, ["body", "auxiliary", "style", "auxiliaryStyle", "tags", "status", "statusStyle"], itemPath, issues);
|
|
443
|
+
if (item.body !== undefined)
|
|
444
|
+
validateContentText(item.body, `${itemPath}.body`, issues);
|
|
445
|
+
if (item.auxiliary !== undefined)
|
|
446
|
+
validateContentText(item.auxiliary, `${itemPath}.auxiliary`, issues);
|
|
447
|
+
validateContentTags(item.tags, `${itemPath}.tags`, issues);
|
|
448
|
+
if (item.status !== undefined)
|
|
449
|
+
validateContentText(item.status, `${itemPath}.status`, issues);
|
|
450
|
+
validateContentTextStyle(item.statusStyle, `${itemPath}.statusStyle`, issues, true);
|
|
451
|
+
validateContentTextStyle(item.style, `${itemPath}.style`, issues);
|
|
452
|
+
validateContentTextStyle(item.auxiliaryStyle, `${itemPath}.auxiliaryStyle`, issues);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/** Validate finite tag styles while preserving the exact authored tag and inline-span paths. */
|
|
456
|
+
function validateContentTags(value, path, issues) {
|
|
457
|
+
if (value === undefined)
|
|
458
|
+
return;
|
|
459
|
+
if (!Array.isArray(value)) {
|
|
460
|
+
issues.push({ path, message: "must be an array" });
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
464
|
+
const tag = value[index];
|
|
465
|
+
const tagPath = `${path}[${index}]`;
|
|
466
|
+
if (!isRecord(tag)) {
|
|
467
|
+
issues.push({ path: tagPath, message: "must be an object" });
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
rejectUnknownFields(tag, ["text", "style"], tagPath, issues);
|
|
471
|
+
validateContentText(tag.text, `${tagPath}.text`, issues);
|
|
472
|
+
if (!isRecord(tag.style)) {
|
|
473
|
+
validateContentTextStyle(tag.style, `${tagPath}.style`, issues, true);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const { fill, ...textStyle } = tag.style;
|
|
477
|
+
validateContentTextStyle(textStyle, `${tagPath}.style`, issues, true);
|
|
478
|
+
if (fill !== undefined)
|
|
479
|
+
validateColor(fill, `${tagPath}.style.fill`, issues);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/** Validate inline source values before evaluation; unknown fields keep their exact source position. */
|
|
483
|
+
export function validateContentText(value, path, issues) {
|
|
484
|
+
if (typeof value === "string")
|
|
485
|
+
return;
|
|
486
|
+
if (!Array.isArray(value)) {
|
|
487
|
+
issues.push({ path, message: "must be a string or an array of text spans" });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
491
|
+
const span = value[index];
|
|
492
|
+
const spanPath = `${path}[${index}]`;
|
|
493
|
+
if (!isRecord(span)) {
|
|
494
|
+
issues.push({ path: spanPath, message: "must be an object" });
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
rejectUnknownFields(span, ["text", "style"], spanPath, issues);
|
|
498
|
+
if (typeof span.text !== "string")
|
|
499
|
+
issues.push({ path: `${spanPath}.text`, message: "must be a string" });
|
|
500
|
+
validateContentTextStyle(span.style, `${spanPath}.style`, issues, true);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/** Validate the controlled style shared by content blocks; no CSS expression or external resource is accepted. */
|
|
504
|
+
export function validateContentTextStyle(value, path, issues, inline = false) {
|
|
505
|
+
if (value === undefined)
|
|
506
|
+
return;
|
|
507
|
+
if (!isRecord(value)) {
|
|
508
|
+
issues.push({ path, message: "must be an object" });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
rejectUnknownFields(value, inline ? ["fontSize", "fontWeight", "color"] : ["fontSize", "fontWeight", "color", "align"], path, issues);
|
|
512
|
+
if (value.fontSize !== undefined)
|
|
513
|
+
validateSpacingNumber(value.fontSize, `${path}.fontSize`, issues, true);
|
|
514
|
+
if (value.fontWeight !== undefined && (typeof value.fontWeight !== "number" || !Number.isInteger(value.fontWeight) || value.fontWeight < MIN_FONT_WEIGHT || value.fontWeight > MAX_FONT_WEIGHT))
|
|
515
|
+
issues.push({ path: `${path}.fontWeight`, message: "must be an integer from 100 through 900" });
|
|
516
|
+
if (value.align !== undefined && value.align !== "left" && value.align !== "center" && value.align !== "right")
|
|
517
|
+
issues.push({ path: `${path}.align`, message: "must be left, center or right" });
|
|
518
|
+
if (value.color !== undefined)
|
|
519
|
+
validateColor(value.color, `${path}.color`, issues);
|
|
520
|
+
}
|
|
521
|
+
function validateColor(value, path, issues) {
|
|
522
|
+
if (typeof value !== "string" || !/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(value))
|
|
523
|
+
issues.push({ path, message: "must be a hexadecimal color" });
|
|
524
|
+
}
|
|
525
|
+
function validateContentSpacing(value, path, issues) {
|
|
526
|
+
if (value === undefined)
|
|
527
|
+
return;
|
|
528
|
+
if (!isRecord(value)) {
|
|
529
|
+
issues.push({ path, message: "must be an object" });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
rejectUnknownFields(value, ["padding", "blockGap"], path, issues);
|
|
533
|
+
for (const field of ["padding", "blockGap"]) {
|
|
534
|
+
if (value[field] !== undefined)
|
|
535
|
+
validateSpacingNumber(value[field], `${path}.${field}`, issues);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
function validateSpacingNumber(value, path, issues, positive = false) {
|
|
539
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
540
|
+
issues.push({ path, message: "must be a finite number" });
|
|
541
|
+
else if (positive ? value <= 0 : value < 0)
|
|
542
|
+
issues.push({ path, message: positive ? "must be greater than zero" : "must be zero or greater" });
|
|
543
|
+
}
|
|
544
|
+
function isRecord(value) {
|
|
545
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
546
|
+
return false;
|
|
547
|
+
const prototype = Object.getPrototypeOf(value);
|
|
548
|
+
return prototype === Object.prototype || prototype === null;
|
|
549
|
+
}
|
|
550
|
+
function rejectUnknownFields(value, allowed, path, issues) {
|
|
551
|
+
for (const key of Object.keys(value))
|
|
552
|
+
if (!allowed.includes(key))
|
|
553
|
+
issues.push({ path: `${path}.${key}`, message: "unknown field" });
|
|
554
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SVG number serialization. Geometry and SVG serialization use this
|
|
3
|
+
* deterministic format so moving either implementation cannot change output.
|
|
4
|
+
*/
|
|
5
|
+
export function formatNumber(value) {
|
|
6
|
+
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(3)));
|
|
7
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Shared deterministic paint values for core rendering and browser-only interaction feedback. */
|
|
2
|
+
export interface ThemePalette {
|
|
3
|
+
background: string;
|
|
4
|
+
fill: string;
|
|
5
|
+
stroke: string;
|
|
6
|
+
text: string;
|
|
7
|
+
mutedText: string;
|
|
8
|
+
tagText: string;
|
|
9
|
+
tagFill: string;
|
|
10
|
+
accentText: string;
|
|
11
|
+
edge: string;
|
|
12
|
+
/** Border and flow-marker emphasis; node interiors use highlightFill instead. */
|
|
13
|
+
highlight: string;
|
|
14
|
+
highlightFill: string;
|
|
15
|
+
highlightText: string;
|
|
16
|
+
}
|
|
17
|
+
export declare const DEFAULT_NODE_STROKE_WIDTH = 1.2;
|
|
18
|
+
export declare const NODE_CORNER_RADIUS = 8;
|
|
19
|
+
export declare const DIAGRAM_FONT_FAMILY = "system-ui, -apple-system, \"PingFang SC\", \"Microsoft YaHei\", \"Noto Sans CJK SC\", sans-serif";
|
|
20
|
+
/** Select a palette without consulting browser preferences. */
|
|
21
|
+
export declare function resolveThemePalette(theme: "light" | "dark" | undefined): ThemePalette;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const LIGHT_PALETTE = {
|
|
2
|
+
background: "#ffffff", fill: "#ffffff", stroke: "#cfd8e3", text: "#1d2939", mutedText: "#5a6473",
|
|
3
|
+
tagText: "#1d4fa8", tagFill: "#eaf1ff", accentText: "#1d4fa8", edge: "#8892a4",
|
|
4
|
+
highlight: "#2a63c8", highlightFill: "#eaf1ff", highlightText: "#ffffff"
|
|
5
|
+
};
|
|
6
|
+
const DARK_PALETTE = {
|
|
7
|
+
background: "#101828", fill: "#1d2939", stroke: "#475467", text: "#f2f4f7", mutedText: "#b8c2d1",
|
|
8
|
+
tagText: "#caddff", tagFill: "#253b5c", accentText: "#9bbfff", edge: "#98a2b3",
|
|
9
|
+
highlight: "#8ab4f8", highlightFill: "#233b60", highlightText: "#101828"
|
|
10
|
+
};
|
|
11
|
+
export const DEFAULT_NODE_STROKE_WIDTH = 1.2;
|
|
12
|
+
export const NODE_CORNER_RADIUS = 8;
|
|
13
|
+
export const DIAGRAM_FONT_FAMILY = 'system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif';
|
|
14
|
+
/** Select a palette without consulting browser preferences. */
|
|
15
|
+
export function resolveThemePalette(theme) {
|
|
16
|
+
return theme === "dark" ? DARK_PALETTE : LIGHT_PALETTE;
|
|
17
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic global timeline: compile actions/snapshots and evaluate base styles, visibility, highlights and focus.
|
|
3
|
+
* Spatial layout and SVG output belong to the core; theme defaults are supplied only during evaluation.
|
|
4
|
+
*/
|
|
5
|
+
import type { DiagramDocument, EvaluateOptions, FlowDirection, NodeVisualState, EdgeVisualState } from "./model.js";
|
|
6
|
+
type VisualValues = NodeVisualState & EdgeVisualState;
|
|
7
|
+
/** Evaluation-time theme defaults; unspecified snapshot colors remain unresolved so compilation does not fix a theme. */
|
|
8
|
+
export interface TimelinePalette {
|
|
9
|
+
fill: string;
|
|
10
|
+
stroke: string;
|
|
11
|
+
edge: string;
|
|
12
|
+
}
|
|
13
|
+
/** One transfer in a synchronized beat-local group; no transfer persists in inherited snapshots. */
|
|
14
|
+
export interface CompiledFlow {
|
|
15
|
+
target: string;
|
|
16
|
+
direction: FlowDirection;
|
|
17
|
+
text?: string;
|
|
18
|
+
}
|
|
19
|
+
/** Object transition result; highlight is the overlay weight, while highlighted is the target logical state. */
|
|
20
|
+
export interface TimelineObject {
|
|
21
|
+
visible: boolean;
|
|
22
|
+
opacity: number;
|
|
23
|
+
highlight: number;
|
|
24
|
+
highlighted: boolean;
|
|
25
|
+
values: VisualValues;
|
|
26
|
+
}
|
|
27
|
+
/** Timeline state/progress only; the core adds absolute geometry, text, edge routing and camera bounds. */
|
|
28
|
+
export interface TimelineFrame {
|
|
29
|
+
timeMs: number;
|
|
30
|
+
sceneId: string | null;
|
|
31
|
+
beatId: string | null;
|
|
32
|
+
beatProgress: number;
|
|
33
|
+
transitionProgress: number;
|
|
34
|
+
focusBefore: string | null;
|
|
35
|
+
focusAfter: string | null;
|
|
36
|
+
caption?: string;
|
|
37
|
+
objects: ReadonlyMap<string, TimelineObject>;
|
|
38
|
+
flows: readonly CompiledFlow[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Flatten scenes into continuous beats and compile snapshots once; queries locate beats by binary search.
|
|
42
|
+
* Each beat inherits the previous final state; evaluation is independent of query order and clocks.
|
|
43
|
+
* See docs/adr/0002-continuous-global-timeline.md for time semantics.
|
|
44
|
+
*/
|
|
45
|
+
export declare class CompiledTimeline {
|
|
46
|
+
readonly totalDuration: number;
|
|
47
|
+
private readonly beats;
|
|
48
|
+
private readonly initial;
|
|
49
|
+
private readonly title?;
|
|
50
|
+
constructor(document: DiagramDocument);
|
|
51
|
+
/** Clamp finite times globally; reduced motion skips interpolation but preserves beat selection and total duration. */
|
|
52
|
+
evaluateAt(timeMs: number, options: EvaluateOptions, palette: TimelinePalette): TimelineFrame;
|
|
53
|
+
/** Select the next beat at endMs, retaining the last beat at total duration; static documents return undefined. */
|
|
54
|
+
private findBeat;
|
|
55
|
+
}
|
|
56
|
+
export {};
|