// artboard-playback.jsx — Trajectory playback with synchronized data
// ── Calendar dropdown ─────────────────────────────────────
const Calendar = ({ selected, onSelect, onClose }) => {
// Anchor: April 2026 — month-view picker
const [viewMonth, setViewMonth] = React.useState(() => {
const [y, m] = selected.split("-").map(Number);
return { y, m: m - 1 }; // 0-indexed
});
React.useEffect(() => {
const onDoc = (e) => { if (!e.target.closest("[data-cal]")) onClose(); };
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [onClose]);
const monthName = ["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"][viewMonth.m];
const firstDay = new Date(viewMonth.y, viewMonth.m, 1).getDay(); // 0=Sun
const daysInMth = new Date(viewMonth.y, viewMonth.m + 1, 0).getDate();
const today = "2026-04-28";
// Mock days with trips (heat indicator)
const tripDays = {
"2026-04-21": 5, "2026-04-22": 6, "2026-04-23": 4, "2026-04-24": 7, "2026-04-25": 3,
"2026-04-26": 0, "2026-04-27": 5, "2026-04-28": 6, "2026-04-15": 4, "2026-04-16": 6,
"2026-04-08": 3, "2026-04-09": 5, "2026-04-10": 4,
};
const cells = [];
for (let i = 0; i < firstDay; i++) cells.push(null);
for (let d = 1; d <= daysInMth; d++) cells.push(d);
while (cells.length % 7) cells.push(null);
const fmt = (d) => `${viewMonth.y}-${String(viewMonth.m + 1).padStart(2,"0")}-${String(d).padStart(2,"0")}`;
return (
{/* Month nav */}
{viewMonth.y} 年 {monthName}
{/* Weekdays */}
{["日","一","二","三","四","五","六"].map(w => (
{w}
))}
{/* Day cells */}
{cells.map((d, i) => {
if (!d) return
;
const ds = fmt(d);
const trips = tripDays[ds] || 0;
const isSel = ds === selected;
const isToday = ds === today;
const heat = trips === 0 ? null : trips < 3 ? "var(--accent-soft)" : trips < 5 ? "rgba(0,113,67,.30)" : "var(--accent)";
return (
onSelect(ds)}
style={{
height:30, borderRadius:5, cursor:"pointer",
display:"flex", flexDirection:"column", alignItems:"center", justifyContent:"center",
fontSize:11, position:"relative",
background: isSel ? "var(--accent)" : "transparent",
color: isSel ? "#fff" : trips === 0 ? "var(--fg-3)" : "var(--fg-1)",
fontWeight: isSel || isToday ? 600 : 400,
border: isToday && !isSel ? "1px solid var(--accent)" : "1px solid transparent",
}}
onMouseEnter={(e) => !isSel && (e.currentTarget.style.background = "var(--bg-2)")}
onMouseLeave={(e) => !isSel && (e.currentTarget.style.background = "transparent")}>
{d}
{trips > 0 && !isSel && (
)}
{trips > 0 && isSel && (
{trips}
)}
);
})}
{/* Footer legend */}
);
};
const ArtboardPlayback = () => {
const [t, setT] = React.useState(42); // 0-100
const [speed, setSpeed] = React.useState(2);
const [playing, setPlaying] = React.useState(true);
// Date / time-range selection
const [dateStr, setDateStr] = React.useState("2026-04-28");
const [timeFrom, setTimeFrom] = React.useState("14:02");
const [timeTo, setTimeTo] = React.useState("14:44");
const [calOpen, setCalOpen] = React.useState(false);
// List of trips on the selected date — each is a candidate range
const trips = [
{id:"T-001", from:"08:14", to:"09:42", km:42.6, evt:1, route:"总站 → 港务局", active:false},
{id:"T-002", from:"10:08", to:"11:35", km:38.1, evt:0, route:"港务局 → 维保中心", active:false},
{id:"T-003", from:"12:45", to:"13:38", km:21.4, evt:2, route:"维保中心 → 总站", active:false},
{id:"T-004", from:"14:02", to:"14:44", km:32.4, evt:3, route:"总站 → 乍浦港 #2", active:true },
{id:"T-005", from:"15:10", to:"16:32", km:48.9, evt:1, route:"乍浦港 → 嘉兴南", active:false},
{id:"T-006", from:"17:48", to:"19:05", km:55.2, evt:2, route:"嘉兴南 → 总站", active:false},
];
// Compute marker pos along a path
const path = "M 200 540 L 280 480 L 360 420 L 440 380 L 520 340 L 620 320 L 720 320 L 820 340 L 900 380 L 980 440";
const points = [
[200,540],[280,480],[360,420],[440,380],[520,340],[620,320],[720,320],[820,340],[900,380],[980,440]
];
const idx = Math.min(points.length - 1, Math.floor((t/100) * (points.length - 1)));
const next = Math.min(points.length - 1, idx + 1);
const frac = (t/100) * (points.length - 1) - idx;
const px = points[idx][0] + (points[next][0] - points[idx][0]) * frac;
const py = points[idx][1] + (points[next][1] - points[idx][1]) * frac;
React.useEffect(() => {
if (!playing) return;
const id = setInterval(() => setT(prev => (prev + 0.4 * speed) % 100), 80);
return () => clearInterval(id);
}, [playing, speed]);
const events = [
{at:8, type:"start", lbl:"出发·总站"},
{at:22, type:"warn", lbl:"急加速"},
{at:38, type:"stop", lbl:"信号停车"},
{at:55, type:"warn", lbl:"超速"},
{at:74, type:"stop", lbl:"补能站"},
{at:92, type:"end", lbl:"到达·机场"},
];
return (
{/* Date / time-range selector bar */}
日期
{/* Date pill with calendar dropdown */}
{calOpen && (
{ setDateStr(d); setCalOpen(false); }} onClose={() => setCalOpen(false)}/>
)}
{/* Quick-range presets */}
{[
{l:"今日", v:"today"},
{l:"昨日", v:"yesterday"},
{l:"近7日", v:"7d"},
{l:"近30日",v:"30d"},
{l:"自定义",v:"custom"},
].map((p,i) => (
{
const today = new Date("2026-04-28");
if (p.v === "today") setDateStr("2026-04-28");
else if (p.v === "yesterday") setDateStr("2026-04-27");
else if (p.v === "7d") setDateStr("2026-04-22");
else if (p.v === "30d") setDateStr("2026-03-29");
}}>{p.l}
))}
{/* Time range */}
时段
setTimeFrom(e.target.value)}
style={{width:54, height:22, background:"transparent", border:"none", color:"var(--fg-0)", fontFamily:"var(--font-mono)", fontSize:12, textAlign:"center", outline:"none"}}/>
→
setTimeTo(e.target.value)}
style={{width:54, height:22, background:"transparent", border:"none", color:"var(--fg-0)", fontFamily:"var(--font-mono)", fontSize:12, textAlign:"center", outline:"none"}}/>
{/* Trip count badge */}
当日行程
{trips.length}
{/* Toolbar */}
浙F07179F ×
+ 添加车辆对比
显示
轨迹
事件
热力
停留点
{/* Map */}
{/* Event markers on map */}
{/* Live readout */}
时间
14:{String(Math.floor(t*0.42 + 2)).padStart(2,"0")}:{String(Math.floor(t*36)%60).padStart(2,"0")}
速度
{Math.floor(40 + Math.sin(t*0.1)*30)} km/h
SOC
{Math.floor(78 - t*0.18)}%
H₂
{(4.2 - t*0.012).toFixed(2)} MPa
累计
{(t*0.32).toFixed(1)} km
{/* Player + multi-curve */}
{/* Synced data curves */}
速度 km/h
SOC %
H₂压力 MPa
v*15)} w={920} h={80} color="var(--warn)" fill={false}/>
{/* playhead */}
{/* Timeline + controls */}
{/* event markers */}
{events.map((e,i)=>(
))}
{/* progress fill */}
{/* playhead */}
{/* time labels */}
{["14:02","14:08","14:15","14:23","14:30","14:36","14:44"].map(x=>{x})}
14:{String(Math.floor(t*0.42 + 2)).padStart(2,"0")}:{String(Math.floor(t*36)%60).padStart(2,"0")} / 14:44:00
倍速
{[0.5, 1, 2, 4, 8, 16].map(s=>(
setSpeed(s)}>{s}×
))}
{/* Side: trips + events + summary */}
{/* Trips of selected day */}
当日行程
{trips.length}
{trips.map((tr,i)=>(
{tr.from} → {tr.to}
{tr.km} km
{tr.route}
{tr.evt > 0 && {tr.evt}事件}
))}
{/* Events */}
事件时间线
{events.length}
{events.map((e,i)=>{
const c = e.type === "warn" ? "var(--warn)" : e.type === "start" ? "var(--ok)" : e.type === "end" ? "var(--accent)" : "var(--fg-2)";
return (
setT(e.at)}>
{i < events.length - 1 && }
{e.lbl}
14:{String(Math.floor(e.at*0.42 + 2)).padStart(2,"0")}
{e.type==="warn" ? "速度 +18 km/h · 持续 2.4s" :
e.type==="stop" ? "停留 1分12秒" :
e.type==="start" ? "里程 0 km" : "里程 32.4 km · 平均 49 km/h"}
);
})}
本次行程
里程32.4 km
时长42 分钟
平均速度46 km/h
最高速度89 km/h
能耗5.8 kWh / 0.32 kg H₂
评分87 / 100
);
};
window.ArtboardPlayback = ArtboardPlayback;