diff --git a/apps/smartem/src/components/weights/WeightsMatrix.tsx b/apps/smartem/src/components/weights/WeightsMatrix.tsx new file mode 100644 index 0000000..6b7413f --- /dev/null +++ b/apps/smartem/src/components/weights/WeightsMatrix.tsx @@ -0,0 +1,262 @@ +import { Box, Tooltip, Typography } from '@mui/material' +import type { QualityPredictionModelWeight } from '@smartem/api' +import { useMemo } from 'react' +import { gray } from '~/theme' +import { cellFillColor, cellTextColor } from './cellColor' + +interface Props { + weightsByModel: Record +} + +interface LatestCell { + weight: number + timestamp: string | undefined +} + +const CELL_HEIGHT = 64 +const CELL_MIN_WIDTH = 100 +const ROW_HEADER_WIDTH = 160 +const COL_HEADER_HEIGHT = 40 + +function formatTimestamp(ts: string | undefined): string { + if (!ts) return '—' + const d = new Date(ts) + if (Number.isNaN(d.getTime())) return ts + return d.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +function pickLatest(entries: QualityPredictionModelWeight[]): LatestCell | undefined { + if (entries.length === 0) return undefined + let best = entries[0] + for (const entry of entries) { + if ((entry.timestamp ?? '') > (best.timestamp ?? '')) best = entry + } + return { weight: best.weight, timestamp: best.timestamp } +} + +export function WeightsMatrix({ weightsByModel }: Props) { + const { models, metrics, cells } = useMemo(() => { + const modelSet = Object.keys(weightsByModel) + const metricSet = new Set() + const grouped = new Map() + + for (const [modelName, entries] of Object.entries(weightsByModel)) { + for (const entry of entries) { + if (!entry.metric_name) continue + metricSet.add(entry.metric_name) + const key = `${modelName}::${entry.metric_name}` + const bucket = grouped.get(key) + if (bucket) bucket.push(entry) + else grouped.set(key, [entry]) + } + } + + const cellMap = new Map() + for (const [key, entries] of grouped.entries()) { + const latest = pickLatest(entries) + if (latest) cellMap.set(key, latest) + } + + return { + models: modelSet, + metrics: Array.from(metricSet).sort(), + cells: cellMap, + } + }, [weightsByModel]) + + if (models.length === 0 || metrics.length === 0) { + return ( + + + No model weights available for this grid. + + + ) + } + + const gridTemplateColumns = `${ROW_HEADER_WIDTH}px repeat(${metrics.length}, minmax(${CELL_MIN_WIDTH}px, 1fr))` + const gridTemplateRows = `${COL_HEADER_HEIGHT}px repeat(${models.length}, ${CELL_HEIGHT}px)` + + return ( + + + + + model × metric + + + + {metrics.map((metric) => ( + + + {metric} + + + ))} + + {models.flatMap((model) => { + const rowKey = `row-${model}` + const headerCell = ( + + + {model} + + + ) + + const dataCells = metrics.map((metric) => { + const cellKey = `cell-${model}-${metric}` + const cell = cells.get(`${model}::${metric}`) + if (!cell) { + return ( + + — + + ) + } + return ( + + + {model} · {metric} + + Weight: {cell.weight.toFixed(3)} + + {formatTimestamp(cell.timestamp)} + + + } + placement="top" + arrow + > + + {cell.weight.toFixed(2)} + + + ) + }) + + return [headerCell, ...dataCells] + })} + + + + + ) +} + +function WeightsMatrixLegend() { + const stops = [0, 0.25, 0.5, 0.75, 1] + return ( + + + Weight + + + + {stops.map((s) => ( + + {s.toFixed(2)} + + ))} + + + ) +} diff --git a/apps/smartem/src/components/weights/cellColor.ts b/apps/smartem/src/components/weights/cellColor.ts new file mode 100644 index 0000000..2ee94a4 --- /dev/null +++ b/apps/smartem/src/components/weights/cellColor.ts @@ -0,0 +1,28 @@ +import { gray } from '~/theme' + +const LOW_RGB = { r: 246, g: 248, b: 250 } +const HIGH_RGB = { r: 5, g: 80, b: 174 } +const TEXT_LIGHT = '#ffffff' +const TEXT_DARK = gray[900] + +function lerp(a: number, b: number, t: number): number { + return Math.round(a + (b - a) * t) +} + +function clamp01(value: number): number { + if (value < 0) return 0 + if (value > 1) return 1 + return value +} + +export function cellFillColor(weight: number): string { + const t = clamp01(weight) + const r = lerp(LOW_RGB.r, HIGH_RGB.r, t) + const g = lerp(LOW_RGB.g, HIGH_RGB.g, t) + const b = lerp(LOW_RGB.b, HIGH_RGB.b, t) + return `rgb(${r}, ${g}, ${b})` +} + +export function cellTextColor(weight: number): string { + return clamp01(weight) >= 0.55 ? TEXT_LIGHT : TEXT_DARK +} diff --git a/apps/smartem/src/data/mock-model-weights.ts b/apps/smartem/src/data/mock-model-weights.ts new file mode 100644 index 0000000..930a56f --- /dev/null +++ b/apps/smartem/src/data/mock-model-weights.ts @@ -0,0 +1,81 @@ +import type { QualityPredictionModelWeight } from '@smartem/api' + +export const mockGridIdForWeights = '11111111-1111-1111-1111-111111111111' + +export const mockGridLabelForWeights = 'grid-A1 (mock)' + +const HISTORY_TIMESTAMPS = [ + '2026-04-29T13:00:00.000Z', + '2026-04-29T14:00:00.000Z', + '2026-04-29T15:00:00.000Z', + '2026-04-29T16:00:00.000Z', + '2026-04-29T17:00:00.000Z', + '2026-04-29T18:00:00.000Z', +] as const + +type LatestWeight = { metric: string; weight: number } + +const latestByModel: Record = { + 'resnet-atlas': [ + { metric: 'defocus', weight: 0.82 }, + { metric: 'astigmatism', weight: 0.71 }, + { metric: 'ice_thickness', weight: 0.45 }, + { metric: 'motion', weight: 0.68 }, + { metric: 'ctf_resolution', weight: 0.79 }, + ], + 'vit-gridsquare': [ + { metric: 'defocus', weight: 0.74 }, + { metric: 'astigmatism', weight: 0.88 }, + { metric: 'ice_thickness', weight: 0.62 }, + { metric: 'ctf_resolution', weight: 0.81 }, + ], + 'efficientnet-ice': [ + { metric: 'defocus', weight: 0.3 }, + { metric: 'astigmatism', weight: 0.35 }, + { metric: 'ice_thickness', weight: 0.92 }, + { metric: 'motion', weight: 0.4 }, + { metric: 'ctf_resolution', weight: 0.55 }, + ], + 'dae-atlas': [ + { metric: 'defocus', weight: 0.55 }, + { metric: 'astigmatism', weight: 0.62 }, + { metric: 'ice_thickness', weight: 0.4 }, + { metric: 'motion', weight: 0.5 }, + { metric: 'ctf_resolution', weight: 0.65 }, + ], +} + +function clamp01(value: number): number { + if (value < 0) return 0 + if (value > 1) return 1 + return value +} + +function buildHistory(modelName: string, latest: LatestWeight): QualityPredictionModelWeight[] { + const seed = (modelName.length + latest.metric.length) % 7 + return HISTORY_TIMESTAMPS.map((timestamp, idx) => { + const drift = ((idx + seed) % 5) * 0.02 - 0.04 + const w = idx === HISTORY_TIMESTAMPS.length - 1 ? latest.weight : clamp01(latest.weight + drift) + return { + grid_uuid: mockGridIdForWeights, + timestamp, + prediction_model_name: modelName, + metric_name: latest.metric, + weight: Number(w.toFixed(3)), + } + }) +} + +const weightsByModel: Record = Object.fromEntries( + Object.entries(latestByModel).map(([modelName, metrics]) => [ + modelName, + metrics.flatMap((m) => buildHistory(modelName, m)), + ]) +) + +export function getModelWeightsForGrid( + gridId: string +): Record { + if (gridId !== mockGridIdForWeights) return {} + return weightsByModel +} diff --git a/apps/smartem/src/routeTree.gen.ts b/apps/smartem/src/routeTree.gen.ts index 29befa1..0196728 100644 --- a/apps/smartem/src/routeTree.gen.ts +++ b/apps/smartem/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as ModelsRouteImport } from './routes/models' import { Route as AcquisitionsRouteImport } from './routes/acquisitions' import { Route as IndexRouteImport } from './routes/index' import { Route as AcquisitionsIndexRouteImport } from './routes/acquisitions.index' @@ -24,6 +25,11 @@ import { Route as AcquisitionsAcquisitionIdGridsGridIdSquaresSquareIdRouteImport import { Route as AcquisitionsAcquisitionIdGridsGridIdSquaresSquareIdIndexRouteImport } from './routes/acquisitions.$acquisitionId.grids.$gridId.squares_.$squareId.index' import { Route as AcquisitionsAcquisitionIdGridsGridIdSquaresSquareIdHolesHoleIdRouteImport } from './routes/acquisitions.$acquisitionId.grids.$gridId.squares_.$squareId.holes_.$holeId' +const ModelsRoute = ModelsRouteImport.update({ + id: '/models', + path: '/models', + getParentRoute: () => rootRouteImport, +} as any) const AcquisitionsRoute = AcquisitionsRouteImport.update({ id: '/acquisitions', path: '/acquisitions', @@ -113,6 +119,7 @@ const AcquisitionsAcquisitionIdGridsGridIdSquaresSquareIdHolesHoleIdRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute '/acquisitions': typeof AcquisitionsRouteWithChildren + '/models': typeof ModelsRoute '/acquisitions/$acquisitionId': typeof AcquisitionsAcquisitionIdRouteWithChildren '/acquisitions/': typeof AcquisitionsIndexRoute '/acquisitions/$acquisitionId/': typeof AcquisitionsAcquisitionIdIndexRoute @@ -128,6 +135,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/models': typeof ModelsRoute '/acquisitions': typeof AcquisitionsIndexRoute '/acquisitions/$acquisitionId': typeof AcquisitionsAcquisitionIdIndexRoute '/acquisitions/$acquisitionId/grids/$gridId/atlas': typeof AcquisitionsAcquisitionIdGridsGridIdAtlasRoute @@ -142,6 +150,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/acquisitions': typeof AcquisitionsRouteWithChildren + '/models': typeof ModelsRoute '/acquisitions/$acquisitionId': typeof AcquisitionsAcquisitionIdRouteWithChildren '/acquisitions/': typeof AcquisitionsIndexRoute '/acquisitions/$acquisitionId/': typeof AcquisitionsAcquisitionIdIndexRoute @@ -160,6 +169,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/acquisitions' + | '/models' | '/acquisitions/$acquisitionId' | '/acquisitions/' | '/acquisitions/$acquisitionId/' @@ -175,6 +185,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/models' | '/acquisitions' | '/acquisitions/$acquisitionId' | '/acquisitions/$acquisitionId/grids/$gridId/atlas' @@ -188,6 +199,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/acquisitions' + | '/models' | '/acquisitions/$acquisitionId' | '/acquisitions/' | '/acquisitions/$acquisitionId/' @@ -205,10 +217,18 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AcquisitionsRoute: typeof AcquisitionsRouteWithChildren + ModelsRoute: typeof ModelsRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/models': { + id: '/models' + path: '/models' + fullPath: '/models' + preLoaderRoute: typeof ModelsRouteImport + parentRoute: typeof rootRouteImport + } '/acquisitions': { id: '/acquisitions' path: '/acquisitions' @@ -392,6 +412,7 @@ const AcquisitionsRouteWithChildren = AcquisitionsRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AcquisitionsRoute: AcquisitionsRouteWithChildren, + ModelsRoute: ModelsRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/smartem/src/routes/models.tsx b/apps/smartem/src/routes/models.tsx new file mode 100644 index 0000000..2900865 --- /dev/null +++ b/apps/smartem/src/routes/models.tsx @@ -0,0 +1,65 @@ +import { Box, Typography } from '@mui/material' +import { createFileRoute } from '@tanstack/react-router' +import { WeightsMatrix } from '~/components/weights/WeightsMatrix' +import { + getModelWeightsForGrid, + mockGridIdForWeights, + mockGridLabelForWeights, +} from '~/data/mock-model-weights' +import { gray } from '~/theme' + +export const Route = createFileRoute('/models')({ + component: ModelsPage, +}) + +function ModelsPage() { + const weightsByModel = getModelWeightsForGrid(mockGridIdForWeights) + + return ( + + + Models + + weights for {mockGridLabelForWeights} + + + + + + + + ) +}