@woosh/meep-engine 3.60.0 → 3.60.2

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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/shade/playground/sh_exponential/main.js +2 -2
  3. package/src/shade/renderer/global_illumination/probe_clipmap/PROBE_CLIPMAP_PROPOSAL_2026_10_27.md +4 -0
  4. package/src/shade/renderer/global_illumination/probe_clipmap/probe/shader_probe_clipmap_shade.d.ts +6 -1
  5. package/src/shade/renderer/global_illumination/probe_clipmap/probe/shader_probe_clipmap_shade.d.ts.map +1 -1
  6. package/src/shade/renderer/global_illumination/probe_clipmap/probe/shader_probe_clipmap_shade.js +12 -4
  7. package/src/shade/renderer/light/GPULightCollection.d.ts.map +1 -1
  8. package/src/shade/renderer/light/GPULightCollection.js +3 -5
  9. package/src/shade/renderer/light/environment/execute_filter_environment_map.d.ts +13 -5
  10. package/src/shade/renderer/light/environment/execute_filter_environment_map.d.ts.map +1 -1
  11. package/src/shade/renderer/light/environment/execute_filter_environment_map.js +133 -7
  12. package/src/shade/renderer/light/environment/graph_filter_environment_map.d.ts +9 -0
  13. package/src/shade/renderer/light/environment/graph_filter_environment_map.d.ts.map +1 -1
  14. package/src/shade/renderer/light/environment/graph_filter_environment_map.js +33 -4
  15. package/src/shade/renderer/light/environment/shader_convolve_reflection_map_from_averages.d.ts +13 -0
  16. package/src/shade/renderer/light/environment/shader_convolve_reflection_map_from_averages.d.ts.map +1 -0
  17. package/src/shade/renderer/light/environment/shader_convolve_reflection_map_from_averages.js +169 -0
  18. package/src/shade/renderer/light/environment/shader_downsample_environment_map.d.ts +3 -0
  19. package/src/shade/renderer/light/environment/shader_downsample_environment_map.d.ts.map +1 -0
  20. package/src/shade/renderer/light/environment/shader_downsample_environment_map.js +105 -0
  21. package/src/shade/renderer/light/environment/sphere_probe_lod_to_roughness.d.ts +10 -0
  22. package/src/shade/renderer/light/environment/sphere_probe_lod_to_roughness.d.ts.map +1 -0
  23. package/src/shade/renderer/light/environment/sphere_probe_lod_to_roughness.js +23 -0
  24. package/src/shade/renderer/shader/chunk/light/probe/chunk_probe_visibility_weight.d.ts +8 -4
  25. package/src/shade/renderer/shader/chunk/light/probe/chunk_probe_visibility_weight.d.ts.map +1 -1
  26. package/src/shade/renderer/shader/chunk/light/probe/chunk_probe_visibility_weight.js +9 -5
  27. package/src/shade/wgsl/emulator/WGSLLanguageCore.d.ts.map +1 -1
  28. package/src/shade/wgsl/emulator/WGSLLanguageCore.js +26 -1
  29. package/src/view/elements/progress/segmented/GhostTrail.d.ts +78 -12
  30. package/src/view/elements/progress/segmented/GhostTrail.d.ts.map +1 -1
  31. package/src/view/elements/progress/segmented/GhostTrail.js +294 -98
  32. package/src/view/elements/progress/segmented/SegmentedResourceBarView.d.ts +68 -24
  33. package/src/view/elements/progress/segmented/SegmentedResourceBarView.d.ts.map +1 -1
  34. package/src/view/elements/progress/segmented/SegmentedResourceBarView.js +196 -128
  35. package/src/view/elements/progress/segmented/playground/main.js +18 -11
  36. package/src/view/elements/progress/segmented/segment_cell_extent.d.ts +14 -0
  37. package/src/view/elements/progress/segmented/segment_cell_extent.d.ts.map +1 -0
  38. package/src/view/elements/progress/segmented/segment_cell_extent.js +44 -0
  39. package/src/view/elements/progress/segmented/segment_mask_url.d.ts +24 -0
  40. package/src/view/elements/progress/segmented/segment_mask_url.d.ts.map +1 -0
  41. package/src/view/elements/progress/segmented/segment_mask_url.js +74 -0
  42. package/src/view/elements/progress/segmented/segment_mask_width.d.ts +18 -0
  43. package/src/view/elements/progress/segmented/segment_mask_width.d.ts.map +1 -0
  44. package/src/view/elements/progress/segmented/segment_mask_width.js +46 -0
  45. package/src/view/elements/progress/segmented/segmented-resource-bar.css +6 -10
@@ -8,8 +8,10 @@ import View from "../../../View.js";
8
8
  import EmptyView from "../../EmptyView.js";
9
9
  import { GhostTrail } from "./GhostTrail.js";
10
10
  import { SEGMENT_TIERS_DEFAULT } from "./SEGMENT_TIERS_DEFAULT.js";
11
+ import { segment_cell_extent } from "./segment_cell_extent.js";
11
12
  import { segment_cell_layout } from "./segment_cell_layout.js";
12
- import { segment_notch_place } from "./segment_notch_place.js";
13
+ import { segment_mask_apply, segment_mask_url } from "./segment_mask_url.js";
14
+ import { segment_mask_width } from "./segment_mask_width.js";
13
15
  import { segment_tick_positions } from "./segment_tick_positions.js";
14
16
 
15
17
  /**
@@ -19,37 +21,28 @@ import { segment_tick_positions } from "./segment_tick_positions.js";
19
21
  const REBUILD_WIDTH_THRESHOLD_PX = 0.5;
20
22
 
21
23
  /**
22
- * Cell fills within this of a whole cell count as whole, so 7 of 30 rounds is 7 full cells and not 7 and a sliver
24
+ * A measured CSS scale this close to 1 is no scale at all, only the rounding in how the browser reports widths
23
25
  * @type {number}
24
26
  */
25
- const CELL_FILL_TOLERANCE = 1e-9;
26
-
27
- const CELL_STATE_EMPTY = 0;
28
- const CELL_STATE_PARTIAL = 1;
29
- const CELL_STATE_FILLED = 2;
30
-
31
- const CELL_STATE_CLASS = ['cell empty', 'cell partial', 'cell filled'];
32
-
33
- /**
34
- * @param {number} x
35
- * @returns {number}
36
- */
37
- function snap_whole(x) {
38
- const r = Math.round(x);
39
-
40
- return Math.abs(x - r) <= CELL_FILL_TOLERANCE ? r : x;
41
- }
27
+ const UNSCALED_TOLERANCE = 2e-3;
42
28
 
43
29
  /**
44
30
  * Resource bar in the style of a MOBA health bar: the values fill it end to end, notches mark fixed amounts, and
45
31
  * a ghost behind the fill trails a drop so the loss stays readable for a moment.
46
32
  *
47
33
  * Two modes:
48
- * - notches (default): a continuous fill, with a notch at every multiple of each {@link SegmentTier}'s unit;
49
- * - cells (`cells` given): `count` separate cells with exact, identical gaps, for counting rounds and the like.
34
+ * - notches (default): a continuous fill, with a notch at every multiple of each {@link SegmentTier}'s unit. Each
35
+ * tier is one layer the width of the bar (`.notch.<class_name>`), which CSS paints — colour, height — and a mask
36
+ * cuts to that tier's notches;
37
+ * - cells (`cells` given): `count` separate cells with exact, identical gaps, for counting rounds and the like. One
38
+ * strip (`.cell-strip`, the empty cells' colour) holding a `.cell-ghost` and a `.cell-fill`, cut into cells by a
39
+ * mask; the fill and ghost end on a cell's edge, or part way across one that is filling.
50
40
  *
51
- * Notches and cells sit on whole device pixels at any pixel ratio and under a CSS scale. Colours, notch heights
52
- * and the ghost are styled by `segmented-resource-bar.css` through `--segmented-bar-*` custom properties.
41
+ * The masks are drawn in whole device pixels and the layers they cut placed so those pixels land on the screen's,
42
+ * at any pixel ratio and under a CSS scale: every notch of a tier is the same width and every gap the same, at
43
+ * 125% as at 100%. Colours, notch heights and the ghost are styled by `segmented-resource-bar.css` through
44
+ * `--segmented-bar-*` custom properties. Where masks cannot be drawn (no canvas, as under jsdom) the layers are
45
+ * placed but uncut.
53
46
  */
54
47
  export class SegmentedResourceBarView extends View {
55
48
  /**
@@ -60,8 +53,10 @@ export class SegmentedResourceBarView extends View {
60
53
  * @param {string[]} [options.classList]
61
54
  * @param {{unit:number, class_name:string, width_px:number, min_spacing_px:number}[]} [options.tiers] notch tiers, finest first; see {@link SegmentTier}
62
55
  * @param {{count:number, group:number}|null} [options.cells] draw `count` cells instead of notches, in groups of `group` (0 for none)
63
- * @param {number} [options.ghost_delay_ms=450] how long the ghost holds after a drop
64
- * @param {number} [options.ghost_duration_ms=350] how long the ghost then takes to catch up; 0 under prefers-reduced-motion
56
+ * @param {number} [options.ghost_delay_ms=100] how long the ghost holds after a drop; see {@link GhostTrail}
57
+ * @param {number} [options.ghost_duration_min_ms=300] how long the ghost takes to close the smallest loss
58
+ * @param {number} [options.ghost_duration_max_ms=800] how long it takes to close a loss of the whole bar; both
59
+ * durations are 0 under prefers-reduced-motion, so the ghost holds, then snaps
65
60
  */
66
61
  constructor({
67
62
  values,
@@ -69,16 +64,20 @@ export class SegmentedResourceBarView extends View {
69
64
  classList = [],
70
65
  tiers = SEGMENT_TIERS_DEFAULT,
71
66
  cells = null,
72
- ghost_delay_ms = 450,
73
- ghost_duration_ms = 350
67
+ ghost_delay_ms = 100,
68
+ ghost_duration_min_ms = 300,
69
+ ghost_duration_max_ms = 800
74
70
  }) {
75
71
  super();
76
72
 
77
73
  assert.isArray(values, 'values');
78
74
  assert.defined(max, 'max');
79
75
  assert.isArray(tiers, 'tiers');
80
- assert.isNumber(ghost_delay_ms, 'ghost_delay_ms');
81
- assert.isNumber(ghost_duration_ms, 'ghost_duration_ms');
76
+ assert.isFinite(ghost_delay_ms, 'ghost_delay_ms');
77
+ assert.greaterThanOrEqual(ghost_delay_ms, 0, 'ghost_delay_ms must not be negative');
78
+ assert.isFinite(ghost_duration_max_ms, 'ghost_duration_max_ms');
79
+ assert.greaterThanOrEqual(ghost_duration_min_ms, 0, 'ghost_duration_min_ms must not be negative');
80
+ assert.greaterThanOrEqual(ghost_duration_max_ms, ghost_duration_min_ms, 'ghost_duration_max_ms must not be under ghost_duration_min_ms');
82
81
 
83
82
  if (cells !== null) {
84
83
  assert.isNonNegativeInteger(cells.count, 'cells.count');
@@ -122,7 +121,13 @@ export class SegmentedResourceBarView extends View {
122
121
  * @type {number}
123
122
  * @private
124
123
  */
125
- this.__ghost_duration_ms = ghost_duration_ms;
124
+ this.__ghost_duration_min_ms = ghost_duration_min_ms;
125
+
126
+ /**
127
+ * @type {number}
128
+ * @private
129
+ */
130
+ this.__ghost_duration_max_ms = ghost_duration_max_ms;
126
131
 
127
132
  /**
128
133
  * Total fill as a fraction of max, as last drawn
@@ -150,6 +155,13 @@ export class SegmentedResourceBarView extends View {
150
155
  this.__frame_id = 0;
151
156
  this.__animate_ghost = this.__animate_ghost.bind(this);
152
157
 
158
+ /**
159
+ * The max moved since the fill was last drawn: the next fill resets the ghost rather than trailing a loss
160
+ * @type {boolean}
161
+ * @private
162
+ */
163
+ this.__max_moved = false;
164
+
153
165
  // what the layout was last built for, so it is rebuilt only when one of them moves
154
166
  this.__built_max = NaN;
155
167
  this.__built_width = NaN;
@@ -157,11 +169,25 @@ export class SegmentedResourceBarView extends View {
157
169
  this.__built_origin_phase = NaN;
158
170
 
159
171
  /**
160
- * scratch for {@link segment_notch_place}
161
- * @type {Float64Array}
172
+ * Cell mode: the cells as last laid out across {@link __el_strip}, or null before the bar has been measured
173
+ * @type {SegmentCellLayout|null}
174
+ * @private
175
+ */
176
+ this.__cell_layout = null;
177
+
178
+ /**
179
+ * Device pixels per CSS pixel the cells were laid out at
180
+ * @type {number}
162
181
  * @private
163
182
  */
164
- this.__place = new Float64Array(2);
183
+ this.__cell_ratio = 1;
184
+
185
+ /**
186
+ * scratch: [x, width, ...] device-pixel columns for a mask
187
+ * @type {number[]}
188
+ * @private
189
+ */
190
+ this.__columns = [];
165
191
 
166
192
  /**
167
193
  *
@@ -193,23 +219,20 @@ export class SegmentedResourceBarView extends View {
193
219
  this.__value_fills = [];
194
220
 
195
221
  /**
222
+ * Notch mode: one layer per tier, the whole bar wide, styled by the tier's class and masked to its notches
196
223
  * @type {HTMLElement[]}
197
224
  * @private
198
225
  */
199
- this.__notch_elements = [];
226
+ this.__tier_layers = [];
200
227
 
201
228
  /**
202
- * @type {HTMLElement[]}
229
+ * Cell mode: the strip the cells are cut from by a mask, and the ghost and fill drawn along it
230
+ * @type {HTMLElement|null}
203
231
  * @private
204
232
  */
205
- this.__cell_elements = [];
206
- this.__cell_fill_elements = [];
207
- this.__cell_ghost_elements = [];
208
- this.__cell_states = new Uint8Array(cells === null ? 0 : cells.count);
209
-
210
- // what each cell's fill and ghost were last drawn at, so an animation frame writes only the cells that moved
211
- this.__cell_fill_drawn = new Float64Array(cells === null ? 0 : cells.count);
212
- this.__cell_ghost_drawn = new Float64Array(cells === null ? 0 : cells.count);
233
+ this.__el_strip = null;
234
+ this.__el_cell_ghost = null;
235
+ this.__el_cell_fill = null;
213
236
 
214
237
  if (cells === null) {
215
238
  this.el.appendChild(this.__el_ghost);
@@ -234,29 +257,35 @@ export class SegmentedResourceBarView extends View {
234
257
  */
235
258
  this.__el_layout = document.createElement('div');
236
259
  this.__el_layout.className = 'notches';
260
+
261
+ for (let i = 0; i < tiers.length; i++) {
262
+ const el_tier = document.createElement('div');
263
+ el_tier.className = `notch ${tiers[i].class_name}`;
264
+ el_tier.style.display = 'none';
265
+
266
+ this.__el_layout.appendChild(el_tier);
267
+ this.__tier_layers[i] = el_tier;
268
+ }
237
269
  } else {
238
270
  this.__el_layout = document.createElement('div');
239
271
  this.__el_layout.className = 'cells';
240
272
 
241
- for (let i = 0; i < cells.count; i++) {
242
- const el_cell = document.createElement('div');
243
- el_cell.className = CELL_STATE_CLASS[CELL_STATE_EMPTY];
273
+ const el_strip = document.createElement('div');
274
+ el_strip.className = 'cell-strip';
244
275
 
245
- const el_cell_ghost = document.createElement('div');
246
- el_cell_ghost.className = 'cell-ghost';
276
+ const el_cell_ghost = document.createElement('div');
277
+ el_cell_ghost.className = 'cell-ghost';
247
278
 
248
- const el_cell_fill = document.createElement('div');
249
- el_cell_fill.className = 'cell-fill';
279
+ const el_cell_fill = document.createElement('div');
280
+ el_cell_fill.className = 'cell-fill';
250
281
 
251
- el_cell.appendChild(el_cell_ghost);
252
- el_cell.appendChild(el_cell_fill);
282
+ el_strip.appendChild(el_cell_ghost);
283
+ el_strip.appendChild(el_cell_fill);
284
+ this.__el_layout.appendChild(el_strip);
253
285
 
254
- this.__el_layout.appendChild(el_cell);
255
-
256
- this.__cell_elements[i] = el_cell;
257
- this.__cell_ghost_elements[i] = el_cell_ghost;
258
- this.__cell_fill_elements[i] = el_cell_fill;
259
- }
286
+ this.__el_strip = el_strip;
287
+ this.__el_cell_ghost = el_cell_ghost;
288
+ this.__el_cell_fill = el_cell_fill;
260
289
  }
261
290
 
262
291
  this.el.appendChild(this.__el_layout);
@@ -363,6 +392,9 @@ export class SegmentedResourceBarView extends View {
363
392
  }
364
393
 
365
394
  handleMaxChange() {
395
+ // the same amount out of a different max is a different share of the bar, and nothing was lost
396
+ this.__max_moved = true;
397
+
366
398
  this.updateSegments();
367
399
  this.updateFill();
368
400
  }
@@ -370,15 +402,24 @@ export class SegmentedResourceBarView extends View {
370
402
  /**
371
403
  * Place the notches or cells again, if the max, the bar's width (by {@link REBUILD_WIDTH_THRESHOLD_PX} or more),
372
404
  * its pixel ratio or the device-pixel phase of its left edge has moved since they were last placed.
405
+ *
406
+ * Both are drawn as a mask in whole device pixels over a layer CSS paints (see {@link segment_mask_url}): a DOM
407
+ * box per notch or cell cannot be put on the device-pixel grid at a fractional pixel ratio, where Chrome paints
408
+ * a row of equal 1 px gaps as 1, 2, 1, 2... whatever positions it is given (measured at 125% and 150%).
373
409
  */
374
410
  updateSegments() {
375
411
  const el = this.__el_layout;
376
412
 
377
413
  /**
378
- * width as laid out, before any transform on an ancestor
414
+ * width as laid out, before any transform on an ancestor. Not `offsetWidth`, which is rounded to a whole
415
+ * pixel and would read as a scale of its own (176 laid out against 175.84 drawn: 0.9991)
379
416
  * @type {number}
380
417
  */
381
- const layout_width = el.offsetWidth;
418
+ let layout_width = typeof getComputedStyle === 'function' ? parseFloat(getComputedStyle(el).width) : NaN;
419
+
420
+ if (!(layout_width > 0)) {
421
+ layout_width = el.offsetWidth;
422
+ }
382
423
 
383
424
  if (!(layout_width > 0)) {
384
425
  // not laid out (detached, display:none); nothing to place against
@@ -389,8 +430,11 @@ export class SegmentedResourceBarView extends View {
389
430
 
390
431
  const dpr = typeof devicePixelRatio === 'number' && devicePixelRatio > 0 ? devicePixelRatio : 1;
391
432
 
392
- // a CSS scale on an ancestor shows each CSS pixel of the bar as `scale` page pixels
393
- const scale = rect.width / layout_width;
433
+ // a CSS scale on an ancestor shows each CSS pixel of the bar as `scale` page pixels. The computed width is
434
+ // serialised to a few decimals (175.844 for 175.84375), so an unscaled bar measures a hair off 1 — enough to
435
+ // put a width that should be whole in device pixels just past it — and is read as unscaled
436
+ const measured_scale = rect.width > 0 ? rect.width / layout_width : 1;
437
+ const scale = Math.abs(measured_scale - 1) < UNSCALED_TOLERANCE ? 1 : measured_scale;
394
438
 
395
439
  // device pixels per CSS pixel of the bar
396
440
  const ratio = dpr * scale;
@@ -414,85 +458,109 @@ export class SegmentedResourceBarView extends View {
414
458
  this.__built_ratio = ratio;
415
459
  this.__built_origin_phase = origin_phase;
416
460
 
417
- // the bar's left edge in its own CSS pixels, such that left * ratio lands where the page puts it
418
- const local_left = origin_device / ratio;
461
+ // the masked layer starts on a whole device pixel, and is a width whole in CSS and device pixels both
462
+ const shift_px = -origin_phase / ratio;
463
+ const width_device = segment_mask_width(layout_width - Math.max(0, shift_px), ratio);
419
464
 
420
465
  if (this.__cells === null) {
421
- this.__place_notches(max, layout_width, local_left, ratio);
466
+ this.__place_notches(max, width_device, shift_px, ratio);
422
467
  } else {
423
- this.__place_cells(layout_width, local_left, ratio);
468
+ this.__place_cells(width_device, shift_px, ratio);
424
469
  }
425
470
  }
426
471
 
472
+ /**
473
+ * @param {HTMLElement} el
474
+ * @param {number} width_device
475
+ * @param {number} shift_px
476
+ * @param {number} ratio
477
+ * @private
478
+ */
479
+ __place_layer(el, width_device, shift_px, ratio) {
480
+ const style = el.style;
481
+
482
+ style.left = `${shift_px}px`;
483
+ style.width = `${width_device / ratio}px`;
484
+ }
485
+
427
486
  /**
428
487
  * @param {number} max
429
- * @param {number} width
430
- * @param {number} left
488
+ * @param {number} width_device
489
+ * @param {number} shift_px
431
490
  * @param {number} ratio
432
491
  * @private
433
492
  */
434
- __place_notches(max, width, left, ratio) {
493
+ __place_notches(max, width_device, shift_px, ratio) {
435
494
  const tiers = this.__tiers;
436
- const ticks = segment_tick_positions(max, width, tiers);
495
+ const ticks = segment_tick_positions(max, width_device / ratio, tiers);
437
496
 
438
497
  const tick_count = ticks.length;
439
- const elements = this.__notch_elements;
440
- const container = this.__el_layout;
498
+ const columns = this.__columns;
499
+ const layers = this.__tier_layers;
441
500
 
442
- while (elements.length < tick_count) {
443
- const el = document.createElement('div');
501
+ // a notch is `width_px` CSS pixels wide, scaled by the whole part of the ratio, so it stays whole
502
+ const notch_scale = Math.max(1, Math.round(ratio));
444
503
 
445
- container.appendChild(el);
446
- elements.push(el);
447
- }
504
+ for (let t = 0; t < tiers.length; t++) {
505
+ const notch_width = tiers[t].width_px * notch_scale;
448
506
 
449
- while (elements.length > tick_count) {
450
- container.removeChild(elements.pop());
451
- }
507
+ let count = 0;
508
+
509
+ for (let i = 0; i < tick_count; i++) {
510
+ const tick = ticks[i];
452
511
 
453
- const place = this.__place;
512
+ if (tick.tier !== t) {
513
+ continue;
514
+ }
454
515
 
455
- for (let i = 0; i < tick_count; i++) {
456
- const tick = ticks[i];
457
- const tier = tiers[tick.tier];
516
+ const centre = Math.round(tick.value / max * width_device);
517
+ const x = Math.min(Math.max(0, centre - Math.floor(notch_width / 2)), width_device - notch_width);
458
518
 
459
- segment_notch_place(place, 0, tick.value / max, tier.width_px, left, width, ratio);
519
+ columns[count * 2] = x;
520
+ columns[count * 2 + 1] = notch_width;
521
+ count++;
522
+ }
460
523
 
461
- const el = elements[i];
524
+ const el = layers[t];
462
525
 
463
- el.className = `notch ${tier.class_name}`;
526
+ if (count === 0) {
527
+ el.style.display = 'none';
528
+ continue;
529
+ }
464
530
 
465
- const style = el.style;
531
+ el.style.display = '';
466
532
 
467
- style.left = `${place[0]}px`;
468
- style.width = `${place[1]}px`;
533
+ this.__place_layer(el, width_device, shift_px, ratio);
534
+ segment_mask_apply(el, segment_mask_url(width_device, columns, count));
469
535
  }
470
536
  }
471
537
 
472
538
  /**
473
- * @param {number} width
474
- * @param {number} left
539
+ * @param {number} width_device
540
+ * @param {number} shift_px
475
541
  * @param {number} ratio
476
542
  * @private
477
543
  */
478
- __place_cells(width, left, ratio) {
544
+ __place_cells(width_device, shift_px, ratio) {
479
545
  const cells = this.__cells;
480
546
 
481
- const layout = segment_cell_layout(width, cells.count, cells.group, ratio);
482
-
483
- const origin_device = left * ratio;
484
- const origin_shift_device = Math.round(origin_device) - origin_device;
547
+ const layout = segment_cell_layout(width_device / ratio, cells.count, cells.group, ratio);
485
548
 
486
- const cell_x = layout.cell_x_device;
487
- const cell_width = layout.cell_width_device;
488
- const elements = this.__cell_elements;
549
+ const columns = this.__columns;
489
550
 
490
551
  for (let i = 0; i < cells.count; i++) {
491
- const style = elements[i].style;
492
-
493
- style.left = `${(cell_x[i] + origin_shift_device) / ratio}px`;
494
- style.width = `${cell_width[i] / ratio}px`;
552
+ columns[i * 2] = layout.cell_x_device[i];
553
+ columns[i * 2 + 1] = layout.cell_width_device[i];
495
554
  }
555
+
556
+ this.__cell_layout = layout;
557
+ this.__cell_ratio = ratio;
558
+
559
+ this.__place_layer(this.__el_strip, width_device, shift_px, ratio);
560
+ segment_mask_apply(this.__el_strip, segment_mask_url(width_device, columns, cells.count));
561
+
562
+ // the fill is drawn against the layout, which just moved
563
+ this.__draw(performance.now());
496
564
  }
497
565
 
498
566
  updateFill() {
@@ -531,8 +599,17 @@ export class SegmentedResourceBarView extends View {
531
599
 
532
600
  const ghost = this.__ghost;
533
601
 
534
- ghost.duration_ms = this.__reduced_motion !== null && this.__reduced_motion.matches ? 0 : this.__ghost_duration_ms;
535
- ghost.set(total_fraction, now);
602
+ const reduced_motion = this.__reduced_motion !== null && this.__reduced_motion.matches;
603
+
604
+ ghost.duration_min_ms = reduced_motion ? 0 : this.__ghost_duration_min_ms;
605
+ ghost.duration_max_ms = reduced_motion ? 0 : this.__ghost_duration_max_ms;
606
+
607
+ if (this.__max_moved) {
608
+ this.__max_moved = false;
609
+ ghost.reset(total_fraction);
610
+ } else {
611
+ ghost.set(total_fraction, now);
612
+ }
536
613
 
537
614
  this.__draw(now);
538
615
  }
@@ -564,33 +641,24 @@ export class SegmentedResourceBarView extends View {
564
641
  */
565
642
  __draw_cells(total_fraction, ghost_fraction) {
566
643
  const count = this.__cells.count;
644
+ const layout = this.__cell_layout;
567
645
 
568
- const fill_cells = snap_whole(total_fraction * count);
569
- const ghost_cells = snap_whole(ghost_fraction * count);
646
+ const fill = this.__el_cell_fill.style;
647
+ const ghost = this.__el_cell_ghost.style;
570
648
 
571
- const states = this.__cell_states;
649
+ if (layout === null) {
650
+ // not measured yet: a share of the strip, until the cells are laid out
651
+ fill.width = `${total_fraction * 100}%`;
652
+ ghost.width = `${ghost_fraction * 100}%`;
572
653
 
573
- for (let i = 0; i < count; i++) {
574
- const fill = clamp01(fill_cells - i);
575
- const ghost = clamp01(ghost_cells - i);
576
-
577
- const state = fill >= 1 ? CELL_STATE_FILLED : (fill > 0 ? CELL_STATE_PARTIAL : CELL_STATE_EMPTY);
578
-
579
- if (states[i] !== state) {
580
- states[i] = state;
581
- this.__cell_elements[i].className = CELL_STATE_CLASS[state];
582
- }
654
+ return;
655
+ }
583
656
 
584
- if (this.__cell_fill_drawn[i] !== fill) {
585
- this.__cell_fill_drawn[i] = fill;
586
- this.__cell_fill_elements[i].style.width = `${fill * 100}%`;
587
- }
657
+ // to the edge of the last whole cell (or part way across the next), never into a gap
658
+ const ratio = this.__cell_ratio;
588
659
 
589
- if (this.__cell_ghost_drawn[i] !== ghost) {
590
- this.__cell_ghost_drawn[i] = ghost;
591
- this.__cell_ghost_elements[i].style.width = `${ghost * 100}%`;
592
- }
593
- }
660
+ fill.width = `${segment_cell_extent(layout, total_fraction * count) / ratio}px`;
661
+ ghost.width = `${segment_cell_extent(layout, ghost_fraction * count) / ratio}px`;
594
662
  }
595
663
 
596
664
  /**
@@ -138,9 +138,12 @@ document.getElementById('reload').addEventListener('click', () => {
138
138
  });
139
139
 
140
140
  /**
141
- * For a driver: every notch and cell edge in device pixels, and whether it is whole. Chrome keeps layout in
142
- * 1/64 CSS pixel steps, so an edge placed at 0.8 CSS px reads back as 0.796875; anything within one such step of
143
- * a whole device pixel paints on it.
141
+ * For a driver: every masked layer (a notch tier, a cell strip) and whether it starts on a whole device pixel and is
142
+ * a width whole in CSS and device pixels both, which is what lets its mask's columns paint where they were drawn.
143
+ * Chrome keeps layout in 1/64 CSS pixel steps, so anything within one such step of whole counts as whole.
144
+ *
145
+ * Necessary, not sufficient: boxes whose edges all read as whole still painted 1, 2, 1, 2 px gaps at 125% when each
146
+ * cell was its own box. Judge the look from the painted pixels of a screenshot.
144
147
  * @returns {{dpr:number, checked:number, worst:number, off_grid:string[]}}
145
148
  */
146
149
  globalThis.segmented_bar_pixel_report = function () {
@@ -152,19 +155,23 @@ globalThis.segmented_bar_pixel_report = function () {
152
155
  let checked = 0;
153
156
  let worst = 0;
154
157
 
155
- for (const el of document.querySelectorAll('.notch, .cell')) {
156
- const r = el.getBoundingClientRect();
158
+ const miss = v => Math.abs(v - Math.round(v));
159
+
160
+ for (const el of document.querySelectorAll('.notch, .cell-strip')) {
161
+ if (el.style.display === 'none') {
162
+ continue;
163
+ }
157
164
 
158
- for (const [name, v] of [['left', r.left], ['right', r.right]]) {
159
- const device = v * dpr;
165
+ const r = el.getBoundingClientRect();
160
166
 
161
- const miss = Math.abs(device - Math.round(device));
167
+ for (const [name, v] of [['left (device)', r.left * dpr], ['width (device)', r.width * dpr], ['width (css)', r.width]]) {
168
+ const m = miss(v);
162
169
 
163
170
  checked++;
164
- worst = Math.max(worst, miss);
171
+ worst = Math.max(worst, m);
165
172
 
166
- if (miss > tolerance) {
167
- off_grid.push(`${el.className} ${name} ${device.toFixed(4)}`);
173
+ if (m > tolerance) {
174
+ off_grid.push(`${el.className} ${name} ${v.toFixed(4)}`);
168
175
  }
169
176
  }
170
177
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Where a fill of `cells` cells ends on a cell layout, in device pixels from its left edge: at the right edge of the
3
+ * last whole cell, or part way across the next one for a fraction of a cell. Never in a gap, so a fill drawn to it
4
+ * paints whole cells.
5
+ *
6
+ * @param {{cell_x_device: number[], cell_width_device: number[]}} layout from {@link segment_cell_layout}
7
+ * @param {number} cells how many cells are filled; fractional for one filling up, clamped to the layout
8
+ * @returns {number}
9
+ */
10
+ export function segment_cell_extent(layout: {
11
+ cell_x_device: number[];
12
+ cell_width_device: number[];
13
+ }, cells: number): number;
14
+ //# sourceMappingURL=segment_cell_extent.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"segment_cell_extent.d.ts","sourceRoot":"","sources":["../../../../../../src/view/elements/progress/segmented/segment_cell_extent.js"],"names":[],"mappings":"AAMA;;;;;;;;GAQG;AACH,4CAJW;IAAC,aAAa,EAAE,MAAM,EAAE,CAAC;IAAC,iBAAiB,EAAE,MAAM,EAAE,CAAA;CAAC,SACtD,MAAM,GACJ,MAAM,CA8BlB"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Cell counts within this of a whole one count as whole, so 7 of 30 rounds is 7 full cells and not 7 and a sliver
3
+ * @type {number}
4
+ */
5
+ const WHOLE_TOLERANCE = 1e-9;
6
+
7
+ /**
8
+ * Where a fill of `cells` cells ends on a cell layout, in device pixels from its left edge: at the right edge of the
9
+ * last whole cell, or part way across the next one for a fraction of a cell. Never in a gap, so a fill drawn to it
10
+ * paints whole cells.
11
+ *
12
+ * @param {{cell_x_device: number[], cell_width_device: number[]}} layout from {@link segment_cell_layout}
13
+ * @param {number} cells how many cells are filled; fractional for one filling up, clamped to the layout
14
+ * @returns {number}
15
+ */
16
+ export function segment_cell_extent(layout, cells) {
17
+ const xs = layout.cell_x_device;
18
+ const widths = layout.cell_width_device;
19
+ const count = xs.length;
20
+
21
+ if (count === 0 || !(cells > 0)) {
22
+ return 0;
23
+ }
24
+
25
+ let c = cells;
26
+ const whole = Math.round(c);
27
+
28
+ if (Math.abs(c - whole) <= WHOLE_TOLERANCE) {
29
+ c = whole;
30
+ }
31
+
32
+ if (c >= count) {
33
+ return xs[count - 1] + widths[count - 1];
34
+ }
35
+
36
+ const k = Math.floor(c);
37
+ const part = c - k;
38
+
39
+ if (part > 0) {
40
+ return xs[k] + part * widths[k];
41
+ }
42
+
43
+ return xs[k - 1] + widths[k - 1];
44
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Whether masks can be drawn here. Keyed on `OffscreenCanvas`, which every browser that draws a canvas has and
3
+ * jsdom does not: jsdom's own canvas reports "not implemented" on the console for every call, so it is never asked.
4
+ * @returns {boolean}
5
+ */
6
+ export function segment_mask_supported(): boolean;
7
+ /**
8
+ * A mask image of opaque columns, one device pixel tall, for a layer `width_device` device pixels wide: stretched
9
+ * over the layer with `mask-size: 100% 100%`, each column covers exactly the device pixels it was drawn on. CSS keeps
10
+ * painting the layer (its colour, gradient, height); the mask only decides which columns of it show.
11
+ *
12
+ * @param {number} width_device
13
+ * @param {number[]} columns [x, width, x, width, ...] in whole device pixels from the layer's left edge
14
+ * @param {number} column_count how many pairs of `columns` to draw
15
+ * @returns {string|null} a `url(...)` for `mask-image`, or null where masks cannot be drawn
16
+ */
17
+ export function segment_mask_url(width_device: number, columns: number[], column_count: number): string | null;
18
+ /**
19
+ * Put a mask from {@link segment_mask_url} on an element, sampled nearest so its columns stay whole, or take it off
20
+ * @param {HTMLElement} el
21
+ * @param {string|null} url
22
+ */
23
+ export function segment_mask_apply(el: HTMLElement, url: string | null): void;
24
+ //# sourceMappingURL=segment_mask_url.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"segment_mask_url.d.ts","sourceRoot":"","sources":["../../../../../../src/view/elements/progress/segmented/segment_mask_url.js"],"names":[],"mappings":"AAKA;;;;GAIG;AACH,0CAFa,OAAO,CAInB;AAED;;;;;;;;;GASG;AACH,+CALW,MAAM,WACN,MAAM,EAAE,gBACR,MAAM,GACJ,MAAM,GAAC,IAAI,CA4BvB;AAED;;;;GAIG;AACH,uCAHW,WAAW,OACX,MAAM,GAAC,IAAI,QAkBrB"}