Skip to content

Commit

Permalink
Merge pull request #59 from sleeplessghost/add-eval-chart
Browse files Browse the repository at this point in the history
Add eval chart
  • Loading branch information
franciscoBSalgueiro authored Oct 29, 2023
2 parents ade55bb + af729eb commit 9bef187
Show file tree
Hide file tree
Showing 7 changed files with 278 additions and 59 deletions.
3 changes: 2 additions & 1 deletion src/components/boards/BoardAnalysis.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Stack, Tabs } from "@mantine/core";
import { Paper, Stack, Tabs } from "@mantine/core";
import { useHotkeys, useToggle } from "@mantine/hooks";
import {
IconDatabase,
Expand Down Expand Up @@ -33,6 +33,7 @@ import {
currentTabSelectedAtom,
} from "@/atoms/atoms";
import { saveToFile } from "@/utils/tabs";
import EvalChart from "../common/EvalChart";

function BoardAnalysis() {
const [editingMode, toggleEditingMode] = useToggle();
Expand Down
175 changes: 175 additions & 0 deletions src/components/common/EvalChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { useContext } from "react";
import { TreeDispatchContext, TreeStateContext } from "./TreeStateContext";
import { Box, LoadingOverlay, createStyles } from "@mantine/core";
import { Stack } from "@mantine/core"
import { ResponsiveContainer, Tooltip as RechartsTooltip, AreaChart, Area, XAxis, YAxis, ReferenceLine } from "recharts";
import { ListNode, TreeNode, treeIteratorMainLine } from "@/utils/treeReducer";
import { ANNOTATION_INFO } from "@/utils/chess";
import { formatScore } from "@/utils/score";
import { arrayEquals, skipWhile, takeWhile } from "@/utils/helperFunctions";
import { Chess } from "chess.js";

interface EvalChartProps {
isAnalysing: boolean;
startAnalysis: () => void;
}

type DataPoint = {
name: string;
evalText: string;
yValue: number | undefined;
altValue: number | undefined;
movePath: number[];
}

const useStyles = createStyles(theme => ({
tooltip: {
margin: 0,
padding: 5,
backgroundColor: theme.colorScheme === 'light' ? 'white' : theme.colors.dark[3],
color: theme.colorScheme === 'light' ? 'black': 'white',
opacity: 0.8,
border: '1px solid #ccc',
whiteSpace: 'nowrap',
},
tooltipTitle: {
fontWeight: 'bold'
}
}));

const EvalChart = (props: EvalChartProps) => {
const { root, position } = useContext(TreeStateContext);
const dispatch = useContext(TreeDispatchContext);
const { classes, theme } = useStyles();
const isLightTheme = theme.colorScheme == 'light';

function getYValue(node: TreeNode): number | undefined {
if (node.score) {
let cp: number = node.score.value;
if (node.score.type == "mate") {
cp = node.score.value > 0 ? Infinity : -Infinity;
}
return 2 / (1 + Math.exp(-0.004 * cp)) - 1;
} else if (node.children.length == 0) {
try {
const chess = new Chess(node.fen);
if (chess.isCheckmate()) {
return node.move!.color == 'w' ? 1 : -1;
} else if (chess.isDraw() || chess.isStalemate()) {
return 0;
}
} catch (error) {}
}
}

function getEvalText(node: TreeNode): string {
if (node.score) {
return `Advantage: ${formatScore(node.score)}`;
} else if (node.children.length == 0) {
try {
const chess = new Chess(node.fen);
if (chess.isCheckmate()) return "Checkmate";
else if (chess.isStalemate()) return "Stalemate";
else if (chess.isDraw()) return "Draw";
} catch (error) {}
}
return 'Not analysed';
}

function getNodes(): ListNode[] {
const allNodes = treeIteratorMainLine(root);
const withoutRoot = skipWhile(allNodes, (node: ListNode) => node.position.length == 0);
const withMoves = takeWhile(withoutRoot, (node: ListNode) => node.node.move != undefined);
return [...withMoves];
}

function* getData(): Iterable<DataPoint> {
const nodes = getNodes();
for (let i = 0; i < nodes.length; i++) {
const prevNode = nodes[i-1]?.node;
const currentNode = nodes[i];
const nextNode = nodes[i+1]?.node;

const move = currentNode.node.move!;
const annotation = currentNode.node.annotation ? ANNOTATION_INFO[currentNode.node.annotation].name.toLowerCase() : undefined;
const yValue = getYValue(currentNode.node);
//hiding gaps in chart areas between analysed and unanalysed positions
const needsAltValue = yValue == undefined ||
(prevNode && !prevNode.score) ||
(nextNode && !nextNode.score);
yield {
name: `${Math.ceil(currentNode.node.halfMoves / 2)}.${move.color === 'w' ? '' : '..'} ${move.san}${annotation ? ` (${annotation})` : ''}`,
evalText: getEvalText(currentNode.node),
yValue: yValue,
altValue: needsAltValue ? 0 : undefined,
movePath: currentNode.position
}
}
}

function gradientOffset(data: DataPoint[]) {
const dataMax = Math.max(...data.map((i) => i.yValue ?? 0));
const dataMin = Math.min(...data.map((i) => i.yValue ?? 0));

if (dataMax <= 0) return 0;
if (dataMin >= 0) return 1;

return dataMax / (dataMax - dataMin);
}

const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length && payload[0].payload) {
const dataPoint: DataPoint = payload[0].payload;
return (
<div className={classes.tooltip}>
<div className={classes.tooltipTitle}>{dataPoint.name}</div>
<div>{dataPoint.evalText}</div>
</div>
);
}
return null;
};

const onChartClick = (data: any) => {
if (data && data.activePayload && data.activePayload.length && data.activePayload[0].payload) {
const dataPoint: DataPoint = data.activePayload[0].payload;
dispatch({
type: "GO_TO_MOVE",
payload: dataPoint.movePath,
});
}
}

const data = [...getData()];
const currentPositionName = data.find(point => arrayEquals(point.movePath, position))?.name;
const colouroffset = gradientOffset(data);
const areaChartPropsHack = { cursor: "pointer" } as any;

return (
<Stack>
<Box pos="relative">
<LoadingOverlay visible={props.isAnalysing == true} />
<ResponsiveContainer width="99%" height={220}>
<AreaChart data={data} onClick={onChartClick} {...areaChartPropsHack}>
<RechartsTooltip content={<CustomTooltip />} />
<XAxis hide dataKey="name" type="category" />
<YAxis hide dataKey="yValue" domain={[-1, 1]} />
<defs>
<linearGradient id="splitColor" x1="0" y1="0" x2="0" y2="1">
<stop offset={colouroffset} stopColor={isLightTheme ? '#e6e6e6' : '#ccc'} stopOpacity={1} />
<stop offset={colouroffset} stopColor={isLightTheme ? '#333333' : '#000'} stopOpacity={1} />
</linearGradient>
</defs>
{currentPositionName &&
<ReferenceLine x={currentPositionName} stroke={theme.colors[theme.primaryColor][7]} />
}
<Area type="linear" dataKey="yValue" stroke={theme.colors[theme.primaryColor][7]} fill="url(#splitColor)" />
<Area type="linear" dataKey="altValue" stroke="#999999" strokeDasharray="3 3" activeDot={false} />
</AreaChart>
</ResponsiveContainer>
</Box>
</Stack>
)
}

export default EvalChart;
115 changes: 60 additions & 55 deletions src/components/panels/analysis/AnalysisPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
Accordion,
Box,
Button,
Grid,
Group,
Expand Down Expand Up @@ -31,12 +32,7 @@ import {
} from "@/atoms/atoms";
import { useAtom, useAtomValue } from "jotai";
import LogsPanel from "./LogsPanel";

const labels = {
action: "Generate report",
completed: "Report generated",
inProgress: "Generating report",
};
import EvalChart from "@/components/common/EvalChart";

function AnalysisPanel({
boardSize,
Expand Down Expand Up @@ -67,6 +63,8 @@ function AnalysisPanel({

const [tab, setTab] = useAtom(currentAnalysisTabAtom);

const stats = useMemo(() => getGameStats(root), [root]);

return (
<Tabs
defaultValue="engines"
Expand All @@ -78,23 +76,25 @@ function AnalysisPanel({
<Tabs.List>
<Tabs.Tab value="engines">Engines</Tabs.Tab>
<Tabs.Tab value="report">Report</Tabs.Tab>
<Tabs.Tab value="logs" disabled={loadedEngines.length == 0}>Logs</Tabs.Tab>
<Tabs.Tab value="logs" disabled={loadedEngines.length == 0}>
Logs
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="engines" pt="xs">
<ScrollArea sx={{ height: boardSize / 2 }} offsetScrollbars>
{loadedEngines.length > 0 &&
{loadedEngines.length > 0 && (
<Group>
<Button
rightIcon={
allEnabled ? <IconPlayerPause /> : <IconChevronsRight />
allEnabled ? <IconPlayerPause /> : <IconChevronsRight />
}
variant={allEnabled ? "filled" : "default"}
onClick={() => enable(!allEnabled)}
>
>
{allEnabled ? "Stop All" : "Run All"}
</Button>
</Group>
}
)}
<Stack mt="sm">
<Accordion
variant="separated"
Expand All @@ -121,19 +121,51 @@ function AnalysisPanel({
</ScrollArea>
</Tabs.Panel>
<Tabs.Panel value="report" pt="xs">
<GameStats {...getGameStats(root)} />
<ProgressButton
id={0}
redoable
disabled={root.children.length === 0}
leftIcon={<IconZoomCheck size={14} />}
onClick={toggleReportingMode}
initInstalled={false}
progressEvent="report_progress"
labels={labels}
inProgress={inProgress}
setInProgress={setInProgress}
/>
<ScrollArea sx={{ height: boardSize / 2 }} offsetScrollbars>
<Stack mb="lg" spacing="0.4rem" mr="xs">
<Group grow sx={{ textAlign: "center" }}>
{stats.whiteAccuracy && stats.blackAccuracy && (
<>
<AccuracyCard
color="WHITE"
accuracy={stats.whiteAccuracy}
cpl={stats.whiteCPL}
/>
<AccuracyCard
color="BLACK"
accuracy={stats.blackAccuracy}
cpl={stats.blackCPL}
/>
</>
)}
<Box w={100}>
<ProgressButton
id={0}
redoable
disabled={root.children.length === 0}
leftIcon={<IconZoomCheck size={14} />}
onClick={() => toggleReportingMode()}
initInstalled={false}
progressEvent="report_progress"
labels={{
action: "Generate report",
completed: "Report generated",
inProgress: "Generating report",
}}
inProgress={inProgress}
setInProgress={setInProgress}
/>
</Box>
</Group>
<Paper withBorder p="md">
<EvalChart
isAnalysing={inProgress}
startAnalysis={() => toggleReportingMode()}
/>
</Paper>
<GameStats {...stats} />
</Stack>
</ScrollArea>
</Tabs.Panel>
<Tabs.Panel value="logs" pt="xs">
<Stack>
Expand All @@ -147,33 +179,10 @@ function AnalysisPanel({
type Stats = ReturnType<typeof getGameStats>;

const GameStats = memo(
function GameStats({
whiteCPL,
blackCPL,
whiteAccuracy,
blackAccuracy,
whiteAnnotations,
blackAnnotations,
}: Stats) {
function GameStats({ whiteAnnotations, blackAnnotations }: Stats) {
return (
<Stack mb="lg" spacing="0.4rem" mr="xs">
<Group grow sx={{ textAlign: "center" }}>
{whiteAccuracy && blackAccuracy && (
<>
<AccuracyCard
color="WHITE"
accuracy={whiteAccuracy}
cpl={whiteCPL}
/>
<AccuracyCard
color="BLACK"
accuracy={blackAccuracy}
cpl={blackCPL}
/>
</>
)}
</Group>
<Grid columns={11} justify="space-between">
<Paper withBorder>
<Grid columns={11} justify="space-between" p="md">
{Object.keys(ANNOTATION_INFO)
.filter((a) => a !== "")
.map((annotation) => {
Expand Down Expand Up @@ -203,15 +212,11 @@ const GameStats = memo(
);
})}
</Grid>
</Stack>
</Paper>
);
},
(prev, next) => {
return (
prev.whiteCPL === next.whiteCPL &&
prev.blackCPL === next.blackCPL &&
prev.whiteAccuracy === next.whiteAccuracy &&
prev.blackAccuracy === next.blackAccuracy &&
shallowEqual(prev.whiteAnnotations, next.whiteAnnotations) &&
shallowEqual(prev.blackAnnotations, next.blackAnnotations)
);
Expand Down
2 changes: 1 addition & 1 deletion src/components/panels/analysis/ReportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ function ReportModal({
type: "ADD_ANALYSIS",
payload: analysisData,
});
});
}).finally(() => setInProgress(false));
}

return (
Expand Down
2 changes: 1 addition & 1 deletion src/layouts/BoardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ function BoardLayout({
}) {
return (
<>
<Flex gap="md" wrap="wrap" align="start">
<Flex gap="md" wrap="nowrap" align="start">
{board}
<Stack
sx={{
Expand Down
Loading

0 comments on commit 9bef187

Please sign in to comment.