From c2287879d2bb8c4f3d5f48e336b5c36a2305d747 Mon Sep 17 00:00:00 2001 From: haoc Date: Tue, 7 Jul 2026 23:31:02 +0800 Subject: [PATCH 1/9] feat: add territory judgement overlay --- src/main/lib/types.ts | 4 + src/main/services/katago.ts | 8 +- src/renderer/src/App.tsx | 186 +++++++++- src/renderer/src/features/board/GoBoardV2.tsx | 75 +++- .../board/TerritoryJudgementPanel.tsx | 145 ++++++++ .../src/features/board/WinrateTimelineV2.tsx | 2 +- src/renderer/src/features/board/board-v2.css | 54 +++ .../src/features/board/territoryJudgement.ts | 235 ++++++++++++ .../src/features/gallery/UiGallery.tsx | 21 ++ .../src/features/gallery/ui-gallery.css | 43 +++ .../src/features/gallery/uiGalleryMock.ts | 14 + src/renderer/src/i18n.ts | 40 ++- src/renderer/src/styles.css | 334 ++++++++++++++++++ tests/mouse-wheel-replay-contract.test.mjs | 24 ++ tests/territory-judgement-contract.test.mjs | 67 ++++ 15 files changed, 1243 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/features/board/TerritoryJudgementPanel.tsx create mode 100644 src/renderer/src/features/board/territoryJudgement.ts create mode 100644 tests/mouse-wheel-replay-contract.test.mjs create mode 100644 tests/territory-judgement-contract.test.mjs diff --git a/src/main/lib/types.ts b/src/main/lib/types.ts index 844c0fd..01bf951 100644 --- a/src/main/lib/types.ts +++ b/src/main/lib/types.ts @@ -849,11 +849,15 @@ export interface KataGoMoveAnalysis { before: { winrate: number scoreLead: number + ownership?: number[] + ownershipStdev?: number[] topMoves: KataGoCandidate[] } after: { winrate: number scoreLead: number + ownership?: number[] + ownershipStdev?: number[] topMoves: KataGoCandidate[] } playedMove?: { diff --git a/src/main/services/katago.ts b/src/main/services/katago.ts index 15f1311..1715ffd 100644 --- a/src/main/services/katago.ts +++ b/src/main/services/katago.ts @@ -39,6 +39,8 @@ interface KataGoResponse { scoreStdev?: number utility?: number scoreMean?: number + ownership?: number[] + ownershipStdev?: number[] } moveInfos?: Array<{ move?: string @@ -265,7 +267,7 @@ function responseSideToMove(response: KataGoResponse, fallback: GameMove['color' : fallback } -function root(response: KataGoResponse, sideToMove: GameMove['color']): { winrate: number; scoreLead: number } { +function root(response: KataGoResponse, sideToMove: GameMove['color']): { winrate: number; scoreLead: number; ownership?: number[]; ownershipStdev?: number[] } { if (!response.rootInfo) { throw new Error(`KataGo 没有返回 rootInfo${response.error ? `: ${response.error}` : ''}`) } @@ -274,7 +276,9 @@ function root(response: KataGoResponse, sideToMove: GameMove['color']): { winrat const rawScoreLead = Number(response.rootInfo.scoreLead ?? response.rootInfo.scoreMean ?? 0) return { winrate: blackWinrateFromSideToMove(rawWinrate, actualSideToMove), - scoreLead: blackScoreLeadFromSideToMove(rawScoreLead, actualSideToMove) + scoreLead: blackScoreLeadFromSideToMove(rawScoreLead, actualSideToMove), + ownership: Array.isArray(response.rootInfo.ownership) ? response.rootInfo.ownership.map(Number) : undefined, + ownershipStdev: Array.isArray(response.rootInfo.ownershipStdev) ? response.rootInfo.ownershipStdev.map(Number) : undefined } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 62a5b84..6da92c0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import type { FormEvent, KeyboardEvent, PointerEvent, ReactElement, ReactNode } from 'react' +import type { FormEvent, KeyboardEvent, PointerEvent, ReactElement, ReactNode, WheelEvent } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AnalyzeGameQuickProgress, @@ -42,8 +42,10 @@ import lizzieWhiteStoneUrl from './assets/lizzie/white.png' import logoUrl from '../../../assets/logo.png' import { GoBoardV2 } from './features/board/GoBoardV2' import type { KeyMoveSummary } from './features/board/KeyMoveNavigator' +import { TerritoryControlPanel, TerritorySummaryStrip } from './features/board/TerritoryJudgementPanel' import { WinrateTimelineV2 } from './features/board/WinrateTimelineV2' import { boardPointLabel, parseBoardPoint, type BoardPoint, type RenderKeyMove } from './features/board/boardGeometry' +import { buildTerritoryJudgement, type TerritoryDisplayMode } from './features/board/territoryJudgement' import { addTrialMove, clearTrialMoves, @@ -167,6 +169,27 @@ const emptyDashboard: DashboardData = { } } +const REPLAY_WHEEL_STEP_PX = 44 +const REPLAY_WHEEL_MAX_STEPS_PER_EVENT = 6 + +function isReplayNavigationTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return true + } + return !target.closest('input, textarea, select, button, [contenteditable="true"], [role="textbox"], [role="combobox"], [role="listbox"], [data-disable-replay-wheel="true"]') +} + +function normalizedWheelPixels(event: WheelEvent): number { + const primaryDelta = Math.abs(event.deltaY) >= Math.abs(event.deltaX) ? event.deltaY : event.deltaX + if (event.deltaMode === 1) { + return primaryDelta * 16 + } + if (event.deltaMode === 2) { + return primaryDelta * 360 + } + return primaryDelta +} + function uniqueModelOptions(models: string[]): string[] { return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean))) } @@ -800,6 +823,9 @@ export function App(): ReactElement { const [prompt, setPrompt] = useState('') const [moveRange, setMoveRange] = useState<{ start: number; end: number } | null>(null) const [boardFlash, setBoardFlash] = useState<(BoardPoint & { nonce: number; label: string }) | null>(null) + const [territoryEnabled, setTerritoryEnabled] = useState(false) + const [territoryMode, setTerritoryMode] = useState('heat') + const [territoryBusy, setTerritoryBusy] = useState(false) const [busy, setBusy] = useState('') const [graphBusy, setGraphBusy] = useState(false) const [graphProgress, setGraphProgress] = useState('') @@ -827,6 +853,7 @@ export function App(): ReactElement { const graphRunId = useRef('') const liveAnalysisRunId = useRef('') const trialAnalysisRunId = useRef('') + const territoryAnalysisRunId = useRef('') const trialAnalysisCacheRef = useRef>({}) const autoAnalysisRequestId = useRef('') const userPausedLiveAnalysisRef = useRef(false) @@ -834,6 +861,7 @@ export function App(): ReactElement { const recordRef = useRef(record) const trialBranchRef = useRef(trialBranch) const jumpToMoveRef = useRef<(next: number) => void>(() => {}) + const replayWheelAccumulatorRef = useRef(0) const teacherBusyRef = useRef(false) const activeTeacherRunRef = useRef(null) const selectedGameIdRef = useRef('') @@ -1078,6 +1106,10 @@ export function App(): ReactElement { const boardRecord = trialRecord ?? record const boardMoveNumber = trialRecord ? trialRecord.moves.length : moveNumber const boardAnalysis = trialBranch.active ? trialAnalysis : currentAnalysis + const territoryJudgement = useMemo( + () => buildTerritoryJudgement(boardAnalysis, boardRecord?.boardSize ?? record?.boardSize ?? 19), + [boardAnalysis, boardRecord?.boardSize, record?.boardSize] + ) const displayBoardKeyMoveMarks = trialBranch.active ? [] : currentBoardKeyMoveMarks useEffect(() => { @@ -1853,6 +1885,33 @@ export function App(): ReactElement { } } + function handleReplayWheel(event: WheelEvent): void { + const rec = recordRef.current + if (!rec || teacherBusyRef.current || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || !isReplayNavigationTarget(event.target)) { + return + } + const delta = normalizedWheelPixels(event) + if (!Number.isFinite(delta) || Math.abs(delta) < 1) { + return + } + event.preventDefault() + replayWheelAccumulatorRef.current += delta + const rawSteps = Math.trunc(replayWheelAccumulatorRef.current / REPLAY_WHEEL_STEP_PX) + if (rawSteps === 0) { + return + } + const steps = Math.max(-REPLAY_WHEEL_MAX_STEPS_PER_EVENT, Math.min(REPLAY_WHEEL_MAX_STEPS_PER_EVENT, rawSteps)) + replayWheelAccumulatorRef.current -= steps * REPLAY_WHEEL_STEP_PX + const currentMove = moveNumberRef.current + const nextMove = Math.max(0, Math.min(rec.moves.length, currentMove + steps)) + if (nextMove === currentMove) { + replayWheelAccumulatorRef.current = 0 + return + } + moveNumberRef.current = nextMove + jumpToMoveRef.current(nextMove) + } + function trialCacheKey(gameId: string, branch: TrialBranch): string { return `${gameId}:${branch.branchHash}:${dashboard.settings.katagoModelPreset || dashboard.settings.katagoModel || 'model'}` } @@ -2455,6 +2514,113 @@ export function App(): ReactElement { } } + async function runTerritoryJudgementAnalysis(): Promise { + const gameId = selectedGame?.id + const targetRecord = record + const targetMove = moveNumber + if (!gameId || !targetRecord || busy === 'teacher') { + return + } + const runId = crypto.randomUUID() + territoryAnalysisRunId.current = runId + setTerritoryEnabled(true) + setTerritoryBusy(true) + setError('') + setLiveAnalysis((current) => ({ + ...current, + running: true, + status: `形势判断中 · 第 ${targetMove} 手`, + targetMoveNumber: targetMove, + visits: 0, + bestVisits: 0, + visitsPerSecond: 0 + })) + await cancelKataGoWork({ group: 'single' }) + const disposeSearchProgress = window.goagent.onAnalyzePositionSearchProgress((progress) => { + if ( + territoryAnalysisRunId.current !== runId || + progress.runId !== runId || + progress.gameId !== gameId || + progress.moveNumber !== targetMove || + selectedGameIdRef.current !== gameId + ) { + return + } + setLiveAnalysis((current) => ({ + ...current, + running: progress.isDuringSearch, + status: progress.isDuringSearch ? `形势判断搜索 ${formatVisits(progress.visits)}` : current.status, + visits: Math.max(current.visits, progress.visits), + visitsPerSecond: progress.visitsPerSecond > 0 ? progress.visitsPerSecond : current.visitsPerSecond, + targetMoveNumber: targetMove + })) + }) + const disposeProgress = window.goagent.onAnalyzePositionProgress((progress) => { + if ( + territoryAnalysisRunId.current !== runId || + progress.runId !== runId || + progress.gameId !== gameId || + progress.moveNumber !== targetMove || + selectedGameIdRef.current !== gameId + ) { + return + } + rememberEvaluation(progress.analysis, { force: true }) + if (moveNumberRef.current === targetMove) { + setAnalysis(progress.analysis) + } + setLiveAnalysis((current) => ({ + ...current, + running: !progress.isFinal, + status: progress.isFinal ? '形势判断完成' : `形势判断搜索 ${formatVisits(candidateVisitsTotal(progress.analysis))}`, + visits: Math.max(current.visits, candidateVisitsTotal(progress.analysis)), + bestVisits: Math.max(current.bestVisits, candidateBestVisits(progress.analysis)), + targetMoveNumber: targetMove + })) + }) + try { + const nextAnalysis = await window.goagent.analyzePositionStream({ + gameId, + moveNumber: targetMove, + maxVisits: 1400, + runId, + group: 'single', + reportDuringSearchEvery: 0.25 + }) + if (territoryAnalysisRunId.current !== runId || selectedGameIdRef.current !== gameId) { + return + } + rememberEvaluation(nextAnalysis, { force: true }) + if (moveNumberRef.current === targetMove) { + setAnalysis(nextAnalysis) + } + setLiveAnalysis((current) => ({ + ...current, + running: false, + status: '形势判断完成', + visits: Math.max(current.visits, candidateVisitsTotal(nextAnalysis)), + bestVisits: Math.max(current.bestVisits, candidateBestVisits(nextAnalysis)), + targetMoveNumber: targetMove + })) + } catch (cause) { + if (territoryAnalysisRunId.current === runId) { + const message = kataGoRuntimeErrorMessage(cause) || String(cause) + setError(`形势判断失败: ${message}`) + setLiveAnalysis((current) => ({ + ...current, + running: false, + status: message + })) + } + } finally { + disposeProgress() + disposeSearchProgress() + if (territoryAnalysisRunId.current === runId) { + setTerritoryBusy(false) + } + } + } + async function runMoveAnalysisAt(targetMoveNumber: number): Promise { if (!record || !selectedGame || busy !== '') { return @@ -2782,7 +2948,7 @@ export function App(): ReactElement { ) : null} -
+
{record ? ( {record ? (
+ setTerritoryEnabled((value) => !value)} + onModeChange={setTerritoryMode} + onDeepen={() => void runTerritoryJudgementAnalysis()} + t={t} + /> {boardRecord && boardRecord.boardSize >= 2 ? ( + ) : timelineFinalRecordScore ? (
{t('recordScoreLead')} {timelineFinalRecordScore.displayLead} diff --git a/src/renderer/src/features/board/GoBoardV2.tsx b/src/renderer/src/features/board/GoBoardV2.tsx index b9ddeaa..054134b 100644 --- a/src/renderer/src/features/board/GoBoardV2.tsx +++ b/src/renderer/src/features/board/GoBoardV2.tsx @@ -19,6 +19,7 @@ import { } from './boardGeometry' import type { CandidateTooltipMove, CandidateTooltipPosition } from './CandidateTooltip' import type { TrialBranch } from './trialBranch' +import type { TerritoryDisplayMode, TerritoryJudgement } from './territoryJudgement' import './board-v2.css' interface GoBoardV2Props { @@ -29,6 +30,8 @@ interface GoBoardV2Props { flashPoint?: (BoardPoint & { nonce?: number; label?: string }) | null compact?: boolean trialBranch?: TrialBranch | null + territoryJudgement?: TerritoryJudgement | null + territoryMode?: TerritoryDisplayMode onPointClick?: (point: BoardPoint) => void onPointContextMenu?: () => void onCandidateHover?: (candidate: RenderCandidate | null) => void @@ -299,7 +302,75 @@ function TrialStoneMark({ move, boardSize }: { move: TrialBranch['moves'][number ) } -export function GoBoardV2({ record, moveNumber, analysis = null, keyMoves = [], flashPoint = null, compact = false, trialBranch = null, onPointClick, onPointContextMenu, onCandidateHover, t: providedT }: GoBoardV2Props): ReactElement { +function TerritoryOverlay({ + judgement, + mode, + boardSize +}: { + judgement: TerritoryJudgement + mode: TerritoryDisplayMode + boardSize: number +}): ReactElement | null { + if (!judgement.available || judgement.cells.length === 0) { + return null + } + const cell = INNER / (boardSize - 1) + return ( + + {judgement.cells.map((cellInfo) => { + const p = xy(cellInfo, boardSize) + const opacity = Math.min(0.46, Math.max(0.055, Math.pow(cellInfo.strength, 1 / 1.33) * 0.34)) + const blockSize = mode === 'blocks' + ? cell * 0.88 + : mode === 'marks' + ? Math.max(6, cell * 0.42 * cellInfo.strength) + : cell * 1.08 + if (mode === 'marks') { + return ( + + ) + } + return ( + + ) + })} + + ) +} + +export function GoBoardV2({ + record, + moveNumber, + analysis = null, + keyMoves = [], + flashPoint = null, + compact = false, + trialBranch = null, + territoryJudgement = null, + territoryMode = 'heat', + onPointClick, + onPointContextMenu, + onCandidateHover, + t: providedT +}: GoBoardV2Props): ReactElement { const t = providedT ?? ((key: string) => { const fallback: Record = { boardImageLabel: '围棋棋盘', @@ -494,6 +565,8 @@ export function GoBoardV2({ record, moveNumber, analysis = null, keyMoves = [], })} + {territoryJudgement ? : null} + {letters.map((letter, index) => { const top = xy({ x: index, y: 0 }, boardSize) diff --git a/src/renderer/src/features/board/TerritoryJudgementPanel.tsx b/src/renderer/src/features/board/TerritoryJudgementPanel.tsx new file mode 100644 index 0000000..66c1319 --- /dev/null +++ b/src/renderer/src/features/board/TerritoryJudgementPanel.tsx @@ -0,0 +1,145 @@ +import type { ReactElement } from 'react' +import type { UiTranslator } from '../../i18n' +import type { TerritoryDisplayMode, TerritoryJudgement } from './territoryJudgement' + +interface TerritoryControlPanelProps { + enabled: boolean + mode: TerritoryDisplayMode + judgement: TerritoryJudgement + busy?: boolean + onToggle: () => void + onModeChange: (mode: TerritoryDisplayMode) => void + onDeepen: () => void + t: UiTranslator +} + +interface TerritorySummaryStripProps { + judgement: TerritoryJudgement + compact?: boolean + t: UiTranslator +} + +function confidenceLabel(confidence: TerritoryJudgement['confidence'], t: UiTranslator): string { + if (confidence === 'high') return t('territoryConfidenceHigh') + if (confidence === 'medium') return t('territoryConfidenceMedium') + if (confidence === 'low') return t('territoryConfidenceLow') + return t('territoryConfidenceMissing') +} + +function modeLabel(mode: TerritoryDisplayMode, t: UiTranslator): string { + if (mode === 'blocks') return t('territoryModeBlocks') + if (mode === 'marks') return t('territoryModeMarks') + return t('territoryModeHeat') +} + +export function TerritoryControlPanel({ + enabled, + mode, + judgement, + busy = false, + onToggle, + onModeChange, + onDeepen, + t +}: TerritoryControlPanelProps): ReactElement { + return ( +
+ + {enabled ? ( + <> +
+ {(['heat', 'blocks', 'marks'] as TerritoryDisplayMode[]).map((item) => ( + + ))} +
+ + {t('territoryConfidence')}{confidenceLabel(judgement.confidence, t)} + + + + ) : null} +
+ ) +} + +export function TerritorySummaryStrip({ judgement, compact = false, t }: TerritorySummaryStripProps): ReactElement { + const whiteShare = Math.max(0, Math.min(100, 100 - judgement.blackShare)) + if (compact) { + return ( +
+ + {t('territoryJudgement')} + {judgement.leadText} + + + + + + + {confidenceLabel(judgement.confidence, t)} + +
+ ) + } + return ( +
+
+
+ {t('territoryLead')} + {judgement.leadText} +
+
+ {t('territoryBlackStrong')} + {judgement.available ? judgement.blackStrong : '—'} +
+
+ {t('territoryWhiteStrong')} + {judgement.available ? judgement.whiteStrong : '—'} +
+
+ {t('territoryUnsettled')} + {judgement.available ? judgement.unsettled : '—'} +
+
+ {t('territoryConfidence')} + {confidenceLabel(judgement.confidence, t)} +
+
+
+ + + {judgement.available ? `${judgement.blackShare}% / ${whiteShare}%` : t('territoryNeedDeepen')} +
+
+ {judgement.available && judgement.regions.length > 0 ? judgement.regions.slice(0, 4).map((region) => ( + + + {region.label} + {region.owner === 'B' ? t('black') : region.owner === 'W' ? t('white') : t('territoryUnclear')} + + )) : ( + + + {judgement.note} + + )} +
+
+ ) +} diff --git a/src/renderer/src/features/board/WinrateTimelineV2.tsx b/src/renderer/src/features/board/WinrateTimelineV2.tsx index 2aaeb49..9312235 100644 --- a/src/renderer/src/features/board/WinrateTimelineV2.tsx +++ b/src/renderer/src/features/board/WinrateTimelineV2.tsx @@ -128,7 +128,7 @@ export function WinrateTimelineV2({ }: WinrateTimelineV2Props): ReactElement { const t = providedT ?? ((key: string, vars?: Record) => { const fallback: Record = { - timelineAria: '胜率图,点击后可用左右方向键切换手数', + timelineAria: '胜率图,可用鼠标滚轮或左右方向键切换手数', timelineTitle: '胜率走势', timelineLoading: '分析中', timelineCurrentBlackWinrate: '当前黑胜率', diff --git a/src/renderer/src/features/board/board-v2.css b/src/renderer/src/features/board/board-v2.css index f6b929d..512dc6c 100644 --- a/src/renderer/src/features/board/board-v2.css +++ b/src/renderer/src/features/board/board-v2.css @@ -49,6 +49,60 @@ fill: rgba(39, 22, 11, 0.72); } +.ks-territory-layer { + pointer-events: none; + mix-blend-mode: multiply; + transition: opacity var(--ks-motion-med, 180ms ease); +} + +.ks-territory-layer--low { + opacity: 0.76; +} + +.ks-territory-layer--medium, +.ks-territory-layer--high { + opacity: 0.92; +} + +.ks-territory-cell, +.ks-territory-mark { + pointer-events: none; + stroke-width: 0.8; +} + +.ks-territory-cell--B, +.ks-territory-mark--B { + fill: rgba(32, 42, 45, 0.92); + stroke: rgba(20, 24, 25, 0.22); +} + +.ks-territory-cell--W, +.ks-territory-mark--W { + fill: rgba(212, 235, 238, 0.94); + stroke: rgba(98, 134, 138, 0.2); +} + +.ks-territory-layer--heat .ks-territory-cell { + filter: blur(1.3px); +} + +.ks-territory-layer--blocks .ks-territory-cell { + filter: none; + stroke-width: 1; + shape-rendering: geometricPrecision; +} + +.ks-territory-layer--marks { + mix-blend-mode: normal; +} + +.ks-territory-layer--marks .ks-territory-mark { + stroke-width: 1.1; + transform-box: fill-box; + transform-origin: center; + transform: rotate(45deg); +} + .ks-board-coordinates-v2 text { fill: rgba(50, 31, 17, 0.58); font-size: 12px; diff --git a/src/renderer/src/features/board/territoryJudgement.ts b/src/renderer/src/features/board/territoryJudgement.ts new file mode 100644 index 0000000..321ffd2 --- /dev/null +++ b/src/renderer/src/features/board/territoryJudgement.ts @@ -0,0 +1,235 @@ +import type { KataGoMoveAnalysis } from '@main/lib/types' +import { boardPointLabel, type BoardPoint } from './boardGeometry' + +export type TerritoryDisplayMode = 'heat' | 'blocks' | 'marks' +export type TerritoryOwner = 'B' | 'W' +export type TerritorySource = 'root' | 'best-continuation' | 'unavailable' +export type TerritoryConfidence = 'high' | 'medium' | 'low' | 'missing' + +export interface TerritoryCell extends BoardPoint { + owner: TerritoryOwner + value: number + strength: number + label: string +} + +export interface TerritoryRegionSummary { + id: string + label: string + owner: TerritoryOwner | 'unclear' + average: number + points: string[] +} + +export interface TerritoryJudgement { + available: boolean + source: TerritorySource + confidence: TerritoryConfidence + boardSize: number + moveNumber?: number + scoreLead?: number + leadText: string + blackStrong: number + whiteStrong: number + unsettled: number + blackInfluence: number + whiteInfluence: number + blackShare: number + cells: TerritoryCell[] + regions: TerritoryRegionSummary[] + note: string +} + +const OWNERSHIP_VISUAL_THRESHOLD = 0.055 +const STRONG_OWNERSHIP_THRESHOLD = 0.52 +const UNSETTLED_THRESHOLD = 0.24 + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)) +} + +function finite(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function validOwnership(value: unknown, boardSize: number): number[] | null { + if (!Array.isArray(value) || value.length < boardSize * boardSize) { + return null + } + const numbers = value.slice(0, boardSize * boardSize).map((item) => Number(item)) + return numbers.every((item) => Number.isFinite(item)) ? numbers : null +} + +function ownershipSource(analysis: KataGoMoveAnalysis | null | undefined, boardSize: number): { source: TerritorySource; ownership: number[] | null } { + const root = validOwnership(analysis?.after.ownership, boardSize) + if (root) { + return { source: 'root', ownership: root } + } + const beforeRoot = analysis?.moveNumber === 0 ? validOwnership(analysis?.before.ownership, boardSize) : null + if (beforeRoot) { + return { source: 'root', ownership: beforeRoot } + } + const bestContinuation = + validOwnership(analysis?.after.topMoves?.[0]?.ownership, boardSize) ?? + validOwnership(analysis?.before.topMoves?.[0]?.ownership, boardSize) + return { source: bestContinuation ? 'best-continuation' : 'unavailable', ownership: bestContinuation } +} + +function confidenceFor(input: { + source: TerritorySource + analysis?: KataGoMoveAnalysis | null + unsettled: number + boardSize: number +}): TerritoryConfidence { + if (input.source === 'unavailable') { + return 'missing' + } + const bestVisits = Math.max( + 0, + finite(input.analysis?.after.topMoves?.[0]?.visits) ?? 0, + finite(input.analysis?.before.topMoves?.[0]?.visits) ?? 0, + finite(input.analysis?.analysisQuality?.bestVisits) ?? 0 + ) + const unsettledRatio = input.unsettled / Math.max(1, input.boardSize * input.boardSize) + if (input.source === 'root' && bestVisits >= 700 && unsettledRatio < 0.42) { + return 'high' + } + if (input.source === 'root' && bestVisits >= 220) { + return 'medium' + } + if (input.source === 'best-continuation' && bestVisits >= 500) { + return 'medium' + } + return 'low' +} + +function leadText(scoreLead: number | undefined): string { + if (typeof scoreLead !== 'number' || !Number.isFinite(scoreLead)) { + return '待分析' + } + if (Math.abs(scoreLead) < 0.05) { + return '均势' + } + return `${scoreLead > 0 ? '黑' : '白'}领先 ${Math.abs(scoreLead).toFixed(1)}目` +} + +function regionFor(point: BoardPoint, boardSize: number): string { + const third = boardSize / 3 + const twoThirds = boardSize - third + if (point.x >= third && point.x < twoThirds && point.y >= third && point.y < twoThirds) return 'center' + const horizontal = point.x < boardSize / 2 ? 'left' : 'right' + const vertical = point.y < boardSize / 2 ? 'top' : 'bottom' + return `${vertical}-${horizontal}` +} + +function regionLabel(id: string): string { + const labels: Record = { + 'top-left': '左上', + 'top-right': '右上', + 'bottom-left': '左下', + 'bottom-right': '右下', + center: '中腹' + } + return labels[id] ?? id +} + +function buildRegions(cells: TerritoryCell[], boardSize: number): TerritoryRegionSummary[] { + const buckets = new Map() + for (const cell of cells) { + const id = regionFor(cell, boardSize) + const bucket = buckets.get(id) ?? [] + bucket.push(cell) + buckets.set(id, bucket) + } + return Array.from(buckets.entries()).map(([id, bucket]) => { + const average = bucket.reduce((sum, cell) => sum + cell.value, 0) / Math.max(1, bucket.length) + const owner: TerritoryRegionSummary['owner'] = Math.abs(average) < 0.18 ? 'unclear' : average > 0 ? 'B' : 'W' + return { + id, + label: regionLabel(id), + owner, + average: Math.round(average * 100) / 100, + points: bucket + .slice() + .sort((a, b) => b.strength - a.strength) + .slice(0, 3) + .map((cell) => cell.label) + } + }).sort((a, b) => Math.abs(b.average) - Math.abs(a.average)).slice(0, 5) +} + +export function buildTerritoryJudgement(analysis: KataGoMoveAnalysis | null | undefined, boardSizeInput = 19): TerritoryJudgement { + const boardSize = Math.max(2, Math.round(boardSizeInput || analysis?.boardSize || 19)) + const { source, ownership } = ownershipSource(analysis, boardSize) + const scoreLead = finite(analysis?.after.scoreLead) + if (!ownership) { + return { + available: false, + source: 'unavailable', + confidence: 'missing', + boardSize, + moveNumber: analysis?.moveNumber, + scoreLead, + leadText: leadText(scoreLead), + blackStrong: 0, + whiteStrong: 0, + unsettled: 0, + blackInfluence: 0, + whiteInfluence: 0, + blackShare: 50, + cells: [], + regions: [], + note: '当前分析没有 ownership 数据。请加深当前手分析后再看形势判断。' + } + } + + let blackStrong = 0 + let whiteStrong = 0 + let unsettled = 0 + let blackInfluence = 0 + let whiteInfluence = 0 + const cells: TerritoryCell[] = [] + for (let y = 0; y < boardSize; y += 1) { + for (let x = 0; x < boardSize; x += 1) { + const value = clamp(Number(ownership[y * boardSize + x] ?? 0), -1, 1) + const strength = Math.abs(value) + if (value >= STRONG_OWNERSHIP_THRESHOLD) blackStrong += 1 + if (value <= -STRONG_OWNERSHIP_THRESHOLD) whiteStrong += 1 + if (strength <= UNSETTLED_THRESHOLD) unsettled += 1 + if (value > 0) blackInfluence += value + if (value < 0) whiteInfluence += Math.abs(value) + if (strength >= OWNERSHIP_VISUAL_THRESHOLD) { + const point = { x, y } + cells.push({ + ...point, + owner: value >= 0 ? 'B' : 'W', + value, + strength, + label: boardPointLabel(point, boardSize) + }) + } + } + } + const totalInfluence = blackInfluence + whiteInfluence + const confidence = confidenceFor({ source, analysis, unsettled, boardSize }) + return { + available: true, + source, + confidence, + boardSize, + moveNumber: analysis?.moveNumber, + scoreLead, + leadText: leadText(scoreLead), + blackStrong, + whiteStrong, + unsettled, + blackInfluence: Math.round(blackInfluence * 10) / 10, + whiteInfluence: Math.round(whiteInfluence * 10) / 10, + blackShare: totalInfluence > 0 ? Math.round((blackInfluence / totalInfluence) * 100) : 50, + cells, + regions: buildRegions(cells, boardSize), + note: source === 'root' + ? '基于 KataGo 当前局面的 ownership。' + : '当前没有 root ownership,暂用一选后续作为参考,不能当作实战最终归属。' + } +} diff --git a/src/renderer/src/features/gallery/UiGallery.tsx b/src/renderer/src/features/gallery/UiGallery.tsx index fac46a4..907fe7b 100644 --- a/src/renderer/src/features/gallery/UiGallery.tsx +++ b/src/renderer/src/features/gallery/UiGallery.tsx @@ -4,8 +4,10 @@ import { BoardInsightPanel } from '../board/BoardInsightPanel' import { CandidateTooltip } from '../board/CandidateTooltip' import { GoBoardV2 } from '../board/GoBoardV2' import { KeyMoveNavigator } from '../board/KeyMoveNavigator' +import { TerritoryControlPanel, TerritorySummaryStrip } from '../board/TerritoryJudgementPanel' import { WinrateTimelineV2 } from '../board/WinrateTimelineV2' import { parseBoardPoint, type RenderKeyMove } from '../board/boardGeometry' +import { buildTerritoryJudgement, type TerritoryDisplayMode } from '../board/territoryJudgement' import { DiagnosticsPanel } from '../diagnostics/DiagnosticsPanel' import { BetaAcceptancePanel } from '../release/BetaAcceptancePanel' import { StudentBindingDialog } from '../student/StudentBindingDialog' @@ -14,6 +16,7 @@ import { KataGoAssetsPanel } from '../settings/KataGoAssetsPanel' import { RuntimeSettingsPanel } from '../settings/RuntimeSettingsPanel' import { TeacherComposerPro } from '../teacher/TeacherComposerPro' import { TeacherRunCardPro } from '../teacher/TeacherRunCardPro' +import { createUiTranslator } from '../../i18n' import { galleryAnalysis, galleryEvaluations, @@ -48,8 +51,12 @@ function noopForm(event: FormEvent): void { export function UiGallery(): ReactElement { const [moveNumber, setMoveNumber] = useState(24) const [composerValue, setComposerValue] = useState('') + const [territoryEnabled, setTerritoryEnabled] = useState(true) + const [territoryMode, setTerritoryMode] = useState('heat') const [dialogOpen, setDialogOpen] = useState(() => new URLSearchParams(window.location.search).has('dialog')) const boardKeyMoves = useMemo(() => keyMoveMarks(), []) + const territoryJudgement = useMemo(() => buildTerritoryJudgement(galleryAnalysis, galleryRecord.boardSize), []) + const t = useMemo(() => createUiTranslator('zh-CN'), []) return (
@@ -73,7 +80,20 @@ export function UiGallery(): ReactElement { moveNumber={moveNumber} analysis={galleryAnalysis} keyMoves={boardKeyMoves} + territoryJudgement={territoryEnabled ? territoryJudgement : null} + territoryMode={territoryMode} /> +
+ setTerritoryEnabled((value) => !value)} + onModeChange={setTerritoryMode} + onDeepen={() => undefined} + t={t} + /> +
} /> { + const x = index % boardSize + const y = Math.floor(index / boardSize) + const leftBias = x < 7 ? 0.46 : 0 + const rightBias = x > 12 ? -0.38 : 0 + const topLeft = x < 8 && y < 8 ? 0.35 : 0 + const bottomRight = x > 10 && y > 10 ? -0.42 : 0 + const centerNoise = Math.sin((x + 1) * 0.9) * Math.cos((y + 2) * 0.7) * 0.16 + return Math.max(-0.96, Math.min(0.96, leftBias + rightBias + topLeft + bottomRight + centerNoise)) + }) +} + export const galleryGame: LibraryGame = { id: 'gallery-game-01', title: 'Sprint 7 UI Gallery', @@ -90,6 +103,7 @@ export const galleryAnalysis: KataGoMoveAnalysis = { after: { winrate: 43, scoreLead: -1.8, + ownership: mockOwnership(19), topMoves: [ { move: 'Q10', winrate: 60, scoreLead: 4.4, visits: 2410, order: 1, prior: 0.19, pv: ['Q10', 'Q8', 'O10'] }, { move: 'K16', winrate: 55, scoreLead: 2.7, visits: 1488, order: 2, prior: 0.14, pv: ['K16', 'N17'] }, diff --git a/src/renderer/src/i18n.ts b/src/renderer/src/i18n.ts index 9bdaed2..76dbc59 100644 --- a/src/renderer/src/i18n.ts +++ b/src/renderer/src/i18n.ts @@ -122,7 +122,7 @@ const ZH_CN = { candidateScore: '目差', candidateVisits: '访问', candidatePrior: '先验', - timelineAria: '胜率图,点击后可用左右方向键切换手数', + timelineAria: '胜率图,可用鼠标滚轮或左右方向键切换手数', timelineTitle: '胜率走势', timelineLoading: '分析中', timelineSubtitle: '胜率 / 目差曲线', @@ -158,6 +158,24 @@ const ZH_CN = { pauseAnalysis: '暂停分析', startAnalysis: '开始分析', currentBoardMetrics: '当前局面数据', + territoryJudgement: '形势判断', + territoryDisplayMode: '形势显示方式', + territoryModeHeat: '热力', + territoryModeBlocks: '方块', + territoryModeMarks: '标记', + territoryConfidence: '置信 ', + territoryConfidenceHigh: '高', + territoryConfidenceMedium: '中', + territoryConfidenceLow: '低', + territoryConfidenceMissing: '待分析', + territoryDeepen: '加深', + territoryLead: '形势', + territoryBlackStrong: '黑势', + territoryWhiteStrong: '白势', + territoryUnsettled: '未定', + territoryBalance: '黑白势力比例', + territoryNeedDeepen: '需要加深分析', + territoryUnclear: '未定', settingsTitle: 'GoAgent 设置', settingsSubtitle: '模型、分析与语音', settingsDescription: '管理 AI 老师、棋盘分析和讲解语音。', @@ -687,7 +705,7 @@ const EN_US: Record = { candidateScore: 'Score', candidateVisits: 'Visits', candidatePrior: 'Prior', - timelineAria: 'Winrate timeline. Click, then use left and right arrow keys to change moves.', + timelineAria: 'Winrate timeline. Use the mouse wheel or left and right arrow keys to change moves.', timelineTitle: 'Winrate trend', timelineLoading: 'Analyzing', timelineSubtitle: 'Winrate / score lead', @@ -723,6 +741,24 @@ const EN_US: Record = { pauseAnalysis: 'Pause', startAnalysis: 'Start', currentBoardMetrics: 'Current position metrics', + territoryJudgement: 'Position', + territoryDisplayMode: 'Position display mode', + territoryModeHeat: 'Heat', + territoryModeBlocks: 'Blocks', + territoryModeMarks: 'Marks', + territoryConfidence: 'Confidence ', + territoryConfidenceHigh: 'High', + territoryConfidenceMedium: 'Medium', + territoryConfidenceLow: 'Low', + territoryConfidenceMissing: 'Needs analysis', + territoryDeepen: 'Deepen', + territoryLead: 'Position', + territoryBlackStrong: 'Black', + territoryWhiteStrong: 'White', + territoryUnsettled: 'Unsettled', + territoryBalance: 'Territory balance', + territoryNeedDeepen: 'Deep analysis needed', + territoryUnclear: 'Unclear', settingsTitle: 'GoAgent Settings', settingsSubtitle: 'Models, analysis, and voice', settingsDescription: 'Manage the AI teacher, board analysis, and spoken explanations.', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 319982f..42c67e8 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -1837,6 +1837,7 @@ label, } .board-table { + position: relative; display: grid; grid-template-rows: minmax(0, 1fr); place-items: center; @@ -1856,6 +1857,143 @@ label, width: min(760px, 100%); } +.territory-control { + position: absolute; + z-index: 8; + top: 10px; + left: 50%; + display: inline-flex; + align-items: center; + max-width: calc(100% - 32px); + min-height: 38px; + padding: 4px; + gap: 6px; + border: 1px solid rgb(79 65 42 / 0.16); + border-radius: 999px; + background: rgb(255 252 245 / 0.88); + box-shadow: 0 14px 34px rgb(65 43 21 / 0.15), inset 0 1px 0 rgb(255 255 255 / 0.78); + backdrop-filter: blur(18px) saturate(1.18); + transform: translateX(-50%); +} + +.territory-control button { + height: 29px; + border: 0; + border-radius: 999px; + background: transparent; + color: #384047; + font-size: 12px; + font-weight: 850; + white-space: nowrap; +} + +.territory-control button:disabled { + cursor: default; + opacity: 0.48; +} + +.territory-control__toggle { + display: inline-flex; + align-items: center; + padding: 0 12px 0 9px; + gap: 7px; +} + +.territory-control__spark { + width: 15px; + height: 15px; + border-radius: 5px; + background: + linear-gradient(90deg, rgb(35 43 47 / 0.92) 0 47%, rgb(207 231 235 / 0.96) 53% 100%); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.5), 0 2px 8px rgb(28 85 86 / 0.2); +} + +.territory-control.is-enabled .territory-control__toggle { + background: rgb(21 120 119 / 0.1); + color: #0b6d69; +} + +.territory-control__segments { + display: inline-flex; + padding: 3px; + gap: 2px; + border: 1px solid rgb(72 63 52 / 0.08); + border-radius: 999px; + background: rgb(37 40 43 / 0.055); +} + +.territory-control__segments button { + height: 23px; + padding: 0 9px; + color: #697078; + font-size: 11px; +} + +.territory-control__segments button.is-active { + background: #138f89; + box-shadow: 0 4px 12px rgb(19 143 137 / 0.22); + color: #fffdf4; +} + +.territory-control__confidence { + display: inline-flex; + align-items: center; + height: 24px; + padding: 0 8px; + border-radius: 999px; + background: rgb(255 255 255 / 0.55); + color: #66717a; + font-size: 10px; + font-weight: 850; + white-space: nowrap; +} + +.territory-control__confidence--high, +.territory-control__confidence--medium { + color: #0a706b; +} + +.territory-control__confidence--low, +.territory-control__confidence--missing { + color: #9b6a2e; +} + +.territory-control__deepen { + padding: 0 10px; + background: #22282d !important; + color: #fff9eb !important; + box-shadow: 0 6px 16px rgb(35 40 45 / 0.18); +} + +@media (max-width: 720px) { + .territory-control { + max-width: calc(100% - 20px); + gap: 4px; + padding: 3px; + } + + .territory-control__toggle { + padding: 0 9px 0 7px; + } + + .territory-control__segments { + gap: 1px; + padding: 2px; + } + + .territory-control__segments button { + padding: 0 7px; + } + + .territory-control__confidence { + display: none; + } + + .territory-control__deepen { + padding: 0 8px; + } +} + .board-insight-stack { display: grid; width: min(760px, 100%); @@ -2059,6 +2197,202 @@ label, height: 100%; } +.territory-summary { + display: grid; + width: min(520px, 28vw); + min-width: 320px; + gap: 7px; +} + +.territory-summary--compact { + display: inline-flex; + align-items: center; + width: min(360px, 100%); + min-width: 250px; + height: 28px; + gap: 8px; + padding: 2px 8px; + overflow: hidden; + border: 1px solid rgb(69 56 42 / 0.11); + border-radius: 999px; + background: rgb(255 253 247 / 0.64); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.62); +} + +.territory-summary__metrics { + display: grid; + grid-template-columns: 1.18fr repeat(4, minmax(42px, 0.62fr)); + gap: 1px; + overflow: hidden; + border: 1px solid rgb(69 56 42 / 0.12); + border-radius: 12px; + background: rgb(255 253 247 / 0.52); +} + +.territory-summary__metric { + display: grid; + min-width: 0; + padding: 6px 8px; + background: rgb(255 255 255 / 0.5); + line-height: 1.05; +} + +.territory-summary__metric span { + overflow: hidden; + color: #7b8289; + font-size: 9px; + font-weight: 850; + text-overflow: ellipsis; + white-space: nowrap; +} + +.territory-summary__metric strong { + margin-top: 4px; + overflow: hidden; + color: #252c32; + font-size: 13px; + font-weight: 920; + text-overflow: ellipsis; + white-space: nowrap; +} + +.territory-summary__metric--lead strong { + color: #0d746f; +} + +.territory-summary__balance { + position: relative; + display: flex; + align-items: center; + height: 16px; + overflow: hidden; + border-radius: 999px; + background: rgb(222 226 228 / 0.74); + box-shadow: inset 0 1px 2px rgb(30 35 38 / 0.12); +} + +.territory-summary__bar { + display: block; + height: 100%; + transition: width 180ms ease; +} + +.territory-summary__bar--black { + background: linear-gradient(90deg, #2a3238, #657078); +} + +.territory-summary__bar--white { + background: linear-gradient(90deg, #d9eef1, #85c7d2); +} + +.territory-summary__balance em { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: rgb(24 29 32 / 0.82); + font-size: 9px; + font-style: normal; + font-weight: 900; + text-shadow: 0 1px 0 rgb(255 255 255 / 0.45); +} + +.territory-summary__regions { + display: flex; + gap: 5px; + overflow: hidden; +} + +.territory-summary__region { + display: inline-flex; + align-items: center; + min-width: 0; + height: 20px; + padding: 0 8px; + gap: 5px; + border: 1px solid rgb(69 56 42 / 0.1); + border-radius: 999px; + background: rgb(255 255 255 / 0.54); + color: #5b626a; + font-size: 10px; + font-weight: 820; + white-space: nowrap; +} + +.territory-summary__region i { + width: 7px; + height: 7px; + border-radius: 50%; + background: #9aa3aa; +} + +.territory-summary__region b { + color: #252c32; + font-weight: 920; +} + +.territory-summary__region--B i { + background: #303940; +} + +.territory-summary__region--W i { + background: #83c7d0; +} + +.territory-summary__region--unclear { + flex: 1; +} + +.territory-summary__compact-lead { + display: inline-flex; + flex: 0 1 auto; + align-items: baseline; + min-width: 0; + gap: 5px; +} + +.territory-summary__compact-lead i { + color: #7b8289; + font-size: 9px; + font-style: normal; + font-weight: 880; + letter-spacing: 0.02em; + white-space: nowrap; +} + +.territory-summary__compact-lead strong { + min-width: 0; + overflow: hidden; + color: #0d746f; + font-size: 11.5px; + font-weight: 930; + text-overflow: ellipsis; + white-space: nowrap; +} + +.territory-summary__compact-balance { + display: inline-flex; + flex: 1 1 66px; + min-width: 56px; + height: 7px; + overflow: hidden; + border-radius: 999px; + background: rgb(222 226 228 / 0.74); + box-shadow: inset 0 1px 2px rgb(30 35 38 / 0.12); +} + +.territory-summary__compact-confidence { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + height: 18px; + padding: 0 7px; + border-radius: 999px; + font-size: 9px; + font-weight: 900; + white-space: nowrap; +} + .timeline-issues { display: grid; grid-template-rows: auto minmax(0, 1fr); diff --git a/tests/mouse-wheel-replay-contract.test.mjs b/tests/mouse-wheel-replay-contract.test.mjs new file mode 100644 index 0000000..14d71b4 --- /dev/null +++ b/tests/mouse-wheel-replay-contract.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import test from 'node:test' + +const root = process.cwd() + +function read(relativePath) { + return readFileSync(join(root, relativePath), 'utf8') +} + +test('board workspace supports mouse-wheel move replay without hijacking form controls', () => { + const app = read('src/renderer/src/App.tsx') + + assert.match(app, /REPLAY_WHEEL_STEP_PX/) + assert.match(app, /REPLAY_WHEEL_MAX_STEPS_PER_EVENT/) + assert.match(app, /function isReplayNavigationTarget/) + assert.match(app, /input, textarea, select, button/) + assert.match(app, /function normalizedWheelPixels/) + assert.match(app, /function handleReplayWheel/) + assert.match(app, /event\.preventDefault\(\)/) + assert.match(app, /jumpToMoveRef\.current\(nextMove\)/) + assert.match(app, /
/) +}) diff --git a/tests/territory-judgement-contract.test.mjs b/tests/territory-judgement-contract.test.mjs new file mode 100644 index 0000000..2f85fe9 --- /dev/null +++ b/tests/territory-judgement-contract.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import test from 'node:test' + +const root = process.cwd() +const read = (path) => readFileSync(join(root, path), 'utf8') + +test('KataGo root ownership is preserved for positional judgement', () => { + const types = read('src/main/lib/types.ts') + const katago = read('src/main/services/katago.ts') + + assert.match(types, /ownership\?: number\[\]/) + assert.match(types, /ownershipStdev\?: number\[\]/) + assert.match(katago, /rootInfo\?: \{/) + assert.match(katago, /rootInfo\.ownership/) + assert.match(katago, /rootInfo\.ownershipStdev/) + assert.match(katago, /ownership: Array\.isArray\(response\.rootInfo\.ownership\)/) +}) + +test('renderer has a reusable territory judgement model and premium board overlay', () => { + const model = read('src/renderer/src/features/board/territoryJudgement.ts') + const board = read('src/renderer/src/features/board/GoBoardV2.tsx') + const css = read('src/renderer/src/features/board/board-v2.css') + + assert.match(model, /export function buildTerritoryJudgement/) + assert.match(model, /TerritoryDisplayMode = 'heat' \| 'blocks' \| 'marks'/) + assert.match(model, /source: 'root'/) + assert.match(model, /best-continuation/) + assert.match(board, /territoryJudgement\?: TerritoryJudgement/) + assert.match(board, /function TerritoryOverlay/) + assert.match(board, /ks-territory-layer/) + assert.match(css, /\.ks-territory-layer--heat/) + assert.match(css, /\.ks-territory-layer--blocks/) + assert.match(css, /\.ks-territory-layer--marks/) +}) + +test('workbench exposes positional judgement controls and summary without replacing analysis flow', () => { + const app = read('src/renderer/src/App.tsx') + const panel = read('src/renderer/src/features/board/TerritoryJudgementPanel.tsx') + const styles = read('src/renderer/src/styles.css') + const i18n = read('src/renderer/src/i18n.ts') + + assert.match(app, /const \[territoryEnabled, setTerritoryEnabled\]/) + assert.match(app, /buildTerritoryJudgement\(boardAnalysis/) + assert.match(app, /runTerritoryJudgementAnalysis/) + assert.match(app, / { + const gallery = read('src/renderer/src/features/gallery/UiGallery.tsx') + const mock = read('src/renderer/src/features/gallery/uiGalleryMock.ts') + + assert.match(mock, /function mockOwnership/) + assert.match(mock, /ownership: mockOwnership\(19\)/) + assert.match(gallery, /TerritoryControlPanel/) + assert.match(gallery, /TerritorySummaryStrip/) + assert.match(gallery, /buildTerritoryJudgement\(galleryAnalysis/) +}) From d497ca639640fdd63644461023dca5f593ed4ba7 Mon Sep 17 00:00:00 2001 From: haoc Date: Tue, 7 Jul 2026 23:48:52 +0800 Subject: [PATCH 2/9] fix: move territory controls out of board overlay --- src/renderer/src/App.tsx | 31 +++++--- .../board/TerritoryJudgementPanel.tsx | 4 +- .../src/features/gallery/ui-gallery.css | 13 ++-- src/renderer/src/styles.css | 70 +++++++++++++++++-- tests/territory-judgement-contract.test.mjs | 8 ++- 5 files changed, 101 insertions(+), 25 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6da92c0..6368d11 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2959,6 +2959,19 @@ export function App(): ReactElement { liveAnalysis={liveAnalysis} disabled={liveAnalysisDisabled} trialBranch={trialBranch} + territoryControl={( + setTerritoryEnabled((value) => !value)} + onModeChange={setTerritoryMode} + onDeepen={() => void runTerritoryJudgementAnalysis()} + t={t} + /> + )} onStart={() => void startLiveAnalysis()} onPause={() => pauseLiveAnalysis(t('pausedFineReview'), true)} onToggleTrial={trialBranch.active ? exitTrialMode : enterTrialMode} @@ -2979,16 +2992,6 @@ export function App(): ReactElement {
{record ? (
- setTerritoryEnabled((value) => !value)} - onModeChange={setTerritoryMode} - onDeepen={() => void runTerritoryJudgementAnalysis()} - t={t} - /> {boardRecord && boardRecord.boardSize >= 2 ? ( void onPause: () => void onToggleTrial: () => void @@ -5565,6 +5570,12 @@ function BoardContextBar({ ) : null} + {territoryControl ? ( + <> + + {territoryControl} + + ) : null} diff --git a/src/renderer/src/features/board/board-v2.css b/src/renderer/src/features/board/board-v2.css index 512dc6c..b82602d 100644 --- a/src/renderer/src/features/board/board-v2.css +++ b/src/renderer/src/features/board/board-v2.css @@ -51,39 +51,67 @@ .ks-territory-layer { pointer-events: none; - mix-blend-mode: multiply; + mix-blend-mode: normal; transition: opacity var(--ks-motion-med, 180ms ease); } .ks-territory-layer--low { - opacity: 0.76; + opacity: 0.82; } .ks-territory-layer--medium, .ks-territory-layer--high { - opacity: 0.92; + opacity: 0.96; } .ks-territory-cell, -.ks-territory-mark { +.ks-territory-mark, +.ks-territory-dot { pointer-events: none; - stroke-width: 0.8; + vector-effect: non-scaling-stroke; } .ks-territory-cell--B, .ks-territory-mark--B { - fill: rgba(32, 42, 45, 0.92); - stroke: rgba(20, 24, 25, 0.22); + fill: rgba(18, 22, 24, 0.52); + stroke: rgba(9, 11, 12, 0.52); + stroke-width: 1.1; } .ks-territory-cell--W, .ks-territory-mark--W { - fill: rgba(212, 235, 238, 0.94); - stroke: rgba(98, 134, 138, 0.2); + fill: rgba(255, 252, 241, 0.66); + stroke: rgba(42, 59, 61, 0.42); + stroke-width: 1.15; +} + +.ks-territory-cell--unclear, +.ks-territory-mark--unclear { + fill: rgba(224, 169, 71, 0.5); + stroke: rgba(131, 86, 24, 0.3); + stroke-width: 0.9; +} + +.ks-territory-dot--B { + fill: rgba(8, 10, 11, 0.9); + stroke: rgba(255, 244, 211, 0.24); + stroke-width: 0.9; +} + +.ks-territory-dot--W { + fill: rgba(255, 255, 250, 0.96); + stroke: rgba(30, 39, 42, 0.68); + stroke-width: 1.1; +} + +.ks-territory-dot--unclear { + fill: rgba(215, 147, 35, 0.78); + stroke: rgba(255, 248, 221, 0.5); + stroke-width: 0.65; } .ks-territory-layer--heat .ks-territory-cell { - filter: blur(1.3px); + filter: drop-shadow(0 1px 2px rgb(73 43 15 / 0.14)); } .ks-territory-layer--blocks .ks-territory-cell { @@ -98,9 +126,7 @@ .ks-territory-layer--marks .ks-territory-mark { stroke-width: 1.1; - transform-box: fill-box; - transform-origin: center; - transform: rotate(45deg); + filter: drop-shadow(0 1px 1px rgb(82 55 20 / 0.18)); } .ks-board-coordinates-v2 text { diff --git a/src/renderer/src/i18n.ts b/src/renderer/src/i18n.ts index 76dbc59..ed9176f 100644 --- a/src/renderer/src/i18n.ts +++ b/src/renderer/src/i18n.ts @@ -176,6 +176,10 @@ const ZH_CN = { territoryBalance: '黑白势力比例', territoryNeedDeepen: '需要加深分析', territoryUnclear: '未定', + territoryLegend: '形势图例', + territoryBlackTerritory: '黑地', + territoryWhiteTerritory: '白地', + territoryContested: '争夺', settingsTitle: 'GoAgent 设置', settingsSubtitle: '模型、分析与语音', settingsDescription: '管理 AI 老师、棋盘分析和讲解语音。', @@ -759,6 +763,10 @@ const EN_US: Record = { territoryBalance: 'Territory balance', territoryNeedDeepen: 'Deep analysis needed', territoryUnclear: 'Unclear', + territoryLegend: 'Territory legend', + territoryBlackTerritory: 'Black territory', + territoryWhiteTerritory: 'White territory', + territoryContested: 'Contested', settingsTitle: 'GoAgent Settings', settingsSubtitle: 'Models, analysis, and voice', settingsDescription: 'Manage the AI teacher, board analysis, and spoken explanations.', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index b6b42d2..c7d2103 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -1954,6 +1954,46 @@ label, color: #9b6a2e; } +.territory-control__legend { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 8px; + gap: 4px; + color: #5f656a; + font-size: 10px; + font-weight: 850; + white-space: nowrap; +} + +.territory-control__legend-dot { + display: inline-block; + width: 8px; + height: 8px; + margin-left: 3px; + border-radius: 50%; + box-shadow: 0 1px 2px rgb(47 37 22 / 0.18); +} + +.territory-control__legend-dot:first-child { + margin-left: 0; +} + +.territory-control__legend-dot--black { + background: #15191c; + border: 1px solid rgb(255 244 211 / 0.28); +} + +.territory-control__legend-dot--white { + background: #fffdf4; + border: 1px solid rgb(35 45 48 / 0.62); +} + +.territory-control__legend-dot--unclear { + background: #d89428; + border: 1px solid rgb(255 248 220 / 0.56); +} + .territory-control__deepen { padding: 0 10px; background: #22282d !important; @@ -2002,6 +2042,18 @@ label, display: none; } +.territory-control--compact .territory-control__legend { + height: 24px; + padding: 0 6px; + gap: 3px; + font-size: 9.5px; +} + +.territory-control--compact .territory-control__legend-dot { + width: 7px; + height: 7px; +} + .territory-control--compact .territory-control__deepen { height: 24px; padding: 0 8px; @@ -2020,6 +2072,10 @@ label, } @media (max-width: 1180px) { + .board-contextbar .territory-control--compact .territory-control__legend { + display: none; + } + .board-contextbar .territory-control--compact .territory-control__deepen { display: none; } @@ -2049,6 +2105,10 @@ label, display: none; } + .territory-control__legend { + display: none; + } + .territory-control__deepen { padding: 0 8px; } diff --git a/tests/territory-judgement-contract.test.mjs b/tests/territory-judgement-contract.test.mjs index c067261..a32a46b 100644 --- a/tests/territory-judgement-contract.test.mjs +++ b/tests/territory-judgement-contract.test.mjs @@ -30,9 +30,14 @@ test('renderer has a reusable territory judgement model and premium board overla assert.match(board, /territoryJudgement\?: TerritoryJudgement/) assert.match(board, /function TerritoryOverlay/) assert.match(board, /ks-territory-layer/) + assert.match(board, /ks-territory-dot--/) + assert.match(board, /visualOwner = isUnclear \? 'unclear' : cellInfo\.owner/) assert.match(css, /\.ks-territory-layer--heat/) assert.match(css, /\.ks-territory-layer--blocks/) assert.match(css, /\.ks-territory-layer--marks/) + assert.match(css, /\.ks-territory-dot--B/) + assert.match(css, /\.ks-territory-dot--W/) + assert.match(css, /\.ks-territory-dot--unclear/) }) test('workbench exposes positional judgement controls and summary without replacing analysis flow', () => { @@ -53,11 +58,17 @@ test('workbench exposes positional judgement controls and summary without replac assert.match(app, / Date: Wed, 8 Jul 2026 00:01:35 +0800 Subject: [PATCH 4/9] fix: remove redundant search stats from board header --- src/renderer/src/App.tsx | 9 --------- src/renderer/src/styles.css | 30 ------------------------------ 2 files changed, 39 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6368d11..28509f7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -5502,12 +5502,7 @@ function BoardContextBar({ const scoreLead = analysis?.after.scoreLead const winrate = analysis?.after.winrate const isCurrentLiveTarget = liveAnalysis.targetMoveNumber === moveNumber - const totalVisits = isCurrentLiveTarget ? liveAnalysis.visits : candidateVisitsTotal(analysis) - const bestVisits = isCurrentLiveTarget ? liveAnalysis.bestVisits : candidateBestVisits(analysis) const finalRecordScore = !trialBranch.active && moveNumber === record.moves.length ? gameResultLeadForUi(record.game, t) : null - const status = isCurrentLiveTarget - ? liveAnalysis.status - : (analysis ? t('analysisSearched', { visits: formatVisits(totalVisits) }) : t('analysisWaiting')) const speedLabel = isCurrentLiveTarget && liveAnalysis.visitsPerSecond > 0 ? formatSearchSpeed(liveAnalysis.visitsPerSecond) : isCurrentLiveTarget && liveAnalysis.running @@ -5532,10 +5527,6 @@ function BoardContextBar({ {t('katagoScoreLead')}: {formatScoreLead(scoreLead, t)} ) : null}
-
- {status} - {t('totalAndBestVisits', { total: formatVisits(totalVisits), best: formatVisits(bestVisits) })} -
{t('searchSpeed')} {speedLabel} diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index c7d2103..122b19d 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -1779,10 +1779,6 @@ label, line-height: 1.05; } -.board-contextbar__metric--search { - min-width: 128px; -} - .board-contextbar__metric--score { min-width: 92px; } @@ -4339,32 +4335,6 @@ label small { box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.72); } -.analysis-control-strip__stats { - display: grid; - min-width: 126px; - padding-left: 9px; - line-height: 1.05; -} - -.analysis-control-strip__stats span { - max-width: 180px; - overflow: hidden; - color: var(--ks-text-main); - font-size: 11px; - font-weight: 850; - text-overflow: ellipsis; - white-space: nowrap; -} - -.analysis-control-strip__stats em { - margin-top: 3px; - color: var(--ks-text-muted); - font-size: 10px; - font-style: normal; - font-weight: 750; - white-space: nowrap; -} - .analysis-mini-button { display: inline-flex; align-items: center; From 988cf5d8fbe6c5d66cf97f500d0d8ad0c13c1de7 Mon Sep 17 00:00:00 2001 From: haoc Date: Wed, 8 Jul 2026 00:11:36 +0800 Subject: [PATCH 5/9] fix: make territory judgement one-click heat view --- src/main/services/analysis/cache.ts | 15 ++++++ .../services/analysis/runtimeIntegration.ts | 1 + src/renderer/src/App.tsx | 19 +++++--- .../board/TerritoryJudgementPanel.tsx | 25 +--------- .../src/features/gallery/UiGallery.tsx | 6 +-- src/renderer/src/i18n.ts | 8 ---- src/renderer/src/styles.css | 47 ------------------- tests/territory-judgement-contract.test.mjs | 8 +++- tests/top3-v2-development-contract.test.mjs | 3 ++ ...3-v3-runtime-integration-contract.test.mjs | 2 +- 10 files changed, 41 insertions(+), 93 deletions(-) diff --git a/src/main/services/analysis/cache.ts b/src/main/services/analysis/cache.ts index 6ac33f3..2726198 100644 --- a/src/main/services/analysis/cache.ts +++ b/src/main/services/analysis/cache.ts @@ -49,6 +49,7 @@ export interface AnalysisCacheRequirement { minBestVisits?: number minActualVisits?: number minTier?: AnalysisCacheTier + requireOwnership?: boolean requireStablePv?: boolean requireMediumConfidence?: boolean allowStaleMs?: number @@ -152,6 +153,9 @@ function cacheEntryMeetsRequirement(entry: AnalysisCacheEntry, requirement: Anal if (requirement.minActualVisits && entry.quality.actualVisits < requirement.minActualVisits) { return { status: 'lower-quality', entry, reason: `actualVisits ${entry.quality.actualVisits} is below ${requirement.minActualVisits}` } } + if (requirement.requireOwnership && !hasRootOwnership(entry.analysis)) { + return { status: 'lower-quality', entry, reason: 'cached analysis does not include root ownership' } + } if (requirement.requireStablePv && pvRank[entry.quality.pvConfidence ?? 'unstable'] < pvRank.medium) { return { status: 'lower-quality', entry, reason: `PV confidence ${entry.quality.pvConfidence ?? 'unknown'} is not stable enough` } } @@ -161,6 +165,17 @@ function cacheEntryMeetsRequirement(entry: AnalysisCacheEntry, requirement: Anal return null } +function hasRootOwnership(analysis: KataGoMoveAnalysis): boolean { + const boardSize = Math.max(2, Math.round(analysis.boardSize || 19)) + const expected = boardSize * boardSize + const afterOwnership = analysis.after.ownership + const beforeOwnership = analysis.moveNumber === 0 ? analysis.before.ownership : undefined + return Boolean( + (Array.isArray(afterOwnership) && afterOwnership.length >= expected) || + (Array.isArray(beforeOwnership) && beforeOwnership.length >= expected) + ) +} + export function readAnalysisCache(input: AnalysisCacheKeyInput, requirement: AnalysisCacheRequirement = {}): AnalysisCacheLookupResult { const path = analysisCachePath(input) if (!existsSync(path)) return { status: 'miss', path, reason: 'cache file does not exist' } diff --git a/src/main/services/analysis/runtimeIntegration.ts b/src/main/services/analysis/runtimeIntegration.ts index 93717c3..093fe77 100644 --- a/src/main/services/analysis/runtimeIntegration.ts +++ b/src/main/services/analysis/runtimeIntegration.ts @@ -162,6 +162,7 @@ function cacheRequirementForProfile(profile: AdaptiveAnalysisProfile): AnalysisC minTier: profile.cacheTier, minBestVisits, minActualVisits: teaching ? Math.max(60, Math.min(profile.maxVisits, Math.round(profile.maxVisits * 0.12))) : undefined, + requireOwnership: profile.includeOwnership, requireStablePv: teaching, requireMediumConfidence: teaching, allowStaleMs: profile.cacheTier === 'oracle' ? -1 : 1000 * 60 * 60 * 24 * 30 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 28509f7..1e35dc3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -45,7 +45,7 @@ import type { KeyMoveSummary } from './features/board/KeyMoveNavigator' import { TerritoryControlPanel, TerritorySummaryStrip } from './features/board/TerritoryJudgementPanel' import { WinrateTimelineV2 } from './features/board/WinrateTimelineV2' import { boardPointLabel, parseBoardPoint, type BoardPoint, type RenderKeyMove } from './features/board/boardGeometry' -import { buildTerritoryJudgement, type TerritoryDisplayMode } from './features/board/territoryJudgement' +import { buildTerritoryJudgement } from './features/board/territoryJudgement' import { addTrialMove, clearTrialMoves, @@ -824,7 +824,6 @@ export function App(): ReactElement { const [moveRange, setMoveRange] = useState<{ start: number; end: number } | null>(null) const [boardFlash, setBoardFlash] = useState<(BoardPoint & { nonce: number; label: string }) | null>(null) const [territoryEnabled, setTerritoryEnabled] = useState(false) - const [territoryMode, setTerritoryMode] = useState('heat') const [territoryBusy, setTerritoryBusy] = useState(false) const [busy, setBusy] = useState('') const [graphBusy, setGraphBusy] = useState(false) @@ -2621,6 +2620,17 @@ export function App(): ReactElement { } } + function toggleTerritoryJudgement(): void { + if (territoryEnabled) { + setTerritoryEnabled(false) + return + } + setTerritoryEnabled(true) + if (!territoryJudgement.available && !territoryBusy) { + void runTerritoryJudgementAnalysis() + } + } + async function runMoveAnalysisAt(targetMoveNumber: number): Promise { if (!record || !selectedGame || busy !== '') { return @@ -2962,12 +2972,10 @@ export function App(): ReactElement { territoryControl={( setTerritoryEnabled((value) => !value)} - onModeChange={setTerritoryMode} + onToggle={toggleTerritoryJudgement} onDeepen={() => void runTerritoryJudgementAnalysis()} t={t} /> @@ -3001,7 +3009,6 @@ export function App(): ReactElement { flashPoint={boardFlash} trialBranch={trialBranch.active ? trialBranch : null} territoryJudgement={territoryEnabled ? territoryJudgement : null} - territoryMode={territoryMode} onPointClick={trialBranch.active ? playTrialPoint : undefined} onPointContextMenu={trialBranch.active ? undoTrialBranch : undefined} t={t} diff --git a/src/renderer/src/features/board/TerritoryJudgementPanel.tsx b/src/renderer/src/features/board/TerritoryJudgementPanel.tsx index 282e1a7..06c75bd 100644 --- a/src/renderer/src/features/board/TerritoryJudgementPanel.tsx +++ b/src/renderer/src/features/board/TerritoryJudgementPanel.tsx @@ -1,15 +1,13 @@ import type { ReactElement } from 'react' import type { UiTranslator } from '../../i18n' -import type { TerritoryDisplayMode, TerritoryJudgement } from './territoryJudgement' +import type { TerritoryJudgement } from './territoryJudgement' interface TerritoryControlPanelProps { enabled: boolean - mode: TerritoryDisplayMode judgement: TerritoryJudgement busy?: boolean compact?: boolean onToggle: () => void - onModeChange: (mode: TerritoryDisplayMode) => void onDeepen: () => void t: UiTranslator } @@ -27,20 +25,12 @@ function confidenceLabel(confidence: TerritoryJudgement['confidence'], t: UiTran return t('territoryConfidenceMissing') } -function modeLabel(mode: TerritoryDisplayMode, t: UiTranslator): string { - if (mode === 'blocks') return t('territoryModeBlocks') - if (mode === 'marks') return t('territoryModeMarks') - return t('territoryModeHeat') -} - export function TerritoryControlPanel({ enabled, - mode, judgement, busy = false, compact = false, onToggle, - onModeChange, onDeepen, t }: TerritoryControlPanelProps): ReactElement { @@ -52,19 +42,6 @@ export function TerritoryControlPanel({ {enabled ? ( <> -
- {(['heat', 'blocks', 'marks'] as TerritoryDisplayMode[]).map((item) => ( - - ))} -
{t('territoryConfidence')}{confidenceLabel(judgement.confidence, t)} diff --git a/src/renderer/src/features/gallery/UiGallery.tsx b/src/renderer/src/features/gallery/UiGallery.tsx index 907fe7b..c462f8b 100644 --- a/src/renderer/src/features/gallery/UiGallery.tsx +++ b/src/renderer/src/features/gallery/UiGallery.tsx @@ -7,7 +7,7 @@ import { KeyMoveNavigator } from '../board/KeyMoveNavigator' import { TerritoryControlPanel, TerritorySummaryStrip } from '../board/TerritoryJudgementPanel' import { WinrateTimelineV2 } from '../board/WinrateTimelineV2' import { parseBoardPoint, type RenderKeyMove } from '../board/boardGeometry' -import { buildTerritoryJudgement, type TerritoryDisplayMode } from '../board/territoryJudgement' +import { buildTerritoryJudgement } from '../board/territoryJudgement' import { DiagnosticsPanel } from '../diagnostics/DiagnosticsPanel' import { BetaAcceptancePanel } from '../release/BetaAcceptancePanel' import { StudentBindingDialog } from '../student/StudentBindingDialog' @@ -52,7 +52,6 @@ export function UiGallery(): ReactElement { const [moveNumber, setMoveNumber] = useState(24) const [composerValue, setComposerValue] = useState('') const [territoryEnabled, setTerritoryEnabled] = useState(true) - const [territoryMode, setTerritoryMode] = useState('heat') const [dialogOpen, setDialogOpen] = useState(() => new URLSearchParams(window.location.search).has('dialog')) const boardKeyMoves = useMemo(() => keyMoveMarks(), []) const territoryJudgement = useMemo(() => buildTerritoryJudgement(galleryAnalysis, galleryRecord.boardSize), []) @@ -81,15 +80,12 @@ export function UiGallery(): ReactElement { analysis={galleryAnalysis} keyMoves={boardKeyMoves} territoryJudgement={territoryEnabled ? territoryJudgement : null} - territoryMode={territoryMode} />
setTerritoryEnabled((value) => !value)} - onModeChange={setTerritoryMode} onDeepen={() => undefined} t={t} /> diff --git a/src/renderer/src/i18n.ts b/src/renderer/src/i18n.ts index ed9176f..dc272a3 100644 --- a/src/renderer/src/i18n.ts +++ b/src/renderer/src/i18n.ts @@ -159,10 +159,6 @@ const ZH_CN = { startAnalysis: '开始分析', currentBoardMetrics: '当前局面数据', territoryJudgement: '形势判断', - territoryDisplayMode: '形势显示方式', - territoryModeHeat: '热力', - territoryModeBlocks: '方块', - territoryModeMarks: '标记', territoryConfidence: '置信 ', territoryConfidenceHigh: '高', territoryConfidenceMedium: '中', @@ -746,10 +742,6 @@ const EN_US: Record = { startAnalysis: 'Start', currentBoardMetrics: 'Current position metrics', territoryJudgement: 'Position', - territoryDisplayMode: 'Position display mode', - territoryModeHeat: 'Heat', - territoryModeBlocks: 'Blocks', - territoryModeMarks: 'Marks', territoryConfidence: 'Confidence ', territoryConfidenceHigh: 'High', territoryConfidenceMedium: 'Medium', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 122b19d..afb4421 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -1905,28 +1905,6 @@ label, color: #0b6d69; } -.territory-control__segments { - display: inline-flex; - padding: 3px; - gap: 2px; - border: 1px solid rgb(72 63 52 / 0.08); - border-radius: 999px; - background: rgb(37 40 43 / 0.055); -} - -.territory-control__segments button { - height: 23px; - padding: 0 9px; - color: #697078; - font-size: 11px; -} - -.territory-control__segments button.is-active { - background: #138f89; - box-shadow: 0 4px 12px rgb(19 143 137 / 0.22); - color: #fffdf4; -} - .territory-control__confidence { display: inline-flex; align-items: center; @@ -2024,16 +2002,6 @@ label, border-radius: 4px; } -.territory-control--compact .territory-control__segments { - padding: 2px; - gap: 1px; -} - -.territory-control--compact .territory-control__segments button { - height: 20px; - padding: 0 7px; -} - .territory-control--compact .territory-control__confidence { display: none; } @@ -2061,12 +2029,6 @@ label, background: rgb(128 104 72 / 0.16); } -@media (max-width: 1340px) { - .board-contextbar .territory-control--compact .territory-control__segments { - display: none; - } -} - @media (max-width: 1180px) { .board-contextbar .territory-control--compact .territory-control__legend { display: none; @@ -2088,15 +2050,6 @@ label, padding: 0 9px 0 7px; } - .territory-control__segments { - gap: 1px; - padding: 2px; - } - - .territory-control__segments button { - padding: 0 7px; - } - .territory-control__confidence { display: none; } diff --git a/tests/territory-judgement-contract.test.mjs b/tests/territory-judgement-contract.test.mjs index a32a46b..bf5cb72 100644 --- a/tests/territory-judgement-contract.test.mjs +++ b/tests/territory-judgement-contract.test.mjs @@ -49,6 +49,9 @@ test('workbench exposes positional judgement controls and summary without replac assert.match(app, /const \[territoryEnabled, setTerritoryEnabled\]/) assert.match(app, /buildTerritoryJudgement\(boardAnalysis/) assert.match(app, /runTerritoryJudgementAnalysis/) + assert.match(app, /function toggleTerritoryJudgement/) + assert.match(app, /!territoryJudgement\.available/) + assert.match(app, /void runTerritoryJudgementAnalysis\(\)/) assert.match(app, /territoryControl=\{\(/) const boardTableStart = app.indexOf('
') const boardComponentStart = app.indexOf('{boardRecord && boardRecord.boardSize >= 2', boardTableStart) @@ -56,7 +59,8 @@ test('workbench exposes positional judgement controls and summary without replac assert.doesNotMatch(app.slice(boardTableStart, boardComponentStart), / { diff --git a/tests/top3-v2-development-contract.test.mjs b/tests/top3-v2-development-contract.test.mjs index 283d0cf..dd0e317 100644 --- a/tests/top3-v2-development-contract.test.mjs +++ b/tests/top3-v2-development-contract.test.mjs @@ -20,6 +20,9 @@ test('top3 v2 adds main-side analysis cache and adaptive analysis profile contra assert.match(cache, /readAnalysisCache/) assert.match(cache, /writeAnalysisCache/) assert.match(cache, /qualityFromAnalysis/) + assert.match(cache, /requireOwnership\?: boolean/) + assert.match(cache, /hasRootOwnership/) + assert.match(cache, /cached analysis does not include root ownership/) assert.match(cache, /summarizeAnalysisCache/) assert.match(profile, /AdaptiveAnalysisIntent/) assert.match(profile, /buildAdaptiveAnalysisProfile/) diff --git a/tests/top3-v3-runtime-integration-contract.test.mjs b/tests/top3-v3-runtime-integration-contract.test.mjs index 428d396..29454f3 100644 --- a/tests/top3-v3-runtime-integration-contract.test.mjs +++ b/tests/top3-v3-runtime-integration-contract.test.mjs @@ -71,5 +71,5 @@ test('V3 analysis cache is tiered and runtime cache keys include engine fingerpr assert.match(runtime, /gameFingerprint/) assert.match(runtime, /runtimeFingerprints/) assert.match(runtime, /cacheRequirementForProfile/) + assert.match(runtime, /requireOwnership: profile\.includeOwnership/) }) - From d70b14199c7f0672286bda17bf057ab25bbfad45 Mon Sep 17 00:00:00 2001 From: haoc Date: Wed, 8 Jul 2026 00:15:27 +0800 Subject: [PATCH 6/9] fix: remove territory deepen control --- src/renderer/src/App.tsx | 1 - .../board/TerritoryJudgementPanel.tsx | 6 +---- .../src/features/gallery/UiGallery.tsx | 1 - src/renderer/src/i18n.ts | 2 -- src/renderer/src/styles.css | 27 +++++++++---------- tests/territory-judgement-contract.test.mjs | 4 +++ 6 files changed, 17 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1e35dc3..40e8771 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2976,7 +2976,6 @@ export function App(): ReactElement { busy={territoryBusy} compact onToggle={toggleTerritoryJudgement} - onDeepen={() => void runTerritoryJudgementAnalysis()} t={t} /> )} diff --git a/src/renderer/src/features/board/TerritoryJudgementPanel.tsx b/src/renderer/src/features/board/TerritoryJudgementPanel.tsx index 06c75bd..f533fc8 100644 --- a/src/renderer/src/features/board/TerritoryJudgementPanel.tsx +++ b/src/renderer/src/features/board/TerritoryJudgementPanel.tsx @@ -8,7 +8,6 @@ interface TerritoryControlPanelProps { busy?: boolean compact?: boolean onToggle: () => void - onDeepen: () => void t: UiTranslator } @@ -31,7 +30,6 @@ export function TerritoryControlPanel({ busy = false, compact = false, onToggle, - onDeepen, t }: TerritoryControlPanelProps): ReactElement { return ( @@ -53,9 +51,7 @@ export function TerritoryControlPanel({ {t('territoryContested')} - + {busy ? {t('timelineLoading')} : null} ) : null}
diff --git a/src/renderer/src/features/gallery/UiGallery.tsx b/src/renderer/src/features/gallery/UiGallery.tsx index c462f8b..1c15a7b 100644 --- a/src/renderer/src/features/gallery/UiGallery.tsx +++ b/src/renderer/src/features/gallery/UiGallery.tsx @@ -86,7 +86,6 @@ export function UiGallery(): ReactElement { enabled={territoryEnabled} judgement={territoryJudgement} onToggle={() => setTerritoryEnabled((value) => !value)} - onDeepen={() => undefined} t={t} />
diff --git a/src/renderer/src/i18n.ts b/src/renderer/src/i18n.ts index dc272a3..c6e6047 100644 --- a/src/renderer/src/i18n.ts +++ b/src/renderer/src/i18n.ts @@ -164,7 +164,6 @@ const ZH_CN = { territoryConfidenceMedium: '中', territoryConfidenceLow: '低', territoryConfidenceMissing: '待分析', - territoryDeepen: '加深', territoryLead: '形势', territoryBlackStrong: '黑势', territoryWhiteStrong: '白势', @@ -747,7 +746,6 @@ const EN_US: Record = { territoryConfidenceMedium: 'Medium', territoryConfidenceLow: 'Low', territoryConfidenceMissing: 'Needs analysis', - territoryDeepen: 'Deepen', territoryLead: 'Position', territoryBlackStrong: 'Black', territoryWhiteStrong: 'White', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index afb4421..19e905f 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -1968,13 +1968,6 @@ label, border: 1px solid rgb(255 248 220 / 0.56); } -.territory-control__deepen { - padding: 0 10px; - background: #22282d !important; - color: #fff9eb !important; - box-shadow: 0 6px 16px rgb(35 40 45 / 0.18); -} - .territory-control--compact { max-width: none; min-height: 28px; @@ -2018,9 +2011,17 @@ label, height: 7px; } -.territory-control--compact .territory-control__deepen { +.territory-control__busy { + display: inline-flex; + align-items: center; height: 24px; - padding: 0 8px; + padding: 0 7px; + border-radius: 999px; + color: #0b6d69; + font-size: 9.5px; + font-weight: 900; + white-space: nowrap; + background: rgb(221 246 241 / 0.7); } .analysis-control-strip__divider { @@ -2033,10 +2034,6 @@ label, .board-contextbar .territory-control--compact .territory-control__legend { display: none; } - - .board-contextbar .territory-control--compact .territory-control__deepen { - display: none; - } } @media (max-width: 720px) { @@ -2058,8 +2055,8 @@ label, display: none; } - .territory-control__deepen { - padding: 0 8px; + .territory-control__busy { + display: none; } } diff --git a/tests/territory-judgement-contract.test.mjs b/tests/territory-judgement-contract.test.mjs index bf5cb72..defa621 100644 --- a/tests/territory-judgement-contract.test.mjs +++ b/tests/territory-judgement-contract.test.mjs @@ -61,6 +61,8 @@ test('workbench exposes positional judgement controls and summary without replac assert.match(app, / { From b21008c84354b6927c20e42cc8e318ce5722a61a Mon Sep 17 00:00:00 2001 From: haoc Date: Wed, 8 Jul 2026 00:20:04 +0800 Subject: [PATCH 7/9] fix: mark stones inside opposing territory --- src/renderer/src/features/board/GoBoardV2.tsx | 56 ++++++++++++++++- src/renderer/src/features/board/board-v2.css | 60 +++++++++++++++++++ tests/territory-judgement-contract.test.mjs | 7 +++ 3 files changed, 122 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/features/board/GoBoardV2.tsx b/src/renderer/src/features/board/GoBoardV2.tsx index 2c1c004..b3cfe9e 100644 --- a/src/renderer/src/features/board/GoBoardV2.tsx +++ b/src/renderer/src/features/board/GoBoardV2.tsx @@ -15,11 +15,12 @@ import { type RenderCandidate, type RenderKeyMove, type RenderPlayedMove, + type RenderStone, type RenderVariationMove } from './boardGeometry' import type { CandidateTooltipMove, CandidateTooltipPosition } from './CandidateTooltip' import type { TrialBranch } from './trialBranch' -import type { TerritoryDisplayMode, TerritoryJudgement } from './territoryJudgement' +import type { TerritoryDisplayMode, TerritoryJudgement, TerritoryOwner } from './territoryJudgement' import './board-v2.css' interface GoBoardV2Props { @@ -53,6 +54,13 @@ type HoveredCandidate = { position: CandidateTooltipPosition } | null type BoardHoverEvent = PointerEvent | MouseEvent +type TerritoryStrandedStone = RenderStone & { + territoryOwner: TerritoryOwner + strength: number + territoryLabel: string +} + +const STRANDED_STONE_THRESHOLD = 0.48 function valueOf(record: unknown, key: string): unknown { return typeof record === 'object' && record !== null ? (record as Record)[key] : undefined @@ -302,6 +310,43 @@ function TrialStoneMark({ move, boardSize }: { move: TrialBranch['moves'][number ) } +function territoryStrandedStones(stones: RenderStone[], judgement: TerritoryJudgement | null | undefined): TerritoryStrandedStone[] { + if (!judgement?.available || judgement.cells.length === 0) { + return [] + } + const ownershipByPoint = new Map(judgement.cells.map((cell) => [`${cell.x},${cell.y}`, cell])) + return stones.flatMap((stone) => { + const ownership = ownershipByPoint.get(`${stone.x},${stone.y}`) + if (!ownership || ownership.owner === stone.color || ownership.strength < STRANDED_STONE_THRESHOLD) { + return [] + } + return [{ + ...stone, + territoryOwner: ownership.owner, + strength: ownership.strength, + territoryLabel: ownership.label + }] + }) +} + +function TerritoryStrandedStoneMark({ marker, boardSize }: { marker: TerritoryStrandedStone; boardSize: number }): ReactElement { + const p = xy(marker, boardSize) + const ownerLabel = marker.territoryOwner === 'B' ? '黑地' : '白地' + const stoneLabel = marker.color === 'B' ? '黑子' : '白子' + return ( + = 0.68 ? 'ks-territory-stranded-stone--strong' : ''}`} + transform={`translate(${p.x} ${p.y})`} + aria-label={`${marker.territoryLabel} ${ownerLabel}中的${stoneLabel}`} + > + + + + + + ) +} + function TerritoryOverlay({ judgement, mode, @@ -396,6 +441,7 @@ export function GoBoardV2({ const stones = useMemo(() => renderStones(record, moveNumber), [record, moveNumber]) const candidates = useMemo(() => renderCandidates(analysis, boardSize), [analysis, boardSize]) const playedMove = useMemo(() => renderPlayedMove(analysis, boardSize), [analysis, boardSize]) + const strandedStones = useMemo(() => territoryStrandedStones(stones, territoryJudgement), [stones, territoryJudgement]) const variationFirstColor = analysis?.trialContext?.active ? analysis.trialContext.nextColor : moveToColor(analysis?.currentMove ?? (moveNumber > 0 ? record.moves[moveNumber - 1] : undefined)) @@ -614,6 +660,14 @@ export function GoBoardV2({ })} + {strandedStones.length > 0 ? ( + + {strandedStones.map((marker) => ( + + ))} + + ) : null} + {trialBranch?.active && trialBranch.moves.length > 0 ? ( {trialBranch.moves.map((move) => ( diff --git a/src/renderer/src/features/board/board-v2.css b/src/renderer/src/features/board/board-v2.css index b82602d..082e20b 100644 --- a/src/renderer/src/features/board/board-v2.css +++ b/src/renderer/src/features/board/board-v2.css @@ -176,6 +176,66 @@ opacity: 0.24; } +.ks-territory-stranded-stones-layer { + pointer-events: none; +} + +.ks-territory-stranded-stone { + pointer-events: none; + opacity: 0.9; + filter: drop-shadow(0 2px 3px rgb(40 25 10 / 0.2)); +} + +.ks-territory-stranded-stone__halo, +.ks-territory-stranded-stone__ring, +.ks-territory-stranded-stone__tick { + fill: none; + vector-effect: non-scaling-stroke; +} + +.ks-territory-stranded-stone__halo { + stroke: var(--ks-territory-stranded-halo); + stroke-width: 3.8; + opacity: 0.62; +} + +.ks-territory-stranded-stone__ring { + stroke: var(--ks-territory-stranded-ring); + stroke-width: 2.2; + stroke-linecap: round; + opacity: 0.92; +} + +.ks-territory-stranded-stone__tick { + stroke: var(--ks-territory-stranded-ring); + stroke-width: 2.45; + stroke-linecap: round; + opacity: 0.78; +} + +.ks-territory-stranded-stone__tick--b { + opacity: 0.5; +} + +.ks-territory-stranded-stone--strong .ks-territory-stranded-stone__ring { + stroke-width: 2.65; + opacity: 0.98; +} + +.ks-territory-stranded-stone--strong .ks-territory-stranded-stone__tick { + opacity: 0.92; +} + +.ks-territory-stranded-stone--in-B { + --ks-territory-stranded-ring: rgba(12, 15, 17, 0.88); + --ks-territory-stranded-halo: rgba(255, 247, 221, 0.58); +} + +.ks-territory-stranded-stone--in-W { + --ks-territory-stranded-ring: rgba(255, 251, 238, 0.94); + --ks-territory-stranded-halo: rgba(20, 26, 28, 0.5); +} + .ks-trial-stones-layer { pointer-events: none; } diff --git a/tests/territory-judgement-contract.test.mjs b/tests/territory-judgement-contract.test.mjs index defa621..c099c76 100644 --- a/tests/territory-judgement-contract.test.mjs +++ b/tests/territory-judgement-contract.test.mjs @@ -29,10 +29,17 @@ test('renderer has a reusable territory judgement model and premium board overla assert.match(model, /best-continuation/) assert.match(board, /territoryJudgement\?: TerritoryJudgement/) assert.match(board, /function TerritoryOverlay/) + assert.match(board, /function territoryStrandedStones/) + assert.match(board, /function TerritoryStrandedStoneMark/) assert.match(board, /ks-territory-layer/) + assert.match(board, /ks-territory-stranded-stones-layer/) + assert.match(board, /ks-territory-stranded-stone--in-/) assert.match(board, /ks-territory-dot--/) assert.match(board, /visualOwner = isUnclear \? 'unclear' : cellInfo\.owner/) assert.match(css, /\.ks-territory-layer--heat/) + assert.match(css, /\.ks-territory-stranded-stones-layer/) + assert.match(css, /\.ks-territory-stranded-stone--in-B/) + assert.match(css, /\.ks-territory-stranded-stone--in-W/) assert.match(css, /\.ks-territory-layer--blocks/) assert.match(css, /\.ks-territory-layer--marks/) assert.match(css, /\.ks-territory-dot--B/) From 9fc74848ab12e2ef489976b060053e3cfa75cc9f Mon Sep 17 00:00:00 2001 From: haoc Date: Wed, 8 Jul 2026 00:29:53 +0800 Subject: [PATCH 8/9] fix: refine territory stone ownership marks --- src/renderer/src/App.tsx | 10 ++- src/renderer/src/features/board/GoBoardV2.tsx | 42 +++++++++--- src/renderer/src/features/board/board-v2.css | 64 ++++++++----------- .../src/features/board/territoryJudgement.ts | 34 +++++++++- .../src/features/gallery/UiGallery.tsx | 7 +- tests/territory-judgement-contract.test.mjs | 7 ++ 6 files changed, 112 insertions(+), 52 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 40e8771..6848a83 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -44,7 +44,7 @@ import { GoBoardV2 } from './features/board/GoBoardV2' import type { KeyMoveSummary } from './features/board/KeyMoveNavigator' import { TerritoryControlPanel, TerritorySummaryStrip } from './features/board/TerritoryJudgementPanel' import { WinrateTimelineV2 } from './features/board/WinrateTimelineV2' -import { boardPointLabel, parseBoardPoint, type BoardPoint, type RenderKeyMove } from './features/board/boardGeometry' +import { boardPointLabel, parseBoardPoint, renderStones, type BoardPoint, type RenderKeyMove } from './features/board/boardGeometry' import { buildTerritoryJudgement } from './features/board/territoryJudgement' import { addTrialMove, @@ -1105,9 +1105,13 @@ export function App(): ReactElement { const boardRecord = trialRecord ?? record const boardMoveNumber = trialRecord ? trialRecord.moves.length : moveNumber const boardAnalysis = trialBranch.active ? trialAnalysis : currentAnalysis + const boardTerritoryStones = useMemo( + () => boardRecord ? renderStones(boardRecord, boardMoveNumber) : [], + [boardRecord, boardMoveNumber] + ) const territoryJudgement = useMemo( - () => buildTerritoryJudgement(boardAnalysis, boardRecord?.boardSize ?? record?.boardSize ?? 19), - [boardAnalysis, boardRecord?.boardSize, record?.boardSize] + () => buildTerritoryJudgement(boardAnalysis, boardRecord?.boardSize ?? record?.boardSize ?? 19, boardTerritoryStones), + [boardAnalysis, boardRecord?.boardSize, boardTerritoryStones, record?.boardSize] ) const displayBoardKeyMoveMarks = trialBranch.active ? [] : currentBoardKeyMoveMarks diff --git a/src/renderer/src/features/board/GoBoardV2.tsx b/src/renderer/src/features/board/GoBoardV2.tsx index b3cfe9e..1965520 100644 --- a/src/renderer/src/features/board/GoBoardV2.tsx +++ b/src/renderer/src/features/board/GoBoardV2.tsx @@ -60,7 +60,7 @@ type TerritoryStrandedStone = RenderStone & { territoryLabel: string } -const STRANDED_STONE_THRESHOLD = 0.48 +const STRANDED_STONE_THRESHOLD = 0.68 function valueOf(record: unknown, key: string): unknown { return typeof record === 'object' && record !== null ? (record as Record)[key] : undefined @@ -315,7 +315,7 @@ function territoryStrandedStones(stones: RenderStone[], judgement: TerritoryJudg return [] } const ownershipByPoint = new Map(judgement.cells.map((cell) => [`${cell.x},${cell.y}`, cell])) - return stones.flatMap((stone) => { + const candidates = stones.flatMap((stone) => { const ownership = ownershipByPoint.get(`${stone.x},${stone.y}`) if (!ownership || ownership.owner === stone.color || ownership.strength < STRANDED_STONE_THRESHOLD) { return [] @@ -327,22 +327,42 @@ function territoryStrandedStones(stones: RenderStone[], judgement: TerritoryJudg territoryLabel: ownership.label }] }) + if (candidates.length > Math.max(8, stones.length * 0.32)) { + return [] + } + return candidates } function TerritoryStrandedStoneMark({ marker, boardSize }: { marker: TerritoryStrandedStone; boardSize: number }): ReactElement { const p = xy(marker, boardSize) const ownerLabel = marker.territoryOwner === 'B' ? '黑地' : '白地' const stoneLabel = marker.color === 'B' ? '黑子' : '白子' + const markerSize = 9.5 + Math.min(5.5, Math.max(0, marker.strength - STRANDED_STONE_THRESHOLD) * 18) return ( = 0.68 ? 'ks-territory-stranded-stone--strong' : ''}`} + className={`ks-territory-stranded-stone ks-territory-stranded-stone--in-${marker.territoryOwner} ${marker.strength >= 0.82 ? 'ks-territory-stranded-stone--strong' : ''}`} transform={`translate(${p.x} ${p.y})`} aria-label={`${marker.territoryLabel} ${ownerLabel}中的${stoneLabel}`} + style={{ opacity: Math.min(0.9, 0.58 + marker.strength * 0.26) }} > - - - - + + ) } @@ -442,6 +462,7 @@ export function GoBoardV2({ const candidates = useMemo(() => renderCandidates(analysis, boardSize), [analysis, boardSize]) const playedMove = useMemo(() => renderPlayedMove(analysis, boardSize), [analysis, boardSize]) const strandedStones = useMemo(() => territoryStrandedStones(stones, territoryJudgement), [stones, territoryJudgement]) + const strandedStoneKeys = useMemo(() => new Set(strandedStones.map((stone) => `${stone.x},${stone.y}`)), [strandedStones]) const variationFirstColor = analysis?.trialContext?.active ? analysis.trialContext.nextColor : moveToColor(analysis?.currentMove ?? (moveNumber > 0 ? record.moves[moveNumber - 1] : undefined)) @@ -649,8 +670,13 @@ export function GoBoardV2({ {stones.map((stone) => { const p = xy(stone, boardSize) const isPreviousMove = stone.moveNumber === previousMoveNumber + const isTerritoryStranded = strandedStoneKeys.has(`${stone.x},${stone.y}`) return ( - + diff --git a/src/renderer/src/features/board/board-v2.css b/src/renderer/src/features/board/board-v2.css index 082e20b..d9c9dc5 100644 --- a/src/renderer/src/features/board/board-v2.css +++ b/src/renderer/src/features/board/board-v2.css @@ -176,64 +176,54 @@ opacity: 0.24; } +.ks-stone--territory-stranded .ks-stone-body { + opacity: 0.86; +} + +.ks-stone--territory-stranded .ks-stone-highlight { + opacity: 0.18; +} + .ks-territory-stranded-stones-layer { pointer-events: none; } .ks-territory-stranded-stone { pointer-events: none; - opacity: 0.9; - filter: drop-shadow(0 2px 3px rgb(40 25 10 / 0.2)); + filter: drop-shadow(0 1.4px 2px rgb(40 25 10 / 0.18)); } -.ks-territory-stranded-stone__halo, -.ks-territory-stranded-stone__ring, -.ks-territory-stranded-stone__tick { - fill: none; +.ks-territory-stranded-stone__chip, +.ks-territory-stranded-stone__chip-core { vector-effect: non-scaling-stroke; } -.ks-territory-stranded-stone__halo { - stroke: var(--ks-territory-stranded-halo); - stroke-width: 3.8; - opacity: 0.62; -} - -.ks-territory-stranded-stone__ring { - stroke: var(--ks-territory-stranded-ring); - stroke-width: 2.2; - stroke-linecap: round; - opacity: 0.92; -} - -.ks-territory-stranded-stone__tick { - stroke: var(--ks-territory-stranded-ring); - stroke-width: 2.45; - stroke-linecap: round; - opacity: 0.78; -} - -.ks-territory-stranded-stone__tick--b { - opacity: 0.5; +.ks-territory-stranded-stone__chip { + fill: var(--ks-territory-stranded-fill); + stroke: var(--ks-territory-stranded-stroke); + stroke-width: 1.35; + paint-order: stroke; } -.ks-territory-stranded-stone--strong .ks-territory-stranded-stone__ring { - stroke-width: 2.65; - opacity: 0.98; +.ks-territory-stranded-stone__chip-core { + fill: var(--ks-territory-stranded-core); + opacity: 0.72; } -.ks-territory-stranded-stone--strong .ks-territory-stranded-stone__tick { - opacity: 0.92; +.ks-territory-stranded-stone--strong .ks-territory-stranded-stone__chip { + stroke-width: 1.55; } .ks-territory-stranded-stone--in-B { - --ks-territory-stranded-ring: rgba(12, 15, 17, 0.88); - --ks-territory-stranded-halo: rgba(255, 247, 221, 0.58); + --ks-territory-stranded-fill: rgba(12, 15, 17, 0.88); + --ks-territory-stranded-stroke: rgba(255, 247, 221, 0.72); + --ks-territory-stranded-core: rgba(255, 247, 221, 0.44); } .ks-territory-stranded-stone--in-W { - --ks-territory-stranded-ring: rgba(255, 251, 238, 0.94); - --ks-territory-stranded-halo: rgba(20, 26, 28, 0.5); + --ks-territory-stranded-fill: rgba(255, 251, 238, 0.92); + --ks-territory-stranded-stroke: rgba(20, 26, 28, 0.62); + --ks-territory-stranded-core: rgba(20, 26, 28, 0.3); } .ks-trial-stones-layer { diff --git a/src/renderer/src/features/board/territoryJudgement.ts b/src/renderer/src/features/board/territoryJudgement.ts index 321ffd2..a1f3801 100644 --- a/src/renderer/src/features/board/territoryJudgement.ts +++ b/src/renderer/src/features/board/territoryJudgement.ts @@ -13,6 +13,10 @@ export interface TerritoryCell extends BoardPoint { label: string } +export interface TerritoryBoardStone extends BoardPoint { + color: TerritoryOwner +} + export interface TerritoryRegionSummary { id: string label: string @@ -43,6 +47,7 @@ export interface TerritoryJudgement { const OWNERSHIP_VISUAL_THRESHOLD = 0.055 const STRONG_OWNERSHIP_THRESHOLD = 0.52 const UNSETTLED_THRESHOLD = 0.24 +const OWNERSHIP_STONE_CALIBRATION_THRESHOLD = 0.18 function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)) @@ -75,6 +80,26 @@ function ownershipSource(analysis: KataGoMoveAnalysis | null | undefined, boardS return { source: bestContinuation ? 'best-continuation' : 'unavailable', ownership: bestContinuation } } +function calibratedOwnershipSign(ownership: number[], boardSize: number, stones: TerritoryBoardStone[] = []): 1 | -1 { + let agreement = 0 + let evidence = 0 + for (const stone of stones) { + if (stone.x < 0 || stone.y < 0 || stone.x >= boardSize || stone.y >= boardSize) { + continue + } + const raw = finite(ownership[stone.y * boardSize + stone.x]) + if (typeof raw !== 'number' || Math.abs(raw) < OWNERSHIP_STONE_CALIBRATION_THRESHOLD) { + continue + } + agreement += stone.color === 'B' ? raw : -raw + evidence += Math.abs(raw) + } + // KataGo ownership should usually agree with stones already on the board. + // Some engines/transports expose the sign from the other perspective; flip it + // when the actual stones give clear opposite-color evidence. + return evidence >= 3 && agreement < -evidence * 0.18 ? -1 : 1 +} + function confidenceFor(input: { source: TerritorySource analysis?: KataGoMoveAnalysis | null @@ -158,7 +183,11 @@ function buildRegions(cells: TerritoryCell[], boardSize: number): TerritoryRegio }).sort((a, b) => Math.abs(b.average) - Math.abs(a.average)).slice(0, 5) } -export function buildTerritoryJudgement(analysis: KataGoMoveAnalysis | null | undefined, boardSizeInput = 19): TerritoryJudgement { +export function buildTerritoryJudgement( + analysis: KataGoMoveAnalysis | null | undefined, + boardSizeInput = 19, + stones: TerritoryBoardStone[] = [] +): TerritoryJudgement { const boardSize = Math.max(2, Math.round(boardSizeInput || analysis?.boardSize || 19)) const { source, ownership } = ownershipSource(analysis, boardSize) const scoreLead = finite(analysis?.after.scoreLead) @@ -183,6 +212,7 @@ export function buildTerritoryJudgement(analysis: KataGoMoveAnalysis | null | un } } + const sign = calibratedOwnershipSign(ownership, boardSize, stones) let blackStrong = 0 let whiteStrong = 0 let unsettled = 0 @@ -191,7 +221,7 @@ export function buildTerritoryJudgement(analysis: KataGoMoveAnalysis | null | un const cells: TerritoryCell[] = [] for (let y = 0; y < boardSize; y += 1) { for (let x = 0; x < boardSize; x += 1) { - const value = clamp(Number(ownership[y * boardSize + x] ?? 0), -1, 1) + const value = clamp(Number(ownership[y * boardSize + x] ?? 0) * sign, -1, 1) const strength = Math.abs(value) if (value >= STRONG_OWNERSHIP_THRESHOLD) blackStrong += 1 if (value <= -STRONG_OWNERSHIP_THRESHOLD) whiteStrong += 1 diff --git a/src/renderer/src/features/gallery/UiGallery.tsx b/src/renderer/src/features/gallery/UiGallery.tsx index 1c15a7b..e97adc6 100644 --- a/src/renderer/src/features/gallery/UiGallery.tsx +++ b/src/renderer/src/features/gallery/UiGallery.tsx @@ -6,7 +6,7 @@ import { GoBoardV2 } from '../board/GoBoardV2' import { KeyMoveNavigator } from '../board/KeyMoveNavigator' import { TerritoryControlPanel, TerritorySummaryStrip } from '../board/TerritoryJudgementPanel' import { WinrateTimelineV2 } from '../board/WinrateTimelineV2' -import { parseBoardPoint, type RenderKeyMove } from '../board/boardGeometry' +import { parseBoardPoint, renderStones, type RenderKeyMove } from '../board/boardGeometry' import { buildTerritoryJudgement } from '../board/territoryJudgement' import { DiagnosticsPanel } from '../diagnostics/DiagnosticsPanel' import { BetaAcceptancePanel } from '../release/BetaAcceptancePanel' @@ -54,7 +54,10 @@ export function UiGallery(): ReactElement { const [territoryEnabled, setTerritoryEnabled] = useState(true) const [dialogOpen, setDialogOpen] = useState(() => new URLSearchParams(window.location.search).has('dialog')) const boardKeyMoves = useMemo(() => keyMoveMarks(), []) - const territoryJudgement = useMemo(() => buildTerritoryJudgement(galleryAnalysis, galleryRecord.boardSize), []) + const territoryJudgement = useMemo( + () => buildTerritoryJudgement(galleryAnalysis, galleryRecord.boardSize, renderStones(galleryRecord, moveNumber)), + [moveNumber] + ) const t = useMemo(() => createUiTranslator('zh-CN'), []) return ( diff --git a/tests/territory-judgement-contract.test.mjs b/tests/territory-judgement-contract.test.mjs index c099c76..ce364a5 100644 --- a/tests/territory-judgement-contract.test.mjs +++ b/tests/territory-judgement-contract.test.mjs @@ -24,6 +24,8 @@ test('renderer has a reusable territory judgement model and premium board overla const css = read('src/renderer/src/features/board/board-v2.css') assert.match(model, /export function buildTerritoryJudgement/) + assert.match(model, /function calibratedOwnershipSign/) + assert.match(model, /OWNERSHIP_STONE_CALIBRATION_THRESHOLD/) assert.match(model, /TerritoryDisplayMode = 'heat' \| 'blocks' \| 'marks'/) assert.match(model, /source: 'root'/) assert.match(model, /best-continuation/) @@ -31,6 +33,7 @@ test('renderer has a reusable territory judgement model and premium board overla assert.match(board, /function TerritoryOverlay/) assert.match(board, /function territoryStrandedStones/) assert.match(board, /function TerritoryStrandedStoneMark/) + assert.match(board, /candidates\.length > Math\.max\(8, stones\.length \* 0\.32\)/) assert.match(board, /ks-territory-layer/) assert.match(board, /ks-territory-stranded-stones-layer/) assert.match(board, /ks-territory-stranded-stone--in-/) @@ -40,6 +43,9 @@ test('renderer has a reusable territory judgement model and premium board overla assert.match(css, /\.ks-territory-stranded-stones-layer/) assert.match(css, /\.ks-territory-stranded-stone--in-B/) assert.match(css, /\.ks-territory-stranded-stone--in-W/) + assert.match(css, /\.ks-territory-stranded-stone__chip/) + assert.doesNotMatch(css, /ks-territory-stranded-stone__ring/) + assert.doesNotMatch(css, /ks-territory-stranded-stone__halo/) assert.match(css, /\.ks-territory-layer--blocks/) assert.match(css, /\.ks-territory-layer--marks/) assert.match(css, /\.ks-territory-dot--B/) @@ -93,6 +99,7 @@ test('UI gallery includes positional judgement sample data for visual QA', () => assert.match(mock, /function mockOwnership/) assert.match(mock, /ownership: mockOwnership\(19\)/) + assert.match(gallery, /renderStones\(galleryRecord, moveNumber\)/) assert.match(gallery, /TerritoryControlPanel/) assert.match(gallery, /TerritorySummaryStrip/) assert.match(gallery, /buildTerritoryJudgement\(galleryAnalysis/) From 72d2f0f04ae4212686ca583b22aab845a43ba296 Mon Sep 17 00:00:00 2001 From: haoc Date: Wed, 8 Jul 2026 00:32:41 +0800 Subject: [PATCH 9/9] fix: center territory stone marks --- src/renderer/src/features/board/GoBoardV2.tsx | 14 +++++++------- tests/territory-judgement-contract.test.mjs | 2 ++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/features/board/GoBoardV2.tsx b/src/renderer/src/features/board/GoBoardV2.tsx index 1965520..5befc67 100644 --- a/src/renderer/src/features/board/GoBoardV2.tsx +++ b/src/renderer/src/features/board/GoBoardV2.tsx @@ -337,7 +337,7 @@ function TerritoryStrandedStoneMark({ marker, boardSize }: { marker: TerritorySt const p = xy(marker, boardSize) const ownerLabel = marker.territoryOwner === 'B' ? '黑地' : '白地' const stoneLabel = marker.color === 'B' ? '黑子' : '白子' - const markerSize = 9.5 + Math.min(5.5, Math.max(0, marker.strength - STRANDED_STONE_THRESHOLD) * 18) + const markerSize = 8.8 + Math.min(5.2, Math.max(0, marker.strength - STRANDED_STONE_THRESHOLD) * 17) return ( = 0.82 ? 'ks-territory-stranded-stone--strong' : ''}`} @@ -347,21 +347,21 @@ function TerritoryStrandedStoneMark({ marker, boardSize }: { marker: TerritorySt > ) diff --git a/tests/territory-judgement-contract.test.mjs b/tests/territory-judgement-contract.test.mjs index ce364a5..35ebb86 100644 --- a/tests/territory-judgement-contract.test.mjs +++ b/tests/territory-judgement-contract.test.mjs @@ -34,6 +34,8 @@ test('renderer has a reusable territory judgement model and premium board overla assert.match(board, /function territoryStrandedStones/) assert.match(board, /function TerritoryStrandedStoneMark/) assert.match(board, /candidates\.length > Math\.max\(8, stones\.length \* 0\.32\)/) + assert.match(board, /transform="rotate\(45\)"/) + assert.doesNotMatch(board, /rotate\(45 \$\{15\} \$\{-22\}\)/) assert.match(board, /ks-territory-layer/) assert.match(board, /ks-territory-stranded-stones-layer/) assert.match(board, /ks-territory-stranded-stone--in-/)