Merge pull request #75 from TNG-dev/release-v2.0.2

This commit is contained in:
zkldi
2021-12-13 19:11:01 +00:00
committed by GitHub
26 changed files with 786 additions and 239 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "tachi-client",
"version": "2.0.1",
"version": "2.0.2",
"private": true,
"scripts": {
"start": "node scripts/start.js -o",
@@ -82,7 +82,7 @@
"resolve": "1.12.0",
"resolve-url-loader": "3.1.0",
"socicon": "3.0.5",
"tachi-common": "0.3.10"
"tachi-common": "0.3.19"
},
"devDependencies": {
"@types/humanize-duration": "3.25.1",
+4 -4
View File
@@ -84,7 +84,7 @@ specifiers:
serve: 11.2.0
socicon: 3.0.5
style-loader: 1.0.0
tachi-common: 0.3.10
tachi-common: 0.3.19
terser-webpack-plugin: 1.4.1
ts-pnp: 1.1.4
typescript: 4.3.5
@@ -156,7 +156,7 @@ dependencies:
resolve: 1.12.0
resolve-url-loader: 3.1.0
socicon: 3.0.5
tachi-common: 0.3.10
tachi-common: 0.3.19
devDependencies:
'@types/humanize-duration': 3.25.1
@@ -11241,8 +11241,8 @@ packages:
strip-ansi: 6.0.0
dev: true
/tachi-common/0.3.10:
resolution: {integrity: sha512-OiEnRz8b7ix+m/W5bFwaN+0fgupmbYHKhsn4/IaFb8f2rCEHY/i3t8TGuYAx212Uo2hOsDSX5/yxVOrgcxZ1AA==}
/tachi-common/0.3.19:
resolution: {integrity: sha512-N3/n9emh2X9pA+3jQ1UfkLX9V8hk/oHGE1GPlkReRmyIR9gRAxhf3uZCiorPqibSkPzx9LrGLJcO52FR5NQ8oA==}
dependencies:
monk: 7.3.4
transitivePeerDependencies:
@@ -79,7 +79,7 @@ function ProfileLeaderboard({ game, playtype }: GamePT) {
) : null;
const { data, isLoading, error } = useApiQuery<UserLeaderboardReturns>(
`/games/${game}/${playtype}/leaderboard?alg=${alg}`
`/games/${game}/${playtype}/leaderboard?alg=${alg}&limit=500`
);
if (error) {
@@ -175,7 +175,7 @@ function AchievedClassRow({
playtype={playtype}
classValue={classInfo.classValue}
/>
{classInfo.classOldValue && (
{classInfo.classOldValue !== null && (
<>
{" "}
(Raised from{" "}
@@ -172,6 +172,7 @@ function PreferencesForm({ reqUser }: { reqUser: PublicUserDocument }) {
invisible: settings?.preferences.invisible ?? false,
contentiousContent: settings?.preferences.contentiousContent ?? false,
advancedMode: settings?.preferences.advancedMode ?? false,
deletableScores: settings?.preferences.deletableScores ?? false,
},
onSubmit: async values => {
const res = await APIFetchV1<UserSettings>(
@@ -224,6 +225,19 @@ function PreferencesForm({ reqUser }: { reqUser: PublicUserDocument }) {
/>
<Form.Text>Hide your last seen status.</Form.Text>
</Form.Group> */}
<Form.Group>
<Form.Check
type="checkbox"
id="deletableScores"
checked={formik.values.deletableScores}
onChange={formik.handleChange}
label="Deletable Scores"
/>
<Form.Text>
Enables the option to delete scores. Turn this off if the thought of deleting a
score gives you anxiety.
</Form.Text>
</Form.Group>
<Form.Group>
<Form.Check
type="checkbox"
@@ -12,6 +12,7 @@ import {
COLOUR_SET,
FormatGame,
GetGameConfig,
GetGamePTConfig,
IDStrings,
integer,
PublicUserDocument,
@@ -92,6 +93,8 @@ function LeaderboardsPageContent({
} & GamePT) {
const { stats, leaderboard } = data;
const gptConfig = GetGamePTConfig(game, playtype);
const userMap = new Map<integer, PublicUserDocument>();
for (const u of stats.users) {
@@ -131,7 +134,13 @@ function LeaderboardsPageContent({
{userMap.get(s.userID)?.username}
</GentleLink>
</td>
<td>{(s.ratings[alg] ?? 0).toFixed(2)}</td>
<td>
{s.ratings[alg]
? gptConfig.profileRatingAlgFormatters[alg]
? gptConfig.profileRatingAlgFormatters[alg]!(s.ratings[alg]!)
: s.ratings[alg]!.toFixed(2)
: "No Data."}
</td>
{/* temp */}
<td>
{Object.entries(s.classes).length
@@ -10,6 +10,7 @@ import useQueryString from "components/util/useQueryString";
import { UGPTSettingsContext } from "context/UGPTSettingsContext";
import deepmerge from "deepmerge";
import { useFormik } from "formik";
import { TachiConfig } from "lib/config";
import React, { useContext, useEffect, useState } from "react";
import { Button, Form } from "react-bootstrap";
import {
@@ -83,6 +84,7 @@ function PreferencesForm({ reqUser, game, playtype }: Props) {
preferredSessionAlg:
settings!.preferences.preferredSessionAlg || gptConfig.defaultSessionRatingAlg,
gameSpecific: settings!.preferences.gameSpecific,
scoreBucket: settings!.preferences.scoreBucket ?? gptConfig.scoreBucket,
},
onSubmit: async values => {
const rj = await APIFetchV1<PublicUserDocument>(
@@ -157,6 +159,24 @@ function PreferencesForm({ reqUser, game, playtype }: Props) {
used for things like leaderboards.
</Form.Text>
</Form.Group>
<Form.Group>
<Form.Label>Preferred Score Info</Form.Label>
<Form.Control
as="select"
id="scoreBucket"
value={formik.values.scoreBucket}
onChange={formik.handleChange}
>
<option value="grade">Score</option>
<option value="lamp">Lamp</option>
</Form.Control>
<Form.Text className="text-muted">
What should {TachiConfig.name} prefer to show you about scores?
<br />
Note: This will only affect defaults, such as what graph is shown in the folder
breakdown. You can still view all the same stats!
</Form.Text>
</Form.Group>
{game === "iidx" && (
<Form.Group>
<Form.Check
@@ -182,13 +202,13 @@ function PreferencesForm({ reqUser, game, playtype }: Props) {
}
function ShowcaseForm({ reqUser, game, playtype }: Props) {
const { settings } = useContext(UGPTSettingsContext);
const { settings, setSettings } = useContext(UGPTSettingsContext);
const [stats, setStats] = useState(settings!.preferences.stats);
const [show, setShow] = useState(false);
const SaveChanges = async () => {
await APIFetchV1(
const r = await APIFetchV1<UGPTSettings>(
`/users/${reqUser.id}/games/${game}/${playtype}/showcase`,
{
method: "PUT",
@@ -198,6 +218,10 @@ function ShowcaseForm({ reqUser, game, playtype }: Props) {
true,
true
);
if (r.success) {
setSettings(r.body);
}
};
const [isFirstPaint, setIsFirstPaint] = useState(true);
@@ -1,5 +1,6 @@
import { ResponsiveBar } from "@nivo/bar";
import { BarChartTooltip } from "components/charts/ChartTooltip";
import DistributionTable from "components/game/folder/FolderDistributionTable";
import Card from "components/layout/page/Card";
import MiniTable from "components/tables/components/MiniTable";
import DebounceSearch from "components/util/DebounceSearch";
@@ -10,6 +11,7 @@ import Loading from "components/util/Loading";
import Muted from "components/util/Muted";
import useApiQuery from "components/util/query/useApiQuery";
import SelectButton from "components/util/SelectButton";
import { useBucket } from "components/util/useBucket";
import useUGPTBase from "components/util/useUGPTBase";
import { UserContext } from "context/UserContext";
import React, { useContext, useMemo, useState } from "react";
@@ -89,7 +91,9 @@ export function FolderInfoComponent({
}: Props & { folder: FolderDocument; folderStats: FolderStatsInfo }) {
const gptConfig = GetGamePTConfig(game, playtype);
const [elements, setElements] = useState<"grade" | "lamp">(gptConfig.scoreBucket);
const scoreBucket = useBucket(game, playtype);
const [elements, setElements] = useState<"grade" | "lamp">(scoreBucket);
const base = useUGPTBase({ reqUser, game, playtype });
@@ -160,94 +164,3 @@ export function FolderInfoComponent({
</div>
);
}
function DistributionTable<T extends string>({
keys,
values,
colours,
max,
}: {
keys: T[];
values: Record<T, integer>;
colours: Record<T, string>;
max: integer;
}) {
const cumulativeValues: Record<T, integer> = {} as Record<T, integer>;
let total = 0;
for (const k of keys) {
total += values[k] ?? 0;
cumulativeValues[k] = total;
}
return (
<MiniTable headers={["Value", "Count (Total)", "Thermometer"]}>
{keys.map((k, i) => (
<tr key={k}>
<td style={{ backgroundColor: ChangeOpacity(colours[k], 0.15) }}>{k}</td>
<td>
{values[k] ?? 0} <Muted>({cumulativeValues[k]})</Muted>
</td>
{i === 0 && (
<FolderThermometer
keys={keys}
values={values}
max={max}
colours={colours}
/>
)}
</tr>
))}
</MiniTable>
);
}
function FolderThermometer<T extends string>({
keys,
values,
max,
colours,
}: {
keys: T[];
values: Record<T, integer>;
colours: Record<T, string>;
max: integer;
}) {
return (
<td rowSpan={keys.length} style={{ width: 200, height: 40 * keys.length }}>
<ResponsiveBar
keys={Reverse(keys)}
data={[Object.assign({ id: "" }, values)]}
theme={Object.assign({}, TACHI_CHART_THEME, { background: "#1c1c1c" })}
// @ts-expect-error temp
colors={k => ChangeOpacity(colours[k.id], 0.5)}
// @ts-expect-error Keys are appended with "." for some reason.
borderColor={k => colours[k.data.id]}
labelTextColor="black"
labelSkipHeight={12}
maxValue={max}
padding={0.33}
borderWidth={1}
valueScale={{ type: "linear" }}
axisRight={{
tickSize: 5,
tickPadding: 5,
tickValues: StepFromToMax(max),
}}
margin={{ right: 50, bottom: 10, top: 20 }}
tooltip={d => (
<BarChartTooltip
point={d}
renderFn={d => (
<div className="w-100 text-center">
{d.label}
{d.value} ({PercentFrom(d.value, max)})
</div>
)}
/>
)}
motionConfig="stiff"
/>
</td>
);
}
@@ -10,6 +10,7 @@ import Loading from "components/util/Loading";
import Muted from "components/util/Muted";
import useApiQuery from "components/util/query/useApiQuery";
import SelectButton from "components/util/SelectButton";
import { useBucket } from "components/util/useBucket";
import { UserContext } from "context/UserContext";
import React, { useContext, useEffect, useMemo, useState } from "react";
import { Form, InputGroup } from "react-bootstrap";
@@ -218,10 +219,9 @@ function TableBarChart({
dataMap: Map<string, UGPTFolderStats>;
}) {
const gptConfig = GetGamePTConfig(table.game, table.playtype);
const bucket = useBucket(table.game, table.playtype);
const [mode, setMode] = useState<"grades" | "lamps">(
gptConfig.scoreBucket === "grade" ? "grades" : "lamps"
);
const [mode, setMode] = useState<"grades" | "lamps">(bucket === "grade" ? "grades" : "lamps");
const colours = useMemo(() => {
if (mode === "grades") {
@@ -254,7 +254,10 @@ function TableBarChart({
}
arr.push({
folder: data.folder.title,
folder:
data.folder.title.length > 10
? `${data.folder.title.slice(0, 10)}...`
: data.folder.title,
...realData,
});
}
@@ -327,7 +330,11 @@ function OverviewBarChart({
}) {
const longestTitle = useMemo(
() =>
dataset.map((e: any) => e.folder).sort(NumericSOV<string>(x => x.length, true))[0] ?? 0,
Math.min(
dataset.map((e: any) => e.folder).sort(NumericSOV<string>(x => x.length, true))[0]
?.length ?? 0,
10
),
[dataset]
);
@@ -360,7 +367,7 @@ function OverviewBarChart({
minValue={0}
maxValue={100}
valueFormat={v => `${v.toFixed(2)}%`}
margin={{ left: 10 + longestTitle.length * 7, bottom: 50, top: 20, right: 20 }}
margin={{ left: 10 + longestTitle * 8, bottom: 50, top: 20, right: 20 }}
layout="horizontal"
axisBottom={{
tickSize: 5,
@@ -1,5 +1,6 @@
import { ResponsiveBar } from "@nivo/bar";
import { BarChartTooltip } from "components/charts/ChartTooltip";
import FolderInfoHeader from "components/game/folder/FolderInfoHeader";
import QuickTooltip from "components/layout/misc/QuickTooltip";
import Card from "components/layout/page/Card";
import FolderTable from "components/tables/folders/FolderTable";
@@ -10,6 +11,7 @@ import Loading from "components/util/Loading";
import Muted from "components/util/Muted";
import useApiQuery from "components/util/query/useApiQuery";
import SelectButton from "components/util/SelectButton";
import { useBucket } from "components/util/useBucket";
import { useFormik } from "formik";
import { nanoid } from "nanoid";
import React, { useEffect, useMemo, useState } from "react";
@@ -93,7 +95,7 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
return (
<FolderInfoHeader
folderDataset={folderDataset}
data={data}
folderTitle={data.folder.title}
game={game}
playtype={playtype}
reqUser={reqUser}
@@ -111,9 +113,6 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
return (
<div className="row">
<div className="col-12">
<Divider className="mb-4" />
</div>
<div className="col-12">{folderInfoHeader}</div>
<div className="col-12">
<Divider />
@@ -658,107 +657,3 @@ function FolderDatasetToTierlistInfo(
return tierlistInfo.sort(NumericSOV(x => x.data.value, true));
}
function FolderInfoHeader({ game, playtype, reqUser, folderDataset, data }: InfoProps) {
return (
<Card header={`${reqUser.username}'s ${data.folder.title} Breakdown`}>
<ScoreDistributionChart
game={game}
data={data}
folderDataset={folderDataset}
playtype={playtype}
reqUser={reqUser}
/>
</Card>
);
}
function ScoreDistributionChart({ game, playtype, folderDataset }: InfoProps) {
const dataMap = CreateChartIDMap(folderDataset);
const gptConfig = GetGamePTConfig(game, playtype);
const dataset = [];
const expScale = GetGradeChartExpScale(game);
const expFn = ComposeExpFn(expScale);
const invExpFn = ComposeInverseExpFn(expScale);
for (const data of folderDataset) {
const value = data.__related.pb ? expFn(data.__related.pb.scoreData.percent) : 0;
dataset.push({
chartID: data.chartID,
expValue: expFn(value),
value,
grade: data.__related.pb?.scoreData.grade,
});
}
dataset.sort(NumericSOV(x => x.expValue));
return (
<div style={{ height: 400 }}>
<ResponsiveBar
indexBy="chartID"
tooltip={d => (
<BarChartTooltip
point={d}
renderFn={d => {
const data = dataMap.get(d.indexValue as string)!;
return (
<div className="w-100 text-center">
{data.__related.song.title}
<br />
{data.__related.pb?.scoreData.percent.toFixed(2)}%
</div>
);
}}
/>
)}
key={"value"}
colors={k => {
if (!k.data.grade) {
return "black";
}
// @ts-expect-error temp
return ChangeOpacity(gptConfig.gradeColours[k.data.grade], 0.5);
}}
// @ts-expect-error temp
borderColor={k => gptConfig.gradeColours[k.data.grade]}
borderWidth={1}
padding={0.2}
// @ts-expect-error temp
data={dataset}
minValue={0}
maxValue={expFn(100)}
margin={{ left: 50, top: 20, bottom: 20 }}
valueFormat={e => `${invExpFn(e).toFixed(2)}%`}
axisLeft={{
tickValues: gptConfig.gradeBoundaries.map(e => (e === 0 ? 0 : expFn(e))),
format: x => {
let nearest;
const lgv = invExpFn(x);
for (const [i, gradeBnd] of gptConfig.gradeBoundaries.entries()) {
if (Math.abs(gradeBnd - lgv) < 0.00005) {
nearest = i;
break;
}
}
if (nearest === undefined) {
return null;
}
return gptConfig.grades[nearest];
},
}}
axisBottom={null}
{...DEFAULT_BAR_PROPS}
labelSkipWidth={40}
/>
</div>
);
}
+9
View File
@@ -164,6 +164,7 @@ function SongChartRoutes({ game, playtype }: GamePT) {
useEffect(() => {
setActiveChart(null);
}, [game, playtype]);
if (error) {
return <ErrorPage statusCode={error.statusCode} customMessage={error.description} />;
}
@@ -172,6 +173,14 @@ function SongChartRoutes({ game, playtype }: GamePT) {
return <Loading />;
}
if (data.charts.every(c => c.playtype !== playtype)) {
return (
<Redirect
to={`/dashboard/games/${game}/${data.charts[0].playtype}/songs/${data.song.id}`}
/>
);
}
return (
<>
<SongInfoHeader
@@ -0,0 +1,100 @@
import { ResponsiveBar } from "@nivo/bar";
import { BarChartTooltip } from "components/charts/ChartTooltip";
import MiniTable from "components/tables/components/MiniTable";
import Muted from "components/util/Muted";
import React from "react";
import { integer } from "tachi-common";
import { ChangeOpacity } from "util/color-opacity";
import { TACHI_CHART_THEME } from "util/constants/chart-theme";
import { Reverse, StepFromToMax, PercentFrom } from "util/misc";
export default function FolderDistributionTable<T extends string>({
keys,
values,
colours,
max,
}: {
keys: T[];
values: Record<T, integer>;
colours: Record<T, string>;
max: integer;
}) {
const cumulativeValues: Record<T, integer> = {} as Record<T, integer>;
let total = 0;
for (const k of keys) {
total += values[k] ?? 0;
cumulativeValues[k] = total;
}
return (
<MiniTable headers={["Value", "Count (Total)", "Thermometer"]}>
{keys.map((k, i) => (
<tr key={k}>
<td style={{ backgroundColor: ChangeOpacity(colours[k], 0.15) }}>{k}</td>
<td>
{values[k] ?? 0} <Muted>({cumulativeValues[k]})</Muted>
</td>
{i === 0 && (
<FolderThermometer
keys={keys}
values={values}
max={max}
colours={colours}
/>
)}
</tr>
))}
</MiniTable>
);
}
function FolderThermometer<T extends string>({
keys,
values,
max,
colours,
}: {
keys: T[];
values: Record<T, integer>;
colours: Record<T, string>;
max: integer;
}) {
return (
<td rowSpan={keys.length} style={{ width: 200, height: 40 * keys.length }}>
<ResponsiveBar
keys={Reverse(keys)}
data={[Object.assign({ id: "" }, values)]}
theme={Object.assign({}, TACHI_CHART_THEME, { background: "#1c1c1c" })}
// @ts-expect-error temp
colors={k => ChangeOpacity(colours[k.id], 0.5)}
// @ts-expect-error Keys are appended with "." for some reason.
borderColor={k => colours[k.data.id]}
labelTextColor="black"
labelSkipHeight={12}
maxValue={max}
padding={0.33}
borderWidth={1}
valueScale={{ type: "linear" }}
axisRight={{
tickSize: 5,
tickPadding: 5,
tickValues: StepFromToMax(max),
}}
margin={{ right: 50, bottom: 10, top: 20 }}
tooltip={d => (
<BarChartTooltip
point={d}
renderFn={d => (
<div className="w-100 text-center">
{d.label}
{d.value} ({PercentFrom(d.value, max)})
</div>
)}
/>
)}
motionConfig="stiff"
/>
</td>
);
}
@@ -0,0 +1,74 @@
import Card from "components/layout/page/Card";
import Divider from "components/util/Divider";
import Icon from "components/util/Icon";
import SelectButton from "components/util/SelectButton";
import { useBucket } from "components/util/useBucket";
import React, { useState } from "react";
import { PublicUserDocument } from "tachi-common";
import { GamePT } from "types/react";
import { FolderDataset } from "types/tables";
import FolderMinimap from "./FolderMinimap";
import FolderScoreDistributionChart from "./FolderScoreDistributionChart";
export default function FolderInfoHeader({
game,
playtype,
reqUser,
folderDataset,
folderTitle,
}: {
reqUser: PublicUserDocument;
folderDataset: FolderDataset;
folderTitle: string;
} & GamePT) {
const scoreBucket = useBucket(game, playtype);
const [view, setView] = useState<"grade" | "lamp">(scoreBucket);
const [breakdownInfo, setBreakdownInfo] = useState<"graph" | "switchboard">("switchboard");
return (
<Card header={`${reqUser.username}'s ${folderTitle} Breakdown`}>
<div className="col-12 d-flex justify-content-center">
<div className="btn-group">
<SelectButton value={view} setValue={setView} id="grade">
<Icon type="chart-area" />
Score Info
</SelectButton>
<SelectButton value={view} setValue={setView} id="lamp">
<Icon type="lightbulb" />
Lamp Info
</SelectButton>
</div>
</div>
<div className="col-12 mt-4 d-flex justify-content-center">
<select
className="form-control"
value={breakdownInfo}
onChange={e => setBreakdownInfo(e.target.value as any)}
>
<option value="graph">Thermometer</option>
<option value="switchboard">Switchboard</option>
</select>
</div>
<div className="col-12">
<Divider />
</div>
{breakdownInfo === "graph" ? (
<FolderScoreDistributionChart
game={game}
folderDataset={folderDataset}
playtype={playtype}
view={view}
/>
) : (
<FolderMinimap
reqUser={reqUser}
game={game}
playtype={playtype}
folderDataset={folderDataset}
view={view}
/>
)}
</Card>
);
}
@@ -0,0 +1,236 @@
import QuickTooltip from "components/layout/misc/QuickTooltip";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import Loading from "components/util/Loading";
import useApiQuery from "components/util/query/useApiQuery";
import React, { useEffect, useMemo, useState } from "react";
import { Form } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
ChartDocument,
FormatChart,
Game,
GamePTConfig,
GetGamePTConfig,
IIDX_LAMPS,
PublicUserDocument,
SessionDocument,
} from "tachi-common";
import { GamePT } from "types/react";
import { FolderDataset } from "types/tables";
import { ChangeOpacity } from "util/color-opacity";
import { CreateChartLink } from "util/data";
import { NumericSOV } from "util/sorts";
type Props = {
folderDataset: FolderDataset;
reqUser: PublicUserDocument;
view: "grade" | "lamp";
} & GamePT;
export default function FolderMinimap(props: Props) {
const { data, isLoading, error } = useApiQuery<SessionDocument>(
`/users/${props.reqUser.id}/games/${props.game}/${props.playtype}/sessions/last`
);
const session = useMemo(() => {
if (error && error.statusCode === 404) {
return null;
} else if (data) {
return data;
}
return null;
}, [data, error]);
if (error && error.statusCode !== 404) {
return <ApiError error={error} />;
}
if (isLoading || !data) {
return <Loading />;
}
return (
<>
<div className="d-none d-lg-block">
<FolderMinimapMain {...props} recentSession={session} />
</div>
<div className="d-block d-lg-none">
Sadly, Switchboard view doesn't work very well on mobile at the moment.
</div>
</>
);
}
function FolderMinimapMain({
game,
playtype,
folderDataset,
view,
recentSession,
}: Props & { recentSession: SessionDocument | null }) {
const gptConfig = GetGamePTConfig(game, playtype);
const [sortAlg, setSortAlg] = useState(view === "grade" ? "score" : "lamp");
useEffect(() => {
if (view === "grade") {
setSortAlg("score");
} else {
setSortAlg("lamp");
}
}, [view]);
const getterFn = useMemo<(f: FolderDataset[0]) => number | undefined>(() => {
if (sortAlg === "score") {
return c => c.__related.pb?.scoreData.percent;
} else if (sortAlg === "lamp") {
return c => c.__related.pb?.scoreData.lampIndex;
} else if (sortAlg.startsWith("tierlist:")) {
const v = sortAlg.split("tierlist:")[1];
return c => c.tierlistInfo[v as keyof ChartDocument["tierlistInfo"]]?.value;
}
return () => 0;
}, [sortAlg]);
const sortedDataset = useMemo(
() => folderDataset.slice(0).sort(NumericSOV(a => getterFn(a) ?? -Infinity, true)),
[getterFn, folderDataset, sortAlg]
);
const recentlyTouched = useMemo(() => {
if (!recentSession) {
return [];
}
return recentSession.scoreInfo
.filter(e => e.isNewScore || e.gradeDelta > 0 || e.lampDelta > 0)
.map(e => e.scoreID);
}, [recentSession]);
return (
<div className="row">
<div className="col-12 col-lg-10 offset-lg-1">
<div className="scoreinfo-grid-minimap">
{sortedDataset.map(d => (
<MinimapElement
key={d.chartID}
data={d}
gptConfig={gptConfig}
view={view}
game={game}
wasRecent={
(d.__related.pb &&
recentlyTouched.some(
scoreID =>
d.__related.pb!.composedFrom.scorePB === scoreID ||
d.__related.pb!.composedFrom.lampPB === scoreID
)) ??
false
}
/>
))}
</div>
<Divider />
<Form.Group>
<Form.Label>Sorting charts on:</Form.Label>
<Form.Control
as="select"
value={sortAlg}
onChange={e => setSortAlg(e.target.value)}
>
{view === "grade" && <option value="score">Your Score</option>}
{view === "lamp" && <option value="lamp">Your Lamp</option>}
{gptConfig.tierlists.map(e => (
<option key={`tierlist:${e}`} value={`tierlist:${e}`}>
Tierlist: {e}
</option>
))}
</Form.Control>
</Form.Group>
</div>
</div>
);
}
function MinimapElement({
data,
gptConfig,
view,
game,
wasRecent,
}: {
data: FolderDataset[0];
view: "grade" | "lamp";
gptConfig: GamePTConfig;
game: Game;
wasRecent: boolean;
}) {
let icon = "level-up-alt";
// if this user recently cleared mare nectaris
// tell them to fuck off
if (
data.chartID === "924bf011fdd8334b609b02e382123f9f5440d16d" &&
data.__related.pb &&
data.__related.pb?.scoreData.lampIndex >= IIDX_LAMPS.EASY_CLEAR
) {
icon = "hand-middle-finger";
}
const colour = useMemo(() => {
if (!data.__related.pb) {
return null;
}
// @ts-expect-error unhinged dynamic access (i dont care)
return gptConfig[`${view}Colours`][data.__related.pb.scoreData[view]];
}, [data.__related.pb, gptConfig, view]);
return (
<QuickTooltip
tooltipContent={
<div>
<span>{FormatChart(game, data.__related.song, data)}</span>
<Divider />
{wasRecent && (
<>
<b>You raised this recently!</b>
<br />
</>
)}
<span>{data.__related.pb?.scoreData.lamp ?? "Not Played"}</span>
{data.__related.pb && (
<>
<br />
{data.__related.pb.scoreData.grade}
<br />
<span>
{data.__related.pb.scoreData.score} (
{data.__related.pb.scoreData.percent.toFixed(2)}%)
</span>
</>
)}
</div>
}
>
<Link to={CreateChartLink(data, game)}>
<div
className={`scoreinfo-grid-minimap-element ${
wasRecent ? "scoreinfo-grid-minimap-element-recent" : ""
}`}
style={{
backgroundColor: colour ? ChangeOpacity(colour, 0.3) : undefined,
}}
>
{wasRecent && (
<span className={`fas fa-${icon}`} style={{ lineHeight: "19.5px" }}></span>
)}
</div>
</Link>
</QuickTooltip>
);
}
@@ -0,0 +1,157 @@
import { ResponsiveBar } from "@nivo/bar";
import { BarChartTooltip } from "components/charts/ChartTooltip";
import React, { useMemo } from "react";
import { GetGamePTConfig } from "tachi-common";
import { GamePT } from "types/react";
import { FolderDataset } from "types/tables";
import { DEFAULT_BAR_PROPS } from "util/charts";
import { ChangeOpacity } from "util/color-opacity";
import { CreateChartIDMap } from "util/data";
import { ComposeExpFn, ComposeInverseExpFn, CountElements, Reverse } from "util/misc";
import { GetGradeChartExpScale } from "util/scales";
import { NumericSOV } from "util/sorts";
import FolderDistributionTable from "./FolderDistributionTable";
type Props = {
folderDataset: FolderDataset;
view: "grade" | "lamp";
} & GamePT;
export default function FolderScoreDistributionChart({
game,
playtype,
folderDataset,
view,
}: Props) {
const gptConfig = GetGamePTConfig(game, playtype);
const values = useMemo(() => {
if (view === "grade") {
return CountElements(folderDataset, x => x.__related.pb?.scoreData.grade ?? null);
}
return CountElements(folderDataset, x => x.__related.pb?.scoreData.lamp ?? null);
}, [folderDataset, view]);
return (
<div className="row">
<div className="col-12 col-lg-6 offset-lg-3">
{view === "grade" ? (
<FolderDistributionTable
colours={gptConfig.gradeColours}
keys={Reverse(gptConfig.grades)}
max={folderDataset.length}
values={values}
/>
) : (
<FolderDistributionTable
colours={gptConfig.lampColours}
keys={Reverse(gptConfig.lamps)}
max={folderDataset.length}
values={values}
/>
)}
</div>
{/* <div className="col-12 col-lg-8">
<ScoreDistributionChart {...{ game, playtype, folderDataset, view }} />
</div> */}
</div>
);
}
function ScoreDistributionChart({ game, folderDataset, playtype, view }: Props) {
if (view === "grade") {
return <ScoreBarChart game={game} playtype={playtype} folderDataset={folderDataset} />;
}
return <>LAMP TODO</>;
}
function ScoreBarChart({ game, playtype, folderDataset }: Omit<Props, "view">) {
const dataMap = CreateChartIDMap(folderDataset);
const gptConfig = GetGamePTConfig(game, playtype);
const expScale = GetGradeChartExpScale(game);
const expFn = ComposeExpFn(expScale);
const invExpFn = ComposeInverseExpFn(expScale);
const dataset = [];
for (const data of folderDataset) {
const value = data.__related.pb ? expFn(data.__related.pb.scoreData.percent) : 0;
dataset.push({
chartID: data.chartID,
expValue: expFn(value),
value,
grade: data.__related.pb?.scoreData.grade,
});
}
dataset.sort(NumericSOV(x => x.expValue));
return (
<div style={{ height: 400 }}>
<ResponsiveBar
indexBy="chartID"
tooltip={d => (
<BarChartTooltip
point={d}
renderFn={d => {
const data = dataMap.get(d.indexValue as string)!;
return (
<div className="w-100 text-center">
{data.__related.song.title}
<br />
{data.__related.pb?.scoreData.percent.toFixed(2)}%
</div>
);
}}
/>
)}
key={"value"}
colors={k => {
if (!k.data.grade) {
return "black";
}
// @ts-expect-error temp
return ChangeOpacity(gptConfig.gradeColours[k.data.grade], 0.5);
}}
// @ts-expect-error temp
borderColor={k => gptConfig.gradeColours[k.data.grade]}
borderWidth={1}
padding={0.2}
// @ts-expect-error temp
data={dataset}
minValue={0}
maxValue={expFn(100)}
margin={{ left: 50, top: 20, bottom: 20 }}
valueFormat={e => `${invExpFn(e).toFixed(2)}%`}
axisLeft={{
tickValues: gptConfig.gradeBoundaries.map(e => (e === 0 ? 0 : expFn(e))),
format: x => {
let nearest;
const lgv = invExpFn(x);
for (const [i, gradeBnd] of gptConfig.gradeBoundaries.entries()) {
if (Math.abs(gradeBnd - lgv) < 0.00005) {
nearest = i;
break;
}
}
if (nearest === undefined) {
return null;
}
return gptConfig.grades[nearest];
},
}}
axisBottom={null}
{...DEFAULT_BAR_PROPS}
labelSkipWidth={40}
/>
</div>
);
}
@@ -22,6 +22,7 @@ import useApiQuery from "components/util/query/useApiQuery";
import HasDevModeOn from "components/util/HasDevModeOn";
import PBCompare from "./components/PBCompare";
import { UserSettingsContext } from "context/UserSettingsContext";
import ManageScore from "./components/ManageScore";
export interface ScoreState {
highlight: boolean;
@@ -46,7 +47,7 @@ export default function GenericScoreDropdown<I extends IDStrings = IDStrings>({
user: PublicUserDocument;
chart: ChartDocument;
scoreState: ScoreState;
defaultView?: "vsPB" | "moreInfo" | "history" | "debug";
defaultView?: "vsPB" | "moreInfo" | "history" | "debug" | "manage";
thisScore: ScoreDocument;
DocComponent?: (props: {
score: ScoreDocument<I> | PBScoreDocument<I>;
@@ -55,6 +56,8 @@ export default function GenericScoreDropdown<I extends IDStrings = IDStrings>({
}) => JSX.Element;
} & GamePT) {
const [view, setView] = useState(defaultView);
const { user: currentUser } = useContext(UserContext);
const { settings } = useContext(UserSettingsContext);
const { isLoading, error, data } = useApiQuery<UGPTChartPBComposition<I>>(
`/users/${user.id}/games/${game}/${playtype}/pbs/${chart.chartID}?getComposition=true`
@@ -94,6 +97,8 @@ export default function GenericScoreDropdown<I extends IDStrings = IDStrings>({
body = <DocComponent score={thisScore as any} scoreState={scoreState} pbData={data} />;
} else if (view === "vsPB") {
body = <PBCompare data={data} DocComponent={DocComponent} scoreState={scoreState} />;
} else if (view === "manage") {
body = <ManageScore score={thisScore} />;
}
return (
@@ -112,6 +117,12 @@ export default function GenericScoreDropdown<I extends IDStrings = IDStrings>({
<Icon type="history" />
Play History
</SelectButton>
{currentUser?.id === user.id && settings?.preferences.deletableScores && (
<SelectButton setValue={setView} value={view} id="manage">
<Icon type="trash" />
Manage Score
</SelectButton>
)}
<HasDevModeOn>
<SelectButton setValue={setView} value={view} id="debug">
<Icon type="bug" />
@@ -1,38 +1,33 @@
import DeltaCell from "components/tables/cells/DeltaCell";
import LampCell from "components/tables/cells/LampCell";
import ScoreCell from "components/tables/cells/ScoreCell";
import TimestampCell from "components/tables/cells/TimestampCell";
import ExternalLink from "components/util/ExternalLink";
import ScoreCoreCells from "components/tables/game-core-cells/ScoreCoreCells";
import useScoreRatingAlg from "components/util/useScoreRatingAlg";
import React, { useEffect, useState } from "react";
import { PBScoreDocument, ScoreDocument } from "tachi-common";
import { UGPTChartPBComposition } from "types/api-returns";
import { SetState } from "types/react";
import { IsScore } from "util/asserts";
import { UppercaseFirst } from "util/misc";
import CommentContainer from "./CommentContainer";
import JudgementTable from "./JudgementTable";
import PBNote from "./PBNote";
import ScoreEditButtons from "./ScoreEditButtons";
export function ScoreInfo({ score }: { score: ScoreDocument | PBScoreDocument }) {
const rating = useScoreRatingAlg(score.game, score.playtype);
return (
<div className="col-12">
<table className="table">
<thead>
<tr>
<td colSpan={100}>Score Info</td>
<td colSpan={3}>Score Info</td>
<td>{UppercaseFirst(rating)}</td>
<td>Timestamp</td>
</tr>
</thead>
<tbody>
<tr>
<ScoreCell score={score} />
<DeltaCell
game={score.game}
playtype={score.playtype}
score={score.scoreData.score}
percent={score.scoreData.percent}
grade={score.scoreData.grade}
/>
<LampCell score={score} />
<ScoreCoreCells game={score.game} score={score} rating={rating} />
{/* @ts-expect-error yeah we know service doesnt necessarily exist */}
<TimestampCell time={score.timeAchieved} service={score?.service} />
</tr>
@@ -0,0 +1,45 @@
import React, { useMemo, useReducer, useState } from "react";
import { ScoreDocument } from "tachi-common";
import { APIFetchV1 } from "util/api";
import { DelayedPageReload } from "util/misc";
export default function ManageScore({ score }: { score: ScoreDocument }) {
const [warn, upgWarn] = useReducer(r => r + 1, 0);
const message = useMemo(() => {
if (warn === 0) {
return "Delete Score (Requires Further Confirmation)";
} else if (warn === 1) {
return "Are you absolutely sure? This score will be gone. Permanently.";
} else if (warn === 2) {
return "I'm serious. You will lose this score. It will be gone. Are you REALLY sure you want to do this?";
} else if (warn === 3) {
return "OK. Click me one last time, then.";
}
return "lol unknown state";
}, [warn]);
return (
<div className="d-flex w-100 justify-content-center">
<div
className="btn btn-danger"
onClick={() => {
if (warn < 3) {
upgWarn();
} else {
APIFetchV1(
`/scores/${score.scoreID}`,
{
method: "DELETE",
},
true,
true
).then(() => DelayedPageReload());
}
}}
>
{message}
</div>
</div>
);
}
@@ -41,7 +41,7 @@ export default function ScoreEditButtons({
onClick={() => setShow(true)}
>
<Icon type="file-signature" />
Edit
Comment
</Button>
</QuickTooltip>
</>
@@ -49,7 +49,7 @@ export default function ScoreEditButtons({
<QuickTooltip tooltipContent="Comment on this score.">
<Button variant="outline-secondary" onClick={() => setShow(true)}>
<Icon type="file-signature" />
Edit
Comment
</Button>
</QuickTooltip>
)}
@@ -25,7 +25,7 @@ import { UGPTPreferenceStatsReturn } from "types/api-returns";
import { GamePT } from "types/react";
import { Playtype } from "types/tachi";
import { APIFetchV1 } from "util/api";
import { UppercaseFirst } from "util/misc";
import { ToPercent, UppercaseFirst } from "util/misc";
import UGPTStatContainer from "./UGPTStatContainer";
import UGPTStatCreator from "./UGPTStatCreator";
@@ -424,15 +424,22 @@ export function StatDisplay({
header={<h5 className="text-muted mb-0">Folder</h5>}
>
<>
<h4>{headerStr}</h4>
<Link
className="gentle-link"
to={`/dashboard/users/${reqUser.id}/games/${game}/${playtype}/folders/${folders[0].folderID}`}
>
<h4>{headerStr}</h4>
</Link>
<h5>
{UppercaseFirst(stat.property)} &gt;={" "}
{FormatPropertyGTE(game, playtype, stat.property, stat.gte)}
</h5>
<h4>
{result.value}
{/* @ts-expect-error temp */}
<small className="text-muted">/{result.outOf}</small>
<small className="text-muted">
{/* @ts-expect-error This property definitely exists.*/}
{/* */}/{result.outOf} ({ToPercent(result.value, result.outOf)})
</small>
</h4>
{user && user.id !== reqUser.id && (
@@ -3,6 +3,7 @@ import CenterLayoutPage from "../layout/CenterLayoutPage";
import DebugContent from "./DebugContent";
import Divider from "./Divider";
import ExternalLink from "./ExternalLink";
import Muted from "./Muted";
export default class ErrorBoundary extends React.Component<any, { error: any; info: any }> {
constructor(props: any) {
@@ -32,6 +33,11 @@ export default class ErrorBoundary extends React.Component<any, { error: any; in
/>
</div>
<Divider />
<Muted>This error is not recoverable. :/</Muted>
<Divider />
<a href="#" onClick={() => window.location.reload()}>
Attempt Page Reload
</a>
<ExternalLink href="/">Go Home</ExternalLink>
</CenterLayoutPage>
);
+16
View File
@@ -0,0 +1,16 @@
import { UGPTSettingsContext } from "context/UGPTSettingsContext";
import { useContext } from "react";
import { Game, GetGamePTConfig } from "tachi-common";
import { Playtype } from "types/tachi";
export function useBucket(game: Game, playtype: Playtype) {
const { settings } = useContext(UGPTSettingsContext);
if (!settings?.preferences.scoreBucket) {
const gptConfig = GetGamePTConfig(game, playtype);
return gptConfig.scoreBucket;
}
return settings.preferences.scoreBucket;
}
+4
View File
@@ -3,6 +3,10 @@
* @param opacity A value between 0 and 1.
*/
export function ChangeOpacity(str: string, opacity: number) {
if (!str) {
throw new Error(`Str given to ChangeOpacity was undefined.`);
}
if (str.startsWith("#")) {
if (str.length === 7) {
return str + Math.floor(opacity * 255).toString(16);
+1 -1
View File
@@ -1,6 +1,6 @@
const MAJOR = 2;
const MINOR = 0;
const PATCH = 1;
const PATCH = 2;
// As is with all front-facing zkldi projects, the version names for tachi-client
// are from an album I like. In this case, the album is The Cure - Disintegration.
+23
View File
@@ -197,3 +197,26 @@ export function HistorySafeGoBack(history: ReturnType<typeof useHistory>) {
// history.goBack();
// }
}
export function ToPercent(n1: number, n2: number) {
return `${((100 * n1) / n2).toFixed(2)}%`;
}
export function CountElements<T>(data: T[], collector: (element: T) => string | null) {
const counts: Record<string, number> = {};
for (const element of data) {
const key = collector(element);
if (key === null) {
continue;
}
if (!counts[key]) {
counts[key] = 1;
} else {
counts[key]++;
}
}
return counts;
}
+2
View File
@@ -112,6 +112,8 @@ export const contentiousSplashes = [
"...and good faith is when people agree with me",
"Anyone else at this party gay?",
"I have the transfeminine urge to pee.",
"I HAVE TO LIE IN THE MIDDLE OF THE FLOOR",
"COMPLETELY MOTIONLESS NOT DARING TO BREATHE",
];
export const heySplashes = [