Files
factorio-signal-exporter/web/components/ChartEditor.tsx
2026-06-03 15:08:25 +02:00

200 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState } from 'react';
import { useApp } from '@/lib/context';
import { resolveKey } from '@/lib/localization';
import type { ChartConfig } from '@/lib/types';
type DraftChart = Omit<ChartConfig, 'id'>;
interface Props {
initial?: ChartConfig;
onSave: (draft: DraftChart) => void;
onClose: () => void;
}
const inputCls =
'w-full bg-gray-800 border border-gray-600 rounded px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500';
/**
* Normalizes a comma-separated list of user tokens (localized names or raw keys)
* 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);
if (arr.length === 0) return null;
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 [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 [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);
function splitCombinators(): string[] | null {
const arr = combinators.split(',').map(x => x.trim()).filter(Boolean);
return arr.length > 0 ? arr : null;
}
function handleSave() {
if (!title.trim()) return;
// 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)
: normalizeList(whitelist, reverseMap);
const filter_items_exclude = useRegex
? (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,
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,
});
}
const isSignals = chartType === 'signals';
const isDivider = chartType === 'divider';
return (
<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()}
>
<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`} />
<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`}>
<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>
</>}
{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">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>
</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>
</>}
{!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 (16 cols)</label>
<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} />
</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>
</div>
</div>
</div>
);
}