diff --git a/bun.lock b/bun.lock
index 24fcbd7f04e..02f4d4b0ca0 100644
--- a/bun.lock
+++ b/bun.lock
@@ -108,6 +108,7 @@
"@sveltejs/vite-plugin-svelte": "7.0.0",
"@tailwindcss/vite": "^4.1.17",
"@vitest/browser-playwright": "^4.1.8",
+ "echarts": "6.0.0",
"feldera-theme": "workspace:*",
"monaco-editor": "0.55.1",
"playwright": "1.58.2",
@@ -115,6 +116,7 @@
"publint": "^0.3.15",
"svelte": "5.55.7",
"svelte-check": "^4.3.5",
+ "svelte-echarts": "1.0.0",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^8.0.0",
@@ -122,10 +124,12 @@
},
"peerDependencies": {
"@monaco-editor/loader": "1.7.0",
+ "echarts": "6.0.0",
"monaco-editor": "0.55.1",
"runed": "0.37.1",
"strip-ansi": "7.1.2",
"svelte": "5.55.7",
+ "svelte-echarts": "1.0.0",
"tiny-invariant": "1.3.3",
"virtua": "0.48.6",
},
@@ -1090,7 +1094,7 @@
"any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="],
- "apache-arrow": ["apache-arrow@github:Karakatiza666/arrow-js#ddba834", { "dependencies": { "@swc/helpers": "^0.5.11", "@types/command-line-args": "^5.2.3", "@types/command-line-usage": "^5.0.4", "@types/node": "^25.2.0", "command-line-args": "^6.0.1", "command-line-usage": "^7.0.1", "flatbuffers": "^25.1.24", "json-with-bigint": "^3.5.3", "tslib": "^2.6.2" }, "bin": { "arrow2csv": "bin/arrow2csv.cjs" } }, "Karakatiza666-arrow-js-ddba834", "sha512-N2H0djCXEuIXEXsZyPKGI4NT0xBhH3DdsNNP3Z273ym1h0OKHR6qrGld4l8RnxGjDwFzXhMZDEqEfN0NMFZUTA=="],
+ "apache-arrow": ["apache-arrow@github:Karakatiza666/arrow-js#ddba834", { "dependencies": { "@swc/helpers": "^0.5.11", "@types/command-line-args": "^5.2.3", "@types/command-line-usage": "^5.0.4", "@types/node": "^25.2.0", "command-line-args": "^6.0.1", "command-line-usage": "^7.0.1", "flatbuffers": "^25.1.24", "json-with-bigint": "^3.5.3", "tslib": "^2.6.2" }, "bin": { "arrow2csv": "bin/arrow2csv.cjs" } }, "Karakatiza666-arrow-js-ddba834"],
"apexcharts": ["apexcharts@5.6.0", "", { "dependencies": { "@yr/monotone-cubic-spline": "^1.0.3" } }, "sha512-BZua59yedRsaDfnxkzNrkyLCvluq2c3ZDBIz4joxSKtgr0xDQXQ5dzceMhf/TpTbAjaF+2NYIpLP3BEEIG2s/w=="],
diff --git a/js-packages/profiler-app/package.json b/js-packages/profiler-app/package.json
index 4795a85b871..f40a21df959 100644
--- a/js-packages/profiler-app/package.json
+++ b/js-packages/profiler-app/package.json
@@ -16,11 +16,13 @@
"@feldera/vite-plugin-monaco-editor": "workspace:*",
"@monaco-editor/loader": "1.7.0",
"common-ui": "workspace:*",
+ "echarts": "6.0.0",
"monaco-editor": "0.55.1",
"profiler-layout": "workspace:*",
"profiler-lib": "workspace:*",
"runed": "0.37.1",
"strip-ansi": "7.1.2",
+ "svelte-echarts": "1.0.0",
"tiny-invariant": "1.3.3",
"triage-types": "workspace:*",
"virtua": "0.48.6"
diff --git a/js-packages/profiler-layout/package.json b/js-packages/profiler-layout/package.json
index 91876fe3901..15ccdbad24f 100644
--- a/js-packages/profiler-layout/package.json
+++ b/js-packages/profiler-layout/package.json
@@ -29,22 +29,28 @@
"dependencies": {
"but-unzip": "^0.1.7",
"common-ui": "workspace:*",
+ "d3-format": "3.1.2",
"paneforge": "1.0.2",
"sort-on": "^7.0.0",
"triage-types": "workspace:*"
},
"peerDependencies": {
"@monaco-editor/loader": "1.7.0",
+ "echarts": "6.0.0",
"monaco-editor": "0.55.1",
"runed": "0.37.1",
"strip-ansi": "7.1.2",
"svelte": "5.55.7",
+ "svelte-echarts": "1.0.0",
"tiny-invariant": "1.3.3",
"virtua": "0.48.6"
},
"devDependencies": {
"@biomejs/biome": "^2.3.8",
+ "@types/d3-format": "3.0.4",
"@monaco-editor/loader": "1.7.0",
+ "echarts": "6.0.0",
+ "svelte-echarts": "1.0.0",
"@skeletonlabs/skeleton": "^4.8.0",
"@skeletonlabs/skeleton-svelte": "^4.9.0",
"@sveltejs/adapter-auto": "^7.0.0",
diff --git a/js-packages/profiler-layout/src/lib/components/MetricsView.svelte b/js-packages/profiler-layout/src/lib/components/MetricsView.svelte
index 15e06c77e53..29b95b4fec4 100644
--- a/js-packages/profiler-layout/src/lib/components/MetricsView.svelte
+++ b/js-packages/profiler-layout/src/lib/components/MetricsView.svelte
@@ -28,8 +28,12 @@
+
+
diff --git a/js-packages/profiler-layout/src/lib/components/metrics/README.md b/js-packages/profiler-layout/src/lib/components/metrics/README.md
new file mode 100644
index 00000000000..907fb2fa34e
--- /dev/null
+++ b/js-packages/profiler-layout/src/lib/components/metrics/README.md
@@ -0,0 +1,227 @@
+# Metrics visualization
+
+The Metrics tab renders the per-operator (and whole-circuit) readings a Feldera pipeline
+emits. This document explains **what kinds of metric exist and how each is visualized**, and
+how a raw reading travels from Rust to a rendered component.
+
+## Pipeline: from Rust to a rendered row
+
+1. **Source of truth (Rust):** `crates/dbsp/src/circuit/metadata.rs` defines
+ `CIRCUIT_METRICS` — every metric's `name` (`metric_id`), `category`
+ (`State | Inputs | Outputs | Cache | Time | Balancer | Multihost`), an `advanced`
+ flag, and a description. The value carried by a reading is a `MetaItem`
+ (`Int, Count, Percent, CacheCounts, String, Array, Map, Bytes, Duration, Bool`). Some
+ readings also carry **labels** (e.g. `compaction_state` and `completed_merges` are
+ emitted per `slot`).
+2. **Decoder (TS):** `profiler-lib` `Measurement.parseValues` dispatches on the **last
+ `_`-separated token** of `metric_id`. Scalars decode to one `PropertyValue`
+ (`CountValue`, `BytesValue`, `TimeValue`, `PercentValue`, `BooleanValue`, `StringValue`);
+ composites **expand into several sub-rows** (`x.count`, `x.avg_size`, …). Each decoded
+ `TooltipRow` is one (sub)field across all workers: `row.cells[worker].value`.
+3. **Grouping + classification (`dispatch.ts` + `kind.ts`):** `buildBlocks` groups rows by
+ category into blocks, then regroups the split sub-rows back under one `baseId` into a
+ `MetricGroup` and tags it with a `MetricKind` (`kind.ts:metricKind`).
+4. **Render:** every kind maps to a **widget** in `parts/` taking a single `MetricGroup`
+ (symmetric contract). `blocks/MetricKindBlock.svelte` is the generic single-metric card that
+ dispatches to the widget for a group's kind — including K1 (bar chart) via
+ `parts/BarChartMetric.svelte`. `blocks/MetricsDistributionBlock.svelte` is a **special case**
+ of the same system: it aggregates many bar-chart metrics into one card with a shared
+ Avg/Min/Max header, built from the same `parts/BarGrid.svelte` + `parts/BarChartRow.svelte`
+ atoms the K1 widget uses. `MetricsView` sends bar-chart kinds to the aggregated block and each
+ other kind to its own `MetricKindBlock` (`isCardKind` in `kind.ts` decides the split).
+5. **Layout (`MetricsView.svelte`):** each block (one per `category`) renders as a collapsible
+ ``. The category display name sits on the page background, left-aligned at `text-xl`,
+ with a chevron aligned right that collapses the whole category; collapse state lives in a
+ `collapsed` record keyed by block id (absent = expanded). Within an expanded category the
+ tiles flow across two columns once the scroll container is `>= TWO_COLUMN_THRESHOLD_PX` wide
+ (CSS multi-column, driven by a `ResizeObserver`), otherwise one. A category with no visible
+ tiles after filtering draws no header (`hasContent`). Search (`runSearch`) expands the target
+ category before scrolling to its `data-block-id`.
+
+## Metric kinds
+
+Each kind is one distinct value shape → one visualization. `metricKind()` derives the kind
+from the base id's suffix (`kind.ts`).
+
+| Kind | Shape | Visualization | Renderer |
+|------|-------|---------------|----------|
+| K1 | per-worker magnitude scalar (count/bytes/seconds) | relative-difference bar chart + Avg/Min/Max + Skew | `parts/BarChartMetric.svelte` |
+| K2 | per-worker bounded fraction (`percent`) | full-width percentage bars, fill grows top+bottom inward | `parts/PercentRingMetric.svelte` |
+| K3 | per-worker fraction of capacity (`{used, max}` bytes) | percentage bars per worker (`used/max`), like K2 | `parts/OccupancyRingMetric.svelte` |
+| K4 | per-worker categorical / string | one chip per worker (one chip-row per sub-field) | `parts/ChipMetric.svelte` |
+| K5 | per-worker boolean | read-only checkbox per worker + `k/N true` | `parts/BooleanMetric.svelte` |
+| K6 | per-(worker × level) categorical state | grid of state chips (levels × workers) | `parts/CompactionStateMetric.svelte` |
+| K7 | batch-size summary (`{count, record_count, min/avg/max size}`) | `batches`/`records`/`avg` column + min/avg/max lines, workers on y (sorted by avg), size on x | `parts/StatsMetric.svelte` |
+| K8 | cache counts (`{avg_latency, count, bytes, elapsed}`) | grid, `avg_latency` headline, then `count`, then `bytes` | `parts/CacheCountsMetric.svelte` |
+| K9 | merge progress per (worker × level) | histogram over levels, hover L→R switches worker | `parts/MergesHistogram.svelte` |
+| K10 | worker-fanout matrix (`Array` indexed by dest worker) | N×N heatmap, vertically squished | `parts/KeyDistributionMatrix.svelte` |
+| K11 | binned size histogram (`Array` of batch sizes) | histogram, hover L→R switches worker | `parts/SizeHistogram.svelte` |
+
+### K1 — per-worker magnitude scalar
+Comparable numbers (`count`/`bytes`/`seconds`) that vary per worker; the spread across
+workers (skew) is the signal. The bar chart min-max-normalizes bar heights and shows
+Avg/Min/Max columns plus a Skew toggle. Bar geometry is shared: `colors.barMetrics` maps a
+value to a `{t, height}` pair (log curve, 6px..24px envelope) and `parts/BarRow.svelte` renders
+the row; the cache tile's `ValueBars` reuse both, so the two look identical.
+Members: `used_memory_bytes`, `allocated_memory_bytes`, `state_records_count`,
+`input_records_count`, `runtime_seconds`, `steps_count`, the filter `*_count`/`*_size_bytes`,
+the exchange `*_bytes`/`*_seconds`, and the rest of the plain count/bytes/seconds scalars.
+
+### K2 — per-worker bounded fraction
+`Percent{numerator, denominator}`, already normalized to 0–100%. A relative-difference bar
+misleads here (95% vs 99% would look like full vs empty), so each worker is a **bar whose fill
+grows from the top and bottom edges inward** — single-px lines at 0%, filling the whole bar at
+100% (`PercentBars`, plain divs like the distribution bars; no ECharts). The fill color carries
+semantics via a `surface-100-900`→`error-500` scale keyed by a `semantics` prop (`low-bad` /
+`high-bad` / `none`); a generic percent has no known good/bad end, so it stays the neutral
+surface color (`none`). Avg weights by denominator; Min/Max/Skew are suppressed.
+Members: `runtime_percent`, `output_redundancy_percent`, `merge_reduction_percent`,
+`foreground_cache_hit_rate_percent`, `background_cache_hit_rate_percent`,
+`bloom_filter_hit_rate_percent`, `roaring_filter_hit_rate_percent`,
+`range_filter_hit_rate_percent`.
+
+### K3 — per-worker fraction of capacity
+Decodes to `{used, max}` bytes — the cache's current use against its capacity. Rendered with the
+same `PercentBars` as K2, plotting `used/max` as a percentage; the tooltip shows the raw
+`used / max` byte values. A full cache is neither inherently good nor bad, so no surface→error
+semantics apply (`semantics='none'`, neutral surface color). `max` is worker-invariant (capacity
+is split evenly, or one global capacity is reported on every worker).
+Members: `foreground_cache_occupancy`, `background_cache_occupancy`.
+
+### K4 — per-worker categorical / string
+Enum / identifier strings where magnitude is meaningless. One chip per worker; metrics with
+several string sub-fields (e.g. `retainment_bounds` → `key_bounds`, `value_bounds`) get one
+chip-row per sub-field.
+Members: `balancer_policy`, `retainment_bounds`, and the `persistent_id` node attribute.
+
+### K5 — per-worker boolean
+A read-only checkbox per worker plus a "k of N workers true" summary.
+Members: `rebalancing_in_progress`.
+
+### K6 — per-(worker × level) categorical state
+A `slot`-labeled enum string → a value per (worker, spine level). A **slot is one level of
+the async spine** (an LSM-style tier grouping similarly-sized batches; up to `MAX_LEVELS = 9`).
+Rendered as a grid: rows = levels, columns = workers, each cell a state chip
+(`none` / `requested` / `in progress`). The worker columns share the row width (CSS grid,
+`1fr` tracks capped by a max width), so a large worker count **compresses the chips
+horizontally** rather than wrapping or scrolling. A header row reuses the same grid: "Worker"
+sits in the left label column and worker numbers label every 4th column.
+Members: `compaction_state`.
+
+### K7 — batch-size summary
+Decodes to `{batches_count, total_records, min_size, avg_size, max_size}` **per worker**. These
+are not five peers: `count` (# batches) and `record_count` (total records) are magnitudes, while
+`min/avg/max` form a per-batch-size **range**. Rendered as a `batches`/`records`/`avg` column
+(avg = `records / batches`) beside a horizontal line chart: **workers on the y axis, sorted by
+average batch size** (top = smallest), **batch size on the x axis**, with three lines — avg (the
+primary series) between thin min and max lines, no fill. The gap between the min and max lines at
+each worker row is that worker's batch-size spread. The x axis spans the global min..max
+(`statsMetric.ts` computes the extent and positions; `fractionIn` is unit-tested); chart height
+grows with the worker count down to a 48px floor. Hovering (or focusing) a worker row marks that
+worker's min/avg/max on the lines and shows a labeled tooltip. This metric carries only
+per-worker summaries, so the distribution is **across workers**, not across individual batches
+(per-batch sizes are K11 `size_distribution`).
+
+A help icon next to the title (any kind with an entry in `kindHelp.ts`, currently K6, K7, K9)
+shows a tooltip explaining what the card is and how to read it. `MetricKindBlock` renders it.
+Members: `input_batches_stats`, `output_batches_stats`, `left_input_batches_stats`,
+`right_input_batches_stats`, `prefix_batches_stats`.
+
+### K8 — cache counts
+Decodes to `{count, bytes, elapsed, avg_latency}` (avg_latency = elapsed/count).
+Members: `foreground_cache_hits`, `foreground_cache_misses`, `background_cache_hits`,
+`background_cache_misses`.
+
+These are **not** rendered as their own K8 widget. Together with the K2 hit-rate metric they are
+subsumed into a composite **cache tile** (`cache.ts` + `parts/cache/CacheTile.svelte`), one per
+cache (foreground / background), built with Apache ECharts. The tile is **collapsible** like a K1
+distribution row: a chevron at the top right toggles it. Always visible are a per-worker
+**hit-rate** row (`PercentBars`, `semantics='low-bad'` — a low hit rate reddens toward error) and
+per-worker **effective-latency** bars (`hit_rate·avg_hit + miss_rate·avg_miss`, reusing the K1
+bar look via `ValueBars`, hover tooltip per bar). Expanding reveals the scatter diagrams and, to
+the left of the chevron, a `SegmentedControl` toggling the count/bytes axis: a row pairing two
+scatter-with-marginals of hit/miss count-or-bytes (counts via `formatQty`, bytes via `humanSize`)
+versus average latency; and a row pairing a scatter of hit rate vs miss/hit latency ratio with
+one of hit rate vs effective latency. Scatter X axes show 3 markers, Y axes 4.
+
+Clicking a bar or a dot **selects** that worker across the whole tile: the selected scatter dot
+renders in primary while the others fall back to a base color (no size change), and the selected
+bar gets a primary outline. Clicking the same element again, or clicking empty canvas (a zrender
+click with no target, via `EChart`'s `onblank`), clears the selection.
+
+Marginal densities are `line` series (so `EChart` registers `LineChart`) anchored at the exact
+data extremes so they span the point cloud. Both are smoothed; the right marginal uses no
+`areaStyle` (ECharts would fill toward the bottom rather than toward x=0) but relies on the line
+series' default grid clipping to trim any smoothing overshoot past the count=0 axis. The
+hits/misses plots take a `tooltipFormat` that always shows **both count and bytes** (plus the
+plotted latency) regardless of the count/bytes axis toggle. The "Hit rate vs effective latency"
+caption carries an inlined `CircleHelp` icon (from `feldera-material-icons`, `currentColor`) with
+a hover `Tooltip` explaining the formula.
+
+Color scale: the heatmap and scatter dots share endpoints — error at the worst reading, a
+theme-adaptive neutral (`surface-100-900`, dark in dark mode) at the best. Scatter dots are
+colored by `cornerColors` — an inverse-distance blend of 2–4 named corners
+(`{ corner: 'top_left' | 'top_right' | 'bottom_left' | 'bottom_right', color }`), symmetric for
+adjacent or opposite corners. ECharts renders to canvas and the theme tokens are `oklch`, so
+colors are resolved to sRGB via a 1×1 canvas pixel (`resolveRgb`) and blended in JS (`mixRgb`);
+parsing the computed string directly would misread oklch's L/C/H as R/G/B (grayscale bug).
+(`profile.propertyRange` is still plumbed onto `TooltipRow.range` → `MetricSubRow.range` as
+general per-metric range infrastructure.)
+
+### K9 — merge progress
+A `slot`-labeled tuple `{avg_step_time, batches, merges, steps}` per (worker, level). A
+**transposed** (horizontal) histogram showing the worker-averaged shape; moving the mouse
+left→right switches to an individual worker's per-level histogram. Layout (`WorkerSwitchHistogram`,
+rendered with ECharts via `EChart`): the binned category (spine level) is the **Y axis**, one bar
+per level (labels thinned to ~12 via `axisLabel.interval`); the `steps` value is the **X axis**,
+drawn in log-normalized space (`logScale01(value / sharedMax)`) so small bars stay visible. The
+axis is fixed to `[0, 1]`; ticks are round "nice" values (`niceTicks`, ≤5, integer — e.g. 500 /
+1000 / 5000) placed at their log positions via ECharts `customValues`, so labels read easily even
+though their spacing is uneven. `sharedMax` is the **global maximum across all workers** (not the
+averaged view) so switching workers never rescales it. Bars are a **flat neutral** (surface-200-800,
+resolved to RGB for the canvas since it cannot read CSS custom properties, and `.metrics-theme`
+vars are invisible to the resolver's body-level probe): length already encodes magnitude, so a
+color ramp would be redundant and the reserved error red would falsely imply "high = bad". In the
+**averaged view only**, each bar carries a right-hand **"Skew N%"** label = per-level cross-worker
+spread `(max - min) / max(|max|, |min|)`, colored neutral → error saturating at 50% (the same ramp
+K1 uses via `skewTextColor`, resolved to RGB). A `categoryTitle` ("Spine level" for K9, "Size bin"
+for K11) labels the Y axis top-left. Below the chart, next to the caption ("avg of N workers" / "Worker k"), a **worker
+strip** shows one cell per worker spanning the chart width; the hovered/focused worker's cell
+lights primary, and the cells are themselves hover/focus targets that drive the selection. The
+caller's header (e.g. "steps per level (log)") names the value quantity.
+Members: `completed_merges`.
+
+### K10 — worker-fanout matrix
+`key_distribution` is an `Array` indexed by **destination worker**; each worker reports
+its own outbound distribution, so stacked over all workers it is an N×N matrix (source →
+destination). Rendered as a vertically-squished heatmap so cross-worker skew is visible at a
+glance.
+Members: `key_distribution`.
+
+### K11 — binned size histogram
+`size_distribution` is an `Array` of the batch sizes a worker holds (index meaningless, length
+varies per worker). The sizes are binned into a histogram; like K9 it uses the transposed
+ECharts `WorkerSwitchHistogram` (size bin on the Y axis, log-scaled per-bin count on the X axis)
+and hovering left→right switches worker.
+Members: `size_distribution`.
+
+## Cross-cutting modifiers
+
+- **M1 — worker-uniform collapse:** when every worker reports an equal value, render one
+ combined value instead of N identical elements. Applies to K2/K3/K4/K5.
+- **M2 — `advanced` flag:** existing show/hide filter (`dispatch.ts`), orthogonal to kind.
+
+## Decoder support
+
+`profiler-lib`'s `parseValues` decodes every kind's suffix:
+
+| metric | suffix | decoded as |
+|--------|--------|------------|
+| `key_distribution` / `size_distribution` | `distribution` | `ArrayValue` (K10 / K11) |
+| `compaction_state` | `state` | `StringValue`, one per `slot` label (K6) |
+| `bloom_filter_bits_per_key` | `key` | `CountValue` (K1) |
+| `completed_merges` | `merges` | `merges`/`batches`/`steps`/`avg_step_seconds`/`avg_step_cpu_seconds`, per `slot` (K9) |
+
+`ArrayValue` is a non-comparable `PropertyValue` holding `number[]`; its `getNumericValue()`
+returns none so numeric-only consumers (the heatmap percentile scale, the bar chart) skip it,
+and the K10/K11 widgets read the array via `toArray()`.
diff --git a/js-packages/profiler-layout/src/lib/components/metrics/blocks/MetricKindBlock.svelte b/js-packages/profiler-layout/src/lib/components/metrics/blocks/MetricKindBlock.svelte
new file mode 100644
index 00000000000..73b75da6bf4
--- /dev/null
+++ b/js-packages/profiler-layout/src/lib/components/metrics/blocks/MetricKindBlock.svelte
@@ -0,0 +1,68 @@
+
+
+
+ {#if group.kind === 'K1'}
+
+
+
+ {:else}
+
+ {group.label}
+ {#if help}
+
+ {help}
+ {/if}
+
+ {#if group.kind === 'K2'}
+
+ {:else if group.kind === 'K3'}
+
+ {:else if group.kind === 'K4'}
+
+ {:else if group.kind === 'K5'}
+
+ {:else if group.kind === 'K6'}
+
+ {:else if group.kind === 'K7'}
+
+ {:else if group.kind === 'K8'}
+
+ {:else if group.kind === 'K9'}
+
+ {:else if group.kind === 'K10'}
+
+ {:else if group.kind === 'K11'}
+
+ {:else}
+
-
-
- {#each entries as entry, i (i)}
- toggle(entry)}
- />
- {/each}
-
+
+ {#each entries as entry (entry.baseId)}
+ {#each barRows(entry) as br (br.key)}
+
+ {/each}
+ {/each}
+
-
-
diff --git a/js-packages/profiler-layout/src/lib/components/metrics/cache.test.ts b/js-packages/profiler-layout/src/lib/components/metrics/cache.test.ts
new file mode 100644
index 00000000000..aa36eea9f61
--- /dev/null
+++ b/js-packages/profiler-layout/src/lib/components/metrics/cache.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest'
+import { buildCacheTiles, isCacheFamilyMetric } from './cache.js'
+import type { MetricGroup } from './dispatch.js'
+import { metricKind } from './kind.js'
+
+function group(baseId: string): MetricGroup {
+ return { baseId, label: baseId, kind: metricKind(baseId), rows: [] }
+}
+
+describe('isCacheFamilyMetric', () => {
+ it('matches the six hit/miss/hit-rate cache metrics', () => {
+ for (const prefix of ['foreground', 'background']) {
+ for (const suffix of ['hits', 'misses', 'hit_rate_percent']) {
+ expect(isCacheFamilyMetric(`${prefix}_cache_${suffix}`)).toBe(true)
+ }
+ }
+ })
+
+ it('excludes occupancy and unrelated metrics', () => {
+ expect(isCacheFamilyMetric('foreground_cache_occupancy')).toBe(false)
+ expect(isCacheFamilyMetric('background_cache_occupancy')).toBe(false)
+ expect(isCacheFamilyMetric('used_memory_bytes')).toBe(false)
+ })
+})
+
+describe('buildCacheTiles', () => {
+ it('assembles one tile per cache, foreground first, with hits/misses/hit-rate assigned', () => {
+ const tiles = buildCacheTiles([
+ group('background_cache_misses'),
+ group('foreground_cache_hits'),
+ group('background_cache_hit_rate_percent'),
+ group('foreground_cache_hit_rate_percent'),
+ group('foreground_cache_misses'),
+ group('background_cache_hits'),
+ group('foreground_cache_occupancy') // ignored
+ ])
+ expect(tiles.map((t) => t.prefix)).toEqual(['foreground', 'background'])
+ const fg = tiles[0]!
+ expect(fg.title).toBe('Foreground cache')
+ expect(fg.hits?.baseId).toBe('foreground_cache_hits')
+ expect(fg.misses?.baseId).toBe('foreground_cache_misses')
+ expect(fg.hitRate?.baseId).toBe('foreground_cache_hit_rate_percent')
+ })
+
+ it('returns no tiles when the family is absent', () => {
+ expect(buildCacheTiles([group('used_memory_bytes')])).toEqual([])
+ })
+})
diff --git a/js-packages/profiler-layout/src/lib/components/metrics/cache.ts b/js-packages/profiler-layout/src/lib/components/metrics/cache.ts
new file mode 100644
index 00000000000..bd06b68d24e
--- /dev/null
+++ b/js-packages/profiler-layout/src/lib/components/metrics/cache.ts
@@ -0,0 +1,47 @@
+// Groups the cache metric family into composite tiles. The hits/misses (K8) and hit-rate (K2)
+// metrics for each cache are subsumed into one CacheTile per cache (foreground / background)
+// rather than rendered as individual widgets. See ./README.md and parts/cache/CacheTile.svelte.
+import type { MetricGroup } from './dispatch'
+
+const CACHE_MEMBER = /^(foreground|background)_cache_(hits|misses|hit_rate_percent)$/
+
+/** True when a metric belongs to a cache tile (and so should not render as its own widget). */
+export function isCacheFamilyMetric(baseId: string): boolean {
+ return CACHE_MEMBER.test(baseId)
+}
+
+export type CacheTileData = {
+ prefix: string
+ title: string
+ hits?: MetricGroup
+ misses?: MetricGroup
+ hitRate?: MetricGroup
+}
+
+/** Assemble the cache-family groups in `groups` into one tile per cache, foreground first. */
+export function buildCacheTiles(groups: MetricGroup[]): CacheTileData[] {
+ const byPrefix = new Map()
+ for (const g of groups) {
+ const m = g.baseId.match(CACHE_MEMBER)
+ if (!m) {
+ continue
+ }
+ const prefix = m[1]!
+ let tile = byPrefix.get(prefix)
+ if (!tile) {
+ tile = { prefix, title: `${prefix[0]!.toUpperCase()}${prefix.slice(1)} cache` }
+ byPrefix.set(prefix, tile)
+ }
+ if (m[2] === 'hits') {
+ tile.hits = g
+ } else if (m[2] === 'misses') {
+ tile.misses = g
+ } else {
+ tile.hitRate = g
+ }
+ }
+ return ['foreground', 'background'].flatMap((p) => {
+ const t = byPrefix.get(p)
+ return t ? [t] : []
+ })
+}
diff --git a/js-packages/profiler-layout/src/lib/components/metrics/colors.test.ts b/js-packages/profiler-layout/src/lib/components/metrics/colors.test.ts
new file mode 100644
index 00000000000..73eb736da5f
--- /dev/null
+++ b/js-packages/profiler-layout/src/lib/components/metrics/colors.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest'
+import { barMetrics, logScale01 } from './colors.js'
+
+describe('barMetrics', () => {
+ it('maps the range endpoints to the height envelope', () => {
+ // v === min -> t 0 -> minH; v === max -> t 1 -> maxH.
+ expect(barMetrics(10, 10, 100, 6, 24)).toEqual({ t: 0, height: 6 })
+ expect(barMetrics(100, 10, 100, 6, 24)).toEqual({ t: 1, height: 24 })
+ })
+
+ it('is log-scaled between the endpoints (concave, emphasizes small values)', () => {
+ const mid = barMetrics(55, 10, 100, 6, 24) // raw 0.5
+ expect(mid.t).toBeCloseTo(logScale01(0.5), 10)
+ // Concave: the midpoint value sits above the linear halfway height.
+ expect(mid.height).toBeGreaterThan(6 + (24 - 6) * 0.5)
+ })
+
+ it('treats a degenerate range (no spread across workers) as a flat minimal bar', () => {
+ // A relative-difference chart has nothing to show when every worker is equal: t 0, not maxH.
+ expect(barMetrics(42, 42, 42)).toEqual({ t: 0, height: 6 })
+ })
+
+ it('honors custom height bounds', () => {
+ expect(barMetrics(100, 0, 100, 12, 32)).toEqual({ t: 1, height: 32 })
+ expect(barMetrics(0, 0, 100, 12, 32)).toEqual({ t: 0, height: 12 })
+ })
+})
diff --git a/js-packages/profiler-layout/src/lib/components/metrics/colors.ts b/js-packages/profiler-layout/src/lib/components/metrics/colors.ts
index 75863216cce..436e4118efb 100644
--- a/js-packages/profiler-layout/src/lib/components/metrics/colors.ts
+++ b/js-packages/profiler-layout/src/lib/components/metrics/colors.ts
@@ -48,3 +48,22 @@ export function logScale01(t: number): number {
// log(1 + x*(e-1)) / log(e) === log(1 + x*(e-1))
return Math.log(1 + x * (Math.E - 1))
}
+
+/**
+ * Bar geometry for a numeric value: log-normalize `v` within [min, max] to a fraction `t`
+ * (0..1, also the input to `barColor`) and the matching pixel `height` in [minH, maxH]. A
+ * degenerate range (min === max, i.e. no spread across workers) maps to `t = 0` — a flat
+ * minimal bar, since a relative-difference chart has nothing to show. Shared by the K1
+ * distribution bars (BarChartRow) and the cache tile's value bars (ValueBars) so both use the
+ * same curve and the same 6px..24px envelope.
+ */
+export function barMetrics(
+ v: number,
+ min: number,
+ max: number,
+ minH = 6,
+ maxH = 24
+): { t: number; height: number } {
+ const t = min === max ? 0 : logScale01((v - min) / (max - min))
+ return { t, height: minH + (maxH - minH) * t }
+}
diff --git a/js-packages/profiler-layout/src/lib/components/metrics/dispatch.test.ts b/js-packages/profiler-layout/src/lib/components/metrics/dispatch.test.ts
index 1f26305c9b8..9fe0dd5b644 100644
--- a/js-packages/profiler-layout/src/lib/components/metrics/dispatch.test.ts
+++ b/js-packages/profiler-layout/src/lib/components/metrics/dispatch.test.ts
@@ -1,5 +1,5 @@
-import { describe, expect, it } from 'vitest'
import type { NodeAttributes, TooltipRow } from 'profiler-lib'
+import { describe, expect, it } from 'vitest'
import { buildBlocks } from './dispatch.js'
function row(metric: string): TooltipRow {
@@ -21,12 +21,7 @@ function makeAttrs(metrics: string[]): NodeAttributes {
describe('buildBlocks', () => {
it('orders metrics lexicographically inside each block (case-insensitive)', () => {
// Metric IDs span categories; the order is intentionally scrambled to confirm the sort.
- const attrs = makeAttrs([
- 'zeta_seconds',
- 'Alpha_count',
- 'gamma_size',
- 'alpha_total'
- ])
+ const attrs = makeAttrs(['zeta_seconds', 'Alpha_count', 'gamma_size', 'alpha_total'])
const blocks = buildBlocks(attrs, /* showAdvanced */ true)
// All metrics fall into the same "Other" bucket (no descriptions registered),
// so we expect a single block whose entries are alphabetised by label.
diff --git a/js-packages/profiler-layout/src/lib/components/metrics/dispatch.ts b/js-packages/profiler-layout/src/lib/components/metrics/dispatch.ts
index 65957661733..eb457aa0790 100644
--- a/js-packages/profiler-layout/src/lib/components/metrics/dispatch.ts
+++ b/js-packages/profiler-layout/src/lib/components/metrics/dispatch.ts
@@ -1,20 +1,40 @@
// Builds the renderable block list for a NodeAttributes payload.
//
-// Blocks are grouped by the metric's category, retrieved from the profile metadata
-// (`ProfileMetricDescription::category`). Every block renders as a distribution.
+// Blocks are grouped by the metric's category (from `ProfileMetricDescription::category`).
+// Within a block, the sub-rows a composite metric was split into by `parseValues` (e.g.
+// `output_batches_stats.count`, `.avg_size`, …) are regrouped under their shared base id into
+// a single `MetricGroup`, tagged with the visualization kind. See ./README.md.
-import type { NodeAttributes, TooltipRow } from 'profiler-lib'
+import type { NodeAttributes, PropertyValue } from 'profiler-lib'
import { measurementCategory, measurementDescription } from 'profiler-lib'
+import { type MetricKind, metricKind, splitBaseId } from './kind'
-export type RenderableMetric = {
- row: TooltipRow
+/** One decoded (sub)field of a metric, carrying its per-worker values (indexed by worker). */
+export type MetricSubRow = {
+ /** Suffix after the base id: '' for scalars, e.g. '.count', '.used', '.slot:0.steps'. */
+ suffix: string
+ /** Humanized label of the full metric id (base + suffix). */
label: string
+ values: PropertyValue[]
+ /** Profile-wide value range for this metric (min/max across all nodes and workers). */
+ range?: { min: number; max: number }
+}
+
+/** A metric and all its sub-rows, grouped for rendering by a single kind renderer. */
+export type MetricGroup = {
+ /** Base metric id (before the first '.'), e.g. 'output_batches_stats'. */
+ baseId: string
+ /** Humanized label of the base id. */
+ label: string
+ kind: MetricKind
+ /** Sub-rows sharing this base id, in first-seen order. Scalars have exactly one, with ''. */
+ rows: MetricSubRow[]
}
export type RenderableBlock = {
id: string
title: string
- entries: RenderableMetric[]
+ entries: MetricGroup[]
}
const UNCATEGORIZED = 'Other'
@@ -31,37 +51,41 @@ const labelFor = (id: string): string => {
const slugify = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]+/g, '-')
export function buildBlocks(attrs: NodeAttributes, showAdvanced: boolean): RenderableBlock[] {
- // Preserve first-seen category order from the rows.
- const byCategory = new Map()
+ // Preserve first-seen category order; within a category, group rows by base id.
+ const byCategory = new Map>()
for (const row of attrs.rows) {
if (!showAdvanced && measurementDescription(row.metric).advanced) {
continue
}
const category = measurementCategory(row.metric) || UNCATEGORIZED
- let bucket = byCategory.get(category)
- if (!bucket) {
- bucket = []
- byCategory.set(category, bucket)
+ const { baseId, suffix } = splitBaseId(row.metric)
+ let groups = byCategory.get(category)
+ if (!groups) {
+ groups = new Map()
+ byCategory.set(category, groups)
+ }
+ let group = groups.get(baseId)
+ if (!group) {
+ group = { baseId, label: labelFor(baseId), kind: metricKind(baseId), rows: [] }
+ groups.set(baseId, group)
}
- bucket.push({
- row,
- label: labelFor(row.metric)
+ group.rows.push({
+ suffix,
+ label: labelFor(row.metric),
+ values: row.cells.map((c) => c.value),
+ ...(row.range ? { range: row.range } : {})
})
}
- // Sort metrics inside each block by their displayed label so users can scan a long block
+ // Sort groups inside each block by their displayed label so users can scan a long block
// without re-reading the whole thing. Locale-aware compare with `numeric: true`
// keeps numbered labels like "slot 2 ..." / "slot 10 ..." in their natural sequence.
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true })
- for (const entries of byCategory.values()) {
- entries.sort(
- (a, b) =>
- collator.compare(a.label, b.label) || collator.compare(a.row.metric, b.row.metric)
- )
- }
-
const out: RenderableBlock[] = []
- for (const [category, entries] of byCategory) {
+ for (const [category, groups] of byCategory) {
+ const entries = [...groups.values()].sort(
+ (a, b) => collator.compare(a.label, b.label) || collator.compare(a.baseId, b.baseId)
+ )
out.push({ id: `category-${slugify(category)}`, title: category, entries })
}
return out
diff --git a/js-packages/profiler-layout/src/lib/components/metrics/kind.test.ts b/js-packages/profiler-layout/src/lib/components/metrics/kind.test.ts
new file mode 100644
index 00000000000..1f0c6a40f6e
--- /dev/null
+++ b/js-packages/profiler-layout/src/lib/components/metrics/kind.test.ts
@@ -0,0 +1,151 @@
+import { describe, expect, it } from 'vitest'
+import { CARD_KINDS, isCardKind, type MetricKind, metricKind, splitBaseId } from './kind.js'
+
+describe('metricKind', () => {
+ // Exhaustive expected classification for every metric_id defined in CIRCUIT_METRICS
+ // (crates/dbsp/src/circuit/metadata.rs) plus the `persistent_id` node attribute. Keep in sync
+ // when metrics are added on the Rust side; a new metric that lands here as K1 by default
+ // should be moved to the kind it visualizes best. See ./README.md.
+ const EXPECTED: Record = {
+ K1: [
+ 'accumulator_records_to_repartition_count',
+ 'allocated_memory_bytes',
+ 'bloom_filter_bits_per_key',
+ 'bloom_filter_hits_count',
+ 'bloom_filter_misses_count',
+ 'bloom_filter_size_bytes',
+ 'circuit_idle_time_seconds',
+ 'circuit_runtime_elapsed_seconds',
+ 'circuit_runtime_seconds',
+ 'circuit_wait_time_seconds',
+ 'computed_output_records_count',
+ 'exchange_deserialization_time_seconds',
+ 'exchange_deserialized_bytes',
+ 'exchange_serialization_time_seconds',
+ 'exchange_serialized_bytes',
+ 'exchange_wait_time_seconds',
+ 'inprogress_rebalancing_time_seconds',
+ 'input_integral_records_count',
+ 'input_records_count',
+ 'integral_records_to_repartition_count',
+ 'invocations_count',
+ 'left_input_records_count',
+ 'local_shard_records_count',
+ 'loose_batches_count',
+ 'loose_memory_records_count',
+ 'loose_storage_records_count',
+ 'memory_allocations_count',
+ 'merge_backpressure_wait_time_seconds',
+ 'merging_batches_count',
+ 'merging_memory_records_count',
+ 'merging_size_bytes',
+ 'merging_storage_records_count',
+ 'negative_weight_count',
+ 'range_filter_hits_count',
+ 'range_filter_misses_count',
+ 'range_filter_size_bytes',
+ 'rebalancings_count',
+ 'right_input_integral_records_count',
+ 'roaring_filter_hits_count',
+ 'roaring_filter_misses_count',
+ 'roaring_filter_size_bytes',
+ 'runtime_seconds',
+ 'shared_memory_bytes',
+ 'spine_batches_count',
+ 'spine_count',
+ 'spine_storage_size_bytes',
+ 'state_records_count',
+ 'steps_count',
+ 'total_rebalancing_time_seconds',
+ 'used_memory_bytes'
+ ],
+ K2: [
+ 'background_cache_hit_rate_percent',
+ 'bloom_filter_hit_rate_percent',
+ 'foreground_cache_hit_rate_percent',
+ 'merge_reduction_percent',
+ 'nonblocking_percent',
+ 'output_redundancy_percent',
+ 'range_filter_hit_rate_percent',
+ 'roaring_filter_hit_rate_percent',
+ 'runtime_percent'
+ ],
+ K3: ['background_cache_occupancy', 'foreground_cache_occupancy'],
+ K4: ['balancer_policy', 'retainment_bounds', 'persistent_id'],
+ K5: ['rebalancing_in_progress_bool'],
+ K6: ['compaction_state'],
+ K7: [
+ 'input_batches_stats',
+ 'left_input_batches_stats',
+ 'output_batches_stats',
+ 'prefix_batches_stats',
+ 'right_input_batches_stats'
+ ],
+ K8: [
+ 'background_cache_hits',
+ 'background_cache_misses',
+ 'foreground_cache_hits',
+ 'foreground_cache_misses'
+ ],
+ K9: ['completed_merges'],
+ K10: ['key_distribution'],
+ K11: ['size_distribution']
+ }
+
+ for (const [kind, ids] of Object.entries(EXPECTED) as Array<[MetricKind, string[]]>) {
+ it.each(ids)(`classifies %s as ${kind}`, (metric) => {
+ expect(metricKind(metric)).toBe(kind)
+ })
+ }
+
+ it('falls back to K1 for unknown suffixes', () => {
+ expect(metricKind('some_new_metric_widget')).toBe('K1')
+ })
+})
+
+describe('splitBaseId', () => {
+ it('returns the whole id and empty suffix when there is no dot', () => {
+ expect(splitBaseId('used_memory_bytes')).toEqual({
+ baseId: 'used_memory_bytes',
+ suffix: ''
+ })
+ })
+
+ it('splits a composite sub-row at the first dot', () => {
+ expect(splitBaseId('output_batches_stats.avg_size')).toEqual({
+ baseId: 'output_batches_stats',
+ suffix: '.avg_size'
+ })
+ })
+
+ it('keeps labels and trailing sub-fields together in the suffix', () => {
+ expect(splitBaseId('completed_merges.slot:0.steps')).toEqual({
+ baseId: 'completed_merges',
+ suffix: '.slot:0.steps'
+ })
+ })
+})
+
+describe('isCardKind', () => {
+ it('routes every non-bar kind to its own card', () => {
+ for (const k of [
+ 'K2',
+ 'K3',
+ 'K4',
+ 'K5',
+ 'K6',
+ 'K7',
+ 'K8',
+ 'K9',
+ 'K10',
+ 'K11'
+ ] as MetricKind[]) {
+ expect(isCardKind(k)).toBe(true)
+ }
+ })
+
+ it('keeps K1 (bar chart) in the distribution grid', () => {
+ expect(isCardKind('K1')).toBe(false)
+ expect(CARD_KINDS.has('K1')).toBe(false)
+ })
+})
diff --git a/js-packages/profiler-layout/src/lib/components/metrics/kind.ts b/js-packages/profiler-layout/src/lib/components/metrics/kind.ts
new file mode 100644
index 00000000000..aeda8b527e0
--- /dev/null
+++ b/js-packages/profiler-layout/src/lib/components/metrics/kind.ts
@@ -0,0 +1,87 @@
+// Classifies a metric into a visualization kind. See ./README.md for the full catalog of
+// kinds, their value shapes, and member metrics.
+//
+// The kind is derived from the last `_`-separated token of the metric's *base id* (the part
+// before the first '.'), mirroring the suffix dispatch in profiler-lib's `parseValues`. The
+// base id is used so composite sub-rows (e.g. `output_batches_stats.count`) and labeled rows
+// (e.g. `compaction_state.slot:0`) classify by their parent metric.
+
+export type MetricKind =
+ | 'K1' // per-worker magnitude scalar (count/bytes/seconds) -> bar chart
+ | 'K2' // per-worker bounded fraction (percent) -> ring
+ | 'K3' // per-worker fraction of capacity ({used, max}) -> ring (track = max)
+ | 'K4' // per-worker categorical / string -> chips
+ | 'K5' // per-worker boolean -> checkboxes
+ | 'K6' // per-(worker x level) categorical state -> chip grid
+ | 'K7' // batch-size summary tuple -> count/total + range bar
+ | 'K8' // cache counts tuple -> grid
+ | 'K9' // merge progress per (worker x level) -> histogram (switch worker)
+ | 'K10' // worker-fanout matrix -> heatmap
+ | 'K11' // binned size histogram -> histogram (switch worker)
+
+/** Split a metric id into its base id (before the first '.') and the remaining suffix. */
+export function splitBaseId(metric: string): { baseId: string; suffix: string } {
+ const dot = metric.indexOf('.')
+ if (dot < 0) {
+ return { baseId: metric, suffix: '' }
+ }
+ return { baseId: metric.slice(0, dot), suffix: metric.slice(dot) }
+}
+
+/** Classify a base metric id into its visualization kind. Unknown suffixes fall to K1. */
+export function metricKind(baseId: string): MetricKind {
+ const suffix = baseId.split('_').pop()
+ switch (suffix) {
+ case 'percent':
+ return 'K2'
+ case 'occupancy':
+ return 'K3'
+ case 'policy':
+ case 'bounds':
+ case 'id':
+ return 'K4'
+ case 'bool':
+ return 'K5'
+ case 'state':
+ return 'K6'
+ case 'stats':
+ return 'K7'
+ case 'hits':
+ case 'misses':
+ return 'K8'
+ case 'merges':
+ return 'K9'
+ case 'distribution':
+ // Both `key_distribution` and `size_distribution` share this suffix; the array axis of
+ // the former is the worker axis (a matrix), the latter is a bag of batch sizes.
+ return baseId.startsWith('key_') ? 'K10' : 'K11'
+ default:
+ return 'K1'
+ }
+}
+
+/**
+ * Kinds that render as their own self-contained card, outside the bar-chart distribution grid.
+ *
+ * The distribution grid (`MetricsDistributionBlock`) is bar-chart-only: a fixed Avg/Min/Max
+ * grid with a per-row skew toggle. Only kinds absent from this set go there; a kind is added
+ * here once its dedicated card renderer lands (see ./README.md). While empty, every metric
+ * still renders as a bar chart, exactly as before.
+ */
+export const CARD_KINDS: ReadonlySet = new Set([
+ 'K2',
+ 'K3',
+ 'K4',
+ 'K5',
+ 'K6',
+ 'K7',
+ 'K8',
+ 'K9',
+ 'K10',
+ 'K11'
+])
+
+/** True when a kind renders as its own card rather than a row in the bar-chart grid. */
+export function isCardKind(kind: MetricKind): boolean {
+ return CARD_KINDS.has(kind)
+}
diff --git a/js-packages/profiler-layout/src/lib/components/metrics/kindHelp.ts b/js-packages/profiler-layout/src/lib/components/metrics/kindHelp.ts
new file mode 100644
index 00000000000..91efe27801c
--- /dev/null
+++ b/js-packages/profiler-layout/src/lib/components/metrics/kindHelp.ts
@@ -0,0 +1,26 @@
+import type { MetricKind } from './kind'
+
+// Concise "what is this and how do I read it" help for kinds whose visualization is not
+// self-evident. Shown as a tooltip on a help icon next to the metric title (MetricKindBlock).
+// Kinds absent from this map get no help icon.
+const KIND_HELP: Partial> = {
+ K6:
+ 'Compaction state per spine level. One row per level (slot), one cell per worker, colored by ' +
+ 'state: idle, compaction requested, or compaction in progress. Read down a column to see one ' +
+ 'worker across levels; read across a row to compare workers at the same level. Hover a cell ' +
+ 'for its worker and state.',
+ K7:
+ 'Batch-size distribution across workers. Each row is a worker, sorted by average records ' +
+ 'per batch (smallest on top); the x axis is records per batch. The avg line runs between the ' +
+ 'min and max lines, so the horizontal gap on a row is that worker’s batch-size spread. ' +
+ 'Hover a row to mark that worker on the lines.',
+ K9:
+ 'Completed merge work per spine level. Each row is a spine level; bar length is the number ' +
+ 'of merge steps completed there (log-scaled, so small bars stay visible). Shows the average and skew ' +
+ 'across workers by default; hover the cursor left to right to inspect a single worker.'
+}
+
+/** Help text for a kind, or undefined when the visualization needs no explanation. */
+export function kindHelp(kind: MetricKind): string | undefined {
+ return KIND_HELP[kind]
+}
diff --git a/js-packages/profiler-layout/src/lib/components/metrics/parts/BarChartMetric.svelte b/js-packages/profiler-layout/src/lib/components/metrics/parts/BarChartMetric.svelte
index 6f7800b60fb..55d9a8b9eb6 100644
--- a/js-packages/profiler-layout/src/lib/components/metrics/parts/BarChartMetric.svelte
+++ b/js-packages/profiler-layout/src/lib/components/metrics/parts/BarChartMetric.svelte
@@ -1,173 +1,22 @@
-
-
- {label}
-
-
{label}
-
{metricId}
-
-
-
-{#each [display.avg, display.min, display.max] as stat}
-
- {stat.toString()}
-
-{/each}
-
-
-
-
-
-
-
- {#each values as v, i (i)}
- {@const b = bar(v)}
-
- Worker {i}: {v.toString()}
- {/each}
-
+ Hit rate vs effective latency
+
+
+ Effective latency = hit rate × avg hit latency + miss rate × avg miss latency (miss
+ rate = 1 − hit rate). The average latency of a cache access at this worker's hit rate.
+
+