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>
|
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',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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%' }} />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user