@kgdiff/scene 0.0.0-stage → 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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -2
  3. package/dist/build.d.ts +23 -0
  4. package/dist/build.d.ts.map +1 -0
  5. package/dist/build.js +407 -0
  6. package/dist/build.js.map +1 -0
  7. package/dist/choreography.d.ts +98 -0
  8. package/dist/choreography.d.ts.map +1 -0
  9. package/dist/choreography.js +271 -0
  10. package/dist/choreography.js.map +1 -0
  11. package/dist/color.d.ts +42 -0
  12. package/dist/color.d.ts.map +1 -0
  13. package/dist/color.js +169 -0
  14. package/dist/color.js.map +1 -0
  15. package/dist/constants.d.ts +89 -0
  16. package/dist/constants.d.ts.map +1 -0
  17. package/dist/constants.js +89 -0
  18. package/dist/constants.js.map +1 -0
  19. package/dist/easing.d.ts +28 -0
  20. package/dist/easing.d.ts.map +1 -0
  21. package/dist/easing.js +103 -0
  22. package/dist/easing.js.map +1 -0
  23. package/dist/focus.d.ts +45 -0
  24. package/dist/focus.d.ts.map +1 -0
  25. package/dist/focus.js +317 -0
  26. package/dist/focus.js.map +1 -0
  27. package/dist/frame.d.ts +13 -0
  28. package/dist/frame.d.ts.map +1 -0
  29. package/dist/frame.js +32 -0
  30. package/dist/frame.js.map +1 -0
  31. package/dist/geometry.d.ts +61 -0
  32. package/dist/geometry.d.ts.map +1 -0
  33. package/dist/geometry.js +122 -0
  34. package/dist/geometry.js.map +1 -0
  35. package/dist/index.d.ts +16 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +16 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/resolver.d.ts +9 -0
  40. package/dist/resolver.d.ts.map +1 -0
  41. package/dist/resolver.js +1621 -0
  42. package/dist/resolver.js.map +1 -0
  43. package/dist/roles.d.ts +46 -0
  44. package/dist/roles.d.ts.map +1 -0
  45. package/dist/roles.js +197 -0
  46. package/dist/roles.js.map +1 -0
  47. package/dist/styles.d.ts +116 -0
  48. package/dist/styles.d.ts.map +1 -0
  49. package/dist/styles.js +391 -0
  50. package/dist/styles.js.map +1 -0
  51. package/dist/theme.d.ts +14 -0
  52. package/dist/theme.d.ts.map +1 -0
  53. package/dist/theme.js +154 -0
  54. package/dist/theme.js.map +1 -0
  55. package/dist/timeline.d.ts +69 -0
  56. package/dist/timeline.d.ts.map +1 -0
  57. package/dist/timeline.js +362 -0
  58. package/dist/timeline.js.map +1 -0
  59. package/dist/types.d.ts +493 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +2 -0
  62. package/dist/types.js.map +1 -0
  63. package/dist/view.d.ts +11 -0
  64. package/dist/view.d.ts.map +1 -0
  65. package/dist/view.js +22 -0
  66. package/dist/view.js.map +1 -0
  67. package/package.json +46 -3
  68. package/src/build.ts +450 -0
  69. package/src/choreography.ts +342 -0
  70. package/src/color.ts +194 -0
  71. package/src/constants.ts +97 -0
  72. package/src/easing.ts +104 -0
  73. package/src/focus.ts +335 -0
  74. package/src/frame.ts +37 -0
  75. package/src/geometry.ts +162 -0
  76. package/src/index.ts +15 -0
  77. package/src/resolver.ts +1710 -0
  78. package/src/roles.ts +203 -0
  79. package/src/styles.ts +513 -0
  80. package/src/theme.ts +185 -0
  81. package/src/timeline.ts +391 -0
  82. package/src/types.ts +544 -0
  83. package/src/view.ts +27 -0
package/src/roles.ts ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Diff semantics → visual roles and meaning families (docs/visual-language.md §Meaning families).
3
+ */
4
+ import type { AttrChange, HunkKind, NodeFacet, UnionEdge, UnionNode } from '@kgdiff/core';
5
+ import type { ChangeFamily, EdgeRole, Family, NodeRole } from './types.js';
6
+
7
+ /** The family a hunk kind is colored with by default. */
8
+ export function hunkFamily(kind: HunkKind): ChangeFamily {
9
+ switch (kind) {
10
+ case 'add':
11
+ return 'added';
12
+ case 'remove':
13
+ return 'removed';
14
+ case 'merge':
15
+ case 'split':
16
+ return 'identity';
17
+ case 'relations':
18
+ return 'structure';
19
+ case 'modify':
20
+ return 'content';
21
+ default:
22
+ return assertNever(kind);
23
+ }
24
+ }
25
+
26
+ export function assertNever(value: never): never {
27
+ throw new Error(`Unexpected value ${String(value)}`);
28
+ }
29
+
30
+ const has = (facets: readonly NodeFacet[], kind: NodeFacet['kind']): boolean =>
31
+ facets.some((f) => f.kind === kind);
32
+
33
+ /**
34
+ * Restructuring roles win (a merge source is drawn as one even when it also changed content);
35
+ * among them merge before split, the side that disappears before the side that stays.
36
+ * Otherwise the status decides; a node that became deprecated has its own role.
37
+ */
38
+ export function nodeRole(node: UnionNode): NodeRole {
39
+ const f = node.facets;
40
+ if (has(f, 'mergedInto')) return 'merge-source';
41
+ if (has(f, 'mergeTarget')) return 'merge-target';
42
+ if (has(f, 'splitInto')) return 'split-source';
43
+ if (has(f, 'splitFrom')) return 'split-target';
44
+ switch (node.status) {
45
+ case 'added':
46
+ return 'added';
47
+ case 'removed':
48
+ return 'removed';
49
+ case 'unchanged':
50
+ return 'unchanged';
51
+ case 'modified':
52
+ return f.some((x) => x.kind === 'deprecate' && x.deprecated) ? 'deprecated' : 'modified';
53
+ default:
54
+ return assertNever(node.status);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Family of a node's own (non-restructuring) facets by the primary-facet rule:
60
+ * `rekey` → identity, `move` → structure, anything else → content; none → neutral.
61
+ * "Anything else" deliberately includes facet kinds added later (e.g. `replacement`, changed
62
+ * replacement links): they are content changes unless a merge / split facet makes the node an
63
+ * identity participant, which `nodeRole` handles.
64
+ */
65
+ export function ownFacetFamily(facets: readonly NodeFacet[]): Family {
66
+ let family: Family = 'neutral';
67
+ for (const f of facets) {
68
+ switch (f.kind) {
69
+ case 'rekey':
70
+ return 'identity';
71
+ case 'move':
72
+ family = 'structure';
73
+ break;
74
+ case 'mergedInto':
75
+ case 'mergeTarget':
76
+ case 'splitFrom':
77
+ case 'splitInto':
78
+ break;
79
+ default:
80
+ if (family === 'neutral') family = 'content';
81
+ }
82
+ }
83
+ return family;
84
+ }
85
+
86
+ /**
87
+ * Node family from its primary facet. Merge / split participants are identity on both sides
88
+ * (sources are identity ghosts, targets identity rings); deprecated nodes are content.
89
+ */
90
+ export function nodeFamily(node: UnionNode, role: NodeRole): Family {
91
+ switch (role) {
92
+ case 'merge-source':
93
+ case 'merge-target':
94
+ case 'split-source':
95
+ case 'split-target':
96
+ return 'identity';
97
+ case 'added':
98
+ return 'added';
99
+ case 'removed':
100
+ return 'removed';
101
+ case 'unchanged':
102
+ return 'neutral';
103
+ case 'deprecated':
104
+ return 'content';
105
+ case 'modified': {
106
+ const own = ownFacetFamily(node.facets);
107
+ return own === 'neutral' ? 'content' : own;
108
+ }
109
+ default:
110
+ return assertNever(role);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Family a node is drawn in in the `before` layer. Merge / split targets that existed before
116
+ * show their own facets (neutral when they have none); every other node keeps `family`
117
+ * (including nodes absent before, which the before layer hides).
118
+ */
119
+ export function nodeFamilyBefore(node: UnionNode, role: NodeRole, family: Family): Family {
120
+ const present = node.before !== undefined || node.beforeId !== undefined;
121
+ if ((role === 'merge-target' || role === 'split-target') && present)
122
+ return ownFacetFamily(node.facets);
123
+ return family;
124
+ }
125
+
126
+ export function edgeRole(edge: UnionEdge): EdgeRole {
127
+ for (const f of edge.facets) {
128
+ if (f.kind === 'collapsed') return 'collapsed';
129
+ if (f.kind === 'retarget')
130
+ return f.cause === 'merge' || f.cause === 'split' ? 'carried' : 'retarget';
131
+ }
132
+ return edge.status;
133
+ }
134
+
135
+ export function edgeFamily(edge: UnionEdge, role: EdgeRole): Family {
136
+ switch (role) {
137
+ case 'added':
138
+ return 'added';
139
+ case 'removed':
140
+ return 'removed';
141
+ case 'unchanged':
142
+ return 'neutral';
143
+ case 'collapsed':
144
+ case 'carried':
145
+ return 'identity';
146
+ case 'retarget':
147
+ return 'structure';
148
+ case 'modified':
149
+ return edge.facets.some((f) => f.kind === 'predicate') ? 'structure' : 'content';
150
+ default:
151
+ return assertNever(role);
152
+ }
153
+ }
154
+
155
+ /** `+2 −1 ~1` (U+2212 minus): attributes added / removed / changed; undefined when none. */
156
+ export function attrBadge(changes: readonly AttrChange[]): string | undefined {
157
+ let added = 0;
158
+ let removed = 0;
159
+ let changed = 0;
160
+ for (const c of changes) {
161
+ if (c.before === undefined) added++;
162
+ else if (c.after === undefined) removed++;
163
+ else changed++;
164
+ }
165
+ const parts: string[] = [];
166
+ if (added > 0) parts.push(`+${added}`);
167
+ if (removed > 0) parts.push(`−${removed}`);
168
+ if (changed > 0) parts.push(`~${changed}`);
169
+ return parts.length > 0 ? parts.join(' ') : undefined;
170
+ }
171
+
172
+ /** `⊕ n`: number of nodes merged into a merge target. */
173
+ export const mergeBadge = (n: number): string => `⊕ ${n}`;
174
+
175
+ /** Badge text for a node: `⊕ n` for merge targets, else the attribute summary. */
176
+ export function nodeBadge(node: UnionNode, role: NodeRole): string | undefined {
177
+ if (role === 'merge-target') {
178
+ const facet = node.facets.find((f) => f.kind === 'mergeTarget');
179
+ if (facet !== undefined && facet.kind === 'mergeTarget' && facet.sources.length > 0) {
180
+ return mergeBadge(facet.sources.length);
181
+ }
182
+ }
183
+ const attrs = node.facets.find((f) => f.kind === 'attrs');
184
+ return attrs !== undefined && attrs.kind === 'attrs' ? attrBadge(attrs.changes) : undefined;
185
+ }
186
+
187
+ /** Old name of a renamed node. */
188
+ export function nodeLabelBefore(node: UnionNode): string | undefined {
189
+ const f = node.facets.find((x) => x.kind === 'rename');
190
+ return f !== undefined && f.kind === 'rename' ? f.from : undefined;
191
+ }
192
+
193
+ /** Old display label of an edge whose label or predicate changed. */
194
+ export function edgeLabelBefore(edge: UnionEdge): string | undefined {
195
+ for (const f of edge.facets) {
196
+ if (f.kind === 'label') return f.from;
197
+ if (f.kind === 'predicate') {
198
+ const before = edge.before?.label;
199
+ return before !== undefined && before !== edge.label ? before : f.from;
200
+ }
201
+ }
202
+ return undefined;
203
+ }
package/src/styles.ts ADDED
@@ -0,0 +1,513 @@
1
+ /**
2
+ * Static encodings: the "Layer encodings" table of docs/visual-language.md as precomputed,
3
+ * shared style records per element and layer (`union`, `before`, `after`). The resolver copies
4
+ * these into frame visuals and only animates elements inside active replay windows.
5
+ */
6
+
7
+ import { desaturate, RampCache } from './color.js';
8
+ import { DASH_EDGE, DASH_GHOST, EMPHASIS, HALO, STROKE } from './constants.js';
9
+ import { assertNever } from './roles.js';
10
+ import type { Family, SceneEdge, SceneNode, Theme } from './types.js';
11
+
12
+ // ─── Family codes ───────────────────────────────────────────────────────────
13
+
14
+ export const F_ADDED = 0;
15
+ export const F_REMOVED = 1;
16
+ export const F_CONTENT = 2;
17
+ export const F_STRUCTURE = 3;
18
+ export const F_IDENTITY = 4;
19
+ export const F_NEUTRAL = 5;
20
+
21
+ export const FAMILY_BY_CODE: readonly Family[] = [
22
+ 'added',
23
+ 'removed',
24
+ 'content',
25
+ 'structure',
26
+ 'identity',
27
+ 'neutral',
28
+ ];
29
+
30
+ export function familyCode(f: Family): number {
31
+ switch (f) {
32
+ case 'added':
33
+ return F_ADDED;
34
+ case 'removed':
35
+ return F_REMOVED;
36
+ case 'content':
37
+ return F_CONTENT;
38
+ case 'structure':
39
+ return F_STRUCTURE;
40
+ case 'identity':
41
+ return F_IDENTITY;
42
+ case 'neutral':
43
+ return F_NEUTRAL;
44
+ default:
45
+ return assertNever(f);
46
+ }
47
+ }
48
+
49
+ // ─── Derived tokens ─────────────────────────────────────────────────────────
50
+
51
+ /** Theme colors indexed by family code, plus derived colors (precomputed once per theme). */
52
+ export interface Tokens {
53
+ readonly theme: Theme;
54
+ /** Mark color (neutral: node fill). */
55
+ readonly base: readonly string[];
56
+ /** Emphasis / node stroke (neutral: node stroke). */
57
+ readonly strong: readonly string[];
58
+ /** Text color (neutral: neutral label). */
59
+ readonly text: readonly string[];
60
+ /** Badge backgrounds (neutral: raised surface). */
61
+ readonly soft: readonly string[];
62
+ /** Edge color (neutral: neutral edge). */
63
+ readonly edge: readonly string[];
64
+ readonly deprecatedFill: string;
65
+ readonly deprecatedStroke: string;
66
+ readonly background: string;
67
+ readonly primary: string;
68
+ readonly secondary: string;
69
+ readonly muted: string;
70
+ readonly neutralLabel: string;
71
+ readonly accent: string;
72
+ readonly glow: boolean;
73
+ /** Halo radius as a multiple of r (glow 2.6; offset ring 1, drawn 5 px outside r). */
74
+ readonly haloScale: number;
75
+ readonly haloAlpha: number;
76
+ readonly weightRegular: number;
77
+ readonly weightStrong: number;
78
+ readonly ramps: RampCache;
79
+ }
80
+
81
+ const tokenCache = new WeakMap<Theme, Tokens>();
82
+
83
+ export function tokensFor(theme: Theme): Tokens {
84
+ const cached = tokenCache.get(theme);
85
+ if (cached !== undefined) return cached;
86
+ const fam = theme.family;
87
+ const order = [fam.added, fam.removed, fam.content, fam.structure, fam.identity] as const;
88
+ const tokens: Tokens = {
89
+ theme,
90
+ base: [...order.map((f) => f.base), theme.neutral.node],
91
+ strong: [...order.map((f) => f.strong), theme.neutral.nodeStroke],
92
+ text: [...order.map((f) => f.text), theme.neutral.label],
93
+ soft: [...order.map((f) => f.soft), theme.surface.raised],
94
+ edge: [...order.map((f) => f.base), theme.neutral.edge],
95
+ // "content-colored, desaturated": keeps the amber family hint but reads as retired.
96
+ deprecatedFill: desaturate(fam.content.base, 0.6),
97
+ deprecatedStroke: desaturate(fam.content.strong, 0.6),
98
+ background: theme.canvas.background,
99
+ primary: theme.text.primary,
100
+ secondary: theme.text.secondary,
101
+ muted: theme.text.muted,
102
+ neutralLabel: theme.neutral.label,
103
+ accent: theme.accent.base,
104
+ glow: theme.glow,
105
+ haloScale: theme.glow ? HALO.glowScale : 1,
106
+ haloAlpha: theme.glow ? HALO.glowAlpha : HALO.ringAlpha,
107
+ weightRegular: theme.font.weightRegular,
108
+ weightStrong: theme.font.weightStrong,
109
+ ramps: new RampCache(),
110
+ };
111
+ tokenCache.set(theme, tokens);
112
+ return tokens;
113
+ }
114
+
115
+ // ─── Node styles ────────────────────────────────────────────────────────────
116
+
117
+ export interface NodeStyle {
118
+ readonly hidden: boolean;
119
+ /** Family code the style is drawn in (halo / badge colors). */
120
+ readonly family: number;
121
+ readonly fill: string | null;
122
+ readonly stroke: string | null;
123
+ readonly dash: readonly number[] | null;
124
+ /** Ring color, or null for no ring. */
125
+ readonly ring: string | null;
126
+ /** Halo color, or null for no halo. */
127
+ readonly halo: string | null;
128
+ /** Primary label shows the before name. */
129
+ readonly labelBefore: boolean;
130
+ readonly labelColor: string;
131
+ readonly labelWeight: number;
132
+ readonly strike: boolean;
133
+ /** Changed-label LOD and priority (vs neutral). */
134
+ readonly changed: boolean;
135
+ /** Show the old name, struck, under the primary label (renames). */
136
+ readonly secondary: boolean;
137
+ readonly badge: boolean;
138
+ readonly z: number;
139
+ }
140
+
141
+ export type NodeLook =
142
+ | 'neutral'
143
+ | 'solid'
144
+ | 'added'
145
+ | 'ringed'
146
+ | 'ringHalo'
147
+ | 'ghost'
148
+ | 'deprecated';
149
+
150
+ export interface LookOptions {
151
+ readonly labelBefore?: boolean;
152
+ readonly strike?: boolean;
153
+ readonly secondary?: boolean;
154
+ readonly badge?: boolean;
155
+ }
156
+
157
+ /** Rebuild a node style as one literal in fixed field order: every style shares one map. */
158
+ function nodeStyle(o: NodeStyle): NodeStyle {
159
+ return Object.freeze({
160
+ hidden: o.hidden,
161
+ family: o.family,
162
+ fill: o.fill,
163
+ stroke: o.stroke,
164
+ dash: o.dash,
165
+ ring: o.ring,
166
+ halo: o.halo,
167
+ labelBefore: o.labelBefore,
168
+ labelColor: o.labelColor,
169
+ labelWeight: o.labelWeight,
170
+ strike: o.strike,
171
+ changed: o.changed,
172
+ secondary: o.secondary,
173
+ badge: o.badge,
174
+ z: o.z,
175
+ });
176
+ }
177
+
178
+ export const HIDDEN_NODE: NodeStyle = nodeStyle({
179
+ hidden: true,
180
+ family: F_NEUTRAL,
181
+ fill: null,
182
+ stroke: null,
183
+ dash: null,
184
+ ring: null,
185
+ halo: null,
186
+ labelBefore: false,
187
+ labelColor: '',
188
+ labelWeight: 400,
189
+ strike: false,
190
+ changed: false,
191
+ secondary: false,
192
+ badge: false,
193
+ z: 0,
194
+ });
195
+
196
+ // ─── Edge styles ────────────────────────────────────────────────────────────
197
+
198
+ /** Edge label sources. */
199
+ export const EL_NONE = 0;
200
+ export const EL_CURRENT = 1;
201
+ export const EL_BEFORE = 2;
202
+ /** `old → new` (union layer of a relabeled edge). */
203
+ export const EL_UNION = 3;
204
+
205
+ export interface EdgeStyle {
206
+ readonly hidden: boolean;
207
+ readonly family: number;
208
+ /** Draw the before form (sb → tb) instead of the after form (s → t). */
209
+ readonly before: boolean;
210
+ readonly color: string;
211
+ readonly width: number;
212
+ readonly alpha: number;
213
+ readonly dash: readonly number[] | null;
214
+ readonly arrow: boolean;
215
+ readonly label: number;
216
+ readonly labelColor: string;
217
+ readonly strike: boolean;
218
+ readonly changed: boolean;
219
+ readonly z: number;
220
+ }
221
+
222
+ export type EdgeLook = 'neutral' | 'solid' | 'dashed' | 'collapsed';
223
+
224
+ /** Rebuild an edge style as one literal in fixed field order: every style shares one map. */
225
+ function edgeStyle(o: EdgeStyle): EdgeStyle {
226
+ return Object.freeze({
227
+ hidden: o.hidden,
228
+ family: o.family,
229
+ before: o.before,
230
+ color: o.color,
231
+ width: o.width,
232
+ alpha: o.alpha,
233
+ dash: o.dash,
234
+ arrow: o.arrow,
235
+ label: o.label,
236
+ labelColor: o.labelColor,
237
+ strike: o.strike,
238
+ changed: o.changed,
239
+ z: o.z,
240
+ });
241
+ }
242
+
243
+ export const HIDDEN_EDGE: EdgeStyle = edgeStyle({
244
+ hidden: true,
245
+ family: F_NEUTRAL,
246
+ before: false,
247
+ color: '',
248
+ width: 0,
249
+ alpha: 0,
250
+ dash: null,
251
+ arrow: false,
252
+ label: EL_NONE,
253
+ labelColor: '',
254
+ strike: false,
255
+ changed: false,
256
+ z: 0,
257
+ });
258
+
259
+ /** Memoized style records: equal looks share one frozen object. */
260
+ export class StyleBook {
261
+ private readonly nodes = new Map<string, NodeStyle>();
262
+ private readonly edges = new Map<string, EdgeStyle>();
263
+
264
+ constructor(readonly tokens: Tokens) {}
265
+
266
+ node(look: NodeLook, family: number, o: LookOptions = {}): NodeStyle {
267
+ const key = `${look}|${family}|${o.labelBefore ? 1 : 0}${o.strike ? 1 : 0}${o.secondary ? 1 : 0}${o.badge ? 1 : 0}`;
268
+ let style = this.nodes.get(key);
269
+ if (style === undefined) {
270
+ style = nodeStyle(this.makeNode(look, family, o));
271
+ this.nodes.set(key, style);
272
+ }
273
+ return style;
274
+ }
275
+
276
+ private makeNode(look: NodeLook, family: number, o: LookOptions): NodeStyle {
277
+ const t = this.tokens;
278
+ const common = {
279
+ hidden: false,
280
+ family,
281
+ dash: null,
282
+ ring: null,
283
+ halo: null,
284
+ labelBefore: o.labelBefore ?? false,
285
+ labelColor: t.primary,
286
+ labelWeight: t.weightStrong,
287
+ strike: o.strike ?? false,
288
+ changed: true,
289
+ secondary: o.secondary ?? false,
290
+ badge: o.badge ?? false,
291
+ z: 2,
292
+ };
293
+ switch (look) {
294
+ case 'neutral':
295
+ return {
296
+ ...common,
297
+ family: F_NEUTRAL,
298
+ fill: t.base[F_NEUTRAL] as string,
299
+ stroke: t.strong[F_NEUTRAL] as string,
300
+ labelColor: t.neutralLabel,
301
+ labelWeight: t.weightRegular,
302
+ changed: false,
303
+ z: 0,
304
+ };
305
+ case 'solid':
306
+ return { ...common, fill: t.base[family] as string, stroke: t.strong[family] as string };
307
+ case 'added':
308
+ return {
309
+ ...common,
310
+ fill: t.base[family] as string,
311
+ stroke: t.strong[family] as string,
312
+ halo: t.base[family] as string,
313
+ };
314
+ case 'ringed':
315
+ return {
316
+ ...common,
317
+ fill: t.base[family] as string,
318
+ stroke: t.strong[family] as string,
319
+ ring: t.base[family] as string,
320
+ };
321
+ case 'ringHalo':
322
+ return {
323
+ ...common,
324
+ fill: t.base[family] as string,
325
+ stroke: t.strong[family] as string,
326
+ ring: t.base[family] as string,
327
+ halo: t.base[family] as string,
328
+ };
329
+ case 'ghost':
330
+ return { ...common, fill: null, stroke: t.base[family] as string, dash: DASH_GHOST, z: 1 };
331
+ case 'deprecated':
332
+ return {
333
+ ...common,
334
+ family: F_CONTENT,
335
+ fill: t.deprecatedFill,
336
+ stroke: t.deprecatedStroke,
337
+ ring: t.deprecatedFill,
338
+ strike: true,
339
+ };
340
+ default:
341
+ return assertNever(look);
342
+ }
343
+ }
344
+
345
+ edge(look: EdgeLook, family: number, before: boolean, label: number, strike = false): EdgeStyle {
346
+ const key = `${look}|${family}|${before ? 1 : 0}|${label}|${strike ? 1 : 0}`;
347
+ let style = this.edges.get(key);
348
+ if (style === undefined) {
349
+ style = edgeStyle(this.makeEdge(look, family, before, label, strike));
350
+ this.edges.set(key, style);
351
+ }
352
+ return style;
353
+ }
354
+
355
+ private makeEdge(
356
+ look: EdgeLook,
357
+ family: number,
358
+ before: boolean,
359
+ label: number,
360
+ strike: boolean,
361
+ ): EdgeStyle {
362
+ const t = this.tokens;
363
+ const changed: EdgeStyle = {
364
+ hidden: false,
365
+ family,
366
+ before,
367
+ color: t.edge[family] as string,
368
+ width: STROKE.changedEdge,
369
+ alpha: 1,
370
+ dash: null,
371
+ arrow: true,
372
+ label,
373
+ labelColor: t.text[family] as string,
374
+ strike,
375
+ changed: true,
376
+ z: 2,
377
+ };
378
+ switch (look) {
379
+ case 'neutral':
380
+ return {
381
+ ...changed,
382
+ family: F_NEUTRAL,
383
+ color: t.edge[F_NEUTRAL] as string,
384
+ width: STROKE.neutralEdge,
385
+ labelColor: t.neutralLabel,
386
+ changed: false,
387
+ z: 0,
388
+ };
389
+ case 'solid':
390
+ return changed;
391
+ case 'dashed':
392
+ return { ...changed, dash: DASH_EDGE, z: 1 };
393
+ case 'collapsed':
394
+ return { ...changed, dash: DASH_EDGE, alpha: EMPHASIS.collapsedEdge, z: 1 };
395
+ default:
396
+ return assertNever(look);
397
+ }
398
+ }
399
+ }
400
+
401
+ /** [union, before, after] styles of a node (docs/visual-language.md §Layer encodings). */
402
+ export function nodeLayerStyles(
403
+ book: StyleBook,
404
+ n: SceneNode,
405
+ ): readonly [NodeStyle, NodeStyle, NodeStyle] {
406
+ const fam = familyCode(n.family);
407
+ const renamed = n.labelBefore !== undefined;
408
+ switch (n.role) {
409
+ case 'unchanged': {
410
+ const s = book.node('neutral', F_NEUTRAL);
411
+ return [s, s, s];
412
+ }
413
+ case 'added': {
414
+ const s = book.node('added', F_ADDED, { badge: true });
415
+ return [s, HIDDEN_NODE, s];
416
+ }
417
+ case 'removed':
418
+ return [
419
+ book.node('ghost', F_REMOVED, { strike: true, badge: true }),
420
+ book.node('solid', F_REMOVED),
421
+ HIDDEN_NODE,
422
+ ];
423
+ case 'modified':
424
+ return [
425
+ book.node('ringed', fam, { secondary: renamed, badge: true }),
426
+ book.node('ringed', fam, { labelBefore: true }),
427
+ book.node('ringed', fam, { badge: true }),
428
+ ];
429
+ case 'deprecated':
430
+ return [
431
+ book.node('deprecated', F_CONTENT, { secondary: renamed, badge: true }),
432
+ book.node('ringed', F_CONTENT, { labelBefore: true }),
433
+ book.node('deprecated', F_CONTENT, { badge: true }),
434
+ ];
435
+ case 'merge-source':
436
+ return [
437
+ book.node('ghost', F_IDENTITY, { strike: true }),
438
+ book.node('solid', F_IDENTITY, { labelBefore: true }),
439
+ n.presentAfter ? book.node('deprecated', F_CONTENT) : HIDDEN_NODE,
440
+ ];
441
+ case 'merge-target':
442
+ case 'split-target': {
443
+ // Before: "neutral or own facet" (`familyBefore`).
444
+ const own = familyCode(n.familyBefore);
445
+ const before = !n.presentBefore
446
+ ? HIDDEN_NODE
447
+ : own === F_NEUTRAL
448
+ ? book.node('neutral', F_NEUTRAL)
449
+ : book.node('ringed', own, { labelBefore: true });
450
+ return [
451
+ book.node('ringHalo', F_IDENTITY, { secondary: renamed, badge: true }),
452
+ before,
453
+ book.node('ringHalo', F_IDENTITY, { badge: true }),
454
+ ];
455
+ }
456
+ case 'split-source':
457
+ return [
458
+ n.presentAfter
459
+ ? book.node('ringHalo', F_IDENTITY, { secondary: renamed, badge: true })
460
+ : book.node('ghost', F_IDENTITY, { strike: true }),
461
+ book.node('solid', F_IDENTITY, { labelBefore: true }),
462
+ n.presentAfter ? book.node('ringHalo', F_IDENTITY, { badge: true }) : HIDDEN_NODE,
463
+ ];
464
+ default:
465
+ return assertNever(n.role);
466
+ }
467
+ }
468
+
469
+ /** [union, before, after] styles of an edge (docs/visual-language.md §Layer encodings). */
470
+ export function edgeLayerStyles(
471
+ book: StyleBook,
472
+ e: SceneEdge,
473
+ ): readonly [EdgeStyle, EdgeStyle, EdgeStyle] {
474
+ const fam = familyCode(e.family);
475
+ const relabeled = e.labelBefore !== undefined;
476
+ switch (e.role) {
477
+ case 'unchanged': {
478
+ const s = book.edge('neutral', F_NEUTRAL, false, EL_NONE);
479
+ return [s, s, s];
480
+ }
481
+ case 'added': {
482
+ const s = book.edge('solid', F_ADDED, false, EL_CURRENT);
483
+ return [s, HIDDEN_EDGE, s];
484
+ }
485
+ case 'removed':
486
+ return [
487
+ book.edge('dashed', F_REMOVED, false, EL_CURRENT, true),
488
+ book.edge('solid', F_REMOVED, false, EL_CURRENT),
489
+ HIDDEN_EDGE,
490
+ ];
491
+ case 'modified':
492
+ return [
493
+ book.edge('solid', fam, false, relabeled ? EL_UNION : EL_CURRENT),
494
+ book.edge('solid', fam, false, EL_BEFORE),
495
+ book.edge('solid', fam, false, EL_CURRENT),
496
+ ];
497
+ case 'retarget':
498
+ case 'carried':
499
+ return [
500
+ book.edge('solid', fam, false, relabeled ? EL_UNION : EL_CURRENT),
501
+ book.edge('solid', fam, true, EL_BEFORE),
502
+ book.edge('solid', fam, false, EL_CURRENT),
503
+ ];
504
+ case 'collapsed':
505
+ return [
506
+ book.edge('collapsed', F_IDENTITY, false, EL_CURRENT, true),
507
+ book.edge('solid', F_IDENTITY, false, EL_CURRENT),
508
+ HIDDEN_EDGE,
509
+ ];
510
+ default:
511
+ return assertNever(e.role);
512
+ }
513
+ }