diff --git a/server/installer/data-downloader/backend/app.py b/server/installer/data-downloader/backend/app.py index e9eff4f..2506b15 100644 --- a/server/installer/data-downloader/backend/app.py +++ b/server/installer/data-downloader/backend/app.py @@ -77,6 +77,7 @@ class AnalysisConfigCreate(BaseModel): start: datetime end: datetime plots: List[PlotGroupModel] + colors: Dict[str, str] | None = None class AnalysisConfigPatch(BaseModel): @@ -332,17 +333,18 @@ def create_analysis_config(payload: AnalysisConfigCreate) -> dict: if not set(group.rightAxis).issubset(set(group.signals)): raise HTTPException(status_code=400, detail="rightAxis must be a subset of signals") normalized_plots.append({"signals": group.signals, "rightAxis": group.rightAxis}) - config = service.create_analysis_config( - { - "name": name, - "note": payload.note.strip(), - "author": payload.author.strip(), - "season": payload.season, - "start": series_queries.normalize_utc(payload.start).isoformat(), - "end": series_queries.normalize_utc(payload.end).isoformat(), - "plots": normalized_plots, - } - ) + fields = { + "name": name, + "note": payload.note.strip(), + "author": payload.author.strip(), + "season": payload.season, + "start": series_queries.normalize_utc(payload.start).isoformat(), + "end": series_queries.normalize_utc(payload.end).isoformat(), + "plots": normalized_plots, + } + if payload.colors: + fields["colors"] = payload.colors + config = service.create_analysis_config(fields) return config diff --git a/server/installer/data-downloader/backend/storage.py b/server/installer/data-downloader/backend/storage.py index 2d349c8..2442b10 100644 --- a/server/installer/data-downloader/backend/storage.py +++ b/server/installer/data-downloader/backend/storage.py @@ -249,6 +249,8 @@ def create_config(self, fields: dict) -> dict: "created_at": now, "updated_at": now, } + if fields.get("colors"): + config["colors"] = fields["colors"] # Newest first so the UI list needs no re-sort. payload.setdefault("configs", []).insert(0, config) payload["updated_at"] = now diff --git a/server/installer/data-downloader/backend/tests/test_analysis_configs_endpoint.py b/server/installer/data-downloader/backend/tests/test_analysis_configs_endpoint.py index 1ca590a..4fcd112 100644 --- a/server/installer/data-downloader/backend/tests/test_analysis_configs_endpoint.py +++ b/server/installer/data-downloader/backend/tests/test_analysis_configs_endpoint.py @@ -92,6 +92,23 @@ def test_create_rejects_rightaxis_not_subset(): assert "subset" in r.json()["detail"].lower() +def test_create_persists_color_overrides(): + colors = {"Brake_Pressure": "#ff0000"} + created = client.post( + "/api/analysis-configs", + json=create_payload(colors=colors), + ) + assert created.status_code == 201 + body = created.json() + assert body["colors"] == colors + + listed = client.get("/api/analysis-configs") + saved = next(c for c in listed.json()["configs"] if c["id"] == body["id"]) + assert saved["colors"] == colors + + client.delete(f"/api/analysis-configs/{body['id']}") + + def test_patch_rejects_blank_name(): created = client.post("/api/analysis-configs", json=create_payload()) config_id = created.json()["id"] diff --git a/server/installer/data-downloader/backend/tests/test_analysis_configs_repo.py b/server/installer/data-downloader/backend/tests/test_analysis_configs_repo.py index 443a463..cdc714a 100644 --- a/server/installer/data-downloader/backend/tests/test_analysis_configs_repo.py +++ b/server/installer/data-downloader/backend/tests/test_analysis_configs_repo.py @@ -26,6 +26,15 @@ def test_create_assigns_id_and_timestamps(tmp_path: Path): assert config["created_at"] == config["updated_at"] assert config["name"] == "Brake event" assert config["plots"] == [{"signals": ["Brake_Pressure"], "rightAxis": []}] + assert "colors" not in config + + +def test_create_persists_colors_when_provided(tmp_path: Path): + repo = AnalysisConfigsRepository(tmp_path) + colors = {"Brake_Pressure": "#ff0000"} + config = repo.create_config(_fields(colors=colors)) + assert config["colors"] == colors + assert repo.list_configs()["configs"][0]["colors"] == colors def test_list_returns_newest_first(tmp_path: Path): diff --git a/server/installer/data-downloader/frontend/src/analysis/plot-layout.test.ts b/server/installer/data-downloader/frontend/src/analysis/plot-layout.test.ts index 1d59098..71c3c41 100644 --- a/server/installer/data-downloader/frontend/src/analysis/plot-layout.test.ts +++ b/server/installer/data-downloader/frontend/src/analysis/plot-layout.test.ts @@ -6,6 +6,7 @@ import { NEW_PLOT, type PlotLayout, assignSignals, + clearRightAxisForSignals, flattenSignals, parseLayout, pruneUnknown, @@ -82,6 +83,24 @@ describe("assignSignals", () => { ); expect(next).toEqual([group("b", ["S2", "S1"], ["S1"])]); }); + + it("clears rightAxis when a moved signal is assigned to the left axis", () => { + const moved = assignSignals( + [group("a", ["S1"], ["S1"]), group("b", ["S2"])], + ["S1"], + "b", + ); + const next = clearRightAxisForSignals(moved, ["S1"]); + expect(next).toEqual([group("b", ["S2", "S1"])]); + }); +}); + +describe("clearRightAxisForSignals", () => { + it("removes listed signals from rightAxis and is a no-op when none match", () => { + const layout = [group("a", ["S1", "S2"], ["S2"])]; + expect(clearRightAxisForSignals(layout, ["S2"])).toEqual([group("a", ["S1", "S2"])]); + expect(clearRightAxisForSignals(layout, ["S9"])).toBe(layout); + }); }); describe("toggleRightAxis", () => { @@ -121,12 +140,31 @@ describe("serializeLayout and parseLayout", () => { const layout = [group("a", ["S1", "S2"], ["S2"])]; const parsed = parseLayout(serializeLayout(layout)); expect(parsed).not.toBeNull(); - expect(parsed![0].signals).toEqual(["S1", "S2"]); - expect(parsed![0].rightAxis).toEqual(["S2"]); - expect(parsed![0].id).toBeTruthy(); + expect(parsed!.layout[0].signals).toEqual(["S1", "S2"]); + expect(parsed!.layout[0].rightAxis).toEqual(["S2"]); + expect(parsed!.layout[0].id).toBeTruthy(); + expect(parsed!.colorOverrides).toEqual({}); + }); + + it("round-trips v2 with color overrides", () => { + const layout = [group("a", ["S1"])]; + const colors = { S1: "#ff0000" }; + const serialized = serializeLayout(layout, colors); + const parsed = parseLayout(serialized); + expect(parsed).not.toBeNull(); + expect(parsed!.layout[0].signals).toEqual(["S1"]); + expect(parsed!.colorOverrides).toEqual({ S1: "#ff0000" }); + }); + + it("parses legacy v1 layout without errors", () => { + const raw = '{"v":1,"plots":[{"signals":["S1"],"rightAxis":[]}]}'; + const parsed = parseLayout(raw); + expect(parsed).not.toBeNull(); + expect(parsed!.layout[0].signals).toEqual(["S1"]); + expect(parsed!.colorOverrides).toEqual({}); }); - it.each([null, "", "not json", '{"v":2,"plots":[]}', '{"v":1,"plots":"x"}', '{"v":1,"plots":[{"signals":"x"}]}'])( + it.each([null, "", "not json", '{"v":3,"plots":[]}', '{"v":1,"plots":"x"}', '{"v":1,"plots":[{"signals":"x"}]}'])( "returns null for corrupt or wrong-version input %#", (raw) => { expect(parseLayout(raw as string | null)).toBeNull(); @@ -135,7 +173,7 @@ describe("serializeLayout and parseLayout", () => { it("drops non-string entries and keeps rightAxis a subset on parse", () => { const parsed = parseLayout('{"v":1,"plots":[{"signals":["S1",5],"rightAxis":["S1","GHOST"]}]}'); - expect(parsed).toEqual([ + expect(parsed!.layout).toEqual([ { id: expect.any(String), signals: ["S1"], rightAxis: ["S1"] }, ]); }); diff --git a/server/installer/data-downloader/frontend/src/analysis/plot-layout.ts b/server/installer/data-downloader/frontend/src/analysis/plot-layout.ts index 53ee4e7..af1342a 100644 --- a/server/installer/data-downloader/frontend/src/analysis/plot-layout.ts +++ b/server/installer/data-downloader/frontend/src/analysis/plot-layout.ts @@ -80,6 +80,19 @@ export function assignSignals( ); } +export function clearRightAxisForSignals( + layout: PlotLayout, + signals: string[], +): PlotLayout { + const remove = new Set(signals); + const hasAny = layout.some((g) => g.rightAxis.some((s) => remove.has(s))); + if (!hasAny) return layout; + return layout.map((g) => ({ + ...g, + rightAxis: g.rightAxis.filter((s) => !remove.has(s)), + })); +} + export function toggleRightAxis( layout: PlotLayout, groupId: string, @@ -116,18 +129,27 @@ export function pruneUnknown( ); } -export function serializeLayout(layout: PlotLayout): string { +export function serializeLayout( + layout: PlotLayout, + colorOverrides?: Record, +): string { return JSON.stringify({ - v: 1, + v: 2, plots: layout.map((g) => ({ signals: g.signals, rightAxis: g.rightAxis })), + colors: colorOverrides && Object.keys(colorOverrides).length > 0 ? colorOverrides : undefined, }); } -export function parseLayout(raw: string | null): PlotLayout | null { +export interface ParsedLayout { + layout: PlotLayout; + colorOverrides: Record; +} + +export function parseLayout(raw: string | null): ParsedLayout | null { if (!raw) return null; try { - const data = JSON.parse(raw) as { v?: unknown; plots?: unknown }; - if (data.v !== 1 || !Array.isArray(data.plots)) return null; + const data = JSON.parse(raw) as { v?: unknown; plots?: unknown; colors?: unknown }; + if ((data.v !== 1 && data.v !== 2) || !Array.isArray(data.plots)) return null; const layout: PlotLayout = []; for (const entry of data.plots) { if (typeof entry !== "object" || entry === null) return null; @@ -144,8 +166,35 @@ export function parseLayout(raw: string | null): PlotLayout | null { rightAxis: right.filter((s) => signals.includes(s)), }); } - return layout; + // v2 added color overrides; v1 layouts have none. + const colorOverrides: Record = + data.v === 2 && data.colors && typeof data.colors === "object" && !Array.isArray(data.colors) + ? Object.fromEntries( + Object.entries(data.colors as Record).filter( + ([, v]) => typeof v === "string", + ), + ) + : {}; + return { layout, colorOverrides }; } catch { return null; } } + +export function setSignalColor( + overrides: Record, + signal: string, + color: string, +): Record { + return { ...overrides, [signal]: color }; +} + +export function clearSignalColor( + overrides: Record, + signal: string, +): Record { + const next = { ...overrides }; + delete next[signal]; + return next; +} + diff --git a/server/installer/data-downloader/frontend/src/components/AnalysisPlotStack.test.tsx b/server/installer/data-downloader/frontend/src/components/AnalysisPlotStack.test.tsx index 7a431f8..6154693 100644 --- a/server/installer/data-downloader/frontend/src/components/AnalysisPlotStack.test.tsx +++ b/server/installer/data-downloader/frontend/src/components/AnalysisPlotStack.test.tsx @@ -169,6 +169,57 @@ describe("AnalysisPlotStack groups", () => { expect(plot).toHaveStyle({ width: "100%", height: "180px" }); expect(plot.style.height).not.toBe("100%"); }); + + it("dispatches onAssignSignalsToAxis when dropped on left or right axis overlay zones", () => { + const onAssignSignalsToAxis = vi.fn(); + render( + , + ); + const card = screen.getByTestId("analysis-plot-card"); + const dt = makeDataTransfer({ signals: ["S2"] }); + + // Drag enter to reveal zones + fireEvent.dragEnter(card, { dataTransfer: dt }); + + const leftZone = screen.getByText(/left axis/i); + + fireEvent.drop(leftZone, { dataTransfer: dt }); + expect(onAssignSignalsToAxis).toHaveBeenCalledWith(["S2"], "g1", "left"); + + onAssignSignalsToAxis.mockClear(); + fireEvent.dragEnter(card, { dataTransfer: dt }); + const rightZoneAfter = screen.getByText(/right axis/i); + fireEvent.drop(rightZoneAfter, { dataTransfer: dt }); + expect(onAssignSignalsToAxis).toHaveBeenCalledWith(["S2"], "g1", "right"); + }); + + it("opens color picker on swatch click and dispatches color handlers", () => { + const onSetSignalColor = vi.fn(); + const onClearSignalColor = vi.fn(); + render( + , + ); + + const swatch = screen.getByRole("button", { name: /change color for S1/i }); + fireEvent.click(swatch); + + expect(screen.getByText(/custom color/i)).toBeInTheDocument(); + const resetButton = screen.getByRole("button", { name: /reset/i }); + expect(resetButton).toBeInTheDocument(); + + fireEvent.click(resetButton); + expect(onClearSignalColor).toHaveBeenCalledWith("S1"); + }); }); describe("readSignalsPayload", () => { diff --git a/server/installer/data-downloader/frontend/src/components/AnalysisPlotStack.tsx b/server/installer/data-downloader/frontend/src/components/AnalysisPlotStack.tsx index 4f204ad..21805a5 100644 --- a/server/installer/data-downloader/frontend/src/components/AnalysisPlotStack.tsx +++ b/server/installer/data-downloader/frontend/src/components/AnalysisPlotStack.tsx @@ -1,11 +1,11 @@ -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import type { Config, Data, Layout, PlotRelayoutEvent } from "plotly.js"; import Plot from "react-plotly.js"; import { NEW_PLOT, type PlotGroup, type PlotLayout } from "../analysis/plot-layout"; import { buildTraces, parseXRangeRelayout, PLOT_AREA_MARGIN } from "../analysis/plot-traces"; import type { SeriesMap } from "../analysis/series-cache"; -import { plotStroke } from "./sensor-palette"; +import { plotStroke, type SignalColorOverrides } from "./sensor-palette"; export const SIGNALS_MIME = "application/x-wfr-signals"; @@ -28,10 +28,14 @@ export interface AnalysisPlotStackProps { layout: PlotLayout; seriesBySignal: SeriesMap; range: [number, number]; + colorOverrides?: SignalColorOverrides; onRangeChange: (startMs: number, endMs: number) => void; onAssignSignals: (signals: string[], target: string) => void; + onAssignSignalsToAxis?: (signals: string[], target: string, axis: "left" | "right") => void; onRemoveSignal: (signal: string) => void; onToggleRightAxis: (groupId: string, signal: string) => void; + onSetSignalColor?: (signal: string, color: string) => void; + onClearSignalColor?: (signal: string) => void; theme: "light" | "dark"; } @@ -45,28 +49,84 @@ function totalPoints(group: PlotGroup, seriesBySignal: SeriesMap): number { ); } +/** Inline color picker that appears when a legend swatch is clicked. */ +function SwatchColorPicker({ + signal, + currentColor, + hasOverride, + onSetColor, + onClearColor, + onClose, +}: { + signal: string; + currentColor: string; + hasOverride: boolean; + onSetColor: (signal: string, color: string) => void; + onClearColor: (signal: string) => void; + onClose: () => void; +}) { + const ref = useRef(null); + + return ( +
+ + {hasOverride && ( + + )} +
+ ); +} + function PlotCard({ group, seriesBySignal, range, isBottom, theme, + colorOverrides, onRangeChange, onAssignSignals, + onAssignSignalsToAxis, onRemoveSignal, onToggleRightAxis, + onSetSignalColor, + onClearSignalColor, }: { group: PlotGroup; seriesBySignal: SeriesMap; range: [number, number]; isBottom: boolean; theme: "light" | "dark"; + colorOverrides?: SignalColorOverrides; onRangeChange: (startMs: number, endMs: number) => void; onAssignSignals: (signals: string[], target: string) => void; + onAssignSignalsToAxis?: (signals: string[], target: string, axis: "left" | "right") => void; onRemoveSignal: (signal: string) => void; onToggleRightAxis: (groupId: string, signal: string) => void; + onSetSignalColor?: (signal: string, color: string) => void; + onClearSignalColor?: (signal: string) => void; }) { - const [dragOver, setDragOver] = useState(false); + const [axisDragOver, setAxisDragOver] = useState<"left" | "right" | null>(null); + const [isCardDragOver, setIsCardDragOver] = useState(false); + const [colorPickerSignal, setColorPickerSignal] = useState(null); + const dragCounterRef = useRef(0); const isDark = theme === "dark"; const chartFont = isDark ? "#e6e8eb" : "#111827"; const chartGrid = isDark ? "#2c313a" : "#e5e7eb"; @@ -80,11 +140,11 @@ function PlotCard({ return buildTraces( signal, series, - plotStroke(signal, theme), + plotStroke(signal, theme, colorOverrides), rightSet.has(signal) ? "y2" : "y", ); }); - }, [group.signals, seriesBySignal, theme, rightSet]); + }, [group.signals, seriesBySignal, theme, colorOverrides, rightSet]); const hasRight = group.signals.some((s) => rightSet.has(s)); @@ -143,26 +203,84 @@ function PlotCard({ onRangeChange(next[0], next[1]); }; + // --- Drag-and-drop axis zone handlers --- + const hasMimeType = useCallback((e: React.DragEvent) => { + if (!e.dataTransfer || !e.dataTransfer.types) return false; + const types = Array.from(e.dataTransfer.types); + return types.includes(SIGNALS_MIME); + }, []); + + const handleCardDragEnter = useCallback( + (e: React.DragEvent) => { + if (!hasMimeType(e)) return; + e.preventDefault(); + dragCounterRef.current += 1; + setIsCardDragOver(true); + }, + [hasMimeType], + ); + + const handleCardDragLeave = useCallback(() => { + dragCounterRef.current -= 1; + if (dragCounterRef.current <= 0) { + dragCounterRef.current = 0; + setIsCardDragOver(false); + setAxisDragOver(null); + } + }, []); + + const handleAxisZoneDragOver = useCallback( + (e: React.DragEvent, axis: "left" | "right") => { + if (!hasMimeType(e)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setAxisDragOver(axis); + }, + [hasMimeType], + ); + + const handleAxisZoneDrop = useCallback( + (e: React.DragEvent, axis: "left" | "right") => { + e.preventDefault(); + dragCounterRef.current = 0; + setIsCardDragOver(false); + setAxisDragOver(null); + const signals = readSignalsPayload(e.dataTransfer); + if (!signals) return; + if (onAssignSignalsToAxis) { + onAssignSignalsToAxis(signals, group.id, axis); + } else { + onAssignSignals(signals, group.id); + } + }, + [group.id, onAssignSignals, onAssignSignalsToAxis], + ); + + const handleCardDrop = useCallback( + (e: React.DragEvent) => { + // Fallback: if the drop somehow misses both zones + e.preventDefault(); + dragCounterRef.current = 0; + setIsCardDragOver(false); + setAxisDragOver(null); + const signals = readSignalsPayload(e.dataTransfer); + if (signals) onAssignSignals(signals, group.id); + }, + [group.id, onAssignSignals], + ); + const points = totalPoints(group, seriesBySignal); + const showingAxisZones = isCardDragOver || axisDragOver !== null; return (
{ - if (e.dataTransfer.types.includes(SIGNALS_MIME)) { - e.preventDefault(); - setDragOver(true); - } - }} - onDragLeave={() => setDragOver(false)} - onDrop={(e) => { - e.preventDefault(); - setDragOver(false); - const signals = readSignalsPayload(e.dataTransfer); - if (signals) onAssignSignals(signals, group.id); - }} + onDragEnter={handleCardDragEnter} + onDragLeave={handleCardDragLeave} + onDragOver={(e) => { if (hasMimeType(e)) e.preventDefault(); }} + onDrop={handleCardDrop} >
@@ -170,17 +288,23 @@ function PlotCard({ const series = seriesBySignal[signal]; const mode = series ? series.mode : "none"; const onRight = rightSet.has(signal); + const color = plotStroke(signal, theme, colorOverrides); + const hasOverride = Boolean(colorOverrides?.[signal]); return ( - ); })} @@ -227,6 +361,28 @@ function PlotCard({ onRelayout={handleRelayout} /> )} + + {/* Semi-translucent axis drop zones — visible when dragging over */} + {showingAxisZones && ( + + )}
); @@ -282,10 +438,14 @@ export function AnalysisPlotStack({ layout, seriesBySignal, range, + colorOverrides, onRangeChange, onAssignSignals, + onAssignSignalsToAxis, onRemoveSignal, onToggleRightAxis, + onSetSignalColor, + onClearSignalColor, theme, }: AnalysisPlotStackProps) { if (layout.length === 0) { @@ -310,10 +470,14 @@ export function AnalysisPlotStack({ range={range} isBottom={index === layout.length - 1} theme={theme} + colorOverrides={colorOverrides} onRangeChange={onRangeChange} onAssignSignals={onAssignSignals} + onAssignSignalsToAxis={onAssignSignalsToAxis} onRemoveSignal={onRemoveSignal} onToggleRightAxis={onToggleRightAxis} + onSetSignalColor={onSetSignalColor} + onClearSignalColor={onClearSignalColor} /> ))} (() => { if (seededConfig) return plotsToLayout(seededConfig.plots); try { - return parseLayout(window.localStorage.getItem(layoutStorageKey(season.name))) ?? []; + const parsed = parseLayout(window.localStorage.getItem(layoutStorageKey(season.name))); + return parsed?.layout ?? []; } catch { return []; } }); + const [colorOverrides, setColorOverrides] = useState>(() => { + if (seededConfig?.colors) return { ...seededConfig.colors }; + try { + const parsed = parseLayout(window.localStorage.getItem(layoutStorageKey(season.name))); + return parsed?.colorOverrides ?? {}; + } catch { + return {}; + } + }); const seededRange = useMemo<[number, number] | null>(() => { if (!seededConfig) return null; const start = new Date(seededConfig.start).getTime(); @@ -152,11 +166,11 @@ export function AnalysisWorkspace({ // Persist on every layout change; storage failures must never break the UI. useEffect(() => { try { - window.localStorage.setItem(layoutStorageKey(seasonName), serializeLayout(plots)); + window.localStorage.setItem(layoutStorageKey(seasonName), serializeLayout(plots, colorOverrides)); } catch { // Ignore persistence failures in restricted environments. } - }, [plots, seasonName]); + }, [plots, colorOverrides, seasonName]); const knownSignals = useMemo(() => knownSignalsOf(grouped), [grouped]); @@ -194,6 +208,40 @@ export function AnalysisWorkspace({ setPlots((prev) => toggleRightAxis(prev, groupId, signal)); }, []); + const handleAssignSignalsToAxis = useCallback( + (signals: string[], target: string, axis: "left" | "right") => { + setPlots((prev) => { + const next = assignSignals(prev, signals, target); + if (axis === "right") { + // Find the target group after assignment and set each signal to right axis. + const targetGroup = next.find((g) => signals.some((s) => g.signals.includes(s))); + if (targetGroup) { + let result = next; + for (const signal of signals) { + if (!targetGroup.rightAxis.includes(signal)) { + result = toggleRightAxis(result, targetGroup.id, signal); + } + } + return result; + } + } + if (axis === "left") { + return clearRightAxisForSignals(next, signals); + } + return next; + }); + }, + [], + ); + + const handleSetSignalColor = useCallback((signal: string, color: string) => { + setColorOverrides((prev) => setSignalColor(prev, signal, color)); + }, []); + + const handleClearSignalColor = useCallback((signal: string) => { + setColorOverrides((prev) => clearSignalColor(prev, signal)); + }, []); + const handleRunChange = useCallback((runKey: string, startMs: number, endMs: number) => { setSelectedRunKey(runKey); setFullRange([startMs, endMs]); @@ -228,6 +276,7 @@ export function AnalysisWorkspace({ const start = new Date(config.start).getTime(); const end = new Date(config.end).getTime(); setPlots(plotsToLayout(config.plots)); + setColorOverrides(config.colors ?? {}); setSelectedRunKey(""); if (Number.isFinite(start) && Number.isFinite(end) && start < end) { setFullRange([start, end]); @@ -274,13 +323,14 @@ export function AnalysisWorkspace({ start: new Date(viewRange[0]).toISOString(), end: new Date(viewRange[1]).toISOString(), plots: layoutToPlots(plots), + colors: Object.keys(colorOverrides).length > 0 ? colorOverrides : undefined, }) .then(() => refreshConfigs()) .catch(() => { // Surface nothing destructive; the list simply will not gain the entry. }); }, - [viewRange, plots, seasonTable, refreshConfigs], + [viewRange, plots, colorOverrides, seasonTable, refreshConfigs], ); const handleDeleteConfig = useCallback( @@ -574,10 +624,14 @@ export function AnalysisWorkspace({ layout={plots} seriesBySignal={seriesBySignal} range={viewRange} + colorOverrides={colorOverrides} onRangeChange={handlePlotRangeChange} onAssignSignals={handleAssignSignals} + onAssignSignalsToAxis={handleAssignSignalsToAxis} onRemoveSignal={handleToggleSignal} onToggleRightAxis={handleToggleRightAxis} + onSetSignalColor={handleSetSignalColor} + onClearSignalColor={handleClearSignalColor} theme={theme} /> {refreshInFlight && ( @@ -603,10 +657,14 @@ export function AnalysisWorkspace({ layout={[]} seriesBySignal={seriesBySignal} range={viewRange} + colorOverrides={colorOverrides} onRangeChange={handlePlotRangeChange} onAssignSignals={handleAssignSignals} + onAssignSignalsToAxis={handleAssignSignalsToAxis} onRemoveSignal={handleToggleSignal} onToggleRightAxis={handleToggleRightAxis} + onSetSignalColor={handleSetSignalColor} + onClearSignalColor={handleClearSignalColor} theme={theme} /> diff --git a/server/installer/data-downloader/frontend/src/components/sensor-palette.ts b/server/installer/data-downloader/frontend/src/components/sensor-palette.ts index cb5e600..d1b46b8 100644 --- a/server/installer/data-downloader/frontend/src/components/sensor-palette.ts +++ b/server/installer/data-downloader/frontend/src/components/sensor-palette.ts @@ -70,7 +70,15 @@ export function subsystemColor(subsystem: string, theme: "light" | "dark"): Pale return PALETTE[paletteIndex(subsystem)][theme]; } +/** Per-signal color overrides chosen by the user (signal name → hex). */ +export type SignalColorOverrides = Record; + /** Stroke color for plot traces, sharing the subsystem hash and palette borders. */ -export function plotStroke(name: string, theme: "light" | "dark"): string { +export function plotStroke( + name: string, + theme: "light" | "dark", + overrides?: SignalColorOverrides, +): string { + if (overrides?.[name]) return overrides[name]; return PALETTE[paletteIndex(name)][theme].border; } diff --git a/server/installer/data-downloader/frontend/src/styles.css b/server/installer/data-downloader/frontend/src/styles.css index 73aa192..d388e04 100644 --- a/server/installer/data-downloader/frontend/src/styles.css +++ b/server/installer/data-downloader/frontend/src/styles.css @@ -949,6 +949,18 @@ body { width: 0.55rem; height: 0.55rem; border-radius: 2px; + cursor: pointer; + border: none; + padding: 0; + transition: box-shadow 0.15s ease; +} + +.analysis-legend-swatch:hover { + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.35); +} + +.analysis-legend-swatch.has-override { + box-shadow: 0 0 0 1.5px var(--text-muted); } .analysis-legend-axis, @@ -982,6 +994,165 @@ body { background: var(--surface-2); } +/* ── Axis drag-and-drop overlay zones ── */ +.analysis-plot-body { + position: relative; +} + +.analysis-axis-zones { + position: absolute; + inset: 0; + display: flex; + z-index: 8; + pointer-events: auto; + border-radius: 8px; + overflow: hidden; +} + +.analysis-axis-zone { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s ease, backdrop-filter 0.15s ease; + cursor: copy; +} + +.analysis-axis-zone--left { + background: rgba(37, 99, 235, 0.08); + border-right: 1px dashed rgba(37, 99, 235, 0.25); +} + +.analysis-axis-zone--right { + background: rgba(139, 92, 246, 0.08); + border-left: 1px dashed rgba(139, 92, 246, 0.25); +} + +.analysis-axis-zone--left.is-active { + background: rgba(37, 99, 235, 0.18); + backdrop-filter: blur(2px); +} + +.analysis-axis-zone--right.is-active { + background: rgba(139, 92, 246, 0.18); + backdrop-filter: blur(2px); +} + +.analysis-axis-zone-label { + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.03em; + padding: 0.35rem 0.75rem; + border-radius: 6px; + pointer-events: none; + user-select: none; +} + +.analysis-axis-zone--left .analysis-axis-zone-label { + background: rgba(37, 99, 235, 0.12); + color: #2563eb; +} + +.analysis-axis-zone--right .analysis-axis-zone-label { + background: rgba(139, 92, 246, 0.12); + color: #8b5cf6; +} + +.analysis-axis-zone--left.is-active .analysis-axis-zone-label { + background: rgba(37, 99, 235, 0.22); +} + +.analysis-axis-zone--right.is-active .analysis-axis-zone-label { + background: rgba(139, 92, 246, 0.22); +} + +[data-theme="dark"] .analysis-axis-zone--left .analysis-axis-zone-label { + color: #93c5fd; + background: rgba(96, 165, 250, 0.15); +} + +[data-theme="dark"] .analysis-axis-zone--right .analysis-axis-zone-label { + color: #c4b5fd; + background: rgba(167, 139, 250, 0.15); +} + +[data-theme="dark"] .analysis-axis-zone--left.is-active .analysis-axis-zone-label { + background: rgba(96, 165, 250, 0.28); +} + +[data-theme="dark"] .analysis-axis-zone--right.is-active .analysis-axis-zone-label { + background: rgba(167, 139, 250, 0.28); +} + +.analysis-plot-card.is-axis-drag { + border-color: var(--accent, #2563eb); + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15); +} + +/* ── Legend swatch color picker ── */ +.analysis-legend-chip { + position: relative; +} + +.analysis-color-picker { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 20; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.55rem; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: 8px; + box-shadow: var(--shadow-card); + white-space: nowrap; +} + +.analysis-color-picker-label { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.72rem; + color: var(--text-muted); + cursor: pointer; +} + +.analysis-color-input { + width: 1.4rem; + height: 1.4rem; + border: none; + border-radius: 4px; + padding: 0; + cursor: pointer; + background: transparent; +} + +.analysis-color-input::-webkit-color-swatch-wrapper { + padding: 0; +} + +.analysis-color-input::-webkit-color-swatch { + border: 1px solid var(--border-strong); + border-radius: 3px; +} + +.analysis-color-reset { + border: none; + background: transparent; + font-size: 0.7rem; + color: var(--text-muted); + cursor: pointer; + padding: 0.15rem 0.3rem; + border-radius: 4px; +} + +.analysis-color-reset:hover { + background: var(--surface-2); + color: var(--text); +} + .analysis-signal-item { display: flex; flex-wrap: wrap; diff --git a/server/installer/data-downloader/frontend/src/types.ts b/server/installer/data-downloader/frontend/src/types.ts index b7f9969..7f811e1 100644 --- a/server/installer/data-downloader/frontend/src/types.ts +++ b/server/installer/data-downloader/frontend/src/types.ts @@ -151,6 +151,7 @@ export interface SavedConfig { start: string; end: string; plots: SavedConfigPlot[]; + colors?: Record; created_at: string; updated_at: string; } @@ -163,4 +164,5 @@ export interface CreateConfigPayload { start: string; end: string; plots: SavedConfigPlot[]; + colors?: Record; }