// Visual components for the dispatch frontend. // // The map is plain SVG (no external graph lib) — sufficient for ~10 nodes and // gives us full control over courier animations. Couriers are rendered as // foreign objects whose (x, y) interpolates between their previous and target // node when in transit, driven by `eta_remaining` and `step_progress`. import React from 'react'; import { STATUS_COLORS, COURIER_STATUS_LABELS } from './fixtures.js'; // Helper: find a node by id const nodeById = (nodes, id) => nodes.find((n) => n.id === id); // Distribute couriers so they don't stack on top of each other when several // share a location (e.g. all 5 couriers at the hub at t=0, or two heading to // the same store). Returns a Map. // // Two cluster types: // - "node": multiple couriers at the same node -> fan out in a circle // - "edge": multiple couriers traversing the same edge -> fan out perpendicular // to the edge direction (so they look like a row of bikes) // // Couriers in transit are rendered slightly past the midpoint of their edge // (t=0.55) so the eye reads them as "on the way" rather than at either endpoint. function distributeCouriers(couriers, nodes) { const positions = new Map(); const groups = new Map(); for (const c of couriers) { const here = nodeById(nodes, c.node_id); if (!here) continue; let entry; if (c.target_node_id && c.eta_remaining > 0 && c.status !== 'idle') { const there = nodeById(nodes, c.target_node_id); if (there) { const t = 0.55; const centerX = here.x + (there.x - here.x) * t; const centerY = here.y + (there.y - here.y) * t; const dx = there.x - here.x; const dy = there.y - here.y; const len = Math.hypot(dx, dy) || 1; const normal = { x: -dy / len, y: dx / len }; entry = { key: `edge:${c.node_id}->${c.target_node_id}`, type: 'edge', centerX, centerY, normal, }; } } if (!entry) { entry = { key: `node:${c.node_id}`, type: 'node', centerX: here.x, centerY: here.y, }; } if (!groups.has(entry.key)) groups.set(entry.key, { ...entry, members: [] }); groups.get(entry.key).members.push(c); } for (const group of groups.values()) { const { centerX, centerY, members, type } = group; if (members.length === 1) { positions.set(members[0].id, { x: centerX, y: centerY }); continue; } if (type === 'edge') { const spacing = 18; members.forEach((c, i) => { const offset = (i - (members.length - 1) / 2) * spacing; positions.set(c.id, { x: centerX + offset * group.normal.x, y: centerY + offset * group.normal.y, }); }); } else { // node cluster: fan around the node center const radius = 26; members.forEach((c, i) => { const angle = (2 * Math.PI * i) / members.length - Math.PI / 2; positions.set(c.id, { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle), }); }); } } return positions; } // ─────────────────────────────────────────────────────────────── // Map // ─────────────────────────────────────────────────────────────── export function ScenarioMap({ nodes, snapshot, dimmed }) { if (!snapshot) return null; const { couriers, orders } = snapshot; // For every active assignment, draw a thin link from courier to its target. const courierLinks = couriers .filter((c) => c.target_node_id && c.eta_remaining > 0) .map((c) => ({ from: nodeById(nodes, c.node_id), to: nodeById(nodes, c.target_node_id), courier: c, })) .filter((l) => l.from && l.to); return ( {/* subtle dotted background */} {/* base edges: lighter lines from hub to every store and store to nearest customers */} {nodes.filter((n) => n.kind === 'pickup').map((store) => { const hub = nodeById(nodes, 'hub'); return ( ); })} {/* live courier links (overlay) */} {courierLinks.map((l, i) => ( ))} {/* nodes */} {nodes.map((n) => )} {/* couriers (rendered last so they sit on top) */} {(() => { const positions = distributeCouriers(couriers, nodes); return couriers.map((c) => { const pos = positions.get(c.id) || { x: 0, y: 0 }; return ; }); })()} ); } function NodeIcon({ node, orders }) { // Glyph + color per kind let glyph = '⭐'; let fill = '#7C8DB5'; if (node.kind === 'pickup') { glyph = '🍔'; fill = '#F0B541'; } if (node.kind === 'dropoff') { glyph = '🏠'; fill = '#7CC082'; } if (node.kind === 'hub') { glyph = '🏢'; fill = '#7C8DB5'; } // For dropoff: highlight if customer has an active order with deadline pressure const orderHere = orders?.find((o) => o.dropoff_node_id === node.id && o.status !== 'delivered' && o.status !== 'expired'); const isUrgent = orderHere && (orderHere.deadline_tick - (orderHere.delivered_tick ?? 0)) < 5; return ( {glyph} {node.label} ); } function CourierIcon({ courier, x, y, orders }) { const carrying = courier.load && orders.find((o) => o.id === courier.load); const carryColor = carrying ? STATUS_COLORS.picked : '#5DA9F0'; const isMoving = courier.status !== 'idle' && courier.target_node_id; return ( {/* glow ring when active */} {isMoving && } 🛵 {courier.id.replace('courier_', 'C')} {courier.eta_remaining > 0 && ( eta {courier.eta_remaining} )} ); } // ─────────────────────────────────────────────────────────────── // Orders panel // ─────────────────────────────────────────────────────────────── export function OrdersPanel({ snapshot, maxTicks }) { if (!snapshot) return null; const orders = [...snapshot.orders].sort((a, b) => { // sort: in-progress first, then by deadline const order = { ready: 0, queued: 1, picked: 2, delivered: 3, expired: 4 }; return (order[a.status] - order[b.status]) || (a.deadline_tick - b.deadline_tick); }); return (
orders{orders.filter(o => o.status === 'delivered').length}/{orders.length} delivered
{orders.map((o) => )}
); } function OrderCard({ order, tick, maxTicks }) { const ticksLeft = order.deadline_tick - tick; const isUrgent = order.status !== 'delivered' && order.status !== 'expired' && ticksLeft <= 3; return (
{order.id} {order.kind} {order.status}
{order.pickup_node_id} → {order.dropoff_node_id}
{order.status === 'delivered' ? ( ✓ at t{order.delivered_tick} ) : order.status === 'expired' ? ( ✗ expired ) : ( ⏱ {ticksLeft}t left )} {order.assigned_courier_id ? `→ ${order.assigned_courier_id.replace('courier_', 'C')}` : '—'}
); } // ─────────────────────────────────────────────────────────────── // Agent feed // ─────────────────────────────────────────────────────────────── export function AgentFeed({ trajectory, currentTick, onJumpToTick }) { return (
agent feed
{trajectory.slice(0, currentTick + 1).reverse().map((step, idx) => ( onJumpToTick(step.tick)} /> ))}
); } function FeedRow({ step, isCurrent, onClick }) { const a = step.action; let label, args = ''; if (!a) { label = '(reset)'; } else if (a.type === 'assign') { label = 'assign'; args = `${a.courier_id} ← ${a.order_id}`; } else if (a.type === 'reposition') { label = 'reposition'; args = `${a.courier_id} → ${a.node_id}`; } else if (a.type === 'hold') { label = 'hold'; } else if (a.type === 'prioritize') { label = 'prioritize'; args = a.order_id; } else if (a.type === 'view_dashboard') { label = 'view_dashboard'; } else if (a.type === 'finish_shift') { label = 'finish_shift'; } else { label = a.type || 'unknown'; } return (
t{step.tick} {label} {args} {step.events && step.events.length > 0 && ( {step.events[0]} )}
); } // ─────────────────────────────────────────────────────────────── // Reward panel // ─────────────────────────────────────────────────────────────── const REWARD_KEYS = [ ['total', 'total reward', '#5DA9F0'], ['success', 'delivered', '#7CC082'], ['on_time', 'on-time bonus', '#7CC082'], ['progress', 'progress', '#5DA9F0'], ['step_cost', 'step cost', '#a4afc7'], ['idle', 'idle', '#a4afc7'], ['invalid', 'invalid', '#E07560'], ['late', 'late', '#E07560'], ['churn', 'churn', '#a4afc7'], ['fairness', 'fairness', '#a4afc7'], ]; export function RewardPanel({ snapshot, cumulative, showRewards = true }) { if (!snapshot) return null; if (!showRewards) { return (
reward
Reward details stay hidden until you start the replay with step, play, scrub, or reset.
); } const rb = snapshot.reward_breakdown || {}; // For visualization, compute cumulative for each component (mock has only this-step values). // The bar width is proportional to abs(value), and bars are colored by sign. const maxAbs = Math.max(0.5, ...REWARD_KEYS.map(([k]) => Math.abs(rb[k] || 0))); return (
reward total: {cumulative >= 0 ? '+' : ''}{cumulative.toFixed(2)}
{REWARD_KEYS.map(([key, label, color]) => { const val = rb[key] ?? 0; const widthPct = (Math.abs(val) / maxAbs) * 100; const isNeg = val < 0; return (
{label} {val > 0 ? '+' : ''}{val.toFixed(2)}
); })}
); } // ─────────────────────────────────────────────────────────────── // Top control bar // ─────────────────────────────────────────────────────────────── export function ControlBar({ scenarios, scenarioId, onScenarioChange, modes, modeId, onModeChange, isPlaying, onPlayToggle, onStepBack, onStepForward, onReset, }) { return (
Dispatch Arena
); } function formatActionLabel(action) { if (!action) return 'Waiting for the shift to begin'; const type = action.type || action.action_type || 'unknown'; if (type === 'assign') { return `Assign ${shortCourier(action.courier_id)} to ${shortOrder(action.order_id)}`; } if (type === 'reposition') { return `Reposition ${shortCourier(action.courier_id)} to ${action.node_id}`; } if (type === 'prioritize') { return action.order_id ? `Prioritize ${shortOrder(action.order_id)}` : 'Prioritize the backlog'; } if (type === 'hold') return 'Hold and wait for more information'; if (type === 'finish_shift') return 'Finish the shift'; if (type === 'go_pickup') return 'Head to pickup'; if (type === 'pickup') return 'Pick up the order'; if (type === 'go_dropoff') return 'Head to dropoff'; if (type === 'dropoff') return 'Deliver the order'; return type.replace(/_/g, ' '); } function shortCourier(courierId) { return courierId ? courierId.replace('courier_', 'C') : 'a courier'; } function shortOrder(orderId) { return orderId ? orderId.replace('order_', 'O') : 'an order'; } export function ActionBanner({ trajectory, currentTick, playbackPhase, isPlaying }) { const currentStep = trajectory[currentTick]; const nextStep = currentTick < trajectory.length - 1 ? trajectory[currentTick + 1] : null; let status = 'Ready'; let actionText = currentStep?.action ? formatActionLabel(currentStep.action) : 'Choose play to start the replay'; let subtext = currentStep?.events?.[0] || 'Manual stepping is available below.'; if (currentTick >= trajectory.length - 1) { status = 'Shift complete'; actionText = currentStep?.events?.[0] || 'No more actions left in this replay'; subtext = `Final cumulative reward: ${(currentStep?.cumulative_reward ?? 0).toFixed(2)}`; } else if (isPlaying && playbackPhase === 'thinking') { status = 'Agent is thinking'; actionText = nextStep?.action ? formatActionLabel(nextStep.action) : 'Waiting for the next move'; subtext = 'Reading the current state and planning the next action...'; } else if (isPlaying && playbackPhase === 'acting') { status = 'Action taken'; actionText = nextStep?.action ? formatActionLabel(nextStep.action) : 'Waiting for the next move'; subtext = nextStep?.events?.[0] || 'Applying the action to the simulator.'; } else if (currentStep?.action) { status = 'Last action'; } else if (nextStep?.action) { status = 'Next action'; actionText = formatActionLabel(nextStep.action); subtext = nextStep?.events?.[0] || 'Use play to watch the next step.'; } return (
{status}
{actionText}
{subtext}
); } // ─────────────────────────────────────────────────────────────── // Tick scrubber (timeline at the bottom) // ─────────────────────────────────────────────────────────────── export function TickScrubber({ trajectory, currentTick, onTickChange, maxTicks }) { const totalLen = trajectory.length; return (
tick {currentTick} / {maxTicks} {trajectory[currentTick]?.events?.[0] || ''}
onTickChange(parseInt(e.target.value, 10))} className="tick-slider" />
); } // ─────────────────────────────────────────────────────────────── // Final summary card (shown at end of episode) // ─────────────────────────────────────────────────────────────── export function FinalCard({ trajectory, scenario }) { const last = trajectory[trajectory.length - 1]; const orders = last.orders; const delivered = orders.filter((o) => o.status === 'delivered').length; const onTime = orders.filter((o) => o.status === 'delivered' && (o.delivered_tick ?? 99) <= o.deadline_tick).length; const expired = orders.filter((o) => o.status === 'expired').length; const successRate = (delivered / orders.length) * 100; return (
shift complete · {scenario.metadata.theme}
= 80 ? 'good' : successRate >= 50 ? 'mid' : 'bad'} /> 0 ? 'bad' : 'good'} /> 0 ? 'good' : 'bad'} />
); } function Stat({ label, value, accent }) { return (
{value}
{label}
); }