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)
This commit is contained in:
sinohqb 2026-07-31 16:57:42 +08:00
parent 0e1ab75d77
commit f4db90a989

View File

@ -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<string, string>
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 (
<div>
<div style={{ position: 'relative', height, margin: '0 12px' }}>
<div style={{
position: 'absolute', left: 0, right: 0, top: '50%',
height: 2, background: colors.border,
}} />
{ticks.map((t) => (
<div key={t} style={{ position: 'absolute', left: `${t * 100}%`, top: 0, bottom: 0 }}>
<div style={{
position: 'absolute', top: '50%', width: 1, height: 8,
marginTop: -4, background: colors.textMuted,
}} />
<span style={{
position: 'absolute', top: '50%', marginTop: 8, transform: 'translateX(-50%)',
fontSize: fontSizes.meta, color: colors.textMuted, whiteSpace: 'nowrap',
}}>
{fmtTick(t * window)}
</span>
</div>
))}
{markers.map((m) => {
const c = colorOf(m.colorKey)
return (
<Tooltip key={m.key} title={m.tooltip}>
<div
onClick={m.onClick}
style={{
position: 'absolute', left: leftPct(m.offsetSeconds), top: '50%',
transform: 'translate(-50%, -50%)',
cursor: m.onClick ? 'pointer' : 'default',
}}
>
{m.label != null ? (
<span style={{
padding: '1px 6px', borderRadius: 10, background: c, color: '#fff',
fontSize: fontSizes.meta, whiteSpace: 'nowrap',
}}>
{m.label}
{m.badge != null && <b style={{ marginLeft: 4 }}>{m.badge}</b>}
</span>
) : (
<span style={{
display: 'block', width: 12, height: 12, borderRadius: '50%',
background: c, border: '2px solid #fff', boxShadow: '0 0 0 1px rgba(0,0,0,0.1)',
}} />
)}
</div>
</Tooltip>
)
})}
</div>
{showLegend && uniqueKeys.length > 0 && (
<div style={{
display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 24,
fontSize: fontSizes.meta, color: colors.textSecondary,
}}>
{uniqueKeys.map((k) => (
<span key={k} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span style={{ width: 10, height: 10, borderRadius: 2, background: colorOf(k) }} />
{colorLabels?.[k] ?? k}
</span>
))}
</div>
)}
</div>
)
}