diff --git a/vehicle-map/.env.example b/vehicle-map/.env.example new file mode 100644 index 00000000..4969dfa3 --- /dev/null +++ b/vehicle-map/.env.example @@ -0,0 +1,7 @@ +VEHICLE_MAP_HOST=0.0.0.0 +VEHICLE_MAP_PORT=20800 +OPEN_PLATFORM_BASE_URL=https://open.d.lnoneos.com +OPEN_PLATFORM_APP_KEY=replace-with-32-character-app-key +UPSTREAM_TIMEOUT_SECONDS=15 +DASHBOARD_CACHE_SECONDS=5 +STATION_CACHE_SECONDS=3600 diff --git a/vehicle-map/.gitignore b/vehicle-map/.gitignore new file mode 100644 index 00000000..81735943 --- /dev/null +++ b/vehicle-map/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +*.log +node_modules/ +.idea/ +.vscode/ diff --git a/vehicle-map/README.md b/vehicle-map/README.md new file mode 100644 index 00000000..d9691009 --- /dev/null +++ b/vehicle-map/README.md @@ -0,0 +1,37 @@ +# 羚牛 Vehicle Map + +全国氢能车辆与加氢站运营驾驶舱。浏览器只访问本服务的 `/api/dashboard`,服务端使用开放平台 AppKey 获取其授权车辆、当日里程和资产库只读加氢站点位,AppKey 不会下发到浏览器。 + +## 当前部署 + +- ECS 服务:`lingniu-vehicle-map.service` +- 访问地址: +- 健康检查: + +原始仓库只有静态页面和静态文件服务,没有车辆数据接口;当前版本已改为通过服务端代理对接车辆数据开放平台,页面展示的数据不再使用硬编码车辆和加氢站样例。 + +## 本地运行 + +```bash +export OPEN_PLATFORM_APP_KEY='<32位AppKey>' +python3 server.py +``` + +默认地址:`http://127.0.0.1:20800`。 + +## 接口依赖 + +- `POST /api/v1/vehicles/realtime/query`:全部授权车辆实时位置与状态; +- `POST /api/v1/vehicles/mileage/query`:全部授权车辆当日里程; +- `POST /api/v1/hydrogen-stations/query`:资产库只读加氢站地图点位。 + +以上三个接口均使用开放平台的 `Authorization: Bearer ` 鉴权。生产 AppKey 仅保存在 ECS 的 `/opt/lingniu-vehicle-map/env/vehicle-map.env`,文件权限为 `root:vehicle-map 0640`。 + +本服务提供: + +- `GET /api/health`:进程及配置状态; +- `GET /api/dashboard`:面向前端的聚合数据,默认缓存5秒;加氢站缓存1小时。 + +全国视图按车辆最新 GPS 坐标落入省级行政区,不按车牌归属地推断;没有有效实时坐标的车辆会单独计入“无实时位置”,不会伪造省份归属。页面每15秒刷新一次。 + +车辆地图按缩放级别逐级下钻:全国视角(小于7级)按省聚合,7–9.5级按市聚合,9.5–12级按区县聚合,12级及以上显示当前视野内的单车真实位置。点击省、市、区县气泡会自动进入下一级。 diff --git a/vehicle-map/app.js b/vehicle-map/app.js new file mode 100644 index 00000000..54687077 --- /dev/null +++ b/vehicle-map/app.js @@ -0,0 +1,763 @@ +/** Lingniu Vehicle Map — live data from the vehicle open platform. */ + +const i18n = { + zh: { + vehicleModeTitle: '车辆网络', stationModeTitle: '加氢站网络', + kpiTotalFleet: '车辆总数', kpiTotalStation: '站点总数', + kpiOnlineFleet: '今日上线车辆', kpiCooperativeStation: '合作站点', kpiDailyMileage: '今日运营里程', kpiMonthlyHydrogen: '本月加氢量', + unitVehicle: '辆', unitVehicles: '辆', unitStation: '座', unitStations: '座', btnVehicle: '车辆', btnStation: '加氢站', + themeDark: '深色', themeLight: '浅色', legendPrimary: '核心节点 (>100)', + legendMid: '重点节点 (20-100)', legendLow: '普通节点 (<20)', btnResetView: '复位视角', + btn3dView: '3D/2D 视角', btnNationalView: '全国视图', + mapFooterGis: '地图引擎: 羚牛氢能 GIS (AMap 3D Engine)', + mapFooterStatus: '数据来源: 羚牛车辆数据开放平台', hintNational: '视角: 全国运营态势', + panelStatusVehicle: '车辆运营状态分布', panelStatusStation: '加氢站类型分布', + panelRankVehicle: '车辆区域分布 TOP 排名', panelRankStation: '加氢站省份 TOP 排名', + statusTotal: '总数', statusRunning: '行驶中', statusStopped: '静止中', statusOffline: '离线', + stationCooperative: '合作站', stationExternal: '外部站', stationTotal: '全部站点', stationPartnerMarker: '合作', + rankByFleet: '按数量', rankByDist: '按里程', rankByStations: '按站点数', rankByHydrogen: '按加氢量', rankNoHydrogenData: '当前视野暂无加氢数据', + stationViewportEmpty: '当前视野暂无加氢站 · 可拖动地图或复位视角', viewportNow: '当前视野', viewportAll: '全部', + onlineText: '在线', unitTail: '台' + }, + en: { + vehicleModeTitle: 'Vehicle Network', stationModeTitle: 'H₂ Station Network', + kpiTotalFleet: 'Total Fleet', kpiTotalStation: 'Total Stations', + kpiOnlineFleet: 'Active Today', kpiCooperativeStation: 'Partner Stations', kpiDailyMileage: 'Today Mileage', kpiMonthlyHydrogen: 'Monthly Hydrogen Fill', + unitVehicle: 'unit', unitVehicles: 'units', unitStation: 'station', unitStations: 'stations', btnVehicle: 'Fleet', btnStation: 'H₂ Stations', + themeDark: 'Dark', themeLight: 'Light', legendPrimary: 'Core (>100)', + legendMid: 'Key (20-100)', legendLow: 'Standard (<20)', btnResetView: 'Reset View', + btn3dView: '3D/2D View', btnNationalView: 'National View', + mapFooterGis: 'Engine: Lingniu H₂ GIS (AMap 3D Engine)', + mapFooterStatus: 'Source: Lingniu Vehicle Open Platform', hintNational: 'View: National Overview', + panelStatusVehicle: 'Fleet Status Breakdown', panelStatusStation: 'Station Type Breakdown', + panelRankVehicle: 'Top Fleet Regions', panelRankStation: 'Top Provinces by Stations', + statusTotal: 'Total', statusRunning: 'Driving', statusStopped: 'Idle', statusOffline: 'Offline', + stationCooperative: 'Partners', stationExternal: 'External', stationTotal: 'All Stations', stationPartnerMarker: 'Partner', + rankByFleet: 'By Count', rankByDist: 'By Mileage', rankByStations: 'By Stations', rankByHydrogen: 'By H₂ Volume', rankNoHydrogenData: 'No hydrogen data in this view', + stationViewportEmpty: 'No H₂ stations in this view · Pan the map or reset view', viewportNow: 'In view', viewportAll: 'All', + onlineText: 'Online', unitTail: 'units' + } +}; + +const PROVINCE_ENGLISH = { + 北京: 'Beijing', 天津: 'Tianjin', 上海: 'Shanghai', 重庆: 'Chongqing', 河北: 'Hebei', 河南: 'Henan', + 云南: 'Yunnan', 辽宁: 'Liaoning', 黑龙江: 'Heilongjiang', 湖南: 'Hunan', 安徽: 'Anhui', 山东: 'Shandong', + 新疆: 'Xinjiang', 江苏: 'Jiangsu', 浙江: 'Zhejiang', 江西: 'Jiangxi', 湖北: 'Hubei', 广西: 'Guangxi', + 甘肃: 'Gansu', 山西: 'Shanxi', 内蒙古: 'Inner Mongolia', 陕西: 'Shaanxi', 吉林: 'Jilin', 福建: 'Fujian', + 贵州: 'Guizhou', 广东: 'Guangdong', 青海: 'Qinghai', 西藏: 'Tibet', 四川: 'Sichuan', 宁夏: 'Ningxia', + 海南: 'Hainan', 香港: 'Hong Kong', 澳门: 'Macao', 台湾: 'Taiwan' +}; + +const ADMIN_ENGLISH_OVERRIDES = { + 六安: "Lu'an", 台州: 'Taizhou', 重庆: 'Chongqing', 长治: 'Changzhi', 长春: 'Changchun', + 长沙: 'Changsha', 厦门: 'Xiamen', 乐山: 'Leshan', 朝阳: 'Chaoyang', 鄂尔多斯: 'Ordos', + 乌鲁木齐: 'Urumqi', 喀什: 'Kashgar', 香港: 'Hong Kong', 澳门: 'Macao' +}; + +const ADMIN_SUFFIX_ENGLISH = [ + ['特别行政区', 'SAR'], ['自治州', 'Autonomous Prefecture'], ['自治县', 'Autonomous County'], + ['新区', 'New Area'], ['地区', 'Prefecture'], ['林区', 'Forestry District'], ['盟', 'League'], + ['省', 'Province'], ['市', 'City'], ['区', 'District'], ['县', 'County'] +]; + +const ETHNIC_ENGLISH = { + 壮族: 'Zhuang', 回族: 'Hui', 维吾尔: 'Uyghur', 蒙古族: 'Mongol', 藏族: 'Tibetan', + 彝族: 'Yi', 苗族: 'Miao', 傣族: 'Dai', 白族: 'Bai', 哈尼族: 'Hani', 哈萨克: 'Kazakh', + 朝鲜族: 'Korean', 土家族: 'Tujia', 布依族: 'Bouyei', 侗族: 'Dong' +}; + +let map = null; +let markerList = []; +let infoWindowList = []; +let is3DPitch = true; +let currentMode = 'vehicle'; +let currentTheme = 'theme-white'; +let currentLang = 'zh'; +let currentRankType = 'primary'; +let dashboard = null; +let refreshTimer = null; +let districtExplorerPromise = null; +const areaNodePromises = new Map(); +let regionAggregationVersion = 0; +let vehicleRegionSummary = { level: 'province', nodes: [], unassigned: 0, loading: true }; + +const REGION_ZOOM = { city: 7, district: 9.5, vehicle: 12 }; +const MUNICIPALITIES = new Set(['北京', '天津', '上海', '重庆']); + +document.addEventListener('DOMContentLoaded', () => { + initClock(); + initAMapInstance(); + loadDashboard(); + refreshTimer = window.setInterval(loadDashboard, 15000); +}); + +async function loadDashboard() { + setDataState('loading'); + try { + const response = await fetch('/api/dashboard', { headers: { Accept: 'application/json' } }); + const payload = await response.json(); + if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`); + dashboard = payload; + updateDashboardUI(); + refreshVehicleRegionNodes(payload); + setDataState('ready'); + } catch (error) { + console.error('dashboard refresh failed', error); + setDataState('error'); + } +} + +function setDataState(state) { + const el = document.getElementById('mapStatusText'); + if (!el) return; + if (state === 'loading' && !dashboard) el.textContent = currentLang === 'zh' ? '正在同步开放平台数据…' : 'Syncing open-platform data…'; + if (state === 'error') el.textContent = currentLang === 'zh' ? '数据同步失败 · 将自动重试' : 'Data sync failed · Retrying'; +} + +function initAMapInstance() { + if (typeof AMap === 'undefined') { + setDataState('error'); + return; + } + map = new AMap.Map('amapContainer', { + zoom: 4.8, center: [108.948024, 34.263161], viewMode: '3D', pitch: 30, + mapStyle: getAMapThemeStyle(currentTheme), showBuildingBlock: false, showLabel: true + }); + map.on('complete', renderAMapMarkers); + map.on('zoomend', () => { + if (currentMode === 'vehicle') refreshVehicleRegionNodes(dashboard); + else refreshStationViewport(); + }); + map.on('moveend', () => { + if (currentMode === 'vehicle' && hierarchyLevelForZoom() !== 'province') refreshVehicleRegionNodes(dashboard); + if (currentMode === 'station') refreshStationViewport(); + }); +} + +function refreshStationViewport() { + updateRankingControls(); + renderRankingList(currentRankType); + renderAMapMarkers(); +} + +function updateDashboardUI() { + if (!dashboard) return; + const dict = i18n[currentLang]; + const summary = dashboard.summary; + const total = currentMode === 'vehicle' ? summary.totalVehicles : summary.totalStations; + const active = currentMode === 'vehicle' ? summary.onlineVehicles : summary.cooperativeStations; + const unit = countUnit(total, currentMode, dict); + const activeRate = total ? (active * 100 / total).toFixed(1) : '0.0'; + const kpiLabels = modeKpiLabels(dict); + document.getElementById('dashboardTitle').textContent = currentMode === 'vehicle' ? dict.vehicleModeTitle : dict.stationModeTitle; + document.getElementById('kpiTotalLabel').textContent = kpiLabels.total; + document.getElementById('kpiActiveLabel').textContent = kpiLabels.active; + document.getElementById('kpiFleetTotal').innerHTML = `${formatNumber(total)} ${unit}`; + document.getElementById('kpiFleetOnline').innerHTML = `${formatNumber(active)} ${countUnit(active, currentMode, dict)} (${activeRate}%)`; + const activity = currentMode === 'vehicle' + ? { label: dict.kpiDailyMileage, value: summary.todayMileageKm, unit: 'km' } + : { label: dict.kpiMonthlyHydrogen, value: summary.monthlyHydrogenKg, unit: 'kg' }; + document.getElementById('kpiActivityLabel').textContent = activity.label; + document.getElementById('kpiDailyDist').innerHTML = activity.value == null + ? `— ${activity.unit}` + : `${formatNumber(activity.value, 1)} ${activity.unit}`; + const regionSummary = buildVehicleRegionNodes(); + document.getElementById('mapStatusText').textContent = mapStatusSummaryText(summary, regionSummary, dashboard.asOf); + updateStatusPanel(); + updateRankingControls(); + renderRankingList(currentRankType); + renderAMapMarkers(); +} + +function mapStatusSummaryText(summary, regionSummary, asOf, mode = currentMode, lang = currentLang) { + if (mode === 'station') { + const cooperative = Number(summary.cooperativeStations || 0); + const external = Math.max(0, Number(summary.totalStations || 0) - cooperative); + return lang === 'zh' + ? `开放平台已同步 · ${summary.totalStations}座加氢站 · ${cooperative}座合作站点 · ${external}座外部站点 · ${asOf}` + : `Open platform synced · ${summary.totalStations} H₂ stations · ${cooperative} partner stations · ${external} external stations · ${asOf}`; + } + const regionText = regionSummary.loading ? regionLoadingText(regionSummary.level, lang) : regionSummaryText(regionSummary, lang); + return lang === 'zh' + ? `开放平台已同步 · ${summary.totalVehicles}辆授权车辆 · ${regionText} · ${asOf}` + : `Open platform synced · ${summary.totalVehicles} authorized vehicles · ${regionText} · ${asOf}`; +} + +function modeKpiLabels(dict = i18n[currentLang], mode = currentMode) { + return mode === 'vehicle' + ? { total: dict.kpiTotalFleet, active: dict.kpiOnlineFleet } + : { total: dict.kpiTotalStation, active: dict.kpiCooperativeStation }; +} + +function updateStatusPanel() { + const dict = i18n[currentLang]; + const summary = dashboard.summary; + const title = document.getElementById('panelStatusTitle'); + const labels = [document.getElementById('statusLabel1'), document.getElementById('statusLabel2'), document.getElementById('statusLabel3')]; + const values = [document.getElementById('statusValue1'), document.getElementById('statusValue2'), document.getElementById('statusValue3')]; + const dots = [...document.querySelectorAll('.status-glass-grid3 .status-dot')]; + const metrics = statusPanelMetrics(summary, currentMode); + title.textContent = currentMode === 'vehicle' ? dict.panelStatusVehicle : dict.panelStatusStation; + const denominator = metrics.total; + values.forEach((element, index) => { + const pct = denominator ? metrics.counts[index] * 100 / denominator : 0; + element.innerHTML = `${formatNumber(metrics.counts[index])} ${currentMode === 'vehicle' ? dict.unitVehicles : dict.unitStations}`; + labels[index].textContent = dict[metrics.labelKeys[index]]; + if (dots[index]) dots[index].className = `status-dot ${metrics.dotClasses[index]}`; + const pctElement = document.getElementById(`statusPct${index + 1}`); + if (pctElement) pctElement.textContent = `(${pct.toFixed(1)}%)`; + }); + const segments = document.querySelectorAll('.liquid-progress-bar .seg'); + if (segments.length) { + segments.forEach((segment, index) => segment.style.width = `${denominator ? metrics.progressCounts[index] * 100 / denominator : 0}%`); + } + document.getElementById('panelRankTitle').textContent = currentMode === 'vehicle' ? vehicleRankTitle(vehicleRegionSummary.level) : dict.panelRankStation; +} + +function statusPanelMetrics(summary, mode = currentMode) { + const total = Number(mode === 'vehicle' ? summary.totalVehicles : summary.totalStations) || 0; + if (mode === 'vehicle') { + const driving = Number(summary.drivingVehicles) || 0; + const idle = Number(summary.idleVehicles) || 0; + return { + total, counts: [total, driving, idle], + labelKeys: ['statusTotal', 'statusRunning', 'statusStopped'], + dotClasses: ['dot-total', 'dot-mint', 'dot-blue'], + progressCounts: [driving, idle] + }; + } + const cooperative = Number(summary.cooperativeStations) || 0; + const external = Math.max(0, total - cooperative); + return { + total, counts: [total, cooperative, external], + labelKeys: ['stationTotal', 'stationCooperative', 'stationExternal'], + dotClasses: ['dot-total', 'dot-mint', 'dot-blue'], + progressCounts: [cooperative, external] + }; +} + +function compactProvinceName(name) { + return String(name || '') + .replace(/(壮族|回族|维吾尔)?自治区$/, '') + .replace(/特别行政区$/, '') + .replace(/[省市]$/, ''); +} + +function compactRegionName(name) { + return compactProvinceName(name) + .replace(/自治州$/, '') + .replace(/地区$/, '') + .replace(/林区$/, '') + .replace(/[盟区县市]$/, ''); +} + +function romanizeAdministrativeBase(value) { + const normalized = String(value || '').trim(); + if (!normalized) return ''; + if (ADMIN_ENGLISH_OVERRIDES[normalized]) return ADMIN_ENGLISH_OVERRIDES[normalized]; + for (const [ethnicZh, ethnicEn] of Object.entries(ETHNIC_ENGLISH)) { + if (!normalized.endsWith(ethnicZh)) continue; + const prefix = normalized.slice(0, -ethnicZh.length); + const romanizedPrefix = romanizeAdministrativeBase(prefix); + return [romanizedPrefix, ethnicEn].filter(Boolean).join(' '); + } + const converter = globalThis.pinyinPro?.pinyin; + if (typeof converter !== 'function') return normalized; + const pinyin = converter(normalized, { toneType: 'none', separator: ' ' }); + const joined = String(pinyin || '').replace(/[^A-Za-z0-9]+/g, ''); + return joined ? joined.charAt(0).toUpperCase() + joined.slice(1).toLowerCase() : normalized; +} + +function administrativeEnglishName(name, level = '') { + const normalized = String(name || '').trim(); + if (!normalized || normalized === '[]') return ''; + const compactProvince = compactProvinceName(normalized); + if (PROVINCE_ENGLISH[compactProvince] && (level === 'province' || /省|自治区|特别行政区$/.test(normalized) || MUNICIPALITIES.has(compactProvince))) { + return PROVINCE_ENGLISH[compactProvince]; + } + const suffixEntry = ADMIN_SUFFIX_ENGLISH.find(([suffix]) => normalized.endsWith(suffix)); + const suffix = suffixEntry?.[0] || ''; + const base = suffix ? normalized.slice(0, -suffix.length) : normalized; + const translatedBase = ADMIN_ENGLISH_OVERRIDES[base] || romanizeAdministrativeBase(base); + return [translatedBase, suffixEntry?.[1]].filter(Boolean).join(' '); +} + +function stationAdministrativePath(station, lang = currentLang) { + const levels = [ + ['province', station?.province], ['city', station?.city], ['district', station?.district] + ].map(([level, name]) => lang === 'en' ? administrativeEnglishName(name, level) : String(name || '').trim()) + .filter(name => name && name !== '[]'); + return levels.filter((name, index) => index === 0 || name !== levels[index - 1]).join(' · '); +} + +function hierarchyLevelForZoom(zoom = map?.getZoom?.() || 0) { + if (zoom < REGION_ZOOM.city) return 'province'; + if (zoom < REGION_ZOOM.district) return 'city'; + if (zoom < REGION_ZOOM.vehicle) return 'district'; + return 'vehicle'; +} + +function regionLoadingText(level, lang = currentLang) { + const zh = { province: '省级归属计算中', city: '市级归属计算中', district: '区县级归属计算中', vehicle: '单车定位计算中' }; + const en = { province: 'province grouping in progress', city: 'city grouping in progress', district: 'district grouping in progress', vehicle: 'vehicle positioning in progress' }; + return (lang === 'zh' ? zh : en)[level]; +} + +function regionSummaryText(summary, lang = currentLang) { + const zh = { province: '个省级行政区', city: '个当前视野城市', district: '个当前视野区县', vehicle: '辆当前视野车辆' }; + const en = { province: ' province-level regions', city: ' cities in view', district: ' districts in view', vehicle: ' vehicles in view' }; + return `${summary.nodes.length}${(lang === 'zh' ? zh : en)[summary.level]}`; +} + +function vehicleRankTitle(level) { + if (currentLang === 'en') return ({ province: 'Top Provinces by Fleet', city: 'Top Cities by Fleet', district: 'Top Districts by Fleet', vehicle: 'Vehicles in Current View' })[level]; + return ({ province: '省级车辆分布 TOP 排名', city: '市级车辆分布 TOP 排名', district: '区县级车辆分布 TOP 排名', vehicle: '当前视野车辆' })[level]; +} + +function loadAMapUIScript() { + const src = 'https://webapi.amap.com/ui/1.1/main.js?v=1.1.1'; + if (window.AMapUI) return Promise.resolve(); + return new Promise((resolve, reject) => { + const existing = document.querySelector?.(`script[src="${src}"]`); + if (existing) { + existing.addEventListener('load', resolve, { once: true }); + existing.addEventListener('error', () => reject(new Error('高德行政区组件加载失败')), { once: true }); + return; + } + const script = document.createElement('script'); + script.src = src; script.async = true; script.onload = resolve; + script.onerror = () => reject(new Error('高德行政区组件加载失败')); + document.head.appendChild(script); + }); +} + +function districtExplorer() { + if (districtExplorerPromise) return districtExplorerPromise; + districtExplorerPromise = loadAMapUIScript().then(() => new Promise((resolve) => { + window.AMapUI.loadUI(['geo/DistrictExplorer'], (DistrictExplorer) => { + resolve(new DistrictExplorer({ eventSupport: false })); + }); + })).catch(error => { + districtExplorerPromise = null; + throw error; + }); + return districtExplorerPromise; +} + +function areaNodeFor(adcode) { + const key = String(adcode); + if (areaNodePromises.has(key)) return areaNodePromises.get(key); + const promise = districtExplorer().then(explorer => new Promise((resolve, reject) => { + explorer.loadAreaNode(Number(adcode), (error, areaNode) => { + if (error || !areaNode) reject(error || new Error(`行政区边界不可用: ${adcode}`)); + else resolve(areaNode); + }); + })).catch(error => { + areaNodePromises.delete(key); + throw error; + }); + areaNodePromises.set(key, promise); + return promise; +} + +const GCJ_A = 6378245; +const GCJ_EE = 0.006693421622965943; +function outsideGcjChina(longitude, latitude) { return longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271; } +function transformLatitude(longitude, latitude) { + let value = -100 + 2 * longitude + 3 * latitude + 0.2 * latitude * latitude + 0.1 * longitude * latitude + 0.2 * Math.sqrt(Math.abs(longitude)); + value += (20 * Math.sin(6 * longitude * Math.PI) + 20 * Math.sin(2 * longitude * Math.PI)) * 2 / 3; + value += (20 * Math.sin(latitude * Math.PI) + 40 * Math.sin(latitude / 3 * Math.PI)) * 2 / 3; + value += (160 * Math.sin(latitude / 12 * Math.PI) + 320 * Math.sin(latitude * Math.PI / 30)) * 2 / 3; + return value; +} +function transformLongitude(longitude, latitude) { + let value = 300 + longitude + 2 * latitude + 0.1 * longitude * longitude + 0.1 * longitude * latitude + 0.1 * Math.sqrt(Math.abs(longitude)); + value += (20 * Math.sin(6 * longitude * Math.PI) + 20 * Math.sin(2 * longitude * Math.PI)) * 2 / 3; + value += (20 * Math.sin(longitude * Math.PI) + 40 * Math.sin(longitude / 3 * Math.PI)) * 2 / 3; + value += (150 * Math.sin(longitude / 12 * Math.PI) + 300 * Math.sin(longitude / 30 * Math.PI)) * 2 / 3; + return value; +} +function wgs84ToGcj02(longitude, latitude) { + if (!Number.isFinite(longitude) || !Number.isFinite(latitude) || outsideGcjChina(longitude, latitude)) return [longitude, latitude]; + const latitudeOffset = transformLatitude(longitude - 105, latitude - 35); + const longitudeOffset = transformLongitude(longitude - 105, latitude - 35); + const radianLatitude = latitude / 180 * Math.PI; + const magic = 1 - GCJ_EE * Math.sin(radianLatitude) ** 2; + const sqrtMagic = Math.sqrt(magic); + return [ + longitude + longitudeOffset * 180 / (GCJ_A / sqrtMagic * Math.cos(radianLatitude) * Math.PI), + latitude + latitudeOffset * 180 / ((GCJ_A * (1 - GCJ_EE)) / (magic * sqrtMagic) * Math.PI) + ]; +} + +function locatedVehicles(vehicles = dashboard?.vehicles || []) { + return vehicles.filter(vehicle => vehicle.locationAvailable && Number.isFinite(Number(vehicle.longitude)) && Number.isFinite(Number(vehicle.latitude))); +} + +function vehicleActiveToday(vehicle) { + return typeof vehicle?.activeToday === 'boolean' ? vehicle.activeToday : Boolean(vehicle?.online); +} + +function partitionVehiclesByArea(areaNode, vehicles, level) { + const groups = []; + const unmatched = []; + for (const group of areaNode.groupByPosition(vehicles, vehicle => wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)))) { + if (!group.points?.length) continue; + const feature = group.subFeature; + if (group.subFeatureIndex < 0 || !feature) { unmatched.push(...group.points); continue; } + const rawName = String(feature.properties.name || ''); + const nameZh = level === 'province' ? compactProvinceName(rawName) : compactRegionName(rawName); + const positions = group.points.map(vehicle => wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude))); + const center = feature.properties.center || [ + positions.reduce((sum, point) => sum + point[0], 0) / positions.length, + positions.reduce((sum, point) => sum + point[1], 0) / positions.length + ]; + groups.push({ adcode: String(feature.properties.adcode), rawName, nameZh, lnglat: center, vehicles: group.points }); + } + return { groups, unmatched }; +} + +function fallbackRegionGroup(parent, vehicles) { + return { ...parent, vehicles }; +} + +async function expandRegionGroups(parents, level) { + const expanded = await Promise.all(parents.map(async parent => { + if (level === 'city' && MUNICIPALITIES.has(compactProvinceName(parent.rawName))) { + return [{ ...parent, nameZh: compactProvinceName(parent.rawName) }]; + } + try { + const areaNode = await areaNodeFor(parent.adcode); + const partition = partitionVehiclesByArea(areaNode, parent.vehicles, level); + if (partition.unmatched.length) partition.groups.push(fallbackRegionGroup(parent, partition.unmatched)); + return partition.groups.length ? partition.groups : [parent]; + } catch (error) { + console.warn(`region expansion failed for ${parent.adcode}`, error); + return [parent]; + } + })); + return expanded.flat(); +} + +function regionNodesFromGroups(groups, level) { + return groups.map(group => ({ + id: `vehicle-${level}-${group.adcode}`, kind: `vehicle${level.charAt(0).toUpperCase()}${level.slice(1)}`, + level, adcode: group.adcode, nameZh: group.nameZh, + nameEn: administrativeEnglishName(group.rawName || group.nameZh, level), + lnglat: group.lnglat, count: group.vehicles.length, online: group.vehicles.filter(vehicle => vehicleActiveToday(vehicle)).length, + dist: group.vehicles.reduce((sum, vehicle) => sum + Number(vehicle.dailyMileageKm || 0), 0), vehicles: group.vehicles, + detail: group.vehicles.slice(0, 5).map(vehicle => vehicle.plateNumber || vehicle.vin).join('、') + })).sort((left, right) => right.count - left.count || left.nameZh.localeCompare(right.nameZh, 'zh-CN')); +} + +async function aggregateVehicleHierarchy(level, vehicles = dashboard?.vehicles || []) { + const located = locatedVehicles(vehicles); + let unassigned = vehicles.length - located.length; + const nationalNode = await areaNodeFor(100000); + const provincePartition = partitionVehiclesByArea(nationalNode, located, 'province'); + unassigned += provincePartition.unmatched.length; + if (level === 'province') return { level, nodes: regionNodesFromGroups(provincePartition.groups, level), unassigned, loading: false }; + const cityGroups = await expandRegionGroups(provincePartition.groups, 'city'); + if (level === 'city') return { level, nodes: regionNodesFromGroups(cityGroups, level), unassigned, loading: false }; + const districtGroups = await expandRegionGroups(cityGroups, 'district'); + return { level, nodes: regionNodesFromGroups(districtGroups, level), unassigned, loading: false }; +} + +function currentMapBounds() { + const bounds = map?.getBounds?.(); + const southWest = bounds?.getSouthWest?.(); + const northEast = bounds?.getNorthEast?.(); + const west = southWest?.getLng?.(); const south = southWest?.getLat?.(); + const east = northEast?.getLng?.(); const north = northEast?.getLat?.(); + if (![west, south, east, north].every(Number.isFinite)) return null; + return { west, south, east, north }; +} + +function vehiclePointNodes(vehicles = dashboard?.vehicles || []) { + const bounds = currentMapBounds(); + return locatedVehicles(vehicles).map(vehicle => { + const lnglat = wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)); + return { vehicle, lnglat }; + }).filter(({ lnglat }) => !bounds || (lnglat[0] >= bounds.west && lnglat[0] <= bounds.east && lnglat[1] >= bounds.south && lnglat[1] <= bounds.north)) + .map(({ vehicle, lnglat }) => ({ + id: `vehicle-point-${vehicle.vin}`, kind: 'vehiclePoint', level: 'vehicle', + nameZh: vehicle.plateNumber || vehicle.vin, nameEn: vehicle.plateNumber || vehicle.vin, + lnglat, count: 1, online: vehicleActiveToday(vehicle) ? 1 : 0, dist: Number(vehicle.dailyMileageKm || 0), vehicles: [vehicle], + speedKmh: Number(vehicle.speedKmh || 0), protocol: vehicle.protocol || '', + detail: [vehicle.vin, vehicle.protocol, vehicle.recordTime].filter(Boolean).join(' · ') + })); +} + +function nodesInCurrentView(nodes, level) { + if (level === 'province') return nodes; + const bounds = currentMapBounds(); + if (!bounds) return nodes; + return nodes.filter(node => node.lnglat[0] >= bounds.west && node.lnglat[0] <= bounds.east && node.lnglat[1] >= bounds.south && node.lnglat[1] <= bounds.north); +} + +async function refreshVehicleRegionNodes(sourceDashboard = dashboard) { + if (!sourceDashboard) return; + const level = hierarchyLevelForZoom(); + const version = ++regionAggregationVersion; + const unassigned = (sourceDashboard.vehicles || []).length - locatedVehicles(sourceDashboard.vehicles || []).length; + if (level === 'vehicle') { + vehicleRegionSummary = { level, nodes: vehiclePointNodes(sourceDashboard.vehicles || []), unassigned, loading: false }; + updateDashboardUI(); + return; + } + vehicleRegionSummary = { level, nodes: [], unassigned, loading: true }; + updateDashboardUI(); + try { + const result = await aggregateVehicleHierarchy(level, sourceDashboard.vehicles || []); + if (version !== regionAggregationVersion || sourceDashboard !== dashboard || level !== hierarchyLevelForZoom()) return; + result.nodes = nodesInCurrentView(result.nodes, level); + vehicleRegionSummary = result; + } catch (error) { + console.error('vehicle region aggregation failed', error); + if (version !== regionAggregationVersion) return; + vehicleRegionSummary = { level, nodes: [], unassigned: sourceDashboard.vehicles?.length || 0, loading: false }; + } + updateDashboardUI(); +} + +function buildVehicleRegionNodes() { + return { + ...vehicleRegionSummary, + nodes: vehicleRegionSummary.nodes.map(node => ({ ...node, name: currentLang === 'zh' ? node.nameZh : node.nameEn })) + }; +} + +function vehicleNodes() { return buildVehicleRegionNodes().nodes; } + +function stationHierarchyLevelForZoom(zoom = map?.getZoom?.() || 0) { + if (zoom < 7) return 'province'; + if (zoom < 11) return 'city'; + return 'station'; +} + +function stationGroupName(station, level) { + const raw = level === 'province' ? station.province : station.city; + if (!raw) return currentLang === 'zh' ? '未标注区域' : 'Unspecified'; + if (currentLang === 'en') return administrativeEnglishName(raw, level) || raw; + return level === 'province' ? compactProvinceName(raw) : compactRegionName(raw); +} + +function buildStationNodes(stations = dashboard?.stations || [], level = stationHierarchyLevelForZoom()) { + if (level === 'station') { + return stations.map(station => ({ + id: `station-${station.id}`, kind: 'station', name: station.name, lnglat: [station.longitude, station.latitude], + count: 1, online: station.cooperative ? 1 : 0, + adminPath: stationAdministrativePath(station), + detail: [stationAdministrativePath(station), currentLang === 'zh' ? station.address : ''].filter(Boolean).join(' · '), + cooperative: station.cooperative, hydrogenKg: Number(station.totalHydrogenKg) || 0, + hasHydrogen: station.totalHydrogenKg != null + })); + } + const grouped = new Map(); + for (const station of stations) { + const key = (level === 'province' ? station.province : `${station.province || ''}/${station.city || ''}`) || 'UNSPECIFIED'; + if (!grouped.has(key)) grouped.set(key, { + kind: level === 'province' ? 'stationProvince' : 'stationCity', name: stationGroupName(station, level), + lng: 0, lat: 0, count: 0, online: 0, hydrogenKg: 0, hasHydrogen: false, stations: [] + }); + const group = grouped.get(key); + group.lng += Number(station.longitude); group.lat += Number(station.latitude); group.count += 1; + group.online += station.cooperative ? 1 : 0; group.stations.push(station); + if (station.totalHydrogenKg != null) { + group.hasHydrogen = true; + group.hydrogenKg += Number(station.totalHydrogenKg) || 0; + } + } + return [...grouped.values()].map((group, index) => ({ + ...group, id: `station-${level}-${index}`, lnglat: [group.lng / group.count, group.lat / group.count], + detail: group.stations.slice(0, 5).map(station => station.name).join('、') + })); +} + +function nodesInsideCurrentMapBounds(nodes) { + const bounds = currentMapBounds(); + if (!bounds) return nodes; + return nodes.filter(node => node.lnglat[0] >= bounds.west && node.lnglat[0] <= bounds.east && node.lnglat[1] >= bounds.south && node.lnglat[1] <= bounds.north); +} + +function stationNodes(visibleOnly = false) { + const nodes = buildStationNodes(); + return visibleOnly ? nodesInsideCurrentMapBounds(nodes) : nodes; +} + +function stationViewportSummary() { + const level = stationHierarchyLevelForZoom(); + const allNodes = stationNodes(false); + const visibleNodes = nodesInsideCurrentMapBounds(allNodes); + return { level, allNodes, visibleNodes }; +} + +function countUnit(count, resource, dict = i18n[currentLang]) { + const isEnglish = dict === i18n.en; + if (resource === 'vehicle') return isEnglish && Number(count) === 1 ? dict.unitVehicle : dict.unitVehicles; + return isEnglish && Number(count) === 1 ? dict.unitStation : dict.unitStations; +} + +function countLabel(count, resource, dict = i18n[currentLang]) { + return `${formatNumber(count)} ${countUnit(count, resource, dict)}`; +} + +function markerLabel(node, dict = i18n[currentLang]) { + if (node.kind === 'station') return node.cooperative ? `H₂ · ${dict.stationPartnerMarker}` : 'H₂'; + if (node.kind === 'stationProvince' || node.kind === 'stationCity' || node.kind === 'stationCluster') return countLabel(node.count, 'station', dict); + if (node.kind === 'vehiclePoint') return node.online ? dict.onlineText : dict.statusOffline; + return countLabel(node.count, 'vehicle', dict); +} + +function renderAMapMarkers() { + if (!map || !dashboard) return; + markerList.forEach(marker => marker.remove()); markerList = []; + infoWindowList.forEach(infoWindow => infoWindow.close()); infoWindowList = []; + const nodes = currentMode === 'vehicle' ? vehicleNodes() : stationNodes(true); + const dict = i18n[currentLang]; + for (const node of nodes) { + const isVehicleNode = node.kind.startsWith('vehicle'); + const isStationPoint = node.kind === 'station'; + const markerContent = document.createElement('div'); + markerContent.className = 'province-info-badge-wrap'; + const label = markerLabel(node, dict); + const subline = node.kind === 'vehiclePoint' ? `${formatNumber(node.speedKmh, 1)} km/h${node.protocol ? ` · ${node.protocol}` : ''}` : isStationPoint ? node.adminPath : `${dict.onlineText} ${node.online}`; + markerContent.innerHTML = `
${escapeHTML(node.name)}${escapeHTML(label)}
${(isVehicleNode || isStationPoint) && subline ? `
${escapeHTML(subline)}
` : ''}
`; + const marker = new AMap.Marker({ position: node.lnglat, content: markerContent, offset: new AMap.Pixel(-30, -12), title: node.name }); + const infoWindow = new AMap.InfoWindow({ + isCustom: true, + content: `
${escapeHTML(node.name)}
${escapeHTML(node.detail || '')}
${isVehicleNode ? `
${dict.onlineText}: ${node.online}/${node.count} · ${formatNumber(node.dist, 1)} km
` : ''}
`, + offset: new AMap.Pixel(0, -32) + }); + infoWindowList.push(infoWindow); + marker.on('mouseover', () => infoWindow.open(map, node.lnglat)); + marker.on('mouseout', () => infoWindow.close()); + if (node.kind === 'vehicleProvince') marker.on('click', () => map.setZoomAndCenter(7.4, node.lnglat)); + if (node.kind === 'vehicleCity') marker.on('click', () => map.setZoomAndCenter(9.8, node.lnglat)); + if (node.kind === 'vehicleDistrict') marker.on('click', () => map.setZoomAndCenter(12.2, node.lnglat)); + if (node.kind === 'stationProvince' || node.kind === 'stationCluster') marker.on('click', () => map.setZoomAndCenter(7.2, node.lnglat)); + if (node.kind === 'stationCity') marker.on('click', () => map.setZoomAndCenter(11.2, node.lnglat)); + marker.setMap(map); markerList.push(marker); + } +} + +function renderRankingList(type) { + const box = document.getElementById('rankingListBox'); + if (!box || !dashboard) return; + box.innerHTML = ''; + let rows; + if (currentMode === 'vehicle') { + rows = buildVehicleRegionNodes().nodes + .sort((a, b) => type === 'secondary' ? b.dist - a.dist : b.count - a.count) + .slice(0, 12) + .map(item => ({ + name: item.name, + value: type === 'secondary' ? `${formatNumber(item.dist, 1)} km` : item.kind === 'vehiclePoint' ? `${formatNumber(item.speedKmh, 1)} km/h` : countLabel(item.count, 'vehicle'), + location: item.lnglat, + zoom: item.kind === 'vehicleProvince' ? 7.4 : item.kind === 'vehicleCity' ? 9.8 : item.kind === 'vehicleDistrict' ? 12.2 : 14 + })); + } else { + const { level, allNodes, visibleNodes } = stationViewportSummary(); + const byHydrogen = type === 'secondary'; + rows = [...visibleNodes] + .filter(node => !byHydrogen || node.hasHydrogen) + .sort((a, b) => byHydrogen ? b.hydrogenKg - a.hydrogenKg : b.count - a.count || a.name.localeCompare(b.name, 'zh-CN')) + .map(node => ({ + name: node.name, + value: byHydrogen ? `${formatNumber(node.hydrogenKg, 1)} kg` : countLabel(node.count, 'station'), + location: node.lnglat, + zoom: level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14 + })); + updateStationViewportMeta(visibleNodes.length, allNodes.length); + if (!rows.length) { + const message = byHydrogen && visibleNodes.length ? i18n[currentLang].rankNoHydrogenData : i18n[currentLang].stationViewportEmpty; + box.innerHTML = `
${escapeHTML(message)}
`; + } + } + rows.forEach((item, index) => { + const row = document.createElement('div'); row.className = 'rank-glass-row'; + row.innerHTML = `${index + 1}${escapeHTML(item.name)}${escapeHTML(item.value)}`; + if (item.location) row.onclick = () => map?.setZoomAndCenter(item.zoom || 9, item.location); + box.appendChild(row); + }); +} + +function updateStationViewportMeta(visibleCount, totalCount) { + const meta = document.getElementById('rankViewportMeta'); + if (!meta) return; + if (currentMode !== 'station') { + meta.hidden = true; + return; + } + const dict = i18n[currentLang]; + meta.hidden = false; + meta.textContent = `${dict.viewportNow} ${formatNumber(visibleCount)} · ${dict.viewportAll} ${formatNumber(totalCount)}`; +} + +function updateRankingControls() { + const dict = i18n[currentLang]; + const stationLevel = stationHierarchyLevelForZoom(); + const title = currentMode === 'vehicle' + ? dict.panelRankVehicle + : stationLevel === 'province' ? dict.panelRankStation : stationLevel === 'city' + ? (currentLang === 'zh' ? '加氢站城市 TOP 排名' : 'Top Cities by Stations') + : (currentLang === 'zh' ? '加氢站 TOP 排名' : 'Top H₂ Stations'); + document.getElementById('panelRankTitle').textContent = title; + if (currentMode === 'vehicle') updateStationViewportMeta(0, 0); + document.getElementById('rankPrimaryTab').textContent = currentMode === 'vehicle' ? dict.rankByFleet : dict.rankByStations; + document.getElementById('rankSecondaryTab').textContent = currentMode === 'vehicle' ? dict.rankByDist : dict.rankByHydrogen; + document.querySelectorAll('.gtab').forEach(button => button.classList.toggle('active', button.id === (currentRankType === 'primary' ? 'rankPrimaryTab' : 'rankSecondaryTab'))); +} + +function switchRankTab(type) { + currentRankType = type; + updateRankingControls(); + renderRankingList(type); +} + +function switchMode(mode) { + currentMode = mode; + document.getElementById('btnModeVehicle').classList.toggle('active', mode === 'vehicle'); + document.getElementById('btnModeStation').classList.toggle('active', mode === 'station'); + currentRankType = 'primary'; + updateDashboardUI(); + if (mode === 'vehicle') refreshVehicleRegionNodes(dashboard); +} + +function setLanguage(lang) { + currentLang = lang; + document.querySelectorAll('.lang-switcher-bw .segment-btn').forEach(button => button.classList.remove('active')); + document.querySelector(`.lang-${lang}-btn`)?.classList.add('active'); + const dict = i18n[lang]; + document.querySelectorAll('[data-i18n]').forEach(element => { + const key = element.getAttribute('data-i18n'); if (dict[key]) element.textContent = dict[key]; + }); + const brandLogo = document.getElementById('brandLogo'); + const brandLogoEnglish = document.getElementById('brandLogoEnglish'); + if (brandLogo) { + const english = lang === 'en'; + brandLogo.hidden = english; + brandLogoEnglish.hidden = !english; + brandLogo.alt = '羚牛氢能'; + } + updateDashboardUI(); +} + +function setTheme(themeName) { + currentTheme = themeName; document.body.className = themeName; + document.querySelectorAll('.bw-btn').forEach(button => button.classList.remove('active')); + document.querySelector(themeName === 'theme-dark' ? '.theme-dark-btn' : '.theme-white-btn')?.classList.add('active'); + if (map) { map.setMapStyle(getAMapThemeStyle(themeName)); renderAMapMarkers(); } +} + +function getAMapThemeStyle(theme) { return theme === 'theme-white' ? 'amap://styles/light' : 'amap://styles/darkblue'; } +function resetMapView() { if (map) { map.setZoomAndCenter(4.8, [108.948024, 34.263161]); map.setPitch(30); } } +function clearProvinceFilter() { resetMapView(); } +function togglePitchView() { if (map) { is3DPitch = !is3DPitch; map.setPitch(is3DPitch ? 30 : 0); } } +function formatNumber(value, digits = 0) { return Number(value || 0).toLocaleString(currentLang === 'zh' ? 'zh-CN' : 'en-US', { maximumFractionDigits: digits, minimumFractionDigits: digits }); } +function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); } +function initClock() { + const clock = document.getElementById('clockTime'); + const update = () => { if (clock) clock.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false }); }; + update(); window.setInterval(update, 1000); +} diff --git a/vehicle-map/deploy/install-release.sh b/vehicle-map/deploy/install-release.sh new file mode 100644 index 00000000..c522db80 --- /dev/null +++ b/vehicle-map/deploy/install-release.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +release_id=${1:?release id is required} +archive=${2:?release archive is required} +root=${VEHICLE_MAP_ROOT:-/opt/lingniu-vehicle-map} +service=${VEHICLE_MAP_SERVICE:-lingniu-vehicle-map.service} +base_url=${VEHICLE_MAP_BASE_URL:-http://127.0.0.1:20800} + +case "$release_id" in + ''|*[!A-Za-z0-9._-]*) printf 'invalid release id: %s\n' "$release_id" >&2; exit 1 ;; +esac +test -f "$archive" + +if ! id vehicle-map >/dev/null 2>&1; then + useradd --system --home-dir "$root" --shell /sbin/nologin vehicle-map +fi +mkdir -p "$root/releases" "$root/env" +next="$root/releases/$release_id" +test ! -e "$next" +mkdir -p "$next" + +while IFS= read -r member; do + case "$member" in + /*|../*|*/../*|*/..) printf 'archive contains unsafe path: %s\n' "$member" >&2; exit 1 ;; + esac +done < <(tar -tzf "$archive") +tar --no-same-owner -xzf "$archive" -C "$next" +test -f "$next/server.py" +test -f "$next/index.html" +chown -R root:vehicle-map "$next" +chmod -R u=rwX,g=rX,o= "$next" +chown root:vehicle-map "$root/env/vehicle-map.env" +chmod 0640 "$root/env/vehicle-map.env" + +ln -s "$next" "$root/current.next" +python3 -c 'import os,sys; os.replace(sys.argv[1],sys.argv[2])' "$root/current.next" "$root/current" +systemctl restart "$service" + +for _ in $(seq 1 30); do + if systemctl is-active --quiet "$service" && curl -fsS "$base_url/api/health" >/dev/null; then + curl -fsS "$base_url/" | grep -q 'id="dashboardTitle"' + curl -fsS "$base_url/api/dashboard" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["status"]=="ok" and d["summary"]["totalStations"]>400' + printf 'vehicle_map_release_install=ok release=%s\n' "$release_id" + exit 0 + fi + sleep 1 +done + +systemctl status "$service" --no-pager >&2 || true +exit 1 diff --git a/vehicle-map/deploy/lingniu-vehicle-map.service b/vehicle-map/deploy/lingniu-vehicle-map.service new file mode 100644 index 00000000..92c20f09 --- /dev/null +++ b/vehicle-map/deploy/lingniu-vehicle-map.service @@ -0,0 +1,22 @@ +[Unit] +Description=Lingniu Vehicle Map Dashboard +After=network-online.target lingniu-vehicle-open-platform.service +Wants=network-online.target + +[Service] +Type=simple +User=vehicle-map +Group=vehicle-map +WorkingDirectory=/opt/lingniu-vehicle-map/current +EnvironmentFile=/opt/lingniu-vehicle-map/env/vehicle-map.env +ExecStart=/usr/bin/python3 /opt/lingniu-vehicle-map/current/server.py +Restart=always +RestartSec=3 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +[Install] +WantedBy=multi-user.target diff --git a/vehicle-map/index.html b/vehicle-map/index.html new file mode 100644 index 00000000..e94b0cfd --- /dev/null +++ b/vehicle-map/index.html @@ -0,0 +1,196 @@ + + + + + + 羚牛氢能 - 车辆网络 | Lingniu H2 Executive Cockpit + + + + + + + + + + + + + +
+ + +
+
+
+ +
+ + +
+
+
+
+

车辆网络

+
+
+ + +
+
+ 车辆总数 + -- +
+
+ 当前在线运行 + -- +
+
+ 今日运营里程 + -- km +
+
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
19:04:00
+
+
+ + +
+ + +
+ + +
+
+ + 正在同步开放平台数据… +
+ +
+ + +
+
+ + +
+ + + +
+
+ + +
+ 高质感地图引擎: 羚牛氢能GIS (AMap 3D Engine) + 系统状态: 稳定连接 (AES-256) + 视角: 全国运营态势 +
+
+ + + + +
+ +
+ + + + + diff --git a/vehicle-map/logo_en.svg b/vehicle-map/logo_en.svg new file mode 100644 index 00000000..002aea06 --- /dev/null +++ b/vehicle-map/logo_en.svg @@ -0,0 +1,7 @@ + + Lingniu Hydrogen Mobility + + LINGNIU + HYDROGEN MOBILITY + + diff --git a/vehicle-map/logo_light.svg b/vehicle-map/logo_light.svg new file mode 100644 index 00000000..d6900abc --- /dev/null +++ b/vehicle-map/logo_light.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vehicle-map/package-lock.json b/vehicle-map/package-lock.json new file mode 100644 index 00000000..bf187d33 --- /dev/null +++ b/vehicle-map/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "lingniu-truck-map", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lingniu-truck-map", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "pinyin-pro": "3.28.2" + } + }, + "node_modules/pinyin-pro": { + "version": "3.28.2", + "resolved": "https://registry.npmjs.org/pinyin-pro/-/pinyin-pro-3.28.2.tgz", + "integrity": "sha512-jV38yxXHLfidirMC4hrXasLDozLCSq/4DfX88GnHcSEJ2+GpSedG6I9VOiEXJu6iQ5dbJC/RjmzyMuS5h/wH5A==", + "license": "MIT" + } + } +} diff --git a/vehicle-map/package.json b/vehicle-map/package.json new file mode 100644 index 00000000..2e9ff531 --- /dev/null +++ b/vehicle-map/package.json @@ -0,0 +1,22 @@ +{ + "name": "lingniu-truck-map", + "version": "1.0.0", + "description": "羚牛氢能 - 全国氢能物流运营驾驶舱 (Apple Design Spec)", + "main": "index.html", + "scripts": { + "start": "python3 server.py", + "dev": "VEHICLE_MAP_PORT=20800 python3 server.py", + "test": "python3 -m unittest discover -s tests -v && node tests/test_app.mjs" + }, + "keywords": [ + "lingniu", + "hydrogen", + "map", + "cockpit" + ], + "author": "Antigravity", + "license": "ISC", + "dependencies": { + "pinyin-pro": "3.28.2" + } +} diff --git a/vehicle-map/server.py b/vehicle-map/server.py new file mode 100644 index 00000000..407e848b --- /dev/null +++ b/vehicle-map/server.py @@ -0,0 +1,188 @@ +"""Vehicle Map static server and server-side proxy for the vehicle open platform.""" + +from concurrent.futures import ThreadPoolExecutor +from http.server import HTTPServer, SimpleHTTPRequestHandler +import json +import os +from pathlib import Path +import threading +import time +from socketserver import ThreadingMixIn +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +ROOT = Path(__file__).resolve().parent +HOST = os.getenv("VEHICLE_MAP_HOST", "0.0.0.0") +PORT = int(os.getenv("VEHICLE_MAP_PORT", "20800")) +OPEN_PLATFORM_BASE_URL = os.getenv("OPEN_PLATFORM_BASE_URL", "https://open.d.lnoneos.com").rstrip("/") +OPEN_PLATFORM_APP_KEY = os.getenv("OPEN_PLATFORM_APP_KEY", "").strip() +UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "15")) +DASHBOARD_CACHE_SECONDS = int(os.getenv("DASHBOARD_CACHE_SECONDS", "5")) +STATION_CACHE_SECONDS = int(os.getenv("STATION_CACHE_SECONDS", "3600")) + +_cache_lock = threading.Lock() +_cache = {} + + +class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): + daemon_threads = True + allow_reuse_address = True + + +def _json_bytes(value): + return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + + +def _post_open_platform(path, body): + if not OPEN_PLATFORM_APP_KEY: + raise RuntimeError("OPEN_PLATFORM_APP_KEY is not configured") + request = Request( + OPEN_PLATFORM_BASE_URL + path, + data=_json_bytes(body), + method="POST", + headers={ + "Authorization": "Bearer " + OPEN_PLATFORM_APP_KEY, + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "lingniu-vehicle-map/1.0", + }, + ) + try: + with urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response: + payload = json.load(response) + except HTTPError as exc: + detail = exc.read(2048).decode("utf-8", errors="replace") + raise RuntimeError(f"open platform returned HTTP {exc.code}: {detail}") from exc + except URLError as exc: + raise RuntimeError(f"open platform unavailable: {exc.reason}") from exc + if payload.get("code") != "SUCCESS": + raise RuntimeError(f"open platform rejected request: {payload.get('code')} {payload.get('message')}") + return payload.get("data") or [] + + +def _cached(key, ttl_seconds, loader): + now = time.time() + with _cache_lock: + cached = _cache.get(key) + if cached and now - cached[0] < ttl_seconds: + return cached[1] + value = loader() + with _cache_lock: + _cache[key] = (now, value) + return value + + +def _load_dashboard(): + today = time.strftime("%Y-%m-%d", time.localtime()) + with ThreadPoolExecutor(max_workers=3) as executor: + realtime_future = executor.submit( + _post_open_platform, "/api/v1/vehicles/realtime/query", {} + ) + mileage_future = executor.submit( + _post_open_platform, "/api/v1/vehicles/mileage/query", {"date": today} + ) + stations_future = executor.submit( + _cached, + "stations", + STATION_CACHE_SECONDS, + lambda: _post_open_platform("/api/v1/hydrogen-stations/query", {}), + ) + vehicles = realtime_future.result() + mileage_rows = mileage_future.result() + stations = stations_future.result() + + mileage_by_vin = { + row.get("vin"): row + for row in mileage_rows + if row.get("vin") and row.get("status") == "NORMAL" + } + # The operations card is a daily activity view: every vehicle that reported + # today is placed in exactly one bucket using its latest selected speed. + active_vehicles = [item for item in vehicles if item.get("activeToday")] + driving = sum(1 for item in active_vehicles if float(item.get("speedKmh") or 0) > 3) + idle = len(active_vehicles) - driving + offline = sum(1 for item in vehicles if item.get("motionStatus") == "offline") + active_today = len(active_vehicles) + daily_mileage = round( + sum(float(row.get("dailyMileageKm") or 0) for row in mileage_rows), 3 + ) + monthly_hydrogen = round( + sum(float(station.get("monthlyHydrogenKg") or 0) for station in stations), 3 + ) + for vehicle in vehicles: + mileage = mileage_by_vin.get(vehicle.get("vin"), {}) + vehicle["dailyMileageKm"] = mileage.get("dailyMileageKm", 0) + + return { + "status": "ok", + "asOf": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + "date": today, + "summary": { + "totalVehicles": len(vehicles), + "onlineVehicles": active_today, + "drivingVehicles": driving, + "idleVehicles": idle, + "offlineVehicles": offline, + "todayMileageKm": daily_mileage, + "monthlyHydrogenKg": monthly_hydrogen, + "totalStations": len(stations), + "cooperativeStations": sum(1 for item in stations if item.get("cooperative")), + }, + "vehicles": vehicles, + "stations": stations, + } + + +class VehicleMapHandler(SimpleHTTPRequestHandler): + server_version = "LingniuVehicleMap/1.0" + + def do_GET(self): + if self.path == "/api/health": + self._write_json(200, { + "status": "ok", + "service": "vehicle-map", + "openPlatformConfigured": bool(OPEN_PLATFORM_APP_KEY), + }) + return + if self.path == "/api/dashboard": + try: + payload = _cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard) + self._write_json(200, payload) + except Exception as exc: # keep upstream details server-side only + self.log_error("dashboard refresh failed: %s", exc) + self._write_json(502, { + "status": "error", + "code": "UPSTREAM_UNAVAILABLE", + "message": "车辆数据暂时不可用,请稍后重试", + }) + return + super().do_GET() + + def end_headers(self): + if self.path == "/" or self.path.split("?", 1)[0].endswith((".html", ".css", ".js", ".svg")): + self.send_header("Cache-Control", "no-cache") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "strict-origin-when-cross-origin") + self.send_header("X-Frame-Options", "SAMEORIGIN") + super().end_headers() + + def _write_json(self, status, payload): + body = _json_bytes(payload) + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +if __name__ == "__main__": + server = ThreadingHTTPServer((HOST, PORT), VehicleMapHandler) + print(f"Lingniu Vehicle Map listening on http://{HOST}:{PORT}") + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() diff --git a/vehicle-map/start.sh b/vehicle-map/start.sh new file mode 100644 index 00000000..5c46a101 --- /dev/null +++ b/vehicle-map/start.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# 羚牛氢能驾驶舱 - 局域网本地部署启动脚本 + +echo "==================================================" +echo " 羚牛氢能 - 全国氢能物流运营驾驶舱" +echo " 局域网 (LAN) 服务启动中..." +echo "==================================================" + +IP_ADDR=$(ifconfig | grep "inet " | grep -v 127.0.0.1 | head -n 1 | awk '{print $2}') + +PORT="${VEHICLE_MAP_PORT:-20800}" +echo "本机访问地址: http://localhost:${PORT}" +echo "局域网访问地址: http://${IP_ADDR}:${PORT}" +echo "==================================================" + +exec python3 server.py diff --git a/vehicle-map/styles.css b/vehicle-map/styles.css new file mode 100644 index 00000000..09adecd8 --- /dev/null +++ b/vehicle-map/styles.css @@ -0,0 +1,957 @@ +/* ========================================================================== + 羚牛氢能 - 车辆网络 + Design Philosophy: Apple Restrained Minimalist (极致克制 · 纯净无痕) + ========================================================================== */ + +/* -------------------------------------------------------------------------- + RESTRICTED DUAL-THEME COLOR SYSTEM (纯黑 / 纯白) + -------------------------------------------------------------------------- */ + +/* 1. 纯黑/深色模式 (Minimal Dark Charcoal) */ +.theme-dark { + --bg-app: #0A0B0E; + --bg-radial: radial-gradient(circle at 50% 20%, #12141C 0%, #0A0B0E 100%); + --glass-bg: rgba(20, 22, 28, 0.68); + --glass-border: rgba(255, 255, 255, 0.08); + --glass-hover-border: rgba(255, 255, 255, 0.2); + --glass-shadow: 0 16px 40px rgba(0, 0, 0, 0.4); + --accent-primary: #007143; /* 羚牛品牌绿 */ + --accent-quantity: #34D399; /* 品牌绿的深色主题高对比版本 */ + --accent-cyan: #38BDF8; /* 柔和冰蓝 */ + --accent-mint: #10B981; + --accent-blue: #3B82F6; + --text-main: #F1F5F9; + --text-muted: #94A3B8; + --text-sub: #64748B; + --pill-bg: rgba(255, 255, 255, 0.06); + --segmented-bg: rgba(255, 255, 255, 0.05); + --map-style: amap://styles/darkblue; +} + +/* 2. 纯白/浅色模式 (Apple Studio Pure White) */ +.theme-white { + --bg-app: #F5F5F7; + --bg-radial: radial-gradient(circle at 50% 20%, #FFFFFF 0%, #F5F5F7 100%); + --glass-bg: rgba(255, 255, 255, 0.82); + --glass-border: rgba(0, 0, 0, 0.08); + --glass-hover-border: rgba(0, 0, 0, 0.18); + --glass-shadow: 0 12px 32px rgba(0, 0, 0, 0.05); + --accent-primary: #007143; + --accent-quantity: #007143; + --accent-cyan: #0284C7; + --accent-mint: #059669; + --accent-blue: #2563EB; + --text-main: #1D1D1F; + --text-muted: #6E6E73; + --text-sub: #86868B; + --pill-bg: rgba(0, 0, 0, 0.04); + --segmented-bg: rgba(0, 0, 0, 0.04); + --map-style: amap://styles/light; +} + +/* Elegant Subscript Typographic Tuning for H₂ */ +sub, .h2-sub { + font-size: 0.7em; + line-height: 0; + vertical-align: -0.22em; + font-family: 'JetBrains Mono', -apple-system, sans-serif; + font-weight: 700; + margin-left: 0.5px; + opacity: 0.9; +} + +/* -------------------------------------------------------------------------- + GLOBAL RESET & RESTRAINED GLASS WRAPPER + -------------------------------------------------------------------------- */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; + user-select: none; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", Arial, sans-serif; + background: var(--bg-app); + background-image: var(--bg-radial); + color: var(--text-main); + height: 100vh; + width: 100vw; + overflow: hidden; + -webkit-font-smoothing: antialiased; + transition: background 0.3s ease, color 0.3s ease; +} + +.liquid-cockpit-wrapper { + display: flex; + flex-direction: column; + height: 100vh; + padding: 12px 16px; + gap: 12px; +} + +/* Restrained Apple Glass Panel */ +.glass-panel { + background: var(--glass-bg); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--glass-border); + border-radius: 16px; + box-shadow: var(--glass-shadow); + transition: border-color 0.25s ease, background 0.25s ease; +} + +.glass-panel:hover { + border-color: var(--glass-hover-border); +} + +/* -------------------------------------------------------------------------- + 1. NAVIGATION HEADER (PROMINENT LOGO) + -------------------------------------------------------------------------- */ +.liquid-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 56px; + padding: 0 18px; + flex-shrink: 0; +} + +.header-left { + display: flex; + align-items: center; + gap: 16px; +} + +.brand-block { + display: flex; + align-items: center; +} + +.brand-logo-switcher { display: flex; align-items: center; } + +.brand-logo-en { + display: flex; + align-items: center; + gap: 5px; + height: 34px; + transition: transform 0.2s ease; +} + +.brand-logo-svg[hidden], +.brand-logo-en[hidden] { display: none; } + +.brand-mark-crop { + display: block; + width: 45px; + height: 34px; + overflow: hidden; +} + +.brand-mark-crop img { + display: block; + width: auto; + height: 34px; + max-width: none; +} + +.brand-logo-en > img { + display: block; + width: auto; + height: 34px; +} + +/* Enlarged Lingniu Logo */ +.brand-logo-svg { + height: 34px; + width: auto; + display: block; + object-fit: contain; + transition: transform 0.2s ease; +} + +.theme-dark .brand-block { + padding: 3px 6px; + background: rgba(255, 255, 255, 0.94); + border-radius: 8px; +} + +.brand-logo-svg:hover { + transform: scale(1.03); +} + +.brand-logo-en:hover { transform: scale(1.03); } + +.brand-divider { + width: 1px; + height: 22px; + background: var(--glass-border); +} + +.cockpit-title-wrap { + display: flex; + align-items: center; + gap: 8px; +} + +.cockpit-title { + font-size: 16px; + font-weight: 600; + letter-spacing: -0.3px; + color: var(--text-main); +} + +.de-tag { + font-size: 9px; + padding: 1px 6px; + background: var(--pill-bg); + border-radius: 4px; + color: var(--text-muted); + font-weight: 500; +} + +/* Header KPI Capsules */ +.header-kpi-group { + display: flex; + align-items: center; + gap: 10px; +} + +.kpi-glass-capsule { + display: flex; + flex-direction: column; + padding: 4px 14px; + background: var(--pill-bg); + border: 1px solid var(--glass-border); + border-radius: 10px; +} + +.capsule-lbl { + font-size: 9px; + color: var(--text-muted); +} + +.capsule-val { + font-family: 'JetBrains Mono', -apple-system, sans-serif; + font-size: 14px; + font-weight: 700; + color: var(--text-main); + line-height: 1.15; +} + +.capsule-val small { + font-size: 10px; + font-weight: 400; + color: var(--text-muted); +} + +.trend-pill { + font-size: 9px; + font-style: normal; + font-weight: 600; + color: var(--accent-cyan); + margin-left: 2px; +} + +/* Header Controls */ +.header-controls { + display: flex; + align-items: center; + gap: 10px; +} + +.glass-segmented-control, .theme-switcher-bw { + display: flex; + background: var(--segmented-bg); + padding: 2px; + border-radius: 8px; + border: 1px solid var(--glass-border); +} + +.segment-btn, .bw-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + background: transparent; + border: none; + border-radius: 6px; + color: var(--text-muted); + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.segment-btn.active, .bw-btn.active { + background: var(--glass-bg); + color: var(--text-main); + font-weight: 600; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); +} + +.bw-svg { + stroke: currentColor; +} + +.time-widget { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); +} + +/* -------------------------------------------------------------------------- + 2. MAIN BODY (CENTER MAP CANVAS + RIGHT SIDEBAR) + -------------------------------------------------------------------------- */ +.cockpit-main-grid { + display: grid; + grid-template-columns: 1fr 320px; + gap: 12px; + flex: 1; + min-height: 0; +} + +/* SPATIAL MAP CONTAINER */ +.map-spatial-container { + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.map-top-bar { + position: absolute; + top: 12px; + left: 12px; + right: 12px; + display: flex; + align-items: center; + justify-content: space-between; + z-index: 10; + pointer-events: none; +} + +.map-top-bar * { pointer-events: auto; } + +.status-glass-pill { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: 20px; + font-size: 11px; + color: var(--text-main); +} + +.pulse-ring { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent-cyan); +} + +.map-legend-glass { + display: flex; + gap: 10px; + padding: 4px 10px; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: 8px; + font-size: 10px; + color: var(--text-muted); +} + +.leg-item { + display: flex; + align-items: center; + gap: 4px; +} + +.dot { + width: 5px; + height: 5px; + border-radius: 50%; +} + +.dot-primary { background: var(--accent-cyan); } +.dot-mid { background: var(--accent-blue); } +.dot-low { background: var(--text-sub); } + +.amap-canvas-box { + flex: 1; + position: relative; + width: 100%; + height: 100%; +} + +#amapContainer { + width: 100%; + height: 100%; +} + +.map-action-controls { + position: absolute; + bottom: 24px; + right: 14px; + display: flex; + gap: 6px; + z-index: 10; +} + +.glass-btn { + padding: 4px 10px; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: 8px; + color: var(--text-main); + font-size: 10px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.glass-btn:hover { + border-color: var(--text-muted); +} + +.map-bottom-info { + height: 24px; + background: rgba(0, 0, 0, 0.15); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + font-size: 10px; + color: var(--text-muted); + border-top: 1px solid var(--glass-border); +} + +/* RIGHT SIDEBAR */ +.sidebar-operations { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; +} + +.sidebar-card { + display: flex; + flex-direction: column; + padding: 12px 14px; + overflow: hidden; +} + +.flex-fill-auto { flex: 1; min-height: 0; } + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + padding-bottom: 6px; + border-bottom: 1px solid var(--glass-border); +} + +.panel-title { + font-size: 12px; + font-weight: 600; + color: var(--text-main); +} + +.panel-heading-stack { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.viewport-meta { + color: var(--text-muted); + font-size: 9px; + font-family: 'JetBrains Mono', monospace; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.panel-tag { + font-size: 9px; + padding: 1px 5px; + background: var(--pill-bg); + border-radius: 4px; + color: var(--text-muted); + font-family: 'JetBrains Mono', monospace; +} + +/* Card 1: Clean Status Cells */ +.status-glass-grid3 { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + margin-bottom: 8px; +} + +.glass-cell { + padding: 6px 8px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--glass-border); + border-radius: 8px; +} + +.cell-num { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + font-weight: 700; + color: var(--text-main); + line-height: 1.1; +} + +.cell-num small { + font-size: 9px; + color: var(--text-muted); +} + +.cell-label { + display: flex; + align-items: center; + gap: 3px; + font-size: 9px; + color: var(--text-muted); + margin-top: 2px; +} + +.status-dot { + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; +} + +.dot-mint { background: var(--accent-mint); } +.dot-blue { background: var(--accent-blue); } +.dot-sub { background: var(--text-sub); } +.dot-total { background: var(--accent-cyan); } + +.liquid-progress-bar { + display: flex; + height: 4px; + border-radius: 2px; + overflow: hidden; + background: rgba(0, 0, 0, 0.2); +} + +.seg { height: 100%; } +.seg-running { background: var(--accent-mint); } +.seg-stopped { background: var(--accent-blue); } +/* Card 2: TOP Ranking List */ +.glass-tab-control { + display: flex; + gap: 2px; + background: var(--segmented-bg); + padding: 1px; + border-radius: 6px; +} + +.gtab { + padding: 2px 6px; + background: transparent; + border: none; + border-radius: 4px; + font-size: 9px; + color: var(--text-muted); + cursor: pointer; +} + +.gtab.active { + background: var(--glass-bg); + color: var(--text-main); + font-weight: 600; +} + +.liquid-ranking-list { + display: flex; + flex-direction: column; + gap: 5px; + overflow-y: auto; + flex: 1; +} + +.rank-empty { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 120px; + padding: 24px 16px; + border: 1px dashed var(--glass-border); + border-radius: 12px; + color: var(--text-muted); + font-size: 11px; + line-height: 1.6; + text-align: center; +} + +.rank-empty-viewport { + flex-direction: column; + gap: 8px; +} + +.rank-empty-icon { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: 50%; + background: var(--pill-bg); + color: var(--accent-primary); + font-size: 16px; +} + +.rank-glass-row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px; + border-radius: 8px; + font-size: 11px; + background: rgba(255, 255, 255, 0.01); + border: 1px solid var(--glass-border); + transition: all 0.15s ease; + cursor: pointer; +} + +.rank-glass-row:hover { + background: var(--pill-bg); + border-color: var(--glass-hover-border); +} + +.r-badge { + width: 18px; + height: 18px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 9.5px; + font-family: 'JetBrains Mono', monospace; + flex-shrink: 0; +} + +/* Scoped Theme Ranking Badge Colors for High Contrast in Both Modes */ +/* 1. Dark Mode Ranking Badges */ +.theme-dark .r-badge.r-top1 { background: #38BDF8; color: #0A0B0E; font-weight: 800; } +.theme-dark .r-badge.r-top2 { background: rgba(56, 189, 248, 0.2); color: #38BDF8; border: 1px solid rgba(56, 189, 248, 0.3); } +.theme-dark .r-badge.r-top3 { background: rgba(255, 255, 255, 0.1); color: #CBD5E1; } +.theme-dark .r-badge { background: rgba(255, 255, 255, 0.06); color: #64748B; } + +/* 2. Light Mode Ranking Badges (Clear High-Contrast Blue/Green/Dark Gray) */ +.theme-white .r-badge.r-top1 { background: #007143; color: #FFFFFF; font-weight: 800; } +.theme-white .r-badge.r-top2 { background: #0284C7; color: #FFFFFF; font-weight: 700; } +.theme-white .r-badge.r-top3 { background: rgba(0, 0, 0, 0.08); color: #1D1D1F; font-weight: 700; } +.theme-white .r-badge { background: rgba(0, 0, 0, 0.04); color: #86868B; } + +.r-name { + font-weight: 500; + color: var(--text-main); + flex: 1; +} + +.r-val { + font-family: 'JetBrains Mono', monospace; + font-weight: 600; + color: var(--text-main); +} + +/* Custom Scrollbars */ +::-webkit-scrollbar { width: 3px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--glass-border); border-radius: 3px; } + +/* AMAP MAP PROVINCE BUOYS */ +.amap-logo, .amap-copyright { + opacity: 0.2 !important; + filter: invert(0.9) hue-rotate(180deg) !important; +} + +.province-info-badge-wrap { + position: relative; + z-index: 10; +} + +.map-info-window { + min-width: 190px; + max-width: 300px; + padding: 11px 14px; + border: 1px solid var(--glass-border); + border-radius: 12px; + background: var(--glass-bg); + color: var(--text-main); + box-shadow: 0 14px 34px rgba(15, 23, 42, 0.16); + backdrop-filter: blur(18px); + font: 12px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.map-info-window strong { + display: block; + margin-bottom: 4px; + font-size: 13px; +} + +.province-info-badge-wrap:hover { + z-index: 999 !important; +} + +.province-info-card { + display: inline-flex; + flex-direction: column; + padding: 3px 8px; + background: var(--glass-bg); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--glass-border); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + cursor: pointer; + white-space: nowrap; + transition: transform 0.2s ease; +} + +.province-info-card.is-vehicle-point { + border-radius: 10px; + border-color: rgba(2, 132, 199, 0.34); + box-shadow: 0 5px 16px rgba(2, 132, 199, 0.18); +} + +.province-info-card.is-vehicle-point.is-online .p-dot { background: #00a86b; } +.province-info-card.is-vehicle-point.is-offline .p-dot { background: #8e8e93; } + +.province-info-badge-wrap:hover .province-info-card { + transform: scale(1.15); + border-color: var(--accent-cyan) !important; +} + +.p-head { + display: flex; + align-items: center; + gap: 4px; +} + +.p-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--accent-cyan); +} + +.p-name { + font-size: 10px; + font-weight: 500; + color: var(--text-main); +} + +.p-total { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 700; + color: var(--accent-quantity); + margin-left: 2px; +} + +.p-sub { + font-size: 8px; + color: var(--text-sub); + margin-top: 1px; +} + +/* -------------------------------------------------------------------------- + MOBILE / NARROW VIEWPORT + -------------------------------------------------------------------------- */ +@media (max-width: 768px) { + body { + height: auto; + min-height: 100dvh; + overflow-x: hidden; + overflow-y: auto; + } + + .liquid-cockpit-wrapper { + width: 100%; + height: auto; + min-height: 100dvh; + padding: 8px; + gap: 8px; + } + + .liquid-header { + height: auto; + min-height: 0; + padding: 10px; + flex-wrap: wrap; + gap: 10px; + } + + .header-left { + width: 100%; + min-width: 0; + gap: 10px; + } + + .brand-block { + flex: 0 1 150px; + min-width: 118px; + } + + .brand-logo-svg { + width: 100%; + max-width: 150px; + height: auto; + } + + .brand-divider { + flex: 0 0 1px; + } + + .cockpit-title-wrap { + min-width: 0; + flex: 1; + justify-content: flex-end; + } + + .cockpit-title { + flex: 0 0 auto; + font-size: 15px; + white-space: nowrap; + word-break: keep-all; + } + + .de-tag { + display: none; + } + + .header-kpi-group { + order: 2; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + width: 100%; + gap: 6px; + } + + .kpi-glass-capsule { + min-width: 0; + padding: 5px 7px; + } + + .capsule-lbl { + overflow: hidden; + font-size: 8px; + white-space: nowrap; + text-overflow: ellipsis; + } + + .capsule-val { + font-size: 12px; + white-space: nowrap; + } + + .header-controls { + order: 3; + width: 100%; + justify-content: space-between; + gap: 4px; + } + + .segment-btn, + .bw-btn { + padding: 4px 8px; + font-size: 10px; + } + + .time-widget { + display: none; + } + + .cockpit-main-grid { + grid-template-columns: minmax(0, 1fr); + flex: none; + min-height: 0; + gap: 8px; + } + + .map-spatial-container { + height: 520px; + min-height: 520px; + } + + .map-top-bar { + top: 8px; + left: 8px; + right: 8px; + } + + .status-glass-pill { + min-width: 0; + max-width: 100%; + padding: 4px 8px; + } + + #mapStatusText { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .map-legend-glass { + display: none; + } + + .map-action-controls { + right: 8px; + bottom: 30px; + gap: 4px; + } + + .glass-btn { + padding: 5px 7px; + font-size: 9px; + } + + .map-bottom-info { + justify-content: flex-start; + overflow: hidden; + } + + .map-bottom-info span:not(:last-child) { + display: none; + } + + .sidebar-operations { + min-height: auto; + } + + .sidebar-card { + overflow: visible; + } + + .liquid-ranking-list { + max-height: none; + overflow: visible; + } +} + +@media (max-width: 390px) { + .brand-block { + flex-basis: 138px; + } + + .brand-logo-svg { + max-width: 138px; + } + + .theme-switcher-bw .bw-btn span { + display: none; + } +} diff --git a/vehicle-map/tests/test_app.mjs b/vehicle-map/tests/test_app.mjs new file mode 100644 index 00000000..995b5ede --- /dev/null +++ b/vehicle-map/tests/test_app.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; + +const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); +const checks = ` +dashboard = { + vehicles: [ + { vin: 'VIN-GD-1', plateNumber: '粤A00001', online: true, dailyMileageKm: 10, locationAvailable: true, longitude: 113.2, latitude: 23.1 }, + { vin: 'VIN-GD-2', plateNumber: '粤B00002', online: false, dailyMileageKm: 5, locationAvailable: false }, + { vin: 'VIN-ZJ-1', plateNumber: '浙F00003', online: true, dailyMileageKm: 8, locationAvailable: true, longitude: 120.7, latitude: 30.7 }, + { vin: 'VIN-X-1', plateNumber: '观光车001', online: false, dailyMileageKm: 0, locationAvailable: false } + ] +}; +const areaNode = { + groupByPosition(points) { + return [ + { subFeatureIndex: 0, subFeature: { properties: { adcode: '440000', name: '广东省', center: [113.2, 23.1] } }, points: points.filter(point => point.vin === 'VIN-GD-1') }, + { subFeatureIndex: 1, subFeature: { properties: { adcode: '330000', name: '浙江省', center: [120.2, 30.3] } }, points: points.filter(point => point.vin === 'VIN-ZJ-1') } + ]; + } +}; +const partition = partitionVehiclesByArea(areaNode, locatedVehicles(), 'province'); +assert.equal(partition.groups.length, 2); +assert.equal(partition.unmatched.length, 0); +const provinceNodes = regionNodesFromGroups(partition.groups, 'province'); +assert.equal(provinceNodes.length, 2); +const guangdong = provinceNodes.find(node => node.nameZh === '广东'); +assert.equal(guangdong.count, 1); +assert.equal(guangdong.online, 1); +assert.equal(guangdong.dist, 10); + +assert.equal(hierarchyLevelForZoom(4.8), 'province'); +assert.equal(hierarchyLevelForZoom(7), 'city'); +assert.equal(hierarchyLevelForZoom(9.5), 'district'); +assert.equal(hierarchyLevelForZoom(12), 'vehicle'); + +vehicleRegionSummary = { level: 'province', nodes: provinceNodes, unassigned: 2, loading: false }; +map = { getZoom: () => 4.8 }; +assert.equal(vehicleNodes().length, 2); +assert.ok(vehicleNodes().every(node => node.kind === 'vehicleProvince')); + +const cityPartition = partitionVehiclesByArea({ + groupByPosition(points) { + return [{ subFeatureIndex: 0, subFeature: { properties: { adcode: '440100', name: '广州市', center: [113.3, 23.1] } }, points }]; + } +}, [dashboard.vehicles[0]], 'city'); +const cityNodes = regionNodesFromGroups(cityPartition.groups, 'city'); +assert.equal(cityNodes[0].kind, 'vehicleCity'); +assert.equal(cityNodes[0].nameZh, '广州'); +assert.equal(cityNodes[0].nameEn, 'Guangzhou City'); + +assert.equal(administrativeEnglishName('广东省', 'province'), 'Guangdong'); +assert.equal(administrativeEnglishName('广州市', 'city'), 'Guangzhou City'); +assert.equal(administrativeEnglishName('黄埔区', 'district'), 'Huangpu District'); +assert.equal(administrativeEnglishName('两江新区', 'district'), 'Liangjiang New Area'); +assert.equal(stationAdministrativePath({ province: '广东省', city: '广州市', district: '黄埔区' }, 'en'), 'Guangdong · Guangzhou City · Huangpu District'); +assert.deepEqual(modeKpiLabels(i18n.zh, 'station'), { total: '站点总数', active: '合作站点' }); +assert.deepEqual(modeKpiLabels(i18n.en, 'station'), { total: 'Total Stations', active: 'Partner Stations' }); +assert.equal(markerLabel({ kind: 'stationCluster', count: 65 }, i18n.en), '65 stations'); +assert.equal(markerLabel({ kind: 'stationCluster', count: 1 }, i18n.en), '1 station'); +assert.equal(markerLabel({ kind: 'station', cooperative: true }, i18n.en), 'H₂ · Partner'); + +map = { + getZoom: () => 12, + getBounds: () => ({ + getSouthWest: () => ({ getLng: () => 100, getLat: () => 20 }), + getNorthEast: () => ({ getLng: () => 125, getLat: () => 35 }) + }) +}; +const points = vehiclePointNodes(); +assert.equal(points.length, 2); +assert.ok(points.every(node => node.kind === 'vehiclePoint')); +assert.equal(points.find(node => node.nameZh === '粤A00001').online, 1); + +dashboard = { + stations: [ + { id: 'GD-1', name: '广州合作站', province: '广东省', city: '广州市', longitude: 113.2, latitude: 23.1, cooperative: true, totalHydrogenKg: 20 }, + { id: 'GD-2', name: '佛山外部站', province: '广东省', city: '佛山市', longitude: 113.1, latitude: 23.0, cooperative: false, totalHydrogenKg: 10 }, + { id: 'ZJ-1', name: '嘉兴合作站', province: '浙江省', city: '嘉兴市', longitude: 120.7, latitude: 30.7, cooperative: true, totalHydrogenKg: 30 } + ] +}; +currentMode = 'station'; +map = { + getZoom: () => 12, + getBounds: () => ({ + getSouthWest: () => ({ getLng: () => 112, getLat: () => 22 }), + getNorthEast: () => ({ getLng: () => 114, getLat: () => 24 }) + }) +}; +assert.equal(stationNodes(false).length, 3); +assert.equal(stationNodes(true).length, 2); +assert.deepEqual(stationNodes(true).map(node => node.name).sort(), ['佛山外部站', '广州合作站']); +const stationView = stationViewportSummary(); +assert.equal(stationView.level, 'station'); +assert.equal(stationView.visibleNodes.length, 2); +assert.equal(stationView.allNodes.length, 3); + +map = { + getZoom: () => 4.8, + getBounds: () => ({ + getSouthWest: () => ({ getLng: () => 112, getLat: () => 22 }), + getNorthEast: () => ({ getLng: () => 114, getLat: () => 24 }) + }) +}; +const provinceView = stationViewportSummary(); +assert.equal(provinceView.allNodes.length, 2); +assert.equal(provinceView.visibleNodes.length, 1); +assert.equal(provinceView.visibleNodes[0].name, '广东'); +assert.equal(provinceView.visibleNodes[0].count, 2); +`; + +const sandbox = { + assert, + console, + pinyinPro: { + pinyin(value) { + return ({ 广州: 'guang zhou', 黄埔: 'huang pu', 两江: 'liang jiang' })[value] || value; + } + }, + document: { addEventListener() {}, querySelector() {}, createElement() { return {}; }, head: { appendChild() {} } }, + window: {}, + setInterval() {}, + clearInterval() {} +}; +vm.runInNewContext(`${source}\n${checks}`, sandbox, { filename: 'app.js' }); +console.log('vehicle hierarchy aggregation tests: ok'); diff --git a/vehicle-map/tests/test_server.py b/vehicle-map/tests/test_server.py new file mode 100644 index 00000000..abc70e38 --- /dev/null +++ b/vehicle-map/tests/test_server.py @@ -0,0 +1,46 @@ +import importlib.util +from pathlib import Path +import unittest +from unittest.mock import patch + + +SPEC = importlib.util.spec_from_file_location("vehicle_map_server", Path(__file__).parents[1] / "server.py") +server = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(server) + + +class DashboardTest(unittest.TestCase): + def test_dashboard_aggregates_authorized_vehicle_and_station_data(self): + def fake_post(path, body): + if path.endswith("realtime/query"): + return [ + {"vin": "VIN1", "motionStatus": "driving", "speedKmh": 18, "activeToday": True, "online": True}, + {"vin": "VIN2", "motionStatus": "idle", "speedKmh": 0, "activeToday": True, "online": True}, + {"vin": "VIN3", "motionStatus": "offline", "speedKmh": 0, "activeToday": False, "online": False}, + ] + if path.endswith("mileage/query"): + return [ + {"vin": "VIN1", "status": "NORMAL", "dailyMileageKm": 12.345}, + {"vin": "VIN2", "status": "NORMAL", "dailyMileageKm": 7.655}, + {"vin": "VIN3", "status": "NO_DATA", "dailyMileageKm": None}, + ] + if path.endswith("hydrogen-stations/query"): + return [{"id": "1", "cooperative": True}, {"id": "2", "cooperative": False}] + raise AssertionError(path) + + with patch.object(server, "_post_open_platform", side_effect=fake_post): + with patch.object(server, "_cache", {}): + result = server._load_dashboard() + + self.assertEqual(result["summary"]["totalVehicles"], 3) + self.assertEqual(result["summary"]["onlineVehicles"], 2) + self.assertEqual(result["summary"]["drivingVehicles"], 1) + self.assertEqual(result["summary"]["todayMileageKm"], 20.0) + self.assertEqual(result["summary"]["totalStations"], 2) + self.assertEqual(result["summary"]["cooperativeStations"], 1) + self.assertEqual(result["vehicles"][0]["dailyMileageKm"], 12.345) + self.assertEqual(result["vehicles"][2]["dailyMileageKm"], 0) + + +if __name__ == "__main__": + unittest.main()