feat(campaign): stagger preview markers and cap plan start at window

Plan preview markers are plain colour blocks (names live in the
tooltip and legend); blocks that would overlap spread onto staggered
rows. Plan start time is limited to the window length via InputNumber
max plus a validator, re-checked immediately when the window shrinks.
This commit is contained in:
sinohqb 2026-08-02 01:49:25 +08:00
parent 3aac5e8068
commit 76ff184cae
2 changed files with 60 additions and 9 deletions

View File

@ -26,6 +26,8 @@ interface WindowTimelineProps {
colorLabels?: Record<string, string> colorLabels?: Record<string, string>
showLegend?: boolean showLegend?: boolean
height?: number height?: number
/** Spread markers that would overlap onto separate vertical rows. */
stagger?: boolean
} }
const PALETTE = [ const PALETTE = [
@ -35,6 +37,10 @@ const PALETTE = [
const TICK_COUNT = 6 const TICK_COUNT = 6
/** Markers closer than this fraction of the window collide and need their own row. */
const STAGGER_GAP = 0.04
const STAGGER_ROW_PX = 16
export function fmtTick(seconds: number): string { export function fmtTick(seconds: number): string {
if (seconds < 3600) return `${Math.round(seconds / 60)}m` if (seconds < 3600) return `${Math.round(seconds / 60)}m`
const h = seconds / 3600 const h = seconds / 3600
@ -42,7 +48,7 @@ export function fmtTick(seconds: number): string {
} }
export default function WindowTimeline({ export default function WindowTimeline({
windowSeconds, markers, colorMap, colorLabels, showLegend = false, height = 64, windowSeconds, markers, colorMap, colorLabels, showLegend = false, height = 64, stagger = false,
}: WindowTimelineProps) { }: WindowTimelineProps) {
const window = windowSeconds > 0 ? windowSeconds : 1 const window = windowSeconds > 0 ? windowSeconds : 1
const uniqueKeys = Array.from(new Set(markers.map((m) => m.colorKey))).sort() const uniqueKeys = Array.from(new Set(markers.map((m) => m.colorKey))).sort()
@ -50,11 +56,33 @@ export default function WindowTimeline({
colorMap?.[key] ?? PALETTE[Math.max(0, uniqueKeys.indexOf(key)) % PALETTE.length] colorMap?.[key] ?? PALETTE[Math.max(0, uniqueKeys.indexOf(key)) % PALETTE.length]
const leftPct = (offset: number) => `${Math.min(1, Math.max(0, offset / window)) * 100}%` const leftPct = (offset: number) => `${Math.min(1, Math.max(0, offset / window)) * 100}%`
// Greedy row assignment in offset order: a marker joins the first row whose
// previous marker is far enough away, otherwise it opens a new row.
const slotOf: number[] = markers.map(() => 0)
let rowCount = 1
if (stagger) {
const lastEnd: number[] = []
const order = markers.map((_, i) => i).sort((a, b) => markers[a].offsetSeconds - markers[b].offsetSeconds)
for (const i of order) {
const off = markers[i].offsetSeconds
let s = lastEnd.findIndex((last) => off - last >= STAGGER_GAP * window)
if (s === -1) {
s = lastEnd.length
lastEnd.push(0)
}
lastEnd[s] = off
slotOf[i] = s
}
rowCount = Math.max(1, lastEnd.length)
}
const rowY = (slot: number) => (slot - (rowCount - 1) / 2) * STAGGER_ROW_PX
const trackHeight = rowCount > 1 ? 24 + rowCount * STAGGER_ROW_PX : height
const ticks = Array.from({ length: TICK_COUNT + 1 }, (_, i) => i / TICK_COUNT) const ticks = Array.from({ length: TICK_COUNT + 1 }, (_, i) => i / TICK_COUNT)
return ( return (
<div> <div>
<div style={{ position: 'relative', height, margin: '0 12px' }}> <div style={{ position: 'relative', height: trackHeight, margin: '0 12px' }}>
<div style={{ <div style={{
position: 'absolute', left: 0, right: 0, top: '50%', position: 'absolute', left: 0, right: 0, top: '50%',
height: 2, background: colors.border, height: 2, background: colors.border,
@ -75,7 +103,7 @@ export default function WindowTimeline({
</div> </div>
))} ))}
{markers.map((m) => { {markers.map((m, idx) => {
const c = colorOf(m.colorKey) const c = colorOf(m.colorKey)
return ( return (
<Tooltip key={m.key} title={m.tooltip}> <Tooltip key={m.key} title={m.tooltip}>
@ -83,7 +111,7 @@ export default function WindowTimeline({
onClick={m.onClick} onClick={m.onClick}
style={{ style={{
position: 'absolute', left: leftPct(m.offsetSeconds), top: '50%', position: 'absolute', left: leftPct(m.offsetSeconds), top: '50%',
transform: 'translate(-50%, -50%)', transform: `translate(-50%, -50%)${stagger ? ` translateY(${rowY(slotOf[idx])}px)` : ''}`,
cursor: m.onClick ? 'pointer' : 'default', cursor: m.onClick ? 'pointer' : 'default',
}} }}
> >

View File

@ -130,8 +130,6 @@ export default function CampaignsPage() {
key: `plan-${i}`, key: `plan-${i}`,
offsetSeconds: offsetHours * 3600, offsetSeconds: offsetHours * 3600,
colorKey: e.scenario_id as string, colorKey: e.scenario_id as string,
label: name,
badge: count > 1 ? `×${count}` : undefined,
tooltip: `${name} · +${offsetHours}h · ${count}`, tooltip: `${name} · +${offsetHours}h · ${count}`,
} }
}) })
@ -455,7 +453,15 @@ export default function CampaignsPage() {
tooltip="服务对象的一个完整服务周期(如早 8 点到次日早 8 点 = 24 小时),所有计划条目都落在窗口内" tooltip="服务对象的一个完整服务周期(如早 8 点到次日早 8 点 = 24 小时),所有计划条目都落在窗口内"
rules={[{ required: true }]} rules={[{ required: true }]}
> >
<Select options={WINDOW_OPTIONS} /> <Select
options={WINDOW_OPTIONS}
onChange={() => {
// Shrinking the window can invalidate existing plan offsets —
// re-validate them so the errors surface immediately.
const plan = (form.getFieldValue('plan') as PlanFormEntry[] | undefined) ?? []
form.validateFields(plan.map((_, i) => ['plan', i, 'offset_hours'])).catch(() => {})
}}
/>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="realtime" name="realtime"
@ -518,6 +524,7 @@ export default function CampaignsPage() {
markers={planMarkers} markers={planMarkers}
colorLabels={scenarioNames} colorLabels={scenarioNames}
showLegend showLegend
stagger
/> />
) : ( ) : (
<div style={{ textAlign: 'center', color: colors.textMuted, padding: '12px 0' }}> <div style={{ textAlign: 'center', color: colors.textMuted, padding: '12px 0' }}>
@ -559,8 +566,24 @@ export default function CampaignsPage() {
options={scenarios.map((s) => ({ label: s.name, value: s.id }))} options={scenarios.map((s) => ({ label: s.name, value: s.id }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name={[field.name, 'offset_hours']} style={{ width: 140, marginBottom: 0 }}> <Form.Item
<InputNumber min={0} step={0.5} addonAfter="小时后" style={{ width: '100%' }} /> name={[field.name, 'offset_hours']}
style={{ width: 140, marginBottom: 0 }}
rules={[{
validator: async (_, v: number | undefined) => {
if (v == null) return
const win = (form.getFieldValue('window_seconds') as number | undefined) ?? 0
if (win > 0 && v * 3600 > win) {
throw new Error(`不能超过窗口长度(${fmtWindow(win)}`)
}
},
}]}
>
<InputNumber
min={0} step={0.5} addonAfter="小时后"
max={wWindow > 0 ? wWindow / 3600 : undefined}
style={{ width: '100%' }}
/>
</Form.Item> </Form.Item>
<Form.Item name={[field.name, 'count']} style={{ width: 104, marginBottom: 0 }}> <Form.Item name={[field.name, 'count']} style={{ width: 104, marginBottom: 0 }}>
<InputNumber min={1} addonAfter="次" style={{ width: '100%' }} /> <InputNumber min={1} addonAfter="次" style={{ width: '100%' }} />