dispatch_arena_v0 / frontend /src /components.jsx
Freakdivi's picture
Upload folder using huggingface_hub
95e8836 verified
// 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<courier_id, {x, y}>.
//
// 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 (
<svg viewBox="0 0 900 520" className={`map-svg${dimmed ? ' dimmed' : ''}`}>
{/* subtle dotted background */}
<defs>
<pattern id="dots" x="0" y="0" width="24" height="24" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="1" fill="#2a3142" opacity="0.7" />
</pattern>
</defs>
<rect width="900" height="520" fill="url(#dots)" />
{/* 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 (
<line key={`hub-${store.id}`}
x1={hub.x} y1={hub.y} x2={store.x} y2={store.y}
stroke="#2c3548" strokeWidth="1" strokeDasharray="3 4" />
);
})}
{/* live courier links (overlay) */}
{courierLinks.map((l, i) => (
<line key={`link-${i}`}
x1={l.from.x} y1={l.from.y} x2={l.to.x} y2={l.to.y}
stroke="#5DA9F0" strokeOpacity="0.6" strokeWidth="2" strokeDasharray="6 4">
<animate attributeName="stroke-dashoffset" from="0" to="-20" dur="1s" repeatCount="indefinite" />
</line>
))}
{/* nodes */}
{nodes.map((n) => <NodeIcon key={n.id} node={n} orders={orders} />)}
{/* 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 <CourierIcon key={c.id} courier={c} x={pos.x} y={pos.y} orders={orders} />;
});
})()}
</svg>
);
}
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 (
<g className="node-group" transform={`translate(${node.x}, ${node.y})`}>
<circle r={isUrgent ? 22 : 20} fill={fill} fillOpacity="0.18" stroke={fill} strokeWidth="1.5" />
<text textAnchor="middle" dy="0.35em" fontSize="20">{glyph}</text>
<text textAnchor="middle" y="34" fontSize="11" fill="#a4afc7" className="node-label">{node.label}</text>
</g>
);
}
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 (
<g className={`courier-group${isMoving ? ' moving' : ''}`}
transform={`translate(${x}, ${y})`}
style={{ transition: 'transform 0.6s ease-out' }}>
{/* glow ring when active */}
{isMoving && <circle r="18" fill={carryColor} fillOpacity="0.2">
<animate attributeName="r" values="14;20;14" dur="1.5s" repeatCount="indefinite" />
</circle>}
<circle r="13" fill={carryColor} stroke="#0f1419" strokeWidth="2" />
<text textAnchor="middle" dy="0.35em" fontSize="14">🛵</text>
<text textAnchor="middle" y="-19" fontSize="10" fill="#dde2ed" fontWeight="600">{courier.id.replace('courier_', 'C')}</text>
{courier.eta_remaining > 0 && (
<text textAnchor="middle" y="26" fontSize="10" fill="#a4afc7">eta {courier.eta_remaining}</text>
)}
</g>
);
}
// ───────────────────────────────────────────────────────────────
// 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 (
<div className="panel orders-panel">
<div className="panel-header">orders<span className="panel-meta">{orders.filter(o => o.status === 'delivered').length}/{orders.length} delivered</span></div>
<div className="orders-list">
{orders.map((o) => <OrderCard key={o.id} order={o} tick={snapshot.tick} maxTicks={maxTicks} />)}
</div>
</div>
);
}
function OrderCard({ order, tick, maxTicks }) {
const ticksLeft = order.deadline_tick - tick;
const isUrgent = order.status !== 'delivered' && order.status !== 'expired' && ticksLeft <= 3;
return (
<div className={`order-card status-${order.status}${isUrgent ? ' urgent' : ''}`}>
<div className="order-row1">
<span className="order-id">{order.id}</span>
<span className={`order-kind kind-${order.kind}`}>{order.kind}</span>
<span className="order-status">{order.status}</span>
</div>
<div className="order-row2">
<span>{order.pickup_node_id} → {order.dropoff_node_id}</span>
</div>
<div className="order-row3">
{order.status === 'delivered' ? (
<span className="order-delivered-info">✓ at t{order.delivered_tick}</span>
) : order.status === 'expired' ? (
<span className="order-delivered-info">✗ expired</span>
) : (
<span className={isUrgent ? 'deadline-urgent' : ''}>⏱ {ticksLeft}t left</span>
)}
<span className="order-assigned">
{order.assigned_courier_id ? `→ ${order.assigned_courier_id.replace('courier_', 'C')}` : '—'}
</span>
</div>
</div>
);
}
// ───────────────────────────────────────────────────────────────
// Agent feed
// ───────────────────────────────────────────────────────────────
export function AgentFeed({ trajectory, currentTick, onJumpToTick }) {
return (
<div className="panel agent-feed">
<div className="panel-header">agent feed</div>
<div className="feed-list">
{trajectory.slice(0, currentTick + 1).reverse().map((step, idx) => (
<FeedRow key={step.tick}
step={step}
isCurrent={step.tick === currentTick}
onClick={() => onJumpToTick(step.tick)} />
))}
</div>
</div>
);
}
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 (
<div className={`feed-row${isCurrent ? ' current' : ''}`} onClick={onClick}>
<span className="feed-tick">t{step.tick}</span>
<span className="feed-action">{label}</span>
<span className="feed-args">{args}</span>
{step.events && step.events.length > 0 && (
<span className="feed-events">{step.events[0]}</span>
)}
</div>
);
}
// ───────────────────────────────────────────────────────────────
// 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 (
<div className="panel reward-panel">
<div className="panel-header">reward</div>
<div className="panel-empty">
Reward details stay hidden until you start the replay with step, play, scrub, or reset.
</div>
</div>
);
}
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 (
<div className="panel reward-panel">
<div className="panel-header">reward
<span className="panel-meta cumulative-reward">total: {cumulative >= 0 ? '+' : ''}{cumulative.toFixed(2)}</span>
</div>
<div className="reward-rows">
{REWARD_KEYS.map(([key, label, color]) => {
const val = rb[key] ?? 0;
const widthPct = (Math.abs(val) / maxAbs) * 100;
const isNeg = val < 0;
return (
<div className="reward-row" key={key}>
<span className="reward-label">{label}</span>
<span className="reward-bar-track">
<span className={`reward-bar ${isNeg ? 'neg' : 'pos'}`}
style={{ width: `${widthPct}%`, background: color, opacity: val === 0 ? 0.15 : 1 }} />
</span>
<span className={`reward-val ${isNeg ? 'neg' : ''}`}>
{val > 0 ? '+' : ''}{val.toFixed(2)}
</span>
</div>
);
})}
</div>
</div>
);
}
// ───────────────────────────────────────────────────────────────
// Top control bar
// ───────────────────────────────────────────────────────────────
export function ControlBar({
scenarios,
scenarioId,
onScenarioChange,
modes,
modeId,
onModeChange,
isPlaying,
onPlayToggle,
onStepBack,
onStepForward,
onReset,
}) {
return (
<div className="control-bar">
<div className="control-bar-left">
<span className="brand">Dispatch Arena</span>
<select className="ctrl-select" value={scenarioId} onChange={(e) => onScenarioChange(e.target.value)}>
{Object.entries(scenarios).map(([id, s]) => (
<option key={id} value={id}>
{s.metadata.theme} ({s.metadata.difficulty})
</option>
))}
</select>
<select className="ctrl-select" value={modeId} onChange={(e) => onModeChange(e.target.value)}>
{modes.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</div>
<div className="control-bar-right">
<button className="ctrl-btn" onClick={onStepBack}></button>
<button className="ctrl-btn primary" onClick={onPlayToggle}>{isPlaying ? '⏸' : '▶'}</button>
<button className="ctrl-btn" onClick={onStepForward}></button>
<button className="ctrl-btn" onClick={onReset}></button>
</div>
</div>
);
}
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 (
<div className={`action-banner phase-${playbackPhase}`}>
<div className="action-banner-status">{status}</div>
<div className="action-banner-line">{actionText}</div>
<div className="action-banner-subtext">{subtext}</div>
</div>
);
}
// ───────────────────────────────────────────────────────────────
// Tick scrubber (timeline at the bottom)
// ───────────────────────────────────────────────────────────────
export function TickScrubber({ trajectory, currentTick, onTickChange, maxTicks }) {
const totalLen = trajectory.length;
return (
<div className="tick-scrubber">
<div className="tick-meta">
<span>tick {currentTick} / {maxTicks}</span>
<span className="tick-events">{trajectory[currentTick]?.events?.[0] || ''}</span>
</div>
<input type="range"
min={0}
max={Math.max(0, totalLen - 1)}
value={currentTick}
onChange={(e) => onTickChange(parseInt(e.target.value, 10))}
className="tick-slider" />
</div>
);
}
// ───────────────────────────────────────────────────────────────
// 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 (
<div className="final-card">
<div className="final-header">shift complete · {scenario.metadata.theme}</div>
<div className="final-stats">
<Stat label="delivered" value={`${delivered}/${orders.length}`} accent={successRate >= 80 ? 'good' : successRate >= 50 ? 'mid' : 'bad'} />
<Stat label="on time" value={`${onTime}/${delivered}`} />
<Stat label="expired" value={expired} accent={expired > 0 ? 'bad' : 'good'} />
<Stat label="reward" value={(last.cumulative_reward ?? 0).toFixed(1)} accent={last.cumulative_reward > 0 ? 'good' : 'bad'} />
<Stat label="ticks" value={`${last.tick}/${scenario.metadata.max_ticks}`} />
</div>
</div>
);
}
function Stat({ label, value, accent }) {
return (
<div className={`stat${accent ? ' accent-' + accent : ''}`}>
<div className="stat-value">{value}</div>
<div className="stat-label">{label}</div>
</div>
);
}