diff --git a/.gitignore b/.gitignore index ac50e01..f93cfd1 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,6 @@ dist-site/ .codefuse/ -.codefuseone/ \ No newline at end of file +.codefuseone/ +# pnpm 本地 store(不应入库) +.pnpm-store/ diff --git a/CHANGELOG.md b/CHANGELOG.md index afea791..33975ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,91 @@ # Changelog +## 0.4.4 (2026-07-15) + +### Bug Fixes + +- **MapScene:** 底图引擎改从 `@antv/l7` 统一导入,避免把 `@antv/l7-maps` 各引擎适配层(mapbox-gl / maplibre-gl / AMap Loader 等)及 GLSL 着色器打包进产物 +- **cli:** `aimapui --version` 改为从 `package.json` 动态读取,修复版本号长期显示 `0.1.0` 的问题 + +### Documentation + +- aimapui skill 版本说明同步至 0.4.4 + +## 0.4.3 (2026-07-14) + +### Bug Fixes + +- **SchemaLayer:** 修复 `sortValue` 递归遍历对象时遇到循环引用导致 `Maximum call stack size exceeded` 的问题,通过 `WeakSet` 检测已访问对象 + +### Features + +- **ChinaDistrict:** 下钻模式支持双击上卷(dblclick 触发 drillUp) +- **SchemaLayer/PolygonLayer:** 新增 `onDblclick`/`onUndblclick` 事件,与 click 互斥 + +## 0.4.2 (2026-07-13) + +### Documentation + +- 走查修复文档与 skill 同步:补充 H3Layer、RouteLayer、AnnotationControl、ResetViewControl、LogoControl、LegendControl、SatelliteLayerControl 站点文档 +- 删除 RouteLayer 已废弃属性文档(glow/animate/animateSpeed) +- controls.md skill reference 补齐所有控件 +- CHANGELOG.md 补录 0.3.2~0.4.1 变更记录 +- SKILL.md 补充 @antv/aimapui-plot 包说明 +- CLI 版本对齐至 0.4.2 + +## 0.4.1 (2026-07-13) + +### Features + +- **台风地图:** 新增台风路径地图 Demo,支持降雨/云图/雷达/风场/卫星图层切换(浙江水利气象 API) +- **台风预报:** 台风预报路径改用实线渲染,修复虚线像素级退化成点串问题;历史轨迹使用实线 +- **台风地图移动端:** 台风地图移动端适配 + BlockPage 设备切换 +- **map-app-builder skill:** 新增 map-app-builder skill,覆盖布局架构、图层层级、DOM z-index、主题系统等 +- **Canvas 风场图层:** 添加 Canvas 风场图层及 PC 端 UI 布局优化 +- **Apple/Google Maps 演示:** 优化 AppleMaps/GoogleMaps 演示与台风默认图层 + +### Breaking Changes + +- **RouteLayer:** 移除 `glow`、`animate`、`animateSpeed` 属性,发光与流动动画能力不再支持 + +### Bug Fixes + +- **RouteLayer:** 修复文字与图标/圆点重叠问题,优化默认参数(`lineWidth` 3→4,`stopSize` 14→8) +- **RouteLayer:** 修复 `rest` 未定义错误、NaN 坐标校验及优化标注布局 +- **GoogleMaps:** 修复 GoogleMapsMobileDemo 地图无法交互的问题 + +## 0.3.5 (2026-06-20) + +### Bug Fixes + +- **ImageCalibrationControl:** 清理冗余判断逻辑 +- 添加 `@types/geojson` 依赖 +- 更新依赖锁文件及 plot 包配置 + +## 0.3.4 (2026-06-19) + +### Features + +- **SatelliteLayerControl:** 新增卫星影像图层控件,支持提供商切换(高德/天地图/Google)、可见性开关、透明度调节 +- 补齐复合图层设计规范 + +### Bug Fixes + +- **ImageCalibrationControl:** 修复多图切换时图层消失的问题 + +## 0.3.3 (2026-06-19) + +### Refactoring + +- 地图引擎从动态 `import` 改为静态 `import`,避免 chunk 加载失败 + +## 0.3.2 (2026-06-19) + +### Bug Fixes + +- **ChinaDistrict:** 修复中国行政区划直辖市下钻时 adcode 前缀匹配不生效的问题 +- 标注 L7 版本要求 ≥ 2.28.14 + ## 0.3.1 (2026-06-18) ### Features diff --git a/packages/cli/package.json b/packages/cli/package.json index e1d419f..130f79b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@antv/aimapui-cli", - "version": "0.3.3", + "version": "0.4.4", "type": "module", "description": "CLI for adding @antv/aimapui components to your project (shadcn/ui style)", "bin": { diff --git a/packages/cli/registry.json b/packages/cli/registry.json index e5ec226..b13e0f1 100644 --- a/packages/cli/registry.json +++ b/packages/cli/registry.json @@ -3,8 +3,7 @@ "name": "@antv/aimapui", "baseUrl": "https://raw.githubusercontent.com/antvis/aimapui/main", "peerDependencies": { - "@antv/l7": "^2.28.0", - "@antv/l7-maps": "^2.28.0", + "@antv/l7": "^2.29.1", "react": ">=18.0.0", "react-dom": ">=18.0.0" }, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a1bf000..f62f252 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Command } from 'commander'; +import packageJson from '../package.json' with { type: 'json' }; import { init } from './commands/init.js'; import { add } from './commands/add.js'; import { list } from './commands/list.js'; @@ -10,7 +11,7 @@ const program = new Command(); program .name('aimapui') .description('CLI for adding @antv/aimapui components to your project') - .version('0.1.0'); + .version(packageJson.version); program .command('init') diff --git a/packages/core/package.json b/packages/core/package.json index 8da79b1..ed3c279 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@antv/aimapui", - "version": "0.3.3", + "version": "0.4.4", "type": "module", "description": "Schema/DSL-driven React map visualization component library powered by L7", "main": "dist/index.cjs", @@ -36,8 +36,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@antv/l7": "2.28.14", - "@antv/l7-maps": "2.28.14", + "@antv/l7": "2.29.1", "ahooks": "^3.8.4", "clsx": "^2.1.1", "geotiff": "^3.0.5", @@ -49,7 +48,9 @@ "react-dom": ">=18.0.0" }, "devDependencies": { + "@tailwindcss/postcss": "^4.3.2", "@tailwindcss/vite": "^4.0.0", + "@types/geojson": "^7946.0.16", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@types/supercluster": "^7.1.3", diff --git a/packages/core/postcss.config.mjs b/packages/core/postcss.config.mjs new file mode 100644 index 0000000..a34a3d5 --- /dev/null +++ b/packages/core/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; diff --git a/packages/core/src/components/CompositeLayer/ArcFlowLayer.tsx b/packages/core/src/components/CompositeLayer/ArcFlowLayer.tsx index cad45e3..bc92ca0 100644 --- a/packages/core/src/components/CompositeLayer/ArcFlowLayer.tsx +++ b/packages/core/src/components/CompositeLayer/ArcFlowLayer.tsx @@ -168,8 +168,8 @@ export function ArcFlowLayer({ nodeSize = 4, nodeSizeRange, nodePulse = false, - showTooltip = true, - showNodePopup = true, + showTooltip = false, + showNodePopup = false, activeColor = '#FFD93D', onArcHover, onArcClick, diff --git a/packages/core/src/components/CompositeLayer/BubbleLayer.tsx b/packages/core/src/components/CompositeLayer/BubbleLayer.tsx index 1ae8c41..be8330e 100644 --- a/packages/core/src/components/CompositeLayer/BubbleLayer.tsx +++ b/packages/core/src/components/CompositeLayer/BubbleLayer.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import type { ActiveConfig, LayerSchema, SelectConfig, LayerEventPayload } from '../../schema/types'; import { PointLayer } from '../Layer/PointLayer'; +import { getColorPalette, type ColorScheme } from '../../constants/colorPalettes'; export const BUBBLE_SIZE_LEVELS = [8, 16, 32, 48, 64] as const; @@ -36,6 +37,8 @@ export interface BubbleLayerProps extends Omit; + /** 连续数值尺寸范围 [min, max],与 sizeField 配合使用。提供时自动线性映射 */ + sizeRange?: [number, number]; /** 气泡锚点,决定标签相对气泡的连接参考点 */ bubbleAnchor?: BubbleAnchor; /** 文本锚点,映射到 textAnchor */ @@ -55,6 +58,9 @@ export interface BubbleLayerProps extends Omit void; @@ -88,14 +94,16 @@ export function BubbleLayer({ showLabel = true, labelOffset, sizeDomain, + sizeRange, bubbleAnchor = 'bottom', labelAnchor = 'top', - hoverEffect = true, - clickEffect = true, - tooltipEffect = true, + hoverEffect = false, + clickEffect = false, + tooltipEffect = false, tooltipFields, tooltipTemplate, semanticColorField, + colorScheme = 'categorical', color = '#2563eb', size = 16, sizeField, @@ -119,14 +127,16 @@ export function BubbleLayer({ BUBBLE_QUALITATIVE_COLORS.error, BUBBLE_QUALITATIVE_COLORS.success, ] - : undefined); + : mappedColorField + ? [...getColorPalette(colorScheme)] + : undefined); const mappedSizeValues = sizeField - ? (sizeValues ?? [...BUBBLE_SIZE_LEVELS]) + ? (sizeValues ?? (sizeRange ? undefined : [...BUBBLE_SIZE_LEVELS])) : sizeValues; - const defaultActive: ActiveConfig = { color: '#60a5fa' }; - const defaultSelect: SelectConfig = { color: '#1d4ed8' }; + const defaultActive: ActiveConfig = { color: '#60a5fa', duration: 150 }; + const defaultSelect: SelectConfig = { color: '#1d4ed8', duration: 150 }; const resolvedEvents = (() => { const origin = rest.events; @@ -150,7 +160,7 @@ export function BubbleLayer({ // 气泡圆图层的 size 配置:当有 sizeField 时不传固定 size 避免冲突 const bubbleSizeProps = sizeField - ? { sizeField, sizeValues: mappedSizeValues } + ? { sizeField, sizeValues: mappedSizeValues, ...(sizeRange ? { sizeRange } : {}) } : { size }; return ( @@ -195,7 +205,7 @@ export function BubbleLayer({ shapeValues="text" color={labelColor} size={labelSize} - zIndex={(rest.zIndex ?? 0) + 1} + zIndex={(rest.zIndex ?? 0) + 10} style={{ textAnchor: labelAnchor, textOffset: labelOffset ?? [0, 0], diff --git a/packages/core/src/components/CompositeLayer/ChinaDistrict.tsx b/packages/core/src/components/CompositeLayer/ChinaDistrict.tsx index d2fb478..0a63e0e 100644 --- a/packages/core/src/components/CompositeLayer/ChinaDistrict.tsx +++ b/packages/core/src/components/CompositeLayer/ChinaDistrict.tsx @@ -88,8 +88,10 @@ export interface ChinaDistrictProps { /** Tooltip 自定义字段 */ tooltipFields?: string[]; - /** 区域点击事件 */ + /** 区域单击事件 */ onRegionClick?: (feature: Record, level: AdministrativeLevel) => void; + /** 区域双击事件(上卷时触发) */ + onRegionDblclick?: (feature: Record, level: AdministrativeLevel) => void; /** 图层 zIndex */ zIndex?: number; } @@ -141,11 +143,12 @@ export const ChinaDistrict = React.forwardRef { const feature = payload.feature; if (!feature) return; @@ -316,6 +319,13 @@ export const ChinaDistrict = React.forwardRef { + if (drillEnabled) { + drillUp(); + } + }, [drillEnabled, drillUp]); + // 所有 hooks 必须在 conditional return 之前 if (!scene || !displayData) return null; @@ -351,10 +361,11 @@ export const ChinaDistrict = React.forwardRef diff --git a/packages/core/src/components/CompositeLayer/FillLayer.tsx b/packages/core/src/components/CompositeLayer/FillLayer.tsx index 03e669a..d88f5c4 100644 --- a/packages/core/src/components/CompositeLayer/FillLayer.tsx +++ b/packages/core/src/components/CompositeLayer/FillLayer.tsx @@ -4,14 +4,14 @@ import { useScene } from '../../context/SceneContext'; import { PolygonLayer } from '../Layer/PolygonLayer'; import { LineLayer } from '../Layer/LineLayer'; import { PointLayer } from '../Layer/PointLayer'; +import { getColorPalette, type ColorScheme } from '../../constants/colorPalettes'; -export type FillColorMapping = 'sequential' | 'diverging' | 'categorical'; +export type FillColorMapping = ColorScheme; -export const CHOROPLETH_SEQUENTIAL_COLORS = ['#dbeafe', '#93c5fd', '#60a5fa', '#3b82f6', '#2563eb'] as const; - -export const CHOROPLETH_DIVERGING_COLORS = ['#dc2626', '#fca5a5', '#e5e7eb', '#86efac', '#16a34a'] as const; - -export const CHOROPLETH_CATEGORICAL_COLORS = ['#2563eb', '#f59e0b', '#ef4444', '#10b981', '#8b5cf6', '#14b8a6', '#f97316', '#64748b'] as const; +// Re-export for backward compatibility +export { SEQUENTIAL_COLORS as CHOROPLETH_SEQUENTIAL_COLORS } from '../../constants/colorPalettes'; +export { DIVERGING_COLORS as CHOROPLETH_DIVERGING_COLORS } from '../../constants/colorPalettes'; +export { CATEGORICAL_COLORS as CHOROPLETH_CATEGORICAL_COLORS } from '../../constants/colorPalettes'; export interface FillLayerProps extends Omit { source: LayerSchema['source']; @@ -40,6 +40,8 @@ export interface FillLayerProps extends Omit void; onDrilldown?: (feature: Record) => void; + onMouseMove?: (payload: LayerEventPayload) => void; + onMouseLeave?: (payload: LayerEventPayload) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any onLayerCreated?: (layer: any) => void; @@ -66,20 +68,22 @@ export function FillLayer({ showStroke = true, strokeColor = 'rgba(255,255,255,0.30)', strokeWidth = 0.5, - hoverEffect = true, - clickEffect = true, - stickySelection = true, - tooltipEffect = true, + hoverEffect = false, + clickEffect = false, + stickySelection = false, + tooltipEffect = false, tooltipFields, tooltipTemplate, regionIdField = 'name', highlightStrokeColor = '#2563eb', highlightStrokeWidth = 2, - zoomToRegionOnClick = true, + zoomToRegionOnClick = false, clickZoomPadding = 40, clickZoomDelta = 1.2, onRegionClick, onDrilldown, + onMouseMove, + onMouseLeave, onLayerCreated, showLabel = false, labelField = 'name', @@ -136,15 +140,7 @@ export function FillLayer({ const mappedColorValues = useMemo(() => { if (colorValues) return colorValues; - switch (colorMapping) { - case 'diverging': - return [...CHOROPLETH_DIVERGING_COLORS]; - case 'categorical': - return [...CHOROPLETH_CATEGORICAL_COLORS]; - case 'sequential': - default: - return [...CHOROPLETH_SEQUENTIAL_COLORS]; - } + return [...getColorPalette(colorMapping)]; }, [colorValues, colorMapping]); const tooltipValueField = valueField ?? colorField ?? 'value'; @@ -154,8 +150,8 @@ export function FillLayer({ [tooltipTemplate, tooltipNameField, tooltipValueField, percentageField], ); - const defaultActive: ActiveConfig = { color: '#ffffff' }; - const defaultSelect: SelectConfig = { color: '#0f172a' }; + const defaultActive: ActiveConfig = { color: '', duration: 150 }; + const defaultSelect: SelectConfig = { color: '#0f172a', duration: 150 }; const labelSource = useMemo( () => buildLabelSourceFromGeoJSON(source, sourceType, labelField, regionIdField, labelAreaThreshold), @@ -237,34 +233,27 @@ export function FillLayer({ events={resolvedEvents} onClick={handleClick} onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} + onMouseLeave={(payload) => { + handleMouseLeave(); + onMouseLeave?.(payload); + }} + onMouseMove={onMouseMove} onLayerCreated={onLayerCreated} style={{ opacity: 0.8, ...(style ?? {}) }} /> - {showStroke && } + {showStroke && } {hoverEffect && hoveredRegionId !== null && ( - <> - - - + )} {clickEffect && selectedRegionId !== null && ( @@ -275,7 +264,7 @@ export function FillLayer({ size={Math.max(1.5, highlightStrokeWidth)} filterField={regionIdField} filterValues={[selectedRegionId]} - zIndex={5} + zIndex={(rest.zIndex ?? 0) + 5} /> )} @@ -288,6 +277,7 @@ export function FillLayer({ shapeValues="text" color={labelColor} size={labelSize} + zIndex={(rest.zIndex ?? 0) + 10} style={{ textAllowOverlap: false, stroke: '#ffffff', diff --git a/packages/core/src/components/CompositeLayer/H3Layer.tsx b/packages/core/src/components/CompositeLayer/H3Layer.tsx index 4b12644..40f6cb7 100644 --- a/packages/core/src/components/CompositeLayer/H3Layer.tsx +++ b/packages/core/src/components/CompositeLayer/H3Layer.tsx @@ -4,8 +4,10 @@ import type { LayerSchema, LayerEventPayload } from '../../schema/types'; import { PolygonLayer } from '../Layer/PolygonLayer'; import { LineLayer } from '../Layer/LineLayer'; import { PointLayer } from '../Layer/PointLayer'; +import { getColorPalette, type ColorScheme } from '../../constants/colorPalettes'; -export const H3_SEQUENTIAL_COLORS = ['#dbeafe', '#93c5fd', '#60a5fa', '#3b82f6', '#2563eb'] as const; +// Re-export for backward compatibility +export { SEQUENTIAL_COLORS as H3_SEQUENTIAL_COLORS } from '../../constants/colorPalettes'; export interface H3DataItem { [key: string]: unknown; @@ -16,6 +18,9 @@ export interface H3LayerProps source: H3DataItem[]; h3Field?: string; + /** 色板预设,默认 'sequential' */ + colorScheme?: ColorScheme; + showStroke?: boolean; strokeColor?: string; strokeWidth?: number; @@ -40,11 +45,12 @@ export function H3Layer({ color, colorField, colorValues, + colorScheme = 'sequential', opacity = 0.8, showStroke = true, strokeColor = 'rgba(255,255,255,0.3)', strokeWidth = 0.5, - hoverEffect = true, + hoverEffect = false, clickEffect = false, showLabel = false, labelField, @@ -116,8 +122,8 @@ export function H3Layer({ return { type: 'FeatureCollection' as const, features }; }, [source, h3Field, showLabel]); - const resolvedActive = active ?? (hoverEffect ? { color: '#2563eb' } : false); - const resolvedSelect = select ?? (clickEffect ? { color: '#1d4ed8' } : false); + const resolvedActive = active ?? (hoverEffect ? { color: '#2563eb', duration: 150 } : false); + const resolvedSelect = select ?? (clickEffect ? { color: '#1d4ed8', duration: 150 } : false); return ( <> @@ -127,7 +133,7 @@ export function H3Layer({ sourceType="geojson" color={color} colorField={colorField} - colorValues={colorValues} + colorValues={colorValues ?? [...getColorPalette(colorScheme)]} opacity={opacity} active={resolvedActive} select={resolvedSelect} @@ -143,6 +149,7 @@ export function H3Layer({ sourceType="geojson" color={strokeColor} size={strokeWidth} + zIndex={(rest.zIndex ?? 0) + 2} style={{ opacity: 1 }} /> )} @@ -154,6 +161,7 @@ export function H3Layer({ shapeValues="text" color={labelColor} size={labelSize} + zIndex={(rest.zIndex ?? 0) + 10} style={{ textAnchor: 'center', textOffset: [0, 0], diff --git a/packages/core/src/components/CompositeLayer/HexagonLayer.tsx b/packages/core/src/components/CompositeLayer/HexagonLayer.tsx index 6a68483..b4900b9 100644 --- a/packages/core/src/components/CompositeLayer/HexagonLayer.tsx +++ b/packages/core/src/components/CompositeLayer/HexagonLayer.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type { LayerSchema, LayerEventPayload } from '../../schema/types'; import { HeatmapLayer } from '../Layer/HeatmapLayer'; +import { getColorPalette, type ColorScheme } from '../../constants/colorPalettes'; /** * 蜂窝热力图渲染模式 @@ -36,6 +37,9 @@ export interface HexagonLayerProps /** hover 高亮色,默认 '#fbbf24' (secondary) */ activeColor?: string; + /** 色板预设,默认 'sequential'。用于生成 heatmap rampColors */ + colorScheme?: ColorScheme; + onClick?: (payload: LayerEventPayload) => void; onMouseMove?: (payload: LayerEventPayload) => void; onMouseEnter?: (payload: LayerEventPayload) => void; @@ -62,8 +66,9 @@ export function HexagonLayer({ showStroke = false, strokeColor = 'rgba(255,255,255,0.3)', strokeWidth = 0.5, - hoverEffect = true, + hoverEffect = false, activeColor = '#fbbf24', + colorScheme = 'sequential', onClick, onMouseMove, onMouseEnter, @@ -84,9 +89,16 @@ export function HexagonLayer({ const shape = mode === '3d' ? 'hexagonColumn' : 'hexagon'; // 构建样式配置 + const palette = getColorPalette(colorScheme); + const rampColors: Record = {}; + palette.forEach((c, i) => { + rampColors[i / (palette.length - 1)] = c; + }); + const resolvedStyle: Record = { coverage: 0.8, angle: 0, + rampColors, ...(style ?? {}), }; @@ -104,7 +116,7 @@ export function HexagonLayer({ sourceConfig={{ ...sourceConfig, transforms }} shape={shape} style={resolvedStyle} - active={hoverEffect ? { color: activeColor } : undefined} + active={hoverEffect ? { color: activeColor, duration: 150 } : undefined} onClick={onClick} onMouseMove={onMouseMove} onMouseEnter={onMouseEnter} diff --git a/packages/core/src/components/CompositeLayer/IconLayer.tsx b/packages/core/src/components/CompositeLayer/IconLayer.tsx index 3cffc70..0691e49 100644 --- a/packages/core/src/components/CompositeLayer/IconLayer.tsx +++ b/packages/core/src/components/CompositeLayer/IconLayer.tsx @@ -60,6 +60,13 @@ export interface IconLayerProps extends Omit void; onMouseEnter?: (payload: LayerEventPayload) => void; onMouseLeave?: (payload: LayerEventPayload) => void; + + /** + * 定位精度模式 + * - precise: 精确地址,使用尖头 pin 图标(默认) + * - general: 模糊区域,使用圆形标记,避免误导用户以为精确定位 + */ + accuracy?: 'precise' | 'general'; } /** @@ -98,6 +105,7 @@ export function IconLayer({ onClick, onMouseEnter, onMouseLeave, + accuracy = 'precise', ...rest }: IconLayerProps) { const scene = useScene(); @@ -107,6 +115,9 @@ export function IconLayer({ const iconKeys = useMemo(() => Object.keys(iconMap), [iconMap]); const resolvedIconSize = size ?? iconSize; const resolvedLabelField = labelField ?? iconField; + // general 模式下使用居中锚点 + 圆形标记 + const isGeneralMode = accuracy === 'general'; + const effectiveIconAnchor = isGeneralMode ? 'center' : iconAnchor; // 注册图片资源 useEffect(() => { @@ -158,21 +169,26 @@ export function IconLayer({ source={source} sourceType={sourceType} sourceConfig={sourceConfig} - shapeField={iconField} - shapeValues={iconKeys} + {...(isGeneralMode + ? { shape: 'circle', color: rest.color ?? '#3b82f6' } + : { shapeField: iconField, shapeValues: iconKeys })} size={resolvedIconSize} style={{ ...((rest.style as Record) ?? {}), - anchor: iconAnchor, + anchor: effectiveIconAnchor, allowOverlap: iconAllowOverlap, - // 图标白色描边 (Halo) 增强复杂底图辨识度 - stroke: '#ffffff', - strokeWidth: 1, - // 轻微阴影提供物理悬浮感 - shadowColor: 'rgba(0,0,0,0.2)', - shadowBlur: 4, - shadowOffsetX: 0, - shadowOffsetY: 2, + ...(isGeneralMode + ? { opacity: 0.7, stroke: '#ffffff', strokeWidth: 1.5 } + : { + // 图标白色描边 (Halo) 增强复杂底图辨识度 + stroke: '#ffffff', + strokeWidth: 1, + // 轻微阴影提供物理悬浮感 + shadowColor: 'rgba(0,0,0,0.2)', + shadowBlur: 4, + shadowOffsetX: 0, + shadowOffsetY: 2, + }), }} onClick={onClick} onMouseEnter={onMouseEnter} diff --git a/packages/core/src/components/CompositeLayer/RouteLayer.tsx b/packages/core/src/components/CompositeLayer/RouteLayer.tsx index fe8e77f..c756b88 100644 --- a/packages/core/src/components/CompositeLayer/RouteLayer.tsx +++ b/packages/core/src/components/CompositeLayer/RouteLayer.tsx @@ -87,12 +87,6 @@ export interface RouteLayerProps { lineWidth?: number; /** 路径透明度,默认 0.9 */ opacity?: number; - /** 是否显示发光效果,默认 true */ - glow?: boolean; - /** 是否启用流动动画,默认 false */ - animate?: boolean; - /** 动画速度,默认 1 */ - animateSpeed?: number; // ===== 途经点视觉 ===== /** 途经点大小,默认 14 */ @@ -109,7 +103,7 @@ export interface RouteLayerProps { stopNameColor?: string; /** 名称文字大小,默认 11 */ stopNameSize?: number; - /** 停留点渲染模式,默认 'point' */ + /** 停留点渲染模式,默认 'marker'(优先使用 marker 类型标注) */ stopRenderer?: 'point' | 'marker' | 'icon'; /** marker 模式下的默认变体,默认 'circle' */ stopMarkerVariant?: MarkerVariant; @@ -123,7 +117,7 @@ export interface RouteLayerProps { stopIconAnchor?: IconAnchor; // ===== 交互 ===== - /** 是否在点击途经点时显示 Popup,默认 true */ + /** 是否在点击途经点时显示 Popup,默认 false */ showStopPopup?: boolean; /** hover 高亮色 */ activeColor?: string; @@ -131,6 +125,8 @@ export interface RouteLayerProps { onPathClick?: (payload: LayerEventPayload) => void; /** 途经点点击 */ onStopClick?: (payload: LayerEventPayload) => void; + /** 图层层级 */ + zIndex?: number; } /** @@ -138,9 +134,7 @@ export interface RouteLayerProps { * * 遵循设计规范: * - 序列化途经点 (Numbered Stops) - * - 路径发光效果 * - 分段着色(路况/属性) - * - 流动动画 * * @example * ```tsx @@ -150,8 +144,6 @@ export interface RouteLayerProps { * { lng: 120.15, lat: 30.28, name: '西湖' }, * { lng: 120.17, lat: 30.25, name: '灵隐寺' }, * ]} - * animate - * glow * /> * ``` */ @@ -163,28 +155,27 @@ export function RouteLayer({ onRouteQuery, onRouteResult, color = '#2563eb', - lineWidth = 4, + lineWidth = 3, opacity = 0.9, - glow = true, - animate = false, - animateSpeed = 1, - stopSize = 14, + stopSize = 8, stopColor, endColor = '#10b981', showStopIndex = true, showStopName = true, stopNameColor = '#334155', stopNameSize = 11, - stopRenderer = 'point', + stopRenderer = 'marker', stopMarkerVariant = 'circle', stopIconMap, stopIconField = 'iconValue', stopIconSize = 16, stopIconAnchor = 'bottom', - showStopPopup = true, + showStopPopup = false, activeColor = '#fbbf24', onPathClick, onStopClick, + zIndex, + ...rest }: RouteLayerProps) { // 交通路线查询结果 const [routeQueryResult, setRouteQueryResult] = useState(null); @@ -249,7 +240,7 @@ export function RouteLayer({ return null; }, [effectivePath, effectiveSegments, color, lineWidth]); - // 发光层 GeoJSON(同路径但宽度更大) + // 是否存在分段颜色(用于主路径分段色/宽渲染) const hasSegmentColors = effectiveSegments && effectiveSegments.some((s) => s.color); // 途经点数据(增加序号和类型) @@ -262,8 +253,45 @@ export function RouteLayer({ let merged = [...stops, ...extraStops]; if (effectivePathForStops && effectivePathForStops.length >= 2) { - const [startLng, startLat] = effectivePathForStops[0]; - const [endLng, endLat] = effectivePathForStops[effectivePathForStops.length - 1]; + const startCoord = effectivePathForStops[0]; + const endCoord = effectivePathForStops[effectivePathForStops.length - 1]; + + // 防御性检查:确保坐标有效 + if (!startCoord || !endCoord || startCoord.length < 2 || endCoord.length < 2) { + return merged.map((stop, idx) => ({ + ...stop, + index: stop.index ?? idx + 1, + type: stop.type ?? (idx === 0 ? 'start' : idx === merged.length - 1 ? 'end' : 'waypoint'), + stopColor: (stop.type === 'end' || (!stop.type && idx === merged.length - 1)) + ? endColor + : (stopColor ?? color), + iconValue: stop.icon ?? 'marker', + indexLabel: String(stop.index ?? idx + 1), + markerColorValue: stop.markerColor ?? resolveMarkerColor(stop.type ?? (idx === 0 ? 'start' : idx === merged.length - 1 ? 'end' : 'waypoint')), + })); + } + + const [startLng, startLat] = startCoord; + const [endLng, endLat] = endCoord; + + // 检查坐标值是否为有效数字 + if (typeof startLng !== 'number' || typeof startLat !== 'number' || + typeof endLng !== 'number' || typeof endLat !== 'number' || + !isFinite(startLng) || !isFinite(startLat) || + !isFinite(endLng) || !isFinite(endLat)) { + return merged.map((stop, idx) => ({ + ...stop, + index: stop.index ?? idx + 1, + type: stop.type ?? (idx === 0 ? 'start' : idx === merged.length - 1 ? 'end' : 'waypoint'), + stopColor: (stop.type === 'end' || (!stop.type && idx === merged.length - 1)) + ? endColor + : (stopColor ?? color), + iconValue: stop.icon ?? 'marker', + indexLabel: String(stop.index ?? idx + 1), + markerColorValue: stop.markerColor ?? resolveMarkerColor(stop.type ?? (idx === 0 ? 'start' : idx === merged.length - 1 ? 'end' : 'waypoint')), + })); + } + const hasStart = stops.some((s) => Math.abs(s.lng - startLng) < 1e-6 && Math.abs(s.lat - startLat) < 1e-6); const hasEnd = stops.some((s) => Math.abs(s.lng - endLng) < 1e-6 && Math.abs(s.lat - endLat) < 1e-6); @@ -324,20 +352,6 @@ export function RouteLayer({ return ( <> - {/* 发光层(底层,更宽更透明) */} - {glow && ( - s.color || color) : undefined} - size={lineWidth * 2.5} - style={{ opacity: 0.15 }} - /> - )} - {/* 主路径层 */} - {/* 流动粒子层 — 白色半透明虚线沿路径流动,指示行驶方向 */} - {animate && ( - - )} - {/* 途经点层 */} {stopsWithIndex.length > 0 && stopRenderer === 'point' && ( = { + circle: { size: 18, fontSize: 12 }, + dot: { size: 18, fontSize: 10 }, + pin: { size: 24, fontSize: 11 }, + icon: { size: 24, fontSize: 11 }, +}; + function createMarkerStopContent( indexLabel: string, fill: string, @@ -543,25 +550,23 @@ function createMarkerStopContent( if (!showStopIndex) return undefined; if (variant !== 'circle' && variant !== 'dot') return undefined; - const size = variant === 'circle' ? 24 : 18; - const fontSize = variant === 'circle' ? 11 : 10; + const config = MARKER_SIZE_CONFIG[variant]; return (
{indexLabel} diff --git a/packages/core/src/components/Control/ImageCalibrationControl/ImageCalibrationControl.tsx b/packages/core/src/components/Control/ImageCalibrationControl/ImageCalibrationControl.tsx index 2b4ee05..96c102a 100644 --- a/packages/core/src/components/Control/ImageCalibrationControl/ImageCalibrationControl.tsx +++ b/packages/core/src/components/Control/ImageCalibrationControl/ImageCalibrationControl.tsx @@ -184,6 +184,8 @@ export const ImageCalibrationControl = forwardRef(defaultValue ?? ''); + const [currentValue, setCurrentValue] = useState(defaultValue ?? 'dark'); const [options, setOptions] = useState(propOptions ?? []); const containerRef = useRef(null); + // 根据当前地图样式值判断是否为暗色主题 + const isDarkTheme = useMemo(() => { + const darkKeywords = ['dark', 'darkblue', 'night', 'black']; + return darkKeywords.some((keyword) => currentValue.toLowerCase().includes(keyword)); + }, [currentValue]); + const { popperRef, popperClass } = usePopperPosition(position, open, containerRef); // 自动获取地图样式选项 @@ -164,8 +170,15 @@ export function MapThemeControl({ palette {open && options.length > 0 && ( -
-
+
+
{options.map((opt) => { const isActive = currentValue === opt.value; @@ -176,12 +189,19 @@ export function MapThemeControl({ className={`l7-theme-chip${isActive ? ' l7-theme-chip--active' : ''}`} onClick={() => handleSelect(opt.value)} title={opt.text} + style={isDarkTheme ? { + background: isActive ? 'rgba(34, 211, 238, 0.15)' : '#1e2c3e', + borderColor: isActive ? '#22d3ee' : 'rgba(180, 197, 255, 0.1)', + } : undefined} > - {opt.text} + {opt.text} ); })} diff --git a/packages/core/src/components/Control/ResetViewControl.tsx b/packages/core/src/components/Control/ResetViewControl.tsx new file mode 100644 index 0000000..8e93401 --- /dev/null +++ b/packages/core/src/components/Control/ResetViewControl.tsx @@ -0,0 +1,81 @@ +import React, { useCallback } from 'react'; +import { useMapControl, type ControlPosition } from '../../hooks/useMapControl'; +import { useControlContainer, ControlRegistry } from './ControlContainer'; + +/** + * ResetView 控件 — 一键重置地图到初始视图 + * + * 遵循 L7 Control 规范 + Material Design 3 玻璃态风格。 + * 点击后将地图中心、缩放级别、旋转角、俯仰角恢复到初始状态。 + */ +export interface ResetViewControlProps { + /** 控件位置,默认 bottomright */ + position?: ControlPosition; + /** 按钮图标,默认 Material Symbols "center_focus_strong" */ + icon?: React.ReactNode; + /** 按钮标题/tooltip */ + title?: string; + /** 自定义初始视图参数。不传时使用 scene 创建时的初始值 */ + initialView?: { + center?: [number, number]; + zoom?: number; + pitch?: number; + rotation?: number; + }; + className?: string; + style?: React.CSSProperties; +} + +export function ResetViewControl({ + position = 'bottomright', + icon, + title = 'Reset view', + initialView, + className, + style, +}: ResetViewControlProps) { + const { scene, mapsService, positionClassName } = useMapControl(position); + const isInContainer = useControlContainer(); + + const handleReset = useCallback(() => { + if (!scene || !mapsService) return; + + const center = initialView?.center ?? [104.0, 35.0]; + const zoom = initialView?.zoom ?? 4; + const pitch = initialView?.pitch ?? 0; + const rotation = initialView?.rotation ?? 0; + + scene.setCenter(center); + scene.setZoom(zoom); + scene.setPitch(pitch); + scene.setRotation(rotation); + }, [scene, mapsService, initialView]); + + const controlContent = ( +
+ +
+ ); + + if (isInContainer) return controlContent; + + return ( +
+ {controlContent} +
+ ); +} + +ControlRegistry.mark(ResetViewControl); + +export default ResetViewControl; diff --git a/packages/core/src/components/Interaction/Popup.tsx b/packages/core/src/components/Interaction/Popup.tsx index 1574f2c..4b4a0b1 100644 --- a/packages/core/src/components/Interaction/Popup.tsx +++ b/packages/core/src/components/Interaction/Popup.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useScene } from '../../context/SceneContext'; import { useMapPosition } from '../../hooks/useMapPosition'; +import { useTheme } from '../../context/ThemeContext'; import { cx } from '../../utils/style'; import type { PopupSchema } from '../../schema/types'; @@ -78,6 +79,14 @@ export interface PopupProps extends Omit { placement?: PopupPlacement; /** 弹出框偏移量(像素),默认 8px,正数远离锚点 */ offset?: number; + /** + * 布局预设 — 快速选择信息排布方式,自动映射到 size + 内部样式 + * - simple: 纯文本/HTML,compact 尺寸 + * - card: 标题 + 属性列表,standard 尺寸 + * - rich: 封面图 + 标题 + 属性 + 操作按钮,detailed 尺寸 + * 传入后仍可被 size/header/attributes/actions 单独覆盖 + */ + layout?: 'simple' | 'card' | 'rich'; /** 结构化标题栏(可选,传入后覆盖简单内容模式) */ header?: PopupHeader; /** 属性列表(可选,"标签-值"对齐模式) */ @@ -228,9 +237,9 @@ function PopupTip({ placement }: { placement: 'top' | 'bottom' | 'left' | 'right const viewBox = isVertical ? '0 0 16 8' : '0 0 8 16'; const path = ARROW_PATHS[placement]; - // 颜色与容器保持一致 - const fillColor = 'rgba(248, 249, 255, 0.95)'; - const strokeColor = 'rgba(195, 198, 215, 0.3)'; + // 颜色与容器保持一致(使用 CSS 变量支持主题切换) + const fillColor = 'var(--color-surface)'; + const strokeColor = 'var(--color-outline-variant)'; return (
@@ -385,9 +394,10 @@ export function Popup({ latitude, content, closeButton = true, - size = 'standard', + size: sizeProp, placement: placementProp = 'auto', offset = 8, + layout, header, attributes, actions, @@ -397,7 +407,11 @@ export function Popup({ onClose, className, }: PopupProps) { + // layout 预设自动映射 size + const resolvedSize = sizeProp ?? (layout === 'simple' ? 'compact' : layout === 'rich' ? 'detailed' : 'standard'); + const size = resolvedSize; const scene = useScene(); + const { resolvedTheme } = useTheme(); // 如果传入了 visibleProp,则为受控模式;否则内部管理 const isControlled = visibleProp !== undefined; const [internalVisible, setInternalVisible] = useState(true); @@ -590,34 +604,17 @@ export function Popup({ return () => document.removeEventListener('keydown', handleKeyDown); }, [visible, handleClose]); - // ── 点击地图空白处关闭 ── + // ── 点击地图空白处关闭(L7 unclick) ── useEffect(() => { - if (!visible || !container) return; - const handleMapClick = (e: MouseEvent) => { - const el = popupRef.current; - if (!el) return; - // 如果点击目标是 Popup 元素内部,不关闭 - if (el.contains(e.target as Node)) return; - // 如果点击目标是 Marker 相关元素,不关闭(由 Marker 的 onClick 处理) - const target = e.target as HTMLElement; - if (target.closest('.aimapui-marker') || target.closest('[data-marker]')) return; - // 点击地图空白区域,关闭 Popup + if (!visible || !scene) return; + const handleUnclick = () => { handleClose(); }; - - // 获取地图容器并监听点击事件 - let mapContainer: HTMLElement | null = null; - try { - const mapsService = (scene as any)?.mapService; - if (mapsService) { - mapContainer = mapsService.getContainer?.() as HTMLElement; - } - } catch { /* 降级 */ } - - const targetEl = mapContainer || container.parentElement || container; - targetEl.addEventListener('click', handleMapClick, true); - return () => targetEl.removeEventListener('click', handleMapClick, true); - }, [visible, container, scene, handleClose]); + scene.on('unclick', handleUnclick); + return () => { + scene.off('unclick', handleUnclick); + }; + }, [visible, scene, handleClose]); // 高性能位置更新 — 地图交互时持续同步(复用 applyPositionRef,避免逻辑漂移) useMapPosition(scene, longitude, latitude, (x, y) => { @@ -710,6 +707,7 @@ export function Popup({
diff --git a/packages/core/src/components/Layer/PolygonLayer.tsx b/packages/core/src/components/Layer/PolygonLayer.tsx index 6aa24b6..db3d2b2 100644 --- a/packages/core/src/components/Layer/PolygonLayer.tsx +++ b/packages/core/src/components/Layer/PolygonLayer.tsx @@ -10,6 +10,8 @@ export interface PolygonLayerProps sourceConfig?: LayerSchema['sourceConfig']; onClick?: (payload: LayerEventPayload) => void; + onDblclick?: (payload: LayerEventPayload) => void; + onUndblclick?: (payload: LayerEventPayload) => void; onMouseMove?: (payload: LayerEventPayload) => void; onMouseEnter?: (payload: LayerEventPayload) => void; onMouseLeave?: (payload: LayerEventPayload) => void; @@ -25,6 +27,8 @@ export function PolygonLayer({ sourceType, sourceConfig, onClick, + onDblclick, + onUndblclick, onMouseMove, onMouseEnter, onMouseLeave, @@ -43,8 +47,8 @@ export function PolygonLayer({ }; const eventHandlers: LayerEventHandlers | undefined = - onClick || onMouseMove || onMouseEnter || onMouseLeave - ? { onClick, onMouseMove, onMouseEnter, onMouseLeave } + onClick || onDblclick || onUndblclick || onMouseMove || onMouseEnter || onMouseLeave + ? { onClick, onDblclick, onUndblclick, onMouseMove, onMouseEnter, onMouseLeave } : undefined; return ; diff --git a/packages/core/src/components/Layer/SchemaLayer.tsx b/packages/core/src/components/Layer/SchemaLayer.tsx index 56ffb6a..28ca9f5 100644 --- a/packages/core/src/components/Layer/SchemaLayer.tsx +++ b/packages/core/src/components/Layer/SchemaLayer.tsx @@ -20,7 +20,7 @@ interface LayerAdapter { sourceConfig: { data: unknown; options?: Record }; visual: { color?: { field?: string; values?: string[] | string }; - size?: { field?: string; values?: number[] | number }; + size?: { field?: string; values?: number[] | number; range?: [number, number] }; shape?: { field?: string; values?: string[] | string }; style?: Record; }; @@ -30,6 +30,8 @@ interface LayerAdapter { /** 图层事件回调集合 */ export interface LayerEventHandlers { onClick?: (payload: LayerEventPayload) => void; + onDblclick?: (payload: LayerEventPayload) => void; + onUndblclick?: (payload: LayerEventPayload) => void; onMouseMove?: (payload: LayerEventPayload) => void; onMouseEnter?: (payload: LayerEventPayload) => void; onMouseLeave?: (payload: LayerEventPayload) => void; @@ -101,7 +103,9 @@ function buildLayer(adapter: LayerAdapter, _scene: Scene): L7Layer { } if (sizeConfig) { - if (sizeConfig.field && sizeConfig.values) { + if (sizeConfig.field && sizeConfig.range) { + layer.size(sizeConfig.field, sizeConfig.range); + } else if (sizeConfig.field && sizeConfig.values) { layer.size(sizeConfig.field, sizeConfig.values); } else if (sizeConfig.values !== undefined) { layer.size(sizeConfig.values); @@ -295,41 +299,49 @@ export function SchemaLayer({ schema, scene, eventHandlers, onLayerCreated }: Sc }; const needsClick = Boolean(eventHandlersRef.current?.onClick || layerEvents?.click || (popupEnabled && popupTrigger === 'click')); + const needsDblclick = Boolean(eventHandlersRef.current?.onDblclick); + const needsUndblclick = Boolean(eventHandlersRef.current?.onUndblclick); const needsMouseMove = Boolean(eventHandlersRef.current?.onMouseMove || layerEvents?.mousemove || (popupEnabled && popupTrigger === 'hover')); const needsMouseEnter = Boolean(eventHandlersRef.current?.onMouseEnter || layerEvents?.mouseenter); const needsMouseLeave = Boolean(eventHandlersRef.current?.onMouseLeave || layerEvents?.mouseleave || (popupEnabled && popupTrigger === 'hover')); - // L7 click 事件 + // L7 click 事件(普通单击,与 dblclick/undblclick 互斥) if (needsClick) { layer.on('click', (evt: unknown) => { const payload = extractLayerPayload(layerId, schema.type, evt); - - // 1. 直接回调 eventHandlersRef.current?.onClick?.(payload); - - // 2. EventBus 广播(Schema 事件标识符) if (layerEvents?.click) { eventBus.emit(layerEvents.click, payload); } - - // 3. 内置 Popup(click) - if (popupTrigger === 'click') { + if (popupEnabled && popupTrigger === 'click') { showPopup(payload); } }); } + // L7 dblclick 事件(双击) + if (needsDblclick) { + layer.on('dblclick', (evt: unknown) => { + const payload = extractLayerPayload(layerId, schema.type, evt); + eventHandlersRef.current?.onDblclick?.(payload); + }); + } + + // L7 undblclick 事件(双击取消后的单击确认,与 click/dblclick 互斥) + if (needsUndblclick) { + layer.on('undblclick', (evt: unknown) => { + const payload = extractLayerPayload(layerId, schema.type, evt); + eventHandlersRef.current?.onUndblclick?.(payload); + }); + } + // L7 mousemove 事件 if (needsMouseMove) { layer.on('mousemove', (evt: unknown) => { const payload = extractLayerPayload(layerId, schema.type, evt); eventHandlersRef.current?.onMouseMove?.(payload); - if (popupTrigger === 'hover') { - showPopup(payload); - } - if (layerEvents?.mousemove) { - eventBus.emit(layerEvents.mousemove, payload); - } + if (popupTrigger === 'hover') showPopup(payload); + if (layerEvents?.mousemove) eventBus.emit(layerEvents.mousemove, payload); }); } @@ -349,15 +361,21 @@ export function SchemaLayer({ schema, scene, eventHandlers, onLayerCreated }: Sc layer.on('mouseleave', (evt: unknown) => { const payload = extractLayerPayload(layerId, schema.type, evt); eventHandlersRef.current?.onMouseLeave?.(payload); - if (popupTrigger === 'hover') { - hidePopup(); - } - if (layerEvents?.mouseleave) { - eventBus.emit(layerEvents.mouseleave, payload); - } + if (popupTrigger === 'hover') hidePopup(); + if (layerEvents?.mouseleave) eventBus.emit(layerEvents.mouseleave, payload); }); } + // 兜底:L7 unmousemove 事件(鼠标移出图层要素时触发) + // 当 mouseleave 未触发时,unmousemove 可作为可靠的离开信号 + if (needsMouseLeave && needsMouseMove) { + layer.on('unmousemove', (evt: unknown) => { + const payload = extractLayerPayload(layerId, schema.type, evt); + eventHandlersRef.current?.onMouseLeave?.(payload); + if (popupTrigger === 'hover') hidePopup(); + if (layerEvents?.mouseleave) eventBus.emit(layerEvents.mouseleave, payload); + }); + } // 添加到场景 scene.addLayer(layer); onLayerCreated?.(layer); @@ -382,15 +400,9 @@ export function SchemaLayer({ schema, scene, eventHandlers, onLayerCreated }: Sc return () => { destroyed = true; if (layerRef.current) { - try { - scene.removeLayer(layerRef.current); - } catch { - // layer 可能已被销毁 - } + try { scene.removeLayer(layerRef.current); } catch { /* ignore */ } layerRef.current = null; } - - // 组件卸载时清理 Popup 状态(确保 popup 消失) setPopupState((prev) => ({ ...prev, visible: false })); }; }, [schemaSignature, scene, eventBus]); @@ -438,10 +450,10 @@ export function SchemaLayer({ schema, scene, eventHandlers, onLayerCreated }: Sc } function stableStringify(value: unknown): string { - return JSON.stringify(sortValue(value)); + return JSON.stringify(sortValue(value, new WeakSet())); } -function sortValue(value: unknown): unknown { +function sortValue(value: unknown, seen: WeakSet): unknown { // TypedArray / ArrayBuffer 不递归,用长度做签名 if (ArrayBuffer.isView(value)) { return `[TypedArray:${(value as unknown as { length: number }).length}]`; @@ -455,15 +467,21 @@ function sortValue(value: unknown): unknown { if (value.length > 1000) { return `[Array:${value.length}]`; } - return value.map(sortValue); + return value.map((item) => sortValue(item, seen)); } if (value && typeof value === 'object') { + // 循环引用检测:避免无限递归导致栈溢出 + if (seen.has(value as object)) { + return '[Circular]'; + } + seen.add(value as object); + const obj = value as Record; const sortedKeys = Object.keys(obj).sort(); const next: Record = {}; sortedKeys.forEach((key) => { - next[key] = sortValue(obj[key]); + next[key] = sortValue(obj[key], seen); }); return next; } diff --git a/packages/core/src/components/Layer/adapters/common.ts b/packages/core/src/components/Layer/adapters/common.ts index 8fc75eb..f428746 100644 --- a/packages/core/src/components/Layer/adapters/common.ts +++ b/packages/core/src/components/Layer/adapters/common.ts @@ -13,6 +13,8 @@ export interface ColorConfig { export interface SizeConfig { field?: string; values?: number[] | number; + /** 连续数值尺寸范围 [min, max],与 field 配合使用 */ + range?: [number, number]; } export interface ShapeConfig { @@ -50,7 +52,7 @@ export function buildColorConfig(schema: LayerSchema): ColorConfig | undefined { */ export function buildSizeConfig(schema: LayerSchema): SizeConfig | undefined { if (schema.sizeField) { - return { field: schema.sizeField, values: schema.sizeValues }; + return { field: schema.sizeField, values: schema.sizeValues, range: schema.sizeRange }; } if (schema.size !== undefined) { return { values: schema.size }; @@ -119,7 +121,16 @@ export function buildCsvSourceConfig(schema: LayerSchema): SourceOutput { * 构建 GeoJSON 类型数据源配置 */ export function buildGeojsonSourceConfig(schema: LayerSchema): SourceOutput { - return { data: schema.source, options: undefined }; + return { + data: schema.source, + options: { + parser: { + type: 'geojson', + // 保留所有原始 properties,确保事件回调中能获取完整 feature 数据 + ...(schema.sourceConfig?.parser ?? {}), + }, + }, + }; } /** diff --git a/packages/core/src/components/MapScene/MapSceneRenderer.tsx b/packages/core/src/components/MapScene/MapSceneRenderer.tsx index 65bd520..0af8b6b 100644 --- a/packages/core/src/components/MapScene/MapSceneRenderer.tsx +++ b/packages/core/src/components/MapScene/MapSceneRenderer.tsx @@ -47,6 +47,7 @@ export function MapSceneRenderer({ const dragRotate = mapConfig.gestureConfig?.dragRotate ?? true; try { + // 方式1: 通过 mapService.setMapStatus (L7 内部 API) const mapService = (targetScene as unknown as { mapService?: { setMapStatus?: (s: Record) => void; map?: any } }).mapService; if (mapService && typeof mapService.setMapStatus === 'function') { mapService.setMapStatus({ @@ -57,6 +58,20 @@ export function MapSceneRenderer({ }); } + // 方式2: 直接操作 L7 Scene 的交互控制器 (更可靠) + const interactionService = (targetScene as unknown as { interactionService?: any }).interactionService; + if (interactionService) { + if (typeof interactionService.enableDrag === 'function') { + dragPan ? interactionService.enableDrag() : interactionService.disableDrag(); + } + if (typeof interactionService.enableZoom === 'function') { + pinchZoom ? interactionService.enableZoom() : interactionService.disableZoom(); + } + if (typeof interactionService.enableRotate === 'function') { + dragRotate ? interactionService.enableRotate() : interactionService.disableRotate(); + } + } + // Google Maps: setMapStatus 会重新打开原生 zoomControl, // 在它之后强制关闭所有原生 UI 控件 if (mapConfig.basemap === 'google' && mapService?.map) { diff --git a/packages/core/src/components/MapScene/basemap-factory.ts b/packages/core/src/components/MapScene/basemap-factory.ts index 1277184..d842fde 100644 --- a/packages/core/src/components/MapScene/basemap-factory.ts +++ b/packages/core/src/components/MapScene/basemap-factory.ts @@ -1,15 +1,18 @@ import type { MapSchema, BasemapType } from '../../schema/types'; import { DEFAULT_MAP } from '../../schema/defaults'; -// 静态导入所有底图引擎,避免运行时动态 chunk 加载失败 -import { GaodeMap } from '@antv/l7-maps/gaode'; -import { Mapbox } from '@antv/l7-maps/mapbox'; -import { TMap } from '@antv/l7-maps/tianditu'; -import { TencentMap } from '@antv/l7-maps/tencent'; -import { BaiduMap } from '@antv/l7-maps/baidu'; -import { MapLibre } from '@antv/l7-maps/maplibre'; -import { GoogleMap } from '@antv/l7-maps/google'; -import { Map } from '@antv/l7-maps/simple'; +// 底图引擎统一从已 external 的 @antv/l7 导入,避免把 @antv/l7-maps 各引擎适配层 +// (mapbox-gl / maplibre-gl / AMap loader 等)及 GLSL 着色器打包进产物 +import { + GaodeMap, + Mapbox, + TMap, + TencentMap, + BaiduMap, + MapLibre, + GoogleMap, + Map, +} from '@antv/l7'; /** * 底图工厂 — 根据配置创建 L7 Map 实例 diff --git a/packages/core/src/components/Mobile/BottomSheet.tsx b/packages/core/src/components/Mobile/BottomSheet.tsx index 8e74f01..4e99d61 100644 --- a/packages/core/src/components/Mobile/BottomSheet.tsx +++ b/packages/core/src/components/Mobile/BottomSheet.tsx @@ -221,7 +221,7 @@ export function BottomSheet({ width: '100%', height: currentHeight, zIndex: 1000, - pointerEvents: 'auto', + pointerEvents: 'none', transition: isAnimating ? 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1)' : 'none', willChange: 'height', touchAction: 'none', @@ -239,6 +239,7 @@ export function BottomSheet({ display: 'flex', flexDirection: 'column', overflow: 'hidden', + pointerEvents: 'auto', }} > {/* 拖拽手柄区域 */} diff --git a/packages/core/src/constants/colorPalettes.ts b/packages/core/src/constants/colorPalettes.ts new file mode 100644 index 0000000..6458316 --- /dev/null +++ b/packages/core/src/constants/colorPalettes.ts @@ -0,0 +1,30 @@ +/** + * 语义化色板预设 + * + * 基于 ColorBrewer 推荐,色盲安全。 + * 参考: https://colorbrewer2.org/ + */ + +export type ColorScheme = 'sequential' | 'diverging' | 'categorical'; + +/** 顺序色板(蓝)— 适用于单调递增/递减数据 */ +export const SEQUENTIAL_COLORS = ['#dbeafe', '#93c5fd', '#60a5fa', '#3b82f6', '#2563eb'] as const; + +/** 发散色板(红-白-绿)— 适用于有中心点的双向数据 */ +export const DIVERGING_COLORS = ['#dc2626', '#fca5a5', '#e5e7eb', '#86efac', '#16a34a'] as const; + +/** 分类色板 — 适用于离散类别,色盲安全 */ +export const CATEGORICAL_COLORS = ['#2563eb', '#f59e0b', '#ef4444', '#10b981', '#8b5cf6', '#14b8a6', '#f97316', '#64748b'] as const; + +/** 根据 colorScheme 获取对应色板 */ +export function getColorPalette(scheme: ColorScheme): readonly string[] { + switch (scheme) { + case 'diverging': + return DIVERGING_COLORS; + case 'categorical': + return CATEGORICAL_COLORS; + case 'sequential': + default: + return SEQUENTIAL_COLORS; + } +} diff --git a/packages/core/src/design/composite/route-layer.md b/packages/core/src/design/composite/route-layer.md index f956557..c5d8804 100644 --- a/packages/core/src/design/composite/route-layer.md +++ b/packages/core/src/design/composite/route-layer.md @@ -12,24 +12,11 @@ - **透明度**: 0.9,确保底图可见但不干扰路径识别 - **线型**: 直线 (`line`) 或弧线 (`arc`),通过 `routeType` 切换 -### 1.2 发光效果 (Glow) -- **底层光晕**: 宽度为主路径的 2.5 倍,透明度 0.15 -- **作用**: 增强路径在复杂底图上的辨识度,提供视觉深度 -- **可选关闭**: 通过 `glow={false}` 禁用 - -### 1.3 分段着色 (Segment Coloring) +### 1.2 分段着色 (Segment Coloring) - **用途**: 路况(拥堵/畅通)、属性分类、时间分段 - **实现**: 每段独立颜色 + 独立线宽 - **优先级**: `segments` > `path`,分段数据覆盖单一路径 -### 1.4 流动动画 (Flow Animation) -- **样式**: 白色半透明虚线沿路径流动,指示行驶方向 -- **虚线规格**: `[8, 16]` (dash/gap),线宽为主路径的 0.5 倍(最小 1.5px) -- **透明度**: 0.6 -- **速度**: 通过 `animateSpeed` 控制,默认 1 -- **持续时间**: 1500ms 循环 -- **层级**: zIndex=10,确保在最上层 - --- ## 2. 途经点 (Stops / Waypoints) @@ -47,19 +34,19 @@ | 终点 (end) | `#10b981` (Success Green) | 旅程结束,语义区分 | ### 2.3 渲染模式 (Renderer) -支持三种途经点渲染方式: - -#### Point 模式(默认) -- **圆形标记**: 直径 14px,2px 白色描边 -- **序号标签**: 白色文字居中,字号为 stopSize × 0.75(最小 10px),深色描边 1.5px -- **名称标签**: 位于标记下方,偏移 `stopSize/2 + 4` px,2px 白色光晕 +支持三种途经点渲染方式,**默认使用 Marker 模式**(优先 marker 类型标注): -#### Marker 模式 +#### Marker 模式(默认) - **组件**: 使用内置 `Marker` 组件 - **变体**: `circle` / `dot` / 其他 MarkerVariant - **颜色映射**: start→success, end→error, waypoint→primary - **自定义内容**: 支持传入 ReactNode +#### Point 模式 +- **圆形标记**: 直径 14px,2px 白色描边 +- **序号标签**: 白色文字居中,字号为 stopSize × 0.75(最小 10px),深色描边 1.5px +- **名称标签**: 位于标记下方,偏移 `stopSize/2 + 4` px,2px 白色光晕 + #### Icon 模式 - **图标资源**: 通过 `stopIconMap` 映射,或使用 Maki Pin 自动生成 - **尺寸**: 默认 16px,锚点 `bottom` @@ -110,8 +97,6 @@ `RouteLayer` 组件默认封装中已实现: -- 发光底层 + 主路径双层渲染 -- 白色虚线流动动画(方向指示) - 三种途经点渲染模式(point/marker/icon) - 自动编号 + 起终点自动补全 - 分段着色支持 @@ -126,9 +111,7 @@ { lng: 120.15, lat: 30.28, name: '西湖' }, { lng: 120.17, lat: 30.25, name: '灵隐寺' }, ]} - animate - glow - stopRenderer="point" + // stopRenderer 默认 'marker'(DOM Marker 编号标注) /> ``` diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4765fde..3e27609 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -121,6 +121,8 @@ export { MarkerClusterLayer } from './components/CompositeLayer/MarkerClusterLay export type { MarkerClusterLayerProps } from './components/CompositeLayer/MarkerClusterLayer'; export { FillLayer } from './components/CompositeLayer/FillLayer'; export type { FillLayerProps } from './components/CompositeLayer/FillLayer'; +export { getColorPalette, SEQUENTIAL_COLORS, DIVERGING_COLORS, CATEGORICAL_COLORS } from './constants/colorPalettes'; +export type { ColorScheme } from './constants/colorPalettes'; export { ChinaDistrict, ADMIN_SEQUENTIAL_COLORS, DEFAULT_PROVINCE_SOURCE, DEFAULT_CITY_SOURCE, DEFAULT_DISTRICT_SOURCE } from './components/CompositeLayer/ChinaDistrict'; export type { ChinaDistrictProps, ChinaDistrictHandle, AdministrativeLevel, DrillPathNode, BusinessDataItem } from './components/CompositeLayer/ChinaDistrict'; export { GlyphLayer } from './components/CompositeLayer/GlyphLayer'; @@ -141,6 +143,8 @@ export type { H3LayerProps, H3DataItem } from './components/CompositeLayer/H3Lay // Control 组件 export { ZoomControl } from './components/Control/ZoomControl'; export type { ZoomControlProps } from './components/Control/ZoomControl'; +export { ResetViewControl } from './components/Control/ResetViewControl'; +export type { ResetViewControlProps } from './components/Control/ResetViewControl'; export { ScaleControl } from './components/Control/ScaleControl'; export type { ScaleControlProps } from './components/Control/ScaleControl'; export { FullscreenControl } from './components/Control/FullscreenControl'; diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 3141e46..fcab069 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -60,8 +60,8 @@ export interface AnimateConfig { repeat?: number; } -export type ActiveConfig = boolean | { color: string }; -export type SelectConfig = boolean | { color: string }; +export type ActiveConfig = boolean | { color: string; duration?: number }; +export type SelectConfig = boolean | { color: string; duration?: number }; export interface LayerSchema { id?: string; @@ -86,6 +86,8 @@ export interface LayerSchema { size?: number; sizeField?: string; sizeValues?: number[]; + /** 连续数值尺寸范围 [min, max],与 sizeField 配合使用 */ + sizeRange?: [number, number]; shape?: string; shapeField?: string; diff --git a/packages/core/src/styles/tailwind.css b/packages/core/src/styles/tailwind.css index 334bbec..b572679 100644 --- a/packages/core/src/styles/tailwind.css +++ b/packages/core/src/styles/tailwind.css @@ -666,10 +666,10 @@ /* Popup 内容容器 — MD3 玻璃拟态 */ .aimapui-popup-content { position: relative; - background: rgba(248, 249, 255, 0.95); + background: color-mix(in srgb, var(--color-surface) 95%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); - border: 1px solid rgba(195, 198, 215, 0.3); + border: 1px solid color-mix(in srgb, var(--color-outline-variant) 30%, transparent); border-radius: 12px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); animation: aimapui-popup-enter 200ms cubic-bezier(0, 0, 0.2, 1) forwards; diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index 14d466c..5b4ed03 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -1,7 +1,6 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; -import tailwindcss from '@tailwindcss/vite'; import { resolve } from 'path'; export default defineConfig({ @@ -11,7 +10,6 @@ export default defineConfig({ }, plugins: [ react(), - tailwindcss(), dts({ insertTypesEntry: true, rollupTypes: true, @@ -25,6 +23,7 @@ export default defineConfig({ build: { emptyOutDir: false, assetsInlineLimit: 0, + minify: 'terser', lib: { entry: resolve(__dirname, 'src/index.ts'), name: 'aimapui', @@ -37,10 +36,12 @@ export default defineConfig({ /^react\//, 'react-dom', /^react-dom\//, + 'react/jsx-runtime', 'ahooks', 'clsx', 'geotiff', 'supercluster', + 'h3-js', '@antv/l7', '@antv/l7-core', /^@antv\/l7-core\//, diff --git a/packages/plot/package.json b/packages/plot/package.json index 7eba584..3e8cb8d 100644 --- a/packages/plot/package.json +++ b/packages/plot/package.json @@ -15,7 +15,10 @@ }, "./style.css": "./dist/style.css" }, - "files": ["dist", "README.md"], + "files": [ + "dist", + "README.md" + ], "scripts": { "build": "rm -rf dist && vite build && vite build --config vite.config.cjs.ts", "build:esm": "vite build", @@ -32,8 +35,9 @@ }, "devDependencies": { "@antv/aimapui": "workspace:*", - "@antv/l7": "2.28.13", + "@antv/l7": "2.29.1", "@tailwindcss/vite": "^4.0.0", + "@types/geojson": "^7946.0.16", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", @@ -45,12 +49,24 @@ "vite-plugin-dts": "^4.3.0", "vitest": "^4.1.8" }, - "publishConfig": { "access": "public" }, + "publishConfig": { + "access": "public" + }, "license": "MIT", "repository": { "type": "git", "url": "https://github.com/antvis/aimapui", "directory": "packages/plot" }, - "keywords": ["antv", "l7", "map", "bindtype", "bindtype", "bindtype", "bindtype", "bindtype", "bindtype"] + "keywords": [ + "antv", + "l7", + "map", + "bindtype", + "bindtype", + "bindtype", + "bindtype", + "bindtype", + "bindtype" + ] } diff --git a/packages/site/package.json b/packages/site/package.json index e11d2fd..379244f 100644 --- a/packages/site/package.json +++ b/packages/site/package.json @@ -12,8 +12,7 @@ "dependencies": { "@antv/aimapui": "workspace:*", "@antv/aimapui-plot": "workspace:*", - "@antv/l7": "2.28.13", - "@antv/l7-maps": "2.28.13", + "@antv/l7": "2.29.1", "ahooks": "^3.8.4", "highlight.js": "^11.11.1", "marked": "^18.0.4", diff --git a/packages/site/src/app/AppleMapsDemo.tsx b/packages/site/src/app/AppleMapsDemo.tsx new file mode 100644 index 0000000..1bf2a51 --- /dev/null +++ b/packages/site/src/app/AppleMapsDemo.tsx @@ -0,0 +1,1418 @@ +import { useCallback, useRef, useState } from 'react'; +import type { Scene } from '@antv/l7'; +import { AiMap, Marker } from '@antv/aimapui'; + +/* ================================================================ + Apple Maps 风格地图 Demo + 设计参考 Apple Maps iOS 原生应用设计规范 (.codefuse/apple.md) + - 地图浏览模式:Liquid Glass 浮层控件 + 蓝点 Pin 标记 + - POI 详情面板:Bottom Sheet 半屏卡片 + ================================================================ */ + +// ─── Apple Maps Design Tokens ─────────────────────────────────── +const A = { + primary: '#0088FF', + primaryDark: '#0C79FE', + primaryAlpha: 'rgba(0, 136, 255, 0.08)', + primaryTinted: 'rgba(0, 136, 255, 0.12)', + red: '#FF383C', + locStart: '#FFA62B', + locEnd: '#FF5D00', + locPin: '#F19B3F', + locDot: '#FF5C00', + labelText: '#AC480C', + surface: '#FFFFFF', + surfaceLight: '#F5F6F3', + surfaceGray: '#E6E5E5', + separator: '#BDBDBD', + textPrimary: '#000000', + textSecondary: '#868782', + textTertiary: '#7A7B78', + textLink: '#0088FF', + textLinkEdit: '#008BFF', + textOnDark: '#FFFFFF', + glassIcon: '#211906', + searchIcon: '#6C6C6C', + searchPlaceholder: '#88898B', + avatarStart: '#A0C2E4', + avatarEnd: '#717FBE', + weatherIcon: '#D1D1D5', + glassLight: 'linear-gradient(0deg, rgba(245,245,245,0.4), rgba(245,245,245,0.4)), #0F0F0F', + glassPanel: 'linear-gradient(0deg, rgba(245,245,245,0.2), rgba(245,245,245,0.2)), rgba(15,15,15,0.2)', + glassBlend: 'normal, color-dodge' as const, + glassFill: 'rgba(245, 245, 245, 0.1)', + glassButtonBg: 'rgba(255, 255, 255, 0.85)', + // Tools-style light translucent glass (toolbar, temperature, search bar) + // No dark base, no color-dodge — just white translucent + toolsBg: 'rgba(245, 245, 245, 0.4)', + toolsFill: 'rgba(245, 245, 245, 0.1)', + pinGlow: 'radial-gradient(50% 50% at 50% 50%, #579DFF 0%, rgba(87, 196, 255, 0) 100%)', + glassShadow: '0px 4px 32px rgba(0, 0, 0, 0.16)', + glassShadowLight: '0px 4px 32px rgba(0, 0, 0, 0.08)', + pinShadow: 'drop-shadow(0px 4px 16px rgba(0, 0, 0, 0.24))', + guideGray: 'linear-gradient(180deg, #E9E9E9 0%, #FDFDFC 100%)', + guideYellow: 'linear-gradient(180deg, #FFF6C8 0%, #FDFDFC 100%)', +}; + +// ─── 类型定义 ─────────────────────────────────────────────────── +interface POI { + id: string; + name: string; + category: string; + lng: number; + lat: number; + address: string; + phone: string; + status: 'Open' | 'Closed'; + statusLabel: string; + walkPercent: number; + priceLevel: number; // 1-4 for $-$$$$ + driveDist: string; + driveTime: string; + photos: string[]; + ratings: { category: string; score: number; count: number }[]; + goodToKnow: string[]; + hours: { day: string; time: string }[]; + todayLabel: string; +} + +// ─── POI 数据(杭州地区) ──────────────────────────────────────── +const POIS: POI[] = [ + { + id: 'westlake', + name: 'West Lake', + category: 'Scenic Spot · Park', + lng: 120.147, + lat: 30.243, + address: '1 Longjing Rd, Xihu District\nHangzhou, Zhejiang 310013', + phone: '+86 (571) 8717-9600', + status: 'Open', + statusLabel: 'Open 24 hours', + walkPercent: 92, + priceLevel: 1, + driveDist: '3.2 km', + driveTime: '12 min', + photos: [ + 'https://images.unsplash.com/photo-1599571234909-29ed5d1321d6?w=400&h=400&fit=crop', + 'https://images.unsplash.com/photo-1580587771525-78b9f2858a22?w=400&h=400&fit=crop', + ], + ratings: [ + { category: 'Overall', score: 98, count: 8456 }, + { category: 'Scenery', score: 99, count: 7234 }, + { category: 'Cleanliness', score: 95, count: 6102 }, + { category: 'Accessibility', score: 91, count: 4890 }, + ], + goodToKnow: ['Good for Kids', 'Wheelchair Accessible'], + hours: [ + { day: 'Mon - Sun', time: 'Open 24 hours' }, + ], + todayLabel: 'Open Now', + }, + { + id: 'lingyin', + name: 'Lingyin Temple', + category: 'Buddhist Temple · Historic Site', + lng: 120.101, + lat: 30.242, + address: '1 Fayun Lane, Xihu District\nHangzhou, Zhejiang 310013', + phone: '+86 (571) 8796-8665', + status: 'Open', + statusLabel: 'Until 17:15', + walkPercent: 85, + priceLevel: 2, + driveDist: '7.8 km', + driveTime: '25 min', + photos: [ + 'https://images.unsplash.com/photo-1600269452121-4f2416e55c28?w=400&h=400&fit=crop', + 'https://images.unsplash.com/photo-1547981609-4b6bfe67ca0b?w=400&h=400&fit=crop', + ], + ratings: [ + { category: 'Overall', score: 96, count: 5230 }, + { category: 'History', score: 97, count: 4102 }, + { category: 'Atmosphere', score: 98, count: 3890 }, + { category: 'Facilities', score: 88, count: 3100 }, + ], + goodToKnow: ['Cash Only', 'No Photos Inside Halls'], + hours: [ + { day: 'Mon - Sun', time: '07:00 - 17:15' }, + ], + todayLabel: 'Open Until 17:15', + }, + { + id: 'longjing', + name: 'Longjing Tea Village', + category: 'Tea House · Cafe', + lng: 120.124, + lat: 30.221, + address: 'Longjing Rd, Xihu District\nHangzhou, Zhejiang 310008', + phone: '+86 (571) 8799-4002', + status: 'Open', + statusLabel: 'Until 18:00', + walkPercent: 78, + priceLevel: 3, + driveDist: '5.5 km', + driveTime: '18 min', + photos: [ + 'https://images.unsplash.com/photo-1558160074-4d7d8bdf4256?w=400&h=400&fit=crop', + 'https://images.unsplash.com/photo-1544787219-7f47ccb76574?w=400&h=400&fit=crop', + ], + ratings: [ + { category: 'Overall', score: 94, count: 3210 }, + { category: 'Food & Drink', score: 95, count: 2800 }, + { category: 'Service', score: 92, count: 2450 }, + { category: 'Atmosphere', score: 97, count: 2600 }, + ], + goodToKnow: ['Accepts WeChat Pay', 'Outdoor Seating'], + hours: [ + { day: 'Mon - Sat', time: '09:00 - 18:00' }, + { day: 'Sunday', time: '10:00 - 17:00' }, + ], + todayLabel: 'Open Until 18:00', + }, + { + id: 'hefang', + name: 'Hefang Street', + category: 'Shopping · Historic Street', + lng: 120.168, + lat: 30.242, + address: 'Hefang St, Shangcheng District\nHangzhou, Zhejiang 310002', + phone: '+86 (571) 8780-8851', + status: 'Open', + statusLabel: 'Until 22:00', + walkPercent: 88, + priceLevel: 2, + driveDist: '2.1 km', + driveTime: '8 min', + photos: [ + 'https://images.unsplash.com/photo-1555881400-74d7acaacd8b?w=400&h=400&fit=crop', + 'https://images.unsplash.com/photo-1519451241324-20b4ea2c4220?w=400&h=400&fit=crop', + ], + ratings: [ + { category: 'Overall', score: 91, count: 6780 }, + { category: 'Shopping', score: 89, count: 5100 }, + { category: 'Food & Drink', score: 93, count: 4600 }, + { category: 'Atmosphere', score: 92, count: 4100 }, + ], + goodToKnow: ['Accepts Credit Cards', 'Busy on Weekends'], + hours: [ + { day: 'Mon - Thu', time: '10:00 - 21:00' }, + { day: 'Fri - Sat', time: '10:00 - 22:00' }, + { day: 'Sunday', time: '10:00 - 20:00' }, + ], + todayLabel: 'Open Until 22:00', + }, + { + id: 'xixi', + name: 'Xixi Wetland Park', + category: 'National Park · Wetland', + lng: 120.067, + lat: 30.27, + address: '518 Tianmushan Rd, Xihu District\nHangzhou, Zhejiang 310023', + phone: '+86 (571) 8810-6688', + status: 'Open', + statusLabel: 'Until 17:30', + walkPercent: 72, + priceLevel: 2, + driveDist: '12.4 km', + driveTime: '35 min', + photos: [ + 'https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=400&h=400&fit=crop', + 'https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=400&h=400&fit=crop', + ], + ratings: [ + { category: 'Overall', score: 93, count: 4560 }, + { category: 'Scenery', score: 94, count: 3900 }, + { category: 'Facilities', score: 87, count: 3200 }, + { category: 'Accessibility', score: 85, count: 2800 }, + ], + goodToKnow: ['Good for Kids', 'Boat Tours Available'], + hours: [ + { day: 'Mon - Sun', time: '08:00 - 17:30' }, + ], + todayLabel: 'Open Until 17:30', + }, +]; + +// ─── 子组件:Liquid Glass 按钮 ────────────────────────────────── +function GlassButton({ + size, + style, + children, + onClick, +}: { + size: number; + style?: React.CSSProperties; + children: React.ReactNode; + onClick?: () => void; +}) { + return ( +
+
+
{children}
+
+ ); +} + +// ─── 子组件:Glass 面板 ──────────────────────────────────────── +function GlassPanel({ + width, + height, + borderRadius, + style, + children, +}: { + width?: number | string; + height?: number | string; + borderRadius?: number; + style?: React.CSSProperties; + children: React.ReactNode; +}) { + return ( +
+
+
{children}
+
+ ); +} + +// ─── 子组件:温度小组件 ────────────────────────────────────────── +function TemperatureWidget() { + return ( +
+
+ + ☀️ + + + 28° + +
+ ); +} + +// ─── 子组件:垂直工具栏 ────────────────────────────────────────── +function VerticalToolbar({ onLocate }: { onLocate: () => void }) { + return ( +
+
+ {/* Explore / Look Around */} + + 🔭 + + {/* Locate me */} + + location_on + +
+ ); +} + +// ─── 子组件:底部搜索栏 ────────────────────────────────────────── +function BottomSearchBar() { + return ( +
+
+ {/* Search Input */} +
+
+ {/* Search Icon */} + + + + + + Search Places + +
+ {/* Mic Icon */} + + 🎤 + +
+ {/* Avatar */} +
+ + L + +
+
+ ); +} + +// ─── 子组件:POI 详情面板 ──────────────────────────────────────── +function PlaceCard({ poi, onClose }: { poi: POI; onClose: () => void }) { + return ( + <> + {/* 半透明遮罩 */} +
+ {/* 详情面板 */} +
+ {/* Fill layer */} +
+ {/* Grabber */} +
+ {/* 头部信息 */} +
+
+ {poi.name} +
+
+ {poi.category} +
+
+ + {/* 可滚动内容 */} +
+ {/* Actions Row */} +
+ + + +
+ + {/* Info Stats */} +
+ + + + +
+ + {/* Photo Gallery */} +
+ {poi.photos.map((url, i) => ( +
+ + {i === 0 ? 'Atmosphere' : 'All Photos'} + +
+ ))} + {/* Add / More buttons */} +
+
+ add_a_photo + + Add Photo + +
+
+ photo_library + + More Photos + +
+
+
+ + {/* Ratings Section */} +
+ +
+ {poi.ratings.map((r, i) => ( + + ))} +
+ {/* Rate Button */} +
+ star + + Rate This Place + +
+
+ + {/* Good to Know */} +
+ +
+ {poi.goodToKnow.map((item, i) => ( +
+ + + {item} + +
+ ))} +
+
+ + {/* Hours */} +
+
+ + Hours + + + Edit + +
+
+ {poi.todayLabel} +
+ {poi.hours.map((h, i) => ( +
+ + {h.day} + + + {h.time} + +
+ ))} +
+ + {/* Details */} +
+ +
+ {/* Phone */} +
+ + Phone + + + {poi.phone} + +
+ {/* Address */} +
+ + Address + + + {poi.address} + +
+
+
+ + {/* System Actions */} +
+ + + +
+
+
+ + ); +} + +// ─── 详情面板子组件 ───────────────────────────────────────────── +function ActionButton({ label, icon, primary }: { label: string; icon: string; primary?: boolean }) { + return ( +
+ {icon} + + {label} + +
+ ); +} + +function InfoStat({ label, value, color, icon, sub }: { label: string; value: string; color?: string; icon?: string; sub?: string }) { + return ( +
+ + {label} + +
+ {icon && {icon}} + + {value} + +
+ {sub && ( + + {sub} + + )} +
+ ); +} + +function SectionTitle({ title }: { title: string }) { + return ( + + {title} + + ); +} + +function RatingRow({ category, score, count }: { category: string; score: number; count: number }) { + return ( +
+
+ + {category} + + + {count} ratings + +
+
+ star + + {score}% + +
+
+ ); +} + +function SystemActionButton({ label, icon }: { label: string; icon: string }) { + return ( +
+ {icon} + + {label} + +
+ ); +} + +// ─── 主组件 ───────────────────────────────────────────────────── +export default function AppleMapsDemo() { + const sceneRef = useRef(null); + const [selectedPoi, setSelectedPoi] = useState(null); + + const handleSceneReady = useCallback((scene: Scene) => { + sceneRef.current = scene; + setTimeout(() => { + scene.fitBounds( + [ + [120.05, 30.2], + [120.18, 30.28], + ], + { padding: { top: 80, bottom: 120, left: 60, right: 60 } }, + ); + }, 400); + }, []); + + const handleLocate = useCallback(() => { + if (sceneRef.current) { + sceneRef.current.setZoomAndCenter(14, [120.15, 30.25]); + } + }, []); + + return ( +
+ {/* ── AiMap 地图画布 ── */} + + {/* POI Markers — 橙色地点标记 */} + {POIS.map((poi) => ( + +
setSelectedPoi(poi)} + style={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + cursor: 'pointer', + }} + > + {/* Pin Icon */} +
+ {/* Pin body */} +
+ {/* Inner icon area */} +
+ location_on +
+
+ {/* Pin bottom tip */} +
+ {/* Small dot at tip */} +
+
+ {/* Name Label */} + + {poi.name} + +
+ + ))} + + {/* Center Pin — 搜索蓝点 + 光晕 */} + +
+ {/* Radial Glow */} +
+ {/* Blue Dot Core */} +
+
+ + + + {/* ── 模式 A:地图浏览覆盖层 ── */} + {!selectedPoi && ( + <> + {/* 关闭按钮(左上角)*/} + + + ✕ + + + + {/* 信息按钮(右上角) */} + + + ℹ️ + + + + + + + + )} + + {/* ── 模式 B:POI 详情面板 ── */} + {selectedPoi && ( + setSelectedPoi(null)} /> + )} +
+ ); +} \ No newline at end of file diff --git a/packages/site/src/app/CheckInMap.tsx b/packages/site/src/app/CheckInMap.tsx index 981106d..38a7035 100644 --- a/packages/site/src/app/CheckInMap.tsx +++ b/packages/site/src/app/CheckInMap.tsx @@ -208,7 +208,7 @@ export default function CheckInMap() {
{/* ── UI 覆盖层 ── */} -
+
{/* ── 顶部:搜索栏 + 进度 ── */}
{/* 搜索栏 */} @@ -342,7 +342,7 @@ export default function CheckInMap() { bottom: bottomSheetSnap === 'collapsed' ? 108 : 16, left: 16, right: 16, - zIndex: 20, + zIndex: 1000, pointerEvents: 'auto', transition: 'bottom 0.3s ease', }} diff --git a/packages/site/src/app/FootprintMap.tsx b/packages/site/src/app/FootprintMap.tsx index 173baa0..c7377aa 100644 --- a/packages/site/src/app/FootprintMap.tsx +++ b/packages/site/src/app/FootprintMap.tsx @@ -360,7 +360,7 @@ export default function FootprintMap() {
= { + restaurant: { icon: 'restaurant', color: '#EA4335' }, + cafe: { icon: 'local_cafe', color: '#8E6B4F' }, + hotel: { icon: 'hotel', color: '#1A73E8' }, + shopping: { icon: 'shopping_bag', color: '#F9AB00' }, + gas: { icon: 'local_gas_station', color: '#34A853' }, + park: { icon: 'park', color: '#34A853' }, + museum: { icon: 'museum', color: '#9C6BFF' }, +}; + +const NAV_TABS = [ + { key: 'explore', label: '探索', icon: 'explore' }, + { key: 'commute', label: '通勤', icon: 'commute' }, + { key: 'saved', label: '收藏', icon: 'bookmark' }, + { key: 'contrib', label: '贡献', icon: 'add_location_alt' }, + { key: 'updates', label: '更新', icon: 'notifications' }, +]; + +// Spec constants +const BOTTOM_NAV_H = 89; // 53 + 36 safe area +const CARD_COLLAPSED_H = 96; +const CARD_EXPANDED_H = 520; + +// ── Zoom Buttons — spec: FAB Small Secondary (40×40, radius 44, bg #FFF, shadow light, icon 24px #3F3F3F) ── +function ZoomButtons({ scene }: { scene: Scene | null }) { + const fabSmallSecondary: React.CSSProperties = { + width: 40, height: 40, borderRadius: 44, border: 'none', cursor: 'pointer', + background: G.surface, boxShadow: G.shadowLight, + display: 'flex', alignItems: 'center', justifyContent: 'center', + }; + return ( +
+ + +
+ ); +} + +// ── Bottom Nav (spec: 89px, 0px -2px 4px shadow, z-index base) ── +function BottomNav({ activeTab, onTab }: { activeTab: string; onTab: (k: string) => void }) { + return ( +
+ {NAV_TABS.map(tab => { + const isActive = activeTab === tab.key; + return ( +
onTab(tab.key)} + style={{ + display: 'flex', flexDirection: 'column', alignItems: 'center', + gap: 4, cursor: 'pointer', width: 64, height: 49, + padding: 0, + }}> +
+ {tab.icon} +
+ {tab.label} +
+ ); + })} +
+ ); +} + +// ── Business-style drawer per spec ───────────────────────────── +function BottomCard({ filteredPois, activeFilter, selectedPoi, onSelect, selectedPoiId, onPoiClick, visible = false, onStartNavigation }: { + filteredPois: PoiItem[]; activeFilter: string; selectedPoi: PoiItem | null; selectedPoiId: string | null; + onSelect: (p: PoiItem) => void; onPoiClick: (p: PoiItem) => void; visible?: boolean; onStartNavigation?: () => void; +}) { + const [activeTab, setActiveTab] = useState<'overview' | 'list'>('overview'); + + if (!visible) return null; + + return ( +
+ {/* Close Button */} +
+ onPoiClick(selectedPoi!)} style={{ fontSize: 24, color: '#4F4F4F', cursor: 'pointer', background: G.surface, borderRadius: '50%', padding: 4, boxShadow: G.shadowLight }}>close +
+ + {/* Scrollable Content */} +
+ {/* Bottom Sheet Handle — spec 3.12: 73×4px, #C5C6CD, radius 12px */} +
+ {/* Title — spec: 20px/400 */} +
+ {selectedPoi ? selectedPoi.name : '西湖风景区'} +
+ + {/* Address — spec: 14px, #867F7F */} +
+ {selectedPoi ? selectedPoi.address : '杭州市西湖区龙井路 1 号'} +
+ + {/* Action Buttons row — spec exact */} +
+ {/* Primary button — spec: 12px 18px 12px 16px, gap 6, #1A73E8, radius 44, icon 18px, label 16px/500 */} + + {/* Secondary Call — spec: 12px 16px, gap 6, #ECF3FE, radius 44, icon 22px, label 16px/500 #0B57D0 */} + + {/* Secondary Save — spec: 12px 16px, gap 6, #ECF3FE, radius 44, icon 22px, label 16px/500 #0B57D0 */} + + {/* Secondary Share — spec: 12px 16px */} + +
+ + {/* Tab Bar — spec: 36px, selected=15px/600 #1761D7 + 3px highlighter, inactive=15px/500 #7F7F7F */} +
+ {(['overview', 'list'] as const).map(tab => { + const isActive = activeTab === tab; + return ( +
setActiveTab(tab)} + style={{ + position: 'relative', cursor: 'pointer', + display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', + padding: '4px 4px 0', gap: 4, width: 72, height: 36, + }}> + {tab === 'overview' ? '概览' : '列表'} + {isActive && ( +
+ )} +
+ ); + })} +
+ + {/* Tab Content */} +
+ {activeTab === 'overview' ? ( + /* Overview — simple info rows per spec */ +
+ {selectedPoi && ( + <> + + + + {selectedPoi.openNow && } + + )} + {!selectedPoi && ( + + )} +
+ ) : ( + /* List tab — POI items per spec: 72px, 34px icon, divider #F3F2F2 */ +
+
+ {activeFilter === 'all' ? '附近地点' : PILL_FILTERS.find(f => f.key === activeFilter)?.label} + {filteredPois.length} 个 +
+ {filteredPois.map((poi, i) => { + const cat = CAT_ICON[poi.category] ?? CAT_ICON.restaurant; + return ( +
onPoiClick(poi)} + style={{ + display: 'flex', alignItems: 'center', gap: 20, + height: 72, padding: '0 0 0 12px', cursor: 'pointer', + borderBottom: i < filteredPois.length - 1 ? `1px solid ${G.borderLight}` : 'none', + background: selectedPoiId === poi.id ? G.highlightBgAlt : 'transparent', + transition: 'background 0.15s', + }}> +
+ {cat.icon} +
+
+
{poi.name}
+
+ ★ {poi.rating} + ({poi.reviewCount.toLocaleString()})·{poi.distance} + {poi.openNow && <>·营业中} +
+
{poi.address}
+
+ chevron_right +
+ ); + })} +
+ )} +
+
+
+ ); +} + +function InfoRow({ label, value, valueColor }: { label: string; value: string; valueColor?: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +// 模拟定位 +const SIM_USER_LOC = { lng: 120.155, lat: 30.255 }; + +export default function GoogleMapsMobileDemo() { + const sceneRef = useRef(null); + const [activeFilter, setActiveFilter] = useState('all'); + const [searchFocused, setSearchFocused] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedPoi, setSelectedPoi] = useState(null); + const [activeTab, setActiveTab] = useState('explore'); + const [userLoc, setUserLoc] = useState<{ lng: number; lat: number } | null>(null); + const [navigationMode, setNavigationMode] = useState(false); + + useEffect(() => { + let resolved = false; + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + pos => { resolved = true; setUserLoc({ lng: pos.coords.longitude, lat: pos.coords.latitude }); }, + () => { if (!resolved) setUserLoc(SIM_USER_LOC); }, + { enableHighAccuracy: true, timeout: 5000 }, + ); + } + const fallback = setTimeout(() => { if (!resolved) setUserLoc(SIM_USER_LOC); }, 6000); + return () => clearTimeout(fallback); + }, []); + + const filteredPois = useMemo(() => { + if (activeFilter === 'all') return POIS; + return POIS.filter(p => p.category === activeFilter); + }, [activeFilter]); + + const searchResults = useMemo(() => { + if (!searchQuery.trim()) return []; + const query = searchQuery.toLowerCase(); + return POIS.filter(p => + p.name.toLowerCase().includes(query) || + p.address.toLowerCase().includes(query) || + p.category.toLowerCase().includes(query) + ); + }, [searchQuery]); + + const handleSceneReady = useCallback((scene: Scene) => { + sceneRef.current = scene; + setTimeout(() => { + scene.fitBounds( + [[120.095, 30.20], [120.18, 30.29]], + { padding: [72, 20, BOTTOM_NAV_H + CARD_COLLAPSED_H + 10, 20] }, + ); + }, 400); + }, []); + + const handlePoiClick = useCallback((poi: PoiItem) => { + setSelectedPoi(prev => prev?.id === poi.id ? null : poi); + sceneRef.current?.setZoomAndCenter(15, [poi.lng, poi.lat]); + }, []); + + const handlePoiSelect = useCallback((poi: PoiItem) => { + setSelectedPoi(poi); + sceneRef.current?.setZoomAndCenter(15, [poi.lng, poi.lat]); + }, []); + + const handleStartNavigation = useCallback(() => { + if (!selectedPoi || !userLoc) return; + setNavigationMode(true); + // 调整视野以同时显示起点和终点 + const bounds: [[number, number], [number, number]] = [ + [Math.min(userLoc.lng, selectedPoi.lng) - 0.01, Math.min(userLoc.lat, selectedPoi.lat) - 0.01], + [Math.max(userLoc.lng, selectedPoi.lng) + 0.01, Math.max(userLoc.lat, selectedPoi.lat) + 0.01], + ]; + sceneRef.current?.fitBounds(bounds, { padding: [80, 40, BOTTOM_NAV_H + 200, 40] }); + }, [selectedPoi, userLoc]); + + const handleExitNavigation = useCallback(() => { + setNavigationMode(false); + }, []); + + // 生成模拟路线(直线 + 简单折线) + const routeFeature = useMemo(() => { + if (!navigationMode || !selectedPoi || !userLoc) return null; + const start = [userLoc.lng, userLoc.lat]; + const end = [selectedPoi.lng, selectedPoi.lat]; + const midLng = (start[0] + end[0]) / 2; + const midLat = (start[1] + end[1]) / 2 + 0.005; + return { + type: 'Feature' as const, + geometry: { + type: 'LineString' as const, + coordinates: [start, [midLng, midLat], end], + }, + properties: {}, + }; + }, [navigationMode, selectedPoi, userLoc]); + + return ( +
+ {/* Map */} +
+ + {filteredPois.map(poi => { + const cat = CAT_ICON[poi.category] ?? CAT_ICON.restaurant; + return ( + +
handlePoiClick(poi)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer' }}> + {selectedPoi?.id === poi.id && ( +
{poi.name}
+ )} +
+ {cat.icon} +
+
+
+ + ); + })} + + {/* User Location Marker — spec 3.10: 62×62 container, 73.5×63 cone, 22×22 blue dot with 4px white border */} + {userLoc && ( + +
+ {/* Direction cone: spec 73.5×63px at left:3.5 top:-2 */} +
+ {/* Blue dot: 22×22 with 4px white border + shadow */} +
+
+ + )} + + {/* Navigation Route Line — spec 3.14: dashed 5px #2985FF */} + {routeFeature && ( + } + zIndex={10} + /> + )} + +
+ + {/* ═══ Status Bar — spec: 48px, padding 18px 26px 14px 27px ═══ */} +
+ 9:41 +
+ signal_cellular_alt + wifi + battery_full +
+
+ + {/* ═══ Search Bar — spec: top: 52px, container 56px, bar 48px, radius 40 ═══ */} +
+
+ {searchFocused ? ( + <> + { setSearchFocused(false); setSearchQuery(''); }}>arrow_back + setSearchQuery(e.target.value)} placeholder="搜索地点" style={{ flex: 1, border: 'none', outline: 'none', fontSize: 20, color: G.textPrimary, background: 'transparent', fontFamily: 'inherit' }} onBlur={() => { if (!searchQuery) setSearchFocused(false); }} /> + { setSearchFocused(false); setSearchQuery(''); setSelectedPoi(null); }}>close + + ) : ( + <> + search + setSearchFocused(true)}>搜索地点 +
+ person +
+ + )} +
+
+ + {/* ═══ Search Results List ═══ */} + {searchFocused && searchQuery.trim() && ( +
+ {searchResults.length === 0 ? ( +
+ 未找到相关地点 +
+ ) : ( + searchResults.map(poi => { + const cat = CAT_ICON[poi.category] ?? CAT_ICON.restaurant; + const isSelected = selectedPoi?.id === poi.id; + return ( +
{ handlePoiClick(poi); setSearchFocused(false); setSearchQuery(''); }} + style={{ + display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', + cursor: 'pointer', borderBottom: `1px solid ${G.borderLight}`, + background: isSelected ? G.highlightBgAlt : 'transparent', + }} + > +
+ {cat.icon} +
+
+
{poi.name}
+
{poi.address}
+
+ {poi.distance} +
+ ); + }) + )} +
+ )} + + {/* ═══ Pills — spec: top: 122px, height 40px ═══ */} + {!navigationMode && ( +
+ {PILL_FILTERS.map(f => { + const isActive = activeFilter === f.key; + return ( + + ); + })} +
+ )} + + {/* ═══ Transportation Chips — spec 3.7: shown in navigation mode ═══ */} + {navigationMode && ( +
+ {[ + { key: 'car', icon: 'directions_car', label: '驾车' }, + { key: 'transit', icon: 'directions_bus', label: '公交' }, + { key: 'walk', icon: 'directions_walk', label: '步行' }, + { key: 'bike', icon: 'directions_bike', label: '骑行' }, + { key: 'fly', icon: 'flight', label: '飞行' }, + ].map((chip, i) => { + const isSelected = i === 0; // 默认选中驾车 + return ( + + ); + })} +
+ )} + + {/* ═══ Left Column: Zoom + Locate + Directions ═══ */} +
+
+ + +
+ + {/* ═══ Bottom Nav — spec: 89px, 0px -2px 4px shadow ═══ */} + + + {/* ═══ Bottom Card — spec: -2px 0px 11px shadow ═══ */} + + + {/* ═══ Popup ═══ */} + {selectedPoi && ( + setSelectedPoi(null)} + header={{ title: selectedPoi.name, statusLabel: selectedPoi.openNow ? '营业中' : undefined, statusColor: '#34A853' }} + attributes={[ + { label: '评分', value: `${selectedPoi.rating} (${selectedPoi.reviewCount.toLocaleString()} 条评价)` }, + { label: '地址', value: selectedPoi.address }, + { label: '距离', value: selectedPoi.distance }, + ]} + actions={[ + { variant: 'primary' as const, label: '导航', onClick: () => {} }, + { variant: 'secondary' as const, label: '收藏', onClick: () => {} }, + ]} + /> + )} +
+ ); +} \ No newline at end of file diff --git a/packages/site/src/app/HangzhouTravelMap.tsx b/packages/site/src/app/HangzhouTravelMap.tsx new file mode 100644 index 0000000..3f238a0 --- /dev/null +++ b/packages/site/src/app/HangzhouTravelMap.tsx @@ -0,0 +1,1321 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Scene } from '@antv/l7'; +import { AiMap, RouteLayer, Popup, Marker } from '@antv/aimapui'; + +/* ================================================================ + 杭州三日游旅游地图 — Material Design 3 设计风格 + Design Tokens: Primary #005da7, Secondary #42691a + ================================================================ */ + +// ── Material Design 3 Design Tokens ────────────────────────────── +const T = { + primary: '#005da7', + primaryContainer: '#2976c7', + secondary: '#42691a', + secondaryContainer: '#c2f191', + onSecondaryContainer: '#486f20', + background: '#f4fafd', + surface: '#f4fafd', + surfaceContainer: '#e8eff1', + surfaceContainerLow: '#eef5f7', + surfaceContainerLowest: '#ffffff', + surfaceContainerHighest: '#dde4e6', + surfaceDim: '#d4dbdd', + surfaceVariant: '#dde4e6', + surfaceBright: '#f4fafd', + onBackground: '#161d1f', + onSurface: '#161d1f', + onSurfaceVariant: '#414751', + onPrimary: '#ffffff', + onSecondary: '#ffffff', + onPrimaryFixed: '#001c39', + onPrimaryFixedVariant: '#004883', + outline: '#717783', + outlineVariant: '#c1c7d3', + error: '#ba1a1a', + inverseSurface: '#2b3234', + inverseOnSurface: '#ebf2f4', + tertiary: '#595c5f', + radius: '0.25rem', + radiusLg: '0.5rem', + radiusXl: '0.75rem', + radius2xl: '1rem', + radius3xl: '1.5rem', + radiusFull: '9999px', +}; + +// ── Data Model ─────────────────────────────────────────────────── +interface Spot { + id: string; + name: string; + lng: number; + lat: number; + icon: string; + time: string; + duration: string; + rating: number; + description: string; + image: string; + hasFood?: boolean; + photoTip?: boolean; +} + +interface DayRoute { + day: number; + title: string; + theme: string; + spots: Spot[]; +} + +const HANGZHOU_ROUTES: DayRoute[] = [ + { + day: 1, + title: '第一天', + theme: '西湖经典环游', + spots: [ + { + id: 'd1-1', + name: '断桥残雪', + lng: 120.155, + lat: 30.261, + icon: 'landscape', + time: '09:00', + duration: '1.5h', + rating: 4.8, + description: '西湖十景之一,民间爱情传说发生地。', + image: 'https://images.unsplash.com/photo-1599571234909-29ed5d1321d6?w=400&h=400&fit=crop', + }, + { + id: 'd1-2', + name: '郭庄 (Gusu Garden)', + lng: 120.143, + lat: 30.249, + icon: 'nature', + time: '11:30', + duration: '2h', + rating: 4.6, + description: '典型的江南私家园林,临湖凭眺。', + image: 'https://images.unsplash.com/photo-1545569341-9eb8b30979d9?w=400&h=400&fit=crop', + hasFood: true, + }, + { + id: 'd1-3', + name: '雷峰塔', + lng: 120.149, + lat: 30.232, + icon: 'temple_buddhist', + time: '15:00', + duration: '1.5h', + rating: 4.7, + description: '俯瞰西湖全景的最佳地点,传说之地。', + image: 'https://images.unsplash.com/photo-1600269452121-4f2416e55c28?w=400&h=400&fit=crop', + photoTip: true, + }, + ], + }, + { + day: 2, + title: '第二天', + theme: '灵隐禅意之旅', + spots: [ + { + id: 'd2-1', + name: '灵隐寺', + lng: 120.1, + lat: 30.24, + icon: 'temple_buddhist', + time: '08:00', + duration: '2h', + rating: 4.9, + description: '千年古刹,飞来峰石窟艺术精华。', + image: 'https://images.unsplash.com/photo-1547981609-4b6bfe67ca0b?w=400&h=400&fit=crop', + }, + { + id: 'd2-2', + name: '北高峰', + lng: 120.095, + lat: 30.248, + icon: 'hiking', + time: '10:30', + duration: '1.5h', + rating: 4.5, + description: '登顶俯瞰灵隐全景,可选缆车上下。', + image: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=400&fit=crop', + }, + { + id: 'd2-3', + name: '龙井村', + lng: 120.115, + lat: 30.225, + icon: 'local_cafe', + time: '13:00', + duration: '2h', + rating: 4.7, + description: '品正宗西湖龙井,春季可体验采茶。', + image: 'https://images.unsplash.com/photo-1558160074-4d7d8bdf4256?w=400&h=400&fit=crop', + hasFood: true, + }, + ], + }, + { + day: 3, + title: '第三天', + theme: '运河与现代杭州', + spots: [ + { + id: 'd3-1', + name: '京杭大运河', + lng: 120.13, + lat: 30.32, + icon: 'directions_boat', + time: '09:00', + duration: '1.5h', + rating: 4.6, + description: '水上巴士体验运河风光,从武林门出发。', + image: 'https://images.unsplash.com/photo-1555881400-74d7acaacd8b?w=400&h=400&fit=crop', + }, + { + id: 'd3-2', + name: '西溪湿地', + lng: 120.06, + lat: 30.27, + icon: 'park', + time: '11:30', + duration: '3h', + rating: 4.7, + description: '电瓶船深入湿地,看白鹭翩飞。', + image: 'https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=400&h=400&fit=crop', + }, + { + id: 'd3-3', + name: '武林夜市', + lng: 120.165, + lat: 30.275, + icon: 'restaurant', + time: '16:00', + duration: '2h', + rating: 4.5, + description: '杭帮菜小吃一条街,片儿川必吃。', + image: 'https://images.unsplash.com/photo-1555939594-58d7cb561ad1?w=400&h=400&fit=crop', + hasFood: true, + }, + ], + }, +]; + +type Category = '全部' | '西湖' | '寺庙' | '历史' | '美食'; +const CATEGORIES: Category[] = ['全部', '西湖', '寺庙', '历史', '美食']; + +function spotsToPath(spots: Spot[]): [number, number][] { + return spots.map((s) => [s.lng, s.lat]); +} + +function spotsToStops(spots: Spot[]) { + return spots.map((s, i) => ({ + id: s.id, + lng: s.lng, + lat: s.lat, + name: s.name, + index: i, + icon: s.icon, + })); +} + +const CARD_COLLAPSED_H = 130; +const CARD_EXPANDED_H = 450; + +// ── TopAppBar: Search + Category Chips ─────────────────────────── +function TopAppBar({ + activeCategory, + onCategoryChange, +}: { + activeCategory: Category; + onCategoryChange: (c: Category) => void; +}) { + const [searchFocused, setSearchFocused] = useState(false); + + return ( +
+ {/* Search Bar */} +
+
+ + search + + setSearchFocused(true)} + onBlur={() => setSearchFocused(false)} + style={{ + background: 'transparent', + border: 'none', + outline: 'none', + flex: 1, + fontSize: 14, + fontWeight: 400, + color: T.onSurface, + fontFamily: "'Plus Jakarta Sans', -apple-system, sans-serif", + }} + /> +
+ +
+ + {/* Category Chips */} +
+ {CATEGORIES.map((cat) => { + const isActive = cat === activeCategory; + return ( + + ); + })} +
+
+ ); +} + +// ── FAB: Current Location ──────────────────────────────────────── +function LocationFab({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +// ── BottomSheet: Trip Plan Panel ───────────────────────────────── +function TravelBottomSheet({ + routes, + activeDay, + selectedSpot, + onDayChange, + onSpotSelect, +}: { + routes: DayRoute[]; + activeDay: number; + selectedSpot: Spot | null; + onDayChange: (d: number) => void; + onSpotSelect: (s: Spot) => void; +}) { + const [expanded, setExpanded] = useState(false); + const route = routes[activeDay]; + const transform = expanded ? 'translateY(5%)' : 'translateY(62%)'; + + return ( +
+ {/* Drag Handle */} +
setExpanded(!expanded)} + style={{ + width: '100%', + display: 'flex', + justifyContent: 'center', + padding: '10px 0 4px', + cursor: 'pointer', + flexShrink: 0, + }} + > +
+
+ + {/* Content */} +
+ {/* Header */} +
+
+

+ 杭州三日游 +

+

+ 为您精选的深度文化路线 +

+
+ +
+ + {/* Day Tabs */} +
+ {routes.map((r, i) => { + const isActive = i === activeDay; + return ( + + ); + })} +
+ + {/* Timeline Route List */} +
+ {route.spots.map((spot, i) => { + const isLast = i === route.spots.length - 1; + const isSel = selectedSpot?.id === spot.id; + return ( +
+ {/* Vertical line */} + {!isLast && ( +
+ )} + {/* Number circle */} +
+ {i + 1} +
+ {/* Card */} +
onSpotSelect(spot)} + style={{ + display: 'flex', + gap: 12, + padding: 12, + borderRadius: 16, + cursor: 'pointer', + background: isSel + ? 'rgba(0, 93, 167, 0.04)' + : T.surfaceContainerLowest, + border: `1px solid ${ + isSel ? 'rgba(0, 93, 167, 0.2)' : 'rgba(22, 29, 31, 0.05)' + }`, + boxShadow: '0px 1px 3px rgba(0,0,0,0.04)', + transition: 'all 0.2s', + marginBottom: 4, + }} + > +
+ + image + + {spot.name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + style={{ + width: '100%', + height: '100%', + objectFit: 'cover', + position: 'relative', + }} + /> +
+
+
+ + {spot.name} + + + {spot.time} + +
+

+ {spot.description} +

+
+
+ + schedule + + + {spot.duration} + +
+
+ + star + + + {spot.rating} + +
+ {spot.hasFood && ( +
+ + restaurant + + + 推荐午餐 + +
+ )} + {spot.photoTip && ( +
+ + photo_camera + + + 落日最佳 + +
+ )} +
+
+
+
+ ); + })} +
+
+
+ ); +} + +// ── BottomNav: Navigation Bar ──────────────────────────────────── +function BottomNav({ sheetExpanded }: { sheetExpanded: boolean }) { + const bottomOffset = sheetExpanded ? 10 : CARD_COLLAPSED_H - 100; + + return ( + + ); +} + +// ── Main Component ─────────────────────────────────────────────── +export default function HangzhouTravelMap() { + const [activeDay, setActiveDay] = useState(0); + const [selectedSpot, setSelectedSpot] = useState(null); + const [activeCategory, setActiveCategory] = useState('全部'); + const [sheetExpanded, setSheetExpanded] = useState(false); + const sceneRef = useRef(null); + const route = HANGZHOU_ROUTES[activeDay]; + const routeColor = '#005da7'; + + // Filter spots by category + const filteredSpots = route.spots.filter( + (s) => activeCategory === '全部' || matchesCategory(s, activeCategory), + ); + + function matchesCategory(spot: Spot, cat: Category): boolean { + switch (cat) { + case '西湖': + return spot.name.includes('西湖') || spot.name.includes('湖') || spot.name.includes('堤') || spot.name.includes('湿地'); + case '寺庙': + return spot.name.includes('寺') || spot.name.includes('庙') || spot.name.includes('塔'); + case '历史': + return spot.name.includes('古') || spot.name.includes('运河') || spot.name.includes('村'); + case '美食': + return spot.name.includes('夜市') || spot.name.includes('村') || spot.hasFood === true; + default: + return true; + } + } + + const handleSceneReady = useCallback((scene: Scene) => { + sceneRef.current = scene; + const spots = HANGZHOU_ROUTES[0].spots; + setTimeout(() => { + const lngs = spots.map((s) => s.lng); + const lats = spots.map((s) => s.lat); + const pad = 0.06; + scene.fitBounds( + [ + [Math.min(...lngs) - pad, Math.min(...lats) - pad], + [Math.max(...lngs) + pad, Math.max(...lats) + pad], + ], + { padding: [80, 40, CARD_COLLAPSED_H + 20, 40] }, + ); + }, 400); + }, []); + + const handleDayChange = useCallback( + (dayIndex: number) => { + setActiveDay(dayIndex); + setSelectedSpot(null); + const spots = HANGZHOU_ROUTES[dayIndex].spots; + const scene = sceneRef.current; + if (!scene) return; + const lngs = spots.map((s) => s.lng); + const lats = spots.map((s) => s.lat); + const pad = 0.06; + scene.fitBounds( + [ + [Math.min(...lngs) - pad, Math.min(...lats) - pad], + [Math.max(...lngs) + pad, Math.max(...lats) + pad], + ], + { padding: [80, 40, CARD_COLLAPSED_H + 20, 40], animate: true }, + ); + }, + [], + ); + + const handleSpotClick = useCallback((spot: Spot) => { + setSelectedSpot((prev) => (prev?.id === spot.id ? null : spot)); + const scene = sceneRef.current; + if (!scene) return; + scene.setZoomAndCenter(14, [spot.lng, spot.lat]); + }, []); + + const handleLocate = useCallback(() => { + const scene = sceneRef.current; + if (!scene) return; + scene.setZoomAndCenter(14, [120.145, 30.26]); + }, []); + + // Detect sheet expansion from DOM + useEffect(() => { + const sheet = document.getElementById('travel-bottom-sheet'); + if (!sheet) return; + const observer = new MutationObserver(() => { + const currentTransform = sheet.style.transform; + setSheetExpanded(currentTransform.includes('translateY(5%)')); + }); + observer.observe(sheet, { attributes: true, attributeFilter: ['style'] }); + return () => observer.disconnect(); + }, []); + + return ( +
+ {/* ── Map ── */} + + {/* Route Layer */} + { + const stopId = typeof p.feature?.id === 'string' ? p.feature.id : undefined; + const spot = route.spots.find((s) => s.id === stopId); + if (spot) handleSpotClick(spot); + }} + color={routeColor} + lineWidth={3} + stopSize={10} + stopRenderer="icon" + stopIconSize={24} + stopColor={routeColor} + showStopIndex={false} + /> + + {/* West Lake Pulse Marker */} + +
+
+
+ + water_lux + +
+
+ + 西湖景区 + +
+
+ + + {/* Lingyin Temple Marker */} + +
+
+ + temple_buddhist + +
+
+ + 灵隐寺 + +
+
+
+ + {/* Xixi Wetland Marker */} + +
+
+ + park + +
+
+ + 西溪湿地 + +
+
+
+ + + {/* ── UI Overlays ── */} + + + + + {/* ── Bottom Sheet (with id for observer) ── */} +
+ +
+ + {/* ── Bottom Nav ── */} + + + {/* ── Popup ── */} + {selectedSpot && ( + setSelectedSpot(null)} + header={{ + title: selectedSpot.name, + statusLabel: selectedSpot.duration, + statusColor: routeColor, + }} + attributes={[ + { + label: '路线', + value: `Day${route.day} · ${route.theme}`, + }, + { label: '贴士', value: selectedSpot.description }, + ]} + actions={[ + { + variant: 'primary' as const, + label: '导航到这里', + onClick: () => {}, + }, + { + variant: 'secondary' as const, + label: '查看详情', + onClick: () => {}, + }, + ]} + /> + )} + + {/* ── CSS Keyframes for pulse animation ── */} + +
+ ); +} \ No newline at end of file diff --git a/packages/site/src/app/InterestMap.tsx b/packages/site/src/app/InterestMap.tsx index d036ba1..eaaf1ba 100644 --- a/packages/site/src/app/InterestMap.tsx +++ b/packages/site/src/app/InterestMap.tsx @@ -390,7 +390,7 @@ export default function InterestMap() { position: 'absolute', bottom: selectedShop ? 220 : 20, right: 12, - zIndex: 20, + zIndex: 1000, display: 'flex', flexDirection: 'column', gap: 10, @@ -409,7 +409,7 @@ export default function InterestMap() { bottom: 0, left: 0, right: 0, - zIndex: 20, + zIndex: 1000, }} > @@ -586,7 +586,7 @@ function IPSelectPanel({ const filteredIPs = categoryTab === '全部' ? IP_LIST : IP_LIST.filter((ip) => ip.category === categoryTab); return ( -
+
{/* 遮罩 */}
@@ -711,7 +711,7 @@ function NewsTypePanel({ const subOptions = NEWS_SUB_MAP[selectedCategory] || ['全部']; return ( -
+
{/* 遮罩 */}
diff --git a/packages/site/src/app/MobileApp.tsx b/packages/site/src/app/MobileApp.tsx index 28fa3ff..a63aa28 100644 --- a/packages/site/src/app/MobileApp.tsx +++ b/packages/site/src/app/MobileApp.tsx @@ -1,528 +1,303 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { Scene } from '@antv/l7'; -import { AiMap, RouteLayer, ZoomControl, GeoLocateControl, BottomSheet } from '@antv/aimapui'; -import type { BottomSheetSnap, LayerEventPayload } from '@antv/aimapui'; +import { AiMap, RouteLayer, Popup } from '@antv/aimapui'; + /* ================================================================ - 杭州 3 日游旅游攻略 — 多日路线数据 + 杭州 3 日游旅游攻略 — Google Maps Mobile 设计规范 + Design Tokens: Primary #1A73E8, Surface #FFF ================================================================ */ +// ── Google Maps Design Tokens ─────────────────────────────────── +const T = { + primary: '#1A73E8', secondary: '#0B57D0', + highlightBg: '#D3E3FD', highlightBgAlt: '#E8F0FE', + surface: '#FFFFFF', + textPrimary: '#1C1B1F', textSecondary: '#5E5E5E', + textTertiary: '#707070', textQuaternary: '#867F7F', + borderLight: '#F3F2F2', borderMedium: '#D9D9D9', + shadowLight: '0px 1px 2px rgba(0,0,0,0.25)', + shadowMedium: '0px 4px 4px rgba(0,0,0,0.25)', + shadowHeavy: '0px 4px 12px rgba(0,0,0,0.25)', + tabInactive: '#7F7F7F', +}; + +// ── Data ──────────────────────────────────────────────────────── interface Spot { - id: string; - name: string; - lng: number; - lat: number; - icon: string; - duration: string; - tips: string; - image: string; + id: string; name: string; lng: number; lat: number; icon: string; + duration: string; tips: string; image: string; } - interface DayRoute { - day: number; - title: string; - theme: string; - color: string; - spots: Spot[]; + day: number; title: string; theme: string; color: string; spots: Spot[]; } const TRAVEL_ROUTES: DayRoute[] = [ - { - day: 1, - title: '西湖经典环游', - theme: '湖光山色 · 文化之旅', - color: '#2563eb', - spots: [ - { id: 'd1-1', name: '断桥残雪', lng: 120.155, lat: 30.261, icon: 'landscape', duration: '40min', tips: '清晨人少,推荐日出前到达', image: 'https://gw.alipayobjects.com/zos/rmsportal/NeUTMwKtPcPxIFNTWZOZ.png' }, - { id: 'd1-2', name: '白堤', lng: 120.152, lat: 30.257, icon: 'directions_walk', duration: '30min', tips: '沿白堤漫步至孤山,两侧桃柳交替', image: 'https://gw.alipayobjects.com/zos/rmsportal/cjHbStmRxBSLsOiZWJql.png' }, - { id: 'd1-3', name: '苏堤春晓', lng: 120.14, lat: 30.245, icon: 'nature', duration: '1h', tips: '骑行通过六桥,感受西湖全景', image: 'https://gw.alipayobjects.com/zos/rmsportal/siCrBXXhmvTQGWPNLBow.png' }, - { id: 'd1-4', name: '雷峰塔', lng: 120.149, lat: 30.232, icon: 'temple_buddhist', duration: '1.5h', tips: '登塔俯瞰西湖,黄昏时分最佳', image: 'https://gw.alipayobjects.com/zos/rmsportal/IhosLpSUSQEHbuMxCbvr.png' }, - { id: 'd1-5', name: '河坊街', lng: 120.17, lat: 30.245, icon: 'restaurant', duration: '2h', tips: '晚餐推荐知味观,叫化鸡必点', image: 'https://gw.alipayobjects.com/zos/rmsportal/mqaQJPlMSJALyixnfogy.png' }, - ], - }, - { - day: 2, - title: '灵隐禅意之旅', - theme: '山林古刹 · 茶韵悠然', - color: '#10b981', - spots: [ - { id: 'd2-1', name: '灵隐寺', lng: 120.1, lat: 30.24, icon: 'temple_buddhist', duration: '2h', tips: '上午去人少,飞来峰石窟值得细看', image: 'https://gw.alipayobjects.com/zos/rmsportal/psUFoAMjkCcjqtUCNPSB.png' }, - { id: 'd2-2', name: '北高峰', lng: 120.095, lat: 30.248, icon: 'hiking', duration: '1.5h', tips: '灵隐后山登顶,可选缆车', image: 'https://gw.alipayobjects.com/zos/rmsportal/RmczdrAcbEOqLmHinhKB.png' }, - { id: 'd2-3', name: '龙井村', lng: 120.115, lat: 30.225, icon: 'local_cafe', duration: '2h', tips: '品正宗西湖龙井,春季可体验采茶', image: 'https://gw.alipayobjects.com/zos/rmsportal/MXXetJAxlqrbisIuZxDO.png' }, - { id: 'd2-4', name: '九溪烟树', lng: 120.115, lat: 30.205, icon: 'forest', duration: '1.5h', tips: '溪水潺潺,适合徒步纳凉', image: 'https://gw.alipayobjects.com/zos/rmsportal/ZBfODzMWLaEHkFEfJQmp.png' }, - { id: 'd2-5', name: '宋城', lng: 120.115, lat: 30.18, icon: 'theater_comedy', duration: '3h', tips: '《宋城千古情》演出 17:30 场最佳', image: 'https://gw.alipayobjects.com/zos/rmsportal/bFJWCjZnNxMdcDCvAtmC.png' }, - ], - }, - { - day: 3, - title: '运河与现代杭州', - theme: '古运河 · 科技城 · 美食', - color: '#f59e0b', - spots: [ - { id: 'd3-1', name: '京杭大运河', lng: 120.13, lat: 30.32, icon: 'directions_boat', duration: '1.5h', tips: '水上巴士体验运河风光,从武林门出发', image: 'https://gw.alipayobjects.com/zos/rmsportal/komGVRUxIAMorSZflTvl.png' }, - { id: 'd3-2', name: '拱宸桥', lng: 120.12, lat: 30.325, icon: 'museum', duration: '1h', tips: '参观运河博物馆,了解漕运历史', image: 'https://gw.alipayobjects.com/zos/rmsportal/zlYzVCeQrTXRqpHXSfUr.png' }, - { id: 'd3-3', name: '西溪湿地', lng: 120.06, lat: 30.27, icon: 'park', duration: '3h', tips: '电瓶船深入湿地,看白鹭翩飞', image: 'https://gw.alipayobjects.com/zos/rmsportal/JiqGstEfoWAOHiTxclqi.png' }, - { id: 'd3-4', name: '天目里', lng: 120.08, lat: 30.285, icon: 'shopping_bag', duration: '2h', tips: '隈研吾设计的文艺综合体,茑屋书店打卡', image: 'https://gw.alipayobjects.com/zos/rmsportal/nxkuOJlFJuAUhzlhEQIn.png' }, - { id: 'd3-5', name: '武林夜市', lng: 120.165, lat: 30.275, icon: 'nightlife', duration: '2h', tips: '杭帮菜小吃一条街,片儿川必吃', image: 'https://gw.alipayobjects.com/zos/rmsportal/PvjLTFaqIVHCOacTfknB.png' }, - ], - }, + { day: 1, title: '西湖经典环游', theme: '湖光山色 · 文化之旅', color: '#2563eb', spots: [ + { id: 'd1-1', name: '断桥残雪', lng: 120.155, lat: 30.261, icon: 'landscape', duration: '40min', tips: '清晨人少,推荐日出前到达', image: 'https://gw.alipayobjects.com/zos/rmsportal/NeUTMwKtPcPxIFNTWZOZ.png' }, + { id: 'd1-2', name: '白堤', lng: 120.152, lat: 30.257, icon: 'directions_walk', duration: '30min', tips: '沿白堤漫步至孤山,两侧桃柳交替', image: 'https://gw.alipayobjects.com/zos/rmsportal/cjHbStmRxBSLsOiZWJql.png' }, + { id: 'd1-3', name: '苏堤春晓', lng: 120.14, lat: 30.245, icon: 'nature', duration: '1h', tips: '骑行通过六桥,感受西湖全景', image: 'https://gw.alipayobjects.com/zos/rmsportal/siCrBXXhmvTQGWPNLBow.png' }, + { id: 'd1-4', name: '雷峰塔', lng: 120.149, lat: 30.232, icon: 'temple_buddhist', duration: '1.5h', tips: '登塔俯瞰西湖,黄昏时分最佳', image: 'https://gw.alipayobjects.com/zos/rmsportal/IhosLpSUSQEHbuMxCbvr.png' }, + { id: 'd1-5', name: '河坊街', lng: 120.17, lat: 30.245, icon: 'restaurant', duration: '2h', tips: '晚餐推荐知味观,叫化鸡必点', image: 'https://gw.alipayobjects.com/zos/rmsportal/mqaQJPlMSJALyixnfogy.png' }, + ]}, + { day: 2, title: '灵隐禅意之旅', theme: '山林古刹 · 茶韵悠然', color: '#10b981', spots: [ + { id: 'd2-1', name: '灵隐寺', lng: 120.1, lat: 30.24, icon: 'temple_buddhist', duration: '2h', tips: '上午去人少,飞来峰石窟值得细看', image: 'https://gw.alipayobjects.com/zos/rmsportal/psUFoAMjkCcjqtUCNPSB.png' }, + { id: 'd2-2', name: '北高峰', lng: 120.095, lat: 30.248, icon: 'hiking', duration: '1.5h', tips: '灵隐后山登顶,可选缆车', image: 'https://gw.alipayobjects.com/zos/rmsportal/RmczdrAcbEOqLmHinhKB.png' }, + { id: 'd2-3', name: '龙井村', lng: 120.115, lat: 30.225, icon: 'local_cafe', duration: '2h', tips: '品正宗西湖龙井,春季可体验采茶', image: 'https://gw.alipayobjects.com/zos/rmsportal/MXXetJAxlqrbisIuZxDO.png' }, + { id: 'd2-4', name: '九溪烟树', lng: 120.115, lat: 30.205, icon: 'forest', duration: '1.5h', tips: '溪水潺潺,适合徒步纳凉', image: 'https://gw.alipayobjects.com/zos/rmsportal/ZBfODzMWLaEHkFEfJQmp.png' }, + { id: 'd2-5', name: '宋城', lng: 120.115, lat: 30.18, icon: 'theater_comedy', duration: '3h', tips: '《宋城千古情》演出 17:30 场最佳', image: 'https://gw.alipayobjects.com/zos/rmsportal/bFJWCjZnNxMdcDCvAtmC.png' }, + ]}, + { day: 3, title: '运河与现代杭州', theme: '古运河 · 科技城 · 美食', color: '#f59e0b', spots: [ + { id: 'd3-1', name: '京杭大运河', lng: 120.13, lat: 30.32, icon: 'directions_boat', duration: '1.5h', tips: '水上巴士体验运河风光,从武林门出发', image: 'https://gw.alipayobjects.com/zos/rmsportal/komGVRUxIAMorSZflTvl.png' }, + { id: 'd3-2', name: '拱宸桥', lng: 120.12, lat: 30.325, icon: 'museum', duration: '1h', tips: '参观运河博物馆,了解漕运历史', image: 'https://gw.alipayobjects.com/zos/rmsportal/zlYzVCeQrTXRqpHXSfUr.png' }, + { id: 'd3-3', name: '西溪湿地', lng: 120.06, lat: 30.27, icon: 'park', duration: '3h', tips: '电瓶船深入湿地,看白鹭翩飞', image: 'https://gw.alipayobjects.com/zos/rmsportal/JiqGstEfoWAOHiTxclqi.png' }, + { id: 'd3-4', name: '天目里', lng: 120.08, lat: 30.285, icon: 'shopping_bag', duration: '2h', tips: '隈研吾设计的文艺综合体,茑屋书店打卡', image: 'https://gw.alipayobjects.com/zos/rmsportal/nxkuOJlFJuAUhzlhEQIn.png' }, + { id: 'd3-5', name: '武林夜市', lng: 120.165, lat: 30.275, icon: 'nightlife', duration: '2h', tips: '杭帮菜小吃一条街,片儿川必吃', image: 'https://gw.alipayobjects.com/zos/rmsportal/PvjLTFaqIVHCOacTfknB.png' }, + ]}, ]; -/** 将景点数组转换为 RouteLayer 的 path 坐标数组 */ function spotsToPath(spots: Spot[]): [number, number][] { - return spots.map((spot) => [spot.lng, spot.lat]); + return spots.map(s => [s.lng, s.lat]); } - -/** 将景点数组转换为 RouteLayer 的 stops 途经点数组 */ function spotsToStops(spots: Spot[]) { - return spots.map((spot) => ({ - id: spot.id, - lng: spot.lng, - lat: spot.lat, - name: spot.name, - icon: spot.icon, - })); + return spots.map(s => ({ id: s.id, lng: s.lng, lat: s.lat, name: s.name, icon: s.icon })); } -/** - * 旅游攻略地图 Demo(移动端) - * - * - 顶部:行程标题 + 多日标签切换 - * - 地图:展示当日路线(LineLayer)+ 景点标注(Marker) - * - 底部:BottomSheet 抽屉展示景点卡片列表 - */ -export default function MobileApp() { - const [activeDay, setActiveDay] = useState(0); - const [selectedSpot, setSelectedSpot] = useState(null); - const [bottomSheetSnap, setBottomSheetSnap] = useState('collapsed'); - const sceneRef = useRef(null); +const CARD_COLLAPSED_H = 104; +const CARD_EXPANDED_H = 458; - const currentRoute = TRAVEL_ROUTES[activeDay]; +// ── Custom Zoom Buttons (Google Maps style) ───────────────────── +function ZoomButtons({ scene }: { scene: Scene | null }) { + const [zoom, setZoom] = useState(12); + useEffect(() => { + if (!scene) return; + const onZoom = () => setZoom(scene.getZoom()); + scene.on('zoomend', onZoom); + return () => { scene.off('zoomend', onZoom); }; + }, [scene]); + return ( +
+ +
+ +
+ ); +} - /** 根据景点坐标计算包围盒并缩放到合适视图 */ - const fitRouteBounds = useCallback((spots: Spot[], animate = true) => { - const scene = sceneRef.current; - if (!scene || spots.length === 0) return; +// ── Bottom Card per Google Maps spec ──────────────────────────── +function BottomCard({ + routes, activeDay, selectedSpot, onDayChange, onSpotSelect, +}: { + routes: DayRoute[]; activeDay: number; selectedSpot: Spot | null; + onDayChange: (d: number) => void; onSpotSelect: (s: Spot) => void; +}) { + const [expanded, setExpanded] = useState(false); + const route = routes[activeDay]; - const lngs = spots.map((s) => s.lng); - const lats = spots.map((s) => s.lat); - const minLng = Math.min(...lngs); - const maxLng = Math.max(...lngs); - const minLat = Math.min(...lats); - const maxLat = Math.max(...lats); + return ( +
+ {/* Handle */} +
setExpanded(!expanded)} style={{ display: 'flex', justifyContent: 'center', cursor: 'pointer', flexShrink: 0 }}> +
+
- // 中心点(含 UI 偏移:为底部 BottomSheet 向上偏移) - const avgLng = (minLng + maxLng) / 2; - const avgLat = (minLat + maxLat) / 2; + {/* Header */} +
+
+ route +
+
+
Day{route.day} · {route.title}
+
{route.theme} · {route.spots.length} 个景点
+
+
- // 根据 Web Mercator 投影计算合适的 zoom 级别 - // zoom z 时视口显示 H * 360 * cos(φ) / (256 * 2^z) 纬度度数 - // 要求点位跨度占据视口的一定比例(留出 UI 空间) - const lngSpan = maxLng - minLng || 0.001; - const latSpan = maxLat - minLat || 0.001; - const mapHeight = window.innerHeight || 667; - const mapWidth = window.innerWidth || 375; - const fractionH = 0.4; // 点位占视口高度 40%(留出 BottomSheet 和顶部 UI 空间) - const fractionW = 0.7; // 点位占视口宽度 70% - const latAtCenter = avgLat * Math.PI / 180; - const zLat = Math.log2(fractionH * mapHeight * 360 * Math.cos(latAtCenter) / (256 * latSpan)); - const zLng = Math.log2(fractionW * mapWidth * 360 / (256 * lngSpan)); - const zoom = Math.max(3, Math.min(18, Math.floor(Math.min(zLat, zLng)))); + {/* Day tabs — Google Maps pill style */} +
+ {routes.map((r, i) => { + const isActive = i === activeDay; + return ( + + ); + })} +
- // 向上偏移中心点(为底部 BottomSheet 留空间,约偏移可视高度的 20%) - const metersPerPixel = 156543.03392 * Math.cos(latAtCenter) / Math.pow(2, zoom); - const pixelOffsetY = -80; // 向上 80px - const latOffset = pixelOffsetY * metersPerPixel / 110540; + {/* Spot timeline list */} +
+ {route.spots.map((spot, i) => { + const isSel = selectedSpot?.id === spot.id; + const isLast = i === route.spots.length - 1; + return ( +
+ {/* Timeline: number + line */} +
+
{i + 1}
+ {!isLast &&
} +
+ {/* Card */} +
onSpotSelect(spot)} + style={{ + flex: 1, display: 'flex', gap: 10, padding: 12, + borderRadius: 12, cursor: 'pointer', marginBottom: 8, + background: isSel ? T.highlightBgAlt : T.surface, + border: isSel ? `1.5px solid ${route.color}` : `1px solid ${T.borderLight}`, + transition: 'all 0.15s', + }}> +
+ {spot.name} +
+
+
+ {spot.name} + + schedule + {spot.duration} + +
+
+ {spot.tips} +
+
+ chevron_right +
+
+ ); + })} +
+
+ ); +} - try { - scene.setZoomAndCenter(zoom, [avgLng, avgLat + latOffset]); - } catch { - try { scene.setCenter([avgLng, avgLat + latOffset]); scene.setZoom(zoom); } catch { /* ignore */ } - } - }, []); +// ── Main ──────────────────────────────────────────────────────── +export default function MobileApp() { + const [activeDay, setActiveDay] = useState(0); + const [selectedSpot, setSelectedSpot] = useState(null); + const sceneRef = useRef(null); + const route = TRAVEL_ROUTES[activeDay]; - /** 场景就绪回调 */ const handleSceneReady = useCallback((scene: Scene) => { sceneRef.current = scene; - // 初始加载时适配第一天路线 - setTimeout(() => fitRouteBounds(TRAVEL_ROUTES[0].spots, false), 300); - }, [fitRouteBounds]); - - const handleSpotClick = useCallback((spot: Spot) => { - setSelectedSpot(spot); + const spots = TRAVEL_ROUTES[0].spots; + setTimeout(() => { + const lngs = spots.map(s => s.lng), lats = spots.map(s => s.lat); + const pad = 0.05; + scene.fitBounds( + [[Math.min(...lngs) - pad, Math.min(...lats) - pad], [Math.max(...lngs) + pad, Math.max(...lats) + pad]], + { padding: [80, 40, CARD_COLLAPSED_H + 20, 40] }, + ); + }, 400); }, []); - const handleStopClick = useCallback((payload: LayerEventPayload) => { - const stopId = typeof payload.feature?.id === 'string' ? payload.feature.id : undefined; - const stopName = typeof payload.feature?.name === 'string' ? payload.feature.name : undefined; - const spot = currentRoute.spots.find((item) => item.id === stopId) - ?? currentRoute.spots.find((item) => item.name === stopName); - - if (spot) { - handleSpotClick(spot); - } - }, [currentRoute.spots, handleSpotClick]); - const handleDayChange = useCallback((dayIndex: number) => { setActiveDay(dayIndex); setSelectedSpot(null); - // 切换路线时,fitBounds 到新路线的景点范围 - fitRouteBounds(TRAVEL_ROUTES[dayIndex].spots, true); - }, [fitRouteBounds]); + const spots = TRAVEL_ROUTES[dayIndex].spots; + const scene = sceneRef.current; + if (!scene) return; + const lngs = spots.map(s => s.lng), lats = spots.map(s => s.lat); + const pad = 0.05; + scene.fitBounds( + [[Math.min(...lngs) - pad, Math.min(...lats) - pad], [Math.max(...lngs) + pad, Math.max(...lats) + pad]], + { padding: [80, 40, CARD_COLLAPSED_H + 20, 40], animate: true }, + ); + }, []); + + const handleSpotClick = useCallback((spot: Spot) => { + setSelectedSpot(prev => prev?.id === spot.id ? null : spot); + const scene = sceneRef.current; + if (!scene) return; + scene.setZoomAndCenter(14, [spot.lng, spot.lat]); + }, []); return ( -
- {/* 全屏地图 */} +
+ {/* Map */}
- - - - - {/* 当日路线(路径图层) */} + { + const stopId = typeof p.feature?.id === 'string' ? p.feature.id : undefined; + const spot = route.spots.find(s => s.id === stopId); + if (spot) handleSpotClick(spot); + }} + color={route.color} lineWidth={3} - glow stopSize={10} stopRenderer="icon" stopIconSize={24} - stopIconColor={currentRoute.color} + stopColor={route.color} showStopIndex={false} />
- {/* UI 覆盖层 */} -
- {/* 顶部:行程标题 + 日期切换 */} -
- {/* 标题栏 */} -
-
- - travel_explore - - - 杭州 3 日游攻略 - - - {TRAVEL_ROUTES.reduce((sum, route) => sum + route.spots.length, 0)} 个景点 - -
-
- - {/* 日期切换标签 */} -
- {TRAVEL_ROUTES.map((route, index) => { - const isActive = index === activeDay; - return ( - - ); - })} -
-
- - {/* 选中景点的浮动卡片 */} - {selectedSpot && ( -
-
-
- {/* 景点图片 */} -
- {selectedSpot.name} -
-
-
-

- {selectedSpot.name} -

- - {selectedSpot.duration} - -
-

- 💡 {selectedSpot.tips} -

-
- -
-
-
- )} - - {/* 底部抽屉:路线详情 */} - - {/* 抽屉标题 */} -
-
-

- Day{currentRoute.day} · {currentRoute.title} -

-

- {currentRoute.theme} · {currentRoute.spots.length} 个景点 -

-
-
- - route - -
-
- - {/* 景点时间轴列表 */} -
- {currentRoute.spots.map((spot, index) => { - const isSelected = selectedSpot?.id === spot.id; - const isLast = index === currentRoute.spots.length - 1; + {/* Right FAB column */} +
+ + +
- return ( -
- {/* 时间轴线 */} -
- {/* 序号圆点 */} -
- {index + 1} -
- {/* 连接线 */} - {!isLast && ( -
- )} -
+ {/* Bottom Card */} + - {/* 景点卡片 */} -
handleSpotClick(spot)} - style={{ - flex: 1, - padding: 14, - borderRadius: 16, - border: isSelected ? `2px solid ${currentRoute.color}` : '1px solid rgba(195, 198, 215, 0.25)', - background: isSelected ? `${currentRoute.color}08` : 'rgba(248, 249, 255, 0.6)', - cursor: 'pointer', - transition: 'all 0.2s ease', - marginBottom: 10, - }} - > -
-
- {spot.name} -
-
-
-

- {spot.name} -

- - schedule - {spot.duration} - -
-

- {spot.tips} -

-
- - chevron_right - -
-
-
- ); - })} -
- -
+ {/* Popup on selected spot */} + {selectedSpot && ( + setSelectedSpot(null)} + header={{ title: selectedSpot.name, statusLabel: selectedSpot.duration, statusColor: route.color }} + attributes={[ + { label: '路线', value: `Day${route.day} · ${route.title}` }, + { label: '贴士', value: selectedSpot.tips }, + ]} + actions={[ + { variant: 'primary' as const, label: '导航到这里', onClick: () => {} }, + { variant: 'secondary' as const, label: '查看详情', onClick: () => {} }, + ]} + /> + )}
); -} +} \ No newline at end of file diff --git a/packages/site/src/app/PcApp.tsx b/packages/site/src/app/PcApp.tsx index 5cb6430..b2afeb5 100644 --- a/packages/site/src/app/PcApp.tsx +++ b/packages/site/src/app/PcApp.tsx @@ -508,7 +508,7 @@ export default function PcApp() { top: 80, left: sidebarWidth, right: 0, - zIndex: 800, + zIndex: 1000, display: 'flex', justifyContent: 'center', pointerEvents: 'none', diff --git a/packages/site/src/app/SearchBarDemo.tsx b/packages/site/src/app/SearchBarDemo.tsx index 4740c56..e14092c 100644 --- a/packages/site/src/app/SearchBarDemo.tsx +++ b/packages/site/src/app/SearchBarDemo.tsx @@ -26,7 +26,7 @@ export default function SearchBarDemo() { top: 16, left: 16, right: 16, - zIndex: 10, + zIndex: 1000, }} > +
{label}
+
{value}
+
+ ); +} \ No newline at end of file diff --git a/packages/site/src/app/typhoon/LegendPanel.tsx b/packages/site/src/app/typhoon/LegendPanel.tsx new file mode 100644 index 0000000..5318879 --- /dev/null +++ b/packages/site/src/app/typhoon/LegendPanel.tsx @@ -0,0 +1,129 @@ +/* ================================================================ + 图例面板 + 含风圈开关、登陆点、预报台、台风等级、风圈等级、警戒线、 + 降雨等级、雷达反射率 + ================================================================ */ + +import { LegendCategories } from '@antv/aimapui'; +import type { LandPoint } from './types'; +import { + C, GRADE_LABELS, GRADE_COLORS, WIND_LEVEL_KEY, WIND_LEVEL_COLOR, + AGENCIES, AGENCY_COLOR, WARNING_LINE_COLORS, +} from './constants'; + +interface LegendPanelProps { + showWindCircles: boolean; + legendExpanded: boolean; + landMarks: LandPoint[]; + isMobile: boolean; + onToggleLegend: () => void; + onToggleWindCircles: () => void; +} + +export default function LegendPanel({ + showWindCircles, legendExpanded, landMarks, isMobile, + onToggleLegend, onToggleWindCircles, +}: LegendPanelProps) { + return ( +
+
+ {isMobile && !legendExpanded ? '' : '图例'} + {isMobile ? 'chevron_left' : 'expand_more'} +
+ {legendExpanded && ( + <> + {/* 风圈开关 */} +
+ 风圈 + +
+ {/* 登陆点 */} + {landMarks.length > 0 && ( +
+ + 登陆点 +
+ )} + {/* 预报台 */} +
+
预报台
+
+ {AGENCIES.map(a => ( +
+
+ {a} +
+ ))} +
+
+ {/* 台风等级 */} +
+ +
+ {/* 风圈等级 */} +
+
风圈等级
+
+ {WIND_LEVEL_KEY.map(k => ( +
+
+ {k} 级风圈 +
+ ))} +
+
+ {/* 警戒线 */} +
+
警戒线
+
+
+
+ 24小时警戒线 +
+
+
+ 48小时警戒线 +
+
+
+ {/* 降雨等级 */} +
+
降雨等级
+
+ {[ + { label: '0-10', color: '#a5d6a7' }, { label: '10-25', color: '#66bb6a' }, + { label: '25-50', color: '#42a5f5' }, { label: '50-100', color: '#1e88e5' }, + { label: '100-250', color: '#e040fb' }, { label: '>250', color: '#c62828' }, + ].map(r => ( +
+
+ {r.label} +
+ ))} +
+
+ {/* 雷达反射率 */} +
+
雷达反射率 dBZ
+
+ {['#00bcd4', '#4caf50', '#8bc34a', '#cddc39', '#ffeb3b', '#ffc107', '#ff9800', '#ff5722', '#f44336', '#e91e63', '#9c27b0', '#673ab7'].map((c, i) => ( +
+ ))} +
+
+ 10 + 70 +
+
+ + )} +
+ ); +} \ No newline at end of file diff --git a/packages/site/src/app/typhoon/TECHNICAL_DOC.md b/packages/site/src/app/typhoon/TECHNICAL_DOC.md new file mode 100644 index 0000000..67ca5cb --- /dev/null +++ b/packages/site/src/app/typhoon/TECHNICAL_DOC.md @@ -0,0 +1,814 @@ +# 台风路径地图(TyphoonMap)— 技术文档 + +> 本文档供 AI Agent 阅读,目标:根据本文档可完全还原 TyphoonMap 组件的全部功能,无需查看源码。 + +--- + +## 一、项目概览 + +### 1.1 功能列表 + +| 编号 | 功能 | 描述 | +|------|------|------| +| F01 | 台风列表加载 | 拉取当年所有台风列表,标注活跃台风 | +| F02 | 台风详情加载 | 选中台风后加载其完整路径点数据 | +| F03 | 历史轨迹 | 按台风等级分段着色显示历史路径(实线) | +| F04 | 路径节点 | 路径上的每个观测点以小圆点标注,hover 显示 Tooltip,click 弹出 Popup | +| F05 | 台风眼动画 | 当前台风中心显示旋转 GIF 动画 | +| F06 | 风圈 | 显示 7/10/12 级四象限风圈(填充 + 描边 + 文字标注)| +| F07 | 预报路径 | 多家机构(中国/日本/美国/中国台湾/中国香港)预报路径同图显示 | +| F08 | 机构切换 | 选中某机构高亮其预报路径,其余机构半透明虚线 | +| F09 | 预报日期标注 | 中国机构预报路径上每天标一个日期(MM/DD)| +| F10 | 警戒线 | 显示 24/48 小时台风警戒线(固定坐标虚线 + 文字标注)| +| F11 | 登陆点标注 | 显示台风登陆点图标 | +| F12 | 气象图层 | 支持云图/雷达/降雨/卫星底图四种叠加层切换 | +| F13 | 图层透明度 | 气象图层透明度滑块调节 | +| F14 | 云图时段 | 云图支持 30min/1h/3h/6h 四档切换 | +| F15 | 降雨时段 | 降雨支持 24h/48h/72h 三档切换 | +| F16 | 台风信息卡 | PC 端显示当前台风基本信息卡片 | +| F17 | 台风列表选择器 | 可展开/收起的台风列表,点击切换台风 | +| F18 | 点位详情表 | 选中台风后可展开所有路径点的时间/强度/风速/气压表格 | +| F19 | 图例面板 | 可折叠图例:风圈开关、登陆点、预报台、台风等级、风圈等级、警戒线、降雨等级、雷达反射率 | +| F20 | 历史点选择 | 点击路径节点可选中历史点,风圈会随之变化,底部显示历史点提示条 | +| F21 | 地图自适应 | 切换台风时自动 fitBounds 到台风路径范围 | +| F22 | 移动端适配 | 支持 mobilePreview prop + useResponsive() 响应式断点 | +| F23 | 错误兜底 | API 失败时使用内置样本数据,顶部显示"使用内置样本"标签 | + +### 1.2 数据源 + +- **台风数据**: 浙江水利台风实时发布系统 `https://typhoon.slt.zj.gov.cn/Api/` + - `API_ACTIVITY`: 获取活跃台风列表 + - `API_LIST(year)`: 获取某年全部台风列表 + - `API_INFO(tfid)`: 获取单个台风详细路径数据(含预报) +- **气象数据**: 同域 API + - `API_LEASTCLOUD(type)`: 云图(0.5h/1h/3h/6h) + - `API_LASTRADAR`: 雷达拼图 + - `API_LEASTRAIN(hours)`: 降雨等值线(24h/48h/72h) + - `API_LASTWIND`: GRIB2 风场数据 + +### 1.3 技术栈 + +- React 18+ (functional components + hooks) +- `@antv/aimapui`: AiMap, LineLayer, PointLayer, FillLayer, ImageLayer, Marker, Tooltip, Popup, SatelliteLayer, ZoomControl, MapThemeControl, LegendCategories, useResponsive +- `@antv/l7`: 底层地图引擎 Scene 类型 +- 高德底图 (basemap: 'gaode') + +--- + +## 二、文件结构 + +``` +packages/site/src/app/typhoon/ +├── index.ts # 统一导出入口 +├── constants.ts # 所有常量、配色、API URL、兜底数据 +├── types.ts # 所有 TS 接口定义 +├── api.ts # 气象 API 获取函数 +├── transform.ts # 数据转换工具函数 +├── TyphoonEye.tsx # 台风眼动画组件(未使用,保留备用) +├── Info.tsx # 信息行列组件 +├── TyphoonMap.tsx # 主组件(组装 hooks + 子组件) +├── TyphoonMapLayers.tsx # 地图内所有图层的子组件 +├── TyphoonInfoCard.tsx # 台风信息卡 + 列表选择器 +├── WeatherToolbar.tsx # 气象图层工具条 +├── LegendPanel.tsx # 图例面板 +└── hooks/ + ├── useTyphoonData.ts # 台风数据获取 + 派生计算 + ├── useWeatherData.ts # 气象图层状态管理 + └── useTyphoonPopup.ts # Popup/Tooltip 交互处理 +``` + +--- + +## 三、常量 (`constants.ts`) + +### 3.1 API 端点 + +```typescript +const API_BASE = 'https://typhoon.slt.zj.gov.cn/Api' +function API_LIST(year: number): string // → /TyphoonList/{year} +function API_INFO(tfid: string): string // → /TyphoonInfo/{tfid} +API_ACTIVITY: string // → /TyhoonActivity +function API_LEASTCLOUD(type: number): string // → /LeastCloud?type={type} +API_LASTRADAR: string // → /LastRadar +function API_LEASTRAIN(hours: number): string // → /LeastRain/{hours} +API_LASTWIND: string // → /LastWind +``` + +### 3.2 台风等级系统 + +```typescript +// 等级 Key(由弱到强排序) +type GradeKey = 'TD' | 'TS' | 'STS' | 'TY' | 'STY' | 'SuperTY' + +// 中文名称 → Key 映射 +STRENGTH_TO_KEY = { + '热带低压': 'TD', + '热带风暴': 'TS', + '强热带风暴': 'STS', + '台风': 'TY', + '强台风': 'STY', + '超强台风': 'SuperTY', +} + +// Key → 颜色(渐进:蓝 → 紫 → 橙 → 红) +GRADE_COLOR = { TD:'#7dd3fc', TS:'#38bdf8', STS:'#3b82f6', TY:'#8b5cf6', STY:'#f59e0b', SuperTY:'#ef4444' } + +// 用于 LineLayer/PointLayer 的数组形式 +GRADE_ORDER = ['TD','TS','STS','TY','STY','SuperTY'] +GRADE_LABELS = ['热带低压','热带风暴','强热带风暴','台风','强台风','超强台风'] +GRADE_COLORS = ['#7dd3fc','#38bdf8','#3b82f6','#8b5cf6','#f59e0b','#ef4444'] +``` + +### 3.3 风圈配色 + +```typescript +WIND_LEVEL_KEY = ['7', '10', '12'] // 3个等级 +WIND_LEVEL_COLOR = { '7':'#3b82f6', '10':'#f59e0b', '12':'#ef4444' } +``` + +### 3.4 预报机构配色 + +```typescript +AGENCIES = ['中国', '中国台湾', '日本', '中国香港', '美国'] +AGENCY_COLOR = { + 中国:'#22d3ee', 中国台湾:'#34d399', 日本:'#f472b6', 中国香港:'#fbbf24', 美国:'#a78bfa' +} +``` + +### 3.5 警戒线 + +```typescript +// 格式 [lng, lat],GeoJSON 标准(经度在前) +WARNING_LINE_24H = [[127,34],[127,22],[119,18],[119,11],[113,4.5],[105,0]] +WARNING_LINE_48H = [[132,34],[132,15],[120,0],[105,0]] +WARNING_LINE_COLORS = { '24h':'#f59e0b', '48h':'#ef4444' } +``` + +### 3.6 降雨等级 + +```typescript +RAIN_LEVEL_LABEL = { '0':'小雨','2.5':'小雨','5':'小雨','10':'中雨','25':'大雨','50':'暴雨','100':'大暴雨','250':'特大暴雨' } + +function getRainLevelLabel(symbol: string): string +// num >= 250 → '特大暴雨', >= 100 → '大暴雨', >= 50 → '暴雨', >= 25 → '大雨', >= 10 → '中雨' else '小雨' +``` + +### 3.7 UI 颜色常量 + +```typescript +const C = { + bg: '#0f172a', // 背景色 + panel: 'rgba(15,23,42,0.82)', // 面板背景(半透明) + border: 'rgba(56,189,248,0.15)', // 面板边框 + fg: '#e2e8f0', // 前景文字 + muted: 'rgba(148,163,184,0.7)', // 次要文字 + accent: '#22d3ee', // 强调色(青色) +} +``` + +### 3.8 雷达瓦片常量 + +```typescript +// 单张大图模式边界 [westLng, southLat, eastLng, northLat] +RADAR_FULL_EXTENT = [69.85, 12.17, 140.09, 54.33] + +// 四象限 tile→extent 映射 (NW, SW, NE, SE) +RADAR_TILES = [ + { key:'radar0_0', extent:[67.5, 36.58, 104.07, 55.77] }, + { key:'radar0_1', extent:[67.5, 11.17, 104.07, 36.58] }, + { key:'radar1_0', extent:[104.07, 36.58, 140.62, 55.77] }, + { key:'radar1_1', extent:[104.07, 11.17, 140.62, 36.58] }, +] +``` + +### 3.9 兜底数据 + +```typescript +CURRENT_YEAR = new Date().getFullYear() +FALLBACK_INFO // TyphoonInfo — 台风"巴威"的完整模拟数据(6个路径点+3家机构预报) +FALLBACK_LIST // TyphoonListItem[] — 只含"巴威"一项 +``` + +--- + +## 四、类型定义 (`types.ts`) + +```typescript +// ── API 原始数据 ── +interface TyphoonPoint { + time: string // "2026-07-10 08:00:00" + lng: string // 经度,字符串格式 + lat: string // 纬度,字符串格式 + strong: string // 强度中文名:"台风"/"超强台风"等 + power: string // 风力等级:"14" + speed: string // 风速 m/s:"42" + pressure: string // 中心气压 hPa:"955" + movespeed?: string // 移速 km/h + movedirection?: string // 移向:"西北" + radius7?: string // 7级风圈四象限半径:"500|500|450|480" + radius10?: string // 10级风圈 + radius12?: string // 12级风圈 + forecast?: ForecastAgency[] // 内嵌预报(仅在最新点存在) +} + +interface ForecastAgency { + tm: string // 机构名:"中国"/"日本"/"美国"等 + forecastpoints: TyphoonPoint[] // 预报路径点 +} + +interface LandPoint { + landaddress: string + landtime: string + lng: string; lat: string + info: string + strong: string | null +} + +interface TyphoonInfo { + tfid: string // 台风编号 "202609" + name: string // 中文名 "巴威" + enname: string // 英文名 "BAVI" + isactive?: string // "1"=活跃 + starttime?: string + endtime?: string + warnlevel?: string + centerlng?: string + centerlat?: string + points: TyphoonPoint[] // 完整路径点数组 + land?: LandPoint[] +} + +interface TyphoonListItem { + tfid: string + name: string; enname: string + starttime: string; endtime: string + isactive: string // "1"=活跃 + warnlevel?: string +} + +// ── 派生数据形状 ── +interface TrackSegment { + path: [number, number][] // 单个线段(通常只有2点) + grade: GradeKey // 用于 LineLayer colorField 映射 +} +// 注意:LineLayer 使用 sourceConfig.coordinates='path',故字段名必须为 path + +interface TrackNode { + lng: number; lat: number; grade: GradeKey + time: string; strong: string; power: string; speed: string; pressure: string + index: number // 在原始 points 数组中的索引 +} + +interface WindPolygon { + coordinates: [number, number][][] // GeoJSON Polygon 坐标(单环) + level: string // '7'/'10'/'12' +} + +// ── 气象数据 ── +interface CloudData { + img: string // data:image/png;base64,... + time: string // "2026-07-11 08:00:00" + extent: [number, number, number, number] // [minLng, minLat, maxLng, maxLat] +} + +interface RadarTileData { + img: string + extent: [number, number, number, number] +} + +interface RadarData { + tiles: RadarTileData[] + time: string +} + +interface RainFeature { + type: 'Feature' + properties: { color: string; symbol: string } // color=hex色值, symbol=降水量阈值 + geometry: { type: 'Polygon'; coordinates: [number, number][][] } +} + +interface RainData { + features: RainFeature[] + colors: string[] // 去重后的色值列表(用于 FillLayer colorValues) + time: string + colorToSymbol: Record +} + +interface WindData { + imageUrl: string // RGBA PNG,R=U分量, G=V分量 + time: string + extent: [number, number, number, number] + uMin: number; uMax: number + vMin: number; vMax: number +} + +// ── UI 状态 ── +interface PopupData { + lng: number; lat: number + title: string; statusLabel: string; statusColor: string + attrs: PopupAttribute[] // 来自 @antv/aimapui +} + +interface TooltipState { + visible: boolean + lng: number; lat: number + time: string; strong: string; power: string; speed: string; pressure: string +} + +interface RainTooltipState { + visible: boolean + lng: number; lat: number + symbol: string // 降水量阈值 + color: string // hex色值 +} +``` + +--- + +## 五、数据转换 (`transform.ts`) + +### 5.1 `parseRadii(s: string | undefined | null): [number, number, number, number]` + +解析四象限半径字符串 `"NE|SE|SW|NW"` → `[ne, se, sw, nw]`(单位 km)。 +空串或无效值 → 0。 + +### 5.2 `pointGrade(p: TyphoonPoint): GradeKey` + +`STRENGTH_TO_KEY[p.strong] ?? 'TS'` — 将台风强度映射到等级 Key。 + +### 5.3 `toTrackSegments(points: TyphoonPoint[]): TrackSegment[]` + +遍历 points 相邻两点,生成 TrackSegment 数组。跳过 NaN 坐标。 +每个 segment 的 grade 取终点(b)的等级。 + +### 5.4 `toNodes(points: TyphoonPoint[]): TrackNode[]` + +将每个 TyphoonPoint 转为 TrackNode,保留原始索引。跳过 NaN 坐标。 + +### 5.5 `destinationPoint(lng, lat, distanceKm, bearingDeg): [lng, lat]` + +球面几何计算:从起点沿指定方位角走 distanceKm 到达目标点。 +使用地球半径 6371km,Haversine 公式的逆运算。 + +### 5.6 `toWindPolygons(p: TyphoonPoint | undefined): WindPolygon[]` + +为指定点生成 3 个风圈多边形(7/10/12 级)。 +每个风圈:分别取对应 level 的四个象限半径,用 `destinationPoint` 生成弧线段(12 步/象限),拼接成完整外环并闭合。 + +### 5.7 `toForecastSegments(agency: ForecastAgency | undefined): TrackSegment[]` + +同 `toTrackSegments`,但处理 ForecastAgency 的 `forecastpoints`。 + +--- + +## 六、数据获取 API (`api.ts`) + +### 6.1 `fetchCloud(type: 0.5|1|3|6): Promise` + +- GET `API_LEASTCLOUD(type)` +- 从响应中取 `cloud{typeKey}h` 作为 base64 图片,`timeStr{typeKey}h` 为时间 +- 取 `minLng/minLat/maxLng/maxLat` 作为 extent +- 异常/缺数据 → null + +### 6.2 `fetchRadar(): Promise` + +- GET `API_LASTRADAR` +- `radarType === '2'` → 单张大图,extent 用 `RADAR_FULL_EXTENT` +- 否则遍历 `RADAR_TILES` 四个象限分别取图 +- 异常/缺数据 → null + +### 6.3 `fetchRain(hours: 24|48|72): Promise` + +- GET `API_LEASTRAIN(hours)` +- 解析 `contours`(双重 JSON 编码) +- 每个等高线 { color, latAndLong, symbol } → 转换 latAndLong 中 `[lat,lng]` 为 `[lng,lat]` +- 过滤 NaN 坐标,闭合多边形环 +- 将 RGBA 颜色转换为 hex +- 返回 `{ features, colors, time, colorToSymbol }` + +### 6.4 `fetchWind(): Promise` + +- GET `API_LASTWIND` +- 解析 `windData`(GRIB2 JSON 数组:[U分量, V分量]) +- 用 `windDataToImage()` 将 U/V 数值编码为 RGBA PNG(R=U, G=V, B=0, A=255 — 归一化到 0-255) +- 返回 `{ imageUrl, time, extent }` + 固定 uMin/uMax/vMin/vMax + +--- + +## 七、Hook 详解 + +### 7.1 `useTyphoonData()` → `TyphoonDataState` + +**核心状态管理**: + +| 状态 | 类型 | 说明 | +|------|------|------| +| list | TyphoonListItem[] | 台风列表 | +| activeIds | string[] | 活跃台风 tfid 数组 | +| tfid | string | 当前选中的台风编号 | +| info | TyphoonInfo \| null | 当前台风详情 | +| loading | boolean | 列表加载中 | +| error | boolean | API 失败标记 | +| selectedAgency | string | 选中的预报机构 | +| selectedPointIdx | number | 选中的路径点索引(-1=最新点) | +| pointsDetailExpanded | boolean | 点位详情表格是否展开 | +| sceneRef | RefObject<Scene> | L7 Scene 引用 | + +**数据获取流程**: + +1. **mount 时**:fetch `API_ACTIVITY` → 获取活跃台风 IDs;fetch `API_LIST(CURRENT_YEAR)` → 获取当年全部台风。选台风逻辑:优先选中活跃台风,否则按 `starttime` 最新排序。API 失败 → 使用 `FALLBACK_LIST` / `FALLBACK_INFO`,`error=true`。 + +2. **tfid 变化时**:fetch `API_INFO(tfid)` → 解析 TyphoonInfo。失败 → 使用 `FALLBACK_INFO`。 + +3. **trackSegments 变化时**:调用 `scene.fitBounds()` 将地图缩放至台风路径范围(padding=80,经纬度 padding=4)。 + +**派生数据(useMemo)**: + +| 字段 | 派生逻辑 | +|------|----------| +| points | `info?.points ?? []` | +| trackSegments | `toTrackSegments(points)` | +| nodes | `toNodes(points)` | +| currentIdx | `points.length - 1` | +| selectedIdx | `selectedPointIdx >= 0 && < points.length ? selectedPointIdx : currentIdx` | +| selectedPoint | `points[selectedIdx]` | +| currentPoint | `points[currentIdx]` | +| windPolygons | `toWindPolygons(selectedPoint)` | +| forecastSrc | `selectedPoint?.forecast ?? currentPoint?.forecast ?? []` | +| presentAgencies | `Set(forecastSrc.map(f => f.tm))` | +| activeForecastSegs | selectedAgency 对应的机构预报段 | +| otherForecastSegs | 非 selectedAgency 的机构预报段 | +| forecastPoints | 所有预报路径点(lng/lat 已转 number) | +| landMarks | `info?.land` 中有效坐标的登陆点 | + +### 7.2 `useWeatherData()` → `WeatherDataState` + +**状态**: + +| 状态 | 类型 | 默认值 | +|------|------|--------| +| weatherLayer | 'none'\|'cloud'\|'radar'\|'rain'\|'satellite' | 'none' | +| weatherOpacity | number | 0.7 | +| cloudType | 0.5\|1\|3\|6 | 1 | +| rainHours | 24\|48\|72 | 24 | +| cloud | CloudData\|null | null | +| radar | RadarData\|null | null | +| rain | RainData\|null | null | +| satellite | boolean | false | +| satProvider | 'gaode'\|'tianditu'\|'google' | 'gaode' | +| satOpacity | number | 0.8 | +| rainTooltip | RainTooltipState | {visible:false,...} | + +**行为**: + +- `weatherLayer` 切换且对应数据为 null 时 → 自动 fetch +- `cloudType` 变化且当前在 cloud 模式 → 清空 cloud 数据(触发重新 fetch) +- `rainHours` 变化同理 +- `handleRainTooltipMove`: 从 LayerEventPayload.feature 提取 symbol/color +- `handleRainTooltipLeave`: 设置 visible=false + +### 7.3 `useTyphoonPopup(deps)` → `TyphoonPopupState` + +**参数**: `{ info: TyphoonInfo | null, selectedAgency: string }` + +**状态**: + +| 状态 | 说明 | +|------|------| +| popup | PopupData \| null(点击节点/预报线时设置) | +| tooltip | TooltipState(hover 节点时更新) | + +**事件处理器**: + +1. **handleNodeHover**: 从 LayerEventPayload.feature 提取 time/strong/power/speed/pressure → 更新 tooltip + +2. **handleNodeLeave**: tooltip.visible = false + +3. **handleNodeClick**: 从 feature 提取完整路径点信息 → 构建 PopupData(含时间、位置、风速风力、气压、移速移向、7/10/12级风圈半径) + +4. **handleForecastClick**: 弹出该机构预报路径 Popup(机构名、路径类型、坐标) + +5. **handleForecastPointHover/Leave**: 类似 node hover + +6. **handleForecastPointClick**: 弹出预报点 Popup(机构、时间、位置、风速、强度、气压) + +--- + +## 八、主组件 (`TyphoonMap.tsx`) + +### 8.1 Props + +```typescript +interface TyphoonMapProps { + mobilePreview?: boolean // 强制移动端模式(BlockPage 中使用) +} +// 默认值: {} +``` + +### 8.2 状态架构 + +``` +TyphoonMap +├── useTyphoonData() → 台风数据 + 派生计算 +├── useWeatherData() → 气象图层 + 降雨 tooltip +├── useTyphoonPopup(deps) → 节点/预报交互 +├── [listExpanded] → 本地状态: 列表展开 +├── [legendExpanded] → 本地状态: 图例展开 +└── [showWindCircles] → 本地状态: 风圈开关 +``` + +### 8.3 派生计算 + +```typescript +eyeColor = GRADE_COLOR[pointGrade(currentPoint)] ?? C.accent +eyeLng = Number(currentPoint.lng) +eyeLat = Number(currentPoint.lat) +selectedWind = { strong, power, lng, lat, time } | null +``` + +### 8.4 事件粘合 + +```typescript +handleNodeClick = (payload) => { + // 更新 selectedPointIdx (影响风圈、预报数据) + typhoon.setSelectedPointIdx(payload.feature.index) + // 同时弹出 Popup + popup.handleNodeClick(payload) +} + +handleSelectTfid = (id) => { + typhoon.setTfid(id) + typhoon.setPointsDetailExpanded(false) +} +``` + +### 8.5 JSX 结构 + +``` +
← 根容器,暗色主题 + + + + ← 全部图层 + + + + + // ── 顶部导航条(绝对定位) +
cyclone 图标 + "台风路径图" + 年份 + 错误标记
+ + // ── 台风信息卡(绝对定位) + + + // ── 气象工具条(绝对定位) + + + // ── 图例面板 + 历史点提示 + 数据来源(绝对定位) + + <历史点提示条 /> ← 内联渲染 + <数据来源文字 /> +
+``` + +--- + +## 九、UI 子组件详解 + +### 9.1 `TyphoonInfoCard` + +**Props**: + +| Prop | 类型 | 说明 | +|------|------|------| +| info | TyphoonInfo\|null | | +| currentPoint | TyphoonPoint\|undefined | | +| list | TyphoonListItem[] | | +| activeIds | string[] | | +| tfid | string | 当前选中 | +| points | TyphoonPoint[] | | +| loading | boolean | | +| listExpanded | boolean | | +| pointsDetailExpanded | boolean | | +| isMobile | boolean | | +| onToggleListExpanded | () => void | | +| onTogglePointsDetail | () => void | | +| onSelectTfid | (tfid) => void | | + +**渲染逻辑**: + +1. **PC 端(!isMobile)且 currentPoint 存在**:显示台风信息卡片 + - 台风眼图标 + 名称/编号 + 活跃标签 + - 强度徽章(等级色背景)+ 风力风速 + - 6 列网格:经度、纬度、中心气压、移速、移向、时间 + +2. **台风列表选择器**: + - **移动端 + 未展开**:显示圆形列表按钮(36×36) + - **其他情况**:显示完整列表面板 + - 标题行:可折叠的"台风列表(N)" + - 列表项:绿点(活跃)/灰点 + 名称 + 编号后三位 + - 选中项高亮(青色背景),点击选中项 → 切换点位详情展开 + - 未展开时只显示 3 项,底部有"展开全部(N)" + - 选中台风 + pointsDetailExpanded 时:显示点位详情表格 + - 4 列:时间、强度(着色)、风速、气压 + - 表格按时间倒序排列 + +### 9.2 `LegendPanel` + +**Props**: + +| Prop | 说明 | +|------|------| +| showWindCircles | 风圈开关状态 | +| legendExpanded | 图例展开状态 | +| landMarks | 登陆点数组 | +| isMobile | | +| onToggleLegend | 切换展开 | +| onToggleWindCircles | 切换风圈 | + +**渲染内容**(仅展开时显示): + +1. 风圈开关 checkbox +2. 登陆点图标 + 文字(仅当 landMarks.length > 0) +3. 预报台:5 个机构 × 颜色虚线 +4. 台风等级:`` 组件 +5. 风圈等级:3 个圆形图标 × 文字 +6. 警戒线:24h 橙色虚线 / 48h 红色虚线 +7. 降雨等级:6 种颜色块 2×3 网格 +8. 雷达反射率:12 色渐变色条 + 10/70 标注 + +**移动端**:图例从右侧滑出(transform: translateX),切换 chevron 方向。 + +### 9.3 `WeatherToolbar` + +**Props**: + +| Prop | 说明 | +|------|------| +| weatherLayer, weatherOpacity, cloudType, rainHours | 当前值 | +| cloudTime?, radarTime?, rainTime? | 数据时间 | +| isMobile | | +| onSetWeatherLayer, onSetWeatherOpacity, ... | setter 回调 | + +**渲染**: + +1. 图层切换按钮组:无/云图/雷达/降雨/卫星,5 个按钮并排 +2. 选中 `satellite` → 同时 `onSetSatellite(true)`;选其他 → `onSetSatellite(false)` +3. 下方子面板(仅当 weatherLayer !== 'none'): + - cloud 模式:30m/1h/3h/6h 四档按钮 + - rain 模式:24h/48h/72h 三档按钮 + - 非 satellite 模式:透明度滑块(range input 0-1) + - 显示数据时间 + +### 9.4 `TyphoonMapLayers` + +这是最复杂的子组件,接收所有数据并通过 props 控制(无内部状态)。 + +**图层顺序(zIndex 从低到高)**: + +| zIndex | 图层 | 组件 | +|--------|------|------| +| -2 | 卫星底图 | `` | +| -1 | 云图/雷达/降雨覆盖 | `` / `` | +| 0 | 风圈填充(opacity=0.25) | `` | +| 1 | 历史轨迹段 + 其他机构预报 | `` | +| 2 | 风圈描边(opacity=0.8)+ 选中机构预报 | `` | +| 3 | 警戒线 | `` | +| 4 | 警戒线文字标注 | `` | +| 5 | 风圈文字标注 | `` | +| 6 | 路径节点 | `` | +| 7 | 预报路径点位 | `` | +| 8 | 预报日期标注(MM/DD) | `` | +| — | 台风眼 Marker | `` | +| — | 登陆点 Marker | `` | +| — | 节点 Tooltip | `` | +| — | 降雨 Tooltip | `` | +| — | 点击 Popup | `` | + +**交互**: + +- 路径节点 (zIndex=6):`onMouseMove → onNodeHover`, `onMouseLeave → onNodeLeave`, `onClick → onNodeClick` +- 预报路径点 (zIndex=7):`onMouseMove → onForecastPointHover`, `onMouseLeave → onForecastPointLeave`, `onClick → onForecastPointClick` +- 其他机构预报线 (zIndex=1):`onClick → onForecastClick` +- 选中机构预报线 (zIndex=2):`onClick → onForecastClick` +- 降雨 FillLayer (zIndex=-1):`onMouseMove → onRainTooltipMove`, `onMouseLeave → onRainTooltipLeave` +- 历史轨迹段:无交互 +- Popup:`onClose → onPopupClose` + +**预报日期标注(内联 IIFE)**: + +- 取 `forecastSrc` 中机构名为"中国"的预报点 +- 按日期去重(每天只取第一个点) +- 格式化为 MM/DD 作为文字标注 + +**Lookup 注意**: + +- LineLayer 使用 `sourceConfig={{ coordinates: 'path' }}`(字段名为 `path` 不是 `coordinates`) +- PointLayer 使用 `sourceConfig={{ x: 'lng', y: 'lat' }}` +- GeoJSON 坐标格式为 `[lng, lat]`(经度在前) + +--- + +## 十、完整数据流图 + +``` +┌─────────────────┐ +│ TyphoonMap │ 主组件 +├─────────────────┤ +│ isMobile (prop │← useResponsive() +│ + responsive) │ +│ │ +│ listExpanded ───┤ local state +│ legendExpanded──┤ local state +│ showWindCircles─┤ local state +│ │ +│ ┌─────────────┐ │ +│ │useTyphoonData│ │→ fetch API_ACTIVITY + API_LIST → list, activeIds, tfid +│ │ │ │→ fetch API_INFO(tfid) → info +│ │ │ │→ useMemo: trackSegments, nodes, windPolygons, forecast* +│ │ │ │→ fitBounds on tfid change +│ └─────────────┘ │ +│ │ +│ ┌─────────────┐ │ +│ │useWeatherData│ │→ fetchCloud/Radar/Rain on layer change +│ │ │ │→ rainTooltip state + handlers +│ └─────────────┘ │ +│ │ +│ ┌─────────────┐ │ +│ │usePopup │ │→ popup, tooltip state +│ │ │ │→ nodeHover/Click, forecastHover/Click handlers +│ └─────────────┘ │ +└────────┬────────┘ + │ props + ▼ +┌─────────────────────────────────────────────────┐ +│ 子组件 │ +├──────────────┬──────────────┬───────────────────┤ +│TyphoonMap │TyphoonInfo │WeatherToolbar │ +│Layers │Card │+ LegendPanel │ +│(AiMap children)(台风卡片+列表)│(气象控制+图例) │ +└──────────────┴──────────────┴───────────────────┘ +``` + +--- + +## 十一、入口与注册 (`main.tsx`) + +```typescript +// import +import TyphoonMap from './app/typhoon'; + +// 注册(devices 配置) +{ + name: 'TyphoonMap', + icon: 'cyclone', + component: TyphoonMap, + group: 'App Templates', + file: 'app/typhoon/TyphoonMap', + device: 'both', // 支持桌面端和移动端 +} + +// 源码展示 glob 需包含 +'./{engine,control,marker,layer,composite,app,app/typhoon,app/typhoon/hooks}/*.tsx' + +// 渲染时: +// device='mobile' → 390×844 手机框内渲染 +// device='desktop'/'both' → 全屏渲染 +// BlockPage 中 mobile preview 传入 mobilePreview prop +``` + +--- + +## 十二、移动端适配规则 + +| 场景 | 桌面端 | 移动端 | +|------|--------|--------| +| 顶部导航条 | width=264, padding=12×16 | width=100%, padding=8×12 | +| 图标字号 | 22 | 18 | +| 标题字号 | 17 | 14 | +| 显示年份 | 是 | 否 | +| 台风信息卡 | 显示(width=240, top=82) | 隐藏 | +| 列表选择器 | 始终展开 | 初始为圆形按钮(36×36),点击展开 | +| 图例面板 | 右上角,垂直折叠 | 右侧滑入,水平折叠 | +| 气象工具条 | 右上角 | 居中横排 | +| 历史点提示 | 右下角 | 右下角 | + +--- + +## 十三、CSS 动画 + +1. **台风眼旋转**: `@keyframes typhoon-eye-rot { to { transform: rotate(360deg) } }`,4s linear infinite +2. **台风眼双环动画** (TyphoonEye.tsx): `@keyframes typhoon-rot` — 外环 6s 顺时针,内环 4s 逆时针 +3. **图例折叠过渡**: `transition: max-height 0.3s`(桌面) / `transition: transform 0.3s ease`(移动) + +--- + +## 十四、关键边界情况处理 + +1. **API 全部失败**: 使用 `FALLBACK_LIST` + `FALLBACK_INFO`(台风"巴威"),`error=true`,顶部显示"使用内置样本"标签 +2. **坐标 NaN**: `toTrackSegments`/`toNodes`/`fetchRain` 中均有 `Number.isFinite` 过滤 +3. **风圈半径为 0**: `toWindPolygons` 中 `radii[qi] > 0` 检查,`hasAny` 标记 +4. **预报为空**: forecastSrc 默认 `[]`,activeForecastSegs/otherForecastSegs 为空时不渲染 +5. **雷达单张模式**: `radarType === '2'` 时用 `RADAR_FULL_EXTENT` 单片渲染 +6. **无需风圈数据**: `showWindCircles` 开关控制,`windPolygons.length > 0` 时才渲染 +7. **selectedPointIdx 越界**: `selectedIdx` 计算时做了 bounds check +8. **Polygon 闭合**: `toWindPolygons` 和 `fetchRain` 中都会检查并闭合环 \ No newline at end of file diff --git a/packages/site/src/app/typhoon/TyphoonEye.tsx b/packages/site/src/app/typhoon/TyphoonEye.tsx new file mode 100644 index 0000000..2b06ab8 --- /dev/null +++ b/packages/site/src/app/typhoon/TyphoonEye.tsx @@ -0,0 +1,27 @@ +/* ================================================================ + 台风眼旋转动画符号 + ================================================================ */ + +export default function TyphoonEye({ color, label }: { color: string; label: string }) { + return ( +
+
+
+
+
{label}
+ +
+ ); +} \ No newline at end of file diff --git a/packages/site/src/app/typhoon/TyphoonInfoCard.tsx b/packages/site/src/app/typhoon/TyphoonInfoCard.tsx new file mode 100644 index 0000000..27daace --- /dev/null +++ b/packages/site/src/app/typhoon/TyphoonInfoCard.tsx @@ -0,0 +1,155 @@ +/* ================================================================ + 台风信息卡片 + 列表选择器 + ================================================================ */ + +import type { TyphoonInfo, TyphoonListItem, TyphoonPoint } from './types'; +import { GRADE_COLOR, STRENGTH_TO_KEY, C } from './constants'; +import { pointGrade } from './transform'; +import InfoItem from './Info'; + +interface TyphoonInfoCardProps { + info: TyphoonInfo | null; + currentPoint: TyphoonPoint | undefined; + list: TyphoonListItem[]; + activeIds: string[]; + tfid: string; + points: TyphoonPoint[]; + loading: boolean; + listExpanded: boolean; + pointsDetailExpanded: boolean; + isMobile: boolean; + onToggleListExpanded: () => void; + onTogglePointsDetail: () => void; + onSelectTfid: (tfid: string) => void; +} + +export default function TyphoonInfoCard({ + info, currentPoint, list, activeIds, tfid, points, + loading, listExpanded, pointsDetailExpanded, isMobile, + onToggleListExpanded, onTogglePointsDetail, onSelectTfid, +}: TyphoonInfoCardProps) { + const eyeColor = currentPoint ? GRADE_COLOR[pointGrade(currentPoint)] : C.accent; + + return ( +
+ {/* 台风信息卡 */} + {currentPoint && !isMobile && ( +
+
+ cyclone +
+
{info?.name}
+
{info?.enname} · {info?.tfid}
+
+ {info?.isactive === '1' && ( + 活跃 + )} +
+
+ {currentPoint.strong} + {currentPoint.power}级 · {currentPoint.speed}m/s +
+
+ + + + + + +
+
+ )} + + {/* 台风列表选择器 */} + {isMobile && !listExpanded ? ( +
{ e.currentTarget.style.background = 'rgba(34,211,238,0.15)'; }} + onMouseLeave={e => { e.currentTarget.style.background = C.panel; }}> + list +
+ ) : ( +
+
+ {listExpanded ? 'expand_less' : 'expand_more'} + 台风列表({list.length}) +
+ {(listExpanded || !isMobile) && ( + <> + {loading &&
加载中…
} + {[...list].reverse().slice(0, listExpanded ? list.length : 3).map(t => { + const sel = t.tfid === tfid; + const actv = activeIds.includes(t.tfid) || t.isactive === '1'; + return ( +
+
{ + onSelectTfid(t.tfid); + if (sel) onTogglePointsDetail(); + }} style={{ + display: 'flex', alignItems: 'center', gap: 6, padding: '5px 6px', margin: '2px 0', borderRadius: 6, + cursor: 'pointer', fontSize: 11, + background: sel ? 'rgba(34,211,238,0.12)' : 'transparent', + color: sel ? '#e0f2fe' : 'rgba(203,213,225,0.75)', fontWeight: sel ? 600 : 400, + transition: 'background 120ms', + }} + onMouseEnter={e => { if (!sel) { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; } }} + onMouseLeave={e => { if (!sel) { e.currentTarget.style.background = 'transparent'; } }}> + + {t.name} + {t.tfid.slice(-3)} + {sel && {pointsDetailExpanded ? 'expand_less' : 'expand_more'}} +
+ {/* 点位详情展开面板 */} + {sel && pointsDetailExpanded && info?.points && ( +
+ + + + + + + + + + + {[...points].reverse().map((p, idx) => ( + { e.currentTarget.style.background = 'rgba(34,211,238,0.08)'; }} + onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}> + + + + + + ))} + +
时间强度风速气压
{p.time?.slice(5, 16)}{p.strong}{p.speed} m/s{p.pressure} hPa
+
+ )} +
+ ); + })} + {!listExpanded && list.length > 3 && ( +
+ 展开全部({list.length}) +
+ )} + + )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/packages/site/src/app/typhoon/TyphoonMap.tsx b/packages/site/src/app/typhoon/TyphoonMap.tsx new file mode 100644 index 0000000..f44c39a --- /dev/null +++ b/packages/site/src/app/typhoon/TyphoonMap.tsx @@ -0,0 +1,190 @@ +/* ================================================================ + 台风路径地图 — 主组件 + 组装所有子组件与 hooks + ================================================================ */ + +import { useMemo, useCallback, useState } from 'react'; +import { AiMap, ZoomControl, MapThemeControl, useResponsive } from '@antv/aimapui'; +import type { LayerEventPayload } from '@antv/aimapui'; +import { C, CURRENT_YEAR } from './constants'; +import { GRADE_COLOR } from './constants'; +import { pointGrade } from './transform'; +import { useTyphoonData } from './hooks/useTyphoonData'; +import { useWeatherData } from './hooks/useWeatherData'; +import { useTyphoonPopup } from './hooks/useTyphoonPopup'; + +import TyphoonMapLayers from './TyphoonMapLayers'; +import TyphoonInfoCard from './TyphoonInfoCard'; +import WeatherToolbar from './WeatherToolbar'; +import LegendPanel from './LegendPanel'; + +export default function TyphoonMap({ mobilePreview }: { mobilePreview?: boolean } = {}) { + const { isMobile: responsiveMobile } = useResponsive(); + const isMobile = mobilePreview ?? responsiveMobile; + + // ── Hooks ── + const typhoon = useTyphoonData(); + const weather = useWeatherData(); + const popup = useTyphoonPopup({ + info: typhoon.info, + selectedAgency: typhoon.selectedAgency, + }); + + // ── 本地 UI 状态 ── + const [listExpanded, setListExpanded] = useState(false); + const [legendExpanded, setLegendExpanded] = useState(false); + const [showWindCircles, setShowWindCircles] = useState(true); + + // ── 派生计算 ── + const eyeColor = typhoon.currentPoint ? GRADE_COLOR[pointGrade(typhoon.currentPoint)] : C.accent; + const selectedWind = typhoon.selectedPoint ? { + strong: typhoon.selectedPoint.strong, + power: typhoon.selectedPoint.power, + lng: Number(typhoon.selectedPoint.lng), + lat: Number(typhoon.selectedPoint.lat), + time: typhoon.selectedPoint.time, + } : null; + + // ── 事件处理 ── + const handleNodeClick = useCallback((payload: LayerEventPayload) => { + const f = payload.feature as Record | undefined; + if (f && typeof f.index === 'number') typhoon.setSelectedPointIdx(f.index); + popup.handleNodeClick(payload); + }, [typhoon.setSelectedPointIdx, popup.handleNodeClick]); + + const handleSelectTfid = useCallback((id: string) => { + typhoon.setTfid(id); + typhoon.setPointsDetailExpanded(false); + }, [typhoon.setTfid, typhoon.setPointsDetailExpanded]); + + return ( +
+ + + {/* ── 地图 ── */} +
+ + popup.setPopup(null)} + onNodeHover={popup.handleNodeHover} + onNodeLeave={popup.handleNodeLeave} + onNodeClick={handleNodeClick} + onForecastClick={popup.handleForecastClick} + onForecastPointHover={popup.handleForecastPointHover} + onForecastPointLeave={popup.handleForecastPointLeave} + onForecastPointClick={popup.handleForecastPointClick} + onRainTooltipMove={weather.handleRainTooltipMove} + onRainTooltipLeave={weather.handleRainTooltipLeave} + /> + + + +
+ + {/* ════════ 顶部导航条 ════════ */} +
+
+ cyclone + 台风路径图 + {!isMobile && {CURRENT_YEAR} 年 西太平洋} + {typhoon.error && ( + + wifi_off 使用内置样本 + + )} +
+
+ + {/* ═══════ 右上角面板:工具条 + 信息卡 ═══════ */} +
+
+ +
+ setListExpanded(!listExpanded)} + onTogglePointsDetail={() => typhoon.setPointsDetailExpanded(!typhoon.pointsDetailExpanded)} + onSelectTfid={handleSelectTfid} + /> +
+ + {/* ═══════ 图例 + 选中历史点提示 ═══════ */} +
+ setLegendExpanded(!legendExpanded)} + onToggleWindCircles={() => setShowWindCircles(!showWindCircles)} + /> + {/* 选中历史点提示 */} + {selectedWind && typhoon.selectedIdx !== typhoon.currentIdx && ( +
+ history + 历史点 · {selectedWind.time.slice(5)} · {selectedWind.strong} {selectedWind.power}级 + +
+ )} + {/* 数据来源 */} +
数据来源:浙江水利 typhoon.slt.zj.gov.cn
+
+
+ ); +} \ No newline at end of file diff --git a/packages/site/src/app/typhoon/TyphoonMapLayers.tsx b/packages/site/src/app/typhoon/TyphoonMapLayers.tsx new file mode 100644 index 0000000..b06ee48 --- /dev/null +++ b/packages/site/src/app/typhoon/TyphoonMapLayers.tsx @@ -0,0 +1,414 @@ +/* ================================================================ + 地图图层子组件 + 包含:气象覆盖层、风圈、警戒线、轨迹段、预报路径、 + 标注、Tooltip、Popup + ================================================================ */ + +import { Marker, Tooltip, Popup, ImageLayer, FillLayer, LineLayer, PointLayer, SatelliteLayer } from '@antv/aimapui'; +import type { TyphoonPoint, WindPolygon, TrackNode, LandPoint, PopupData, TooltipState, RainTooltipState, WindFieldRawData } from './types'; +import WindFieldLayer from './WindFieldLayer'; +import { + GRADE_ORDER, GRADE_COLOR, WIND_LEVEL_KEY, WIND_LEVEL_COLOR, + AGENCIES, AGENCY_COLOR, WARNING_LINE_24H, WARNING_LINE_48H, WARNING_LINE_COLORS, + C, +} from './constants'; +import { getRainLevelLabel } from './constants'; +import { pointGrade } from './transform'; +import type { LayerEventPayload } from '@antv/aimapui'; + +interface TyphoonMapLayersProps { + // typhoon data + trackSegments: { path: [number, number][]; grade: string }[]; + nodes: TrackNode[]; + windPolygons: WindPolygon[]; + showWindCircles: boolean; + landMarks: LandPoint[]; + forecastSrc: { tm: string; forecastpoints: TyphoonPoint[] }[]; + selectedAgency: string; + activeForecastSegs: { path: [number, number][]; agency: string }[]; + otherForecastSegs: { path: [number, number][]; agency: string }[]; + forecastPoints: { lng: number; lat: number; agency: string; time: string; strong: string; speed: string; pressure: string }[]; + info: { name?: string; enname?: string } | null; + currentPoint: TyphoonPoint | undefined; + + // weather + weatherLayer: 'none' | 'cloud' | 'radar' | 'rain' | 'satellite' | 'wind'; + weatherOpacity: number; + satellite: boolean; + satProvider: 'gaode' | 'tianditu' | 'google'; + satOpacity: number; + cloud: { img: string; extent: [number, number, number, number] } | null; + radar: { tiles: { img: string; extent: [number, number, number, number] }[] } | null; + rain: { features: { type: 'Feature'; properties: { color: string; symbol: string }; geometry: { type: 'Polygon'; coordinates: [number, number][][] } }[]; colors: string[] } | null; + windField: WindFieldRawData | null; + + // interactions + popup: PopupData | null; + tooltip: TooltipState; + rainTooltip: RainTooltipState; + satelliteMode: boolean; + onPopupClose: () => void; + onNodeHover: (payload: LayerEventPayload) => void; + onNodeLeave: () => void; + onNodeClick: (payload: LayerEventPayload) => void; + onForecastClick: (payload: LayerEventPayload) => void; + onForecastPointHover: (payload: LayerEventPayload) => void; + onForecastPointLeave: () => void; + onForecastPointClick: (payload: LayerEventPayload) => void; + onRainTooltipMove: (payload: LayerEventPayload) => void; + onRainTooltipLeave: () => void; +} + +export default function TyphoonMapLayers({ + trackSegments, nodes, windPolygons, showWindCircles, landMarks, + forecastSrc, selectedAgency, activeForecastSegs, otherForecastSegs, forecastPoints, + info, currentPoint, + weatherLayer, weatherOpacity, satellite, satProvider, satOpacity, + cloud, radar, rain, windField, + popup, tooltip, rainTooltip, satelliteMode, + onPopupClose, onNodeHover, onNodeLeave, onNodeClick, + onForecastClick, onForecastPointHover, onForecastPointLeave, onForecastPointClick, + onRainTooltipMove, onRainTooltipLeave, +}: TyphoonMapLayersProps) { + const eyeColor = currentPoint ? GRADE_COLOR[pointGrade(currentPoint)] : C.accent; + const eyeLng = currentPoint ? Number(currentPoint.lng) : NaN; + const eyeLat = currentPoint ? Number(currentPoint.lat) : NaN; + + return ( + <> + {/* ⓪ 气象覆盖层 */} + {satellite && ()} + {weatherLayer === 'wind' && windField && ( + + )} + {weatherLayer === 'cloud' && cloud && ( + } + zIndex={-1} + /> + )} + {weatherLayer === 'radar' && radar && radar.tiles.map((t, i) => ( + } + zIndex={-1} + /> + ))} + {weatherLayer === 'rain' && rain && ( + } + zIndex={-1} + hoverEffect={false} + clickEffect={false} + tooltipEffect={false} + onMouseMove={onRainTooltipMove} + onMouseLeave={onRainTooltipLeave} + /> + )} + + {/* 风圈 */} + {showWindCircles && windPolygons.length > 0 && ( + <> + ({ type: 'Feature' as const, properties: { level: w.level }, geometry: { type: 'Polygon' as const, coordinates: w.coordinates } })) }} + sourceType="geojson" + shape="fill" + colorField="level" + colorValues={WIND_LEVEL_KEY.map(k => WIND_LEVEL_COLOR[k])} + style={{ opacity: 0.25 } as Record} + zIndex={0} + /> + ({ type: 'Feature' as const, properties: { level: w.level }, geometry: { type: 'Polygon' as const, coordinates: w.coordinates } })) }} + sourceType="geojson" + shape="line" + size={1} + colorField="level" + colorValues={WIND_LEVEL_KEY.map(k => WIND_LEVEL_COLOR[k])} + style={{ opacity: 0.8 } as Record} + zIndex={2} + /> + { + const coords = w.coordinates[0]; + const midIdx = Math.floor(coords.length / 4); + const pt = coords[midIdx] || coords[0]; + return { + type: 'Feature' as const, + properties: { label: `${w.level}级风圈`, level: w.level }, + geometry: { type: 'Point' as const, coordinates: pt }, + }; + }) }} + sourceType="geojson" + shapeField="label" + shapeValues="text" + colorField="level" + colorValues={WIND_LEVEL_KEY.map(k => WIND_LEVEL_COLOR[k])} + size={11} + zIndex={5} + style={{ + textAnchor: 'center', + textOffset: [0, -8], + fontWeight: 600, + stroke: '#0f172a', + strokeWidth: 3, + } as Record} + /> + + )} + + {/* 警戒线 */} + } + zIndex={3} + /> + } + /> + + {/* 历史轨迹段 */} + {trackSegments.length > 0 && ( + GRADE_COLOR[g])} + zIndex={1} + /> + )} + + {/* 其他机构预报(淡显) */} + {otherForecastSegs.length > 0 && ( + a !== selectedAgency).map(a => AGENCY_COLOR[a])} + style={{ opacity: 0.55, lineType: 'dash', dashArray: [8, 8] } as Record} + active={{ color: '#fff' }} + onClick={onForecastClick} + zIndex={1} + /> + )} + + {/* 选中机构预报(高亮) */} + {activeForecastSegs.length > 0 && ( + } + active={{ color: '#fff' }} + onClick={onForecastClick} + zIndex={2} + /> + )} + + {/* 预报路径点位 */} + {forecastPoints.length > 0 && ( + AGENCY_COLOR[a])} + style={{ opacity: 0.7, stroke: '#fff', strokeWidth: 0.5 } as Record} + active={{ color: '#fff' }} + onMouseMove={onForecastPointHover} + onMouseLeave={onForecastPointLeave} + onClick={onForecastPointClick} + zIndex={7} + /> + )} + + {/* 预报路径日期标注(仅中国机构) */} + {(() => { + const chinaPoints = forecastSrc.find(f => f.tm === '中国')?.forecastpoints ?? []; + const dailyLabels: { lng: number; lat: number; dateLabel: string }[] = []; + const seenDates = new Set(); + for (const pt of chinaPoints) { + const timeStr = String(pt.time ?? ''); + const datePart = timeStr.slice(0, 10); + if (!datePart || seenDates.has(datePart)) continue; + seenDates.add(datePart); + const lng = Number(pt.lng), lat = Number(pt.lat); + if (!Number.isFinite(lng) || !Number.isFinite(lat)) continue; + const month = datePart.slice(5, 7); + const day = datePart.slice(8, 10); + dailyLabels.push({ lng, lat, dateLabel: `${month}/${day}` }); + } + if (dailyLabels.length === 0) return null; + return ( + ({ + type: 'Feature' as const, + properties: { label: d.dateLabel }, + geometry: { type: 'Point' as const, coordinates: [d.lng, d.lat] }, + })) }} + sourceType="geojson" + shapeField="label" + shapeValues="text" + color="#22d3ee" + size={11} + zIndex={8} + style={{ + textAnchor: 'center', + textOffset: [0, -12], + fontWeight: 700, + stroke: '#0f172a', + strokeWidth: 3, + } as Record} + /> + ); + })()} + + {/* 路径节点 */} + {nodes.length > 0 && ( + GRADE_COLOR[g])} + active={{ color: '#fff' }} + onMouseMove={onNodeHover} + onMouseLeave={onNodeLeave} + onClick={onNodeClick} + zIndex={6} + /> + )} + + {/* 台风眼 */} + {Number.isFinite(eyeLng) && Number.isFinite(eyeLat) && ( + + } + /> + )} + + {/* 登陆点标注 */} + {landMarks.map((land) => { + const lng = Number(land.lng), lat = Number(land.lat); + if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null; + return ( + + } + /> + ); + })} + + {/* 节点 Tooltip */} + + + {/* 降雨 Tooltip */} + + + {/* 点击 Popup */} + {popup && ( + + )} + + ); +} \ No newline at end of file diff --git a/packages/site/src/app/typhoon/WeatherToolbar.tsx b/packages/site/src/app/typhoon/WeatherToolbar.tsx new file mode 100644 index 0000000..fb178b3 --- /dev/null +++ b/packages/site/src/app/typhoon/WeatherToolbar.tsx @@ -0,0 +1,114 @@ +/* ================================================================ + 气象图层工具条 + 图层切换按钮组 + 时段/透明度/时间面板 + ================================================================ */ + +import { C } from './constants'; + +interface WeatherToolbarProps { + weatherLayer: 'none' | 'cloud' | 'radar' | 'rain' | 'satellite' | 'wind'; + weatherOpacity: number; + cloudType: 0.5 | 1 | 3 | 6; + rainHours: 24 | 48 | 72; + cloudTime?: string; + radarTime?: string; + rainTime?: string; + isMobile: boolean; + onSetWeatherLayer: (v: 'none' | 'cloud' | 'radar' | 'rain' | 'satellite' | 'wind') => void; + onSetWeatherOpacity: (v: number) => void; + onSetCloudType: (v: 0.5 | 1 | 3 | 6) => void; + onSetRainHours: (v: 24 | 48 | 72) => void; + onSetSatellite: (v: boolean) => void; +} + +export default function WeatherToolbar({ + weatherLayer, weatherOpacity, cloudType, rainHours, + cloudTime, radarTime, rainTime, + isMobile, + onSetWeatherLayer, onSetWeatherOpacity, onSetCloudType, onSetRainHours, onSetSatellite, +}: WeatherToolbarProps) { + const wl = weatherLayer; + const showTime = (wl === 'cloud' && cloudTime) || (wl === 'radar' && radarTime) || (wl === 'rain' && rainTime); + const timeStr = wl === 'cloud' ? cloudTime : wl === 'radar' ? radarTime : wl === 'rain' ? rainTime : undefined; + + return ( +
+ {/* 图层切换按钮组 */} +
+
+ {([ + { k: 'none', label: '无', icon: 'block' }, + { k: 'cloud', label: '云图', icon: 'cloud' }, + { k: 'radar', label: '雷达', icon: 'radar' }, + { k: 'rain', label: '降雨', icon: 'rainy' }, + { k: 'wind', label: '风场', icon: 'air' }, + { k: 'satellite', label: '卫星', icon: 'satellite_alt' }, + ] as { k: typeof weatherLayer; label: string; icon: string }[]).map(o => { + const sel = weatherLayer === o.k; + return ( + + ); + })} +
+
+ + {/* 下方按需显示:时段 / 透明度 / 数据时间 */} + {wl === 'none' ? null : ( +
+ {wl === 'cloud' && ( +
+ {([0.5, 1, 3, 6] as const).map(h => ( + + ))} +
+ )} + {wl === 'rain' && ( +
+ {([24, 48, 72] as const).map(h => ( + + ))} +
+ )} + {wl !== 'satellite' && ( +
+ 透明度 + onSetWeatherOpacity(Number(e.target.value))} + style={{ width: 60, accentColor: C.accent, height: 3 }} /> + {Math.round(weatherOpacity * 100)}% +
+ )} + {showTime && timeStr && ( +
+ schedule + {timeStr} +
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/packages/site/src/app/typhoon/WindFieldLayer.tsx b/packages/site/src/app/typhoon/WindFieldLayer.tsx new file mode 100644 index 0000000..fbb9ca0 --- /dev/null +++ b/packages/site/src/app/typhoon/WindFieldLayer.tsx @@ -0,0 +1,288 @@ +/* ================================================================ + Canvas 风场图层 — 粒子动画渲染全球风场 + 数据来源: https://typhoon.slt.zj.gov.cn/Api/LastWind + GRIB2 U/V 分量, 360×181 网格 (1° 分辨率) + ================================================================ */ + +import { useEffect, useRef, useCallback } from 'react'; +import { useScene } from '@antv/aimapui'; +import type { Scene } from '@antv/l7'; +import type { WindFieldRawData } from './types'; + +// ── 配置常量 ────────────────────────────────────────────────── +const PARTICLE_COUNT = 12000; +const PARTICLE_AGE_MAX = 180; +const SPEED_SCALE = 0.35; +const FADE_OPACITY = 0.96; +const LINE_WIDTH = 1.0; + +// 风速 → 颜色映射 (HSL) +function windSpeedToColor(speed: number): string { + if (speed < 5) return `hsla(220, 80%, ${55 + speed * 3}%, 0.7)`; + if (speed < 15) return `hsla(${190 - (speed - 5) * 3}, 85%, 55%, 0.75)`; + if (speed < 30) return `hsla(${140 - (speed - 15) * 4}, 90%, 50%, 0.8)`; + return `hsla(${Math.max(0, 80 - (speed - 30) * 3)}, 95%, 50%, 0.85)`; +} + +// ── 双线性插值获取格点值 ────────────────────────────────────── +function interpolateValue(data: number[], nx: number, ny: number, lo1: number, la1: number, dx: number, dy: number, lng: number, lat: number): number { + const x = (lng - lo1) / dx; + const y = (la1 - lat) / dy; + + const x0 = Math.floor(x); + const y0 = Math.floor(y); + const x1 = x0 + 1; + const y1 = y0 + 1; + + if (x0 < 0 || x1 >= nx || y0 < 0 || y1 >= ny) return NaN; + + const fx = x - x0; + const fy = y - y0; + + const i00 = y0 * nx + x0; + const i10 = y0 * nx + x1; + const i01 = y1 * nx + x0; + const i11 = y1 * nx + x1; + + return ( + data[i00] * (1 - fx) * (1 - fy) + + data[i10] * fx * (1 - fy) + + data[i01] * (1 - fx) * fy + + data[i11] * fx * fy + ); +} + +// ── 粒子系统 ────────────────────────────────────────────────── +interface Particle { + x: number; // 经度 + y: number; // 纬度 + age: number; + speed: number; +} + +function createParticles(count: number, bounds: { west: number; east: number; south: number; north: number }): Particle[] { + const particles: Particle[] = []; + for (let i = 0; i < count; i++) { + particles.push({ + x: bounds.west + Math.random() * (bounds.east - bounds.west), + y: bounds.south + Math.random() * (bounds.north - bounds.south), + age: Math.floor(Math.random() * PARTICLE_AGE_MAX), + speed: 0, + }); + } + return particles; +} + +// ── 组件 Props ──────────────────────────────────────────────── +export interface WindFieldLayerProps { + /** 风场原始网格数据 */ + windData: WindFieldRawData | null; + /** 是否可见 */ + visible?: boolean; + /** 透明度 0-1 */ + opacity?: number; + /** 粒子数量 */ + particleCount?: number; + /** z-index */ + zIndex?: number; +} + +export default function WindFieldLayer({ + windData, + visible = true, + opacity = 0.8, + particleCount = PARTICLE_COUNT, + zIndex = -2, +}: WindFieldLayerProps) { + const scene = useScene(); + const canvasRef = useRef(null); + const animFrameRef = useRef(0); + const particlesRef = useRef([]); + const prevBoundsRef = useRef(''); + + // 创建 Canvas 覆盖层 — 挂载到 L7 地图 Canvas 容器,确保在底图同级、所有图层下方 + useEffect(() => { + if (!scene) return; + + // 优先使用 L7 提供的 getMapCanvasContainer 获取底图容器 + let targetContainer: HTMLElement | null = null; + try { + targetContainer = (scene as any).getMapCanvasContainer?.() || null; + } catch { /* ignore */ } + + if (!targetContainer) { + // fallback: 查找地图 canvas 容器 + const mapContainer = (scene as any).getContainer?.() || document.querySelector('.l7-container, .amap-container'); + if (mapContainer) { + targetContainer = mapContainer.querySelector('canvas')?.parentElement || mapContainer; + } + } + if (!targetContainer) return; + + const canvas = document.createElement('canvas'); + canvas.style.position = 'absolute'; + canvas.style.top = '0'; + canvas.style.left = '0'; + canvas.style.width = '100%'; + canvas.style.height = '100%'; + canvas.style.pointerEvents = 'none'; + canvas.style.zIndex = '0'; + canvas.style.opacity = String(opacity); + targetContainer.appendChild(canvas); + canvasRef.current = canvas; + + return () => { + cancelAnimationFrame(animFrameRef.current); + if (canvas.parentNode) canvas.parentNode.removeChild(canvas); + canvasRef.current = null; + }; + }, [scene, opacity]); + + // 动画循环 + const animate = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas || !windData || !visible) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // 同步 canvas 尺寸 + const width = canvas.clientWidth; + const height = canvas.clientHeight; + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + + // 获取当前地图范围(兼容 L7 不同版本的 getBounds 返回格式) + const bounds = scene.getBounds(); + let mapBounds: { west: number; east: number; south: number; north: number }; + if (bounds && typeof (bounds as any).getSouthWest === 'function') { + const sw = (bounds as any).getSouthWest(); + const ne = (bounds as any).getNorthEast(); + mapBounds = { west: sw.lng, east: ne.lng, south: sw.lat, north: ne.lat }; + } else if (Array.isArray(bounds) && bounds.length >= 2) { + // L7 某些版本返回 [[west,south],[east,north]] 数组 + mapBounds = { west: bounds[0][0], south: bounds[0][1], east: bounds[1][0], north: bounds[1][1] }; + } else { + // fallback: 使用 scene 的 center + zoom 估算 + const center = scene.getCenter(); + const zoom = scene.getZoom(); + const span = 180 / Math.pow(2, zoom - 1); + mapBounds = { west: center.lng - span, east: center.lng + span, south: center.lat - span, north: center.lat + span }; + } + const boundsKey = `${mapBounds.west.toFixed(2)},${mapBounds.east.toFixed(2)},${mapBounds.south.toFixed(2)},${mapBounds.north.toFixed(2)}`; + + // 地图范围变化时重新生成粒子 + if (boundsKey !== prevBoundsRef.current || particlesRef.current.length === 0) { + prevBoundsRef.current = boundsKey; + particlesRef.current = createParticles(particleCount, mapBounds); + } + + // 淡出旧轨迹(保留背景色以增强对比) + ctx.globalCompositeOperation = 'destination-in'; + ctx.fillStyle = `rgba(0,0,0,${FADE_OPACITY})`; + ctx.fillRect(0, 0, width, height); + + // 绘制半透明背景底色,增强粒子轨迹可见性 + ctx.globalCompositeOperation = 'destination-over'; + ctx.fillStyle = 'rgba(10, 15, 30, 0.6)'; + ctx.fillRect(0, 0, width, height); + + // 绘制新轨迹段 + ctx.globalCompositeOperation = 'source-over'; + ctx.lineWidth = LINE_WIDTH; + + const particles = particlesRef.current; + const { uData, vData, nx, ny, lo1, la1, dx, dy } = windData; + + for (let i = 0; i < particles.length; i++) { + const p = particles[i]; + + // 获取当前位置的风速 + const u = interpolateValue(uData, nx, ny, lo1, la1, dx, dy, p.x, p.y); + const v = interpolateValue(vData, nx, ny, lo1, la1, dx, dy, p.x, p.y); + + if (isNaN(u) || isNaN(v)) { + // 超出网格范围,重置粒子 + p.x = mapBounds.west + Math.random() * (mapBounds.east - mapBounds.west); + p.y = mapBounds.south + Math.random() * (mapBounds.north - mapBounds.south); + p.age = 0; + continue; + } + + const speed = Math.sqrt(u * u + v * v); + p.speed = speed; + + // 将经纬度转换为屏幕像素 + const pixelStart = scene.lngLatToContainer([p.x, p.y]); + if (!pixelStart) continue; + + // 计算下一位置 (简单欧拉积分) + const dt = SPEED_SCALE; + const nextX = p.x + u * dt * 0.01; + const nextY = p.y + v * dt * 0.01; + + const pixelEnd = scene.lngLatToContainer([nextX, nextY]); + if (!pixelEnd) continue; + + // 绘制线段 + ctx.beginPath(); + ctx.moveTo(pixelStart.x, pixelStart.y); + ctx.lineTo(pixelEnd.x, pixelEnd.y); + ctx.strokeStyle = windSpeedToColor(speed); + ctx.stroke(); + + // 更新粒子位置 + p.x = nextX; + p.y = nextY; + p.age++; + + // 老化或超出视口则重置 + if ( + p.age > PARTICLE_AGE_MAX || + p.x < mapBounds.west || p.x > mapBounds.east || + p.y < mapBounds.south || p.y > mapBounds.north + ) { + p.x = mapBounds.west + Math.random() * (mapBounds.east - mapBounds.west); + p.y = mapBounds.south + Math.random() * (mapBounds.north - mapBounds.south); + p.age = 0; + } + } + + animFrameRef.current = requestAnimationFrame(animate); + }, [scene, visible, particleCount, windData]); + + // 启动/停止动画 + useEffect(() => { + if (!visible || !windData) { + cancelAnimationFrame(animFrameRef.current); + const canvas = canvasRef.current; + if (canvas) { + const ctx = canvas.getContext('2d'); + ctx?.clearRect(0, 0, canvas.width, canvas.height); + } + return; + } + animFrameRef.current = requestAnimationFrame(animate); + return () => cancelAnimationFrame(animFrameRef.current); + }, [visible, animate, windData]); + + // 监听地图移动/缩放以重绘 + useEffect(() => { + if (!scene || !visible) return; + + const handleViewChange = () => { + prevBoundsRef.current = ''; // 强制下次帧重新检查边界 + }; + + scene.on('mapmove', handleViewChange); + scene.on('zoomchange', handleViewChange); + return () => { + scene.off('mapmove', handleViewChange); + scene.off('zoomchange', handleViewChange); + }; + }, [scene, visible]); + + return null; +} diff --git a/packages/site/src/app/typhoon/api.ts b/packages/site/src/app/typhoon/api.ts new file mode 100644 index 0000000..9c2e54b --- /dev/null +++ b/packages/site/src/app/typhoon/api.ts @@ -0,0 +1,201 @@ +/* ================================================================ + 台风路径地图 — 气象数据获取 API + 数据来源:浙江水利台风 API + ================================================================ */ + +import { + API_LEASTCLOUD, API_LASTRADAR, API_LEASTRAIN, API_LASTWIND, + RADAR_FULL_EXTENT, RADAR_TILES, +} from './constants'; +import type { + CloudData, RadarData, RadarTileData, + RainData, RainFeature, + WindData, WindFieldRawData, +} from './types'; + +// ── 云图 ────────────────────────────────────────────────────────── +export async function fetchCloud(type: 0.5 | 1 | 3 | 6 = 1): Promise { + try { + const r = await fetch(API_LEASTCLOUD(type), { cache: 'no-store' }); + if (!r.ok) return null; + const j = await r.json() as Record; + const typeKey = type === 0.5 ? '05' : String(type); + const imgKey = `cloud${typeKey}h`; + const timeKey = `timeStr${typeKey}h`; + const img = j[imgKey]?.startsWith('data:image') ? j[imgKey] : (j[imgKey] ? `data:image/png;base64,${j[imgKey]}` : undefined); + if (!img) return null; + const minLng = Number(j.minLng), minLat = Number(j.minLat), maxLng = Number(j.maxLng), maxLat = Number(j.maxLat); + if (![minLng, minLat, maxLng, maxLat].every(Number.isFinite)) return null; + return { img, time: j[timeKey] ?? '', extent: [minLng, minLat, maxLng, maxLat] }; + } catch { return null; } +} + +// ── 雷达 ────────────────────────────────────────────────────────── +export async function fetchRadar(): Promise { + try { + const r = await fetch(API_LASTRADAR, { cache: 'no-store' }); + if (!r.ok) return null; + const j = await r.json() as Record; + const synTime = j.synTime ?? ''; + const radarType = j.radarType ?? '1'; + if (radarType === '2' && j.radar0_0?.startsWith('data:image')) { + return { tiles: [{ img: j.radar0_0, extent: RADAR_FULL_EXTENT }], time: synTime }; + } + const tiles: RadarTileData[] = []; + for (const t of RADAR_TILES) { + const raw = j[t.key]; + if (!raw) continue; + const img = raw.startsWith('data:image') ? raw : `data:image/png;base64,${raw}`; + tiles.push({ img, extent: t.extent }); + } + if (tiles.length === 0) return null; + return { tiles, time: synTime }; + } catch { return null; } +} + +// ── 降雨 ────────────────────────────────────────────────────────── +export async function fetchRain(hours: number = 24): Promise { + try { + const r = await fetch(API_LEASTRAIN(hours), { cache: 'no-store' }); + if (!r.ok) return null; + const j = await r.json() as { contours?: string; time?: string }; + const raw = typeof j.contours === 'string' ? JSON.parse(j.contours) : j.contours; + if (!Array.isArray(raw)) return null; + const features: RainFeature[] = []; + const colorSet = new Set(); + const colorToSymbol: Record = {}; + for (const c of raw as Array<{ color?: string; latAndLong?: number[][]; symbol?: string }>) { + const ll = c.latAndLong; + if (!Array.isArray(ll) || ll.length < 3) continue; + const ring: [number, number][] = ll.map(p => { + const lat = Number(p[0]), lng = Number(p[1]); + return [Number.isFinite(lng) && Number.isFinite(lat) ? lng : NaN, lat] as [number, number]; + }).filter(p => Number.isFinite(p[0]) && Number.isFinite(p[1])) as [number, number][]; + if (ring.length < 3) continue; + if (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1]) ring.push(ring[0]); + const rgba = String(c.color ?? '120,180,255,255').split(',').map(Number); + const hex = `#${rgba.slice(0, 3).map(v => Math.max(0, Math.min(255, v | 0)).toString(16).padStart(2, '0')).join('')}`; + colorSet.add(hex); + const sym = String(c.symbol ?? '0'); + colorToSymbol[hex] = sym; + features.push({ + type: 'Feature', + properties: { color: hex, symbol: sym }, + geometry: { type: 'Polygon', coordinates: [ring] }, + }); + } + if (features.length === 0) return null; + return { features, colors: [...colorSet], time: j.time ?? '', colorToSymbol }; + } catch { return null; } +} + +// ── 风场 ────────────────────────────────────────────────────────── +function windDataToImage(uData: number[], vData: number[], nx: number, ny: number): string { + const canvas = document.createElement('canvas'); + canvas.width = nx; + canvas.height = ny; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) return ''; + + const imageData = ctx.createImageData(nx, ny); + const pixels = imageData.data; + + let uMin = Infinity, uMax = -Infinity, vMin = Infinity, vMax = -Infinity; + for (let i = 0; i < uData.length; i++) { + const u = uData[i], v = vData[i]; + if (u < uMin) uMin = u; + if (u > uMax) uMax = u; + if (v < vMin) vMin = v; + if (v > vMax) vMax = v; + } + + const uRange = uMax - uMin || 1; + const vRange = vMax - vMin || 1; + + for (let i = 0; i < uData.length; i++) { + const idx = i * 4; + const uNorm = ((uData[i] - uMin) / uRange) * 255; + const vNorm = ((vData[i] - vMin) / vRange) * 255; + pixels[idx] = uNorm; + pixels[idx + 1] = vNorm; + pixels[idx + 2] = 0; + pixels[idx + 3] = 255; + } + + ctx.putImageData(imageData, 0, 0); + return canvas.toDataURL('image/png'); +} + +export async function fetchWind(): Promise { + try { + const res = await fetch(API_LASTWIND, { cache: 'no-store' }); + if (!res.ok) return null; + + const json = await res.json(); + const rawWindData = json.windData; + + const windArray = typeof rawWindData === 'string' ? JSON.parse(rawWindData) : rawWindData; + if (!Array.isArray(windArray) || windArray.length < 2) return null; + + const uItem = windArray[0]; + const vItem = windArray[1]; + const uHeader = uItem.header; + const vHeader = vItem.header; + + if (uHeader.nx !== vHeader.nx || uHeader.ny !== vHeader.ny) return null; + + const nx = uHeader.nx; + const ny = uHeader.ny; + + const imageUrl = windDataToImage(uItem.data, vItem.data, nx, ny); + if (!imageUrl) return null; + + const minLng = uHeader.lo1; + const maxLng = uHeader.lo2; + const maxLat = uHeader.la1; + const minLat = uHeader.la2; + + return { + imageUrl, + time: json.synTime ?? '', + extent: [minLng, minLat, maxLng, maxLat], + uMin: -21.32, + uMax: 26.8, + vMin: -21.57, + vMax: 21.42, + }; + } catch { return null; } +} + +/** 获取原始 U/V 网格数据,供 Canvas 粒子风场渲染 */ +export async function fetchWindFieldRaw(): Promise { + try { + const res = await fetch(API_LASTWIND, { cache: 'no-store' }); + if (!res.ok) return null; + + const json = await res.json(); + const rawWindData = json.windData; + const windArray = typeof rawWindData === 'string' ? JSON.parse(rawWindData) : rawWindData; + if (!Array.isArray(windArray) || windArray.length < 2) return null; + + const uItem = windArray.find((g: { header?: { parameterNumberName?: string } }) => + g.header?.parameterNumberName?.includes('U-component')); + const vItem = windArray.find((g: { header?: { parameterNumberName?: string } }) => + g.header?.parameterNumberName?.includes('V-component')); + + if (!uItem || !vItem) return null; + + const h = uItem.header; + return { + uData: uItem.data, + vData: vItem.data, + nx: h.nx, + ny: h.ny, + lo1: h.lo1, + la1: h.la1, + dx: h.dx, + dy: h.dy, + time: json.synTime ?? '', + }; + } catch { return null; } +} \ No newline at end of file diff --git a/packages/site/src/app/typhoon/constants.ts b/packages/site/src/app/typhoon/constants.ts new file mode 100644 index 0000000..adf4fbe --- /dev/null +++ b/packages/site/src/app/typhoon/constants.ts @@ -0,0 +1,144 @@ +/* ================================================================ + 台风路径地图 — 常量、配色与兜底数据 + ================================================================ */ + +// ── API ───────────────────────────────────────────────────────── +export const API_BASE = 'https://typhoon.slt.zj.gov.cn/Api'; +export const API_LIST = (year: number) => `${API_BASE}/TyphoonList/${year}`; +export const API_INFO = (tfid: string) => `${API_BASE}/TyphoonInfo/${tfid}`; +export const API_ACTIVITY = `${API_BASE}/TyhoonActivity`; + +// ── 气象 API ──────────────────────────────────────────────────── +export const API_LEASTCLOUD = (type: number) => `${API_BASE}/LeastCloud?type=${type}`; +export const API_LASTRADAR = `${API_BASE}/LastRadar`; +export const API_LEASTRAIN = (hours: number) => `${API_BASE}/LeastRain/${hours}`; +export const API_LASTWIND = `${API_BASE}/LastWind`; + +// ── 等级系统 ───────────────────────────────────────────────────── +export type GradeKey = 'TD' | 'TS' | 'STS' | 'TY' | 'STY' | 'SuperTY'; + +export const STRENGTH_TO_KEY: Record = { + 热带低压: 'TD', + 热带风暴: 'TS', + 强热带风暴: 'STS', + 台风: 'TY', + 强台风: 'STY', + 超强台风: 'SuperTY', +}; + +export const GRADE_ORDER: GradeKey[] = ['TD', 'TS', 'STS', 'TY', 'STY', 'SuperTY']; + +export const GRADE_LABEL: Record = { + TD: '热带低压', TS: '热带风暴', STS: '强热带风暴', + TY: '台风', STY: '强台风', SuperTY: '超强台风', +}; + +export const GRADE_COLOR: Record = { + TD: '#7dd3fc', TS: '#38bdf8', STS: '#3b82f6', TY: '#8b5cf6', STY: '#f59e0b', SuperTY: '#ef4444', +}; + +export const GRADE_LABELS = GRADE_ORDER.map(g => GRADE_LABEL[g]); +export const GRADE_COLORS = GRADE_ORDER.map(g => GRADE_COLOR[g]); + +// ── 风圈配色 ───────────────────────────────────────────────────── +export const WIND_LEVEL_KEY = ['7', '10', '12'] as const; +export const WIND_LEVEL_COLOR: Record = { '7': '#3b82f6', '10': '#f59e0b', '12': '#ef4444' }; + +// ── 预报机构配色 ────────────────────────────────────────────────── +export const AGENCIES = ['中国', '中国台湾', '日本', '中国香港', '美国'] as const; +export const AGENCY_COLOR: Record = { + 中国: '#22d3ee', 中国台湾: '#34d399', 日本: '#f472b6', 中国香港: '#fbbf24', 美国: '#a78bfa', +}; + +// ── 警戒线坐标 ──────────────────────────────────────────────────── +export const WARNING_LINE_24H: [number, number][] = [ + [127, 34], [127, 22], [119, 18], [119, 11], [113, 4.5], [105, 0], +]; + +export const WARNING_LINE_48H: [number, number][] = [ + [132, 34], [132, 15], [120, 0], [105, 0], +]; + +export const WARNING_LINE_COLORS = { + '24h': '#f59e0b', + '48h': '#ef4444', +}; + +// ── 雷达瓦片 ────────────────────────────────────────────────────── +export const RADAR_FULL_EXTENT: [number, number, number, number] = + [69.85883897374661, 12.17563341623027, 140.09971829625096, 54.338914427211094]; + +export const RADAR_TILES: { key: 'radar0_0' | 'radar0_1' | 'radar1_0' | 'radar1_1'; extent: [number, number, number, number] }[] = [ + { key: 'radar0_0', extent: [67.5, 36.580247, 104.073486, 55.7766] }, + { key: 'radar0_1', extent: [67.5, 11.1784, 104.073486, 36.580247] }, + { key: 'radar1_0', extent: [104.073486, 36.580247, 140.625, 55.7766] }, + { key: 'radar1_1', extent: [104.073486, 11.1784, 140.625, 36.580247] }, +]; + +// ── 降雨等级 ────────────────────────────────────────────────────── +export const RAIN_LEVEL_LABEL: Record = { + '0': '小雨', '2.5': '小雨', '5': '小雨', + '10': '中雨', '25': '大雨', '50': '暴雨', + '100': '大暴雨', '250': '特大暴雨', +}; + +export function getRainLevelLabel(symbol: string): string { + const num = Number(symbol); + if (!Number.isFinite(num)) return symbol || '—'; + if (num >= 250) return '特大暴雨'; + if (num >= 100) return '大暴雨'; + if (num >= 50) return '暴雨'; + if (num >= 25) return '大雨'; + if (num >= 10) return '中雨'; + return '小雨'; +} + +// ── 兜底数据 ────────────────────────────────────────────────────── +import type { TyphoonInfo, TyphoonListItem } from './types'; + +export const CURRENT_YEAR = new Date().getFullYear(); + +export const FALLBACK_INFO: TyphoonInfo = { + tfid: '202609', name: '巴威', enname: 'BAVI', isactive: '1', + starttime: '2026-07-02 08:00:00', endtime: '2026-07-10 08:00:00', + centerlng: '127.60', centerlat: '20.80', warnlevel: 'white', land: [], + points: [ + { time: '2026-07-04 02:00:00', lng: '149.00', lat: '14.10', strong: '超强台风', power: '17', speed: '58', pressure: '925', movespeed: '26', movedirection: '西北', radius7: '320|300|300|280', radius10: '120|110|110|100', radius12: '50|45|45|40' }, + { time: '2026-07-06 08:00:00', lng: '144.80', lat: '15.10', strong: '超强台风', power: '17', speed: '60', pressure: '920', movespeed: '20', movedirection: '西北', radius7: '350|320|320|300', radius10: '140|120|120|110', radius12: '60|50|50|45' }, + { time: '2026-07-08 08:00:00', lng: '137.50', lat: '16.80', strong: '超强台风', power: '17', speed: '57', pressure: '925', movespeed: '18', movedirection: '西北', radius7: '400|380|360|350', radius10: '180|150|150|140', radius12: '80|60|60|55' }, + { time: '2026-07-09 11:00:00', lng: '129.50', lat: '18.40', strong: '超强台风', power: '16', speed: '52', pressure: '935', movespeed: '19', movedirection: '西北', radius7: '500|500|500|500', radius10: '300|280|300|280', radius12: '180|120|180|120' }, + { time: '2026-07-10 02:00:00', lng: '128.20', lat: '19.90', strong: '强台风', power: '14', speed: '45', pressure: '945', movespeed: '20', movedirection: '西北', radius7: '500|500|450|480', radius10: '300|280|300|280', radius12: '180|120|180|120' }, + { time: '2026-07-10 08:00:00', lng: '127.60', lat: '20.80', strong: '强台风', power: '14', speed: '42', pressure: '955', movespeed: '19', movedirection: '北西', radius7: '500|500|450|480', radius10: '300|280|300|280', radius12: '130|110|130|110', + forecast: [ + { tm: '中国', forecastpoints: [ + { time: '2026-07-10 08:00:00', lng: '127.60', lat: '20.80', strong: '强台风', power: '14', speed: '42', pressure: '955' }, + { time: '2026-07-10 20:00:00', lng: '126.20', lat: '22.40', strong: '台风', power: '13', speed: '40', pressure: '960' }, + { time: '2026-07-11 08:00:00', lng: '122.80', lat: '24.60', strong: '台风', power: '12', speed: '35', pressure: '970' }, + { time: '2026-07-11 20:00:00', lng: '120.10', lat: '26.90', strong: '强热带风暴', power: '10', speed: '25', pressure: '985' }, + ] }, + { tm: '日本', forecastpoints: [ + { time: '2026-07-10 08:00:00', lng: '127.60', lat: '20.80', strong: '强台风', power: '14', speed: '42', pressure: '955' }, + { time: '2026-07-11 08:00:00', lng: '123.50', lat: '24.00', strong: '台风', power: '12', speed: '33', pressure: '975' }, + { time: '2026-07-12 08:00:00', lng: '119.80', lat: '27.50', strong: '强热带风暴', power: '11', speed: '30', pressure: '980' }, + ] }, + { tm: '美国', forecastpoints: [ + { time: '2026-07-10 08:00:00', lng: '127.60', lat: '20.80', strong: '强台风', power: '14', speed: '42', pressure: '955' }, + { time: '2026-07-11 08:00:00', lng: '124.00', lat: '24.40', strong: '台风', power: '12', speed: '34', pressure: '972' }, + ] }, + ] }, + ], +}; + +export const FALLBACK_LIST: TyphoonListItem[] = [ + { tfid: '202609', name: '巴威', enname: 'BAVI', starttime: '2026-07-02 08:00:00', endtime: '2026-07-10 08:00:00', isactive: '1', warnlevel: 'white' }, +]; + +// ── UI 颜色常量 ──────────────────────────────────────────────────── +export const C = { + bg: '#0f172a', + panel: 'rgba(15,23,42,0.82)', + border: 'rgba(56,189,248,0.15)', + fg: '#e2e8f0', + muted: 'rgba(148,163,184,0.7)', + accent: '#22d3ee', +}; \ No newline at end of file diff --git a/packages/site/src/app/typhoon/hooks/useTyphoonData.ts b/packages/site/src/app/typhoon/hooks/useTyphoonData.ts new file mode 100644 index 0000000..19e251f --- /dev/null +++ b/packages/site/src/app/typhoon/hooks/useTyphoonData.ts @@ -0,0 +1,228 @@ +/* ================================================================ + 台风数据获取钩子 + 管理:台风列表、活跃状态、选中台风详情、派生数据 + ================================================================ */ + +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import type { Scene } from '@antv/l7'; +import type { + TyphoonListItem, TyphoonInfo, TyphoonPoint, + TrackSegment, TrackNode, WindPolygon, LandPoint, +} from '../types'; +import type { GradeKey } from '../constants'; +import { + API_ACTIVITY, API_LIST, API_INFO, + FALLBACK_LIST, FALLBACK_INFO, CURRENT_YEAR, + AGENCIES, AGENCY_COLOR, +} from '../constants'; +import { toTrackSegments, toNodes, toWindPolygons, toForecastSegments } from '../transform'; + +export interface TyphoonDataState { + // raw data + list: TyphoonListItem[]; + activeIds: string[]; + tfid: string; + info: TyphoonInfo | null; + loading: boolean; + error: boolean; + + // selection + selectedPointIdx: number; + selectedAgency: string; + pointsDetailExpanded: boolean; + + // derived + points: TyphoonPoint[]; + trackSegments: TrackSegment[]; + nodes: TrackNode[]; + currentIdx: number; + selectedIdx: number; + selectedPoint: TyphoonPoint | undefined; + currentPoint: TyphoonPoint | undefined; + windPolygons: WindPolygon[]; + + // forecast + forecastSrc: { tm: string; forecastpoints: TyphoonPoint[] }[]; + presentAgencies: Set; + activeForecastSegs: { path: [number, number][]; agency: string }[]; + otherForecastSegs: { path: [number, number][]; agency: string }[]; + forecastPoints: { lng: number; lat: number; agency: string; time: string; strong: string; speed: string; pressure: string }[]; + + // land + landMarks: LandPoint[]; + + // setters + setTfid: (v: string) => void; + setSelectedPointIdx: (v: number) => void; + setSelectedAgency: (v: string) => void; + setPointsDetailExpanded: (v: boolean) => void; + + // scene + sceneRef: React.MutableRefObject; + handleSceneReady: (scene: Scene) => () => void; +} + +export function useTyphoonData(): TyphoonDataState { + // ── 基础状态 ── + const [list, setList] = useState([]); + const [activeIds, setActiveIds] = useState([]); + const [tfid, setTfid] = useState(''); + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + const [selectedAgency, setSelectedAgency] = useState('中国'); + const [selectedPointIdx, setSelectedPointIdx] = useState(-1); + const [pointsDetailExpanded, setPointsDetailExpanded] = useState(false); + + const sceneRef = useRef(null); + + // ── 1. 拉取当年台风列表 ── + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + let active: string[] = []; + try { + const ar = await fetch(API_ACTIVITY, { cache: 'no-store' }); + if (ar.ok) { + const aj = await ar.json() as TyphoonListItem[]; + active = (Array.isArray(aj) ? aj : []).map(t => t.tfid); + } + } catch { /* ignore */ } + try { + const r = await fetch(API_LIST(CURRENT_YEAR), { cache: 'no-store' }); + if (!r.ok) throw new Error('list'); + const j = (await r.json()) as TyphoonListItem[]; + if (cancelled) return; + const arr = Array.isArray(j) ? j : []; + if (arr.length === 0) throw new Error('empty'); + setList(arr); + setActiveIds(active); + const chosen = active[0] ?? [...arr].sort((a, b) => (b.starttime || '').localeCompare(a.starttime || ''))[0]?.tfid; + setTfid(chosen ?? arr[0].tfid); + setError(false); + } catch { + if (cancelled) return; + setList(FALLBACK_LIST); + setActiveIds(['202609']); + setTfid('202609'); + setError(true); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, []); + + // ── 2. 选中 tfid → 拉取详情 ── + useEffect(() => { + if (!tfid) return; + let cancelled = false; + (async () => { + if (tfid === FALLBACK_INFO.tfid) setInfo(FALLBACK_INFO); + try { + const r = await fetch(API_INFO(tfid), { cache: 'no-store' }); + if (!r.ok) throw new Error('info'); + const j = (await r.json()) as TyphoonInfo; + if (cancelled) return; + if (j && Array.isArray(j.points) && j.points.length > 0) { + setInfo(j); + setError(false); + } else { + throw new Error('empty'); + } + } catch { + if (cancelled) return; + setInfo(FALLBACK_INFO); + setError(true); + } + })(); + return () => { cancelled = true; }; + }, [tfid]); + + // ── 3. 派生数据 ── + const points = info?.points ?? []; + const trackSegments = useMemo(() => toTrackSegments(points), [points]); + const nodes = useMemo(() => toNodes(points), [points]); + const currentIdx = points.length - 1; + const selectedIdx = selectedPointIdx >= 0 && selectedPointIdx < points.length ? selectedPointIdx : currentIdx; + const selectedPoint = points[selectedIdx]; + const currentPoint = points[currentIdx]; + + const windPolygons = useMemo(() => toWindPolygons(selectedPoint), [selectedPoint]); + + const forecastSrc = useMemo( + () => selectedPoint?.forecast ?? currentPoint?.forecast ?? [], + [selectedPoint, currentPoint], + ); + const presentAgencies = useMemo(() => new Set(forecastSrc.map(f => f.tm)), [forecastSrc]); + + const activeForecastSegs = useMemo(() => { + const a = forecastSrc.find(f => f.tm === selectedAgency); + return toForecastSegments(a).map(s => ({ path: s.path, agency: selectedAgency })); + }, [forecastSrc, selectedAgency]); + + const otherForecastSegs = useMemo(() => { + const out: { path: [number, number][]; agency: string }[] = []; + for (const a of forecastSrc) { + if (a.tm === selectedAgency) continue; + for (const seg of toForecastSegments(a)) out.push({ path: seg.path, agency: a.tm }); + } + return out; + }, [forecastSrc, selectedAgency]); + + const forecastPoints = useMemo(() => { + const pts: { lng: number; lat: number; agency: string; time: string; strong: string; speed: string; pressure: string }[] = []; + for (const a of forecastSrc) { + for (const pt of a.forecastpoints ?? []) { + const lng = Number(pt.lng), lat = Number(pt.lat); + if (Number.isFinite(lng) && Number.isFinite(lat)) { + pts.push({ lng, lat, agency: a.tm, time: String(pt.time ?? ''), strong: String(pt.strong ?? ''), speed: String(pt.speed ?? ''), pressure: String(pt.pressure ?? '') }); + } + } + } + return pts; + }, [forecastSrc]); + + const landMarks = useMemo(() => { + const lands = info?.land ?? []; + return lands.filter(l => { + const lng = Number(l.lng), lat = Number(l.lat); + return Number.isFinite(lng) && Number.isFinite(lat); + }); + }, [info]); + + // ── Scene ready ── + const handleSceneReady = useCallback((scene: Scene) => { + sceneRef.current = scene; + return () => {}; + }, []); + + // ── fitBounds ── + useEffect(() => { + const scene = sceneRef.current; + if (!scene || trackSegments.length === 0) return; + const coords = points.map(p => [Number(p.lng), Number(p.lat)] as [number, number]).filter(c => Number.isFinite(c[0]) && Number.isFinite(c[1])); + if (coords.length === 0) return; + const lngs = coords.map(c => c[0]), lats = coords.map(c => c[1]); + const pad = 4; + scene.fitBounds( + [[Math.min(...lngs) - pad, Math.min(...lats) - pad], [Math.max(...lngs) + pad, Math.max(...lats) + pad]], + { padding: [80, 80, 80, 80] }, + ); + setSelectedPointIdx(-1); + }, [tfid, trackSegments.length]); // eslint-disable-line react-hooks/exhaustive-deps + + return { + list, activeIds, tfid, info, loading, error, + selectedPointIdx, selectedAgency, pointsDetailExpanded, + points, trackSegments, nodes, + currentIdx, selectedIdx, selectedPoint, currentPoint, + windPolygons, + forecastSrc, presentAgencies, activeForecastSegs, otherForecastSegs, forecastPoints, + landMarks, + setTfid, setSelectedPointIdx, setSelectedAgency, setPointsDetailExpanded, + sceneRef, handleSceneReady, + }; +} \ No newline at end of file diff --git a/packages/site/src/app/typhoon/hooks/useTyphoonPopup.ts b/packages/site/src/app/typhoon/hooks/useTyphoonPopup.ts new file mode 100644 index 0000000..efc2f8d --- /dev/null +++ b/packages/site/src/app/typhoon/hooks/useTyphoonPopup.ts @@ -0,0 +1,137 @@ +/* ================================================================ + Popup/Tooltip 交互钩子 + 管理:节点/预报 hover & click 事件、Popup/Tooltip 状态 + ================================================================ */ + +import { useState, useCallback, useRef } from 'react'; +import type { LayerEventPayload, PopupAttribute } from '@antv/aimapui'; +import type { TyphoonInfo, PopupData, TooltipState } from '../types'; +import { GRADE_COLOR, STRENGTH_TO_KEY, AGENCY_COLOR } from '../constants'; + +export interface TyphoonPopupState { + popup: PopupData | null; + tooltip: TooltipState; + + setPopup: (v: PopupData | null) => void; + + handleNodeHover: (payload: LayerEventPayload) => void; + handleNodeLeave: () => void; + handleNodeClick: (payload: LayerEventPayload) => void; + + handleForecastClick: (payload: LayerEventPayload) => void; + handleForecastPointHover: (payload: LayerEventPayload) => void; + handleForecastPointLeave: () => void; + handleForecastPointClick: (payload: LayerEventPayload) => void; +} + +export function useTyphoonPopup( + deps: { info: TyphoonInfo | null; selectedAgency: string }, +): TyphoonPopupState { + const { info, selectedAgency } = deps; + const [popup, setPopup] = useState(null); + const [tooltip, setTooltip] = useState({ visible: false, lng: 0, lat: 0, time: '', strong: '', power: '', speed: '', pressure: '' }); + const hoverRef = useRef<{ lng: number; lat: number; time: string; strong: string; power: string; speed: string; pressure: string } | null>(null); + + // ── 节点交互 ── + const handleNodeHover = useCallback((payload: LayerEventPayload) => { + const f = payload.feature as Record | undefined; + if (!f) return; + hoverRef.current = { + lng: payload.lng, lat: payload.lat, + time: String(f.time ?? ''), strong: String(f.strong ?? ''), power: String(f.power ?? ''), + speed: String(f.speed ?? ''), pressure: String(f.pressure ?? ''), + }; + setTooltip({ visible: true, ...hoverRef.current }); + }, []); + + const handleNodeLeave = useCallback(() => setTooltip(t => ({ ...t, visible: false })), []); + + const handleNodeClick = useCallback((payload: LayerEventPayload) => { + const f = payload.feature as Record | undefined; + if (!f) return; + const lng = payload.lng, lat = payload.lat; + const strong = String(f.strong ?? ''), power = String(f.power ?? ''), speed = String(f.speed ?? ''), + pressure = String(f.pressure ?? ''), time = String(f.time ?? ''), + movespeed = String(f.movespeed ?? ''), movedirection = String(f.movedirection ?? ''), + radius7 = String(f.radius7 ?? ''), radius10 = String(f.radius10 ?? ''), radius12 = String(f.radius12 ?? ''); + const key = STRENGTH_TO_KEY[strong] ?? 'TS'; + const formatRadius = (r: string) => { + if (!r || r === 'undefined') return '—'; + const vals = r.split('|').map(Number).filter(Number.isFinite); + if (vals.length === 0) return '—'; + const min = Math.min(...vals), max = Math.max(...vals); + return min === max ? `${min}公里` : `${min}-${max}公里`; + }; + setPopup({ + lng, lat, + title: info?.name ? `${info.name}(${info.enname})` : '台风', + statusLabel: strong, + statusColor: GRADE_COLOR[key], + attrs: [ + { label: '时间', value: time.slice(0, 16).replace('T', ' ') || '—' }, + { label: '中心位置', value: `${Number(lng).toFixed(2)}° / ${Number(lat).toFixed(2)}°` }, + { label: '风速风力', value: speed && power ? `${speed}米/秒,${power}级(${strong})` : '—', valueColor: GRADE_COLOR[key] }, + { label: '中心气压', value: pressure ? `${pressure}百帕` : '—' }, + { label: '移速移向', value: movespeed && movedirection ? `${movespeed}公里/小时,${movedirection}` : '—' }, + { label: '七级半径', value: formatRadius(radius7) }, + { label: '十级半径', value: formatRadius(radius10) }, + { label: '十二级半径', value: formatRadius(radius12) }, + ], + }); + }, [info]); + + // ── 预报交互 ── + const handleForecastClick = useCallback((payload: LayerEventPayload) => { + const f = payload.feature as Record | undefined; + const agencyName = String(f?.agency ?? selectedAgency); + setPopup({ + lng: payload.lng, lat: payload.lat, + title: `${agencyName} 预报路径`, statusLabel: '预报', statusColor: AGENCY_COLOR[agencyName], + attrs: [ + { label: '机构', value: agencyName }, + { label: '路径类型', value: '预报(虚线)' }, + { label: '经度', value: `${Number(payload.lng).toFixed(1)}°` }, + { label: '纬度', value: `${Number(payload.lat).toFixed(1)}°` }, + ], + }); + }, [selectedAgency]); + + const handleForecastPointHover = useCallback((payload: LayerEventPayload) => { + const f = payload.feature as Record | undefined; + if (!f) return; + hoverRef.current = { + lng: payload.lng, lat: payload.lat, + time: String(f.time ?? ''), strong: String(f.strong ?? ''), power: '', + speed: String(f.speed ?? ''), pressure: String(f.pressure ?? ''), + }; + setTooltip({ visible: true, ...hoverRef.current }); + }, []); + + const handleForecastPointLeave = useCallback(() => setTooltip(t => ({ ...t, visible: false })), []); + + const handleForecastPointClick = useCallback((payload: LayerEventPayload) => { + const f = payload.feature as Record | undefined; + if (!f) return; + const agencyName = String(f.agency ?? ''); + const time = String(f.time ?? ''), strong = String(f.strong ?? ''), + speed = String(f.speed ?? ''), pressure = String(f.pressure ?? ''); + setPopup({ + lng: payload.lng, lat: payload.lat, + title: `${agencyName} 预报点`, statusLabel: strong || '预报', statusColor: AGENCY_COLOR[agencyName] ?? '#22d3ee', + attrs: [ + { label: '机构', value: agencyName }, + { label: '时间', value: time.slice(0, 16).replace('T', ' ') || '—' }, + { label: '中心位置', value: `${Number(payload.lng).toFixed(2)}° / ${Number(payload.lat).toFixed(2)}°` }, + { label: '风速', value: speed ? `${speed}米/秒` : '—' }, + { label: '强度', value: strong || '—' }, + { label: '中心气压', value: pressure ? `${pressure}百帕` : '—' }, + ], + }); + }, []); + + return { + popup, tooltip, setPopup, + handleNodeHover, handleNodeLeave, handleNodeClick, + handleForecastClick, handleForecastPointHover, handleForecastPointLeave, handleForecastPointClick, + }; +} \ No newline at end of file diff --git a/packages/site/src/app/typhoon/hooks/useWeatherData.ts b/packages/site/src/app/typhoon/hooks/useWeatherData.ts new file mode 100644 index 0000000..86e9b70 --- /dev/null +++ b/packages/site/src/app/typhoon/hooks/useWeatherData.ts @@ -0,0 +1,95 @@ +/* ================================================================ + 气象图层数据获取钩子 + 管理:图层切换、云图/雷达/降雨状态、透明度 + ================================================================ */ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import type { CloudData, RadarData, RainData, RainTooltipState, WindFieldRawData } from '../types'; +import { fetchCloud, fetchRadar, fetchRain, fetchWindFieldRaw } from '../api'; +import type { LayerEventPayload } from '@antv/aimapui'; + +export interface WeatherDataState { + weatherLayer: 'none' | 'cloud' | 'radar' | 'rain' | 'satellite' | 'wind'; + weatherOpacity: number; + cloudType: 0.5 | 1 | 3 | 6; + rainHours: 24 | 48 | 72; + cloud: CloudData | null; + radar: RadarData | null; + rain: RainData | null; + satellite: boolean; + satProvider: 'gaode' | 'tianditu' | 'google'; + satOpacity: number; + windField: WindFieldRawData | null; + + rainTooltip: RainTooltipState; + + setWeatherLayer: (v: 'none' | 'cloud' | 'radar' | 'rain' | 'satellite' | 'wind') => void; + setWeatherOpacity: (v: number) => void; + setCloudType: (v: 0.5 | 1 | 3 | 6) => void; + setRainHours: (v: 24 | 48 | 72) => void; + setSatellite: (v: boolean) => void; + setSatProvider: (v: 'gaode' | 'tianditu' | 'google') => void; + setSatOpacity: (v: number) => void; + + handleRainTooltipMove: (payload: LayerEventPayload) => void; + handleRainTooltipLeave: () => void; +} + +export function useWeatherData(): WeatherDataState { + const [weatherLayer, setWeatherLayer] = useState<'none' | 'cloud' | 'radar' | 'rain' | 'satellite' | 'wind'>('none'); + const [weatherOpacity, setWeatherOpacity] = useState(0.7); + const [cloudType, setCloudType] = useState<0.5 | 1 | 3 | 6>(1); + const [rainHours, setRainHours] = useState<24 | 48 | 72>(24); + const [cloud, setCloud] = useState(null); + const [radar, setRadar] = useState(null); + const [rain, setRain] = useState(null); + const [satellite, setSatellite] = useState(false); + const [satProvider, setSatProvider] = useState<'gaode' | 'tianditu' | 'google'>('gaode'); + const [satOpacity, setSatOpacity] = useState(0.8); + const [windField, setWindField] = useState(null); + + const [rainTooltip, setRainTooltip] = useState({ visible: false, lng: 0, lat: 0, symbol: '', color: '' }); + + // ── 气象图层按需拉取 ── + useEffect(() => { + if (weatherLayer === 'cloud' && !cloud) { + fetchCloud(cloudType).then(d => { if (d) setCloud(d); }); + } else if (weatherLayer === 'radar' && !radar) { + fetchRadar().then(d => { if (d) setRadar(d); }); + } else if (weatherLayer === 'rain' && !rain) { + fetchRain(rainHours).then(d => { if (d) setRain(d); }); + } else if (weatherLayer === 'wind' && !windField) { + fetchWindFieldRaw().then(d => { if (d) setWindField(d); }); + } + }, [weatherLayer, cloudType, rainHours, cloud, radar, rain, windField]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── 云图时段切换 ── + useEffect(() => { if (weatherLayer === 'cloud') setCloud(null); }, [cloudType]); + + // ── 降雨时段切换 ── + useEffect(() => { if (weatherLayer === 'rain') setRain(null); }, [rainHours]); + + const handleRainTooltipMove = useCallback((payload: LayerEventPayload) => { + const f = payload.feature as Record | undefined; + const symbol = String(f?.symbol ?? ''); + setRainTooltip({ + visible: true, + lng: payload.lng, lat: payload.lat, + symbol, + color: String(f?.color ?? ''), + }); + }, []); + + const handleRainTooltipLeave = useCallback(() => { + setRainTooltip((prev) => ({ ...prev, visible: false })); + }, []); + + return { + weatherLayer, weatherOpacity, cloudType, rainHours, + cloud, radar, rain, satellite, satProvider, satOpacity, windField, + rainTooltip, + setWeatherLayer, setWeatherOpacity, setCloudType, setRainHours, + setSatellite, setSatProvider, setSatOpacity, + handleRainTooltipMove, handleRainTooltipLeave, + }; +} \ No newline at end of file diff --git a/packages/site/src/app/typhoon/index.ts b/packages/site/src/app/typhoon/index.ts new file mode 100644 index 0000000..5b5c2ad --- /dev/null +++ b/packages/site/src/app/typhoon/index.ts @@ -0,0 +1,8 @@ +/* ================================================================ + 台风路径地图 — 导出入口 + 保留原 import 路径兼容:import TyphoonMap from './TyphoonMap' + 现在位于 typhoon/ 子目录,通过此文件转发 + ================================================================ */ + +export { default as default } from './TyphoonMap'; +export { default as TyphoonMap } from './TyphoonMap'; \ No newline at end of file diff --git a/packages/site/src/app/typhoon/transform.ts b/packages/site/src/app/typhoon/transform.ts new file mode 100644 index 0000000..adeee63 --- /dev/null +++ b/packages/site/src/app/typhoon/transform.ts @@ -0,0 +1,117 @@ +/* ================================================================ + 台风路径地图 — 数据转换工具 + ================================================================ */ + +import type { TyphoonPoint, ForecastAgency, TrackSegment, TrackNode, WindPolygon } from './types'; +import { STRENGTH_TO_KEY, type GradeKey } from './constants'; + +// ── 四象限半径解析 ─────────────────────────────────────────── +export function parseRadii(s: string | undefined | null): [number, number, number, number] { + if (!s) return [0, 0, 0, 0]; + const parts = s.split('|'); + const n = (v?: string) => { + const num = Number(v); + return Number.isFinite(num) && num > 0 ? num : 0; + }; + return [n(parts[0]), n(parts[1]), n(parts[2]), n(parts[3])]; +} + +// ── 等级 ───────────────────────────────────────────────────── +export function pointGrade(p: TyphoonPoint): GradeKey { + return STRENGTH_TO_KEY[p.strong] ?? 'TS'; +} + +// ── 轨迹段 ─────────────────────────────────────────────────── +export function toTrackSegments(points: TyphoonPoint[]): TrackSegment[] { + const segs: TrackSegment[] = []; + for (let i = 1; i < points.length; i++) { + const a = points[i - 1], b = points[i]; + const aLng = Number(a.lng), aLat = Number(a.lat), bLng = Number(b.lng), bLat = Number(b.lat); + if (![aLng, aLat, bLng, bLat].every(Number.isFinite)) continue; + segs.push({ path: [[aLng, aLat], [bLng, bLat]], grade: pointGrade(b) }); + } + return segs; +} + +// ── 路径节点 ───────────────────────────────────────────────── +export function toNodes(points: TyphoonPoint[]): TrackNode[] { + return points + .map((p, i) => { + const lng = Number(p.lng), lat = Number(p.lat); + if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null; + return { lng, lat, grade: pointGrade(p), time: p.time, strong: p.strong, power: p.power, speed: p.speed, pressure: p.pressure, index: i }; + }) + .filter(Boolean) as TrackNode[]; +} + +// ── 球面几何:从起点沿方位角走指定距离 ────────────────────── +export function destinationPoint( + startLng: number, startLat: number, distanceKm: number, bearingDeg: number, +): [number, number] { + const R = 6371; + const dR = distanceKm / R; + const lat1 = (startLat * Math.PI) / 180; + const lng1 = (startLng * Math.PI) / 180; + const bearing = (bearingDeg * Math.PI) / 180; + const lat2 = Math.asin( + Math.sin(lat1) * Math.cos(dR) + + Math.cos(lat1) * Math.sin(dR) * Math.cos(bearing), + ); + const lng2 = lng1 + Math.atan2( + Math.sin(bearing) * Math.sin(dR) * Math.cos(lat1), + Math.cos(dR) - Math.sin(lat1) * Math.sin(lat2), + ); + return [(lng2 * 180) / Math.PI, (lat2 * 180) / Math.PI]; +} + +// ── 四象限风圈 → GeoJSON Polygon ──────────────────────────── +export function toWindPolygons(p: TyphoonPoint | undefined): WindPolygon[] { + if (!p) return []; + const lng = Number(p.lng), lat = Number(p.lat); + if (!Number.isFinite(lng) || !Number.isFinite(lat)) return []; + const out: WindPolygon[] = []; + const QUADRANT_ANGLES: [number, number][] = [[0, 90], [90, 180], [180, 270], [270, 360]]; + const buildArc = (radiusKm: number, startDeg: number, endDeg: number): [number, number][] => { + const points: [number, number][] = []; + const steps = Math.max(12, Math.round((endDeg - startDeg) / 3)); + for (let i = 0; i <= steps; i++) { + const bearing = startDeg + (endDeg - startDeg) * (i / steps); + points.push(destinationPoint(lng, lat, radiusKm, bearing)); + } + return points; + }; + const pushLevel = (level: string, radii: [number, number, number, number]) => { + const ring: [number, number][] = []; + let hasAny = false; + for (let qi = 0; qi < 4; qi++) { + if (radii[qi] > 0) { + hasAny = true; + const [startDeg, endDeg] = QUADRANT_ANGLES[qi]; + ring.push(...buildArc(radii[qi], startDeg, endDeg)); + } + } + if (!hasAny || ring.length < 3) return; + if (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1]) { + ring.push(ring[0]); + } + out.push({ coordinates: [ring], level }); + }; + pushLevel('7', parseRadii(p.radius7)); + pushLevel('10', parseRadii(p.radius10)); + pushLevel('12', parseRadii(p.radius12)); + return out; +} + +// ── 预报路径段 ─────────────────────────────────────────────── +export function toForecastSegments(agency: ForecastAgency | undefined): TrackSegment[] { + if (!agency) return []; + const pts = agency.forecastpoints; + const segs: TrackSegment[] = []; + for (let i = 1; i < pts.length; i++) { + const a = pts[i - 1], b = pts[i]; + const aLng = Number(a.lng), aLat = Number(a.lat), bLng = Number(b.lng), bLat = Number(b.lat); + if (![aLng, aLat, bLng, bLat].every(Number.isFinite)) continue; + segs.push({ path: [[aLng, aLat], [bLng, bLat]], grade: pointGrade(b) }); + } + return segs; +} \ No newline at end of file diff --git a/packages/site/src/app/typhoon/types.ts b/packages/site/src/app/typhoon/types.ts new file mode 100644 index 0000000..fe33adf --- /dev/null +++ b/packages/site/src/app/typhoon/types.ts @@ -0,0 +1,133 @@ +/* ================================================================ + 台风路径地图 — 类型定义 + ================================================================ */ + +import type { GradeKey } from './constants'; +import type { PopupAttribute } from '@antv/aimapui'; + +// ── 台风数据 ───────────────────────────────────────────────────── +export interface TyphoonPoint { + time: string; + lng: string; + lat: string; + strong: string; + power: string; + speed: string; + pressure: string; + movespeed?: string; + movedirection?: string; + radius7?: string; + radius10?: string; + radius12?: string; + forecast?: ForecastAgency[]; +} + +export interface ForecastAgency { + tm: string; + forecastpoints: TyphoonPoint[]; +} + +export interface LandPoint { + landaddress: string; + landtime: string; + lng: string; + lat: string; + info: string; + strong: string | null; +} + +export interface TyphoonInfo { + tfid: string; + name: string; + enname: string; + isactive?: string; + starttime?: string; + endtime?: string; + warnlevel?: string; + centerlng?: string; + centerlat?: string; + points: TyphoonPoint[]; + land?: LandPoint[]; +} + +export interface TyphoonListItem { + tfid: string; + name: string; + enname: string; + starttime: string; + endtime: string; + isactive: string; + warnlevel?: string; +} + +// ── 派生数据形状 ───────────────────────────────────────────────── +export interface TrackSegment { path: [number, number][]; grade: GradeKey; } +export interface TrackNode { lng: number; lat: number; grade: GradeKey; time: string; strong: string; power: string; speed: string; pressure: string; index: number; } +export interface WindPolygon { coordinates: [number, number][][]; level: string; } + +// ── 气象数据 ───────────────────────────────────────────────────── +export interface CloudData { img: string; time: string; extent: [number, number, number, number]; } +export interface RadarTileData { img: string; extent: [number, number, number, number]; } +export interface RadarData { tiles: RadarTileData[]; time: string; } +export interface RainFeature { type: 'Feature'; properties: { color: string; symbol: string }; geometry: { type: 'Polygon'; coordinates: [number, number][][] } } +export interface RainData { features: RainFeature[]; colors: string[]; time: string; colorToSymbol: Record; } + +export interface WindData { + imageUrl: string; + time: string; + extent: [number, number, number, number]; + uMin: number; uMax: number; + vMin: number; vMax: number; +} + +/** Canvas 粒子风场所需的原始 U/V 网格数据 */ +export interface WindFieldRawData { + uData: number[]; + vData: number[]; + nx: number; + ny: number; + lo1: number; + la1: number; + dx: number; + dy: number; + time: string; +} + +export interface WindGribHeader { + nx: number; ny: number; + lo1: number; la1: number; + dx: number; dy: number; + parameterNumberName: string; + parameterUnit: string; +} + +export interface WindGribResponse { + header: WindGribHeader; + data: number[]; +} + +// ── UI 状态 ────────────────────────────────────────────────────── +export interface PopupData { + lng: number; lat: number; + title: string; statusLabel: string; statusColor: string; + attrs: PopupAttribute[]; +} + +export interface TooltipState { + visible: boolean; + lng: number; + lat: number; + time: string; + strong: string; + power: string; + speed: string; + pressure: string; +} + +export interface RainTooltipState { + visible: boolean; + lng: number; + lat: number; + symbol: string; + color: string; +} \ No newline at end of file diff --git a/packages/site/src/composite/MarkerCluster.tsx b/packages/site/src/composite/MarkerCluster.tsx index 0fd915e..91798e4 100644 --- a/packages/site/src/composite/MarkerCluster.tsx +++ b/packages/site/src/composite/MarkerCluster.tsx @@ -92,9 +92,9 @@ export default function Demo33MarkerCluster() { // eslint-disable-next-line no-console console.log('点击点位:', point.properties?.name); }} - onClusterClick={(cluster: any, leaves: any[]) => { + onClusterClick={(cluster: any, leaves?: any[]) => { // eslint-disable-next-line no-console - console.log('点击聚合点:', leaves.length, '个要素'); + console.log('点击聚合点:', leaves?.length ?? 0, '个要素'); }} /> diff --git a/packages/site/src/composite/RouteLayer.tsx b/packages/site/src/composite/RouteLayer.tsx index 1dcd851..368b483 100644 --- a/packages/site/src/composite/RouteLayer.tsx +++ b/packages/site/src/composite/RouteLayer.tsx @@ -55,10 +55,10 @@ const day1Stops = [ ]; const day2Stops = [ - { lng: 120.0990, lat: 30.2400, name: '灵隐寺', markerVariant: 'pin' as const, markerColor: 'success' as const }, - { lng: 120.1050, lat: 30.2450, name: '飞来峰', markerVariant: 'circle' as const, markerColor: 'warning' as const }, - { lng: 120.1000, lat: 30.2550, name: '北高峰', markerVariant: 'dot' as const, markerColor: 'primary' as const }, - { lng: 120.0700, lat: 30.2700, name: '西溪湿地', markerVariant: 'pin' as const, markerColor: 'error' as const }, + { lng: 120.0990, lat: 30.2400, name: '灵隐寺' }, + { lng: 120.1050, lat: 30.2450, name: '飞来峰' }, + { lng: 120.1000, lat: 30.2550, name: '北高峰' }, + { lng: 120.0700, lat: 30.2700, name: '西溪湿地' }, ]; const day3Stops = [ @@ -87,42 +87,29 @@ export default function RouteLayerDemo() { style: 'light', }} > - {/* Day 1 — 西湖环线 */} + {/* Day 1 — 西湖环线(point 渲染,显式指定) */} - {/* Day 2 — 灵隐-西溪 */} + {/* Day 2 — 灵隐-西溪(marker 渲染,组件默认值) */} - {/* Day 3 — 钱塘江-滨江 */} + {/* Day 3 — 钱塘江-滨江(icon 渲染) */} diff --git a/packages/site/src/docs/DocsPage.tsx b/packages/site/src/docs/DocsPage.tsx index ff335c4..c3fe376 100644 --- a/packages/site/src/docs/DocsPage.tsx +++ b/packages/site/src/docs/DocsPage.tsx @@ -18,7 +18,6 @@ interface DocsPageProps { onToggleTheme: () => void; onNavigateHome: () => void; onNavigateDemo: () => void; - onNavigateDesign: () => void; onNavigateBlock: () => void; onNavigateSkill?: () => void; docsMap: Record; // id → markdown content diff --git a/packages/site/src/docs/content/composite-layers/h3-layer.md b/packages/site/src/docs/content/composite-layers/h3-layer.md new file mode 100644 index 0000000..cdbfa09 --- /dev/null +++ b/packages/site/src/docs/content/composite-layers/h3-layer.md @@ -0,0 +1,180 @@ +# H3Layer + +H3 六边形网格图层,基于 [Uber H3](https://h3geo.org/) 空间索引系统,将 H3 cell index 转换为六边形多边形并渲染。适合已有 H3 编码数据的可视化场景(如出行 OD 分析、网格化指标展示),与 [HexagonLayer](./hexagon-layer) 不同的是,H3Layer 直接使用 H3 索引而非经纬度聚合,网格层级由 H3 分辨率决定。 + +> **何时选择:** 数据已包含 H3 索引(如 `85440637fffffff`)、需要跨数据集对齐网格时用 H3Layer;原始数据为经纬度点、需要前端聚合时用 [HexagonLayer](./hexagon-layer);只需展示平滑密度趋势时用 [HeatmapLayer](../layers/heatmap-layer)。 + +## 导入 + +```tsx +import { H3Layer } from '@antv/aimapui' +``` + +## Props + +H3Layer 继承 [LayerSchema 公共属性](../layers/point-layer#公共属性),此处仅列出专有属性和重定义属性。`sourceType` 已固定为 `'geojson'`(内部自动将 H3 索引转换为 GeoJSON Feature),不可更改。 + +### 数据源 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `source` | `H3DataItem[]` | **必填** | H3 数据数组,每项须包含 H3 索引字段(字段名由 `h3Field` 指定),其余字段作为属性透传至 GeoJSON Feature | +| `h3Field` | `string` | `'h3'` | 数据中 H3 索引字段的名称,如 `'h3_index'`、`'hex_id'` 等 | + +### 颜色映射 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `color` | `string` | - | 固定填充色,未设置 `colorField` 时生效 | +| `colorField` | `string` | - | 颜色映射字段,通常为数据中的数值字段(如 `'value'`) | +| `colorValues` | `string[]` | - | 自定义色板,如 `['#DBEAFE', '#3B82F6', '#1E3A8A']`;不传时使用 `colorScheme` 对应的内置色板 | +| `colorScheme` | `ColorScheme` | `'sequential'` | 内置色板预设,可选 `'sequential'`、`'diverging'` 等 | + +### 边线配置 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `showStroke` | `boolean` | `true` | 是否显示六边形边线,关闭后格子间无描边 | +| `strokeColor` | `string` | `'rgba(255,255,255,0.3)'` | 边线颜色 | +| `strokeWidth` | `number` | `0.5` | 边线宽度(像素) | + +### 标签配置 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `showLabel` | `boolean` | `false` | 是否在六边形中心显示文字标签 | +| `labelField` | `string` | - | 标签文字来源字段,如 `'name'`、`'value'`;`showLabel` 为 `true` 且该字段有值时才显示 | +| `labelColor` | `string` | `'#333'` | 标签文字颜色 | +| `labelSize` | `number` | `12` | 标签文字大小(像素) | + +### 交互效果 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `hoverEffect` | `boolean` | `false` | 是否启用 hover 高亮效果,开启后鼠标悬停时六边形高亮为 `#2563eb` | +| `clickEffect` | `boolean` | `false` | 是否启用点击选中效果,开启后点击时六边形高亮为 `#1d4ed8` | + +### 事件 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `onClick` | `(payload: [LayerEventPayload](../layers/point-layer#layereventpayload)) => void` | 六边形点击回调,`feature.properties` 中包含原始数据字段及 `_h3Index` | +| `onMouseMove` | `(payload: LayerEventPayload) => void` | 鼠标在六边形上移动 | +| `onMouseEnter` | `(payload: LayerEventPayload) => void` | 鼠标进入六边形 | +| `onMouseLeave` | `(payload: LayerEventPayload) => void` | 鼠标离开六边形 | + +### 公共属性(继承自 LayerSchema) + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `visible` | `boolean` | `true` | 图层可见性 | +| `opacity` | `number` | `0.8` | 不透明度 (0~1) | +| `blend` | `'normal' \| 'additive' \| 'subtractive' \| 'max'` | `'normal'` | 混合模式 | +| `zIndex` | `number` | `0` | 图层层级,边线层自动 `+2`,标签层自动 `+10` | +| `minZoom` | `number` | - | 最小可见缩放级别 | +| `maxZoom` | `number` | - | 最大可见缩放级别 | +| `autoFit` | `boolean` | `false` | 数据加载后是否自动适配视图范围 | +| `active` | `boolean \| { color: string }` | - | hover 高亮配置,未设置时由 `hoverEffect` 控制 | +| `select` | `boolean \| { color: string }` | - | 选中态配置,未设置时由 `clickEffect` 控制 | +| `name` | `string` | - | 图层名称 | +| `id` | `string` | 自动生成 | 图层唯一标识 | + +### 样式(style 对象) + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `style` | `object` | - | 多边形样式,透传给底层 PolygonLayer,可配置 `opacity` 等属性 | + +## 示例 + +### 基础用法 -- H3 网格指标可视化 + +```tsx +import { AiMap, H3Layer } from '@antv/aimapui' + +// 数据包含 H3 索引和数值指标 +const h3Data = [ + { h3: '85440637fffffff', value: 42 }, + { h3: '85440633fffffff', value: 18 }, + { h3: '85440631fffffff', value: 87 }, + { h3: '8544063bfffffff', value: 55 }, +] + + + + +``` + +### 自定义 h3Field 字段名 + +数据中 H3 索引字段不是默认的 `'h3'` 时,通过 `h3Field` 指定: + +```tsx +const data = [ + { hex_id: '85440637fffffff', population: 1200 }, + { hex_id: '85440633fffffff', population: 800 }, +] + + +``` + +### 开启边线、标签和交互 + +```tsx + { + console.log('H3:', payload.feature.properties._h3Index) + console.log('Value:', payload.feature.properties.value) + }} +/> +``` + +### 使用内置色板 + +不传 `colorValues` 时,使用 `colorScheme` 对应的内置色板: + +```tsx + +``` + +## 注意事项 + +- H3Layer 仅接收包含 H3 索引的数据数组,不接受经纬度点数据;若原始数据为经纬度,需先使用 `h3-js` 的 `latLngToCell` 转换为 H3 索引,或使用 [HexagonLayer](./hexagon-layer) 进行前端聚合 +- 无效的 H3 索引(`isValidCell` 校验失败)会被自动过滤,不会渲染也不会报错 +- H3 网格的分辨率(粒度)由索引本身决定(0~15 级,数字越大格子越小),不同分辨率的数据可以混合渲染 +- `hoverEffect` 和 `clickEffect` 是 `active` / `select` 的便捷封装;若同时传入 `active` 和 `hoverEffect`,`active` 优先生效 +- 边线层(LineLayer)和标签层(PointLayer)的 `zIndex` 分别在基础值上 `+2` 和 `+10`,确保标签始终显示在最上层 +- 数据量较大时(超过 5 万个 H3 cell),建议关闭 `showLabel` 和 `showStroke` 以提升渲染性能 + +## 相关组件 + +- [HexagonLayer](./hexagon-layer) -- 经纬度点数据的六边形聚合图层,适合原始点数据的前端聚合 +- [HeatmapLayer](../layers/heatmap-layer) -- 平滑热力渐变,适合展示密度趋势 +- [BubbleLayer](./bubble-layer) -- 圆形气泡图层,适合分级统计 +- [PolygonLayer](../layers/polygon-layer) -- H3Layer 内部使用的多边形渲染图层 +- [PointLayer](../layers/point-layer) -- 基础散点/文本图层,H3Layer 内部用于标签渲染 diff --git a/packages/site/src/docs/content/composite-layers/route-layer.md b/packages/site/src/docs/content/composite-layers/route-layer.md index 57b391c..0744db0 100644 --- a/packages/site/src/docs/content/composite-layers/route-layer.md +++ b/packages/site/src/docs/content/composite-layers/route-layer.md @@ -1,12 +1,13 @@ # RouteLayer -路线图组件,支持编号站点、发光路径、分段着色和流动动画。支持多种路径模式:直线、弧线、步行、骑行、驾车、公交等。 +路径地图复合图层,用于在地图上绘制带编号站点的路线。内置三种站点渲染模式(圆点 / Marker / 图标)、分段着色(路况可视化)、弧线长距离路径,以及步行 / 骑行 / 驾车 / 公交四种交通路线查询——只需传入站点列表和查询回调,组件自动完成路径规划与渲染。 + +> **何时选择:** 需要绘制带编号途经点的路线(旅行线路、物流配送、导航轨迹等)时用 RouteLayer;只需简单连线、不需要站点标注时用 [LineLayer](../layers/line-layer);只需 OD 弧线流向时用 [ArcFlowLayer](./arc-flow-layer)。 ## 导入 ```tsx -import { RouteLayer } from '@antv/aimapui' -import '@antv/aimapui/style.css' +import { RouteLayer } from '@antv/aimapui'; ``` ## Props @@ -14,43 +15,63 @@ import '@antv/aimapui/style.css' ### 路径与数据 | 属性 | 类型 | 默认值 | 说明 | -|------|------|-------|------| -| `path` | `[number, number][]` | - | 路径坐标点数组 `[lng, lat]` | -| `segments` | `RouteSegment[]` | - | 路径分段配置,每段可独立设置颜色 | -| `stops` | `RouteStop[]` | - | 站点列表 | -| `routeType` | `RouteType` | `'straight'` | 路径类型 | -| `onRouteQuery` | `(params: RouteQueryParams) => Promise` | - | 交通路线查询回调 | -| `onRouteResult` | `(result: RouteQueryResult) => void` | - | 路线查询完成回调 | +|------|------|--------|------| +| `path` | `[number, number][]` | - | 路径坐标数组 `[lng, lat]`,`straight` / `arc` 模式下必填 | +| `segments` | [RouteSegment](#routesegment) | - | 分段路径,每段可独立设置颜色和宽度(优先级高于 `path`) | +| `stops` | [RouteStop](#routestop) | `[]` | 途经点列表,自动补全路径首尾为起终点 | +| `routeType` | [RouteType](#routetype--路径模式) | `'straight'` | 路径类型 | +| `onRouteQuery` | `(params: RouteQueryParams) => Promise` | - | 交通路线查询回调,`routeType` 为 `walking/cycling/driving/transit` 时使用 | +| `onRouteResult` | `(result: RouteQueryResult) => void` | - | 路线查询完成后的回调,可用于获取距离、时长等信息 | ### 路径视觉 | 属性 | 类型 | 默认值 | 说明 | -|------|------|-------|------| +|------|------|--------|------| | `color` | `string` | `'#2563eb'` | 路径颜色 | -| `lineWidth` | `number` | `4` | 路径宽度(px) | -| `opacity` | `number` | `0.9` | 路径不透明度 | -| `glow` | `boolean` | `true` | 发光效果 | -| `animate` | `boolean` | `false` | 流动动画 | -| `animateSpeed` | `number` | `1` | 动画速度 | +| `lineWidth` | `number` | `3` | 路径宽度(px) | +| `opacity` | `number` | `0.9` | 路径不透明度(0~1) | ### 站点视觉 | 属性 | 类型 | 默认值 | 说明 | -|------|------|-------|------| -| `stopSize` | `number` | `14` | 站点圆点大小(px) | +|------|------|--------|------| +| `stopSize` | `number` | `8` | 站点圆点大小(px),仅 `stopRenderer='point'` 时生效 | | `stopColor` | `string` | 跟随路径色 | 中间站点颜色 | | `endColor` | `string` | `'#10b981'` | 终点站点颜色 | | `showStopIndex` | `boolean` | `true` | 是否显示站点序号 | -| `stopRenderer` | `'point' \| 'marker' \| 'icon'` | `'point'` | 停留点渲染模式 | -| `stopMarkerVariant` | `'pin' \| 'circle' \| 'icon' \| 'dot'` | `'circle'` | marker 模式下的默认形态 | -| `stopIconMap` | `Record` | - | icon 模式下的图标资源映射 | +| `showStopName` | `boolean` | `true` | 是否显示站点名称 | +| `stopNameColor` | `string` | `'#334155'` | 站点名称文字颜色 | +| `stopNameSize` | `number` | `11` | 站点名称文字大小(px) | + +### 站点渲染模式 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `stopRenderer` | `'point' \| 'marker' \| 'icon'` | `'marker'` | 站点渲染模式:默认 `marker`(DOM Marker 编号标注),`point` 为圆点序号、`icon` 为图片图标 | +| `stopMarkerVariant` | `MarkerVariant` | `'circle'` | `marker` 模式下的默认形态(`circle` / `dot` / `pin` / `icon`) | +| `stopIconMap` | `Record` | - | `icon` 模式下的图标资源映射;不传时基于站点 `icon` 字段自动生成 Maki Pin 图标 | +| `stopIconField` | `string` | `'iconValue'` | `icon` 模式下的图标字段名 | +| `stopIconSize` | `number` | `16` | `icon` 模式下的图标尺寸(px) | +| `stopIconAnchor` | `IconAnchor` | `'bottom'` | `icon` 模式下的图标锚点位置 | + +### 交互 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `showStopPopup` | `boolean` | `false` | 点击站点时是否弹出内置 Popup | | `activeColor` | `string` | `'#fbbf24'` | 悬停高亮颜色 | -| `onPathClick` | `(e: LayerEventPayload) => void` | - | 路径点击事件 | -| `onStopClick` | `(e: LayerEventPayload) => void` | - | 站点点击事件 | +| `onPathClick` | `(payload: LayerEventPayload) => void` | - | 路径点击事件 | +| `onStopClick` | `(payload: LayerEventPayload) => void` | - | 站点点击事件 | + +### 公共属性 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `zIndex` | `number` | - | 图层层级 | ## RouteType — 路径模式 -| 值 | 说明 | 是否需要 onRouteQuery | +| 值 | 说明 | 是否需要 `onRouteQuery` | |------|------|------| | `'straight'` | 直线连接坐标点(默认) | 否,使用 `path` | | `'arc'` | 弧线连接,适合长距离路线(如航线) | 否,使用 `path` | @@ -59,76 +80,83 @@ import '@antv/aimapui/style.css' | `'driving'` | 驾车路线 | 是 | | `'transit'` | 公交路线 | 是 | -## RouteQueryParams +## Types -交通路线查询时传给 `onRouteQuery` 的参数,RouteLayer 自动从 stops 的首尾提取起终点。 +### RouteStop ```typescript -interface RouteQueryParams { - origin: [number, number]; - destination: [number, number]; - waypoints?: [number, number][]; - routeType: 'walking' | 'cycling' | 'driving' | 'transit'; +interface RouteStop { + lng: number; // 经度 + lat: number; // 纬度 + name: string; // 名称 + index?: number; // 序号(不传则自动递增) + type?: 'start' | 'end' | 'waypoint'; // 类型,不传则自动推断 + icon?: string; // 图标名,stopRenderer='icon' 时使用 + markerVariant?: MarkerVariant; // marker 模式下的自定义变体 + markerColor?: MarkerColor; // marker 模式下的语义颜色 } ``` -## RouteQueryResult - -`onRouteQuery` 的返回值,至少返回 `path`。 +### RouteSegment ```typescript -interface RouteQueryResult { - path: [number, number][]; // 必填:路径坐标 - segments?: RouteSegment[]; // 可选:分段路径(如路况着色) - stops?: RouteStop[]; // 可选:补充站点(如公交换乘站) - info?: { - distance?: number; // 距离(米) - duration?: number; // 时长(秒) - description?: string; // 路线描述 - }; +interface RouteSegment { + coordinates: [number, number][]; // 坐标序列 [[lng, lat], ...] + color?: string; // 分段颜色(如路况着色) + width?: number; // 分段宽度 } ``` -## RouteSegment +### RouteQueryParams + +交通路线查询时组件传给 `onRouteQuery` 的参数,自动从 `stops` 的首尾提取起终点。 ```typescript -interface RouteSegment { - coordinates: [number, number][]; - color?: string; - width?: number; +interface RouteQueryParams { + origin: [number, number]; // 起点坐标 + destination: [number, number]; // 终点坐标 + waypoints?: [number, number][]; // 途经点坐标 + routeType: 'walking' | 'cycling' | 'driving' | 'transit'; } ``` -## RouteStop +### RouteQueryResult + +`onRouteQuery` 的返回值,至少返回 `path`。 ```typescript -interface RouteStop { - lng: number; - lat: number; - name: string; - index?: number; - type?: 'start' | 'end' | 'waypoint'; - icon?: string; - markerVariant?: 'pin' | 'circle' | 'icon' | 'dot'; - markerColor?: 'primary' | 'success' | 'warning' | 'error'; +interface RouteQueryResult { + path: [number, number][]; // 必填:完整路径坐标 + segments?: RouteSegment[]; // 可选:分段路径(如路况着色) + stops?: RouteStop[]; // 可选:补充站点(如公交换乘站) + info?: { + distance?: number; // 距离(米) + duration?: number; // 时长(秒) + description?: string; // 路线描述 + }; } ``` ## 示例 -### 基础路线图 +### 基础用法 — 带编号站点的直线路线 + +> 默认 `stopRenderer='marker'`,途经点以 DOM Marker 编号标注,无需显式配置即可获得语义化起终点配色。 ```tsx @@ -136,9 +164,15 @@ interface RouteStop { ### 弧线模式(航线) +长距离路线使用弧线连接,视觉效果更自然。 + ```tsx +``` + +### 分段着色(路况可视化) + +使用 `segments` 传入分段路径数据,每段可独立设置颜色和宽度,适合展示道路拥堵情况。 + +```tsx + ``` ### 驾车路线查询 -通过 `onRouteQuery` 回调对接外部路径规划 API(如高德、MapNova),RouteLayer 自动从 stops 提取起终点调用回调。 +通过 `onRouteQuery` 回调对接外部路径规划 API(如高德、MapNova),组件自动从 `stops` 提取起终点并调用回调获取路径。 ```tsx { - const res = await fetch(`/api/route?type=${routeType}&origin=${origin}&dest=${destination}`); + const res = await fetch( + `/api/route?type=${routeType}&origin=${origin}&dest=${destination}` + ); const data = await res.json(); return { path: data.coordinates, @@ -175,8 +227,6 @@ interface RouteStop { console.log(`距离: ${result.info?.distance}m, 时长: ${result.info?.duration}s`); }} color="#10b981" - glow - animate /> ``` @@ -195,7 +245,7 @@ interface RouteStop { const result = await queryTransitRoute(params); return { path: result.path, - stops: result.transferStops, // 换乘站自动合并 + stops: result.transferStops, info: result.info, }; }} @@ -203,10 +253,57 @@ interface RouteStop { /> ``` +### Marker 模式(默认) + +`marker` 为默认渲染模式,使用 DOM Marker 渲染站点,支持 `circle`、`dot`、`pin`、`icon` 等变体;以下示例显式切换变体。 + +```tsx + +``` + +### Icon 图标模式 + +使用图片图标渲染站点,不传 `stopIconMap` 时自动生成 Maki Pin 图标。 + +```tsx + +``` + ## 注意事项 -> `routeType` 为 `walking/cycling/driving/transit` 时,`path` 由回调返回,不需要手动传入;仅需传入 `stops` 标注起终点和途经点。 -> -> `glow` 会在路径下方叠加发光效果,使路线在深色底图上更醒目。 -> -> `showStopIndex` 为 `true` 时,`point` 模式在站点中心绘制编号;`marker/icon` 模式以标签方式补充显示。 +> 💡 **停留点默认 `stopRenderer='marker'`**(DOM Marker 编号标注)。如需纯 L7 圆点性能模式可显式设 `stopRenderer='point'`,需要图片图标时设 `stopRenderer='icon'`。 + +> 💡 `routeType` 为 `walking/cycling/driving/transit` 时,路径由 `onRouteQuery` 回调返回,不需要手动传入 `path`;仅需传入 `stops` 标注起终点和途经点。 + +> 💡 `stops` 列表可以不包含路径的首尾坐标,组件会自动将路径起终点补充为「起点」和「终点」站点。 + +> 💡 `showStopIndex` 为 `true` 时,`point` 模式在站点圆心绘制编号;`marker` 和 `icon` 模式以标签方式补充显示。 + +> ⚠️ `segments` 优先级高于 `path`——当两者同时传入时,使用 `segments` 渲染路径并支持分段着色。 + +> ⚠️ `icon` 模式下如不传 `stopIconMap`,组件会基于站点的 `icon` 字段自动生成 Maki Pin 图标,无需手动准备图标资源。 + +## 相关组件 + +- [LineLayer](../layers/line-layer) — 基础线图层,适合不需要站点标注的简单连线 +- [ArcFlowLayer](./arc-flow-layer) — OD 弧线流向图层,适合城市间流量可视化 +- [PointLayer](../layers/point-layer) — 基础点图层 +- [Marker](../interaction/marker) — DOM Marker 组件,RouteLayer 的 `marker` 模式底层基于此实现 diff --git a/packages/site/src/docs/content/container/aimap.md b/packages/site/src/docs/content/container/aimap.md index 6e25b4e..c2ec829 100644 --- a/packages/site/src/docs/content/container/aimap.md +++ b/packages/site/src/docs/content/container/aimap.md @@ -175,7 +175,7 @@ const cities = [ 当项目已安装 L7 底图引擎包,或需要跳过动态 import(SSR、微前端等场景)时,可通过 `engine` 直接传入引擎构造函数: ```tsx -import { GaodeMap } from '@antv/l7-maps/gaode'; +import { GaodeMap } from '@antv/l7'; import { AiMap, PointLayer, ZoomControl } from '@antv/aimapui'; 仅需简单点位展示时考虑 Marker;需要只读展示矢量数据时使用 PointLayer / LineLayer / PolygonLayer;需要在地图上自由标注、添加多媒体备注时使用本控件。 + +## 导入 + +```tsx +import { AnnotationControl } from '@antv/aimapui'; +import type { AnnotationControlHandle, AnnotationControlProps, AnnotationMode } from '@antv/aimapui'; +``` + +## Props + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| position | `ControlPosition` | `'topright'` | 工具栏在地图中的位置 | +| defaultFeatures | `AnnotationFeature[]` | `[]` | 默认标注数据(非受控模式) | +| features | `AnnotationFeature[]` | - | 标注数据(受控模式,传入后进入受控状态) | +| tools | `AnnotationToolMode[]` | 全部工具 | 工具栏中展示的工具列表 | +| styles | `AnnotationStyleConfig` | - | 标注样式配置 | +| onUpload | `(file: File) => Promise` | - | 图片 / 视频文件上传回调,返回资源 URL | +| className | `string` | - | 控件容器的自定义类名 | +| style | `React.CSSProperties` | - | 控件容器的自定义样式 | +| onAnnotationCreate | `(feature: AnnotationFeature) => void` | - | 标注创建完成时触发 | +| onAnnotationUpdate | `(feature: AnnotationFeature) => void` | - | 标注更新时触发 | +| onAnnotationDelete | `(feature: AnnotationFeature) => void` | - | 标注删除时触发 | +| onAnnotationSelect | `(feature: AnnotationFeature \| null) => void` | - | 选中 / 取消选中标注时触发 | +| onModeChange | `(mode: AnnotationMode) => void` | - | 当前工具模式切换时触发 | +| onChange | `(features: AnnotationFeature[]) => void` | - | 标注数据变化时触发 | + +### AnnotationMode + +标注模式,决定当前激活的工具。 + +```ts +type AnnotationMode = + | 'select' // 选择模式(默认) + | 'marker' // 标记 + | 'highlighter' // 高亮笔 + | 'text' // 文本 + | 'note' // 便签 + | 'link' // 链接 + | 'image' // 图片 + | 'video' // 视频 + | 'none'; // 无工具 +``` + +### AnnotationToolMode + +工具栏中可选的工具模式,不包含 `select` 和 `none`。 + +```ts +type AnnotationToolMode = 'marker' | 'highlighter' | 'text' | 'note' | 'link' | 'image' | 'video'; +``` + +### AnnotationFeature + +标注要素,基于 GeoJSON Feature 扩展,`properties` 中包含 `annotationType` 字段用于区分标注类型。 + +```ts +interface AnnotationFeature extends GeoJSON.Feature { + id: string; + properties: AnnotationProperties; +} +``` + +### AnnotationProperties + +标注属性联合类型,根据 `annotationType` 区分。 + +| annotationType | 属性类型 | 说明 | +|----------------|----------|------| +| `marker` | `MarkerAnnotationProperties` | 点位标记,包含坐标与样式 | +| `highlighter` | `HighlighterAnnotationProperties` | 高亮笔,包含自由绘制的坐标串、颜色、线宽、透明度 | +| `text` | `TextAnnotationProperties` | 文本标注,包含内容、字号、颜色 | +| `note` | `NoteAnnotationProperties` | 便签,包含标题、正文、颜色 | +| `link` | `LinkAnnotationProperties` | 链接,包含 URL、描述文本 | +| `image` | `ImageAnnotationProperties` | 图片,包含图片 URL、尺寸 | +| `video` | `VideoAnnotationProperties` | 视频,包含视频 URL、尺寸 | + +### AnnotationControlHandle + +通过 `ref` 获取的组件实例,提供命令式 API。 + +| 方法 | 类型 | 说明 | +|------|------|------| +| setMode | `(mode: AnnotationMode) => void` | 切换当前标注模式 | +| addAnnotation | `(feature: AnnotationFeature) => void` | 添加一条标注 | +| updateAnnotation | `(id: string, properties: Partial) => void` | 更新指定标注的属性 | +| deleteAnnotation | `(id: string) => void` | 删除指定标注 | +| clearAll | `() => void` | 清空所有标注 | +| getAnnotations | `() => AnnotationFeature[]` | 获取当前所有标注数据 | +| selectAnnotation | `(id: string) => void` | 选中指定标注 | + +## 示例 + +### 基础用法 + +最简单的用法,直接渲染控件即可使用全部 7 种标注工具。 + +```tsx +import { Map, AnnotationControl } from '@antv/aimapui'; + +export default () => ( + + + +); +``` + +### 指定工具列表 + +通过 `tools` 属性控制工具栏中展示的工具。 + +```tsx +import { Map, AnnotationControl } from '@antv/aimapui'; + +export default () => ( + + + +); +``` + +### 受控模式 + +传入 `features` 进入受控模式,配合 `onChange` 管理标注数据。 + +```tsx +import { Map, AnnotationControl } from '@antv/aimapui'; +import { useState } from 'react'; +import type { AnnotationFeature } from '@antv/aimapui'; + +export default () => { + const [features, setFeatures] = useState([]); + + return ( + + console.log('created', f)} + onAnnotationDelete={(f) => console.log('deleted', f)} + /> + + ); +}; +``` + +### 使用 ref 命令式操作 + +通过 `ref` 获取 `AnnotationControlHandle`,在外部触发标注操作。 + +```tsx +import { Map, AnnotationControl } from '@antv/aimapui'; +import { useRef } from 'react'; +import type { AnnotationControlHandle } from '@antv/aimapui'; + +export default () => { + const ref = useRef(null); + + return ( + + + + + + ); +}; +``` + +### 图片 / 视频上传 + +标注类型为 `image` 或 `video` 时需要提供 `onUpload` 回调以处理文件上传。 + +```tsx +import { Map, AnnotationControl } from '@antv/aimapui'; + +export default () => { + const handleUpload = async (file: File) => { + const formData = new FormData(); + formData.append('file', file); + const res = await fetch('/api/upload', { method: 'POST', body: formData }); + const { url } = await res.json(); + return url; + }; + + return ( + + + + ); +}; +``` + +## 注意事项 + +1. **受控与非受控**:传入 `features` 时进入受控模式,需通过 `onChange` 同步数据;否则使用内部状态管理,可通过 `defaultFeatures` 设置初始值。 +2. **编辑方式差异**:文本(`text`)和链接(`link`)类型直接在地图上内联编辑;其余类型(`marker`、`highlighter`、`note`、`image`、`video`)通过双击弹出编辑器。 +3. **高亮笔渲染**:`highlighter` 类型使用 HighlighterLayer 进行自由绘制渲染,性能较好,适合大面积涂抹标注。 +4. **上传回调**:使用 `image` 或 `video` 工具时,必须提供 `onUpload` 回调,否则文件无法上传。 +5. **工具栏位置**:工具栏默认显示在地图右上角(`topright`),样式面板默认显示在左上角(`topleft`),可通过 `position` 调整工具栏位置。 + +## 相关组件 + +- [DrawControl](./draw-control) — 矢量绘制控件,支持点、线、面、矩形、圆形等几何图形绘制 +- [Marker](../overlay/marker) — 轻量级点标记组件,适合少量固定点位展示 +- [PointLayer](../layer/point-layer) — 海量点图层,适合大规模点位数据的只读渲染 +- [Popup](../overlay/popup) — 地图气泡组件,可用于自定义标注内容的展示 diff --git a/packages/site/src/docs/content/controls/legend-control.md b/packages/site/src/docs/content/controls/legend-control.md new file mode 100644 index 0000000..3678a7f --- /dev/null +++ b/packages/site/src/docs/content/controls/legend-control.md @@ -0,0 +1,114 @@ +# LegendControl + +图例容器控件,将自定义图例内容放置在地图角落的毛玻璃容器中。本身不渲染任何图例,仅作为布局容器使用。 + +> **何时选择:** 需要将 LegendCategories、LegendRamp 等图例组件固定在地图某个角落时用 LegendControl;图例已经通过其他 UI 布局(如侧边栏)展示时不需要此控件;只需展示品牌 Logo 时用 [LogoControl](./logo-control)。 + +## 导入 + +```tsx +import { LegendControl } from '@antv/aimapui' +``` + +## Props + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `position` | [ControlPosition](#controlposition) | `'bottomleft'` | 控件在地图上的位置 | +| `className` | `string` | - | 自定义 CSS 类名,用于覆盖容器样式 | +| `style` | `React.CSSProperties` | - | 自定义行内样式 | +| `children` | `React.ReactNode` | - | 图例内容,通常传入 LegendCategories、LegendRamp 等图例组件,也可传入任意自定义 React 节点 | + +### ControlPosition + +```typescript +type ControlPosition = + | 'topleft' | 'topright' + | 'bottomleft' | 'bottomright' + | 'topcenter' | 'bottomcenter' + | 'lefttop' | 'leftbottom' + | 'righttop' | 'rightbottom' + | 'leftcenter' | 'rightcenter' +``` + +## 示例 + +### 基础用法 — 分类图例 + +将分类图例放在地图左下角,容器自动应用毛玻璃效果: + +```tsx +import { AiMap, LegendControl, LegendCategories } from '@antv/aimapui' + +const categories = [ + { label: '商业区', color: '#e6553a' }, + { label: '住宅区', color: '#3a8ee6' }, + { label: '工业区', color: '#f5c842' }, + { label: '绿地', color: '#4caf50' }, +] + + + + + + +``` + +### 连续色带图例 — 温度分布 + +搭配 LegendRamp 展示连续数值型数据的色带图例: + +```tsx +import { AiMap, LegendControl, LegendRamp } from '@antv/aimapui' + + + + + + +``` + +### 自定义图例内容 — 多块组合 + +`children` 支持任意 React 节点,可以将多个图例块组合在一起: + +```tsx +import { AiMap, LegendControl, LegendCategories, LegendRamp } from '@antv/aimapui' + + + + +
+ + + +``` + +## 注意事项 + +- LegendControl 本身不渲染任何图例内容,只是一个带毛玻璃背景的布局容器;需要搭配具体的图例组件(LegendCategories、LegendRamp 等)或自定义内容使用 +- `children` 为 `undefined` 或 `null` 时容器仍然渲染,但内容为空,建议条件性挂载:`{showLegend && ...}` +- 容器默认使用 `l7-control--glass` 毛玻璃样式,在暗色和亮色底图上均有良好表现;如需完全自定义外观可通过 `className` 覆盖 +- 多个控件放在同一位置时会依次堆叠,注意与 LogoControl、ScaleControl 等控件的布局冲突 + +## 相关组件 + +- [LogoControl](./logo-control) — Logo 展示控件,同样放在地图角落但用于品牌标识 +- [LayerSwitchControl](./layer-switch-control) — 图层切换控件,切换图层时图例通常需要同步更新 +- [ScaleControl](./scale-control) — 比例尺控件,常与图例放在同一侧 diff --git a/packages/site/src/docs/content/controls/logo-control.md b/packages/site/src/docs/content/controls/logo-control.md new file mode 100644 index 0000000..ee49e76 --- /dev/null +++ b/packages/site/src/docs/content/controls/logo-control.md @@ -0,0 +1,105 @@ +# LogoControl + +品牌 Logo 展示控件,在地图角落水平排列一个或多个 Logo 图片,支持点击跳转到指定链接。 + +> **何时选择:** 需要在地图上展示项目/公司/合作方品牌标识时用 LogoControl;需要放置图例内容时用 [LegendControl](./legend-control);需要功能性按钮(缩放、全屏等)时用对应专用控件。 + +## 导入 + +```tsx +import { LogoControl } from '@antv/aimapui' +``` + +## Props + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `logos` | [LogoItem[]](#logoitem) | **必填** | Logo 列表,按数组顺序水平排列。至少传入一项 | +| `position` | [ControlPosition](#controlposition) | `'bottomleft'` | 控件在地图上的位置 | +| `className` | `string` | - | 自定义 CSS 类名,用于覆盖控件样式 | +| `style` | `React.CSSProperties` | - | 自定义行内样式 | + +### LogoItem + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `src` | `string` | **必填** | Logo 图片 URL,支持 PNG / SVG 等浏览器可识别的格式 | +| `alt` | `string` | `''` | 图片的 `alt` 属性,用于无障碍读屏和图片加载失败时的占位文字 | +| `href` | `string` | - | 点击 Logo 跳转的链接地址。不传时 Logo 不可点击,仅作为展示用途 | +| `width` | `number` | `24` | Logo 图片宽度(px),高度按比例自动缩放 | + +### ControlPosition + +```typescript +type ControlPosition = + | 'topleft' | 'topright' + | 'bottomleft' | 'bottomright' + | 'topcenter' | 'bottomcenter' + | 'lefttop' | 'leftbottom' + | 'righttop' | 'rightbottom' + | 'leftcenter' | 'rightcenter' +``` + +## 示例 + +### 基础用法 — 单 Logo 展示 + +在地图左下角展示一个品牌 Logo,点击可跳转到官网: + +```tsx +import { AiMap, LogoControl } from '@antv/aimapui' + + + + +``` + +### 多 Logo 并排 — 合作方联合展示 + +多个合作方 Logo 水平排列,适合联合项目、多机构背书场景: + +```tsx +import { AiMap, LogoControl } from '@antv/aimapui' + + + + +``` + +### 自定义样式 — 带背景色的 Logo 栏 + +通过 `style` 为 Logo 区域添加背景色和内边距,使其在卫星图等复杂底图上更醒目: + +```tsx + +``` + +## 注意事项 + +- `logos` 为必填项,传空数组时控件不渲染任何内容 +- `href` 不传时 Logo 仅展示不可点击,不会包裹 `` 标签 +- `width` 只控制宽度,高度由浏览器按图片原始比例自动计算;建议提供宽高比合理的图片以避免变形 +- 多个 Logo 水平排列,间距由控件内部样式控制;如需调整间距可通过 `className` 覆盖 + +## 相关组件 + +- [LegendControl](./legend-control) — 图例容器控件,同样放在地图角落但用于承载图例内容 +- [ScaleControl](./scale-control) — 比例尺控件,常与 Logo 放在同一行 +- [ResetViewControl](./reset-view-control) — 重置视图控件,常放在 Logo 附近的角落 diff --git a/packages/site/src/docs/content/controls/reset-view-control.md b/packages/site/src/docs/content/controls/reset-view-control.md new file mode 100644 index 0000000..0492eec --- /dev/null +++ b/packages/site/src/docs/content/controls/reset-view-control.md @@ -0,0 +1,103 @@ +# ResetViewControl + +一键重置地图到初始视图的控件,点击按钮后地图平滑回到预设的中心点、缩放级别、倾斜角和旋转角。 + +> **何时选择:** 用户在地图上反复拖拽、缩放后需要快速回到默认视角时用 ResetViewControl;需要定位到用户当前位置时用 [GeoLocateControl](./geo-locate-control);需要缩放操作用 [ZoomControl](./zoom-control)。 + +## 导入 + +```tsx +import { ResetViewControl } from '@antv/aimapui' +``` + +## Props + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `position` | [ControlPosition](#controlposition) | `'bottomright'` | 控件在地图上的位置 | +| `icon` | `React.ReactNode` | Material Symbols `center_focus_strong` 图标 | 按钮内容,可传文本或自定义图标组件 | +| `title` | `string` | `'Reset view'` | 按钮的 `title` 属性(hover 提示) | +| `initialView` | [InitialView](#initialview) | - | 重置目标视图。不传时回退到地图初始化时的 center / zoom / pitch / rotation;传入后以该值为准 | +| `className` | `string` | - | 自定义 CSS 类名,用于覆盖控件样式 | +| `style` | `React.CSSProperties` | - | 自定义行内样式 | + +### InitialView + +| 字段 | 类型 | 说明 | +|------|------|------| +| `center` | `[number, number]` | 目标中心点 `[lng, lat]`,不传则保持当前中心点不变 | +| `zoom` | `number` | 目标缩放级别,不传则保持当前缩放不变 | +| `pitch` | `number` | 目标倾斜角(度),不传则保持当前倾斜角不变 | +| `rotation` | `number` | 目标旋转角(度),不传则保持当前旋转角不变 | + +### ControlPosition + +```typescript +type ControlPosition = + | 'topleft' | 'topright' + | 'bottomleft' | 'bottomright' + | 'topcenter' | 'bottomcenter' + | 'lefttop' | 'leftbottom' + | 'righttop' | 'rightbottom' + | 'leftcenter' | 'rightcenter' +``` + +## 示例 + +### 基础用法 — 一键回到默认视角 + +不传 `initialView` 时,点击按钮回到地图初始化时的视图状态,适合大屏监控、数据看板等需要快速归位的场景: + +```tsx +import { AiMap, ResetViewControl } from '@antv/aimapui' + + + + +``` + +### 指定初始视图 — 园区巡检场景 + +为地图设置一个固定的巡检起始视角,无论用户拖到哪里,点击按钮都会回到指定的中心点和缩放级别: + +```tsx +import { AiMap, ResetViewControl } from '@antv/aimapui' + + + + +``` + +### 自定义按钮图标 + +替换默认图标为文字或自定义 React 节点,适合品牌化定制场景: + +```tsx + +``` + +## 注意事项 + +- 点击后通过 `scene.setCenter` / `setZoom` / `setPitch` / `setRotation` 逐一设置,地图会平滑过渡到目标视图 +- `initialView` 中未传的字段保持当前值不变,不会强制重置为零或默认值 +- 控件默认使用毛玻璃样式(`l7-control--glass`),如需覆盖可通过 `className` 自定义 +- 默认图标依赖 Material Symbols Outlined 字体,确保项目引入了该字体资源;未引入时按钮会显示为空白,此时需手动传入 `icon` + +## 相关组件 + +- [ZoomControl](./zoom-control) — 缩放控件,手动调整缩放级别 +- [GeoLocateControl](./geo-locate-control) — 定位控件,回到用户当前位置而非预设视角 +- [FullscreenControl](./fullscreen-control) — 全屏切换控件,常与重置控件放在同一角落 diff --git a/packages/site/src/docs/content/controls/satellite-layer-control.md b/packages/site/src/docs/content/controls/satellite-layer-control.md new file mode 100644 index 0000000..c323a9a --- /dev/null +++ b/packages/site/src/docs/content/controls/satellite-layer-control.md @@ -0,0 +1,122 @@ +# SatelliteLayerControl + +卫星影像图层控制面板,点击按钮弹出 Popper 面板,支持切换卫星图源、开关显隐和调节透明度。 + +> **何时选择:** 需要在地图上叠加卫星影像底图并允许用户控制时用 SatelliteLayerControl;只需切换标准/暗色等矢量底图主题时用 [MapThemeControl](./map-theme-control);只需控制业务图层的显隐时用 [LayerSwitchControl](./layer-switch-control)。 + +## 导入 + +```tsx +import { SatelliteLayerControl } from '@antv/aimapui' +``` + +## Props + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `position` | [ControlPosition](#controlposition) | `'topright'` | 控件在地图上的位置 | +| `activeProvider` | [SatelliteProvider](#satelliteprovider) | `'gaode'` | 当前激活的卫星图源。切换后地图叠加的卫星瓦片来源随之改变 | +| `visible` | `boolean` | `true` | 卫星图层是否可见,控制面板中开关的初始状态 | +| `opacity` | `number` | `1` | 卫星图层透明度,范围 0~1。1 为全不透明,0 为全透明 | +| `onProviderChange` | `(provider: SatelliteProvider) => void` | - | 切换卫星图源后的回调,`provider` 为新选中的图源标识 | +| `onVisibleChange` | `(visible: boolean) => void` | - | 显隐切换回调,`visible` 为切换后的状态 | +| `onOpacityChange` | `(opacity: number) => void` | - | 透明度变化回调,`opacity` 为新值(0~1) | +| `providers` | [SatelliteProvider[]](#satelliteprovider) | `['gaode', 'tianditu', 'google']` | 面板中可选的卫星图源列表,按数组顺序展示。传入子集可隐藏不需要的选项 | +| `className` | `string` | - | 自定义 CSS 类名,用于覆盖控件样式 | +| `style` | `React.CSSProperties` | - | 自定义行内样式 | + +### SatelliteProvider + +```typescript +type SatelliteProvider = 'gaode' | 'tianditu' | 'google' +``` + +| 值 | 图源 | 说明 | +|----|------|------| +| `'gaode'` | 高德卫星 | 国内首选,瓦片加载稳定 | +| `'tianditu'` | 天地图卫星 | 国家地理信息公共服务平台,适合政务场景 | +| `'google'` | Google 卫星 | 全球覆盖,海外区域清晰度更高,国内访问可能受限 | + +### ControlPosition + +```typescript +type ControlPosition = + | 'topleft' | 'topright' + | 'bottomleft' | 'bottomright' + | 'topcenter' | 'bottomcenter' + | 'lefttop' | 'leftbottom' + | 'righttop' | 'rightbottom' + | 'leftcenter' | 'rightcenter' +``` + +## 示例 + +### 基础用法 — 默认卫星图控制 + +点击按钮弹出面板,默认提供高德/天地图/Google 三个图源切换,同时支持显隐和透明度调节: + +```tsx +import { AiMap, SatelliteLayerControl } from '@antv/aimapui' + + + + +``` + +### 限定图源 — 仅高德和天地图 + +内网或政务场景下 Google 图源不可用,只展示国内可选图源: + +```tsx +import { AiMap, SatelliteLayerControl } from '@antv/aimapui' + + + console.log('切换图源:', provider)} + /> + +``` + +### 受控模式 — 外部同步状态 + +将卫星图状态提升到父组件,实现与其他 UI(如侧边栏图层列表)联动: + +```tsx +import { useState } from 'react' +import { AiMap, SatelliteLayerControl } from '@antv/aimapui' + +function SatelliteDemo() { + const [provider, setProvider] = useState('gaode') + const [visible, setVisible] = useState(true) + const [opacity, setOpacity] = useState(1) + + return ( + + + + ) +} +``` + +## 注意事项 + +- 卫星图源切换会重新加载瓦片,有短暂白屏属于正常现象;`onProviderChange` 在图源实际生效后触发 +- Google 图源在国内网络环境下可能加载缓慢或失败,建议根据项目部署环境通过 `providers` 过滤可选图源 +- `opacity` 设为 0 时卫星图层全透明但仍参与渲染,如不需要卫星图应通过 `visible` 关闭以节省资源 +- 控件通常与 SatelliteLayer 组件配合使用:SatelliteLayerControl 负责交互,SatelliteLayer 负责实际的瓦片加载和渲染 +- 面板通过点击按钮弹出(Popper),点击面板外区域自动关闭 + +## 相关组件 + +- [MapThemeControl](./map-theme-control) — 底图主题切换,切换标准/暗色等矢量底图而非卫星影像 +- [LayerSwitchControl](./layer-switch-control) — 业务图层开关,控制业务图层而非卫星底图 +- [FullscreenControl](./fullscreen-control) — 全屏控件,全屏查看卫星图效果 diff --git a/packages/site/src/docs/content/getting-started.md b/packages/site/src/docs/content/getting-started.md index 86ea65d..38a6693 100644 --- a/packages/site/src/docs/content/getting-started.md +++ b/packages/site/src/docs/content/getting-started.md @@ -12,9 +12,9 @@ AiMapUI 提供两种使用方式,按需选择: ### 方式一:npm 包安装(推荐) ```bash -npm install @antv/aimapui @antv/l7 @antv/l7-maps material-symbols +npm install @antv/aimapui @antv/l7 material-symbols # 或 -pnpm add @antv/aimapui @antv/l7 @antv/l7-maps material-symbols +pnpm add @antv/aimapui @antv/l7 material-symbols ``` 安装完成后,在入口文件引入样式(图标字体已通过 CDN 内置,无需额外安装): @@ -70,7 +70,7 @@ npx aimapui init -y CLI 模式下,**仍需手动安装 L7 引擎和外设依赖**(CLI 只负责拷贝组件源码): ```bash -pnpm add @antv/l7 @antv/l7-maps react react-dom clsx +pnpm add @antv/l7 react react-dom clsx ``` #### 3. 添加组件 @@ -165,7 +165,7 @@ AiMapUI 支持两种底图加载方式: 如果你的项目已经安装了 L7 底图引擎,或者需要跳过动态 import(如 SSR、微前端、构建工具兼容性等场景),可以通过 `engine` 直接传入地图引擎构造函数: ```tsx -import { GaodeMap } from '@antv/l7-maps/gaode'; +import { GaodeMap } from '@antv/l7'; ``` diff --git a/packages/site/src/engine/TiandituMap.tsx b/packages/site/src/engine/TiandituMap.tsx index ba6309d..7717917 100644 --- a/packages/site/src/engine/TiandituMap.tsx +++ b/packages/site/src/engine/TiandituMap.tsx @@ -38,7 +38,7 @@ const TIANDITU_THEME_OPTIONS: ThemeOption[] = [ /** * 天地图 — 使用天地图原生引擎 * - * 通过 @antv/l7-maps 的 TMap(天地图引擎)直接加载天地图底图 + * 通过 TMap(天地图引擎,由 @antv/l7 统一 re-export)直接加载天地图底图 * 使用 MapThemeControl 支持五种底图切换:矢量底图、卫星影像、混合底图、地形底图、地形混合 * 包含:放大缩小、定位、比例尺、主题切换 */ diff --git a/packages/site/src/home/BlockDemoPage.tsx b/packages/site/src/home/BlockDemoPage.tsx index d86ba7c..5a5cde1 100644 --- a/packages/site/src/home/BlockDemoPage.tsx +++ b/packages/site/src/home/BlockDemoPage.tsx @@ -20,7 +20,6 @@ interface BlockDemoPageProps { onNavigateHome: () => void; onNavigateDemo: () => void; onNavigateDocs: () => void; - onNavigateDesign: () => void; onNavigateBlock: () => void; onNavigateSkill?: () => void; markdownToHtml: (md: string) => string; diff --git a/packages/site/src/home/BlockPage.tsx b/packages/site/src/home/BlockPage.tsx index fdcf707..9ea6f2f 100644 --- a/packages/site/src/home/BlockPage.tsx +++ b/packages/site/src/home/BlockPage.tsx @@ -18,7 +18,6 @@ interface BlockPageProps { onNavigateHome: () => void; onNavigateDemo: () => void; onNavigateDocs: () => void; - onNavigateDesign: () => void; onNavigateBlock: () => void; onNavigateSkill?: () => void; initialDemoIndex?: number; @@ -29,6 +28,7 @@ export default function BlockPage({ demos, theme, sourceModules, onToggleTheme, const isDark = theme === 'dark'; const [current, setCurrent] = React.useState(initialDemoIndex); const [showPanel, setShowPanel] = React.useState(false); + const [previewDevice, setPreviewDevice] = React.useState<'mobile' | 'desktop'>('desktop'); const handleDemoChange = (idx: number) => { setCurrent(idx); @@ -111,11 +111,28 @@ export default function BlockPage({ demos, theme, sourceModules, onToggleTheme, {/* 中间内容区 */}
- {demo?.device === 'mobile' ? ( + {/* 设备切换按钮(仅 device=both 时显示) */} + {demo?.device === 'both' && ( +
+ {(['desktop', 'mobile'] as const).map(d => ( + + ))} +
+ )} + {(demo?.device === 'mobile' || (demo?.device === 'both' && previewDevice === 'mobile')) ? (
- {DemoComponent && } + {DemoComponent && }
diff --git a/packages/site/src/home/DesignPage.tsx b/packages/site/src/home/DesignPage.tsx deleted file mode 100644 index b55893c..0000000 --- a/packages/site/src/home/DesignPage.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import React from 'react'; -import NavBar from './NavBar'; - -function MarkdownActions({ markdown, isDark }: { markdown: string; isDark: boolean }) { - const [copied, setCopied] = React.useState(false); - - const handleViewMarkdown = () => { - const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' }); - const url = URL.createObjectURL(blob); - window.open(url, '_blank'); - }; - - const handleCopyForLLM = () => { - const doCopy = (text: string) => { - if (navigator.clipboard?.writeText) { - return navigator.clipboard.writeText(text); - } - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0'; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - return Promise.resolve(); - }; - doCopy(markdown).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }); - }; - - const linkStyle: React.CSSProperties = { - cursor: 'pointer', fontSize: 12, fontWeight: 400, - color: '#2563eb', background: 'none', border: 'none', padding: 0, - transition: 'opacity 150ms', - }; - - return ( -
- - -
- ); -} - -interface DesignDoc { - id: string; - name: string; - icon: string; - group: string; - content: string; - htmlDemo: string; -} - -interface DesignPageProps { - docs: DesignDoc[]; - groups: string[]; - theme: 'light' | 'dark'; - initialDocIndex?: number; - onDocChange?: (docId: string) => void; - onToggleTheme: () => void; - onNavigateHome: () => void; - onNavigateDemo: () => void; - onNavigateDocs: () => void; - onNavigateBlock: () => void; - onNavigateSkill?: () => void; - markdownToHtml: (md: string) => string; -} - -export default function DesignPage({ docs, groups, theme, initialDocIndex = 0, onDocChange, onToggleTheme, onNavigateHome, onNavigateDemo, onNavigateDocs, onNavigateBlock, onNavigateSkill, markdownToHtml }: DesignPageProps) { - const isDark = theme === 'dark'; - const [currentDoc, setCurrentDoc] = React.useState(initialDocIndex); - const [showSource, setShowSource] = React.useState(false); - const iframeRef = React.useRef(null); - - // URL 变化时同步更新选中文档 - React.useEffect(() => { - setCurrentDoc(initialDocIndex); - }, [initialDocIndex]); - - // 注入 data-theme 到 srcDoc,确保首帧渲染就使用正确主题 - const injectTheme = (html: string, dark: boolean): string => { - const themeAttr = dark ? 'dark' : 'light'; - return html.replace(/]*)?>/i, ``); - }; - - // 同步主题到 iframe(切换主题时动态更新,避免重新加载) - React.useEffect(() => { - const iframe = iframeRef.current; - if (!iframe) return; - try { - const doc = iframe.contentDocument; - if (doc?.documentElement) { - doc.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light'); - } - } catch (_) { /* cross-origin guard */ } - }, [isDark]); - - const handleSelectDoc = (index: number) => { - setCurrentDoc(index); - const doc = docs[index]; - if (doc && onDocChange) { - onDocChange(doc.id); - } - }; - - const c = { - bg: isDark ? '#000000' : '#ffffff', - fg: isDark ? '#fafafa' : '#171717', - subtle: isDark ? '#111111' : '#fafafa', - border: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)', - secondary: isDark ? '#888888' : '#666666', - muted: isDark ? '#666666' : '#999999', - cardBg: isDark ? '#0a0a0a' : '#ffffff', - navBg: isDark ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.85)', - sidebarBg: isDark ? '#0a0a0a' : '#ffffff', - activeItem: isDark ? 'rgba(255,255,255,0.06)' : '#fafafa', - }; - - const selectedDoc = docs[currentDoc]; - - return ( -
- {/* ══════ Navigation ══════ */} - {}} - onNavigateBlock={onNavigateBlock} - onNavigateSkill={onNavigateSkill} - onToggleTheme={onToggleTheme} - /> - - {/* ══════ Content ══════ */} -
- {/* Sidebar */} - - - {/* Main Content */} -
- {/* HTML Demo 内容 */} - {selectedDoc ? ( - selectedDoc.htmlDemo ? ( -