mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-06 22:07:58 +03:00
feat(client): chunithm graphs, song durations (#1284)
* feat: chunithm score graph * feat: add life * feat: add song duration data for all songs * fix: make chunithm graphs non-nullable * fix: test data * fix: potrząsanie kanalizacją * fix: misleading old function name * fix: make duration optional for chunithm * fix: add the missing duration
This commit is contained in:
+123
-54
@@ -11,7 +11,7 @@ import {
|
||||
PointTooltipProps,
|
||||
LineSvgProps,
|
||||
} from "@nivo/line";
|
||||
import { COLOUR_SET, Difficulties } from "tachi-common";
|
||||
import { COLOUR_SET, Difficulties, Game } from "tachi-common";
|
||||
import { GPT_CLIENT_IMPLEMENTATIONS } from "lib/game-implementations";
|
||||
import ChartTooltip from "./ChartTooltip";
|
||||
|
||||
@@ -22,21 +22,39 @@ const formatTime = (s: DatumValue) =>
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
|
||||
const scoreToLamp = (s: number) => {
|
||||
switch (s) {
|
||||
case 970000:
|
||||
return "S";
|
||||
case 990000:
|
||||
return "SS";
|
||||
case 1000000:
|
||||
return "SSS";
|
||||
case 1007500:
|
||||
return "SSS+";
|
||||
const getScoreYAxisNotch = (game: Game) => (s: number) => {
|
||||
if (game === "ongeki") {
|
||||
switch (s) {
|
||||
case 970_000:
|
||||
return "S";
|
||||
case 990_000:
|
||||
return "SS";
|
||||
case 1000_000:
|
||||
return "SSS";
|
||||
case 1007_500:
|
||||
return "SSS+";
|
||||
}
|
||||
}
|
||||
if (game === "chunithm") {
|
||||
switch (s) {
|
||||
case 990_000:
|
||||
return "S+";
|
||||
case 1000_000:
|
||||
return "SS";
|
||||
case 1005_000:
|
||||
return "SS+";
|
||||
case 1007_500:
|
||||
return "SSS";
|
||||
case 1009_000:
|
||||
return "SSS+";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const strokeColor = (type: Difficulties["ongeki:Single"] | "BELLS") => {
|
||||
const strokeColor = (
|
||||
type: Difficulties["ongeki:Single"] | Difficulties["chunithm:Single"] | "BELLS"
|
||||
) => {
|
||||
const isLight = getTheme() === "light";
|
||||
switch (type) {
|
||||
case "BASIC":
|
||||
@@ -49,21 +67,26 @@ const strokeColor = (type: Difficulties["ongeki:Single"] | "BELLS") => {
|
||||
return `hsl(280, 60%, ${isLight ? 35 : 67}%)`;
|
||||
case "BELLS":
|
||||
return `hsl(55, 90%, ${isLight ? 35 : 42}%)`;
|
||||
case "ULTIMA":
|
||||
return `hsl(360, 50%, ${isLight ? 35 : 67}%)`;
|
||||
default:
|
||||
return `hsl(0, 0%, ${isLight ? 35 : 67}%)`;
|
||||
}
|
||||
};
|
||||
|
||||
const limitScoreGraph = (data: Serie[]) => {
|
||||
const limitScoreGraph = (game: Game, data: Serie[]) => {
|
||||
for (const val of data[0].data) {
|
||||
if (val.y === null || val.y === undefined) {
|
||||
if (typeof val.y !== "number") {
|
||||
break;
|
||||
}
|
||||
if (val.y < 970000) {
|
||||
if (game === "ongeki" && val.y < 970_000) {
|
||||
// 969999 will be used to represent values below S
|
||||
// Without this, the line would cross the bottom axis
|
||||
// which looks very bad
|
||||
val.y = 969999;
|
||||
val.y = 969_999;
|
||||
}
|
||||
if (game === "chunithm" && val.y < 990_000) {
|
||||
val.y = 989_999;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
@@ -77,7 +100,7 @@ const bellFloor = (data: Datum[], totalBellCount: number) => {
|
||||
: clamp(-totalBellCount, Math.floor(lowestValue * 1.333), -1);
|
||||
};
|
||||
|
||||
export default function OngekiScoreChart({
|
||||
export default function GekichuScoreChart({
|
||||
width = "100%",
|
||||
height = "100%",
|
||||
mobileHeight = "100%",
|
||||
@@ -86,28 +109,46 @@ export default function OngekiScoreChart({
|
||||
difficulty,
|
||||
totalBellCount,
|
||||
data,
|
||||
game,
|
||||
duration,
|
||||
}: {
|
||||
mobileHeight?: number | string;
|
||||
mobileWidth?: number | string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
type: "Score" | "Bells" | "Life";
|
||||
difficulty: Difficulties["ongeki:Single"];
|
||||
totalBellCount: number;
|
||||
difficulty: Difficulties["ongeki:Single"] | Difficulties["chunithm:Single"];
|
||||
totalBellCount?: number;
|
||||
data: Serie[];
|
||||
game: Game;
|
||||
duration: number;
|
||||
} & ResponsiveLine["props"]) {
|
||||
const color =
|
||||
type === "Score"
|
||||
? GPT_CLIENT_IMPLEMENTATIONS["ongeki:Single"].difficultyColours[difficulty]
|
||||
: type === "Bells"
|
||||
? COLOUR_SET.vibrantYellow
|
||||
: COLOUR_SET.vibrantGreen;
|
||||
let color = COLOUR_SET.gray;
|
||||
|
||||
if (type === "Score") {
|
||||
if (game === "chunithm") {
|
||||
color =
|
||||
GPT_CLIENT_IMPLEMENTATIONS["chunithm:Single"].difficultyColours[
|
||||
difficulty as Difficulties["chunithm:Single"]
|
||||
];
|
||||
} else if (game === "ongeki") {
|
||||
color =
|
||||
GPT_CLIENT_IMPLEMENTATIONS["ongeki:Single"].difficultyColours[
|
||||
difficulty as Difficulties["ongeki:Single"]
|
||||
];
|
||||
}
|
||||
} else if (type === "Bells") {
|
||||
color = COLOUR_SET.vibrantYellow;
|
||||
} else {
|
||||
color = COLOUR_SET.vibrantGreen;
|
||||
}
|
||||
|
||||
const gradientId = type === "Score" ? difficulty : type;
|
||||
|
||||
const commonProps: Omit<LineSvgProps, "data"> = {
|
||||
margin: { top: 30, bottom: 50, left: 50, right: 50 },
|
||||
enableGridX: false,
|
||||
xScale: { type: "linear", min: 0, max: data[0].data.length - 1 },
|
||||
xScale: { type: "linear", min: 0, max: duration },
|
||||
axisBottom: { format: (d: number) => formatTime(d) },
|
||||
motionConfig: "stiff",
|
||||
crosshairType: "x",
|
||||
@@ -134,28 +175,53 @@ export default function OngekiScoreChart({
|
||||
|
||||
let component;
|
||||
if (type === "Score") {
|
||||
component = (
|
||||
<ResponsiveLine
|
||||
{...commonProps}
|
||||
data={limitScoreGraph(data)}
|
||||
yScale={{ type: "linear", min: 970000, max: 1010000 }}
|
||||
yFormat={">-,.0f"}
|
||||
axisLeft={{
|
||||
tickValues: [970000, 990000, 1000000, 1007500, 1010000],
|
||||
format: scoreToLamp,
|
||||
}}
|
||||
gridYValues={[970000, 980000, 990000, 1000000, 1007500, 1010000]}
|
||||
enableGridY={true}
|
||||
colors={strokeColor(difficulty)}
|
||||
areaBaselineValue={970000}
|
||||
tooltip={(d: PointTooltipProps) => (
|
||||
<ChartTooltip>
|
||||
{d.point.data.y === 969999 ? "< 970,000 " : d.point.data.yFormatted}@{" "}
|
||||
{formatTime(d.point.data.x)}
|
||||
</ChartTooltip>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
if (game === "ongeki") {
|
||||
component = (
|
||||
<ResponsiveLine
|
||||
{...commonProps}
|
||||
data={limitScoreGraph(game, data)}
|
||||
yScale={{ type: "linear", min: 970000, max: 1010000 }}
|
||||
yFormat={">-,.0f"}
|
||||
axisLeft={{
|
||||
tickValues: [970000, 990000, 1000000, 1007500, 1010000],
|
||||
format: getScoreYAxisNotch(game),
|
||||
}}
|
||||
gridYValues={[970000, 980000, 990000, 1000000, 1007500, 1010000]}
|
||||
enableGridY={true}
|
||||
colors={strokeColor(difficulty)}
|
||||
areaBaselineValue={970000}
|
||||
tooltip={(d: PointTooltipProps) => (
|
||||
<ChartTooltip>
|
||||
{d.point.data.y === 969999 ? "< 970,000 " : d.point.data.yFormatted}@{" "}
|
||||
{formatTime(d.point.data.x)}
|
||||
</ChartTooltip>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
} else if (game === "chunithm") {
|
||||
component = (
|
||||
<ResponsiveLine
|
||||
{...commonProps}
|
||||
data={limitScoreGraph(game, data)}
|
||||
yScale={{ type: "linear", min: 990_000, max: 1010_000 }}
|
||||
yFormat={">-,.0f"}
|
||||
axisLeft={{
|
||||
tickValues: [990_000, 1000_000, 1005_000, 1007_500, 1009_000, 1010_000],
|
||||
format: getScoreYAxisNotch(game),
|
||||
}}
|
||||
gridYValues={[990_000, 1000_000, 1005_000, 1007_500, 1009_000, 1010_000]}
|
||||
enableGridY={true}
|
||||
colors={strokeColor(difficulty)}
|
||||
areaBaselineValue={990000}
|
||||
tooltip={(d: PointTooltipProps) => (
|
||||
<ChartTooltip>
|
||||
{d.point.data.y === 989_999 ? "< 990,000 " : d.point.data.yFormatted}@{" "}
|
||||
{formatTime(d.point.data.x)}
|
||||
</ChartTooltip>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (type === "Bells") {
|
||||
component = (
|
||||
<ResponsiveLine
|
||||
@@ -163,14 +229,14 @@ export default function OngekiScoreChart({
|
||||
data={data}
|
||||
yScale={{
|
||||
type: "linear",
|
||||
min: bellFloor(data[0].data, totalBellCount),
|
||||
min: bellFloor(data[0].data, totalBellCount!),
|
||||
max: 0,
|
||||
stacked: false,
|
||||
}}
|
||||
enableGridY={false}
|
||||
axisLeft={{ format: (e: number) => Math.floor(e) === e && e }}
|
||||
colors={strokeColor("BELLS")}
|
||||
areaBaselineValue={bellFloor(data[0].data, totalBellCount)}
|
||||
areaBaselineValue={bellFloor(data[0].data, totalBellCount!)}
|
||||
tooltip={(d: PointTooltipProps) => (
|
||||
<ChartTooltip>
|
||||
MAX{d.point.data.y === 0 ? "" : d.point.data.y} @{" "}
|
||||
@@ -179,19 +245,22 @@ export default function OngekiScoreChart({
|
||||
)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
} else if (type === "Life") {
|
||||
const max = game === "ongeki" ? 100 : (data[0].data[0].y as number);
|
||||
const suffix = game === "ongeki" ? "%" : "";
|
||||
component = (
|
||||
<ResponsiveLine
|
||||
{...commonProps}
|
||||
data={data}
|
||||
yScale={{ type: "linear", min: 0, max: 100 }}
|
||||
yScale={{ type: "linear", min: 0, max }}
|
||||
enableGridY={false}
|
||||
axisLeft={{ format: (d: number) => `${d}%` }}
|
||||
axisLeft={{ format: (d: number) => `${d}${suffix}` }}
|
||||
colors={strokeColor("BASIC")}
|
||||
areaBaselineValue={0}
|
||||
tooltip={(d: PointTooltipProps) => (
|
||||
<ChartTooltip>
|
||||
{d.point.data.y}% @ {formatTime(d.point.data.x)}
|
||||
{d.point.data.y}
|
||||
{suffix} @ {formatTime(d.point.data.x)}
|
||||
</ChartTooltip>
|
||||
)}
|
||||
/>
|
||||
@@ -4,6 +4,7 @@ import { IIDXGraphsComponent } from "./components/IIDXScoreDropdownParts";
|
||||
import { ITGGraphsComponent } from "./components/ITGScoreDropdownParts";
|
||||
import { JubeatGraphsComponent } from "./components/JubeatScoreDropdownParts";
|
||||
import { OngekiGraphsComponent } from "./components/OngekiScoreDropdownParts";
|
||||
import { ChunithmGraphsComponent } from "./components/ChunithmScoreDropdownParts";
|
||||
|
||||
export function GPTDropdownSettings(game: Game, playtype: Playtype): any {
|
||||
if (game === "iidx") {
|
||||
@@ -33,6 +34,11 @@ export function GPTDropdownSettings(game: Game, playtype: Playtype): any {
|
||||
renderScoreInfo: true,
|
||||
GraphComponent: OngekiGraphsComponent as any,
|
||||
};
|
||||
} else if (game === "chunithm") {
|
||||
return {
|
||||
renderScoreInfo: true,
|
||||
GraphComponent: ChunithmGraphsComponent as any,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
|
||||
@@ -148,6 +148,7 @@ export default function PBDropdown({
|
||||
pbData={data}
|
||||
scoreState={scoreState}
|
||||
chart={chart}
|
||||
song={song}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,10 +120,11 @@ export default function ScoreDropdown({
|
||||
onScoreUpdate={onScoreUpdate}
|
||||
pbData={data}
|
||||
chart={chart}
|
||||
song={song}
|
||||
/>
|
||||
);
|
||||
} else if (view === "vsPB") {
|
||||
body = <PBCompare data={data} DocComponent={DocComponent} scoreState={scoreState} />;
|
||||
body = <PBCompare data={data} DocComponent={DocComponent as any} scoreState={scoreState} />;
|
||||
} else if (view === "manage") {
|
||||
body = <DeleteScoreBtn score={thisScore} />;
|
||||
} else if (view === "targets") {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
ChartDocument,
|
||||
Difficulties,
|
||||
PBScoreDocument,
|
||||
ScoreData,
|
||||
ScoreDocument,
|
||||
SongDocument,
|
||||
} from "tachi-common";
|
||||
import GekichuScoreChart from "components/charts/GekichuScoreChart";
|
||||
import SelectNav from "components/util/SelectNav";
|
||||
import { Nav } from "react-bootstrap";
|
||||
|
||||
type ChartType = "Score" | "Life";
|
||||
|
||||
export function ChunithmGraphsComponent({
|
||||
score,
|
||||
chart,
|
||||
song,
|
||||
}: {
|
||||
score: ScoreDocument<"chunithm:Single"> | PBScoreDocument<"chunithm:Single">;
|
||||
chart: ChartDocument<"chunithm:Single">;
|
||||
song: SongDocument<"chunithm">;
|
||||
}) {
|
||||
const [graph, setGraph] = useState<ChartType>("Score");
|
||||
const available =
|
||||
score.scoreData.optional.scoreGraph &&
|
||||
score.scoreData.optional.lifeGraph &&
|
||||
song.data.duration !== undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-12 d-flex justify-content-center">
|
||||
<Nav variant="pills">
|
||||
<SelectNav id="Score" value={graph} setValue={setGraph} disabled={!available}>
|
||||
Score
|
||||
</SelectNav>
|
||||
<SelectNav id="Life" value={graph} setValue={setGraph} disabled={!available}>
|
||||
Life
|
||||
</SelectNav>
|
||||
</Nav>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
{available ? (
|
||||
<GraphComponent
|
||||
type={graph}
|
||||
scoreData={score.scoreData}
|
||||
difficulty={chart.difficulty}
|
||||
song={song}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-center"
|
||||
style={{ height: "200px" }}
|
||||
>
|
||||
<span className="text-body-secondary">No charts available</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GraphComponent({
|
||||
scoreData,
|
||||
song,
|
||||
difficulty,
|
||||
type,
|
||||
}: {
|
||||
scoreData: ScoreData<"chunithm:Single">;
|
||||
song: SongDocument<"chunithm">;
|
||||
difficulty: Difficulties["chunithm:Single"];
|
||||
type: ChartType;
|
||||
}) {
|
||||
const values =
|
||||
type === "Score" ? scoreData.optional.scoreGraph! : scoreData.optional.lifeGraph!;
|
||||
return (
|
||||
<GekichuScoreChart
|
||||
height="360px"
|
||||
mobileHeight="175px"
|
||||
type={type}
|
||||
difficulty={difficulty}
|
||||
data={[
|
||||
{
|
||||
id: type,
|
||||
data: values.map((e, i) => ({ x: i, y: e })),
|
||||
},
|
||||
]}
|
||||
game="chunithm"
|
||||
duration={song.data.duration!}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import { IsScore } from "util/asserts";
|
||||
import { FormatGPTProfileRatingName, UppercaseFirst } from "util/misc";
|
||||
import { FormatGPTProfileRatingName } from "util/misc";
|
||||
import TimestampCell from "components/tables/cells/TimestampCell";
|
||||
import ScoreCoreCells from "components/tables/game-core-cells/ScoreCoreCells";
|
||||
import useScoreRatingAlg from "components/util/useScoreRatingAlg";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { ChartDocument, GetGPTString, PBScoreDocument, ScoreDocument } from "tachi-common";
|
||||
import {
|
||||
ChartDocument,
|
||||
GetGPTString,
|
||||
PBScoreDocument,
|
||||
ScoreDocument,
|
||||
SongDocument,
|
||||
} from "tachi-common";
|
||||
import { UGPTChartPBComposition } from "types/api-returns";
|
||||
import { SetState } from "types/react";
|
||||
import { UserContext } from "context/UserContext";
|
||||
@@ -68,6 +74,7 @@ export default function DocumentComponent({
|
||||
forceScoreData = false,
|
||||
pbData,
|
||||
chart,
|
||||
song,
|
||||
onScoreUpdate,
|
||||
}: {
|
||||
score: ScoreDocument | PBScoreDocument;
|
||||
@@ -81,14 +88,17 @@ export default function DocumentComponent({
|
||||
pbData: UGPTChartPBComposition;
|
||||
forceScoreData?: boolean;
|
||||
chart: ChartDocument;
|
||||
song: SongDocument;
|
||||
onScoreUpdate?: (sc: ScoreDocument) => void;
|
||||
GraphComponent?:
|
||||
| (({
|
||||
score,
|
||||
chart,
|
||||
song,
|
||||
}: {
|
||||
score: ScoreDocument | PBScoreDocument;
|
||||
chart: ChartDocument;
|
||||
song: SongDocument;
|
||||
}) => JSX.Element)
|
||||
| null;
|
||||
}) {
|
||||
@@ -111,7 +121,7 @@ export default function DocumentComponent({
|
||||
<div style={{ flex: 9 }}>
|
||||
<div className="row h-100 justify-content-center">
|
||||
{GraphComponent ? (
|
||||
<GraphComponent chart={chart} score={score} />
|
||||
<GraphComponent chart={chart} score={score} song={song} />
|
||||
) : (
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-center"
|
||||
|
||||
@@ -7,19 +7,22 @@ import {
|
||||
PBScoreDocument,
|
||||
ScoreData,
|
||||
ScoreDocument,
|
||||
SongDocument,
|
||||
} from "tachi-common";
|
||||
import OngekiScoreChart from "components/charts/OngekiScoreChart";
|
||||
import GekichuScoreChart from "components/charts/GekichuScoreChart";
|
||||
|
||||
type ChartTypes = "Score" | "Bells" | "Life";
|
||||
type ChartType = "Score" | "Bells" | "Life";
|
||||
|
||||
export function OngekiGraphsComponent({
|
||||
score,
|
||||
chart,
|
||||
song,
|
||||
}: {
|
||||
score: ScoreDocument<"ongeki:Single"> | PBScoreDocument<"ongeki:Single">;
|
||||
chart: ChartDocument<"ongeki:Single">;
|
||||
song: SongDocument<"ongeki">;
|
||||
}) {
|
||||
const [graph, setGraph] = useState<ChartTypes>("Score");
|
||||
const [graph, setGraph] = useState<ChartType>("Score");
|
||||
const available =
|
||||
score.scoreData.optional.scoreGraph &&
|
||||
score.scoreData.optional.bellGraph &&
|
||||
@@ -47,6 +50,7 @@ export function OngekiGraphsComponent({
|
||||
<GraphComponent
|
||||
type={graph}
|
||||
scoreData={score.scoreData}
|
||||
song={song}
|
||||
difficulty={chart.difficulty}
|
||||
/>
|
||||
) : (
|
||||
@@ -65,10 +69,12 @@ export function OngekiGraphsComponent({
|
||||
function GraphComponent({
|
||||
type,
|
||||
scoreData,
|
||||
song,
|
||||
difficulty,
|
||||
}: {
|
||||
type: ChartTypes;
|
||||
type: ChartType;
|
||||
scoreData: ScoreData<"ongeki:Single">;
|
||||
song: SongDocument<"ongeki">;
|
||||
difficulty: Difficulties["ongeki:Single"];
|
||||
}) {
|
||||
const values =
|
||||
@@ -78,7 +84,7 @@ function GraphComponent({
|
||||
? scoreData.optional.bellGraph!
|
||||
: scoreData.optional.lifeGraph!;
|
||||
return (
|
||||
<OngekiScoreChart
|
||||
<GekichuScoreChart
|
||||
height="360px"
|
||||
mobileHeight="175px"
|
||||
type={type}
|
||||
@@ -90,6 +96,8 @@ function GraphComponent({
|
||||
data: values.map((e, i) => ({ x: i, y: e })),
|
||||
},
|
||||
]}
|
||||
game="ongeki"
|
||||
duration={song.data.duration}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user