[
  {
    "path": ".eslintrc.json",
    "content": "{\n  \"parser\": \"@typescript-eslint/parser\",\n  \"parserOptions\": {\n    \"typescript\": true,\n    \"ecmaVersion\": 8,\n    \"sourceType\": \"module\",\n    \"ecmaFeatures\": {\n      \"impliedStrict\": true,\n      \"jsx\": true\n    }\n  },\n  \"ignorePatterns\": [\"node_modules/**\", \"build\"],\n  \"env\": {\n    \"es6\": true,\n    \"node\": true,\n    \"browser\": true\n  },\n  \"settings\": {\n    \"version\": \"detect\",\n    \"react\": {\n      \"version\": \"detect\"\n    }\n  },\n  \"extends\": [\"eslint:recommended\", \"plugin:@typescript-eslint/recommended\", \"plugin:react/recommended\"],\n  \"plugins\": [\"@typescript-eslint\", \"react\", \"react-hooks\"],\n  \"rules\": {\n    \"@typescript-eslint/explicit-function-return-type\": [\"off\"],\n    \"react-hooks/rules-of-hooks\": \"error\",\n    \"react-hooks/exhaustive-deps\": \"warn\",\n    \"react/prop-types\": [\"off\"],\n    \"react/display-name\": [\"off\"],\n    \"react/react-in-jsx-scope\": \"off\",\n    \"curly\": 2,\n    \"eqeqeq\": [2, \"smart\"],\n    \"no-unused-expressions\": \"error\",\n    \"no-labels\": 2,\n    \"no-console\": 0,\n    \"no-eq-null\": 2,\n    \"no-eval\": 2,\n    \"no-fallthrough\": 2,\n    \"no-octal-escape\": 2,\n    \"no-octal\": 2,\n    \"no-redeclare\": \"off\",\n    \"@typescript-eslint/no-redeclare\": [\"error\", { \"ignoreDeclarationMerge\": true }],\n    \"no-with\": 2,\n    \"no-catch-shadow\": 2,\n    \"no-undef\": 2,\n    \"no-use-before-define\": \"off\",\n    \"@typescript-eslint/no-use-before-define\": [\"error\"],\n    \"@typescript-eslint/explicit-module-boundary-types\": \"off\",\n    \"brace-style\": [1, \"1tbs\", { \"allowSingleLine\": true }],\n    \"comma-spacing\": [2, { \"after\": true }],\n    \"comma-style\": [2, \"last\"],\n    \"comma-dangle\": 0,\n    \"computed-property-spacing\": [2, \"never\"],\n    \"indent\": \"off\",\n    \"@typescript-eslint/indent\": \"off\",\n    \"key-spacing\": [2, { \"afterColon\": true }],\n    \"no-mixed-spaces-and-tabs\": 2,\n    \"no-trailing-spaces\": 2,\n    \"quotes\": [2, \"double\", \"avoid-escape\"],\n    \"semi\": [2, \"always\"],\n    \"strict\": [2, \"global\"],\n    \"keyword-spacing\": 2,\n    \"no-var\": 2,\n    \"object-shorthand\": [2, \"always\"],\n    \"prefer-const\": 1,\n    \"prefer-spread\": 2,\n    \"require-yield\": 2\n  }\n}\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# production\n/build\n\n# misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.vscode\n.eslintcache\n"
  },
  {
    "path": ".nvmrc",
    "content": "20\n"
  },
  {
    "path": ".rescriptsrc.js",
    "content": "const { prependWebpackPlugin, getPaths, edit } = require(\"@rescripts/utilities\");\nconst { RetryChunkLoadPlugin } = require(\"webpack-retry-chunk-load-plugin\");\nconst isBabelLoader = (inQuestion) => inQuestion && inQuestion.loader && inQuestion.loader.includes(\"babel-loader\");\n\nif (process.env.NODE_ENV === \"production\") {\n  const dayjs = require(\"dayjs\");\n  dayjs.extend(require(\"dayjs/plugin/utc\"));\n  const timestamp = dayjs.utc().format(\"YYYYMMDDHHmm\");\n  process.env.SENTRY_RELEASE = `${timestamp}-${(process.env.COMMIT_REF || \"unknown\").slice(0, 7)}`;\n} else {\n  process.env.SENTRY_RELEASE = \"devel\";\n}\nprocess.env.REACT_APP_RELEASE = process.env.SENTRY_RELEASE || process.env.REACT_APP_RELEASE || \"\";\nprocess.env.REACT_APP_SENTRY_DSN = process.env.SENTRY_DSN || process.env.REACT_APP_SENTRY_DSN || \"\";\n\nmodule.exports = [\n  process.env.NODE_ENV === \"production\" && process.env.SENTRY_AUTH_TOKEN\n    ? (config) =>\n        prependWebpackPlugin(\n          new (require(\"@sentry/webpack-plugin\"))({\n            validate: true,\n            include: \"build\",\n            ext: [\"js\", \"jsx\", \"ts\", \"tsx\", \"map\", \"jsbundle\", \"bundle\"],\n            release: process.env.REACT_APP_RELEASE,\n            ...(process.env.SENTRY_URL ? { url: process.env.SENTRY_URL } : {}),\n            setCommits: {\n              auto: true,\n              ignoreMissing: true,\n            },\n          }),\n          config\n        )\n    : (x) => x,\n  process.env.RUN_ANALYZER\n    ? (config) => prependWebpackPlugin(new (require(\"webpack-bundle-analyzer\").BundleAnalyzerPlugin)(), config)\n    : (x) => x,\n  process.env.NODE_ENV !== \"production\"\n    ? (config) => {\n        const babelLoaderPaths = getPaths(isBabelLoader, config);\n        return edit(\n          (section) => {\n            if (section.test.toString().includes(\"tsx\")) {\n              section.options.plugins.unshift([\n                \"babel-plugin-direct-import\",\n                { modules: [\"@mui/material\", \"@mui/icons-material\", \"@mui/lab\"] },\n              ]);\n            }\n            return section;\n          },\n          babelLoaderPaths,\n          config\n        );\n      }\n    : (config) => config,\n  process.env.NODE_ENV === \"production\"\n    ? (config) =>\n        prependWebpackPlugin(\n          new RetryChunkLoadPlugin({\n            cacheBust: function () {\n              if (\"serviceWorker\" in navigator) {\n                navigator.serviceWorker.ready\n                  .then(function (registration) {\n                    return registration.unregister();\n                  })\n                  .catch(function () {});\n              }\n              return Date.now();\n            }.toString(),\n            maxRetries: 5,\n            retryDelay: 100,\n            lastResortScript: `(${function () {\n              if (\"serviceWorker\" in navigator) {\n                navigator.serviceWorker.ready\n                  .then(function (registration) {\n                    return registration.unregister();\n                  })\n                  .then(function () {\n                    window.location.href = \"?t=\" + Date.now();\n                  })\n                  .catch(function (error) {\n                    console.error(error.message);\n                    window.location.href = \"?t=\" + Date.now();\n                  });\n              } else {\n                window.location.href = \"?t=\" + Date.now();\n              }\n            }.toString()})()`,\n          }),\n          config\n        )\n    : (x) => x,\n];\n// vim: sts=2:sw=2:ts=2:expandtab\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2020 SAPikachu\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"amae-koromo\",\n  \"version\": \"1.0.0\",\n  \"homepage\": \"https://amae-koromo.sapk.ch\",\n  \"description\": \"\",\n  \"keywords\": [],\n  \"main\": \"src/index.tsx\",\n  \"engines\": {\n    \"node\": \">=14.0.0\",\n    \"npm\": \">=8.0.0\"\n  },\n  \"dependencies\": {\n    \"@emotion/react\": \"^11.11.4\",\n    \"@emotion/styled\": \"^11.11.5\",\n    \"@fontsource/roboto\": \"^4.5.8\",\n    \"@mui/icons-material\": \"^5.15.16\",\n    \"@mui/lab\": \"5.0.0-alpha.65\",\n    \"@mui/material\": \"^5.15.16\",\n    \"@sentry/react\": \"^6.17.4\",\n    \"clsx\": \"^1.1.1\",\n    \"copy-to-clipboard\": \"^3.3.3\",\n    \"dayjs\": \"^1.11.11\",\n    \"i18next\": \"^19.9.2\",\n    \"i18next-browser-languagedetector\": \"^6.1.8\",\n    \"notistack\": \"^2.0.8\",\n    \"react\": \"^17.0.2\",\n    \"react-dom\": \"^17.0.2\",\n    \"react-helmet\": \"^6.1.0\",\n    \"react-i18next\": \"^11.8.3\",\n    \"react-router-dom\": \"5.3.0\",\n    \"react-scripts\": \"^5.0.1\",\n    \"react-virtualized\": \"9.22.2\",\n    \"recharts\": \"^2.1.9\",\n    \"uuid\": \"^8.3.2\"\n  },\n  \"devDependencies\": {\n    \"@pmmmwh/react-refresh-webpack-plugin\": \"^0.5.13\",\n    \"@rescripts/cli\": \"git+https://github.com/SAPikachu/rescripts.git#cli\",\n    \"@rescripts/utilities\": \"git+https://github.com/SAPikachu/rescripts.git#utilities\",\n    \"@sentry/webpack-plugin\": \"^1.18.5\",\n    \"@types/google.analytics\": \"0.0.41\",\n    \"@types/history\": \"^5.0.0\",\n    \"@types/lodash\": \"^4.14.178\",\n    \"@types/react\": \"17.0.0\",\n    \"@types/react-dom\": \"17.0.0\",\n    \"@types/react-helmet\": \"^6.1.0\",\n    \"@types/react-loadable\": \"^5.5.4\",\n    \"@types/react-router-dom\": \"5.3.3\",\n    \"@types/react-virtualized\": \"9.21.10\",\n    \"@types/uuid\": \"^8.3.4\",\n    \"@typescript-eslint/eslint-plugin\": \"^5.62.0\",\n    \"@typescript-eslint/parser\": \"^5.62.0\",\n    \"babel-plugin-direct-import\": \"^0.9.2\",\n    \"prettier\": \"^2.8.8\",\n    \"sass\": \"^1.76.0\",\n    \"typescript\": \"^4.9.5\",\n    \"webpack-bundle-analyzer\": \"^4.5.0\",\n    \"webpack-retry-chunk-load-plugin\": \"^3.0.0\"\n  },\n  \"overrides\": {\n    \"@rescripts/utilities\": \"git+https://github.com/SAPikachu/rescripts.git#utilities\",\n    \"react-virtualized\": {\n      \"react\": \"^17.0.2\",\n      \"react-dom\": \"^17.0.2\"\n    }\n  },\n  \"scripts\": {\n    \"analyze\": \"RUN_ANALYZER=1 npm run build\",\n    \"start\": \"unset BROWSER; rescripts start\",\n    \"build\": \"rescripts build && cp build/index.html build/404.html\",\n    \"test\": \"rescripts test\",\n    \"eject\": \"react-scripts eject\"\n  },\n  \"browserslist\": [\n    \">0.2%\",\n    \"not dead\",\n    \"not ie <= 11\",\n    \"not op_mini all\"\n  ]\n}\n"
  },
  {
    "path": "public/CNAME",
    "content": "amae-koromo.sapk.ch"
  },
  {
    "path": "public/_redirects",
    "content": "/*    /index.html   200"
  },
  {
    "path": "public/favicon2/manifest.json",
    "content": "{\n  \"name\": \"amae-koromo\",\n  \"short_name\": \"amae-koromo\",\n  \"icons\": [\n    { \"src\": \"/favicon2/android-chrome-192x192.png\", \"sizes\": \"192x192\", \"type\": \"image/png\" },\n    { \"src\": \"/favicon2/android-chrome-512x512.png\", \"sizes\": \"512x512\", \"type\": \"image/png\" }\n  ],\n  \"theme_color\": \"#ffffff\",\n  \"background_color\": \"#ffffff\",\n  \"display\": \"standalone\"\n}\n"
  },
  {
    "path": "public/index.html",
    "content": "<!DOCTYPE html>\n<html dir=\"ltr\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta name=\"google\" content=\"notranslate\">\n    <meta name=\"referrer\" content=\"no-referrer\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\" />\n    <meta name=\"theme-color\" content=\"#000000\" />\n    <!--\n      manifest.json provides metadata used when your web app is added to the\n      homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/\n    -->\n    <link rel=\"manifest\" href=\"%PUBLIC_URL%/favicon2/manifest.json\" />\n    <link rel=\"shortcut icon\" href=\"%PUBLIC_URL%/favicon2/favicon.ico\" />\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"%PUBLIC_URL%/favicon2/apple-touch-icon.png\" />\n    <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"%PUBLIC_URL%/favicon2/favicon-32x32.png\" />\n    <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"%PUBLIC_URL%/favicon2/favicon-16x16.png\" />\n    <!--\n      Notice the use of %PUBLIC_URL% in the tags above.\n      It will be replaced with the URL of the `public` folder during the build.\n      Only files inside the `public` folder can be referenced from the HTML.\n\n      Unlike \"/favicon.ico\" or \"favicon.ico\", \"%PUBLIC_URL%/favicon.ico\" will\n      work correctly both with client-side routing and a non-root public URL.\n      Learn how to configure a non-root public URL by running `npm run build`.\n    -->\n    <title>雀魂牌谱屋</title>\n    <style type=\"text/css\">\n      .page-loading {\n        display: flex;\n        height: 100vh;\n        align-items: center;\n        justify-content: center;\n      }\n      @-webkit-keyframes spinner-border {\n        to {\n          -webkit-transform: rotate(360deg);\n          transform: rotate(360deg);\n        }\n      }\n      @keyframes spinner-border {\n        to {\n          -webkit-transform: rotate(360deg);\n          transform: rotate(360deg);\n        }\n      }\n      .spinner-border {\n        display: inline-block;\n        width: 2rem;\n        height: 2rem;\n        vertical-align: text-bottom;\n        border: 0.25em solid currentColor;\n        border-right-color: transparent;\n        border-radius: 50%;\n        -webkit-animation: spinner-border 0.75s linear infinite;\n        animation: spinner-border 0.75s linear infinite;\n      }\n    </style>\n    <meta name=\"google-site-verification\" content=\"Pl474Y0s7H_p4UmS8cGPVL6jG9fXWsUtwj7ePgobt7Q\" />\n    <link rel=\"canonical\" href=\"https://amae-koromo.sapk.ch/\" data-react-helmet=\"true\" />\n  </head>\n\n  <body>\n    <noscript> You need to enable JavaScript to run this app. </noscript>\n    <div id=\"root\">\n      <div class=\"d-flex justify-content-center page-loading\">\n        <div class=\"spinner-border\" role=\"status\" aria-label=\"Loading\"></div>\n      </div>\n    </div>\n    <!--\n      This HTML file is a template.\n      If you open it directly in the browser, you will see an empty page.\n\n      You can add webfonts, meta tags, or analytics to this file.\n      The build step will place the bundled scripts into the <body> tag.\n\n      To begin the development, run `npm start` or `yarn start`.\n      To create a production bundle, use `npm run build` or `yarn build`.\n    -->\n  </body>\n</html>\n"
  },
  {
    "path": "public/robots.txt",
    "content": "User-Agent: *\nDisallow: /player/\n"
  },
  {
    "path": "src/bootstrap.tsx",
    "content": "import { render } from \"react-dom\";\n\nimport * as serviceWorker from \"./serviceWorkerRegistration\";\nimport \"./i18n\";\n\nimport \"@fontsource/roboto/300.css\";\nimport \"@fontsource/roboto/400.css\";\nimport \"@fontsource/roboto/400-italic.css\";\nimport \"@fontsource/roboto/500.css\";\nimport \"@fontsource/roboto/700.css\";\n\nimport \"./styles/styles.scss\";\n\nimport App from \"./components/app\";\n\nimport { Suspense } from \"react\";\nimport Loading from \"./components/misc/loading\";\nimport { SentryErrorBoundary } from \"./utils/sentry\";\n\nimport dayjs from \"dayjs\";\nimport utc from \"dayjs/plugin/utc\";\nimport Conf from \"./utils/conf\";\ndayjs.extend(utc);\n\nif (location.host === \"amae-koromo.vercel.app\") {\n  location.href = \"https://\" + Conf.canonicalDomain;\n}\n\nconst rootElement = document.getElementById(\"root\");\nrender(\n  <SentryErrorBoundary>\n    <Suspense fallback={<Loading />}>\n      <App />\n    </Suspense>\n  </SentryErrorBoundary>,\n  rootElement\n);\n\nserviceWorker.register({\n  onControllerChange() {\n    window.location.reload();\n  },\n  onUpdate(registration) {\n    const waitingServiceWorker = registration.waiting || navigator.serviceWorker.controller;\n\n    if (waitingServiceWorker) {\n      if (waitingServiceWorker.state === \"activated\" || waitingServiceWorker.state === \"activating\") {\n        window.location.reload();\n        return;\n      }\n      waitingServiceWorker.addEventListener(\"statechange\", (event) => {\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        const state = (event.target as any)?.state;\n        if (state === \"activated\" || state === \"activating\") {\n          window.location.reload();\n        }\n      });\n      waitingServiceWorker.postMessage({ type: \"SKIP_WAITING\" });\n      window.location.reload();\n    }\n  },\n});\n\nconst statusPageScript = document.createElement(\"script\");\nstatusPageScript.async = true;\nstatusPageScript.src = \"https://qltr0c2md09b.statuspage.io/embed/script.js\";\ndocument.body.appendChild(statusPageScript);\n"
  },
  {
    "path": "src/components/app/appHeader.tsx",
    "content": "import React from \"react\";\r\n\r\nimport { Container } from \"../layout\";\r\nimport { Alert } from \"../misc/alert\";\r\nimport Conf from \"../../utils/conf\";\r\nimport { useTranslation } from \"react-i18next\";\r\nimport { AlertTitle, styled } from \"@mui/material\";\r\n\r\nconst StyledUl = styled(\"ul\")(({ theme }) => ({\r\n  margin: \"1rem -2rem 1rem 0\",\r\n  padding: 0,\r\n\r\n  [theme.breakpoints.down(\"md\")]: {\r\n    margin: \"1rem -3rem 1rem -1rem\",\r\n  },\r\n}));\r\n\r\nfunction AlertDefault() {\r\n  return (\r\n    <>\r\n      <AlertTitle>说明</AlertTitle>\r\n      <StyledUl>\r\n        <li>本页面数据由第三方维护，不能绝对保证完整和正确，信息仅供参考，请勿用于不良用途。</li>\r\n        <li>记录包含雀魂段位战金之间、玉之间及王座之间的牌谱。</li>\r\n        <li>页面不是实时更新，对局一般会在结束后数分钟至数小时内出现。</li>\r\n        <li>对局数据收集从 2019 年 11 月 29 日开始（玉南及王座南为 2019 年 8 月 23 日），之前的对局已无法获取。</li>\r\n        <li>\r\n          网站主线路会收集少量匿名浏览数据作后续改进及优化之用，如不希望被收集数据，请使用\r\n          <a href={Conf.mirrorUrl}>镜像线路</a>。\r\n        </li>\r\n        <li>\r\n          如有问题或建议，请戳 <a href=\"mailto:i@sapika.ch\">SAPikachu (i@sapika.ch)</a> 或{\" \"}\r\n          <a href=\"https://github.com/SAPikachu/amae-koromo/\">提交 Issue</a>。\r\n        </li>\r\n        <li>\r\n          感谢 <a href=\"https://twitter.com/EDWARDH_Jantama\">EDWARDH</a> 提供新服务器。\r\n        </li>\r\n        <li>\r\n          感谢 <a href=\"https://github.com/kamicloud/\">Kamicloud</a> 提供部分数据。\r\n        </li>\r\n        <li>\r\n          友情链接： <a href=\"https://000.mk\">线上团体赛网站【大凤林】</a>{\" \"}\r\n          <a href=\"https://rate.r-mj.com\">线下段位场【雀庄公式战】</a>{\" \"}\r\n          <a href=\"https://r-mj.com/\">麻将地图【雀士远征踢馆指南】</a>\r\n        </li>\r\n      </StyledUl>\r\n    </>\r\n  );\r\n}\r\n\r\nfunction AlertEn() {\r\n  return (\r\n    <>\r\n      <AlertTitle>Notes</AlertTitle>\r\n      <StyledUl>\r\n        <li>\r\n          This is a fan site, data accuracy can&apos;t be fully guaranteed, please use the data for reference only and\r\n          don&apos;t use it for malicious purpose.\r\n        </li>\r\n        <li>\r\n          Data is not updated in real-time, finished matches will show up on the site in a few minutes to a few hours.\r\n        </li>\r\n        <li>\r\n          Data collection was started from 2019-11-29 (2019-08-23 for Jade South and Throne South matches), matches\r\n          finished before then could no longer be retrived.\r\n        </li>\r\n        <li>\r\n          Main mirror of the site collects small amount of anonymous usage data for improving the site. If you wish to\r\n          opt-out from this, please use <a href={Conf.mirrorUrl}>the alternative mirror</a>.\r\n        </li>\r\n        <li>\r\n          If you have any question or suggestion, feel free to email{\" \"}\r\n          <a href=\"mailto:i@sapika.ch\">SAPikachu (i@sapika.ch)</a> or{\" \"}\r\n          <a href=\"https://github.com/SAPikachu/amae-koromo/\">submit an issue</a>.\r\n        </li>\r\n        <li>\r\n          English translation of the site is contributed by <a href=\"https://github.com/Mjonir\">Mjonir</a> and{\" \"}\r\n          <a href=\"https://github.com/kator-278\">kator-278</a>. Thank you!\r\n        </li>\r\n        <li>\r\n          Thanks <a href=\"https://twitter.com/EDWARDH_Jantama\">EDWARDH</a> for providing new server.\r\n        </li>\r\n        <li>\r\n          Thanks <a href=\"https://github.com/kamicloud/\">Kamicloud</a> for providing some missing data.\r\n        </li>\r\n      </StyledUl>\r\n    </>\r\n  );\r\n}\r\n\r\nfunction AlertJa() {\r\n  return (\r\n    <>\r\n      <AlertTitle>説明</AlertTitle>\r\n      <StyledUl>\r\n        <li>\r\n          当サイトは非公式サイトで、データの完全性と正確性が保証できません、予めご了承ください。サイトの内容を悪用しないでください。\r\n        </li>\r\n        <li>データの更新はリアルタイムではありません。対局がサイトに載るまで数分から数時間がかかります。</li>\r\n        <li>\r\n          データの収集は 2019 年 11 月 29 日から（玉南と王座南は 2019 年 8 月 23\r\n          日）です。収集開始以前の対局は検索できません。\r\n        </li>\r\n        <li>\r\n          <a href={\"https://\" + Conf.canonicalDomain}>メインサイト</a>\r\n          はサービス向上のため、少しの匿名化された利用情報を収集しています。希望しない方は、\r\n          <a href={Conf.mirrorUrl}>ミラーサイト</a>をご利用ください。\r\n        </li>\r\n        <li>\r\n          内容の誤り・誤植等はご報告いただけますと幸いです。 <a href=\"mailto:i@sapika.ch\">SAPikachu (i@sapika.ch)</a>{\" \"}\r\n          または <a href=\"https://github.com/SAPikachu/amae-koromo/\">GitHub</a> でご連絡ください。\r\n        </li>\r\n        <li>\r\n          新しいサーバーを提供してくださった <a href=\"https://twitter.com/EDWARDH_Jantama\">EDWARDH</a> に感謝します。\r\n        </li>\r\n        <li>\r\n          一部のデータを提供してくださった <a href=\"https://github.com/kamicloud/\">Kamicloud</a> に感謝します。\r\n        </li>\r\n      </StyledUl>\r\n    </>\r\n  );\r\n}\r\n\r\nfunction AlertKo() {\r\n  return (\r\n    <>\r\n      <AlertTitle>안내</AlertTitle>\r\n      <StyledUl>\r\n        <li>\r\n          본 사이트는 비공식 사이트로, 데이터의 완전성과 정확성이 보증되지 않습니다. 사이트 내용을 악용하지 말아\r\n          주십시오.\r\n        </li>\r\n        <li>\r\n          데이터 갱신은 실시간으로 이루어지지 않습니다. 대국이 사이트에 반영되기까지는 수 분에서 수 시간이 걸립니다.\r\n        </li>\r\n        <li>\r\n          데이터 수집은 2019년 11월 29일부터(옥탁 반장과 왕좌탁 반장은 2019년 8월 23일) 시작되었습니다. 수집 개시 이전의\r\n          대국은 검색할 수 없습니다.\r\n        </li>\r\n        <li>\r\n          <a href={\"https://\" + Conf.canonicalDomain}>메인 사이트</a>는 서비스 향상을 위해 약간의 익명 사용 데이터를\r\n          수집하고 있습니다. 원치 않는 분은 <a href={Conf.mirrorUrl}>미러 사이트</a>를 이용해 주십시오.\r\n        </li>\r\n        <li>\r\n          잘못된 내용 등이 있는 경우 <a href=\"mailto:i@sapika.ch\">SAPikachu (i@sapika.ch)</a> 또는{\" \"}\r\n          <a href=\"https://github.com/SAPikachu/amae-koromo/\">GitHub</a>로 연락해주시길 바랍니다.\r\n        </li>\r\n        <li>\r\n          한국어 번역은 <a href=\"https://github.com/limgit\">limgit</a>가 도움을 주었습니다. 감사합니다!\r\n        </li>\r\n        <li>\r\n          新しいサーバーを提供してくださった <a href=\"https://twitter.com/EDWARDH_Jantama\">EDWARDH</a> に感謝します。\r\n        </li>\r\n        <li>\r\n          一部のデータを提供してくださった <a href=\"https://github.com/kamicloud/\">Kamicloud</a> に感謝します。\r\n        </li>\r\n      </StyledUl>\r\n    </>\r\n  );\r\n}\r\n\r\nexport function AppHeader() {\r\n  const { i18n } = useTranslation();\r\n  return (\r\n    <Alert container={Container} stateName=\"topNote20211211\">\r\n      {i18n.language.indexOf(\"ja\") === 0 ? (\r\n        <AlertJa />\r\n      ) : i18n.language.indexOf(\"en\") === 0 ? (\r\n        <AlertEn />\r\n      ) : i18n.language.indexOf(\"ko\") === 0 ? (\r\n        <AlertKo />\r\n      ) : (\r\n        <AlertDefault />\r\n      )}\r\n    </Alert>\r\n  );\r\n}\r\n"
  },
  {
    "path": "src/components/app/index.tsx",
    "content": "import { BrowserRouter as Router } from \"react-router-dom\";\nimport Loadable from \"../misc/customizedLoadable\";\nimport Scroller from \"../misc/scroller\";\n\nimport { Container } from \"../layout\";\nimport { AppHeader } from \"./appHeader\";\nimport { MaintenanceHandler } from \"./maintenance\";\nimport Navbar from \"./navbar\";\nimport CanonicalLink from \"../misc/canonicalLink\";\nimport Tracker from \"../misc/tracker\";\nimport Conf from \"../../utils/conf\";\nimport { useTranslation } from \"react-i18next\";\n\nimport \"./theme\";\nimport RootThemeProvider from \"./theme\";\nimport { CssBaseline } from \"@mui/material\";\nimport AdapterDayJs from \"@mui/lab/AdapterDayjs\";\nimport LocalizationProvider from \"@mui/lab/LocalizationProvider\";\nimport { SnackbarProvider } from \"notistack\";\nimport { RegisterSnackbarProvider } from \"../../utils/notify\";\nimport { FC } from \"react\";\nimport StarPlayerProvider from \"../playerDetails/star/starPlayerProvider\";\nimport { Routes } from \"./routes\";\nimport GameLinkActionsProvider from \"../gameRecords/gameLinkActions\";\n\nconst Helmet = Loadable({\n  loader: () => import(\"react-helmet\"),\n  loading: () => <></>,\n});\nconst LP: FC = ({ children }) => (\n  <LocalizationProvider\n    dateAdapter={AdapterDayJs}\n    dateFormats={{\n      month: \"MM\",\n      monthShort: \"MM\",\n      monthAndDate: \"MM-DD\",\n      shortDate: \"MM-DD\",\n      monthAndYear: \"YYYY-MM\",\n      fullDate: \"YYYY-MM-DD\",\n      keyboardDate: \"YYYY-MM-DD\",\n      fullTime: \"HH:mm\",\n    }}\n  >\n    {children}\n  </LocalizationProvider>\n);\n\nconst Providers: FC = ({ children }) => (\n  <RootThemeProvider>\n    <SnackbarProvider maxSnack={3}>\n      <LP>\n        <StarPlayerProvider>{children}</StarPlayerProvider>\n      </LP>\n    </SnackbarProvider>\n  </RootThemeProvider>\n);\n\nfunction App() {\n  const { t, i18n } = useTranslation();\n  return (\n    <Providers>\n      <RegisterSnackbarProvider />\n      <CssBaseline />\n      <div className={\"lang-\" + i18n.language}>\n        <Router>\n          <Helmet defaultTitle={t(Conf.siteTitle)} titleTemplate={`%s | ${t(Conf.siteTitle)}`} />\n          <CanonicalLink />\n          <Tracker />\n          <Navbar />\n          <MaintenanceHandler>\n            <Scroller>\n              {Conf.showTopNotice ? <AppHeader /> : <></>}\n              <Container>\n                <GameLinkActionsProvider>\n                  <Routes />\n                </GameLinkActionsProvider>\n              </Container>\n            </Scroller>\n          </MaintenanceHandler>\n        </Router>\n      </div>\n    </Providers>\n  );\n}\nexport default App;\n"
  },
  {
    "path": "src/components/app/maintenance.tsx",
    "content": "import * as React from \"react\";\nimport { useState } from \"react\";\n\nimport { Alert } from \"../misc/alert\";\nimport { Container } from \"../layout/container\";\nimport { setMaintenanceHandler } from \"../../data/source/api\";\n\nexport function MaintenanceHandler({ children }: { children: React.ReactElement }): React.ReactElement {\n  const [msg, setMsg] = useState(\"\");\n  setMaintenanceHandler(setMsg);\n  if (!msg) {\n    return children;\n  }\n  return (\n    <Alert container={Container} closable={false} title=\"临时维护公告\">\n      {msg}\n    </Alert>\n  );\n}\n"
  },
  {
    "path": "src/components/app/navbar.tsx",
    "content": "import React, { ReactElement, useState } from \"react\";\nimport { Location } from \"history\";\nimport Conf, { CONFIGURATIONS } from \"../../utils/conf\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  AppBar,\n  Button,\n  ButtonGroup,\n  Container,\n  Toolbar,\n  MenuItem,\n  ButtonProps,\n  Box,\n  IconButton,\n  useScrollTrigger,\n  Slide,\n  Drawer,\n  List,\n  ListItemButton,\n  ListItemText,\n  Divider,\n  ListItemIcon,\n  ListItem,\n  ThemeOptions,\n} from \"@mui/material\";\nimport { ArrowDropDown, Language, GitHub, Twitter, Menu as MenuIcon } from \"@mui/icons-material\";\nimport { OverrideTheme } from \"./theme\";\nimport clsx from \"clsx\";\nimport { NavLink, NavLinkProps } from \"react-router-dom\";\nimport NavButton from \"../misc/navButton\";\nimport { MenuButton } from \"../misc/menuButton\";\nimport StarredPlayerMenu from \"../playerDetails/star/starredPlayerMenu\";\n\nconst NAV_ITEMS = [\n  [\"最近役满\", \"highlight\"],\n  [\"排行榜\", \"ranking\"],\n  [\"大数据\", \"statistics\"],\n]\n  .filter(([, path]) => !(path in Conf.features) || Conf.features[path as keyof typeof Conf.features])\n  .map(([label, path]) => ({ label, path }));\n\nconst SITE_LINKS = [\n  [\"四麻\", CONFIGURATIONS.DEFAULT.canonicalDomain],\n  [\"三麻\", CONFIGURATIONS.ikeda.canonicalDomain],\n].map(([label, domain]) => ({ label, domain, active: Conf.canonicalDomain === domain }));\n\nconst LANGUAGES = [\n  [\"中文\", \"zh-hans\"],\n  [\"日本語\", \"ja\"],\n  [\"English\", \"en\"],\n  [\"한국어\", \"ko\"],\n].map(([label, code]) => ({ label, code }));\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction isActive(match: any, location: Location): boolean {\n  if (!match) {\n    return false;\n  }\n  return !NAV_ITEMS.some(({ path }) => location.pathname.startsWith(\"/\" + path));\n}\n\nfunction HideOnScroll({ children }: { children: ReactElement }) {\n  const trigger = useScrollTrigger();\n\n  return (\n    <Slide appear={false} direction=\"down\" in={!trigger}>\n      {children}\n    </Slide>\n  );\n}\n\nconst MobileNavButton = ({ href, children, ...props }: ButtonProps & Omit<NavLinkProps, \"to\">) => (\n  <ListItem disablePadding>\n    <ListItemButton component={NavLink} {...{ to: href, activeClassName: \"Mui-selected\" }} {...props}>\n      <ListItemIcon></ListItemIcon>\n      <ListItemText>{children}</ListItemText>\n    </ListItemButton>\n  </ListItem>\n);\n\nfunction handleSwitchSite(e: React.MouseEvent<HTMLAnchorElement>) {\n  e.preventDefault();\n  if (e.currentTarget.classList.contains(\"active\") || e.currentTarget.classList.contains(\"Mui-selected\")) {\n    return;\n  }\n  const url = new URL(e.currentTarget.href);\n  url.pathname = location.pathname;\n  window.location.href = url.toString();\n}\nfunction DesktopItems() {\n  const { t, i18n } = useTranslation();\n  return (\n    <>\n      <ButtonGroup>\n        <NavButton href=\"/\" isActive={isActive}>\n          {t(\"主页\")}\n        </NavButton>\n        {NAV_ITEMS.map(({ label, path }) => (\n          <NavButton key={path} href={`/${path}`}>\n            {t(label)}\n          </NavButton>\n        ))}\n      </ButtonGroup>\n      <ButtonGroup>\n        {SITE_LINKS.map(({ label, domain, active }) => (\n          <Button\n            className={clsx(active && \"active\")}\n            key={domain}\n            href={`https://${domain}/`}\n            onClick={handleSwitchSite}\n          >\n            {t(label)}\n          </Button>\n        ))}\n      </ButtonGroup>\n      <MenuButton\n        startIcon={<Language />}\n        endIcon={<ArrowDropDown />}\n        label={LANGUAGES.find((x) => x.code === i18n.language)?.label}\n      >\n        {LANGUAGES.map(({ label, code }) => (\n          <MenuItem key={code} onClick={() => i18n.changeLanguage(code)} selected={code === i18n.language}>\n            {label}\n          </MenuItem>\n        ))}\n      </MenuButton>\n      <IconButton href=\"https://twitter.com/AmaeKoromo_MajS\">\n        <Twitter />\n      </IconButton>\n      <IconButton href=\"https://github.com/SAPikachu/amae-koromo\">\n        <GitHub />\n      </IconButton>\n    </>\n  );\n}\nfunction MobileItems() {\n  const { t, i18n } = useTranslation();\n  const [open, setOpen] = useState(false);\n  return (\n    <>\n      <IconButton onClick={() => setOpen(true)}>\n        <MenuIcon />\n      </IconButton>\n      <Drawer anchor=\"right\" open={open} onClose={() => setOpen(false)}>\n        <Box width={250} onClick={() => setOpen(false)}>\n          <List>\n            <MobileNavButton href=\"/\" isActive={isActive}>\n              {t(\"主页\")}\n            </MobileNavButton>\n            {NAV_ITEMS.map(({ label, path }) => (\n              <MobileNavButton key={path} href={`/${path}`}>\n                {t(label)}\n              </MobileNavButton>\n            ))}\n          </List>\n          <Divider />\n          <List>\n            {SITE_LINKS.map(({ label, domain, active }) => (\n              <ListItem disablePadding key={domain}>\n                <ListItemButton selected={active} href={`https://${domain}/`} onClick={handleSwitchSite}>\n                  <ListItemIcon></ListItemIcon>\n                  <ListItemText>{t(label)}</ListItemText>\n                </ListItemButton>\n              </ListItem>\n            ))}\n          </List>\n          <Divider />\n          <List>\n            {LANGUAGES.map(({ label, code }) => (\n              <ListItem disablePadding key={code}>\n                <ListItemButton onClick={() => i18n.changeLanguage(code)} selected={code === i18n.language}>\n                  <ListItemIcon></ListItemIcon>\n                  <ListItemText>{label}</ListItemText>\n                </ListItemButton>\n              </ListItem>\n            ))}\n          </List>\n          <Divider />\n          <List>\n            <ListItem disablePadding>\n              <ListItemButton href=\"https://twitter.com/AmaeKoromo_MajS\">\n                <ListItemIcon>\n                  <Twitter />\n                </ListItemIcon>\n                <ListItemText>{t(\"Twitter\")}</ListItemText>\n              </ListItemButton>\n            </ListItem>\n            <ListItem disablePadding>\n              <ListItemButton href=\"https://github.com/SAPikachu/amae-koromo\">\n                <ListItemIcon>\n                  <GitHub />\n                </ListItemIcon>\n                <ListItemText>{t(\"GitHub\")}</ListItemText>\n              </ListItemButton>\n            </ListItem>\n          </List>\n        </Box>\n      </Drawer>\n    </>\n  );\n}\n\nconst NAVBAR_THEME: ThemeOptions = {\n  components: {\n    MuiIconButton: {\n      defaultProps: {\n        color: \"inherit\",\n      },\n    },\n    MuiButton: {\n      defaultProps: {\n        sx: {\n          transition: (theme) => theme.transitions.create(\"opacity\"),\n        },\n      },\n    },\n    MuiButtonGroup: {\n      defaultProps: {\n        variant: \"text\",\n        sx: {\n          mr: 2,\n        },\n      },\n      styleOverrides: {\n        grouped: {\n          opacity: 0.5,\n          \"&:hover, &.active\": {\n            opacity: 1,\n          },\n          \"&:not(:last-of-type)\": {\n            borderColor: \"transparent\",\n          },\n        },\n      },\n    },\n  },\n} as const;\nexport default function Navbar() {\n  const { t } = useTranslation();\n  return (\n    <OverrideTheme theme={NAVBAR_THEME}>\n      <HideOnScroll>\n        <AppBar position=\"fixed\">\n          <Toolbar variant=\"dense\">\n            <Container>\n              <Box display=\"flex\" alignItems=\"center\">\n                <Button\n                  href=\"/\"\n                  size=\"large\"\n                  variant=\"text\"\n                  sx={{\n                    padding: 0,\n                    \"&:hover\": {\n                      backgroundColor: \"transparent\",\n                    },\n                  }}\n                  disableRipple\n                >\n                  {t(Conf.siteTitle)}\n                </Button>\n                <Box flexGrow={1}></Box>\n                <Box display={[\"none\", null, \"flex\"]} alignItems=\"center\">\n                  <DesktopItems />\n                </Box>\n                <StarredPlayerMenu />\n                <Box display={[\"block\", null, \"none\"]}>\n                  <MobileItems />\n                </Box>\n              </Box>\n            </Container>\n          </Toolbar>\n        </AppBar>\n      </HideOnScroll>\n    </OverrideTheme>\n  );\n}\n"
  },
  {
    "path": "src/components/app/routes.tsx",
    "content": "import { Route, Switch } from \"react-router-dom\";\nimport Loadable from \"../misc/customizedLoadable\";\nimport GameRecords from \"../gameRecords\";\nimport { PageCategory } from \"../misc/tracker\";\nimport Conf from \"../../utils/conf\";\n\nconst Ranking = Loadable({\n  loader: () => import(\"../ranking\"),\n});\nconst Statistics = Loadable({\n  loader: () => import(\"../statistics\"),\n});\nconst RecentHighlight = Loadable({\n  loader: () => import(\"../recentHighlight\"),\n});\nconst ContestTools = Loadable({\n  loader: () => import(\"../contestTools\"),\n});\nexport function Routes() {\n  return (\n    <Switch>\n      <Route path=\"/ranking\">\n        <PageCategory category=\"Ranking\" />\n        <Ranking />\n      </Route>\n      <Route path=\"/statistics\">\n        <PageCategory category=\"Statistics\" />\n        <Statistics />\n      </Route>\n      <Route path=\"/highlight\">\n        <PageCategory category=\"RecentHighlight\" />\n        <RecentHighlight />\n      </Route>\n      {Conf.features.contestTools ? (\n        <Route path=\"/contest-tools\">\n          <ContestTools />\n        </Route>\n      ) : null}\n      <Route path=\"/\">\n        <GameRecords />\n      </Route>\n    </Switch>\n  );\n}\n"
  },
  {
    "path": "src/components/app/theme.tsx",
    "content": "import { ReactNode, useMemo } from \"react\";\nimport {\n  alpha,\n  createTheme,\n  responsiveFontSizes,\n  Theme,\n  ThemeOptions,\n  ThemeProvider as MaterialThemeProvider,\n} from \"@mui/material\";\nimport { enUS, jaJP, koKR, Localization, zhCN } from \"@mui/material/locale\";\nimport { deepmerge } from \"@mui/utils\";\nimport { useTranslation } from \"react-i18next\";\nimport { LinkBehavior } from \"../misc/linkBehavior\";\n\nconst LOCALES: { [key: string]: Localization } = {\n  en: enUS,\n  ja: jaJP,\n  ko: koKR,\n} as const;\nconst DEFAULT_LOCALE = zhCN;\n\nconst FONTS: { [key: string]: string } = {\n  en: '\"Roboto\", \"Meiryo\", \"Microsoft YaHei\", sans-serif',\n  ja: '\"Roboto\", \"Meiryo\", \"Microsoft YaHei\", sans-serif',\n  ko: '\"Roboto\", \"Malgun Gothic\", \"Meiryo\", \"Microsoft YaHei\", sans-serif',\n};\nconst DEFAULT_FONT = '\"Roboto\", \"Microsoft YaHei\", \"Meiryo\", sans-serif';\n\nconst THEME_BASIC: ThemeOptions = {\n  palette: {\n    mode: \"light\",\n    primary: {\n      light: \"#6a4f4b\",\n      main: \"#3e2723\",\n      dark: \"#1b0000\",\n      contrastText: \"#fff\",\n    },\n    secondary: {\n      light: \"#ffffff\",\n      main: \"#f8f0ed\",\n      dark: \"#004ba0\",\n      contrastText: \"#000\",\n    },\n  },\n  typography: {\n    fontFamily: '\"Roboto\", \"Microsoft YaHei\", \"Meiryo\", sans-serif',\n    fontSize: 16,\n  },\n};\nconst THEME_VALUES = createTheme(THEME_BASIC);\n\nconst THEME: ThemeOptions = {\n  ...THEME_BASIC,\n  components: {\n    MuiLink: {\n      defaultProps: {\n        color: \"info.main\",\n        underline: \"hover\",\n        ...({\n          component: LinkBehavior,\n        } as any), // eslint-disable-line @typescript-eslint/no-explicit-any\n      },\n    },\n    MuiListItemButton: {\n      defaultProps: {\n        ...({\n          component: LinkBehavior,\n        } as any), // eslint-disable-line @typescript-eslint/no-explicit-any\n      },\n    },\n    MuiButtonBase: {\n      defaultProps: {\n        LinkComponent: LinkBehavior,\n      },\n    },\n    MuiButton: {\n      styleOverrides: {\n        root: {\n          fontWeight: \"normal\",\n          textTransform: \"none\",\n        },\n      },\n    },\n    MuiAppBar: {\n      defaultProps: {\n        color: \"secondary\",\n      },\n    },\n    MuiToolbar: {\n      styleOverrides: {\n        root: {\n          padding: 0,\n          \"& .MuiButton-root\": {\n            color: \"inherit\",\n          },\n        },\n      },\n    },\n    MuiOutlinedInput: {\n      styleOverrides: {\n        root: {\n          backgroundColor: \"rgba(255, 255, 255, 0.5)\",\n        },\n      },\n    },\n    MuiTableRow: {\n      styleOverrides: {\n        root: {\n          \"&:nth-of-type(2n+1) .MuiTableCell-root\": {\n            boxShadow: `inset 0 0 0 9999px ${alpha(THEME_VALUES.palette.primary.dark, 0.05)};`,\n          },\n        },\n      },\n    },\n    MuiTableHead: {\n      styleOverrides: {\n        root: {\n          boxShadow: `inset 0 0 0 9999px ${alpha(THEME_VALUES.palette.primary.dark, 0.075)};`,\n          \"& .MuiTableCell-root\": {\n            fontWeight: \"bold\",\n          },\n        },\n      },\n    },\n    MuiTableCell: {\n      styleOverrides: {\n        root: {\n          padding: THEME_VALUES.spacing(1.5),\n        },\n      },\n    },\n    MuiFormControlLabel: {\n      styleOverrides: {\n        root: {\n          \"& .MuiTypography-root\": {\n            fontSize: \"1rem\",\n          },\n        },\n      },\n    },\n    MuiTooltip: {\n      defaultProps: {\n        enterTouchDelay: 100,\n        leaveTouchDelay: 15000,\n      },\n    },\n    MuiUseMediaQuery: {\n      defaultProps: {\n        noSsr: true,\n      },\n    },\n    ...{\n      MuiCalendarPicker: {\n        styleOverrides: {\n          root: {\n            \"& > div:first-child > [role=presentation] > .PrivatePickersFadeTransitionGroup-root:nth-child(2)\": {\n              order: -1,\n              display: \"flex\",\n              div: {\n                margin: 0,\n              },\n              \"&::after\": {\n                display: \"block\",\n                content: \"'-'\",\n                marginLeft: \"0.5rem\",\n                marginRight: \"0.5rem\",\n              },\n            },\n          },\n        },\n      },\n    },\n  },\n};\n\nexport function OverrideTheme({ theme, children }: { theme: ThemeOptions; children: ReactNode }) {\n  const themeFunc = useMemo(() => (outerTheme: Theme) => deepmerge(outerTheme, theme), [theme]);\n  return <MaterialThemeProvider theme={themeFunc}>{children}</MaterialThemeProvider>;\n}\nexport default function RootThemeProvider({ children }: { children: ReactNode }) {\n  const { i18n } = useTranslation();\n  const theme = useMemo(\n    () =>\n      responsiveFontSizes(\n        createTheme(\n          {\n            ...THEME,\n            typography: {\n              ...THEME.typography,\n              fontFamily: FONTS[i18n.language] || DEFAULT_FONT,\n              fontWeightMedium: i18n.language === \"en\" ? 500 : 700,\n            },\n          },\n          LOCALES[i18n.language] || DEFAULT_LOCALE\n        ),\n        {\n          variants: [\"h1\", \"h2\", \"h3\", \"h4\", \"h5\", \"h6\"],\n        }\n      ),\n    [i18n.language]\n  );\n  return <MaterialThemeProvider theme={theme}>{children}</MaterialThemeProvider>;\n}\n"
  },
  {
    "path": "src/components/charts/simplePieChart.tsx",
    "content": "/* eslint-disable @typescript-eslint/indent */\nimport {\n  ResponsiveContainer,\n  PieChart,\n  Pie,\n  Cell,\n  LabelList,\n  LabelProps,\n  ResponsiveContainerProps,\n  PieProps,\n} from \"recharts\";\nimport { PolarViewBox } from \"recharts/src/util/types\";\nimport { useEffect, useMemo, useState } from \"react\";\n\nconst DEFAULT_COLORS = [\"#003f5c\", \"#7a5195\", \"#ef5675\", \"#ffa600\"];\n\nconst getDeltaAngle = (startAngle: number, endAngle: number) => {\n  const sign = Math.sign(endAngle - startAngle);\n  const deltaAngle = Math.min(Math.abs(endAngle - startAngle), 360);\n\n  return sign * deltaAngle;\n};\n\nconst RADIAN = Math.PI / 180;\n\nconst polarToCartesian = (cx: number, cy: number, radius: number, angle: number) => ({\n  x: cx + Math.cos(-RADIAN * angle) * radius,\n  y: cy + Math.sin(-RADIAN * angle) * radius,\n});\n\nconst renderCustomizedLabelFactory =\n  ({ lineHeight = 24, innerLabelFontSize = \"1rem\" }) =>\n  (props: LabelProps) => {\n    const { value } = props;\n    if (!value) {\n      return null;\n    }\n    const lines = value.toString().trim().split(\"\\n\");\n    const { cx, cy, outerRadius, startAngle, endAngle } = props.viewBox as Required<PolarViewBox>;\n    const labelAngle = startAngle + getDeltaAngle(startAngle, endAngle) / 2;\n    const { x, y } = polarToCartesian(cx, cy, outerRadius / 2, labelAngle);\n    const yStart = y - (lines.length - 1) * (lineHeight / 2);\n    return (\n      <g>\n        {lines.map((text, index) => (\n          <text\n            key={index}\n            x={x}\n            y={yStart + index * lineHeight}\n            stroke=\"#fff\"\n            strokeWidth=\"0.5\"\n            fill=\"#fff\"\n            fontSize={innerLabelFontSize}\n            textAnchor=\"middle\"\n            dominantBaseline=\"central\"\n          >\n            {text}\n          </text>\n        ))}\n      </g>\n    );\n  };\n\nexport type PieChartItem = {\n  value: number;\n  innerLabel?: string;\n  outerLabel?: string;\n};\n\nfunction defaultInnerLabel<T extends PieChartItem>(item: T) {\n  return item.innerLabel || \"\";\n}\nfunction defaultOuterLabel<T extends PieChartItem>(item: T) {\n  return item.outerLabel || \"\";\n}\nfunction labelLine<T extends PieChartItem>(item: T) {\n  if (!item.outerLabel) {\n    return null;\n  }\n  return Pie.renderLabelLineItem(true, item);\n}\n\nexport default function SimplePieChart<T extends PieChartItem>({\n  items,\n  innerLabel = defaultInnerLabel,\n  outerLabel = defaultOuterLabel,\n  outerLabelOffset = 0,\n  innerLabelLineHeight = 24,\n  startAngle = 0,\n  colors = DEFAULT_COLORS,\n  innerLabelFontSize = \"1rem\",\n  aspect = 1,\n  pieProps = {},\n  onSelect = undefined,\n  ...props\n}: {\n  items: T[];\n  innerLabel?: (item: T) => string;\n  outerLabel?: (item: T) => string;\n  outerLabelOffset?: number;\n  innerLabelLineHeight?: number;\n  startAngle?: number;\n  colors?: string[];\n  innerLabelFontSize?: string;\n  aspect?: number;\n  pieProps?: Partial<PieProps>;\n  onSelect?: ((item: T | null) => void) | undefined;\n} & Partial<ResponsiveContainerProps>) {\n  const [activeIndex, setActiveIndex] = useState(null as number | null);\n  useEffect(() => {\n    setActiveIndex(null);\n  }, [items]);\n  useEffect(() => {\n    if (!onSelect) {\n      return;\n    }\n    onSelect(activeIndex === null ? null : items[activeIndex]);\n  }, [onSelect, activeIndex, items]);\n  const cells = useMemo(\n    () =>\n      Array(items.length)\n        .fill(0)\n        .map((_, index) => (\n          <Cell\n            {...(activeIndex === index ? { className: \"selectable active\" } : {})}\n            fill={colors[index % colors.length]}\n            fillOpacity={1}\n            key={`cell-${index}`}\n            onClick={onSelect ? () => setActiveIndex(index === activeIndex ? null : index) : undefined}\n          />\n        )),\n    [items.length, colors, activeIndex, onSelect]\n  );\n  const renderCustomizedLabel = useMemo(\n    () => renderCustomizedLabelFactory({ lineHeight: innerLabelLineHeight, innerLabelFontSize }),\n    [innerLabelLineHeight, innerLabelFontSize]\n  );\n  const wrappedOuterLabel = useMemo(() => {\n    const ret = (item: T) => outerLabel(item);\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    (ret as any).offsetRadius = outerLabelOffset;\n    return ret;\n  }, [outerLabel, outerLabelOffset]);\n  return (\n    <ResponsiveContainer width=\"100%\" aspect={aspect} height=\"auto\" {...props}>\n      <PieChart>\n        <Pie\n          className={onSelect ? \"selectable\" + (activeIndex !== null ? \" with-active\" : \"\") : \"\"}\n          isAnimationActive={false}\n          data={items}\n          nameKey=\"outerLabel\"\n          dataKey=\"value\"\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          label={wrappedOuterLabel as (x: any) => string}\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          labelLine={labelLine as any}\n          startAngle={startAngle}\n          endAngle={startAngle + 360}\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          {...(pieProps as any)}\n        >\n          {cells}\n          <LabelList\n            valueAccessor={innerLabel}\n            // eslint-disable-next-line @typescript-eslint/no-explicit-any\n            dataKey={undefined as any}\n            position=\"inside\"\n            content={renderCustomizedLabel}\n            {...{ fill: \"#fff\" }}\n          />\n        </Pie>\n      </PieChart>\n    </ResponsiveContainer>\n  );\n}\n"
  },
  {
    "path": "src/components/contestTools/index.tsx",
    "content": "import React from \"react\";\n\nimport { ModelModeProvider } from \"../modeModel\";\nimport { ViewRoutes, SimpleRoutedSubViews, NavButtons, RouteDef } from \"../routing\";\nimport { ViewSwitch } from \"../routing/index\";\nimport MinMax from \"./minMax\";\n\nconst ROUTES = (\n  <ViewRoutes>\n    {[\n      <RouteDef key=\"\" path=\"min-max\" title=\"最低/最高点对局\">\n        <MinMax />\n      </RouteDef>,\n    ]}\n  </ViewRoutes>\n);\n\nexport default function Routes() {\n  return (\n    <SimpleRoutedSubViews>\n      {ROUTES}\n      <ModelModeProvider>\n        <NavButtons />\n        <ViewSwitch />\n      </ModelModeProvider>\n    </SimpleRoutedSubViews>\n  );\n}\n"
  },
  {
    "path": "src/components/contestTools/minMax.tsx",
    "content": "import { useState, useCallback } from \"react\";\nimport { DatePicker } from \"../form\";\nimport Conf from \"../../utils/conf\";\nimport Loading from \"../misc/loading\";\nimport dayjs from \"dayjs\";\nimport { ListingDataLoader } from \"../../data/source/records/loader\";\nimport { GameRecord, PlayerRecord } from \"../../data/types\";\nimport { generatePlayerPathById } from \"../gameRecords/routeUtils\";\n\nexport default function MinMax() {\n  const [dateStart, setDateStart] = useState(() => dayjs());\n  const [dateEnd, setDateEnd] = useState(() => dayjs());\n  const [loading, setLoading] = useState(false);\n  const [playerList, setPlayerList] = useState(\n    [] as {\n      id: string;\n      minGame: GameRecord;\n      maxGame: GameRecord;\n      minGamePlayer: PlayerRecord;\n      maxGamePlayer: PlayerRecord;\n      numGames: number;\n      totalPoints: number;\n    }[]\n  );\n  const search = useCallback(async () => {\n    setLoading(true);\n    let cur = dateStart.startOf(\"day\");\n    const end = dateEnd.endOf(\"day\");\n    const players = {} as {\n      [key: string]: typeof playerList[0];\n    };\n    while (cur.isBefore(end)) {\n      const loader = new ListingDataLoader(cur, null);\n      for (;;) {\n        const records = await loader.getNextChunk();\n        if (!records.length) {\n          break;\n        }\n        for (const rec of records) {\n          for (const player of rec.players) {\n            const id = player.accountId.toString();\n            if (!(id in players)) {\n              players[id] = {\n                id,\n                minGame: rec,\n                maxGame: rec,\n                minGamePlayer: player,\n                maxGamePlayer: player,\n                numGames: 1,\n                totalPoints: player.score,\n              };\n              continue;\n            }\n            const info = players[id];\n            info.numGames++;\n            info.totalPoints += player.score;\n            if (player.score > info.maxGamePlayer.score) {\n              info.maxGame = rec;\n              info.maxGamePlayer = player;\n            }\n            if (player.score < info.minGamePlayer.score) {\n              info.minGame = rec;\n              info.minGamePlayer = player;\n            }\n          }\n        }\n      }\n      cur = cur.add(1, \"day\");\n    }\n    setPlayerList(Object.values(players));\n    setLoading(false);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [setLoading, dateStart, dateEnd, setPlayerList, playerList]);\n  return (\n    <>\n      <DatePicker min={Conf.dateMin} date={dateStart} onChange={setDateStart} />\n      <DatePicker min={Conf.dateMin} date={dateEnd} onChange={setDateEnd} />\n      {loading ? (\n        <Loading />\n      ) : (\n        <>\n          <button type=\"button\" className=\"btn btn-primary mt-3\" onClick={search}>\n            查询\n          </button>\n          {playerList && playerList.length ? (\n            <table className=\"table table-responsive-xl table-striped table-hover mt-3\">\n              <thead>\n                <tr>\n                  <th>玩家</th>\n                  <th>最低分</th>\n                  <th>最低分比赛时间</th>\n                  <th>最高分</th>\n                  <th>最高分比赛时间</th>\n                  <th>平均点数</th>\n                </tr>\n              </thead>\n              <tbody>\n                {playerList.map((player) => (\n                  <tr key={player.id}>\n                    <td>\n                      <a href={generatePlayerPathById(player.id)}>{player.maxGamePlayer.nickname}</a>\n                    </td>\n                    <td>\n                      <a href={GameRecord.getRecordLink(player.minGame, player.minGamePlayer)}>\n                        {player.minGamePlayer.score}\n                      </a>\n                    </td>\n                    <td>{GameRecord.formatFullStartTime(player.minGame)}</td>\n                    <td>\n                      <a href={GameRecord.getRecordLink(player.maxGame, player.maxGamePlayer)}>\n                        {player.maxGamePlayer.score}\n                      </a>\n                    </td>\n                    <td>{GameRecord.formatFullStartTime(player.maxGame)}</td>\n                    <td>{Math.round(player.totalPoints / player.numGames)}</td>\n                  </tr>\n                ))}\n              </tbody>\n            </table>\n          ) : null}\n        </>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/form/checkboxGroup.tsx",
    "content": "import React, { useCallback, useMemo } from \"react\";\r\nimport { useTranslation } from \"react-i18next\";\r\nimport { useState } from \"react\";\r\nimport {\r\n  Checkbox,\r\n  FormControl,\r\n  FormControlLabel,\r\n  FormGroup,\r\n  FormLabel,\r\n  Radio,\r\n  RadioGroup as MuiRadioGroup,\r\n} from \"@mui/material\";\r\n\r\nexport interface CheckboxItem<T> {\r\n  key: string;\r\n  label: string;\r\n  value: T;\r\n}\r\n\r\ntype GroupParams<T> = {\r\n  type: \"checkbox\" | \"radio\";\r\n  items: CheckboxItem<T>[];\r\n  selectedItems: Iterable<string | CheckboxItem<T>> | null;\r\n  onChange: (selectedItems: CheckboxItem<T>[]) => void;\r\n  i18nNamespace?: string | string[] | undefined;\r\n  label?: string;\r\n};\r\nfunction InternalRadioGroup<T>({\r\n  items = [],\r\n  selectedItems = null,\r\n  // eslint-disable-next-line @typescript-eslint/no-empty-function\r\n  onChange = () => {},\r\n  i18nNamespace = undefined,\r\n}: GroupParams<T>) {\r\n  const { t } = useTranslation(i18nNamespace);\r\n  const selectedItemKey = useMemo(() => {\r\n    for (const item of selectedItems || []) {\r\n      if (typeof item === \"string\") {\r\n        return item;\r\n      } else {\r\n        return item.key;\r\n      }\r\n    }\r\n    return undefined;\r\n  }, [selectedItems]);\r\n  const handleChange = useCallback(\r\n    (event: React.ChangeEvent<HTMLInputElement>) => {\r\n      const value = (event.target as HTMLInputElement).value;\r\n      if (value === selectedItemKey) {\r\n        return;\r\n      }\r\n      const item = items.find((x) => x.key === value);\r\n      onChange(item ? [item] : []);\r\n    },\r\n    [items, onChange, selectedItemKey]\r\n  );\r\n  return (\r\n    <MuiRadioGroup value={selectedItemKey || null} onChange={handleChange} row>\r\n      {items.map((x) => (\r\n        <FormControlLabel\r\n          key={x.key}\r\n          value={x.key}\r\n          label={t(x.label || x.label).toString()}\r\n          control={<Radio size=\"small\" />}\r\n        />\r\n      ))}\r\n    </MuiRadioGroup>\r\n  );\r\n}\r\nfunction InternalCheckboxGroup<T>({\r\n  items = [],\r\n  selectedItems = null,\r\n  // eslint-disable-next-line @typescript-eslint/no-empty-function\r\n  onChange = () => {},\r\n  i18nNamespace = undefined,\r\n}: GroupParams<T>) {\r\n  const { t } = useTranslation(i18nNamespace);\r\n  const [highlightKey, setHighlightKey] = useState(null as string | null);\r\n  const selectedItemKeys = useMemo(() => {\r\n    const ret = new Set<string>();\r\n    for (const item of selectedItems || []) {\r\n      if (typeof item === \"string\") {\r\n        ret.add(item);\r\n      } else {\r\n        ret.add(item.key);\r\n      }\r\n    }\r\n    return ret;\r\n  }, [selectedItems]);\r\n  const handleClick = useCallback(\r\n    (e: React.MouseEvent) => {\r\n      e.preventDefault();\r\n      e.stopPropagation();\r\n\r\n      const key = (e.currentTarget as HTMLElement).dataset.value as string;\r\n\r\n      if (!(e.target as HTMLElement).classList.contains(\"MuiFormControlLabel-label\")) {\r\n        const newSet = new Set(selectedItemKeys);\r\n        if (selectedItemKeys.has(key)) {\r\n          if (selectedItemKeys.size === 1) {\r\n            return;\r\n          }\r\n          newSet.delete(key);\r\n        } else {\r\n          newSet.add(key);\r\n        }\r\n        onChange(items.filter((x) => newSet.has(x.key)));\r\n      } else {\r\n        if (selectedItemKeys.size === 1 && selectedItemKeys.has(key)) {\r\n          return;\r\n        }\r\n        onChange(items.filter((x) => key === x.key));\r\n      }\r\n    },\r\n    [items, onChange, selectedItemKeys]\r\n  );\r\n  const handleMouseOver = (e: React.MouseEvent) => {\r\n    if (!(e.target as HTMLElement).classList.contains(\"MuiFormControlLabel-label\")) {\r\n      return;\r\n    }\r\n    const key = (e.currentTarget as HTMLElement).dataset.value as string;\r\n    setHighlightKey(key);\r\n  };\r\n  const handleMouseOut = (e: React.MouseEvent) => {\r\n    if (!(e.target as HTMLElement).classList.contains(\"MuiFormControlLabel-label\")) {\r\n      return;\r\n    }\r\n    const key = (e.currentTarget as HTMLElement).dataset.value as string;\r\n    setHighlightKey((oldValue) => (oldValue === key ? null : oldValue));\r\n  };\r\n  return (\r\n    <FormGroup row>\r\n      {items.map((x) => (\r\n        <FormControlLabel\r\n          key={x.key}\r\n          value={x.key}\r\n          label={(t(x.label) || x.label).toString()}\r\n          data-value={x.key}\r\n          onClick={handleClick}\r\n          onMouseOver={handleMouseOver}\r\n          onMouseOut={handleMouseOut}\r\n          sx={{\r\n            opacity: !highlightKey || highlightKey === x.key ? 1 : 0.25,\r\n            transition: (theme) => theme.transitions.create(\"opacity\"),\r\n          }}\r\n          control={<Checkbox checked={selectedItemKeys.has(x.key)} size=\"small\" />}\r\n        />\r\n      ))}\r\n    </FormGroup>\r\n  );\r\n}\r\nexport function CheckboxGroup<T>(\r\n  props: GroupParams<T> = {\r\n    type: \"checkbox\",\r\n    items: [],\r\n    selectedItems: null,\r\n    // eslint-disable-next-line @typescript-eslint/no-empty-function\r\n    onChange: () => {},\r\n    i18nNamespace: undefined,\r\n    label: \"\",\r\n  }\r\n) {\r\n  const { t } = useTranslation(props.i18nNamespace);\r\n  return (\r\n    <FormControl component=\"fieldset\">\r\n      {props.label && <FormLabel component=\"legend\">{t(props.label)}</FormLabel>}\r\n      {props.type === \"checkbox\" ? <InternalCheckboxGroup {...props} /> : <InternalRadioGroup {...props} />}\r\n    </FormControl>\r\n  );\r\n}\r\n"
  },
  {
    "path": "src/components/form/datePicker.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-empty-function */\r\n\r\nimport dayjs from \"dayjs\";\r\nimport { useCallback } from \"react\";\r\n\r\nimport { DatePicker as MuiDatePicker, DatePickerProps } from \"@mui/lab\";\r\nimport { TextField } from \"@mui/material\";\r\nimport { useTranslation } from \"react-i18next\";\r\n\r\nexport function DatePicker({\r\n  date = dayjs() as dayjs.ConfigType,\r\n  onChange = (() => {}) as (_: dayjs.Dayjs) => void,\r\n  min = 0 as dayjs.ConfigType,\r\n  max = dayjs() as dayjs.ConfigType,\r\n  label = \"\",\r\n  fullWidth = false,\r\n  size = \"medium\" as \"medium\" | \"small\",\r\n  renderInput = null as null | DatePickerProps[\"renderInput\"],\r\n}) {\r\n  const handleChange = useCallback(\r\n    (value: dayjs.Dayjs | null) => onChange(value || dayjs(date).startOf(\"day\")),\r\n    [date, onChange]\r\n  );\r\n  const { t } = useTranslation(\"form\");\r\n  return (\r\n    <MuiDatePicker\r\n      disableCloseOnSelect={false}\r\n      label={t(label)}\r\n      value={dayjs(date)}\r\n      onChange={handleChange}\r\n      ignoreInvalidInputs\r\n      toolbarFormat=\" \"\r\n      toolbarTitle=\"\"\r\n      mask=\"____-__-__\"\r\n      renderInput={renderInput || ((params: any) => <TextField fullWidth={fullWidth} size={size} {...params} />)} // eslint-disable-line @typescript-eslint/no-explicit-any\r\n      minDate={dayjs(min)}\r\n      maxDate={dayjs(max)}\r\n    />\r\n  );\r\n}\r\n"
  },
  {
    "path": "src/components/form/index.tsx",
    "content": "export * from \"./checkboxGroup\";\r\nexport * from \"./datePicker\";\r\n"
  },
  {
    "path": "src/components/gameRecords/columns.tsx",
    "content": "import React from \"react\";\nimport { TableCellProps } from \"react-virtualized\";\nimport { Column } from \"react-virtualized/dist/es/Table\";\nimport dayjs from \"dayjs\";\nimport { GameRecord, modeLabel } from \"../../data/types\";\nimport { Player } from \"./player\";\nimport Conf from \"../../utils/conf\";\nimport { Trans } from \"react-i18next\";\nimport i18n from \"../../i18n\";\nimport { Box, Tooltip, TypographyProps, useTheme } from \"@mui/material\";\n\nconst formatTime = (x: number) => (x ? dayjs.unix(x).format(\"HH:mm\") : null);\ntype ActivePlayerId = number | string | ((x: GameRecord) => number | string);\ntype PlayersProps = {\n  game: GameRecord;\n  activePlayerId?: ActivePlayerId;\n  language?: string;\n  activeProps?: TypographyProps;\n  inactiveProps?: TypographyProps;\n  alwaysShowDetailLink?: boolean;\n  maskedGameLink?: boolean;\n};\nconst Players = React.memo(\n  ({ game, activePlayerId, alwaysShowDetailLink, activeProps, inactiveProps, maskedGameLink }: PlayersProps) => {\n    const theme = useTheme();\n    if (typeof activePlayerId === \"function\") {\n      activePlayerId = activePlayerId(game);\n    }\n    if (typeof activePlayerId !== \"string\") {\n      activePlayerId = activePlayerId?.toString() || \"\";\n    }\n    if (activePlayerId) {\n      inactiveProps = inactiveProps || {\n        color: theme.palette.grey[500],\n      };\n    }\n    return (\n      <Box display=\"grid\" gridTemplateColumns={[\"1fr\", null, \"1fr 1fr\"]}>\n        {game.players.map((x) => (\n          <Player\n            key={x.accountId}\n            game={game}\n            player={x}\n            maskedGameLink={maskedGameLink}\n            {...(x.accountId.toString() === activePlayerId\n              ? { hideDetailIcon: !alwaysShowDetailLink, showAiReviewIcon: !alwaysShowDetailLink, ...activeProps }\n              : inactiveProps)}\n          />\n        ))}\n      </Box>\n    );\n  }\n);\nconst cellFormatTime = ({ cellData }: TableCellProps) => formatTime(cellData);\nconst cellFormatFullTime = ({ rowData }: TableCellProps) =>\n  rowData.loading ? \"\" : GameRecord.formatFullStartTime(rowData);\nconst cellFormatFullTimeMobile = ({ rowData }: TableCellProps) =>\n  rowData.loading ? (\n    \"\"\n  ) : (\n    <Tooltip title={GameRecord.formatFullStartTime(rowData)} placement=\"left\" arrow>\n      <Box>\n        <Box>{GameRecord.formatStartDate(rowData)}</Box>\n        <Box>{formatTime(rowData.startTime)}</Box>\n      </Box>\n    </Tooltip>\n  );\nconst cellFormatRank = ({ rowData, columnData }: TableCellProps) =>\n  !rowData || rowData.loading || !columnData.activePlayerId ? (\n    \"\"\n  ) : (\n    <Box fontWeight=\"bold\" color={GameRecord.getPlayerRankColor(rowData, columnData.activePlayerId)}>\n      {GameRecord.getPlayerRankLabel(rowData, columnData.activePlayerId)\n        .slice(0, 1)\n        .replace(/[0-9]/g, (s) => String.fromCodePoint(s.charCodeAt(0) + 0xfee0))}\n    </Box>\n  );\nconst cellFormatGameMode = ({ cellData }: TableCellProps) => (cellData ? modeLabel(parseInt(cellData)) : \"\");\n\ntype TableColumnDefKey = {\n  key?: string;\n};\nexport type TableColumn = React.FunctionComponentElement<Column> | false | undefined | null;\nexport type TableColumnDef = TableColumnDefKey & (() => TableColumn);\n\n// eslint-disable-next-line @typescript-eslint/ban-types\nexport function makeColumn<T extends unknown[]>(builder: (...args: T) => TableColumn): (...args: T) => TableColumnDef {\n  const key = Math.random().toString();\n  const newBuilder = (...args: T) => {\n    const outer = () => {\n      const ret = builder(...args);\n      if (ret) {\n        return React.cloneElement(ret, { key });\n      }\n      return ret;\n    };\n    outer.key = key + args.join(\"-\");\n    return outer;\n  };\n  return newBuilder;\n}\n\nexport const COLUMN_GAMEMODE = makeColumn(\n  () =>\n    Conf.table.showGameMode && (\n      <Column\n        dataKey=\"modeId\"\n        label={<Trans>等级</Trans>}\n        cellRenderer={cellFormatGameMode}\n        width={40}\n        columnData={{\n          mobileProps: {\n            label: \"\",\n            width: 20,\n            style: {\n              writingMode: \"vertical-lr\",\n              padding: \"0.5rem 0\",\n            },\n          },\n        }}\n      />\n    )\n)();\n\nexport const COLUMN_RANK = makeColumn((activePlayerId: number | string) => (\n  <Column\n    dataKey=\"modeId\"\n    label={<Trans>顺位</Trans>}\n    columnData={{\n      activePlayerId,\n      mobileProps: {\n        label: \"\",\n        width: 20,\n        style: {\n          writingMode: \"vertical-lr\",\n          padding: \"0.5rem 0\",\n        },\n      },\n    }}\n    cellRenderer={cellFormatRank}\n    width={40}\n  />\n));\n\nexport const COLUMN_PLAYERS = makeColumn((props: Partial<Omit<PlayersProps, \"game\">> = {}) => (\n  <Column\n    dataKey=\"players\"\n    label={<Trans>玩家</Trans>}\n    cellRenderer={({ rowData }: TableCellProps) =>\n      rowData && rowData.players ? <Players game={rowData} language={i18n.language} {...props} /> : null\n    }\n    width={120}\n    flexGrow={1}\n  />\n));\n\nexport const COLUMN_STARTTIME = makeColumn(() => (\n  <Column\n    dataKey=\"startTime\"\n    label={<Trans>开始</Trans>}\n    cellRenderer={cellFormatTime}\n    width={50}\n    className=\"text-right\"\n    headerClassName=\"text-right\"\n    columnData={{\n      mobileProps: {\n        width: 40,\n      },\n    }}\n  />\n))();\n\nexport const COLUMN_ENDTIME = makeColumn(() => (\n  <Column\n    dataKey=\"endTime\"\n    label={<Trans>结束</Trans>}\n    cellRenderer={cellFormatTime}\n    width={50}\n    headerClassName=\"text-right\"\n    className=\"text-right\"\n    columnData={{\n      mobileProps: {\n        width: 40,\n      },\n    }}\n  />\n))();\n\nexport const COLUMN_FULLTIME = makeColumn(() => (\n  <Column\n    dataKey=\"startTime\"\n    label={<Trans>时间</Trans>}\n    cellRenderer={cellFormatFullTime}\n    width={150}\n    className=\"text-right\"\n    headerClassName=\"text-right\"\n    columnData={{\n      mobileProps: {\n        width: 45,\n        cellRenderer: cellFormatFullTimeMobile,\n      },\n    }}\n  />\n))();\n"
  },
  {
    "path": "src/components/gameRecords/dataAdapterProvider.tsx",
    "content": "import { useState, useEffect, useMemo, useCallback, useContext } from \"react\";\nimport React, { ReactChild } from \"react\";\nimport dayjs from \"dayjs\";\n\nimport { DataProvider, DUMMY_DATA_PROVIDER, FilterPredicate } from \"../../data/source/records/provider\";\nimport { useModel, Model } from \"./model\";\nimport { Metadata, GameRecord, Level } from \"../../data/types\";\nimport { generatePath } from \"./routeUtils\";\nimport { networkError } from \"../../utils/notify\";\nimport { ApiError } from \"../../data/source/api\";\nimport { useExtraFilterPredicate } from \"./extraFilterPredicate\";\nimport Conf from \"../../utils/conf\";\n\ninterface ItemLoadingPlaceholder {\n  loading: boolean;\n}\n\nconst loadingPlaceholder = { loading: true };\n\nexport interface IDataAdapter {\n  getCount(): number;\n  hasCount(): boolean;\n  getUnfilteredCount(): number;\n  getMetadata<T extends Metadata>(): T | null;\n  getItem(index: number): GameRecord | ItemLoadingPlaceholder;\n  isItemLoaded(index: number): boolean;\n}\n\nclass DummyDataAdapter implements IDataAdapter {\n  getCount(): number {\n    return 0;\n  }\n  hasCount(): boolean {\n    return true;\n  }\n  getUnfilteredCount(): number {\n    return 0;\n  }\n  getMetadata<T extends Metadata>(): T | null {\n    return null;\n  }\n  getItem(): GameRecord | ItemLoadingPlaceholder {\n    return loadingPlaceholder;\n  }\n  isItemLoaded(): boolean {\n    return false;\n  }\n}\n\nexport const DUMMY_DATA_ADAPTER = new DummyDataAdapter() as IDataAdapter;\n\n// eslint-disable-next-line @typescript-eslint/no-empty-function\nconst noop = () => {};\n\nclass DataAdapter implements IDataAdapter {\n  _provider: DataProvider;\n  _onDataUpdate: (error: Error | ApiError | false) => void;\n  _triggeredRequest: boolean;\n\n  constructor(provider: DataProvider, onDataUpdate = noop) {\n    this._provider = provider;\n    this._onDataUpdate = onDataUpdate;\n    this._triggeredRequest = false;\n  }\n  _installHook<T>(promise: Promise<T>) {\n    if (this._triggeredRequest) {\n      return;\n    }\n    this._triggeredRequest = true;\n    promise.then(() => this._callHook(false)).catch((reason) => this._callHook(reason));\n  }\n  _callHook(error: Error | ApiError | false) {\n    setTimeout(() => {\n      this._onDataUpdate(error);\n      this._onDataUpdate = noop;\n    }, 0);\n  }\n  getCount(): number {\n    try {\n      const maybeCount = this._provider.getCountMaybeSync();\n      if (maybeCount instanceof Promise) {\n        this._installHook(maybeCount);\n        return 0;\n      }\n      return maybeCount;\n    } catch (e) {\n      this._callHook(e);\n      return 0;\n    }\n  }\n  hasCount(): boolean {\n    try {\n      return !(this._provider.getCountMaybeSync() instanceof Promise);\n    } catch (e) {\n      this._callHook(e);\n      return false;\n    }\n  }\n  getUnfilteredCount(): number {\n    try {\n      return this._provider.getUnfilteredCountSync() || 0;\n    } catch (e) {\n      this._callHook(e);\n      return 0;\n    }\n  }\n  getMetadata<T extends Metadata>(): T | null {\n    try {\n      return this._provider.getMetadataSync() as T | null;\n    } catch (e) {\n      this._callHook(e);\n      return null;\n    }\n  }\n  getItem(index: number): GameRecord | ItemLoadingPlaceholder {\n    if (index >= this.getCount()) {\n      return loadingPlaceholder;\n    }\n    if (this._provider.isItemLoaded(index)) {\n      return this._provider.getItem(index) as GameRecord;\n    }\n    if (!this._triggeredRequest) {\n      this._installHook(this._provider.getItem(index) as Promise<GameRecord>);\n    }\n    return loadingPlaceholder;\n  }\n  isItemLoaded(index: number): boolean {\n    if (index < 0) {\n      return false;\n    }\n    return this._provider.isItemLoaded(index);\n  }\n  setUpdateHook(hook: () => void) {\n    this._onDataUpdate = hook;\n  }\n  cancelUpdateHook() {\n    this._onDataUpdate = noop;\n  }\n}\n\nconst DataAdapterContext = React.createContext(DUMMY_DATA_ADAPTER);\n\nexport const useDataAdapter = () => useContext(DataAdapterContext);\nexport const DataAdapterConsumer = DataAdapterContext.Consumer;\n\nfunction getProviderKey(model: Model): string {\n  if (model.type === undefined) {\n    return `${dayjs(model.date || dayjs())\n      .startOf(\"day\")\n      .valueOf()\n      .toString()}_${model.selectedMode}`;\n  } else if (model.type === \"player\") {\n    return generatePath(model);\n  }\n  throw new Error(\"Unknown model type\");\n}\n\nfunction createProvider(model: Model): DataProvider {\n  if (model.type === undefined) {\n    return DataProvider.createListing(model.date || dayjs().startOf(\"day\"), model.selectedMode || null);\n  }\n  if (model.type === \"player\") {\n    return DataProvider.createPlayer(model.playerId, model.startDate, model.endDate, model.limit, model.selectedModes);\n  }\n  throw new Error(\"Not implemented\");\n}\n\nfunction usePredicate(model: Model): FilterPredicate {\n  const extraPredicate = useExtraFilterPredicate();\n  let memoFunc: () => FilterPredicate = () => null;\n  const searchText = (model.searchText || \"\").trim().toLowerCase() || \"\";\n  const needPredicate =\n    searchText || (\"rank\" in model && model.rank) || (\"kontenOnly\" in model && model.kontenOnly) || extraPredicate;\n  memoFunc = () =>\n    needPredicate\n      ? (game) => {\n          if (!game.players.some((player) => player.nickname.toLowerCase().indexOf(searchText) > -1)) {\n            return false;\n          }\n          if (\"rank\" in model) {\n            if (model.rank && GameRecord.getRankIndexByPlayer(game, model.playerId) !== model.rank - 1) {\n              return false;\n            }\n            if (model.kontenOnly && !game.players.every((x) => new Level(x.level).isKonten())) {\n              return false;\n            }\n          }\n          if (extraPredicate && !extraPredicate(game)) {\n            return false;\n          }\n          return true;\n        }\n      : null;\n  const memoDeps = [\n    (model.type === undefined && model.selectedMode) || null,\n    searchText,\n    \"rank\" in model && model.rank,\n    \"kontenOnly\" in model && model.kontenOnly,\n    extraPredicate,\n  ];\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  return useMemo(memoFunc, memoDeps);\n}\n\nfunction useDataAdapterCommon(\n  dataProvider: DataProvider,\n  onError: (error: Error | ApiError | false) => void,\n  deps: React.DependencyList\n) {\n  const [dataAdapter, setDataAdapter] = useState(() => DUMMY_DATA_ADAPTER);\n  const onErrorOnce = useMemo(() => {\n    let called = false;\n    return (error: Error | ApiError | false) => {\n      if (!called) {\n        called = true;\n        onError(error);\n      }\n    };\n  }, [onError]);\n  const refreshDataAdapter = useCallback(\n    (error?: Error | ApiError | false) => {\n      if (error) {\n        onErrorOnce(error);\n        return;\n      }\n      const adapter = new DataAdapter(dataProvider);\n      setDataAdapter(adapter);\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [dataProvider, onErrorOnce, ...deps]\n  );\n  useEffect(refreshDataAdapter, [refreshDataAdapter]);\n  useEffect(() => {\n    const adapter = dataAdapter;\n    if (adapter instanceof DataAdapter) {\n      return () => adapter.cancelUpdateHook();\n    }\n  }, [dataAdapter]);\n  useEffect(() => {\n    const adapter = dataAdapter;\n    if (adapter instanceof DataAdapter) {\n      adapter.setUpdateHook(refreshDataAdapter);\n    }\n  }, [dataAdapter, refreshDataAdapter]);\n  useEffect(() => {\n    try {\n      // Preload metadata\n      const result = dataProvider.getCountMaybeSync();\n      if (result instanceof Promise) {\n        result.catch((e) => onErrorOnce(e));\n      }\n    } catch (e) {\n      onErrorOnce(e);\n    }\n  }, [dataProvider, onErrorOnce]);\n  return {\n    dataAdapter,\n  };\n}\n\nexport function DataAdapterProvider({ children }: { children: ReactChild | ReactChild[] }) {\n  const [model, updateModel] = useModel();\n  const [dataProviders] = useState(() => new Map<string, DataProvider>());\n  const searchPredicate = usePredicate(model);\n  const dataProviderVanilla = useMemo(() => {\n    if (model.type === undefined && !model.selectedMode && Conf.availableModes.length > 1) {\n      return DUMMY_DATA_PROVIDER;\n    }\n    const key = getProviderKey(model);\n    if (!dataProviders.has(key)) {\n      dataProviders.set(key, createProvider(model));\n    }\n    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n    return dataProviders.get(key)!;\n  }, [model, dataProviders]);\n  useEffect(() => dataProviderVanilla.setFilterPredicate(searchPredicate), [dataProviderVanilla, searchPredicate]);\n  const dataProvider = useMemo(() => {\n    if (!searchPredicate) {\n      return dataProviderVanilla;\n    }\n    if (model.type !== \"player\") {\n      return dataProviderVanilla;\n    }\n    if (!model.selectedModes?.length) {\n      return dataProviderVanilla;\n    }\n    return DataProvider.createFilteredPlayer(\n      model.playerId,\n      async () => {\n        await dataProviderVanilla.getCount();\n        const ret = [];\n        for (let i = 0; ; i++) {\n          const item = await dataProviderVanilla.getItem(i);\n          if (!item) {\n            break;\n          }\n          ret.push(item);\n        }\n        return ret;\n      },\n      model.selectedModes\n    );\n  }, [searchPredicate, model, dataProviderVanilla]);\n  const onError = useCallback(\n    (e) => {\n      if (e && \"status\" in e && e.status === 404) {\n        if (model.type === \"player\") {\n          if (model.startDate || model.endDate || model.limit) {\n            if (Object.keys(dataProviders).length > 1) {\n              // User changing settings, allow to continue\n              networkError();\n              return;\n            }\n            updateModel({\n              type: \"player\",\n              playerId: model.playerId,\n              limit: null,\n              startDate: null,\n              endDate: null,\n            });\n            return;\n          } else if (model.selectedModes.length) {\n            updateModel({\n              type: \"player\",\n              playerId: model.playerId,\n              selectedModes: [],\n              limit: null,\n              startDate: null,\n              endDate: null,\n            });\n            return;\n          }\n          updateModel({ type: undefined, selectedMode: null });\n          return;\n        }\n      }\n      networkError();\n      // updateModel(Model.removeExtraParams(model));\n    },\n    [model, updateModel, dataProviders]\n  );\n  const { dataAdapter } = useDataAdapterCommon(dataProvider, onError, [model, searchPredicate]);\n  return <DataAdapterContext.Provider value={dataAdapter}>{children}</DataAdapterContext.Provider>;\n}\n\nexport function DataAdapterProviderCustom({\n  provider,\n  children,\n}: {\n  provider: DataProvider;\n  children: ReactChild | ReactChild[];\n}) {\n  const { dataAdapter } = useDataAdapterCommon(provider, noop, []);\n  return <DataAdapterContext.Provider value={dataAdapter}>{children}</DataAdapterContext.Provider>;\n}\n"
  },
  {
    "path": "src/components/gameRecords/extraFilterPredicate.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-empty-function */\nimport React, { useContext, useMemo, useState } from \"react\";\nimport { FilterPredicate } from \"../../data/source/records/provider\";\n\nconst Context = React.createContext({\n  extraFilterPredicate: null as FilterPredicate,\n  setExtraFilterPredicate: (() => {}) as (predicate: FilterPredicate) => void,\n});\n\nexport const useExtraFilterPredicate = () => useContext(Context).extraFilterPredicate;\n\nexport const useSetExtraFilterPredicate = () => useContext(Context).setExtraFilterPredicate;\n\nexport function ExtraFilterPredicateProvider({ children }: { children: React.ReactNode }) {\n  const [extraFilterPredicate, setExtraFilterPredicate] = useState(() => null as FilterPredicate);\n  const value = useMemo(() => ({ extraFilterPredicate, setExtraFilterPredicate }), [extraFilterPredicate]);\n  return <Context.Provider value={value}>{children}</Context.Provider>;\n}\n"
  },
  {
    "path": "src/components/gameRecords/filterPanel.tsx",
    "content": "import { useCallback } from \"react\";\r\n\r\nimport { useTranslation } from \"react-i18next\";\r\n\r\nimport { DatePicker } from \"../form\";\r\nimport { useModel } from \"./model\";\r\nimport dayjs from \"dayjs\";\r\nimport { ModeSelector } from \"./modeSelector\";\r\nimport Conf from \"../../utils/conf\";\r\nimport { GameMode } from \"../../data/types\";\r\nimport { Box } from \"@mui/material\";\r\n\r\nconst DEFAULT_DATE = dayjs().startOf(\"day\");\r\n\r\nexport function FilterPanel() {\r\n  const { t } = useTranslation();\r\n  const [model, updateModel] = useModel();\r\n  const setMode = useCallback((mode: GameMode[]) => updateModel({ selectedMode: mode[0] || null }), [updateModel]);\r\n  const setDate = useCallback(\r\n    (date: dayjs.ConfigType) => updateModel({ date: date ? dayjs(date).startOf(\"day\") : date }),\r\n    [updateModel]\r\n  );\r\n  if (model.type !== undefined) {\r\n    return null;\r\n  }\r\n  return (\r\n    <>\r\n      <DatePicker fullWidth label={t(\"日期\")} min={Conf.dateMin} date={model.date || DEFAULT_DATE} onChange={setDate} />\r\n      {Conf.availableModes.length > 1 && (\r\n        <Box mt={1}>\r\n          <ModeSelector mode={model.selectedMode ? [model.selectedMode] : []} onChange={setMode} />\r\n        </Box>\r\n      )}\r\n    </>\r\n  );\r\n}\r\n"
  },
  {
    "path": "src/components/gameRecords/gameLinkActions/dialog.tsx",
    "content": "import { ContentCopy, PieChartRounded, ReadMore, Replay, SvgIconComponent } from \"@mui/icons-material\";\nimport { Avatar, Dialog, List, ListItem, ListItemAvatar, ListItemButton, ListItemText } from \"@mui/material\";\nimport copy from \"copy-to-clipboard\";\nimport { useSnackbar } from \"notistack\";\nimport React, { AnchorHTMLAttributes, useCallback, useEffect, useReducer } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { GameRecord, PlayerRecord } from \"../../../data/types\";\nimport Conf from \"../../../utils/conf\";\nimport { generatePlayerPathById } from \"../routeUtils\";\n\nconst Action = ({\n  Icon,\n  text,\n  ...props\n}: {\n  Icon: SvgIconComponent;\n  text: string;\n} & Parameters<typeof ListItemButton>[0] &\n  AnchorHTMLAttributes<HTMLAnchorElement>) => (\n  <ListItem disableGutters>\n    <ListItemButton {...props}>\n      <ListItemAvatar>\n        <Avatar>\n          <Icon fontSize=\"inherit\" />\n        </Avatar>\n      </ListItemAvatar>\n      <ListItemText primary={text} primaryTypographyProps={{ variant: \"body2\", sx: { pr: 1 } }} />\n    </ListItemButton>\n  </ListItem>\n);\nexport const ActionsDialog = React.memo(\n  ({ player, game, onClose }: { player?: PlayerRecord; game?: GameRecord; onClose: () => void }) => {\n    const { t } = useTranslation();\n    const { enqueueSnackbar } = useSnackbar();\n    const [savedGame, updateGame] = useReducer(\n      (prev: GameRecord | undefined, cur: GameRecord | undefined) => cur || prev,\n      game,\n      (game) => game\n    );\n    useEffect(() => {\n      updateGame(game);\n    }, [game]);\n    const isMasked = !(game || savedGame)?.uuid || (game || savedGame)?._masked;\n    const gameLink = !game ? \"#\" : (isMasked ? GameRecord.getMaskedRecordLink : GameRecord.getRecordLink)(game, player);\n    const copyLink = useCallback(() => {\n      if (!gameLink) {\n        return;\n      }\n      copy(gameLink);\n      enqueueSnackbar(t(\"链接复制成功\"), { variant: \"success\", autoHideDuration: 2000 });\n    }, [gameLink, enqueueSnackbar, t]);\n    return (\n      <Dialog open={!!game} onClose={onClose} onClick={onClose} maxWidth=\"xs\">\n        <List>\n          <Action Icon={Replay} text={t(\"查看牌谱\")} href={gameLink} target=\"_blank\" />\n          {!isMasked && <Action Icon={ContentCopy} onClick={copyLink} text={t(\"复制链接\")} />}\n          <Action\n            Icon={ReadMore}\n            text={t(\"玩家详细\")}\n            href={player?.accountId ? generatePlayerPathById(player.accountId) : \"#\"}\n          />\n          {Conf.features.aiReview && !isMasked && (\n            <Action\n              Icon={PieChartRounded}\n              text={t(\"AI 检讨\")}\n              target=\"_blank\"\n              href={\n                game && player\n                  ? `${t(\"https://mjai.ekyu.moe/zh-cn.html\")}?url=${encodeURIComponent(\n                      GameRecord.getRecordLink(game, player)\n                    )}`\n                  : \"#\"\n              }\n            />\n          )}\n        </List>\n      </Dialog>\n    );\n  }\n);\n\nexport default ActionsDialog;\n"
  },
  {
    "path": "src/components/gameRecords/gameLinkActions/index.tsx",
    "content": "import React, { ReactNode, useCallback, useMemo, useState } from \"react\";\nimport { GameRecord, PlayerRecord } from \"../../../data/types\";\nimport Loadable from \"../../misc/customizedLoadable\";\n\nconst ActionsDialog = Loadable({\n  loader: () => import(/* webpackMode: \"lazy\" */ /* webpackFetchPriority: \"low\" */ \"./dialog\"),\n  loading: () => <></>,\n});\n\nconst Context = React.createContext<{ open: (player: PlayerRecord, game: GameRecord) => void }>({\n  open: () => {\n    /* Placeholder */\n  },\n});\n\nexport const useGameLinkActions = () => React.useContext(Context);\n\nconst GameLinkActionsProvider = ({ children }: { children: ReactNode }) => {\n  const [info, setInfo] = useState<{ player: PlayerRecord; game: GameRecord } | null>(null);\n  const { player, game } = info || {};\n  const open = useCallback(\n    (player: PlayerRecord, game: GameRecord) => {\n      setInfo({ player, game });\n    },\n    [setInfo]\n  );\n  const close = useCallback(() => setInfo(null), [setInfo]);\n  const value = useMemo(() => ({ open }), [open]);\n  return (\n    <Context.Provider value={value}>\n      <ActionsDialog player={player} game={game} onClose={close} />\n      {children}\n    </Context.Provider>\n  );\n};\nexport default GameLinkActionsProvider;\n"
  },
  {
    "path": "src/components/gameRecords/home.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\n\nimport { FilterPanel } from \"./filterPanel\";\nimport { PlayerSearch } from \"./playerSearch\";\nimport { Box, Typography } from \"@mui/material\";\nimport Loadable from \"../misc/customizedLoadable\";\nimport { useModel } from \"./model\";\nimport Conf from \"../../utils/conf\";\n\nconst GameRecordTableHomeView = Loadable({\n  loader: () => import(\"./tableViews\").then((x) => ({ default: x.GameRecordTableHomeView })),\n});\n\nexport default function Home() {\n  const { t } = useTranslation(\"form\");\n  const [model] = useModel();\n  return (\n    <>\n      <Typography variant=\"h4\" mb={3} textAlign=\"center\">\n        {t(\"查找玩家\")}\n      </Typography>\n      <Box mb={5}>\n        <PlayerSearch />\n      </Box>\n      <Typography variant=\"h4\" mb={3} textAlign=\"center\">\n        {t(\"对局浏览\")}\n      </Typography>\n      <Box mb={5}>\n        <FilterPanel />\n      </Box>\n      {(model.type === undefined && model.selectedMode) || Conf.availableModes.length <= 1 ? (\n        <GameRecordTableHomeView />\n      ) : null}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/gameRecords/index.tsx",
    "content": "import { ModelProvider } from \"./model\";\r\nimport Loadable from \"../misc/customizedLoadable\";\r\n\r\nconst Routes = Loadable({\r\n  loader: () => import(\"./routes\"),\r\n});\r\n\r\nexport default function GameRecords() {\r\n  return (\r\n    <ModelProvider>\r\n      <Routes />\r\n    </ModelProvider>\r\n  );\r\n}\r\n"
  },
  {
    "path": "src/components/gameRecords/modeSelector.tsx",
    "content": "import React, { useMemo } from \"react\";\n\nimport { CheckboxGroup } from \"../form\";\nimport { GameMode, modeLabelNonTranslated } from \"../../data/types\";\nimport Conf from \"../../utils/conf\";\nimport { useTranslation } from \"react-i18next\";\n\nexport function ModeSelector({\n  mode,\n  onChange,\n  label = \"\",\n  type = \"radio\",\n  availableModes = Conf.availableModes,\n  i18nNamespace = undefined,\n}: {\n  mode: GameMode[];\n  onChange: (x: GameMode[]) => void;\n  label?: string;\n  type?: \"checkbox\" | \"radio\";\n  availableModes?: GameMode[];\n  i18nNamespace?: string | string[] | undefined;\n}) {\n  useTranslation();\n  const items = useMemo(\n    () =>\n      availableModes.map((x) => ({\n        key: String(x),\n        label: modeLabelNonTranslated(x),\n        value: x,\n      })),\n    [availableModes]\n  );\n  if (items.length < 1) {\n    return null;\n  }\n  return (\n    <CheckboxGroup\n      type={type}\n      label={label}\n      items={items}\n      selectedItems={mode.map((x) => x.toString())}\n      onChange={(newItems) => onChange(newItems.map((x) => x.value))}\n      i18nNamespace={i18nNamespace}\n    />\n  );\n}\n"
  },
  {
    "path": "src/components/gameRecords/model.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-empty-function */\r\nimport dayjs from \"dayjs\";\r\nimport React, { useReducer, useContext, ReactChild, useMemo } from \"react\";\r\nimport { useHistory } from \"react-router\";\r\nimport { useEventCallback } from \"../../utils\";\r\nimport { generatePath } from \"./routeUtils\";\r\nimport { GameMode } from \"../../data/types\";\r\n\r\nexport interface ListingModel {\r\n  type: undefined;\r\n  date: dayjs.ConfigType | null;\r\n  selectedMode: GameMode | null;\r\n  searchText: string;\r\n}\r\nexport interface PlayerModel {\r\n  type: \"player\";\r\n  playerId: string;\r\n  startDate: dayjs.ConfigType | null;\r\n  endDate: dayjs.ConfigType | null;\r\n  selectedModes: GameMode[];\r\n  searchText: string;\r\n  rank: number | null;\r\n  kontenOnly: boolean;\r\n  limit: number | null;\r\n}\r\nexport type Model = ListingModel | PlayerModel;\r\n// eslint-disable-next-line @typescript-eslint/no-redeclare\r\nexport const Model = Object.freeze({\r\n  removeExtraParams(model: Model): Model {\r\n    if (model.type === \"player\") {\r\n      return {\r\n        type: \"player\",\r\n        playerId: model.playerId,\r\n        selectedModes: [],\r\n        startDate: null,\r\n        endDate: null,\r\n        searchText: \"\",\r\n        rank: null,\r\n        kontenOnly: false,\r\n        limit: null,\r\n      };\r\n    }\r\n    return {\r\n      type: undefined,\r\n      searchText: \"\",\r\n      selectedMode: null,\r\n      date: null,\r\n    };\r\n  },\r\n  hasAdvancedParams(model: Model): boolean {\r\n    return Boolean(\"rank\" in model && (model.searchText || model.rank || model.kontenOnly));\r\n  },\r\n});\r\ntype ModelUpdate = Partial<ListingModel> | ({ type: \"player\" } & Partial<PlayerModel>);\r\ntype DispatchModelUpdate = (props: ModelUpdate) => void;\r\n\r\nconst DEFAULT_MODEL: ListingModel = { type: undefined, date: null, selectedMode: null, searchText: \"\" };\r\nconst ModelContext = React.createContext<[Readonly<Model>, DispatchModelUpdate]>([DEFAULT_MODEL, () => {}]);\r\nexport const useModel = () => useContext(ModelContext);\r\n\r\nfunction normalizeUpdate(newProps: ModelUpdate): ModelUpdate {\r\n  if (newProps.type === undefined) {\r\n    if (newProps.date) {\r\n      const isDateOnly = typeof newProps.date === \"string\" && !/^\\d{6,}$/.test(newProps.date);\r\n      newProps.date = isDateOnly ? dayjs(newProps.date).startOf(\"date\").valueOf() : dayjs(newProps.date).valueOf();\r\n    }\r\n  }\r\n  for (const key of Object.keys(newProps)) {\r\n    if (key !== \"type\" && newProps[key as keyof typeof newProps] === undefined) {\r\n      delete newProps[key as keyof typeof newProps];\r\n    }\r\n  }\r\n  return newProps;\r\n}\r\nfunction isSameModel(a: Model, b: Model): boolean {\r\n  return generatePath(a) === generatePath(b);\r\n}\r\n\r\nconst OnRouteModelUpdatedContext = React.createContext((() => {}) as (model: Model) => void);\r\nexport const useOnRouteModelUpdated = () => useContext(OnRouteModelUpdatedContext);\r\n\r\nexport function ModelProvider({ children }: { children: ReactChild | ReactChild[] }) {\r\n  const history = useHistory();\r\n  const [model, setModel] = useReducer(\r\n    (oldModel: Model, newModel: Model): Readonly<Model> => {\r\n      if (isSameModel(oldModel, newModel)) {\r\n        return oldModel;\r\n      }\r\n      return Object.freeze(newModel);\r\n    },\r\n    undefined,\r\n    () => Object.freeze(DEFAULT_MODEL as Model)\r\n  );\r\n  const dispatchModelUpdate = useEventCallback(\r\n    (newProps: ModelUpdate) => {\r\n      const newModel = {\r\n        ...((model.type === newProps.type ? model : {}) as Model),\r\n        ...(normalizeUpdate(newProps) as Model),\r\n      };\r\n      if (newModel.type === \"player\" && (!newModel.selectedModes || !newModel.selectedModes.length)) {\r\n        if (\r\n          model.type === undefined &&\r\n          model.selectedMode &&\r\n          (!newModel.selectedModes || !newModel.selectedModes.length)\r\n        ) {\r\n          newModel.selectedModes = [model.selectedMode];\r\n        } else {\r\n          newModel.selectedModes = [];\r\n        }\r\n      }\r\n      if (isSameModel(model, newModel)) {\r\n        return;\r\n      }\r\n      history.replace(generatePath(newModel));\r\n    },\r\n    [model, history]\r\n  );\r\n  const value = useMemo(\r\n    () => [model, dispatchModelUpdate] as [Readonly<Model>, DispatchModelUpdate],\r\n    [model, dispatchModelUpdate]\r\n  );\r\n  return (\r\n    <ModelContext.Provider value={value}>\r\n      <OnRouteModelUpdatedContext.Provider value={setModel}>{children}</OnRouteModelUpdatedContext.Provider>\r\n    </ModelContext.Provider>\r\n  );\r\n}\r\n"
  },
  {
    "path": "src/components/gameRecords/player.tsx",
    "content": "import { PieChartRounded, ReadMore } from \"@mui/icons-material\";\nimport { Link, Typography, TypographyProps, useTheme } from \"@mui/material\";\nimport React from \"react\";\nimport { useTranslation } from \"react-i18next\";\n\nimport { GameRecord, PlayerRecord, getLevelTag } from \"../../data/types\";\nimport Conf from \"../../utils/conf\";\nimport { useGameLinkActions } from \"./gameLinkActions\";\nimport { generatePlayerPathById } from \"./routeUtils\";\n\nexport const Player = React.memo(function ({\n  player,\n  game,\n  hideDetailIcon,\n  showAiReviewIcon,\n  maskedGameLink,\n  ...props\n}: {\n  player: PlayerRecord;\n  game: GameRecord;\n  hideDetailIcon?: boolean;\n  showAiReviewIcon?: boolean;\n  maskedGameLink?: boolean;\n} & TypographyProps) {\n  const { t } = useTranslation();\n  const theme = useTheme();\n  const { open } = useGameLinkActions();\n  const { nickname, level, score, accountId } = player;\n  const isTop = GameRecord.getRankIndexByPlayer(game, player) === 0;\n  return (\n    <Typography\n      variant=\"body2\"\n      component=\"span\"\n      fontWeight={isTop ? \"bold\" : \"normal\"}\n      display=\"inline-flex\"\n      alignItems=\"center\"\n      color={theme.palette.info.main}\n      {...props}\n    >\n      <Link\n        href={maskedGameLink || !game.uuid || game._masked ? \"#\" : GameRecord.getRecordLink(game, player)}\n        onClick={(e) => {\n          e.preventDefault();\n          open(player, game);\n        }}\n        title={t(\"查看牌谱\")}\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        display=\"block\"\n        color=\"inherit\"\n      >\n        [{getLevelTag(level)}] {nickname} {score !== undefined && `[${score}]`}\n      </Link>\n      {!hideDetailIcon && (\n        <Link\n          className=\"detail-link\"\n          title={t(\"玩家详细\")}\n          href={generatePlayerPathById(accountId)}\n          display=\"block\"\n          color=\"inherit\"\n        >\n          <ReadMore fontSize=\"small\" sx={{ ml: 1, display: \"block\" }} />\n        </Link>\n      )}\n      {Conf.features.aiReview && game.uuid && showAiReviewIcon && (\n        <Link\n          className=\"detail-link\"\n          title={t(\"AI 检讨\")}\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          href={`${t(\"https://mjai.ekyu.moe/zh-cn.html\")}?url=${encodeURIComponent(\n            GameRecord.getRecordLink(game, player)\n          )}`}\n          display=\"block\"\n          color=\"inherit\"\n        >\n          <PieChartRounded fontSize=\"small\" sx={{ ml: 1, display: \"block\" }} />\n        </Link>\n      )}\n    </Typography>\n  );\n});\n"
  },
  {
    "path": "src/components/gameRecords/playerSearch.tsx",
    "content": "import React from \"react\";\nimport { useEffect, useState, useMemo } from \"react\";\n\nimport { LevelWithDelta, Level, getAccountZone } from \"../../data/types\";\nimport { searchPlayer, PlayerSearchResult } from \"../../data/source/misc\";\nimport { Redirect } from \"react-router-dom\";\nimport { generatePlayerPathById } from \"./routeUtils\";\nimport { useTranslation } from \"react-i18next\";\nimport { Autocomplete, CircularProgress, TextField } from \"@mui/material\";\nimport { networkError } from \"../../utils/notify\";\nimport Conf, { CONFIGURATIONS } from \"../../utils/conf\";\nimport Loading from \"../misc/loading\";\n\ntype PlayerSearchResultExt = PlayerSearchResult & {\n  isDeleted?: boolean;\n};\n\nconst playerSearchCache = new Map<string, PlayerSearchResultExt[] | Promise<PlayerSearchResultExt[]>>();\nconst NUM_FETCH = 20;\n\nconst normalizeName = (s: string) => s.toLowerCase().trim();\n\nfunction findRawResultFromCache(prefix: string): { result: PlayerSearchResultExt[]; isExactMatch: boolean } | null {\n  const normalizedPrefix = normalizeName(prefix);\n  prefix = normalizedPrefix;\n  while (prefix) {\n    const players = playerSearchCache.get(prefix);\n    if (!players || players instanceof Promise) {\n      prefix = prefix.slice(0, prefix.length - 1);\n      continue;\n    }\n    return {\n      isExactMatch: prefix === normalizedPrefix,\n      result: players,\n    };\n  }\n  return null;\n}\n\nfunction getCrossSiteConf(x: PlayerSearchResultExt) {\n  if (Conf.availableModes.length > 1) {\n    const level = new Level(x.level.id);\n    if (!Conf.availableModes.some((mode) => level.isAllowedMode(mode))) {\n      return level.getNumPlayerId() === 2 ? CONFIGURATIONS.ikeda : CONFIGURATIONS.DEFAULT;\n    }\n  }\n  return null;\n}\nfunction getOptionLabel(x: PlayerSearchResultExt, t: (x: string) => string): string {\n  let ret = `[${LevelWithDelta.getTag(x.level)}] ${x.nickname}`;\n  const conf = getCrossSiteConf(x);\n  if (conf) {\n    ret = `[${conf.rankColors.length === 3 ? t(\"三麻\") : t(\"四麻\")}] ${ret}`;\n  }\n  return ret;\n}\nexport function PlayerSearch() {\n  const { t } = useTranslation(\"form\");\n  const [selectedItem, setSelectedItem] = useState(null as PlayerSearchResultExt | null);\n  const [version, setVersion] = useState(0);\n  const [searchText, setSearchText] = useState(\"\");\n  const [open, setOpen] = React.useState(false);\n  const [players, isLoading] = useMemo(() => {\n    if (!searchText) {\n      return [[], false];\n    }\n    const cachedResult = findRawResultFromCache(searchText);\n    if (!cachedResult) {\n      return [[], true];\n    }\n    if (cachedResult.isExactMatch) {\n      return [cachedResult.result, false];\n    }\n    const normalizedPrefix = normalizeName(searchText);\n    let mayHaveMore = cachedResult.result.length >= NUM_FETCH;\n    const filteredPlayers = [] as PlayerSearchResultExt[];\n    cachedResult.result.forEach((player) => {\n      if (normalizeName(player.nickname).startsWith(normalizedPrefix)) {\n        filteredPlayers.push(player);\n      } else if (filteredPlayers.length) {\n        // Result covers all players who have the specified prefix\n        mayHaveMore = false;\n      }\n    });\n    return [filteredPlayers, mayHaveMore];\n  }, [searchText, version]); // eslint-disable-line react-hooks/exhaustive-deps\n  useEffect(() => {\n    if (!searchText.trim()) {\n      return;\n    }\n    const prefix = normalizeName(searchText);\n    if (playerSearchCache.has(prefix)) {\n      return;\n    }\n    if (!isLoading) {\n      return;\n    }\n    let cancelled = false;\n    let debounceToken: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {\n      debounceToken = undefined;\n      if (cancelled) {\n        return;\n      }\n      if (playerSearchCache.has(prefix)) {\n        return;\n      }\n      const promise = searchPlayer(prefix, NUM_FETCH).then(function (players: PlayerSearchResultExt[]) {\n        players.forEach((x) => {\n          x.isDeleted = players.some(\n            (y) =>\n              x.nickname === y.nickname &&\n              getAccountZone(x.id) === getAccountZone(y.id) &&\n              x.latest_timestamp < y.latest_timestamp\n          );\n        });\n        playerSearchCache.set(prefix, players);\n        if (!cancelled) {\n          setVersion(new Date().getTime());\n        }\n        return players;\n      });\n      playerSearchCache.set(prefix, promise);\n      promise.catch((e) => {\n        console.error(e);\n        playerSearchCache.delete(prefix);\n        networkError();\n      });\n    }, 500);\n    return () => {\n      cancelled = true;\n      if (debounceToken) {\n        clearTimeout(debounceToken);\n      }\n    };\n  }, [searchText, isLoading]);\n  if (selectedItem) {\n    const crossSiteConf = getCrossSiteConf(selectedItem);\n    if (crossSiteConf) {\n      location.href = `https://${crossSiteConf.canonicalDomain}${generatePlayerPathById(selectedItem.id)}`;\n      return <Loading />;\n    }\n    return <Redirect to={generatePlayerPathById(selectedItem.id)} push />;\n  }\n  return (\n    <Autocomplete\n      fullWidth\n      blurOnSelect\n      open={open && !!searchText.trim()}\n      onOpen={() => {\n        setOpen(true);\n      }}\n      onClose={() => {\n        setOpen(false);\n      }}\n      inputValue={searchText}\n      onInputChange={(_, value, reason) => setSearchText(reason === \"reset\" ? \"\" : value)}\n      onChange={(_, value, reason) => reason === \"selectOption\" && setSelectedItem(value)}\n      options={players}\n      getOptionLabel={(x) => getOptionLabel(x, t)}\n      renderOption={(props, option) => {\n        const { key, ...otherProps } = props as typeof props & { key: string };\n        return (\n          <li key={key} {...otherProps}>\n            <span style={option.isDeleted ? { textDecoration: \"line-through\", color: \"#888\" } : {}}>\n              {\" \"}\n              {getOptionLabel(option, t)}\n            </span>\n          </li>\n        );\n      }}\n      isOptionEqualToValue={(option, value) => option.id === value.id}\n      loading={isLoading}\n      filterOptions={(x) => x}\n      renderInput={(params) => (\n        <TextField\n          {...params}\n          label={t(\"名字\")}\n          InputProps={{\n            ...params.InputProps,\n            endAdornment: (\n              <React.Fragment>{isLoading ? <CircularProgress color=\"inherit\" size={20} /> : null}</React.Fragment>\n            ),\n          }}\n        />\n      )}\n    />\n  );\n}\n"
  },
  {
    "path": "src/components/gameRecords/routeSync.tsx",
    "content": "import React from \"react\";\nimport dayjs from \"dayjs\";\n\nimport { useParams, useLocation, Redirect } from \"react-router\";\nimport { Model, useOnRouteModelUpdated } from \"./model\";\nimport { useEffect } from \"react\";\nimport { scrollToTop, triggerRelayout } from \"../../utils/index\";\nimport Conf from \"../../utils/conf\";\nimport { parseCombinedMode } from \"../../data/types\";\n\ntype ListingRouteParams = {\n  date?: string;\n  mode?: string;\n  search?: string;\n};\n\ntype PlayerRouteParams = {\n  id: string;\n  startDate?: string;\n  endDate?: string;\n  mode?: string;\n  search?: string;\n  rank?: string;\n  kontenOnly?: string;\n  limit?: string;\n};\n\nfunction parseOptionalDate<T>(\n  s: string | null | undefined,\n  defaultValue: T,\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  postprocess = (d: dayjs.Dayjs, isPrecise: boolean) => d\n): dayjs.Dayjs | T {\n  if (!s) {\n    return defaultValue;\n  }\n  const isPrecise = /^\\d{6,}$/.test(s);\n  const ret = isPrecise ? dayjs(parseInt(s, 10)) : dayjs(s);\n  if (!ret.isValid()) {\n    return defaultValue;\n  }\n  return postprocess(ret, isPrecise);\n}\n\nconst ModelBuilders = {\n  player(params: PlayerRouteParams): Model | string {\n    if (params.rank) {\n      const rank = parseInt(params.rank);\n      if (!rank || rank < 1 || rank > Conf.rankColors.length) {\n        delete params.rank;\n      }\n    }\n    const selectedModes = parseCombinedMode(params.mode || \"\");\n    if (!selectedModes.length && Conf.availableModes.length > 1) {\n      delete params.limit;\n    }\n    if (params.limit) {\n      delete params.startDate;\n      delete params.endDate;\n    }\n    return {\n      type: \"player\",\n      playerId: params.id,\n      startDate: parseOptionalDate(params.startDate, null),\n      endDate: parseOptionalDate(params.endDate, null, (d, isPrecise) => (isPrecise ? d : d.endOf(\"day\"))),\n      selectedModes,\n      searchText: params.search ? params.search.slice(1) : \"\",\n      rank: parseInt(params.rank || \"\") || null,\n      kontenOnly: !!params.kontenOnly,\n      limit: parseInt(params.limit || \"\", 10) || null,\n    };\n  },\n  listing(params: ListingRouteParams): Model | string {\n    const date = parseOptionalDate(params.date, null);\n    if (date && !date.isValid()) {\n      return \"/\";\n    }\n    return {\n      type: undefined,\n      date: date ? date.startOf(\"day\").valueOf() : null,\n      selectedMode: parseCombinedMode(params.mode || \"\")[0] || null,\n      searchText: params.search || \"\",\n    };\n  },\n};\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function RouteSync({ view }: { view: keyof typeof ModelBuilders }): React.FunctionComponentElement<any> {\n  useEffect(() => {\n    triggerRelayout();\n    scrollToTop();\n    return scrollToTop;\n  }, []);\n  const onRouteModelUpdated = useOnRouteModelUpdated();\n  const params = useParams();\n  const location = useLocation();\n  const query = new URLSearchParams(location.search);\n  Object.assign(params, {\n    rank: query.get(\"rank\"),\n    kontenOnly: query.get(\"kontenOnly\"),\n    limit: query.get(\"limit\"),\n  });\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  const modelResult = ModelBuilders[view](params as any);\n  useEffect(() => {\n    if (typeof modelResult !== \"string\") {\n      onRouteModelUpdated(modelResult);\n    }\n  }, [modelResult, onRouteModelUpdated]);\n  if (typeof modelResult === \"string\") {\n    return <Redirect to={modelResult} />;\n  }\n  return <></>;\n}\n"
  },
  {
    "path": "src/components/gameRecords/routeUtils.tsx",
    "content": "import { generatePath as genPath } from \"react-router-dom\";\nimport { Model } from \"./model\";\nimport dayjs from \"dayjs\";\n\nexport const PLAYER_PATH =\n  \"/player/:id/:mode([0-9.]+)?/:search(-[^/]+)?/:startDate(\\\\d{4}-\\\\d{2}-\\\\d{2}|\\\\d{6,})?/:endDate(\\\\d{4}-\\\\d{2}-\\\\d{2}|\\\\d{6,})?\";\nexport const PATH = \"/:date(\\\\d{4}-\\\\d{2}-\\\\d{2})/:mode([0-9]+)?/:search?\";\nfunction dateToStringSafe(value: dayjs.ConfigType | null | undefined): string | undefined {\n  if (!value) {\n    return undefined;\n  }\n  const dateObj = dayjs(value);\n  if (!dateObj.isValid() || dateObj.year() < 2019 || dateObj.year() > 9999) {\n    return undefined;\n  }\n  if (\n    dateObj.valueOf() - dateObj.startOf(\"day\").valueOf() > 0 &&\n    dateObj.endOf(\"day\").valueOf() - dateObj.valueOf() > 60000\n  ) {\n    return dateObj.valueOf().toString();\n  }\n  return dateObj.format(\"YYYY-MM-DD\");\n}\n\nexport function generatePath(model: Model): string {\n  if (model.type === \"player\") {\n    if (model.limit) {\n      delete model.startDate;\n      delete model.endDate;\n    }\n    let result = genPath(PLAYER_PATH, {\n      id: model.playerId,\n      startDate: dateToStringSafe(model.startDate),\n      endDate: dateToStringSafe(model.endDate),\n      mode: model.selectedModes.join(\".\") || undefined,\n      search: model.searchText ? \"-\" + model.searchText : undefined,\n    } as any); // eslint-disable-line @typescript-eslint/no-explicit-any\n    const params = new URLSearchParams(\"\");\n    if (model.rank) {\n      params.set(\"rank\", model.rank.toString());\n    }\n    if (model.kontenOnly) {\n      params.set(\"kontenOnly\", \"1\");\n    }\n    if (model.limit) {\n      params.set(\"limit\", model.limit.toString());\n    }\n    const paramString = params.toString();\n    if (paramString) {\n      result += \"?\" + paramString;\n    }\n    return result;\n  }\n  if (!model.selectedMode && !model.searchText && !model.date) {\n    return \"/\";\n  }\n  const dateString = dateToStringSafe(model.date || dayjs().startOf(\"day\"));\n  if (!dateString) {\n    return \"/\";\n  }\n  return genPath(PATH, {\n    date: dateString,\n    mode: model.selectedMode || undefined,\n    search: model.searchText || undefined,\n  } as any); // eslint-disable-line @typescript-eslint/no-explicit-any\n}\nexport function generatePlayerPathById(playerId: number | string): string {\n  return generatePath({\n    type: \"player\",\n    playerId: playerId.toString(),\n    startDate: null,\n    endDate: null,\n    selectedModes: [],\n    searchText: \"\",\n    rank: null,\n    kontenOnly: false,\n    limit: null,\n  });\n}\n"
  },
  {
    "path": "src/components/gameRecords/routes.tsx",
    "content": "import { Switch, Route, Redirect } from \"react-router-dom\";\n\nimport { RouteSync } from \"./routeSync\";\nimport Loadable from \"../misc/customizedLoadable\";\nimport { PageCategory } from \"../misc/tracker\";\nimport Home from \"./home\";\nimport { ExtraFilterPredicateProvider } from \"./extraFilterPredicate\";\nimport { DataAdapterProvider } from \"./dataAdapterProvider\";\nimport { PLAYER_PATH, PATH } from \"./routeUtils\";\n\nconst PlayerDetails = Loadable({\n  loader: () => import(\"../playerDetails/playerDetails\"),\n});\nconst GameRecordTablePlayerView = Loadable({\n  loader: () => import(\"./tableViews\").then((x) => ({ default: x.GameRecordTablePlayerView })),\n});\n\nfunction Routes() {\n  return (\n    <Switch>\n      <Route path={PLAYER_PATH}>\n        <RouteSync view=\"player\" />\n        <PageCategory category=\"Player\" />\n        <ExtraFilterPredicateProvider>\n          <DataAdapterProvider>\n            <PlayerDetails />\n            <GameRecordTablePlayerView />\n          </DataAdapterProvider>\n        </ExtraFilterPredicateProvider>\n      </Route>\n      <Route exact path={[\"/\", PATH]}>\n        <RouteSync view=\"listing\" />\n        <PageCategory category=\"Listing\" />\n        <DataAdapterProvider>\n          <Home />\n        </DataAdapterProvider>\n      </Route>\n      <Route>\n        <Redirect to=\"/\" />\n      </Route>\n    </Switch>\n  );\n}\nexport default Routes;\n"
  },
  {
    "path": "src/components/gameRecords/table.tsx",
    "content": "import React, { useCallback, useEffect, useMemo } from \"react\";\nimport { Index } from \"react-virtualized\";\nimport { ColumnProps, Table } from \"react-virtualized/dist/es/Table\";\nimport { AutoSizer } from \"react-virtualized/dist/es/AutoSizer\";\nimport clsx from \"clsx\";\n\nimport { useScrollerProps } from \"../misc/scroller\";\nimport { useDataAdapter } from \"./dataAdapterProvider\";\nimport { triggerRelayout, useIsMobile } from \"../../utils/index\";\nimport Loading from \"../misc/loading\";\nimport { useTranslation } from \"react-i18next\";\nimport { TableColumnDef } from \"./columns\";\nimport { Box, styled, useTheme } from \"@mui/material\";\nimport useMediaQuery from \"@mui/material/useMediaQuery\";\n\nexport { Column } from \"react-virtualized/dist/es/Table\";\n\nconst StyledTableContainer = styled(Box)(({ theme }) => ({\n  ...theme.typography.body2,\n\n  [theme.breakpoints.down(\"sm\")]: {\n    \".MuiBox-root, .MuiTypography-root, .ReactVirtualized__Table__rowColumn, .ReactVirtualized__Table__headerColumn\": {\n      fontSize: \"0.85rem\",\n      margin: \"0 2px\",\n    },\n  },\n}));\n\nexport default function GameRecordTable({ columns }: { columns: TableColumnDef[] }) {\n  const { i18n } = useTranslation();\n  const data = useDataAdapter();\n  const scrollerProps = useScrollerProps();\n  const { isScrolling, onChildScroll, scrollTop, height, registerChild } = scrollerProps;\n  const rowGetter = useCallback(({ index }: Index) => data.getItem(index), [data]);\n  const getRowClassName = useCallback(\n    ({ index }: Index) => (index >= 0 ? clsx({ loading: !data.isItemLoaded(index), even: (index & 1) === 0 }) : \"\"),\n    [data]\n  );\n  const noRowsRenderer = useCallback(() => (data.hasCount() ? null : <Loading />), [data]);\n  const unfilteredCount = data.getUnfilteredCount();\n  const shouldTriggerLayout = !!unfilteredCount;\n  const isMobile = useIsMobile();\n  const theme = useTheme();\n  const isMd = useMediaQuery(theme.breakpoints.up(\"md\"));\n  useEffect(() => {\n    triggerRelayout();\n  }, [shouldTriggerLayout]);\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  const memoColumns = useMemo(\n    () =>\n      columns\n        .map((x) => x())\n        .filter((x) => x)\n        .map((x) => {\n          if (!isMobile) {\n            return x;\n          }\n          const props = x && (x.props as unknown as ColumnProps);\n          if (!props) {\n            return x;\n          }\n          if (props.columnData?.mobileProps) {\n            return React.cloneElement(x, { ...props, ...props.columnData?.mobileProps });\n          }\n          return x;\n        }),\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [\n      // eslint-disable-next-line react-hooks/exhaustive-deps\n      isMobile,\n      // eslint-disable-next-line react-hooks/exhaustive-deps\n      i18n.language,\n      // eslint-disable-next-line react-hooks/exhaustive-deps\n      ...columns.map((x) => x.key || x),\n    ]\n  );\n  if (data.hasCount() && !data.getCount()) {\n    return <></>;\n  }\n  return (\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    <StyledTableContainer ref={registerChild as any}>\n      <AutoSizer disableHeight>\n        {({ width }) => (\n          <Table\n            autoHeight\n            rowCount={data.getCount()}\n            rowGetter={rowGetter}\n            rowHeight={isMd ? 70 : !isMobile ? 140 : 100}\n            headerHeight={50}\n            width={width}\n            height={height}\n            isScrolling={isScrolling}\n            onScroll={onChildScroll}\n            scrollTop={scrollTop}\n            rowClassName={getRowClassName}\n            noRowsRenderer={noRowsRenderer}\n          >\n            {memoColumns}\n          </Table>\n        )}\n      </AutoSizer>\n    </StyledTableContainer>\n  );\n}\n"
  },
  {
    "path": "src/components/gameRecords/tableViews.tsx",
    "content": "import { useModel } from \"./model\";\n\nimport { default as GameRecordTable } from \"./table\";\nimport {\n  COLUMN_RANK,\n  COLUMN_GAMEMODE,\n  COLUMN_PLAYERS,\n  COLUMN_FULLTIME,\n  COLUMN_STARTTIME,\n  COLUMN_ENDTIME,\n} from \"./columns\";\n\nexport function GameRecordTablePlayerView() {\n  const [model] = useModel();\n  if (!(\"playerId\" in model)) {\n    return null;\n  }\n  return (\n    <GameRecordTable\n      columns={[\n        COLUMN_GAMEMODE,\n        COLUMN_RANK(model.playerId),\n        COLUMN_PLAYERS({ activePlayerId: model.playerId }),\n        COLUMN_FULLTIME,\n      ]}\n    />\n  );\n}\n\nexport function GameRecordTableHomeView() {\n  return (\n    <GameRecordTable\n      columns={[COLUMN_GAMEMODE, COLUMN_PLAYERS({ maskedGameLink: true }), COLUMN_STARTTIME, COLUMN_ENDTIME]}\n    />\n  );\n}\n"
  },
  {
    "path": "src/components/layout/container.tsx",
    "content": "import { ReactNode } from \"react\";\r\n\r\nimport { Container as MuiContainer, Typography } from \"@mui/material\";\r\n\r\nexport const Container = ({ title = undefined, children = undefined as ReactNode }) => (\r\n  <MuiContainer sx={{ my: 5 }}>\r\n    {title && (\r\n      <Typography variant=\"h2\" sx={{ mb: 4 }}>\r\n        {title}\r\n      </Typography>\r\n    )}\r\n    {children}\r\n  </MuiContainer>\r\n);\r\n"
  },
  {
    "path": "src/components/layout/index.tsx",
    "content": "export * from \"./container\";"
  },
  {
    "path": "src/components/misc/alert.tsx",
    "content": "import { useState, useEffect, ReactNode } from \"react\";\r\nimport React from \"react\";\r\nimport { ReactComponentLike } from \"prop-types\";\r\nimport { triggerRelayout } from \"../../utils/index\";\r\nimport { Alert as MuiAlert, AlertColor, AlertTitle, Fade, AlertProps } from \"@mui/material\";\r\nimport { loadPreference, savePreference } from \"../../utils/preference\";\r\n\r\nexport function Alert({\r\n  type = \"info\" as AlertColor,\r\n  container = React.Fragment as ReactComponentLike,\r\n  stateName = \"\",\r\n  closable = true,\r\n  title = \"\",\r\n  children = undefined as ReactNode,\r\n  sx = { mb: 2 } as AlertProps[\"sx\"],\r\n}) {\r\n  const stateKey = `alertState_${stateName}`;\r\n  const [closed, setClosed] = useState(() => stateName && loadPreference(stateKey, false));\r\n  useEffect(() => {\r\n    if (stateName && closed) {\r\n      savePreference(stateKey, true);\r\n    }\r\n  }, [closed, stateName, stateKey]);\r\n  if (closed && closable) {\r\n    return null;\r\n  }\r\n  const Cont = container;\r\n  return (\r\n    <Cont>\r\n      <Fade in={!closed} onEntering={() => triggerRelayout()} onExited={() => triggerRelayout()}>\r\n        <MuiAlert\r\n          severity={type}\r\n          onClose={closable ? () => setClosed(true) : undefined}\r\n          sx={{ \"& .MuiAlert-message\": { overflow: \"unset\" }, ...sx }}\r\n        >\r\n          {title && <AlertTitle>{title} </AlertTitle>}\r\n          {children}\r\n        </MuiAlert>\r\n      </Fade>\r\n    </Cont>\r\n  );\r\n}\r\n"
  },
  {
    "path": "src/components/misc/canonicalLink.tsx",
    "content": "import React from \"react\";\nimport { Helmet } from \"react-helmet\";\nimport { useLocation } from \"react-router\";\nimport Conf from \"../../utils/conf\";\n\nexport default function CanonicalLink() {\n  const loc = useLocation();\n  return (\n    <Helmet>\n      <link rel=\"canonical\" href={`https://${Conf.canonicalDomain}${loc.pathname}`} />\n    </Helmet>\n  );\n}\n"
  },
  {
    "path": "src/components/misc/customizedLoadable.tsx",
    "content": "import React, { ComponentType, ReactNode, Suspense } from \"react\";\nimport Loading from \"./loading\";\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction CustomizedLoadable<T extends ComponentType<any>>({\n  loader,\n  loading = () => <Loading />,\n}: {\n  loader: () => Promise<{ default: T }>;\n  loading?: () => ReactNode;\n}): React.ComponentType<T extends ComponentType<infer TProps> ? TProps : unknown> {\n  const LazyComponent = React.lazy(loader);\n\n  return function (props: T extends ComponentType<infer TProps> ? TProps : unknown) {\n    return (\n      <Suspense fallback={loading() || null}>\n        <LazyComponent {...props} />\n      </Suspense>\n    );\n  };\n}\n\nexport default CustomizedLoadable;\n"
  },
  {
    "path": "src/components/misc/linkBehavior.tsx",
    "content": "import React from \"react\";\nimport { Link, LinkProps } from \"react-router-dom\";\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport const LinkBehavior = React.forwardRef<any, Omit<LinkProps, \"to\"> & { href: LinkProps[\"to\"]; }>((props, ref) => {\n  const { href, ...other } = props;\n  if (!href) {\n    return <span ref={ref} {...other} />;\n  }\n  if (typeof href === \"string\" && /^https?:\\/\\//i.test(href)) {\n    return <a ref={ref} href={href} {...other} />;\n  }\n  return <Link ref={ref} to={href} {...other} />;\n});\n"
  },
  {
    "path": "src/components/misc/loading.tsx",
    "content": "import { Box, CircularProgress } from \"@mui/material\";\n\nexport default function Loading({ size = \"normal\" }: { size?: \"normal\" | \"small\" }) {\n  return (\n    <Box m={size === \"normal\" ? 5 : 1} display=\"flex\" justifyContent=\"center\">\n      <CircularProgress size={size === \"normal\" ? 40 : 20} />\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/components/misc/menuButton.tsx",
    "content": "import React, { ReactElement, ReactNode } from \"react\";\nimport { Button, Menu, MenuItemProps, ButtonProps } from \"@mui/material\";\n\nexport function MenuButton({\n  label,\n  children,\n  ...props\n}: {\n  label: ReactNode;\n  children: ReactElement<MenuItemProps>[];\n} & ButtonProps) {\n  const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);\n  const open = Boolean(anchorEl);\n  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {\n    setAnchorEl(event.currentTarget);\n  };\n  const handleClose = () => {\n    setAnchorEl(null);\n  };\n  const handleItemClick = (item: ReactElement<MenuItemProps>) => {\n    const onClick = item.props.onClick;\n    if (!onClick) {\n      return handleClose;\n    }\n    return (e: React.MouseEvent<HTMLLIElement>) => {\n      handleClose();\n      onClick(e);\n    };\n  };\n  return (\n    <>\n      <Button {...props} onClick={handleClick}>\n        {label}\n      </Button>\n      <Menu\n        open={open}\n        onClose={handleClose}\n        anchorEl={anchorEl}\n        anchorOrigin={{\n          vertical: \"bottom\",\n          horizontal: \"center\",\n        }}\n        transformOrigin={{\n          vertical: \"top\",\n          horizontal: \"center\",\n        }}\n        disableScrollLock\n      >\n        {React.Children.map(children, (x) => React.cloneElement(x, { onClick: handleItemClick(x) }))}\n      </Menu>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/misc/navButton.tsx",
    "content": "/* eslint-disable @typescript-eslint/indent */\nimport { Button, ButtonProps } from \"@mui/material\";\nimport { NavLink, NavLinkProps } from \"react-router-dom\";\n\nconst InnerButton = ({\n  navigate,\n  href,\n  activeProps,\n  children,\n  ...props\n}: ButtonProps<\"a\"> & { navigate: () => void; activeProps?: ButtonProps<\"a\"> }) => {\n  return (\n    <Button\n      LinkComponent=\"a\"\n      href={href || \"#\"}\n      onClick={(e) => {\n        e.preventDefault();\n        navigate();\n      }}\n      {...props}\n      {...(activeProps && props[\"aria-current\"] ? activeProps : {})}\n    >\n      {children}\n    </Button>\n  );\n};\n\nconst NavButton = ({\n  href,\n  children,\n  ...props\n}: Omit<ButtonProps<\"a\">, \"href\"> &\n  Omit<NavLinkProps, \"to\" | \"href\"> & { href: NavLinkProps[\"to\"]; activeProps?: ButtonProps<\"a\"> }) => (\n  <NavLink component={InnerButton} to={href} activeClassName=\"active\" {...props}>\n    {children}\n  </NavLink>\n);\n\nexport default NavButton;\n"
  },
  {
    "path": "src/components/misc/scroller.tsx",
    "content": "import React, { ReactChild, useContext } from \"react\";\r\n\r\nimport { WindowScrollerChildProps } from \"react-virtualized\";\r\nimport { WindowScroller } from \"react-virtualized/dist/es/WindowScroller\";\r\n\r\nconst ScrollerContext = React.createContext<WindowScrollerChildProps>({} as WindowScrollerChildProps);\r\n\r\nexport const useScrollerProps = () => useContext(ScrollerContext);\r\n\r\nfunction Scroller({ children }: { children: ReactChild | ReactChild[] }) {\r\n  return (\r\n    <WindowScroller>\r\n      {scrollerProps => <ScrollerContext.Provider value={scrollerProps}>{children}</ScrollerContext.Provider>}\r\n    </WindowScroller>\r\n  );\r\n}\r\nexport default Scroller;\r\n"
  },
  {
    "path": "src/components/misc/tracker.tsx",
    "content": "import { useLocation } from \"react-router\";\nimport { useEffect, useLayoutEffect } from \"react\";\nimport Helmet from \"react-helmet\";\nimport { canTrackUser } from \"../../utils/conf\";\n\nlet currentCategory = \"Home\";\n\ntype Ga = NonNullable<typeof window.ga>;\n\ndeclare global {\n  interface Window {\n    __loadGa?: () => Ga;\n  }\n}\n\nexport function PageCategory({ category }: { category: string }) {\n  useLayoutEffect(() => {\n    const oldCategory = currentCategory;\n    currentCategory = category;\n    return () => {\n      currentCategory = oldCategory;\n    };\n  }, [category]);\n  return null;\n}\n\nfunction TrackerImpl() {\n  const loc = useLocation();\n  useEffect(() => {\n    let cancelled = false;\n    window.requestAnimationFrame(() => {\n      if (cancelled) {\n        return;\n      }\n      const helmet = Helmet.peek();\n      const title = (helmet.title || document.title).toString();\n      if (window.ga) {\n        window.ga(\"send\", {\n          hitType: \"pageview\",\n          page: loc.pathname,\n          title: `${currentCategory} ${title}`,\n          contentGroup1: currentCategory,\n        });\n      }\n    });\n    return () => {\n      cancelled = true;\n    };\n  }, [loc.pathname]);\n  return null;\n}\n\nexport default function Tracker() {\n  if (process.env.NODE_ENV !== \"production\") {\n    return null;\n  }\n  if (!canTrackUser()) {\n    return null;\n  }\n  if (!window.__loadGa) {\n    return null;\n  }\n  window.__loadGa();\n  return <TrackerImpl />;\n}\n"
  },
  {
    "path": "src/components/modeModel/index.tsx",
    "content": "export { ModelModeProvider, useModel } from \"./model\";\nexport { default as ModelModeSelector } from \"./modelModeSelector\";\n"
  },
  {
    "path": "src/components/modeModel/model.tsx",
    "content": "import React, { useReducer, useContext, ReactChild } from \"react\";\nimport { useMemo } from \"react\";\nimport { GameMode } from \"../../data/types\";\n\nexport interface Model {\n  selectedModes: GameMode[];\n  careerRankingMinGames?: number;\n}\n\ntype ModelUpdate = Partial<Model>;\ntype DispatchModelUpdate = (props: ModelUpdate) => void;\n\nconst DEFAULT_MODEL: Model = { selectedModes: [] };\n// eslint-disable-next-line @typescript-eslint/no-empty-function\nconst ModelContext = React.createContext<[Readonly<Model>, DispatchModelUpdate]>([{ ...DEFAULT_MODEL }, () => {}]);\nexport const useModel = () => useContext(ModelContext);\n\nexport function ModelModeProvider({ children }: { children: ReactChild | ReactChild[] }) {\n  const [model, updateModel] = useReducer(\n    (oldModel: Model, newProps: ModelUpdate): Model => ({\n      ...oldModel,\n      ...newProps,\n    }),\n    null,\n    (): Model => ({\n      ...DEFAULT_MODEL,\n    })\n  );\n  const value: [Model, DispatchModelUpdate] = useMemo(() => [model, updateModel], [model, updateModel]);\n  return <ModelContext.Provider value={value}>{children}</ModelContext.Provider>;\n}\n"
  },
  {
    "path": "src/components/modeModel/modelModeSelector.tsx",
    "content": "import React, { useEffect, useMemo } from \"react\";\nimport { useCallback } from \"react\";\nimport { ModeSelector } from \"../gameRecords/modeSelector\";\nimport { useModel } from \"./model\";\nimport Conf from \"../../utils/conf\";\nimport { GameMode } from \"../../data/types\";\nimport { Box } from \"@mui/material\";\n\nexport default function ModelModeSelector({\n  type = \"radio\" as \"radio\" | \"checkbox\",\n  availableModes = Conf.availableModes,\n  autoSelectFirst = false,\n  oneOrAll = false,\n  allowedCombinations = null as null | GameMode[][],\n}) {\n  allowedCombinations = useMemo(\n    () => allowedCombinations || (oneOrAll ? [availableModes] : null),\n    [allowedCombinations, oneOrAll, availableModes]\n  );\n  const [model, updateModel] = useModel();\n  const uiSetModes = useCallback(\n    (modes: GameMode[]) => {\n      if (!availableModes.length) {\n        return;\n      }\n      modes = modes.filter((x) => availableModes.includes(x));\n      if (!modes.length) {\n        return;\n      }\n      if (type === \"radio\") {\n        if (model.selectedModes[0] !== modes[0]) {\n          updateModel({ selectedModes: [modes[0]] });\n        }\n        return;\n      }\n      if (modes.length > 1 && allowedCombinations) {\n        const isAllowed = allowedCombinations.some(\n          (comb) => modes.length === comb.length && modes.every((m) => comb.includes(m))\n        );\n        if (!isAllowed) {\n          let newAllowedCombinations = allowedCombinations.filter((comb) => modes.every((mode) => comb.includes(mode)));\n          if (newAllowedCombinations.length > 0) {\n            const removed = model.selectedModes.find((x) => !modes.includes(x));\n            if (removed) {\n              const filteredCombinations = newAllowedCombinations.filter((x) => !x.includes(removed));\n              if (!filteredCombinations.length) {\n                return;\n              }\n              newAllowedCombinations = filteredCombinations;\n            }\n          }\n          if (newAllowedCombinations.length > 0) {\n            modes = newAllowedCombinations[0];\n          } else {\n            const added = modes.find((x) => !model.selectedModes.includes(x));\n            if (!added) {\n              return;\n            }\n            modes = [added];\n          }\n        }\n      }\n      if (modes.length === model.selectedModes.length && modes.every((x) => model.selectedModes.includes(x))) {\n        return;\n      }\n      updateModel({ selectedModes: modes });\n    },\n    [updateModel, availableModes, model, allowedCombinations, type]\n  );\n  useEffect(() => {\n    if (!availableModes.length) {\n      return;\n    }\n    let selectedModes = (model.selectedModes || []).filter((x) => availableModes.includes(x));\n    if (\n      allowedCombinations &&\n      selectedModes.length > 1 &&\n      !allowedCombinations.some(\n        (comb) => comb.length === selectedModes.length && comb.every((mode) => selectedModes.includes(mode))\n      )\n    ) {\n      selectedModes = [];\n    }\n    if (type === \"radio\" && selectedModes.length > 1) {\n      selectedModes = [selectedModes[0]];\n    }\n\n    if (!selectedModes.length) {\n      if (autoSelectFirst) {\n        updateModel({ selectedModes: [availableModes[0]] });\n      } else if (allowedCombinations) {\n        updateModel({ selectedModes: allowedCombinations[0] });\n      }\n      return;\n    }\n    if (\n      selectedModes.length === model.selectedModes.length &&\n      selectedModes.every((x) => model.selectedModes.includes(x))\n    ) {\n      return;\n    }\n    updateModel({ selectedModes });\n  }, [autoSelectFirst, availableModes, model.selectedModes, allowedCombinations, type, updateModel]);\n  if (availableModes.length < 2) {\n    return null;\n  }\n  return (\n    <Box\n      mb={3}\n      visibility={\n        allowedCombinations &&\n        model.selectedModes.length !== 1 &&\n        !allowedCombinations.some(\n          (x) => x.length === model.selectedModes.length && x.every((mode) => model.selectedModes.includes(mode))\n        )\n          ? \"hidden\"\n          : \"visible\"\n      }\n    >\n      <ModeSelector type={type} mode={model.selectedModes} onChange={uiSetModes} availableModes={availableModes} />\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/components/playerDetails/charts/rankRate.tsx",
    "content": "import React from \"react\";\nimport { ResponsiveContainer, PieChart, Pie, Cell, LabelList, Curve } from \"recharts\";\n\nimport { PlayerMetadata, getRankLabelByIndex } from \"../../../data/types\";\nimport { useMemo } from \"react\";\nimport { formatPercent } from \"../../../utils/index\";\nimport Conf from \"../../../utils/conf\";\nimport { useTranslation } from \"react-i18next\";\n\nconst generateCells = (activeIndex: number) =>\n  Conf.rankColors.map((color, index) => (\n    <Cell fill={color} fillOpacity={activeIndex === index ? 1 : 1} key={`cell-${index}`} />\n  ));\n\nconst CELLS = generateCells(-1);\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst formatLabel = (x: any) => (x.rate > 0 ? x.label : null);\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst createLabelLine = (props: any) =>\n  props.payload.payload.rate > 0 ? <Curve {...props} type=\"linear\" className=\"recharts-pie-label-line\" /> : null;\n\nconst RankRateChart = React.memo(function ({ metadata, aspect = 1 }: { metadata: PlayerMetadata; aspect?: number }) {\n  const { i18n } = useTranslation();\n  const ranks = useMemo(\n    () => metadata.rank_rates.map((x, index) => ({ label: getRankLabelByIndex(index), rate: x })),\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [metadata, i18n.language]\n  );\n  const startAngle = ranks.filter((x) => x.rate > 0).length < 4 ? 45 : 0;\n  return (\n    <ResponsiveContainer width=\"100%\" aspect={aspect} height=\"auto\">\n      <PieChart margin={{ left: 20, right: 20 }}>\n        <Pie\n          isAnimationActive={false}\n          data={ranks}\n          label={formatLabel}\n          labelLine={createLabelLine as any} // eslint-disable-line @typescript-eslint/no-explicit-any\n          nameKey=\"label\"\n          dataKey=\"rate\"\n          startAngle={startAngle}\n          endAngle={startAngle + 360}\n        >\n          {CELLS}\n          <LabelList dataKey=\"rate\" formatter={formatPercent} position=\"inside\" {...{ fill: \"#fff\" }} />\n        </Pie>\n      </PieChart>\n    </ResponsiveContainer>\n  );\n});\nexport default RankRateChart;\n"
  },
  {
    "path": "src/components/playerDetails/charts/recentRank.tsx",
    "content": "import { ResponsiveContainer, LineChart, Line, Dot, Tooltip, YAxis, TooltipProps } from \"recharts\";\n\nimport { IDataAdapter } from \"../../gameRecords/dataAdapterProvider\";\nimport { GameRecord, Level, modeLabel, getRankLabelByIndex } from \"../../../data/types\";\nimport { useMemo } from \"react\";\nimport { Player } from \"../../gameRecords/player\";\nimport Loading from \"../../misc/loading\";\nimport { calculateDeltaPoint } from \"../../../data/types/metadata\";\nimport { useIsMobile } from \"../../../utils/index\";\nimport Conf from \"../../../utils/conf\";\nimport { alpha, Box, styled, Typography } from \"@mui/material\";\nimport React from \"react\";\n\ndeclare module \"recharts\" {\n  interface DotProps {\n    strokeWidth?: number;\n    stroke?: string;\n    fill?: string;\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    payload?: any;\n  }\n}\n\ntype DotPayload = {\n  pos: number;\n  rank: number;\n  delta: number;\n  cumulativeDelta: number;\n  game: GameRecord;\n  playerId: number;\n};\n\nconst createDot = (isMobile: boolean) => (props: { payload: DotPayload }, active?: boolean) => {\n  const scale = isMobile ? 1.5 : 2;\n  return (\n    <Dot\n      {...props}\n      stroke={Conf.rankColors[props.payload.rank]}\n      {...{\n        onClick: () => window.open(GameRecord.getRecordLink(props.payload.game, props.payload.playerId), \"_blank\"),\n      }}\n      {...(active ? { fill: Conf.rankColors[props.payload.rank], r: 5 * scale } : { r: 2.5 * scale })}\n    />\n  );\n};\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst createActiveDot = (isMobile: boolean) => (props: Parameters<ReturnType<typeof createDot>>[0]) =>\n  createDot(isMobile)(props, true);\n\nconst TooltipBox = styled(Box)(({ theme }) => ({\n  backgroundColor: alpha(theme.palette.grey[700], 0.92),\n  borderRadius: theme.shape.borderRadius,\n  color: theme.palette.common.white,\n  fontFamily: theme.typography.fontFamily,\n  padding: \"16px\",\n  fontSize: theme.typography.pxToRem(11),\n  fontWeight: theme.typography.fontWeightMedium,\n}));\n\nconst RankChartTooltip = ({ active, payload }: TooltipProps<number, string> = {}) => {\n  if (!active || !payload || !payload.length) {\n    return null;\n  }\n  const realPayload = payload[0].payload as DotPayload;\n  return (\n    <TooltipBox>\n      <Typography variant=\"h6\">\n        {GameRecord.formatFullStartTime(realPayload.game)}{\" \"}\n        {realPayload.game.modeId ? modeLabel(realPayload.game.modeId) : \"\"} {getRankLabelByIndex(realPayload.rank)}{\" \"}\n        {realPayload.delta > 0 ? \"+\" : \"\"}\n        {realPayload.delta}pt\n      </Typography>\n      {realPayload.game.players.map((x) => (\n        <Typography key={x.accountId.toString()} variant=\"body2\">\n          <Player\n            player={x}\n            game={realPayload.game}\n            sx={{ textDecoration: realPayload.playerId === x.accountId ? \"underline\" : \"none\" }}\n            color=\"inherit\"\n            hideDetailIcon\n          />\n        </Typography>\n      ))}\n    </TooltipBox>\n  );\n};\n\nconst RecentRankChart = React.memo(function ({\n  dataAdapter,\n  playerId,\n  aspect = 2,\n  numGames = 0,\n}: {\n  dataAdapter: IDataAdapter;\n  playerId: number;\n  aspect?: number;\n  numGames?: number;\n}) {\n  const isMobile = useIsMobile();\n  if (!numGames) {\n    numGames = isMobile ? 20 : 30;\n  }\n  const dataPoints = useMemo(() => {\n    const result = [] as DotPayload[];\n    const totalGames = dataAdapter.getCount();\n    if (!totalGames) {\n      return result;\n    }\n    for (let i = 0; i < Math.min(totalGames, numGames); i++) {\n      const game = dataAdapter.getItem(i);\n      if (!game || !(\"uuid\" in game)) {\n        break;\n      }\n      const rank = GameRecord.getRankIndexByPlayer(game, playerId);\n      result.unshift({\n        pos: 3 - rank,\n        rank,\n        delta: 0,\n        cumulativeDelta: 0,\n        game,\n        playerId,\n      });\n    }\n    let delta = 0;\n    for (const point of result) {\n      const game = point.game;\n      if (!game.modeId) {\n        continue;\n      }\n      const playerRecord = game.players.filter((x) => x.accountId.toString() === playerId.toString())[0];\n      point.delta =\n        typeof playerRecord.gradingScore === \"number\"\n          ? playerRecord.gradingScore\n          : calculateDeltaPoint(playerRecord.score, point.rank, game.modeId, new Level(playerRecord.level));\n      delta += point.delta;\n      point.cumulativeDelta = delta;\n    }\n    return result;\n  }, [dataAdapter, numGames, playerId]);\n  const dot = useMemo(() => createDot(isMobile), [isMobile]);\n  const activeDot = useMemo(() => createActiveDot(isMobile), [isMobile]);\n  if (!dataPoints.length) {\n    return <Loading />;\n  }\n  const haveDelta = dataPoints.some((x) => x.delta !== 0);\n  return (\n    <ResponsiveContainer width=\"100%\" aspect={aspect} height=\"auto\">\n      <LineChart data={dataPoints} margin={{ top: 15, right: 15, bottom: 15, left: 15 }}>\n        <YAxis type=\"number\" domain={[\"dataMin\", \"dataMax\"]} yAxisId={0} hide={true} />\n        <YAxis type=\"number\" domain={[\"dataMin\", \"dataMax\"]} yAxisId={1} hide={true} />\n        {haveDelta && (\n          <Line\n            isAnimationActive={false}\n            dataKey=\"cumulativeDelta\"\n            type=\"linear\"\n            stroke=\"#969696\"\n            strokeWidth={1.5}\n            yAxisId={1}\n            dot={false}\n            activeDot={false}\n            strokeDasharray=\"5 5\"\n          />\n        )}\n        <Line\n          isAnimationActive={false}\n          dataKey=\"pos\"\n          type=\"linear\"\n          stroke=\"#b5c2ce\"\n          strokeWidth={3}\n          dot={dot}\n          activeDot={activeDot}\n        />\n        <Tooltip cursor={false} content={<RankChartTooltip />} />\n      </LineChart>\n    </ResponsiveContainer>\n  );\n});\nexport default RecentRankChart;\n"
  },
  {
    "path": "src/components/playerDetails/charts/winLoseDistribution.tsx",
    "content": "import { PlayerExtendedStats, PlayerMetadata } from \"../../../data/types\";\nimport SimplePieChart, { PieChartItem } from \"../../charts/simplePieChart\";\nimport { sum } from \"../../../utils\";\nimport { formatPercent } from \"../../../utils/index\";\nimport React, { useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Box, Typography, useTheme } from \"@mui/material\";\n\nfunction buildItems(\n  stats: PlayerExtendedStats,\n  keys: (keyof PlayerExtendedStats)[],\n  labels: string[],\n  total = 0\n): PieChartItem[] {\n  total = total || sum(keys.map((key) => (stats[key] as number) || 0));\n  return keys\n    .map((key, index) => ({\n      value: stats[key] as number,\n      outerLabel: labels[index],\n      innerLabel: formatPercent((stats[key] as number) / total),\n    }))\n    .filter((item) => item.value);\n}\n\nconst WinLoseDistribution = React.memo(function ({ stats }: { stats: PlayerExtendedStats; metadata: PlayerMetadata }) {\n  const { t } = useTranslation();\n  const theme = useTheme();\n  const winData = useMemo(\n    () => buildItems(stats, [\"立直和了\", \"副露和了\", \"默听和了\"], [\"立直\", \"副露\", \"默听\"]),\n    [stats]\n  );\n  const loseData = useMemo(\n    () => buildItems(stats, [\"放铳至立直\", \"放铳至副露\", \"放铳至默听\"], [\"立直\", \"副露\", \"默听\"]),\n    [stats]\n  );\n  const loseSelfData = useMemo(() => {\n    const result = buildItems(stats, [\"放铳时立直率\", \"放铳时副露率\"], [\"立直\", \"副露\"], 1);\n    const selfOther = {\n      value: 1 - (stats.放铳时副露率 || 0) - (stats.放铳时立直率 || 0),\n      outerLabel: \"门清\",\n    } as PieChartItem;\n    if (selfOther.value > 0.00001) {\n      selfOther.innerLabel = formatPercent(selfOther.value / 1);\n      result.push(selfOther);\n    }\n    return result.filter((item) => item.value);\n  }, [stats]);\n  return (\n    <Box\n      display=\"grid\"\n      width=\"100%\"\n      gridTemplateColumns={[\"1fr\", \"1fr\", \"1fr 1fr\", \"1fr 1fr 1fr\"]}\n      sx={{\n        gridColumnGap: theme.spacing(2),\n        \"& > .MuiBox-root\": {\n          maxWidth: 480,\n          width: \"100%\",\n          justifySelf: \"center\",\n          overflow: \"hidden\",\n        },\n      }}\n    >\n      <Box>\n        <Typography variant=\"subtitle1\" textAlign=\"center\">\n          {t(\"和牌时\")}\n        </Typography>\n        <SimplePieChart\n          aspect={4 / 3}\n          items={winData}\n          startAngle={-45}\n          innerLabelFontSize=\"0.85rem\"\n          outerLabelOffset={10}\n          outerLabel={(x) => t(x.outerLabel || \"\")}\n        />\n      </Box>\n      <Box>\n        <Typography variant=\"subtitle1\" textAlign=\"center\">\n          {t(\"放铳时\")}\n        </Typography>\n        <SimplePieChart\n          aspect={4 / 3}\n          items={loseSelfData}\n          startAngle={-45}\n          innerLabelFontSize=\"0.85rem\"\n          outerLabelOffset={10}\n          outerLabel={(x) => t(x.outerLabel || \"\")}\n        />\n      </Box>\n      <Box>\n        <Typography variant=\"subtitle1\" textAlign=\"center\">\n          {t(\"放铳至\")}\n        </Typography>\n        <SimplePieChart\n          aspect={4 / 3}\n          items={loseData}\n          startAngle={-45}\n          innerLabelFontSize=\"0.85rem\"\n          outerLabelOffset={10}\n          outerLabel={(x) => t(x.outerLabel || \"\")}\n        />\n      </Box>\n    </Box>\n  );\n});\nexport default WinLoseDistribution;\n"
  },
  {
    "path": "src/components/playerDetails/dateRangeSetting.tsx",
    "content": "import { ReactNode, useEffect, useState } from \"react\";\n\nimport dayjs from \"dayjs\";\nimport {\n  Button,\n  MenuItem,\n  Divider,\n  TextField,\n  Box,\n  useTheme,\n  MenuProps,\n  Popover,\n  MenuListProps,\n  MenuList,\n  styled,\n} from \"@mui/material\";\nimport useMediaQuery from \"@mui/material/useMediaQuery\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport { WatchLater, WatchLaterOutlined } from \"@mui/icons-material\";\nimport { MobileDateTimePicker, MobileDatePicker } from \"@mui/lab\";\nimport Conf from \"../../utils/conf\";\n\nconst NEW_THRONE_TS = dayjs(\"2021-08-26T02:00:00.000Z\");\n\nfunction ResponsiveMenu({ children, ...params }: MenuProps) {\n  const isMobile = useMediaQuery(useTheme().breakpoints.down(\"md\"));\n  return (\n    <Popover\n      anchorOrigin={\n        isMobile ? { vertical: \"center\", horizontal: \"center\" } : { vertical: \"bottom\", horizontal: \"left\" }\n      }\n      transformOrigin={isMobile ? { vertical: \"center\", horizontal: \"center\" } : undefined}\n      {...params}\n      PaperProps={{\n        sx: { maxWidth: \"80vw\", maxHeight: \"90vh\", padding: 1 },\n        ...(params.PaperProps || {}),\n      }}\n    >\n      <Box display=\"flex\" flexDirection={[\"column\", \"column\", \"row\"]} flexWrap=\"wrap\">\n        {children}\n      </Box>\n    </Popover>\n  );\n}\nconst StyledMenuList = styled(MenuList)(({ theme }) => ({\n  padding: 0,\n\n  \"&:last-child .MuiDivider-root:last-child\": {\n    display: \"none\",\n  },\n  [theme.breakpoints.up(\"md\")]: {\n    \".MuiDivider-root:last-child\": {\n      display: \"none\",\n    },\n  },\n}));\nfunction MenuGroup({ children, ...params }: MenuListProps) {\n  return (\n    <StyledMenuList {...params}>\n      {children}\n      <Divider sx={{ my: 1 }} />\n    </StyledMenuList>\n  );\n}\n\nfunction DatePickerMenuItem({\n  onClose,\n  onChange,\n  value,\n  children,\n}: {\n  onClose: () => void;\n  onChange: (value: dayjs.ConfigType) => void;\n  value: dayjs.ConfigType;\n  children: ReactNode;\n}) {\n  const { t, i18n } = useTranslation();\n  const [state, setState] = useState(\"closed\" as \"closed\" | \"date\" | \"datetime\");\n  const [selectedDate, setSelectedDate] = useState(dayjs(value));\n  const [timeEnabled, setTimeEnabled] = useState(false);\n  const [closePending, setClosePending] = useState(false);\n  const open = function () {\n    onClose();\n    setSelectedDate(dayjs(value));\n    setClosePending(false);\n    setState(timeEnabled ? \"datetime\" : \"date\");\n  };\n  useEffect(() => {\n    if (!closePending) {\n      return;\n    }\n    if (timeEnabled && state === \"date\") {\n      setClosePending(false);\n      setState(\"datetime\");\n      return;\n    }\n    setState(\"closed\");\n  }, [closePending, state, timeEnabled]);\n  return (\n    <>\n      <MenuItem dense onClick={open}>\n        {children}\n      </MenuItem>\n      <Box display=\"none\">\n        {timeEnabled ? (\n          <MobileDateTimePicker\n            open={state !== \"closed\"}\n            ampm={false}\n            onClose={() => setClosePending(true)}\n            renderInput={(params) => <TextField {...params} />}\n            value={selectedDate}\n            onAccept={onChange}\n            onChange={(newDate) => setSelectedDate(dayjs(newDate))}\n            minDateTime={dayjs(Conf.dateMin)}\n            maxDateTime={dayjs().endOf(\"day\")}\n            cancelText={\"\"}\n            okText={t(\"确定\")}\n            mask=\"____-__-__ __:__\"\n            toolbarTitle=\"\"\n            toolbarFormat={i18n.language === \"en\" ? \"MMM D\" : \"M/D\"}\n            disableCloseOnSelect\n          />\n        ) : (\n          <MobileDatePicker\n            open={state !== \"closed\"}\n            onClose={() => setClosePending(true)}\n            renderInput={(params) => <TextField {...params} />}\n            value={selectedDate}\n            onAccept={(newDate) => void (newDate ? onChange(newDate) : setTimeEnabled(true))}\n            clearable\n            onChange={(newDate) => void (newDate ? setSelectedDate(newDate) : setTimeEnabled(true))}\n            minDate={dayjs(Conf.dateMin)}\n            maxDate={dayjs().endOf(\"day\")}\n            cancelText={\"\"}\n            okText={\"\"}\n            clearText={t(\"自定义时间\")}\n            mask=\"____-__-__\"\n            toolbarTitle=\"\"\n            toolbarFormat={\" \"}\n            disableCloseOnSelect={false}\n          />\n        )}\n      </Box>\n    </>\n  );\n}\n\nexport default function DateRangeSetting({\n  onSelectDate,\n  onSelectLimit,\n  start,\n  end,\n  limit,\n  isThrone,\n}: {\n  onSelectDate: (start: dayjs.ConfigType | null, end: dayjs.ConfigType | null) => void;\n  onSelectLimit: (limit: number) => void;\n  start: dayjs.ConfigType | null;\n  end: dayjs.ConfigType | null;\n  limit: number | null;\n  isThrone: boolean;\n}) {\n  const [anchorEl, setAnchorEl] = useState(null as HTMLElement | null);\n  const handleClose = () => setAnchorEl(null);\n  const selectAll = () => {\n    onSelectDate(null, null);\n    handleClose();\n  };\n  const selectWeek = (week: number) => {\n    onSelectDate(\n      dayjs()\n        .subtract(week * 7 - 1, \"day\")\n        .startOf(\"day\"),\n      null\n    );\n    handleClose();\n  };\n  const selectLimit = (limit: number) => {\n    onSelectLimit(limit);\n    handleClose();\n  };\n  const selectRange = (start: dayjs.Dayjs, end: dayjs.Dayjs | null = null) => {\n    onSelectDate(start, end);\n    handleClose();\n  };\n  const haveCustomRange = start || end || limit;\n  const shouldRenderTime =\n    (start && !dayjs(start).startOf(\"day\").isSame(start, \"second\")) ||\n    (end && !dayjs(end).endOf(\"day\").isSame(end, \"second\"));\n  const format = shouldRenderTime ? \"YYYY-MM-DD HH:mm\" : \"YYYY-MM-DD\";\n  const isNewThrone = isThrone && !end && start && dayjs(start).isSame(NEW_THRONE_TS);\n  const isOldThrone =\n    isThrone && end && (!start || !dayjs(start).isAfter(Conf.dateMin)) && dayjs(end).isSame(NEW_THRONE_TS);\n  return (\n    <div>\n      <Button\n        disableElevation\n        variant={haveCustomRange ? \"outlined\" : \"text\"}\n        onClick={(e) => setAnchorEl(e.currentTarget)}\n        startIcon={haveCustomRange ? <WatchLater /> : <WatchLaterOutlined />}\n      >\n        {haveCustomRange ? (\n          isNewThrone ? (\n            <Trans>新王座</Trans>\n          ) : isOldThrone ? (\n            <Trans>旧王座</Trans>\n          ) : limit ? (\n            <Trans defaults=\"最近 {{x}} 场\" count={limit} values={{ x: limit }} />\n          ) : (\n            `${dayjs(start || Conf.dateMin).format(format)} ~ ${dayjs(end || undefined).format(format)}`\n          )\n        ) : (\n          <Trans>时间</Trans>\n        )}\n      </Button>\n      <ResponsiveMenu anchorEl={anchorEl} open={!!anchorEl} onClose={handleClose} keepMounted>\n        <MenuGroup>\n          <MenuItem dense onClick={selectAll}>\n            <Trans>全部</Trans>\n          </MenuItem>\n          <Divider />\n          <DatePickerMenuItem\n            onClose={handleClose}\n            value={start || dayjs(Conf.dateMin)}\n            onChange={(date) => onSelectDate(date, end || dayjs().endOf(\"day\"))}\n          >\n            <Trans>自定开始时间...</Trans>\n          </DatePickerMenuItem>\n          <DatePickerMenuItem\n            onClose={handleClose}\n            value={end || dayjs().endOf(\"day\")}\n            onChange={(date) => onSelectDate(start || dayjs(Conf.dateMin), dayjs(date).endOf(\"minute\"))}\n          >\n            <Trans>自定结束时间...</Trans>\n          </DatePickerMenuItem>\n        </MenuGroup>\n        <MenuGroup>\n          {[4, 13, 26, 52].map((x) => (\n            <MenuItem dense key={x} onClick={() => selectWeek(x)}>\n              <Trans defaults=\"最近 {{x}} 周\" count={x} values={{ x }} />\n            </MenuItem>\n          ))}\n        </MenuGroup>\n        <MenuGroup>\n          <MenuItem dense onClick={() => selectRange(dayjs().startOf(\"month\"))}>\n            <Trans>本月</Trans>\n          </MenuItem>\n          <MenuItem\n            dense\n            onClick={() =>\n              selectRange(dayjs().startOf(\"month\").subtract(1, \"month\"), dayjs().startOf(\"month\").subtract(1, \"second\"))\n            }\n          >\n            <Trans>上月</Trans>\n          </MenuItem>\n          <MenuItem dense onClick={() => selectRange(dayjs().startOf(\"year\"))}>\n            <Trans>今年</Trans>\n          </MenuItem>\n          <MenuItem\n            dense\n            onClick={() =>\n              selectRange(dayjs().startOf(\"year\").subtract(1, \"year\"), dayjs().startOf(\"year\").subtract(1, \"second\"))\n            }\n          >\n            <Trans>去年</Trans>\n          </MenuItem>\n        </MenuGroup>\n        <MenuGroup>\n          {[100, 200, 300, 500].map((x) => (\n            <MenuItem dense key={x} onClick={() => selectLimit(x)}>\n              <Trans defaults=\"最近 {{x}} 场\" count={x} values={{ x }} />\n            </MenuItem>\n          ))}\n        </MenuGroup>\n        {isThrone && (\n          <MenuGroup>\n            <MenuItem dense onClick={() => selectRange(NEW_THRONE_TS)}>\n              <Trans>新王座</Trans>\n            </MenuItem>\n            <MenuItem dense onClick={() => selectRange(dayjs(Conf.dateMin), NEW_THRONE_TS)}>\n              <Trans>旧王座</Trans>\n            </MenuItem>\n          </MenuGroup>\n        )}\n      </ResponsiveMenu>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/playerDetails/estimatedStableLevel.tsx",
    "content": "import React from \"react\";\nimport { LevelWithDelta, PlayerMetadata, GameMode, Level, modeLabel } from \"../../data/types\";\nimport { useModel } from \"../gameRecords/model\";\nimport StatItem from \"./statItem\";\nimport Conf from \"../../utils/conf\";\nimport { useTranslation } from \"react-i18next\";\nimport { formatFixed3 } from \"../../utils\";\nimport { Box } from \"@mui/material\";\n\nconst ENABLED_MODES = [\n  GameMode.玉,\n  GameMode.王座,\n  GameMode.三玉,\n  GameMode.三王座,\n  GameMode.王东,\n  GameMode.玉东,\n  GameMode.三王东,\n  GameMode.三玉东,\n];\n\nexport default function EstimatedStableLevel({ metadata }: { metadata: PlayerMetadata }) {\n  const [model] = useModel();\n  const { t } = useTranslation();\n  if (!Conf.features.estimatedStableLevel) {\n    return null;\n  }\n  let level = LevelWithDelta.getAdjustedLevel(metadata.cross_stats?.level || metadata.level);\n  if (!(\"selectedModes\" in model) || model.selectedModes.length !== 1) {\n    return null;\n  }\n  const mode = model.selectedModes[0];\n  if (!ENABLED_MODES.includes(mode)) {\n    return null;\n  }\n  if (!level.isAllowedMode(mode)) {\n    level = LevelWithDelta.getAdjustedLevel(metadata.level);\n  }\n  const notEnoughData = metadata.count < 100;\n  const expectedGamePoint = PlayerMetadata.calculateExpectedGamePoint(metadata, mode);\n  let estimatedNumGamesToChangeLevel = null as number | null;\n  if (level.getMaxPoint() && level.isAllowedMode(mode)) {\n    const curPoint = level.isSame(new Level(metadata.level.id))\n      ? metadata.level.score + metadata.level.delta\n      : level.getStartingPoint();\n    estimatedNumGamesToChangeLevel =\n      expectedGamePoint > 0 ? (level.getMaxPoint() - curPoint) / expectedGamePoint : curPoint / expectedGamePoint;\n  }\n  const changeLevelMsg = estimatedNumGamesToChangeLevel\n    ? t(\"，括号内为预计{{ label }}段场数\", { label: estimatedNumGamesToChangeLevel > 0 ? t(\"升\") : t(\"降\") })\n    : \"\";\n  const levelComponents = PlayerMetadata.getStableLevelComponents(metadata, mode);\n  const levelNames = \"一二三四\".slice(0, levelComponents.length);\n  const modeL = modeLabel(mode);\n  return (\n    <>\n      <StatItem\n        label=\"安定段位\"\n        description={\n          <Box>\n            {`${t(\"在{{ modeL }}之间一直进行对局，预测最终能达到的段位。\", { modeL })}${\n              levelNames.length === 3 ? t(\"括号内为安定段位时的分数期望。\") : \"\"\n            }${notEnoughData ? t(\"（数据量不足，计算结果可能有较大偏差）\") : \"\"}`}\n            {!level.isKonten() && (\n              <>\n                <br />\n                {`${t(\"{{ levelNames1 }}位平均 Pt / {{ levelName2 }}位平均得点 Pt：\", {\n                  levelNames1: t(levelNames.slice(0, levelNames.length - 1)),\n                  levelName2: t(levelNames[levelNames.length - 1]),\n                })}[${levelComponents.map((x) => x.toFixed(2)).join(\"/\")}]`}\n              </>\n            )}\n            <br />\n            {`${t(\"得点效率（各顺位平均 Pt 及平均得点 Pt 的加权平均值）：\")}${formatFixed3(\n              PlayerMetadata.calculateExpectedGamePoint(metadata, mode, undefined, false)\n            )}`}\n          </Box>\n        }\n        valueProps={notEnoughData ? { fontStyle: \"italic\", fontWeight: 300, sx: { opacity: 0.5 } } : {}}\n      >\n        <span>\n          {PlayerMetadata.estimateStableLevel2(metadata, mode)}\n          {notEnoughData && \"?\"}\n        </span>\n      </StatItem>\n      <StatItem\n        label=\"分数期望\"\n        description={`${t(\"在{{ modeL }}之间每局获得点数的数学期望值{{ changeLevelMsg }}\", {\n          changeLevelMsg,\n          modeL,\n        })}${notEnoughData ? t(\"（数据量不足，计算结果可能有较大偏差）\") : \"\"}`}\n        valueProps={notEnoughData ? { fontStyle: \"italic\", fontWeight: 300, sx: { opacity: 0.5 } } : {}}\n      >\n        <span>\n          {level.isKonten() && level.isAllowedMode(mode)\n            ? (expectedGamePoint / 100).toFixed(3)\n            : expectedGamePoint.toFixed(1)}\n          {estimatedNumGamesToChangeLevel && Math.abs(estimatedNumGamesToChangeLevel) < 10000\n            ? ` (${Math.abs(estimatedNumGamesToChangeLevel).toFixed(0)})`\n            : \"\"}\n          {notEnoughData && \"?\"}\n        </span>\n      </StatItem>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/playerDetails/extraSettings.tsx",
    "content": "import { Close, Done, FilterAlt } from \"@mui/icons-material\";\nimport {\n  Box,\n  Button,\n  ButtonGroup,\n  Checkbox,\n  Dialog,\n  DialogActions,\n  DialogContent,\n  DialogTitle,\n  FormControlLabel,\n  IconButton,\n  TextField,\n} from \"@mui/material\";\nimport React, { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { GameMode, getRankLabelByIndexRaw } from \"../../data/types\";\nimport Conf from \"../../utils/conf\";\nimport { CheckboxGroup } from \"../form\";\nimport { Model, useModel } from \"../gameRecords/model\";\n\nconst RANK_ITEMS = [\n  {\n    key: \"All\",\n    label: \"全部\",\n    value: \"全部\",\n  },\n].concat(\n  Conf.rankColors.map((_, index) => ({\n    key: (index + 1).toString(),\n    label: getRankLabelByIndexRaw(index),\n    value: (index + 1).toString(),\n  }))\n);\n\nfunction ExtraSettingsBody({ model, updateModel }: { model: Model; updateModel: (model: Partial<Model>) => void }) {\n  const { t } = useTranslation(\"form\");\n  const updateSearchTextFromEvent = useCallback(\n    (e: React.ChangeEvent<HTMLInputElement>) => updateModel({ type: \"player\", searchText: e.currentTarget.value }),\n    [updateModel]\n  );\n  const setRank = useCallback(\n    (rank: string) => updateModel({ type: \"player\", rank: parseInt(rank) || null }),\n    [updateModel]\n  );\n  const setKontenOnly = useCallback(\n    (kontenOnly: boolean) => updateModel({ type: \"player\", kontenOnly }),\n    [updateModel]\n  );\n  if (!(\"rank\" in model)) {\n    return <></>;\n  }\n  return (\n    <>\n      <CheckboxGroup\n        type=\"radio\"\n        label=\"顺位\"\n        selectedItems={[(model.rank || \"All\").toString()]}\n        items={RANK_ITEMS}\n        onChange={(items) => setRank(items[0].key)}\n      />\n      <Box mt={2}>\n        <TextField\n          fullWidth\n          label={t(\"查找玩家\")}\n          value={model.searchText || \"\"}\n          onChange={updateSearchTextFromEvent}\n        />\n      </Box>\n      <Box mt={2}>\n        <FormControlLabel\n          label={t(\"巅峰对决\").toString()}\n          control={\n            <Checkbox\n              disabled={\n                !model.selectedModes.every((x) =>\n                  [GameMode.王座, GameMode.王东, GameMode.三王座, GameMode.三王东].includes(x)\n                )\n              }\n              checked={model.kontenOnly || false}\n              onChange={(e) => setKontenOnly(e.target.checked)}\n            />\n          }\n        />\n      </Box>\n    </>\n  );\n}\n\nfunction ExtraSettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {\n  const { t } = useTranslation();\n  const [globalModel, globalUpdateModel] = useModel();\n  const [modelChanges, setModelChanges] = useState<Partial<Model>>({});\n  useEffect(() => {\n    setModelChanges({});\n  }, [globalModel]);\n  const mergedModel = useMemo<Model>(() => ({ ...globalModel, ...modelChanges } as Model), [globalModel, modelChanges]);\n  const onSubmit = useCallback(() => {\n    if (modelChanges.type === \"player\") {\n      globalUpdateModel(modelChanges);\n    }\n    onClose();\n  }, [globalUpdateModel, modelChanges, onClose]);\n  const onUpdateModel = useCallback(\n    (changes: Partial<Model>) => setModelChanges((prev) => ({ ...prev, ...changes })),\n    [setModelChanges]\n  );\n  return (\n    <Dialog open={open} disableEscapeKeyDown>\n      <DialogTitle>{t(\"筛选\")}</DialogTitle>\n      <DialogContent>\n        <ExtraSettingsBody model={mergedModel} updateModel={onUpdateModel} />\n      </DialogContent>\n      <DialogActions>\n        <IconButton size=\"large\" onClick={onSubmit}>\n          <Done />\n        </IconButton>\n      </DialogActions>\n    </Dialog>\n  );\n}\n\nexport default function ExtraSettings() {\n  const { t } = useTranslation();\n  const [open, setOpen] = useState(false);\n  const [model, updateModel] = useModel();\n  const extraSettingsEnabled = Model.hasAdvancedParams(model);\n  return (\n    <Box alignSelf={[undefined, undefined, \"flex-end\"]}>\n      {extraSettingsEnabled ? (\n        <ButtonGroup variant=\"contained\">\n          <Button disableElevation startIcon={<FilterAlt />} onClick={() => setOpen(true)}>\n            {t(\"筛选\")}\n          </Button>\n          <Button\n            size=\"small\"\n            onClick={() =>\n              updateModel({\n                type: \"player\",\n                rank: null,\n                searchText: \"\",\n                kontenOnly: false,\n              })\n            }\n          >\n            <Close />\n          </Button>\n        </ButtonGroup>\n      ) : (\n        <Button disableElevation startIcon={<FilterAlt />} onClick={() => setOpen(true)}>\n          {t(\"筛选\")}\n        </Button>\n      )}\n\n      <ExtraSettingsDialog open={open} onClose={() => setOpen(false)}></ExtraSettingsDialog>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/components/playerDetails/histogram.tsx",
    "content": "import { Box, Typography, useTheme } from \"@mui/material\";\nimport React, { SVGAttributes } from \"react\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport { getGlobalHistogram } from \"../../data/source/misc\";\n\nimport { GameMode, HistogramData, HistogramGroup, modeLabelNonTranslated, PlayerExtendedStats } from \"../../data/types\";\nimport { formatPercent, sum } from \"../../utils\";\nimport { useAsyncFactory } from \"../../utils/async\";\nimport { useModel } from \"../gameRecords/model\";\n\nconst VIEWBOX_HEIGHT = 40;\n\nfunction generatePath(bins: number[], barMax: number, start: number) {\n  return `M ${start} 0 ` + bins.map((bin) => `h 1 V ${(bin / barMax) * VIEWBOX_HEIGHT}`).join(\" \") + \" V 0 Z\";\n}\n\nfunction shouldUseClamped(value: number | undefined, data: HistogramGroup) {\n  return (\n    typeof value !== \"number\" ||\n    (data.histogramClamped && value >= data.histogramClamped.min && value <= data.histogramClamped.max)\n  );\n}\n\nfunction getValueAccumulation(value: number, data: HistogramData) {\n  const binStep = (data.max - data.min) / data.bins.length;\n  const bin = Math.floor((value - data.min) / binStep);\n  if (bin < 0) {\n    return 0;\n  }\n  if (bin >= data.bins.length) {\n    return sum(data.bins);\n  }\n  return sum(data.bins.slice(0, bin)) + data.bins[bin] * ((value - (data.min + binStep * bin)) / binStep);\n}\nconst Histogram = React.memo(function ({\n  data,\n  value,\n  extraMeanLines = [],\n}: {\n  data?: HistogramGroup;\n  value?: number;\n  extraMeanLines?: number[];\n}) {\n  const theme = useTheme();\n  if (!data) {\n    return <></>;\n  }\n  const histogram = shouldUseClamped(value, data) ? data.histogramClamped : data.histogramFull;\n  if (!histogram) {\n    return <></>;\n  }\n  if (value !== undefined) {\n    value = Math.max(histogram.min, Math.min(histogram.max, value));\n  }\n  const barMax = Math.max(...histogram.bins);\n  const binStep = (histogram.max - histogram.min) / histogram.bins.length;\n  const splitPoint = value === undefined ? histogram.bins.length : Math.ceil((value - histogram.min) / binStep);\n  const ValueLine = ({ v, ...props }: { v: number } & SVGAttributes<SVGLineElement>) => {\n    if (v < histogram.min || v > histogram.max) {\n      return <></>;\n    }\n    const bin = Math.floor((v - histogram.min) / binStep);\n    return (\n      <line\n        key={v}\n        x1={bin}\n        x2={bin}\n        y1={0}\n        y2={VIEWBOX_HEIGHT}\n        stroke={theme.palette.grey[50]}\n        strokeWidth={1}\n        {...props}\n      />\n    );\n  };\n  return (\n    <svg\n      width={120}\n      height={VIEWBOX_HEIGHT}\n      viewBox={`0 0 ${histogram.bins.length} ${VIEWBOX_HEIGHT}`}\n      preserveAspectRatio=\"none\"\n    >\n      <g style={{ transformOrigin: \"center\", transform: \"scale(1, -1)\" }}>\n        <path\n          d={generatePath(histogram.bins.slice(0, splitPoint), barMax, 0)}\n          strokeWidth={1}\n          fillRule=\"nonzero\"\n          stroke={theme.palette.grey[500]}\n          fill={theme.palette.grey[500]}\n        />\n        {splitPoint < histogram.bins.length && (\n          <path\n            d={generatePath(histogram.bins.slice(splitPoint), barMax, splitPoint)}\n            strokeWidth={1}\n            fillRule=\"nonzero\"\n            stroke={theme.palette.grey[800]}\n            fill={theme.palette.grey[800]}\n          />\n        )}\n        {!Number.isInteger(binStep) && histogram.bins.length > 60 && (\n          <g>\n            <ValueLine v={data.mean} />\n            {extraMeanLines.map((v, index) => (\n              <ValueLine key={index} v={v} strokeDasharray=\"4 12\" strokeDashoffset={index * 3} />\n            ))}\n          </g>\n        )}\n      </g>\n    </svg>\n  );\n});\n\nconst StatHistogramInner = React.memo(function ({\n  mode,\n  value,\n  valueFormatter,\n  rankMeans,\n  histogramData,\n}: {\n  mode: GameMode;\n  value?: number;\n  valueFormatter: (value: number) => string;\n  rankMeans: number[];\n  histogramData: Omit<HistogramGroup, \"histogramFull\"> & Required<Pick<HistogramGroup, \"histogramFull\">>;\n}) {\n  const { t } = useTranslation();\n  const numTotal = sum(histogramData.histogramFull.bins);\n  const numPos =\n    value === undefined\n      ? 0\n      : shouldUseClamped(value, histogramData) && histogramData.histogramClamped\n      ? getValueAccumulation(value, histogramData.histogramClamped) +\n        getValueAccumulation(histogramData.histogramClamped.min, histogramData.histogramFull)\n      : getValueAccumulation(value, histogramData.histogramFull);\n  return (\n    <Box>\n      <Typography variant=\"inherit\">\n        <Trans defaults=\"{{mode}}平均值：\" values={{ mode: t(modeLabelNonTranslated(mode)) }} />\n        {valueFormatter(histogramData.mean)}\n      </Typography>\n      <Typography variant=\"inherit\" mb={2}>\n        <Trans defaults=\"{{mode}}各段位平均值：\" values={{ mode: t(modeLabelNonTranslated(mode)) }} />\n        {rankMeans.map(valueFormatter).join(\" / \")}\n      </Typography>\n      <Histogram data={histogramData} value={value} extraMeanLines={rankMeans} />\n      {value !== undefined && (\n        <Typography variant=\"inherit\">\n          <Trans defaults=\"{{mode}}位置：\" values={{ mode: t(modeLabelNonTranslated(mode)) }} />\n          {formatPercent(numPos / numTotal)}\n        </Typography>\n      )}\n    </Box>\n  );\n});\n\nexport function useStatHistogram({\n  statKey,\n  value,\n  valueFormatter,\n}: {\n  statKey: keyof PlayerExtendedStats;\n  value?: number;\n  valueFormatter: (value: number) => string;\n}) {\n  const [model] = useModel();\n  const globalHistogram = useAsyncFactory(() => getGlobalHistogram().catch(() => null), [], \"globalHistogram\");\n  if (!globalHistogram || model.type !== \"player\" || model.selectedModes.length !== 1) {\n    return null;\n  }\n  const mode = model.selectedModes[0];\n  const modeHistogram = globalHistogram[mode];\n  if (!modeHistogram || !(statKey in modeHistogram[\"0\"])) {\n    return null;\n  }\n  const histogramData = modeHistogram[\"0\"][statKey];\n  if (!histogramData?.histogramFull) {\n    return null;\n  }\n  const rankMeans = Object.keys(modeHistogram)\n    .map((x) => parseInt(x, 10))\n    .filter((x) => x)\n    .sort((a, b) => a - b)\n    .map((x) => modeHistogram[x][statKey]?.mean)\n    .filter((x) => x !== undefined) as number[];\n  return (\n    <StatHistogramInner\n      mode={mode}\n      value={value}\n      valueFormatter={valueFormatter}\n      rankMeans={rankMeans}\n      histogramData={{ ...histogramData, histogramFull: histogramData.histogramFull }}\n    />\n  );\n}\n\nexport const StatHistogram = React.memo(function ({\n  statKey,\n  value,\n  valueFormatter,\n}: {\n  statKey: keyof PlayerExtendedStats;\n  value?: number;\n  valueFormatter: (value: number) => string;\n}) {\n  return useStatHistogram({ statKey, value, valueFormatter });\n});\n\nexport default Histogram;\n"
  },
  {
    "path": "src/components/playerDetails/playerDetails.tsx",
    "content": "import React, { ReactNode, useCallback, useMemo, useState } from \"react\";\nimport Loadable from \"../misc/customizedLoadable\";\nimport { Helmet } from \"react-helmet\";\n\nimport { useDataAdapter } from \"../gameRecords/dataAdapterProvider\";\nimport { useEffect } from \"react\";\nimport { triggerRelayout, formatPercent, formatFixed3, formatRound, formatIdentity } from \"../../utils/index\";\nimport { useAsync } from \"../../utils/async\";\nimport {\n  LevelWithDelta,\n  PlayerExtendedStats,\n  PlayerMetadata,\n  GameRecord,\n  FanStatEntry2,\n  FanStatEntryList,\n  getAccountZoneTag,\n} from \"../../data/types\";\nimport Loading from \"../misc/loading\";\nimport PlayerDetailsSettings from \"./playerDetailsSettings\";\nimport StatItem, { StatList } from \"./statItem\";\nimport EstimatedStableLevel from \"./estimatedStableLevel\";\nimport { Level } from \"../../data/types/level\";\nimport { ViewRoutes, RouteDef, SimpleRoutedSubViews, NavButtons, ViewSwitch } from \"../routing\";\nimport SameMatchRate from \"./sameMatchRate\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport { Model, useModel } from \"../gameRecords/model\";\nimport Conf from \"../../utils/conf\";\nimport { GameMode } from \"../../data/types/gameMode\";\nimport { loadPlayerPreference } from \"../../utils/preference\";\nimport { Box, BoxProps, Grid, Link, Typography } from \"@mui/material\";\nimport { useStatHistogram } from \"./histogram\";\nimport StarButton from \"./star/starButton\";\nimport { networkError } from \"../../utils/notify\";\n\nconst RankRateChart = Loadable({\n  loader: () => import(\"./charts/rankRate\"),\n});\nconst RecentRankChart = Loadable({\n  loader: () => import(\"./charts/recentRank\"),\n});\nconst WinLoseDistribution = Loadable({\n  loader: () => import(\"./charts/winLoseDistribution\"),\n});\n\nfunction GenericStat({\n  stats,\n  statKey,\n  description,\n  formatter,\n  formatterHistogram,\n  label,\n  disableHistogram,\n  defaultValue = 0,\n  hideValue = false,\n}: {\n  stats: PlayerExtendedStats;\n  statKey: keyof PlayerExtendedStats;\n  description?: ReactNode;\n  formatter: (value: number) => string;\n  formatterHistogram?: (value: number) => string;\n  label?: string;\n  disableHistogram?: boolean;\n  defaultValue?: number | string;\n  hideValue?: boolean;\n}) {\n  const value = stats[statKey] ?? defaultValue;\n  if (typeof value !== \"number\" && value !== defaultValue) {\n    throw new Error(`${statKey} is not a number`);\n  }\n  const extraTip = useCallback(() => {\n    // eslint-disable-next-line react-hooks/rules-of-hooks\n    const ret = useStatHistogram({\n      statKey,\n      valueFormatter: formatterHistogram || formatter,\n      value: typeof value === \"number\" ? value : undefined,\n    });\n    if (disableHistogram) {\n      return null;\n    }\n    return stats.count > 100 ? ret : null;\n  }, [statKey, formatterHistogram, formatter, value, disableHistogram, stats.count]);\n  return (\n    <StatItem description={description} label={label || statKey} extraTip={extraTip}>\n      {hideValue ? \"\" : typeof value === \"string\" ? value : formatter(value)}\n    </StatItem>\n  );\n}\n\nfunction ExtendedStatsViewAsync({\n  metadata,\n  view,\n  hasAdvancedParams,\n}: {\n  metadata: PlayerMetadata;\n  view: React.ComponentType<{ stats: PlayerExtendedStats; metadata: PlayerMetadata; hasAdvancedParams?: boolean }>;\n  hasAdvancedParams: boolean;\n}) {\n  const stats = useAsync(metadata.extended_stats);\n  useEffect(triggerRelayout, [!!stats]);\n  if (!stats) {\n    return null;\n  }\n  const View = view;\n  return <View stats={stats} metadata={metadata} hasAdvancedParams={hasAdvancedParams} />;\n}\n\nfunction PlayerExtendedStatsView({ stats }: { stats: PlayerExtendedStats }) {\n  return (\n    <>\n      <GenericStat stats={stats} formatter={formatPercent} statKey=\"和牌率\" description=\"和牌局数 / 总局数\" />\n      <GenericStat stats={stats} formatter={formatPercent} statKey=\"放铳率\" description=\"放铳局数 / 总局数\" />\n      <GenericStat stats={stats} formatter={formatPercent} statKey=\"自摸率\" description=\"自摸局数 / 和牌局数\" />\n      <GenericStat\n        stats={stats}\n        formatter={formatPercent}\n        statKey=\"默听率\"\n        label=\"默胡率\"\n        description=\"门清默听和牌局数 / 和牌局数\"\n      />\n      <GenericStat stats={stats} formatter={formatPercent} statKey=\"流局率\" description=\"流局局数 / 总局数\" />\n      <GenericStat stats={stats} formatter={formatPercent} statKey=\"流听率\" description=\"流局听牌局数 / 流局局数\" />\n      <GenericStat stats={stats} formatter={formatPercent} statKey=\"副露率\" description=\"副露局数 / 总局数\" />\n      <GenericStat stats={stats} formatter={formatPercent} statKey=\"立直率\" description=\"立直局数 / 总局数\" />\n      <GenericStat stats={stats} formatter={formatFixed3} statKey=\"和了巡数\" />\n      <GenericStat stats={stats} formatter={formatRound} statKey=\"平均打点\" />\n      <GenericStat stats={stats} formatter={formatRound} statKey=\"平均铳点\" />\n    </>\n  );\n}\n\nfunction fixMaxLevel(level: LevelWithDelta): LevelWithDelta {\n  const levelObj = new Level(level.id);\n  if (level.score + level.delta < levelObj.getStartingPoint()) {\n    return {\n      id: level.id,\n      score: levelObj.getStartingPoint(),\n      delta: 0,\n    };\n  }\n  return level;\n}\n\nfunction MoreStats({\n  stats,\n  metadata,\n  hasAdvancedParams,\n}: {\n  stats: PlayerExtendedStats;\n  metadata: PlayerMetadata;\n  hasAdvancedParams?: boolean;\n}) {\n  const { t } = useTranslation();\n  return (\n    <>\n      {!hasAdvancedParams && (\n        <>\n          <StatItem label=\"最高等级\">\n            {LevelWithDelta.getTag(metadata.cross_stats?.max_level || metadata.max_level)}\n          </StatItem>\n          <StatItem label=\"最高分数\">\n            {LevelWithDelta.formatAdjustedScore(fixMaxLevel(metadata.cross_stats?.max_level || metadata.max_level))}\n          </StatItem>\n        </>\n      )}\n      <GenericStat stats={stats} formatter={formatIdentity} formatterHistogram={formatFixed3} statKey=\"最大连庄\" />\n      <GenericStat stats={stats} formatter={formatPercent} statKey=\"里宝率\" description=\"中里宝局数 / 立直和了局数\" />\n      <GenericStat\n        stats={stats}\n        formatter={formatPercent}\n        statKey=\"被炸率\"\n        description=\"被炸庄（满贯或以上）次数 / 被自摸次数\"\n      />\n      <GenericStat\n        stats={stats}\n        formatter={formatRound}\n        statKey=\"平均被炸点数\"\n        description=\"被炸庄（满贯或以上）点数 / 次数\"\n      />\n      <GenericStat\n        stats={stats}\n        formatter={formatPercent}\n        statKey=\"放铳时立直率\"\n        description=\"放铳时立直次数 / 放铳次数\"\n      />\n      <GenericStat\n        stats={stats}\n        formatter={formatPercent}\n        statKey=\"放铳时副露率\"\n        description=\"放铳时副露次数 / 放铳次数\"\n      />\n      <GenericStat\n        stats={stats}\n        formatter={formatPercent}\n        statKey=\"副露后放铳率\"\n        description=\"放铳时副露次数 / 副露次数\"\n      />\n      <GenericStat\n        stats={stats}\n        formatter={formatPercent}\n        statKey=\"副露后和牌率\"\n        description=\"副露后和牌次数 / 副露次数\"\n      />\n      <GenericStat\n        stats={stats}\n        formatter={formatPercent}\n        statKey=\"副露后流局率\"\n        description=\"副露后流局次数 / 副露次数\"\n      />\n      <GenericStat\n        stats={stats}\n        formatter={formatRound}\n        defaultValue=\"\"\n        statKey=\"打点效率\"\n        description={`${t(\"和牌率\")} * ${t(\"平均打点\")}`}\n      />\n      <GenericStat\n        stats={stats}\n        formatter={formatRound}\n        defaultValue=\"\"\n        statKey=\"铳点损失\"\n        description={`${t(\"放铳率\")} * ${t(\"平均铳点\")}`}\n      />\n      <GenericStat\n        stats={stats}\n        formatter={formatRound}\n        defaultValue=\"\"\n        statKey=\"净打点效率\"\n        description={`${t(\"和牌率\")} * ${t(\"平均打点\")} - ${t(\"放铳率\")} * ${t(\"平均铳点\")}`}\n      />\n      <GenericStat\n        stats={stats}\n        formatter={formatRound}\n        defaultValue=\"\"\n        statKey=\"局收支\"\n        description={`(${t(\"场平均素点\")} - ${t(\"场起始素点\")}) * ${t(\"记录场数\")} / ${t(\"总计局数\")}`}\n      />\n      <StatItem label=\"总计局数\">{stats.count}</StatItem>\n    </>\n  );\n}\nfunction RiichiStats({ stats }: { stats: PlayerExtendedStats; metadata: PlayerMetadata }) {\n  return (\n    <>\n      <GenericStat stats={stats} formatter={formatPercent} statKey=\"立直率\" description=\"立直局数 / 总局数\" />\n      <GenericStat\n        stats={stats}\n        formatter={formatPercent}\n        statKey=\"立直后和牌率\"\n        label=\"立直和了\"\n        description=\"立直和了局数 / 立直局数\"\n      />\n      <GenericStat\n        stats={stats}\n        formatter={formatPercent}\n        label=\"立直放铳A\"\n        statKey=\"立直后放铳率\"\n        description=\"立直放铳局数（含立直瞬间） / 立直局数\"\n      />\n      <GenericStat\n        stats={stats}\n        formatter={formatPercent}\n        label=\"立直放铳B\"\n        statKey=\"立直后非瞬间放铳率\"\n        description=\"立直放铳局数（不含立直瞬间） / 立直局数\"\n      />\n      <GenericStat\n        stats={stats}\n        formatter={formatRound}\n        statKey=\"立直收支\"\n        description=\"立直总收支（含供托） / 立直局数\"\n      />\n      <GenericStat\n        stats={stats}\n        formatter={formatRound}\n        statKey=\"立直收入\"\n        description=\"立直和了收入（含供托） / 立直和了局数\"\n      />\n      <GenericStat\n        stats={stats}\n        formatter={formatRound}\n        statKey=\"立直支出\"\n        description=\"立直放铳支出（含立直棒） / 立直放铳局数\"\n      />\n      <GenericStat stats={stats} formatter={formatPercent} statKey=\"先制率\" description=\"先制立直局数 / 立直局数\" />\n      <GenericStat stats={stats} formatter={formatPercent} statKey=\"追立率\" description=\"追立局数 / 立直局数\" />\n      <GenericStat stats={stats} formatter={formatPercent} statKey=\"被追率\" description=\"被追立局数 / 立直局数\" />\n      <GenericStat stats={stats} formatter={formatFixed3} statKey=\"立直巡目\" />\n      <GenericStat\n        stats={stats}\n        formatter={formatPercent}\n        label=\"立直流局\"\n        statKey=\"立直后流局率\"\n        description=\"立直流局局数 / 立直局数\"\n      />\n      <GenericStat stats={stats} formatter={formatPercent} statKey=\"一发率\" description=\"一发局数 / 立直和了局数\" />\n      <GenericStat\n        stats={stats}\n        formatter={formatPercent}\n        label=\"振听率\"\n        statKey=\"振听立直率\"\n        description=\"振听立直局数（不含立直见逃） / 立直局数\"\n      />\n      {(stats.立直多面 || stats.立直多面 === 0) && (\n        <GenericStat\n          stats={stats}\n          formatter={formatPercent}\n          statKey=\"立直多面\"\n          description={\n            <Box>\n              <Trans>\n                多面立直局数 / 立直局数\n                <br />\n                听牌两种或以上即视为多面（含对碰）\n              </Trans>\n              <br />\n              <Trans values={{ date: \"2021/9/10\" }} defaults=\"（数据从 {{date}} 前后开始收集）\" />\n            </Box>\n          }\n        />\n      )}\n      {(stats.立直好型2 || stats.立直好型2 === 0) && (\n        <GenericStat\n          stats={stats}\n          formatter={formatPercent}\n          statKey=\"立直好型2\"\n          label=\"立直好型\"\n          description={\n            <Box>\n              <Trans>\n                好型立直局数 / 立直局数\n                <br />\n                立直时听牌可见剩余 6 枚或以上视为好型\n              </Trans>\n              <br />\n              <Trans values={{ date: \"2021/11/7\" }} defaults=\"（数据从 {{date}} 前后开始收集）\" />\n            </Box>\n          }\n        />\n      )}\n    </>\n  );\n}\nfunction BasicStats({ metadata, hasAdvancedParams }: { metadata: PlayerMetadata; hasAdvancedParams: boolean }) {\n  return (\n    <>\n      <StatItem label=\"记录场数\">{metadata.count}</StatItem>\n      <StatItem label=\"记录等级\">{LevelWithDelta.getTag(metadata.cross_stats?.level || metadata.level)}</StatItem>\n      <StatItem label=\"记录分数\">\n        {LevelWithDelta.formatAdjustedScore(metadata.cross_stats?.level || metadata.level)}\n      </StatItem>\n      <ExtendedStatsViewAsync\n        metadata={metadata}\n        view={PlayerExtendedStatsView}\n        hasAdvancedParams={hasAdvancedParams}\n      />\n      <StatItem label=\"平均顺位\">{metadata.avg_rank.toFixed(3)}</StatItem>\n      <StatItem label=\"被飞率\">{formatPercent(metadata.negative_rate)}</StatItem>\n      {!hasAdvancedParams && <EstimatedStableLevel metadata={metadata} />}\n    </>\n  );\n}\nfunction LuckStats({ stats }: { stats: PlayerExtendedStats }) {\n  return (\n    <>\n      <StatItem label=\"役满\" description=\"和出役满次数\">\n        {stats.役满 || 0}\n      </StatItem>\n      <StatItem label=\"累计役满\" description=\"和出累计役满次数\">\n        {stats.累计役满 || 0}\n      </StatItem>\n      <StatItem label=\"最大累计番数\" description=\"和出的最大番数（不含役满役）\">\n        {stats.最大累计番数 || 0}\n      </StatItem>\n      <StatItem label=\"流满\" description=\"流满次数\">\n        {stats.流满 || 0}\n      </StatItem>\n      <StatItem label=\"两立直\" description=\"两立直次数\">\n        {stats.W立直 || 0}\n      </StatItem>\n      <GenericStat stats={stats} formatter={formatFixed3} statKey=\"平均起手向听\" label=\"起手向听\" />\n      <GenericStat\n        stats={stats}\n        formatter={formatFixed3}\n        statKey=\"平均起手向听亲\"\n        label=\"亲起手向听\"\n        description={\n          <Box>\n            <Trans values={{ date: \"2022/6/27\" }} defaults=\"（数据从 {{date}} 前后开始收集）\" />\n          </Box>\n        }\n        hideValue={!stats?.平均起手向听亲}\n      />\n      <GenericStat\n        stats={stats}\n        formatter={formatFixed3}\n        statKey=\"平均起手向听子\"\n        label=\"子起手向听\"\n        description={\n          <Box>\n            <Trans values={{ date: \"2022/6/27\" }} defaults=\"（数据从 {{date}} 前后开始收集）\" />\n          </Box>\n        }\n        hideValue={!stats?.平均起手向听子}\n      />\n    </>\n  );\n}\nfunction LargestLost({ stats, metadata }: { stats: PlayerExtendedStats; metadata: PlayerMetadata }) {\n  const { t } = useTranslation();\n  if (!stats.最近大铳) {\n    return <Typography textAlign=\"center\">{t(\"无超过满贯大铳\")}</Typography>;\n  }\n  return (\n    <Box>\n      <Link\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        sx={{\n          display: \"flex\",\n          justifyContent: \"space-between\",\n          fontWeight: \"bold\",\n        }}\n        href={GameRecord.getRecordLink(stats.最近大铳.id, metadata.id)}\n      >\n        <Box>{FanStatEntryList.formatFanSummary(stats.最近大铳.fans)}</Box>\n        <Box>{GameRecord.formatFullStartTime(stats.最近大铳.start_time)}</Box>\n      </Link>\n      <StatList mt={2}>\n        {stats.最近大铳.fans.map((x) => (\n          <StatItem key={x.label} label={x.label}>\n            {FanStatEntry2.formatFan(x)}\n          </StatItem>\n        ))}\n      </StatList>\n    </Box>\n  );\n}\nfunction PlayerStats({\n  metadata,\n  isChangingSettings,\n  hasAdvancedParams,\n}: {\n  metadata: PlayerMetadata;\n  isChangingSettings: boolean;\n  hasAdvancedParams: boolean;\n}) {\n  return (\n    <SimpleRoutedSubViews>\n      <ViewRoutes>\n        <RouteDef path=\"\" exact title=\"基本\">\n          <StatList>\n            <BasicStats metadata={metadata} hasAdvancedParams={hasAdvancedParams} />\n          </StatList>\n        </RouteDef>\n        <RouteDef path=\"riichi\" title=\"立直\">\n          <StatList>\n            <ExtendedStatsViewAsync metadata={metadata} view={RiichiStats} hasAdvancedParams={hasAdvancedParams} />\n          </StatList>\n        </RouteDef>\n        <RouteDef path=\"extended\" title=\"更多\">\n          <StatList>\n            <ExtendedStatsViewAsync metadata={metadata} view={MoreStats} hasAdvancedParams={hasAdvancedParams} />\n          </StatList>\n        </RouteDef>\n        <RouteDef path=\"win-lose\" title=\"和铳分布\">\n          <ExtendedStatsViewAsync\n            metadata={metadata}\n            view={WinLoseDistribution}\n            hasAdvancedParams={hasAdvancedParams}\n          />\n        </RouteDef>\n        <RouteDef path=\"luck\" title=\"血统\">\n          <StatList>\n            <ExtendedStatsViewAsync metadata={metadata} view={LuckStats} hasAdvancedParams={hasAdvancedParams} />\n          </StatList>\n        </RouteDef>\n        <RouteDef path=\"largest-lost\" title=\"最近大铳\">\n          <ExtendedStatsViewAsync metadata={metadata} view={LargestLost} hasAdvancedParams={hasAdvancedParams} />\n        </RouteDef>\n        <RouteDef path=\"same-match\" title=\"最常同桌\">\n          {!isChangingSettings ? <SameMatchRate currentAccountId={metadata.id} /> : <></>}\n        </RouteDef>\n      </ViewRoutes>\n      <NavButtons sx={{ mt: 3 }} replace keepState withQueryString />\n      <ViewSwitch mutateTitle={false} />\n    </SimpleRoutedSubViews>\n  );\n}\n\nconst BlurrableBox = ({ blur, sx, ...props }: { blur: boolean } & BoxProps) => (\n  <Box sx={{ ...(blur ? { opacity: 0.2, pointerEvents: \"none\" } : {}), ...sx }} {...props} />\n);\n\nexport default function PlayerDetails() {\n  const { t } = useTranslation();\n  const latestDataAdapter = useDataAdapter();\n  const [dataAdapter, setDataAdapter] = useState(latestDataAdapter);\n  useEffect(() => {\n    if (latestDataAdapter === dataAdapter) {\n      return;\n    }\n    latestDataAdapter.getCount();\n    const metadata = latestDataAdapter.getMetadata<PlayerMetadata>();\n    if (!metadata) {\n      return;\n    }\n    if (dataAdapter.getMetadata()?.count === 0) {\n      setDataAdapter(latestDataAdapter);\n      return;\n    }\n    if (!latestDataAdapter.isItemLoaded(0)) {\n      latestDataAdapter.getItem(0);\n      return;\n    }\n    if (metadata.extended_stats instanceof Promise) {\n      let changed = false;\n      metadata.extended_stats\n        .then(() => {\n          if (changed) {\n            return;\n          } else {\n            setDataAdapter(latestDataAdapter);\n          }\n        })\n        .catch((e) => {\n          console.error(\"PlayerDetails: Failed to fetch extended stats\", e);\n          networkError();\n        });\n      return () => {\n        changed = true;\n      };\n    }\n    setDataAdapter(latestDataAdapter);\n  }, [latestDataAdapter, dataAdapter]);\n  const metadata = dataAdapter.getMetadata<PlayerMetadata>();\n  const [model, updateModel] = useModel();\n  const availableModes = useMemo(\n    () =>\n      latestDataAdapter.getMetadata<PlayerMetadata>()?.cross_stats?.played_modes ||\n      metadata?.cross_stats?.played_modes ||\n      [],\n    [metadata, latestDataAdapter]\n  );\n  useEffect(() => {\n    if (model.type !== \"player\" || Conf.availableModes.length < 2) {\n      return;\n    }\n    if (!model.selectedModes.length && !model.startDate && !model.endDate) {\n      const savedMode = loadPlayerPreference<GameMode[]>(\"modePreference\", model.playerId, []);\n      if (savedMode && savedMode.length) {\n        updateModel({ type: \"player\", playerId: model.playerId, selectedModes: savedMode });\n        return;\n      }\n    }\n    if (availableModes.length) {\n      const newSelectedModes = model.selectedModes.filter((x) => availableModes.includes(x));\n      if (!newSelectedModes.length) {\n        newSelectedModes.push(Conf.modePreference.find((x) => availableModes.includes(x)) || availableModes[0]);\n      }\n      if (\n        newSelectedModes.length !== model.selectedModes.length ||\n        newSelectedModes.some((x) => !model.selectedModes.includes(x))\n      ) {\n        updateModel({ type: \"player\", playerId: model.playerId, selectedModes: newSelectedModes });\n      }\n    }\n  }, [availableModes, model, updateModel]);\n  useEffect(triggerRelayout, [!!metadata]);\n  const hasMetadata = metadata && metadata.nickname && metadata.count;\n  const isChangingSettings = !!(\n    hasMetadata &&\n    latestDataAdapter !== dataAdapter &&\n    metadata !== latestDataAdapter.getMetadata()\n  );\n  /* eslint-disable @typescript-eslint/no-non-null-assertion */\n  return (\n    <Box mb={1} position=\"relative\">\n      {isChangingSettings && (\n        <Box position=\"absolute\" top=\"50%\" left=\"50%\" sx={{ transform: \"translate(-50%, -50%)\" }}>\n          <Loading />\n        </Box>\n      )}\n      {hasMetadata ? (\n        <BlurrableBox blur={isChangingSettings}>\n          <Helmet>\n            <title>{metadata?.cross_stats?.nickname || metadata?.nickname}</title>\n          </Helmet>\n          <Typography variant=\"h4\" textAlign=\"center\">\n            {getAccountZoneTag(metadata!.id)} {metadata?.cross_stats?.nickname || metadata?.nickname}\n          </Typography>\n          <Grid container mt={2} rowSpacing={2} spacing={2}>\n            <Grid item xs={12} md={8}>\n              <Typography variant=\"h5\" mb={2} textAlign=\"center\">\n                {t(\"最近走势\")}\n              </Typography>\n              <RecentRankChart dataAdapter={dataAdapter} playerId={metadata!.id} aspect={6} />\n              <PlayerStats\n                metadata={metadata!}\n                isChangingSettings={isChangingSettings}\n                hasAdvancedParams={Model.hasAdvancedParams(model)}\n              />\n            </Grid>\n            <Grid item xs={12} md={4}>\n              <Typography variant=\"h5\" textAlign=\"center\">\n                {t(\"累计战绩\")}\n              </Typography>\n              <Box maxWidth={480} margin=\"auto\">\n                <RankRateChart metadata={metadata!} />\n              </Box>\n              <Box margin=\"auto\" textAlign=\"center\">\n                <StarButton metadata={metadata!} />\n              </Box>\n            </Grid>\n          </Grid>\n        </BlurrableBox>\n      ) : (\n        <Loading />\n      )}\n      <PlayerDetailsSettings showLevel={true} availableModes={availableModes} />\n    </Box>\n  );\n  /* eslint-enable @typescript-eslint/no-non-null-assertion */\n}\n"
  },
  {
    "path": "src/components/playerDetails/playerDetailsSettings.tsx",
    "content": "import { useCallback } from \"react\";\nimport { useModel } from \"../gameRecords/model\";\nimport { ModeSelector } from \"../gameRecords/modeSelector\";\nimport { GameMode } from \"../../data/types\";\nimport { savePlayerPreference } from \"../../utils/preference\";\nimport { Box, styled } from \"@mui/material\";\nimport ExtraSettings from \"./extraSettings\";\nimport DateRangeSetting from \"./dateRangeSetting\";\nimport Conf from \"../../utils/conf\";\n\nconst SettingContainer = styled(Box)(({ theme }) => ({\n  display: \"flex\",\n  [theme.breakpoints.down(\"md\")]: {\n    alignItems: \"center\",\n    flexDirection: \"column\",\n  },\n  [theme.breakpoints.up(\"md\")]: {\n    justifyContent: \"space-between\",\n    alignItems: \"center\",\n  },\n\n  \"& > .MuiFormControl-root\": {\n    display: \"flex\",\n  },\n}));\n\nexport default function PlayerDetailsSettings({ showLevel = false, availableModes = [] as GameMode[] }) {\n  const [model, updateModel] = useModel();\n  const setSelectedMode = useCallback(\n    (mode) => {\n      if (mode.length && model.type === \"player\") {\n        savePlayerPreference(\"modePreference\", model.playerId, mode);\n      }\n      updateModel({ type: \"player\", selectedModes: mode });\n    },\n    [model, updateModel]\n  );\n  if (model.type !== \"player\") {\n    return null;\n  }\n  return (\n    <SettingContainer\n      mt={3}\n      sx={{ visibility: model.selectedModes.length >= 1 || Conf.availableModes.length <= 1 ? \"visible\" : \"hidden\" }}\n    >\n      <DateRangeSetting\n        start={model.startDate || null}\n        end={model.endDate || null}\n        limit={model.limit || null}\n        isThrone={model.selectedModes?.some((x) =>\n          [GameMode.王座, GameMode.王东, GameMode.三王座, GameMode.三王东].includes(x)\n        )}\n        onSelectDate={(start, end) =>\n          updateModel({\n            type: \"player\",\n            playerId: model.playerId,\n            startDate: start,\n            endDate: end,\n            limit: null,\n          })\n        }\n        onSelectLimit={(limit) =>\n          updateModel({\n            type: \"player\",\n            playerId: model.playerId,\n            startDate: null,\n            endDate: null,\n            limit,\n          })\n        }\n      />\n      {showLevel && availableModes.length > 0 && (\n        <ModeSelector\n          type=\"checkbox\"\n          mode={model.selectedModes}\n          onChange={setSelectedMode}\n          availableModes={availableModes}\n          i18nNamespace=\"gameModeShort\"\n        />\n      )}\n      <ExtraSettings />\n    </SettingContainer>\n  );\n}\n"
  },
  {
    "path": "src/components/playerDetails/sameMatchRate.tsx",
    "content": "import { useMemo } from \"react\";\nimport { useDataAdapter } from \"../gameRecords/dataAdapterProvider\";\nimport { PlayerRecord, RankRates, GameRecord, calculateDeltaPoint, Level } from \"../../data/types\";\nimport Loading from \"../misc/loading\";\nimport { generatePlayerPathById } from \"../gameRecords/routeUtils\";\nimport { formatPercent, formatFixed3 } from \"../../utils\";\nimport { SimpleRoutedSubViews, ViewRoutes, RouteDef, NavButtons, ViewSwitch } from \"../routing\";\nimport { useModel } from \"../gameRecords/model\";\nimport { useTranslation } from \"react-i18next\";\nimport { StatList, StatTooltip } from \"./statItem\";\nimport {\n  Box,\n  IconButton,\n  Link,\n  styled,\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableRow,\n  Typography,\n} from \"@mui/material\";\nimport { FormatListBulleted } from \"@mui/icons-material\";\n\ntype RateItem = {\n  player: PlayerRecord;\n  count: number;\n  resultSelf: RankRates;\n  resultOpponent: RankRates;\n  pointSelf: number;\n  pointOpponent: number;\n  win: number;\n};\nconst StyledTable = styled(Table)(({ theme }) => ({\n  display: \"inline-table\",\n  whiteSpace: \"nowrap\",\n\n  \"& .MuiTableRow-root.MuiTableRow-root .MuiTableCell-root, & .MuiTableHead-root\": {\n    boxShadow: \"none\",\n  },\n  \"& .MuiTableHead-root .MuiTableCell-root\": {\n    lineHeight: 1.25,\n  },\n  \"& .MuiTableCell-root\": {\n    fontSize: \"inherit\",\n    color: \"inherit\",\n    padding: theme.spacing(0.5),\n  },\n  \"& .MuiTableCell-root:not(:first-child)\": {\n    textAlign: \"right\",\n  },\n  \"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root\": {\n    border: \"0 none\",\n  },\n}));\nfunction TipTable({ item }: { item: RateItem }) {\n  const { t } = useTranslation();\n  return (\n    <Box>\n      <Typography textAlign=\"center\" variant=\"body2\" my={1}>\n        {t(\"胜率：\")}\n        {formatPercent(item.win / item.count)}\n      </Typography>\n      <StyledTable>\n        <TableHead>\n          <TableRow>\n            <TableCell></TableCell>\n            <TableCell>{t(\"玩家\")}</TableCell>\n            <TableCell>{t(\"对手\")}</TableCell>\n          </TableRow>\n        </TableHead>\n        <TableBody>\n          <TableRow>\n            <TableCell>{t(\"平均顺位\")}</TableCell>\n            <TableCell>{formatFixed3(RankRates.getAvg(item.resultSelf))}</TableCell>\n            <TableCell>{formatFixed3(RankRates.getAvg(item.resultOpponent))}</TableCell>\n          </TableRow>\n          <TableRow>\n            <TableCell>{t(\"平均得点\")}</TableCell>\n            <TableCell>{formatFixed3(item.pointSelf / item.count)}</TableCell>\n            <TableCell>{formatFixed3(item.pointOpponent / item.count)}</TableCell>\n          </TableRow>\n          {[\"一\", \"二\", \"三\", \"四\"].slice(0, item.resultSelf.length).map((label, index) => (\n            <TableRow key={index}>\n              <TableCell>{t(label + \"位\")}</TableCell>\n              <TableCell>\n                {formatPercent(item.resultSelf[index] / item.count)} ({item.resultSelf[index]})\n              </TableCell>\n              <TableCell>\n                {formatPercent(item.resultOpponent[index] / item.count)} ({item.resultOpponent[index]})\n              </TableCell>\n            </TableRow>\n          ))}\n        </TableBody>\n      </StyledTable>\n    </Box>\n  );\n}\n\nexport function SameMatchRateTable({ numGames = 100, numDisplay = 12, currentAccountId = 0 }) {\n  const adapter = useDataAdapter();\n  const [, updateModel] = useModel();\n  const count = adapter.getCount();\n  const numProcessedGames = Math.min(count, numGames);\n  const rates = useMemo(() => {\n    if (count <= 0) {\n      return null;\n    }\n    const map: {\n      [key: number]: RateItem;\n    } = {};\n    for (let i = 0; i < numProcessedGames; i++) {\n      const game = adapter.getItem(i);\n      if (!(\"uuid\" in game)) {\n        return null; // Not loaded, try again later\n      }\n      const currentPlayer = game.players.find((p) => p.accountId.toString() === currentAccountId.toString());\n      if (!currentPlayer) {\n        throw new Error(\n          `Can't find current player, shouldn't happen. Current: ${currentAccountId}, Players: ${game.players\n            .map((p) => p.accountId)\n            .join(\", \")}`\n        );\n      }\n      for (const player of game.players) {\n        if (player.accountId === currentAccountId) {\n          continue;\n        }\n        if (!map[player.accountId]) {\n          map[player.accountId] = {\n            player,\n            count: 0,\n            resultSelf: new Array<number>(game.players.length).fill(0) as RankRates,\n            resultOpponent: new Array<number>(game.players.length).fill(0) as RankRates,\n            pointSelf: 0,\n            pointOpponent: 0,\n            win: 0,\n          };\n        }\n        const entry = map[player.accountId];\n        entry.count++;\n        const selfRank = GameRecord.getRankIndexByPlayer(game, currentAccountId);\n        const opponentRank = GameRecord.getRankIndexByPlayer(game, player);\n        entry.resultSelf[selfRank]++;\n        entry.resultOpponent[opponentRank]++;\n        if (selfRank < opponentRank) {\n          entry.win++;\n        }\n        if (game.modeId) {\n          entry.pointSelf += calculateDeltaPoint(\n            currentPlayer.score,\n            selfRank,\n            game.modeId,\n            new Level(currentPlayer.level),\n            true,\n            true\n          );\n          entry.pointOpponent += calculateDeltaPoint(\n            player.score,\n            opponentRank,\n            game.modeId,\n            new Level(player.level),\n            true,\n            true\n          );\n        }\n      }\n    }\n    const result = Object.values(map);\n    result.sort((a, b) => b.count - a.count);\n    return result;\n  }, [count, adapter, numProcessedGames, currentAccountId]);\n  if (count <= 0) {\n    return null;\n  }\n  if (!rates) {\n    return <Loading />;\n  }\n  return (\n    <StatList className=\"mobile-1col\">\n      {rates.slice(0, numDisplay).map((x) => (\n        <Box\n          display=\"flex\"\n          justifyContent=\"space-between\"\n          alignItems=\"center\"\n          sx={{ whiteSpace: \"nowrap\" }}\n          key={x.player.accountId}\n        >\n          <Typography variant=\"body2\" mr={2}>\n            <Link href={generatePlayerPathById(x.player.accountId)}>{x.player.nickname}</Link>\n            <IconButton\n              size=\"small\"\n              color=\"info\"\n              onClick={() => updateModel({ type: \"player\", searchText: x.player.nickname })}\n              sx={{ margin: \"-5px 0\", verticalAlign: \"text-top\" }}\n            >\n              <FormatListBulleted fontSize=\"inherit\" />\n            </IconButton>\n          </Typography>\n          <Typography variant=\"body2\" component=\"div\">\n            <StatTooltip title={<TipTable item={x} />} arrow>\n              <Box>\n                {formatPercent(x.count / numProcessedGames)} ({x.count})\n              </Box>\n            </StatTooltip>\n          </Typography>\n        </Box>\n      ))}\n    </StatList>\n  );\n}\n\nexport default function SameMatchRate({ numDisplay = 12, currentAccountId = 0 }) {\n  return (\n    <SimpleRoutedSubViews>\n      <ViewRoutes>\n        <RouteDef path=\"latest\" title=\"最近 100 局\">\n          <SameMatchRateTable currentAccountId={currentAccountId} numDisplay={numDisplay} />\n        </RouteDef>\n        <RouteDef path=\"all\" title=\"全部\">\n          <SameMatchRateTable currentAccountId={currentAccountId} numDisplay={numDisplay} numGames={0x7fffffff} />\n        </RouteDef>\n      </ViewRoutes>\n      <NavButtons sx={{ mt: -1.5 }} />\n      <ViewSwitch mutateTitle={false} />\n    </SimpleRoutedSubViews>\n  );\n}\n"
  },
  {
    "path": "src/components/playerDetails/star/starButton.tsx",
    "content": "import { Star, StarBorder } from \"@mui/icons-material\";\nimport { Button } from \"@mui/material\";\nimport React, { useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { PlayerMetadata } from \"../../../data/types\";\nimport { useStarPlayer } from \"./starPlayerProvider\";\n\nconst StarButton = React.memo(function ({ metadata }: { metadata: PlayerMetadata }) {\n  const { t } = useTranslation();\n  const { refreshAndGetIsPlayerStarred, starPlayer, unstarPlayer } = useStarPlayer();\n  const isStarred = useMemo(() => refreshAndGetIsPlayerStarred(metadata), [metadata, refreshAndGetIsPlayerStarred]);\n  return isStarred ? (\n    <Button startIcon={<Star />} disableElevation variant=\"outlined\" onClick={() => unstarPlayer(metadata)}>\n      {t(\"已收藏\")}\n    </Button>\n  ) : (\n    <Button startIcon={<StarBorder />} onClick={() => starPlayer(metadata)}>\n      {t(\"收藏\")}\n    </Button>\n  );\n});\nexport default StarButton;\n"
  },
  {
    "path": "src/components/playerDetails/star/starPlayerProvider.tsx",
    "content": "import React, { useCallback, useEffect } from \"react\";\nimport { LevelWithDelta, PlayerMetadata } from \"../../../data/types\";\nimport { loadPreference, savePreference } from \"../../../utils/preference\";\n\ntype StarredPlayer = {\n  id: number;\n  name: string;\n  levelId: number;\n  timestamp: number;\n};\n\nconst Context = React.createContext({\n  starredPlayers: [] as StarredPlayer[],\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function\n  unstarPlayer(_: PlayerMetadata) {},\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function\n  starPlayer(_: PlayerMetadata) {},\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  refreshAndGetIsPlayerStarred(_: PlayerMetadata): boolean {\n    return false;\n  },\n});\nexport const useStarPlayer = () => React.useContext(Context);\n\nconst channel = window.BroadcastChannel ? new BroadcastChannel(\"StarPlayerProvider\") : null;\n\nfunction loadStarredPlayers() {\n  const list = loadPreference<StarredPlayer[]>(\"starredPlayers\", []);\n  const map = new Map(list.map((item) => [item.id, item]));\n  return { list, map };\n}\nfunction saveStarredPlayers(list: StarredPlayer[]) {\n  savePreference(\"starredPlayers\", list);\n  if (channel) {\n    setTimeout(() => channel.postMessage(\"refresh\"), 100);\n  }\n}\n\nexport default function StarPlayerProvider({ children }: { children: React.ReactNode }) {\n  const [starredPlayers, setStarredPlayers] = React.useState(() => loadStarredPlayers());\n  const [debouceCounter, setDebouceCounter] = React.useState(0);\n  useEffect(() => {\n    if (debouceCounter > 0) {\n      setStarredPlayers(loadStarredPlayers());\n    }\n  }, [debouceCounter]);\n  useEffect(() => {\n    if (!channel) {\n      return;\n    }\n    const handler = function handler(e: MessageEvent) {\n      if (e.data === \"refresh\") {\n        setDebouceCounter((c) => c + 1);\n      }\n    };\n    channel.addEventListener(\"message\", handler);\n    return () => {\n      channel.removeEventListener(\"message\", handler);\n    };\n  }, []);\n  const starPlayer = useCallback(\n    (player: PlayerMetadata) => {\n      const newStarredPlayer = {\n        id: player.id,\n        name: player.nickname,\n        levelId: LevelWithDelta.getAdjustedLevel(player.level).toLevelId(),\n        timestamp: Date.now(),\n      };\n      const index = starredPlayers.list.findIndex((item) => item.id === newStarredPlayer.id);\n      if (\n        index === 0 &&\n        starredPlayers.list[0].name === newStarredPlayer.name &&\n        starredPlayers.list[0].levelId === newStarredPlayer.levelId\n      ) {\n        return;\n      }\n      starredPlayers.map.set(newStarredPlayer.id, newStarredPlayer);\n      if (index >= 0) {\n        starredPlayers.list.splice(index, 1);\n      }\n      starredPlayers.list.unshift(newStarredPlayer);\n      saveStarredPlayers(starredPlayers.list);\n      setDebouceCounter((c) => c + 1);\n    },\n    [starredPlayers]\n  );\n  const value = React.useMemo(\n    () => ({\n      starredPlayers: starredPlayers.list,\n      unstarPlayer(player: PlayerMetadata) {\n        if (!starredPlayers.map.has(player.id)) {\n          return;\n        }\n        starredPlayers.map.delete(player.id);\n        starredPlayers.list = starredPlayers.list.filter((item) => item.id !== player.id);\n        saveStarredPlayers(starredPlayers.list);\n        setStarredPlayers({ ...starredPlayers });\n      },\n      starPlayer,\n      refreshAndGetIsPlayerStarred(stats: PlayerMetadata) {\n        const isStarred = starredPlayers.map.has(stats.id);\n        if (!isStarred) {\n          return false;\n        }\n        starPlayer(stats);\n        return true;\n      },\n    }),\n    [starPlayer, starredPlayers]\n  );\n  return <Context.Provider value={value}>{children}</Context.Provider>;\n}\n"
  },
  {
    "path": "src/components/playerDetails/star/starredPlayerMenu.tsx",
    "content": "import { Box, Grow, MenuItem } from \"@mui/material\";\nimport { TransitionGroup } from \"react-transition-group\";\nimport React from \"react\";\nimport { MenuButton } from \"../../misc/menuButton\";\nimport { generatePlayerPathById } from \"../../gameRecords/routeUtils\";\nimport { useStarPlayer } from \"./starPlayerProvider\";\nimport { ArrowDropDown, Star } from \"@mui/icons-material\";\nimport { Level } from \"../../../data/types\";\nimport { LinkBehavior } from \"../../misc/linkBehavior\";\nimport { useTranslation } from \"react-i18next\";\n\nconst StarredPlayerMenu = React.memo(function () {\n  useTranslation();\n  const { starredPlayers } = useStarPlayer();\n  return (\n    <TransitionGroup>\n      {starredPlayers.length\n        ? [\n            <Grow key={1}>\n              <Box>\n                <MenuButton\n                  label={<Star />}\n                  endIcon={<ArrowDropDown />}\n                  sx={{ \".MuiButton-endIcon\": { marginLeft: 0 } }}\n                >\n                  {starredPlayers.map((p) => (\n                    <MenuItem key={p.id} href={generatePlayerPathById(p.id)} component={LinkBehavior}>\n                      [{new Level(p.levelId).getTag()}] {p.name}\n                    </MenuItem>\n                  ))}\n                </MenuButton>\n              </Box>\n            </Grow>,\n          ]\n        : []}\n    </TransitionGroup>\n  );\n});\nexport default StarredPlayerMenu;\n"
  },
  {
    "path": "src/components/playerDetails/statItem.tsx",
    "content": "import {\n  Box,\n  Typography,\n  Tooltip,\n  TooltipProps,\n  styled,\n  tooltipClasses,\n  TypographyProps,\n  useTheme,\n} from \"@mui/material\";\nimport useMediaQuery from \"@mui/material/useMediaQuery\";\nimport React, { ReactNode } from \"react\";\nimport { useTranslation } from \"react-i18next\";\n\nexport const StatTooltip = styled(({ className, ...props }: TooltipProps) => {\n  const theme = useTheme();\n  const matches = useMediaQuery(theme.breakpoints.up(\"md\"));\n  return <Tooltip placement={matches ? \"bottom\" : \"bottom-end\"} {...props} classes={{ popper: className }} />;\n})(({ theme }) => ({\n  [`& .${tooltipClasses.tooltip}.${tooltipClasses.tooltip}.${tooltipClasses.tooltip}.${tooltipClasses.tooltip}`]: {\n    textAlign: \"center\",\n    marginTop: theme.spacing(1),\n    marginBottom: theme.spacing(1),\n\n    \"&, & *\": {\n      userSelect: \"none\",\n    },\n  },\n}));\n\nexport const StatList = styled(Box)(({ theme }) => ({\n  display: \"grid\",\n  justifyContent: \"space-between\",\n  gridGap: theme.spacing(1.5),\n  gridTemplateColumns: \"1fr\",\n  \"&, & *\": {\n    userSelect: \"none\",\n  },\n  [theme.breakpoints.down(\"sm\")]: {\n    gridGap: theme.spacing(0.5),\n    \"& > div\": {\n      borderBottom: `1px dashed ${theme.palette.grey[500]}`,\n      paddingBottom: theme.spacing(0.5),\n    },\n  },\n  \"&:not(.mobile-1col)\": {\n    [theme.breakpoints.down(\"sm\") + \" and (min-width: 410px)\"]: {\n      gridTemplateColumns: \"repeat(2, min-content)\",\n\n      \".lang-en &, .lang-ko &\": {\n        gridTemplateColumns: \"1fr\",\n      },\n    },\n    [theme.breakpoints.down(\"sm\") + \" and (min-width: 440px)\"]: {\n      \".lang-ko &\": {\n        gridTemplateColumns: \"repeat(2, min-content)\",\n      },\n    },\n    [theme.breakpoints.down(\"sm\") + \" and (min-width: 480px)\"]: {\n      \".lang-en &\": {\n        gridTemplateColumns: \"repeat(2, min-content)\",\n      },\n    },\n  },\n  [theme.breakpoints.up(\"sm\")]: {\n    gridTemplateColumns: \"repeat(2, min-content)\",\n  },\n  \"@media (min-width: 767px)\": {\n    gridTemplateColumns: \"repeat(3, min-content)\",\n    \".lang-en &, .lang-ko &\": {\n      gridTemplateColumns: \"repeat(2, min-content)\",\n    },\n  },\n  [theme.breakpoints.up(\"lg\")]: {\n    \".lang-en &, .lang-ko &\": {\n      gridTemplateColumns: \"repeat(3, min-content)\",\n    },\n  },\n}));\n\nconst StatItem = React.memo(function ({\n  label,\n  description = \"\",\n  i18nNamespace,\n  children,\n  valueProps = {},\n  extraTip,\n}: {\n  label: string;\n  description?: ReactNode;\n  i18nNamespace?: string[];\n  children: React.ReactChild;\n  valueProps?: TypographyProps;\n  extraTip?: ReactNode | (() => ReactNode);\n}) {\n  const { t } = useTranslation(i18nNamespace);\n  if (typeof extraTip === \"function\") {\n    extraTip = extraTip();\n  }\n  const translatedTip =\n    (description ? (typeof description === \"string\" ? t(description).toString() : description) : \"\") || \"\";\n  return (\n    <Box display=\"flex\" justifyContent=\"space-between\">\n      <Typography variant=\"subtitle2\" lineHeight=\"1.25\" mr={[1, 2]} noWrap textOverflow=\"initial\">\n        {t(label)}\n      </Typography>\n      <StatTooltip\n        title={\n          !translatedTip && !extraTip ? (\n            \"\"\n          ) : (\n            <Box padding={1}>\n              {translatedTip && typeof translatedTip === \"string\" ? (\n                <Box whiteSpace=\"pre-wrap\">{translatedTip}</Box>\n              ) : (\n                translatedTip\n              )}\n              {extraTip}\n            </Box>\n          )\n        }\n        arrow\n      >\n        <Typography variant=\"body2\" lineHeight=\"1.25\" noWrap textAlign=\"right\" {...valueProps}>\n          {children}\n        </Typography>\n      </StatTooltip>\n    </Box>\n  );\n});\nexport default StatItem;\n"
  },
  {
    "path": "src/components/ranking/careerRanking.tsx",
    "content": "/* eslint-disable @typescript-eslint/indent */\nimport React from \"react\";\n\nimport { CareerRankingItem, CareerRankingType } from \"../../data/types/ranking\";\nimport { useAsyncFactory } from \"../../utils/async\";\nimport { getCareerRanking } from \"../../data/source/misc\";\nimport Loading from \"../misc/loading\";\nimport { generatePlayerPathById } from \"../gameRecords/routeUtils\";\nimport { LevelWithDelta, GameMode } from \"../../data/types\";\nimport { formatPercent } from \"../../utils/index\";\nimport { ModelModeProvider, ModelModeSelector, useModel } from \"../modeModel\";\nimport { useTranslation } from \"react-i18next\";\nimport Conf from \"../../utils/conf\";\nimport { CheckboxGroup } from \"../form\";\nimport {\n  Box,\n  Grid,\n  Table,\n  TableBody,\n  TableCell,\n  TableContainer,\n  TableHead,\n  TableRow,\n  Typography,\n  Link,\n} from \"@mui/material\";\n\ntype ExtraColumnInternal = {\n  label: string;\n  value: (item: CareerRankingItem) => string;\n};\n\ntype ExtraColumn = {\n  label: string;\n  value: (item: CareerRankingItem, mode: GameMode[]) => string;\n};\n\nfunction RankingTable({\n  rows = null as CareerRankingItem[] | null,\n  formatter = formatPercent as (x: number, item: CareerRankingItem, modes: GameMode[]) => string,\n  showNumGames = true,\n  valueLabel = \"\",\n  extraColumns = [] as ExtraColumnInternal[],\n  modes = [] as GameMode[],\n}) {\n  const { t } = useTranslation();\n  if (!rows) {\n    return <Loading />;\n  }\n  return (\n    <TableContainer>\n      <Table>\n        <TableHead>\n          <TableRow>\n            <TableCell sx={{ textAlign: \"right\" }}>{t(\"排名\")}</TableCell>\n            <TableCell>{t(\"玩家\")}</TableCell>\n            {showNumGames && <TableCell sx={{ textAlign: \"right\" }}>{t(\"对局数\")}</TableCell>}\n            {extraColumns.map((x) => (\n              <TableCell sx={{ textAlign: \"right\" }} key={x.label}>\n                {t(x.label)}\n              </TableCell>\n            ))}\n            <TableCell sx={{ textAlign: \"right\" }}>{valueLabel}</TableCell>\n          </TableRow>\n        </TableHead>\n        <TableBody>\n          {rows.map((x, index) => (\n            <TableRow key={x.id}>\n              <TableCell sx={{ textAlign: \"right\" }}>{index + 1}</TableCell>\n              <TableCell>\n                <Link href={generatePlayerPathById(x.id)}>\n                  [{LevelWithDelta.getTag(x.level)}] {x.nickname}\n                </Link>\n              </TableCell>\n              {showNumGames && <TableCell sx={{ textAlign: \"right\" }}>{x.count}</TableCell>}\n              {extraColumns.map((col) => (\n                <TableCell sx={{ textAlign: \"right\" }} key={col.label}>\n                  {col.value(x)}\n                </TableCell>\n              ))}\n              <TableCell sx={{ textAlign: \"right\" }}>{formatter(x.rank_key, x, modes)}</TableCell>\n            </TableRow>\n          ))}\n        </TableBody>\n      </Table>\n    </TableContainer>\n  );\n}\n\nexport function CareerRankingColumn({\n  type,\n  title,\n  formatter = formatPercent,\n  showNumGames = true,\n  valueLabel = \"\",\n  disableMixedMode = false,\n  extraColumns = [],\n  forceMode = undefined,\n}: {\n  type: CareerRankingType;\n  title: string;\n  formatter?: (x: number, item: CareerRankingItem, modes: GameMode[]) => string;\n  showNumGames?: boolean;\n  valueLabel?: string;\n  disableMixedMode?: boolean;\n  extraColumns?: ExtraColumn[];\n  forceMode?: undefined | GameMode | number;\n}) {\n  const { t } = useTranslation();\n  const [model] = useModel();\n  const modes = forceMode === undefined ? model.selectedModes.sort((a, b) => a - b) : [forceMode];\n  const isMixedMode = modes.length !== 1;\n  const data = useAsyncFactory(\n    () =>\n      modes.length > 0 ? getCareerRanking(type, modes.join(\".\"), model.careerRankingMinGames) : Promise.resolve([]),\n    [type, model],\n    `getCareerRanking-${modes.join(\".\")}-${model.careerRankingMinGames || 300}`\n  );\n  return (\n    <>\n      <Typography textAlign=\"center\" mb={2} variant=\"h5\">\n        {t(title)}\n      </Typography>\n      {!disableMixedMode || !isMixedMode ? (\n        <RankingTable\n          rows={data}\n          formatter={formatter}\n          valueLabel={t(valueLabel || title)}\n          showNumGames={showNumGames}\n          modes={model.selectedModes}\n          extraColumns={extraColumns.map((x) => ({ ...x, value: (item) => x.value(item, modes) }))}\n        />\n      ) : (\n        <Box textAlign=\"center\" mt={4}>\n          {t(\"请选择模式\")}\n        </Box>\n      )}\n    </>\n  );\n}\nexport function CareerRankingPlain({\n  children,\n}: {\n  children:\n    | React.ReactElement<ReturnType<typeof CareerRankingColumn>>\n    | React.ReactElement<ReturnType<typeof CareerRankingColumn>>[];\n}) {\n  if (!(\"length\" in children)) {\n    children = [children];\n  }\n  return (\n    <Grid container spacing={2} rowSpacing={3}>\n      {children.map((x, i) => (\n        <Grid item xs={12} md={6} lg key={i}>\n          {x}\n        </Grid>\n      ))}\n    </Grid>\n  );\n}\nfunction CareerRankingInner({\n  children,\n}: {\n  children:\n    | React.ReactElement<ReturnType<typeof CareerRankingColumn>>\n    | React.ReactElement<ReturnType<typeof CareerRankingColumn>>[];\n}) {\n  const [model, updateModel] = useModel();\n  const { t } = useTranslation();\n  if (!(\"length\" in children)) {\n    children = [children];\n  }\n  return (\n    <>\n      <CheckboxGroup\n        type=\"radio\"\n        items={[\n          { key: \"300\", value: 300, label: \"300 \" + t(\"局\") },\n          { key: \"600\", value: 600, label: \"600 \" + t(\"局\") },\n          { key: \"1000\", value: 1000, label: \"1000 \" + t(\"局\") },\n          { key: \"2500\", value: 2500, label: \"2500 \" + t(\"局\") },\n          { key: \"5000\", value: 5000, label: \"5000 \" + t(\"局\") },\n        ]}\n        selectedItems={[(model.careerRankingMinGames || 300).toString()]}\n        onChange={(newItems) => {\n          updateModel({ careerRankingMinGames: newItems[0].value });\n        }}\n      />\n      <ModelModeSelector type=\"radio\" availableModes={Conf.features.ranking || []} autoSelectFirst />\n      <CareerRankingPlain>{children}</CareerRankingPlain>\n    </>\n  );\n}\n\nexport function CareerRanking({\n  children,\n}: {\n  children:\n    | React.ReactElement<ReturnType<typeof CareerRankingColumn>>\n    | React.ReactElement<ReturnType<typeof CareerRankingColumn>>[];\n}) {\n  return (\n    <ModelModeProvider>\n      <CareerRankingInner>{children}</CareerRankingInner>\n    </ModelModeProvider>\n  );\n}\n"
  },
  {
    "path": "src/components/ranking/deltaRanking.tsx",
    "content": "import { DeltaRankingItem, RankingTimeSpan } from \"../../data/types/ranking\";\nimport { useAsyncFactory } from \"../../utils/async\";\nimport { getDeltaRanking } from \"../../data/source/misc\";\nimport Loading from \"../misc/loading\";\nimport { generatePlayerPathById } from \"../gameRecords/routeUtils\";\nimport { GameMode, LevelWithDelta } from \"../../data/types\";\nimport { useModel, ModelModeSelector, ModelModeProvider } from \"../modeModel\";\nimport { useTranslation } from \"react-i18next\";\nimport Conf from \"../../utils/conf\";\nimport {\n  Box,\n  Grid,\n  Table,\n  TableBody,\n  TableCell,\n  TableContainer,\n  TableRow,\n  Typography,\n  Link,\n  TypographyProps,\n  GridProps,\n} from \"@mui/material\";\nimport { useState } from \"react\";\nimport { CheckboxGroup } from \"../form\";\n\nfunction RankingTable({ rows = [] as DeltaRankingItem[] }) {\n  return (\n    <TableContainer>\n      <Table>\n        <TableBody>\n          {rows.map((x) => (\n            <TableRow key={x.id}>\n              <TableCell>\n                <Link href={generatePlayerPathById(x.id)}>\n                  [{LevelWithDelta.getTag(x.level)}] {x.nickname}\n                </Link>\n              </TableCell>\n              <TableCell sx={{ textAlign: \"right\" }}>{x.delta}</TableCell>\n            </TableRow>\n          ))}\n        </TableBody>\n      </Table>\n    </TableContainer>\n  );\n}\n\nconst Title = (props: TypographyProps) => <Typography mb={1} textAlign=\"center\" {...props} sx={{}} />;\nconst GridContainer = (props: GridProps) => <Grid container spacing={2} rowSpacing={3} {...props} />;\n\nfunction DeltaRankingInner() {\n  const { t } = useTranslation();\n  const [selectedTimeSpan, setSelectedTimeSpan] = useState(RankingTimeSpan.FourWeeks);\n  const data = useAsyncFactory(\n    () => getDeltaRanking(selectedTimeSpan),\n    [selectedTimeSpan],\n    \"getDeltaRanking_\" + selectedTimeSpan\n  );\n  const [model] = useModel();\n  const modes = model.selectedModes;\n  const modeKey = modes.length !== 1 ? 0 : modes[0];\n  const availableModes = (\n    data\n      ? Object.keys(data)\n          .filter((x) => x !== \"0\")\n          .map((x) => parseInt(x, 10) as GameMode)\n      : []\n  ).sort((a, b) => Conf.availableModes.indexOf(a) - Conf.availableModes.indexOf(b));\n  return (\n    <>\n      <CheckboxGroup\n        type=\"radio\"\n        items={[\n          { key: RankingTimeSpan.FourWeeks, value: RankingTimeSpan.FourWeeks, label: t(\"四周\") },\n          { key: RankingTimeSpan.OneWeek, value: RankingTimeSpan.OneWeek, label: t(\"一周\") },\n          { key: RankingTimeSpan.ThreeDays, value: RankingTimeSpan.ThreeDays, label: t(\"三天\") },\n          { key: RankingTimeSpan.OneDay, value: RankingTimeSpan.OneDay, label: t(\"一天\") },\n        ]}\n        selectedItems={[selectedTimeSpan]}\n        onChange={(newItems) => {\n          setSelectedTimeSpan(newItems[0].value);\n        }}\n      />\n      <Box visibility={data ? \"visible\" : \"hidden\"} mb={2}>\n        <ModelModeSelector type=\"checkbox\" availableModes={availableModes} allowedCombinations={[availableModes]} />\n      </Box>\n      {data ? (\n        <GridContainer>\n          <Grid item xs={12} md={4}>\n            <Title variant=\"h5\">{t(\"苦主榜\")}</Title>\n            <RankingTable rows={data[modeKey].bottom} />\n          </Grid>\n          <Grid item xs={12} md={4}>\n            <Title variant=\"h5\">{t(\"汪汪榜\")}</Title>\n            <RankingTable rows={data[modeKey].top} />\n          </Grid>\n          <Grid item xs={12} md={4}>\n            <Title variant=\"h5\">{t(\"劳模榜\")}</Title>\n            <RankingTable rows={data[modeKey].num_games} />\n          </Grid>\n        </GridContainer>\n      ) : (\n        <Loading />\n      )}\n    </>\n  );\n}\n\nexport default function DeltaRanking() {\n  return (\n    <ModelModeProvider>\n      <DeltaRankingInner />\n    </ModelModeProvider>\n  );\n}\n"
  },
  {
    "path": "src/components/ranking/index.tsx",
    "content": "import React from \"react\";\n\nimport { Alert } from \"../misc/alert\";\nimport DeltaRanking from \"./deltaRanking\";\nimport { CareerRanking, CareerRankingColumn, CareerRankingPlain } from \"./careerRanking\";\nimport { CareerRankingType, LevelWithDelta } from \"../../data/types\";\nimport { PlayerMetadata } from \"../../data/types/metadata\";\nimport { formatFixed3, formatIdentity, formatPercent, formatRound } from \"../../utils/index\";\nimport { ViewRoutes, SimpleRoutedSubViews, NavButtons, RouteDef } from \"../routing\";\nimport { ViewSwitch } from \"../routing/index\";\nimport { useTranslation } from \"react-i18next\";\nimport Conf from \"../../utils/conf\";\n\nconst SANMA = Conf.rankColors.length === 3;\n\nconst ROUTES = (\n  <ViewRoutes>\n    <RouteDef path=\"delta\" title=\"苦主及汪汪\">\n      <DeltaRanking />\n    </RouteDef>\n    <RouteDef path=\"career1\" title=\"一位率/四位率\" disabled={SANMA}>\n      <CareerRanking>\n        <CareerRankingColumn type={CareerRankingType.Rank1} title=\"一位率\" />\n        <CareerRankingColumn type={CareerRankingType.Rank4} title=\"四位率\" />\n      </CareerRanking>\n    </RouteDef>\n    <RouteDef path=\"career1\" title=\"一位率/三位率\" disabled={!SANMA}>\n      <CareerRanking>\n        <CareerRankingColumn type={CareerRankingType.Rank1} title=\"一位率\" />\n        <CareerRankingColumn type={CareerRankingType.Rank3} title=\"三位率\" />\n      </CareerRanking>\n    </RouteDef>\n    <RouteDef path=\"career2\" title=\"连对率/安定段位\" disabled={SANMA}>\n      <CareerRanking>\n        <CareerRankingColumn type={CareerRankingType.Rank12} title=\"连对率\" />\n        <CareerRankingColumn\n          type={CareerRankingType.StableLevel}\n          title=\"安定段位\"\n          formatter={(_, metadata, modes) =>\n            PlayerMetadata.estimateStableLevel2({ ...metadata, level: metadata.ranking_level }, modes[0])\n          }\n          disableMixedMode\n        />\n      </CareerRanking>\n    </RouteDef>\n    <RouteDef path=\"career2\" title=\"安定段位\" disabled={!SANMA}>\n      <CareerRanking>\n        <CareerRankingColumn\n          type={CareerRankingType.StableLevel}\n          title=\"安定段位\"\n          formatter={(_, metadata, modes) =>\n            PlayerMetadata.estimateStableLevel({ ...metadata, level: metadata.ranking_level }, modes[0])\n          }\n          disableMixedMode\n        />\n      </CareerRanking>\n    </RouteDef>\n    <RouteDef path=\"maxlevel\" title=\"最高等级\">\n      <CareerRankingPlain>\n        <CareerRankingColumn\n          type={CareerRankingType.MaxLevelGlobal}\n          title=\"最高等级\"\n          forceMode={0}\n          showNumGames={false}\n          formatter={(_, metadata) => `${LevelWithDelta.format(metadata.max_level)}`}\n        />\n      </CareerRankingPlain>\n    </RouteDef>\n    <RouteDef path=\"career3\" title=\"平均顺位/对局数\">\n      <CareerRanking>\n        <CareerRankingColumn type={CareerRankingType.AvgRank} title=\"平均顺位\" formatter={formatFixed3} />\n        <CareerRankingColumn\n          type={CareerRankingType.NumGames}\n          title=\"对局数\"\n          formatter={formatIdentity}\n          showNumGames={false}\n        />\n      </CareerRanking>\n    </RouteDef>\n    <RouteDef path=\"career4\" title={(t) => `${t(\"平均打点\")}/${t(\"平均铳点\")}`}>\n      <CareerRanking>\n        <CareerRankingColumn type={CareerRankingType.平均打点} title=\"平均打点\" formatter={formatRound} />\n        <CareerRankingColumn type={CareerRankingType.平均铳点} title=\"平均铳点\" formatter={formatRound} />\n      </CareerRanking>\n    </RouteDef>\n    <RouteDef path=\"career5\" title={(t) => `${t(\"打点效率\")}/${t(\"铳点损失\")}`}>\n      <CareerRanking>\n        <CareerRankingColumn type={CareerRankingType.打点效率} title=\"打点效率\" formatter={formatRound} />\n        <CareerRankingColumn type={CareerRankingType.铳点损失} title=\"铳点损失\" formatter={formatRound} />\n      </CareerRanking>\n    </RouteDef>\n    <RouteDef path=\"netwinefficiency\" title=\"净打点效率\">\n      <CareerRanking>\n        <CareerRankingColumn\n          type={CareerRankingType.净打点效率}\n          title=\"净打点效率\"\n          formatter={formatRound}\n          extraColumns={[\n            {\n              label: \"打点效率\",\n              value: (x) =>\n                x.extended_stats && \"count\" in x.extended_stats ? formatRound(x.extended_stats.打点效率) : \"\",\n            },\n            {\n              label: \"铳点损失\",\n              value: (x) =>\n                x.extended_stats && \"count\" in x.extended_stats ? formatRound(x.extended_stats.铳点损失) : \"\",\n            },\n          ]}\n        />\n      </CareerRanking>\n    </RouteDef>\n    <RouteDef path=\"winlose\" title=\"和率/铳率\">\n      <CareerRanking>\n        <CareerRankingColumn type={CareerRankingType.Win} title=\"和牌率\" />\n        <CareerRankingColumn type={CareerRankingType.Lose} title=\"放铳率\" />\n      </CareerRanking>\n    </RouteDef>\n    <RouteDef path=\"winlosediff\" title=\"和铳差\">\n      <CareerRanking>\n        <CareerRankingColumn\n          type={CareerRankingType.WinLoseDiff}\n          title=\"和铳差\"\n          extraColumns={[\n            {\n              label: \"和牌率\",\n              value: (x) =>\n                x.extended_stats && \"和牌率\" in x.extended_stats ? formatPercent(x.extended_stats.和牌率) : \"\",\n            },\n            {\n              label: \"放铳率\",\n              value: (x) =>\n                x.extended_stats && \"放铳率\" in x.extended_stats ? formatPercent(x.extended_stats.放铳率) : \"\",\n            },\n          ]}\n        />\n      </CareerRanking>\n    </RouteDef>\n    <RouteDef path=\"ept12\" title=\"一/二位平均 Pt\" disabled={SANMA}>\n      <CareerRanking>\n        <CareerRankingColumn\n          type={CareerRankingType.ExpectedGamePoint0}\n          title=\"一位平均 Pt\"\n          formatter={formatFixed3}\n          valueLabel=\"Pt\"\n          disableMixedMode\n        />\n        <CareerRankingColumn\n          type={CareerRankingType.ExpectedGamePoint1}\n          title=\"二位平均 Pt\"\n          formatter={formatFixed3}\n          valueLabel=\"Pt\"\n          disableMixedMode\n        />\n      </CareerRanking>\n    </RouteDef>\n    <RouteDef path=\"ept34\" title=\"三位平均 Pt/四位平均得点 Pt\" disabled={SANMA}>\n      <CareerRanking>\n        <CareerRankingColumn\n          type={CareerRankingType.ExpectedGamePoint2}\n          title=\"三位平均 Pt\"\n          formatter={formatFixed3}\n          valueLabel=\"Pt\"\n          disableMixedMode\n        />\n        <CareerRankingColumn\n          type={CareerRankingType.ExpectedGamePoint3}\n          title=\"四位平均得点 Pt\"\n          formatter={formatFixed3}\n          valueLabel=\"Pt\"\n          disableMixedMode\n        />\n      </CareerRanking>\n    </RouteDef>\n    <RouteDef path=\"efficiency\" title=\"得点效率\" disabled={SANMA}>\n      <CareerRanking>\n        <CareerRankingColumn\n          type={CareerRankingType.PointEfficiency}\n          title=\"得点效率\"\n          formatter={formatFixed3}\n          disableMixedMode\n        />\n      </CareerRanking>\n    </RouteDef>\n    <RouteDef path=\"career6\" title=\"局收支\">\n      <CareerRanking>\n        <CareerRankingColumn type={CareerRankingType.局收支} title=\"局收支\" formatter={formatRound} disableMixedMode />\n      </CareerRanking>\n    </RouteDef>\n    <RouteDef path=\"lucky\" title=\"欧洲人\">\n      <CareerRanking>\n        <CareerRankingColumn showNumGames={false} type={CareerRankingType.被炸率} title=\"被炸率\" />\n        <CareerRankingColumn showNumGames={false} type={CareerRankingType.里宝率} title=\"里宝率\" />\n        <CareerRankingColumn showNumGames={false} type={CareerRankingType.一发率} title=\"一发率\" />\n      </CareerRanking>\n    </RouteDef>\n    <RouteDef path=\"unlucky\" title=\"非洲人\">\n      <CareerRanking>\n        <CareerRankingColumn showNumGames={false} type={CareerRankingType.被炸率Rev} title=\"被炸率\" />\n        <CareerRankingColumn showNumGames={false} type={CareerRankingType.里宝率Rev} title=\"里宝率\" />\n        <CareerRankingColumn showNumGames={false} type={CareerRankingType.一发率Rev} title=\"一发率\" />\n      </CareerRanking>\n    </RouteDef>\n  </ViewRoutes>\n);\n\nexport default function Routes() {\n  const { t } = useTranslation();\n  if (!Array.isArray(Conf.features.ranking)) {\n    return <></>;\n  }\n  return (\n    <SimpleRoutedSubViews>\n      {ROUTES}\n      <>\n        <Alert stateName=\"rankingNotice20201229\" title={t(\"提示\")}>\n          {t(\"排行榜非实时更新，可能会有数小时的延迟。\")}\n        </Alert>\n        <NavButtons />\n        <ViewSwitch />\n      </>\n    </SimpleRoutedSubViews>\n  );\n}\n"
  },
  {
    "path": "src/components/recentHighlight/index.tsx",
    "content": "import React, { ReactNode, useEffect, useMemo } from \"react\";\nimport Helmet from \"react-helmet\";\nimport { DataProvider } from \"../../data/source/records/provider\";\nimport { DataAdapterProviderCustom } from \"../gameRecords/dataAdapterProvider\";\nimport GameRecordTable, { Column } from \"../gameRecords/table\";\nimport { COLUMN_PLAYERS, COLUMN_FULLTIME, makeColumn } from \"../gameRecords/columns\";\nimport { GameRecord, FanStatEntryList, HighlightEvent, GameRecordWithEvent } from \"../../data/types\";\nimport { TableCellProps } from \"react-virtualized/dist/es/Table\";\nimport { sum } from \"../../utils\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport i18n from \"../../i18n\";\nimport { ModelModeProvider, ModelModeSelector, useModel } from \"../modeModel\";\nimport Conf from \"../../utils/conf\";\nimport { Box, Tooltip } from \"@mui/material\";\n\nconst t = i18n.t.bind(i18n);\n\nconst EventInfo = ({ title, children }: { title: ReactNode; children: ReactNode }) => (\n  <Tooltip title={<Box whiteSpace=\"pre\">{title}</Box>} arrow placement=\"right\">\n    <Box>{children}</Box>\n  </Tooltip>\n);\n\nfunction buildEventInfo({ cellData }: TableCellProps) {\n  if (!cellData) {\n    return null;\n  }\n  const event = cellData as HighlightEvent;\n  if (!event.fan[0].役满) {\n    return (\n      <EventInfo title={FanStatEntryList.formatFanList(event.fan)}>\n        {sum(event.fan.map((x) => x.count))} <Trans>番</Trans>\n        <br />\n        <Trans>累计役满</Trans>\n      </EventInfo>\n    );\n  }\n  if (event.fan.length === 1) {\n    const label = t(event.fan[0].label);\n    if (i18n.language === \"en\") {\n      return <EventInfo title={label}>{label}</EventInfo>;\n    }\n    if (label.length > 4) {\n      return (\n        <EventInfo title={label}>\n          {label.slice(0, 4)}\n          <br />\n          {label.slice(4)}\n        </EventInfo>\n      );\n    }\n    return label;\n  } else if (event.fan.length === 2) {\n    return (\n      <EventInfo title={FanStatEntryList.formatFanList(event.fan)}>\n        <Trans>{event.fan[0].label}</Trans>\n        <br />\n        <Trans>{event.fan[1].label}</Trans>\n      </EventInfo>\n    );\n  }\n  return (\n    <EventInfo title={FanStatEntryList.formatFanList(event.fan)}>\n      {FanStatEntryList.formatFanSummary(event.fan)}\n    </EventInfo>\n  );\n}\n\nconst COLUMN_EVENTINFO = makeColumn(() => (\n  <Column dataKey=\"event\" label={<Trans>类型</Trans>} cellRenderer={buildEventInfo} width={80} />\n))();\n\nfunction getEventPlayerId(rec: GameRecord) {\n  return (rec as GameRecordWithEvent).event.player;\n}\n\nfunction RecentHighlightInner() {\n  const [model, updateModel] = useModel();\n  const provider = useMemo(() => {\n    if (!Conf.availableModes.length) {\n      return DataProvider.createHightlight(undefined);\n    }\n    return model.selectedModes && model.selectedModes.length\n      ? DataProvider.createHightlight(model.selectedModes[0])\n      : null;\n  }, [model]);\n  useEffect(() => {\n    if (!model.selectedModes || !model.selectedModes.length) {\n      if (Conf.availableModes.length) {\n        updateModel({ selectedModes: [Conf.availableModes[0]] });\n      }\n    }\n  }, [model, updateModel]);\n  if (!provider) {\n    return <></>;\n  }\n  return (\n    <DataAdapterProviderCustom provider={provider}>\n      <GameRecordTable\n        columns={[\n          COLUMN_EVENTINFO,\n          COLUMN_PLAYERS({ activePlayerId: getEventPlayerId, alwaysShowDetailLink: true }),\n          COLUMN_FULLTIME,\n        ]}\n      />\n    </DataAdapterProviderCustom>\n  );\n}\n\nexport default function RecentHighlight() {\n  const { t } = useTranslation();\n  return (\n    <>\n      <Helmet title={t(\"最近役满\")} />\n      <ModelModeProvider>\n        <ModelModeSelector />\n        <RecentHighlightInner />\n      </ModelModeProvider>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/routing/index.tsx",
    "content": "export * from \"./subView\";\n"
  },
  {
    "path": "src/components/routing/subView.tsx",
    "content": "import React from \"react\";\nimport { useContext } from \"react\";\nimport { useRouteMatch, Switch, Route, Redirect, useLocation } from \"react-router\";\nimport { Helmet } from \"react-helmet\";\nimport { TFunction, useTranslation } from \"react-i18next\";\nimport { Stack, StackProps } from \"@mui/material\";\nimport NavButton from \"../misc/navButton\";\n\ntype RouteDefProps = {\n  path: string;\n  exact?: boolean;\n  title: string | ((t: TFunction) => string);\n  disabled?: boolean;\n  children: React.ReactNode;\n};\nexport const RouteDef: React.FunctionComponent<RouteDefProps> = () => {\n  throw new Error(\"Not intended for rendering\");\n};\ntype RoutesProps = { children: React.FunctionComponentElement<RouteDefProps>[] };\nexport const ViewRoutes: React.FunctionComponent<RoutesProps> = () => {\n  throw new Error(\"Not intended for rendering\");\n};\nconst Context = React.createContext<RouteDefProps[]>([]);\n\nexport function NavButtons({\n  replace = false,\n  keepState = false,\n  withQueryString = false,\n  sx = {} as StackProps[\"sx\"],\n}) {\n  const { t } = useTranslation(\"navButtons\");\n  const routes = useContext(Context);\n  const match = useRouteMatch() || { url: \"\" };\n  const urlBase = match.url.replace(/\\/+$/, \"\");\n  return (\n    <Stack direction=\"row\" spacing={0} sx={{ mb: 2, ...sx }} flexWrap=\"wrap\">\n      {routes\n        .filter((x) => !x.disabled)\n        .map((route) => (\n          <NavButton\n            key={route.path}\n            href={(loc) => ({\n              pathname: `${urlBase}/${route.path}`,\n              state: keepState ? loc.state : undefined,\n              ...(withQueryString && loc.search ? { search: loc.search } : {}),\n            })}\n            replace={replace}\n            exact={!!route.exact}\n            color=\"info\"\n            activeProps={{ variant: \"contained\" }}\n            disableElevation\n            sx={{ mr: 1 }}\n          >\n            {typeof route.title === \"string\" ? t(route.title) : route.title(t)}\n          </NavButton>\n        ))}\n    </Stack>\n  );\n}\n\nexport function ViewSwitch({\n  defaultRenderDirectly = false,\n  mutateTitle = true,\n  children,\n}: {\n  defaultRenderDirectly?: boolean;\n  mutateTitle?: boolean;\n  children?: React.ReactChild | React.ReactChildren;\n}) {\n  const { t } = useTranslation(\"navButtons\");\n  const routes = useContext(Context);\n  const match = useRouteMatch() || { url: \"\" };\n  const loc = useLocation();\n  const urlBase = match.url.replace(/\\/+$/, \"\");\n  if (loc.pathname.indexOf(\"%\") !== -1) {\n    try {\n      decodeURI(loc.pathname);\n    } catch (e) {\n      return <Redirect to={`${urlBase.replace(/%/g, \"\")}/`} />;\n    }\n  }\n  return (\n    <Switch>\n      {routes\n        .filter((x) => !x.disabled)\n        .map((route) => (\n          <Route exact={route.exact} key={route.path} path={`${urlBase}/${route.path}`}>\n            {mutateTitle && (\n              <Helmet>\n                <title>{typeof route.title === \"string\" ? t(route.title) : route.title(t)}</title>\n              </Helmet>\n            )}\n            {route.children}\n          </Route>\n        ))}\n      <Route>\n        {defaultRenderDirectly ? (\n          routes.filter((x) => !x.disabled)[0].children\n        ) : (\n          <Redirect to={{ ...loc, pathname: `${urlBase}/${routes.filter((x) => !x.disabled)[0].path}` }} push={false} />\n        )}\n      </Route>\n      {children}\n    </Switch>\n  );\n}\n\nexport function SimpleRoutedSubViews({\n  children,\n}: {\n  children: [React.FunctionComponentElement<RoutesProps>, ...(React.ReactChild | React.ReactChildren)[]];\n}) {\n  return (\n    <Context.Provider value={children[0].props.children.map((x) => x.props)}>{children.slice(1)}</Context.Provider>\n  );\n}\n"
  },
  {
    "path": "src/components/statistics/dataByRank.tsx",
    "content": "import { forwardRef, ReactElement, useMemo, useState, VFC } from \"react\";\n\nimport { formatPercent, formatFixed3 } from \"../../utils/index\";\nimport { useAsyncFactory } from \"../../utils/async\";\nimport { getGlobalStatistics, getGlobalStatisticsSnapshot, getGlobalStatisticsYear } from \"../../data/source/misc\";\nimport Loading from \"../misc/loading\";\nimport { useModel } from \"../modeModel/model\";\nimport { Level } from \"../../data/types/level\";\nimport { ModelModeSelector } from \"../modeModel\";\nimport { useTranslation } from \"react-i18next\";\nimport Conf from \"../../utils/conf\";\nimport {\n  Box,\n  Table,\n  TableCell as MuiTableCell,\n  TableContainer,\n  TableHead,\n  TableRow,\n  TableCellProps,\n  TableBody,\n  Typography,\n  ToggleButtonGroup,\n  ToggleButton,\n  Tooltip,\n  ToggleButtonProps,\n  TooltipProps,\n  tooltipClasses,\n} from \"@mui/material\";\nimport { styled } from \"@mui/material/styles\";\nimport { DatePicker } from \"../form\";\nimport dayjs from \"dayjs\";\nimport { CalendarToday } from \"@mui/icons-material\";\nimport { GameMode } from \"../../data/types\";\n\nconst HEADERS = [\"等级\"].concat([\"一位率\", \"二位率\", \"三位率\", \"四位率\"].slice(0, Conf.rankColors.length), [\n  \"被飞率\",\n  \"平均顺位\",\n  \"和牌率\",\n  \"放铳率\",\n  \"副露率\",\n  \"立直率\",\n  \"自摸率\",\n  \"流局率\",\n  \"流听率\",\n  \"对战数\",\n  \"在位记录\",\n]);\nconst HEADERS2 = [\"等级\", \"平均打点\", \"平均铳点\", \"打点效率\", \"铳点损失\", \"净打点效率\"];\n\nconst TableCell = (props: TableCellProps) => (\n  <MuiTableCell {...props} sx={{ textAlign: \"center\", padding: 1, ...props.sx }} />\n);\n\nconst HeaderBox = styled(Box)({\n  display: \"inline\",\n  letterSpacing: \"0.5em\",\n  writingMode: \"vertical-lr\",\n  verticalAlign: \"middle\",\n  \".lang-en &\": {\n    letterSpacing: \"0.05em\",\n    marginBottom: \"0.75em\",\n  },\n});\n\ntype TooltipToggleButtonProps = ToggleButtonProps & {\n  TooltipProps: Omit<TooltipProps, \"children\">;\n};\n\nexport const StyledTooltip = styled(({ className, ...props }: TooltipProps) => {\n  return <Tooltip {...props} classes={{ popper: className }} />;\n})(() => ({\n  [`& .${tooltipClasses.tooltip}.${tooltipClasses.tooltip}.${tooltipClasses.tooltip}.${tooltipClasses.tooltip}`]: {\n    textAlign: \"center\",\n  },\n}));\nconst TooltipToggleButton: VFC<TooltipToggleButtonProps> = forwardRef(({ TooltipProps, ...props }, ref) => {\n  return (\n    <StyledTooltip {...TooltipProps}>\n      <ToggleButton ref={ref} {...props} />\n    </StyledTooltip>\n  );\n});\n\nconst dataLoaders = {\n  overall: getGlobalStatistics,\n  year: getGlobalStatisticsYear,\n};\n\nexport default function DataByRank() {\n  const { t } = useTranslation();\n  const [model] = useModel();\n  const [dataRange, setDataRange] = useState(\"overall\" as keyof typeof dataLoaders | \"date\");\n  const [cutoff] = useState(() => dayjs().startOf(\"day\").add(-1, \"day\"));\n  const [selectedDate, setSelectedDate] = useState(() => cutoff);\n  const effectiveDataRange = useMemo(\n    () => (dataRange === \"overall\" && selectedDate.isBefore(cutoff) ? \"date\" : dataRange),\n    [dataRange, selectedDate, cutoff]\n  );\n  const factory = useMemo(\n    () =>\n      effectiveDataRange !== \"date\"\n        ? dataLoaders[effectiveDataRange]\n        : (modes: GameMode[]) => getGlobalStatisticsSnapshot(selectedDate, modes),\n    [effectiveDataRange, selectedDate]\n  );\n  const modes = useMemo(\n    () =>\n      model.selectedModes\n        .filter((x) => (Conf.features.statisticsSubPages.dataByRank || []).includes(x))\n        .sort((a, b) => a - b),\n    [model]\n  );\n  const data = useAsyncFactory(\n    () => (modes && modes.length ? factory(modes) : Promise.resolve(null)),\n    [modes, effectiveDataRange, selectedDate, factory],\n    \"getGlobalStatistics_\" +\n      effectiveDataRange +\n      (effectiveDataRange === \"date\" ? selectedDate.format(\"YYYYMMDD\") : \"\") +\n      modes.join(\".\")\n  );\n  const modeData = useMemo(() => {\n    if (!data) {\n      return undefined;\n    }\n    const selectedData = data[modes.join(\".\")];\n    if (!selectedData) {\n      return undefined;\n    }\n    const modeData = Object.entries(selectedData);\n    if (!modeData) {\n      return undefined;\n    }\n    modeData.sort((a, b) => a[0].localeCompare(b[0]));\n    return modeData;\n  }, [data, modes]);\n  const haveNumPlayers = modeData && Object.values(modeData)[0][1].num_players;\n  const headers = useMemo(() => (haveNumPlayers ? HEADERS : HEADERS.slice(0, HEADERS.length - 1)), [haveNumPlayers]);\n  if (!Conf.features.statisticsSubPages.dataByRank) {\n    return <></>;\n  }\n  return (\n    <>\n      <ModelModeSelector type=\"checkbox\" availableModes={Conf.features.statisticsSubPages.dataByRank} autoSelectFirst />\n      <ToggleButtonGroup\n        exclusive\n        color=\"primary\"\n        onChange={(e, value) => value && value !== \"date\" && (setDataRange(value), setSelectedDate(cutoff))}\n        value={effectiveDataRange}\n        size=\"small\"\n      >\n        <ToggleButton value=\"overall\">{t(\"全体\")}</ToggleButton>\n        <TooltipToggleButton value=\"year\" TooltipProps={{ title: t(\"一年内对局过的玩家的一年对局数据\") || \"\" }}>\n          {t(\"活跃玩家\")}\n        </TooltipToggleButton>\n        <ToggleButton value=\"date\">\n          <DatePicker\n            min=\"2020-10-13\"\n            max={cutoff}\n            date={selectedDate}\n            onChange={(date) => {\n              setSelectedDate(date);\n              setDataRange(\"overall\");\n            }}\n            renderInput={({ inputRef, InputProps }) => (\n              <Box\n                ref={inputRef}\n                onClick={(InputProps?.endAdornment as ReactElement)?.props?.children?.props?.onClick}\n                sx={{ display: \"flex\", alignItems: \"center\" }}\n              >\n                <CalendarToday />\n                <Box ml={1}>\n                  {effectiveDataRange === \"date\"\n                    ? data?._lastModified?.format(\"YYYY-MM-DD\") || \"...\"\n                    : t(\"日期\", { ns: \"form\" })}\n                </Box>\n              </Box>\n            )}\n          />\n        </ToggleButton>\n      </ToggleButtonGroup>\n      {modeData ? (\n        <>\n          <TableContainer sx={{ mt: 2 }}>\n            <Table sx={{ textAlign: \"center\" }}>\n              <TableHead>\n                <TableRow sx={{ boxShadow: \"none\" }}>\n                  {headers.map((x) => (\n                    <TableCell key={x} sx={{ verticalAlign: \"bottom\", padding: \"1em 0 0\" }}>\n                      <HeaderBox>{t(x)}</HeaderBox>\n                    </TableCell>\n                  ))}\n                </TableRow>\n              </TableHead>\n              <TableBody>\n                {modeData.map(([levelId, levelData]) => (\n                  <TableRow key={levelId}>\n                    <TableCell className=\"text-nowrap\">{new Level(parseInt(levelId)).getTag()}</TableCell>\n                    {levelData.basic.rank_rates.slice(0, Conf.rankColors.length).map((x, i) => (\n                      <TableCell key={i}>{formatPercent(x)}</TableCell>\n                    ))}\n                    <TableCell>{formatPercent(levelData.basic.negative_rate)}</TableCell>\n                    <TableCell>{formatFixed3(levelData.basic.avg_rank)}</TableCell>\n                    <TableCell>{formatPercent(levelData.extended.和牌率)}</TableCell>\n                    <TableCell>{formatPercent(levelData.extended.放铳率)}</TableCell>\n                    <TableCell>{formatPercent(levelData.extended.副露率)}</TableCell>\n                    <TableCell>{formatPercent(levelData.extended.立直率)}</TableCell>\n                    <TableCell>{formatPercent(levelData.extended.自摸率)}</TableCell>\n                    <TableCell>{formatPercent(levelData.extended.流局率)}</TableCell>\n                    <TableCell>{formatPercent(levelData.extended.流听率)}</TableCell>\n                    <TableCell>{levelData.basic.count}</TableCell>\n                    {haveNumPlayers && <TableCell>{levelData.num_players}</TableCell>}\n                  </TableRow>\n                ))}\n              </TableBody>\n            </Table>\n          </TableContainer>\n          <TableContainer sx={{ mt: 2 }}>\n            <Table sx={{ textAlign: \"center\" }}>\n              <TableHead>\n                <TableRow sx={{ boxShadow: \"none\" }}>\n                  {HEADERS2.map((x) => (\n                    <TableCell key={x}>{t(x)}</TableCell>\n                  ))}\n                </TableRow>\n              </TableHead>\n              <TableBody>\n                {modeData.map(([levelId, levelData]) => (\n                  <TableRow key={levelId}>\n                    <TableCell className=\"text-nowrap\">{new Level(parseInt(levelId)).getTag()}</TableCell>\n                    <TableCell>{levelData.extended.平均打点}</TableCell>\n                    <TableCell>{levelData.extended.平均铳点}</TableCell>\n                    <TableCell>{levelData.extended.打点效率}</TableCell>\n                    <TableCell>{levelData.extended.铳点损失}</TableCell>\n                    <TableCell>{levelData.extended.净打点效率}</TableCell>\n                  </TableRow>\n                ))}\n              </TableBody>\n            </Table>\n          </TableContainer>\n          <Typography mt={2} textAlign=\"right\">\n            {t(\"统计对战数：\")}\n            {Math.floor(\n              modeData.map(([, levelData]) => levelData.basic.count).reduce((a, b) => a + b, 0) / Conf.rankColors.length\n            )}\n          </Typography>\n        </>\n      ) : (\n        <Loading />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/statistics/fanStats.tsx",
    "content": "import React from \"react\";\n\nimport { formatPercent } from \"../../utils/index\";\nimport { useAsyncFactory } from \"../../utils/async\";\nimport { getFanStats } from \"../../data/source/misc\";\nimport Loading from \"../misc/loading\";\nimport { FanStatEntry, FanStats, GameMode, modeLabelNonTranslated } from \"../../data/types\";\nimport { useState, useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport Conf from \"../../utils/conf\";\nimport { Grid, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from \"@mui/material\";\n\nconst SORTERS: (undefined | ((a: FanStatEntry, b: FanStatEntry) => number))[] = [\n  undefined,\n  (a, b) => a.count - b.count,\n  (a, b) => b.count - a.count,\n];\n\nexport default function FanStatsView() {\n  const { t } = useTranslation();\n  const data = useAsyncFactory(getFanStats, [], \"getFanStats\");\n  const [sorterIndex, setSorterIndex] = useState(0);\n  const sortedData = useMemo((): FanStats | undefined => {\n    if (!data) {\n      return undefined;\n    }\n    if (!SORTERS[sorterIndex]) {\n      return data;\n    }\n    const ret = { ...data };\n    for (const key of Object.keys(ret)) {\n      ret[key] = {\n        ...ret[key],\n        entries: [...ret[key].entries].sort(SORTERS[sorterIndex]),\n      };\n    }\n    return ret;\n  }, [data, sorterIndex]);\n  if (!sortedData) {\n    return <Loading />;\n  }\n  return (\n    <>\n      <Grid container spacing={2} rowSpacing={3} mt={2}>\n        {Object.entries(sortedData)\n          .map(([modeId, value]) => [parseInt(modeId, 10) as GameMode, value] as [GameMode, typeof value])\n          .sort(([id1], [id2]) => Conf.availableModes.indexOf(id1) - Conf.availableModes.indexOf(id2))\n          .map(([mode, value]) => (\n            <Grid item lg={4} md={6} xs={12} key={mode}>\n              <Typography variant=\"h5\" textAlign=\"center\">\n                {t(modeLabelNonTranslated(mode))}\n              </Typography>\n              <Typography textAlign=\"center\" mt={1}>\n                {t(\"记录和出局数：\")}\n                {value.count}\n              </Typography>\n              <TableContainer sx={{ mt: 1 }}>\n                <Table>\n                  <TableHead\n                    onClick={() => setSorterIndex((sorterIndex + 1) % SORTERS.length)}\n                    sx={{ cursor: \"pointer\" }}\n                  >\n                    <TableRow>\n                      <TableCell>{t(\"役\")}</TableCell>\n                      <TableCell sx={{ textAlign: \"right\" }}>{t(\"记录数\")}</TableCell>\n                      <TableCell sx={{ textAlign: \"right\" }}>{t(\"比率\")}</TableCell>\n                    </TableRow>\n                  </TableHead>\n                  <TableBody>\n                    {value.entries.map((x) => (\n                      <TableRow key={x.label}>\n                        <TableCell>{t(x.label)}</TableCell>\n                        <TableCell sx={{ textAlign: \"right\" }}>{x.count}</TableCell>\n                        <TableCell sx={{ textAlign: \"right\" }}>\n                          {x.count\n                            ? x.count / value.count < 0.0001\n                              ? \"<0.01%\"\n                              : formatPercent(x.count / value.count)\n                            : \"\"}\n                        </TableCell>\n                      </TableRow>\n                    ))}\n                  </TableBody>\n                </Table>\n              </TableContainer>\n            </Grid>\n          ))}\n      </Grid>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/statistics/index.tsx",
    "content": "import React from \"react\";\n\nimport { ModelModeProvider } from \"../modeModel\";\nimport { ViewRoutes, SimpleRoutedSubViews, NavButtons, RouteDef } from \"../routing\";\nimport { ViewSwitch } from \"../routing/index\";\n\nimport RankBySeats from \"./rankBySeats\";\nimport DataByRank from \"./dataByRank\";\nimport FanStats from \"./fanStats\";\nimport Conf from \"../../utils/conf\";\nimport NumPlayerStats from \"./numPlayerStats\";\n\nconst ROUTES = (\n  <ViewRoutes>\n    <RouteDef path=\"rank-by-seat\" title=\"坐席顺位\" disabled={!Conf.features.statisticsSubPages.rankBySeat}>\n      <RankBySeats />\n    </RouteDef>\n    <RouteDef path=\"data-by-rank\" title=\"等级数据\" disabled={!Conf.features.statisticsSubPages.dataByRank}>\n      <DataByRank />\n    </RouteDef>\n    <RouteDef path=\"fan-stats\" title=\"和出役种统计\" disabled={!Conf.features.statisticsSubPages.fanStats}>\n      <FanStats />\n    </RouteDef>\n    <RouteDef path=\"num-player-stats\" title=\"记录玩家数\" disabled={!Conf.features.statisticsSubPages.numPlayerStats}>\n      <NumPlayerStats />\n    </RouteDef>\n  </ViewRoutes>\n);\n\nexport default function Routes() {\n  return (\n    <SimpleRoutedSubViews>\n      {ROUTES}\n      <ModelModeProvider>\n        <NavButtons />\n        <ViewSwitch />\n      </ModelModeProvider>\n    </SimpleRoutedSubViews>\n  );\n}\n"
  },
  {
    "path": "src/components/statistics/numPlayerStats.tsx",
    "content": "import { Box, Grid, Table, TableBody, TableCell, TableRow, Typography } from \"@mui/material\";\nimport { useMemo, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { getLevelStatistics } from \"../../data/source/misc\";\nimport { getZoneTag, Level, LevelStatistics, LevelStatisticsItem } from \"../../data/types\";\nimport { formatPercent } from \"../../utils\";\nimport { useAsyncFactory } from \"../../utils/async\";\nimport SimplePieChart, { PieChartItem } from \"../charts/simplePieChart\";\nimport Loading from \"../misc/loading\";\n\nfunction groupData(\n  raw: LevelStatistics,\n  getLabel: (x: LevelStatisticsItem) => string,\n  getKey = getLabel\n): (PieChartItem & { percent: string; key: string })[] {\n  const map = new Map<string, LevelStatisticsItem[]>();\n  const labels: string[] = [];\n  for (const item of raw) {\n    const key = getKey(item);\n    const list = map.get(key) || [];\n    list.push(item);\n    if (!map.has(key)) {\n      map.set(key, list);\n      labels.push(key);\n    }\n  }\n  const items = labels.map((key) => ({\n    value:\n      map\n        .get(key)\n        ?.map((x) => x[2])\n        .reduce((a, b) => a + b, 0) || 0,\n    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n    label: getLabel(map.get(key)![0]!),\n    key,\n  }));\n  const total = items.reduce((a, b) => a + b.value, 0);\n  return items.map((x) => ({\n    key: x.key,\n    value: x.value,\n    percent: formatPercent(x.value / total),\n    innerLabel: x.label.replace(\n      /\\{\\{(\\w+)\\}\\}/g,\n      (_, key) => ({ value: x.value, percentage: formatPercent(x.value / total) }[key as string] as string)\n    ),\n  }));\n}\n\nexport default function NumPlayerStats() {\n  const { t } = useTranslation();\n  const data = useAsyncFactory(getLevelStatistics, [], \"getLevelStatistics\");\n  const serverStats = useMemo(\n    () =>\n      data\n        ? groupData(\n            data,\n            (x) => `${getZoneTag(x[0])} {{value}} / {{percentage}}`,\n            (x) => x[0].toString()\n          )\n        : [],\n    [data]\n  );\n  const [selectedServer, setSelectedServer] = useState(null as null | typeof serverStats[0]);\n  const levelStats = useMemo(() => {\n    if (!data) {\n      return [];\n    }\n    const filteredData = selectedServer ? data.filter((x) => x[0].toString() === selectedServer.key) : data;\n    const majorLevelHandled = new Map<string, { count: number; entries: number }>();\n    return groupData(filteredData, (x) => new Level(x[1]).getTag()).map(\n      (x: ReturnType<typeof groupData>[0] & { majorLevel?: { count: number; entries: number } }) => {\n        const majorLevel = x.innerLabel?.replace(/\\d+$/, \"\");\n        if (majorLevel) {\n          const record = majorLevelHandled.get(majorLevel) || { count: 0, entries: 0 };\n          if (!record.count) {\n            x.majorLevel = record;\n            majorLevelHandled.set(majorLevel, record);\n          }\n          record.count += x.value;\n          record.entries++;\n        }\n        return x;\n      }\n    );\n  }, [data, selectedServer]);\n  if (!data) {\n    return <Loading />;\n  }\n  const total = levelStats.reduce((a, b) => a + b.value, 0);\n  return (\n    <Grid container mt={4}>\n      <Grid item xs={12} lg overflow=\"hidden\">\n        <Typography variant=\"h5\" textAlign=\"center\">\n          {t(\"按服务器\")}\n        </Typography>\n        <Box maxWidth={576} marginX=\"auto\" my={3}>\n          <SimplePieChart onSelect={setSelectedServer} pieProps={{ outerRadius: \"95%\" }} items={serverStats} />\n        </Box>\n      </Grid>\n      <Grid item xs={12} lg overflow=\"hidden\">\n        <Typography variant=\"h5\" textAlign=\"center\">\n          {t(\"按等级\")}\n        </Typography>\n        <Box mt={4}>\n          <Table sx={{ \"td:not(:first-child)\": { textAlign: \"right\" } }}>\n            <TableBody>\n              {levelStats.map((x) => (\n                <TableRow key={x.innerLabel}>\n                  <TableCell>{x.innerLabel}</TableCell>\n                  <TableCell>{x.value}</TableCell>\n                  <TableCell>{x.percent}</TableCell>\n                  {x.majorLevel &&\n                    ((x.majorLevel?.entries || 0) > 1 ? (\n                      <>\n                        <TableCell rowSpan={x.majorLevel?.entries}>{x.majorLevel?.count}</TableCell>\n                        <TableCell rowSpan={x.majorLevel?.entries}>\n                          {formatPercent((x.majorLevel?.count || 0) / total)}\n                        </TableCell>\n                      </>\n                    ) : (\n                      <TableCell colSpan={2} />\n                    ))}\n                </TableRow>\n              ))}\n            </TableBody>\n          </Table>\n        </Box>\n      </Grid>\n    </Grid>\n  );\n}\n"
  },
  {
    "path": "src/components/statistics/rankBySeats.tsx",
    "content": "import React from \"react\";\nimport { useAsyncFactory } from \"../../utils/async\";\nimport { getRankRateBySeat } from \"../../data/source/misc\";\nimport Loading from \"../misc/loading\";\nimport { useMemo } from \"react\";\nimport { useModel, ModelModeSelector } from \"../modeModel\";\nimport SimplePieChart from \"../charts/simplePieChart\";\nimport { useTranslation } from \"react-i18next\";\nimport { RankRates } from \"../../data/types\";\nimport Conf from \"../../utils/conf\";\nimport { Grid, Typography } from \"@mui/material\";\n\nconst SEAT_LABELS = \"东南西北\";\n\nfunction Chart({ rates, numGames, aspect = 1 }: { rates: RankRates; numGames: number; aspect?: number }) {\n  const { t } = useTranslation();\n  const items = useMemo(\n    () =>\n      rates.map((x, index) => ({\n        value: x,\n        outerLabel: t(SEAT_LABELS[index]),\n        innerLabel: `${(x * 100).toFixed(2)}%\\n[${Math.round(x * numGames)}]`,\n      })),\n    [rates, numGames, t]\n  );\n  return <SimplePieChart aspect={aspect} items={items} />;\n}\n\nexport default function RankBySeats() {\n  const { t } = useTranslation();\n  const data = useAsyncFactory(getRankRateBySeat, [], \"getRankRateBySeat\");\n  const [model] = useModel();\n  if (!data) {\n    return <Loading />;\n  }\n  const selectedData = Conf.availableModes.length\n    ? model.selectedModes && model.selectedModes.length && data[model.selectedModes[0]]\n    : data[0];\n  return (\n    <>\n      <ModelModeSelector autoSelectFirst={true} />\n      {selectedData ? (\n        <>\n          <Grid container mt={2}>\n            <Grid item xs={12} sm overflow=\"hidden\">\n              <Typography variant=\"h5\" textAlign=\"center\">\n                {t(\"坐席吃一率\")}\n              </Typography>\n              <Chart rates={selectedData[1]} numGames={selectedData.numGames} />\n            </Grid>\n            <Grid item xs={12} sm overflow=\"hidden\">\n              <Typography variant=\"h5\" textAlign=\"center\">\n                {t(`坐席吃${selectedData.length > 4 ? \"四\" : \"三\"}率`)}\n              </Typography>\n              <Chart rates={selectedData[selectedData.length - 1]} numGames={selectedData.numGames} />\n            </Grid>\n          </Grid>\n          <Typography textAlign=\"right\">\n            {t(\"统计对战数：\")}\n            {selectedData.numGames}\n          </Typography>\n        </>\n      ) : (\n        <></>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/data/source/api.ts",
    "content": "/* eslint-disable @typescript-eslint/no-empty-function */\nimport dayjs from \"dayjs\";\nimport Conf from \"../../utils/conf\";\nimport { savePreference } from \"../../utils/preference\";\n\nconst DATA_MIRRORS = [\n  \"https://5-data.amae-koromo.com/\",\n  \"https://1.data.amae-koromo.com/\",\n  \"https://2.data.amae-koromo.com/\",\n  \"https://4.data.amae-koromo.com/\",\n];\nconst PROBE_TIMEOUT = 15000;\n\nlet selectedMirror = DATA_MIRRORS[0];\n\nlet onMaintenance: (msg: string) => void = () => {};\n\nexport function setMaintenanceHandler(handler: (msg: string) => void) {\n  onMaintenance = handler;\n}\n\nexport const getApiPrefix = () => selectedMirror + Conf.apiSuffix;\n\nasync function fetchWithTimeout(\n  url: string,\n  opts: Parameters<typeof fetch>[1] = {},\n  timeout = 5000\n): Promise<Response> {\n  const abortController = window.AbortController ? new AbortController() : { signal: undefined, abort: () => {} };\n  const timeoutToken = setTimeout(function () {\n    abortController.abort();\n  }, timeout);\n  const ret = fetch(url, { ...opts, signal: abortController.signal }) as Promise<Response>;\n  ret.then(() => clearTimeout(timeoutToken)).catch(() => clearTimeout(timeoutToken));\n  return ret;\n}\n\nlet mirrorProbePromise = null as null | Promise<Response>;\n\nasync function fetchData(path: string, opts: Parameters<typeof fetch>[1] = {}, retry = true): Promise<Response> {\n  try {\n    return await fetchWithTimeout(selectedMirror + path, opts);\n  } catch (e) {\n    console.warn(e);\n    if (!retry) {\n      throw e;\n    }\n    if (mirrorProbePromise) {\n      console.warn(`Failed to fetch data from mirror ${selectedMirror}, waiting for probe in progress...`);\n      await mirrorProbePromise.then(() => {}).catch(() => {});\n      return fetchData(path, opts, false);\n    }\n    console.warn(`Failed to fetch data from mirror ${selectedMirror}, trying other mirror...`);\n  }\n\n  mirrorProbePromise = (async function () {\n    let completedResponse = null as null | Response;\n    return Promise.race(\n      DATA_MIRRORS.map((mirror) =>\n        fetchWithTimeout(mirror + path, opts, PROBE_TIMEOUT)\n          .then(function (resp) {\n            if (completedResponse) {\n              return resp;\n            }\n            completedResponse = resp;\n            selectedMirror = mirror;\n            savePreference(\"selectedMirror\", selectedMirror);\n            console.log(`Set ${mirror} as preferred`);\n            return resp;\n          })\n          .catch(\n            (e) =>\n              new Promise((resolve) =>\n                setTimeout(() => {\n                  if (completedResponse) {\n                    return resolve(completedResponse);\n                  }\n                  resolve(e); // Do not reject here, may cause unhandled promise rejection\n                }, PROBE_TIMEOUT)\n              )\n          )\n      )\n    ).then((result) => {\n      if (\"ok\" in (result as Response | Error)) {\n        return result;\n      }\n      return Promise.reject(result);\n    }) as Promise<Response>;\n  })();\n  mirrorProbePromise.then(() => (mirrorProbePromise = null)).catch(() => (mirrorProbePromise = null));\n  return mirrorProbePromise;\n}\n\nlet apiCache = {} as { [path: string]: unknown };\n\nexport type ApiError = Error & {\n  status: number;\n  statusText: string;\n  url: string;\n};\n\nexport type WithLastModified = {\n  readonly _lastModified?: dayjs.Dayjs;\n};\n\nasync function handleResponse<T>(cacheKey: string, resp: Response): Promise<T & WithLastModified> {\n  if (!resp.ok) {\n    const error = new Error(\"Failed API call\");\n    Object.assign(error, {\n      response: resp,\n      status: resp.status,\n      statusText: resp.statusText,\n      headers: resp.headers,\n      url: resp.url,\n      json:\n        resp.json?.bind(resp) ||\n        (async () => {\n          throw resp;\n        }),\n    });\n    throw error;\n  }\n  let data = await resp.json();\n  if (data?.maintenance) {\n    onMaintenance(data.maintenance);\n    return new Promise(() => {}) as Promise<T & WithLastModified>; // Freeze all other components\n  }\n  if (data?.result_key) {\n    await new Promise((res) => setTimeout(res, 1000));\n    const resultResp = await fetchData(`${Conf.apiSuffix}result/${data.result_key}`, {\n      headers: {\n        \"Cache-Control\": \"max-age=0, no-cache\",\n      },\n    });\n    return handleResponse(cacheKey, resultResp);\n  }\n  const lastModified = resp.headers.get(\"last-modified\");\n  if (lastModified && typeof data === \"object\") {\n    const parsed = dayjs.utc(lastModified.slice(lastModified.indexOf(\" \") + 1), \"DD MMM YYYY HH:mm:ss\");\n    if (parsed.isValid()) {\n      data = Object.defineProperty(data, \"_lastModified\", { value: parsed, writable: false });\n    }\n  }\n  if (Object.keys(apiCache).length > 500) {\n    apiCache = {};\n  }\n  apiCache[cacheKey] = data;\n  return data as T & WithLastModified;\n}\n\nexport async function apiGet<T>(path: string): Promise<T & { _lastModified?: dayjs.ConfigType }> {\n  if (path in apiCache) {\n    return apiCache[path] as T & WithLastModified;\n  }\n  const resp = await fetchData(Conf.apiSuffix + path);\n  return await handleResponse(path, resp);\n}\n\nexport async function apiCacheablePost<T>(path: string, body: unknown): Promise<T> {\n  const bodyStr = JSON.stringify(body);\n  const key = `${path}|${bodyStr}`;\n  if (key in apiCache) {\n    return apiCache[key] as T;\n  }\n  const resp = await fetchData(Conf.apiSuffix + path, {\n    method: \"POST\",\n    body: bodyStr,\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n  });\n  return await handleResponse(key, resp);\n}\n"
  },
  {
    "path": "src/data/source/misc.ts",
    "content": "/* eslint-disable @typescript-eslint/no-use-before-define */\n/* eslint-disable no-use-before-define */\nimport dayjs from \"dayjs\";\n\nimport { apiGet } from \"./api\";\nimport { PlayerMetadataLite, PlayerExtendedStats, GameMode } from \"../types\";\nimport { RankingTimeSpan, DeltaRankingResponse } from \"../types\";\nimport { RankRateBySeat } from \"../types\";\nimport { CareerRankingItem, CareerRankingType } from \"../types/ranking\";\nimport { GlobalStatistics, FanStats, GlobalHistogram, LevelStatistics } from \"../types/statistics\";\n\n\nexport type PlayerSearchResult = Pick<PlayerMetadataLite, \"id\" | \"nickname\" | \"level\"> & {\n  latest_timestamp: number;\n};\nexport async function searchPlayer(prefix: string, limit = 20): Promise<PlayerSearchResult[]> {\n  prefix = prefix.trim();\n  if (!prefix) {\n    return [];\n  }\n  const result = await apiGet<PlayerSearchResult[]>(\n    `search_player/${encodeURIComponent(prefix)}?limit=${limit}&tag=all`\n  );\n  return result || [];\n}\n\nexport async function getExtendedStats(\n  playerId: number,\n  startDate?: dayjs.ConfigType,\n  endDate?: dayjs.ConfigType,\n  mode = \"\"\n): Promise<PlayerExtendedStats> {\n  let datePath = \"\";\n  if (startDate) {\n    datePath += `/${dayjs(startDate).valueOf()}`;\n    if (endDate) {\n      datePath += `/${dayjs(endDate).valueOf()}`;\n    }\n  }\n  return await apiGet<PlayerExtendedStats>(`player_extended_stats/${playerId}${datePath}?mode=${mode}`);\n}\n\nexport async function getDeltaRanking(timespan: RankingTimeSpan): Promise<DeltaRankingResponse> {\n  return await apiGet<DeltaRankingResponse>(`player_delta_ranking/${timespan}`);\n}\n\nexport async function getCareerRanking(\n  type: CareerRankingType,\n  modeId?: string,\n  minGames?: number\n): Promise<CareerRankingItem[]> {\n  minGames = minGames || 300;\n  const suffix = minGames === 300 ? \"\" : `_${minGames}`;\n  return await apiGet<CareerRankingItem[]>(`career_ranking/${type + suffix}?mode=${modeId || \"\"}`);\n}\n\nexport async function getGlobalStatistics(modes: GameMode[]): Promise<GlobalStatistics> {\n  return await apiGet<GlobalStatistics>(`global_statistics_2?mode=${modes.join(\".\")}`);\n}\nexport async function getGlobalStatisticsYear(modes: GameMode[]): Promise<GlobalStatistics> {\n  return await apiGet<GlobalStatistics>(`global_statistics_year?mode=${modes.join(\".\")}`);\n}\nexport async function getGlobalStatisticsSnapshot(\n  date: dayjs.ConfigType,\n  modes: GameMode[]\n): Promise<GlobalStatistics> {\n  return await apiGet<GlobalStatistics>(\n    `global_statistics_snapshot/${dayjs(date).format(\"YYYY-MM-DD\")}?mode=${modes.join(\".\")}`\n  );\n}\nexport async function getLevelStatistics(): Promise<LevelStatistics> {\n  return await apiGet<LevelStatistics>(\"level_statistics\").then((data) => {\n    data.sort((a, b) => a[1] - b[1]);\n    return data;\n  });\n}\nexport async function getGlobalHistogram(): Promise<GlobalHistogram> {\n  return await apiGet<GlobalHistogram>(\"global_histogram\");\n}\nexport async function getFanStats(): Promise<FanStats> {\n  return await apiGet<FanStats>(\"fan_stats\");\n}\n\nexport async function getRankRateBySeat(): Promise<RankRateBySeat> {\n  type RawResponse = [[number, number, number], number][];\n  let rawResp = await apiGet<RawResponse>(\"rank_rate_by_seat\");\n  if (rawResp.some((x) => x[0][0] === null)) {\n    // Contest\n    rawResp = rawResp.filter((x) => x[0][0] !== 0);\n  }\n  const counts: {\n    [modeId: string]: { [rank: number]: number };\n  } = {};\n  let maxRank = 0;\n  for (const [[modeId, rank], count] of rawResp) {\n    if (maxRank < rank) {\n      maxRank = rank;\n    }\n    const modeIdStr = (modeId || 0).toString();\n    counts[modeIdStr] = counts[modeIdStr] || [];\n    counts[modeIdStr][rank] = counts[modeIdStr][rank] || 0;\n    counts[modeIdStr][rank] += count;\n  }\n  const result: RankRateBySeat = {};\n  for (const [[modeId, rank, seatId], count] of rawResp) {\n    const modeIdStr = (modeId || 0).toString();\n    result[modeIdStr] = result[modeIdStr] || [];\n    result[modeIdStr].numGames = counts[modeIdStr][rank];\n    result[modeIdStr][rank] = result[modeIdStr][rank] || Array(maxRank).fill(0);\n    result[modeIdStr][rank][seatId] = count / counts[modeIdStr][rank];\n  }\n  return result;\n}\n"
  },
  {
    "path": "src/data/source/records/loader.ts",
    "content": "import dayjs from \"dayjs\";\n\nimport { GameRecord, GameRecordWithEvent } from \"../../types/record\";\nimport { Metadata, PlayerMetadata, PlayerExtendedStats, MODE_BASE_POINT } from \"../../types/metadata\";\nimport { apiCacheablePost, apiGet } from \"../api\";\nimport { GameMode } from \"../../types\";\nimport Conf from \"../../../utils/conf\";\n\nconst CHUNK_SIZE = 100;\n\nexport interface DataLoader<T extends Metadata, TRecord = GameRecord> {\n  getMetadata(): Promise<T>;\n  getNextChunk(): Promise<TRecord[]>;\n  getEstimatedChunkSize(): number;\n}\n\nexport class DummyDataLoader implements DataLoader<Metadata> {\n  getMetadata(): Promise<Metadata> {\n    return Promise.resolve({ count: 0 });\n  }\n  getNextChunk(): Promise<GameRecord[]> {\n    return Promise.resolve([]);\n  }\n  getEstimatedChunkSize(): number {\n    return 0;\n  }\n}\n\nexport class RecentHighlightDataLoader implements DataLoader<Metadata> {\n  _data: Promise<GameRecord[]>;\n  _index: number;\n  constructor(mode: GameMode | undefined, numItems = 100) {\n    this._index = 0;\n    this._data = apiGet<GameRecordWithEvent[]>(`recent_highlight_games?limit=${numItems}&mode=${mode || \"\"}`)\n      .then((data) => {\n        if (data.every((x) => x.uuid)) {\n          return data;\n        }\n        return apiGet<GameRecordWithEvent[]>(`games_by_id/${data.map((x) => x._id).join(\",\")}`).then((records) => {\n          const recordMap = {} as { [key: string]: GameRecordWithEvent };\n          records.forEach((x) => (recordMap[x._id || \"\"] = x));\n          return data.map((x) => ({ ...x, ...recordMap[x._id || \"\"] }));\n        });\n      })\n      .then((data) => data.sort((a, b) => b.startTime - a.startTime))\n      .catch((e) => {\n        if (e.status === 404) {\n          return [];\n        }\n        return Promise.reject(e);\n      });\n  }\n  getEstimatedChunkSize() {\n    return CHUNK_SIZE;\n  }\n  async getMetadata(): Promise<Metadata> {\n    return this._data.then((x) => ({ count: x.length }));\n  }\n  async getNextChunk(): Promise<GameRecord[]> {\n    const index = this._index;\n    this._index += CHUNK_SIZE;\n    return this._data.then((data) => data.slice(index, index + CHUNK_SIZE));\n  }\n}\n\nexport class ListingDataLoader implements DataLoader<Metadata> {\n  _date: dayjs.Dayjs;\n  _cursor: dayjs.Dayjs;\n  _modeString: string;\n  constructor(date: dayjs.ConfigType, mode: GameMode | null) {\n    this._date = dayjs(date).startOf(\"day\");\n    const cursor = Math.floor(new Date().getTime() / 120000) * 120000;\n    this._cursor = dayjs(Math.min(this._date.clone().add(1, \"day\").valueOf() - 1, cursor));\n    this._modeString = mode && mode.toString() !== \"0\" ? mode.toString() : \"\";\n  }\n  getEstimatedChunkSize() {\n    return CHUNK_SIZE;\n  }\n  shouldReturnEmptyResult() {\n    return !this._modeString && Conf.availableModes.length > 1;\n  }\n  async getMetadata(): Promise<Metadata> {\n    if (this.shouldReturnEmptyResult()) {\n      return { count: 0 };\n    }\n    return { count: +Infinity };\n  }\n  async getNextChunk(): Promise<GameRecord[]> {\n    if (this._cursor.isBefore(this._date) || this._cursor.isSame(this._date) || this.shouldReturnEmptyResult()) {\n      return [];\n    }\n    const chunk = await apiGet<GameRecord[]>(\n      `games/${this._cursor.valueOf()}/${this._date.valueOf()}?limit=${CHUNK_SIZE}&descending=true&mode=${\n        this._modeString\n      }`\n    );\n    if (chunk.length) {\n      this._cursor = dayjs(chunk[chunk.length - 1].startTime * 1000 - 1);\n    } else {\n      this._cursor = this._date;\n    }\n    return chunk;\n  }\n}\n\nfunction processExtendedStats(stats: PlayerMetadata): (value: PlayerExtendedStats) => PlayerExtendedStats {\n  return (extendedStats) => {\n    const gameBasePoint = MODE_BASE_POINT[Conf.availableModes[0]];\n    if (gameBasePoint) {\n      extendedStats.局收支 =\n        ((stats.rank_rates.reduce((acc, x, index) => acc + x * stats.rank_avg_score[index], 0) - gameBasePoint) *\n          stats.count) /\n        extendedStats.count;\n    }\n    stats.extended_stats = extendedStats;\n    return extendedStats;\n  };\n}\n\nexport class PlayerDataLoader implements DataLoader<PlayerMetadata> {\n  _playerId: string;\n  _startDate: dayjs.Dayjs;\n  _endDate: dayjs.Dayjs;\n  _cursor: dayjs.Dayjs;\n  _mode: GameMode[];\n  _initialParams: string;\n  _tag: string;\n  constructor(playerId: string, startDate?: dayjs.Dayjs, endDate?: dayjs.Dayjs, mode = [] as GameMode[]) {\n    this._playerId = playerId;\n    this._startDate = startDate || dayjs(\"2010-01-01T00:00:00.000Z\");\n    this._endDate = endDate || dayjs().endOf(\"minute\");\n    this._cursor = this._endDate;\n    this._mode = mode;\n    this._initialParams = this._getParams();\n    this._tag = \"\";\n  }\n  _getDatePath(): string {\n    let result = `/${this._startDate.valueOf()}`;\n    if (this._cursor) {\n      result += `/${this._cursor.valueOf()}`;\n    }\n    return result;\n  }\n  _getParams(mode = this._mode): string {\n    return `${this._playerId}${this._getDatePath()}?mode=${(mode.length ? mode : Conf.availableModes).join(\".\")}`;\n  }\n  getEstimatedChunkSize() {\n    return CHUNK_SIZE;\n  }\n  async getMetadata(): Promise<PlayerMetadata> {\n    if (this._endDate.isBefore(this._startDate)) {\n      return Promise.reject(new Error(\"Invalid date range\"));\n    }\n    const timeTag = Math.floor(new Date().getTime() / 1000 / 60 / 60);\n    const stats = await apiGet<PlayerMetadata>(`player_stats/${this._initialParams}&tag=${timeTag}`);\n    if (this._mode.length || !Conf.availableModes.length) {\n      stats.extended_stats = apiGet<PlayerExtendedStats>(\n        `player_extended_stats/${this._initialParams}&tag=${timeTag}`\n      ).then(processExtendedStats(stats));\n      stats.extended_stats.catch((e) => {\n        console.error(\"Failed to get extended stats:\", e);\n      });\n    }\n    if (!this._mode.length && Conf.availableModes.length) {\n      stats.count = 0;\n    }\n    let crossStats = stats;\n    if (this._mode.length && !Conf.availableModes.every((x) => this._mode.includes(x))) {\n      crossStats = await apiGet<PlayerMetadata>(`player_stats/${this._getParams([])}&tag=${timeTag}`);\n    }\n    stats.cross_stats = {\n      id: crossStats.id,\n      level: crossStats.level,\n      max_level: crossStats.max_level,\n      played_modes:\n        crossStats.played_modes\n          ?.map((x) => (typeof x === \"string\" ? (parseInt(x, 10) as GameMode) : x))\n          ?.sort((a, b) => Conf.availableModes.indexOf(a) - Conf.availableModes.indexOf(b)) || [],\n      nickname: crossStats.nickname,\n      count: crossStats.count,\n    };\n    this._tag = stats.count.toString();\n    return stats;\n  }\n  async getNextChunk(): Promise<GameRecord[]> {\n    if (this._cursor.isBefore(this._startDate) || this._cursor.isSame(this._startDate)) {\n      return [];\n    }\n    if (!this._mode.length && Conf.availableModes.length) {\n      return [];\n    }\n    const chunk = await apiGet<GameRecord[]>(\n      `player_records/${this._playerId}/${this._cursor.valueOf()}/${this._startDate.valueOf()}?limit=${\n        CHUNK_SIZE + ((parseInt(this._tag, 10) || 0) % CHUNK_SIZE)\n      }&mode=${this._mode}&descending=true&tag=${this._tag}`\n    );\n    if (chunk.length) {\n      this._cursor = dayjs(chunk[chunk.length - 1].startTime * 1000 - 1);\n    } else {\n      this._cursor = this._startDate;\n    }\n    this._tag = \"\";\n    return chunk;\n  }\n}\nexport class FilteredPlayerDataLoader implements DataLoader<PlayerMetadata> {\n  private _recordPromise: Promise<GameRecord[]> | GameRecord[] | null = null;\n  private _chunkReturned = false;\n  constructor(private _playerId: string, private _loadRecord: () => Promise<GameRecord[]>, private _mode: GameMode[]) {\n    if (!_mode.length) {\n      throw new Error(\"No mode\");\n    }\n    _mode.sort((a, b) => Conf.availableModes.indexOf(a) - Conf.availableModes.indexOf(b));\n  }\n  getEstimatedChunkSize() {\n    return CHUNK_SIZE;\n  }\n  private async getRecords(): Promise<GameRecord[]> {\n    if (!this._recordPromise) {\n      this._recordPromise = this._loadRecord().then((records) => {\n        this._recordPromise = records;\n        return records;\n      });\n    }\n    return this._recordPromise;\n  }\n  async getMetadata(): Promise<PlayerMetadata> {\n    const records = await this.getRecords();\n    if (!records.length) {\n      throw new Error(\"No records\");\n    }\n    const keys = records.map((x) => x.startTime);\n    keys.sort((a, b) => b - a);\n    const stats = await apiCacheablePost<PlayerMetadata>(`player_stats/${this._playerId}`, { keys, modes: this._mode });\n    if (this._mode.length || !Conf.availableModes.length) {\n      stats.extended_stats = apiCacheablePost<PlayerExtendedStats>(`player_extended_stats/${this._playerId}`, {\n        keys,\n        modes: this._mode,\n      }).then(processExtendedStats(stats));\n      stats.extended_stats.catch((e) => {\n        console.error(\"Failed to get extended stats:\", e);\n      });\n    }\n    const crossStats = stats;\n    stats.cross_stats = {\n      id: crossStats.id,\n      level: crossStats.level,\n      max_level: crossStats.max_level,\n      played_modes:\n        crossStats.played_modes\n          ?.map((x) => (typeof x === \"string\" ? (parseInt(x, 10) as GameMode) : x))\n          ?.sort((a, b) => Conf.availableModes.indexOf(a) - Conf.availableModes.indexOf(b)) || [],\n      nickname: crossStats.nickname,\n      count: crossStats.count,\n    };\n    return stats;\n  }\n  async getNextChunk(): Promise<GameRecord[]> {\n    if (this._chunkReturned) {\n      return [];\n    }\n    const chunk = await this.getRecords();\n    this._chunkReturned = true;\n    return chunk;\n  }\n}\nexport class FixedNumberPlayerDataLoader extends PlayerDataLoader {\n  _limit: number;\n  _data: GameRecord[];\n  constructor(playerId: string, limit: number, mode: GameMode[]) {\n    super(playerId, undefined, dayjs().endOf(\"hour\"), mode);\n    if (!mode.length) {\n      if (Conf.availableModes.length <= 1) {\n        mode = Conf.availableModes;\n        this._mode = mode;\n      } else {\n        throw new Error(\"No mode specified\");\n      }\n    }\n    this._limit = limit;\n    this._data = [];\n  }\n  getEstimatedChunkSize() {\n    return this._limit;\n  }\n  async getMetadata(): Promise<PlayerMetadata> {\n    const chunk = await apiGet<GameRecord[]>(\n      `player_records/${this._playerId}/${this._endDate.valueOf()}/${this._startDate.valueOf()}?limit=${\n        this._limit\n      }&mode=${this._mode}&descending=true`\n    );\n    if (!chunk.length) {\n      throw new Error(\"No data\");\n    }\n    this._data = chunk;\n    this._startDate = dayjs(chunk[chunk.length - 1].startTime * 1000);\n    this._initialParams = this._getParams();\n    return super.getMetadata().then((x) => {\n      this._cursor = this._startDate;\n      return x;\n    });\n  }\n  async getNextChunk(): Promise<GameRecord[]> {\n    const chunk = this._data;\n    this._data = [];\n    return chunk;\n  }\n}\n"
  },
  {
    "path": "src/data/source/records/provider.ts",
    "content": "import dayjs from \"dayjs\";\n\nimport { GameRecord } from \"../../types/record\";\nimport { Metadata, PlayerMetadata } from \"../../types/metadata\";\nimport {\n  ListingDataLoader,\n  PlayerDataLoader,\n  DataLoader,\n  RecentHighlightDataLoader,\n  FixedNumberPlayerDataLoader,\n  DummyDataLoader,\n  FilteredPlayerDataLoader,\n} from \"./loader\";\nimport { GameMode } from \"../../types\";\n\nexport type FilterPredicate<TRecord = GameRecord> = ((record: TRecord) => boolean) | null;\nclass DataProviderImpl<TMetadata extends Metadata, TRecord extends { uuid: string } = GameRecord> {\n  _loader: DataLoader<TMetadata, TRecord>;\n  _metadata: TMetadata | Promise<TMetadata> | null;\n  _metadataError?: unknown;\n  _countPromise: Promise<number> | null;\n  _loadingPromise: Promise<unknown> | null;\n  _data: TRecord[];\n  _filterPredicate: FilterPredicate<TRecord>;\n  _filteredIndices: number[] | null;\n  _filterResultCache: { [uuid: string]: boolean };\n\n  constructor(loader: DataLoader<TMetadata, TRecord>) {\n    this._loader = loader;\n    this._metadata = null;\n    this._data = [];\n    this._countPromise = null;\n    this._filterPredicate = null;\n    this._filteredIndices = null;\n    this._filterResultCache = {};\n    this._loadingPromise = null;\n  }\n  setFilterPredicate(predicate: FilterPredicate<TRecord>) {\n    if (this._filterPredicate === predicate) {\n      return;\n    }\n    this._filterPredicate = predicate;\n    this._filterResultCache = {};\n    this.updateFilteredIndices();\n  }\n  updateFilteredIndices() {\n    this._filteredIndices = null;\n    if (!this._filterPredicate) {\n      return;\n    }\n    const metadata = this.getMetadataSync();\n    if (!metadata) {\n      return;\n    }\n    const count = this.getEstimatedCountSync();\n    const indices = [];\n    for (let i = 0; i < count; i++) {\n      if (i >= this._data.length) {\n        indices.push(i);\n        continue;\n      }\n      const game = this._data[i];\n      let result = this._filterResultCache[game.uuid];\n      if (result === undefined) {\n        this._filterResultCache[game.uuid] = result = this._filterPredicate(game);\n      }\n      if (result) {\n        indices.push(i);\n      }\n    }\n    this._filteredIndices = indices;\n  }\n  getMetadataSync(): TMetadata | null {\n    if (this._metadataError) {\n      throw this._metadataError;\n    }\n    return this._metadata && !(this._metadata instanceof Promise) ? this._metadata : null;\n  }\n  getEstimatedCountSync(): number {\n    const metadata = this.getMetadataSync();\n    const count = metadata ? metadata.count : this._data.length + 100;\n    if (count === +Infinity) {\n      return this._data.length + 100;\n    }\n    return count;\n  }\n  getCountMaybeSync(): number | Promise<number> {\n    const metadata = this.getMetadataSync();\n    if (metadata) {\n      return this._filteredIndices ? this._filteredIndices.length : this.getEstimatedCountSync();\n    }\n    return this.getCount().catch(() => 0); // Have to catch here to avoid unhandled promise rejection\n  }\n  async getCount(): Promise<number> {\n    const metadata = this.getMetadataSync();\n    if (metadata) {\n      return this.getCountMaybeSync();\n    }\n    if (!this._metadata) {\n      this._metadata = this._loader.getMetadata().then((metadata) => {\n        if (!metadata) {\n          console.log(\"No metadata returned\");\n          throw new Error(\"No metadata returned\");\n        }\n        this._metadata = metadata;\n        this.updateFilteredIndices();\n        this._countPromise = null;\n        return metadata;\n      });\n      this._metadata.catch((e) => {\n        console.error(e);\n        this._metadataError = e;\n      });\n    }\n    if (this._countPromise) {\n      return this._countPromise;\n    }\n    this._countPromise = Promise.resolve(this._metadata)\n      .then(() => new Promise((resolve) => setTimeout(resolve, 100)))\n      .then(() => this.getCountMaybeSync());\n    this._countPromise.catch(() => {\n      /* Kill unhandled rejection */\n    });\n    return this._countPromise;\n  }\n  getUnfilteredCountSync(): number | null {\n    const metadata = this.getMetadataSync();\n    if (!metadata) {\n      return null;\n    }\n    return this.getEstimatedCountSync();\n  }\n  isItemLoaded(index: number): boolean {\n    const mappedIndex = this._mapItemIndex(index);\n    if (mappedIndex === null) {\n      return false;\n    }\n    return mappedIndex < this._data.length;\n  }\n  getItem(index: number, skipPreload = false): TRecord | Promise<TRecord | null> {\n    const mappedIndex = this._mapItemIndex(index);\n    if (mappedIndex === null) {\n      return this.getCount()\n        .then((count) => {\n          const newMappedIndex = this._mapItemIndex(index);\n          if (index > count - 1 || newMappedIndex === null) {\n            return null;\n          }\n          return this.getItem(index, skipPreload);\n        })\n        .catch(() => null);\n    }\n    if (mappedIndex >= this._data.length) {\n      const curLength = this._data.length;\n      return this._loadNextChunk().then(() => {\n        if (this._data.length > curLength) {\n          return this.getItem(index, skipPreload);\n        }\n        return null;\n      });\n    }\n    if (!skipPreload && !this._filteredIndices) {\n      this.preload(index + this._loader.getEstimatedChunkSize() / 2);\n    }\n    return this._data[mappedIndex];\n  }\n  preload(index: number) {\n    const count = this.getCountMaybeSync();\n    if (count instanceof Promise) {\n      return;\n    }\n    if (index >= count) {\n      return;\n    }\n    this.getItem(index, true);\n  }\n  _mapItemIndex(requestedIndex: number): number | null {\n    const count = this.getCountMaybeSync();\n    if (count instanceof Promise) {\n      return null;\n    }\n    if (requestedIndex < 0 || requestedIndex >= count) {\n      return null;\n    }\n    return this._filteredIndices ? this._filteredIndices[requestedIndex] : requestedIndex;\n  }\n  async _loadNextChunk(): Promise<unknown> {\n    if (this._loadingPromise) {\n      return this._loadingPromise;\n    }\n    this._loadingPromise = (async () => {\n      const count = this.getUnfilteredCountSync() || 0;\n      if (this._data.length >= count) {\n        this._loadingPromise = null;\n        return;\n      }\n      const nextChunk = await this._loader.getNextChunk();\n      this._loadingPromise = null;\n      if (nextChunk.length) {\n        this._data.splice(this._data.length, 0, ...nextChunk);\n      } else {\n        const metadata = await this._metadata;\n        if (metadata) {\n          console.warn(\"Fixing incorrect item count: \" + metadata?.count + \" -> \" + this._data.length);\n          metadata.count = this._data.length;\n          this._metadata = metadata;\n        }\n      }\n      this.updateFilteredIndices();\n    })();\n    return this._loadingPromise;\n  }\n}\n\nexport type ListingDataProvider = DataProviderImpl<Metadata>;\nexport type PlayerDataProvider = DataProviderImpl<PlayerMetadata>;\nexport const DUMMY_DATA_PROVIDER = new DataProviderImpl<Metadata>(new DummyDataLoader());\n\nexport type DataProvider = ListingDataProvider | PlayerDataProvider;\n// eslint-disable-next-line @typescript-eslint/no-redeclare\nexport const DataProvider = Object.freeze({\n  createListing(date: dayjs.ConfigType, mode: GameMode | null): ListingDataProvider {\n    return new DataProviderImpl(new ListingDataLoader(date, mode));\n  },\n  createHightlight(mode: GameMode | undefined): ListingDataProvider {\n    return new DataProviderImpl(new RecentHighlightDataLoader(mode));\n  },\n  createPlayer(\n    playerId: string,\n    startDate: dayjs.ConfigType | null,\n    endDate: dayjs.ConfigType | null,\n    limit: number | null,\n    mode: GameMode[]\n  ): PlayerDataProvider {\n    if (limit) {\n      return new DataProviderImpl(new FixedNumberPlayerDataLoader(playerId, limit, mode));\n    }\n    return new DataProviderImpl(\n      new PlayerDataLoader(\n        playerId,\n        startDate ? dayjs(startDate) : undefined,\n        endDate ? dayjs(endDate) : undefined,\n        mode\n      )\n    );\n  },\n  createFilteredPlayer(playerId: string, loadRecord: () => Promise<GameRecord[]>, mode: GameMode[]): PlayerDataProvider {\n    return new DataProviderImpl(new FilteredPlayerDataLoader(playerId, loadRecord, mode));\n  },\n});\n"
  },
  {
    "path": "src/data/types/constants.ts",
    "content": "export const PLAYER_RANKS = \"初士杰豪圣魂\";\nexport const RANK_LABELS = [\"一位\", \"二位\", \"三位\", \"四位\"];\n"
  },
  {
    "path": "src/data/types/gameMode.ts",
    "content": "import i18n from \"../../i18n\";\n\nconst t = i18n.getFixedT(null, \"gameModeShort\");\n\nexport enum GameMode {\n  王座 = 16,\n  玉 = 12,\n  金 = 9,\n  王东 = 15,\n  玉东 = 11,\n  金东 = 8,\n  三金 = 22,\n  三玉 = 24,\n  三王座 = 26,\n  三金东 = 21,\n  三玉东 = 23,\n  三王东 = 25,\n}\nexport function modeLabelNonTranslated(mode: GameMode) {\n  if (!mode) {\n    return \"全部\";\n  }\n  return GameMode[mode].replace(/^三/, \"\");\n}\nexport function modeLabel(mode: GameMode) {\n  return t(modeLabelNonTranslated(mode));\n}\nexport function parseCombinedMode(modeString?: string): GameMode[] {\n  return (modeString || \"\")\n    .split(\".\")\n    .map((x) => parseInt(x.trim(), 10) as GameMode)\n    .map((x) => (GameMode[x] ? x : (0 as GameMode)))\n    .filter((x) => x);\n}\n"
  },
  {
    "path": "src/data/types/index.ts",
    "content": "export * from \"./constants\";\nexport * from \"./gameMode\";\nexport * from \"./level\";\nexport * from \"./metadata\";\nexport * from \"./record\";\nexport * from \"./ranking\";\nexport * from \"./statistics\";\nexport * from \"./utils\";\nexport * from \"./zone\";\n"
  },
  {
    "path": "src/data/types/level.ts",
    "content": "import { GameMode } from \"./gameMode\";\nimport { PLAYER_RANKS } from \"./constants\";\nimport i18n from \"../../i18n\";\n\nconst t = i18n.t.bind(i18n);\n\nconst LEVEL_MAX_POINTS = [20, 80, 200, 600, 800, 1000, 1200, 1400, 2000, 2800, 3200, 3600, 4000, 6000, 9000];\nconst LEVEL_PENALTY = [0, 0, 0, 20, 40, 60, 80, 100, 120, 165, 180, 195, 210, 225, 240, 255];\nconst LEVEL_PENALTY_3 = [0, 0, 0, 20, 40, 60, 80, 100, 120, 165, 190, 215, 240, 265, 290, 320];\nconst LEVEL_PENALTY_E = [0, 0, 0, 10, 20, 30, 40, 50, 60, 80, 90, 100, 110, 120, 130, 140];\nconst LEVEL_PENALTY_E_3 = [0, 0, 0, 10, 20, 30, 40, 50, 60, 80, 95, 110, 125, 140, 160, 175];\n\nconst LEVEL_KONTEN = 7;\nconst LEVEL_MAX_POINT_KONTEN = 2000;\n\nconst LEVEL_ALLOWED_MODES: { [key: number]: GameMode[] } = {\n  101: [],\n  102: [],\n  103: [GameMode.金, GameMode.金东],\n  104: [GameMode.金, GameMode.玉, GameMode.金东, GameMode.玉东],\n  105: [GameMode.玉, GameMode.王座, GameMode.玉东, GameMode.王东],\n  106: [GameMode.王座, GameMode.王东],\n  107: [GameMode.王座, GameMode.王东],\n  201: [],\n  202: [],\n  203: [GameMode.三金, GameMode.三金东],\n  204: [GameMode.三金, GameMode.三玉, GameMode.三金东, GameMode.三玉东],\n  205: [GameMode.三玉, GameMode.三王座, GameMode.三玉东, GameMode.三王东],\n  206: [GameMode.三王座, GameMode.三王东],\n  207: [GameMode.三王座, GameMode.三王东],\n};\n\nconst MODE_PENALTY: { [mode in GameMode]: typeof LEVEL_PENALTY } = {\n  [GameMode.金]: LEVEL_PENALTY,\n  [GameMode.玉]: LEVEL_PENALTY,\n  [GameMode.王座]: LEVEL_PENALTY,\n  [GameMode.金东]: LEVEL_PENALTY_E,\n  [GameMode.玉东]: LEVEL_PENALTY_E,\n  [GameMode.王东]: LEVEL_PENALTY_E,\n  [GameMode.三金]: LEVEL_PENALTY_3,\n  [GameMode.三玉]: LEVEL_PENALTY_3,\n  [GameMode.三王座]: LEVEL_PENALTY_3,\n  [GameMode.三金东]: LEVEL_PENALTY_E_3,\n  [GameMode.三玉东]: LEVEL_PENALTY_E_3,\n  [GameMode.三王东]: LEVEL_PENALTY_E_3,\n};\n\nexport function getTranslatedLevelTags(): string[] {\n  const rawTags = t(PLAYER_RANKS) as string;\n  if (rawTags.charCodeAt(0) > 127) {\n    return rawTags.split(\"\");\n  }\n  return Array(rawTags.length / 2)\n    .fill(\"\")\n    .map((_, index) => rawTags.slice(index * 2, index * 2 + 2));\n}\n\nexport class Level {\n  _majorRank: number;\n  _minorRank: number;\n  _numPlayerId: number;\n  constructor(levelId: number) {\n    const realId = levelId % 10000;\n    this._majorRank = Math.floor(realId / 100);\n    this._minorRank = realId % 100;\n    this._numPlayerId = Math.floor(levelId / 10000);\n  }\n  toLevelId() {\n    return this._numPlayerId * 10000 + this._majorRank * 100 + this._minorRank;\n  }\n  isSameMajorRank(other: Level): boolean {\n    return this._majorRank === other._majorRank;\n  }\n  isSame(other: Level): boolean {\n    if (this.isKonten() && other.isKonten()) {\n      if (this._majorRank === LEVEL_KONTEN - 1 || other._majorRank === LEVEL_KONTEN - 1) {\n        return true;\n      }\n    }\n    return this._majorRank === other._majorRank && this._minorRank === other._minorRank;\n  }\n  isAllowedMode(mode: GameMode): boolean {\n    return LEVEL_ALLOWED_MODES[this._numPlayerId * 100 + this._majorRank].includes(mode);\n  }\n  isKonten(): boolean {\n    return this._majorRank >= LEVEL_KONTEN - 1;\n  }\n  getNumPlayerId(): number {\n    return this._numPlayerId;\n  }\n  withLevelId(newLevelId: number): Level {\n    return new Level(this._numPlayerId * 10000 + newLevelId);\n  }\n  getTag(): string {\n    const label = getTranslatedLevelTags()[this.isKonten() ? LEVEL_KONTEN - 2 : this._majorRank - 1];\n    if (this._majorRank === LEVEL_KONTEN - 1) {\n      return label;\n    }\n    return label + this._minorRank;\n  }\n  getMaxPoint(): number {\n    if (this.isKonten()) {\n      if (this._minorRank === 20) {\n        return 0;\n      }\n      return LEVEL_MAX_POINT_KONTEN;\n    }\n    return LEVEL_MAX_POINTS[(this._majorRank - 1) * 3 + this._minorRank - 1];\n  }\n  getPenaltyPoint(mode: GameMode): number {\n    if (this.isKonten()) {\n      return 0;\n    }\n    return MODE_PENALTY[mode][(this._majorRank - 1) * 3 + this._minorRank - 1];\n  }\n  getStartingPoint(): number {\n    if (this._majorRank === 1) {\n      return 0;\n    }\n    return this.getMaxPoint() / 2;\n  }\n  getNextLevel(): Level {\n    const level = this.getVersionAdjustedLevel();\n    let majorRank = level._majorRank;\n    let minorRank = level._minorRank + 1;\n    if (minorRank > 3 && !level.isKonten()) {\n      majorRank++;\n      minorRank = 1;\n    }\n    if (majorRank === LEVEL_KONTEN - 1) {\n      majorRank = LEVEL_KONTEN;\n    }\n    return new Level(level._numPlayerId * 10000 + majorRank * 100 + minorRank);\n  }\n  getPreviousLevel(): Level {\n    if (this._majorRank === 1 && this._minorRank === 1) {\n      return this;\n    }\n    const level = this.getVersionAdjustedLevel();\n    let majorRank = level._majorRank;\n    let minorRank = level._minorRank - 1;\n    if (minorRank < 1) {\n      majorRank--;\n      minorRank = 3;\n    }\n    if (majorRank === LEVEL_KONTEN - 1) {\n      majorRank = LEVEL_KONTEN - 2;\n    }\n    return new Level(level._numPlayerId * 10000 + majorRank * 100 + minorRank);\n  }\n  getAdjustedLevel(score: number): Level {\n    score = this.getVersionAdjustedScore(score);\n    // eslint-disable-next-line @typescript-eslint/no-this-alias\n    let level: Level = this.getVersionAdjustedLevel();\n    let maxPoints = level.getMaxPoint();\n    if (maxPoints && score >= maxPoints) {\n      level = level.getNextLevel();\n      maxPoints = level.getMaxPoint();\n      score = level.getStartingPoint();\n    } else if (score < 0) {\n      if (!maxPoints || level._majorRank === 1 || (level._majorRank === 2 && level._minorRank === 1)) {\n        score = 0;\n      } else {\n        level = level.getPreviousLevel();\n        maxPoints = level.getMaxPoint();\n        score = level.getStartingPoint();\n      }\n    }\n    return level;\n  }\n  getVersionAdjustedLevel() {\n    if (this._majorRank !== LEVEL_KONTEN - 1) {\n      return this;\n    }\n    return new Level(this._numPlayerId * 10000 + LEVEL_KONTEN * 100 + 1);\n  }\n  getVersionAdjustedScore(score: number) {\n    if (this._majorRank === LEVEL_KONTEN - 1) {\n      return Math.ceil(score / 100) * 10 + 200;\n    }\n    return score;\n  }\n  getScoreDisplay(score: number) {\n    score = this.getVersionAdjustedScore(score);\n    if (this.isKonten()) {\n      return (score / 100).toFixed(1);\n    }\n    return score.toString();\n  }\n  formatAdjustedScoreWithTag(score: number) {\n    const level = this.getAdjustedLevel(score);\n    return `${level.getTag()} ${this.formatAdjustedScore(score)}`;\n  }\n  formatAdjustedScore(score: number) {\n    const level = this.getAdjustedLevel(score);\n    score = this.getVersionAdjustedScore(score);\n    return `${level.getScoreDisplay(level.isSame(this) ? Math.max(score, 0) : level.getStartingPoint())}${\n      level.getMaxPoint() ? \"/\" + level.getScoreDisplay(level.getMaxPoint()) : \"\"\n    }`;\n  }\n}\nexport function getLevelTag(levelId: number) {\n  return new Level(levelId).getTag();\n}\nexport type LevelWithDelta = {\n  id: number;\n  score: number;\n  delta: number;\n};\n// eslint-disable-next-line @typescript-eslint/no-redeclare\nexport const LevelWithDelta = Object.freeze({\n  format(obj: LevelWithDelta): string {\n    return new Level(obj.id).formatAdjustedScoreWithTag(obj.score + obj.delta);\n  },\n  formatAdjustedScore(obj: LevelWithDelta): string {\n    return new Level(obj.id).formatAdjustedScore(obj.score + obj.delta);\n  },\n  getTag(obj: LevelWithDelta): string {\n    return LevelWithDelta.getAdjustedLevel(obj).getTag();\n  },\n  getAdjustedLevel(obj: LevelWithDelta): Level {\n    return new Level(obj.id).getAdjustedLevel(obj.score + obj.delta);\n  },\n});\n"
  },
  {
    "path": "src/data/types/metadata.ts",
    "content": "import { LevelWithDelta, Level, getTranslatedLevelTags } from \"./level\";\nimport { GameMode } from \"./gameMode\";\nimport { FanStatEntry } from \"./statistics\";\nimport { sum } from \"../../utils\";\nimport i18n from \"../../i18n\";\n\nconst t = i18n.t.bind(i18n);\n\nconst RANK_DELTA_4 = [15, 5, -5, -15];\nconst RANK_DELTA_3 = [15, 0, -15];\nconst RANK_DELTA = {\n  [GameMode.金]: RANK_DELTA_4,\n  [GameMode.玉]: RANK_DELTA_4,\n  [GameMode.王座]: RANK_DELTA_4,\n  [GameMode.金东]: RANK_DELTA_4,\n  [GameMode.玉东]: RANK_DELTA_4,\n  [GameMode.王东]: RANK_DELTA_4,\n  [GameMode.三金]: RANK_DELTA_3,\n  [GameMode.三玉]: RANK_DELTA_3,\n  [GameMode.三王座]: RANK_DELTA_3,\n  [GameMode.三金东]: RANK_DELTA_3,\n  [GameMode.三玉东]: RANK_DELTA_3,\n  [GameMode.三王东]: RANK_DELTA_3,\n};\nconst MODE_DELTA = {\n  [GameMode.金]: [80, 40, 0, 0],\n  [GameMode.玉]: [110, 55, 0, 0],\n  [GameMode.王座]: [120, 60, 0, 0],\n  [GameMode.金东]: [40, 20, 0, 0],\n  [GameMode.玉东]: [55, 30, 0, 0],\n  [GameMode.王东]: [60, 30, 0, 0],\n  [GameMode.三金]: [105, 0, 0],\n  [GameMode.三玉]: [160, 0, 0],\n  [GameMode.三王座]: [240, 0, 0],\n  [GameMode.三金东]: [55, 0, 0],\n  [GameMode.三玉东]: [75, 0, 0],\n  [GameMode.三王东]: [120, 0, 0],\n};\nconst KONTEN_DELTA: { [mode in GameMode]?: number[] } = {\n  [GameMode.王座]: [50, 20, -20, -50],\n  [GameMode.王东]: [30, 10, -10, -30],\n  [GameMode.三王座]: [50, 0, -50],\n  [GameMode.三王东]: [30, 0, -30],\n};\nexport const MODE_BASE_POINT = {\n  [GameMode.金]: 25000,\n  [GameMode.玉]: 25000,\n  [GameMode.王座]: 25000,\n  [GameMode.金东]: 25000,\n  [GameMode.玉东]: 25000,\n  [GameMode.王东]: 25000,\n  [GameMode.三金]: 35000,\n  [GameMode.三玉]: 35000,\n  [GameMode.三王座]: 35000,\n  [GameMode.三金东]: 35000,\n  [GameMode.三玉东]: 35000,\n  [GameMode.三王东]: 35000,\n};\n\nconst KONTEN_FALLBACK_LEVEL_ID = 503;\n\nexport type RankRates = [number, number, number, number] | [number, number, number];\n// eslint-disable-next-line @typescript-eslint/no-redeclare\nexport const RankRates = Object.freeze({\n  getAvg(rates: RankRates): number {\n    return sum(rates.map((value, index) => value * (index + 1))) / sum(rates);\n  },\n  normalize(rates: RankRates): RankRates {\n    const total = sum(rates);\n    return rates.map((value) => value / total) as RankRates;\n  },\n});\n\nexport type FanStatEntry2 = FanStatEntry & {\n  役满: number;\n};\n// eslint-disable-next-line @typescript-eslint/no-redeclare\nexport const FanStatEntry2 = Object.freeze({\n  formatFan(entry: FanStatEntry2): string {\n    if (entry.役满) {\n      if (entry.役满 === 1) {\n        return t(\"役满\");\n      }\n      return `${entry.役满} ${t(\"倍役满\")}`;\n    }\n    return `${entry.count} ${t(\"番\")}`;\n  },\n});\nexport type FanStatEntryList = FanStatEntry2[];\n// eslint-disable-next-line @typescript-eslint/no-redeclare\nexport const FanStatEntryList = Object.freeze({\n  formatFanList(list: FanStatEntryList): string {\n    return list.map((x) => `[${x.count}] ${t(x.label)}`).join(\"\\n\");\n  },\n  formatFanSummary(list: FanStatEntryList): string {\n    const count = sum(list.map((x) => x.count));\n    const 役满 = sum(list.map((x) => x.役满));\n    if (役满) {\n      if (役满 === 1) {\n        return t(\"役满\");\n      }\n      return `${役满} ${t(\"倍役满\")}`;\n    }\n    let result = `${count} ${t(\"番\")}`;\n    if (count >= 13) {\n      result += \" - \" + t(\"累计役满\");\n    } else if (count >= 11) {\n      result += \" - \" + t(\"三倍满\");\n    } else if (count >= 8) {\n      result += \" - \" + t(\"倍满\");\n    } else if (count >= 6) {\n      result += \" - \" + t(\"跳满\");\n    } else if (count === 5) {\n      result += \" - \" + t(\"满贯\");\n    }\n    return result;\n  },\n});\n\nexport type PlayerExtendedStats = {\n  count: number;\n  和牌率: number;\n  自摸率: number;\n  默听率: number;\n  放铳率: number;\n  副露率: number;\n  立直率: number;\n  平均打点: number;\n  最大连庄?: number;\n  和了巡数: number;\n  平均铳点: number;\n  流局率: number;\n  流听率: number;\n  里宝率: number;\n  一发率: number;\n  被炸率: number;\n  平均被炸点数: number;\n  放铳时立直率: number;\n  放铳时副露率: number;\n  立直后放铳率: number;\n  立直后非瞬间放铳率: number;\n  副露后放铳率: number;\n  立直后和牌率: number;\n  副露后和牌率: number;\n  立直后流局率: number;\n  副露后流局率: number;\n  役满?: number;\n  累计役满?: number;\n  最大累计番数?: number;\n  W立直?: number;\n  流满?: number;\n  平均起手向听: number;\n  平均起手向听亲?: number;\n  平均起手向听子?: number;\n  放铳至立直: number;\n  放铳至副露: number;\n  放铳至默听: number;\n  立直和了: number;\n  副露和了: number;\n  默听和了: number;\n  立直巡目: number;\n  立直流局: number;\n  立直收支: number;\n  立直收入: number;\n  立直支出: number;\n  先制率: number;\n  追立率: number;\n  被追率: number;\n  振听立直率: number;\n  立直多面?: number;\n  立直好型2?: number;\n  打点效率: number;\n  铳点损失: number;\n  净打点效率: number;\n  局收支?: number;\n  最近大铳?: {\n    id: string;\n    start_time: number;\n    fans: FanStatEntryList;\n  };\n};\nexport interface Metadata {\n  count: number;\n}\nexport interface PlayerMetadataLite extends Metadata {\n  id: number;\n  nickname: string;\n  level: LevelWithDelta;\n}\nexport interface PlayerMetadataLite2 extends Metadata {\n  rank_rates: RankRates;\n  avg_rank: number;\n  negative_rate: number;\n}\nexport interface PlayerMetadata extends PlayerMetadataLite, PlayerMetadataLite2 {\n  rank_avg_score: RankRates;\n  max_level: LevelWithDelta;\n  played_modes?: (string | GameMode)[];\n  cross_stats?: PlayerMetadataLite & {\n    max_level: LevelWithDelta;\n    played_modes: GameMode[];\n  };\n  extended_stats?: PlayerExtendedStats | Promise<PlayerExtendedStats>;\n}\n\nexport function calculateDeltaPoint(\n  score: number,\n  rank: number,\n  mode: GameMode,\n  level: Level,\n  includePenalty = true,\n  trimNumber = true\n): number {\n  if (level.isKonten()) {\n    const delta = KONTEN_DELTA[mode];\n    if (delta) {\n      return delta[rank];\n    }\n    level = level.withLevelId(KONTEN_FALLBACK_LEVEL_ID);\n  }\n  let result =\n    (trimNumber ? Math.ceil : (x: number) => x)((score - MODE_BASE_POINT[mode]) / 1000 + RANK_DELTA[mode][rank]) +\n    MODE_DELTA[mode][rank];\n  if (rank === RANK_DELTA[mode].length - 1 && includePenalty) {\n    result -= level.getPenaltyPoint(mode);\n  }\n  /*\n  console.log(\n    `calculateDeltaPoint: score=${score}, rank=${rank}, mode=${mode}, level=${level.getTag()}, result=${result}`\n  );\n  */\n  return result;\n}\n\n// eslint-disable-next-line @typescript-eslint/no-redeclare\nexport const PlayerMetadata = Object.freeze({\n  calculateRankDeltaPoints(\n    metadata: PlayerMetadata,\n    mode: GameMode,\n    level?: Level,\n    includePenalty = true,\n    trimNumber = true\n  ): RankRates {\n    const rankDeltaPoints = metadata.rank_avg_score.map((score, rank) =>\n      calculateDeltaPoint(\n        score,\n        rank,\n        mode,\n        level || LevelWithDelta.getAdjustedLevel(metadata.level),\n        includePenalty,\n        trimNumber\n      )\n    ) as typeof metadata.rank_avg_score;\n    return rankDeltaPoints;\n  },\n  calculateExpectedGamePoint(metadata: PlayerMetadata, mode: GameMode, level?: Level, includePenalty = true): number {\n    const rankDeltaPoints = PlayerMetadata.calculateRankDeltaPoints(metadata, mode, level, includePenalty);\n    const rankWeightedPoints = rankDeltaPoints.map((point, rank) => point * metadata.rank_rates[rank]);\n    const expectedGamePoint = rankWeightedPoints.reduce((a, b) => a + b, 0);\n    /*\n    console.log(rankDeltaPoints);\n    console.log(rankWeightedPoints);\n    console.log(\n      `calculateExpectedGamePoint: mode=${mode}, level=${level ? level.getTag() : \"\"}, result=${expectedGamePoint}`\n    );\n    */\n    return expectedGamePoint;\n  },\n  estimateStableLevel(metadata: PlayerMetadata, mode: GameMode): string {\n    const calcPoint = (level: Level) => PlayerMetadata.calculateExpectedGamePoint(metadata, mode, level);\n    let level = new Level(metadata.level.id);\n    let lastPositiveLevel: Level | undefined = undefined;\n    for (;;) {\n      const expectedGamePoint = calcPoint(level);\n      if (Math.abs(expectedGamePoint) < 0.001) {\n        return level.getTag() + \" (0)\";\n      }\n      if (expectedGamePoint >= 0) {\n        if (level.isKonten()) {\n          return level.getTag().replace(/\\d+/g, \"\") + \"+\" + expectedGamePoint.toFixed(2);\n        }\n        lastPositiveLevel = level;\n        level = level.getNextLevel();\n        if (!level.isAllowedMode(mode) || level === lastPositiveLevel) {\n          return `${lastPositiveLevel.getTag()}+ (${expectedGamePoint.toFixed(2)})`;\n        }\n      } else {\n        if (lastPositiveLevel) {\n          return `${lastPositiveLevel.getTag()} (${calcPoint(lastPositiveLevel).toFixed(2)})`;\n        }\n        break;\n      }\n    }\n    for (;;) {\n      const prevLevel = level.getPreviousLevel();\n      if (!prevLevel.isAllowedMode(mode) || prevLevel === level) {\n        return `${level.getTag()}- (${calcPoint(level).toFixed(2)})`;\n      }\n      level = prevLevel;\n      const expectedGamePoint = calcPoint(level);\n      if (expectedGamePoint > -0.001) {\n        return `${level.getTag()} (${Math.abs(calcPoint(level)).toFixed(2)})`;\n      }\n    }\n  },\n  formatStableLevel2(level: number): string {\n    const formatNumber = function (x: number): string {\n      // Trim after the second digit after decimal point\n      let s = x.toString();\n      if (s.indexOf(\".\") === -1) {\n        s += \".00\";\n      }\n      if (s.length < 8) {\n        s += \"00\";\n      }\n      return s.slice(0, s.indexOf(\".\") + 3);\n    };\n    const translatedLevelTags = getTranslatedLevelTags();\n    if (level >= 4) {\n      return `${translatedLevelTags[4]}${formatNumber(level - 3)}`;\n    }\n    return `${translatedLevelTags[3]}${formatNumber(level)}`;\n  },\n  getStableLevelComponents(metadata: PlayerMetadata, mode: GameMode): RankRates {\n    return this.calculateRankDeltaPoints(metadata, mode, undefined, false, false);\n  },\n  estimateStableLevel2(metadata: PlayerMetadata, mode: GameMode): string {\n    if (![GameMode.玉, GameMode.王座].includes(mode)) {\n      return this.estimateStableLevel(metadata, mode);\n    }\n    if (!metadata.rank_rates[3]) {\n      return \"\";\n    }\n    let estimatedPoints = this.calculateExpectedGamePoint(metadata, mode, undefined, false);\n    let result = estimatedPoints / (metadata.rank_rates[3] * 15) - 10;\n    const level = LevelWithDelta.getAdjustedLevel(metadata.level);\n    if (level.isKonten() && KONTEN_DELTA[mode]) {\n      const tag = level.getTag().replace(/\\d+/g, \"\");\n      if (Math.abs(estimatedPoints) < 0.001) {\n        return tag;\n      }\n      if (estimatedPoints > 0) {\n        return tag + \"+\" + estimatedPoints.toFixed(2);\n      }\n      estimatedPoints = this.calculateExpectedGamePoint(\n        metadata,\n        mode,\n        level.withLevelId(KONTEN_FALLBACK_LEVEL_ID),\n        false\n      );\n    } else if (result > 7 && KONTEN_DELTA[mode]) {\n      return this.estimateStableLevel(metadata, mode);\n    }\n    result = estimatedPoints / (metadata.rank_rates[3] * 15) - 10;\n    return PlayerMetadata.formatStableLevel2(result);\n  },\n});\n"
  },
  {
    "path": "src/data/types/ranking.ts",
    "content": "import { LevelWithDelta } from \"./level\";\nimport { PlayerMetadata } from \"./metadata\";\n\nexport enum RankingTimeSpan {\n  OneDay = \"1d\",\n  ThreeDays = \"3d\",\n  OneWeek = \"1w\",\n  FourWeeks = \"4w\",\n}\nexport type DeltaRankingItem = {\n  id: number;\n  nickname: string;\n  level: LevelWithDelta;\n  delta: number;\n};\nexport type DeltaRankingResponse = {\n  [modeId: string]: {\n    top: DeltaRankingItem[];\n    bottom: DeltaRankingItem[];\n    num_games: DeltaRankingItem[];\n  };\n};\nexport interface CareerRankingItem extends PlayerMetadata {\n  rank_key: number;\n  ranking_level: LevelWithDelta;\n  count: number;\n}\nexport enum CareerRankingType {\n  Rank1 = \"rank1\",\n  Rank12 = \"rank12\",\n  Rank123 = \"rank123\",\n  Rank3 = \"rank3\",\n  Rank4 = \"rank4\",\n  AvgRank = \"avg_rank\",\n  MaxLevelGlobal = \"max_level_global\",\n  NumGames = \"num_games\",\n  StableLevel = \"stable_level\",\n  PointEfficiency = \"point_efficiency\",\n  Win = \"win\",\n  Lose = \"lose\",\n  WinLoseDiff = \"win_lose_diff\",\n  WinRev = \"win_rev\",\n  LoseRev = \"lose_rev\",\n  ExpectedGamePoint0 = \"expected_game_point_0\",\n  ExpectedGamePoint1 = \"expected_game_point_1\",\n  ExpectedGamePoint2 = \"expected_game_point_2\",\n  ExpectedGamePoint3 = \"expected_game_point_3\",\n  里宝率 = \"里宝率\",\n  被炸率 = \"被炸率\",\n  一发率 = \"一发率\",\n  里宝率Rev = \"里宝率_rev\",\n  被炸率Rev = \"被炸率_rev\",\n  一发率Rev = \"一发率_rev\",\n  平均打点 = \"平均打点\",\n  平均铳点 = \"平均铳点\",\n  打点效率 = \"打点效率\",\n  净打点效率 = \"净打点效率\",\n  铳点损失 = \"铳点损失\",\n  局收支 = \"局收支\",\n}\n"
  },
  {
    "path": "src/data/types/record.ts",
    "content": "import dayjs from \"dayjs\";\n\nimport { GameMode } from \"./gameMode\";\nimport { getRankLabelByIndex } from \"./utils\";\nimport Conf from \"../../utils/conf\";\n\nimport i18n from \"../../i18n\";\nimport { FanStatEntryList } from \"./metadata\";\nimport { getApiPrefix } from \"../source/api\";\nimport { getZoneFromLocale } from \"./zone\";\n\nexport interface PlayerRecord {\n  accountId: number;\n  nickname: string;\n  level: number;\n  score: number;\n  gradingScore?: number;\n}\nexport interface GameRecord {\n  _id?: string;\n  _masked?: boolean;\n  modeId: GameMode;\n  uuid: string;\n  startTime: number;\n  endTime: number;\n  players: PlayerRecord[];\n}\nexport type HighlightEvent = {\n  type: \"役满\";\n  fan: FanStatEntryList;\n  player: number;\n};\nexport type GameRecordWithEvent = GameRecord & {\n  event: HighlightEvent;\n};\n// eslint-disable-next-line @typescript-eslint/no-redeclare\nexport const GameRecord = Object.freeze({\n  getRankIndexByPlayer(rec: GameRecord, player: number | string | PlayerRecord): number {\n    const playerId = (typeof player === \"object\" ? player.accountId : player).toString();\n    const sortedPlayers = rec.players.map((player, index) => ({ player, index }));\n    sortedPlayers.sort((a, b) => 5 - b.index + b.player.score - (5 - a.index + a.player.score));\n    for (let i = 0; i < sortedPlayers.length; i++) {\n      if (sortedPlayers[i].player.accountId.toString() === playerId) {\n        return i;\n      }\n    }\n    return -1;\n  },\n  getPlayerRankLabel(rec: GameRecord, player: number | string | PlayerRecord): string {\n    return getRankLabelByIndex(GameRecord.getRankIndexByPlayer(rec, player)) || \"\";\n  },\n  getPlayerRankColor(rec: GameRecord, player: number | string | PlayerRecord): string {\n    return Conf.rankColors[GameRecord.getRankIndexByPlayer(rec, player)];\n  },\n  encodeAccountId: (t: number) => 1358437 + ((7 * t + 1117113) ^ 86216345),\n  getStartTime: (rec: GameRecord | number) => (typeof rec === \"number\" ? rec : rec.startTime) * 1000,\n  formatFullStartTime: (rec: GameRecord | number) => dayjs(GameRecord.getStartTime(rec)).format(\"YYYY/M/D HH:mm\"),\n  formatStartDate: (rec: GameRecord | number) => dayjs(GameRecord.getStartTime(rec)).format(\"M/D\"),\n  getRecordLink(rec: GameRecord | string, player?: PlayerRecord | number | string) {\n    const playerId = typeof player === \"object\" ? player.accountId : player;\n    const trailer = playerId\n      ? `_a${GameRecord.encodeAccountId(typeof playerId === \"number\" ? playerId : parseInt(playerId))}`\n      : \"\";\n    const uuid = typeof rec === \"string\" ? rec : rec.uuid;\n    return `${i18n.t(\"https://game.maj-soul.com/1/\")}?paipu=${uuid}${trailer}`;\n  },\n  getMaskedRecordLink(rec: GameRecord, player?: PlayerRecord | number | string) {\n    if (!Conf.maskedGameLink) {\n      return GameRecord.getRecordLink(rec, player);\n    }\n    const playerId = typeof player === \"object\" ? player.accountId : player;\n    const trailer = playerId\n      ? `/${GameRecord.encodeAccountId(typeof playerId === \"number\" ? playerId : parseInt(playerId))}`\n      : \"\";\n    return `${getApiPrefix()}view_game/${getZoneFromLocale(i18n.language)}/${rec.modeId}/${rec._id}${trailer}`;\n  },\n});\n"
  },
  {
    "path": "src/data/types/statistics.ts",
    "content": "import { AccountZone, GameMode } from \".\";\nimport { WithLastModified } from \"../source/api\";\nimport { PlayerMetadataLite2, PlayerExtendedStats, RankRates } from \"./metadata\";\nexport type RankRateBySeat = {\n  [modeId: string]: {\n    [rankId: number]: RankRates;\n  } & { numGames: number; length: number };\n};\nexport type GlobalStatistics = WithLastModified & {\n  [modeId: string]: {\n    [levelId: string]: {\n      num_players: number;\n      basic: PlayerMetadataLite2;\n      extended: PlayerExtendedStats;\n    };\n  };\n};\nexport type LevelStatisticsItem = [AccountZone, number, number];\nexport type LevelStatistics = LevelStatisticsItem[];\nexport type HistogramData = {\n  min: number;\n  max: number;\n  bins: number[];\n};\nexport type HistogramGroup = {\n  mean: number;\n  histogramFull?: HistogramData;\n  histogramClamped?: HistogramData;\n};\n\nexport type GlobalHistogram = {\n  [modeId in GameMode]: {\n    [levelId: string]: {\n      [name in keyof PlayerExtendedStats]: HistogramGroup;\n    };\n  };\n};\nexport type FanStatEntry = {\n  label: string;\n  count: number;\n};\nexport type FanStats = {\n  [modeId: string]: {\n    count: number;\n    entries: FanStatEntry[];\n  };\n};\n"
  },
  {
    "path": "src/data/types/utils.ts",
    "content": "import i18n from \"../../i18n\";\nimport { RANK_LABELS } from \"./constants\";\n\nconst t = i18n.t.bind(i18n);\n\nexport function getRankLabelByIndex(index: number): string {\n  return t(RANK_LABELS[index]);\n}\nexport function getRankLabelByIndexRaw(index: number): string {\n  return RANK_LABELS[index];\n}\n"
  },
  {
    "path": "src/data/types/zone.ts",
    "content": "export enum AccountZone {\n  China = 1,\n  Japan = 2,\n  International = 3,\n  Unknown = -1,\n}\n\nexport function getZoneFromLocale(locale: string): AccountZone {\n  if (/^ja/i.test(locale)) {\n    return AccountZone.Japan;\n  }\n  if (/^zh/i.test(locale)) {\n    return AccountZone.China;\n  }\n  return AccountZone.International;\n}\n\nexport function getAccountZone(accountId: number): AccountZone {\n  if (!accountId) {\n    return AccountZone.Unknown;\n  }\n  const prefix = accountId >> 23;\n  if (prefix >= 0 && prefix <= 6) {\n    return AccountZone.China;\n  }\n  if (prefix >= 7 && prefix <= 12) {\n    return AccountZone.Japan;\n  }\n  if (prefix >= 13 && prefix <= 15) {\n    return AccountZone.International;\n  }\n  return AccountZone.Unknown;\n}\n\nexport function getZoneTag(zone: AccountZone): string {\n  switch (zone) {\n    case AccountZone.China:\n      return \"Ⓒ\";\n    case AccountZone.Japan:\n      return \"Ⓙ\";\n    case AccountZone.International:\n      return \"Ⓔ\";\n    default:\n      return \"\";\n  }\n}\n\nexport function getAccountZoneTag(accountId: number): string {\n  return getZoneTag(getAccountZone(accountId));\n}\n"
  },
  {
    "path": "src/i18n.ts",
    "content": "import i18n from \"i18next\";\nimport { initReactI18next } from \"react-i18next\";\nimport LanguageDetector from \"i18next-browser-languagedetector\";\n\nimport { triggerRelayout } from \"./utils\";\n\nconst DEBUG = process.env.NODE_ENV === \"development\" && sessionStorage.i18nDebug;\n\nif (DEBUG) {\n  sessionStorage.removeItem(\"__i18nMissingKeys\");\n}\n\ni18n\n  .use({\n    type: \"backend\",\n    read(language: string, namespace: string, callback: (errorValue: unknown, translations: null | unknown) => void) {\n      if (language === \"zh-hans\") {\n        return callback(null, {});\n      }\n      import(`./locales/${language}.json`)\n        .then((resources) => {\n          resources = resources.default;\n          callback(null, { ...resources[\"default\"], ...resources[namespace] });\n        })\n        .catch((error) => {\n          callback(error, null);\n        });\n    },\n  })\n  .use(LanguageDetector)\n  .use(initReactI18next) // passes i18n down to react-i18next\n  .init({\n    lowerCaseLng: true,\n    fallbackLng: \"zh-hans\",\n    defaultNS: \"default\",\n    debug: DEBUG,\n    whitelist: [\"ja\", \"zh-hans\", \"en\", \"ko\"],\n    detection: {\n      order: [\"localStorage\", \"navigator\"],\n      caches: [\"localStorage\"],\n      checkWhitelist: true,\n    },\n\n    returnEmptyString: false,\n    returnNull: false,\n\n    saveMissing: DEBUG,\n    missingKeyHandler: DEBUG\n      ? function (lng, ns, key) {\n          const missingKeys = JSON.parse(sessionStorage.getItem(\"__i18nMissingKeys\") || \"{}\") || {};\n          const l = i18n.language;\n          if (l === \"zh-hans\") {\n            return;\n          }\n          missingKeys[l] = missingKeys[l] || {};\n          missingKeys[l][ns] = missingKeys[l][ns] || {};\n          missingKeys[l][ns][key] = \"\";\n          sessionStorage.setItem(\"__i18nMissingKeys\", JSON.stringify(missingKeys));\n        }\n      : false,\n\n    nsSeparator: false,\n    keySeparator: false,\n\n    interpolation: {\n      escapeValue: false,\n    },\n  });\n\nif (\"document\" in global) {\n  // Fix error in node\n  i18n.on(\"languageChanged\", function () {\n    document.documentElement.lang = i18n.language;\n    triggerRelayout();\n  });\n}\n\nexport default i18n;\n"
  },
  {
    "path": "src/index.tsx",
    "content": "/* eslint-disable */\n// @ts-nocheck\nwindow.__loadGa = function () {\n  if (window.ga) {\n    return ga;\n  }\n  window.dataLayer = window.dataLayer || [];\n  function gtag() {\n    dataLayer.push(arguments);\n  }\n  gtag(\"js\", new Date());\n  gtag(\"config\", \"G-3M94EBS8XE\");\n  const gtagElement = document.createElement(\"script\");\n  gtagElement.src = \"https://www.googletagmanager.com/gtag/js?id=G-3M94EBS8XE\";\n  gtagElement.async = true;\n  document.head.appendChild(gtagElement);\n\n  (function (i, s, o, g, r, a, m) {\n    i[\"GoogleAnalyticsObject\"] = r;\n    (i[r] =\n      i[r] ||\n      function () {\n        (i[r].q = i[r].q || []).push(arguments);\n      }),\n      (i[r].l = 1 * new Date());\n    (a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]);\n    a.async = 1;\n    a.src = g;\n    m.parentNode.insertBefore(a, m);\n  })(window, document, \"script\", \"https://www.google-analytics.com/analytics.js\", \"ga\");\n\n  ga(\"create\", \"UA-155269742-1\", \"auto\");\n  return ga;\n};\n/* eslint-enable */\n\nconst init = () => import(/* webpackMode: \"eager\" */ \"./bootstrap\");\n\nif (!Object.values || !window.URLSearchParams || !window.fetch || !window.Set) {\n  import(/* webpackMode: \"lazy\" */ \"./utils/polyfill\").then(init);\n} else {\n  init();\n}\n\nexport {};\n"
  },
  {
    "path": "src/locales/en.json",
    "content": "{\n  \"default\": {\n    \"雀魂牌谱屋\": \"MajSoul Stats\",\n    \"雀魂牌谱屋·金\": \"MajSoul Stats - Gold\",\n    \"雀魂牌谱屋·三麻\": \"MajSoul Stats - 3P\",\n    \"主页\": \"Top\",\n    \"最近役满\": \"Recent Yakuman\",\n    \"排行榜\": \"Ranking\",\n    \"大数据\": \"Data\",\n    \"四麻玉/王座\": \"4P Jade/Throne\",\n    \"四麻\": \"4P\",\n    \"四麻金\": \"4P Gold\",\n    \"三麻\": \"3P\",\n\n    \"等级\": \"Lvl\",\n    \"顺位\": \"Rk\",\n    \"玩家\": \"Player\",\n    \"时间\": \"Date and time\",\n    \"开始\": \"Strt\",\n    \"结束\": \"End\",\n\n    \"全部\": \"All\",\n    \"最近四周\": \"4 weeks\",\n    \"自定义\": \"Custom\",\n    \"王座\": \"Throne\",\n    \"玉\": \"Jade\",\n    \"金\": \"Gold\",\n    \"王东\": \"Throne East\",\n    \"玉东\": \"Jade East\",\n    \"金东\": \"Gold East\",\n\n    \"最近 {{x}} 周\": \"{{x}} weeks\",\n    \"最近 {{x}} 场\": \"{{x}} matches\",\n    \"本月\": \"This month\",\n    \"上月\": \"Last month\",\n    \"今年\": \"This year\",\n    \"去年\": \"Last year\",\n    \"新王座\": \"New Throne\",\n    \"旧王座\": \"Old Throne\",\n    \"自定义时间\": \"Set time\",\n    \"自定开始时间...\": \"Set start...\",\n    \"自定结束时间...\": \"Set end...\",\n    \"确定\": \"OK\",\n\n    \"收藏\": \"Bookmark\",\n    \"已收藏\": \"Bookmarked\",\n\n    \"筛选\": \"Filter\",\n\n    \"查看牌谱\": \"View game\",\n    \"复制链接\": \"Copy link\",\n    \"链接复制成功\": \"Link copied\",\n    \"玩家详细\": \"Player details\",\n    \"AI 检讨\": \"AI review\",\n    \"https://mjai.ekyu.moe/zh-cn.html\": \"https://mjai.ekyu.moe/\",\n\n    \"玩家：\": \"Player: \",\n    \"最近走势\": \"Trends\",\n    \"累计战绩\": \"Rank distribution\",\n\n    \"和牌时\": \"Wins\",\n    \"放铳时\": \"Self hand when dealing in\",\n    \"放铳至\": \"Opponent's hand when dealing in\",\n    \"副露\": \"Open\",\n    \"默听\": \"Dama\",\n    \"门清\": \"Closed\",\n\n    \"{{mode}}位置：\": \"Position in {{mode}}: \",\n    \"{{mode}}平均值：\": \"Mean in {{mode}}: \",\n    \"{{mode}}各段位平均值：\": \"Mean for ranks in {{mode}}: \",\n\n    \"记录场数\": \"Recorded matches\",\n    \"记录等级\": \"Current rank\",\n    \"记录分数\": \"Current rk points\",\n    \"平均顺位\": \"Average rank\",\n    \"被飞率\": \"Busting rate\",\n    \"安定段位\": \"Stable rank\",\n    \"分数期望\": \"Expected score\",\n    \"和牌率\": \"Win rate\",\n    \"放铳率\": \"Deal-in rate\",\n    \"自摸率\": \"Tsumo rate\",\n    \"默胡率\": \"Dama rate\",\n    \"流局率\": \"Exhaustive draw rate\",\n    \"流听率\": \"Draw tenpai rate\",\n    \"副露率\": \"Call rate\",\n    \"立直率\": \"Riichi rate\",\n    \"和了巡数\": \"Avg turns to win\",\n    \"平均打点\": \"Average win score\",\n    \"平均铳点\": \"Average deal-in score\",\n    \"立直和了\": \"Riichi win rate\",\n    \"立直放铳A\": \"Deal-in after riichi A\",\n    \"立直放铳B\": \"Deal-in after riichi B\",\n    \"立直收支\": \"Riichi payment\",\n    \"立直收入\": \"Avg riichi hand value\",\n    \"立直支出\": \"Avg riichi deal-in\",\n    \"先制率\": \"First riichi\",\n    \"追立率\": \"Chasing riichi\",\n    \"被追率\": \"Chased rate\",\n    \"立直巡目\": \"Avg riichi turns\",\n    \"立直流局\": \"Riichi draw rate\",\n    \"一发率\": \"Ippatsu rate\",\n    \"振听率\": \"Furiten rate\",\n    \"最高等级\": \"Best rank\",\n    \"最高分数\": \"Best rank points\",\n    \"最大连庄\": \"Max repeats\",\n    \"里宝率\": \"Uradora rate\",\n    \"被炸率\": \"Tsumo hit as dealer\",\n    \"平均被炸点数\": \"Tsumo hit as dler pt\",\n    \"放铳时立直率\": \"Deal-in while riichi\",\n    \"放铳时副露率\": \"Deal-in while open\",\n    \"副露后放铳率\": \"Deal-in after open\",\n    \"副露后和牌率\": \"Win rate after open\",\n    \"副露后流局率\": \"Draw rate after open\",\n    \"总计局数\": \"Total rounds\",\n    \"役满\": \"Yakuman\",\n    \"累计役满\": \"Counted Yakuman\",\n    \"最大累计番数\": \"Max total han count\",\n    \"流满\": \"Nagashi mangan\",\n    \"起手向听\": \"Haipai shanten\",\n    \"亲起手向听\": \"Dealer h. shanten\",\n    \"子起手向听\": \"Non-dealer h. shanten\",\n    \"立直好型\": \"Good-hand riichi\",\n    \"立直多面\": \"Multi-sided riichi\",\n    \"打点效率\": \"Win efficiency\",\n    \"铳点损失\": \"Deal-in loss\",\n    \"净打点效率\": \"Net win efficiency\",\n    \"局收支\": \"G/L per round\",\n    \"场平均素点\": \"Avg match points\",\n    \"场起始素点\": \"Starting points\",\n\n    \"和牌局数 / 总局数\": \"Win rounds / Total rounds\",\n    \"放铳局数 / 总局数\": \"Deal-in rounds / Total rounds\",\n    \"自摸局数 / 和牌局数\": \"Tsumo rounds / Win rounds\",\n    \"门清默听和牌局数 / 和牌局数\": \"Dama-hand win rounds / Win rounds\",\n    \"流局局数 / 总局数\": \"Draw rounds / Total rounds\",\n    \"流局听牌局数 / 流局局数\": \"Tenpai-at-draw rounds / Draw rounds\",\n    \"副露局数 / 总局数\": \"Open-hand rounds / Total rounds\",\n    \"立直局数 / 总局数\": \"Riichi rounds / Total rounds\",\n    \"立直和了局数 / 立直局数\": \"Win after riichi rounds / Riichi rounds\",\n    \"立直放铳局数（含立直瞬间 / 不含立直瞬间） / 立直局数\": \"Deal-in after riichi rounds (including / not including deal-in at the same round of riichi) / Riichi rounds\",\n    \"立直放铳局数（含立直瞬间） / 立直局数\": \"Deal-in after riichi rounds (including deal-in at the same round of riichi) / Riichi rounds\",\n    \"立直放铳局数（不含立直瞬间） / 立直局数\": \"Deal-in after riichi rounds (not including deal-in at the same round of riichi) / Riichi rounds\",\n    \"立直总收支（含供托） / 立直局数\": \"Riichi total balances (with round debts) / Riichi rounds\",\n    \"立直和了收入（含供托） / 立直和了局数\": \"Riichi winning incomes (with round debts) / Win after riichi rounds\",\n    \"立直放铳支出（含立直棒） / 立直放铳局数\": \"Riichi deal-in expenses (with round debts) / Deal-in after riichi rounds\",\n    \"先制立直局数 / 立直局数\": \"Leading-riichi rounds / Riichi rounds\",\n    \"追立局数 / 立直局数\": \"Chasing-riichi rounds / Riichi rounds\",\n    \"被追立局数 / 立直局数\": \"Chased-riichi rounds / Riichi rounds\",\n    \"立直流局局数 / 立直局数\": \"Riichi draw rounds / Riichi rounds\",\n    \"一发局数 / 立直和了局数\": \"Ippatsu rounds / Riichi win rounds\",\n    \"振听立直局数（不含立直见逃） / 立直局数\": \"Furiten-riichi rounds (excludes skipping wins after riichi) / Riichi rounds\",\n    \"中里宝局数 / 立直和了局数\": \"Uradora rounds / Riichi win rounds\",\n    \"被炸庄（满贯或以上）次数 / 被自摸次数\": \"Being tsumo'd as dealer (by mangan or above) rounds / Tsumo'd rounds\",\n    \"被炸庄（满贯或以上）点数 / 次数\": \"Being tsumo'd as dealer (by mangan or above) points / rounds\",\n    \"放铳时立直次数 / 放铳次数\": \"Deal-in after riichi rounds / Deal-in rounds\",\n    \"放铳时副露次数 / 放铳次数\": \"Deal-in with open-hand rounds / Deal-in rounds\",\n    \"放铳时副露次数 / 副露次数\": \"Deal-in with open-hand rounds / Open-hand rounds\",\n    \"副露后和牌次数 / 副露次数\": \"Open-hand win rounds / Open-hand rounds\",\n    \"副露后流局次数 / 副露次数\": \"Open-hand draw rounds / Open-hand rounds\",\n    \"和出役满次数\": \"Yakuman win rounds\",\n    \"和出累计役满次数\": \"Counted-yakuman win rounds\",\n    \"和出的最大番数（不含役满役）\": \"Highest han (except yakuman yakus)\",\n    \"流满次数\": \"Mangan-at-draw rounds\",\n    \"两立直次数\": \"Double riichi rounds\",\n    \"多面立直局数 / 立直局数<br/>听牌两种或以上即视为多面（含对碰）\": \"Multi-sided wait riichi rounds / Riichi rounds<br/>Tenpai with 2 or more sided waits is considered multi-sided wait, including shanpon\",\n    \"好型立直局数 / 立直局数<br/>立直时听牌可见剩余 6 枚或以上视为好型\": \"Good-hand riichi rounds / Riichi rounds<br/>Tenpai with 6 or more available tiles (all visible tiles factored in) at the time of riichi is considered good hand\",\n    \"（数据从 {{date}} 前后开始收集）\": \"(Data collection of this metric was started around {{date}})\",\n\n    \"升\": \"ranking-up\",\n    \"降\": \"ranking-down\",\n    \"，括号内为预计{{ label }}段场数\": \", the number in brackets is the prediction of matches for {{ label }}\",\n    \"在{{ modeL }}之间一直进行对局，预测最终能达到的段位。\": \"The predicted rank can be reached for continuing to play in {{ modeL }} Room.\",\n    \"括号内为安定段位时的分数期望。\": \"the number in brackets is the prediction of points in stable rank\",\n    \"（数据量不足，计算结果可能有较大偏差）\": \" (The result may be strongly biased if there's no sufficient data amounts)\",\n    \"{{ levelNames1 }}位平均 Pt / {{ levelName2 }}位平均得点 Pt：\": \"{{ levelNames1 }} place average pts / {{ levelName2 }} place average pts\",\n    \"在{{ modeL }}之间每局获得点数的数学期望值{{ changeLevelMsg }}\": \"The mathematical expectation of each round's points in {{ modeL }} Room{{ changeLevelMsg }} \",\n    \"得点效率（各顺位平均 Pt 及平均得点 Pt 的加权平均值）：\": \"Point efficiency (Weighted average of each rank's points with corresponding rank's rate): \",\n\n    \"番\": \"Han\",\n    \"满贯\": \"Mangan\",\n    \"跳满\": \"Haneman\",\n    \"倍满\": \"Baiman\",\n    \"三倍满\": \"Sanbaiman\",\n\n    \"胜率：\": \"Win rate: \",\n    \"对手\": \"Opponent\",\n    \"平均得点\": \"Average points\",\n\n    \"类型\": \"Type\",\n    \"记录和出局数：\": \"Recorded wins in this category: \",\n    \"役\": \"Yaku\",\n    \"记录数\": \"Recorded\",\n    \"比率\": \"Rate\",\n\n    \"一位率\": \"1st rate\",\n    \"二位率\": \"2nd rate\",\n    \"三位率\": \"3rd rate\",\n    \"四位率\": \"4th rate\",\n    \"对战数\": \"Matches played\",\n    \"在位记录\": \"Players recorded\",\n    \"统计对战数：\": \"Total number of matches: \",\n\n    \"局\": \"games\",\n\n    \"坐席吃一率\": \"Seat 1st rate\",\n    \"坐席吃三率\": \"Seat 3rd rate\",\n    \"坐席吃四率\": \"Seat 4th rate\",\n\n    \"苦主榜\": \"Negative ranking\",\n    \"一周\": \"1 week\",\n    \"四周\": \"4 weeks\",\n    \"三天\": \"3 days\",\n    \"一天\": \"1 day\",\n    \"汪汪榜\": \"Positive ranking\",\n    \"劳模榜\": \"Stamina ranking\",\n    \"提示\": \"Notice\",\n    \"本榜只包含有至少 300 场对局记录的玩家\": \"The ranking only includes the players with at least 300 recorded matches\",\n    \"排行榜非实时更新，可能会有数小时的延迟。\": \"The ranking is not renewed in real-time, a delay may occur for several hours.\",\n    \"排名\": \"Rk\",\n    \"对局数\": \"Matches\",\n    \"连对率\": \"Top 2 rate\",\n    \"得点效率\": \"Point efficiency\",\n    \"和铳差\": \"Win-lose diff\",\n\n    \"一位平均 Pt\": \"1st average Pt\",\n    \"请选择模式\": \"Please choose a level\",\n    \"二位平均 Pt\": \"2nd average Pt\",\n    \"三位平均 Pt\": \"3rd average Pt\",\n    \"四位平均得点 Pt\": \"4th average Pt\",\n    \"按服务器\": \"By server\",\n    \"按等级\": \"By rank\",\n\n    \"全体\": \"Overall\",\n    \"活跃玩家\": \"Active players\",\n    \"一年内对局过的玩家的一年对局数据\": \"1-year data of players who have played in the past year\",\n\n    \"初士杰豪圣魂\": \"NoAdExMsStCl\",\n\n    \"玩家前缀搜索\": \"Matching players\",\n    \"（输入更长名字显示其它结果）\": \"(Input longer names to show other results)\",\n\n    \"无超过满贯大铳\": \"No greater-than-mangan deal-in\",\n\n    \"加载数据失败\": \"Failed to load data\",\n\n    \"https://game.maj-soul.com/1/\": \"https://mahjongsoul.game.yo-star.com/\",\n    \"一位\": \"1st\",\n    \"二位\": \"2nd\",\n    \"三位\": \"3rd\",\n    \"四位\": \"4th\",\n    \"三\": \"3rd\",\n    \"四\": \"4th\",\n    \"一二三\": \"1st/2nd/3rd\",\n    \"一二\": \"1st/2nd\",\n\n    \"东\": \"E\",\n    \"南\": \"S\",\n    \"西\": \"W\",\n    \"北\": \"N\",\n\n    \"门前清自摸和\": \"Fully Concealed Hand\",\n    \"立直\": \"Riichi\",\n    \"枪杠\": \"Robbing a Kan\",\n    \"岭上开花\": \"After a Kan\",\n    \"海底摸月\": \"Under the Sea\",\n    \"河底捞鱼\": \"Under the River\",\n    \"役牌 白\": \"White Dragon (Haku)\",\n    \"役牌 发\": \"Green Dragon (Hatsu)\",\n    \"役牌 中\": \"Red Dragon (Chun)\",\n    \"役牌:门风牌\": \"Seat Wind\",\n    \"役牌:场风牌\": \"Prevalent Wind\",\n    \"断幺九\": \"All Simples\",\n    \"一杯口\": \"Pure Double Sequence\",\n    \"平和\": \"Pinfu\",\n    \"混全带幺九\": \"Half Outside Hand\",\n    \"一气通贯\": \"Pure Straight\",\n    \"三色同顺\": \"Mixed Triple Sequence\",\n    \"两立直\": \"Double riichi\",\n    \"三色同刻\": \"Triple Triplets\",\n    \"三杠子\": \"Three Quads\",\n    \"对对和\": \"All Triplets\",\n    \"三暗刻\": \"Three Concealed Triplets\",\n    \"小三元\": \"Little Three Dragons\",\n    \"混老头\": \"All Terminals and Honours\",\n    \"七对子\": \"Seven Pairs\",\n    \"纯全带幺九\": \"Fully Outside Hand\",\n    \"混一色\": \"Half Flush\",\n    \"二杯口\": \"Twice Pure Double Sequence\",\n    \"清一色\": \"Full Flush\",\n    \"一发\": \"Ippatsu\",\n    \"宝牌\": \"Dora\",\n    \"红宝牌\": \"Red Five\",\n    \"里宝牌\": \"Uradora\",\n    \"拔北宝牌\": \"Kita\",\n    \"天和\": \"Blessing of Heaven\",\n    \"地和\": \"Blessing of Earth\",\n    \"大三元\": \"Big Three Dragons\",\n    \"四暗刻\": \"Four Concealed Triplets\",\n    \"字一色\": \"All Honors\",\n    \"绿一色\": \"All Green\",\n    \"清老头\": \"All Terminals\",\n    \"国士无双\": \"Thirteen Orphans\",\n    \"小四喜\": \"Four Little Winds\",\n    \"四杠子\": \"Four Quads\",\n    \"九莲宝灯\": \"Nine Gates\",\n    \"八连庄\": \"Paarenchan\",\n    \"纯正九莲宝灯\": \"True Nine Gates\",\n    \"四暗刻单骑\": \"Single-wait Four Concealed Triplets\",\n    \"国士无双十三面\": \"Thirteen-wait Thirteen Orphans\",\n    \"大四喜\": \"Four Big Winds\",\n    \"燕返\": \"Tsubame-gaeshi\",\n    \"杠振\": \"Kanburi\",\n    \"十二落抬\": \"Shiiaruraotai\",\n    \"五门齐\": \"Uumensai\",\n    \"三连刻\": \"Three Chained Triplets\",\n    \"一色三同顺\": \"Pure Triple Chow\",\n    \"一筒摸月\": \"Iipinmoyue\",\n    \"九筒捞鱼\": \"Chuupinraoyui\",\n    \"人和\": \"Hand of Man\",\n    \"大车轮\": \"Big Wheels\",\n    \"大竹林\": \"Bamboo Forest\",\n    \"大数邻\": \"Numerous Neighbours\",\n    \"石上三年\": \"Ishinouenimosannen\",\n    \"大七星\": \"Big Seven Stars\"\n  },\n  \"form\": {\n    \"日期\": \"Date\",\n    \"查找玩家\": \"Player search\",\n    \"对局浏览\": \"Browse matches\",\n    \"名字\": \"Name\",\n    \"时间\": \"Period\",\n    \"等级\": \"Level\",\n    \"顺位\": \"Rank\",\n    \"巅峰对决\": \"Konten-only games\"\n  },\n  \"navButtons\": {\n    \"基本\": \"Basic\",\n    \"立直\": \"Riichi\",\n    \"更多\": \"Others\",\n    \"和铳分布\": \"Win-lose distribution\",\n    \"血统\": \"Lucky\",\n    \"最近大铳\": \"Recent high loss\",\n    \"最常同桌\": \"Frequent opponents\",\n    \"最近 100 局\": \"Last 100 matches\",\n    \"全部\": \"All\",\n\n    \"坐席顺位\": \"Seat ranking\",\n    \"等级数据\": \"Player ranks\",\n    \"和出役种统计\": \"Winning yakus\",\n    \"记录玩家数\": \"Number of players in record\",\n\n    \"苦主及汪汪\": \"Up/Down\",\n    \"一位率/四位率\": \"1st rate/4th rate\",\n    \"一位率/三位率\": \"1st rate/3rd rate\",\n    \"连对率/安定段位\": \"Top 2 rate/Stable rank\",\n    \"安定段位\": \"Stable rank\",\n    \"最高等级\": \"Best rank\",\n    \"平均顺位/对局数\": \"Average rank/Matches played\",\n    \"得点效率\": \"Point efficiency\",\n    \"和率/铳率\": \"Win rate/Deal-in rate\",\n    \"欧洲人\": \"Lucky\",\n    \"非洲人\": \"Unlucky\",\n    \"和铳差\": \"Win-lose diff\",\n\n    \"一/二位平均 Pt\": \"1st/2nd average Pt\",\n    \"三位平均 Pt/四位平均得点 Pt\": \"3rd/4th average Pt\"\n  },\n  \"gameModeShort\": {\n    \"金\": \"Gld\",\n    \"玉\": \"Jad\",\n    \"王座\": \"Thr\",\n    \"王东\": \"Thr E\",\n    \"玉东\": \"Jad E\",\n    \"金东\": \"Gld E\",\n\n    \"等级\": \"Level\"\n  }\n}\n"
  },
  {
    "path": "src/locales/ja.json",
    "content": "{\n  \"default\": {\n    \"雀魂牌谱屋\": \"雀魂牌譜屋\",\n    \"雀魂牌谱屋·金\": \"雀魂牌譜屋·金\",\n    \"雀魂牌谱屋·三麻\": \"雀魂牌譜屋·三麻\",\n    \"主页\": \"トップ\",\n    \"最近役满\": \"最近役満\",\n    \"排行榜\": \"ランキング\",\n    \"大数据\": \"データ\",\n    \"四麻玉/王座\": \"四人玉/王座\",\n    \"四麻金\": \"四人金\",\n    \"三麻\": \"三人\",\n    \"四麻\": \"四人\",\n\n    \"Twitter\": \"ツイッター\",\n\n    \"等级\": \"ﾚﾍﾞﾙ\",\n    \"顺位\": \"順位\",\n    \"玩家\": \"プレイヤー\",\n    \"时间\": \"日時\",\n    \"开始\": \"開始\",\n    \"结束\": \"終了\",\n\n    \"全部\": \"全部\",\n    \"最近四周\": \"四週間\",\n    \"自定义\": \"指定\",\n    \"王座\": \"王座\",\n    \"玉\": \"玉\",\n    \"金\": \"金\",\n    \"王东\": \"王東\",\n    \"玉东\": \"玉東\",\n    \"金东\": \"金東\",\n\n    \"最近 {{x}} 周\": \"{{x}} 週間\",\n    \"最近 {{x}} 场\": \"{{x}} 戦\",\n    \"本月\": \"今月\",\n    \"上月\": \"先月\",\n    \"今年\": \"今年\",\n    \"去年\": \"去年\",\n    \"新王座\": \"新王座\",\n    \"旧王座\": \"旧王座\",\n    \"自定义时间\": \"時刻を指定する\",\n    \"自定开始时间...\": \"開始を指定する...\",\n    \"自定结束时间...\": \"終了を指定する...\",\n    \"确定\": \"OK\",\n\n    \"收藏\": \"ブックマーク\",\n    \"已收藏\": \"ブックマーク済み\",\n\n    \"王東\": \"王東\",\n    \"玉東\": \"玉東\",\n    \"金東\": \"金東\",\n    \"一位\": \"一位\",\n    \"二位\": \"二位\",\n    \"三位\": \"三位\",\n    \"四位\": \"四位\",\n\n    \"筛选\": \"絞り込み\",\n\n    \"东\": \"東\",\n    \"南\": \"南\",\n    \"西\": \"西\",\n    \"北\": \"北\",\n\n    \"查看牌谱\": \"牌譜を見る\",\n    \"复制链接\": \"リンクをコピーする\",\n    \"链接复制成功\": \"リンクはコピーされました\",\n    \"玩家详细\": \"プレイヤーの情報\",\n    \"AI 检讨\": \"AI レビュー\",\n    \"https://mjai.ekyu.moe/zh-cn.html\": \"https://mjai.ekyu.moe/ja.html\",\n\n    \"玩家：\": \"プレイヤー：\",\n    \"最近走势\": \"対戦記録\",\n    \"累计战绩\": \"順位分布\",\n\n    \"和牌时\": \"和了時\",\n    \"放铳时\": \"放銃時\",\n    \"放铳至\": \"放銃相手\",\n    \"副露\": \"副露\",\n    \"默听\": \"ダマ\",\n    \"门清\": \"門前\",\n\n    \"{{mode}}位置：\": \"{{mode}}での位置：\",\n    \"{{mode}}平均值：\": \"{{mode}}の平均値：\",\n    \"{{mode}}各段位平均值：\": \"{{mode}}の段位別の平均値：\",\n\n    \"记录场数\": \"記録対戦数\",\n    \"记录等级\": \"記録段位\",\n    \"记录分数\": \"記録点数\",\n    \"平均顺位\": \"平均順位\",\n    \"被飞率\": \"飛び率\",\n    \"安定段位\": \"安定段位\",\n    \"分数期望\": \"点数期待\",\n    \"和牌率\": \"和了率\",\n    \"放铳率\": \"放銃率\",\n    \"自摸率\": \"ツモ率\",\n    \"默胡率\": \"ダマ率\",\n    \"流局率\": \"流局率\",\n    \"流听率\": \"流局聴牌率\",\n    \"副露率\": \"副露率\",\n    \"立直率\": \"立直率\",\n    \"和了巡数\": \"和了巡数\",\n    \"平均打点\": \"平均和了\",\n    \"平均铳点\": \"平均放銃\",\n    \"立直和了\": \"立直和了\",\n    \"立直放铳A\": \"立直放銃A\",\n    \"立直放铳B\": \"立直放銃B\",\n    \"立直收支\": \"立直収支\",\n    \"立直收入\": \"立直収入\",\n    \"立直支出\": \"立直支出\",\n    \"先制率\": \"先制率\",\n    \"追立率\": \"追っかけ率\",\n    \"被追率\": \"追っかけられ率\",\n    \"立直巡目\": \"立直巡目\",\n    \"立直流局\": \"立直流局\",\n    \"一发率\": \"一発率\",\n    \"振听率\": \"振聴率\",\n    \"最高等级\": \"最高段位\",\n    \"最高分数\": \"最高点数\",\n    \"最大连庄\": \"最大連荘\",\n    \"里宝率\": \"裏ドラ率\",\n    \"被炸率\": \"痛い親かぶり率\",\n    \"平均被炸点数\": \"痛い親かぶり平均\",\n    \"放铳时立直率\": \"放銃時立直率\",\n    \"放铳时副露率\": \"放銃時副露率\",\n    \"副露后放铳率\": \"副露後放銃率\",\n    \"副露后和牌率\": \"副露後和了率\",\n    \"副露后流局率\": \"副露後流局率\",\n    \"总计局数\": \"総計局数\",\n    \"役满\": \"役満\",\n    \"累计役满\": \"数え役満\",\n    \"最大累计番数\": \"最大合計飜数\",\n    \"流满\": \"流し満貫\",\n    \"起手向听\": \"配牌向聴\",\n    \"亲起手向听\": \"親配牌向聴\",\n    \"子起手向听\": \"子配牌向聴\",\n    \"立直好型\": \"立直良形\",\n    \"立直多面\": \"立直多面\",\n    \"打点效率\": \"打点効率\",\n    \"铳点损失\": \"銃点損失\",\n    \"净打点效率\": \"調整打点効率\",\n    \"局收支\": \"局収支\",\n    \"场平均素点\": \"対戦平均持ち点\",\n    \"场起始素点\": \"対戦初期持ち点\",\n\n    \"和牌局数 / 总局数\": \"和了回数 / 配牌回数\",\n    \"放铳局数 / 总局数\": \"放銃回数 / 配牌回数\",\n    \"自摸局数 / 和牌局数\": \"ツモ回数 / 和了回数\",\n    \"门清默听和牌局数 / 和牌局数\": \"門前ダマ和了回数 / 和了回数\",\n    \"流局局数 / 总局数\": \"流局回数 / 配牌回数\",\n    \"流局听牌局数 / 流局局数\": \"流局聴牌回数 / 流局回数\",\n    \"副露局数 / 总局数\": \"副露回数 / 配牌回数\",\n    \"立直局数 / 总局数\": \"立直回数 / 配牌回数\",\n    \"立直和了局数 / 立直局数\": \"立直和了回数 / 立直回数\",\n    \"立直放铳局数（含立直瞬间 / 不含立直瞬间） / 立直局数\": \"立直放銃回数（立直瞬間を含む / 含まない） / 立直回数\",\n    \"立直放铳局数（含立直瞬间） / 立直局数\": \"立直放銃回数（立直瞬間を含む） / 立直回数\",\n    \"立直放铳局数（不含立直瞬间） / 立直局数\": \"立直放銃回数（立直瞬間を含まない） / 立直回数\",\n    \"立直总收支（含供托） / 立直局数\": \"立直時の収支（供託を含む） / 立直回数\",\n    \"立直和了收入（含供托） / 立直和了局数\": \"立直和了収入（供託を含む） / 立直和了回数\",\n    \"立直放铳支出（含立直棒） / 立直放铳局数\": \"立直時の放銃支出（供託を含む） / 立直放銃回数\",\n    \"先制立直局数 / 立直局数\": \"最初に立直した回数 / 立直回数\",\n    \"追立局数 / 立直局数\": \"追っかけ立直した回数 / 立直回数\",\n    \"被追立局数 / 立直局数\": \"立直を追っかけられた回数 / 立直回数\",\n    \"立直流局局数 / 立直局数\": \"立直流局回数 / 立直回数\",\n    \"一发局数 / 立直和了局数\": \"一発回数 / 立直和了回数\",\n    \"振听立直局数（不含立直见逃） / 立直局数\": \"振聴立直回数（見逃しを除く） / 立直回数\",\n    \"中里宝局数 / 立直和了局数\": \"裏ドラある和了回数 / 立直和了回数\",\n    \"被炸庄（满贯或以上）次数 / 被自摸次数\": \"満貫以上の親かぶり回数 / ツモされた回数\",\n    \"被炸庄（满贯或以上）点数 / 次数\": \"満貫以上の親かぶり点数 / 回数\",\n    \"放铳时立直次数 / 放铳次数\": \"立直放銃回数 / 放銃回数\",\n    \"放铳时副露次数 / 放铳次数\": \"副露放銃回数 / 放銃回数\",\n    \"放铳时副露次数 / 副露次数\": \"副露放銃回数 / 副露回数\",\n    \"副露后和牌次数 / 副露次数\": \"副露和了回数 / 副露回数\",\n    \"副露后流局次数 / 副露次数\": \"副露流局回数 / 副露回数\",\n    \"和出役满次数\": \"役満和了回数\",\n    \"和出累计役满次数\": \"数え役満和了回数\",\n    \"和出的最大番数（不含役满役）\": \"和了した最大飜数（役満の役を除く）\",\n    \"流满次数\": \"流局満貫回数\",\n    \"两立直次数\": \"ダブル立直回数\",\n    \"多面立直局数 / 立直局数<br/>听牌两种或以上即视为多面（含对碰）\": \"多面立直回数 / 立直回数<br/>二面以上待ちの聴牌が多面と見なされます（シャンポン待ちを含む）\",\n    \"好型立直局数 / 立直局数<br/>立直时听牌可见剩余 6 枚或以上视为好型\": \"良形立直回数 / 立直回数<br/>立直の時に自分の視点で残り枚数が６以上の聴牌が良形と見なされます\",\n    \"（数据从 {{date}} 前后开始收集）\": \"（この数値は {{date}} ごろから集計しています）\",\n\n    \"升\": \"昇\",\n    \"降\": \"降\",\n    \"，括号内为预计{{ label }}段场数\": \"、括弧内は予測した{{ label }}段対戦数\",\n    \"在{{ modeL }}之间一直进行对局，预测最终能达到的段位。\": \"{{ modeL }}の間に対戦し続けると、最終に安定している段位を予測します。\",\n    \"括号内为安定段位时的分数期望。\": \"括弧内は安定段位に着く後、点数変化の期待値\",\n    \"（数据量不足，计算结果可能有较大偏差）\": \"（データが足りないので、予測した結果は大きい誤差が出る可能性があります）\",\n    \"{{ levelNames1 }}位平均 Pt / {{ levelName2 }}位平均得点 Pt：\": \"{{ levelNames1 }}位平均 Pt / {{ levelName2 }}位平均得点 Pt：\",\n    \"在{{ modeL }}之间每局获得点数的数学期望值{{ changeLevelMsg }}\": \"{{ modeL }}の間に対戦の点数変化の期待値{{ changeLevelMsg }}\",\n    \"得点效率（各顺位平均 Pt 及平均得点 Pt 的加权平均值）：\": \"得点効率（順位ごとの平均 Pt / 平均得点 Ptの加重平均値）：\",\n\n    \"番\": \"飜\",\n    \"满贯\": \"満貫\",\n    \"跳满\": \"跳満\",\n    \"倍满\": \"倍満\",\n    \"三倍满\": \"三倍満\",\n\n    \"胜率：\": \"勝率：\",\n    \"对手\": \"相手\",\n    \"平均得点\": \"平均得点\",\n\n    \"类型\": \"タイプ\",\n    \"记录和出局数：\": \"記録した和了件数：\",\n    \"役\": \"役\",\n    \"记录数\": \"記録数\",\n    \"比率\": \"割合\",\n\n    \"一位率\": \"一位率\",\n    \"二位率\": \"二位率\",\n    \"三位率\": \"三位率\",\n    \"四位率\": \"四位率\",\n    \"对战数\": \"対戦数\",\n    \"在位记录\": \"在位記録\",\n    \"统计对战数：\": \"集計した対戦数：\",\n\n    \"坐席吃一率\": \"座席一位率\",\n    \"坐席吃三率\": \"座席三位率\",\n    \"坐席吃四率\": \"座席四位率\",\n\n    \"苦主榜\": \"不調ランキング\",\n    \"一周\": \"一週間\",\n    \"四周\": \"四週間\",\n    \"三天\": \"三日間\",\n    \"一天\": \"一日間\",\n    \"汪汪榜\": \"好調ランキング\",\n    \"劳模榜\": \"鬼打ちランキング\",\n    \"提示\": \"提示\",\n    \"本榜只包含有至少 300 场对局记录的玩家\": \"本ランキングは 300 戦以上の記録があるプレイヤーだけが入られます\",\n    \"排行榜非实时更新，可能会有数小时的延迟。\": \"ランキングはリアルタイムではありません、数時間ぐらい遅れることがあります。\",\n    \"排名\": \"順位\",\n    \"对局数\": \"対戦\",\n    \"连对率\": \"連対率\",\n    \"得点效率\": \"得点効率\",\n\n    \"一位平均 Pt\": \"一位平均 Pt\",\n    \"请选择模式\": \"レベルを選んでください\",\n    \"二位平均 Pt\": \"二位平均 Pt\",\n    \"三位平均 Pt\": \"三位平均 Pt\",\n    \"四位平均得点 Pt\": \"四位平均得点 Pt\",\n    \"和铳差\": \"和銃差\",\n    \"按服务器\": \"サーバー別\",\n    \"按等级\": \"段位別\",\n\n    \"全体\": \"全体\",\n    \"活跃玩家\": \"アクティブプレイヤー\",\n    \"一年内对局过的玩家的一年对局数据\": \"過去一年間に対局したプレイヤーの一年分の対局データ\",\n\n    \"局\": \"戦\",\n\n    \"初士杰豪圣魂\": \"初士傑豪聖魂\",\n\n    \"玩家前缀搜索\": \"名前の先頭部分による検索\",\n    \"（输入更长名字显示其它结果）\": \"（入力し続くと他の結果が表示します）\",\n\n    \"无超过满贯大铳\": \"満貫を超える放銃はありません\",\n\n    \"加载数据失败\": \"データの読み込みに失敗しました\",\n\n    \"https://game.maj-soul.com/1/\": \"https://game.mahjongsoul.com/\",\n\n    \"门前清自摸和\": \"門前清自摸和\",\n    \"立直\": \"立直\",\n    \"枪杠\": \"槍槓\",\n    \"岭上开花\": \"嶺上開花\",\n    \"海底摸月\": \"海底摸月\",\n    \"河底捞鱼\": \"河底撈魚\",\n    \"役牌 白\": \"役牌 白\",\n    \"役牌 发\": \"役牌 發\",\n    \"役牌 中\": \"役牌 中\",\n    \"役牌:门风牌\": \"役牌:自風牌\",\n    \"役牌:场风牌\": \"役牌:場風牌\",\n    \"断幺九\": \"断幺九\",\n    \"一杯口\": \"一盃口\",\n    \"平和\": \"平和\",\n    \"混全带幺九\": \"混全帯幺九\",\n    \"一气通贯\": \"一気通貫\",\n    \"三色同顺\": \"三色同順\",\n    \"两立直\": \"ダブル立直\",\n    \"三色同刻\": \"三色同刻\",\n    \"三杠子\": \"三槓子\",\n    \"对对和\": \"対々和\",\n    \"三暗刻\": \"三暗刻\",\n    \"小三元\": \"小三元\",\n    \"混老头\": \"混老頭\",\n    \"七对子\": \"七対子\",\n    \"纯全带幺九\": \"純全帯幺九\",\n    \"混一色\": \"混一色\",\n    \"二杯口\": \"二盃口\",\n    \"清一色\": \"清一色\",\n    \"一发\": \"一発\",\n    \"宝牌\": \"ドラ\",\n    \"红宝牌\": \"赤ドラ\",\n    \"里宝牌\": \"裏ドラ\",\n    \"拔北宝牌\": \"抜きドラ\",\n    \"天和\": \"天和\",\n    \"地和\": \"地和\",\n    \"大三元\": \"大三元\",\n    \"四暗刻\": \"四暗刻\",\n    \"字一色\": \"字一色\",\n    \"绿一色\": \"緑一色\",\n    \"清老头\": \"清老頭\",\n    \"国士无双\": \"国士無双\",\n    \"小四喜\": \"小四喜\",\n    \"四杠子\": \"四槓子\",\n    \"九莲宝灯\": \"九蓮宝燈\",\n    \"八连庄\": \"八連荘\",\n    \"纯正九莲宝灯\": \"純正九蓮宝燈\",\n    \"四暗刻单骑\": \"四暗刻単騎\",\n    \"国士无双十三面\": \"国士無双十三面待ち\",\n    \"大四喜\": \"大四喜\",\n    \"燕返\": \"燕返し\",\n    \"杠振\": \"槓振り\",\n    \"十二落抬\": \"十二落抬\",\n    \"五门齐\": \"五門斉\",\n    \"三连刻\": \"三連刻\",\n    \"一色三同顺\": \"一色三順\",\n    \"一筒摸月\": \"一筒摸月\",\n    \"九筒捞鱼\": \"九筒撈魚\",\n    \"人和\": \"人和\",\n    \"大车轮\": \"大車輪\",\n    \"大竹林\": \"大竹林\",\n    \"大数邻\": \"大数隣\",\n    \"石上三年\": \"石の上にも三年\",\n    \"大七星\": \"大七星\"\n  },\n  \"form\": {\n    \"日期\": \"日付\",\n    \"查找玩家\": \"プレイヤー検索\",\n    \"对局浏览\": \"対局閲覧\",\n    \"名字\": \"名前\",\n    \"时间\": \"期間\",\n    \"等级\": \"レベル\",\n    \"顺位\": \"順位\",\n    \"巅峰对决\": \"頂上対決\"\n  },\n  \"navButtons\": {\n    \"基本\": \"基本\",\n    \"立直\": \"立直\",\n    \"更多\": \"ほか\",\n    \"和铳分布\": \"和銃分布\",\n    \"血统\": \"幸運度\",\n    \"最近大铳\": \"最近大銃\",\n    \"最常同桌\": \"よく同卓する相手\",\n    \"最近 100 局\": \"最近 100 戦\",\n    \"全部\": \"全部\",\n\n    \"坐席顺位\": \"座席順位\",\n    \"等级数据\": \"段位データ\",\n    \"和出役种统计\": \"和了役集計\",\n    \"记录玩家数\": \"記録プレイヤー数\",\n\n    \"苦主及汪汪\": \"不調と好調\",\n    \"一位率/四位率\": \"一位率/四位率\",\n    \"一位率/三位率\": \"一位率/三位率\",\n    \"连对率/安定段位\": \"連対率/安定段位\",\n    \"最高等级\": \"最高段位\",\n    \"安定段位\": \"安定段位\",\n    \"平均顺位/对局数\": \"平均順位/対戦数\",\n    \"得点效率\": \"得点効率\",\n    \"和率/铳率\": \"和了率/放銃率\",\n    \"欧洲人\": \"ラッキー\",\n    \"非洲人\": \"アンラッキー\",\n    \"和铳差\": \"和銃差\",\n\n    \"一/二位平均 Pt\": \"一/二位平均 Pt\",\n    \"三位平均 Pt/四位平均得点 Pt\": \"三位平均 Pt/四位平均得点 Pt\"\n  },\n  \"gameModeShort\": {\n    \"王座\": \"王座\",\n    \"玉\": \"玉\",\n    \"金\": \"金\",\n    \"王东\": \"王東\",\n    \"玉东\": \"玉東\",\n    \"金东\": \"金東\",\n\n    \"等级\": \"レベル\"\n  }\n}\n"
  },
  {
    "path": "src/locales/ko.json",
    "content": "{\n  \"default\": {\n    \"雀魂牌谱屋\": \"작혼 통계\",\n    \"雀魂牌谱屋·金\": \"작혼 통계·금탁\",\n    \"雀魂牌谱屋·三麻\": \"작혼 통계·3마\",\n    \"主页\": \"홈\",\n    \"最近役满\": \"최근 역만\",\n    \"排行榜\": \"랭킹\",\n    \"大数据\": \"데이터\",\n    \"四麻玉/王座\": \"4인 옥/왕좌탁\",\n    \"四麻\": \"4인\",\n    \"四麻金\": \"4인 금탁\",\n    \"三麻\": \"3인\",\n\n    \"Twitter\": \"트위터\",\n\n    \"等级\": \"등급\",\n    \"顺位\": \"순위\",\n    \"玩家\": \"플레이어\",\n    \"时间\": \"일시\",\n    \"开始\": \"시작\",\n    \"结束\": \"종료\",\n\n    \"全部\": \"전체\",\n    \"最近四周\": \"4주간\",\n    \"自定义\": \"지정\",\n    \"王座\": \"왕좌\",\n    \"玉\": \"옥\",\n    \"金\": \"금\",\n    \"王东\": \"왕좌E\",\n    \"玉东\": \"옥E\",\n    \"金东\": \"금E\",\n\n    \"王東\": \"왕좌E\",\n    \"玉東\": \"옥E\",\n    \"金東\": \"금E\",\n\n    \"最近 {{x}} 周\": \"{{x}} 주간\",\n    \"最近 {{x}} 场\": \"{{x}} 대국\",\n    \"本月\": \"이번 달\",\n    \"上月\": \"지난 달\",\n    \"今年\": \"올해\",\n    \"去年\": \"작년\",\n    \"新王座\": \"신왕좌\",\n    \"旧王座\": \"구왕좌\",\n    \"自定义时间\": \"시각 선택\",\n    \"自定开始时间...\": \"시작 시점 선택\",\n    \"自定结束时间...\": \"종료 시점 선택\",\n    \"确定\": \"OK\",\n\n    \"收藏\": \"즐겨찾기\",\n    \"已收藏\": \"즐겨찾기 완료\",\n\n    \"筛选\": \"필터\",\n\n    \"东\": \"동\",\n    \"南\": \"남\",\n    \"西\": \"서\",\n    \"北\": \"북\",\n\n    \"查看牌谱\": \"패보 보기\",\n    \"复制链接\": \"링크 복사\",\n    \"链接复制成功\": \"링크가 복사되었습니다\",\n    \"玩家详细\": \"플레이어 정보\",\n    \"AI 检讨\": \"AI 패보 복기하기\",\n    \"https://mjai.ekyu.moe/zh-cn.html\": \"https://mjai.ekyu.moe/ko.html\",\n\n    \"玩家：\": \"플레이어: \",\n    \"最近走势\": \"대전 기록\",\n    \"累计战绩\": \"순위 분포\",\n\n    \"和牌时\": \"화료시\",\n    \"放铳时\": \"방총시\",\n    \"放铳至\": \"방총 상대\",\n    \"副露\": \"후로\",\n    \"默听\": \"다마\",\n    \"门清\": \"멘젠\",\n\n    \"{{mode}}位置：\": \"{{mode}}탁에서의 위치: \",\n    \"{{mode}}平均值：\": \"{{mode}}탁의 평균치: \",\n    \"{{mode}}各段位平均值：\": \"{{mode}}탁의 단위별 평균치: \",\n\n    \"记录场数\": \"기록 대국 수\",\n    \"记录等级\": \"현재 단위\",\n    \"记录分数\": \"현재 점수\",\n    \"平均顺位\": \"평균 순위\",\n    \"被飞率\": \"토비율\",\n    \"安定段位\": \"안정 단위\",\n    \"分数期望\": \"기대 점수\",\n    \"和牌率\": \"화료율\",\n    \"放铳率\": \"방총률\",\n    \"自摸率\": \"쯔모율\",\n    \"默胡率\": \"다마율\",\n    \"流局率\": \"유국률\",\n    \"流听率\": \"유국 텐파이율\",\n    \"副露率\": \"후로율\",\n    \"立直率\": \"리치율\",\n    \"和了巡数\": \"화료순\",\n    \"平均打点\": \"평균 화료\",\n    \"平均铳点\": \"평균 방총\",\n    \"立直和了\": \"리치 화료\",\n    \"立直放铳A\": \"리치 방총 A\",\n    \"立直放铳B\": \"리치 방총 B\",\n    \"立直收支\": \"리치 수지\",\n    \"立直收入\": \"리치 수입\",\n    \"立直支出\": \"리치 지출\",\n    \"先制率\": \"선제율\",\n    \"追立率\": \"추격률\",\n    \"被追率\": \"피추격률\",\n    \"立直巡目\": \"리치순\",\n    \"立直流局\": \"리치 유국\",\n    \"一发率\": \"일발률\",\n    \"振听率\": \"후리텐률\",\n    \"最高等级\": \"최고 단위\",\n    \"最高分数\": \"최고 점수\",\n    \"最大连庄\": \"최대 연장\",\n    \"里宝率\": \"뒷도라율\",\n    \"被炸率\": \"아픈 오야카부리율\",\n    \"平均被炸点数\": \"아픈 오야카부리 평균\",\n    \"放铳时立直率\": \"방총시 리치율\",\n    \"放铳时副露率\": \"방총시 후로율\",\n    \"副露后放铳率\": \"후로 후 방총률\",\n    \"副露后和牌率\": \"후로 후 화료율\",\n    \"副露后流局率\": \"후로 후 유국률\",\n    \"总计局数\": \"총합 국 수\",\n    \"役满\": \"역만\",\n    \"累计役满\": \"카조에 역만\",\n    \"最大累计番数\": \"최대 합계 판수\",\n    \"流满\": \"나가시만관\",\n    \"起手向听\": \"배패 텐수\",\n    \"亲起手向听\": \"친 배패 텐수\",\n    \"子起手向听\": \"자 배패 텐수\",\n    \"立直好型\": \"리치 양형\",\n    \"立直多面\": \"리치 다면\",\n    \"打点效率\": \"화료 효율\",\n    \"铳点损失\": \"방총 손실\",\n    \"净打点效率\": \"알짜 화료 효율\",\n    \"局收支\": \"국수지\",\n    \"场平均素点\": \"대전 평균 소지점\",\n    \"场起始素点\": \"대전 초기 소지점\",\n\n    \"和牌局数 / 总局数\": \"화료 횟수 / 배패 횟수\",\n    \"放铳局数 / 总局数\": \"방총 횟수 / 배패 횟수\",\n    \"自摸局数 / 和牌局数\": \"쯔모 횟수 / 화료 횟수\",\n    \"门清默听和牌局数 / 和牌局数\": \"멘젠다마 화료 횟수 / 화료 횟수\",\n    \"流局局数 / 总局数\": \"유국 횟수 / 배패 횟수\",\n    \"流局听牌局数 / 流局局数\": \"유국 텐파이 횟수 / 유국 횟수\",\n    \"副露局数 / 总局数\": \"후로 횟수 / 배패 횟수\",\n    \"立直局数 / 总局数\": \"리치 횟수 / 배패 횟수\",\n    \"立直和了局数 / 立直局数\": \"리치화료 횟수 / 리치 횟수\",\n    \"立直放铳局数（含立直瞬间 / 不含立直瞬间） / 立直局数\": \"리치방총 횟수(리치 순간을 포함/포함하지 않음) / 리치 횟수\",\n    \"立直放铳局数（含立直瞬间） / 立直局数\": \"리치방총 횟수(리치 순간을 포함) / 리치 횟수\",\n    \"立直放铳局数（不含立直瞬间） / 立直局数\": \"리치방총 횟수(리치 순간을 포함하지 않음) / 리치 횟수\",\n    \"立直总收支（含供托） / 立直局数\": \"리치시의 수지(공탁금 포함) / 리치 횟수\",\n    \"立直和了收入（含供托） / 立直和了局数\": \"리치화료 수입(공탁금 포함) / 리치화료 횟수\",\n    \"立直放铳支出（含立直棒） / 立直放铳局数\": \"리치시의 방총 지출(공탁금 포함) / 리치방총 횟수\",\n    \"先制立直局数 / 立直局数\": \"최초에 리치한 횟수 / 리치 횟수\",\n    \"追立局数 / 立直局数\": \"추격리치한 횟수 / 리치 횟수\",\n    \"被追立局数 / 立直局数\": \"리치를 추격당한 횟수 / 리치 횟수\",\n    \"立直流局局数 / 立直局数\": \"리치유국 횟수 / 리치 횟수\",\n    \"一发局数 / 立直和了局数\": \"일발 횟수 / 리치화료 횟수\",\n    \"振听立直局数（不含立直见逃） / 立直局数\": \"후리텐리치 횟수(미노가시 제외) / 리치 횟수\",\n    \"中里宝局数 / 立直和了局数\": \"뒷도라 있는 화료 횟수 / 리치화료 횟수\",\n    \"被炸庄（满贯或以上）次数 / 被自摸次数\": \"만관 이상의 오야카부리 횟수 / 쯔모당한 횟수\",\n    \"被炸庄（满贯或以上）点数 / 次数\": \"만관 이상의 오야카부리 점수 / 횟수\",\n    \"放铳时立直次数 / 放铳次数\": \"리치방총 횟수 / 방총 횟수\",\n    \"放铳时副露次数 / 放铳次数\": \"후로방총 횟수 / 방총 횟수\",\n    \"放铳时副露次数 / 副露次数\": \"후로방총 횟수 / 후로 횟수\",\n    \"副露后和牌次数 / 副露次数\": \"후로화료 횟수 / 후로 횟수\",\n    \"副露后流局次数 / 副露次数\": \"후로유국 횟수 / 후로 횟수\",\n    \"和出役满次数\": \"역만화료 횟수\",\n    \"和出累计役满次数\": \"카조에역만 화료 횟수\",\n    \"和出的最大番数（不含役满役）\": \"화료한 최대 판수(역만 역 제외)\",\n    \"流满次数\": \"유국만관 횟수\",\n    \"两立直次数\": \"더블리치 횟수\",\n    \"多面立直局数 / 立直局数<br/>听牌两种或以上即视为多面（含对碰）\": \"다면리치 횟수 / 리치 횟수<br/>2면 이상의 대기인 텐파이를 다면으로 취급합니다(샤보 대기 포함)\",\n    \"好型立直局数 / 立直局数<br/>立直时听牌可见剩余 6 枚或以上视为好型\": \"양형리치 횟수 / 리치 횟수<br/>리치 시에 자신의 시점에서 남은 매수가 6매 이상인 텐파이를 양형으로 취급합니다\",\n    \"（数据从 {{date}} 前后开始收集）\": \"(이 수치는 {{date}} 즈음부터 계산되고 있습니다)\",\n\n    \"升\": \"승\",\n    \"降\": \"강\",\n    \"，括号内为预计{{ label }}段场数\": \", 괄호 안은 예측된 {{ label }}단 대전수\",\n    \"在{{ modeL }}之间一直进行对局，预测最终能达到的段位。\": \"{{ modeL }}탁에서 대전을 계속했을 때 최종적으로 안정되는 단위를 예측합니다.\",\n    \"括号内为安定段位时的分数期望。\": \"괄호 안은 안정 단위에 도달한 후, 점수 변화의 기대치\",\n    \"（数据量不足，计算结果可能有较大偏差）\": \"(데이터가 부족하므로 예측한 결과와 큰 오차가 발생할 수 있습니다)\",\n    \"{{ levelNames1 }}位平均 Pt / {{ levelName2 }}位平均得点 Pt：\": \"{{ levelNames1 }} 평균 Pt / {{ levelName2 }} 평균 Pt: \",\n    \"在{{ modeL }}之间每局获得点数的数学期望值{{ changeLevelMsg }}\": \"{{ modeL }}탁에서 대전의 점수 변화 기대치{{ changeLevelMsg }}\",\n    \"得点效率（各顺位平均 Pt 及平均得点 Pt 的加权平均值）：\": \"득점 효율 (순위별 평균 Pt의 가중 평균): \",\n\n    \"番\": \"판\",\n    \"满贯\": \"만관\",\n    \"跳满\": \"하네만\",\n    \"倍满\": \"배만\",\n    \"三倍满\": \"삼배만\",\n\n    \"胜率：\": \"승률: \",\n    \"对手\": \"상대\",\n    \"平均得点\": \"평균 득점\",\n\n    \"类型\": \"종류\",\n    \"记录和出局数：\": \"기록된 화료 건수: \",\n    \"役\": \"역\",\n    \"记录数\": \"기록 수\",\n    \"比率\": \"비율\",\n\n    \"一位率\": \"1위율\",\n    \"二位率\": \"2위율\",\n    \"三位率\": \"3위율\",\n    \"四位率\": \"4위율\",\n    \"对战数\": \"대전\",\n    \"在位记录\": \"기록된 플레이어 수\",\n    \"统计对战数：\": \"집계된 대전 수: \",\n\n    \"局\": \"전\",\n\n    \"坐席吃一率\": \"자리별 1위율\",\n    \"坐席吃三率\": \"자리별 3위율\",\n    \"坐席吃四率\": \"자리별 4위율\",\n\n    \"苦主榜\": \"하강 랭킹\",\n    \"一周\": \"1주간\",\n    \"四周\": \"4주간\",\n    \"三天\": \"3일간\",\n    \"一天\": \"1일간\",\n    \"汪汪榜\": \"상승 랭킹\",\n    \"劳模榜\": \"겜창 랭킹\",\n    \"提示\": \"안내\",\n    \"本榜只包含有至少 300 场对局记录的玩家\": \"본 랭킹은 300전 이상의 기록이 있는 플레이어만 포함합니다\",\n    \"排行榜非实时更新，可能会有数小时的延迟。\": \"랭킹은 실시간으로 갱신되지 않습니다. 수 시간 정도 늦을 수 있습니다.\",\n    \"排名\": \"순위\",\n    \"对局数\": \"대전\",\n    \"连对率\": \"연대율\",\n    \"得点效率\": \"득점 효율\",\n    \"和铳差\": \"화료방총차\",\n    \"按服务器\": \"서버별\",\n    \"按等级\": \"단위별\",\n\n    \"全体\": \"전체\",\n    \"活跃玩家\": \"액티브 플레이어\",\n    \"一年内对局过的玩家的一年对局数据\": \"과거 1년간 대국한 플레이어의 1년분의 대국 데이터\",\n\n    \"一位平均 Pt\": \"1위 평균 Pt\",\n    \"请选择模式\": \"등급을 선택해 주세요\",\n    \"二位平均 Pt\": \"2위 평균 Pt\",\n    \"三位平均 Pt\": \"3위 평균 Pt\",\n    \"四位平均得点 Pt\": \"4위 평균 Pt\",\n\n    \"初士杰豪圣魂\": \"초사걸호성혼\",\n\n    \"玩家前缀搜索\": \"검색된 플레이어\",\n    \"（输入更长名字显示其它结果）\": \"(입력을 계속해서 다른 결과를 볼 수 있습니다)\",\n\n    \"无超过满贯大铳\": \"만관을 넘는 방총이 없습니다\",\n\n    \"加载数据失败\": \"데이터를 읽지 못했습니다\",\n\n    \"https://game.maj-soul.com/1/\": \"https://game.mahjongsoul.com/\",\n    \"一位\": \"1위\",\n    \"二位\": \"2위\",\n    \"三位\": \"3위\",\n    \"四位\": \"4위\",\n    \"三\": \"3위\",\n    \"四\": \"4위\",\n    \"一二三\": \"1위/2위/3위\",\n    \"一二\": \"1위/2위\",\n\n    \"门前清自摸和\": \"멘젠쯔모\",\n    \"立直\": \"리치\",\n    \"枪杠\": \"창깡\",\n    \"岭上开花\": \"영상개화\",\n    \"海底摸月\": \"해저로월\",\n    \"河底捞鱼\": \"하저로어\",\n    \"役牌 白\": \"역패 백\",\n    \"役牌 发\": \"역패 발\",\n    \"役牌 中\": \"역패 중\",\n    \"役牌:门风牌\": \"역패:자풍패\",\n    \"役牌:场风牌\": \"역패:장풍패\",\n    \"断幺九\": \"탕야오\",\n    \"一杯口\": \"이페코\",\n    \"平和\": \"핑후\",\n    \"混全带幺九\": \"찬타\",\n    \"一气通贯\": \"일기통관\",\n    \"三色同顺\": \"삼색동순\",\n    \"两立直\": \"더블리치\",\n    \"三色同刻\": \"삼색동각\",\n    \"三杠子\": \"산깡쯔\",\n    \"对对和\": \"또이또이\",\n    \"三暗刻\": \"산안커\",\n    \"小三元\": \"소삼원\",\n    \"混老头\": \"혼노두\",\n    \"七对子\": \"치또이츠\",\n    \"纯全带幺九\": \"준찬타\",\n    \"混一色\": \"혼일색\",\n    \"二杯口\": \"량페코\",\n    \"清一色\": \"청일색\",\n    \"一发\": \"일발\",\n    \"宝牌\": \"도라\",\n    \"红宝牌\": \"적도라\",\n    \"里宝牌\": \"뒷도라\",\n    \"拔北宝牌\": \"빼기도라\",\n    \"天和\": \"천화\",\n    \"地和\": \"지화\",\n    \"大三元\": \"대삼원\",\n    \"四暗刻\": \"스안커\",\n    \"字一色\": \"자일색\",\n    \"绿一色\": \"녹일색\",\n    \"清老头\": \"청노두\",\n    \"国士无双\": \"국사무쌍\",\n    \"小四喜\": \"소사희\",\n    \"四杠子\": \"스깡쯔\",\n    \"九莲宝灯\": \"구련보등\",\n    \"八连庄\": \"팔연장\",\n    \"纯正九莲宝灯\": \"순정구련보등\",\n    \"四暗刻单骑\": \"스안커단기\",\n    \"国士无双十三面\": \"국사무쌍 13면 대기\",\n    \"大四喜\": \"대사희\",\n    \"燕返\": \"츠바메가에시\",\n    \"杠振\": \"영상개론\",\n    \"十二落抬\": \"십이낙태\",\n    \"五门齐\": \"오문제\",\n    \"三连刻\": \"삼련각\",\n    \"一色三同顺\": \"일색삼순\",\n    \"一筒摸月\": \"일통모월\",\n    \"九筒捞鱼\": \"구통로어\",\n    \"人和\": \"인화\",\n    \"大车轮\": \"대차륜\",\n    \"大竹林\": \"대죽림\",\n    \"大数邻\": \"대수린\",\n    \"石上三年\": \"돌 위에서 삼년\",\n    \"大七星\": \"대칠성\"\n  },\n  \"form\": {\n    \"日期\": \"일자\",\n    \"查找玩家\": \"플레이어 검색\",\n    \"对局浏览\": \"대국 기록\",\n    \"名字\": \"이름\",\n    \"时间\": \"기간\",\n    \"等级\": \"등급\",\n    \"顺位\": \"순위\",\n    \"巅峰对决\": \"4혼천 대국\"\n  },\n  \"navButtons\": {\n    \"基本\": \"기본\",\n    \"立直\": \"리치\",\n    \"更多\": \"그 외\",\n    \"和铳分布\": \"화료 방총 분포\",\n    \"血统\": \"행운도\",\n    \"最近大铳\": \"최근 고타점 방총\",\n    \"最常同桌\": \"자주 만나는 상대\",\n    \"最近 100 局\": \"최근 100 전\",\n    \"全部\": \"전체\",\n\n    \"坐席顺位\": \"자리별 순위\",\n    \"等级数据\": \"단위별 데이터\",\n    \"和出役种统计\": \"화료역 집계\",\n    \"记录玩家数\": \"기록 플레이어 수\",\n\n    \"苦主及汪汪\": \"하강과 상승\",\n    \"一位率/四位率\": \"1위율/4위율\",\n    \"一位率/三位率\": \"1위율/3위율\",\n    \"连对率/安定段位\": \"연대율/안정 단위\",\n    \"最高等级\": \"최고 단위\",\n    \"安定段位\": \"안정 단위\",\n    \"平均顺位/对局数\": \"평균 순위/대전수\",\n    \"得点效率\": \"득점 효율\",\n    \"和率/铳率\": \"화료율/방총률\",\n    \"欧洲人\": \"행운\",\n    \"非洲人\": \"불운\",\n    \"和铳差\": \"화료방총차\",\n\n    \"一/二位平均 Pt\": \"1위/2위 평균 Pt\",\n    \"三位平均 Pt/四位平均得点 Pt\": \"3위/4위 평균 Pt\"\n  },\n  \"gameModeShort\": {\n    \"王座\": \"왕좌\",\n    \"玉\": \"옥\",\n    \"金\": \"금\",\n    \"王东\": \"왕좌E\",\n    \"玉东\": \"옥E\",\n    \"金东\": \"금E\",\n\n    \"等级\": \"등급\"\n  }\n}\n"
  },
  {
    "path": "src/react-app-env.d.ts",
    "content": "/// <reference types=\"react-scripts\" />\n"
  },
  {
    "path": "src/service-worker.ts",
    "content": "/* eslint-disable no-restricted-globals */\n// This service worker can be customized!\n// See https://developers.google.com/web/tools/workbox/modules\n// for the list of available Workbox modules, or add any other\n// code you'd like.\n// You can also remove this file if you'd prefer not to use a\n// service worker, and the Workbox build step will be skipped.\nimport { ExpirationPlugin } from \"workbox-expiration\";\nimport { precacheAndRoute, createHandlerBoundToURL } from \"workbox-precaching\";\nimport { registerRoute } from \"workbox-routing\";\nimport { StaleWhileRevalidate } from \"workbox-strategies\";\n\n// eslint-disable-next-line no-undef\ndeclare const self: ServiceWorkerGlobalScope;\n\n// Precache all of the assets generated by your build process.\n// Their URLs are injected into the manifest variable below.\n// This variable must be present somewhere in your service worker file,\n// even if you decide not to use precaching. See https://cra.link/PWA\nprecacheAndRoute(self.__WB_MANIFEST);\n\n// Set up App Shell-style routing, so that all navigation requests\n// are fulfilled with your index.html shell. Learn more at\n// https://developers.google.com/web/fundamentals/architecture/app-shell\nconst fileExtensionRegexp = new RegExp(\"/[^/?]+\\\\.[^/]+$\");\nregisterRoute(\n  // Return false to exempt requests from being fulfilled by index.html.\n  ({ request, url }: { request: Request; url: URL }) => {\n    // If this isn't a navigation, skip.\n    if (request.mode !== \"navigate\") {\n      return false;\n    }\n\n    // If this is a URL that starts with /_, skip.\n    if (url.pathname.startsWith(\"/_\")) {\n      return false;\n    }\n\n    // If this looks like a URL for a resource, because it contains\n    // a file extension, skip.\n    if (url.pathname.match(fileExtensionRegexp)) {\n      return false;\n    }\n\n    // Return true to signal that we want to use the handler.\n    return true;\n  },\n  createHandlerBoundToURL(process.env.PUBLIC_URL + \"/index.html\")\n);\n\n// An example runtime caching route for requests that aren't handled by the\n// precache, in this case same-origin .png requests like those from in public/\nregisterRoute(\n  // Add in any other file extensions or routing criteria as needed.\n  ({ url }) => url.origin === self.location.origin && url.pathname.endsWith(\".png\"),\n  // Customize this strategy as needed, e.g., by changing to CacheFirst.\n  new StaleWhileRevalidate({\n    cacheName: \"images\",\n    plugins: [\n      // Ensure that once this runtime cache reaches a maximum size the\n      // least-recently used images are removed.\n      new ExpirationPlugin({ maxEntries: 50 }),\n    ],\n  })\n);\n\n// This allows the web app to trigger skipWaiting via\n// registration.waiting.postMessage({type: 'SKIP_WAITING'})\nself.addEventListener(\"message\", (event) => {\n  if (event.data && event.data.type === \"SKIP_WAITING\") {\n    self.skipWaiting();\n  }\n});\nself.addEventListener(\"activate\", () => {\n  self.clients.claim();\n});\nself.addEventListener(\"install\", () => {\n  self.skipWaiting();\n});\n\n// Any other custom service worker logic can go here.\n"
  },
  {
    "path": "src/serviceWorkerRegistration.ts",
    "content": "/* eslint-disable no-eq-null */\n/* eslint-disable @typescript-eslint/no-use-before-define */\n// This optional code is used to register a service worker.\n// register() is not called by default.\n\n// This lets the app load faster on subsequent visits in production, and gives\n// it offline capabilities. However, it also means that developers (and users)\n// will only see deployed updates on subsequent visits to a page, after all the\n// existing tabs open on the page have been closed, since previously cached\n// resources are updated in the background.\n\n// To learn more about the benefits of this model and instructions on how to\n// opt-in, read https://cra.link/PWA\n\nconst isLocalhost = Boolean(\n  window.location.hostname === \"localhost\" ||\n    // [::1] is the IPv6 localhost address.\n    window.location.hostname === \"[::1]\" ||\n    // 127.0.0.0/8 are considered localhost for IPv4.\n    window.location.hostname.match(/^127(?:\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)\n);\n\ntype Config = {\n  onSuccess?: (registration: ServiceWorkerRegistration) => void;\n  onUpdate?: (registration: ServiceWorkerRegistration) => void;\n  onControllerChange?: (container: ServiceWorkerContainer) => void;\n};\n\nexport function register(config?: Config) {\n  if (process.env.NODE_ENV === \"production\" && \"serviceWorker\" in navigator) {\n    // The URL constructor is available in all browsers that support SW.\n    const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);\n    if (publicUrl.origin !== window.location.origin) {\n      // Our service worker won't work if PUBLIC_URL is on a different origin\n      // from what our page is served on. This might happen if a CDN is used to\n      // serve assets; see https://github.com/facebook/create-react-app/issues/2374\n      return;\n    }\n\n    window.addEventListener(\"load\", () => {\n      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;\n\n      if (isLocalhost) {\n        // This is running on localhost. Let's check if a service worker still exists or not.\n        checkValidServiceWorker(swUrl, config);\n\n        // Add some additional logging to localhost, pointing developers to the\n        // service worker/PWA documentation.\n        navigator.serviceWorker.ready.then(() => {\n          console.log(\n            \"This web app is being served cache-first by a service \" +\n              \"worker. To learn more, visit https://cra.link/PWA\"\n          );\n        });\n      } else {\n        // Is not localhost. Just register service worker\n        registerValidSW(swUrl, config);\n      }\n    });\n  }\n}\n\nfunction registerValidSW(swUrl: string, config?: Config) {\n  navigator.serviceWorker\n    .register(swUrl)\n    .then((registration) => {\n      if (navigator.serviceWorker.controller) {\n        navigator.serviceWorker.oncontrollerchange = () => {\n          if (config?.onControllerChange) {\n            config.onControllerChange(navigator.serviceWorker);\n          }\n        };\n      }\n      if (registration.waiting && registration.waiting !== registration.active) {\n        if (config?.onUpdate) {\n          config.onUpdate(registration);\n        }\n      }\n      registration.onupdatefound = () => {\n        const installingWorker = registration.installing;\n        if (installingWorker == null) {\n          return;\n        }\n        installingWorker.onstatechange = () => {\n          if (installingWorker.state === \"installed\") {\n            if (navigator.serviceWorker.controller) {\n              // At this point, the updated precached content has been fetched,\n              // but the previous service worker will still serve the older\n              // content until all client tabs are closed.\n              console.log(\n                \"New content is available and will be used when all \" +\n                  \"tabs for this page are closed. See https://cra.link/PWA.\"\n              );\n\n              // Execute callback\n              if (config && config.onUpdate) {\n                config.onUpdate(registration);\n              }\n            } else {\n              // At this point, everything has been precached.\n              // It's the perfect time to display a\n              // \"Content is cached for offline use.\" message.\n              console.log(\"Content is cached for offline use.\");\n\n              // Execute callback\n              if (config && config.onSuccess) {\n                config.onSuccess(registration);\n              }\n            }\n          }\n        };\n      };\n      if (\n        !localStorage.serviceWorkerLastManualUpdate ||\n        Date.now() - parseInt(localStorage.serviceWorkerLastManualUpdate, 10) > 1000 * 60 * 60 * 24\n      ) {\n        registration.update().catch(() => {/* Ignore */});\n        localStorage.serviceWorkerLastManualUpdate = Date.now().toString();\n      }\n    })\n    .catch((error) => {\n      console.error(\"Error during service worker registration:\", error);\n    });\n}\n\nfunction checkValidServiceWorker(swUrl: string, config?: Config) {\n  // Check if the service worker can be found. If it can't reload the page.\n  fetch(swUrl, {\n    headers: { \"Service-Worker\": \"script\" },\n  })\n    .then((response) => {\n      // Ensure service worker exists, and that we really are getting a JS file.\n      const contentType = response.headers.get(\"content-type\");\n      if (response.status === 404 || (contentType != null && contentType.indexOf(\"javascript\") === -1)) {\n        // No service worker found. Probably a different app. Reload the page.\n        navigator.serviceWorker.ready.then((registration) => {\n          registration.unregister().then(() => {\n            window.location.reload();\n          });\n        });\n      } else {\n        // Service worker found. Proceed as normal.\n        registerValidSW(swUrl, config);\n      }\n    })\n    .catch(() => {\n      console.log(\"No internet connection found. App is running in offline mode.\");\n    });\n}\n\nexport function unregister() {\n  if (\"serviceWorker\" in navigator) {\n    navigator.serviceWorker.ready\n      .then((registration) => {\n        registration.unregister();\n      })\n      .catch((error) => {\n        console.error(error.message);\n      });\n  }\n}\n"
  },
  {
    "path": "src/styles/styles.scss",
    "content": "@import \"~react-virtualized/styles\";\n\nbody {\n  font-family: \"Roboto\", \"Microsoft YaHei\", \"Meiryo\", sans-serif;\n  overflow-x: hidden;\n  overflow-y: auto;\n  padding-top: 50px;\n  background-size: cover;\n  background-position: center;\n  background-repeat: no-repeat;\n  background-image: var(--background-image);\n  background-attachment: fixed;\n  color-scheme: only light;\n}\n/* @media (min-width: 1025px) { */\n/* background-attachment breaks on iOS: https://caniuse.com/background-attachment */\nbody {\n  background: transparent;\n}\nbody::before {\n  content: \"\";\n  display: block;\n  pointer-events: none;\n  position: fixed;\n  top: 0;\n  left: 0;\n  width: 100vw;\n  bottom: 0;\n\n  background-size: cover;\n  background-position: center;\n  background-repeat: no-repeat;\n  z-index: -1;\n  background-image: var(--background-image);\n}\n/* } */\n.koromo {\n  --background-image: url(../assets/img/koromo.jpg);\n}\n.achiga {\n  --background-image: url(../assets/img/achiga.jpg);\n}\n.yuuki {\n  --background-image: url(../assets/img/yuuki.jpg);\n}\n#root {\n  overflow: hidden;\n}\n.text-right {\n  text-align: right;\n}\n.ReactVirtualized__Table__row.even {\n  background-color: rgba(0, 0, 0, 0.05);\n}\n.ReactVirtualized__Table__headerRow,\n.ReactVirtualized__Table__row {\n  border-top: 1px solid #dee2e6;\n}\n.ReactVirtualized__Table__headerRow span {\n  display: block;\n}\n@keyframes placeHolderShimmer {\n  0% {\n    background-position: -800px 0;\n  }\n  100% {\n    background-position: 800px 0;\n  }\n}\n.ReactVirtualized__Table__row.loading {\n  animation-duration: 3s;\n  animation-fill-mode: forwards;\n  animation-iteration-count: infinite;\n  animation-name: placeHolderShimmer;\n  animation-timing-function: linear;\n  background: #f6f7f8;\n  background: linear-gradient(to right, #eeeeee 8%, #dddddd 18%, #eeeeee 33%);\n  background-size: 800px 104px;\n}\n.ReactVirtualized__Table__row.loading:nth-of-type(odd) {\n  animation-delay: -0.75s;\n}\nsvg.recharts-surface {\n  overflow: visible;\n}\n.recharts-label,\n.recharts-label-list {\n  pointer-events: none;\n  user-select: none;\n}\n.recharts-pie-label-text {\n  font-weight: bold;\n  transform: translateY(5px);\n}\n.recharts-tooltip-wrapper {\n  z-index: 1;\n}\n.recharts-pie.selectable {\n  transition: transform 0.3s;\n  transform: scale(0.95);\n  transform-origin: center;\n}\n.recharts-pie.selectable.with-active,\n.recharts-pie.selectable:hover {\n  transform: scale(1);\n}\n.recharts-pie-sector .recharts-sector.selectable {\n  cursor: pointer;\n  transition: opacity 0.3s;\n}\n.recharts-pie.with-active .recharts-pie-sector .recharts-sector {\n  opacity: 0.1;\n}\n.recharts-pie.with-active .recharts-pie-sector .recharts-sector.active {\n  opacity: 1;\n}\n.recharts-pie-sector .recharts-sector.selectable:hover {\n  opacity: 0.8;\n}\n"
  },
  {
    "path": "src/utils/async.ts",
    "content": "import React, { useState, useEffect, useMemo } from \"react\";\nimport { networkError } from \"./notify\";\nimport Sentry from \"./sentry\";\n\ntype NotFinished = { notFinished: string };\nconst NOT_FINISHED = { notFinished: \"yes\" };\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst __useAsyncCache = {} as { [key: string]: any };\n\nexport function useAsync<T>(maybePromise: T | Promise<T>, cacheKey?: string): T | undefined {\n  const [fulfilledValue, setFulfilledValue] = useState<T | NotFinished>(\n    maybePromise instanceof Promise ? NOT_FINISHED : maybePromise\n  );\n  useEffect(() => {\n    let promise = maybePromise;\n    if (cacheKey) {\n      if (__useAsyncCache[cacheKey]) {\n        if (\n          promise instanceof Promise &&\n          __useAsyncCache[cacheKey] instanceof Promise &&\n          promise !== __useAsyncCache[cacheKey]\n        ) {\n          const e = new Error(`Replacing cached promise with new one (key: ${cacheKey})`);\n          console.error(e);\n          Sentry.captureException(e);\n        }\n        promise = __useAsyncCache[cacheKey];\n      } else {\n        __useAsyncCache[cacheKey] = promise;\n      }\n    }\n    let cancelled = false;\n    if (promise instanceof Promise) {\n      setFulfilledValue(NOT_FINISHED);\n      promise\n        .then((result) => {\n          if (cancelled) {\n            return;\n          }\n          if (cacheKey) {\n            __useAsyncCache[cacheKey] = result;\n          }\n          setFulfilledValue(result);\n        })\n        .catch((e) => {\n          console.error(e);\n          if (cacheKey && __useAsyncCache[cacheKey] === promise) {\n            delete __useAsyncCache[cacheKey];\n          }\n          networkError();\n        });\n    } else {\n      setFulfilledValue(promise);\n    }\n    return () => {\n      cancelled = true;\n    };\n  }, [maybePromise, cacheKey]);\n  if (fulfilledValue !== NOT_FINISHED) {\n    return fulfilledValue as T;\n  }\n  return undefined;\n}\nexport function useAsyncFactory<T>(\n  factory: () => Promise<T>,\n  deps: React.DependencyList,\n  cacheKey?: string\n): T | undefined {\n  const realKey = cacheKey ? `${cacheKey}-${deps.join(\",\")}` : undefined;\n  const promise = useMemo(() => {\n    if (realKey && __useAsyncCache[realKey]) {\n      return __useAsyncCache[realKey];\n    }\n    const ret = factory();\n    if (realKey) {\n      __useAsyncCache[realKey] = ret;\n    }\n    return ret;\n  }, deps); // eslint-disable-line react-hooks/exhaustive-deps\n  return useAsync(promise, realKey);\n}\n"
  },
  {
    "path": "src/utils/conf.ts",
    "content": "import { GameMode } from \"../data/types\";\nimport dayjs from \"dayjs\";\n\nconst domain =\n  sessionStorage.getItem(\"overrideDomain\") || localStorage.getItem(\"overrideDomain\") || window.location.hostname;\n\nexport const CONFIGURATIONS = {\n  DEFAULT: {\n    apiSuffix: process.env.NODE_ENV === \"development\" ? \"api-test/v2/pl4/\" : \"api/v2/pl4/\",\n    features: {\n      ranking: [GameMode.王座, GameMode.玉, GameMode.玉东] as GameMode[] | false,\n      statistics: true,\n      estimatedStableLevel: true,\n      contestTools: false,\n      statisticsSubPages: {\n        rankBySeat: true,\n        dataByRank: [GameMode.王座, GameMode.玉, GameMode.金, GameMode.王东, GameMode.玉东, GameMode.金东] as\n          | GameMode[]\n          | false,\n        fanStats: true,\n        numPlayerStats: true,\n      },\n      aiReview: true,\n    },\n    table: {\n      showGameMode: true,\n    },\n    availableModes: [GameMode.王座, GameMode.玉, GameMode.金, GameMode.王东, GameMode.玉东, GameMode.金东],\n    modePreference: [GameMode.王座, GameMode.玉, GameMode.王东, GameMode.玉东, GameMode.金, GameMode.金东],\n    dateMin: dayjs(\"2019-08-23\", \"YYYY-MM-DD\"),\n    siteTitle: \"雀魂牌谱屋\",\n    canonicalDomain: \"amae-koromo.sapk.ch\",\n    showTopNotice: true,\n    mirrorUrl: \"https://saki.sapk.ch/\",\n    rootClassName: \"koromo\",\n    rankColors: [\"#28a745\", \"#17a2b8\", \"#6c757d\", \"#dc3545\"],\n    maskedGameLink: true,\n  },\n  ikeda: {\n    apiSuffix: \"api/v2/pl3/\",\n    features: {\n      ranking: [GameMode.三王座, GameMode.三玉, GameMode.三金, GameMode.三王东, GameMode.三玉东, GameMode.三金东],\n      statistics: true,\n      estimatedStableLevel: true,\n      contestTools: false,\n      statisticsSubPages: {\n        rankBySeat: true,\n        dataByRank: [GameMode.三王座, GameMode.三玉, GameMode.三金, GameMode.三王东, GameMode.三玉东, GameMode.三金东],\n        fanStats: true,\n        numPlayerStats: true,\n      },\n      aiReview: false,\n    },\n    availableModes: [GameMode.三王座, GameMode.三玉, GameMode.三金, GameMode.三王东, GameMode.三玉东, GameMode.三金东],\n    modePreference: [GameMode.三王座, GameMode.三玉, GameMode.三王东, GameMode.三玉东, GameMode.三金, GameMode.三金东],\n    dateMin: dayjs(\"2019-11-29\", \"YYYY-MM-DD\"),\n    siteTitle: \"雀魂牌谱屋·三麻\",\n    canonicalDomain: \"ikeda.sapk.ch\",\n    mirrorUrl: \"https://momoko.sapk.ch/\",\n    rankColors: [\"#28a745\", \"#6c757d\", \"#dc3545\"],\n    rootClassName: \"yuuki\",\n  },\n  contest: {\n    apiSuffix: (s: string) => `api/contest/${s}/`,\n    features: {\n      ranking: false as const,\n      rankingGroups: null,\n      statistics: true,\n      estimatedStableLevel: false,\n      contestTools: true,\n      statisticsSubPages: {\n        rankBySeat: true,\n        dataByRank: false as const,\n        fanStats: true,\n        numPlayerStats: false,\n      },\n      aiReview: false,\n    },\n    table: {\n      showGameMode: true,\n    },\n    availableModes: [],\n    canonicalDomain: domain,\n    showTopNotice: false,\n    maskedGameLink: false,\n  },\n};\n\ntype Configuration = typeof CONFIGURATIONS.DEFAULT;\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction mergeDeep<T extends { [key: string]: any }>(...objects: Partial<T>[]): T {\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  const isObject = <T>(obj: T) => obj && typeof obj === \"object\" && (obj as any).constructor === Object;\n\n  return objects.reduce((prev: T, obj: Partial<T>) => {\n    Object.keys(obj).forEach((key: keyof T) => {\n      const pVal = prev[key];\n      const oVal = obj[key];\n\n      if (Array.isArray(pVal) && Array.isArray(oVal)) {\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        prev[key] = oVal as any;\n      } else if (isObject(pVal) && isObject(oVal)) {\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        prev[key] = mergeDeep(pVal, oVal as any);\n      } else {\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        prev[key] = oVal as any;\n      }\n    });\n\n    return prev;\n  }, {} as T) as T;\n}\n\nconst ConfBase: Partial<Configuration> = (() => {\n  if (/^(ikeda|momoko)\\./i.test(domain)) {\n    return CONFIGURATIONS.ikeda;\n  }\n  const m = /^([^.]+)\\.contest\\./i.exec(domain);\n  if (m) {\n    return { ...CONFIGURATIONS.contest, apiSuffix: CONFIGURATIONS.contest.apiSuffix(m[1]) };\n  }\n  return CONFIGURATIONS.DEFAULT;\n})();\n\nconst Conf = mergeDeep<Configuration>(CONFIGURATIONS.DEFAULT, ConfBase);\n\ndocument.documentElement.className += \" \" + Conf.rootClassName;\n\nexport function canTrackUser() {\n  return window.location.host === Conf.canonicalDomain;\n}\n\nexport default Conf;\n"
  },
  {
    "path": "src/utils/index.ts",
    "content": "import { useTheme } from \"@mui/material\";\nimport useMediaQuery from \"@mui/material/useMediaQuery\";\nimport React, { useEffect, useRef, useCallback } from \"react\";\n\nexport function triggerRelayout() {\n  requestAnimationFrame(() => window.dispatchEvent(new UIEvent(\"resize\")));\n  setTimeout(function () {\n    window.dispatchEvent(new UIEvent(\"resize\"));\n  }, 200);\n}\nexport function scrollToTop() {\n  window.scrollTo(0, 0);\n  requestAnimationFrame(() => window.scrollTo(0, 0));\n}\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport const formatPercent = (x: any) => {\n  if (!x) {\n    return \"0%\";\n  }\n  if (x < 0.0001) {\n    return \"<0.01%\";\n  }\n  return `${(x * 100).toFixed(2)}%`;\n};\n\nexport const formatFixed3 = (x: number) => x.toFixed(3);\nexport const formatRound = (x: number) => Math.round(x).toString();\nexport const formatIdentity = (x: number) => x.toString();\n\nexport function useEventCallback<T extends unknown[]>(fn: (...args: T) => void, dependencies: React.DependencyList) {\n  const ref = useRef<(...args: T) => void>(() => {\n    throw new Error(\"Cannot call an event handler while rendering.\");\n  });\n\n  useEffect(() => {\n    ref.current = fn;\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [fn, ...dependencies]);\n\n  return useCallback(\n    (...args) => {\n      const fn = ref.current;\n      return fn(...(args as T));\n    },\n    [ref]\n  );\n}\n\nexport function sum(numbers: number[]): number {\n  return numbers.reduce((a, b) => a + b, 0);\n}\n\nexport function useIsMobile() {\n  const theme = useTheme();\n  const matches = useMediaQuery(theme.breakpoints.up(\"sm\"));\n  return !matches;\n}\n"
  },
  {
    "path": "src/utils/notify.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-empty-function */\nimport { Close } from \"@mui/icons-material\";\nimport { IconButton } from \"@mui/material\";\nimport { useSnackbar, SnackbarMessage, OptionsObject, SnackbarKey } from \"notistack\";\nimport { useEffect } from \"react\";\n\nimport i18n from \"../i18n\";\n\nlet _enqueueSnackbar: (message: SnackbarMessage, options?: OptionsObject) => SnackbarKey = () => \"\";\nlet _closeSnackbar: (key: SnackbarKey) => void = () => {};\n\nexport function RegisterSnackbarProvider() {\n  const { enqueueSnackbar, closeSnackbar } = useSnackbar();\n  useEffect(() => {\n    _enqueueSnackbar = enqueueSnackbar;\n    _closeSnackbar = closeSnackbar;\n    return () => {\n      _enqueueSnackbar = () => \"\";\n      _closeSnackbar = () => {};\n    };\n  }, [enqueueSnackbar, closeSnackbar]);\n  return <></>;\n}\n\nexport function error(message: string, options: Partial<OptionsObject> = {}) {\n  return _enqueueSnackbar(message, {\n    variant: \"error\",\n    action: (key) => (\n      <IconButton\n        color=\"inherit\"\n        onClick={() => {\n          _closeSnackbar(key);\n        }}\n      >\n        <Close />\n      </IconButton>\n    ),\n    ...options,\n  });\n}\n\nlet networkErrorActive = \"\" as SnackbarKey;\n\nexport function networkError() {\n  if (networkErrorActive) {\n    return networkErrorActive;\n  }\n  networkErrorActive = error(i18n.t(\"加载数据失败\"), { onClose: () => (networkErrorActive = \"\") });\n  return networkErrorActive;\n}\n"
  },
  {
    "path": "src/utils/polyfill.ts",
    "content": "require(\"react-app-polyfill/ie9\");\nrequire(\"react-app-polyfill/stable\");\n\nexport default {};\n"
  },
  {
    "path": "src/utils/preference.ts",
    "content": "import Conf from \"./conf\";\n\nexport function savePlayerPreference(key: string, id: string, value: unknown) {\n  try {\n    localStorage.setItem(`${key}${Conf.canonicalDomain}${id}`, JSON.stringify(value));\n  } catch (e) {\n    // Incognito mode, ignore\n  }\n}\n\nexport function loadPlayerPreference<T>(key: string, id: string, defaultValue: T): T {\n  try {\n    return JSON.parse(localStorage.getItem(`${key}${Conf.canonicalDomain}${id}`) || \"\") ?? defaultValue;\n  } catch (e) {\n    return defaultValue;\n  }\n}\n\nexport function loadPreference<T>(key: string, defaultValue: T): T {\n  return loadPlayerPreference(key, \"GLOBAL\", defaultValue);\n}\n\nexport function savePreference(key: string, value: unknown) {\n  savePlayerPreference(key, \"GLOBAL\", value);\n}\n"
  },
  {
    "path": "src/utils/sentry.ts",
    "content": "import * as Sentry from \"@sentry/react\";\nimport { v4 as uuidv4 } from \"uuid\";\n\nif (process.env.REACT_APP_SENTRY_DSN) {\n  const ignoredFunctions = new Set([\n    \"is_mark_able_element\",\n    \"findParentClickTag\",\n    \"close_cache_key\",\n    \"check_swipe_element\",\n    \"eval\",\n  ]);\n  Sentry.init({\n    dsn: process.env.REACT_APP_SENTRY_DSN,\n    release: process.env.REACT_APP_RELEASE || \"unknown\",\n    ignoreErrors: [\n      \"this.hostIndex.push is not a function\",\n      \"undefined is not an object (evaluating 't.uv')\",\n      \"SyntaxError: The string did not match the expected pattern.\",\n      \"instantSearchSDKJSBridgeClearHighlight\",\n      \"window.bannerNight\",\n      \"window.ucbrowser\",\n      \"webkitExitFullScreen\",\n      \"close_cache_key\",\n      \"UCShellJava\",\n      \"file:///\",\n      \"hw-upgrade-client\",\n      \"is_mark_able_element\",\n      \"QK_middlewareReadModePageDetect\",\n      \"window.webkit.messageHandlers\",\n      \"Timeout to initialize runtime\",\n      \"this.excludedTags.length\",\n    ],\n    denyUrls: [/^chrome-extension:\\/\\//i, /^moz-extension:\\/\\//i, /^safari-extension:\\/\\//i, /^file:\\/\\//i],\n    autoSessionTracking: true,\n    beforeSend: (event, hint) => {\n      if (event?.exception?.values?.[0]?.stacktrace?.frames?.some((x) => ignoredFunctions.has(x?.function || \"\"))) {\n        return null;\n      }\n      if (\n        hint?.originalException &&\n        typeof hint.originalException !== \"string\" &&\n        /Loading chunk \\d+ failed after \\d+ retries/.test(hint.originalException.message)\n      ) {\n        event.fingerprint = [\"ChunkLoadError\"];\n      }\n      return event;\n    },\n  });\n  let sentryUserId;\n  try {\n    sentryUserId = localStorage.getItem(\"sentryUserId\") || sessionStorage.getItem(\"sentryUserId\");\n    if (!sentryUserId) {\n      sentryUserId = uuidv4();\n      sessionStorage.setItem(\"sentryUserId\", sentryUserId);\n      localStorage.setItem(\"sentryUserId\", sentryUserId);\n    }\n  } catch (e) {\n    // Ignore\n  }\n  if (sentryUserId) {\n    Sentry.setUser({ id: sentryUserId });\n  }\n}\n\nexport const SentryErrorBoundary = Sentry.ErrorBoundary;\n\nexport default Sentry;"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"include\": [\"./src/**/*\"],\n  \"compilerOptions\": {\n    \"allowSyntheticDefaultImports\": true,\n    \"useUnknownInCatchVariables\": false,\n    \"moduleResolution\": \"node\",\n    \"target\": \"es2017\",\n    \"strict\": true,\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"module\": \"esnext\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    \"lib\": [\"dom\", \"es2015\", \"es2017\", \"DOM\", \"webworker\"],\n    \"noFallthroughCasesInSwitch\": true\n  }\n}\n"
  },
  {
    "path": "vercel.json",
    "content": "{\n  \"version\": 2,\n  \"builds\": [{ \"src\": \"package.json\", \"use\": \"@vercel/static-build\", \"config\": { \"distDir\": \"build\" } }],\n  \"routes\": [\n    {\n      \"src\": \"/_.*\",\n      \"status\": 404\n    },\n    {\n      \"src\": \"/(static|favicon2)/.*\",\n      \"headers\": { \"Cache-Control\": \"public, immutable, max-age=604800, s-maxage=604800\" },\n      \"continue\": true\n    },\n    {\n      \"src\": \"/(.*)\",\n      \"headers\": {\n        \"Content-Security-Policy-Report-Only\": \"default-src * data:; script-src 'report-sample' 'self' https://*.sapk.ch https://www.google-analytics.com https://www.googletagmanager.com https://*.statuspage.io; script-src-elem 'report-sample' 'self' https://*.sapk.ch https://www.google-analytics.com https://www.googletagmanager.com https://*.statuspage.io; style-src * 'unsafe-inline' 'report-sample'; style-src-elem * 'unsafe-inline' 'report-sample'; style-src-attr * 'unsafe-inline' 'report-sample'; report-uri https://sentry.sapikachu.net/api/31/security/?sentry_key=876acfa224b8425c92f9553b9c6676be\"\n      },\n      \"continue\": true\n    },\n    {\n      \"handle\": \"filesystem\"\n    },\n    {\n      \"src\": \"/(static|favicon2)/.*\",\n      \"status\": 404\n    },\n    {\n      \"src\": \"/(.*)\",\n      \"dest\": \"/index.html\"\n    }\n  ]\n}\n"
  }
]