Files
oneos-truck-date-prototype/artboards/history.jsx
kkfluous b2d0016a0d
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
init: 羚牛车辆数据中心原型 + 部署配置
- React 18 + Babel-in-browser SPA 原型,覆盖 8 个画板:
  实时地图 / 车辆详情 / 历史查询 / 轨迹回放 / 事件规则 / 通知中心 / ESG 碳减排 / 移动端
- 设计系统:IBM Plex Sans + JetBrains Mono,亮/暗双主题,羚牛绿 #007143
- 数据模型:12 + 40 辆车,TBOX (T) / JT808+1078 (JT) / 双源 (B)
- 部署:nginx 静态托管,Dockerfile + woodpecker.yml + docker-compose.yml
- 镜像:harbor.lnh2e.com/lingniu-v1/ln-vdc:<branch>-<VERSION>
- 容器端口 80,宿主映射 8112,含 /healthz 探活
2026-04-28 15:12:32 +08:00

607 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// artboard-history.jsx — 数据检索 (data search studio)
// Flow: ① 选车辆+时段 → ② 选数据项目 → ③ 选展示方式 → ④ 渲染结果
const DATA_GROUPS = [
{
id: "vehicle", label: "车辆运行", icon: "car", color: "var(--info)",
items: [
{id:"speed", l:"速度", u:"km/h", src:"TBOX", freq:"10s"},
{id:"odometer", l:"累计里程", u:"km", src:"TBOX", freq:"60s"},
{id:"trip_km", l:"行程里程", u:"km", src:"TBOX", freq:"事件"},
{id:"engine_run", l:"运行时长", u:"s", src:"TBOX", freq:"60s"},
{id:"gear", l:"档位", u:"-", src:"TBOX", freq:"10s"},
{id:"steer", l:"方向盘转角", u:"°", src:"CAN", freq:"100ms"},
],
},
{
id: "energy", label: "氢电系统", icon: "h2", color: "var(--accent)",
items: [
{id:"soc", l:"动力电池 SOC", u:"%", src:"BMS", freq:"10s"},
{id:"batt_volt", l:"电池总压", u:"V", src:"BMS", freq:"10s"},
{id:"batt_curr", l:"电池电流", u:"A", src:"BMS", freq:"10s"},
{id:"batt_temp", l:"电池温度", u:"℃", src:"BMS", freq:"10s"},
{id:"h2_pressure",l:"H₂ 压力", u:"MPa", src:"FCU", freq:"10s"},
{id:"h2_flow", l:"H₂ 流量", u:"g/s", src:"FCU", freq:"10s"},
{id:"fc_power", l:"电堆输出功率", u:"kW", src:"FCU", freq:"10s"},
{id:"fc_temp", l:"电堆温度", u:"℃", src:"FCU", freq:"10s"},
],
},
{
id: "chassis", label: "底盘 & 安全", icon: "shield", color: "var(--warn)",
items: [
{id:"tire_p_fl", l:"胎压 左前", u:"MPa", src:"TPMS", freq:"60s"},
{id:"tire_p_fr", l:"胎压 右前", u:"MPa", src:"TPMS", freq:"60s"},
{id:"tire_p_rl", l:"胎压 左后", u:"MPa", src:"TPMS", freq:"60s"},
{id:"tire_p_rr", l:"胎压 右后", u:"MPa", src:"TPMS", freq:"60s"},
{id:"brake", l:"制动信号", u:"0/1", src:"CAN", freq:"100ms"},
{id:"airbag", l:"安全气囊状态", u:"0/1", src:"CAN", freq:"事件"},
{id:"abs", l:"ABS 状态", u:"0/1", src:"CAN", freq:"事件"},
],
},
{
id: "location", label: "定位 & 通讯", icon: "pin", color: "var(--info)",
items: [
{id:"gps_lat", l:"GPS 纬度", u:"°", src:"JT808", freq:"10s"},
{id:"gps_lng", l:"GPS 经度", u:"°", src:"JT808", freq:"10s"},
{id:"gps_alt", l:"海拔", u:"m", src:"JT808", freq:"10s"},
{id:"signal", l:"信号强度", u:"dBm", src:"TBOX", freq:"60s"},
{id:"network", l:"网络类型", u:"-", src:"TBOX", freq:"60s"},
],
},
{
id: "driving", label: "驾驶行为", icon: "speed", color: "var(--accent)",
items: [
{id:"hard_acc", l:"急加速次数", u:"次/h", src:"TBOX", freq:"事件"},
{id:"hard_brake", l:"急刹车次数", u:"次/h", src:"TBOX", freq:"事件"},
{id:"sharp_turn", l:"急转弯次数", u:"次/h", src:"TBOX", freq:"事件"},
{id:"overspeed", l:"超速时长", u:"s/h", src:"TBOX", freq:"事件"},
{id:"score", l:"驾驶评分", u:"分", src:"算法", freq:"日"},
],
},
];
const VIEW_MODES = [
{id:"line", l:"曲线图", ic:"chart", d:"时间序列趋势"},
{id:"area", l:"面积图", ic:"pulse", d:"叠加趋势对比"},
{id:"bar", l:"柱状图", ic:"layers", d:"分时统计"},
{id:"table", l:"数据表", ic:"list", d:"按时间戳列表"},
{id:"heat", l:"热力日历", ic:"history", d:"按日聚合"},
{id:"summary",l:"统计摘要", ic:"gauge", d:"min/max/avg/p95"},
];
const QUICK_RANGES = [
{id:"1h", l:"近1小时"}, {id:"6h", l:"近6小时"}, {id:"24h", l:"近24小时"},
{id:"7d", l:"近7日"}, {id:"30d", l:"近30日"}, {id:"custom", l:"自定义"},
];
const ArtboardHistory = () => {
// Wizard step: 1 选范围, 2 选项目, 3 展示
const [step, setStep] = React.useState(3);
const [vehicle] = React.useState({plate:"浙F03980F", vin:"LJ2A...8814", dept:"业务一部"});
const [range, setRange] = React.useState("24h");
const [dateFrom] = React.useState("2026-04-27 14:02");
const [dateTo] = React.useState("2026-04-28 14:02");
// Selected data items (ids)
const [picked, setPicked] = React.useState(new Set(["speed","soc","h2_pressure"]));
const [activeGroup, setActiveGroup] = React.useState("vehicle");
// Visualization mode
const [view, setView] = React.useState("line");
const togglePick = (id) => {
const next = new Set(picked);
if (next.has(id)) next.delete(id); else next.add(id);
setPicked(next);
};
const pickedItems = DATA_GROUPS.flatMap(g => g.items.filter(it => picked.has(it.id)).map(it => ({...it, group:g})));
return (
<div className="app">
<Sidebar active="history"/>
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
<Topbar crumbs={["数据检索", `${vehicle.plate}`]} kpis={[]} showSearch={false}/>
{/* Step indicator */}
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", gap:24, alignItems:"center"}}>
{[
{n:1, l:"选择范围"},
{n:2, l:"选择数据项目"},
{n:3, l:"选择展示方式"},
].map((s,i,arr)=>(
<React.Fragment key={s.n}>
<div className="mid gap-2" style={{cursor:"pointer", opacity: step >= s.n ? 1 : 0.5}} onClick={() => setStep(s.n)}>
<span style={{
width:22, height:22, borderRadius:11,
background: step >= s.n ? "var(--accent)" : "var(--bg-3)",
color: step >= s.n ? "#fff" : "var(--fg-3)",
display:"grid", placeItems:"center",
fontSize:11, fontWeight:600, fontFamily:"var(--font-mono)",
}}>{s.n}</span>
<span className={step === s.n ? "strong" : ""} style={{fontSize:12}}>{s.l}</span>
</div>
{i < arr.length - 1 && <span style={{flex:0, width:32, height:1, background:"var(--border-2)"}}/>}
</React.Fragment>
))}
<div style={{marginLeft:"auto", display:"flex", gap:6, alignItems:"center"}}>
<span className="muted" style={{fontSize:11}}>已选 <span className="strong" style={{color:"var(--accent)"}}>{picked.size}</span> </span>
<span style={{width:1, height:18, background:"var(--border-1)"}}/>
<button className="btn" onClick={() => location.hash = "#/compare"}><Icon name="layers" size={12}/> 多车对比</button>
<button className="btn"><Icon name="download" size={12}/> 导出 CSV</button>
<button className="btn"><Icon name="bookmark" size={12}/> 保存查询</button>
</div>
</div>
{/* Main */}
<div style={{flex:1, display:"grid", gridTemplateColumns:"320px 1fr", minHeight:0}}>
{/* LEFT: range + data items selector */}
<div style={{borderRight:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
{/* Vehicle + time */}
<div style={{padding:14, borderBottom:"1px solid var(--border-1)"}}>
<div className="eyebrow" style={{marginBottom:8}}>查询对象</div>
<div className="mid gap-2" style={{padding:"8px 10px", background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:6}}>
<Icon name="car" size={14} style={{color:"var(--accent)"}}/>
<div style={{flex:1}}>
<div className="mono strong" style={{fontSize:12}}>{vehicle.plate}</div>
<div className="muted" style={{fontSize:10}}>{vehicle.vin} · {vehicle.dept}</div>
</div>
<Icon name="chevDown" size={11} style={{color:"var(--fg-3)"}}/>
</div>
<div className="eyebrow" style={{marginTop:14, marginBottom:8}}>时间范围</div>
<div style={{display:"grid", gridTemplateColumns:"repeat(3, 1fr)", gap:4, marginBottom:8}}>
{QUICK_RANGES.map(r => (
<span key={r.id}
onClick={() => setRange(r.id)}
className={"chip " + (range === r.id ? "accent" : "")}
style={{justifyContent:"center", cursor:"pointer", fontSize:10, padding:"4px 0"}}>
{r.l}
</span>
))}
</div>
<div style={{padding:"8px 10px", background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:6, fontSize:11, fontFamily:"var(--font-mono)"}}>
<div className="muted" style={{fontSize:9, marginBottom:2}}> </div>
<div>{dateFrom}</div>
<div>{dateTo}</div>
</div>
</div>
{/* Data item picker */}
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<Icon name="cube" size={13}/>
<span className="title">数据项目</span>
<span className="chip accent" style={{marginLeft:"auto"}}>已选 {picked.size}</span>
</div>
<div style={{display:"flex", borderBottom:"1px solid var(--border-1)", padding:"6px 10px", gap:4, flexWrap:"wrap", background:"var(--bg-2)"}}>
{DATA_GROUPS.map(g => (
<span key={g.id}
onClick={() => setActiveGroup(g.id)}
className={"chip " + (activeGroup === g.id ? "accent" : "")}
style={{cursor:"pointer", fontSize:10, padding:"3px 8px"}}>
<Icon name={g.icon} size={10}/> {g.label}
<span className="mono" style={{marginLeft:4, opacity:.7}}>
{g.items.filter(it => picked.has(it.id)).length || g.items.length}
</span>
</span>
))}
</div>
<div className="scroll" style={{flex:1}}>
{DATA_GROUPS.filter(g => g.id === activeGroup).map(g => (
<div key={g.id}>
{g.items.map(it => {
const on = picked.has(it.id);
return (
<div key={it.id}
onClick={() => togglePick(it.id)}
style={{
display:"flex", alignItems:"center", gap:10,
padding:"9px 14px", borderBottom:"1px solid var(--border-1)",
cursor:"pointer",
background: on ? "var(--accent-soft)" : "transparent",
}}>
<span style={{
width:14, height:14, borderRadius:3,
border: "1.5px solid " + (on ? "var(--accent)" : "var(--border-2)"),
background: on ? "var(--accent)" : "transparent",
display:"grid", placeItems:"center",
flexShrink:0,
}}>
{on && <svg width="9" height="9" viewBox="0 0 12 12" fill="none"><path d="M2 6l3 3 5-6" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</span>
<div style={{flex:1, minWidth:0}}>
<div className="between">
<span className="strong" style={{fontSize:12}}>{it.l}</span>
<span className="mono muted" style={{fontSize:10}}>{it.u}</span>
</div>
<div className="mid gap-1" style={{marginTop:2}}>
<span style={{fontSize:9, padding:"1px 5px", borderRadius:2, background:"var(--bg-2)", border:"1px solid var(--border-1)", color: g.color, fontFamily:"var(--font-mono)"}}>{it.src}</span>
<span className="muted mono" style={{fontSize:9}}>· {it.freq}</span>
</div>
</div>
</div>
);
})}
</div>
))}
</div>
<div style={{padding:"10px 14px", borderTop:"1px solid var(--border-1)", display:"flex", gap:6}}>
<button className="btn" style={{flex:1}} onClick={() => setPicked(new Set())}>清空</button>
<button className="btn primary" style={{flex:2}}>
<Icon name="search" size={12}/> 查询 {picked.size}
</button>
</div>
</div>
{/* RIGHT: visualization */}
<div style={{display:"flex", flexDirection:"column", minHeight:0, background:"var(--bg-2)"}}>
{/* View-mode picker */}
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", gap:6, alignItems:"center", flexWrap:"wrap"}}>
<span className="muted" style={{fontSize:11, marginRight:4}}>展示</span>
{VIEW_MODES.map(v => (
<span key={v.id}
onClick={() => setView(v.id)}
style={{
display:"flex", alignItems:"center", gap:6,
padding:"5px 10px", borderRadius:5, cursor:"pointer", fontSize:11,
background: view === v.id ? "var(--accent-soft)" : "transparent",
border: "1px solid " + (view === v.id ? "var(--accent)" : "var(--border-1)"),
color: view === v.id ? "var(--accent)" : "var(--fg-1)",
}}>
<Icon name={v.ic} size={12}/>
<span>{v.l}</span>
</span>
))}
<div style={{marginLeft:"auto"}}>
<span className="muted" style={{fontSize:11}}>采样</span>
<span className="chip">原始</span>
<span className="chip" style={{marginLeft:4}}>1分钟</span>
<span className="chip accent" style={{marginLeft:4}}>5分钟</span>
<span className="chip" style={{marginLeft:4}}>1小时</span>
</div>
</div>
{/* Selected items pills */}
<div style={{padding:"8px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", gap:6, flexWrap:"wrap", alignItems:"center"}}>
{pickedItems.length === 0 && <span className="muted" style={{fontSize:11}}>请从左侧选择数据项</span>}
{pickedItems.map(it => (
<span key={it.id} className="chip" style={{
fontSize:10, padding:"3px 8px",
background:"var(--bg-2)",
borderColor: it.group.color, color: it.group.color,
}}>
<span style={{width:6, height:6, borderRadius:3, background: it.group.color}}/>
{it.l}
<span className="muted" style={{fontSize:9, marginLeft:2}}>{it.u}</span>
<span style={{cursor:"pointer", marginLeft:2}} onClick={() => togglePick(it.id)}>×</span>
</span>
))}
</div>
{/* Render area */}
<div className="scroll" style={{flex:1, padding:16}}>
{view === "line" && <LineView items={pickedItems}/>}
{view === "area" && <AreaView items={pickedItems}/>}
{view === "bar" && <BarView items={pickedItems}/>}
{view === "table" && <TableView items={pickedItems}/>}
{view === "heat" && <HeatView items={pickedItems}/>}
{view === "summary" && <SummaryView items={pickedItems}/>}
</div>
</div>
</div>
</div>
</div>
);
};
// ── Visualizations ───────────────────────────────────────────
const colorFor = (it, fallback) => {
if (it.id === "speed" || it.id.startsWith("hard_") || it.id === "h2_flow") return "var(--info)";
if (it.id === "soc" || it.id.startsWith("batt") || it.id === "fc_power") return "var(--accent)";
if (it.id.startsWith("h2_") || it.id.startsWith("fc_temp")) return "var(--warn)";
if (it.id.startsWith("tire_")) return "var(--danger)";
return it.group?.color || fallback || "var(--accent)";
};
const synth = (id, n=120) => {
// Deterministic per-id pseudo-random series
const seed = id.split("").reduce((s,c) => s + c.charCodeAt(0), 0);
const out = [];
for (let i=0;i<n;i++) {
const v = Math.sin((i+seed) * 0.18) * 0.4 + Math.cos((i+seed*0.7) * 0.07) * 0.3 + 0.5
+ Math.sin((i+seed*1.3) * 0.5) * 0.08;
out.push(Math.max(0.02, Math.min(0.98, v)));
}
return out;
};
const LineView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
return (
<div className="col gap-3">
{items.map(it => (
<div key={it.id} className="panel">
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<span style={{width:8, height:8, borderRadius:4, background: colorFor(it)}}/>
<span className="title">{it.l}</span>
<span className="muted mono" style={{fontSize:10, marginLeft:4}}>{it.u}</span>
<div className="actions">
<span className="mono muted" style={{fontSize:10}}>{it.src} · {it.freq}</span>
<span className="chip"><Icon name="download" size={9}/> CSV</span>
<span className="chip"><Icon name="expand" size={9}/></span>
</div>
</div>
<div style={{padding:"14px 16px"}}>
<Sparkline data={synth(it.id, 240)} h={120} color={colorFor(it)} fill axis/>
<TimeAxis/>
</div>
</div>
))}
</div>
);
};
const AreaView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
return (
<div className="panel">
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<Icon name="pulse" size={12}/>
<span className="title">{items.length} 项叠加</span>
<div className="actions">
{items.map(it => (
<span key={it.id} className="mid gap-1" style={{fontSize:10}}>
<span style={{width:8, height:8, borderRadius:2, background: colorFor(it)}}/>
{it.l}
</span>
))}
</div>
</div>
<div style={{padding:"14px 16px", position:"relative"}}>
<svg width="100%" height="320" viewBox="0 0 800 320" preserveAspectRatio="none" style={{display:"block"}}>
{[0,0.25,0.5,0.75,1].map((p,i) => (
<line key={i} x1="0" y1={300*p+10} x2="800" y2={300*p+10} stroke="var(--border-1)" strokeWidth="1"/>
))}
{items.map((it,idx) => {
const data = synth(it.id, 240);
const path = data.map((v,i) => `${(i/(data.length-1))*800},${310 - v*300}`).join(" L");
return (
<g key={it.id}>
<path d={`M ${path} L 800,310 L 0,310 Z`} fill={colorFor(it)} opacity={0.12}/>
<path d={`M ${path}`} fill="none" stroke={colorFor(it)} strokeWidth="1.6"/>
</g>
);
})}
</svg>
<TimeAxis/>
</div>
</div>
);
};
const BarView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
const buckets = ["00","02","04","06","08","10","12","14","16","18","20","22"];
return (
<div className="col gap-3">
{items.map(it => {
const data = synth(it.id, buckets.length).map(v => v * 100);
return (
<div key={it.id} className="panel">
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<span style={{width:8, height:8, borderRadius:4, background: colorFor(it)}}/>
<span className="title">{it.l} <span className="muted" style={{fontSize:10, marginLeft:4}}>· 2 小时聚合</span></span>
<div className="actions"><span className="mono muted" style={{fontSize:10}}>{it.u}</span></div>
</div>
<div style={{padding:"14px 16px"}}>
<svg width="100%" height="120" viewBox="0 0 720 120" preserveAspectRatio="none" style={{display:"block"}}>
{data.map((v,i) => {
const x = i * (720/data.length) + 8;
const w = (720/data.length) - 16;
const h = (v/100) * 100;
return (
<g key={i}>
<rect x={x} y={110-h} width={w} height={h} fill={colorFor(it)} opacity="0.85" rx="2"/>
<text x={x+w/2} y="118" textAnchor="middle" fontSize="9" fill="var(--fg-3)" fontFamily="var(--font-mono)">{buckets[i]}</text>
</g>
);
})}
</svg>
</div>
</div>
);
})}
</div>
);
};
const TableView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
// 30 rows of timestamps
const rows = Array.from({length:40}, (_,i) => {
const d = new Date(2026, 3, 28, 14, 0, 0);
d.setMinutes(d.getMinutes() - i * 5);
const ts = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")} ${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}:00`;
const vals = items.map(it => synth(it.id, 240)[i % 240]);
return { ts, vals };
});
return (
<div className="panel">
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<Icon name="list" size={12}/>
<span className="title">数据列表</span>
<span className="muted" style={{fontSize:11, marginLeft:6}}> 1,728 · 显示 40</span>
<div className="actions">
<span className="chip"><Icon name="filter" size={9}/> 筛选</span>
<span className="chip"><Icon name="download" size={9}/> CSV</span>
</div>
</div>
<div style={{maxHeight:520, overflowY:"auto"}}>
<table className="tbl">
<thead>
<tr>
<th style={{width:160, position:"sticky", top:0, background:"var(--bg-1)"}}>时间戳</th>
{items.map(it => (
<th key={it.id} style={{textAlign:"right", position:"sticky", top:0, background:"var(--bg-1)"}}>
<div>{it.l}</div>
<div className="muted mono" style={{fontSize:9, fontWeight:400}}>{it.u} · {it.src}</div>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r,i) => (
<tr key={i}>
<td className="mono muted" style={{fontSize:11}}>{r.ts}</td>
{r.vals.map((v,j) => {
const it = items[j];
let display;
if (it.id === "speed") display = (v*80).toFixed(1);
else if (it.id === "soc") display = (20 + v*70).toFixed(1);
else if (it.id.startsWith("h2_pressure")) display = (3.5 + v*1.3).toFixed(2);
else if (it.id.startsWith("tire_")) display = (2.7 + v*0.6).toFixed(2);
else if (it.id.startsWith("batt_temp")) display = (24 + v*22).toFixed(1);
else if (it.id === "odometer") display = (124820 + v*4).toFixed(1);
else display = (v*100).toFixed(2);
return <td key={j} className="mono" style={{textAlign:"right", color: colorFor(it)}}>{display}</td>;
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
const HeatView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
return (
<div className="col gap-3">
{items.map(it => {
const cells = synth(it.id, 30); // 30 days
return (
<div key={it.id} className="panel">
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<span style={{width:8, height:8, borderRadius:4, background: colorFor(it)}}/>
<span className="title">{it.l} · 30 日热力</span>
<div className="actions"><span className="mono muted" style={{fontSize:10}}>{it.u}</span></div>
</div>
<div style={{padding:"14px 16px"}}>
<div style={{display:"grid", gridTemplateColumns:"repeat(30, 1fr)", gap:3}}>
{cells.map((v,i) => (
<div key={i} title={`${i+1} 日 · ${(v*100).toFixed(0)}`}
style={{
aspectRatio:"1",
borderRadius:3,
background: colorFor(it),
opacity: 0.15 + v*0.85,
}}/>
))}
</div>
<div className="between muted mono" style={{marginTop:8, fontSize:10}}>
<span>30 日前</span><span></span>
</div>
</div>
</div>
);
})}
</div>
);
};
const SummaryView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
return (
<div style={{display:"grid", gridTemplateColumns:"repeat(auto-fit, minmax(280px, 1fr))", gap:12}}>
{items.map(it => {
const data = synth(it.id, 240).map(v => v*100);
const min = Math.min(...data), max = Math.max(...data);
const avg = data.reduce((s,v)=>s+v,0)/data.length;
const sorted = [...data].sort((a,b)=>a-b);
const p95 = sorted[Math.floor(sorted.length*0.95)];
const p50 = sorted[Math.floor(sorted.length*0.50)];
// Scale to plausible units
const fmt = (v) => {
if (it.id === "speed") return (v*0.8).toFixed(1);
if (it.id === "soc") return (20 + v*0.7).toFixed(1);
if (it.id.startsWith("h2_pressure")) return (3.5 + v*0.013).toFixed(2);
return v.toFixed(1);
};
return (
<div key={it.id} className="panel" style={{padding:14}}>
<div className="between" style={{marginBottom:10}}>
<div>
<div className="strong" style={{fontSize:13}}>{it.l}</div>
<div className="muted mono" style={{fontSize:10}}>{it.u} · {it.src}</div>
</div>
<span style={{width:10, height:10, borderRadius:5, background: colorFor(it)}}/>
</div>
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:10}}>
{[
{l:"平均", v:fmt(avg), c:colorFor(it)},
{l:"中位", v:fmt(p50), c:"var(--fg-1)"},
{l:"最大", v:fmt(max), c:"var(--danger)"},
{l:"最小", v:fmt(min), c:"var(--ok)"},
{l:"P95", v:fmt(p95), c:"var(--warn)"},
{l:"样本", v:"1,728", c:"var(--fg-2)"},
].map((s,i) => (
<div key={i} style={{padding:"6px 10px", background:"var(--bg-2)", borderRadius:4, border:"1px solid var(--border-1)"}}>
<div className="muted" style={{fontSize:10}}>{s.l}</div>
<div className="mono strong" style={{fontSize:14, color: s.c, marginTop:2}}>{s.v}</div>
</div>
))}
</div>
<div style={{marginTop:10}}>
<Sparkline data={synth(it.id, 80)} h={32} color={colorFor(it)} fill/>
</div>
</div>
);
})}
</div>
);
};
// Sparkline + axis helpers
const Sparkline = ({ data, h=80, color="var(--accent)", fill=false, axis=false }) => {
const w = 800;
const max = Math.max(...data, 0.01);
const path = data.map((v,i) => `${(i/(data.length-1))*w},${h - (v/max) * (h-12) - 6}`).join(" L");
return (
<svg width="100%" height={h} viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{display:"block"}}>
{axis && [0,0.25,0.5,0.75,1].map((p,i) => (
<line key={i} x1="0" y1={(h-12)*p+6} x2={w} y2={(h-12)*p+6} stroke="var(--border-1)" strokeWidth="1"/>
))}
{fill && <path d={`M ${path} L ${w},${h} L 0,${h} Z`} fill={color} opacity="0.16"/>}
<path d={`M ${path}`} fill="none" stroke={color} strokeWidth="1.6"/>
</svg>
);
};
const TimeAxis = () => (
<div className="muted mono" style={{display:"flex", justifyContent:"space-between", marginTop:6, fontSize:10}}>
{["00:00","04:00","08:00","12:00","16:00","20:00","24:00"].map(t => <span key={t}>{t}</span>)}
</div>
);
const EmptyHint = () => (
<div style={{padding:"60px 20px", textAlign:"center", color:"var(--fg-3)"}}>
<Icon name="cube" size={48} style={{opacity:.3}}/>
<div style={{marginTop:14, fontSize:13}}>从左侧选择数据项目以开始检索</div>
<div className="muted" style={{fontSize:11, marginTop:4}}>支持速度 / SOC / H₂ 压力 / 胎压 / 驾驶行为等 30+ 字段</div>
</div>
);
window.ArtboardHistory = ArtboardHistory;