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/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/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/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 20113b46c3..81bb0f60c6 100644 --- a/src/components/Visualizations/Graph/MatchGraph.tsx +++ b/src/components/Visualizations/Graph/MatchGraph.tsx @@ -29,10 +29,11 @@ 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 { getHeroLevel } from "./data"; +import LaningPhaseCaption from "./LaningPhaseCaption"; const formatGraphTime = (minutes: number) => `${minutes}:00`; @@ -247,23 +248,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 +326,7 @@ class PlayersGraph extends React.Component< const stroke = isSelected ? 4 : 2; return ( + {type === "levels" && } ); } @@ -370,6 +381,7 @@ const MatchGraph = ({ type === "xp" || type === "lh" || type === "networth" || + type === "levels" || type === "hero_damage" || type === "hero_healing" || type === "camps_stacked" 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 new file mode 100644 index 0000000000..978637e9e1 --- /dev/null +++ b/src/components/Visualizations/Graph/data.ts @@ -0,0 +1,53 @@ +type HeroLevel = { + level: number; + totalXp: number; + xpToNextLevel: number | null; +}; + +/** + * Hero level XP thresholds derived from Liquipedia's Dota 2 Experience page. + * @see https://liquipedia.net/dota2/Experience + */ +const heroLevels: HeroLevel[] = [ + { level: 1, totalXp: 0, xpToNextLevel: 240 }, + { level: 2, totalXp: 240, xpToNextLevel: 400 }, + { level: 3, totalXp: 640, xpToNextLevel: 520 }, + { level: 4, totalXp: 1160, xpToNextLevel: 600 }, + { level: 5, totalXp: 1760, xpToNextLevel: 680 }, + { level: 6, totalXp: 2440, xpToNextLevel: 760 }, + { level: 7, totalXp: 3200, xpToNextLevel: 800 }, + { level: 8, totalXp: 4000, xpToNextLevel: 900 }, + { level: 9, totalXp: 4900, xpToNextLevel: 1000 }, + { level: 10, totalXp: 5900, xpToNextLevel: 1100 }, + { level: 11, totalXp: 7000, xpToNextLevel: 1200 }, + { level: 12, totalXp: 8200, xpToNextLevel: 1300 }, + { level: 13, totalXp: 9500, xpToNextLevel: 1400 }, + { level: 14, totalXp: 10900, xpToNextLevel: 1500 }, + { level: 15, totalXp: 12400, xpToNextLevel: 1600 }, + { level: 16, totalXp: 14000, xpToNextLevel: 1700 }, + { level: 17, totalXp: 15700, xpToNextLevel: 1800 }, + { level: 18, totalXp: 17500, xpToNextLevel: 1900 }, + { level: 19, totalXp: 19400, xpToNextLevel: 2000 }, + { level: 20, totalXp: 21400, xpToNextLevel: 2200 }, + { level: 21, totalXp: 23600, xpToNextLevel: 2400 }, + { level: 22, totalXp: 26000, xpToNextLevel: 2600 }, + { level: 23, totalXp: 28600, xpToNextLevel: 2800 }, + { level: 24, totalXp: 31400, xpToNextLevel: 3000 }, + { level: 25, totalXp: 34400, xpToNextLevel: 4000 }, + { level: 26, totalXp: 38400, xpToNextLevel: 5000 }, + { level: 27, totalXp: 43400, xpToNextLevel: 6000 }, + { level: 28, totalXp: 49400, xpToNextLevel: 7000 }, + { level: 29, totalXp: 56400, xpToNextLevel: 8000 }, + { level: 30, totalXp: 64400, xpToNextLevel: null }, +]; + +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[]; diff --git a/src/lang/en-US.json b/src/lang/en-US.json index 12689dec05..17987ac376 100644 --- a/src/lang/en-US.json +++ b/src/lang/en-US.json @@ -344,6 +344,7 @@ "heading_graph_difference": "Radiant Advantage", "heading_graph_gold": "Gold", "heading_graph_xp": "Experience", + "heading_graph_levels": "Levels", "heading_graph_lh": "Last Hits", "heading_graph_networth": "Net Worth", "heading_graph_hero_damage": "Hero Damage", @@ -1055,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 5f94aa413f..ef50d346f0 100644 --- a/src/lang/pt-BR.json +++ b/src/lang/pt-BR.json @@ -325,6 +325,7 @@ "heading_graph_difference": "Vantagem dos Iluminados", "heading_graph_gold": "Ouro", "heading_graph_xp": "Experiência", + "heading_graph_levels": "Níveis", "heading_graph_lh": "Finalizações", "heading_overview": "Geral", "heading_ability_draft": "Seleção de Habilidades", @@ -975,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 f7b1260414..dde9c97d6b 100644 --- a/src/lang/ru-RU.json +++ b/src/lang/ru-RU.json @@ -343,6 +343,7 @@ "heading_graph_difference": "Преимущество сил Света", "heading_graph_gold": "Золото", "heading_graph_xp": "Опыт", + "heading_graph_levels": "Уровни", "heading_graph_lh": "Добито", "heading_graph_cs": "Добито + Не отдано", "heading_overview": "Обзор", @@ -1030,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 deaa8117ac..80f753ac17 100644 --- a/src/lang/zh-CN.json +++ b/src/lang/zh-CN.json @@ -325,6 +325,7 @@ "heading_graph_difference": "正向为天辉领先", "heading_graph_gold": "经济", "heading_graph_xp": "经验", + "heading_graph_levels": "等级", "heading_graph_lh": "正补", "heading_overview": "摘要", "heading_ability_draft": "技能征召", @@ -975,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": "受到物品或技能的最大伤害",