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:
parent
0e1ab75d77
commit
f4db90a989
122
frontend/web/src/components/WindowTimeline.tsx
Normal file
122
frontend/web/src/components/WindowTimeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user