chore: add prettier with config and format all files
This commit is contained in:
@@ -7,34 +7,43 @@ import { resolveName, resolveKey } from '@/lib/localization';
|
||||
import type { AlertConfig } from '@/lib/types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const inputCls = 'w-full bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-indigo-500';
|
||||
const selectCls = 'flex-1 bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none';
|
||||
const inputCls =
|
||||
'w-full bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-indigo-500';
|
||||
const selectCls =
|
||||
'flex-1 bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none';
|
||||
|
||||
interface AlertFormState {
|
||||
itemKey: string;
|
||||
itemKey: string;
|
||||
itemKeyIsRegex: boolean;
|
||||
combinator: string;
|
||||
signalType: 'green' | 'red';
|
||||
condition: 'above' | 'below';
|
||||
threshold: string;
|
||||
combinator: string;
|
||||
signalType: 'green' | 'red';
|
||||
condition: 'above' | 'below';
|
||||
threshold: string;
|
||||
}
|
||||
|
||||
function emptyForm(): AlertFormState {
|
||||
return { itemKey: '', itemKeyIsRegex: false, combinator: '', signalType: 'green', condition: 'below', threshold: '0' };
|
||||
return {
|
||||
itemKey: '',
|
||||
itemKeyIsRegex: false,
|
||||
combinator: '',
|
||||
signalType: 'green',
|
||||
condition: 'below',
|
||||
threshold: '0',
|
||||
};
|
||||
}
|
||||
|
||||
function alertToForm(a: AlertConfig): AlertFormState {
|
||||
return {
|
||||
itemKey: a.item_key,
|
||||
itemKey: a.item_key,
|
||||
itemKeyIsRegex: a.item_key_is_regex,
|
||||
combinator: a.combinator ?? '',
|
||||
signalType: a.signal_type,
|
||||
condition: a.condition,
|
||||
threshold: String(a.threshold),
|
||||
combinator: a.combinator ?? '',
|
||||
signalType: a.signal_type,
|
||||
condition: a.condition,
|
||||
threshold: String(a.threshold),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,48 +59,78 @@ function Tooltip({ text }: { text: string }) {
|
||||
}
|
||||
|
||||
function AlertForm({
|
||||
value, onChange, onSubmit, onCancel, submitLabel,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel,
|
||||
}: {
|
||||
value: AlertFormState;
|
||||
onChange: (s: AlertFormState) => void;
|
||||
onSubmit: () => void;
|
||||
onCancel?: () => void;
|
||||
value: AlertFormState;
|
||||
onChange: (s: AlertFormState) => void;
|
||||
onSubmit: () => void;
|
||||
onCancel?: () => void;
|
||||
submitLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={value.itemKey}
|
||||
onChange={e => onChange({ ...value, itemKey: e.target.value })}
|
||||
onChange={(e) => onChange({ ...value, itemKey: e.target.value })}
|
||||
placeholder={value.itemKeyIsRegex ? 'iron-.*|Iron Plate' : 'Iron Plate or item-key'}
|
||||
className={inputCls}
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer">
|
||||
<input type="checkbox" checked={value.itemKeyIsRegex}
|
||||
onChange={e => onChange({ ...value, itemKeyIsRegex: e.target.checked })}
|
||||
className="accent-indigo-500" />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.itemKeyIsRegex}
|
||||
onChange={(e) => onChange({ ...value, itemKeyIsRegex: e.target.checked })}
|
||||
className="accent-indigo-500"
|
||||
/>
|
||||
Item key is regex
|
||||
</label>
|
||||
<input value={value.combinator} onChange={e => onChange({ ...value, combinator: e.target.value })}
|
||||
placeholder="combinator (empty = all)" className={inputCls} />
|
||||
<input
|
||||
value={value.combinator}
|
||||
onChange={(e) => onChange({ ...value, combinator: e.target.value })}
|
||||
placeholder="combinator (empty = all)"
|
||||
className={inputCls}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<select value={value.signalType} onChange={e => onChange({ ...value, signalType: e.target.value as 'green' | 'red' })} className={selectCls}>
|
||||
<select
|
||||
value={value.signalType}
|
||||
onChange={(e) => onChange({ ...value, signalType: e.target.value as 'green' | 'red' })}
|
||||
className={selectCls}
|
||||
>
|
||||
<option value="green">Green</option>
|
||||
<option value="red">Red</option>
|
||||
</select>
|
||||
<select value={value.condition} onChange={e => onChange({ ...value, condition: e.target.value as 'above' | 'below' })} className={selectCls}>
|
||||
<select
|
||||
value={value.condition}
|
||||
onChange={(e) => onChange({ ...value, condition: e.target.value as 'above' | 'below' })}
|
||||
className={selectCls}
|
||||
>
|
||||
<option value="below">Below</option>
|
||||
<option value="above">Above</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="number" value={value.threshold} onChange={e => onChange({ ...value, threshold: e.target.value })}
|
||||
placeholder="threshold" className={inputCls} />
|
||||
<input
|
||||
type="number"
|
||||
value={value.threshold}
|
||||
onChange={(e) => onChange({ ...value, threshold: e.target.value })}
|
||||
placeholder="threshold"
|
||||
className={inputCls}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onSubmit} className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded py-1.5">
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded py-1.5"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button onClick={onCancel} className="px-3 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
@@ -102,10 +141,10 @@ function AlertForm({
|
||||
|
||||
export default function AlertPanel({ open, onClose }: Props) {
|
||||
const { triggeredAlerts, refreshAlerts, localeMap, reverseMap } = useApp();
|
||||
const [alerts, setAlerts] = useState<AlertConfig[]>([]);
|
||||
const [newForm, setNewForm] = useState<AlertFormState>(emptyForm());
|
||||
const [alerts, setAlerts] = useState<AlertConfig[]>([]);
|
||||
const [newForm, setNewForm] = useState<AlertFormState>(emptyForm());
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editForm, setEditForm] = useState<AlertFormState>(emptyForm());
|
||||
const [editForm, setEditForm] = useState<AlertFormState>(emptyForm());
|
||||
const prevTriggeredCount = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -115,8 +154,8 @@ export default function AlertPanel({ open, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
if (triggeredAlerts.length > prevTriggeredCount.current) {
|
||||
try {
|
||||
const ctx = new AudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const ctx = new AudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
@@ -125,7 +164,9 @@ export default function AlertPanel({ open, onClose }: Props) {
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + 0.4);
|
||||
} catch { /* AudioContext blocked */ }
|
||||
} catch {
|
||||
/* AudioContext blocked */
|
||||
}
|
||||
}
|
||||
prevTriggeredCount.current = triggeredAlerts.length;
|
||||
}, [triggeredAlerts.length]);
|
||||
@@ -139,14 +180,14 @@ export default function AlertPanel({ open, onClose }: Props) {
|
||||
async function handleCreate() {
|
||||
if (!newForm.itemKey.trim()) return;
|
||||
const created = await createAlert({
|
||||
item_key: normalizeItemKey(newForm),
|
||||
item_key: normalizeItemKey(newForm),
|
||||
item_key_is_regex: newForm.itemKeyIsRegex,
|
||||
combinator: newForm.combinator.trim() || null,
|
||||
signal_type: newForm.signalType,
|
||||
condition: newForm.condition,
|
||||
threshold: parseInt(newForm.threshold, 10),
|
||||
combinator: newForm.combinator.trim() || null,
|
||||
signal_type: newForm.signalType,
|
||||
condition: newForm.condition,
|
||||
threshold: parseInt(newForm.threshold, 10),
|
||||
});
|
||||
setAlerts(a => [created, ...a]);
|
||||
setAlerts((a) => [created, ...a]);
|
||||
setNewForm(emptyForm());
|
||||
await refreshAlerts();
|
||||
}
|
||||
@@ -154,21 +195,21 @@ export default function AlertPanel({ open, onClose }: Props) {
|
||||
async function handleEdit(id: string) {
|
||||
if (!editForm.itemKey.trim()) return;
|
||||
const updated = await updateAlert(id, {
|
||||
item_key: normalizeItemKey(editForm),
|
||||
item_key: normalizeItemKey(editForm),
|
||||
item_key_is_regex: editForm.itemKeyIsRegex,
|
||||
combinator: editForm.combinator.trim() || null,
|
||||
signal_type: editForm.signalType,
|
||||
condition: editForm.condition,
|
||||
threshold: parseInt(editForm.threshold, 10),
|
||||
combinator: editForm.combinator.trim() || null,
|
||||
signal_type: editForm.signalType,
|
||||
condition: editForm.condition,
|
||||
threshold: parseInt(editForm.threshold, 10),
|
||||
});
|
||||
setAlerts(a => a.map(x => x.id === id ? updated : x));
|
||||
setAlerts((a) => a.map((x) => (x.id === id ? updated : x)));
|
||||
setEditingId(null);
|
||||
await refreshAlerts();
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
await deleteAlert(id);
|
||||
setAlerts(a => a.filter(x => x.id !== id));
|
||||
setAlerts((a) => a.filter((x) => x.id !== id));
|
||||
await refreshAlerts();
|
||||
}
|
||||
|
||||
@@ -178,21 +219,29 @@ export default function AlertPanel({ open, onClose }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`fixed top-0 right-0 h-full w-80 bg-gray-900 border-l border-gray-700 shadow-xl z-40 transform transition-transform duration-200 ${open ? 'translate-x-0' : 'translate-x-full'}`}>
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-80 bg-gray-900 border-l border-gray-700 shadow-xl z-40 transform transition-transform duration-200 ${open ? 'translate-x-0' : 'translate-x-full'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
|
||||
<span className="font-semibold text-white">Alerts</span>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white">✕</button>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{triggeredAlerts.length > 0 && (
|
||||
<div className="px-4 py-2 bg-red-900/40 border-b border-red-800">
|
||||
<p className="text-red-300 text-xs font-semibold mb-1">🔴 TRIGGERED ({triggeredAlerts.length})</p>
|
||||
<p className="text-red-300 text-xs font-semibold mb-1">
|
||||
🔴 TRIGGERED ({triggeredAlerts.length})
|
||||
</p>
|
||||
{triggeredAlerts.map((a, i) => (
|
||||
<div key={i} className="text-xs text-red-200 flex items-center gap-1 flex-wrap">
|
||||
<span>{resolveName(a.matched_item_key, localeMap)}</span>
|
||||
<span className="text-red-400">({a.combinator_match})</span>
|
||||
<span>[{a.signal_type}]</span>
|
||||
<span>= {a.current_value} {a.condition} {a.threshold}</span>
|
||||
<span>
|
||||
= {a.current_value} {a.condition} {a.threshold}
|
||||
</span>
|
||||
{a.item_key_is_regex && a.matched_item_key !== a.item_key && (
|
||||
<Tooltip text={`Matched by regex: /${a.item_key}/`} />
|
||||
)}
|
||||
@@ -203,12 +252,17 @@ export default function AlertPanel({ open, onClose }: Props) {
|
||||
|
||||
<div className="p-4 border-b border-gray-700 space-y-2">
|
||||
<p className="text-xs text-gray-400 font-semibold uppercase">New Alert</p>
|
||||
<AlertForm value={newForm} onChange={setNewForm} onSubmit={handleCreate} submitLabel="Add Alert" />
|
||||
<AlertForm
|
||||
value={newForm}
|
||||
onChange={setNewForm}
|
||||
onSubmit={handleCreate}
|
||||
submitLabel="Add Alert"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 p-4 space-y-2">
|
||||
{alerts.length === 0 && <p className="text-gray-500 text-sm">No alerts configured.</p>}
|
||||
{alerts.map(a => (
|
||||
{alerts.map((a) => (
|
||||
<div key={a.id} className="bg-gray-800 rounded p-2 text-xs text-gray-300">
|
||||
{editingId === a.id ? (
|
||||
<AlertForm
|
||||
@@ -221,18 +275,30 @@ export default function AlertPanel({ open, onClose }: Props) {
|
||||
) : (
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<span className="font-medium text-white">{resolveName(a.item_key, localeMap)}</span>
|
||||
{a.item_key_is_regex && (
|
||||
<Tooltip text={`Regex pattern: /${a.item_key}/`} />
|
||||
)}
|
||||
<span className="font-medium text-white">
|
||||
{resolveName(a.item_key, localeMap)}
|
||||
</span>
|
||||
{a.item_key_is_regex && <Tooltip text={`Regex pattern: /${a.item_key}/`} />}
|
||||
{a.combinator && <span className="text-gray-400"> @ {a.combinator}</span>}
|
||||
<br />
|
||||
<span className={a.signal_type === 'green' ? 'text-green-400' : 'text-red-400'}>[{a.signal_type}]</span>
|
||||
{' '}{a.condition} {a.threshold}
|
||||
<span className={a.signal_type === 'green' ? 'text-green-400' : 'text-red-400'}>
|
||||
[{a.signal_type}]
|
||||
</span>{' '}
|
||||
{a.condition} {a.threshold}
|
||||
</div>
|
||||
<div className="flex gap-1 ml-2 shrink-0">
|
||||
<button onClick={() => startEdit(a)} className="text-gray-500 hover:text-indigo-400">✏️</button>
|
||||
<button onClick={() => handleDelete(a.id)} className="text-gray-500 hover:text-red-400">✕</button>
|
||||
<button
|
||||
onClick={() => startEdit(a)}
|
||||
className="text-gray-500 hover:text-indigo-400"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(a.id)}
|
||||
className="text-gray-500 hover:text-red-400"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -241,4 +307,4 @@ export default function AlertPanel({ open, onClose }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
onEdit: () => void;
|
||||
title: string;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,18 @@ export function Header({ title, onEdit, onDelete }: HeaderProps) {
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-gray-700 shrink-0">
|
||||
<span className="text-sm font-medium text-gray-200 truncate">{title}</span>
|
||||
<div className="flex gap-1 ml-2 shrink-0">
|
||||
<button onClick={onEdit} className="text-xs text-gray-400 hover:text-white px-1.5 py-0.5 rounded hover:bg-gray-700">✏️</button>
|
||||
<button onClick={onDelete} className="text-xs text-gray-400 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-gray-700">🗑</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="text-xs text-gray-400 hover:text-white px-1.5 py-0.5 rounded hover:bg-gray-700"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="text-xs text-gray-400 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-gray-700"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -25,64 +35,81 @@ export function EmptyState() {
|
||||
}
|
||||
|
||||
interface CardShellProps extends HeaderProps {
|
||||
empty: boolean;
|
||||
children: React.ReactNode;
|
||||
empty: boolean;
|
||||
children: React.ReactNode;
|
||||
/** Ref to the div where the uPlot legend will be mounted */
|
||||
legendContainerRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function CardShell({ title, onEdit, onDelete, empty, children, legendContainerRef }: CardShellProps) {
|
||||
export function CardShell({
|
||||
title,
|
||||
onEdit,
|
||||
onDelete,
|
||||
empty,
|
||||
children,
|
||||
legendContainerRef,
|
||||
}: CardShellProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [legendHeight, setLegendHeight] = useState<number | null>(null);
|
||||
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const el = legendContainerRef?.current;
|
||||
if (!el) return;
|
||||
dragRef.current = { startY: e.clientY, startH: el.offsetHeight };
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const el = legendContainerRef?.current;
|
||||
if (!el) return;
|
||||
dragRef.current = { startY: e.clientY, startH: el.offsetHeight };
|
||||
|
||||
function onMove(ev: MouseEvent) {
|
||||
if (!dragRef.current) return;
|
||||
const delta = dragRef.current.startY - ev.clientY;
|
||||
const containerH = containerRef.current?.offsetHeight ?? 400;
|
||||
const newH = Math.max(32, Math.min(containerH - 64, dragRef.current.startH + delta));
|
||||
setLegendHeight(newH);
|
||||
}
|
||||
function onUp() {
|
||||
dragRef.current = null;
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}, [legendContainerRef]);
|
||||
function onMove(ev: MouseEvent) {
|
||||
if (!dragRef.current) return;
|
||||
const delta = dragRef.current.startY - ev.clientY;
|
||||
const containerH = containerRef.current?.offsetHeight ?? 400;
|
||||
const newH = Math.max(32, Math.min(containerH - 64, dragRef.current.startH + delta));
|
||||
setLegendHeight(newH);
|
||||
}
|
||||
function onUp() {
|
||||
dragRef.current = null;
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
},
|
||||
[legendContainerRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="h-full flex flex-col bg-gray-900 rounded border border-gray-700 overflow-hidden">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full flex flex-col bg-gray-900 rounded border border-gray-700 overflow-hidden"
|
||||
>
|
||||
<Header title={title} onEdit={onEdit} onDelete={onDelete} />
|
||||
{empty ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
{legendContainerRef && <>
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="shrink-0 h-1.5 cursor-row-resize bg-gray-800 hover:bg-gray-700 active:bg-gray-600"
|
||||
/>
|
||||
<div
|
||||
ref={legendContainerRef}
|
||||
className="shrink-0 overflow-y-auto px-2 py-1 text-[10px] text-gray-400 [&_.u-legend]:w-full [&_.u-series]:flex [&_.u-series]:items-center [&_.u-series_th]:flex [&_.u-series_th]:items-center [&_.u-series_th]:gap-1 [&_.u-marker]:w-3 [&_.u-marker]:h-0.5 [&_.u-marker]:shrink-0 [&_.u-label]:truncate [&_.u-value]:ml-auto [&_.u-value]:font-mono [&_.u-value]:shrink-0"
|
||||
style={legendHeight != null ? { height: legendHeight, maxHeight: legendHeight } : { maxHeight: '25%' }}
|
||||
/>
|
||||
<style>{'.u-legend .u-series:first-child { display: none; }'}</style>
|
||||
</>}
|
||||
<div className="flex-1 min-h-0">{children}</div>
|
||||
{legendContainerRef && (
|
||||
<>
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="shrink-0 h-1.5 cursor-row-resize bg-gray-800 hover:bg-gray-700 active:bg-gray-600"
|
||||
/>
|
||||
<div
|
||||
ref={legendContainerRef}
|
||||
className="shrink-0 overflow-y-auto px-2 py-1 text-[10px] text-gray-400 [&_.u-legend]:w-full [&_.u-series]:flex [&_.u-series]:items-center [&_.u-series_th]:flex [&_.u-series_th]:items-center [&_.u-series_th]:gap-1 [&_.u-marker]:w-3 [&_.u-marker]:h-0.5 [&_.u-marker]:shrink-0 [&_.u-label]:truncate [&_.u-value]:ml-auto [&_.u-value]:font-mono [&_.u-value]:shrink-0"
|
||||
style={
|
||||
legendHeight != null
|
||||
? { height: legendHeight, maxHeight: legendHeight }
|
||||
: { maxHeight: '25%' }
|
||||
}
|
||||
/>
|
||||
<style>{'.u-legend .u-series:first-child { display: none; }'}</style>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
interface Props {
|
||||
title: string;
|
||||
onEdit: () => void;
|
||||
title: string;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function DividerCard({ title, onEdit, onDelete }: Props) {
|
||||
return (
|
||||
<div className="h-full flex items-center bg-gray-900/30 rounded border border-gray-600/40 px-4 gap-4 overflow-hidden">
|
||||
<span className="text-sm font-bold text-gray-200 uppercase tracking-widest shrink-0">{title}</span>
|
||||
<span className="text-sm font-bold text-gray-200 uppercase tracking-widest shrink-0">
|
||||
{title}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-gray-600" />
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<button onClick={onEdit} className="text-xs text-gray-500 hover:text-white px-1.5 py-0.5 rounded hover:bg-gray-700">✏️</button>
|
||||
<button onClick={onDelete} className="text-xs text-gray-500 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-gray-700">🗑</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="text-xs text-gray-500 hover:text-white px-1.5 py-0.5 rounded hover:bg-gray-700"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="text-xs text-gray-500 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-gray-700"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,27 +8,43 @@ import { resolveName } from '@/lib/localization';
|
||||
import { getColorMap } from '@/lib/colors';
|
||||
import type { ColorMap } from '@/lib/colors';
|
||||
import { CardShell } from './CardShell';
|
||||
import { makeYScale, makeAnnotationHooks, makeSignalsSeries, makeSignalsAxes, CURSOR_NO_DRAG } from './plotHelpers';
|
||||
import {
|
||||
makeYScale,
|
||||
makeAnnotationHooks,
|
||||
makeSignalsSeries,
|
||||
makeSignalsAxes,
|
||||
CURSOR_NO_DRAG,
|
||||
} from './plotHelpers';
|
||||
import { buildSeriesData } from './seriesData';
|
||||
import { usePlot } from './usePlot';
|
||||
import type { AlertConfig, ChartConfig, SessionBoundary, SignalRow } from '@/lib/types';
|
||||
import type { TimeMode } from '@/lib/types';
|
||||
|
||||
interface Props {
|
||||
config: ChartConfig;
|
||||
rows: SignalRow[];
|
||||
config: ChartConfig;
|
||||
rows: SignalRow[];
|
||||
sessions: SessionBoundary[];
|
||||
alerts: AlertConfig[];
|
||||
alerts: AlertConfig[];
|
||||
timeMode: TimeMode;
|
||||
onEdit: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function SignalsChart({ config, rows, sessions, alerts, timeMode, onEdit, onDelete }: Props) {
|
||||
export default function SignalsChart({
|
||||
config,
|
||||
rows,
|
||||
sessions,
|
||||
alerts,
|
||||
timeMode,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const { localeMap } = useApp();
|
||||
const locale = typeof navigator !== 'undefined' ? navigator.language : 'de-DE';
|
||||
const [colorMap, setColorMap] = useState<ColorMap>(new Map());
|
||||
useEffect(() => { getColorMap().then(setColorMap); }, []);
|
||||
useEffect(() => {
|
||||
getColorMap().then(setColorMap);
|
||||
}, []);
|
||||
|
||||
const { containerRef, legendRef } = usePlot(
|
||||
(el, w, h, lRef) => {
|
||||
@@ -36,35 +52,47 @@ export default function SignalsChart({ config, rows, sessions, alerts, timeMode,
|
||||
if (!data) return null;
|
||||
|
||||
const { keys, allXs, data: seriesData } = data;
|
||||
const sessionXs = sessions.map(s => timeMode === 'tick' ? s.game_tick : new Date(s.real_time).getTime() / 1000);
|
||||
const sessionXs = sessions.map((s) =>
|
||||
timeMode === 'tick' ? s.game_tick : new Date(s.real_time).getTime() / 1000,
|
||||
);
|
||||
const alertThresholds = alerts
|
||||
.filter(a => config.signal_type === 'both' || config.signal_type === a.signal_type)
|
||||
.map(a => a.threshold);
|
||||
.filter((a) => config.signal_type === 'both' || config.signal_type === a.signal_type)
|
||||
.map((a) => a.threshold);
|
||||
|
||||
return new uPlot({
|
||||
width: w,
|
||||
height: h,
|
||||
cursor: CURSOR_NO_DRAG,
|
||||
legend: {
|
||||
mount: (_u, legendEl) => {
|
||||
if (lRef.current) lRef.current.appendChild(legendEl);
|
||||
return new uPlot(
|
||||
{
|
||||
width: w,
|
||||
height: h,
|
||||
cursor: CURSOR_NO_DRAG,
|
||||
legend: {
|
||||
mount: (_u, legendEl) => {
|
||||
if (lRef.current) lRef.current.appendChild(legendEl);
|
||||
},
|
||||
},
|
||||
series: makeSignalsSeries(keys, timeMode, (key) => resolveName(key, localeMap), colorMap),
|
||||
axes: makeSignalsAxes(timeMode, locale),
|
||||
scales: {
|
||||
x: { time: false },
|
||||
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
|
||||
},
|
||||
hooks: makeAnnotationHooks(sessionXs, alertThresholds),
|
||||
},
|
||||
series: makeSignalsSeries(keys, timeMode, key => resolveName(key, localeMap), colorMap),
|
||||
axes: makeSignalsAxes(timeMode, locale),
|
||||
scales: {
|
||||
x: { time: false },
|
||||
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
|
||||
},
|
||||
hooks: makeAnnotationHooks(sessionXs, alertThresholds),
|
||||
}, [allXs, ...seriesData], el);
|
||||
[allXs, ...seriesData],
|
||||
el,
|
||||
);
|
||||
},
|
||||
[rows, sessions, alerts, config, timeMode, localeMap],
|
||||
);
|
||||
|
||||
return (
|
||||
<CardShell title={config.title} onEdit={onEdit} onDelete={onDelete} empty={rows.length === 0} legendContainerRef={legendRef}>
|
||||
<CardShell
|
||||
title={config.title}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
empty={rows.length === 0}
|
||||
legendContainerRef={legendRef}
|
||||
>
|
||||
<div ref={containerRef as React.RefObject<HTMLDivElement>} className="w-full h-full" />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,16 +8,18 @@ import { CardShell } from './CardShell';
|
||||
import type { ChartConfig, SignalRow } from '@/lib/types';
|
||||
|
||||
interface Props {
|
||||
config: ChartConfig;
|
||||
rows: SignalRow[];
|
||||
onEdit: () => void;
|
||||
config: ChartConfig;
|
||||
rows: SignalRow[];
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
|
||||
const { localeMap } = useApp();
|
||||
const [colorMap, setColorMap] = useState<ColorMap>(new Map());
|
||||
useEffect(() => { getColorMap().then(setColorMap); }, []);
|
||||
useEffect(() => {
|
||||
getColorMap().then(setColorMap);
|
||||
}, []);
|
||||
|
||||
const latest = new Map<string, { green?: number; red?: number }>();
|
||||
for (const row of rows) {
|
||||
@@ -33,8 +35,12 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
|
||||
<tr>
|
||||
<th className="text-left px-2 py-1">Item</th>
|
||||
<th className="text-left px-2 py-1">Combinator</th>
|
||||
{config.signal_type !== 'red' && <th className="text-right px-2 py-1 text-green-400">Green</th>}
|
||||
{config.signal_type !== 'green' && <th className="text-right px-2 py-1 text-red-400">Red (NP)</th>}
|
||||
{config.signal_type !== 'red' && (
|
||||
<th className="text-right px-2 py-1 text-green-400">Green</th>
|
||||
)}
|
||||
{config.signal_type !== 'green' && (
|
||||
<th className="text-right px-2 py-1 text-red-400">Red (NP)</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -43,13 +49,17 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
|
||||
return (
|
||||
<tr key={key} className="border-t border-gray-800 hover:bg-gray-800/50">
|
||||
<td className="px-2 py-0.5 flex items-center gap-1.5">
|
||||
<span className="inline-block w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: getItemColor(item_key, colorMap) }} />
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: getItemColor(item_key, colorMap) }}
|
||||
/>
|
||||
{resolveName(item_key, localeMap)}
|
||||
</td>
|
||||
<td className="px-2 py-0.5 text-gray-500">{combinator}</td>
|
||||
{config.signal_type !== 'red' && (
|
||||
<td className={`px-2 py-0.5 text-right font-mono ${(vals.green ?? 0) < 0 ? 'text-red-400' : 'text-green-400'}`}>
|
||||
<td
|
||||
className={`px-2 py-0.5 text-right font-mono ${(vals.green ?? 0) < 0 ? 'text-red-400' : 'text-green-400'}`}
|
||||
>
|
||||
{vals.green != null ? formatSI(vals.green, undefined, 0) : '--'}
|
||||
</td>
|
||||
)}
|
||||
@@ -66,4 +76,4 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
|
||||
</div>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ import type { ChartConfig, UpsRow } from '@/lib/types';
|
||||
import type { TimeMode } from '@/lib/types';
|
||||
|
||||
interface Props {
|
||||
config: ChartConfig;
|
||||
upsRows: UpsRow[];
|
||||
config: ChartConfig;
|
||||
upsRows: UpsRow[];
|
||||
timeMode: TimeMode;
|
||||
onEdit: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
@@ -27,42 +27,59 @@ export default function UpsChart({ config, upsRows, timeMode, onEdit, onDelete }
|
||||
? a.game_tick - b.game_tick
|
||||
: new Date(a.real_time).getTime() - new Date(b.real_time).getTime(),
|
||||
);
|
||||
const xs = sorted.map(r => timeMode === 'tick' ? r.game_tick : new Date(r.real_time).getTime() / 1000);
|
||||
const ys = sorted.map(r => r.ups);
|
||||
const xs = sorted.map((r) =>
|
||||
timeMode === 'tick' ? r.game_tick : new Date(r.real_time).getTime() / 1000,
|
||||
);
|
||||
const ys = sorted.map((r) => r.ups);
|
||||
|
||||
const xAxis: uPlot.Axis = {
|
||||
...AXIS_BASE,
|
||||
values: timeMode === 'real'
|
||||
? (_u, vals) => vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString())
|
||||
: (_u, vals) => vals.map(v => v == null ? '' : formatSI(v)),
|
||||
values:
|
||||
timeMode === 'real'
|
||||
? (_u, vals) =>
|
||||
vals.map((v) => (v == null ? '' : new Date(v * 1000).toLocaleTimeString()))
|
||||
: (_u, vals) => vals.map((v) => (v == null ? '' : formatSI(v))),
|
||||
};
|
||||
|
||||
return new uPlot({
|
||||
width: w,
|
||||
height: h,
|
||||
cursor: CURSOR_NO_DRAG,
|
||||
legend: {
|
||||
mount: (_u, legendEl) => {
|
||||
if (lRef.current) lRef.current.appendChild(legendEl);
|
||||
return new uPlot(
|
||||
{
|
||||
width: w,
|
||||
height: h,
|
||||
cursor: CURSOR_NO_DRAG,
|
||||
legend: {
|
||||
mount: (_u, legendEl) => {
|
||||
if (lRef.current) lRef.current.appendChild(legendEl);
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{ label: timeMode === 'tick' ? 'Tick' : 'Time' },
|
||||
{ label: 'UPS', stroke: '#4ade80', width: 1.5 },
|
||||
],
|
||||
axes: [
|
||||
xAxis,
|
||||
{ ...AXIS_BASE, values: (_u, vals) => vals.map((v) => (v == null ? '' : formatSI(v))) },
|
||||
],
|
||||
scales: {
|
||||
x: { time: false },
|
||||
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{ label: timeMode === 'tick' ? 'Tick' : 'Time' },
|
||||
{ label: 'UPS', stroke: '#4ade80', width: 1.5 },
|
||||
],
|
||||
axes: [xAxis, { ...AXIS_BASE, values: (_u, vals) => vals.map(v => v == null ? '' : formatSI(v)) }],
|
||||
scales: {
|
||||
x: { time: false },
|
||||
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
|
||||
},
|
||||
}, [xs, ys], el);
|
||||
[xs, ys],
|
||||
el,
|
||||
);
|
||||
},
|
||||
[upsRows, config.y_min, config.y_max, config.y_scale, timeMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<CardShell title={config.title} onEdit={onEdit} onDelete={onDelete} empty={upsRows.length === 0} legendContainerRef={legendRef}>
|
||||
<CardShell
|
||||
title={config.title}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
empty={upsRows.length === 0}
|
||||
legendContainerRef={legendRef}
|
||||
>
|
||||
<div ref={containerRef as React.RefObject<HTMLDivElement>} className="w-full h-full" />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,20 +6,29 @@ import TableViz from './TableViz';
|
||||
import DividerCard from './DividerCard';
|
||||
|
||||
export interface ChartCardProps {
|
||||
config: ChartConfig;
|
||||
rows: SignalRow[];
|
||||
upsRows: UpsRow[];
|
||||
config: ChartConfig;
|
||||
rows: SignalRow[];
|
||||
upsRows: UpsRow[];
|
||||
sessions: SessionBoundary[];
|
||||
alerts: AlertConfig[];
|
||||
alerts: AlertConfig[];
|
||||
timeMode: TimeMode;
|
||||
onEdit: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function ChartCard(props: ChartCardProps) {
|
||||
const { config } = props;
|
||||
if (config.chart_type === 'divider') return <DividerCard title={config.title} onEdit={props.onEdit} onDelete={props.onDelete} />;
|
||||
if (config.chart_type === 'ups') return <UpsChart {...props} />;
|
||||
if (config.viz_type === 'table') return <TableViz config={props.config} rows={props.rows} onEdit={props.onEdit} onDelete={props.onDelete} />;
|
||||
return <SignalsChart {...props} />;
|
||||
}
|
||||
if (config.chart_type === 'divider')
|
||||
return <DividerCard title={config.title} onEdit={props.onEdit} onDelete={props.onDelete} />;
|
||||
if (config.chart_type === 'ups') return <UpsChart {...props} />;
|
||||
if (config.viz_type === 'table')
|
||||
return (
|
||||
<TableViz
|
||||
config={props.config}
|
||||
rows={props.rows}
|
||||
onEdit={props.onEdit}
|
||||
onDelete={props.onDelete}
|
||||
/>
|
||||
);
|
||||
return <SignalsChart {...props} />;
|
||||
}
|
||||
|
||||
@@ -5,18 +5,18 @@ import type { ColorMap } from '@/lib/colors';
|
||||
import { getItemColor } from '@/lib/colors';
|
||||
|
||||
const SEMANTIC_GREEN = '#4ade80';
|
||||
const SEMANTIC_RED = '#f87171';
|
||||
const SEMANTIC_RED = '#f87171';
|
||||
|
||||
export interface SeriesStyle {
|
||||
color: string;
|
||||
dash: number[] | undefined;
|
||||
dash: number[] | undefined;
|
||||
}
|
||||
|
||||
export function getSeriesStyle(
|
||||
key: string,
|
||||
uCombs: number,
|
||||
uItems: number,
|
||||
uSigs: number,
|
||||
key: string,
|
||||
uCombs: number,
|
||||
uItems: number,
|
||||
uSigs: number,
|
||||
colorMap: ColorMap = new Map(),
|
||||
): SeriesStyle {
|
||||
const [combinator, item_key, sig] = key.split('::');
|
||||
@@ -25,9 +25,15 @@ export function getSeriesStyle(
|
||||
return { color: sig === 'green' ? SEMANTIC_GREEN : SEMANTIC_RED, dash: undefined };
|
||||
}
|
||||
if (uItems > 1) {
|
||||
return { color: getItemColor(item_key, colorMap), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined };
|
||||
return {
|
||||
color: getItemColor(item_key, colorMap),
|
||||
dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined,
|
||||
};
|
||||
}
|
||||
return { color: getItemColor(combinator, colorMap), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined };
|
||||
return {
|
||||
color: getItemColor(combinator, colorMap),
|
||||
dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,10 +41,10 @@ export function getSeriesStyle(
|
||||
* @param displayName Pre-resolved localized name for the item.
|
||||
*/
|
||||
export function getSeriesLabel(
|
||||
key: string,
|
||||
uCombs: number,
|
||||
uItems: number,
|
||||
uSigs: number,
|
||||
key: string,
|
||||
uCombs: number,
|
||||
uItems: number,
|
||||
uSigs: number,
|
||||
displayName: string,
|
||||
): string {
|
||||
const [combinator, , sig] = key.split('::');
|
||||
@@ -46,7 +52,7 @@ export function getSeriesLabel(
|
||||
const parts: string[] = [];
|
||||
if (uItems > 1) parts.push(displayName);
|
||||
if (uCombs > 1) parts.push(`(${combinator})`);
|
||||
if (uSigs > 1) parts.push(`[${sig}]`);
|
||||
if (uSigs > 1) parts.push(`[${sig}]`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
@@ -54,8 +60,8 @@ export function getSeriesLabel(
|
||||
|
||||
export const AXIS_BASE: uPlot.Axis = {
|
||||
stroke: '#9ca3af',
|
||||
ticks: { stroke: '#374151' },
|
||||
grid: { stroke: '#1f2937' },
|
||||
ticks: { stroke: '#374151' },
|
||||
grid: { stroke: '#1f2937' },
|
||||
};
|
||||
|
||||
export const CURSOR_NO_DRAG: uPlot.Cursor = {
|
||||
@@ -67,17 +73,19 @@ export const CURSOR_NO_DRAG: uPlot.Cursor = {
|
||||
* 'log' uses arcsinh distribution (distr:4) — handles negatives, zero, positives.
|
||||
*/
|
||||
export function makeYScale(
|
||||
yMin: number | null,
|
||||
yMax: number | null,
|
||||
yMin: number | null,
|
||||
yMax: number | null,
|
||||
yScale: ChartConfig['y_scale'] = 'linear',
|
||||
): uPlot.Scale {
|
||||
if (yScale === 'log') {
|
||||
return {
|
||||
distr: 4,
|
||||
asinh: 1,
|
||||
...(yMin !== null || yMax !== null ? {
|
||||
range: (_u, dataMin, dataMax) => [yMin ?? dataMin, yMax ?? dataMax],
|
||||
} : {}),
|
||||
...(yMin !== null || yMax !== null
|
||||
? {
|
||||
range: (_u, dataMin, dataMax) => [yMin ?? dataMin, yMax ?? dataMax],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,53 +93,61 @@ export function makeYScale(
|
||||
return {
|
||||
dir: 1,
|
||||
range: (_u, dataMin, dataMax) => {
|
||||
const lo = yMin ?? (dataMin ?? 0);
|
||||
const hi = yMax ?? (dataMax ?? 1);
|
||||
const lo = yMin ?? dataMin ?? 0;
|
||||
const hi = yMax ?? dataMax ?? 1;
|
||||
if (lo === hi) return [lo - 1, hi + 1];
|
||||
const pad = (yMin == null || yMax == null) ? Math.abs(hi - lo) * 0.05 : 0;
|
||||
const pad = yMin == null || yMax == null ? Math.abs(hi - lo) * 0.05 : 0;
|
||||
return [lo - pad, hi + pad];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function makeAnnotationHooks(
|
||||
sessionXs: number[],
|
||||
sessionXs: number[],
|
||||
alertThresholds: number[],
|
||||
): uPlot.Options['hooks'] {
|
||||
return {
|
||||
draw: [(u) => {
|
||||
const { ctx, bbox } = u;
|
||||
ctx.save();
|
||||
draw: [
|
||||
(u) => {
|
||||
const { ctx, bbox } = u;
|
||||
ctx.save();
|
||||
|
||||
ctx.strokeStyle = 'rgba(251,191,36,0.6)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
for (const sx of sessionXs) {
|
||||
const cx = Math.round(u.valToPos(sx, 'x', true));
|
||||
ctx.beginPath(); ctx.moveTo(cx, bbox.top); ctx.lineTo(cx, bbox.top + bbox.height); ctx.stroke();
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(251,191,36,0.6)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
for (const sx of sessionXs) {
|
||||
const cx = Math.round(u.valToPos(sx, 'x', true));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, bbox.top);
|
||||
ctx.lineTo(cx, bbox.top + bbox.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.strokeStyle = 'rgba(248,113,113,0.7)';
|
||||
ctx.setLineDash([6, 3]);
|
||||
for (const t of alertThresholds) {
|
||||
const cy = Math.round(u.valToPos(t, 'y', true));
|
||||
ctx.beginPath(); ctx.moveTo(bbox.left, cy); ctx.lineTo(bbox.left + bbox.width, cy); ctx.stroke();
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(248,113,113,0.7)';
|
||||
ctx.setLineDash([6, 3]);
|
||||
for (const t of alertThresholds) {
|
||||
const cy = Math.round(u.valToPos(t, 'y', true));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bbox.left, cy);
|
||||
ctx.lineTo(bbox.left + bbox.width, cy);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}],
|
||||
ctx.restore();
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function makeSignalsSeries(
|
||||
keys: string[],
|
||||
timeMode: 'real' | 'tick',
|
||||
keys: string[],
|
||||
timeMode: 'real' | 'tick',
|
||||
resolveName: (key: string) => string,
|
||||
colorMap: ColorMap = new Map(),
|
||||
colorMap: ColorMap = new Map(),
|
||||
): uPlot.Series[] {
|
||||
const uCombs = new Set(keys.map(k => k.split('::')[0])).size;
|
||||
const uItems = new Set(keys.map(k => k.split('::')[1])).size;
|
||||
const uSigs = new Set(keys.map(k => k.split('::')[2])).size;
|
||||
const uCombs = new Set(keys.map((k) => k.split('::')[0])).size;
|
||||
const uItems = new Set(keys.map((k) => k.split('::')[1])).size;
|
||||
const uSigs = new Set(keys.map((k) => k.split('::')[2])).size;
|
||||
|
||||
const xSeries: uPlot.Series = {
|
||||
label: timeMode === 'tick' ? 'Tick' : 'Time',
|
||||
@@ -143,13 +159,13 @@ export function makeSignalsSeries(
|
||||
|
||||
return [
|
||||
xSeries,
|
||||
...keys.map(k => {
|
||||
...keys.map((k) => {
|
||||
const [, item_key] = k.split('::');
|
||||
const { color, dash } = getSeriesStyle(k, uCombs, uItems, uSigs, colorMap);
|
||||
return {
|
||||
label: getSeriesLabel(k, uCombs, uItems, uSigs, resolveName(item_key)),
|
||||
label: getSeriesLabel(k, uCombs, uItems, uSigs, resolveName(item_key)),
|
||||
stroke: color,
|
||||
width: 1.5,
|
||||
width: 1.5,
|
||||
dash,
|
||||
};
|
||||
}),
|
||||
@@ -160,16 +176,17 @@ export function makeSignalsAxes(timeMode: 'real' | 'tick', locale?: string): uPl
|
||||
return [
|
||||
{
|
||||
...AXIS_BASE,
|
||||
values: timeMode === 'real'
|
||||
? (_u: uPlot, vals: (number | null)[]) =>
|
||||
vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString())
|
||||
: (_u: uPlot, vals: (number | null)[]) =>
|
||||
vals.map(v => v == null ? '' : formatSI(v, locale)),
|
||||
values:
|
||||
timeMode === 'real'
|
||||
? (_u: uPlot, vals: (number | null)[]) =>
|
||||
vals.map((v) => (v == null ? '' : new Date(v * 1000).toLocaleTimeString()))
|
||||
: (_u: uPlot, vals: (number | null)[]) =>
|
||||
vals.map((v) => (v == null ? '' : formatSI(v, locale))),
|
||||
},
|
||||
{
|
||||
...AXIS_BASE,
|
||||
values: (_u: uPlot, vals: (number | null)[]) =>
|
||||
vals.map(v => v == null ? '' : formatSI(v, locale)),
|
||||
vals.map((v) => (v == null ? '' : formatSI(v, locale))),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,26 +4,30 @@ import type { TimeMode } from '@/lib/types';
|
||||
const MAX_SERIES = 80;
|
||||
|
||||
export interface SeriesData {
|
||||
keys: string[];
|
||||
keys: string[];
|
||||
allXs: number[];
|
||||
data: (number | undefined)[][];
|
||||
data: (number | undefined)[][];
|
||||
}
|
||||
|
||||
export function buildSeriesData(
|
||||
rows: SignalRow[],
|
||||
rows: SignalRow[],
|
||||
signalType: ChartConfig['signal_type'],
|
||||
timeMode: TimeMode,
|
||||
timeMode: TimeMode,
|
||||
): SeriesData | null {
|
||||
const seriesMap = new Map<string, Map<number, number>>();
|
||||
|
||||
for (const row of rows) {
|
||||
for (const [sig, val] of [['green', row.green], ['red', row.red]] as ['green' | 'red', number | undefined][]) {
|
||||
for (const [sig, val] of [
|
||||
['green', row.green],
|
||||
['red', row.red],
|
||||
] as ['green' | 'red', number | undefined][]) {
|
||||
if (signalType !== 'both' && signalType !== sig) continue;
|
||||
if (val === undefined) continue;
|
||||
const key = `${row.combinator}::${row.item_key}::${sig}`;
|
||||
const x = timeMode === 'tick'
|
||||
? parseInt(row.game_tick, 10)
|
||||
: new Date(row.real_time).getTime() / 1000;
|
||||
const x =
|
||||
timeMode === 'tick'
|
||||
? parseInt(row.game_tick, 10)
|
||||
: new Date(row.real_time).getTime() / 1000;
|
||||
if (!seriesMap.has(key)) seriesMap.set(key, new Map());
|
||||
seriesMap.get(key)!.set(x, val);
|
||||
}
|
||||
@@ -31,13 +35,15 @@ export function buildSeriesData(
|
||||
|
||||
if (seriesMap.size === 0) return null;
|
||||
|
||||
const keys = [...seriesMap.keys()].slice(0, MAX_SERIES);
|
||||
const allXs = [...new Set(keys.flatMap(k => [...seriesMap.get(k)!.keys()]))].sort((a, b) => a - b);
|
||||
const keys = [...seriesMap.keys()].slice(0, MAX_SERIES);
|
||||
const allXs = [...new Set(keys.flatMap((k) => [...seriesMap.get(k)!.keys()]))].sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
|
||||
const data = keys.map(k => {
|
||||
const data = keys.map((k) => {
|
||||
const m = seriesMap.get(k)!;
|
||||
return allXs.map(x => m.get(x)); // undefined = gap
|
||||
return allXs.map((x) => m.get(x)); // undefined = gap
|
||||
});
|
||||
|
||||
return { keys, allXs, data };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useCallback, useEffect, useRef } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
export type BuildFn = (
|
||||
el: HTMLDivElement,
|
||||
w: number,
|
||||
h: number,
|
||||
el: HTMLDivElement,
|
||||
w: number,
|
||||
h: number,
|
||||
legendRef: React.RefObject<HTMLDivElement>,
|
||||
) => uPlot | null;
|
||||
|
||||
@@ -25,17 +25,21 @@ function idxToPixel(plot: uPlot, idx: number): number {
|
||||
export function usePlot(
|
||||
build: BuildFn,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
deps: any[],
|
||||
): { containerRef: React.RefObject<HTMLDivElement | null>; legendRef: React.RefObject<HTMLDivElement> } {
|
||||
deps: any[],
|
||||
): {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
legendRef: React.RefObject<HTMLDivElement>;
|
||||
} {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const legendRef = useRef<HTMLDivElement>(null!);
|
||||
const plotRef = useRef<uPlot | null>(null);
|
||||
const lastIdxRef = useRef<number>(0);
|
||||
const legendRef = useRef<HTMLDivElement>(null!);
|
||||
const plotRef = useRef<uPlot | null>(null);
|
||||
const lastIdxRef = useRef<number>(0);
|
||||
|
||||
const rebuild = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const w = el.clientWidth, h = el.clientHeight;
|
||||
const w = el.clientWidth,
|
||||
h = el.clientHeight;
|
||||
if (w < 10 || h < 10) return;
|
||||
|
||||
plotRef.current?.destroy();
|
||||
@@ -60,12 +64,15 @@ export function usePlot(
|
||||
});
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
useEffect(() => {
|
||||
rebuild();
|
||||
return () => { plotRef.current?.destroy(); plotRef.current = null; };
|
||||
return () => {
|
||||
plotRef.current?.destroy();
|
||||
plotRef.current = null;
|
||||
};
|
||||
}, [rebuild]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,4 +84,4 @@ export function usePlot(
|
||||
}, [rebuild]);
|
||||
|
||||
return { containerRef, legendRef };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ type DraftChart = Omit<ChartConfig, 'id'>;
|
||||
|
||||
interface Props {
|
||||
initial?: ChartConfig;
|
||||
onSave: (draft: DraftChart) => void;
|
||||
onSave: (draft: DraftChart) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -21,32 +21,42 @@ const inputCls =
|
||||
* to raw item_keys. Unknown tokens are kept as-is.
|
||||
*/
|
||||
function normalizeList(raw: string, reverseMap: Map<string, string>): string[] | null {
|
||||
const arr = raw.split(',').map(x => x.trim()).filter(Boolean);
|
||||
const arr = raw
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
if (arr.length === 0) return null;
|
||||
return arr.map(t => resolveKey(t, reverseMap));
|
||||
return arr.map((t) => resolveKey(t, reverseMap));
|
||||
}
|
||||
|
||||
export default function ChartEditor({ initial, onSave, onClose }: Props) {
|
||||
const { reverseMap } = useApp();
|
||||
|
||||
const [title, setTitle] = useState(initial?.title ?? '');
|
||||
const [chartType, setChartType] = useState<ChartConfig['chart_type']>(initial?.chart_type ?? 'signals');
|
||||
const [vizType, setVizType] = useState<ChartConfig['viz_type']>(initial?.viz_type ?? 'line');
|
||||
const [signalType, setSignalType] = useState<ChartConfig['signal_type']>(initial?.signal_type ?? 'both');
|
||||
const [title, setTitle] = useState(initial?.title ?? '');
|
||||
const [chartType, setChartType] = useState<ChartConfig['chart_type']>(
|
||||
initial?.chart_type ?? 'signals',
|
||||
);
|
||||
const [vizType, setVizType] = useState<ChartConfig['viz_type']>(initial?.viz_type ?? 'line');
|
||||
const [signalType, setSignalType] = useState<ChartConfig['signal_type']>(
|
||||
initial?.signal_type ?? 'both',
|
||||
);
|
||||
const [combinators, setCombinators] = useState((initial?.filter_combinators ?? []).join(', '));
|
||||
const [whitelist, setWhitelist] = useState((initial?.filter_items ?? []).join(', '));
|
||||
const [blacklist, setBlacklist] = useState((initial?.filter_items_exclude ?? []).join(', '));
|
||||
const [useRegex, setUseRegex] = useState(initial?.filter_items_regex ?? false);
|
||||
const [orderBy, setOrderBy] = useState<ChartConfig['order_by']>(initial?.order_by ?? 'value_asc');
|
||||
const [whitelist, setWhitelist] = useState((initial?.filter_items ?? []).join(', '));
|
||||
const [blacklist, setBlacklist] = useState((initial?.filter_items_exclude ?? []).join(', '));
|
||||
const [useRegex, setUseRegex] = useState(initial?.filter_items_regex ?? false);
|
||||
const [orderBy, setOrderBy] = useState<ChartConfig['order_by']>(initial?.order_by ?? 'value_asc');
|
||||
const [seriesLimit, setSeriesLimit] = useState(initial?.series_limit ?? 20);
|
||||
const [yMin, setYMin] = useState(initial?.y_min?.toString() ?? '');
|
||||
const [yMax, setYMax] = useState(initial?.y_max?.toString() ?? '');
|
||||
const [yScale, setYScale] = useState<ChartConfig['y_scale']>(initial?.y_scale ?? 'linear');
|
||||
const [width, setWidth] = useState(initial?.width ?? 2);
|
||||
const [height, setHeight] = useState(initial?.height ?? 4);
|
||||
const [yMin, setYMin] = useState(initial?.y_min?.toString() ?? '');
|
||||
const [yMax, setYMax] = useState(initial?.y_max?.toString() ?? '');
|
||||
const [yScale, setYScale] = useState<ChartConfig['y_scale']>(initial?.y_scale ?? 'linear');
|
||||
const [width, setWidth] = useState(initial?.width ?? 2);
|
||||
const [height, setHeight] = useState(initial?.height ?? 4);
|
||||
|
||||
function splitCombinators(): string[] | null {
|
||||
const arr = combinators.split(',').map(x => x.trim()).filter(Boolean);
|
||||
const arr = combinators
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
return arr.length > 0 ? arr : null;
|
||||
}
|
||||
|
||||
@@ -55,30 +65,35 @@ export default function ChartEditor({ initial, onSave, onClose }: Props) {
|
||||
|
||||
// Regex mode: store pattern exactly as typed — server expands via matchKeys at query time.
|
||||
// Non-regex mode: resolve each comma-token (localized name or raw key) to raw key.
|
||||
const filter_items = useRegex
|
||||
? (whitelist.trim() ? [whitelist.trim()] : null)
|
||||
const filter_items = useRegex
|
||||
? whitelist.trim()
|
||||
? [whitelist.trim()]
|
||||
: null
|
||||
: normalizeList(whitelist, reverseMap);
|
||||
const filter_items_exclude = useRegex
|
||||
? (blacklist.trim() ? [blacklist.trim()] : null)
|
||||
? blacklist.trim()
|
||||
? [blacklist.trim()]
|
||||
: null
|
||||
: normalizeList(blacklist, reverseMap);
|
||||
|
||||
onSave({
|
||||
title: title.trim(),
|
||||
chart_type: chartType,
|
||||
viz_type: chartType === 'divider' ? 'line' : vizType,
|
||||
signal_type: signalType,
|
||||
pos_x: initial?.pos_x ?? 0,
|
||||
pos_y: initial?.pos_y ?? 0,
|
||||
width, height,
|
||||
filter_combinators: chartType === 'divider' ? null : splitCombinators(),
|
||||
filter_items: chartType === 'divider' ? null : filter_items,
|
||||
title: title.trim(),
|
||||
chart_type: chartType,
|
||||
viz_type: chartType === 'divider' ? 'line' : vizType,
|
||||
signal_type: signalType,
|
||||
pos_x: initial?.pos_x ?? 0,
|
||||
pos_y: initial?.pos_y ?? 0,
|
||||
width,
|
||||
height,
|
||||
filter_combinators: chartType === 'divider' ? null : splitCombinators(),
|
||||
filter_items: chartType === 'divider' ? null : filter_items,
|
||||
filter_items_exclude: chartType === 'divider' ? null : filter_items_exclude,
|
||||
filter_items_regex: useRegex,
|
||||
order_by: orderBy,
|
||||
series_limit: seriesLimit,
|
||||
y_min: yMin !== '' ? parseFloat(yMin) : null,
|
||||
y_max: yMax !== '' ? parseFloat(yMax) : null,
|
||||
y_scale: yScale,
|
||||
filter_items_regex: useRegex,
|
||||
order_by: orderBy,
|
||||
series_limit: seriesLimit,
|
||||
y_min: yMin !== '' ? parseFloat(yMin) : null,
|
||||
y_max: yMax !== '' ? parseFloat(yMax) : null,
|
||||
y_scale: yScale,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,115 +101,205 @@ export default function ChartEditor({ initial, onSave, onClose }: Props) {
|
||||
const isDivider = chartType === 'divider';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-gray-900 border border-gray-700 rounded-lg p-6 w-full max-w-md shadow-xl overflow-y-auto max-h-[90vh]"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">
|
||||
{initial ? 'Edit Chart' : 'New Chart'}
|
||||
</h2>
|
||||
|
||||
<label className="block text-sm text-gray-400 mb-1">Title</label>
|
||||
<input value={title} onChange={e => setTitle(e.target.value)} className={`${inputCls} mb-3`} />
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className={`${inputCls} mb-3`}
|
||||
/>
|
||||
|
||||
<label className="block text-sm text-gray-400 mb-1">Chart Type</label>
|
||||
<select value={chartType} onChange={e => setChartType(e.target.value as ChartConfig['chart_type'])} className={`${inputCls} mb-3`}>
|
||||
<select
|
||||
value={chartType}
|
||||
onChange={(e) => setChartType(e.target.value as ChartConfig['chart_type'])}
|
||||
className={`${inputCls} mb-3`}
|
||||
>
|
||||
<option value="signals">Signals</option>
|
||||
<option value="ups">UPS / Game Tick Rate</option>
|
||||
<option value="divider">Divider / Section Label</option>
|
||||
</select>
|
||||
|
||||
{!isDivider && <>
|
||||
<label className="block text-sm text-gray-400 mb-1">Visualization</label>
|
||||
<select value={vizType} onChange={e => setVizType(e.target.value as ChartConfig['viz_type'])} className={`${inputCls} mb-3`}>
|
||||
<option value="line">Line Chart</option>
|
||||
<option value="table">Table</option>
|
||||
</select>
|
||||
</>}
|
||||
{!isDivider && (
|
||||
<>
|
||||
<label className="block text-sm text-gray-400 mb-1">Visualization</label>
|
||||
<select
|
||||
value={vizType}
|
||||
onChange={(e) => setVizType(e.target.value as ChartConfig['viz_type'])}
|
||||
className={`${inputCls} mb-3`}
|
||||
>
|
||||
<option value="line">Line Chart</option>
|
||||
<option value="table">Table</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isSignals && <>
|
||||
<label className="block text-sm text-gray-400 mb-1">Signal</label>
|
||||
<select value={signalType} onChange={e => setSignalType(e.target.value as ChartConfig['signal_type'])} className={`${inputCls} mb-3`}>
|
||||
<option value="both">Both (green + red)</option>
|
||||
<option value="green">Green only</option>
|
||||
<option value="red">Red only</option>
|
||||
</select>
|
||||
{isSignals && (
|
||||
<>
|
||||
<label className="block text-sm text-gray-400 mb-1">Signal</label>
|
||||
<select
|
||||
value={signalType}
|
||||
onChange={(e) => setSignalType(e.target.value as ChartConfig['signal_type'])}
|
||||
className={`${inputCls} mb-3`}
|
||||
>
|
||||
<option value="both">Both (green + red)</option>
|
||||
<option value="green">Green only</option>
|
||||
<option value="red">Red only</option>
|
||||
</select>
|
||||
|
||||
<label className="block text-sm text-gray-400 mb-1">Sort series by</label>
|
||||
<select value={orderBy} onChange={e => setOrderBy(e.target.value as ChartConfig['order_by'])} className={`${inputCls} mb-3`}>
|
||||
<option value="value_asc">Latest lowest values</option>
|
||||
<option value="value_desc">Latest highest values</option>
|
||||
<option value="delta_asc">Biggest decrease (last 10 min)</option>
|
||||
<option value="delta_desc">Biggest increase (last 10 min)</option>
|
||||
</select>
|
||||
<label className="block text-sm text-gray-400 mb-1">Sort series by</label>
|
||||
<select
|
||||
value={orderBy}
|
||||
onChange={(e) => setOrderBy(e.target.value as ChartConfig['order_by'])}
|
||||
className={`${inputCls} mb-3`}
|
||||
>
|
||||
<option value="value_asc">Latest lowest values</option>
|
||||
<option value="value_desc">Latest highest values</option>
|
||||
<option value="delta_asc">Biggest decrease (last 10 min)</option>
|
||||
<option value="delta_desc">Biggest increase (last 10 min)</option>
|
||||
</select>
|
||||
|
||||
<label className="block text-sm text-gray-400 mb-1">Max series (lines)</label>
|
||||
<input type="number" min={1} max={200} value={seriesLimit}
|
||||
onChange={e => setSeriesLimit(Number(e.target.value))}
|
||||
className={`${inputCls} mb-3`} />
|
||||
<label className="block text-sm text-gray-400 mb-1">Max series (lines)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={seriesLimit}
|
||||
onChange={(e) => setSeriesLimit(Number(e.target.value))}
|
||||
className={`${inputCls} mb-3`}
|
||||
/>
|
||||
|
||||
<label className="block text-sm text-gray-400 mb-1">Combinators (comma-separated, empty = all)</label>
|
||||
<input value={combinators} onChange={e => setCombinators(e.target.value)}
|
||||
placeholder="nauvis, nauvis-orbit" className={`${inputCls} mb-3`} />
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="text-sm text-gray-400 flex-1">Item filters</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer">
|
||||
<input type="checkbox" checked={useRegex} onChange={e => setUseRegex(e.target.checked)}
|
||||
className="accent-indigo-500" />
|
||||
Use regex
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
Combinators (comma-separated, empty = all)
|
||||
</label>
|
||||
</div>
|
||||
<input value={whitelist} onChange={e => setWhitelist(e.target.value)}
|
||||
placeholder={useRegex ? 'wissen.*|Iron Plate' : 'Iron Plate, copper-plate'}
|
||||
className={`${inputCls} mb-1`} />
|
||||
<p className="text-xs text-gray-500 mb-2">Whitelist — localized names or item keys accepted (empty = all)</p>
|
||||
<input value={blacklist} onChange={e => setBlacklist(e.target.value)}
|
||||
placeholder={useRegex ? 'Holz|stone' : 'Wood, stone'}
|
||||
className={`${inputCls} mb-1`} />
|
||||
<p className="text-xs text-gray-500 mb-3">Blacklist — localized names or item keys accepted</p>
|
||||
</>}
|
||||
<input
|
||||
value={combinators}
|
||||
onChange={(e) => setCombinators(e.target.value)}
|
||||
placeholder="nauvis, nauvis-orbit"
|
||||
className={`${inputCls} mb-3`}
|
||||
/>
|
||||
|
||||
{!isDivider && <>
|
||||
<div className="flex gap-3 mb-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-gray-400 mb-1">Y Min (empty = auto)</label>
|
||||
<input type="number" value={yMin} onChange={e => setYMin(e.target.value)}
|
||||
placeholder="auto" className={inputCls} />
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="text-sm text-gray-400 flex-1">Item filters</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useRegex}
|
||||
onChange={(e) => setUseRegex(e.target.checked)}
|
||||
className="accent-indigo-500"
|
||||
/>
|
||||
Use regex
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-gray-400 mb-1">Y Max (empty = auto)</label>
|
||||
<input type="number" value={yMax} onChange={e => setYMax(e.target.value)}
|
||||
placeholder="auto" className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
value={whitelist}
|
||||
onChange={(e) => setWhitelist(e.target.value)}
|
||||
placeholder={useRegex ? 'wissen.*|Iron Plate' : 'Iron Plate, copper-plate'}
|
||||
className={`${inputCls} mb-1`}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
Whitelist — localized names or item keys accepted (empty = all)
|
||||
</p>
|
||||
<input
|
||||
value={blacklist}
|
||||
onChange={(e) => setBlacklist(e.target.value)}
|
||||
placeholder={useRegex ? 'Holz|stone' : 'Wood, stone'}
|
||||
className={`${inputCls} mb-1`}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Blacklist — localized names or item keys accepted
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="block text-sm text-gray-400 mb-1">Y Scale</label>
|
||||
<select value={yScale} onChange={e => setYScale(e.target.value as ChartConfig['y_scale'])} className={`${inputCls} mb-3`}>
|
||||
<option value="linear">Linear</option>
|
||||
<option value="log">Symmetric Log (arcsinh)</option>
|
||||
</select>
|
||||
</>}
|
||||
{!isDivider && (
|
||||
<>
|
||||
<div className="flex gap-3 mb-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-gray-400 mb-1">Y Min (empty = auto)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={yMin}
|
||||
onChange={(e) => setYMin(e.target.value)}
|
||||
placeholder="auto"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-gray-400 mb-1">Y Max (empty = auto)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={yMax}
|
||||
onChange={(e) => setYMax(e.target.value)}
|
||||
placeholder="auto"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block text-sm text-gray-400 mb-1">Y Scale</label>
|
||||
<select
|
||||
value={yScale}
|
||||
onChange={(e) => setYScale(e.target.value as ChartConfig['y_scale'])}
|
||||
className={`${inputCls} mb-3`}
|
||||
>
|
||||
<option value="linear">Linear</option>
|
||||
<option value="log">Symmetric Log (arcsinh)</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-gray-400 mb-1">Width (1–6 cols)</label>
|
||||
<input type="number" min={1} max={6} value={width}
|
||||
onChange={e => setWidth(Number(e.target.value))} className={inputCls} />
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={6}
|
||||
value={width}
|
||||
onChange={(e) => setWidth(Number(e.target.value))}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-gray-400 mb-1">Height (rows)</label>
|
||||
<input type="number" min={2} max={20} value={height}
|
||||
onChange={e => setHeight(Number(e.target.value))} className={inputCls} />
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={20}
|
||||
value={height}
|
||||
onChange={(e) => setHeight(Number(e.target.value))}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-4 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700">Cancel</button>
|
||||
<button onClick={handleSave} className="px-4 py-1.5 text-sm bg-indigo-600 hover:bg-indigo-500 text-white rounded">Save</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-1.5 text-sm bg-indigo-600 hover:bg-indigo-500 text-white rounded"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,20 @@ import type { Layout, LayoutItem } from 'react-grid-layout';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { useApp } from '@/lib/context';
|
||||
import { fetchCharts, createChart, updateChart, deleteChart, fetchSignals, fetchSessions, fetchUps } from '@/lib/api';
|
||||
import {
|
||||
fetchCharts,
|
||||
createChart,
|
||||
updateChart,
|
||||
deleteChart,
|
||||
fetchSignals,
|
||||
fetchSessions,
|
||||
fetchUps,
|
||||
} from '@/lib/api';
|
||||
import type { ChartConfig, SignalRow, UpsRow, SessionBoundary, AlertConfig } from '@/lib/types';
|
||||
import ChartCard from './ChartCard';
|
||||
import ChartEditor from './ChartEditor';
|
||||
|
||||
const COLS = 6;
|
||||
const COLS = 6;
|
||||
const ROW_HEIGHT = 80;
|
||||
|
||||
interface Props {
|
||||
@@ -20,21 +28,25 @@ interface Props {
|
||||
|
||||
export default function Dashboard({ alerts }: Props) {
|
||||
const { timeRange, timeMode, getFromTo } = useApp();
|
||||
const [charts, setCharts] = useState<ChartConfig[]>([]);
|
||||
const [signalData, setSignalData] = useState<Map<string, SignalRow[]>>(new Map());
|
||||
const [upsData, setUpsData] = useState<Map<string, UpsRow[]>>(new Map());
|
||||
const [sessions, setSessions] = useState<SessionBoundary[]>([]);
|
||||
const [editingChart, setEditingChart] = useState<ChartConfig | null>(null);
|
||||
const [creatingChart, setCreatingChart] = useState(false);
|
||||
const [charts, setCharts] = useState<ChartConfig[]>([]);
|
||||
const [signalData, setSignalData] = useState<Map<string, SignalRow[]>>(new Map());
|
||||
const [upsData, setUpsData] = useState<Map<string, UpsRow[]>>(new Map());
|
||||
const [sessions, setSessions] = useState<SessionBoundary[]>([]);
|
||||
const [editingChart, setEditingChart] = useState<ChartConfig | null>(null);
|
||||
const [creatingChart, setCreatingChart] = useState(false);
|
||||
const [containerWidth, setContainerWidth] = useState(1200);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartsRef = useRef<ChartConfig[]>([]);
|
||||
const refreshingRef = useRef(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartsRef = useRef<ChartConfig[]>([]);
|
||||
const refreshingRef = useRef(false);
|
||||
const layoutSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => { chartsRef.current = charts; }, [charts]);
|
||||
useEffect(() => { fetchCharts().then(setCharts); }, []);
|
||||
useEffect(() => {
|
||||
chartsRef.current = charts;
|
||||
}, [charts]);
|
||||
useEffect(() => {
|
||||
fetchCharts().then(setCharts);
|
||||
}, []);
|
||||
|
||||
const refreshData = useCallback(async () => {
|
||||
if (refreshingRef.current) return;
|
||||
@@ -44,24 +56,27 @@ export default function Dashboard({ alerts }: Props) {
|
||||
refreshingRef.current = true;
|
||||
try {
|
||||
const { from, to } = getFromTo();
|
||||
const signalCharts = current.filter(c => c.chart_type === 'signals');
|
||||
const upsCharts = current.filter(c => c.chart_type === 'ups');
|
||||
const signalCharts = current.filter((c) => c.chart_type === 'signals');
|
||||
const upsCharts = current.filter((c) => c.chart_type === 'ups');
|
||||
|
||||
if (signalCharts.length === 0 && upsCharts.length === 0) return;
|
||||
|
||||
const [newSessions, ...results] = await Promise.all([
|
||||
fetchSessions(from, to),
|
||||
...signalCharts.map(c => fetchSignals({
|
||||
combinator: c.filter_combinators ?? undefined,
|
||||
item: c.filter_items ?? undefined,
|
||||
exclude: c.filter_items_exclude ?? undefined,
|
||||
signal: c.signal_type,
|
||||
time_mode: timeMode,
|
||||
from, to,
|
||||
regex: c.filter_items_regex || undefined,
|
||||
...(c.order_by !== 'time' ? { order_by: c.order_by, limit: c.series_limit } : {}),
|
||||
})),
|
||||
...upsCharts.map(c => fetchUps({ combinator: c.filter_combinators?.[0], from, to })),
|
||||
...signalCharts.map((c) =>
|
||||
fetchSignals({
|
||||
combinator: c.filter_combinators ?? undefined,
|
||||
item: c.filter_items ?? undefined,
|
||||
exclude: c.filter_items_exclude ?? undefined,
|
||||
signal: c.signal_type,
|
||||
time_mode: timeMode,
|
||||
from,
|
||||
to,
|
||||
regex: c.filter_items_regex || undefined,
|
||||
...(c.order_by !== 'time' ? { order_by: c.order_by, limit: c.series_limit } : {}),
|
||||
}),
|
||||
),
|
||||
...upsCharts.map((c) => fetchUps({ combinator: c.filter_combinators?.[0], from, to })),
|
||||
]);
|
||||
|
||||
setSessions(newSessions as SessionBoundary[]);
|
||||
@@ -98,47 +113,56 @@ export default function Dashboard({ alerts }: Props) {
|
||||
|
||||
async function handleCreate(draft: Omit<ChartConfig, 'id'>) {
|
||||
const created = await createChart(draft);
|
||||
setCharts(cs => [...cs, created]);
|
||||
setCharts((cs) => [...cs, created]);
|
||||
setCreatingChart(false);
|
||||
}
|
||||
|
||||
async function handleUpdate(id: string, draft: Omit<ChartConfig, 'id'>) {
|
||||
const updated = await updateChart(id, draft);
|
||||
setCharts(cs => cs.map(c => c.id === id ? updated : c));
|
||||
setCharts((cs) => cs.map((c) => (c.id === id ? updated : c)));
|
||||
setEditingChart(null);
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
await deleteChart(id);
|
||||
setCharts(cs => cs.filter(c => c.id !== id));
|
||||
setCharts((cs) => cs.filter((c) => c.id !== id));
|
||||
}
|
||||
|
||||
function handleLayoutChange(layout: Layout) {
|
||||
const items = layout as readonly LayoutItem[];
|
||||
const changed = items.filter(item => {
|
||||
const chart = chartsRef.current.find(c => c.id === item.i);
|
||||
return chart && (
|
||||
chart.pos_x !== item.x || chart.pos_y !== item.y ||
|
||||
chart.width !== item.w || chart.height !== item.h
|
||||
const changed = items.filter((item) => {
|
||||
const chart = chartsRef.current.find((c) => c.id === item.i);
|
||||
return (
|
||||
chart &&
|
||||
(chart.pos_x !== item.x ||
|
||||
chart.pos_y !== item.y ||
|
||||
chart.width !== item.w ||
|
||||
chart.height !== item.h)
|
||||
);
|
||||
});
|
||||
if (changed.length === 0) return;
|
||||
|
||||
setCharts(cs => cs.map(c => {
|
||||
const l = changed.find(item => item.i === c.id);
|
||||
return l ? { ...c, pos_x: l.x, pos_y: l.y, width: l.w, height: l.h } : c;
|
||||
}));
|
||||
setCharts((cs) =>
|
||||
cs.map((c) => {
|
||||
const l = changed.find((item) => item.i === c.id);
|
||||
return l ? { ...c, pos_x: l.x, pos_y: l.y, width: l.w, height: l.h } : c;
|
||||
}),
|
||||
);
|
||||
|
||||
if (layoutSaveTimer.current) clearTimeout(layoutSaveTimer.current);
|
||||
layoutSaveTimer.current = setTimeout(() => {
|
||||
changed.forEach(item =>
|
||||
changed.forEach((item) =>
|
||||
updateChart(item.i, { pos_x: item.x, pos_y: item.y, width: item.w, height: item.h }),
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const layout: Layout = charts.map(c => ({
|
||||
i: c.id, x: c.pos_x, y: c.pos_y, w: c.width, h: c.height,
|
||||
const layout: Layout = charts.map((c) => ({
|
||||
i: c.id,
|
||||
x: c.pos_x,
|
||||
y: c.pos_y,
|
||||
w: c.width,
|
||||
h: c.height,
|
||||
minW: 1,
|
||||
minH: c.chart_type === 'divider' ? 1 : 2,
|
||||
}));
|
||||
@@ -153,7 +177,8 @@ export default function Dashboard({ alerts }: Props) {
|
||||
dragConfig={{ handle: '.drag-handle' }}
|
||||
resizeConfig={{
|
||||
handleComponent: (axis, ref) => (
|
||||
<span ref={ref}
|
||||
<span
|
||||
ref={ref}
|
||||
className="react-resizable-handle react-resizable-handle-se"
|
||||
style={{
|
||||
borderRight: '3px solid #4b5563',
|
||||
@@ -164,16 +189,18 @@ export default function Dashboard({ alerts }: Props) {
|
||||
),
|
||||
}}
|
||||
>
|
||||
{charts.map(c => (
|
||||
{charts.map((c) => (
|
||||
<div key={c.id} className="drag-handle cursor-grab active:cursor-grabbing">
|
||||
<ChartCard
|
||||
config={c}
|
||||
rows={signalData.get(c.id) ?? []}
|
||||
upsRows={upsData.get(c.id) ?? []}
|
||||
sessions={sessions}
|
||||
alerts={alerts.filter(a =>
|
||||
!c.filter_combinators || !a.combinator ||
|
||||
c.filter_combinators.includes(a.combinator),
|
||||
alerts={alerts.filter(
|
||||
(a) =>
|
||||
!c.filter_combinators ||
|
||||
!a.combinator ||
|
||||
c.filter_combinators.includes(a.combinator),
|
||||
)}
|
||||
timeMode={timeMode}
|
||||
onEdit={() => setEditingChart(c)}
|
||||
@@ -196,10 +223,10 @@ export default function Dashboard({ alerts }: Props) {
|
||||
{editingChart && (
|
||||
<ChartEditor
|
||||
initial={editingChart}
|
||||
onSave={draft => handleUpdate(editingChart.id, draft)}
|
||||
onSave={(draft) => handleUpdate(editingChart.id, draft)}
|
||||
onClose={() => setEditingChart(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function TimeRangeSelector() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex rounded overflow-hidden border border-gray-700">
|
||||
{RANGES.map(r => (
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setTimeRange(r)}
|
||||
@@ -27,7 +27,7 @@ export default function TimeRangeSelector() {
|
||||
</div>
|
||||
|
||||
<div className="flex rounded overflow-hidden border border-gray-700">
|
||||
{(['real', 'tick'] as TimeMode[]).map(m => (
|
||||
{(['real', 'tick'] as TimeMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setTimeMode(m)}
|
||||
@@ -43,4 +43,4 @@ export default function TimeRangeSelector() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user