Anton Malykhin commited on
Commit
236e9a2
·
1 Parent(s): 771a5b0

feat: refine leaderboard visualizations and CSV exports

Browse files

Polish the Ranking, Details, and Visualizations experiences with clearer
sorting states, reusable cursor tooltips, better chart labels, and working
CSV exports.

- add reusable cursor-based tooltip components for charts and overflowing text
- refactor OverflowTooltip to use the new cursor tooltip behavior
- add RadarLabelsOverlay for radar chart labels that can truncate and reveal
full names on hover
- add global thin scrollbar styling for light and dark themes
- allow table column header/cell classes to be computed reactively so active
sort columns can be highlighted

Ranking:
- show active sort direction with up/down icons instead of a static sort icon
- highlight the active sorted column in headers and cells
- add overflow tooltip support for long model names
- display model params consistently in filter rows and table summaries

Details:
- add CSV serialization for benchmark matrix rows and filtered model rows
- replace inactive matrix export buttons with a single working CSV export
- wire the models tab CSV button to export the current filtered model list
- improve benchmark matrix sorting indicators and active-column highlighting
- add overflow tooltips for long matrix dataset and model group labels
- render model radar labels through RadarLabelsOverlay for cleaner clipping

Visualizations:
- start with the first five models selected by default for a cleaner initial view
- move chart tooltip state into the shared ChartCursorTooltip component
- add pinned-model support across comparison charts and Pareto labels
- improve radar chart labels and hover details
- show an empty heatmap state when no models or groups are selected
- change grouped bars to compare models by averaged selected-group metrics
- fix scatter axes to a stable 0..1 scale
- simplify Pareto copy and axis labels, and keep pinned labels visible
- highlight active latency table sort columns
- include FPR real/robust/delta values in robustness rows alongside FNR

Data and types:
- parse optional robustness FPR fields from bucket visualization payloads
- propagate FPR robustness fields through bucket adapters and frontend types
- remove obsolete chart tooltip types from tools types

Localization:
- add heatmap empty-state text
- add grouped average summary text
- update grouped bars and Pareto descriptions in English and Russian

messages/en.json CHANGED
@@ -253,6 +253,7 @@
253
  "tools_radar_scale": "Radar scale",
254
  "tools_radar_group_score": "group score",
255
  "tools_mean": "mean",
 
256
  "tools_real_score": "score real",
257
  "tools_robust_score": "score robust",
258
  "tools_selected_summary": "{models} models · {groups} groups",
@@ -269,17 +270,17 @@
269
  "tools_heatmap_scale_min": "min {value}",
270
  "tools_heatmap_scale_max": "max {value}",
271
  "tools_heatmap_header": "{metric} · group / model",
 
272
  "tools_grouped_bars_title": "Grouped bars",
273
- "tools_grouped_bars_hint": "FNR / FPR / F1 by group · averages across selected models",
274
- "tools_grouped_bars_aria": "Grouped FNR FPR F1 bars by group",
275
  "tools_lower_better": "lower = better",
276
  "tools_higher_better": "higher = better",
277
  "tools_pareto_title": "Pareto: quality × latency",
278
- "tools_pareto_hint": "x = p95 latency (ms, lower = better), y = integral (higher = better) · color = overall FPR",
279
  "tools_pareto_aria": "Pareto quality versus p95 latency",
280
- "tools_pareto_x_axis": "p95 latency, ms (← lower better)",
281
- "tools_pareto_y_axis": "integral (higher better →)",
282
- "tools_pareto_fpr_legend": "teal (few false alarms) → red (many)",
283
  "tools_pareto_click_pin_hint": "hover for details · dashed line = Pareto frontier",
284
  "tools_pareto_pin_model": "Pin {model} label",
285
  "tools_pareto_unpin_model": "Unpin {model} label",
 
253
  "tools_radar_scale": "Radar scale",
254
  "tools_radar_group_score": "group score",
255
  "tools_mean": "mean",
256
+ "tools_group_mean_summary": "average across {groups} groups",
257
  "tools_real_score": "score real",
258
  "tools_robust_score": "score robust",
259
  "tools_selected_summary": "{models} models · {groups} groups",
 
270
  "tools_heatmap_scale_min": "min {value}",
271
  "tools_heatmap_scale_max": "max {value}",
272
  "tools_heatmap_header": "{metric} · group / model",
273
+ "tools_heatmap_empty": "Select at least one model and one group to show the heatmap.",
274
  "tools_grouped_bars_title": "Grouped bars",
275
+ "tools_grouped_bars_hint": "FNR / FPR / F1 by model · averages across selected groups",
276
+ "tools_grouped_bars_aria": "Grouped FNR FPR F1 bars by model",
277
  "tools_lower_better": "lower = better",
278
  "tools_higher_better": "higher = better",
279
  "tools_pareto_title": "Pareto: quality × latency",
280
+ "tools_pareto_hint": "x = p95 latency (ms, lower = better), y = integral (higher = better)",
281
  "tools_pareto_aria": "Pareto quality versus p95 latency",
282
+ "tools_pareto_x_axis": "p95 ms ",
283
+ "tools_pareto_y_axis": "Integral ",
 
284
  "tools_pareto_click_pin_hint": "hover for details · dashed line = Pareto frontier",
285
  "tools_pareto_pin_model": "Pin {model} label",
286
  "tools_pareto_unpin_model": "Unpin {model} label",
messages/ru.json CHANGED
@@ -253,6 +253,7 @@
253
  "tools_radar_scale": "Шкала радара",
254
  "tools_radar_group_score": "group score",
255
  "tools_mean": "среднее",
 
256
  "tools_real_score": "score real",
257
  "tools_robust_score": "score robust",
258
  "tools_selected_summary": "{models} моделей · {groups} групп",
@@ -269,17 +270,17 @@
269
  "tools_heatmap_scale_min": "min {value}",
270
  "tools_heatmap_scale_max": "max {value}",
271
  "tools_heatmap_header": "{metric} · группа / модель",
 
272
  "tools_grouped_bars_title": "Сгруппированные бары",
273
- "tools_grouped_bars_hint": "FNR / FPR / F1 по группам · средние по выбранным моделям",
274
- "tools_grouped_bars_aria": "Сгруппированные бары FNR FPR F1 по группам",
275
  "tools_lower_better": "ниже = лучше",
276
  "tools_higher_better": "выше = лучше",
277
  "tools_pareto_title": "Парето: качество × задержка",
278
- "tools_pareto_hint": "x = p95 latency (ms, ниже = лучше), y = integral (выше = лучше) · цвет = overall FPR",
279
  "tools_pareto_aria": "Парето качество против задержки p95",
280
- "tools_pareto_x_axis": "p95 latency, ms (← ниже лучше)",
281
- "tools_pareto_y_axis": "integral (выше лучше →)",
282
- "tools_pareto_fpr_legend": "teal (мало ложных) → red (много)",
283
  "tools_pareto_click_pin_hint": "hover — детали · пунктир = Pareto frontier",
284
  "tools_pareto_pin_model": "Закрепить подпись {model}",
285
  "tools_pareto_unpin_model": "Снять подпись {model}",
 
253
  "tools_radar_scale": "Шкала радара",
254
  "tools_radar_group_score": "group score",
255
  "tools_mean": "среднее",
256
+ "tools_group_mean_summary": "среднее по {groups} группам",
257
  "tools_real_score": "score real",
258
  "tools_robust_score": "score robust",
259
  "tools_selected_summary": "{models} моделей · {groups} групп",
 
270
  "tools_heatmap_scale_min": "min {value}",
271
  "tools_heatmap_scale_max": "max {value}",
272
  "tools_heatmap_header": "{metric} · группа / модель",
273
+ "tools_heatmap_empty": "Выберите хотя бы одну модель и одну группу, чтобы показать heatmap.",
274
  "tools_grouped_bars_title": "Сгруппированные бары",
275
+ "tools_grouped_bars_hint": "FNR / FPR / F1 по моделям · средние по выбранным группам",
276
+ "tools_grouped_bars_aria": "Сгруппированные бары FNR FPR F1 по моделям",
277
  "tools_lower_better": "ниже = лучше",
278
  "tools_higher_better": "выше = лучше",
279
  "tools_pareto_title": "Парето: качество × задержка",
280
+ "tools_pareto_hint": "x = p95 latency (ms, ниже = лучше), y = integral (выше = лучше)",
281
  "tools_pareto_aria": "Парето качество против задержки p95",
282
+ "tools_pareto_x_axis": "p95 ms ",
283
+ "tools_pareto_y_axis": "Integral ",
 
284
  "tools_pareto_click_pin_hint": "hover — детали · пунктир = Pareto frontier",
285
  "tools_pareto_pin_model": "Закрепить подпись {model}",
286
  "tools_pareto_unpin_model": "Снять подпись {model}",
src/app.css CHANGED
@@ -39,6 +39,8 @@
39
  --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
40
  --sidebar-border: oklch(0.92 0.004 286.32);
41
  --sidebar-ring: oklch(0.705 0.015 286.067);
 
 
42
  }
43
 
44
  .dark {
@@ -73,6 +75,8 @@
73
  --sidebar-accent-foreground: oklch(0.985 0 0);
74
  --sidebar-border: oklch(1 0 0 / 10%);
75
  --sidebar-ring: oklch(0.552 0.016 285.938);
 
 
76
  }
77
 
78
  @theme inline {
@@ -120,6 +124,27 @@
120
  @layer base {
121
  * {
122
  @apply border-border outline-ring/50;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  }
124
  body {
125
  @apply bg-background text-foreground;
 
39
  --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
40
  --sidebar-border: oklch(0.92 0.004 286.32);
41
  --sidebar-ring: oklch(0.705 0.015 286.067);
42
+ --scrollbar-thumb: oklch(0.705 0.015 286.067 / 65%);
43
+ --scrollbar-thumb-hover: oklch(0.552 0.016 285.938 / 85%);
44
  }
45
 
46
  .dark {
 
75
  --sidebar-accent-foreground: oklch(0.985 0 0);
76
  --sidebar-border: oklch(1 0 0 / 10%);
77
  --sidebar-ring: oklch(0.552 0.016 285.938);
78
+ --scrollbar-thumb: oklch(0.552 0.016 285.938 / 70%);
79
+ --scrollbar-thumb-hover: oklch(0.705 0.015 286.067 / 90%);
80
  }
81
 
82
  @theme inline {
 
124
  @layer base {
125
  * {
126
  @apply border-border outline-ring/50;
127
+ scrollbar-color: var(--scrollbar-thumb) transparent;
128
+ scrollbar-width: thin;
129
+ }
130
+ *::-webkit-scrollbar {
131
+ width: 10px;
132
+ height: 10px;
133
+ }
134
+ *::-webkit-scrollbar-track {
135
+ background: transparent;
136
+ }
137
+ *::-webkit-scrollbar-thumb {
138
+ border: 3px solid transparent;
139
+ border-radius: 999px;
140
+ background-color: var(--scrollbar-thumb);
141
+ background-clip: content-box;
142
+ }
143
+ *::-webkit-scrollbar-thumb:hover {
144
+ background-color: var(--scrollbar-thumb-hover);
145
+ }
146
+ *::-webkit-scrollbar-corner {
147
+ background: transparent;
148
  }
149
  body {
150
  @apply bg-background text-foreground;
src/lib/components/OverflowTooltip.svelte CHANGED
@@ -1,6 +1,6 @@
1
  <script lang="ts">
2
- import { tick } from 'svelte';
3
- import Tooltip from '$lib/components/Tooltip.svelte';
4
 
5
  type Props = {
6
  text: string;
@@ -15,48 +15,11 @@
15
  triggerClass = 'block min-w-0',
16
  contentClass = 'max-w-sm break-all'
17
  }: Props = $props();
18
- let element: HTMLElement | null = $state(null);
19
- let isOverflowing = $state(false);
20
-
21
- function updateOverflow() {
22
- if (!element) {
23
- isOverflowing = false;
24
- return;
25
- }
26
-
27
- isOverflowing =
28
- element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight;
29
- }
30
-
31
- $effect(() => {
32
- const currentText = text;
33
- tick().then(() => {
34
- if (currentText === text) updateOverflow();
35
- });
36
- });
37
-
38
- $effect(() => {
39
- if (!element || typeof ResizeObserver === 'undefined') return;
40
-
41
- const observer = new ResizeObserver(updateOverflow);
42
- observer.observe(element);
43
- updateOverflow();
44
-
45
- return () => observer.disconnect();
46
- });
47
  </script>
48
 
49
- {#snippet label()}
50
- <span bind:this={element} class={className}>{text}</span>
51
- {/snippet}
52
-
53
- {#if isOverflowing}
54
- <Tooltip {triggerClass} {contentClass}>
55
- {@render label()}
56
- {#snippet content()}
57
- {text}
58
- {/snippet}
59
- </Tooltip>
60
- {:else}
61
- {@render label()}
62
- {/if}
 
1
  <script lang="ts">
2
+ import OverflowCursorTooltip from '$lib/components/tooltips/OverflowCursorTooltip.svelte';
3
+ import { cn } from '$lib/utils';
4
 
5
  type Props = {
6
  text: string;
 
15
  triggerClass = 'block min-w-0',
16
  contentClass = 'max-w-sm break-all'
17
  }: Props = $props();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  </script>
19
 
20
+ <OverflowCursorTooltip
21
+ {text}
22
+ labelClass={cn(triggerClass, className)}
23
+ tooltipClass={contentClass}
24
+ position="label"
25
+ />
 
 
 
 
 
 
 
 
src/lib/components/Table.svelte CHANGED
@@ -4,8 +4,8 @@
4
  export type TableColumn<Key extends string = string> = {
5
  key: Key;
6
  label: string | (() => string);
7
- headClass?: string;
8
- cellClass?: string;
9
  ariaSort?: () => 'ascending' | 'descending' | 'none';
10
  };
11
  </script>
@@ -52,6 +52,10 @@
52
  return typeof rowClass === 'function' ? rowClass(row, index) : rowClass;
53
  }
54
 
 
 
 
 
55
  function handleRowKeydown(event: KeyboardEvent, row: Row, index: number) {
56
  if (!onrowclick || (event.key !== 'Enter' && event.key !== ' ')) return;
57
 
@@ -70,7 +74,7 @@
70
  <UITable.Row class={headerClass}>
71
  {#each columns as column (column.key)}
72
  <UITable.Head
73
- class={cn('text-muted-foreground', column.headClass)}
74
  aria-sort={column.ariaSort?.() ?? 'none'}
75
  >
76
  {#if header}
@@ -93,7 +97,7 @@
93
  onkeydown={(event) => handleRowKeydown(event, row, index)}
94
  >
95
  {#each columns as column (column.key)}
96
- <UITable.Cell class={column.cellClass}>
97
  {@render cell(row, column, index)}
98
  </UITable.Cell>
99
  {/each}
 
4
  export type TableColumn<Key extends string = string> = {
5
  key: Key;
6
  label: string | (() => string);
7
+ headClass?: string | (() => string | undefined);
8
+ cellClass?: string | (() => string | undefined);
9
  ariaSort?: () => 'ascending' | 'descending' | 'none';
10
  };
11
  </script>
 
52
  return typeof rowClass === 'function' ? rowClass(row, index) : rowClass;
53
  }
54
 
55
+ function resolvedColumnClass(className: string | (() => string | undefined) | undefined) {
56
+ return typeof className === 'function' ? className() : className;
57
+ }
58
+
59
  function handleRowKeydown(event: KeyboardEvent, row: Row, index: number) {
60
  if (!onrowclick || (event.key !== 'Enter' && event.key !== ' ')) return;
61
 
 
74
  <UITable.Row class={headerClass}>
75
  {#each columns as column (column.key)}
76
  <UITable.Head
77
+ class={cn('text-muted-foreground', resolvedColumnClass(column.headClass))}
78
  aria-sort={column.ariaSort?.() ?? 'none'}
79
  >
80
  {#if header}
 
97
  onkeydown={(event) => handleRowKeydown(event, row, index)}
98
  >
99
  {#each columns as column (column.key)}
100
+ <UITable.Cell class={resolvedColumnClass(column.cellClass)}>
101
  {@render cell(row, column, index)}
102
  </UITable.Cell>
103
  {/each}
src/lib/components/charts/RadarLabelsOverlay.svelte ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script module lang="ts">
2
+ export type RadarLabelAnchor = 'start' | 'middle' | 'end';
3
+
4
+ export type RadarOverlayLabel = {
5
+ key: string;
6
+ text: string;
7
+ x: number;
8
+ y: number;
9
+ anchor: RadarLabelAnchor;
10
+ };
11
+ </script>
12
+
13
+ <script lang="ts">
14
+ import OverflowCursorTooltip from '$lib/components/tooltips/OverflowCursorTooltip.svelte';
15
+ import { cn } from '$lib/utils';
16
+
17
+ type Props = {
18
+ labels: readonly RadarOverlayLabel[];
19
+ viewBoxWidth: number;
20
+ viewBoxHeight: number;
21
+ labelClass?: string;
22
+ tooltipClass?: string;
23
+ position?: 'cursor' | 'label';
24
+ };
25
+
26
+ let {
27
+ labels,
28
+ viewBoxWidth,
29
+ viewBoxHeight,
30
+ labelClass,
31
+ tooltipClass,
32
+ position = 'cursor'
33
+ }: Props = $props();
34
+
35
+ function labelStyle(label: RadarOverlayLabel) {
36
+ return `left:${(label.x / viewBoxWidth) * 100}%; top:${(label.y / viewBoxHeight) * 100}%;`;
37
+ }
38
+
39
+ function labelAlignmentClass(anchor: RadarLabelAnchor) {
40
+ if (anchor === 'middle') return '-translate-x-1/2 text-center';
41
+ if (anchor === 'start') return 'text-left';
42
+ return '-translate-x-full text-right';
43
+ }
44
+ </script>
45
+
46
+ <div class="pointer-events-none absolute inset-0">
47
+ {#each labels as label (label.key)}
48
+ <OverflowCursorTooltip
49
+ text={label.text}
50
+ labelClass={cn(
51
+ 'pointer-events-auto absolute block w-28 -translate-y-1/2 truncate font-mono text-[10px] leading-none text-muted-foreground',
52
+ labelAlignmentClass(label.anchor),
53
+ labelClass
54
+ )}
55
+ labelStyle={labelStyle(label)}
56
+ {tooltipClass}
57
+ {position}
58
+ />
59
+ {/each}
60
+ </div>
src/lib/components/tooltips/ChartCursorTooltip.svelte ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script module lang="ts">
2
+ import type { CursorTooltipRow } from './CursorTooltip.svelte';
3
+
4
+ export type ChartCursorTooltipApi = {
5
+ show: (event: PointerEvent, title: string, rows: readonly CursorTooltipRow[]) => void;
6
+ move: (event: PointerEvent) => void;
7
+ hide: () => void;
8
+ };
9
+ </script>
10
+
11
+ <script lang="ts">
12
+ import { tick } from 'svelte';
13
+ import type { Snippet } from 'svelte';
14
+ import CursorTooltip from './CursorTooltip.svelte';
15
+
16
+ type Props = {
17
+ children: Snippet<[ChartCursorTooltipApi]>;
18
+ class?: string;
19
+ };
20
+
21
+ let { children, class: className }: Props = $props();
22
+
23
+ let tooltipElement: HTMLDivElement | null = $state(null);
24
+ let tooltip = $state<{
25
+ x: number;
26
+ y: number;
27
+ title: string;
28
+ rows: readonly CursorTooltipRow[];
29
+ } | null>(null);
30
+
31
+ function tooltipPosition(event: Pick<PointerEvent, 'clientX' | 'clientY'>) {
32
+ const margin = 12;
33
+ const offset = 16;
34
+ const tooltipRect = tooltipElement?.getBoundingClientRect();
35
+ const tooltipWidth = tooltipRect?.width ?? 384;
36
+ const tooltipHeight = tooltipRect?.height ?? 180;
37
+ let x = event.clientX + offset;
38
+ let y = event.clientY + offset;
39
+
40
+ if (typeof window === 'undefined') return { x, y };
41
+
42
+ if (x + tooltipWidth + margin > window.innerWidth) {
43
+ x = event.clientX - tooltipWidth - offset;
44
+ }
45
+ if (y + tooltipHeight + margin > window.innerHeight) {
46
+ y = event.clientY - tooltipHeight - offset;
47
+ }
48
+
49
+ return {
50
+ x: Math.min(Math.max(margin, x), Math.max(margin, window.innerWidth - tooltipWidth - margin)),
51
+ y: Math.min(
52
+ Math.max(margin, y),
53
+ Math.max(margin, window.innerHeight - tooltipHeight - margin)
54
+ )
55
+ };
56
+ }
57
+
58
+ function show(event: PointerEvent, title: string, rows: readonly CursorTooltipRow[]) {
59
+ const position = tooltipPosition(event);
60
+ tooltip = {
61
+ x: position.x,
62
+ y: position.y,
63
+ title,
64
+ rows
65
+ };
66
+
67
+ tick().then(() => {
68
+ if (!tooltip) return;
69
+ tooltip = { ...tooltip, ...tooltipPosition(event) };
70
+ });
71
+ }
72
+
73
+ function move(event: PointerEvent) {
74
+ if (!tooltip) return;
75
+ tooltip = { ...tooltip, ...tooltipPosition(event) };
76
+ }
77
+
78
+ function hide() {
79
+ tooltip = null;
80
+ }
81
+
82
+ const api: ChartCursorTooltipApi = { show, move, hide };
83
+ </script>
84
+
85
+ {@render children(api)}
86
+
87
+ {#if tooltip}
88
+ <CursorTooltip
89
+ bind:ref={tooltipElement}
90
+ x={tooltip.x}
91
+ y={tooltip.y}
92
+ title={tooltip.title}
93
+ rows={tooltip.rows}
94
+ class={className}
95
+ />
96
+ {/if}
src/lib/components/tooltips/CursorTooltip.svelte ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script module lang="ts">
2
+ export type CursorTooltipRow = {
3
+ label: string;
4
+ value: string;
5
+ };
6
+ </script>
7
+
8
+ <script lang="ts">
9
+ import type { Snippet } from 'svelte';
10
+ import { cn } from '$lib/utils';
11
+
12
+ type Props = {
13
+ x: number;
14
+ y: number;
15
+ title?: string;
16
+ rows?: readonly CursorTooltipRow[];
17
+ children?: Snippet;
18
+ class?: string;
19
+ titleClass?: string;
20
+ rowsClass?: string;
21
+ rowClass?: string;
22
+ labelClass?: string;
23
+ valueClass?: string;
24
+ ref?: HTMLDivElement | null;
25
+ };
26
+
27
+ let {
28
+ x,
29
+ y,
30
+ title,
31
+ rows = [],
32
+ children,
33
+ class: className,
34
+ titleClass,
35
+ rowsClass,
36
+ rowClass,
37
+ labelClass,
38
+ valueClass,
39
+ ref = $bindable(null)
40
+ }: Props = $props();
41
+
42
+ function portalTooltip(node: HTMLDivElement) {
43
+ if (typeof document === 'undefined') return;
44
+
45
+ document.body.appendChild(node);
46
+
47
+ return {
48
+ destroy() {
49
+ node.remove();
50
+ }
51
+ };
52
+ }
53
+ </script>
54
+
55
+ <div
56
+ use:portalTooltip
57
+ bind:this={ref}
58
+ class={cn(
59
+ 'pointer-events-none fixed z-[100] max-w-[min(24rem,calc(100vw-1.5rem))] rounded-lg border bg-popover px-4 py-3 text-sm text-popover-foreground shadow-xl',
60
+ className
61
+ )}
62
+ style={`left:${x}px; top:${y}px`}
63
+ >
64
+ {#if children}
65
+ {@render children()}
66
+ {:else}
67
+ {#if title}
68
+ <div class={cn('break-words font-semibold', titleClass)}>{title}</div>
69
+ {/if}
70
+ {#if rows.length}
71
+ <div class={cn('mt-2 space-y-1 font-mono', rowsClass)}>
72
+ {#each rows as row (`${row.label}-${row.value}`)}
73
+ <div class={cn('grid grid-cols-[auto_1fr] gap-5', rowClass)}>
74
+ <span class={cn('break-words text-muted-foreground', labelClass)}>{row.label}</span>
75
+ <span class={cn('text-right', valueClass)}>{row.value}</span>
76
+ </div>
77
+ {/each}
78
+ </div>
79
+ {/if}
80
+ {/if}
81
+ </div>
src/lib/components/tooltips/OverflowCursorTooltip.svelte ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { tick } from 'svelte';
3
+ import type { Snippet } from 'svelte';
4
+ import CursorTooltip from './CursorTooltip.svelte';
5
+
6
+ type Props = {
7
+ text: string;
8
+ children?: Snippet;
9
+ labelClass?: string;
10
+ labelStyle?: string;
11
+ tooltipClass?: string;
12
+ role?: string;
13
+ ariaLabel?: string;
14
+ overflowPadding?: number;
15
+ position?: 'cursor' | 'label';
16
+ };
17
+
18
+ let {
19
+ text,
20
+ children,
21
+ labelClass = 'block min-w-0 truncate',
22
+ labelStyle,
23
+ tooltipClass,
24
+ role = 'note',
25
+ ariaLabel,
26
+ overflowPadding = 2,
27
+ position = 'cursor'
28
+ }: Props = $props();
29
+
30
+ let labelElement: HTMLSpanElement | null = $state(null);
31
+ let tooltipElement: HTMLDivElement | null = $state(null);
32
+ let tooltip = $state<{ x: number; y: number } | null>(null);
33
+ let measureCanvas: HTMLCanvasElement | undefined;
34
+
35
+ function textOverflows() {
36
+ if (!labelElement) return false;
37
+
38
+ const availableWidth = labelElement.clientWidth;
39
+ const label = text.trim();
40
+ if (availableWidth <= 0 || label === '') return false;
41
+
42
+ if (typeof document === 'undefined') {
43
+ return labelElement.scrollWidth > availableWidth + overflowPadding;
44
+ }
45
+
46
+ measureCanvas ??= document.createElement('canvas');
47
+ const context = measureCanvas.getContext('2d');
48
+ if (!context) return labelElement.scrollWidth > availableWidth + overflowPadding;
49
+
50
+ context.font = getComputedStyle(labelElement).font;
51
+ return context.measureText(label).width > availableWidth + overflowPadding;
52
+ }
53
+
54
+ function clampTooltipPosition(x: number, y: number, tooltipWidth: number, tooltipHeight: number) {
55
+ const margin = 12;
56
+ if (typeof window === 'undefined') return { x, y };
57
+
58
+ if (x + tooltipWidth + margin > window.innerWidth) {
59
+ x = window.innerWidth - tooltipWidth - margin;
60
+ }
61
+ if (y + tooltipHeight + margin > window.innerHeight) {
62
+ y = window.innerHeight - tooltipHeight - margin;
63
+ }
64
+
65
+ return {
66
+ x: Math.min(Math.max(margin, x), Math.max(margin, window.innerWidth - tooltipWidth - margin)),
67
+ y: Math.min(
68
+ Math.max(margin, y),
69
+ Math.max(margin, window.innerHeight - tooltipHeight - margin)
70
+ )
71
+ };
72
+ }
73
+
74
+ function cursorTooltipPosition(event: Pick<PointerEvent, 'clientX' | 'clientY'>) {
75
+ const offset = 16;
76
+ const tooltipRect = tooltipElement?.getBoundingClientRect();
77
+ const tooltipWidth = tooltipRect?.width ?? 320;
78
+ const tooltipHeight = tooltipRect?.height ?? 64;
79
+ let x = event.clientX + offset;
80
+ let y = event.clientY + offset;
81
+
82
+ if (typeof window !== 'undefined') {
83
+ if (x + tooltipWidth + 12 > window.innerWidth) {
84
+ x = event.clientX - tooltipWidth - offset;
85
+ }
86
+ if (y + tooltipHeight + 12 > window.innerHeight) {
87
+ y = event.clientY - tooltipHeight - offset;
88
+ }
89
+ }
90
+
91
+ return clampTooltipPosition(x, y, tooltipWidth, tooltipHeight);
92
+ }
93
+
94
+ function labelTooltipPosition() {
95
+ const labelRect = labelElement?.getBoundingClientRect();
96
+ const tooltipRect = tooltipElement?.getBoundingClientRect();
97
+ const tooltipWidth = tooltipRect?.width ?? 320;
98
+ const tooltipHeight = tooltipRect?.height ?? 64;
99
+ const offset = 12;
100
+ if (!labelRect) return { x: 12, y: 12 };
101
+
102
+ const x = labelRect.left + labelRect.width / 2 - tooltipWidth / 2;
103
+ const y =
104
+ labelRect.top >= tooltipHeight + offset + 12
105
+ ? labelRect.top - tooltipHeight - offset
106
+ : labelRect.bottom + offset;
107
+
108
+ return clampTooltipPosition(x, y, tooltipWidth, tooltipHeight);
109
+ }
110
+
111
+ function tooltipPosition(event: PointerEvent) {
112
+ return position === 'label' ? labelTooltipPosition() : cursorTooltipPosition(event);
113
+ }
114
+
115
+ function updateTooltip(event: PointerEvent) {
116
+ if (!textOverflows()) {
117
+ tooltip = null;
118
+ return;
119
+ }
120
+
121
+ tooltip = tooltipPosition(event);
122
+
123
+ tick().then(() => {
124
+ if (!tooltip) return;
125
+ tooltip = tooltipPosition(event);
126
+ });
127
+ }
128
+
129
+ function hideTooltip() {
130
+ tooltip = null;
131
+ }
132
+ </script>
133
+
134
+ <span
135
+ bind:this={labelElement}
136
+ class={labelClass}
137
+ style={labelStyle}
138
+ {role}
139
+ aria-label={ariaLabel ?? text}
140
+ onpointerenter={updateTooltip}
141
+ onpointermove={updateTooltip}
142
+ onpointerleave={hideTooltip}
143
+ >
144
+ {#if children}
145
+ {@render children()}
146
+ {:else}
147
+ {text}
148
+ {/if}
149
+ </span>
150
+
151
+ {#if tooltip}
152
+ <CursorTooltip
153
+ bind:ref={tooltipElement}
154
+ x={tooltip.x}
155
+ y={tooltip.y}
156
+ title={text}
157
+ class={tooltipClass}
158
+ />
159
+ {/if}
src/lib/data/details.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
  BenchmarkRankingRow,
7
  DetailMetric,
8
  DetailsLanguageFilter,
 
9
  MatrixDataset,
10
  MatrixSortDirection,
11
  ModelGroupRow,
@@ -88,6 +89,14 @@ function modelKey(model: GuardModel) {
88
  return model.modelId ?? model.name;
89
  }
90
 
 
 
 
 
 
 
 
 
91
  export function matrixCellValue(
92
  model: GuardModel,
93
  dataset: MatrixDataset,
@@ -263,6 +272,85 @@ export function filterModels(
263
  .sort((a, b) => modelSortValue(b, sort) - modelSortValue(a, sort));
264
  }
265
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  function groupName(key: string, dataset: DetailsDataset, locale: SupportedLocale) {
267
  return localized(dataset.groupLabels[key], locale, key);
268
  }
 
6
  BenchmarkRankingRow,
7
  DetailMetric,
8
  DetailsLanguageFilter,
9
+ MatrixGroup,
10
  MatrixDataset,
11
  MatrixSortDirection,
12
  ModelGroupRow,
 
89
  return model.modelId ?? model.name;
90
  }
91
 
92
+ function csvCell(value: string | number | null | undefined) {
93
+ return `"${String(value ?? '').replaceAll('"', '""')}"`;
94
+ }
95
+
96
+ function csvNumber(value: number | null | undefined, digits = 4) {
97
+ return isNumber(value) ? value.toFixed(digits) : '';
98
+ }
99
+
100
  export function matrixCellValue(
101
  model: GuardModel,
102
  dataset: MatrixDataset,
 
272
  .sort((a, b) => modelSortValue(b, sort) - modelSortValue(a, sort));
273
  }
274
 
275
+ export function detailMatrixRowsToCsv(
276
+ rows: readonly GuardModel[],
277
+ groups: readonly MatrixGroup[],
278
+ metric: DetailMetric,
279
+ datasetCells: Record<string, Record<string, DetailsDatasetMatrixCell>>
280
+ ) {
281
+ const datasets = groups.flatMap((group) =>
282
+ group.datasets.map((dataset) => ({
283
+ ...dataset,
284
+ header: `${group.label} / ${dataset.label} ${metric}`
285
+ }))
286
+ );
287
+ const header = [
288
+ 'rank',
289
+ 'model',
290
+ 'organization',
291
+ 'size',
292
+ 'integral',
293
+ 'metric',
294
+ ...datasets.map((dataset) => dataset.header)
295
+ ];
296
+ const lines = rows.map((model, index) => [
297
+ index + 1,
298
+ model.name,
299
+ model.org,
300
+ model.params,
301
+ csvNumber(model.integral),
302
+ metric,
303
+ ...datasets.map((dataset) =>
304
+ csvNumber(detailMetricValue(datasetCells[modelKey(model)]?.[dataset.id], metric))
305
+ )
306
+ ]);
307
+
308
+ return [header, ...lines].map((row) => row.map(csvCell).join(',')).join('\n');
309
+ }
310
+
311
+ export function detailModelRowsToCsv(rows: readonly GuardModel[]) {
312
+ const header = [
313
+ 'rank',
314
+ 'model',
315
+ 'organization',
316
+ 'family',
317
+ 'type',
318
+ 'license',
319
+ 'size',
320
+ 'languages',
321
+ 'integral',
322
+ 'min_group',
323
+ 'overall_fpr',
324
+ 'overall_fnr',
325
+ 'overall_f1',
326
+ 'p50_ms',
327
+ 'p95_ms',
328
+ 'p99_ms',
329
+ 'run_date'
330
+ ];
331
+ const lines = rows.map((model, index) => [
332
+ index + 1,
333
+ model.name,
334
+ model.org,
335
+ model.family,
336
+ model.type,
337
+ model.license,
338
+ model.params,
339
+ model.languages.join('; '),
340
+ csvNumber(model.integral),
341
+ csvNumber(model.minGroup),
342
+ csvNumber(model.fpr),
343
+ csvNumber(model.fnr),
344
+ csvNumber(model.f1),
345
+ csvNumber(model.p50, 1),
346
+ csvNumber(model.p95, 1),
347
+ csvNumber(model.p99, 1),
348
+ model.runDate
349
+ ]);
350
+
351
+ return [header, ...lines].map((row) => row.map(csvCell).join(',')).join('\n');
352
+ }
353
+
354
  function groupName(key: string, dataset: DetailsDataset, locale: SupportedLocale) {
355
  return localized(dataset.groupLabels[key], locale, key);
356
  }
src/lib/server/hf-bucket/adapt-tools.ts CHANGED
@@ -160,6 +160,9 @@ export function adaptBucketTools(snapshot: BucketVisualizationSnapshot): ToolsDa
160
  fnrReal: row.fnr_real,
161
  fnrRobust: row.fnr_robust,
162
  deltaFnr: row.delta_fnr,
 
 
 
163
  categoryLabel: row.category_label
164
  }))
165
  }
 
160
  fnrReal: row.fnr_real,
161
  fnrRobust: row.fnr_robust,
162
  deltaFnr: row.delta_fnr,
163
+ fprReal: row.fpr_real,
164
+ fprRobust: row.fpr_robust,
165
+ deltaFpr: row.delta_fpr,
166
  categoryLabel: row.category_label
167
  }))
168
  }
src/lib/server/hf-bucket/types.ts CHANGED
@@ -280,6 +280,9 @@ export type BucketRobustnessRow = {
280
  fnr_real: NullableMetricValue;
281
  fnr_robust: NullableMetricValue;
282
  delta_fnr: NullableMetricValue;
 
 
 
283
  category_label: LocalizedString;
284
  };
285
 
 
280
  fnr_real: NullableMetricValue;
281
  fnr_robust: NullableMetricValue;
282
  delta_fnr: NullableMetricValue;
283
+ fpr_real: NullableMetricValue;
284
+ fpr_robust: NullableMetricValue;
285
+ delta_fpr: NullableMetricValue;
286
  category_label: LocalizedString;
287
  };
288
 
src/lib/server/hf-bucket/visualizations-payload.ts CHANGED
@@ -227,6 +227,9 @@ function parseRobustnessRow(value: unknown, index: number): BucketRobustnessRow
227
  fnr_real: nullableNumber(item.fnr_real, `${field}.fnr_real`),
228
  fnr_robust: nullableNumber(item.fnr_robust, `${field}.fnr_robust`),
229
  delta_fnr: nullableNumber(item.delta_fnr, `${field}.delta_fnr`),
 
 
 
230
  category_label: localized(item.category_label, `${field}.category_label`)
231
  };
232
  }
 
227
  fnr_real: nullableNumber(item.fnr_real, `${field}.fnr_real`),
228
  fnr_robust: nullableNumber(item.fnr_robust, `${field}.fnr_robust`),
229
  delta_fnr: nullableNumber(item.delta_fnr, `${field}.delta_fnr`),
230
+ fpr_real: nullableNumber(item.fpr_real ?? null, `${field}.fpr_real`),
231
+ fpr_robust: nullableNumber(item.fpr_robust ?? null, `${field}.fpr_robust`),
232
+ delta_fpr: nullableNumber(item.delta_fpr ?? null, `${field}.delta_fpr`),
233
  category_label: localized(item.category_label, `${field}.category_label`)
234
  };
235
  }
src/lib/types/tools-data.ts CHANGED
@@ -105,6 +105,9 @@ export type ToolsRobustnessVisualization = {
105
  fnrReal: number | null;
106
  fnrRobust: number | null;
107
  deltaFnr: number | null;
 
 
 
108
  categoryLabel: LocalizedString;
109
  }>;
110
  };
 
105
  fnrReal: number | null;
106
  fnrRobust: number | null;
107
  deltaFnr: number | null;
108
+ fprReal: number | null;
109
+ fprRobust: number | null;
110
+ deltaFpr: number | null;
111
  categoryLabel: LocalizedString;
112
  }>;
113
  };
src/lib/types/tools.ts CHANGED
@@ -4,15 +4,3 @@ export type ToolsRadarScale = 'score' | 'f1';
4
  export type ToolsHeatmapMetric = ToolsVizMetric | 'score';
5
  export type ToolsSortDirection = 'asc' | 'desc';
6
  export type ToolsLatencySortKey = 'model' | 'p50' | 'p95' | 'p99' | 'errorRate';
7
-
8
- export type ToolsTooltipRow = {
9
- label: string;
10
- value: string;
11
- };
12
-
13
- export type ToolsChartTooltip = {
14
- x: number;
15
- y: number;
16
- title: string;
17
- rows: ToolsTooltipRow[];
18
- };
 
4
  export type ToolsHeatmapMetric = ToolsVizMetric | 'score';
5
  export type ToolsSortDirection = 'asc' | 'desc';
6
  export type ToolsLatencySortKey = 'model' | 'p50' | 'p95' | 'p99' | 'errorRate';
 
 
 
 
 
 
 
 
 
 
 
 
src/routes/+page.svelte CHANGED
@@ -1,7 +1,14 @@
1
  <script lang="ts">
2
- import { ArrowUpDownIcon, DownloadIcon, ExternalLinkIcon } from '@lucide/svelte';
 
 
 
 
 
 
3
  import BucketDataFallback from '$lib/components/BucketDataFallback.svelte';
4
  import BucketDataWarning from '$lib/components/BucketDataWarning.svelte';
 
5
  import Select from '$lib/components/Select.svelte';
6
  import Sheet from '$lib/components/Sheet.svelte';
7
  import Tabs from '$lib/components/Tabs.svelte';
@@ -153,8 +160,17 @@
153
  key: column.key,
154
  label: column.label,
155
  metric: column,
156
- headClass: cn('align-top', column.align === 'right' && 'text-right'),
157
- cellClass: cn(column.align === 'right' && 'text-right'),
 
 
 
 
 
 
 
 
 
158
  ariaSort: () =>
159
  sortKey === column.key ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'
160
  }))
@@ -526,7 +542,7 @@
526
  {@const model = modelByFilterValue(option.value)}
527
  <span class="min-w-0 truncate">{model?.short ?? option.label}</span>
528
  {#if model}
529
- <code class="ml-auto text-xs text-muted-foreground">{model.size}</code>
530
  {/if}
531
  {/if}
532
  {/snippet}
@@ -585,11 +601,13 @@
585
  {#snippet SortHeader({ column }: { column: LeaderboardTableColumn })}
586
  {@const metric = column.metric}
587
  {#if metric}
 
588
  <button
589
  type="button"
590
  class={cn(
591
  'inline-flex w-full justify-start flex-col gap-0.5 text-sm font-medium text-inherit transition-colors hover:text-inherit',
592
- metric.align === 'right' ? 'items-end text-right' : 'items-start text-left'
 
593
  )}
594
  onclick={() => updateSort(metric.key)}
595
  >
@@ -600,7 +618,15 @@
600
  )}
601
  >
602
  {metric.label()}
603
- <ArrowUpDownIcon class="size-3.5" />
 
 
 
 
 
 
 
 
604
  </span>
605
  {#if metric.directionLabel}
606
  <span class="text-[10px] leading-3 font-light text-muted-foreground normal-case">
@@ -620,9 +646,14 @@
620
  {row.model.org.slice(0, 2)}
621
  </div>
622
  <div class="min-w-0 space-y-1">
623
- <p class="truncate text-sm font-semibold text-foreground">{row.model.short}</p>
 
 
 
 
 
624
  <p class="truncate text-xs text-muted-foreground">
625
- {row.model.family} · {row.size} · {row.model.org}
626
  </p>
627
  {#if row.model.leak}
628
  <Badge
 
1
  <script lang="ts">
2
+ import {
3
+ ArrowDownIcon,
4
+ ArrowUpDownIcon,
5
+ ArrowUpIcon,
6
+ DownloadIcon,
7
+ ExternalLinkIcon
8
+ } from '@lucide/svelte';
9
  import BucketDataFallback from '$lib/components/BucketDataFallback.svelte';
10
  import BucketDataWarning from '$lib/components/BucketDataWarning.svelte';
11
+ import OverflowTooltip from '$lib/components/OverflowTooltip.svelte';
12
  import Select from '$lib/components/Select.svelte';
13
  import Sheet from '$lib/components/Sheet.svelte';
14
  import Tabs from '$lib/components/Tabs.svelte';
 
160
  key: column.key,
161
  label: column.label,
162
  metric: column,
163
+ headClass: () =>
164
+ cn(
165
+ 'align-top transition-colors',
166
+ column.align === 'right' && 'text-right',
167
+ sortKey === column.key && 'bg-muted/80 text-foreground'
168
+ ),
169
+ cellClass: () =>
170
+ cn(
171
+ column.align === 'right' && 'text-right',
172
+ sortKey === column.key && 'bg-muted/20 text-foreground'
173
+ ),
174
  ariaSort: () =>
175
  sortKey === column.key ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'
176
  }))
 
542
  {@const model = modelByFilterValue(option.value)}
543
  <span class="min-w-0 truncate">{model?.short ?? option.label}</span>
544
  {#if model}
545
+ <code class="ml-auto text-xs text-muted-foreground">{model.params}</code>
546
  {/if}
547
  {/if}
548
  {/snippet}
 
601
  {#snippet SortHeader({ column }: { column: LeaderboardTableColumn })}
602
  {@const metric = column.metric}
603
  {#if metric}
604
+ {@const isActive = sortKey === metric.key}
605
  <button
606
  type="button"
607
  class={cn(
608
  'inline-flex w-full justify-start flex-col gap-0.5 text-sm font-medium text-inherit transition-colors hover:text-inherit',
609
+ metric.align === 'right' ? 'items-end text-right' : 'items-start text-left',
610
+ isActive && 'text-foreground'
611
  )}
612
  onclick={() => updateSort(metric.key)}
613
  >
 
618
  )}
619
  >
620
  {metric.label()}
621
+ {#if isActive}
622
+ {#if sortDirection === 'asc'}
623
+ <ArrowUpIcon class="size-3.5 shrink-0" />
624
+ {:else}
625
+ <ArrowDownIcon class="size-3.5 shrink-0" />
626
+ {/if}
627
+ {:else}
628
+ <ArrowUpDownIcon class="size-3.5 shrink-0 text-muted-foreground" />
629
+ {/if}
630
  </span>
631
  {#if metric.directionLabel}
632
  <span class="text-[10px] leading-3 font-light text-muted-foreground normal-case">
 
646
  {row.model.org.slice(0, 2)}
647
  </div>
648
  <div class="min-w-0 space-y-1">
649
+ <OverflowTooltip
650
+ text={row.model.short}
651
+ class="block w-full min-w-0 truncate text-sm font-semibold text-foreground"
652
+ triggerClass="block w-full min-w-0"
653
+ contentClass="max-w-sm break-all"
654
+ />
655
  <p class="truncate text-xs text-muted-foreground">
656
+ {row.model.family} · {row.model.params} · {row.model.org}
657
  </p>
658
  {#if row.model.leak}
659
  <Badge
src/routes/details/+page.svelte CHANGED
@@ -1,7 +1,14 @@
1
  <script lang="ts">
2
- import { ArrowUpDownIcon, DownloadIcon, ExternalLinkIcon } from '@lucide/svelte';
 
 
 
 
 
 
3
  import BucketDataFallback from '$lib/components/BucketDataFallback.svelte';
4
  import BucketDataWarning from '$lib/components/BucketDataWarning.svelte';
 
5
  import OverflowTooltip from '$lib/components/OverflowTooltip.svelte';
6
  import PageHeadingSection from '$lib/components/PageHeadingSection.svelte';
7
  import Select from '$lib/components/Select.svelte';
@@ -16,6 +23,8 @@
16
  buildBenchmarkCards,
17
  buildMatrixGroups,
18
  buildMatrixRows,
 
 
19
  filterModels,
20
  matrixCellValue,
21
  matrixDatasetLabel,
@@ -49,6 +58,9 @@
49
  type ColumnExtrema = { best: number | null; worst: number | null };
50
 
51
  const selectContentClass = 'max-h-72';
 
 
 
52
  let { data }: PageProps = $props();
53
  const detailsState = $derived(data.detailsState);
54
  const details = $derived(detailsState.data);
@@ -188,6 +200,19 @@
188
  const filteredModels = $derived.by(() =>
189
  filterModels(modelSearch, modelLanguage, modelType, modelSize, modelSort, models)
190
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  const selectedModelGroups = $derived.by(() =>
192
  modelGroupRows(selectedModel, details, getLocale())
193
  );
@@ -285,9 +310,17 @@
285
  return matrixSortDirection === -1 ? 'descending' : 'ascending';
286
  }
287
 
 
 
 
 
 
 
 
 
288
  function metricCellClass(value: number | null, metric: DetailMetric, datasetName: string) {
289
  return cn(
290
- 'matrix-cell min-w-32 border-b border-l px-3 py-3 text-right font-mono text-sm tabular-nums',
291
  !isNumber(value) && 'matrix-cell-empty !bg-muted/30 text-muted-foreground',
292
  isNumber(value) &&
293
  extremaTone(roundedDetailMetric(value, metric), matrixColumnExtrema[datasetName])
@@ -300,7 +333,11 @@
300
  datasetName: string,
301
  startsGroup: boolean
302
  ) {
303
- return cn(metricCellClass(value, metric, datasetName), startsGroup && 'border-l-border');
 
 
 
 
304
  }
305
 
306
  function scorePillClass(value: number | null) {
@@ -509,18 +546,15 @@
509
  <Button variant="outline" size="sm" onclick={() => (selectedGroups = [])}>
510
  {m.details_clear()}
511
  </Button>
512
- <Button size="sm" class="ml-auto bg-[#0dce97] text-emerald-950 hover:bg-[#0dce97]/90">
 
 
 
 
 
513
  <DownloadIcon data-icon="inline-start" />
514
  {m.ranking_csv()}
515
  </Button>
516
- <Button size="sm" class="bg-[#0dce97] text-emerald-950 hover:bg-[#0dce97]/90">
517
- <DownloadIcon data-icon="inline-start" />
518
- Excel
519
- </Button>
520
- <Button size="sm" class="bg-[#0dce97] text-emerald-950 hover:bg-[#0dce97]/90">
521
- <DownloadIcon data-icon="inline-start" />
522
- PDF
523
- </Button>
524
  </div>
525
  </section>
526
 
@@ -542,7 +576,10 @@
542
  <thead>
543
  <tr>
544
  <th
545
- class="sticky top-0 z-30 h-24 min-w-80 max-w-80 border-r border-b border-border bg-muted px-4 py-3 text-left align-middle md:left-0"
 
 
 
546
  rowspan="2"
547
  scope="col"
548
  aria-sort={matrixAriaSort(null)}
@@ -550,11 +587,22 @@
550
  <Button
551
  variant="ghost"
552
  size="sm"
553
- class="h-auto justify-start gap-1.5 p-0 text-xs font-medium"
 
 
 
554
  onclick={() => handleMatrixSort(null)}
555
  >
556
  {m.details_matrix_model_sort()}
557
- <ArrowUpDownIcon class="size-3.5" />
 
 
 
 
 
 
 
 
558
  </Button>
559
  </th>
560
  {#each visibleMatrixGroups as group, groupIndex (group.key)}
@@ -576,6 +624,7 @@
576
  <th
577
  class={cn(
578
  'sticky top-12 z-20 h-12 min-w-32 border-b border-l border-border bg-muted px-3 py-3 text-right text-xs font-semibold whitespace-nowrap text-muted-foreground',
 
579
  groupIndex === 0 && datasetIndex === 0 && 'border-l-0',
580
  (groupIndex > 0 || datasetIndex > 0) &&
581
  datasetIndex === 0 &&
@@ -587,12 +636,27 @@
587
  <Button
588
  variant="ghost"
589
  size="sm"
590
- class="h-auto justify-end gap-1.5 p-0 text-[11px] font-medium text-muted-foreground hover:text-foreground"
591
- title={dataset.name}
 
 
592
  onclick={() => handleMatrixSort(dataset.name)}
593
  >
594
- {matrixDatasetLabel(dataset)}
595
- <ArrowUpDownIcon class="size-3.5" />
 
 
 
 
 
 
 
 
 
 
 
 
 
596
  </Button>
597
  </th>
598
  {/each}
@@ -605,7 +669,10 @@
605
  class="hover:[&>td.matrix-cell-bad]:!bg-red-500/15 hover:[&>td.matrix-cell-empty]:!bg-muted/40 hover:[&>td.matrix-cell-good]:!bg-emerald-500/15 hover:[&>th]:bg-muted"
606
  >
607
  <th
608
- class="z-20 min-w-80 max-w-80 border-r border-b bg-card px-4 py-3 text-left md:sticky md:left-0"
 
 
 
609
  scope="row"
610
  >
611
  <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
@@ -990,7 +1057,12 @@
990
  >
991
  {m.details_model_count({ shown: filteredModels.length, total: models.length })}
992
  </span>
993
- <Button size="sm" class="bg-[#0dce97] text-emerald-950 hover:bg-[#0dce97]/90">
 
 
 
 
 
994
  <DownloadIcon data-icon="inline-start" />
995
  {m.ranking_csv()}
996
  </Button>
@@ -1182,51 +1254,68 @@
1182
  </h3>
1183
  {#if selectedModelRadarPoints.length >= 3}
1184
  <div class="flex justify-center">
1185
- <svg
1186
- viewBox="0 0 320 320"
1187
- class="h-[28rem] w-full max-w-xl"
1188
- role="img"
1189
- aria-label={m.details_model_sheet_radar()}
1190
- >
1191
- {#each radarGridPolygons as polygon (polygon)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1192
  <polygon
1193
- points={polygon}
1194
- fill="none"
1195
- stroke="currentColor"
1196
- stroke-width="1"
1197
- stroke-dasharray="3 4"
1198
- class="text-border"
1199
  />
1200
- {/each}
1201
- {#each selectedModelRadarPoints as point, index (point.name)}
1202
- <line
1203
- x1="160"
1204
- y1="160"
1205
- x2={radarPoint(index, selectedModelRadarPoints.length, 118)[0]}
1206
- y2={radarPoint(index, selectedModelRadarPoints.length, 118)[1]}
1207
- stroke="currentColor"
1208
- stroke-width="1"
1209
- class="text-border"
1210
- />
1211
- <text
1212
- x={point.labelX}
1213
- y={point.labelY + 3}
1214
- text-anchor={point.anchor}
1215
- class="fill-muted-foreground font-mono text-[9px]"
1216
- >
1217
- {point.label}
1218
- </text>
1219
- {/each}
1220
- <polygon
1221
- points={selectedModelRadarPolygon}
1222
- fill="rgba(13, 206, 151, 0.22)"
1223
- stroke="#0dce97"
1224
- stroke-width="2"
 
1225
  />
1226
- {#each selectedModelRadarPoints as point (point.name)}
1227
- <circle cx={point.x} cy={point.y} r="3" fill="#0dce97" />
1228
- {/each}
1229
- </svg>
1230
  </div>
1231
  <div
1232
  class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-xs text-muted-foreground"
@@ -1334,9 +1423,12 @@
1334
  <div class="space-y-3">
1335
  {#each selectedModelGroups as group (group.name)}
1336
  <div class="grid items-center gap-3 sm:grid-cols-[minmax(9rem,13rem)_1fr_auto]">
1337
- <p class="truncate font-mono text-xs font-semibold" title={group.name}>
1338
- {group.name}
1339
- </p>
 
 
 
1340
  <div class="h-2 rounded-full bg-muted">
1341
  <div
1342
  class="h-full rounded-full bg-[#0dce97]"
 
1
  <script lang="ts">
2
+ import {
3
+ ArrowDownIcon,
4
+ ArrowUpDownIcon,
5
+ ArrowUpIcon,
6
+ DownloadIcon,
7
+ ExternalLinkIcon
8
+ } from '@lucide/svelte';
9
  import BucketDataFallback from '$lib/components/BucketDataFallback.svelte';
10
  import BucketDataWarning from '$lib/components/BucketDataWarning.svelte';
11
+ import RadarLabelsOverlay from '$lib/components/charts/RadarLabelsOverlay.svelte';
12
  import OverflowTooltip from '$lib/components/OverflowTooltip.svelte';
13
  import PageHeadingSection from '$lib/components/PageHeadingSection.svelte';
14
  import Select from '$lib/components/Select.svelte';
 
23
  buildBenchmarkCards,
24
  buildMatrixGroups,
25
  buildMatrixRows,
26
+ detailMatrixRowsToCsv,
27
+ detailModelRowsToCsv,
28
  filterModels,
29
  matrixCellValue,
30
  matrixDatasetLabel,
 
58
  type ColumnExtrema = { best: number | null; worst: number | null };
59
 
60
  const selectContentClass = 'max-h-72';
61
+ const modelSheetRadarViewBoxSize = 320;
62
+ const modelSheetRadarLabelRadius = 132;
63
+ const modelSheetRadarHorizontalLabelScale = 0.86;
64
  let { data }: PageProps = $props();
65
  const detailsState = $derived(data.detailsState);
66
  const details = $derived(detailsState.data);
 
200
  const filteredModels = $derived.by(() =>
201
  filterModels(modelSearch, modelLanguage, modelType, modelSize, modelSort, models)
202
  );
203
+ const matrixCsvHref = $derived(
204
+ `data:text/csv;charset=utf-8,${encodeURIComponent(
205
+ detailMatrixRowsToCsv(
206
+ matrixRows,
207
+ visibleMatrixGroups,
208
+ benchmarkMetric,
209
+ details.datasetMatrix.cellsByModelId
210
+ )
211
+ )}`
212
+ );
213
+ const modelsCsvHref = $derived(
214
+ `data:text/csv;charset=utf-8,${encodeURIComponent(detailModelRowsToCsv(filteredModels))}`
215
+ );
216
  const selectedModelGroups = $derived.by(() =>
217
  modelGroupRows(selectedModel, details, getLocale())
218
  );
 
310
  return matrixSortDirection === -1 ? 'descending' : 'ascending';
311
  }
312
 
313
+ function matrixSortIsActive(datasetName: string | null) {
314
+ return matrixSortDataset === datasetName;
315
+ }
316
+
317
+ function matrixSortColumnClass(datasetName: string | null) {
318
+ return matrixSortIsActive(datasetName) && 'bg-muted/80 text-foreground';
319
+ }
320
+
321
  function metricCellClass(value: number | null, metric: DetailMetric, datasetName: string) {
322
  return cn(
323
+ 'matrix-cell min-w-32 border-b border-l px-3 py-3 text-center align-middle font-mono text-base font-semibold tabular-nums',
324
  !isNumber(value) && 'matrix-cell-empty !bg-muted/30 text-muted-foreground',
325
  isNumber(value) &&
326
  extremaTone(roundedDetailMetric(value, metric), matrixColumnExtrema[datasetName])
 
333
  datasetName: string,
334
  startsGroup: boolean
335
  ) {
336
+ return cn(
337
+ metricCellClass(value, metric, datasetName),
338
+ startsGroup && 'border-l-border',
339
+ matrixSortIsActive(datasetName) && 'bg-muted/20 text-foreground'
340
+ );
341
  }
342
 
343
  function scorePillClass(value: number | null) {
 
546
  <Button variant="outline" size="sm" onclick={() => (selectedGroups = [])}>
547
  {m.details_clear()}
548
  </Button>
549
+ <Button
550
+ href={matrixCsvHref}
551
+ download={`hivetrace_details_matrix_${benchmarkMetric}.csv`}
552
+ size="sm"
553
+ class="ml-auto bg-[#0dce97] text-emerald-950 hover:bg-[#0dce97]/90"
554
+ >
555
  <DownloadIcon data-icon="inline-start" />
556
  {m.ranking_csv()}
557
  </Button>
 
 
 
 
 
 
 
 
558
  </div>
559
  </section>
560
 
 
576
  <thead>
577
  <tr>
578
  <th
579
+ class={cn(
580
+ 'sticky top-0 z-30 h-24 min-w-80 max-w-80 border-r border-b border-border bg-muted px-4 py-3 text-left align-middle md:left-0',
581
+ matrixSortColumnClass(null)
582
+ )}
583
  rowspan="2"
584
  scope="col"
585
  aria-sort={matrixAriaSort(null)}
 
587
  <Button
588
  variant="ghost"
589
  size="sm"
590
+ class={cn(
591
+ 'h-auto justify-start gap-1.5 p-0 text-xs font-medium hover:bg-transparent',
592
+ matrixSortIsActive(null) && 'text-foreground'
593
+ )}
594
  onclick={() => handleMatrixSort(null)}
595
  >
596
  {m.details_matrix_model_sort()}
597
+ {#if matrixSortIsActive(null)}
598
+ {#if matrixSortDirection === 1}
599
+ <ArrowUpIcon class="size-3.5 shrink-0" />
600
+ {:else}
601
+ <ArrowDownIcon class="size-3.5 shrink-0" />
602
+ {/if}
603
+ {:else}
604
+ <ArrowUpDownIcon class="size-3.5 shrink-0 text-muted-foreground" />
605
+ {/if}
606
  </Button>
607
  </th>
608
  {#each visibleMatrixGroups as group, groupIndex (group.key)}
 
624
  <th
625
  class={cn(
626
  'sticky top-12 z-20 h-12 min-w-32 border-b border-l border-border bg-muted px-3 py-3 text-right text-xs font-semibold whitespace-nowrap text-muted-foreground',
627
+ matrixSortColumnClass(dataset.name),
628
  groupIndex === 0 && datasetIndex === 0 && 'border-l-0',
629
  (groupIndex > 0 || datasetIndex > 0) &&
630
  datasetIndex === 0 &&
 
636
  <Button
637
  variant="ghost"
638
  size="sm"
639
+ class={cn(
640
+ 'h-auto justify-end gap-1.5 p-0 text-[11px] font-medium text-muted-foreground hover:bg-transparent hover:text-foreground',
641
+ matrixSortIsActive(dataset.name) && 'text-foreground'
642
+ )}
643
  onclick={() => handleMatrixSort(dataset.name)}
644
  >
645
+ <OverflowTooltip
646
+ text={matrixDatasetLabel(dataset)}
647
+ class="block max-w-28 truncate"
648
+ triggerClass="block min-w-0"
649
+ contentClass="max-w-sm break-all"
650
+ />
651
+ {#if matrixSortIsActive(dataset.name)}
652
+ {#if matrixSortDirection === 1}
653
+ <ArrowUpIcon class="size-3.5 shrink-0" />
654
+ {:else}
655
+ <ArrowDownIcon class="size-3.5 shrink-0" />
656
+ {/if}
657
+ {:else}
658
+ <ArrowUpDownIcon class="size-3.5 shrink-0 text-muted-foreground" />
659
+ {/if}
660
  </Button>
661
  </th>
662
  {/each}
 
669
  class="hover:[&>td.matrix-cell-bad]:!bg-red-500/15 hover:[&>td.matrix-cell-empty]:!bg-muted/40 hover:[&>td.matrix-cell-good]:!bg-emerald-500/15 hover:[&>th]:bg-muted"
670
  >
671
  <th
672
+ class={cn(
673
+ 'z-20 min-w-80 max-w-80 border-r border-b bg-card px-4 py-3 text-left md:sticky md:left-0',
674
+ matrixSortIsActive(null) && 'bg-muted/30'
675
+ )}
676
  scope="row"
677
  >
678
  <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
 
1057
  >
1058
  {m.details_model_count({ shown: filteredModels.length, total: models.length })}
1059
  </span>
1060
+ <Button
1061
+ href={modelsCsvHref}
1062
+ download="hivetrace_details_models.csv"
1063
+ size="sm"
1064
+ class="bg-[#0dce97] text-emerald-950 hover:bg-[#0dce97]/90"
1065
+ >
1066
  <DownloadIcon data-icon="inline-start" />
1067
  {m.ranking_csv()}
1068
  </Button>
 
1254
  </h3>
1255
  {#if selectedModelRadarPoints.length >= 3}
1256
  <div class="flex justify-center">
1257
+ <div class="relative h-[28rem] w-full max-w-xl">
1258
+ <svg
1259
+ viewBox="0 0 320 320"
1260
+ class="h-full w-full"
1261
+ role="img"
1262
+ aria-label={m.details_model_sheet_radar()}
1263
+ >
1264
+ {#each radarGridPolygons as polygon (polygon)}
1265
+ <polygon
1266
+ points={polygon}
1267
+ fill="none"
1268
+ stroke="currentColor"
1269
+ stroke-width="1"
1270
+ stroke-dasharray="3 4"
1271
+ class="text-border"
1272
+ />
1273
+ {/each}
1274
+ {#each selectedModelRadarPoints as point, index (point.name)}
1275
+ <line
1276
+ x1="160"
1277
+ y1="160"
1278
+ x2={radarPoint(index, selectedModelRadarPoints.length, 118)[0]}
1279
+ y2={radarPoint(index, selectedModelRadarPoints.length, 118)[1]}
1280
+ stroke="currentColor"
1281
+ stroke-width="1"
1282
+ class="text-border"
1283
+ />
1284
+ {/each}
1285
  <polygon
1286
+ points={selectedModelRadarPolygon}
1287
+ fill="rgba(13, 206, 151, 0.22)"
1288
+ stroke="#0dce97"
1289
+ stroke-width="2"
 
 
1290
  />
1291
+ {#each selectedModelRadarPoints as point (point.name)}
1292
+ <circle cx={point.x} cy={point.y} r="3" fill="#0dce97" />
1293
+ {/each}
1294
+ </svg>
1295
+ <RadarLabelsOverlay
1296
+ labels={selectedModelRadarPoints.map((point, index) => {
1297
+ const [labelX, labelY] = radarPoint(
1298
+ index,
1299
+ selectedModelRadarPoints.length,
1300
+ modelSheetRadarLabelRadius
1301
+ );
1302
+ const adjustedLabelX =
1303
+ 160 + (labelX - 160) * modelSheetRadarHorizontalLabelScale;
1304
+ return {
1305
+ key: point.name,
1306
+ text: point.name,
1307
+ x: adjustedLabelX,
1308
+ y: labelY + 3,
1309
+ anchor:
1310
+ adjustedLabelX < 155 ? 'end' : adjustedLabelX > 165 ? 'start' : 'middle'
1311
+ };
1312
+ })}
1313
+ viewBoxWidth={modelSheetRadarViewBoxSize}
1314
+ viewBoxHeight={modelSheetRadarViewBoxSize}
1315
+ labelClass="text-[9px]"
1316
+ position="label"
1317
  />
1318
+ </div>
 
 
 
1319
  </div>
1320
  <div
1321
  class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-xs text-muted-foreground"
 
1423
  <div class="space-y-3">
1424
  {#each selectedModelGroups as group (group.name)}
1425
  <div class="grid items-center gap-3 sm:grid-cols-[minmax(9rem,13rem)_1fr_auto]">
1426
+ <OverflowTooltip
1427
+ text={group.name}
1428
+ class="block truncate font-mono text-xs font-semibold"
1429
+ triggerClass="block min-w-0"
1430
+ contentClass="max-w-sm break-all"
1431
+ />
1432
  <div class="h-2 rounded-full bg-muted">
1433
  <div
1434
  class="h-full rounded-full bg-[#0dce97]"
src/routes/tools/+page.svelte CHANGED
@@ -1,17 +1,25 @@
1
  <script lang="ts">
2
- import { tick } from 'svelte';
3
- import { ArrowUpDownIcon, DownloadIcon, PinIcon, XIcon } from '@lucide/svelte';
 
 
 
 
 
 
4
  import BucketDataFallback from '$lib/components/BucketDataFallback.svelte';
5
  import BucketDataWarning from '$lib/components/BucketDataWarning.svelte';
 
6
  import PageHeadingSection from '$lib/components/PageHeadingSection.svelte';
7
  import Select from '$lib/components/Select.svelte';
8
  import Table, { type TableColumn } from '$lib/components/Table.svelte';
 
 
9
  import { Badge } from '$lib/components/ui/badge';
10
  import { Button } from '$lib/components/ui/button';
11
  import { Checkbox } from '$lib/components/ui/checkbox';
12
  import {
13
  toolsActiveGroups,
14
- toolsBarLabelLines as barLabelLines,
15
  toolsBarY as barY,
16
  toolsCsvDataUri,
17
  toolsDataGroups,
@@ -31,12 +39,10 @@
31
  import { isNumber } from '$lib/helpers/leaderboard';
32
  import type { GuardModel } from '$lib/types/leaderboard';
33
  import type {
34
- ToolsChartTooltip as ChartTooltip,
35
  ToolsLanguageFilter as LangFilter,
36
  ToolsLatencySortKey as LatencySortKey,
37
  ToolsRadarScale as RadarScale,
38
  ToolsSortDirection as SortDirection,
39
- ToolsTooltipRow as TooltipRow,
40
  ToolsVizMetric as VizMetric
41
  } from '$lib/types/tools';
42
  import { m } from '$lib/paraglide/messages';
@@ -59,7 +65,6 @@
59
  type ParetoRow = VisualizationModelRow & {
60
  p95: number;
61
  integral: number;
62
- fpr: number | null;
63
  };
64
  type PerformanceRow = VisualizationModelRow & {
65
  rank: number;
@@ -76,6 +81,9 @@
76
  fnrReal: number | null;
77
  fnrRobust: number | null;
78
  deltaFnr: number | null;
 
 
 
79
  category: string;
80
  };
81
  type HeatmapRow = ToolsDataset['visualizations']['heatmap']['rows'][number];
@@ -84,6 +92,14 @@
84
  max: number | null;
85
  };
86
  const selectContentClass = 'max-h-72';
 
 
 
 
 
 
 
 
87
  let { data }: PageProps = $props();
88
  const toolsState = $derived(data.toolsState);
89
  const tools = $derived(toolsState.data);
@@ -94,7 +110,7 @@
94
  const initialTools = (() => data.toolsState.data)();
95
  const initialGroups = toolsDataGroups(initialTools.models, initialTools.allGroups);
96
 
97
- let selectedModelKeys = $state(initialTools.models.map((model) => modelKey(model)));
98
  let selectedGroups = $state<string[]>(initialGroups);
99
  let language = $state<LangFilter>('all');
100
  let metric = $state<VizMetric>('fnr');
@@ -102,12 +118,11 @@
102
  let pinnedModelKeys = $state<string[]>([]);
103
  let latencySortKey = $state<LatencySortKey>('p95');
104
  let latencySortDirection = $state<SortDirection>('asc');
105
- let chartTooltip = $state<ChartTooltip | null>(null);
106
- let chartTooltipElement: HTMLDivElement | null = $state(null);
107
  let hoveredRadarModelKey = $state<string | null>(null);
108
  let hoveredScatterModelKey = $state<string | null>(null);
109
  let hoveredBarKey = $state<string | null>(null);
110
  let hoveredParetoModelKey = $state<string | null>(null);
 
111
 
112
  const languageOptions = [
113
  { value: 'all', label: m.tools_language_all() },
@@ -123,11 +138,36 @@
123
  { value: 'f1', label: 'F1' }
124
  ];
125
  const latencyColumns: LatencyColumn[] = [
126
- { key: 'model', label: m.ranking_column_model },
127
- { key: 'p50', label: () => `p50 (${m.ranking_unit_ms()})`, cellClass: 'text-right' },
128
- { key: 'p95', label: () => `p95 (${m.ranking_unit_ms()})`, cellClass: 'text-right' },
129
- { key: 'p99', label: () => `p99 (${m.ranking_unit_ms()})`, cellClass: 'text-right' },
130
- { key: 'errorRate', label: m.details_error_rate, cellClass: 'text-right' }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  ];
132
 
133
  const currentLanguageLabel = $derived(
@@ -158,6 +198,7 @@
158
  const heatColumns = $derived(
159
  `minmax(8rem, 10rem) repeat(${heatmapModels.length}, minmax(4rem, 1fr))`
160
  );
 
161
  const scatterRows = $derived(
162
  scatterRowsFor(tools.visualizations.scatter.points, modelByKey, selectedModelKeySet)
163
  );
@@ -181,9 +222,11 @@
181
  const robustRows = $derived(
182
  robustnessRowsFor(tools.visualizations.robustness.rows, modelByKey, selectedModelKeySet)
183
  );
184
- const scatterXMax = $derived(Math.max(0.2, ...scatterRows.map((row) => row.fpr * 1.1)));
185
- const scatterYMax = $derived(Math.max(0.45, ...scatterRows.map((row) => row.fnr * 1.08)));
186
- const barSvgWidth = $derived(Math.max(720, activeGroups.length * 76));
 
 
187
  const paretoXMax = $derived(Math.max(1, ...paretoRows.map((row) => row.p95)) * 1.08);
188
  const paretoY0 = $derived(paretoYStart(paretoRows));
189
  const paretoXTicks = $derived(niceTicks(0, paretoXMax, 6));
@@ -214,6 +257,10 @@
214
  return `${Math.round(clamp01(value ?? 0) * 100)}%`;
215
  }
216
 
 
 
 
 
217
  function scatterRowsFor(
218
  points: ToolsDataset['visualizations']['scatter']['points'],
219
  modelsByKey: ReadonlyMap<string, GuardModel>,
@@ -250,9 +297,7 @@
250
  ) {
251
  return [];
252
  }
253
- return [
254
- { modelKey: key, model, p95: row.latencyMs.p95, integral: row.integral, fpr: row.fpr }
255
- ];
256
  });
257
  }
258
 
@@ -312,6 +357,9 @@
312
  fnrReal: row.fnrReal,
313
  fnrRobust: row.fnrRobust,
314
  deltaFnr: row.deltaFnr,
 
 
 
315
  category: localized(row.categoryLabel, '')
316
  }
317
  ];
@@ -340,17 +388,16 @@
340
  }
341
 
342
  function groupedBarsMean(
343
- group: string,
344
  key: VizMetric,
345
  modelsByVisualizationKey: ReadonlyMap<
346
  string,
347
  ToolsDataset['visualizations']['groupedBars']['models'][number]
348
  >,
349
- selectedKeys: ReadonlySet<string>
350
  ) {
351
- const values = [...selectedKeys]
352
- .map((selectedKey) => modelsByVisualizationKey.get(selectedKey)?.groups[group]?.[key])
353
- .filter(isNumber);
354
 
355
  if (!values.length) return null;
356
  return values.reduce((sum, value) => sum + value, 0) / values.length;
@@ -375,7 +422,8 @@
375
  }
376
 
377
  function paretoLabelText(row: ParetoRow) {
378
- return row.model.short.length > 18 ? `${row.model.short.slice(0, 17)}…` : row.model.short;
 
379
  }
380
 
381
  function paretoYStart(rows: readonly ParetoRow[]) {
@@ -430,57 +478,6 @@
430
  action();
431
  }
432
 
433
- function tooltipPosition(event: Pick<PointerEvent, 'clientX' | 'clientY'>) {
434
- const margin = 12;
435
- const offset = 16;
436
- const tooltipRect = chartTooltipElement?.getBoundingClientRect();
437
- const tooltipWidth = tooltipRect?.width ?? 384;
438
- const tooltipHeight = tooltipRect?.height ?? 180;
439
- let x = event.clientX + offset;
440
- let y = event.clientY + offset;
441
-
442
- if (typeof window === 'undefined') return { x, y };
443
-
444
- if (x + tooltipWidth + margin > window.innerWidth) {
445
- x = event.clientX - tooltipWidth - offset;
446
- }
447
- if (y + tooltipHeight + margin > window.innerHeight) {
448
- y = event.clientY - tooltipHeight - offset;
449
- }
450
-
451
- return {
452
- x: Math.min(Math.max(margin, x), Math.max(margin, window.innerWidth - tooltipWidth - margin)),
453
- y: Math.min(
454
- Math.max(margin, y),
455
- Math.max(margin, window.innerHeight - tooltipHeight - margin)
456
- )
457
- };
458
- }
459
-
460
- function updateTooltip(event: PointerEvent, title: string, rows: TooltipRow[]) {
461
- const position = tooltipPosition(event);
462
- chartTooltip = {
463
- x: position.x,
464
- y: position.y,
465
- title,
466
- rows
467
- };
468
-
469
- tick().then(() => {
470
- if (!chartTooltip) return;
471
- chartTooltip = { ...chartTooltip, ...tooltipPosition(event) };
472
- });
473
- }
474
-
475
- function moveTooltip(event: PointerEvent) {
476
- if (!chartTooltip) return;
477
- chartTooltip = { ...chartTooltip, ...tooltipPosition(event) };
478
- }
479
-
480
- function hideTooltip() {
481
- chartTooltip = null;
482
- }
483
-
484
  function toggleLatencySort(key: LatencySortKey) {
485
  if (latencySortKey === key) {
486
  latencySortDirection = latencySortDirection === 'asc' ? 'desc' : 'asc';
@@ -495,12 +492,20 @@
495
  return typeof column.label === 'function' ? column.label() : column.label;
496
  }
497
 
 
 
 
 
 
 
 
 
498
  function heatTextColor(value: number | null | undefined) {
499
  if (!isNumber(value)) return 'text-muted-foreground';
500
  return 'text-black';
501
  }
502
 
503
- function deltaBadgeClass(value: number | null, kind: 'score' | 'fnr') {
504
  if (!isNumber(value) || value === 0) {
505
  return 'bg-muted/40 text-muted-foreground';
506
  }
@@ -526,995 +531,1045 @@
526
  <BucketDataFallback />
527
  </div>
528
  {:else}
529
- <div class="mx-auto flex w-full min-w-0 max-w-7xl flex-col px-4 py-8 sm:px-6 lg:px-8">
530
- {#if toolsState.status === 'stale'}
531
- <BucketDataWarning />
532
- {/if}
533
- <PageHeadingSection title={m.navigation_visualizations()} description={m.tools_description()} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
 
535
- <section
536
- class="mt-6 min-w-0 rounded-xl border bg-card p-4 shadow-sm"
537
- aria-label={m.tools_controls_aria()}
538
- >
539
- <div class="grid min-w-0 gap-4 lg:grid-cols-[1.1fr_1.1fr_.7fr]">
540
- <div class="min-w-0">
541
- <div class="mb-2 flex items-center justify-between gap-3">
542
- <h2 class="text-xs font-semibold tracking-wider text-muted-foreground uppercase">
543
- {m.tools_models_title()}
544
- </h2>
545
- <div class="flex gap-2 text-xs">
546
- <Button
547
- variant="outline"
548
- size="sm"
549
- class="h-8 rounded-full px-4"
550
- onclick={() => (selectedModelKeys = models.map((model) => modelKey(model)))}
551
- >{m.details_select_all()}</Button
552
- >
553
- <Button
554
- variant="outline"
555
- size="sm"
556
- class="h-8 rounded-full px-4"
557
- onclick={() => (selectedModelKeys = [])}>{m.details_reset()}</Button
558
- >
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
  </div>
560
  </div>
561
- <div class="max-h-36 space-y-1 overflow-auto rounded-lg border bg-background/50 p-2">
562
- {#each models as model (modelKey(model))}
563
- {@const key = modelKey(model)}
 
 
 
 
564
  <div
565
- class="flex min-w-0 items-center gap-3 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50"
566
- role="checkbox"
567
- tabindex="0"
568
- aria-checked={selectedModelKeys.includes(key)}
569
- onclick={() => (selectedModelKeys = toggleListValue(selectedModelKeys, key))}
570
- onkeydown={(event) =>
571
- handleCheckboxKeydown(
572
- event,
573
- () => (selectedModelKeys = toggleListValue(selectedModelKeys, key))
574
- )}
575
  >
576
- <Checkbox
577
- checked={selectedModelKeys.includes(key)}
578
- aria-label={model.short}
579
- tabindex={-1}
580
- />
581
- <span class="size-3 rounded-sm" style={`background:${colorOf(model)}`}></span>
582
- <span class="min-w-0 flex-1 truncate">
583
- {model.short} <code class="text-muted-foreground">{model.params}</code>
584
- </span>
585
  <Button
586
- variant="outline"
587
  size="sm"
588
- class={cn(
589
- 'h-7 rounded-full px-3 text-xs',
590
- pinnedModelKeys.includes(key) &&
591
- 'border-[#0dce97]/60 bg-[#0dce97]/10 text-[#0dce97] hover:bg-[#0dce97]/15 hover:text-[#0dce97]'
592
- )}
593
- aria-pressed={pinnedModelKeys.includes(key)}
594
- onclick={(event) => {
595
- event.preventDefault();
596
- event.stopPropagation();
597
- togglePin(key);
598
- }}
599
  >
600
- <PinIcon class="size-3" />
601
- {pinnedModelKeys.includes(key) ? m.tools_pinned() : m.tools_pin()}
 
 
 
 
 
 
 
602
  </Button>
603
  </div>
604
- {/each}
605
- </div>
606
- </div>
607
-
608
- <div class="min-w-0">
609
- <div class="mb-2 flex items-center justify-between gap-3">
610
- <h2 class="text-xs font-semibold tracking-wider text-muted-foreground uppercase">
611
- {m.tools_groups_title()}
612
- </h2>
613
- <div class="flex gap-2 text-xs">
614
- <Button
615
- variant="outline"
616
- size="sm"
617
- class="h-8 rounded-full px-4"
618
- onclick={() => (selectedGroups = [...dataGroups])}>{m.details_select_all()}</Button
619
  >
 
 
 
 
 
620
  <Button
621
- variant="outline"
 
622
  size="sm"
623
- class="h-8 rounded-full px-4"
624
- onclick={() => (selectedGroups = [])}>{m.details_reset()}</Button
625
  >
 
 
 
626
  </div>
627
  </div>
628
- <div class="max-h-36 space-y-1 overflow-auto rounded-lg border bg-background/50 p-2">
629
- {#each dataGroups as group (group)}
630
- <div
631
- class="flex min-w-0 items-center gap-3 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50"
632
- role="checkbox"
633
- tabindex="0"
634
- aria-checked={selectedGroups.includes(group)}
635
- onclick={() => (selectedGroups = toggleListValue(selectedGroups, group))}
636
- onkeydown={(event) =>
637
- handleCheckboxKeydown(
638
- event,
639
- () => (selectedGroups = toggleListValue(selectedGroups, group))
640
- )}
641
- >
642
- <Checkbox
643
- checked={selectedGroups.includes(group)}
644
- aria-label={groupLabel(group)}
645
- tabindex={-1}
646
- />
647
- <span class="min-w-0 truncate">
648
- {groupLabel(group)}
649
- <span class="text-muted-foreground"
650
- >{benchmarkMeta[group]?.languages.join(' · ')}</span
651
- >
652
- </span>
653
- </div>
654
- {/each}
655
- </div>
656
- </div>
657
 
658
- <div class="grid content-start gap-3">
659
- <Select
660
- id="tools-language"
661
- type="single"
662
- bind:value={language}
663
- options={languageOptions}
664
- label={m.tools_language_label()}
665
- triggerLabel={currentLanguageLabel}
666
- contentClass={selectContentClass}
667
- />
668
- <Select
669
- id="tools-metric"
670
- type="single"
671
- bind:value={metric}
672
- options={metricOptions}
673
- label={m.tools_metric_label()}
674
- triggerLabel={currentMetricLabel}
675
- contentClass={selectContentClass}
676
- />
677
- </div>
678
- </div>
679
-
680
- <div class="mt-4 flex flex-wrap items-end gap-3">
681
- <div class="min-w-0 space-y-2">
682
- <span class="block text-xs font-semibold tracking-wider text-muted-foreground uppercase"
683
- >{m.tools_radar_scale()}</span
684
- >
685
- <div
686
- class="inline-flex max-w-full flex-wrap gap-1 rounded-4xl border bg-input/50 p-1"
687
- role="group"
688
- aria-label={m.tools_radar_scale()}
689
- >
690
- <Button
691
- variant={radarScale === 'score' ? 'default' : 'ghost'}
692
- size="sm"
693
- aria-pressed={radarScale === 'score'}
694
- onclick={() => (radarScale = 'score')}
695
- >
696
- {m.tools_radar_group_score()}
697
- </Button>
698
- <Button
699
- variant={radarScale === 'f1' ? 'default' : 'ghost'}
700
- size="sm"
701
- aria-pressed={radarScale === 'f1'}
702
- onclick={() => (radarScale = 'f1')}
703
- >
704
- F1
705
- </Button>
706
- </div>
707
- </div>
708
- <div class="ml-auto flex flex-wrap items-center justify-end gap-3">
709
- <span
710
- class="rounded-full border border-[#0dce97]/40 bg-[#0dce97]/10 px-4 py-2 font-mono text-xs font-semibold text-[#0dce97]"
711
- >
712
- {m.tools_selected_summary({
713
- models: selectedModels.length,
714
- groups: activeGroups.length
715
- })}
716
- </span>
717
- <Button
718
- href={csvHref}
719
- download="hivetrace_tools_selection.csv"
720
- size="sm"
721
- class="bg-[#0dce97] text-emerald-950 hover:bg-[#0dce97]/90"
722
  >
723
- <DownloadIcon data-icon="inline-start" />
724
- {m.ranking_csv()}
725
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
726
  </div>
727
- </div>
728
- </section>
729
 
730
- <div
731
- class="mt-4 flex min-h-[46px] flex-wrap items-center gap-x-2 gap-y-3 rounded-xl border bg-card px-4 py-3 text-xs leading-none"
732
- >
733
- <span class="font-semibold tracking-wider text-muted-foreground uppercase leading-none"
734
- >{m.tools_pinned_for_comparison()}</span
735
- >
736
- {#if pinnedModels.length}
737
- {#each pinnedModels as model (modelKey(model))}
738
- <Badge variant="secondary" class="gap-1 rounded-full">
739
- <span class="size-2 rounded-full" style={`background:${colorOf(model)}`}></span>
740
- {model.short}
741
- <button
742
- type="button"
743
- aria-label={m.tools_unpin_model({ model: model.short })}
744
- onclick={() => togglePin(modelKey(model))}
745
- >
746
- <XIcon class="size-3" />
747
- </button>
748
- </Badge>
749
- {/each}
750
- {:else}
751
- <span class="leading-none text-muted-foreground">
752
- {m.tools_no_pinned()}
753
- </span>
754
- {/if}
755
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
756
 
757
- <div class="mt-4 grid min-w-0 gap-4 lg:grid-cols-2">
758
- <section class="min-w-0 rounded-xl border bg-card p-4 shadow-sm">
759
- <div class="mb-3 flex flex-wrap items-baseline gap-2">
760
- <h2 class="font-mono text-sm font-semibold">{m.tools_radar_title()}</h2>
761
- <span class="text-xs text-muted-foreground">{m.tools_radar_hint()}</span>
762
- </div>
763
- {#if selectedModels.length && activeGroups.length >= 3}
764
- <div class="overflow-hidden">
765
  <svg
766
  viewBox="0 0 520 420"
767
- class="h-auto w-full"
768
  role="img"
769
- aria-label={m.tools_radar_aria()}
770
  >
771
- {#each [0.25, 0.5, 0.75, 1] as scale (scale)}
772
- <polygon
773
- points={activeGroups
774
- .map((_, index) => {
775
- const point = radarPoint(index, activeGroups.length, 150 * scale);
776
- return `${point.x.toFixed(1)},${point.y.toFixed(1)}`;
777
- })
778
- .join(' ')}
779
- fill="none"
780
  stroke="currentColor"
781
  stroke-width="1"
782
  stroke-dasharray="3 4"
783
  class="text-border"
784
  />
785
- <text
786
- x="264"
787
- y={214 - 150 * scale}
788
- class="fill-muted-foreground font-mono text-[10px]"
789
- >
790
- {scale.toFixed(2)}
791
- </text>
792
- {/each}
793
- {#each activeGroups as group, index (group)}
794
- {@const axis = radarPoint(index, activeGroups.length, 150)}
795
- {@const label = radarPoint(index, activeGroups.length, 178)}
796
  <line
797
- x1="260"
798
- y1="214"
799
- x2={axis.x}
800
- y2={axis.y}
801
  stroke="currentColor"
802
  stroke-width="1"
 
803
  class="text-border"
804
  />
 
 
 
 
 
 
 
 
805
  <text
806
- x={label.x}
807
- y={label.y + 4}
808
- text-anchor={Math.abs(label.x - 260) < 8
809
- ? 'middle'
810
- : label.x > 260
811
- ? 'start'
812
- : 'end'}
813
- class="fill-muted-foreground font-mono text-[10px]"
814
  >
815
- {groupLabel(group).length > 14
816
- ? `${groupLabel(group).slice(0, 13)}…`
817
- : groupLabel(group)}
818
- </text>
819
  {/each}
820
- {#each selectedModels as model (modelKey(model))}
821
- {@const key = modelKey(model)}
822
- {@const isPinned = pinnedModelKeys.includes(key)}
823
- {@const isHovered = hoveredRadarModelKey === key}
824
- <polygon
825
- points={radarVisualizationPolygon(key, activeGroups, radarScale)}
826
- fill={colorOf(model)}
827
- fill-opacity={isHovered
828
- ? 0.24
829
- : isPinned
830
- ? 0.22
831
- : pinnedModelKeys.length
832
- ? 0.06
833
- : 0.12}
834
- stroke={colorOf(model)}
835
- stroke-width={isPinned ? 3 : 1.6}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
836
  />
837
- {#each activeGroups as group, index (group)}
838
- {@const value = radarVisualizationValue(key, group, radarScale) ?? 0}
839
- {@const point = radarPoint(index, activeGroups.length, 150 * clamp01(value))}
840
  <circle
841
  cx={point.x}
842
  cy={point.y}
843
- r={isHovered ? 5 : isPinned ? 4 : 3}
844
- fill={colorOf(model)}
845
- class="transition-[r]"
846
- role="img"
847
- aria-label={`${model.short} ${groupLabel(group)} ${radarScale.toUpperCase()} ${fmt(value, 3)}`}
848
- onpointerenter={(event) => {
849
- hoveredRadarModelKey = key;
850
- updateTooltip(event, model.short, [
851
- { label: groupLabel(group), value: radarScale.toUpperCase() },
852
- { label: 'value', value: fmt(value, 3) }
853
- ]);
854
- }}
855
- onpointermove={moveTooltip}
856
- onpointerleave={() => {
857
- hoveredRadarModelKey = null;
858
- hideTooltip();
859
- }}
860
  />
861
- {/each}
862
  {/each}
863
  </svg>
864
- </div>
865
- <div class="mt-3 flex min-w-0 flex-wrap gap-x-4 gap-y-2 text-xs text-muted-foreground">
866
- {#each selectedModels as model (modelKey(model))}
867
- {@const key = modelKey(model)}
868
- {@const isPinned = pinnedModelKeys.includes(key)}
869
- <span
870
- class={cn(
871
- 'inline-flex min-w-0 max-w-full items-center gap-2 rounded-full border border-transparent px-2 py-1 transition-colors',
872
- isPinned &&
873
- 'border-[#0dce97]/35 bg-[#0dce97]/10 text-foreground shadow-sm shadow-[#0dce97]/5'
874
- )}
875
- >
876
- <span class="size-2 rounded-full" style={`background:${colorOf(model)}`}></span>
877
- {#if isPinned}
878
- <PinIcon class="size-3 text-[#0dce97]" />
879
- {/if}
880
- <span class="min-w-0 truncate">{model.short}</span>
881
- <button
882
- class="font-semibold text-[#0dce97]"
883
- type="button"
884
- onclick={() => togglePin(key)}
885
- >
886
- {isPinned ? m.tools_unpin() : m.tools_pin()}
887
- </button>
888
- </span>
889
- {/each}
890
- </div>
891
- {:else}
892
- <p class="rounded-lg border bg-muted/30 p-6 text-center text-sm text-muted-foreground">
893
- {m.tools_radar_empty()}
894
- </p>
895
- {/if}
896
- </section>
897
-
898
- <section class="min-w-0 rounded-xl border bg-card p-4 shadow-sm">
899
- <div class="mb-3 flex flex-wrap items-baseline gap-2">
900
- <h2 class="font-mono text-sm font-semibold">FPR × FNR</h2>
901
- <span class="text-xs text-muted-foreground">{m.tools_scatter_hint()}</span>
902
- </div>
903
- <svg
904
- viewBox="0 0 520 420"
905
- class="h-auto w-full"
906
- role="img"
907
- aria-label={m.tools_scatter_aria()}
908
- >
909
- {#each [0.25, 0.5, 0.75, 1] as ratio (ratio)}
910
- {@const xt = scatterXMax * ratio}
911
- {@const yt = scatterYMax * ratio}
912
- <line
913
- x1={scatterX(xt, scatterXMax)}
914
- y1="18"
915
- x2={scatterX(xt, scatterXMax)}
916
- y2="368"
917
- stroke="currentColor"
918
- stroke-width="1"
919
- stroke-dasharray="3 4"
920
- class="text-border"
921
- />
922
- <line
923
- x1="52"
924
- y1={scatterY(yt, scatterYMax)}
925
- x2="502"
926
- y2={scatterY(yt, scatterYMax)}
927
- stroke="currentColor"
928
- stroke-width="1"
929
- stroke-dasharray="3 4"
930
- class="text-border"
931
- />
932
- {/each}
933
- {#each [0, 0.25, 0.5, 0.75, 1] as ratio (ratio)}
934
- {@const xt = scatterXMax * ratio}
935
- {@const yt = scatterYMax * ratio}
936
- <text
937
- x={scatterX(xt, scatterXMax)}
938
- y="386"
939
- text-anchor="middle"
940
- class="fill-muted-foreground font-mono text-[10px]">{xt.toFixed(2)}</text
941
- >
942
- <text
943
- x="44"
944
- y={scatterY(yt, scatterYMax) + 3}
945
- text-anchor="end"
946
- class="fill-muted-foreground font-mono text-[10px]">{yt.toFixed(2)}</text
947
- >
948
- {/each}
949
- <line x1="52" y1="18" x2="52" y2="368" stroke="currentColor" class="text-border" />
950
- <line x1="52" y1="368" x2="502" y2="368" stroke="currentColor" class="text-border" />
951
- <text
952
- x="277"
953
- y="408"
954
- text-anchor="middle"
955
- class="fill-muted-foreground font-mono text-[10px]">FPR →</text
956
- >
957
- <text
958
- transform="translate(16,193) rotate(-90)"
959
- text-anchor="middle"
960
- class="fill-muted-foreground font-mono text-[10px]">FNR →</text
961
- >
962
- <text
963
- x={scatterX(0, scatterXMax)}
964
- y={scatterY(0, scatterYMax)}
965
- text-anchor="middle"
966
- dominant-baseline="central"
967
- class="fill-[#0dce97] text-sm">★</text
968
- >
969
- {#each scatterRows as row (row.modelKey)}
970
- {@const point = {
971
- x: scatterX(row.fpr, scatterXMax),
972
- y: scatterY(row.fnr, scatterYMax)
973
- }}
974
- {@const isPinned = pinnedModelKeys.includes(row.modelKey)}
975
- {@const isHovered = hoveredScatterModelKey === row.modelKey}
976
- <circle
977
- cx={point.x}
978
- cy={point.y}
979
- r={isHovered ? 9 : isPinned ? 8 : 6}
980
- fill={colorOf(row.model)}
981
- fill-opacity="0.85"
982
- stroke="currentColor"
983
- stroke-width={isPinned ? 2 : 1}
984
- class="text-background transition-[r]"
985
- role="img"
986
- aria-label={`${row.model.short} FPR ${fmt(row.fpr, 3)} FNR ${fmt(row.fnr, 3)}`}
987
- onpointerenter={(event) => {
988
- hoveredScatterModelKey = row.modelKey;
989
- updateTooltip(event, row.model.short, [
990
- { label: 'FPR', value: fmt(row.fpr, 3) },
991
- { label: 'FNR', value: fmt(row.fnr, 3) }
992
- ]);
993
- }}
994
- onpointermove={moveTooltip}
995
- onpointerleave={() => {
996
- hoveredScatterModelKey = null;
997
- hideTooltip();
998
- }}
999
- />
1000
- {#if isPinned}
1001
- <circle
1002
- cx={point.x}
1003
- cy={point.y}
1004
- r="11"
1005
- fill="none"
1006
- stroke={colorOf(row.model)}
1007
- stroke-width="1.5"
1008
- />
1009
- {/if}
1010
- {/each}
1011
- </svg>
1012
- <div class="mt-3 flex flex-wrap gap-4 text-xs text-muted-foreground">
1013
- <span class="text-[#0dce97]">{m.tools_scatter_ideal()}</span>
1014
- </div>
1015
- </section>
1016
-
1017
- <section class="min-w-0 rounded-xl border bg-card p-4 shadow-sm lg:col-span-2">
1018
- <div class="mb-3 flex flex-wrap items-center gap-3">
1019
- <h2 class="font-mono text-sm font-semibold">
1020
- Heatmap <span class="font-mono uppercase">{heatmapMetric}</span>
1021
- </h2>
1022
- <span class="ml-auto inline-flex items-center gap-2 text-xs text-muted-foreground">
1023
- {m.tools_heatmap_scale_min({ value: heatmapScaleLabel(heatmapColorScale.min) })}
1024
- <span class="h-2 w-28 rounded-full bg-[linear-gradient(90deg,#0dce97,#ffca28,#ff4d4f)]"
1025
- ></span>
1026
- {m.tools_heatmap_scale_max({ value: heatmapScaleLabel(heatmapColorScale.max) })}
1027
- </span>
1028
- </div>
1029
- <div class="max-w-full overflow-auto">
1030
- <div class="grid min-w-[54rem] gap-1" style:grid-template-columns={heatColumns}>
1031
- <div
1032
- class="px-2 py-2 font-mono text-[10px] text-muted-foreground grid place-items-center"
1033
- >
1034
- {m.tools_heatmap_header({ metric: heatmapMetric.toUpperCase() })}
1035
  </div>
1036
- {#each heatmapModels as model (modelKey(model))}
1037
- <div
1038
- class="flex h-32 min-w-0 items-end justify-center overflow-hidden px-1 pb-2 text-center font-mono text-[10px] text-muted-foreground"
1039
- >
1040
- <span
1041
- class="block max-h-full max-w-full overflow-hidden leading-tight break-words whitespace-normal [overflow-wrap:anywhere] [writing-mode:vertical-rl]"
1042
- aria-label={model.short}
1043
- >
1044
- {model.short}
 
 
 
 
 
1045
  </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1046
  </div>
1047
- {/each}
1048
- {#each heatmapRows as row (row.id)}
1049
  <div
1050
- class="flex min-w-0 items-center justify-end px-2 py-1 text-right text-xs leading-none text-muted-foreground"
1051
  >
1052
- <span class="min-w-0 truncate">{heatmapRowLabel(row)}</span>
1053
  </div>
1054
- {#each heatmapModels as model (modelKey(model))}
1055
- {@const key = modelKey(model)}
1056
- {@const value = heatmapValue(row, key)}
1057
- {@const hasValue = isNumber(value)}
1058
- <div
 
 
 
 
 
 
 
 
 
 
 
 
1059
  role="img"
1060
- class={cn(
1061
- 'w-full rounded-md px-2 py-2 text-center font-mono text-sm font-semibold',
1062
- hasValue && 'transition duration-150 hover:brightness-110 hover:saturate-125',
1063
- !isNumber(value) && 'bg-muted/70 text-muted-foreground',
1064
- isNumber(value) && heatTextColor(value)
1065
- )}
1066
- style={isNumber(value)
1067
- ? `background:${heatColor(
1068
- value,
1069
- heatmapColorScale.min ?? value,
1070
- heatmapColorScale.max ?? value,
1071
- 'sqrt'
1072
- )}`
1073
- : ''}
1074
- aria-label={`${model.short} ${heatmapRowLabel(row)} ${heatmapMetric.toUpperCase()} ${fmt(value, 3)}`}
1075
- onpointerenter={(event) =>
1076
- updateTooltip(event, model.short, [
1077
- { label: groupLabel(row.groupKey), value: heatmapRowLabel(row) },
1078
- {
1079
- label: heatmapMetric.toUpperCase(),
1080
- value: fmt(value, 3)
1081
- }
1082
- ])}
1083
- onpointermove={moveTooltip}
1084
- onpointerleave={hideTooltip}
1085
  >
1086
- {isNumber(value) ? fmt(value, 2) : '-'}
1087
- </div>
1088
- {/each}
1089
- {/each}
1090
- </div>
1091
- </div>
1092
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1093
 
1094
- <section class="min-w-0 rounded-xl border bg-card p-4 shadow-sm lg:col-span-2">
1095
- <div class="mb-3 flex flex-wrap items-baseline gap-2">
1096
- <h2 class="font-mono text-sm font-semibold">{m.tools_grouped_bars_title()}</h2>
1097
- <span class="text-xs text-muted-foreground">{m.tools_grouped_bars_hint()}</span>
1098
- </div>
1099
- <div class="max-w-full overflow-x-auto">
1100
- <svg
1101
- viewBox={`0 0 ${barSvgWidth} 360`}
1102
- class="h-auto min-w-[45rem] w-full"
1103
- role="img"
1104
- aria-label={m.tools_grouped_bars_aria()}
1105
- >
1106
- {#each [0, 0.25, 0.5, 0.75, 1] as tick (tick)}
1107
- <line
1108
- x1="44"
1109
- y1={barY(tick)}
1110
- x2={barSvgWidth - 12}
1111
- y2={barY(tick)}
1112
- stroke="currentColor"
1113
- stroke-width="1"
1114
- stroke-dasharray="3 4"
1115
- class="text-border"
1116
- />
1117
- <text
1118
- x="38"
1119
- y={barY(tick) + 3}
1120
- text-anchor="end"
1121
- class="fill-muted-foreground font-mono text-[13px]">{tick.toFixed(2)}</text
1122
  >
1123
- {/each}
1124
- {#each activeGroups as group, index (group)}
1125
- {@const band = (barSvgWidth - 56) / activeGroups.length}
1126
- {@const x0 = 44 + index * band + band * 0.18}
1127
- {@const barW = (band * 0.64) / 3}
1128
- {#each [{ key: 'fnr', color: '#ff4d4f' }, { key: 'fpr', color: '#f5a623' }, { key: 'f1', color: '#0dce97' }] as series, seriesIndex (series.key)}
1129
- {@const barKey = `${group}-${series.key}`}
1130
- {@const isHovered = hoveredBarKey === barKey}
1131
- {@const isDimmed = hoveredBarKey !== null && !isHovered}
1132
- {@const value = groupedBarsMean(
1133
- group,
1134
- series.key as VizMetric,
1135
- groupedBarsByModelKey,
1136
- selectedModelKeySet
1137
- )}
1138
- {#if isNumber(value)}
1139
- <rect
1140
- x={x0 + seriesIndex * barW}
1141
- y={barY(value)}
1142
- width={barW - 2}
1143
- height={268 - barY(value)}
1144
- rx="2"
1145
- fill={series.color}
1146
- fill-opacity={isDimmed ? 0.45 : 1}
1147
- class={cn('transition-[fill-opacity,filter]', isHovered && 'brightness-125')}
1148
- role="img"
1149
- aria-label={`${groupLabel(group)} ${series.key.toUpperCase()} ${fmt(value, 3)}`}
1150
- onpointerenter={(event) => {
1151
- hoveredBarKey = barKey;
1152
- updateTooltip(event, groupLabel(group), [
1153
- {
1154
- label: series.key.toUpperCase(),
1155
- value: fmt(value, 3)
1156
- },
1157
- {
1158
- label: m.tools_mean(),
1159
- value: m.tools_selected_summary({
1160
- models: selectedModels.length,
1161
- groups: activeGroups.length
1162
- })
1163
- }
1164
- ]);
1165
- }}
1166
- onpointermove={moveTooltip}
1167
- onpointerleave={() => {
1168
- hoveredBarKey = null;
1169
- hideTooltip();
1170
- }}
1171
  />
1172
- {/if}
1173
- {/each}
1174
- <text
1175
- transform={`translate(${x0 + (barW * 3) / 2},282) rotate(40)`}
1176
- text-anchor="start"
1177
- class="fill-muted-foreground font-mono text-[13px]"
1178
- >
1179
- {#each barLabelLines(groupLabel(group)) as line, lineIndex (`${group}-${lineIndex}`)}
1180
- <tspan x="0" dy={lineIndex === 0 ? 0 : 14}>{line}</tspan>
1181
  {/each}
1182
- </text>
1183
- {/each}
1184
- </svg>
1185
- </div>
1186
- <div class="mt-3 flex flex-wrap gap-4 text-xs text-muted-foreground">
1187
- <span class="inline-flex items-center gap-2"
1188
- ><span class="size-2 rounded-full bg-[#ff4d4f]"></span>FNR ({m.tools_lower_better()})</span
1189
- >
1190
- <span class="inline-flex items-center gap-2"
1191
- ><span class="size-2 rounded-full bg-[#f5a623]"></span>FPR ({m.tools_lower_better()})</span
1192
- >
1193
- <span class="inline-flex items-center gap-2"
1194
- ><span class="size-2 rounded-full bg-[#0dce97]"></span>F1 ({m.tools_higher_better()})</span
1195
- >
1196
- </div>
1197
- </section>
1198
-
1199
- <section class="min-w-0 rounded-xl border bg-card p-4 shadow-sm lg:col-span-2">
1200
- <div class="mb-3 flex flex-wrap items-baseline gap-2">
1201
- <h2 class="font-mono text-sm font-semibold">{m.tools_pareto_title()}</h2>
1202
- <span class="text-xs text-muted-foreground">{m.tools_pareto_hint()}</span>
1203
- </div>
1204
- <div class="max-w-full overflow-x-auto">
1205
- <svg
1206
- viewBox="0 0 760 380"
1207
- class="h-auto min-w-[42rem] w-full"
1208
- role="img"
1209
- aria-label={m.tools_pareto_aria()}
1210
- >
1211
- {#each paretoXTicks as xt (xt)}
1212
- {#if xt > 0}
1213
  <line
1214
- x1={paretoX(xt, paretoXMax)}
1215
  y1="18"
1216
- x2={paretoX(xt, paretoXMax)}
1217
  y2="330"
1218
  stroke="currentColor"
1219
  stroke-width="1"
1220
- stroke-dasharray="3 4"
1221
  class="text-border"
1222
  />
1223
- {/if}
1224
- <text
1225
- x={paretoX(xt, paretoXMax)}
1226
- y="348"
1227
- text-anchor="middle"
1228
- class="fill-muted-foreground font-mono text-[7px]">{Math.round(xt)}</text
1229
- >
1230
- {/each}
1231
- {#each paretoYTicks as yt (yt)}
1232
- <line
1233
- x1="56"
1234
- y1={paretoY(yt, paretoY0)}
1235
- x2="742"
1236
- y2={paretoY(yt, paretoY0)}
1237
- stroke="currentColor"
1238
- stroke-width="1"
1239
- stroke-dasharray="3 4"
1240
- class="text-border"
1241
- />
1242
- <text
1243
- x="48"
1244
- y={paretoY(yt, paretoY0) + 3}
1245
- text-anchor="end"
1246
- class="fill-muted-foreground font-mono text-[7px]">{yt.toFixed(2)}</text
1247
- >
1248
- {/each}
1249
- <line
1250
- x1="56"
1251
- y1="18"
1252
- x2="56"
1253
- y2="330"
1254
- stroke="currentColor"
1255
- stroke-width="1"
1256
- class="text-border"
1257
- />
1258
- <line
1259
- x1="56"
1260
- y1="330"
1261
- x2="742"
1262
- y2="330"
1263
- stroke="currentColor"
1264
- stroke-width="1"
1265
- class="text-border"
1266
- />
1267
- <polyline
1268
- points={paretoFrontFor(paretoRows, paretoXMax, paretoY0)}
1269
- fill="none"
1270
- stroke="#6b4bf5"
1271
- stroke-width="1.6"
1272
- stroke-dasharray="5 4"
1273
- />
1274
- {#each paretoRows as row (row.modelKey)}
1275
- {@const point = paretoPointFor(row, paretoXMax, paretoY0)}
1276
- {@const pinned = pinnedModelKeys.includes(row.modelKey)}
1277
- {@const hovered = hoveredParetoModelKey === row.modelKey}
1278
- <circle
1279
- cx={point.x}
1280
- cy={point.y}
1281
- r={hovered ? 8 : pinned ? 7 : 5.5}
1282
- fill={heatColor(clamp01((row.fpr ?? 0) / 0.15))}
1283
- stroke="currentColor"
1284
- stroke-width={pinned ? 2 : 1.5}
1285
- class="cursor-pointer text-background transition-[r] focus:outline-none"
1286
- role="button"
1287
- tabindex="0"
1288
- aria-pressed={pinned}
1289
- aria-label={pinned
1290
- ? m.tools_pareto_unpin_model({ model: row.model.short })
1291
- : m.tools_pareto_pin_model({ model: row.model.short })}
1292
- onclick={() => togglePin(row.modelKey)}
1293
- onkeydown={(event) => handleCheckboxKeydown(event, () => togglePin(row.modelKey))}
1294
- onfocus={() => (hoveredParetoModelKey = row.modelKey)}
1295
- onblur={() => (hoveredParetoModelKey = null)}
1296
- onpointerenter={(event) => {
1297
- hoveredParetoModelKey = row.modelKey;
1298
- updateTooltip(event, row.model.short, [
1299
- { label: 'integral', value: fmt(row.integral, 3) },
1300
- { label: 'p95', value: `${fmtMs(row.p95)} ${m.ranking_unit_ms()}` },
1301
- { label: 'FPR', value: fmt(row.fpr, 3) }
1302
- ]);
1303
- }}
1304
- onpointermove={moveTooltip}
1305
- onpointerleave={() => {
1306
- hoveredParetoModelKey = null;
1307
- hideTooltip();
1308
- }}
1309
- />
1310
- {#if pinned}
1311
- <circle
1312
- cx={point.x}
1313
- cy={point.y}
1314
- r="9.5"
1315
  fill="none"
1316
  stroke="#6b4bf5"
1317
- stroke-width="1.5"
1318
- pointer-events="none"
1319
  />
1320
- {/if}
1321
- {/each}
1322
- {#each paretoRows as row (row.modelKey)}
1323
- {@const point = paretoPointFor(row, paretoXMax, paretoY0)}
1324
- {@const pinned = pinnedModelKeys.includes(row.modelKey)}
1325
- {#if pinned}
1326
- {@const labelX = Math.min(Math.max(72, point.x), 728)}
1327
- {@const labelY = Math.max(24, point.y - 18)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1328
  <text
1329
- x={labelX}
1330
- y={labelY}
1331
  text-anchor="middle"
1332
- stroke="var(--card)"
1333
- stroke-width="4"
1334
- stroke-linejoin="round"
1335
- paint-order="stroke"
1336
- class="fill-muted-foreground font-mono text-[8px]"
1337
  >
1338
- {paretoLabelText(row)}
1339
- </text>
1340
- {/if}
1341
- {/each}
1342
- <text
1343
- x="399"
1344
- y="370"
1345
- text-anchor="middle"
1346
- class="fill-muted-foreground font-mono text-[8px]">{m.tools_pareto_x_axis()}</text
1347
- >
1348
- <text
1349
- transform="translate(16,174) rotate(-90)"
1350
- text-anchor="middle"
1351
- class="fill-muted-foreground font-mono text-[8px]">{m.tools_pareto_y_axis()}</text
1352
- >
1353
- </svg>
1354
- </div>
1355
- <div class="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
1356
- <span class="font-mono">FPR</span> 0.0
1357
- <span class="h-2 w-28 rounded-full bg-[linear-gradient(90deg,#0dce97,#ffca28,#ff4d4f)]"
1358
- ></span>
1359
- 0.15+ <span>{m.tools_pareto_fpr_legend()}</span>
1360
- <span class="text-muted-foreground/70">·</span>
1361
- <span>{m.tools_pareto_click_pin_hint()}</span>
1362
- </div>
1363
- </section>
1364
-
1365
- <section class="min-w-0 rounded-xl border bg-card p-4 shadow-sm lg:col-span-2">
1366
- <div class="mb-3 flex flex-wrap items-baseline gap-2">
1367
- <h2 class="font-mono text-sm font-semibold">
1368
- {m.tools_latency_title()}
1369
- </h2>
1370
- <span class="text-xs text-muted-foreground">{m.tools_latency_hint()}</span>
1371
- </div>
1372
- <Table
1373
- data={perfRows}
1374
- columns={latencyColumns}
1375
- getRowKey={(row) => row.modelKey}
1376
- containerClass="max-w-full overflow-x-auto rounded-lg border"
1377
- tableClass="min-w-[50rem]"
1378
- headerClass="bg-muted/70 hover:bg-muted/70 [&_th]:text-muted-foreground"
1379
- rowClass="hover:bg-muted/30"
1380
- >
1381
- {#snippet header(column)}
1382
- <Button
1383
- variant="ghost"
1384
- size="sm"
1385
- class={cn(
1386
- 'h-auto gap-1 px-0 py-0 text-xs font-semibold text-muted-foreground hover:bg-transparent',
1387
- column.key !== 'model' && 'ml-auto'
1388
- )}
1389
- onclick={() => toggleLatencySort(column.key)}
1390
  >
1391
- {latencyColumnLabel(column)}
1392
- <ArrowUpDownIcon class="size-3" />
1393
- </Button>
1394
- {/snippet}
1395
-
1396
- {#snippet cell(row, column)}
1397
- {#if column.key === 'model'}
1398
- <span class="inline-flex min-w-0 items-center gap-2 font-mono text-xs font-semibold">
1399
- <span class="size-2 rounded-full" style={`background:${colorOf(row.model)}`}></span>
1400
- <span class="min-w-0 max-w-80 truncate">{row.model.short}</span>
1401
- <code class="text-muted-foreground">{row.model.params}</code>
1402
- </span>
1403
- {:else if column.key === 'errorRate'}
1404
- <span class="font-mono">{fmtPercent(row.errorRate)}</span>
1405
- {:else}
1406
- <span class="font-mono">{fmtMs(row[column.key])}</span>
1407
- {/if}
1408
- {/snippet}
1409
- </Table>
1410
- <p class="mt-3 text-xs text-muted-foreground">{m.tools_latency_note()}</p>
1411
- </section>
1412
-
1413
- <section class="min-w-0 rounded-xl border bg-card p-4 shadow-sm lg:col-span-2">
1414
- <div class="mb-3 flex flex-wrap items-baseline gap-2">
1415
- <h2 class="font-mono text-sm font-semibold">
1416
- {m.tools_robustness_title()}
1417
- </h2>
1418
- <span class="text-xs text-muted-foreground">{m.tools_robustness_hint()}</span>
1419
- </div>
1420
- <p class="mb-3 text-xs leading-5 text-muted-foreground">{m.tools_robustness_note()}</p>
1421
- <div class="max-w-full overflow-x-auto rounded-lg border">
1422
- <table class="w-full min-w-[54rem] text-sm">
1423
- <thead class="bg-muted/70 text-xs text-muted-foreground">
1424
- <tr>
1425
- <th class="px-3 py-3 text-left font-semibold">{m.ranking_column_model()}</th>
1426
- <th class="px-3 py-3 text-right font-semibold">{m.tools_real_score()}</th>
1427
- <th class="px-3 py-3 text-right font-semibold">{m.tools_robust_score()}</th>
1428
- <th class="px-3 py-3 text-right font-semibold">Δ score</th>
1429
- <th class="px-3 py-3 text-right font-semibold">FNR real</th>
1430
- <th class="px-3 py-3 text-right font-semibold">FNR robust</th>
1431
- <th class="px-3 py-3 text-right font-semibold">Δ FNR</th>
1432
- </tr>
1433
- </thead>
1434
- <tbody>
1435
- {#each robustRows as row (row.modelKey)}
1436
- {@const sDelta = row.deltaScore}
1437
- {@const fDelta = row.deltaFnr}
1438
- <tr class="border-t transition-colors hover:bg-muted/30">
1439
- <td class="px-3 py-2">
1440
- <div class="font-mono text-xs font-semibold">
1441
- <span
1442
- class="mr-2 inline-block size-2 rounded-full"
1443
- style={`background:${colorOf(row.model)}`}
1444
- ></span>
1445
- {row.model.short}
1446
- <code class="text-muted-foreground">{row.model.params}</code>
1447
- </div>
1448
- {#if row.category}
1449
- <div class="mt-1 text-xs text-muted-foreground">{row.category}</div>
1450
  {/if}
1451
- <div class="mt-2 flex max-w-40 flex-col gap-1">
1452
- <span class="h-1.5 rounded-full bg-muted">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1453
  <span
1454
- class="block h-full rounded-full bg-[#0dce97]"
1455
- style={`width:${scoreWidth(row.scoreReal)}`}
1456
- ></span>
1457
- </span>
1458
- <span class="h-1.5 rounded-full bg-muted">
 
 
 
 
 
 
1459
  <span
1460
- class="block h-full rounded-full bg-[#6b4bf5]"
1461
- style={`width:${scoreWidth(row.scoreRobust)}`}
1462
- ></span>
1463
- </span>
1464
- </div>
1465
- </td>
1466
- <td class="px-3 py-2 text-right font-mono">{fmt(row.scoreReal, 3)}</td>
1467
- <td class="px-3 py-2 text-right font-mono">{fmt(row.scoreRobust, 3)}</td>
1468
- <td class="px-3 py-2 text-right">
1469
- <span
1470
- class={cn(
1471
- 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 font-mono text-xs font-semibold whitespace-nowrap',
1472
- deltaBadgeClass(sDelta, 'score')
1473
- )}
1474
- >
1475
- {fmtDelta(sDelta)}
1476
- </span>
1477
- </td>
1478
- <td class="px-3 py-2 text-right font-mono">{fmt(row.fnrReal, 3)}</td>
1479
- <td class="px-3 py-2 text-right font-mono">{fmt(row.fnrRobust, 3)}</td>
1480
- <td class="px-3 py-2 text-right">
1481
- <span
1482
- class={cn(
1483
- 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 font-mono text-xs font-semibold whitespace-nowrap',
1484
- deltaBadgeClass(fDelta, 'fnr')
1485
- )}
1486
- >
1487
- {fmtDelta(fDelta)}
1488
- </span>
1489
- </td>
1490
- </tr>
1491
- {/each}
1492
- </tbody>
1493
- </table>
1494
- </div>
1495
- </section>
1496
- </div>
1497
-
1498
- <p class="mt-8 text-center text-xs text-muted-foreground">
1499
- {m.tools_footer()}
1500
- </p>
1501
-
1502
- {#if chartTooltip}
1503
- <div
1504
- bind:this={chartTooltipElement}
1505
- class="pointer-events-none fixed z-50 min-w-48 max-w-[min(24rem,calc(100vw-1.5rem))] rounded-lg border bg-popover px-4 py-3 text-sm text-popover-foreground shadow-xl"
1506
- style={`left:${chartTooltip.x}px; top:${chartTooltip.y}px`}
1507
- >
1508
- <div class="break-words font-semibold">{chartTooltip.title}</div>
1509
- <div class="mt-2 space-y-1 font-mono">
1510
- {#each chartTooltip.rows as row (`${row.label}-${row.value}`)}
1511
- <div class="grid grid-cols-[auto_1fr] gap-5">
1512
- <span class="break-words text-muted-foreground">{row.label}</span>
1513
- <span class="text-right">{row.value}</span>
1514
  </div>
1515
- {/each}
1516
  </div>
 
 
 
 
1517
  </div>
1518
- {/if}
1519
- </div>
1520
  {/if}
 
1
  <script lang="ts">
2
+ import {
3
+ ArrowDownIcon,
4
+ ArrowUpDownIcon,
5
+ ArrowUpIcon,
6
+ DownloadIcon,
7
+ PinIcon,
8
+ XIcon
9
+ } from '@lucide/svelte';
10
  import BucketDataFallback from '$lib/components/BucketDataFallback.svelte';
11
  import BucketDataWarning from '$lib/components/BucketDataWarning.svelte';
12
+ import RadarLabelsOverlay from '$lib/components/charts/RadarLabelsOverlay.svelte';
13
  import PageHeadingSection from '$lib/components/PageHeadingSection.svelte';
14
  import Select from '$lib/components/Select.svelte';
15
  import Table, { type TableColumn } from '$lib/components/Table.svelte';
16
+ import ChartCursorTooltip from '$lib/components/tooltips/ChartCursorTooltip.svelte';
17
+ import OverflowCursorTooltip from '$lib/components/tooltips/OverflowCursorTooltip.svelte';
18
  import { Badge } from '$lib/components/ui/badge';
19
  import { Button } from '$lib/components/ui/button';
20
  import { Checkbox } from '$lib/components/ui/checkbox';
21
  import {
22
  toolsActiveGroups,
 
23
  toolsBarY as barY,
24
  toolsCsvDataUri,
25
  toolsDataGroups,
 
39
  import { isNumber } from '$lib/helpers/leaderboard';
40
  import type { GuardModel } from '$lib/types/leaderboard';
41
  import type {
 
42
  ToolsLanguageFilter as LangFilter,
43
  ToolsLatencySortKey as LatencySortKey,
44
  ToolsRadarScale as RadarScale,
45
  ToolsSortDirection as SortDirection,
 
46
  ToolsVizMetric as VizMetric
47
  } from '$lib/types/tools';
48
  import { m } from '$lib/paraglide/messages';
 
65
  type ParetoRow = VisualizationModelRow & {
66
  p95: number;
67
  integral: number;
 
68
  };
69
  type PerformanceRow = VisualizationModelRow & {
70
  rank: number;
 
81
  fnrReal: number | null;
82
  fnrRobust: number | null;
83
  deltaFnr: number | null;
84
+ fprReal: number | null;
85
+ fprRobust: number | null;
86
+ deltaFpr: number | null;
87
  category: string;
88
  };
89
  type HeatmapRow = ToolsDataset['visualizations']['heatmap']['rows'][number];
 
92
  max: number | null;
93
  };
94
  const selectContentClass = 'max-h-72';
95
+ const radarViewBoxWidth = 520;
96
+ const radarViewBoxHeight = 420;
97
+ const barModelBandWidth = 145;
98
+ const barSeriesWidth = 26;
99
+ const barSeriesGap = 3;
100
+ const barGroupWidth = barSeriesWidth * 3 + barSeriesGap * 2;
101
+ const scatterAxisMax = 1;
102
+ const scatterTicks = [0, 0.25, 0.5, 0.75, 1];
103
  let { data }: PageProps = $props();
104
  const toolsState = $derived(data.toolsState);
105
  const tools = $derived(toolsState.data);
 
110
  const initialTools = (() => data.toolsState.data)();
111
  const initialGroups = toolsDataGroups(initialTools.models, initialTools.allGroups);
112
 
113
+ let selectedModelKeys = $state(initialTools.models.slice(0, 5).map((model) => modelKey(model)));
114
  let selectedGroups = $state<string[]>(initialGroups);
115
  let language = $state<LangFilter>('all');
116
  let metric = $state<VizMetric>('fnr');
 
118
  let pinnedModelKeys = $state<string[]>([]);
119
  let latencySortKey = $state<LatencySortKey>('p95');
120
  let latencySortDirection = $state<SortDirection>('asc');
 
 
121
  let hoveredRadarModelKey = $state<string | null>(null);
122
  let hoveredScatterModelKey = $state<string | null>(null);
123
  let hoveredBarKey = $state<string | null>(null);
124
  let hoveredParetoModelKey = $state<string | null>(null);
125
+ let barContainerWidth = $state(0);
126
 
127
  const languageOptions = [
128
  { value: 'all', label: m.tools_language_all() },
 
138
  { value: 'f1', label: 'F1' }
139
  ];
140
  const latencyColumns: LatencyColumn[] = [
141
+ {
142
+ key: 'model',
143
+ label: m.ranking_column_model,
144
+ headClass: () => latencySortColumnClass('model'),
145
+ cellClass: () => latencySortCellClass('model')
146
+ },
147
+ {
148
+ key: 'p50',
149
+ label: () => `p50 (${m.ranking_unit_ms()})`,
150
+ headClass: () => cn('text-right', latencySortColumnClass('p50')),
151
+ cellClass: () => cn('text-right', latencySortCellClass('p50'))
152
+ },
153
+ {
154
+ key: 'p95',
155
+ label: () => `p95 (${m.ranking_unit_ms()})`,
156
+ headClass: () => cn('text-right', latencySortColumnClass('p95')),
157
+ cellClass: () => cn('text-right', latencySortCellClass('p95'))
158
+ },
159
+ {
160
+ key: 'p99',
161
+ label: () => `p99 (${m.ranking_unit_ms()})`,
162
+ headClass: () => cn('text-right', latencySortColumnClass('p99')),
163
+ cellClass: () => cn('text-right', latencySortCellClass('p99'))
164
+ },
165
+ {
166
+ key: 'errorRate',
167
+ label: m.details_error_rate,
168
+ headClass: () => cn('text-right', latencySortColumnClass('errorRate')),
169
+ cellClass: () => cn('text-right', latencySortCellClass('errorRate'))
170
+ }
171
  ];
172
 
173
  const currentLanguageLabel = $derived(
 
198
  const heatColumns = $derived(
199
  `minmax(8rem, 10rem) repeat(${heatmapModels.length}, minmax(4rem, 1fr))`
200
  );
201
+ const heatmapHasData = $derived(heatmapModels.length > 0 && heatmapRows.length > 0);
202
  const scatterRows = $derived(
203
  scatterRowsFor(tools.visualizations.scatter.points, modelByKey, selectedModelKeySet)
204
  );
 
222
  const robustRows = $derived(
223
  robustnessRowsFor(tools.visualizations.robustness.rows, modelByKey, selectedModelKeySet)
224
  );
225
+ const scatterXMax = scatterAxisMax;
226
+ const scatterYMax = scatterAxisMax;
227
+ const barSvgWidth = $derived(
228
+ Math.max(720, barContainerWidth, selectedModels.length * barModelBandWidth + 56)
229
+ );
230
  const paretoXMax = $derived(Math.max(1, ...paretoRows.map((row) => row.p95)) * 1.08);
231
  const paretoY0 = $derived(paretoYStart(paretoRows));
232
  const paretoXTicks = $derived(niceTicks(0, paretoXMax, 6));
 
257
  return `${Math.round(clamp01(value ?? 0) * 100)}%`;
258
  }
259
 
260
+ function modelLabel(model: GuardModel) {
261
+ return `${model.short} ${model.params}`;
262
+ }
263
+
264
  function scatterRowsFor(
265
  points: ToolsDataset['visualizations']['scatter']['points'],
266
  modelsByKey: ReadonlyMap<string, GuardModel>,
 
297
  ) {
298
  return [];
299
  }
300
+ return [{ modelKey: key, model, p95: row.latencyMs.p95, integral: row.integral }];
 
 
301
  });
302
  }
303
 
 
357
  fnrReal: row.fnrReal,
358
  fnrRobust: row.fnrRobust,
359
  deltaFnr: row.deltaFnr,
360
+ fprReal: row.fprReal,
361
+ fprRobust: row.fprRobust,
362
+ deltaFpr: row.deltaFpr,
363
  category: localized(row.categoryLabel, '')
364
  }
365
  ];
 
388
  }
389
 
390
  function groupedBarsMean(
391
+ modelKeyValue: string,
392
  key: VizMetric,
393
  modelsByVisualizationKey: ReadonlyMap<
394
  string,
395
  ToolsDataset['visualizations']['groupedBars']['models'][number]
396
  >,
397
+ groups: readonly string[]
398
  ) {
399
+ const model = modelsByVisualizationKey.get(modelKeyValue);
400
+ const values = groups.map((group) => model?.groups[group]?.[key]).filter(isNumber);
 
401
 
402
  if (!values.length) return null;
403
  return values.reduce((sum, value) => sum + value, 0) / values.length;
 
422
  }
423
 
424
  function paretoLabelText(row: ParetoRow) {
425
+ const label = modelLabel(row.model);
426
+ return label.length > 18 ? `${label.slice(0, 17)}…` : label;
427
  }
428
 
429
  function paretoYStart(rows: readonly ParetoRow[]) {
 
478
  action();
479
  }
480
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
  function toggleLatencySort(key: LatencySortKey) {
482
  if (latencySortKey === key) {
483
  latencySortDirection = latencySortDirection === 'asc' ? 'desc' : 'asc';
 
492
  return typeof column.label === 'function' ? column.label() : column.label;
493
  }
494
 
495
+ function latencySortColumnClass(key: LatencySortKey) {
496
+ return latencySortKey === key ? 'bg-muted/80 text-foreground' : undefined;
497
+ }
498
+
499
+ function latencySortCellClass(key: LatencySortKey) {
500
+ return latencySortKey === key ? 'bg-muted/20 text-foreground' : undefined;
501
+ }
502
+
503
  function heatTextColor(value: number | null | undefined) {
504
  if (!isNumber(value)) return 'text-muted-foreground';
505
  return 'text-black';
506
  }
507
 
508
+ function deltaBadgeClass(value: number | null, kind: 'score' | 'fnr' | 'fpr') {
509
  if (!isNumber(value) || value === 0) {
510
  return 'bg-muted/40 text-muted-foreground';
511
  }
 
531
  <BucketDataFallback />
532
  </div>
533
  {:else}
534
+ <ChartCursorTooltip class="min-w-48">
535
+ {#snippet children(tooltip)}
536
+ <div class="mx-auto flex w-full min-w-0 max-w-7xl flex-col px-4 py-8 sm:px-6 lg:px-8">
537
+ {#if toolsState.status === 'stale'}
538
+ <BucketDataWarning />
539
+ {/if}
540
+ <PageHeadingSection
541
+ title={m.navigation_visualizations()}
542
+ description={m.tools_description()}
543
+ />
544
+
545
+ <section
546
+ class="mt-6 min-w-0 rounded-xl border bg-card p-4 shadow-sm"
547
+ aria-label={m.tools_controls_aria()}
548
+ >
549
+ <div class="grid min-w-0 gap-4 lg:grid-cols-[1.1fr_1.1fr_.7fr]">
550
+ <div class="min-w-0">
551
+ <div class="mb-2 flex items-center justify-between gap-3">
552
+ <h2 class="text-xs font-semibold tracking-wider text-muted-foreground uppercase">
553
+ {m.tools_models_title()}
554
+ </h2>
555
+ <div class="flex gap-2 text-xs">
556
+ <Button
557
+ variant="outline"
558
+ size="sm"
559
+ class="h-8 rounded-full px-4"
560
+ onclick={() => (selectedModelKeys = models.map((model) => modelKey(model)))}
561
+ >{m.details_select_all()}</Button
562
+ >
563
+ <Button
564
+ variant="outline"
565
+ size="sm"
566
+ class="h-8 rounded-full px-4"
567
+ onclick={() => (selectedModelKeys = [])}>{m.details_reset()}</Button
568
+ >
569
+ </div>
570
+ </div>
571
+ <div class="max-h-36 space-y-1 overflow-auto rounded-lg border bg-background/50 p-2">
572
+ {#each models as model (modelKey(model))}
573
+ {@const key = modelKey(model)}
574
+ <div
575
+ class="flex min-w-0 items-center gap-3 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50"
576
+ role="checkbox"
577
+ tabindex="0"
578
+ aria-checked={selectedModelKeys.includes(key)}
579
+ onclick={() => (selectedModelKeys = toggleListValue(selectedModelKeys, key))}
580
+ onkeydown={(event) =>
581
+ handleCheckboxKeydown(
582
+ event,
583
+ () => (selectedModelKeys = toggleListValue(selectedModelKeys, key))
584
+ )}
585
+ >
586
+ <Checkbox
587
+ checked={selectedModelKeys.includes(key)}
588
+ aria-label={modelLabel(model)}
589
+ tabindex={-1}
590
+ />
591
+ <span class="size-3 rounded-sm" style={`background:${colorOf(model)}`}></span>
592
+ <span class="min-w-0 flex-1 truncate">
593
+ {model.short} <code class="text-muted-foreground">{model.params}</code>
594
+ </span>
595
+ <Button
596
+ variant="outline"
597
+ size="sm"
598
+ class={cn(
599
+ 'h-7 rounded-full px-3 text-xs',
600
+ pinnedModelKeys.includes(key) &&
601
+ 'border-[#0dce97]/60 bg-[#0dce97]/10 text-[#0dce97] hover:bg-[#0dce97]/15 hover:text-[#0dce97]'
602
+ )}
603
+ aria-pressed={pinnedModelKeys.includes(key)}
604
+ onclick={(event) => {
605
+ event.preventDefault();
606
+ event.stopPropagation();
607
+ togglePin(key);
608
+ }}
609
+ >
610
+ <PinIcon class="size-3" />
611
+ {pinnedModelKeys.includes(key) ? m.tools_pinned() : m.tools_pin()}
612
+ </Button>
613
+ </div>
614
+ {/each}
615
+ </div>
616
+ </div>
617
 
618
+ <div class="min-w-0">
619
+ <div class="mb-2 flex items-center justify-between gap-3">
620
+ <h2 class="text-xs font-semibold tracking-wider text-muted-foreground uppercase">
621
+ {m.tools_groups_title()}
622
+ </h2>
623
+ <div class="flex gap-2 text-xs">
624
+ <Button
625
+ variant="outline"
626
+ size="sm"
627
+ class="h-8 rounded-full px-4"
628
+ onclick={() => (selectedGroups = [...dataGroups])}
629
+ >{m.details_select_all()}</Button
630
+ >
631
+ <Button
632
+ variant="outline"
633
+ size="sm"
634
+ class="h-8 rounded-full px-4"
635
+ onclick={() => (selectedGroups = [])}>{m.details_reset()}</Button
636
+ >
637
+ </div>
638
+ </div>
639
+ <div class="max-h-36 space-y-1 overflow-auto rounded-lg border bg-background/50 p-2">
640
+ {#each dataGroups as group (group)}
641
+ <div
642
+ class="flex min-w-0 items-center gap-3 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50"
643
+ role="checkbox"
644
+ tabindex="0"
645
+ aria-checked={selectedGroups.includes(group)}
646
+ onclick={() => (selectedGroups = toggleListValue(selectedGroups, group))}
647
+ onkeydown={(event) =>
648
+ handleCheckboxKeydown(
649
+ event,
650
+ () => (selectedGroups = toggleListValue(selectedGroups, group))
651
+ )}
652
+ >
653
+ <Checkbox
654
+ checked={selectedGroups.includes(group)}
655
+ aria-label={groupLabel(group)}
656
+ tabindex={-1}
657
+ />
658
+ <span class="min-w-0 truncate">
659
+ {groupLabel(group)}
660
+ <span class="text-muted-foreground"
661
+ >{benchmarkMeta[group]?.languages.join(' · ')}</span
662
+ >
663
+ </span>
664
+ </div>
665
+ {/each}
666
+ </div>
667
+ </div>
668
+
669
+ <div class="grid content-start gap-3">
670
+ <Select
671
+ id="tools-language"
672
+ type="single"
673
+ bind:value={language}
674
+ options={languageOptions}
675
+ label={m.tools_language_label()}
676
+ triggerLabel={currentLanguageLabel}
677
+ contentClass={selectContentClass}
678
+ />
679
+ <Select
680
+ id="tools-metric"
681
+ type="single"
682
+ bind:value={metric}
683
+ options={metricOptions}
684
+ label={m.tools_metric_label()}
685
+ triggerLabel={currentMetricLabel}
686
+ contentClass={selectContentClass}
687
+ />
688
  </div>
689
  </div>
690
+
691
+ <div class="mt-4 flex flex-wrap items-end gap-3">
692
+ <div class="min-w-0 space-y-2">
693
+ <span
694
+ class="block text-xs font-semibold tracking-wider text-muted-foreground uppercase"
695
+ >{m.tools_radar_scale()}</span
696
+ >
697
  <div
698
+ class="inline-flex max-w-full flex-wrap gap-1 rounded-4xl border bg-input/50 p-1"
699
+ role="group"
700
+ aria-label={m.tools_radar_scale()}
 
 
 
 
 
 
 
701
  >
 
 
 
 
 
 
 
 
 
702
  <Button
703
+ variant={radarScale === 'score' ? 'default' : 'ghost'}
704
  size="sm"
705
+ aria-pressed={radarScale === 'score'}
706
+ onclick={() => (radarScale = 'score')}
 
 
 
 
 
 
 
 
 
707
  >
708
+ {m.tools_radar_group_score()}
709
+ </Button>
710
+ <Button
711
+ variant={radarScale === 'f1' ? 'default' : 'ghost'}
712
+ size="sm"
713
+ aria-pressed={radarScale === 'f1'}
714
+ onclick={() => (radarScale = 'f1')}
715
+ >
716
+ F1
717
  </Button>
718
  </div>
719
+ </div>
720
+ <div class="ml-auto flex flex-wrap items-center justify-end gap-3">
721
+ <span
722
+ class="rounded-full border border-[#0dce97]/40 bg-[#0dce97]/10 px-4 py-2 font-mono text-xs font-semibold text-[#0dce97]"
 
 
 
 
 
 
 
 
 
 
 
723
  >
724
+ {m.tools_selected_summary({
725
+ models: selectedModels.length,
726
+ groups: activeGroups.length
727
+ })}
728
+ </span>
729
  <Button
730
+ href={csvHref}
731
+ download="hivetrace_tools_selection.csv"
732
  size="sm"
733
+ class="bg-[#0dce97] text-emerald-950 hover:bg-[#0dce97]/90"
 
734
  >
735
+ <DownloadIcon data-icon="inline-start" />
736
+ {m.ranking_csv()}
737
+ </Button>
738
  </div>
739
  </div>
740
+ </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741
 
742
+ <div
743
+ class="mt-4 flex min-h-[46px] flex-wrap items-center gap-x-2 gap-y-3 rounded-xl border bg-card px-4 py-3 text-xs leading-none"
744
+ >
745
+ <span class="font-semibold tracking-wider text-muted-foreground uppercase leading-none"
746
+ >{m.tools_pinned_for_comparison()}</span
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
747
  >
748
+ {#if pinnedModels.length}
749
+ {#each pinnedModels as model (modelKey(model))}
750
+ <Badge variant="secondary" class="gap-1 rounded-full">
751
+ <span class="size-2 rounded-full" style={`background:${colorOf(model)}`}></span>
752
+ {modelLabel(model)}
753
+ <button
754
+ type="button"
755
+ aria-label={m.tools_unpin_model({ model: modelLabel(model) })}
756
+ onclick={() => togglePin(modelKey(model))}
757
+ >
758
+ <XIcon class="size-3" />
759
+ </button>
760
+ </Badge>
761
+ {/each}
762
+ {:else}
763
+ <span class="leading-none text-muted-foreground">
764
+ {m.tools_no_pinned()}
765
+ </span>
766
+ {/if}
767
  </div>
 
 
768
 
769
+ <div class="mt-4 grid min-w-0 gap-4 lg:grid-cols-2">
770
+ <section class="min-w-0 rounded-xl border bg-card p-4 shadow-sm lg:col-span-2">
771
+ <div class="mb-3 flex flex-wrap items-baseline gap-2">
772
+ <h2 class="font-mono text-sm font-semibold">{m.tools_radar_title()}</h2>
773
+ <span class="text-xs text-muted-foreground">{m.tools_radar_hint()}</span>
774
+ </div>
775
+ {#if selectedModels.length && activeGroups.length >= 3}
776
+ <div class="grid min-w-0 gap-4 lg:grid-cols-[minmax(0,1fr)_24rem] lg:items-start">
777
+ <div class="relative min-w-0 overflow-hidden">
778
+ <svg
779
+ viewBox="0 0 520 420"
780
+ class="h-auto w-full"
781
+ role="img"
782
+ aria-label={m.tools_radar_aria()}
783
+ >
784
+ {#each [0.25, 0.5, 0.75, 1] as scale (scale)}
785
+ <polygon
786
+ points={activeGroups
787
+ .map((_, index) => {
788
+ const point = radarPoint(index, activeGroups.length, 150 * scale);
789
+ return `${point.x.toFixed(1)},${point.y.toFixed(1)}`;
790
+ })
791
+ .join(' ')}
792
+ fill="none"
793
+ stroke="currentColor"
794
+ stroke-width="1"
795
+ stroke-dasharray="3 4"
796
+ pointer-events="none"
797
+ class="text-border"
798
+ />
799
+ <text
800
+ x="264"
801
+ y={214 - 150 * scale}
802
+ class="fill-muted-foreground font-mono text-[10px]"
803
+ >
804
+ {scale.toFixed(2)}
805
+ </text>
806
+ {/each}
807
+ {#each activeGroups as group, index (group)}
808
+ {@const axis = radarPoint(index, activeGroups.length, 150)}
809
+ <line
810
+ x1="260"
811
+ y1="214"
812
+ x2={axis.x}
813
+ y2={axis.y}
814
+ stroke="currentColor"
815
+ stroke-width="1"
816
+ class="text-border"
817
+ />
818
+ {/each}
819
+ {#each selectedModels as model (modelKey(model))}
820
+ {@const key = modelKey(model)}
821
+ {@const isPinned = pinnedModelKeys.includes(key)}
822
+ {@const isHovered = hoveredRadarModelKey === key}
823
+ {@const isDimmed = hoveredRadarModelKey !== null && !isHovered}
824
+ <polygon
825
+ points={radarVisualizationPolygon(key, activeGroups, radarScale)}
826
+ fill={colorOf(model)}
827
+ fill-opacity={isHovered
828
+ ? 0.24
829
+ : isDimmed
830
+ ? 0.025
831
+ : isPinned
832
+ ? 0.22
833
+ : pinnedModelKeys.length
834
+ ? 0.06
835
+ : 0.12}
836
+ stroke={colorOf(model)}
837
+ stroke-width={isHovered ? 3 : isPinned ? 2.4 : 1.6}
838
+ stroke-opacity={isDimmed ? 0.18 : 1}
839
+ pointer-events="none"
840
+ />
841
+ {#each activeGroups as group, index (group)}
842
+ {@const value = radarVisualizationValue(key, group, radarScale) ?? 0}
843
+ {@const point = radarPoint(
844
+ index,
845
+ activeGroups.length,
846
+ 150 * clamp01(value)
847
+ )}
848
+ <circle
849
+ cx={point.x}
850
+ cy={point.y}
851
+ r={isHovered ? 5 : isPinned ? 4 : 3}
852
+ fill={colorOf(model)}
853
+ fill-opacity={isDimmed ? 0.28 : 1}
854
+ stroke-opacity={isDimmed ? 0.25 : 1}
855
+ class="transition-[r,fill-opacity,stroke-opacity]"
856
+ role="img"
857
+ aria-label={`${modelLabel(model)} ${groupLabel(group)} ${radarScale.toUpperCase()} ${fmt(value, 3)}`}
858
+ onpointerenter={(event) => {
859
+ hoveredRadarModelKey = key;
860
+ tooltip.show(event, modelLabel(model), [
861
+ { label: groupLabel(group), value: radarScale.toUpperCase() },
862
+ { label: 'value', value: fmt(value, 3) }
863
+ ]);
864
+ }}
865
+ onpointermove={tooltip.move}
866
+ onpointerleave={() => {
867
+ hoveredRadarModelKey = null;
868
+ tooltip.hide();
869
+ }}
870
+ />
871
+ {/each}
872
+ {/each}
873
+ </svg>
874
+ <RadarLabelsOverlay
875
+ labels={activeGroups.map((group, index) => {
876
+ const label = radarPoint(index, activeGroups.length, 178);
877
+ return {
878
+ key: group,
879
+ text: groupLabel(group),
880
+ x: label.x,
881
+ y: label.y,
882
+ anchor:
883
+ Math.abs(label.x - 260) < 8 ? 'middle' : label.x > 260 ? 'start' : 'end'
884
+ };
885
+ })}
886
+ viewBoxWidth={radarViewBoxWidth}
887
+ viewBoxHeight={radarViewBoxHeight}
888
+ />
889
+ </div>
890
+ <div
891
+ class="flex min-w-0 flex-wrap content-start gap-x-4 gap-y-2 text-xs text-muted-foreground lg:flex-col lg:gap-x-0"
892
+ >
893
+ {#each selectedModels as model (modelKey(model))}
894
+ {@const key = modelKey(model)}
895
+ {@const isPinned = pinnedModelKeys.includes(key)}
896
+ <span
897
+ class={cn(
898
+ 'inline-flex min-w-0 max-w-full items-center gap-2 rounded-full border border-transparent px-2 py-1 transition-colors lg:w-full',
899
+ isPinned &&
900
+ 'border-[#0dce97]/35 bg-[#0dce97]/10 text-foreground shadow-sm shadow-[#0dce97]/5'
901
+ )}
902
+ >
903
+ <span class="size-2 rounded-full" style={`background:${colorOf(model)}`}
904
+ ></span>
905
+ {#if isPinned}
906
+ <PinIcon class="size-3 text-[#0dce97]" />
907
+ {/if}
908
+ <span class="min-w-0 truncate">{modelLabel(model)}</span>
909
+ <button
910
+ class="ml-auto font-semibold text-[#0dce97]"
911
+ type="button"
912
+ onclick={() => togglePin(key)}
913
+ >
914
+ {isPinned ? m.tools_unpin() : m.tools_pin()}
915
+ </button>
916
+ </span>
917
+ {/each}
918
+ </div>
919
+ </div>
920
+ {:else}
921
+ <p
922
+ class="rounded-lg border bg-muted/30 p-6 text-center text-sm text-muted-foreground"
923
+ >
924
+ {m.tools_radar_empty()}
925
+ </p>
926
+ {/if}
927
+ </section>
928
 
929
+ <section class="min-w-0 rounded-xl border bg-card p-4 shadow-sm lg:col-span-2">
930
+ <div class="mb-3 flex flex-wrap items-baseline gap-2">
931
+ <h2 class="font-mono text-sm font-semibold">FPR × FNR</h2>
932
+ <span class="text-xs text-muted-foreground">{m.tools_scatter_hint()}</span>
933
+ </div>
 
 
 
934
  <svg
935
  viewBox="0 0 520 420"
936
+ class="mx-auto h-auto w-full max-w-3xl"
937
  role="img"
938
+ aria-label={m.tools_scatter_aria()}
939
  >
940
+ {#each scatterTicks.slice(1) as tick (tick)}
941
+ <line
942
+ x1={scatterX(tick, scatterXMax)}
943
+ y1="18"
944
+ x2={scatterX(tick, scatterXMax)}
945
+ y2="368"
 
 
 
946
  stroke="currentColor"
947
  stroke-width="1"
948
  stroke-dasharray="3 4"
949
  class="text-border"
950
  />
 
 
 
 
 
 
 
 
 
 
 
951
  <line
952
+ x1="52"
953
+ y1={scatterY(tick, scatterYMax)}
954
+ x2="502"
955
+ y2={scatterY(tick, scatterYMax)}
956
  stroke="currentColor"
957
  stroke-width="1"
958
+ stroke-dasharray="3 4"
959
  class="text-border"
960
  />
961
+ {/each}
962
+ {#each scatterTicks as tick (tick)}
963
+ <text
964
+ x={scatterX(tick, scatterXMax)}
965
+ y="386"
966
+ text-anchor="middle"
967
+ class="fill-muted-foreground font-mono text-[10px]">{tick.toFixed(2)}</text
968
+ >
969
  <text
970
+ x="44"
971
+ y={scatterY(tick, scatterYMax) + 3}
972
+ text-anchor="end"
973
+ class="fill-muted-foreground font-mono text-[10px]">{tick.toFixed(2)}</text
 
 
 
 
974
  >
 
 
 
 
975
  {/each}
976
+ <line x1="52" y1="18" x2="52" y2="368" stroke="currentColor" class="text-border" />
977
+ <line x1="52" y1="368" x2="502" y2="368" stroke="currentColor" class="text-border" />
978
+ <text
979
+ x="277"
980
+ y="408"
981
+ text-anchor="middle"
982
+ class="fill-muted-foreground font-mono text-[10px]">FPR →</text
983
+ >
984
+ <text
985
+ transform="translate(16,193) rotate(-90)"
986
+ text-anchor="middle"
987
+ class="fill-muted-foreground font-mono text-[10px]">FNR →</text
988
+ >
989
+ <text
990
+ x={scatterX(0, scatterXMax)}
991
+ y={scatterY(0, scatterYMax)}
992
+ text-anchor="middle"
993
+ dominant-baseline="central"
994
+ class="fill-[#0dce97] text-sm">★</text
995
+ >
996
+ {#each scatterRows as row (row.modelKey)}
997
+ {@const point = {
998
+ x: scatterX(row.fpr, scatterXMax),
999
+ y: scatterY(row.fnr, scatterYMax)
1000
+ }}
1001
+ {@const isPinned = pinnedModelKeys.includes(row.modelKey)}
1002
+ {@const isHovered = hoveredScatterModelKey === row.modelKey}
1003
+ <circle
1004
+ cx={point.x}
1005
+ cy={point.y}
1006
+ r={isHovered ? 9 : isPinned ? 8 : 6}
1007
+ fill={colorOf(row.model)}
1008
+ fill-opacity="0.85"
1009
+ stroke="currentColor"
1010
+ stroke-width={isPinned ? 2 : 1}
1011
+ class="text-background transition-[r]"
1012
+ role="img"
1013
+ aria-label={`${modelLabel(row.model)} FPR ${fmt(row.fpr, 3)} FNR ${fmt(row.fnr, 3)}`}
1014
+ onpointerenter={(event) => {
1015
+ hoveredScatterModelKey = row.modelKey;
1016
+ tooltip.show(event, modelLabel(row.model), [
1017
+ { label: 'FPR', value: fmt(row.fpr, 3) },
1018
+ { label: 'FNR', value: fmt(row.fnr, 3) }
1019
+ ]);
1020
+ }}
1021
+ onpointermove={tooltip.move}
1022
+ onpointerleave={() => {
1023
+ hoveredScatterModelKey = null;
1024
+ tooltip.hide();
1025
+ }}
1026
  />
1027
+ {#if isPinned}
 
 
1028
  <circle
1029
  cx={point.x}
1030
  cy={point.y}
1031
+ r="11"
1032
+ fill="none"
1033
+ stroke={colorOf(row.model)}
1034
+ stroke-width="1.5"
 
 
 
 
 
 
 
 
 
 
 
 
 
1035
  />
1036
+ {/if}
1037
  {/each}
1038
  </svg>
1039
+ <div class="mt-3 flex flex-wrap gap-4 text-xs text-muted-foreground">
1040
+ <span class="text-[#0dce97]">{m.tools_scatter_ideal()}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1041
  </div>
1042
+ </section>
1043
+
1044
+ <section class="min-w-0 rounded-xl border bg-card p-4 shadow-sm lg:col-span-2">
1045
+ <div class="mb-3 flex flex-wrap items-center gap-3">
1046
+ <h2 class="font-mono text-sm font-semibold">
1047
+ Heatmap <span class="font-mono uppercase">{heatmapMetric}</span>
1048
+ </h2>
1049
+ {#if heatmapHasData}
1050
+ <span class="ml-auto inline-flex items-center gap-2 text-xs text-muted-foreground">
1051
+ {m.tools_heatmap_scale_min({ value: heatmapScaleLabel(heatmapColorScale.min) })}
1052
+ <span
1053
+ class="h-2 w-28 rounded-full bg-[linear-gradient(90deg,#0dce97,#ffca28,#ff4d4f)]"
1054
+ ></span>
1055
+ {m.tools_heatmap_scale_max({ value: heatmapScaleLabel(heatmapColorScale.max) })}
1056
  </span>
1057
+ {/if}
1058
+ </div>
1059
+ {#if heatmapHasData}
1060
+ <div class="max-w-full overflow-auto">
1061
+ <div class="grid min-w-[54rem] gap-1" style:grid-template-columns={heatColumns}>
1062
+ <div
1063
+ class="grid place-items-center px-2 py-2 font-mono text-[10px] text-muted-foreground"
1064
+ >
1065
+ {m.tools_heatmap_header({ metric: heatmapMetric.toUpperCase() })}
1066
+ </div>
1067
+ {#each heatmapModels as model (modelKey(model))}
1068
+ <div
1069
+ class="relative h-24 min-w-0 overflow-visible px-1 pb-2 font-mono text-[10px] text-muted-foreground"
1070
+ >
1071
+ <OverflowCursorTooltip
1072
+ text={modelLabel(model)}
1073
+ labelClass="absolute bottom-2 left-1/2 z-10 block w-28 origin-bottom-left -translate-x-1 rotate-[-40deg] truncate leading-none"
1074
+ />
1075
+ </div>
1076
+ {/each}
1077
+ {#each heatmapRows as row (row.id)}
1078
+ {@const rowLabel = heatmapRowLabel(row)}
1079
+ <div
1080
+ class="flex min-w-0 items-center justify-end px-2 py-1 text-right text-xs leading-none text-muted-foreground"
1081
+ >
1082
+ <OverflowCursorTooltip text={rowLabel} labelClass="block min-w-0 truncate" />
1083
+ </div>
1084
+ {#each heatmapModels as model (modelKey(model))}
1085
+ {@const key = modelKey(model)}
1086
+ {@const value = heatmapValue(row, key)}
1087
+ {@const hasValue = isNumber(value)}
1088
+ <div
1089
+ role="img"
1090
+ class={cn(
1091
+ 'w-full rounded-md px-2 py-2 text-center font-mono text-sm font-semibold',
1092
+ hasValue &&
1093
+ 'transition duration-150 hover:brightness-110 hover:saturate-125',
1094
+ !isNumber(value) && 'bg-muted/70 text-muted-foreground',
1095
+ isNumber(value) && heatTextColor(value)
1096
+ )}
1097
+ style={isNumber(value)
1098
+ ? `background:${heatColor(
1099
+ value,
1100
+ heatmapColorScale.min ?? value,
1101
+ heatmapColorScale.max ?? value,
1102
+ 'sqrt'
1103
+ )}`
1104
+ : ''}
1105
+ aria-label={`${modelLabel(model)} ${rowLabel} ${heatmapMetric.toUpperCase()} ${fmt(value, 3)}`}
1106
+ onpointerenter={(event) =>
1107
+ tooltip.show(event, modelLabel(model), [
1108
+ { label: groupLabel(row.groupKey), value: rowLabel },
1109
+ {
1110
+ label: heatmapMetric.toUpperCase(),
1111
+ value: fmt(value, 3)
1112
+ }
1113
+ ])}
1114
+ onpointermove={tooltip.move}
1115
+ onpointerleave={tooltip.hide}
1116
+ >
1117
+ {isNumber(value) ? fmt(value, 2) : '-'}
1118
+ </div>
1119
+ {/each}
1120
+ {/each}
1121
+ </div>
1122
  </div>
1123
+ {:else}
 
1124
  <div
1125
+ class="rounded-lg border border-dashed bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
1126
  >
1127
+ {m.tools_heatmap_empty()}
1128
  </div>
1129
+ {/if}
1130
+ </section>
1131
+
1132
+ <section class="min-w-0 rounded-xl border bg-card p-4 shadow-sm lg:col-span-2">
1133
+ <div class="mb-3 flex flex-wrap items-baseline gap-2">
1134
+ <h2 class="font-mono text-sm font-semibold">{m.tools_grouped_bars_title()}</h2>
1135
+ <span class="text-xs text-muted-foreground">{m.tools_grouped_bars_hint()}</span>
1136
+ </div>
1137
+ <div
1138
+ class="max-w-full overflow-x-auto overflow-y-hidden"
1139
+ bind:clientWidth={barContainerWidth}
1140
+ >
1141
+ <div class="relative h-[360px]" style:width={`${barSvgWidth}px`}>
1142
+ <svg
1143
+ viewBox={`0 0 ${barSvgWidth} 360`}
1144
+ class="h-auto max-w-none"
1145
+ style:width={`${barSvgWidth}px`}
1146
  role="img"
1147
+ aria-label={m.tools_grouped_bars_aria()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1148
  >
1149
+ {#each [0, 0.25, 0.5, 0.75, 1] as tick (tick)}
1150
+ <line
1151
+ x1="44"
1152
+ y1={barY(tick)}
1153
+ x2={barSvgWidth - 12}
1154
+ y2={barY(tick)}
1155
+ stroke="currentColor"
1156
+ stroke-width="1"
1157
+ stroke-dasharray="3 4"
1158
+ class="text-border"
1159
+ />
1160
+ <text
1161
+ x="38"
1162
+ y={barY(tick) + 3}
1163
+ text-anchor="end"
1164
+ class="fill-muted-foreground font-mono text-[13px]">{tick.toFixed(2)}</text
1165
+ >
1166
+ {/each}
1167
+ {#each selectedModels as model, index (modelKey(model))}
1168
+ {@const key = modelKey(model)}
1169
+ {@const band = (barSvgWidth - 56) / selectedModels.length}
1170
+ {@const x0 = 44 + index * band + Math.max(0, (band - barGroupWidth) / 2)}
1171
+ {#each [{ key: 'fnr', color: '#ff4d4f' }, { key: 'fpr', color: '#f5a623' }, { key: 'f1', color: '#0dce97' }] as series, seriesIndex (series.key)}
1172
+ {@const barKey = `${key}-${series.key}`}
1173
+ {@const isHovered = hoveredBarKey === barKey}
1174
+ {@const isDimmed = hoveredBarKey !== null && !isHovered}
1175
+ {@const value = groupedBarsMean(
1176
+ key,
1177
+ series.key as VizMetric,
1178
+ groupedBarsByModelKey,
1179
+ activeGroups
1180
+ )}
1181
+ {#if isNumber(value)}
1182
+ <rect
1183
+ x={x0 + seriesIndex * (barSeriesWidth + barSeriesGap)}
1184
+ y={barY(value)}
1185
+ width={barSeriesWidth}
1186
+ height={268 - barY(value)}
1187
+ rx="2"
1188
+ fill={series.color}
1189
+ fill-opacity={isDimmed ? 0.45 : 1}
1190
+ class={cn(
1191
+ 'transition-[fill-opacity,filter]',
1192
+ isHovered && 'brightness-125'
1193
+ )}
1194
+ role="img"
1195
+ aria-label={`${modelLabel(model)} ${series.key.toUpperCase()} ${fmt(value, 3)}`}
1196
+ onpointerenter={(event) => {
1197
+ hoveredBarKey = barKey;
1198
+ tooltip.show(event, modelLabel(model), [
1199
+ {
1200
+ label: series.key.toUpperCase(),
1201
+ value: fmt(value, 3)
1202
+ },
1203
+ {
1204
+ label: m.tools_mean(),
1205
+ value: m.tools_group_mean_summary({ groups: activeGroups.length })
1206
+ }
1207
+ ]);
1208
+ }}
1209
+ onpointermove={tooltip.move}
1210
+ onpointerleave={() => {
1211
+ hoveredBarKey = null;
1212
+ tooltip.hide();
1213
+ }}
1214
+ />
1215
+ {/if}
1216
+ {/each}
1217
+ {/each}
1218
+ </svg>
1219
+ {#each selectedModels as model, index (modelKey(model))}
1220
+ {@const band = (barSvgWidth - 56) / selectedModels.length}
1221
+ {@const x0 = 44 + index * band + Math.max(0, (band - barGroupWidth) / 2)}
1222
+ <div
1223
+ class="absolute h-5 w-20 origin-top-left rotate-45 overflow-visible"
1224
+ style:left={`${x0 + barGroupWidth / 2}px`}
1225
+ style:top="282px"
1226
+ >
1227
+ <OverflowCursorTooltip
1228
+ text={modelLabel(model)}
1229
+ labelClass="block max-w-20 truncate font-mono text-[9px] leading-none text-muted-foreground"
1230
+ tooltipClass="max-w-sm"
1231
+ />
1232
+ </div>
1233
+ {/each}
1234
+ </div>
1235
+ </div>
1236
+ <div class="mt-3 flex flex-wrap gap-4 text-xs text-muted-foreground">
1237
+ <span class="inline-flex items-center gap-2"
1238
+ ><span class="size-2 rounded-full bg-[#ff4d4f]"></span>FNR ({m.tools_lower_better()})</span
1239
+ >
1240
+ <span class="inline-flex items-center gap-2"
1241
+ ><span class="size-2 rounded-full bg-[#f5a623]"></span>FPR ({m.tools_lower_better()})</span
1242
+ >
1243
+ <span class="inline-flex items-center gap-2"
1244
+ ><span class="size-2 rounded-full bg-[#0dce97]"></span>F1 ({m.tools_higher_better()})</span
1245
+ >
1246
+ </div>
1247
+ </section>
1248
 
1249
+ <section class="min-w-0 rounded-xl border bg-card p-4 shadow-sm lg:col-span-2">
1250
+ <div class="mb-3 flex flex-wrap items-baseline gap-2">
1251
+ <h2 class="font-mono text-sm font-semibold">{m.tools_pareto_title()}</h2>
1252
+ <span class="text-xs text-muted-foreground">{m.tools_pareto_hint()}</span>
1253
+ </div>
1254
+ <div class="max-w-full overflow-x-auto">
1255
+ <svg
1256
+ viewBox="0 0 760 380"
1257
+ class="h-auto min-w-[42rem] w-full"
1258
+ role="img"
1259
+ aria-label={m.tools_pareto_aria()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1260
  >
1261
+ {#each paretoXTicks as xt (xt)}
1262
+ {#if xt > 0}
1263
+ <line
1264
+ x1={paretoX(xt, paretoXMax)}
1265
+ y1="18"
1266
+ x2={paretoX(xt, paretoXMax)}
1267
+ y2="330"
1268
+ stroke="currentColor"
1269
+ stroke-width="1"
1270
+ stroke-dasharray="3 4"
1271
+ class="text-border"
1272
+ />
1273
+ {/if}
1274
+ <text
1275
+ x={paretoX(xt, paretoXMax)}
1276
+ y="348"
1277
+ text-anchor="middle"
1278
+ class="fill-muted-foreground font-mono text-[7px]">{Math.round(xt)}</text
1279
+ >
1280
+ {/each}
1281
+ {#each paretoYTicks as yt (yt)}
1282
+ <line
1283
+ x1="56"
1284
+ y1={paretoY(yt, paretoY0)}
1285
+ x2="742"
1286
+ y2={paretoY(yt, paretoY0)}
1287
+ stroke="currentColor"
1288
+ stroke-width="1"
1289
+ stroke-dasharray="3 4"
1290
+ class="text-border"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1291
  />
1292
+ <text
1293
+ x="48"
1294
+ y={paretoY(yt, paretoY0) + 3}
1295
+ text-anchor="end"
1296
+ class="fill-muted-foreground font-mono text-[7px]">{yt.toFixed(2)}</text
1297
+ >
 
 
 
1298
  {/each}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1299
  <line
1300
+ x1="56"
1301
  y1="18"
1302
+ x2="56"
1303
  y2="330"
1304
  stroke="currentColor"
1305
  stroke-width="1"
 
1306
  class="text-border"
1307
  />
1308
+ <line
1309
+ x1="56"
1310
+ y1="330"
1311
+ x2="742"
1312
+ y2="330"
1313
+ stroke="currentColor"
1314
+ stroke-width="1"
1315
+ class="text-border"
1316
+ />
1317
+ <polyline
1318
+ points={paretoFrontFor(paretoRows, paretoXMax, paretoY0)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1319
  fill="none"
1320
  stroke="#6b4bf5"
1321
+ stroke-width="1.6"
1322
+ stroke-dasharray="5 4"
1323
  />
1324
+ {#each paretoRows as row (row.modelKey)}
1325
+ {@const point = paretoPointFor(row, paretoXMax, paretoY0)}
1326
+ {@const pinned = pinnedModelKeys.includes(row.modelKey)}
1327
+ {@const hovered = hoveredParetoModelKey === row.modelKey}
1328
+ <circle
1329
+ cx={point.x}
1330
+ cy={point.y}
1331
+ r={hovered ? 8 : pinned ? 7 : 5.5}
1332
+ fill={colorOf(row.model)}
1333
+ stroke="currentColor"
1334
+ stroke-width={pinned ? 2 : 1.5}
1335
+ class="cursor-pointer text-background transition-[r] focus:outline-none"
1336
+ role="button"
1337
+ tabindex="0"
1338
+ aria-pressed={pinned}
1339
+ aria-label={pinned
1340
+ ? m.tools_pareto_unpin_model({ model: modelLabel(row.model) })
1341
+ : m.tools_pareto_pin_model({ model: modelLabel(row.model) })}
1342
+ onclick={() => togglePin(row.modelKey)}
1343
+ onkeydown={(event) =>
1344
+ handleCheckboxKeydown(event, () => togglePin(row.modelKey))}
1345
+ onfocus={() => (hoveredParetoModelKey = row.modelKey)}
1346
+ onblur={() => (hoveredParetoModelKey = null)}
1347
+ onpointerenter={(event) => {
1348
+ hoveredParetoModelKey = row.modelKey;
1349
+ tooltip.show(event, modelLabel(row.model), [
1350
+ { label: 'integral', value: fmt(row.integral, 3) },
1351
+ { label: 'p95', value: `${fmtMs(row.p95)} ${m.ranking_unit_ms()}` }
1352
+ ]);
1353
+ }}
1354
+ onpointermove={tooltip.move}
1355
+ onpointerleave={() => {
1356
+ hoveredParetoModelKey = null;
1357
+ tooltip.hide();
1358
+ }}
1359
+ />
1360
+ {#if pinned}
1361
+ <circle
1362
+ cx={point.x}
1363
+ cy={point.y}
1364
+ r="9.5"
1365
+ fill="none"
1366
+ stroke="#6b4bf5"
1367
+ stroke-width="1.5"
1368
+ pointer-events="none"
1369
+ />
1370
+ {/if}
1371
+ {/each}
1372
+ {#each paretoRows as row (row.modelKey)}
1373
+ {@const point = paretoPointFor(row, paretoXMax, paretoY0)}
1374
+ {@const pinned = pinnedModelKeys.includes(row.modelKey)}
1375
+ {#if pinned}
1376
+ {@const labelX = Math.min(Math.max(72, point.x), 728)}
1377
+ {@const labelY = Math.max(24, point.y - 18)}
1378
+ <text
1379
+ x={labelX}
1380
+ y={labelY}
1381
+ text-anchor="middle"
1382
+ stroke="var(--card)"
1383
+ stroke-width="4"
1384
+ stroke-linejoin="round"
1385
+ paint-order="stroke"
1386
+ class="fill-muted-foreground font-mono text-[8px]"
1387
+ >
1388
+ {paretoLabelText(row)}
1389
+ </text>
1390
+ {/if}
1391
+ {/each}
1392
  <text
1393
+ x="399"
1394
+ y="370"
1395
  text-anchor="middle"
1396
+ class="fill-muted-foreground font-mono text-[8px]">{m.tools_pareto_x_axis()}</text
 
 
 
 
1397
  >
1398
+ <text
1399
+ transform="translate(16,174) rotate(-90)"
1400
+ text-anchor="middle"
1401
+ class="fill-muted-foreground font-mono text-[8px]">{m.tools_pareto_y_axis()}</text
1402
+ >
1403
+ </svg>
1404
+ </div>
1405
+ <div class="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
1406
+ <span>{m.tools_pareto_click_pin_hint()}</span>
1407
+ </div>
1408
+ </section>
1409
+
1410
+ <section class="min-w-0 rounded-xl border bg-card p-4 shadow-sm lg:col-span-2">
1411
+ <div class="mb-3 flex flex-wrap items-baseline gap-2">
1412
+ <h2 class="font-mono text-sm font-semibold">
1413
+ {m.tools_latency_title()}
1414
+ </h2>
1415
+ <span class="text-xs text-muted-foreground">{m.tools_latency_hint()}</span>
1416
+ </div>
1417
+ <Table
1418
+ data={perfRows}
1419
+ columns={latencyColumns}
1420
+ getRowKey={(row) => row.modelKey}
1421
+ containerClass="max-w-full overflow-x-auto rounded-lg border"
1422
+ tableClass="min-w-[50rem]"
1423
+ headerClass="bg-muted/70 hover:bg-muted/70 [&_th]:text-muted-foreground"
1424
+ rowClass="hover:bg-muted/30"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1425
  >
1426
+ {#snippet header(column)}
1427
+ {@const isActive = latencySortKey === column.key}
1428
+ <Button
1429
+ variant="ghost"
1430
+ size="sm"
1431
+ class={cn(
1432
+ 'h-auto gap-1 px-0 py-0 text-xs font-semibold text-muted-foreground hover:bg-transparent',
1433
+ column.key !== 'model' && 'ml-auto justify-end text-right',
1434
+ isActive && 'text-foreground'
1435
+ )}
1436
+ onclick={() => toggleLatencySort(column.key)}
1437
+ >
1438
+ {latencyColumnLabel(column)}
1439
+ {#if isActive}
1440
+ {#if latencySortDirection === 'asc'}
1441
+ <ArrowUpIcon class="size-3 shrink-0" />
1442
+ {:else}
1443
+ <ArrowDownIcon class="size-3 shrink-0" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1444
  {/if}
1445
+ {:else}
1446
+ <ArrowUpDownIcon class="size-3 shrink-0 text-muted-foreground" />
1447
+ {/if}
1448
+ </Button>
1449
+ {/snippet}
1450
+
1451
+ {#snippet cell(row, column)}
1452
+ {#if column.key === 'model'}
1453
+ <span
1454
+ class="inline-flex min-w-0 items-center gap-2 font-mono text-xs font-semibold"
1455
+ >
1456
+ <span class="size-2 rounded-full" style={`background:${colorOf(row.model)}`}
1457
+ ></span>
1458
+ <span class="min-w-0 max-w-80 truncate">{row.model.short}</span>
1459
+ <code class="text-muted-foreground">{row.model.params}</code>
1460
+ </span>
1461
+ {:else if column.key === 'errorRate'}
1462
+ <span class="font-mono">{fmtPercent(row.errorRate)}</span>
1463
+ {:else}
1464
+ <span class="font-mono">{fmtMs(row[column.key])}</span>
1465
+ {/if}
1466
+ {/snippet}
1467
+ </Table>
1468
+ <p class="mt-3 text-xs text-muted-foreground">{m.tools_latency_note()}</p>
1469
+ </section>
1470
+
1471
+ <section class="min-w-0 rounded-xl border bg-card p-4 shadow-sm lg:col-span-2">
1472
+ <div class="mb-3 flex flex-wrap items-baseline gap-2">
1473
+ <h2 class="font-mono text-sm font-semibold">
1474
+ {m.tools_robustness_title()}
1475
+ </h2>
1476
+ <span class="text-xs text-muted-foreground">{m.tools_robustness_hint()}</span>
1477
+ </div>
1478
+ <p class="mb-3 text-xs leading-5 text-muted-foreground">{m.tools_robustness_note()}</p>
1479
+ <div class="max-w-full overflow-x-auto rounded-lg border">
1480
+ <table class="w-full min-w-[70rem] text-sm">
1481
+ <thead class="bg-muted/70 text-xs text-muted-foreground">
1482
+ <tr>
1483
+ <th class="px-3 py-3 text-left font-semibold">{m.ranking_column_model()}</th>
1484
+ <th class="px-3 py-3 text-right font-semibold">{m.tools_real_score()}</th>
1485
+ <th class="px-3 py-3 text-right font-semibold">{m.tools_robust_score()}</th>
1486
+ <th class="px-3 py-3 text-right font-semibold">Δ score</th>
1487
+ <th class="px-3 py-3 text-right font-semibold">FNR real</th>
1488
+ <th class="px-3 py-3 text-right font-semibold">FNR robust</th>
1489
+ <th class="px-3 py-3 text-right font-semibold">Δ FNR</th>
1490
+ <th class="px-3 py-3 text-right font-semibold">FPR real</th>
1491
+ <th class="px-3 py-3 text-right font-semibold">FPR robust</th>
1492
+ <th class="px-3 py-3 text-right font-semibold">Δ FPR</th>
1493
+ </tr>
1494
+ </thead>
1495
+ <tbody>
1496
+ {#each robustRows as row (row.modelKey)}
1497
+ {@const sDelta = row.deltaScore}
1498
+ {@const fnrDelta = row.deltaFnr}
1499
+ {@const fprDelta = row.deltaFpr}
1500
+ <tr class="border-t transition-colors hover:bg-muted/30">
1501
+ <td class="px-3 py-2">
1502
+ <div class="font-mono text-xs font-semibold">
1503
+ <span
1504
+ class="mr-2 inline-block size-2 rounded-full"
1505
+ style={`background:${colorOf(row.model)}`}
1506
+ ></span>
1507
+ <span>{row.model.short}</span>
1508
+ <code class="ml-2 text-muted-foreground">{row.model.params}</code>
1509
+ </div>
1510
+ <div class="mt-2 flex max-w-40 flex-col gap-1">
1511
+ <span class="h-1.5 rounded-full bg-muted">
1512
+ <span
1513
+ class="block h-full rounded-full bg-[#0dce97]"
1514
+ style={`width:${scoreWidth(row.scoreReal)}`}
1515
+ ></span>
1516
+ </span>
1517
+ <span class="h-1.5 rounded-full bg-muted">
1518
+ <span
1519
+ class="block h-full rounded-full bg-[#6b4bf5]"
1520
+ style={`width:${scoreWidth(row.scoreRobust)}`}
1521
+ ></span>
1522
+ </span>
1523
+ </div>
1524
+ </td>
1525
+ <td class="px-3 py-2 text-right font-mono">{fmt(row.scoreReal, 3)}</td>
1526
+ <td class="px-3 py-2 text-right font-mono">{fmt(row.scoreRobust, 3)}</td>
1527
+ <td class="px-3 py-2 text-right">
1528
  <span
1529
+ class={cn(
1530
+ 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 font-mono text-xs font-semibold whitespace-nowrap',
1531
+ deltaBadgeClass(sDelta, 'score')
1532
+ )}
1533
+ >
1534
+ {fmtDelta(sDelta)}
1535
+ </span>
1536
+ </td>
1537
+ <td class="px-3 py-2 text-right font-mono">{fmt(row.fnrReal, 3)}</td>
1538
+ <td class="px-3 py-2 text-right font-mono">{fmt(row.fnrRobust, 3)}</td>
1539
+ <td class="px-3 py-2 text-right">
1540
  <span
1541
+ class={cn(
1542
+ 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 font-mono text-xs font-semibold whitespace-nowrap',
1543
+ deltaBadgeClass(fnrDelta, 'fnr')
1544
+ )}
1545
+ >
1546
+ {fmtDelta(fnrDelta)}
1547
+ </span>
1548
+ </td>
1549
+ <td class="px-3 py-2 text-right font-mono">{fmt(row.fprReal, 3)}</td>
1550
+ <td class="px-3 py-2 text-right font-mono">{fmt(row.fprRobust, 3)}</td>
1551
+ <td class="px-3 py-2 text-right">
1552
+ <span
1553
+ class={cn(
1554
+ 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 font-mono text-xs font-semibold whitespace-nowrap',
1555
+ deltaBadgeClass(fprDelta, 'fpr')
1556
+ )}
1557
+ >
1558
+ {fmtDelta(fprDelta)}
1559
+ </span>
1560
+ </td>
1561
+ </tr>
1562
+ {/each}
1563
+ </tbody>
1564
+ </table>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1565
  </div>
1566
+ </section>
1567
  </div>
1568
+
1569
+ <p class="mt-8 text-center text-xs text-muted-foreground">
1570
+ {m.tools_footer()}
1571
+ </p>
1572
  </div>
1573
+ {/snippet}
1574
+ </ChartCursorTooltip>
1575
  {/if}