diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..983485f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +.env +.git +.codex +outputs +reports +data/geo +data/vehicle-heatmap/source.csv diff --git a/docker-compose.yml b/docker-compose.yml index dce0546..ca52561 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,14 @@ services: SERVER_PORT: "8111" EXTERNAL_API_BASE: "https://lnh2e.com" JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7" + AMAP_WEB_KEY: "${AMAP_WEB_KEY}" + AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}" + HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}" + HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}" + HEATMAP_DB_USER: "${HEATMAP_DB_USER}" + HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}" + HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}" + HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}" deploy: replicas: 1 restart_policy: diff --git a/package-lock.json b/package-lock.json index a8f1b09..1f05926 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "ln-bi", - "version": "1.1.6", + "version": "1.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ln-bi", - "version": "1.1.6", + "version": "1.1.7", "dependencies": { + "@amap/amap-jsapi-loader": "^1.0.1", "@hono/node-server": "^1.13.0", "@types/jsonwebtoken": "^9.0.10", "ali-oss": "^6.23.0", @@ -17,6 +18,7 @@ "lucide-react": "^0.546.0", "motion": "^12.23.24", "mysql2": "^3.11.0", + "pg": "^8.22.0", "react": "^19.0.0", "react-dom": "^19.0.0", "recharts": "^3.8.1", @@ -27,6 +29,7 @@ "@tailwindcss/vite": "^4.1.14", "@types/ali-oss": "^6.23.3", "@types/node": "^22.14.0", + "@types/pg": "^8.20.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.0.4", @@ -36,6 +39,12 @@ "vite": "^6.2.0" } }, + "node_modules/@amap/amap-jsapi-loader": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@amap/amap-jsapi-loader/-/amap-jsapi-loader-1.0.1.tgz", + "integrity": "sha512-nPyLKt7Ow/ThHLkSvn2etQlUzqxmTVgK7bIgwdBRTg2HK5668oN7xVxkaiRe3YZEzGzfV2XgH5Jmu2T73ljejw==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1620,6 +1629,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -3521,6 +3542,95 @@ "through": "~2.3" } }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3576,6 +3686,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -3969,6 +4118,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sql-escaper": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", diff --git a/package.json b/package.json index 85b75a2..805b434 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ln-bi", "private": true, - "version": "1.1.6", + "version": "1.1.7", "type": "module", "scripts": { "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", @@ -12,6 +12,7 @@ "lint": "tsc --noEmit" }, "dependencies": { + "@amap/amap-jsapi-loader": "^1.0.1", "@hono/node-server": "^1.13.0", "@types/jsonwebtoken": "^9.0.10", "ali-oss": "^6.23.0", @@ -21,6 +22,7 @@ "lucide-react": "^0.546.0", "motion": "^12.23.24", "mysql2": "^3.11.0", + "pg": "^8.22.0", "react": "^19.0.0", "react-dom": "^19.0.0", "recharts": "^3.8.1", @@ -31,6 +33,7 @@ "@tailwindcss/vite": "^4.1.14", "@types/ali-oss": "^6.23.3", "@types/node": "^22.14.0", + "@types/pg": "^8.20.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.0.4", diff --git a/src/App.tsx b/src/App.tsx index 911e947..d9d15be 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense, useEffect, useMemo, useState } from "react"; -import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt } from "lucide-react"; +import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt, MapPinned } from "lucide-react"; import { Shell, type ModuleConfig } from "./components/Shell"; import AuthProvider from "./auth/AuthProvider"; import { useAuth } from "./auth/useAuth"; @@ -15,6 +15,8 @@ const ElectricModule = lazy(() => import("./modules/energy/ElectricModule")); const EtcModule = lazy(() => import("./modules/energy/EtcModule")); const EleImportPage = lazy(() => import("./modules/ele/EleImportPage")); const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage")); +const VehicleHeatmapModule = lazy(() => import("./modules/vehicle-heatmap/VehicleHeatmapModule")); +const HydrogenHeatmapModule = lazy(() => import("./modules/hydrogen-heatmap/HydrogenHeatmapModule")); const ASSETS_MODULE: ModuleConfig = { id: "assets", @@ -30,6 +32,13 @@ const MILEAGE_MODULE: ModuleConfig = { component: MileageModule, }; +const VEHICLE_HEATMAP_MODULE: ModuleConfig = { + id: "vehicle-heatmap", + label: "车辆", + icon: MapPinned, + component: VehicleHeatmapModule, +}; + const SCHEDULING_MODULE: ModuleConfig = { id: "scheduling", label: "智能调度", @@ -44,6 +53,13 @@ const HYDROGEN_MODULE: ModuleConfig = { component: HydrogenModule, }; +const HYDROGEN_HEATMAP_MODULE: ModuleConfig = { + id: "hydrogen-heatmap", + label: "加氢", + icon: MapPinned, + component: HydrogenHeatmapModule, +}; + const ELECTRIC_MODULE: ModuleConfig = { id: "electric", label: "电能", @@ -138,7 +154,16 @@ function AuthGate() { if (pathSet === "energy") { return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE]; } - const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE]; + const heatmapChildren = canAccessEnergy(user?.roles) + ? [HYDROGEN_HEATMAP_MODULE, VEHICLE_HEATMAP_MODULE] + : [VEHICLE_HEATMAP_MODULE]; + const heatmapModule: ModuleConfig = { + id: "heatmap", + label: "热力图", + icon: MapPinned, + children: heatmapChildren, + }; + const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE, heatmapModule]; if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE); return result; }, [pathSet, user?.roles]); diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 659ba9d..71971e3 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react'; import { motion } from 'motion/react'; -import { Building2, ShieldCheck } from 'lucide-react'; +import { Building2, ChevronRight, ShieldCheck } from 'lucide-react'; import { useAuth } from '../auth/useAuth'; import { DemoModeProvider } from './Blur'; import FeedbackFab from './FeedbackFab'; @@ -11,7 +11,12 @@ export interface ModuleConfig { id: string; label: string; icon: ComponentType<{ size?: number; className?: string }>; - component: ElementType; + component?: ElementType; + children?: ModuleConfig[]; +} + +function flattenModules(modules: ModuleConfig[]): ModuleConfig[] { + return modules.flatMap((module) => module.children ?? [module]); } /** hash 一级段(`#` 或 `#/` 都只取 id) */ @@ -31,16 +36,27 @@ function getHashModule(modules: ModuleConfig[]): string { } export function Shell({ modules }: { modules: ModuleConfig[] }) { - const [activeModule, setActiveModule] = useState(() => getInitialModule(modules)); + const flatModules = useMemo(() => flattenModules(modules), [modules]); + const [activeModule, setActiveModule] = useState(() => getInitialModule(flattenModules(modules))); + const [openGroupId, setOpenGroupId] = useState(null); useEffect(() => { const onHashChange = () => { - const h = getHashModule(modules); + const h = getHashModule(flatModules); if (h) setActiveModule(h); }; window.addEventListener('hashchange', onHashChange); return () => window.removeEventListener('hashchange', onHashChange); - }, [modules]); + }, [flatModules]); + + useEffect(() => { + if (!openGroupId) return undefined; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpenGroupId(null); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [openGroupId]); useEffect(() => { // 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录, @@ -53,16 +69,20 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) { }, [activeModule]); const switchModule = (id: string) => { - if (getHashHead() === id) return; + setOpenGroupId(null); + if (getHashHead() === id) { + setActiveModule(id); + return; + } const { pathname, search } = window.location; window.history.replaceState(null, '', `${pathname}${search}#${id}`); setActiveModule(id); }; - const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component; + const ActiveComponent = flatModules.find((module) => module.id === activeModule)?.component ?? flatModules[0]?.component; const { user } = useAuth(); - const activeLabel = modules.find((m) => m.id === activeModule)?.label ?? '业务看板'; + const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板'; const watermarkText = useMemo(() => { const name = user?.userName || '未登录'; const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-'); @@ -87,29 +107,43 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
{modules.map((m) => { const Icon = m.icon; - const isActive = m.id === activeModule; + const isGroup = Boolean(m.children?.length); + const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule)); + const isOpen = openGroupId === m.id; return ( - + {isGroup && isOpen ? ( +
+ {m.children!.map((child) => { + const ChildIcon = child.icon; + const childActive = child.id === activeModule; + return ( + + ); + })} +
) : null} - - {m.label} - +
); })} @@ -139,26 +173,38 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
{modules.map((m) => { const Icon = m.icon; - const isActive = m.id === activeModule; + const isGroup = Boolean(m.children?.length); + const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule)); + const isOpen = openGroupId === m.id; return ( - + {isGroup && isOpen ? ( +
+ {m.children!.map((child) => { + const ChildIcon = child.icon; + const childActive = child.id === activeModule; + return ( + + ); + })} +
) : null} - - {m.label} - +
); })} diff --git a/src/index.css b/src/index.css index e19c174..f41699f 100644 --- a/src/index.css +++ b/src/index.css @@ -20,6 +20,32 @@ html { body { overflow: auto; height: 100%; + color: #0f172a; + background: #fff; + font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +button, input, select { + font: inherit; +} + +.amap-logo, +.amap-copyright { + opacity: 0.82; +} + +.amap-toolbar { + border: 1px solid #e2e8f0 !important; + border-radius: 10px !important; + box-shadow: 0 8px 24px rgb(15 23 42 / 10%) !important; + overflow: hidden; +} + +@media (max-width: 767px) { + .amap-logo, + .amap-copyright { + bottom: 60px !important; + } } @keyframes marquee { diff --git a/src/modules/hydrogen-heatmap/HydrogenAmapCanvas.tsx b/src/modules/hydrogen-heatmap/HydrogenAmapCanvas.tsx new file mode 100644 index 0000000..3ac891f --- /dev/null +++ b/src/modules/hydrogen-heatmap/HydrogenAmapCanvas.tsx @@ -0,0 +1,148 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { AmapConfig, HydrogenHeatmapMetric, HydrogenHeatmapPoint } from './types'; + +type Props = { + config: AmapConfig; + points: HydrogenHeatmapPoint[]; + max: number; + metric: HydrogenHeatmapMetric; + focusQuery: string; + onMapClick: (longitude: number, latitude: number) => void; +}; + +type AmapInstance = { + Map: new (container: HTMLElement, options: Record) => any; + HeatMap: new (map: any, options: Record) => any; + ToolBar: new (options?: Record) => any; + Scale: new (options?: Record) => any; + Bounds: new (southWest: [number, number], northEast: [number, number]) => any; +}; + +declare global { + interface Window { + _AMapSecurityConfig?: { securityJsCode: string }; + } +} + +function getBounds(points: HydrogenHeatmapPoint[]) { + if (!points.length) return null; + return points.reduce((bounds, point) => ({ + minLng: Math.min(bounds.minLng, point.lng), + maxLng: Math.max(bounds.maxLng, point.lng), + minLat: Math.min(bounds.minLat, point.lat), + maxLat: Math.max(bounds.maxLat, point.lat), + }), { minLng: points[0].lng, maxLng: points[0].lng, minLat: points[0].lat, maxLat: points[0].lat }); +} + +export default function HydrogenAmapCanvas({ config, points, max, metric, focusQuery, onMapClick }: Props) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const heatmapRef = useRef(null); + const amapRef = useRef(null); + const clickHandlerRef = useRef(onMapClick); + const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading'); + const intensity = useMemo(() => { + const transform = metric === 'kg' + ? (value: number) => Math.log1p(value) + : (value: number) => Math.sqrt(value); + return { + points: points.map((point) => ({ ...point, count: transform(point.count) })), + max: transform(Math.max(1, max)), + }; + }, [max, metric, points]); + + useEffect(() => { + clickHandlerRef.current = onMapClick; + }, [onMapClick]); + + useEffect(() => { + let cancelled = false; + const container = containerRef.current; + if (!container) return undefined; + + async function initialize() { + try { + window._AMapSecurityConfig = { securityJsCode: config.securityCode }; + const loaderModule = await import('@amap/amap-jsapi-loader'); + const AMap = await loaderModule.default.load({ + key: config.key, + version: '2.0', + plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'], + }) as unknown as AmapInstance; + if (cancelled || !container) return; + const map = new AMap.Map(container, { + viewMode: '2D', + zoom: 5, + center: [105.4, 34.4], + mapStyle: 'amap://styles/whitesmoke', + resizeEnable: true, + showLabel: true, + }); + map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } })); + map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } })); + const heatmap = new AMap.HeatMap(map, { + radius: 34, + opacity: [0.14, 0.84], + gradient: { + 0.1: '#2563eb', + 0.3: '#0891b2', + 0.5: '#16a34a', + 0.68: '#eab308', + 0.84: '#f97316', + 1: '#dc2626', + }, + }); + map.on('click', (event: any) => clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat())); + mapRef.current = map; + heatmapRef.current = heatmap; + amapRef.current = AMap; + setStatus('ready'); + } catch (error) { + console.error('Hydrogen heatmap AMap initialization failed', error); + if (!cancelled) setStatus('error'); + } + } + + initialize(); + return () => { + cancelled = true; + heatmapRef.current?.setMap?.(null); + mapRef.current?.destroy?.(); + heatmapRef.current = null; + mapRef.current = null; + amapRef.current = null; + }; + }, [config.key, config.securityCode]); + + useEffect(() => { + if (status !== 'ready' || !heatmapRef.current) return; + heatmapRef.current.setDataSet({ data: intensity.points, max: intensity.max }); + if (!focusQuery || !points.length || !mapRef.current || !amapRef.current) return; + const bounds = getBounds(points); + if (!bounds) return; + if (points.length === 1) { + mapRef.current.setZoomAndCenter(12, [points[0].lng, points[0].lat]); + return; + } + mapRef.current.setBounds(new amapRef.current.Bounds( + [bounds.minLng, bounds.minLat], + [bounds.maxLng, bounds.maxLat], + ), false, [80, 80, 80, 80]); + }, [focusQuery, intensity, points, status]); + + return ( +
+
+ {status === 'loading' ? ( +
+ 正在加载高德地图 +
+ ) : null} + {status === 'error' ? ( +
+

地图加载失败

请检查高德 Key、域名白名单与网络连接

+
+ ) : null} +
+ ); +} diff --git a/src/modules/hydrogen-heatmap/HydrogenHeatmapDetailPanel.tsx b/src/modules/hydrogen-heatmap/HydrogenHeatmapDetailPanel.tsx new file mode 100644 index 0000000..c8ab998 --- /dev/null +++ b/src/modules/hydrogen-heatmap/HydrogenHeatmapDetailPanel.tsx @@ -0,0 +1,85 @@ +import { ChevronRight, Fuel, X } from 'lucide-react'; +import type { HydrogenHeatmapMetric, HydrogenStationRank } from './types'; + +type Props = { + title: string; + subtitle: string; + kg: number; + refuelCount: number; + vehicleCount: number; + stationCount: number; + stations: HydrogenStationRank[]; + metric: HydrogenHeatmapMetric; + selected: boolean; + loading: boolean; + coverageText: string; + onClose: () => void; + onHide: () => void; +}; + +const integerFormat = new Intl.NumberFormat('zh-CN'); +const kgFormat = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }); + +function metricLabel(metric: HydrogenHeatmapMetric) { + if (metric === 'refuels') return '按加氢次数排序'; + if (metric === 'vehicles') return '按车辆数排序'; + return '按加氢量排序'; +} + +function metricValue(station: HydrogenStationRank, metric: HydrogenHeatmapMetric) { + if (metric === 'kg') return `${kgFormat.format(station.kg)} kg`; + if (metric === 'refuels') return `${integerFormat.format(station.refuelCount)} 次`; + return `${integerFormat.format(station.vehicleCount)} 辆`; +} + +export default function HydrogenHeatmapDetailPanel(props: Props) { + return ( + + ); +} diff --git a/src/modules/hydrogen-heatmap/HydrogenHeatmapFilters.tsx b/src/modules/hydrogen-heatmap/HydrogenHeatmapFilters.tsx new file mode 100644 index 0000000..1f2ca4a --- /dev/null +++ b/src/modules/hydrogen-heatmap/HydrogenHeatmapFilters.tsx @@ -0,0 +1,85 @@ +import { CalendarDays, ChevronUp, RotateCcw, Search } from 'lucide-react'; +import type { HydrogenHeatmapMetric, HydrogenPayer, HydrogenStationOption } from './types'; + +type Props = { + startDate: string; + endDate: string; + minDate: string; + maxDate: string; + query: string; + payer: HydrogenPayer; + metric: HydrogenHeatmapMetric; + stations: HydrogenStationOption[]; + onStartDateChange: (value: string) => void; + onEndDateChange: (value: string) => void; + onQueryChange: (value: string) => void; + onPayerChange: (value: HydrogenPayer) => void; + onMetricChange: (value: HydrogenHeatmapMetric) => void; + onReset: () => void; + onHide: () => void; +}; + +const FIELD = 'relative h-11 min-w-0 rounded-lg border border-slate-200 bg-white transition focus-within:border-cyan-400 focus-within:ring-2 focus-within:ring-cyan-100'; +const LABEL = 'pointer-events-none absolute left-3 top-1 z-10 text-[9px] font-medium leading-none text-slate-400'; +const CONTROL = 'h-full w-full min-w-0 rounded-lg border-0 bg-transparent px-3 pt-3 text-[12px] text-slate-700 outline-none'; + +export default function HydrogenHeatmapFilters(props: Props) { + return ( +
+ + + + + + + + +
+ + +
+
+ ); +} diff --git a/src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx b/src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx new file mode 100644 index 0000000..83a2dbd --- /dev/null +++ b/src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx @@ -0,0 +1,222 @@ +import { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react'; +import { AlertTriangle, ChevronLeft, Clock3, Fuel, Maximize2, Minimize2, SlidersHorizontal } from 'lucide-react'; +import HydrogenAmapCanvas from './HydrogenAmapCanvas'; +import HydrogenHeatmapDetailPanel from './HydrogenHeatmapDetailPanel'; +import HydrogenHeatmapFilters from './HydrogenHeatmapFilters'; +import { fetchAmapConfig, fetchHydrogenHeatmapMeta, fetchHydrogenHeatmapPoints, fetchNearbyHydrogenStations } from './api'; +import type { AmapConfig, HydrogenHeatmapMeta, HydrogenHeatmapMetric, HydrogenHeatmapResponse, HydrogenNearbyResponse, HydrogenPayer } from './types'; + +const DEFAULT_START = '2026-01-01'; +const DEFAULT_END = '2026-07-13'; +const integerFormat = new Intl.NumberFormat('zh-CN'); +const kgFormat = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }); + +function Metric({ value, label }: { value: string; label: string }) { + return ( +
+

{value}

+

{label}

+
+ ); +} + +function metricName(metric: HydrogenHeatmapMetric) { + if (metric === 'refuels') return '加氢频次'; + if (metric === 'vehicles') return '车辆覆盖'; + return '加氢量强度'; +} + +function metricDescription(metric: HydrogenHeatmapMetric) { + if (metric === 'refuels') return '每个站点累计有效加氢订单数;平方根平滑强度。'; + if (metric === 'vehicles') return '每个站点按车牌去重统计车辆数;平方根平滑强度。'; + return '每个站点累计有效加氢量(kg);对数平滑强度。'; +} + +export default function HydrogenHeatmapModule() { + const [meta, setMeta] = useState(null); + const [config, setConfig] = useState(null); + const [data, setData] = useState(null); + const [nearby, setNearby] = useState(null); + const [startDate, setStartDate] = useState(DEFAULT_START); + const [endDate, setEndDate] = useState(DEFAULT_END); + const [query, setQuery] = useState(''); + const [payer, setPayer] = useState('all'); + const [metric, setMetric] = useState('kg'); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showFilters, setShowFilters] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches); + const [showDetails, setShowDetails] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches); + const [loading, setLoading] = useState(true); + const [nearbyLoading, setNearbyLoading] = useState(false); + const [error, setError] = useState(''); + const deferredQuery = useDeferredValue(query.trim()); + + useEffect(() => { + if (!isFullscreen) return undefined; + const previousOverflow = document.body.style.overflow; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setIsFullscreen(false); + }; + document.body.style.overflow = 'hidden'; + document.addEventListener('keydown', handleKeyDown); + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isFullscreen]); + + useEffect(() => { + const controller = new AbortController(); + Promise.all([fetchHydrogenHeatmapMeta(controller.signal), fetchAmapConfig(controller.signal)]) + .then(([nextMeta, nextConfig]) => { + setMeta(nextMeta); + setConfig(nextConfig); + setStartDate(nextMeta.startDate); + setEndDate(nextMeta.endDate); + }) + .catch((requestError) => { + if (requestError?.name !== 'AbortError') setError('加氢热力图基础配置加载失败'); + }); + return () => controller.abort(); + }, []); + + useEffect(() => { + const controller = new AbortController(); + const timer = window.setTimeout(() => { + setLoading(true); + setError(''); + fetchHydrogenHeatmapPoints({ startDate, endDate, query: deferredQuery, payer, metric }, controller.signal) + .then((response) => { + setData(response); + setNearby(null); + }) + .catch((requestError) => { + if (requestError?.name !== 'AbortError') setError('加氢热力图数据加载失败,请稍后重试'); + }) + .finally(() => setLoading(false)); + }, 220); + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [deferredQuery, endDate, metric, payer, startDate]); + + const handleMapClick = useCallback((longitude: number, latitude: number) => { + const controller = new AbortController(); + setNearbyLoading(true); + fetchNearbyHydrogenStations({ lng: longitude, lat: latitude, startDate, endDate, query: deferredQuery, payer, metric }, controller.signal) + .then(setNearby) + .catch((requestError) => { + if (requestError?.name !== 'AbortError') setError('区域加氢站明细加载失败'); + }) + .finally(() => setNearbyLoading(false)); + }, [deferredQuery, endDate, metric, payer, startDate]); + + const reset = () => { + setStartDate(meta?.startDate || DEFAULT_START); + setEndDate(meta?.endDate || DEFAULT_END); + setQuery(''); + setPayer('all'); + setMetric('kg'); + setNearby(null); + }; + + const coverageText = meta + ? `GPS覆盖 ${(meta.gpsCoverageRate * 100).toFixed(3)}% · 已排除 ${integerFormat.format(meta.excludedRefuelCount)} 笔缺坐标数据` + : '正在核对站点 GPS 覆盖率'; + + const detail = useMemo(() => { + if (nearby) { + return { + title: '选中区域', + subtitle: `${nearby.center.lng.toFixed(3)}, ${nearby.center.lat.toFixed(3)} · 半径 ${nearby.radiusKm} 公里`, + kg: nearby.kg, + refuelCount: nearby.refuelCount, + vehicleCount: nearby.vehicleCount, + stationCount: nearby.stationCount, + stations: nearby.topStations, + selected: true, + }; + } + return { + title: deferredQuery ? '站点筛选结果' : '全国 · 当前范围', + subtitle: `${payer === 'all' ? '' : `${payer === 'lingniu' ? '羚牛承担' : '客户承担'} · `}${startDate} ~ ${endDate}`, + kg: data?.kg || 0, + refuelCount: data?.refuelCount || 0, + vehicleCount: data?.vehicleCount || 0, + stationCount: data?.stationCount || 0, + stations: data?.topStations || [], + selected: false, + }; + }, [data, deferredQuery, endDate, nearby, payer, startDate]); + + return ( +
+
+
+

加氢站加氢热力图

+

加氢账本 × 加氢站 GPS · 上海时区

+
+
+ + + + + +
+
+

数据更新至 {meta?.endDate || DEFAULT_END}

+ +
+
+ +
+
+ {config ? :
} + + {showFilters ? ( +
+ setShowFilters(false)} + /> +
+ ) : ( + + )} + + {!showDetails ? ( + + ) : null} + +
+
{metricName(metric)}{loading ? : null}
+

{metricDescription(metric)}

+
+
相对较低相对较高
+
+ + {error ?
{error}
: null} +
+ + {showDetails ? ( + setNearby(null)} onHide={() => setShowDetails(false)} /> + ) : null} +
+
+ ); +} diff --git a/src/modules/hydrogen-heatmap/api.ts b/src/modules/hydrogen-heatmap/api.ts new file mode 100644 index 0000000..e10e1cc --- /dev/null +++ b/src/modules/hydrogen-heatmap/api.ts @@ -0,0 +1,51 @@ +import { fetchJson } from '../../auth/api-client'; +import type { + AmapConfig, + HydrogenHeatmapMeta, + HydrogenHeatmapMetric, + HydrogenHeatmapResponse, + HydrogenNearbyResponse, + HydrogenPayer, +} from './types'; + +const BASE = '/api/hydrogen-heatmap'; + +export function fetchAmapConfig(signal?: AbortSignal) { + return fetchJson(`${BASE}/config`, { signal }); +} + +export function fetchHydrogenHeatmapMeta(signal?: AbortSignal) { + return fetchJson(`${BASE}/meta`, { signal }); +} + +export function fetchHydrogenHeatmapPoints( + params: { startDate: string; endDate: string; query: string; payer: HydrogenPayer; metric: HydrogenHeatmapMetric }, + signal?: AbortSignal, +) { + return fetchJson(`${BASE}/points?${new URLSearchParams(params)}`, { signal }); +} + +export function fetchNearbyHydrogenStations( + params: { + lng: number; + lat: number; + startDate: string; + endDate: string; + query: string; + payer: HydrogenPayer; + metric: HydrogenHeatmapMetric; + }, + signal?: AbortSignal, +) { + const search = new URLSearchParams({ + lng: String(params.lng), + lat: String(params.lat), + startDate: params.startDate, + endDate: params.endDate, + query: params.query, + payer: params.payer, + metric: params.metric, + radiusKm: '50', + }); + return fetchJson(`${BASE}/nearby?${search}`, { signal }); +} diff --git a/src/modules/hydrogen-heatmap/types.ts b/src/modules/hydrogen-heatmap/types.ts new file mode 100644 index 0000000..c928d59 --- /dev/null +++ b/src/modules/hydrogen-heatmap/types.ts @@ -0,0 +1,72 @@ +export type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles'; +export type HydrogenPayer = 'all' | 'lingniu' | 'customer'; + +export type HydrogenHeatmapPoint = { + lng: number; + lat: number; + count: number; +}; + +export type HydrogenStationRank = { + stationId: string; + stationName: string; + address: string; + lng: number; + lat: number; + kg: number; + refuelCount: number; + vehicleCount: number; + firstRefuel: string; + lastRefuel: string; + metricValue: number; +}; + +export type HydrogenStationOption = { + stationId: string; + stationName: string; +}; + +export type HydrogenHeatmapMeta = { + startDate: string; + endDate: string; + totalRefuelCount: number; + eligibleRefuelCount: number; + excludedRefuelCount: number; + gpsCoverageRate: number; + totalKg: number; + eligibleKg: number; + vehicleCount: number; + totalStationCount: number; + eligibleStationCount: number; + stations: HydrogenStationOption[]; +}; + +export type HydrogenHeatmapResponse = { + startDate: string; + endDate: string; + metric: HydrogenHeatmapMetric; + payer: HydrogenPayer; + kg: number; + refuelCount: number; + vehicleCount: number; + stationCount: number; + dayCount: number; + points: HydrogenHeatmapPoint[]; + max: number; + topStations: HydrogenStationRank[]; +}; + +export type HydrogenNearbyResponse = { + center: { lng: number; lat: number }; + radiusKm: number; + kg: number; + refuelCount: number; + vehicleCount: number; + stationCount: number; + topStations: HydrogenStationRank[]; +}; + +export type AmapConfig = { + key: string; + securityCode: string; +}; diff --git a/src/modules/vehicle-heatmap/AmapHeatmapCanvas.tsx b/src/modules/vehicle-heatmap/AmapHeatmapCanvas.tsx new file mode 100644 index 0000000..222f3e0 --- /dev/null +++ b/src/modules/vehicle-heatmap/AmapHeatmapCanvas.tsx @@ -0,0 +1,158 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { AmapConfig, HeatmapMetric, HeatmapPoint } from './types'; + +type Props = { + config: AmapConfig; + points: HeatmapPoint[]; + max: number; + metric: HeatmapMetric; + focusQuery: string; + onMapClick: (longitude: number, latitude: number) => void; +}; + +type AmapInstance = { + Map: new (container: HTMLElement, options: Record) => any; + HeatMap: new (map: any, options: Record) => any; + ToolBar: new (options?: Record) => any; + Scale: new (options?: Record) => any; + Bounds: new (southWest: [number, number], northEast: [number, number]) => any; +}; + +declare global { + interface Window { + _AMapSecurityConfig?: { securityJsCode: string }; + } +} + +function getBounds(points: HeatmapPoint[]) { + if (points.length === 0) return null; + let minLng = points[0].lng; + let maxLng = points[0].lng; + let minLat = points[0].lat; + let maxLat = points[0].lat; + for (let index = 1; index < points.length; index += 1) { + const point = points[index]; + minLng = Math.min(minLng, point.lng); + maxLng = Math.max(maxLng, point.lng); + minLat = Math.min(minLat, point.lat); + maxLat = Math.max(maxLat, point.lat); + } + return { minLng, maxLng, minLat, maxLat }; +} + +export default function AmapHeatmapCanvas({ config, points, max, metric, focusQuery, onMapClick }: Props) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const heatmapRef = useRef(null); + const amapRef = useRef(null); + const clickHandlerRef = useRef(onMapClick); + const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading'); + const intensity = useMemo(() => { + const transform = metric === 'locations' + ? (value: number) => Math.log1p(value) + : (value: number) => Math.sqrt(value); + return { + points: points.map((point) => ({ ...point, count: transform(point.count) })), + max: transform(Math.max(1, max)), + }; + }, [max, metric, points]); + + useEffect(() => { + clickHandlerRef.current = onMapClick; + }, [onMapClick]); + + useEffect(() => { + let cancelled = false; + const container = containerRef.current; + if (!container) return undefined; + + async function initialize() { + try { + window._AMapSecurityConfig = { securityJsCode: config.securityCode }; + const loaderModule = await import('@amap/amap-jsapi-loader'); + const AMap = await loaderModule.default.load({ + key: config.key, + version: '2.0', + plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'], + }) as unknown as AmapInstance; + if (cancelled || !container) return; + + const map = new AMap.Map(container, { + viewMode: '2D', + zoom: 5, + center: [105.4, 34.4], + mapStyle: 'amap://styles/whitesmoke', + resizeEnable: true, + showLabel: true, + }); + map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } })); + map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } })); + const heatmap = new AMap.HeatMap(map, { + radius: 25, + opacity: [0.12, 0.82], + gradient: { + 0.1: '#2563eb', + 0.3: '#0891b2', + 0.5: '#16a34a', + 0.68: '#eab308', + 0.84: '#f97316', + 1: '#dc2626', + }, + }); + map.on('click', (event: any) => { + clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat()); + }); + mapRef.current = map; + heatmapRef.current = heatmap; + amapRef.current = AMap; + setStatus('ready'); + } catch (error) { + console.error('AMap initialization failed', error); + if (!cancelled) setStatus('error'); + } + } + + initialize(); + return () => { + cancelled = true; + heatmapRef.current?.setMap?.(null); + mapRef.current?.destroy?.(); + heatmapRef.current = null; + mapRef.current = null; + amapRef.current = null; + }; + }, [config.key, config.securityCode]); + + useEffect(() => { + if (status !== 'ready' || !heatmapRef.current) return; + heatmapRef.current.setDataSet({ data: intensity.points, max: intensity.max }); + + if (!focusQuery || points.length === 0 || !mapRef.current || !amapRef.current) return; + const bounds = getBounds(points); + if (!bounds) return; + if (points.length === 1) { + mapRef.current.setZoomAndCenter(12, [points[0].lng, points[0].lat]); + return; + } + mapRef.current.setBounds(new amapRef.current.Bounds( + [bounds.minLng, bounds.minLat], + [bounds.maxLng, bounds.maxLat], + ), false, [80, 80, 80, 80]); + }, [focusQuery, intensity, points, status]); + + return ( +
+
+ {status === 'loading' ? ( +
+ 正在加载高德地图 +
+ ) : null} + {status === 'error' ? ( +
+

地图加载失败

请检查高德 Key、域名白名单与网络连接

+
+ ) : null} +
+ ); +} diff --git a/src/modules/vehicle-heatmap/HeatmapDetailPanel.tsx b/src/modules/vehicle-heatmap/HeatmapDetailPanel.tsx new file mode 100644 index 0000000..5cbd911 --- /dev/null +++ b/src/modules/vehicle-heatmap/HeatmapDetailPanel.tsx @@ -0,0 +1,86 @@ +import { ChevronRight, Crosshair, X } from 'lucide-react'; +import type { VehicleRank } from './types'; + +type Props = { + title: string; + subtitle: string; + locationCount: number; + vehicleCount: number; + vehicles: VehicleRank[]; + selected: boolean; + loading: boolean; + onClose: () => void; + onHide: () => void; +}; + +const numberFormat = new Intl.NumberFormat('zh-CN'); + +export default function HeatmapDetailPanel(props: Props) { + return ( + + ); +} diff --git a/src/modules/vehicle-heatmap/HeatmapFilters.tsx b/src/modules/vehicle-heatmap/HeatmapFilters.tsx new file mode 100644 index 0000000..18f50a4 --- /dev/null +++ b/src/modules/vehicle-heatmap/HeatmapFilters.tsx @@ -0,0 +1,115 @@ +import { CalendarDays, ChevronUp, RotateCcw, Search } from 'lucide-react'; +import type { HeatmapMetric, VehicleOption } from './types'; + +type Props = { + startDate: string; + endDate: string; + minDate: string; + maxDate: string; + query: string; + batchModel: string; + batchModels: string[]; + metric: HeatmapMetric; + vehicles: VehicleOption[]; + onStartDateChange: (value: string) => void; + onEndDateChange: (value: string) => void; + onQueryChange: (value: string) => void; + onBatchModelChange: (value: string) => void; + onMetricChange: (value: HeatmapMetric) => void; + onReset: () => void; + onHide: () => void; +}; + +const FIELD = 'relative h-11 min-w-0 rounded-lg border border-slate-200 bg-white transition focus-within:border-blue-400 focus-within:ring-2 focus-within:ring-blue-100'; +const LABEL = 'pointer-events-none absolute left-3 top-1 z-10 text-[9px] font-medium leading-none text-slate-400'; +const CONTROL = 'h-full w-full min-w-0 rounded-lg border-0 bg-transparent px-3 pt-3 text-[12px] text-slate-700 outline-none'; + +export default function HeatmapFilters(props: Props) { + return ( +
+ + + + + + + + +
+ + +
+
+ ); +} diff --git a/src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx b/src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx new file mode 100644 index 0000000..b417aac --- /dev/null +++ b/src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx @@ -0,0 +1,249 @@ +import { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react'; +import { AlertTriangle, ChevronLeft, Clock3, MapPinned, Maximize2, Minimize2, SlidersHorizontal } from 'lucide-react'; +import AmapHeatmapCanvas from './AmapHeatmapCanvas'; +import HeatmapDetailPanel from './HeatmapDetailPanel'; +import HeatmapFilters from './HeatmapFilters'; +import { fetchAmapConfig, fetchHeatmapMeta, fetchHeatmapPoints, fetchNearbyVehicles } from './api'; +import type { AmapConfig, HeatmapMeta, HeatmapMetric, HeatmapResponse, NearbyResponse } from './types'; + +const DEFAULT_START = '2026-01-01'; +const DEFAULT_END = '2026-07-13'; +const numberFormat = new Intl.NumberFormat('zh-CN'); + +function Metric({ value, label }: { value: number; label: string }) { + return ( +
+

{numberFormat.format(value)}

+

{label}

+
+ ); +} + +export default function VehicleHeatmapModule() { + const [meta, setMeta] = useState(null); + const [config, setConfig] = useState(null); + const [data, setData] = useState(null); + const [nearby, setNearby] = useState(null); + const [startDate, setStartDate] = useState(DEFAULT_START); + const [endDate, setEndDate] = useState(DEFAULT_END); + const [query, setQuery] = useState(''); + const [batchModel, setBatchModel] = useState(''); + const [metric, setMetric] = useState('locations'); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showFilters, setShowFilters] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches); + const [showDetails, setShowDetails] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches); + const [loading, setLoading] = useState(true); + const [nearbyLoading, setNearbyLoading] = useState(false); + const [error, setError] = useState(''); + const deferredQuery = useDeferredValue(query.trim()); + + useEffect(() => { + if (!isFullscreen) return undefined; + const previousOverflow = document.body.style.overflow; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setIsFullscreen(false); + }; + document.body.style.overflow = 'hidden'; + document.addEventListener('keydown', handleKeyDown); + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isFullscreen]); + + const toggleFullscreen = useCallback(() => { + setIsFullscreen((current) => !current); + }, []); + + useEffect(() => { + const controller = new AbortController(); + Promise.all([fetchHeatmapMeta(controller.signal), fetchAmapConfig(controller.signal)]) + .then(([nextMeta, nextConfig]) => { + setMeta(nextMeta); + setConfig(nextConfig); + setStartDate(nextMeta.startDate); + setEndDate(nextMeta.endDate); + }) + .catch((requestError) => { + if (requestError?.name !== 'AbortError') setError('热力图基础配置加载失败'); + }); + return () => controller.abort(); + }, []); + + useEffect(() => { + const controller = new AbortController(); + const timer = window.setTimeout(() => { + setLoading(true); + setError(''); + fetchHeatmapPoints({ startDate, endDate, query: deferredQuery, batchModel, metric }, controller.signal) + .then((response) => { + setData(response); + setNearby(null); + }) + .catch((requestError) => { + if (requestError?.name !== 'AbortError') setError('热力图数据加载失败,请稍后重试'); + }) + .finally(() => setLoading(false)); + }, 220); + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [batchModel, deferredQuery, endDate, metric, startDate]); + + const handleMapClick = useCallback((longitude: number, latitude: number) => { + const controller = new AbortController(); + setNearbyLoading(true); + fetchNearbyVehicles({ lng: longitude, lat: latitude, startDate, endDate, query: deferredQuery, batchModel }, controller.signal) + .then(setNearby) + .catch((requestError) => { + if (requestError?.name !== 'AbortError') setError('区域车辆明细加载失败'); + }) + .finally(() => setNearbyLoading(false)); + }, [batchModel, deferredQuery, endDate, startDate]); + + const reset = () => { + setStartDate(meta?.startDate || DEFAULT_START); + setEndDate(meta?.endDate || DEFAULT_END); + setQuery(''); + setBatchModel(''); + setMetric('locations'); + setNearby(null); + }; + + const detail = useMemo(() => { + if (nearby) { + return { + title: '选中区域', + subtitle: `${nearby.center.lng.toFixed(3)}, ${nearby.center.lat.toFixed(3)} · 半径 ${nearby.radiusKm} 公里`, + locationCount: nearby.locationCount, + vehicleCount: nearby.vehicleCount, + vehicles: nearby.topVehicles, + selected: true, + }; + } + return { + title: deferredQuery ? '车辆定位概览' : '全国 · 当前范围', + subtitle: `${batchModel ? `${batchModel} · ` : ''}${startDate} ~ ${endDate}`, + locationCount: data?.locationCount || 0, + vehicleCount: data?.vehicleCount || 0, + vehicles: data?.topVehicles || [], + selected: false, + }; + }, [batchModel, data, deferredQuery, endDate, nearby, startDate]); + + return ( +
+
+
+

车辆运营热力图

+

每日每车首个有效定位 · 上海时区

+
+
+ + + + + +
+
+

数据更新至 {meta?.endDate || DEFAULT_END}

+ +
+
+ +
+
+ {config ? ( + + ) :
} + + {showFilters ? ( +
+ setShowFilters(false)} + /> +
+ ) : ( + + )} + + {!showDetails ? ( + + ) : null} + +
+
{metric === 'locations' ? '定位活跃度' : '车辆覆盖度'}{loading ? : null}
+

+ {metric === 'locations' + ? '同一网格累计每日首个定位,同车跨天重复计数;对数平滑强度。' + : '同一网格按 VIN 去重,同车仅计 1 辆;平方根平滑强度。'} +

+
+
相对较低相对较高
+
+ + {error ? ( +
+ {error} +
+ ) : null} +
+ + {showDetails ? ( + setNearby(null)} + onHide={() => setShowDetails(false)} + /> + ) : null} +
+
+ ); +} diff --git a/src/modules/vehicle-heatmap/api.ts b/src/modules/vehicle-heatmap/api.ts new file mode 100644 index 0000000..0487b58 --- /dev/null +++ b/src/modules/vehicle-heatmap/api.ts @@ -0,0 +1,34 @@ +import { fetchJson } from '../../auth/api-client'; +import type { AmapConfig, HeatmapMeta, HeatmapMetric, HeatmapResponse, NearbyResponse } from './types'; + +export function fetchAmapConfig(signal?: AbortSignal) { + return fetchJson('/api/vehicle-heatmap/config', { signal }); +} + +export function fetchHeatmapMeta(signal?: AbortSignal) { + return fetchJson('/api/vehicle-heatmap/meta', { signal }); +} + +export function fetchHeatmapPoints( + params: { startDate: string; endDate: string; query: string; batchModel: string; metric: HeatmapMetric }, + signal?: AbortSignal, +) { + const search = new URLSearchParams(params); + return fetchJson(`/api/vehicle-heatmap/points?${search}`, { signal }); +} + +export function fetchNearbyVehicles( + params: { lng: number; lat: number; startDate: string; endDate: string; query: string; batchModel: string }, + signal?: AbortSignal, +) { + const search = new URLSearchParams({ + lng: String(params.lng), + lat: String(params.lat), + startDate: params.startDate, + endDate: params.endDate, + query: params.query, + batchModel: params.batchModel, + radiusKm: '50', + }); + return fetchJson(`/api/vehicle-heatmap/nearby?${search}`, { signal }); +} diff --git a/src/modules/vehicle-heatmap/types.ts b/src/modules/vehicle-heatmap/types.ts new file mode 100644 index 0000000..e1a9a27 --- /dev/null +++ b/src/modules/vehicle-heatmap/types.ts @@ -0,0 +1,59 @@ +export type HeatmapMetric = 'locations' | 'vehicles'; + +export type HeatmapPoint = { + lng: number; + lat: number; + count: number; +}; + +export type VehicleRank = { + vin: string; + plateNumber: string; + locationCount: number; + firstSeen: string; + lastSeen: string; +}; + +export type VehicleOption = { + vin: string; + plateNumber: string; +}; + +export type HeatmapMeta = { + startDate: string; + endDate: string; + locationCount: number; + vehicleCount: number; + dayCount: number; + totalLocationCount: number; + excludedLocationCount: number; + outsideMainlandCount: number; + tibetCount: number; + batchModels: string[]; + vehicles: VehicleOption[]; +}; + +export type HeatmapResponse = { + startDate: string; + endDate: string; + metric: HeatmapMetric; + locationCount: number; + vehicleCount: number; + dayCount: number; + points: HeatmapPoint[]; + max: number; + topVehicles: VehicleRank[]; +}; + +export type NearbyResponse = { + center: { lng: number; lat: number }; + radiusKm: number; + locationCount: number; + vehicleCount: number; + topVehicles: VehicleRank[]; +}; + +export type AmapConfig = { + key: string; + securityCode: string; +}; diff --git a/src/server/heatmap-db.ts b/src/server/heatmap-db.ts new file mode 100644 index 0000000..1a8f6fa --- /dev/null +++ b/src/server/heatmap-db.ts @@ -0,0 +1,29 @@ +import dotenv from 'dotenv'; +import pg from 'pg'; + +dotenv.config(); + +const { Pool } = pg; + +const heatmapPool = new Pool({ + host: process.env.HEATMAP_DB_HOST, + port: Number(process.env.HEATMAP_DB_PORT) || 5432, + user: process.env.HEATMAP_DB_USER, + password: process.env.HEATMAP_DB_PASSWORD, + database: process.env.HEATMAP_DB_NAME, + ssl: process.env.HEATMAP_DB_SSL === 'true' + ? { rejectUnauthorized: false } + : false, + max: 5, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 10_000, + statement_timeout: 30_000, + application_name: 'ln-bi-vehicle-heatmap', + options: '-c timezone=Asia/Shanghai -c default_transaction_read_only=on', +}); + +heatmapPool.on('error', (error) => { + console.error('[vehicle-heatmap] idle PostgreSQL client error:', error); +}); + +export default heatmapPool; diff --git a/src/server/index.ts b/src/server/index.ts index d521938..5df95d5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -9,6 +9,8 @@ import schedulingRouter from './routes/scheduling/index.js'; import energyRouter from './routes/energy/index.js'; import eleRouter from './routes/ele/index.js'; import feedbackRouter from './routes/feedback/index.js'; +import vehicleHeatmapRouter from './routes/vehicle-heatmap.js'; +import hydrogenHeatmapRouter from './routes/hydrogen-heatmap.js'; import { ensureSchedulingTables } from './routes/scheduling/db-schema.js'; import authRouter from './auth/login.js'; import { authMiddleware } from './auth/middleware.js'; @@ -31,6 +33,8 @@ app.route('/api/scheduling', schedulingRouter); app.route('/api/energy', energyRouter); app.route('/api/ele', eleRouter); app.route('/api/feedback', feedbackRouter); +app.route('/api/vehicle-heatmap', vehicleHeatmapRouter); +app.route('/api/hydrogen-heatmap', hydrogenHeatmapRouter); app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() })); diff --git a/src/server/routes/hydrogen-heatmap.ts b/src/server/routes/hydrogen-heatmap.ts new file mode 100644 index 0000000..a3a92c4 --- /dev/null +++ b/src/server/routes/hydrogen-heatmap.ts @@ -0,0 +1,290 @@ +import { Hono } from 'hono'; +import type { RowDataPacket } from 'mysql2'; +import pool from '../db.js'; +import type { AuthUser } from '../auth/types.js'; +import { canAccessEnergy } from '../auth/types.js'; + +type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles'; +type Payer = 'all' | 'lingniu' | 'customer'; + +type StationRow = RowDataPacket & { + station_id: string; + station_name: string; + address: string; + longitude: string | number; + latitude: string | number; + kg: string | number; + refuel_count: string | number; + vehicle_count: string | number; + first_refuel: string; + last_refuel: string; +}; + +const DEFAULT_START = '2026-01-01'; +const DEFAULT_END = '2026-07-13'; +const VALID_COORDINATE = ` + o.longitude BETWEEN 73.5 AND 135.1 + AND o.latitude BETWEEN 18 AND 53.6 + AND COALESCE(o.province, '') NOT LIKE '%西藏%' +`; + +function isDate(value: string | undefined): value is string { + return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value)); +} + +function parseMetric(value: string | undefined): HydrogenHeatmapMetric { + if (value === 'refuels' || value === 'vehicles') return value; + return 'kg'; +} + +function parsePayer(value: string | undefined): Payer { + if (value === 'lingniu' || value === 'customer') return value; + return 'all'; +} + +function buildWhere(startDate: string, endDate: string, query: string, payer: Payer) { + const clauses = [ + `b.del_flag = '0'`, + `DATE(b.refuel_time) BETWEEN ? AND ?`, + VALID_COORDINATE, + ]; + const params: Array = [startDate, endDate]; + + if (query) { + clauses.push(`CONVERT(CONCAT_WS(' ', s.station_name, s.station_short_name, b.station_name, o.station_name, o.fixed_station_name, o.station_address, o.city) USING utf8mb4) COLLATE utf8mb4_unicode_ci LIKE CONVERT(? USING utf8mb4) COLLATE utf8mb4_unicode_ci`); + params.push(`%${query}%`); + } + if (payer === 'lingniu') { + clauses.push(`COALESCE(b.customer_price, 0) <= 0 AND COALESCE(b.fee_total, 0) <= 0`); + } else if (payer === 'customer') { + clauses.push(`(COALESCE(b.customer_price, 0) > 0 OR COALESCE(b.fee_total, 0) > 0)`); + } + return { sql: clauses.join(' AND '), params }; +} + +async function loadStations(startDate: string, endDate: string, query: string, payer: Payer): Promise { + const where = buildWhere(startDate, endDate, query, payer); + const [rows] = await pool.execute(` + SELECT + CAST(b.station_id AS CHAR) AS station_id, + COALESCE( + NULLIF(MAX(s.station_short_name), ''), + NULLIF(MAX(s.station_name), ''), + NULLIF(MAX(b.station_name), ''), + NULLIF(MAX(o.fixed_station_name), ''), + CONCAT('未知站点 #', b.station_id) + ) AS station_name, + COALESCE(NULLIF(MAX(o.station_address), ''), NULLIF(MAX(s.station_address), ''), '') AS address, + MAX(o.longitude) AS longitude, + MAX(o.latitude) AS latitude, + ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg, + COUNT(*) AS refuel_count, + COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count, + DATE_FORMAT(MIN(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS first_refuel, + DATE_FORMAT(MAX(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS last_refuel + FROM hydrogen_fuel_ledger b + LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0' + INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id + WHERE ${where.sql} + GROUP BY b.station_id + `, where.params); + return rows; +} + +function metricValue(row: StationRow, metric: HydrogenHeatmapMetric): number { + if (metric === 'refuels') return Number(row.refuel_count) || 0; + if (metric === 'vehicles') return Number(row.vehicle_count) || 0; + return Number(row.kg) || 0; +} + +function serializeStation(row: StationRow, metric: HydrogenHeatmapMetric) { + return { + stationId: row.station_id, + stationName: row.station_name, + address: row.address, + lng: Number(row.longitude), + lat: Number(row.latitude), + kg: Number(row.kg) || 0, + refuelCount: Number(row.refuel_count) || 0, + vehicleCount: Number(row.vehicle_count) || 0, + firstRefuel: row.first_refuel, + lastRefuel: row.last_refuel, + metricValue: metricValue(row, metric), + }; +} + +function haversineKm(leftLng: number, leftLat: number, rightLng: number, rightLat: number): number { + const radians = (degrees: number) => degrees * Math.PI / 180; + const earthRadius = 6371; + const dLat = radians(rightLat - leftLat); + const dLng = radians(rightLng - leftLng); + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(radians(leftLat)) * Math.cos(radians(rightLat)) * Math.sin(dLng / 2) ** 2; + return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +const router = new Hono(); + +router.use('*', async (c, next) => { + const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined; + if (user && !canAccessEnergy(user.roles)) { + return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403); + } + return next(); +}); + +router.get('/config', (c) => { + const key = process.env.AMAP_WEB_KEY; + const securityCode = process.env.AMAP_SECURITY_JS_CODE; + if (!key || !securityCode) return c.json({ error: '高德地图配置缺失' }, 503); + return c.json({ key, securityCode }); +}); + +router.get('/meta', async (c) => { + const [profileRows, optionRows] = await Promise.all([ + pool.query(` + SELECT + DATE_FORMAT(MIN(b.refuel_time), '%Y-%m-%d') AS start_date, + DATE_FORMAT(MAX(b.refuel_time), '%Y-%m-%d') AS end_date, + COUNT(*) AS total_refuel_count, + ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS total_kg, + COUNT(DISTINCT b.station_id) AS total_station_count, + COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count, + SUM(CASE WHEN ${VALID_COORDINATE} THEN 1 ELSE 0 END) AS eligible_refuel_count, + ROUND(SUM(CASE WHEN ${VALID_COORDINATE} THEN COALESCE(b.amount_kg, 0) ELSE 0 END), 2) AS eligible_kg, + COUNT(DISTINCT CASE WHEN ${VALID_COORDINATE} THEN b.station_id END) AS eligible_station_count + FROM hydrogen_fuel_ledger b + LEFT JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id + WHERE b.del_flag = '0' + `), + pool.query(` + SELECT + CAST(s.id AS CHAR) AS station_id, + COALESCE(NULLIF(s.station_short_name, ''), s.station_name) AS station_name + FROM hydrogen_station s + INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = s.id + INNER JOIN hydrogen_fuel_ledger b ON b.station_id = s.id AND b.del_flag = '0' + WHERE s.del_flag = '0' AND ${VALID_COORDINATE} + GROUP BY s.id, s.station_short_name, s.station_name + ORDER BY station_name + `), + ]); + const profile = profileRows[0][0] || {}; + const totalRefuelCount = Number(profile.total_refuel_count) || 0; + const eligibleRefuelCount = Number(profile.eligible_refuel_count) || 0; + return c.json({ + startDate: profile.start_date || DEFAULT_START, + endDate: profile.end_date || DEFAULT_END, + totalRefuelCount, + eligibleRefuelCount, + excludedRefuelCount: Math.max(0, totalRefuelCount - eligibleRefuelCount), + gpsCoverageRate: totalRefuelCount ? eligibleRefuelCount / totalRefuelCount : 0, + totalKg: Number(profile.total_kg) || 0, + eligibleKg: Number(profile.eligible_kg) || 0, + vehicleCount: Number(profile.vehicle_count) || 0, + totalStationCount: Number(profile.total_station_count) || 0, + eligibleStationCount: Number(profile.eligible_station_count) || 0, + stations: optionRows[0].map((row) => ({ + stationId: String(row.station_id), + stationName: String(row.station_name), + })), + }); +}); + +router.get('/points', async (c) => { + const startDate = isDate(c.req.query('startDate')) ? c.req.query('startDate')! : DEFAULT_START; + const endDate = isDate(c.req.query('endDate')) ? c.req.query('endDate')! : DEFAULT_END; + if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400); + const query = (c.req.query('query') || '').trim(); + const payer = parsePayer(c.req.query('payer')); + const metric = parseMetric(c.req.query('metric')); + const stations = await loadStations(startDate, endDate, query, payer); + const ranked = [...stations].sort((left, right) => metricValue(right, metric) - metricValue(left, metric)); + const points = ranked.map((row) => ({ + lng: Number(row.longitude), + lat: Number(row.latitude), + count: metricValue(row, metric), + })); + const where = buildWhere(startDate, endDate, query, payer); + const [summaryRows] = await pool.execute(` + SELECT + ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg, + COUNT(*) AS refuel_count, + COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count, + COUNT(DISTINCT b.station_id) AS station_count, + COUNT(DISTINCT DATE(b.refuel_time)) AS day_count + FROM hydrogen_fuel_ledger b + LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0' + INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id + WHERE ${where.sql} + `, where.params); + const summary = summaryRows[0] || {}; + return c.json({ + startDate, + endDate, + metric, + payer, + kg: Number(summary.kg) || 0, + refuelCount: Number(summary.refuel_count) || 0, + vehicleCount: Number(summary.vehicle_count) || 0, + stationCount: Number(summary.station_count) || 0, + dayCount: Number(summary.day_count) || 0, + points, + max: Math.max(1, ...points.map((point) => point.count)), + topStations: ranked.slice(0, 10).map((row) => serializeStation(row, metric)), + }); +}); + +router.get('/nearby', async (c) => { + const longitude = Number(c.req.query('lng')); + const latitude = Number(c.req.query('lat')); + if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return c.json({ error: '经纬度参数无效' }, 400); + const startDate = isDate(c.req.query('startDate')) ? c.req.query('startDate')! : DEFAULT_START; + const endDate = isDate(c.req.query('endDate')) ? c.req.query('endDate')! : DEFAULT_END; + const query = (c.req.query('query') || '').trim(); + const payer = parsePayer(c.req.query('payer')); + const metric = parseMetric(c.req.query('metric')); + const radiusKm = Math.min(200, Math.max(5, Number(c.req.query('radiusKm')) || 50)); + const stations = await loadStations(startDate, endDate, query, payer); + const nearby = stations.filter((row) => haversineKm( + longitude, + latitude, + Number(row.longitude), + Number(row.latitude), + ) <= radiusKm); + const nearbyIds = nearby.map((row) => row.station_id); + let nearbySummary = { kg: 0, refuelCount: 0, vehicleCount: 0, stationCount: 0 }; + if (nearbyIds.length) { + const placeholders = nearbyIds.map(() => '?').join(','); + const where = buildWhere(startDate, endDate, query, payer); + const [rows] = await pool.execute(` + SELECT + ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg, + COUNT(*) AS refuel_count, + COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count, + COUNT(DISTINCT b.station_id) AS station_count + FROM hydrogen_fuel_ledger b + LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0' + INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id + WHERE ${where.sql} AND CAST(b.station_id AS CHAR) IN (${placeholders}) + `, [...where.params, ...nearbyIds]); + nearbySummary = { + kg: Number(rows[0]?.kg) || 0, + refuelCount: Number(rows[0]?.refuel_count) || 0, + vehicleCount: Number(rows[0]?.vehicle_count) || 0, + stationCount: Number(rows[0]?.station_count) || 0, + }; + } + const ranked = [...nearby].sort((left, right) => metricValue(right, metric) - metricValue(left, metric)); + return c.json({ + center: { lng: longitude, lat: latitude }, + radiusKm, + kg: nearbySummary.kg, + refuelCount: nearbySummary.refuelCount, + vehicleCount: nearbySummary.vehicleCount, + stationCount: nearbySummary.stationCount, + topStations: ranked.slice(0, 10).map((row) => serializeStation(row, metric)), + }); +}); + +export default router; diff --git a/src/server/routes/vehicle-heatmap.ts b/src/server/routes/vehicle-heatmap.ts new file mode 100644 index 0000000..591fae8 --- /dev/null +++ b/src/server/routes/vehicle-heatmap.ts @@ -0,0 +1,299 @@ +import { Hono } from 'hono'; +import pool from '../db.js'; +import heatmapPool from '../heatmap-db.js'; +import { getCache } from './mileage/cache.js'; + +type HeatmapRecord = { + date: string; + vin: string; + plate: string; + time: string; + longitude: number; + latitude: number; + source: string; + sourceRecordId: string; +}; + +type VehicleRank = { + vin: string; + plateNumber: string; + locationCount: number; + firstSeen: string; + lastSeen: string; +}; + +type MetaRow = { + start_date: string; + end_date: string; + location_count: string; + vehicle_count: string; + day_count: string; + total_location_count: string; + excluded_location_count: string; + outside_mainland_count: string; + tibet_count: string; +}; + +type VehicleOptionRow = { + vin: string; + plate_number: string; +}; + +type RecordRow = { + date: string; + vin: string; + plate: string; + time: string; + longitude: number; + latitude: number; + source: string; + source_record_id: string; +}; + +type BatchModelRow = { + target_name: string; + plate_number: string; +}; + +function isDate(value: string | undefined): value is string { + return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value)); +} + +function daysInclusive(startDate: string, endDate: string): number { + const start = Date.parse(`${startDate}T00:00:00+08:00`); + const end = Date.parse(`${endDate}T00:00:00+08:00`); + return Math.max(0, Math.floor((end - start) / 86_400_000) + 1); +} + +async function loadBatchModelPlates(): Promise>> { + const cached = getCache()?.targetPlatesMap; + if (cached?.size) return cached; + + try { + const [rows] = await pool.execute(` + select t.target_name, v.plate_number + from lingniu_prod.tab_mileage_assessment_target t + join lingniu_prod.tab_mileage_assessment_vehicle v + on v.target_id = t.id and v.is_deleted = 0 + where t.is_deleted = 0 + `) as [BatchModelRow[], unknown]; + const result = new Map>(); + for (const row of rows) { + const plates = result.get(row.target_name) || new Set(); + plates.add(row.plate_number); + result.set(row.target_name, plates); + } + return result; + } catch (error) { + console.error('[vehicle-heatmap] batch model lookup failed', error); + return new Map(); + } +} + +async function loadRecords( + startDate: string, + endDate: string, + query: string, + batchModel: string, +): Promise { + let batchPlates: string[] = []; + if (batchModel) { + const modelPlates = await loadBatchModelPlates(); + batchPlates = [...(modelPlates.get(batchModel) || [])]; + if (batchPlates.length === 0) return []; + } + + const result = await heatmapPool.query(` + select + to_char(stat_date, 'YYYY-MM-DD') as date, + vin, + plate_number as plate, + to_char(first_event_time at time zone 'Asia/Shanghai', + 'YYYY-MM-DD HH24:MI:SS') as time, + longitude, + latitude, + source_name as source, + coalesce(source_record_id::text, '') as source_record_id + from analytics.vehicle_daily_first_location + where stat_date between $1::date and $2::date + and is_heatmap_eligible + and ($4::boolean = false or plate_number = any($5::text[])) + and ( + $3 = '' + or upper(vin) like '%' || upper($3) || '%' + or upper(plate_number) like '%' || upper($3) || '%' + ) + order by stat_date, vin + `, [startDate, endDate, query.trim(), Boolean(batchModel), batchPlates]); + + return result.rows.map((row) => ({ + ...row, + longitude: Number(row.longitude), + latitude: Number(row.latitude), + sourceRecordId: row.source_record_id, + })); +} + +function buildRanking(records: HeatmapRecord[], limit = 10): VehicleRank[] { + const vehicles = new Map(); + for (const record of records) { + const current = vehicles.get(record.vin); + if (!current) { + vehicles.set(record.vin, { + vin: record.vin, + plateNumber: record.plate, + locationCount: 1, + firstSeen: record.time, + lastSeen: record.time, + }); + continue; + } + current.locationCount += 1; + if (record.time < current.firstSeen) current.firstSeen = record.time; + if (record.time > current.lastSeen) current.lastSeen = record.time; + if (!current.plateNumber && record.plate) current.plateNumber = record.plate; + } + return [...vehicles.values()] + .sort((left, right) => right.locationCount - left.locationCount + || left.plateNumber.localeCompare(right.plateNumber, 'zh-CN')) + .slice(0, limit); +} + +function haversineKm(leftLng: number, leftLat: number, rightLng: number, rightLat: number): number { + const radians = (degrees: number) => degrees * Math.PI / 180; + const earthRadius = 6371; + const dLat = radians(rightLat - leftLat); + const dLng = radians(rightLng - leftLng); + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(radians(leftLat)) * Math.cos(radians(rightLat)) * Math.sin(dLng / 2) ** 2; + return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +const router = new Hono(); + +router.get('/config', (c) => { + const key = process.env.AMAP_WEB_KEY; + const securityCode = process.env.AMAP_SECURITY_JS_CODE; + if (!key || !securityCode) { + return c.json({ error: '高德地图配置缺失' }, 503); + } + return c.json({ key, securityCode }); +}); + +router.get('/meta', async (c) => { + const [metaResult, vehiclesResult, batchModelPlates] = await Promise.all([ + heatmapPool.query(` + select + to_char(min(stat_date) filter (where is_heatmap_eligible), 'YYYY-MM-DD') as start_date, + to_char(max(stat_date) filter (where is_heatmap_eligible), 'YYYY-MM-DD') as end_date, + count(*) filter (where is_heatmap_eligible)::text as location_count, + count(distinct vin) filter (where is_heatmap_eligible)::text as vehicle_count, + count(distinct stat_date) filter (where is_heatmap_eligible)::text as day_count, + count(*)::text as total_location_count, + count(*) filter (where not is_heatmap_eligible)::text as excluded_location_count, + count(*) filter (where exclusion_reason = 'outside_mainland_china')::text as outside_mainland_count, + count(*) filter (where exclusion_reason = 'tibet')::text as tibet_count + from analytics.vehicle_daily_first_location + `), + heatmapPool.query(` + select distinct on (vin) vin, plate_number + from analytics.vehicle_daily_first_location + where is_heatmap_eligible + order by vin, stat_date desc + `), + loadBatchModelPlates(), + ]); + const meta = metaResult.rows[0]; + const vehicles = vehiclesResult.rows + .map(({ vin, plate_number: plateNumber }) => ({ vin, plateNumber })) + .sort((left, right) => left.plateNumber.localeCompare(right.plateNumber, 'zh-CN')); + return c.json({ + startDate: meta.start_date, + endDate: meta.end_date, + locationCount: Number(meta.location_count), + vehicleCount: Number(meta.vehicle_count), + dayCount: Number(meta.day_count), + totalLocationCount: Number(meta.total_location_count), + excludedLocationCount: Number(meta.excluded_location_count), + outsideMainlandCount: Number(meta.outside_mainland_count), + tibetCount: Number(meta.tibet_count), + batchModels: [...batchModelPlates.keys()].sort((left, right) => left.localeCompare(right, 'zh-CN')), + vehicles, + }); +}); + +router.get('/points', async (c) => { + const requestedStart = c.req.query('startDate'); + const requestedEnd = c.req.query('endDate'); + const startDate = isDate(requestedStart) ? requestedStart : '2026-01-01'; + const endDate = isDate(requestedEnd) ? requestedEnd : '2026-07-13'; + if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400); + + const query = c.req.query('query') || ''; + const batchModel = (c.req.query('batchModel') || '').trim(); + const metric = c.req.query('metric') === 'vehicles' ? 'vehicles' : 'locations'; + const records = await loadRecords(startDate, endDate, query, batchModel); + const precision = daysInclusive(startDate, endDate) <= 14 || Boolean(query || batchModel) ? 3 : 2; + const grid = new Map }>(); + + for (const record of records) { + const lng = Number(record.longitude.toFixed(precision)); + const lat = Number(record.latitude.toFixed(precision)); + const key = `${lng},${lat}`; + let point = grid.get(key); + if (!point) { + point = { lng, lat, count: 0, vins: metric === 'vehicles' ? new Set() : undefined }; + grid.set(key, point); + } + if (point.vins) { + point.vins.add(record.vin); + point.count = point.vins.size; + } else { + point.count += 1; + } + } + + const points = [...grid.values()].map(({ lng, lat, count }) => ({ lng, lat, count })); + let max = 1; + for (const point of points) max = Math.max(max, point.count); + + return c.json({ + startDate, + endDate, + metric, + locationCount: records.length, + vehicleCount: new Set(records.map((record) => record.vin)).size, + dayCount: new Set(records.map((record) => record.date)).size, + points, + max, + topVehicles: buildRanking(records), + }); +}); + +router.get('/nearby', async (c) => { + const longitude = Number(c.req.query('lng')); + const latitude = Number(c.req.query('lat')); + if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) { + return c.json({ error: '经纬度参数无效' }, 400); + } + + const startDate = isDate(c.req.query('startDate')) ? c.req.query('startDate')! : '2026-01-01'; + const endDate = isDate(c.req.query('endDate')) ? c.req.query('endDate')! : '2026-07-13'; + const query = c.req.query('query') || ''; + const batchModel = (c.req.query('batchModel') || '').trim(); + const radiusKm = Math.min(200, Math.max(5, Number(c.req.query('radiusKm')) || 50)); + const records = await loadRecords(startDate, endDate, query, batchModel); + const nearby = records.filter((record) => ( + haversineKm(longitude, latitude, record.longitude, record.latitude) <= radiusKm + )); + + return c.json({ + center: { lng: longitude, lat: latitude }, + radiusKm, + locationCount: nearby.length, + vehicleCount: new Set(nearby.map((record) => record.vin)).size, + topVehicles: buildRanking(nearby), + }); +}); + +export default router;