feat(frontend): add websocket send speed packet control
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { AudioSample } from "../../../entities/audioSample/model/types";
|
import type { AudioSample } from "../../../entities/audioSample/model/types";
|
||||||
import { env } from "../../../shared/config/env";
|
import { env } from "../../../shared/config/env";
|
||||||
|
import { RingBuffer } from "../../../shared/lib/ringBuffer";
|
||||||
import type { WsStatus } from "./types";
|
import type { WsStatus } from "./types";
|
||||||
import { LiveWsClient } from "../lib/liveWsClient";
|
import { LiveWsClient } from "../lib/liveWsClient";
|
||||||
import { parseAndValidateMessage } from "../lib/parseAndValidate";
|
import { parseAndValidateMessage } from "../lib/parseAndValidate";
|
||||||
@@ -9,114 +10,249 @@ type LiveStreamState = {
|
|||||||
status: WsStatus;
|
status: WsStatus;
|
||||||
lastMessageAt: number | null;
|
lastMessageAt: number | null;
|
||||||
|
|
||||||
latest: AudioSample | null;
|
// WS frequency control
|
||||||
|
requestedHz: number; // 1..60
|
||||||
|
setRequestedHz: (hz: number) => void;
|
||||||
|
|
||||||
history60s: AudioSample[];
|
// Window selection
|
||||||
|
windowMs: number;
|
||||||
|
setWindowMs: (ms: number) => void;
|
||||||
|
|
||||||
|
// Derived UI state (throttled)
|
||||||
|
latest: AudioSample | null;
|
||||||
peakHoldDb3s: number | null;
|
peakHoldDb3s: number | null;
|
||||||
|
chartHistory: AudioSample[];
|
||||||
|
|
||||||
connect: () => void;
|
connect: () => void;
|
||||||
disconnect: () => void;
|
disconnect: () => void;
|
||||||
loadLatest: (limit?: number) => Promise<void>;
|
loadLatest: (limit?: number) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const WINDOW_MS = 60_000;
|
|
||||||
const PEAK_WINDOW_MS = 3_000;
|
const PEAK_WINDOW_MS = 3_000;
|
||||||
|
|
||||||
// safety cap для worst-case 60Hz: ~3600 точек за 60 секунд
|
const WINDOW_OPTIONS_MS = [15_000, 30_000, 60_000, 120_000, 300_000] as const;
|
||||||
// (нужен на случай странных таймстампов/скачков времени)
|
const DEFAULT_WINDOW_MS = 60_000;
|
||||||
const MAX_POINTS_60S = 60 * 60;
|
|
||||||
|
|
||||||
let historyWindow: AudioSample[] = [];
|
const MIN_HZ = 1;
|
||||||
|
const MAX_HZ = 60;
|
||||||
|
const DEFAULT_REQUESTED_HZ = 10;
|
||||||
|
|
||||||
|
// If there are more than 600 points in selected window -> downsample
|
||||||
|
const MAX_CHART_POINTS = 600;
|
||||||
|
|
||||||
|
// UI updates not more than ~12 Hz (10–15 Hz recommended)
|
||||||
|
const UI_FLUSH_HZ = 12;
|
||||||
|
const UI_FLUSH_MS = Math.round(1000 / UI_FLUSH_HZ);
|
||||||
|
|
||||||
|
// Max window is 5m; worst-case 60 Hz => 18k points (+ headroom)
|
||||||
|
const RAW_CAPACITY = 20_000;
|
||||||
|
|
||||||
|
// -------- Module-level raw buffers (NOT in Zustand state) --------
|
||||||
|
const rawHistory = new RingBuffer<AudioSample>(RAW_CAPACITY);
|
||||||
let peakWindow: AudioSample[] = [];
|
let peakWindow: AudioSample[] = [];
|
||||||
let client: LiveWsClient | null = null;
|
|
||||||
|
|
||||||
function trimByTime(arr: AudioSample[], cutoffMs: number): AudioSample[] {
|
let client: LiveWsClient | null = null;
|
||||||
// shift в цикле ок для ~3600 элементов; если захочешь — можно оптимизировать индексом
|
let flushTimer: number | null = null;
|
||||||
while (arr.length && arr[0]!.timeMs < cutoffMs) arr.shift();
|
|
||||||
if (arr.length > MAX_POINTS_60S) arr = arr.slice(-MAX_POINTS_60S);
|
let lastSeenSample: AudioSample | null = null;
|
||||||
return arr;
|
|
||||||
|
function clampInt(v: number, min: number, max: number): number {
|
||||||
|
if (!Number.isFinite(v)) return min;
|
||||||
|
return Math.max(min, Math.min(max, Math.trunc(v)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function computePeakDb(windowSamples: AudioSample[]): number | null {
|
function isAllowedWindowMs(ms: number): ms is (typeof WINDOW_OPTIONS_MS)[number] {
|
||||||
if (windowSamples.length === 0) return null;
|
return (WINDOW_OPTIONS_MS as readonly number[]).includes(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWsUrl(base: string, hz: number): string {
|
||||||
|
const safeHz = clampInt(hz, MIN_HZ, MAX_HZ);
|
||||||
|
|
||||||
|
// Prefer URL() for correctness (keeps existing params)
|
||||||
|
try {
|
||||||
|
const u = new URL(base);
|
||||||
|
u.searchParams.set("hz", String(safeHz));
|
||||||
|
return u.toString();
|
||||||
|
} catch {
|
||||||
|
const sep = base.includes("?") ? "&" : "?";
|
||||||
|
return `${base}${sep}hz=${encodeURIComponent(String(safeHz))}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimPeakWindow(nowSampleMs: number): void {
|
||||||
|
const cutoff = nowSampleMs - PEAK_WINDOW_MS;
|
||||||
|
while (peakWindow.length && peakWindow[0]!.timeMs < cutoff) peakWindow.shift();
|
||||||
|
|
||||||
|
// Safety cap: even at 60 Hz, 3 sec ~ 180 points; allow some jitter
|
||||||
|
if (peakWindow.length > 512) peakWindow = peakWindow.slice(-512);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePeakDb3s(): number | null {
|
||||||
|
if (!peakWindow.length) return null;
|
||||||
let max = -Infinity;
|
let max = -Infinity;
|
||||||
for (const s of windowSamples) max = Math.max(max, s.rms_db);
|
for (const s of peakWindow) max = Math.max(max, s.rms_db);
|
||||||
return Number.isFinite(max) ? max : null;
|
return Number.isFinite(max) ? max : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useLiveStreamStore = create<LiveStreamState>()((set) => ({
|
function makeChartHistory(windowMs: number): AudioSample[] {
|
||||||
status: "disconnected",
|
const latest = lastSeenSample ?? rawHistory.last();
|
||||||
lastMessageAt: null,
|
if (!latest) return [];
|
||||||
|
|
||||||
latest: null,
|
const cutoff = latest.timeMs - windowMs;
|
||||||
history60s: [],
|
|
||||||
peakHoldDb3s: null,
|
|
||||||
|
|
||||||
connect: () => {
|
// Note: rawHistory.toArray() allocates, but it's called at ~12 Hz, not per WS packet
|
||||||
|
const windowed = rawHistory.toArray().filter((s) => s.timeMs >= cutoff);
|
||||||
|
|
||||||
|
if (windowed.length <= MAX_CHART_POINTS) return windowed;
|
||||||
|
|
||||||
|
const step = Math.ceil(windowed.length / MAX_CHART_POINTS);
|
||||||
|
const out: AudioSample[] = [];
|
||||||
|
for (let i = 0; i < windowed.length; i += step) out.push(windowed[i]!);
|
||||||
|
|
||||||
|
// Ensure last point is present (prevents “missing tail” effect)
|
||||||
|
const last = windowed.at(-1);
|
||||||
|
if (last && out.at(-1)?.timeMs !== last.timeMs) out.push(last);
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLiveStreamStore = create<LiveStreamState>()((set, get) => {
|
||||||
|
function clearFlushTimer(): void {
|
||||||
|
if (flushTimer !== null) {
|
||||||
|
window.clearTimeout(flushTimer);
|
||||||
|
flushTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushToUi(): void {
|
||||||
|
clearFlushTimer();
|
||||||
|
|
||||||
|
const { windowMs } = get();
|
||||||
|
const latest = lastSeenSample ?? rawHistory.last();
|
||||||
|
const chartHistory = makeChartHistory(windowMs);
|
||||||
|
|
||||||
|
set({
|
||||||
|
latest: latest ?? null,
|
||||||
|
peakHoldDb3s: computePeakDb3s(),
|
||||||
|
chartHistory,
|
||||||
|
lastMessageAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFlush(): void {
|
||||||
|
if (flushTimer !== null) return;
|
||||||
|
flushTimer = window.setTimeout(flushToUi, UI_FLUSH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureClientConnected(): void {
|
||||||
if (client) return;
|
if (client) return;
|
||||||
|
|
||||||
client = new LiveWsClient(env.wsUrl, {
|
const hz = get().requestedHz;
|
||||||
|
const url = buildWsUrl(env.wsUrl, hz);
|
||||||
|
|
||||||
|
client = new LiveWsClient(url, {
|
||||||
onStatus: (st) => set({ status: st }),
|
onStatus: (st) => set({ status: st }),
|
||||||
onMessage: (data) => {
|
onMessage: (data) => {
|
||||||
const parsed = parseAndValidateMessage(data);
|
const parsed = parseAndValidateMessage(data);
|
||||||
if (!parsed.ok) return;
|
if (!parsed.ok) return;
|
||||||
|
|
||||||
const sample = parsed.sample;
|
const sample = parsed.sample;
|
||||||
|
lastSeenSample = sample;
|
||||||
|
|
||||||
// 60s time window (по времени, а не по "600 точек")
|
rawHistory.push(sample);
|
||||||
historyWindow.push(sample);
|
|
||||||
historyWindow = trimByTime(historyWindow, sample.timeMs - WINDOW_MS);
|
|
||||||
|
|
||||||
// 3s peak-hold window (тоже по времени сэмпла)
|
|
||||||
peakWindow.push(sample);
|
peakWindow.push(sample);
|
||||||
peakWindow = trimByTime(peakWindow, sample.timeMs - PEAK_WINDOW_MS);
|
trimPeakWindow(sample.timeMs);
|
||||||
|
|
||||||
set({
|
// Throttled UI update (important for Recharts + overall UI smoothness)
|
||||||
latest: sample,
|
scheduleFlush();
|
||||||
history60s: historyWindow.slice(), // копия для UI
|
|
||||||
peakHoldDb3s: computePeakDb(peakWindow),
|
|
||||||
lastMessageAt: Date.now(),
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
client.connect();
|
client.connect();
|
||||||
},
|
}
|
||||||
|
|
||||||
disconnect: () => {
|
function reconnectWithNewHz(): void {
|
||||||
client?.close();
|
if (!client) return;
|
||||||
|
client.close();
|
||||||
client = null;
|
client = null;
|
||||||
peakWindow = [];
|
ensureClientConnected();
|
||||||
// historyWindow оставляем: график не должен "прыгать" при reconnect
|
}
|
||||||
set({ status: "disconnected" });
|
|
||||||
},
|
|
||||||
|
|
||||||
loadLatest: async (limit = 100) => {
|
return {
|
||||||
const url = `${env.apiUrl.replace(/\/$/, "")}/api/v1/audio/latest?limit=${limit}`;
|
status: "disconnected",
|
||||||
try {
|
lastMessageAt: null,
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) return;
|
|
||||||
|
|
||||||
const raw = (await res.json()) as Array<{
|
requestedHz: DEFAULT_REQUESTED_HZ,
|
||||||
time: string;
|
setRequestedHz: (hz) => {
|
||||||
rms_db: number;
|
const next = clampInt(hz, MIN_HZ, MAX_HZ);
|
||||||
freq_hz: number;
|
const prev = get().requestedHz;
|
||||||
}>;
|
if (next === prev) return;
|
||||||
|
|
||||||
for (const item of raw) {
|
set({ requestedHz: next });
|
||||||
const parsed = parseAndValidateMessage(JSON.stringify(item));
|
reconnectWithNewHz();
|
||||||
if (!parsed.ok) continue;
|
},
|
||||||
historyWindow.push(parsed.sample);
|
|
||||||
|
windowMs: DEFAULT_WINDOW_MS,
|
||||||
|
setWindowMs: (ms) => {
|
||||||
|
const next = isAllowedWindowMs(ms) ? ms : DEFAULT_WINDOW_MS;
|
||||||
|
if (next === get().windowMs) return;
|
||||||
|
|
||||||
|
set({ windowMs: next });
|
||||||
|
// Recompute immediately (doesn't touch WS)
|
||||||
|
flushToUi();
|
||||||
|
},
|
||||||
|
|
||||||
|
latest: null,
|
||||||
|
peakHoldDb3s: null,
|
||||||
|
chartHistory: [],
|
||||||
|
|
||||||
|
connect: () => {
|
||||||
|
ensureClientConnected();
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnect: () => {
|
||||||
|
clearFlushTimer();
|
||||||
|
client?.close();
|
||||||
|
client = null;
|
||||||
|
set({ status: "disconnected" });
|
||||||
|
// Raw history intentionally preserved to avoid “jumping” on reconnect
|
||||||
|
},
|
||||||
|
|
||||||
|
loadLatest: async (limit = 300) => {
|
||||||
|
const safeLimit = clampInt(limit, 1, 5000);
|
||||||
|
const base = env.apiUrl.replace(/\/$/, "");
|
||||||
|
const url = `${base}/api/v1/audio/latest?limit=${safeLimit}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const raw = (await res.json()) as Array<{
|
||||||
|
time: string;
|
||||||
|
rms_db: number;
|
||||||
|
freq_hz: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Push historical points into raw buffer (no per-item setState!)
|
||||||
|
for (const item of raw) {
|
||||||
|
const parsed = parseAndValidateMessage(JSON.stringify(item));
|
||||||
|
if (!parsed.ok) continue;
|
||||||
|
|
||||||
|
rawHistory.push(parsed.sample);
|
||||||
|
lastSeenSample = parsed.sample;
|
||||||
|
|
||||||
|
// Warm-up peak window too (optional but consistent)
|
||||||
|
peakWindow.push(parsed.sample);
|
||||||
|
trimPeakWindow(parsed.sample.timeMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single UI update after warm-up
|
||||||
|
flushToUi();
|
||||||
|
} catch {
|
||||||
|
// graceful fallback: ignore (dashboard must stay usable without REST)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// после warm-up тоже режем по времени "последней точки"
|
|
||||||
const last = historyWindow.at(-1);
|
|
||||||
if (last)
|
|
||||||
historyWindow = trimByTime(historyWindow, last.timeMs - WINDOW_MS);
|
|
||||||
|
|
||||||
set({ history60s: historyWindow.slice() });
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// src/widgets/audioLive/ui/AudioLiveWidget.tsx
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useLiveStreamStore } from "../../../features/liveStream/model/liveStream.store";
|
import { useLiveStreamStore } from "../../../features/liveStream/model/liveStream.store";
|
||||||
import { WsStatusBadge } from "../../../features/liveStream/ui/WsStatusBadge";
|
import { WsStatusBadge } from "../../../features/liveStream/ui/WsStatusBadge";
|
||||||
import { AudioMeter } from "./AudioMeter";
|
import { AudioMeter } from "./AudioMeter";
|
||||||
@@ -11,16 +13,34 @@ import {
|
|||||||
import { freqToNote } from "../../../entities/audioSample/lib/note";
|
import { freqToNote } from "../../../entities/audioSample/lib/note";
|
||||||
import { formatTimeHHMMSS, isStale } from "../../../shared/lib/time";
|
import { formatTimeHHMMSS, isStale } from "../../../shared/lib/time";
|
||||||
|
|
||||||
|
const HZ_OPTIONS = [1, 5, 10, 15, 30, 60] as const;
|
||||||
|
|
||||||
|
const WINDOW_OPTIONS: Array<{ label: string; ms: number }> = [
|
||||||
|
{ label: "15s", ms: 15_000 },
|
||||||
|
{ label: "30s", ms: 30_000 },
|
||||||
|
{ label: "60s", ms: 60_000 },
|
||||||
|
{ label: "2m", ms: 120_000 },
|
||||||
|
{ label: "5m", ms: 300_000 },
|
||||||
|
];
|
||||||
|
|
||||||
export function AudioLiveWidget() {
|
export function AudioLiveWidget() {
|
||||||
const status = useLiveStreamStore((s) => s.status);
|
const status = useLiveStreamStore((s) => s.status);
|
||||||
const latest = useLiveStreamStore((s) => s.latest);
|
const latest = useLiveStreamStore((s) => s.latest);
|
||||||
const history60s = useLiveStreamStore((s) => s.history60s);
|
const chartHistory = useLiveStreamStore((s) => s.chartHistory);
|
||||||
const peakHoldDb3s = useLiveStreamStore((s) => s.peakHoldDb3s);
|
const peakHoldDb3s = useLiveStreamStore((s) => s.peakHoldDb3s);
|
||||||
const lastMessageAt = useLiveStreamStore((s) => s.lastMessageAt);
|
const lastMessageAt = useLiveStreamStore((s) => s.lastMessageAt);
|
||||||
|
|
||||||
|
const requestedHz = useLiveStreamStore((s) => s.requestedHz);
|
||||||
|
const setRequestedHz = useLiveStreamStore((s) => s.setRequestedHz);
|
||||||
|
|
||||||
|
const windowMs = useLiveStreamStore((s) => s.windowMs);
|
||||||
|
const setWindowMs = useLiveStreamStore((s) => s.setWindowMs);
|
||||||
|
|
||||||
const stale = isStale(lastMessageAt, 1500);
|
const stale = isStale(lastMessageAt, 1500);
|
||||||
|
|
||||||
const note = latest ? freqToNote(latest.freq_hz) : "--";
|
const note = useMemo(() => {
|
||||||
|
return latest ? freqToNote(latest.freq_hz) : "--";
|
||||||
|
}, [latest]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
@@ -35,9 +55,12 @@ export function AudioLiveWidget() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2 flex flex-row items-center justify-between gap-3">
|
<CardHeader className="pb-2 flex flex-row items-center justify-between gap-3">
|
||||||
<CardTitle className="text-base">Live status</CardTitle>
|
<CardTitle className="text-base">Live status</CardTitle>
|
||||||
<WsStatusBadge status={status} />
|
<div className="flex items-center gap-3">
|
||||||
|
<WsStatusBadge status={status} />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-wrap items-center justify-between gap-4">
|
|
||||||
|
<CardContent className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Last update</div>
|
<div className="text-sm text-muted-foreground">Last update</div>
|
||||||
<div className="text-lg font-medium tabular-nums">
|
<div className="text-lg font-medium tabular-nums">
|
||||||
@@ -64,10 +87,43 @@ export function AudioLiveWidget() {
|
|||||||
{latest ? `${latest.rms_db.toFixed(1)} dB` : "—"}
|
{latest ? `${latest.rms_db.toFixed(1)} dB` : "—"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">WS Hz</span>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
value={requestedHz}
|
||||||
|
onChange={(e) => setRequestedHz(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{HZ_OPTIONS.map((hz) => (
|
||||||
|
<option key={hz} value={hz}>
|
||||||
|
{hz} Hz
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">Window</span>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
value={windowMs}
|
||||||
|
onChange={(e) => setWindowMs(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{WINDOW_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.ms} value={opt.ms}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<FrequencyHistoryChart history={history60s} />
|
<FrequencyHistoryChart history={chartHistory} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// src/widgets/audioLive/ui/FrequencyHistoryChart.tsx
|
||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Line,
|
Line,
|
||||||
@@ -28,15 +29,25 @@ type ChartPoint = {
|
|||||||
const Y_MIN = 129;
|
const Y_MIN = 129;
|
||||||
const Y_MAX = 5500;
|
const Y_MAX = 5500;
|
||||||
|
|
||||||
const Y_TICKS = [130, 200, 300, 440, 660, 880, 1000, 2000, 3000, 4000, 5500];
|
const Y_TICKS = [139, 200, 500, 1000, 2000, 5000, 5500];
|
||||||
|
|
||||||
|
function formatHzTick(v: number): string {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n)) return "";
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`;
|
||||||
|
return `${Math.round(n)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export const FrequencyHistoryChart = memo(function FrequencyHistoryChart({
|
export const FrequencyHistoryChart = memo(function FrequencyHistoryChart({
|
||||||
history = [],
|
history = [],
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const data: ChartPoint[] = useMemo(() => {
|
const data: ChartPoint[] = useMemo(() => {
|
||||||
|
if (!history?.length) return [];
|
||||||
return history.map((s) => ({ timeMs: s.timeMs, freq_hz: s.freq_hz }));
|
return history.map((s) => ({ timeMs: s.timeMs, freq_hz: s.freq_hz }));
|
||||||
}, [history]);
|
}, [history]);
|
||||||
|
|
||||||
|
const hasData = data.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
@@ -44,7 +55,13 @@ export const FrequencyHistoryChart = memo(function FrequencyHistoryChart({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-56 w-full">
|
<div className="relative h-56 min-h-[14rem] w-full">
|
||||||
|
{!hasData && (
|
||||||
|
<div className="absolute inset-0 grid place-items-center text-sm text-muted-foreground">
|
||||||
|
No data yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart
|
<LineChart
|
||||||
data={data}
|
data={data}
|
||||||
@@ -56,6 +73,7 @@ export const FrequencyHistoryChart = memo(function FrequencyHistoryChart({
|
|||||||
domain={["dataMin", "dataMax"]}
|
domain={["dataMin", "dataMax"]}
|
||||||
tickFormatter={(v) => formatTimeHHMMSS(Number(v))}
|
tickFormatter={(v) => formatTimeHHMMSS(Number(v))}
|
||||||
tick={{ fontSize: 12 }}
|
tick={{ fontSize: 12 }}
|
||||||
|
interval="preserveStartEnd"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<YAxis
|
<YAxis
|
||||||
@@ -66,7 +84,7 @@ export const FrequencyHistoryChart = memo(function FrequencyHistoryChart({
|
|||||||
allowDataOverflow
|
allowDataOverflow
|
||||||
tick={{ fontSize: 12 }}
|
tick={{ fontSize: 12 }}
|
||||||
width={52}
|
width={52}
|
||||||
tickFormatter={(v) => `${Number(v).toFixed(0)}`}
|
tickFormatter={(v) => formatHzTick(Number(v))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
|||||||
Reference in New Issue
Block a user