mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-12 09:18:25 +03:00
feat: masterpiece
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
||||
.feedRowLink {
|
||||
color: inherit;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
box-shadow 0.12s ease,
|
||||
transform 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow:
|
||||
0 0 0 1px var(--bs-primary),
|
||||
0 0.35rem 1rem rgb(0 0 0 / 0.12);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--bs-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import type { TableEvolutionEventAPI } from "#types/api-returns";
|
||||
|
||||
import Muted from "#components/util/Muted";
|
||||
import { CreateChartLink } from "#util/data";
|
||||
import { UppercaseFirst } from "#util/misc";
|
||||
import { FormatTime, MillisToSince } from "#util/time";
|
||||
import React, { type CSSProperties } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
type ChartDocument,
|
||||
FormatDifficultyShort,
|
||||
type SongDocument,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
import rowStyles from "./EvolutionMilestoneFeedRow.module.scss";
|
||||
|
||||
type EvolutionFeedEvent = TableEvolutionEventAPI;
|
||||
|
||||
const feedRowBaseClass =
|
||||
"border border-secondary border-opacity-25 flex-shrink-0 position-relative rounded-3 shadow-sm";
|
||||
|
||||
export default function EvolutionMilestoneFeedRow({
|
||||
evRow,
|
||||
chart,
|
||||
game,
|
||||
song,
|
||||
articleStyle,
|
||||
chipFill,
|
||||
chipBg,
|
||||
}: {
|
||||
articleStyle: CSSProperties;
|
||||
chart?: ChartDocument;
|
||||
chipBg: string | undefined;
|
||||
chipFill: string | undefined;
|
||||
evRow: EvolutionFeedEvent;
|
||||
game: V3Game;
|
||||
song?: SongDocument;
|
||||
}) {
|
||||
const timeMs = evRow.timeAchieved ?? evRow.timeAdded;
|
||||
const chartHref = chart ? CreateChartLink(chart) : `/games/${game}/charts/${evRow.chartID}`;
|
||||
|
||||
const body = (
|
||||
<div className="p-3">
|
||||
<div className="row g-3 align-items-start">
|
||||
<div className="col-12 col-lg-8 order-2 order-lg-1 min-w-0">
|
||||
<div className="fs-6 lh-sm mb-2">
|
||||
{song && chart ? (
|
||||
<>
|
||||
<span className="fw-semibold text-body-emphasis">{song.title}</span>
|
||||
<span className="text-body-secondary fw-normal ms-2 small text-nowrap">
|
||||
({FormatDifficultyShort(chart)})
|
||||
</span>
|
||||
</>
|
||||
) : chart ? (
|
||||
<>
|
||||
<span className="fst-italic text-body-secondary">Unknown chart</span>
|
||||
<span className="text-body-secondary fw-normal ms-2 small text-nowrap">
|
||||
({FormatDifficultyShort(chart)})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="fst-italic text-body-secondary">Unknown chart</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="d-flex align-items-center flex-wrap gap-2 border-secondary border-opacity-25 border-top pt-2">
|
||||
<small className="text-body-secondary lh-1 mb-0 text-uppercase">
|
||||
{UppercaseFirst(evRow.metric)}
|
||||
</small>
|
||||
<span
|
||||
className="fw-semibold lh-sm px-2 py-1 rounded-pill border small shadow-sm"
|
||||
style={{
|
||||
borderColor: chipFill ?? "var(--bs-secondary)",
|
||||
backgroundColor: chipBg ?? "var(--bs-body-bg)",
|
||||
}}
|
||||
>
|
||||
{evRow.value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-lg-4 order-1 order-lg-2">
|
||||
<div
|
||||
className="bg-body-tertiary bg-opacity-50 border border-secondary border-opacity-25 lh-sm ms-lg-auto px-3 py-2 rounded-3 tabular-nums text-lg-end w-100"
|
||||
style={{
|
||||
maxWidth: "22rem",
|
||||
}}
|
||||
>
|
||||
<div className="fs-6 fw-semibold mb-1 text-body-emphasis lh-sm">
|
||||
{MillisToSince(timeMs)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
<Muted>{FormatTime(timeMs)}</Muted>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={`${rowStyles.feedRowLink} ${feedRowBaseClass}`}
|
||||
style={articleStyle}
|
||||
to={chartHref}
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
.track {
|
||||
&:focus-visible {
|
||||
outline: 3px solid var(--bs-focus-ring-color, rgb(230 28 110 / 0.55));
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
}
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
import type { TableEvolutionEventAPI } from "#types/api-returns";
|
||||
|
||||
import { FormatDate } from "#util/time";
|
||||
import React, { useCallback, useMemo, useRef } from "react";
|
||||
import { type GameConfig, GetScoreMetricConf } from "tachi-common";
|
||||
|
||||
import timelineStyles from "./EvolutionTimelineStrip.module.scss";
|
||||
import { evoEventTimeMs } from "./folderTableShared";
|
||||
|
||||
/** Horizontal resolution of the density graph (few DOM nodes vs one per milestone). */
|
||||
const TIMELINE_BIN_COUNT = 200;
|
||||
|
||||
const VIEW_BOX_H = 120;
|
||||
const PLOT_TOP = 8;
|
||||
const PLOT_BOTTOM = VIEW_BOX_H - 10;
|
||||
|
||||
const GRAPH_CSS_HEIGHT_PX = VIEW_BOX_H;
|
||||
|
||||
export default function EvolutionTimelineStrip({
|
||||
allEnumColours,
|
||||
axisSpan,
|
||||
enumMetric,
|
||||
gameConfig,
|
||||
maxAxis,
|
||||
minAxis,
|
||||
rangeId,
|
||||
rangeStep,
|
||||
scrubTimeMs,
|
||||
setPlaying,
|
||||
setScrubTimeMs,
|
||||
workingEvents,
|
||||
}: {
|
||||
allEnumColours: Record<string, Record<string, string>> | undefined;
|
||||
axisSpan: number;
|
||||
enumMetric: string;
|
||||
gameConfig: GameConfig;
|
||||
maxAxis: number;
|
||||
minAxis: number;
|
||||
rangeId: string;
|
||||
rangeStep: number;
|
||||
scrubTimeMs: number;
|
||||
setPlaying: (v: boolean) => void;
|
||||
setScrubTimeMs: (v: number) => void;
|
||||
workingEvents: TableEvolutionEventAPI[];
|
||||
}) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrubClampedMs = Math.min(Math.max(scrubTimeMs, minAxis), maxAxis);
|
||||
const playheadLeftPct =
|
||||
axisSpan <= 0
|
||||
? 0
|
||||
: Math.min(100, Math.max(0, (100 * (scrubClampedMs - minAxis)) / axisSpan));
|
||||
|
||||
const scrubFromClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
const el = trackRef.current;
|
||||
if (!el || workingEvents.length === 0 || axisSpan <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { left, width } = el.getBoundingClientRect();
|
||||
if (width <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ratio = Math.min(1, Math.max(0, (clientX - left) / width));
|
||||
const nextMs = minAxis + ratio * axisSpan;
|
||||
setScrubTimeMs(Math.min(maxAxis, Math.max(minAxis, nextMs)));
|
||||
},
|
||||
[axisSpan, maxAxis, minAxis, setScrubTimeMs, workingEvents.length],
|
||||
);
|
||||
|
||||
const onPointerDownTrack = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (workingEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
setPlaying(false);
|
||||
scrubFromClientX(e.clientX);
|
||||
},
|
||||
[scrubFromClientX, setPlaying, workingEvents.length],
|
||||
);
|
||||
|
||||
const onPointerMoveTrack = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (workingEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(e.pressure > 0 || e.buttons & 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrubFromClientX(e.clientX);
|
||||
},
|
||||
[scrubFromClientX, workingEvents.length],
|
||||
);
|
||||
|
||||
const releasePointerCaptureIfAny = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
try {
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
} catch {
|
||||
// ignore races with unmount/cancelled pointers
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onSliderKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (workingEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stepMs = Number.isFinite(rangeStep) ? rangeStep : 1000;
|
||||
|
||||
let nextMs = scrubClampedMs;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowLeft":
|
||||
case "ArrowDown":
|
||||
nextMs -= stepMs;
|
||||
e.preventDefault();
|
||||
break;
|
||||
|
||||
case "ArrowRight":
|
||||
case "ArrowUp":
|
||||
nextMs += stepMs;
|
||||
e.preventDefault();
|
||||
break;
|
||||
|
||||
case "Home":
|
||||
nextMs = minAxis;
|
||||
e.preventDefault();
|
||||
break;
|
||||
|
||||
case "End":
|
||||
nextMs = maxAxis;
|
||||
e.preventDefault();
|
||||
break;
|
||||
|
||||
case "PageDown":
|
||||
nextMs += axisSpan * 0.1;
|
||||
e.preventDefault();
|
||||
break;
|
||||
|
||||
case "PageUp":
|
||||
nextMs -= axisSpan * 0.1;
|
||||
e.preventDefault();
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
setPlaying(false);
|
||||
setScrubTimeMs(Math.min(maxAxis, Math.max(minAxis, nextMs)));
|
||||
},
|
||||
[
|
||||
axisSpan,
|
||||
maxAxis,
|
||||
minAxis,
|
||||
rangeStep,
|
||||
scrubClampedMs,
|
||||
setPlaying,
|
||||
setScrubTimeMs,
|
||||
workingEvents.length,
|
||||
],
|
||||
);
|
||||
|
||||
const graphBody = useMemo(() => {
|
||||
const conf = GetScoreMetricConf(gameConfig, enumMetric);
|
||||
const metricEnumLen = conf.type === "ENUM" ? conf.values.length : 1;
|
||||
const enumLen = Math.max(metricEnumLen, 1);
|
||||
|
||||
const bins: number[][] = Array.from({ length: TIMELINE_BIN_COUNT }, () =>
|
||||
new Array(enumLen).fill(0),
|
||||
);
|
||||
|
||||
if (workingEvents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const span = Math.max(axisSpan, Number.EPSILON);
|
||||
for (const evNotch of workingEvents) {
|
||||
const tNotch = evoEventTimeMs(evNotch);
|
||||
const rel = (tNotch - minAxis) / span;
|
||||
if (!(rel >= 0 && rel <= 1)) {
|
||||
continue;
|
||||
}
|
||||
let bucket = Math.floor(rel * TIMELINE_BIN_COUNT);
|
||||
if (bucket >= TIMELINE_BIN_COUNT) {
|
||||
bucket = TIMELINE_BIN_COUNT - 1;
|
||||
}
|
||||
if (bucket < 0) {
|
||||
bucket = 0;
|
||||
}
|
||||
|
||||
let enumIdx = 0;
|
||||
if (conf.type === "ENUM" && metricEnumLen > 0) {
|
||||
enumIdx = Math.min(Math.max(evNotch.enumIndex, 0), metricEnumLen - 1);
|
||||
}
|
||||
bins[bucket][enumIdx] += 1;
|
||||
}
|
||||
|
||||
let maxBucketTotal = 0;
|
||||
for (const row of bins) {
|
||||
let sum = 0;
|
||||
for (const c of row) {
|
||||
sum += c;
|
||||
}
|
||||
if (sum > maxBucketTotal) {
|
||||
maxBucketTotal = sum;
|
||||
}
|
||||
}
|
||||
const norm = maxBucketTotal > 0 ? maxBucketTotal : 1;
|
||||
|
||||
const plots: React.ReactElement[] = [];
|
||||
const bucketW = TIMELINE_BIN_COUNT;
|
||||
const inset = 0.1;
|
||||
const barW = 1 - 2 * inset;
|
||||
|
||||
for (let bi = 0; bi < TIMELINE_BIN_COUNT; bi++) {
|
||||
const row = bins[bi];
|
||||
let bucketTotal = 0;
|
||||
for (const c of row) {
|
||||
bucketTotal += c;
|
||||
}
|
||||
if (bucketTotal === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bucketH = (bucketTotal / norm) * (PLOT_BOTTOM - PLOT_TOP);
|
||||
let yBottom = PLOT_BOTTOM;
|
||||
|
||||
for (let ej = row.length - 1; ej >= 0; ej--) {
|
||||
const count = row[ej];
|
||||
if (count === 0 || bucketTotal === 0) {
|
||||
continue;
|
||||
}
|
||||
const segH = (count / bucketTotal) * bucketH;
|
||||
const yTop = yBottom - segH;
|
||||
const valueLabel =
|
||||
conf.type === "ENUM" && metricEnumLen > 0 ? (conf.values[ej] ?? "") : "";
|
||||
const colour =
|
||||
valueLabel !== ""
|
||||
? (allEnumColours?.[enumMetric]?.[valueLabel] ?? "var(--bs-secondary)")
|
||||
: "var(--bs-secondary)";
|
||||
|
||||
plots.push(
|
||||
<rect
|
||||
fill={colour}
|
||||
height={Math.max(segH, 0.15)}
|
||||
key={`b-${bi}-e-${ej}`}
|
||||
opacity={0.9}
|
||||
width={barW}
|
||||
x={bi + inset}
|
||||
y={yTop}
|
||||
/>,
|
||||
);
|
||||
yBottom = yTop;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<g aria-hidden>
|
||||
<rect
|
||||
fill="var(--bs-secondary-bg)"
|
||||
height={PLOT_BOTTOM - PLOT_TOP}
|
||||
opacity={0.65}
|
||||
rx={1}
|
||||
stroke="var(--bs-border-color)"
|
||||
strokeOpacity={0.4}
|
||||
strokeWidth={0.12}
|
||||
width={bucketW}
|
||||
x={0}
|
||||
y={PLOT_TOP}
|
||||
/>
|
||||
{plots}
|
||||
</g>
|
||||
);
|
||||
}, [allEnumColours, axisSpan, enumMetric, gameConfig, minAxis, workingEvents]);
|
||||
|
||||
const interactive = workingEvents.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="small lh-sm mx-auto mb-3 text-body-secondary text-center px-2">
|
||||
<strong>Drag or click</strong> the chart to move in time - use arrow keys when the
|
||||
chart has focus for keyboard scrubbing.
|
||||
</p>
|
||||
|
||||
<div
|
||||
aria-disabled={!interactive}
|
||||
aria-labelledby={rangeId}
|
||||
aria-valuemax={maxAxis}
|
||||
aria-valuemin={minAxis}
|
||||
aria-valuenow={Math.round(scrubClampedMs)}
|
||||
aria-valuetext={`Replay time ${FormatDate(scrubClampedMs)}`}
|
||||
className={`${timelineStyles.track} position-relative rounded-4 overflow-hidden shadow-sm border border-primary border-opacity-25 bg-body-secondary bg-opacity-10 w-100 ${
|
||||
interactive ? "user-select-none" : "opacity-75 pe-none"
|
||||
}`}
|
||||
onKeyDown={onSliderKeyDown}
|
||||
onPointerCancel={releasePointerCaptureIfAny}
|
||||
onPointerDown={onPointerDownTrack}
|
||||
onPointerMove={onPointerMoveTrack}
|
||||
onPointerUp={releasePointerCaptureIfAny}
|
||||
ref={trackRef}
|
||||
role={interactive ? "slider" : undefined}
|
||||
style={{
|
||||
cursor: interactive ? "grab" : "not-allowed",
|
||||
height: GRAPH_CSS_HEIGHT_PX,
|
||||
touchAction: interactive ? "none" : undefined,
|
||||
}}
|
||||
tabIndex={interactive ? 0 : -1}
|
||||
>
|
||||
<svg
|
||||
aria-hidden
|
||||
className="d-block h-100 w-100 bg-transparent"
|
||||
height="100%"
|
||||
preserveAspectRatio="none"
|
||||
viewBox={`0 0 ${TIMELINE_BIN_COUNT} ${VIEW_BOX_H}`}
|
||||
width="100%"
|
||||
>
|
||||
<title>Milestone density (decorative).</title>
|
||||
{graphBody}
|
||||
</svg>
|
||||
|
||||
{interactive ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="bg-body-emphasis opacity-85 position-absolute rounded-pill shadow"
|
||||
style={{
|
||||
bottom: "6%",
|
||||
left: `${playheadLeftPct}%`,
|
||||
pointerEvents: "none",
|
||||
top: "8%",
|
||||
transform: "translateX(-50%)",
|
||||
width: "4px",
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
.enumRowInteractive {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
filter 120ms ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
var(--bs-box-shadow-sm),
|
||||
0 0 0 1px color-mix(in srgb, var(--bs-emphasis-color) 12%, transparent);
|
||||
filter: brightness(1.03);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--bs-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
filter: brightness(0.99);
|
||||
}
|
||||
}
|
||||
|
||||
.enumRowButtonReset {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
cursor: inherit;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
import type { FolderStatsInfo } from "#types/api-returns";
|
||||
|
||||
import { WindowContext } from "#context/WindowContext";
|
||||
import { ChangeOpacity } from "#util/color-opacity";
|
||||
import { ToFixedFloor, UppercaseFirst } from "#util/misc";
|
||||
import React, { useContext } from "react";
|
||||
import { type GameConfig, GetScoreMetricConf } from "tachi-common";
|
||||
|
||||
import breakdownStyles from "./FolderEnumDistributionBreakdown.module.scss";
|
||||
|
||||
/** Row wash (track) uses a faint tint of the ladder colour; the fill bar uses a stronger tint of that same ladder colour — no neutral body BG. */
|
||||
function distributionRowChrome(enumColour: string | undefined): {
|
||||
borderLeftColor: string;
|
||||
fillStrong: string | undefined;
|
||||
trackBg: string;
|
||||
} {
|
||||
const fallbackTrack =
|
||||
"color-mix(in srgb, var(--bs-secondary) 14%, transparent)";
|
||||
const fallbackFillStrong =
|
||||
"color-mix(in srgb, var(--bs-secondary) 42%, transparent)";
|
||||
|
||||
const borderLeftColor = enumColour ?? "var(--bs-secondary)";
|
||||
|
||||
if (
|
||||
enumColour &&
|
||||
(enumColour.startsWith("#") || enumColour.startsWith("rgb"))
|
||||
) {
|
||||
try {
|
||||
return {
|
||||
borderLeftColor: enumColour,
|
||||
fillStrong: ChangeOpacity(enumColour, 0.5),
|
||||
trackBg: ChangeOpacity(enumColour, 0.14),
|
||||
};
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
|
||||
if (enumColour) {
|
||||
return {
|
||||
borderLeftColor: enumColour,
|
||||
fillStrong: `color-mix(in srgb, ${enumColour} 48%, transparent)`,
|
||||
trackBg: `color-mix(in srgb, ${enumColour} 14%, transparent)`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
borderLeftColor,
|
||||
fillStrong: fallbackFillStrong,
|
||||
trackBg: fallbackTrack,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Styled enum value × count breakdown (counts + % of folder charts).
|
||||
* Rows use GPT enum colours; optional floor at `minimumRelevantValue`; remainder row for unmatched charts.
|
||||
*/
|
||||
export default function FolderEnumDistributionBreakdown({
|
||||
clipToMinimumRelevance = false,
|
||||
colours,
|
||||
enumMetric,
|
||||
gameConfig,
|
||||
onActivate,
|
||||
onEnumBreakdownRowClick,
|
||||
remainderLabel,
|
||||
selected,
|
||||
stats,
|
||||
suppressTopRule = false,
|
||||
}: {
|
||||
clipToMinimumRelevance?: boolean;
|
||||
colours: Record<string, string> | undefined;
|
||||
enumMetric: string;
|
||||
gameConfig: GameConfig;
|
||||
onActivate?: () => void;
|
||||
/** Desktop `(isLg)`: activates folder chart filter + scroll; ignored on narrow viewports. */
|
||||
onEnumBreakdownRowClick?: (enumValueLabel: string) => void;
|
||||
remainderLabel?: string;
|
||||
/** Highlights this block when picking the active evolution replay metric from Overview. */
|
||||
selected?: boolean;
|
||||
/** Folder-level stats blob from `GET .../folders/:slug/stats`, or replay slice with same shape. */
|
||||
stats: FolderStatsInfo;
|
||||
/** When stacking inside one card, skip the inset top rule on the first section. */
|
||||
suppressTopRule?: boolean;
|
||||
}) {
|
||||
const remainderLine = remainderLabel ?? "Not played";
|
||||
|
||||
const {
|
||||
breakpoint: { isLg },
|
||||
} = useContext(WindowContext);
|
||||
|
||||
const conf = GetScoreMetricConf(gameConfig, enumMetric);
|
||||
if (conf.type !== "ENUM") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const total = stats.chartCount;
|
||||
const bucket = stats.stats[enumMetric] ?? {};
|
||||
|
||||
const minRelIx = conf.values.indexOf(conf.minimumRelevantValue);
|
||||
const valueIndexFloor =
|
||||
clipToMinimumRelevance && minRelIx !== -1 ? minRelIx : 0;
|
||||
|
||||
if (total === 0) {
|
||||
return <small className="text-body-secondary">No charts in this folder.</small>;
|
||||
}
|
||||
|
||||
let filled = 0;
|
||||
const rows = [];
|
||||
for (let vi = conf.values.length - 1; vi >= valueIndexFloor; vi--) {
|
||||
const label = conf.values[vi];
|
||||
const count = bucket[label] ?? 0;
|
||||
|
||||
filled += count;
|
||||
|
||||
const pct = total > 0 ? (100 * count) / total : 0;
|
||||
const chipFill = colours?.[label];
|
||||
const chrome = distributionRowChrome(chipFill);
|
||||
const pctWidth =
|
||||
pct > 0 ? `${ToFixedFloor(Math.min(100, Math.max(0, pct)), 3)}%` : "0%";
|
||||
|
||||
const clickableRow =
|
||||
Boolean(onEnumBreakdownRowClick && isLg && count > 0);
|
||||
const rowHoverChromeClass =
|
||||
clickableRow ? breakdownStyles.enumRowInteractive : "";
|
||||
|
||||
const rowShellStyle = {
|
||||
backgroundColor: chrome.trackBg,
|
||||
border: "1px solid var(--bs-border-color-translucent)",
|
||||
borderLeftColor: chrome.borderLeftColor,
|
||||
borderLeftStyle: "solid" as const,
|
||||
borderLeftWidth: 4,
|
||||
borderRadius: "var(--bs-border-radius-lg)",
|
||||
opacity: count === 0 ? 0.62 : undefined,
|
||||
};
|
||||
|
||||
const rowInterior = (
|
||||
<>
|
||||
{chrome.fillStrong && pct > 0 ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="position-absolute start-0 top-0 bottom-0"
|
||||
style={{
|
||||
backgroundColor: chrome.fillStrong,
|
||||
transition: "width 0.35s ease-out",
|
||||
width: pctWidth,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className="align-items-center position-relative z-1 d-flex flex-wrap gap-2 justify-content-between px-3 py-2">
|
||||
<span className="fw-semibold lh-sm mb-0 pe-2 text-body" title={label}>
|
||||
<span
|
||||
aria-hidden
|
||||
className="d-inline-block me-2 rounded-circle"
|
||||
style={{
|
||||
backgroundColor: chipFill ?? "var(--bs-secondary)",
|
||||
flexShrink: 0,
|
||||
height: "0.5rem",
|
||||
verticalAlign: "middle",
|
||||
width: "0.5rem",
|
||||
}}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
<span className="ms-auto tabular-nums text-nowrap">
|
||||
<span className="fw-bold fs-6 text-body-emphasis">{count}</span>
|
||||
<span className="ms-2 small text-body-secondary">
|
||||
({ToFixedFloor(pct, 1)}%)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
rows.push(
|
||||
clickableRow && onEnumBreakdownRowClick ? (
|
||||
<button
|
||||
aria-label={`Show charts filtered to ${enumMetric}: ${label}`}
|
||||
className={`position-relative overflow-hidden shadow-sm text-start border-0 p-0 w-100 text-body ${rowHoverChromeClass}`}
|
||||
key={label}
|
||||
onClick={() => {
|
||||
onEnumBreakdownRowClick(label);
|
||||
}}
|
||||
style={rowShellStyle}
|
||||
type="button"
|
||||
>
|
||||
{rowInterior}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
className={`position-relative overflow-hidden shadow-sm ${rowHoverChromeClass}`}
|
||||
key={label}
|
||||
style={rowShellStyle}
|
||||
>
|
||||
{rowInterior}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const remainder = Math.max(0, total - filled);
|
||||
const remainderPct = total > 0 ? (100 * remainder) / total : 0;
|
||||
|
||||
return (
|
||||
<div className={selected ? "border border-primary border-opacity-75 pb-3 pe-2 pt-2 ps-2 rounded-4 shadow-sm bg-primary bg-opacity-10" : undefined}>
|
||||
<div
|
||||
className={suppressTopRule ? undefined : "mt-3 border-secondary border-opacity-25 border-top pt-3"}
|
||||
>
|
||||
<div className="align-items-center d-flex flex-wrap gap-2 justify-content-between mb-2">
|
||||
{onActivate ? (
|
||||
<button
|
||||
className={`btn btn-sm fw-semibold text-uppercase ${selected ? "btn-primary" : "btn-outline-secondary"}`}
|
||||
onClick={onActivate}
|
||||
style={{ letterSpacing: "0.06em" }}
|
||||
type="button"
|
||||
>
|
||||
{UppercaseFirst(enumMetric)}
|
||||
{selected ? (
|
||||
<span className="fst-normal ms-2 fw-normal text-white-50 text-capitalize">
|
||||
(for replay)
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
className="fw-semibold lh-sm small text-body-secondary text-uppercase"
|
||||
style={{ letterSpacing: "0.06em" }}
|
||||
>
|
||||
{UppercaseFirst(enumMetric)} breakdown
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="d-flex flex-column gap-2">{rows}</div>
|
||||
{remainder > 0 ? (
|
||||
<div
|
||||
className="position-relative overflow-hidden mt-2"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--bs-secondary) 14%, transparent)",
|
||||
border: "1px dashed var(--bs-border-color-translucent)",
|
||||
borderRadius: "var(--bs-border-radius-lg)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="position-absolute start-0 top-0 bottom-0"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--bs-secondary) 40%, transparent)",
|
||||
transition: "width 0.35s ease-out",
|
||||
width: `${ToFixedFloor(Math.min(100, Math.max(0, remainderPct)), 3)}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="position-relative z-1 align-items-center d-flex flex-wrap gap-2 justify-content-between px-3 py-2 small text-body-secondary">
|
||||
<span className="fst-italic fw-semibold">{remainderLine}</span>
|
||||
<span className="ms-auto tabular-nums text-nowrap">
|
||||
<span className="fw-bold text-body-emphasis">{remainder}</span>
|
||||
<span className="ms-2 small text-body-secondary">
|
||||
({ToFixedFloor(remainderPct, 1)}%)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
@keyframes folderEnumBarEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes folderEnumSegmentFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.folderEnumBar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.folderEnumBarAnimated {
|
||||
animation: folderEnumBarEnter 0.42s ease-out both;
|
||||
}
|
||||
|
||||
.folderEnumBarSegment {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: none;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.folderEnumBarSegmentAnimated {
|
||||
animation: folderEnumSegmentFade 0.38s ease-out both;
|
||||
}
|
||||
|
||||
.folderEnumBarRemainder {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.folderEnumBarRemainderAnimated {
|
||||
animation: folderEnumSegmentFade 0.38s ease-out both;
|
||||
}
|
||||
|
||||
.folderEnumBarLabel {
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
text-shadow:
|
||||
0 0 1px rgb(0 0 0 / 0.9),
|
||||
0 1px 2px rgb(0 0 0 / 0.55);
|
||||
white-space: nowrap;
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import type { FolderStatsInfo } from "#types/api-returns";
|
||||
|
||||
import QuickTooltip from "#components/layout/misc/QuickTooltip";
|
||||
import Muted from "#components/util/Muted";
|
||||
import { ChangeOpacity } from "#util/color-opacity";
|
||||
import { ToFixedFloor } from "#util/misc";
|
||||
import React, { useLayoutEffect, useRef, useState } from "react";
|
||||
import { type GameConfig, GetScoreMetricConf } from "tachi-common";
|
||||
|
||||
import barStyles from "./FolderEnumProgressBar.module.scss";
|
||||
|
||||
const FOLDER_ENUM_BAR_HEIGHT = "1.625rem";
|
||||
|
||||
/** In-bar `%` text only when the segment is at least this wide (measured in CSS pixels). */
|
||||
const MIN_SEGMENT_WIDTH_PX_FOR_INLINE_LABEL = 30;
|
||||
|
||||
export default function FolderEnumProgressBar({
|
||||
animateSegments = true,
|
||||
clipToMinimumRelevance = false,
|
||||
stats,
|
||||
enumMetric,
|
||||
gameConfig,
|
||||
colours,
|
||||
}: {
|
||||
animateSegments?: boolean;
|
||||
/** When true, only segments at or stronger than `minimumRelevantValue` contribute (matches evolution semantics). */
|
||||
clipToMinimumRelevance?: boolean;
|
||||
colours: Record<string, string> | undefined;
|
||||
enumMetric: string;
|
||||
gameConfig: GameConfig;
|
||||
stats: FolderStatsInfo;
|
||||
}) {
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const [barWidthPx, setBarWidthPx] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = barRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
setBarWidthPx(el.getBoundingClientRect().width);
|
||||
};
|
||||
|
||||
update();
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width;
|
||||
if (w !== undefined) {
|
||||
setBarWidthPx(w);
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const conf = GetScoreMetricConf(gameConfig, enumMetric);
|
||||
if (conf.type !== "ENUM") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const total = stats.chartCount;
|
||||
const bucket = stats.stats[enumMetric] ?? {};
|
||||
|
||||
const minRelIx = conf.values.indexOf(conf.minimumRelevantValue);
|
||||
const valueIndexFloor =
|
||||
clipToMinimumRelevance && minRelIx !== -1 ? minRelIx : 0;
|
||||
|
||||
const segments: Array<{ count: number; label: string; rawFill: string }> = [];
|
||||
let filled = 0;
|
||||
for (let vi = conf.values.length - 1; vi >= valueIndexFloor; vi--) {
|
||||
const v = conf.values[vi];
|
||||
const count = bucket[v] ?? 0;
|
||||
if (count > 0) {
|
||||
filled += count;
|
||||
segments.push({
|
||||
label: v,
|
||||
count,
|
||||
rawFill: colours?.[v] ?? "var(--bs-secondary-bg)",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
return <Muted>—</Muted>;
|
||||
}
|
||||
|
||||
const remainder = Math.max(0, total - filled);
|
||||
const remainderWidthPct = total > 0 ? (100 * remainder) / total : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border overflow-hidden rounded-3 ${barStyles.folderEnumBar} ${animateSegments ? barStyles.folderEnumBarAnimated : ""}`}
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
ref={barRef}
|
||||
style={{
|
||||
backgroundColor: "var(--bs-secondary-bg)",
|
||||
direction: "ltr",
|
||||
display: "block",
|
||||
height: FOLDER_ENUM_BAR_HEIGHT,
|
||||
minHeight: "1.25rem",
|
||||
unicodeBidi: "isolate",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{segments.map((seg, segIndex) => {
|
||||
const pct = total > 0 ? (100 * seg.count) / total : 0;
|
||||
const fill = seg.rawFill.startsWith("var(")
|
||||
? seg.rawFill
|
||||
: ChangeOpacity(seg.rawFill, 0.92);
|
||||
const segmentWidthPx = barWidthPx > 0 ? (pct / 100) * barWidthPx : 0;
|
||||
const showInlinePct = segmentWidthPx >= MIN_SEGMENT_WIDTH_PX_FOR_INLINE_LABEL;
|
||||
const inlinePctText = `${Math.round(pct)}%`;
|
||||
|
||||
return (
|
||||
<QuickTooltip
|
||||
key={seg.label}
|
||||
tooltipContent={
|
||||
<>
|
||||
<div>{seg.label}</div>
|
||||
<div>{ToFixedFloor(pct, 2)}%</div>
|
||||
<Muted>
|
||||
({seg.count} / {total})
|
||||
</Muted>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`h-100 ${barStyles.folderEnumBarSegment} ${animateSegments ? barStyles.folderEnumBarSegmentAnimated : ""}`}
|
||||
style={{
|
||||
...(animateSegments
|
||||
? { animationDelay: `${0.05 + segIndex * 0.055}s` }
|
||||
: {}),
|
||||
backgroundColor: fill,
|
||||
flex: "none",
|
||||
minWidth: seg.count ? "4px" : 0,
|
||||
width: `${pct}%`,
|
||||
}}
|
||||
>
|
||||
{showInlinePct && (
|
||||
<span className={barStyles.folderEnumBarLabel}>
|
||||
{inlinePctText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</QuickTooltip>
|
||||
);
|
||||
})}
|
||||
{remainderWidthPct > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={`h-100 ${barStyles.folderEnumBarRemainder} ${animateSegments ? barStyles.folderEnumBarRemainderAnimated : ""}`}
|
||||
style={{
|
||||
...(animateSegments
|
||||
? {
|
||||
animationDelay: `${0.05 + segments.length * 0.055}s`,
|
||||
}
|
||||
: {}),
|
||||
backgroundColor: "transparent",
|
||||
flex: "none",
|
||||
width: `${remainderWidthPct}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+58
-54
@@ -1,57 +1,3 @@
|
||||
@keyframes folderEnumBarEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes folderEnumSegmentFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.folderEnumBar {
|
||||
animation: folderEnumBarEnter 0.42s ease-out both;
|
||||
}
|
||||
|
||||
.folderEnumBarSegment {
|
||||
align-items: center;
|
||||
animation: folderEnumSegmentFade 0.38s ease-out both;
|
||||
display: flex;
|
||||
flex: none;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.folderEnumBarRemainder {
|
||||
animation: folderEnumSegmentFade 0.38s ease-out both;
|
||||
}
|
||||
|
||||
.folderEnumBarLabel {
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
text-shadow:
|
||||
0 0 1px rgb(0 0 0 / 0.9),
|
||||
0 1px 2px rgb(0 0 0 / 0.55);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folderRowChartMeta {
|
||||
align-items: center;
|
||||
column-gap: 0.5rem;
|
||||
@@ -82,6 +28,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes folder-row-highlight-intro {
|
||||
0% {
|
||||
background-color: transparent;
|
||||
border-color: var(--bs-border-color);
|
||||
box-shadow: 0 0 0 color-mix(in srgb, var(--bs-primary) 0%, transparent);
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
background-color: color-mix(in srgb, var(--bs-primary) 18%, var(--bs-body-bg));
|
||||
border-color: color-mix(in srgb, var(--bs-primary) 65%, var(--bs-border-color));
|
||||
box-shadow:
|
||||
0 0.25rem 1.15rem color-mix(in srgb, var(--bs-primary) 32%, transparent),
|
||||
0 0 0 1px color-mix(in srgb, var(--bs-primary) 40%, transparent);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
100% {
|
||||
background-color: color-mix(in srgb, var(--bs-primary) 12%, var(--bs-body-bg));
|
||||
border-color: color-mix(in srgb, var(--bs-primary) 58%, var(--bs-border-color));
|
||||
box-shadow:
|
||||
0 0.22rem 0.85rem color-mix(in srgb, var(--bs-primary) 28%, transparent),
|
||||
0 0 0 1px color-mix(in srgb, var(--bs-primary) 38%, transparent),
|
||||
inset 3px 0 0 0 var(--bs-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.folderRow {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius-xl);
|
||||
@@ -108,3 +80,35 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/** Row for the folder that matches `highlightFolderSlug`; intro bumps when the overview accordion opens (`FolderTablePage`). */
|
||||
.folderRowHighlighted {
|
||||
animation: folder-row-highlight-intro 1.15s cubic-bezier(0.33, 0.82, 0.35, 1) both;
|
||||
border-width: 2px;
|
||||
|
||||
&:hover {
|
||||
background-color: color-mix(in srgb, var(--bs-primary) 16%, var(--bs-body-bg));
|
||||
border-color: color-mix(in srgb, var(--bs-primary) 72%, var(--bs-border-color));
|
||||
box-shadow:
|
||||
0 0.35rem 1.1rem color-mix(in srgb, var(--bs-primary) 30%, transparent),
|
||||
0 0 0 1px color-mix(in srgb, var(--bs-primary) 42%, transparent),
|
||||
inset 3px 0 0 0 var(--bs-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--bs-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
background-color: color-mix(in srgb, var(--bs-primary) 12%, var(--bs-body-bg));
|
||||
border-color: color-mix(in srgb, var(--bs-primary) 55%, var(--bs-border-color));
|
||||
border-left-width: 6px;
|
||||
border-width: 2px;
|
||||
box-shadow:
|
||||
0 0.18rem 0.65rem color-mix(in srgb, var(--bs-primary) 22%, transparent),
|
||||
inset 3px 0 0 0 var(--bs-primary);
|
||||
padding-inline-start: calc(1rem - 4px);
|
||||
}
|
||||
}
|
||||
|
||||
+112
-374
@@ -1,50 +1,84 @@
|
||||
import QuickTooltip from "#components/layout/misc/QuickTooltip";
|
||||
import Card from "#components/layout/page/Card";
|
||||
import type { TableDocument } from "tachi-common";
|
||||
|
||||
import ApiError from "#components/util/ApiError";
|
||||
import Divider from "#components/util/Divider";
|
||||
import Icon from "#components/util/Icon";
|
||||
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 useLUGPTSettings from "#components/util/useLUGPTSettings";
|
||||
import { UserContext } from "#context/UserContext";
|
||||
import { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations";
|
||||
import { type FolderStatsInfo, type UGPTTableReturns } from "#types/api-returns";
|
||||
import { APIFetchV1 } from "#util/api";
|
||||
import { ChangeOpacity } from "#util/color-opacity";
|
||||
import { ToFixedFloor, UppercaseFirst } from "#util/misc";
|
||||
import React, { useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
|
||||
import Button from "react-bootstrap/Button";
|
||||
import Collapse from "react-bootstrap/Collapse";
|
||||
import Form from "react-bootstrap/Form";
|
||||
import InputGroup from "react-bootstrap/InputGroup";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
type FolderDocument,
|
||||
type GameConfig,
|
||||
GetGameConfig,
|
||||
GetScoreMetricConf,
|
||||
GetScoreMetrics,
|
||||
type TableDocument,
|
||||
type UserDocument,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
import { matchPath, useHistory, useLocation } from "react-router-dom";
|
||||
|
||||
import folderTableStyles from "./FolderTablePage.module.scss";
|
||||
import type { FolderTableScopedProps } from "./folderTableShared";
|
||||
|
||||
interface Props {
|
||||
reqUser: UserDocument;
|
||||
game: V3Game;
|
||||
}
|
||||
import TableFolderViewer from "./TableFolderViewer";
|
||||
|
||||
export default function FolderTablePage({ reqUser, game }: Props) {
|
||||
const FOLDER_ROUTE_PATTERN = "/u/:userID/games/:game/folders/:folderSlug";
|
||||
|
||||
export default function FolderTablePage({ reqUser, game }: FolderTableScopedProps) {
|
||||
const { data, error } = useApiQuery<TableDocument[]>(`/games/${game}/tables?showInactive=true`);
|
||||
|
||||
const { settings } = useLUGPTSettings();
|
||||
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
const folderRouteMatch = useMemo(
|
||||
() =>
|
||||
matchPath<{ folderSlug: string }>(location.pathname, {
|
||||
exact: true,
|
||||
path: FOLDER_ROUTE_PATTERN,
|
||||
}),
|
||||
[location.pathname],
|
||||
);
|
||||
|
||||
const activeFolderSlug = folderRouteMatch?.params.folderSlug ?? undefined;
|
||||
|
||||
const [tableOverviewOpen, setTableOverviewOpen] = useState(true);
|
||||
const overviewPanelId = useMemo(() => `folder-table-overview-${game}`, [game]);
|
||||
|
||||
const [highlightRevealKey, setHighlightRevealKey] = useState(0);
|
||||
|
||||
const [tableID, setTableID] = useState("");
|
||||
const [tableMap, setTableMap] = useState(new Map());
|
||||
|
||||
const collapseTableOverview = useCallback(() => {
|
||||
setTableOverviewOpen(false);
|
||||
}, []);
|
||||
|
||||
const onTablePickChange = useCallback(
|
||||
(nextTableID: string) => {
|
||||
setTableID(nextTableID);
|
||||
if (!activeFolderSlug) {
|
||||
return;
|
||||
}
|
||||
|
||||
history.replace(`/u/${reqUser.username}/games/${game}/folders`);
|
||||
},
|
||||
[activeFolderSlug, game, history, reqUser.username],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (activeFolderSlug) {
|
||||
setTableOverviewOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTableOverviewOpen(true);
|
||||
}, [activeFolderSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeFolderSlug || !tableOverviewOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHighlightRevealKey((n) => n + 1);
|
||||
}, [activeFolderSlug, tableOverviewOpen]);
|
||||
|
||||
const table = useMemo(() => tableMap.get(tableID), [tableID, tableMap]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -55,13 +89,11 @@ export default function FolderTablePage({ reqUser, game }: Props) {
|
||||
for (const table of data) {
|
||||
newMap.set(table.tableID, table);
|
||||
|
||||
// If the user has a preference
|
||||
if (settings?.preferences.defaultTable) {
|
||||
if (settings.preferences.defaultTable === table.tableID) {
|
||||
setTableID(table.tableID);
|
||||
foundDefault = true;
|
||||
}
|
||||
// Otherwise just use the provided default.
|
||||
}
|
||||
|
||||
if (table.default && !foundDefault) {
|
||||
@@ -92,358 +124,64 @@ export default function FolderTablePage({ reqUser, game }: Props) {
|
||||
(settings?.preferences.defaultTable && settings.preferences.defaultTable === e.tableID),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputGroup size="lg">
|
||||
<InputGroup.Text>Table</InputGroup.Text>
|
||||
<Form.Select onChange={(e) => setTableID(e.target.value)} value={tableID}>
|
||||
{displayableTables.map((e) => (
|
||||
<option key={e.tableID} value={e.tableID}>
|
||||
{e.title}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</InputGroup>
|
||||
<Divider />
|
||||
{table && <TableFolderViewer {...{ reqUser, game, table }} />}
|
||||
</>
|
||||
const tablePicker = (
|
||||
<InputGroup size="lg">
|
||||
<InputGroup.Text>Table</InputGroup.Text>
|
||||
<Form.Select onChange={(e) => onTablePickChange(e.target.value)} value={tableID}>
|
||||
{displayableTables.map((e) => (
|
||||
<option key={e.tableID} value={e.tableID}>
|
||||
{e.title}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface UGPTFolderStats {
|
||||
folder: FolderDocument;
|
||||
stats: FolderStatsInfo;
|
||||
}
|
||||
|
||||
/** API `table.folders` is ascending (e.g. Level 1 … 12); show highest folder / level first in chart + table. */
|
||||
function tableFolderSlugsDisplayOrder(table: TableDocument): string[] {
|
||||
return [...table.folders].reverse();
|
||||
}
|
||||
|
||||
const FOLDER_ENUM_BAR_HEIGHT = "1.625rem";
|
||||
|
||||
/** In-bar `%` text only when the segment is at least this wide (measured in CSS pixels). */
|
||||
const MIN_SEGMENT_WIDTH_PX_FOR_INLINE_LABEL = 30;
|
||||
|
||||
function FolderEnumProgressBar({
|
||||
stats,
|
||||
enumMetric,
|
||||
gameConfig,
|
||||
colours,
|
||||
}: {
|
||||
colours: Record<string, string> | undefined;
|
||||
enumMetric: string;
|
||||
gameConfig: GameConfig;
|
||||
stats: FolderStatsInfo;
|
||||
}) {
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const [barWidthPx, setBarWidthPx] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = barRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
setBarWidthPx(el.getBoundingClientRect().width);
|
||||
};
|
||||
|
||||
update();
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width;
|
||||
if (w !== undefined) {
|
||||
setBarWidthPx(w);
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const conf = GetScoreMetricConf(gameConfig, enumMetric);
|
||||
if (conf.type !== "ENUM") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const total = stats.chartCount;
|
||||
const bucket = stats.stats[enumMetric] ?? {};
|
||||
|
||||
const segments: Array<{ count: number; label: string; rawFill: string }> = [];
|
||||
let filled = 0;
|
||||
for (let vi = conf.values.length - 1; vi >= 0; vi--) {
|
||||
const v = conf.values[vi];
|
||||
const count = bucket[v] ?? 0;
|
||||
if (count > 0) {
|
||||
filled += count;
|
||||
segments.push({
|
||||
label: v,
|
||||
count,
|
||||
rawFill: colours?.[v] ?? "var(--bs-secondary-bg)",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
return <Muted>—</Muted>;
|
||||
}
|
||||
|
||||
const remainder = Math.max(0, total - filled);
|
||||
const remainderWidthPct = total > 0 ? (100 * remainder) / total : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border overflow-hidden rounded-3 ${folderTableStyles.folderEnumBar}`}
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
ref={barRef}
|
||||
style={{
|
||||
backgroundColor: "var(--bs-secondary-bg)",
|
||||
direction: "ltr",
|
||||
display: "block",
|
||||
height: FOLDER_ENUM_BAR_HEIGHT,
|
||||
minHeight: "1.25rem",
|
||||
unicodeBidi: "isolate",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{segments.map((seg, segIndex) => {
|
||||
const pct = total > 0 ? (100 * seg.count) / total : 0;
|
||||
const fill = seg.rawFill.startsWith("var(")
|
||||
? seg.rawFill
|
||||
: ChangeOpacity(seg.rawFill, 0.92);
|
||||
const segmentWidthPx = barWidthPx > 0 ? (pct / 100) * barWidthPx : 0;
|
||||
const showInlinePct = segmentWidthPx >= MIN_SEGMENT_WIDTH_PX_FOR_INLINE_LABEL;
|
||||
const inlinePctText = `${Math.round(pct)}%`;
|
||||
|
||||
return (
|
||||
<QuickTooltip
|
||||
key={seg.label}
|
||||
tooltipContent={
|
||||
<>
|
||||
<div>{seg.label}</div>
|
||||
<div>{ToFixedFloor(pct, 2)}%</div>
|
||||
<Muted>
|
||||
({seg.count} / {total})
|
||||
</Muted>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`h-100 ${folderTableStyles.folderEnumBarSegment}`}
|
||||
style={{
|
||||
animationDelay: `${0.05 + segIndex * 0.055}s`,
|
||||
backgroundColor: fill,
|
||||
flex: "none",
|
||||
minWidth: seg.count ? "4px" : 0,
|
||||
width: `${pct}%`,
|
||||
}}
|
||||
>
|
||||
{showInlinePct && (
|
||||
<span className={folderTableStyles.folderEnumBarLabel}>
|
||||
{inlinePctText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</QuickTooltip>
|
||||
);
|
||||
})}
|
||||
{remainderWidthPct > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={`h-100 ${folderTableStyles.folderEnumBarRemainder}`}
|
||||
style={{
|
||||
animationDelay: `${0.05 + segments.length * 0.055}s`,
|
||||
backgroundColor: "transparent",
|
||||
flex: "none",
|
||||
width: `${remainderWidthPct}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFolderViewer({ reqUser, game, table }: { table: TableDocument } & Props) {
|
||||
const { data, error } = useApiQuery<UGPTTableReturns>(
|
||||
`/users/${reqUser.id}/games/${game}/tables/${table.tableID}`,
|
||||
);
|
||||
|
||||
const bucket = useBucket(game);
|
||||
const [enumMetric, setEnumMetric] = useState(bucket);
|
||||
|
||||
useEffect(() => {
|
||||
setEnumMetric(bucket);
|
||||
}, [bucket, table.tableID]);
|
||||
|
||||
const [dataMap, setDataMap] = useState<Map<string, UGPTFolderStats>>(new Map());
|
||||
const [hasLoadedFolderMap, setHasLoadedFolderMap] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const statMap = new Map();
|
||||
for (const stat of data.stats) {
|
||||
statMap.set(stat.slug, stat);
|
||||
}
|
||||
|
||||
const newMap = new Map();
|
||||
for (const folder of data.folders) {
|
||||
const stats = statMap.get(folder.slug)!;
|
||||
newMap.set(folder.slug, { folder, stats });
|
||||
}
|
||||
setDataMap(newMap);
|
||||
setHasLoadedFolderMap(true);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const gameConfig = useMemo(() => GetGameConfig(game), [game]);
|
||||
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
if (!data || !hasLoadedFolderMap) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card header="Overview">
|
||||
<div className="row">
|
||||
<div className="col-12 d-flex justify-content-center">
|
||||
<div className="btn-group">
|
||||
{GetScoreMetrics(gameConfig, "ENUM").map((e) => (
|
||||
<SelectButton
|
||||
id={e}
|
||||
key={e}
|
||||
setValue={setEnumMetric}
|
||||
value={enumMetric}
|
||||
>
|
||||
{/* @ts-expect-error enum icon keys align with score metrics */}
|
||||
<Icon type={gptImpl.enumIcons[e]} /> {UppercaseFirst(e)}s
|
||||
</SelectButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Divider />
|
||||
<TableFolderList
|
||||
dataMap={dataMap}
|
||||
enumMetric={enumMetric}
|
||||
game={game}
|
||||
reqUser={reqUser}
|
||||
table={table}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFolderList({
|
||||
table,
|
||||
dataMap,
|
||||
enumMetric,
|
||||
reqUser,
|
||||
game,
|
||||
}: {
|
||||
dataMap: Map<string, UGPTFolderStats>;
|
||||
enumMetric: string;
|
||||
table: TableDocument;
|
||||
} & Props) {
|
||||
const gameConfig = useMemo(() => GetGameConfig(game), [game]);
|
||||
|
||||
const enumColours = useMemo(
|
||||
() =>
|
||||
(
|
||||
GPT_CLIENT_IMPLEMENTATIONS[game].enumColours as
|
||||
| Record<string, Record<string, string>>
|
||||
| undefined
|
||||
)?.[enumMetric],
|
||||
[enumMetric, game],
|
||||
);
|
||||
|
||||
const dataset = useMemo(() => {
|
||||
const arr = [];
|
||||
for (const folder of tableFolderSlugsDisplayOrder(table)) {
|
||||
const data = dataMap.get(folder);
|
||||
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
arr.push(data);
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [dataMap, table]);
|
||||
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{dataset.length === 0 ? (
|
||||
<div className="text-center text-body-secondary py-5">No folders.</div>
|
||||
) : (
|
||||
dataset.map((data) => (
|
||||
<Link
|
||||
className={`${folderTableStyles.folderRow} bg-body-tertiary bg-opacity-25`}
|
||||
key={data.folder.slug}
|
||||
{activeFolderSlug ? (
|
||||
<div>
|
||||
<Button
|
||||
aria-controls={overviewPanelId}
|
||||
aria-expanded={tableOverviewOpen}
|
||||
className={`align-items-center d-flex fw-semibold gap-2 px-4 py-3 shadow-sm text-wrap justify-content-between w-100 ${!tableOverviewOpen ? "border-0" : ""}`}
|
||||
onClick={() => {
|
||||
if (user?.id === reqUser.id) {
|
||||
APIFetchV1(
|
||||
`/users/${reqUser.id}/games/${game}/folders/${data.folder.slug}/viewed`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
setTableOverviewOpen((prev) => !prev);
|
||||
}}
|
||||
to={`/u/${reqUser.username}/games/${game}/folders/${data.folder.slug}`}
|
||||
type="button"
|
||||
variant={tableOverviewOpen ? "outline-primary" : "primary"}
|
||||
>
|
||||
<div className="d-flex flex-column flex-lg-row align-items-lg-center gap-2 gap-lg-3">
|
||||
<div
|
||||
className={`fw-semibold text-truncate ${folderTableStyles.folderRowTitle}`}
|
||||
>
|
||||
{data.folder.title}
|
||||
</div>
|
||||
<div className="flex-grow-1 min-w-0" style={{ minWidth: "10rem" }}>
|
||||
<FolderEnumProgressBar
|
||||
colours={enumColours}
|
||||
enumMetric={enumMetric}
|
||||
gameConfig={gameConfig}
|
||||
key={`${enumMetric}-${data.folder.slug}`}
|
||||
stats={data.stats}
|
||||
<span>{tableOverviewOpen ? "Hide all folders" : "Show all folders"}</span>
|
||||
<Icon
|
||||
aria-hidden
|
||||
className={tableOverviewOpen ? undefined : "opacity-75"}
|
||||
type={tableOverviewOpen ? "chevron-up" : "chevron-down"}
|
||||
/>
|
||||
</Button>
|
||||
<Collapse in={tableOverviewOpen}>
|
||||
<div className="pt-3" id={overviewPanelId}>
|
||||
{tablePicker}
|
||||
<Divider className="my-3" />
|
||||
{table ? (
|
||||
<TableFolderViewer
|
||||
game={game}
|
||||
highlightFolderSlug={activeFolderSlug}
|
||||
highlightRevealKey={highlightRevealKey}
|
||||
onFolderRowNavigate={collapseTableOverview}
|
||||
reqUser={reqUser}
|
||||
table={table}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`d-flex align-items-center ms-lg-auto ${folderTableStyles.folderRowChartMeta}`}
|
||||
>
|
||||
<span
|
||||
className={`text-body-secondary ${folderTableStyles.folderRowChartCount}`}
|
||||
>
|
||||
{data.stats.chartCount}
|
||||
</span>
|
||||
<Icon
|
||||
aria-hidden
|
||||
className="text-body-secondary opacity-75"
|
||||
type="chevron-right"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
</Collapse>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{tablePicker}
|
||||
<Divider />
|
||||
{table ? <TableFolderViewer {...{ reqUser, game, table }} /> : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+11
-5
@@ -1,4 +1,5 @@
|
||||
import useSetSubheader from "#components/layout/header/useSetSubheader";
|
||||
import Divider from "#components/util/Divider";
|
||||
import { type UGPT } from "#types/react";
|
||||
import React from "react";
|
||||
import { Redirect, Route, Switch } from "react-router-dom";
|
||||
@@ -24,9 +25,6 @@ export default function FoldersMainPage({ reqUser, game }: UGPT) {
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
<Switch>
|
||||
<Route exact path="/u/:userID/games/:game/folders">
|
||||
<FolderTablePage {...{ reqUser, game }} />
|
||||
</Route>
|
||||
<Route
|
||||
exact
|
||||
path="/u/:userID/games/:game/folders/search"
|
||||
@@ -45,8 +43,16 @@ export default function FoldersMainPage({ reqUser, game }: UGPT) {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route path="/u/:userID/games/:game/folders/:folderSlug">
|
||||
<SpecificFolderPage {...{ reqUser, game }} />
|
||||
<Route path="/u/:userID/games/:game/folders">
|
||||
<>
|
||||
<FolderTablePage {...{ reqUser, game }} />
|
||||
<Route path="/u/:userID/games/:game/folders/:folderSlug">
|
||||
<>
|
||||
<Divider className="border-2 mb-4 mt-5" />
|
||||
<SpecificFolderPage {...{ reqUser, game }} />
|
||||
</>
|
||||
</Route>
|
||||
</>
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
+68
-250
@@ -1,11 +1,11 @@
|
||||
import FolderInfoHeader from "#components/game/folder/FolderInfoHeader";
|
||||
import QuickTooltip from "#components/layout/misc/QuickTooltip";
|
||||
import Card from "#components/layout/page/Card";
|
||||
import DifficultyCell from "#components/tables/cells/DifficultyCell";
|
||||
import TimestampCell from "#components/tables/cells/TimestampCell";
|
||||
import TitleCell from "#components/tables/cells/TitleCell";
|
||||
import MiniTable from "#components/tables/components/MiniTable";
|
||||
import FolderTable from "#components/tables/folders/FolderTable";
|
||||
import FolderTable, {
|
||||
FOLDER_FOLDER_TABLE_SCROLL_INTO_VIEW_ID,
|
||||
type FolderEnumBreakdownTablePreset,
|
||||
} from "#components/tables/folders/FolderTable";
|
||||
import ScoreCoreCells from "#components/tables/game-core-cells/ScoreCoreCells";
|
||||
import ApiError from "#components/util/ApiError";
|
||||
import Divider from "#components/util/Divider";
|
||||
@@ -23,13 +23,10 @@ import { type GPTRatingSystem } from "#lib/types";
|
||||
import { type UGPTFolderReturns } from "#types/api-returns";
|
||||
import { type FolderDataset } from "#types/tables";
|
||||
import { ChangeOpacity } from "#util/color-opacity";
|
||||
import { ONE_DAY } from "#util/constants/time";
|
||||
import { CreateChartIDMap, CreateChartLink, CreateSongMap } from "#util/data";
|
||||
import { DistinctArr, ToFixedFloor, UppercaseFirst } from "#util/misc";
|
||||
import { DistinctArr, ToFixedFloor } from "#util/misc";
|
||||
import { NumericSOV, StrSOV } from "#util/sorts";
|
||||
import { FormatDate } from "#util/time";
|
||||
import React, {
|
||||
type CSSProperties,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useContext,
|
||||
@@ -40,25 +37,25 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { Col, Collapse, Form, Row } from "react-bootstrap";
|
||||
import { Link, Route, Switch, useHistory, useLocation, useParams } from "react-router-dom";
|
||||
import { Link, Redirect, Route, Switch, useHistory, useLocation, useParams } from "react-router-dom";
|
||||
import {
|
||||
type ChartDocument,
|
||||
COLOUR_SET,
|
||||
EnumIndexToValue,
|
||||
FormatDifficultyShort,
|
||||
GetGameConfig,
|
||||
GetScoreEnumConfs,
|
||||
type integer,
|
||||
type ScoreDocument,
|
||||
type SongDocument,
|
||||
type UserDocument,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
import { type ConfEnumScoreMetric } from "tachi-common/types/metrics";
|
||||
|
||||
import FolderComparePage from "./FolderComparePage";
|
||||
import FolderQuestsPage from "./FolderQuestsPage";
|
||||
import tierlistStyles from "./SpecificFolderPage.module.scss";
|
||||
import TableEvolutionReplay from "./TableEvolutionReplay";
|
||||
|
||||
/** Extra offset when jumping from breakdown → folder table (`scrollIntoView` start − this). */
|
||||
const FOLDER_BREAKDOWN_TABLE_SCROLL_EXTRA_OFFSET_PX = 50;
|
||||
|
||||
interface Props {
|
||||
reqUser: UserDocument;
|
||||
@@ -96,7 +93,29 @@ export default function SpecificFolderPage({ reqUser, game }: Props) {
|
||||
folderDataset.sort(StrSOV((x) => x.__related.song.title));
|
||||
|
||||
return folderDataset;
|
||||
}, [data]);
|
||||
}, [data, reqUser]);
|
||||
|
||||
const [folderTableEnumPreset, setFolderTableEnumPreset] =
|
||||
useState<FolderEnumBreakdownTablePreset | null>(null);
|
||||
|
||||
const onBreakdownEnumValueClick = useCallback((metricKey: string, valueLabel: string) => {
|
||||
setFolderTableEnumPreset((p) => ({
|
||||
metricKey,
|
||||
valueLabel,
|
||||
nonce: (p?.nonce ?? 0) + 1,
|
||||
}));
|
||||
|
||||
queueMicrotask(() => {
|
||||
const anchor = document.getElementById(FOLDER_FOLDER_TABLE_SCROLL_INTO_VIEW_ID);
|
||||
if (!anchor) return;
|
||||
|
||||
const y =
|
||||
anchor.getBoundingClientRect().top +
|
||||
window.scrollY -
|
||||
FOLDER_BREAKDOWN_TABLE_SCROLL_EXTRA_OFFSET_PX;
|
||||
window.scrollTo({ behavior: "smooth", top: Math.max(0, y) });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const folderInfoHeader = useMemo(() => {
|
||||
if (!folderDataset || !data) {
|
||||
@@ -105,13 +124,14 @@ export default function SpecificFolderPage({ reqUser, game }: Props) {
|
||||
|
||||
return (
|
||||
<FolderInfoHeader
|
||||
folderDataset={folderDataset}
|
||||
folderSlug={folderSlug}
|
||||
folderTitle={data.folder.title}
|
||||
game={game}
|
||||
onBreakdownEnumValueClick={onBreakdownEnumValueClick}
|
||||
reqUser={reqUser}
|
||||
/>
|
||||
);
|
||||
}, [folderDataset]);
|
||||
}, [data, folderSlug, folderDataset, game, onBreakdownEnumValueClick, reqUser]);
|
||||
|
||||
const base = `${useUGPTBase({ reqUser, game })}/folders/${folderSlug}`;
|
||||
|
||||
@@ -146,9 +166,6 @@ export default function SpecificFolderPage({ reqUser, game }: Props) {
|
||||
<Icon type="sort-alpha-up" /> Tierlist View
|
||||
</SelectLinkButton>
|
||||
)}
|
||||
<SelectLinkButton className="text-wrap" to={`${base}/timeline`}>
|
||||
<Icon type="stream" /> Timeline View
|
||||
</SelectLinkButton>
|
||||
<SelectLinkButton className="text-wrap" to={`${base}/compare`}>
|
||||
<Icon type="users" /> Compare Against User
|
||||
</SelectLinkButton>
|
||||
@@ -163,7 +180,13 @@ export default function SpecificFolderPage({ reqUser, game }: Props) {
|
||||
<div className="col-12">
|
||||
<Switch>
|
||||
<Route exact path={base}>
|
||||
<FolderTable dataset={folderDataset} game={game} />
|
||||
<FolderNormalView
|
||||
data={data}
|
||||
folderDataset={folderDataset}
|
||||
folderTableEnumPreset={folderTableEnumPreset}
|
||||
game={game}
|
||||
reqUser={reqUser}
|
||||
/>
|
||||
</Route>
|
||||
<Route exact path={`${base}/tierlist`}>
|
||||
<TierlistBreakdown
|
||||
@@ -174,7 +197,7 @@ export default function SpecificFolderPage({ reqUser, game }: Props) {
|
||||
/>
|
||||
</Route>
|
||||
<Route exact path={`${base}/timeline`}>
|
||||
<TimelineView folderSlug={folderSlug} game={game} reqUser={reqUser} />
|
||||
<Redirect to={base} />
|
||||
</Route>
|
||||
<Route exact path={`${base}/compare`}>
|
||||
<FolderComparePage folder={data.folder} game={game} reqUser={reqUser} />
|
||||
@@ -188,247 +211,42 @@ export default function SpecificFolderPage({ reqUser, game }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Total `<td>` count per timeline score row (index, difficulty, title, scores, timestamp). */
|
||||
function folderTimelineColumnCount(game: V3Game): integer {
|
||||
const headersLen = GPT_CLIENT_IMPLEMENTATIONS[game].scoreHeaders.length;
|
||||
return 5 + headersLen;
|
||||
}
|
||||
|
||||
function TimelineView({ game, reqUser, folderSlug }: { folderSlug: string } & Props) {
|
||||
const gameConfig = GetGameConfig(game);
|
||||
const enumConfs = GetScoreEnumConfs(gameConfig);
|
||||
|
||||
const [selectedEnum, setSelectedEnum] = useState<string>(gameConfig.preferredDefaultEnum);
|
||||
const [enumConf, setEnumConf] = useState<ConfEnumScoreMetric<string>>(enumConfs[selectedEnum]!);
|
||||
|
||||
const [value, setValue] = useState<string>(enumConf.minimumRelevantValue);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(enumConfs[selectedEnum]!.minimumRelevantValue);
|
||||
setEnumConf(enumConfs[selectedEnum]!);
|
||||
}, [selectedEnum]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card cardBodyClassName="vstack gap-4" header="Timeline View">
|
||||
<h5 className="text-center">
|
||||
The timeline view shows the order in which you achieved something in a folder!
|
||||
You can choose the criteria up here.
|
||||
</h5>
|
||||
<div className="d-flex flex-column flex-lg-row gap-4">
|
||||
<Form.Select
|
||||
onChange={(e) => setSelectedEnum(e.target.value)}
|
||||
value={selectedEnum}
|
||||
>
|
||||
{Object.keys(enumConfs).map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{UppercaseFirst(e)}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
<Form.Select onChange={(e) => setValue(e.target.value)} value={value}>
|
||||
{enumConf.values
|
||||
.slice(enumConf.values.indexOf(enumConf.minimumRelevantValue))
|
||||
.map((e) => (
|
||||
<option key={e}>{e}</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</div>
|
||||
</Card>
|
||||
<hr />
|
||||
<TimelineMain {...{ reqUser, game, folderSlug, enumMetric: selectedEnum, value }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineMain({
|
||||
reqUser,
|
||||
function FolderNormalView({
|
||||
data,
|
||||
folderDataset,
|
||||
folderTableEnumPreset,
|
||||
game,
|
||||
folderSlug,
|
||||
enumMetric: enumMetric,
|
||||
value,
|
||||
reqUser,
|
||||
}: {
|
||||
enumMetric: string;
|
||||
folderSlug: string;
|
||||
value: string;
|
||||
data: UGPTFolderReturns;
|
||||
folderDataset: FolderDataset;
|
||||
folderTableEnumPreset: FolderEnumBreakdownTablePreset | null;
|
||||
} & Props) {
|
||||
const { data, error } = useApiQuery<{
|
||||
charts: ChartDocument[];
|
||||
scores: ScoreDocument[];
|
||||
songs: SongDocument[];
|
||||
}>(
|
||||
`/users/${
|
||||
reqUser.id
|
||||
}/games/${game}/folders/${folderSlug}/timeline?criteriaValue=${encodeURIComponent(
|
||||
value,
|
||||
)}&criteriaType=${encodeURIComponent(enumMetric)}`,
|
||||
const gameConfig = useMemo(() => GetGameConfig(game), [game]);
|
||||
|
||||
const evolutionFolderScope = useMemo(
|
||||
() => ({ folder: data.folder, kind: "folder" as const }),
|
||||
[data.folder],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const scoreDataset = [];
|
||||
|
||||
const songMap = CreateSongMap(data.songs);
|
||||
const chartMap = CreateChartIDMap(data.charts);
|
||||
|
||||
for (const score of data.scores) {
|
||||
scoreDataset.push({
|
||||
...score,
|
||||
__related: {
|
||||
song: songMap.get(score.songID)!,
|
||||
chart: chartMap.get(score.chartID)!,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
scoreDataset.sort(NumericSOV((x) => x.timeAchieved ?? Infinity));
|
||||
|
||||
const columnCount = folderTimelineColumnCount(game);
|
||||
const folderTimelineScoreColVar = GPT_CLIENT_IMPLEMENTATIONS[game].scoreHeaders.length + 1;
|
||||
|
||||
const timelineRows = [];
|
||||
|
||||
let lastDay = 0;
|
||||
let index = 1;
|
||||
let hasHitNulls = false;
|
||||
|
||||
for (const scoreData of scoreDataset) {
|
||||
if (scoreData.timeAchieved !== null) {
|
||||
// Insane hack to floor a date to the beginning of that
|
||||
// day.
|
||||
const dayNum = new Date(scoreData.timeAchieved).setHours(0, 0, 0, 0);
|
||||
|
||||
if (!lastDay || lastDay !== dayNum) {
|
||||
lastDay = dayNum;
|
||||
timelineRows.push(
|
||||
<TimelineDivider
|
||||
columnCount={columnCount}
|
||||
key={`folder-timeline-day-${dayNum}`}
|
||||
>
|
||||
{FormatDate(scoreData.timeAchieved)}
|
||||
</TimelineDivider>,
|
||||
);
|
||||
}
|
||||
} else if (!hasHitNulls) {
|
||||
timelineRows.push(
|
||||
<TimelineDivider columnCount={columnCount} key="folder-timeline-unknown-time">
|
||||
Unknown Time
|
||||
</TimelineDivider>,
|
||||
);
|
||||
hasHitNulls = true;
|
||||
}
|
||||
|
||||
timelineRows.push(
|
||||
<TimelineElement index={index} key={scoreData.scoreID} scoreData={scoreData} />,
|
||||
);
|
||||
index++;
|
||||
}
|
||||
|
||||
const emptyTimelineRow =
|
||||
timelineRows.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className="py-5 text-center text-body-secondary fst-italic"
|
||||
colSpan={columnCount}
|
||||
>
|
||||
No scores match this folder and criteria yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-center">
|
||||
<h1 className="display-4">Total Progress</h1>
|
||||
<h1 className="display-4">
|
||||
{data.scores.length}
|
||||
<span className="text-body-secondary" style={{ fontSize: "1.1rem" }}>
|
||||
/{data.charts.length}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
<TableEvolutionReplay
|
||||
game={game}
|
||||
gameConfig={gameConfig}
|
||||
reqUser={reqUser}
|
||||
scope={evolutionFolderScope}
|
||||
/>
|
||||
<Divider />
|
||||
<div className="timeline timeline-2 timeline-folder">
|
||||
<div className="folder-timeline-table-wrap">
|
||||
<table
|
||||
className="table table-hover table-sm mb-0 folder-timeline-table text-center"
|
||||
style={
|
||||
{
|
||||
["--folder-timeline-score-cols" as string]:
|
||||
folderTimelineScoreColVar,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<tbody>{emptyTimelineRow ?? timelineRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="text-center">
|
||||
<h1 className="display-4">Total Progress</h1>
|
||||
<h1 className="display-4">
|
||||
{data.scores.length}
|
||||
<span className="text-body-secondary" style={{ fontSize: "1.1rem" }}>
|
||||
/{data.charts.length}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
<FolderTable
|
||||
dataset={folderDataset}
|
||||
folderBreakdownEnumTablePreset={folderTableEnumPreset}
|
||||
game={game}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineDivider({ children, columnCount }: { children: string; columnCount: integer }) {
|
||||
return (
|
||||
<tr className="folder-timeline-date-row">
|
||||
<td colSpan={columnCount}>
|
||||
<span className="folder-timeline-date-heading">{children}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineElement({
|
||||
scoreData,
|
||||
index,
|
||||
}: {
|
||||
index: integer;
|
||||
scoreData: {
|
||||
__related: {
|
||||
chart: ChartDocument;
|
||||
song: SongDocument;
|
||||
};
|
||||
} & ScoreDocument;
|
||||
}) {
|
||||
return (
|
||||
<tr className="folder-timeline-score-row timeline-hover">
|
||||
<td style={{ fontSize: "1.15rem" }}>
|
||||
<b>#{index}</b>
|
||||
{Date.now() - scoreData.timeAdded < ONE_DAY && (
|
||||
<span className="ms-2 label label-inline label-primary fw-bolder">NEW!</span>
|
||||
)}
|
||||
</td>
|
||||
<DifficultyCell alwaysShort chart={scoreData.__related.chart} game={scoreData.game} />
|
||||
<TitleCell
|
||||
chart={scoreData.__related.chart}
|
||||
game={scoreData.game}
|
||||
song={scoreData.__related.song}
|
||||
/>
|
||||
<ScoreCoreCells
|
||||
chart={scoreData.__related.chart}
|
||||
game={scoreData.game}
|
||||
score={scoreData}
|
||||
/>
|
||||
<TimestampCell tableFixedLayoutCompat time={scoreData.timeAchieved} />
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// here be demons
|
||||
// i don't remember how this code works
|
||||
// and i was almost certainly drinking heavily when i wrote it
|
||||
|
||||
+749
@@ -0,0 +1,749 @@
|
||||
import type { FolderStatsInfo, UGPTEvolutionReplayReturns } from "#types/api-returns";
|
||||
|
||||
import ApiError from "#components/util/ApiError";
|
||||
import Icon from "#components/util/Icon";
|
||||
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 { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations";
|
||||
import { ChangeOpacity } from "#util/color-opacity";
|
||||
import { CreateChartIDMap, CreateSongMap } from "#util/data";
|
||||
import { UppercaseFirst } from "#util/misc";
|
||||
import { FormatDate, MillisToSince } from "#util/time";
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import Button from "react-bootstrap/Button";
|
||||
import Collapse from "react-bootstrap/Collapse";
|
||||
import Dropdown from "react-bootstrap/Dropdown";
|
||||
import {
|
||||
type FolderDocument,
|
||||
type GameConfig,
|
||||
GetScoreMetrics,
|
||||
type TableDocument,
|
||||
type UserDocument,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
import EvolutionMilestoneFeedRow from "./EvolutionMilestoneFeedRow";
|
||||
import EvolutionTimelineStrip from "./EvolutionTimelineStrip";
|
||||
import FolderEnumDistributionBreakdown from "./FolderEnumDistributionBreakdown";
|
||||
import FolderEnumProgressBar from "./FolderEnumProgressBar";
|
||||
import folderTableStyles from "./FolderTablePage.module.scss";
|
||||
import {
|
||||
buildEvolutionReplayFolderStats,
|
||||
evoEventTimeMs,
|
||||
tableFolderSlugsDisplayOrder,
|
||||
} from "./folderTableShared";
|
||||
|
||||
export type EvolutionReplayScope =
|
||||
| { folder: FolderDocument; kind: "folder" }
|
||||
| { kind: "table"; table: TableDocument };
|
||||
|
||||
const MILESTONE_FEED_MAX_ROWS = 20;
|
||||
|
||||
/** Wall-clock multipliers vs the baseline tween (long spans aim for ~≤28s at 1×). */
|
||||
const PLAYBACK_SPEED_OPTIONS = [0.25, 0.5, 1, 2, 4] as const;
|
||||
|
||||
export default function TableEvolutionReplay({
|
||||
controlledEnumMetric,
|
||||
game,
|
||||
gameConfig,
|
||||
reqUser,
|
||||
scope,
|
||||
}: {
|
||||
controlledEnumMetric?: readonly [
|
||||
metric: string,
|
||||
setMetric: React.Dispatch<React.SetStateAction<string>>,
|
||||
];
|
||||
game: V3Game;
|
||||
gameConfig: GameConfig;
|
||||
reqUser: UserDocument;
|
||||
scope: EvolutionReplayScope;
|
||||
}) {
|
||||
const externallyControlled = controlledEnumMetric !== undefined;
|
||||
const replayScopeKey = scope.kind === "folder" ? scope.folder.folderID : scope.table.tableID;
|
||||
|
||||
const bucket = useBucket(game);
|
||||
const [internalEnumMetric, setInternalEnumMetric] = useState(bucket);
|
||||
|
||||
useEffect(() => {
|
||||
if (externallyControlled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInternalEnumMetric(bucket);
|
||||
}, [bucket, externallyControlled, game, replayScopeKey]);
|
||||
|
||||
const enumMetric = externallyControlled ? controlledEnumMetric[0] : internalEnumMetric;
|
||||
const setEnumMetric = externallyControlled ? controlledEnumMetric[1] : setInternalEnumMetric;
|
||||
|
||||
const enumMetricChoices = useMemo(() => GetScoreMetrics(gameConfig, "ENUM"), [gameConfig]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scrubTimeMs, setScrubTimeMs] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState<(typeof PLAYBACK_SPEED_OPTIONS)[number]>(1);
|
||||
|
||||
/** Wall clock anchor for timeline max (right edge = "now"); ticks while replay is expanded. */
|
||||
const [replayNowMs, setReplayNowMs] = useState(() => Date.now());
|
||||
/** Bumps whenever the replay panel expands so we snap scrub to today's edge without reacting to replayNowMs ticking. */
|
||||
const [replayOpenNonce, setReplayOpenNonce] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReplayNowMs(Date.now());
|
||||
setReplayOpenNonce((n) => n + 1);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => {
|
||||
setReplayNowMs(Date.now());
|
||||
}, 60_000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const evolutionUrl =
|
||||
scope.kind === "table"
|
||||
? `/users/${reqUser.id}/games/${game}/tables/${scope.table.tableID}/evolution`
|
||||
: `/users/${reqUser.id}/games/${game}/folders/${scope.folder.slug}/evolution`;
|
||||
|
||||
const replayDomId = scope.kind === "table" ? scope.table.tableID : scope.folder.folderID;
|
||||
|
||||
const folderSlugOrder = useMemo(
|
||||
() =>
|
||||
scope.kind === "table"
|
||||
? tableFolderSlugsDisplayOrder(scope.table)
|
||||
: [scope.folder.slug],
|
||||
[scope],
|
||||
);
|
||||
|
||||
const scrubRef = useRef(0);
|
||||
const foldersReplayPanelRef = useRef<HTMLDivElement>(null);
|
||||
const [milestoneFeedFrameHeightPx, setMilestoneFeedFrameHeightPx] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
scrubRef.current = scrubTimeMs;
|
||||
}, [scrubTimeMs]);
|
||||
|
||||
const {
|
||||
data: evo,
|
||||
error: evoError,
|
||||
isLoading: evolutionLoading,
|
||||
} = useApiQuery<UGPTEvolutionReplayReturns>(evolutionUrl, undefined, undefined, !open);
|
||||
|
||||
const events = useMemo(() => evo?.events ?? [], [evo?.events]);
|
||||
|
||||
const workingEvents = useMemo(
|
||||
() => events.filter((evItem) => evItem.metric === enumMetric),
|
||||
[events, enumMetric],
|
||||
);
|
||||
|
||||
/** Chronologically first milestone in the evolution payload (any enum metric ≈ earliest table scores we model). */
|
||||
const tableEvolutionEarliestMs = useMemo(() => {
|
||||
let earliest: number | undefined;
|
||||
|
||||
for (const evItem of events) {
|
||||
const t = evoEventTimeMs(evItem);
|
||||
if (!Number.isFinite(t)) {
|
||||
continue;
|
||||
}
|
||||
earliest = earliest === undefined ? t : Math.min(earliest, t);
|
||||
}
|
||||
|
||||
return earliest;
|
||||
}, [events]);
|
||||
|
||||
const { minAxis, maxAxis } = useMemo(() => {
|
||||
const nowMs = replayNowMs;
|
||||
|
||||
if (workingEvents.length === 0) {
|
||||
return { minAxis: 0, maxAxis: 1 };
|
||||
}
|
||||
|
||||
const times = workingEvents.map(evoEventTimeMs);
|
||||
const firstT = Math.min(...times);
|
||||
const lastT = Math.max(...times);
|
||||
|
||||
if (!(Number.isFinite(firstT) && Number.isFinite(lastT))) {
|
||||
const anchorMs =
|
||||
tableEvolutionEarliestMs !== undefined
|
||||
? tableEvolutionEarliestMs
|
||||
: nowMs - 3_600_000;
|
||||
return {
|
||||
minAxis: anchorMs,
|
||||
maxAxis: nowMs,
|
||||
};
|
||||
}
|
||||
|
||||
const axisLeftMs =
|
||||
tableEvolutionEarliestMs !== undefined ? tableEvolutionEarliestMs : firstT;
|
||||
|
||||
return {
|
||||
// Timeline starts at the earliest milestone in evolution for this table (any metric).
|
||||
minAxis: axisLeftMs,
|
||||
|
||||
maxAxis: Math.max(nowMs, lastT),
|
||||
};
|
||||
}, [replayNowMs, tableEvolutionEarliestMs, workingEvents]);
|
||||
|
||||
const axisSpan = Math.max(maxAxis - minAxis, 1);
|
||||
|
||||
const minAxisRef = useRef(minAxis);
|
||||
minAxisRef.current = minAxis;
|
||||
const maxAxisRef = useRef(maxAxis);
|
||||
maxAxisRef.current = maxAxis;
|
||||
const axisSpanRef = useRef(axisSpan);
|
||||
axisSpanRef.current = axisSpan;
|
||||
|
||||
const appliedEvents = useMemo(() => {
|
||||
if (workingEvents.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return workingEvents.filter((e) => evoEventTimeMs(e) <= scrubTimeMs);
|
||||
}, [scrubTimeMs, workingEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !evo) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (workingEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setScrubTimeMs(maxAxisRef.current);
|
||||
setPlaying(false);
|
||||
}, [evo, enumMetric, open, replayOpenNonce, workingEvents.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing || workingEvents.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const rawFrom = scrubRef.current;
|
||||
const maxStart = maxAxisRef.current;
|
||||
const minStart = minAxisRef.current;
|
||||
const spanStart = axisSpanRef.current;
|
||||
|
||||
const from = Math.min(Math.max(rawFrom, minStart), maxStart);
|
||||
|
||||
if (from >= maxStart) {
|
||||
setPlaying(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const remainder = Math.max(maxStart - from, 1);
|
||||
const timelineSpan = Math.max(spanStart, 1);
|
||||
const playWallMsBase = Math.max(600, Math.min(28_000, (remainder / timelineSpan) * 28_000));
|
||||
// Playback presets were uniformly too brisk; ×2 duration halves effective speed at each multiplier.
|
||||
const playWallMs = (2 * playWallMsBase) / playbackSpeed;
|
||||
const wallT0 = performance.now();
|
||||
|
||||
function tickFrame(nowWall: number) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const p = Math.min(1, (nowWall - wallT0) / playWallMs);
|
||||
const next = from + remainder * p;
|
||||
|
||||
setScrubTimeMs(Math.min(Math.max(next, minAxisRef.current), maxAxisRef.current));
|
||||
|
||||
if (p >= 1) {
|
||||
queueMicrotask(() => {
|
||||
setPlaying(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(tickFrame);
|
||||
}
|
||||
|
||||
requestAnimationFrame(tickFrame);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [playbackSpeed, playing, workingEvents.length]);
|
||||
|
||||
const rangeStep = useMemo(() => {
|
||||
const coarse = axisSpan / 1500;
|
||||
return Math.min(axisSpan, Math.max(1000, Math.floor(coarse)));
|
||||
}, [axisSpan]);
|
||||
|
||||
const folderBySlug = useMemo(() => {
|
||||
const m = new Map<string, FolderDocument>();
|
||||
for (const f of evo?.folders ?? []) {
|
||||
m.set(f.slug, f);
|
||||
}
|
||||
return m;
|
||||
}, [evo?.folders]);
|
||||
|
||||
const chartMap = useMemo(
|
||||
() => (evo?.charts ? CreateChartIDMap(evo.charts) : new Map()),
|
||||
[evo?.charts],
|
||||
);
|
||||
const songMap = useMemo(
|
||||
() => (evo?.songs ? CreateSongMap(evo.songs) : new Map()),
|
||||
[evo?.songs],
|
||||
);
|
||||
|
||||
const folderChartIDs = useMemo(() => evo?.folderChartIDs ?? {}, [evo?.folderChartIDs]);
|
||||
const replayRows = useMemo(() => {
|
||||
if (!open || !evo) {
|
||||
return [] as Array<{ folder: FolderDocument; stats: FolderStatsInfo }>;
|
||||
}
|
||||
|
||||
const applied = appliedEvents;
|
||||
const rows: Array<{ folder: FolderDocument; stats: FolderStatsInfo }> = [];
|
||||
|
||||
for (const slug of folderSlugOrder) {
|
||||
const folder = folderBySlug.get(slug);
|
||||
|
||||
if (!folder) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stats = buildEvolutionReplayFolderStats({
|
||||
enumMetric,
|
||||
eventsPrefix: applied,
|
||||
folderChartIDs,
|
||||
folderSlug: slug,
|
||||
gameConfig,
|
||||
});
|
||||
|
||||
rows.push({ folder, stats });
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [
|
||||
appliedEvents,
|
||||
enumMetric,
|
||||
evo,
|
||||
folderBySlug,
|
||||
folderChartIDs,
|
||||
folderSlugOrder,
|
||||
gameConfig,
|
||||
open,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || workingEvents.length === 0) {
|
||||
setMilestoneFeedFrameHeightPx(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const el = foldersReplayPanelRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
let rafId = 0;
|
||||
|
||||
const syncHeight = () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const h = el.getBoundingClientRect().height;
|
||||
const next = Boolean(h) && Number.isFinite(h) ? Math.round(h * 1000) / 1000 : 0;
|
||||
setMilestoneFeedFrameHeightPx((prev) => (prev === next ? prev : next));
|
||||
});
|
||||
};
|
||||
|
||||
syncHeight();
|
||||
|
||||
const ro = new ResizeObserver(syncHeight);
|
||||
ro.observe(el);
|
||||
window.addEventListener("resize", syncHeight);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
window.removeEventListener("resize", syncHeight);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [
|
||||
appliedEvents.length,
|
||||
enumMetric,
|
||||
open,
|
||||
replayRows.length,
|
||||
scrubTimeMs,
|
||||
replayDomId,
|
||||
workingEvents.length,
|
||||
]);
|
||||
|
||||
const allEnumColours = useMemo(
|
||||
() =>
|
||||
GPT_CLIENT_IMPLEMENTATIONS[game].enumColours as
|
||||
| Record<string, Record<string, string>>
|
||||
| undefined,
|
||||
[game],
|
||||
);
|
||||
|
||||
const enumColours = useMemo(() => allEnumColours?.[enumMetric], [allEnumColours, enumMetric]);
|
||||
|
||||
const feedRows = useMemo(() => [...appliedEvents].reverse(), [appliedEvents]);
|
||||
const visibleFeedRows = useMemo(() => feedRows.slice(0, MILESTONE_FEED_MAX_ROWS), [feedRows]);
|
||||
const _milestoneFeedTruncated = feedRows.length > MILESTONE_FEED_MAX_ROWS;
|
||||
|
||||
const onPlayToggle = () => {
|
||||
if (workingEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (playing) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrubTimeMs >= maxAxis) {
|
||||
setScrubTimeMs(minAxis);
|
||||
}
|
||||
|
||||
setPlaying(true);
|
||||
};
|
||||
|
||||
const feedRowAccent = (metric: string, valueLabel: string): React.CSSProperties => {
|
||||
const raw = allEnumColours?.[metric]?.[valueLabel];
|
||||
const borderLeftColor = raw ?? "var(--bs-secondary)";
|
||||
let backgroundColor: string | undefined;
|
||||
if (raw?.startsWith("#") || raw?.startsWith("rgb")) {
|
||||
try {
|
||||
backgroundColor = ChangeOpacity(raw, 0.12);
|
||||
} catch {
|
||||
backgroundColor = undefined;
|
||||
}
|
||||
}
|
||||
return {
|
||||
borderLeftWidth: 4,
|
||||
borderLeftStyle: "solid",
|
||||
borderLeftColor,
|
||||
...(backgroundColor ? { backgroundColor } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const rangeId = `evolution-replay-scrub-${replayDomId}`;
|
||||
const triggerLabelId = `evolution-replay-trigger-${replayDomId}`;
|
||||
const collapseId = `evolution-replay-collapse-${replayDomId}`;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={triggerLabelId}
|
||||
className="border border-secondary border-opacity-50 bg-body-tertiary bg-opacity-10 mb-4 px-3 py-4 rounded-4 shadow-sm"
|
||||
>
|
||||
<div className="d-flex justify-content-center px-2">
|
||||
<Button
|
||||
aria-controls={collapseId}
|
||||
aria-expanded={open}
|
||||
className="align-items-center d-flex flex-column flex-sm-row gap-2 gap-sm-3 fw-bold justify-content-center px-4 px-md-5 py-3 rounded-3 shadow-sm text-nowrap"
|
||||
onClick={() => {
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
size="lg"
|
||||
style={{ maxWidth: "36rem", width: "100%" }}
|
||||
variant="primary"
|
||||
>
|
||||
<Icon
|
||||
aria-hidden
|
||||
className="opacity-90"
|
||||
type={open ? "chevron-up" : "chevron-down"}
|
||||
/>
|
||||
<span className="text-center" id={triggerLabelId}>
|
||||
{scope.kind === "table"
|
||||
? "Watch table progress over time"
|
||||
: "Watch folder progress over time"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Collapse in={open}>
|
||||
<div className="mt-3" id={collapseId}>
|
||||
{enumMetricChoices.length > 1 ? (
|
||||
<div className="d-flex justify-content-center mb-3 px-2">
|
||||
<div className="btn-group scrollbar-hide flex-wrap justify-content-center overflow-x-auto">
|
||||
{enumMetricChoices.map((metric) => (
|
||||
<SelectButton
|
||||
className="text-wrap"
|
||||
id={metric}
|
||||
key={metric}
|
||||
setValue={setEnumMetric}
|
||||
value={enumMetric}
|
||||
>
|
||||
<Icon
|
||||
type={
|
||||
/* @ts-expect-error enum icon keys align with score metrics */
|
||||
GPT_CLIENT_IMPLEMENTATIONS[game].enumIcons[metric]
|
||||
}
|
||||
/>{" "}
|
||||
{UppercaseFirst(metric)}
|
||||
</SelectButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{evoError ? <ApiError error={evoError} /> : null}
|
||||
|
||||
{open && evolutionLoading ? <Loading /> : null}
|
||||
|
||||
{evo && !evolutionLoading && !evoError ? (
|
||||
<>
|
||||
{events.length === 0 ? (
|
||||
<small className="d-block mb-3 text-body-secondary">
|
||||
{scope.kind === "table"
|
||||
? "Nothing, yet for this table."
|
||||
: "Nothing, yet for this folder."}
|
||||
</small>
|
||||
) : (
|
||||
<>
|
||||
<div className="row g-4">
|
||||
<div className="col-12">
|
||||
<EvolutionTimelineStrip
|
||||
allEnumColours={allEnumColours}
|
||||
axisSpan={axisSpan}
|
||||
enumMetric={enumMetric}
|
||||
gameConfig={gameConfig}
|
||||
maxAxis={maxAxis}
|
||||
minAxis={minAxis}
|
||||
rangeId={rangeId}
|
||||
rangeStep={rangeStep}
|
||||
scrubTimeMs={scrubTimeMs}
|
||||
setPlaying={setPlaying}
|
||||
setScrubTimeMs={setScrubTimeMs}
|
||||
workingEvents={workingEvents}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="border-primary border-opacity-50 bg-primary bg-opacity-10 border min-w-0 mt-4 px-3 py-4 rounded-4 shadow-sm text-center w-100"
|
||||
style={{ borderWidth: "2px" }}
|
||||
>
|
||||
<div className="fw-semibold lh-sm pb-2 small text-body-secondary text-uppercase">
|
||||
Current replay time
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="display-5 fw-bold text-body-emphasis tabular-nums text-truncate"
|
||||
title={FormatDate(scrubTimeMs)}
|
||||
>
|
||||
{FormatDate(scrubTimeMs)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="lh-sm mt-2 text-truncate"
|
||||
title={MillisToSince(scrubTimeMs) ?? undefined}
|
||||
>
|
||||
<Muted>{MillisToSince(scrubTimeMs)}</Muted>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex justify-content-center flex-wrap align-items-stretch gap-3 mt-4">
|
||||
<Button
|
||||
className="align-items-center d-inline-flex fw-bold gap-3 px-5 py-3 shadow-sm"
|
||||
onClick={onPlayToggle}
|
||||
size="lg"
|
||||
type="button"
|
||||
variant="success"
|
||||
>
|
||||
<Icon
|
||||
aria-hidden
|
||||
className="fs-3"
|
||||
type={playing ? "pause" : "play"}
|
||||
/>
|
||||
<span>
|
||||
{playing ? "Pause replay" : "Play replay"}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<div className="d-flex align-items-center">
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle
|
||||
className="h-100"
|
||||
id={`replay-playback-menu-${replayDomId}`}
|
||||
variant="outline-secondary"
|
||||
>
|
||||
<span className="d-inline-flex align-items-center gap-2 fw-semibold">
|
||||
<span>Playback</span>
|
||||
<span className="small tabular-nums text-body-secondary">
|
||||
{playbackSpeed}×
|
||||
</span>
|
||||
</span>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{PLAYBACK_SPEED_OPTIONS.map((rate) => (
|
||||
<Dropdown.Item
|
||||
active={playbackSpeed === rate}
|
||||
as="button"
|
||||
key={rate}
|
||||
onClick={() =>
|
||||
setPlaybackSpeed(rate)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{rate}×
|
||||
{rate === 1 ? (
|
||||
<span className="text-body-secondary small ms-1">
|
||||
(normal)
|
||||
</span>
|
||||
) : null}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row g-4 mt-4">
|
||||
<div className="col-12 col-lg-7">
|
||||
<h6 className="text-body-secondary mb-3">
|
||||
{scope.kind === "table"
|
||||
? "Folders at"
|
||||
: "Folder at"}{" "}
|
||||
<span className="fw-semibold tabular-nums text-body-emphasis">
|
||||
{FormatDate(scrubTimeMs)}
|
||||
</span>
|
||||
</h6>
|
||||
<div
|
||||
className="border bg-body-secondary bg-opacity-10 gap-3 rounded vstack px-3 py-2"
|
||||
ref={foldersReplayPanelRef}
|
||||
>
|
||||
{replayRows.map(({ folder, stats }) => (
|
||||
<div
|
||||
className={`${folderTableStyles.folderRow} bg-body-tertiary bg-opacity-25 rounded px-2 py-2`}
|
||||
key={folder.slug}
|
||||
>
|
||||
<div className="d-flex flex-column flex-lg-row align-items-lg-center gap-2 gap-lg-3">
|
||||
<div
|
||||
className={`fw-semibold text-truncate ${folderTableStyles.folderRowTitle}`}
|
||||
>
|
||||
{folder.title}
|
||||
</div>
|
||||
<div
|
||||
className="flex-grow-1 min-w-0"
|
||||
style={{
|
||||
minWidth: "10rem",
|
||||
}}
|
||||
>
|
||||
<FolderEnumProgressBar
|
||||
animateSegments={false}
|
||||
clipToMinimumRelevance
|
||||
colours={enumColours}
|
||||
enumMetric={enumMetric}
|
||||
gameConfig={gameConfig}
|
||||
stats={stats}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`text-body-secondary ${folderTableStyles.folderRowChartMeta}`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
folderTableStyles.folderRowChartCount
|
||||
}
|
||||
>
|
||||
{stats.chartCount}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{scope.kind === "folder" ? (
|
||||
<FolderEnumDistributionBreakdown
|
||||
clipToMinimumRelevance
|
||||
colours={enumColours}
|
||||
enumMetric={enumMetric}
|
||||
gameConfig={gameConfig}
|
||||
remainderLabel="Unfilled bar (no qualifying milestone)"
|
||||
stats={stats}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-lg-5">
|
||||
<div className="mb-3">
|
||||
<h6 className="mb-1 text-body-secondary">Scores</h6>
|
||||
</div>
|
||||
<div
|
||||
className="border bg-body-secondary bg-opacity-10 d-flex flex-column gap-3 overflow-auto px-3 py-3 rounded-3"
|
||||
style={{
|
||||
minHeight: "400px",
|
||||
overflowX: "hidden",
|
||||
...(milestoneFeedFrameHeightPx > 0
|
||||
? {
|
||||
height: milestoneFeedFrameHeightPx,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{visibleFeedRows.length === 0 ? (
|
||||
<small className="d-block mb-1 text-body-secondary">
|
||||
Nothing before this position.
|
||||
</small>
|
||||
) : (
|
||||
visibleFeedRows.map((evRow) => {
|
||||
const chart = chartMap.get(evRow.chartID);
|
||||
const song = chart
|
||||
? songMap.get(chart.song.id)
|
||||
: undefined;
|
||||
|
||||
const chipFill =
|
||||
allEnumColours?.[evRow.metric]?.[
|
||||
evRow.value
|
||||
];
|
||||
let chipBg: string | undefined;
|
||||
if (
|
||||
chipFill &&
|
||||
(chipFill.startsWith("#") ||
|
||||
chipFill.startsWith("rgb"))
|
||||
) {
|
||||
try {
|
||||
chipBg = ChangeOpacity(
|
||||
chipFill,
|
||||
0.2,
|
||||
);
|
||||
} catch {
|
||||
chipBg = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EvolutionMilestoneFeedRow
|
||||
articleStyle={feedRowAccent(
|
||||
evRow.metric,
|
||||
evRow.value,
|
||||
)}
|
||||
chart={chart}
|
||||
chipBg={chipBg}
|
||||
chipFill={chipFill}
|
||||
evRow={evRow}
|
||||
game={game}
|
||||
key={`${evRow.scoreID}-${evRow.metric}-${evRow.enumIndex}`}
|
||||
song={song}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Collapse>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import Icon from "#components/util/Icon";
|
||||
import { UserContext } from "#context/UserContext";
|
||||
import { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations";
|
||||
import { APIFetchV1 } from "#util/api";
|
||||
import React, { useContext, useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
GetGameConfig,
|
||||
type TableDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
import FolderEnumProgressBar from "./FolderEnumProgressBar";
|
||||
import folderTableStyles from "./FolderTablePage.module.scss";
|
||||
import {
|
||||
type FolderTableScopedProps,
|
||||
tableFolderSlugsDisplayOrder,
|
||||
type UGPTFolderStats,
|
||||
} from "./folderTableShared";
|
||||
|
||||
export default function TableFolderList({
|
||||
dataMap,
|
||||
enumMetric,
|
||||
game,
|
||||
highlightFolderSlug,
|
||||
highlightRevealKey = 0,
|
||||
onFolderRowNavigate,
|
||||
reqUser,
|
||||
table,
|
||||
}: {
|
||||
dataMap: Map<string, UGPTFolderStats>;
|
||||
enumMetric: string;
|
||||
highlightFolderSlug?: string;
|
||||
highlightRevealKey?: number;
|
||||
onFolderRowNavigate?: () => void;
|
||||
table: TableDocument;
|
||||
} & FolderTableScopedProps) {
|
||||
const gameConfig = useMemo(() => GetGameConfig(game), [game]);
|
||||
|
||||
const enumColours = useMemo(
|
||||
() =>
|
||||
(
|
||||
GPT_CLIENT_IMPLEMENTATIONS[game].enumColours as
|
||||
| Record<string, Record<string, string>>
|
||||
| undefined
|
||||
)?.[enumMetric],
|
||||
[enumMetric, game],
|
||||
);
|
||||
|
||||
const dataset = useMemo(() => {
|
||||
const arr = [];
|
||||
for (const folder of tableFolderSlugsDisplayOrder(table)) {
|
||||
const data = dataMap.get(folder);
|
||||
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
arr.push(data);
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [dataMap, table]);
|
||||
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{dataset.length === 0 ? (
|
||||
<div className="text-center text-body-secondary py-5">No folders.</div>
|
||||
) : (
|
||||
dataset.map((data) => {
|
||||
const isFolderOpen = highlightFolderSlug === data.folder.slug;
|
||||
const linkKey =
|
||||
isFolderOpen && highlightRevealKey > 0
|
||||
? `${data.folder.slug}--hilite--${highlightRevealKey}`
|
||||
: data.folder.slug;
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={`${folderTableStyles.folderRow} bg-body-tertiary bg-opacity-25${
|
||||
isFolderOpen ? ` ${folderTableStyles.folderRowHighlighted}` : ""
|
||||
}`}
|
||||
key={linkKey}
|
||||
onClick={() => {
|
||||
if (user?.id === reqUser.id) {
|
||||
APIFetchV1(
|
||||
`/users/${reqUser.id}/games/${game}/folders/${data.folder.slug}/viewed`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
onFolderRowNavigate?.();
|
||||
}}
|
||||
to={`/u/${reqUser.username}/games/${game}/folders/${data.folder.slug}`}
|
||||
>
|
||||
<div className="d-flex flex-column flex-lg-row align-items-lg-center gap-2 gap-lg-3">
|
||||
<div
|
||||
className={`fw-semibold text-truncate ${folderTableStyles.folderRowTitle}`}
|
||||
>
|
||||
{data.folder.title}
|
||||
</div>
|
||||
<div className="flex-grow-1 min-w-0" style={{ minWidth: "10rem" }}>
|
||||
<FolderEnumProgressBar
|
||||
colours={enumColours}
|
||||
enumMetric={enumMetric}
|
||||
gameConfig={gameConfig}
|
||||
key={`${enumMetric}-${data.folder.slug}`}
|
||||
stats={data.stats}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`d-flex align-items-center ms-lg-auto ${folderTableStyles.folderRowChartMeta}`}
|
||||
>
|
||||
<span
|
||||
className={`text-body-secondary ${folderTableStyles.folderRowChartCount}`}
|
||||
>
|
||||
{data.stats.chartCount}
|
||||
</span>
|
||||
<Icon
|
||||
aria-hidden
|
||||
className="text-body-secondary opacity-75"
|
||||
type="chevron-right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import type { UGPTTableReturns } from "#types/api-returns";
|
||||
|
||||
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 { useBucket } from "#components/util/useBucket";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { GetGameConfig, type TableDocument } from "tachi-common";
|
||||
|
||||
import type { FolderTableScopedProps, UGPTFolderStats } from "./folderTableShared";
|
||||
|
||||
import TableEvolutionReplay from "./TableEvolutionReplay";
|
||||
import TableFolderList from "./TableFolderList";
|
||||
|
||||
export default function TableFolderViewer({
|
||||
game,
|
||||
highlightFolderSlug,
|
||||
highlightRevealKey = 0,
|
||||
onFolderRowNavigate,
|
||||
reqUser,
|
||||
table,
|
||||
}: {
|
||||
highlightFolderSlug?: string;
|
||||
highlightRevealKey?: number;
|
||||
onFolderRowNavigate?: () => void;
|
||||
table: TableDocument;
|
||||
} & FolderTableScopedProps) {
|
||||
const { data, error } = useApiQuery<UGPTTableReturns>(
|
||||
`/users/${reqUser.id}/games/${game}/tables/${table.tableID}`,
|
||||
);
|
||||
|
||||
const bucket = useBucket(game);
|
||||
const [enumMetric, setEnumMetric] = useState(bucket);
|
||||
|
||||
useEffect(() => {
|
||||
setEnumMetric(bucket);
|
||||
}, [bucket, table.tableID]);
|
||||
|
||||
const [dataMap, setDataMap] = useState<Map<string, UGPTFolderStats>>(new Map());
|
||||
const [hasLoadedFolderMap, setHasLoadedFolderMap] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const statMap = new Map();
|
||||
for (const stat of data.stats) {
|
||||
statMap.set(stat.slug, stat);
|
||||
}
|
||||
|
||||
const newMap = new Map();
|
||||
for (const folder of data.folders) {
|
||||
const stats = statMap.get(folder.slug)!;
|
||||
newMap.set(folder.slug, { folder, stats });
|
||||
}
|
||||
setDataMap(newMap);
|
||||
setHasLoadedFolderMap(true);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const gameConfig = useMemo(() => GetGameConfig(game), [game]);
|
||||
|
||||
const evolutionScope = useMemo(
|
||||
() => ({ kind: "table" as const, table }),
|
||||
[table],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
if (!data || !hasLoadedFolderMap) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableEvolutionReplay
|
||||
controlledEnumMetric={[enumMetric, setEnumMetric]}
|
||||
game={game}
|
||||
gameConfig={gameConfig}
|
||||
reqUser={reqUser}
|
||||
scope={evolutionScope}
|
||||
/>
|
||||
<Divider className="my-0 border-2" />
|
||||
<TableFolderList
|
||||
dataMap={dataMap}
|
||||
enumMetric={enumMetric}
|
||||
game={game}
|
||||
highlightFolderSlug={highlightFolderSlug}
|
||||
highlightRevealKey={highlightRevealKey}
|
||||
onFolderRowNavigate={onFolderRowNavigate}
|
||||
reqUser={reqUser}
|
||||
table={table}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
FolderStatsInfo,
|
||||
TableEvolutionEventAPI,
|
||||
} from "#types/api-returns";
|
||||
import {
|
||||
GetScoreMetricConf,
|
||||
type FolderDocument,
|
||||
type GameConfig,
|
||||
type integer,
|
||||
type TableDocument,
|
||||
type UserDocument,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
export type FolderTableScopedProps = {
|
||||
game: V3Game;
|
||||
reqUser: UserDocument;
|
||||
};
|
||||
|
||||
export interface UGPTFolderStats {
|
||||
folder: FolderDocument;
|
||||
stats: FolderStatsInfo;
|
||||
}
|
||||
|
||||
/** API `table.folders` is ascending (e.g. Level 1 … 12); show highest folder / level first in chart + table. */
|
||||
export function tableFolderSlugsDisplayOrder(table: TableDocument): string[] {
|
||||
return [...table.folders].reverse();
|
||||
}
|
||||
|
||||
export function evoEventTimeMs(ev: TableEvolutionEventAPI): number {
|
||||
return ev.timeAchieved ?? ev.timeAdded;
|
||||
}
|
||||
|
||||
export function buildEvolutionReplayFolderStats(opts: {
|
||||
enumMetric: string;
|
||||
eventsPrefix: TableEvolutionEventAPI[];
|
||||
folderChartIDs: Record<string, string[]>;
|
||||
folderSlug: string;
|
||||
gameConfig: GameConfig;
|
||||
}): FolderStatsInfo {
|
||||
const chartIds = opts.folderChartIDs[opts.folderSlug] ?? [];
|
||||
|
||||
const metricConf = GetScoreMetricConf(opts.gameConfig, opts.enumMetric);
|
||||
if (metricConf.type !== "ENUM") {
|
||||
return { slug: opts.folderSlug, chartCount: chartIds.length, stats: {} };
|
||||
}
|
||||
|
||||
const peakByMetricByChart = new Map<string, Record<string, number>>();
|
||||
|
||||
for (const ev of opts.eventsPrefix) {
|
||||
const forChart = peakByMetricByChart.get(ev.chartID) ?? {};
|
||||
forChart[ev.metric] = ev.enumIndex;
|
||||
peakByMetricByChart.set(ev.chartID, forChart);
|
||||
}
|
||||
|
||||
const bucket: Record<string, integer> = {};
|
||||
|
||||
for (const cid of chartIds) {
|
||||
const idx = peakByMetricByChart.get(cid)?.[opts.enumMetric];
|
||||
if (idx === undefined) {
|
||||
continue;
|
||||
}
|
||||
const lbl = metricConf.values[idx];
|
||||
if (!lbl) {
|
||||
continue;
|
||||
}
|
||||
bucket[lbl] = (bucket[lbl] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
slug: opts.folderSlug,
|
||||
chartCount: chartIds.length,
|
||||
stats: { [opts.enumMetric]: bucket },
|
||||
};
|
||||
}
|
||||
@@ -1,93 +1,88 @@
|
||||
import FolderEnumDistributionBreakdown from "#app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumDistributionBreakdown";
|
||||
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 ApiError from "#components/util/ApiError";
|
||||
import Loading from "#components/util/Loading";
|
||||
import useApiQuery from "#components/util/query/useApiQuery";
|
||||
import { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations";
|
||||
import { type UGPTFolderSlugStatsReturns } from "#types/api-returns";
|
||||
import { type GamePT } from "#types/react";
|
||||
import { type FolderDataset } from "#types/tables";
|
||||
import { UppercaseFirst } from "#util/misc";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { GetGameConfig, GetScoreEnumConfs, type UserDocument } from "tachi-common";
|
||||
|
||||
import FolderMinimap from "./FolderMinimap";
|
||||
import FolderScoreAverages from "./FolderScoreAverages";
|
||||
import FolderScoreDistributionChart from "./FolderScoreDistributionChart";
|
||||
import React, { useMemo } from "react";
|
||||
import { GetGameConfig, GetScoreMetrics, type UserDocument } from "tachi-common";
|
||||
|
||||
export default function FolderInfoHeader({
|
||||
game,
|
||||
reqUser,
|
||||
folderDataset,
|
||||
folderSlug,
|
||||
folderTitle,
|
||||
game,
|
||||
onBreakdownEnumValueClick,
|
||||
reqUser,
|
||||
}: {
|
||||
folderDataset: FolderDataset;
|
||||
folderSlug: string;
|
||||
folderTitle: string;
|
||||
onBreakdownEnumValueClick?: (metricKey: string, enumValueLabel: string) => void;
|
||||
reqUser: UserDocument;
|
||||
} & GamePT) {
|
||||
const preferredDefaultEnum = useBucket(game);
|
||||
|
||||
const [currentGraph, setCurrentGraph] = useState<string>(`${preferredDefaultEnum}-stats`);
|
||||
|
||||
const enumGraphs = ["minimap", "stats"];
|
||||
const folderStatsUrl = `/users/${reqUser.id}/games/${game}/folders/${folderSlug}/stats`;
|
||||
const {
|
||||
data: folderStatsBody,
|
||||
error: folderStatsError,
|
||||
isLoading: folderStatsLoading,
|
||||
} = useApiQuery<UGPTFolderSlugStatsReturns>(folderStatsUrl);
|
||||
|
||||
const gameConfig = GetGameConfig(game);
|
||||
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
|
||||
|
||||
const enumConf = GetScoreEnumConfs(gameConfig);
|
||||
|
||||
const [metric, type] = useMemo(() => currentGraph.split("-"), [currentGraph]);
|
||||
const enumMetrics = useMemo(() => GetScoreMetrics(gameConfig, "ENUM"), [gameConfig]);
|
||||
const enumColourMaps = GPT_CLIENT_IMPLEMENTATIONS[game].enumColours as
|
||||
| Record<string, Record<string, string>>
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<Card header={`${reqUser.username}'s ${folderTitle} Breakdown`}>
|
||||
<div className="col-12 d-flex justify-content-center">
|
||||
<div className="btn-group">
|
||||
{enumGraphs.flatMap((g) =>
|
||||
Object.keys(enumConf).flatMap((en) => (
|
||||
<SelectButton
|
||||
className={
|
||||
g === "minimap" ? "d-none d-lg-block text-wrap" : "text-wrap"
|
||||
}
|
||||
id={`${en}-${g}`}
|
||||
key={`${en}-${g}`}
|
||||
setValue={setCurrentGraph}
|
||||
value={currentGraph}
|
||||
>
|
||||
{/* @ts-expect-error zzz */}
|
||||
<Icon type={gptImpl.enumIcons[en]} /> {UppercaseFirst(en)}{" "}
|
||||
{UppercaseFirst(g)}
|
||||
</SelectButton>
|
||||
)),
|
||||
)}
|
||||
<SelectButton
|
||||
className="text-wrap"
|
||||
id="score"
|
||||
setValue={setCurrentGraph}
|
||||
value={currentGraph}
|
||||
>
|
||||
<Icon type="sort" /> Score Averages
|
||||
</SelectButton>
|
||||
</div>
|
||||
<div className="vstack gap-3">
|
||||
{folderStatsError ? (
|
||||
<ApiError error={folderStatsError} />
|
||||
) : folderStatsLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
(() => {
|
||||
const liveFolderStats = folderStatsBody?.stats;
|
||||
if (liveFolderStats === null || liveFolderStats === undefined) {
|
||||
return (
|
||||
<small className="text-body-secondary">
|
||||
Could not load folder statistics.
|
||||
</small>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="row g-3">
|
||||
{enumMetrics.map((metric) => (
|
||||
<div
|
||||
className={
|
||||
enumMetrics.length === 1 ? "col-12" : "col-12 col-md-6"
|
||||
}
|
||||
key={metric}
|
||||
>
|
||||
<FolderEnumDistributionBreakdown
|
||||
clipToMinimumRelevance={false}
|
||||
colours={enumColourMaps?.[metric]}
|
||||
enumMetric={metric}
|
||||
gameConfig={gameConfig}
|
||||
onEnumBreakdownRowClick={
|
||||
onBreakdownEnumValueClick
|
||||
? (enumValueLabel: string) => {
|
||||
onBreakdownEnumValueClick(metric, enumValueLabel);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
stats={liveFolderStats}
|
||||
suppressTopRule
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
{type === "stats" ? (
|
||||
<FolderScoreDistributionChart
|
||||
folderDataset={folderDataset}
|
||||
game={game}
|
||||
view={metric}
|
||||
/>
|
||||
) : type === "minimap" ? (
|
||||
<FolderMinimap
|
||||
enumMetric={metric}
|
||||
folderDataset={folderDataset}
|
||||
game={game}
|
||||
reqUser={reqUser}
|
||||
/>
|
||||
) : (
|
||||
<FolderScoreAverages folderDataset={folderDataset} game={game} reqUser={reqUser} />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ export default function QuickTooltip({
|
||||
tooltipContent: React.ReactChild | undefined;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
const [popperArmed, setPopperArmed] = useState(false);
|
||||
const [show, setShow] = useState(false);
|
||||
const [mousedOver, setMousedOver] = useState(false);
|
||||
|
||||
@@ -59,26 +58,8 @@ export default function QuickTooltip({
|
||||
});
|
||||
}
|
||||
|
||||
const armPopper = () => {
|
||||
if (!popperArmed) {
|
||||
setPopperArmed(true);
|
||||
setShow(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!popperArmed) {
|
||||
return React.cloneElement(children, {
|
||||
onMouseEnter: (e: React.MouseEvent) => {
|
||||
armPopper();
|
||||
children.props.onMouseEnter?.(e);
|
||||
},
|
||||
onFocus: (e: React.FocusEvent) => {
|
||||
armPopper();
|
||||
children.props.onFocus?.(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Mount OverlayTrigger from the first paint. A lazy first render used cloneElement only,
|
||||
// then swapped to OverlayTrigger on hover; that remounted the trigger and restarted CSS animations.
|
||||
return (
|
||||
<OverlayTrigger
|
||||
delay={overlayDelay}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/* Shell around the Bootstrap input-group filter bar */
|
||||
.filterBarChrome {
|
||||
align-self: flex-end;
|
||||
border-radius: var(--bs-border-radius);
|
||||
flex: none;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
box-shadow 0.22s ease,
|
||||
background-color 0.22s ease,
|
||||
filter 0.22s ease;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.filterBarChromeActive {
|
||||
background-color: color-mix(in srgb, var(--bs-primary) 12%, transparent);
|
||||
box-shadow:
|
||||
inset 0 0 0 2px var(--bs-primary),
|
||||
0 0 0 3px color-mix(in srgb, var(--bs-primary) 18%, transparent),
|
||||
0 8px 24px color-mix(in srgb, var(--bs-primary) 12%, transparent);
|
||||
|
||||
:global(.form-control) {
|
||||
background-color: color-mix(in srgb, var(--bs-primary) 6%, var(--bs-body-bg, #fff));
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bs-border-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.btn),
|
||||
:global(.input-group-text) {
|
||||
background-color: color-mix(in srgb, var(--bs-primary) 8%, var(--bs-body-bg, #fff));
|
||||
border-left-color: var(--bs-border-color);
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra punch when syncing from preset (combined with `.filterBarChromeActive`) */
|
||||
.searchFilterFlash {
|
||||
animation: tachiFilterAttentionBurst 1.15s cubic-bezier(0.33, 1, 0.68, 1);
|
||||
}
|
||||
|
||||
@keyframes tachiFilterAttentionBurst {
|
||||
0% {
|
||||
background-color: color-mix(in srgb, var(--bs-primary) 12%, transparent);
|
||||
box-shadow:
|
||||
inset 0 0 0 2px var(--bs-primary),
|
||||
0 0 0 3px color-mix(in srgb, var(--bs-primary) 18%, transparent),
|
||||
0 8px 24px color-mix(in srgb, var(--bs-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
28% {
|
||||
background-color: color-mix(in srgb, var(--bs-primary) 24%, transparent);
|
||||
box-shadow:
|
||||
inset 0 0 0 3px color-mix(in srgb, var(--bs-warning) 35%, var(--bs-primary)),
|
||||
0 0 0 7px color-mix(in srgb, var(--bs-primary) 38%, transparent),
|
||||
0 14px 40px color-mix(in srgb, var(--bs-primary) 28%, transparent);
|
||||
filter: saturate(1.12);
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: color-mix(in srgb, var(--bs-primary) 12%, transparent);
|
||||
box-shadow:
|
||||
inset 0 0 0 2px var(--bs-primary),
|
||||
0 0 0 3px color-mix(in srgb, var(--bs-primary) 18%, transparent),
|
||||
0 8px 24px color-mix(in srgb, var(--bs-primary) 12%, transparent);
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,13 @@ import { UserSettingsContext } from "#context/UserSettingsContext";
|
||||
import { WindowContext } from "#context/WindowContext";
|
||||
import { CopyToClipboard } from "#util/misc";
|
||||
import { ComposeSearchFunction, type SearchFunctions } from "#util/ztable/search";
|
||||
import React, { type Key, useContext, useMemo, useState } from "react";
|
||||
import React, { type Key, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import Form from "react-bootstrap/Form";
|
||||
import InputGroup from "react-bootstrap/InputGroup";
|
||||
import { type integer } from "tachi-common";
|
||||
|
||||
import tachitableStyles from "./TachiTable.module.scss";
|
||||
import FilterDirectivesIndicator from "./FilterDirectivesIndicator";
|
||||
import NoDataWrapper from "./NoDataWrapper";
|
||||
import PageSelector from "./PageSelector";
|
||||
@@ -98,6 +99,7 @@ export default function TachiTable<D>({
|
||||
noBottomDisplayPager = false,
|
||||
pageLenOptions = DEFAULT_TACHI_PAGE_LEN_OPTIONS,
|
||||
rowKey,
|
||||
externalSearchPreset = undefined,
|
||||
}: {
|
||||
dataset: D[];
|
||||
defaultReverseSort?: boolean;
|
||||
@@ -112,8 +114,15 @@ export default function TachiTable<D>({
|
||||
/** Stable keys for tbody rows (e.g. chartID) so filtering reconciles instead of re-mounting. */
|
||||
rowKey?: (row: D) => Key;
|
||||
searchFunctions?: SearchFunctions<D>;
|
||||
/**
|
||||
* When `nonce` changes (desktop breakpoints only), replaces the filter text — e.g.
|
||||
* syncing from folder breakdown → table filter.
|
||||
*/
|
||||
externalSearchPreset?: { nonce: number; search: string } | null;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const lastExternalNonce = useRef<number | undefined>(undefined);
|
||||
const [highlightFilterPulse, setHighlightFilterPulse] = useState(false);
|
||||
|
||||
const searchFunction = useMemo(
|
||||
() => (searchFunctions ? ComposeSearchFunction(searchFunctions) : undefined),
|
||||
@@ -161,6 +170,55 @@ export default function TachiTable<D>({
|
||||
const {
|
||||
breakpoint: { isLg },
|
||||
} = useContext(WindowContext);
|
||||
|
||||
const filterChromeClass = useMemo(() => {
|
||||
const hasFilterText = search.trim().length > 0;
|
||||
|
||||
return [
|
||||
tachitableStyles.filterBarChrome,
|
||||
hasFilterText ? tachitableStyles.filterBarChromeActive : "",
|
||||
highlightFilterPulse ? tachitableStyles.searchFilterFlash : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}, [highlightFilterPulse, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!externalSearchPreset || !isLg) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { nonce, search: presetSearch } = externalSearchPreset;
|
||||
|
||||
if (lastExternalNonce.current === nonce) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastExternalNonce.current = nonce;
|
||||
setSearch(presetSearch);
|
||||
|
||||
let canceled = false;
|
||||
setHighlightFilterPulse(false);
|
||||
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
if (!canceled) {
|
||||
setHighlightFilterPulse(true);
|
||||
}
|
||||
});
|
||||
|
||||
const t = window.setTimeout(() => {
|
||||
if (!canceled) {
|
||||
setHighlightFilterPulse(false);
|
||||
}
|
||||
}, 1250);
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
window.cancelAnimationFrame(raf);
|
||||
window.clearTimeout(t);
|
||||
};
|
||||
}, [externalSearchPreset?.nonce, externalSearchPreset?.search, isLg]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="hstack justify-content-between">
|
||||
@@ -168,20 +226,25 @@ export default function TachiTable<D>({
|
||||
<div className="d-none d-lg-flex align-self-center">{displayStr}</div>
|
||||
)}
|
||||
{searchFunctions && (
|
||||
<InputGroup className="ms-lg-auto" style={{ maxWidth: isLg ? 384 : undefined }}>
|
||||
<Form.Control
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={`Filter ${entryName}`}
|
||||
type="text"
|
||||
value={search}
|
||||
/>
|
||||
{dataset[0] && (
|
||||
<FilterDirectivesIndicator
|
||||
doc={dataset[0]}
|
||||
searchFunctions={searchFunctions}
|
||||
<div
|
||||
className={`ms-lg-auto ${filterChromeClass}`}
|
||||
style={{ maxWidth: isLg ? 384 : undefined }}
|
||||
>
|
||||
<InputGroup>
|
||||
<Form.Control
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={`Filter ${entryName}`}
|
||||
type="text"
|
||||
value={search}
|
||||
/>
|
||||
)}
|
||||
</InputGroup>
|
||||
{dataset[0] && (
|
||||
<FilterDirectivesIndicator
|
||||
doc={dataset[0]}
|
||||
searchFunctions={searchFunctions}
|
||||
/>
|
||||
)}
|
||||
</InputGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-0 mt-4 mb-4 overflow-x-auto overflow-x-lg-hidden">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Muted from "#components/util/Muted";
|
||||
import usePreferredRanking from "#components/util/usePreferredRanking";
|
||||
import useScoreRatingAlg from "#components/util/useScoreRatingAlg";
|
||||
import { WindowContext } from "#context/WindowContext";
|
||||
import { type FolderDataset } from "#types/tables";
|
||||
import { NumericSOV, StrSOV } from "#util/sorts";
|
||||
import { CreateDefaultFolderSearchParams } from "#util/tables/create-search";
|
||||
import React, { memo, useCallback, useMemo, useState } from "react";
|
||||
import React, { memo, useCallback, useContext, useMemo, useState } from "react";
|
||||
import { Alert, Button } from "react-bootstrap";
|
||||
import { type PBScoreDocument, type ScoreRatingAlgorithms, type V3Game } from "tachi-common";
|
||||
|
||||
@@ -23,7 +24,30 @@ import { GetGPTCoreHeaders } from "../headers/GameHeaders";
|
||||
import { EmptyHeader, FolderIndicatorHeader } from "../headers/IndicatorHeader";
|
||||
import { CreateRankingHeader } from "../headers/RankingHeader";
|
||||
|
||||
export default function FolderTable({ dataset, game }: { dataset: FolderDataset; game: V3Game }) {
|
||||
export type FolderEnumBreakdownTablePreset = {
|
||||
metricKey: string;
|
||||
valueLabel: string;
|
||||
nonce: number;
|
||||
};
|
||||
|
||||
/** Scroll target used when jumping from folder breakdown chips to this table (desktop-only flow). */
|
||||
export const FOLDER_FOLDER_TABLE_SCROLL_INTO_VIEW_ID =
|
||||
"folder-folder-table-scroll-anchor";
|
||||
|
||||
function formatFolderEnumTableFilter(metricKey: string, valueLabel: string): string {
|
||||
const escaped = valueLabel.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
return `${metricKey}=="${escaped}"`;
|
||||
}
|
||||
|
||||
export default function FolderTable({
|
||||
dataset,
|
||||
game,
|
||||
folderBreakdownEnumTablePreset = null,
|
||||
}: {
|
||||
dataset: FolderDataset;
|
||||
game: V3Game;
|
||||
folderBreakdownEnumTablePreset?: FolderEnumBreakdownTablePreset | null;
|
||||
}) {
|
||||
const defaultRating = useScoreRatingAlg(game);
|
||||
|
||||
const preferredRanking = usePreferredRanking();
|
||||
@@ -34,8 +58,26 @@ export default function FolderTable({ dataset, game }: { dataset: FolderDataset;
|
||||
);
|
||||
const [usePaginatedView, setUsePaginatedView] = useState(false);
|
||||
|
||||
const {
|
||||
breakpoint: { isLg },
|
||||
} = useContext(WindowContext);
|
||||
|
||||
const folderSearchFunctions = useMemo(() => CreateDefaultFolderSearchParams(game), [game]);
|
||||
|
||||
const externalPreset = useMemo(() => {
|
||||
if (!folderBreakdownEnumTablePreset || !isLg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
search: formatFolderEnumTableFilter(
|
||||
folderBreakdownEnumTablePreset.metricKey,
|
||||
folderBreakdownEnumTablePreset.valueLabel,
|
||||
),
|
||||
nonce: folderBreakdownEnumTablePreset.nonce,
|
||||
};
|
||||
}, [folderBreakdownEnumTablePreset, isLg]);
|
||||
|
||||
const headers: Header<FolderDataset[0]>[] = [
|
||||
ChartHeader(game, (k) => k),
|
||||
FolderIndicatorHeader,
|
||||
@@ -58,7 +100,7 @@ export default function FolderTable({ dataset, game }: { dataset: FolderDataset;
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id={FOLDER_FOLDER_TABLE_SCROLL_INTO_VIEW_ID}>
|
||||
<Alert className="mb-4" variant="warning">
|
||||
<div className="d-flex flex-column flex-md-row align-items-md-center gap-3">
|
||||
<p className="flex-grow-1 mb-0">
|
||||
@@ -79,6 +121,7 @@ export default function FolderTable({ dataset, game }: { dataset: FolderDataset;
|
||||
<TachiTable
|
||||
dataset={dataset}
|
||||
entryName="Charts"
|
||||
externalSearchPreset={externalPreset}
|
||||
headers={headers}
|
||||
key={usePaginatedView ? "folder-paginated" : "folder-full"}
|
||||
pageLen={usePaginatedView ? 10 : 1000}
|
||||
@@ -87,7 +130,7 @@ export default function FolderTable({ dataset, game }: { dataset: FolderDataset;
|
||||
rowKey={(d) => d.chartID}
|
||||
searchFunctions={folderSearchFunctions}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,38 @@ export interface UGPTTableReturns {
|
||||
table: TableDocument;
|
||||
}
|
||||
|
||||
/** Rows returned by `GET .../tables/:tableID/evolution`. */
|
||||
export interface TableEvolutionEventAPI {
|
||||
chartID: string;
|
||||
enumIndex: integer;
|
||||
metric: string;
|
||||
scoreID: string;
|
||||
timeAchieved: number | null;
|
||||
timeAdded: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface UGPTTableEvolutionReturns {
|
||||
charts: ChartDocument[];
|
||||
events: TableEvolutionEventAPI[];
|
||||
folderChartIDs: Record<string, string[]>;
|
||||
folders: FolderDocument[];
|
||||
songs: SongDocument<GameGroup>[];
|
||||
table: TableDocument;
|
||||
}
|
||||
|
||||
/** Same event/chart payloads as table evolution; scoped to a single folder. */
|
||||
export interface UGPTFolderEvolutionReturns {
|
||||
charts: ChartDocument[];
|
||||
events: TableEvolutionEventAPI[];
|
||||
folder: FolderDocument;
|
||||
folderChartIDs: Record<string, string[]>;
|
||||
folders: FolderDocument[];
|
||||
songs: SongDocument<GameGroup>[];
|
||||
}
|
||||
|
||||
export type UGPTEvolutionReplayReturns = UGPTTableEvolutionReturns | UGPTFolderEvolutionReturns;
|
||||
|
||||
export interface UGPTFolderReturns<GPT extends V3Game = V3Game> {
|
||||
folder: FolderDocument;
|
||||
songs: SongDocument<GameGroup>[];
|
||||
@@ -142,6 +174,12 @@ export interface UGPTFolderReturns<GPT extends V3Game = V3Game> {
|
||||
pbs: PBScoreDocument<GPT>[];
|
||||
}
|
||||
|
||||
/** `GET .../folders/:folderSlug/stats`. */
|
||||
export interface UGPTFolderSlugStatsReturns {
|
||||
folder: FolderDocument;
|
||||
stats: FolderStatsInfo | null;
|
||||
}
|
||||
|
||||
export interface GPTFolderReturns<GPT extends V3Game = V3Game> {
|
||||
folder: FolderDocument;
|
||||
songs: SongDocument<GameGroup>[];
|
||||
|
||||
@@ -13,6 +13,11 @@ export function FormatDate(ms: number) {
|
||||
return DateTime.fromMillis(ms).toLocaleString(DateTime.DATE_HUGE);
|
||||
}
|
||||
|
||||
/** Local time of day only; use with {@link FormatDate} when the calendar date is shown separately. */
|
||||
export function FormatTimeOfDay(ms: number) {
|
||||
return DateTime.fromMillis(ms).toLocaleString(DateTime.TIME_SIMPLE);
|
||||
}
|
||||
|
||||
export function FormatDuration(ms: number) {
|
||||
return humaniseDuration(ms, {
|
||||
units: ["d", "h", "m"],
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart";
|
||||
import { GetSongsByIDs } from "#lib/db-formats/song";
|
||||
import DB from "#services/pg/db";
|
||||
import { GetScoresForTableEvolution } from "#utils/queries/scores";
|
||||
import {
|
||||
GetGameConfig,
|
||||
GetScoreEnumConfs,
|
||||
type integer,
|
||||
type ChartDocument,
|
||||
type FolderDocument,
|
||||
type ScoreDocument,
|
||||
type SongDocument,
|
||||
type TableDocument,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
import type { ConfEnumScoreMetric } from "tachi-common/types/metrics";
|
||||
|
||||
import { GetFolderChartIDs } from "./folders";
|
||||
|
||||
/** One strict improvement on a `(chart × metric)` enum index within table evolution semantics. */
|
||||
export interface TableEvolutionEvent {
|
||||
chartID: string;
|
||||
enumIndex: integer;
|
||||
metric: string;
|
||||
scoreID: string;
|
||||
timeAchieved: integer | null;
|
||||
timeAdded: integer;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export async function GetTableEvolutionFolderChartMembership(folders: Array<FolderDocument>): Promise<{
|
||||
distinctChartIDs: Array<string>;
|
||||
folderChartIDs: Record<string, Array<string>>;
|
||||
}> {
|
||||
const folderChartIDs: Record<string, Array<string>> = {};
|
||||
const distinct = new Set<string>();
|
||||
|
||||
for (const folder of folders) {
|
||||
const chartIds = await GetFolderChartIDs(folder.folderID);
|
||||
|
||||
folderChartIDs[folder.slug] = chartIds;
|
||||
for (const id of chartIds) {
|
||||
distinct.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return { distinctChartIDs: [...distinct], folderChartIDs };
|
||||
}
|
||||
|
||||
export async function LoadEvolutionChartsAndSongs(
|
||||
chartIds: Array<string>,
|
||||
): Promise<{ charts: Array<ChartDocument>; songs: Array<SongDocument> }> {
|
||||
if (chartIds.length === 0) {
|
||||
return { charts: [], songs: [] };
|
||||
}
|
||||
|
||||
const rows = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.where("chart.id", "in", chartIds)
|
||||
.execute();
|
||||
|
||||
const charts = rows.map(ToChartDocument);
|
||||
const songLegacyIds = [...new Set(charts.map((item) => item.song.id))];
|
||||
const songs = await GetSongsByIDs(songLegacyIds);
|
||||
|
||||
return { charts, songs };
|
||||
}
|
||||
|
||||
export function ComputeTableEvolutionEvents(
|
||||
scores: Array<ScoreDocument>,
|
||||
enumConfs: Record<string, ConfEnumScoreMetric<string>>,
|
||||
): Array<TableEvolutionEvent> {
|
||||
const evolutionEvents: Array<TableEvolutionEvent> = [];
|
||||
|
||||
let currentChartID: string | undefined;
|
||||
const prevMaxForMetric: Record<string, integer> = {};
|
||||
|
||||
const readEnumIdx = (scoreData: ScoreDocument["scoreData"], metric: string): integer | undefined => {
|
||||
const fromRoot = scoreData.enumIndexes?.[metric as keyof typeof scoreData.enumIndexes] as
|
||||
| integer
|
||||
| undefined;
|
||||
const fromOptional =
|
||||
scoreData.optional?.enumIndexes?.[metric as keyof typeof scoreData.optional.enumIndexes];
|
||||
|
||||
const coerced = typeof fromOptional === "number" ? fromOptional : undefined;
|
||||
|
||||
return fromRoot ?? coerced ?? undefined;
|
||||
};
|
||||
|
||||
for (const score of scores) {
|
||||
if (score.chartID !== currentChartID) {
|
||||
currentChartID = score.chartID;
|
||||
for (const key of Object.keys(prevMaxForMetric)) {
|
||||
delete prevMaxForMetric[key];
|
||||
}
|
||||
}
|
||||
|
||||
for (const [metricName, metricConf] of Object.entries(enumConfs)) {
|
||||
if (metricConf.type !== "ENUM") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const idxRaw = readEnumIdx(score.scoreData, metricName);
|
||||
if (idxRaw === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const prev = prevMaxForMetric[metricName];
|
||||
if (prev !== undefined && idxRaw <= prev) {
|
||||
continue;
|
||||
}
|
||||
|
||||
prevMaxForMetric[metricName] = idxRaw;
|
||||
|
||||
const minRelIdx = metricConf.values.indexOf(metricConf.minimumRelevantValue);
|
||||
if (idxRaw < minRelIdx) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const valueLabel = metricConf.values[idxRaw];
|
||||
if (valueLabel === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
evolutionEvents.push({
|
||||
chartID: score.chartID,
|
||||
enumIndex: idxRaw,
|
||||
metric: metricName,
|
||||
scoreID: score.scoreID,
|
||||
timeAchieved: score.timeAchieved,
|
||||
timeAdded: score.timeAdded,
|
||||
value: valueLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
evolutionEvents.sort((aItem, bItem) => {
|
||||
const timeCmp = (aItem.timeAchieved ?? 0) - (bItem.timeAchieved ?? 0);
|
||||
|
||||
return timeCmp !== 0 ? timeCmp : aItem.timeAdded - bItem.timeAdded;
|
||||
});
|
||||
|
||||
return evolutionEvents;
|
||||
}
|
||||
|
||||
export async function LoadTableEvolutionPayload(
|
||||
userID: integer,
|
||||
game: V3Game,
|
||||
folders: Array<FolderDocument>,
|
||||
table: TableDocument,
|
||||
): Promise<{
|
||||
charts: Array<ChartDocument>;
|
||||
events: Array<TableEvolutionEvent>;
|
||||
folderChartIDs: Record<string, Array<string>>;
|
||||
folders: Array<FolderDocument>;
|
||||
songs: Array<SongDocument>;
|
||||
table: TableDocument;
|
||||
}> {
|
||||
const enumConfs = GetScoreEnumConfs(GetGameConfig(game));
|
||||
|
||||
const { distinctChartIDs, folderChartIDs } = await GetTableEvolutionFolderChartMembership(folders);
|
||||
|
||||
const scores = await GetScoresForTableEvolution(userID, game, distinctChartIDs);
|
||||
const events = ComputeTableEvolutionEvents(scores, enumConfs);
|
||||
const { charts, songs } = await LoadEvolutionChartsAndSongs(distinctChartIDs);
|
||||
|
||||
return { charts, events, folderChartIDs, folders, songs, table };
|
||||
}
|
||||
|
||||
export async function LoadFolderEvolutionPayload(
|
||||
userID: integer,
|
||||
game: V3Game,
|
||||
folder: FolderDocument,
|
||||
): Promise<{
|
||||
charts: Array<ChartDocument>;
|
||||
events: Array<TableEvolutionEvent>;
|
||||
folder: FolderDocument;
|
||||
folderChartIDs: Record<string, Array<string>>;
|
||||
folders: Array<FolderDocument>;
|
||||
songs: Array<SongDocument>;
|
||||
}> {
|
||||
const enumConfs = GetScoreEnumConfs(GetGameConfig(game));
|
||||
|
||||
const { distinctChartIDs, folderChartIDs } = await GetTableEvolutionFolderChartMembership([folder]);
|
||||
|
||||
const scores = await GetScoresForTableEvolution(userID, game, distinctChartIDs);
|
||||
const events = ComputeTableEvolutionEvents(scores, enumConfs);
|
||||
const { charts, songs } = await LoadEvolutionChartsAndSongs(distinctChartIDs);
|
||||
|
||||
return {
|
||||
charts,
|
||||
events,
|
||||
folder,
|
||||
folderChartIDs,
|
||||
folders: [folder],
|
||||
songs,
|
||||
};
|
||||
}
|
||||
@@ -913,6 +913,30 @@ export const API_V1_SPEC = {
|
||||
}),
|
||||
},
|
||||
|
||||
"GET /users/:userID/games/:game/tables/:tableID/evolution": {
|
||||
description:
|
||||
"Table evolution: strict per-chart enum milestones at or above each metric minimumRelevantValue; events sorted by play time.",
|
||||
input: z.object({}),
|
||||
output: z.strictObject({
|
||||
charts: docArray<ChartDocument>(),
|
||||
events: z.array(
|
||||
z.strictObject({
|
||||
chartID: z.string(),
|
||||
enumIndex: z.number(),
|
||||
metric: z.string(),
|
||||
scoreID: z.string(),
|
||||
timeAchieved: z.number().nullable(),
|
||||
timeAdded: z.number(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
folderChartIDs: z.record(z.string(), z.array(z.string())),
|
||||
folders: docArray<FolderDocument>(),
|
||||
songs: docArray<SongDocument>(),
|
||||
table: doc<TableDocument>(),
|
||||
}),
|
||||
},
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// UGPT Showcase
|
||||
// ────────────────────────────────────────────────
|
||||
@@ -1031,6 +1055,30 @@ export const API_V1_SPEC = {
|
||||
}),
|
||||
},
|
||||
|
||||
"GET /users/:userID/games/:game/folders/:folderSlug/evolution": {
|
||||
description:
|
||||
"Folder evolution: strict per-chart enum milestones at or above each metric minimumRelevantValue; events sorted by play time.",
|
||||
input: z.object({}),
|
||||
output: z.strictObject({
|
||||
charts: docArray<ChartDocument>(),
|
||||
events: z.array(
|
||||
z.strictObject({
|
||||
chartID: z.string(),
|
||||
enumIndex: z.number(),
|
||||
metric: z.string(),
|
||||
scoreID: z.string(),
|
||||
timeAchieved: z.number().nullable(),
|
||||
timeAdded: z.number(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
folder: doc<FolderDocument>(),
|
||||
folderChartIDs: z.record(z.string(), z.array(z.string())),
|
||||
folders: docArray<FolderDocument>(),
|
||||
songs: docArray<SongDocument>(),
|
||||
}),
|
||||
},
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// UGPT Targets
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
+80
@@ -242,3 +242,83 @@ describe("GET /api/v1/users/:userID/games/:game/folders/:folderID/timeline", ()
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/v1/users/:userID/games/:game/folders/:folderSlug/evolution", () => {
|
||||
it("returns lamp milestones chronologically above minimum relevance", async () => {
|
||||
const { id: userId } = await seedUser({ username: `folder_evo_${Date.now()}` });
|
||||
await seedIidxSpProfile(userId);
|
||||
|
||||
const folderId = `folder_evo_${Date.now()}`;
|
||||
const { chartA } = await seedFolderWithThreeCharts({
|
||||
folderId,
|
||||
folderLegacy: `${folderId}_leg`,
|
||||
});
|
||||
|
||||
const uid = `${Date.now()}_${Math.floor(Math.random() * 100_000)}`;
|
||||
|
||||
async function insScore(scoreId: string, lamp: string, iso: string) {
|
||||
const { data, derived, judgements } = mongoScoreDataToPg(
|
||||
"iidx-sp",
|
||||
{
|
||||
score: 1400,
|
||||
lamp,
|
||||
percent: 93,
|
||||
optional: {},
|
||||
judgements: { pgreat: 1, great: 0 },
|
||||
grade: "AAA",
|
||||
} as ScoreData<"iidx-sp">,
|
||||
);
|
||||
|
||||
await DB.insertInto("score")
|
||||
.values({
|
||||
id: scoreId,
|
||||
user_id: userId,
|
||||
chart_id: chartA,
|
||||
game: "iidx-sp",
|
||||
session_id: null,
|
||||
import_id: null,
|
||||
data: JSON.stringify(data),
|
||||
derived_data: JSON.stringify(derived),
|
||||
judgements: JSON.stringify(judgements),
|
||||
calculated_data: JSON.stringify({}),
|
||||
meta: JSON.stringify({}),
|
||||
time_achieved: iso,
|
||||
time_added: iso,
|
||||
highlight: false,
|
||||
comment: null,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
await insScore(`sc_${uid}_1`, "FAILED", "2020-01-01T10:00:00.000Z");
|
||||
await insScore(`sc_${uid}_2`, "EASY CLEAR", "2020-02-01T10:00:00.000Z");
|
||||
await insScore(`sc_${uid}_3`, "HARD CLEAR", "2020-03-01T10:00:00.000Z");
|
||||
|
||||
const res = await mockApi.get(
|
||||
`/api/v1/users/${userId}/games/iidx-sp/folders/${folderId}/evolution`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body.folder.slug).toBe(folderId);
|
||||
|
||||
const { events } = res.body.body;
|
||||
const lampEvents = events.filter((e: { metric: string }) => e.metric === "lamp");
|
||||
|
||||
expect(lampEvents.length).toBe(2);
|
||||
expect(lampEvents[0].value).toBe("EASY CLEAR");
|
||||
expect(lampEvents[1].value).toBe("HARD CLEAR");
|
||||
|
||||
expect(res.body.body.folderChartIDs[folderId]).toContain(chartA);
|
||||
});
|
||||
|
||||
it("returns 404 when the folder does not exist", async () => {
|
||||
const { id: userId } = await seedUser({ username: `folder_evo_404_${Date.now()}` });
|
||||
await seedIidxSpProfile(userId);
|
||||
|
||||
const res = await mockApi.get(
|
||||
`/api/v1/users/${userId}/games/iidx-sp/folders/ghost_folder_xyz/evolution`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
+28
@@ -4,6 +4,7 @@ import {
|
||||
GetFolderChartsAndSongs,
|
||||
GetPBsOnFolder,
|
||||
} from "#lib/folders/folders";
|
||||
import { LoadFolderEvolutionPayload } from "#lib/folders/table-evolution";
|
||||
import { withSelf, withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
@@ -166,3 +167,30 @@ API_V1_ROUTER.add(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* **Folder evolution:** distinct enum milestones (per metric, per chart) at or above each metric's
|
||||
* `minimumRelevantValue`, scoped to charts in this folder — same semantics as table evolution.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/games/:game/folders/:folderSlug/evolution
|
||||
*/
|
||||
API_V1_ROUTER.add(
|
||||
"GET /users/:userID/games/:game/folders/:folderSlug/evolution",
|
||||
withUserGameProfile,
|
||||
async ({ ctx, params }) => {
|
||||
const { requestedUser: user, game } = ctx;
|
||||
|
||||
const folder = await LoadFolderDocumentByGameAndSlug(game, params.folderSlug);
|
||||
|
||||
if (!folder || folder.game !== game) {
|
||||
throw new ExpectedErr(404, "This folder does not exist.");
|
||||
}
|
||||
|
||||
const body = await LoadFolderEvolutionPayload(user.id, game, folder);
|
||||
|
||||
return success(
|
||||
`Returned ${body.events.length} folder evolution events for ${folder.title}.`,
|
||||
body,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
+79
@@ -76,6 +76,13 @@ describe("GET /api/v1/users/:userID/games/:game/tables/:tableID", () => {
|
||||
.values({ table_id: tableUuid, folder_id: folderId, ordering: 0 })
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("folder_chart_lookup")
|
||||
.values({
|
||||
folder_id: folderId,
|
||||
chart_id: chartId,
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("game_profile")
|
||||
.values({
|
||||
user_id: 1,
|
||||
@@ -124,4 +131,76 @@ describe("GET /api/v1/users/:userID/games/:game/tables/:tableID", () => {
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
describe("GET /evolution", () => {
|
||||
beforeEach(async () => {
|
||||
const chartId = TestingIIDXSPScorePB.chartID;
|
||||
const uid = `${Date.now()}_${Math.floor(Math.random() * 100_000)}`;
|
||||
|
||||
async function insScore(scoreId: string, lamp: string, iso: string) {
|
||||
const { data, derived, judgements } = mongoScoreDataToPg(
|
||||
"iidx-sp",
|
||||
{
|
||||
score: 1400,
|
||||
lamp,
|
||||
percent: 93,
|
||||
optional: {},
|
||||
judgements: { pgreat: 1, great: 0 },
|
||||
grade: "AAA",
|
||||
} as ScoreData<"iidx-sp">,
|
||||
);
|
||||
|
||||
await DB.insertInto("score")
|
||||
.values({
|
||||
id: scoreId,
|
||||
user_id: 1,
|
||||
chart_id: chartId,
|
||||
game: "iidx-sp",
|
||||
session_id: null,
|
||||
import_id: null,
|
||||
data: JSON.stringify(data),
|
||||
derived_data: JSON.stringify(derived),
|
||||
judgements: JSON.stringify(judgements),
|
||||
calculated_data: JSON.stringify({}),
|
||||
meta: JSON.stringify({}),
|
||||
time_achieved: iso,
|
||||
time_added: iso,
|
||||
highlight: false,
|
||||
comment: null,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
await insScore(`sc_${uid}_1`, "FAILED", "2020-01-01T10:00:00.000Z");
|
||||
await insScore(`sc_${uid}_2`, "EASY CLEAR", "2020-02-01T10:00:00.000Z");
|
||||
await insScore(`sc_${uid}_3`, "HARD CLEAR", "2020-03-01T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("returns lamp milestones chronologically above minimum relevance", async () => {
|
||||
const res = await mockApi.get(
|
||||
"/api/v1/users/1/games/iidx-sp/tables/mock_table/evolution",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const { events } = res.body.body;
|
||||
const lampEvents = events.filter((e: { metric: string }) => e.metric === "lamp");
|
||||
|
||||
expect(lampEvents.length).toBe(2);
|
||||
expect(lampEvents[0].value).toBe("EASY CLEAR");
|
||||
expect(lampEvents[1].value).toBe("HARD CLEAR");
|
||||
|
||||
expect(res.body.body.folderChartIDs["ugtfolder"]).toContain(
|
||||
TestingIIDXSPScorePB.chartID,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 404 when the table does not exist", async () => {
|
||||
const res = await mockApi.get(
|
||||
"/api/v1/users/1/games/iidx-sp/tables/ghost_table_xyz/evolution",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+29
@@ -1,5 +1,6 @@
|
||||
import { LoadTableDocumentByLegacyIdForGame } from "#lib/db-formats/table";
|
||||
import { GetEnumDistForFolders, GetFoldersFromTable } from "#lib/folders/folders";
|
||||
import { LoadTableEvolutionPayload } from "#lib/folders/table-evolution";
|
||||
import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
@@ -28,3 +29,31 @@ API_V1_ROUTER.add(
|
||||
return success(`Returned stats for ${folders.length} folders.`, { folders, stats, table });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* **Table evolution:** distinct enum milestones (per metric, per chart) at or above {@link ConfEnumScoreMetric.minimumRelevantValue},
|
||||
* scoped to charts in folders attached to this table.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/games/:game/tables/:tableID/evolution
|
||||
*/
|
||||
API_V1_ROUTER.add(
|
||||
"GET /users/:userID/games/:game/tables/:tableID/evolution",
|
||||
withUserGameProfile,
|
||||
async ({ ctx, params }) => {
|
||||
const { requestedUser: user, game } = ctx;
|
||||
|
||||
const table = await LoadTableDocumentByLegacyIdForGame(params.tableID, game);
|
||||
|
||||
if (!table) {
|
||||
throw new ExpectedErr(404, `No table with ID ${params.tableID} exists.`);
|
||||
}
|
||||
|
||||
const folders = await GetFoldersFromTable(table);
|
||||
const body = await LoadTableEvolutionPayload(user.id, game, folders, table);
|
||||
|
||||
return success(
|
||||
`Returned ${body.events.length} table evolution events for ${folders.length} folders.`,
|
||||
body,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -140,3 +140,29 @@ export async function GetFolderTimelineScores(
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full score history on the charts, ordered like {@link GetFolderTimelineScores}: by `chart.id`,
|
||||
* then `time_achieved` ascending (unknown play time sorts last via infinity), then `time_added`.
|
||||
* Used by table evolution to detect strict PB increases per `(chart × metric)` in chronological order.
|
||||
*/
|
||||
export async function GetScoresForTableEvolution(
|
||||
userID: integer,
|
||||
game: V3Game,
|
||||
chartIDs: string[],
|
||||
): Promise<Array<ScoreDocument>> {
|
||||
if (chartIDs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await scoreDocumentJoin()
|
||||
.where("score.user_id", "=", userID)
|
||||
.where("score.game", "=", game)
|
||||
.where("chart.id", "in", chartIDs)
|
||||
.orderBy("chart.id")
|
||||
.orderBy(sql`coalesce(score.time_achieved, 'infinity'::timestamptz) asc`)
|
||||
.orderBy("score.time_added", "asc")
|
||||
.execute();
|
||||
|
||||
return rows.map((r) => ToScoreDocument(r as ScoreDocumentJoinRow));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user