diff --git a/components/chess/chess-board.tsx b/components/chess/chess-board.tsx index 97dc65a..8b3b079 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, useRef, 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,130 @@ 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]); + // The "displayed" cells lag behind incoming heatmap props by one fade-out + // step so the user sees the previous heatmap fade out before the new one + // fades in. `phase` drives the opacity of the overlay/label divs via CSS + // transition. + // + // We keep a ref alongside the state so the effect can read the current + // displayed value without listing it as a dependency. Listing it caused a + // bug: setting `displayed` inside the effect changed the dep, triggering an + // immediate cleanup that cancelled the pending `requestAnimationFrame` for + // phase="in" — so the heatmap stuck at opacity 0 after the first re-enable. + const [displayed, setDisplayed] = useState( + heatmap ?? null, + ); + const displayedRef = useRef(displayed); + const [phase, setPhase] = useState<"in" | "out">("in"); + + useEffect(() => { + if (heatmap === displayedRef.current) return; + + // First-paint enable (also fires on every re-enable after a previous + // disable): swap in the new cells immediately at opacity 0, then fade + // them up on the next frame so the CSS transition has a starting point. + if (!displayedRef.current && heatmap) { + displayedRef.current = heatmap; + setDisplayed(heatmap); + setPhase("out"); + const raf = requestAnimationFrame(() => setPhase("in")); + return () => cancelAnimationFrame(raf); + } + + // Otherwise: fade out → swap → fade in. + setPhase("out"); + const t = setTimeout(() => { + displayedRef.current = heatmap ?? null; + setDisplayed(heatmap ?? null); + setPhase("in"); + }, HEATMAP_FADE_MS); + return () => clearTimeout(t); + }, [heatmap]); + + const heatmapBySquare = useMemo(() => { + const map = new Map(); + if (displayed) { + for (const cell of displayed) { + // If two SANs land on the same square (rare; chess.js disambiguates + // SANs already), the most-played wins. + const existing = map.get(cell.square); + if (!existing || cell.count > existing.count) { + map.set(cell.square, cell); + } + } + } + return map; + }, [displayed]); + + const squareRenderer = useMemo(() => { + if (heatmapBySquare.size === 0) return undefined; + const visible = phase === "in" ? 1 : 0; + 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, phase]); + return (
@@ -55,9 +171,147 @@ 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, + opacity, + square, +}: { + cell: HeatmapCell; + opacity: number; + 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 saturated blue→red — the + // existing palette guarantees AA contrast at lightness 40%. + // Untinted (insignificant) cells: pick a label colour that reads against + // the raw board square below (warm cream or warm brown). + 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..a8e19aa --- /dev/null +++ b/components/chess/heatmap-toggle.tsx @@ -0,0 +1,47 @@ +"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; + disabled?: boolean; +} + +export function HeatmapToggle({ + checked, + onCheckedChange, + disabled, +}: HeatmapToggleProps) { + const dict = useDictionary(); + return ( + + ); +} diff --git a/components/scanner/dashboard.tsx b/components/scanner/dashboard.tsx index af16e72..4fddd85 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, 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"; @@ -13,6 +14,8 @@ import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group"; +import { useEcoLookup } from "@/hooks/use-eco-lookup"; +import { buildHeatmap, type HeatmapCell } from "@/lib/heatmap/heatmap"; import { buildGlobalTree, type MoveNode, @@ -38,6 +41,8 @@ export function Dashboard({ stats }: DashboardProps) { }, [stats.colorBreakdown]); const [color, setColor] = useState(availableColors[0] ?? "white"); + const [heatmapEnabled, setHeatmapEnabled] = useState(false); + const ecoLookup = useEcoLookup(heatmapEnabled); const { selectedId, path, @@ -66,8 +71,15 @@ export function Dashboard({ stats }: DashboardProps) { }, [color, clearOpening]); const selected = selectedId ? stats.byOpening[selectedId] : null; - const baseMoves = selected?.entry?.moves ?? []; - const boardMoves = previewMoves ?? [...baseMoves, ...path]; + const selectedMoves = selected?.entry?.moves; + const baseMoves = useMemo( + () => selectedMoves ?? [], + [selectedMoves], + ); + 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 @@ -116,6 +128,22 @@ export function Dashboard({ stats }: DashboardProps) { const focusedSan = currentChildren[focusIndex]?.san ?? null; + const heatmapCells = useMemo(() => { + if (!heatmapEnabled) return null; + return buildHeatmap( + boardMoves, + currentChildren, + ecoLookup, + selected?.entry?.name ?? null, + ); + }, [ + heatmapEnabled, + boardMoves, + currentChildren, + ecoLookup, + selected?.entry?.name, + ]); + // Arrow-key navigation through the continuation tree. // ← pop one ply // → push the focused continuation (defaults to the most-played one) @@ -226,26 +254,35 @@ export function Dashboard({ stats }: DashboardProps) {
) : ( (() => { - const exportAction = ( - + const boardActions = ( +
+ + +
); return selected ? ( ) : ( ); })() @@ -355,10 +392,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; @@ -378,7 +417,7 @@ function EmptyCenterPanel({ {actions ?
{actions}
: null}
- +

{dict.dashboard.tipLegend} @@ -392,11 +431,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; @@ -426,7 +467,7 @@ function CenterPanel({

- +
diff --git a/docs/heatmap/01-dashboard-default.png b/docs/heatmap/01-dashboard-default.png new file mode 100644 index 0000000..b695116 Binary files /dev/null and b/docs/heatmap/01-dashboard-default.png differ diff --git a/docs/heatmap/02-heatmap-initial.png b/docs/heatmap/02-heatmap-initial.png new file mode 100644 index 0000000..c5c1287 Binary files /dev/null and b/docs/heatmap/02-heatmap-initial.png differ diff --git a/docs/heatmap/03-board-initial-closeup.png b/docs/heatmap/03-board-initial-closeup.png new file mode 100644 index 0000000..eadcc3b Binary files /dev/null and b/docs/heatmap/03-board-initial-closeup.png differ diff --git a/docs/heatmap/04-heatmap-mid-opening.png b/docs/heatmap/04-heatmap-mid-opening.png new file mode 100644 index 0000000..953c985 Binary files /dev/null and b/docs/heatmap/04-heatmap-mid-opening.png differ diff --git a/docs/heatmap/05-board-mid-closeup.png b/docs/heatmap/05-board-mid-closeup.png new file mode 100644 index 0000000..daf1307 Binary files /dev/null and b/docs/heatmap/05-board-mid-closeup.png differ diff --git a/docs/heatmap/06-board-heatmap-off.png b/docs/heatmap/06-board-heatmap-off.png new file mode 100644 index 0000000..2ad1630 Binary files /dev/null and b/docs/heatmap/06-board-heatmap-off.png differ diff --git a/hooks/use-eco-lookup.ts b/hooks/use-eco-lookup.ts new file mode 100644 index 0000000..eef50e3 --- /dev/null +++ b/hooks/use-eco-lookup.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export interface EcoLookupResult { + name: string; + family: string; +} + +export type EcoLookup = (epd: string) => EcoLookupResult | null; + +/** + * Lazy-load the ECO catalog (~800kB module) only when the caller needs it, + * e.g. when the heatmap toggle is first flipped on. Subsequent calls return + * the cached lookup synchronously via the bundler's module cache. + */ +export function useEcoLookup(enabled: boolean): EcoLookup | null { + const [lookup, setLookup] = useState(null); + + useEffect(() => { + if (!enabled || lookup) return; + let cancelled = false; + import("@/lib/catalog/eco-data").then(({ ECO_BY_EPD }) => { + if (cancelled) return; + setLookup(() => (epd: string) => { + const rec = ECO_BY_EPD[epd]; + return rec ? { name: rec.name, family: rec.family } : null; + }); + }); + return () => { + cancelled = true; + }; + }, [enabled, lookup]); + + return lookup; +} diff --git a/lib/heatmap/heatmap.ts b/lib/heatmap/heatmap.ts new file mode 100644 index 0000000..51ebfca --- /dev/null +++ b/lib/heatmap/heatmap.ts @@ -0,0 +1,162 @@ +import { Chess } from "chess.js"; +import { fenToEpd } from "@/lib/catalog/eco-classify"; +import type { EcoLookup, EcoLookupResult } from "@/hooks/use-eco-lookup"; +import type { MoveNode } from "@/lib/repertoire/aggregate"; + +export interface HeatmapCell { + /** Destination square in algebraic notation, e.g. "e4". */ + square: string; + san: string; + count: number; + /** count / max(counts) over significant cells; drives the hue. */ + intensity: number; + /** count / sum(counts), shown as the percentage label. */ + share: number; + /** Name of the ECO opening reached after playing this move, when known. */ + openingName: string | null; + /** Whether this cell's share is large enough to show the colored tint. */ + significant: boolean; +} + +/** + * Below this share-of-games, a continuation is treated as anecdotal and the + * cell drops the colored tint (the label still renders, but with a font color + * tuned to the underlying board square instead of the heatmap palette). + */ +const MIN_SIGNIFICANT_SHARE = 0.03; + +/** + * Build per-square heatmap stats for the candidate next moves at the current + * board position. Cells are keyed by the move's destination square — castling + * resolves to the king's destination square (g1/c1/g8/c8), which lines up + * with how players think of "where the move went". + */ +export function buildHeatmap( + baseMoves: readonly string[], + children: readonly MoveNode[], + ecoLookup: EcoLookup | null, + currentOpeningName: string | null, +): HeatmapCell[] { + if (children.length === 0) return []; + + const chess = new Chess(); + for (const m of baseMoves) { + try { + chess.move(m); + } catch { + return []; + } + } + + const sumCounts = children.reduce((acc, c) => acc + c.count, 0); + // Intensity (used to pick the hue) is normalised against the most-played + // significant continuation so that one runaway popular move doesn't compress + // the rest of the gradient. Insignificant cells fall back to intensity=0. + const maxSignificantCount = children.reduce((acc, c) => { + const share = sumCounts > 0 ? c.count / sumCounts : 0; + return share >= MIN_SIGNIFICANT_SHARE ? Math.max(acc, c.count) : acc; + }, 0); + + // First pass: walk the candidates, collect raw ECO lookups per cell. We + // need to know the full set of (name, family) pairs before we can decide + // whether to prefer variation suffixes over full names. + type Raw = Omit & { + eco: EcoLookupResult | null; + }; + const raw: Raw[] = []; + for (const child of children) { + let move; + try { + move = chess.move(child.san); + } catch { + continue; + } + if (!move) continue; + + const epd = fenToEpd(chess.fen()); + const eco = ecoLookup ? ecoLookup(epd) : null; + chess.undo(); + + const total = child.count; + const share = sumCounts > 0 ? total / sumCounts : 0; + const significant = share >= MIN_SIGNIFICANT_SHARE; + raw.push({ + san: child.san, + square: move.to, + count: total, + share, + significant, + intensity: + significant && maxSignificantCount > 0 + ? total / maxSignificantCount + : 0, + eco, + }); + } + + // If every continuation with an ECO match shares the same family, the + // family name is redundant on every cell — strip it down to the variation + // suffix so each square shows the differentiating part instead. + const families = new Set(raw.map((r) => r.eco?.family).filter(Boolean)); + const sharedFamily = families.size === 1 ? [...families][0] : null; + + return raw.map(({ eco, ...rest }) => ({ + ...rest, + openingName: pickOpeningLabel(eco, sharedFamily, currentOpeningName), + })); +} + +/** + * Pick the most useful label for a heatmap cell: the variation suffix when + * every candidate shares a family, the full ECO name otherwise. Falls back + * to null when the resulting label would be redundant with the current + * position's opening, or when no ECO match exists. + */ +function pickOpeningLabel( + eco: EcoLookupResult | null, + sharedFamily: string | null | undefined, + currentOpeningName: string | null, +): string | null { + if (!eco) return null; + if (sharedFamily) { + const suffix = variationSuffix(eco.name, sharedFamily); + if (suffix) return suffix; + // No suffix means this candidate IS the bare family — nothing useful to + // add when every other cell already implies it. + return null; + } + return eco.name !== currentOpeningName ? eco.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 heatmap cell on a straight cold-to-hot scale: saturated + * blue → purple → red, the short way around the hue wheel (skipping green and + * yellow, which would clash with the warm wood board). Saturation, lightness + * and alpha are held constant so white label text keeps the same legibility + * across the whole gradient — only the hue carries the signal. + */ +export function heatmapOverlayColor(intensity: number): string { + const clamped = Math.max(0, Math.min(1, intensity)); + const hue = (230 + 130 * clamped) % 360; + // Lightness held at 40% so white label text clears WCAG AA contrast across + // the whole hue range — pure red at 45%+ would drop the red end below AA. + return `hsl(${hue.toFixed(1)}, 75%, 40%)`; +} diff --git a/lib/i18n/dictionaries/de.json b/lib/i18n/dictionaries/de.json index b12c83d..511c01c 100644 --- a/lib/i18n/dictionaries/de.json +++ b/lib/i18n/dictionaries/de.json @@ -196,5 +196,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 fc4bad3..92ecc19 100644 --- a/lib/i18n/dictionaries/en.json +++ b/lib/i18n/dictionaries/en.json @@ -198,5 +198,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 87311aa..2ef5d26 100644 --- a/lib/i18n/dictionaries/es.json +++ b/lib/i18n/dictionaries/es.json @@ -196,5 +196,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 0922b90..c3f625f 100644 --- a/lib/i18n/dictionaries/fr.json +++ b/lib/i18n/dictionaries/fr.json @@ -196,5 +196,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 da98491..d4093f7 100644 --- a/lib/i18n/dictionaries/it.json +++ b/lib/i18n/dictionaries/it.json @@ -196,5 +196,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 a0ad3c2..0831516 100644 --- a/lib/i18n/dictionaries/ja.json +++ b/lib/i18n/dictionaries/ja.json @@ -196,5 +196,9 @@ "noGamesMatched": "フィルターに一致する対局がありません。", "widenTryHint": "期間を広げたり、レート戦のみをオフにしたり、プラットフォームを切り替えてみてください。" }, + "heatmap": { + "label": "ヒートマップ", + "tooltip": "各マスに次の手の出現頻度を表示" + }, "loading": "読み込み中…" } diff --git a/lib/i18n/dictionaries/nl.json b/lib/i18n/dictionaries/nl.json index 977928d..f962f9c 100644 --- a/lib/i18n/dictionaries/nl.json +++ b/lib/i18n/dictionaries/nl.json @@ -196,5 +196,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 9080365..e12a1fd 100644 --- a/lib/i18n/dictionaries/pl.json +++ b/lib/i18n/dictionaries/pl.json @@ -196,5 +196,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 d8fed93..f7ca31a 100644 --- a/lib/i18n/dictionaries/pt-BR.json +++ b/lib/i18n/dictionaries/pt-BR.json @@ -196,5 +196,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 bece306..b2b1b2c 100644 --- a/lib/i18n/dictionaries/ru.json +++ b/lib/i18n/dictionaries/ru.json @@ -196,5 +196,9 @@ "noGamesMatched": "Нет партий, соответствующих фильтрам.", "widenTryHint": "Попробуйте расширить период, отключить «только рейтинговые» или сменить платформу." }, + "heatmap": { + "label": "Тепловая карта", + "tooltip": "Показывать частоту следующего хода на каждой клетке" + }, "loading": "Загрузка…" } diff --git a/lib/i18n/dictionaries/tr.json b/lib/i18n/dictionaries/tr.json index ec91d35..42bebcf 100644 --- a/lib/i18n/dictionaries/tr.json +++ b/lib/i18n/dictionaries/tr.json @@ -196,5 +196,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 cf77cc1..75d736a 100644 --- a/lib/i18n/dictionaries/uk.json +++ b/lib/i18n/dictionaries/uk.json @@ -196,5 +196,9 @@ "noGamesMatched": "Жодна партія не відповідає фільтрам.", "widenTryHint": "Спробуйте розширити період, вимкнути «тільки рейтингові партії» або змінити платформу." }, + "heatmap": { + "label": "Теплова карта", + "tooltip": "Показувати частоту наступного ходу на кожній клітинці" + }, "loading": "Завантаження…" } diff --git a/lib/i18n/dictionaries/zh-CN.json b/lib/i18n/dictionaries/zh-CN.json index 9d1b47c..6691ece 100644 --- a/lib/i18n/dictionaries/zh-CN.json +++ b/lib/i18n/dictionaries/zh-CN.json @@ -196,5 +196,9 @@ "noGamesMatched": "没有对局匹配筛选条件。", "widenTryHint": "尝试放宽时段、关闭“仅限有等级分的对局”或切换平台。" }, + "heatmap": { + "label": "热力图", + "tooltip": "在每个方格上显示下一步着法的出现频率" + }, "loading": "加载中…" } diff --git a/lib/i18n/dictionary.ts b/lib/i18n/dictionary.ts index 7488aac..582753d 100644 --- a/lib/i18n/dictionary.ts +++ b/lib/i18n/dictionary.ts @@ -188,6 +188,10 @@ export interface Dictionary { noGamesMatched: string; widenTryHint: string; }; + heatmap: { + label: string; + tooltip: string; + }; loading: string; }