init: 羚牛车辆数据中心原型 + 部署配置
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful

- 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 探活
This commit is contained in:
kkfluous
2026-04-28 15:12:32 +08:00
commit b2d0016a0d
59 changed files with 6938 additions and 0 deletions

150
components/charts.jsx Normal file
View File

@@ -0,0 +1,150 @@
// charts.jsx — minimal SVG charts for the cockpit
// Sparkline / line chart
const LineChart = ({ data, w = 300, h = 80, color = "var(--accent)", fill = true, axis = false, showDots = false, baseline = null }) => {
const min = Math.min(...data), max = Math.max(...data);
const span = max - min || 1;
const stepX = w / (data.length - 1);
const padY = 6;
const y = v => padY + (h - padY * 2) * (1 - (v - min) / span);
const pts = data.map((v, i) => [i * stepX, y(v)]);
const path = pts.map((p, i) => (i === 0 ? "M" : "L") + p[0] + " " + p[1]).join(" ");
const areaPath = path + ` L ${w} ${h} L 0 ${h} Z`;
return (
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: "block" }}>
<defs>
<linearGradient id={`g-${color.replace(/[^a-z]/gi,'')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={color} stopOpacity="0.35"/>
<stop offset="1" stopColor={color} stopOpacity="0"/>
</linearGradient>
</defs>
{axis && (
<>
<line x1="0" y1={h-1} x2={w} y2={h-1} stroke="var(--border-1)" strokeWidth="1"/>
{[0.25, 0.5, 0.75].map(p => (
<line key={p} x1="0" y1={h*p} x2={w} y2={h*p} stroke="var(--border-1)" strokeWidth="0.5" strokeDasharray="2 3"/>
))}
</>
)}
{baseline != null && (
<line x1="0" y1={y(baseline)} x2={w} y2={y(baseline)} stroke="var(--warn)" strokeWidth="1" strokeDasharray="3 3" opacity="0.6"/>
)}
{fill && <path d={areaPath} fill={`url(#g-${color.replace(/[^a-z]/gi,'')})`}/>}
<path d={path} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
{showDots && pts.map((p, i) => (
<circle key={i} cx={p[0]} cy={p[1]} r="2" fill={color}/>
))}
</svg>
);
};
const Bars = ({ data, w = 300, h = 80, color = "var(--accent)", labels = null }) => {
const max = Math.max(...data) || 1;
const slot = w / data.length;
const bw = Math.max(slot * 0.55, 3);
return (
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: "block" }}>
{data.map((v, i) => {
const bh = (h - 14) * (v / max);
return (
<g key={i}>
<rect x={i*slot + (slot-bw)/2} y={h - 14 - bh} width={bw} height={bh} fill={color} rx="1.5" opacity={0.85}/>
{labels && <text x={i*slot + slot/2} y={h-3} textAnchor="middle" fill="var(--fg-3)" fontSize="9" fontFamily="var(--font-mono)">{labels[i]}</text>}
</g>
);
})}
</svg>
);
};
// Donut for pie share
const Donut = ({ size = 80, value = 0.7, color = "var(--accent)", track = "var(--bg-3)", thick = 8, label }) => {
const r = size/2 - thick/2;
const c = 2*Math.PI*r;
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke={track} strokeWidth={thick}/>
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke={color} strokeWidth={thick}
strokeDasharray={`${c*value} ${c}`} strokeDashoffset={c*0.25}
transform={`rotate(-90 ${size/2} ${size/2})`}
strokeLinecap="round"/>
{label && <text x={size/2} y={size/2+1} textAnchor="middle" fontSize="13" fontWeight="600" fill="var(--fg-0)" fontFamily="var(--font-mono)">{label}</text>}
</svg>
);
};
// Radial gauge (for speed / soc)
const Gauge = ({ value = 0.6, size = 100, color = "var(--accent)", label, sub }) => {
const r = size/2 - 8;
const c = Math.PI * r; // half circle
return (
<svg width={size} height={size*0.65} viewBox={`0 0 ${size} ${size*0.65}`}>
<path d={`M 8 ${size/2} A ${r} ${r} 0 0 1 ${size-8} ${size/2}`}
fill="none" stroke="var(--bg-3)" strokeWidth="6" strokeLinecap="round"/>
<path d={`M 8 ${size/2} A ${r} ${r} 0 0 1 ${size-8} ${size/2}`}
fill="none" stroke={color} strokeWidth="6" strokeLinecap="round"
strokeDasharray={`${c*value} ${c}`}/>
<text x={size/2} y={size/2 - 4} textAnchor="middle" fontSize="18" fontWeight="600" fill="var(--fg-0)" fontFamily="var(--font-mono)">{label}</text>
{sub && <text x={size/2} y={size/2 + 10} textAnchor="middle" fontSize="9" fill="var(--fg-3)" letterSpacing="0.1em">{sub}</text>}
</svg>
);
};
// Stacked horizontal bar (battery + h2)
const Stack = ({ segs, w = 200, h = 8 }) => {
const total = segs.reduce((a, s) => a + s.v, 0) || 1;
let x = 0;
return (
<svg width={w} height={h} style={{display: "block"}}>
{segs.map((s, i) => {
const sw = w * (s.v/total);
const r = <rect key={i} x={x} y="0" width={sw} height={h} fill={s.color || "var(--accent)"}/>;
x += sw;
return r;
})}
</svg>
);
};
// Sample data generators (deterministic)
const seed = (s) => () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
const genSpeed = () => {
const r = seed(12);
const out = [];
let v = 50;
for (let i = 0; i < 60; i++) {
v += (r() - 0.5) * 12;
v = Math.max(0, Math.min(95, v));
out.push(v);
}
return out;
};
const genSoc = () => {
const out = [];
let v = 92;
for (let i = 0; i < 60; i++) {
v -= 0.08 + Math.random()*0.4;
out.push(Math.max(15, v));
}
return out;
};
const genH2 = () => {
const out = [];
let v = 4.8;
for (let i = 0; i < 60; i++) {
v -= 0.005 + Math.random()*0.04;
out.push(Math.max(0.5, v));
}
return out;
};
window.LineChart = LineChart;
window.Bars = Bars;
window.Donut = Donut;
window.Gauge = Gauge;
window.Stack = Stack;
window.genSpeed = genSpeed;
window.genSoc = genSoc;
window.genH2 = genH2;

166
components/chrome.jsx Normal file
View File

@@ -0,0 +1,166 @@
// chrome.jsx — Sidebar, Topbar shared chrome (route-aware)
const SIDEBAR_ITEMS = [
{ id: "overview", icon: "map", label: "实时地图" },
{ id: "detail", icon: "car", label: "车辆详情" },
{ id: "history", icon: "history", label: "历史查询" },
{ id: "playback", icon: "route", label: "轨迹回放" },
{ id: "alarm", icon: "bell", label: "事件规则" },
{ id: "inbox", icon: "inbox", label: "通知中心" },
];
const SIDEBAR_SUB = [
{ id: "esg", icon: "chart", label: "ESG·碳减排" },
{ id: "canvas", icon: "settings", label: "设计画板" },
];
const Sidebar = ({ active }) => {
// resolve current route from context if present, fall back to prop
const ctx = (typeof window.useRoute === "function") ? window.useRoute() : null;
const cur = (ctx && ctx.route) || active || "overview";
const nav = (ctx && ctx.navigate) || ((p) => { window.location.hash = "#/" + p; });
return (
<div className="sidebar">
<div className="logo" title="羚牛 · Lingniu" onClick={() => nav("overview")} style={{
cursor:"pointer",
background:"#FFFFFF",
border:"1px solid var(--border-1)",
boxShadow:"0 1px 2px rgba(47,40,40,.06)",
overflow:"hidden",
padding:0,
}}>
<img src="assets/logo_light.svg" alt="羚牛"
style={{height:24, width:"auto", maxWidth:"none", marginLeft:-3, display:"block", pointerEvents:"none"}}/>
</div>
{SIDEBAR_ITEMS.map(i => (
<div key={i.id}
className={"nav-item" + (i.id === cur ? " active" : "")}
title={i.label}
onClick={() => nav(i.id)}
style={{cursor:"pointer"}}>
<Icon name={i.icon} size={18}/>
</div>
))}
<div className="sidebar-divider"/>
{SIDEBAR_SUB.map(i => (
<div key={i.id}
className={"nav-item" + (i.id === cur ? " active" : "")}
title={i.label}
onClick={() => nav(i.id)}
style={{cursor:"pointer"}}>
<Icon name={i.icon} size={18}/>
</div>
))}
<div style={{flex:1}}/>
<div className="avatar" title="张工">ZG</div>
</div>
);
};
const Topbar = ({ crumbs = ["羚牛车辆数据中心", "实时监控"], kpis = [], showSearch = true }) => {
const ctx = (typeof window.useRoute === "function") ? window.useRoute() : null;
const isMobile = ctx && ctx.isMobile;
return (
<div className="topbar">
{isMobile && (
<button onClick={ctx.openDrawer} aria-label="菜单" style={{
width:36, height:36, display:"grid", placeItems:"center", borderRadius:8,
background:"transparent", border:"none", color:"var(--fg-1)", cursor:"pointer",
marginLeft:-4,
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
)}
<div className="crumbs">
{crumbs.map((c, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="sep">/</span>}
<span className={i === crumbs.length - 1 ? "now" : "muted"}>{c}</span>
</React.Fragment>
))}
</div>
<div className="kpi-row">
{kpis.map((k, i) => (
<div key={i} className="kpi-mini">
<span className="lbl">{k.lbl}</span>
<span className="val tnum">{k.val}</span>
{k.delta && <span className={"delta " + (k.deltaUp ? "up" : "down")}>{k.deltaUp ? "▲" : "▼"} {k.delta}</span>}
</div>
))}
</div>
{showSearch && (
<div className="search" style={{marginLeft: "auto"}}>
<Icon name="search" size={13}/>
<input placeholder="搜索车牌 / VIN / 部门 / 客户…"/>
<kbd>K</kbd>
</div>
)}
<div className="right">
<div className="icon-btn"><Icon name="refresh" size={15}/></div>
<div className="icon-btn">
<Icon name="bell" size={15}/>
<span className="dot"/>
</div>
<ThemeToggle/>
<div className="icon-btn"><Icon name="settings" size={15}/></div>
<RoleBadge/>
</div>
</div>
);
};
// Role badge — shows current logged-in user, opens tweaks panel on click
const RoleBadge = () => {
const { role } = (typeof window.useCurrentRole === "function") ? window.useCurrentRole() : { role: null };
if (!role) return <div className="avatar" style={{marginLeft: 4}}></div>;
const initial = role.name.replace(/.*·/,"").charAt(0) || role.name.charAt(0);
const isAdmin = role.scope === "all";
const tip = role.scope === "dept" ? `仅可见 ${role.deptId === "biz1" ? "业务一部" : "业务二部"} 车辆` : role.desc;
return (
<div title={tip} style={{
display:"flex", alignItems:"center", gap:8, padding:"4px 4px 4px 10px",
borderRadius: 16, border:"1px solid var(--border-1)",
background: isAdmin ? "transparent" : "var(--accent-soft)",
cursor:"pointer", marginLeft: 4,
}}>
<div className="col" style={{lineHeight:1.1, alignItems:"flex-end"}}>
<span style={{fontSize:11, color:"var(--fg-1)", fontWeight:500}}>{role.name}</span>
<span style={{fontSize:9, color: isAdmin ? "var(--fg-3)" : "var(--accent)", textTransform:"uppercase", letterSpacing:0.5}}>
{role.scope === "all" ? "FULL ACCESS" : role.scope === "dept" ? "DEPT SCOPE" : role.scope === "ops" ? "OPS" : "FINANCE"}
</span>
</div>
<div className="avatar" style={{margin:0}}>{initial}</div>
</div>
);
};
// Theme toggle — switches between light & dark themes
const ThemeToggle = () => {
const ctx = (typeof window.useTheme === "function") ? window.useTheme() : null;
if (!ctx) return null;
const { theme, setTheme } = ctx;
const isDark = theme === "dark";
return (
<button className="icon-btn" onClick={() => setTheme(isDark ? "light" : "dark")}
title={isDark ? "切换到亮色" : "切换到暗色"}
style={{background:"transparent", border:"none", cursor:"pointer", padding:0}}>
{isDark ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z"/>
</svg>
)}
</button>
);
};
window.Sidebar = Sidebar;
window.Topbar = Topbar;

74
components/icons.jsx Normal file
View File

@@ -0,0 +1,74 @@
// icons.jsx — tiny stroke icon set for the cockpit
const Icon = ({ name, size = 16, className = "", style = {} }) => {
const paths = {
map: <><path d="M9 3 3 5v13l6-2 6 2 6-2V3l-6 2Z"/><path d="M9 3v13M15 5v13"/></>,
car: <><path d="M3 12l2-5h14l2 5"/><rect x="2" y="12" width="20" height="6" rx="1"/><circle cx="7" cy="18" r="1.5"/><circle cx="17" cy="18" r="1.5"/></>,
history: <><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></>,
route: <><circle cx="6" cy="19" r="2"/><circle cx="18" cy="5" r="2"/><path d="M8 19h6a4 4 0 0 0 0-8h-4a4 4 0 0 1 0-8h6"/></>,
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 7 3 9H3c0-2 3-2 3-9Z"/><path d="M10 21a2 2 0 0 0 4 0"/></>,
inbox: <><path d="M3 13h5l1 2h6l1-2h5"/><path d="M5 5h14l2 8v6H3v-6Z"/></>,
settings: <><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.6-2-3.4-2.4.9a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.5a7 7 0 0 0-2 1.2l-2.4-.9-2 3.4 2 1.6A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.6 2 3.4 2.4-.9c.6.5 1.3.9 2 1.2L10 21h4l.5-2.5c.7-.3 1.4-.7 2-1.2l2.4.9 2-3.4-2-1.6c.1-.4.1-.8.1-1.2Z"/></>,
bolt: <><path d="M13 2 4 13h7l-1 9 9-11h-7l1-9Z"/></>,
fuel: <><path d="M3 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M3 21h11M14 8h2a2 2 0 0 1 2 2v6a2 2 0 0 0 4 0V8l-3-3"/></>,
h2: <><path d="M5 6v12M5 12h6M11 6v12"/><path d="M15 14a3 3 0 1 1 6 0c0 2-3 2-3 4h3"/></>,
gauge: <><path d="M21 12a9 9 0 1 0-15.5 6.2"/><path d="m12 12 5-3"/><circle cx="12" cy="12" r="1.4"/></>,
thermo: <><path d="M14 14V5a2 2 0 0 0-4 0v9a4 4 0 1 0 4 0Z"/></>,
tire: <><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v6M12 15v6M3 12h6M15 12h6M5.6 5.6l4.2 4.2M14.2 14.2l4.2 4.2M5.6 18.4l4.2-4.2M14.2 9.8l4.2-4.2"/></>,
play: <><path d="M6 4l14 8-14 8Z"/></>,
pause: <><path d="M7 4v16M17 4v16"/></>,
next: <><path d="M5 4v16l14-8Z"/></>,
prev: <><path d="M19 20V4L5 12Z"/></>,
chevron: <><path d="m9 6 6 6-6 6"/></>,
chevDown: <><path d="m6 9 6 6 6-6"/></>,
plus: <><path d="M12 5v14M5 12h14"/></>,
close: <><path d="M6 6l12 12M18 6 6 18"/></>,
search: <><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></>,
filter: <><path d="M3 5h18l-7 9v6l-4-2v-4Z"/></>,
download: <><path d="M12 4v12m-5-5 5 5 5-5M4 20h16"/></>,
refresh: <><path d="M4 12a8 8 0 0 1 14-5.3L21 9M21 4v5h-5M20 12a8 8 0 0 1-14 5.3L3 15M3 20v-5h5"/></>,
pin: <><path d="M12 22s7-7 7-12a7 7 0 0 0-14 0c0 5 7 12 7 12Z"/><circle cx="12" cy="10" r="2.5"/></>,
expand: <><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></>,
grip: <><circle cx="9" cy="6" r="1.2"/><circle cx="15" cy="6" r="1.2"/><circle cx="9" cy="12" r="1.2"/><circle cx="15" cy="12" r="1.2"/><circle cx="9" cy="18" r="1.2"/><circle cx="15" cy="18" r="1.2"/></>,
layers: <><path d="m12 3 9 5-9 5-9-5Z"/><path d="m3 13 9 5 9-5M3 18l9 5 9-5"/></>,
chart: <><path d="M4 4v16h16"/><path d="m8 14 3-4 3 3 5-7"/></>,
user: <><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></>,
shield: <><path d="M12 3 4 6v6c0 5 3.5 8 8 9 4.5-1 8-4 8-9V6Z"/></>,
flag: <><path d="M5 21V4M5 4h13l-3 5 3 5H5"/></>,
sliders: <><path d="M4 6h12M20 6h0M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="18" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/></>,
edit: <><path d="M4 20h4l11-11-4-4L4 16Z"/></>,
trash: <><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></>,
plug: <><path d="M9 2v6M15 2v6M7 8h10v3a5 5 0 0 1-10 0Z"/><path d="M12 16v6"/></>,
wifi: <><path d="M5 12a10 10 0 0 1 14 0M8 15a6 6 0 0 1 8 0M11 18h2"/></>,
sat: <><circle cx="12" cy="12" r="3"/><path d="M5 12a7 7 0 0 1 7-7M19 12a7 7 0 0 1-7 7M3 12a9 9 0 0 1 9-9M21 12a9 9 0 0 1-9 9"/></>,
lightning: <><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8Z"/></>,
wrench: <><path d="M14 7a4 4 0 0 1 5 5l-2-1-2 2 1 2a4 4 0 0 1-5-5l-7 7 3 3 7-7"/></>,
truck: <><path d="M3 7h11v10H3zM14 10h4l3 3v4h-7"/><circle cx="7" cy="18" r="1.5"/><circle cx="17" cy="18" r="1.5"/></>,
list: <><path d="M4 6h16M4 12h16M4 18h16"/></>,
fullscreen: <><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></>,
timeline: <><path d="M3 12h18"/><circle cx="6" cy="12" r="2"/><circle cx="14" cy="12" r="2"/><circle cx="20" cy="12" r="2"/></>,
branch: <><path d="M6 3v18M18 21V9a4 4 0 0 0-4-4h-4"/><circle cx="6" cy="3" r="2"/><circle cx="6" cy="21" r="2"/><circle cx="18" cy="9" r="2"/></>,
bookmark: <><path d="M5 3h14v18l-7-5-7 5Z"/></>,
moon: <><path d="M21 13a9 9 0 1 1-10-10 7 7 0 0 0 10 10Z"/></>,
speed: <><circle cx="12" cy="13" r="9"/><path d="m12 13 5-3M12 4v2"/></>,
leaf: <><path d="M4 20c0-9 7-16 16-16 0 9-7 16-16 16Z"/><path d="M4 20c5-5 11-9 16-13"/></>,
cube: <><path d="m12 3 9 5v8l-9 5-9-5V8Z"/><path d="m3 8 9 5 9-5M12 13v9"/></>,
pulse: <><path d="M3 12h4l3-7 4 14 3-7h4"/></>,
mail: <><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></>,
phone: <><path d="M5 4h4l2 5-3 2a11 11 0 0 0 5 5l2-3 5 2v4a2 2 0 0 1-2 2A17 17 0 0 1 3 6a2 2 0 0 1 2-2Z"/></>,
clipboard: <><rect x="6" y="4" width="12" height="17" rx="2"/><rect x="9" y="2" width="6" height="4" rx="1"/><path d="M9 11h6M9 15h4"/></>,
x: <><path d="M6 6l12 12M18 6 6 18"/></>,
sun: <><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></>,
};
return (
<svg
viewBox="0 0 24 24"
width={size} height={size}
fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"
className={className}
style={style}
aria-hidden="true"
>{paths[name] || null}</svg>
);
};
window.Icon = Icon;

318
components/map.jsx Normal file
View File

@@ -0,0 +1,318 @@
// map.jsx — Stylized cockpit map. SVG-based road network. Theme-aware via CSS vars.
const MAP_BG = "var(--map-bg)";
const MAP_GRID = "var(--map-grid)";
const MAP_PARK = "var(--map-park)";
const MAP_PARK_STROKE = "var(--map-park-stroke)";
const MAP_RIVER = "var(--map-river)";
const MAP_ROAD_MINOR = "var(--map-road-minor)";
const MAP_ROAD_MAJOR_OUTER = "var(--map-road-major-outer)";
const MAP_ROAD_MAJOR_INNER = "var(--map-road-major-inner)";
// 嘉兴乍浦 (Zhapu Port, Jiaxing) — port city on Hangzhou Bay.
// Layout: Hangzhou Bay sea fills the south (y > ~620). Port piers jutting south.
// G15 Shen-Hai expressway runs roughly NS (right side). 乍嘉苏 expressway diagonal.
// Inland canals (东湖、独山港河) cross the city. Pinghu 老城 in the upper-middle.
const ROADS_MAJOR = [
// G15 沈海高速 (NS, right)
"M 1020 40 L 1020 200 L 1010 380 L 1000 540 L 1000 620",
// 乍嘉苏高速 (NWSE diagonal)
"M 60 120 L 240 220 L 420 320 L 600 400 L 760 480 L 880 560",
// 海盐塘公路 (EW arterial through old town)
"M 60 280 L 280 280 L 520 300 L 780 290 L 1140 280",
// 乍浦大道 (EW, mid, leading to port)
"M 60 460 L 280 460 L 520 470 L 800 470 L 1140 470",
// 港区疏港路 (curves down to port)
"M 600 470 L 600 560 L 580 620",
"M 800 470 L 820 560 L 840 620",
// 外环 — connector
"M 240 220 L 240 460 L 260 600",
"M 880 200 L 880 400 L 880 560",
];
const ROADS_MINOR = [
// city grid (north of bay)
"M 80 160 L 1140 160","M 80 220 L 1140 220","M 80 360 L 1140 360","M 80 410 L 1140 410","M 80 540 L 980 540",
"M 160 60 L 160 600","M 320 60 L 320 600","M 400 60 L 400 600","M 520 60 L 520 600","M 680 60 L 680 600","M 760 60 L 760 600","M 880 60 L 880 600","M 940 60 L 940 600",
// port grid
"M 540 540 L 540 620","M 660 540 L 660 620","M 720 540 L 720 620","M 780 540 L 780 620",
];
// 杭州湾 + 内河水系
const RIVERS = [
// 东湖塘 (EW canal in city)
"M 0 350 Q 200 340 380 360 T 760 350 Q 920 340 1240 360",
// 独山港河 / pier inlet
"M 460 470 L 470 540 L 480 600",
];
// 杭州湾 — fills bottom of map
const SEA_PATH = "M -20 620 L 1260 620 L 1260 820 L -20 820 Z";
// 港池 — port basins (water inlets cut into land)
const PORT_BASINS = [
"M 360 620 L 380 540 L 440 540 L 460 620 Z",
"M 600 620 L 620 580 L 700 580 L 720 620 Z",
"M 820 620 L 840 580 L 920 580 L 940 620 Z",
];
// 防波堤 / 码头 — piers extending into the sea
const PIERS = [
"M 480 620 L 480 700 L 540 700 L 540 620",
"M 740 620 L 740 720 L 800 720 L 800 620",
"M 960 620 L 960 680 L 1020 680 L 1020 620",
];
const PARKS = [
// 九龙山 / 南湾绿地
{ x: 220, y: 80, w: 90, h: 70, label: "九龙山" },
// 东湖公园
{ x: 440, y: 220, w: 80, h: 50, label: "东湖" },
// 临港绿带
{ x: 80, y: 500, w: 140, h: 90, label: "南湾绿带" },
];
const POIS = [
{ x: 320, y: 180, label: "总站·乍浦城区" },
{ x: 600, y: 240, label: "氢能补能站·东湖" },
{ x: 880, y: 320, label: "维保中心·G15" },
{ x: 700, y: 540, label: "调度中心·港区" },
{ x: 960, y: 540, label: "重卡停车场" },
{ x: 240, y: 540, label: "化工园补能站" },
];
// 12 vehicles around the city
// src: T = TBOX 3296/2016国标, J = JT808/1078, B = both (TBOX + JT)
// VEHICLES dataset comes from data/fleet.js (window.VEHICLES) — asset-management model.
// Source badge — small T/JT chip
const SourceBadge = ({ src, size = "sm" }) => {
const items = src === "B" ? ["T","JT"] : src === "T" ? ["T"] : ["JT"];
const colors = { T: "var(--info)", JT: "var(--accent)" };
return (
<span style={{display:"inline-flex", gap:2}}>
{items.map(k => (
<span key={k} style={{
fontFamily:"var(--font-mono)", fontSize: size === "sm" ? 9 : 10,
padding: size === "sm" ? "1px 4px" : "2px 5px",
borderRadius:3, lineHeight:1.2,
color: colors[k], background: `oklch(from ${colors[k]} l c h / 0.15)`,
border: `1px solid ${colors[k]}`, opacity:0.85, letterSpacing:"0.04em", fontWeight:600,
}}>{k}</span>
))}
</span>
);
};
window.SourceBadge = SourceBadge;
// recent path traces (last 30 min ghost trails) for animation feel
const TRACES = [
{ id: "浙F07179F", d: "M 200 260 L 240 250 L 280 260", color: "ok" },
{ id: "浙F02002F", d: "M 540 260 L 580 256 L 620 260", color: "ok" },
{ id: "浙F08638F", d: "M 540 460 L 560 500 L 580 540", color: "danger" },
];
const StatusColor = {
ok: "var(--ok)",
warn: "var(--warn)",
danger: "var(--danger)",
idle: "var(--fg-3)",
};
const VehiclePin = ({ v, selected, onClick, showHeading = true, animate = true }) => {
const color = StatusColor[v.status];
return (
<g transform={`translate(${v.x} ${v.y})`} style={{cursor:"pointer"}} onClick={() => onClick && onClick(v)}>
{/* heading cone */}
{showHeading && v.status !== "idle" && (
<g transform={`rotate(${v.h})`} opacity={0.5}>
<path d="M -10 -2 L 0 -22 L 10 -2 Z" fill={color} opacity="0.35"/>
</g>
)}
{/* pulse ring (only for moving) */}
{animate && v.status === "ok" && v.speed > 0 && (
<circle r="14" fill="none" stroke={color} strokeWidth="1" opacity="0.6">
<animate attributeName="r" from="6" to="20" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" from="0.7" to="0" dur="2s" repeatCount="indefinite"/>
</circle>
)}
{/* outer halo */}
<circle r="9" fill={color} opacity="0.18"/>
{/* core */}
<circle r="5" fill={color} stroke="var(--map-bg)" strokeWidth="1.5"/>
{selected && <circle r="11" fill="none" stroke={color} strokeWidth="1.5"/>}
</g>
);
};
const PoiMarker = ({ poi }) => (
<g transform={`translate(${poi.x} ${poi.y})`}>
<rect x="-3" y="-3" width="6" height="6" fill="var(--accent)" opacity="0.8" transform="rotate(45)"/>
<text x="8" y="3" fill="var(--fg-2)" fontSize="9" fontFamily="var(--font-mono)" letterSpacing="0.05em">{poi.label}</text>
</g>
);
const Compass = () => (
<g transform="translate(60 60)">
<circle r="22" fill="var(--bg-1)" opacity="0.85" stroke="var(--border-2)"/>
<path d="M 0 -14 L 4 0 L 0 14 L -4 0 Z" fill="var(--accent)" opacity="0.5"/>
<path d="M 0 -14 L 4 0 L 0 4 L -4 0 Z" fill="var(--accent)"/>
<text textAnchor="middle" y="-26" fontSize="9" fill="var(--fg-2)" fontFamily="var(--font-mono)">N</text>
</g>
);
const ScaleBar = ({ x = 60, y = 720 }) => (
<g transform={`translate(${x} ${y})`}>
<line x1="0" y1="0" x2="100" y2="0" stroke="var(--fg-1)" strokeWidth="1.5"/>
<line x1="0" y1="-3" x2="0" y2="3" stroke="var(--fg-1)" strokeWidth="1.5"/>
<line x1="50" y1="-3" x2="50" y2="3" stroke="var(--fg-1)" strokeWidth="1.5"/>
<line x1="100" y1="-3" x2="100" y2="3" stroke="var(--fg-1)" strokeWidth="1.5"/>
<text x="100" y="-6" fontSize="9" fill="var(--fg-2)" fontFamily="var(--font-mono)">500m</text>
</g>
);
// the main rendered map
const FleetMap = ({
selectedId,
onSelect,
vehicles,
showLabels = false,
showHeatmap = false,
showPaths = true,
highlightPath = null, // for playback view: a polyline string
playbackProgress = 0,
playbackPoint = null,
variant = "default", // "default" | "minimal" | "satellite"
}) => {
const isMin = variant === "minimal";
// Default: pull from global fleet, only those with map coords
const _vehicles = vehicles || (window.VEHICLES || []).filter(v => v.x != null && v.y != null);
return (
<svg viewBox="0 0 1240 800" width="100%" height="100%" style={{display:"block", background: MAP_BG}}>
<defs>
<linearGradient id="mapVignette" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--map-vignette)" stopOpacity="0"/>
<stop offset="1" stopColor="var(--map-vignette)" stopOpacity="var(--map-vignette-strength)"/>
</linearGradient>
<radialGradient id="heat" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stopColor="var(--accent)" stopOpacity="0.5"/>
<stop offset="1" stopColor="var(--accent)" stopOpacity="0"/>
</radialGradient>
<pattern id="mapGrid" x="0" y="0" width="80" height="80" patternUnits="userSpaceOnUse">
<path d="M 80 0 L 0 0 0 80" fill="none" stroke={MAP_GRID} strokeWidth="0.5"/>
</pattern>
</defs>
{/* base */}
<rect width="1240" height="800" fill={MAP_BG}/>
<rect width="1240" height="800" fill="url(#mapGrid)"/>
{/* 杭州湾 — sea */}
{!isMin && (
<path d={SEA_PATH} fill={MAP_RIVER} opacity="0.55"/>
)}
{/* 港池 — port water basins cut into land */}
{!isMin && PORT_BASINS.map((d, i) => (
<path key={"pb"+i} d={d} fill={MAP_RIVER} opacity="0.55"/>
))}
{/* coastline marker */}
{!isMin && (
<line x1="0" y1="620" x2="1240" y2="620"
stroke="var(--map-park-stroke)" strokeWidth="0.6" strokeDasharray="3 3" opacity="0.6"/>
)}
{/* sea label */}
{!isMin && (
<text x="1140" y="700" fontSize="14" fill="var(--fg-3)" textAnchor="end"
fontFamily="var(--font-sans)" letterSpacing="2" opacity="0.55">杭州湾 · 乍浦港</text>
)}
{/* parks */}
{!isMin && PARKS.map((p, i) => (
<g key={i}>
<rect x={p.x} y={p.y} width={p.w} height={p.h}
fill={MAP_PARK} stroke={MAP_PARK_STROKE} strokeWidth="0.5" rx="3"/>
{p.label && (
<text x={p.x + p.w/2} y={p.y + p.h/2 + 3} fontSize="9"
fill="var(--fg-3)" textAnchor="middle" opacity="0.7">{p.label}</text>
)}
</g>
))}
{/* river */}
{!isMin && RIVERS.map((d, i) => (
<path key={i} d={d} stroke={MAP_RIVER} strokeWidth="10" fill="none" strokeLinecap="round"/>
))}
{/* piers */}
{!isMin && PIERS.map((d, i) => (
<path key={"pier"+i} d={d} fill="none" stroke="var(--map-road-major-outer)"
strokeWidth="8" strokeLinecap="butt"/>
))}
{/* heatmap layer */}
{showHeatmap && _vehicles.map((v, i) => (
<ellipse key={i} cx={v.x} cy={v.y} rx="50" ry="50" fill="url(#heat)"/>
))}
{/* minor roads */}
{ROADS_MINOR.map((d, i) => (
<path key={i} d={d} fill="none" stroke={MAP_ROAD_MINOR} strokeWidth="1"/>
))}
{/* major roads casing + center stripe */}
{ROADS_MAJOR.map((d, i) => (
<g key={i}>
<path d={d} fill="none" stroke={MAP_ROAD_MAJOR_OUTER} strokeWidth="6" strokeLinecap="round"/>
<path d={d} fill="none" stroke={MAP_ROAD_MAJOR_INNER} strokeWidth="3" strokeLinecap="round"/>
</g>
))}
{/* vignette */}
<rect width="1240" height="800" fill="url(#mapVignette)" pointerEvents="none"/>
{/* highlighted path */}
{highlightPath && (
<g>
<path d={highlightPath} fill="none" stroke="var(--accent)" strokeWidth="3" strokeLinecap="round" opacity="0.35"/>
<path d={highlightPath} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round"
strokeDasharray="6 4" style={{filter: "drop-shadow(0 0 6px var(--accent-glow))"}}/>
</g>
)}
{/* recent traces */}
{showPaths && !highlightPath && TRACES.map((t, i) => (
<path key={i} d={t.d} fill="none" stroke={StatusColor[t.color]} strokeWidth="2" opacity="0.35" strokeLinecap="round"/>
))}
{/* POIs */}
{!isMin && POIS.map((p, i) => <PoiMarker key={i} poi={p}/>)}
{/* vehicles */}
{_vehicles.map(v => (
<VehiclePin key={v.id} v={v}
selected={v.id === selectedId}
onClick={onSelect}
showHeading
animate={!highlightPath}/>
))}
{/* selected vehicle label */}
{selectedId && _vehicles.filter(v => v.id === selectedId).map(v => (
<g key={v.id} transform={`translate(${v.x + 14} ${v.y - 14})`}>
<rect x="0" y="-12" width="86" height="22" rx="4" fill="var(--bg-1)" stroke={StatusColor[v.status]} strokeWidth="1"/>
<text x="6" y="3" fill="var(--fg-0)" fontSize="11" fontFamily="var(--font-mono)" fontWeight="500">{v.id}</text>
<text x="56" y="3" fill={StatusColor[v.status]} fontSize="10" fontFamily="var(--font-mono)">{v.speed}km/h</text>
</g>
))}
{/* playback marker */}
{playbackPoint && (
<g transform={`translate(${playbackPoint.x} ${playbackPoint.y})`}>
<circle r="14" fill="var(--accent)" opacity="0.18"/>
<circle r="7" fill="var(--accent)" stroke="var(--map-bg)" strokeWidth="2"/>
<circle r="3" fill="var(--map-bg)"/>
</g>
)}
{/* HUD overlays */}
<Compass/>
<ScaleBar/>
</svg>
);
};
window.FleetMap = FleetMap;