Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,182 @@
/**
* Per-player status bands over a game's scanned icon-strip reads: one row
* per player (weapon icon as the label), a band while the player was
* splatted and another while they held their special, both teams stacked.
* Rendered above the ObjectiveTimeline chart on the same `t` seconds axis —
* pass `domain` so both span the same range. Reads re-confirm an unchanged
* state every few seconds; a longer sample gap means the HUD was not
* observed, so bands never bridge across one (the state there is unknown,
* not continued).
*/
import { useTranslation } from "react-i18next";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { abilityImageUrl } from "~/utils/urls";
import { Image, WeaponImage } from "./Image";
import {
formatElapsed,
TIMELINE_PLOT_GUTTER_PX,
} from "./objective-timeline-utils";
import styles from "./PlayerStatusTimeline.module.css";
/** Consecutive reads further apart than this leave an unknown gap. */
const MAX_BRIDGE_SECONDS = 15;
/** Trailing open band drawn this long past its last confirming read. */
export const PLAYER_STATUS_TAIL_SECONDS = 1;
type PlayerFlags = readonly [boolean, boolean, boolean, boolean];
/** One icon-strip read, sides in `[alpha, bravo]` order. */
export interface PlayerStatusTimelineSample {
/** seconds into the source (video, stream or game) the read was made at */
t: number;
special: readonly [PlayerFlags, PlayerFlags];
dead: readonly [PlayerFlags, PlayerFlags];
}
export interface PlayerStatusTimelineTeam {
label: string;
/** weapon per slot in row order; null/absent slots render a placeholder */
weapons: (MainWeaponId | null)[];
}
export function PlayerStatusTimeline({
samples,
teams,
domain,
}: {
samples: readonly PlayerStatusTimelineSample[];
teams: readonly [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam];
/** x-axis range override, to share the objective chart's axis */
domain?: [number, number];
}) {
const { t } = useTranslation(["common"]);
const sorted = samples.toSorted((a, b) => a.t - b.t);
if (sorted.length === 0) return null;
const min = Math.min(domain?.[0] ?? Number.POSITIVE_INFINITY, sorted[0]!.t);
const max = Math.max(
domain?.[1] ?? 0,
sorted[sorted.length - 1]!.t + PLAYER_STATUS_TAIL_SECONDS,
);
const range = Math.max(1, max - min);
const leftOf = (span: StatusSpan) => `${((span.start - min) / range) * 100}%`;
const widthOf = (span: StatusSpan) =>
`${((span.end - span.start) / range) * 100}%`;
const titleOf = (label: string, span: StatusSpan) =>
`${label} · ${formatElapsed(span.start)}–${formatElapsed(span.end)}`;
return (
<div
className={styles.container}
style={
{
"--plot-gutter": `${TIMELINE_PLOT_GUTTER_PX}px`,
} as React.CSSProperties
}
>
<div className={styles.legend}>
<span className={styles.legendItem}>
<span className={styles.legendSwatchDead} />
{t("common:playerStatusTimeline.splatted")}
</span>
<span className={styles.legendItem}>
<span className={styles.legendSwatchSpecial} />
{t("common:playerStatusTimeline.specialReady")}
</span>
</div>
{([0, 1] as const).map((side) => (
<div key={side} className={styles.team}>
<div className={styles.teamLabel}>{teams[side].label}</div>
{[0, 1, 2, 3].map((slot) => (
<div key={slot} className={styles.row}>
<div className={styles.slotLabel}>
<SlotWeapon weaponSplId={teams[side].weapons[slot] ?? null} />
</div>
<div className={styles.track}>
{statusSpans(sorted, (sample) => sample.dead[side][slot]!).map(
(span, i) => (
<div
key={`d${i}`}
className={styles.spanDead}
style={{ left: leftOf(span), width: widthOf(span) }}
title={titleOf(
t("common:playerStatusTimeline.splatted"),
span,
)}
/>
),
)}
{statusSpans(
sorted,
(sample) => sample.special[side][slot]!,
).map((span, i) => (
<div
key={`s${i}`}
className={styles.spanSpecial}
style={{ left: leftOf(span), width: widthOf(span) }}
title={titleOf(
t("common:playerStatusTimeline.specialReady"),
span,
)}
/>
))}
</div>
</div>
))}
</div>
))}
</div>
);
}
function SlotWeapon({ weaponSplId }: { weaponSplId: MainWeaponId | null }) {
if (weaponSplId === null) {
return (
<Image
path={abilityImageUrl("UNKNOWN")}
alt="?"
size={22}
className={styles.unknownWeapon}
/>
);
}
return <WeaponImage weaponSplId={weaponSplId} variant="badge" size={22} />;
}
interface StatusSpan {
start: number;
end: number;
}
/**
* Contiguous stretches where the flag held true: a span opens at its first
* true read and closes at the read that shows false — or one second past
* its last confirmation when the next read is too far away (or the series
* ends) to know what happened in between.
*/
export function statusSpans(
sorted: readonly PlayerStatusTimelineSample[],
flagOf: (sample: PlayerStatusTimelineSample) => boolean,
): StatusSpan[] {
const spans: StatusSpan[] = [];
let start: number | null = null;
let lastTrueT = 0;
for (const sample of sorted) {
const flag = flagOf(sample);
if (start !== null && sample.t - lastTrueT > MAX_BRIDGE_SECONDS) {
spans.push({ start, end: lastTrueT + PLAYER_STATUS_TAIL_SECONDS });
start = null;
}
if (flag) {
start ??= sample.t;
lastTrueT = sample.t;
} else if (start !== null) {
spans.push({ start, end: sample.t });
start = null;
}
}
if (start !== null)
spans.push({ start, end: lastTrueT + PLAYER_STATUS_TAIL_SECONDS });
return spans;
}