hfinfer / src /lib /explorer /CorrelationHeatmap.svelte
Morgan Funtowicz
Add kernel scaling explorer screen (Svelte 5)
b66867a
Raw History Blame
6.9 kB
<script lang="ts">
import type { CellReason, CorrelationCell, CorrelationModel } from './model'
import { metricLabel } from '../types'
interface Props {
model: CorrelationModel
selected: { axis: string; metric: string }
onselect: (axis: string, metric: string) => void
}
let { model, selected, onselect }: Props = $props()
const REASON_LABELS: Record<CellReason, string> = {
'constant-axis': 'constant axis',
'constant-metric': 'constant metric',
coverage: 'below gates',
missing: 'no data',
}
let cellMap = $derived.by(() => {
const m = new Map<string, CorrelationCell>()
for (const c of model.cells) m.set(`${c.axis}\u0001${c.metric}`, c)
return m
})
function cellAt(axis: string, metric: string): CorrelationCell | undefined {
return cellMap.get(`${axis}\u0001${metric}`)
}
/** Signed coefficient, e.g. '+0.82' / 'βˆ’0.31' (true minus sign). */
function signedRho(rho: number): string {
return `${rho < 0 ? 'βˆ’' : '+'}${Math.abs(rho).toFixed(2)}`
}
/** Only ever show a coefficient the model computed AND passed its gates. */
function cellText(c: CorrelationCell | undefined): string {
return c && c.passed && c.rho !== null ? signedRho(c.rho) : 'β€”'
}
function reasonText(c: CorrelationCell | undefined): string {
return REASON_LABELS[c?.reason ?? 'missing']
}
function cellTitle(axis: string, metric: string, c: CorrelationCell | undefined): string {
const head = `${axis} Β· ${metricLabel(metric)}`
if (c && c.passed && c.rho !== null) {
return `${head} β€” Spearman rho ${signedRho(c.rho)} (${c.n} configurations)`
}
return `${head} β€” unavailable: ${reasonText(c)} (${c ? c.n : 0} configurations)`
}
function cellAria(axis: string, metric: string, c: CorrelationCell | undefined): string {
const head = `axis ${axis}, metric ${metricLabel(metric)}`
if (c && c.passed && c.rho !== null) {
return `${head}: Spearman rho ${signedRho(c.rho)}, ${c.n} configurations`
}
return `${head}: unavailable β€” ${reasonText(c)}, ${c ? c.n : 0} configurations`
}
/** Diverging tint: blue (accent) positive, orange negative, strength = |rho|. */
function cellStyle(c: CorrelationCell | undefined): string {
if (!c || c.rho === null) return ''
const pct = Math.round(10 + 65 * Math.min(1, Math.abs(c.rho)))
const color = c.rho >= 0 ? '--accent' : '--bad'
return `background-color: color-mix(in srgb, var(${color}) ${pct}%, transparent)`
}
let hasGrid = $derived(model.axes.length > 0 && model.metrics.length > 0)
</script>
<div class="hm-card">
{#if !hasGrid}
<div class="empty">No measurements match the current filters.</div>
{:else}
<div
class="hm"
style="grid-template-columns: max-content repeat({model.metrics.length}, minmax(4.75rem, 1fr))"
>
<div class="corner"></div>
{#each model.metrics as metric (metric)}
<div class="col-head">{metricLabel(metric)}</div>
{/each}
{#each model.axes as axis (axis)}
<div class="row-head">{metricLabel(axis)}</div>
{#each model.metrics as metric (metric)}
{@const c = cellAt(axis, metric)}
<button
class="cell"
class:sel={selected.axis === axis && selected.metric === metric}
class:hatched={!!c && !c.passed && c.reason === 'coverage'}
style={cellStyle(c)}
title={cellTitle(axis, metric, c)}
aria-label={cellAria(axis, metric, c)}
onclick={() => onselect(axis, metric)}
>
<span class="rho">{cellText(c)}</span>
{#if !c || !c.passed}
<span class="reason">{reasonText(c)}</span>
{/if}
</button>
{/each}
{/each}
</div>
<div class="zero-legend">
<div class="strip"></div>
<div class="strip-labels">
<span>βˆ’1</span>
<span>0</span>
<span>+1</span>
</div>
</div>
<p class="caption">
Spearman ρ per cell β€” positive = blue tint, negative = orange tint, strength = saturation.
</p>
{/if}
<p class="gates">
Coverage gates: at least {model.gates.minConfigs} configurations and {model.gates.minValues}
distinct axis values per cell β€” these are coverage rules, not a guarantee of statistical
significance.
</p>
</div>
<style>
.hm-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.9rem 1rem;
min-width: 0;
}
.hm {
display: grid;
gap: 3px;
align-items: stretch;
}
.corner {
/* top-left empty slot of the grid: keeps the header row aligned */
min-width: 2.5rem;
}
.col-head {
font-size: 0.68rem;
color: var(--muted);
text-align: center;
padding: 0.2rem 0.15rem;
overflow-wrap: anywhere;
}
.row-head {
font-size: 0.72rem;
color: var(--muted);
display: flex;
align-items: center;
padding-right: 0.5rem;
white-space: nowrap;
}
.cell {
font: inherit;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
color: var(--fg);
background-color: transparent;
border: 1px solid var(--border);
border-radius: 5px;
min-height: 2.6rem;
padding: 0.25rem 0.2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
cursor: pointer;
}
.cell:hover {
border-color: var(--accent);
}
.cell.sel {
outline: 2px solid var(--accent);
outline-offset: -2px;
border-color: var(--accent);
}
.cell.hatched {
background-image: repeating-linear-gradient(
45deg,
transparent 0 5px,
var(--border) 5px 7px
);
opacity: 0.75;
}
.rho {
font-weight: 600;
}
.reason {
font-size: 0.58rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
}
.zero-legend {
margin-top: 0.7rem;
max-width: 15rem;
}
.strip {
height: 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--bad) 75%, transparent),
color-mix(in srgb, var(--bad) 0%, transparent) 50%,
color-mix(in srgb, var(--accent) 75%, transparent)
);
}
.strip-labels {
display: flex;
justify-content: space-between;
font-size: 0.62rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
margin-top: 0.15rem;
}
.caption {
margin: 0.35rem 0 0;
font-size: 0.68rem;
color: var(--muted);
}
.gates {
margin: 0.6rem 0 0;
font-size: 0.68rem;
color: var(--muted);
border-top: 1px solid var(--border);
padding-top: 0.5rem;
}
.empty {
color: var(--muted);
font-size: 0.85rem;
padding: 2.5rem 1rem;
text-align: center;
}
</style>