From 92a891e7c3a176fba544088695d672c650a50bed Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:30:59 -0500 Subject: [PATCH 1/5] fix: widen eval_results unique constraint to include isl, osl, conc --- packages/db/migrations/002_eval_unique_conc.sql | 8 ++++++++ packages/db/src/etl/eval-ingest.ts | 2 +- packages/db/src/ingest-ci-run.ts | 4 ++-- 3 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 packages/db/migrations/002_eval_unique_conc.sql diff --git a/packages/db/migrations/002_eval_unique_conc.sql b/packages/db/migrations/002_eval_unique_conc.sql new file mode 100644 index 0000000..5f3ba9b --- /dev/null +++ b/packages/db/migrations/002_eval_unique_conc.sql @@ -0,0 +1,8 @@ +-- Widen eval_results unique constraint to include isl, osl, conc. + +alter table eval_results + drop constraint eval_results_unique; + +alter table eval_results + add constraint eval_results_unique + unique (workflow_run_id, config_id, task, isl, osl, conc); diff --git a/packages/db/src/etl/eval-ingest.ts b/packages/db/src/etl/eval-ingest.ts index 74ddeea..f1123c0 100644 --- a/packages/db/src/etl/eval-ingest.ts +++ b/packages/db/src/etl/eval-ingest.ts @@ -37,7 +37,7 @@ export async function ingestEvalRow( ${p.isl}, ${p.osl}, ${p.conc}, ${p.lmEvalVersion}, ${sql.json(p.metrics)} ) - on conflict (workflow_run_id, config_id, task) + on conflict (workflow_run_id, config_id, task, isl, osl, conc) do update set metrics = excluded.metrics returning (xmax = 0) as inserted `; diff --git a/packages/db/src/ingest-ci-run.ts b/packages/db/src/ingest-ci-run.ts index 38b68cd..5f56086 100644 --- a/packages/db/src/ingest-ci-run.ts +++ b/packages/db/src/ingest-ci-run.ts @@ -87,7 +87,7 @@ if (isDownloadMode) { console.log(`\n--- Downloading artifacts to ${artifactsDir} ---`); const artifactListJson = execSync( - `gh api "repos/${REPO}/actions/runs/${runIdStr}/artifacts" --paginate`, + `gh api "repos/${REPO}/actions/runs/${runIdStr}/artifacts" --paginate --jq '.artifacts[]'`, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 }, ); @@ -97,7 +97,7 @@ if (isDownloadMode) { if (!line) continue; try { const parsed = JSON.parse(line); - if (parsed.artifacts) allArtifacts.push(...parsed.artifacts); + allArtifacts.push(parsed); } catch {} } From 7d42782a50c2dad4072e4df17e2a7b26d35ec274 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:15:13 -0500 Subject: [PATCH 2/5] fix: add p90 and p99.9 percentile metrics to known keys --- packages/db/src/etl/benchmark-mapper.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/db/src/etl/benchmark-mapper.ts b/packages/db/src/etl/benchmark-mapper.ts index a04c8d0..940d8be 100644 --- a/packages/db/src/etl/benchmark-mapper.ts +++ b/packages/db/src/etl/benchmark-mapper.ts @@ -69,23 +69,33 @@ const KNOWN_METRIC_RAW_KEYS = new Set([ 'input_tput_per_gpu', 'median_ttft', 'mean_ttft', + 'p90_ttft', 'p99_ttft', + 'p99.9_ttft', 'std_ttft', 'median_tpot', 'mean_tpot', + 'p90_tpot', 'p99_tpot', + 'p99.9_tpot', 'std_tpot', 'median_itl', 'mean_itl', + 'p90_itl', 'p99_itl', + 'p99.9_itl', 'std_itl', 'median_e2el', 'mean_e2el', + 'p90_e2el', 'p99_e2el', + 'p99.9_e2el', 'std_e2el', 'median_intvty', 'mean_intvty', + 'p90_intvty', 'p99_intvty', + 'p99.9_intvty', 'std_intvty', ]); From 8eb8461331c577c2a18e3e77e1ee0f20267dc50b Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:04:28 -0500 Subject: [PATCH 3/5] fix: filter evals by exact date and dedup by hw-framework-spec-precision --- .../evaluation/EvaluationContext.tsx | 72 +++++++++---------- 1 file changed, 34 insertions(+), 38 deletions(-) diff --git a/packages/app/src/components/evaluation/EvaluationContext.tsx b/packages/app/src/components/evaluation/EvaluationContext.tsx index abd89a0..3be9766 100644 --- a/packages/app/src/components/evaluation/EvaluationContext.tsx +++ b/packages/app/src/components/evaluation/EvaluationContext.tsx @@ -167,13 +167,13 @@ export function EvaluationProvider({ children }: { children: ReactNode }) { const dbModelKey = DISPLAY_MODEL_TO_DB[selectedModel]; if (!dbModelKey) return []; - const filteredData = rawData + // Map all rows up to selected date + const allData = rawData .filter((item) => { - const itemDate = item.date; return ( item.task === selectedBenchmark && item.model === dbModelKey && - itemDate <= selectedRunDate + item.date <= selectedRunDate ); }) .map((item): EvaluationChartData | null => { @@ -187,9 +187,6 @@ export function EvaluationProvider({ children }: { children: ReactNode }) { ) as keyof typeof HARDWARE_CONFIG; if (hwKey === 'unknown') return null; - const itemDate = item.date; - const itemDateTime = item.timestamp ?? ''; - return { configId: item.config_id, hwKey, @@ -199,8 +196,8 @@ export function EvaluationProvider({ children }: { children: ReactNode }) { model: item.model, benchmark: item.task, specDecode: item.spec_method, - date: itemDate, - datetime: itemDateTime, + date: item.date, + datetime: item.timestamp ?? '', precision: item.precision, framework: item.framework, tp: item.decode_tp, @@ -211,39 +208,38 @@ export function EvaluationProvider({ children }: { children: ReactNode }) { }) .filter((item): item is EvaluationChartData => item !== null); - // Group by config_id and keep most recent per TP+conc combination - const groupMap = new Map(); - filteredData.forEach((item) => { - if (!groupMap.has(item.configId)) groupMap.set(item.configId, []); - groupMap.get(item.configId)!.push(item); - }); + // Group by hw-framework-specmethod-precision. Find the latest date for each + // group (up to selectedRunDate), then keep ALL rows from that date for the + // group. This means if parallelism (conc/tp/ep) changes between runs, we + // only show the latest run's parallelism — old combos don't leak in. + const groupKeyFn = (item: EvaluationChartData) => + `${item.hwKey}_${item.framework}_${item.specDecode}_${item.precision}`; + + const latestDateForGroup = new Map(); + for (const item of allData) { + const key = groupKeyFn(item); + const existing = latestDateForGroup.get(key); + if (!existing || item.date > existing) latestDateForGroup.set(key, item.date); + } const result: EvaluationChartData[] = []; - groupMap.forEach((groupItems) => { - // Dedup by TP+conc, keeping most recent date for each combination - const dedupMap = new Map(); - groupItems.forEach((item) => { - const key = `${item.tp}_${item.conc}`; - const existing = dedupMap.get(key); - if (!existing || item.date > existing.date) dedupMap.set(key, item); - }); - - dedupMap.forEach((item) => { - const hwConfig = HARDWARE_CONFIG[item.hwKey]; - const hwLabel = String(hwConfig?.label || item.hwKey); - result.push({ - ...item, - configLabel: buildConfigLabel( - hwLabel, - item.framework, - item.specDecode, - item.precision, - item.conc, - item.tp, - ), - }); + for (const item of allData) { + const key = groupKeyFn(item); + if (item.date !== latestDateForGroup.get(key)) continue; + const hwConfig = HARDWARE_CONFIG[item.hwKey]; + const hwLabel = String(hwConfig?.label || item.hwKey); + result.push({ + ...item, + configLabel: buildConfigLabel( + hwLabel, + item.framework, + item.specDecode, + item.precision, + item.conc, + item.tp, + ), }); - }); + } return result.sort((a, b) => a.configLabel.localeCompare(b.configLabel)); }, [rawData, selectedBenchmark, selectedModel, selectedRunDate]); From 7f7b0dc78a3932f3038c9264edc3383a0af1f5d0 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:18:26 -0500 Subject: [PATCH 4/5] fix: update grid line extents on re-render when chart dimensions change --- packages/app/src/lib/d3-chart/chart-update.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/app/src/lib/d3-chart/chart-update.ts b/packages/app/src/lib/d3-chart/chart-update.ts index 9387980..12b07ad 100644 --- a/packages/app/src/lib/d3-chart/chart-update.ts +++ b/packages/app/src/lib/d3-chart/chart-update.ts @@ -102,7 +102,11 @@ export function renderGrid( (exit) => exit.remove(), ); const vTarget = dur > 0 ? (vJoin as any).transition().duration(dur) : vJoin; - vTarget.attr('x1', (d: number) => tickScale(d)).attr('x2', (d: number) => tickScale(d)); + vTarget + .attr('x1', (d: number) => tickScale(d)) + .attr('x2', (d: number) => tickScale(d)) + .attr('y1', 0) + .attr('y2', height); } // Horizontal grid lines — reuse existing group @@ -137,6 +141,10 @@ export function renderGrid( (exit) => exit.remove(), ); const hTarget = dur > 0 ? (hJoin as any).transition().duration(dur) : hJoin; - hTarget.attr('y1', (d: number) => yScale(d)).attr('y2', (d: number) => yScale(d)); + hTarget + .attr('x1', 0) + .attr('x2', width) + .attr('y1', (d: number) => yScale(d)) + .attr('y2', (d: number) => yScale(d)); } } From 04575bf15b4ba51e7cec31b5e32227c2b09ba47a Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:18:37 -0500 Subject: [PATCH 5/5] feat: horizontal eval bar chart with labels on y-axis and dynamic height --- .../components/evaluation/ui/BarChartD3.tsx | 170 +++++++++++------- 1 file changed, 103 insertions(+), 67 deletions(-) diff --git a/packages/app/src/components/evaluation/ui/BarChartD3.tsx b/packages/app/src/components/evaluation/ui/BarChartD3.tsx index 7e1a50e..8980fc3 100644 --- a/packages/app/src/components/evaluation/ui/BarChartD3.tsx +++ b/packages/app/src/components/evaluation/ui/BarChartD3.tsx @@ -7,7 +7,7 @@ import * as d3 from 'd3'; import { getModelSortIndex } from '@/lib/constants'; import { D3Chart } from '@/lib/d3-chart/D3Chart'; import type { LayerConfig } from '@/lib/d3-chart/D3Chart'; -import { renderErrorBars, updateErrorBarsOnZoom } from '@/lib/d3-chart/layers/error-bars'; +import { renderErrorBars } from '@/lib/d3-chart/layers/error-bars'; import { renderPoints, updatePointsOnZoom } from '@/lib/d3-chart/layers/points'; import { useEvaluation } from '@/components/evaluation/EvaluationContext'; @@ -22,7 +22,7 @@ import ChartLegend from '@/components/ui/chart-legend'; import { Skeleton } from '@/components/ui/skeleton'; import { useThemeColors } from '@/hooks/useThemeColors'; -const CHART_MARGIN = { top: 24, right: 10, bottom: 80, left: 60 }; +const CHART_MARGIN = { top: 24, right: 24, bottom: 52, left: 160 }; const generateEvaluationTooltipContent = (data: EvaluationChartData, isPinned: boolean): string => { const minScore = data.minScore ?? data.score; @@ -44,26 +44,23 @@ const generateEvaluationTooltipContent = (data: EvaluationChartData, isPinned: b `; }; -/** Custom x-axis label formatting: split on newline, rotate -36deg */ -function formatXAxisLabels(axisGroup: d3.Selection) { +/** Custom y-axis label formatting for horizontal bar chart: split on newline, show multi-line */ +function formatYAxisLabels(axisGroup: d3.Selection) { axisGroup.selectAll('.tick text').each(function () { const el = d3.select(this); const label = el.text(); const lines = label.split('\n'); + const totalHeight = lines.length * 1.1; // em units el.text(null); lines.forEach((line: string, i: number) => { el.append('tspan') .text(line) - .attr('x', 0) - .attr('dy', i === 0 ? '0' : '1.1em') + .attr('x', -8) + .attr('dy', i === 0 ? `${-totalHeight / 2 + 0.9}em` : '1.1em') .attr('font-weight', i === 0 ? '600' : 'normal') .attr('font-size', i === 0 ? '10px' : '9px'); }); - el.attr('transform', 'rotate(-36)') - .attr('text-anchor', 'end') - .attr('dx', '-.8em') - .attr('dy', '1em') - .attr('font-size', '10px'); + el.attr('text-anchor', 'end'); }); } @@ -134,60 +131,98 @@ export default function EvalBarChartD3({ caption }: { caption?: ReactNode }) { [configurations, enabledHardware, highlightedConfigs, toggleHardware, resolveColor], ); - const yDomain = useMemo((): [number, number] => { + const xDomain = useMemo((): [number, number] => { if (chartData.length === 0) return [0, 1]; - const yMin = d3.min(chartData, (d) => d.score - (d.scoreError || 0)) || 0; - const yMax = d3.max(chartData, (d) => d.score + (d.scoreError || 0)) || 1; - const yPadding = (yMax - yMin) * 0.3; - return [Math.max(0, yMin - yPadding), Math.min(1, yMax + yPadding)]; + const xMin = d3.min(chartData, (d) => d.score - (d.scoreError || 0)) || 0; + const xMax = d3.max(chartData, (d) => d.score + (d.scoreError || 0)) || 1; + const xPadding = (xMax - xMin) * 0.3; + return [Math.max(0, xMin - xPadding), Math.min(1, xMax + xPadding)]; }, [chartData]); + const chartHeight = Math.max(400, chartData.length * 40 + CHART_MARGIN.top + CHART_MARGIN.bottom); + const errorData = useMemo( () => chartData.filter((d) => d.errorMin !== undefined && d.errorMax !== undefined), [chartData], ); - // Use custom layers since error bars + points need band-scale-aware positioning + // Horizontal bar chart: yScale = band (config labels), xScale = linear (scores) const layers = useMemo( (): LayerConfig[] => [ { type: 'custom', key: 'error-bars', render: (group, { xScale: xs, yScale: ys }) => { - const xScale = xs as d3.ScaleBand; - const yScale = ys as d3.ScaleLinear; + const xScale = xs as d3.ScaleLinear; + const yScale = ys as d3.ScaleBand; + // Horizontal error bars: swap x/y semantics + // getCx = y center, getYMin = x left, getYMax = x right, capWidth = vertical cap height renderErrorBars(group, errorData, { getCx: (d: EvaluationChartData) => - (xScale(d.configLabel) || 0) + xScale.bandwidth() / 2, - getYMin: (d: EvaluationChartData) => yScale(d.errorMin!), - getYMax: (d: EvaluationChartData) => yScale(d.errorMax!), - capWidth: xScale.bandwidth() / 3, + (yScale(d.configLabel) || 0) + yScale.bandwidth() / 2, + getYMin: (d: EvaluationChartData) => xScale(d.errorMin!), + getYMax: (d: EvaluationChartData) => xScale(d.errorMax!), + capWidth: yScale.bandwidth() / 3, stroke: 'var(--foreground)', }); + // Rotate error bars 90 degrees — the render draws vertical, we need horizontal. + // Instead, manually position: stem is horizontal, caps are vertical. + const bars = group.selectAll('.error-bar'); + bars + .select('.eb-stem') + .attr('x1', (d) => xScale(d.errorMin!)) + .attr('x2', (d) => xScale(d.errorMax!)) + .attr('y1', (d) => (yScale(d.configLabel) || 0) + yScale.bandwidth() / 2) + .attr('y2', (d) => (yScale(d.configLabel) || 0) + yScale.bandwidth() / 2); + const capH = yScale.bandwidth() / 6; + bars + .select('.eb-cap-top') + .attr('x1', (d) => xScale(d.errorMin!)) + .attr('x2', (d) => xScale(d.errorMin!)) + .attr('y1', (d) => (yScale(d.configLabel) || 0) + yScale.bandwidth() / 2 - capH) + .attr('y2', (d) => (yScale(d.configLabel) || 0) + yScale.bandwidth() / 2 + capH); + bars + .select('.eb-cap-bot') + .attr('x1', (d) => xScale(d.errorMax!)) + .attr('x2', (d) => xScale(d.errorMax!)) + .attr('y1', (d) => (yScale(d.configLabel) || 0) + yScale.bandwidth() / 2 - capH) + .attr('y2', (d) => (yScale(d.configLabel) || 0) + yScale.bandwidth() / 2 + capH); }, onZoom: (group, ctx) => { - const xScale = ctx.xScale as d3.ScaleBand; - const newYScale = ctx.newYScale as d3.ScaleLinear; - updateErrorBarsOnZoom(group, { - getCx: (d: EvaluationChartData) => - (xScale(d.configLabel) || 0) + xScale.bandwidth() / 2, - getYMin: (d: EvaluationChartData) => newYScale(d.errorMin!), - getYMax: (d: EvaluationChartData) => newYScale(d.errorMax!), - capWidth: xScale.bandwidth() / 3, - stroke: 'var(--foreground)', - }); + const newXScale = ctx.newXScale as d3.ScaleLinear; + const yScale = ctx.yScale as d3.ScaleBand; + const bars = group.selectAll('.error-bar'); + bars + .select('.eb-stem') + .attr('x1', (d) => newXScale(d.errorMin!)) + .attr('x2', (d) => newXScale(d.errorMax!)) + .attr('y1', (d) => (yScale(d.configLabel) || 0) + yScale.bandwidth() / 2) + .attr('y2', (d) => (yScale(d.configLabel) || 0) + yScale.bandwidth() / 2); + const capH = yScale.bandwidth() / 6; + bars + .select('.eb-cap-top') + .attr('x1', (d) => newXScale(d.errorMin!)) + .attr('x2', (d) => newXScale(d.errorMin!)) + .attr('y1', (d) => (yScale(d.configLabel) || 0) + yScale.bandwidth() / 2 - capH) + .attr('y2', (d) => (yScale(d.configLabel) || 0) + yScale.bandwidth() / 2 + capH); + bars + .select('.eb-cap-bot') + .attr('x1', (d) => newXScale(d.errorMax!)) + .attr('x2', (d) => newXScale(d.errorMax!)) + .attr('y1', (d) => (yScale(d.configLabel) || 0) + yScale.bandwidth() / 2 - capH) + .attr('y2', (d) => (yScale(d.configLabel) || 0) + yScale.bandwidth() / 2 + capH); }, }, { type: 'custom', key: 'mean-points', render: (group, { xScale: xs, yScale: ys }) => { - const xScale = xs as d3.ScaleBand; - const yScale = ys as d3.ScaleLinear; + const xScale = xs as d3.ScaleLinear; + const yScale = ys as d3.ScaleBand; return renderPoints(group, chartData, { - getCx: (d: EvaluationChartData) => - (xScale(d.configLabel) || 0) + xScale.bandwidth() / 2, - getCy: (d: EvaluationChartData) => yScale(d.score), + getCx: (d: EvaluationChartData) => xScale(d.score), + getCy: (d: EvaluationChartData) => + (yScale(d.configLabel) || 0) + yScale.bandwidth() / 2, getColor: (d: EvaluationChartData) => getCssColor(resolveColor(d.configLabel, d.hwKey as string)), getRadius: () => 6, @@ -196,12 +231,12 @@ export default function EvalBarChartD3({ caption }: { caption?: ReactNode }) { }); }, onZoom: (group, ctx) => { - const xScale = ctx.xScale as d3.ScaleBand; - const newYScale = ctx.newYScale as d3.ScaleLinear; + const newXScale = ctx.newXScale as d3.ScaleLinear; + const yScale = ctx.yScale as d3.ScaleBand; updatePointsOnZoom( group, - (d) => (xScale(d.configLabel) || 0) + xScale.bandwidth() / 2, - (d) => newYScale(d.score), + (d) => newXScale(d.score), + (d) => (yScale(d.configLabel) || 0) + yScale.bandwidth() / 2, ); }, }, @@ -211,8 +246,8 @@ export default function EvalBarChartD3({ caption }: { caption?: ReactNode }) { render: (group, { xScale: xs, yScale: ys }) => { group.selectAll('.score-label-group').remove(); if (!showLabels) return; - const xScale = xs as d3.ScaleBand; - const yScale = ys as d3.ScaleLinear; + const xScale = xs as d3.ScaleLinear; + const yScale = ys as d3.ScaleBand; const labelGroups = group .selectAll('.score-label-group') .data(chartData) @@ -221,7 +256,7 @@ export default function EvalBarChartD3({ caption }: { caption?: ReactNode }) { .attr( 'transform', (d) => - `translate(${(xScale(d.configLabel) || 0) + xScale.bandwidth() / 2},${yScale(d.score) - 16})`, + `translate(${xScale(d.score) + 12},${(yScale(d.configLabel) || 0) + yScale.bandwidth() / 2})`, ); labelGroups .append('rect') @@ -234,7 +269,7 @@ export default function EvalBarChartD3({ caption }: { caption?: ReactNode }) { labelGroups .append('text') .attr('class', 'score-label') - .attr('text-anchor', 'middle') + .attr('text-anchor', 'start') .style('fill', 'var(--foreground)') .attr('font-size', '10px') .attr('font-weight', '600') @@ -252,14 +287,14 @@ export default function EvalBarChartD3({ caption }: { caption?: ReactNode }) { }, onZoom: (group, ctx) => { if (!showLabels) return; - const xScale = ctx.xScale as d3.ScaleBand; - const newYScale = ctx.newYScale as d3.ScaleLinear; + const newXScale = ctx.newXScale as d3.ScaleLinear; + const yScale = ctx.yScale as d3.ScaleBand; group .selectAll('.score-label-group') .attr( 'transform', (d) => - `translate(${(xScale(d.configLabel) || 0) + xScale.bandwidth() / 2},${newYScale(d.score) - 16})`, + `translate(${newXScale(d.score) + 12},${(yScale(d.configLabel) || 0) + yScale.bandwidth() / 2})`, ); }, }, @@ -318,45 +353,46 @@ export default function EvalBarChartD3({ caption }: { caption?: ReactNode }) { chartId="evaluation-chart" data={chartData} - height={600} + height={chartHeight} margin={CHART_MARGIN} watermark="logo" grabCursor={false} caption={caption} - xScale={{ type: 'band', domain: chartData.map((d) => d.configLabel), padding: 0.1 }} - yScale={{ type: 'linear', domain: yDomain }} - xAxis={{ customize: formatXAxisLabels }} - yAxis={{ + xScale={{ type: 'linear', domain: xDomain }} + yScale={{ type: 'band', domain: chartData.map((d) => d.configLabel), padding: 0.1 }} + xAxis={{ label: `${getEvalBenchmarkLabel(selectedBenchmark as EvalBenchmark)} Score`, tickFormat: (d) => Number(d).toFixed(2), tickCount: 5, }} + yAxis={{ customize: formatYAxisLabels }} layers={layers} zoom={{ enabled: true, - axes: 'y', - scaleExtent: [0.25, 20], + axes: 'x', + scaleExtent: [1, 20], resetEventName: 'evaluation_zoom_reset_evaluation-chart', constrain: (transform) => { const k = transform.k; - const yScale = d3 - .scaleLinear() - .domain(yDomain) - .range([600 - CHART_MARGIN.top - CHART_MARGIN.bottom, 0]); - const minTy = 600 - CHART_MARGIN.top - CHART_MARGIN.bottom - yScale(0) * k; - const maxTy = -yScale(1) * k; - const ty = minTy < maxTy ? Math.max(minTy, Math.min(maxTy, transform.y)) : transform.y; - return d3.zoomIdentity.translate(transform.x, ty).scale(k); + const innerWidth = + (typeof window !== 'undefined' ? window.innerWidth : 800) - + CHART_MARGIN.left - + CHART_MARGIN.right; + const xScale = d3.scaleLinear().domain(xDomain).range([0, innerWidth]); + const minTx = -xScale(1) * k + innerWidth; + const maxTx = -xScale(0) * k; + const tx = minTx < maxTx ? Math.max(minTx, Math.min(maxTx, transform.x)) : transform.x; + return d3.zoomIdentity.translate(tx, transform.y).scale(k); }, }} tooltip={{ rulerType: 'crosshair', content: generateEvaluationTooltipContent, - getRulerX: (d, xs) => { - const bs = xs as d3.ScaleBand; + getRulerX: (d, xs) => (xs as d3.ScaleLinear)(d.score), + getRulerY: (d, ys) => { + const bs = ys as unknown as d3.ScaleBand; return (bs(d.configLabel) || 0) + bs.bandwidth() / 2; }, - getRulerY: (d, ys) => ys(d.score), onHoverStart: (sel) => sel.attr('r', 8), onHoverEnd: (sel) => sel.attr('r', 6), attachToLayer: 1,