(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;