From 3b1b18ab03d70209e1bb02f5a9081c54a7ba16d5 Mon Sep 17 00:00:00 2001 From: Mark Toh Date: Mon, 19 Oct 2026 12:03:00 +0800 Subject: [PATCH 01/29] feat: add player level graphs derived from XP thresholds - Add hero XP thresholds with Liquipedia attribution - Add level graph translations for Portuguese, Russian, and Chinese - Refactor and document graph data generation - Fix missing player line keys and header link warnings --- package-lock.json | 87 ++++--------------- src/components/Match/matchPages.tsx | 2 + .../Visualizations/Graph/MatchGraph.tsx | 54 ++++++++---- src/components/Visualizations/Graph/data.ts | 44 ++++++++++ src/lang/en-US.json | 1 + src/lang/pt-BR.json | 1 + src/lang/ru-RU.json | 1 + src/lang/zh-CN.json | 1 + 8 files changed, 104 insertions(+), 87 deletions(-) create mode 100644 src/components/Visualizations/Graph/data.ts diff --git a/package-lock.json b/package-lock.json index 0681d76ac0..3af53ba538 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "@melloware/react-logviewer": "^6.6.0", "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.6", - "abcolor": "^0.5.5", "ace-builds": "^1.43.5", "air-datepicker": "^3.6.0", "dota2-emoticons": "^1.0.4", @@ -112,7 +111,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.7.tgz", "integrity": "sha512-+UpDgowcmqe36d4NwqvKsyPMlOLNGMsfMmQ5WGCu+siCe3t3dfe9njrzGfdN4qq+bcNUt0+Vw6haRxBOycs4dw==", "dev": true, - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -2175,7 +2173,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2845,7 +2842,6 @@ "version": "7.3.6", "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.6.tgz", "integrity": "sha512-R4DaYF3dgCQCUAkr4wW1w26GHXcf5rCmBRHVBuuvJvaGLmZdD8EjatP80Nz5JCw0KxORAzwftnHzXVnjR8HnFw==", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.6", @@ -3651,7 +3647,6 @@ "version": "17.0.47", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.47.tgz", "integrity": "sha512-mk0BL8zBinf2ozNr3qPnlu1oyVTYq+4V7WA76RgxUAtf0Em/Wbid38KN6n4abEkvO4xMTBWmnP1FtQzgkEiJoA==", - "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -3755,14 +3750,6 @@ "integrity": "sha512-UMKd/doXE3karDUd5l+2LqFKUU4SmuPWOT/VA22OrPOUGMlb8/qQvSf+DBLQf/sd8UphuBGxOK/jB/1BB5oQRw==", "dev": true }, - "node_modules/abcolor": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/abcolor/-/abcolor-0.5.5.tgz", - "integrity": "sha512-xYDsPOWM2/n6Xvc9mwcJ1BIHdk6ahjWIqp5dwx9P+s0Ou/f6moythvJOdUNTdeRkB18e0yGin4DBZUksoaEQiw==", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/ace-builds": { "version": "1.43.5", "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.5.tgz", @@ -4109,7 +4096,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6924,7 +6910,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -7028,7 +7013,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -7065,7 +7049,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -7387,7 +7370,6 @@ "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==", - "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } @@ -7897,7 +7879,6 @@ "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-4.4.1.tgz", "integrity": "sha512-RNqj14kYzw++6Sr38n7197xG33ipEOktGElty4I70IKzQF1jzaD1U4xQ+Ny/i03UUhHlC5NWEO+d8olRCDji6g==", "hasInstallScript": true, - "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.0.0", "@babel/traverse": "^7.0.0", @@ -7926,8 +7907,7 @@ "node_modules/stylis": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/stylis/-/stylis-3.5.4.tgz", - "integrity": "sha512-8/3pSmthWM7lsPBKv7NXkzn2Uc9W7NotcwGNpJaa3k7WMM1XDCA4MgT5k/8BIexd5ydZdboXtU90XH9Ec4Bv/Q==", - "peer": true + "integrity": "sha512-8/3pSmthWM7lsPBKv7NXkzn2Uc9W7NotcwGNpJaa3k7WMM1XDCA4MgT5k/8BIexd5ydZdboXtU90XH9Ec4Bv/Q==" }, "node_modules/stylis-rule-sheet": { "version": "0.0.10", @@ -8715,7 +8695,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, - "peer": true, "engines": { "node": ">=12" }, @@ -9324,7 +9303,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, - "peer": true, "engines": { "node": ">=12" }, @@ -9613,7 +9591,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.7.tgz", "integrity": "sha512-+UpDgowcmqe36d4NwqvKsyPMlOLNGMsfMmQ5WGCu+siCe3t3dfe9njrzGfdN4qq+bcNUt0+Vw6haRxBOycs4dw==", "dev": true, - "peer": true, "requires": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -9988,8 +9965,7 @@ "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, - "requires": {} + "dev": true }, "@babel/plugin-syntax-async-generators": { "version": "7.8.4", @@ -11035,7 +11011,6 @@ "version": "11.14.0", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", - "peer": true, "requires": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -11112,8 +11087,7 @@ "@emotion/use-insertion-effect-with-fallbacks": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", - "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", - "requires": {} + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==" }, "@emotion/utils": { "version": "1.4.2", @@ -11383,7 +11357,6 @@ "version": "7.3.6", "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.6.tgz", "integrity": "sha512-R4DaYF3dgCQCUAkr4wW1w26GHXcf5rCmBRHVBuuvJvaGLmZdD8EjatP80Nz5JCw0KxORAzwftnHzXVnjR8HnFw==", - "peer": true, "requires": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.6", @@ -11402,8 +11375,7 @@ "@types/react-transition-group": { "version": "4.4.12", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", - "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", - "requires": {} + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==" }, "dom-helpers": { "version": "5.2.1", @@ -11862,7 +11834,6 @@ "version": "17.0.47", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.47.tgz", "integrity": "sha512-mk0BL8zBinf2ozNr3qPnlu1oyVTYq+4V7WA76RgxUAtf0Em/Wbid38KN6n4abEkvO4xMTBWmnP1FtQzgkEiJoA==", - "peer": true, "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -11963,11 +11934,6 @@ "integrity": "sha512-UMKd/doXE3karDUd5l+2LqFKUU4SmuPWOT/VA22OrPOUGMlb8/qQvSf+DBLQf/sd8UphuBGxOK/jB/1BB5oQRw==", "dev": true }, - "abcolor": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/abcolor/-/abcolor-0.5.5.tgz", - "integrity": "sha512-xYDsPOWM2/n6Xvc9mwcJ1BIHdk6ahjWIqp5dwx9P+s0Ou/f6moythvJOdUNTdeRkB18e0yGin4DBZUksoaEQiw==" - }, "ace-builds": { "version": "1.43.5", "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.5.tgz", @@ -12221,7 +12187,6 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", "dev": true, - "peer": true, "requires": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -14113,7 +14078,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "peer": true, "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -14184,7 +14148,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", - "peer": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -14193,8 +14156,7 @@ "react-content-loader": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/react-content-loader/-/react-content-loader-7.1.1.tgz", - "integrity": "sha512-yNkqtd+15wXRLfDKZb5nTqDV2fPTG2kpUgeGRb+WBz43bU0j4DSGXETF0bnFr44fAoTPpm0Dya0WGdhpHSvtYA==", - "requires": {} + "integrity": "sha512-yNkqtd+15wXRLfDKZb5nTqDV2fPTG2kpUgeGRb+WBz43bU0j4DSGXETF0bnFr44fAoTPpm0Dya0WGdhpHSvtYA==" }, "react-countup": { "version": "6.5.3", @@ -14208,7 +14170,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "peer": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -14223,8 +14184,7 @@ "react-ga": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/react-ga/-/react-ga-3.3.1.tgz", - "integrity": "sha512-4Vc0W5EvXAXUN/wWyxvsAKDLLgtJ3oLmhYYssx+YzphJpejtOst6cbIHCIyF50Fdxuf5DDKqRYny24yJ2y7GFQ==", - "requires": {} + "integrity": "sha512-4Vc0W5EvXAXUN/wWyxvsAKDLLgtJ3oLmhYYssx+YzphJpejtOst6cbIHCIyF50Fdxuf5DDKqRYny24yJ2y7GFQ==" }, "react-helmet": { "version": "6.1.0", @@ -14330,8 +14290,7 @@ "react-side-effect": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz", - "integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==", - "requires": {} + "integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==" }, "react-smooth": { "version": "4.0.4", @@ -14462,7 +14421,6 @@ "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==", - "peer": true, "requires": { "@babel/runtime": "^7.9.2" } @@ -14478,8 +14436,7 @@ "redux-thunk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", - "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==", - "requires": {} + "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==" }, "regenerate": { "version": "1.4.2", @@ -14834,7 +14791,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-4.4.1.tgz", "integrity": "sha512-RNqj14kYzw++6Sr38n7197xG33ipEOktGElty4I70IKzQF1jzaD1U4xQ+Ny/i03UUhHlC5NWEO+d8olRCDji6g==", - "peer": true, "requires": { "@babel/helper-module-imports": "^7.0.0", "@babel/traverse": "^7.0.0", @@ -14861,14 +14817,12 @@ "stylis": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/stylis/-/stylis-3.5.4.tgz", - "integrity": "sha512-8/3pSmthWM7lsPBKv7NXkzn2Uc9W7NotcwGNpJaa3k7WMM1XDCA4MgT5k/8BIexd5ydZdboXtU90XH9Ec4Bv/Q==", - "peer": true + "integrity": "sha512-8/3pSmthWM7lsPBKv7NXkzn2Uc9W7NotcwGNpJaa3k7WMM1XDCA4MgT5k/8BIexd5ydZdboXtU90XH9Ec4Bv/Q==" }, "stylis-rule-sheet": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz", - "integrity": "sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw==", - "requires": {} + "integrity": "sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw==" }, "supports-color": { "version": "5.5.0", @@ -15377,8 +15331,7 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/testcafe-react-selectors/-/testcafe-react-selectors-5.0.3.tgz", "integrity": "sha512-UBqkuQwrPmoxc//KUEtDiAkcns99EhgDNwVa3Q662yY8cQL01hbInXKqY1smOZRHEQwjnFjA2cEL7BHrQzR/pg==", - "dev": true, - "requires": {} + "dev": true }, "testcafe-reporter-json": { "version": "2.2.0", @@ -15446,15 +15399,13 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "requires": {} + "dev": true }, "picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "peer": true + "dev": true } } }, @@ -15775,8 +15726,7 @@ "virtua": { "version": "0.51.3", "resolved": "https://registry.npmjs.org/virtua/-/virtua-0.51.3.tgz", - "integrity": "sha512-UQ9+GzgMO+0jP0X6QpOICTHjxQddoUESOuzRGeXGY8F6fMqNbeVdTwI4Vj8ZhO0fzPUQWi3gzYJ7Mwfm1uD7eQ==", - "requires": {} + "integrity": "sha512-UQ9+GzgMO+0jP0X6QpOICTHjxQddoUESOuzRGeXGY8F6fMqNbeVdTwI4Vj8ZhO0fzPUQWi3gzYJ7Mwfm1uD7eQ==" }, "vite": { "version": "7.3.6", @@ -15797,15 +15747,13 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "requires": {} + "dev": true }, "picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "peer": true + "dev": true } } }, @@ -15962,8 +15910,7 @@ "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "dev": true, - "requires": {} + "dev": true }, "yallist": { "version": "4.0.0", diff --git a/src/components/Match/matchPages.tsx b/src/components/Match/matchPages.tsx index 8be232b25c..e7db2efa46 100644 --- a/src/components/Match/matchPages.tsx +++ b/src/components/Match/matchPages.tsx @@ -305,6 +305,8 @@ const matchPages = ( + + diff --git a/src/components/Visualizations/Graph/MatchGraph.tsx b/src/components/Visualizations/Graph/MatchGraph.tsx index 20113b46c3..58e2229200 100644 --- a/src/components/Visualizations/Graph/MatchGraph.tsx +++ b/src/components/Visualizations/Graph/MatchGraph.tsx @@ -33,9 +33,19 @@ import config from "../../../config"; import useStrings from "../../../hooks/useStrings.hook"; import { Spacer } from "../../Spacer/Spacer"; import Grid from "./Grid"; +import { heroLevels } from "./data"; const formatGraphTime = (minutes: number) => `${minutes}:00`; +const getHeroLevel = (totalXp: number): number => { + let level = 1; + for (const threshold of heroLevels) { + if (totalXp < threshold.totalXp) break; + level = threshold.level; + } + return level; +}; + const generateDiffData = (match: Match) => { const radiantGoldAdv = match.radiant_gold_adv; const radiantXpAdv = match.radiant_xp_adv; @@ -247,23 +257,31 @@ class PlayersGraph extends React.Component< const { match, type, strings } = this.props; const { hoverHero } = this.state; - const matchData: any[] = []; - if ( - match.players && - match.players[0] && - match.players[0][`${type}_t` as keyof MatchPlayer] - ) { - match.players[0][`${type}_t` as keyof MatchPlayer].forEach( - (value: number, index: number) => { - if (index <= Math.floor(match.duration / 60)) { - const obj: Record = { time: formatGraphTime(index) }; - match.players.forEach((player) => { - const hero = heroes[player.hero_id] || {}; - obj[hero.localized_name] = - player[`${type}_t` as keyof MatchPlayer][index]; - }); - matchData.push(obj); - } + const dataKey = `${type === "levels" ? "xp" : type}_t` as keyof MatchPlayer; + const timeline = match.players?.[0]?.[dataKey]; + if (timeline) { + const lastMinute = Math.floor(match.duration / 60); + // One entry per minute through the match duration, containing the formatted time + // and each hero's stat value (or level for the levels graph), keyed by hero name. + const matchData = timeline.slice(0, lastMinute + 1).map( + // Use the array index as the minute; the timeline value itself is unused. + (_: number, minute: number) => { + const dataPoint: Record = { + time: formatGraphTime(minute), + }; + + // Add each hero's stat at this minute. For the levels graph, convert XP to a level; + // for other graph types, use the stat value directly. + match.players.forEach((player) => { + const hero = heroes[player.hero_id] || {}; + const value = player[dataKey]?.[minute]; + dataPoint[hero.localized_name] = + type === "levels" && typeof value === "number" + ? getHeroLevel(value) + : value; + }); + + return dataPoint; }, ); @@ -317,6 +335,7 @@ class PlayersGraph extends React.Component< const stroke = isSelected ? 4 : 2; return ( Date: Mon, 19 Oct 2026 12:47:40 +0800 Subject: [PATCH 02/29] feat: add collapsible laning phase levels report - Compare minute-10 XP and levels using inferred lane roles - Add team perspective switching, hero icons, and level badges - Style advantages and deficits with colored comparisons - Extract report component and share XP-to-level conversion - Add player XP timeline typing and document role inference --- .../Graph/LaningPhaseCaption.tsx | 250 ++++++++++++++++++ .../Visualizations/Graph/MatchGraph.tsx | 14 +- .../Visualizations/Graph/Styled.tsx | 181 +++++++++++++ src/components/Visualizations/Graph/data.ts | 11 +- src/global.d.ts | 1 + 5 files changed, 445 insertions(+), 12 deletions(-) create mode 100644 src/components/Visualizations/Graph/LaningPhaseCaption.tsx diff --git a/src/components/Visualizations/Graph/LaningPhaseCaption.tsx b/src/components/Visualizations/Graph/LaningPhaseCaption.tsx new file mode 100644 index 0000000000..2d17f9ed9a --- /dev/null +++ b/src/components/Visualizations/Graph/LaningPhaseCaption.tsx @@ -0,0 +1,250 @@ +import React, { useState } from "react"; +import SwapHorizIcon from "@mui/icons-material/SwapHoriz"; +import AccessTimeIcon from "@mui/icons-material/AccessTime"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { heroes } from "dotaconstants"; +import config from "../../../config"; +import useStrings from "../../../hooks/useStrings.hook"; +import { getHeroLevel } from "./data"; +import { + LaningCaptionGrid, + PerspectiveHeader, + LaningCaptionCard, + LaningXpDifference, + PhaseHero, + HeroLevelBadge, +} from "./Styled"; + +type LaneRole = "carry" | "hardSupport" | "midlane" | "offlane" | "softSupport"; + +/** + * Infers roles from match-provided `player.lane` and minute-10 last hits. + * + * | Lane | Map | Radiant | Dire | + * |------|--------|------------------------|------------------------| + * | 1 | Bottom | Carry / Hard Support | Offlane / Soft Support | + * | 2 | Mid | Midlane | Midlane | + * | 3 | Top | Offlane / Soft Support | Carry / Hard Support | + * + * Excluding roamers: a single mid player becomes midlane; side-lane pairs + * become core/support by higher/lower last hits. Solo side-lane players + * become cores. One unassigned roamer can fill one missing support role. + * Ties, missing data, or unusual lanes may leave roles undetermined. + * + * `lane_role` differs: 1 = Safe, 2 = Mid, 3 = Off for either team. + * `player_slot` identifies players, not roles: Radiant 0–4, Dire 128–132. + */ +const getLaningRoles = ( + players: MatchPlayer[], + isRadiant: boolean, +): Partial> => { + const team = players.filter( + (player) => player.isRadiant === isRadiant && !player.is_roaming, + ); + const roles: Partial> = {}; + const midPlayers = team.filter((player) => player.lane === 2); + if (midPlayers.length === 1) roles.midlane = midPlayers[0]; + + // Physical lanes: Radiant's safe lane is bottom (1), Dire's is top (3). + const safeLane = isRadiant ? 1 : 3; + const offLane = isRadiant ? 3 : 1; + const assignSideLane = (lane: number, core: LaneRole, support: LaneRole) => { + const lanePlayers = team.filter((player) => player.lane === lane); + if (lanePlayers.length === 1) { + roles[core] = lanePlayers[0]; + return; + } + if ( + lanePlayers.length !== 2 || + lanePlayers.some((player) => !Number.isFinite(player.lh_t?.[10])) + ) + return; + + lanePlayers.sort((a, b) => b.lh_t[10] - a.lh_t[10]); + if (lanePlayers[0].lh_t[10] === lanePlayers[1].lh_t[10]) return; + [roles[core], roles[support]] = lanePlayers; + }; + assignSideLane(safeLane, "carry", "hardSupport"); + assignSideLane(offLane, "offlane", "softSupport"); + + // A roaming support can leave a core alone in their recorded lane. + // Infer the missing support only when there is exactly one candidate and vacancy. + const missingSupports = (["hardSupport", "softSupport"] as const).filter( + (role) => !roles[role], + ); + const assignedPlayers = new Set(Object.values(roles)); + const roamers = players.filter( + (player) => + player.isRadiant === isRadiant && + player.is_roaming && + !assignedPlayers.has(player), + ); + if (missingSupports.length === 1 && roamers.length === 1) { + roles[missingSupports[0]] = roamers[0]; + } + return roles; +}; + +const laningMatchups: { role: LaneRole; enemyRole: LaneRole; label: string }[] = + [ + { role: "carry", enemyRole: "offlane", label: "Carry : Offlane" }, + { + role: "hardSupport", + enemyRole: "softSupport", + label: "Hard Support : Soft Support", + }, + { role: "midlane", enemyRole: "midlane", label: "Midlane : Midlane" }, + { role: "offlane", enemyRole: "carry", label: "Offlane : Carry" }, + { + role: "softSupport", + enemyRole: "hardSupport", + label: "Soft Support : Hard Support", + }, + ]; + +const LaningPhaseCaption = ({ match }: { match: Match }) => { + const strings = useStrings(); + const [perspective, setPerspective] = useState(null); + const [expanded, setExpanded] = useState(true); + const isRadiant = perspective ?? match.radiant_win; + if (match.duration < 600 || typeof match.radiant_win !== "boolean") + return null; + + const teamRoles = getLaningRoles(match.players, isRadiant); + const enemyRoles = getLaningRoles(match.players, !isRadiant); + const renderHero = (player: MatchPlayer | undefined, minute: number) => { + const hero = player && heroes[player.hero_id]; + const xp = player?.xp_t?.[minute]; + const level = Number.isFinite(xp) ? getHeroLevel(xp!) : null; + if (!player) return Role undetermined; + if (!hero) return Unknown hero (ID {player.hero_id}); + return ( + + + {hero.localized_name} + {level !== null && ( + + {level} + + )} + + ); + }; + const laneGroups: { label: string; roles: LaneRole[] }[] = [ + { label: "Safe Lane", roles: ["carry", "hardSupport"] }, + { label: "Mid Lane", roles: ["midlane"] }, + { label: "Offlane", roles: ["offlane", "softSupport"] }, + ]; + const renderComparisons = (minute: number, roles: LaneRole[]) => + laningMatchups + .filter(({ role }) => roles.includes(role)) + .map(({ role, enemyRole, label }) => { + const player = teamRoles[role]; + const enemy = enemyRoles[enemyRole]; + const playerXp = player?.xp_t?.[minute]; + const enemyXp = enemy?.xp_t?.[minute]; + const hasXp = Number.isFinite(playerXp) && Number.isFinite(enemyXp); + const xpDifference = hasXp ? playerXp! - enemyXp! : 0; + const playerLevel = hasXp ? getHeroLevel(playerXp!) : 0; + const enemyLevel = hasXp ? getHeroLevel(enemyXp!) : 0; + const levelDifference = playerLevel - enemyLevel; + const [teamRole, opponentRole] = label.split(" : "); + return ( +
+
+ + {teamRole} + {renderHero(player, minute)} + + + + {opponentRole} + {renderHero(enemy, minute)} + +
+
+ {hasXp ? ( + <> + + {Math.abs(levelDifference)}{" "} + {Math.abs(levelDifference) === 1 ? "level" : "levels"} ( + {xpDifference > 0 ? "+" : ""} + {xpDifference.toLocaleString()}xp) + {" "} + {xpDifference === 0 + ? "equal to" + : xpDifference > 0 + ? "ahead of" + : "behind"}{" "} + the enemy + + ) : ( + "Phase data unavailable" + )} +
+
+ ); + }); + + return ( + + +

+ +

+ +
+ {expanded && ( + <> + +

+ + Laning phase +

+

XP and level comparison at minute 10.

+ {laneGroups.map((group) => ( +
+
{group.label}
+
{renderComparisons(10, group.roles)}
+
+ ))} +
+ + )} +
+ ); +}; + +export default LaningPhaseCaption; diff --git a/src/components/Visualizations/Graph/MatchGraph.tsx b/src/components/Visualizations/Graph/MatchGraph.tsx index 58e2229200..81bb0f60c6 100644 --- a/src/components/Visualizations/Graph/MatchGraph.tsx +++ b/src/components/Visualizations/Graph/MatchGraph.tsx @@ -29,23 +29,14 @@ import { TooltipLineEntryHeroName, TooltipLineEntryValue, } from "./Styled"; -import config from "../../../config"; import useStrings from "../../../hooks/useStrings.hook"; import { Spacer } from "../../Spacer/Spacer"; import Grid from "./Grid"; -import { heroLevels } from "./data"; +import { getHeroLevel } from "./data"; +import LaningPhaseCaption from "./LaningPhaseCaption"; const formatGraphTime = (minutes: number) => `${minutes}:00`; -const getHeroLevel = (totalXp: number): number => { - let level = 1; - for (const threshold of heroLevels) { - if (totalXp < threshold.totalXp) break; - level = threshold.level; - } - return level; -}; - const generateDiffData = (match: Match) => { const radiantGoldAdv = match.radiant_gold_adv; const radiantXpAdv = match.radiant_xp_adv; @@ -356,6 +347,7 @@ class PlayersGraph extends React.Component< /> + {type === "levels" && } ); } diff --git a/src/components/Visualizations/Graph/Styled.tsx b/src/components/Visualizations/Graph/Styled.tsx index 8b2c62efec..5c3cbdce64 100644 --- a/src/components/Visualizations/Graph/Styled.tsx +++ b/src/components/Visualizations/Graph/Styled.tsx @@ -1,6 +1,147 @@ import styled from "styled-components"; import constants from "../../constants"; +export const LaningXpDifference = styled.span<{ $difference: number }>` + font-weight: 600; + color: ${({ $difference }) => + $difference > 0 + ? constants.colorSuccess + : $difference < 0 + ? constants.golden + : "inherit"}; +`; + +export const HeroLevelBadge = styled.span` + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 3px; + box-sizing: border-box; + border: 1px solid rgba(128, 128, 128, 0.4); + border-radius: 50%; + background: rgba(128, 128, 128, 0.12); + color: ${constants.golden}; + font-size: 10px; + font-weight: 600; + line-height: 1; + + .milestone-dots { + position: absolute; + top: -3px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 2px; + } + + .milestone-dots > span { + width: 2px; + height: 2px; + border-radius: 50%; + background: currentColor; + opacity: 0.65; + } +`; + +export const PhaseHero = styled.span` + display: flex; + align-items: center; + gap: 6px; + color: ${constants.primaryTextColor}; + font-weight: 500; + + img { + width: 1em; + height: 1em; + object-fit: contain; + } +`; + +export const LaningCaptionGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); + gap: 16px; + margin-top: 24px; + color: ${constants.primaryTextColor}; + font-family: ${constants.fontFamilySerif}; + font-size: 12px; + line-height: 1.7; + + > p { + grid-column: 1 / -1; + margin: 0; + color: ${constants.colorGreyMuted}; + font-size: 12px; + } +`; + +export const LaningCaptionCard = styled.section<{ $isRadiant: boolean }>` + padding: 20px; + background: ${constants.primarySurfaceColor}; + border: 1px solid rgba(128, 128, 128, 0.2); + border-top: 3px solid + ${({ $isRadiant }) => + $isRadiant ? constants.colorSuccess : constants.colorDanger}; + border-radius: 8px; + min-width: 0; + + h4 { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + } + + h5 { + margin: 16px 0 6px; + font-size: 12px; + color: ${constants.primaryTextColor}; + } + + + h3 { + margin: 0 0 12px; + font-family: ${constants.fontFamilyFuturistic}; + font-size: 14px; + color: ${({ $isRadiant }) => + $isRadiant ? constants.colorSuccess : constants.colorDanger}; + } + + p { + margin: 0 0 16px; + } + + dl { + margin: 0; + } + + dl > div { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 4px 12px; + padding: 10px 0; + border-top: 1px solid rgba(128, 128, 128, 0.2); + } + + dt { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + color: ${constants.colorGreyMuted}; + } + + dd { + margin: 0; + color: ${constants.colorGreyMuted}; + font-weight: 400; + } +`; + export const StyledTooltip = styled.div` position: relative; width: auto; @@ -135,3 +276,43 @@ export const HistogramTooltipDiv = styled.div<{ fontSize === "medium" ? constants.fontSizeMedium : constants.fontSizeSmall}; color: ${({ color }) => color}; `; + +export const PerspectiveHeader = styled.header` + & { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + grid-column: 1 / -1; + } + + h3 { + margin: 0; + font-size: 13px; + } + + button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-left: auto; + padding: 6px; + border: 0; + border-radius: 6px; + background: transparent; + color: ${constants.primaryTextColor}; + font: inherit; + cursor: pointer; + + &:hover { + background: rgba(128, 128, 128, 0.15); + } + + &:focus-visible { + outline: 2px solid currentColor; + outline-offset: 3px; + } + } +`; diff --git a/src/components/Visualizations/Graph/data.ts b/src/components/Visualizations/Graph/data.ts index 17b82ead83..978637e9e1 100644 --- a/src/components/Visualizations/Graph/data.ts +++ b/src/components/Visualizations/Graph/data.ts @@ -41,4 +41,13 @@ const heroLevels: HeroLevel[] = [ { level: 30, totalXp: 64400, xpToNextLevel: null }, ]; -export { heroLevels }; +const getHeroLevel = (totalXp: number): number => { + let level = 1; + for (const threshold of heroLevels) { + if (totalXp < threshold.totalXp) break; + level = threshold.level; + } + return level; +}; + +export { heroLevels, getHeroLevel }; diff --git a/src/global.d.ts b/src/global.d.ts index 0366b551bb..e4557ca9a7 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -108,6 +108,7 @@ type MatchPlayer = { lane: number; stuns: number; gold_t: number[]; + xp_t: number[]; networth_t: number[]; hero_damage_t: number[]; hero_healing_t: number[]; From ab241b04027db1c181c51c1f37a867475d182302 Mon Sep 17 00:00:00 2001 From: Mark Toh Date: Mon, 19 Oct 2026 13:41:47 +0800 Subject: [PATCH 03/29] feat: add net worth and level at 10 minutes to laning table Add sortable NET@10 and LVL@10 columns with tooltips and translations for English, Portuguese, Russian, and Simplified Chinese. --- src/components/Match/Laning/Laning.tsx | 12 +++++++++++- src/components/Match/matchColumns.tsx | 17 +++++++++++++++++ src/lang/en-US.json | 4 ++++ src/lang/pt-BR.json | 4 ++++ src/lang/ru-RU.json | 4 ++++ src/lang/zh-CN.json | 4 ++++ 6 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/components/Match/Laning/Laning.tsx b/src/components/Match/Laning/Laning.tsx index fab01226f6..93bcc7e92a 100644 --- a/src/components/Match/Laning/Laning.tsx +++ b/src/components/Match/Laning/Laning.tsx @@ -12,6 +12,7 @@ import { } from "../StyledMatch"; import Graph from "./Graph"; import config from "../../../config"; +import { getHeroLevel } from "../../Visualizations/Graph/data"; type LaningProps = { match: Match; @@ -44,7 +45,16 @@ class Laning extends React.Component { const { match, strings } = this.props; const { laningColumns } = mcs(strings); - const tableData = [...match.players].sort(this.defaultSort); + const tableData = match.players + .map((player) => { + const xp = player.xp_t?.[10]; + return { + ...player, + net_worth_ten: player.gold_t?.[10], + level_ten: Number.isFinite(xp) ? getHeroLevel(xp) : undefined, + }; + }) + .sort(this.defaultSort); return ( diff --git a/src/components/Match/matchColumns.tsx b/src/components/Match/matchColumns.tsx index 4b64f845da..540b116fc7 100644 --- a/src/components/Match/matchColumns.tsx +++ b/src/components/Match/matchColumns.tsx @@ -1176,6 +1176,23 @@ export default (strings: Strings, beta = false) => { relativeBars: true, sumFn: true, }, + { + displayName: strings.th_net_worth_ten, + tooltip: strings.tooltip_net_worth_ten, + field: "net_worth_ten", + sortFn: true, + displayFn: (row: MatchPlayer, column: any, field: any) => field ?? "-", + relativeBars: true, + sumFn: true, + }, + { + displayName: strings.th_level_ten, + tooltip: strings.tooltip_level_ten, + field: "level_ten", + sortFn: true, + displayFn: (row: MatchPlayer, column: any, field: any) => field ?? "-", + relativeBars: true, + }, ]; const unitKillsColumns = [ diff --git a/src/lang/en-US.json b/src/lang/en-US.json index b837156143..17987ac376 100644 --- a/src/lang/en-US.json +++ b/src/lang/en-US.json @@ -1056,6 +1056,10 @@ "tooltip_win_pct_against": "Win percentage against this player/hero", "tooltip_lhten": "Last hits at 10 minutes", "tooltip_dnten": "Denies at 10 minutes", + "th_net_worth_ten": "NET@10", + "tooltip_net_worth_ten": "Net worth at 10 minutes", + "th_level_ten": "LVL@10", + "tooltip_level_ten": "Level at 10 minutes", "tooltip_biggest_hit": "Largest instance of damage on a hero", "tooltip_damage_dealt": "Damage dealt to heroes by items/abilities", "tooltip_damage_received": "Damage received from heroes by items/abilities", diff --git a/src/lang/pt-BR.json b/src/lang/pt-BR.json index 4c224e15b3..ef50d346f0 100644 --- a/src/lang/pt-BR.json +++ b/src/lang/pt-BR.json @@ -976,6 +976,10 @@ "tooltip_win_pct_against": "Porcentagem de vitória contra este jogador/herói", "tooltip_lhten": "Finalizações aos 10 minutos", "tooltip_dnten": "Negações aos 10 minutos", + "th_net_worth_ten": "Patrimônio aos 10 min", + "tooltip_net_worth_ten": "Patrimônio líquido aos 10 minutos", + "th_level_ten": "Nível aos 10 min", + "tooltip_level_ten": "Nível aos 10 minutos", "tooltip_biggest_hit": "Instância maior de dano em um herói", "tooltip_damage_dealt": "Dano causado a heróis por itens/habilidades", "tooltip_damage_received": "Dano recebido de heróis por itens/habilidades", diff --git a/src/lang/ru-RU.json b/src/lang/ru-RU.json index d971bfd548..dde9c97d6b 100644 --- a/src/lang/ru-RU.json +++ b/src/lang/ru-RU.json @@ -1031,6 +1031,10 @@ "tooltip_win_pct_against": "Процент побед против игроком/героем", "tooltip_lhten": "Добиваний за первые 10 минут", "tooltip_dnten": "Не отданные крипы за первые 10 минут", + "th_net_worth_ten": "Ценность на 10-й мин.", + "tooltip_net_worth_ten": "Общая ценность героя на 10-й минуте", + "th_level_ten": "Ур. на 10-й мин.", + "tooltip_level_ten": "Уровень героя на 10-й минуте", "tooltip_biggest_hit": "Крупнейший мгновенный нанесённый урон по герою", "tooltip_damage_dealt": "Урон, нанесённый героем предметами/способностями", "tooltip_damage_received": "Урон, полученный от героев предметами/способностями", diff --git a/src/lang/zh-CN.json b/src/lang/zh-CN.json index 7443de9a14..80f753ac17 100644 --- a/src/lang/zh-CN.json +++ b/src/lang/zh-CN.json @@ -976,6 +976,10 @@ "tooltip_win_pct_against": "在敌方时,己方胜率", "tooltip_lhten": "前 10 分钟正补数量", "tooltip_dnten": "前 10 分钟反补数量", + "th_net_worth_ten": "10分钟经济", + "tooltip_net_worth_ten": "第10分钟时的英雄总资产", + "th_level_ten": "10分钟等级", + "tooltip_level_ten": "第10分钟时的英雄等级", "tooltip_biggest_hit": "对英雄造成的最大单次伤害", "tooltip_damage_dealt": "使用物品或技能造成的最大伤害", "tooltip_damage_received": "受到物品或技能的最大伤害", From b1d2d9ad27b6d82abfc49294c64b7216fe0200e8 Mon Sep 17 00:00:00 2001 From: Mark Toh Date: Tue, 20 Oct 2026 09:13:47 +0800 Subject: [PATCH 04/29] feat: expand laning analysis and match graphs - Add laning summaries, hero comparisons, and team net worth shares - Improve graph filters, tooltips, captions, and timeline layout - Persist graph and comparison preferences - Add match response types and localized laning labels --- src/actions/analyzeMatch.ts | 42 ++ src/components/Match/Laning/Graph.tsx | 354 +++++++-------- src/components/Match/Laning/GraphCaption.tsx | 248 ++++++++++ src/components/Match/Laning/GraphFilters.tsx | 137 ++++++ src/components/Match/Laning/GraphTooltip.tsx | 214 +++++++++ .../Match/Laning/HeroComparison.tsx | 422 ++++++++++++++++++ src/components/Match/Laning/HeroSelect.tsx | 107 +++++ src/components/Match/Laning/LaneMatchups.tsx | 194 ++++++++ src/components/Match/Laning/Laning.tsx | 368 ++++++++++++--- src/components/Match/Laning/NetWorthShare.tsx | 227 ++++++++++ src/components/Match/Laning/PlayerCards.tsx | 91 ++++ .../Match/Laning/RoleComparisons.tsx | 126 ++++++ src/components/Match/Laning/StyledGraph.ts | 232 ++++++++++ .../Match/Laning/StyledHeroComparison.ts | 221 +++++++++ .../Match/Laning/StyledLaneMatchups.ts | 60 +++ src/components/Match/Laning/StyledLaning.ts | 215 +++++++++ .../Match/Laning/StyledNetWorthShare.ts | 188 ++++++++ src/components/Match/Laning/laning.utils.ts | 412 +++++++++++++++++ src/components/Match/StyledGraphs.ts | 73 +++ src/components/Match/StyledMatch.tsx | 25 +- src/components/Match/Timeline.tsx | 62 ++- src/components/Match/matchColumns.tsx | 91 ++-- src/components/Match/matchPages.tsx | 52 +-- .../Graph/AdvantageGraphTooltip.tsx | 54 +++ .../Visualizations/Graph/HeroLegendLabel.tsx | 26 ++ .../Graph/LaningPhaseCaption.tsx | 169 +++---- .../Visualizations/Graph/MatchGraph.tsx | 230 +++++----- .../Graph/PlayerGraphTooltip.tsx | 48 ++ .../Visualizations/Graph/Styled.tsx | 3 +- .../Visualizations/Table/HeroImage.tsx | 4 +- src/components/constants.ts | 1 + src/hooks/useLocalStorageBoolean.ts | 29 ++ src/lang/en-US.json | 112 ++++- src/lang/pt-BR.json | 111 ++++- src/lang/ru-RU.json | 112 ++++- src/lang/zh-CN.json | 111 ++++- src/tokens.css | 1 + src/types/matchResponse.ts | 248 ++++++++++ src/utility.tsx | 95 +++- 39 files changed, 4968 insertions(+), 547 deletions(-) create mode 100644 src/components/Match/Laning/GraphCaption.tsx create mode 100644 src/components/Match/Laning/GraphFilters.tsx create mode 100644 src/components/Match/Laning/GraphTooltip.tsx create mode 100644 src/components/Match/Laning/HeroComparison.tsx create mode 100644 src/components/Match/Laning/HeroSelect.tsx create mode 100644 src/components/Match/Laning/LaneMatchups.tsx create mode 100644 src/components/Match/Laning/NetWorthShare.tsx create mode 100644 src/components/Match/Laning/PlayerCards.tsx create mode 100644 src/components/Match/Laning/RoleComparisons.tsx create mode 100644 src/components/Match/Laning/StyledGraph.ts create mode 100644 src/components/Match/Laning/StyledHeroComparison.ts create mode 100644 src/components/Match/Laning/StyledLaneMatchups.ts create mode 100644 src/components/Match/Laning/StyledLaning.ts create mode 100644 src/components/Match/Laning/StyledNetWorthShare.ts create mode 100644 src/components/Match/Laning/laning.utils.ts create mode 100644 src/components/Match/StyledGraphs.ts create mode 100644 src/components/Visualizations/Graph/AdvantageGraphTooltip.tsx create mode 100644 src/components/Visualizations/Graph/HeroLegendLabel.tsx create mode 100644 src/components/Visualizations/Graph/PlayerGraphTooltip.tsx create mode 100644 src/hooks/useLocalStorageBoolean.ts create mode 100644 src/types/matchResponse.ts diff --git a/src/actions/analyzeMatch.ts b/src/actions/analyzeMatch.ts index e9a1fef21e..cfe1db07be 100644 --- a/src/actions/analyzeMatch.ts +++ b/src/actions/analyzeMatch.ts @@ -2,6 +2,48 @@ import { skillshots } from "dotaconstants"; import { isSupport, getObsWardsPlaced, isRoshHero } from "../utility"; import store from "../store"; +/** + * Derives player advice using the active language dictionary from the store. + * @param match Match data used to evaluate advice checks. + * @param _pm Player data evaluated by each check. + * @returns An object keyed by check name: eff, farm_drought, skillshot, late_courier, + * wards, roshan, and rune_control. Each result contains a translated name, measured + * value, validity flag, reference target (top), score function, and optional grade flag. + * All checks are returned, including invalid ones. Score functions are not called here. + * @example + * ```ts + * const match = { duration: 1200 }; + * const _pm = { + * hero_id: 1, + * lane_role: 2, + * lane_efficiency: 0.65, + * gold_t: Array.from({ length: 12 }, (_, minute) => minute * 300), + * obs_log: [], + * purchase: {}, + * first_purchase_time: { flying_courier: 300 }, + * runes: { 0: 5 }, + * }; + * ``` + * With the store’s active language set to English: + * ```ts + * const advice = analyzeMatch(match, _pm); + * advice.eff; // { grade: true, name: "Lane efficiency", value: 0.65, top: 0.6, valid: true, score(raw) { return raw; } } + * advice.farm_drought.value; // 300 + * advice.farm_drought.valid; // true + * advice.skillshot.value; // undefined + * advice.skillshot.valid; // false + * advice.late_courier.value; // 120 + * advice.wards.value; // 0 + * advice.wards.valid; // false + * advice.roshan.value; // 0 + * advice.roshan.valid; // false + * advice.rune_control.value; // 5 + * advice.rune_control.top; // 5 + * advice.rune_control.valid; // true + * advice.eff.score(advice.eff.value); // 0.65 + * advice.rune_control.score(advice.rune_control.value); // 1 + * ``` + */ export default function analyzeMatch(match: any, _pm: any) { const { strings } = store.getState().app; // define condition check for each advice point diff --git a/src/components/Match/Laning/Graph.tsx b/src/components/Match/Laning/Graph.tsx index 40e2a84b8a..5c40188b45 100644 --- a/src/components/Match/Laning/Graph.tsx +++ b/src/components/Match/Laning/Graph.tsx @@ -1,199 +1,191 @@ -import { heroes, player_colors as playerColors } from "dotaconstants"; -import React from "react"; -import { - Brush, - CartesianGrid, - Legend, - Line, - LineChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; +/** + * @file Graph. + * @description Shows how each hero’s net worth or level changes over time. Filter by lane or role, select heroes in the legend, and save your tooltip preferences. + */ -import { getHeroIconUrlFromHeroKey } from "../../../utility"; -import Heading from "../../Heading/Heading"; -import { - StyledCustomizedTooltip, - StyledHolder, -} from "../../Visualizations/Graph/Styled"; +import { heroes, player_colors as playerColors } from "dotaconstants"; +import React, { useState } from "react"; +import { Switch } from "@mui/material"; +import { TooltipTotalToggle, RoleCaption, NoMatchingPlayers } from "./StyledGraph"; +import HeroLegendLabel from "../../Visualizations/Graph/HeroLegendLabel"; +import { Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import useStrings from "../../../hooks/useStrings.hook"; +import { localStorageKeys } from "../../../utility"; +import useLocalStorageBoolean from "../../../hooks/useLocalStorageBoolean"; +import Heading from "../../Heading/Heading"; import { Spacer } from "../../Spacer/Spacer"; import constants from "../../constants"; import Grid from "../../Visualizations/Graph/Grid"; +import { StyledHolder } from "../../Visualizations/Graph/Styled"; +import LaningPhaseCaption from "../../Visualizations/Graph/LaningPhaseCaption"; +import { GraphMetric, GraphGrouping, RoleSelection, getPlayerRoles, filterGraphPlayers, buildGraphData } from "./laning.utils"; +import GraphTooltip from "./GraphTooltip"; +import GraphFilters from "./GraphFilters"; +import GraphCaption from "./GraphCaption"; -const formatGraphTime = (minutes: number) => `${minutes}:00`; - -// asdf -const CustomizedTooltip = ({ - label, - payload, -}: { - label?: string; - payload?: any[]; -}) => { - const origOrderMap: Record = {}; - payload?.forEach((p, i) => { - origOrderMap[p.dataKey] = i; - }); - return ( - -
{label}
- {payload - ?.sort((a, b) => b.value - a.value) - ?.map((data, i) => ( -
- {data.dataKey}: {data.value} -
- ))} -
- ); -}; - -const CustomizedDot = (props: { - cx?: number; - cy?: number; - payload?: any; - killsLog: any[]; -}) => { - const { cx, cy, payload, killsLog } = props; - - const kills = killsLog.filter((l) => { - const t = (l.time - (l.time % 60)) / 60; - return formatGraphTime(t) === payload.time; - }); - - if (kills.length > 0) { - return ( - - {kills.map((k, i) => ( - - ))} - - ); - } - - return ; -}; type GraphProps = { match: Match; - selectedPlayer: number; + selectedPlayer?: number; + maxMinute?: number; + metric: GraphMetric; }; -const Graph = (props: GraphProps) => { +/** + * Renders a player net worth or level chart with role/lane filtering and legend selection. + * Tooltip preferences are persisted in localStorage. The timeline defaults to ten minutes; + * the Graphs page supplies the full match duration instead. + * @param props Match data, metric, optional selected player, and inclusive minute limit. + * @returns The interactive chart, or null when no timeline data is available. + */ +const Graph = ({ selectedPlayer, match, metric, maxMinute = 10 }: GraphProps) => { const strings = useStrings(); - const { selectedPlayer, match } = props; - const matchData: any[] = []; + const [showAdvantageSummary, setShowAdvantageSummary] = useLocalStorageBoolean(localStorageKeys.graphTooltipAdvantage); + const [showTooltipTeamTotals, setShowTooltipTeamTotals] = useLocalStorageBoolean( + metric === "networth" ? localStorageKeys.graphTooltipTeamNetWorth : localStorageKeys.graphTooltipTeamLevels, + ); + const [focusedHeroSelection, setFocusedHeroSelection] = useState(null); + const [hoverHero, setHoverHero] = useState(null); + const [groupBy, setGroupBy] = useState("role"); + const [lane, setLane] = useState("all"); + const [role, setRole] = useState("all"); const { players } = match; + const playerRoles = getPlayerRoles(players); + const visiblePlayers = filterGraphPlayers(players, playerRoles, groupBy, lane, role); + const matchData = buildGraphData(match, visiblePlayers, metric, maxMinute); + if(!matchData.length) return null; - if (players[0] && players[0].cs_t) { - players[0].lh_t.forEach((value, index) => { - if (index <= Math.floor(match.duration / 60)) { - const obj: Record = { time: formatGraphTime(index) }; - players.forEach((player) => { - const hero = heroes[player.hero_id] || {}; - obj[hero.localized_name] = player.cs_t[index]; - }); - matchData.push(obj); - } - }); - - return ( - - - - - + + + + setShowAdvantageSummary(event.target.checked)} + /> + {strings.laning_show_tooltip_advantage} + + + setShowTooltipTeamTotals(event.target.checked)} + /> + {(metric === "networth" ? strings.laning_show_tooltip_networth : strings.laning_show_tooltip_levels)} + + + + {visiblePlayers.length === 0 && ( + {strings.laning_empty} + )} + + + - - - - } /> - {match.players.map((player) => { - const hero = heroes[player.hero_id] || {}; - const playerColor = - playerColors[ - player.player_slot as unknown as keyof typeof playerColors - ]; - const isSelected = selectedPlayer === player.player_slot; - const opacity = isSelected ? 1 : 0.25; - const stroke = isSelected ? 4 : 2; - return ( - - ) : ( - false - ) - } - dataKey={hero.localized_name} - key={hero.localized_name} - stroke={playerColor} - strokeWidth={stroke} - strokeOpacity={opacity} - name={hero.localized_name} - /> - ); - })} - - - - - - ); - } - - return null; + axisLine={false} + tickLine={false} + /> + value.toLocaleString("en-US") : undefined} + allowDecimals={metric !== "levels"} + mirror + tick={{ + fontFamily: constants.fontFamilySerif, + fontSize: 10, + fill: constants.colorGreyMuted, + }} + axisLine={false} + tickLine={false} + /> + + + } + wrapperStyle={{ zIndex: 1000 }} + reverseDirection={{ x: true, y: false }} + allowEscapeViewBox={{ x: false, y: true }} + /> + {visiblePlayers.map((player) => { + const hero = heroes[player.hero_id] || {}; + const playerColor = + playerColors[ + player.player_slot as unknown as keyof typeof playerColors + ]; + const focusedHero = hoverHero ?? focusedHeroSelection; + const isSelected = focusedHero + ? focusedHero === hero.localized_name + : selectedPlayer === player.player_slot; + const opacity = !focusedHero || focusedHero === hero.localized_name ? 1 : 0.25; + const stroke = isSelected ? 2 : 1; + return ( + + ); + })} + { + const hero = String(entry.dataKey); + setFocusedHeroSelection((current) => current === hero ? null : hero); + }} + onMouseEnter={(entry) => setHoverHero(String(entry.dataKey))} + onMouseLeave={() => setHoverHero(null)} + formatter={(name) => } + wrapperStyle={{ + fontFamily: constants.fontFamilyFuturistic, + cursor: "pointer", + fontSize: 11, + color: constants.colorGreyMuted, + }} + /> + + + {strings.laning_level_legend_hint} + {metric === "levels" && } + + ); }; export default Graph; diff --git a/src/components/Match/Laning/GraphCaption.tsx b/src/components/Match/Laning/GraphCaption.tsx new file mode 100644 index 0000000000..ee52953848 --- /dev/null +++ b/src/components/Match/Laning/GraphCaption.tsx @@ -0,0 +1,248 @@ +/** + * @file Graph Caption. + * @description Builds localized lane and role result captions from end-of-laning stats and links to the level calculation source. + */ + +import { formatTemplateToString } from "../../../utility"; +import React from "react"; +import { heroes } from "dotaconstants"; +import TrendingUpIcon from "@mui/icons-material/TrendingUp"; +import { IconRadiant, IconDire } from "../../Icons"; +import AccessTimeIcon from "@mui/icons-material/AccessTime"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import MuiTooltip from "@mui/material/Tooltip"; +import useStrings from "../../../hooks/useStrings.hook"; +import { getHeroLevel } from "../../Visualizations/Graph/data"; +import { getRoleLabels, GraphMetric, GraphGrouping, RoleSelection } from "./laning.utils"; +import { RoleCaption, LevelCaption, LevelSourceLink } from "./StyledGraph"; + + +type GraphCaptionProps = { + match: Match; + metric: GraphMetric; + groupBy: GraphGrouping; + lane: string; + role: RoleSelection; + visiblePlayers: MatchPlayer[]; +}; + +type HistoryField = "gold_t" | "xp_t"; + +/** + * Adds a team's sampled values at a given minute, optionally transforming each value. + * + * Validate sample availability before calling: missing values must not count as zero. + * @param players Players belonging to one side of the comparison. + * @param field Gold or XP history to read. + * @param minute Index of the minute sample, starting at minute zero. + * @param transformValue Converts each sample before summing, such as XP to hero level. + * @returns The combined value for the supplied players. + */ +const sumAtMinute = ( + players: MatchPlayer[], + field: HistoryField, + minute: number, + transformValue: (value: number) => number = (value) => value, +) => players.reduce( + (total, player) => total + transformValue(player[field][minute]), + 0, +); + +/** + * Describes the selected lane or role at the end of the first ten minutes. + * Uses the last completed minute for shorter matches and localized fallback text + * when a comparison lacks data. Level captions also link to the XP threshold source. + * @returns A caption, or null for a net worth graph grouped by role. + */ +const GraphCaption = ({ + match, + metric, + groupBy, + lane, + role, + visiblePlayers, +}: GraphCaptionProps) => { + const strings = useStrings(); + const captionParts: React.ReactNode[] = []; + let hasXpValue = false; + /** + * Formats a translated caption and keeps styled time, team, and XP values inline. + * @param template Message from the active language dictionary. + * @param values Named placeholder replacements. + * @returns Caption text with markers for the styled values rendered below. + */ + const formatCaption = (template: string, values: Record = {}) => { + if (metric !== "levels") return formatTemplateToString(template, values); + const formattedValues = { ...values }; + Object.entries(values).forEach(([name, value]) => { + let content: React.ReactNode; + if (name === "minute") { + content = ; + } else if (["team", "radiant", "dire"].includes(name)) { + content = + {value === strings.general_radiant ? : }{value} + ; + } else if (name === "xp") { + hasXpValue = true; + content = + ; + } else return; + formattedValues[name] = `\uE000${captionParts.length}\uE001`; + captionParts.push(content); + }); + const text = formatTemplateToString(template, formattedValues); + return values.xp === undefined ? text : text.replace(`+${formattedValues.xp}`, String(formattedValues.xp)); + }; + const roleLabels = getRoleLabels(strings); + + if (metric !== "levels" && groupBy !== "lane") return null; + + // Summarize the end of the laning window, or the final minute of a shorter match. + const summaryMinute = Math.min(10, Math.floor(match.duration / 60)); + const radiantPlayers = visiblePlayers.filter((player) => player.isRadiant); + const direPlayers = visiblePlayers.filter((player) => !player.isRadiant); + + /** + * Checks that both sides have finite samples at the summary minute. + * @param field History required by the comparison. + * @param comparingAllPlayers Whether both teams must contain all five players. + * @returns Whether totals can be calculated without treating missing data as zero. + */ + const hasComparisonData = (field: HistoryField, comparingAllPlayers: boolean) => { + if (radiantPlayers.length === 0 || direPlayers.length === 0) return false; + + // Whole-team totals require all five players; lane and role groups can be smaller. + if (comparingAllPlayers && (radiantPlayers.length !== 5 || direPlayers.length !== 5)) { + return false; + } + + return visiblePlayers.every((player) => + Number.isFinite(player[field]?.[summaryMinute]), + ); + }; + + /** + * Compares combined gold or combined hero levels for the selected physical lane. + * Each hero's XP is converted to a level before calculating the lane's level total. + * @returns Localized team totals and the lead margin, tie, or missing-data message. + * @example + * // English net worth caption for a selected lane: + * // "At minute 10: Radiant 12,000 vs Dire 10,500 gold. Radiant +1,500 gold." + * @example + * // English levels caption for a selected lane: + * // "At minute 10: Radiant 15 vs Dire 13 combined levels. Radiant +2 levels." + */ + const getLaneCaption = () => { + const isNetWorthGraph = metric === "networth"; + const historyField = isNetWorthGraph ? "gold_t" : "xp_t"; + if (!hasComparisonData(historyField, lane === "all")) { + return formatCaption(strings.laning_insufficient, { minute: summaryMinute }); + } + + // Convert each hero's XP to a level before adding levels together. + // Converting the lane's combined XP would describe a single imaginary hero. + const valueToAdd = isNetWorthGraph ? (value: number) => value : getHeroLevel; + const radiantTotal = sumAtMinute(radiantPlayers, historyField, summaryMinute, valueToAdd); + const direTotal = sumAtMinute(direPlayers, historyField, summaryMinute, valueToAdd); + const signedDifference = radiantTotal - direTotal; + const advantage = Math.abs(signedDifference); + const totalUnit = (isNetWorthGraph ? strings.laning_gold : strings.laning_combined); + const advantageUnit = (isNetWorthGraph ? strings.laning_gold : advantage === 1 ? strings.laning_level : strings.laning_levels); + + const result = signedDifference === 0 + ? formatCaption(strings.laning_equal, { unit: totalUnit }) + : formatCaption(strings.laning_lead_margin, { + team: signedDifference > 0 ? strings.general_radiant : strings.general_dire, + value: advantage.toLocaleString(), + unit: advantageUnit, + }); + + return formatCaption(strings.laning_lane_result, { + minute: summaryMinute, + radiant: strings.general_radiant, + dire: strings.general_dire, + r: radiantTotal.toLocaleString(), + d: direTotal.toLocaleString(), + unit: totalUnit, + result, + }); + }; + + /** + * Compares XP for matching roles, or total team XP when all roles are selected. + * XP determines the leader; a role comparison also reports the hero level gap. + * @returns A localized result, tie, or explanation of why comparison is unavailable. + * @example + * // English caption when filtering to carry: + * // "At minute 10, Radiant — Carry (Sniper) led by +1,635 XP; level difference: 2." + * @example + * // English caption when all roles are selected: + * // "At minute 10, Radiant led by +3,000 total XP." + * @example + * // Missing minute-10 samples: + * // "Insufficient data to compare at minute 10." + */ + const getLevelCaption = () => { + // Unknown roles cannot be reliably paired with an opposing player. + if (role === "unknown") return strings.laning_unknown_comparison; + if (!hasComparisonData("xp_t", role === "all")) { + return formatCaption(strings.laning_insufficient, { minute: summaryMinute }); + } + + const radiantXp = sumAtMinute(radiantPlayers, "xp_t", summaryMinute); + const direXp = sumAtMinute(direPlayers, "xp_t", summaryMinute); + const xpDifference = radiantXp - direXp; + if (xpDifference === 0) return formatCaption(strings.laning_tie, { minute: summaryMinute }); + + // XP determines the leader even when both heroes are still at the same level. + const radiantAhead = xpDifference > 0; + const leadingTeam = radiantAhead ? strings.general_radiant : strings.general_dire; + const xpAdvantage = Math.abs(xpDifference).toLocaleString(); + if (role === "all") { + return formatCaption(strings.laning_team_xp, { minute: summaryMinute, team: leadingTeam, xp: xpAdvantage }); + } + + // A determined role has one player per side, unlike a whole-team comparison. + const leadingPlayer = radiantAhead ? radiantPlayers[0] : direPlayers[0]; + const radiantLevels = sumAtMinute(radiantPlayers, "xp_t", summaryMinute, getHeroLevel); + const direLevels = sumAtMinute(direPlayers, "xp_t", summaryMinute, getHeroLevel); + return formatCaption(strings.laning_role_result, { + minute: summaryMinute, + team: leadingTeam, + role: roleLabels[role], + hero: heroes[leadingPlayer.hero_id]?.localized_name ?? strings.general_no_hero, + xp: xpAdvantage, + levels: Math.abs(radiantLevels - direLevels), + }); + }; + + const Caption = metric === "levels" ? LevelCaption : RoleCaption; + const caption = groupBy === "lane" ? getLaneCaption() : getLevelCaption(); + const captionContent = metric !== "levels" ? caption : caption.split(/(\uE000\d+\uE001|XP)/g).map((part, index) => ( + + {part.startsWith("\uE000") ? captionParts[Number(part.slice(1, -1))] + : part === "XP" ? {!hasXpValue && : part} + + )); + + return ( + + {captionContent} + {metric === "levels" && ( + + + + + + )} + + ); +}; + +export default GraphCaption; diff --git a/src/components/Match/Laning/GraphFilters.tsx b/src/components/Match/Laning/GraphFilters.tsx new file mode 100644 index 0000000000..62cce716e4 --- /dev/null +++ b/src/components/Match/Laning/GraphFilters.tsx @@ -0,0 +1,137 @@ +/** + * @file Graph Filters. + * @description Controlled radio filters for chart grouping, roles, and physical lanes, hiding unused undetermined categories. + */ + +import React from "react"; +import { PhysicalLane } from "../../../types/matchResponse"; +import useStrings from "../../../hooks/useStrings.hook"; +import { Spacer } from "../../Spacer/Spacer"; +import { + getRoleLabels, + GraphMetric, + GraphGrouping, + RoleSelection, + LaneRole, +} from "./laning.utils"; +import { RoleFilter, RoleOptions } from "./StyledGraph"; + +type GraphFiltersProps = { + metric: GraphMetric; + players: MatchPlayer[]; + playerRoles: Map; + groupBy: GraphGrouping; + setGroupBy: (value: GraphGrouping) => void; + lane: string; + setLane: (value: string) => void; + role: RoleSelection; + setRole: (value: RoleSelection) => void; +}; + +/** + * Renders radio controls for choosing a grouping mode and a lane or role. + * Undetermined options appear only when a player lacks that classification. + * @param props Current selections, player classifications, and selection callbacks. + * @returns Controlled filters; selection state belongs to the parent graph. + */ +const GraphFilters = ({ + metric, + players, + playerRoles, + groupBy, + setGroupBy, + lane, + setLane, + role, + setRole, +}: GraphFiltersProps) => { + const strings = useStrings(); + const roleLabels = getRoleLabels(strings); + const hasUndeterminedRoles = players.some( + (player) => !playerRoles.has(player.player_slot), + ); + const laneLabels: Record = { + [PhysicalLane.Bottom]: strings.laning_bottom, + [PhysicalLane.Middle]: strings.laning_mid, + [PhysicalLane.Top]: strings.laning_top, + [PhysicalLane.Jungle]: strings.laning_jungle, + }; + const hasUndeterminedLanes = players.some( + (player) => !laneLabels[String(player.lane)], + ); + return ( + <> + + {strings.laning_group} + + {(["role", "lane"] as const).map((value) => ( + + ))} + + + + {groupBy === "lane" ? ( + + {strings.laning_filter_lane} + + {[ + ["all", strings.laning_all_lanes], + ...Object.entries(laneLabels).filter( + ([value]) => value !== String(PhysicalLane.Jungle), + ), + ["unknown", strings.laning_unknown_lane], + ] + .filter(([value]) => value !== "unknown" || hasUndeterminedLanes) + .map(([value, label]) => ( + + ))} + + + ) : ( + + {strings.laning_filter_role} + + {( + [ + ["all", strings.laning_all_roles], + ...Object.entries(roleLabels), + ["unknown", strings.laning_unknown_role], + ] as [typeof role, string][] + ) + .filter(([value]) => value !== "unknown" || hasUndeterminedRoles) + .map(([value, label]) => ( + + ))} + + + )} + + ); +}; + +export default GraphFilters; diff --git a/src/components/Match/Laning/GraphTooltip.tsx b/src/components/Match/Laning/GraphTooltip.tsx new file mode 100644 index 0000000000..ff0faab076 --- /dev/null +++ b/src/components/Match/Laning/GraphTooltip.tsx @@ -0,0 +1,214 @@ +/** + * @file Graph Tooltip. + * @description Displays hovered hero values and optional role, lane, or team comparisons for Net Worth and Levels charts. + */ + +import { formatTemplateToString } from "../../../utility"; +import React from "react"; +import config from "../../../config"; +import AccessTimeIcon from "@mui/icons-material/AccessTime"; +import { heroes } from "dotaconstants"; +import useStrings from "../../../hooks/useStrings.hook"; +import constants from "../../constants"; +import { getHeroLevel } from "../../Visualizations/Graph/data"; +import { getLaningRoles, LaneRole, getRoleLabels } from "./laning.utils"; + +import { LaningTooltip, TooltipColumns, TooltipDifferences, DifferenceRow, DifferenceValue, TeamIndicator, TooltipTeamTotals } from "./StyledGraph"; + +/** + * Displays hovered hero values, optional team totals, and role or lane advantages. + * Team totals use the full roster at the hovered minute, regardless of filtering. + * Positive differences favor Radiant; negative differences favor Dire. + * @param props Recharts hover data, full player roster, metric, and visibility options. + * @returns Tooltip content, or null when inactive or without hover data. + */ +const GraphTooltip = ({ + active, + label, + payload, + players, + metric, + lanePlayers, + showTeamTotals = true, + teamTotalsEnabled = true, + showAdvantageSummary = true, +}: { + active?: boolean; + label?: string; + payload?: any[]; + players: MatchPlayer[]; + metric: "networth" | "levels"; + lanePlayers?: MatchPlayer[]; + showTeamTotals?: boolean; + teamTotalsEnabled?: boolean; + showAdvantageSummary?: boolean; +}) => { + const strings = useStrings(); + const roleLabels = getRoleLabels(strings); + const radiantRoles = getLaningRoles(players, true); + const direRoles = getLaningRoles(players, false); + const differences = (Object.keys(roleLabels) as LaneRole[]).flatMap((role) => { + const radiant = radiantRoles[role]; + const dire = direRoles[role]; + if(!radiant || !dire) return []; + const radiantValue = payload?.find( + (entry) => entry.dataKey === heroes[radiant.hero_id]?.localized_name, + )?.value; + const direValue = payload?.find( + (entry) => entry.dataKey === heroes[dire.hero_id]?.localized_name, + )?.value; + if(!Number.isFinite(radiantValue) || !Number.isFinite(direValue)) return []; + return [{ role, difference: radiantValue - direValue }]; + }); + /** + * Adds hovered values for one side of the selected lane. + * @param isRadiant Which team’s visible players to total. + * @returns The combined value, or null if the team or a value is missing. + */ + const laneTotal = (isRadiant: boolean) => { + const team = lanePlayers?.filter((player) => player.isRadiant === isRadiant); + if(!team?.length) return null; + const values = team.map((player) => payload?.find( + (entry) => entry.dataKey === heroes[player.hero_id]?.localized_name, + )?.value); + return values.every((value) => Number.isFinite(value)) + ? values.reduce((sum, value) => sum + value, 0) : null; + }; + const radiantTotal = laneTotal(true); + const direTotal = laneTotal(false); + const laneDifference = radiantTotal !== null && direTotal !== null + ? radiantTotal - direTotal : null; + // Read the full roster, rather than the filtered tooltip payload, for team totals. + const hoveredMinute = label?.match(/^(\d+):00$/)?.[1]; + /** + * Calculates full-team totals independently of the visible hero filter. + * Gold is summed directly; XP is converted to each hero’s level before summing. + * @param isRadiant Which team to total. + * @returns The hovered minute’s total, or null without five valid player values. + */ + const teamTotal = (isRadiant: boolean): number | null => { + if (hoveredMinute === undefined) return null; + const minute = Number(hoveredMinute); + const team = players.filter((player) => player.isRadiant === isRadiant); + const field = metric === "networth" ? "gold_t" : "xp_t"; + if (team.length !== 5 || team.some((player) => !Number.isFinite(player[field]?.[minute]))) { + return null; + } + return team.reduce((total, player) => { + const value = player[field][minute]; + return total + (metric === "levels" ? getHeroLevel(value) : value); + }, 0); + }; + const radiantTeamTotal = teamTotal(true); + const direTeamTotal = teamTotal(false); + const totalDifference = radiantTeamTotal !== null && direTeamTotal !== null + ? radiantTeamTotal - direTeamTotal : null; + const teamTotals = ( + +

{(metric === "networth" ? strings.laning_team_networth : strings.laning_team_level_totals)}

+ {showAdvantageSummary && + {strings.laning_difference} + {totalDifference === null ? {strings.laning_unavailable} : ( + + {totalDifference !== 0 && "+"}{Math.abs(totalDifference).toLocaleString("en-US")} {(metric === "networth" ? strings.laning_gold : Math.abs(totalDifference) === 1 ? strings.laning_level : strings.laning_levels)} + + )} + } + {[true, false].map((isRadiant) => { + const total = isRadiant ? radiantTeamTotal : direTeamTotal; + return ( + + + {isRadiant ? strings.general_radiant : strings.general_dire} + + + {total === null ? strings.laning_unavailable : `${total.toLocaleString("en-US")} ${(metric === "networth" ? strings.laning_gold : strings.laning_levels)}`} + + + ); + })} +
+ ); + const hideBreakdown = showTeamTotals; + if (!active || !payload?.length) return null; + return ( + +
+ + {showTeamTotals && teamTotalsEnabled && teamTotals} + {showAdvantageSummary && !hideBreakdown &&
+ {lanePlayers ? ( + +

{strings.laning_lane_totals}

+ + {strings.general_radiant} + {radiantTotal?.toLocaleString() ?? strings.laning_unavailable} {radiantTotal !== null && (metric === "networth" ? strings.laning_gold : strings.laning_levels)} + + + {strings.general_dire} + {direTotal?.toLocaleString() ?? strings.laning_unavailable} {direTotal !== null && (metric === "networth" ? strings.laning_gold : strings.laning_levels)} + + + {laneDifference === null || laneDifference === 0 ? strings.laning_advantage : formatTemplateToString(strings.laning_leads, { team: laneDifference > 0 ? strings.general_radiant : strings.general_dire })} + {laneDifference === null ? {strings.laning_unavailable} : ( + + {laneDifference !== 0 && "+"}{Math.abs(laneDifference).toLocaleString()}{" "} + {metric === "networth" ? strings.laning_gold : Math.abs(laneDifference) === 1 ? strings.laning_level : strings.laning_levels} + + )} + +
+ ) : differences.length > 0 && ( + +

{strings.laning_advantage}

+ {differences.map(({ role, difference }) => ( + + {roleLabels[role]} + + {difference !== 0 && "+"} + {Math.abs(difference).toLocaleString()}{" "} + {metric === "networth" + ? strings.laning_gold + : Math.abs(difference) === 1 ? strings.laning_level : strings.laning_levels} + + + ))} +
+ )} +
} +
+ {payload + ?.slice() + .sort((a, b) => b.value - a.value) + ?.map((data, i) => { + const player = players.find( + (entry) => heroes[entry.hero_id]?.localized_name === data.dataKey, + ); + const hero = player && heroes[player.hero_id]; + return ( +
+ {hero && ( + {hero.localized_name} + )} + {typeof data.value === "number" ? data.value.toLocaleString("en-US") : data.value} + +
+ ); + })} +
+
+
+ ); +}; + +export default GraphTooltip; diff --git a/src/components/Match/Laning/HeroComparison.tsx b/src/components/Match/Laning/HeroComparison.tsx new file mode 100644 index 0000000000..8f31f58b07 --- /dev/null +++ b/src/components/Match/Laning/HeroComparison.tsx @@ -0,0 +1,422 @@ +/** + * @file Hero Comparison. + * @description Compares selected opposing heroes at minute ten using compact metric bars and an optional same-role selection lock. + */ + +import { + formatTemplate, + formatTemplateToString, + localStorageKeys, +} from "../../../utility"; +import React, { useState } from "react"; +import AccessTimeIcon from "@mui/icons-material/AccessTime"; +import TrendingUpIcon from "@mui/icons-material/TrendingUp"; +import GpsFixedIcon from "@mui/icons-material/GpsFixed"; +import BlockIcon from "@mui/icons-material/Block"; +import config from "../../../config"; +import { Switch } from "@mui/material"; +import { heroes, player_colors as playerColors } from "dotaconstants"; +import useLocalStorageBoolean from "../../../hooks/useLocalStorageBoolean"; +import useStrings from "../../../hooks/useStrings.hook"; +import HeroSelect from "./HeroSelect"; +import Heading from "../../Heading/Heading"; +import { + getStatAtMinute, + getLevelAtMinute, + formatStat, + StatPlayer, + HistoryField, + rolePositions, + getPlayerRoles, +} from "./laning.utils"; +import { TableCaption } from "./StyledLaning"; +import { + HeroMenus, + HeroMenu, + SameRoleToggle, + ComparisonLead, + MetricBalance, + MetricBalanceSide, + MetricList, + MetricRow, + MetricHeading, + MetricValues, + MetricSummary, +} from "./StyledHeroComparison"; + +/** + * Compares two selected opposing heroes using minute-ten levels, gold, last hits, and denies. + * Same-role mode synchronizes selections and excludes roles without an opponent. + * That mode is persisted in localStorage; hero selections remain local to the component. + * @param props The match whose players can be selected. + * @returns Hero menus and proportional metric bars, with unavailable values shown explicitly. + */ +const HeroComparison = ({ match }: { match: Match }) => { + const strings = useStrings(); + const [sameRolesOnly, setSameRolesOnly] = useLocalStorageBoolean( + localStorageKeys.heroComparisonSameRolesOnly, + true, + ); + const [radiantSlot, setRadiantSlot] = useState(null); + const [direSlot, setDireSlot] = useState(null); + const roles = getPlayerRoles(match.players); + /** + * Looks up a hero’s inferred position for menu sorting. + * @returns Position 1–5, or 6 for an undetermined role. + */ + const position = (player: MatchPlayer) => { + const role = roles.get(player.player_slot); + return role ? rolePositions[role] : 6; + }; + /** + * Orders players by inferred position, breaking ties by player slot. + * @returns A comparator result without modifying either player. + */ + const sortByPosition = (first: MatchPlayer, second: MatchPlayer) => + position(first) - position(second) || + first.player_slot - second.player_slot; + const radiantPlayers = match.players + .filter((player) => player.isRadiant) + .sort(sortByPosition); + const direPlayers = match.players + .filter((player) => !player.isRadiant) + .sort(sortByPosition); + // Only include roles with an identified player on both teams in locked mode. + /** + * Checks whether the other team has an identified player with the same role. + * @returns False for undetermined roles or roles without a matching opponent. + */ + const hasOpponent = (player: MatchPlayer, opponents: MatchPlayer[]) => { + const role = roles.get(player.player_slot); + return ( + role !== undefined && + opponents.some((opponent) => roles.get(opponent.player_slot) === role) + ); + }; + const matchingRadiant = radiantPlayers.filter((player) => + hasOpponent(player, direPlayers), + ); + const matchingDire = direPlayers.filter((player) => + hasOpponent(player, radiantPlayers), + ); + const radiantOptions = sameRolesOnly ? matchingRadiant : radiantPlayers; + const direOptions = sameRolesOnly ? matchingDire : direPlayers; + const radiant = + radiantOptions.find((player) => player.player_slot === radiantSlot) ?? + radiantOptions[0]; + const dire = sameRolesOnly + ? direOptions.find( + (player) => + radiant && + roles.get(player.player_slot) === roles.get(radiant.player_slot), + ) + : (direOptions.find((player) => player.player_slot === direSlot) ?? + direOptions[0]); + /** + * Selects a Radiant hero and, in same-role mode, synchronizes the Dire selection. + * @param slot Selected Radiant player slot. + */ + const selectRadiant = (slot: number) => { + setRadiantSlot(slot); + if (sameRolesOnly) + setDireSlot( + direOptions.find( + (player) => roles.get(player.player_slot) === roles.get(slot), + )?.player_slot ?? null, + ); + }; + /** + * Selects a Dire hero and, in same-role mode, synchronizes the Radiant selection. + * @param slot Selected Dire player slot. + */ + const selectDire = (slot: number) => { + setDireSlot(slot); + if (sameRolesOnly) + setRadiantSlot( + radiantOptions.find( + (player) => roles.get(player.player_slot) === roles.get(slot), + )?.player_slot ?? null, + ); + }; + /** Reads minute-ten data only after the match reaches ten minutes. */ + const valueAtTen = (player: StatPlayer | undefined, field: HistoryField) => + match.duration >= 600 ? getStatAtMinute(player, field) : null; + /** Returns the selected hero's level, preserving unavailable short-match data. */ + const level = (player: MatchPlayer | undefined) => + match.duration >= 600 ? getLevelAtMinute(player) : null; + const metrics = [ + { + icon: