virtual-tree-canvas 0.3.9 → 0.5.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 (36) hide show
  1. package/README.md +173 -225
  2. package/docs/inspector-demo.png +0 -0
  3. package/docs/tree-demo.png +0 -0
  4. package/package.json +2 -1
  5. package/src/assets/icons/air.svg +1 -0
  6. package/src/assets/icons/aircraft.svg +1 -0
  7. package/src/assets/icons/bus.svg +1 -0
  8. package/src/assets/icons/control.svg +1 -0
  9. package/src/assets/icons/damage.svg +1 -0
  10. package/src/assets/icons/error.svg +1 -0
  11. package/src/assets/icons/folder.svg +1 -0
  12. package/src/assets/icons/ground.svg +1 -0
  13. package/src/assets/icons/inspector-array.svg +1 -0
  14. package/src/assets/icons/inspector-object.svg +1 -0
  15. package/src/assets/icons/inspector-value.svg +1 -0
  16. package/src/assets/icons/munition.svg +1 -0
  17. package/src/assets/icons/placeholder.svg +1 -0
  18. package/src/assets/icons/point.svg +1 -0
  19. package/src/assets/icons/radar.svg +1 -0
  20. package/src/assets/icons/situation.svg +1 -0
  21. package/src/assets/icons/space.svg +1 -0
  22. package/src/assets/icons/subsurface.svg +1 -0
  23. package/src/assets/icons/surface.svg +1 -0
  24. package/src/assets/icons/task.svg +1 -0
  25. package/src/assets/icons/track.svg +1 -0
  26. package/src/assets/icons/warning.svg +1 -0
  27. package/src/assets/icons.js +25 -0
  28. package/src/core/icon-registry.js +173 -495
  29. package/src/core/theme-manager.js +1 -0
  30. package/src/core/tree-worker-client.js +17 -0
  31. package/src/index.d.ts +17 -0
  32. package/src/inspector/cell-editor-manager.js +23 -18
  33. package/src/inspector/editor-resolver.js +6 -1
  34. package/src/inspector/numeric-layout.js +16 -0
  35. package/src/renderers/tree-row-renderer.js +56 -4
  36. package/src/tree-view-controller.js +13 -5
@@ -1,550 +1,228 @@
1
+ import { builtinIconUrls } from '../assets/icons.js';
2
+
3
+ /**
4
+ * Icon source registry for the Canvas2D tree renderer.
5
+ *
6
+ * SVG icons remain editable files, but are decoded to an ImageBitmap (or a
7
+ * small canvas fallback) only once for each size, device-pixel-ratio and
8
+ * colour combination. The render hot path is therefore a single drawImage.
9
+ */
1
10
  export class IconRegistry {
2
- constructor() {
11
+ constructor({ pixelRatio } = {}) {
3
12
  this.icons = new Map();
4
- this.loading = new Map();
13
+ this.listeners = new Set();
14
+ this.pixelRatio = pixelRatio ?? devicePixelRatio();
5
15
  this.#registerBuiltIns();
6
16
  }
7
17
 
18
+ /** Register an SVG string/URL, an image URL, ImageBitmap, or legacy Canvas draw function. */
8
19
  register(name, icon) {
9
20
  if (!name) throw new Error('Icon name is required');
10
21
  if (this.icons.has(name)) return this.icons.get(name);
22
+
11
23
  if (typeof icon === 'function') {
12
- this.icons.set(name, { kind: 'vector', draw: icon });
13
- return this.icons.get(name);
24
+ const entry = { kind: 'vector', draw: icon };
25
+ this.icons.set(name, entry);
26
+ return entry;
14
27
  }
28
+
15
29
  if (typeof icon === 'string') {
16
- if (icon.trim().startsWith('<svg')) return this.#registerSvgString(name, icon);
17
- return this.#registerUrl(name, icon);
30
+ const source = icon.trim();
31
+ if (source.startsWith('<svg')) return this.#registerSvg(name, { source });
32
+ if (isSvgUrl(source)) return this.#registerSvg(name, { url: source });
33
+ return this.#registerImageUrl(name, source);
18
34
  }
19
- this.icons.set(name, { kind: 'image', image: icon, loaded: true });
20
- return this.icons.get(name);
35
+
36
+ const entry = { kind: 'image', image: icon, loaded: true };
37
+ this.icons.set(name, entry);
38
+ return entry;
21
39
  }
22
40
 
23
41
  get(name) {
24
42
  return this.icons.get(name) ?? this.icons.get('placeholder');
25
43
  }
26
44
 
45
+ /** Listen for a decoded icon becoming ready, so hosts can repaint once. */
46
+ onChange(listener) {
47
+ this.listeners.add(listener);
48
+ return () => this.listeners.delete(listener);
49
+ }
50
+
51
+ /**
52
+ * Starts loading and rasterising a known set of icons ahead of first paint.
53
+ * A colour is part of the cache key because theme colours are baked into SVG
54
+ * assets that use currentColor.
55
+ */
56
+ prepare({ icons = this.icons.keys(), size = 15, color = '#94a3b8', pixelRatio = this.pixelRatio } = {}) {
57
+ return Promise.all([...icons].map((name) => this.#requestRaster(this.get(name), size, color, pixelRatio)));
58
+ }
59
+
27
60
  draw(ctx, name, x, y, size, color) {
28
61
  const icon = this.get(name);
29
62
  if (!icon) return;
30
63
  if (icon.kind === 'vector') {
64
+ // Kept for backwards compatibility. Built-in icons are all SVG assets.
31
65
  icon.draw(ctx, x, y, size, color);
32
66
  return;
33
67
  }
34
- if (icon.loaded && icon.image) {
35
- ctx.drawImage(icon.image, x, y, size, size);
68
+ if (icon.kind === 'image') {
69
+ if (icon.loaded && icon.image) ctx.drawImage(icon.image, x, y, size, size);
70
+ return;
71
+ }
72
+
73
+ const pixelRatio = canvasPixelRatio(ctx, this.pixelRatio);
74
+ const key = rasterKey(size, color, pixelRatio);
75
+ const raster = icon.rasters.get(key);
76
+ if (raster) {
77
+ ctx.drawImage(raster, x, y, size, size);
36
78
  return;
37
79
  }
38
- this.get('placeholder')?.draw(ctx, x, y, size, color);
80
+ this.#requestRaster(icon, size, color, pixelRatio);
39
81
  }
40
82
 
41
- #registerUrl(name, url) {
42
- if (this.icons.has(name)) return this.icons.get(name);
43
- const entry = { kind: 'image', image: null, loaded: false, url };
83
+ #registerSvg(name, { source = null, url = null }) {
84
+ const entry = {
85
+ kind: 'svg',
86
+ source,
87
+ url,
88
+ rasters: new Map(),
89
+ pending: new Map(),
90
+ sourcePromise: null,
91
+ error: null,
92
+ };
44
93
  this.icons.set(name, entry);
45
- if (typeof Image !== 'undefined' && !this.loading.has(url)) {
46
- const image = new Image();
47
- image.decoding = 'async';
48
- image.onload = () => {
49
- entry.image = image;
50
- entry.loaded = true;
51
- this.loading.delete(url);
52
- };
53
- image.src = url;
54
- this.loading.set(url, image);
55
- }
94
+ // Fetching source eagerly moves network and SVG parsing out of scrolling.
95
+ // Avoid file-URL fetch attempts while the package is exercised in Node.
96
+ if (typeof window !== 'undefined') this.#loadSvg(entry);
56
97
  return entry;
57
98
  }
58
99
 
59
- #registerSvgString(name, svg) {
60
- if (typeof Blob === 'undefined' || typeof URL === 'undefined') {
61
- this.icons.set(name, { kind: 'svg', svg, loaded: false });
62
- return this.icons.get(name);
63
- }
64
- const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
65
- return this.#registerUrl(name, url);
100
+ #registerImageUrl(name, url) {
101
+ const entry = { kind: 'image', image: null, loaded: false, url };
102
+ this.icons.set(name, entry);
103
+ if (typeof Image === 'undefined') return entry;
104
+ const image = new Image();
105
+ image.decoding = 'async';
106
+ image.onload = () => {
107
+ entry.image = image;
108
+ entry.loaded = true;
109
+ this.#notify();
110
+ };
111
+ image.onerror = () => { entry.error = new Error(`Unable to load icon: ${url}`); };
112
+ image.src = url;
113
+ return entry;
66
114
  }
67
115
 
68
- #registerBuiltIns() {
69
- this.register('placeholder', drawPlaceholder);
70
- this.register('folder', drawFolder);
71
- this.register('aircraft', drawAircraft);
72
- this.register('radar', drawRadar);
73
- this.register('warning', drawWarning);
74
- this.register('error', drawError);
75
- this.register('task', drawTask);
76
- this.register('bus', drawBus);
77
- this.register('track', drawTrack);
78
- this.register('point', drawPoint);
79
- this.register('munition', drawMunition);
80
- this.register('air', drawAircraft);
81
- this.register('ground', drawGroundVehicle);
82
- this.register('surface', drawSurfaceVehicle);
83
- this.register('subsurface', drawSubsurfaceVehicle);
84
- this.register('space', drawSpaceVehicle);
85
- this.register('control', drawControl);
86
- this.register('situation', drawSituation);
87
- this.register('damage', drawDamage);
88
- this.register('inspector-object', drawInspectorObject);
89
- this.register('inspector-array', drawInspectorArray);
90
- this.register('inspector-value', drawInspectorValue);
116
+ async #requestRaster(icon, size, color, pixelRatio) {
117
+ if (!icon || icon.kind !== 'svg') return null;
118
+ const key = rasterKey(size, color, pixelRatio);
119
+ if (icon.rasters.has(key)) return icon.rasters.get(key);
120
+ if (icon.pending.has(key)) return icon.pending.get(key);
121
+
122
+ const pending = this.#loadSvg(icon)
123
+ .then((source) => source && rasterizeSvg(source, size, color, pixelRatio))
124
+ .then((raster) => {
125
+ if (raster) {
126
+ icon.rasters.set(key, raster);
127
+ this.#notify();
128
+ }
129
+ return raster;
130
+ })
131
+ .catch((error) => {
132
+ icon.error = error;
133
+ return null;
134
+ })
135
+ .finally(() => icon.pending.delete(key));
136
+ icon.pending.set(key, pending);
137
+ return pending;
91
138
  }
92
- }
93
-
94
- function drawPlaceholder(ctx, x, y, size, color) {
95
- ctx.strokeStyle = color;
96
- ctx.strokeRect(x + 2.5, y + 2.5, size - 5, size - 5);
97
- }
98
-
99
- function drawFolder(ctx, x, y, size, color) {
100
- ctx.fillStyle = color;
101
- ctx.beginPath();
102
- ctx.moveTo(x + 1, y + 5);
103
- ctx.lineTo(x + size * 0.38, y + 5);
104
- ctx.lineTo(x + size * 0.48, y + 8);
105
- ctx.lineTo(x + size - 1, y + 8);
106
- ctx.lineTo(x + size - 1, y + size - 2);
107
- ctx.lineTo(x + 1, y + size - 2);
108
- ctx.closePath();
109
- ctx.fill();
110
- }
111
-
112
- function drawAircraft(ctx, x, y, size, color) {
113
- const cx = x + size * 0.5;
114
- ctx.save();
115
- ctx.strokeStyle = color;
116
- ctx.fillStyle = color;
117
- ctx.lineWidth = 1.35;
118
- ctx.lineCap = 'round';
119
- ctx.lineJoin = 'round';
120
-
121
- ctx.beginPath();
122
- ctx.moveTo(cx, y + 1.5);
123
- ctx.lineTo(cx + size * 0.08, y + size * 0.56);
124
- ctx.lineTo(cx + size * 0.05, y + size - 2);
125
- ctx.lineTo(cx - size * 0.05, y + size - 2);
126
- ctx.lineTo(cx - size * 0.08, y + size * 0.56);
127
- ctx.closePath();
128
- ctx.fill();
129
-
130
- ctx.beginPath();
131
- ctx.moveTo(cx - size * 0.08, y + size * 0.54);
132
- ctx.lineTo(x + 1.5, y + size * 0.76);
133
- ctx.lineTo(cx - size * 0.03, y + size * 0.68);
134
- ctx.lineTo(cx + size * 0.03, y + size * 0.68);
135
- ctx.lineTo(x + size - 1.5, y + size * 0.76);
136
- ctx.lineTo(cx + size * 0.08, y + size * 0.54);
137
- ctx.stroke();
138
-
139
- ctx.beginPath();
140
- ctx.moveTo(cx - size * 0.05, y + size * 0.82);
141
- ctx.lineTo(x + size * 0.25, y + size - 1.5);
142
- ctx.moveTo(cx + size * 0.05, y + size * 0.82);
143
- ctx.lineTo(x + size * 0.75, y + size - 1.5);
144
- ctx.stroke();
145
- ctx.restore();
146
- }
147
139
 
148
- function drawTask(ctx, x, y, size, color) {
149
- ctx.save();
150
- ctx.strokeStyle = color;
151
- ctx.fillStyle = color;
152
- ctx.lineWidth = 1.35;
153
- ctx.lineCap = 'round';
154
- ctx.lineJoin = 'round';
155
-
156
- roundIconRect(ctx, x + 3.5, y + 2.5, size - 7, size - 5, 2);
157
- ctx.stroke();
158
- ctx.beginPath();
159
- ctx.moveTo(x + size * 0.4, y + 2.5);
160
- ctx.lineTo(x + size * 0.6, y + 2.5);
161
- ctx.stroke();
162
-
163
- ctx.beginPath();
164
- ctx.moveTo(x + 5.2, y + size * 0.48);
165
- ctx.lineTo(x + size * 0.38, y + size * 0.63);
166
- ctx.lineTo(x + size * 0.6, y + size * 0.38);
167
- ctx.stroke();
168
-
169
- ctx.beginPath();
170
- ctx.moveTo(x + size * 0.62, y + size * 0.6);
171
- ctx.lineTo(x + size - 4.5, y + size * 0.6);
172
- ctx.moveTo(x + 5, y + size * 0.78);
173
- ctx.lineTo(x + size - 4.5, y + size * 0.78);
174
- ctx.stroke();
175
- ctx.restore();
176
- }
177
-
178
- function roundIconRect(ctx, x, y, width, height, radius) {
179
- ctx.beginPath();
180
- if (typeof ctx.roundRect === 'function') {
181
- ctx.roundRect(x, y, width, height, radius);
182
- return;
140
+ #loadSvg(icon) {
141
+ if (icon.source) return Promise.resolve(icon.source);
142
+ if (icon.sourcePromise) return icon.sourcePromise;
143
+ if (!icon.url || typeof fetch !== 'function') return Promise.resolve(null);
144
+
145
+ icon.sourcePromise = fetch(icon.url)
146
+ .then((response) => {
147
+ if (!response.ok) throw new Error(`Unable to load SVG icon: ${icon.url}`);
148
+ return response.text();
149
+ })
150
+ .then((source) => {
151
+ icon.source = source;
152
+ return source;
153
+ })
154
+ .catch((error) => {
155
+ icon.error = error;
156
+ return null;
157
+ });
158
+ return icon.sourcePromise;
183
159
  }
184
- ctx.moveTo(x + radius, y);
185
- ctx.lineTo(x + width - radius, y);
186
- ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
187
- ctx.lineTo(x + width, y + height - radius);
188
- ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
189
- ctx.lineTo(x + radius, y + height);
190
- ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
191
- ctx.lineTo(x, y + radius);
192
- ctx.quadraticCurveTo(x, y, x + radius, y);
193
- }
194
-
195
- function drawRadar(ctx, x, y, size, color) {
196
- ctx.strokeStyle = color;
197
- ctx.lineWidth = 1.5;
198
- ctx.beginPath();
199
- ctx.arc(x + size / 2, y + size / 2, size * 0.34, -0.4, Math.PI * 1.4);
200
- ctx.moveTo(x + size / 2, y + size / 2);
201
- ctx.lineTo(x + size * 0.84, y + size * 0.28);
202
- ctx.stroke();
203
- ctx.fillStyle = color;
204
- ctx.beginPath();
205
- ctx.arc(x + size / 2, y + size / 2, 2, 0, Math.PI * 2);
206
- ctx.fill();
207
- }
208
-
209
- function drawWarning(ctx, x, y, size, color) {
210
- ctx.fillStyle = color;
211
- ctx.beginPath();
212
- ctx.moveTo(x + size / 2, y + 1);
213
- ctx.lineTo(x + size - 1, y + size - 2);
214
- ctx.lineTo(x + 1, y + size - 2);
215
- ctx.closePath();
216
- ctx.fill();
217
- }
218
160
 
219
- function drawError(ctx, x, y, size, color) {
220
- ctx.fillStyle = color;
221
- ctx.beginPath();
222
- ctx.arc(x + size / 2, y + size / 2, size * 0.42, 0, Math.PI * 2);
223
- ctx.fill();
224
- ctx.strokeStyle = '#ffffff';
225
- ctx.beginPath();
226
- ctx.moveTo(x + 5, y + 5);
227
- ctx.lineTo(x + size - 5, y + size - 5);
228
- ctx.moveTo(x + size - 5, y + 5);
229
- ctx.lineTo(x + 5, y + size - 5);
230
- ctx.stroke();
231
- }
232
-
233
- function drawBus(ctx, x, y, size, color) {
234
- const left = x + size * 0.22;
235
- const right = x + size * 0.82;
236
- const ys = [y + size * 0.32, y + size * 0.5, y + size * 0.68];
237
- ctx.strokeStyle = color;
238
- ctx.fillStyle = color;
239
- ctx.lineWidth = 1.25;
240
-
241
- ctx.beginPath();
242
- ctx.moveTo(left, ys[0]);
243
- ctx.lineTo(right, ys[0]);
244
- ctx.moveTo(left, ys[1]);
245
- ctx.lineTo(right, ys[1]);
246
- ctx.moveTo(left, ys[2]);
247
- ctx.lineTo(right, ys[2]);
248
- ctx.moveTo(left, ys[0]);
249
- ctx.lineTo(left, ys[2]);
250
- ctx.stroke();
251
-
252
- for (const cy of ys) {
253
- ctx.beginPath();
254
- ctx.arc(left, cy, size * 0.075, 0, Math.PI * 2);
255
- ctx.arc(right, cy, size * 0.075, 0, Math.PI * 2);
256
- ctx.fill();
161
+ #notify() {
162
+ for (const listener of this.listeners) listener();
257
163
  }
258
- }
259
-
260
- function drawTrack(ctx, x, y, size, color) {
261
- ctx.strokeStyle = color;
262
- ctx.lineWidth = 1.5;
263
- ctx.beginPath();
264
- ctx.arc(x + size / 2, y + size / 2, size * 0.34, 0, Math.PI * 2);
265
- ctx.moveTo(x + size / 2, y + 1);
266
- ctx.lineTo(x + size / 2, y + size - 1);
267
- ctx.moveTo(x + 1, y + size / 2);
268
- ctx.lineTo(x + size - 1, y + size / 2);
269
- ctx.stroke();
270
- }
271
-
272
- function drawPoint(ctx, x, y, size, color) {
273
- const cx = x + size / 2;
274
- const cy = y + size / 2;
275
- ctx.strokeStyle = color;
276
- ctx.fillStyle = color;
277
- ctx.lineWidth = 1.4;
278
- ctx.beginPath();
279
- ctx.arc(cx, cy, size * 0.22, 0, Math.PI * 2);
280
- ctx.fill();
281
- ctx.beginPath();
282
- ctx.arc(cx, cy, size * 0.42, 0, Math.PI * 2);
283
- ctx.stroke();
284
- ctx.beginPath();
285
- ctx.moveTo(cx, y + 1);
286
- ctx.lineTo(cx, y + size * 0.22);
287
- ctx.moveTo(cx, y + size * 0.78);
288
- ctx.lineTo(cx, y + size - 1);
289
- ctx.moveTo(x + 1, cy);
290
- ctx.lineTo(x + size * 0.22, cy);
291
- ctx.moveTo(x + size * 0.78, cy);
292
- ctx.lineTo(x + size - 1, cy);
293
- ctx.stroke();
294
- }
295
-
296
- function drawMunition(ctx, x, y, size, color) {
297
- const cx = x + size * 0.5;
298
- const bodyTop = y + size * 0.25;
299
- const bodyBottom = y + size * 0.76;
300
- ctx.fillStyle = color;
301
-
302
- ctx.beginPath();
303
- ctx.moveTo(cx, y + 1);
304
- ctx.lineTo(cx + size * 0.17, bodyTop);
305
- ctx.lineTo(cx - size * 0.17, bodyTop);
306
- ctx.closePath();
307
- ctx.fill();
308
-
309
- ctx.fillRect(cx - size * 0.11, bodyTop, size * 0.22, bodyBottom - bodyTop);
310
-
311
- ctx.beginPath();
312
- ctx.moveTo(cx - size * 0.11, y + size * 0.63);
313
- ctx.lineTo(x + 1, y + size - 2);
314
- ctx.lineTo(cx - size * 0.11, y + size * 0.82);
315
- ctx.closePath();
316
- ctx.fill();
317
-
318
- ctx.beginPath();
319
- ctx.moveTo(cx + size * 0.11, y + size * 0.63);
320
- ctx.lineTo(x + size - 1, y + size - 2);
321
- ctx.lineTo(cx + size * 0.11, y + size * 0.82);
322
- ctx.closePath();
323
- ctx.fill();
324
-
325
- ctx.strokeStyle = 'rgba(255,255,255,.35)';
326
- ctx.lineWidth = 1;
327
- ctx.beginPath();
328
- ctx.moveTo(cx, bodyTop + 1);
329
- ctx.lineTo(cx, bodyBottom - 1);
330
- ctx.stroke();
331
-
332
- ctx.strokeStyle = color;
333
- ctx.beginPath();
334
- ctx.moveTo(cx - size * 0.12, bodyBottom);
335
- ctx.lineTo(cx, y + size - 1);
336
- ctx.lineTo(cx + size * 0.12, bodyBottom);
337
- ctx.stroke();
338
- }
339
-
340
- function drawGroundVehicle(ctx, x, y, size, color) {
341
- ctx.fillStyle = color;
342
- const bodyY = y + size * 0.42;
343
- ctx.fillRect(x + size * 0.16, bodyY, size * 0.68, size * 0.26);
344
- ctx.fillRect(x + size * 0.34, y + size * 0.26, size * 0.28, size * 0.2);
345
- ctx.fillRect(x + size * 0.62, y + size * 0.34, size * 0.3, size * 0.06);
346
- ctx.beginPath();
347
- ctx.arc(x + size * 0.28, y + size * 0.76, size * 0.09, 0, Math.PI * 2);
348
- ctx.arc(x + size * 0.5, y + size * 0.76, size * 0.09, 0, Math.PI * 2);
349
- ctx.arc(x + size * 0.72, y + size * 0.76, size * 0.09, 0, Math.PI * 2);
350
- ctx.fill();
351
- }
352
-
353
- function drawSurfaceVehicle(ctx, x, y, size, color) {
354
- ctx.fillStyle = color;
355
- ctx.beginPath();
356
- ctx.moveTo(x + 1, y + size * 0.58);
357
- ctx.lineTo(x + size * 0.82, y + size * 0.58);
358
- ctx.lineTo(x + size - 1, y + size * 0.72);
359
- ctx.lineTo(x + size * 0.18, y + size * 0.82);
360
- ctx.closePath();
361
- ctx.fill();
362
164
 
363
- ctx.fillRect(x + size * 0.34, y + size * 0.34, size * 0.24, size * 0.2);
364
- ctx.fillRect(x + size * 0.46, y + size * 0.18, size * 0.08, size * 0.18);
365
- ctx.strokeStyle = color;
366
- ctx.lineWidth = 1.2;
367
- ctx.beginPath();
368
- ctx.moveTo(x + size * 0.18, y + size * 0.88);
369
- ctx.quadraticCurveTo(x + size * 0.34, y + size * 0.78, x + size * 0.5, y + size * 0.88);
370
- ctx.quadraticCurveTo(x + size * 0.66, y + size * 0.98, x + size * 0.82, y + size * 0.88);
371
- ctx.stroke();
372
- }
373
-
374
- function drawSubsurfaceVehicle(ctx, x, y, size, color) {
375
- ctx.fillStyle = color;
376
- ctx.beginPath();
377
- ctx.ellipse(x + size * 0.5, y + size * 0.58, size * 0.38, size * 0.18, 0, 0, Math.PI * 2);
378
- ctx.fill();
379
- ctx.fillRect(x + size * 0.44, y + size * 0.28, size * 0.12, size * 0.18);
380
- ctx.fillRect(x + size * 0.38, y + size * 0.26, size * 0.24, size * 0.06);
381
- ctx.strokeStyle = color;
382
- ctx.lineWidth = 1.2;
383
- ctx.beginPath();
384
- ctx.moveTo(x + size * 0.16, y + size * 0.86);
385
- ctx.quadraticCurveTo(x + size * 0.32, y + size * 0.78, x + size * 0.5, y + size * 0.86);
386
- ctx.quadraticCurveTo(x + size * 0.68, y + size * 0.94, x + size * 0.84, y + size * 0.86);
387
- ctx.stroke();
388
- }
389
-
390
- function drawSpaceVehicle(ctx, x, y, size, color) {
391
- const cx = x + size * 0.5;
392
- const cy = y + size * 0.5;
393
- ctx.strokeStyle = color;
394
- ctx.fillStyle = color;
395
- ctx.lineWidth = 1.2;
396
- ctx.beginPath();
397
- ctx.arc(cx, cy, size * 0.16, 0, Math.PI * 2);
398
- ctx.fill();
399
- ctx.strokeRect(x + size * 0.12, y + size * 0.32, size * 0.24, size * 0.36);
400
- ctx.strokeRect(x + size * 0.64, y + size * 0.32, size * 0.24, size * 0.36);
401
- ctx.beginPath();
402
- ctx.moveTo(x + size * 0.36, cy);
403
- ctx.lineTo(x + size * 0.64, cy);
404
- ctx.stroke();
405
- ctx.beginPath();
406
- ctx.ellipse(cx, cy, size * 0.42, size * 0.18, -0.45, 0, Math.PI * 2);
407
- ctx.stroke();
165
+ #registerBuiltIns() {
166
+ for (const [name, url] of Object.entries(builtinIconUrls)) this.register(name, url);
167
+ }
408
168
  }
409
169
 
410
- function drawControl(ctx, x, y, size, color) {
411
- ctx.strokeStyle = color;
412
- ctx.fillStyle = color;
413
- ctx.lineWidth = 1.3;
414
- const cx = x + size * 0.5;
415
- const baseY = y + size * 0.68;
416
-
417
- ctx.beginPath();
418
- ctx.moveTo(x + size * 0.22, baseY);
419
- ctx.lineTo(x + size * 0.78, baseY);
420
- ctx.lineTo(x + size * 0.88, y + size * 0.88);
421
- ctx.lineTo(x + size * 0.12, y + size * 0.88);
422
- ctx.closePath();
423
- ctx.stroke();
424
-
425
- ctx.beginPath();
426
- ctx.moveTo(cx, baseY);
427
- ctx.lineTo(x + size * 0.42, y + size * 0.32);
428
- ctx.stroke();
429
-
430
- ctx.beginPath();
431
- ctx.arc(x + size * 0.4, y + size * 0.28, size * 0.13, 0, Math.PI * 2);
432
- ctx.fill();
170
+ async function rasterizeSvg(source, size, color, pixelRatio) {
171
+ const pixelSize = Math.max(1, Math.round(size * pixelRatio));
172
+ const svg = source.replaceAll('currentColor', color);
173
+ const blob = new Blob([svg], { type: 'image/svg+xml' });
174
+
175
+ if (typeof createImageBitmap === 'function') {
176
+ try {
177
+ return await createImageBitmap(blob, {
178
+ resizeWidth: pixelSize,
179
+ resizeHeight: pixelSize,
180
+ resizeQuality: 'high',
181
+ });
182
+ } catch {
183
+ // Safari and older Chromium versions can reject SVG blobs here. The
184
+ // canvas fallback below still creates one fixed-size raster per key.
185
+ }
186
+ }
433
187
 
434
- ctx.beginPath();
435
- ctx.arc(x + size * 0.66, y + size * 0.78, size * 0.055, 0, Math.PI * 2);
436
- ctx.arc(x + size * 0.78, y + size * 0.78, size * 0.055, 0, Math.PI * 2);
437
- ctx.fill();
188
+ if (typeof Image === 'undefined' || typeof document === 'undefined' || typeof URL === 'undefined') return null;
189
+ const url = URL.createObjectURL(blob);
190
+ try {
191
+ const image = await loadImage(url);
192
+ const canvas = document.createElement('canvas');
193
+ canvas.width = pixelSize;
194
+ canvas.height = pixelSize;
195
+ const ctx = canvas.getContext('2d');
196
+ ctx?.drawImage(image, 0, 0, pixelSize, pixelSize);
197
+ return canvas;
198
+ } finally {
199
+ URL.revokeObjectURL(url);
200
+ }
438
201
  }
439
202
 
440
- function drawSituation(ctx, x, y, size, color) {
441
- const cx = x + size * 0.5;
442
- const cy = y + size * 0.5;
443
- ctx.strokeStyle = color;
444
- ctx.fillStyle = color;
445
- ctx.lineWidth = 1.25;
446
-
447
- ctx.beginPath();
448
- ctx.arc(cx, cy, size * 0.42, 0, Math.PI * 2);
449
- ctx.moveTo(cx - size * 0.42, cy);
450
- ctx.lineTo(cx + size * 0.42, cy);
451
- ctx.moveTo(cx, cy - size * 0.42);
452
- ctx.lineTo(cx, cy + size * 0.42);
453
- ctx.stroke();
454
-
455
- ctx.globalAlpha = 0.55;
456
- ctx.beginPath();
457
- ctx.arc(cx, cy, size * 0.25, 0, Math.PI * 2);
458
- ctx.stroke();
459
- ctx.globalAlpha = 1;
460
-
461
- ctx.beginPath();
462
- ctx.moveTo(cx, cy);
463
- ctx.lineTo(x + size * 0.82, y + size * 0.26);
464
- ctx.stroke();
465
-
466
- ctx.beginPath();
467
- ctx.arc(x + size * 0.68, y + size * 0.38, size * 0.055, 0, Math.PI * 2);
468
- ctx.arc(x + size * 0.36, y + size * 0.62, size * 0.045, 0, Math.PI * 2);
469
- ctx.fill();
203
+ function loadImage(url) {
204
+ return new Promise((resolve, reject) => {
205
+ const image = new Image();
206
+ image.decoding = 'async';
207
+ image.onload = () => resolve(image);
208
+ image.onerror = () => reject(new Error(`Unable to decode SVG icon: ${url}`));
209
+ image.src = url;
210
+ });
470
211
  }
471
212
 
472
- function drawDamage(ctx, x, y, size, color) {
473
- const cx = x + size * 0.5;
474
- const cy = y + size * 0.52;
475
- const r = size * 0.42;
476
-
477
- ctx.beginPath();
478
- ctx.moveTo(cx, y + 1);
479
- ctx.lineTo(cx + r * 0.28, cy - r * 0.28);
480
- ctx.lineTo(x + size - 1, y + size * 0.2);
481
- ctx.lineTo(cx + r * 0.55, cy + r * 0.05);
482
- ctx.lineTo(x + size * 0.92, y + size * 0.76);
483
- ctx.lineTo(cx + r * 0.22, cy + r * 0.32);
484
- ctx.lineTo(cx + r * 0.08, y + size - 1);
485
- ctx.lineTo(cx - r * 0.18, cy + r * 0.34);
486
- ctx.lineTo(x + size * 0.14, y + size * 0.86);
487
- ctx.lineTo(cx - r * 0.42, cy + r * 0.12);
488
- ctx.lineTo(x + 1, y + size * 0.38);
489
- ctx.lineTo(cx - r * 0.3, cy - r * 0.18);
490
- ctx.closePath();
491
- ctx.fillStyle = color;
492
- ctx.fill();
493
-
494
- ctx.fillStyle = '#fff7ed';
495
- ctx.beginPath();
496
- ctx.arc(cx, cy, size * 0.13, 0, Math.PI * 2);
497
- ctx.fill();
498
-
499
- ctx.strokeStyle = color;
500
- ctx.lineWidth = 1.1;
501
- ctx.beginPath();
502
- ctx.moveTo(x + size * 0.08, y + size * 0.08);
503
- ctx.lineTo(x + size * 0.2, y + size * 0.2);
504
- ctx.moveTo(x + size * 0.84, y + size * 0.88);
505
- ctx.lineTo(x + size * 0.94, y + size * 0.98);
506
- ctx.moveTo(x + size * 0.9, y + size * 0.06);
507
- ctx.lineTo(x + size * 0.82, y + size * 0.18);
508
- ctx.stroke();
213
+ function isSvgUrl(value) {
214
+ return /^data:image\/svg\+xml/i.test(value) || /\.svg(?:[?#].*)?$/i.test(value);
509
215
  }
510
216
 
511
- function drawInspectorObject(ctx, x, y, size, color) {
512
- ctx.strokeStyle = color;
513
- ctx.lineWidth = 1.2;
514
- const left = x + size * 0.24;
515
- const top = y + size * 0.22;
516
- const width = size * 0.52;
517
- const height = size * 0.56;
518
- ctx.strokeRect(left, top, width, height);
519
- ctx.beginPath();
520
- ctx.moveTo(left + width * 0.28, top);
521
- ctx.lineTo(left + width * 0.28, top + height);
522
- ctx.moveTo(left + width * 0.72, top);
523
- ctx.lineTo(left + width * 0.72, top + height);
524
- ctx.stroke();
217
+ function rasterKey(size, color, pixelRatio) {
218
+ return `${size}|${pixelRatio}|${color}`;
525
219
  }
526
220
 
527
- function drawInspectorArray(ctx, x, y, size, color) {
528
- ctx.strokeStyle = color;
529
- ctx.lineWidth = 1.2;
530
- const cx = x + size * 0.5;
531
- const cy = y + size * 0.5;
532
- ctx.beginPath();
533
- ctx.arc(cx - size * 0.18, cy, size * 0.18, 0, Math.PI * 2);
534
- ctx.arc(cx + size * 0.18, cy, size * 0.18, 0, Math.PI * 2);
535
- ctx.stroke();
221
+ function devicePixelRatio() {
222
+ return Math.max(1, globalThis.devicePixelRatio || 1);
536
223
  }
537
224
 
538
- function drawInspectorValue(ctx, x, y, size, color) {
539
- const cx = x + size * 0.5;
540
- const cy = y + size * 0.5;
541
- ctx.strokeStyle = color;
542
- ctx.fillStyle = color;
543
- ctx.lineWidth = 1.25;
544
- ctx.beginPath();
545
- ctx.arc(cx, cy, size * 0.3, 0, Math.PI * 2);
546
- ctx.stroke();
547
- ctx.beginPath();
548
- ctx.arc(cx, cy, size * 0.11, 0, Math.PI * 2);
549
- ctx.fill();
225
+ function canvasPixelRatio(ctx, fallback) {
226
+ const transform = ctx.getTransform?.();
227
+ return Math.max(1, Math.abs(transform?.a) || fallback || 1);
550
228
  }