Files
factorio-signal-exporter/web/components/ChartEditor.tsx

308 lines
11 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 type { ChartConfig } from '@/lib/types';
import { useApp } from '@/lib/context';
import { resolveKey } from '@/lib/localization';
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>
);
}