forked from sk1982/actaeon
chuni: add score calculator
This commit is contained in:
parent
71c0450ea3
commit
3a8595a1d4
32
.vscode/launch.json
vendored
Normal file
32
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/node_modules/.bin/next",
|
||||
"runtimeArgs": ["--inspect"],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"serverReadyAction": {
|
||||
"action": "debugWithEdge",
|
||||
"killOnServerStop": true,
|
||||
"pattern": "- Local:.+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@ -2,7 +2,7 @@
|
||||
|
||||
import { getUser, requireUser } from '@/actions/auth';
|
||||
import { db } from '@/db';
|
||||
import { chuniRating } from '@/helpers/chuni/rating';
|
||||
import { sqlChuniRating } from '@/helpers/chuni/rating';
|
||||
import { CHUNI_MUSIC_PROPERTIES } from '@/helpers/chuni/music';
|
||||
import { UserPayload } from '@/types/user';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
@ -29,7 +29,7 @@ export const getMusic = async (musicId?: number) => {
|
||||
'score.isFullCombo', 'score.isAllJustice', 'score.isSuccess', 'score.scoreRank', 'score.scoreMax',
|
||||
'score.maxComboCount',
|
||||
fn<boolean>('NOT ISNULL', ['favorite.favId']).as('favorite'),
|
||||
chuniRating()] as const)
|
||||
sqlChuniRating()] as const)
|
||||
.where(({ selectFrom, eb, and, or }) => and([
|
||||
eb('music.version', '=', selectFrom('chuni_static_music')
|
||||
.select(({ fn }) => fn.max('version').as('latest'))),
|
||||
|
@ -4,7 +4,7 @@ import { requireUser } from '@/actions/auth';
|
||||
import { PlaylogFilterState } from '@/app/(with-header)/chuni/playlog/page';
|
||||
import { db } from '@/db';
|
||||
import { CHUNI_MUSIC_PROPERTIES } from '@/helpers/chuni/music';
|
||||
import { chuniRating } from '@/helpers/chuni/rating';
|
||||
import { sqlChuniRating } from '@/helpers/chuni/rating';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
const SORT_KEYS = {
|
||||
@ -52,7 +52,7 @@ export async function getPlaylog(opts: GetPlaylogOptions) {
|
||||
'playlog.isNewRecord', 'playlog.isFullCombo', 'playlog.fullChainKind', 'playlog.isAllJustice',
|
||||
'playlog.playKind', 'playlog.isClear', 'playlog.placeName',
|
||||
...CHUNI_MUSIC_PROPERTIES,
|
||||
chuniRating(ref('playlog.score')),
|
||||
sqlChuniRating(ref('playlog.score')),
|
||||
sql<number>`(playlog.playerRating - (LEAD(playlog.playerRating) OVER (ORDER BY id DESC)))`
|
||||
.as('playerRatingChange')
|
||||
] as const)
|
||||
|
@ -3,7 +3,7 @@
|
||||
import { sql } from 'kysely';
|
||||
|
||||
import { db, GeneratedDB } from '@/db';
|
||||
import { chuniRating } from '@/helpers/chuni/rating';
|
||||
import { sqlChuniRating } from '@/helpers/chuni/rating';
|
||||
import { CHUNI_MUSIC_PROPERTIES } from '@/helpers/chuni/music';
|
||||
import { UserPayload } from '@/types/user';
|
||||
import { ItemKind } from '@/helpers/chuni/items';
|
||||
@ -96,7 +96,7 @@ export async function getUserRating(user: UserPayload) {
|
||||
.innerJoin('actaeon_chuni_static_music_ext as musicExt', join => join
|
||||
.onRef('music.songId', '=', 'musicExt.songId')
|
||||
.onRef('music.chartId', '=', 'musicExt.chartId'))
|
||||
.select(({ lit }) => [...CHUNI_MUSIC_PROPERTIES, chuniRating(sql.raw(`CAST(score.scoreMax AS INT)`)),
|
||||
.select(({ lit }) => [...CHUNI_MUSIC_PROPERTIES, sqlChuniRating(sql.raw(`CAST(score.scoreMax AS INT)`)),
|
||||
sql<string>`CAST(score.scoreMax AS INT)`.as('scoreMax'),
|
||||
lit<number>(1).as('pastIndex')
|
||||
])
|
||||
@ -119,7 +119,7 @@ export async function getUserRating(user: UserPayload) {
|
||||
]))
|
||||
.select([
|
||||
...CHUNI_MUSIC_PROPERTIES, 'score.scoreMax',
|
||||
chuniRating()
|
||||
sqlChuniRating()
|
||||
])
|
||||
.orderBy('rating desc')
|
||||
.limit(30)
|
||||
@ -131,6 +131,8 @@ export async function getUserRating(user: UserPayload) {
|
||||
return { recent, top };
|
||||
}
|
||||
|
||||
export type ChuniUserRating = Awaited<ReturnType<typeof getUserRating>>;
|
||||
|
||||
const validators = new Map<keyof GeneratedDB['chuni_profile_data'], (user: UserPayload, profile: NonNullable<ChuniUserData>, value: any) => Promise<any>>();
|
||||
|
||||
const itemValidators = [
|
||||
|
@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { ChuniTopRating } from './top-rating';
|
||||
import { getUserRating } from '@/actions/chuni/profile';
|
||||
import { ChuniUserRating } from '@/actions/chuni/profile';
|
||||
import { useState } from 'react';
|
||||
import { ButtonGroup, Button } from '@nextui-org/button';
|
||||
import { useBreakpoint } from '@/helpers/use-breakpoint';
|
||||
import { BigDecimal } from '@/helpers/big-decimal';
|
||||
import { ChuniRating } from '@/components/chuni/rating';
|
||||
|
||||
export const ChuniTopRatingSidebar = ({ rating }: { rating: Awaited<ReturnType<typeof getUserRating>> }) => {
|
||||
export const ChuniTopRatingSidebar = ({ rating }: { rating: ChuniUserRating }) => {
|
||||
const [shownRating, setShownRating] = useState<'top' | 'recent' | null>('recent');
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
|
@ -12,13 +12,15 @@ import { HeartIcon as OutlineHeartIcon } from '@heroicons/react/24/outline';
|
||||
import { useState } from 'react';
|
||||
import { useErrorModal } from '@/components/error-modal';
|
||||
import { useUser } from '@/helpers/use-user';
|
||||
import { ChuniUserRating } from '@/actions/chuni/profile';
|
||||
|
||||
type ChuniMusicDetailProps = {
|
||||
music: ChuniMusic[],
|
||||
playlog: ChuniPlaylog
|
||||
playlog: ChuniPlaylog,
|
||||
rating: ChuniUserRating
|
||||
};
|
||||
|
||||
export const ChuniMusicDetail = ({ music, playlog }: ChuniMusicDetailProps) => {
|
||||
export const ChuniMusicDetail = ({ music, playlog, rating }: ChuniMusicDetailProps) => {
|
||||
const cueId = music[0].jacketPath?.match(/UI_Jacket_(\d+)/)?.[1];
|
||||
const [favorite, setFavorite] = useState(music[0].favorite);
|
||||
const [pendingFavorite, setPendingFavorite] = useState(false);
|
||||
@ -48,6 +50,6 @@ export const ChuniMusicDetail = ({ music, playlog }: ChuniMusicDetailProps) => {
|
||||
{favorite ? <SolidHeartIcon className="w-3/4" /> : <OutlineHeartIcon className="w-3/4" />}
|
||||
</Button>}
|
||||
</MusicPlayer>
|
||||
<ChuniMusicPlaylog music={music} playlog={playlog} />
|
||||
<ChuniMusicPlaylog music={music} playlog={playlog} rating={rating} />
|
||||
</div>);
|
||||
};
|
||||
|
@ -6,21 +6,27 @@ import { Accordion, AccordionItem } from '@nextui-org/accordion';
|
||||
import { CHUNI_DIFFICULTIES } from '@/helpers/chuni/difficulties';
|
||||
import { ChuniLevelBadge } from '@/components/chuni/level-badge';
|
||||
import { ChuniRating } from '@/components/chuni/rating';
|
||||
import { ChuniLampComboBadge, ChuniLampSuccessBadge, ChuniScoreBadge, getVariantFromRank } from '@/components/chuni/score-badge';
|
||||
import { CHUNI_SCORE_RANKS } from '@/helpers/chuni/score-ranks';
|
||||
import { ChuniLampComboBadge, ChuniLampSuccessBadge, ChuniScoreBadge, ChuniScoreRankBadge, getVariantFromRank } from '@/components/chuni/score-badge';
|
||||
import { ChuniPlaylogCard } from '@/components/chuni/playlog-card';
|
||||
import { useState } from 'react';
|
||||
import { CalculatorIcon } from '@heroicons/react/24/outline';
|
||||
import { Tooltip } from '@nextui-org/tooltip';
|
||||
import { ChuniScoreCalculator, ChuniScoreCalculatorProps } from '@/components/chuni/score-calculator';
|
||||
import { ChuniUserRating } from '@/actions/chuni/profile';
|
||||
import { Spacer } from '@nextui-org/spacer';
|
||||
|
||||
type ChuniMusicPlaylogProps = {
|
||||
music: ChuniMusic[],
|
||||
playlog: ChuniPlaylog
|
||||
playlog: ChuniPlaylog,
|
||||
rating: ChuniUserRating
|
||||
};
|
||||
|
||||
export const ChuniMusicPlaylog = ({ music, playlog }: ChuniMusicPlaylogProps) => {
|
||||
export const ChuniMusicPlaylog = ({ music, playlog, rating }: ChuniMusicPlaylogProps) => {
|
||||
type Music = (typeof music)[number];
|
||||
type Playlog = (typeof playlog)['data'][number];
|
||||
|
||||
const [selected, setSelected] = useState(new Set<string>());
|
||||
const [scoreCalculator, setScoreCalculator] = useState<ChuniScoreCalculatorProps['music']>(null);
|
||||
|
||||
const difficulties: (Music & { playlog: Playlog[] })[] = [];
|
||||
music.forEach(m => {
|
||||
@ -34,27 +40,19 @@ export const ChuniMusicPlaylog = ({ music, playlog }: ChuniMusicPlaylogProps) =>
|
||||
const badgeClass = 'h-6 sm:h-8';
|
||||
|
||||
return (<div className="flex flex-col w-full px-1 sm:px-0">
|
||||
<ChuniScoreCalculator music={scoreCalculator} topRating={rating} onClose={() => setScoreCalculator(null)} />
|
||||
<Accordion selectionMode="multiple" selectedKeys={selected}>
|
||||
{difficulties.map((data, i) => {
|
||||
const rank = CHUNI_SCORE_RANKS[data.scoreRank!];
|
||||
const badges = [
|
||||
!!data.scoreRank && <ChuniScoreBadge variant={getVariantFromRank(data.scoreRank)} className={`${badgeClass} tracking-[0.05cqw]`} key="1">
|
||||
{rank.endsWith('+') ? <>
|
||||
{rank.slice(0, -1)}
|
||||
<div className="inline-block translate-y-[-15cqh]">+</div>
|
||||
</> : rank}
|
||||
</ChuniScoreBadge>,
|
||||
data.scoreRank !== null && <ChuniScoreRankBadge key="1" rank={data.scoreRank} className={badgeClass} />,
|
||||
data.isSuccess ? <ChuniLampSuccessBadge key="2" className={badgeClass} success={data.isSuccess} /> : null,
|
||||
(data.isFullCombo || data.isAllJustice) && <ChuniLampComboBadge key="3" className={badgeClass} {...data} />
|
||||
].filter(x => x);
|
||||
|
||||
|
||||
// <div key={i} className="mb-2 border-b pb-2 border-gray-500 flex flex-row flex-wrap items-center">
|
||||
return (<AccordionItem key={i.toString()} classNames={{ trigger: 'py-0 my-2' }} title={<div className="flex flex-row flex-wrap items-center gap-y-1.5"
|
||||
onClick={() => {
|
||||
return (<AccordionItem key={i.toString()} classNames={{ trigger: 'py-0 my-2' }} onClick={() => {
|
||||
const key = i.toString();
|
||||
setSelected(s => s.has(key) ? new Set([...s].filter(k => k !== key)) : new Set([...s, key]))
|
||||
}}>
|
||||
}} title={<div className="flex flex-row flex-wrap items-center gap-y-1.5 -mr-3">
|
||||
<div className={`flex items-center gap-2 flex-wrap lg:flex-grow ${data.playlog.length ? 'cursor-pointer w-full lg:w-auto' : 'flex-grow'}`}>
|
||||
<div className="flex items-center">
|
||||
<div className="w-14 mr-2 p-0.5 bg-black">
|
||||
@ -63,7 +61,7 @@ export const ChuniMusicPlaylog = ({ music, playlog }: ChuniMusicPlaylogProps) =>
|
||||
<div className="text-xl font-semibold">{CHUNI_DIFFICULTIES[i]}</div>
|
||||
</div>
|
||||
{!data.playlog.length && <div className="text-right italic text-gray-500 flex-grow">No Play History</div>}
|
||||
{data.rating ? <ChuniRating className="text-2xl text-right" rating={+data.rating * 100} /> : null}
|
||||
{data.rating && data.chartId !== 5 ? <ChuniRating className="text-2xl text-right" rating={+data.rating * 100} /> : null}
|
||||
{data.scoreMax ? <div className="ml-2 text-center flex-grow sm:flex-grow-0 max-sm:text-sm">
|
||||
<span className="font-semibold">High Score: </span>{data.scoreMax.toLocaleString()}
|
||||
</div> : null}
|
||||
@ -74,6 +72,16 @@ export const ChuniMusicPlaylog = ({ music, playlog }: ChuniMusicPlaylogProps) =>
|
||||
{badges.length ? <div className={`flex-grow items-center lg:flex-grow-0 ml-auto mr-auto sm:ml-0 lg:ml-auto lg:mr-0 flex gap-0.5 flex-wrap justify-center sm:justify-start ${data.playlog.length ? 'cursor-pointer' : ''}`}>
|
||||
{badges}
|
||||
</div> : null}
|
||||
{data.chartId !== 5 ? <Tooltip content="Score calculator">
|
||||
<div className="ml-2 text-gray-400 rounded-full w-10 h-10 flex items-center justify-center transition hover:bg-default"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setScoreCalculator(data);
|
||||
}}>
|
||||
<CalculatorIcon className="h-3/4" />
|
||||
</div>
|
||||
</Tooltip> : <Spacer />}
|
||||
|
||||
</div>}>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2 mb-3 justify-center sm:justify-end max-sm:text-xs">
|
||||
<span className="mr-auto max-sm:w-full text-center"><span className="font-semibold">Chart designer:</span> {data.chartDesigner}</span>
|
||||
|
@ -3,20 +3,22 @@ import { notFound } from 'next/navigation';
|
||||
import { getPlaylog } from '@/actions/chuni/playlog';
|
||||
|
||||
import { ChuniMusicDetail } from './music-detail';
|
||||
import { getUserRating } from '@/actions/chuni/profile';
|
||||
import { requireUser } from '@/actions/auth';
|
||||
|
||||
export default async function ChuniMusicDetailPage({ params }: { params: { musicId: string } }) {
|
||||
const musicId = parseInt(params.musicId);
|
||||
if (Number.isNaN(musicId))
|
||||
return notFound();
|
||||
|
||||
const [music, playlog] = await Promise.all([
|
||||
const [music, playlog, rating] = await Promise.all([
|
||||
getMusic(musicId).then(d => structuredClone(d)),
|
||||
getPlaylog({ musicId, limit: 500 })
|
||||
getPlaylog({ musicId, limit: 500 }),
|
||||
getUserRating(await requireUser()).then(d => structuredClone(d))
|
||||
]);
|
||||
|
||||
|
||||
if (!music.length)
|
||||
return notFound();
|
||||
|
||||
return (<ChuniMusicDetail music={music} playlog={playlog} />)
|
||||
return (<ChuniMusicDetail music={music} playlog={playlog} rating={rating} />)
|
||||
}
|
||||
|
@ -11,16 +11,20 @@ import { ChuniScoreBadge, ChuniLampSuccessBadge, getVariantFromRank, ChuniLampCo
|
||||
import { ChuniRating } from '@/components/chuni/rating';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { HeartIcon as OutlineHeartIcon, Squares2X2Icon } from '@heroicons/react/24/outline';
|
||||
import { CalculatorIcon, HeartIcon as OutlineHeartIcon, Squares2X2Icon } from '@heroicons/react/24/outline';
|
||||
import { HeartIcon as SolidHeartIcon } from '@heroicons/react/24/solid';
|
||||
import { Ticker, TickerHoverProvider } from '@/components/ticker';
|
||||
import { useErrorModal } from '@/components/error-modal';
|
||||
import { CHUNI_FILTER_DIFFICULTY, CHUNI_FILTER_FAVORITE, CHUNI_FILTER_GENRE, CHUNI_FILTER_LAMP, CHUNI_FILTER_LEVEL, CHUNI_FILTER_RATING, CHUNI_FILTER_SCORE, CHUNI_FILTER_WORLDS_END_STARS, CHUNI_FILTER_WORLDS_END_TAG } from '@/helpers/chuni/filter';
|
||||
import { WindowScrollerGrid } from '@/components/window-scroller-grid';
|
||||
import { useUser } from '@/helpers/use-user';
|
||||
import { Tooltip } from '@nextui-org/tooltip';
|
||||
import { ChuniScoreCalculator } from '@/components/chuni/score-calculator';
|
||||
import { ChuniUserRating } from '@/actions/chuni/profile';
|
||||
|
||||
export type ChuniMusicListProps = {
|
||||
music: ChuniMusic[]
|
||||
music: ChuniMusic[],
|
||||
topRating?: ChuniUserRating
|
||||
};
|
||||
|
||||
const perPage = [25, 50, 100, 250, 500, Infinity];
|
||||
@ -48,7 +52,7 @@ const searcher = (query: string, data: ChuniMusicListProps['music'][number]) =>
|
||||
return data.title?.toLowerCase().includes(query) || data.artist?.toLowerCase().includes(query);
|
||||
};
|
||||
|
||||
const MusicGrid = ({ music, size, setMusicList, fullMusicList }: ChuniMusicListProps & {
|
||||
const MusicGrid = ({ music, size, setMusicList, fullMusicList, topRating }: ChuniMusicListProps & {
|
||||
size: 'sm' | 'lg' | 'xs',
|
||||
setMusicList: (m: typeof music) => void,
|
||||
fullMusicList: ChuniMusicListProps['music']
|
||||
@ -75,77 +79,88 @@ const MusicGrid = ({ music, size, setMusicList, fullMusicList }: ChuniMusicListP
|
||||
|
||||
const setError = useErrorModal();
|
||||
const [pendingFavorite, setPendingFavorite] = useState(false);
|
||||
const [scoreCalculator, setScoreCalculator] = useState<ChuniMusic | null>(null);
|
||||
|
||||
return (<WindowScrollerGrid rowSize={itemHeight} colSize={itemWidth} items={music}>
|
||||
{item => <TickerHoverProvider>
|
||||
{setHover => <div className={itemClass}><ChuniDifficultyContainer difficulty={item.chartId!}
|
||||
containerClassName="flex flex-col"
|
||||
className="w-full h-full border border-gray-500/75 rounded-md"
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}>
|
||||
<div className="aspect-square w-full p-[0.2rem] relative">
|
||||
<Link href={`/chuni/music/${item.songId}`}>
|
||||
<Image width={100} height={100}
|
||||
src={getJacketUrl(`chuni/jacket/${item.jacketPath}`)}
|
||||
alt={item.title ?? 'Music'} className="rounded w-full h-full" />
|
||||
</Link>
|
||||
{item.rating && !item.worldsEndTag && <div className={`${size === 'lg' ? 'text-2xl' : ''} absolute bottom-0.5 left-0.5 bg-gray-200/60 backdrop-blur-sm moz-no-backdrop-blur px-0.5 rounded`}>
|
||||
<ChuniRating rating={+item.rating * 100} className="-my-0.5">
|
||||
return (<>
|
||||
<ChuniScoreCalculator music={scoreCalculator} onClose={() => setScoreCalculator(null)} topRating={topRating} />
|
||||
<WindowScrollerGrid rowSize={itemHeight} colSize={itemWidth} items={music}>
|
||||
{item => <TickerHoverProvider>
|
||||
{setHover => <div className={itemClass}><ChuniDifficultyContainer difficulty={item.chartId!}
|
||||
containerClassName="flex flex-col"
|
||||
className="w-full h-full border border-gray-500/75 rounded-md"
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}>
|
||||
<div className="aspect-square w-full p-[0.2rem] relative">
|
||||
<Link href={`/chuni/music/${item.songId}`}>
|
||||
<Image width={100} height={100}
|
||||
src={getJacketUrl(`chuni/jacket/${item.jacketPath}`)}
|
||||
alt={item.title ?? 'Music'} className="rounded w-full h-full" />
|
||||
</Link>
|
||||
{item.rating && !item.worldsEndTag && <div className={`${size === 'lg' ? 'text-2xl' : ''} absolute bottom-0.5 left-0.5 bg-gray-200/60 backdrop-blur-sm moz-no-backdrop-blur px-0.5 rounded`}>
|
||||
<ChuniRating rating={+item.rating * 100} className="-my-0.5">
|
||||
{item.rating.slice(0, item.rating.indexOf('.') + 3)}
|
||||
</ChuniRating>
|
||||
</div>}
|
||||
<ChuniLevelBadge className={`${size === 'lg' ? 'h-14' : 'w-14'} absolute bottom-px right-px`} music={item} />
|
||||
</ChuniRating>
|
||||
</div>}
|
||||
<ChuniLevelBadge className={`${size === 'lg' ? 'h-14' : 'w-14'} absolute bottom-px right-px`} music={item} />
|
||||
|
||||
{!!user?.chuni && <Button isIconOnly className={`absolute top-0 left-0 pt-1 bg-gray-600/25 ${item.favorite ? 'text-red-500' : ''}`}
|
||||
size={size === 'xs' ? 'sm' : 'md'} variant="flat" radius="full"
|
||||
onPress={() => {
|
||||
if (pendingFavorite) return;
|
||||
const favorite = item.favorite;
|
||||
setMusicList(fullMusicList.map(m => {
|
||||
if (m.songId !== item.songId)
|
||||
return m;
|
||||
return { ...m, favorite: !favorite };
|
||||
}));
|
||||
setPendingFavorite(true);
|
||||
(item.favorite ? removeFavoriteMusic : addFavoriteMusic)(item.songId!)
|
||||
.then(res => {
|
||||
if (res?.error) {
|
||||
setMusicList(fullMusicList.map(m => {
|
||||
if (m.songId !== item.songId)
|
||||
return m;
|
||||
return { ...m, favorite };
|
||||
}));
|
||||
return setError(`Failed to set favorite: ${res.message}`);
|
||||
}
|
||||
})
|
||||
.finally(() => setPendingFavorite(false));
|
||||
}}>
|
||||
{item.favorite ? <SolidHeartIcon className="w-3/4" /> : <OutlineHeartIcon className="w-3/4" />}
|
||||
</Button>}
|
||||
</div>
|
||||
<div className="px-0.5 mb-1 flex">
|
||||
{size === 'lg' && <div className="h-full w-1/3 mr-0.5">
|
||||
{item.isSuccess ? <ChuniLampSuccessBadge success={item.isSuccess} /> : null}
|
||||
</div>}
|
||||
<Tooltip content="Score Calculator">
|
||||
<Button isIconOnly className="absolute top-0 right-0 pt-0.5 bg-gray-600/25" size={size === 'xs' ? 'sm' : 'md'} variant="flat" radius="full"
|
||||
onPress={() => setScoreCalculator(item)}>
|
||||
<CalculatorIcon className="w-3/4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<div className={`h-full ${size === 'lg' ? 'w-1/3' : 'w-1/2'}`}>
|
||||
{item.scoreRank !== null && <ChuniScoreBadge variant={getVariantFromRank(item.scoreRank)} className="h-full">
|
||||
{item.scoreMax!.toLocaleString()}
|
||||
</ChuniScoreBadge>}
|
||||
{!!user?.chuni && <Button isIconOnly className={`absolute top-0 left-0 pt-1 bg-gray-600/25 ${item.favorite ? 'text-red-500' : ''}`}
|
||||
size={size === 'xs' ? 'sm' : 'md'} variant="flat" radius="full"
|
||||
onPress={() => {
|
||||
if (pendingFavorite) return;
|
||||
const favorite = item.favorite;
|
||||
setMusicList(fullMusicList.map(m => {
|
||||
if (m.songId !== item.songId)
|
||||
return m;
|
||||
return { ...m, favorite: !favorite };
|
||||
}));
|
||||
setPendingFavorite(true);
|
||||
(item.favorite ? removeFavoriteMusic : addFavoriteMusic)(item.songId!)
|
||||
.then(res => {
|
||||
if (res?.error) {
|
||||
setMusicList(fullMusicList.map(m => {
|
||||
if (m.songId !== item.songId)
|
||||
return m;
|
||||
return { ...m, favorite };
|
||||
}));
|
||||
return setError(`Failed to set favorite: ${res.message}`);
|
||||
}
|
||||
})
|
||||
.finally(() => setPendingFavorite(false));
|
||||
}}>
|
||||
{item.favorite ? <SolidHeartIcon className="w-3/4" /> : <OutlineHeartIcon className="w-3/4" />}
|
||||
</Button>}
|
||||
</div>
|
||||
<div className="px-0.5 mb-1 flex">
|
||||
{size === 'lg' && <div className="h-full w-1/3 mr-0.5">
|
||||
{item.isSuccess ? <ChuniLampSuccessBadge success={item.isSuccess} /> : null}
|
||||
</div>}
|
||||
|
||||
<div className={`h-full ml-0.5 ${size === 'lg' ? 'w-1/3' : 'w-1/2'}`}>
|
||||
<ChuniLampComboBadge {...item} />
|
||||
<div className={`h-full ${size === 'lg' ? 'w-1/3' : 'w-1/2'}`}>
|
||||
{item.scoreRank !== null && <ChuniScoreBadge variant={getVariantFromRank(item.scoreRank)} className="h-full">
|
||||
{item.scoreMax!.toLocaleString()}
|
||||
</ChuniScoreBadge>}
|
||||
</div>
|
||||
|
||||
<div className={`h-full ml-0.5 ${size === 'lg' ? 'w-1/3' : 'w-1/2'}`}>
|
||||
<ChuniLampComboBadge {...item} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/chuni/music/${item.songId}`}
|
||||
className={`${size === 'lg' ? 'text-lg' : 'text-xs'} mt-auto px-1 block text-white hover:text-gray-200 transition text-center font-semibold drop-shadow-lg`}>
|
||||
<Ticker hoverOnly noDelay>{item.title}</Ticker>
|
||||
</Link>
|
||||
<Ticker className={`${size === 'lg' ? 'text-medium mb-1.5' : 'text-xs mb-0.5' } text-center px-1 drop-shadow-2xl text-white`} hoverOnly noDelay>{item.artist}</Ticker>
|
||||
</ChuniDifficultyContainer></div>}
|
||||
</TickerHoverProvider>}
|
||||
</WindowScrollerGrid>);
|
||||
<Link href={`/chuni/music/${item.songId}`}
|
||||
className={`${size === 'lg' ? 'text-lg' : 'text-xs'} mt-auto px-1 block text-white hover:text-gray-200 transition text-center font-semibold drop-shadow-lg`}>
|
||||
<Ticker hoverOnly noDelay>{item.title}</Ticker>
|
||||
</Link>
|
||||
<Ticker className={`${size === 'lg' ? 'text-medium mb-1.5' : 'text-xs mb-0.5'} text-center px-1 drop-shadow-2xl text-white`} hoverOnly noDelay>{item.artist}</Ticker>
|
||||
</ChuniDifficultyContainer></div>}
|
||||
</TickerHoverProvider>}
|
||||
</WindowScrollerGrid>
|
||||
</>);
|
||||
};
|
||||
|
||||
const DISPLAY_MODES = [{
|
||||
@ -177,7 +192,7 @@ const FILTERERS = [
|
||||
CHUNI_FILTER_RATING
|
||||
];
|
||||
|
||||
export const ChuniMusicList = ({ music }: ChuniMusicListProps) => {
|
||||
export const ChuniMusicList = ({ music, topRating }: ChuniMusicListProps) => {
|
||||
const [localMusic, setLocalMusic] = useState(music);
|
||||
|
||||
return (
|
||||
@ -185,7 +200,7 @@ export const ChuniMusicList = ({ music }: ChuniMusicListProps) => {
|
||||
displayModes={DISPLAY_MODES} searcher={searcher}>
|
||||
{(displayMode, data) => <div className="w-full flex-grow my-2">
|
||||
<MusicGrid music={data} size={DISPLAY_IDS[displayMode as keyof typeof DISPLAY_IDS]}
|
||||
fullMusicList={localMusic} setMusicList={setLocalMusic} />
|
||||
fullMusicList={localMusic} setMusicList={setLocalMusic} topRating={topRating} />
|
||||
</div>}
|
||||
</FilterSorter>);
|
||||
};
|
||||
|
@ -1,11 +1,18 @@
|
||||
import { getMusic } from '@/actions/chuni/music';
|
||||
import { ChuniMusicList } from './music-list';
|
||||
import { getUser } from '@/actions/auth';
|
||||
import { getUserRating } from '@/actions/chuni/profile';
|
||||
|
||||
|
||||
export default async function ChuniMusicPage() {
|
||||
const music = await getMusic();
|
||||
const [music, rating] = await Promise.all([
|
||||
getMusic(),
|
||||
getUser().then(async u => {
|
||||
if (u) return structuredClone(await getUserRating(u));
|
||||
})
|
||||
]);
|
||||
|
||||
return (
|
||||
<ChuniMusicList music={structuredClone(music)} />
|
||||
<ChuniMusicList music={structuredClone(music)} topRating={rating} />
|
||||
);
|
||||
}
|
||||
|
@ -117,7 +117,7 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
|
||||
let parent: HTMLElement | null = e.event.target as HTMLElement;
|
||||
while (parent) {
|
||||
const data = parent.dataset;
|
||||
if (data?.slot === 'thumb' || data?.dragging ||
|
||||
if (data?.slot === 'thumb' || data?.dragging || data?.draggable ||
|
||||
parent?.classList?.contains('react-draggable-dragging') ||
|
||||
parent?.classList?.contains('react-resizable-handle'))
|
||||
return;
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { CHUNI_LAMPS } from '@/helpers/chuni/lamps';
|
||||
import { CHUNI_SCORE_RANKS } from '@/helpers/chuni/score-ranks';
|
||||
|
||||
const BACKGROUNDS = [
|
||||
'bg-[linear-gradient(135deg,rgba(120,120,120,1)_30%,rgba(90,91,90,1)_50%,rgba(172,170,170,1)_50%,rgba(115,114,114,1)_63%,rgba(98,98,98,1)_80%,rgba(129,129,129,1)_100%)]',
|
||||
@ -78,8 +79,10 @@ export const ChuniScoreBadge = ({ children, variant, className, fontSize }: Chun
|
||||
export const ChuniLampSuccessBadge = ({ success, className }: { className?: string, success: number }) => {
|
||||
const text = CHUNI_LAMPS.get(success)?.toUpperCase();
|
||||
const fontSize = text?.length! > 5 ? text?.length! > 10 ? 'xs' : 'sm' : 'md';
|
||||
return (<ChuniScoreBadge variant={getVariantFromLamp(success)} className={`${className ?? ''} ${fontSize === 'md' ? 'tracking-[0.1cqw]' : 'tracking-[0.025cqw]'}`} fontSize={fontSize}>
|
||||
{text}
|
||||
return (<ChuniScoreBadge variant={getVariantFromLamp(success)} className={className} fontSize={fontSize}>
|
||||
<span className={fontSize === 'md' ? 'tracking-[1.5cqw]' : 'tracking-[0.75cqw]'}>
|
||||
{text}
|
||||
</span>
|
||||
</ChuniScoreBadge>)
|
||||
}
|
||||
|
||||
@ -89,3 +92,15 @@ export const ChuniLampComboBadge = ({ className, isFullCombo, isAllJustice }: {
|
||||
{isAllJustice ? 'ALL JUSTICE' : 'FULL COMBO'}
|
||||
</ChuniScoreBadge>)
|
||||
}
|
||||
|
||||
export const ChuniScoreRankBadge = ({ rank, ...rest }: { rank: number } & Pick<ChuniScoreBadgeProps, 'className' | 'fontSize'>) => {
|
||||
const scoreRank = CHUNI_SCORE_RANKS[rank];
|
||||
return (<ChuniScoreBadge variant={getVariantFromRank(rank)} {...rest}>
|
||||
<div className="tracking-[1cqw]">
|
||||
{scoreRank.endsWith('+') ? <>
|
||||
{scoreRank.slice(0, -1)}
|
||||
<div className="inline-block translate-y-[-15cqh]">+</div>
|
||||
</> : scoreRank}
|
||||
</div>
|
||||
</ChuniScoreBadge>);
|
||||
};
|
||||
|
503
src/components/chuni/score-calculator.tsx
Normal file
503
src/components/chuni/score-calculator.tsx
Normal file
@ -0,0 +1,503 @@
|
||||
import { ChuniMusic } from '@/actions/chuni/music';
|
||||
import { ChuniUserRating } from '@/actions/chuni/profile';
|
||||
import { useHashNavigation } from '@/helpers/use-hash-navigation';
|
||||
import { Modal, ModalHeader, ModalContent, ModalBody, ModalFooter } from '@nextui-org/modal';
|
||||
import { Button } from '@nextui-org/button';
|
||||
import { CHUNI_DIFFICULTIES } from '@/helpers/chuni/difficulties';
|
||||
import { floorToDp } from '@/helpers/floor-dp';
|
||||
import { Input } from '@nextui-org/input';
|
||||
import { useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||
import { chuniRating, chuniRatingInverse } from '@/helpers/chuni/rating';
|
||||
import { ChuniRating } from './rating';
|
||||
import { ChuniScoreRankBadge } from './score-badge';
|
||||
import { CHUNI_SCORE_RANKS, CHUNI_SCORE_THRESHOLDS, getRankFromScore } from '@/helpers/chuni/score-ranks';
|
||||
import { Tabs, Tab } from '@nextui-org/tabs';
|
||||
import { BigDecimal } from '@/helpers/big-decimal';
|
||||
import { Divider } from '@nextui-org/divider';
|
||||
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from '@nextui-org/dropdown';
|
||||
import { useValueChange } from '@/helpers/use-value-change';
|
||||
import { Entries, RequireExactlyOne } from 'type-fest';
|
||||
|
||||
export type ChuniScoreCalculatorProps = {
|
||||
topRating?: ChuniUserRating,
|
||||
music: Pick<ChuniMusic, 'scoreMax' | 'allJudgeCount' | 'level' | 'songId' | 'chartId' | 'title' | 'rating'> | null,
|
||||
onClose: () => void
|
||||
};
|
||||
|
||||
const calculateTop = ({ topRating, subtract, rating, totalTopRating }:
|
||||
{ topRating?: ChuniUserRating, subtract: BigDecimal | null, rating: BigDecimal, totalTopRating: BigDecimal }) => {
|
||||
if (!topRating) return [null, null];
|
||||
|
||||
let topIncrease: string | null = null;
|
||||
let topIndex: number | null = null;
|
||||
|
||||
if (subtract !== null && rating.compare(subtract) > 0) {
|
||||
topIncrease = totalTopRating.add(rating.sub(subtract)).div(30, 4n).sub(totalTopRating.div(30, 4n)).toFixed(3);
|
||||
topIndex = topRating.top.findIndex(t => rating.compare(t.rating) >= 0);
|
||||
if (topIndex === -1)
|
||||
topIndex = null;
|
||||
}
|
||||
|
||||
return [topIncrease, topIndex] as const;
|
||||
};
|
||||
|
||||
type MissAttackState = { miss: string, attack: string, totalAttack: number, missPercent: number; };
|
||||
type MissAttackAction = { miss: string; } | { attack: string, recompute?: boolean; } | { miss: string, totalAttack: number; } | { missPercent: number; } | 'recompute';
|
||||
type NoteValues = { noteMiss: string, noteAttack: string, noteJustice: string, noteJusticeCritical: string; };
|
||||
type NoteState = NoteValues & { order: (keyof NoteValues)[], totalNote: number };
|
||||
|
||||
const STORAGE_KEY = 'chuni-score-calculator';
|
||||
|
||||
type StoredState = {
|
||||
selectedKey: string,
|
||||
score: string,
|
||||
rating: string,
|
||||
topRatingIncrease: string,
|
||||
ratio: string,
|
||||
missPercent: number
|
||||
};
|
||||
|
||||
export const ChuniScoreCalculator = ({ topRating, music, onClose }: ChuniScoreCalculatorProps) => {
|
||||
const onModalClose = useHashNavigation({
|
||||
onClose,
|
||||
isOpen: music !== null,
|
||||
hash: '#chuni-score-calculator'
|
||||
});
|
||||
|
||||
const [selectedKey, setSelectedKey] = useState('rating');
|
||||
const [score, setScore] = useState('');
|
||||
const [rating, setRating] = useState('');
|
||||
const [topRatingIncrease, setTopRatingIncrease] = useState('');
|
||||
const [ratio, setRatio] = useState('100');
|
||||
const [{ miss, attack, missPercent }, dispatchScore] = useReducer((state: MissAttackState, action: MissAttackAction): MissAttackState => {
|
||||
if (action === 'recompute') {
|
||||
if (!Number.isInteger(+state.miss)) return state;
|
||||
|
||||
const attack = state.totalAttack - +state.miss * 2;
|
||||
if (attack < 0 || Number.isNaN(attack)) return state;
|
||||
return { ...state, attack: attack.toString() };
|
||||
}
|
||||
|
||||
if ('totalAttack' in action || 'missPercent' in action)
|
||||
return { ...state, ...action };
|
||||
const totalAttack = state.totalAttack;
|
||||
|
||||
if ('miss' in action) {
|
||||
const val = action.miss.trim();
|
||||
if (val === '' || !Number.isInteger(+val))
|
||||
return { ...state, miss: val };
|
||||
|
||||
const miss = Math.floor(Math.max(Math.min(+action.miss, totalAttack / 2), 0));
|
||||
const attack = state.totalAttack - miss * 2;
|
||||
if (Number.isNaN(miss)) return state;
|
||||
const missPercent = miss / totalAttack;
|
||||
return { ...state, miss: miss.toString(), attack: attack.toString(), missPercent };
|
||||
} else if ('attack' in action) {
|
||||
const val = action.attack.trim();
|
||||
if (val === '' || !Number.isInteger(+val))
|
||||
return { ...state, attack: val };
|
||||
let attack = Math.floor(Math.max(Math.min(+action.attack, totalAttack), 0));
|
||||
const miss = Math.min((attack < +state.attack && action.recompute ? Math.ceil : Math.floor)((totalAttack - attack) / 2), Math.floor(totalAttack / 2));
|
||||
if (action.recompute)
|
||||
attack = state.totalAttack - miss * 2;
|
||||
|
||||
if (Number.isNaN(attack) || attack < 0) return state;
|
||||
const missPercent = miss / totalAttack;
|
||||
|
||||
return { ...state, miss: miss.toString(), attack: attack.toString(), missPercent };
|
||||
}
|
||||
|
||||
return state;
|
||||
}, { miss: '0', totalAttack: 0, missPercent: 0, attack: '0' });
|
||||
const [unachievable, setUnachievable] = useState(false);
|
||||
const [justiceCount, setJusticeCount] = useState(0);
|
||||
|
||||
useValueChange(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ selectedKey, score, rating, topRatingIncrease, ratio, missPercent }));
|
||||
}, [selectedKey, score, rating, topRatingIncrease, ratio, missPercent]);
|
||||
|
||||
useEffect(() => {
|
||||
let data: StoredState;
|
||||
try {
|
||||
const val = localStorage.getItem(STORAGE_KEY);
|
||||
if (val)
|
||||
data = JSON.parse(val);
|
||||
else
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedKey(data.selectedKey);
|
||||
setScore(data.score);
|
||||
setRating(data.rating);
|
||||
setTopRatingIncrease(data.topRatingIncrease);
|
||||
setRatio(data.ratio);
|
||||
dispatchScore({ missPercent: data.missPercent });
|
||||
}, []);
|
||||
|
||||
const [noteCount, dispatchNote] = useReducer((state: NoteState, action: RequireExactlyOne<NoteValues> | number) => {
|
||||
if (typeof action === 'number')
|
||||
return { ...state, noteMiss: '0', noteAttack: '0', noteJustice: '0', noteJusticeCritical: action.toString(), totalNote: action };
|
||||
|
||||
state = { ...state };
|
||||
const [key, val] = (Object.entries(action) as Entries<typeof action>)[0];
|
||||
state.order = [key, ...state.order.filter(x => x !== key)];
|
||||
state[key] = val?.trim() ?? '';
|
||||
if (state[key] !== '' && Number.isFinite(+state[key]))
|
||||
state[key] = Math.floor(Math.min(Math.max(+state[key], 0), state.totalNote)).toString();
|
||||
|
||||
if (state.order.some(k => state[k] === '' || !Number.isInteger(+state[k])))
|
||||
return state;
|
||||
|
||||
let sum = state.order.reduce((t, k) => t + +state[k], 0);
|
||||
if (sum === state.totalNote)
|
||||
return state;
|
||||
|
||||
if (sum < state.totalNote) {
|
||||
const k = state.order.at(-1)!;
|
||||
state[k] = (+state[k] + state.totalNote - sum).toString();
|
||||
} else {
|
||||
[...state.order].reverse().forEach(key => {
|
||||
if (sum === state.totalNote) return;
|
||||
const val = +state[key];
|
||||
const sub = Math.min(val, sum - state.totalNote);
|
||||
sum -= sub;
|
||||
state[key] = (val - sub).toString();
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
}, {
|
||||
noteMiss: '0', noteAttack: '0', noteJustice: '0', noteJusticeCritical: '0', totalNote: 0,
|
||||
order: ['noteMiss', 'noteAttack', 'noteJustice', 'noteJusticeCritical']
|
||||
})
|
||||
|
||||
const totalTopRating = useMemo(() => topRating?.top.reduce((t, x) => t.add(x.rating), BigDecimal.ZERO) ?? BigDecimal.ZERO, [topRating]);
|
||||
const musicLevel = useMemo(() => music ? new BigDecimal(music.level?.toFixed(1)!) : null, [music]);
|
||||
const subtract = useMemo(() => {
|
||||
if (!music) return null;
|
||||
const top = topRating?.top.find(r => r.songId === music.songId && r.chartId === music.chartId);
|
||||
const maxPossibleRating = musicLevel!.add('2.15');
|
||||
|
||||
const smallestRating = topRating?.top.at(-1)?.rating ?? 0;
|
||||
if (maxPossibleRating.compare(smallestRating) <= 0)
|
||||
return null;
|
||||
|
||||
if (top) {
|
||||
const rating = new BigDecimal(top.rating);
|
||||
const currentRatingDiff = rating.sub(musicLevel!);
|
||||
if (currentRatingDiff.compare('2.15') >= 0)
|
||||
return null;
|
||||
return rating;
|
||||
} else {
|
||||
return new BigDecimal(smallestRating);
|
||||
}
|
||||
}, [musicLevel, topRating, music]);
|
||||
|
||||
const maxPossibleTopIncrease = useMemo(() => {
|
||||
if (!music || selectedKey !== 'top') return null;
|
||||
const maxPossibleRating = musicLevel!.add('2.15');
|
||||
|
||||
if (subtract === null) return null;
|
||||
|
||||
return totalTopRating.add(maxPossibleRating.sub(subtract)).div(30, 4n).sub(totalTopRating.div(30, 4n)).toString()
|
||||
}, [topRating, music, selectedKey, totalTopRating, musicLevel, subtract]);
|
||||
|
||||
const { musicRating, reverseScore, topIncrease, topIndex, targetTopScore, targetTotalRating, computedNoteScore, computedRating } = useMemo(() => {
|
||||
const data: {
|
||||
musicRating: BigDecimal | null,
|
||||
reverseScore: number | null,
|
||||
topIncrease: string | null,
|
||||
topIndex: number | null,
|
||||
targetTopScore: number | null,
|
||||
targetTotalRating: BigDecimal | null,
|
||||
computedNoteScore: number | null,
|
||||
computedRating: BigDecimal | null,
|
||||
} = { musicRating: null, reverseScore: null, topIncrease: null, topIndex: null, targetTopScore: null, targetTotalRating: null, computedNoteScore: null, computedRating: null };
|
||||
|
||||
if (!music) return data;
|
||||
|
||||
let computedRating: BigDecimal | null = null;
|
||||
|
||||
if (selectedKey === 'rating') {
|
||||
if (isNaN(+score)) return data;
|
||||
|
||||
data.musicRating = computedRating = chuniRating(Math.max(Math.min(Math.floor(+score), 1010000), 0), music?.level ?? 0, 5n);
|
||||
} else if (selectedKey === 'score') {
|
||||
if (isNaN(+rating)) return data;
|
||||
|
||||
computedRating = new BigDecimal(rating);
|
||||
data.reverseScore = chuniRatingInverse(computedRating, music.level ?? 0);
|
||||
} else if (selectedKey === 'top') {
|
||||
if (subtract === null || isNaN(+topRatingIncrease)) return data;
|
||||
|
||||
const ratingIncrease = new BigDecimal(topRatingIncrease).mul(30);
|
||||
|
||||
if (ratingIncrease.compare(0) === 0) {
|
||||
data.targetTopScore = 0;
|
||||
data.targetTotalRating = totalTopRating.div(30, 4n);
|
||||
return data;
|
||||
}
|
||||
|
||||
const targetSongRating = subtract.add(ratingIncrease);
|
||||
const targetScore = chuniRatingInverse(targetSongRating, music.level ?? 0);
|
||||
data.targetTopScore = targetScore;
|
||||
|
||||
if (targetScore === Infinity) return data;
|
||||
|
||||
data.targetTotalRating = totalTopRating.add(ratingIncrease).div(30, 4n);
|
||||
data.topIndex = calculateTop({ topRating, subtract, totalTopRating, rating: targetSongRating })[1];
|
||||
} else {
|
||||
if (noteCount.order.some(k => !Number.isInteger(+noteCount[k])))
|
||||
return data;
|
||||
|
||||
data.computedNoteScore = Math.floor((+noteCount.noteJusticeCritical * 1_010_000 + +noteCount.noteJustice * 1_000_000 + +noteCount.noteAttack * 500_000) / music.allJudgeCount);
|
||||
computedRating = chuniRating(data.computedNoteScore, music?.level ?? 0, 5n);
|
||||
}
|
||||
|
||||
if (computedRating !== null) {
|
||||
data.computedRating = computedRating;
|
||||
const [topIncrease, topIndex] = calculateTop({ topRating, subtract, totalTopRating, rating: computedRating });
|
||||
data.topIncrease = topIncrease;
|
||||
data.topIndex = topIndex;
|
||||
}
|
||||
|
||||
return data;
|
||||
}, [music, score, rating, selectedKey, subtract, totalTopRating, topRating, topRatingIncrease, noteCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (music)
|
||||
dispatchNote(music.allJudgeCount);
|
||||
}, [music, dispatchNote]);
|
||||
|
||||
let targetScore: number | null;
|
||||
if (selectedKey === 'rating')
|
||||
targetScore = Math.max(Math.min(Math.floor(+score), 1010000), 0);
|
||||
else if (selectedKey === 'score')
|
||||
targetScore = reverseScore;
|
||||
else if (selectedKey === 'top')
|
||||
targetScore = targetTopScore;
|
||||
else
|
||||
targetScore = computedNoteScore;
|
||||
const validScore = targetScore !== null && !isNaN(+targetScore) && isFinite(targetScore);
|
||||
const rank = !validScore ? null : getRankFromScore(targetScore!);
|
||||
|
||||
const badge = useMemo(() => rank !== null && <ChuniScoreRankBadge className="h-5 sm:h-8 ml-auto my-auto" rank={rank} />, [rank]);
|
||||
const badgeDropdown = useMemo(() => badge !== null && selectedKey !== 'top' && (<Dropdown className="!min-w-0">
|
||||
<DropdownTrigger>
|
||||
<div className="ml-auto my-auto">
|
||||
{badge}
|
||||
</div>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu>
|
||||
{CHUNI_SCORE_RANKS.map((name, i) => (<DropdownItem key={i} className="py-1 px-0.5" onPress={() => {
|
||||
if (selectedKey === 'rating')
|
||||
setScore(CHUNI_SCORE_THRESHOLDS[i].toString());
|
||||
else if (i === 1)
|
||||
setRating('0.01');
|
||||
else
|
||||
setRating((Math.ceil(chuniRating(CHUNI_SCORE_THRESHOLDS[i], music?.level!, 3n).mul(100).valueOf()) / 100).toString());
|
||||
}}>
|
||||
<ChuniScoreRankBadge className="h-6" rank={i} />
|
||||
</DropdownItem>)).reverse()}
|
||||
</DropdownMenu>
|
||||
</Dropdown>), [badge, selectedKey]);
|
||||
|
||||
useValueChange(() => {
|
||||
if (!music || targetScore === null || !Number.isInteger(targetScore)) return;
|
||||
const justiceRatio = Math.min(Math.max(+ratio, 0), 100) / 100;
|
||||
if (!Number.isFinite(justiceRatio)) return;
|
||||
const noteCount = music.allJudgeCount;
|
||||
|
||||
const maxAttackCount = targetScore <= 500000 ? noteCount :
|
||||
Math.floor((noteCount * (10000 * justiceRatio - targetScore + 1000000)) / (10000 * (justiceRatio + 50)));
|
||||
|
||||
if (maxAttackCount < 0) {
|
||||
setUnachievable(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setUnachievable(false);
|
||||
|
||||
const remainingJusticeCount = noteCount - maxAttackCount;
|
||||
const justiceCriticalCount = Math.floor(remainingJusticeCount * justiceRatio);
|
||||
const justiceCount = remainingJusticeCount - justiceCriticalCount;
|
||||
|
||||
setJusticeCount(justiceCount);
|
||||
|
||||
const miss = Math.ceil(Math.min(maxAttackCount * missPercent, Math.floor(maxAttackCount / 2)));
|
||||
const attack = maxAttackCount - 2 * miss;
|
||||
dispatchScore({ miss: miss.toString(), attack: attack.toString(), totalAttack: maxAttackCount });
|
||||
}, [music, targetScore, ratio], [miss, attack]);
|
||||
|
||||
const scoreTargetText = useMemo(() => {
|
||||
if (selectedKey === 'rating') return null;
|
||||
let score: number | null = null;
|
||||
if (selectedKey === 'score')
|
||||
score = reverseScore;
|
||||
else if (selectedKey === 'top')
|
||||
score = targetTopScore;
|
||||
else
|
||||
score = computedNoteScore;
|
||||
|
||||
return (<span className="pt-0.5"><span className="sm:text-lg text-xs">Score: </span>{score === Infinity ?
|
||||
<span className="text-danger">Error</span> :
|
||||
<span className="text-xs sm:text-xl font-semibold">{score?.toLocaleString()}</span>}</span>);
|
||||
}, [selectedKey, reverseScore, targetTopScore, computedNoteScore]);
|
||||
|
||||
const topIncreaseText = topIncrease && <span className="text-xs sm:text-sm mt-auto pb-0.5 sm:pb-1 ml-2.5">(Top +{topIncrease}{topIndex !== null && `, #${topIndex + 1}`})</span>;
|
||||
|
||||
return (<Modal isOpen={music !== null} onClose={onModalClose} size="3xl">
|
||||
<ModalContent>
|
||||
{onClose => <>
|
||||
<ModalHeader>
|
||||
Score Calculator
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<header className="font-semibold text-lg flex">
|
||||
{ music?.title } <span className="ml-auto text-right">{ CHUNI_DIFFICULTIES[music?.chartId!] } ({ music?.level }) </span>
|
||||
</header>
|
||||
<div className="flex text-xs sm:text-sm md:text-medium">
|
||||
{music?.scoreMax !== null && <>
|
||||
<span className="font-semibold">High score: </span>
|
||||
{music?.scoreMax?.toLocaleString()} ({floorToDp(+music?.rating!, 4)})
|
||||
</>}
|
||||
|
||||
<span className="ml-auto text-right">Note count: {music?.allJudgeCount}</span>
|
||||
</div>
|
||||
<Tabs fullWidth selectedKey={selectedKey} onSelectionChange={k => setSelectedKey(k as string)} size="sm" data-draggable="true">
|
||||
<Tab key="rating" title="Score to Rating" className="p-0 m-0">
|
||||
<Input labelPlacement="outside-left" placeholder="Enter score" label="Score" type="number" inputMode="numeric" min="0" max="1010000" step="100"
|
||||
size="md" classNames={{ mainWrapper: 'w-full' }}
|
||||
value={score} onValueChange={setScore} />
|
||||
{musicRating && <div className="sm:text-lg flex mt-2.5 flex-wrap">
|
||||
<span className="mt-auto text-sm sm:text-lg">Song rating: </span>
|
||||
<ChuniRating rating={musicRating.mul(100).valueOf()} className="inline text-lg max-sm:-mb-1 sm:text-2xl mt-auto">{musicRating.toString()}</ChuniRating>
|
||||
{topIncreaseText}
|
||||
{badgeDropdown}
|
||||
</div>}
|
||||
</Tab>
|
||||
<Tab key="score" title="Rating to Score" className="p-0 m-0">
|
||||
<Input labelPlacement="outside-left" placeholder="Enter rating" label="Rating" type="number" inputMode="numeric" min="0" max={music?.level! + 2.15} step="0.01"
|
||||
size="md" classNames={{ mainWrapper: 'w-full' }}
|
||||
value={rating} onValueChange={setRating} />
|
||||
<div className="mt-2.5 flex">
|
||||
{scoreTargetText}
|
||||
{reverseScore !== Infinity && topIncreaseText}
|
||||
{badgeDropdown}
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab key="note" title="Note Count">
|
||||
<div className="flex w-full justify-around text-xs sm:text-sm md:text-medium gap-x-2 items-end">
|
||||
{([
|
||||
['noteJusticeCritical', 'Justice Critical', 'dark:text-yellow-400 text-yellow-500'],
|
||||
['noteJustice', 'Justice', 'text-orange-500'],
|
||||
['noteAttack', 'Attack', 'text-emerald-600'],
|
||||
['noteMiss', 'Miss', 'text-gray-400']
|
||||
] as const).map(([key, name, className]) => (<span key={key} className={`${className} drop-shadow text-center max-sm:flex-1`}>
|
||||
{name}
|
||||
<Input className="max-sm:mt-1 inline-block max-w-[80px]" size="sm" type="number" inputMode="numeric" min="0" max={music?.allJudgeCount}
|
||||
classNames={{ input: '!text-inherit' }} value={noteCount[key]} onValueChange={v => dispatchNote({ [key]: v } as any)} />
|
||||
</span>))}
|
||||
</div>
|
||||
<div className="mt-2.5 flex items-center flex-wrap">
|
||||
{scoreTargetText}
|
||||
{computedNoteScore !== null && topIncreaseText}
|
||||
{computedRating !== null && <div className="w-full flex">
|
||||
<span className="text-xs sm:text-sm md:text-medium self-center">Rating: </span>
|
||||
<ChuniRating rating={computedRating.mul(100).valueOf()} className="inline-block text-lg sm:text-xl md:text-2xl">
|
||||
{computedRating.toString()}
|
||||
</ChuniRating>
|
||||
{badge}
|
||||
</div>}
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab key="top" title="Top Rating" className="p-0 m-0">
|
||||
{maxPossibleTopIncrease === null ? <header className="italic text-gray-500 w-full text-center mt-3 max-sm:text-sm">This chart cannot increase your top rating.</header> : <div>
|
||||
<div className="w-full text-center text-xs sm:text-sm text-gray-500 mb-3 mt-1">
|
||||
Maximum possible increase to top rating: <span className="cursor-pointer underline transition hover:text-foreground"
|
||||
onClick={() => setTopRatingIncrease(maxPossibleTopIncrease)}>
|
||||
{maxPossibleTopIncrease}
|
||||
</span>
|
||||
</div>
|
||||
<Input labelPlacement="outside-left" placeholder="Enter rating" label="Top rating increase" type="number" inputMode="numeric" min="0" max={maxPossibleTopIncrease} step="0.01"
|
||||
size="md" classNames={{ mainWrapper: 'w-full', label: 'text-nowrap mr-1.5' }}
|
||||
value={topRatingIncrease}
|
||||
onValueChange={setTopRatingIncrease} />
|
||||
<div className="mt-2.5 flex items-center">
|
||||
{scoreTargetText}
|
||||
{targetTotalRating !== null && <span className="max-sm:mt-0.5 text-xs sm:text-sm md:text-medium"> (Top rating <ChuniRating className="inline-block text-sm md:text-lg" rating={targetTotalRating.mul(100)?.valueOf()!}>
|
||||
{targetTotalRating.toFixed(3)}
|
||||
</ChuniRating>{topIndex !== null && `, #${topIndex + 1}`})</span>}
|
||||
{badge}
|
||||
</div>
|
||||
</div>}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
{validScore && selectedKey !== 'note' && <>
|
||||
<Divider />
|
||||
<section className="text-xs md:text-sm lg:text-medium flex gap-y-2 flex-wrap items-center">
|
||||
<span>
|
||||
Score {targetScore?.toLocaleString()} with a
|
||||
</span>
|
||||
<span className="dark:text-yellow-400 text-yellow-500 drop-shadow">Justice </span>
|
||||
<span className="dark:text-yellow-400 text-yellow-500 drop-shadow">Critical </span>
|
||||
<span>accuracy </span>
|
||||
<span>of </span>
|
||||
<div>
|
||||
<Input className="inline-block w-[83px]" size="sm" inputMode="numeric" type="number" min="0" max="100"
|
||||
value={ratio} onValueChange={setRatio} />
|
||||
<span>% </span>
|
||||
</div>
|
||||
<span>is </span>
|
||||
{unachievable && <span className="font-bold">not </span>}
|
||||
<span>achievable </span>
|
||||
{unachievable ? <div className="h-8 w-full" /> : <>
|
||||
<span>with </span>
|
||||
<span>no </span>
|
||||
<span>greater </span>
|
||||
<span>than: </span>
|
||||
|
||||
<div className="flex gap-6 max-sm:w-full max-sm:mx-auto ml-auto items-center justify-center">
|
||||
{!!justiceCount && <div>
|
||||
<span className="text-orange-500">{justiceCount} Justice</span>
|
||||
</div>}
|
||||
<div>
|
||||
<Input className="inline-block w-20 mr-1" size="sm" inputMode="numeric" type="number" min="0" step="1" classNames={{ input: '!text-emerald-600' }}
|
||||
value={attack.toString()}
|
||||
onInput={e => {
|
||||
dispatchScore({
|
||||
attack: (e.target as HTMLInputElement).value,
|
||||
recompute: (e.nativeEvent as InputEvent).inputType === 'insertReplacementText'
|
||||
});
|
||||
}}
|
||||
onBlur={() => dispatchScore('recompute')}
|
||||
/>
|
||||
<span className="text-emerald-600">
|
||||
Attack
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<Input className="inline-block w-20 mr-1" size="sm" inputMode="numeric" type="number" min="0" step="1" classNames={{ input: '!text-gray-400' }}
|
||||
value={miss.toString()} onValueChange={v => dispatchScore({ miss: v })} />
|
||||
<span className="text-gray-400">
|
||||
Miss
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
</section>
|
||||
</>}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="primary" onPress={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>}
|
||||
</ModalContent>
|
||||
</Modal>)
|
||||
};
|
@ -1,13 +1,16 @@
|
||||
type DecimalInput = BigDecimal | number | string;
|
||||
|
||||
export class BigDecimal {
|
||||
private val: bigint;
|
||||
private decimals: bigint;
|
||||
private _val: bigint;
|
||||
private _decimals: bigint;
|
||||
|
||||
static readonly ZERO = new BigDecimal(0);
|
||||
static readonly ONE = new BigDecimal(1);
|
||||
|
||||
constructor(val: DecimalInput) {
|
||||
if (val instanceof BigDecimal) {
|
||||
this.val = val.val;
|
||||
this.decimals = val.decimals;
|
||||
this._val = val._val;
|
||||
this._decimals = val._decimals;
|
||||
return;
|
||||
}
|
||||
|
||||
@ -16,12 +19,12 @@ export class BigDecimal {
|
||||
|
||||
const decimalIndex = val.indexOf('.');
|
||||
val = val.replace('.', '');
|
||||
this.val = BigInt(val);
|
||||
this._val = BigInt(val);
|
||||
|
||||
if (decimalIndex === -1) {
|
||||
this.decimals = 0n;
|
||||
this._decimals = 0n;
|
||||
} else {
|
||||
this.decimals = BigInt(val.length - decimalIndex);
|
||||
this._decimals = BigInt(val.length - decimalIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,12 +32,12 @@ export class BigDecimal {
|
||||
const a = new BigDecimal(other);
|
||||
const b = new BigDecimal(this);
|
||||
|
||||
if (a.decimals > b.decimals) {
|
||||
b.val *= 10n ** (a.decimals - b.decimals);
|
||||
b.decimals = a.decimals;
|
||||
} else if (a.decimals < b.decimals) {
|
||||
a.val *= 10n ** (b.decimals - a.decimals);
|
||||
a.decimals = b.decimals;
|
||||
if (a._decimals > b._decimals) {
|
||||
b._val *= 10n ** (a._decimals - b._decimals);
|
||||
b._decimals = a._decimals;
|
||||
} else if (a._decimals < b._decimals) {
|
||||
a._val *= 10n ** (b._decimals - a._decimals);
|
||||
a._decimals = b._decimals;
|
||||
}
|
||||
|
||||
return [a, b];
|
||||
@ -42,21 +45,21 @@ export class BigDecimal {
|
||||
|
||||
add(other: DecimalInput) {
|
||||
const [a, b] = this.coerceDecimals(other);
|
||||
a.val += b.val;
|
||||
a._val += b._val;
|
||||
return a;
|
||||
}
|
||||
|
||||
sub(other: DecimalInput) {
|
||||
const [a, b] = this.coerceDecimals(other);
|
||||
b.val -= a.val;
|
||||
b._val -= a._val;
|
||||
return b;
|
||||
}
|
||||
|
||||
mul(other: DecimalInput) {
|
||||
const a = new BigDecimal(other);
|
||||
const b = new BigDecimal(this);
|
||||
a.val *= b.val;
|
||||
a.decimals += b.decimals;
|
||||
a._val *= b._val;
|
||||
a._decimals += b._decimals;
|
||||
return a;
|
||||
}
|
||||
|
||||
@ -64,33 +67,48 @@ export class BigDecimal {
|
||||
const a = new BigDecimal(other);
|
||||
const b = new BigDecimal(this);
|
||||
|
||||
if ((b.decimals - a.decimals) < minDecimals) {
|
||||
const exp = minDecimals - (b.decimals - a.decimals);
|
||||
b.val *= 10n ** exp;
|
||||
b.decimals += exp;
|
||||
if ((b._decimals - a._decimals) < minDecimals) {
|
||||
const exp = minDecimals - (b._decimals - a._decimals);
|
||||
b._val *= 10n ** exp;
|
||||
b._decimals += exp;
|
||||
}
|
||||
|
||||
b.val /= a.val;
|
||||
b.decimals -= a.decimals;
|
||||
if (b.decimals < 0) b.decimals = 0n;
|
||||
b._val /= a._val;
|
||||
b._decimals -= a._decimals;
|
||||
if (b._decimals < 0) b._decimals = 0n;
|
||||
|
||||
return b;
|
||||
}
|
||||
|
||||
static stringVal(val: bigint, decimals: bigint) {
|
||||
const str = val.toString();
|
||||
if (decimals === 0n || val === 0n) return val.toString();
|
||||
const str = val.toString().padStart(Number(decimals), '0');
|
||||
const pos = -Number(decimals);
|
||||
return str.slice(0, pos) + '.' + str.slice(pos);
|
||||
return (str.slice(0, pos) || '0') + '.' + str.slice(pos);
|
||||
}
|
||||
|
||||
compare(other: DecimalInput) {
|
||||
const [b, a] = this.coerceDecimals(other);
|
||||
if (a._val === b._val) return 0;
|
||||
if (a._val > b._val) return 1;
|
||||
return -1;
|
||||
}
|
||||
|
||||
toFixed(places: number | bigint) {
|
||||
places = BigInt(places);
|
||||
|
||||
if (places >= this.decimals)
|
||||
return BigDecimal.stringVal(this.val, this.decimals) + '0'.repeat(Number(places - this.decimals));
|
||||
if (places >= this._decimals)
|
||||
return BigDecimal.stringVal(this._val, this._decimals) + '0'.repeat(Number(places - this._decimals));
|
||||
|
||||
return BigDecimal.stringVal(this.val / (10n ** (this.decimals - places)), places);
|
||||
return BigDecimal.stringVal(this._val / (10n ** (this._decimals - places)), places);
|
||||
}
|
||||
|
||||
get val() {
|
||||
return this._val;
|
||||
}
|
||||
|
||||
get decimals() {
|
||||
return this._decimals;
|
||||
}
|
||||
|
||||
valueOf() {
|
||||
@ -98,9 +116,9 @@ export class BigDecimal {
|
||||
}
|
||||
|
||||
toString() {
|
||||
let val = this.val;
|
||||
let decimals = this.decimals;
|
||||
while (val && !(val % 10n)) {
|
||||
let val = this._val;
|
||||
let decimals = this._decimals;
|
||||
while (val && !(val % 10n) && decimals) {
|
||||
val /= 10n;
|
||||
--decimals;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { sql } from 'kysely';
|
||||
import { BigDecimal } from '../big-decimal';
|
||||
|
||||
export const chuniRating = (score: any = sql.raw(`CAST(score.scoreMax AS INT)`),
|
||||
export const sqlChuniRating = (score: any = sql.raw(`CAST(score.scoreMax AS INT)`),
|
||||
level: any = sql.raw(`(CAST(music.level AS DECIMAL(3, 1)) * 100)`)) => sql<string>`
|
||||
CAST(GREATEST((CASE
|
||||
WHEN ${score} IS NULL THEN NULL
|
||||
@ -14,3 +15,72 @@ CAST(GREATEST((CASE
|
||||
WHEN ${score} >= 500000 THEN ((${level} - 500) / 2 * (${score} - 500000)) / 300000
|
||||
ELSE 0 END) / 100, 0) AS DECIMAL(10, 8))
|
||||
`.as('rating');
|
||||
|
||||
|
||||
export const chuniRating = (score: number, level: number | BigDecimal, decimals = 8n) => {
|
||||
level = new BigDecimal(level.toFixed(1)).mul(100);
|
||||
|
||||
let rating: BigDecimal;
|
||||
if (score >= 1009000)
|
||||
rating = level.add(215);
|
||||
else if (score >= 1007500)
|
||||
rating = level.add(200).add(new BigDecimal(score - 1007500).div(100, decimals));
|
||||
else if (score >= 1005000)
|
||||
rating = level.add(150).add(new BigDecimal(score - 1005000).div(50, decimals));
|
||||
else if (score >= 1000000)
|
||||
rating = level.add(100).add(new BigDecimal(score - 1000000).div(100, decimals));
|
||||
else if (score >= 975000)
|
||||
rating = level.add(new BigDecimal(score - 975000).div(250, decimals));
|
||||
else if (score >= 900000)
|
||||
rating = level.sub(500).add(new BigDecimal(score - 900000).div(150, decimals));
|
||||
else if (score >= 800000)
|
||||
rating = level.sub(500).div(2, decimals).add(new BigDecimal(score - 800000).mul(level.sub(500).div(2, decimals)).div(100000, decimals));
|
||||
else if (score >= 500000)
|
||||
rating = level.sub(500).div(2).mul(score - 500000).div(300000);
|
||||
else
|
||||
rating = BigDecimal.ZERO;
|
||||
|
||||
if (rating.val < 0n)
|
||||
rating = BigDecimal.ZERO;
|
||||
|
||||
return rating.div(100, decimals);
|
||||
};
|
||||
|
||||
export const chuniRatingInverse = (rating: number | string | BigDecimal, level: number | BigDecimal) => {
|
||||
const ratingDecimal = new BigDecimal(rating);
|
||||
rating = ratingDecimal.mul(100);
|
||||
if (rating.val === 0n) return 0;
|
||||
|
||||
const levelDecimal = new BigDecimal(level.toFixed(1));
|
||||
level = levelDecimal.mul(100);
|
||||
|
||||
const diff = rating.sub(level);
|
||||
const compare = diff.compare(215);
|
||||
|
||||
if (compare > 0)
|
||||
return Infinity;
|
||||
if (compare === 0)
|
||||
return 1009000;
|
||||
|
||||
let val: number;
|
||||
|
||||
if (diff.compare(200) >= 0)
|
||||
val = diff.add(9875).mul(100).valueOf();
|
||||
else if (diff.compare(150) >= 0)
|
||||
val = diff.add(19950).mul(50).valueOf();
|
||||
else if (diff.compare(100) >= 0)
|
||||
val = diff.add(9900).mul(100).valueOf();
|
||||
else if (diff.compare(0) >= 0)
|
||||
val = diff.add(3900).mul(250).valueOf();
|
||||
else if (diff.compare(-500) >= 0)
|
||||
val = diff.add(6500).mul(150).valueOf();
|
||||
// between BBB and A [800000, 900000)
|
||||
else if (ratingDecimal.compare(chuniRating(800000, levelDecimal, ratingDecimal.decimals)) >= 0)
|
||||
val = level.mul(7).add(rating.mul(2)).sub(3500).mul(100000)
|
||||
.div(level.sub(500), 1n).valueOf();
|
||||
else
|
||||
val = level.mul(5).add(rating.mul(6)).sub(2500).mul(100000)
|
||||
.div(level.sub(500), 1n).valueOf();
|
||||
|
||||
return Math.ceil(val);
|
||||
};
|
||||
|
@ -1 +1,10 @@
|
||||
export const CHUNI_SCORE_RANKS = ['D', 'C', 'B', 'BB', 'BBB', 'A', 'AA', 'AAA', 'S', 'S+', 'SS', 'SS+', 'SSS', 'SSS+'];
|
||||
export const CHUNI_SCORE_THRESHOLDS = [0, 500000, 600000, 700000, 800000, 900000, 925000, 950000, 975000, 990000, 1000000, 1005000, 1007500, 1009000];
|
||||
|
||||
|
||||
export const getRankFromScore = (score: number) => {
|
||||
for (let i = CHUNI_SCORE_THRESHOLDS.length - 1; i >= 0; --i)
|
||||
if (score >= CHUNI_SCORE_THRESHOLDS[i])
|
||||
return i;
|
||||
return 0;
|
||||
};
|
||||
|
16
src/helpers/use-value-change.ts
Normal file
16
src/helpers/use-value-change.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { EffectCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export const useValueChange = (onChange: EffectCallback, vals: any[], deps: any[] = []) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const lastVals = vals.map(val => useRef(val));
|
||||
|
||||
useEffect(() => {
|
||||
const last = lastVals.map(r => r.current);
|
||||
lastVals.forEach((ref, i) => ref.current = vals[i]);
|
||||
|
||||
if (last.every((r, i) => r === vals[i]))
|
||||
return;
|
||||
|
||||
return onChange();
|
||||
}, [...vals, ...deps])
|
||||
};
|
Loading…
Reference in New Issue
Block a user