From f4db90a989f308e7bad8172d743f80d17b4d9453 Mon Sep 17 00:00:00 2001 From: sinohqb Date: Fri, 31 Jul 2026 16:57:42 +0800 Subject: [PATCH] feat(campaign): add shared read-only WindowTimeline component A domain-agnostic horizontal timeline: an axis over the service-cycle window with offset-positioned markers, stable per-colorKey colours, optional badges, hover tooltips, click callbacks, and an optional legend. Shared base for the plan preview (09) and process timeline (10). (v0.6 ticket 08) --- .../web/src/components/WindowTimeline.tsx | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 frontend/web/src/components/WindowTimeline.tsx diff --git a/frontend/web/src/components/WindowTimeline.tsx b/frontend/web/src/components/WindowTimeline.tsx new file mode 100644 index 0000000..91a20ca --- /dev/null +++ b/frontend/web/src/components/WindowTimeline.tsx @@ -0,0 +1,122 @@ +import { Tooltip } from 'antd' +import type { ReactNode } from 'react' +import { colors, fontSizes } from '../tokens' + +// A read-only horizontal timeline: one axis representing a service-cycle window, +// with markers positioned by their window offset. It is domain-agnostic — the +// ②计划预览 and ④过程时间轴 callers each adapt their data into markers. Colours +// are assigned stably per `colorKey` (same key → same colour, order-independent). + +export interface TimelineMarker { + key: string + offsetSeconds: number + colorKey: string + label?: string + badge?: number | string + tooltip: ReactNode + onClick?: () => void +} + +interface WindowTimelineProps { + windowSeconds: number + markers: TimelineMarker[] + /** colorKey → legend display name; falls back to the raw key. */ + colorLabels?: Record + showLegend?: boolean + height?: number +} + +const PALETTE = [ + '#1677ff', '#52c41a', '#fa8c16', '#722ed1', + '#13c2c2', '#eb2f96', '#faad14', '#2f54eb', +] + +const TICK_COUNT = 6 + +function fmtTick(seconds: number): string { + if (seconds < 3600) return `${Math.round(seconds / 60)}m` + const h = seconds / 3600 + return `${Number.isInteger(h) ? h : h.toFixed(1)}h` +} + +export default function WindowTimeline({ + windowSeconds, markers, colorLabels, showLegend = false, height = 64, +}: WindowTimelineProps) { + const window = windowSeconds > 0 ? windowSeconds : 1 + const uniqueKeys = Array.from(new Set(markers.map((m) => m.colorKey))).sort() + const colorOf = (key: string) => PALETTE[Math.max(0, uniqueKeys.indexOf(key)) % PALETTE.length] + const leftPct = (offset: number) => `${Math.min(1, Math.max(0, offset / window)) * 100}%` + + const ticks = Array.from({ length: TICK_COUNT + 1 }, (_, i) => i / TICK_COUNT) + + return ( +
+
+
+ + {ticks.map((t) => ( +
+
+ + {fmtTick(t * window)} + +
+ ))} + + {markers.map((m) => { + const c = colorOf(m.colorKey) + return ( + +
+ {m.label != null ? ( + + {m.label} + {m.badge != null && {m.badge}} + + ) : ( + + )} +
+
+ ) + })} +
+ + {showLegend && uniqueKeys.length > 0 && ( +
+ {uniqueKeys.map((k) => ( + + + {colorLabels?.[k] ?? k} + + ))} +
+ )} +
+ ) +}