Files
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

319 lines
13 KiB
JavaScript
Raw Permalink 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.
// 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;