diff --git a/frontend-web/src/components/workspace/admin/concurrency-monitor.tsx b/frontend-web/src/components/workspace/admin/concurrency-monitor.tsx index 51d6e6b8..dec77b11 100644 --- a/frontend-web/src/components/workspace/admin/concurrency-monitor.tsx +++ b/frontend-web/src/components/workspace/admin/concurrency-monitor.tsx @@ -9,7 +9,7 @@ import { Square, Zap, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; @@ -60,39 +60,117 @@ interface LineSeries { values: (number | null)[]; } -/** Minimal dependency-free SVG line chart (1–2 series + area under the first). */ +/** Round a value up to a "nice" axis ceiling (1 / 2 / 5 × 10ⁿ). */ +function niceCeil(v: number): number { + if (!Number.isFinite(v) || v <= 0) return 1; + const exp = Math.floor(Math.log10(v)); + const base = 10 ** exp; + const f = v / base; + const nice = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10; + return nice * base; +} + +/** + * Build distinct axis ticks from the data max. For integer series (并发量) this + * yields whole-number ticks with no duplicate labels (fixes the「0 0 0 1 1」 + * axis when the max is 1); for floats it uses a nice ceiling at quarters. + */ +function buildTicks(dataMax: number, integer: boolean): number[] { + if (!Number.isFinite(dataMax) || dataMax <= 0) return [0, 1]; + if (integer) { + const niceMax = Math.max(1, Math.ceil(dataMax)); + const step = Math.max(1, Math.ceil(niceMax / 4)); // ≤5 integer ticks + const top = Math.ceil(niceMax / step) * step; + const ticks: number[] = []; + for (let v = 0; v <= top; v += step) ticks.push(v); + return ticks; + } + const niceMax = niceCeil(dataMax); + return [0, 0.25, 0.5, 0.75, 1].map((r) => niceMax * r); +} + +function fmtAxis(v: number, integer: boolean): string { + if (integer) return String(Math.round(v)); + if (v === 0) return "0"; + return v < 10 ? v.toFixed(1) : String(Math.round(v)); +} + +/** Last non-null value of a series (for the legend's current reading). */ +function latestOf(values: (number | null)[]): number | null { + for (let i = values.length - 1; i >= 0; i -= 1) { + if (values[i] != null) return values[i] as number; + } + return null; +} + +/** Minimal dependency-free SVG line chart: legend + 1–2 series + area + caption. */ function LineChart({ labels, series, unit, + integer = false, + note, emptyHint, }: { labels: string[]; series: LineSeries[]; unit: string; + integer?: boolean; + note?: string; emptyHint: string; }) { + const gradId = `cm-area-${useId().replace(/:/g, "")}`; const width = 760; const height = 220; - const pad = { top: 18, right: 16, bottom: 26, left: 40 }; + const pad = { top: 16, right: 16, bottom: 26, left: 44 }; const cw = width - pad.left - pad.right; const ch = height - pad.top - pad.bottom; + const n = labels.length; const allValues = series.flatMap((s) => s.values.filter((v): v is number => v != null)); - const max = Math.max(1, ...allValues); - const n = labels.length; + const dataMax = allValues.length ? Math.max(...allValues) : 0; + const ticks = buildTicks(dataMax, integer); + const axisMax = ticks[ticks.length - 1] || 1; + + // Legend always renders (even with no data) so the lines are self-explanatory. + const legend = ( +
{note}
+ ) : null; if (n === 0) { return ( -