init: 羚牛车辆数据中心原型 + 部署配置
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
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:
150
components/charts.jsx
Normal file
150
components/charts.jsx
Normal 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
166
components/chrome.jsx
Normal 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
74
components/icons.jsx
Normal 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
318
components/map.jsx
Normal 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 N–S (right side). 乍嘉苏 expressway diagonal.
|
||||
// Inland canals (东湖、独山港河) cross the city. Pinghu 老城 in the upper-middle.
|
||||
const ROADS_MAJOR = [
|
||||
// G15 沈海高速 (N–S, right)
|
||||
"M 1020 40 L 1020 200 L 1010 380 L 1000 540 L 1000 620",
|
||||
// 乍嘉苏高速 (NW–SE diagonal)
|
||||
"M 60 120 L 240 220 L 420 320 L 600 400 L 760 480 L 880 560",
|
||||
// 海盐塘公路 (E–W arterial through old town)
|
||||
"M 60 280 L 280 280 L 520 300 L 780 290 L 1140 280",
|
||||
// 乍浦大道 (E–W, 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 = [
|
||||
// 东湖塘 (E–W 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;
|
||||
Reference in New Issue
Block a user