@kgdiff/renderer 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 (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
package/src/camera.ts ADDED
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Camera: world ↔ screen transforms, fitting, and the motions that move it (van Wijk–Nuij
3
+ * flights, cursor-anchored smooth wheel zoom, inertial glide).
4
+ *
5
+ * The camera never schedules frames itself: it reports changes through `CameraHost.invalidate`
6
+ * and is advanced by the renderer's single animation-frame loop (`tick`).
7
+ */
8
+ import type { Point, Rect } from '@kgdiff/core';
9
+ import type { Camera, CameraState, CameraTransitionOptions } from './index.js';
10
+ import { clamp, easeInOutCubic } from './math.js';
11
+ import { smoothZoom, type ZoomPath } from './smooth-zoom.js';
12
+
13
+ export interface Size {
14
+ readonly width: number;
15
+ readonly height: number;
16
+ }
17
+
18
+ export interface Insets {
19
+ readonly top: number;
20
+ readonly right: number;
21
+ readonly bottom: number;
22
+ readonly left: number;
23
+ }
24
+
25
+ export const NO_INSETS: Insets = Object.freeze({ top: 0, right: 0, bottom: 0, left: 0 });
26
+
27
+ /** Default fit padding (px). */
28
+ export const DEFAULT_PADDING = 48;
29
+ /** `flyTo` / `fit` never zoom in beyond this unless `maxZoom` says otherwise. */
30
+ export const DEFAULT_FLY_MAX_ZOOM = 2.5;
31
+ /** van Wijk–Nuij ρ (docs/visual-language.md §Replay). */
32
+ export const FLY_RHO = Math.SQRT2;
33
+ /** Time constant (ms) of the exponential approach used by animated wheel / button zoom. */
34
+ export const ZOOM_TAU = 80;
35
+ /** Time constant (ms) of the inertial glide after a drag. */
36
+ export const GLIDE_TAU = 325;
37
+ /** Glide stops below this speed (px/ms). */
38
+ export const GLIDE_MIN_SPEED = 0.02;
39
+
40
+ // ─── Pure math ───────────────────────────────────────────────────────────────
41
+
42
+ export function worldToScreen(s: CameraState, size: Size, p: Point): Point {
43
+ return { x: (p.x - s.x) * s.k + size.width / 2, y: (p.y - s.y) * s.k + size.height / 2 };
44
+ }
45
+
46
+ export function screenToWorld(s: CameraState, size: Size, p: Point): Point {
47
+ return { x: (p.x - size.width / 2) / s.k + s.x, y: (p.y - size.height / 2) / s.k + s.y };
48
+ }
49
+
50
+ export function viewportRect(s: CameraState, size: Size): Rect {
51
+ const w = size.width / s.k;
52
+ const h = size.height / s.k;
53
+ return { x: s.x - w / 2, y: s.y - h / 2, width: w, height: h };
54
+ }
55
+
56
+ /** The state at zoom `k` that keeps the world point under screen point (sx, sy) in place. */
57
+ export function zoomAround(
58
+ s: CameraState,
59
+ size: Size,
60
+ k: number,
61
+ sx: number,
62
+ sy: number,
63
+ ): CameraState {
64
+ const wx = (sx - size.width / 2) / s.k + s.x;
65
+ const wy = (sy - size.height / 2) / s.k + s.y;
66
+ return { x: wx - (sx - size.width / 2) / k, y: wy - (sy - size.height / 2) / k, k };
67
+ }
68
+
69
+ export function resolvePadding(padding: CameraTransitionOptions['padding']): Insets {
70
+ if (padding === undefined) {
71
+ return {
72
+ top: DEFAULT_PADDING,
73
+ right: DEFAULT_PADDING,
74
+ bottom: DEFAULT_PADDING,
75
+ left: DEFAULT_PADDING,
76
+ };
77
+ }
78
+ if (typeof padding === 'number') {
79
+ const p = Number.isFinite(padding) ? Math.max(0, padding) : DEFAULT_PADDING;
80
+ return { top: p, right: p, bottom: p, left: p };
81
+ }
82
+ return padding;
83
+ }
84
+
85
+ /**
86
+ * Camera state that shows `bounds` inside the viewport minus `safe` (floating chrome) and
87
+ * `padding`, centred in that free region. Degenerate bounds (a single point) zoom to `maxK`.
88
+ * When insets leave no room they are dropped (safe area first, then padding).
89
+ */
90
+ export function fitState(
91
+ bounds: Rect,
92
+ size: Size,
93
+ padding: Insets,
94
+ safe: Insets,
95
+ minK: number,
96
+ maxK: number,
97
+ ): CameraState {
98
+ const attempts: readonly (readonly [Insets, Insets])[] = [
99
+ [safe, padding],
100
+ [NO_INSETS, padding],
101
+ [NO_INSETS, NO_INSETS],
102
+ ];
103
+ let left = 0;
104
+ let top = 0;
105
+ let availW = size.width;
106
+ let availH = size.height;
107
+ for (const [s, p] of attempts) {
108
+ left = s.left + p.left;
109
+ top = s.top + p.top;
110
+ availW = size.width - left - s.right - p.right;
111
+ availH = size.height - top - s.bottom - p.bottom;
112
+ if (availW >= 8 && availH >= 8) break;
113
+ }
114
+ availW = Math.max(availW, 1);
115
+ availH = Math.max(availH, 1);
116
+ const bw = Math.max(bounds.width, 0);
117
+ const bh = Math.max(bounds.height, 0);
118
+ let k = Math.min(
119
+ bw > 0 ? availW / bw : Number.POSITIVE_INFINITY,
120
+ bh > 0 ? availH / bh : Number.POSITIVE_INFINITY,
121
+ );
122
+ if (!Number.isFinite(k)) k = maxK;
123
+ k = clamp(k, minK, maxK);
124
+ const cx = bounds.x + bw / 2;
125
+ const cy = bounds.y + bh / 2;
126
+ const sx = left + availW / 2;
127
+ const sy = top + availH / 2;
128
+ return { x: cx - (sx - size.width / 2) / k, y: cy - (sy - size.height / 2) / k, k };
129
+ }
130
+
131
+ /** Flight duration: `clamp(S · 620 ms, 350, 1100)`. */
132
+ export function flyDuration(S: number): number {
133
+ return clamp(Math.abs(S) * 620, 350, 1100);
134
+ }
135
+
136
+ /** Exponential velocity decay over `dt` ms. */
137
+ export function decay(v: number, dt: number, tau: number = GLIDE_TAU): number {
138
+ return v * Math.exp(-dt / tau);
139
+ }
140
+
141
+ // ─── Controller ──────────────────────────────────────────────────────────────
142
+
143
+ /** Who moved the camera: input (pointer, wheel, pinch, inertia, minimap) or an API call. */
144
+ export type CameraSource = 'user' | 'program';
145
+
146
+ export interface CameraHost {
147
+ /** Viewport size in css px. */
148
+ size(): Size;
149
+ safeArea(): Insets;
150
+ /** Bounds of everything drawn, or null when the frame is empty. */
151
+ contentBounds(): Rect | null;
152
+ reducedMotion(): boolean;
153
+ /** The state changed or a motion started: schedule a frame. */
154
+ invalidate(): void;
155
+ }
156
+
157
+ type Motion =
158
+ | {
159
+ readonly type: 'fly';
160
+ readonly path: ZoomPath;
161
+ /** Viewport extent used to convert zoom ↔ visible width. */
162
+ readonly ref: number;
163
+ readonly duration: number;
164
+ start: number;
165
+ readonly target: CameraState;
166
+ readonly resolve: () => void;
167
+ }
168
+ | {
169
+ readonly type: 'zoom';
170
+ source: CameraSource;
171
+ ax: number;
172
+ ay: number;
173
+ wx: number;
174
+ wy: number;
175
+ kTarget: number;
176
+ last: number;
177
+ }
178
+ | { readonly type: 'glide'; vx: number; vy: number; last: number };
179
+
180
+ interface PendingFly {
181
+ readonly bounds: Rect;
182
+ readonly options: CameraTransitionOptions;
183
+ readonly resolve: () => void;
184
+ }
185
+
186
+ function validRect(r: Rect): boolean {
187
+ return (
188
+ Number.isFinite(r.x) &&
189
+ Number.isFinite(r.y) &&
190
+ Number.isFinite(r.width) &&
191
+ Number.isFinite(r.height) &&
192
+ r.width >= 0 &&
193
+ r.height >= 0
194
+ );
195
+ }
196
+
197
+ /** The renderer's camera. Public methods implement `Camera`; the rest is driven by input. */
198
+ export class CameraController implements Camera {
199
+ x = 0;
200
+ y = 0;
201
+ k = 1;
202
+ /** Set by any explicit camera call or user input; disables the automatic first fit. */
203
+ touched = false;
204
+ private motion: Motion | null = null;
205
+ private pending: PendingFly | null = null;
206
+ /** A user-driven change happened since the last `takeSource`. */
207
+ private userChanged = false;
208
+
209
+ constructor(
210
+ private readonly host: CameraHost,
211
+ readonly minK: number,
212
+ readonly maxK: number,
213
+ ) {}
214
+
215
+ get(): CameraState {
216
+ return { x: this.x, y: this.y, k: this.k };
217
+ }
218
+
219
+ set(state: CameraState, source: CameraSource = 'program'): void {
220
+ this.touched = true;
221
+ this.stop();
222
+ this.apply(state.x, state.y, state.k, source);
223
+ }
224
+
225
+ flyTo(bounds: Rect, options: CameraTransitionOptions = {}): Promise<void> {
226
+ this.touched = true;
227
+ this.stop();
228
+ if (!validRect(bounds)) return Promise.resolve();
229
+ const size = this.host.size();
230
+ if (size.width <= 0 || size.height <= 0) {
231
+ // Not laid out yet: apply (as a jump) on the first real size.
232
+ return new Promise((resolve) => {
233
+ this.pending = { bounds, options, resolve };
234
+ });
235
+ }
236
+ const target = this.targetFor(bounds, options, size);
237
+ const animate = (options.animate ?? true) && !this.host.reducedMotion();
238
+ const ref = Math.max(size.width, size.height);
239
+ const path = smoothZoom(
240
+ { x: this.x, y: this.y, w: ref / this.k },
241
+ { x: target.x, y: target.y, w: ref / target.k },
242
+ FLY_RHO,
243
+ );
244
+ const duration = options.duration ?? flyDuration(path.S);
245
+ const still =
246
+ Math.abs(target.k / this.k - 1) < 1e-6 &&
247
+ Math.hypot(target.x - this.x, target.y - this.y) * this.k < 0.5;
248
+ if (!animate || !(duration > 0) || still) {
249
+ this.apply(target.x, target.y, target.k, 'program');
250
+ return Promise.resolve();
251
+ }
252
+ return new Promise((resolve) => {
253
+ this.motion = { type: 'fly', path, ref, duration, start: -1, target, resolve };
254
+ this.host.invalidate();
255
+ });
256
+ }
257
+
258
+ fit(bounds?: Rect, options?: CameraTransitionOptions): Promise<void> {
259
+ const b = bounds ?? this.host.contentBounds();
260
+ if (!b) {
261
+ this.touched = true;
262
+ this.stop();
263
+ return Promise.resolve();
264
+ }
265
+ return this.flyTo(b, options);
266
+ }
267
+
268
+ zoomBy(factor: number, around?: Point, options?: { readonly animate?: boolean }): void {
269
+ this.touched = true;
270
+ if (!(factor > 0) || !Number.isFinite(factor)) return;
271
+ const size = this.host.size();
272
+ const ax = around?.x ?? size.width / 2;
273
+ const ay = around?.y ?? size.height / 2;
274
+ const animate = (options?.animate ?? true) && !this.host.reducedMotion();
275
+ if (animate) this.zoomSmooth(factor, ax, ay, 'program');
276
+ else {
277
+ this.stop();
278
+ this.zoomNow(factor, ax, ay, 'program');
279
+ }
280
+ }
281
+
282
+ panBy(dx: number, dy: number): void {
283
+ this.touched = true;
284
+ this.stop();
285
+ this.panNow(dx, dy, 'program');
286
+ }
287
+
288
+ worldToScreen(p: Point): Point {
289
+ return worldToScreen(this, this.host.size(), p);
290
+ }
291
+
292
+ screenToWorld(p: Point): Point {
293
+ return screenToWorld(this, this.host.size(), p);
294
+ }
295
+
296
+ viewport(): Rect {
297
+ return viewportRect(this, this.host.size());
298
+ }
299
+
300
+ // ─── Internal API (renderer, input) ───────────────────────────────────────
301
+
302
+ /** Whether a motion is running (the frame loop must keep ticking). */
303
+ get animating(): boolean {
304
+ return this.motion !== null;
305
+ }
306
+
307
+ /** User input started: stop every motion (resolving a pending flight). */
308
+ interrupt(): void {
309
+ this.touched = true;
310
+ this.stop();
311
+ }
312
+
313
+ /** Jump without marking the camera as touched (automatic first fit). */
314
+ jump(state: CameraState): void {
315
+ this.stop();
316
+ this.apply(state.x, state.y, state.k, 'program');
317
+ }
318
+
319
+ /** The state `fit` would end in (no motion). */
320
+ targetFor(bounds: Rect, options: CameraTransitionOptions, size: Size): CameraState {
321
+ const maxK = Math.min(this.maxK, options.maxZoom ?? DEFAULT_FLY_MAX_ZOOM);
322
+ return fitState(
323
+ bounds,
324
+ size,
325
+ resolvePadding(options.padding),
326
+ this.host.safeArea(),
327
+ this.minK,
328
+ Math.max(this.minK, maxK),
329
+ );
330
+ }
331
+
332
+ /** Move the content by (dx, dy) screen px immediately (input unless told otherwise). */
333
+ panNow(dx: number, dy: number, source: CameraSource = 'user'): void {
334
+ if (!Number.isFinite(dx) || !Number.isFinite(dy)) return;
335
+ this.apply(this.x - dx / this.k, this.y - dy / this.k, this.k, source);
336
+ }
337
+
338
+ /** Zoom by `factor` around screen point (ax, ay) immediately (pinch). */
339
+ zoomNow(factor: number, ax: number, ay: number, source: CameraSource = 'user'): void {
340
+ if (!(factor > 0) || !Number.isFinite(factor)) return;
341
+ if (this.motion?.type === 'zoom') this.motion = null;
342
+ const k = clamp(this.k * factor, this.minK, this.maxK);
343
+ const s = zoomAround(this, this.host.size(), k, ax, ay);
344
+ this.apply(s.x, s.y, s.k, source);
345
+ }
346
+
347
+ /** Animated zoom toward `factor ×` the current target, anchored at (ax, ay) (wheel notches). */
348
+ zoomSmooth(factor: number, ax: number, ay: number, source: CameraSource = 'user'): void {
349
+ if (!(factor > 0) || !Number.isFinite(factor)) return;
350
+ const size = this.host.size();
351
+ const wx = (ax - size.width / 2) / this.k + this.x;
352
+ const wy = (ay - size.height / 2) / this.k + this.y;
353
+ const m = this.motion;
354
+ if (m?.type === 'zoom') {
355
+ m.kTarget = clamp(m.kTarget * factor, this.minK, this.maxK);
356
+ m.source = source;
357
+ m.ax = ax;
358
+ m.ay = ay;
359
+ m.wx = wx;
360
+ m.wy = wy;
361
+ } else {
362
+ this.stop();
363
+ this.motion = {
364
+ type: 'zoom',
365
+ source,
366
+ ax,
367
+ ay,
368
+ wx,
369
+ wy,
370
+ kTarget: clamp(this.k * factor, this.minK, this.maxK),
371
+ last: -1,
372
+ };
373
+ }
374
+ this.host.invalidate();
375
+ }
376
+
377
+ /** Inertial glide with initial velocity (px/ms, content direction). */
378
+ glide(vx: number, vy: number): void {
379
+ this.stop();
380
+ if (!(Math.hypot(vx, vy) > GLIDE_MIN_SPEED)) return;
381
+ this.motion = { type: 'glide', vx, vy, last: -1 };
382
+ this.host.invalidate();
383
+ }
384
+
385
+ /** The viewport size became valid: run a flight requested before layout (as a jump). */
386
+ resized(): void {
387
+ const p = this.pending;
388
+ if (!p) return;
389
+ const size = this.host.size();
390
+ if (size.width <= 0 || size.height <= 0) return;
391
+ this.pending = null;
392
+ const t = this.targetFor(p.bounds, p.options, size);
393
+ this.apply(t.x, t.y, t.k, 'program');
394
+ p.resolve();
395
+ }
396
+
397
+ /** Advance the running motion to time `now` (ms). Returns whether it is still running. */
398
+ tick(now: number): boolean {
399
+ const m = this.motion;
400
+ if (!m) return false;
401
+ switch (m.type) {
402
+ case 'fly': {
403
+ if (m.start < 0) m.start = now;
404
+ const t = clamp((now - m.start) / m.duration, 0, 1);
405
+ if (t >= 1) {
406
+ this.motion = null;
407
+ this.apply(m.target.x, m.target.y, m.target.k, 'program');
408
+ m.resolve();
409
+ return false;
410
+ }
411
+ const v = m.path.at(easeInOutCubic(t));
412
+ this.apply(v.x, v.y, m.ref / v.w, 'program');
413
+ return true;
414
+ }
415
+ case 'zoom': {
416
+ const dt = m.last < 0 ? 16 : clamp(now - m.last, 0, 64);
417
+ m.last = now;
418
+ const lk = Math.log(this.k);
419
+ const lt = Math.log(m.kTarget);
420
+ const done = Math.abs(lt - lk) < 1e-3;
421
+ const k = done ? m.kTarget : Math.exp(lk + (lt - lk) * (1 - Math.exp(-dt / ZOOM_TAU)));
422
+ const size = this.host.size();
423
+ const kk = clamp(k, this.minK, this.maxK);
424
+ this.apply(
425
+ m.wx - (m.ax - size.width / 2) / kk,
426
+ m.wy - (m.ay - size.height / 2) / kk,
427
+ kk,
428
+ m.source,
429
+ );
430
+ if (done) this.motion = null;
431
+ return !done;
432
+ }
433
+ case 'glide': {
434
+ const dt = m.last < 0 ? 16 : clamp(now - m.last, 0, 64);
435
+ m.last = now;
436
+ this.panNow(m.vx * dt, m.vy * dt, 'user');
437
+ m.vx = decay(m.vx, dt);
438
+ m.vy = decay(m.vy, dt);
439
+ if (Math.hypot(m.vx, m.vy) < GLIDE_MIN_SPEED) {
440
+ this.motion = null;
441
+ return false;
442
+ }
443
+ return true;
444
+ }
445
+ default: {
446
+ const never: never = m;
447
+ return never;
448
+ }
449
+ }
450
+ }
451
+
452
+ /** Stop the running motion and any pending flight, resolving their promises. */
453
+ stop(): void {
454
+ const m = this.motion;
455
+ this.motion = null;
456
+ if (m?.type === 'fly') m.resolve();
457
+ const p = this.pending;
458
+ this.pending = null;
459
+ p?.resolve();
460
+ }
461
+
462
+ /**
463
+ * Source of the changes since the last call: 'user' if any of them came from input. The
464
+ * renderer reads it once per `camera` event (events are coalesced per frame).
465
+ */
466
+ takeSource(): CameraSource {
467
+ const source = this.userChanged ? 'user' : 'program';
468
+ this.userChanged = false;
469
+ return source;
470
+ }
471
+
472
+ private apply(x: number, y: number, k: number, source: CameraSource): void {
473
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(k) || k <= 0) return;
474
+ const kk = clamp(k, this.minK, this.maxK);
475
+ if (x === this.x && y === this.y && kk === this.k) return;
476
+ this.x = x;
477
+ this.y = y;
478
+ this.k = kk;
479
+ if (source === 'user') this.userChanged = true;
480
+ this.host.invalidate();
481
+ }
482
+ }
package/src/color.ts ADDED
@@ -0,0 +1,58 @@
1
+ /** Minimal CSS color parsing for alpha ramps (halo sprites). Hex and rgb()/rgba() only. */
2
+ import { clamp } from './math.js';
3
+
4
+ export interface Rgba {
5
+ readonly r: number;
6
+ readonly g: number;
7
+ readonly b: number;
8
+ /** [0, 1] */
9
+ readonly a: number;
10
+ }
11
+
12
+ const cache = new Map<string, Rgba | null>();
13
+
14
+ /** Parse `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()` and `rgba()`; null for anything else. */
15
+ export function parseColor(input: string): Rgba | null {
16
+ const hit = cache.get(input);
17
+ if (hit !== undefined) return hit;
18
+ const parsed = parse(input.trim().toLowerCase());
19
+ if (cache.size > 512) cache.clear();
20
+ cache.set(input, parsed);
21
+ return parsed;
22
+ }
23
+
24
+ function parse(s: string): Rgba | null {
25
+ if (s.startsWith('#')) {
26
+ const hex = s.slice(1);
27
+ if (!/^[0-9a-f]+$/.test(hex)) return null;
28
+ if (hex.length === 3 || hex.length === 4) {
29
+ const v = (i: number): number => Number.parseInt(hex.charAt(i) + hex.charAt(i), 16);
30
+ return { r: v(0), g: v(1), b: v(2), a: hex.length === 4 ? v(3) / 255 : 1 };
31
+ }
32
+ if (hex.length === 6 || hex.length === 8) {
33
+ const v = (i: number): number => Number.parseInt(hex.slice(i, i + 2), 16);
34
+ return { r: v(0), g: v(2), b: v(4), a: hex.length === 8 ? v(6) / 255 : 1 };
35
+ }
36
+ return null;
37
+ }
38
+ const m = /^rgba?\(([^)]*)\)$/.exec(s);
39
+ if (!m) return null;
40
+ const parts = (m[1] ?? '').split(/[\s,/]+/).filter((p) => p.length > 0);
41
+ if (parts.length < 3 || parts.length > 4) return null;
42
+ const channel = (p: string): number =>
43
+ p.endsWith('%') ? Number.parseFloat(p) * 2.55 : Number.parseFloat(p);
44
+ const alpha = (p: string | undefined): number =>
45
+ p === undefined ? 1 : p.endsWith('%') ? Number.parseFloat(p) / 100 : Number.parseFloat(p);
46
+ const r = channel(parts[0] ?? '');
47
+ const g = channel(parts[1] ?? '');
48
+ const b = channel(parts[2] ?? '');
49
+ const a = alpha(parts[3]);
50
+ if (![r, g, b, a].every(Number.isFinite)) return null;
51
+ return { r: clamp(r, 0, 255), g: clamp(g, 0, 255), b: clamp(b, 0, 255), a: clamp(a, 0, 1) };
52
+ }
53
+
54
+ /** `rgba()` string of `color` with its alpha multiplied by `alpha`. */
55
+ export function rgbaString(color: Rgba, alpha: number): string {
56
+ const a = Math.round(clamp(color.a * alpha, 0, 1) * 1000) / 1000;
57
+ return `rgba(${Math.round(color.r)}, ${Math.round(color.g)}, ${Math.round(color.b)}, ${a})`;
58
+ }
package/src/ctx.ts ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The slice of `CanvasRenderingContext2D` the renderer draws through.
3
+ *
4
+ * Every pass talks to this interface only, so drawing is testable with a recording mock
5
+ * (happy-dom has no 2D context) and works unchanged on `OffscreenCanvasRenderingContext2D`.
6
+ */
7
+ export interface Ctx2D {
8
+ globalAlpha: number;
9
+ fillStyle: string | CanvasGradient | CanvasPattern;
10
+ strokeStyle: string | CanvasGradient | CanvasPattern;
11
+ lineWidth: number;
12
+ lineCap: CanvasLineCap;
13
+ lineJoin: CanvasLineJoin;
14
+ miterLimit: number;
15
+ lineDashOffset: number;
16
+ font: string;
17
+ textAlign: CanvasTextAlign;
18
+ textBaseline: CanvasTextBaseline;
19
+ imageSmoothingEnabled: boolean;
20
+ save(): void;
21
+ restore(): void;
22
+ setTransform(a: number, b: number, c: number, d: number, e: number, f: number): void;
23
+ setLineDash(segments: readonly number[]): void;
24
+ beginPath(): void;
25
+ closePath(): void;
26
+ moveTo(x: number, y: number): void;
27
+ lineTo(x: number, y: number): void;
28
+ quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void;
29
+ arc(
30
+ x: number,
31
+ y: number,
32
+ radius: number,
33
+ startAngle: number,
34
+ endAngle: number,
35
+ counterclockwise?: boolean,
36
+ ): void;
37
+ rect(x: number, y: number, w: number, h: number): void;
38
+ fill(): void;
39
+ stroke(): void;
40
+ clip(): void;
41
+ fillRect(x: number, y: number, w: number, h: number): void;
42
+ clearRect(x: number, y: number, w: number, h: number): void;
43
+ fillText(text: string, x: number, y: number): void;
44
+ strokeText(text: string, x: number, y: number): void;
45
+ measureText(text: string): { readonly width: number };
46
+ drawImage(image: CanvasImageSource, dx: number, dy: number, dw: number, dh: number): void;
47
+ drawImage(
48
+ image: CanvasImageSource,
49
+ sx: number,
50
+ sy: number,
51
+ sw: number,
52
+ sh: number,
53
+ dx: number,
54
+ dy: number,
55
+ dw: number,
56
+ dh: number,
57
+ ): void;
58
+ createRadialGradient(
59
+ x0: number,
60
+ y0: number,
61
+ r0: number,
62
+ x1: number,
63
+ y1: number,
64
+ r1: number,
65
+ ): CanvasGradient;
66
+ createPattern(image: CanvasImageSource, repetition: string | null): CanvasPattern | null;
67
+ }
68
+
69
+ /** An offscreen drawing surface (sprites, label atlas, grid tiles, exports). */
70
+ export interface Surface {
71
+ readonly canvas: HTMLCanvasElement | OffscreenCanvas;
72
+ readonly ctx: Ctx2D;
73
+ }
74
+
75
+ /** Creates offscreen surfaces; returns null where 2D canvases are unavailable. */
76
+ export type SurfaceFactory = (width: number, height: number) => Surface | null;
77
+
78
+ /** OffscreenCanvas when it works, else a detached `<canvas>`, else null. */
79
+ export const domSurfaceFactory: SurfaceFactory = (width, height) => {
80
+ const w = Math.max(1, Math.ceil(width));
81
+ const h = Math.max(1, Math.ceil(height));
82
+ if (typeof OffscreenCanvas === 'function') {
83
+ try {
84
+ const canvas = new OffscreenCanvas(w, h);
85
+ const ctx = canvas.getContext('2d');
86
+ if (ctx) return { canvas, ctx };
87
+ } catch {
88
+ // Fall through to a DOM canvas.
89
+ }
90
+ }
91
+ if (typeof document !== 'undefined') {
92
+ const canvas = document.createElement('canvas');
93
+ canvas.width = w;
94
+ canvas.height = h;
95
+ const ctx = canvas.getContext('2d');
96
+ if (ctx) return { canvas, ctx };
97
+ }
98
+ return null;
99
+ };
100
+
101
+ /** Shared empty dash pattern (solid line) so passes never allocate one. */
102
+ export const NO_DASH: readonly number[] = Object.freeze([]);