From 108d2f5124d1b7dbd5fd71d909839102d75eb910 Mon Sep 17 00:00:00 2001 From: atamano Date: Sun, 12 Jul 2026 14:29:44 +0200 Subject: [PATCH] feat(heatmap): overlay next-move frequencies on the board Co-Authored-By: Jeff Palmer --- app/[locale]/page.tsx | 3 +- components/chess/chess-board.tsx | 236 +++++++++++++++++++++++++++- components/chess/heatmap-toggle.tsx | 38 +++++ components/scanner/dashboard.tsx | 62 ++++++-- hooks/use-heatmap.ts | 61 +++++++ hooks/use-scanner.ts | 16 ++ lib/catalog/eco-classify.ts | 20 +++ lib/heatmap/heatmap.ts | 218 +++++++++++++++++++++++++ lib/i18n/dictionaries/de.json | 4 + lib/i18n/dictionaries/en.json | 4 + lib/i18n/dictionaries/es.json | 4 + lib/i18n/dictionaries/fr.json | 4 + lib/i18n/dictionaries/it.json | 4 + lib/i18n/dictionaries/ja.json | 4 + lib/i18n/dictionaries/nl.json | 4 + lib/i18n/dictionaries/pl.json | 4 + lib/i18n/dictionaries/pt-BR.json | 4 + lib/i18n/dictionaries/ru.json | 4 + lib/i18n/dictionaries/tr.json | 4 + lib/i18n/dictionaries/uk.json | 4 + lib/i18n/dictionaries/zh-CN.json | 4 + lib/i18n/dictionary.ts | 4 + workers/scanner.worker.ts | 14 ++ 23 files changed, 709 insertions(+), 15 deletions(-) create mode 100644 components/chess/heatmap-toggle.tsx create mode 100644 hooks/use-heatmap.ts create mode 100644 lib/heatmap/heatmap.ts diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index af818c4..3ef0feb 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -39,6 +39,7 @@ function HomeInner() { scan, abort, reset, + lookupEco, } = useScanner(); const [color] = useQueryState( @@ -132,7 +133,7 @@ function HomeInner() { ) : null} {status === "done" && stats && stats.totalGames > 0 ? ( - + ) : null} {status === "done" && stats && stats.totalGames === 0 ? ( diff --git a/components/chess/chess-board.tsx b/components/chess/chess-board.tsx index 97dc65a..6394169 100644 --- a/components/chess/chess-board.tsx +++ b/components/chess/chess-board.tsx @@ -2,7 +2,12 @@ import { Chess } from "chess.js"; import dynamic from "next/dynamic"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { + heatmapOverlayColor, + type HeatmapCell, +} from "@/lib/heatmap/heatmap"; +import { formatNumber } from "@/lib/utils"; const Chessboard = dynamic( () => import("react-chessboard").then((m) => m.Chessboard), @@ -27,19 +32,114 @@ export function fenFromMoves(moves: readonly string[]): string { return chess.fen(); } +/** One-direction duration of the heatmap fade. Matches the board's animation. */ +const HEATMAP_FADE_MS = 140; + interface ChessBoardProps { moves: readonly string[]; orientation?: "white" | "black"; id?: string; + heatmap?: readonly HeatmapCell[] | null; } export function ChessBoard({ moves, orientation = "white", id, + heatmap, }: ChessBoardProps) { const fen = useMemo(() => fenFromMoves(moves), [moves]); + // `displayed` outlives `heatmap` going null by one fade-out so the overlay + // can animate away instead of vanishing. `leaving` drives that fade. + // + // The two directions use different mechanisms on purpose. Enabling mounts the + // overlay divs fresh (displayed goes null → cells, so `squareRenderer` goes + // undefined → defined), and a CSS mount animation is the one thing guaranteed + // to run from its first keyframe — unlike an opacity transition, which needs + // the browser to paint the initial frame first and races the scheduler. + // Disabling animates already-mounted divs, where an opacity transition is + // exactly right. + const [displayed, setDisplayed] = useState( + heatmap ?? null, + ); + const [leaving, setLeaving] = useState(false); + + useEffect(() => { + if (heatmap) { + setLeaving(false); + setDisplayed(heatmap); + return; + } + if (!displayed) return; + setLeaving(true); + const t = setTimeout(() => { + setDisplayed(null); + setLeaving(false); + }, HEATMAP_FADE_MS); + return () => clearTimeout(t); + }, [heatmap, displayed]); + + const heatmapBySquare = useMemo(() => { + const map = new Map(); + // buildHeatmap already emits one cell per destination square, so this is a + // straight index — no move survives at the cost of another. + for (const cell of displayed ?? []) map.set(cell.square, cell); + return map; + }, [displayed]); + + const squareRenderer = useMemo(() => { + if (heatmapBySquare.size === 0) return undefined; + const opacity = leaving ? 0 : 1; + return function HeatmapSquare({ + square, + children, + }: { + piece: { pieceType: string } | null; + square: string; + children?: React.ReactNode; + }) { + const cell = heatmapBySquare.get(square); + return ( +
+ {cell?.significant ? ( +
+ ) : null} +
+ {children} +
+ {cell ? : null} +
+ ); + }; + }, [heatmapBySquare, leaving]); + return (
@@ -55,9 +155,143 @@ export function ChessBoard({ borderRadius: "0", }, animationDurationInMs: 150, + squareRenderer, }} />
); } + +/** True for squares where the board renders the cream "light" colour. */ +function isLightSquare(square: string): boolean { + const file = square.charCodeAt(0) - 97; + const rank = Number(square.slice(1)) - 1; + return (file + rank) % 2 !== 0; +} + +/** + * Render a share as a percentage with adaptive precision: integer for the + * tinted "significant" cells, two-decimal for everything else (so a 1-game + * outlier shows as `0.02%` instead of collapsing to `0%`). Trailing zeros + * past the first decimal are trimmed for readability — `2.40 → 2.4`, but + * `0.05` is left intact. + */ +function formatSharePct(share: number, significant: boolean): string { + const pct = share * 100; + if (significant) return `${Math.round(pct)}%`; + const fixed = pct + .toFixed(2) + .replace(/(\.\d*?)0+$/, "$1") + .replace(/\.$/, ""); + return `${fixed}%`; +} + +function HeatmapLabel({ + cell, + square, +}: { + cell: HeatmapCell; + square: string; +}) { + // Significant cells round to whole percent (the headline use-case is "21%"); + // insignificant cells get hundredth-of-a-percent precision so a one-game + // outlier doesn't collapse to the same `0%` as a never-played move. + const sharePctText = formatSharePct(cell.share, cell.significant); + // The stat line (`21% · 412`) keeps `whiteSpace: nowrap`, so wide numbers + // would otherwise spill past the square. Scale the cqi target down with + // character count — empirically, ~`120 / chars` lands the line inside the + // square at our font weight (bold sans-serif digits run ~0.6em wide), with + // a 16cqi cap so the common short-count case stays bold and readable. + const statChars = `${sharePctText} · ${formatNumber(cell.count)}`.length; + const statCqi = Math.min(16, 120 / Math.max(statChars, 1)); + // Tinted (significant) cells: white label on the saturated blue→red overlay, + // whose lightness stays ≤ 52% so white clears AA across the ramp, backed by a + // dark shadow. Untinted cells: the label sits on the bare board, which is wood + // in either app theme, so these read against the square's own colour (cream or + // brown) rather than a theme token that would flip out from under it. + const onTint = cell.significant; + const lightSquare = isLightSquare(square); + const color = onTint + ? "#ffffff" + : lightSquare + ? "#1a1410" /* ink, reads on cream */ + : "#fff8eb" /* warm paper, reads on brown */; + const textShadow = onTint + ? "0 0.0625rem 0.125rem rgba(0,0,0,0.7), 0 0 0.0625rem rgba(0,0,0,0.55)" + : lightSquare + ? "0 0.0625rem 0.0625rem rgba(255,253,247,0.5)" + : "0 0.0625rem 0.0625rem rgba(0,0,0,0.45)"; + return ( +
+ {/* Always render the top slot — even empty — so the stats line stays + pinned to the bottom of the square via `space-between`. */} + + {cell.openingName ?? ""} + + + {sharePctText} + · {formatNumber(cell.count)} + +
+ ); +} diff --git a/components/chess/heatmap-toggle.tsx b/components/chess/heatmap-toggle.tsx new file mode 100644 index 0000000..d0dece2 --- /dev/null +++ b/components/chess/heatmap-toggle.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Flame } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { useDictionary } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +interface HeatmapToggleProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; +} + +export function HeatmapToggle({ checked, onCheckedChange }: HeatmapToggleProps) { + const dict = useDictionary(); + return ( +
+ + {dict.heatmap.label} + +
+ ); +} diff --git a/components/scanner/dashboard.tsx b/components/scanner/dashboard.tsx index 3ebbcf7..615035c 100644 --- a/components/scanner/dashboard.tsx +++ b/components/scanner/dashboard.tsx @@ -3,6 +3,7 @@ import { GitBranch, Info, MousePointerClick } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ChessBoard } from "@/components/chess/chess-board"; +import { HeatmapToggle } from "@/components/chess/heatmap-toggle"; import { ContinuationsPanel } from "@/components/scanner/continuations-panel"; import { ExportMenu } from "@/components/scanner/export-menu"; import { GapAnalysis } from "@/components/scanner/gap-analysis"; @@ -14,6 +15,9 @@ import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group"; +import { useHeatmap } from "@/hooks/use-heatmap"; +import type { EcoLookup } from "@/hooks/use-scanner"; +import type { HeatmapCell } from "@/lib/heatmap/heatmap"; import { buildGlobalTree, findGamesAtPath, @@ -30,9 +34,10 @@ import { formatNumber, formatPct } from "@/lib/utils"; interface DashboardProps { stats: RepertoireStats; + lookupEco: EcoLookup; } -export function Dashboard({ stats }: DashboardProps) { +export function Dashboard({ stats, lookupEco }: DashboardProps) { const dict = useDictionary(); const availableColors = useMemo(() => { const out: PlayerColor[] = []; @@ -42,6 +47,7 @@ export function Dashboard({ stats }: DashboardProps) { }, [stats.colorBreakdown]); const [color, setColor] = useState(availableColors[0] ?? "white"); + const [heatmapEnabled, setHeatmapEnabled] = useState(false); const { selectedFamily, selectedId, @@ -100,7 +106,10 @@ export function Dashboard({ stats }: DashboardProps) { ); return mostPlayed.entry?.moves ?? []; }, [selected, selectedFamily, stats, color]); - const boardMoves = previewMoves ?? [...baseMoves, ...path]; + const boardMoves = useMemo( + () => previewMoves ?? [...baseMoves, ...path], + [previewMoves, baseMoves, path], + ); // When a new opening is picked we normally reset the drill path. The // "jump to variation" flow (ref provided by the filters context) skips it @@ -168,6 +177,21 @@ export function Dashboard({ stats }: DashboardProps) { const focusedSan = currentChildren[focusIndex]?.san ?? null; + // The children are those of `baseMoves + path`, so the overlay must be built + // from that same position — not `boardMoves`, which swaps in `previewMoves` + // while a preview is showing and would replay the wrong root. + const heatmapBase = useMemo( + () => [...baseMoves, ...path], + [baseMoves, path], + ); + const heatmapCells = useHeatmap( + heatmapEnabled, + heatmapBase, + currentChildren, + selected?.entry?.name ?? null, + lookupEco, + ); + // Arrow-key navigation through the continuation tree. // ← pop one ply // → push the focused continuation (defaults to the most-played one) @@ -285,26 +309,34 @@ export function Dashboard({ stats }: DashboardProps) {
) : ( (() => { - const exportAction = ( - + const boardActions = ( +
+ + +
); return selected ? ( ) : ( ); })() @@ -417,10 +449,12 @@ function EmptyCenterPanel({ color, boardMoves, actions, + heatmap, }: { color: PlayerColor; boardMoves: string[]; actions?: React.ReactNode; + heatmap?: HeatmapCell[] | null; }) { const dict = useDictionary(); const hasMoves = boardMoves.length > 0; @@ -440,7 +474,7 @@ function EmptyCenterPanel({ {actions ?
{actions}
: null}
- +

{dict.dashboard.tipLegend} @@ -454,11 +488,13 @@ function CenterPanel({ color, boardMoves, actions, + heatmap, }: { stats: OpeningStats; color: PlayerColor; boardMoves: string[]; actions?: React.ReactNode; + heatmap?: HeatmapCell[] | null; }) { const dict = useDictionary(); const winPct = stats.gameCount ? stats.playerWins / stats.gameCount : 0; @@ -488,7 +524,7 @@ function CenterPanel({

- +
diff --git a/hooks/use-heatmap.ts b/hooks/use-heatmap.ts new file mode 100644 index 0000000..ee5044c --- /dev/null +++ b/hooks/use-heatmap.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { EcoLookup } from "@/hooks/use-scanner"; +import { + buildHeatmap, + heatmapEpds, + labelHeatmap, + type HeatmapCell, +} from "@/lib/heatmap/heatmap"; +import type { MoveNode } from "@/lib/repertoire/aggregate"; + +/** + * Build the board overlay for the candidates at the current position, with + * their opening names resolved by the worker. + * + * Cells are published in one shot, already labelled. Publishing the unlabelled + * pass first would fade an anonymous overlay in, then fade it straight back out + * when the names landed — a visible double flash on every enable. + */ +export function useHeatmap( + enabled: boolean, + baseMoves: readonly string[], + children: readonly MoveNode[], + currentOpeningName: string | null, + lookupEco: EcoLookup, +): HeatmapCell[] | null { + const [cells, setCells] = useState(null); + + useEffect(() => { + if (!enabled) { + setCells(null); + return; + } + const base = buildHeatmap(baseMoves, children); + if (base.length === 0) { + setCells(null); + return; + } + + let cancelled = false; + lookupEco(heatmapEpds(base)) + .then((labels) => { + if (cancelled) return; + setCells(labelHeatmap(base, labels, currentOpeningName)); + }) + .catch(() => { + // The worker is the only thing that can fail here, and only by being + // torn down mid-flight. Fall back to an unlabelled overlay: the + // frequencies are the point, the names are the garnish. + if (cancelled) return; + setCells(base); + }); + + return () => { + cancelled = true; + }; + }, [enabled, baseMoves, children, currentOpeningName, lookupEco]); + + return cells; +} diff --git a/hooks/use-scanner.ts b/hooks/use-scanner.ts index 94511a2..7eb0221 100644 --- a/hooks/use-scanner.ts +++ b/hooks/use-scanner.ts @@ -2,6 +2,7 @@ import * as Comlink from "comlink"; import { useCallback, useEffect, useRef, useState } from "react"; +import type { EcoLabel } from "@/lib/catalog/eco-classify"; import type { RepertoireStats } from "@/lib/repertoire/aggregate"; import type { ScanParams, @@ -126,6 +127,16 @@ export function useScanner() { } }, []); + /** Name the openings at a batch of EPDs. Routed to the worker so the ~830 kB + * ECO catalog is never imported on the main thread. */ + const lookupEco = useCallback( + async (epds: readonly string[]): Promise<(EcoLabel | null)[]> => { + if (!apiRef.current || epds.length === 0) return []; + return apiRef.current.lookupEco(epds); + }, + [], + ); + const abort = useCallback(() => { scanIdRef.current++; apiRef.current?.abort(); @@ -154,5 +165,10 @@ export function useScanner() { scan, abort, reset, + lookupEco, }; } + +export type EcoLookup = ( + epds: readonly string[], +) => Promise<(EcoLabel | null)[]>; diff --git a/lib/catalog/eco-classify.ts b/lib/catalog/eco-classify.ts index e00ef76..04609d9 100644 --- a/lib/catalog/eco-classify.ts +++ b/lib/catalog/eco-classify.ts @@ -7,6 +7,13 @@ export interface EcoMatch extends EcoRecord { atPly: number; } +/** An opening's identity at one position, without the catalog's move list. */ +export interface EcoLabel { + eco: string; + name: string; + family: string; +} + /** * Classify a game by walking through its SAN moves and returning the deepest * position whose EPD is present in the Lichess ECO database. Returns null @@ -35,3 +42,16 @@ export function classifyByEco(moves: readonly string[]): EcoMatch | null { export function fenToEpd(fen: string): string { return fen.split(" ").slice(0, 4).join(" "); } + +/** + * Name the opening at a single position. Callers that already hold an EPD (the + * heatmap, which needs a label per candidate square) use this instead of + * replaying the game through `classifyByEco`. + * + * Keep this in the worker. `eco-data` is a ~830 kB object literal, so importing + * it costs a synchronous parse on whatever thread touches it — see + * `ScannerAPI.lookupEco`, which is the only way the main thread reaches it. + */ +export function lookupEcoByEpd(epd: string): EcoRecord | null { + return ECO_BY_EPD[epd] ?? null; +} diff --git a/lib/heatmap/heatmap.ts b/lib/heatmap/heatmap.ts new file mode 100644 index 0000000..b76b74c --- /dev/null +++ b/lib/heatmap/heatmap.ts @@ -0,0 +1,218 @@ +import { Chess } from "chess.js"; +import { fenToEpd, type EcoLabel } from "@/lib/catalog/eco-classify"; +import type { MoveNode } from "@/lib/repertoire/aggregate"; + +/** + * One board square, carrying every candidate move that lands on it. + * + * Squares — not moves — are the unit here, because the overlay paints squares. + * Several legal moves routinely share a destination (`Nf3` and `f3` both land + * on f3 from the very starting position; so do `Nxe5`/`dxe5` and `Rae1`/`Rfe1` + * later on), so a cell aggregates them. Anything that kept only one move per + * square would drop the others' games and leave the shares short of 100%. + */ +export interface HeatmapCell { + /** Destination square in algebraic notation, e.g. "e4". Castling resolves to + * the king's destination (g1/c1/g8/c8). */ + square: string; + /** Every SAN landing here, most-played first. Usually one. */ + sans: string[]; + /** Games across all of `sans`. */ + count: number; + /** EPD after the most-played move landing here — what `openingName` names. */ + epd: string; + /** count / max(count) over significant cells; drives the tint. */ + intensity: number; + /** count / sum(counts), shown as the percentage label. */ + share: number; + /** Opening reached by the most-played move, once labels resolve. */ + openingName: string | null; + /** Whether this cell carries enough games to show the colored tint. */ + significant: boolean; +} + +/** + * A cell must clear BOTH bars to be tinted. + * + * The share bar keeps the gradient readable. The absolute floor is the one that + * matters: without it a node holding 2 games paints a 50%-share square in + * saturated red, which is exactly the "tiny sample dressed as signal" that + * `confidence-badge` exists to prevent. Below either bar the label still + * renders, just untinted. + */ +const MIN_SIGNIFICANT_SHARE = 0.03; +const MIN_SIGNIFICANT_GAMES = 5; + +/** + * Build the per-square overlay for the candidate next moves at a position. + * + * Pure and synchronous: it resolves geometry, counts and EPDs, but leaves + * `openingName` null. Names come from the worker (which owns the ECO catalog) + * and are applied afterwards by `labelHeatmap`. + */ +export function buildHeatmap( + baseMoves: readonly string[], + children: readonly MoveNode[], +): HeatmapCell[] { + if (children.length === 0) return []; + + const chess = new Chess(); + for (const m of baseMoves) { + try { + chess.move(m); + } catch { + return []; + } + } + + // Group candidates by destination square, most-played move first so the + // square inherits its EPD (and therefore its opening name) from the move a + // reader would assume it means. + const bySquare = new Map(); + for (const child of [...children].sort((a, b) => b.count - a.count)) { + let move; + try { + move = chess.move(child.san); + } catch { + continue; + } + if (!move) continue; + const epd = fenToEpd(chess.fen()); + chess.undo(); + + const cell = bySquare.get(move.to); + if (cell) { + cell.sans.push(child.san); + cell.count += child.count; + } else { + bySquare.set(move.to, { + sans: [child.san], + count: child.count, + epd, + }); + } + } + + const cells = [...bySquare.entries()]; + const sumCounts = cells.reduce((acc, [, c]) => acc + c.count, 0); + + const isSignificant = (count: number) => + count >= MIN_SIGNIFICANT_GAMES && + sumCounts > 0 && + count / sumCounts >= MIN_SIGNIFICANT_SHARE; + + // The tint is a *relative* scale: the most-played significant square is + // always the hot end, whatever its absolute share. That keeps the gradient + // legible on flat distributions, at the cost of not being comparable across + // positions — the percentage label carries the absolute magnitude. + const maxSignificantCount = cells.reduce( + (acc, [, c]) => (isSignificant(c.count) ? Math.max(acc, c.count) : acc), + 0, + ); + + return cells.map(([square, c]) => { + const significant = isSignificant(c.count); + return { + square, + sans: c.sans, + count: c.count, + epd: c.epd, + share: sumCounts > 0 ? c.count / sumCounts : 0, + significant, + intensity: + significant && maxSignificantCount > 0 + ? c.count / maxSignificantCount + : 0, + openingName: null, + }; + }); +} + +/** The EPDs `labelHeatmap` needs names for, in order. */ +export function heatmapEpds(cells: readonly HeatmapCell[]): string[] { + return cells.map((c) => c.epd); +} + +/** + * Attach opening names to cells. `labels` is positional against + * `heatmapEpds(cells)`. + */ +export function labelHeatmap( + cells: readonly HeatmapCell[], + labels: readonly (EcoLabel | null)[], + currentOpeningName: string | null, +): HeatmapCell[] { + // When every *named* candidate sits in the same family, that family is + // already implied by the position — strip it so each square shows only the + // part that differentiates it. Needs at least two names to be a shared + // prefix at all: with one, stripping would delete the only label there is. + const families = new Set( + labels.filter((l): l is EcoLabel => l !== null).map((l) => l.family), + ); + const named = labels.filter(Boolean).length; + const sharedFamily = named >= 2 && families.size === 1 ? [...families][0] : null; + + return cells.map((cell, i) => ({ + ...cell, + openingName: pickOpeningLabel( + labels[i] ?? null, + sharedFamily, + currentOpeningName, + ), + })); +} + +/** + * Pick the most useful label for a cell: the variation suffix when every + * candidate shares a family, the full ECO name otherwise. Null when the label + * would only restate the position the reader is already looking at. + */ +function pickOpeningLabel( + label: EcoLabel | null, + sharedFamily: string | null, + currentOpeningName: string | null, +): string | null { + if (!label) return null; + if (sharedFamily) { + // No suffix means this candidate IS the bare family — nothing to add when + // every sibling already implies it. + return variationSuffix(label.name, sharedFamily); + } + return label.name !== currentOpeningName ? label.name : null; +} + +/** + * Strip the family prefix from a full ECO name. Names in the Lichess catalog + * follow patterns like `"Sicilian Defense"`, `"Sicilian Defense, Najdorf"`, + * or `"Italian Game: Evans Gambit, Tartakower Attack"` — we try the common + * separators in order and return what's left. + */ +function variationSuffix(name: string, family: string): string | null { + if (name === family) return null; + for (const sep of [", ", ": ", " "]) { + const prefix = family + sep; + if (name.startsWith(prefix)) { + const suffix = name.slice(prefix.length).trim(); + return suffix.length > 0 ? suffix : null; + } + } + return null; +} + +/** + * Overlay color for a cell, cold-to-hot: blue → purple → red, the short way + * around the hue wheel (skipping green and yellow, which would fight the warm + * wood board). + * + * Hue alone is a poor carrier — it's not perceptually ordered and it collapses + * for red/blue color deficiency — so lightness and alpha ramp alongside it. The + * hot end is darker, more opaque and more saturated, which survives grayscale + * and lets the wood grain show through at the cold end. + */ +export function heatmapOverlayColor(intensity: number): string { + const t = Math.max(0, Math.min(1, intensity)); + const hue = 230 + 130 * t; + const lightness = 52 - 14 * t; + const alpha = 0.62 + 0.3 * t; + return `hsl(${hue.toFixed(1)} 75% ${lightness.toFixed(1)}% / ${alpha.toFixed(2)})`; +} diff --git a/lib/i18n/dictionaries/de.json b/lib/i18n/dictionaries/de.json index 222bca7..0b04f94 100644 --- a/lib/i18n/dictionaries/de.json +++ b/lib/i18n/dictionaries/de.json @@ -210,5 +210,9 @@ "noGamesMatched": "Keine Partien entsprechen den Filtern.", "widenTryHint": "Versuche den Zeitraum zu erweitern, „nur gewertet“ zu deaktivieren oder die Plattform zu wechseln." }, + "heatmap": { + "label": "Heatmap", + "tooltip": "Häufigkeit des nächsten Zugs auf jedem Feld einblenden" + }, "loading": "Lädt…" } diff --git a/lib/i18n/dictionaries/en.json b/lib/i18n/dictionaries/en.json index 9e30da4..d1115c6 100644 --- a/lib/i18n/dictionaries/en.json +++ b/lib/i18n/dictionaries/en.json @@ -212,5 +212,9 @@ "noGamesMatched": "No games matched the filters.", "widenTryHint": "Try widening the time-window, toggling rated-only off, or switching platform." }, + "heatmap": { + "label": "Heatmap", + "tooltip": "Overlay next-move frequency on each square" + }, "loading": "Loading…" } diff --git a/lib/i18n/dictionaries/es.json b/lib/i18n/dictionaries/es.json index 402a24f..d98a715 100644 --- a/lib/i18n/dictionaries/es.json +++ b/lib/i18n/dictionaries/es.json @@ -210,5 +210,9 @@ "noGamesMatched": "Ninguna partida coincide con los filtros.", "widenTryHint": "Prueba ampliando el periodo, desactivando «solo con rating» o cambiando de plataforma." }, + "heatmap": { + "label": "Mapa de calor", + "tooltip": "Mostrar la frecuencia del siguiente movimiento en cada casilla" + }, "loading": "Cargando…" } diff --git a/lib/i18n/dictionaries/fr.json b/lib/i18n/dictionaries/fr.json index 5f6fd05..0049ec9 100644 --- a/lib/i18n/dictionaries/fr.json +++ b/lib/i18n/dictionaries/fr.json @@ -210,5 +210,9 @@ "noGamesMatched": "Aucune partie ne correspond aux filtres.", "widenTryHint": "Essayez d'élargir la période, de désactiver « parties classées uniquement » ou de changer de plateforme." }, + "heatmap": { + "label": "Carte de chaleur", + "tooltip": "Afficher la fréquence du prochain coup sur chaque case" + }, "loading": "Chargement…" } diff --git a/lib/i18n/dictionaries/it.json b/lib/i18n/dictionaries/it.json index 620fb7e..f220322 100644 --- a/lib/i18n/dictionaries/it.json +++ b/lib/i18n/dictionaries/it.json @@ -210,5 +210,9 @@ "noGamesMatched": "Nessuna partita corrisponde ai filtri.", "widenTryHint": "Prova ad ampliare il periodo, disattivare «solo con rating» o cambiare piattaforma." }, + "heatmap": { + "label": "Mappa di calore", + "tooltip": "Mostra la frequenza della mossa successiva su ogni casella" + }, "loading": "Caricamento…" } diff --git a/lib/i18n/dictionaries/ja.json b/lib/i18n/dictionaries/ja.json index 564109a..0712551 100644 --- a/lib/i18n/dictionaries/ja.json +++ b/lib/i18n/dictionaries/ja.json @@ -210,5 +210,9 @@ "noGamesMatched": "フィルターに一致する対局がありません。", "widenTryHint": "期間を広げたり、レート戦のみをオフにしたり、プラットフォームを切り替えてみてください。" }, + "heatmap": { + "label": "ヒートマップ", + "tooltip": "各マスに次の手の出現頻度を表示" + }, "loading": "読み込み中…" } diff --git a/lib/i18n/dictionaries/nl.json b/lib/i18n/dictionaries/nl.json index fae3ac8..76c714f 100644 --- a/lib/i18n/dictionaries/nl.json +++ b/lib/i18n/dictionaries/nl.json @@ -210,5 +210,9 @@ "noGamesMatched": "Geen partijen passen bij de filters.", "widenTryHint": "Probeer de periode te verruimen, \"alleen met rating\" uit te zetten of van platform te wisselen." }, + "heatmap": { + "label": "Heatmap", + "tooltip": "Toon de frequentie van de volgende zet per veld" + }, "loading": "Laden…" } diff --git a/lib/i18n/dictionaries/pl.json b/lib/i18n/dictionaries/pl.json index d3b9929..fbdc030 100644 --- a/lib/i18n/dictionaries/pl.json +++ b/lib/i18n/dictionaries/pl.json @@ -210,5 +210,9 @@ "noGamesMatched": "Żadne partie nie pasują do filtrów.", "widenTryHint": "Spróbuj rozszerzyć okres, wyłączyć „tylko rankingowe” lub zmienić platformę." }, + "heatmap": { + "label": "Mapa cieplna", + "tooltip": "Pokaż częstość następnego ruchu na każdym polu" + }, "loading": "Ładowanie…" } diff --git a/lib/i18n/dictionaries/pt-BR.json b/lib/i18n/dictionaries/pt-BR.json index e87eb5f..36fa8fa 100644 --- a/lib/i18n/dictionaries/pt-BR.json +++ b/lib/i18n/dictionaries/pt-BR.json @@ -210,5 +210,9 @@ "noGamesMatched": "Nenhuma partida corresponde aos filtros.", "widenTryHint": "Tente ampliar o período, desativar \"apenas valendo rating\" ou trocar de plataforma." }, + "heatmap": { + "label": "Mapa de calor", + "tooltip": "Mostrar a frequência do próximo lance em cada casa" + }, "loading": "Carregando…" } diff --git a/lib/i18n/dictionaries/ru.json b/lib/i18n/dictionaries/ru.json index 8f4bb6e..deb61b4 100644 --- a/lib/i18n/dictionaries/ru.json +++ b/lib/i18n/dictionaries/ru.json @@ -210,5 +210,9 @@ "noGamesMatched": "Нет партий, соответствующих фильтрам.", "widenTryHint": "Попробуйте расширить период, отключить «только рейтинговые» или сменить платформу." }, + "heatmap": { + "label": "Тепловая карта", + "tooltip": "Показывать частоту следующего хода на каждой клетке" + }, "loading": "Загрузка…" } diff --git a/lib/i18n/dictionaries/tr.json b/lib/i18n/dictionaries/tr.json index e6c7b39..d3c9ba8 100644 --- a/lib/i18n/dictionaries/tr.json +++ b/lib/i18n/dictionaries/tr.json @@ -210,5 +210,9 @@ "noGamesMatched": "Filtrelerle eşleşen oyun yok.", "widenTryHint": "Aralığı genişletmeyi, \"yalnızca dereceli\" seçeneğini kapatmayı veya platformu değiştirmeyi deneyin." }, + "heatmap": { + "label": "Isı haritası", + "tooltip": "Her karede sonraki hamlenin sıklığını göster" + }, "loading": "Yükleniyor…" } diff --git a/lib/i18n/dictionaries/uk.json b/lib/i18n/dictionaries/uk.json index 6b04474..1bdea44 100644 --- a/lib/i18n/dictionaries/uk.json +++ b/lib/i18n/dictionaries/uk.json @@ -210,5 +210,9 @@ "noGamesMatched": "Жодна партія не відповідає фільтрам.", "widenTryHint": "Спробуйте розширити період, вимкнути «тільки рейтингові партії» або змінити платформу." }, + "heatmap": { + "label": "Теплова карта", + "tooltip": "Показувати частоту наступного ходу на кожній клітинці" + }, "loading": "Завантаження…" } diff --git a/lib/i18n/dictionaries/zh-CN.json b/lib/i18n/dictionaries/zh-CN.json index a89bfb0..faa8ac0 100644 --- a/lib/i18n/dictionaries/zh-CN.json +++ b/lib/i18n/dictionaries/zh-CN.json @@ -210,5 +210,9 @@ "noGamesMatched": "没有对局匹配筛选条件。", "widenTryHint": "尝试放宽时段、关闭“仅限有等级分的对局”或切换平台。" }, + "heatmap": { + "label": "热力图", + "tooltip": "在每个方格上显示下一步着法的出现频率" + }, "loading": "加载中…" } diff --git a/lib/i18n/dictionary.ts b/lib/i18n/dictionary.ts index 0819f91..1fd2196 100644 --- a/lib/i18n/dictionary.ts +++ b/lib/i18n/dictionary.ts @@ -202,6 +202,10 @@ export interface Dictionary { noGamesMatched: string; widenTryHint: string; }; + heatmap: { + label: string; + tooltip: string; + }; loading: string; } diff --git a/workers/scanner.worker.ts b/workers/scanner.worker.ts index c513fa6..4c77529 100644 --- a/workers/scanner.worker.ts +++ b/workers/scanner.worker.ts @@ -1,5 +1,6 @@ /// import * as Comlink from "comlink"; +import { lookupEcoByEpd, type EcoLabel } from "@/lib/catalog/eco-classify"; import { streamChessComGames } from "@/lib/sources/chesscom"; import { streamLichessGames } from "@/lib/sources/lichess"; import { @@ -160,6 +161,19 @@ const api = { abort() { currentController?.abort(); }, + + /** + * Name the openings reached at a batch of EPDs. The heatmap needs a label per + * candidate square (~20 at a time), and this keeps that lookup on the side of + * the wire that already holds the ECO catalog — importing `eco-data` on the + * main thread would parse a second ~830 kB copy of it there. + */ + lookupEco(epds: readonly string[]): (EcoLabel | null)[] { + return epds.map((epd) => { + const rec = lookupEcoByEpd(epd); + return rec ? { eco: rec.eco, name: rec.name, family: rec.family } : null; + }); + }, }; export type ScannerAPI = typeof api;