vireglass 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/dist/dom.cjs +199 -16
- package/dist/dom.cjs.map +1 -1
- package/dist/dom.d.cts +24 -2
- package/dist/dom.d.ts +24 -2
- package/dist/dom.js +198 -17
- package/dist/dom.js.map +1 -1
- package/package.json +2 -1
- package/src/dom/index.ts +175 -13
- package/src/dom/interim.ts +47 -0
- package/src/dom/rim.ts +6 -4
- package/src/dom/stats.ts +28 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vireglass",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "A physically-derived liquid glass material for interfaces. One material model, three render targets: WebGL2, live DOM, and Android AGSL.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Danil Kotlaev",
|
|
@@ -140,6 +140,7 @@
|
|
|
140
140
|
"expo": "~57.0.18",
|
|
141
141
|
"expo-blur": "~57.0.2",
|
|
142
142
|
"expo-sensors": "~57.0.2",
|
|
143
|
+
"jsdom": "^30.1.3",
|
|
143
144
|
"playwright": "^1.62.1",
|
|
144
145
|
"react": "19.2.3",
|
|
145
146
|
"react-native": "0.86.2",
|
package/src/dom/index.ts
CHANGED
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
// See `docs/superpowers/specs/2026-09-19-vireglass-over-live-dom.md` for the technique and the
|
|
13
13
|
// three findings that make it work (objectBoundingBox, prefiltering the source, the 8-bit step).
|
|
14
14
|
import { ambientFrom, INK_DARK, INK_LIGHT, shouldInkBeLight, type BackdropSample } from '../adaptation';
|
|
15
|
-
import { buildDisplacementMap } from './displacement';
|
|
15
|
+
import { buildDisplacementMap, type DisplacementMap } from './displacement';
|
|
16
16
|
import { buildSpectralMap, hasSpectralEdge, type SpectralMap } from './spectral-map';
|
|
17
17
|
import { cached } from './map-cache';
|
|
18
18
|
import { roundedRectGeometry, type VireGlassGeometry } from '../geometry';
|
|
19
19
|
import { resolveOptics, VIREGLASS_MATERIAL, type VireGlassMaterial, type VireGlassOptics } from '../material';
|
|
20
20
|
import { probeBackdrop } from './probe';
|
|
21
|
+
import { bump } from './stats';
|
|
22
|
+
import { createLru, filterSignature, interimKey, quantise } from './interim';
|
|
21
23
|
import { supportsSvgBackdropFilter } from './support';
|
|
22
24
|
import { boxShadowCss } from './shadow';
|
|
23
25
|
import { bodyLayer, resolveBody, withPresence } from './body';
|
|
@@ -49,6 +51,7 @@ export * from './scroll-edge';
|
|
|
49
51
|
export * from './group';
|
|
50
52
|
export * from './spectral-map';
|
|
51
53
|
export * from './map-cache';
|
|
54
|
+
export { renderStats, resetRenderStats, type RenderStats } from './stats';
|
|
52
55
|
|
|
53
56
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
54
57
|
const FILTER_HOST_ATTR = 'data-vireglass-filters';
|
|
@@ -282,6 +285,28 @@ function fallbackFilterCss(optics: VireGlassOptics): string {
|
|
|
282
285
|
return `blur(${blurPx}px) saturate(${saturate})`;
|
|
283
286
|
}
|
|
284
287
|
|
|
288
|
+
function probe(el: HTMLElement): BackdropSample {
|
|
289
|
+
bump('probes');
|
|
290
|
+
return probeBackdrop(el);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** A map that is still decoding draws the backdrop displaced by the wrong amount, so a new one is
|
|
294
|
+
* decoded before it is shown. */
|
|
295
|
+
function decoded(url: string): Promise<void> {
|
|
296
|
+
if (typeof Image === 'undefined') return Promise.resolve();
|
|
297
|
+
const img = new Image();
|
|
298
|
+
img.src = url;
|
|
299
|
+
return typeof img.decode === 'function' ? img.decode().catch(() => undefined) : Promise.resolve();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
type InterimMap = { map: DisplacementMap; ready: boolean };
|
|
303
|
+
|
|
304
|
+
/** Interim with no call for this long ends itself: a morph that never reported landing must not
|
|
305
|
+
* leave the glass on a stretched map for good. */
|
|
306
|
+
const INTERIM_IDLE_MS = 2000;
|
|
307
|
+
|
|
308
|
+
const isEmpty = (g: VireGlassGeometry) => quantise(g.width) < 1 || quantise(g.height) < 1;
|
|
309
|
+
|
|
285
310
|
const to255 = (v: number) => Math.round(Math.min(Math.max(v, 0), 1) * 255);
|
|
286
311
|
|
|
287
312
|
function rgbCss(r: number, g: number, b: number): string {
|
|
@@ -406,6 +431,14 @@ export type GlassHandle = {
|
|
|
406
431
|
/** A glow that started on a neighbour, in page coordinates (§5). Null clears it. */
|
|
407
432
|
setNeighbourGlow(glow: { pageX: number; pageY: number; strength: number } | null): void;
|
|
408
433
|
setMorph(morph: GlassMorph | null): void;
|
|
434
|
+
/**
|
|
435
|
+
* While on, the element is changing shape every frame (a morph, a drag, a growth). It stops
|
|
436
|
+
* probing the backdrop, stops rebuilding its maps and keeps its filter: the existing map is
|
|
437
|
+
* stretched over the live box. A change of `setMorph` shapes still updates the map, at reduced
|
|
438
|
+
* resolution and only once the new image has decoded. Turning it off does one full-resolution
|
|
439
|
+
* rebuild and one probe. Repeated calls with the same value do nothing.
|
|
440
|
+
*/
|
|
441
|
+
setInterim(on: boolean): void;
|
|
409
442
|
/**
|
|
410
443
|
* Replaces the backdrop the glass adapts to, without re-attaching, and repaints. The same
|
|
411
444
|
* object as `sample` in the options; `null` goes back to probing declared backgrounds.
|
|
@@ -430,6 +463,13 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
430
463
|
let filterEl: SVGFilterElement | null = null;
|
|
431
464
|
let destroyed = false;
|
|
432
465
|
let mapKey = '';
|
|
466
|
+
let interim = false;
|
|
467
|
+
let settling = false;
|
|
468
|
+
let lastSample: BackdropSample | null = null;
|
|
469
|
+
let seq = 0;
|
|
470
|
+
let shownSeq = 0;
|
|
471
|
+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
472
|
+
const interimMaps = createLru<InterimMap>(8);
|
|
433
473
|
let morph: GlassMorph | null = null;
|
|
434
474
|
const onlyWhenTouched = opts.variant === 'interactive';
|
|
435
475
|
let appear = onlyWhenTouched ? 0 : (opts.appear ?? 1);
|
|
@@ -443,6 +483,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
443
483
|
mapKey = '';
|
|
444
484
|
deform.setElastic(elasticAllowed(opts.accessibility ?? a11y));
|
|
445
485
|
apply();
|
|
486
|
+
if (interim) requestInterimMap();
|
|
446
487
|
});
|
|
447
488
|
|
|
448
489
|
warnIfNested(el);
|
|
@@ -487,11 +528,82 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
487
528
|
return `${morph.smoothing}/${part(morph.shape)}/${part(morph.shape2)}`;
|
|
488
529
|
}
|
|
489
530
|
|
|
531
|
+
/** Drops every pending decode: whatever installs a map synchronously supersedes them. */
|
|
532
|
+
function invalidate(): void {
|
|
533
|
+
seq += 1;
|
|
534
|
+
shownSeq = seq;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function keepInterim(): void {
|
|
538
|
+
if (idleTimer !== null) clearTimeout(idleTimer);
|
|
539
|
+
idleTimer = setTimeout(leaveInterim, INTERIM_IDLE_MS);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function leaveInterim(): void {
|
|
543
|
+
if (idleTimer !== null) clearTimeout(idleTimer);
|
|
544
|
+
idleTimer = null;
|
|
545
|
+
if (destroyed || !interim) return;
|
|
546
|
+
interim = false;
|
|
547
|
+
invalidate();
|
|
548
|
+
mapKey = '';
|
|
549
|
+
settling = true;
|
|
550
|
+
apply();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function retarget(f: SVGFilterElement, map: DisplacementMap, hue: SpectralMap | null): void {
|
|
554
|
+
const images = f.querySelectorAll('feImage');
|
|
555
|
+
images[0]?.setAttribute('href', map.url);
|
|
556
|
+
if (hue) images[1]?.setAttribute('href', hue.url);
|
|
557
|
+
// EVERY pass, with its own share of the scale: see paintInteraction.
|
|
558
|
+
for (const pass of f.querySelectorAll('feDisplacementMap')) {
|
|
559
|
+
const ratio = Number(pass.getAttribute('data-vireglass-ratio') ?? 1);
|
|
560
|
+
pass.setAttribute('scale', String(map.scale * (Number.isFinite(ratio) ? ratio : 1)));
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function requestInterimMap(): void {
|
|
565
|
+
if (!svgSupported || !filterEl || destroyed) return;
|
|
566
|
+
const geometry = opts.geometry ?? measureGeometry(el);
|
|
567
|
+
if (isEmpty(geometry)) return;
|
|
568
|
+
const opticsNow = optics();
|
|
569
|
+
const key = interimKey(
|
|
570
|
+
geometry.width,
|
|
571
|
+
geometry.height,
|
|
572
|
+
geometry.cornerRadius,
|
|
573
|
+
JSON.stringify([morph?.smoothing, morph?.shape, morph?.shape2, morph?.shapes]),
|
|
574
|
+
`a${appear.toFixed(3)}:${opticsNow.refraction}:${opticsNow.refractionScale}:${opticsNow.bevelDp}`,
|
|
575
|
+
);
|
|
576
|
+
let rec = interimMaps.get(key);
|
|
577
|
+
if (!rec) {
|
|
578
|
+
bump('reducedBakes');
|
|
579
|
+
rec = { map: buildDisplacementMap(opticsNow, geometry, Math.min(1, devicePixelRatio()), morphState()), ready: false };
|
|
580
|
+
interimMaps.set(key, rec);
|
|
581
|
+
}
|
|
582
|
+
const mine = (seq += 1);
|
|
583
|
+
const record = rec;
|
|
584
|
+
const forFilter = filterEl;
|
|
585
|
+
const show = () => {
|
|
586
|
+
if (destroyed || filterEl !== forFilter || mine <= shownSeq) return;
|
|
587
|
+
shownSeq = mine;
|
|
588
|
+
retarget(forFilter, record.map, null);
|
|
589
|
+
};
|
|
590
|
+
if (record.ready) {
|
|
591
|
+
show();
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
void decoded(record.map.url).then(() => {
|
|
595
|
+
record.ready = true;
|
|
596
|
+
show();
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
490
600
|
function apply(): void {
|
|
491
601
|
if (destroyed) return;
|
|
602
|
+
if (interim) keepInterim();
|
|
492
603
|
const geometry = opts.geometry ?? measureGeometry(el);
|
|
493
604
|
const opticsNow = optics();
|
|
494
|
-
const sample = supplied ??
|
|
605
|
+
const sample = supplied ?? (interim && lastSample ? lastSample : probe(el));
|
|
606
|
+
lastSample = sample;
|
|
495
607
|
const light = shouldInkBeLight(sample, wasLight);
|
|
496
608
|
wasLight = light;
|
|
497
609
|
const body = opts.body === false ? null : bodyCss(settledOptics(), sample, light ? 1 : 0, accent, appear);
|
|
@@ -501,17 +613,44 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
501
613
|
// it on every scroll would re-run a per-pixel loop and a PNG encode for a picture that did
|
|
502
614
|
// not change; the refraction itself updates in the compositor with no JS at all.
|
|
503
615
|
const key = `a${appear.toFixed(3)}|m${morphKey()}|${geometry.width}x${geometry.height}r${geometry.cornerRadius}@${devicePixelRatio()}:${opticsNow.refraction}:${opticsNow.refractionScale}:${opticsNow.bevelDp}:${opticsNow.blur}`;
|
|
504
|
-
if (
|
|
616
|
+
if (interim && filterEl) {
|
|
617
|
+
// The filter keeps its map; feImage stretches it over the live box.
|
|
618
|
+
} else if (isEmpty(geometry)) {
|
|
619
|
+
// Nothing to refract in a box of no size; the next resize bakes it.
|
|
620
|
+
} else if (key !== mapKey || !filterEl) {
|
|
505
621
|
// Keyed by exactly what the maps are functions of, which is exactly the cache key above.
|
|
506
|
-
const map = cached(`d|${key}`, () =>
|
|
507
|
-
|
|
622
|
+
const map = cached(`d|${key}`, () => {
|
|
623
|
+
bump('fullBakes');
|
|
624
|
+
return buildDisplacementMap(opticsNow, geometry, devicePixelRatio(), morphState());
|
|
625
|
+
});
|
|
508
626
|
const hue = hasSpectralEdge(opticsNow)
|
|
509
|
-
? cached(`h|${key}`, () =>
|
|
627
|
+
? cached(`h|${key}`, () => {
|
|
628
|
+
bump('fullBakes');
|
|
629
|
+
return buildSpectralMap(opticsNow, geometry, devicePixelRatio());
|
|
630
|
+
})
|
|
510
631
|
: null;
|
|
511
|
-
const
|
|
512
|
-
if (filterEl?.
|
|
513
|
-
|
|
514
|
-
|
|
632
|
+
const signature = filterSignature(opticsNow.blur, opticsNow.ior, opticsNow.iorSpread, !!hue);
|
|
633
|
+
if (settling && filterEl?.getAttribute('data-vireglass-signature') === signature) {
|
|
634
|
+
settling = false;
|
|
635
|
+
const mine = (seq += 1);
|
|
636
|
+
const forFilter = filterEl;
|
|
637
|
+
const forKey = key;
|
|
638
|
+
void Promise.all([decoded(map.url), hue ? decoded(hue.url) : undefined]).then(() => {
|
|
639
|
+
if (destroyed || filterEl !== forFilter || mapKey !== forKey || mine <= shownSeq) return;
|
|
640
|
+
shownSeq = mine;
|
|
641
|
+
retarget(forFilter, map, hue);
|
|
642
|
+
});
|
|
643
|
+
} else {
|
|
644
|
+
settling = false;
|
|
645
|
+
invalidate();
|
|
646
|
+
const defs = getFilterHost(host).querySelector('defs') as SVGDefsElement;
|
|
647
|
+
const next = buildFilterElement(id, map.url, opticsNow, map.scale, hue);
|
|
648
|
+
next.setAttribute('data-vireglass-signature', signature);
|
|
649
|
+
if (filterEl?.parentNode) defs.replaceChild(next, filterEl);
|
|
650
|
+
else defs.appendChild(next);
|
|
651
|
+
bump('filterSwaps');
|
|
652
|
+
filterEl = next;
|
|
653
|
+
}
|
|
515
654
|
mapKey = key;
|
|
516
655
|
}
|
|
517
656
|
el.style.setProperty('backdrop-filter', `url(#${id})`);
|
|
@@ -548,7 +687,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
548
687
|
el.style.setProperty(
|
|
549
688
|
'--vireglass-rim',
|
|
550
689
|
(opts.accessibility ?? a11y).increaseContrast
|
|
551
|
-
? contrastRimCss(pole, contrastRimLuma(pole))
|
|
690
|
+
? contrastRimCss(pole, contrastRimLuma(pole), appear)
|
|
552
691
|
: rimGradientCss(opticsNow, [tr, tg, tb], opts.light ?? REST_LIGHT, appear),
|
|
553
692
|
);
|
|
554
693
|
el.style.setProperty('--vireglass-rim-width', `${RIM_WIDTH_PX}px`);
|
|
@@ -587,10 +726,12 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
587
726
|
// applying because a finger is down.
|
|
588
727
|
const risen = optics({ ...opts.material, roughness: (opts.material?.roughness ?? VIREGLASS_MATERIAL.roughness) * rise.solid });
|
|
589
728
|
|
|
729
|
+
if (interim) keepInterim();
|
|
590
730
|
// A reduced-resolution map while the finger is down: `feImage` stretches it over the element
|
|
591
731
|
// regardless, the displacement is smooth, and a full device-pixel rebuild plus a PNG encode
|
|
592
732
|
// every frame does not hold a frame budget.
|
|
593
|
-
|
|
733
|
+
bump('reducedBakes');
|
|
734
|
+
const map = buildDisplacementMap(risen, geometry, 1, {
|
|
594
735
|
...morphState(),
|
|
595
736
|
touch: {
|
|
596
737
|
x: sampleOut.touchX,
|
|
@@ -603,6 +744,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
603
744
|
wavePhase: sampleOut.wavePhase,
|
|
604
745
|
},
|
|
605
746
|
});
|
|
747
|
+
invalidate();
|
|
606
748
|
if (filterEl) {
|
|
607
749
|
const feImage = filterEl.querySelector('feImage');
|
|
608
750
|
feImage?.setAttribute('href', map.url);
|
|
@@ -641,7 +783,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
641
783
|
el.style.setProperty('--vireglass-glow-opacity', '0');
|
|
642
784
|
return;
|
|
643
785
|
}
|
|
644
|
-
const [gr, gg, gb] = ambientFrom(supplied ??
|
|
786
|
+
const [gr, gg, gb] = ambientFrom(supplied ?? (interim && lastSample ? lastSample : probe(el)));
|
|
645
787
|
const cx = ((x + geometry.width / 2) / geometry.width) * 100;
|
|
646
788
|
const cy = ((y + geometry.height / 2) / geometry.height) * 100;
|
|
647
789
|
const reach = halfMinDp(geometry) * TOUCH_RADIUS_FRACTION;
|
|
@@ -670,7 +812,9 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
670
812
|
// Settled: put the full-resolution map back and drop the glow.
|
|
671
813
|
el.style.setProperty('--vireglass-glow-opacity', '0');
|
|
672
814
|
opts.onGlow?.(null);
|
|
815
|
+
settling = !interim;
|
|
673
816
|
apply();
|
|
817
|
+
if (interim) requestInterimMap();
|
|
674
818
|
return;
|
|
675
819
|
}
|
|
676
820
|
frame = requestAnimationFrame(tick);
|
|
@@ -732,6 +876,9 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
732
876
|
|
|
733
877
|
function destroy(): void {
|
|
734
878
|
destroyed = true;
|
|
879
|
+
invalidate();
|
|
880
|
+
if (idleTimer !== null) clearTimeout(idleTimer);
|
|
881
|
+
idleTimer = null;
|
|
735
882
|
stopWatchingA11y();
|
|
736
883
|
if (attached.get(el) === handle) attached.delete(el);
|
|
737
884
|
if (frame !== null) cancelAnimationFrame(frame);
|
|
@@ -774,6 +921,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
774
921
|
if (next === appear) return;
|
|
775
922
|
appear = next;
|
|
776
923
|
apply();
|
|
924
|
+
if (interim) requestInterimMap();
|
|
777
925
|
},
|
|
778
926
|
setAccent(next) {
|
|
779
927
|
accent = next ?? undefined;
|
|
@@ -796,9 +944,23 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
796
944
|
},
|
|
797
945
|
setMorph(next) {
|
|
798
946
|
morph = next && next.smoothing > 0 ? next : null;
|
|
947
|
+
if (interim) {
|
|
948
|
+
apply();
|
|
949
|
+
requestInterimMap();
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
799
952
|
mapKey = '';
|
|
800
953
|
apply();
|
|
801
954
|
},
|
|
955
|
+
setInterim(on) {
|
|
956
|
+
if (destroyed || on === interim) return;
|
|
957
|
+
if (on) {
|
|
958
|
+
interim = true;
|
|
959
|
+
keepInterim();
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
leaveInterim();
|
|
963
|
+
},
|
|
802
964
|
setSample(next) {
|
|
803
965
|
supplied = next ?? undefined;
|
|
804
966
|
apply();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Geometry snapped to a grid, so a morph that moves by a fraction of a pixel reuses a map. */
|
|
2
|
+
export function quantise(value: number, step = 2): number {
|
|
3
|
+
return Math.round(value / step) * step;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function interimKey(
|
|
7
|
+
width: number,
|
|
8
|
+
height: number,
|
|
9
|
+
cornerRadius: number,
|
|
10
|
+
morph: string,
|
|
11
|
+
extra: string,
|
|
12
|
+
): string {
|
|
13
|
+
return `i|${quantise(width)}x${quantise(height)}r${quantise(cornerRadius)}|${morph}|${extra}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What a filter's structure depends on. A filter with the same signature can take a new map in
|
|
18
|
+
* place; a different one has to be rebuilt.
|
|
19
|
+
*/
|
|
20
|
+
export function filterSignature(blur: number, ior: number, iorSpread: number, hasHue: boolean): string {
|
|
21
|
+
return `${blur}|${ior}|${iorSpread > 1e-4 ? iorSpread : 0}|${hasHue ? 'h' : '-'}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A small least-recently-used store, so a long morph cannot evict the resting maps. */
|
|
25
|
+
export function createLru<T>(limit: number) {
|
|
26
|
+
const store = new Map<string, T>();
|
|
27
|
+
return {
|
|
28
|
+
get(key: string): T | undefined {
|
|
29
|
+
const hit = store.get(key);
|
|
30
|
+
if (hit === undefined) return undefined;
|
|
31
|
+
store.delete(key);
|
|
32
|
+
store.set(key, hit);
|
|
33
|
+
return hit;
|
|
34
|
+
},
|
|
35
|
+
set(key: string, value: T): void {
|
|
36
|
+
store.delete(key);
|
|
37
|
+
store.set(key, value);
|
|
38
|
+
if (store.size > limit) {
|
|
39
|
+
const oldest = store.keys().next();
|
|
40
|
+
if (!oldest.done) store.delete(oldest.value);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
get size(): number {
|
|
44
|
+
return store.size;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
package/src/dom/rim.ts
CHANGED
|
@@ -129,10 +129,12 @@ export const RIM_WIDTH_PX = RIM.widthPx;
|
|
|
129
129
|
* everything around it. It has to hold at every angle, including the one facing away from the key
|
|
130
130
|
* light where the ordinary rim falls back to a dim dark line.
|
|
131
131
|
*/
|
|
132
|
-
export function contrastRimCss(bodyLuma: number, rimLuma: number): string {
|
|
132
|
+
export function contrastRimCss(bodyLuma: number, rimLuma: number, appear = 1): string {
|
|
133
133
|
const v = to255(rimLuma);
|
|
134
|
-
// Opaque on purpose. A border that lets the backdrop through is the
|
|
135
|
-
// to replace, and the body behind it is already at its own pole.
|
|
134
|
+
// Opaque on purpose once the element is there. A border that lets the backdrop through is the
|
|
135
|
+
// thing this setting exists to replace, and the body behind it is already at its own pole.
|
|
136
136
|
void bodyLuma;
|
|
137
|
-
|
|
137
|
+
const present = clamp01(appear);
|
|
138
|
+
const colour = present >= 1 ? `rgb(${v},${v},${v})` : `rgba(${v},${v},${v},${present.toFixed(3)})`;
|
|
139
|
+
return `conic-gradient(${colour} 0deg, ${colour} 360deg)`;
|
|
138
140
|
}
|
package/src/dom/stats.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type RenderStats = {
|
|
2
|
+
/** `elementsFromPoint` backdrop probes. */
|
|
3
|
+
probes: number;
|
|
4
|
+
/** Displacement maps baked at full device resolution (cache hits are not bakes). */
|
|
5
|
+
fullBakes: number;
|
|
6
|
+
/** Displacement maps baked at reduced resolution: interim morphs and the finger path. */
|
|
7
|
+
reducedBakes: number;
|
|
8
|
+
/** Filter elements put into the shared `<defs>`, replacing or adding. */
|
|
9
|
+
filterSwaps: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const counts: RenderStats = { probes: 0, fullBakes: 0, reducedBakes: 0, filterSwaps: 0 };
|
|
13
|
+
|
|
14
|
+
/** The work the renderer has done since the last reset; the hook for a consumer's performance gate. */
|
|
15
|
+
export function renderStats(): RenderStats {
|
|
16
|
+
return { ...counts };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resetRenderStats(): void {
|
|
20
|
+
counts.probes = 0;
|
|
21
|
+
counts.fullBakes = 0;
|
|
22
|
+
counts.reducedBakes = 0;
|
|
23
|
+
counts.filterSwaps = 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function bump(key: keyof RenderStats): void {
|
|
27
|
+
counts[key] += 1;
|
|
28
|
+
}
|