analysis WiP
This commit is contained in:
@@ -7,7 +7,9 @@ import { AdminLedger } from "@/components/admin-ledger";
|
||||
import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
|
||||
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
|
||||
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
|
||||
import { AdminMatchAnalysis } from "@/components/admin-match-analysis";
|
||||
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
||||
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
|
||||
import {
|
||||
buildDashboardHref,
|
||||
type AdminDashboardTab,
|
||||
@@ -217,6 +219,10 @@ type Props = {
|
||||
matchmakerError: string | null;
|
||||
/** Ledger: add-system-supply action failed (URL `supplyErr=1`). */
|
||||
supplyError: boolean;
|
||||
analysisFrom: string;
|
||||
analysisTo: string;
|
||||
analysisPlayerIds: number[];
|
||||
matchAnalysis: MatchLogAnalysisResult;
|
||||
};
|
||||
|
||||
function StatCard({
|
||||
@@ -267,6 +273,10 @@ export function AdminDashboard({
|
||||
matchmakerContent,
|
||||
matchmakerError,
|
||||
supplyError,
|
||||
analysisFrom,
|
||||
analysisTo,
|
||||
analysisPlayerIds,
|
||||
matchAnalysis,
|
||||
}: Props) {
|
||||
const [hideNoWinner, setHideNoWinner] = useState(true);
|
||||
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
|
||||
@@ -423,6 +433,23 @@ export function AdminDashboard({
|
||||
>
|
||||
Ledger
|
||||
</Link>
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "analysis",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
analysisFrom,
|
||||
analysisTo,
|
||||
analysisPlayers:
|
||||
analysisPlayerIds.length > 0
|
||||
? analysisPlayerIds.join(",")
|
||||
: null,
|
||||
})}
|
||||
className={tabClass(tab === "analysis")}
|
||||
scroll={false}
|
||||
>
|
||||
Analysis
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className={tabClass(false)}
|
||||
@@ -721,6 +748,16 @@ export function AdminDashboard({
|
||||
errorMessage={matchmakerError}
|
||||
/>
|
||||
</section>
|
||||
) : tab === "analysis" ? (
|
||||
<AdminMatchAnalysis
|
||||
analysis={matchAnalysis}
|
||||
users={users}
|
||||
analysisFrom={analysisFrom}
|
||||
analysisTo={analysisTo}
|
||||
selectedPlayerIds={analysisPlayerIds}
|
||||
highlightId={highlightId}
|
||||
participantRaw={participantRaw}
|
||||
/>
|
||||
) : tab === "ledger" ? (
|
||||
<AdminLedger
|
||||
ledgerGlobalSummary={ledgerGlobalSummary}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { ClickableUserId } from "@/components/clickable-user-id";
|
||||
import { MatchAnalysisEmoteChart } from "@/components/match-analysis-emote-chart";
|
||||
import { MatchAnalysisForceChart } from "@/components/match-analysis-force-chart";
|
||||
import { buildDashboardHref } from "@/lib/dashboard-search-url";
|
||||
import type { MatchLogAnalysisResult, NumericAggregate } from "@/lib/match-log-parser";
|
||||
import type { DbUser } from "@/types/database";
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const s = Math.round(seconds);
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${String(r).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatAggregate(
|
||||
agg: NumericAggregate | null,
|
||||
formatValue: (n: number) => string,
|
||||
): { avg: string; min: string; max: string } {
|
||||
if (!agg) {
|
||||
return { avg: "—", min: "—", max: "—" };
|
||||
}
|
||||
return {
|
||||
avg: formatValue(agg.avg),
|
||||
min: formatValue(agg.min),
|
||||
max: formatValue(agg.max),
|
||||
};
|
||||
}
|
||||
|
||||
function StatBlock({
|
||||
title,
|
||||
avg,
|
||||
min,
|
||||
max,
|
||||
hint,
|
||||
}: {
|
||||
title: string;
|
||||
avg: string;
|
||||
min: string;
|
||||
max: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||
{title}
|
||||
</p>
|
||||
{hint ? (
|
||||
<p className="mt-0.5 text-[11px] text-zinc-500 dark:text-zinc-400">
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
<dl className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<dt className="text-[10px] uppercase text-zinc-500">Avg</dt>
|
||||
<dd className="mt-0.5 font-mono text-lg font-semibold tabular-nums">
|
||||
{avg}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[10px] uppercase text-zinc-500">Min</dt>
|
||||
<dd className="mt-0.5 font-mono text-lg font-semibold tabular-nums">
|
||||
{min}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[10px] uppercase text-zinc-500">Max</dt>
|
||||
<dd className="mt-0.5 font-mono text-lg font-semibold tabular-nums">
|
||||
{max}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
analysis: MatchLogAnalysisResult;
|
||||
users: DbUser[];
|
||||
analysisFrom: string;
|
||||
analysisTo: string;
|
||||
selectedPlayerIds: number[];
|
||||
highlightId: string | null;
|
||||
participantRaw: string | null;
|
||||
};
|
||||
|
||||
export function AdminMatchAnalysis({
|
||||
analysis,
|
||||
users,
|
||||
analysisFrom,
|
||||
analysisTo,
|
||||
selectedPlayerIds,
|
||||
highlightId,
|
||||
participantRaw,
|
||||
}: Props) {
|
||||
const usernameById = useMemo(() => {
|
||||
const m = new Map<number, string | null>();
|
||||
for (const u of users) m.set(u.id, u.username);
|
||||
return m;
|
||||
}, [users]);
|
||||
|
||||
const durationFmt = formatAggregate(analysis.duration, formatDuration);
|
||||
const shotsFmt = formatAggregate(analysis.shots, (n) =>
|
||||
Math.round(n).toLocaleString("en-US"),
|
||||
);
|
||||
const forceFmt = formatAggregate(analysis.force, (n) =>
|
||||
n.toLocaleString("en-US", { maximumFractionDigits: 1 }),
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Aggregates gameplay metrics from per-match log files (
|
||||
<span className="font-mono text-zinc-700 dark:text-zinc-300">
|
||||
MATCH_LOGS_DIR
|
||||
</span>
|
||||
/{" "}
|
||||
<span className="font-mono"><matchId>.txt</span>). Match length is
|
||||
first-to-last log timestamp; each{" "}
|
||||
<span className="font-mono">launching puck … force</span> line counts as
|
||||
one shot.
|
||||
</p>
|
||||
|
||||
{analysis.configError ? (
|
||||
<div
|
||||
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
|
||||
role="status"
|
||||
>
|
||||
{analysis.configError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form
|
||||
method="get"
|
||||
action="/"
|
||||
className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
||||
>
|
||||
<input type="hidden" name="tab" value="analysis" />
|
||||
{highlightId ? (
|
||||
<input type="hidden" name="highlight" value={highlightId} />
|
||||
) : null}
|
||||
{participantRaw ? (
|
||||
<input type="hidden" name="participant" value={participantRaw} />
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex min-w-[10rem] flex-col gap-1">
|
||||
<label
|
||||
htmlFor="analysis-afrom"
|
||||
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
|
||||
>
|
||||
From (UTC)
|
||||
</label>
|
||||
<input
|
||||
id="analysis-afrom"
|
||||
name="afrom"
|
||||
type="date"
|
||||
defaultValue={analysisFrom}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-[10rem] flex-col gap-1">
|
||||
<label
|
||||
htmlFor="analysis-ato"
|
||||
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
|
||||
>
|
||||
To (UTC)
|
||||
</label>
|
||||
<input
|
||||
id="analysis-ato"
|
||||
name="ato"
|
||||
type="date"
|
||||
defaultValue={analysisTo}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend className="text-xs font-medium text-zinc-500 dark:text-zinc-400">
|
||||
Players (optional — leave empty for all)
|
||||
</legend>
|
||||
<div className="mt-2 max-h-48 overflow-y-auto rounded-md border border-zinc-200 p-2 dark:border-zinc-700">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||||
{users.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500">No players loaded.</p>
|
||||
) : (
|
||||
users.map((u) => {
|
||||
const checked = selectedPlayerIds.includes(u.id);
|
||||
return (
|
||||
<label
|
||||
key={u.id}
|
||||
className="flex cursor-pointer items-center gap-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="aplayers"
|
||||
value={String(u.id)}
|
||||
defaultChecked={checked}
|
||||
className="rounded border-zinc-300 text-sky-600 focus:ring-sky-500 dark:border-zinc-600"
|
||||
/>
|
||||
<span className="font-mono text-xs">
|
||||
<ClickableUserId id={u.id} />
|
||||
{u.username ? (
|
||||
<span className="text-zinc-600 dark:text-zinc-400">
|
||||
{" "}
|
||||
({u.username})
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "analysis",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
})}
|
||||
scroll={false}
|
||||
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
Reset filters
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700 dark:border-zinc-800 dark:bg-zinc-900/60 dark:text-zinc-300">
|
||||
<span className="font-mono">
|
||||
{analysisFrom}–{analysisTo}
|
||||
</span>{" "}
|
||||
UTC
|
||||
{selectedPlayerIds.length > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
· players{" "}
|
||||
{selectedPlayerIds.map((id) => (
|
||||
<span key={id} className="font-mono">
|
||||
{id}
|
||||
{usernameById.get(id) ? ` (${usernameById.get(id)})` : ""}
|
||||
{" "}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
" · all players"
|
||||
)}
|
||||
<br />
|
||||
<span className="text-zinc-600 dark:text-zinc-400">
|
||||
{analysis.matchesInFilter.toLocaleString("en-US")} matches in filter
|
||||
{analysis.matchesScanned < analysis.matchesInFilter
|
||||
? ` · scanned ${analysis.matchesScanned.toLocaleString("en-US")} (cap)`
|
||||
: null}
|
||||
· {analysis.matchesWithLogs.toLocaleString("en-US")} with parsed logs
|
||||
{analysis.matchesMissingLogs > 0
|
||||
? ` · ${analysis.matchesMissingLogs.toLocaleString("en-US")} missing or empty`
|
||||
: null}
|
||||
{analysis.matchesSkippedTooLarge > 0
|
||||
? ` · ${analysis.matchesSkippedTooLarge.toLocaleString("en-US")} too large`
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<StatBlock
|
||||
title="Match length"
|
||||
avg={durationFmt.avg}
|
||||
min={durationFmt.min}
|
||||
max={durationFmt.max}
|
||||
hint="First to last log line (m:ss)"
|
||||
/>
|
||||
<StatBlock
|
||||
title="Shots per match"
|
||||
avg={shotsFmt.avg}
|
||||
min={shotsFmt.min}
|
||||
max={shotsFmt.max}
|
||||
hint="launching puck lines"
|
||||
/>
|
||||
<StatBlock
|
||||
title="Shot force magnitude"
|
||||
avg={forceFmt.avg}
|
||||
min={forceFmt.min}
|
||||
max={forceFmt.max}
|
||||
hint="√(fx² + fy²) per shot"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||
Force distribution (Fx, Fy)
|
||||
</h3>
|
||||
<MatchAnalysisForceChart points={analysis.forcePoints} />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||
Text emotes
|
||||
</h3>
|
||||
<MatchAnalysisEmoteChart
|
||||
rows={analysis.textEmotes}
|
||||
title="Text emotes"
|
||||
barClassName="fill-violet-500 dark:fill-violet-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||
Emoji emotes
|
||||
</h3>
|
||||
<MatchAnalysisEmoteChart
|
||||
rows={analysis.emojiEmotes}
|
||||
title="Emoji emotes"
|
||||
barClassName="fill-amber-500 dark:fill-amber-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -90,7 +90,9 @@ export function EditUserCcRcOverlay({
|
||||
? "matchmaker"
|
||||
: tab === "ledger"
|
||||
? "ledger"
|
||||
: "dashboard"
|
||||
: tab === "analysis"
|
||||
? "analysis"
|
||||
: "dashboard"
|
||||
}
|
||||
/>
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import type { EmoteBarRow } from "@/lib/match-log-parser";
|
||||
|
||||
const ROW_H = 22;
|
||||
const LABEL_W = 120;
|
||||
const BAR_MAX_W = 180;
|
||||
const PAD = 4;
|
||||
|
||||
type Props = {
|
||||
rows: EmoteBarRow[];
|
||||
/** e.g. "Text emotes" */
|
||||
title: string;
|
||||
barClassName?: string;
|
||||
};
|
||||
|
||||
export function MatchAnalysisEmoteChart({
|
||||
rows,
|
||||
title,
|
||||
barClassName = "fill-violet-500 dark:fill-violet-400",
|
||||
}: Props) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
No {title.toLowerCase()} in range.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const max = Math.max(...rows.map((r) => r.count), 1);
|
||||
const vbH = PAD * 2 + rows.length * ROW_H;
|
||||
const vbW = LABEL_W + BAR_MAX_W + 48;
|
||||
|
||||
return (
|
||||
<figure aria-label={`${title} bar chart`}>
|
||||
<svg
|
||||
viewBox={`0 0 ${vbW} ${vbH}`}
|
||||
className="h-auto w-full max-w-lg text-zinc-800 dark:text-zinc-200"
|
||||
role="img"
|
||||
>
|
||||
{rows.map((row, i) => {
|
||||
const y = PAD + i * ROW_H + ROW_H / 2;
|
||||
const barW = (row.count / max) * BAR_MAX_W;
|
||||
const label =
|
||||
row.label.length > 16
|
||||
? `${row.label.slice(0, 15)}…`
|
||||
: row.label;
|
||||
return (
|
||||
<g key={row.label}>
|
||||
<text
|
||||
x={0}
|
||||
y={y}
|
||||
dominantBaseline="middle"
|
||||
className="fill-zinc-600 text-[9px] dark:fill-zinc-400"
|
||||
style={{ fontFamily: "ui-monospace, monospace" }}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
<rect
|
||||
x={LABEL_W}
|
||||
y={y - 6}
|
||||
width={barW}
|
||||
height={12}
|
||||
rx={2}
|
||||
className={barClassName}
|
||||
/>
|
||||
<text
|
||||
x={LABEL_W + BAR_MAX_W + 6}
|
||||
y={y}
|
||||
dominantBaseline="middle"
|
||||
className="fill-zinc-500 text-[9px] tabular-nums dark:fill-zinc-400"
|
||||
style={{ fontFamily: "ui-monospace, monospace" }}
|
||||
>
|
||||
{row.count.toLocaleString("en-US")}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
const VB = 320;
|
||||
const PAD = 18;
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
function extent(values: number[]): { min: number; max: number } {
|
||||
if (values.length === 0) return { min: -1, max: 1 };
|
||||
let min = values[0]!;
|
||||
let max = values[0]!;
|
||||
for (const v of values) {
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
if (min === max) {
|
||||
const pad = Math.abs(min) * 0.1 + 1;
|
||||
return { min: min - pad, max: max + pad };
|
||||
}
|
||||
const margin = (max - min) * 0.05;
|
||||
return { min: min - margin, max: max + margin };
|
||||
}
|
||||
|
||||
type Props = {
|
||||
points: Point[];
|
||||
};
|
||||
|
||||
export function MatchAnalysisForceChart({ points }: Props) {
|
||||
const [hover, setHover] = useState<Point | null>(null);
|
||||
|
||||
const { dots, xExt, yExt } = useMemo(() => {
|
||||
const xs = points.map((p) => p.x);
|
||||
const ys = points.map((p) => p.y);
|
||||
const xExt = extent(xs);
|
||||
const yExt = extent(ys);
|
||||
const inner = VB - PAD * 2;
|
||||
const dots = points.map((p) => {
|
||||
const tx =
|
||||
xExt.max === xExt.min
|
||||
? 0.5
|
||||
: (p.x - xExt.min) / (xExt.max - xExt.min);
|
||||
const ty =
|
||||
yExt.max === yExt.min
|
||||
? 0.5
|
||||
: (p.y - yExt.min) / (yExt.max - yExt.min);
|
||||
return {
|
||||
cx: PAD + tx * inner,
|
||||
cy: PAD + (1 - ty) * inner,
|
||||
raw: p,
|
||||
};
|
||||
});
|
||||
return { dots, xExt, yExt };
|
||||
}, [points]);
|
||||
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<p className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
No shot force vectors in the selected matches.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<figure aria-label="Force vector scatter plot (x and y components)">
|
||||
<div
|
||||
className="relative cursor-crosshair"
|
||||
onPointerLeave={() => setHover(null)}
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${VB} ${VB}`}
|
||||
className="h-auto w-full max-w-md text-sky-600 dark:text-sky-400"
|
||||
role="img"
|
||||
onPointerMove={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const sx =
|
||||
((e.clientX - rect.left) / rect.width) * VB;
|
||||
const sy =
|
||||
((e.clientY - rect.top) / rect.height) * VB;
|
||||
let best: (typeof dots)[0] | null = null;
|
||||
let bestD = 12;
|
||||
for (const d of dots) {
|
||||
const dist = Math.hypot(d.cx - sx, d.cy - sy);
|
||||
if (dist < bestD) {
|
||||
bestD = dist;
|
||||
best = d;
|
||||
}
|
||||
}
|
||||
setHover(best?.raw ?? null);
|
||||
}}
|
||||
>
|
||||
<rect
|
||||
x={PAD}
|
||||
y={PAD}
|
||||
width={VB - PAD * 2}
|
||||
height={VB - PAD * 2}
|
||||
className="fill-zinc-50 stroke-zinc-200 dark:fill-zinc-900/50 dark:stroke-zinc-700"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<line
|
||||
x1={PAD + (VB - PAD * 2) / 2}
|
||||
y1={PAD}
|
||||
x2={PAD + (VB - PAD * 2) / 2}
|
||||
y2={VB - PAD}
|
||||
className="stroke-zinc-200 dark:stroke-zinc-700"
|
||||
strokeWidth={0.5}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<line
|
||||
x1={PAD}
|
||||
y1={PAD + (VB - PAD * 2) / 2}
|
||||
x2={VB - PAD}
|
||||
y2={PAD + (VB - PAD * 2) / 2}
|
||||
className="stroke-zinc-200 dark:stroke-zinc-700"
|
||||
strokeWidth={0.5}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
{dots.map((d, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={d.cx}
|
||||
cy={d.cy}
|
||||
r={hover === d.raw ? 3.5 : 2}
|
||||
className="fill-current opacity-70"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{hover ? (
|
||||
<div
|
||||
className="pointer-events-none absolute left-2 top-2 rounded-md border border-zinc-200 bg-white/95 px-2 py-1 text-[10px] font-mono tabular-nums shadow-sm dark:border-zinc-700 dark:bg-zinc-900/95"
|
||||
role="status"
|
||||
>
|
||||
({hover.x.toFixed(1)}, {hover.y.toFixed(1)})
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<figcaption className="mt-2 flex justify-between gap-2 font-mono text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||
<span>
|
||||
Fx {xExt.min.toFixed(0)} … {xExt.max.toFixed(0)}
|
||||
</span>
|
||||
<span>
|
||||
Fy {yExt.min.toFixed(0)} … {yExt.max.toFixed(0)}
|
||||
</span>
|
||||
</figcaption>
|
||||
<p className="mt-1 text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||
{points.length.toLocaleString("en-US")} shots · magnitude √(x² + y²)
|
||||
</p>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user