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:
parent
3aac5e8068
commit
76ff184cae
@ -26,6 +26,8 @@ interface WindowTimelineProps {
|
||||
colorLabels?: Record<string, string>
|
||||
showLegend?: boolean
|
||||
height?: number
|
||||
/** Spread markers that would overlap onto separate vertical rows. */
|
||||
stagger?: boolean
|
||||
}
|
||||
|
||||
const PALETTE = [
|
||||
@ -35,6 +37,10 @@ const PALETTE = [
|
||||
|
||||
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 {
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)}m`
|
||||
const h = seconds / 3600
|
||||
@ -42,7 +48,7 @@ export function fmtTick(seconds: number): string {
|
||||
}
|
||||
|
||||
export default function WindowTimeline({
|
||||
windowSeconds, markers, colorMap, colorLabels, showLegend = false, height = 64,
|
||||
windowSeconds, markers, colorMap, colorLabels, showLegend = false, height = 64, stagger = false,
|
||||
}: WindowTimelineProps) {
|
||||
const window = windowSeconds > 0 ? windowSeconds : 1
|
||||
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]
|
||||
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)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ position: 'relative', height, margin: '0 12px' }}>
|
||||
<div style={{ position: 'relative', height: trackHeight, margin: '0 12px' }}>
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, right: 0, top: '50%',
|
||||
height: 2, background: colors.border,
|
||||
@ -75,7 +103,7 @@ export default function WindowTimeline({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{markers.map((m) => {
|
||||
{markers.map((m, idx) => {
|
||||
const c = colorOf(m.colorKey)
|
||||
return (
|
||||
<Tooltip key={m.key} title={m.tooltip}>
|
||||
@ -83,7 +111,7 @@ export default function WindowTimeline({
|
||||
onClick={m.onClick}
|
||||
style={{
|
||||
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',
|
||||
}}
|
||||
>
|
||||
|
||||
@ -130,8 +130,6 @@ export default function CampaignsPage() {
|
||||
key: `plan-${i}`,
|
||||
offsetSeconds: offsetHours * 3600,
|
||||
colorKey: e.scenario_id as string,
|
||||
label: name,
|
||||
badge: count > 1 ? `×${count}` : undefined,
|
||||
tooltip: `${name} · +${offsetHours}h · ${count} 次`,
|
||||
}
|
||||
})
|
||||
@ -455,7 +453,15 @@ export default function CampaignsPage() {
|
||||
tooltip="服务对象的一个完整服务周期(如早 8 点到次日早 8 点 = 24 小时),所有计划条目都落在窗口内"
|
||||
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
|
||||
name="realtime"
|
||||
@ -518,6 +524,7 @@ export default function CampaignsPage() {
|
||||
markers={planMarkers}
|
||||
colorLabels={scenarioNames}
|
||||
showLegend
|
||||
stagger
|
||||
/>
|
||||
) : (
|
||||
<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 }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'offset_hours']} style={{ width: 140, marginBottom: 0 }}>
|
||||
<InputNumber min={0} step={0.5} addonAfter="小时后" style={{ width: '100%' }} />
|
||||
<Form.Item
|
||||
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 name={[field.name, 'count']} style={{ width: 104, marginBottom: 0 }}>
|
||||
<InputNumber min={1} addonAfter="次" style={{ width: '100%' }} />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user