From 1752b9ef84c5937972e1b921a3a117b322a23474 Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 23 Sep 2026 13:19:30 +0800 Subject: [PATCH] feat: extend supply workbench demo and map navigation --- .env.example | 1 + .gitignore | 1 + README.md | 39 +- docker-entrypoint.sh | 2 + package.json | 1 + pnpm-lock.yaml | 16 + public/runtime-config.js | 2 + src/features/agent/api/client.ts | 28 +- src/features/agent/index.ts | 1 + .../core/components/camera-actions.test.ts | 69 ++++ .../map/core/components/camera-actions.ts | 42 +++ src/features/map/core/components/zoom.tsx | 178 ++++++--- .../scheduled-condition-detail-panel.test.tsx | 29 ++ .../scheduled-condition-detail-panel.tsx | 23 +- .../workbench/data/running-workflows.ts | 31 ++ .../workbench/hooks/use-workbench-agent.ts | 66 ++++ .../workbench/hooks/use-workbench-map.ts | 53 ++- .../workbench/layout/workbench-layout.test.ts | 2 + .../workbench/layout/workbench-layout.ts | 11 +- src/features/workbench/map-workbench-page.tsx | 345 +++++++++++------- src/features/workbench/map/camera.test.ts | 18 +- src/features/workbench/map/camera.ts | 2 + .../workbench/map/city-scene-control.ts | 87 +++++ .../workbench/map/city-scene-layers.test.ts | 56 +++ .../workbench/map/city-scene-layers.ts | 60 +++ .../workbench/map/map-control-config.test.ts | 110 ++++-- .../workbench/map/map-control-config.ts | 233 +++++++++--- src/features/workbench/map/sources.test.ts | 45 ++- src/features/workbench/map/sources.ts | 69 +++- .../peripheral-sample/client.test.ts | 1 + .../peripheral-sample/generated/schema.d.ts | 12 + .../managed-test-resources.tsx | 205 +++++++---- .../peripheral-sample-panel.tsx | 7 +- .../work-order-agent-trigger.test.ts | 67 ++++ .../work-order-agent-trigger.ts | 75 ++++ src/features/workbench/types.ts | 3 +- src/shared/config/env.test.ts | 37 +- src/shared/config/env.ts | 19 +- tests/browser/city-scene.e2e.ts | 119 ++++++ tests/browser/map-camera-controls.e2e.ts | 111 ++++++ tests/browser/peripheral-sample-panel.e2e.ts | 189 +++++++++- tests/browser/support/mock-agent-api.ts | 42 +++ .../browser/support/mock-workbench-context.ts | 6 +- tests/browser/workbench-visual.e2e.ts | 63 +++- vite.config.ts | 10 +- 45 files changed, 2137 insertions(+), 449 deletions(-) create mode 100644 src/features/map/core/components/camera-actions.test.ts create mode 100644 src/features/map/core/components/camera-actions.ts create mode 100644 src/features/workbench/map/city-scene-control.ts create mode 100644 src/features/workbench/map/city-scene-layers.test.ts create mode 100644 src/features/workbench/map/city-scene-layers.ts create mode 100644 src/features/workbench/peripheral-sample/work-order-agent-trigger.test.ts create mode 100644 src/features/workbench/peripheral-sample/work-order-agent-trigger.ts create mode 100644 tests/browser/city-scene.e2e.ts create mode 100644 tests/browser/map-camera-controls.e2e.ts diff --git a/.env.example b/.env.example index 0b0b65f..f764876 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ TJWATER_AUTH_MODE=required TJWATER_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/tjwater TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater TJWATER_MAPBOX_ACCESS_TOKEN= +TJWATER_TIANDITU_TOKEN= TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver TJWATER_GEOSERVER_WORKSPACE=tjwater_next TJWATER_SERVER_API_BASE_URL=http://127.0.0.1:8000 diff --git a/.gitignore b/.gitignore index 5ed1afe..e51b81b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist-server coverage playwright-report test-results +/public/city-model .env .env.* !.env.example diff --git a/README.md b/README.md index 4241adb..f00f9ba 100644 --- a/README.md +++ b/README.md @@ -32,26 +32,27 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力 浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_` 或 `NEXT_PUBLIC_` 前缀。可在 `.env.local` 中设置以下变量: -| 变量 | 默认值 | 说明 | -| --- | --- | --- | -| `TJWATER_AUTH_MODE` | `required` | Keycloak 认证模式;本地测试可显式设为 `disabled` | -| `TJWATER_KEYCLOAK_ISSUER` | 无 | Keycloak Realm issuer,例如 `https://keycloak.example.com/realms/tjwater` | -| `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID | -| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 | -| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 | -| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater_next` | GeoServer 工作区 | -| `TJWATER_SERVER_API_BASE_URL` | `http://127.0.0.1:8000` | 浏览器访问的 TJWater 后端 API 地址 | -| `TJWATER_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 | -| `TJWATER_WORKBENCH_RUNTIME_MODE` | `auto` | 工作台时间模式:工况始终使用 mock,预约工单只读取后端投影;`auto` 和 `backend` 使用当天日期;`demo` 与 `history` 定位到最近一个有后端预约工单的日期,并在同一天生成 mock 工况,其中 `demo` 标记为演示时间。分析成果和后端任务状态不作为工况或日期依据 | -| `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` | 空 | 可选的外围系统模拟服务地址,留空时不发起连接 | -| `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE` | `demo` | 历史回放默认时间模式:`demo` 使用固定有数据窗口,`recent` 使用最近时段 | -| `TJWATER_PERIPHERAL_SAMPLE_DEMO_START` | `2026-09-17T00:00:00Z` | 演示历史窗口开始时间(UTC、带时区) | -| `TJWATER_PERIPHERAL_SAMPLE_DEMO_END` | `2026-09-17T12:00:00Z` | 演示历史窗口结束时间(UTC、最长 7 天) | -| `TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS` | `8` | 实时时间模式回看小时数,范围 1—168 | -| `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 | -| `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker | +| 变量 | 默认值 | 说明 | +| ---------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `TJWATER_AUTH_MODE` | `required` | Keycloak 认证模式;本地测试可显式设为 `disabled` | +| `TJWATER_KEYCLOAK_ISSUER` | 无 | Keycloak Realm issuer,例如 `https://keycloak.example.com/realms/tjwater` | +| `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID | +| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 | +| `TJWATER_TIANDITU_TOKEN` | 空 | 天地图矢量、影像及中文注记访问令牌 | +| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 | +| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater_next` | GeoServer 工作区 | +| `TJWATER_SERVER_API_BASE_URL` | `http://127.0.0.1:8000` | 浏览器访问的 TJWater 后端 API 地址 | +| `TJWATER_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 | +| `TJWATER_WORKBENCH_RUNTIME_MODE` | `auto` | 工作台时间模式:工况始终使用 mock,预约工单只读取后端投影;`auto` 和 `backend` 使用当天日期;`demo` 与 `history` 定位到最近一个有后端预约工单的日期,并在同一天生成 mock 工况,其中 `demo` 标记为演示时间。分析成果和后端任务状态不作为工况或日期依据 | +| `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` | 空 | 可选的外围系统模拟服务地址,留空时不发起连接 | +| `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE` | `demo` | 历史回放默认时间模式:`demo` 使用固定有数据窗口,`recent` 使用最近时段 | +| `TJWATER_PERIPHERAL_SAMPLE_DEMO_START` | `2026-09-17T00:00:00Z` | 演示历史窗口开始时间(UTC、带时区) | +| `TJWATER_PERIPHERAL_SAMPLE_DEMO_END` | `2026-09-17T12:00:00Z` | 演示历史窗口结束时间(UTC、最长 7 天) | +| `TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS` | `8` | 实时时间模式回看小时数,范围 1—168 | +| `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 | +| `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker | -`.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。 +`.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。Mapbox 和天地图令牌会随底图请求发送到浏览器,应在服务商控制台限制允许的来源域名。 浏览器使用 Keycloak Standard Flow 和 PKCE S256,不需要也不能配置 client secret。生产环境必须提供 Realm issuer;未认证用户会跳转到 Keycloak,同一 Realm 中已有登录会话时会直接完成单点登录。 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index af72d16..9bd699b 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -6,6 +6,7 @@ runtime_config=$(jq -cn \ --arg keycloakIssuer "${TJWATER_KEYCLOAK_ISSUER:-}" \ --arg keycloakClientId "${TJWATER_KEYCLOAK_CLIENT_ID:-next-tjwater}" \ --arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \ + --arg tiandituToken "${TJWATER_TIANDITU_TOKEN:-}" \ --arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \ --arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater_next}" \ --arg serverApiBaseUrl "${TJWATER_SERVER_API_BASE_URL:-http://127.0.0.1:8000}" \ @@ -23,6 +24,7 @@ runtime_config=$(jq -cn \ TJWATER_KEYCLOAK_ISSUER: $keycloakIssuer, TJWATER_KEYCLOAK_CLIENT_ID: $keycloakClientId, TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken, + TJWATER_TIANDITU_TOKEN: $tiandituToken, TJWATER_MAP_URL: $mapUrl, TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace, TJWATER_SERVER_API_BASE_URL: $serverApiBaseUrl, diff --git a/package.json b/package.json index e34aeec..d4c527d 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "maplibre-gl": "^4.7.1", "motion": "^12.40.0", "nanoid": "^5.1.14", + "pmtiles": "4.3.0", "react": "^19.2.7", "react-dom": "^19.2.7", "shiki": "^4.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2acb6dc..6d5ed3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: nanoid: specifier: ^5.1.14 version: 5.1.16 + pmtiles: + specifier: 4.3.0 + version: 4.3.0 react: specifier: ^19.2.7 version: 19.2.7 @@ -2456,6 +2459,7 @@ packages: eslint@9.39.5: resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -2531,6 +2535,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -3354,6 +3361,9 @@ packages: engines: {node: '>=18'} hasBin: true + pmtiles@4.3.0: + resolution: {integrity: sha512-wnzQeSiYT/MyO63o7AVxwt7+uKqU0QUy2lHrivM7GvecNy0m1A4voVyGey7bujnEW5Hn+ZzLdvHPoFaqrOzbPA==} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -6271,6 +6281,8 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fflate@0.8.3: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -7373,6 +7385,10 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pmtiles@4.3.0: + dependencies: + fflate: 0.8.3 + points-on-curve@0.2.0: {} points-on-path@0.2.1: diff --git a/public/runtime-config.js b/public/runtime-config.js index dada733..6d780a6 100644 --- a/public/runtime-config.js +++ b/public/runtime-config.js @@ -2,6 +2,8 @@ globalThis.__TJWATER_CONFIG__ = { TJWATER_AUTH_MODE: "required", TJWATER_KEYCLOAK_ISSUER: "", TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater", + TJWATER_MAPBOX_ACCESS_TOKEN: "", + TJWATER_TIANDITU_TOKEN: "", TJWATER_SERVER_API_BASE_URL: "http://127.0.0.1:8000", TJWATER_WORKBENCH_RUNTIME_MODE: "auto" }; diff --git a/src/features/agent/api/client.ts b/src/features/agent/api/client.ts index f2674e0..151291f 100644 --- a/src/features/agent/api/client.ts +++ b/src/features/agent/api/client.ts @@ -3,6 +3,25 @@ import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; export type AgentRunStatus = "running" | "completed" | "error" | "aborted"; +export type AgentEventTriggerContext = { + schema_version: "agent-trigger@1"; + kind: "event"; + trigger_id: string; + occurred_at: string; + source: "peripheral_sample"; + subject_type: "work_order"; + subject_id: string; + workflow: "water_quality_complaint"; + payload: { + title: string; + summary: string; + location?: string | null; + priority: "low" | "normal" | "high" | "urgent"; + scenario_code: "WQ-SECONDARY-SUPPLY-001"; + origin: "synthetic"; + }; +}; + export type AgentPermissionReply = "once" | "always" | "reject"; export type AgentModelOption = { @@ -29,6 +48,9 @@ export type AgentChatSession = { updated_at?: number | string; is_title_manually_edited?: boolean; parent_session_id?: string; + created?: boolean; + event_duplicate?: boolean; + trigger_id?: string; }; export type AgentChatSessionSummary = { @@ -80,7 +102,7 @@ export type AgentSessionStreamEvent = }; export type AgentApiClient = { - createSession: () => Promise; + createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise; listSessions: () => Promise; loadSession: (sessionId: string) => Promise; streamSession: ( @@ -154,11 +176,11 @@ export function createAgentApiClient( }; return { - async createSession() { + async createSession(options) { return requestJson("/session", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}) + body: JSON.stringify({ trigger_context: options?.triggerContext }) }); }, diff --git a/src/features/agent/index.ts b/src/features/agent/index.ts index 287d1a6..ed9c24a 100644 --- a/src/features/agent/index.ts +++ b/src/features/agent/index.ts @@ -11,6 +11,7 @@ export type { AgentApiClient, AgentApiClientOptions, AgentChatSessionSummary, + AgentEventTriggerContext, AgentLoadedChatSession, AgentSessionStreamEvent } from "./api/client"; diff --git a/src/features/map/core/components/camera-actions.test.ts b/src/features/map/core/components/camera-actions.test.ts new file mode 100644 index 0000000..1bfc1e1 --- /dev/null +++ b/src/features/map/core/components/camera-actions.test.ts @@ -0,0 +1,69 @@ +import type { Map as MapLibreMap } from "maplibre-gl"; +import { describe, expect, it, vi } from "vitest"; +import { getCombinedCameraAction, setThreeDimensionalNavigation } from "./camera-actions"; + +describe("combined camera mode action", () => { + it.each([ + { bearing: 0, pitch: 0, expected: "enter-3d" }, + { bearing: 0.4, pitch: 50, expected: "exit-3d" }, + { bearing: 0.6, pitch: 50, expected: "reset-north" }, + { bearing: -35, pitch: 45, expected: "reset-north" } + ] as const)( + "resolves $expected for bearing=$bearing pitch=$pitch", + ({ bearing, pitch, expected }) => { + expect(getCombinedCameraAction(bearing, pitch)).toBe(expected); + } + ); + + it("enables and disables only the 3D navigation gestures", () => { + const canvas = document.createElement("canvas"); + const handlers = { + dragRotate: { enable: vi.fn(), disable: vi.fn() }, + getCanvas: () => canvas, + touchPitch: { enable: vi.fn(), disable: vi.fn() }, + touchZoomRotate: { enableRotation: vi.fn(), disableRotation: vi.fn() } + }; + const map = handlers as unknown as MapLibreMap; + + setThreeDimensionalNavigation(map, true); + expect(handlers.dragRotate.enable).toHaveBeenCalledOnce(); + expect(handlers.touchPitch.enable).toHaveBeenCalledOnce(); + expect(handlers.touchZoomRotate.enableRotation).toHaveBeenCalledOnce(); + + setThreeDimensionalNavigation(map, false); + expect(handlers.dragRotate.disable).toHaveBeenCalledOnce(); + expect(handlers.touchPitch.disable).toHaveBeenCalledOnce(); + expect(handlers.touchZoomRotate.disableRotation).toHaveBeenCalledOnce(); + }); + + it("overrides the browser context menu while 3D navigation is enabled", () => { + const canvas = document.createElement("canvas"); + const onMapMouseDown = vi.fn(); + canvas.addEventListener("mousedown", onMapMouseDown); + const map = { + dragRotate: { enable: vi.fn(), disable: vi.fn() }, + getCanvas: () => canvas, + touchPitch: { enable: vi.fn(), disable: vi.fn() }, + touchZoomRotate: { enableRotation: vi.fn(), disableRotation: vi.fn() } + } as unknown as MapLibreMap; + + setThreeDimensionalNavigation(map, true); + canvas.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 2 })); + expect(onMapMouseDown).toHaveBeenCalledOnce(); + expect( + canvas.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2 }) + ) + ).toBe(false); + + canvas.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, ctrlKey: true })); + expect(onMapMouseDown).toHaveBeenCalledTimes(2); + + setThreeDimensionalNavigation(map, false); + expect( + canvas.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2 }) + ) + ).toBe(true); + }); +}); diff --git a/src/features/map/core/components/camera-actions.ts b/src/features/map/core/components/camera-actions.ts new file mode 100644 index 0000000..6293a1e --- /dev/null +++ b/src/features/map/core/components/camera-actions.ts @@ -0,0 +1,42 @@ +import type { Map as MapLibreMap } from "maplibre-gl"; + +export type CombinedCameraAction = "enter-3d" | "reset-north" | "exit-3d"; + +export const CAMERA_MODE_ANGLE_TOLERANCE = 0.5; + +const browserContextMenuOverrides = new WeakMap void>(); + +export function getCombinedCameraAction(bearing: number, pitch: number): CombinedCameraAction { + if (pitch <= CAMERA_MODE_ANGLE_TOLERANCE) return "enter-3d"; + if (Math.abs(bearing) > CAMERA_MODE_ANGLE_TOLERANCE) return "reset-north"; + return "exit-3d"; +} + +export function setThreeDimensionalNavigation(map: MapLibreMap, enabled: boolean) { + if (enabled) { + overrideBrowserContextMenu(map); + map.dragRotate.enable(); + map.touchPitch.enable(); + map.touchZoomRotate.enableRotation(); + return; + } + + browserContextMenuOverrides.get(map)?.(); + map.dragRotate.disable(); + map.touchPitch.disable(); + map.touchZoomRotate.disableRotation(); +} + +function overrideBrowserContextMenu(map: MapLibreMap) { + if (browserContextMenuOverrides.has(map)) return; + const canvas = map.getCanvas(); + const preventBrowserContextMenu = (event: MouseEvent) => { + event.preventDefault(); + }; + + canvas.addEventListener("contextmenu", preventBrowserContextMenu, true); + browserContextMenuOverrides.set(map, () => { + canvas.removeEventListener("contextmenu", preventBrowserContextMenu, true); + browserContextMenuOverrides.delete(map); + }); +} diff --git a/src/features/map/core/components/zoom.tsx b/src/features/map/core/components/zoom.tsx index 3e391a7..fdd74ae 100644 --- a/src/features/map/core/components/zoom.tsx +++ b/src/features/map/core/components/zoom.tsx @@ -1,13 +1,13 @@ import type { Map as MapLibreMap } from "maplibre-gl"; -import { Home, Minus, Plus, type LucideIcon } from "lucide-react"; -import { type RefObject, useCallback } from "react"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger -} from "@/shared/ui/tooltip"; +import { Home, Minus, Navigation2, Plus, type LucideIcon } from "lucide-react"; +import { type ReactNode, type RefObject, useCallback, useEffect, useState } from "react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip"; import { cn } from "@/shared/ui/cn"; +import { + CAMERA_MODE_ANGLE_TOLERANCE, + getCombinedCameraAction, + setThreeDimensionalNavigation +} from "./camera-actions"; import { MAP_CONTROL_HOVER_CLASS_NAME, MAP_CONTROL_RADIUS_CLASS_NAME, @@ -19,10 +19,60 @@ type MapZoomProps = { mapRef: RefObject; mapReady: boolean; onHome: () => void; + reducedMotion?: boolean; className?: string; }; -export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomProps) { +export function MapZoom({ + mapRef, + mapReady, + onHome, + reducedMotion = false, + className = "" +}: MapZoomProps) { + const [orientation, setOrientation] = useState({ bearing: 0, pitch: 0 }); + const is3d = orientation.pitch > CAMERA_MODE_ANGLE_TOLERANCE; + const cameraAction = getCombinedCameraAction(orientation.bearing, orientation.pitch); + useEffect(() => { + const map = mapRef.current; + if (!map || !mapReady) return; + const syncOrientation = () => { + const nextOrientation = { bearing: map.getBearing(), pitch: map.getPitch() }; + setOrientation(nextOrientation); + setThreeDimensionalNavigation(map, nextOrientation.pitch > CAMERA_MODE_ANGLE_TOLERANCE); + }; + syncOrientation(); + map.on("pitch", syncOrientation); + map.on("rotate", syncOrientation); + return () => { + map.off("pitch", syncOrientation); + map.off("rotate", syncOrientation); + }; + }, [mapRef, mapReady]); + + const handleCameraMode = useCallback(() => { + const map = mapRef.current; + if (!map) return; + const action = getCombinedCameraAction(map.getBearing(), map.getPitch()); + const duration = reducedMotion ? 0 : 350; + + if (action === "enter-3d") { + setThreeDimensionalNavigation(map, true); + map.easeTo({ + pitch: window.matchMedia("(max-width: 639px)").matches ? 45 : 50, + duration + }); + return; + } + + if (action === "reset-north") { + map.easeTo({ bearing: 0, duration }); + return; + } + + setThreeDimensionalNavigation(map, false); + map.easeTo({ pitch: 0, bearing: 0, duration }); + }, [mapRef, reducedMotion]); const handleZoomIn = useCallback(() => { mapRef.current?.zoomIn({ duration: 260 }); }, [mapRef]); @@ -36,10 +86,7 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
- + + + + } + label={ + cameraAction === "reset-north" + ? "恢复北向" + : cameraAction === "exit-3d" + ? "切换到二维" + : "切换到三维" + } + pressed={is3d} disabled={!mapReady} - onClick={handleZoomOut} + onClick={handleCameraMode} />
-
- +
+
@@ -83,19 +129,29 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro } type ControlButtonProps = { - icon: LucideIcon; + icon?: LucideIcon; + content?: ReactNode; + pressed?: boolean; label: string; disabled: boolean; onClick: () => void; }; -function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonProps) { +function ControlButton({ + icon: Icon, + content, + pressed, + label, + disabled, + onClick +}: ControlButtonProps) { return (