From 2eda361750cb3f5df5360ca0a7a8e8718dcee9c6 Mon Sep 17 00:00:00 2001 From: J-Babin Date: Thu, 19 Dec 2024 15:41:51 +0100 Subject: [PATCH 01/45] fix(history-page): delete column move, use date_fns, and change result --- src/pages/history/columns.tsx | 1 + src/pages/history/data-table.tsx | 14 ++++++-------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/pages/history/columns.tsx b/src/pages/history/columns.tsx index 9811d98..dd18382 100644 --- a/src/pages/history/columns.tsx +++ b/src/pages/history/columns.tsx @@ -26,6 +26,7 @@ export interface GameDetails { Result: string; Date: string; Round: string; + Termination: string; }; id: string; pgn: string; diff --git a/src/pages/history/data-table.tsx b/src/pages/history/data-table.tsx index 99fb3d2..ee70760 100644 --- a/src/pages/history/data-table.tsx +++ b/src/pages/history/data-table.tsx @@ -9,11 +9,11 @@ import { useReactTable, getPaginationRowModel, getSortedRowModel, -} from '@tanstack/react-table'; -import Moment from 'moment'; +} from '@tanstack/react-table';; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { useTranslation } from 'react-i18next'; import { GameDetails } from './columns'; +import { format, parse } from 'date-fns'; interface DataTableProps { columns: ColumnDef[]; @@ -56,7 +56,7 @@ export function DataTable({ columns, data }: DataTabl {header.column.id === 'header_Result' ? flexRender(header.column.columnDef.header, header.getContext()) : null} - {header.column.id === 'moves' ? t(String(header.column.id).toLowerCase()) : null} + {/* {header.column.id === 'moves' ? t(String(header.column.id).toLowerCase()) : null} */} {header.column.id === 'header_Date' ? t('date') : null} ); @@ -73,12 +73,10 @@ export function DataTable({ columns, data }: DataTabl {cell.column.id === 'select' ? flexRender(cell.column.columnDef.cell, cell.getContext()) : null} {cell.column.id === 'players' ? flexRender(cell.column.columnDef.cell, cell.getContext()) : null} {cell.column.id === 'header_Result' - ? t(String(cell.row.original.header.Result).toLowerCase()) - : null} - {cell.column.id === 'moves' ? cell.row.original.header.Round : null} - {cell.column.id === 'header_Date' - ? Moment(cell.row.original.header.Date).format(' DD / MM / YYYY') + ? t(String(cell.row.original.header.Termination).toLowerCase()) : null} + {/* {cell.column.id === 'moves' ? cell.row.original.header.Round : null} */} + {cell.column.id === 'header_Date' ? format(parse(cell.row.original.header.Date, 'yyyy.MM.dd', new Date()), 'dd / MM / yyyy') : null} {cell.column.id === 'actions' ? flexRender(cell.column.columnDef.cell, cell.getContext()) : null} ))} From 58a39f68b0c0459db15910d50b709ee4572ed6f1 Mon Sep 17 00:00:00 2001 From: Ludovic <80395681+Lxdovic@users.noreply.github.com> Date: Fri, 7 Feb 2025 18:07:49 +0100 Subject: [PATCH 02/45] Improve/analysis (#96) * feat(analysis): started rewriting analysis with a dynamic layout * fix(analysis): fix panels order * feat(analysis): put layout in storage and add preview when moving panels * feat(analysis): finished implementing panel manager * improve(analysis): improve panel dragging * feat(analysis): added move list * improve(move-list): add scroll * improve(move-list): add current move highlight * improve(analysis): move opening name in move-list * improve(analysis): change default theme * improve(analysis): add eval history as a panel * refactor(analysis) * improve(analysis): improve panel management * fix(classification): fix classification * improve(analysis): add controls and set chessboard to be fixed at center * style(start-analysis): cut form into two columns for more space --- eslint.config.js | 2 + package-lock.json | 455 +++++++++++++++++- package.json | 6 + src/app.tsx | 6 +- src/components/evalbar/evalbar.tsx | 2 +- src/components/evalchart/evalchart.tsx | 4 +- src/components/ui/resizable.tsx | 37 ++ .../analysis => data}/classifications.ts | 13 +- src/lib/analysis.ts | 32 +- src/pages/analysis/analysis-old.tsx | 364 ++++++++++++++ src/pages/analysis/analysis.tsx | 453 +++-------------- src/pages/analysis/droppable-panel.tsx | 55 +++ src/pages/analysis/layout-sidebar-item.tsx | 103 ++++ src/pages/analysis/layout-sidebar.tsx | 65 +++ .../panels/chessboard/chessboard-panel.css | 13 + .../panels/chessboard/chessboard-panel.tsx | 50 ++ .../analysis/panels/controls/controls.tsx | 202 ++++++++ .../classificationDescription.ts | 21 + .../engine-interpretation.tsx | 8 + .../panels/engineLines/engine-lines.tsx | 8 + .../panels/evalHistory/eval-history.tsx | 35 ++ .../analysis/panels/moveList/move-list.tsx | 93 ++++ src/pages/history/data-table.tsx | 6 +- src/pages/start-analysis/start-analysis.tsx | 280 +++++------ src/store/analysis.ts | 6 +- src/store/layout.ts | 61 +++ src/store/theme.ts | 2 +- src/types/layout.ts | 9 + 28 files changed, 1832 insertions(+), 559 deletions(-) create mode 100644 src/components/ui/resizable.tsx rename src/{pages/analysis => data}/classifications.ts (84%) create mode 100644 src/pages/analysis/analysis-old.tsx create mode 100644 src/pages/analysis/droppable-panel.tsx create mode 100644 src/pages/analysis/layout-sidebar-item.tsx create mode 100644 src/pages/analysis/layout-sidebar.tsx create mode 100644 src/pages/analysis/panels/chessboard/chessboard-panel.css create mode 100644 src/pages/analysis/panels/chessboard/chessboard-panel.tsx create mode 100644 src/pages/analysis/panels/controls/controls.tsx create mode 100644 src/pages/analysis/panels/engineInterpretation/classificationDescription.ts create mode 100644 src/pages/analysis/panels/engineInterpretation/engine-interpretation.tsx create mode 100644 src/pages/analysis/panels/engineLines/engine-lines.tsx create mode 100644 src/pages/analysis/panels/evalHistory/eval-history.tsx create mode 100644 src/pages/analysis/panels/moveList/move-list.tsx create mode 100644 src/store/layout.ts create mode 100644 src/types/layout.ts diff --git a/eslint.config.js b/eslint.config.js index 11eed22..097b741 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -10,6 +10,8 @@ export default [ rules: { '@typescript-eslint/no-misused-promises': 'off', '@typescript-eslint/no-floating-promises': 'off', + '@typescript-eslint/no-redundant-type-constituents': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', }, languageOptions: { diff --git a/package-lock.json b/package-lock.json index 04e2909..641914a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "castledchess", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@hookform/resolvers": "^3.9.0", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2", @@ -41,11 +43,14 @@ "next-themes": "^0.3.0", "react": "^18.3.1", "react-day-picker": "^8.10.1", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.3.1", "react-flagkit": "^2.0.4", "react-hook-form": "^7.53.1", "react-hotkeys-hook": "^4.6.1", "react-i18next": "^15.1.1", + "react-resizable-panels": "^2.1.7", "react-router-dom": "^6.26.2", "recharts": "^2.13.0", "sonner": "^1.5.0", @@ -58,6 +63,7 @@ }, "devDependencies": { "@eslint/js": "^9.12.0", + "@iconify/react": "^5.1.0", "@types/node": "^22.7.9", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -395,6 +401,331 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@esbuild/linux-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", @@ -665,6 +996,29 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify/react": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@iconify/react/-/react-5.1.0.tgz", + "integrity": "sha512-vj2wzalywy23DR37AnsogMPIkDa1nKEqITjxpH4z44tiLV869Mh7VyydD4/t0yJLEs9tsxlrPWtXvMOe1Lcd5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@iconify/types": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/cyberalien" + }, + "peerDependencies": { + "react": ">=16" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1867,6 +2221,24 @@ "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", "license": "MIT" }, + "node_modules/@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==", + "license": "MIT" + }, + "node_modules/@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==", + "license": "MIT" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==", + "license": "MIT" + }, "node_modules/@remix-run/router": { "version": "1.21.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", @@ -2071,7 +2443,7 @@ "version": "22.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz", "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.20.0" @@ -3495,6 +3867,17 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "license": "MIT" }, + "node_modules/dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "license": "MIT", + "dependencies": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -4116,7 +4499,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -4644,6 +5026,15 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -6184,6 +6575,45 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "license": "MIT", + "dependencies": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "license": "MIT", + "dependencies": { + "dnd-core": "^16.0.1" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -6320,6 +6750,16 @@ } } }, + "node_modules/react-resizable-panels": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.7.tgz", + "integrity": "sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/react-router": { "version": "6.28.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.0.tgz", @@ -6465,6 +6905,15 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.8.tgz", @@ -7366,7 +7815,7 @@ "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/update-browserslist-db": { diff --git a/package.json b/package.json index 40651d2..bbecb9e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "format": "prettier --write './src/**/*.{js,jsx,ts,tsx,css,md,json}' --config ./.prettierrc" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@hookform/resolvers": "^3.9.0", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2", @@ -45,11 +47,14 @@ "next-themes": "^0.3.0", "react": "^18.3.1", "react-day-picker": "^8.10.1", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.3.1", "react-flagkit": "^2.0.4", "react-hook-form": "^7.53.1", "react-hotkeys-hook": "^4.6.1", "react-i18next": "^15.1.1", + "react-resizable-panels": "^2.1.7", "react-router-dom": "^6.26.2", "recharts": "^2.13.0", "sonner": "^1.5.0", @@ -62,6 +67,7 @@ }, "devDependencies": { "@eslint/js": "^9.12.0", + "@iconify/react": "^5.1.0", "@types/node": "^22.7.9", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/src/app.tsx b/src/app.tsx index fcb1313..f48b470 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -2,7 +2,7 @@ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-d import { ReactNode } from 'react'; import { Navbar } from '@/components/navbar/navbar.tsx'; -import { AnalysisPage } from '@/pages/analysis/analysis.tsx'; +import { Analysis } from '@/pages/analysis/analysis.tsx'; import { StartAnalysis } from '@/pages/start-analysis/start-analysis.tsx'; import { Documentation } from '@/pages/documentation.tsx'; import { Register } from '@/pages/register/register.tsx'; @@ -40,11 +40,11 @@ function App() { path="/analysis/" element={ - + } /> - } /> + } />
{ }, [analysis]); return ( -
+
} /> diff --git a/src/components/ui/resizable.tsx b/src/components/ui/resizable.tsx new file mode 100644 index 0000000..5a35cfc --- /dev/null +++ b/src/components/ui/resizable.tsx @@ -0,0 +1,37 @@ +import { GripVertical } from 'lucide-react'; +import * as ResizablePrimitive from 'react-resizable-panels'; + +import { cn } from '@/lib/utils'; + +const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps) => ( + +); + +const ResizablePanel = ResizablePrimitive.Panel; + +const ResizableHandle = ({ + withHandle, + className, + ...props +}: React.ComponentProps & { + withHandle?: boolean; +}) => ( + div]:rotate-90', + className, + )} + {...props} + > + {withHandle && ( +
+ +
+ )} +
+); + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; diff --git a/src/pages/analysis/classifications.ts b/src/data/classifications.ts similarity index 84% rename from src/pages/analysis/classifications.ts rename to src/data/classifications.ts index bbddea8..9587bf6 100644 --- a/src/pages/analysis/classifications.ts +++ b/src/data/classifications.ts @@ -50,9 +50,20 @@ export const classificationToColor: KeyValuePair = { + [AnalysisMoveClassification.Brilliant]: 'text-sky-500', + [AnalysisMoveClassification.Best]: 'text-green-500', + [AnalysisMoveClassification.Excellent]: 'text-green-500', + [AnalysisMoveClassification.Good]: 'text-green-500', + [AnalysisMoveClassification.Inaccuracy]: 'text-yellow-500', + [AnalysisMoveClassification.Mistake]: 'text-orange-500', + [AnalysisMoveClassification.Blunder]: 'text-red-500', + [AnalysisMoveClassification.None]: '', +}; + export const shouldDisplayClassificationInMoveHistory: KeyValuePair = { [AnalysisMoveClassification.Brilliant]: true, - [AnalysisMoveClassification.Best]: true, + [AnalysisMoveClassification.Best]: false, [AnalysisMoveClassification.Excellent]: false, [AnalysisMoveClassification.Good]: false, [AnalysisMoveClassification.Inaccuracy]: true, diff --git a/src/lib/analysis.ts b/src/lib/analysis.ts index 5261d04..fbe77d6 100644 --- a/src/lib/analysis.ts +++ b/src/lib/analysis.ts @@ -4,7 +4,6 @@ import { StartAnalysisFormSchema } from '@/schema/analysis.ts'; import { Move } from 'chess.js'; import { StockfishService } from '@/services/stockfish/stockfish.service.ts'; import { UciParserService } from '@/services/stockfish/uci-parser.service.ts'; -import { pieceToValue } from '@/pages/analysis/classifications.ts'; import { isCached } from '@/services/cache/cache.service.ts'; export type AnalyseMovesLocalParams = { @@ -112,23 +111,19 @@ export const classifyRegular = (move: AnalysisMove, index: number, moves: Analys if (!next || !current) return { ...move, classification: AnalysisMoveClassification.None }; - if (current.mate) - return { ...move, classification: classifyWithMate(move.move, next.mate || current.mate, current.mate) }; - else return { ...move, classification: classifyWithWinChance(move.move, next.winChance!, current.winChance!) }; + if (current.mate) return { ...move, classification: classifyWithMate(move.move, next, current) }; + else return { ...move, classification: classifyWithWinChance(move.move, next, current) }; }; -const classifyWithMate = (move: Move, next: number, current: number): AnalysisMoveClassification => { - if (next === null || current === null) return AnalysisMoveClassification.None; +const classifyWithMate = (move: Move, next: InfoResult, current: InfoResult): AnalysisMoveClassification => { + if (next.mate === null || current.mate === null) return AnalysisMoveClassification.None; - const mateDelta = next - current - 1; + const mateDelta = (next.mate || current.mate) - current.mate - 1; let classification = AnalysisMoveClassification.None; - if (mateDelta <= 0) { - if (move.captured && pieceToValue[move.captured] < pieceToValue[move.piece]) - classification = AnalysisMoveClassification.Brilliant; - else classification = AnalysisMoveClassification.Best; - } else if (mateDelta === 1) classification = AnalysisMoveClassification.Excellent; + if (move.from + move.to === current.move) classification = AnalysisMoveClassification.Best; + else if (mateDelta <= 0) classification = AnalysisMoveClassification.Excellent; else if (mateDelta === 2) classification = AnalysisMoveClassification.Good; else if (mateDelta === 3) classification = AnalysisMoveClassification.Inaccuracy; else if (mateDelta === 4) classification = AnalysisMoveClassification.Mistake; @@ -137,18 +132,15 @@ const classifyWithMate = (move: Move, next: number, current: number): AnalysisMo return classification; }; -const classifyWithWinChance = (move: Move, next: number, current: number): AnalysisMoveClassification => { - if (!next || !current) return AnalysisMoveClassification.None; +const classifyWithWinChance = (move: Move, next: InfoResult, current: InfoResult): AnalysisMoveClassification => { + if (!next.winChance || !current.winChance) return AnalysisMoveClassification.None; - const winChanceDelta = Math.abs((current - next) / 100); + const winChanceDelta = Math.abs((current.winChance - next.winChance) / 100); let classification = AnalysisMoveClassification.None; - if (winChanceDelta <= 0.0) { - if (move.captured && pieceToValue[move.captured] < pieceToValue[move.piece]) - classification = AnalysisMoveClassification.Brilliant; - else classification = AnalysisMoveClassification.Best; - } else if (winChanceDelta > 0.0 && winChanceDelta <= 0.02) classification = AnalysisMoveClassification.Excellent; + if (move.from + move.to === current.move) classification = AnalysisMoveClassification.Best; + else if (winChanceDelta > 0.0 && winChanceDelta <= 0.02) classification = AnalysisMoveClassification.Excellent; else if (winChanceDelta > 0.02 && winChanceDelta <= 0.05) classification = AnalysisMoveClassification.Good; else if (winChanceDelta > 0.05 && winChanceDelta <= 0.1) classification = AnalysisMoveClassification.Inaccuracy; else if (winChanceDelta > 0.1 && winChanceDelta <= 0.2) classification = AnalysisMoveClassification.Mistake; diff --git a/src/pages/analysis/analysis-old.tsx b/src/pages/analysis/analysis-old.tsx new file mode 100644 index 0000000..13dc6c8 --- /dev/null +++ b/src/pages/analysis/analysis-old.tsx @@ -0,0 +1,364 @@ +import { useEffect, useRef, useState } from 'react'; +import { DrawShape } from 'chessground/draw'; +import { useAnalysisStore } from '@/store/analysis.ts'; +import { PlayerInfo } from '@/components/playerinfo/playerinfo.tsx'; +import { Button } from '@/components/ui/button.tsx'; +import { + ChevronLeft, + ChevronRight, + ChevronLast, + ChevronFirst, + FlipVertical2, + CircleHelp, + Play, + Pause, +} from 'lucide-react'; +import { Evalbar } from '@/components/evalbar/evalbar.tsx'; +import { AnalysisMove, AnalysisMoveClassification } from '@/types/analysis.ts'; +import { Key } from 'chessground/types'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip.tsx'; +import { findOpening } from '@/lib/opening.ts'; +import { Opening } from '@/types/opening.ts'; +import { AnalysisChessboard } from '@/components/chessboard/analysis-chessboard.tsx'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { + classificationToColor, + classificationToGlyph, + classificationToGlyphUrl, + shouldDisplayClassificationInMoveHistory, +} from '@/data/classifications.ts'; +import { EvalChart } from '@/components/evalchart/evalchart.tsx'; +import { CategoricalChartState } from 'recharts/types/chart/types'; +import { useTranslation } from 'react-i18next'; + +export const Analysis = () => { + const { currentMove, setCurrentMove, analysis, chess, chessGround } = useAnalysisStore(); + const [orientation, setOrientation] = useState<'white' | 'black'>('white'); + const [isAutoPlaying, setIsAutoPlaying] = useState(false); + const [opening, setOpening] = useState(undefined); + const autoPlayInterval = useRef(null); + const moveRefs = useRef<(HTMLTableRowElement | null)[]>([]); + + const { t } = useTranslation('analysis'); + + const currMove = analysis!.moves[currentMove]; + const previousMove = analysis!.moves[currentMove - 1]; + const variants = previousMove?.engineResults + ?.sort((a, b) => b.depth! - a.depth!) + ?.filter((result, index, self) => self.findIndex((r) => r.move === result.move) === index) + ?.slice(0, analysis?.variants ?? 1); + + const formatMoves = (moves: AnalysisMove[]): { whiteMove: AnalysisMove; blackMove: AnalysisMove }[] => { + const formattedMoves: { whiteMove: AnalysisMove; blackMove: AnalysisMove }[] = []; + + for (let i = 0; i < moves.length; i += 2) formattedMoves.push({ whiteMove: moves[i], blackMove: moves[i + 1] }); + + return formattedMoves; + }; + + const handleNextMove = () => { + if (currentMove >= analysis!.moves.length) return; + + chess.move(currMove.move); + chessGround?.set({ + fen: chess.fen(), + }); + + setCurrentMove(currentMove + 1); + }; + + const handlePrevMove = () => { + if (currentMove <= 0) return; + + chess.undo(); + chessGround?.set({ + fen: chess.fen(), + }); + + setCurrentMove(currentMove - 1); + }; + + const handleToggleAutoPlay = () => { + setIsAutoPlaying(!isAutoPlaying); + }; + + const handleFlipBoard = () => { + setOrientation((orientation) => (orientation === 'white' ? 'black' : 'white')); + + chessGround?.toggleOrientation(); + }; + + const handleSkipToBegin = () => { + const moves = chess.history(); + + for (let i = 0; i < moves.length; i++) { + chess.undo(); + } + + setCurrentMove(0); + chessGround?.set({ + drawable: { autoShapes: [] }, + highlight: { custom: new Map() }, + fen: chess.fen(), + }); + }; + + const handleSkipToEnd = () => { + const moves = analysis!.moves; + + for (let i = currentMove; i < moves.length; i++) { + chess.move(moves[i].move); + } + + setCurrentMove(moves.length); + chessGround?.set({ + fen: chess.fen(), + }); + }; + + const handleSkipToMove = (index: number) => { + const moves = chess.history(); + + for (let i = 0; i < moves.length; i++) { + chess.undo(); + } + + for (let i = 0; i < index; i++) { + chess.move(analysis!.moves[i].move); + } + + chessGround?.set({ + fen: chess.fen(), + }); + setCurrentMove(index); + chessGround?.redrawAll(); + }; + + const handleClickEvalChart = (nextState: CategoricalChartState) => { + if (nextState.activeTooltipIndex) handleSkipToMove(nextState.activeTooltipIndex + 1); + }; + + useEffect(() => { + findOpening(chess.pgn()) + .then((opening) => setOpening(opening)) + .catch(console.error); + /** When a custom svg (classification) is rendered twice at the same square on two different moves + * it does not trigger a re-render and does not animate the second one, this fixes the issue */ + chessGround?.redrawAll(); + moveRefs.current[currentMove - 1]?.scrollIntoView({ behavior: 'instant', block: 'center' }); + }, [currentMove]); + + useEffect(() => { + if (currentMove >= analysis!.moves.length) setIsAutoPlaying(false); + if (!isAutoPlaying) clearInterval(autoPlayInterval.current!); + else autoPlayInterval.current = setInterval(handleNextMove, 1000); + + return () => clearInterval(autoPlayInterval.current!); + }, [isAutoPlaying, currentMove]); + + useEffect(() => { + if (currentMove >= analysis!.moves.length) return; + + if (!previousMove) return; + + const autoShapes: DrawShape[] = variants.map((result) => ({ + orig: result.from, + dest: result.to, + brush: 'blue', + })) as DrawShape[]; + + if (previousMove.classification && previousMove.classification !== AnalysisMoveClassification.None) { + autoShapes.push({ + orig: previousMove.move.to as Key, + customSvg: { + html: classificationToGlyph[previousMove.classification], + }, + }); + } + + chessGround?.set({ + highlight: { + custom: new Map([ + [previousMove.move.from as Key, classificationToColor[previousMove.classification!]], + [previousMove.move.to as Key, classificationToColor[previousMove.classification!]], + ]), + }, + drawable: { autoShapes: autoShapes }, + }); + }, [currentMove]); + + useHotkeys('right', handleNextMove); + useHotkeys('left', handlePrevMove); + useHotkeys('ctrl+left', handleSkipToBegin); + useHotkeys('ctrl+right', handleSkipToEnd); + useHotkeys('space', handleToggleAutoPlay); + useHotkeys('f', handleFlipBoard); + + return ( +
+ +
+ {analysis && ( + + )} +
+ +
+ {analysis && ( + + )} +
+ +
+
+ + + + + + {t('flipBoard')} + + + + + + + + + Other Button 1 + + + + + + + + + Other Button 2 + + + + + + + + + Other Button 3 + + +
+ +
+ {opening?.name} + +
+ + + {formatMoves(analysis!.moves).map((move, index) => ( + { + moveRefs.current[index * 2] = el; + moveRefs.current[index * 2 + 1] = el; + }} + className="even:bg-foreground/[.02]" + > + + {move.whiteMove && ( + + )} + {move.blackMove && ( + + )} + + ))} + +
{index} +
+ + + +
+
+
+ + +
+
+
+ + +
+ +
+ + + + + +
+
+
+ ); +}; diff --git a/src/pages/analysis/analysis.tsx b/src/pages/analysis/analysis.tsx index be5dc67..f6950bb 100644 --- a/src/pages/analysis/analysis.tsx +++ b/src/pages/analysis/analysis.tsx @@ -1,394 +1,75 @@ -import { useEffect, useRef, useState } from 'react'; -import { DrawShape } from 'chessground/draw'; -import { useAnalysisStore } from '@/store/analysis.ts'; -import { PlayerInfo } from '@/components/playerinfo/playerinfo.tsx'; -import { Button } from '@/components/ui/button.tsx'; -import { - ChevronLeft, - ChevronRight, - ChevronLast, - ChevronFirst, - FlipVertical2, - CircleHelp, - Play, - Pause, -} from 'lucide-react'; -import { Evalbar } from '@/components/evalbar/evalbar.tsx'; -import { Analysis, AnalysisMove, AnalysisMoveClassification } from '@/types/analysis.ts'; -import { Key } from 'chessground/types'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip.tsx'; -import { findOpening } from '@/lib/opening.ts'; -import { Opening } from '@/types/opening.ts'; -import { AnalysisChessboard } from '@/components/chessboard/analysis-chessboard.tsx'; -import { useHotkeys } from 'react-hotkeys-hook'; -import { - classificationToColor, - classificationToGlyph, - classificationToGlyphUrl, - shouldDisplayClassificationInMoveHistory, -} from '@/pages/analysis/classifications.ts'; -import { EvalChart } from '@/components/evalchart/evalchart.tsx'; -import { CategoricalChartState } from 'recharts/types/chart/types'; -import { useTranslation } from 'react-i18next'; -import { getAnalysisById } from '@/api/analysis.ts'; -import { useNavigate, useParams } from 'react-router-dom'; -import { useAuthStore } from '@/store/auth.ts'; - -export const AnalysisPage = () => { - const params = useParams(); - const { currentMove, setCurrentMove, analysis, setAnalysis, chess, chessGround } = useAnalysisStore(); - const { user } = useAuthStore(); - const [orientation, setOrientation] = useState<'white' | 'black'>('white'); - const [isAutoPlaying, setIsAutoPlaying] = useState(false); - const [opening, setOpening] = useState(undefined); - const autoPlayInterval = useRef(null); - const moveRefs = useRef<(HTMLTableRowElement | null)[]>([]); - - const navigate = useNavigate(); - - const { t } = useTranslation('analysis'); - - const currMove = analysis?.moves?.[currentMove]; - const previousMove = analysis?.moves?.[currentMove - 1]; - const variants = previousMove?.engineResults - ?.sort((a, b) => b.depth! - a.depth!) - ?.filter((result, index, self) => self.findIndex((r) => r.move === result.move) === index) - ?.slice(0, analysis?.variants ?? 1); - - const formatMoves = (moves: AnalysisMove[] | undefined): { whiteMove: AnalysisMove; blackMove: AnalysisMove }[] => { - if (!moves) return []; - - const formattedMoves: { whiteMove: AnalysisMove; blackMove: AnalysisMove }[] = []; - - for (let i = 0; i < moves.length; i += 2) formattedMoves.push({ whiteMove: moves[i], blackMove: moves[i + 1] }); - - return formattedMoves; - }; - - const handleNextMove = () => { - if (currentMove >= analysis!.moves.length) return; - if (!currMove) return; - - chess.move(currMove.move); - chessGround?.set({ - fen: chess.fen(), - }); - - setCurrentMove(currentMove + 1); - }; - - const handlePrevMove = () => { - if (currentMove <= 0) return; - - chess.undo(); - chessGround?.set({ - fen: chess.fen(), - }); - - setCurrentMove(currentMove - 1); - }; - - const handleToggleAutoPlay = () => { - setIsAutoPlaying(!isAutoPlaying); - }; - - const handleFlipBoard = () => { - setOrientation((orientation) => (orientation === 'white' ? 'black' : 'white')); - - chessGround?.toggleOrientation(); - }; - - const handleSkipToBegin = () => { - const moves = chess.history(); - - for (let i = 0; i < moves.length; i++) { - chess.undo(); - } - - setCurrentMove(0); - chessGround?.set({ - drawable: { autoShapes: [] }, - highlight: { custom: new Map() }, - fen: chess.fen(), - }); - }; - - const handleSkipToEnd = () => { - const moves = analysis!.moves; - - for (let i = currentMove; i < moves.length; i++) { - chess.move(moves[i].move); - } - - setCurrentMove(moves.length); - chessGround?.set({ - fen: chess.fen(), - }); - }; - - const handleSkipToMove = (index: number) => { - if (!analysis?.moves) return; - - const moves = chess.history(); - - for (let i = 0; i < moves.length; i++) { - chess.undo(); - } - - for (let i = 0; i < index; i++) { - console.log(analysis?.moves?.[i].move); - chess.move(analysis?.moves?.[i].move); - } - - chessGround?.set({ - fen: chess.fen(), - }); - setCurrentMove(index); - chessGround?.redrawAll(); - }; - - const handleClickEvalChart = (nextState: CategoricalChartState) => { - if (nextState.activeTooltipIndex) handleSkipToMove(nextState.activeTooltipIndex + 1); - }; - - useEffect(() => { - findOpening(chess.pgn()) - .then((opening) => setOpening(opening)) - .catch(console.error); - /** When a custom svg (classification) is rendered twice at the same square on two different moves - * it does not trigger a re-render and does not animate the second one, this fixes the issue */ - chessGround?.redrawAll(); - moveRefs.current[currentMove - 1]?.scrollIntoView({ behavior: 'instant', block: 'center' }); - }, [currentMove]); - - useEffect(() => { - if (currentMove >= (analysis?.moves?.length || 0)) setIsAutoPlaying(false); - if (!isAutoPlaying) clearInterval(autoPlayInterval.current!); - else autoPlayInterval.current = setInterval(handleNextMove, 1000); - - return () => clearInterval(autoPlayInterval.current!); - }, [isAutoPlaying, currentMove]); - - useEffect(() => { - if (currentMove >= (analysis?.moves?.length || 0)) return; - - if (!previousMove) return; - - const autoShapes: DrawShape[] = variants?.map((result) => ({ - orig: result.from, - dest: result.to, - brush: 'blue', - })) as DrawShape[]; - - if (previousMove.classification && previousMove.classification !== AnalysisMoveClassification.None) { - autoShapes.push({ - orig: previousMove.move.to as Key, - customSvg: { - html: classificationToGlyph[previousMove.classification], - }, - }); - } - - chessGround?.set({ - highlight: { - custom: new Map([ - [previousMove.move.from as Key, classificationToColor[previousMove.classification!]], - [previousMove.move.to as Key, classificationToColor[previousMove.classification!]], - ]), - }, - drawable: { autoShapes: autoShapes }, - }); - }, [currentMove]); - - useEffect(() => { - const getAnalysis = async () => { - try { - const response = await getAnalysisById(params.id!); - setAnalysis(response.data as Analysis); - } catch (error) { - console.error(error); - navigate('/start-analysis'); - } - }; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable.tsx'; +import { ChessboardPanel } from '@/pages/analysis/panels/chessboard/chessboard-panel.tsx'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { EngineInterpretation } from '@/pages/analysis/panels/engineInterpretation/engine-interpretation.tsx'; +import { EngineLines } from '@/pages/analysis/panels/engineLines/engine-lines.tsx'; +import { MoveList } from '@/pages/analysis/panels/moveList/move-list.tsx'; +import { EvalHistory } from '@/pages/analysis/panels/evalHistory/eval-history.tsx'; +import { useLayoutStore } from '@/store/layout.ts'; +import { LayoutSidebar } from '@/pages/analysis/layout-sidebar.tsx'; +import { Layout, LayoutItem, Panel, SelectedLayouts } from '@/types/layout'; +import React from 'react'; +import { Controls } from '@/pages/analysis/panels/controls/controls.tsx'; + +export const panels: Record = { + engineInterpretation: , + engineLines: , + moveList: , + evalHistory: , +}; - if (user) getAnalysis(); - else if (!analysis) { - navigate('/start-analysis'); - } - }, [user]); +const hasSelectedPanels = (selectedLayouts: SelectedLayouts, layout: Layout, items: LayoutItem[]): boolean => { + return items.some((item) => selectedLayouts[item] !== null && layout[item].length > 0); +}; - useHotkeys('right', handleNextMove); - useHotkeys('left', handlePrevMove); - useHotkeys('ctrl+left', handleSkipToBegin); - useHotkeys('ctrl+right', handleSkipToEnd); - useHotkeys('space', handleToggleAutoPlay); - useHotkeys('f', handleFlipBoard); +export const Analysis = () => { + const { layout, selectedLayouts } = useLayoutStore(); return ( -
- -
- {analysis && ( - - )} -
- -
- {analysis && ( - - )} -
- -
-
- - - - - - {t('flipBoard')} - - - - - - - - - Other Button 1 - - - - - - - - - Other Button 2 - - - - - - - - - Other Button 3 - - -
- -
- {opening?.name} - -
- - - {formatMoves(analysis?.moves).map((move, index) => ( - { - moveRefs.current[index * 2] = el; - moveRefs.current[index * 2 + 1] = el; - }} - className="even:bg-foreground/[.02]" - > - - {move.whiteMove && ( - - )} - {move.blackMove && ( - - )} - - ))} - -
{index} -
- - - -
-
-
- - -
-
-
- - -
- -
- - - - - +
+ + + + + + + + + + + + + {hasSelectedPanels(selectedLayouts, layout, ['topRight', 'bottomRight']) && } + + {hasSelectedPanels(selectedLayouts, layout, ['topRight', 'bottomRight']) && ( + + + {selectedLayouts.topRight !== null && layout.topRight.length > 0 && ( + + {panels[layout.topRight[selectedLayouts.topRight]]} + + )} + + {selectedLayouts.topRight !== null && + selectedLayouts.bottomRight !== null && + layout.topRight.length > 0 && + layout.bottomRight.length > 0 && } + + {selectedLayouts.bottomRight !== null && layout.bottomRight.length > 0 && ( + + {panels[layout.bottomRight[selectedLayouts.bottomRight]]} + + )} + + + )} + +
+ +
-
+
); }; diff --git a/src/pages/analysis/droppable-panel.tsx b/src/pages/analysis/droppable-panel.tsx new file mode 100644 index 0000000..9b7a40b --- /dev/null +++ b/src/pages/analysis/droppable-panel.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { ResizablePanel } from '@/components/ui/resizable.tsx'; +import { DragItem, LayoutItem } from '@/types/layout.ts'; +import { useLayoutStore } from '@/store/layout.ts'; +import { useDrop } from 'react-dnd'; +import { cn } from '@/lib/utils.ts'; + +export const DroppablePanel = ({ + id, + children, + ...props +}: React.ComponentPropsWithoutRef & { + id: LayoutItem; + children: React.ReactNode; +}) => { + const { layout, movePanel, setSelectedLayouts } = useLayoutStore(); + + const [{ canDrop, isOver }, drop] = useDrop({ + accept: 'layoutItem', + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + hover: (dragItem: DragItem, monitor) => { + if (!monitor.isOver({ shallow: true })) { + return; + } + + const dragIndex = layout[dragItem.which].indexOf(dragItem.id); + const hoverIndex = layout[id].length; // Drop at the end of the list + + // Only move if the item is not already in the target list + if (dragItem.which !== id) { + movePanel(dragItem.which, id, dragItem.id, dragIndex, hoverIndex); + dragItem.which = id; + } + }, + drop: (dragItem: DragItem) => { + setSelectedLayouts((selectedLayouts) => ({ + ...selectedLayouts, + [id]: layout[id].indexOf(dragItem.id), + })); + }, + }); + + const isActive: boolean = canDrop && isOver; + + return ( + +
+ {children} +
+
+ ); +}; diff --git a/src/pages/analysis/layout-sidebar-item.tsx b/src/pages/analysis/layout-sidebar-item.tsx new file mode 100644 index 0000000..c440898 --- /dev/null +++ b/src/pages/analysis/layout-sidebar-item.tsx @@ -0,0 +1,103 @@ +import { useLayoutStore } from '@/store/layout.ts'; +import React from 'react'; +import { useDrag, useDrop, XYCoord } from 'react-dnd'; +import { DragItem, LayoutItem, Panel } from '@/types/layout.ts'; +import { Button } from '@/components/ui/button.tsx'; +import { cn } from '@/lib/utils.ts'; +import { Icon } from '@iconify/react'; +import { panels } from './analysis'; + +type LayoutSidebarItemProps = { + item: Panel; + which: LayoutItem; +}; + +const panelIcons: Record = { + chessboard: 'fa-solid:chess-king', + engineInterpretation: 'fa6-solid:hands-asl-interpreting', + engineLines: 'game-icons:striking-arrows', + moveList: 'ix:move', + evalHistory: 'fa-solid:chart-line', +}; + +export const LayoutSidebarItem = ({ item, which }: LayoutSidebarItemProps) => { + const { layout, movePanel, setSelectedLayouts, selectedLayouts } = useLayoutStore(); + const ref = React.useRef(null); + + const [{ isDragging }, drag, dragPreview] = useDrag(() => ({ + type: 'layoutItem', + item: { id: item, which, type: 'layoutItem' }, + options: { + dropEffect: 'guards', + }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + })); + + const [, drop] = useDrop({ + accept: 'layoutItem', + hover: (dragItem: DragItem, monitor) => { + if (!ref.current) return; + + const dragIndex = layout[dragItem.which].indexOf(dragItem.id); + const hoverIndex = layout[which].indexOf(item); + + // Don't replace items with themselves + if (dragItem.id === item) return; + + const hoverBoundingRect = ref.current?.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top; + + // Only perform the move when the mouse has crossed half of the items height + // When dragging downwards, only move when the cursor is below 50% + // When dragging upwards, only move when the cursor is above 50% + if ( + (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) || + (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) + ) + return; + + movePanel(dragItem.which, which, dragItem.id, dragIndex, hoverIndex); + + // Avoid flickering when hovering over the dragged item + dragItem.which = which; + }, + }); + + drag(drop(ref)); + + const isSelected = selectedLayouts[which] === layout[which].indexOf(item); + + return isDragging ? ( +
+ ) : ( + + ); +}; diff --git a/src/pages/analysis/layout-sidebar.tsx b/src/pages/analysis/layout-sidebar.tsx new file mode 100644 index 0000000..38d6430 --- /dev/null +++ b/src/pages/analysis/layout-sidebar.tsx @@ -0,0 +1,65 @@ +import { useLayoutStore } from '@/store/layout.ts'; +import { useDragDropManager, useDrop } from 'react-dnd'; +import { DragItem, LayoutItem } from '@/types/layout.ts'; +import { cn } from '@/lib/utils.ts'; +import { LayoutSidebarItem } from '@/pages/analysis/layout-sidebar-item.tsx'; + +type LayoutSidebarProps = { + which: LayoutItem; + justify?: 'start' | 'end' | 'center'; +}; + +export const LayoutSidebar = ({ justify, which }: LayoutSidebarProps) => { + const { layout, movePanel } = useLayoutStore(); + + const manager = useDragDropManager(); + + const [{ canDrop, isOver }, drop] = useDrop({ + accept: 'layoutItem', + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + hover: (dragItem: DragItem, monitor) => { + if (!monitor.isOver({ shallow: true })) { + return; + } + + const dragIndex = layout[dragItem.which].indexOf(dragItem.id); + const hoverIndex = layout[which].length; // Drop at the end of the list + + // Only move if the item is not already in the target list + if (dragItem.which !== which) { + movePanel(dragItem.which, which, dragItem.id, dragIndex, hoverIndex); + dragItem.which = which; + } + }, + }); + + const isActive: boolean = canDrop && isOver; + const monitorIsDragging = manager.getMonitor().isDragging(); + + return ( +
+ {monitorIsDragging && ( +
+ )} + {layout[which].map((key) => ( + + ))} +
+ ); +}; diff --git a/src/pages/analysis/panels/chessboard/chessboard-panel.css b/src/pages/analysis/panels/chessboard/chessboard-panel.css new file mode 100644 index 0000000..b3fafb1 --- /dev/null +++ b/src/pages/analysis/panels/chessboard/chessboard-panel.css @@ -0,0 +1,13 @@ +.chessboard-panel-inner { + container-type: size; +} + +@container (min-width: 0px) { + .chessboard-container { + width: calc(100cqh - 6rem); + } + + .eval-bar-container { + height: min(calc(100cqw - 3.5rem), 100cqh - 6rem); + } +} diff --git a/src/pages/analysis/panels/chessboard/chessboard-panel.tsx b/src/pages/analysis/panels/chessboard/chessboard-panel.tsx new file mode 100644 index 0000000..09a57fa --- /dev/null +++ b/src/pages/analysis/panels/chessboard/chessboard-panel.tsx @@ -0,0 +1,50 @@ +import { Evalbar } from '@/components/evalbar/evalbar.tsx'; +import { PlayerInfo } from '@/components/playerinfo/playerinfo.tsx'; +import { AnalysisChessboard } from '@/components/chessboard/analysis-chessboard.tsx'; +import './chessboard-panel.css'; +import { useAnalysisStore } from '@/store/analysis.ts'; + +export const ChessboardPanel = () => { + const { analysis, currentMove, orientation } = useAnalysisStore(); + + return ( +
+
+
+
+ +
+ +
+ {analysis && ( +
+ +
+ )} + + {analysis && ( +
+ +
+ )} +
+
+
+
+ ); +}; diff --git a/src/pages/analysis/panels/controls/controls.tsx b/src/pages/analysis/panels/controls/controls.tsx new file mode 100644 index 0000000..948fb9e --- /dev/null +++ b/src/pages/analysis/panels/controls/controls.tsx @@ -0,0 +1,202 @@ +import { useAnalysisStore } from '@/store/analysis.ts'; +import { Button } from '@/components/ui/button.tsx'; +import { useEffect, useRef, useState } from 'react'; +import { Key } from 'chessground/types'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { DrawShape } from 'chessground/draw'; +import { AnalysisMoveClassification } from '@/types/analysis.ts'; +import { classificationToColor, classificationToGlyph, classificationToGlyphUrl } from '@/data/classifications.ts'; +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Pause, Play } from 'lucide-react'; +import { Color, Move } from 'chess.js'; +import { descriptions } from '@/pages/analysis/panels/engineInterpretation/classificationDescription.ts'; + +export const Controls = () => { + const { currentMove, setCurrentMove, analysis, chess, chessGround, orientation, setOrientation } = useAnalysisStore(); + const [isAutoPlaying, setIsAutoPlaying] = useState(false); + const autoPlayInterval = useRef(null); + + const currMove = analysis!.moves[currentMove]; + const previousMove = analysis!.moves[currentMove - 1]; + const variants = previousMove?.engineResults + ?.sort((a, b) => b.depth! - a.depth!) + ?.filter((result, index, self) => self.findIndex((r) => r.move === result.move) === index) + ?.slice(0, analysis?.variants ?? 1); + + const handleNextMove = () => { + if (currentMove >= analysis!.moves.length) return; + + chess.move(currMove.move); + chessGround?.set({ + fen: chess.fen(), + }); + + setCurrentMove(currentMove + 1); + }; + + const handlePrevMove = () => { + if (currentMove <= 0) return; + + chess.undo(); + chessGround?.set({ + fen: chess.fen(), + }); + + setCurrentMove(currentMove - 1); + }; + + const handleToggleAutoPlay = () => { + setIsAutoPlaying(!isAutoPlaying); + }; + + const handleFlipBoard = () => { + setOrientation(orientation === 'white' ? 'black' : 'white'); + + chessGround?.toggleOrientation(); + }; + + const handleSkipToBegin = () => { + const moves = chess.history(); + + for (let i = 0; i < moves.length; i++) { + chess.undo(); + } + + setCurrentMove(0); + chessGround?.set({ + drawable: { autoShapes: [] }, + highlight: { custom: new Map() }, + fen: chess.fen(), + }); + }; + + const handleSkipToEnd = () => { + const moves = analysis!.moves; + + for (let i = currentMove; i < moves.length; i++) { + chess.move(moves[i].move); + } + + setCurrentMove(moves.length); + chessGround?.set({ + fen: chess.fen(), + }); + }; + + useHotkeys('right', handleNextMove); + useHotkeys('left', handlePrevMove); + useHotkeys('ctrl+left', handleSkipToBegin); + useHotkeys('ctrl+right', handleSkipToEnd); + useHotkeys('space', handleToggleAutoPlay); + useHotkeys('f', handleFlipBoard); + useEffect(() => { + if (currentMove >= analysis!.moves.length) setIsAutoPlaying(false); + if (!isAutoPlaying) clearInterval(autoPlayInterval.current!); + else autoPlayInterval.current = setInterval(handleNextMove, 1000); + + return () => clearInterval(autoPlayInterval.current!); + }, [isAutoPlaying, currentMove]); + + useEffect(() => { + if (currentMove >= analysis!.moves.length) return; + + if (!previousMove) return; + + const autoShapes: DrawShape[] = variants.map((result) => ({ + orig: result.from, + dest: result.to, + brush: 'blue', + })) as DrawShape[]; + + if (previousMove.classification && previousMove.classification !== AnalysisMoveClassification.None) { + autoShapes.push({ + orig: previousMove.move.to as Key, + customSvg: { + html: classificationToGlyph[previousMove.classification], + }, + }); + } + + chessGround?.set({ + highlight: { + custom: new Map([ + [previousMove.move.from as Key, classificationToColor[previousMove.classification!]], + [previousMove.move.to as Key, classificationToColor[previousMove.classification!]], + ]), + }, + drawable: { autoShapes: autoShapes }, + }); + }, [currentMove]); + + const toPieceNotation = (move: string, color: Color): string => { + if (color === 'w') { + move = move.replace('N', '♞'); + move = move.replace('B', '♝'); + move = move.replace('R', '♜'); + move = move.replace('Q', '♛'); + move = move.replace('K', '♚'); + move = move.replace('P', '♟'); + + return move; + } + + move = move.replace('N', '♘'); + move = move.replace('B', '♗'); + move = move.replace('R', '♖'); + move = move.replace('Q', '♕'); + move = move.replace('K', '♔'); + move = move.replace('P', '♙'); + + return move; + }; + + const formatMoveDescription = (move: Move, optimalMove: string, classification: AnalysisMoveClassification) => { + const notation = toPieceNotation(move.san, move.color); + const sentence = descriptions[classification][Math.floor(Math.random() * descriptions[classification].length)]; + + return sentence?.replace('{x}', notation)?.replace('{y}', optimalMove); + }; + + return ( +
+
+ + + + + +
+ +
+ {previousMove && ( +
+ {previousMove.classification !== AnalysisMoveClassification.None && ( + classification + )} + {toPieceNotation(previousMove.move.san, previousMove.move.color)} + {previousMove.classification !== AnalysisMoveClassification.None && ( + {previousMove.classification} + )} +
+ )} + + {previousMove && previousMove.classification !== AnalysisMoveClassification.None && variants.length > 0 && ( +

{formatMoveDescription(previousMove.move, variants[0].move!, previousMove.classification!)}

+ )} +
+
+ ); +}; diff --git a/src/pages/analysis/panels/engineInterpretation/classificationDescription.ts b/src/pages/analysis/panels/engineInterpretation/classificationDescription.ts new file mode 100644 index 0000000..6432306 --- /dev/null +++ b/src/pages/analysis/panels/engineInterpretation/classificationDescription.ts @@ -0,0 +1,21 @@ +import { AnalysisMoveClassification } from '@/types/analysis.ts'; + +export const descriptions: Record = { + [AnalysisMoveClassification.None]: [], + [AnalysisMoveClassification.Blunder]: [ + '{x} is a blunder. The best move was {y}.', + '{x} is a huge mistake. A better move was {y}.', + ], + [AnalysisMoveClassification.Mistake]: [ + '{x} is a mistake. The best move was {y}.', + '{x} is a bad move. A better move was {y}.', + ], + [AnalysisMoveClassification.Inaccuracy]: [ + '{x} is an inaccuracy. The best move was {y}.', + '{x} is a slight mistake. A better move was {y}.', + ], + [AnalysisMoveClassification.Good]: ['{x} is a decent but not the best move.'], + [AnalysisMoveClassification.Excellent]: ['{x} is an excellent move.', '{x} is a great move.'], + [AnalysisMoveClassification.Best]: ['{x} is the best move. Well done!', '{x} is the optimal move.'], + [AnalysisMoveClassification.Brilliant]: [], +}; diff --git a/src/pages/analysis/panels/engineInterpretation/engine-interpretation.tsx b/src/pages/analysis/panels/engineInterpretation/engine-interpretation.tsx new file mode 100644 index 0000000..770cd73 --- /dev/null +++ b/src/pages/analysis/panels/engineInterpretation/engine-interpretation.tsx @@ -0,0 +1,8 @@ +import { Icon } from '@iconify/react'; + +export const EngineInterpretation = () => ( +
+ + Engine Interpretation +
+); diff --git a/src/pages/analysis/panels/engineLines/engine-lines.tsx b/src/pages/analysis/panels/engineLines/engine-lines.tsx new file mode 100644 index 0000000..8c9e36f --- /dev/null +++ b/src/pages/analysis/panels/engineLines/engine-lines.tsx @@ -0,0 +1,8 @@ +import { Icon } from '@iconify/react'; + +export const EngineLines = () => ( +
+ + Engine Lines +
+); diff --git a/src/pages/analysis/panels/evalHistory/eval-history.tsx b/src/pages/analysis/panels/evalHistory/eval-history.tsx new file mode 100644 index 0000000..69ceb80 --- /dev/null +++ b/src/pages/analysis/panels/evalHistory/eval-history.tsx @@ -0,0 +1,35 @@ +import { EvalChart } from '@/components/evalchart/evalchart.tsx'; +import { CategoricalChartState } from 'recharts/types/chart/types'; +import { useAnalysisStore } from '@/store/analysis.ts'; + +export const EvalHistory = () => { + const { setCurrentMove, analysis, chess, chessGround } = useAnalysisStore(); + + const handleSkipToMove = (index: number) => { + const moves = chess.history(); + + for (let i = 0; i < moves.length; i++) { + chess.undo(); + } + + for (let i = 0; i < index; i++) { + chess.move(analysis!.moves[i].move); + } + + chessGround?.set({ + fen: chess.fen(), + }); + setCurrentMove(index); + chessGround?.redrawAll(); + }; + + const handleClickEvalChart = (nextState: CategoricalChartState) => { + if (nextState.activeTooltipIndex) handleSkipToMove(nextState.activeTooltipIndex + 1); + }; + + return ( +
+ +
+ ); +}; diff --git a/src/pages/analysis/panels/moveList/move-list.tsx b/src/pages/analysis/panels/moveList/move-list.tsx new file mode 100644 index 0000000..55e87c7 --- /dev/null +++ b/src/pages/analysis/panels/moveList/move-list.tsx @@ -0,0 +1,93 @@ +import { useAnalysisStore } from '@/store/analysis.ts'; +import { Button } from '@/components/ui/button'; +import { Color } from 'chess.js'; +import { classificationToTailwindColor, shouldDisplayClassificationInMoveHistory } from '@/data/classifications.ts'; +import { cn } from '@/lib/utils.ts'; +import { useEffect, useState } from 'react'; +import { Opening } from '@/types/opening.ts'; +import { findOpening } from '@/lib/opening.ts'; + +export const MoveList = () => { + const { analysis, chess, chessGround, currentMove, setCurrentMove } = useAnalysisStore(); + const [opening, setOpening] = useState(undefined); + + const handleSkipToMove = (index: number) => { + const moves = chess.history(); + + for (let i = 0; i < moves.length; i++) { + chess.undo(); + } + + for (let i = 0; i < index; i++) { + chess.move(analysis!.moves[i].move); + } + + chessGround?.set({ + fen: chess.fen(), + }); + setCurrentMove(index); + chessGround?.redrawAll(); + }; + + const toPieceNotation = (move: string, color: Color): string => { + if (color === 'w') { + move = move.replace('N', '♞'); + move = move.replace('B', '♝'); + move = move.replace('R', '♜'); + move = move.replace('Q', '♛'); + move = move.replace('K', '♚'); + move = move.replace('P', '♟'); + + return move; + } + + move = move.replace('N', '♘'); + move = move.replace('B', '♗'); + move = move.replace('R', '♖'); + move = move.replace('Q', '♕'); + move = move.replace('K', '♔'); + move = move.replace('P', '♙'); + + return move; + }; + + useEffect(() => { + findOpening(chess.pgn()) + .then((opening) => setOpening(opening)) + .catch(console.error); + /** When a custom svg (classification) is rendered twice at the same square on two different moves + * it does not trigger a re-render and does not animate the second one, this fixes the issue */ + chessGround?.redrawAll(); + }, [currentMove]); + + return ( +
+ {opening && opening.name} +
+ {analysis?.moves.map((move, index) => ( + + ))} +
+
+ ); +}; diff --git a/src/pages/history/data-table.tsx b/src/pages/history/data-table.tsx index ee70760..df38f45 100644 --- a/src/pages/history/data-table.tsx +++ b/src/pages/history/data-table.tsx @@ -9,7 +9,7 @@ import { useReactTable, getPaginationRowModel, getSortedRowModel, -} from '@tanstack/react-table';; +} from '@tanstack/react-table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { useTranslation } from 'react-i18next'; import { GameDetails } from './columns'; @@ -76,7 +76,9 @@ export function DataTable({ columns, data }: DataTabl ? t(String(cell.row.original.header.Termination).toLowerCase()) : null} {/* {cell.column.id === 'moves' ? cell.row.original.header.Round : null} */} - {cell.column.id === 'header_Date' ? format(parse(cell.row.original.header.Date, 'yyyy.MM.dd', new Date()), 'dd / MM / yyyy') : null} + {cell.column.id === 'header_Date' + ? format(parse(cell.row.original.header.Date, 'yyyy.MM.dd', new Date()), 'dd / MM / yyyy') + : null} {cell.column.id === 'actions' ? flexRender(cell.column.columnDef.cell, cell.getContext()) : null} ))} diff --git a/src/pages/start-analysis/start-analysis.tsx b/src/pages/start-analysis/start-analysis.tsx index c6aaaac..bb814b2 100644 --- a/src/pages/start-analysis/start-analysis.tsx +++ b/src/pages/start-analysis/start-analysis.tsx @@ -166,165 +166,167 @@ export const StartAnalysis = () => { return (
-
+

{t('title')}

- - - - {importMode === ImportMode.CHESS_COM && } - - {importMode === ImportMode.LICHESS_ORG && } - - {importMode === ImportMode.PGN && ( + +
+ + + {importMode === ImportMode.CHESS_COM && } + + {importMode === ImportMode.LICHESS_ORG && } + + {importMode === ImportMode.PGN && ( + ( + + +