// 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 */} {events.map((e,i)=>{ const ei = Math.min(points.length - 1, Math.floor((e.at/100) * (points.length - 1))); const ef = (e.at/100) * (points.length - 1) - ei; const en = Math.min(points.length - 1, ei + 1); const x = points[ei][0] + (points[en][0]-points[ei][0])*ef; const y = points[ei][1] + (points[en][1]-points[ei][1])*ef; const c = e.type === "warn" ? "var(--warn)" : e.type === "start" ? "var(--ok)" : e.type === "end" ? "var(--accent)" : "var(--fg-2)"; return ( ); })} {/* 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;