@kgdiff/renderer 0.0.0-stage → 0.1.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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -2
  3. package/dist/atlas.d.ts +55 -0
  4. package/dist/atlas.d.ts.map +1 -0
  5. package/dist/atlas.js +132 -0
  6. package/dist/atlas.js.map +1 -0
  7. package/dist/camera.d.ts +116 -0
  8. package/dist/camera.d.ts.map +1 -0
  9. package/dist/camera.js +368 -0
  10. package/dist/camera.js.map +1 -0
  11. package/dist/color.d.ts +12 -0
  12. package/dist/color.d.ts.map +1 -0
  13. package/dist/color.js +51 -0
  14. package/dist/color.js.map +1 -0
  15. package/dist/ctx.d.ts +57 -0
  16. package/dist/ctx.d.ts.map +1 -0
  17. package/dist/ctx.js +28 -0
  18. package/dist/ctx.js.map +1 -0
  19. package/dist/draw/background.d.ts +33 -0
  20. package/dist/draw/background.d.ts.map +1 -0
  21. package/dist/draw/background.js +105 -0
  22. package/dist/draw/background.js.map +1 -0
  23. package/dist/draw/badges.d.ts +26 -0
  24. package/dist/draw/badges.d.ts.map +1 -0
  25. package/dist/draw/badges.js +82 -0
  26. package/dist/draw/badges.js.map +1 -0
  27. package/dist/draw/collapsed.d.ts +15 -0
  28. package/dist/draw/collapsed.d.ts.map +1 -0
  29. package/dist/draw/collapsed.js +67 -0
  30. package/dist/draw/collapsed.js.map +1 -0
  31. package/dist/draw/edges.d.ts +14 -0
  32. package/dist/draw/edges.d.ts.map +1 -0
  33. package/dist/draw/edges.js +115 -0
  34. package/dist/draw/edges.js.map +1 -0
  35. package/dist/draw/glyphs.d.ts +31 -0
  36. package/dist/draw/glyphs.d.ts.map +1 -0
  37. package/dist/draw/glyphs.js +114 -0
  38. package/dist/draw/glyphs.js.map +1 -0
  39. package/dist/draw/halos.d.ts +19 -0
  40. package/dist/draw/halos.d.ts.map +1 -0
  41. package/dist/draw/halos.js +91 -0
  42. package/dist/draw/halos.js.map +1 -0
  43. package/dist/draw/labels.d.ts +55 -0
  44. package/dist/draw/labels.d.ts.map +1 -0
  45. package/dist/draw/labels.js +300 -0
  46. package/dist/draw/labels.js.map +1 -0
  47. package/dist/draw/link.d.ts +25 -0
  48. package/dist/draw/link.d.ts.map +1 -0
  49. package/dist/draw/link.js +122 -0
  50. package/dist/draw/link.js.map +1 -0
  51. package/dist/draw/nodes.d.ts +13 -0
  52. package/dist/draw/nodes.d.ts.map +1 -0
  53. package/dist/draw/nodes.js +165 -0
  54. package/dist/draw/nodes.js.map +1 -0
  55. package/dist/draw/shapes.d.ts +7 -0
  56. package/dist/draw/shapes.d.ts.map +1 -0
  57. package/dist/draw/shapes.js +26 -0
  58. package/dist/draw/shapes.js.map +1 -0
  59. package/dist/draw/view.d.ts +36 -0
  60. package/dist/draw/view.d.ts.map +1 -0
  61. package/dist/draw/view.js +16 -0
  62. package/dist/draw/view.js.map +1 -0
  63. package/dist/frame-cache.d.ts +103 -0
  64. package/dist/frame-cache.d.ts.map +1 -0
  65. package/dist/frame-cache.js +349 -0
  66. package/dist/frame-cache.js.map +1 -0
  67. package/dist/index.d.ts +162 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +19 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/input.d.ts +81 -0
  72. package/dist/input.d.ts.map +1 -0
  73. package/dist/input.js +329 -0
  74. package/dist/input.js.map +1 -0
  75. package/dist/labels.d.ts +103 -0
  76. package/dist/labels.d.ts.map +1 -0
  77. package/dist/labels.js +203 -0
  78. package/dist/labels.js.map +1 -0
  79. package/dist/math.d.ts +33 -0
  80. package/dist/math.d.ts.map +1 -0
  81. package/dist/math.js +138 -0
  82. package/dist/math.js.map +1 -0
  83. package/dist/minimap.d.ts +56 -0
  84. package/dist/minimap.d.ts.map +1 -0
  85. package/dist/minimap.js +501 -0
  86. package/dist/minimap.js.map +1 -0
  87. package/dist/paint-stats.d.ts +17 -0
  88. package/dist/paint-stats.d.ts.map +1 -0
  89. package/dist/paint-stats.js +23 -0
  90. package/dist/paint-stats.js.map +1 -0
  91. package/dist/paint.d.ts +64 -0
  92. package/dist/paint.d.ts.map +1 -0
  93. package/dist/paint.js +197 -0
  94. package/dist/paint.js.map +1 -0
  95. package/dist/pick.d.ts +26 -0
  96. package/dist/pick.d.ts.map +1 -0
  97. package/dist/pick.js +125 -0
  98. package/dist/pick.js.map +1 -0
  99. package/dist/renderer.d.ts +28 -0
  100. package/dist/renderer.d.ts.map +1 -0
  101. package/dist/renderer.js +470 -0
  102. package/dist/renderer.js.map +1 -0
  103. package/dist/smooth-zoom.d.ts +21 -0
  104. package/dist/smooth-zoom.d.ts.map +1 -0
  105. package/dist/smooth-zoom.js +52 -0
  106. package/dist/smooth-zoom.js.map +1 -0
  107. package/dist/spatial.d.ts +26 -0
  108. package/dist/spatial.d.ts.map +1 -0
  109. package/dist/spatial.js +103 -0
  110. package/dist/spatial.js.map +1 -0
  111. package/dist/text.d.ts +36 -0
  112. package/dist/text.d.ts.map +1 -0
  113. package/dist/text.js +123 -0
  114. package/dist/text.js.map +1 -0
  115. package/package.json +47 -3
  116. package/src/atlas.ts +171 -0
  117. package/src/camera.ts +482 -0
  118. package/src/color.ts +58 -0
  119. package/src/ctx.ts +102 -0
  120. package/src/draw/background.ts +139 -0
  121. package/src/draw/badges.ts +93 -0
  122. package/src/draw/collapsed.ts +86 -0
  123. package/src/draw/edges.ts +135 -0
  124. package/src/draw/glyphs.ts +168 -0
  125. package/src/draw/halos.ts +101 -0
  126. package/src/draw/labels.ts +399 -0
  127. package/src/draw/link.ts +157 -0
  128. package/src/draw/nodes.ts +184 -0
  129. package/src/draw/shapes.ts +36 -0
  130. package/src/draw/view.ts +37 -0
  131. package/src/frame-cache.ts +382 -0
  132. package/src/index.ts +161 -0
  133. package/src/input.ts +370 -0
  134. package/src/labels.ts +269 -0
  135. package/src/math.ts +185 -0
  136. package/src/minimap.ts +545 -0
  137. package/src/paint-stats.ts +38 -0
  138. package/src/paint.ts +235 -0
  139. package/src/pick.ts +173 -0
  140. package/src/renderer.ts +539 -0
  141. package/src/smooth-zoom.ts +65 -0
  142. package/src/spatial.ts +94 -0
  143. package/src/text.ts +130 -0
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Background and dot grid.
3
+ *
4
+ * Dots (1.2 px) sit every 24 world units. When their screen spacing drops below 14.4 px they
5
+ * fade out (gone at 9.6 px, i.e. zoom 0.4) while the 4× coarser level stays, recursively, so the
6
+ * grid never moirés at any zoom. Each level is ONE `fillRect` with a repeating pattern whose
7
+ * tile is rasterized at the exact device-pixel spacing (cost independent of dot count).
8
+ */
9
+ import type { Theme } from '@kgdiff/scene';
10
+ import type { Ctx2D, Surface, SurfaceFactory } from '../ctx.js';
11
+ import { smoothstep, TAU } from '../math.js';
12
+ import type { View } from './view.js';
13
+
14
+ export const GRID_SPACING = 24;
15
+ export const GRID_DOT = 1.2;
16
+ const FADE_LO = 9.6;
17
+ const FADE_HI = 14.4;
18
+
19
+ /** Levels to draw at zoom `k`: [spacing (world), alpha, omit every 4th dot] pairs, coarse first. */
20
+ export function gridLevels(k: number): { spacing: number; alpha: number; nested: boolean }[] {
21
+ if (!(k > 0)) return [];
22
+ let spacing = GRID_SPACING;
23
+ for (let i = 0; i < 12 && spacing * k < FADE_LO; i++) spacing *= 4;
24
+ const a = smoothstep(FADE_LO, FADE_HI, spacing * k);
25
+ if (a >= 0.999) return [{ spacing, alpha: 1, nested: false }];
26
+ return [
27
+ { spacing: spacing * 4, alpha: 1, nested: false },
28
+ { spacing, alpha: a, nested: true },
29
+ ];
30
+ }
31
+
32
+ interface Tile {
33
+ surface: Surface;
34
+ key: string;
35
+ pattern: CanvasPattern | null;
36
+ }
37
+
38
+ /** Pattern tiles per grid level, re-rasterized only when the device spacing changes. */
39
+ export class GridTiles {
40
+ private readonly tiles: (Tile | null)[] = [null, null];
41
+
42
+ constructor(private readonly factory: SurfaceFactory) {}
43
+
44
+ clear(): void {
45
+ this.tiles[0] = null;
46
+ this.tiles[1] = null;
47
+ }
48
+
49
+ /**
50
+ * Pattern for a tile `size` device px wide holding `m × m` dots (the first omitted when
51
+ * `nested`, as the coarser level draws it).
52
+ */
53
+ pattern(
54
+ ctx: Ctx2D,
55
+ slot: number,
56
+ size: number,
57
+ m: number,
58
+ nested: boolean,
59
+ color: string,
60
+ dotR: number,
61
+ ): CanvasPattern | null {
62
+ const key = `${size}|${m}|${nested}|${color}|${dotR}`;
63
+ let tile = this.tiles[slot] ?? null;
64
+ if (tile?.key === key) return tile.pattern;
65
+ if (!tile) {
66
+ const surface = this.factory(size, size);
67
+ if (!surface) return null;
68
+ tile = { surface, key: '', pattern: null };
69
+ this.tiles[slot] = tile;
70
+ }
71
+ const { canvas, ctx: tctx } = tile.surface;
72
+ canvas.width = size;
73
+ canvas.height = size;
74
+ tctx.setTransform(1, 0, 0, 1, 0, 0);
75
+ tctx.clearRect(0, 0, size, size);
76
+ tctx.fillStyle = color;
77
+ tctx.beginPath();
78
+ const step = size / m;
79
+ for (let j = 0; j < m; j++) {
80
+ for (let i = 0; i < m; i++) {
81
+ if (nested && i === 0 && j === 0) continue;
82
+ const cx = (i + 0.5) * step;
83
+ const cy = (j + 0.5) * step;
84
+ tctx.moveTo(cx + dotR, cy);
85
+ tctx.arc(cx, cy, dotR, 0, TAU);
86
+ }
87
+ }
88
+ tctx.fill();
89
+ tile.key = key;
90
+ tile.pattern = ctx.createPattern(canvas, 'repeat');
91
+ return tile.pattern;
92
+ }
93
+ }
94
+
95
+ export function drawBackground(
96
+ ctx: Ctx2D,
97
+ v: View,
98
+ theme: Theme,
99
+ tiles: GridTiles | null,
100
+ spotlight: number,
101
+ ): void {
102
+ ctx.globalAlpha = 1;
103
+ ctx.fillStyle = theme.canvas.background;
104
+ ctx.fillRect(0, 0, v.width, v.height);
105
+ if (!tiles) return;
106
+ const dim = 1 - 0.5 * Math.min(1, Math.max(0, Number.isFinite(spotlight) ? spotlight : 0));
107
+ const levels = gridLevels(v.k);
108
+ const dpr = v.dpr;
109
+ const W = Math.ceil(v.width * dpr);
110
+ const H = Math.ceil(v.height * dpr);
111
+ ctx.save();
112
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
113
+ for (let slot = 0; slot < levels.length; slot++) {
114
+ const level = levels[slot]!;
115
+ const m = level.nested ? 4 : 1;
116
+ const period = m * level.spacing * v.k * dpr; // device px between tile repeats
117
+ const size = Math.max(2, Math.round(period));
118
+ const pattern = tiles.pattern(
119
+ ctx,
120
+ slot,
121
+ size,
122
+ m,
123
+ level.nested,
124
+ theme.canvas.grid,
125
+ (GRID_DOT / 2) * dpr,
126
+ );
127
+ if (!pattern || typeof pattern.setTransform !== 'function') continue;
128
+ const s = period / size;
129
+ // Align the first dot of the tile (at half a step) with world (0, 0).
130
+ const half = (0.5 * period) / m;
131
+ const e = (((v.ox * dpr - half) % period) + period) % period;
132
+ const f = (((v.oy * dpr - half) % period) + period) % period;
133
+ pattern.setTransform({ a: s, b: 0, c: 0, d: s, e, f });
134
+ ctx.globalAlpha = level.alpha * dim;
135
+ ctx.fillStyle = pattern;
136
+ ctx.fillRect(0, 0, W, H);
137
+ }
138
+ ctx.restore();
139
+ }
@@ -0,0 +1,93 @@
1
+ /** Badge pass: compact change summaries (`+2 −1 ~1`, `⊕ 3`) as pills at a node's upper right. */
2
+ import type { FrameVisuals, Theme } from '@kgdiff/scene';
3
+ import type { Ctx2D } from '../ctx.js';
4
+ import type { FrameCache } from '../frame-cache.js';
5
+ import type { CollisionGrid } from '../labels.js';
6
+ import { snap } from '../math.js';
7
+ import type { Fonts, TextMeasurer } from '../text.js';
8
+ import { roundRectPath } from './shapes.js';
9
+ import { NODE_MIN_R, type View } from './view.js';
10
+
11
+ export const BADGE_HEIGHT = 15;
12
+ export const BADGE_PAD_X = 5;
13
+ export const BADGE_FONT_SIZE = 10;
14
+
15
+ /**
16
+ * Badges are laid out before label placement (they are obstacles labels and pills avoid) and
17
+ * drawn after nodes. The pill gets an opaque background first so its translucent tint reads the
18
+ * same over edges and over empty canvas.
19
+ */
20
+ export class BadgePass {
21
+ /** x, y, w, h, node index per laid-out badge. */
22
+ private boxes = new Float64Array(5 * 32);
23
+ private count = 0;
24
+ private font = '';
25
+
26
+ /** Compute visible badge boxes and register them in `obstacles`. */
27
+ layout(
28
+ frame: FrameVisuals,
29
+ cache: FrameCache,
30
+ v: View,
31
+ fonts: Fonts,
32
+ measurer: TextMeasurer,
33
+ obstacles: CollisionGrid,
34
+ ): void {
35
+ this.count = 0;
36
+ if (cache.badges.length === 0) return;
37
+ const { k, ox, oy, dpr } = v;
38
+ const font = fonts.get(BADGE_FONT_SIZE, 600, true);
39
+ this.font = font;
40
+ for (const i of cache.badges) {
41
+ const n = frame.nodes[i]!;
42
+ const badge = n.badge!;
43
+ if (k < badge.minZoom || n.alpha < 0.02) continue;
44
+ // Anchor on the outermost outline (ring included) so the pill never covers it.
45
+ const ring = n.ring;
46
+ const r = Math.max(n.r * k, NODE_MIN_R) + (ring ? ring.gap + ring.width / 2 : 0);
47
+ const w = Math.ceil(measurer.width(font, badge.text)) + 2 * BADGE_PAD_X;
48
+ const h = BADGE_HEIGHT;
49
+ const bx = snap(n.x * k + ox + r * 0.7 - 2, dpr);
50
+ const by = snap(n.y * k + oy - r * 0.7 - h + 2, dpr);
51
+ if (bx + w < v.x0 || bx > v.x1 || by + h < v.y0 || by > v.y1) continue;
52
+ obstacles.insert(bx - 1, by - 1, w + 2, h + 2);
53
+ if (this.boxes.length < (this.count + 1) * 5) {
54
+ const next = new Float64Array(this.boxes.length * 2);
55
+ next.set(this.boxes);
56
+ this.boxes = next;
57
+ }
58
+ const o = this.count++ * 5;
59
+ this.boxes[o] = bx;
60
+ this.boxes[o + 1] = by;
61
+ this.boxes[o + 2] = w;
62
+ this.boxes[o + 3] = h;
63
+ this.boxes[o + 4] = i;
64
+ }
65
+ }
66
+
67
+ /** Draw the badges laid out by `layout`. */
68
+ draw(ctx: Ctx2D, frame: FrameVisuals, theme: Theme): void {
69
+ if (this.count === 0) return;
70
+ ctx.font = this.font;
71
+ ctx.textAlign = 'center';
72
+ ctx.textBaseline = 'middle';
73
+ for (let b = 0; b < this.count; b++) {
74
+ const o = b * 5;
75
+ const bx = this.boxes[o]!;
76
+ const by = this.boxes[o + 1]!;
77
+ const w = this.boxes[o + 2]!;
78
+ const h = this.boxes[o + 3]!;
79
+ const n = frame.nodes[this.boxes[o + 4]!]!;
80
+ const badge = n.badge;
81
+ if (!badge) continue;
82
+ ctx.globalAlpha = n.alpha;
83
+ ctx.beginPath();
84
+ roundRectPath(ctx, bx, by, w, h, h / 2);
85
+ ctx.fillStyle = theme.canvas.background;
86
+ ctx.fill();
87
+ ctx.fillStyle = badge.background;
88
+ ctx.fill();
89
+ ctx.fillStyle = badge.color;
90
+ ctx.fillText(badge.text, bx + w / 2, by + h / 2 + 0.5);
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,86 @@
1
+ /** Collapsed-group pills (“+38 unchanged”): hidden nodes summarized at their centroid. */
2
+ import type { CollapsedGroup, Theme } from '@kgdiff/scene';
3
+ import type { Ctx2D } from '../ctx.js';
4
+ import { crispOffset, snap } from '../math.js';
5
+ import type { Fonts, TextMeasurer } from '../text.js';
6
+ import { roundRectPath } from './shapes.js';
7
+ import type { View } from './view.js';
8
+
9
+ export const PILL_HEIGHT = 20;
10
+ export const PILL_PAD_X = 8;
11
+ export const PILL_FONT_SIZE = 11;
12
+
13
+ const texts = new Map<number, string>();
14
+
15
+ /** Pill text: the scene's localized `label`, else `+N` (cached per count). */
16
+ export function pillText(g: CollapsedGroup): string {
17
+ if (typeof g.label === 'string' && g.label.length > 0) return g.label;
18
+ const count = g.count;
19
+ let t = texts.get(count);
20
+ if (t === undefined) {
21
+ t = `+${count.toLocaleString('en-US')}`;
22
+ if (texts.size > 4096) texts.clear();
23
+ texts.set(count, t);
24
+ }
25
+ return t;
26
+ }
27
+
28
+ /** Screen rectangle of a group's pill: [x, y, w, h] written into `out`. */
29
+ export function pillRect(
30
+ g: CollapsedGroup,
31
+ v: Pick<View, 'k' | 'ox' | 'oy' | 'dpr'>,
32
+ fonts: Fonts,
33
+ measurer: TextMeasurer,
34
+ out: Float64Array,
35
+ ): Float64Array {
36
+ const w = Math.ceil(measurer.width(fonts.get(PILL_FONT_SIZE, 500), pillText(g))) + 2 * PILL_PAD_X;
37
+ out[0] = snap(g.x * v.k + v.ox - w / 2, v.dpr);
38
+ out[1] = snap(g.y * v.k + v.oy - PILL_HEIGHT / 2, v.dpr);
39
+ out[2] = w;
40
+ out[3] = PILL_HEIGHT;
41
+ return out;
42
+ }
43
+
44
+ const rect = new Float64Array(4);
45
+
46
+ /** Draw the pills that won their spot in label placement (`shown[i] === 1`). */
47
+ export function drawCollapsed(
48
+ ctx: Ctx2D,
49
+ groups: readonly CollapsedGroup[],
50
+ shown: Uint8Array,
51
+ v: View,
52
+ theme: Theme,
53
+ fonts: Fonts,
54
+ measurer: TextMeasurer,
55
+ ): void {
56
+ if (groups.length === 0) return;
57
+ const font = fonts.get(PILL_FONT_SIZE, 500);
58
+ const o = crispOffset(1, v.dpr);
59
+ let styled = false;
60
+ for (let i = 0; i < groups.length; i++) {
61
+ const g = groups[i]!;
62
+ if (shown[i] !== 1) continue;
63
+ pillRect(g, v, fonts, measurer, rect);
64
+ const x = rect[0]!;
65
+ const y = rect[1]!;
66
+ const w = rect[2]!;
67
+ const h = rect[3]!;
68
+ if (x + w < v.x0 || x > v.x1 || y + h < v.y0 || y > v.y1) continue;
69
+ if (!styled) {
70
+ ctx.globalAlpha = 1;
71
+ ctx.lineWidth = 1;
72
+ ctx.font = font;
73
+ ctx.textAlign = 'center';
74
+ ctx.textBaseline = 'middle';
75
+ styled = true;
76
+ }
77
+ ctx.beginPath();
78
+ roundRectPath(ctx, x + o, y + o, w - 2 * o, h - 2 * o, h / 2);
79
+ ctx.fillStyle = theme.surface.raised;
80
+ ctx.fill();
81
+ ctx.strokeStyle = theme.surface.borderStrong;
82
+ ctx.stroke();
83
+ ctx.fillStyle = theme.text.secondary;
84
+ ctx.fillText(pillText(g), x + w / 2, y + h / 2 + 0.5);
85
+ }
86
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Edge pass: one path + one stroke per style bucket (and one fill for its arrowheads),
3
+ * buckets in z order. Endpoints are trimmed at node radii; culled against the region.
4
+ */
5
+ import type { FrameVisuals } from '@kgdiff/scene';
6
+ import { type Ctx2D, NO_DASH } from '../ctx.js';
7
+ import type { EdgeBucket, FrameCache } from '../frame-cache.js';
8
+ import { segmentHitsRect } from '../math.js';
9
+ import { LINK_ARROW, LINK_NONE, linkPath, loopCenterY, loopPath, loopRadius } from './link.js';
10
+ import { arrowPath } from './shapes.js';
11
+ import { ARROW_GAP, CULL_MARGIN, NODE_MIN_R, type View } from './view.js';
12
+
13
+ const tip = new Float64Array(4);
14
+ let arrows = new Float64Array(4 * 256);
15
+
16
+ function pushArrow(n: number): void {
17
+ if (arrows.length < (n + 1) * 4) {
18
+ const next = new Float64Array(arrows.length * 2);
19
+ next.set(arrows);
20
+ arrows = next;
21
+ }
22
+ const o = n * 4;
23
+ arrows[o] = tip[0]!;
24
+ arrows[o + 1] = tip[1]!;
25
+ arrows[o + 2] = tip[2]!;
26
+ arrows[o + 3] = tip[3]!;
27
+ }
28
+
29
+ function applyStroke(ctx: Ctx2D, b: EdgeBucket): void {
30
+ ctx.globalAlpha = b.alpha;
31
+ ctx.strokeStyle = b.color;
32
+ ctx.lineWidth = b.width;
33
+ ctx.setLineDash(b.dash ?? NO_DASH);
34
+ ctx.lineDashOffset = b.dash ? b.dashOffset : 0;
35
+ ctx.lineCap = b.dash ? 'butt' : 'round';
36
+ ctx.lineJoin = 'round';
37
+ }
38
+
39
+ /** Whether a link is a self-loop (both endpoints at the same world position). */
40
+ export function isLoop(x1: number, y1: number, x2: number, y2: number): boolean {
41
+ return Math.abs(x2 - x1) + Math.abs(y2 - y1) < 1e-9;
42
+ }
43
+
44
+ /** Arrowhead LOD: shown from `arrowMinZoom` on (a missing / NaN threshold shows it). */
45
+ export function showsArrow(arrow: boolean, minZoom: number, k: number): boolean {
46
+ return arrow && !(k < minZoom);
47
+ }
48
+
49
+ export function drawEdges(ctx: Ctx2D, frame: FrameVisuals, cache: FrameCache, v: View): void {
50
+ const { k, ox, oy } = v;
51
+ const minX = v.x0 - CULL_MARGIN;
52
+ const maxX = v.x1 + CULL_MARGIN;
53
+ const minY = v.y0 - CULL_MARGIN;
54
+ const maxY = v.y1 + CULL_MARGIN;
55
+ const edges = frame.edges;
56
+ for (const b of cache.edgeBuckets) {
57
+ let started = false;
58
+ let nArrows = 0;
59
+ const members = b.members;
60
+ for (let m = 0; m < members.length; m++) {
61
+ const e = edges[members[m]!]!;
62
+ const x1 = e.x1 * k + ox;
63
+ const y1 = e.y1 * k + oy;
64
+ const x2 = e.x2 * k + ox;
65
+ const y2 = e.y2 * k + oy;
66
+ const r1 = Math.max(e.r1 * k, NODE_MIN_R);
67
+ const loop = isLoop(e.x1, e.y1, e.x2, e.y2);
68
+ if (loop) {
69
+ const rho = loopRadius(r1, e.curvature);
70
+ const cy = loopCenterY(y1, r1, rho);
71
+ if (x1 + rho < minX || x1 - rho > maxX || cy + rho < minY || cy - rho > maxY) {
72
+ v.stats.culledEdges++;
73
+ continue;
74
+ }
75
+ } else if (e.curvature === 0) {
76
+ if (!segmentHitsRect(x1, y1, x2, y2, minX, minY, maxX, maxY)) {
77
+ v.stats.culledEdges++;
78
+ continue;
79
+ }
80
+ } else {
81
+ const cx = (x1 + x2) / 2 - (y2 - y1) * e.curvature;
82
+ const cy = (y1 + y2) / 2 + (x2 - x1) * e.curvature;
83
+ if (
84
+ Math.max(x1, x2, cx) < minX ||
85
+ Math.min(x1, x2, cx) > maxX ||
86
+ Math.max(y1, y2, cy) < minY ||
87
+ Math.min(y1, y2, cy) > maxY
88
+ ) {
89
+ v.stats.culledEdges++;
90
+ continue;
91
+ }
92
+ }
93
+ if (!started) {
94
+ applyStroke(ctx, b);
95
+ ctx.beginPath();
96
+ started = true;
97
+ }
98
+ let res: number;
99
+ if (loop) {
100
+ res = loopPath(ctx, x1, y1, r1, e.curvature, e.progress);
101
+ } else {
102
+ const arrow = showsArrow(e.arrow, e.arrowMinZoom, k);
103
+ const r2 = Math.max(e.r2 * k, NODE_MIN_R) + (arrow ? ARROW_GAP : 0);
104
+ res = linkPath(
105
+ ctx,
106
+ x1,
107
+ y1,
108
+ x2,
109
+ y2,
110
+ e.curvature,
111
+ r1,
112
+ r2,
113
+ e.progress,
114
+ e.fromTarget,
115
+ arrow,
116
+ tip,
117
+ );
118
+ if (res === LINK_ARROW) pushArrow(nArrows++);
119
+ }
120
+ if (res !== LINK_NONE) v.stats.edges++;
121
+ }
122
+ if (!started) continue;
123
+ ctx.stroke();
124
+ v.stats.edgeStrokes++;
125
+ if (nArrows > 0) {
126
+ ctx.fillStyle = b.color;
127
+ ctx.beginPath();
128
+ for (let a = 0; a < nArrows; a++) {
129
+ const o = a * 4;
130
+ arrowPath(ctx, arrows[o]!, arrows[o + 1]!, arrows[o + 2]!, arrows[o + 3]!);
131
+ }
132
+ ctx.fill();
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Glyph passes. Lines (merge / split / move / replacement arrows, edge ghosts, dotted
3
+ * suggestions with a `?` mark) are drawn above edges and below nodes, trimmed at `r1` / `r2`
4
+ * like edges; pivots are drawn above nodes so the dot on the shared endpoint stays visible.
5
+ * Edge ghosts (the before form of a retargeted / carried edge) are drawn like edges: the glyph's
6
+ * own dash (`4 3` in overlays, solid while crossfading in replay) and an edge-sized arrowhead,
7
+ * always shown since ghosts only exist for changed edges.
8
+ * Kinds this renderer does not know degrade to a plain line, so new scene kinds never break it.
9
+ */
10
+ import type { FrameVisuals, GlyphVisual, Theme } from '@kgdiff/scene';
11
+ import { type Ctx2D, NO_DASH } from '../ctx.js';
12
+ import type { FrameCache } from '../frame-cache.js';
13
+ import { TAU } from '../math.js';
14
+ import type { Fonts } from '../text.js';
15
+ import { LINK_ARROW, linkMidpoint, linkPath } from './link.js';
16
+ import { arrowPath } from './shapes.js';
17
+ import { ARROW_GAP, CULL_MARGIN, NODE_MIN_R, type View } from './view.js';
18
+
19
+ /** Default suggestion pattern: round dots every 4 px. */
20
+ export const SUGGESTION_DASH: readonly number[] = Object.freeze([0.001, 4]);
21
+ /** Radius (px) of the suggestion `?` mark. */
22
+ export const SUGGESTION_MARK_R = 7;
23
+
24
+ export interface GlyphStyle {
25
+ /** Arrowhead at the end (a glyph's own `arrow` flag, when present, overrides it). */
26
+ readonly arrow: boolean;
27
+ /** Pattern used when the glyph has no `dash`. */
28
+ readonly dash: readonly number[] | null;
29
+ /** `?` mark at the midpoint. */
30
+ readonly mark: boolean;
31
+ }
32
+
33
+ const ARC: GlyphStyle = { arrow: true, dash: null, mark: false };
34
+ const SUGGESTION: GlyphStyle = { arrow: false, dash: SUGGESTION_DASH, mark: true };
35
+ const PLAIN: GlyphStyle = { arrow: false, dash: null, mark: false };
36
+
37
+ /** How a glyph kind is drawn; unknown kinds (newer scenes) are a plain line. */
38
+ export function glyphStyle(kind: string): GlyphStyle {
39
+ switch (kind) {
40
+ case 'merge-arrow':
41
+ case 'split-arrow':
42
+ case 'move-arrow':
43
+ case 'replacement-arrow':
44
+ case 'edge-ghost':
45
+ return ARC;
46
+ case 'suggestion':
47
+ return SUGGESTION;
48
+ default:
49
+ return PLAIN;
50
+ }
51
+ }
52
+
53
+ /** World radius → screen trim (px); 0 when the endpoint is not a node. */
54
+ function trim(r: number, k: number): number {
55
+ return r > 0 && Number.isFinite(r) ? Math.max(r * k, NODE_MIN_R) : 0;
56
+ }
57
+
58
+ const tip = new Float64Array(4);
59
+
60
+ export function drawGlyphArcs(
61
+ ctx: Ctx2D,
62
+ frame: FrameVisuals,
63
+ cache: FrameCache,
64
+ v: View,
65
+ theme: Theme,
66
+ fonts: Fonts,
67
+ ): void {
68
+ const { k, ox, oy } = v;
69
+ const minX = v.x0 - CULL_MARGIN;
70
+ const maxX = v.x1 + CULL_MARGIN;
71
+ const minY = v.y0 - CULL_MARGIN;
72
+ const maxY = v.y1 + CULL_MARGIN;
73
+ for (const i of cache.glyphArcs) {
74
+ const g = frame.glyphs[i]!;
75
+ const x1 = g.x1 * k + ox;
76
+ const y1 = g.y1 * k + oy;
77
+ const x2 = g.x2 * k + ox;
78
+ const y2 = g.y2 * k + oy;
79
+ const c = Number.isFinite(g.curvature) ? g.curvature : 0;
80
+ const cx = (x1 + x2) / 2 - (y2 - y1) * c;
81
+ const cy = (y1 + y2) / 2 + (x2 - x1) * c;
82
+ if (
83
+ Math.max(x1, x2, cx) < minX ||
84
+ Math.min(x1, x2, cx) > maxX ||
85
+ Math.max(y1, y2, cy) < minY ||
86
+ Math.min(y1, y2, cy) > maxY
87
+ ) {
88
+ continue;
89
+ }
90
+ const style = glyphStyle(g.kind);
91
+ const own = (g as GlyphVisual & { readonly arrow?: boolean }).arrow;
92
+ const arrow = typeof own === 'boolean' ? own : style.arrow;
93
+ const t1 = trim(g.r1, k);
94
+ const t2 = trim(g.r2, k);
95
+ const dash = g.dash !== null && g.dash.length > 0 ? g.dash : style.dash;
96
+ ctx.globalAlpha = g.alpha;
97
+ ctx.strokeStyle = g.color;
98
+ ctx.lineWidth = g.width;
99
+ ctx.setLineDash(dash ?? NO_DASH);
100
+ ctx.lineDashOffset = 0;
101
+ ctx.lineCap = dash === null || (dash[0] ?? 0) < 0.5 ? 'round' : 'butt';
102
+ ctx.lineJoin = 'round';
103
+ ctx.beginPath();
104
+ const res = linkPath(
105
+ ctx,
106
+ x1,
107
+ y1,
108
+ x2,
109
+ y2,
110
+ c,
111
+ t1,
112
+ t2 > 0 && arrow ? t2 + ARROW_GAP : t2,
113
+ g.progress,
114
+ false,
115
+ arrow,
116
+ tip,
117
+ );
118
+ ctx.stroke();
119
+ if (res === LINK_ARROW) {
120
+ ctx.fillStyle = g.color;
121
+ ctx.beginPath();
122
+ arrowPath(ctx, tip[0]!, tip[1]!, tip[2]!, tip[3]!);
123
+ ctx.fill();
124
+ }
125
+ if (style.mark && g.progress >= 1 && Math.hypot(x2 - x1, y2 - y1) > 6 * SUGGESTION_MARK_R) {
126
+ const [mx, my] = linkMidpoint(x1, y1, x2, y2, c);
127
+ ctx.setLineDash(NO_DASH);
128
+ ctx.beginPath();
129
+ ctx.arc(mx, my, SUGGESTION_MARK_R, 0, TAU);
130
+ ctx.fillStyle = theme.canvas.background;
131
+ ctx.fill();
132
+ ctx.lineWidth = 1.25;
133
+ ctx.stroke();
134
+ ctx.fillStyle = g.color;
135
+ ctx.font = fonts.get(10, 700);
136
+ ctx.textAlign = 'center';
137
+ ctx.textBaseline = 'middle';
138
+ ctx.fillText('?', mx, my + 0.5);
139
+ }
140
+ }
141
+ }
142
+
143
+ export function drawPivots(
144
+ ctx: Ctx2D,
145
+ frame: FrameVisuals,
146
+ cache: FrameCache,
147
+ v: View,
148
+ theme: Theme,
149
+ ): void {
150
+ const { k, ox, oy } = v;
151
+ for (const i of cache.pivots) {
152
+ const g = frame.glyphs[i]!;
153
+ const x = g.x1 * k + ox;
154
+ const y = g.y1 * k + oy;
155
+ // A dot of diameter `width` px, separated from what is under it by a background outline.
156
+ const r = Math.max(1.5, g.width / 2);
157
+ if (x + r < v.x0 || x - r > v.x1 || y + r < v.y0 || y - r > v.y1) continue;
158
+ ctx.globalAlpha = g.alpha;
159
+ ctx.fillStyle = theme.canvas.background;
160
+ ctx.beginPath();
161
+ ctx.arc(x, y, r + 1.5, 0, TAU);
162
+ ctx.fill();
163
+ ctx.fillStyle = g.color;
164
+ ctx.beginPath();
165
+ ctx.arc(x, y, r, 0, TAU);
166
+ ctx.fill();
167
+ }
168
+ }