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 探活
319 lines
13 KiB
JavaScript
319 lines
13 KiB
JavaScript
// 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;
|