Full Code of SAPikachu/amae-koromo for AI

master a0c388da44bf cached
112 files
346.8 KB
102.6k tokens
428 symbols
1 requests
Download .txt
Showing preview only (400K chars total). Download the full file or copy to clipboard to get everything.
Repository: SAPikachu/amae-koromo
Branch: master
Commit: a0c388da44bf
Files: 112
Total size: 346.8 KB

Directory structure:
gitextract_1yoa_b0g/

├── .eslintrc.json
├── .gitignore
├── .nvmrc
├── .rescriptsrc.js
├── LICENSE
├── package.json
├── public/
│   ├── CNAME
│   ├── _redirects
│   ├── favicon2/
│   │   └── manifest.json
│   ├── index.html
│   └── robots.txt
├── src/
│   ├── bootstrap.tsx
│   ├── components/
│   │   ├── app/
│   │   │   ├── appHeader.tsx
│   │   │   ├── index.tsx
│   │   │   ├── maintenance.tsx
│   │   │   ├── navbar.tsx
│   │   │   ├── routes.tsx
│   │   │   └── theme.tsx
│   │   ├── charts/
│   │   │   └── simplePieChart.tsx
│   │   ├── contestTools/
│   │   │   ├── index.tsx
│   │   │   └── minMax.tsx
│   │   ├── form/
│   │   │   ├── checkboxGroup.tsx
│   │   │   ├── datePicker.tsx
│   │   │   └── index.tsx
│   │   ├── gameRecords/
│   │   │   ├── columns.tsx
│   │   │   ├── dataAdapterProvider.tsx
│   │   │   ├── extraFilterPredicate.tsx
│   │   │   ├── filterPanel.tsx
│   │   │   ├── gameLinkActions/
│   │   │   │   ├── dialog.tsx
│   │   │   │   └── index.tsx
│   │   │   ├── home.tsx
│   │   │   ├── index.tsx
│   │   │   ├── modeSelector.tsx
│   │   │   ├── model.tsx
│   │   │   ├── player.tsx
│   │   │   ├── playerSearch.tsx
│   │   │   ├── routeSync.tsx
│   │   │   ├── routeUtils.tsx
│   │   │   ├── routes.tsx
│   │   │   ├── table.tsx
│   │   │   └── tableViews.tsx
│   │   ├── layout/
│   │   │   ├── container.tsx
│   │   │   └── index.tsx
│   │   ├── misc/
│   │   │   ├── alert.tsx
│   │   │   ├── canonicalLink.tsx
│   │   │   ├── customizedLoadable.tsx
│   │   │   ├── linkBehavior.tsx
│   │   │   ├── loading.tsx
│   │   │   ├── menuButton.tsx
│   │   │   ├── navButton.tsx
│   │   │   ├── scroller.tsx
│   │   │   └── tracker.tsx
│   │   ├── modeModel/
│   │   │   ├── index.tsx
│   │   │   ├── model.tsx
│   │   │   └── modelModeSelector.tsx
│   │   ├── playerDetails/
│   │   │   ├── charts/
│   │   │   │   ├── rankRate.tsx
│   │   │   │   ├── recentRank.tsx
│   │   │   │   └── winLoseDistribution.tsx
│   │   │   ├── dateRangeSetting.tsx
│   │   │   ├── estimatedStableLevel.tsx
│   │   │   ├── extraSettings.tsx
│   │   │   ├── histogram.tsx
│   │   │   ├── playerDetails.tsx
│   │   │   ├── playerDetailsSettings.tsx
│   │   │   ├── sameMatchRate.tsx
│   │   │   ├── star/
│   │   │   │   ├── starButton.tsx
│   │   │   │   ├── starPlayerProvider.tsx
│   │   │   │   └── starredPlayerMenu.tsx
│   │   │   └── statItem.tsx
│   │   ├── ranking/
│   │   │   ├── careerRanking.tsx
│   │   │   ├── deltaRanking.tsx
│   │   │   └── index.tsx
│   │   ├── recentHighlight/
│   │   │   └── index.tsx
│   │   ├── routing/
│   │   │   ├── index.tsx
│   │   │   └── subView.tsx
│   │   └── statistics/
│   │       ├── dataByRank.tsx
│   │       ├── fanStats.tsx
│   │       ├── index.tsx
│   │       ├── numPlayerStats.tsx
│   │       └── rankBySeats.tsx
│   ├── data/
│   │   ├── source/
│   │   │   ├── api.ts
│   │   │   ├── misc.ts
│   │   │   └── records/
│   │   │       ├── loader.ts
│   │   │       └── provider.ts
│   │   └── types/
│   │       ├── constants.ts
│   │       ├── gameMode.ts
│   │       ├── index.ts
│   │       ├── level.ts
│   │       ├── metadata.ts
│   │       ├── ranking.ts
│   │       ├── record.ts
│   │       ├── statistics.ts
│   │       ├── utils.ts
│   │       └── zone.ts
│   ├── i18n.ts
│   ├── index.tsx
│   ├── locales/
│   │   ├── en.json
│   │   ├── ja.json
│   │   └── ko.json
│   ├── react-app-env.d.ts
│   ├── service-worker.ts
│   ├── serviceWorkerRegistration.ts
│   ├── styles/
│   │   └── styles.scss
│   └── utils/
│       ├── async.ts
│       ├── conf.ts
│       ├── index.ts
│       ├── notify.tsx
│       ├── polyfill.ts
│       ├── preference.ts
│       └── sentry.ts
├── tsconfig.json
└── vercel.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .eslintrc.json
================================================
{
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "typescript": true,
    "ecmaVersion": 8,
    "sourceType": "module",
    "ecmaFeatures": {
      "impliedStrict": true,
      "jsx": true
    }
  },
  "ignorePatterns": ["node_modules/**", "build"],
  "env": {
    "es6": true,
    "node": true,
    "browser": true
  },
  "settings": {
    "version": "detect",
    "react": {
      "version": "detect"
    }
  },
  "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended"],
  "plugins": ["@typescript-eslint", "react", "react-hooks"],
  "rules": {
    "@typescript-eslint/explicit-function-return-type": ["off"],
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn",
    "react/prop-types": ["off"],
    "react/display-name": ["off"],
    "react/react-in-jsx-scope": "off",
    "curly": 2,
    "eqeqeq": [2, "smart"],
    "no-unused-expressions": "error",
    "no-labels": 2,
    "no-console": 0,
    "no-eq-null": 2,
    "no-eval": 2,
    "no-fallthrough": 2,
    "no-octal-escape": 2,
    "no-octal": 2,
    "no-redeclare": "off",
    "@typescript-eslint/no-redeclare": ["error", { "ignoreDeclarationMerge": true }],
    "no-with": 2,
    "no-catch-shadow": 2,
    "no-undef": 2,
    "no-use-before-define": "off",
    "@typescript-eslint/no-use-before-define": ["error"],
    "@typescript-eslint/explicit-module-boundary-types": "off",
    "brace-style": [1, "1tbs", { "allowSingleLine": true }],
    "comma-spacing": [2, { "after": true }],
    "comma-style": [2, "last"],
    "comma-dangle": 0,
    "computed-property-spacing": [2, "never"],
    "indent": "off",
    "@typescript-eslint/indent": "off",
    "key-spacing": [2, { "afterColon": true }],
    "no-mixed-spaces-and-tabs": 2,
    "no-trailing-spaces": 2,
    "quotes": [2, "double", "avoid-escape"],
    "semi": [2, "always"],
    "strict": [2, "global"],
    "keyword-spacing": 2,
    "no-var": 2,
    "object-shorthand": [2, "always"],
    "prefer-const": 1,
    "prefer-spread": 2,
    "require-yield": 2
  }
}


================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# production
/build

# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local

npm-debug.log*
yarn-debug.log*
yarn-error.log*
.vscode
.eslintcache


================================================
FILE: .nvmrc
================================================
20


================================================
FILE: .rescriptsrc.js
================================================
const { prependWebpackPlugin, getPaths, edit } = require("@rescripts/utilities");
const { RetryChunkLoadPlugin } = require("webpack-retry-chunk-load-plugin");
const isBabelLoader = (inQuestion) => inQuestion && inQuestion.loader && inQuestion.loader.includes("babel-loader");

if (process.env.NODE_ENV === "production") {
  const dayjs = require("dayjs");
  dayjs.extend(require("dayjs/plugin/utc"));
  const timestamp = dayjs.utc().format("YYYYMMDDHHmm");
  process.env.SENTRY_RELEASE = `${timestamp}-${(process.env.COMMIT_REF || "unknown").slice(0, 7)}`;
} else {
  process.env.SENTRY_RELEASE = "devel";
}
process.env.REACT_APP_RELEASE = process.env.SENTRY_RELEASE || process.env.REACT_APP_RELEASE || "";
process.env.REACT_APP_SENTRY_DSN = process.env.SENTRY_DSN || process.env.REACT_APP_SENTRY_DSN || "";

module.exports = [
  process.env.NODE_ENV === "production" && process.env.SENTRY_AUTH_TOKEN
    ? (config) =>
        prependWebpackPlugin(
          new (require("@sentry/webpack-plugin"))({
            validate: true,
            include: "build",
            ext: ["js", "jsx", "ts", "tsx", "map", "jsbundle", "bundle"],
            release: process.env.REACT_APP_RELEASE,
            ...(process.env.SENTRY_URL ? { url: process.env.SENTRY_URL } : {}),
            setCommits: {
              auto: true,
              ignoreMissing: true,
            },
          }),
          config
        )
    : (x) => x,
  process.env.RUN_ANALYZER
    ? (config) => prependWebpackPlugin(new (require("webpack-bundle-analyzer").BundleAnalyzerPlugin)(), config)
    : (x) => x,
  process.env.NODE_ENV !== "production"
    ? (config) => {
        const babelLoaderPaths = getPaths(isBabelLoader, config);
        return edit(
          (section) => {
            if (section.test.toString().includes("tsx")) {
              section.options.plugins.unshift([
                "babel-plugin-direct-import",
                { modules: ["@mui/material", "@mui/icons-material", "@mui/lab"] },
              ]);
            }
            return section;
          },
          babelLoaderPaths,
          config
        );
      }
    : (config) => config,
  process.env.NODE_ENV === "production"
    ? (config) =>
        prependWebpackPlugin(
          new RetryChunkLoadPlugin({
            cacheBust: function () {
              if ("serviceWorker" in navigator) {
                navigator.serviceWorker.ready
                  .then(function (registration) {
                    return registration.unregister();
                  })
                  .catch(function () {});
              }
              return Date.now();
            }.toString(),
            maxRetries: 5,
            retryDelay: 100,
            lastResortScript: `(${function () {
              if ("serviceWorker" in navigator) {
                navigator.serviceWorker.ready
                  .then(function (registration) {
                    return registration.unregister();
                  })
                  .then(function () {
                    window.location.href = "?t=" + Date.now();
                  })
                  .catch(function (error) {
                    console.error(error.message);
                    window.location.href = "?t=" + Date.now();
                  });
              } else {
                window.location.href = "?t=" + Date.now();
              }
            }.toString()})()`,
          }),
          config
        )
    : (x) => x,
];
// vim: sts=2:sw=2:ts=2:expandtab


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2020 SAPikachu

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: package.json
================================================
{
  "name": "amae-koromo",
  "version": "1.0.0",
  "homepage": "https://amae-koromo.sapk.ch",
  "description": "",
  "keywords": [],
  "main": "src/index.tsx",
  "engines": {
    "node": ">=14.0.0",
    "npm": ">=8.0.0"
  },
  "dependencies": {
    "@emotion/react": "^11.11.4",
    "@emotion/styled": "^11.11.5",
    "@fontsource/roboto": "^4.5.8",
    "@mui/icons-material": "^5.15.16",
    "@mui/lab": "5.0.0-alpha.65",
    "@mui/material": "^5.15.16",
    "@sentry/react": "^6.17.4",
    "clsx": "^1.1.1",
    "copy-to-clipboard": "^3.3.3",
    "dayjs": "^1.11.11",
    "i18next": "^19.9.2",
    "i18next-browser-languagedetector": "^6.1.8",
    "notistack": "^2.0.8",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-helmet": "^6.1.0",
    "react-i18next": "^11.8.3",
    "react-router-dom": "5.3.0",
    "react-scripts": "^5.0.1",
    "react-virtualized": "9.22.2",
    "recharts": "^2.1.9",
    "uuid": "^8.3.2"
  },
  "devDependencies": {
    "@pmmmwh/react-refresh-webpack-plugin": "^0.5.13",
    "@rescripts/cli": "git+https://github.com/SAPikachu/rescripts.git#cli",
    "@rescripts/utilities": "git+https://github.com/SAPikachu/rescripts.git#utilities",
    "@sentry/webpack-plugin": "^1.18.5",
    "@types/google.analytics": "0.0.41",
    "@types/history": "^5.0.0",
    "@types/lodash": "^4.14.178",
    "@types/react": "17.0.0",
    "@types/react-dom": "17.0.0",
    "@types/react-helmet": "^6.1.0",
    "@types/react-loadable": "^5.5.4",
    "@types/react-router-dom": "5.3.3",
    "@types/react-virtualized": "9.21.10",
    "@types/uuid": "^8.3.4",
    "@typescript-eslint/eslint-plugin": "^5.62.0",
    "@typescript-eslint/parser": "^5.62.0",
    "babel-plugin-direct-import": "^0.9.2",
    "prettier": "^2.8.8",
    "sass": "^1.76.0",
    "typescript": "^4.9.5",
    "webpack-bundle-analyzer": "^4.5.0",
    "webpack-retry-chunk-load-plugin": "^3.0.0"
  },
  "overrides": {
    "@rescripts/utilities": "git+https://github.com/SAPikachu/rescripts.git#utilities",
    "react-virtualized": {
      "react": "^17.0.2",
      "react-dom": "^17.0.2"
    }
  },
  "scripts": {
    "analyze": "RUN_ANALYZER=1 npm run build",
    "start": "unset BROWSER; rescripts start",
    "build": "rescripts build && cp build/index.html build/404.html",
    "test": "rescripts test",
    "eject": "react-scripts eject"
  },
  "browserslist": [
    ">0.2%",
    "not dead",
    "not ie <= 11",
    "not op_mini all"
  ]
}


================================================
FILE: public/CNAME
================================================
amae-koromo.sapk.ch

================================================
FILE: public/_redirects
================================================
/*    /index.html   200

================================================
FILE: public/favicon2/manifest.json
================================================
{
  "name": "amae-koromo",
  "short_name": "amae-koromo",
  "icons": [
    { "src": "/favicon2/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/favicon2/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
  ],
  "theme_color": "#ffffff",
  "background_color": "#ffffff",
  "display": "standalone"
}


================================================
FILE: public/index.html
================================================
<!DOCTYPE html>
<html dir="ltr">
  <head>
    <meta charset="utf-8" />
    <meta name="google" content="notranslate">
    <meta name="referrer" content="no-referrer" />
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
    <meta name="theme-color" content="#000000" />
    <!--
      manifest.json provides metadata used when your web app is added to the
      homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
    -->
    <link rel="manifest" href="%PUBLIC_URL%/favicon2/manifest.json" />
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon2/favicon.ico" />
    <link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/favicon2/apple-touch-icon.png" />
    <link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon2/favicon-32x32.png" />
    <link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon2/favicon-16x16.png" />
    <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.

      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
    <title>雀魂牌谱屋</title>
    <style type="text/css">
      .page-loading {
        display: flex;
        height: 100vh;
        align-items: center;
        justify-content: center;
      }
      @-webkit-keyframes spinner-border {
        to {
          -webkit-transform: rotate(360deg);
          transform: rotate(360deg);
        }
      }
      @keyframes spinner-border {
        to {
          -webkit-transform: rotate(360deg);
          transform: rotate(360deg);
        }
      }
      .spinner-border {
        display: inline-block;
        width: 2rem;
        height: 2rem;
        vertical-align: text-bottom;
        border: 0.25em solid currentColor;
        border-right-color: transparent;
        border-radius: 50%;
        -webkit-animation: spinner-border 0.75s linear infinite;
        animation: spinner-border 0.75s linear infinite;
      }
    </style>
    <meta name="google-site-verification" content="Pl474Y0s7H_p4UmS8cGPVL6jG9fXWsUtwj7ePgobt7Q" />
    <link rel="canonical" href="https://amae-koromo.sapk.ch/" data-react-helmet="true" />
  </head>

  <body>
    <noscript> You need to enable JavaScript to run this app. </noscript>
    <div id="root">
      <div class="d-flex justify-content-center page-loading">
        <div class="spinner-border" role="status" aria-label="Loading"></div>
      </div>
    </div>
    <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.

      You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.

      To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
  </body>
</html>


================================================
FILE: public/robots.txt
================================================
User-Agent: *
Disallow: /player/


================================================
FILE: src/bootstrap.tsx
================================================
import { render } from "react-dom";

import * as serviceWorker from "./serviceWorkerRegistration";
import "./i18n";

import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/400-italic.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";

import "./styles/styles.scss";

import App from "./components/app";

import { Suspense } from "react";
import Loading from "./components/misc/loading";
import { SentryErrorBoundary } from "./utils/sentry";

import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import Conf from "./utils/conf";
dayjs.extend(utc);

if (location.host === "amae-koromo.vercel.app") {
  location.href = "https://" + Conf.canonicalDomain;
}

const rootElement = document.getElementById("root");
render(
  <SentryErrorBoundary>
    <Suspense fallback={<Loading />}>
      <App />
    </Suspense>
  </SentryErrorBoundary>,
  rootElement
);

serviceWorker.register({
  onControllerChange() {
    window.location.reload();
  },
  onUpdate(registration) {
    const waitingServiceWorker = registration.waiting || navigator.serviceWorker.controller;

    if (waitingServiceWorker) {
      if (waitingServiceWorker.state === "activated" || waitingServiceWorker.state === "activating") {
        window.location.reload();
        return;
      }
      waitingServiceWorker.addEventListener("statechange", (event) => {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const state = (event.target as any)?.state;
        if (state === "activated" || state === "activating") {
          window.location.reload();
        }
      });
      waitingServiceWorker.postMessage({ type: "SKIP_WAITING" });
      window.location.reload();
    }
  },
});

const statusPageScript = document.createElement("script");
statusPageScript.async = true;
statusPageScript.src = "https://qltr0c2md09b.statuspage.io/embed/script.js";
document.body.appendChild(statusPageScript);


================================================
FILE: src/components/app/appHeader.tsx
================================================
import React from "react";

import { Container } from "../layout";
import { Alert } from "../misc/alert";
import Conf from "../../utils/conf";
import { useTranslation } from "react-i18next";
import { AlertTitle, styled } from "@mui/material";

const StyledUl = styled("ul")(({ theme }) => ({
  margin: "1rem -2rem 1rem 0",
  padding: 0,

  [theme.breakpoints.down("md")]: {
    margin: "1rem -3rem 1rem -1rem",
  },
}));

function AlertDefault() {
  return (
    <>
      <AlertTitle>说明</AlertTitle>
      <StyledUl>
        <li>本页面数据由第三方维护,不能绝对保证完整和正确,信息仅供参考,请勿用于不良用途。</li>
        <li>记录包含雀魂段位战金之间、玉之间及王座之间的牌谱。</li>
        <li>页面不是实时更新,对局一般会在结束后数分钟至数小时内出现。</li>
        <li>对局数据收集从 2019 年 11 月 29 日开始(玉南及王座南为 2019 年 8 月 23 日),之前的对局已无法获取。</li>
        <li>
          网站主线路会收集少量匿名浏览数据作后续改进及优化之用,如不希望被收集数据,请使用
          <a href={Conf.mirrorUrl}>镜像线路</a>。
        </li>
        <li>
          如有问题或建议,请戳 <a href="mailto:i@sapika.ch">SAPikachu (i@sapika.ch)</a> 或{" "}
          <a href="https://github.com/SAPikachu/amae-koromo/">提交 Issue</a>。
        </li>
        <li>
          感谢 <a href="https://twitter.com/EDWARDH_Jantama">EDWARDH</a> 提供新服务器。
        </li>
        <li>
          感谢 <a href="https://github.com/kamicloud/">Kamicloud</a> 提供部分数据。
        </li>
        <li>
          友情链接: <a href="https://000.mk">线上团体赛网站【大凤林】</a>{" "}
          <a href="https://rate.r-mj.com">线下段位场【雀庄公式战】</a>{" "}
          <a href="https://r-mj.com/">麻将地图【雀士远征踢馆指南】</a>
        </li>
      </StyledUl>
    </>
  );
}

function AlertEn() {
  return (
    <>
      <AlertTitle>Notes</AlertTitle>
      <StyledUl>
        <li>
          This is a fan site, data accuracy can&apos;t be fully guaranteed, please use the data for reference only and
          don&apos;t use it for malicious purpose.
        </li>
        <li>
          Data is not updated in real-time, finished matches will show up on the site in a few minutes to a few hours.
        </li>
        <li>
          Data collection was started from 2019-11-29 (2019-08-23 for Jade South and Throne South matches), matches
          finished before then could no longer be retrived.
        </li>
        <li>
          Main mirror of the site collects small amount of anonymous usage data for improving the site. If you wish to
          opt-out from this, please use <a href={Conf.mirrorUrl}>the alternative mirror</a>.
        </li>
        <li>
          If you have any question or suggestion, feel free to email{" "}
          <a href="mailto:i@sapika.ch">SAPikachu (i@sapika.ch)</a> or{" "}
          <a href="https://github.com/SAPikachu/amae-koromo/">submit an issue</a>.
        </li>
        <li>
          English translation of the site is contributed by <a href="https://github.com/Mjonir">Mjonir</a> and{" "}
          <a href="https://github.com/kator-278">kator-278</a>. Thank you!
        </li>
        <li>
          Thanks <a href="https://twitter.com/EDWARDH_Jantama">EDWARDH</a> for providing new server.
        </li>
        <li>
          Thanks <a href="https://github.com/kamicloud/">Kamicloud</a> for providing some missing data.
        </li>
      </StyledUl>
    </>
  );
}

function AlertJa() {
  return (
    <>
      <AlertTitle>説明</AlertTitle>
      <StyledUl>
        <li>
          当サイトは非公式サイトで、データの完全性と正確性が保証できません、予めご了承ください。サイトの内容を悪用しないでください。
        </li>
        <li>データの更新はリアルタイムではありません。対局がサイトに載るまで数分から数時間がかかります。</li>
        <li>
          データの収集は 2019 年 11 月 29 日から(玉南と王座南は 2019 年 8 月 23
          日)です。収集開始以前の対局は検索できません。
        </li>
        <li>
          <a href={"https://" + Conf.canonicalDomain}>メインサイト</a>
          はサービス向上のため、少しの匿名化された利用情報を収集しています。希望しない方は、
          <a href={Conf.mirrorUrl}>ミラーサイト</a>をご利用ください。
        </li>
        <li>
          内容の誤り・誤植等はご報告いただけますと幸いです。 <a href="mailto:i@sapika.ch">SAPikachu (i@sapika.ch)</a>{" "}
          または <a href="https://github.com/SAPikachu/amae-koromo/">GitHub</a> でご連絡ください。
        </li>
        <li>
          新しいサーバーを提供してくださった <a href="https://twitter.com/EDWARDH_Jantama">EDWARDH</a> に感謝します。
        </li>
        <li>
          一部のデータを提供してくださった <a href="https://github.com/kamicloud/">Kamicloud</a> に感謝します。
        </li>
      </StyledUl>
    </>
  );
}

function AlertKo() {
  return (
    <>
      <AlertTitle>안내</AlertTitle>
      <StyledUl>
        <li>
          본 사이트는 비공식 사이트로, 데이터의 완전성과 정확성이 보증되지 않습니다. 사이트 내용을 악용하지 말아
          주십시오.
        </li>
        <li>
          데이터 갱신은 실시간으로 이루어지지 않습니다. 대국이 사이트에 반영되기까지는 수 분에서 수 시간이 걸립니다.
        </li>
        <li>
          데이터 수집은 2019년 11월 29일부터(옥탁 반장과 왕좌탁 반장은 2019년 8월 23일) 시작되었습니다. 수집 개시 이전의
          대국은 검색할 수 없습니다.
        </li>
        <li>
          <a href={"https://" + Conf.canonicalDomain}>메인 사이트</a>는 서비스 향상을 위해 약간의 익명 사용 데이터를
          수집하고 있습니다. 원치 않는 분은 <a href={Conf.mirrorUrl}>미러 사이트</a>를 이용해 주십시오.
        </li>
        <li>
          잘못된 내용 등이 있는 경우 <a href="mailto:i@sapika.ch">SAPikachu (i@sapika.ch)</a> 또는{" "}
          <a href="https://github.com/SAPikachu/amae-koromo/">GitHub</a>로 연락해주시길 바랍니다.
        </li>
        <li>
          한국어 번역은 <a href="https://github.com/limgit">limgit</a>가 도움을 주었습니다. 감사합니다!
        </li>
        <li>
          新しいサーバーを提供してくださった <a href="https://twitter.com/EDWARDH_Jantama">EDWARDH</a> に感謝します。
        </li>
        <li>
          一部のデータを提供してくださった <a href="https://github.com/kamicloud/">Kamicloud</a> に感謝します。
        </li>
      </StyledUl>
    </>
  );
}

export function AppHeader() {
  const { i18n } = useTranslation();
  return (
    <Alert container={Container} stateName="topNote20211211">
      {i18n.language.indexOf("ja") === 0 ? (
        <AlertJa />
      ) : i18n.language.indexOf("en") === 0 ? (
        <AlertEn />
      ) : i18n.language.indexOf("ko") === 0 ? (
        <AlertKo />
      ) : (
        <AlertDefault />
      )}
    </Alert>
  );
}


================================================
FILE: src/components/app/index.tsx
================================================
import { BrowserRouter as Router } from "react-router-dom";
import Loadable from "../misc/customizedLoadable";
import Scroller from "../misc/scroller";

import { Container } from "../layout";
import { AppHeader } from "./appHeader";
import { MaintenanceHandler } from "./maintenance";
import Navbar from "./navbar";
import CanonicalLink from "../misc/canonicalLink";
import Tracker from "../misc/tracker";
import Conf from "../../utils/conf";
import { useTranslation } from "react-i18next";

import "./theme";
import RootThemeProvider from "./theme";
import { CssBaseline } from "@mui/material";
import AdapterDayJs from "@mui/lab/AdapterDayjs";
import LocalizationProvider from "@mui/lab/LocalizationProvider";
import { SnackbarProvider } from "notistack";
import { RegisterSnackbarProvider } from "../../utils/notify";
import { FC } from "react";
import StarPlayerProvider from "../playerDetails/star/starPlayerProvider";
import { Routes } from "./routes";
import GameLinkActionsProvider from "../gameRecords/gameLinkActions";

const Helmet = Loadable({
  loader: () => import("react-helmet"),
  loading: () => <></>,
});
const LP: FC = ({ children }) => (
  <LocalizationProvider
    dateAdapter={AdapterDayJs}
    dateFormats={{
      month: "MM",
      monthShort: "MM",
      monthAndDate: "MM-DD",
      shortDate: "MM-DD",
      monthAndYear: "YYYY-MM",
      fullDate: "YYYY-MM-DD",
      keyboardDate: "YYYY-MM-DD",
      fullTime: "HH:mm",
    }}
  >
    {children}
  </LocalizationProvider>
);

const Providers: FC = ({ children }) => (
  <RootThemeProvider>
    <SnackbarProvider maxSnack={3}>
      <LP>
        <StarPlayerProvider>{children}</StarPlayerProvider>
      </LP>
    </SnackbarProvider>
  </RootThemeProvider>
);

function App() {
  const { t, i18n } = useTranslation();
  return (
    <Providers>
      <RegisterSnackbarProvider />
      <CssBaseline />
      <div className={"lang-" + i18n.language}>
        <Router>
          <Helmet defaultTitle={t(Conf.siteTitle)} titleTemplate={`%s | ${t(Conf.siteTitle)}`} />
          <CanonicalLink />
          <Tracker />
          <Navbar />
          <MaintenanceHandler>
            <Scroller>
              {Conf.showTopNotice ? <AppHeader /> : <></>}
              <Container>
                <GameLinkActionsProvider>
                  <Routes />
                </GameLinkActionsProvider>
              </Container>
            </Scroller>
          </MaintenanceHandler>
        </Router>
      </div>
    </Providers>
  );
}
export default App;


================================================
FILE: src/components/app/maintenance.tsx
================================================
import * as React from "react";
import { useState } from "react";

import { Alert } from "../misc/alert";
import { Container } from "../layout/container";
import { setMaintenanceHandler } from "../../data/source/api";

export function MaintenanceHandler({ children }: { children: React.ReactElement }): React.ReactElement {
  const [msg, setMsg] = useState("");
  setMaintenanceHandler(setMsg);
  if (!msg) {
    return children;
  }
  return (
    <Alert container={Container} closable={false} title="临时维护公告">
      {msg}
    </Alert>
  );
}


================================================
FILE: src/components/app/navbar.tsx
================================================
import React, { ReactElement, useState } from "react";
import { Location } from "history";
import Conf, { CONFIGURATIONS } from "../../utils/conf";
import { useTranslation } from "react-i18next";
import {
  AppBar,
  Button,
  ButtonGroup,
  Container,
  Toolbar,
  MenuItem,
  ButtonProps,
  Box,
  IconButton,
  useScrollTrigger,
  Slide,
  Drawer,
  List,
  ListItemButton,
  ListItemText,
  Divider,
  ListItemIcon,
  ListItem,
  ThemeOptions,
} from "@mui/material";
import { ArrowDropDown, Language, GitHub, Twitter, Menu as MenuIcon } from "@mui/icons-material";
import { OverrideTheme } from "./theme";
import clsx from "clsx";
import { NavLink, NavLinkProps } from "react-router-dom";
import NavButton from "../misc/navButton";
import { MenuButton } from "../misc/menuButton";
import StarredPlayerMenu from "../playerDetails/star/starredPlayerMenu";

const NAV_ITEMS = [
  ["最近役满", "highlight"],
  ["排行榜", "ranking"],
  ["大数据", "statistics"],
]
  .filter(([, path]) => !(path in Conf.features) || Conf.features[path as keyof typeof Conf.features])
  .map(([label, path]) => ({ label, path }));

const SITE_LINKS = [
  ["四麻", CONFIGURATIONS.DEFAULT.canonicalDomain],
  ["三麻", CONFIGURATIONS.ikeda.canonicalDomain],
].map(([label, domain]) => ({ label, domain, active: Conf.canonicalDomain === domain }));

const LANGUAGES = [
  ["中文", "zh-hans"],
  ["日本語", "ja"],
  ["English", "en"],
  ["한국어", "ko"],
].map(([label, code]) => ({ label, code }));

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isActive(match: any, location: Location): boolean {
  if (!match) {
    return false;
  }
  return !NAV_ITEMS.some(({ path }) => location.pathname.startsWith("/" + path));
}

function HideOnScroll({ children }: { children: ReactElement }) {
  const trigger = useScrollTrigger();

  return (
    <Slide appear={false} direction="down" in={!trigger}>
      {children}
    </Slide>
  );
}

const MobileNavButton = ({ href, children, ...props }: ButtonProps & Omit<NavLinkProps, "to">) => (
  <ListItem disablePadding>
    <ListItemButton component={NavLink} {...{ to: href, activeClassName: "Mui-selected" }} {...props}>
      <ListItemIcon></ListItemIcon>
      <ListItemText>{children}</ListItemText>
    </ListItemButton>
  </ListItem>
);

function handleSwitchSite(e: React.MouseEvent<HTMLAnchorElement>) {
  e.preventDefault();
  if (e.currentTarget.classList.contains("active") || e.currentTarget.classList.contains("Mui-selected")) {
    return;
  }
  const url = new URL(e.currentTarget.href);
  url.pathname = location.pathname;
  window.location.href = url.toString();
}
function DesktopItems() {
  const { t, i18n } = useTranslation();
  return (
    <>
      <ButtonGroup>
        <NavButton href="/" isActive={isActive}>
          {t("主页")}
        </NavButton>
        {NAV_ITEMS.map(({ label, path }) => (
          <NavButton key={path} href={`/${path}`}>
            {t(label)}
          </NavButton>
        ))}
      </ButtonGroup>
      <ButtonGroup>
        {SITE_LINKS.map(({ label, domain, active }) => (
          <Button
            className={clsx(active && "active")}
            key={domain}
            href={`https://${domain}/`}
            onClick={handleSwitchSite}
          >
            {t(label)}
          </Button>
        ))}
      </ButtonGroup>
      <MenuButton
        startIcon={<Language />}
        endIcon={<ArrowDropDown />}
        label={LANGUAGES.find((x) => x.code === i18n.language)?.label}
      >
        {LANGUAGES.map(({ label, code }) => (
          <MenuItem key={code} onClick={() => i18n.changeLanguage(code)} selected={code === i18n.language}>
            {label}
          </MenuItem>
        ))}
      </MenuButton>
      <IconButton href="https://twitter.com/AmaeKoromo_MajS">
        <Twitter />
      </IconButton>
      <IconButton href="https://github.com/SAPikachu/amae-koromo">
        <GitHub />
      </IconButton>
    </>
  );
}
function MobileItems() {
  const { t, i18n } = useTranslation();
  const [open, setOpen] = useState(false);
  return (
    <>
      <IconButton onClick={() => setOpen(true)}>
        <MenuIcon />
      </IconButton>
      <Drawer anchor="right" open={open} onClose={() => setOpen(false)}>
        <Box width={250} onClick={() => setOpen(false)}>
          <List>
            <MobileNavButton href="/" isActive={isActive}>
              {t("主页")}
            </MobileNavButton>
            {NAV_ITEMS.map(({ label, path }) => (
              <MobileNavButton key={path} href={`/${path}`}>
                {t(label)}
              </MobileNavButton>
            ))}
          </List>
          <Divider />
          <List>
            {SITE_LINKS.map(({ label, domain, active }) => (
              <ListItem disablePadding key={domain}>
                <ListItemButton selected={active} href={`https://${domain}/`} onClick={handleSwitchSite}>
                  <ListItemIcon></ListItemIcon>
                  <ListItemText>{t(label)}</ListItemText>
                </ListItemButton>
              </ListItem>
            ))}
          </List>
          <Divider />
          <List>
            {LANGUAGES.map(({ label, code }) => (
              <ListItem disablePadding key={code}>
                <ListItemButton onClick={() => i18n.changeLanguage(code)} selected={code === i18n.language}>
                  <ListItemIcon></ListItemIcon>
                  <ListItemText>{label}</ListItemText>
                </ListItemButton>
              </ListItem>
            ))}
          </List>
          <Divider />
          <List>
            <ListItem disablePadding>
              <ListItemButton href="https://twitter.com/AmaeKoromo_MajS">
                <ListItemIcon>
                  <Twitter />
                </ListItemIcon>
                <ListItemText>{t("Twitter")}</ListItemText>
              </ListItemButton>
            </ListItem>
            <ListItem disablePadding>
              <ListItemButton href="https://github.com/SAPikachu/amae-koromo">
                <ListItemIcon>
                  <GitHub />
                </ListItemIcon>
                <ListItemText>{t("GitHub")}</ListItemText>
              </ListItemButton>
            </ListItem>
          </List>
        </Box>
      </Drawer>
    </>
  );
}

const NAVBAR_THEME: ThemeOptions = {
  components: {
    MuiIconButton: {
      defaultProps: {
        color: "inherit",
      },
    },
    MuiButton: {
      defaultProps: {
        sx: {
          transition: (theme) => theme.transitions.create("opacity"),
        },
      },
    },
    MuiButtonGroup: {
      defaultProps: {
        variant: "text",
        sx: {
          mr: 2,
        },
      },
      styleOverrides: {
        grouped: {
          opacity: 0.5,
          "&:hover, &.active": {
            opacity: 1,
          },
          "&:not(:last-of-type)": {
            borderColor: "transparent",
          },
        },
      },
    },
  },
} as const;
export default function Navbar() {
  const { t } = useTranslation();
  return (
    <OverrideTheme theme={NAVBAR_THEME}>
      <HideOnScroll>
        <AppBar position="fixed">
          <Toolbar variant="dense">
            <Container>
              <Box display="flex" alignItems="center">
                <Button
                  href="/"
                  size="large"
                  variant="text"
                  sx={{
                    padding: 0,
                    "&:hover": {
                      backgroundColor: "transparent",
                    },
                  }}
                  disableRipple
                >
                  {t(Conf.siteTitle)}
                </Button>
                <Box flexGrow={1}></Box>
                <Box display={["none", null, "flex"]} alignItems="center">
                  <DesktopItems />
                </Box>
                <StarredPlayerMenu />
                <Box display={["block", null, "none"]}>
                  <MobileItems />
                </Box>
              </Box>
            </Container>
          </Toolbar>
        </AppBar>
      </HideOnScroll>
    </OverrideTheme>
  );
}


================================================
FILE: src/components/app/routes.tsx
================================================
import { Route, Switch } from "react-router-dom";
import Loadable from "../misc/customizedLoadable";
import GameRecords from "../gameRecords";
import { PageCategory } from "../misc/tracker";
import Conf from "../../utils/conf";

const Ranking = Loadable({
  loader: () => import("../ranking"),
});
const Statistics = Loadable({
  loader: () => import("../statistics"),
});
const RecentHighlight = Loadable({
  loader: () => import("../recentHighlight"),
});
const ContestTools = Loadable({
  loader: () => import("../contestTools"),
});
export function Routes() {
  return (
    <Switch>
      <Route path="/ranking">
        <PageCategory category="Ranking" />
        <Ranking />
      </Route>
      <Route path="/statistics">
        <PageCategory category="Statistics" />
        <Statistics />
      </Route>
      <Route path="/highlight">
        <PageCategory category="RecentHighlight" />
        <RecentHighlight />
      </Route>
      {Conf.features.contestTools ? (
        <Route path="/contest-tools">
          <ContestTools />
        </Route>
      ) : null}
      <Route path="/">
        <GameRecords />
      </Route>
    </Switch>
  );
}


================================================
FILE: src/components/app/theme.tsx
================================================
import { ReactNode, useMemo } from "react";
import {
  alpha,
  createTheme,
  responsiveFontSizes,
  Theme,
  ThemeOptions,
  ThemeProvider as MaterialThemeProvider,
} from "@mui/material";
import { enUS, jaJP, koKR, Localization, zhCN } from "@mui/material/locale";
import { deepmerge } from "@mui/utils";
import { useTranslation } from "react-i18next";
import { LinkBehavior } from "../misc/linkBehavior";

const LOCALES: { [key: string]: Localization } = {
  en: enUS,
  ja: jaJP,
  ko: koKR,
} as const;
const DEFAULT_LOCALE = zhCN;

const FONTS: { [key: string]: string } = {
  en: '"Roboto", "Meiryo", "Microsoft YaHei", sans-serif',
  ja: '"Roboto", "Meiryo", "Microsoft YaHei", sans-serif',
  ko: '"Roboto", "Malgun Gothic", "Meiryo", "Microsoft YaHei", sans-serif',
};
const DEFAULT_FONT = '"Roboto", "Microsoft YaHei", "Meiryo", sans-serif';

const THEME_BASIC: ThemeOptions = {
  palette: {
    mode: "light",
    primary: {
      light: "#6a4f4b",
      main: "#3e2723",
      dark: "#1b0000",
      contrastText: "#fff",
    },
    secondary: {
      light: "#ffffff",
      main: "#f8f0ed",
      dark: "#004ba0",
      contrastText: "#000",
    },
  },
  typography: {
    fontFamily: '"Roboto", "Microsoft YaHei", "Meiryo", sans-serif',
    fontSize: 16,
  },
};
const THEME_VALUES = createTheme(THEME_BASIC);

const THEME: ThemeOptions = {
  ...THEME_BASIC,
  components: {
    MuiLink: {
      defaultProps: {
        color: "info.main",
        underline: "hover",
        ...({
          component: LinkBehavior,
        } as any), // eslint-disable-line @typescript-eslint/no-explicit-any
      },
    },
    MuiListItemButton: {
      defaultProps: {
        ...({
          component: LinkBehavior,
        } as any), // eslint-disable-line @typescript-eslint/no-explicit-any
      },
    },
    MuiButtonBase: {
      defaultProps: {
        LinkComponent: LinkBehavior,
      },
    },
    MuiButton: {
      styleOverrides: {
        root: {
          fontWeight: "normal",
          textTransform: "none",
        },
      },
    },
    MuiAppBar: {
      defaultProps: {
        color: "secondary",
      },
    },
    MuiToolbar: {
      styleOverrides: {
        root: {
          padding: 0,
          "& .MuiButton-root": {
            color: "inherit",
          },
        },
      },
    },
    MuiOutlinedInput: {
      styleOverrides: {
        root: {
          backgroundColor: "rgba(255, 255, 255, 0.5)",
        },
      },
    },
    MuiTableRow: {
      styleOverrides: {
        root: {
          "&:nth-of-type(2n+1) .MuiTableCell-root": {
            boxShadow: `inset 0 0 0 9999px ${alpha(THEME_VALUES.palette.primary.dark, 0.05)};`,
          },
        },
      },
    },
    MuiTableHead: {
      styleOverrides: {
        root: {
          boxShadow: `inset 0 0 0 9999px ${alpha(THEME_VALUES.palette.primary.dark, 0.075)};`,
          "& .MuiTableCell-root": {
            fontWeight: "bold",
          },
        },
      },
    },
    MuiTableCell: {
      styleOverrides: {
        root: {
          padding: THEME_VALUES.spacing(1.5),
        },
      },
    },
    MuiFormControlLabel: {
      styleOverrides: {
        root: {
          "& .MuiTypography-root": {
            fontSize: "1rem",
          },
        },
      },
    },
    MuiTooltip: {
      defaultProps: {
        enterTouchDelay: 100,
        leaveTouchDelay: 15000,
      },
    },
    MuiUseMediaQuery: {
      defaultProps: {
        noSsr: true,
      },
    },
    ...{
      MuiCalendarPicker: {
        styleOverrides: {
          root: {
            "& > div:first-child > [role=presentation] > .PrivatePickersFadeTransitionGroup-root:nth-child(2)": {
              order: -1,
              display: "flex",
              div: {
                margin: 0,
              },
              "&::after": {
                display: "block",
                content: "'-'",
                marginLeft: "0.5rem",
                marginRight: "0.5rem",
              },
            },
          },
        },
      },
    },
  },
};

export function OverrideTheme({ theme, children }: { theme: ThemeOptions; children: ReactNode }) {
  const themeFunc = useMemo(() => (outerTheme: Theme) => deepmerge(outerTheme, theme), [theme]);
  return <MaterialThemeProvider theme={themeFunc}>{children}</MaterialThemeProvider>;
}
export default function RootThemeProvider({ children }: { children: ReactNode }) {
  const { i18n } = useTranslation();
  const theme = useMemo(
    () =>
      responsiveFontSizes(
        createTheme(
          {
            ...THEME,
            typography: {
              ...THEME.typography,
              fontFamily: FONTS[i18n.language] || DEFAULT_FONT,
              fontWeightMedium: i18n.language === "en" ? 500 : 700,
            },
          },
          LOCALES[i18n.language] || DEFAULT_LOCALE
        ),
        {
          variants: ["h1", "h2", "h3", "h4", "h5", "h6"],
        }
      ),
    [i18n.language]
  );
  return <MaterialThemeProvider theme={theme}>{children}</MaterialThemeProvider>;
}


================================================
FILE: src/components/charts/simplePieChart.tsx
================================================
/* eslint-disable @typescript-eslint/indent */
import {
  ResponsiveContainer,
  PieChart,
  Pie,
  Cell,
  LabelList,
  LabelProps,
  ResponsiveContainerProps,
  PieProps,
} from "recharts";
import { PolarViewBox } from "recharts/src/util/types";
import { useEffect, useMemo, useState } from "react";

const DEFAULT_COLORS = ["#003f5c", "#7a5195", "#ef5675", "#ffa600"];

const getDeltaAngle = (startAngle: number, endAngle: number) => {
  const sign = Math.sign(endAngle - startAngle);
  const deltaAngle = Math.min(Math.abs(endAngle - startAngle), 360);

  return sign * deltaAngle;
};

const RADIAN = Math.PI / 180;

const polarToCartesian = (cx: number, cy: number, radius: number, angle: number) => ({
  x: cx + Math.cos(-RADIAN * angle) * radius,
  y: cy + Math.sin(-RADIAN * angle) * radius,
});

const renderCustomizedLabelFactory =
  ({ lineHeight = 24, innerLabelFontSize = "1rem" }) =>
  (props: LabelProps) => {
    const { value } = props;
    if (!value) {
      return null;
    }
    const lines = value.toString().trim().split("\n");
    const { cx, cy, outerRadius, startAngle, endAngle } = props.viewBox as Required<PolarViewBox>;
    const labelAngle = startAngle + getDeltaAngle(startAngle, endAngle) / 2;
    const { x, y } = polarToCartesian(cx, cy, outerRadius / 2, labelAngle);
    const yStart = y - (lines.length - 1) * (lineHeight / 2);
    return (
      <g>
        {lines.map((text, index) => (
          <text
            key={index}
            x={x}
            y={yStart + index * lineHeight}
            stroke="#fff"
            strokeWidth="0.5"
            fill="#fff"
            fontSize={innerLabelFontSize}
            textAnchor="middle"
            dominantBaseline="central"
          >
            {text}
          </text>
        ))}
      </g>
    );
  };

export type PieChartItem = {
  value: number;
  innerLabel?: string;
  outerLabel?: string;
};

function defaultInnerLabel<T extends PieChartItem>(item: T) {
  return item.innerLabel || "";
}
function defaultOuterLabel<T extends PieChartItem>(item: T) {
  return item.outerLabel || "";
}
function labelLine<T extends PieChartItem>(item: T) {
  if (!item.outerLabel) {
    return null;
  }
  return Pie.renderLabelLineItem(true, item);
}

export default function SimplePieChart<T extends PieChartItem>({
  items,
  innerLabel = defaultInnerLabel,
  outerLabel = defaultOuterLabel,
  outerLabelOffset = 0,
  innerLabelLineHeight = 24,
  startAngle = 0,
  colors = DEFAULT_COLORS,
  innerLabelFontSize = "1rem",
  aspect = 1,
  pieProps = {},
  onSelect = undefined,
  ...props
}: {
  items: T[];
  innerLabel?: (item: T) => string;
  outerLabel?: (item: T) => string;
  outerLabelOffset?: number;
  innerLabelLineHeight?: number;
  startAngle?: number;
  colors?: string[];
  innerLabelFontSize?: string;
  aspect?: number;
  pieProps?: Partial<PieProps>;
  onSelect?: ((item: T | null) => void) | undefined;
} & Partial<ResponsiveContainerProps>) {
  const [activeIndex, setActiveIndex] = useState(null as number | null);
  useEffect(() => {
    setActiveIndex(null);
  }, [items]);
  useEffect(() => {
    if (!onSelect) {
      return;
    }
    onSelect(activeIndex === null ? null : items[activeIndex]);
  }, [onSelect, activeIndex, items]);
  const cells = useMemo(
    () =>
      Array(items.length)
        .fill(0)
        .map((_, index) => (
          <Cell
            {...(activeIndex === index ? { className: "selectable active" } : {})}
            fill={colors[index % colors.length]}
            fillOpacity={1}
            key={`cell-${index}`}
            onClick={onSelect ? () => setActiveIndex(index === activeIndex ? null : index) : undefined}
          />
        )),
    [items.length, colors, activeIndex, onSelect]
  );
  const renderCustomizedLabel = useMemo(
    () => renderCustomizedLabelFactory({ lineHeight: innerLabelLineHeight, innerLabelFontSize }),
    [innerLabelLineHeight, innerLabelFontSize]
  );
  const wrappedOuterLabel = useMemo(() => {
    const ret = (item: T) => outerLabel(item);
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    (ret as any).offsetRadius = outerLabelOffset;
    return ret;
  }, [outerLabel, outerLabelOffset]);
  return (
    <ResponsiveContainer width="100%" aspect={aspect} height="auto" {...props}>
      <PieChart>
        <Pie
          className={onSelect ? "selectable" + (activeIndex !== null ? " with-active" : "") : ""}
          isAnimationActive={false}
          data={items}
          nameKey="outerLabel"
          dataKey="value"
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          label={wrappedOuterLabel as (x: any) => string}
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          labelLine={labelLine as any}
          startAngle={startAngle}
          endAngle={startAngle + 360}
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          {...(pieProps as any)}
        >
          {cells}
          <LabelList
            valueAccessor={innerLabel}
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            dataKey={undefined as any}
            position="inside"
            content={renderCustomizedLabel}
            {...{ fill: "#fff" }}
          />
        </Pie>
      </PieChart>
    </ResponsiveContainer>
  );
}


================================================
FILE: src/components/contestTools/index.tsx
================================================
import React from "react";

import { ModelModeProvider } from "../modeModel";
import { ViewRoutes, SimpleRoutedSubViews, NavButtons, RouteDef } from "../routing";
import { ViewSwitch } from "../routing/index";
import MinMax from "./minMax";

const ROUTES = (
  <ViewRoutes>
    {[
      <RouteDef key="" path="min-max" title="最低/最高点对局">
        <MinMax />
      </RouteDef>,
    ]}
  </ViewRoutes>
);

export default function Routes() {
  return (
    <SimpleRoutedSubViews>
      {ROUTES}
      <ModelModeProvider>
        <NavButtons />
        <ViewSwitch />
      </ModelModeProvider>
    </SimpleRoutedSubViews>
  );
}


================================================
FILE: src/components/contestTools/minMax.tsx
================================================
import { useState, useCallback } from "react";
import { DatePicker } from "../form";
import Conf from "../../utils/conf";
import Loading from "../misc/loading";
import dayjs from "dayjs";
import { ListingDataLoader } from "../../data/source/records/loader";
import { GameRecord, PlayerRecord } from "../../data/types";
import { generatePlayerPathById } from "../gameRecords/routeUtils";

export default function MinMax() {
  const [dateStart, setDateStart] = useState(() => dayjs());
  const [dateEnd, setDateEnd] = useState(() => dayjs());
  const [loading, setLoading] = useState(false);
  const [playerList, setPlayerList] = useState(
    [] as {
      id: string;
      minGame: GameRecord;
      maxGame: GameRecord;
      minGamePlayer: PlayerRecord;
      maxGamePlayer: PlayerRecord;
      numGames: number;
      totalPoints: number;
    }[]
  );
  const search = useCallback(async () => {
    setLoading(true);
    let cur = dateStart.startOf("day");
    const end = dateEnd.endOf("day");
    const players = {} as {
      [key: string]: typeof playerList[0];
    };
    while (cur.isBefore(end)) {
      const loader = new ListingDataLoader(cur, null);
      for (;;) {
        const records = await loader.getNextChunk();
        if (!records.length) {
          break;
        }
        for (const rec of records) {
          for (const player of rec.players) {
            const id = player.accountId.toString();
            if (!(id in players)) {
              players[id] = {
                id,
                minGame: rec,
                maxGame: rec,
                minGamePlayer: player,
                maxGamePlayer: player,
                numGames: 1,
                totalPoints: player.score,
              };
              continue;
            }
            const info = players[id];
            info.numGames++;
            info.totalPoints += player.score;
            if (player.score > info.maxGamePlayer.score) {
              info.maxGame = rec;
              info.maxGamePlayer = player;
            }
            if (player.score < info.minGamePlayer.score) {
              info.minGame = rec;
              info.minGamePlayer = player;
            }
          }
        }
      }
      cur = cur.add(1, "day");
    }
    setPlayerList(Object.values(players));
    setLoading(false);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [setLoading, dateStart, dateEnd, setPlayerList, playerList]);
  return (
    <>
      <DatePicker min={Conf.dateMin} date={dateStart} onChange={setDateStart} />
      <DatePicker min={Conf.dateMin} date={dateEnd} onChange={setDateEnd} />
      {loading ? (
        <Loading />
      ) : (
        <>
          <button type="button" className="btn btn-primary mt-3" onClick={search}>
            查询
          </button>
          {playerList && playerList.length ? (
            <table className="table table-responsive-xl table-striped table-hover mt-3">
              <thead>
                <tr>
                  <th>玩家</th>
                  <th>最低分</th>
                  <th>最低分比赛时间</th>
                  <th>最高分</th>
                  <th>最高分比赛时间</th>
                  <th>平均点数</th>
                </tr>
              </thead>
              <tbody>
                {playerList.map((player) => (
                  <tr key={player.id}>
                    <td>
                      <a href={generatePlayerPathById(player.id)}>{player.maxGamePlayer.nickname}</a>
                    </td>
                    <td>
                      <a href={GameRecord.getRecordLink(player.minGame, player.minGamePlayer)}>
                        {player.minGamePlayer.score}
                      </a>
                    </td>
                    <td>{GameRecord.formatFullStartTime(player.minGame)}</td>
                    <td>
                      <a href={GameRecord.getRecordLink(player.maxGame, player.maxGamePlayer)}>
                        {player.maxGamePlayer.score}
                      </a>
                    </td>
                    <td>{GameRecord.formatFullStartTime(player.maxGame)}</td>
                    <td>{Math.round(player.totalPoints / player.numGames)}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          ) : null}
        </>
      )}
    </>
  );
}


================================================
FILE: src/components/form/checkboxGroup.tsx
================================================
import React, { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import {
  Checkbox,
  FormControl,
  FormControlLabel,
  FormGroup,
  FormLabel,
  Radio,
  RadioGroup as MuiRadioGroup,
} from "@mui/material";

export interface CheckboxItem<T> {
  key: string;
  label: string;
  value: T;
}

type GroupParams<T> = {
  type: "checkbox" | "radio";
  items: CheckboxItem<T>[];
  selectedItems: Iterable<string | CheckboxItem<T>> | null;
  onChange: (selectedItems: CheckboxItem<T>[]) => void;
  i18nNamespace?: string | string[] | undefined;
  label?: string;
};
function InternalRadioGroup<T>({
  items = [],
  selectedItems = null,
  // eslint-disable-next-line @typescript-eslint/no-empty-function
  onChange = () => {},
  i18nNamespace = undefined,
}: GroupParams<T>) {
  const { t } = useTranslation(i18nNamespace);
  const selectedItemKey = useMemo(() => {
    for (const item of selectedItems || []) {
      if (typeof item === "string") {
        return item;
      } else {
        return item.key;
      }
    }
    return undefined;
  }, [selectedItems]);
  const handleChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      const value = (event.target as HTMLInputElement).value;
      if (value === selectedItemKey) {
        return;
      }
      const item = items.find((x) => x.key === value);
      onChange(item ? [item] : []);
    },
    [items, onChange, selectedItemKey]
  );
  return (
    <MuiRadioGroup value={selectedItemKey || null} onChange={handleChange} row>
      {items.map((x) => (
        <FormControlLabel
          key={x.key}
          value={x.key}
          label={t(x.label || x.label).toString()}
          control={<Radio size="small" />}
        />
      ))}
    </MuiRadioGroup>
  );
}
function InternalCheckboxGroup<T>({
  items = [],
  selectedItems = null,
  // eslint-disable-next-line @typescript-eslint/no-empty-function
  onChange = () => {},
  i18nNamespace = undefined,
}: GroupParams<T>) {
  const { t } = useTranslation(i18nNamespace);
  const [highlightKey, setHighlightKey] = useState(null as string | null);
  const selectedItemKeys = useMemo(() => {
    const ret = new Set<string>();
    for (const item of selectedItems || []) {
      if (typeof item === "string") {
        ret.add(item);
      } else {
        ret.add(item.key);
      }
    }
    return ret;
  }, [selectedItems]);
  const handleClick = useCallback(
    (e: React.MouseEvent) => {
      e.preventDefault();
      e.stopPropagation();

      const key = (e.currentTarget as HTMLElement).dataset.value as string;

      if (!(e.target as HTMLElement).classList.contains("MuiFormControlLabel-label")) {
        const newSet = new Set(selectedItemKeys);
        if (selectedItemKeys.has(key)) {
          if (selectedItemKeys.size === 1) {
            return;
          }
          newSet.delete(key);
        } else {
          newSet.add(key);
        }
        onChange(items.filter((x) => newSet.has(x.key)));
      } else {
        if (selectedItemKeys.size === 1 && selectedItemKeys.has(key)) {
          return;
        }
        onChange(items.filter((x) => key === x.key));
      }
    },
    [items, onChange, selectedItemKeys]
  );
  const handleMouseOver = (e: React.MouseEvent) => {
    if (!(e.target as HTMLElement).classList.contains("MuiFormControlLabel-label")) {
      return;
    }
    const key = (e.currentTarget as HTMLElement).dataset.value as string;
    setHighlightKey(key);
  };
  const handleMouseOut = (e: React.MouseEvent) => {
    if (!(e.target as HTMLElement).classList.contains("MuiFormControlLabel-label")) {
      return;
    }
    const key = (e.currentTarget as HTMLElement).dataset.value as string;
    setHighlightKey((oldValue) => (oldValue === key ? null : oldValue));
  };
  return (
    <FormGroup row>
      {items.map((x) => (
        <FormControlLabel
          key={x.key}
          value={x.key}
          label={(t(x.label) || x.label).toString()}
          data-value={x.key}
          onClick={handleClick}
          onMouseOver={handleMouseOver}
          onMouseOut={handleMouseOut}
          sx={{
            opacity: !highlightKey || highlightKey === x.key ? 1 : 0.25,
            transition: (theme) => theme.transitions.create("opacity"),
          }}
          control={<Checkbox checked={selectedItemKeys.has(x.key)} size="small" />}
        />
      ))}
    </FormGroup>
  );
}
export function CheckboxGroup<T>(
  props: GroupParams<T> = {
    type: "checkbox",
    items: [],
    selectedItems: null,
    // eslint-disable-next-line @typescript-eslint/no-empty-function
    onChange: () => {},
    i18nNamespace: undefined,
    label: "",
  }
) {
  const { t } = useTranslation(props.i18nNamespace);
  return (
    <FormControl component="fieldset">
      {props.label && <FormLabel component="legend">{t(props.label)}</FormLabel>}
      {props.type === "checkbox" ? <InternalCheckboxGroup {...props} /> : <InternalRadioGroup {...props} />}
    </FormControl>
  );
}


================================================
FILE: src/components/form/datePicker.tsx
================================================
/* eslint-disable @typescript-eslint/no-empty-function */

import dayjs from "dayjs";
import { useCallback } from "react";

import { DatePicker as MuiDatePicker, DatePickerProps } from "@mui/lab";
import { TextField } from "@mui/material";
import { useTranslation } from "react-i18next";

export function DatePicker({
  date = dayjs() as dayjs.ConfigType,
  onChange = (() => {}) as (_: dayjs.Dayjs) => void,
  min = 0 as dayjs.ConfigType,
  max = dayjs() as dayjs.ConfigType,
  label = "",
  fullWidth = false,
  size = "medium" as "medium" | "small",
  renderInput = null as null | DatePickerProps["renderInput"],
}) {
  const handleChange = useCallback(
    (value: dayjs.Dayjs | null) => onChange(value || dayjs(date).startOf("day")),
    [date, onChange]
  );
  const { t } = useTranslation("form");
  return (
    <MuiDatePicker
      disableCloseOnSelect={false}
      label={t(label)}
      value={dayjs(date)}
      onChange={handleChange}
      ignoreInvalidInputs
      toolbarFormat=" "
      toolbarTitle=""
      mask="____-__-__"
      renderInput={renderInput || ((params: any) => <TextField fullWidth={fullWidth} size={size} {...params} />)} // eslint-disable-line @typescript-eslint/no-explicit-any
      minDate={dayjs(min)}
      maxDate={dayjs(max)}
    />
  );
}


================================================
FILE: src/components/form/index.tsx
================================================
export * from "./checkboxGroup";
export * from "./datePicker";


================================================
FILE: src/components/gameRecords/columns.tsx
================================================
import React from "react";
import { TableCellProps } from "react-virtualized";
import { Column } from "react-virtualized/dist/es/Table";
import dayjs from "dayjs";
import { GameRecord, modeLabel } from "../../data/types";
import { Player } from "./player";
import Conf from "../../utils/conf";
import { Trans } from "react-i18next";
import i18n from "../../i18n";
import { Box, Tooltip, TypographyProps, useTheme } from "@mui/material";

const formatTime = (x: number) => (x ? dayjs.unix(x).format("HH:mm") : null);
type ActivePlayerId = number | string | ((x: GameRecord) => number | string);
type PlayersProps = {
  game: GameRecord;
  activePlayerId?: ActivePlayerId;
  language?: string;
  activeProps?: TypographyProps;
  inactiveProps?: TypographyProps;
  alwaysShowDetailLink?: boolean;
  maskedGameLink?: boolean;
};
const Players = React.memo(
  ({ game, activePlayerId, alwaysShowDetailLink, activeProps, inactiveProps, maskedGameLink }: PlayersProps) => {
    const theme = useTheme();
    if (typeof activePlayerId === "function") {
      activePlayerId = activePlayerId(game);
    }
    if (typeof activePlayerId !== "string") {
      activePlayerId = activePlayerId?.toString() || "";
    }
    if (activePlayerId) {
      inactiveProps = inactiveProps || {
        color: theme.palette.grey[500],
      };
    }
    return (
      <Box display="grid" gridTemplateColumns={["1fr", null, "1fr 1fr"]}>
        {game.players.map((x) => (
          <Player
            key={x.accountId}
            game={game}
            player={x}
            maskedGameLink={maskedGameLink}
            {...(x.accountId.toString() === activePlayerId
              ? { hideDetailIcon: !alwaysShowDetailLink, showAiReviewIcon: !alwaysShowDetailLink, ...activeProps }
              : inactiveProps)}
          />
        ))}
      </Box>
    );
  }
);
const cellFormatTime = ({ cellData }: TableCellProps) => formatTime(cellData);
const cellFormatFullTime = ({ rowData }: TableCellProps) =>
  rowData.loading ? "" : GameRecord.formatFullStartTime(rowData);
const cellFormatFullTimeMobile = ({ rowData }: TableCellProps) =>
  rowData.loading ? (
    ""
  ) : (
    <Tooltip title={GameRecord.formatFullStartTime(rowData)} placement="left" arrow>
      <Box>
        <Box>{GameRecord.formatStartDate(rowData)}</Box>
        <Box>{formatTime(rowData.startTime)}</Box>
      </Box>
    </Tooltip>
  );
const cellFormatRank = ({ rowData, columnData }: TableCellProps) =>
  !rowData || rowData.loading || !columnData.activePlayerId ? (
    ""
  ) : (
    <Box fontWeight="bold" color={GameRecord.getPlayerRankColor(rowData, columnData.activePlayerId)}>
      {GameRecord.getPlayerRankLabel(rowData, columnData.activePlayerId)
        .slice(0, 1)
        .replace(/[0-9]/g, (s) => String.fromCodePoint(s.charCodeAt(0) + 0xfee0))}
    </Box>
  );
const cellFormatGameMode = ({ cellData }: TableCellProps) => (cellData ? modeLabel(parseInt(cellData)) : "");

type TableColumnDefKey = {
  key?: string;
};
export type TableColumn = React.FunctionComponentElement<Column> | false | undefined | null;
export type TableColumnDef = TableColumnDefKey & (() => TableColumn);

// eslint-disable-next-line @typescript-eslint/ban-types
export function makeColumn<T extends unknown[]>(builder: (...args: T) => TableColumn): (...args: T) => TableColumnDef {
  const key = Math.random().toString();
  const newBuilder = (...args: T) => {
    const outer = () => {
      const ret = builder(...args);
      if (ret) {
        return React.cloneElement(ret, { key });
      }
      return ret;
    };
    outer.key = key + args.join("-");
    return outer;
  };
  return newBuilder;
}

export const COLUMN_GAMEMODE = makeColumn(
  () =>
    Conf.table.showGameMode && (
      <Column
        dataKey="modeId"
        label={<Trans>等级</Trans>}
        cellRenderer={cellFormatGameMode}
        width={40}
        columnData={{
          mobileProps: {
            label: "",
            width: 20,
            style: {
              writingMode: "vertical-lr",
              padding: "0.5rem 0",
            },
          },
        }}
      />
    )
)();

export const COLUMN_RANK = makeColumn((activePlayerId: number | string) => (
  <Column
    dataKey="modeId"
    label={<Trans>顺位</Trans>}
    columnData={{
      activePlayerId,
      mobileProps: {
        label: "",
        width: 20,
        style: {
          writingMode: "vertical-lr",
          padding: "0.5rem 0",
        },
      },
    }}
    cellRenderer={cellFormatRank}
    width={40}
  />
));

export const COLUMN_PLAYERS = makeColumn((props: Partial<Omit<PlayersProps, "game">> = {}) => (
  <Column
    dataKey="players"
    label={<Trans>玩家</Trans>}
    cellRenderer={({ rowData }: TableCellProps) =>
      rowData && rowData.players ? <Players game={rowData} language={i18n.language} {...props} /> : null
    }
    width={120}
    flexGrow={1}
  />
));

export const COLUMN_STARTTIME = makeColumn(() => (
  <Column
    dataKey="startTime"
    label={<Trans>开始</Trans>}
    cellRenderer={cellFormatTime}
    width={50}
    className="text-right"
    headerClassName="text-right"
    columnData={{
      mobileProps: {
        width: 40,
      },
    }}
  />
))();

export const COLUMN_ENDTIME = makeColumn(() => (
  <Column
    dataKey="endTime"
    label={<Trans>结束</Trans>}
    cellRenderer={cellFormatTime}
    width={50}
    headerClassName="text-right"
    className="text-right"
    columnData={{
      mobileProps: {
        width: 40,
      },
    }}
  />
))();

export const COLUMN_FULLTIME = makeColumn(() => (
  <Column
    dataKey="startTime"
    label={<Trans>时间</Trans>}
    cellRenderer={cellFormatFullTime}
    width={150}
    className="text-right"
    headerClassName="text-right"
    columnData={{
      mobileProps: {
        width: 45,
        cellRenderer: cellFormatFullTimeMobile,
      },
    }}
  />
))();


================================================
FILE: src/components/gameRecords/dataAdapterProvider.tsx
================================================
import { useState, useEffect, useMemo, useCallback, useContext } from "react";
import React, { ReactChild } from "react";
import dayjs from "dayjs";

import { DataProvider, DUMMY_DATA_PROVIDER, FilterPredicate } from "../../data/source/records/provider";
import { useModel, Model } from "./model";
import { Metadata, GameRecord, Level } from "../../data/types";
import { generatePath } from "./routeUtils";
import { networkError } from "../../utils/notify";
import { ApiError } from "../../data/source/api";
import { useExtraFilterPredicate } from "./extraFilterPredicate";
import Conf from "../../utils/conf";

interface ItemLoadingPlaceholder {
  loading: boolean;
}

const loadingPlaceholder = { loading: true };

export interface IDataAdapter {
  getCount(): number;
  hasCount(): boolean;
  getUnfilteredCount(): number;
  getMetadata<T extends Metadata>(): T | null;
  getItem(index: number): GameRecord | ItemLoadingPlaceholder;
  isItemLoaded(index: number): boolean;
}

class DummyDataAdapter implements IDataAdapter {
  getCount(): number {
    return 0;
  }
  hasCount(): boolean {
    return true;
  }
  getUnfilteredCount(): number {
    return 0;
  }
  getMetadata<T extends Metadata>(): T | null {
    return null;
  }
  getItem(): GameRecord | ItemLoadingPlaceholder {
    return loadingPlaceholder;
  }
  isItemLoaded(): boolean {
    return false;
  }
}

export const DUMMY_DATA_ADAPTER = new DummyDataAdapter() as IDataAdapter;

// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};

class DataAdapter implements IDataAdapter {
  _provider: DataProvider;
  _onDataUpdate: (error: Error | ApiError | false) => void;
  _triggeredRequest: boolean;

  constructor(provider: DataProvider, onDataUpdate = noop) {
    this._provider = provider;
    this._onDataUpdate = onDataUpdate;
    this._triggeredRequest = false;
  }
  _installHook<T>(promise: Promise<T>) {
    if (this._triggeredRequest) {
      return;
    }
    this._triggeredRequest = true;
    promise.then(() => this._callHook(false)).catch((reason) => this._callHook(reason));
  }
  _callHook(error: Error | ApiError | false) {
    setTimeout(() => {
      this._onDataUpdate(error);
      this._onDataUpdate = noop;
    }, 0);
  }
  getCount(): number {
    try {
      const maybeCount = this._provider.getCountMaybeSync();
      if (maybeCount instanceof Promise) {
        this._installHook(maybeCount);
        return 0;
      }
      return maybeCount;
    } catch (e) {
      this._callHook(e);
      return 0;
    }
  }
  hasCount(): boolean {
    try {
      return !(this._provider.getCountMaybeSync() instanceof Promise);
    } catch (e) {
      this._callHook(e);
      return false;
    }
  }
  getUnfilteredCount(): number {
    try {
      return this._provider.getUnfilteredCountSync() || 0;
    } catch (e) {
      this._callHook(e);
      return 0;
    }
  }
  getMetadata<T extends Metadata>(): T | null {
    try {
      return this._provider.getMetadataSync() as T | null;
    } catch (e) {
      this._callHook(e);
      return null;
    }
  }
  getItem(index: number): GameRecord | ItemLoadingPlaceholder {
    if (index >= this.getCount()) {
      return loadingPlaceholder;
    }
    if (this._provider.isItemLoaded(index)) {
      return this._provider.getItem(index) as GameRecord;
    }
    if (!this._triggeredRequest) {
      this._installHook(this._provider.getItem(index) as Promise<GameRecord>);
    }
    return loadingPlaceholder;
  }
  isItemLoaded(index: number): boolean {
    if (index < 0) {
      return false;
    }
    return this._provider.isItemLoaded(index);
  }
  setUpdateHook(hook: () => void) {
    this._onDataUpdate = hook;
  }
  cancelUpdateHook() {
    this._onDataUpdate = noop;
  }
}

const DataAdapterContext = React.createContext(DUMMY_DATA_ADAPTER);

export const useDataAdapter = () => useContext(DataAdapterContext);
export const DataAdapterConsumer = DataAdapterContext.Consumer;

function getProviderKey(model: Model): string {
  if (model.type === undefined) {
    return `${dayjs(model.date || dayjs())
      .startOf("day")
      .valueOf()
      .toString()}_${model.selectedMode}`;
  } else if (model.type === "player") {
    return generatePath(model);
  }
  throw new Error("Unknown model type");
}

function createProvider(model: Model): DataProvider {
  if (model.type === undefined) {
    return DataProvider.createListing(model.date || dayjs().startOf("day"), model.selectedMode || null);
  }
  if (model.type === "player") {
    return DataProvider.createPlayer(model.playerId, model.startDate, model.endDate, model.limit, model.selectedModes);
  }
  throw new Error("Not implemented");
}

function usePredicate(model: Model): FilterPredicate {
  const extraPredicate = useExtraFilterPredicate();
  let memoFunc: () => FilterPredicate = () => null;
  const searchText = (model.searchText || "").trim().toLowerCase() || "";
  const needPredicate =
    searchText || ("rank" in model && model.rank) || ("kontenOnly" in model && model.kontenOnly) || extraPredicate;
  memoFunc = () =>
    needPredicate
      ? (game) => {
          if (!game.players.some((player) => player.nickname.toLowerCase().indexOf(searchText) > -1)) {
            return false;
          }
          if ("rank" in model) {
            if (model.rank && GameRecord.getRankIndexByPlayer(game, model.playerId) !== model.rank - 1) {
              return false;
            }
            if (model.kontenOnly && !game.players.every((x) => new Level(x.level).isKonten())) {
              return false;
            }
          }
          if (extraPredicate && !extraPredicate(game)) {
            return false;
          }
          return true;
        }
      : null;
  const memoDeps = [
    (model.type === undefined && model.selectedMode) || null,
    searchText,
    "rank" in model && model.rank,
    "kontenOnly" in model && model.kontenOnly,
    extraPredicate,
  ];
  // eslint-disable-next-line react-hooks/exhaustive-deps
  return useMemo(memoFunc, memoDeps);
}

function useDataAdapterCommon(
  dataProvider: DataProvider,
  onError: (error: Error | ApiError | false) => void,
  deps: React.DependencyList
) {
  const [dataAdapter, setDataAdapter] = useState(() => DUMMY_DATA_ADAPTER);
  const onErrorOnce = useMemo(() => {
    let called = false;
    return (error: Error | ApiError | false) => {
      if (!called) {
        called = true;
        onError(error);
      }
    };
  }, [onError]);
  const refreshDataAdapter = useCallback(
    (error?: Error | ApiError | false) => {
      if (error) {
        onErrorOnce(error);
        return;
      }
      const adapter = new DataAdapter(dataProvider);
      setDataAdapter(adapter);
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [dataProvider, onErrorOnce, ...deps]
  );
  useEffect(refreshDataAdapter, [refreshDataAdapter]);
  useEffect(() => {
    const adapter = dataAdapter;
    if (adapter instanceof DataAdapter) {
      return () => adapter.cancelUpdateHook();
    }
  }, [dataAdapter]);
  useEffect(() => {
    const adapter = dataAdapter;
    if (adapter instanceof DataAdapter) {
      adapter.setUpdateHook(refreshDataAdapter);
    }
  }, [dataAdapter, refreshDataAdapter]);
  useEffect(() => {
    try {
      // Preload metadata
      const result = dataProvider.getCountMaybeSync();
      if (result instanceof Promise) {
        result.catch((e) => onErrorOnce(e));
      }
    } catch (e) {
      onErrorOnce(e);
    }
  }, [dataProvider, onErrorOnce]);
  return {
    dataAdapter,
  };
}

export function DataAdapterProvider({ children }: { children: ReactChild | ReactChild[] }) {
  const [model, updateModel] = useModel();
  const [dataProviders] = useState(() => new Map<string, DataProvider>());
  const searchPredicate = usePredicate(model);
  const dataProviderVanilla = useMemo(() => {
    if (model.type === undefined && !model.selectedMode && Conf.availableModes.length > 1) {
      return DUMMY_DATA_PROVIDER;
    }
    const key = getProviderKey(model);
    if (!dataProviders.has(key)) {
      dataProviders.set(key, createProvider(model));
    }
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    return dataProviders.get(key)!;
  }, [model, dataProviders]);
  useEffect(() => dataProviderVanilla.setFilterPredicate(searchPredicate), [dataProviderVanilla, searchPredicate]);
  const dataProvider = useMemo(() => {
    if (!searchPredicate) {
      return dataProviderVanilla;
    }
    if (model.type !== "player") {
      return dataProviderVanilla;
    }
    if (!model.selectedModes?.length) {
      return dataProviderVanilla;
    }
    return DataProvider.createFilteredPlayer(
      model.playerId,
      async () => {
        await dataProviderVanilla.getCount();
        const ret = [];
        for (let i = 0; ; i++) {
          const item = await dataProviderVanilla.getItem(i);
          if (!item) {
            break;
          }
          ret.push(item);
        }
        return ret;
      },
      model.selectedModes
    );
  }, [searchPredicate, model, dataProviderVanilla]);
  const onError = useCallback(
    (e) => {
      if (e && "status" in e && e.status === 404) {
        if (model.type === "player") {
          if (model.startDate || model.endDate || model.limit) {
            if (Object.keys(dataProviders).length > 1) {
              // User changing settings, allow to continue
              networkError();
              return;
            }
            updateModel({
              type: "player",
              playerId: model.playerId,
              limit: null,
              startDate: null,
              endDate: null,
            });
            return;
          } else if (model.selectedModes.length) {
            updateModel({
              type: "player",
              playerId: model.playerId,
              selectedModes: [],
              limit: null,
              startDate: null,
              endDate: null,
            });
            return;
          }
          updateModel({ type: undefined, selectedMode: null });
          return;
        }
      }
      networkError();
      // updateModel(Model.removeExtraParams(model));
    },
    [model, updateModel, dataProviders]
  );
  const { dataAdapter } = useDataAdapterCommon(dataProvider, onError, [model, searchPredicate]);
  return <DataAdapterContext.Provider value={dataAdapter}>{children}</DataAdapterContext.Provider>;
}

export function DataAdapterProviderCustom({
  provider,
  children,
}: {
  provider: DataProvider;
  children: ReactChild | ReactChild[];
}) {
  const { dataAdapter } = useDataAdapterCommon(provider, noop, []);
  return <DataAdapterContext.Provider value={dataAdapter}>{children}</DataAdapterContext.Provider>;
}


================================================
FILE: src/components/gameRecords/extraFilterPredicate.tsx
================================================
/* eslint-disable @typescript-eslint/no-empty-function */
import React, { useContext, useMemo, useState } from "react";
import { FilterPredicate } from "../../data/source/records/provider";

const Context = React.createContext({
  extraFilterPredicate: null as FilterPredicate,
  setExtraFilterPredicate: (() => {}) as (predicate: FilterPredicate) => void,
});

export const useExtraFilterPredicate = () => useContext(Context).extraFilterPredicate;

export const useSetExtraFilterPredicate = () => useContext(Context).setExtraFilterPredicate;

export function ExtraFilterPredicateProvider({ children }: { children: React.ReactNode }) {
  const [extraFilterPredicate, setExtraFilterPredicate] = useState(() => null as FilterPredicate);
  const value = useMemo(() => ({ extraFilterPredicate, setExtraFilterPredicate }), [extraFilterPredicate]);
  return <Context.Provider value={value}>{children}</Context.Provider>;
}


================================================
FILE: src/components/gameRecords/filterPanel.tsx
================================================
import { useCallback } from "react";

import { useTranslation } from "react-i18next";

import { DatePicker } from "../form";
import { useModel } from "./model";
import dayjs from "dayjs";
import { ModeSelector } from "./modeSelector";
import Conf from "../../utils/conf";
import { GameMode } from "../../data/types";
import { Box } from "@mui/material";

const DEFAULT_DATE = dayjs().startOf("day");

export function FilterPanel() {
  const { t } = useTranslation();
  const [model, updateModel] = useModel();
  const setMode = useCallback((mode: GameMode[]) => updateModel({ selectedMode: mode[0] || null }), [updateModel]);
  const setDate = useCallback(
    (date: dayjs.ConfigType) => updateModel({ date: date ? dayjs(date).startOf("day") : date }),
    [updateModel]
  );
  if (model.type !== undefined) {
    return null;
  }
  return (
    <>
      <DatePicker fullWidth label={t("日期")} min={Conf.dateMin} date={model.date || DEFAULT_DATE} onChange={setDate} />
      {Conf.availableModes.length > 1 && (
        <Box mt={1}>
          <ModeSelector mode={model.selectedMode ? [model.selectedMode] : []} onChange={setMode} />
        </Box>
      )}
    </>
  );
}


================================================
FILE: src/components/gameRecords/gameLinkActions/dialog.tsx
================================================
import { ContentCopy, PieChartRounded, ReadMore, Replay, SvgIconComponent } from "@mui/icons-material";
import { Avatar, Dialog, List, ListItem, ListItemAvatar, ListItemButton, ListItemText } from "@mui/material";
import copy from "copy-to-clipboard";
import { useSnackbar } from "notistack";
import React, { AnchorHTMLAttributes, useCallback, useEffect, useReducer } from "react";
import { useTranslation } from "react-i18next";
import { GameRecord, PlayerRecord } from "../../../data/types";
import Conf from "../../../utils/conf";
import { generatePlayerPathById } from "../routeUtils";

const Action = ({
  Icon,
  text,
  ...props
}: {
  Icon: SvgIconComponent;
  text: string;
} & Parameters<typeof ListItemButton>[0] &
  AnchorHTMLAttributes<HTMLAnchorElement>) => (
  <ListItem disableGutters>
    <ListItemButton {...props}>
      <ListItemAvatar>
        <Avatar>
          <Icon fontSize="inherit" />
        </Avatar>
      </ListItemAvatar>
      <ListItemText primary={text} primaryTypographyProps={{ variant: "body2", sx: { pr: 1 } }} />
    </ListItemButton>
  </ListItem>
);
export const ActionsDialog = React.memo(
  ({ player, game, onClose }: { player?: PlayerRecord; game?: GameRecord; onClose: () => void }) => {
    const { t } = useTranslation();
    const { enqueueSnackbar } = useSnackbar();
    const [savedGame, updateGame] = useReducer(
      (prev: GameRecord | undefined, cur: GameRecord | undefined) => cur || prev,
      game,
      (game) => game
    );
    useEffect(() => {
      updateGame(game);
    }, [game]);
    const isMasked = !(game || savedGame)?.uuid || (game || savedGame)?._masked;
    const gameLink = !game ? "#" : (isMasked ? GameRecord.getMaskedRecordLink : GameRecord.getRecordLink)(game, player);
    const copyLink = useCallback(() => {
      if (!gameLink) {
        return;
      }
      copy(gameLink);
      enqueueSnackbar(t("链接复制成功"), { variant: "success", autoHideDuration: 2000 });
    }, [gameLink, enqueueSnackbar, t]);
    return (
      <Dialog open={!!game} onClose={onClose} onClick={onClose} maxWidth="xs">
        <List>
          <Action Icon={Replay} text={t("查看牌谱")} href={gameLink} target="_blank" />
          {!isMasked && <Action Icon={ContentCopy} onClick={copyLink} text={t("复制链接")} />}
          <Action
            Icon={ReadMore}
            text={t("玩家详细")}
            href={player?.accountId ? generatePlayerPathById(player.accountId) : "#"}
          />
          {Conf.features.aiReview && !isMasked && (
            <Action
              Icon={PieChartRounded}
              text={t("AI 检讨")}
              target="_blank"
              href={
                game && player
                  ? `${t("https://mjai.ekyu.moe/zh-cn.html")}?url=${encodeURIComponent(
                      GameRecord.getRecordLink(game, player)
                    )}`
                  : "#"
              }
            />
          )}
        </List>
      </Dialog>
    );
  }
);

export default ActionsDialog;


================================================
FILE: src/components/gameRecords/gameLinkActions/index.tsx
================================================
import React, { ReactNode, useCallback, useMemo, useState } from "react";
import { GameRecord, PlayerRecord } from "../../../data/types";
import Loadable from "../../misc/customizedLoadable";

const ActionsDialog = Loadable({
  loader: () => import(/* webpackMode: "lazy" */ /* webpackFetchPriority: "low" */ "./dialog"),
  loading: () => <></>,
});

const Context = React.createContext<{ open: (player: PlayerRecord, game: GameRecord) => void }>({
  open: () => {
    /* Placeholder */
  },
});

export const useGameLinkActions = () => React.useContext(Context);

const GameLinkActionsProvider = ({ children }: { children: ReactNode }) => {
  const [info, setInfo] = useState<{ player: PlayerRecord; game: GameRecord } | null>(null);
  const { player, game } = info || {};
  const open = useCallback(
    (player: PlayerRecord, game: GameRecord) => {
      setInfo({ player, game });
    },
    [setInfo]
  );
  const close = useCallback(() => setInfo(null), [setInfo]);
  const value = useMemo(() => ({ open }), [open]);
  return (
    <Context.Provider value={value}>
      <ActionsDialog player={player} game={game} onClose={close} />
      {children}
    </Context.Provider>
  );
};
export default GameLinkActionsProvider;


================================================
FILE: src/components/gameRecords/home.tsx
================================================
import { useTranslation } from "react-i18next";

import { FilterPanel } from "./filterPanel";
import { PlayerSearch } from "./playerSearch";
import { Box, Typography } from "@mui/material";
import Loadable from "../misc/customizedLoadable";
import { useModel } from "./model";
import Conf from "../../utils/conf";

const GameRecordTableHomeView = Loadable({
  loader: () => import("./tableViews").then((x) => ({ default: x.GameRecordTableHomeView })),
});

export default function Home() {
  const { t } = useTranslation("form");
  const [model] = useModel();
  return (
    <>
      <Typography variant="h4" mb={3} textAlign="center">
        {t("查找玩家")}
      </Typography>
      <Box mb={5}>
        <PlayerSearch />
      </Box>
      <Typography variant="h4" mb={3} textAlign="center">
        {t("对局浏览")}
      </Typography>
      <Box mb={5}>
        <FilterPanel />
      </Box>
      {(model.type === undefined && model.selectedMode) || Conf.availableModes.length <= 1 ? (
        <GameRecordTableHomeView />
      ) : null}
    </>
  );
}


================================================
FILE: src/components/gameRecords/index.tsx
================================================
import { ModelProvider } from "./model";
import Loadable from "../misc/customizedLoadable";

const Routes = Loadable({
  loader: () => import("./routes"),
});

export default function GameRecords() {
  return (
    <ModelProvider>
      <Routes />
    </ModelProvider>
  );
}


================================================
FILE: src/components/gameRecords/modeSelector.tsx
================================================
import React, { useMemo } from "react";

import { CheckboxGroup } from "../form";
import { GameMode, modeLabelNonTranslated } from "../../data/types";
import Conf from "../../utils/conf";
import { useTranslation } from "react-i18next";

export function ModeSelector({
  mode,
  onChange,
  label = "",
  type = "radio",
  availableModes = Conf.availableModes,
  i18nNamespace = undefined,
}: {
  mode: GameMode[];
  onChange: (x: GameMode[]) => void;
  label?: string;
  type?: "checkbox" | "radio";
  availableModes?: GameMode[];
  i18nNamespace?: string | string[] | undefined;
}) {
  useTranslation();
  const items = useMemo(
    () =>
      availableModes.map((x) => ({
        key: String(x),
        label: modeLabelNonTranslated(x),
        value: x,
      })),
    [availableModes]
  );
  if (items.length < 1) {
    return null;
  }
  return (
    <CheckboxGroup
      type={type}
      label={label}
      items={items}
      selectedItems={mode.map((x) => x.toString())}
      onChange={(newItems) => onChange(newItems.map((x) => x.value))}
      i18nNamespace={i18nNamespace}
    />
  );
}


================================================
FILE: src/components/gameRecords/model.tsx
================================================
/* eslint-disable @typescript-eslint/no-empty-function */
import dayjs from "dayjs";
import React, { useReducer, useContext, ReactChild, useMemo } from "react";
import { useHistory } from "react-router";
import { useEventCallback } from "../../utils";
import { generatePath } from "./routeUtils";
import { GameMode } from "../../data/types";

export interface ListingModel {
  type: undefined;
  date: dayjs.ConfigType | null;
  selectedMode: GameMode | null;
  searchText: string;
}
export interface PlayerModel {
  type: "player";
  playerId: string;
  startDate: dayjs.ConfigType | null;
  endDate: dayjs.ConfigType | null;
  selectedModes: GameMode[];
  searchText: string;
  rank: number | null;
  kontenOnly: boolean;
  limit: number | null;
}
export type Model = ListingModel | PlayerModel;
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const Model = Object.freeze({
  removeExtraParams(model: Model): Model {
    if (model.type === "player") {
      return {
        type: "player",
        playerId: model.playerId,
        selectedModes: [],
        startDate: null,
        endDate: null,
        searchText: "",
        rank: null,
        kontenOnly: false,
        limit: null,
      };
    }
    return {
      type: undefined,
      searchText: "",
      selectedMode: null,
      date: null,
    };
  },
  hasAdvancedParams(model: Model): boolean {
    return Boolean("rank" in model && (model.searchText || model.rank || model.kontenOnly));
  },
});
type ModelUpdate = Partial<ListingModel> | ({ type: "player" } & Partial<PlayerModel>);
type DispatchModelUpdate = (props: ModelUpdate) => void;

const DEFAULT_MODEL: ListingModel = { type: undefined, date: null, selectedMode: null, searchText: "" };
const ModelContext = React.createContext<[Readonly<Model>, DispatchModelUpdate]>([DEFAULT_MODEL, () => {}]);
export const useModel = () => useContext(ModelContext);

function normalizeUpdate(newProps: ModelUpdate): ModelUpdate {
  if (newProps.type === undefined) {
    if (newProps.date) {
      const isDateOnly = typeof newProps.date === "string" && !/^\d{6,}$/.test(newProps.date);
      newProps.date = isDateOnly ? dayjs(newProps.date).startOf("date").valueOf() : dayjs(newProps.date).valueOf();
    }
  }
  for (const key of Object.keys(newProps)) {
    if (key !== "type" && newProps[key as keyof typeof newProps] === undefined) {
      delete newProps[key as keyof typeof newProps];
    }
  }
  return newProps;
}
function isSameModel(a: Model, b: Model): boolean {
  return generatePath(a) === generatePath(b);
}

const OnRouteModelUpdatedContext = React.createContext((() => {}) as (model: Model) => void);
export const useOnRouteModelUpdated = () => useContext(OnRouteModelUpdatedContext);

export function ModelProvider({ children }: { children: ReactChild | ReactChild[] }) {
  const history = useHistory();
  const [model, setModel] = useReducer(
    (oldModel: Model, newModel: Model): Readonly<Model> => {
      if (isSameModel(oldModel, newModel)) {
        return oldModel;
      }
      return Object.freeze(newModel);
    },
    undefined,
    () => Object.freeze(DEFAULT_MODEL as Model)
  );
  const dispatchModelUpdate = useEventCallback(
    (newProps: ModelUpdate) => {
      const newModel = {
        ...((model.type === newProps.type ? model : {}) as Model),
        ...(normalizeUpdate(newProps) as Model),
      };
      if (newModel.type === "player" && (!newModel.selectedModes || !newModel.selectedModes.length)) {
        if (
          model.type === undefined &&
          model.selectedMode &&
          (!newModel.selectedModes || !newModel.selectedModes.length)
        ) {
          newModel.selectedModes = [model.selectedMode];
        } else {
          newModel.selectedModes = [];
        }
      }
      if (isSameModel(model, newModel)) {
        return;
      }
      history.replace(generatePath(newModel));
    },
    [model, history]
  );
  const value = useMemo(
    () => [model, dispatchModelUpdate] as [Readonly<Model>, DispatchModelUpdate],
    [model, dispatchModelUpdate]
  );
  return (
    <ModelContext.Provider value={value}>
      <OnRouteModelUpdatedContext.Provider value={setModel}>{children}</OnRouteModelUpdatedContext.Provider>
    </ModelContext.Provider>
  );
}


================================================
FILE: src/components/gameRecords/player.tsx
================================================
import { PieChartRounded, ReadMore } from "@mui/icons-material";
import { Link, Typography, TypographyProps, useTheme } from "@mui/material";
import React from "react";
import { useTranslation } from "react-i18next";

import { GameRecord, PlayerRecord, getLevelTag } from "../../data/types";
import Conf from "../../utils/conf";
import { useGameLinkActions } from "./gameLinkActions";
import { generatePlayerPathById } from "./routeUtils";

export const Player = React.memo(function ({
  player,
  game,
  hideDetailIcon,
  showAiReviewIcon,
  maskedGameLink,
  ...props
}: {
  player: PlayerRecord;
  game: GameRecord;
  hideDetailIcon?: boolean;
  showAiReviewIcon?: boolean;
  maskedGameLink?: boolean;
} & TypographyProps) {
  const { t } = useTranslation();
  const theme = useTheme();
  const { open } = useGameLinkActions();
  const { nickname, level, score, accountId } = player;
  const isTop = GameRecord.getRankIndexByPlayer(game, player) === 0;
  return (
    <Typography
      variant="body2"
      component="span"
      fontWeight={isTop ? "bold" : "normal"}
      display="inline-flex"
      alignItems="center"
      color={theme.palette.info.main}
      {...props}
    >
      <Link
        href={maskedGameLink || !game.uuid || game._masked ? "#" : GameRecord.getRecordLink(game, player)}
        onClick={(e) => {
          e.preventDefault();
          open(player, game);
        }}
        title={t("查看牌谱")}
        target="_blank"
        rel="noopener noreferrer"
        display="block"
        color="inherit"
      >
        [{getLevelTag(level)}] {nickname} {score !== undefined && `[${score}]`}
      </Link>
      {!hideDetailIcon && (
        <Link
          className="detail-link"
          title={t("玩家详细")}
          href={generatePlayerPathById(accountId)}
          display="block"
          color="inherit"
        >
          <ReadMore fontSize="small" sx={{ ml: 1, display: "block" }} />
        </Link>
      )}
      {Conf.features.aiReview && game.uuid && showAiReviewIcon && (
        <Link
          className="detail-link"
          title={t("AI 检讨")}
          target="_blank"
          rel="noopener noreferrer"
          href={`${t("https://mjai.ekyu.moe/zh-cn.html")}?url=${encodeURIComponent(
            GameRecord.getRecordLink(game, player)
          )}`}
          display="block"
          color="inherit"
        >
          <PieChartRounded fontSize="small" sx={{ ml: 1, display: "block" }} />
        </Link>
      )}
    </Typography>
  );
});


================================================
FILE: src/components/gameRecords/playerSearch.tsx
================================================
import React from "react";
import { useEffect, useState, useMemo } from "react";

import { LevelWithDelta, Level, getAccountZone } from "../../data/types";
import { searchPlayer, PlayerSearchResult } from "../../data/source/misc";
import { Redirect } from "react-router-dom";
import { generatePlayerPathById } from "./routeUtils";
import { useTranslation } from "react-i18next";
import { Autocomplete, CircularProgress, TextField } from "@mui/material";
import { networkError } from "../../utils/notify";
import Conf, { CONFIGURATIONS } from "../../utils/conf";
import Loading from "../misc/loading";

type PlayerSearchResultExt = PlayerSearchResult & {
  isDeleted?: boolean;
};

const playerSearchCache = new Map<string, PlayerSearchResultExt[] | Promise<PlayerSearchResultExt[]>>();
const NUM_FETCH = 20;

const normalizeName = (s: string) => s.toLowerCase().trim();

function findRawResultFromCache(prefix: string): { result: PlayerSearchResultExt[]; isExactMatch: boolean } | null {
  const normalizedPrefix = normalizeName(prefix);
  prefix = normalizedPrefix;
  while (prefix) {
    const players = playerSearchCache.get(prefix);
    if (!players || players instanceof Promise) {
      prefix = prefix.slice(0, prefix.length - 1);
      continue;
    }
    return {
      isExactMatch: prefix === normalizedPrefix,
      result: players,
    };
  }
  return null;
}

function getCrossSiteConf(x: PlayerSearchResultExt) {
  if (Conf.availableModes.length > 1) {
    const level = new Level(x.level.id);
    if (!Conf.availableModes.some((mode) => level.isAllowedMode(mode))) {
      return level.getNumPlayerId() === 2 ? CONFIGURATIONS.ikeda : CONFIGURATIONS.DEFAULT;
    }
  }
  return null;
}
function getOptionLabel(x: PlayerSearchResultExt, t: (x: string) => string): string {
  let ret = `[${LevelWithDelta.getTag(x.level)}] ${x.nickname}`;
  const conf = getCrossSiteConf(x);
  if (conf) {
    ret = `[${conf.rankColors.length === 3 ? t("三麻") : t("四麻")}] ${ret}`;
  }
  return ret;
}
export function PlayerSearch() {
  const { t } = useTranslation("form");
  const [selectedItem, setSelectedItem] = useState(null as PlayerSearchResultExt | null);
  const [version, setVersion] = useState(0);
  const [searchText, setSearchText] = useState("");
  const [open, setOpen] = React.useState(false);
  const [players, isLoading] = useMemo(() => {
    if (!searchText) {
      return [[], false];
    }
    const cachedResult = findRawResultFromCache(searchText);
    if (!cachedResult) {
      return [[], true];
    }
    if (cachedResult.isExactMatch) {
      return [cachedResult.result, false];
    }
    const normalizedPrefix = normalizeName(searchText);
    let mayHaveMore = cachedResult.result.length >= NUM_FETCH;
    const filteredPlayers = [] as PlayerSearchResultExt[];
    cachedResult.result.forEach((player) => {
      if (normalizeName(player.nickname).startsWith(normalizedPrefix)) {
        filteredPlayers.push(player);
      } else if (filteredPlayers.length) {
        // Result covers all players who have the specified prefix
        mayHaveMore = false;
      }
    });
    return [filteredPlayers, mayHaveMore];
  }, [searchText, version]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (!searchText.trim()) {
      return;
    }
    const prefix = normalizeName(searchText);
    if (playerSearchCache.has(prefix)) {
      return;
    }
    if (!isLoading) {
      return;
    }
    let cancelled = false;
    let debounceToken: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
      debounceToken = undefined;
      if (cancelled) {
        return;
      }
      if (playerSearchCache.has(prefix)) {
        return;
      }
      const promise = searchPlayer(prefix, NUM_FETCH).then(function (players: PlayerSearchResultExt[]) {
        players.forEach((x) => {
          x.isDeleted = players.some(
            (y) =>
              x.nickname === y.nickname &&
              getAccountZone(x.id) === getAccountZone(y.id) &&
              x.latest_timestamp < y.latest_timestamp
          );
        });
        playerSearchCache.set(prefix, players);
        if (!cancelled) {
          setVersion(new Date().getTime());
        }
        return players;
      });
      playerSearchCache.set(prefix, promise);
      promise.catch((e) => {
        console.error(e);
        playerSearchCache.delete(prefix);
        networkError();
      });
    }, 500);
    return () => {
      cancelled = true;
      if (debounceToken) {
        clearTimeout(debounceToken);
      }
    };
  }, [searchText, isLoading]);
  if (selectedItem) {
    const crossSiteConf = getCrossSiteConf(selectedItem);
    if (crossSiteConf) {
      location.href = `https://${crossSiteConf.canonicalDomain}${generatePlayerPathById(selectedItem.id)}`;
      return <Loading />;
    }
    return <Redirect to={generatePlayerPathById(selectedItem.id)} push />;
  }
  return (
    <Autocomplete
      fullWidth
      blurOnSelect
      open={open && !!searchText.trim()}
      onOpen={() => {
        setOpen(true);
      }}
      onClose={() => {
        setOpen(false);
      }}
      inputValue={searchText}
      onInputChange={(_, value, reason) => setSearchText(reason === "reset" ? "" : value)}
      onChange={(_, value, reason) => reason === "selectOption" && setSelectedItem(value)}
      options={players}
      getOptionLabel={(x) => getOptionLabel(x, t)}
      renderOption={(props, option) => {
        const { key, ...otherProps } = props as typeof props & { key: string };
        return (
          <li key={key} {...otherProps}>
            <span style={option.isDeleted ? { textDecoration: "line-through", color: "#888" } : {}}>
              {" "}
              {getOptionLabel(option, t)}
            </span>
          </li>
        );
      }}
      isOptionEqualToValue={(option, value) => option.id === value.id}
      loading={isLoading}
      filterOptions={(x) => x}
      renderInput={(params) => (
        <TextField
          {...params}
          label={t("名字")}
          InputProps={{
            ...params.InputProps,
            endAdornment: (
              <React.Fragment>{isLoading ? <CircularProgress color="inherit" size={20} /> : null}</React.Fragment>
            ),
          }}
        />
      )}
    />
  );
}


================================================
FILE: src/components/gameRecords/routeSync.tsx
================================================
import React from "react";
import dayjs from "dayjs";

import { useParams, useLocation, Redirect } from "react-router";
import { Model, useOnRouteModelUpdated } from "./model";
import { useEffect } from "react";
import { scrollToTop, triggerRelayout } from "../../utils/index";
import Conf from "../../utils/conf";
import { parseCombinedMode } from "../../data/types";

type ListingRouteParams = {
  date?: string;
  mode?: string;
  search?: string;
};

type PlayerRouteParams = {
  id: string;
  startDate?: string;
  endDate?: string;
  mode?: string;
  search?: string;
  rank?: string;
  kontenOnly?: string;
  limit?: string;
};

function parseOptionalDate<T>(
  s: string | null | undefined,
  defaultValue: T,
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  postprocess = (d: dayjs.Dayjs, isPrecise: boolean) => d
): dayjs.Dayjs | T {
  if (!s) {
    return defaultValue;
  }
  const isPrecise = /^\d{6,}$/.test(s);
  const ret = isPrecise ? dayjs(parseInt(s, 10)) : dayjs(s);
  if (!ret.isValid()) {
    return defaultValue;
  }
  return postprocess(ret, isPrecise);
}

const ModelBuilders = {
  player(params: PlayerRouteParams): Model | string {
    if (params.rank) {
      const rank = parseInt(params.rank);
      if (!rank || rank < 1 || rank > Conf.rankColors.length) {
        delete params.rank;
      }
    }
    const selectedModes = parseCombinedMode(params.mode || "");
    if (!selectedModes.length && Conf.availableModes.length > 1) {
      delete params.limit;
    }
    if (params.limit) {
      delete params.startDate;
      delete params.endDate;
    }
    return {
      type: "player",
      playerId: params.id,
      startDate: parseOptionalDate(params.startDate, null),
      endDate: parseOptionalDate(params.endDate, null, (d, isPrecise) => (isPrecise ? d : d.endOf("day"))),
      selectedModes,
      searchText: params.search ? params.search.slice(1) : "",
      rank: parseInt(params.rank || "") || null,
      kontenOnly: !!params.kontenOnly,
      limit: parseInt(params.limit || "", 10) || null,
    };
  },
  listing(params: ListingRouteParams): Model | string {
    const date = parseOptionalDate(params.date, null);
    if (date && !date.isValid()) {
      return "/";
    }
    return {
      type: undefined,
      date: date ? date.startOf("day").valueOf() : null,
      selectedMode: parseCombinedMode(params.mode || "")[0] || null,
      searchText: params.search || "",
    };
  },
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function RouteSync({ view }: { view: keyof typeof ModelBuilders }): React.FunctionComponentElement<any> {
  useEffect(() => {
    triggerRelayout();
    scrollToTop();
    return scrollToTop;
  }, []);
  const onRouteModelUpdated = useOnRouteModelUpdated();
  const params = useParams();
  const location = useLocation();
  const query = new URLSearchParams(location.search);
  Object.assign(params, {
    rank: query.get("rank"),
    kontenOnly: query.get("kontenOnly"),
    limit: query.get("limit"),
  });
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const modelResult = ModelBuilders[view](params as any);
  useEffect(() => {
    if (typeof modelResult !== "string") {
      onRouteModelUpdated(modelResult);
    }
  }, [modelResult, onRouteModelUpdated]);
  if (typeof modelResult === "string") {
    return <Redirect to={modelResult} />;
  }
  return <></>;
}


================================================
FILE: src/components/gameRecords/routeUtils.tsx
================================================
import { generatePath as genPath } from "react-router-dom";
import { Model } from "./model";
import dayjs from "dayjs";

export const PLAYER_PATH =
  "/player/:id/:mode([0-9.]+)?/:search(-[^/]+)?/:startDate(\\d{4}-\\d{2}-\\d{2}|\\d{6,})?/:endDate(\\d{4}-\\d{2}-\\d{2}|\\d{6,})?";
export const PATH = "/:date(\\d{4}-\\d{2}-\\d{2})/:mode([0-9]+)?/:search?";
function dateToStringSafe(value: dayjs.ConfigType | null | undefined): string | undefined {
  if (!value) {
    return undefined;
  }
  const dateObj = dayjs(value);
  if (!dateObj.isValid() || dateObj.year() < 2019 || dateObj.year() > 9999) {
    return undefined;
  }
  if (
    dateObj.valueOf() - dateObj.startOf("day").valueOf() > 0 &&
    dateObj.endOf("day").valueOf() - dateObj.valueOf() > 60000
  ) {
    return dateObj.valueOf().toString();
  }
  return dateObj.format("YYYY-MM-DD");
}

export function generatePath(model: Model): string {
  if (model.type === "player") {
    if (model.limit) {
      delete model.startDate;
      delete model.endDate;
    }
    let result = genPath(PLAYER_PATH, {
      id: model.playerId,
      startDate: dateToStringSafe(model.startDate),
      endDate: dateToStringSafe(model.endDate),
      mode: model.selectedModes.join(".") || undefined,
      search: model.searchText ? "-" + model.searchText : undefined,
    } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
    const params = new URLSearchParams("");
    if (model.rank) {
      params.set("rank", model.rank.toString());
    }
    if (model.kontenOnly) {
      params.set("kontenOnly", "1");
    }
    if (model.limit) {
      params.set("limit", model.limit.toString());
    }
    const paramString = params.toString();
    if (paramString) {
      result += "?" + paramString;
    }
    return result;
  }
  if (!model.selectedMode && !model.searchText && !model.date) {
    return "/";
  }
  const dateString = dateToStringSafe(model.date || dayjs().startOf("day"));
  if (!dateString) {
    return "/";
  }
  return genPath(PATH, {
    date: dateString,
    mode: model.selectedMode || undefined,
    search: model.searchText || undefined,
  } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
}
export function generatePlayerPathById(playerId: number | string): string {
  return generatePath({
    type: "player",
    playerId: playerId.toString(),
    startDate: null,
    endDate: null,
    selectedModes: [],
    searchText: "",
    rank: null,
    kontenOnly: false,
    limit: null,
  });
}


================================================
FILE: src/components/gameRecords/routes.tsx
================================================
import { Switch, Route, Redirect } from "react-router-dom";

import { RouteSync } from "./routeSync";
import Loadable from "../misc/customizedLoadable";
import { PageCategory } from "../misc/tracker";
import Home from "./home";
import { ExtraFilterPredicateProvider } from "./extraFilterPredicate";
import { DataAdapterProvider } from "./dataAdapterProvider";
import { PLAYER_PATH, PATH } from "./routeUtils";

const PlayerDetails = Loadable({
  loader: () => import("../playerDetails/playerDetails"),
});
const GameRecordTablePlayerView = Loadable({
  loader: () => import("./tableViews").then((x) => ({ default: x.GameRecordTablePlayerView })),
});

function Routes() {
  return (
    <Switch>
      <Route path={PLAYER_PATH}>
        <RouteSync view="player" />
        <PageCategory category="Player" />
        <ExtraFilterPredicateProvider>
          <DataAdapterProvider>
            <PlayerDetails />
            <GameRecordTablePlayerView />
          </DataAdapterProvider>
        </ExtraFilterPredicateProvider>
      </Route>
      <Route exact path={["/", PATH]}>
        <RouteSync view="listing" />
        <PageCategory category="Listing" />
        <DataAdapterProvider>
          <Home />
        </DataAdapterProvider>
      </Route>
      <Route>
        <Redirect to="/" />
      </Route>
    </Switch>
  );
}
export default Routes;


================================================
FILE: src/components/gameRecords/table.tsx
================================================
import React, { useCallback, useEffect, useMemo } from "react";
import { Index } from "react-virtualized";
import { ColumnProps, Table } from "react-virtualized/dist/es/Table";
import { AutoSizer } from "react-virtualized/dist/es/AutoSizer";
import clsx from "clsx";

import { useScrollerProps } from "../misc/scroller";
import { useDataAdapter } from "./dataAdapterProvider";
import { triggerRelayout, useIsMobile } from "../../utils/index";
import Loading from "../misc/loading";
import { useTranslation } from "react-i18next";
import { TableColumnDef } from "./columns";
import { Box, styled, useTheme } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";

export { Column } from "react-virtualized/dist/es/Table";

const StyledTableContainer = styled(Box)(({ theme }) => ({
  ...theme.typography.body2,

  [theme.breakpoints.down("sm")]: {
    ".MuiBox-root, .MuiTypography-root, .ReactVirtualized__Table__rowColumn, .ReactVirtualized__Table__headerColumn": {
      fontSize: "0.85rem",
      margin: "0 2px",
    },
  },
}));

export default function GameRecordTable({ columns }: { columns: TableColumnDef[] }) {
  const { i18n } = useTranslation();
  const data = useDataAdapter();
  const scrollerProps = useScrollerProps();
  const { isScrolling, onChildScroll, scrollTop, height, registerChild } = scrollerProps;
  const rowGetter = useCallback(({ index }: Index) => data.getItem(index), [data]);
  const getRowClassName = useCallback(
    ({ index }: Index) => (index >= 0 ? clsx({ loading: !data.isItemLoaded(index), even: (index & 1) === 0 }) : ""),
    [data]
  );
  const noRowsRenderer = useCallback(() => (data.hasCount() ? null : <Loading />), [data]);
  const unfilteredCount = data.getUnfilteredCount();
  const shouldTriggerLayout = !!unfilteredCount;
  const isMobile = useIsMobile();
  const theme = useTheme();
  const isMd = useMediaQuery(theme.breakpoints.up("md"));
  useEffect(() => {
    triggerRelayout();
  }, [shouldTriggerLayout]);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  const memoColumns = useMemo(
    () =>
      columns
        .map((x) => x())
        .filter((x) => x)
        .map((x) => {
          if (!isMobile) {
            return x;
          }
          const props = x && (x.props as unknown as ColumnProps);
          if (!props) {
            return x;
          }
          if (props.columnData?.mobileProps) {
            return React.cloneElement(x, { ...props, ...props.columnData?.mobileProps });
          }
          return x;
        }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [
      // eslint-disable-next-line react-hooks/exhaustive-deps
      isMobile,
      // eslint-disable-next-line react-hooks/exhaustive-deps
      i18n.language,
      // eslint-disable-next-line react-hooks/exhaustive-deps
      ...columns.map((x) => x.key || x),
    ]
  );
  if (data.hasCount() && !data.getCount()) {
    return <></>;
  }
  return (
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    <StyledTableContainer ref={registerChild as any}>
      <AutoSizer disableHeight>
        {({ width }) => (
          <Table
            autoHeight
            rowCount={data.getCount()}
            rowGetter={rowGetter}
            rowHeight={isMd ? 70 : !isMobile ? 140 : 100}
            headerHeight={50}
            width={width}
            height={height}
            isScrolling={isScrolling}
            onScroll={onChildScroll}
            scrollTop={scrollTop}
            rowClassName={getRowClassName}
            noRowsRenderer={noRowsRenderer}
          >
            {memoColumns}
          </Table>
        )}
      </AutoSizer>
    </StyledTableContainer>
  );
}


================================================
FILE: src/components/gameRecords/tableViews.tsx
================================================
import { useModel } from "./model";

import { default as GameRecordTable } from "./table";
import {
  COLUMN_RANK,
  COLUMN_GAMEMODE,
  COLUMN_PLAYERS,
  COLUMN_FULLTIME,
  COLUMN_STARTTIME,
  COLUMN_ENDTIME,
} from "./columns";

export function GameRecordTablePlayerView() {
  const [model] = useModel();
  if (!("playerId" in model)) {
    return null;
  }
  return (
    <GameRecordTable
      columns={[
        COLUMN_GAMEMODE,
        COLUMN_RANK(model.playerId),
        COLUMN_PLAYERS({ activePlayerId: model.playerId }),
        COLUMN_FULLTIME,
      ]}
    />
  );
}

export function GameRecordTableHomeView() {
  return (
    <GameRecordTable
      columns={[COLUMN_GAMEMODE, COLUMN_PLAYERS({ maskedGameLink: true }), COLUMN_STARTTIME, COLUMN_ENDTIME]}
    />
  );
}


================================================
FILE: src/components/layout/container.tsx
================================================
import { ReactNode } from "react";

import { Container as MuiContainer, Typography } from "@mui/material";

export const Container = ({ title = undefined, children = undefined as ReactNode }) => (
  <MuiContainer sx={{ my: 5 }}>
    {title && (
      <Typography variant="h2" sx={{ mb: 4 }}>
        {title}
      </Typography>
    )}
    {children}
  </MuiContainer>
);


================================================
FILE: src/components/layout/index.tsx
================================================
export * from "./container";

================================================
FILE: src/components/misc/alert.tsx
================================================
import { useState, useEffect, ReactNode } from "react";
import React from "react";
import { ReactComponentLike } from "prop-types";
import { triggerRelayout } from "../../utils/index";
import { Alert as MuiAlert, AlertColor, AlertTitle, Fade, AlertProps } from "@mui/material";
import { loadPreference, savePreference } from "../../utils/preference";

export function Alert({
  type = "info" as AlertColor,
  container = React.Fragment as ReactComponentLike,
  stateName = "",
  closable = true,
  title = "",
  children = undefined as ReactNode,
  sx = { mb: 2 } as AlertProps["sx"],
}) {
  const stateKey = `alertState_${stateName}`;
  const [closed, setClosed] = useState(() => stateName && loadPreference(stateKey, false));
  useEffect(() => {
    if (stateName && closed) {
      savePreference(stateKey, true);
    }
  }, [closed, stateName, stateKey]);
  if (closed && closable) {
    return null;
  }
  const Cont = container;
  return (
    <Cont>
      <Fade in={!closed} onEntering={() => triggerRelayout()} onExited={() => triggerRelayout()}>
        <MuiAlert
          severity={type}
          onClose={closable ? () => setClosed(true) : undefined}
          sx={{ "& .MuiAlert-message": { overflow: "unset" }, ...sx }}
        >
          {title && <AlertTitle>{title} </AlertTitle>}
          {children}
        </MuiAlert>
      </Fade>
    </Cont>
  );
}


================================================
FILE: src/components/misc/canonicalLink.tsx
================================================
import React from "react";
import { Helmet } from "react-helmet";
import { useLocation } from "react-router";
import Conf from "../../utils/conf";

export default function CanonicalLink() {
  const loc = useLocation();
  return (
    <Helmet>
      <link rel="canonical" href={`https://${Conf.canonicalDomain}${loc.pathname}`} />
    </Helmet>
  );
}


================================================
FILE: src/components/misc/customizedLoadable.tsx
================================================
import React, { ComponentType, ReactNode, Suspense } from "react";
import Loading from "./loading";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function CustomizedLoadable<T extends ComponentType<any>>({
  loader,
  loading = () => <Loading />,
}: {
  loader: () => Promise<{ default: T }>;
  loading?: () => ReactNode;
}): React.ComponentType<T extends ComponentType<infer TProps> ? TProps : unknown> {
  const LazyComponent = React.lazy(loader);

  return function (props: T extends ComponentType<infer TProps> ? TProps : unknown) {
    return (
      <Suspense fallback={loading() || null}>
        <LazyComponent {...props} />
      </Suspense>
    );
  };
}

export default CustomizedLoadable;


================================================
FILE: src/components/misc/linkBehavior.tsx
================================================
import React from "react";
import { Link, LinkProps } from "react-router-dom";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const LinkBehavior = React.forwardRef<any, Omit<LinkProps, "to"> & { href: LinkProps["to"]; }>((props, ref) => {
  const { href, ...other } = props;
  if (!href) {
    return <span ref={ref} {...other} />;
  }
  if (typeof href === "string" && /^https?:\/\//i.test(href)) {
    return <a ref={ref} href={href} {...other} />;
  }
  return <Link ref={ref} to={href} {...other} />;
});


================================================
FILE: src/components/misc/loading.tsx
================================================
import { Box, CircularProgress } from "@mui/material";

export default function Loading({ size = "normal" }: { size?: "normal" | "small" }) {
  return (
    <Box m={size === "normal" ? 5 : 1} display="flex" justifyContent="center">
      <CircularProgress size={size === "normal" ? 40 : 20} />
    </Box>
  );
}


================================================
FILE: src/components/misc/menuButton.tsx
================================================
import React, { ReactElement, ReactNode } from "react";
import { Button, Menu, MenuItemProps, ButtonProps } from "@mui/material";

export function MenuButton({
  label,
  children,
  ...props
}: {
  label: ReactNode;
  children: ReactElement<MenuItemProps>[];
} & ButtonProps) {
  const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
  const open = Boolean(anchorEl);
  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    setAnchorEl(event.currentTarget);
  };
  const handleClose = () => {
    setAnchorEl(null);
  };
  const handleItemClick = (item: ReactElement<MenuItemProps>) => {
    const onClick = item.props.onClick;
    if (!onClick) {
      return handleClose;
    }
    return (e: React.MouseEvent<HTMLLIElement>) => {
      handleClose();
      onClick(e);
    };
  };
  return (
    <>
      <Button {...props} onClick={handleClick}>
        {label}
      </Button>
      <Menu
        open={open}
        onClose={handleClose}
        anchorEl={anchorEl}
        anchorOrigin={{
          vertical: "bottom",
          horizontal: "center",
        }}
        transformOrigin={{
          vertical: "top",
          horizontal: "center",
        }}
        disableScrollLock
      >
        {React.Children.map(children, (x) => React.cloneElement(x, { onClick: handleItemClick(x) }))}
      </Menu>
    </>
  );
}


================================================
FILE: src/components/misc/navButton.tsx
================================================
/* eslint-disable @typescript-eslint/indent */
import { Button, ButtonProps } from "@mui/material";
import { NavLink, NavLinkProps } from "react-router-dom";

const InnerButton = ({
  navigate,
  href,
  activeProps,
  children,
  ...props
}: ButtonProps<"a"> & { navigate: () => void; activeProps?: ButtonProps<"a"> }) => {
  return (
    <Button
      LinkComponent="a"
      href={href || "#"}
      onClick={(e) => {
        e.preventDefault();
        navigate();
      }}
      {...props}
      {...(activeProps && props["aria-current"] ? activeProps : {})}
    >
      {children}
    </Button>
  );
};

const NavButton = ({
  href,
  children,
  ...props
}: Omit<ButtonProps<"a">, "href"> &
  Omit<NavLinkProps, "to" | "href"> & { href: NavLinkProps["to"]; activeProps?: ButtonProps<"a"> }) => (
  <NavLink component={InnerButton} to={href} activeClassName="active" {...props}>
    {children}
  </NavLink>
);

export default NavButton;


================================================
FILE: src/components/misc/scroller.tsx
================================================
import React, { ReactChild, useContext } from "react";

import { WindowScrollerChildProps } from "react-virtualized";
import { WindowScroller } from "react-virtualized/dist/es/WindowScroller";

const ScrollerContext = React.createContext<WindowScrollerChildProps>({} as WindowScrollerChildProps);

export const useScrollerProps = () => useContext(ScrollerContext);

function Scroller({ children }: { children: ReactChild | ReactChild[] }) {
  return (
    <WindowScroller>
      {scrollerProps => <ScrollerContext.Provider value={scrollerProps}>{children}</ScrollerContext.Provider>}
    </WindowScroller>
  );
}
export default Scroller;


================================================
FILE: src/components/misc/tracker.tsx
================================================
import { useLocation } from "react-router";
import { useEffect, useLayoutEffect } from "react";
import Helmet from "react-helmet";
import { canTrackUser } from "../../utils/conf";

let currentCategory = "Home";

type Ga = NonNullable<typeof window.ga>;

declare global {
  interface Window {
    __loadGa?: () => Ga;
  }
}

export function PageCategory({ category }: { category: string }) {
  useLayoutEffect(() => {
    const oldCategory = currentCategory;
    currentCategory = category;
    return () => {
      currentCategory = oldCategory;
    };
  }, [category]);
  return null;
}

function TrackerImpl() {
  const loc = useLocation();
  useEffect(() => {
    let cancelled = false;
    window.requestAnimationFrame(() => {
      if (cancelled) {
        return;
      }
      const helmet = Helmet.peek();
      const title = (helmet.title || document.title).toString();
      if (window.ga) {
        window.ga("send", {
          hitType: "pageview",
          page: loc.pathname,
          title: `${currentCategory} ${title}`,
          contentGroup1: currentCategory,
        });
      }
    });
    return () => {
      cancelled = true;
    };
  }, [loc.pathname]);
  return null;
}

export default function Tracker() {
  if (process.env.NODE_ENV !== "production") {
    return null;
  }
  if (!canTrackUser()) {
    return null;
  }
  if (!window.__loadGa) {
    return null;
  }
  window.__loadGa();
  return <TrackerImpl />;
}


================================================
FILE: src/components/modeModel/index.tsx
================================================
export { ModelModeProvider, useModel } from "./model";
export { default as ModelModeSelector } from "./modelModeSelector";


================================================
FILE: src/components/modeModel/model.tsx
================================================
import React, { useReducer, useContext, ReactChild } from "react";
import { useMemo } from "react";
import { GameMode } from "../../data/types";

export interface Model {
  selectedModes: GameMode[];
  careerRankingMinGames?: number;
}

type ModelUpdate = Partial<Model>;
type DispatchModelUpdate = (props: ModelUpdate) => void;

const DEFAULT_MODEL: Model = { selectedModes: [] };
// eslint-disable-next-line @typescript-eslint/no-empty-function
const ModelContext = React.createContext<[Readonly<Model>, DispatchModelUpdate]>([{ ...DEFAULT_MODEL }, () => {}]);
export const useModel = () => useContext(ModelContext);

export function ModelModeProvider({ children }: { children: ReactChild | ReactChild[] }) {
  const [model, updateModel] = useReducer(
    (oldModel: Model, newProps: ModelUpdate): Model => ({
      ...oldModel,
      ...newProps,
    }),
    null,
    (): Model => ({
      ...DEFAULT_MODEL,
    })
  );
  const value: [Model, DispatchModelUpdate] = useMemo(() => [model, updateModel], [model, updateModel]);
  return <ModelContext.Provider value={value}>{children}</ModelContext.Provider>;
}


================================================
FILE: src/components/modeModel/modelModeSelector.tsx
================================================
import React, { useEffect, useMemo } from "react";
import { useCallback } from "react";
import { ModeSelector } from "../gameRecords/modeSelector";
import { useModel } from "./model";
import Conf from "../../utils/conf";
import { GameMode } from "../../data/types";
import { Box } from "@mui/material";

export default function ModelModeSelector({
  type = "radio" as "radio" | "checkbox",
  availableModes = Conf.availableModes,
  autoSelectFirst = false,
  oneOrAll = false,
  allowedCombinations = null as null | GameMode[][],
}) {
  allowedCombinations = useMemo(
    () => allowedCombinations || (oneOrAll ? [availableModes] : null),
    [allowedCombinations, oneOrAll, availableModes]
  );
  const [model, updateModel] = useModel();
  const uiSetModes = useCallback(
    (modes: GameMode[]) => {
      if (!availableModes.length) {
        return;
      }
      modes = modes.filter((x) => availableModes.includes(x));
      if (!modes.length) {
        return;
      }
      if (type === "radio") {
        if (model.selectedModes[0] !== modes[0]) {
          updateModel({ selectedModes: [modes[0]] });
        }
        return;
      }
      if (modes.length > 1 && allowedCombinations) {
        const isAllowed = allowedCombinations.some(
          (comb) => modes.length === comb.length && modes.every((m) => comb.includes(m))
        );
        if (!isAllowed) {
          let newAllowedCombinations = allowedCombinations.filter((comb) => modes.every((mode) => comb.includes(mode)));
          if (newAllowedCombinations.length > 0) {
            const removed = model.selectedModes.find((x) => !modes.includes(x));
            if (removed) {
              const filteredCombinations = newAllowedCombinations.filter((x) => !x.includes(removed));
              if (!filteredCombinations.length) {
                return;
              }
              newAllowedCombinations = filteredCombinations;
            }
          }
          if (newAllowedCombinations.length > 0) {
            modes = newAllowedCombinations[0];
          } else {
            const added = modes.find((x) => !model.selectedModes.includes(x));
            if (!added) {
              return;
            }
            modes = [added];
          }
        }
      }
      if (modes.length === model.selectedModes.length && modes.every((x) => model.selectedModes.includes(x))) {
        return;
      }
      updateModel({ selectedModes: modes });
    },
    [updateModel, availableModes, model, allowedCombinations, type]
  );
  useEffect(() => {
    if (!availableModes.length) {
      return;
    }
    let selectedModes = (model.selectedModes || []).filter((x) => availableModes.includes(x));
    if (
      allowedCombinations &&
      selectedModes.length > 1 &&
      !allowedCombinations.some(
        (comb) => comb.length === selectedModes.length && comb.every((mode) => selectedModes.includes(mode))
      )
    ) {
      selectedModes = [];
    }
    if (type === "radio" && selectedModes.length > 1) {
      selectedModes = [selectedModes[0]];
    }

    if (!selectedModes.length) {
      if (autoSelectFirst) {
        updateModel({ selectedModes: [availableModes[0]] });
      } else if (allowedCombinations) {
        updateModel({ selectedModes: allowedCombinations[0] });
      }
      return;
    }
    if (
      selectedModes.length === model.selectedModes.length &&
      selectedModes.every((x) => model.selectedModes.includes(x))
    ) {
      return;
    }
    updateModel({ selectedModes });
  }, [autoSelectFirst, availableModes, model.selectedModes, allowedCombinations, type, updateModel]);
  if (availableModes.length < 2) {
    return null;
  }
  return (
    <Box
      mb={3}
      visibility={
        allowedCombinations &&
        model.selectedModes.length !== 1 &&
        !allowedCombinations.some(
          (x) => x.length === model.selectedModes.length && x.every((mode) => model.selectedModes.includes(mode))
        )
          ? "hidden"
          : "visible"
      }
    >
      <ModeSelector type={type} mode={model.selectedModes} onChange={uiSetModes} availableModes={availableModes} />
    </Box>
  );
}


================================================
FILE: src/components/playerDetails/charts/rankRate.tsx
================================================
import React from "react";
import { ResponsiveContainer, PieChart, Pie, Cell, LabelList, Curve } from "recharts";

import { PlayerMetadata, getRankLabelByIndex } from "../../../data/types";
import { useMemo } from "react";
import { formatPercent } from "../../../utils/index";
import Conf from "../../../utils/conf";
import { useTranslation } from "react-i18next";

const generateCells = (activeIndex: number) =>
  Conf.rankColors.map((color, index) => (
    <Cell fill={color} fillOpacity={activeIndex === index ? 1 : 1} key={`cell-${index}`} />
  ));

const CELLS = generateCells(-1);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const formatLabel = (x: any) => (x.rate > 0 ? x.label : null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createLabelLine = (props: any) =>
  props.payload.payload.rate > 0 ? <Curve {...props} type="linear" className="recharts-pie-label-line" /> : null;

const RankRateChart = React.memo(function ({ metadata, aspect = 1 }: { metadata: PlayerMetadata; aspect?: number }) {
  const { i18n } = useTranslation();
  const ranks = useMemo(
    () => metadata.rank_rates.map((x, index) => ({ label: getRankLabelByIndex(index), rate: x })),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [metadata, i18n.language]
  );
  const startAngle = ranks.filter((x) => x.rate > 0).length < 4 ? 45 : 0;
  return (
    <ResponsiveContainer width="100%" aspect={aspect} height="auto">
      <PieChart margin={{ left: 20, right: 20 }}>
        <Pie
          isAnimationActive={false}
          data={ranks}
          label={formatLabel}
          labelLine={createLabelLine as any} // eslint-disable-line @typescript-eslint/no-explicit-any
          nameKey="label"
          dataKey="rate"
          startAngle={startAngle}
          endAngle={startAngle + 360}
        >
          {CELLS}
          <LabelList dataKey="rate" formatter={formatPercent} position="inside" {...{ fill: "#fff" }} />
        </Pie>
      </PieChart>
    </ResponsiveContainer>
  );
});
export default RankRateChart;


================================================
FILE: src/components/playerDetails/charts/recentRank.tsx
================================================
import { ResponsiveContainer, LineChart, Line, Dot, Tooltip, YAxis, TooltipProps } from "recharts";

import { IDataAdapter } from "../../gameRecords/dataAdapterProvider";
import { GameRecord, Level, modeLabel, getRankLabelByIndex } from "../../../data/types";
import { useMemo } from "react";
import { Player } from "../../gameRecords/player";
import Loading from "../../misc/loading";
import { calculateDeltaPoint } from "../../../data/types/metadata";
import { useIsMobile } from "../../../utils/index";
import Conf from "../../../utils/conf";
import { alpha, Box, styled, Typography } from "@mui/material";
import React from "react";

declare module "recharts" {
  interface DotProps {
    strokeWidth?: number;
    stroke?: string;
    fill?: string;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    payload?: any;
  }
}

type DotPayload = {
  pos: number;
  rank: number;
  delta: number;
  cumulativeDelta: number;
  game: GameRecord;
  playerId: number;
};

const createDot = (isMobile: boolean) => (props: { payload: DotPayload }, active?: boolean) => {
  const scale = isMobile ? 1.5 : 2;
  return (
    <Dot
      {...props}
      stroke={Conf.rankColors[props.payload.rank]}
      {...{
        onClick: () => window.open(GameRecord.getRecordLink(props.payload.game, props.payload.playerId), "_blank"),
      }}
      {...(active ? { fill: Conf.rankColors[props.payload.rank], r: 5 * scale } : { r: 2.5 * scale })}
    />
  );
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createActiveDot = (isMobile: boolean) => (props: Parameters<ReturnType<typeof createDot>>[0]) =>
  createDot(isMobile)(props, true);

const TooltipBox = styled(Box)(({ theme }) => ({
  backgroundColor: alpha(theme.palette.grey[700], 0.92),
  borderRadius: theme.shape.borderRadius,
  color: theme.palette.common.white,
  fontFamily: theme.typography.fontFamily,
  padding: "16px",
  fontSize: theme.typography.pxToRem(11),
  fontWeight: theme.typography.fontWeightMedium,
}));

const RankChartTooltip = ({ active, payload }: TooltipProps<number, string> = {}) => {
  if (!active || !payload || !payload.length) {
    return null;
  }
  const realPayload = payload[0].payload as DotPayload;
  return (
    <TooltipBox>
      <Typography variant="h6">
        {GameRecord.formatFullStartTime(realPayload.game)}{" "}
        {realPayload.game.modeId ? modeLabel(realPayload.game.modeId) : ""} {getRankLabelByIndex(realPayload.rank)}{" "}
        {realPayload.delta > 0 ? "+" : ""}
        {realPayload.delta}pt
      </Typography>
      {realPayload.game.players.map((x) => (
        <Typography key={x.accountId.toString()} variant="body2">
          <Player
            player={x}
            game={realPayload.game}
            sx={{ textDecoration: realPayload.playerId === x.accountId ? "underline" : "none" }}
            color="inherit"
            hideDetailIcon
          />
        </Typography>
      ))}
    </TooltipBox>
  );
};

const RecentRankChart = React.memo(function ({
  dataAdapter,
  playerId,
  aspect = 2,
  numGames = 0,
}: {
  dataAdapter: IDataAdapter;
  playerId: number;
  aspect?: number;
  numGames?: number;
}) {
  const isMobile = useIsMobile();
  if (!numGames) {
    numGames = isMobile ? 20 : 30;
  }
  const dataPoints = useMemo(() => {
    const result = [] as DotPayload[];
    const totalGames = dataAdapter.getCount();
    if (!totalGames) {
      return result;
    }
    for (let i = 0; i < Math.min(totalGames, numGames); i++) {
      const game = dataAdapter.getItem(i);
      if (!game || !("uuid" in game)) {
        break;
      }
      const rank = GameRecord.getRankIndexByPlayer(game, playerId);
      result.unshift({
        pos: 3 - rank,
        rank,
        delta: 0,
        cumulativeDelta: 0,
        game,
        playerId,
      });
    }
    let delta = 0;
    for (const point of result) {
      const game = point.game;
      if (!game.modeId) {
        continue;
      }
      const playerRecord = game.players.filter((x) => x.accountId.toString() === playerId.toString())[0];
      point.delta =
        typeof playerRecord.gradingScore === "number"
          ? playerRecord.gradingScore
          : calculateDeltaPoint(playerRecord.score, point.rank, game.modeId, new Level(playerRecord.level));
      delta += point.delta;
      point.cumulativeDelta = delta;
    }
    return result;
  }, [dataAdapter, numGames, playerId]);
  const dot = useMemo(() => createDot(isMobile), [isMobile]);
  const activeDot = useMemo(() => createActiveDot(isMobile), [isMobile]);
  if (!dataPoints.length) {
    return <Loading />;
  }
  const haveDelta = dataPoints.some((x) => x.delta !== 0);
  return (
    <ResponsiveContainer width="100%" aspect={aspect} height="auto">
      <LineChart data={dataPoints} margin={{ top: 15, right: 15, bottom: 15, left: 15 }}>
        <YAxis type="number" domain={["dataMin", "dataMax"]} yAxisId={0} hide={true} />
        <YAxis type="number" domain={["dataMin", "dataMax"]} yAxisId={1} hide={true} />
        {haveDelta && (
          <Line
            isAnimationActive={false}
            dataKey="cumulativeDelta"
            type="linear"
            stroke="#969696"
            strokeWidth={1.5}
            yAxisId={1}
            dot={false}
            activeDot={false}
            strokeDasharray="5 5"
          />
        )}
        <Line
          isAnimationActive={false}
          dataKey="pos"
          type="linear"
          stroke="#b5c2ce"
          strokeWidth={3}
          dot={dot}
          activeDot={activeDot}
        />
        <Tooltip cursor={false} content={<RankChartTooltip />} />
      </LineChart>
    </ResponsiveContainer>
  );
});
export default RecentRankChart;


================================================
FILE: src/components/playerDetails/charts/winLoseDistribution.tsx
================================================
import { PlayerExtendedStats, PlayerMetadata } from "../../../data/types";
import SimplePieChart, { PieChartItem } from "../../charts/simplePieChart";
import { sum } from "../../../utils";
import { formatPercent } from "../../../utils/index";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Box, Typography, useTheme } from "@mui/material";

function buildItems(
  stats: PlayerExtendedStats,
  keys: (keyof PlayerExtendedStats)[],
  labels: string[],
  total = 0
): PieChartItem[] {
  total = total || sum(keys.map((key) => (stats[key] as number) || 0));
  return keys
    .map((key, index) => ({
      value: stats[key] as number,
      outerLabel: labels[index],
      innerLabel: formatPercent((stats[key] as number) / total),
    }))
    .filter((item) => item.value);
}

const WinLoseDistribution = React.memo(function ({ stats }: { stats: PlayerExtendedStats; metadata: PlayerMetadata }) {
  const { t } = useTranslation();
  const theme = useTheme();
  const winData = useMemo(
    () => buildItems(stats, ["立直和了", "副露和了", "默听和了"], ["立直", "副露", "默听"]),
    [stats]
  );
  const loseData = useMemo(
    () => buildItems(stats, ["放铳至立直", "放铳至副露", "放铳至默听"], ["立直", "副露", "默听"]),
    [stats]
  );
  const loseSelfData = useMemo(() => {
    const result = buildItems(stats, ["放铳时立直率", "放铳时副露率"], ["立直", "副露"], 1);
    const selfOther = {
      value: 1 - (stats.放铳时副露率 || 0) - (stats.放铳时立直率 || 0),
      outerLabel: "门清",
    } as PieChartItem;
    if (selfOther.value > 0.00001) {
      selfOther.innerLabel = formatPercent(selfOther.value / 1);
      result.push(selfOther);
    }
    return result.filter((item) => item.value);
  }, [stats]);
  return (
    <Box
      display="grid"
      width="100%"
      gridTemplateColumns={["1fr", "1fr", "1fr 1fr", "1fr 1fr 1fr"]}
      sx={{
        gridColumnGap: theme.spacing(2),
        "& > .MuiBox-root": {
          maxWidth: 480,
          width: "100%",
          justifySelf: "center",
          overflow: "hidden",
        },
      }}
    >
      <Box>
        <Typography variant="subtitle1" textAlign="center">
          {t("和牌时")}
        </Typography>
        <SimplePieChart
          aspect={4 / 3}
          items={winData}
          startAngle={-45}
          innerLabelFontSize="0.85rem"
          outerLabelOffset={10}
          outerLabel={(x) => t(x.outerLabel || "")}
        />
      </Box>
      <Box>
        <Typography variant="subtitle1" textAlign="center">
          {t("放铳时")}
        </Typography>
        <SimplePieChart
          aspect={4 / 3}
          items={loseSelfData}
          startAngle={-45}
          innerLabelFontSize="0.85rem"
          outerLabelOffset={10}
          outerLabel={(x) => t(x.outerLabel || "")}
        />
      </Box>
      <Box>
        <Typography variant="subtitle1" textAlign="center">
          {t("放铳至")}
        </Typography>
        <SimplePieChart
          aspect={4 / 3}
          items={loseData}
          startAngle={-45}
          innerLabelFontSize="0.85rem"
          outerLabelOffset={10}
          outerLabel={(x) => t(x.outerLabel || "")}
        />
      </Box>
    </Box>
  );
});
export default WinLoseDistribution;


================================================
FILE: src/components/playerDetails/dateRangeSetting.tsx
================================================
import { ReactNode, useEffect, useState } from "react";

import dayjs from "dayjs";
import {
  Button,
  MenuItem,
  Divider,
  TextField,
  Box,
  useTheme,
  MenuProps,
  Popover,
  MenuListProps,
  MenuList,
  styled,
} from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { Trans, useTranslation } from "react-i18next";
import { WatchLater, WatchLaterOutlined } from "@mui/icons-material";
import { MobileDateTimePicker, MobileDatePicker } from "@mui/lab";
import Conf from "../../utils/conf";

const NEW_THRONE_TS = dayjs("2021-08-26T02:00:00.000Z");

function ResponsiveMenu({ children, ...params }: MenuProps) {
  const isMobile = useMediaQuery(useTheme().breakpoints.down("md"));
  return (
    <Popover
      anchorOrigin={
        isMobile ? { vertical: "center", horizontal: "center" } : { vertical: "bottom", horizontal: "left" }
      }
      transformOrigin={isMobile ? { vertical: "center", horizontal: "center" } : undefined}
      {...params}
      PaperProps={{
        sx: { maxWidth: "80vw", maxHeight: "90vh", padding: 1 },
        ...(params.PaperProps || {}),
      }}
    >
      <Box display="flex" flexDirection={["column", "column", "row"]} flexWrap="wrap">
        {children}
      </Box>
    </Popover>
  );
}
const StyledMenuList = styled(MenuList)(({ theme }) => ({
  padding: 0,

  "&:last-child .MuiDivider-root:last-child": {
    display: "none",
  },
  [theme.breakpoints.up("md")]: {
    ".MuiDivider-root:last-child": {
      display: "none",
    },
  },
}));
function MenuGroup({ children, ...params }: MenuListProps) {
  return (
    <StyledMenuList {...params}>
      {children}
      <Divider sx={{ my: 1 }} />
    </StyledMenuList>
  );
}

function DatePickerMenuItem({
  onClose,
  onChange,
  value,
  children,
}: {
  onClose: () => void;
  onChange: (value: dayjs.ConfigType) => void;
  value: dayjs.ConfigType;
  children: ReactNode;
}) {
  const { t, i18n } = useTranslation();
  const [state, setState] = useState("closed" as "closed" | "date" | "datetime");
  const [selectedDate, setSelectedDate] = useState(dayjs(value));
  const [timeEnabled, setTimeEnabled] = useState(false);
  const [closePending, setClosePending] = useState(false);
  const open = function () {
    onClose();
    setSelectedDate(dayjs(value));
    setClosePending(false);
    setState(timeEnabled ? "datetime" : "date");
  };
  useEffect(() => {
    if (!closePending) {
      return;
    }
    if (timeEnabled && state === "date") {
      setClosePending(false);
      setState("datetime");
      return;
    }
    setState("closed");
  }, [closePending, state, timeEnabled]);
  return (
    <>
      <MenuItem dense onClick={open}>
        {children}
      </MenuItem>
      <Box display="none">
        {timeEnabled ? (
          <MobileDateTimePicker
            open={state !== "closed"}
            ampm={false}
            onClose={() => setClosePending(true)}
            renderInput={(params) => <TextField {...params} />}
            value={selectedDate}
            onAccept={onChange}
            onChange={(newDate) => setSelectedDate(dayjs(newDate))}
            minDateTime={dayjs(Conf.dateMin)}
            maxDateTime={dayjs().endOf("day")}
            cancelText={""}
            okText={t("确定")}
            mask="____-__-__ __:__"
            toolbarTitle=""
            toolbarFormat={i18n.language === "en" ? "MMM D" : "M/D"}
            disableCloseOnSelect
          />
        ) : (
          <MobileDatePicker
            open={state !== "closed"}
            onClose={() => setClosePending(true)}
            renderInput={(params) => <TextField {...params} />}
            value={selectedDate}
            onAccept={(newDate) => void (newDate ? onChange(newDate) : setTimeEnabled(true))}
            clearable
            onChange={(newDate) => void (newDate ? setSelectedDate(newDate) : setTimeEnabled(true))}
            minDate={dayjs(Conf.dateMin)}
            maxDate={dayjs().endOf("day")}
            cancelText={""}
            okText={""}
            clearText={t("自定义时间")}
            mask="____-__-__"
            toolbarTitle=""
            toolbarFormat={" "}
            disableCloseOnSelect={false}
          />
        )}
      </Box>
    </>
  );
}

export default function DateRangeSetting({
  onSelectDate,
  onSelectLimit,
  start,
  end,
  limit,
  isThrone,
}: {
  onSelectDate: (start: dayjs.ConfigType | null, end: dayjs.ConfigType | null) => void;
  onSelectLimit: (limit: number) => void;
  start: dayjs.ConfigType | null;
  end: dayjs.ConfigType | null;
  limit: number | null;
  isThrone: boolean;
}) {
  const [anchorEl, setAnchorEl] = useState(null as HTMLElement | null);
  const handleClose = () => setAnchorEl(null);
  const selectAll = () => {
    onSelectDate(null, null);
    handleClose();
  };
  const selectWeek = (week: number) => {
    onSelectDate(
      dayjs()
        .subtract(week * 7 - 1, "day")
        .startOf("day"),
      null
    );
    handleClose();
  };
  const selectLimit = (limit: number) => {
    onSelectLimit(limit);
    handleClose();
  };
  const selectRange = (start: dayjs.Dayjs, end: dayjs.Dayjs | null = null) => {
    onSelectDate(start, end);
    handleClose();
  };
  const haveCustomRange = start || end || limit;
  const shouldRenderTime =
    (start && !dayjs(start).startOf("day").isSame(start, "second")) ||
    (end && !dayjs(end).endOf("day").isSame(end, "second"));
  const format = shouldRenderTime ? "YYYY-MM-DD HH:mm" : "YYYY-MM-DD";
  const isNewThrone = isThrone && !end && start && dayjs(start).isSame(NEW_THRONE_TS);
  const isOldThrone =
    isThrone && end && (!start || !dayjs(start).isAfter(Conf.dateMin)) && dayjs(end).isSame(NEW_THRONE_TS);
  return (
    <div>
      <Button
        disableElevation
        variant={haveCustomRange ? "outlined" : "text"}
        onClick={(e) => setAnchorEl(e.currentTarget)}
        startIcon={haveCustomRange ? <WatchLater /> : <WatchLaterOutlined />}
      >
        {haveCustomRange ? (
          isNewThrone ? (
            <Trans>新王座</Trans>
          ) : isOldThrone ? (
            <Trans>旧王座</Trans>
          ) : limit ? (
            <Trans defaults="最近 {{x}} 场" count={limit} values={{ x: limit }} />
          ) : (
            `${dayjs(start || Conf.dateMin).format(format)} ~ ${dayjs(end || undefined).format(format)}`
          )
        ) : (
          <Trans>时间</Trans>
        )}
      </Button>
      <ResponsiveMenu anchorEl={anchorEl} open={!!anchorEl} onClose={handleClose} keepMounted>
        <MenuGroup>
          <MenuItem dense onClick={selectAll}>
            <Trans>全部</Trans>
          </MenuItem>
          <Divider />
          <DatePickerMenuItem
            onClose={handleClose}
            value={start || dayjs(Conf.dateMin)}
            onChange={(date) => onSelectDate(date, end || dayjs().endOf("day"))}
          >
            <Trans>自定开始时间...</Trans>
          </DatePickerMenuItem>
          <DatePickerMenuItem
            onClose={handleClose}
            value={end || dayjs().endOf("day")}
            onChange={(date) => onSelectDate(start || dayjs(Conf.dateMin), dayjs(date).endOf("minute"))}
          >
            <Trans>自定结束时间...</Trans>
          </DatePickerMenuItem>
        </MenuGroup>
        <MenuGroup>
          {[4, 13, 26, 52].map((x) => (
            <MenuItem dense key={x} onClick={() => selectWeek(x)}>
              <Trans defaults="最近 {{x}} 周" count={x} values={{ x }} />
            </MenuItem>
          ))}
        </MenuGroup>
        <MenuGroup>
          <MenuItem dense onClick={() => selectRange(dayjs().startOf("month"))}>
            <Trans>本月</Trans>
          </MenuItem>
          <MenuItem
            dense
            onClick={() =>
              selectRange(dayjs().startOf("month").subtract(1, "month"), dayjs().startOf("month").subtract(1, "second"))
            }
          >
            <Trans>上月</Trans>
          </MenuItem>
          <MenuItem dense onClick={() => selectRange(dayjs().startOf("year"))}>
            <Trans>今年</Trans>
          </MenuItem>
          <MenuItem
            dense
            onClick={() =>
              selectRange(dayjs().startOf("year").subtract(1, "year"), dayjs().startOf("year").subtract(1, "second"))
            }
          >
            <Trans>去年</Trans>
          </MenuItem>
        </MenuGroup>
        <MenuGroup>
          {[100, 200, 300, 500].map((x) => (
            <MenuItem dense key={x} onClick={() => selectLimit(x)}>
              <Trans defaults="最近 {{x}} 场" count={x} values={{ x }} />
            </MenuItem>
          ))}
        </MenuGroup>
        {isThrone && (
          <MenuGroup>
            <MenuItem dense onClick={() => selectRange(NEW_THRONE_TS)}>
              <Trans>新王座</Trans>
            </MenuItem>
            <MenuItem dense onClick={() => selectRange(dayjs(Conf.dateMin), NEW_THRONE_TS)}>
              <Trans>旧王座</Trans>
            </MenuItem>
          </MenuGroup>
        )}
      </ResponsiveMenu>
    </div>
  );
}


================================================
FILE: src/components/playerDetails/estimatedStableLevel.tsx
================================================
import React from "react";
import { LevelWithDelta, PlayerMetadata, GameMode, Level, modeLabel } from "../../data/types";
import { useModel } from "../gameRecords/model";
import StatItem from "./statItem";
import Conf from "../../utils/conf";
import { useTranslation } from "react-i18next";
import { formatFixed3 } from "../../utils";
import { Box } from "@mui/material";

const ENABLED_MODES = [
  GameMode.玉,
  GameMode.王座,
  GameMode.三玉,
  GameMode.三王座,
  GameMode.王东,
  GameMode.玉东,
  GameMode.三王东,
  GameMode.三玉东,
];

export default function EstimatedStableLevel({ metadata }: { metadata: PlayerMetadata }) {
  const [model] = useModel();
  const { t } = useTranslation();
  if (!Conf.features.estimatedStableLevel) {
    return null;
  }
  let level = LevelWithDelta.getAdjustedLevel(metadata.cross_stats?.level || metadata.level);
  if (!("selectedModes" in model) || model.selectedModes.length !== 1) {
    return null;
  }
  const mode = model.selectedModes[0];
  if (!ENABLED_MODES.includes(mode)) {
    return null;
  }
  if (!level.isAllowedMode(mode)) {
    level = LevelWithDelta.getAdjustedLevel(metadata.level);
  }
  const notEnoughData = metadata.count < 100;
  const expectedGamePoint = PlayerMetadata.calculateExpectedGamePoint(metadata, mode);
  let estimatedNumGamesToChangeLevel = null as number | null;
  if (level.getMaxPoint() && level.isAllowedMode(mode)) {
    const curPoint = level.isSame(new Level(metadata.level.id))
      ? metadata.level.score + metadata.level.delta
      : level.getStartingPoint();
    estimatedNumGamesToChangeLevel =
      expectedGamePoint > 0 ? (level.getMaxPoint() - curPoint) / expectedGamePoint : curPoint / expectedGamePoint;
  }
  const changeLevelMsg = estimatedNumGamesToChangeLevel
    ? t(",括号内为预计{{ label }}段场数", { label: estimatedNumGamesToChangeLevel > 0 ? t("升") : t("降") })
    : "";
  const levelComponents = PlayerMetadata.getStableLevelComponents(metadata, mode);
  const levelNames = "一二三四".slice(0, levelComponents.length);
  const modeL = modeLabel(mode);
  return (
    <>
      <StatItem
        label="安定段位"
        description={
          <Box>
            {`${t("在{{ modeL }}之间一直进行对局,预测最终能达到的段位。", { modeL })}${
              levelNames.length === 3 ? t("括号内为安定段位时的分数期望。") : ""
            }${notEnoughData ? t("(数据量不足,计算结果可能有较大偏差)") : ""}`}
            {!level.isKonten() && (
              <>
                <br />
                {`${t("{{ levelNames1 }}位平均 Pt / {{ levelName2 }}位平均得点 Pt:", {
                  levelNames1: t(levelNames.slice(0, levelNames.length - 1)),
                  levelName2: t(levelNames[levelNames.length - 1]),
                })}[${levelComponents.map((x) => x.toFixed(2)).join("/")}]`}
              </>
            )}
            <br />
            {`${t("得点效率(各顺位平均 Pt 及平均得点 Pt 的加权平均值):")}${formatFixed3(
              PlayerMetadata.calculateExpectedGamePoint(metadata, mode, undefined, false)
            )}`}
          </Box>
        }
        valueProps={notEnoughData ? { fontStyle: "italic", fontWeight: 300, sx: { opacity: 0.5 } } : {}}
      >
        <span>
          {PlayerMetadata.estimateStableLevel2(metadata, mode)}
          {notEnoughData && "?"}
        </span>
      </StatItem>
      <StatItem
        label="分数期望"
        description={`${t("在{{ modeL }}之间每局获得点数的数学期望值{{ changeLevelMsg }}", {
          changeLevelMsg,
          modeL,
        })}${notEnoughData ? t("(数据量不足,计算结果可能有较大偏差)") : ""}`}
        valueProps={notEnoughData ? { fontStyle: "italic", fontWeight: 300, sx: { opacity: 0.5 } } : {}}
      >
        <span>
          {level.isKonten() && level.isAllowedMode(mode)
            ? (expectedGamePoint / 100).toFixed(3)
            : expectedGamePoint.toFixed(1)}
          {estimatedNumGamesToChangeLevel && Math.abs(estimatedNumGamesToChangeLevel) < 10000
            ? ` (${Math.abs(estimatedNumGamesToChangeLevel).toFixed(0)})`
            : ""}
          {notEnoughData && "?"}
        </span>
      </StatItem>
    </>
  );
}


================================================
FILE: src/components/playerDetails/extraSettings.tsx
================================================
import { Close, Done, FilterAlt } from "@mui/icons-material";
import {
  Box,
  Button,
  ButtonGroup,
  Checkbox,
  Dialog,
  DialogActions,
  DialogContent,
  DialogTitle,
  FormControlLabel,
  IconButton,
  TextField,
} from "@mui/material";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { GameMode, getRankLabelByIndexRaw } from "../../data/types";
import Conf from "../../utils/conf";
import { CheckboxGroup } from "../form";
import { Model, useModel } from "../gameRecords/model";

const RANK_ITEMS = [
  {
    key: "All",
    label: "全部",
    value: "全部",
  },
].concat(
  Conf.rankColors.map((_, index) => ({
    key: (index + 1).toString(),
    label: getRankLabelByIndexRaw(index),
    value: (index + 1).toString(),
  }))
);

function ExtraSettingsBody({ model, updateModel }: { model: Model; updateModel: (model: Partial<Model>) => void }) {
  const { t } = useTranslation("form");
  const updateSearchTextFromEvent = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => updateModel({ type: "player", searchText: e.currentTarget.value }),
    [updateModel]
  );
  const setRank = useCallback(
    (rank: string) => updateModel({ type: "player", rank: parseInt(rank) || null }),
    [updateModel]
  );
  const setKontenOnly = useCallback(
    (kontenOnly: boolean) => updateModel({ type: "player", kontenOnly }),
    [updateModel]
  );
  if (!("rank" in model)) {
    return <></>;
  }
  return (
    <>
      <CheckboxGroup
        type="radio"
        label="顺位"
        selectedItems={[(model.rank || "All").toString()]}
        items={RANK_ITEMS}
        onChange={(items) => setRank(items[0].key)}
      />
      <Box mt={2}>
        <TextField
          fullWidth
          label={t("查找玩家")}
          value={model.searchText || ""}
          onChange={updateSearchTextFromEvent}
        />
      </Box>
      <Box mt={2}>
        <FormControlLabel
          label={t("巅峰对决").toString()}
          control={
            <Checkbox
              disabled={
                !model.selectedModes.every((x) =>
                  [GameMode.王座, GameMode.王东, GameMode.三王座, GameMode.三王东].includes(x)
                )
              }
              checked={model.kontenOnly || false}
              onChange={(e) => setKontenOnly(e.target.checked)}
            />
          }
        />
      </Box>
    </>
  );
}

function ExtraSettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
  const { t } = useTranslation();
  const [globalModel, globalUpdateModel] = useModel();
  const [modelChanges, setModelChanges] = useState<Partial<Model>>({});
  useEffect(() => {
    setModelChanges({});
  }, [globalModel]);
  const mergedModel = useMemo<Model>(() => ({ ...globalModel, ...modelChanges } as Model), [globalModel, modelChanges]);
  const onSubmit = useCallback(() => {
    if (modelChanges.type === "player") {
      globalUpdateModel(modelChanges);
    }
    onClose();
  }, [globalUpdateModel, modelChanges, onClose]);
  const onUpdateModel = useCallback(
    (changes: Partial<Model>) => setModelChanges((prev) => ({ ...prev, ...changes })),
    [setModelChanges]
  );
  return (
    <Dialog open={open} disableEscapeKeyDown>
      <DialogTitle>{t("筛选")}</DialogTitle>
      <DialogContent>
        <ExtraSettingsBody model={mergedModel} updateModel={onUpdateModel} />
      </DialogContent>
      <DialogActions>
        <IconButton size="large" onClick={onSubmit}>
          <Done />
        </IconButton>
      </DialogActions>
    </Dialog>
  );
}

export default function ExtraSettings() {
  const { t } = useTranslation();
  const [open, setOpen] = useState(false);
  const [model, updateModel] = useModel();
  const extraSettingsEnabled = Model.hasAdvancedParams(model);
  return (
    <Box alignSelf={[undefined, undefined, "flex-end"]}>
      {extraSettingsEnabled ? (
        <ButtonGroup variant="contained">
          <Button disableElevation startIcon={<FilterAlt />} onClick={() => setOpen(true)}>
            {t("筛选")}
          </Button>
          <Button
            size="small"
            onClick={() =>
              updateModel({
                type: "player",
                rank: null,
                searchText: "",
                kontenOnly: false,
              })
            }
          >
            <Close />
          </Button>
        </ButtonGroup>
      ) : (
        <Button disableElevation startIcon={<FilterAlt />} onClick={() => setOpen(true)}>
          {t("筛选")}
        </Button>
      )}

      <ExtraSettingsDialog open={open} onClose={() => setOpen(false)}></ExtraSettingsDialog>
    </Box>
  );
}


================================================
FILE: src/components/playerDetails/histogram.tsx
================================================
import { Box, Typography, useTheme } from "@mui/material";
import React, { SVGAttributes } from "react";
import { Trans, useTranslation } from "react-i18next";
import { getGlobalHistogram } from "../../data/source/misc";

import { GameMode, HistogramData, HistogramGroup, modeLabelNonTranslated, PlayerExtendedStats } from "../../data/types";
import { formatPercent, sum } from "../../utils";
import { useAsyncFactory } from "../../utils/async";
import { useModel } from "../gameRecords/model";

const VIEWBOX_HEIGHT = 40;

function generatePath(bins: number[], barMax: number, start: number) {
  return `M ${start} 0 ` + bins.map((bin) => `h 1 V ${(bin / barMax) * VIEWBOX_HEIGHT}`).join(" ") + " V 0 Z";
}

function shouldUseClamped(value: number | undefined, data: HistogramGroup) {
  return (
    typeof value !== "number" ||
    (data.histogramClamped && value >= data.histogramClamped.min && value <= data.histogramClamped.max)
  );
}

function getValueAccumulation(value: number, data: HistogramData) {
  const binStep = (data.max - data.min) / data.bins.length;
  const bin = Math.floor((value - data.min) / binStep);
  if (bin < 0) {
    return 0;
  }
  if (bin >= data.bins.length) {
    return sum(data.bins);
  }
  return sum(data.bins.slice(0, bin)) + data.bins[bin] * ((value - (data.min + binStep * bin)) / binStep);
}
const Histogram = React.memo(function ({
  data,
  value,
  extraMeanLines = [],
}: {
  data?: HistogramGroup;
  value?: number;
  extraMeanLines?: number[];
}) {
  const theme = useTheme();
  if (!data) {
    return <></>;
  }
  const histogram = shouldUseClamped(value, data) ? data.histogramClamped : data.histogramFull;
  if (!histogram) {
    return <></>;
  }
  if (value !== undefined) {
    value = Math.max(histogram.min, Math.min(histogram.max, value));
  }
  const barMax = Math.max(...histogram.bins);
  const binStep = (histogram.max - histogram.min) / histogram.bins.length;
  const splitPoint = value === undefined ? histogram.bins.length : Math.ceil((value - histogram.min) / binStep);
  const ValueLine = ({ v, ...props }: { v: number } & SVGAttributes<SVGLineElement>) => {
    if (v < histogram.min || v > histogram.max) {
      return <></>;
    }
    const bin = Math.floor((v - histogram.min) / binStep);
    return (
      <line
        key={v}
        x1={bin}
        x2={bin}
        y1={0}
        y2={VIEWBOX_HEIGHT}
        stroke={theme.palette.grey[50]}
        strokeWidth={1}
        {...props}
      />
    );
  };
  return (
    <svg
      width={120}
      height={VIEWBOX_HEIGHT}
      viewBox={`0 0 ${histogram.bins.length} ${VIEWBOX_HEIGHT}`}
      preserveAspectRatio="none"
    >
      <g style={{ transformOrigin: "center", transform: "scale(1, -1)" }}>
        <path
          d={generatePath(histogram.bins.slice(0, splitPoint), barMax, 0)}
          strokeWidth={1}
          fillRule="nonzero"
          stroke={theme.palette.grey[500]}
          fill={theme.palette.grey[500]}
        />
        {splitPoint < histogram.bins.length && (
          <path
            d={generatePath(histogram.bins.slice(splitPoint), barMax, splitPoint)}
            strokeWidth={1}
            fillRule="nonzero"
            stroke={theme.palette.grey[800]}
            fill={theme.palette.grey[800]}
          />
        )}
        {!Number.isInteger(binStep) && histogram.bins.length > 60 && (
          <g>
            <ValueLine v={data.mean} />
            {extraMeanLines.map((v, index) => (
              <ValueLine key={index} v={v} strokeDasharray="4 12" strokeDashoffset={index * 3} />
            ))}
          </g>
        )}
      </g>
    </svg>
  );
});

const StatHistogramInner = React.memo(function ({
  mode,
  value,
  valueFormatter,
  rankMeans,
  histogramData,
}: {
  mode: GameMode;
  value?: number;
  valueFormatter: (value: number) => string;
  rankMeans: number[];
  histogramData: Omit<HistogramGroup, "histogramFull"> & Required<Pick<HistogramGroup, "histogramFull">>;
}) {
  const { t } = useTranslation();
  const numTotal = sum(histogramData.histogramFull.bins);
  const numPos =
    value === undefined
      ? 0
      : shouldUseClamped(value, histogramData) && histogramData.histogramClamped
      ? getValueAccumulation(value, histogramData.histogramClamped) +
        getValueAccumulation(histogramData.histogramClamped.min, histogramData.histogramFull)
      : getValueAccumulation(value, histogramData.histogramFull);
  return (
    <Box>
      <Typography variant="inherit">
        <Trans defaults="{{mode}}平均值:" values={{ mode: t(modeLabelNonTranslated(mode)) }} />
        {valueFormatter(histogramData.mean)}
      </Typography>
      <Typography variant="inherit" mb={2}>
        <Trans defaults="{{mode}}各段位平均值:" values={{ mode: t(modeLabelNonTranslated(mode)) }} />
        {rankMeans.map(valueFormatter).join(" / ")}
      </Typography>
      <Histogram data={histogramData} value={value} extraMeanLines={rankMeans} />
      {value !== undefined && (
        <Typography variant="inherit">
          <Trans defaults="{{mode}}位置:" values={{ mode: t(modeLabelNonTranslated(mode)) }} />
          {formatPercent(numPos / numTotal)}
        </Typography>
      )}
    </Box>
  );
});

export function useStatHistogram({
  statKey,
  value,
  valueFormatter,
}: {
  statKey: keyof PlayerExtendedStats;
  value?: number;
  valueFormatter: (value: number) => string;
}) {
  const [model] = useModel();
  const globalHistogram = useAsyncFactory(() => getGlobalHistogram().catch(() => null), [], "globalHistogram");
  if (!globalHistogram || model.type !== "player" || model.selectedModes.length !== 1) {
    return null;
  }
  const mode = model.selectedModes[0];
  const modeHistogram = globalHistogram[mode];
  if (!modeHistogram || !(statKey in modeHistogram["0"])) {
    return null;
  }
  const histogramData = modeHistogram["0"][statKey];
  if (!histogramData?.histogramFull) {
    return null;
  }
  const rankMeans = Object.keys(modeHistogram)
    .map((x) => parseInt(x, 10))
    .filter((x) => x)
    .sort((a, b) => a - b)
    .map((x) => modeHistogram[x][statKey]?.mean)
    .filter((x) => x !== undefined) as number[];
  return (
    <StatHistogramInner
      mode={mode}
      value={value}
      valueFormatter={valueFormatter}
      rankMeans={rankMeans}
      histogramData={{ ...histogramData, histogramFull: histogramData.histogramFull }}
    />
  );
}

export const StatHistogram = React.memo(function ({
  statKey,
  value,
  valueFormatter,
}: {
  statKey: keyof PlayerExtendedStats;
  value?: number;
  valueFormatter: (value: number) => string;
}) {
  return useStatHistogram({ statKey, value, valueFormatter });
});

export default Histogram;


================================================
FILE: src/components/playerDetails/playerDetails.tsx
================================================
import React, { ReactNode, useCallback, useMemo, useState } from "react";
import Loadable from "../misc/customizedLoadable";
import { Helmet } from "react-helmet";

import { useDataAdapter } from "../gameRecords/dataAdapterProvider";
import { useEffect } from "react";
import { triggerRelayout, formatPercent, formatFixed3, formatRound, formatIdentity } from "../../utils/index";
import { useAsync } from "../../utils/async";
import {
  LevelWithDelta,
  PlayerExtendedStats,
  PlayerMetadata,
  GameRecord,
  FanStatEntry2,
  FanStatEntryList,
  getAccountZoneTag,
} from "../../data/types";
import Loading from "../misc/loading";
import PlayerDetailsSettings from "./playerDetailsSettings";
import StatItem, { StatList } from "./statItem";
import EstimatedStableLevel from "./estimatedStableLevel";
import { Level } from "../../data/types/level";
import { ViewRoutes, RouteDef, SimpleRoutedSubViews, NavButtons, ViewSwitch } from "../routing";
import SameMatchRate from "./sameMatchRate";
import { Trans, useTranslation } from "react-i18next";
import { Model, useModel } from "../gameRecords/model";
import Conf from "../../utils/conf";
import { GameMode } from "../../data/types/gameMode";
import { loadPlayerPreference } from "../../utils/preference";
import { Box, BoxProps, Grid, Link, Typography } from "@mui/material";
import { useStatHistogram } from "./histogram";
import StarButton from "./star/starButton";
import { networkError } from "../../utils/notify";

const RankRateChart = Loadable({
  loader: () => import("./charts/rankRate"),
});
const RecentRankChart = Loadable({
  loader: () => import("./charts/recentRank"),
});
const WinLoseDistribution = Loadable({
  loader: () => import("./charts/winLoseDistribution"),
});

function GenericStat({
  stats,
  statKey,
  description,
  formatter,
  formatterHistogram,
  label,
  disableHistogram,
  defaultValue = 0,
  hideValue = false,
}: {
  stats: PlayerExtendedStats;
  statKey: keyof PlayerExtendedStats;
  description?: ReactNode;
  formatter: (value: number) => string;
  formatterHistogram?: (value: number) => string;
  label?: string;
  disableHistogram?: boolean;
  defaultValue?: number | string;
  hideValue?: boolean;
}) {
  const value = stats[statKey] ?? defaultValue;
  if (typeof value !== "number" && value !== defaultValue) {
    throw new Error(`${statKey} is not a number`);
  }
  const extraTip = useCallback(() => {
    // eslint-disable-next-line react-hooks/rules-of-hooks
    const ret = useStatHistogram({
      statKey,
      valueFormatter: formatterHistogram || formatter,
      value: typeof value === "number" ? value : undefined,
    });
    if (disableHistogram) {
      return null;
    }
    return stats.count > 100 ? ret : null;
  }, [statKey, formatterHistogram, formatter, value, disableHistogram, stats.count]);
  return (
    <StatItem description={description} label={label || statKey} extraTip={extraTip}>
      {hideValue ? "" : typeof value === "string" ? value : formatter(value)}
    </StatItem>
  );
}

function ExtendedStatsViewAsync({
  metadata,
  view,
  hasAdvancedParams,
}: {
  metadata: PlayerMetadata;
  view: React.ComponentType<{ stats: PlayerExtendedStats; metadata: PlayerMetadata; hasAdvancedParams?: boolean }>;
  hasAdvancedParams: boolean;
}) {
  const stats = useAsync(metadata.extended_stats);
  useEffect(triggerRelayout, [!!stats]);
  if (!stats) {
    return null;
  }
  const View = view;
  return <View stats={stats} metadata={metadata} hasAdvancedParams={hasAdvancedParams} />;
}

function PlayerExtendedStatsView({ stats }: { stats: PlayerExtendedStats }) {
  return (
    <>
      <GenericStat stats={stats} formatter={formatPercent} statKey="和牌率" description="和牌局数 / 总局数" />
      <GenericStat stats={stats} formatter={formatPercent} statKey="放铳率" description="放铳局数 / 总局数" />
      <GenericStat stats={stats} formatter={formatPercent} statKey="自摸率" description="自摸局数 / 和牌局数" />
      <GenericStat
        stats={stats}
        formatter={formatPercent}
        statKey="默听率"
        label="默胡率"
        description="门清默听和牌局数 / 和牌局数"
      />
      <GenericStat stats={stats} formatter={formatPercent} statKey="流局率" description="流局局数 / 总局数" />
      <GenericStat stats={stats} formatter={formatPercent} statKey="流听率" description="流局听牌局数 / 流局局数" />
      <GenericStat stats={stats} formatter={formatPercent} statKey="副露率" description="副露局数 / 总局数" />
      <GenericStat stats={stats} formatter={formatPercent} statKey="立直率" description="立直局数 / 总局数" />
      <GenericStat stats={stats} formatter={formatFixed3} statKey="和了巡数" />
      <GenericStat stats={stats} formatter={formatRound} statKey="平均打点" />
      <GenericStat stats={stats} formatter={formatRound} statKey="平均铳点" />
    </>
  );
}

function fixMaxLevel(level: LevelWithDelta): LevelWithDelta {
  const levelObj = new Level(level.id);
  if (level.score + level.delta < levelObj.getStartingPoint()) {
    return {
      id: level.id,
      score: levelObj.getStartingPoint(),
      delta: 0,
    };
  }
  return level;
}

function MoreStats({
  stats,
  metadata,
  hasAdvancedParams,
}: {
  stats: PlayerExtendedStats;
  metadata: PlayerMetadata;
  hasAdvancedParams?: boolean;
}) {
  const { t } = useTranslation();
  return (
    <>
      {!hasAdvancedParams && (
        <>
          <StatItem label="最高等级">
            {LevelWithDelta.getTag(metadata.cross_stats?.max_level || metadata.max_level)}
          </StatItem>
          <StatItem label="最高分数">
            {LevelWithDelta.formatAdjustedScore(fixMaxLevel(metadata.cross_stats?.max_level || metadata.max_level))}
          </StatItem>
        </>
      )}
      <GenericStat stats={stats} formatter={formatIdentity} formatterHistogram={formatFixed3} statKey="最大连庄" />
      <GenericStat stats={stats} formatter={formatPercent} statKey="里宝率" description="中里宝局数 / 立直和了局数" />
      <GenericStat
        stats={stats}
        formatter={formatPercent}
        statKey="被炸率"
        description="被炸庄(满贯或以上)次数 / 被自摸次数"
      />
      <GenericStat
        stats={stats}
        formatter={formatRound}
        statKey="平均被炸点数"
        description="被炸庄(满贯或以上)点数 / 次数"
      />
      <GenericStat
        stats={stats}
        formatter={formatPercent}
        statKey="放铳时立直率"
        description="放铳时立直次数 / 放铳次数"
      />
      <GenericStat
        stats={stats}
        formatter={formatPercent}
        statKey="放铳时副露率"
        description="放铳时副露次数 / 放铳次数"
      />
      <GenericStat
        stats={stats}
        formatter={formatPercent}
        statKey="副露后放铳率"
        description="放铳时副露次数 / 副露次数"
      />
      <GenericStat
        stats={stats}
        formatter={formatPercent}
        statKey="副露后和牌率"
        description="副露后和牌次数 / 副露次数"
      />
      <GenericStat
        stats={stats}
        formatter={formatPercent}
        statKey="副露后流局率"
        description="副露后流局次数 / 副露次数"
      />
      <GenericStat
        stats={stats}
        formatter={formatRound}
        defaultValue=""
        statKey="打点效率"
        description={`${t("和牌率")} * ${t("平均打点")}`}
      />
      <GenericStat
        stats={stats}
        formatter={formatRound}
        defaultValue=""
        statKey="铳点损失"
        description={`${t("放铳率")} * ${t("平均铳点")}`}
      />
      <GenericStat
        stats={stats}
        formatter={formatRound}
        defaultValue=""
        statKey="净打点效率"
        description={`${t("和牌率")} * ${t("平均打点")} - ${t("放铳率")} * ${t("平均铳点")}`}
      />
      <GenericStat
        stats={stats}
        formatter={formatRound}
        defaultValue=""
        statKey="局收支"
        description={`(${t("场平均素点")} - ${t("场起始素点")}) * ${t("记录场数")} / ${t("总计局数")}`}
      />
      <StatItem label="总计局数">{stats.count}</StatItem>
    </>
  );
}
function RiichiStats({ stats }: { stats: PlayerExtendedStats; metadata: PlayerMetadata }) {
  return (
    <>
      <GenericStat stats={stats} formatter={formatPercent} statKey="立直率" description="立直局数 / 总局数" />
      <GenericStat
        stats={stats}
        formatter={formatPercent}
        statKey="立直后和牌率"
        label="立直和了"
        description="立直和了局数 / 立直局数"
      />
      <GenericStat
        stats={stats}
        formatter={formatPercent}
        label="立直放铳A"
        statKey="立直后放铳率"
        description="立直放铳局数(含立直瞬间) / 立直局数"
      />
      <GenericStat
        stats={stats}
        formatter={formatPercent}
        label="立直放铳B"
        statKey="立直后非瞬间放铳率"
        description="立直放铳局数(不含立直瞬间) / 立直局数"
      />
      <GenericStat
        stats={stats}
        formatter={formatRound}
        statKey="立直收支"
        description="立直总收支(含供托) / 立直局数"
      />
      <GenericStat
        stats={stats}
        formatter={formatRound}
        statKey="立直收入"
        description="立直和了收入(含供托) / 立直和了局数"
      />
      <GenericStat
        stats={stats}
        formatter={formatRound}
        statKey="立直支出"
        description="立直放铳支出(含立直棒) / 立直放铳局数"
      />
      <GenericStat stats={stats} formatter={formatPercent} statKey="先制率" description="先制立直局数 / 立直局数" />
      <GenericStat stats={stats} formatter={formatPercent} statKey="追立率" description="追立局数 / 立直局数" />
      <GenericStat stats={stats} formatter={formatPercent} statKey="被追率" description="被追立局数 / 立直局数" />
      <GenericStat stats={stats} formatter={formatFixed3} statKey="立直巡目" />
      <GenericStat
        stats={stats}
        formatter={formatPercent}
        label="立直流局"
        statKey="立直后流局率"
        description="立直流局局数 / 立直局数"
      />
      <GenericStat stats={stats} formatter={formatPercent} statKey="一发率" description="一发局数 / 立直和了局数" />
      <GenericStat
        stats={stats}
        formatter={formatPercent}
        label="振听率"
        statKey="振听立直率"
        description="振听立直局数(不含立直见逃) / 立直局数"
      />
      {(stats.立直多面 || stats.立直多面 === 0) && (
        <GenericStat
          stats={stats}
          formatter={formatPercent}
          statKey="立直多面"
          description={
            <Box>
              <Trans>
                多面立直局数 / 立直局数
                <br />
                听牌两种或以上即视为多面(含对碰)
              </Trans>
              <br />
              <Trans values={{ date: "2021/9/10" }} defaults="(数据从 {{date}} 前后开始收集)" />
            </Box>
          }
        />
      )}
      {(stats.立直好型2 || stats.立直好型2 === 0) && (
        <GenericStat
          stats={stats}
          formatter={formatPercent}
          statKey="立直好型2"
          label="立直好型"
          description={
            <Box>
              <Trans>
                好型立直局数 / 立直局数
                <br />
                立直时听牌可见剩余 6 枚或以上视为好型
              </Trans>
              <br />
              <Trans values={{ date: "2021/11/7" }} defaults="(数据从 {{date}} 前后开始收集)" />
            </Box>
          }
        />
      )}
    </>
  );
}
function BasicStats({ metadata, hasAdvancedParams }: { metadata: PlayerMetadata; hasAdvancedParams: boolean }) {
  return (
    <>
      <StatItem label="记录场数">{metadata.count}</StatItem>
      <StatItem label="记录等级">{LevelWithDelta.getTag(metadata.cross_stats?.level || metadata.level)}</StatItem>
      <StatItem label="记录分数">
        {LevelWithDelta.formatAdjustedScore(metadata.cross_stats?.level || metadata.level)}
      </StatItem>
      <ExtendedStatsViewAsync
        metadata={metadata}
        view={PlayerExtendedStatsView}
        hasAdvancedParams={hasAdvancedParams}
      />
      <StatItem label="平均顺位">{metadata.avg_rank.toFixed(3)}</StatItem>
      <StatItem label="被飞率">{formatPercent(metadata.negative_rate)}</StatItem>
      {!hasAdvancedParams && <EstimatedStableLevel metadata={metadata} />}
    </>
  );
}
function LuckStats({ stats }: { stats: PlayerExtendedStats }) {
  return (
    <>
      <StatItem label="役满" description="和出役满次数">
        {stats.役满 || 0}
      </StatItem>
      <StatItem label="累计役满" description="和出累计役满次数">
        {stats.累计役满 || 0}
      </StatItem>
      <StatItem label="最大累计番数" description="和出的最大番数(不含役满役)">
        {stats.最大累计番数 || 0}
      </StatItem>
      <StatItem label="流满" description="流满次数">
        {stats.流满 || 0}
      </StatItem>
      <StatItem label="两立直" description="两立直次数">
        {stats.W立直 || 0}
      </StatItem>
      <GenericStat stats={stats} formatter={formatFixed3} statKey="平均起手向听" label="起手向听" />
      <GenericStat
        stats={stats}
        formatter={formatFixed3}
        statKey="平均起手向听亲"
        label="亲起手向听"
        description={
          <Box>
            <Trans values={{ date: "2022/6/27" }} defaults="(数据从 {{date}} 前后开始收集)" />
          </Box>
        }
        hideValue={!stats?.平均起手向听亲}
      />
      <GenericStat
        stats={stats}
        formatter={formatFixed3}
        statKey="平均起手向听子"
        label="子起手向听"
        description={
          <Box>
            <Trans values={{ date: "2022/6/27" }} defaults="(数据从 {{date}} 前后开始收集)" />
          </Box>
        }
        hideValue={!stats?.平均起手向听子}
      />
    </>
  );
}
function LargestLost({ stats, metadata }: { stats: PlayerExtendedStats; metadata: PlayerMetadata }) {
  const { t } = useTranslation();
  if (!stats.最近大铳) {
    return <Typography textAlign="center">{t("无超过满贯大铳")}</Typography>;
  }
  return (
    <Box>
      <Link
        target="_blank"
        rel="noopener noreferrer"
        sx={{
          display: "flex",
          justifyContent: "space-between",
          fontWeight: "bold",
        }}
        href={GameRecord.getRecordLink(stats.最近大铳.id, metadata.id)}
      >
        <Box>{FanStatEntryList.formatFanSummary(stats.最近大铳.fans)}</Box>
        <Box>{GameRecord.formatFullStartTime(stats.最近大铳.start_time)}</Box>
      </Link>
      <StatList mt={2}>
        {stats.最近大铳.fans.map((x) => (
          <StatItem key={x.label} label={x.label}>
            {FanStatEntry2.formatFan(x)}
          </StatItem>
        ))}
      </StatList>
    </Box>
  );
}
function PlayerStats({
  metadata,
  isChangingSettings,
  hasAdvancedParams,
}: {
  metadata: PlayerMetadata;
  isChangingSettings: boolean;
  hasAdvancedParams: boolean;
}) {
  return (
    <SimpleRoutedSubViews>
      <ViewRoutes>
        <RouteDef path="" exact title="基本">
          <StatList>
            <BasicStats metadata={metadata} hasAdvancedParams={hasAdvancedParams} />
          </StatList>
        </RouteDef>
        <RouteDef path="riichi" title="立直">
          <StatList>
            <ExtendedStatsViewAsync metadata={metadata} view={RiichiStats} hasAdvancedParams={hasAdvancedParams} />
          </StatList>
        </RouteDef>
        <RouteDef path="extended" title="更多">
          <StatList>
            <ExtendedStatsViewAsync metadata={metadata} view={MoreStats} hasAdvancedParams={hasAdvancedParams} />
          </StatList>
        </RouteDef>
        <RouteDef path="win-lose" title="和铳分布">
          <ExtendedStatsViewAsync
            metadata={metadata}
            view={WinLoseDistribution}
            hasAdvancedParams={hasAdvancedParams}
          />
        </RouteDef>
        <RouteDef path="luck" title="血统">
          <StatList>
            <ExtendedStatsViewAsync metadata={metadata} view={LuckStats} hasAdvancedParams={hasAdvancedParams} />
          </StatList>
        </RouteDef>
        <RouteDef path="largest-lost" title="最近大铳">
          <ExtendedStatsViewAsync metadata={metadata} view={LargestLost} hasAdvancedParams={hasAdvancedParams} />
        </RouteDef>
        <RouteDef path="same-match" title="最常同桌">
          {!isChangingSettings ? <SameMatchRate currentAccountId={metadata.id} /> : <></>}
        </RouteDef>
      </ViewRoutes>
      <NavButtons sx={{ mt: 3 }} replace keepState withQueryString />
      <ViewSwitch mutateTitle={false} />
    </SimpleRoutedSubViews>
  );
}

const BlurrableBox = ({ blur, sx, ...props }: { blur: boolean } & BoxProps) => (
  <Box sx={{ ...(blur ? { opacity: 0.2, pointerEvents: "none" } : {}), ...sx }} {...props} />
);

export default function PlayerDetails() {
  const { t } = useTranslation();
  const latestDataAdapter = useDataAdapter();
  const [dataAdapter, setDataAdapter] = useState(latestDataAdapter);
  useEffect(() => {
    if (latestDataAdapter === dataAdapter) {
      return;
    }
    latestDataAdapter.getCount();
    const metadata = latestDataAdapter.getMetadata<PlayerMetadata>();
    if (!metadata) {
      return;
    }
    if (dataAdapter.getMetadata()?.count === 0) {
      setDataAdapter(latestDataAdapter);
      return;
    }
    if (!latestDataAdapter.isItemLoaded(0)) {
      latestDataAdapter.getItem(0);
      return;
    }
    if (metadata.extended_stats instanceof Promise) {
      let changed = false;
      metadata.extended_stats
        .then(() => {
          if (changed) {
            return;
          } else {
            setDataAdapter(latestDataAdapter);
          }
        })
        .catch((e) => {
          console.error("PlayerDetails: Failed to fetch extended stats", e);
          networkError();
        });
      return () => {
        changed = true;
      };
    }
    setDataAdapter(latestDataAdapter);
  }, [latestDataAdapter, dataAdapter]);
  const metadata = dataAdapter.getMetadata<PlayerMetadata>();
  const [model, updateModel] = useModel();
  const availableModes = useMemo(
    () =>
      latestDataAdapter.getMetadata<PlayerMetadata>()?.cross_stats?.played_modes ||
      metadata?.cross_stats?.played_modes ||
      [],
    [metadata, latestDataAdapter]
  );
  useEffect(() => {
    if (model.type !== "player" || Conf.availableModes.length < 2) {
      return;
    }
    if (!model.selectedModes.length && !model.startDate && !model.endDate) {
      const savedMode = loadPlayerPreference<GameMode[]>("modePreference", model.playerId, []);
      if (savedMode && savedMode.length) {
        updateModel({ type: "player", playerId: model.playerId, selectedModes: savedMode });
        return;
      }
    }
    if (availableModes.length) {
      const newSelectedModes = model.selectedModes.filter((x) => availableModes.includes(x));
      if (!newSelectedModes.length) {
        newSelectedModes.push(Conf.modePreference.find((x) => availableModes.includes(x)) || availableModes[0]);
      }
      if (
        newSelectedModes.length !== model.selectedModes.length ||
        newSelectedModes.some((x) => !model.selectedModes.includes(x))
      ) {
        updateModel({ type: "player", playerId: model.playerId, selectedModes: newSelectedModes });
      }
    }
  }, [availableModes, model, updateModel]);
  useEffect(triggerRelayout, [!!metadata]);
  const hasMetadata = metadata && metadata.nickname && metadata.count;
  const isChangingSettings = !!(
    hasMetadata &&
    latestDataAdapter !== dataAdapter &&
    metadata !== latestDataAdapter.getMetadata()
  );
  /* eslint-disable @typescript-eslint/no-non-null-assertion */
  return (
    <Box mb={1} position="relative">
      {isChangingSettings && (
        <Box position="absolute" top="50%" left="50%" sx={{ transform: "translate(-50%, -50%)" }}>
          <Loading />
        </Box>
      )}
      {hasMetadata ? (
        <BlurrableBox blur={isChangingSettings}>
          <Helmet>
            <title>{metadata?.cross_stats?.nickname || metadata?.nickname}</title>
          </Helmet>
          <Typography variant="h4" textAlign="center">
            {getAccountZoneTag(metadata!.id)} {metadata?.cross_stats?.nickname || metadata?.nickname}
          </Typography>
          <Grid container mt={2} rowSpacing={2} spacing={2}>
            <Grid item xs={12} md={8}>
              <Typography variant="h5" mb={2} textAlign="center">
                {t("最近走势")}
              </Typography>
              <RecentRankChart dataAdapter={dataAdapter} playerId={metadata!.id} aspect={6} />
              <PlayerStats
                metadata={metadata!}
                isChangingSettings={isChangingSettings}
                hasAdvancedParams={Model.hasAdvancedParams(model)}
              />
            </Grid>
            <Grid item xs={12} md={4}>
              <Typography variant="h5" textAlign="center">
                {t("累计战绩")}
              </Typography>
              <Box maxWidth={480} margin="auto">
                <RankRateChart metadata={metadata!} />
              </Box>
              <Box margin="auto" textAlign="center">
                <StarButton metadata={metadata!} />
              </Box>
            </Grid>
          </Grid>
        </BlurrableBox>
      ) : (
        <Loading />
      )}
      <PlayerDetailsSettings showLevel={true} availableModes={availableModes} />
    </Box>
  );
  /* eslint-enable @typescript-eslint/no-non-null-assertion */
}


================================================
FILE: src/components/playerDetails/playerDetailsSettings.tsx
================================================
import { useCallback } from "react";
import { useModel } from "../gameRecords/model";
import { ModeSelector } from "../gameRecords/modeSelector";
import { GameMode } from "../../data/types";
import { savePlayerPreference } from "../../utils/preference";
import { Box, styled } from "@mui/material";
import ExtraSettings from "./extraSettings";
import DateRangeSetting from "./dateRangeSetting";
import Conf from "../../utils/conf";

const SettingContainer = styled(Box)(({ theme }) => ({
  display: "flex",
  [theme.breakpoints.down("md")]: {
    alignItems: "center",
    flexDirection: "column",
  },
  [theme.breakpoints.up("md")]: {
    justifyContent: "space-between",
    alignItems: "center",
  },

  "& > .MuiFormControl-root": {
    display: "flex",
  },
}));

export default function PlayerDetailsSettings({ showLevel = false, availableModes = [] as GameMode[] }) {
  const [model, updateModel] = useModel();
  const setSelectedMode = useCallback(
    (mode) => {
      if (mode.length && model.type === "player") {
        savePlayerPreference("modePreference", model.playerId, mode);
      }
      updateModel({ type: "player", selectedModes: mode });
    },
    [model, updateModel]
  );
  if (model.type !== "player") {
    return null;
  }
  return (
    <SettingContainer
      mt={3}
      sx={{ visibility: model.selectedModes.length >= 1 || Conf.availableModes.length <= 1 ? "visible" : "hidden" }}
    >
      <DateRangeSetting
        start={model.startDate || null}
        end={model.endDate || null}
        limit={model.limit || null}
        isThrone={model.selectedModes?.some((x) =>
          [GameMode.王座, GameMode.王东, GameMode.三王座, GameMode.三王东].includes(x)
        )}
        onSelectDate={(start, end) =>
          updateModel({
            type: "player",
            playerId: model.playerId,
            startDate: start,
            endDate: end,
            limit: null,
          })
        }
        onSelectLimit={(limit) =>
          updateModel({
            type: "player",
            playerId: model.playerId,
            startDate: null,
            endDate: null,
            limit,
          })
        }
      />
      {showLevel && availableModes.length > 0 && (
        <ModeSelector
          type="checkbox"
          mode={model.selectedModes}
          onChange={setSelectedMode}
          availableModes={availableModes}
          i18nNamespace="gameModeShort"
        />
      )}
      <ExtraSettings />
    </SettingContainer>
  );
}


================================================
FILE: src/components/playerDetails/sameMatchRate.tsx
================================================
import { useMemo } from "react";
import { useDataAdapter } from "../gameRecords/dataAdapterProvider";
import { PlayerRecord, RankRates, GameRecord, calculateDeltaPoint, Level } from "../../data/types";
import Loading from "../misc/loading";
import { generatePlayerPathById } from "../gameRecords/routeUtils";
import { formatPercent, formatFixed3 } from "../../utils";
import { SimpleRoutedSubViews, ViewRoutes, RouteDef, NavButtons, ViewSwitch } from "../routing";
import { useModel } from "../gameRecords/model";
import { useTranslation } from "react-i18next";
import { StatList, StatTooltip } from "./statItem";
import {
  Box,
  IconButton,
  Link,
  styled,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableRow,
  Typography,
} from "@mui/material";
import { FormatListBulleted } from "@mui/icons-material";

type RateItem = {
  player: PlayerRecord;
  count: number;
  resultSelf: RankRates;
  resultOpponent: RankRates;
  pointSelf: number;
  pointOpponent: number;
  win: number;
};
const StyledTable = styled(Table)(({ theme }) => ({
  display: "inline-table",
  whiteSpace: "nowrap",

  "& .MuiTableRow-root.MuiTableRow-root .MuiTableCell-root, & .MuiTableHead-root": {
    boxShadow: "none",
  },
  "& .MuiTableHead-root .MuiTableCell-root": {
    lineHeight: 1.25,
  },
  "& .MuiTableCell-root": {
    fontSize: "inherit",
    color: "inherit",
    padding: theme.spacing(0.5),
  },
  "& .MuiTableCell-root:not(:first-child)": {
    textAlign: "right",
  },
  "& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
    border: "0 none",
  },
}));
function TipTable({ item }: { item: RateItem }) {
  const { t } = useTranslation();
  return (
    <Box>
      <Typography textAlign="center" variant="body2" my={1}>
        {t("胜率:")}
        {formatPercent(item.win / item.count)}
      </Typography>
      <StyledTable>
        <TableHead>
          <TableRow>
            <TableCell></TableCell>
            <TableCell>{t("玩家")}</TableCell>
            <TableCell>{t("对手")}</TableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          <TableRow>
            <TableCell>{t("平均顺位")}</TableCell>
            <TableCell>{formatFixed3(RankRates.getAvg(item.resultSelf))}</TableCell>
            <TableCell>{formatFixed3(RankRates.getAvg(item.resultOpponent))}</TableCell>
          </TableRow>
          <TableRow>
            <TableCell>{t("平均得点")}</TableCell>
            <TableCell>{formatFixed3(item.pointSelf / item.count)}</TableCell>
            <TableCell>{formatFixed3(item.pointOpponent / item.count)}</TableCell>
          </TableRow>
          {["一", "二", "三", "四"].slice(0, item.resultSelf.length).map((label, index) => (
            <TableRow key={index}>
              <TableCell>{t(label + "位")}</TableCell>
              <TableCell>
                {formatPercent(item.resultSelf[index] / item.count)} ({item.resultSelf[index]})
              </TableCell>
              <TableCell>
                {formatPercent(item.resultOpponent[index] / item.count)} ({item.resultOpponent[index]})
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </StyledTable>
    </Box>
  );
}

export function SameMatchRateTable({ numGames = 100, numDisplay = 12, currentAccountId = 0 }) {
  const adapter = useDataAdapter();
  const [, updateModel] = useModel();
  const count = adapter.getCount();
  const numProcessedGames = Math.min(count, numGames);
  const rates = useMemo(() => {
    if (count <= 0) {
      return null;
    }
    const map: {
      [key: number]: RateItem;
    } = {};
    for (let i = 0; i < numProcessedGames; i++) {
      const game = adapter.getItem(i);
      if (!("uuid" in game)) {
        return null; // Not loaded, try again later
      }
      const currentPlayer = game.players.find((p) => p.accountId.toString() === currentAccountId.toString());
      if (!currentPlayer) {
        throw new Error(
          `Can't find current player, shouldn't happen. Current: ${currentAccountId}, Players: ${game.players
            .map((p) => p.accountId)
            .join(", ")}`
        );
      }
      for (const player of game.players) {
        if (player.accountId === currentAccountId) {
          continue;
        }
        if (!map[player.accountId]) {
          map[player.accountId] = {
            player,
            count: 0,
            resultSelf: new Array<number>(game.players.length).fill(0) as RankRates,
            resultOpponent: new Array<number>(game.players.length).fill(0) as RankRates,
            pointSelf: 0,
            pointOpponent: 0,
            win: 0,
          };
        }
        const entry = map[player.accountId];
        entry.count++;
        const selfRank = GameRecord.getRankIndexByPlayer(game, currentAccountId);
        const opponentRank = GameRecord.getRankIndexByPlayer(game, player);
        entry.resultSelf[selfRank]++;
        entry.resultOpponent[opponentRank]++;
        if (selfRank < opponentRank) {
          entry.win++;
        }
        if (game.modeId) {
          entry.pointSelf += calculateDeltaPoint(
            currentPlayer.score,
            selfRank,
            game.modeId,
            new Level(currentPlayer.level),
            true,
            true
          );
          entry.pointOpponent += calculateDeltaPoint(
            player.score,
            opponentRank,
            game.modeId,
            new Level(player.level),
            true,
            true
          );
        }
      }
    }
    const result = Object.values(map);
    result.sort((a, b) => b.count - a.count);
    return result;
  }, [count, adapter, numProcessedGames, currentAccountId]);
  if (count <= 0) {
    return null;
  }
  if (!rates) {
    return <Loading />;
  }
  return (
    <StatList className="mobile-1col">
      {rates.slice(0, numDisplay).map((x) => (
        <Box
          display="flex"
          justifyContent="space-between"
          alignItems="center"
          sx={{ whiteSpace: "nowrap" }}
          key={x.player.accountId}
        >
          <Typography variant="body2" mr={2}>
            <Link href={generatePlayerPathById(x.player.accountId)}>{x.player.nickname}</Link>
            <IconButton
              size="small"
              color="info"
              onClick={() => updateModel({ type: "player", searchText: x.player.nickname })}
              sx={{ margin: "-5px 0", verticalAlign: "text-top" }}
            >
              <FormatListBulleted fontSize="inherit" />
            </IconButton>
          </Typography>
          <Typography variant="body2" component="div">
            <StatTooltip title={<TipTable item={x} />} arrow>
              <Box>
                {formatPercent(x.count / numProcessedGames)} ({x.count})
              </Box>
            </StatTooltip>
          </Typography>
        </Box>
      ))}
    </StatList>
  );
}

export default function SameMatchRate({ numDisplay = 12, currentAccountId = 0 }) {
  return (
    <SimpleRoutedSubViews>
      <ViewRoutes>
        <RouteDef path="latest" title="最近 100 局">
          <SameMatchRateTable currentAccountId={currentAccountId} numDisplay={numDisplay} />
        </RouteDef>
        <RouteDef path="all" title="全部">
          <SameMatchRateTable currentAccountId={currentAccountId} numDisplay={numDisplay} numGames={0x7fffffff} />
        </RouteDef>
      </ViewRoutes>
      <NavButtons sx={{ mt: -1.5 }} />
      <ViewSwitch mutateTitle={false} />
    </SimpleRoutedSubViews>
  );
}


================================================
FILE: src/components/playerDetails/star/starButton.tsx
================================================
import { Star, StarBorder } from "@mui/icons-material";
import { Button } from "@mui/material";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { PlayerMetadata } from "../../../data/types";
import { useStarPlayer } from "./starPlayerProvider";

const StarButton = React.memo(function ({ metadata }: { metadata: PlayerMetadata }) {
  const { t } = useTranslation();
  const { refreshAndGetIsPlayerStarred, starPlayer, unstarPlayer } = useStarPlayer();
  const isStarred = useMemo(() => refreshAndGetIsPlayerStarred(metadata), [metadata, refreshAndGetIsPlayerStarred]);
  return isStarred ? (
    <Button startIcon={<Star />} disableElevation variant="outlined" onClick={() => unstarPlayer(metadata)}>
      {t("已收藏")}
    </Button>
  ) : (
    <Button startIcon={<StarBorder />} onClick={() => starPlayer(metadata)}>
      {t("收藏")}
    </Button>
  );
});
export default StarButton;


================================================
FILE: src/components/playerDetails/star/starPlayerProvider.tsx
================================================
import React, { useCallback, useEffect } from "react";
import { LevelWithDelta, PlayerMetadata } from "../../../data/types";
import { loadPreference, savePreference } from "../../../utils/preference";

type StarredPlayer = {
  id: number;
  name: string;
  levelId: number;
  timestamp: number;
};

const Context = React.createContext({
  starredPlayers: [] as StarredPlayer[],
  // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function
  unstarPlayer(_: PlayerMetadata) {},
  // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function
  starPlayer(_: PlayerMetadata) {},
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  refreshAndGetIsPlayerStarred(_: PlayerMetadata): boolean {
    return false;
  },
});
export const useStarPlayer = () => React.useContext(Context);

const channel = window.BroadcastChannel ? new BroadcastChannel("StarPlayerProvider") : null;

function loadStarredPlayers() {
  const list = loadPreference<StarredPlayer[]>("starredPlayers", []);
  const map = new Map(list.map((item) => [item.id, item]));
  return { list, map };
}
function saveStarredPlayers(list: StarredPlayer[]) {
  savePreference("starredPlayers", list);
  if (channel) {
    setTimeout(() => channel.postMessage("refresh"), 100);
  }
}

export default function StarPlayerProvider({ children }: { children: React.ReactNode }) {
  const [starredPlayers, setStarredPlayers] = React.useState(() => loadStarredPlayers());
  const [debouceCounter, setDebouceCounter] = React.useState(0);
  useEffect(() => {
    if (debouceCounter > 0) {
      setStarredPlayers(loadStarredPlayers());
    }
  }, [debouceCounter]);
  useEffect(() => {
    if (!channel) {
      return;
    }
    const handler = function handler(e: MessageEvent) {
      if (e.data === "refresh") {
        setDebouceCounter((c) => c + 1);
      }
    };
    channel.addEventListener("message", handler);
    return () => {
      channel.removeEventListener("message", handler);
    };
  }, []);
  const starPlayer = useCallback(
    (player: PlayerMetadata) => {
      const newStarredPlayer = {
        id: player.id,
        name: player.nickname,
        levelId: LevelWithDelta.getAdjustedLevel(player.level).toLevelId(),
        timestamp: Date.now(),
      };
      const index = starredPlayers.list.findIndex((item) => item.id === newStarredPlayer.id);
      if (
        index === 0 &&
        starredPlayers.list[0].name === newStarredPlayer.name &&
        starredPlayers.list[0].levelId === newStarredPlayer.levelId
      ) {
        return;
      }
      starredPlayers.map.set(newStarredPlayer.id, newStarredPlayer);
      if (index >= 0) {
        starredPlayers.list.splice(index, 1);
      }
      starredPlayers.list.unshift(newStarredPlayer);
      saveStarredPlayers(starredPlayers.list);
      setDebouceCounter((c) => c + 1);
    },
    [starredPlayers]
  );
  const value = React.useMemo(
    () => ({
      starredPlayers: starredPlayers.list,
      unstarPlayer(player: PlayerMetadata) {
        if (!starredPlayers.map.has(player.id)) {
          return;
        }
        starredPlayers.map.delete(player.id);
        starredPlayers.list = starredPlayers.list.filter((item) => item.id !== player.id);
        saveStarredPlayers(starredPlayers.list);
        setStarredPlayers({ ...starredPlayers });
      },
      starPlayer,
      refreshAndGetIsPlayerStarred(stats: PlayerMetadata) {
        const isStarred = starredPlayers.map.has(stats.id);
        if (!isStarred) {
          return false;
        }
        starPlayer(stats);
        return true;
      },
    }),
    [starPlayer, starredPlayers]
  );
  return <Context.Provider value={value}>{children}</Context.Provider>;
}


================================================
FILE: src/components/playerDetails/star/starredPlayerMenu.tsx
================================================
import { Box, Grow, MenuItem } from "@mui/material";
import { TransitionGroup } from "react-transition-group";
import React from "react";
import { MenuButton } from "../../misc/menuButton";
import { generatePlayerPathById } from "../../gameRecords/routeUtils";
import { useStarPlayer } from "./starPlayerProvider";
import { ArrowDropDown, Star } from "@mui/icons-material";
import { Level } from "../../../data/types";
import { LinkBehavior } from "../../misc/linkBehavior";
import { useTranslation } from "react-i18next";

const StarredPlayerMenu = 
Download .txt
gitextract_1yoa_b0g/

├── .eslintrc.json
├── .gitignore
├── .nvmrc
├── .rescriptsrc.js
├── LICENSE
├── package.json
├── public/
│   ├── CNAME
│   ├── _redirects
│   ├── favicon2/
│   │   └── manifest.json
│   ├── index.html
│   └── robots.txt
├── src/
│   ├── bootstrap.tsx
│   ├── components/
│   │   ├── app/
│   │   │   ├── appHeader.tsx
│   │   │   ├── index.tsx
│   │   │   ├── maintenance.tsx
│   │   │   ├── navbar.tsx
│   │   │   ├── routes.tsx
│   │   │   └── theme.tsx
│   │   ├── charts/
│   │   │   └── simplePieChart.tsx
│   │   ├── contestTools/
│   │   │   ├── index.tsx
│   │   │   └── minMax.tsx
│   │   ├── form/
│   │   │   ├── checkboxGroup.tsx
│   │   │   ├── datePicker.tsx
│   │   │   └── index.tsx
│   │   ├── gameRecords/
│   │   │   ├── columns.tsx
│   │   │   ├── dataAdapterProvider.tsx
│   │   │   ├── extraFilterPredicate.tsx
│   │   │   ├── filterPanel.tsx
│   │   │   ├── gameLinkActions/
│   │   │   │   ├── dialog.tsx
│   │   │   │   └── index.tsx
│   │   │   ├── home.tsx
│   │   │   ├── index.tsx
│   │   │   ├── modeSelector.tsx
│   │   │   ├── model.tsx
│   │   │   ├── player.tsx
│   │   │   ├── playerSearch.tsx
│   │   │   ├── routeSync.tsx
│   │   │   ├── routeUtils.tsx
│   │   │   ├── routes.tsx
│   │   │   ├── table.tsx
│   │   │   └── tableViews.tsx
│   │   ├── layout/
│   │   │   ├── container.tsx
│   │   │   └── index.tsx
│   │   ├── misc/
│   │   │   ├── alert.tsx
│   │   │   ├── canonicalLink.tsx
│   │   │   ├── customizedLoadable.tsx
│   │   │   ├── linkBehavior.tsx
│   │   │   ├── loading.tsx
│   │   │   ├── menuButton.tsx
│   │   │   ├── navButton.tsx
│   │   │   ├── scroller.tsx
│   │   │   └── tracker.tsx
│   │   ├── modeModel/
│   │   │   ├── index.tsx
│   │   │   ├── model.tsx
│   │   │   └── modelModeSelector.tsx
│   │   ├── playerDetails/
│   │   │   ├── charts/
│   │   │   │   ├── rankRate.tsx
│   │   │   │   ├── recentRank.tsx
│   │   │   │   └── winLoseDistribution.tsx
│   │   │   ├── dateRangeSetting.tsx
│   │   │   ├── estimatedStableLevel.tsx
│   │   │   ├── extraSettings.tsx
│   │   │   ├── histogram.tsx
│   │   │   ├── playerDetails.tsx
│   │   │   ├── playerDetailsSettings.tsx
│   │   │   ├── sameMatchRate.tsx
│   │   │   ├── star/
│   │   │   │   ├── starButton.tsx
│   │   │   │   ├── starPlayerProvider.tsx
│   │   │   │   └── starredPlayerMenu.tsx
│   │   │   └── statItem.tsx
│   │   ├── ranking/
│   │   │   ├── careerRanking.tsx
│   │   │   ├── deltaRanking.tsx
│   │   │   └── index.tsx
│   │   ├── recentHighlight/
│   │   │   └── index.tsx
│   │   ├── routing/
│   │   │   ├── index.tsx
│   │   │   └── subView.tsx
│   │   └── statistics/
│   │       ├── dataByRank.tsx
│   │       ├── fanStats.tsx
│   │       ├── index.tsx
│   │       ├── numPlayerStats.tsx
│   │       └── rankBySeats.tsx
│   ├── data/
│   │   ├── source/
│   │   │   ├── api.ts
│   │   │   ├── misc.ts
│   │   │   └── records/
│   │   │       ├── loader.ts
│   │   │       └── provider.ts
│   │   └── types/
│   │       ├── constants.ts
│   │       ├── gameMode.ts
│   │       ├── index.ts
│   │       ├── level.ts
│   │       ├── metadata.ts
│   │       ├── ranking.ts
│   │       ├── record.ts
│   │       ├── statistics.ts
│   │       ├── utils.ts
│   │       └── zone.ts
│   ├── i18n.ts
│   ├── index.tsx
│   ├── locales/
│   │   ├── en.json
│   │   ├── ja.json
│   │   └── ko.json
│   ├── react-app-env.d.ts
│   ├── service-worker.ts
│   ├── serviceWorkerRegistration.ts
│   ├── styles/
│   │   └── styles.scss
│   └── utils/
│       ├── async.ts
│       ├── conf.ts
│       ├── index.ts
│       ├── notify.tsx
│       ├── polyfill.ts
│       ├── preference.ts
│       └── sentry.ts
├── tsconfig.json
└── vercel.json
Download .txt
SYMBOL INDEX (428 symbols across 77 files)

FILE: src/bootstrap.tsx
  method onControllerChange (line 40) | onControllerChange() {
  method onUpdate (line 43) | onUpdate(registration) {

FILE: src/components/app/appHeader.tsx
  function AlertDefault (line 18) | function AlertDefault() {
  function AlertEn (line 51) | function AlertEn() {
  function AlertJa (line 91) | function AlertJa() {
  function AlertKo (line 124) | function AlertKo() {
  function AppHeader (line 162) | function AppHeader() {

FILE: src/components/app/index.tsx
  function App (line 58) | function App() {

FILE: src/components/app/maintenance.tsx
  function MaintenanceHandler (line 8) | function MaintenanceHandler({ children }: { children: React.ReactElement...

FILE: src/components/app/navbar.tsx
  constant NAV_ITEMS (line 34) | const NAV_ITEMS = [
  constant SITE_LINKS (line 42) | const SITE_LINKS = [
  constant LANGUAGES (line 47) | const LANGUAGES = [
  function isActive (line 55) | function isActive(match: any, location: Location): boolean {
  function HideOnScroll (line 62) | function HideOnScroll({ children }: { children: ReactElement }) {
  function handleSwitchSite (line 81) | function handleSwitchSite(e: React.MouseEvent<HTMLAnchorElement>) {
  function DesktopItems (line 90) | function DesktopItems() {
  function MobileItems (line 136) | function MobileItems() {
  constant NAVBAR_THEME (line 203) | const NAVBAR_THEME: ThemeOptions = {
  function Navbar (line 238) | function Navbar() {

FILE: src/components/app/routes.tsx
  function Routes (line 19) | function Routes() {

FILE: src/components/app/theme.tsx
  constant LOCALES (line 15) | const LOCALES: { [key: string]: Localization } = {
  constant DEFAULT_LOCALE (line 20) | const DEFAULT_LOCALE = zhCN;
  constant FONTS (line 22) | const FONTS: { [key: string]: string } = {
  constant DEFAULT_FONT (line 27) | const DEFAULT_FONT = '"Roboto", "Microsoft YaHei", "Meiryo", sans-serif';
  constant THEME_BASIC (line 29) | const THEME_BASIC: ThemeOptions = {
  constant THEME_VALUES (line 50) | const THEME_VALUES = createTheme(THEME_BASIC);
  constant THEME (line 52) | const THEME: ThemeOptions = {
  function OverrideTheme (line 176) | function OverrideTheme({ theme, children }: { theme: ThemeOptions; child...
  function RootThemeProvider (line 180) | function RootThemeProvider({ children }: { children: ReactNode }) {

FILE: src/components/charts/simplePieChart.tsx
  constant DEFAULT_COLORS (line 15) | const DEFAULT_COLORS = ["#003f5c", "#7a5195", "#ef5675", "#ffa600"];
  constant RADIAN (line 24) | const RADIAN = Math.PI / 180;
  type PieChartItem (line 64) | type PieChartItem = {
  function defaultInnerLabel (line 70) | function defaultInnerLabel<T extends PieChartItem>(item: T) {
  function defaultOuterLabel (line 73) | function defaultOuterLabel<T extends PieChartItem>(item: T) {
  function labelLine (line 76) | function labelLine<T extends PieChartItem>(item: T) {
  function SimplePieChart (line 83) | function SimplePieChart<T extends PieChartItem>({

FILE: src/components/contestTools/index.tsx
  constant ROUTES (line 8) | const ROUTES = (
  function Routes (line 18) | function Routes() {

FILE: src/components/contestTools/minMax.tsx
  function MinMax (line 10) | function MinMax() {

FILE: src/components/form/checkboxGroup.tsx
  type CheckboxItem (line 14) | interface CheckboxItem<T> {
  type GroupParams (line 20) | type GroupParams<T> = {
  function InternalRadioGroup (line 28) | function InternalRadioGroup<T>({
  function InternalCheckboxGroup (line 70) | function InternalCheckboxGroup<T>({
  function CheckboxGroup (line 152) | function CheckboxGroup<T>(

FILE: src/components/form/datePicker.tsx
  function DatePicker (line 10) | function DatePicker({

FILE: src/components/gameRecords/columns.tsx
  type ActivePlayerId (line 13) | type ActivePlayerId = number | string | ((x: GameRecord) => number | str...
  type PlayersProps (line 14) | type PlayersProps = {
  type TableColumnDefKey (line 80) | type TableColumnDefKey = {
  type TableColumn (line 83) | type TableColumn = React.FunctionComponentElement<Column> | false | unde...
  type TableColumnDef (line 84) | type TableColumnDef = TableColumnDefKey & (() => TableColumn);
  function makeColumn (line 87) | function makeColumn<T extends unknown[]>(builder: (...args: T) => TableC...
  constant COLUMN_GAMEMODE (line 103) | const COLUMN_GAMEMODE = makeColumn(
  constant COLUMN_RANK (line 125) | const COLUMN_RANK = makeColumn((activePlayerId: number | string) => (
  constant COLUMN_PLAYERS (line 145) | const COLUMN_PLAYERS = makeColumn((props: Partial<Omit<PlayersProps, "ga...
  constant COLUMN_STARTTIME (line 157) | const COLUMN_STARTTIME = makeColumn(() => (
  constant COLUMN_ENDTIME (line 173) | const COLUMN_ENDTIME = makeColumn(() => (
  constant COLUMN_FULLTIME (line 189) | const COLUMN_FULLTIME = makeColumn(() => (

FILE: src/components/gameRecords/dataAdapterProvider.tsx
  type ItemLoadingPlaceholder (line 14) | interface ItemLoadingPlaceholder {
  type IDataAdapter (line 20) | interface IDataAdapter {
  class DummyDataAdapter (line 29) | class DummyDataAdapter implements IDataAdapter {
    method getCount (line 30) | getCount(): number {
    method hasCount (line 33) | hasCount(): boolean {
    method getUnfilteredCount (line 36) | getUnfilteredCount(): number {
    method getMetadata (line 39) | getMetadata<T extends Metadata>(): T | null {
    method getItem (line 42) | getItem(): GameRecord | ItemLoadingPlaceholder {
    method isItemLoaded (line 45) | isItemLoaded(): boolean {
  constant DUMMY_DATA_ADAPTER (line 50) | const DUMMY_DATA_ADAPTER = new DummyDataAdapter() as IDataAdapter;
  class DataAdapter (line 55) | class DataAdapter implements IDataAdapter {
    method constructor (line 60) | constructor(provider: DataProvider, onDataUpdate = noop) {
    method _installHook (line 65) | _installHook<T>(promise: Promise<T>) {
    method _callHook (line 72) | _callHook(error: Error | ApiError | false) {
    method getCount (line 78) | getCount(): number {
    method hasCount (line 91) | hasCount(): boolean {
    method getUnfilteredCount (line 99) | getUnfilteredCount(): number {
    method getMetadata (line 107) | getMetadata<T extends Metadata>(): T | null {
    method getItem (line 115) | getItem(index: number): GameRecord | ItemLoadingPlaceholder {
    method isItemLoaded (line 127) | isItemLoaded(index: number): boolean {
    method setUpdateHook (line 133) | setUpdateHook(hook: () => void) {
    method cancelUpdateHook (line 136) | cancelUpdateHook() {
  function getProviderKey (line 146) | function getProviderKey(model: Model): string {
  function createProvider (line 158) | function createProvider(model: Model): DataProvider {
  function usePredicate (line 168) | function usePredicate(model: Model): FilterPredicate {
  function useDataAdapterCommon (line 205) | function useDataAdapterCommon(
  function DataAdapterProvider (line 261) | function DataAdapterProvider({ children }: { children: ReactChild | Reac...
  function DataAdapterProviderCustom (line 346) | function DataAdapterProviderCustom({

FILE: src/components/gameRecords/extraFilterPredicate.tsx
  function ExtraFilterPredicateProvider (line 14) | function ExtraFilterPredicateProvider({ children }: { children: React.Re...

FILE: src/components/gameRecords/filterPanel.tsx
  constant DEFAULT_DATE (line 13) | const DEFAULT_DATE = dayjs().startOf("day");
  function FilterPanel (line 15) | function FilterPanel() {

FILE: src/components/gameRecords/home.tsx
  function Home (line 14) | function Home() {

FILE: src/components/gameRecords/index.tsx
  function GameRecords (line 8) | function GameRecords() {

FILE: src/components/gameRecords/modeSelector.tsx
  function ModeSelector (line 8) | function ModeSelector({

FILE: src/components/gameRecords/model.tsx
  type ListingModel (line 9) | interface ListingModel {
  type PlayerModel (line 15) | interface PlayerModel {
  type Model (line 26) | type Model = ListingModel | PlayerModel;
  method removeExtraParams (line 29) | removeExtraParams(model: Model): Model {
  method hasAdvancedParams (line 50) | hasAdvancedParams(model: Model): boolean {
  type ModelUpdate (line 54) | type ModelUpdate = Partial<ListingModel> | ({ type: "player" } & Partial...
  type DispatchModelUpdate (line 55) | type DispatchModelUpdate = (props: ModelUpdate) => void;
  constant DEFAULT_MODEL (line 57) | const DEFAULT_MODEL: ListingModel = { type: undefined, date: null, selec...
  function normalizeUpdate (line 61) | function normalizeUpdate(newProps: ModelUpdate): ModelUpdate {
  function isSameModel (line 75) | function isSameModel(a: Model, b: Model): boolean {
  function ModelProvider (line 82) | function ModelProvider({ children }: { children: ReactChild | ReactChild...

FILE: src/components/gameRecords/playerSearch.tsx
  type PlayerSearchResultExt (line 14) | type PlayerSearchResultExt = PlayerSearchResult & {
  constant NUM_FETCH (line 19) | const NUM_FETCH = 20;
  function findRawResultFromCache (line 23) | function findRawResultFromCache(prefix: string): { result: PlayerSearchR...
  function getCrossSiteConf (line 40) | function getCrossSiteConf(x: PlayerSearchResultExt) {
  function getOptionLabel (line 49) | function getOptionLabel(x: PlayerSearchResultExt, t: (x: string) => stri...
  function PlayerSearch (line 57) | function PlayerSearch() {

FILE: src/components/gameRecords/routeSync.tsx
  type ListingRouteParams (line 11) | type ListingRouteParams = {
  type PlayerRouteParams (line 17) | type PlayerRouteParams = {
  function parseOptionalDate (line 28) | function parseOptionalDate<T>(
  method player (line 46) | player(params: PlayerRouteParams): Model | string {
  method listing (line 73) | listing(params: ListingRouteParams): Model | string {
  function RouteSync (line 88) | function RouteSync({ view }: { view: keyof typeof ModelBuilders }): Reac...

FILE: src/components/gameRecords/routeUtils.tsx
  constant PLAYER_PATH (line 5) | const PLAYER_PATH =
  constant PATH (line 7) | const PATH = "/:date(\\d{4}-\\d{2}-\\d{2})/:mode([0-9]+)?/:search?";
  function dateToStringSafe (line 8) | function dateToStringSafe(value: dayjs.ConfigType | null | undefined): s...
  function generatePath (line 25) | function generatePath(model: Model): string {
  function generatePlayerPathById (line 67) | function generatePlayerPathById(playerId: number | string): string {

FILE: src/components/gameRecords/routes.tsx
  function Routes (line 18) | function Routes() {

FILE: src/components/gameRecords/table.tsx
  function GameRecordTable (line 29) | function GameRecordTable({ columns }: { columns: TableColumnDef[] }) {

FILE: src/components/gameRecords/tableViews.tsx
  function GameRecordTablePlayerView (line 13) | function GameRecordTablePlayerView() {
  function GameRecordTableHomeView (line 30) | function GameRecordTableHomeView() {

FILE: src/components/misc/alert.tsx
  function Alert (line 8) | function Alert({

FILE: src/components/misc/canonicalLink.tsx
  function CanonicalLink (line 6) | function CanonicalLink() {

FILE: src/components/misc/customizedLoadable.tsx
  function CustomizedLoadable (line 5) | function CustomizedLoadable<T extends ComponentType<any>>({

FILE: src/components/misc/loading.tsx
  function Loading (line 3) | function Loading({ size = "normal" }: { size?: "normal" | "small" }) {

FILE: src/components/misc/menuButton.tsx
  function MenuButton (line 4) | function MenuButton({

FILE: src/components/misc/scroller.tsx
  function Scroller (line 10) | function Scroller({ children }: { children: ReactChild | ReactChild[] }) {

FILE: src/components/misc/tracker.tsx
  type Ga (line 8) | type Ga = NonNullable<typeof window.ga>;
  type Window (line 11) | interface Window {
  function PageCategory (line 16) | function PageCategory({ category }: { category: string }) {
  function TrackerImpl (line 27) | function TrackerImpl() {
  function Tracker (line 53) | function Tracker() {

FILE: src/components/modeModel/model.tsx
  type Model (line 5) | interface Model {
  type ModelUpdate (line 10) | type ModelUpdate = Partial<Model>;
  type DispatchModelUpdate (line 11) | type DispatchModelUpdate = (props: ModelUpdate) => void;
  constant DEFAULT_MODEL (line 13) | const DEFAULT_MODEL: Model = { selectedModes: [] };
  function ModelModeProvider (line 18) | function ModelModeProvider({ children }: { children: ReactChild | ReactC...

FILE: src/components/modeModel/modelModeSelector.tsx
  function ModelModeSelector (line 9) | function ModelModeSelector({

FILE: src/components/playerDetails/charts/rankRate.tsx
  constant CELLS (line 15) | const CELLS = generateCells(-1);

FILE: src/components/playerDetails/charts/recentRank.tsx
  type DotProps (line 15) | interface DotProps {
  type DotPayload (line 24) | type DotPayload = {

FILE: src/components/playerDetails/charts/winLoseDistribution.tsx
  function buildItems (line 9) | function buildItems(

FILE: src/components/playerDetails/dateRangeSetting.tsx
  constant NEW_THRONE_TS (line 23) | const NEW_THRONE_TS = dayjs("2021-08-26T02:00:00.000Z");
  function ResponsiveMenu (line 25) | function ResponsiveMenu({ children, ...params }: MenuProps) {
  function MenuGroup (line 57) | function MenuGroup({ children, ...params }: MenuListProps) {
  function DatePickerMenuItem (line 66) | function DatePickerMenuItem({
  function DateRangeSetting (line 148) | function DateRangeSetting({

FILE: src/components/playerDetails/estimatedStableLevel.tsx
  constant ENABLED_MODES (line 10) | const ENABLED_MODES = [
  function EstimatedStableLevel (line 21) | function EstimatedStableLevel({ metadata }: { metadata: PlayerMetadata }) {

FILE: src/components/playerDetails/extraSettings.tsx
  constant RANK_ITEMS (line 22) | const RANK_ITEMS = [
  function ExtraSettingsBody (line 36) | function ExtraSettingsBody({ model, updateModel }: { model: Model; updat...
  function ExtraSettingsDialog (line 90) | function ExtraSettingsDialog({ open, onClose }: { open: boolean; onClose...
  function ExtraSettings (line 123) | function ExtraSettings() {

FILE: src/components/playerDetails/histogram.tsx
  constant VIEWBOX_HEIGHT (line 11) | const VIEWBOX_HEIGHT = 40;
  function generatePath (line 13) | function generatePath(bins: number[], barMax: number, start: number) {
  function shouldUseClamped (line 17) | function shouldUseClamped(value: number | undefined, data: HistogramGrou...
  function getValueAccumulation (line 24) | function getValueAccumulation(value: number, data: HistogramData) {
  function useStatHistogram (line 156) | function useStatHistogram({

FILE: src/components/playerDetails/playerDetails.tsx
  function GenericStat (line 45) | function GenericStat({
  function ExtendedStatsViewAsync (line 89) | function ExtendedStatsViewAsync({
  function PlayerExtendedStatsView (line 107) | function PlayerExtendedStatsView({ stats }: { stats: PlayerExtendedStats...
  function fixMaxLevel (line 131) | function fixMaxLevel(level: LevelWithDelta): LevelWithDelta {
  function MoreStats (line 143) | function MoreStats({
  function RiichiStats (line 241) | function RiichiStats({ stats }: { stats: PlayerExtendedStats; metadata: ...
  function BasicStats (line 343) | function BasicStats({ metadata, hasAdvancedParams }: { metadata: PlayerM...
  function LuckStats (line 362) | function LuckStats({ stats }: { stats: PlayerExtendedStats }) {
  function LargestLost (line 408) | function LargestLost({ stats, metadata }: { stats: PlayerExtendedStats; ...
  function PlayerStats (line 438) | function PlayerStats({
  function PlayerDetails (line 494) | function PlayerDetails() {

FILE: src/components/playerDetails/playerDetailsSettings.tsx
  function PlayerDetailsSettings (line 27) | function PlayerDetailsSettings({ showLevel = false, availableModes = [] ...

FILE: src/components/playerDetails/sameMatchRate.tsx
  type RateItem (line 25) | type RateItem = {
  function TipTable (line 56) | function TipTable({ item }: { item: RateItem }) {
  function SameMatchRateTable (line 100) | function SameMatchRateTable({ numGames = 100, numDisplay = 12, currentAc...
  function SameMatchRate (line 213) | function SameMatchRate({ numDisplay = 12, currentAccountId = 0 }) {

FILE: src/components/playerDetails/star/starPlayerProvider.tsx
  type StarredPlayer (line 5) | type StarredPlayer = {
  method unstarPlayer (line 15) | unstarPlayer(_: PlayerMetadata) {}
  method starPlayer (line 17) | starPlayer(_: PlayerMetadata) {}
  method refreshAndGetIsPlayerStarred (line 19) | refreshAndGetIsPlayerStarred(_: PlayerMetadata): boolean {
  function loadStarredPlayers (line 27) | function loadStarredPlayers() {
  function saveStarredPlayers (line 32) | function saveStarredPlayers(list: StarredPlayer[]) {
  function StarPlayerProvider (line 39) | function StarPlayerProvider({ children }: { children: React.ReactNode }) {

FILE: src/components/ranking/careerRanking.tsx
  type ExtraColumnInternal (line 28) | type ExtraColumnInternal = {
  type ExtraColumn (line 33) | type ExtraColumn = {
  function RankingTable (line 38) | function RankingTable({
  function CareerRankingColumn (line 90) | function CareerRankingColumn({
  function CareerRankingPlain (line 141) | function CareerRankingPlain({
  function CareerRankingInner (line 161) | function CareerRankingInner({
  function CareerRanking (line 195) | function CareerRanking({

FILE: src/components/ranking/deltaRanking.tsx
  function RankingTable (line 26) | function RankingTable({ rows = [] as DeltaRankingItem[] }) {
  function DeltaRankingInner (line 50) | function DeltaRankingInner() {
  function DeltaRanking (line 108) | function DeltaRanking() {

FILE: src/components/ranking/index.tsx
  constant SANMA (line 14) | const SANMA = Conf.rankColors.length === 3;
  constant ROUTES (line 16) | const ROUTES = (
  function Routes (line 207) | function Routes() {

FILE: src/components/recentHighlight/index.tsx
  function buildEventInfo (line 24) | function buildEventInfo({ cellData }: TableCellProps) {
  constant COLUMN_EVENTINFO (line 69) | const COLUMN_EVENTINFO = makeColumn(() => (
  function getEventPlayerId (line 73) | function getEventPlayerId(rec: GameRecord) {
  function RecentHighlightInner (line 77) | function RecentHighlightInner() {
  function RecentHighlight (line 110) | function RecentHighlight() {

FILE: src/components/routing/subView.tsx
  type RouteDefProps (line 9) | type RouteDefProps = {
  type RoutesProps (line 19) | type RoutesProps = { children: React.FunctionComponentElement<RouteDefPr...
  function NavButtons (line 25) | function NavButtons({
  function ViewSwitch (line 61) | function ViewSwitch({
  function SimpleRoutedSubViews (line 108) | function SimpleRoutedSubViews({

FILE: src/components/statistics/dataByRank.tsx
  constant HEADERS (line 35) | const HEADERS = ["等级"].concat(["一位率", "二位率", "三位率", "四位率"].slice(0, Conf...
  constant HEADERS2 (line 48) | const HEADERS2 = ["等级", "平均打点", "平均铳点", "打点效率", "铳点损失", "净打点效率"];
  type TooltipToggleButtonProps (line 65) | type TooltipToggleButtonProps = ToggleButtonProps & {
  function DataByRank (line 89) | function DataByRank() {

FILE: src/components/statistics/fanStats.tsx
  constant SORTERS (line 13) | const SORTERS: (undefined | ((a: FanStatEntry, b: FanStatEntry) => numbe...
  function FanStatsView (line 19) | function FanStatsView() {

FILE: src/components/statistics/index.tsx
  constant ROUTES (line 13) | const ROUTES = (
  function Routes (line 30) | function Routes() {

FILE: src/components/statistics/numPlayerStats.tsx
  function groupData (line 11) | function groupData(
  function NumPlayerStats (line 49) | function NumPlayerStats() {

FILE: src/components/statistics/rankBySeats.tsx
  constant SEAT_LABELS (line 13) | const SEAT_LABELS = "东南西北";
  function Chart (line 15) | function Chart({ rates, numGames, aspect = 1 }: { rates: RankRates; numG...
  function RankBySeats (line 29) | function RankBySeats() {

FILE: src/data/source/api.ts
  constant DATA_MIRRORS (line 6) | const DATA_MIRRORS = [
  constant PROBE_TIMEOUT (line 12) | const PROBE_TIMEOUT = 15000;
  function setMaintenanceHandler (line 18) | function setMaintenanceHandler(handler: (msg: string) => void) {
  function fetchWithTimeout (line 24) | async function fetchWithTimeout(
  function fetchData (line 40) | async function fetchData(path: string, opts: Parameters<typeof fetch>[1]...
  type ApiError (line 96) | type ApiError = Error & {
  type WithLastModified (line 102) | type WithLastModified = {
  function handleResponse (line 106) | async function handleResponse<T>(cacheKey: string, resp: Response): Prom...
  function apiGet (line 151) | async function apiGet<T>(path: string): Promise<T & { _lastModified?: da...
  function apiCacheablePost (line 159) | async function apiCacheablePost<T>(path: string, body: unknown): Promise...

FILE: src/data/source/misc.ts
  type PlayerSearchResult (line 13) | type PlayerSearchResult = Pick<PlayerMetadataLite, "id" | "nickname" | "...
  function searchPlayer (line 16) | async function searchPlayer(prefix: string, limit = 20): Promise<PlayerS...
  function getExtendedStats (line 27) | async function getExtendedStats(
  function getDeltaRanking (line 43) | async function getDeltaRanking(timespan: RankingTimeSpan): Promise<Delta...
  function getCareerRanking (line 47) | async function getCareerRanking(
  function getGlobalStatistics (line 57) | async function getGlobalStatistics(modes: GameMode[]): Promise<GlobalSta...
  function getGlobalStatisticsYear (line 60) | async function getGlobalStatisticsYear(modes: GameMode[]): Promise<Globa...
  function getGlobalStatisticsSnapshot (line 63) | async function getGlobalStatisticsSnapshot(
  function getLevelStatistics (line 71) | async function getLevelStatistics(): Promise<LevelStatistics> {
  function getGlobalHistogram (line 77) | async function getGlobalHistogram(): Promise<GlobalHistogram> {
  function getFanStats (line 80) | async function getFanStats(): Promise<FanStats> {
  function getRankRateBySeat (line 84) | async function getRankRateBySeat(): Promise<RankRateBySeat> {

FILE: src/data/source/records/loader.ts
  constant CHUNK_SIZE (line 9) | const CHUNK_SIZE = 100;
  type DataLoader (line 11) | interface DataLoader<T extends Metadata, TRecord = GameRecord> {
  class DummyDataLoader (line 17) | class DummyDataLoader implements DataLoader<Metadata> {
    method getMetadata (line 18) | getMetadata(): Promise<Metadata> {
    method getNextChunk (line 21) | getNextChunk(): Promise<GameRecord[]> {
    method getEstimatedChunkSize (line 24) | getEstimatedChunkSize(): number {
  class RecentHighlightDataLoader (line 29) | class RecentHighlightDataLoader implements DataLoader<Metadata> {
    method constructor (line 32) | constructor(mode: GameMode | undefined, numItems = 100) {
    method getEstimatedChunkSize (line 53) | getEstimatedChunkSize() {
    method getMetadata (line 56) | async getMetadata(): Promise<Metadata> {
    method getNextChunk (line 59) | async getNextChunk(): Promise<GameRecord[]> {
  class ListingDataLoader (line 66) | class ListingDataLoader implements DataLoader<Metadata> {
    method constructor (line 70) | constructor(date: dayjs.ConfigType, mode: GameMode | null) {
    method getEstimatedChunkSize (line 76) | getEstimatedChunkSize() {
    method shouldReturnEmptyResult (line 79) | shouldReturnEmptyResult() {
    method getMetadata (line 82) | async getMetadata(): Promise<Metadata> {
    method getNextChunk (line 88) | async getNextChunk(): Promise<GameRecord[]> {
  function processExtendedStats (line 106) | function processExtendedStats(stats: PlayerMetadata): (value: PlayerExte...
  class PlayerDataLoader (line 120) | class PlayerDataLoader implements DataLoader<PlayerMetadata> {
    method constructor (line 128) | constructor(playerId: string, startDate?: dayjs.Dayjs, endDate?: dayjs...
    method _getDatePath (line 137) | _getDatePath(): string {
    method _getParams (line 144) | _getParams(mode = this._mode): string {
    method getEstimatedChunkSize (line 147) | getEstimatedChunkSize() {
    method getMetadata (line 150) | async getMetadata(): Promise<PlayerMetadata> {
    method getNextChunk (line 185) | async getNextChunk(): Promise<GameRecord[]> {
  class FilteredPlayerDataLoader (line 206) | class FilteredPlayerDataLoader implements DataLoader<PlayerMetadata> {
    method constructor (line 209) | constructor(private _playerId: string, private _loadRecord: () => Prom...
    method getEstimatedChunkSize (line 215) | getEstimatedChunkSize() {
    method getRecords (line 218) | private async getRecords(): Promise<GameRecord[]> {
    method getMetadata (line 227) | async getMetadata(): Promise<PlayerMetadata> {
    method getNextChunk (line 258) | async getNextChunk(): Promise<GameRecord[]> {
  class FixedNumberPlayerDataLoader (line 267) | class FixedNumberPlayerDataLoader extends PlayerDataLoader {
    method constructor (line 270) | constructor(playerId: string, limit: number, mode: GameMode[]) {
    method getEstimatedChunkSize (line 283) | getEstimatedChunkSize() {
    method getMetadata (line 286) | async getMetadata(): Promise<PlayerMetadata> {
    method getNextChunk (line 303) | async getNextChunk(): Promise<GameRecord[]> {

FILE: src/data/source/records/provider.ts
  type FilterPredicate (line 16) | type FilterPredicate<TRecord = GameRecord> = ((record: TRecord) => boole...
  class DataProviderImpl (line 17) | class DataProviderImpl<TMetadata extends Metadata, TRecord extends { uui...
    method constructor (line 28) | constructor(loader: DataLoader<TMetadata, TRecord>) {
    method setFilterPredicate (line 38) | setFilterPredicate(predicate: FilterPredicate<TRecord>) {
    method updateFilteredIndices (line 46) | updateFilteredIndices() {
    method getMetadataSync (line 73) | getMetadataSync(): TMetadata | null {
    method getEstimatedCountSync (line 79) | getEstimatedCountSync(): number {
    method getCountMaybeSync (line 87) | getCountMaybeSync(): number | Promise<number> {
    method getCount (line 94) | async getCount(): Promise<number> {
    method getUnfilteredCountSync (line 126) | getUnfilteredCountSync(): number | null {
    method isItemLoaded (line 133) | isItemLoaded(index: number): boolean {
    method getItem (line 140) | getItem(index: number, skipPreload = false): TRecord | Promise<TRecord...
    method preload (line 167) | preload(index: number) {
    method _mapItemIndex (line 177) | _mapItemIndex(requestedIndex: number): number | null {
    method _loadNextChunk (line 187) | async _loadNextChunk(): Promise<unknown> {
  type ListingDataProvider (line 215) | type ListingDataProvider = DataProviderImpl<Metadata>;
  type PlayerDataProvider (line 216) | type PlayerDataProvider = DataProviderImpl<PlayerMetadata>;
  constant DUMMY_DATA_PROVIDER (line 217) | const DUMMY_DATA_PROVIDER = new DataProviderImpl<Metadata>(new DummyData...
  type DataProvider (line 219) | type DataProvider = ListingDataProvider | PlayerDataProvider;
  method createListing (line 222) | createListing(date: dayjs.ConfigType, mode: GameMode | null): ListingDat...
  method createHightlight (line 225) | createHightlight(mode: GameMode | undefined): ListingDataProvider {
  method createPlayer (line 228) | createPlayer(
  method createFilteredPlayer (line 247) | createFilteredPlayer(playerId: string, loadRecord: () => Promise<GameRec...

FILE: src/data/types/constants.ts
  constant PLAYER_RANKS (line 1) | const PLAYER_RANKS = "初士杰豪圣魂";
  constant RANK_LABELS (line 2) | const RANK_LABELS = ["一位", "二位", "三位", "四位"];

FILE: src/data/types/gameMode.ts
  type GameMode (line 5) | enum GameMode {
  function modeLabelNonTranslated (line 19) | function modeLabelNonTranslated(mode: GameMode) {
  function modeLabel (line 25) | function modeLabel(mode: GameMode) {
  function parseCombinedMode (line 28) | function parseCombinedMode(modeString?: string): GameMode[] {

FILE: src/data/types/level.ts
  constant LEVEL_MAX_POINTS (line 7) | const LEVEL_MAX_POINTS = [20, 80, 200, 600, 800, 1000, 1200, 1400, 2000,...
  constant LEVEL_PENALTY (line 8) | const LEVEL_PENALTY = [0, 0, 0, 20, 40, 60, 80, 100, 120, 165, 180, 195,...
  constant LEVEL_PENALTY_3 (line 9) | const LEVEL_PENALTY_3 = [0, 0, 0, 20, 40, 60, 80, 100, 120, 165, 190, 21...
  constant LEVEL_PENALTY_E (line 10) | const LEVEL_PENALTY_E = [0, 0, 0, 10, 20, 30, 40, 50, 60, 80, 90, 100, 1...
  constant LEVEL_PENALTY_E_3 (line 11) | const LEVEL_PENALTY_E_3 = [0, 0, 0, 10, 20, 30, 40, 50, 60, 80, 95, 110,...
  constant LEVEL_KONTEN (line 13) | const LEVEL_KONTEN = 7;
  constant LEVEL_MAX_POINT_KONTEN (line 14) | const LEVEL_MAX_POINT_KONTEN = 2000;
  constant LEVEL_ALLOWED_MODES (line 16) | const LEVEL_ALLOWED_MODES: { [key: number]: GameMode[] } = {
  constant MODE_PENALTY (line 33) | const MODE_PENALTY: { [mode in GameMode]: typeof LEVEL_PENALTY } = {
  function getTranslatedLevelTags (line 48) | function getTranslatedLevelTags(): string[] {
  class Level (line 58) | class Level {
    method constructor (line 62) | constructor(levelId: number) {
    method toLevelId (line 68) | toLevelId() {
    method isSameMajorRank (line 71) | isSameMajorRank(other: Level): boolean {
    method isSame (line 74) | isSame(other: Level): boolean {
    method isAllowedMode (line 82) | isAllowedMode(mode: GameMode): boolean {
    method isKonten (line 85) | isKonten(): boolean {
    method getNumPlayerId (line 88) | getNumPlayerId(): number {
    method withLevelId (line 91) | withLevelId(newLevelId: number): Level {
    method getTag (line 94) | getTag(): string {
    method getMaxPoint (line 101) | getMaxPoint(): number {
    method getPenaltyPoint (line 110) | getPenaltyPoint(mode: GameMode): number {
    method getStartingPoint (line 116) | getStartingPoint(): number {
    method getNextLevel (line 122) | getNextLevel(): Level {
    method getPreviousLevel (line 135) | getPreviousLevel(): Level {
    method getAdjustedLevel (line 151) | getAdjustedLevel(score: number): Level {
    method getVersionAdjustedLevel (line 171) | getVersionAdjustedLevel() {
    method getVersionAdjustedScore (line 177) | getVersionAdjustedScore(score: number) {
    method getScoreDisplay (line 183) | getScoreDisplay(score: number) {
    method formatAdjustedScoreWithTag (line 190) | formatAdjustedScoreWithTag(score: number) {
    method formatAdjustedScore (line 194) | formatAdjustedScore(score: number) {
  function getLevelTag (line 202) | function getLevelTag(levelId: number) {
  type LevelWithDelta (line 205) | type LevelWithDelta = {
  method format (line 212) | format(obj: LevelWithDelta): string {
  method formatAdjustedScore (line 215) | formatAdjustedScore(obj: LevelWithDelta): string {
  method getTag (line 218) | getTag(obj: LevelWithDelta): string {
  method getAdjustedLevel (line 221) | getAdjustedLevel(obj: LevelWithDelta): Level {

FILE: src/data/types/metadata.ts
  constant RANK_DELTA_4 (line 9) | const RANK_DELTA_4 = [15, 5, -5, -15];
  constant RANK_DELTA_3 (line 10) | const RANK_DELTA_3 = [15, 0, -15];
  constant RANK_DELTA (line 11) | const RANK_DELTA = {
  constant MODE_DELTA (line 25) | const MODE_DELTA = {
  constant KONTEN_DELTA (line 39) | const KONTEN_DELTA: { [mode in GameMode]?: number[] } = {
  constant MODE_BASE_POINT (line 45) | const MODE_BASE_POINT = {
  constant KONTEN_FALLBACK_LEVEL_ID (line 60) | const KONTEN_FALLBACK_LEVEL_ID = 503;
  type RankRates (line 62) | type RankRates = [number, number, number, number] | [number, number, num...
  method getAvg (line 65) | getAvg(rates: RankRates): number {
  method normalize (line 68) | normalize(rates: RankRates): RankRates {
  type FanStatEntry2 (line 74) | type FanStatEntry2 = FanStatEntry & {
  method formatFan (line 79) | formatFan(entry: FanStatEntry2): string {
  type FanStatEntryList (line 89) | type FanStatEntryList = FanStatEntry2[];
  method formatFanList (line 92) | formatFanList(list: FanStatEntryList): string {
  method formatFanSummary (line 95) | formatFanSummary(list: FanStatEntryList): string {
  type PlayerExtendedStats (line 120) | type PlayerExtendedStats = {
  type Metadata (line 182) | interface Metadata {
  type PlayerMetadataLite (line 185) | interface PlayerMetadataLite extends Metadata {
  type PlayerMetadataLite2 (line 190) | interface PlayerMetadataLite2 extends Metadata {
  type PlayerMetadata (line 195) | interface PlayerMetadata extends PlayerMetadataLite, PlayerMetadataLite2 {
  function calculateDeltaPoint (line 206) | function calculateDeltaPoint(
  method calculateRankDeltaPoints (line 237) | calculateRankDeltaPoints(
  method calculateExpectedGamePoint (line 256) | calculateExpectedGamePoint(metadata: PlayerMetadata, mode: GameMode, lev...
  method estimateStableLevel (line 269) | estimateStableLevel(metadata: PlayerMetadata, mode: GameMode): string {
  method formatStableLevel2 (line 306) | formatStableLevel2(level: number): string {
  method getStableLevelComponents (line 324) | getStableLevelComponents(metadata: PlayerMetadata, mode: GameMode): Rank...
  method estimateStableLevel2 (line 327) | estimateStableLevel2(metadata: PlayerMetadata, mode: GameMode): string {

FILE: src/data/types/ranking.ts
  type RankingTimeSpan (line 4) | enum RankingTimeSpan {
  type DeltaRankingItem (line 10) | type DeltaRankingItem = {
  type DeltaRankingResponse (line 16) | type DeltaRankingResponse = {
  type CareerRankingItem (line 23) | interface CareerRankingItem extends PlayerMetadata {
  type CareerRankingType (line 28) | enum CareerRankingType {

FILE: src/data/types/record.ts
  type PlayerRecord (line 12) | interface PlayerRecord {
  type GameRecord (line 19) | interface GameRecord {
  type HighlightEvent (line 28) | type HighlightEvent = {
  type GameRecordWithEvent (line 33) | type GameRecordWithEvent = GameRecord & {
  method getRankIndexByPlayer (line 38) | getRankIndexByPlayer(rec: GameRecord, player: number | string | PlayerRe...
  method getPlayerRankLabel (line 49) | getPlayerRankLabel(rec: GameRecord, player: number | string | PlayerReco...
  method getPlayerRankColor (line 52) | getPlayerRankColor(rec: GameRecord, player: number | string | PlayerReco...
  method getRecordLink (line 59) | getRecordLink(rec: GameRecord | string, player?: PlayerRecord | number |...
  method getMaskedRecordLink (line 67) | getMaskedRecordLink(rec: GameRecord, player?: PlayerRecord | number | st...

FILE: src/data/types/statistics.ts
  type RankRateBySeat (line 4) | type RankRateBySeat = {
  type GlobalStatistics (line 9) | type GlobalStatistics = WithLastModified & {
  type LevelStatisticsItem (line 18) | type LevelStatisticsItem = [AccountZone, number, number];
  type LevelStatistics (line 19) | type LevelStatistics = LevelStatisticsItem[];
  type HistogramData (line 20) | type HistogramData = {
  type HistogramGroup (line 25) | type HistogramGroup = {
  type GlobalHistogram (line 31) | type GlobalHistogram = {
  type FanStatEntry (line 38) | type FanStatEntry = {
  type FanStats (line 42) | type FanStats = {

FILE: src/data/types/utils.ts
  function getRankLabelByIndex (line 6) | function getRankLabelByIndex(index: number): string {
  function getRankLabelByIndexRaw (line 9) | function getRankLabelByIndexRaw(index: number): string {

FILE: src/data/types/zone.ts
  type AccountZone (line 1) | enum AccountZone {
  function getZoneFromLocale (line 8) | function getZoneFromLocale(locale: string): AccountZone {
  function getAccountZone (line 18) | function getAccountZone(accountId: number): AccountZone {
  function getZoneTag (line 35) | function getZoneTag(zone: AccountZone): string {
  function getAccountZoneTag (line 48) | function getAccountZoneTag(accountId: number): string {

FILE: src/i18n.ts
  constant DEBUG (line 7) | const DEBUG = process.env.NODE_ENV === "development" && sessionStorage.i...
  method read (line 16) | read(language: string, namespace: string, callback: (errorValue: unknown...

FILE: src/index.tsx
  function gtag (line 8) | function gtag() {

FILE: src/serviceWorkerRegistration.ts
  type Config (line 23) | type Config = {
  function register (line 29) | function register(config?: Config) {
  function registerValidSW (line 63) | function registerValidSW(swUrl: string, config?: Config) {
  function checkValidServiceWorker (line 126) | function checkValidServiceWorker(swUrl: string, config?: Config) {
  function unregister (line 151) | function unregister() {

FILE: src/utils/async.ts
  type NotFinished (line 5) | type NotFinished = { notFinished: string };
  constant NOT_FINISHED (line 6) | const NOT_FINISHED = { notFinished: "yes" };
  function useAsync (line 10) | function useAsync<T>(maybePromise: T | Promise<T>, cacheKey?: string): T...
  function useAsyncFactory (line 64) | function useAsyncFactory<T>(

FILE: src/utils/conf.ts
  constant CONFIGURATIONS (line 7) | const CONFIGURATIONS = {
  type Configuration (line 89) | type Configuration = typeof CONFIGURATIONS.DEFAULT;
  function mergeDeep (line 92) | function mergeDeep<T extends { [key: string]: any }>(...objects: Partial...
  function canTrackUser (line 132) | function canTrackUser() {

FILE: src/utils/index.ts
  function triggerRelayout (line 5) | function triggerRelayout() {
  function scrollToTop (line 11) | function scrollToTop() {
  function useEventCallback (line 30) | function useEventCallback<T extends unknown[]>(fn: (...args: T) => void,...
  function sum (line 49) | function sum(numbers: number[]): number {
  function useIsMobile (line 53) | function useIsMobile() {

FILE: src/utils/notify.tsx
  function RegisterSnackbarProvider (line 12) | function RegisterSnackbarProvider() {
  function error (line 25) | function error(message: string, options: Partial<OptionsObject> = {}) {
  function networkError (line 44) | function networkError() {

FILE: src/utils/preference.ts
  function savePlayerPreference (line 3) | function savePlayerPreference(key: string, id: string, value: unknown) {
  function loadPlayerPreference (line 11) | function loadPlayerPreference<T>(key: string, id: string, defaultValue: ...
  function loadPreference (line 19) | function loadPreference<T>(key: string, defaultValue: T): T {
  function savePreference (line 23) | function savePreference(key: string, value: unknown) {
Condensed preview — 112 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (407K chars).
[
  {
    "path": ".eslintrc.json",
    "chars": 2075,
    "preview": "{\n  \"parser\": \"@typescript-eslint/parser\",\n  \"parserOptions\": {\n    \"typescript\": true,\n    \"ecmaVersion\": 8,\n    \"sourc"
  },
  {
    "path": ".gitignore",
    "chars": 331,
    "preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
  },
  {
    "path": ".nvmrc",
    "chars": 3,
    "preview": "20\n"
  },
  {
    "path": ".rescriptsrc.js",
    "chars": 3499,
    "preview": "const { prependWebpackPlugin, getPaths, edit } = require(\"@rescripts/utilities\");\nconst { RetryChunkLoadPlugin } = requi"
  },
  {
    "path": "LICENSE",
    "chars": 1066,
    "preview": "MIT License\n\nCopyright (c) 2020 SAPikachu\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\n"
  },
  {
    "path": "package.json",
    "chars": 2434,
    "preview": "{\n  \"name\": \"amae-koromo\",\n  \"version\": \"1.0.0\",\n  \"homepage\": \"https://amae-koromo.sapk.ch\",\n  \"description\": \"\",\n  \"ke"
  },
  {
    "path": "public/CNAME",
    "chars": 19,
    "preview": "amae-koromo.sapk.ch"
  },
  {
    "path": "public/_redirects",
    "chars": 23,
    "preview": "/*    /index.html   200"
  },
  {
    "path": "public/favicon2/manifest.json",
    "chars": 356,
    "preview": "{\n  \"name\": \"amae-koromo\",\n  \"short_name\": \"amae-koromo\",\n  \"icons\": [\n    { \"src\": \"/favicon2/android-chrome-192x192.pn"
  },
  {
    "path": "public/index.html",
    "chars": 3215,
    "preview": "<!DOCTYPE html>\n<html dir=\"ltr\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta name=\"google\" content=\"notranslate\">\n  "
  },
  {
    "path": "public/robots.txt",
    "chars": 33,
    "preview": "User-Agent: *\nDisallow: /player/\n"
  },
  {
    "path": "src/bootstrap.tsx",
    "chars": 1975,
    "preview": "import { render } from \"react-dom\";\n\nimport * as serviceWorker from \"./serviceWorkerRegistration\";\nimport \"./i18n\";\n\nimp"
  },
  {
    "path": "src/components/app/appHeader.tsx",
    "chars": 6015,
    "preview": "import React from \"react\";\r\n\r\nimport { Container } from \"../layout\";\r\nimport { Alert } from \"../misc/alert\";\r\nimport Con"
  },
  {
    "path": "src/components/app/index.tsx",
    "chars": 2527,
    "preview": "import { BrowserRouter as Router } from \"react-router-dom\";\nimport Loadable from \"../misc/customizedLoadable\";\nimport Sc"
  },
  {
    "path": "src/components/app/maintenance.tsx",
    "chars": 543,
    "preview": "import * as React from \"react\";\nimport { useState } from \"react\";\n\nimport { Alert } from \"../misc/alert\";\nimport { Conta"
  },
  {
    "path": "src/components/app/navbar.tsx",
    "chars": 8110,
    "preview": "import React, { ReactElement, useState } from \"react\";\nimport { Location } from \"history\";\nimport Conf, { CONFIGURATIONS"
  },
  {
    "path": "src/components/app/routes.tsx",
    "chars": 1161,
    "preview": "import { Route, Switch } from \"react-router-dom\";\nimport Loadable from \"../misc/customizedLoadable\";\nimport GameRecords "
  },
  {
    "path": "src/components/app/theme.tsx",
    "chars": 5060,
    "preview": "import { ReactNode, useMemo } from \"react\";\nimport {\n  alpha,\n  createTheme,\n  responsiveFontSizes,\n  Theme,\n  ThemeOpti"
  },
  {
    "path": "src/components/charts/simplePieChart.tsx",
    "chars": 5352,
    "preview": "/* eslint-disable @typescript-eslint/indent */\nimport {\n  ResponsiveContainer,\n  PieChart,\n  Pie,\n  Cell,\n  LabelList,\n "
  },
  {
    "path": "src/components/contestTools/index.tsx",
    "chars": 624,
    "preview": "import React from \"react\";\n\nimport { ModelModeProvider } from \"../modeModel\";\nimport { ViewRoutes, SimpleRoutedSubViews,"
  },
  {
    "path": "src/components/contestTools/minMax.tsx",
    "chars": 4319,
    "preview": "import { useState, useCallback } from \"react\";\nimport { DatePicker } from \"../form\";\nimport Conf from \"../../utils/conf\""
  },
  {
    "path": "src/components/form/checkboxGroup.tsx",
    "chars": 5223,
    "preview": "import React, { useCallback, useMemo } from \"react\";\r\nimport { useTranslation } from \"react-i18next\";\r\nimport { useState"
  },
  {
    "path": "src/components/form/datePicker.tsx",
    "chars": 1325,
    "preview": "/* eslint-disable @typescript-eslint/no-empty-function */\r\n\r\nimport dayjs from \"dayjs\";\r\nimport { useCallback } from \"re"
  },
  {
    "path": "src/components/form/index.tsx",
    "chars": 65,
    "preview": "export * from \"./checkboxGroup\";\r\nexport * from \"./datePicker\";\r\n"
  },
  {
    "path": "src/components/gameRecords/columns.tsx",
    "chars": 5875,
    "preview": "import React from \"react\";\nimport { TableCellProps } from \"react-virtualized\";\nimport { Column } from \"react-virtualized"
  },
  {
    "path": "src/components/gameRecords/dataAdapterProvider.tsx",
    "chars": 10754,
    "preview": "import { useState, useEffect, useMemo, useCallback, useContext } from \"react\";\nimport React, { ReactChild } from \"react\""
  },
  {
    "path": "src/components/gameRecords/extraFilterPredicate.tsx",
    "chars": 917,
    "preview": "/* eslint-disable @typescript-eslint/no-empty-function */\nimport React, { useContext, useMemo, useState } from \"react\";\n"
  },
  {
    "path": "src/components/gameRecords/filterPanel.tsx",
    "chars": 1208,
    "preview": "import { useCallback } from \"react\";\r\n\r\nimport { useTranslation } from \"react-i18next\";\r\n\r\nimport { DatePicker } from \"."
  },
  {
    "path": "src/components/gameRecords/gameLinkActions/dialog.tsx",
    "chars": 2983,
    "preview": "import { ContentCopy, PieChartRounded, ReadMore, Replay, SvgIconComponent } from \"@mui/icons-material\";\nimport { Avatar,"
  },
  {
    "path": "src/components/gameRecords/gameLinkActions/index.tsx",
    "chars": 1228,
    "preview": "import React, { ReactNode, useCallback, useMemo, useState } from \"react\";\nimport { GameRecord, PlayerRecord } from \"../."
  },
  {
    "path": "src/components/gameRecords/home.tsx",
    "chars": 1049,
    "preview": "import { useTranslation } from \"react-i18next\";\n\nimport { FilterPanel } from \"./filterPanel\";\nimport { PlayerSearch } fr"
  },
  {
    "path": "src/components/gameRecords/index.tsx",
    "chars": 290,
    "preview": "import { ModelProvider } from \"./model\";\r\nimport Loadable from \"../misc/customizedLoadable\";\r\n\r\nconst Routes = Loadable("
  },
  {
    "path": "src/components/gameRecords/modeSelector.tsx",
    "chars": 1103,
    "preview": "import React, { useMemo } from \"react\";\n\nimport { CheckboxGroup } from \"../form\";\nimport { GameMode, modeLabelNonTransla"
  },
  {
    "path": "src/components/gameRecords/model.tsx",
    "chars": 4400,
    "preview": "/* eslint-disable @typescript-eslint/no-empty-function */\r\nimport dayjs from \"dayjs\";\r\nimport React, { useReducer, useCo"
  },
  {
    "path": "src/components/gameRecords/player.tsx",
    "chars": 2505,
    "preview": "import { PieChartRounded, ReadMore } from \"@mui/icons-material\";\nimport { Link, Typography, TypographyProps, useTheme } "
  },
  {
    "path": "src/components/gameRecords/playerSearch.tsx",
    "chars": 6302,
    "preview": "import React from \"react\";\nimport { useEffect, useState, useMemo } from \"react\";\n\nimport { LevelWithDelta, Level, getAcc"
  },
  {
    "path": "src/components/gameRecords/routeSync.tsx",
    "chars": 3414,
    "preview": "import React from \"react\";\nimport dayjs from \"dayjs\";\n\nimport { useParams, useLocation, Redirect } from \"react-router\";\n"
  },
  {
    "path": "src/components/gameRecords/routeUtils.tsx",
    "chars": 2503,
    "preview": "import { generatePath as genPath } from \"react-router-dom\";\nimport { Model } from \"./model\";\nimport dayjs from \"dayjs\";\n"
  },
  {
    "path": "src/components/gameRecords/routes.tsx",
    "chars": 1355,
    "preview": "import { Switch, Route, Redirect } from \"react-router-dom\";\n\nimport { RouteSync } from \"./routeSync\";\nimport Loadable fr"
  },
  {
    "path": "src/components/gameRecords/table.tsx",
    "chars": 3714,
    "preview": "import React, { useCallback, useEffect, useMemo } from \"react\";\nimport { Index } from \"react-virtualized\";\nimport { Colu"
  },
  {
    "path": "src/components/gameRecords/tableViews.tsx",
    "chars": 779,
    "preview": "import { useModel } from \"./model\";\n\nimport { default as GameRecordTable } from \"./table\";\nimport {\n  COLUMN_RANK,\n  COL"
  },
  {
    "path": "src/components/layout/container.tsx",
    "chars": 385,
    "preview": "import { ReactNode } from \"react\";\r\n\r\nimport { Container as MuiContainer, Typography } from \"@mui/material\";\r\n\r\nexport c"
  },
  {
    "path": "src/components/layout/index.tsx",
    "chars": 28,
    "preview": "export * from \"./container\";"
  },
  {
    "path": "src/components/misc/alert.tsx",
    "chars": 1416,
    "preview": "import { useState, useEffect, ReactNode } from \"react\";\r\nimport React from \"react\";\r\nimport { ReactComponentLike } from "
  },
  {
    "path": "src/components/misc/canonicalLink.tsx",
    "chars": 351,
    "preview": "import React from \"react\";\nimport { Helmet } from \"react-helmet\";\nimport { useLocation } from \"react-router\";\nimport Con"
  },
  {
    "path": "src/components/misc/customizedLoadable.tsx",
    "chars": 719,
    "preview": "import React, { ComponentType, ReactNode, Suspense } from \"react\";\nimport Loading from \"./loading\";\n\n// eslint-disable-n"
  },
  {
    "path": "src/components/misc/linkBehavior.tsx",
    "chars": 533,
    "preview": "import React from \"react\";\nimport { Link, LinkProps } from \"react-router-dom\";\n\n// eslint-disable-next-line @typescript-"
  },
  {
    "path": "src/components/misc/loading.tsx",
    "chars": 312,
    "preview": "import { Box, CircularProgress } from \"@mui/material\";\n\nexport default function Loading({ size = \"normal\" }: { size?: \"n"
  },
  {
    "path": "src/components/misc/menuButton.tsx",
    "chars": 1372,
    "preview": "import React, { ReactElement, ReactNode } from \"react\";\nimport { Button, Menu, MenuItemProps, ButtonProps } from \"@mui/m"
  },
  {
    "path": "src/components/misc/navButton.tsx",
    "chars": 943,
    "preview": "/* eslint-disable @typescript-eslint/indent */\nimport { Button, ButtonProps } from \"@mui/material\";\nimport { NavLink, Na"
  },
  {
    "path": "src/components/misc/scroller.tsx",
    "chars": 655,
    "preview": "import React, { ReactChild, useContext } from \"react\";\r\n\r\nimport { WindowScrollerChildProps } from \"react-virtualized\";\r"
  },
  {
    "path": "src/components/misc/tracker.tsx",
    "chars": 1445,
    "preview": "import { useLocation } from \"react-router\";\nimport { useEffect, useLayoutEffect } from \"react\";\nimport Helmet from \"reac"
  },
  {
    "path": "src/components/modeModel/index.tsx",
    "chars": 123,
    "preview": "export { ModelModeProvider, useModel } from \"./model\";\nexport { default as ModelModeSelector } from \"./modelModeSelector"
  },
  {
    "path": "src/components/modeModel/model.tsx",
    "chars": 1113,
    "preview": "import React, { useReducer, useContext, ReactChild } from \"react\";\nimport { useMemo } from \"react\";\nimport { GameMode } "
  },
  {
    "path": "src/components/modeModel/modelModeSelector.tsx",
    "chars": 4140,
    "preview": "import React, { useEffect, useMemo } from \"react\";\nimport { useCallback } from \"react\";\nimport { ModeSelector } from \".."
  },
  {
    "path": "src/components/playerDetails/charts/rankRate.tsx",
    "chars": 2070,
    "preview": "import React from \"react\";\nimport { ResponsiveContainer, PieChart, Pie, Cell, LabelList, Curve } from \"recharts\";\n\nimpor"
  },
  {
    "path": "src/components/playerDetails/charts/recentRank.tsx",
    "chars": 5726,
    "preview": "import { ResponsiveContainer, LineChart, Line, Dot, Tooltip, YAxis, TooltipProps } from \"recharts\";\n\nimport { IDataAdapt"
  },
  {
    "path": "src/components/playerDetails/charts/winLoseDistribution.tsx",
    "chars": 3203,
    "preview": "import { PlayerExtendedStats, PlayerMetadata } from \"../../../data/types\";\nimport SimplePieChart, { PieChartItem } from "
  },
  {
    "path": "src/components/playerDetails/dateRangeSetting.tsx",
    "chars": 9009,
    "preview": "import { ReactNode, useEffect, useState } from \"react\";\n\nimport dayjs from \"dayjs\";\nimport {\n  Button,\n  MenuItem,\n  Div"
  },
  {
    "path": "src/components/playerDetails/estimatedStableLevel.tsx",
    "chars": 3984,
    "preview": "import React from \"react\";\nimport { LevelWithDelta, PlayerMetadata, GameMode, Level, modeLabel } from \"../../data/types\""
  },
  {
    "path": "src/components/playerDetails/extraSettings.tsx",
    "chars": 4672,
    "preview": "import { Close, Done, FilterAlt } from \"@mui/icons-material\";\nimport {\n  Box,\n  Button,\n  ButtonGroup,\n  Checkbox,\n  Dia"
  },
  {
    "path": "src/components/playerDetails/histogram.tsx",
    "chars": 6673,
    "preview": "import { Box, Typography, useTheme } from \"@mui/material\";\nimport React, { SVGAttributes } from \"react\";\nimport { Trans,"
  },
  {
    "path": "src/components/playerDetails/playerDetails.tsx",
    "chars": 20479,
    "preview": "import React, { ReactNode, useCallback, useMemo, useState } from \"react\";\nimport Loadable from \"../misc/customizedLoadab"
  },
  {
    "path": "src/components/playerDetails/playerDetailsSettings.tsx",
    "chars": 2492,
    "preview": "import { useCallback } from \"react\";\nimport { useModel } from \"../gameRecords/model\";\nimport { ModeSelector } from \"../g"
  },
  {
    "path": "src/components/playerDetails/sameMatchRate.tsx",
    "chars": 7490,
    "preview": "import { useMemo } from \"react\";\nimport { useDataAdapter } from \"../gameRecords/dataAdapterProvider\";\nimport { PlayerRec"
  },
  {
    "path": "src/components/playerDetails/star/starButton.tsx",
    "chars": 930,
    "preview": "import { Star, StarBorder } from \"@mui/icons-material\";\nimport { Button } from \"@mui/material\";\nimport React, { useMemo "
  },
  {
    "path": "src/components/playerDetails/star/starPlayerProvider.tsx",
    "chars": 3765,
    "preview": "import React, { useCallback, useEffect } from \"react\";\nimport { LevelWithDelta, PlayerMetadata } from \"../../../data/typ"
  },
  {
    "path": "src/components/playerDetails/star/starredPlayerMenu.tsx",
    "chars": 1389,
    "preview": "import { Box, Grow, MenuItem } from \"@mui/material\";\nimport { TransitionGroup } from \"react-transition-group\";\nimport Re"
  },
  {
    "path": "src/components/playerDetails/statItem.tsx",
    "chars": 3640,
    "preview": "import {\n  Box,\n  Typography,\n  Tooltip,\n  TooltipProps,\n  styled,\n  tooltipClasses,\n  TypographyProps,\n  useTheme,\n} fr"
  },
  {
    "path": "src/components/ranking/careerRanking.tsx",
    "chars": 6311,
    "preview": "/* eslint-disable @typescript-eslint/indent */\nimport React from \"react\";\n\nimport { CareerRankingItem, CareerRankingType"
  },
  {
    "path": "src/components/ranking/deltaRanking.tsx",
    "chars": 3752,
    "preview": "import { DeltaRankingItem, RankingTimeSpan } from \"../../data/types/ranking\";\nimport { useAsyncFactory } from \"../../uti"
  },
  {
    "path": "src/components/ranking/index.tsx",
    "chars": 8072,
    "preview": "import React from \"react\";\n\nimport { Alert } from \"../misc/alert\";\nimport DeltaRanking from \"./deltaRanking\";\nimport { C"
  },
  {
    "path": "src/components/recentHighlight/index.tsx",
    "chars": 3712,
    "preview": "import React, { ReactNode, useEffect, useMemo } from \"react\";\nimport Helmet from \"react-helmet\";\nimport { DataProvider }"
  },
  {
    "path": "src/components/routing/index.tsx",
    "chars": 27,
    "preview": "export * from \"./subView\";\n"
  },
  {
    "path": "src/components/routing/subView.tsx",
    "chars": 3643,
    "preview": "import React from \"react\";\nimport { useContext } from \"react\";\nimport { useRouteMatch, Switch, Route, Redirect, useLocat"
  },
  {
    "path": "src/components/statistics/dataByRank.tsx",
    "chars": 9196,
    "preview": "import { forwardRef, ReactElement, useMemo, useState, VFC } from \"react\";\n\nimport { formatPercent, formatFixed3 } from \""
  },
  {
    "path": "src/components/statistics/fanStats.tsx",
    "chars": 3446,
    "preview": "import React from \"react\";\n\nimport { formatPercent } from \"../../utils/index\";\nimport { useAsyncFactory } from \"../../ut"
  },
  {
    "path": "src/components/statistics/index.tsx",
    "chars": 1258,
    "preview": "import React from \"react\";\n\nimport { ModelModeProvider } from \"../modeModel\";\nimport { ViewRoutes, SimpleRoutedSubViews,"
  },
  {
    "path": "src/components/statistics/numPlayerStats.tsx",
    "chars": 4657,
    "preview": "import { Box, Grid, Table, TableBody, TableCell, TableRow, Typography } from \"@mui/material\";\nimport { useMemo, useState"
  },
  {
    "path": "src/components/statistics/rankBySeats.tsx",
    "chars": 2310,
    "preview": "import React from \"react\";\nimport { useAsyncFactory } from \"../../utils/async\";\nimport { getRankRateBySeat } from \"../.."
  },
  {
    "path": "src/data/source/api.ts",
    "chars": 5491,
    "preview": "/* eslint-disable @typescript-eslint/no-empty-function */\nimport dayjs from \"dayjs\";\nimport Conf from \"../../utils/conf\""
  },
  {
    "path": "src/data/source/misc.ts",
    "chars": 4141,
    "preview": "/* eslint-disable @typescript-eslint/no-use-before-define */\n/* eslint-disable no-use-before-define */\nimport dayjs from"
  },
  {
    "path": "src/data/source/records/loader.ts",
    "chars": 10705,
    "preview": "import dayjs from \"dayjs\";\n\nimport { GameRecord, GameRecordWithEvent } from \"../../types/record\";\nimport { Metadata, Pla"
  },
  {
    "path": "src/data/source/records/provider.ts",
    "chars": 8106,
    "preview": "import dayjs from \"dayjs\";\n\nimport { GameRecord } from \"../../types/record\";\nimport { Metadata, PlayerMetadata } from \"."
  },
  {
    "path": "src/data/types/constants.ts",
    "chars": 91,
    "preview": "export const PLAYER_RANKS = \"初士杰豪圣魂\";\nexport const RANK_LABELS = [\"一位\", \"二位\", \"三位\", \"四位\"];\n"
  },
  {
    "path": "src/data/types/gameMode.ts",
    "chars": 708,
    "preview": "import i18n from \"../../i18n\";\n\nconst t = i18n.getFixedT(null, \"gameModeShort\");\n\nexport enum GameMode {\n  王座 = 16,\n  玉 "
  },
  {
    "path": "src/data/types/index.ts",
    "chars": 242,
    "preview": "export * from \"./constants\";\nexport * from \"./gameMode\";\nexport * from \"./level\";\nexport * from \"./metadata\";\nexport * f"
  },
  {
    "path": "src/data/types/level.ts",
    "chars": 7426,
    "preview": "import { GameMode } from \"./gameMode\";\nimport { PLAYER_RANKS } from \"./constants\";\nimport i18n from \"../../i18n\";\n\nconst"
  },
  {
    "path": "src/data/types/metadata.ts",
    "chars": 10535,
    "preview": "import { LevelWithDelta, Level, getTranslatedLevelTags } from \"./level\";\nimport { GameMode } from \"./gameMode\";\nimport {"
  },
  {
    "path": "src/data/types/ranking.ts",
    "chars": 1414,
    "preview": "import { LevelWithDelta } from \"./level\";\nimport { PlayerMetadata } from \"./metadata\";\n\nexport enum RankingTimeSpan {\n  "
  },
  {
    "path": "src/data/types/record.ts",
    "chars": 3135,
    "preview": "import dayjs from \"dayjs\";\n\nimport { GameMode } from \"./gameMode\";\nimport { getRankLabelByIndex } from \"./utils\";\nimport"
  },
  {
    "path": "src/data/types/statistics.ts",
    "chars": 1165,
    "preview": "import { AccountZone, GameMode } from \".\";\nimport { WithLastModified } from \"../source/api\";\nimport { PlayerMetadataLite"
  },
  {
    "path": "src/data/types/utils.ts",
    "chars": 295,
    "preview": "import i18n from \"../../i18n\";\nimport { RANK_LABELS } from \"./constants\";\n\nconst t = i18n.t.bind(i18n);\n\nexport function"
  },
  {
    "path": "src/data/types/zone.ts",
    "chars": 1101,
    "preview": "export enum AccountZone {\n  China = 1,\n  Japan = 2,\n  International = 3,\n  Unknown = -1,\n}\n\nexport function getZoneFromL"
  },
  {
    "path": "src/i18n.ts",
    "chars": 2136,
    "preview": "import i18n from \"i18next\";\nimport { initReactI18next } from \"react-i18next\";\nimport LanguageDetector from \"i18next-brow"
  },
  {
    "path": "src/index.tsx",
    "chars": 1238,
    "preview": "/* eslint-disable */\n// @ts-nocheck\nwindow.__loadGa = function () {\n  if (window.ga) {\n    return ga;\n  }\n  window.dataL"
  },
  {
    "path": "src/locales/en.json",
    "chars": 12261,
    "preview": "{\n  \"default\": {\n    \"雀魂牌谱屋\": \"MajSoul Stats\",\n    \"雀魂牌谱屋·金\": \"MajSoul Stats - Gold\",\n    \"雀魂牌谱屋·三麻\": \"MajSoul Stats - 3"
  },
  {
    "path": "src/locales/ja.json",
    "chars": 8393,
    "preview": "{\n  \"default\": {\n    \"雀魂牌谱屋\": \"雀魂牌譜屋\",\n    \"雀魂牌谱屋·金\": \"雀魂牌譜屋·金\",\n    \"雀魂牌谱屋·三麻\": \"雀魂牌譜屋·三麻\",\n    \"主页\": \"トップ\",\n    \"最近役满\""
  },
  {
    "path": "src/locales/ko.json",
    "chars": 8680,
    "preview": "{\n  \"default\": {\n    \"雀魂牌谱屋\": \"작혼 통계\",\n    \"雀魂牌谱屋·金\": \"작혼 통계·금탁\",\n    \"雀魂牌谱屋·三麻\": \"작혼 통계·3마\",\n    \"主页\": \"홈\",\n    \"最近役满\":"
  },
  {
    "path": "src/react-app-env.d.ts",
    "chars": 40,
    "preview": "/// <reference types=\"react-scripts\" />\n"
  },
  {
    "path": "src/service-worker.ts",
    "chars": 3044,
    "preview": "/* eslint-disable no-restricted-globals */\n// This service worker can be customized!\n// See https://developers.google.co"
  },
  {
    "path": "src/serviceWorkerRegistration.ts",
    "chars": 6133,
    "preview": "/* eslint-disable no-eq-null */\n/* eslint-disable @typescript-eslint/no-use-before-define */\n// This optional code is us"
  },
  {
    "path": "src/styles/styles.scss",
    "chars": 2710,
    "preview": "@import \"~react-virtualized/styles\";\n\nbody {\n  font-family: \"Roboto\", \"Microsoft YaHei\", \"Meiryo\", sans-serif;\n  overflo"
  },
  {
    "path": "src/utils/async.ts",
    "chars": 2477,
    "preview": "import React, { useState, useEffect, useMemo } from \"react\";\nimport { networkError } from \"./notify\";\nimport Sentry from"
  },
  {
    "path": "src/utils/conf.ts",
    "chars": 4480,
    "preview": "import { GameMode } from \"../data/types\";\nimport dayjs from \"dayjs\";\n\nconst domain =\n  sessionStorage.getItem(\"overrideD"
  },
  {
    "path": "src/utils/index.ts",
    "chars": 1633,
    "preview": "import { useTheme } from \"@mui/material\";\nimport useMediaQuery from \"@mui/material/useMediaQuery\";\nimport React, { useEf"
  },
  {
    "path": "src/utils/notify.tsx",
    "chars": 1427,
    "preview": "/* eslint-disable @typescript-eslint/no-empty-function */\nimport { Close } from \"@mui/icons-material\";\nimport { IconButt"
  },
  {
    "path": "src/utils/polyfill.ts",
    "chars": 93,
    "preview": "require(\"react-app-polyfill/ie9\");\nrequire(\"react-app-polyfill/stable\");\n\nexport default {};\n"
  },
  {
    "path": "src/utils/preference.ts",
    "chars": 746,
    "preview": "import Conf from \"./conf\";\n\nexport function savePlayerPreference(key: string, id: string, value: unknown) {\n  try {\n    "
  },
  {
    "path": "src/utils/sentry.ts",
    "chars": 2101,
    "preview": "import * as Sentry from \"@sentry/react\";\nimport { v4 as uuidv4 } from \"uuid\";\n\nif (process.env.REACT_APP_SENTRY_DSN) {\n "
  },
  {
    "path": "tsconfig.json",
    "chars": 568,
    "preview": "{\n  \"include\": [\"./src/**/*\"],\n  \"compilerOptions\": {\n    \"allowSyntheticDefaultImports\": true,\n    \"useUnknownInCatchVa"
  },
  {
    "path": "vercel.json",
    "chars": 1232,
    "preview": "{\n  \"version\": 2,\n  \"builds\": [{ \"src\": \"package.json\", \"use\": \"@vercel/static-build\", \"config\": { \"distDir\": \"build\" } "
  }
]

About this extraction

This page contains the full source code of the SAPikachu/amae-koromo GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 112 files (346.8 KB), approximately 102.6k tokens, and a symbol index with 428 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!