@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/theme.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Design tokens (docs/visual-language.md §Color tokens, §Typography, motion).
3
+ *
4
+ * Four frozen themes: dark ("night") / light ("paper") × default / colorblind palette.
5
+ * Contrast requirements are asserted by `test/theme.test.ts`.
6
+ */
7
+ import { KgdiffError } from '@kgdiff/core';
8
+ import { withAlpha } from './color.js';
9
+ import type { ChangeFamily, FamilyColors, PaletteName, Theme, ThemeName } from './types.js';
10
+
11
+ export const FONT_FAMILY =
12
+ 'Inter, "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", ' +
13
+ '"Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", system-ui, sans-serif';
14
+ export const FONT_MONO = '"JetBrains Mono", "SF Mono", ui-monospace, Menlo, Consolas, monospace';
15
+
16
+ const MOTION: Theme['motion'] = { fast: 120, base: 220, slow: 420, easing: [0.2, 0, 0, 1] };
17
+ const FONT: Theme['font'] = {
18
+ family: FONT_FAMILY,
19
+ mono: FONT_MONO,
20
+ size: 12,
21
+ weightRegular: 400,
22
+ weightStrong: 600,
23
+ };
24
+
25
+ type FamilyTable = Readonly<Record<ChangeFamily, FamilyColors>>;
26
+
27
+ const DARK_FAMILIES: FamilyTable = {
28
+ added: { base: '#34D399', strong: '#6EE7B7', soft: 'rgba(52,211,153,0.16)', text: '#6EE7B7' },
29
+ removed: { base: '#F87171', strong: '#FCA5A5', soft: 'rgba(248,113,113,0.15)', text: '#FCA5A5' },
30
+ content: { base: '#FBBF24', strong: '#FCD34D', soft: 'rgba(251,191,36,0.15)', text: '#FCD34D' },
31
+ structure: { base: '#38BDF8', strong: '#7DD3FC', soft: 'rgba(56,189,248,0.15)', text: '#7DD3FC' },
32
+ identity: { base: '#A78BFA', strong: '#C4B5FD', soft: 'rgba(167,139,250,0.17)', text: '#C4B5FD' },
33
+ };
34
+
35
+ const LIGHT_FAMILIES: FamilyTable = {
36
+ // text: spec #15803D measures 4.20:1 over its soft tint on the canvas (badges); one step
37
+ // darker, same hue → 4.54:1.
38
+ added: { base: '#16A34A', strong: '#15803D', soft: 'rgba(22,163,74,0.11)', text: '#147A3A' },
39
+ removed: { base: '#DC2626', strong: '#B91C1C', soft: 'rgba(220,38,38,0.09)', text: '#B91C1C' },
40
+ // Spec value #D97706 measures 2.998:1 on #F7F8FA (needs ≥ 3:1); one step darker passes (3.06:1)
41
+ // and is visually indistinguishable. The soft tint keeps the original hue.
42
+ content: { base: '#D87506', strong: '#B45309', soft: 'rgba(217,119,6,0.11)', text: '#92400E' },
43
+ structure: { base: '#0284C7', strong: '#0369A1', soft: 'rgba(2,132,199,0.10)', text: '#075985' },
44
+ identity: { base: '#7C3AED', strong: '#6D28D9', soft: 'rgba(124,58,237,0.10)', text: '#5B21B6' },
45
+ };
46
+
47
+ /**
48
+ * Okabe–Ito based palette (spec gives base / text). Derived tokens follow the default palette's
49
+ * pattern: dark `strong` = `text` (as in every default dark family); light `strong` = the sRGB
50
+ * midpoint of base and text (between mark and text, like content / structure / identity);
51
+ * `soft` = base at the default palette's per-family tint alpha.
52
+ */
53
+ function okabeIto(
54
+ base: Record<ChangeFamily, readonly [base: string, text: string, strong: string]>,
55
+ soft: Readonly<Record<ChangeFamily, number>>,
56
+ ): FamilyTable {
57
+ const make = (f: ChangeFamily): FamilyColors => {
58
+ const [b, text, strong] = base[f];
59
+ return { base: b, strong, soft: withAlpha(b, soft[f]), text };
60
+ };
61
+ return {
62
+ added: make('added'),
63
+ removed: make('removed'),
64
+ content: make('content'),
65
+ structure: make('structure'),
66
+ identity: make('identity'),
67
+ };
68
+ }
69
+
70
+ const DARK_SOFT = { added: 0.16, removed: 0.15, content: 0.15, structure: 0.15, identity: 0.17 };
71
+ const LIGHT_SOFT = { added: 0.11, removed: 0.09, content: 0.11, structure: 0.1, identity: 0.1 };
72
+
73
+ const DARK_COLORBLIND = okabeIto(
74
+ {
75
+ added: ['#56B4E9', '#8FD0F5', '#8FD0F5'],
76
+ removed: ['#E69F00', '#F5C45C', '#F5C45C'],
77
+ content: ['#F0E442', '#F5EC85', '#F5EC85'],
78
+ structure: ['#009E73', '#3CC79E', '#3CC79E'],
79
+ identity: ['#CC79A7', '#E0A6C6', '#E0A6C6'],
80
+ },
81
+ DARK_SOFT,
82
+ );
83
+
84
+ const LIGHT_COLORBLIND = okabeIto(
85
+ {
86
+ added: ['#0072B2', '#005A8C', '#00669F'],
87
+ removed: ['#D55E00', '#A84A00', '#BF5400'],
88
+ content: ['#9A8700', '#6E6100', '#847400'],
89
+ structure: ['#00805C', '#006248', '#007152'],
90
+ identity: ['#A3477F', '#83335F', '#933D6F'],
91
+ },
92
+ LIGHT_SOFT,
93
+ );
94
+
95
+ function dark(palette: PaletteName): Theme {
96
+ return {
97
+ name: 'dark',
98
+ palette,
99
+ canvas: { background: '#0B0E14', grid: 'rgba(148, 163, 184, 0.07)' },
100
+ surface: {
101
+ panel: '#11151D',
102
+ raised: '#171C26',
103
+ overlay: 'rgba(17, 21, 29, 0.78)',
104
+ border: 'rgba(255,255,255,0.07)',
105
+ borderStrong: 'rgba(255,255,255,0.13)',
106
+ controlBorder: '#5E687C',
107
+ shadow: '0 8px 32px rgba(0,0,0,0.45), 0 1px 0 rgba(255,255,255,0.04) inset',
108
+ },
109
+ // muted: spec #737D8F measures 4.11:1 on raised; same hue, lighter → ≥ 4.5:1 everywhere.
110
+ text: { primary: '#E7EBF3', secondary: '#A6AFBF', muted: '#7A8495' },
111
+ accent: { base: '#7C9CFF', strong: '#A3BAFF', soft: 'rgba(124,156,255,0.16)', text: '#A9BEFF' },
112
+ neutral: { node: '#4B5568', nodeStroke: '#5E687C', edge: '#323A4A', label: '#8B94A6' },
113
+ family: palette === 'colorblind' ? DARK_COLORBLIND : DARK_FAMILIES,
114
+ glow: true,
115
+ font: FONT,
116
+ motion: MOTION,
117
+ };
118
+ }
119
+
120
+ function light(palette: PaletteName): Theme {
121
+ return {
122
+ name: 'light',
123
+ palette,
124
+ canvas: { background: '#F7F8FA', grid: 'rgba(15, 23, 42, 0.07)' },
125
+ surface: {
126
+ panel: '#FFFFFF',
127
+ raised: '#FFFFFF',
128
+ overlay: 'rgba(255, 255, 255, 0.82)',
129
+ border: 'rgba(15,23,42,0.08)',
130
+ borderStrong: 'rgba(15,23,42,0.15)',
131
+ controlBorder: '#8690A3',
132
+ shadow: '0 8px 28px rgba(15,23,42,0.10), 0 1px 2px rgba(15,23,42,0.06)',
133
+ },
134
+ // muted: spec #7B8597 measures 3.50:1 on the canvas; same hue, darker → ≥ 4.5:1 everywhere.
135
+ text: { primary: '#0F172A', secondary: '#475569', muted: '#687284' },
136
+ accent: { base: '#3B5BDB', strong: '#2F49B5', soft: 'rgba(59,91,219,0.10)', text: '#2F49B5' },
137
+ neutral: { node: '#A7B0BF', nodeStroke: '#8C96A8', edge: '#D3D9E2', label: '#5F6B7D' },
138
+ family: palette === 'colorblind' ? LIGHT_COLORBLIND : LIGHT_FAMILIES,
139
+ glow: false,
140
+ font: FONT,
141
+ motion: MOTION,
142
+ };
143
+ }
144
+
145
+ function deepFreeze<T>(value: T): T {
146
+ if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
147
+ Object.freeze(value);
148
+ for (const v of Object.values(value)) deepFreeze(v);
149
+ }
150
+ return value;
151
+ }
152
+
153
+ const cache = new Map<string, Theme>();
154
+
155
+ export const THEME_NAMES: readonly ThemeName[] = ['dark', 'light'];
156
+ export const PALETTE_NAMES: readonly PaletteName[] = ['default', 'colorblind'];
157
+
158
+ /**
159
+ * Design tokens for a theme / palette pair (docs/visual-language.md §Color).
160
+ * Returns a deeply frozen, cached object: the same pair always yields the same instance,
161
+ * so it can key caches.
162
+ *
163
+ * @throws KgdiffError(`invalid-input`) for an unknown theme or palette name.
164
+ */
165
+ export function getTheme(name: ThemeName, palette: PaletteName = 'default'): Theme {
166
+ if (!THEME_NAMES.includes(name)) {
167
+ throw new KgdiffError(
168
+ 'invalid-input',
169
+ `Unknown theme "${String(name)}" (expected "dark" or "light").`,
170
+ );
171
+ }
172
+ if (!PALETTE_NAMES.includes(palette)) {
173
+ throw new KgdiffError(
174
+ 'invalid-input',
175
+ `Unknown palette "${String(palette)}" (expected "default" or "colorblind").`,
176
+ );
177
+ }
178
+ const key = `${name}/${palette}`;
179
+ let theme = cache.get(key);
180
+ if (theme === undefined) {
181
+ theme = deepFreeze(name === 'dark' ? dark(palette) : light(palette));
182
+ cache.set(key, theme);
183
+ }
184
+ return theme;
185
+ }
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Review / replay order and the replay timeline (docs/algorithms.md §10).
3
+ */
4
+ import type { HunkKind, Rect, ReplayPhase } from '@kgdiff/core';
5
+ import { TIMELINE } from './constants.js';
6
+ import { BoundsBuilder } from './geometry.js';
7
+ import type { Timeline, TimelineChapter, TimelineWindow } from './types.js';
8
+
9
+ export const PHASE_ORDER: readonly ReplayPhase[] = ['remove', 'restructure', 'modify', 'add'];
10
+
11
+ export interface TourHunk {
12
+ readonly phase: ReplayPhase;
13
+ /** Changed nodes + edges. */
14
+ readonly size: number;
15
+ /** Default review order from core (tie-breaker). */
16
+ readonly order: number;
17
+ /** Anchor position. */
18
+ readonly x: number;
19
+ readonly y: number;
20
+ /** Indices (into the same array) of hunks that must come first (core `Hunk.requires`). */
21
+ readonly requires: readonly number[];
22
+ }
23
+
24
+ /** Lexicographic (squared distance, default order) comparison: true when a beats b. */
25
+ const better = (d2a: number, oa: number, d2b: number, ob: number): boolean =>
26
+ d2a < d2b || (d2a === d2b && oa < ob);
27
+
28
+ /**
29
+ * Uniform grid over a set of points supporting "nearest eligible point" queries. Points start
30
+ * ineligible; `activate` makes one eligible (its requirements are placed), `take` removes it.
31
+ * Ties are broken by `order`. Expected O(1) cells per query for spread points.
32
+ */
33
+ class NearestGrid {
34
+ private readonly minX: number;
35
+ private readonly minY: number;
36
+ private readonly cw: number;
37
+ private readonly ch: number;
38
+ private readonly gw: number;
39
+ private readonly gh: number;
40
+ private readonly cellStart: Int32Array;
41
+ private readonly cellItems: Int32Array;
42
+ private readonly cellAlive: Int32Array;
43
+ private readonly alive: Uint8Array;
44
+ private remaining: number;
45
+
46
+ constructor(
47
+ private readonly items: readonly TourHunk[],
48
+ private readonly members: readonly number[],
49
+ ) {
50
+ let minX = Number.POSITIVE_INFINITY;
51
+ let minY = Number.POSITIVE_INFINITY;
52
+ let maxX = Number.NEGATIVE_INFINITY;
53
+ let maxY = Number.NEGATIVE_INFINITY;
54
+ for (const m of members) {
55
+ const it = items[m] as TourHunk;
56
+ minX = Math.min(minX, it.x);
57
+ minY = Math.min(minY, it.y);
58
+ maxX = Math.max(maxX, it.x);
59
+ maxY = Math.max(maxY, it.y);
60
+ }
61
+ const side = Math.max(1, Math.ceil(Math.sqrt(members.length)));
62
+ this.gw = side;
63
+ this.gh = side;
64
+ this.minX = minX;
65
+ this.minY = minY;
66
+ // Guard against zero / underflowing extents (e.g. coordinates a denormal apart).
67
+ const cw = (maxX - minX) / side;
68
+ const ch = (maxY - minY) / side;
69
+ this.cw = cw > 0 && Number.isFinite(cw) ? cw : 1;
70
+ this.ch = ch > 0 && Number.isFinite(ch) ? ch : 1;
71
+ const cells = this.gw * this.gh;
72
+ const counts = new Int32Array(cells + 1);
73
+ const cellOf = new Int32Array(members.length);
74
+ for (let k = 0; k < members.length; k++) {
75
+ const it = items[members[k] as number] as TourHunk;
76
+ const c = this.cellIndex(it.x, it.y);
77
+ cellOf[k] = c;
78
+ counts[c + 1] = (counts[c + 1] as number) + 1;
79
+ }
80
+ for (let c = 0; c < cells; c++)
81
+ counts[c + 1] = (counts[c + 1] as number) + (counts[c] as number);
82
+ this.cellStart = counts;
83
+ const fill = counts.slice(0, cells);
84
+ this.cellItems = new Int32Array(members.length);
85
+ for (let k = 0; k < members.length; k++) {
86
+ const c = cellOf[k] as number;
87
+ this.cellItems[fill[c] as number] = k;
88
+ fill[c] = (fill[c] as number) + 1;
89
+ }
90
+ this.cellAlive = new Int32Array(cells);
91
+ this.alive = new Uint8Array(members.length);
92
+ this.remaining = 0;
93
+ }
94
+
95
+ private cellX(x: number): number {
96
+ const c = Math.floor((x - this.minX) / this.cw);
97
+ return c >= 0 ? (c >= this.gw ? this.gw - 1 : c) : 0;
98
+ }
99
+
100
+ private cellY(y: number): number {
101
+ const c = Math.floor((y - this.minY) / this.ch);
102
+ return c >= 0 ? (c >= this.gh ? this.gh - 1 : c) : 0;
103
+ }
104
+
105
+ private cellIndex(x: number, y: number): number {
106
+ return this.cellY(y) * this.gw + this.cellX(x);
107
+ }
108
+
109
+ get size(): number {
110
+ return this.remaining;
111
+ }
112
+
113
+ /** Make member slot `k` (index into `members`) eligible. */
114
+ activate(k: number): void {
115
+ if (this.alive[k] === 1) return;
116
+ this.alive[k] = 1;
117
+ this.remaining++;
118
+ const it = this.items[this.members[k] as number] as TourHunk;
119
+ const c = this.cellIndex(it.x, it.y);
120
+ this.cellAlive[c] = (this.cellAlive[c] as number) + 1;
121
+ }
122
+
123
+ /** Remove eligible member slot `k`. */
124
+ take(k: number): void {
125
+ if (this.alive[k] === 0) return;
126
+ this.alive[k] = 0;
127
+ this.remaining--;
128
+ const it = this.items[this.members[k] as number] as TourHunk;
129
+ const c = this.cellIndex(it.x, it.y);
130
+ this.cellAlive[c] = (this.cellAlive[c] as number) - 1;
131
+ }
132
+
133
+ /** Eligible member slot with the largest size (ties → lowest order), or -1. O(members). */
134
+ largest(): number {
135
+ let best = -1;
136
+ for (let k = 0; k < this.members.length; k++) {
137
+ if (this.alive[k] === 0) continue;
138
+ const a = this.items[this.members[k] as number] as TourHunk;
139
+ const b = best < 0 ? undefined : (this.items[this.members[best] as number] as TourHunk);
140
+ if (b === undefined || a.size > b.size || (a.size === b.size && a.order < b.order)) best = k;
141
+ }
142
+ return best;
143
+ }
144
+
145
+ /** Member slot of the nearest remaining point (ties → lowest order), or -1. */
146
+ nearest(x: number, y: number): number {
147
+ if (this.remaining === 0) return -1;
148
+ const qx = this.cellX(x);
149
+ const qy = this.cellY(y);
150
+ const minCell = Math.min(this.cw, this.ch);
151
+ let best = -1;
152
+ let bestD2 = Number.POSITIVE_INFINITY;
153
+ let bestOrder = Number.POSITIVE_INFINITY;
154
+ const maxRing = Math.max(this.gw, this.gh);
155
+ for (let ring = 0; ring <= maxRing; ring++) {
156
+ const x0 = qx - ring;
157
+ const x1 = qx + ring;
158
+ const y0 = qy - ring;
159
+ const y1 = qy + ring;
160
+ for (let cy = y0; cy <= y1; cy++) {
161
+ if (cy < 0 || cy >= this.gh) continue;
162
+ const edgeRow = cy === y0 || cy === y1;
163
+ for (let cx = x0; cx <= x1; cx += edgeRow ? 1 : x1 - x0 || 1) {
164
+ if (cx < 0 || cx >= this.gw) continue;
165
+ const c = cy * this.gw + cx;
166
+ if (this.cellAlive[c] === 0) continue;
167
+ for (let p = this.cellStart[c] as number; p < (this.cellStart[c + 1] as number); p++) {
168
+ const k = this.cellItems[p] as number;
169
+ if (this.alive[k] === 0) continue;
170
+ const it = this.items[this.members[k] as number] as TourHunk;
171
+ const dx = it.x - x;
172
+ const dy = it.y - y;
173
+ const d2 = dx * dx + dy * dy;
174
+ if (better(d2, it.order, bestD2, bestOrder)) {
175
+ best = k;
176
+ bestD2 = d2;
177
+ bestOrder = it.order;
178
+ }
179
+ }
180
+ }
181
+ }
182
+ // Every cell at ring ≥ ring + 1 is at least ring·minCell away; strict so ties keep searching.
183
+ const bound = ring * minCell;
184
+ if (best >= 0 && bestD2 < bound * bound) break;
185
+ }
186
+ return best;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Order hunks as a short spatial tour (docs/algorithms.md §10) that respects `requires`:
192
+ *
193
+ * - Only *ready* hunks (every requirement already placed) are candidates.
194
+ * - Phase is a preference, not a barrier: the candidates are the ready hunks of the earliest
195
+ * phase that has any, so a hunk may follow a later-phase hunk it requires.
196
+ * - The very first hunk is the largest candidate; every next one is the candidate nearest the
197
+ * previous anchor (greedy nearest neighbour), which also starts each phase near where the
198
+ * previous one ended.
199
+ * - Ties are broken by the default `order`. Unknown or self requirements are ignored; a cycle
200
+ * (never produced by core) is broken by releasing its lowest-`order` hunk.
201
+ *
202
+ * Returns indices into `hunks`. ~O((H + R) √H) worst case, near-linear typically.
203
+ */
204
+ export function tourOrder(hunks: readonly TourHunk[]): number[] {
205
+ const H = hunks.length;
206
+ const phaseRank = (p: ReplayPhase): number => PHASE_ORDER.indexOf(p);
207
+ // Per-phase grids over all hunks of the phase; slots activate once their hunk is ready.
208
+ const members: number[][] = PHASE_ORDER.map(() => []);
209
+ for (let i = 0; i < H; i++) members[phaseRank((hunks[i] as TourHunk).phase)]?.push(i);
210
+ for (const m of members)
211
+ m.sort((a, b) => (hunks[a] as TourHunk).order - (hunks[b] as TourHunk).order);
212
+ const grids = members.map((m) => new NearestGrid(hunks, m));
213
+ const slotOf = new Int32Array(H);
214
+ for (const m of members) for (let k = 0; k < m.length; k++) slotOf[m[k] as number] = k;
215
+ // Pending requirement counts and reverse edges (deduplicated).
216
+ const pending = new Int32Array(H);
217
+ const dependents: number[][] = Array.from({ length: H }, () => []);
218
+ for (let i = 0; i < H; i++) {
219
+ for (const r of new Set((hunks[i] as TourHunk).requires)) {
220
+ if (r === i || r < 0 || r >= H) continue;
221
+ pending[i] = (pending[i] as number) + 1;
222
+ (dependents[r] as number[]).push(i);
223
+ }
224
+ }
225
+ const placed = new Uint8Array(H);
226
+ const activate = (i: number): void => {
227
+ grids[phaseRank((hunks[i] as TourHunk).phase)]?.activate(slotOf[i] as number);
228
+ };
229
+ for (let i = 0; i < H; i++) if (pending[i] === 0) activate(i);
230
+
231
+ const result: number[] = [];
232
+ let last: TourHunk | null = null;
233
+ while (result.length < H) {
234
+ let g = grids.findIndex((grid) => grid.size > 0);
235
+ if (g < 0) {
236
+ // A requirement cycle: release the unplaced hunk with the lowest default order.
237
+ let pick = -1;
238
+ for (let i = 0; i < H; i++) {
239
+ if (placed[i] === 1) continue;
240
+ if (pick < 0 || (hunks[i] as TourHunk).order < (hunks[pick] as TourHunk).order) pick = i;
241
+ }
242
+ pending[pick] = 0;
243
+ activate(pick);
244
+ g = phaseRank((hunks[pick] as TourHunk).phase);
245
+ }
246
+ const grid = grids[g] as NearestGrid;
247
+ const slot = last === null ? grid.largest() : grid.nearest(last.x, last.y);
248
+ grid.take(slot);
249
+ const idx = (members[g] as number[])[slot] as number;
250
+ placed[idx] = 1;
251
+ result.push(idx);
252
+ last = hunks[idx] as TourHunk;
253
+ for (const d of dependents[idx] as number[]) {
254
+ if (placed[d] === 1) continue;
255
+ pending[d] = (pending[d] as number) - 1;
256
+ if (pending[d] === 0) activate(d);
257
+ }
258
+ }
259
+ return result;
260
+ }
261
+
262
+ /**
263
+ * Chapter of each hunk (hunks in a requirement-respecting order, e.g. the tour) for `chapters`
264
+ * mode: one chapter per phase present, in replay order; a hunk that requires a hunk of a later
265
+ * chapter moves to that chapter (the earliest chapter not before any requirement). O(H + R).
266
+ */
267
+ export function chapterPlan(
268
+ hunks: readonly { readonly phase: ReplayPhase; readonly requires: readonly number[] }[],
269
+ ): Int32Array {
270
+ const present = PHASE_ORDER.filter((p) => hunks.some((h) => h.phase === p));
271
+ const chapter = new Int32Array(hunks.length);
272
+ for (let i = 0; i < hunks.length; i++) {
273
+ const h = hunks[i] as (typeof hunks)[number];
274
+ let c = present.indexOf(h.phase);
275
+ for (const r of h.requires) {
276
+ // Requirements placed earlier in the order have their chapter already.
277
+ if (r >= 0 && r < i) c = Math.max(c, chapter[r] as number);
278
+ }
279
+ chapter[i] = c;
280
+ }
281
+ return chapter;
282
+ }
283
+
284
+ const KIND_FACTOR: Readonly<Record<HunkKind, number>> = {
285
+ add: 0.85,
286
+ remove: 0.85,
287
+ modify: 0.8,
288
+ relations: 0.95,
289
+ merge: 1.3,
290
+ split: 1.3,
291
+ };
292
+
293
+ /** Times are rounded to µs so results do not depend on last-ulp differences between engines. */
294
+ const roundMs = (ms: number): number => Math.round(ms * 1000) / 1000;
295
+
296
+ /** Play time of one hunk in `hunks` mode: `pace · kindFactor · clamp(√size, 1, 2.2)`. */
297
+ export function hunkDuration(kind: HunkKind, size: number, pace: number): number {
298
+ return roundMs(pace * KIND_FACTOR[kind] * Math.min(2.2, Math.max(1, Math.sqrt(size))));
299
+ }
300
+
301
+ /** Length of a chapter of `n` hunks in `chapters` mode: `pace · clamp(1.2 + log₂ n, 1.2, 4)`. */
302
+ export function chapterDuration(n: number, pace: number): number {
303
+ return roundMs(pace * Math.min(4, Math.max(1.2, 1.2 + Math.log2(Math.max(1, n)))));
304
+ }
305
+
306
+ export interface TimelineHunk {
307
+ readonly kind: HunkKind;
308
+ readonly phase: ReplayPhase;
309
+ readonly size: number;
310
+ readonly bounds: Rect;
311
+ /** Indices (into the same array) of hunks that must play first. */
312
+ readonly requires: readonly number[];
313
+ }
314
+
315
+ function chapterBounds(hunks: readonly TimelineHunk[], members: readonly number[]): Rect {
316
+ const b = new BoundsBuilder();
317
+ for (const h of members) b.addRect((hunks[h] as TimelineHunk).bounds);
318
+ return b.rect();
319
+ }
320
+
321
+ export const timelineMode = (hunkCount: number, chapterThreshold: number): Timeline['mode'] =>
322
+ hunkCount > chapterThreshold ? 'chapters' : 'hunks';
323
+
324
+ /**
325
+ * Replay schedule for hunks already in play order (docs/algorithms.md §10). The order must
326
+ * respect `requires` (the tour does; in `chapters` mode, stable-sort the tour by `chapterPlan`).
327
+ *
328
+ * - `hunks` mode (≤ `chapterThreshold` hunks): one hunk at a time, 160 ms apart, so every hunk
329
+ * starts after its requirements end. Chapters are the consecutive runs of one phase, reported
330
+ * for scrubber grouping.
331
+ * - `chapters` mode: one chapter per `chapterPlan` value, of length
332
+ * `pace · clamp(1.2 + log₂ n, 1.2, 4)`; its hunks play together, staggered in play order over
333
+ * `35% · (n − 1) / n` of the chapter, so a hunk requiring another of its chapter starts later.
334
+ * A chapter's `phase` is the phase it was planned for. Chapters are 160 ms apart.
335
+ */
336
+ export function buildTimeline(
337
+ hunks: readonly TimelineHunk[],
338
+ pace: number,
339
+ chapterThreshold: number,
340
+ ): Timeline {
341
+ const mode = timelineMode(hunks.length, chapterThreshold);
342
+ const windows: TimelineWindow[] = [];
343
+ const chapters: TimelineChapter[] = [];
344
+ const plan = mode === 'chapters' ? chapterPlan(hunks) : null;
345
+ const present = PHASE_ORDER.filter((p) => hunks.some((h) => h.phase === p));
346
+ // Runs of consecutive hunks sharing a phase (hunks mode) or a planned chapter (chapters mode).
347
+ const runs: { key: number; phase: ReplayPhase; members: number[] }[] = [];
348
+ for (let h = 0; h < hunks.length; h++) {
349
+ const hunk = hunks[h] as TimelineHunk;
350
+ const key = plan === null ? PHASE_ORDER.indexOf(hunk.phase) : (plan[h] as number);
351
+ const run = runs[runs.length - 1];
352
+ if (run !== undefined && run.key === key) run.members.push(h);
353
+ else {
354
+ const phase = plan === null ? hunk.phase : (present[key] ?? hunk.phase);
355
+ runs.push({ key, phase, members: [h] });
356
+ }
357
+ }
358
+ let cursor = 0;
359
+ for (let c = 0; c < runs.length; c++) {
360
+ const { phase, members: run } = runs[c] as (typeof runs)[number];
361
+ if (c > 0) cursor = roundMs(cursor + TIMELINE.gap);
362
+ const start = cursor;
363
+ if (mode === 'hunks') {
364
+ for (let k = 0; k < run.length; k++) {
365
+ if (k > 0) cursor = roundMs(cursor + TIMELINE.gap);
366
+ const h = hunks[run[k] as number] as TimelineHunk;
367
+ const end = roundMs(cursor + hunkDuration(h.kind, h.size, pace));
368
+ windows.push({ hunk: run[k] as number, start: cursor, end, chapter: c });
369
+ cursor = end;
370
+ }
371
+ } else {
372
+ const n = run.length;
373
+ const length = chapterDuration(n, pace);
374
+ const span = (TIMELINE.maxStagger * length * (n - 1)) / n;
375
+ const step = n > 1 ? span / (n - 1) : 0;
376
+ const windowLength = length - span;
377
+ for (let k = 0; k < n; k++) {
378
+ const ws = roundMs(start + k * step);
379
+ windows.push({
380
+ hunk: run[k] as number,
381
+ start: ws,
382
+ end: roundMs(ws + windowLength),
383
+ chapter: c,
384
+ });
385
+ }
386
+ cursor = roundMs(start + length);
387
+ }
388
+ chapters.push({ phase, start, end: cursor, hunks: run, bounds: chapterBounds(hunks, run) });
389
+ }
390
+ return { duration: cursor, windows, chapters, mode };
391
+ }