@zkmake/three-meter 0.9.2 → 0.11.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.
@@ -1,17 +1,24 @@
1
1
  //#region src/ui/budgets.ts
2
2
  const DEFAULT_TARGET_FPS = 60;
3
- const FLOORS = /* @__PURE__ */ new Set(["fps", "low"]);
4
- const resolveBudgets = (budgets) => {
5
- const targetFps = budgets && budgets.targetFps || DEFAULT_TARGET_FPS;
3
+ const FLOORS = /* @__PURE__ */ new Set([
4
+ "fps",
5
+ "headroom",
6
+ "low"
7
+ ]);
8
+ /** `detectedHz` is the monitor's refresh reading; an explicit `targetFps` wins over it. */
9
+ const resolveBudgets = (budgets, detectedHz = null) => {
10
+ const targetFps = budgets && budgets.targetFps || detectedHz || DEFAULT_TARGET_FPS;
6
11
  const frameMs = 1e3 / targetFps;
7
12
  if (budgets === false) return {
8
13
  frameMs,
9
- limits: {}
14
+ limits: {},
15
+ targetFps
10
16
  };
11
17
  const merged = {
12
18
  cpu: frameMs,
13
19
  fps: targetFps * .95,
14
20
  gpu: frameMs,
21
+ headroom: 0,
15
22
  low: targetFps / 2,
16
23
  p99: frameMs * 1.5,
17
24
  ...budgets
@@ -20,7 +27,8 @@ const resolveBudgets = (budgets) => {
20
27
  for (const [key, limit] of Object.entries(merged)) if (key !== "targetFps" && typeof limit === "number" && Number.isFinite(limit)) limits[key] = limit;
21
28
  return {
22
29
  frameMs,
23
- limits
30
+ limits,
31
+ targetFps
24
32
  };
25
33
  };
26
34
  const isFloor = (key) => FLOORS.has(key);
@@ -486,6 +494,9 @@ const dockPanel = (element, options = {}) => {
486
494
  //#endregion
487
495
  //#region src/ui/icons.ts
488
496
  const ICON_MARKUP = {
497
+ eye: "<path d=\"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>",
498
+ eyeOff: "<path d=\"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49\"/><path d=\"M14.084 14.158a3 3 0 0 1-4.242-4.242\"/><path d=\"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143\"/><path d=\"m2 2 20 20\"/>",
499
+ hourglass: "<path d=\"M5 22h14\"/><path d=\"M5 2h14\"/><path d=\"M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22\"/><path d=\"M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2\"/>",
489
500
  flame: "<path d=\"M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z\"/>",
490
501
  activity: "<path d=\"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2\"/>",
491
502
  box: "<path d=\"M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z\"/><path d=\"m3.3 7 8.7 5 8.7-5\"/><path d=\"M12 22V12\"/>",
@@ -526,991 +537,32 @@ const createIcon = (name, className) => {
526
537
  return svg;
527
538
  };
528
539
  //#endregion
529
- //#region package.json
530
- var name = "@zkmake/three-meter";
531
- var version = "0.9.2";
532
- //#endregion
533
- //#region src/ui/clipboard.ts
534
- /**
535
- * Copy text from a click handler. The async Clipboard API needs a secure
536
- * context and the `clipboard-write` permission; a dev server opened over a LAN
537
- * IP has neither, and some embeds deny the permission. The fallback is the old
538
- * select-a-textarea `execCommand("copy")`, still honoured inside a user gesture.
539
- */
540
- const copyWithSelection = (text) => {
541
- const textarea = document.createElement("textarea");
542
- textarea.value = text;
543
- textarea.setAttribute("readonly", "");
544
- textarea.style.position = "fixed";
545
- textarea.style.opacity = "0";
546
- textarea.style.pointerEvents = "none";
547
- const previous = document.activeElement;
548
- document.body.append(textarea);
549
- textarea.select();
550
- try {
551
- return document.execCommand("copy");
552
- } catch {
553
- return false;
554
- } finally {
555
- textarea.remove();
556
- previous?.focus?.();
557
- }
558
- };
559
- const copyText = async (text) => {
560
- try {
561
- await navigator.clipboard.writeText(text);
562
- return true;
563
- } catch {
564
- return copyWithSelection(text);
565
- }
566
- };
567
- //#endregion
568
- //#region src/ui/format.ts
569
- /**
570
- * Number formatting for the card. Counts stay exact with thousands separators
571
- * while they fit the compact grid's 6ch value column (`99,999`), then switch
572
- * to compact notation (`250K`, `1.4M`, `12.3M`, `1.2B`) so a triangle count in
573
- * the millions can't run into the neighbouring label.
574
- */
575
- const COMPACT_FROM = 1e5;
576
- const exact = new Intl.NumberFormat("en-US");
577
- const compact = new Intl.NumberFormat("en-US", {
578
- maximumFractionDigits: 1,
579
- notation: "compact"
580
- });
581
- const formatCount = (value) => Math.abs(value) < COMPACT_FROM ? exact.format(value) : compact.format(value);
582
- //#endregion
583
- //#region src/ui/metric-help.ts
584
- /**
585
- * One plain-language line per metric: what it is, and what a bad reading
586
- * means. Shown as the label's tooltip and, with "explain metrics" on, under
587
- * each row. Keyed like the stat rows; the graphs reuse `fps`, `cpu`, `gpu`.
588
- */
589
- const METRIC_HELP = {
590
- calls: "Separate draw commands sent to the GPU this frame. Fewer is better: merge or instance meshes that share a material.",
591
- cpu: "JavaScript time per frame: your update code plus three.js preparing the render. It has to fit the frame budget (16.7 ms at 60 FPS).",
592
- fps: "Frames drawn per second. Higher is smoother; most displays top out at 60 or 120.",
593
- geometries: "Geometries held in GPU memory. Should level off; a steady climb is a leak, so dispose() what you remove.",
594
- gpu: "Time the graphics card spent drawing the frame. High means shaders, overdraw or resolution are the bottleneck.",
595
- hitches: "Frames that took over twice as long as usual, in the last 1,000 frames. Each one is a visible stutter.",
596
- lines: "Line segments drawn this frame.",
597
- low: "Frame rate during the slowest 1% of frames. Far below FPS means stutter the average hides.",
598
- p99: "99% of frames finished within this many ms. Above the frame budget (16.7 ms at 60 FPS) means dropped frames.",
599
- passes: "Times renderer.render() ran this frame. Shadows, post-processing and extra cameras each add passes.",
600
- points: "Points drawn this frame.",
601
- shaders: "Compiled shader programs. A jump mid-session usually means a stutter while a new material compiled.",
602
- textures: "Textures held in GPU memory. Should level off; a steady climb is a leak, so dispose() what you remove.",
603
- triangles: "Triangles drawn this frame. Mostly GPU cost: use lower-poly models or level of detail to cut it."
604
- };
605
- //#endregion
606
- //#region src/ui/report.ts
540
+ //#region src/ui/styles.ts
607
541
  /**
608
- * A plain-text snapshot of the monitor for bug reports: versions, backend and
609
- * GPU, the current sample, stutter stats, the scene's top meshes and the page. Fenced as a `text`
610
- * code block so GitHub and Slack keep the line breaks.
542
+ * The HUD stylesheet as a string, so the package works without a bundler and
543
+ * injects itself once per document. `.perf-monitor` is the metrics card,
544
+ * `.perf-hud` the floating host around it. Both namespaced so a host page can
545
+ * override safely; the `--perf-*` custom properties are the theming surface.
546
+ *
547
+ * Both elements carry `data-theme="dark" | "light"` (see `HudTheme`) and each
548
+ * resolves its own tokens from it, so the card also themes when mounted on
549
+ * its own, outside a `.perf-hud`.
611
550
  */
612
- const BACKEND_LABELS = {
613
- webgl: "WebGL",
614
- webgl2: "WebGL2",
615
- webgpu: "WebGPU"
616
- };
617
- /** `WebGPU`, `WebGL2`, `WebGL2 fallback`, or `""` before the backend settles. */
618
- const backendLabel = (environment) => {
619
- if (!environment.backend) return "";
620
- const label = BACKEND_LABELS[environment.backend];
621
- return environment.fallback ? `${label} fallback` : label;
622
- };
623
- const ms = (value) => `${value.toFixed(1)} ms`;
624
- /** Top meshes by draw calls, three at most: `Top: cubes ×2,000 (1 call, 24K tris) · …`. */
625
- const topMeshes = (cost) => `Top: ${cost.meshes.slice(0, 3).map((entry) => {
626
- const copies = entry.instances > 1 ? ` ×${formatCount(entry.instances)}` : "";
627
- const calls = `${formatCount(entry.calls)} ${entry.calls === 1 ? "call" : "calls"}`;
628
- return `${entry.label}${copies} (${calls}, ${formatCount(entry.triangles)} tris)`;
629
- }).join(" · ")}`;
630
- const renderReport = ({ cost, environment, page, sample, stats }) => {
631
- const { render, resources } = sample;
632
- const heading = [
633
- `three-meter v${version}`,
634
- environment.three ? `three r${environment.three}` : "",
635
- backendLabel(environment),
636
- environment.gpu ?? ""
637
- ].filter(Boolean);
638
- const frames = stats.frames === 0 ? `FPS ${Math.round(sample.fps)}` : [
639
- `FPS ${Math.round(sample.fps)}`,
640
- `1% low ${Math.round(stats.lowFps)}`,
641
- `p99 ${ms(stats.p99Ms)}`,
642
- `hitches ${formatCount(stats.hitches)} / ${formatCount(stats.frames)} frames`
643
- ].join(" · ");
644
- const lines = [
645
- heading.join(" · "),
646
- frames,
647
- `CPU ${ms(sample.cpu)} · GPU ${sample.gpu.available ? ms(sample.gpu.ms) : "unavailable"}`,
648
- [
649
- `Calls ${formatCount(render.calls)}`,
650
- `passes ${formatCount(render.passes)}`,
651
- `triangles ${formatCount(render.triangles)}`,
652
- `lines ${formatCount(render.lines)}`,
653
- `points ${formatCount(render.points)}`
654
- ].join(" · "),
655
- [
656
- `Geometries ${formatCount(resources.geometries)}`,
657
- `textures ${formatCount(resources.textures)}`,
658
- `shaders ${formatCount(resources.programs)}`
659
- ].join(" · ")
660
- ];
661
- if (cost && cost.meshes.length > 0) lines.push(topMeshes(cost));
662
- if (page) lines.push(`Viewport ${page.width}×${page.height} @${page.dpr}x`, page.userAgent);
663
- return [
664
- "```text",
665
- ...lines,
666
- "```"
667
- ].join("\n");
668
- };
669
- const readPage = () => typeof window === "undefined" ? null : {
670
- dpr: window.devicePixelRatio || 1,
671
- height: window.innerHeight,
672
- userAgent: navigator.userAgent,
673
- width: window.innerWidth
674
- };
675
- /** The report for `monitor` right now, as Markdown ready to paste into an issue. */
676
- const formatReport = (monitor) => renderReport({
677
- cost: monitor.getSceneCost(),
678
- environment: monitor.getEnvironment(),
679
- page: readPage(),
680
- sample: monitor.getSample(),
681
- stats: monitor.getFrameStats()
682
- });
683
- //#endregion
684
- //#region src/ui/sparkline.ts
685
- function drawSparkline(ctx, width, height, values, style, guide) {
686
- ctx.clearRect(0, 0, width, height);
687
- if (values.length < 2) return;
688
- let max = 0;
689
- for (const value of values) if (value > max) max = value;
690
- const range = max * 1.1 || 1;
691
- const stepX = width / (values.length - 1);
692
- const toY = (value) => height - value / range * height;
693
- ctx.beginPath();
694
- ctx.moveTo(0, toY(values[0]));
695
- for (let index = 1; index < values.length; index += 1) ctx.lineTo(index * stepX, toY(values[index]));
696
- ctx.lineWidth = 1;
697
- ctx.strokeStyle = style.stroke;
698
- ctx.stroke();
699
- ctx.lineTo(width, height);
700
- ctx.lineTo(0, height);
701
- ctx.closePath();
702
- ctx.fillStyle = style.fill;
703
- ctx.fill();
704
- if (guide && guide.value > 0 && guide.value <= range) {
705
- const y = Math.round(toY(guide.value)) + .5;
706
- ctx.save();
707
- ctx.setLineDash([3, 3]);
708
- ctx.strokeStyle = guide.color;
709
- ctx.beginPath();
710
- ctx.moveTo(0, y);
711
- ctx.lineTo(width, y);
712
- ctx.stroke();
713
- ctx.restore();
714
- }
715
- }
716
- //#endregion
717
- //#region src/ui/performance-view.ts
718
- const TIMINGS = [
719
- {
720
- format: (value) => Math.round(value).toString(),
721
- icon: "gauge",
722
- label: "FPS",
723
- metric: "fps",
724
- style: {
725
- dark: {
726
- fill: "rgba(74, 222, 128, 0.15)",
727
- stroke: "#4ade80"
728
- },
729
- light: {
730
- fill: "rgba(22, 163, 74, 0.12)",
731
- stroke: "#16a34a"
732
- }
733
- },
734
- unit: ""
735
- },
736
- {
737
- format: (value) => value.toFixed(1),
738
- icon: "cpu",
739
- label: "CPU",
740
- metric: "cpu",
741
- style: {
742
- dark: {
743
- fill: "rgba(96, 165, 250, 0.15)",
744
- stroke: "#60a5fa"
745
- },
746
- light: {
747
- fill: "rgba(37, 99, 235, 0.12)",
748
- stroke: "#2563eb"
749
- }
750
- },
751
- unit: "ms"
752
- },
753
- {
754
- format: (value) => value.toFixed(1),
755
- icon: "zap",
756
- label: "GPU",
757
- metric: "gpu",
758
- style: {
759
- dark: {
760
- fill: "rgba(244, 114, 182, 0.15)",
761
- stroke: "#f472b6"
762
- },
763
- light: {
764
- fill: "rgba(219, 39, 119, 0.12)",
765
- stroke: "#db2777"
766
- }
767
- },
768
- unit: "ms"
769
- }
770
- ];
771
- const THEME_OPTIONS = [
772
- {
773
- icon: "sun",
774
- label: "Light theme",
775
- mode: "light"
776
- },
777
- {
778
- icon: "monitor",
779
- label: "Match the system theme",
780
- mode: "system"
781
- },
782
- {
783
- icon: "moon",
784
- label: "Dark theme",
785
- mode: "dark"
786
- }
787
- ];
788
- /** The monorepo tags each package's releases `@zkmake/three-meter@0.9.2`. */
789
- const RELEASE_URL = `https://github.com/zkmake/three-kit/releases/tag/${encodeURIComponent(`${name}@${version}`)}`;
790
- /** How long the report button says `copied` / `failed` before reading `copy` again. */
791
- const COPY_FEEDBACK_MS = 1500;
792
- /** Walking the scene graph isn't free; the top-costs list refreshes at 2 Hz while open. */
793
- const COSTS_INTERVAL_MS = 500;
794
- const COSTS_ROWS = 5;
795
- const COST_GROUPS = [{
796
- group: "meshes",
797
- label: "meshes"
798
- }, {
799
- group: "materials",
800
- label: "materials"
801
- }];
802
- const COSTS_HELP = "The scene's biggest draw costs in the main render pass, estimated from the scene graph. Hidden and off-screen objects are left out; shadow and post-processing passes aren't counted. Click a row to log its objects to the console.";
803
- /** `×2,000` for instances or copies; nothing for a single object. */
804
- const copiesOf = (entry) => entry.instances > 1 ? `×${formatCount(entry.instances)}` : "";
805
- const costTitle = (entry) => {
806
- const objects = entry.objects.length;
807
- const shape = entry.instances > objects ? `${formatCount(entry.instances)} instances in ${objects === 1 ? "one object" : `${objects} objects`}` : `${formatCount(objects)} ${objects === 1 ? "object" : "separate objects"}`;
808
- return `${entry.label}: ${shape}, ${formatCount(entry.calls)} draw ${entry.calls === 1 ? "call" : "calls"}, ${formatCount(entry.triangles)} triangles. Click to log to the console.`;
809
- };
810
- const readTiming = (sample, metric) => {
811
- switch (metric) {
812
- case "fps": return sample.fps;
813
- case "cpu": return sample.cpu;
814
- case "gpu": return sample.gpu.ms;
815
- }
816
- };
817
- const isTimingAvailable = (sample, metric) => metric !== "gpu" || sample.gpu.available;
818
- const whole = (value) => Math.round(value).toString();
819
- const tenths = (value) => value.toFixed(1);
820
- const NUMBERS = [
821
- {
822
- format: whole,
823
- icon: "gauge",
824
- key: "fps",
825
- label: "FPS",
826
- value: (sample) => sample.fps || null
827
- },
828
- {
829
- format: formatCount,
830
- icon: "layers",
831
- key: "calls",
832
- label: "Calls",
833
- value: (sample) => sample.render.calls
834
- },
835
- {
836
- format: tenths,
837
- icon: "cpu",
838
- key: "cpu",
839
- label: "CPU",
840
- unit: "ms",
841
- value: (sample) => sample.cpu
842
- },
843
- {
844
- format: tenths,
845
- icon: "zap",
846
- key: "gpu",
847
- label: "GPU",
848
- unit: "ms",
849
- value: (sample) => sample.gpu.available ? sample.gpu.ms : null
850
- },
851
- {
852
- format: whole,
853
- icon: "trendingDown",
854
- key: "low",
855
- label: "1% low",
856
- value: (_sample, stats) => stats.frames ? stats.lowFps : null
857
- },
858
- {
859
- format: tenths,
860
- icon: "timer",
861
- key: "p99",
862
- label: "Frame p99",
863
- unit: "ms",
864
- value: (_sample, stats) => stats.frames ? stats.p99Ms : null
865
- },
866
- {
867
- format: formatCount,
868
- icon: "activity",
869
- key: "hitches",
870
- label: "Hitches",
871
- value: (_sample, stats) => stats.frames ? stats.hitches : null
872
- },
873
- {
874
- format: formatCount,
875
- icon: "triangle",
876
- key: "triangles",
877
- label: "Triangles",
878
- value: (sample) => sample.render.triangles
879
- },
880
- {
881
- format: formatCount,
882
- icon: "spline",
883
- key: "lines",
884
- label: "Lines",
885
- value: (sample) => sample.render.lines
886
- },
887
- {
888
- format: formatCount,
889
- icon: "circleDot",
890
- key: "points",
891
- label: "Points",
892
- value: (sample) => sample.render.points
893
- },
894
- {
895
- format: formatCount,
896
- icon: "repeat",
897
- key: "passes",
898
- label: "Render passes",
899
- value: (sample) => sample.render.passes
900
- },
901
- {
902
- format: formatCount,
903
- icon: "box",
904
- key: "geometries",
905
- label: "Geometries",
906
- value: (sample) => sample.resources.geometries
907
- },
908
- {
909
- format: formatCount,
910
- icon: "image",
911
- key: "textures",
912
- label: "Textures",
913
- value: (sample) => sample.resources.textures
914
- },
915
- {
916
- format: formatCount,
917
- icon: "palette",
918
- key: "shaders",
919
- label: "Shaders",
920
- value: (sample) => sample.resources.programs
921
- }
922
- ];
923
- const NUMBER_BY_KEY = new Map(NUMBERS.map((config) => [config.key, config]));
924
- /** Canvas can't read CSS custom properties; the budget line's amber per theme. */
925
- const GUIDE_COLOR = {
926
- dark: "rgba(245, 158, 11, 0.75)",
927
- light: "rgba(180, 83, 9, 0.7)"
928
- };
929
- /** `Budget: at most 16.7 ms` / `at least 57`, or `""` without one. */
930
- const budgetTitle = (budgets, key) => {
931
- const limit = budgets.limits[key];
932
- const config = NUMBER_BY_KEY.get(key);
933
- if (limit === void 0 || !config) return "";
934
- return `Budget: ${isFloor(key) ? "at least" : "at most"} ${config.format(limit)}${config.unit ? ` ${config.unit}` : ""}`;
935
- };
936
- /** The sample value a timing graph shows, `null` when not known. */
937
- const timingValue = (sample, metric) => {
938
- if (!isTimingAvailable(sample, metric)) return null;
939
- const value = readTiming(sample, metric);
940
- return metric === "fps" && value === 0 ? null : value;
941
- };
942
- /**
943
- * Host-agnostic widget that paints {@link Sample}s. Mount {@link element}
944
- * anywhere; the view drives its own throttled repaint. Compact is the floating
945
- * HUD; full is the checkbox list that chooses what compact shows. Styles are
946
- * not injected here. Call `injectStyles()` or use `mountPerfHud`.
947
- */
948
- var PerformanceView = class {
949
- element;
950
- settings;
951
- theme;
952
- ownsTheme;
953
- unsubscribeTheme;
954
- monitor;
955
- minIntervalMs;
956
- mode;
957
- graphValueEls = /* @__PURE__ */ new Map();
958
- graphCanvases = /* @__PURE__ */ new Map();
959
- graphCheckboxes = /* @__PURE__ */ new Map();
960
- statValueEls = /* @__PURE__ */ new Map();
961
- statCheckboxes = /* @__PURE__ */ new Map();
962
- infoCheckbox;
963
- themeRadios = /* @__PURE__ */ new Map();
964
- footerThreeEl;
965
- footerHardwareEl;
966
- footerBackendEl;
967
- footerGpuEl;
968
- lastEnvironmentKey = "";
969
- hudGraphsEl;
970
- hudGridEl;
971
- hudGraphs = /* @__PURE__ */ new Map();
972
- hudNumberEls = /* @__PURE__ */ new Map();
973
- budgets;
974
- rafId = null;
975
- lastPaintAt = 0;
976
- resizeObserver;
977
- unsubscribe;
978
- costsOpen = false;
979
- costGroup = "meshes";
980
- costSort = "calls";
981
- lastCostsAt = -Infinity;
982
- costsBodyEl;
983
- costsListEl;
984
- costGroupRadios = /* @__PURE__ */ new Map();
985
- costSortButtons = /* @__PURE__ */ new Map();
986
- constructor(options) {
987
- this.monitor = options.monitor;
988
- this.settings = options.settings ?? new HudSettings();
989
- this.ownsTheme = !options.theme;
990
- this.theme = options.theme ?? new HudTheme();
991
- this.theme.setOverride(this.settings.theme);
992
- this.mode = options.mode ?? "full";
993
- this.minIntervalMs = 1e3 / (options.refreshHz ?? 10);
994
- this.budgets = resolveBudgets(options.budgets);
995
- this.element = document.createElement("div");
996
- this.element.className = "perf-monitor";
997
- this.applyModeClass();
998
- this.resizeObserver = new ResizeObserver(() => this.resizeCanvases());
999
- this.build();
1000
- this.applyInfoClass();
1001
- this.rebuildHud();
1002
- this.onThemeChanged();
1003
- this.unsubscribe = this.settings.subscribe(() => this.onSelectionChanged());
1004
- this.unsubscribeTheme = this.theme.subscribe(() => this.onThemeChanged());
1005
- }
1006
- start() {
1007
- if (this.rafId === null) this.rafId = requestAnimationFrame(this.frame);
1008
- }
1009
- stop() {
1010
- if (this.rafId !== null) {
1011
- cancelAnimationFrame(this.rafId);
1012
- this.rafId = null;
1013
- }
1014
- }
1015
- setMode(mode) {
1016
- if (mode === this.mode) return;
1017
- this.mode = mode;
1018
- this.applyModeClass();
1019
- this.resizeCanvases();
1020
- }
1021
- getMode() {
1022
- return this.mode;
1023
- }
1024
- /** Replace the budgets; `false` turns them all off. Applies on the next paint. */
1025
- setBudgets(budgets) {
1026
- this.budgets = resolveBudgets(budgets);
1027
- this.applyBudgetTitles();
1028
- }
1029
- dispose() {
1030
- this.stop();
1031
- this.unsubscribe();
1032
- this.unsubscribeTheme();
1033
- if (this.ownsTheme) this.theme.dispose();
1034
- this.resizeObserver.disconnect();
1035
- this.element.remove();
1036
- }
1037
- frame = (now) => {
1038
- this.rafId = requestAnimationFrame(this.frame);
1039
- if (now - this.lastPaintAt < this.minIntervalMs) return;
1040
- this.lastPaintAt = now;
1041
- this.render();
1042
- };
1043
- render() {
1044
- const sample = this.monitor.getSample();
1045
- if (this.mode === "compact") {
1046
- this.renderHud(sample);
1047
- if (this.settings.info) this.renderFooter();
1048
- return;
1049
- }
1050
- for (const config of TIMINGS) this.paintGraph(config, sample, this.graphValueEls.get(config.metric), this.graphCanvases.get(config.metric), "unavailable");
1051
- const stats = this.monitor.getFrameStats();
1052
- for (const config of NUMBERS) this.paintNumber(config, this.statValueEls.get(config.key), sample, stats);
1053
- if (this.costsOpen && performance.now() - this.lastCostsAt >= COSTS_INTERVAL_MS) this.renderCosts();
1054
- this.renderFooter();
1055
- }
1056
- renderCosts() {
1057
- this.lastCostsAt = performance.now();
1058
- const cost = this.monitor.getSceneCost();
1059
- if (!cost) {
1060
- this.costsListEl.replaceChildren(this.costsNote("No scene rendered yet."));
1061
- return;
1062
- }
1063
- const entries = [...this.costGroup === "meshes" ? cost.meshes : cost.materials];
1064
- if (this.costSort === "triangles") entries.sort((a, b) => b.triangles - a.triangles || b.calls - a.calls);
1065
- if (entries.length === 0) {
1066
- this.costsListEl.replaceChildren(this.costsNote("Nothing drawn."));
1067
- return;
1068
- }
1069
- const rows = entries.slice(0, COSTS_ROWS).map((entry) => {
1070
- const row = document.createElement("button");
1071
- row.type = "button";
1072
- row.className = "perf-monitor__cost";
1073
- row.title = costTitle(entry);
1074
- const name = document.createElement("span");
1075
- name.className = "perf-monitor__cost-name";
1076
- name.textContent = entry.label;
1077
- const copies = copiesOf(entry);
1078
- if (copies) {
1079
- const suffix = document.createElement("span");
1080
- suffix.className = "perf-monitor__cost-copies";
1081
- suffix.textContent = ` ${copies}`;
1082
- name.append(suffix);
1083
- }
1084
- const calls = document.createElement("span");
1085
- calls.className = "perf-monitor__cost-value";
1086
- calls.textContent = formatCount(entry.calls);
1087
- const triangles = document.createElement("span");
1088
- triangles.className = "perf-monitor__cost-value";
1089
- triangles.textContent = formatCount(entry.triangles);
1090
- row.append(name, calls, triangles);
1091
- row.addEventListener("click", () => {
1092
- console.log(`three-meter: ${entry.label}`, entry.objects);
1093
- });
1094
- return row;
1095
- });
1096
- this.costsListEl.replaceChildren(...rows);
1097
- }
1098
- costsNote(text) {
1099
- const note = document.createElement("div");
1100
- note.className = "perf-monitor__costs-note";
1101
- note.textContent = text;
1102
- return note;
1103
- }
1104
- /** The monitor caches the environment once its backend settles; until then this re-reads. */
1105
- renderFooter() {
1106
- const environment = this.monitor.getEnvironment();
1107
- const three = environment.three ? `three r${environment.three}` : "";
1108
- const backend = backendLabel(environment);
1109
- const gpu = environment.gpu ?? "";
1110
- const key = `${three}\n${backend}\n${gpu}`;
1111
- if (key === this.lastEnvironmentKey) return;
1112
- this.lastEnvironmentKey = key;
1113
- this.footerThreeEl.textContent = three;
1114
- this.footerBackendEl.textContent = backend;
1115
- this.footerBackendEl.hidden = backend === "";
1116
- this.footerBackendEl.title = environment.fallback ? "WebGPURenderer couldn't get WebGPU and fell back to WebGL2" : "";
1117
- this.footerBackendEl.classList.toggle("is-fallback", environment.fallback);
1118
- this.footerGpuEl.textContent = gpu;
1119
- this.footerGpuEl.title = gpu;
1120
- this.footerHardwareEl.hidden = backend === "" && gpu === "";
1121
- }
1122
- renderHud(sample) {
1123
- const stats = this.hudNumberEls.size > 0 ? this.monitor.getFrameStats() : null;
1124
- for (const [key, element] of this.hudNumberEls) {
1125
- const config = NUMBER_BY_KEY.get(key);
1126
- if (config && stats) this.paintNumber(config, element, sample, stats);
1127
- }
1128
- for (const [metric, graph] of this.hudGraphs) {
1129
- const config = TIMINGS.find((timing) => timing.metric === metric);
1130
- this.paintGraph(config, sample, graph.valueEl, graph, "—");
1131
- }
1132
- }
1133
- paintNumber(config, element, sample, stats) {
1134
- const value = config.value(sample, stats);
1135
- element.textContent = value === null ? "—" : config.format(value);
1136
- element.classList.toggle("is-over", isOverBudget(this.budgets, config.key, value));
1137
- }
1138
- /** Value and sparkline, with the budget line drawn once the series reaches it. */
1139
- paintGraph(config, sample, valueEl, graph, missing) {
1140
- const value = timingValue(sample, config.metric);
1141
- const available = isTimingAvailable(sample, config.metric);
1142
- const limit = this.budgets.limits[config.metric];
1143
- valueEl.textContent = available ? this.formatTiming(config, readTiming(sample, config.metric)) : missing;
1144
- valueEl.classList.toggle("is-over", isOverBudget(this.budgets, config.metric, value));
1145
- drawSparkline(graph.ctx, graph.width, graph.height, available ? this.monitor.getHistory(config.metric) : [], config.style[this.theme.resolved], limit === void 0 ? void 0 : {
1146
- color: GUIDE_COLOR[this.theme.resolved],
1147
- value: limit
1148
- });
1149
- }
1150
- /** Each value's tooltip names its budget, so amber explains itself. */
1151
- applyBudgetTitles() {
1152
- for (const [key, element] of this.statValueEls) element.title = budgetTitle(this.budgets, key);
1153
- for (const [key, element] of this.hudNumberEls) element.title = budgetTitle(this.budgets, key);
1154
- for (const [metric, element] of this.graphValueEls) element.title = budgetTitle(this.budgets, metric);
1155
- for (const [metric, graph] of this.hudGraphs) graph.valueEl.title = budgetTitle(this.budgets, metric);
1156
- }
1157
- formatTiming(config, value) {
1158
- return `${config.format(value)}${config.unit ? ` ${config.unit}` : ""}`;
1159
- }
1160
- build() {
1161
- const hud = document.createElement("div");
1162
- hud.className = "perf-monitor__hud";
1163
- this.hudGraphsEl = document.createElement("div");
1164
- this.hudGraphsEl.className = "perf-monitor__hud-graphs";
1165
- this.hudGridEl = document.createElement("div");
1166
- this.hudGridEl.className = "perf-monitor__hud-grid";
1167
- hud.append(this.hudGraphsEl, this.hudGridEl);
1168
- const options = document.createElement("div");
1169
- options.className = "perf-monitor__section perf-monitor__options";
1170
- const themeRow = document.createElement("div");
1171
- themeRow.className = "perf-monitor__row perf-monitor__row--static";
1172
- const themeLabel = document.createElement("span");
1173
- themeLabel.className = "perf-monitor__label";
1174
- themeLabel.textContent = "theme";
1175
- const segment = document.createElement("div");
1176
- segment.className = "perf-monitor__segment";
1177
- segment.setAttribute("role", "radiogroup");
1178
- segment.setAttribute("aria-label", "Panel theme");
1179
- for (const option of THEME_OPTIONS) {
1180
- const radio = document.createElement("button");
1181
- radio.type = "button";
1182
- radio.className = "perf-monitor__segment-option";
1183
- radio.setAttribute("role", "radio");
1184
- radio.setAttribute("aria-checked", "false");
1185
- radio.setAttribute("aria-label", option.label);
1186
- radio.title = option.label;
1187
- radio.append(createIcon(option.icon, "perf-monitor__icon"));
1188
- radio.addEventListener("click", () => this.settings.setTheme(option.mode));
1189
- this.themeRadios.set(option.mode, radio);
1190
- segment.append(radio);
1191
- }
1192
- themeRow.append(createIcon("contrast", "perf-monitor__icon"), themeLabel, segment);
1193
- const explainRow = document.createElement("label");
1194
- explainRow.className = "perf-monitor__row";
1195
- const explainCheckbox = this.buildCheckbox(false, "Explain each metric", (on) => this.element.classList.toggle("perf-monitor--explain", on));
1196
- const explainLabel = document.createElement("span");
1197
- explainLabel.className = "perf-monitor__label";
1198
- explainLabel.textContent = "explain metrics";
1199
- explainRow.append(createIcon("help", "perf-monitor__icon"), explainLabel, explainCheckbox);
1200
- options.append(themeRow, explainRow, this.buildReportRow());
1201
- const graphs = document.createElement("div");
1202
- graphs.className = "perf-monitor__graphs";
1203
- for (const config of TIMINGS) {
1204
- const graph = document.createElement("div");
1205
- graph.className = "perf-monitor__graph";
1206
- const canvas = document.createElement("canvas");
1207
- canvas.className = "perf-monitor__canvas";
1208
- const overlay = document.createElement("label");
1209
- overlay.className = "perf-monitor__graph-overlay perf-monitor__graph-overlay--toggle";
1210
- const head = document.createElement("span");
1211
- head.className = "perf-monitor__graph-head";
1212
- const checkbox = this.buildCheckbox(this.settings.isGraphEnabled(config.metric), `Show ${config.label} graph in HUD`, (on) => this.settings.setGraphEnabled(config.metric, on));
1213
- this.graphCheckboxes.set(config.metric, checkbox);
1214
- const label = document.createElement("span");
1215
- label.className = "perf-monitor__graph-label";
1216
- label.textContent = config.label;
1217
- label.title = METRIC_HELP[config.metric] ?? "";
1218
- const value = document.createElement("span");
1219
- value.className = "perf-monitor__graph-value";
1220
- value.textContent = "—";
1221
- this.graphValueEls.set(config.metric, value);
1222
- const tail = document.createElement("span");
1223
- tail.className = "perf-monitor__graph-head";
1224
- tail.append(value, checkbox);
1225
- head.append(createIcon(config.icon, "perf-monitor__icon"), label);
1226
- overlay.append(head, tail);
1227
- graph.append(canvas, overlay);
1228
- graphs.append(graph);
1229
- const ctx = canvas.getContext("2d");
1230
- if (ctx) {
1231
- this.graphCanvases.set(config.metric, {
1232
- canvas,
1233
- ctx,
1234
- height: 0,
1235
- width: 0
1236
- });
1237
- this.resizeObserver.observe(graph);
1238
- }
1239
- }
1240
- const stats = document.createElement("div");
1241
- stats.className = "perf-monitor__section";
1242
- for (const config of NUMBERS) {
1243
- const rowEl = document.createElement("label");
1244
- rowEl.className = "perf-monitor__row";
1245
- const checkbox = this.buildCheckbox(this.settings.isNumberEnabled(config.key), `Show ${config.label} in HUD`, (on) => this.settings.setNumberEnabled(config.key, on));
1246
- this.statCheckboxes.set(config.key, checkbox);
1247
- const help = METRIC_HELP[config.key] ?? "";
1248
- const label = document.createElement("span");
1249
- label.className = "perf-monitor__label";
1250
- label.textContent = config.label;
1251
- label.title = help;
1252
- const value = document.createElement("span");
1253
- value.className = "perf-monitor__value";
1254
- value.textContent = "—";
1255
- this.statValueEls.set(config.key, value);
1256
- const hint = document.createElement("span");
1257
- hint.className = "perf-monitor__hint";
1258
- hint.textContent = help;
1259
- rowEl.append(createIcon(config.icon, "perf-monitor__icon"), label, value, checkbox, hint);
1260
- stats.append(rowEl);
1261
- }
1262
- const footer = document.createElement("div");
1263
- footer.className = "perf-monitor__footer";
1264
- this.infoCheckbox = this.buildCheckbox(this.settings.info, "Show version and environment in HUD", (on) => this.settings.setInfo(on));
1265
- const footerBody = document.createElement("div");
1266
- footerBody.className = "perf-monitor__footer-body";
1267
- const softwareRow = document.createElement("div");
1268
- softwareRow.className = "perf-monitor__footer-row";
1269
- const release = document.createElement("a");
1270
- release.className = "perf-monitor__link";
1271
- release.href = RELEASE_URL;
1272
- release.target = "_blank";
1273
- release.rel = "noopener noreferrer";
1274
- release.title = `Release notes for v${version}`;
1275
- release.textContent = `three-meter v${version}`;
1276
- this.footerThreeEl = document.createElement("span");
1277
- softwareRow.append(release, this.footerThreeEl);
1278
- this.footerHardwareEl = document.createElement("div");
1279
- this.footerHardwareEl.className = "perf-monitor__footer-row";
1280
- this.footerHardwareEl.hidden = true;
1281
- this.footerBackendEl = document.createElement("span");
1282
- this.footerBackendEl.className = "perf-monitor__badge";
1283
- this.footerGpuEl = document.createElement("span");
1284
- this.footerGpuEl.className = "perf-monitor__footer-gpu";
1285
- this.footerHardwareEl.append(this.footerBackendEl, this.footerGpuEl);
1286
- footerBody.append(softwareRow, this.footerHardwareEl);
1287
- footer.append(createIcon("info", "perf-monitor__icon"), footerBody, this.infoCheckbox);
1288
- this.element.append(hud, options, graphs, stats, this.buildCosts(), footer);
1289
- }
1290
- rebuildHud() {
1291
- const { selection } = this.settings;
1292
- for (const graph of this.hudGraphs.values()) this.resizeObserver.unobserve(graph.canvas.parentElement ?? graph.canvas);
1293
- this.hudGraphs.clear();
1294
- this.hudNumberEls.clear();
1295
- this.hudGraphsEl.replaceChildren();
1296
- this.hudGridEl.replaceChildren();
1297
- for (const config of TIMINGS) {
1298
- if (!selection.graphs.has(config.metric)) continue;
1299
- const row = document.createElement("div");
1300
- row.className = "perf-monitor__hud-graph";
1301
- const canvas = document.createElement("canvas");
1302
- canvas.className = "perf-monitor__canvas";
1303
- const overlay = document.createElement("div");
1304
- overlay.className = "perf-monitor__graph-overlay";
1305
- const label = document.createElement("span");
1306
- label.className = "perf-monitor__graph-label";
1307
- label.textContent = config.label;
1308
- label.title = METRIC_HELP[config.metric] ?? "";
1309
- const head = document.createElement("span");
1310
- head.className = "perf-monitor__graph-head";
1311
- head.append(createIcon(config.icon, "perf-monitor__icon"), label);
1312
- const value = document.createElement("span");
1313
- value.className = "perf-monitor__graph-value";
1314
- value.textContent = "—";
1315
- overlay.append(head, value);
1316
- row.append(canvas, overlay);
1317
- this.hudGraphsEl.append(row);
1318
- const ctx = canvas.getContext("2d");
1319
- if (ctx) {
1320
- this.hudGraphs.set(config.metric, {
1321
- canvas,
1322
- ctx,
1323
- height: 0,
1324
- valueEl: value,
1325
- width: 0
1326
- });
1327
- this.resizeObserver.observe(row);
1328
- }
1329
- }
1330
- for (const config of NUMBERS) {
1331
- if (!selection.numbers.has(config.key)) continue;
1332
- const item = document.createElement("div");
1333
- item.className = "perf-monitor__hud-item";
1334
- const label = document.createElement("span");
1335
- label.className = "perf-monitor__hud-label";
1336
- label.title = METRIC_HELP[config.key] ?? "";
1337
- label.append(createIcon(config.icon, "perf-monitor__icon"), config.label);
1338
- const value = document.createElement("span");
1339
- value.className = "perf-monitor__hud-value";
1340
- value.textContent = "—";
1341
- item.append(label, value);
1342
- this.hudGridEl.append(item);
1343
- this.hudNumberEls.set(config.key, value);
1344
- }
1345
- this.applyBudgetTitles();
1346
- this.resizeCanvases();
1347
- }
1348
- onSelectionChanged() {
1349
- for (const [metric, checkbox] of this.graphCheckboxes) checkbox.checked = this.settings.isGraphEnabled(metric);
1350
- for (const [key, checkbox] of this.statCheckboxes) checkbox.checked = this.settings.isNumberEnabled(key);
1351
- this.infoCheckbox.checked = this.settings.info;
1352
- this.applyInfoClass();
1353
- this.theme.setOverride(this.settings.theme);
1354
- this.rebuildHud();
1355
- }
1356
- /** Repaint the palette and mark the radio matching what applies (pick, else consumer mode). */
1357
- onThemeChanged() {
1358
- applyTheme(this.element, this.theme);
1359
- for (const [mode, radio] of this.themeRadios) radio.setAttribute("aria-checked", String(mode === this.theme.effective));
1360
- }
1361
- /** "top costs": a toggle row, then meshes / materials and a sortable top five. */
1362
- buildCosts() {
1363
- const section = document.createElement("div");
1364
- section.className = "perf-monitor__section perf-monitor__costs";
1365
- const toggleRow = document.createElement("label");
1366
- toggleRow.className = "perf-monitor__row";
1367
- toggleRow.title = COSTS_HELP;
1368
- const label = document.createElement("span");
1369
- label.className = "perf-monitor__label";
1370
- label.textContent = "top costs";
1371
- const checkbox = this.buildCheckbox(false, "Show the scene's biggest draw costs", (on) => {
1372
- this.costsOpen = on;
1373
- this.costsBodyEl.hidden = !on;
1374
- if (on) this.renderCosts();
1375
- });
1376
- toggleRow.append(createIcon("flame", "perf-monitor__icon"), label, checkbox);
1377
- this.costsBodyEl = document.createElement("div");
1378
- this.costsBodyEl.className = "perf-monitor__costs-body";
1379
- this.costsBodyEl.hidden = true;
1380
- const head = document.createElement("div");
1381
- head.className = "perf-monitor__cost perf-monitor__cost--head";
1382
- const segment = document.createElement("div");
1383
- segment.className = "perf-monitor__segment";
1384
- segment.setAttribute("role", "radiogroup");
1385
- segment.setAttribute("aria-label", "Group costs by");
1386
- for (const option of COST_GROUPS) {
1387
- const radio = document.createElement("button");
1388
- radio.type = "button";
1389
- radio.className = "perf-monitor__segment-option perf-monitor__segment-option--text";
1390
- radio.setAttribute("role", "radio");
1391
- radio.setAttribute("aria-checked", String(option.group === this.costGroup));
1392
- radio.textContent = option.label;
1393
- radio.addEventListener("click", () => {
1394
- this.costGroup = option.group;
1395
- for (const [group, button] of this.costGroupRadios) button.setAttribute("aria-checked", String(group === option.group));
1396
- this.renderCosts();
1397
- });
1398
- this.costGroupRadios.set(option.group, radio);
1399
- segment.append(radio);
1400
- }
1401
- head.append(segment);
1402
- for (const sort of ["calls", "triangles"]) {
1403
- const button = document.createElement("button");
1404
- button.type = "button";
1405
- button.className = "perf-monitor__cost-sort";
1406
- button.textContent = sort === "calls" ? "calls" : "tris";
1407
- button.title = `Sort by ${sort === "calls" ? "draw calls" : "triangles"}`;
1408
- button.setAttribute("aria-pressed", String(sort === this.costSort));
1409
- button.addEventListener("click", () => {
1410
- this.costSort = sort;
1411
- for (const [key, other] of this.costSortButtons) other.setAttribute("aria-pressed", String(key === sort));
1412
- this.renderCosts();
1413
- });
1414
- this.costSortButtons.set(sort, button);
1415
- head.append(button);
1416
- }
1417
- this.costsListEl = document.createElement("div");
1418
- this.costsListEl.className = "perf-monitor__costs-list";
1419
- const hint = document.createElement("span");
1420
- hint.className = "perf-monitor__hint";
1421
- hint.textContent = COSTS_HELP;
1422
- this.costsBodyEl.append(head, this.costsListEl, hint);
1423
- section.append(toggleRow, this.costsBodyEl);
1424
- return section;
1425
- }
1426
- buildReportRow() {
1427
- const row = document.createElement("div");
1428
- row.className = "perf-monitor__row perf-monitor__row--static";
1429
- const label = document.createElement("span");
1430
- label.className = "perf-monitor__label";
1431
- label.textContent = "report";
1432
- const button = document.createElement("button");
1433
- button.type = "button";
1434
- button.className = "perf-monitor__button";
1435
- button.title = "Copy versions, GPU and current metrics as Markdown for a bug report";
1436
- button.textContent = "copy";
1437
- let resetTimer = 0;
1438
- const flash = (text) => {
1439
- button.textContent = text;
1440
- window.clearTimeout(resetTimer);
1441
- resetTimer = window.setTimeout(() => {
1442
- button.textContent = "copy";
1443
- }, COPY_FEEDBACK_MS);
1444
- };
1445
- button.addEventListener("click", () => {
1446
- copyText(formatReport(this.monitor)).then((copied) => flash(copied ? "copied" : "failed"));
1447
- });
1448
- row.append(createIcon("clipboard", "perf-monitor__icon"), label, button);
1449
- return row;
1450
- }
1451
- buildCheckbox(checked, title, onChange) {
1452
- const checkbox = document.createElement("input");
1453
- checkbox.type = "checkbox";
1454
- checkbox.className = "perf-monitor__checkbox";
1455
- checkbox.checked = checked;
1456
- checkbox.title = title;
1457
- checkbox.setAttribute("aria-label", title);
1458
- checkbox.addEventListener("change", () => onChange(checkbox.checked));
1459
- return checkbox;
1460
- }
1461
- applyInfoClass() {
1462
- this.element.classList.toggle("perf-monitor--info", this.settings.info);
1463
- }
1464
- applyModeClass() {
1465
- this.element.classList.toggle("perf-monitor--compact", this.mode === "compact");
1466
- this.element.classList.toggle("perf-monitor--full", this.mode === "full");
1467
- }
1468
- resizeCanvases() {
1469
- const dpr = window.devicePixelRatio || 1;
1470
- const sizeAll = (graphs) => {
1471
- for (const graph of graphs) {
1472
- const { canvas, ctx } = graph;
1473
- const width = canvas.clientWidth;
1474
- const height = canvas.clientHeight;
1475
- if (width === 0 || height === 0) continue;
1476
- canvas.width = Math.round(width * dpr);
1477
- canvas.height = Math.round(height * dpr);
1478
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1479
- graph.width = width;
1480
- graph.height = height;
1481
- }
1482
- };
1483
- sizeAll(this.graphCanvases.values());
1484
- sizeAll(this.hudGraphs.values());
1485
- }
1486
- };
1487
- //#endregion
1488
- //#region src/ui/styles.ts
1489
- /**
1490
- * The HUD stylesheet as a string, so the package works without a bundler and
1491
- * injects itself once per document. `.perf-monitor` is the metrics card,
1492
- * `.perf-hud` the floating host around it. Both namespaced so a host page can
1493
- * override safely; the `--perf-*` custom properties are the theming surface.
1494
- *
1495
- * Both elements carry `data-theme="dark" | "light"` (see `HudTheme`) and each
1496
- * resolves its own tokens from it, so the card also themes when mounted on
1497
- * its own, outside a `.perf-hud`.
1498
- */
1499
- const STYLE_ATTRIBUTE = "data-three-meter";
1500
- const PERF_HUD_STYLES = `
1501
- .perf-hud,
1502
- .perf-monitor {
1503
- --perf-bg: rgba(22, 24, 29, 0.85);
1504
- --perf-fg: #e6e8eb;
1505
- --perf-fg-dim: #c5c8ce;
1506
- --perf-muted: #8b909a;
1507
- --perf-row: rgba(255, 255, 255, 0.04);
1508
- --perf-border: rgba(255, 255, 255, 0.12);
1509
- --perf-accent: #60a5fa;
1510
- --perf-warn: #f59e0b;
1511
- --perf-shadow: 0 8px 24px rgba(0, 0, 0, 0.32);
1512
-
1513
- color-scheme: dark;
551
+ const STYLE_ATTRIBUTE = "data-three-meter";
552
+ const PERF_HUD_STYLES = `
553
+ .perf-hud,
554
+ .perf-monitor {
555
+ --perf-bg: rgba(22, 24, 29, 0.85);
556
+ --perf-fg: #e6e8eb;
557
+ --perf-fg-dim: #c5c8ce;
558
+ --perf-muted: #8b909a;
559
+ --perf-row: rgba(255, 255, 255, 0.04);
560
+ --perf-border: rgba(255, 255, 255, 0.12);
561
+ --perf-accent: #60a5fa;
562
+ --perf-warn: #f59e0b;
563
+ --perf-shadow: 0 8px 24px rgba(0, 0, 0, 0.32);
564
+
565
+ color-scheme: dark;
1514
566
  }
1515
567
 
1516
568
  .perf-hud[data-theme="light"],
@@ -1732,11 +784,10 @@ const PERF_HUD_STYLES = `
1732
784
  }
1733
785
 
1734
786
  .perf-monitor__cost {
1735
- /* Rows are buttons, which shrink to their content without a width. */
1736
787
  box-sizing: border-box;
1737
788
  width: 100%;
1738
789
  display: grid;
1739
- grid-template-columns: minmax(0, 1fr) 5ch 6ch;
790
+ grid-template-columns: minmax(0, 1fr) 5ch 6ch 14px;
1740
791
  gap: 6px;
1741
792
  align-items: baseline;
1742
793
  padding: 2px 6px;
@@ -1746,13 +797,58 @@ const PERF_HUD_STYLES = `
1746
797
  color: inherit;
1747
798
  font: inherit;
1748
799
  text-align: left;
1749
- cursor: pointer;
800
+ cursor: default;
1750
801
  }
1751
802
 
1752
803
  .perf-monitor__cost:not(.perf-monitor__cost--head):hover {
1753
804
  background: var(--perf-row);
1754
805
  }
1755
806
 
807
+ .perf-monitor__cost.is-hidden .perf-monitor__cost-name {
808
+ opacity: 0.6;
809
+ text-decoration: line-through;
810
+ }
811
+
812
+ /* What hiding saved, across the calls and tris columns. */
813
+ .perf-monitor__cost-saved {
814
+ grid-column: 2 / span 2;
815
+ color: var(--perf-accent);
816
+ font-variant-numeric: tabular-nums lining-nums;
817
+ text-align: right;
818
+ white-space: nowrap;
819
+ }
820
+
821
+ .perf-monitor__cost-eye {
822
+ display: flex;
823
+ align-items: center;
824
+ justify-content: center;
825
+ width: 14px;
826
+ height: 14px;
827
+ padding: 0;
828
+ border: 0;
829
+ border-radius: 3px;
830
+ background: transparent;
831
+ color: var(--perf-muted);
832
+ cursor: pointer;
833
+ align-self: center;
834
+ }
835
+
836
+ .perf-monitor__cost-eye .perf-monitor__icon {
837
+ width: 12px;
838
+ height: 12px;
839
+ color: inherit;
840
+ }
841
+
842
+ .perf-monitor__cost-eye:hover,
843
+ .perf-monitor__cost-eye[aria-pressed="true"] {
844
+ color: var(--perf-fg);
845
+ }
846
+
847
+ .perf-monitor__cost-eye:focus-visible {
848
+ outline: 1px solid color-mix(in srgb, var(--perf-accent) 60%, transparent);
849
+ outline-offset: 1px;
850
+ }
851
+
1756
852
  .perf-monitor__cost:focus-visible {
1757
853
  outline: 1px solid color-mix(in srgb, var(--perf-accent) 60%, transparent);
1758
854
  outline-offset: -1px;
@@ -1763,13 +859,30 @@ const PERF_HUD_STYLES = `
1763
859
  cursor: default;
1764
860
  }
1765
861
 
862
+ /* The name is a button: a click logs the row's objects. */
1766
863
  .perf-monitor__cost-name {
864
+ min-width: 0;
865
+ padding: 0;
866
+ border: 0;
867
+ background: transparent;
868
+ font: inherit;
869
+ text-align: left;
870
+ cursor: pointer;
1767
871
  overflow: hidden;
1768
872
  text-overflow: ellipsis;
1769
873
  white-space: nowrap;
1770
874
  color: var(--perf-fg-dim);
1771
875
  }
1772
876
 
877
+ .perf-monitor__cost-name:hover {
878
+ color: var(--perf-fg);
879
+ }
880
+
881
+ .perf-monitor__cost-name:focus-visible {
882
+ outline: 1px solid color-mix(in srgb, var(--perf-accent) 60%, transparent);
883
+ outline-offset: 1px;
884
+ }
885
+
1773
886
  .perf-monitor__cost-copies {
1774
887
  color: var(--perf-muted);
1775
888
  }
@@ -1989,21 +1102,24 @@ const PERF_HUD_STYLES = `
1989
1102
  touch-action: none;
1990
1103
  }
1991
1104
 
1992
- .perf-hud--full > .perf-monitor {
1105
+ .perf-hud--full .perf-hud__card > .perf-monitor {
1993
1106
  width: 16rem;
1994
1107
  max-width: 100%;
1995
1108
  }
1996
1109
 
1997
1110
  /* Room for the hints, so most fit on two or three lines. Plus padding, this fills the host's 20rem cap. */
1998
- .perf-hud--full > .perf-monitor--explain {
1111
+ .perf-hud--full .perf-hud__card > .perf-monitor--explain {
1999
1112
  width: 19rem;
2000
1113
  }
2001
1114
 
2002
- .perf-hud > .perf-monitor {
1115
+ /* The card every dev panel sits in (\`mountDevPanel\`): the brand label, then the panel's content. */
1116
+ .perf-hud__card {
2003
1117
  position: relative;
2004
1118
  z-index: 1;
1119
+ display: flex;
1120
+ flex-direction: column;
2005
1121
  max-height: 70vh;
2006
- overflow: auto;
1122
+ overflow: hidden;
2007
1123
  background: var(--perf-bg);
2008
1124
  border-radius: 6px;
2009
1125
  box-shadow: var(--perf-shadow);
@@ -2012,10 +1128,59 @@ const PERF_HUD_STYLES = `
2012
1128
  transition: opacity 0.25s ease;
2013
1129
  }
2014
1130
 
2015
- .perf-hud--dim:not(.is-awake):not(.is-dragging) > .perf-monitor {
1131
+ .perf-hud__card > .perf-monitor {
1132
+ min-height: 0;
1133
+ overflow: auto;
1134
+ }
1135
+
1136
+ .perf-hud--dim:not(.is-awake):not(.is-dragging) > .perf-hud__card {
2016
1137
  opacity: 0.32;
2017
1138
  }
2018
1139
 
1140
+ .perf-hud__brand {
1141
+ display: flex;
1142
+ align-items: center;
1143
+ gap: 6px;
1144
+ padding: 6px 8px 0;
1145
+ color: var(--perf-muted);
1146
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
1147
+ font-size: 9px;
1148
+ font-weight: 600;
1149
+ letter-spacing: 0.08em;
1150
+ line-height: 1;
1151
+ text-transform: uppercase;
1152
+ }
1153
+
1154
+ /* Alone in the card: pad it all round. */
1155
+ .perf-hud__brand:last-child {
1156
+ padding-bottom: 6px;
1157
+ }
1158
+
1159
+ .perf-hud__brand-name {
1160
+ display: inline-flex;
1161
+ align-items: center;
1162
+ gap: 5px;
1163
+ }
1164
+
1165
+ .perf-hud__brand-name::before {
1166
+ width: 6px;
1167
+ height: 6px;
1168
+ border-radius: 2px;
1169
+ background: var(--perf-accent);
1170
+ content: "";
1171
+ }
1172
+
1173
+ .perf-hud__brand-detail {
1174
+ margin-left: auto;
1175
+ font-weight: 400;
1176
+ letter-spacing: 0.02em;
1177
+ text-transform: none;
1178
+ }
1179
+
1180
+ .perf-hud__brand-detail:empty {
1181
+ display: none;
1182
+ }
1183
+
2019
1184
  .perf-hud__hotspot {
2020
1185
  position: absolute;
2021
1186
  z-index: 0;
@@ -2101,50 +1266,1275 @@ const PERF_HUD_STYLES = `
2101
1266
  background: var(--perf-accent);
2102
1267
  color: var(--perf-bg);
2103
1268
  }
2104
- `;
1269
+ `;
1270
+ /**
1271
+ * Append the stylesheet to `target`'s head, once. Call before mounting a view
1272
+ * by hand; {@link mountPerfHud} does it for you unless told not to.
1273
+ */
1274
+ const injectStyles = (target = document) => {
1275
+ if (target.head.querySelector(`style[${STYLE_ATTRIBUTE}]`)) return;
1276
+ const style = target.createElement("style");
1277
+ style.setAttribute(STYLE_ATTRIBUTE, "");
1278
+ style.textContent = PERF_HUD_STYLES;
1279
+ target.head.append(style);
1280
+ };
1281
+ //#endregion
1282
+ //#region src/ui/dev-panel.ts
1283
+ /**
1284
+ * The frame every zkmake dev panel shares, so they look and handle alike: a fixed host docked to a
1285
+ * screen edge, a card with a brand label on top and the panel's content under it, and three discs
1286
+ * on the inward side — drag grip, expand/compact, dim on leave — that stay hidden until the pointer
1287
+ * is near or someone taps. `mountPerfHud` is one; `@zkmake/three-textures`' panel is another.
1288
+ */
1289
+ const TOUCH_LINGER_MS = 2500;
1290
+ const disc = (label, className, icon) => {
1291
+ const button = document.createElement("button");
1292
+ button.type = "button";
1293
+ button.className = `perf-hud__disc ${className}`.trim();
1294
+ button.setAttribute("aria-label", label);
1295
+ button.title = label;
1296
+ button.append(createIcon(icon, "perf-hud__icon"));
1297
+ return button;
1298
+ };
1299
+ /** A dim flag in localStorage, for panels with no settings of their own. */
1300
+ const storedDim = (key) => {
1301
+ const listeners = /* @__PURE__ */ new Set();
1302
+ let on = false;
1303
+ try {
1304
+ on = key !== null && localStorage.getItem(key) === "1";
1305
+ } catch {}
1306
+ return {
1307
+ get: () => on,
1308
+ set: (next) => {
1309
+ on = next;
1310
+ try {
1311
+ if (key !== null) localStorage.setItem(key, next ? "1" : "0");
1312
+ } catch {}
1313
+ for (const listener of listeners) listener();
1314
+ },
1315
+ subscribe: (listener) => {
1316
+ listeners.add(listener);
1317
+ return () => {
1318
+ listeners.delete(listener);
1319
+ };
1320
+ }
1321
+ };
1322
+ };
1323
+ const mountDevPanel = (options) => {
1324
+ const parent = options.parent ?? document.body;
1325
+ const dim = options.dim ?? storedDim(options.storageKey === null ? null : `${options.storageKey}:dim`);
1326
+ if (options.injectStyles ?? true) injectStyles(parent.ownerDocument);
1327
+ const host = document.createElement("div");
1328
+ host.className = "perf-hud";
1329
+ host.dataset.edge = "left";
1330
+ host.setAttribute("aria-label", options.label);
1331
+ const hotspot = document.createElement("div");
1332
+ hotspot.className = "perf-hud__hotspot";
1333
+ hotspot.setAttribute("aria-hidden", "true");
1334
+ const tools = document.createElement("div");
1335
+ tools.className = "perf-hud__tools";
1336
+ const grip = disc(`Drag the ${options.brand} panel`, "perf-hud__drag", "grip");
1337
+ const toggle = disc(options.toggleLabel ?? "Expand or compact the panel", "", "sliders");
1338
+ const dimDisc = disc("Dim the panel when the pointer leaves", "perf-hud__dim", "blend");
1339
+ dimDisc.addEventListener("click", () => dim.set(!dim.get()));
1340
+ toggle.addEventListener("click", () => options.onToggle());
1341
+ tools.append(grip, toggle, dimDisc);
1342
+ const card = document.createElement("div");
1343
+ card.className = "perf-hud__card";
1344
+ const brand = document.createElement("div");
1345
+ brand.className = "perf-hud__brand";
1346
+ const name = document.createElement("span");
1347
+ name.className = "perf-hud__brand-name";
1348
+ name.textContent = options.brand;
1349
+ const detail = document.createElement("span");
1350
+ detail.className = "perf-hud__brand-detail";
1351
+ brand.append(name, detail);
1352
+ card.append(brand, options.content);
1353
+ host.append(hotspot, tools, card);
1354
+ parent.append(host);
1355
+ const applyDim = () => {
1356
+ host.classList.toggle("perf-hud--dim", dim.get());
1357
+ dimDisc.setAttribute("aria-pressed", String(dim.get()));
1358
+ dimDisc.title = `Dim on leave: ${dim.get() ? "on" : "off"}`;
1359
+ };
1360
+ applyDim();
1361
+ applyTheme(host, options.theme);
1362
+ const unsubscribeDim = dim.subscribe(applyDim);
1363
+ const unsubscribeTheme = options.theme.subscribe(() => applyTheme(host, options.theme));
1364
+ const dock = dockPanel(host, {
1365
+ defaultPlacement: options.defaultPlacement,
1366
+ handle: grip,
1367
+ storageKey: options.storageKey
1368
+ });
1369
+ let edge = host.dataset.edge;
1370
+ const edgeWatch = new MutationObserver(() => {
1371
+ const next = host.dataset.edge;
1372
+ if (next !== edge) {
1373
+ edge = next;
1374
+ options.onEdgeChange?.(edge);
1375
+ }
1376
+ });
1377
+ edgeWatch.observe(host, {
1378
+ attributes: true,
1379
+ attributeFilter: ["data-edge"]
1380
+ });
1381
+ options.onEdgeChange?.(edge);
1382
+ let hideTimer = 0;
1383
+ const sleep = () => {
1384
+ if (host.classList.contains("is-dragging")) return;
1385
+ host.classList.remove("is-awake");
1386
+ };
1387
+ const wake = (linger) => {
1388
+ host.classList.add("is-awake");
1389
+ window.clearTimeout(hideTimer);
1390
+ if (linger) hideTimer = window.setTimeout(sleep, TOUCH_LINGER_MS);
1391
+ };
1392
+ const onEnter = () => {
1393
+ wake(false);
1394
+ };
1395
+ const onLeave = (event) => {
1396
+ if (host.contains(event.relatedTarget)) return;
1397
+ if (event.pointerType === "touch" || event.pointerType === "pen") {
1398
+ wake(true);
1399
+ return;
1400
+ }
1401
+ sleep();
1402
+ };
1403
+ const onDown = (event) => {
1404
+ wake(event.pointerType !== "mouse");
1405
+ };
1406
+ host.addEventListener("pointerenter", onEnter);
1407
+ host.addEventListener("pointerleave", onLeave);
1408
+ host.addEventListener("pointerdown", onDown);
1409
+ return {
1410
+ element: host,
1411
+ card,
1412
+ detail,
1413
+ refresh: () => {
1414
+ requestAnimationFrame(() => dock.refresh());
1415
+ },
1416
+ dispose: () => {
1417
+ window.clearTimeout(hideTimer);
1418
+ edgeWatch.disconnect();
1419
+ host.removeEventListener("pointerenter", onEnter);
1420
+ host.removeEventListener("pointerleave", onLeave);
1421
+ host.removeEventListener("pointerdown", onDown);
1422
+ unsubscribeDim();
1423
+ unsubscribeTheme();
1424
+ dock.dispose();
1425
+ host.remove();
1426
+ }
1427
+ };
1428
+ };
1429
+ //#endregion
1430
+ //#region package.json
1431
+ var name = "@zkmake/three-meter";
1432
+ var version = "0.11.0";
1433
+ //#endregion
1434
+ //#region src/ui/clipboard.ts
1435
+ /**
1436
+ * Copy text from a click handler. The async Clipboard API needs a secure
1437
+ * context and the `clipboard-write` permission; a dev server opened over a LAN
1438
+ * IP has neither, and some embeds deny the permission. The fallback is the old
1439
+ * select-a-textarea `execCommand("copy")`, still honoured inside a user gesture.
1440
+ */
1441
+ const copyWithSelection = (text) => {
1442
+ const textarea = document.createElement("textarea");
1443
+ textarea.value = text;
1444
+ textarea.setAttribute("readonly", "");
1445
+ textarea.style.position = "fixed";
1446
+ textarea.style.opacity = "0";
1447
+ textarea.style.pointerEvents = "none";
1448
+ const previous = document.activeElement;
1449
+ document.body.append(textarea);
1450
+ textarea.select();
1451
+ try {
1452
+ return document.execCommand("copy");
1453
+ } catch {
1454
+ return false;
1455
+ } finally {
1456
+ textarea.remove();
1457
+ previous?.focus?.();
1458
+ }
1459
+ };
1460
+ const copyText = async (text) => {
1461
+ try {
1462
+ await navigator.clipboard.writeText(text);
1463
+ return true;
1464
+ } catch {
1465
+ return copyWithSelection(text);
1466
+ }
1467
+ };
1468
+ //#endregion
1469
+ //#region src/ui/format.ts
1470
+ /**
1471
+ * Number formatting for the card. Counts stay exact with thousands separators
1472
+ * while they fit the compact grid's 6ch value column (`99,999`), then switch
1473
+ * to compact notation (`250K`, `1.4M`, `12.3M`, `1.2B`) so a triangle count in
1474
+ * the millions can't run into the neighbouring label.
1475
+ */
1476
+ const COMPACT_FROM = 1e5;
1477
+ const exact = new Intl.NumberFormat("en-US");
1478
+ const compact = new Intl.NumberFormat("en-US", {
1479
+ maximumFractionDigits: 1,
1480
+ notation: "compact"
1481
+ });
1482
+ const formatCount = (value) => Math.abs(value) < COMPACT_FROM ? exact.format(value) : compact.format(value);
1483
+ //#endregion
1484
+ //#region src/ui/hider.ts
1485
+ var Hider = class {
1486
+ groups = /* @__PURE__ */ new Map();
1487
+ /** Each hidden object's `visible` from before the first group hid it. */
1488
+ original = /* @__PURE__ */ new Map();
1489
+ hide(key, objects) {
1490
+ if (this.groups.has(key)) return;
1491
+ const hideable = objects.filter((object) => typeof object === "object" && object !== null);
1492
+ this.groups.set(key, hideable);
1493
+ for (const object of hideable) {
1494
+ if (!this.original.has(object)) this.original.set(object, object.visible !== false);
1495
+ object.visible = false;
1496
+ }
1497
+ }
1498
+ show(key) {
1499
+ const objects = this.groups.get(key);
1500
+ if (!objects) return;
1501
+ this.groups.delete(key);
1502
+ for (const object of objects) if (!this.heldElsewhere(object)) {
1503
+ object.visible = this.original.get(object) ?? true;
1504
+ this.original.delete(object);
1505
+ }
1506
+ }
1507
+ isHidden(key) {
1508
+ return this.groups.has(key);
1509
+ }
1510
+ /** Hidden group keys, in the order they were hidden. */
1511
+ keys() {
1512
+ return [...this.groups.keys()];
1513
+ }
1514
+ restoreAll() {
1515
+ for (const key of this.keys()) this.show(key);
1516
+ }
1517
+ heldElsewhere(object) {
1518
+ for (const objects of this.groups.values()) if (objects.includes(object)) return true;
1519
+ return false;
1520
+ }
1521
+ };
1522
+ //#endregion
1523
+ //#region src/ui/metric-help.ts
1524
+ /**
1525
+ * One plain-language line per metric: what it is, and what a bad reading
1526
+ * means. Shown as the label's tooltip and, with "explain metrics" on, under
1527
+ * each row. Keyed like the stat rows; the graphs reuse `fps`, `cpu`, `gpu`.
1528
+ */
1529
+ const METRIC_HELP = {
1530
+ calls: "Separate draw commands sent to the GPU this frame. Fewer is better: merge or instance meshes that share a material.",
1531
+ cpu: "JavaScript time per frame: your update code plus three.js preparing the render. It has to fit the frame budget (16.7 ms at 60 FPS).",
1532
+ fps: "Frames drawn per second. Higher is smoother; most displays top out at 60 or 120.",
1533
+ geometries: "Geometries held in GPU memory. Should level off; a steady climb is a leak, so dispose() what you remove.",
1534
+ headroom: "Spare time left in each frame: the frame budget minus whichever of CPU and GPU took longer. Below zero, frames miss the display's refresh.",
1535
+ refresh: "The rate the loop runs at when it keeps up: the display's refresh rate, or the app's own cap. Budgets use it unless you set targetFps.",
1536
+ gpu: "Time the graphics card spent drawing the frame. High means shaders, overdraw or resolution are the bottleneck.",
1537
+ hitches: "Frames that took over twice as long as usual, in the last 1,000 frames. Each one is a visible stutter.",
1538
+ lines: "Line segments drawn this frame.",
1539
+ low: "Frame rate during the slowest 1% of frames. Far below FPS means stutter the average hides.",
1540
+ p99: "99% of frames finished within this many ms. Above the frame budget (16.7 ms at 60 FPS) means dropped frames.",
1541
+ passes: "Times renderer.render() ran this frame. Shadows, post-processing and extra cameras each add passes.",
1542
+ points: "Points drawn this frame.",
1543
+ shaders: "Compiled shader programs. A jump mid-session usually means a stutter while a new material compiled.",
1544
+ textures: "Textures held in GPU memory. Should level off; a steady climb is a leak, so dispose() what you remove.",
1545
+ triangles: "Triangles drawn this frame. Mostly GPU cost: use lower-poly models or level of detail to cut it."
1546
+ };
1547
+ //#endregion
1548
+ //#region src/ui/report.ts
1549
+ /**
1550
+ * A plain-text snapshot of the monitor for bug reports: versions, backend and
1551
+ * GPU, the current sample, stutter stats, the scene's top meshes and the page. Fenced as a `text`
1552
+ * code block so GitHub and Slack keep the line breaks.
1553
+ */
1554
+ const BACKEND_LABELS = {
1555
+ webgl: "WebGL",
1556
+ webgl2: "WebGL2",
1557
+ webgpu: "WebGPU"
1558
+ };
1559
+ /** `WebGPU`, `WebGL2`, `WebGL2 fallback`, or `""` before the backend settles. */
1560
+ const backendLabel = (environment) => {
1561
+ if (!environment.backend) return "";
1562
+ const label = BACKEND_LABELS[environment.backend];
1563
+ return environment.fallback ? `${label} fallback` : label;
1564
+ };
1565
+ const ms = (value) => `${value.toFixed(1)} ms`;
1566
+ /** Top meshes by draw calls, three at most: `Top: cubes ×2,000 (1 call, 24K tris) · …`. */
1567
+ const topMeshes = (cost) => `Top: ${cost.meshes.slice(0, 3).map((entry) => {
1568
+ const copies = entry.instances > 1 ? ` ×${formatCount(entry.instances)}` : "";
1569
+ const calls = `${formatCount(entry.calls)} ${entry.calls === 1 ? "call" : "calls"}`;
1570
+ return `${entry.label}${copies} (${calls}, ${formatCount(entry.triangles)} tris)`;
1571
+ }).join(" · ")}`;
1572
+ const renderReport = ({ cost, environment, page, sample, stats }) => {
1573
+ const { render, resources } = sample;
1574
+ const heading = [
1575
+ `three-meter v${version}`,
1576
+ environment.three ? `three r${environment.three}` : "",
1577
+ backendLabel(environment),
1578
+ environment.gpu ?? ""
1579
+ ].filter(Boolean);
1580
+ const frames = stats.frames === 0 ? `FPS ${Math.round(sample.fps)}` : [
1581
+ `FPS ${Math.round(sample.fps)}`,
1582
+ `1% low ${Math.round(stats.lowFps)}`,
1583
+ `p99 ${ms(stats.p99Ms)}`,
1584
+ `hitches ${formatCount(stats.hitches)} / ${formatCount(stats.frames)} frames`
1585
+ ].join(" · ");
1586
+ const timing = [`CPU ${ms(sample.cpu)}`, `GPU ${sample.gpu.available ? ms(sample.gpu.ms) : "unavailable"}`];
1587
+ if (stats.refreshHz) {
1588
+ const busiest = Math.max(sample.cpu, sample.gpu.available ? sample.gpu.ms : 0);
1589
+ timing.push(`refresh ${stats.refreshHz} Hz`, `headroom ${ms(1e3 / stats.refreshHz - busiest)}`);
1590
+ }
1591
+ const lines = [
1592
+ heading.join(" · "),
1593
+ frames,
1594
+ timing.join(" · "),
1595
+ [
1596
+ `Calls ${formatCount(render.calls)}`,
1597
+ `passes ${formatCount(render.passes)}`,
1598
+ `triangles ${formatCount(render.triangles)}`,
1599
+ `lines ${formatCount(render.lines)}`,
1600
+ `points ${formatCount(render.points)}`
1601
+ ].join(" · "),
1602
+ [
1603
+ `Geometries ${formatCount(resources.geometries)}`,
1604
+ `textures ${formatCount(resources.textures)}`,
1605
+ `shaders ${formatCount(resources.programs)}`
1606
+ ].join(" · ")
1607
+ ];
1608
+ if (cost && cost.meshes.length > 0) lines.push(topMeshes(cost));
1609
+ if (page) lines.push(`Viewport ${page.width}×${page.height} @${page.dpr}x`, page.userAgent);
1610
+ return [
1611
+ "```text",
1612
+ ...lines,
1613
+ "```"
1614
+ ].join("\n");
1615
+ };
1616
+ const readPage = () => typeof window === "undefined" ? null : {
1617
+ dpr: window.devicePixelRatio || 1,
1618
+ height: window.innerHeight,
1619
+ userAgent: navigator.userAgent,
1620
+ width: window.innerWidth
1621
+ };
1622
+ /** The report for `monitor` right now, as Markdown ready to paste into an issue. */
1623
+ const formatReport = (monitor) => renderReport({
1624
+ cost: monitor.getSceneCost(),
1625
+ environment: monitor.getEnvironment(),
1626
+ page: readPage(),
1627
+ sample: monitor.getSample(),
1628
+ stats: monitor.getFrameStats()
1629
+ });
1630
+ //#endregion
1631
+ //#region src/ui/sparkline.ts
1632
+ function drawSparkline(ctx, width, height, values, style, guide) {
1633
+ ctx.clearRect(0, 0, width, height);
1634
+ if (values.length < 2) return;
1635
+ let max = 0;
1636
+ for (const value of values) if (value > max) max = value;
1637
+ const range = max * 1.1 || 1;
1638
+ const stepX = width / (values.length - 1);
1639
+ const toY = (value) => height - value / range * height;
1640
+ ctx.beginPath();
1641
+ ctx.moveTo(0, toY(values[0]));
1642
+ for (let index = 1; index < values.length; index += 1) ctx.lineTo(index * stepX, toY(values[index]));
1643
+ ctx.lineWidth = 1;
1644
+ ctx.strokeStyle = style.stroke;
1645
+ ctx.stroke();
1646
+ ctx.lineTo(width, height);
1647
+ ctx.lineTo(0, height);
1648
+ ctx.closePath();
1649
+ ctx.fillStyle = style.fill;
1650
+ ctx.fill();
1651
+ if (guide && guide.value > 0 && guide.value <= range) {
1652
+ const y = Math.round(toY(guide.value)) + .5;
1653
+ ctx.save();
1654
+ ctx.setLineDash([3, 3]);
1655
+ ctx.strokeStyle = guide.color;
1656
+ ctx.beginPath();
1657
+ ctx.moveTo(0, y);
1658
+ ctx.lineTo(width, y);
1659
+ ctx.stroke();
1660
+ ctx.restore();
1661
+ }
1662
+ }
1663
+ //#endregion
1664
+ //#region src/ui/performance-view.ts
1665
+ const TIMINGS = [
1666
+ {
1667
+ format: (value) => Math.round(value).toString(),
1668
+ icon: "gauge",
1669
+ label: "FPS",
1670
+ metric: "fps",
1671
+ style: {
1672
+ dark: {
1673
+ fill: "rgba(74, 222, 128, 0.15)",
1674
+ stroke: "#4ade80"
1675
+ },
1676
+ light: {
1677
+ fill: "rgba(22, 163, 74, 0.12)",
1678
+ stroke: "#16a34a"
1679
+ }
1680
+ },
1681
+ unit: ""
1682
+ },
1683
+ {
1684
+ format: (value) => value.toFixed(1),
1685
+ icon: "cpu",
1686
+ label: "CPU",
1687
+ metric: "cpu",
1688
+ style: {
1689
+ dark: {
1690
+ fill: "rgba(96, 165, 250, 0.15)",
1691
+ stroke: "#60a5fa"
1692
+ },
1693
+ light: {
1694
+ fill: "rgba(37, 99, 235, 0.12)",
1695
+ stroke: "#2563eb"
1696
+ }
1697
+ },
1698
+ unit: "ms"
1699
+ },
1700
+ {
1701
+ format: (value) => value.toFixed(1),
1702
+ icon: "zap",
1703
+ label: "GPU",
1704
+ metric: "gpu",
1705
+ style: {
1706
+ dark: {
1707
+ fill: "rgba(244, 114, 182, 0.15)",
1708
+ stroke: "#f472b6"
1709
+ },
1710
+ light: {
1711
+ fill: "rgba(219, 39, 119, 0.12)",
1712
+ stroke: "#db2777"
1713
+ }
1714
+ },
1715
+ unit: "ms"
1716
+ }
1717
+ ];
1718
+ const THEME_OPTIONS = [
1719
+ {
1720
+ icon: "sun",
1721
+ label: "Light theme",
1722
+ mode: "light"
1723
+ },
1724
+ {
1725
+ icon: "monitor",
1726
+ label: "Match the system theme",
1727
+ mode: "system"
1728
+ },
1729
+ {
1730
+ icon: "moon",
1731
+ label: "Dark theme",
1732
+ mode: "dark"
1733
+ }
1734
+ ];
1735
+ /** The monorepo tags each package's releases `@zkmake/three-meter@0.9.2`. */
1736
+ const RELEASE_URL = `https://github.com/zkmake/three-kit/releases/tag/${encodeURIComponent(`${name}@${version}`)}`;
1737
+ /** How long the report button says `copied` / `failed` before reading `copy` again. */
1738
+ const COPY_FEEDBACK_MS = 1500;
1739
+ /** Walking the scene graph isn't free; the top-costs list refreshes at 2 Hz while open. */
1740
+ const COSTS_INTERVAL_MS = 500;
1741
+ const COSTS_ROWS = 5;
1742
+ const COST_GROUPS = [{
1743
+ group: "meshes",
1744
+ label: "meshes"
1745
+ }, {
1746
+ group: "materials",
1747
+ label: "materials"
1748
+ }];
1749
+ const COSTS_HELP = "The scene's biggest draw costs in the main render pass, estimated from the scene graph. Hidden and off-screen objects are left out; shadow and post-processing passes aren't counted. Click a row to log its objects to the console; use the eye to hide them and measure the time they cost.";
1750
+ /** Frames averaged on each side of hiding a row. */
1751
+ const MEASURE_FRAMES = 30;
1752
+ /** How long after hiding before the saving is read, so the average holds only frames without the row. */
1753
+ const MEASURE_SETTLE_MS = 700;
1754
+ const meanOfLast = (values, count) => {
1755
+ const tail = values.slice(-count);
1756
+ return tail.length === 0 ? 0 : tail.reduce((sum, value) => sum + value, 0) / tail.length;
1757
+ };
1758
+ /** `×2,000` for instances or copies; nothing for a single object. */
1759
+ const copiesOf = (entry) => entry.instances > 1 ? `×${formatCount(entry.instances)}` : "";
1760
+ const costTitle = (entry) => {
1761
+ const objects = entry.objects.length;
1762
+ const shape = entry.instances > objects ? `${formatCount(entry.instances)} instances in ${objects === 1 ? "one object" : `${objects} objects`}` : `${formatCount(objects)} ${objects === 1 ? "object" : "separate objects"}`;
1763
+ return `${entry.label}: ${shape}, ${formatCount(entry.calls)} draw ${entry.calls === 1 ? "call" : "calls"}, ${formatCount(entry.triangles)} triangles. Click to log to the console.`;
1764
+ };
1765
+ const readTiming = (sample, metric) => {
1766
+ switch (metric) {
1767
+ case "fps": return sample.fps;
1768
+ case "cpu": return sample.cpu;
1769
+ case "gpu": return sample.gpu.ms;
1770
+ }
1771
+ };
1772
+ const isTimingAvailable = (sample, metric) => metric !== "gpu" || sample.gpu.available;
1773
+ const whole = (value) => Math.round(value).toString();
1774
+ const tenths = (value) => value.toFixed(1);
1775
+ const NUMBERS = [
1776
+ {
1777
+ format: whole,
1778
+ icon: "gauge",
1779
+ key: "fps",
1780
+ label: "FPS",
1781
+ value: (sample) => sample.fps || null
1782
+ },
1783
+ {
1784
+ format: formatCount,
1785
+ icon: "layers",
1786
+ key: "calls",
1787
+ label: "Calls",
1788
+ value: (sample) => sample.render.calls
1789
+ },
1790
+ {
1791
+ format: tenths,
1792
+ icon: "cpu",
1793
+ key: "cpu",
1794
+ label: "CPU",
1795
+ unit: "ms",
1796
+ value: (sample) => sample.cpu
1797
+ },
1798
+ {
1799
+ format: tenths,
1800
+ icon: "zap",
1801
+ key: "gpu",
1802
+ label: "GPU",
1803
+ unit: "ms",
1804
+ value: (sample) => sample.gpu.available ? sample.gpu.ms : null
1805
+ },
1806
+ {
1807
+ format: tenths,
1808
+ icon: "hourglass",
1809
+ key: "headroom",
1810
+ label: "Headroom",
1811
+ unit: "ms",
1812
+ value: (sample, _stats, frameMs) => sample.fps ? frameMs - Math.max(sample.cpu, sample.gpu.available ? sample.gpu.ms : 0) : null
1813
+ },
1814
+ {
1815
+ format: (value) => `${Math.round(value)} Hz`,
1816
+ icon: "monitor",
1817
+ key: "refresh",
1818
+ label: "Refresh",
1819
+ unit: "Hz",
1820
+ value: (_sample, stats) => stats.refreshHz
1821
+ },
1822
+ {
1823
+ format: whole,
1824
+ icon: "trendingDown",
1825
+ key: "low",
1826
+ label: "1% low",
1827
+ value: (_sample, stats) => stats.frames ? stats.lowFps : null
1828
+ },
1829
+ {
1830
+ format: tenths,
1831
+ icon: "timer",
1832
+ key: "p99",
1833
+ label: "Frame p99",
1834
+ unit: "ms",
1835
+ value: (_sample, stats) => stats.frames ? stats.p99Ms : null
1836
+ },
1837
+ {
1838
+ format: formatCount,
1839
+ icon: "activity",
1840
+ key: "hitches",
1841
+ label: "Hitches",
1842
+ value: (_sample, stats) => stats.frames ? stats.hitches : null
1843
+ },
1844
+ {
1845
+ format: formatCount,
1846
+ icon: "triangle",
1847
+ key: "triangles",
1848
+ label: "Triangles",
1849
+ value: (sample) => sample.render.triangles
1850
+ },
1851
+ {
1852
+ format: formatCount,
1853
+ icon: "spline",
1854
+ key: "lines",
1855
+ label: "Lines",
1856
+ value: (sample) => sample.render.lines
1857
+ },
1858
+ {
1859
+ format: formatCount,
1860
+ icon: "circleDot",
1861
+ key: "points",
1862
+ label: "Points",
1863
+ value: (sample) => sample.render.points
1864
+ },
1865
+ {
1866
+ format: formatCount,
1867
+ icon: "repeat",
1868
+ key: "passes",
1869
+ label: "Render passes",
1870
+ value: (sample) => sample.render.passes
1871
+ },
1872
+ {
1873
+ format: formatCount,
1874
+ icon: "box",
1875
+ key: "geometries",
1876
+ label: "Geometries",
1877
+ value: (sample) => sample.resources.geometries
1878
+ },
1879
+ {
1880
+ format: formatCount,
1881
+ icon: "image",
1882
+ key: "textures",
1883
+ label: "Textures",
1884
+ value: (sample) => sample.resources.textures
1885
+ },
1886
+ {
1887
+ format: formatCount,
1888
+ icon: "palette",
1889
+ key: "shaders",
1890
+ label: "Shaders",
1891
+ value: (sample) => sample.resources.programs
1892
+ }
1893
+ ];
1894
+ const NUMBER_BY_KEY = new Map(NUMBERS.map((config) => [config.key, config]));
1895
+ /** Canvas can't read CSS custom properties; the budget line's amber per theme. */
1896
+ const GUIDE_COLOR = {
1897
+ dark: "rgba(245, 158, 11, 0.75)",
1898
+ light: "rgba(180, 83, 9, 0.7)"
1899
+ };
1900
+ /** `Budget: at most 16.7 ms` / `at least 57`, or `""` without one. */
1901
+ const budgetTitle = (budgets, key) => {
1902
+ const limit = budgets.limits[key];
1903
+ const config = NUMBER_BY_KEY.get(key);
1904
+ if (limit === void 0 || !config) return "";
1905
+ return `Budget: ${isFloor(key) ? "at least" : "at most"} ${config.format(limit)}${config.unit ? ` ${config.unit}` : ""}`;
1906
+ };
1907
+ /** The sample value a timing graph shows, `null` when not known. */
1908
+ const timingValue = (sample, metric) => {
1909
+ if (!isTimingAvailable(sample, metric)) return null;
1910
+ const value = readTiming(sample, metric);
1911
+ return metric === "fps" && value === 0 ? null : value;
1912
+ };
2105
1913
  /**
2106
- * Append the stylesheet to `target`'s head, once. Call before mounting a view
2107
- * by hand; {@link mountPerfHud} does it for you unless told not to.
1914
+ * Host-agnostic widget that paints {@link Sample}s. Mount {@link element}
1915
+ * anywhere; the view drives its own throttled repaint. Compact is the floating
1916
+ * HUD; full is the checkbox list that chooses what compact shows. Styles are
1917
+ * not injected here. Call `injectStyles()` or use `mountPerfHud`.
2108
1918
  */
2109
- const injectStyles = (target = document) => {
2110
- if (target.head.querySelector(`style[${STYLE_ATTRIBUTE}]`)) return;
2111
- const style = target.createElement("style");
2112
- style.setAttribute(STYLE_ATTRIBUTE, "");
2113
- style.textContent = PERF_HUD_STYLES;
2114
- target.head.append(style);
1919
+ var PerformanceView = class {
1920
+ element;
1921
+ settings;
1922
+ theme;
1923
+ ownsTheme;
1924
+ unsubscribeTheme;
1925
+ monitor;
1926
+ minIntervalMs;
1927
+ mode;
1928
+ graphValueEls = /* @__PURE__ */ new Map();
1929
+ graphCanvases = /* @__PURE__ */ new Map();
1930
+ graphCheckboxes = /* @__PURE__ */ new Map();
1931
+ statValueEls = /* @__PURE__ */ new Map();
1932
+ statCheckboxes = /* @__PURE__ */ new Map();
1933
+ infoCheckbox;
1934
+ themeRadios = /* @__PURE__ */ new Map();
1935
+ footerThreeEl;
1936
+ footerHardwareEl;
1937
+ footerBackendEl;
1938
+ footerGpuEl;
1939
+ lastEnvironmentKey = "";
1940
+ hudGraphsEl;
1941
+ hudGridEl;
1942
+ hudGraphs = /* @__PURE__ */ new Map();
1943
+ hudNumberEls = /* @__PURE__ */ new Map();
1944
+ budgets;
1945
+ budgetsInput;
1946
+ detectedHz = null;
1947
+ hider = new Hider();
1948
+ hiddenRows = /* @__PURE__ */ new Map();
1949
+ rafId = null;
1950
+ lastPaintAt = 0;
1951
+ resizeObserver;
1952
+ unsubscribe;
1953
+ costsOpen = false;
1954
+ costGroup = "meshes";
1955
+ costSort = "calls";
1956
+ lastCostsAt = -Infinity;
1957
+ costsBodyEl;
1958
+ costsListEl;
1959
+ costGroupRadios = /* @__PURE__ */ new Map();
1960
+ costSortButtons = /* @__PURE__ */ new Map();
1961
+ constructor(options) {
1962
+ this.monitor = options.monitor;
1963
+ this.settings = options.settings ?? new HudSettings();
1964
+ this.ownsTheme = !options.theme;
1965
+ this.theme = options.theme ?? new HudTheme();
1966
+ this.theme.setOverride(this.settings.theme);
1967
+ this.mode = options.mode ?? "full";
1968
+ this.minIntervalMs = 1e3 / (options.refreshHz ?? 10);
1969
+ this.budgetsInput = options.budgets;
1970
+ this.budgets = resolveBudgets(options.budgets);
1971
+ this.element = document.createElement("div");
1972
+ this.element.className = "perf-monitor";
1973
+ this.applyModeClass();
1974
+ this.resizeObserver = new ResizeObserver(() => this.resizeCanvases());
1975
+ this.build();
1976
+ this.applyInfoClass();
1977
+ this.rebuildHud();
1978
+ this.onThemeChanged();
1979
+ this.unsubscribe = this.settings.subscribe(() => this.onSelectionChanged());
1980
+ this.unsubscribeTheme = this.theme.subscribe(() => this.onThemeChanged());
1981
+ }
1982
+ start() {
1983
+ if (this.rafId === null) this.rafId = requestAnimationFrame(this.frame);
1984
+ }
1985
+ stop() {
1986
+ if (this.rafId !== null) {
1987
+ cancelAnimationFrame(this.rafId);
1988
+ this.rafId = null;
1989
+ }
1990
+ }
1991
+ setMode(mode) {
1992
+ if (mode === this.mode) return;
1993
+ this.mode = mode;
1994
+ this.applyModeClass();
1995
+ this.resizeCanvases();
1996
+ }
1997
+ getMode() {
1998
+ return this.mode;
1999
+ }
2000
+ /** Replace the budgets; `false` turns them all off. Applies on the next paint. */
2001
+ setBudgets(budgets) {
2002
+ this.budgetsInput = budgets;
2003
+ this.budgets = resolveBudgets(budgets, this.detectedHz);
2004
+ this.applyBudgetTitles();
2005
+ }
2006
+ dispose() {
2007
+ this.stop();
2008
+ this.showAllHidden();
2009
+ this.unsubscribe();
2010
+ this.unsubscribeTheme();
2011
+ if (this.ownsTheme) this.theme.dispose();
2012
+ this.resizeObserver.disconnect();
2013
+ this.element.remove();
2014
+ }
2015
+ frame = (now) => {
2016
+ this.rafId = requestAnimationFrame(this.frame);
2017
+ if (now - this.lastPaintAt < this.minIntervalMs) return;
2018
+ this.lastPaintAt = now;
2019
+ this.render();
2020
+ };
2021
+ render() {
2022
+ const sample = this.monitor.getSample();
2023
+ const stats = this.monitor.getFrameStats();
2024
+ this.followRefresh(stats.refreshHz);
2025
+ if (this.mode === "compact") {
2026
+ this.renderHud(sample, stats);
2027
+ if (this.settings.info) this.renderFooter();
2028
+ return;
2029
+ }
2030
+ for (const config of TIMINGS) this.paintGraph(config, sample, this.graphValueEls.get(config.metric), this.graphCanvases.get(config.metric), "unavailable");
2031
+ for (const config of NUMBERS) this.paintNumber(config, this.statValueEls.get(config.key), sample, stats);
2032
+ if (this.costsOpen && performance.now() - this.lastCostsAt >= COSTS_INTERVAL_MS) this.renderCosts();
2033
+ this.renderFooter();
2034
+ }
2035
+ renderCosts() {
2036
+ this.lastCostsAt = performance.now();
2037
+ const cost = this.monitor.getSceneCost();
2038
+ const hidden = [...this.hiddenRows.values()].map((row) => this.hiddenCostRow(row));
2039
+ if (!cost) {
2040
+ this.costsListEl.replaceChildren(...hidden, this.costsNote("No scene rendered yet."));
2041
+ return;
2042
+ }
2043
+ const entries = (this.costGroup === "meshes" ? cost.meshes : cost.materials).filter((entry) => !this.hiddenRows.has(entry.key));
2044
+ if (this.costSort === "triangles") entries.sort((a, b) => b.triangles - a.triangles || b.calls - a.calls);
2045
+ if (entries.length === 0 && hidden.length === 0) {
2046
+ this.costsListEl.replaceChildren(this.costsNote("Nothing drawn."));
2047
+ return;
2048
+ }
2049
+ const rows = entries.slice(0, COSTS_ROWS).map((entry) => {
2050
+ const row = this.costRowShell(entry);
2051
+ const calls = document.createElement("span");
2052
+ calls.className = "perf-monitor__cost-value";
2053
+ calls.textContent = formatCount(entry.calls);
2054
+ const triangles = document.createElement("span");
2055
+ triangles.className = "perf-monitor__cost-value";
2056
+ triangles.textContent = formatCount(entry.triangles);
2057
+ row.append(calls, triangles, this.eyeButton(entry, false));
2058
+ return row;
2059
+ });
2060
+ this.costsListEl.replaceChildren(...hidden, ...rows);
2061
+ }
2062
+ /** Name button (logs the objects) in a row; the caller appends the value cells and the eye. */
2063
+ costRowShell(entry) {
2064
+ const row = document.createElement("div");
2065
+ row.className = "perf-monitor__cost";
2066
+ const name = document.createElement("button");
2067
+ name.type = "button";
2068
+ name.className = "perf-monitor__cost-name";
2069
+ name.title = costTitle(entry);
2070
+ name.textContent = entry.label;
2071
+ const copies = copiesOf(entry);
2072
+ if (copies) {
2073
+ const suffix = document.createElement("span");
2074
+ suffix.className = "perf-monitor__cost-copies";
2075
+ suffix.textContent = ` ${copies}`;
2076
+ name.append(suffix);
2077
+ }
2078
+ name.addEventListener("click", () => {
2079
+ console.log(`three-meter: ${entry.label}`, entry.objects);
2080
+ });
2081
+ row.append(name);
2082
+ return row;
2083
+ }
2084
+ /** A hidden row: what hiding it saved, measured from the timing history. */
2085
+ hiddenCostRow(hidden) {
2086
+ const row = this.costRowShell(hidden.entry);
2087
+ row.classList.add("is-hidden");
2088
+ const saved = document.createElement("span");
2089
+ saved.className = "perf-monitor__cost-saved";
2090
+ const label = hidden.metric.toUpperCase();
2091
+ if (performance.now() - hidden.hiddenAt < MEASURE_SETTLE_MS) saved.textContent = "measuring…";
2092
+ else {
2093
+ const now = meanOfLast(this.monitor.getHistory(hidden.metric), MEASURE_FRAMES);
2094
+ const delta = hidden.baseline - now;
2095
+ saved.textContent = `${delta >= 0 ? "−" : "+"}${Math.abs(delta).toFixed(1)} ms ${label}`;
2096
+ saved.title = `${label} time went from ${hidden.baseline.toFixed(1)} ms to ${now.toFixed(1)} ms with this hidden, averaged over ${MEASURE_FRAMES} frames each side.`;
2097
+ }
2098
+ row.append(saved, this.eyeButton(hidden.entry, true));
2099
+ return row;
2100
+ }
2101
+ eyeButton(entry, hidden) {
2102
+ const eye = document.createElement("button");
2103
+ eye.type = "button";
2104
+ eye.className = "perf-monitor__cost-eye";
2105
+ eye.setAttribute("aria-pressed", String(hidden));
2106
+ const label = hidden ? `Show ${entry.label} again` : `Hide ${entry.label} to measure its cost`;
2107
+ eye.title = label;
2108
+ eye.setAttribute("aria-label", label);
2109
+ eye.append(createIcon(hidden ? "eyeOff" : "eye", "perf-monitor__icon"));
2110
+ eye.addEventListener("click", () => hidden ? this.showRow(entry.key) : this.hideRow(entry));
2111
+ return eye;
2112
+ }
2113
+ hideRow(entry) {
2114
+ const metric = this.monitor.getSample().gpu.available ? "gpu" : "cpu";
2115
+ this.hiddenRows.set(entry.key, {
2116
+ baseline: meanOfLast(this.monitor.getHistory(metric), MEASURE_FRAMES),
2117
+ entry,
2118
+ hiddenAt: performance.now(),
2119
+ metric
2120
+ });
2121
+ const all = this.monitor.getObjectsForKey(entry.key);
2122
+ this.hider.hide(entry.key, all.length > 0 ? all : entry.objects);
2123
+ this.renderCosts();
2124
+ }
2125
+ showRow(key) {
2126
+ this.hider.show(key);
2127
+ this.hiddenRows.delete(key);
2128
+ this.renderCosts();
2129
+ }
2130
+ /** Everything hidden goes back: on closing top costs and on dispose. */
2131
+ showAllHidden() {
2132
+ this.hider.restoreAll();
2133
+ this.hiddenRows.clear();
2134
+ }
2135
+ costsNote(text) {
2136
+ const note = document.createElement("div");
2137
+ note.className = "perf-monitor__costs-note";
2138
+ note.textContent = text;
2139
+ return note;
2140
+ }
2141
+ /** The monitor caches the environment once its backend settles; until then this re-reads. */
2142
+ renderFooter() {
2143
+ const environment = this.monitor.getEnvironment();
2144
+ const three = environment.three ? `three r${environment.three}` : "";
2145
+ const backend = backendLabel(environment);
2146
+ const gpu = environment.gpu ?? "";
2147
+ const key = `${three}\n${backend}\n${gpu}`;
2148
+ if (key === this.lastEnvironmentKey) return;
2149
+ this.lastEnvironmentKey = key;
2150
+ this.footerThreeEl.textContent = three;
2151
+ this.footerBackendEl.textContent = backend;
2152
+ this.footerBackendEl.hidden = backend === "";
2153
+ this.footerBackendEl.title = environment.fallback ? "WebGPURenderer couldn't get WebGPU and fell back to WebGL2" : "";
2154
+ this.footerBackendEl.classList.toggle("is-fallback", environment.fallback);
2155
+ this.footerGpuEl.textContent = gpu;
2156
+ this.footerGpuEl.title = gpu;
2157
+ this.footerHardwareEl.hidden = backend === "" && gpu === "";
2158
+ }
2159
+ /** Budgets follow the detected refresh rate unless `targetFps` was given. */
2160
+ followRefresh(hz) {
2161
+ if (hz === this.detectedHz) return;
2162
+ this.detectedHz = hz;
2163
+ this.budgets = resolveBudgets(this.budgetsInput, hz);
2164
+ this.applyBudgetTitles();
2165
+ }
2166
+ renderHud(sample, stats) {
2167
+ for (const [key, element] of this.hudNumberEls) {
2168
+ const config = NUMBER_BY_KEY.get(key);
2169
+ if (config) this.paintNumber(config, element, sample, stats);
2170
+ }
2171
+ for (const [metric, graph] of this.hudGraphs) {
2172
+ const config = TIMINGS.find((timing) => timing.metric === metric);
2173
+ this.paintGraph(config, sample, graph.valueEl, graph, "—");
2174
+ }
2175
+ }
2176
+ paintNumber(config, element, sample, stats) {
2177
+ const value = config.value(sample, stats, this.budgets.frameMs);
2178
+ element.textContent = value === null ? "—" : config.format(value);
2179
+ element.classList.toggle("is-over", isOverBudget(this.budgets, config.key, value));
2180
+ }
2181
+ /** Value and sparkline, with the budget line drawn once the series reaches it. */
2182
+ paintGraph(config, sample, valueEl, graph, missing) {
2183
+ const value = timingValue(sample, config.metric);
2184
+ const available = isTimingAvailable(sample, config.metric);
2185
+ const limit = this.budgets.limits[config.metric];
2186
+ valueEl.textContent = available ? this.formatTiming(config, readTiming(sample, config.metric)) : missing;
2187
+ valueEl.classList.toggle("is-over", isOverBudget(this.budgets, config.metric, value));
2188
+ drawSparkline(graph.ctx, graph.width, graph.height, available ? this.monitor.getHistory(config.metric) : [], config.style[this.theme.resolved], limit === void 0 ? void 0 : {
2189
+ color: GUIDE_COLOR[this.theme.resolved],
2190
+ value: limit
2191
+ });
2192
+ }
2193
+ /** Each value's tooltip names its budget, so amber explains itself. */
2194
+ applyBudgetTitles() {
2195
+ for (const [key, element] of this.statValueEls) element.title = budgetTitle(this.budgets, key);
2196
+ for (const [key, element] of this.hudNumberEls) element.title = budgetTitle(this.budgets, key);
2197
+ for (const [metric, element] of this.graphValueEls) element.title = budgetTitle(this.budgets, metric);
2198
+ for (const [metric, graph] of this.hudGraphs) graph.valueEl.title = budgetTitle(this.budgets, metric);
2199
+ }
2200
+ formatTiming(config, value) {
2201
+ return `${config.format(value)}${config.unit ? ` ${config.unit}` : ""}`;
2202
+ }
2203
+ build() {
2204
+ const hud = document.createElement("div");
2205
+ hud.className = "perf-monitor__hud";
2206
+ this.hudGraphsEl = document.createElement("div");
2207
+ this.hudGraphsEl.className = "perf-monitor__hud-graphs";
2208
+ this.hudGridEl = document.createElement("div");
2209
+ this.hudGridEl.className = "perf-monitor__hud-grid";
2210
+ hud.append(this.hudGraphsEl, this.hudGridEl);
2211
+ const options = document.createElement("div");
2212
+ options.className = "perf-monitor__section perf-monitor__options";
2213
+ const themeRow = document.createElement("div");
2214
+ themeRow.className = "perf-monitor__row perf-monitor__row--static";
2215
+ const themeLabel = document.createElement("span");
2216
+ themeLabel.className = "perf-monitor__label";
2217
+ themeLabel.textContent = "theme";
2218
+ const segment = document.createElement("div");
2219
+ segment.className = "perf-monitor__segment";
2220
+ segment.setAttribute("role", "radiogroup");
2221
+ segment.setAttribute("aria-label", "Panel theme");
2222
+ for (const option of THEME_OPTIONS) {
2223
+ const radio = document.createElement("button");
2224
+ radio.type = "button";
2225
+ radio.className = "perf-monitor__segment-option";
2226
+ radio.setAttribute("role", "radio");
2227
+ radio.setAttribute("aria-checked", "false");
2228
+ radio.setAttribute("aria-label", option.label);
2229
+ radio.title = option.label;
2230
+ radio.append(createIcon(option.icon, "perf-monitor__icon"));
2231
+ radio.addEventListener("click", () => this.settings.setTheme(option.mode));
2232
+ this.themeRadios.set(option.mode, radio);
2233
+ segment.append(radio);
2234
+ }
2235
+ themeRow.append(createIcon("contrast", "perf-monitor__icon"), themeLabel, segment);
2236
+ const explainRow = document.createElement("label");
2237
+ explainRow.className = "perf-monitor__row";
2238
+ const explainCheckbox = this.buildCheckbox(false, "Explain each metric", (on) => this.element.classList.toggle("perf-monitor--explain", on));
2239
+ const explainLabel = document.createElement("span");
2240
+ explainLabel.className = "perf-monitor__label";
2241
+ explainLabel.textContent = "explain metrics";
2242
+ explainRow.append(createIcon("help", "perf-monitor__icon"), explainLabel, explainCheckbox);
2243
+ options.append(themeRow, explainRow, this.buildReportRow());
2244
+ const graphs = document.createElement("div");
2245
+ graphs.className = "perf-monitor__graphs";
2246
+ for (const config of TIMINGS) {
2247
+ const graph = document.createElement("div");
2248
+ graph.className = "perf-monitor__graph";
2249
+ const canvas = document.createElement("canvas");
2250
+ canvas.className = "perf-monitor__canvas";
2251
+ const overlay = document.createElement("label");
2252
+ overlay.className = "perf-monitor__graph-overlay perf-monitor__graph-overlay--toggle";
2253
+ const head = document.createElement("span");
2254
+ head.className = "perf-monitor__graph-head";
2255
+ const checkbox = this.buildCheckbox(this.settings.isGraphEnabled(config.metric), `Show ${config.label} graph in HUD`, (on) => this.settings.setGraphEnabled(config.metric, on));
2256
+ this.graphCheckboxes.set(config.metric, checkbox);
2257
+ const label = document.createElement("span");
2258
+ label.className = "perf-monitor__graph-label";
2259
+ label.textContent = config.label;
2260
+ label.title = METRIC_HELP[config.metric] ?? "";
2261
+ const value = document.createElement("span");
2262
+ value.className = "perf-monitor__graph-value";
2263
+ value.textContent = "—";
2264
+ this.graphValueEls.set(config.metric, value);
2265
+ const tail = document.createElement("span");
2266
+ tail.className = "perf-monitor__graph-head";
2267
+ tail.append(value, checkbox);
2268
+ head.append(createIcon(config.icon, "perf-monitor__icon"), label);
2269
+ overlay.append(head, tail);
2270
+ graph.append(canvas, overlay);
2271
+ graphs.append(graph);
2272
+ const ctx = canvas.getContext("2d");
2273
+ if (ctx) {
2274
+ this.graphCanvases.set(config.metric, {
2275
+ canvas,
2276
+ ctx,
2277
+ height: 0,
2278
+ width: 0
2279
+ });
2280
+ this.resizeObserver.observe(graph);
2281
+ }
2282
+ }
2283
+ const stats = document.createElement("div");
2284
+ stats.className = "perf-monitor__section";
2285
+ for (const config of NUMBERS) {
2286
+ const rowEl = document.createElement("label");
2287
+ rowEl.className = "perf-monitor__row";
2288
+ const checkbox = this.buildCheckbox(this.settings.isNumberEnabled(config.key), `Show ${config.label} in HUD`, (on) => this.settings.setNumberEnabled(config.key, on));
2289
+ this.statCheckboxes.set(config.key, checkbox);
2290
+ const help = METRIC_HELP[config.key] ?? "";
2291
+ const label = document.createElement("span");
2292
+ label.className = "perf-monitor__label";
2293
+ label.textContent = config.label;
2294
+ label.title = help;
2295
+ const value = document.createElement("span");
2296
+ value.className = "perf-monitor__value";
2297
+ value.textContent = "—";
2298
+ this.statValueEls.set(config.key, value);
2299
+ const hint = document.createElement("span");
2300
+ hint.className = "perf-monitor__hint";
2301
+ hint.textContent = help;
2302
+ rowEl.append(createIcon(config.icon, "perf-monitor__icon"), label, value, checkbox, hint);
2303
+ stats.append(rowEl);
2304
+ }
2305
+ const footer = document.createElement("div");
2306
+ footer.className = "perf-monitor__footer";
2307
+ this.infoCheckbox = this.buildCheckbox(this.settings.info, "Show version and environment in HUD", (on) => this.settings.setInfo(on));
2308
+ const footerBody = document.createElement("div");
2309
+ footerBody.className = "perf-monitor__footer-body";
2310
+ const softwareRow = document.createElement("div");
2311
+ softwareRow.className = "perf-monitor__footer-row";
2312
+ const release = document.createElement("a");
2313
+ release.className = "perf-monitor__link";
2314
+ release.href = RELEASE_URL;
2315
+ release.target = "_blank";
2316
+ release.rel = "noopener noreferrer";
2317
+ release.title = `Release notes for v${version}`;
2318
+ release.textContent = `three-meter v${version}`;
2319
+ this.footerThreeEl = document.createElement("span");
2320
+ softwareRow.append(release, this.footerThreeEl);
2321
+ this.footerHardwareEl = document.createElement("div");
2322
+ this.footerHardwareEl.className = "perf-monitor__footer-row";
2323
+ this.footerHardwareEl.hidden = true;
2324
+ this.footerBackendEl = document.createElement("span");
2325
+ this.footerBackendEl.className = "perf-monitor__badge";
2326
+ this.footerGpuEl = document.createElement("span");
2327
+ this.footerGpuEl.className = "perf-monitor__footer-gpu";
2328
+ this.footerHardwareEl.append(this.footerBackendEl, this.footerGpuEl);
2329
+ footerBody.append(softwareRow, this.footerHardwareEl);
2330
+ footer.append(createIcon("info", "perf-monitor__icon"), footerBody, this.infoCheckbox);
2331
+ this.element.append(hud, options, graphs, stats, this.buildCosts(), footer);
2332
+ }
2333
+ rebuildHud() {
2334
+ const { selection } = this.settings;
2335
+ for (const graph of this.hudGraphs.values()) this.resizeObserver.unobserve(graph.canvas.parentElement ?? graph.canvas);
2336
+ this.hudGraphs.clear();
2337
+ this.hudNumberEls.clear();
2338
+ this.hudGraphsEl.replaceChildren();
2339
+ this.hudGridEl.replaceChildren();
2340
+ for (const config of TIMINGS) {
2341
+ if (!selection.graphs.has(config.metric)) continue;
2342
+ const row = document.createElement("div");
2343
+ row.className = "perf-monitor__hud-graph";
2344
+ const canvas = document.createElement("canvas");
2345
+ canvas.className = "perf-monitor__canvas";
2346
+ const overlay = document.createElement("div");
2347
+ overlay.className = "perf-monitor__graph-overlay";
2348
+ const label = document.createElement("span");
2349
+ label.className = "perf-monitor__graph-label";
2350
+ label.textContent = config.label;
2351
+ label.title = METRIC_HELP[config.metric] ?? "";
2352
+ const head = document.createElement("span");
2353
+ head.className = "perf-monitor__graph-head";
2354
+ head.append(createIcon(config.icon, "perf-monitor__icon"), label);
2355
+ const value = document.createElement("span");
2356
+ value.className = "perf-monitor__graph-value";
2357
+ value.textContent = "—";
2358
+ overlay.append(head, value);
2359
+ row.append(canvas, overlay);
2360
+ this.hudGraphsEl.append(row);
2361
+ const ctx = canvas.getContext("2d");
2362
+ if (ctx) {
2363
+ this.hudGraphs.set(config.metric, {
2364
+ canvas,
2365
+ ctx,
2366
+ height: 0,
2367
+ valueEl: value,
2368
+ width: 0
2369
+ });
2370
+ this.resizeObserver.observe(row);
2371
+ }
2372
+ }
2373
+ for (const config of NUMBERS) {
2374
+ if (!selection.numbers.has(config.key)) continue;
2375
+ const item = document.createElement("div");
2376
+ item.className = "perf-monitor__hud-item";
2377
+ const label = document.createElement("span");
2378
+ label.className = "perf-monitor__hud-label";
2379
+ label.title = METRIC_HELP[config.key] ?? "";
2380
+ label.append(createIcon(config.icon, "perf-monitor__icon"), config.label);
2381
+ const value = document.createElement("span");
2382
+ value.className = "perf-monitor__hud-value";
2383
+ value.textContent = "—";
2384
+ item.append(label, value);
2385
+ this.hudGridEl.append(item);
2386
+ this.hudNumberEls.set(config.key, value);
2387
+ }
2388
+ this.applyBudgetTitles();
2389
+ this.resizeCanvases();
2390
+ }
2391
+ onSelectionChanged() {
2392
+ for (const [metric, checkbox] of this.graphCheckboxes) checkbox.checked = this.settings.isGraphEnabled(metric);
2393
+ for (const [key, checkbox] of this.statCheckboxes) checkbox.checked = this.settings.isNumberEnabled(key);
2394
+ this.infoCheckbox.checked = this.settings.info;
2395
+ this.applyInfoClass();
2396
+ this.theme.setOverride(this.settings.theme);
2397
+ this.rebuildHud();
2398
+ }
2399
+ /** Repaint the palette and mark the radio matching what applies (pick, else consumer mode). */
2400
+ onThemeChanged() {
2401
+ applyTheme(this.element, this.theme);
2402
+ for (const [mode, radio] of this.themeRadios) radio.setAttribute("aria-checked", String(mode === this.theme.effective));
2403
+ }
2404
+ /** "top costs": a toggle row, then meshes / materials and a sortable top five. */
2405
+ buildCosts() {
2406
+ const section = document.createElement("div");
2407
+ section.className = "perf-monitor__section perf-monitor__costs";
2408
+ const toggleRow = document.createElement("label");
2409
+ toggleRow.className = "perf-monitor__row";
2410
+ toggleRow.title = COSTS_HELP;
2411
+ const label = document.createElement("span");
2412
+ label.className = "perf-monitor__label";
2413
+ label.textContent = "top costs";
2414
+ const checkbox = this.buildCheckbox(false, "Show the scene's biggest draw costs", (on) => {
2415
+ this.costsOpen = on;
2416
+ this.costsBodyEl.hidden = !on;
2417
+ if (!on) this.showAllHidden();
2418
+ if (on) this.renderCosts();
2419
+ });
2420
+ toggleRow.append(createIcon("flame", "perf-monitor__icon"), label, checkbox);
2421
+ this.costsBodyEl = document.createElement("div");
2422
+ this.costsBodyEl.className = "perf-monitor__costs-body";
2423
+ this.costsBodyEl.hidden = true;
2424
+ const head = document.createElement("div");
2425
+ head.className = "perf-monitor__cost perf-monitor__cost--head";
2426
+ const segment = document.createElement("div");
2427
+ segment.className = "perf-monitor__segment";
2428
+ segment.setAttribute("role", "radiogroup");
2429
+ segment.setAttribute("aria-label", "Group costs by");
2430
+ for (const option of COST_GROUPS) {
2431
+ const radio = document.createElement("button");
2432
+ radio.type = "button";
2433
+ radio.className = "perf-monitor__segment-option perf-monitor__segment-option--text";
2434
+ radio.setAttribute("role", "radio");
2435
+ radio.setAttribute("aria-checked", String(option.group === this.costGroup));
2436
+ radio.textContent = option.label;
2437
+ radio.addEventListener("click", () => {
2438
+ this.costGroup = option.group;
2439
+ for (const [group, button] of this.costGroupRadios) button.setAttribute("aria-checked", String(group === option.group));
2440
+ this.renderCosts();
2441
+ });
2442
+ this.costGroupRadios.set(option.group, radio);
2443
+ segment.append(radio);
2444
+ }
2445
+ head.append(segment);
2446
+ for (const sort of ["calls", "triangles"]) {
2447
+ const button = document.createElement("button");
2448
+ button.type = "button";
2449
+ button.className = "perf-monitor__cost-sort";
2450
+ button.textContent = sort === "calls" ? "calls" : "tris";
2451
+ button.title = `Sort by ${sort === "calls" ? "draw calls" : "triangles"}`;
2452
+ button.setAttribute("aria-pressed", String(sort === this.costSort));
2453
+ button.addEventListener("click", () => {
2454
+ this.costSort = sort;
2455
+ for (const [key, other] of this.costSortButtons) other.setAttribute("aria-pressed", String(key === sort));
2456
+ this.renderCosts();
2457
+ });
2458
+ this.costSortButtons.set(sort, button);
2459
+ head.append(button);
2460
+ }
2461
+ head.append(document.createElement("span"));
2462
+ this.costsListEl = document.createElement("div");
2463
+ this.costsListEl.className = "perf-monitor__costs-list";
2464
+ const hint = document.createElement("span");
2465
+ hint.className = "perf-monitor__hint";
2466
+ hint.textContent = COSTS_HELP;
2467
+ this.costsBodyEl.append(head, this.costsListEl, hint);
2468
+ section.append(toggleRow, this.costsBodyEl);
2469
+ return section;
2470
+ }
2471
+ buildReportRow() {
2472
+ const row = document.createElement("div");
2473
+ row.className = "perf-monitor__row perf-monitor__row--static";
2474
+ const label = document.createElement("span");
2475
+ label.className = "perf-monitor__label";
2476
+ label.textContent = "report";
2477
+ const button = document.createElement("button");
2478
+ button.type = "button";
2479
+ button.className = "perf-monitor__button";
2480
+ button.title = "Copy versions, GPU and current metrics as Markdown for a bug report";
2481
+ button.textContent = "copy";
2482
+ let resetTimer = 0;
2483
+ const flash = (text) => {
2484
+ button.textContent = text;
2485
+ window.clearTimeout(resetTimer);
2486
+ resetTimer = window.setTimeout(() => {
2487
+ button.textContent = "copy";
2488
+ }, COPY_FEEDBACK_MS);
2489
+ };
2490
+ button.addEventListener("click", () => {
2491
+ copyText(formatReport(this.monitor)).then((copied) => flash(copied ? "copied" : "failed"));
2492
+ });
2493
+ row.append(createIcon("clipboard", "perf-monitor__icon"), label, button);
2494
+ return row;
2495
+ }
2496
+ buildCheckbox(checked, title, onChange) {
2497
+ const checkbox = document.createElement("input");
2498
+ checkbox.type = "checkbox";
2499
+ checkbox.className = "perf-monitor__checkbox";
2500
+ checkbox.checked = checked;
2501
+ checkbox.title = title;
2502
+ checkbox.setAttribute("aria-label", title);
2503
+ checkbox.addEventListener("change", () => onChange(checkbox.checked));
2504
+ return checkbox;
2505
+ }
2506
+ applyInfoClass() {
2507
+ this.element.classList.toggle("perf-monitor--info", this.settings.info);
2508
+ }
2509
+ applyModeClass() {
2510
+ this.element.classList.toggle("perf-monitor--compact", this.mode === "compact");
2511
+ this.element.classList.toggle("perf-monitor--full", this.mode === "full");
2512
+ }
2513
+ resizeCanvases() {
2514
+ const dpr = window.devicePixelRatio || 1;
2515
+ const sizeAll = (graphs) => {
2516
+ for (const graph of graphs) {
2517
+ const { canvas, ctx } = graph;
2518
+ const width = canvas.clientWidth;
2519
+ const height = canvas.clientHeight;
2520
+ if (width === 0 || height === 0) continue;
2521
+ canvas.width = Math.round(width * dpr);
2522
+ canvas.height = Math.round(height * dpr);
2523
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2524
+ graph.width = width;
2525
+ graph.height = height;
2526
+ }
2527
+ };
2528
+ sizeAll(this.graphCanvases.values());
2529
+ sizeAll(this.hudGraphs.values());
2530
+ }
2115
2531
  };
2116
2532
  //#endregion
2117
2533
  //#region src/ui/mount-perf-hud.ts
2118
- const TOUCH_LINGER_MS = 2500;
2119
- const disc = (label, className, icon) => {
2120
- const button = document.createElement("button");
2121
- button.type = "button";
2122
- button.className = `perf-hud__disc ${className}`.trim();
2123
- button.setAttribute("aria-label", label);
2124
- button.title = label;
2125
- button.append(createIcon(icon, "perf-hud__icon"));
2126
- return button;
2127
- };
2128
2534
  const mountPerfHud = (monitor, options = {}) => {
2129
2535
  const storageKey = options.storageKey === void 0 ? DEFAULT_STORAGE_KEY : options.storageKey;
2130
2536
  const settings = options.settings ?? new HudSettings({ storageKey });
2131
2537
  const theme = new HudTheme(options.theme ?? "system");
2132
- const parent = options.parent ?? document.body;
2133
- if (options.injectStyles ?? true) injectStyles(parent.ownerDocument);
2134
- const host = document.createElement("div");
2135
- host.className = "perf-hud";
2136
- host.dataset.edge = "left";
2137
- host.setAttribute("aria-label", options.label ?? "Performance");
2138
- const hotspot = document.createElement("div");
2139
- hotspot.className = "perf-hud__hotspot";
2140
- hotspot.setAttribute("aria-hidden", "true");
2141
- const tools = document.createElement("div");
2142
- tools.className = "perf-hud__tools";
2143
- const grip = disc("Drag performance panel", "perf-hud__drag", "grip");
2144
- const toggle = disc("Toggle full performance metrics", "", "sliders");
2145
- const dim = disc("Dim the panel when the pointer leaves", "perf-hud__dim", "blend");
2146
- dim.addEventListener("click", () => settings.setDim(!settings.dim));
2147
- tools.append(grip, toggle, dim);
2148
2538
  const view = new PerformanceView({
2149
2539
  budgets: options.budgets,
2150
2540
  mode: options.mode ?? "compact",
@@ -2153,74 +2543,39 @@ const mountPerfHud = (monitor, options = {}) => {
2153
2543
  settings,
2154
2544
  theme
2155
2545
  });
2156
- host.append(hotspot, tools, view.element);
2157
- parent.append(host);
2546
+ const panel = mountDevPanel({
2547
+ brand: "three-meter",
2548
+ content: view.element,
2549
+ defaultPlacement: options.defaultPlacement,
2550
+ dim: {
2551
+ get: () => settings.dim,
2552
+ set: (on) => settings.setDim(on),
2553
+ subscribe: (listener) => settings.subscribe(listener)
2554
+ },
2555
+ injectStyles: options.injectStyles,
2556
+ label: options.label ?? "Performance",
2557
+ onToggle: () => setMode(view.getMode() === "compact" ? "full" : "compact"),
2558
+ parent: options.parent,
2559
+ storageKey,
2560
+ theme,
2561
+ toggleLabel: "Toggle full performance metrics"
2562
+ });
2563
+ const host = panel.element;
2158
2564
  const applyMode = () => {
2159
2565
  host.classList.toggle("perf-hud--full", view.getMode() === "full");
2160
2566
  };
2161
- const applyDim = () => {
2162
- host.classList.toggle("perf-hud--dim", settings.dim);
2163
- dim.setAttribute("aria-pressed", String(settings.dim));
2164
- dim.title = `Dim on leave: ${settings.dim ? "on" : "off"}`;
2165
- };
2166
- applyMode();
2167
- applyDim();
2168
- applyTheme(host, theme);
2169
- const unsubscribe = settings.subscribe(applyDim);
2170
- const unsubscribeTheme = theme.subscribe(() => applyTheme(host, theme));
2171
- const dock = dockPanel(host, {
2172
- defaultPlacement: options.defaultPlacement,
2173
- handle: grip,
2174
- storageKey
2175
- });
2176
2567
  const setMode = (mode) => {
2177
2568
  view.setMode(mode);
2178
2569
  applyMode();
2179
- requestAnimationFrame(() => dock.refresh());
2180
- };
2181
- toggle.addEventListener("click", () => {
2182
- setMode(view.getMode() === "compact" ? "full" : "compact");
2183
- });
2184
- let hideTimer = 0;
2185
- const sleep = () => {
2186
- if (host.classList.contains("is-dragging")) return;
2187
- host.classList.remove("is-awake");
2188
- };
2189
- const wake = (linger) => {
2190
- host.classList.add("is-awake");
2191
- window.clearTimeout(hideTimer);
2192
- if (linger) hideTimer = window.setTimeout(sleep, TOUCH_LINGER_MS);
2193
- };
2194
- const onEnter = () => {
2195
- wake(false);
2196
- };
2197
- const onLeave = (event) => {
2198
- if (host.contains(event.relatedTarget)) return;
2199
- if (event.pointerType === "touch" || event.pointerType === "pen") {
2200
- wake(true);
2201
- return;
2202
- }
2203
- sleep();
2204
- };
2205
- const onDown = (event) => {
2206
- wake(event.pointerType !== "mouse");
2570
+ panel.refresh();
2207
2571
  };
2208
- host.addEventListener("pointerenter", onEnter);
2209
- host.addEventListener("pointerleave", onLeave);
2210
- host.addEventListener("pointerdown", onDown);
2572
+ applyMode();
2211
2573
  view.start();
2212
2574
  return {
2213
2575
  dispose: () => {
2214
- window.clearTimeout(hideTimer);
2215
- host.removeEventListener("pointerenter", onEnter);
2216
- host.removeEventListener("pointerleave", onLeave);
2217
- host.removeEventListener("pointerdown", onDown);
2218
- unsubscribe();
2219
- unsubscribeTheme();
2220
- dock.dispose();
2576
+ panel.dispose();
2221
2577
  view.dispose();
2222
2578
  theme.dispose();
2223
- host.remove();
2224
2579
  },
2225
2580
  element: host,
2226
2581
  getMode: () => view.getMode(),
@@ -2234,6 +2589,6 @@ const mountPerfHud = (monitor, options = {}) => {
2234
2589
  };
2235
2590
  };
2236
2591
  //#endregion
2237
- export { formatReport as a, HudSettings as c, isThemeMode as d, resolveBudgets as f, PerformanceView as i, HudTheme as l, PERF_HUD_STYLES as n, dockPanel as o, injectStyles as r, DEFAULT_STORAGE_KEY as s, mountPerfHud as t, THEME_MODES as u };
2592
+ export { PERF_HUD_STYLES as a, DEFAULT_STORAGE_KEY as c, THEME_MODES as d, isThemeMode as f, mountDevPanel as i, HudSettings as l, PerformanceView as n, injectStyles as o, resolveBudgets as p, formatReport as r, dockPanel as s, mountPerfHud as t, HudTheme as u };
2238
2593
 
2239
- //# sourceMappingURL=mount-perf-hud-BL84ddLm.js.map
2594
+ //# sourceMappingURL=mount-perf-hud-DZKJ1L3J.js.map