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 ================================================ 雀魂牌谱屋
================================================ 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( }> , 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 ( <> 说明
  • 本页面数据由第三方维护,不能绝对保证完整和正确,信息仅供参考,请勿用于不良用途。
  • 记录包含雀魂段位战金之间、玉之间及王座之间的牌谱。
  • 页面不是实时更新,对局一般会在结束后数分钟至数小时内出现。
  • 对局数据收集从 2019 年 11 月 29 日开始(玉南及王座南为 2019 年 8 月 23 日),之前的对局已无法获取。
  • 网站主线路会收集少量匿名浏览数据作后续改进及优化之用,如不希望被收集数据,请使用 镜像线路
  • 如有问题或建议,请戳 SAPikachu (i@sapika.ch) 或{" "} 提交 Issue
  • 感谢 EDWARDH 提供新服务器。
  • 感谢 Kamicloud 提供部分数据。
  • 友情链接: 线上团体赛网站【大凤林】{" "} 线下段位场【雀庄公式战】{" "} 麻将地图【雀士远征踢馆指南】
  • ); } function AlertEn() { return ( <> Notes
  • This is a fan site, data accuracy can't be fully guaranteed, please use the data for reference only and don't use it for malicious purpose.
  • Data is not updated in real-time, finished matches will show up on the site in a few minutes to a few hours.
  • 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.
  • 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 the alternative mirror.
  • If you have any question or suggestion, feel free to email{" "} SAPikachu (i@sapika.ch) or{" "} submit an issue.
  • English translation of the site is contributed by Mjonir and{" "} kator-278. Thank you!
  • Thanks EDWARDH for providing new server.
  • Thanks Kamicloud for providing some missing data.
  • ); } function AlertJa() { return ( <> 説明
  • 当サイトは非公式サイトで、データの完全性と正確性が保証できません、予めご了承ください。サイトの内容を悪用しないでください。
  • データの更新はリアルタイムではありません。対局がサイトに載るまで数分から数時間がかかります。
  • データの収集は 2019 年 11 月 29 日から(玉南と王座南は 2019 年 8 月 23 日)です。収集開始以前の対局は検索できません。
  • メインサイト はサービス向上のため、少しの匿名化された利用情報を収集しています。希望しない方は、 ミラーサイトをご利用ください。
  • 内容の誤り・誤植等はご報告いただけますと幸いです。 SAPikachu (i@sapika.ch){" "} または GitHub でご連絡ください。
  • 新しいサーバーを提供してくださった EDWARDH に感謝します。
  • 一部のデータを提供してくださった Kamicloud に感謝します。
  • ); } function AlertKo() { return ( <> 안내
  • 본 사이트는 비공식 사이트로, 데이터의 완전성과 정확성이 보증되지 않습니다. 사이트 내용을 악용하지 말아 주십시오.
  • 데이터 갱신은 실시간으로 이루어지지 않습니다. 대국이 사이트에 반영되기까지는 수 분에서 수 시간이 걸립니다.
  • 데이터 수집은 2019년 11월 29일부터(옥탁 반장과 왕좌탁 반장은 2019년 8월 23일) 시작되었습니다. 수집 개시 이전의 대국은 검색할 수 없습니다.
  • 메인 사이트는 서비스 향상을 위해 약간의 익명 사용 데이터를 수집하고 있습니다. 원치 않는 분은 미러 사이트를 이용해 주십시오.
  • 잘못된 내용 등이 있는 경우 SAPikachu (i@sapika.ch) 또는{" "} GitHub로 연락해주시길 바랍니다.
  • 한국어 번역은 limgit가 도움을 주었습니다. 감사합니다!
  • 新しいサーバーを提供してくださった EDWARDH に感謝します。
  • 一部のデータを提供してくださった Kamicloud に感謝します。
  • ); } export function AppHeader() { const { i18n } = useTranslation(); return ( {i18n.language.indexOf("ja") === 0 ? ( ) : i18n.language.indexOf("en") === 0 ? ( ) : i18n.language.indexOf("ko") === 0 ? ( ) : ( )} ); } ================================================ 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 }) => ( {children} ); const Providers: FC = ({ children }) => ( {children} ); function App() { const { t, i18n } = useTranslation(); return (
    {Conf.showTopNotice ? : <>}
    ); } 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 ( {msg} ); } ================================================ 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 ( {children} ); } const MobileNavButton = ({ href, children, ...props }: ButtonProps & Omit) => ( {children} ); function handleSwitchSite(e: React.MouseEvent) { 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 ( <> {t("主页")} {NAV_ITEMS.map(({ label, path }) => ( {t(label)} ))} {SITE_LINKS.map(({ label, domain, active }) => ( ))} } endIcon={} label={LANGUAGES.find((x) => x.code === i18n.language)?.label} > {LANGUAGES.map(({ label, code }) => ( i18n.changeLanguage(code)} selected={code === i18n.language}> {label} ))} ); } function MobileItems() { const { t, i18n } = useTranslation(); const [open, setOpen] = useState(false); return ( <> setOpen(true)}> setOpen(false)}> setOpen(false)}> {t("主页")} {NAV_ITEMS.map(({ label, path }) => ( {t(label)} ))} {SITE_LINKS.map(({ label, domain, active }) => ( {t(label)} ))} {LANGUAGES.map(({ label, code }) => ( i18n.changeLanguage(code)} selected={code === i18n.language}> {label} ))} {t("Twitter")} {t("GitHub")} ); } 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 ( ); } ================================================ 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 ( {Conf.features.contestTools ? ( ) : null} ); } ================================================ 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 {children}; } 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 {children}; } ================================================ 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; 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 ( {lines.map((text, index) => ( {text} ))} ); }; export type PieChartItem = { value: number; innerLabel?: string; outerLabel?: string; }; function defaultInnerLabel(item: T) { return item.innerLabel || ""; } function defaultOuterLabel(item: T) { return item.outerLabel || ""; } function labelLine(item: T) { if (!item.outerLabel) { return null; } return Pie.renderLabelLineItem(true, item); } export default function SimplePieChart({ 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; onSelect?: ((item: T | null) => void) | undefined; } & Partial) { 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) => ( 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 ( 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} ); } ================================================ 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 = ( {[ , ]} ); export default function Routes() { return ( {ROUTES} ); } ================================================ 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 ( <> {loading ? ( ) : ( <> {playerList && playerList.length ? ( {playerList.map((player) => ( ))}
    玩家 最低分 最低分比赛时间 最高分 最高分比赛时间 平均点数
    {player.maxGamePlayer.nickname} {player.minGamePlayer.score} {GameRecord.formatFullStartTime(player.minGame)} {player.maxGamePlayer.score} {GameRecord.formatFullStartTime(player.maxGame)} {Math.round(player.totalPoints / player.numGames)}
    ) : 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 { key: string; label: string; value: T; } type GroupParams = { type: "checkbox" | "radio"; items: CheckboxItem[]; selectedItems: Iterable> | null; onChange: (selectedItems: CheckboxItem[]) => void; i18nNamespace?: string | string[] | undefined; label?: string; }; function InternalRadioGroup({ items = [], selectedItems = null, // eslint-disable-next-line @typescript-eslint/no-empty-function onChange = () => {}, i18nNamespace = undefined, }: GroupParams) { 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) => { 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 ( {items.map((x) => ( } /> ))} ); } function InternalCheckboxGroup({ items = [], selectedItems = null, // eslint-disable-next-line @typescript-eslint/no-empty-function onChange = () => {}, i18nNamespace = undefined, }: GroupParams) { const { t } = useTranslation(i18nNamespace); const [highlightKey, setHighlightKey] = useState(null as string | null); const selectedItemKeys = useMemo(() => { const ret = new Set(); 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 ( {items.map((x) => ( theme.transitions.create("opacity"), }} control={} /> ))} ); } export function CheckboxGroup( props: GroupParams = { type: "checkbox", items: [], selectedItems: null, // eslint-disable-next-line @typescript-eslint/no-empty-function onChange: () => {}, i18nNamespace: undefined, label: "", } ) { const { t } = useTranslation(props.i18nNamespace); return ( {props.label && {t(props.label)}} {props.type === "checkbox" ? : } ); } ================================================ 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 ( )} // 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 ( {game.players.map((x) => ( ))} ); } ); const cellFormatTime = ({ cellData }: TableCellProps) => formatTime(cellData); const cellFormatFullTime = ({ rowData }: TableCellProps) => rowData.loading ? "" : GameRecord.formatFullStartTime(rowData); const cellFormatFullTimeMobile = ({ rowData }: TableCellProps) => rowData.loading ? ( "" ) : ( {GameRecord.formatStartDate(rowData)} {formatTime(rowData.startTime)} ); const cellFormatRank = ({ rowData, columnData }: TableCellProps) => !rowData || rowData.loading || !columnData.activePlayerId ? ( "" ) : ( {GameRecord.getPlayerRankLabel(rowData, columnData.activePlayerId) .slice(0, 1) .replace(/[0-9]/g, (s) => String.fromCodePoint(s.charCodeAt(0) + 0xfee0))} ); const cellFormatGameMode = ({ cellData }: TableCellProps) => (cellData ? modeLabel(parseInt(cellData)) : ""); type TableColumnDefKey = { key?: string; }; export type TableColumn = React.FunctionComponentElement | false | undefined | null; export type TableColumnDef = TableColumnDefKey & (() => TableColumn); // eslint-disable-next-line @typescript-eslint/ban-types export function makeColumn(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 && ( 等级} 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) => ( 顺位} 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> = {}) => ( 玩家} cellRenderer={({ rowData }: TableCellProps) => rowData && rowData.players ? : null } width={120} flexGrow={1} /> )); export const COLUMN_STARTTIME = makeColumn(() => ( 开始} cellRenderer={cellFormatTime} width={50} className="text-right" headerClassName="text-right" columnData={{ mobileProps: { width: 40, }, }} /> ))(); export const COLUMN_ENDTIME = makeColumn(() => ( 结束} cellRenderer={cellFormatTime} width={50} headerClassName="text-right" className="text-right" columnData={{ mobileProps: { width: 40, }, }} /> ))(); export const COLUMN_FULLTIME = makeColumn(() => ( 时间} 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 | 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 | 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(promise: Promise) { 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 | 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); } 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()); 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 {children}; } export function DataAdapterProviderCustom({ provider, children, }: { provider: DataProvider; children: ReactChild | ReactChild[]; }) { const { dataAdapter } = useDataAdapterCommon(provider, noop, []); return {children}; } ================================================ 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 {children}; } ================================================ 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 ( <> {Conf.availableModes.length > 1 && ( )} ); } ================================================ 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[0] & AnchorHTMLAttributes) => ( ); 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 ( {!isMasked && } {Conf.features.aiReview && !isMasked && ( )} ); } ); 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 ( {children} ); }; 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 ( <> {t("查找玩家")} {t("对局浏览")} {(model.type === undefined && model.selectedMode) || Conf.availableModes.length <= 1 ? ( ) : 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 ( ); } ================================================ 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 ( 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 | ({ type: "player" } & Partial); type DispatchModelUpdate = (props: ModelUpdate) => void; const DEFAULT_MODEL: ListingModel = { type: undefined, date: null, selectedMode: null, searchText: "" }; const ModelContext = React.createContext<[Readonly, 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 => { 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, DispatchModelUpdate], [model, dispatchModelUpdate] ); return ( {children} ); } ================================================ 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 ( { e.preventDefault(); open(player, game); }} title={t("查看牌谱")} target="_blank" rel="noopener noreferrer" display="block" color="inherit" > [{getLevelTag(level)}] {nickname} {score !== undefined && `[${score}]`} {!hideDetailIcon && ( )} {Conf.features.aiReview && game.uuid && showAiReviewIcon && ( )} ); }); ================================================ 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>(); 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 | 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 ; } return ; } return ( { 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 (
  • {" "} {getOptionLabel(option, t)}
  • ); }} isOptionEqualToValue={(option, value) => option.id === value.id} loading={isLoading} filterOptions={(x) => x} renderInput={(params) => ( {isLoading ? : null} ), }} /> )} /> ); } ================================================ 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( 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 { 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 ; } 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 ( ); } 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 : ), [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 {({ width }) => ( {memoColumns}
    )}
    ); } ================================================ 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 ( ); } export function GameRecordTableHomeView() { return ( ); } ================================================ 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 }) => ( {title && ( {title} )} {children} ); ================================================ 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 ( triggerRelayout()} onExited={() => triggerRelayout()}> setClosed(true) : undefined} sx={{ "& .MuiAlert-message": { overflow: "unset" }, ...sx }} > {title && {title} } {children} ); } ================================================ 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 ( ); } ================================================ 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>({ loader, loading = () => , }: { loader: () => Promise<{ default: T }>; loading?: () => ReactNode; }): React.ComponentType ? TProps : unknown> { const LazyComponent = React.lazy(loader); return function (props: T extends ComponentType ? TProps : unknown) { return ( ); }; } 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 & { href: LinkProps["to"]; }>((props, ref) => { const { href, ...other } = props; if (!href) { return ; } if (typeof href === "string" && /^https?:\/\//i.test(href)) { return ; } return ; }); ================================================ FILE: src/components/misc/loading.tsx ================================================ import { Box, CircularProgress } from "@mui/material"; export default function Loading({ size = "normal" }: { size?: "normal" | "small" }) { return ( ); } ================================================ 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[]; } & ButtonProps) { const [anchorEl, setAnchorEl] = React.useState(null); const open = Boolean(anchorEl); const handleClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); }; const handleClose = () => { setAnchorEl(null); }; const handleItemClick = (item: ReactElement) => { const onClick = item.props.onClick; if (!onClick) { return handleClose; } return (e: React.MouseEvent) => { handleClose(); onClick(e); }; }; return ( <> {React.Children.map(children, (x) => React.cloneElement(x, { onClick: handleItemClick(x) }))} ); } ================================================ 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 ( ); }; const NavButton = ({ href, children, ...props }: Omit, "href"> & Omit & { href: NavLinkProps["to"]; activeProps?: ButtonProps<"a"> }) => ( {children} ); 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({} as WindowScrollerChildProps); export const useScrollerProps = () => useContext(ScrollerContext); function Scroller({ children }: { children: ReactChild | ReactChild[] }) { return ( {scrollerProps => {children}} ); } 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; 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 ; } ================================================ 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; type DispatchModelUpdate = (props: ModelUpdate) => void; const DEFAULT_MODEL: Model = { selectedModes: [] }; // eslint-disable-next-line @typescript-eslint/no-empty-function const ModelContext = React.createContext<[Readonly, 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 {children}; } ================================================ 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 ( x.length === model.selectedModes.length && x.every((mode) => model.selectedModes.includes(mode)) ) ? "hidden" : "visible" } > ); } ================================================ 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) => ( )); 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 ? : 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 ( {CELLS} ); }); 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 ( 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>[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 = {}) => { if (!active || !payload || !payload.length) { return null; } const realPayload = payload[0].payload as DotPayload; return ( {GameRecord.formatFullStartTime(realPayload.game)}{" "} {realPayload.game.modeId ? modeLabel(realPayload.game.modeId) : ""} {getRankLabelByIndex(realPayload.rank)}{" "} {realPayload.delta > 0 ? "+" : ""} {realPayload.delta}pt {realPayload.game.players.map((x) => ( ))} ); }; 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 ; } const haveDelta = dataPoints.some((x) => x.delta !== 0); return ( {haveDelta && ( )} } /> ); }); 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 ( .MuiBox-root": { maxWidth: 480, width: "100%", justifySelf: "center", overflow: "hidden", }, }} > {t("和牌时")} t(x.outerLabel || "")} /> {t("放铳时")} t(x.outerLabel || "")} /> {t("放铳至")} t(x.outerLabel || "")} /> ); }); 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 ( {children} ); } 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 ( {children} ); } 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 ( <> {children} {timeEnabled ? ( setClosePending(true)} renderInput={(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 /> ) : ( setClosePending(true)} renderInput={(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} /> )} ); } 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 (
    全部 onSelectDate(date, end || dayjs().endOf("day"))} > 自定开始时间... onSelectDate(start || dayjs(Conf.dateMin), dayjs(date).endOf("minute"))} > 自定结束时间... {[4, 13, 26, 52].map((x) => ( selectWeek(x)}> ))} selectRange(dayjs().startOf("month"))}> 本月 selectRange(dayjs().startOf("month").subtract(1, "month"), dayjs().startOf("month").subtract(1, "second")) } > 上月 selectRange(dayjs().startOf("year"))}> 今年 selectRange(dayjs().startOf("year").subtract(1, "year"), dayjs().startOf("year").subtract(1, "second")) } > 去年 {[100, 200, 300, 500].map((x) => ( selectLimit(x)}> ))} {isThrone && ( selectRange(NEW_THRONE_TS)}> 新王座 selectRange(dayjs(Conf.dateMin), NEW_THRONE_TS)}> 旧王座 )}
    ); } ================================================ 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 ( <> {`${t("在{{ modeL }}之间一直进行对局,预测最终能达到的段位。", { modeL })}${ levelNames.length === 3 ? t("括号内为安定段位时的分数期望。") : "" }${notEnoughData ? t("(数据量不足,计算结果可能有较大偏差)") : ""}`} {!level.isKonten() && ( <>
    {`${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("/")}]`} )}
    {`${t("得点效率(各顺位平均 Pt 及平均得点 Pt 的加权平均值):")}${formatFixed3( PlayerMetadata.calculateExpectedGamePoint(metadata, mode, undefined, false) )}`} } valueProps={notEnoughData ? { fontStyle: "italic", fontWeight: 300, sx: { opacity: 0.5 } } : {}} > {PlayerMetadata.estimateStableLevel2(metadata, mode)} {notEnoughData && "?"}
    {level.isKonten() && level.isAllowedMode(mode) ? (expectedGamePoint / 100).toFixed(3) : expectedGamePoint.toFixed(1)} {estimatedNumGamesToChangeLevel && Math.abs(estimatedNumGamesToChangeLevel) < 10000 ? ` (${Math.abs(estimatedNumGamesToChangeLevel).toFixed(0)})` : ""} {notEnoughData && "?"} ); } ================================================ 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) => void }) { const { t } = useTranslation("form"); const updateSearchTextFromEvent = useCallback( (e: React.ChangeEvent) => 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 ( <> setRank(items[0].key)} /> [GameMode.王座, GameMode.王东, GameMode.三王座, GameMode.三王东].includes(x) ) } checked={model.kontenOnly || false} onChange={(e) => setKontenOnly(e.target.checked)} /> } /> ); } function ExtraSettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) { const { t } = useTranslation(); const [globalModel, globalUpdateModel] = useModel(); const [modelChanges, setModelChanges] = useState>({}); useEffect(() => { setModelChanges({}); }, [globalModel]); const mergedModel = useMemo(() => ({ ...globalModel, ...modelChanges } as Model), [globalModel, modelChanges]); const onSubmit = useCallback(() => { if (modelChanges.type === "player") { globalUpdateModel(modelChanges); } onClose(); }, [globalUpdateModel, modelChanges, onClose]); const onUpdateModel = useCallback( (changes: Partial) => setModelChanges((prev) => ({ ...prev, ...changes })), [setModelChanges] ); return ( {t("筛选")} ); } export default function ExtraSettings() { const { t } = useTranslation(); const [open, setOpen] = useState(false); const [model, updateModel] = useModel(); const extraSettingsEnabled = Model.hasAdvancedParams(model); return ( {extraSettingsEnabled ? ( ) : ( )} setOpen(false)}> ); } ================================================ 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) => { if (v < histogram.min || v > histogram.max) { return <>; } const bin = Math.floor((v - histogram.min) / binStep); return ( ); }; return ( {splitPoint < histogram.bins.length && ( )} {!Number.isInteger(binStep) && histogram.bins.length > 60 && ( {extraMeanLines.map((v, index) => ( ))} )} ); }); const StatHistogramInner = React.memo(function ({ mode, value, valueFormatter, rankMeans, histogramData, }: { mode: GameMode; value?: number; valueFormatter: (value: number) => string; rankMeans: number[]; histogramData: Omit & Required>; }) { 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 ( {valueFormatter(histogramData.mean)} {rankMeans.map(valueFormatter).join(" / ")} {value !== undefined && ( {formatPercent(numPos / numTotal)} )} ); }); 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 ( ); } 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 ( {hideValue ? "" : typeof value === "string" ? value : formatter(value)} ); } 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 ; } function PlayerExtendedStatsView({ stats }: { stats: PlayerExtendedStats }) { return ( <> ); } 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 && ( <> {LevelWithDelta.getTag(metadata.cross_stats?.max_level || metadata.max_level)} {LevelWithDelta.formatAdjustedScore(fixMaxLevel(metadata.cross_stats?.max_level || metadata.max_level))} )} {stats.count} ); } function RiichiStats({ stats }: { stats: PlayerExtendedStats; metadata: PlayerMetadata }) { return ( <> {(stats.立直多面 || stats.立直多面 === 0) && ( 多面立直局数 / 立直局数
    听牌两种或以上即视为多面(含对碰)

    } /> )} {(stats.立直好型2 || stats.立直好型2 === 0) && ( 好型立直局数 / 立直局数
    立直时听牌可见剩余 6 枚或以上视为好型

    } /> )} ); } function BasicStats({ metadata, hasAdvancedParams }: { metadata: PlayerMetadata; hasAdvancedParams: boolean }) { return ( <> {metadata.count} {LevelWithDelta.getTag(metadata.cross_stats?.level || metadata.level)} {LevelWithDelta.formatAdjustedScore(metadata.cross_stats?.level || metadata.level)} {metadata.avg_rank.toFixed(3)} {formatPercent(metadata.negative_rate)} {!hasAdvancedParams && } ); } function LuckStats({ stats }: { stats: PlayerExtendedStats }) { return ( <> {stats.役满 || 0} {stats.累计役满 || 0} {stats.最大累计番数 || 0} {stats.流满 || 0} {stats.W立直 || 0} } hideValue={!stats?.平均起手向听亲} /> } hideValue={!stats?.平均起手向听子} /> ); } function LargestLost({ stats, metadata }: { stats: PlayerExtendedStats; metadata: PlayerMetadata }) { const { t } = useTranslation(); if (!stats.最近大铳) { return {t("无超过满贯大铳")}; } return ( {FanStatEntryList.formatFanSummary(stats.最近大铳.fans)} {GameRecord.formatFullStartTime(stats.最近大铳.start_time)} {stats.最近大铳.fans.map((x) => ( {FanStatEntry2.formatFan(x)} ))} ); } function PlayerStats({ metadata, isChangingSettings, hasAdvancedParams, }: { metadata: PlayerMetadata; isChangingSettings: boolean; hasAdvancedParams: boolean; }) { return ( {!isChangingSettings ? : <>} ); } const BlurrableBox = ({ blur, sx, ...props }: { blur: boolean } & BoxProps) => ( ); 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(); 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(); const [model, updateModel] = useModel(); const availableModes = useMemo( () => latestDataAdapter.getMetadata()?.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("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 ( {isChangingSettings && ( )} {hasMetadata ? ( {metadata?.cross_stats?.nickname || metadata?.nickname} {getAccountZoneTag(metadata!.id)} {metadata?.cross_stats?.nickname || metadata?.nickname} {t("最近走势")} {t("累计战绩")} ) : ( )} ); /* 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 ( = 1 || Conf.availableModes.length <= 1 ? "visible" : "hidden" }} > [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 && ( )} ); } ================================================ 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 ( {t("胜率:")} {formatPercent(item.win / item.count)} {t("玩家")} {t("对手")} {t("平均顺位")} {formatFixed3(RankRates.getAvg(item.resultSelf))} {formatFixed3(RankRates.getAvg(item.resultOpponent))} {t("平均得点")} {formatFixed3(item.pointSelf / item.count)} {formatFixed3(item.pointOpponent / item.count)} {["一", "二", "三", "四"].slice(0, item.resultSelf.length).map((label, index) => ( {t(label + "位")} {formatPercent(item.resultSelf[index] / item.count)} ({item.resultSelf[index]}) {formatPercent(item.resultOpponent[index] / item.count)} ({item.resultOpponent[index]}) ))} ); } 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(game.players.length).fill(0) as RankRates, resultOpponent: new Array(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 ; } return ( {rates.slice(0, numDisplay).map((x) => ( {x.player.nickname} updateModel({ type: "player", searchText: x.player.nickname })} sx={{ margin: "-5px 0", verticalAlign: "text-top" }} > } arrow> {formatPercent(x.count / numProcessedGames)} ({x.count}) ))} ); } export default function SameMatchRate({ numDisplay = 12, currentAccountId = 0 }) { return ( ); } ================================================ 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 ? ( ) : ( ); }); 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("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 {children}; } ================================================ 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 = React.memo(function () { useTranslation(); const { starredPlayers } = useStarPlayer(); return ( {starredPlayers.length ? [ } endIcon={} sx={{ ".MuiButton-endIcon": { marginLeft: 0 } }} > {starredPlayers.map((p) => ( [{new Level(p.levelId).getTag()}] {p.name} ))} , ] : []} ); }); export default StarredPlayerMenu; ================================================ FILE: src/components/playerDetails/statItem.tsx ================================================ import { Box, Typography, Tooltip, TooltipProps, styled, tooltipClasses, TypographyProps, useTheme, } from "@mui/material"; import useMediaQuery from "@mui/material/useMediaQuery"; import React, { ReactNode } from "react"; import { useTranslation } from "react-i18next"; export const StatTooltip = styled(({ className, ...props }: TooltipProps) => { const theme = useTheme(); const matches = useMediaQuery(theme.breakpoints.up("md")); return ; })(({ theme }) => ({ [`& .${tooltipClasses.tooltip}.${tooltipClasses.tooltip}.${tooltipClasses.tooltip}.${tooltipClasses.tooltip}`]: { textAlign: "center", marginTop: theme.spacing(1), marginBottom: theme.spacing(1), "&, & *": { userSelect: "none", }, }, })); export const StatList = styled(Box)(({ theme }) => ({ display: "grid", justifyContent: "space-between", gridGap: theme.spacing(1.5), gridTemplateColumns: "1fr", "&, & *": { userSelect: "none", }, [theme.breakpoints.down("sm")]: { gridGap: theme.spacing(0.5), "& > div": { borderBottom: `1px dashed ${theme.palette.grey[500]}`, paddingBottom: theme.spacing(0.5), }, }, "&:not(.mobile-1col)": { [theme.breakpoints.down("sm") + " and (min-width: 410px)"]: { gridTemplateColumns: "repeat(2, min-content)", ".lang-en &, .lang-ko &": { gridTemplateColumns: "1fr", }, }, [theme.breakpoints.down("sm") + " and (min-width: 440px)"]: { ".lang-ko &": { gridTemplateColumns: "repeat(2, min-content)", }, }, [theme.breakpoints.down("sm") + " and (min-width: 480px)"]: { ".lang-en &": { gridTemplateColumns: "repeat(2, min-content)", }, }, }, [theme.breakpoints.up("sm")]: { gridTemplateColumns: "repeat(2, min-content)", }, "@media (min-width: 767px)": { gridTemplateColumns: "repeat(3, min-content)", ".lang-en &, .lang-ko &": { gridTemplateColumns: "repeat(2, min-content)", }, }, [theme.breakpoints.up("lg")]: { ".lang-en &, .lang-ko &": { gridTemplateColumns: "repeat(3, min-content)", }, }, })); const StatItem = React.memo(function ({ label, description = "", i18nNamespace, children, valueProps = {}, extraTip, }: { label: string; description?: ReactNode; i18nNamespace?: string[]; children: React.ReactChild; valueProps?: TypographyProps; extraTip?: ReactNode | (() => ReactNode); }) { const { t } = useTranslation(i18nNamespace); if (typeof extraTip === "function") { extraTip = extraTip(); } const translatedTip = (description ? (typeof description === "string" ? t(description).toString() : description) : "") || ""; return ( {t(label)} {translatedTip && typeof translatedTip === "string" ? ( {translatedTip} ) : ( translatedTip )} {extraTip} ) } arrow > {children} ); }); export default StatItem; ================================================ FILE: src/components/ranking/careerRanking.tsx ================================================ /* eslint-disable @typescript-eslint/indent */ import React from "react"; import { CareerRankingItem, CareerRankingType } from "../../data/types/ranking"; import { useAsyncFactory } from "../../utils/async"; import { getCareerRanking } from "../../data/source/misc"; import Loading from "../misc/loading"; import { generatePlayerPathById } from "../gameRecords/routeUtils"; import { LevelWithDelta, GameMode } from "../../data/types"; import { formatPercent } from "../../utils/index"; import { ModelModeProvider, ModelModeSelector, useModel } from "../modeModel"; import { useTranslation } from "react-i18next"; import Conf from "../../utils/conf"; import { CheckboxGroup } from "../form"; import { Box, Grid, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography, Link, } from "@mui/material"; type ExtraColumnInternal = { label: string; value: (item: CareerRankingItem) => string; }; type ExtraColumn = { label: string; value: (item: CareerRankingItem, mode: GameMode[]) => string; }; function RankingTable({ rows = null as CareerRankingItem[] | null, formatter = formatPercent as (x: number, item: CareerRankingItem, modes: GameMode[]) => string, showNumGames = true, valueLabel = "", extraColumns = [] as ExtraColumnInternal[], modes = [] as GameMode[], }) { const { t } = useTranslation(); if (!rows) { return ; } return ( {t("排名")} {t("玩家")} {showNumGames && {t("对局数")}} {extraColumns.map((x) => ( {t(x.label)} ))} {valueLabel} {rows.map((x, index) => ( {index + 1} [{LevelWithDelta.getTag(x.level)}] {x.nickname} {showNumGames && {x.count}} {extraColumns.map((col) => ( {col.value(x)} ))} {formatter(x.rank_key, x, modes)} ))}
    ); } export function CareerRankingColumn({ type, title, formatter = formatPercent, showNumGames = true, valueLabel = "", disableMixedMode = false, extraColumns = [], forceMode = undefined, }: { type: CareerRankingType; title: string; formatter?: (x: number, item: CareerRankingItem, modes: GameMode[]) => string; showNumGames?: boolean; valueLabel?: string; disableMixedMode?: boolean; extraColumns?: ExtraColumn[]; forceMode?: undefined | GameMode | number; }) { const { t } = useTranslation(); const [model] = useModel(); const modes = forceMode === undefined ? model.selectedModes.sort((a, b) => a - b) : [forceMode]; const isMixedMode = modes.length !== 1; const data = useAsyncFactory( () => modes.length > 0 ? getCareerRanking(type, modes.join("."), model.careerRankingMinGames) : Promise.resolve([]), [type, model], `getCareerRanking-${modes.join(".")}-${model.careerRankingMinGames || 300}` ); return ( <> {t(title)} {!disableMixedMode || !isMixedMode ? ( ({ ...x, value: (item) => x.value(item, modes) }))} /> ) : ( {t("请选择模式")} )} ); } export function CareerRankingPlain({ children, }: { children: | React.ReactElement> | React.ReactElement>[]; }) { if (!("length" in children)) { children = [children]; } return ( {children.map((x, i) => ( {x} ))} ); } function CareerRankingInner({ children, }: { children: | React.ReactElement> | React.ReactElement>[]; }) { const [model, updateModel] = useModel(); const { t } = useTranslation(); if (!("length" in children)) { children = [children]; } return ( <> { updateModel({ careerRankingMinGames: newItems[0].value }); }} /> {children} ); } export function CareerRanking({ children, }: { children: | React.ReactElement> | React.ReactElement>[]; }) { return ( {children} ); } ================================================ FILE: src/components/ranking/deltaRanking.tsx ================================================ import { DeltaRankingItem, RankingTimeSpan } from "../../data/types/ranking"; import { useAsyncFactory } from "../../utils/async"; import { getDeltaRanking } from "../../data/source/misc"; import Loading from "../misc/loading"; import { generatePlayerPathById } from "../gameRecords/routeUtils"; import { GameMode, LevelWithDelta } from "../../data/types"; import { useModel, ModelModeSelector, ModelModeProvider } from "../modeModel"; import { useTranslation } from "react-i18next"; import Conf from "../../utils/conf"; import { Box, Grid, Table, TableBody, TableCell, TableContainer, TableRow, Typography, Link, TypographyProps, GridProps, } from "@mui/material"; import { useState } from "react"; import { CheckboxGroup } from "../form"; function RankingTable({ rows = [] as DeltaRankingItem[] }) { return ( {rows.map((x) => ( [{LevelWithDelta.getTag(x.level)}] {x.nickname} {x.delta} ))}
    ); } const Title = (props: TypographyProps) => ; const GridContainer = (props: GridProps) => ; function DeltaRankingInner() { const { t } = useTranslation(); const [selectedTimeSpan, setSelectedTimeSpan] = useState(RankingTimeSpan.FourWeeks); const data = useAsyncFactory( () => getDeltaRanking(selectedTimeSpan), [selectedTimeSpan], "getDeltaRanking_" + selectedTimeSpan ); const [model] = useModel(); const modes = model.selectedModes; const modeKey = modes.length !== 1 ? 0 : modes[0]; const availableModes = ( data ? Object.keys(data) .filter((x) => x !== "0") .map((x) => parseInt(x, 10) as GameMode) : [] ).sort((a, b) => Conf.availableModes.indexOf(a) - Conf.availableModes.indexOf(b)); return ( <> { setSelectedTimeSpan(newItems[0].value); }} /> {data ? ( {t("苦主榜")} {t("汪汪榜")} {t("劳模榜")} ) : ( )} ); } export default function DeltaRanking() { return ( ); } ================================================ FILE: src/components/ranking/index.tsx ================================================ import React from "react"; import { Alert } from "../misc/alert"; import DeltaRanking from "./deltaRanking"; import { CareerRanking, CareerRankingColumn, CareerRankingPlain } from "./careerRanking"; import { CareerRankingType, LevelWithDelta } from "../../data/types"; import { PlayerMetadata } from "../../data/types/metadata"; import { formatFixed3, formatIdentity, formatPercent, formatRound } from "../../utils/index"; import { ViewRoutes, SimpleRoutedSubViews, NavButtons, RouteDef } from "../routing"; import { ViewSwitch } from "../routing/index"; import { useTranslation } from "react-i18next"; import Conf from "../../utils/conf"; const SANMA = Conf.rankColors.length === 3; const ROUTES = ( PlayerMetadata.estimateStableLevel2({ ...metadata, level: metadata.ranking_level }, modes[0]) } disableMixedMode /> PlayerMetadata.estimateStableLevel({ ...metadata, level: metadata.ranking_level }, modes[0]) } disableMixedMode /> `${LevelWithDelta.format(metadata.max_level)}`} /> `${t("平均打点")}/${t("平均铳点")}`}> `${t("打点效率")}/${t("铳点损失")}`}> x.extended_stats && "count" in x.extended_stats ? formatRound(x.extended_stats.打点效率) : "", }, { label: "铳点损失", value: (x) => x.extended_stats && "count" in x.extended_stats ? formatRound(x.extended_stats.铳点损失) : "", }, ]} /> x.extended_stats && "和牌率" in x.extended_stats ? formatPercent(x.extended_stats.和牌率) : "", }, { label: "放铳率", value: (x) => x.extended_stats && "放铳率" in x.extended_stats ? formatPercent(x.extended_stats.放铳率) : "", }, ]} /> ); export default function Routes() { const { t } = useTranslation(); if (!Array.isArray(Conf.features.ranking)) { return <>; } return ( {ROUTES} <> {t("排行榜非实时更新,可能会有数小时的延迟。")} ); } ================================================ FILE: src/components/recentHighlight/index.tsx ================================================ import React, { ReactNode, useEffect, useMemo } from "react"; import Helmet from "react-helmet"; import { DataProvider } from "../../data/source/records/provider"; import { DataAdapterProviderCustom } from "../gameRecords/dataAdapterProvider"; import GameRecordTable, { Column } from "../gameRecords/table"; import { COLUMN_PLAYERS, COLUMN_FULLTIME, makeColumn } from "../gameRecords/columns"; import { GameRecord, FanStatEntryList, HighlightEvent, GameRecordWithEvent } from "../../data/types"; import { TableCellProps } from "react-virtualized/dist/es/Table"; import { sum } from "../../utils"; import { Trans, useTranslation } from "react-i18next"; import i18n from "../../i18n"; import { ModelModeProvider, ModelModeSelector, useModel } from "../modeModel"; import Conf from "../../utils/conf"; import { Box, Tooltip } from "@mui/material"; const t = i18n.t.bind(i18n); const EventInfo = ({ title, children }: { title: ReactNode; children: ReactNode }) => ( {title}} arrow placement="right"> {children} ); function buildEventInfo({ cellData }: TableCellProps) { if (!cellData) { return null; } const event = cellData as HighlightEvent; if (!event.fan[0].役满) { return ( {sum(event.fan.map((x) => x.count))}
    累计役满
    ); } if (event.fan.length === 1) { const label = t(event.fan[0].label); if (i18n.language === "en") { return {label}; } if (label.length > 4) { return ( {label.slice(0, 4)}
    {label.slice(4)}
    ); } return label; } else if (event.fan.length === 2) { return ( {event.fan[0].label}
    {event.fan[1].label}
    ); } return ( {FanStatEntryList.formatFanSummary(event.fan)} ); } const COLUMN_EVENTINFO = makeColumn(() => ( 类型
    } cellRenderer={buildEventInfo} width={80} /> ))(); function getEventPlayerId(rec: GameRecord) { return (rec as GameRecordWithEvent).event.player; } function RecentHighlightInner() { const [model, updateModel] = useModel(); const provider = useMemo(() => { if (!Conf.availableModes.length) { return DataProvider.createHightlight(undefined); } return model.selectedModes && model.selectedModes.length ? DataProvider.createHightlight(model.selectedModes[0]) : null; }, [model]); useEffect(() => { if (!model.selectedModes || !model.selectedModes.length) { if (Conf.availableModes.length) { updateModel({ selectedModes: [Conf.availableModes[0]] }); } } }, [model, updateModel]); if (!provider) { return <>; } return ( ); } export default function RecentHighlight() { const { t } = useTranslation(); return ( <> ); } ================================================ FILE: src/components/routing/index.tsx ================================================ export * from "./subView"; ================================================ FILE: src/components/routing/subView.tsx ================================================ import React from "react"; import { useContext } from "react"; import { useRouteMatch, Switch, Route, Redirect, useLocation } from "react-router"; import { Helmet } from "react-helmet"; import { TFunction, useTranslation } from "react-i18next"; import { Stack, StackProps } from "@mui/material"; import NavButton from "../misc/navButton"; type RouteDefProps = { path: string; exact?: boolean; title: string | ((t: TFunction) => string); disabled?: boolean; children: React.ReactNode; }; export const RouteDef: React.FunctionComponent = () => { throw new Error("Not intended for rendering"); }; type RoutesProps = { children: React.FunctionComponentElement[] }; export const ViewRoutes: React.FunctionComponent = () => { throw new Error("Not intended for rendering"); }; const Context = React.createContext([]); export function NavButtons({ replace = false, keepState = false, withQueryString = false, sx = {} as StackProps["sx"], }) { const { t } = useTranslation("navButtons"); const routes = useContext(Context); const match = useRouteMatch() || { url: "" }; const urlBase = match.url.replace(/\/+$/, ""); return ( {routes .filter((x) => !x.disabled) .map((route) => ( ({ pathname: `${urlBase}/${route.path}`, state: keepState ? loc.state : undefined, ...(withQueryString && loc.search ? { search: loc.search } : {}), })} replace={replace} exact={!!route.exact} color="info" activeProps={{ variant: "contained" }} disableElevation sx={{ mr: 1 }} > {typeof route.title === "string" ? t(route.title) : route.title(t)} ))} ); } export function ViewSwitch({ defaultRenderDirectly = false, mutateTitle = true, children, }: { defaultRenderDirectly?: boolean; mutateTitle?: boolean; children?: React.ReactChild | React.ReactChildren; }) { const { t } = useTranslation("navButtons"); const routes = useContext(Context); const match = useRouteMatch() || { url: "" }; const loc = useLocation(); const urlBase = match.url.replace(/\/+$/, ""); if (loc.pathname.indexOf("%") !== -1) { try { decodeURI(loc.pathname); } catch (e) { return ; } } return ( {routes .filter((x) => !x.disabled) .map((route) => ( {mutateTitle && ( {typeof route.title === "string" ? t(route.title) : route.title(t)} )} {route.children} ))} {defaultRenderDirectly ? ( routes.filter((x) => !x.disabled)[0].children ) : ( !x.disabled)[0].path}` }} push={false} /> )} {children} ); } export function SimpleRoutedSubViews({ children, }: { children: [React.FunctionComponentElement, ...(React.ReactChild | React.ReactChildren)[]]; }) { return ( x.props)}>{children.slice(1)} ); } ================================================ FILE: src/components/statistics/dataByRank.tsx ================================================ import { forwardRef, ReactElement, useMemo, useState, VFC } from "react"; import { formatPercent, formatFixed3 } from "../../utils/index"; import { useAsyncFactory } from "../../utils/async"; import { getGlobalStatistics, getGlobalStatisticsSnapshot, getGlobalStatisticsYear } from "../../data/source/misc"; import Loading from "../misc/loading"; import { useModel } from "../modeModel/model"; import { Level } from "../../data/types/level"; import { ModelModeSelector } from "../modeModel"; import { useTranslation } from "react-i18next"; import Conf from "../../utils/conf"; import { Box, Table, TableCell as MuiTableCell, TableContainer, TableHead, TableRow, TableCellProps, TableBody, Typography, ToggleButtonGroup, ToggleButton, Tooltip, ToggleButtonProps, TooltipProps, tooltipClasses, } from "@mui/material"; import { styled } from "@mui/material/styles"; import { DatePicker } from "../form"; import dayjs from "dayjs"; import { CalendarToday } from "@mui/icons-material"; import { GameMode } from "../../data/types"; const HEADERS = ["等级"].concat(["一位率", "二位率", "三位率", "四位率"].slice(0, Conf.rankColors.length), [ "被飞率", "平均顺位", "和牌率", "放铳率", "副露率", "立直率", "自摸率", "流局率", "流听率", "对战数", "在位记录", ]); const HEADERS2 = ["等级", "平均打点", "平均铳点", "打点效率", "铳点损失", "净打点效率"]; const TableCell = (props: TableCellProps) => ( ); const HeaderBox = styled(Box)({ display: "inline", letterSpacing: "0.5em", writingMode: "vertical-lr", verticalAlign: "middle", ".lang-en &": { letterSpacing: "0.05em", marginBottom: "0.75em", }, }); type TooltipToggleButtonProps = ToggleButtonProps & { TooltipProps: Omit; }; export const StyledTooltip = styled(({ className, ...props }: TooltipProps) => { return ; })(() => ({ [`& .${tooltipClasses.tooltip}.${tooltipClasses.tooltip}.${tooltipClasses.tooltip}.${tooltipClasses.tooltip}`]: { textAlign: "center", }, })); const TooltipToggleButton: VFC = forwardRef(({ TooltipProps, ...props }, ref) => { return ( ); }); const dataLoaders = { overall: getGlobalStatistics, year: getGlobalStatisticsYear, }; export default function DataByRank() { const { t } = useTranslation(); const [model] = useModel(); const [dataRange, setDataRange] = useState("overall" as keyof typeof dataLoaders | "date"); const [cutoff] = useState(() => dayjs().startOf("day").add(-1, "day")); const [selectedDate, setSelectedDate] = useState(() => cutoff); const effectiveDataRange = useMemo( () => (dataRange === "overall" && selectedDate.isBefore(cutoff) ? "date" : dataRange), [dataRange, selectedDate, cutoff] ); const factory = useMemo( () => effectiveDataRange !== "date" ? dataLoaders[effectiveDataRange] : (modes: GameMode[]) => getGlobalStatisticsSnapshot(selectedDate, modes), [effectiveDataRange, selectedDate] ); const modes = useMemo( () => model.selectedModes .filter((x) => (Conf.features.statisticsSubPages.dataByRank || []).includes(x)) .sort((a, b) => a - b), [model] ); const data = useAsyncFactory( () => (modes && modes.length ? factory(modes) : Promise.resolve(null)), [modes, effectiveDataRange, selectedDate, factory], "getGlobalStatistics_" + effectiveDataRange + (effectiveDataRange === "date" ? selectedDate.format("YYYYMMDD") : "") + modes.join(".") ); const modeData = useMemo(() => { if (!data) { return undefined; } const selectedData = data[modes.join(".")]; if (!selectedData) { return undefined; } const modeData = Object.entries(selectedData); if (!modeData) { return undefined; } modeData.sort((a, b) => a[0].localeCompare(b[0])); return modeData; }, [data, modes]); const haveNumPlayers = modeData && Object.values(modeData)[0][1].num_players; const headers = useMemo(() => (haveNumPlayers ? HEADERS : HEADERS.slice(0, HEADERS.length - 1)), [haveNumPlayers]); if (!Conf.features.statisticsSubPages.dataByRank) { return <>; } return ( <> value && value !== "date" && (setDataRange(value), setSelectedDate(cutoff))} value={effectiveDataRange} size="small" > {t("全体")} {t("活跃玩家")} { setSelectedDate(date); setDataRange("overall"); }} renderInput={({ inputRef, InputProps }) => ( {effectiveDataRange === "date" ? data?._lastModified?.format("YYYY-MM-DD") || "..." : t("日期", { ns: "form" })} )} /> {modeData ? ( <> {headers.map((x) => ( {t(x)} ))} {modeData.map(([levelId, levelData]) => ( {new Level(parseInt(levelId)).getTag()} {levelData.basic.rank_rates.slice(0, Conf.rankColors.length).map((x, i) => ( {formatPercent(x)} ))} {formatPercent(levelData.basic.negative_rate)} {formatFixed3(levelData.basic.avg_rank)} {formatPercent(levelData.extended.和牌率)} {formatPercent(levelData.extended.放铳率)} {formatPercent(levelData.extended.副露率)} {formatPercent(levelData.extended.立直率)} {formatPercent(levelData.extended.自摸率)} {formatPercent(levelData.extended.流局率)} {formatPercent(levelData.extended.流听率)} {levelData.basic.count} {haveNumPlayers && {levelData.num_players}} ))}
    {HEADERS2.map((x) => ( {t(x)} ))} {modeData.map(([levelId, levelData]) => ( {new Level(parseInt(levelId)).getTag()} {levelData.extended.平均打点} {levelData.extended.平均铳点} {levelData.extended.打点效率} {levelData.extended.铳点损失} {levelData.extended.净打点效率} ))}
    {t("统计对战数:")} {Math.floor( modeData.map(([, levelData]) => levelData.basic.count).reduce((a, b) => a + b, 0) / Conf.rankColors.length )} ) : ( )} ); } ================================================ FILE: src/components/statistics/fanStats.tsx ================================================ import React from "react"; import { formatPercent } from "../../utils/index"; import { useAsyncFactory } from "../../utils/async"; import { getFanStats } from "../../data/source/misc"; import Loading from "../misc/loading"; import { FanStatEntry, FanStats, GameMode, modeLabelNonTranslated } from "../../data/types"; import { useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; import Conf from "../../utils/conf"; import { Grid, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from "@mui/material"; const SORTERS: (undefined | ((a: FanStatEntry, b: FanStatEntry) => number))[] = [ undefined, (a, b) => a.count - b.count, (a, b) => b.count - a.count, ]; export default function FanStatsView() { const { t } = useTranslation(); const data = useAsyncFactory(getFanStats, [], "getFanStats"); const [sorterIndex, setSorterIndex] = useState(0); const sortedData = useMemo((): FanStats | undefined => { if (!data) { return undefined; } if (!SORTERS[sorterIndex]) { return data; } const ret = { ...data }; for (const key of Object.keys(ret)) { ret[key] = { ...ret[key], entries: [...ret[key].entries].sort(SORTERS[sorterIndex]), }; } return ret; }, [data, sorterIndex]); if (!sortedData) { return ; } return ( <> {Object.entries(sortedData) .map(([modeId, value]) => [parseInt(modeId, 10) as GameMode, value] as [GameMode, typeof value]) .sort(([id1], [id2]) => Conf.availableModes.indexOf(id1) - Conf.availableModes.indexOf(id2)) .map(([mode, value]) => ( {t(modeLabelNonTranslated(mode))} {t("记录和出局数:")} {value.count} setSorterIndex((sorterIndex + 1) % SORTERS.length)} sx={{ cursor: "pointer" }} > {t("役")} {t("记录数")} {t("比率")} {value.entries.map((x) => ( {t(x.label)} {x.count} {x.count ? x.count / value.count < 0.0001 ? "<0.01%" : formatPercent(x.count / value.count) : ""} ))}
    ))}
    ); } ================================================ FILE: src/components/statistics/index.tsx ================================================ import React from "react"; import { ModelModeProvider } from "../modeModel"; import { ViewRoutes, SimpleRoutedSubViews, NavButtons, RouteDef } from "../routing"; import { ViewSwitch } from "../routing/index"; import RankBySeats from "./rankBySeats"; import DataByRank from "./dataByRank"; import FanStats from "./fanStats"; import Conf from "../../utils/conf"; import NumPlayerStats from "./numPlayerStats"; const ROUTES = ( ); export default function Routes() { return ( {ROUTES} ); } ================================================ FILE: src/components/statistics/numPlayerStats.tsx ================================================ import { Box, Grid, Table, TableBody, TableCell, TableRow, Typography } from "@mui/material"; import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { getLevelStatistics } from "../../data/source/misc"; import { getZoneTag, Level, LevelStatistics, LevelStatisticsItem } from "../../data/types"; import { formatPercent } from "../../utils"; import { useAsyncFactory } from "../../utils/async"; import SimplePieChart, { PieChartItem } from "../charts/simplePieChart"; import Loading from "../misc/loading"; function groupData( raw: LevelStatistics, getLabel: (x: LevelStatisticsItem) => string, getKey = getLabel ): (PieChartItem & { percent: string; key: string })[] { const map = new Map(); const labels: string[] = []; for (const item of raw) { const key = getKey(item); const list = map.get(key) || []; list.push(item); if (!map.has(key)) { map.set(key, list); labels.push(key); } } const items = labels.map((key) => ({ value: map .get(key) ?.map((x) => x[2]) .reduce((a, b) => a + b, 0) || 0, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion label: getLabel(map.get(key)![0]!), key, })); const total = items.reduce((a, b) => a + b.value, 0); return items.map((x) => ({ key: x.key, value: x.value, percent: formatPercent(x.value / total), innerLabel: x.label.replace( /\{\{(\w+)\}\}/g, (_, key) => ({ value: x.value, percentage: formatPercent(x.value / total) }[key as string] as string) ), })); } export default function NumPlayerStats() { const { t } = useTranslation(); const data = useAsyncFactory(getLevelStatistics, [], "getLevelStatistics"); const serverStats = useMemo( () => data ? groupData( data, (x) => `${getZoneTag(x[0])} {{value}} / {{percentage}}`, (x) => x[0].toString() ) : [], [data] ); const [selectedServer, setSelectedServer] = useState(null as null | typeof serverStats[0]); const levelStats = useMemo(() => { if (!data) { return []; } const filteredData = selectedServer ? data.filter((x) => x[0].toString() === selectedServer.key) : data; const majorLevelHandled = new Map(); return groupData(filteredData, (x) => new Level(x[1]).getTag()).map( (x: ReturnType[0] & { majorLevel?: { count: number; entries: number } }) => { const majorLevel = x.innerLabel?.replace(/\d+$/, ""); if (majorLevel) { const record = majorLevelHandled.get(majorLevel) || { count: 0, entries: 0 }; if (!record.count) { x.majorLevel = record; majorLevelHandled.set(majorLevel, record); } record.count += x.value; record.entries++; } return x; } ); }, [data, selectedServer]); if (!data) { return ; } const total = levelStats.reduce((a, b) => a + b.value, 0); return ( {t("按服务器")} {t("按等级")} {levelStats.map((x) => ( {x.innerLabel} {x.value} {x.percent} {x.majorLevel && ((x.majorLevel?.entries || 0) > 1 ? ( <> {x.majorLevel?.count} {formatPercent((x.majorLevel?.count || 0) / total)} ) : ( ))} ))}
    ); } ================================================ FILE: src/components/statistics/rankBySeats.tsx ================================================ import React from "react"; import { useAsyncFactory } from "../../utils/async"; import { getRankRateBySeat } from "../../data/source/misc"; import Loading from "../misc/loading"; import { useMemo } from "react"; import { useModel, ModelModeSelector } from "../modeModel"; import SimplePieChart from "../charts/simplePieChart"; import { useTranslation } from "react-i18next"; import { RankRates } from "../../data/types"; import Conf from "../../utils/conf"; import { Grid, Typography } from "@mui/material"; const SEAT_LABELS = "东南西北"; function Chart({ rates, numGames, aspect = 1 }: { rates: RankRates; numGames: number; aspect?: number }) { const { t } = useTranslation(); const items = useMemo( () => rates.map((x, index) => ({ value: x, outerLabel: t(SEAT_LABELS[index]), innerLabel: `${(x * 100).toFixed(2)}%\n[${Math.round(x * numGames)}]`, })), [rates, numGames, t] ); return ; } export default function RankBySeats() { const { t } = useTranslation(); const data = useAsyncFactory(getRankRateBySeat, [], "getRankRateBySeat"); const [model] = useModel(); if (!data) { return ; } const selectedData = Conf.availableModes.length ? model.selectedModes && model.selectedModes.length && data[model.selectedModes[0]] : data[0]; return ( <> {selectedData ? ( <> {t("坐席吃一率")} {t(`坐席吃${selectedData.length > 4 ? "四" : "三"}率`)} {t("统计对战数:")} {selectedData.numGames} ) : ( <> )} ); } ================================================ FILE: src/data/source/api.ts ================================================ /* eslint-disable @typescript-eslint/no-empty-function */ import dayjs from "dayjs"; import Conf from "../../utils/conf"; import { savePreference } from "../../utils/preference"; const DATA_MIRRORS = [ "https://5-data.amae-koromo.com/", "https://1.data.amae-koromo.com/", "https://2.data.amae-koromo.com/", "https://4.data.amae-koromo.com/", ]; const PROBE_TIMEOUT = 15000; let selectedMirror = DATA_MIRRORS[0]; let onMaintenance: (msg: string) => void = () => {}; export function setMaintenanceHandler(handler: (msg: string) => void) { onMaintenance = handler; } export const getApiPrefix = () => selectedMirror + Conf.apiSuffix; async function fetchWithTimeout( url: string, opts: Parameters[1] = {}, timeout = 5000 ): Promise { const abortController = window.AbortController ? new AbortController() : { signal: undefined, abort: () => {} }; const timeoutToken = setTimeout(function () { abortController.abort(); }, timeout); const ret = fetch(url, { ...opts, signal: abortController.signal }) as Promise; ret.then(() => clearTimeout(timeoutToken)).catch(() => clearTimeout(timeoutToken)); return ret; } let mirrorProbePromise = null as null | Promise; async function fetchData(path: string, opts: Parameters[1] = {}, retry = true): Promise { try { return await fetchWithTimeout(selectedMirror + path, opts); } catch (e) { console.warn(e); if (!retry) { throw e; } if (mirrorProbePromise) { console.warn(`Failed to fetch data from mirror ${selectedMirror}, waiting for probe in progress...`); await mirrorProbePromise.then(() => {}).catch(() => {}); return fetchData(path, opts, false); } console.warn(`Failed to fetch data from mirror ${selectedMirror}, trying other mirror...`); } mirrorProbePromise = (async function () { let completedResponse = null as null | Response; return Promise.race( DATA_MIRRORS.map((mirror) => fetchWithTimeout(mirror + path, opts, PROBE_TIMEOUT) .then(function (resp) { if (completedResponse) { return resp; } completedResponse = resp; selectedMirror = mirror; savePreference("selectedMirror", selectedMirror); console.log(`Set ${mirror} as preferred`); return resp; }) .catch( (e) => new Promise((resolve) => setTimeout(() => { if (completedResponse) { return resolve(completedResponse); } resolve(e); // Do not reject here, may cause unhandled promise rejection }, PROBE_TIMEOUT) ) ) ) ).then((result) => { if ("ok" in (result as Response | Error)) { return result; } return Promise.reject(result); }) as Promise; })(); mirrorProbePromise.then(() => (mirrorProbePromise = null)).catch(() => (mirrorProbePromise = null)); return mirrorProbePromise; } let apiCache = {} as { [path: string]: unknown }; export type ApiError = Error & { status: number; statusText: string; url: string; }; export type WithLastModified = { readonly _lastModified?: dayjs.Dayjs; }; async function handleResponse(cacheKey: string, resp: Response): Promise { if (!resp.ok) { const error = new Error("Failed API call"); Object.assign(error, { response: resp, status: resp.status, statusText: resp.statusText, headers: resp.headers, url: resp.url, json: resp.json?.bind(resp) || (async () => { throw resp; }), }); throw error; } let data = await resp.json(); if (data?.maintenance) { onMaintenance(data.maintenance); return new Promise(() => {}) as Promise; // Freeze all other components } if (data?.result_key) { await new Promise((res) => setTimeout(res, 1000)); const resultResp = await fetchData(`${Conf.apiSuffix}result/${data.result_key}`, { headers: { "Cache-Control": "max-age=0, no-cache", }, }); return handleResponse(cacheKey, resultResp); } const lastModified = resp.headers.get("last-modified"); if (lastModified && typeof data === "object") { const parsed = dayjs.utc(lastModified.slice(lastModified.indexOf(" ") + 1), "DD MMM YYYY HH:mm:ss"); if (parsed.isValid()) { data = Object.defineProperty(data, "_lastModified", { value: parsed, writable: false }); } } if (Object.keys(apiCache).length > 500) { apiCache = {}; } apiCache[cacheKey] = data; return data as T & WithLastModified; } export async function apiGet(path: string): Promise { if (path in apiCache) { return apiCache[path] as T & WithLastModified; } const resp = await fetchData(Conf.apiSuffix + path); return await handleResponse(path, resp); } export async function apiCacheablePost(path: string, body: unknown): Promise { const bodyStr = JSON.stringify(body); const key = `${path}|${bodyStr}`; if (key in apiCache) { return apiCache[key] as T; } const resp = await fetchData(Conf.apiSuffix + path, { method: "POST", body: bodyStr, headers: { "Content-Type": "application/json", }, }); return await handleResponse(key, resp); } ================================================ FILE: src/data/source/misc.ts ================================================ /* eslint-disable @typescript-eslint/no-use-before-define */ /* eslint-disable no-use-before-define */ import dayjs from "dayjs"; import { apiGet } from "./api"; import { PlayerMetadataLite, PlayerExtendedStats, GameMode } from "../types"; import { RankingTimeSpan, DeltaRankingResponse } from "../types"; import { RankRateBySeat } from "../types"; import { CareerRankingItem, CareerRankingType } from "../types/ranking"; import { GlobalStatistics, FanStats, GlobalHistogram, LevelStatistics } from "../types/statistics"; export type PlayerSearchResult = Pick & { latest_timestamp: number; }; export async function searchPlayer(prefix: string, limit = 20): Promise { prefix = prefix.trim(); if (!prefix) { return []; } const result = await apiGet( `search_player/${encodeURIComponent(prefix)}?limit=${limit}&tag=all` ); return result || []; } export async function getExtendedStats( playerId: number, startDate?: dayjs.ConfigType, endDate?: dayjs.ConfigType, mode = "" ): Promise { let datePath = ""; if (startDate) { datePath += `/${dayjs(startDate).valueOf()}`; if (endDate) { datePath += `/${dayjs(endDate).valueOf()}`; } } return await apiGet(`player_extended_stats/${playerId}${datePath}?mode=${mode}`); } export async function getDeltaRanking(timespan: RankingTimeSpan): Promise { return await apiGet(`player_delta_ranking/${timespan}`); } export async function getCareerRanking( type: CareerRankingType, modeId?: string, minGames?: number ): Promise { minGames = minGames || 300; const suffix = minGames === 300 ? "" : `_${minGames}`; return await apiGet(`career_ranking/${type + suffix}?mode=${modeId || ""}`); } export async function getGlobalStatistics(modes: GameMode[]): Promise { return await apiGet(`global_statistics_2?mode=${modes.join(".")}`); } export async function getGlobalStatisticsYear(modes: GameMode[]): Promise { return await apiGet(`global_statistics_year?mode=${modes.join(".")}`); } export async function getGlobalStatisticsSnapshot( date: dayjs.ConfigType, modes: GameMode[] ): Promise { return await apiGet( `global_statistics_snapshot/${dayjs(date).format("YYYY-MM-DD")}?mode=${modes.join(".")}` ); } export async function getLevelStatistics(): Promise { return await apiGet("level_statistics").then((data) => { data.sort((a, b) => a[1] - b[1]); return data; }); } export async function getGlobalHistogram(): Promise { return await apiGet("global_histogram"); } export async function getFanStats(): Promise { return await apiGet("fan_stats"); } export async function getRankRateBySeat(): Promise { type RawResponse = [[number, number, number], number][]; let rawResp = await apiGet("rank_rate_by_seat"); if (rawResp.some((x) => x[0][0] === null)) { // Contest rawResp = rawResp.filter((x) => x[0][0] !== 0); } const counts: { [modeId: string]: { [rank: number]: number }; } = {}; let maxRank = 0; for (const [[modeId, rank], count] of rawResp) { if (maxRank < rank) { maxRank = rank; } const modeIdStr = (modeId || 0).toString(); counts[modeIdStr] = counts[modeIdStr] || []; counts[modeIdStr][rank] = counts[modeIdStr][rank] || 0; counts[modeIdStr][rank] += count; } const result: RankRateBySeat = {}; for (const [[modeId, rank, seatId], count] of rawResp) { const modeIdStr = (modeId || 0).toString(); result[modeIdStr] = result[modeIdStr] || []; result[modeIdStr].numGames = counts[modeIdStr][rank]; result[modeIdStr][rank] = result[modeIdStr][rank] || Array(maxRank).fill(0); result[modeIdStr][rank][seatId] = count / counts[modeIdStr][rank]; } return result; } ================================================ FILE: src/data/source/records/loader.ts ================================================ import dayjs from "dayjs"; import { GameRecord, GameRecordWithEvent } from "../../types/record"; import { Metadata, PlayerMetadata, PlayerExtendedStats, MODE_BASE_POINT } from "../../types/metadata"; import { apiCacheablePost, apiGet } from "../api"; import { GameMode } from "../../types"; import Conf from "../../../utils/conf"; const CHUNK_SIZE = 100; export interface DataLoader { getMetadata(): Promise; getNextChunk(): Promise; getEstimatedChunkSize(): number; } export class DummyDataLoader implements DataLoader { getMetadata(): Promise { return Promise.resolve({ count: 0 }); } getNextChunk(): Promise { return Promise.resolve([]); } getEstimatedChunkSize(): number { return 0; } } export class RecentHighlightDataLoader implements DataLoader { _data: Promise; _index: number; constructor(mode: GameMode | undefined, numItems = 100) { this._index = 0; this._data = apiGet(`recent_highlight_games?limit=${numItems}&mode=${mode || ""}`) .then((data) => { if (data.every((x) => x.uuid)) { return data; } return apiGet(`games_by_id/${data.map((x) => x._id).join(",")}`).then((records) => { const recordMap = {} as { [key: string]: GameRecordWithEvent }; records.forEach((x) => (recordMap[x._id || ""] = x)); return data.map((x) => ({ ...x, ...recordMap[x._id || ""] })); }); }) .then((data) => data.sort((a, b) => b.startTime - a.startTime)) .catch((e) => { if (e.status === 404) { return []; } return Promise.reject(e); }); } getEstimatedChunkSize() { return CHUNK_SIZE; } async getMetadata(): Promise { return this._data.then((x) => ({ count: x.length })); } async getNextChunk(): Promise { const index = this._index; this._index += CHUNK_SIZE; return this._data.then((data) => data.slice(index, index + CHUNK_SIZE)); } } export class ListingDataLoader implements DataLoader { _date: dayjs.Dayjs; _cursor: dayjs.Dayjs; _modeString: string; constructor(date: dayjs.ConfigType, mode: GameMode | null) { this._date = dayjs(date).startOf("day"); const cursor = Math.floor(new Date().getTime() / 120000) * 120000; this._cursor = dayjs(Math.min(this._date.clone().add(1, "day").valueOf() - 1, cursor)); this._modeString = mode && mode.toString() !== "0" ? mode.toString() : ""; } getEstimatedChunkSize() { return CHUNK_SIZE; } shouldReturnEmptyResult() { return !this._modeString && Conf.availableModes.length > 1; } async getMetadata(): Promise { if (this.shouldReturnEmptyResult()) { return { count: 0 }; } return { count: +Infinity }; } async getNextChunk(): Promise { if (this._cursor.isBefore(this._date) || this._cursor.isSame(this._date) || this.shouldReturnEmptyResult()) { return []; } const chunk = await apiGet( `games/${this._cursor.valueOf()}/${this._date.valueOf()}?limit=${CHUNK_SIZE}&descending=true&mode=${ this._modeString }` ); if (chunk.length) { this._cursor = dayjs(chunk[chunk.length - 1].startTime * 1000 - 1); } else { this._cursor = this._date; } return chunk; } } function processExtendedStats(stats: PlayerMetadata): (value: PlayerExtendedStats) => PlayerExtendedStats { return (extendedStats) => { const gameBasePoint = MODE_BASE_POINT[Conf.availableModes[0]]; if (gameBasePoint) { extendedStats.局收支 = ((stats.rank_rates.reduce((acc, x, index) => acc + x * stats.rank_avg_score[index], 0) - gameBasePoint) * stats.count) / extendedStats.count; } stats.extended_stats = extendedStats; return extendedStats; }; } export class PlayerDataLoader implements DataLoader { _playerId: string; _startDate: dayjs.Dayjs; _endDate: dayjs.Dayjs; _cursor: dayjs.Dayjs; _mode: GameMode[]; _initialParams: string; _tag: string; constructor(playerId: string, startDate?: dayjs.Dayjs, endDate?: dayjs.Dayjs, mode = [] as GameMode[]) { this._playerId = playerId; this._startDate = startDate || dayjs("2010-01-01T00:00:00.000Z"); this._endDate = endDate || dayjs().endOf("minute"); this._cursor = this._endDate; this._mode = mode; this._initialParams = this._getParams(); this._tag = ""; } _getDatePath(): string { let result = `/${this._startDate.valueOf()}`; if (this._cursor) { result += `/${this._cursor.valueOf()}`; } return result; } _getParams(mode = this._mode): string { return `${this._playerId}${this._getDatePath()}?mode=${(mode.length ? mode : Conf.availableModes).join(".")}`; } getEstimatedChunkSize() { return CHUNK_SIZE; } async getMetadata(): Promise { if (this._endDate.isBefore(this._startDate)) { return Promise.reject(new Error("Invalid date range")); } const timeTag = Math.floor(new Date().getTime() / 1000 / 60 / 60); const stats = await apiGet(`player_stats/${this._initialParams}&tag=${timeTag}`); if (this._mode.length || !Conf.availableModes.length) { stats.extended_stats = apiGet( `player_extended_stats/${this._initialParams}&tag=${timeTag}` ).then(processExtendedStats(stats)); stats.extended_stats.catch((e) => { console.error("Failed to get extended stats:", e); }); } if (!this._mode.length && Conf.availableModes.length) { stats.count = 0; } let crossStats = stats; if (this._mode.length && !Conf.availableModes.every((x) => this._mode.includes(x))) { crossStats = await apiGet(`player_stats/${this._getParams([])}&tag=${timeTag}`); } stats.cross_stats = { id: crossStats.id, level: crossStats.level, max_level: crossStats.max_level, played_modes: crossStats.played_modes ?.map((x) => (typeof x === "string" ? (parseInt(x, 10) as GameMode) : x)) ?.sort((a, b) => Conf.availableModes.indexOf(a) - Conf.availableModes.indexOf(b)) || [], nickname: crossStats.nickname, count: crossStats.count, }; this._tag = stats.count.toString(); return stats; } async getNextChunk(): Promise { if (this._cursor.isBefore(this._startDate) || this._cursor.isSame(this._startDate)) { return []; } if (!this._mode.length && Conf.availableModes.length) { return []; } const chunk = await apiGet( `player_records/${this._playerId}/${this._cursor.valueOf()}/${this._startDate.valueOf()}?limit=${ CHUNK_SIZE + ((parseInt(this._tag, 10) || 0) % CHUNK_SIZE) }&mode=${this._mode}&descending=true&tag=${this._tag}` ); if (chunk.length) { this._cursor = dayjs(chunk[chunk.length - 1].startTime * 1000 - 1); } else { this._cursor = this._startDate; } this._tag = ""; return chunk; } } export class FilteredPlayerDataLoader implements DataLoader { private _recordPromise: Promise | GameRecord[] | null = null; private _chunkReturned = false; constructor(private _playerId: string, private _loadRecord: () => Promise, private _mode: GameMode[]) { if (!_mode.length) { throw new Error("No mode"); } _mode.sort((a, b) => Conf.availableModes.indexOf(a) - Conf.availableModes.indexOf(b)); } getEstimatedChunkSize() { return CHUNK_SIZE; } private async getRecords(): Promise { if (!this._recordPromise) { this._recordPromise = this._loadRecord().then((records) => { this._recordPromise = records; return records; }); } return this._recordPromise; } async getMetadata(): Promise { const records = await this.getRecords(); if (!records.length) { throw new Error("No records"); } const keys = records.map((x) => x.startTime); keys.sort((a, b) => b - a); const stats = await apiCacheablePost(`player_stats/${this._playerId}`, { keys, modes: this._mode }); if (this._mode.length || !Conf.availableModes.length) { stats.extended_stats = apiCacheablePost(`player_extended_stats/${this._playerId}`, { keys, modes: this._mode, }).then(processExtendedStats(stats)); stats.extended_stats.catch((e) => { console.error("Failed to get extended stats:", e); }); } const crossStats = stats; stats.cross_stats = { id: crossStats.id, level: crossStats.level, max_level: crossStats.max_level, played_modes: crossStats.played_modes ?.map((x) => (typeof x === "string" ? (parseInt(x, 10) as GameMode) : x)) ?.sort((a, b) => Conf.availableModes.indexOf(a) - Conf.availableModes.indexOf(b)) || [], nickname: crossStats.nickname, count: crossStats.count, }; return stats; } async getNextChunk(): Promise { if (this._chunkReturned) { return []; } const chunk = await this.getRecords(); this._chunkReturned = true; return chunk; } } export class FixedNumberPlayerDataLoader extends PlayerDataLoader { _limit: number; _data: GameRecord[]; constructor(playerId: string, limit: number, mode: GameMode[]) { super(playerId, undefined, dayjs().endOf("hour"), mode); if (!mode.length) { if (Conf.availableModes.length <= 1) { mode = Conf.availableModes; this._mode = mode; } else { throw new Error("No mode specified"); } } this._limit = limit; this._data = []; } getEstimatedChunkSize() { return this._limit; } async getMetadata(): Promise { const chunk = await apiGet( `player_records/${this._playerId}/${this._endDate.valueOf()}/${this._startDate.valueOf()}?limit=${ this._limit }&mode=${this._mode}&descending=true` ); if (!chunk.length) { throw new Error("No data"); } this._data = chunk; this._startDate = dayjs(chunk[chunk.length - 1].startTime * 1000); this._initialParams = this._getParams(); return super.getMetadata().then((x) => { this._cursor = this._startDate; return x; }); } async getNextChunk(): Promise { const chunk = this._data; this._data = []; return chunk; } } ================================================ FILE: src/data/source/records/provider.ts ================================================ import dayjs from "dayjs"; import { GameRecord } from "../../types/record"; import { Metadata, PlayerMetadata } from "../../types/metadata"; import { ListingDataLoader, PlayerDataLoader, DataLoader, RecentHighlightDataLoader, FixedNumberPlayerDataLoader, DummyDataLoader, FilteredPlayerDataLoader, } from "./loader"; import { GameMode } from "../../types"; export type FilterPredicate = ((record: TRecord) => boolean) | null; class DataProviderImpl { _loader: DataLoader; _metadata: TMetadata | Promise | null; _metadataError?: unknown; _countPromise: Promise | null; _loadingPromise: Promise | null; _data: TRecord[]; _filterPredicate: FilterPredicate; _filteredIndices: number[] | null; _filterResultCache: { [uuid: string]: boolean }; constructor(loader: DataLoader) { this._loader = loader; this._metadata = null; this._data = []; this._countPromise = null; this._filterPredicate = null; this._filteredIndices = null; this._filterResultCache = {}; this._loadingPromise = null; } setFilterPredicate(predicate: FilterPredicate) { if (this._filterPredicate === predicate) { return; } this._filterPredicate = predicate; this._filterResultCache = {}; this.updateFilteredIndices(); } updateFilteredIndices() { this._filteredIndices = null; if (!this._filterPredicate) { return; } const metadata = this.getMetadataSync(); if (!metadata) { return; } const count = this.getEstimatedCountSync(); const indices = []; for (let i = 0; i < count; i++) { if (i >= this._data.length) { indices.push(i); continue; } const game = this._data[i]; let result = this._filterResultCache[game.uuid]; if (result === undefined) { this._filterResultCache[game.uuid] = result = this._filterPredicate(game); } if (result) { indices.push(i); } } this._filteredIndices = indices; } getMetadataSync(): TMetadata | null { if (this._metadataError) { throw this._metadataError; } return this._metadata && !(this._metadata instanceof Promise) ? this._metadata : null; } getEstimatedCountSync(): number { const metadata = this.getMetadataSync(); const count = metadata ? metadata.count : this._data.length + 100; if (count === +Infinity) { return this._data.length + 100; } return count; } getCountMaybeSync(): number | Promise { const metadata = this.getMetadataSync(); if (metadata) { return this._filteredIndices ? this._filteredIndices.length : this.getEstimatedCountSync(); } return this.getCount().catch(() => 0); // Have to catch here to avoid unhandled promise rejection } async getCount(): Promise { const metadata = this.getMetadataSync(); if (metadata) { return this.getCountMaybeSync(); } if (!this._metadata) { this._metadata = this._loader.getMetadata().then((metadata) => { if (!metadata) { console.log("No metadata returned"); throw new Error("No metadata returned"); } this._metadata = metadata; this.updateFilteredIndices(); this._countPromise = null; return metadata; }); this._metadata.catch((e) => { console.error(e); this._metadataError = e; }); } if (this._countPromise) { return this._countPromise; } this._countPromise = Promise.resolve(this._metadata) .then(() => new Promise((resolve) => setTimeout(resolve, 100))) .then(() => this.getCountMaybeSync()); this._countPromise.catch(() => { /* Kill unhandled rejection */ }); return this._countPromise; } getUnfilteredCountSync(): number | null { const metadata = this.getMetadataSync(); if (!metadata) { return null; } return this.getEstimatedCountSync(); } isItemLoaded(index: number): boolean { const mappedIndex = this._mapItemIndex(index); if (mappedIndex === null) { return false; } return mappedIndex < this._data.length; } getItem(index: number, skipPreload = false): TRecord | Promise { const mappedIndex = this._mapItemIndex(index); if (mappedIndex === null) { return this.getCount() .then((count) => { const newMappedIndex = this._mapItemIndex(index); if (index > count - 1 || newMappedIndex === null) { return null; } return this.getItem(index, skipPreload); }) .catch(() => null); } if (mappedIndex >= this._data.length) { const curLength = this._data.length; return this._loadNextChunk().then(() => { if (this._data.length > curLength) { return this.getItem(index, skipPreload); } return null; }); } if (!skipPreload && !this._filteredIndices) { this.preload(index + this._loader.getEstimatedChunkSize() / 2); } return this._data[mappedIndex]; } preload(index: number) { const count = this.getCountMaybeSync(); if (count instanceof Promise) { return; } if (index >= count) { return; } this.getItem(index, true); } _mapItemIndex(requestedIndex: number): number | null { const count = this.getCountMaybeSync(); if (count instanceof Promise) { return null; } if (requestedIndex < 0 || requestedIndex >= count) { return null; } return this._filteredIndices ? this._filteredIndices[requestedIndex] : requestedIndex; } async _loadNextChunk(): Promise { if (this._loadingPromise) { return this._loadingPromise; } this._loadingPromise = (async () => { const count = this.getUnfilteredCountSync() || 0; if (this._data.length >= count) { this._loadingPromise = null; return; } const nextChunk = await this._loader.getNextChunk(); this._loadingPromise = null; if (nextChunk.length) { this._data.splice(this._data.length, 0, ...nextChunk); } else { const metadata = await this._metadata; if (metadata) { console.warn("Fixing incorrect item count: " + metadata?.count + " -> " + this._data.length); metadata.count = this._data.length; this._metadata = metadata; } } this.updateFilteredIndices(); })(); return this._loadingPromise; } } export type ListingDataProvider = DataProviderImpl; export type PlayerDataProvider = DataProviderImpl; export const DUMMY_DATA_PROVIDER = new DataProviderImpl(new DummyDataLoader()); export type DataProvider = ListingDataProvider | PlayerDataProvider; // eslint-disable-next-line @typescript-eslint/no-redeclare export const DataProvider = Object.freeze({ createListing(date: dayjs.ConfigType, mode: GameMode | null): ListingDataProvider { return new DataProviderImpl(new ListingDataLoader(date, mode)); }, createHightlight(mode: GameMode | undefined): ListingDataProvider { return new DataProviderImpl(new RecentHighlightDataLoader(mode)); }, createPlayer( playerId: string, startDate: dayjs.ConfigType | null, endDate: dayjs.ConfigType | null, limit: number | null, mode: GameMode[] ): PlayerDataProvider { if (limit) { return new DataProviderImpl(new FixedNumberPlayerDataLoader(playerId, limit, mode)); } return new DataProviderImpl( new PlayerDataLoader( playerId, startDate ? dayjs(startDate) : undefined, endDate ? dayjs(endDate) : undefined, mode ) ); }, createFilteredPlayer(playerId: string, loadRecord: () => Promise, mode: GameMode[]): PlayerDataProvider { return new DataProviderImpl(new FilteredPlayerDataLoader(playerId, loadRecord, mode)); }, }); ================================================ FILE: src/data/types/constants.ts ================================================ export const PLAYER_RANKS = "初士杰豪圣魂"; export const RANK_LABELS = ["一位", "二位", "三位", "四位"]; ================================================ FILE: src/data/types/gameMode.ts ================================================ import i18n from "../../i18n"; const t = i18n.getFixedT(null, "gameModeShort"); export enum GameMode { 王座 = 16, 玉 = 12, 金 = 9, 王东 = 15, 玉东 = 11, 金东 = 8, 三金 = 22, 三玉 = 24, 三王座 = 26, 三金东 = 21, 三玉东 = 23, 三王东 = 25, } export function modeLabelNonTranslated(mode: GameMode) { if (!mode) { return "全部"; } return GameMode[mode].replace(/^三/, ""); } export function modeLabel(mode: GameMode) { return t(modeLabelNonTranslated(mode)); } export function parseCombinedMode(modeString?: string): GameMode[] { return (modeString || "") .split(".") .map((x) => parseInt(x.trim(), 10) as GameMode) .map((x) => (GameMode[x] ? x : (0 as GameMode))) .filter((x) => x); } ================================================ FILE: src/data/types/index.ts ================================================ export * from "./constants"; export * from "./gameMode"; export * from "./level"; export * from "./metadata"; export * from "./record"; export * from "./ranking"; export * from "./statistics"; export * from "./utils"; export * from "./zone"; ================================================ FILE: src/data/types/level.ts ================================================ import { GameMode } from "./gameMode"; import { PLAYER_RANKS } from "./constants"; import i18n from "../../i18n"; const t = i18n.t.bind(i18n); const LEVEL_MAX_POINTS = [20, 80, 200, 600, 800, 1000, 1200, 1400, 2000, 2800, 3200, 3600, 4000, 6000, 9000]; const LEVEL_PENALTY = [0, 0, 0, 20, 40, 60, 80, 100, 120, 165, 180, 195, 210, 225, 240, 255]; const LEVEL_PENALTY_3 = [0, 0, 0, 20, 40, 60, 80, 100, 120, 165, 190, 215, 240, 265, 290, 320]; const LEVEL_PENALTY_E = [0, 0, 0, 10, 20, 30, 40, 50, 60, 80, 90, 100, 110, 120, 130, 140]; const LEVEL_PENALTY_E_3 = [0, 0, 0, 10, 20, 30, 40, 50, 60, 80, 95, 110, 125, 140, 160, 175]; const LEVEL_KONTEN = 7; const LEVEL_MAX_POINT_KONTEN = 2000; const LEVEL_ALLOWED_MODES: { [key: number]: GameMode[] } = { 101: [], 102: [], 103: [GameMode.金, GameMode.金东], 104: [GameMode.金, GameMode.玉, GameMode.金东, GameMode.玉东], 105: [GameMode.玉, GameMode.王座, GameMode.玉东, GameMode.王东], 106: [GameMode.王座, GameMode.王东], 107: [GameMode.王座, GameMode.王东], 201: [], 202: [], 203: [GameMode.三金, GameMode.三金东], 204: [GameMode.三金, GameMode.三玉, GameMode.三金东, GameMode.三玉东], 205: [GameMode.三玉, GameMode.三王座, GameMode.三玉东, GameMode.三王东], 206: [GameMode.三王座, GameMode.三王东], 207: [GameMode.三王座, GameMode.三王东], }; const MODE_PENALTY: { [mode in GameMode]: typeof LEVEL_PENALTY } = { [GameMode.金]: LEVEL_PENALTY, [GameMode.玉]: LEVEL_PENALTY, [GameMode.王座]: LEVEL_PENALTY, [GameMode.金东]: LEVEL_PENALTY_E, [GameMode.玉东]: LEVEL_PENALTY_E, [GameMode.王东]: LEVEL_PENALTY_E, [GameMode.三金]: LEVEL_PENALTY_3, [GameMode.三玉]: LEVEL_PENALTY_3, [GameMode.三王座]: LEVEL_PENALTY_3, [GameMode.三金东]: LEVEL_PENALTY_E_3, [GameMode.三玉东]: LEVEL_PENALTY_E_3, [GameMode.三王东]: LEVEL_PENALTY_E_3, }; export function getTranslatedLevelTags(): string[] { const rawTags = t(PLAYER_RANKS) as string; if (rawTags.charCodeAt(0) > 127) { return rawTags.split(""); } return Array(rawTags.length / 2) .fill("") .map((_, index) => rawTags.slice(index * 2, index * 2 + 2)); } export class Level { _majorRank: number; _minorRank: number; _numPlayerId: number; constructor(levelId: number) { const realId = levelId % 10000; this._majorRank = Math.floor(realId / 100); this._minorRank = realId % 100; this._numPlayerId = Math.floor(levelId / 10000); } toLevelId() { return this._numPlayerId * 10000 + this._majorRank * 100 + this._minorRank; } isSameMajorRank(other: Level): boolean { return this._majorRank === other._majorRank; } isSame(other: Level): boolean { if (this.isKonten() && other.isKonten()) { if (this._majorRank === LEVEL_KONTEN - 1 || other._majorRank === LEVEL_KONTEN - 1) { return true; } } return this._majorRank === other._majorRank && this._minorRank === other._minorRank; } isAllowedMode(mode: GameMode): boolean { return LEVEL_ALLOWED_MODES[this._numPlayerId * 100 + this._majorRank].includes(mode); } isKonten(): boolean { return this._majorRank >= LEVEL_KONTEN - 1; } getNumPlayerId(): number { return this._numPlayerId; } withLevelId(newLevelId: number): Level { return new Level(this._numPlayerId * 10000 + newLevelId); } getTag(): string { const label = getTranslatedLevelTags()[this.isKonten() ? LEVEL_KONTEN - 2 : this._majorRank - 1]; if (this._majorRank === LEVEL_KONTEN - 1) { return label; } return label + this._minorRank; } getMaxPoint(): number { if (this.isKonten()) { if (this._minorRank === 20) { return 0; } return LEVEL_MAX_POINT_KONTEN; } return LEVEL_MAX_POINTS[(this._majorRank - 1) * 3 + this._minorRank - 1]; } getPenaltyPoint(mode: GameMode): number { if (this.isKonten()) { return 0; } return MODE_PENALTY[mode][(this._majorRank - 1) * 3 + this._minorRank - 1]; } getStartingPoint(): number { if (this._majorRank === 1) { return 0; } return this.getMaxPoint() / 2; } getNextLevel(): Level { const level = this.getVersionAdjustedLevel(); let majorRank = level._majorRank; let minorRank = level._minorRank + 1; if (minorRank > 3 && !level.isKonten()) { majorRank++; minorRank = 1; } if (majorRank === LEVEL_KONTEN - 1) { majorRank = LEVEL_KONTEN; } return new Level(level._numPlayerId * 10000 + majorRank * 100 + minorRank); } getPreviousLevel(): Level { if (this._majorRank === 1 && this._minorRank === 1) { return this; } const level = this.getVersionAdjustedLevel(); let majorRank = level._majorRank; let minorRank = level._minorRank - 1; if (minorRank < 1) { majorRank--; minorRank = 3; } if (majorRank === LEVEL_KONTEN - 1) { majorRank = LEVEL_KONTEN - 2; } return new Level(level._numPlayerId * 10000 + majorRank * 100 + minorRank); } getAdjustedLevel(score: number): Level { score = this.getVersionAdjustedScore(score); // eslint-disable-next-line @typescript-eslint/no-this-alias let level: Level = this.getVersionAdjustedLevel(); let maxPoints = level.getMaxPoint(); if (maxPoints && score >= maxPoints) { level = level.getNextLevel(); maxPoints = level.getMaxPoint(); score = level.getStartingPoint(); } else if (score < 0) { if (!maxPoints || level._majorRank === 1 || (level._majorRank === 2 && level._minorRank === 1)) { score = 0; } else { level = level.getPreviousLevel(); maxPoints = level.getMaxPoint(); score = level.getStartingPoint(); } } return level; } getVersionAdjustedLevel() { if (this._majorRank !== LEVEL_KONTEN - 1) { return this; } return new Level(this._numPlayerId * 10000 + LEVEL_KONTEN * 100 + 1); } getVersionAdjustedScore(score: number) { if (this._majorRank === LEVEL_KONTEN - 1) { return Math.ceil(score / 100) * 10 + 200; } return score; } getScoreDisplay(score: number) { score = this.getVersionAdjustedScore(score); if (this.isKonten()) { return (score / 100).toFixed(1); } return score.toString(); } formatAdjustedScoreWithTag(score: number) { const level = this.getAdjustedLevel(score); return `${level.getTag()} ${this.formatAdjustedScore(score)}`; } formatAdjustedScore(score: number) { const level = this.getAdjustedLevel(score); score = this.getVersionAdjustedScore(score); return `${level.getScoreDisplay(level.isSame(this) ? Math.max(score, 0) : level.getStartingPoint())}${ level.getMaxPoint() ? "/" + level.getScoreDisplay(level.getMaxPoint()) : "" }`; } } export function getLevelTag(levelId: number) { return new Level(levelId).getTag(); } export type LevelWithDelta = { id: number; score: number; delta: number; }; // eslint-disable-next-line @typescript-eslint/no-redeclare export const LevelWithDelta = Object.freeze({ format(obj: LevelWithDelta): string { return new Level(obj.id).formatAdjustedScoreWithTag(obj.score + obj.delta); }, formatAdjustedScore(obj: LevelWithDelta): string { return new Level(obj.id).formatAdjustedScore(obj.score + obj.delta); }, getTag(obj: LevelWithDelta): string { return LevelWithDelta.getAdjustedLevel(obj).getTag(); }, getAdjustedLevel(obj: LevelWithDelta): Level { return new Level(obj.id).getAdjustedLevel(obj.score + obj.delta); }, }); ================================================ FILE: src/data/types/metadata.ts ================================================ import { LevelWithDelta, Level, getTranslatedLevelTags } from "./level"; import { GameMode } from "./gameMode"; import { FanStatEntry } from "./statistics"; import { sum } from "../../utils"; import i18n from "../../i18n"; const t = i18n.t.bind(i18n); const RANK_DELTA_4 = [15, 5, -5, -15]; const RANK_DELTA_3 = [15, 0, -15]; const RANK_DELTA = { [GameMode.金]: RANK_DELTA_4, [GameMode.玉]: RANK_DELTA_4, [GameMode.王座]: RANK_DELTA_4, [GameMode.金东]: RANK_DELTA_4, [GameMode.玉东]: RANK_DELTA_4, [GameMode.王东]: RANK_DELTA_4, [GameMode.三金]: RANK_DELTA_3, [GameMode.三玉]: RANK_DELTA_3, [GameMode.三王座]: RANK_DELTA_3, [GameMode.三金东]: RANK_DELTA_3, [GameMode.三玉东]: RANK_DELTA_3, [GameMode.三王东]: RANK_DELTA_3, }; const MODE_DELTA = { [GameMode.金]: [80, 40, 0, 0], [GameMode.玉]: [110, 55, 0, 0], [GameMode.王座]: [120, 60, 0, 0], [GameMode.金东]: [40, 20, 0, 0], [GameMode.玉东]: [55, 30, 0, 0], [GameMode.王东]: [60, 30, 0, 0], [GameMode.三金]: [105, 0, 0], [GameMode.三玉]: [160, 0, 0], [GameMode.三王座]: [240, 0, 0], [GameMode.三金东]: [55, 0, 0], [GameMode.三玉东]: [75, 0, 0], [GameMode.三王东]: [120, 0, 0], }; const KONTEN_DELTA: { [mode in GameMode]?: number[] } = { [GameMode.王座]: [50, 20, -20, -50], [GameMode.王东]: [30, 10, -10, -30], [GameMode.三王座]: [50, 0, -50], [GameMode.三王东]: [30, 0, -30], }; export const MODE_BASE_POINT = { [GameMode.金]: 25000, [GameMode.玉]: 25000, [GameMode.王座]: 25000, [GameMode.金东]: 25000, [GameMode.玉东]: 25000, [GameMode.王东]: 25000, [GameMode.三金]: 35000, [GameMode.三玉]: 35000, [GameMode.三王座]: 35000, [GameMode.三金东]: 35000, [GameMode.三玉东]: 35000, [GameMode.三王东]: 35000, }; const KONTEN_FALLBACK_LEVEL_ID = 503; export type RankRates = [number, number, number, number] | [number, number, number]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const RankRates = Object.freeze({ getAvg(rates: RankRates): number { return sum(rates.map((value, index) => value * (index + 1))) / sum(rates); }, normalize(rates: RankRates): RankRates { const total = sum(rates); return rates.map((value) => value / total) as RankRates; }, }); export type FanStatEntry2 = FanStatEntry & { 役满: number; }; // eslint-disable-next-line @typescript-eslint/no-redeclare export const FanStatEntry2 = Object.freeze({ formatFan(entry: FanStatEntry2): string { if (entry.役满) { if (entry.役满 === 1) { return t("役满"); } return `${entry.役满} ${t("倍役满")}`; } return `${entry.count} ${t("番")}`; }, }); export type FanStatEntryList = FanStatEntry2[]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const FanStatEntryList = Object.freeze({ formatFanList(list: FanStatEntryList): string { return list.map((x) => `[${x.count}] ${t(x.label)}`).join("\n"); }, formatFanSummary(list: FanStatEntryList): string { const count = sum(list.map((x) => x.count)); const 役满 = sum(list.map((x) => x.役满)); if (役满) { if (役满 === 1) { return t("役满"); } return `${役满} ${t("倍役满")}`; } let result = `${count} ${t("番")}`; if (count >= 13) { result += " - " + t("累计役满"); } else if (count >= 11) { result += " - " + t("三倍满"); } else if (count >= 8) { result += " - " + t("倍满"); } else if (count >= 6) { result += " - " + t("跳满"); } else if (count === 5) { result += " - " + t("满贯"); } return result; }, }); export type PlayerExtendedStats = { count: number; 和牌率: number; 自摸率: number; 默听率: number; 放铳率: number; 副露率: number; 立直率: number; 平均打点: number; 最大连庄?: number; 和了巡数: number; 平均铳点: number; 流局率: number; 流听率: number; 里宝率: number; 一发率: number; 被炸率: number; 平均被炸点数: number; 放铳时立直率: number; 放铳时副露率: number; 立直后放铳率: number; 立直后非瞬间放铳率: number; 副露后放铳率: number; 立直后和牌率: number; 副露后和牌率: number; 立直后流局率: number; 副露后流局率: number; 役满?: number; 累计役满?: number; 最大累计番数?: number; W立直?: number; 流满?: number; 平均起手向听: number; 平均起手向听亲?: number; 平均起手向听子?: number; 放铳至立直: number; 放铳至副露: number; 放铳至默听: number; 立直和了: number; 副露和了: number; 默听和了: number; 立直巡目: number; 立直流局: number; 立直收支: number; 立直收入: number; 立直支出: number; 先制率: number; 追立率: number; 被追率: number; 振听立直率: number; 立直多面?: number; 立直好型2?: number; 打点效率: number; 铳点损失: number; 净打点效率: number; 局收支?: number; 最近大铳?: { id: string; start_time: number; fans: FanStatEntryList; }; }; export interface Metadata { count: number; } export interface PlayerMetadataLite extends Metadata { id: number; nickname: string; level: LevelWithDelta; } export interface PlayerMetadataLite2 extends Metadata { rank_rates: RankRates; avg_rank: number; negative_rate: number; } export interface PlayerMetadata extends PlayerMetadataLite, PlayerMetadataLite2 { rank_avg_score: RankRates; max_level: LevelWithDelta; played_modes?: (string | GameMode)[]; cross_stats?: PlayerMetadataLite & { max_level: LevelWithDelta; played_modes: GameMode[]; }; extended_stats?: PlayerExtendedStats | Promise; } export function calculateDeltaPoint( score: number, rank: number, mode: GameMode, level: Level, includePenalty = true, trimNumber = true ): number { if (level.isKonten()) { const delta = KONTEN_DELTA[mode]; if (delta) { return delta[rank]; } level = level.withLevelId(KONTEN_FALLBACK_LEVEL_ID); } let result = (trimNumber ? Math.ceil : (x: number) => x)((score - MODE_BASE_POINT[mode]) / 1000 + RANK_DELTA[mode][rank]) + MODE_DELTA[mode][rank]; if (rank === RANK_DELTA[mode].length - 1 && includePenalty) { result -= level.getPenaltyPoint(mode); } /* console.log( `calculateDeltaPoint: score=${score}, rank=${rank}, mode=${mode}, level=${level.getTag()}, result=${result}` ); */ return result; } // eslint-disable-next-line @typescript-eslint/no-redeclare export const PlayerMetadata = Object.freeze({ calculateRankDeltaPoints( metadata: PlayerMetadata, mode: GameMode, level?: Level, includePenalty = true, trimNumber = true ): RankRates { const rankDeltaPoints = metadata.rank_avg_score.map((score, rank) => calculateDeltaPoint( score, rank, mode, level || LevelWithDelta.getAdjustedLevel(metadata.level), includePenalty, trimNumber ) ) as typeof metadata.rank_avg_score; return rankDeltaPoints; }, calculateExpectedGamePoint(metadata: PlayerMetadata, mode: GameMode, level?: Level, includePenalty = true): number { const rankDeltaPoints = PlayerMetadata.calculateRankDeltaPoints(metadata, mode, level, includePenalty); const rankWeightedPoints = rankDeltaPoints.map((point, rank) => point * metadata.rank_rates[rank]); const expectedGamePoint = rankWeightedPoints.reduce((a, b) => a + b, 0); /* console.log(rankDeltaPoints); console.log(rankWeightedPoints); console.log( `calculateExpectedGamePoint: mode=${mode}, level=${level ? level.getTag() : ""}, result=${expectedGamePoint}` ); */ return expectedGamePoint; }, estimateStableLevel(metadata: PlayerMetadata, mode: GameMode): string { const calcPoint = (level: Level) => PlayerMetadata.calculateExpectedGamePoint(metadata, mode, level); let level = new Level(metadata.level.id); let lastPositiveLevel: Level | undefined = undefined; for (;;) { const expectedGamePoint = calcPoint(level); if (Math.abs(expectedGamePoint) < 0.001) { return level.getTag() + " (0)"; } if (expectedGamePoint >= 0) { if (level.isKonten()) { return level.getTag().replace(/\d+/g, "") + "+" + expectedGamePoint.toFixed(2); } lastPositiveLevel = level; level = level.getNextLevel(); if (!level.isAllowedMode(mode) || level === lastPositiveLevel) { return `${lastPositiveLevel.getTag()}+ (${expectedGamePoint.toFixed(2)})`; } } else { if (lastPositiveLevel) { return `${lastPositiveLevel.getTag()} (${calcPoint(lastPositiveLevel).toFixed(2)})`; } break; } } for (;;) { const prevLevel = level.getPreviousLevel(); if (!prevLevel.isAllowedMode(mode) || prevLevel === level) { return `${level.getTag()}- (${calcPoint(level).toFixed(2)})`; } level = prevLevel; const expectedGamePoint = calcPoint(level); if (expectedGamePoint > -0.001) { return `${level.getTag()} (${Math.abs(calcPoint(level)).toFixed(2)})`; } } }, formatStableLevel2(level: number): string { const formatNumber = function (x: number): string { // Trim after the second digit after decimal point let s = x.toString(); if (s.indexOf(".") === -1) { s += ".00"; } if (s.length < 8) { s += "00"; } return s.slice(0, s.indexOf(".") + 3); }; const translatedLevelTags = getTranslatedLevelTags(); if (level >= 4) { return `${translatedLevelTags[4]}${formatNumber(level - 3)}`; } return `${translatedLevelTags[3]}${formatNumber(level)}`; }, getStableLevelComponents(metadata: PlayerMetadata, mode: GameMode): RankRates { return this.calculateRankDeltaPoints(metadata, mode, undefined, false, false); }, estimateStableLevel2(metadata: PlayerMetadata, mode: GameMode): string { if (![GameMode.玉, GameMode.王座].includes(mode)) { return this.estimateStableLevel(metadata, mode); } if (!metadata.rank_rates[3]) { return ""; } let estimatedPoints = this.calculateExpectedGamePoint(metadata, mode, undefined, false); let result = estimatedPoints / (metadata.rank_rates[3] * 15) - 10; const level = LevelWithDelta.getAdjustedLevel(metadata.level); if (level.isKonten() && KONTEN_DELTA[mode]) { const tag = level.getTag().replace(/\d+/g, ""); if (Math.abs(estimatedPoints) < 0.001) { return tag; } if (estimatedPoints > 0) { return tag + "+" + estimatedPoints.toFixed(2); } estimatedPoints = this.calculateExpectedGamePoint( metadata, mode, level.withLevelId(KONTEN_FALLBACK_LEVEL_ID), false ); } else if (result > 7 && KONTEN_DELTA[mode]) { return this.estimateStableLevel(metadata, mode); } result = estimatedPoints / (metadata.rank_rates[3] * 15) - 10; return PlayerMetadata.formatStableLevel2(result); }, }); ================================================ FILE: src/data/types/ranking.ts ================================================ import { LevelWithDelta } from "./level"; import { PlayerMetadata } from "./metadata"; export enum RankingTimeSpan { OneDay = "1d", ThreeDays = "3d", OneWeek = "1w", FourWeeks = "4w", } export type DeltaRankingItem = { id: number; nickname: string; level: LevelWithDelta; delta: number; }; export type DeltaRankingResponse = { [modeId: string]: { top: DeltaRankingItem[]; bottom: DeltaRankingItem[]; num_games: DeltaRankingItem[]; }; }; export interface CareerRankingItem extends PlayerMetadata { rank_key: number; ranking_level: LevelWithDelta; count: number; } export enum CareerRankingType { Rank1 = "rank1", Rank12 = "rank12", Rank123 = "rank123", Rank3 = "rank3", Rank4 = "rank4", AvgRank = "avg_rank", MaxLevelGlobal = "max_level_global", NumGames = "num_games", StableLevel = "stable_level", PointEfficiency = "point_efficiency", Win = "win", Lose = "lose", WinLoseDiff = "win_lose_diff", WinRev = "win_rev", LoseRev = "lose_rev", ExpectedGamePoint0 = "expected_game_point_0", ExpectedGamePoint1 = "expected_game_point_1", ExpectedGamePoint2 = "expected_game_point_2", ExpectedGamePoint3 = "expected_game_point_3", 里宝率 = "里宝率", 被炸率 = "被炸率", 一发率 = "一发率", 里宝率Rev = "里宝率_rev", 被炸率Rev = "被炸率_rev", 一发率Rev = "一发率_rev", 平均打点 = "平均打点", 平均铳点 = "平均铳点", 打点效率 = "打点效率", 净打点效率 = "净打点效率", 铳点损失 = "铳点损失", 局收支 = "局收支", } ================================================ FILE: src/data/types/record.ts ================================================ import dayjs from "dayjs"; import { GameMode } from "./gameMode"; import { getRankLabelByIndex } from "./utils"; import Conf from "../../utils/conf"; import i18n from "../../i18n"; import { FanStatEntryList } from "./metadata"; import { getApiPrefix } from "../source/api"; import { getZoneFromLocale } from "./zone"; export interface PlayerRecord { accountId: number; nickname: string; level: number; score: number; gradingScore?: number; } export interface GameRecord { _id?: string; _masked?: boolean; modeId: GameMode; uuid: string; startTime: number; endTime: number; players: PlayerRecord[]; } export type HighlightEvent = { type: "役满"; fan: FanStatEntryList; player: number; }; export type GameRecordWithEvent = GameRecord & { event: HighlightEvent; }; // eslint-disable-next-line @typescript-eslint/no-redeclare export const GameRecord = Object.freeze({ getRankIndexByPlayer(rec: GameRecord, player: number | string | PlayerRecord): number { const playerId = (typeof player === "object" ? player.accountId : player).toString(); const sortedPlayers = rec.players.map((player, index) => ({ player, index })); sortedPlayers.sort((a, b) => 5 - b.index + b.player.score - (5 - a.index + a.player.score)); for (let i = 0; i < sortedPlayers.length; i++) { if (sortedPlayers[i].player.accountId.toString() === playerId) { return i; } } return -1; }, getPlayerRankLabel(rec: GameRecord, player: number | string | PlayerRecord): string { return getRankLabelByIndex(GameRecord.getRankIndexByPlayer(rec, player)) || ""; }, getPlayerRankColor(rec: GameRecord, player: number | string | PlayerRecord): string { return Conf.rankColors[GameRecord.getRankIndexByPlayer(rec, player)]; }, encodeAccountId: (t: number) => 1358437 + ((7 * t + 1117113) ^ 86216345), getStartTime: (rec: GameRecord | number) => (typeof rec === "number" ? rec : rec.startTime) * 1000, formatFullStartTime: (rec: GameRecord | number) => dayjs(GameRecord.getStartTime(rec)).format("YYYY/M/D HH:mm"), formatStartDate: (rec: GameRecord | number) => dayjs(GameRecord.getStartTime(rec)).format("M/D"), getRecordLink(rec: GameRecord | string, player?: PlayerRecord | number | string) { const playerId = typeof player === "object" ? player.accountId : player; const trailer = playerId ? `_a${GameRecord.encodeAccountId(typeof playerId === "number" ? playerId : parseInt(playerId))}` : ""; const uuid = typeof rec === "string" ? rec : rec.uuid; return `${i18n.t("https://game.maj-soul.com/1/")}?paipu=${uuid}${trailer}`; }, getMaskedRecordLink(rec: GameRecord, player?: PlayerRecord | number | string) { if (!Conf.maskedGameLink) { return GameRecord.getRecordLink(rec, player); } const playerId = typeof player === "object" ? player.accountId : player; const trailer = playerId ? `/${GameRecord.encodeAccountId(typeof playerId === "number" ? playerId : parseInt(playerId))}` : ""; return `${getApiPrefix()}view_game/${getZoneFromLocale(i18n.language)}/${rec.modeId}/${rec._id}${trailer}`; }, }); ================================================ FILE: src/data/types/statistics.ts ================================================ import { AccountZone, GameMode } from "."; import { WithLastModified } from "../source/api"; import { PlayerMetadataLite2, PlayerExtendedStats, RankRates } from "./metadata"; export type RankRateBySeat = { [modeId: string]: { [rankId: number]: RankRates; } & { numGames: number; length: number }; }; export type GlobalStatistics = WithLastModified & { [modeId: string]: { [levelId: string]: { num_players: number; basic: PlayerMetadataLite2; extended: PlayerExtendedStats; }; }; }; export type LevelStatisticsItem = [AccountZone, number, number]; export type LevelStatistics = LevelStatisticsItem[]; export type HistogramData = { min: number; max: number; bins: number[]; }; export type HistogramGroup = { mean: number; histogramFull?: HistogramData; histogramClamped?: HistogramData; }; export type GlobalHistogram = { [modeId in GameMode]: { [levelId: string]: { [name in keyof PlayerExtendedStats]: HistogramGroup; }; }; }; export type FanStatEntry = { label: string; count: number; }; export type FanStats = { [modeId: string]: { count: number; entries: FanStatEntry[]; }; }; ================================================ FILE: src/data/types/utils.ts ================================================ import i18n from "../../i18n"; import { RANK_LABELS } from "./constants"; const t = i18n.t.bind(i18n); export function getRankLabelByIndex(index: number): string { return t(RANK_LABELS[index]); } export function getRankLabelByIndexRaw(index: number): string { return RANK_LABELS[index]; } ================================================ FILE: src/data/types/zone.ts ================================================ export enum AccountZone { China = 1, Japan = 2, International = 3, Unknown = -1, } export function getZoneFromLocale(locale: string): AccountZone { if (/^ja/i.test(locale)) { return AccountZone.Japan; } if (/^zh/i.test(locale)) { return AccountZone.China; } return AccountZone.International; } export function getAccountZone(accountId: number): AccountZone { if (!accountId) { return AccountZone.Unknown; } const prefix = accountId >> 23; if (prefix >= 0 && prefix <= 6) { return AccountZone.China; } if (prefix >= 7 && prefix <= 12) { return AccountZone.Japan; } if (prefix >= 13 && prefix <= 15) { return AccountZone.International; } return AccountZone.Unknown; } export function getZoneTag(zone: AccountZone): string { switch (zone) { case AccountZone.China: return "Ⓒ"; case AccountZone.Japan: return "Ⓙ"; case AccountZone.International: return "Ⓔ"; default: return ""; } } export function getAccountZoneTag(accountId: number): string { return getZoneTag(getAccountZone(accountId)); } ================================================ FILE: src/i18n.ts ================================================ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import { triggerRelayout } from "./utils"; const DEBUG = process.env.NODE_ENV === "development" && sessionStorage.i18nDebug; if (DEBUG) { sessionStorage.removeItem("__i18nMissingKeys"); } i18n .use({ type: "backend", read(language: string, namespace: string, callback: (errorValue: unknown, translations: null | unknown) => void) { if (language === "zh-hans") { return callback(null, {}); } import(`./locales/${language}.json`) .then((resources) => { resources = resources.default; callback(null, { ...resources["default"], ...resources[namespace] }); }) .catch((error) => { callback(error, null); }); }, }) .use(LanguageDetector) .use(initReactI18next) // passes i18n down to react-i18next .init({ lowerCaseLng: true, fallbackLng: "zh-hans", defaultNS: "default", debug: DEBUG, whitelist: ["ja", "zh-hans", "en", "ko"], detection: { order: ["localStorage", "navigator"], caches: ["localStorage"], checkWhitelist: true, }, returnEmptyString: false, returnNull: false, saveMissing: DEBUG, missingKeyHandler: DEBUG ? function (lng, ns, key) { const missingKeys = JSON.parse(sessionStorage.getItem("__i18nMissingKeys") || "{}") || {}; const l = i18n.language; if (l === "zh-hans") { return; } missingKeys[l] = missingKeys[l] || {}; missingKeys[l][ns] = missingKeys[l][ns] || {}; missingKeys[l][ns][key] = ""; sessionStorage.setItem("__i18nMissingKeys", JSON.stringify(missingKeys)); } : false, nsSeparator: false, keySeparator: false, interpolation: { escapeValue: false, }, }); if ("document" in global) { // Fix error in node i18n.on("languageChanged", function () { document.documentElement.lang = i18n.language; triggerRelayout(); }); } export default i18n; ================================================ FILE: src/index.tsx ================================================ /* eslint-disable */ // @ts-nocheck window.__loadGa = function () { if (window.ga) { return ga; } window.dataLayer = window.dataLayer || []; function gtag() { dataLayer.push(arguments); } gtag("js", new Date()); gtag("config", "G-3M94EBS8XE"); const gtagElement = document.createElement("script"); gtagElement.src = "https://www.googletagmanager.com/gtag/js?id=G-3M94EBS8XE"; gtagElement.async = true; document.head.appendChild(gtagElement); (function (i, s, o, g, r, a, m) { i["GoogleAnalyticsObject"] = r; (i[r] = i[r] || function () { (i[r].q = i[r].q || []).push(arguments); }), (i[r].l = 1 * new Date()); (a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]); a.async = 1; a.src = g; m.parentNode.insertBefore(a, m); })(window, document, "script", "https://www.google-analytics.com/analytics.js", "ga"); ga("create", "UA-155269742-1", "auto"); return ga; }; /* eslint-enable */ const init = () => import(/* webpackMode: "eager" */ "./bootstrap"); if (!Object.values || !window.URLSearchParams || !window.fetch || !window.Set) { import(/* webpackMode: "lazy" */ "./utils/polyfill").then(init); } else { init(); } export {}; ================================================ FILE: src/locales/en.json ================================================ { "default": { "雀魂牌谱屋": "MajSoul Stats", "雀魂牌谱屋·金": "MajSoul Stats - Gold", "雀魂牌谱屋·三麻": "MajSoul Stats - 3P", "主页": "Top", "最近役满": "Recent Yakuman", "排行榜": "Ranking", "大数据": "Data", "四麻玉/王座": "4P Jade/Throne", "四麻": "4P", "四麻金": "4P Gold", "三麻": "3P", "等级": "Lvl", "顺位": "Rk", "玩家": "Player", "时间": "Date and time", "开始": "Strt", "结束": "End", "全部": "All", "最近四周": "4 weeks", "自定义": "Custom", "王座": "Throne", "玉": "Jade", "金": "Gold", "王东": "Throne East", "玉东": "Jade East", "金东": "Gold East", "最近 {{x}} 周": "{{x}} weeks", "最近 {{x}} 场": "{{x}} matches", "本月": "This month", "上月": "Last month", "今年": "This year", "去年": "Last year", "新王座": "New Throne", "旧王座": "Old Throne", "自定义时间": "Set time", "自定开始时间...": "Set start...", "自定结束时间...": "Set end...", "确定": "OK", "收藏": "Bookmark", "已收藏": "Bookmarked", "筛选": "Filter", "查看牌谱": "View game", "复制链接": "Copy link", "链接复制成功": "Link copied", "玩家详细": "Player details", "AI 检讨": "AI review", "https://mjai.ekyu.moe/zh-cn.html": "https://mjai.ekyu.moe/", "玩家:": "Player: ", "最近走势": "Trends", "累计战绩": "Rank distribution", "和牌时": "Wins", "放铳时": "Self hand when dealing in", "放铳至": "Opponent's hand when dealing in", "副露": "Open", "默听": "Dama", "门清": "Closed", "{{mode}}位置:": "Position in {{mode}}: ", "{{mode}}平均值:": "Mean in {{mode}}: ", "{{mode}}各段位平均值:": "Mean for ranks in {{mode}}: ", "记录场数": "Recorded matches", "记录等级": "Current rank", "记录分数": "Current rk points", "平均顺位": "Average rank", "被飞率": "Busting rate", "安定段位": "Stable rank", "分数期望": "Expected score", "和牌率": "Win rate", "放铳率": "Deal-in rate", "自摸率": "Tsumo rate", "默胡率": "Dama rate", "流局率": "Exhaustive draw rate", "流听率": "Draw tenpai rate", "副露率": "Call rate", "立直率": "Riichi rate", "和了巡数": "Avg turns to win", "平均打点": "Average win score", "平均铳点": "Average deal-in score", "立直和了": "Riichi win rate", "立直放铳A": "Deal-in after riichi A", "立直放铳B": "Deal-in after riichi B", "立直收支": "Riichi payment", "立直收入": "Avg riichi hand value", "立直支出": "Avg riichi deal-in", "先制率": "First riichi", "追立率": "Chasing riichi", "被追率": "Chased rate", "立直巡目": "Avg riichi turns", "立直流局": "Riichi draw rate", "一发率": "Ippatsu rate", "振听率": "Furiten rate", "最高等级": "Best rank", "最高分数": "Best rank points", "最大连庄": "Max repeats", "里宝率": "Uradora rate", "被炸率": "Tsumo hit as dealer", "平均被炸点数": "Tsumo hit as dler pt", "放铳时立直率": "Deal-in while riichi", "放铳时副露率": "Deal-in while open", "副露后放铳率": "Deal-in after open", "副露后和牌率": "Win rate after open", "副露后流局率": "Draw rate after open", "总计局数": "Total rounds", "役满": "Yakuman", "累计役满": "Counted Yakuman", "最大累计番数": "Max total han count", "流满": "Nagashi mangan", "起手向听": "Haipai shanten", "亲起手向听": "Dealer h. shanten", "子起手向听": "Non-dealer h. shanten", "立直好型": "Good-hand riichi", "立直多面": "Multi-sided riichi", "打点效率": "Win efficiency", "铳点损失": "Deal-in loss", "净打点效率": "Net win efficiency", "局收支": "G/L per round", "场平均素点": "Avg match points", "场起始素点": "Starting points", "和牌局数 / 总局数": "Win rounds / Total rounds", "放铳局数 / 总局数": "Deal-in rounds / Total rounds", "自摸局数 / 和牌局数": "Tsumo rounds / Win rounds", "门清默听和牌局数 / 和牌局数": "Dama-hand win rounds / Win rounds", "流局局数 / 总局数": "Draw rounds / Total rounds", "流局听牌局数 / 流局局数": "Tenpai-at-draw rounds / Draw rounds", "副露局数 / 总局数": "Open-hand rounds / Total rounds", "立直局数 / 总局数": "Riichi rounds / Total rounds", "立直和了局数 / 立直局数": "Win after riichi rounds / Riichi rounds", "立直放铳局数(含立直瞬间 / 不含立直瞬间) / 立直局数": "Deal-in after riichi rounds (including / not including deal-in at the same round of riichi) / Riichi rounds", "立直放铳局数(含立直瞬间) / 立直局数": "Deal-in after riichi rounds (including deal-in at the same round of riichi) / Riichi rounds", "立直放铳局数(不含立直瞬间) / 立直局数": "Deal-in after riichi rounds (not including deal-in at the same round of riichi) / Riichi rounds", "立直总收支(含供托) / 立直局数": "Riichi total balances (with round debts) / Riichi rounds", "立直和了收入(含供托) / 立直和了局数": "Riichi winning incomes (with round debts) / Win after riichi rounds", "立直放铳支出(含立直棒) / 立直放铳局数": "Riichi deal-in expenses (with round debts) / Deal-in after riichi rounds", "先制立直局数 / 立直局数": "Leading-riichi rounds / Riichi rounds", "追立局数 / 立直局数": "Chasing-riichi rounds / Riichi rounds", "被追立局数 / 立直局数": "Chased-riichi rounds / Riichi rounds", "立直流局局数 / 立直局数": "Riichi draw rounds / Riichi rounds", "一发局数 / 立直和了局数": "Ippatsu rounds / Riichi win rounds", "振听立直局数(不含立直见逃) / 立直局数": "Furiten-riichi rounds (excludes skipping wins after riichi) / Riichi rounds", "中里宝局数 / 立直和了局数": "Uradora rounds / Riichi win rounds", "被炸庄(满贯或以上)次数 / 被自摸次数": "Being tsumo'd as dealer (by mangan or above) rounds / Tsumo'd rounds", "被炸庄(满贯或以上)点数 / 次数": "Being tsumo'd as dealer (by mangan or above) points / rounds", "放铳时立直次数 / 放铳次数": "Deal-in after riichi rounds / Deal-in rounds", "放铳时副露次数 / 放铳次数": "Deal-in with open-hand rounds / Deal-in rounds", "放铳时副露次数 / 副露次数": "Deal-in with open-hand rounds / Open-hand rounds", "副露后和牌次数 / 副露次数": "Open-hand win rounds / Open-hand rounds", "副露后流局次数 / 副露次数": "Open-hand draw rounds / Open-hand rounds", "和出役满次数": "Yakuman win rounds", "和出累计役满次数": "Counted-yakuman win rounds", "和出的最大番数(不含役满役)": "Highest han (except yakuman yakus)", "流满次数": "Mangan-at-draw rounds", "两立直次数": "Double riichi rounds", "多面立直局数 / 立直局数
    听牌两种或以上即视为多面(含对碰)": "Multi-sided wait riichi rounds / Riichi rounds
    Tenpai with 2 or more sided waits is considered multi-sided wait, including shanpon", "好型立直局数 / 立直局数
    立直时听牌可见剩余 6 枚或以上视为好型": "Good-hand riichi rounds / Riichi rounds
    Tenpai with 6 or more available tiles (all visible tiles factored in) at the time of riichi is considered good hand", "(数据从 {{date}} 前后开始收集)": "(Data collection of this metric was started around {{date}})", "升": "ranking-up", "降": "ranking-down", ",括号内为预计{{ label }}段场数": ", the number in brackets is the prediction of matches for {{ label }}", "在{{ modeL }}之间一直进行对局,预测最终能达到的段位。": "The predicted rank can be reached for continuing to play in {{ modeL }} Room.", "括号内为安定段位时的分数期望。": "the number in brackets is the prediction of points in stable rank", "(数据量不足,计算结果可能有较大偏差)": " (The result may be strongly biased if there's no sufficient data amounts)", "{{ levelNames1 }}位平均 Pt / {{ levelName2 }}位平均得点 Pt:": "{{ levelNames1 }} place average pts / {{ levelName2 }} place average pts", "在{{ modeL }}之间每局获得点数的数学期望值{{ changeLevelMsg }}": "The mathematical expectation of each round's points in {{ modeL }} Room{{ changeLevelMsg }} ", "得点效率(各顺位平均 Pt 及平均得点 Pt 的加权平均值):": "Point efficiency (Weighted average of each rank's points with corresponding rank's rate): ", "番": "Han", "满贯": "Mangan", "跳满": "Haneman", "倍满": "Baiman", "三倍满": "Sanbaiman", "胜率:": "Win rate: ", "对手": "Opponent", "平均得点": "Average points", "类型": "Type", "记录和出局数:": "Recorded wins in this category: ", "役": "Yaku", "记录数": "Recorded", "比率": "Rate", "一位率": "1st rate", "二位率": "2nd rate", "三位率": "3rd rate", "四位率": "4th rate", "对战数": "Matches played", "在位记录": "Players recorded", "统计对战数:": "Total number of matches: ", "局": "games", "坐席吃一率": "Seat 1st rate", "坐席吃三率": "Seat 3rd rate", "坐席吃四率": "Seat 4th rate", "苦主榜": "Negative ranking", "一周": "1 week", "四周": "4 weeks", "三天": "3 days", "一天": "1 day", "汪汪榜": "Positive ranking", "劳模榜": "Stamina ranking", "提示": "Notice", "本榜只包含有至少 300 场对局记录的玩家": "The ranking only includes the players with at least 300 recorded matches", "排行榜非实时更新,可能会有数小时的延迟。": "The ranking is not renewed in real-time, a delay may occur for several hours.", "排名": "Rk", "对局数": "Matches", "连对率": "Top 2 rate", "得点效率": "Point efficiency", "和铳差": "Win-lose diff", "一位平均 Pt": "1st average Pt", "请选择模式": "Please choose a level", "二位平均 Pt": "2nd average Pt", "三位平均 Pt": "3rd average Pt", "四位平均得点 Pt": "4th average Pt", "按服务器": "By server", "按等级": "By rank", "全体": "Overall", "活跃玩家": "Active players", "一年内对局过的玩家的一年对局数据": "1-year data of players who have played in the past year", "初士杰豪圣魂": "NoAdExMsStCl", "玩家前缀搜索": "Matching players", "(输入更长名字显示其它结果)": "(Input longer names to show other results)", "无超过满贯大铳": "No greater-than-mangan deal-in", "加载数据失败": "Failed to load data", "https://game.maj-soul.com/1/": "https://mahjongsoul.game.yo-star.com/", "一位": "1st", "二位": "2nd", "三位": "3rd", "四位": "4th", "三": "3rd", "四": "4th", "一二三": "1st/2nd/3rd", "一二": "1st/2nd", "东": "E", "南": "S", "西": "W", "北": "N", "门前清自摸和": "Fully Concealed Hand", "立直": "Riichi", "枪杠": "Robbing a Kan", "岭上开花": "After a Kan", "海底摸月": "Under the Sea", "河底捞鱼": "Under the River", "役牌 白": "White Dragon (Haku)", "役牌 发": "Green Dragon (Hatsu)", "役牌 中": "Red Dragon (Chun)", "役牌:门风牌": "Seat Wind", "役牌:场风牌": "Prevalent Wind", "断幺九": "All Simples", "一杯口": "Pure Double Sequence", "平和": "Pinfu", "混全带幺九": "Half Outside Hand", "一气通贯": "Pure Straight", "三色同顺": "Mixed Triple Sequence", "两立直": "Double riichi", "三色同刻": "Triple Triplets", "三杠子": "Three Quads", "对对和": "All Triplets", "三暗刻": "Three Concealed Triplets", "小三元": "Little Three Dragons", "混老头": "All Terminals and Honours", "七对子": "Seven Pairs", "纯全带幺九": "Fully Outside Hand", "混一色": "Half Flush", "二杯口": "Twice Pure Double Sequence", "清一色": "Full Flush", "一发": "Ippatsu", "宝牌": "Dora", "红宝牌": "Red Five", "里宝牌": "Uradora", "拔北宝牌": "Kita", "天和": "Blessing of Heaven", "地和": "Blessing of Earth", "大三元": "Big Three Dragons", "四暗刻": "Four Concealed Triplets", "字一色": "All Honors", "绿一色": "All Green", "清老头": "All Terminals", "国士无双": "Thirteen Orphans", "小四喜": "Four Little Winds", "四杠子": "Four Quads", "九莲宝灯": "Nine Gates", "八连庄": "Paarenchan", "纯正九莲宝灯": "True Nine Gates", "四暗刻单骑": "Single-wait Four Concealed Triplets", "国士无双十三面": "Thirteen-wait Thirteen Orphans", "大四喜": "Four Big Winds", "燕返": "Tsubame-gaeshi", "杠振": "Kanburi", "十二落抬": "Shiiaruraotai", "五门齐": "Uumensai", "三连刻": "Three Chained Triplets", "一色三同顺": "Pure Triple Chow", "一筒摸月": "Iipinmoyue", "九筒捞鱼": "Chuupinraoyui", "人和": "Hand of Man", "大车轮": "Big Wheels", "大竹林": "Bamboo Forest", "大数邻": "Numerous Neighbours", "石上三年": "Ishinouenimosannen", "大七星": "Big Seven Stars" }, "form": { "日期": "Date", "查找玩家": "Player search", "对局浏览": "Browse matches", "名字": "Name", "时间": "Period", "等级": "Level", "顺位": "Rank", "巅峰对决": "Konten-only games" }, "navButtons": { "基本": "Basic", "立直": "Riichi", "更多": "Others", "和铳分布": "Win-lose distribution", "血统": "Lucky", "最近大铳": "Recent high loss", "最常同桌": "Frequent opponents", "最近 100 局": "Last 100 matches", "全部": "All", "坐席顺位": "Seat ranking", "等级数据": "Player ranks", "和出役种统计": "Winning yakus", "记录玩家数": "Number of players in record", "苦主及汪汪": "Up/Down", "一位率/四位率": "1st rate/4th rate", "一位率/三位率": "1st rate/3rd rate", "连对率/安定段位": "Top 2 rate/Stable rank", "安定段位": "Stable rank", "最高等级": "Best rank", "平均顺位/对局数": "Average rank/Matches played", "得点效率": "Point efficiency", "和率/铳率": "Win rate/Deal-in rate", "欧洲人": "Lucky", "非洲人": "Unlucky", "和铳差": "Win-lose diff", "一/二位平均 Pt": "1st/2nd average Pt", "三位平均 Pt/四位平均得点 Pt": "3rd/4th average Pt" }, "gameModeShort": { "金": "Gld", "玉": "Jad", "王座": "Thr", "王东": "Thr E", "玉东": "Jad E", "金东": "Gld E", "等级": "Level" } } ================================================ FILE: src/locales/ja.json ================================================ { "default": { "雀魂牌谱屋": "雀魂牌譜屋", "雀魂牌谱屋·金": "雀魂牌譜屋·金", "雀魂牌谱屋·三麻": "雀魂牌譜屋·三麻", "主页": "トップ", "最近役满": "最近役満", "排行榜": "ランキング", "大数据": "データ", "四麻玉/王座": "四人玉/王座", "四麻金": "四人金", "三麻": "三人", "四麻": "四人", "Twitter": "ツイッター", "等级": "レベル", "顺位": "順位", "玩家": "プレイヤー", "时间": "日時", "开始": "開始", "结束": "終了", "全部": "全部", "最近四周": "四週間", "自定义": "指定", "王座": "王座", "玉": "玉", "金": "金", "王东": "王東", "玉东": "玉東", "金东": "金東", "最近 {{x}} 周": "{{x}} 週間", "最近 {{x}} 场": "{{x}} 戦", "本月": "今月", "上月": "先月", "今年": "今年", "去年": "去年", "新王座": "新王座", "旧王座": "旧王座", "自定义时间": "時刻を指定する", "自定开始时间...": "開始を指定する...", "自定结束时间...": "終了を指定する...", "确定": "OK", "收藏": "ブックマーク", "已收藏": "ブックマーク済み", "王東": "王東", "玉東": "玉東", "金東": "金東", "一位": "一位", "二位": "二位", "三位": "三位", "四位": "四位", "筛选": "絞り込み", "东": "東", "南": "南", "西": "西", "北": "北", "查看牌谱": "牌譜を見る", "复制链接": "リンクをコピーする", "链接复制成功": "リンクはコピーされました", "玩家详细": "プレイヤーの情報", "AI 检讨": "AI レビュー", "https://mjai.ekyu.moe/zh-cn.html": "https://mjai.ekyu.moe/ja.html", "玩家:": "プレイヤー:", "最近走势": "対戦記録", "累计战绩": "順位分布", "和牌时": "和了時", "放铳时": "放銃時", "放铳至": "放銃相手", "副露": "副露", "默听": "ダマ", "门清": "門前", "{{mode}}位置:": "{{mode}}での位置:", "{{mode}}平均值:": "{{mode}}の平均値:", "{{mode}}各段位平均值:": "{{mode}}の段位別の平均値:", "记录场数": "記録対戦数", "记录等级": "記録段位", "记录分数": "記録点数", "平均顺位": "平均順位", "被飞率": "飛び率", "安定段位": "安定段位", "分数期望": "点数期待", "和牌率": "和了率", "放铳率": "放銃率", "自摸率": "ツモ率", "默胡率": "ダマ率", "流局率": "流局率", "流听率": "流局聴牌率", "副露率": "副露率", "立直率": "立直率", "和了巡数": "和了巡数", "平均打点": "平均和了", "平均铳点": "平均放銃", "立直和了": "立直和了", "立直放铳A": "立直放銃A", "立直放铳B": "立直放銃B", "立直收支": "立直収支", "立直收入": "立直収入", "立直支出": "立直支出", "先制率": "先制率", "追立率": "追っかけ率", "被追率": "追っかけられ率", "立直巡目": "立直巡目", "立直流局": "立直流局", "一发率": "一発率", "振听率": "振聴率", "最高等级": "最高段位", "最高分数": "最高点数", "最大连庄": "最大連荘", "里宝率": "裏ドラ率", "被炸率": "痛い親かぶり率", "平均被炸点数": "痛い親かぶり平均", "放铳时立直率": "放銃時立直率", "放铳时副露率": "放銃時副露率", "副露后放铳率": "副露後放銃率", "副露后和牌率": "副露後和了率", "副露后流局率": "副露後流局率", "总计局数": "総計局数", "役满": "役満", "累计役满": "数え役満", "最大累计番数": "最大合計飜数", "流满": "流し満貫", "起手向听": "配牌向聴", "亲起手向听": "親配牌向聴", "子起手向听": "子配牌向聴", "立直好型": "立直良形", "立直多面": "立直多面", "打点效率": "打点効率", "铳点损失": "銃点損失", "净打点效率": "調整打点効率", "局收支": "局収支", "场平均素点": "対戦平均持ち点", "场起始素点": "対戦初期持ち点", "和牌局数 / 总局数": "和了回数 / 配牌回数", "放铳局数 / 总局数": "放銃回数 / 配牌回数", "自摸局数 / 和牌局数": "ツモ回数 / 和了回数", "门清默听和牌局数 / 和牌局数": "門前ダマ和了回数 / 和了回数", "流局局数 / 总局数": "流局回数 / 配牌回数", "流局听牌局数 / 流局局数": "流局聴牌回数 / 流局回数", "副露局数 / 总局数": "副露回数 / 配牌回数", "立直局数 / 总局数": "立直回数 / 配牌回数", "立直和了局数 / 立直局数": "立直和了回数 / 立直回数", "立直放铳局数(含立直瞬间 / 不含立直瞬间) / 立直局数": "立直放銃回数(立直瞬間を含む / 含まない) / 立直回数", "立直放铳局数(含立直瞬间) / 立直局数": "立直放銃回数(立直瞬間を含む) / 立直回数", "立直放铳局数(不含立直瞬间) / 立直局数": "立直放銃回数(立直瞬間を含まない) / 立直回数", "立直总收支(含供托) / 立直局数": "立直時の収支(供託を含む) / 立直回数", "立直和了收入(含供托) / 立直和了局数": "立直和了収入(供託を含む) / 立直和了回数", "立直放铳支出(含立直棒) / 立直放铳局数": "立直時の放銃支出(供託を含む) / 立直放銃回数", "先制立直局数 / 立直局数": "最初に立直した回数 / 立直回数", "追立局数 / 立直局数": "追っかけ立直した回数 / 立直回数", "被追立局数 / 立直局数": "立直を追っかけられた回数 / 立直回数", "立直流局局数 / 立直局数": "立直流局回数 / 立直回数", "一发局数 / 立直和了局数": "一発回数 / 立直和了回数", "振听立直局数(不含立直见逃) / 立直局数": "振聴立直回数(見逃しを除く) / 立直回数", "中里宝局数 / 立直和了局数": "裏ドラある和了回数 / 立直和了回数", "被炸庄(满贯或以上)次数 / 被自摸次数": "満貫以上の親かぶり回数 / ツモされた回数", "被炸庄(满贯或以上)点数 / 次数": "満貫以上の親かぶり点数 / 回数", "放铳时立直次数 / 放铳次数": "立直放銃回数 / 放銃回数", "放铳时副露次数 / 放铳次数": "副露放銃回数 / 放銃回数", "放铳时副露次数 / 副露次数": "副露放銃回数 / 副露回数", "副露后和牌次数 / 副露次数": "副露和了回数 / 副露回数", "副露后流局次数 / 副露次数": "副露流局回数 / 副露回数", "和出役满次数": "役満和了回数", "和出累计役满次数": "数え役満和了回数", "和出的最大番数(不含役满役)": "和了した最大飜数(役満の役を除く)", "流满次数": "流局満貫回数", "两立直次数": "ダブル立直回数", "多面立直局数 / 立直局数
    听牌两种或以上即视为多面(含对碰)": "多面立直回数 / 立直回数
    二面以上待ちの聴牌が多面と見なされます(シャンポン待ちを含む)", "好型立直局数 / 立直局数
    立直时听牌可见剩余 6 枚或以上视为好型": "良形立直回数 / 立直回数
    立直の時に自分の視点で残り枚数が6以上の聴牌が良形と見なされます", "(数据从 {{date}} 前后开始收集)": "(この数値は {{date}} ごろから集計しています)", "升": "昇", "降": "降", ",括号内为预计{{ label }}段场数": "、括弧内は予測した{{ label }}段対戦数", "在{{ modeL }}之间一直进行对局,预测最终能达到的段位。": "{{ modeL }}の間に対戦し続けると、最終に安定している段位を予測します。", "括号内为安定段位时的分数期望。": "括弧内は安定段位に着く後、点数変化の期待値", "(数据量不足,计算结果可能有较大偏差)": "(データが足りないので、予測した結果は大きい誤差が出る可能性があります)", "{{ levelNames1 }}位平均 Pt / {{ levelName2 }}位平均得点 Pt:": "{{ levelNames1 }}位平均 Pt / {{ levelName2 }}位平均得点 Pt:", "在{{ modeL }}之间每局获得点数的数学期望值{{ changeLevelMsg }}": "{{ modeL }}の間に対戦の点数変化の期待値{{ changeLevelMsg }}", "得点效率(各顺位平均 Pt 及平均得点 Pt 的加权平均值):": "得点効率(順位ごとの平均 Pt / 平均得点 Ptの加重平均値):", "番": "飜", "满贯": "満貫", "跳满": "跳満", "倍满": "倍満", "三倍满": "三倍満", "胜率:": "勝率:", "对手": "相手", "平均得点": "平均得点", "类型": "タイプ", "记录和出局数:": "記録した和了件数:", "役": "役", "记录数": "記録数", "比率": "割合", "一位率": "一位率", "二位率": "二位率", "三位率": "三位率", "四位率": "四位率", "对战数": "対戦数", "在位记录": "在位記録", "统计对战数:": "集計した対戦数:", "坐席吃一率": "座席一位率", "坐席吃三率": "座席三位率", "坐席吃四率": "座席四位率", "苦主榜": "不調ランキング", "一周": "一週間", "四周": "四週間", "三天": "三日間", "一天": "一日間", "汪汪榜": "好調ランキング", "劳模榜": "鬼打ちランキング", "提示": "提示", "本榜只包含有至少 300 场对局记录的玩家": "本ランキングは 300 戦以上の記録があるプレイヤーだけが入られます", "排行榜非实时更新,可能会有数小时的延迟。": "ランキングはリアルタイムではありません、数時間ぐらい遅れることがあります。", "排名": "順位", "对局数": "対戦", "连对率": "連対率", "得点效率": "得点効率", "一位平均 Pt": "一位平均 Pt", "请选择模式": "レベルを選んでください", "二位平均 Pt": "二位平均 Pt", "三位平均 Pt": "三位平均 Pt", "四位平均得点 Pt": "四位平均得点 Pt", "和铳差": "和銃差", "按服务器": "サーバー別", "按等级": "段位別", "全体": "全体", "活跃玩家": "アクティブプレイヤー", "一年内对局过的玩家的一年对局数据": "過去一年間に対局したプレイヤーの一年分の対局データ", "局": "戦", "初士杰豪圣魂": "初士傑豪聖魂", "玩家前缀搜索": "名前の先頭部分による検索", "(输入更长名字显示其它结果)": "(入力し続くと他の結果が表示します)", "无超过满贯大铳": "満貫を超える放銃はありません", "加载数据失败": "データの読み込みに失敗しました", "https://game.maj-soul.com/1/": "https://game.mahjongsoul.com/", "门前清自摸和": "門前清自摸和", "立直": "立直", "枪杠": "槍槓", "岭上开花": "嶺上開花", "海底摸月": "海底摸月", "河底捞鱼": "河底撈魚", "役牌 白": "役牌 白", "役牌 发": "役牌 發", "役牌 中": "役牌 中", "役牌:门风牌": "役牌:自風牌", "役牌:场风牌": "役牌:場風牌", "断幺九": "断幺九", "一杯口": "一盃口", "平和": "平和", "混全带幺九": "混全帯幺九", "一气通贯": "一気通貫", "三色同顺": "三色同順", "两立直": "ダブル立直", "三色同刻": "三色同刻", "三杠子": "三槓子", "对对和": "対々和", "三暗刻": "三暗刻", "小三元": "小三元", "混老头": "混老頭", "七对子": "七対子", "纯全带幺九": "純全帯幺九", "混一色": "混一色", "二杯口": "二盃口", "清一色": "清一色", "一发": "一発", "宝牌": "ドラ", "红宝牌": "赤ドラ", "里宝牌": "裏ドラ", "拔北宝牌": "抜きドラ", "天和": "天和", "地和": "地和", "大三元": "大三元", "四暗刻": "四暗刻", "字一色": "字一色", "绿一色": "緑一色", "清老头": "清老頭", "国士无双": "国士無双", "小四喜": "小四喜", "四杠子": "四槓子", "九莲宝灯": "九蓮宝燈", "八连庄": "八連荘", "纯正九莲宝灯": "純正九蓮宝燈", "四暗刻单骑": "四暗刻単騎", "国士无双十三面": "国士無双十三面待ち", "大四喜": "大四喜", "燕返": "燕返し", "杠振": "槓振り", "十二落抬": "十二落抬", "五门齐": "五門斉", "三连刻": "三連刻", "一色三同顺": "一色三順", "一筒摸月": "一筒摸月", "九筒捞鱼": "九筒撈魚", "人和": "人和", "大车轮": "大車輪", "大竹林": "大竹林", "大数邻": "大数隣", "石上三年": "石の上にも三年", "大七星": "大七星" }, "form": { "日期": "日付", "查找玩家": "プレイヤー検索", "对局浏览": "対局閲覧", "名字": "名前", "时间": "期間", "等级": "レベル", "顺位": "順位", "巅峰对决": "頂上対決" }, "navButtons": { "基本": "基本", "立直": "立直", "更多": "ほか", "和铳分布": "和銃分布", "血统": "幸運度", "最近大铳": "最近大銃", "最常同桌": "よく同卓する相手", "最近 100 局": "最近 100 戦", "全部": "全部", "坐席顺位": "座席順位", "等级数据": "段位データ", "和出役种统计": "和了役集計", "记录玩家数": "記録プレイヤー数", "苦主及汪汪": "不調と好調", "一位率/四位率": "一位率/四位率", "一位率/三位率": "一位率/三位率", "连对率/安定段位": "連対率/安定段位", "最高等级": "最高段位", "安定段位": "安定段位", "平均顺位/对局数": "平均順位/対戦数", "得点效率": "得点効率", "和率/铳率": "和了率/放銃率", "欧洲人": "ラッキー", "非洲人": "アンラッキー", "和铳差": "和銃差", "一/二位平均 Pt": "一/二位平均 Pt", "三位平均 Pt/四位平均得点 Pt": "三位平均 Pt/四位平均得点 Pt" }, "gameModeShort": { "王座": "王座", "玉": "玉", "金": "金", "王东": "王東", "玉东": "玉東", "金东": "金東", "等级": "レベル" } } ================================================ FILE: src/locales/ko.json ================================================ { "default": { "雀魂牌谱屋": "작혼 통계", "雀魂牌谱屋·金": "작혼 통계·금탁", "雀魂牌谱屋·三麻": "작혼 통계·3마", "主页": "홈", "最近役满": "최근 역만", "排行榜": "랭킹", "大数据": "데이터", "四麻玉/王座": "4인 옥/왕좌탁", "四麻": "4인", "四麻金": "4인 금탁", "三麻": "3인", "Twitter": "트위터", "等级": "등급", "顺位": "순위", "玩家": "플레이어", "时间": "일시", "开始": "시작", "结束": "종료", "全部": "전체", "最近四周": "4주간", "自定义": "지정", "王座": "왕좌", "玉": "옥", "金": "금", "王东": "왕좌E", "玉东": "옥E", "金东": "금E", "王東": "왕좌E", "玉東": "옥E", "金東": "금E", "最近 {{x}} 周": "{{x}} 주간", "最近 {{x}} 场": "{{x}} 대국", "本月": "이번 달", "上月": "지난 달", "今年": "올해", "去年": "작년", "新王座": "신왕좌", "旧王座": "구왕좌", "自定义时间": "시각 선택", "自定开始时间...": "시작 시점 선택", "自定结束时间...": "종료 시점 선택", "确定": "OK", "收藏": "즐겨찾기", "已收藏": "즐겨찾기 완료", "筛选": "필터", "东": "동", "南": "남", "西": "서", "北": "북", "查看牌谱": "패보 보기", "复制链接": "링크 복사", "链接复制成功": "링크가 복사되었습니다", "玩家详细": "플레이어 정보", "AI 检讨": "AI 패보 복기하기", "https://mjai.ekyu.moe/zh-cn.html": "https://mjai.ekyu.moe/ko.html", "玩家:": "플레이어: ", "最近走势": "대전 기록", "累计战绩": "순위 분포", "和牌时": "화료시", "放铳时": "방총시", "放铳至": "방총 상대", "副露": "후로", "默听": "다마", "门清": "멘젠", "{{mode}}位置:": "{{mode}}탁에서의 위치: ", "{{mode}}平均值:": "{{mode}}탁의 평균치: ", "{{mode}}各段位平均值:": "{{mode}}탁의 단위별 평균치: ", "记录场数": "기록 대국 수", "记录等级": "현재 단위", "记录分数": "현재 점수", "平均顺位": "평균 순위", "被飞率": "토비율", "安定段位": "안정 단위", "分数期望": "기대 점수", "和牌率": "화료율", "放铳率": "방총률", "自摸率": "쯔모율", "默胡率": "다마율", "流局率": "유국률", "流听率": "유국 텐파이율", "副露率": "후로율", "立直率": "리치율", "和了巡数": "화료순", "平均打点": "평균 화료", "平均铳点": "평균 방총", "立直和了": "리치 화료", "立直放铳A": "리치 방총 A", "立直放铳B": "리치 방총 B", "立直收支": "리치 수지", "立直收入": "리치 수입", "立直支出": "리치 지출", "先制率": "선제율", "追立率": "추격률", "被追率": "피추격률", "立直巡目": "리치순", "立直流局": "리치 유국", "一发率": "일발률", "振听率": "후리텐률", "最高等级": "최고 단위", "最高分数": "최고 점수", "最大连庄": "최대 연장", "里宝率": "뒷도라율", "被炸率": "아픈 오야카부리율", "平均被炸点数": "아픈 오야카부리 평균", "放铳时立直率": "방총시 리치율", "放铳时副露率": "방총시 후로율", "副露后放铳率": "후로 후 방총률", "副露后和牌率": "후로 후 화료율", "副露后流局率": "후로 후 유국률", "总计局数": "총합 국 수", "役满": "역만", "累计役满": "카조에 역만", "最大累计番数": "최대 합계 판수", "流满": "나가시만관", "起手向听": "배패 텐수", "亲起手向听": "친 배패 텐수", "子起手向听": "자 배패 텐수", "立直好型": "리치 양형", "立直多面": "리치 다면", "打点效率": "화료 효율", "铳点损失": "방총 손실", "净打点效率": "알짜 화료 효율", "局收支": "국수지", "场平均素点": "대전 평균 소지점", "场起始素点": "대전 초기 소지점", "和牌局数 / 总局数": "화료 횟수 / 배패 횟수", "放铳局数 / 总局数": "방총 횟수 / 배패 횟수", "自摸局数 / 和牌局数": "쯔모 횟수 / 화료 횟수", "门清默听和牌局数 / 和牌局数": "멘젠다마 화료 횟수 / 화료 횟수", "流局局数 / 总局数": "유국 횟수 / 배패 횟수", "流局听牌局数 / 流局局数": "유국 텐파이 횟수 / 유국 횟수", "副露局数 / 总局数": "후로 횟수 / 배패 횟수", "立直局数 / 总局数": "리치 횟수 / 배패 횟수", "立直和了局数 / 立直局数": "리치화료 횟수 / 리치 횟수", "立直放铳局数(含立直瞬间 / 不含立直瞬间) / 立直局数": "리치방총 횟수(리치 순간을 포함/포함하지 않음) / 리치 횟수", "立直放铳局数(含立直瞬间) / 立直局数": "리치방총 횟수(리치 순간을 포함) / 리치 횟수", "立直放铳局数(不含立直瞬间) / 立直局数": "리치방총 횟수(리치 순간을 포함하지 않음) / 리치 횟수", "立直总收支(含供托) / 立直局数": "리치시의 수지(공탁금 포함) / 리치 횟수", "立直和了收入(含供托) / 立直和了局数": "리치화료 수입(공탁금 포함) / 리치화료 횟수", "立直放铳支出(含立直棒) / 立直放铳局数": "리치시의 방총 지출(공탁금 포함) / 리치방총 횟수", "先制立直局数 / 立直局数": "최초에 리치한 횟수 / 리치 횟수", "追立局数 / 立直局数": "추격리치한 횟수 / 리치 횟수", "被追立局数 / 立直局数": "리치를 추격당한 횟수 / 리치 횟수", "立直流局局数 / 立直局数": "리치유국 횟수 / 리치 횟수", "一发局数 / 立直和了局数": "일발 횟수 / 리치화료 횟수", "振听立直局数(不含立直见逃) / 立直局数": "후리텐리치 횟수(미노가시 제외) / 리치 횟수", "中里宝局数 / 立直和了局数": "뒷도라 있는 화료 횟수 / 리치화료 횟수", "被炸庄(满贯或以上)次数 / 被自摸次数": "만관 이상의 오야카부리 횟수 / 쯔모당한 횟수", "被炸庄(满贯或以上)点数 / 次数": "만관 이상의 오야카부리 점수 / 횟수", "放铳时立直次数 / 放铳次数": "리치방총 횟수 / 방총 횟수", "放铳时副露次数 / 放铳次数": "후로방총 횟수 / 방총 횟수", "放铳时副露次数 / 副露次数": "후로방총 횟수 / 후로 횟수", "副露后和牌次数 / 副露次数": "후로화료 횟수 / 후로 횟수", "副露后流局次数 / 副露次数": "후로유국 횟수 / 후로 횟수", "和出役满次数": "역만화료 횟수", "和出累计役满次数": "카조에역만 화료 횟수", "和出的最大番数(不含役满役)": "화료한 최대 판수(역만 역 제외)", "流满次数": "유국만관 횟수", "两立直次数": "더블리치 횟수", "多面立直局数 / 立直局数
    听牌两种或以上即视为多面(含对碰)": "다면리치 횟수 / 리치 횟수
    2면 이상의 대기인 텐파이를 다면으로 취급합니다(샤보 대기 포함)", "好型立直局数 / 立直局数
    立直时听牌可见剩余 6 枚或以上视为好型": "양형리치 횟수 / 리치 횟수
    리치 시에 자신의 시점에서 남은 매수가 6매 이상인 텐파이를 양형으로 취급합니다", "(数据从 {{date}} 前后开始收集)": "(이 수치는 {{date}} 즈음부터 계산되고 있습니다)", "升": "승", "降": "강", ",括号内为预计{{ label }}段场数": ", 괄호 안은 예측된 {{ label }}단 대전수", "在{{ modeL }}之间一直进行对局,预测最终能达到的段位。": "{{ modeL }}탁에서 대전을 계속했을 때 최종적으로 안정되는 단위를 예측합니다.", "括号内为安定段位时的分数期望。": "괄호 안은 안정 단위에 도달한 후, 점수 변화의 기대치", "(数据量不足,计算结果可能有较大偏差)": "(데이터가 부족하므로 예측한 결과와 큰 오차가 발생할 수 있습니다)", "{{ levelNames1 }}位平均 Pt / {{ levelName2 }}位平均得点 Pt:": "{{ levelNames1 }} 평균 Pt / {{ levelName2 }} 평균 Pt: ", "在{{ modeL }}之间每局获得点数的数学期望值{{ changeLevelMsg }}": "{{ modeL }}탁에서 대전의 점수 변화 기대치{{ changeLevelMsg }}", "得点效率(各顺位平均 Pt 及平均得点 Pt 的加权平均值):": "득점 효율 (순위별 평균 Pt의 가중 평균): ", "番": "판", "满贯": "만관", "跳满": "하네만", "倍满": "배만", "三倍满": "삼배만", "胜率:": "승률: ", "对手": "상대", "平均得点": "평균 득점", "类型": "종류", "记录和出局数:": "기록된 화료 건수: ", "役": "역", "记录数": "기록 수", "比率": "비율", "一位率": "1위율", "二位率": "2위율", "三位率": "3위율", "四位率": "4위율", "对战数": "대전", "在位记录": "기록된 플레이어 수", "统计对战数:": "집계된 대전 수: ", "局": "전", "坐席吃一率": "자리별 1위율", "坐席吃三率": "자리별 3위율", "坐席吃四率": "자리별 4위율", "苦主榜": "하강 랭킹", "一周": "1주간", "四周": "4주간", "三天": "3일간", "一天": "1일간", "汪汪榜": "상승 랭킹", "劳模榜": "겜창 랭킹", "提示": "안내", "本榜只包含有至少 300 场对局记录的玩家": "본 랭킹은 300전 이상의 기록이 있는 플레이어만 포함합니다", "排行榜非实时更新,可能会有数小时的延迟。": "랭킹은 실시간으로 갱신되지 않습니다. 수 시간 정도 늦을 수 있습니다.", "排名": "순위", "对局数": "대전", "连对率": "연대율", "得点效率": "득점 효율", "和铳差": "화료방총차", "按服务器": "서버별", "按等级": "단위별", "全体": "전체", "活跃玩家": "액티브 플레이어", "一年内对局过的玩家的一年对局数据": "과거 1년간 대국한 플레이어의 1년분의 대국 데이터", "一位平均 Pt": "1위 평균 Pt", "请选择模式": "등급을 선택해 주세요", "二位平均 Pt": "2위 평균 Pt", "三位平均 Pt": "3위 평균 Pt", "四位平均得点 Pt": "4위 평균 Pt", "初士杰豪圣魂": "초사걸호성혼", "玩家前缀搜索": "검색된 플레이어", "(输入更长名字显示其它结果)": "(입력을 계속해서 다른 결과를 볼 수 있습니다)", "无超过满贯大铳": "만관을 넘는 방총이 없습니다", "加载数据失败": "데이터를 읽지 못했습니다", "https://game.maj-soul.com/1/": "https://game.mahjongsoul.com/", "一位": "1위", "二位": "2위", "三位": "3위", "四位": "4위", "三": "3위", "四": "4위", "一二三": "1위/2위/3위", "一二": "1위/2위", "门前清自摸和": "멘젠쯔모", "立直": "리치", "枪杠": "창깡", "岭上开花": "영상개화", "海底摸月": "해저로월", "河底捞鱼": "하저로어", "役牌 白": "역패 백", "役牌 发": "역패 발", "役牌 中": "역패 중", "役牌:门风牌": "역패:자풍패", "役牌:场风牌": "역패:장풍패", "断幺九": "탕야오", "一杯口": "이페코", "平和": "핑후", "混全带幺九": "찬타", "一气通贯": "일기통관", "三色同顺": "삼색동순", "两立直": "더블리치", "三色同刻": "삼색동각", "三杠子": "산깡쯔", "对对和": "또이또이", "三暗刻": "산안커", "小三元": "소삼원", "混老头": "혼노두", "七对子": "치또이츠", "纯全带幺九": "준찬타", "混一色": "혼일색", "二杯口": "량페코", "清一色": "청일색", "一发": "일발", "宝牌": "도라", "红宝牌": "적도라", "里宝牌": "뒷도라", "拔北宝牌": "빼기도라", "天和": "천화", "地和": "지화", "大三元": "대삼원", "四暗刻": "스안커", "字一色": "자일색", "绿一色": "녹일색", "清老头": "청노두", "国士无双": "국사무쌍", "小四喜": "소사희", "四杠子": "스깡쯔", "九莲宝灯": "구련보등", "八连庄": "팔연장", "纯正九莲宝灯": "순정구련보등", "四暗刻单骑": "스안커단기", "国士无双十三面": "국사무쌍 13면 대기", "大四喜": "대사희", "燕返": "츠바메가에시", "杠振": "영상개론", "十二落抬": "십이낙태", "五门齐": "오문제", "三连刻": "삼련각", "一色三同顺": "일색삼순", "一筒摸月": "일통모월", "九筒捞鱼": "구통로어", "人和": "인화", "大车轮": "대차륜", "大竹林": "대죽림", "大数邻": "대수린", "石上三年": "돌 위에서 삼년", "大七星": "대칠성" }, "form": { "日期": "일자", "查找玩家": "플레이어 검색", "对局浏览": "대국 기록", "名字": "이름", "时间": "기간", "等级": "등급", "顺位": "순위", "巅峰对决": "4혼천 대국" }, "navButtons": { "基本": "기본", "立直": "리치", "更多": "그 외", "和铳分布": "화료 방총 분포", "血统": "행운도", "最近大铳": "최근 고타점 방총", "最常同桌": "자주 만나는 상대", "最近 100 局": "최근 100 전", "全部": "전체", "坐席顺位": "자리별 순위", "等级数据": "단위별 데이터", "和出役种统计": "화료역 집계", "记录玩家数": "기록 플레이어 수", "苦主及汪汪": "하강과 상승", "一位率/四位率": "1위율/4위율", "一位率/三位率": "1위율/3위율", "连对率/安定段位": "연대율/안정 단위", "最高等级": "최고 단위", "安定段位": "안정 단위", "平均顺位/对局数": "평균 순위/대전수", "得点效率": "득점 효율", "和率/铳率": "화료율/방총률", "欧洲人": "행운", "非洲人": "불운", "和铳差": "화료방총차", "一/二位平均 Pt": "1위/2위 평균 Pt", "三位平均 Pt/四位平均得点 Pt": "3위/4위 평균 Pt" }, "gameModeShort": { "王座": "왕좌", "玉": "옥", "金": "금", "王东": "왕좌E", "玉东": "옥E", "金东": "금E", "等级": "등급" } } ================================================ FILE: src/react-app-env.d.ts ================================================ /// ================================================ FILE: src/service-worker.ts ================================================ /* eslint-disable no-restricted-globals */ // This service worker can be customized! // See https://developers.google.com/web/tools/workbox/modules // for the list of available Workbox modules, or add any other // code you'd like. // You can also remove this file if you'd prefer not to use a // service worker, and the Workbox build step will be skipped. import { ExpirationPlugin } from "workbox-expiration"; import { precacheAndRoute, createHandlerBoundToURL } from "workbox-precaching"; import { registerRoute } from "workbox-routing"; import { StaleWhileRevalidate } from "workbox-strategies"; // eslint-disable-next-line no-undef declare const self: ServiceWorkerGlobalScope; // Precache all of the assets generated by your build process. // Their URLs are injected into the manifest variable below. // This variable must be present somewhere in your service worker file, // even if you decide not to use precaching. See https://cra.link/PWA precacheAndRoute(self.__WB_MANIFEST); // Set up App Shell-style routing, so that all navigation requests // are fulfilled with your index.html shell. Learn more at // https://developers.google.com/web/fundamentals/architecture/app-shell const fileExtensionRegexp = new RegExp("/[^/?]+\\.[^/]+$"); registerRoute( // Return false to exempt requests from being fulfilled by index.html. ({ request, url }: { request: Request; url: URL }) => { // If this isn't a navigation, skip. if (request.mode !== "navigate") { return false; } // If this is a URL that starts with /_, skip. if (url.pathname.startsWith("/_")) { return false; } // If this looks like a URL for a resource, because it contains // a file extension, skip. if (url.pathname.match(fileExtensionRegexp)) { return false; } // Return true to signal that we want to use the handler. return true; }, createHandlerBoundToURL(process.env.PUBLIC_URL + "/index.html") ); // An example runtime caching route for requests that aren't handled by the // precache, in this case same-origin .png requests like those from in public/ registerRoute( // Add in any other file extensions or routing criteria as needed. ({ url }) => url.origin === self.location.origin && url.pathname.endsWith(".png"), // Customize this strategy as needed, e.g., by changing to CacheFirst. new StaleWhileRevalidate({ cacheName: "images", plugins: [ // Ensure that once this runtime cache reaches a maximum size the // least-recently used images are removed. new ExpirationPlugin({ maxEntries: 50 }), ], }) ); // This allows the web app to trigger skipWaiting via // registration.waiting.postMessage({type: 'SKIP_WAITING'}) self.addEventListener("message", (event) => { if (event.data && event.data.type === "SKIP_WAITING") { self.skipWaiting(); } }); self.addEventListener("activate", () => { self.clients.claim(); }); self.addEventListener("install", () => { self.skipWaiting(); }); // Any other custom service worker logic can go here. ================================================ FILE: src/serviceWorkerRegistration.ts ================================================ /* eslint-disable no-eq-null */ /* eslint-disable @typescript-eslint/no-use-before-define */ // This optional code is used to register a service worker. // register() is not called by default. // This lets the app load faster on subsequent visits in production, and gives // it offline capabilities. However, it also means that developers (and users) // will only see deployed updates on subsequent visits to a page, after all the // existing tabs open on the page have been closed, since previously cached // resources are updated in the background. // To learn more about the benefits of this model and instructions on how to // opt-in, read https://cra.link/PWA const isLocalhost = Boolean( window.location.hostname === "localhost" || // [::1] is the IPv6 localhost address. window.location.hostname === "[::1]" || // 127.0.0.0/8 are considered localhost for IPv4. window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) ); type Config = { onSuccess?: (registration: ServiceWorkerRegistration) => void; onUpdate?: (registration: ServiceWorkerRegistration) => void; onControllerChange?: (container: ServiceWorkerContainer) => void; }; export function register(config?: Config) { if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { // The URL constructor is available in all browsers that support SW. const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); if (publicUrl.origin !== window.location.origin) { // Our service worker won't work if PUBLIC_URL is on a different origin // from what our page is served on. This might happen if a CDN is used to // serve assets; see https://github.com/facebook/create-react-app/issues/2374 return; } window.addEventListener("load", () => { const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; if (isLocalhost) { // This is running on localhost. Let's check if a service worker still exists or not. checkValidServiceWorker(swUrl, config); // Add some additional logging to localhost, pointing developers to the // service worker/PWA documentation. navigator.serviceWorker.ready.then(() => { console.log( "This web app is being served cache-first by a service " + "worker. To learn more, visit https://cra.link/PWA" ); }); } else { // Is not localhost. Just register service worker registerValidSW(swUrl, config); } }); } } function registerValidSW(swUrl: string, config?: Config) { navigator.serviceWorker .register(swUrl) .then((registration) => { if (navigator.serviceWorker.controller) { navigator.serviceWorker.oncontrollerchange = () => { if (config?.onControllerChange) { config.onControllerChange(navigator.serviceWorker); } }; } if (registration.waiting && registration.waiting !== registration.active) { if (config?.onUpdate) { config.onUpdate(registration); } } registration.onupdatefound = () => { const installingWorker = registration.installing; if (installingWorker == null) { return; } installingWorker.onstatechange = () => { if (installingWorker.state === "installed") { if (navigator.serviceWorker.controller) { // At this point, the updated precached content has been fetched, // but the previous service worker will still serve the older // content until all client tabs are closed. console.log( "New content is available and will be used when all " + "tabs for this page are closed. See https://cra.link/PWA." ); // Execute callback if (config && config.onUpdate) { config.onUpdate(registration); } } else { // At this point, everything has been precached. // It's the perfect time to display a // "Content is cached for offline use." message. console.log("Content is cached for offline use."); // Execute callback if (config && config.onSuccess) { config.onSuccess(registration); } } } }; }; if ( !localStorage.serviceWorkerLastManualUpdate || Date.now() - parseInt(localStorage.serviceWorkerLastManualUpdate, 10) > 1000 * 60 * 60 * 24 ) { registration.update().catch(() => {/* Ignore */}); localStorage.serviceWorkerLastManualUpdate = Date.now().toString(); } }) .catch((error) => { console.error("Error during service worker registration:", error); }); } function checkValidServiceWorker(swUrl: string, config?: Config) { // Check if the service worker can be found. If it can't reload the page. fetch(swUrl, { headers: { "Service-Worker": "script" }, }) .then((response) => { // Ensure service worker exists, and that we really are getting a JS file. const contentType = response.headers.get("content-type"); if (response.status === 404 || (contentType != null && contentType.indexOf("javascript") === -1)) { // No service worker found. Probably a different app. Reload the page. navigator.serviceWorker.ready.then((registration) => { registration.unregister().then(() => { window.location.reload(); }); }); } else { // Service worker found. Proceed as normal. registerValidSW(swUrl, config); } }) .catch(() => { console.log("No internet connection found. App is running in offline mode."); }); } export function unregister() { if ("serviceWorker" in navigator) { navigator.serviceWorker.ready .then((registration) => { registration.unregister(); }) .catch((error) => { console.error(error.message); }); } } ================================================ FILE: src/styles/styles.scss ================================================ @import "~react-virtualized/styles"; body { font-family: "Roboto", "Microsoft YaHei", "Meiryo", sans-serif; overflow-x: hidden; overflow-y: auto; padding-top: 50px; background-size: cover; background-position: center; background-repeat: no-repeat; background-image: var(--background-image); background-attachment: fixed; color-scheme: only light; } /* @media (min-width: 1025px) { */ /* background-attachment breaks on iOS: https://caniuse.com/background-attachment */ body { background: transparent; } body::before { content: ""; display: block; pointer-events: none; position: fixed; top: 0; left: 0; width: 100vw; bottom: 0; background-size: cover; background-position: center; background-repeat: no-repeat; z-index: -1; background-image: var(--background-image); } /* } */ .koromo { --background-image: url(../assets/img/koromo.jpg); } .achiga { --background-image: url(../assets/img/achiga.jpg); } .yuuki { --background-image: url(../assets/img/yuuki.jpg); } #root { overflow: hidden; } .text-right { text-align: right; } .ReactVirtualized__Table__row.even { background-color: rgba(0, 0, 0, 0.05); } .ReactVirtualized__Table__headerRow, .ReactVirtualized__Table__row { border-top: 1px solid #dee2e6; } .ReactVirtualized__Table__headerRow span { display: block; } @keyframes placeHolderShimmer { 0% { background-position: -800px 0; } 100% { background-position: 800px 0; } } .ReactVirtualized__Table__row.loading { animation-duration: 3s; animation-fill-mode: forwards; animation-iteration-count: infinite; animation-name: placeHolderShimmer; animation-timing-function: linear; background: #f6f7f8; background: linear-gradient(to right, #eeeeee 8%, #dddddd 18%, #eeeeee 33%); background-size: 800px 104px; } .ReactVirtualized__Table__row.loading:nth-of-type(odd) { animation-delay: -0.75s; } svg.recharts-surface { overflow: visible; } .recharts-label, .recharts-label-list { pointer-events: none; user-select: none; } .recharts-pie-label-text { font-weight: bold; transform: translateY(5px); } .recharts-tooltip-wrapper { z-index: 1; } .recharts-pie.selectable { transition: transform 0.3s; transform: scale(0.95); transform-origin: center; } .recharts-pie.selectable.with-active, .recharts-pie.selectable:hover { transform: scale(1); } .recharts-pie-sector .recharts-sector.selectable { cursor: pointer; transition: opacity 0.3s; } .recharts-pie.with-active .recharts-pie-sector .recharts-sector { opacity: 0.1; } .recharts-pie.with-active .recharts-pie-sector .recharts-sector.active { opacity: 1; } .recharts-pie-sector .recharts-sector.selectable:hover { opacity: 0.8; } ================================================ FILE: src/utils/async.ts ================================================ import React, { useState, useEffect, useMemo } from "react"; import { networkError } from "./notify"; import Sentry from "./sentry"; type NotFinished = { notFinished: string }; const NOT_FINISHED = { notFinished: "yes" }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const __useAsyncCache = {} as { [key: string]: any }; export function useAsync(maybePromise: T | Promise, cacheKey?: string): T | undefined { const [fulfilledValue, setFulfilledValue] = useState( maybePromise instanceof Promise ? NOT_FINISHED : maybePromise ); useEffect(() => { let promise = maybePromise; if (cacheKey) { if (__useAsyncCache[cacheKey]) { if ( promise instanceof Promise && __useAsyncCache[cacheKey] instanceof Promise && promise !== __useAsyncCache[cacheKey] ) { const e = new Error(`Replacing cached promise with new one (key: ${cacheKey})`); console.error(e); Sentry.captureException(e); } promise = __useAsyncCache[cacheKey]; } else { __useAsyncCache[cacheKey] = promise; } } let cancelled = false; if (promise instanceof Promise) { setFulfilledValue(NOT_FINISHED); promise .then((result) => { if (cancelled) { return; } if (cacheKey) { __useAsyncCache[cacheKey] = result; } setFulfilledValue(result); }) .catch((e) => { console.error(e); if (cacheKey && __useAsyncCache[cacheKey] === promise) { delete __useAsyncCache[cacheKey]; } networkError(); }); } else { setFulfilledValue(promise); } return () => { cancelled = true; }; }, [maybePromise, cacheKey]); if (fulfilledValue !== NOT_FINISHED) { return fulfilledValue as T; } return undefined; } export function useAsyncFactory( factory: () => Promise, deps: React.DependencyList, cacheKey?: string ): T | undefined { const realKey = cacheKey ? `${cacheKey}-${deps.join(",")}` : undefined; const promise = useMemo(() => { if (realKey && __useAsyncCache[realKey]) { return __useAsyncCache[realKey]; } const ret = factory(); if (realKey) { __useAsyncCache[realKey] = ret; } return ret; }, deps); // eslint-disable-line react-hooks/exhaustive-deps return useAsync(promise, realKey); } ================================================ FILE: src/utils/conf.ts ================================================ import { GameMode } from "../data/types"; import dayjs from "dayjs"; const domain = sessionStorage.getItem("overrideDomain") || localStorage.getItem("overrideDomain") || window.location.hostname; export const CONFIGURATIONS = { DEFAULT: { apiSuffix: process.env.NODE_ENV === "development" ? "api-test/v2/pl4/" : "api/v2/pl4/", features: { ranking: [GameMode.王座, GameMode.玉, GameMode.玉东] as GameMode[] | false, statistics: true, estimatedStableLevel: true, contestTools: false, statisticsSubPages: { rankBySeat: true, dataByRank: [GameMode.王座, GameMode.玉, GameMode.金, GameMode.王东, GameMode.玉东, GameMode.金东] as | GameMode[] | false, fanStats: true, numPlayerStats: true, }, aiReview: true, }, table: { showGameMode: true, }, availableModes: [GameMode.王座, GameMode.玉, GameMode.金, GameMode.王东, GameMode.玉东, GameMode.金东], modePreference: [GameMode.王座, GameMode.玉, GameMode.王东, GameMode.玉东, GameMode.金, GameMode.金东], dateMin: dayjs("2019-08-23", "YYYY-MM-DD"), siteTitle: "雀魂牌谱屋", canonicalDomain: "amae-koromo.sapk.ch", showTopNotice: true, mirrorUrl: "https://saki.sapk.ch/", rootClassName: "koromo", rankColors: ["#28a745", "#17a2b8", "#6c757d", "#dc3545"], maskedGameLink: true, }, ikeda: { apiSuffix: "api/v2/pl3/", features: { ranking: [GameMode.三王座, GameMode.三玉, GameMode.三金, GameMode.三王东, GameMode.三玉东, GameMode.三金东], statistics: true, estimatedStableLevel: true, contestTools: false, statisticsSubPages: { rankBySeat: true, dataByRank: [GameMode.三王座, GameMode.三玉, GameMode.三金, GameMode.三王东, GameMode.三玉东, GameMode.三金东], fanStats: true, numPlayerStats: true, }, aiReview: false, }, availableModes: [GameMode.三王座, GameMode.三玉, GameMode.三金, GameMode.三王东, GameMode.三玉东, GameMode.三金东], modePreference: [GameMode.三王座, GameMode.三玉, GameMode.三王东, GameMode.三玉东, GameMode.三金, GameMode.三金东], dateMin: dayjs("2019-11-29", "YYYY-MM-DD"), siteTitle: "雀魂牌谱屋·三麻", canonicalDomain: "ikeda.sapk.ch", mirrorUrl: "https://momoko.sapk.ch/", rankColors: ["#28a745", "#6c757d", "#dc3545"], rootClassName: "yuuki", }, contest: { apiSuffix: (s: string) => `api/contest/${s}/`, features: { ranking: false as const, rankingGroups: null, statistics: true, estimatedStableLevel: false, contestTools: true, statisticsSubPages: { rankBySeat: true, dataByRank: false as const, fanStats: true, numPlayerStats: false, }, aiReview: false, }, table: { showGameMode: true, }, availableModes: [], canonicalDomain: domain, showTopNotice: false, maskedGameLink: false, }, }; type Configuration = typeof CONFIGURATIONS.DEFAULT; // eslint-disable-next-line @typescript-eslint/no-explicit-any function mergeDeep(...objects: Partial[]): T { // eslint-disable-next-line @typescript-eslint/no-explicit-any const isObject = (obj: T) => obj && typeof obj === "object" && (obj as any).constructor === Object; return objects.reduce((prev: T, obj: Partial) => { Object.keys(obj).forEach((key: keyof T) => { const pVal = prev[key]; const oVal = obj[key]; if (Array.isArray(pVal) && Array.isArray(oVal)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any prev[key] = oVal as any; } else if (isObject(pVal) && isObject(oVal)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any prev[key] = mergeDeep(pVal, oVal as any); } else { // eslint-disable-next-line @typescript-eslint/no-explicit-any prev[key] = oVal as any; } }); return prev; }, {} as T) as T; } const ConfBase: Partial = (() => { if (/^(ikeda|momoko)\./i.test(domain)) { return CONFIGURATIONS.ikeda; } const m = /^([^.]+)\.contest\./i.exec(domain); if (m) { return { ...CONFIGURATIONS.contest, apiSuffix: CONFIGURATIONS.contest.apiSuffix(m[1]) }; } return CONFIGURATIONS.DEFAULT; })(); const Conf = mergeDeep(CONFIGURATIONS.DEFAULT, ConfBase); document.documentElement.className += " " + Conf.rootClassName; export function canTrackUser() { return window.location.host === Conf.canonicalDomain; } export default Conf; ================================================ FILE: src/utils/index.ts ================================================ import { useTheme } from "@mui/material"; import useMediaQuery from "@mui/material/useMediaQuery"; import React, { useEffect, useRef, useCallback } from "react"; export function triggerRelayout() { requestAnimationFrame(() => window.dispatchEvent(new UIEvent("resize"))); setTimeout(function () { window.dispatchEvent(new UIEvent("resize")); }, 200); } export function scrollToTop() { window.scrollTo(0, 0); requestAnimationFrame(() => window.scrollTo(0, 0)); } // eslint-disable-next-line @typescript-eslint/no-explicit-any export const formatPercent = (x: any) => { if (!x) { return "0%"; } if (x < 0.0001) { return "<0.01%"; } return `${(x * 100).toFixed(2)}%`; }; export const formatFixed3 = (x: number) => x.toFixed(3); export const formatRound = (x: number) => Math.round(x).toString(); export const formatIdentity = (x: number) => x.toString(); export function useEventCallback(fn: (...args: T) => void, dependencies: React.DependencyList) { const ref = useRef<(...args: T) => void>(() => { throw new Error("Cannot call an event handler while rendering."); }); useEffect(() => { ref.current = fn; // eslint-disable-next-line react-hooks/exhaustive-deps }, [fn, ...dependencies]); return useCallback( (...args) => { const fn = ref.current; return fn(...(args as T)); }, [ref] ); } export function sum(numbers: number[]): number { return numbers.reduce((a, b) => a + b, 0); } export function useIsMobile() { const theme = useTheme(); const matches = useMediaQuery(theme.breakpoints.up("sm")); return !matches; } ================================================ FILE: src/utils/notify.tsx ================================================ /* eslint-disable @typescript-eslint/no-empty-function */ import { Close } from "@mui/icons-material"; import { IconButton } from "@mui/material"; import { useSnackbar, SnackbarMessage, OptionsObject, SnackbarKey } from "notistack"; import { useEffect } from "react"; import i18n from "../i18n"; let _enqueueSnackbar: (message: SnackbarMessage, options?: OptionsObject) => SnackbarKey = () => ""; let _closeSnackbar: (key: SnackbarKey) => void = () => {}; export function RegisterSnackbarProvider() { const { enqueueSnackbar, closeSnackbar } = useSnackbar(); useEffect(() => { _enqueueSnackbar = enqueueSnackbar; _closeSnackbar = closeSnackbar; return () => { _enqueueSnackbar = () => ""; _closeSnackbar = () => {}; }; }, [enqueueSnackbar, closeSnackbar]); return <>; } export function error(message: string, options: Partial = {}) { return _enqueueSnackbar(message, { variant: "error", action: (key) => ( { _closeSnackbar(key); }} > ), ...options, }); } let networkErrorActive = "" as SnackbarKey; export function networkError() { if (networkErrorActive) { return networkErrorActive; } networkErrorActive = error(i18n.t("加载数据失败"), { onClose: () => (networkErrorActive = "") }); return networkErrorActive; } ================================================ FILE: src/utils/polyfill.ts ================================================ require("react-app-polyfill/ie9"); require("react-app-polyfill/stable"); export default {}; ================================================ FILE: src/utils/preference.ts ================================================ import Conf from "./conf"; export function savePlayerPreference(key: string, id: string, value: unknown) { try { localStorage.setItem(`${key}${Conf.canonicalDomain}${id}`, JSON.stringify(value)); } catch (e) { // Incognito mode, ignore } } export function loadPlayerPreference(key: string, id: string, defaultValue: T): T { try { return JSON.parse(localStorage.getItem(`${key}${Conf.canonicalDomain}${id}`) || "") ?? defaultValue; } catch (e) { return defaultValue; } } export function loadPreference(key: string, defaultValue: T): T { return loadPlayerPreference(key, "GLOBAL", defaultValue); } export function savePreference(key: string, value: unknown) { savePlayerPreference(key, "GLOBAL", value); } ================================================ FILE: src/utils/sentry.ts ================================================ import * as Sentry from "@sentry/react"; import { v4 as uuidv4 } from "uuid"; if (process.env.REACT_APP_SENTRY_DSN) { const ignoredFunctions = new Set([ "is_mark_able_element", "findParentClickTag", "close_cache_key", "check_swipe_element", "eval", ]); Sentry.init({ dsn: process.env.REACT_APP_SENTRY_DSN, release: process.env.REACT_APP_RELEASE || "unknown", ignoreErrors: [ "this.hostIndex.push is not a function", "undefined is not an object (evaluating 't.uv')", "SyntaxError: The string did not match the expected pattern.", "instantSearchSDKJSBridgeClearHighlight", "window.bannerNight", "window.ucbrowser", "webkitExitFullScreen", "close_cache_key", "UCShellJava", "file:///", "hw-upgrade-client", "is_mark_able_element", "QK_middlewareReadModePageDetect", "window.webkit.messageHandlers", "Timeout to initialize runtime", "this.excludedTags.length", ], denyUrls: [/^chrome-extension:\/\//i, /^moz-extension:\/\//i, /^safari-extension:\/\//i, /^file:\/\//i], autoSessionTracking: true, beforeSend: (event, hint) => { if (event?.exception?.values?.[0]?.stacktrace?.frames?.some((x) => ignoredFunctions.has(x?.function || ""))) { return null; } if ( hint?.originalException && typeof hint.originalException !== "string" && /Loading chunk \d+ failed after \d+ retries/.test(hint.originalException.message) ) { event.fingerprint = ["ChunkLoadError"]; } return event; }, }); let sentryUserId; try { sentryUserId = localStorage.getItem("sentryUserId") || sessionStorage.getItem("sentryUserId"); if (!sentryUserId) { sentryUserId = uuidv4(); sessionStorage.setItem("sentryUserId", sentryUserId); localStorage.setItem("sentryUserId", sentryUserId); } } catch (e) { // Ignore } if (sentryUserId) { Sentry.setUser({ id: sentryUserId }); } } export const SentryErrorBoundary = Sentry.ErrorBoundary; export default Sentry; ================================================ FILE: tsconfig.json ================================================ { "include": ["./src/**/*"], "compilerOptions": { "allowSyntheticDefaultImports": true, "useUnknownInCatchVariables": false, "moduleResolution": "node", "target": "es2017", "strict": true, "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "module": "esnext", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "lib": ["dom", "es2015", "es2017", "DOM", "webworker"], "noFallthroughCasesInSwitch": true } } ================================================ FILE: vercel.json ================================================ { "version": 2, "builds": [{ "src": "package.json", "use": "@vercel/static-build", "config": { "distDir": "build" } }], "routes": [ { "src": "/_.*", "status": 404 }, { "src": "/(static|favicon2)/.*", "headers": { "Cache-Control": "public, immutable, max-age=604800, s-maxage=604800" }, "continue": true }, { "src": "/(.*)", "headers": { "Content-Security-Policy-Report-Only": "default-src * data:; script-src 'report-sample' 'self' https://*.sapk.ch https://www.google-analytics.com https://www.googletagmanager.com https://*.statuspage.io; script-src-elem 'report-sample' 'self' https://*.sapk.ch https://www.google-analytics.com https://www.googletagmanager.com https://*.statuspage.io; style-src * 'unsafe-inline' 'report-sample'; style-src-elem * 'unsafe-inline' 'report-sample'; style-src-attr * 'unsafe-inline' 'report-sample'; report-uri https://sentry.sapikachu.net/api/31/security/?sentry_key=876acfa224b8425c92f9553b9c6676be" }, "continue": true }, { "handle": "filesystem" }, { "src": "/(static|favicon2)/.*", "status": 404 }, { "src": "/(.*)", "dest": "/index.html" } ] }