@neta-art/cohub 12.2.0 → 12.4.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 (93) hide show
  1. package/README.md +87 -3
  2. package/dist/board/editor/context.d.ts +2 -0
  3. package/dist/board/editor/document.d.ts +3 -1
  4. package/dist/board/editor/document.js +8 -6
  5. package/dist/board/editor/editor.d.ts +7 -3
  6. package/dist/board/editor/editor.js +8 -1
  7. package/dist/board/editor/index.d.ts +3 -3
  8. package/dist/board/editor/index.js +2 -2
  9. package/dist/board/editor/interaction.d.ts +23 -2
  10. package/dist/board/editor/interaction.js +99 -5
  11. package/dist/board/editor/query.d.ts +1 -1
  12. package/dist/board/editor/query.js +3 -7
  13. package/dist/board/editor/selection.js +13 -1
  14. package/dist/board/editor/tool.d.ts +4 -1
  15. package/dist/board/editor/tool.js +6 -1
  16. package/dist/board/model/asset-key.d.ts +9 -1
  17. package/dist/board/model/asset-key.js +27 -7
  18. package/dist/board/model/index.d.ts +5 -5
  19. package/dist/board/model/index.js +4 -4
  20. package/dist/board/model/items/create.js +1 -1
  21. package/dist/board/model/items/definition.d.ts +9 -1
  22. package/dist/board/model/items/index.d.ts +2 -2
  23. package/dist/board/model/items/registry.d.ts +2 -1
  24. package/dist/board/model/items/registry.js +1 -0
  25. package/dist/board/model/text-metrics.d.ts +21 -1
  26. package/dist/board/model/text-metrics.js +24 -1
  27. package/dist/board/model/themes.d.ts +49 -42
  28. package/dist/board/model/themes.js +49 -44
  29. package/dist/board/render/index.d.ts +8 -6
  30. package/dist/board/render/index.js +7 -5
  31. package/dist/board/render/labels.d.ts +15 -0
  32. package/dist/board/render/labels.js +14 -0
  33. package/dist/board/render/lod.d.ts +9 -1
  34. package/dist/board/render/lod.js +7 -1
  35. package/dist/board/render/media-interaction.d.ts +5 -2
  36. package/dist/board/render/media-interaction.js +14 -2
  37. package/dist/board/render/renderers/arrow-card-renderer.js +7 -5
  38. package/dist/board/render/renderers/board-renderer-registry.d.ts +12 -1
  39. package/dist/board/render/renderers/board-renderer-registry.js +9 -4
  40. package/dist/board/render/renderers/far-plate.d.ts +4 -1
  41. package/dist/board/render/renderers/far-plate.js +5 -1
  42. package/dist/board/render/renderers/file-card-renderer.js +44 -24
  43. package/dist/board/render/renderers/frame-card-renderer.js +9 -3
  44. package/dist/board/render/renderers/image-card-renderer.js +2 -2
  45. package/dist/board/render/renderers/model-card-renderer.js +2 -2
  46. package/dist/board/render/renderers/task-card-renderer.js +2 -2
  47. package/dist/board/render/renderers/video-card-renderer.js +2 -2
  48. package/dist/board/render/style.d.ts +1 -0
  49. package/dist/board/render/style.js +3 -1
  50. package/dist/board/render/text-edit.d.ts +32 -0
  51. package/dist/board/render/text-edit.js +134 -0
  52. package/dist/board/render/text-fit.d.ts +1 -3
  53. package/dist/board/render/text-fit.js +4 -43
  54. package/dist/board/render/text-measurement.d.ts +12 -2
  55. package/dist/board/render/text-measurement.js +55 -10
  56. package/dist/board/replica/index.d.ts +4 -2
  57. package/dist/board/replica/index.js +34 -21
  58. package/dist/board/replica/indexeddb.d.ts +16 -0
  59. package/dist/board/replica/indexeddb.js +155 -0
  60. package/dist/board/stage/assets.d.ts +16 -4
  61. package/dist/board/stage/assets.js +155 -30
  62. package/dist/board/stage/bench.d.ts +35 -0
  63. package/dist/board/stage/bench.js +153 -0
  64. package/dist/board/stage/device.d.ts +41 -0
  65. package/dist/board/stage/device.js +62 -0
  66. package/dist/board/stage/frame-budget.d.ts +19 -0
  67. package/dist/board/stage/frame-budget.js +69 -0
  68. package/dist/board/stage/idle.d.ts +4 -0
  69. package/dist/board/stage/idle.js +26 -0
  70. package/dist/board/stage/index.d.ts +9 -5
  71. package/dist/board/stage/index.js +5 -2
  72. package/dist/board/stage/presses.d.ts +21 -0
  73. package/dist/board/stage/presses.js +66 -0
  74. package/dist/board/stage/scene.d.ts +4 -0
  75. package/dist/board/stage/scene.js +45 -13
  76. package/dist/board/stage/stage.d.ts +21 -2
  77. package/dist/board/stage/stage.js +309 -89
  78. package/dist/board/stage/texture-pool.d.ts +6 -4
  79. package/dist/board/stage/texture-pool.js +42 -8
  80. package/dist/board/stage/views.d.ts +1 -1
  81. package/dist/board/stage/views.js +2 -2
  82. package/dist/chunks/environment.d.ts +6 -0
  83. package/dist/chunks/http.d.ts +9 -1
  84. package/dist/chunks/http.js +791 -4
  85. package/dist/index.d.ts +52 -1
  86. package/dist/index.js +65 -733
  87. package/dist/protocol/dist/board-model.d.ts +15 -1
  88. package/dist/protocol/dist/board-model.js +12 -2
  89. package/dist/protocol/dist/board-schema.d.ts +2 -1
  90. package/dist/protocol/dist/board-schema.js +4 -1
  91. package/dist/protocol/dist/core/media.js +4 -0
  92. package/dist/protocol/dist/index.d.ts +2 -2
  93. package/package.json +2 -1
@@ -1,43 +1,53 @@
1
1
  import { probeMediaInfo } from "../../protocol/dist/core/media.js";
2
2
  import { MEDIA_VARIANT_SIZES, imageVariantUrl, variantSource } from "../../media.js";
3
3
  import { boardModelFormat } from "../model/media.js";
4
- import { boardAssetKey, boardAssetKeySource } from "../model/asset-key.js";
4
+ import { boardAssetKey, boardAssetKeySource, boardAssetRefKey } from "../model/asset-key.js";
5
5
  import { loadVideoThumbnailTexture, videoTextureNaturalSize } from "../render/video-thumbnail.js";
6
- import { DEFAULT_LRU_BUDGET, planLruTrim } from "./lru.js";
7
- import { boardImageTexturePool, loadBoardImageTexture, loadFittedImageTexture, shrinkBoardTexture, unloadBoardImageTexture } from "./texture-pool.js";
6
+ import { planLruTrim } from "./lru.js";
7
+ import { defaultBoardDeviceBudget } from "./device.js";
8
+ import { scheduleBoardIdle } from "./idle.js";
9
+ import { boardImageTexturePool, loadBoardImageTexture, loadFittedImageTexture, readTextureTone, shrinkBoardTexture, unloadBoardImageTexture } from "./texture-pool.js";
8
10
  import { Texture } from "pixi.js";
9
11
  //#region src/board/stage/assets.ts
10
- function variantUrl(source, edge) {
12
+ function variantUrl(source, edge, cover) {
11
13
  if (!(edge <= 1536)) return null;
12
- const url = imageVariantUrl(source, edge);
14
+ const url = imageVariantUrl(source, edge, cover ? "cover" : "contain");
13
15
  return url && url !== source ? url : null;
14
16
  }
15
17
  const SMALLEST_VARIANT = MEDIA_VARIANT_SIZES[0];
16
- function sharpEdge(texture, edge, resized) {
17
- return resized && Math.max(texture.width, texture.height) >= edge ? edge : Number.POSITIVE_INFINITY;
18
+ function boundEdge({ width, height }, cover) {
19
+ return cover ? Math.min(width, height) : Math.max(width, height);
20
+ }
21
+ function sharpEdge(texture, edge, resized, cover) {
22
+ return resized && boundEdge(texture, cover) >= edge ? edge : Number.POSITIVE_INFINITY;
18
23
  }
19
24
  const MAX_RETRY_DELAY = 3e4;
25
+ const PREFETCH_CONCURRENCY = 2;
26
+ const PREFETCH_POOL_SHARE = .5;
20
27
  const SHRINK_EDGE = 256;
21
28
  const SHRINK_CONCURRENCY = 4;
22
29
  const TRIM_DELAY = 400;
23
30
  const TRIM_MAX_DELAY = 2e3;
31
+ const TONE_LIMIT = 8192;
24
32
  function footprintOf(texture) {
25
33
  if (!texture) return 0;
26
34
  return (texture.width || 0) * (texture.height || 0) * 4;
27
35
  }
28
36
  function createBoardAssetManager(options) {
29
- const concurrency = Math.max(1, options.concurrency ?? 6);
37
+ const concurrency = Math.max(1, options.concurrency ?? defaultBoardDeviceBudget().concurrency);
30
38
  const limits = {
31
39
  image: concurrency,
32
40
  video: Math.max(1, Math.min(options.videoConcurrency ?? 2, concurrency)),
33
41
  model: 1
34
42
  };
35
- const budget = options.lruBudget ?? DEFAULT_LRU_BUDGET;
43
+ const budget = options.lruBudget ?? defaultBoardDeviceBudget().lru;
44
+ const prefetchLimit = Math.min(PREFETCH_CONCURRENCY, concurrency - 1);
36
45
  const now = options.now ?? (() => Date.now());
37
46
  const imagePool = options.imageTexturePool ?? boardImageTexturePool;
38
47
  const useSharedImagePool = !options.loadTexture && !options.unloadTexture;
39
48
  const loadTexture = options.loadTexture ?? ((url, media) => media === "video" ? loadVideoThumbnailTexture(url) : useSharedImagePool ? imagePool.acquire(url) : loadBoardImageTexture(url));
40
49
  const shrinkTexture = options.shrinkTexture ?? (useSharedImagePool ? shrinkBoardTexture : null);
50
+ const readTone = options.readTone === void 0 ? typeof createImageBitmap === "function" ? readTextureTone : null : options.readTone;
41
51
  const pendingUnloads = /* @__PURE__ */ new Map();
42
52
  const unloadTexture = options.unloadTexture ?? (async (url, texture, media) => {
43
53
  if (media !== "image" || !url) {
@@ -86,8 +96,39 @@ function createBoardAssetManager(options) {
86
96
  let trimTimer = null;
87
97
  let trimSince = 0;
88
98
  let shrinking = 0;
89
- function notify(key) {
90
- for (const listener of listeners) listener(key);
99
+ let warm = /* @__PURE__ */ new Set();
100
+ let warming = 0;
101
+ const unwarmable = /* @__PURE__ */ new Set();
102
+ function isWarm(key) {
103
+ return warm.has(key) && (entries.get(key)?.refs ?? 0) <= 0;
104
+ }
105
+ function notify(key, change = "texture") {
106
+ for (const listener of listeners) listener(key, change);
107
+ }
108
+ const tones = /* @__PURE__ */ new Map();
109
+ const toneQueue = /* @__PURE__ */ new Set();
110
+ let cancelTone = null;
111
+ function queueTone(key) {
112
+ if (!readTone || tones.has(key)) return;
113
+ toneQueue.add(key);
114
+ cancelTone ??= scheduleBoardIdle(readNextTone, 0);
115
+ }
116
+ async function readNextTone() {
117
+ const [key] = toneQueue;
118
+ if (key === void 0) {
119
+ cancelTone = null;
120
+ return;
121
+ }
122
+ toneQueue.delete(key);
123
+ const texture = entries.get(key)?.texture;
124
+ const tone = texture && readTone ? await readTone(texture).catch(() => null) : null;
125
+ if (disposed) return;
126
+ if (tone !== null) {
127
+ if (tones.size >= TONE_LIMIT) tones.delete(tones.keys().next().value);
128
+ tones.set(key, tone);
129
+ notify(key, "tone");
130
+ }
131
+ cancelTone = toneQueue.size > 0 ? scheduleBoardIdle(readNextTone, 0) : null;
91
132
  }
92
133
  const keys = /* @__PURE__ */ new WeakMap();
93
134
  let keyEpoch = 0;
@@ -102,7 +143,8 @@ function createBoardAssetManager(options) {
102
143
  return key;
103
144
  }
104
145
  function resolveAssetKey(item) {
105
- const key = boardAssetKey(item);
146
+ const asset = options.itemAsset?.(item);
147
+ const key = asset === void 0 ? boardAssetKey(item) : asset && boardAssetRefKey(asset);
106
148
  if (!key) return null;
107
149
  const source = boardAssetKeySource(key);
108
150
  if (source?.media === "video" && options.loadVideoPreviews === false) return null;
@@ -120,8 +162,10 @@ function createBoardAssetManager(options) {
120
162
  function ensureEntry(key) {
121
163
  let entry = entries.get(key);
122
164
  if (!entry) {
165
+ const source = boardAssetKeySource(key);
123
166
  entry = {
124
- media: boardAssetKeySource(key)?.media ?? "image",
167
+ media: source?.media ?? "image",
168
+ cover: source?.fit === "cover",
125
169
  source: null,
126
170
  url: null,
127
171
  texture: null,
@@ -221,6 +265,7 @@ function createBoardAssetManager(options) {
221
265
  if (loaded) releaseTexture(entry.media, loaded.url, loaded.texture);
222
266
  return;
223
267
  }
268
+ if (warm.delete(key) && !loaded && !entry.texture && entry.refs <= 0) unwarmable.add(key);
224
269
  if (loaded) {
225
270
  const replaced = entry.texture;
226
271
  const replacedUrl = entry.url ?? "";
@@ -238,6 +283,7 @@ function createBoardAssetManager(options) {
238
283
  if (entry.refs <= 0) scheduleTrim();
239
284
  else notify(key);
240
285
  if (replaced) retire(entry.media, replacedUrl, replaced);
286
+ queueTone(key);
241
287
  refine(key, entry);
242
288
  return;
243
289
  }
@@ -258,11 +304,11 @@ function createBoardAssetManager(options) {
258
304
  notify(key);
259
305
  }
260
306
  }
261
- function refinable(entry) {
262
- return entry.media === "image" && entry.refs > 0 && entry.texture !== null && entry.source !== null && entry.want > entry.edge;
307
+ function refinable(key, entry) {
308
+ return entry.media === "image" && (entry.refs > 0 || warm.has(key)) && entry.texture !== null && entry.source !== null && entry.want > entry.edge;
263
309
  }
264
310
  function refine(key, entry) {
265
- if (refinable(entry) && !entry.loading && entry.source) enqueue(key, "image", textureLoad("image", entry.source));
311
+ if (refinable(key, entry) && !entry.loading && entry.source) enqueue(key, "image", textureLoad("image", entry.source));
266
312
  }
267
313
  function enqueue(key, media, load) {
268
314
  if (queue.some((task) => task.key === key)) return;
@@ -275,16 +321,20 @@ function createBoardAssetManager(options) {
275
321
  }
276
322
  function nextTask() {
277
323
  let best = -1;
324
+ let bestWarm = true;
278
325
  let nearest = Number.POSITIVE_INFINITY;
279
326
  for (let index = 0; index < queue.length; index += 1) {
280
327
  const task = queue[index];
281
328
  if (activeByMedia[task.media] >= limits[task.media]) continue;
329
+ const prefetching = isWarm(task.key);
330
+ if (prefetching && (!bestWarm || warming >= prefetchLimit)) continue;
282
331
  const center = entries.get(task.key)?.center;
283
- if (!focus || !center) return index;
284
- const distance = (center.x - focus.x) ** 2 + (center.y - focus.y) ** 2;
285
- if (distance < nearest) {
332
+ const distance = focus && center ? (center.x - focus.x) ** 2 + (center.y - focus.y) ** 2 : 0;
333
+ if (best < 0 || bestWarm && !prefetching || distance < nearest) {
286
334
  nearest = distance;
287
335
  best = index;
336
+ bestWarm = prefetching;
337
+ if (!focus && !prefetching) return index;
288
338
  }
289
339
  }
290
340
  return best;
@@ -296,13 +346,16 @@ function createBoardAssetManager(options) {
296
346
  const [task] = queue.splice(taskIndex, 1);
297
347
  if (!task) continue;
298
348
  const entry = entries.get(task.key);
299
- if (!entry || entry.loading || entry.texture && !refinable(entry)) continue;
349
+ if (!entry || entry.loading || entry.texture && !refinable(task.key, entry)) continue;
350
+ const prefetching = isWarm(task.key);
300
351
  entry.loading = true;
301
352
  active += 1;
302
353
  activeByMedia[task.media] += 1;
354
+ if (prefetching) warming += 1;
303
355
  const done = (loaded, error) => {
304
356
  active -= 1;
305
357
  activeByMedia[task.media] -= 1;
358
+ if (prefetching) warming -= 1;
306
359
  adopt(task.key, entry, loaded, error);
307
360
  if (!disposed) pump();
308
361
  };
@@ -312,24 +365,26 @@ function createBoardAssetManager(options) {
312
365
  function textureLoad(media, source) {
313
366
  return async (entry) => {
314
367
  entry.source = source;
315
- const edge = entry.want;
316
- const variant = media === "image" ? variantUrl(source, Math.max(edge, SMALLEST_VARIANT)) : null;
368
+ const { cover, want: edge } = entry;
369
+ const variant = media === "image" ? variantUrl(source, Math.max(edge, SMALLEST_VARIANT), cover) : null;
317
370
  const url = variant ?? source;
318
371
  const pending = pendingUnloads.get(`${media}:${url}`);
319
372
  if (pending) await pending;
320
373
  if (disposed) return null;
321
374
  if (media === "image" && !variant && useSharedImagePool && Number.isFinite(edge)) {
322
- const fitted = await loadFittedImageTexture(url, edge).catch(() => null);
375
+ const fitted = await loadFittedImageTexture(url, edge, cover).catch(() => null);
323
376
  if (fitted) return {
324
377
  texture: fitted,
325
378
  url: "",
326
- edge: sharpEdge(fitted, edge, true)
379
+ edge: sharpEdge(fitted, edge, true, cover)
327
380
  };
328
381
  }
329
382
  const texture = await loadTexture(url, media);
330
383
  if (!texture) return null;
331
- if (media === "image" && shrinkTexture && edge < Math.max(texture.width, texture.height) / 2) {
332
- const small = await shrinkTexture(texture, edge).catch(() => null);
384
+ const bound = boundEdge(texture, cover);
385
+ if (media === "image" && shrinkTexture && edge < bound / 2) {
386
+ const longEdge = Math.round(edge * Math.max(texture.width, texture.height) / bound);
387
+ const small = await shrinkTexture(texture, longEdge).catch(() => null);
333
388
  if (small) {
334
389
  releaseTexture(media, url, texture);
335
390
  return {
@@ -342,7 +397,7 @@ function createBoardAssetManager(options) {
342
397
  return {
343
398
  texture,
344
399
  url,
345
- edge: sharpEdge(texture, edge, Boolean(variant))
400
+ edge: sharpEdge(texture, edge, Boolean(variant), cover)
346
401
  };
347
402
  };
348
403
  }
@@ -352,7 +407,7 @@ function createBoardAssetManager(options) {
352
407
  entry.loading = true;
353
408
  resolveFileUrl(path, fresh ? { fresh } : void 0).then((url) => {
354
409
  if (disposed || entries.get(key) !== entry) return;
355
- if (entry.refs <= 0) return evict(key, entry);
410
+ if (entry.refs <= 0 && !warm.has(key)) return evict(key, entry);
356
411
  if (!url) return adopt(key, entry, null, /* @__PURE__ */ new Error(`Unavailable file: ${path}`));
357
412
  entry.loading = false;
358
413
  enqueue(key, media, textureLoad(media, url));
@@ -419,6 +474,9 @@ function createBoardAssetManager(options) {
419
474
  keyEpoch += 1;
420
475
  for (const key of stale) notify(key);
421
476
  },
477
+ invalidateItemAssets() {
478
+ if (options.itemAsset) keyEpoch += 1;
479
+ },
422
480
  requestItem(item, edge = Number.POSITIVE_INFINITY) {
423
481
  if (disposed) return;
424
482
  const key = managedAssetKey(item);
@@ -457,10 +515,14 @@ function createBoardAssetManager(options) {
457
515
  hasError(key) {
458
516
  return entries.get(key)?.error ?? false;
459
517
  },
518
+ getTone(key) {
519
+ return tones.get(key) ?? null;
520
+ },
460
521
  focus(point) {
461
522
  focus = point;
462
523
  },
463
524
  acquire(key) {
525
+ unwarmable.delete(key);
464
526
  const entry = ensureEntry(key);
465
527
  if (entry.refs <= 0) entry.want = 0;
466
528
  entry.refs += 1;
@@ -477,6 +539,55 @@ function createBoardAssetManager(options) {
477
539
  if (!entry.texture) evict(key, entry);
478
540
  else scheduleTrim();
479
541
  },
542
+ prefetch(requests) {
543
+ if (disposed) return;
544
+ const next = /* @__PURE__ */ new Set();
545
+ let room = budget.maxBytes * PREFETCH_POOL_SHARE;
546
+ for (const entry of entries.values()) if (entry.refs <= 0) room -= footprintOf(entry.texture);
547
+ for (const { item, edge } of requests) {
548
+ const key = managedAssetKey(item);
549
+ if (!key || !Number.isFinite(edge) || unwarmable.has(key) || boardAssetKeySource(key)?.media !== "image") continue;
550
+ const entry = entries.get(key);
551
+ const sharp = entry?.texture && entry.edge >= edge;
552
+ if (entry && (entry.refs > 0 || sharp || entry.error || entry.loading && !warm.has(key))) continue;
553
+ room -= edge * edge * 4;
554
+ if (room < 0) break;
555
+ next.add(key);
556
+ }
557
+ for (const key of warm) {
558
+ if (next.has(key)) continue;
559
+ const entry = entries.get(key);
560
+ if (!entry || entry.refs > 0 || entry.loading) continue;
561
+ const index = queue.findIndex((task) => task.key === key);
562
+ if (index >= 0) queue.splice(index, 1);
563
+ if (!entry.texture) evict(key, entry);
564
+ }
565
+ const fresh = new Set([...next].filter((key) => !warm.has(key)));
566
+ warm = next;
567
+ for (const { item, edge } of requests) {
568
+ const key = managedAssetKey(item);
569
+ if (!key || !next.has(key)) continue;
570
+ const entry = ensureEntry(key);
571
+ entry.want = edge;
572
+ entry.center = {
573
+ x: item.frame.x + item.frame.width / 2,
574
+ y: item.frame.y + item.frame.height / 2
575
+ };
576
+ if (!fresh.delete(key)) continue;
577
+ if (entry.texture) {
578
+ refine(key, entry);
579
+ continue;
580
+ }
581
+ const start = starter(item, key, boardAssetKeySource(key));
582
+ if (!start) {
583
+ warm.delete(key);
584
+ evict(key, entry);
585
+ continue;
586
+ }
587
+ starters.set(key, start);
588
+ start(entry);
589
+ }
590
+ },
480
591
  async withTextures(items, use, loadOptions) {
481
592
  const keys = /* @__PURE__ */ new Set();
482
593
  if (!disposed) for (const item of items) {
@@ -490,7 +601,7 @@ function createBoardAssetManager(options) {
490
601
  for (const item of items) manager.requestItem(item, loadOptions?.edge?.(item));
491
602
  const settled = () => [...keys].every((key) => {
492
603
  const entry = entries.get(key);
493
- return Boolean(entry?.error) || Boolean(entry?.texture && !refinable(entry));
604
+ return Boolean(entry?.error) || Boolean(entry?.texture && !refinable(key, entry));
494
605
  });
495
606
  if (!settled()) {
496
607
  const timeoutMs = loadOptions?.timeoutMs ?? 15e3;
@@ -525,10 +636,19 @@ function createBoardAssetManager(options) {
525
636
  if (entry.refs <= 0) cooling += 1;
526
637
  bytes += footprintOf(entry.texture);
527
638
  }
639
+ let loading = active - warming;
640
+ let prefetching = warming;
641
+ for (const task of queue) {
642
+ if (!entries.has(task.key)) continue;
643
+ if (isWarm(task.key)) prefetching += 1;
644
+ else loading += 1;
645
+ }
528
646
  return {
529
647
  textures,
530
648
  cooling,
531
- bytes
649
+ bytes,
650
+ loading,
651
+ prefetching
532
652
  };
533
653
  },
534
654
  subscribe(listener) {
@@ -541,7 +661,12 @@ function createBoardAssetManager(options) {
541
661
  disposed = true;
542
662
  if (trimTimer !== null) clearTimeout(trimTimer);
543
663
  trimTimer = null;
664
+ cancelTone?.();
665
+ toneQueue.clear();
666
+ tones.clear();
544
667
  queue.length = 0;
668
+ warm.clear();
669
+ unwarmable.clear();
545
670
  listeners.clear();
546
671
  pathVersions.clear();
547
672
  starters.clear();
@@ -0,0 +1,35 @@
1
+ import { BoardStage } from "./stage.js";
2
+ //#region src/board/stage/bench.d.ts
3
+ type BoardBenchPhase = {
4
+ name: string;
5
+ frames: number;
6
+ wheel: Pick<WheelEventInit, "deltaX" | "deltaY" | "ctrlKey">;
7
+ rest?: number;
8
+ };
9
+ type BoardBenchPercentiles = {
10
+ p50: number;
11
+ p95: number;
12
+ max: number;
13
+ };
14
+ type BoardBenchSettle = {
15
+ shown: number;
16
+ ready: number;
17
+ loaded: number;
18
+ };
19
+ type BoardBenchPhaseReport = BoardBenchSettle & {
20
+ name: string;
21
+ frame: BoardBenchPercentiles;
22
+ work: BoardBenchPercentiles;
23
+ long: number;
24
+ };
25
+ type BoardBenchReport = {
26
+ open: BoardBenchSettle;
27
+ phases: BoardBenchPhaseReport[];
28
+ };
29
+ declare const BOARD_BENCH_PHASES: readonly BoardBenchPhase[];
30
+ declare function runBoardStageBench(stage: BoardStage, { since, phases }?: {
31
+ since?: number;
32
+ phases?: readonly BoardBenchPhase[];
33
+ }): Promise<BoardBenchReport>;
34
+ //#endregion
35
+ export { BOARD_BENCH_PHASES, BoardBenchPercentiles, BoardBenchPhase, BoardBenchPhaseReport, BoardBenchReport, BoardBenchSettle, runBoardStageBench };
@@ -0,0 +1,153 @@
1
+ //#region src/board/stage/bench.ts
2
+ const STEP = {
3
+ name: "step",
4
+ frames: 1,
5
+ rest: 3e3,
6
+ wheel: { deltaY: 960 }
7
+ };
8
+ const BOARD_BENCH_PHASES = [
9
+ {
10
+ name: "zoom in",
11
+ frames: 15,
12
+ wheel: {
13
+ deltaY: -10,
14
+ ctrlKey: true
15
+ }
16
+ },
17
+ {
18
+ name: "close",
19
+ frames: 12,
20
+ wheel: {
21
+ deltaY: -10,
22
+ ctrlKey: true
23
+ }
24
+ },
25
+ STEP,
26
+ STEP,
27
+ STEP,
28
+ {
29
+ name: "pan close",
30
+ frames: 60,
31
+ wheel: {
32
+ deltaX: -24,
33
+ deltaY: 16
34
+ }
35
+ },
36
+ {
37
+ name: "zoom out",
38
+ frames: 12,
39
+ wheel: {
40
+ deltaY: 10,
41
+ ctrlKey: true
42
+ }
43
+ },
44
+ {
45
+ name: "pan",
46
+ frames: 120,
47
+ wheel: { deltaX: 24 }
48
+ },
49
+ {
50
+ name: "overview",
51
+ frames: 16,
52
+ wheel: {
53
+ deltaY: 10,
54
+ ctrlKey: true
55
+ }
56
+ }
57
+ ];
58
+ const SETTLE_LIMIT_MS = 2e4;
59
+ const LONG_FRAME_MS = 50;
60
+ function percentiles(values) {
61
+ const sorted = [...values].sort((a, b) => a - b);
62
+ const at = (ratio) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))] ?? 0;
63
+ return {
64
+ p50: at(.5),
65
+ p95: at(.95),
66
+ max: sorted[sorted.length - 1] ?? 0
67
+ };
68
+ }
69
+ const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
70
+ async function settle(stage, since) {
71
+ const times = {};
72
+ for (;;) {
73
+ const now = await nextFrame();
74
+ const { waiting, settling, loading } = stage.stats();
75
+ const at = now - since;
76
+ if (times.shown === void 0 && waiting === 0) times.shown = at;
77
+ if (times.ready === void 0 && !settling) times.ready = at;
78
+ if (times.loaded === void 0 && !settling && loading === 0) times.loaded = at;
79
+ if (times.loaded !== void 0 || at >= SETTLE_LIMIT_MS) return {
80
+ shown: times.shown ?? SETTLE_LIMIT_MS,
81
+ ready: times.ready ?? SETTLE_LIMIT_MS,
82
+ loaded: times.loaded ?? SETTLE_LIMIT_MS
83
+ };
84
+ }
85
+ }
86
+ function measureFrameWork() {
87
+ const original = globalThis.requestAnimationFrame;
88
+ let work = 0;
89
+ globalThis.requestAnimationFrame = (callback) => original((time) => {
90
+ const start = performance.now();
91
+ try {
92
+ callback(time);
93
+ } finally {
94
+ work += performance.now() - start;
95
+ }
96
+ });
97
+ return {
98
+ take: () => {
99
+ const taken = work;
100
+ work = 0;
101
+ return taken;
102
+ },
103
+ stop: () => {
104
+ globalThis.requestAnimationFrame = original;
105
+ }
106
+ };
107
+ }
108
+ async function runBoardStageBench(stage, { since = performance.now(), phases = BOARD_BENCH_PHASES } = {}) {
109
+ await stage.ready;
110
+ const canvas = stage.renderer?.canvas;
111
+ if (!canvas) throw new Error("Board stage has no canvas.");
112
+ const open = await settle(stage, since);
113
+ const rect = canvas.getBoundingClientRect();
114
+ const work = measureFrameWork();
115
+ try {
116
+ const reports = [];
117
+ for (const phase of phases) {
118
+ if (phase.rest) await new Promise((resolve) => setTimeout(resolve, phase.rest));
119
+ const intervals = [];
120
+ const costs = [];
121
+ let previous = await nextFrame();
122
+ for (let frame = 0; frame < phase.frames; frame += 1) {
123
+ canvas.dispatchEvent(new WheelEvent("wheel", {
124
+ bubbles: true,
125
+ cancelable: true,
126
+ clientX: rect.left + rect.width / 2,
127
+ clientY: rect.top + rect.height / 2,
128
+ ...phase.wheel
129
+ }));
130
+ work.take();
131
+ const now = await nextFrame();
132
+ costs.push(work.take());
133
+ intervals.push(now - previous);
134
+ previous = now;
135
+ }
136
+ reports.push({
137
+ name: phase.name,
138
+ frame: percentiles(intervals),
139
+ work: percentiles(costs),
140
+ long: intervals.filter((interval) => interval > LONG_FRAME_MS).length,
141
+ ...await settle(stage, previous)
142
+ });
143
+ }
144
+ return {
145
+ open,
146
+ phases: reports
147
+ };
148
+ } finally {
149
+ work.stop();
150
+ }
151
+ }
152
+ //#endregion
153
+ export { BOARD_BENCH_PHASES, runBoardStageBench };
@@ -0,0 +1,41 @@
1
+ import { BoardLod } from "../render/lod.js";
2
+ import { LruBudget } from "./lru.js";
3
+ //#region src/board/stage/device.d.ts
4
+ type BoardDeviceTier = "low" | "standard" | "high";
5
+ type BoardDeviceSignals = {
6
+ phone?: boolean;
7
+ memory?: number;
8
+ cores?: number;
9
+ saveData?: boolean;
10
+ slowNetwork?: boolean;
11
+ };
12
+ type BoardDeviceBudget = {
13
+ tier: BoardDeviceTier;
14
+ lod: BoardLod;
15
+ lru: LruBudget;
16
+ concurrency: number;
17
+ prefetch: number;
18
+ };
19
+ declare const BOARD_DEVICE_TIERS: readonly BoardDeviceTier[];
20
+ type BoardDeviceEnvironment = {
21
+ navigator?: Navigator & {
22
+ deviceMemory?: number;
23
+ connection?: {
24
+ saveData?: boolean;
25
+ effectiveType?: string;
26
+ };
27
+ };
28
+ matchMedia?: (query: string) => {
29
+ matches: boolean;
30
+ };
31
+ screen?: {
32
+ width: number;
33
+ height: number;
34
+ };
35
+ };
36
+ declare function readBoardDeviceSignals(environment?: BoardDeviceEnvironment): BoardDeviceSignals;
37
+ declare function boardDeviceTier({ phone, memory, cores }: BoardDeviceSignals): BoardDeviceTier;
38
+ declare function boardDeviceBudget(signals: BoardDeviceSignals, tier?: BoardDeviceTier): BoardDeviceBudget;
39
+ declare function defaultBoardDeviceBudget(): BoardDeviceBudget;
40
+ //#endregion
41
+ export { BOARD_DEVICE_TIERS, BoardDeviceBudget, BoardDeviceEnvironment, BoardDeviceSignals, BoardDeviceTier, boardDeviceBudget, boardDeviceTier, defaultBoardDeviceBudget, readBoardDeviceSignals };
@@ -0,0 +1,62 @@
1
+ import { COMPACT_BOARD_LOD, DEFAULT_BOARD_LOD } from "../render/lod.js";
2
+ import { COMPACT_LRU_BUDGET, DEFAULT_LRU_BUDGET } from "./lru.js";
3
+ //#region src/board/stage/device.ts
4
+ const BUDGETS = {
5
+ low: {
6
+ lod: COMPACT_BOARD_LOD,
7
+ lru: COMPACT_LRU_BUDGET,
8
+ concurrency: 4,
9
+ prefetch: 0
10
+ },
11
+ standard: {
12
+ lod: DEFAULT_BOARD_LOD,
13
+ lru: DEFAULT_LRU_BUDGET,
14
+ concurrency: 6,
15
+ prefetch: .5
16
+ },
17
+ high: {
18
+ lod: DEFAULT_BOARD_LOD,
19
+ lru: {
20
+ maxCount: 4096,
21
+ maxBytes: 402653184
22
+ },
23
+ concurrency: 8,
24
+ prefetch: 1
25
+ }
26
+ };
27
+ const BOARD_DEVICE_TIERS = Object.keys(BUDGETS);
28
+ const PHONE_SHORT_SIDE = 600;
29
+ function readBoardDeviceSignals(environment = globalThis) {
30
+ const { navigator: device, matchMedia, screen } = environment;
31
+ if (!device || !matchMedia || !screen) return {};
32
+ const connection = device.connection;
33
+ return {
34
+ phone: matchMedia("(pointer: coarse)").matches && !matchMedia("(any-pointer: fine)").matches && Math.min(screen.width, screen.height) < PHONE_SHORT_SIDE,
35
+ memory: device.deviceMemory,
36
+ cores: device.hardwareConcurrency,
37
+ saveData: Boolean(connection?.saveData),
38
+ slowNetwork: /^(slow-2g|2g|3g)$/.test(connection?.effectiveType ?? "")
39
+ };
40
+ }
41
+ function boardDeviceTier({ phone, memory, cores }) {
42
+ if (memory !== void 0 && memory <= 2 || cores !== void 0 && cores <= 2) return "low";
43
+ if (phone) return memory !== void 0 && memory >= 8 ? "standard" : "low";
44
+ if (memory !== void 0 && memory <= 4 && cores !== void 0 && cores <= 4) return "low";
45
+ if (memory !== void 0 && memory >= 8 && cores !== void 0 && cores >= 8) return "high";
46
+ return "standard";
47
+ }
48
+ function boardDeviceBudget(signals, tier = boardDeviceTier(signals)) {
49
+ const budget = BUDGETS[tier];
50
+ return {
51
+ tier,
52
+ ...budget,
53
+ prefetch: signals.saveData || signals.slowNetwork ? 0 : budget.prefetch
54
+ };
55
+ }
56
+ let detected;
57
+ function defaultBoardDeviceBudget() {
58
+ detected ??= boardDeviceBudget(readBoardDeviceSignals());
59
+ return detected;
60
+ }
61
+ //#endregion
62
+ export { BOARD_DEVICE_TIERS, boardDeviceBudget, boardDeviceTier, defaultBoardDeviceBudget, readBoardDeviceSignals };
@@ -0,0 +1,19 @@
1
+ //#region src/board/stage/frame-budget.d.ts
2
+ type BoardFrameWork = "materialize" | "plate" | "resolution";
3
+ type BoardFrameBudget = {
4
+ allow(kind: BoardFrameWork): number;
5
+ spend(kind: BoardFrameWork, units: number, ms: number): void;
6
+ };
7
+ type BoardFrameClock = BoardFrameBudget & {
8
+ begin(frameTime: number, options: {
9
+ moving: boolean;
10
+ chained: boolean;
11
+ }): void;
12
+ readonly interval: number;
13
+ };
14
+ declare function fixedBoardFrameBudget(): BoardFrameBudget & {
15
+ reset(): void;
16
+ };
17
+ declare function createBoardFrameClock(now?: () => number): BoardFrameClock;
18
+ //#endregion
19
+ export { BoardFrameBudget, BoardFrameClock, BoardFrameWork, createBoardFrameClock, fixedBoardFrameBudget };