diff --git a/.env.example b/.env.example index d127786..38583ba 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=your_mapbox_access_token_here NEXT_PUBLIC_AGENT_API_BASE_URL=http://127.0.0.1:8787 NEXT_PUBLIC_MAP_URL=https://geoserver.waternetwork.cn/geoserver +NEXT_PUBLIC_GEOSERVER_WORKSPACE=wenzhou +NEXT_PUBLIC_ENABLE_DEV_PANEL=false diff --git a/ACRYLIC_DESIGN_INVENTORY.md b/ACRYLIC_DESIGN_INVENTORY.md new file mode 100644 index 0000000..56b689d --- /dev/null +++ b/ACRYLIC_DESIGN_INVENTORY.md @@ -0,0 +1,227 @@ +# 亚克力设计类别与组件使用清单 + +本文档记录当前前端的亚克力材质体系及实际使用位置。样式定义以 `app/globals.css` 为准,组件映射以 `features/` 下的现有代码为准。 + +## 总览 + +当前材质体系分为 3 组,共 13 个语义类: + +| 分组 | 数量 | 类别 | 是否使用背景模糊 | +|---|---:|---|---| +| 外层亚克力与临时玻璃 | 4 | 导航、面板、独立控件、临时任务浮层 | 是 | +| 内部实体表面 | 4 | Dock、控件面、内容凹槽、阅读面 | 否 | +| 语义状态表面 | 5 | 信息、正常、警告、危险、Agent 推理 | 否 | + +只有第一组属于真正的亚克力或玻璃外壳。第二组用于亚克力壳层内部,保证文字、表单和密集数据不受底图干扰。第三组只表达业务状态,不承担界面层级。 + +## 1. 外层亚克力与临时玻璃 + +这组样式包含冷色半透明底色、饱和度、明度混合、细噪点、边框和阴影,并使用 `backdrop-filter`。同一区域只应保留一个带模糊的外层壳体,内部子元素应改用实体表面。 + +### `acrylic-navigation` + +- 用途:全局导航栏。 +- 模糊:使用面板级模糊,默认 22px,浅色底图 24px,卫星底图 20px。 +- 特征:保留底部分隔和下投影,不使用顶部高光。 +- 当前组件: + - `WorkbenchTopBar`,工作台顶部 Header。 +- 文件:`features/workbench/components/workbench-top-bar.tsx`。 + +### `acrylic-panel` + +- 用途:承载持续显示或需要集中阅读的主要浮动面板。 +- 模糊:使用面板级模糊,20px 至 24px。 +- 当前组件: + - `AgentCommandPanel`,Agent 命令面板外壳。 + - `AgentCollapsedRail`,Agent 收起状态侧栏。 + - `ScheduledConditionFeed`,预约任务与工况面板,通过 `MAP_TOOL_PANEL_SURFACE_CLASS_NAME` 间接使用。 + - `LayerControl`、`ControlPanel`、`Legend`、`BaseLayersControl`,地图工具面板,通过共享材质常量间接使用。 + - `FeaturePopover`,要素详情浮层,通过 `MAP_FOCUS_SURFACE_CLASS_NAME` 间接使用。 + - `FeatureInsightPanel`,要素洞察面板。 + - `MapDevPanel`,地图开发调试面板。 + - `AlertMenu`、`ScenarioMenu`、`UserMenu`,顶部导航下拉菜单。 + - `MapNotice`,地图通知。 +- 直接或间接涉及文件: + - `features/agent/components/agent-command-panel.tsx` + - `features/agent/components/agent-collapsed-rail.tsx` + - `features/map/core/components/map-control-styles.ts` + - `features/map/core/components/notice.tsx` + - `features/workbench/components/scheduled-condition-feed.tsx` + - `features/workbench/components/feature-popover.tsx` + - `features/workbench/components/feature-insight-panel.tsx` + - `features/workbench/components/map-dev-panel.tsx` + - `features/workbench/components/workbench-top-bar-menus.tsx` + +### `acrylic-control` + +- 用途:始终悬浮在地图上的紧凑型独立控件。 +- 模糊:使用控件级模糊,默认 18px,浅色底图 20px,卫星底图 16px。 +- 当前组件: + - `Toolbar`,地图主工具栏。 + - `DrawToolbar`,绘制工具栏。 + - `ZoomControl`,缩放控件。 + - `ScaleLine`,比例尺。 + - `BaseLayersControl`,底图切换入口与预览控件。 + - `map-workbench-page.tsx` 中的浮动地图控制入口。 +- 使用方式:上述组件主要通过 `MAP_CONTROL_SURFACE_CLASS_NAME` 间接引用。 +- 共享定义:`features/map/core/components/map-control-styles.ts`。 + +### `glass-transient` + +- 用途:短时出现、需要突出当前任务状态的玻璃浮层。 +- 模糊:沿用控件级模糊,16px 至 20px。 +- 当前组件: + - `AgentTaskTicker` 的活动任务卡片。 +- 文件:`features/workbench/components/agent-task-ticker.tsx`。 +- 约束:仅用于活动任务浮条,非活动堆叠卡改用 `surface-control`。 + +## 2. 内部实体表面 + +这组样式不使用 `backdrop-filter`。它们用于亚克力外壳内部,通过不透明度和背景层级提高可读性,避免出现嵌套模糊。 + +### `surface-dock` + +- 用途:预留给固定 Dock 或侧边停靠区域。 +- 当前状态:已在 `app/globals.css` 中定义,暂无组件直接使用。 +- 适用组件: + - 桌面端固定展开的 Agent 侧栏,前提是它从地图浮层改为占据稳定布局宽度的 Dock。 + - 固定展开的预约工况详情侧栏或图层管理侧栏,前提是侧栏长期贴边并参与工作区布局。 + - 后续可能增加的对象目录、调度队列等常驻侧栏。 +- 不适用:临时弹层、悬浮工具面板、下拉菜单和可覆盖地图的短时侧栏。这些组件仍应使用 `acrylic-panel`。 + +### `surface-control` + +- 用途:面板内部的 Header、输入区域、筛选器、按钮、紧凑控制区和任务堆叠卡。 +- 当前组件范围: + - Agent 收起侧栏条目、Agent 操作摘要按钮、命令面板快捷入口。 + - 图层、量测和通用控制面板中的交互控件。 + - 预约任务面板与工况详情中的筛选器、元信息块和次级操作。 + - 任务浮条的非活动卡片。 + - 要素洞察面板的内部按钮。 + - Header 下拉菜单的内部控制区。 + - 通用 `IconButton`。 +- 主要文件: + - `features/agent/components/agent-collapsed-rail.tsx` + - `features/agent/components/agent-command-panel.tsx` + - `features/agent/components/agent-operational-brief.tsx` + - `features/map/core/components/control-panel.tsx` + - `features/map/core/components/layer-control.tsx` + - `features/map/core/components/measure-panel.tsx` + - `features/workbench/components/agent-task-ticker.tsx` + - `features/workbench/components/scheduled-condition-feed.tsx` + - `features/workbench/components/scheduled-condition-detail-panel.tsx` + - `features/workbench/components/feature-insight-panel.tsx` + - `features/workbench/components/workbench-top-bar-menus.tsx` + +### `surface-well` + +- 用途:低密度内容区、空白支撑区、未选中的面板条目和内部凹槽。 +- 当前组件: + - `LayerPanel`、`LayerControl` 的未激活条目。 + - `MeasurePanel`、`DrawToolbar`、`AnnotationPanel` 的内部区域。 + - `ControlPanel`、`Legend`、`BaseLayersControl` 的分组与空白区域。 + - Header 下拉菜单的普通菜单项。 +- 使用方式:部分组件直接引用,部分通过 `MAP_READABLE_SURFACE_CLASS_NAME` 间接引用。 + +### `surface-reading` + +- 用途:消息、报告、表单、属性列表、时间线内容和其他密集阅读区域。 +- 当前组件范围: + - Agent 消息详情、操作摘要、UI Envelope 内容和命令面板空状态。 + - 预约任务列表、工况详情、KPI、报告内容与下拉列表。 + - 要素洞察、属性列表和底图名称标签。 + - Header 下拉菜单中的详情块与空状态。 +- 主要文件: + - `features/agent/components/agent-command-panel.tsx` + - `features/agent/components/agent-message-details.tsx` + - `features/agent/components/agent-operational-brief.tsx` + - `features/agent/components/agent-ui-envelope-renderer.tsx` + - `features/workbench/components/scheduled-condition-feed.tsx` + - `features/workbench/components/scheduled-condition-detail-panel.tsx` + - `features/workbench/components/feature-insight-panel.tsx` + - `features/workbench/components/feature-property-list.tsx` + - `features/workbench/components/workbench-top-bar-menus.tsx` + - `features/map/core/components/base-layers-control.tsx` + +## 3. 语义状态表面 + +语义状态表面不使用模糊,只表达业务含义。它们不能替代 `acrylic-panel`、`surface-control` 或 `surface-reading` 来建立界面层级。 + +| 类名 | 含义 | 当前使用位置 | +|---|---|---| +| `material-tone-info` | 信息、液位等蓝色状态 | `FeatureInsightPanel` 的雷达液位区块 | +| `material-tone-normal` | 正常、健康、流量或水质状态 | `FeatureInsightPanel` 的水质、综合监测等区块 | +| `material-tone-warning` | 风险、告警、超声流量等橙色状态 | `FeatureInsightPanel` 的告警与超声流量区块 | +| `material-tone-danger` | 严重故障或事故状态 | 已定义,建议用于严重事故、关键设备故障和高风险工况内容块 | +| `material-tone-agent` | Agent 推理或模型建议 | 已定义,建议用于 Agent 解释、推理结论和建议动作内容块 | + +## 未使用样式的建议落点 + +### `surface-dock` + +建议用于参与页面布局的常驻停靠区,而不是覆盖在地图上的浮层。现有组件中,以下场景适合在布局调整后使用: + +- `AgentCommandPanel` 的桌面常驻模式。只有当面板固定贴左、占据稳定宽度并推动地图安全区时才使用。 +- `ScheduledConditionFeed` 或 `ConditionDetailPanel` 的常驻右侧模式。适用于调度员持续对照地图与工况详情的工作流。 +- `LayerPanel` 的常驻图层目录模式。适用于图层较多、需要持续管理的专业工作台。 + +如果组件仍然悬浮在地图上、可以随时关闭或与其他浮层替换,应继续使用 `acrylic-panel`。 + +### `material-tone-danger` + +建议用于已经确认的严重业务状态,不用于普通校验失败或短暂接口错误。现有组件中适合的落点包括: + +- `FeatureInsightPanel`:要素状态为 `critical`、严重故障、爆管或关键设备失效时的状态说明区块。 +- `ScheduledConditionDetailPanel`:工况风险等级为 `critical`,或任务状态为 `error` 且代表真实运行事故时的风险摘要和影响范围区块。 +- `MapNotice`:仅在需要持续强调严重运行事故时用于通知内容区,不替换通知最外层的 `acrylic-panel`。 +- Agent 权限或确认卡片:仅当待执行动作具有明确的高风险业务后果时使用,不能仅因提交失败就使用。 + +不应使用在网络请求失败、加载失败、表单校验错误等通用技术错误上。这类错误继续使用局部红色图标、边框或文字即可,避免把技术故障误读为管网事故。 + +### `material-tone-agent` + +建议用于明确由 Agent 或模型生成的内容,帮助用户区分事实数据、业务状态与机器推理。现有组件中适合的落点包括: + +- `FeatureInsightPanel` 的“Agent 解释”区块。该区块目前使用 `material-tone-warning`,如果内容只是解释或推理而非风险告警,更适合改用 `material-tone-agent`。 +- `AgentOperationalBrief`:Agent 生成的任务判断、建议动作或需要人工确认的结论区块。 +- `AgentMessageDetails`:推理摘要、模型结论、建议方案等非原始证据内容。 +- `AgentUiEnvelopeRenderer`:由 Agent 返回的建议面板或注册组件中的结论区块。 +- `ScheduledConditionDetailPanel`:Agent 针对工况生成的处置建议区块。 + +原始监测值、设备属性、人工录入内容和系统事实不应使用该样式。即使这些内容出现在 Agent 面板内,也应继续使用 `surface-reading` 或对应的业务状态色。 + +## 共享类映射 + +地图核心组件通过 `features/map/core/components/map-control-styles.ts` 复用材质类,避免在各组件中重复写样式值。 + +| 共享常量 | 实际材质类 | 典型用途 | +|---|---|---| +| `MAP_CONTROL_SURFACE_CLASS_NAME` | `acrylic-control border` | 工具栏、缩放、比例尺、底图入口 | +| `MAP_TOOL_PANEL_SURFACE_CLASS_NAME` | `acrylic-panel border` | 图层、图例、控制和预约任务面板 | +| `MAP_FOCUS_SURFACE_CLASS_NAME` | `acrylic-panel border` | 要素详情等焦点浮层 | +| `MAP_READABLE_SURFACE_CLASS_NAME` | `surface-well border` | 面板内部低密度区域 | +| `MAP_READABLE_SURFACE_STRONG_CLASS_NAME` | `surface-control border` | 面板内部高可读控制区 | + +## 自适应与无障碍规则 + +- `data-basemap-tone="light"` 会提高部分面板模糊强度,并降低导航、控件和任务浮条的背景不透明度。 +- `data-basemap-tone="satellite"` 会提高背景不透明度、轮廓和阴影强度,压制卫星底图纹理。 +- `prefers-reduced-transparency: reduce` 下,亚克力和玻璃表面会切换到接近不透明的背景并关闭模糊。 +- 不支持 `backdrop-filter` 的浏览器使用同样的高不透明度回退。 +- `forced-colors: active` 下,材质背景和边框改用系统颜色,并关闭背景图与阴影。 + +## 使用约束 + +1. 一个浮动区域只允许最外层使用 `acrylic-*` 或 `glass-transient`。 +2. 外层亚克力内部使用 `surface-control`、`surface-well` 或 `surface-reading`,不要叠加第二层模糊。 +3. 密集文字、表格、属性和值必须落在 `surface-reading` 等高可读表面上。 +4. `material-tone-*` 只表达状态,不表达层级,也不添加模糊。 +5. 新增地图控件时优先复用 `map-control-styles.ts` 中的共享常量。 +6. 组件只负责布局、圆角和交互状态,材质颜色、边框和阴影由全局语义类统一维护。 + +## 当前待清理项 + +- `surface-dock` 可在固定 Dock 落地时启用。如果产品不计划引入参与布局的常驻侧栏,可以移除该样式。 +- `material-tone-danger` 应等待明确的严重业务状态落点,不要为了消除未使用状态而套用到通用错误提示。 +- `material-tone-agent` 可优先用于 `FeatureInsightPanel` 的“Agent 解释”和 Agent 建议内容,替代不准确的警告语义。 +- Agent 面板仍有少量局部类直接使用 `--surface-control`、`--surface-reading` 或自定义透明背景。后续统一材质语义时,应评估是否迁移到对应的 `surface-*` 类。 diff --git a/app/api/agent-locate/route.ts b/app/api/agent-locate/route.ts index 43b3ce1..3de56ec 100644 --- a/app/api/agent-locate/route.ts +++ b/app/api/agent-locate/route.ts @@ -1,12 +1,13 @@ import { NextResponse } from "next/server"; -import { MAP_URL } from "@/lib/config"; -const TYPES = { junction: "drainage:geo_junctions", conduit: "drainage:geo_conduits", pipe: "drainage:geo_conduits", orifice: "drainage:geo_orifices", outfall: "drainage:geo_outfalls", pump: "drainage:geo_pumps" } as const; +import { GEOSERVER_WORKSPACE, MAP_URL } from "@/lib/config"; +const TYPES = { junction: "geo_junctions", conduit: "geo_conduits", pipe: "geo_conduits", orifice: "geo_orifices", outfall: "geo_outfalls", pump: "geo_pumps" } as const; export async function POST(request: Request) { const body = await request.json().catch(() => null) as { ids?: unknown; featureType?: unknown } | null; if (!body || !Array.isArray(body.ids) || body.ids.length < 1 || body.ids.length > 100 || !body.ids.every((id) => typeof id === "string" && id.trim())) return NextResponse.json({ code: "INVALID_REQUEST" }, { status: 400 }); if (typeof body.featureType !== "string" || !(body.featureType in TYPES)) return NextResponse.json({ code: "UNSUPPORTED_FEATURE_TYPE" }, { status: 422 }); const ids = body.ids.map((id) => String(id).trim()); const cql = `id IN (${ids.map((id) => `'${id.replaceAll("'", "''")}'`).join(",")})`; - const url = new URL(`${MAP_URL}/drainage/ows`); url.search = new URLSearchParams({ service: "WFS", version: "2.0.0", request: "GetFeature", typeNames: TYPES[body.featureType as keyof typeof TYPES], outputFormat: "application/json", srsName: "EPSG:4326", count: "100", cql_filter: cql }).toString(); + const typeName = `${GEOSERVER_WORKSPACE}:${TYPES[body.featureType as keyof typeof TYPES]}`; + const url = new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows`); url.search = new URLSearchParams({ service: "WFS", version: "2.0.0", request: "GetFeature", typeNames: typeName, outputFormat: "application/json", srsName: "EPSG:4326", count: "100", cql_filter: cql }).toString(); try { const response = await fetch(url, { signal: AbortSignal.timeout(8_000), cache: "no-store" }); const data = await response.json(); if (!response.ok || !data || data.type !== "FeatureCollection" || !Array.isArray(data.features)) throw new Error(); return NextResponse.json({ type: "FeatureCollection", features: data.features.slice(0, 100) }); } catch { return NextResponse.json({ code: "WFS_UNAVAILABLE" }, { status: 502 }); } } diff --git a/app/api/map-features/route.ts b/app/api/map-features/route.ts new file mode 100644 index 0000000..ee7d261 --- /dev/null +++ b/app/api/map-features/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { + MAX_CONDUIT_FEATURES, + MAX_MAP_FEATURE_IDS, + createMapFeatureWfsUrl, + normalizeMapFeatureCollection, + parseMapFeatureQuery +} from "@/features/workbench/map/map-feature-query"; + +export async function POST(request: Request) { + const parsed = parseMapFeatureQuery(await request.json().catch(() => null)); + if (!parsed.ok) { + return NextResponse.json({ code: parsed.code }, { status: parsed.status }); + } + + try { + const response = await fetch(createMapFeatureWfsUrl(parsed.value), { + cache: "no-store", + signal: AbortSignal.timeout(8_000) + }); + const collection = normalizeMapFeatureCollection( + await response.json(), + parsed.value.featureIds ? MAX_MAP_FEATURE_IDS : MAX_CONDUIT_FEATURES + ); + if (!response.ok || !collection) throw new Error("Invalid WFS response"); + return NextResponse.json(collection); + } catch { + return NextResponse.json({ code: "WFS_UNAVAILABLE" }, { status: 502 }); + } +} diff --git a/app/api/tts/edge/route.ts b/app/api/tts/edge/route.ts new file mode 100644 index 0000000..2ae6d72 --- /dev/null +++ b/app/api/tts/edge/route.ts @@ -0,0 +1,59 @@ +import { Communicate } from "edge-tts-ts"; +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const DEFAULT_VOICE = process.env.EDGE_TTS_VOICE || "zh-CN-XiaoxiaoNeural"; +const MAX_TEXT_LENGTH = 12_000; + +const jsonError = (message: string, status: number) => + NextResponse.json({ error: message }, { status }); + +export async function POST(request: Request) { + let payload: { text?: unknown; voice?: unknown }; + try { + payload = await request.json() as { text?: unknown; voice?: unknown }; + } catch { + return jsonError("请求内容不是有效 JSON", 400); + } + + const text = typeof payload.text === "string" ? payload.text.trim() : ""; + if (!text) return jsonError("缺少待朗读文本", 400); + if (text.length > MAX_TEXT_LENGTH) return jsonError(`单次文本不能超过 ${MAX_TEXT_LENGTH} 字`, 413); + + const voice = typeof payload.voice === "string" && payload.voice.trim() + ? payload.voice.trim() + : DEFAULT_VOICE; + + try { + const communicate = new Communicate(text, { voice }); + const chunks: Uint8Array[] = []; + let byteLength = 0; + + for await (const chunk of communicate.stream()) { + if (chunk.type !== "audio") continue; + chunks.push(chunk.data); + byteLength += chunk.data.byteLength; + } + + if (!byteLength) return jsonError("语音服务返回了空音频", 502); + + const audio = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + audio.set(chunk, offset); + offset += chunk.byteLength; + } + + return new Response(audio.buffer.slice(audio.byteOffset, audio.byteOffset + audio.byteLength), { + headers: { + "Content-Type": "audio/mpeg", + "Cache-Control": "no-store" + } + }); + } catch (error) { + console.error("[Agent TTS] 语音生成失败", error); + return jsonError("语音生成失败", 502); + } +} diff --git a/app/globals.css b/app/globals.css index fb3a348..531510e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -181,7 +181,6 @@ input:focus-visible { .acrylic-navigation { background-color: var(--acrylic-navigation); box-shadow: - inset 0 1px 0 var(--acrylic-highlight), 0 1px 0 var(--acrylic-outline), 0 8px 24px rgba(15, 23, 42, 0.08); backdrop-filter: blur(var(--acrylic-panel-blur)) saturate(var(--acrylic-backdrop-saturation)); @@ -339,7 +338,7 @@ input:focus-visible { } .agent-panel-conversation { - background: var(--surface-well); + background: rgba(232, 240, 248, 0.12); } .agent-panel-message { diff --git a/features/agent/components/agent-command-panel.tsx b/features/agent/components/agent-command-panel.tsx index 3d91d7c..b258b08 100644 --- a/features/agent/components/agent-command-panel.tsx +++ b/features/agent/components/agent-command-panel.tsx @@ -8,13 +8,23 @@ import { DatabaseZap, GitMerge, History, + LoaderCircle, MapPinned, + Mic, + Pause, + Play, Plus, - SendHorizontal + SendHorizontal, + Shield, + ShieldCheck, + Square, + Volume2 } from "lucide-react"; -import { AnimatePresence, MotionConfig, motion } from "motion/react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; import { useStickToBottomContext } from "use-stick-to-bottom"; +import { showMapNotice } from "@/features/map/core/components/notice"; import { Agent, AgentContent } from "@/shared/ai-elements/agent"; import { Conversation, @@ -52,7 +62,15 @@ import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-detai import { AgentOperationalBrief } from "./agent-operational-brief"; import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer"; import { StreamingTokenResponse } from "./streaming-token-response"; +import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice"; +import { + findSpeechSelectionStartOffset, + stripSpeechMarkdown, + type AgentSpeakOptions, + type AgentSpeechState +} from "../speech"; import type { + AgentApprovalMode, AgentChatMessage, AgentModelOption, AgentPermissionReply, @@ -75,6 +93,7 @@ type AgentCommandPanelProps = { streamRenderState?: AgentStreamRenderState; modelOptions?: AgentModelOption[]; selectedModel?: string; + approvalMode?: AgentApprovalMode; uiResults?: AgentUiResult[]; onCollapse: () => void; onRefreshHistory?: () => Promise | void; @@ -83,6 +102,7 @@ type AgentCommandPanelProps = { onRenameHistorySession?: (sessionId: string, title: string) => Promise | void; onDeleteHistorySession?: (sessionId: string) => Promise | void; onSelectModel?: (model: string) => void; + onApprovalModeChange?: (mode: AgentApprovalMode) => void; onSubmitPrompt: (prompt: string) => Promise | void; onStopPrompt?: () => void; onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise | void; @@ -116,6 +136,7 @@ export function AgentCommandPanel({ streamRenderState = {}, modelOptions = [], selectedModel = "", + approvalMode = "request", uiResults = [], onCollapse, onRefreshHistory, @@ -124,6 +145,7 @@ export function AgentCommandPanel({ onRenameHistorySession, onDeleteHistorySession, onSelectModel, + onApprovalModeChange, onSubmitPrompt, onStopPrompt, onReplyPermission, @@ -135,6 +157,33 @@ export function AgentCommandPanel({ const [loadingSessionId, setLoadingSessionId] = useState(null); const [scrollRequestId, setScrollRequestId] = useState(0); const trimmedPrompt = prompt.trim(); + const { + speechState, + speakingMessageId, + speak, + pause: pauseSpeech, + resume: resumeSpeech, + stop: stopSpeech, + isSupported: isSpeechSupported + } = useAgentSpeechSynthesis(); + const appendRecognitionResult = useCallback((text: string) => { + setPrompt((current) => `${current}${current.trim() ? " " : ""}${text}`); + }, []); + const showRecognitionError = useCallback((message: string) => { + showMapNotice({ + id: "agent-speech-recognition-error", + tone: "error", + title: "语音输入不可用", + message, + duration: 5000 + }); + }, []); + const { + isListening, + start: startListening, + stop: stopListening, + isSupported: isRecognitionSupported + } = useAgentSpeechRecognition(appendRecognitionResult); useEffect(() => { if (historyOpen) { @@ -142,11 +191,20 @@ export function AgentCommandPanel({ } }, [historyOpen, onRefreshHistory]); + useEffect(() => { + stopSpeech(); + }, [activeSessionId, stopSpeech]); + + useEffect(() => { + if (streaming) stopListening(); + }, [stopListening, streaming]); + const submitPrompt = (nextPrompt: string) => { if (!nextPrompt || streaming) { return; } setPrompt(""); + stopListening(); setScrollRequestId((requestId) => requestId + 1); void Promise.resolve(onSubmitPrompt(nextPrompt)).catch(() => { setPrompt(nextPrompt); @@ -166,7 +224,7 @@ export function AgentCommandPanel({ )} > -
+
) : ( - + +
+ +
+ {isRecognitionSupported ? ( + startListening(showRecognitionError)} + /> + ) : null} void; +}) { + const reduceMotion = useReducedMotion(); + const label = isListening ? "停止语音输入" : "语音输入"; + + return ( + + {isListening ? ( + + ); +} + +function ApprovalModeSelect({ + value, + disabled, + onValueChange +}: { + value: AgentApprovalMode; + disabled: boolean; + onValueChange?: (mode: AgentApprovalMode) => void; +}) { + const requestApproval = value === "request"; + + return ( + + + + + + 权限批准模式 + onValueChange?.("request")} + > + + onValueChange?.("always")} + > + + + + ); +} + type AgentConversationScrollSnapshot = { activeSessionId?: string | null; lastUserMessageId: string | null; @@ -593,27 +843,46 @@ function AgentEmptyState() { return (
-
- +
+
+
+ +
+
+

+ 我已就绪,请描述任务 +

+
+
+ +

+ 直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。 +

-

- 我已就绪,请描述任务 -

-

- 使用自然语言下达指令,我会整理监测与空间证据,并在地图上呈现分析结果。 -

-
    - {AGENT_CAPABILITIES.map(({ icon: Icon, label }) => ( + +
      + {AGENT_CAPABILITIES.map(({ icon: Icon, label }, index) => (
    • - -
    • ))}
    @@ -627,7 +896,14 @@ function BackendMessageList({ streamRenderState, onReplyPermission, onReplyQuestion, - onRejectQuestion + onRejectQuestion, + isSpeechSupported, + speakingMessageId, + speechState, + onSpeak, + onPauseSpeech, + onResumeSpeech, + onStopSpeech }: { messages: AgentChatMessage[]; streaming: boolean; @@ -635,6 +911,13 @@ function BackendMessageList({ onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise | void; onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise | void; onRejectQuestion?: (request: AgentQuestionRequest) => Promise | void; + isSpeechSupported: boolean; + speakingMessageId: string | null; + speechState: AgentSpeechState; + onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise | void; + onPauseSpeech: () => void; + onResumeSpeech: () => void; + onStopSpeech: () => void; }) { return ( @@ -667,13 +950,25 @@ function BackendMessageList({ ) : null} {message.content ? ( message.role === "assistant" ? ( - + content={message.content} + disabled={streaming && index === messages.length - 1} + isSupported={isSpeechSupported} + speechState={speakingMessageId === message.id ? speechState : "idle"} + onSpeak={onSpeak} + onPause={onPauseSpeech} + onResume={onResumeSpeech} + onStop={onStopSpeech} + > + + ) : (

    {message.content}

    ) @@ -697,3 +992,235 @@ function BackendMessageList({
    ); } + +type SpeechSelection = { + startOffset: number; + left: number; + top: number; +}; + +function AgentSpeechMessage({ + messageId, + content, + disabled, + isSupported, + speechState, + onSpeak, + onPause, + onResume, + onStop, + children +}: { + messageId: string; + content: string; + disabled: boolean; + isSupported: boolean; + speechState: AgentSpeechState; + onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise | void; + onPause: () => void; + onResume: () => void; + onStop: () => void; + children: ReactNode; +}) { + const contentRef = useRef(null); + const lastSelectionRef = useRef(null); + const [selection, setSelection] = useState(null); + const speechText = useMemo(() => stripSpeechMarkdown(content), [content]); + const selectionAnchor = selection ?? lastSelectionRef.current; + + if (selection) { + lastSelectionRef.current = selection; + } + + const captureSelection = useCallback(() => { + if (!isSupported || disabled) { + setSelection(null); + return; + } + + const browserSelection = window.getSelection(); + const container = contentRef.current; + if (!browserSelection || browserSelection.rangeCount === 0 || browserSelection.isCollapsed || !container) { + setSelection(null); + return; + } + + const range = browserSelection.getRangeAt(0); + if (!container.contains(range.commonAncestorContainer)) { + setSelection(null); + return; + } + + const startOffset = findSpeechSelectionStartOffset(speechText, browserSelection.toString()); + const rect = range.getBoundingClientRect(); + if (startOffset === null || (!rect.width && !rect.height)) { + setSelection(null); + return; + } + + setSelection({ + startOffset, + left: Math.min(Math.max(rect.left + rect.width / 2, 88), window.innerWidth - 88), + top: Math.max(rect.top - 10, 48) + }); + }, [disabled, isSupported, speechText]); + + useEffect(() => { + setSelection(null); + }, [messageId, speechText]); + + useEffect(() => { + if (!selection) return; + const close = () => setSelection(null); + const closeCollapsedSelection = () => { + if (window.getSelection()?.isCollapsed) close(); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") close(); + }; + + document.addEventListener("selectionchange", closeCollapsedSelection); + document.addEventListener("keydown", closeOnEscape); + window.addEventListener("resize", close); + window.addEventListener("scroll", close, true); + return () => { + document.removeEventListener("selectionchange", closeCollapsedSelection); + document.removeEventListener("keydown", closeOnEscape); + window.removeEventListener("resize", close); + window.removeEventListener("scroll", close, true); + }; + }, [selection]); + + const speakFromSelection = () => { + if (!selection) return; + void Promise.resolve(onSpeak(messageId, speechText, { startOffset: selection.startOffset })); + window.getSelection()?.removeAllRanges(); + setSelection(null); + }; + + return ( +
    +
    window.requestAnimationFrame(captureSelection)} + onKeyUp={() => window.requestAnimationFrame(captureSelection)} + > + {children} +
    + + {selectionAnchor && typeof document !== "undefined" ? createPortal( +
    + + {selection ? ( + event.preventDefault()} + onClick={speakFromSelection} + > + + 从这里开始朗读 + + ) : null} + +
    , + document.body + ) : null} + + {isSupported && !disabled ? ( +
    + {speechState === "idle" ? ( + void Promise.resolve(onSpeak(messageId, speechText))}> + + ) : null} + {speechState === "loading" ? ( + <> + + + + + + ) : null} + {speechState === "playing" ? ( + <> + + + + + + ) : null} + {speechState === "paused" ? ( + <> + + + + + + ) : null} + + {speechState === "idle" ? "语音播放" : speechState === "loading" ? "正在生成" : speechState === "playing" ? "播放中" : "已暂停"} + +
    + ) : null} +
    + ); +} + +function SpeechIconButton({ + label, + tone = "default", + disabled = false, + onClick, + children +}: { + label: string; + tone?: "default" | "primary" | "danger"; + disabled?: boolean; + onClick?: () => void; + children: ReactNode; +}) { + return ( + + ); +} diff --git a/features/agent/components/agent-persona.tsx b/features/agent/components/agent-persona.tsx index dc55dd1..1d48080 100644 --- a/features/agent/components/agent-persona.tsx +++ b/features/agent/components/agent-persona.tsx @@ -41,7 +41,11 @@ export function AgentPersona({ return ( setFailed(true)} state={personaState} variant={variant} diff --git a/features/agent/frontend-action/index.test.ts b/features/agent/frontend-action/index.test.ts new file mode 100644 index 0000000..f99a6f9 --- /dev/null +++ b/features/agent/frontend-action/index.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { FrontendActionExecutor } from "./executor"; +import { parseFrontendActionRegistry, parseFrontendActionRequest, type FrontendActionResult } from "."; + +const storage = new Map(); + +beforeEach(() => { + storage.clear(); + vi.stubGlobal("sessionStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value) + }); +}); + +describe("SCADA frontend actions", () => { + it("accepts only the two registered SCADA action names", () => { + expect(parseFrontendActionRegistry({ + schema_version: "frontend-action-registry@1", + actions: [ + { id: "render_scada_analysis", version: "frontend-action@1" }, + { id: "clear_scada_analysis", version: "frontend-action@1" }, + { id: "zoom_to_map", version: "frontend-action@1" } + ] + })?.actions.map((action) => action.id)).toEqual([ + "render_scada_analysis", + "clear_scada_analysis" + ]); + expect(parseFrontendActionRequest(createRequest("zoom_to_map"))).toBeNull(); + expect(parseFrontendActionRequest(createRequest("render_scada_analysis"))).not.toBeNull(); + }); + + it("submits the real browser output and replays the terminal receipt idempotently", async () => { + const execute = vi.fn(async () => ({ + rendered_ids: ["MP01"], + missing_ids: [], + level_counts: { high: 1, medium: 0, low: 0, unrated: 0 }, + fitted: true + })); + const submit = vi.fn(async ( + _sessionId: string, + _actionId: string, + _result: FrontendActionResult + ) => undefined); + const executor = new FrontendActionExecutor(execute, submit); + const request = createRequest("render_scada_analysis"); + await executor.handle(request, "session-1"); + await executor.handle(request, "session-1"); + expect(execute).toHaveBeenCalledTimes(1); + expect(submit).toHaveBeenCalledTimes(2); + expect(submit.mock.calls[0][2]).toMatchObject({ + status: "succeeded", + output: { rendered_ids: ["MP01"], fitted: true } + }); + expect(submit.mock.calls[1][2]).toEqual(submit.mock.calls[0][2]); + }); +}); + +function createRequest(name: string) { + return { + version: "frontend-action@1", + actionId: "action-1", + toolCallId: "call-1", + sessionId: "session-1", + name, + params: { items: [{ sensor_id: "MP01", level: "high" }] }, + issuedAt: Date.now(), + expiresAt: Date.now() + 15_000 + }; +} diff --git a/features/agent/frontend-action/index.ts b/features/agent/frontend-action/index.ts index d302757..3573cc9 100644 --- a/features/agent/frontend-action/index.ts +++ b/features/agent/frontend-action/index.ts @@ -1,4 +1,4 @@ -export const FRONTEND_ACTION_NAMES = ["zoom_to_map", "locate_features", "view_history"] as const; +export const FRONTEND_ACTION_NAMES = ["render_scada_analysis", "clear_scada_analysis"] as const; export type FrontendActionName = (typeof FRONTEND_ACTION_NAMES)[number]; export type FrontendActionRegistry = { schema_version: "frontend-action-registry@1"; actions: Array<{ id: FrontendActionName; version: "frontend-action@1" }> }; export type FrontendActionRequest = { version: "frontend-action@1"; actionId: string; toolCallId: string; sessionId: string; name: FrontendActionName; params: Record; issuedAt: number; expiresAt: number }; diff --git a/features/agent/hooks/use-agent-voice.ts b/features/agent/hooks/use-agent-voice.ts new file mode 100644 index 0000000..53962b3 --- /dev/null +++ b/features/agent/hooks/use-agent-voice.ts @@ -0,0 +1,347 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { + splitSpeechTextIntoChunks, + type AgentSpeakOptions, + type AgentSpeechState +} from "../speech"; + +interface BrowserSpeechRecognitionEvent extends Event { + readonly resultIndex: number; + readonly results: SpeechRecognitionResultList; +} + +interface BrowserSpeechRecognitionErrorEvent extends Event { + readonly error: string; + readonly message?: string; +} + +interface BrowserSpeechRecognition extends EventTarget { + lang: string; + continuous: boolean; + interimResults: boolean; + onresult: ((event: BrowserSpeechRecognitionEvent) => void) | null; + onerror: ((event: BrowserSpeechRecognitionErrorEvent) => void) | null; + onend: (() => void) | null; + start(): void; + stop(): void; + abort(): void; +} + +type BrowserSpeechRecognitionConstructor = new () => BrowserSpeechRecognition; + +declare global { + interface Window { + SpeechRecognition?: BrowserSpeechRecognitionConstructor; + webkitSpeechRecognition?: BrowserSpeechRecognitionConstructor; + } +} + +const subscribeToBrowserCapabilities = () => () => undefined; + +function getSpeechSynthesisSupport() { + return ( + typeof window.Audio !== "undefined" && + typeof window.URL !== "undefined" && + typeof window.fetch !== "undefined" + ); +} + +function getSpeechRecognitionSupport() { + return "SpeechRecognition" in window || "webkitSpeechRecognition" in window; +} + +function getServerBrowserCapability() { + return false; +} + +export function useAgentSpeechSynthesis() { + const [speechState, setSpeechState] = useState("idle"); + const [speakingMessageId, setSpeakingMessageId] = useState(null); + const audioRef = useRef(null); + const currentAudioUrlRef = useRef(null); + const audioUrlsRef = useRef>(new Set()); + const fetchControllersRef = useRef>(new Set()); + const chunkUrlCacheRef = useRef>(new Map()); + const chunkPromisesRef = useRef>>(new Map()); + const chunksRef = useRef([]); + const currentChunkIndexRef = useRef(0); + const playbackTokenRef = useRef(0); + const activeMessageIdRef = useRef(null); + const playChunkRef = useRef<(chunkIndex: number, playbackToken: number) => Promise>(async () => undefined); + + const isSupported = useSyncExternalStore( + subscribeToBrowserCapabilities, + getSpeechSynthesisSupport, + getServerBrowserCapability + ); + + const detachAudio = useCallback((revokeUrl: boolean) => { + const audio = audioRef.current; + audioRef.current = null; + if (audio) { + audio.pause(); + audio.onended = null; + audio.onerror = null; + audio.removeAttribute("src"); + audio.load(); + } + + const currentUrl = currentAudioUrlRef.current; + currentAudioUrlRef.current = null; + if (revokeUrl && currentUrl) { + URL.revokeObjectURL(currentUrl); + audioUrlsRef.current.delete(currentUrl); + chunkUrlCacheRef.current.delete(currentChunkIndexRef.current); + } + }, []); + + const releaseAudio = useCallback(() => { + fetchControllersRef.current.forEach((controller) => controller.abort()); + fetchControllersRef.current.clear(); + chunkPromisesRef.current.clear(); + detachAudio(false); + audioUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); + audioUrlsRef.current.clear(); + chunkUrlCacheRef.current.clear(); + chunksRef.current = []; + currentChunkIndexRef.current = 0; + activeMessageIdRef.current = null; + }, [detachAudio]); + + const fetchChunkAudio = useCallback((chunkIndex: number, playbackToken: number) => { + const cachedUrl = chunkUrlCacheRef.current.get(chunkIndex); + if (cachedUrl) return Promise.resolve(cachedUrl); + + const currentPromise = chunkPromisesRef.current.get(chunkIndex); + if (currentPromise) return currentPromise; + + const text = chunksRef.current[chunkIndex]; + if (!text) return Promise.reject(new Error("语音片段不存在")); + + const controller = new AbortController(); + fetchControllersRef.current.add(controller); + const promise = fetch("/api/tts/edge", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text }), + signal: controller.signal + }) + .then(async (response) => { + if (!response.ok) { + const payload = await response.json().catch(() => null) as { error?: string } | null; + throw new Error(payload?.error ?? `语音生成失败 (${response.status})`); + } + const audioBlob = await response.blob(); + if (!audioBlob.size) throw new Error("语音服务返回了空音频"); + if (playbackToken !== playbackTokenRef.current) { + throw new DOMException("语音播放已取消", "AbortError"); + } + + const objectUrl = URL.createObjectURL(audioBlob); + audioUrlsRef.current.add(objectUrl); + chunkUrlCacheRef.current.set(chunkIndex, objectUrl); + return objectUrl; + }) + .finally(() => { + fetchControllersRef.current.delete(controller); + chunkPromisesRef.current.delete(chunkIndex); + }); + + chunkPromisesRef.current.set(chunkIndex, promise); + return promise; + }, []); + + const prefetchChunk = useCallback((chunkIndex: number, playbackToken: number) => { + if (chunkIndex >= chunksRef.current.length) return; + void fetchChunkAudio(chunkIndex, playbackToken).catch(() => undefined); + }, [fetchChunkAudio]); + + const playChunk = useCallback(async (chunkIndex: number, playbackToken: number) => { + setSpeechState("loading"); + const objectUrl = await fetchChunkAudio(chunkIndex, playbackToken); + if (playbackToken !== playbackTokenRef.current) return; + + detachAudio(true); + currentChunkIndexRef.current = chunkIndex; + const audio = new Audio(objectUrl); + audio.preload = "auto"; + audioRef.current = audio; + currentAudioUrlRef.current = objectUrl; + + audio.onended = () => { + if (playbackToken !== playbackTokenRef.current) return; + detachAudio(true); + const nextChunkIndex = chunkIndex + 1; + if (nextChunkIndex >= chunksRef.current.length) { + releaseAudio(); + setSpeechState("idle"); + setSpeakingMessageId(null); + return; + } + currentChunkIndexRef.current = nextChunkIndex; + void playChunkRef.current(nextChunkIndex, playbackToken); + }; + audio.onerror = () => { + if (playbackToken !== playbackTokenRef.current) return; + playbackTokenRef.current += 1; + releaseAudio(); + setSpeechState("idle"); + setSpeakingMessageId(null); + }; + + await audio.play(); + if (playbackToken !== playbackTokenRef.current) return; + setSpeechState("playing"); + prefetchChunk(chunkIndex + 1, playbackToken); + }, [detachAudio, fetchChunkAudio, prefetchChunk, releaseAudio]); + + useEffect(() => { + playChunkRef.current = playChunk; + }, [playChunk]); + + const speak = useCallback(async (messageId: string, text: string, options: AgentSpeakOptions = {}) => { + const normalizedText = text.trim(); + if (!isSupported || !normalizedText) return; + + const startOffset = Math.max(0, Math.min(options.startOffset ?? 0, normalizedText.length)); + const chunks = splitSpeechTextIntoChunks(normalizedText.slice(startOffset)); + if (!chunks.length) return; + + const playbackToken = playbackTokenRef.current + 1; + playbackTokenRef.current = playbackToken; + releaseAudio(); + chunksRef.current = chunks; + activeMessageIdRef.current = messageId; + setSpeakingMessageId(messageId); + setSpeechState("loading"); + + try { + await playChunk(0, playbackToken); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return; + releaseAudio(); + setSpeechState("idle"); + setSpeakingMessageId(null); + } + }, [isSupported, playChunk, releaseAudio]); + + const pause = useCallback(() => { + if (!audioRef.current || speechState !== "playing") return; + audioRef.current.pause(); + setSpeechState("paused"); + }, [speechState]); + + const resume = useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + setSpeakingMessageId(activeMessageIdRef.current); + setSpeechState("playing"); + void audio.play().catch(() => { + playbackTokenRef.current += 1; + releaseAudio(); + setSpeechState("idle"); + setSpeakingMessageId(null); + }); + }, [releaseAudio]); + + const stop = useCallback(() => { + playbackTokenRef.current += 1; + releaseAudio(); + setSpeechState("idle"); + setSpeakingMessageId(null); + }, [releaseAudio]); + + useEffect(() => () => { + playbackTokenRef.current += 1; + releaseAudio(); + }, [releaseAudio]); + + return { speechState, speakingMessageId, speak, pause, resume, stop, isSupported }; +} + +export function useAgentSpeechRecognition(onResult: (text: string) => void) { + const [isListening, setIsListening] = useState(false); + const recognitionRef = useRef(null); + const onResultRef = useRef(onResult); + const onErrorRef = useRef<(message: string) => void>(() => undefined); + + useEffect(() => { + onResultRef.current = onResult; + }, [onResult]); + + const isSupported = useSyncExternalStore( + subscribeToBrowserCapabilities, + getSpeechRecognitionSupport, + getServerBrowserCapability + ); + + const start = useCallback((onError?: (message: string) => void) => { + if (!isSupported || recognitionRef.current) return; + const Recognition = window.SpeechRecognition ?? window.webkitSpeechRecognition; + if (!Recognition) return; + + onErrorRef.current = onError ?? (() => undefined); + + const recognition = new Recognition(); + recognition.lang = "zh-CN"; + recognition.continuous = true; + recognition.interimResults = false; + recognition.onresult = (event) => { + for (let index = event.resultIndex; index < event.results.length; index += 1) { + if (event.results[index].isFinal) { + onResultRef.current(event.results[index][0].transcript); + } + } + }; + recognition.onerror = (event) => { + setIsListening(false); + if (event.error !== "aborted") { + onErrorRef.current(getSpeechRecognitionErrorMessage(event.error)); + } + }; + recognition.onend = () => { + if (recognitionRef.current === recognition) { + recognitionRef.current = null; + } + setIsListening(false); + }; + + recognitionRef.current = recognition; + try { + recognition.start(); + setIsListening(true); + } catch { + recognitionRef.current = null; + setIsListening(false); + onErrorRef.current("无法启动语音输入,请检查浏览器麦克风权限后重试"); + } + }, [isSupported]); + + const stop = useCallback(() => { + recognitionRef.current?.stop(); + recognitionRef.current = null; + setIsListening(false); + }, []); + + useEffect(() => () => recognitionRef.current?.abort(), []); + + return { isListening, start, stop, isSupported }; +} + +function getSpeechRecognitionErrorMessage(error: string) { + switch (error) { + case "not-allowed": + case "service-not-allowed": + return "无法使用麦克风,请在浏览器地址栏允许麦克风权限后重试"; + case "audio-capture": + return "未检测到可用麦克风,请检查设备连接"; + case "network": + return "语音识别服务连接失败,请检查网络后重试"; + case "no-speech": + return "未检测到语音,请靠近麦克风后重试"; + default: + return "语音输入暂时不可用,请稍后重试"; + } +} diff --git a/features/agent/index.ts b/features/agent/index.ts index 2be0c05..a00e551 100644 --- a/features/agent/index.ts +++ b/features/agent/index.ts @@ -2,20 +2,16 @@ export { AgentCollapsedRail } from "./components/agent-collapsed-rail"; export { AgentCommandPanel } from "./components/agent-command-panel"; export { AgentPersona } from "./components/agent-persona"; export { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data"; -export { parseAssistantMessageSections, parseContentWithToolCalls } from "./message-sections"; export { applyPermissionResponse, applyQuestionResponse, - cancelRunningTodos, - completeRunningProgress, - finalizeAssistantMessageAfterAbort, toTodoUpdate, - updateMessageById, upsertPermission, upsertProgress, upsertQuestion } from "./session-state"; export type { + AgentApprovalMode, AgentChatMessage, AgentChatProgress, AgentInteractionToolRef, @@ -26,7 +22,6 @@ export type { AgentQuestionInfo, AgentQuestionOption, AgentQuestionRequest, - AgentStreamRenderChunk, AgentStreamRenderMessageState, AgentStreamRenderState, AgentTodoItem, @@ -38,9 +33,3 @@ export type { SafeChartSeries, SafeChartSpec } from "./chart-data"; -export type { - AssistantMessageSections, - ContentSegment, - ParsedToolContent, - ToolCall -} from "./message-sections"; diff --git a/features/agent/message-sections.test.ts b/features/agent/message-sections.test.ts deleted file mode 100644 index 8293c11..0000000 --- a/features/agent/message-sections.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseAssistantMessageSections, parseContentWithToolCalls } from "./message-sections"; - -describe("parseAssistantMessageSections", () => { - it("returns plain assistant content when there is no thought block", () => { - expect(parseAssistantMessageSections("直接回答")).toEqual({ - answer: "直接回答", - thought: null, - thoughtComplete: false - }); - }); - - it("extracts a completed thought block and keeps the final answer visible", () => { - expect(parseAssistantMessageSections("先分析需求\n\n最终回答")).toEqual({ - answer: "最终回答", - thought: "先分析需求", - thoughtComplete: true - }); - }); - - it("supports streaming thought content before the closing tag arrives", () => { - expect(parseAssistantMessageSections("准备中...\n继续推理中")).toEqual({ - answer: "准备中...", - thought: "继续推理中", - thoughtComplete: false - }); - }); - - it("merges multiple thought blocks into a single collapsed section", () => { - expect( - parseAssistantMessageSections( - "第一段思考\n答案开头\n第二段思考\n答案结尾" - ) - ).toEqual({ - answer: "答案开头\n\n答案结尾", - thought: "第一段思考\n\n第二段思考", - thoughtComplete: true - }); - }); -}); - -describe("parseContentWithToolCalls", () => { - it("returns a single text segment when there are no tool calls", () => { - const result = parseContentWithToolCalls("普通文本回答"); - - expect(result.segments).toEqual([{ type: "text", content: "普通文本回答" }]); - expect(result.toolCalls).toHaveLength(0); - }); - - it("parses a complete tool_call block", () => { - const content = - '分析完成。\n{"tool":"locate_junctions","params":{"ids":["J1","J2"]}}\n以上是结果。'; - const result = parseContentWithToolCalls(content); - - expect(result.toolCalls).toHaveLength(1); - expect(result.toolCalls[0].tool).toBe("locate_junctions"); - expect(result.toolCalls[0].params).toEqual({ ids: ["J1", "J2"] }); - expect(result.segments).toHaveLength(3); - expect(result.segments[0]).toEqual({ type: "text", content: "分析完成。" }); - expect(result.segments[1]).toMatchObject({ - type: "tool_call", - toolCall: { tool: "locate_junctions" } - }); - expect(result.segments[2]).toEqual({ type: "text", content: "以上是结果。" }); - }); - - it("parses multiple tool_call blocks", () => { - const content = - '文本1\n{"tool":"locate_pipes","params":{"ids":["P1"]}}\n文本2\n{"tool":"chart","params":{"title":"图"}}'; - const result = parseContentWithToolCalls(content); - - expect(result.toolCalls).toHaveLength(2); - expect(result.toolCalls[0].tool).toBe("locate_pipes"); - expect(result.toolCalls[1].tool).toBe("chart"); - expect(result.segments).toHaveLength(4); - }); - - it("detects an unclosed tool_call tag as pending while streaming", () => { - const result = parseContentWithToolCalls('正在分析...\n{"tool":"locate_no'); - - expect(result.segments).toEqual([ - { type: "text", content: "正在分析..." }, - { type: "tool_call_pending" } - ]); - expect(result.toolCalls).toHaveLength(0); - }); - - it("strips partial opening tags during streaming", () => { - const result = parseContentWithToolCalls("正在分析...\n { - const result = parseContentWithToolCalls("前文\n{invalid json}\n后文"); - - expect(result.toolCalls).toHaveLength(0); - expect(result.segments.length).toBeGreaterThanOrEqual(2); - }); - - it("returns empty segments for empty content", () => { - const result = parseContentWithToolCalls(""); - - expect(result.segments).toHaveLength(0); - expect(result.toolCalls).toHaveLength(0); - }); -}); diff --git a/features/agent/message-sections.ts b/features/agent/message-sections.ts deleted file mode 100644 index 2ede1b5..0000000 --- a/features/agent/message-sections.ts +++ /dev/null @@ -1,129 +0,0 @@ -export type AssistantMessageSections = { - answer: string; - thought: string | null; - thoughtComplete: boolean; -}; - -export type ToolCall = { - id: string; - tool: string; - params: Record; -}; - -export type ContentSegment = - | { type: "text"; content: string } - | { type: "tool_call"; toolCall: ToolCall } - | { type: "tool_call_pending" }; - -export type ParsedToolContent = { - segments: ContentSegment[]; - toolCalls: ToolCall[]; -}; - -const THINK_BLOCK_PATTERN = /([\s\S]*?)<\/think>/gi; -const THINK_OPEN_TAG = ""; -const THINK_CLOSE_TAG = ""; - -export function parseAssistantMessageSections(content: string): AssistantMessageSections { - if (!content) { - return { answer: "", thought: null, thoughtComplete: false }; - } - - const thoughtParts: string[] = []; - let answer = content; - - answer = answer.replace(THINK_BLOCK_PATTERN, (_, thoughtContent: string) => { - const trimmedThought = thoughtContent.trim(); - if (trimmedThought) { - thoughtParts.push(trimmedThought); - } - - return "\n"; - }); - - const lastOpenIndex = answer.lastIndexOf(THINK_OPEN_TAG); - const lastCloseIndex = answer.lastIndexOf(THINK_CLOSE_TAG); - const hasUnclosedThought = lastOpenIndex !== -1 && lastOpenIndex > lastCloseIndex; - - if (hasUnclosedThought) { - const streamingThought = answer.slice(lastOpenIndex + THINK_OPEN_TAG.length).trim(); - - if (streamingThought) { - thoughtParts.push(streamingThought); - } - - answer = answer.slice(0, lastOpenIndex); - } - - const normalizedAnswer = answer.replace(/\n{3,}/g, "\n\n").trim(); - const normalizedThought = thoughtParts.join("\n\n").trim(); - - return { - answer: normalizedAnswer, - thought: normalizedThought || null, - thoughtComplete: Boolean(normalizedThought) && !hasUnclosedThought - }; -} - -const TOOL_CALL_OPEN_TAG = ""; -const TOOL_CALL_PATTERN = /([\s\S]*?)<\/tool_call>/gi; -const PARTIAL_TOOL_TAG_TAIL = /<(?:t(?:o(?:o(?:l(?:_(?:c(?:a(?:l(?:l)?)?)?)?)?)?)?)?)?$/; - -export function parseContentWithToolCalls(content: string): ParsedToolContent { - if (!content) { - return { segments: [], toolCalls: [] }; - } - - const segments: ContentSegment[] = []; - const toolCalls: ToolCall[] = []; - let lastIndex = 0; - let toolCallIndex = 0; - let match: RegExpExecArray | null; - - while ((match = TOOL_CALL_PATTERN.exec(content)) !== null) { - const textBefore = content.slice(lastIndex, match.index); - if (textBefore.trim()) { - segments.push({ type: "text", content: textBefore.trim() }); - } - - try { - const parsed = JSON.parse(match[1].trim()) as { - tool?: string; - params?: Record; - }; - const toolCall: ToolCall = { - id: `tc-${toolCallIndex++}`, - tool: parsed.tool ?? "unknown", - params: parsed.params ?? {} - }; - segments.push({ type: "tool_call", toolCall }); - toolCalls.push(toolCall); - } catch { - segments.push({ type: "text", content: match[0] }); - } - - lastIndex = match.index + match[0].length; - } - - const remaining = content.slice(lastIndex); - const unclosedIndex = remaining.lastIndexOf(TOOL_CALL_OPEN_TAG); - - if (unclosedIndex !== -1) { - const textBefore = remaining.slice(0, unclosedIndex); - if (textBefore.trim()) { - segments.push({ type: "text", content: textBefore.trim() }); - } - segments.push({ type: "tool_call_pending" }); - } else { - const cleaned = remaining.replace(PARTIAL_TOOL_TAG_TAIL, "").trim(); - if (cleaned) { - segments.push({ type: "text", content: cleaned }); - } - } - - if (segments.length === 0) { - segments.push({ type: "text", content }); - } - - return { segments, toolCalls }; -} diff --git a/features/agent/session-state.ts b/features/agent/session-state.ts index 344859c..0c925b8 100644 --- a/features/agent/session-state.ts +++ b/features/agent/session-state.ts @@ -12,14 +12,6 @@ import type { type RecordValue = Record; -export function updateMessageById( - messages: AgentChatMessage[], - messageId: string, - updater: (message: AgentChatMessage) => AgentChatMessage -) { - return messages.map((message) => (message.id === messageId ? updater(message) : message)); -} - export function upsertProgress(progress: AgentChatProgress[] | undefined, data: unknown) { const payload = asRecord(data); const id = readString(payload.id) ?? `progress-${Date.now().toString(36)}`; diff --git a/features/agent/speech.test.ts b/features/agent/speech.test.ts new file mode 100644 index 0000000..c3aa094 --- /dev/null +++ b/features/agent/speech.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { + findSpeechSelectionStartOffset, + splitSpeechTextIntoChunks, + stripSpeechMarkdown +} from "./speech"; + +describe("Agent 语音文本处理", () => { + it("定位经过空白压缩的选择文本", () => { + const text = "第一段内容。\n第二段 包含空格。"; + expect(findSpeechSelectionStartOffset(text, "第二段 包含空格")).toBe(text.indexOf("第二段")); + expect(findSpeechSelectionStartOffset(text, "不存在")).toBeNull(); + }); + + it("按句子拆分长文本", () => { + const chunks = splitSpeechTextIntoChunks(`${"甲".repeat(260)}。${"乙".repeat(260)}。`); + expect(chunks).toHaveLength(2); + expect(chunks.join("")).toContain("乙"); + }); + + it("移除会干扰朗读的 Markdown 标记", () => { + expect(stripSpeechMarkdown("## 分析结果\n**水位** [详情](https://example.com)")).toBe("分析结果\n水位 详情"); + }); +}); diff --git a/features/agent/speech.ts b/features/agent/speech.ts new file mode 100644 index 0000000..041d575 --- /dev/null +++ b/features/agent/speech.ts @@ -0,0 +1,111 @@ +const compactWhitespace = (value: string) => value.replace(/\s+/g, " ").trim(); +const MAX_SPEECH_CHUNK_LENGTH = 520; +const MIN_SPEECH_CHUNK_LENGTH = 180; +const SPEECH_SENTENCE_PATTERN = /[^。!?!?;;\n]+(?:[。!?!?;;]+|(?=\n|$))/g; + +export type AgentSpeechState = "idle" | "loading" | "playing" | "paused"; + +export type AgentSpeakOptions = { + startOffset?: number; +}; + +const normalizeWithOffsetMap = (value: string) => { + let normalized = ""; + const offsetMap: number[] = []; + let previousWasWhitespace = false; + + Array.from(value).forEach((character, index) => { + if (/\s/u.test(character)) { + if (!previousWasWhitespace && normalized.length > 0) { + normalized += " "; + offsetMap.push(index); + } + previousWasWhitespace = true; + return; + } + + normalized += character; + offsetMap.push(index); + previousWasWhitespace = false; + }); + + return { normalized: normalized.trimEnd(), offsetMap }; +}; + +export function stripSpeechMarkdown(markdown: string) { + return markdown + .replace(/```[\s\S]*?```/g, "") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[.*?\]\(.*?\)/g, "") + .replace(/\[([^\]]+)\]\(.*?\)/g, "$1") + .replace(/#{1,6}\s+/g, "") + .replace(/\*{1,3}(.+?)\*{1,3}/g, "$1") + .replace(/~~(.+?)~~/g, "$1") + .replace(/^>\s+/gm, "") + .replace(/^[-*+]\s+/gm, "") + .replace(/^\d+\.\s+/gm, "") + .replace(/<[^>]+>/g, "") + .replace(/\n{2,}/g, "\n") + .trim(); +} + +export function findSpeechSelectionStartOffset(text: string, selectedText: string): number | null { + const needle = selectedText.trim(); + if (!needle) return null; + + const exactIndex = text.indexOf(needle); + if (exactIndex >= 0) return exactIndex; + + const normalizedNeedle = compactWhitespace(needle); + if (!normalizedNeedle) return null; + + const haystack = normalizeWithOffsetMap(text); + const normalizedIndex = haystack.normalized.indexOf(normalizedNeedle); + if (normalizedIndex < 0) return null; + + return haystack.offsetMap[normalizedIndex] ?? null; +} + +export function splitSpeechTextIntoChunks(text: string): string[] { + const normalizedText = text.trim(); + if (!normalizedText) return []; + + const segments = Array.from(normalizedText.matchAll(SPEECH_SENTENCE_PATTERN), (match) => + compactWhitespace(match[0]) + ).filter(Boolean); + const sourceSegments = segments.length > 0 ? segments : [normalizedText]; + const chunks: string[] = []; + let currentChunk = ""; + + const flush = () => { + if (!currentChunk) return; + chunks.push(currentChunk); + currentChunk = ""; + }; + + for (const segment of sourceSegments) { + if (segment.length > MAX_SPEECH_CHUNK_LENGTH) { + flush(); + for (let offset = 0; offset < segment.length; offset += MAX_SPEECH_CHUNK_LENGTH) { + chunks.push(segment.slice(offset, offset + MAX_SPEECH_CHUNK_LENGTH)); + } + continue; + } + + const candidate = currentChunk ? `${currentChunk}${segment}` : segment; + if ( + currentChunk && + candidate.length > MAX_SPEECH_CHUNK_LENGTH && + currentChunk.length >= MIN_SPEECH_CHUNK_LENGTH + ) { + flush(); + currentChunk = segment; + continue; + } + + currentChunk = candidate; + } + + flush(); + return chunks; +} diff --git a/features/agent/types.ts b/features/agent/types.ts index 41fcebf..a6f12c7 100644 --- a/features/agent/types.ts +++ b/features/agent/types.ts @@ -8,13 +8,7 @@ export type AgentChatMessage = { todos?: AgentTodoUpdate; }; -export type AgentStreamRenderChunk = { - id: number; - text: string; -}; - export type AgentStreamRenderMessageState = { - chunks: AgentStreamRenderChunk[]; done: boolean; }; @@ -44,6 +38,8 @@ export type AgentPermissionStatus = export type AgentPermissionReply = "once" | "always" | "reject"; +export type AgentApprovalMode = "request" | "always"; + export type AgentModelOption = { id: string; label: string; diff --git a/features/workbench/components/map-dev-panel.tsx b/features/workbench/components/map-dev-panel.tsx new file mode 100644 index 0000000..69d34aa --- /dev/null +++ b/features/workbench/components/map-dev-panel.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { ChevronDown, Crosshair, LocateFixed, RotateCcw, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + LAYER_GROUP_GEOMETRIES, + type LayerGroupId, + type LayerGroupStylePatch +} from "../map/layer-group-style"; +import { getNumericFeatureProperties } from "../map/value-label"; +import type { + FeatureTarget, + WorkbenchMapCommands, + WorkbenchMapControllerState +} from "../map/workbench-map-controller"; +import { WATER_NETWORK_SOURCE_IDS, type WaterNetworkSourceId } from "../map/sources"; +import type { DetailFeature } from "../types"; + +type MapDevPanelProps = { + commands: WorkbenchMapCommands; + controllerState: WorkbenchMapControllerState; + detailFeature: DetailFeature | null; + onClose: () => void; +}; + +const GROUP_LABELS: Record = { + conduits: "管渠", + junctions: "检查井", + orifices: "孔口", + outfalls: "排放口", + pumps: "泵", + scada: "SCADA" +}; + +const ERROR_LABELS: Record = { + MAP_NOT_READY: "地图尚未就绪", + FEATURE_NOT_FOUND: "目标要素不存在", + WFS_UNAVAILABLE: "WFS 查询暂不可用", + FLOW_UNAVAILABLE: "水流图层初始化失败", + INVALID_STYLE_PATCH: "参数不符合受控范围" +}; + +export function MapDevPanel({ commands, controllerState, detailFeature, onClose }: MapDevPanelProps) { + const [sourceId, setSourceId] = useState("conduits"); + const [featureId, setFeatureId] = useState("DWS30265WS2010031117"); + const [property, setProperty] = useState("diameter"); + const [precision, setPrecision] = useState(0); + const [unit, setUnit] = useState("mm"); + const [groupId, setGroupId] = useState("conduits"); + const [draft, setDraft] = useState>({ + color: "#0F766E", + fillColor: "#0369A1", + strokeColor: "#FFFFFF", + opacity: 0.9, + widthMultiplier: 1, + radiusMultiplier: 1, + strokeMultiplier: 1, + iconMultiplier: 1, + dash: "original" + }); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + const numericProperties = useMemo( + () => detailFeature && detailFeature.layer === sourceId && detailFeature.id === featureId + ? getNumericFeatureProperties(detailFeature.properties) + : [], + [detailFeature, featureId, sourceId] + ); + const target: FeatureTarget = { sourceId, featureId: featureId.trim() }; + const geometry = LAYER_GROUP_GEOMETRIES[groupId]; + + function useSelectedFeature() { + if (!detailFeature) return; + setSourceId(detailFeature.layer); + setFeatureId(detailFeature.id); + const firstNumeric = getNumericFeatureProperties(detailFeature.properties)[0]; + if (firstNumeric) setProperty(firstNumeric[0]); + } + + function applyDraft() { + let patch: LayerGroupStylePatch; + if (geometry === "line") { + patch = { + color: String(draft.color), + opacity: Number(draft.opacity), + widthMultiplier: Number(draft.widthMultiplier), + dash: draft.dash as "original" | "solid" | "dashed" + }; + } else { + patch = { + fillColor: String(draft.fillColor), + strokeColor: String(draft.strokeColor), + opacity: Number(draft.opacity), + radiusMultiplier: Number(draft.radiusMultiplier), + strokeMultiplier: Number(draft.strokeMultiplier), + ...(geometry === "scada" ? { iconMultiplier: Number(draft.iconMultiplier) } : {}) + }; + } + commands.applyLayerGroupStyle(groupId, patch); + } + + return ( + + ); +} + +function PanelSection({ title, description, children }: { title: string; description: string; children: React.ReactNode }) { + return

    {title}

    {description}

    {children}
    ; +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ; +} + +function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) { + return
    onChange(event.target.value.toUpperCase())} className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0" />{value}
    ; +} + +function RangeField({ label, min, max, step, value, onChange }: { label: string; min: number; max: number; step: number; value: number; onChange: (value: number) => void }) { + return onChange(Number(event.target.value))} className="h-6 w-full accent-blue-600" />; +} + +const inputClassName = "h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15"; +const primaryButtonClassName = "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg bg-blue-600 px-3 text-xs font-semibold text-white transition hover:bg-blue-700 active:scale-95 disabled:cursor-not-allowed disabled:scale-100 disabled:opacity-45"; +const secondaryButtonClassName = "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white/70 px-3 text-xs font-semibold text-slate-700 transition hover:bg-white active:scale-95 disabled:cursor-not-allowed disabled:scale-100 disabled:opacity-45"; diff --git a/features/workbench/components/workbench-top-bar.tsx b/features/workbench/components/workbench-top-bar.tsx index a453ce8..136032a 100644 --- a/features/workbench/components/workbench-top-bar.tsx +++ b/features/workbench/components/workbench-top-bar.tsx @@ -8,6 +8,7 @@ import { Droplets, Eye, EyeOff, + FlaskConical, type LucideIcon } from "lucide-react"; import { @@ -27,10 +28,13 @@ export type WorkbenchTopBarProps = { conditionFeedVisible: boolean; taskTickerAvailable: boolean; taskTickerVisible: boolean; + devPanelEnabled: boolean; + devPanelOpen: boolean; onSelectScenario: (scenarioId: string) => void; onSelectAlert: (alert: WorkbenchAlert) => void; onToggleConditionFeed: () => void; onToggleTaskTicker: () => void; + onToggleDevPanel: () => void; onPreviewScenario: () => void; onCompareScenario: () => void; onExportScenarioReport: () => void; @@ -58,10 +62,13 @@ export function WorkbenchTopBar({ conditionFeedVisible, taskTickerAvailable, taskTickerVisible, + devPanelEnabled, + devPanelOpen, onSelectScenario, onSelectAlert, onToggleConditionFeed, onToggleTaskTicker, + onToggleDevPanel, onPreviewScenario, onCompareScenario, onExportScenarioReport, @@ -84,7 +91,7 @@ export function WorkbenchTopBar({
-

排水管网

+

排水管网智能调度平台

调度工作台

@@ -128,6 +135,18 @@ export function WorkbenchTopBar({
+ {devPanelEnabled ?
+ +
: null}
>; -type StreamRenderChunkIdRef = MutableRefObject; -export function appendStreamRenderToken( +export function markStreamRenderPending( data: Record, messages: AgentUiMessage[], - setStreamRenderState: StreamRenderStateSetter, - chunkIdRef: StreamRenderChunkIdRef + setStreamRenderState: StreamRenderStateSetter ) { const text = getDataString(data, "content"); if (!text) { @@ -66,17 +64,14 @@ export function appendStreamRenderToken( return; } - const chunkId = chunkIdRef.current; - chunkIdRef.current += 1; - setStreamRenderState((current) => { - const previous = current[messageId] ?? { chunks: [], done: false }; + if (current[messageId]?.done === false) { + return current; + } + return { ...current, - [messageId]: { - chunks: [...previous.chunks, { id: chunkId, text }], - done: false - } + [messageId]: { done: false } }; }); } @@ -84,10 +79,7 @@ export function appendStreamRenderToken( export function createCompletedStreamRenderState(messages: AgentUiMessage[]): AgentStreamRenderState { return messages.reduce((next, message) => { if (message.role === "assistant") { - next[message.id] = { - chunks: [], - done: true - }; + next[message.id] = { done: true }; } return next; }, {}); @@ -107,10 +99,7 @@ export function markLastAssistantStreamDone( return { ...current, - [messageId]: { - chunks: current[messageId]?.chunks ?? [], - done: true - } + [messageId]: { done: true } }; }); } diff --git a/features/workbench/hooks/use-workbench-agent.ts b/features/workbench/hooks/use-workbench-agent.ts index 496434c..db8cd76 100644 --- a/features/workbench/hooks/use-workbench-agent.ts +++ b/features/workbench/hooks/use-workbench-agent.ts @@ -8,6 +8,7 @@ import useSWRImmutable from "swr/immutable"; import type { PersonaState } from "@/shared/ai-elements/persona"; import { showMapNotice } from "@/features/map/core/components/notice"; import type { + AgentApprovalMode, AgentChatMessage, AgentModelOption, AgentPermissionReply, @@ -34,11 +35,11 @@ import { import type { FrontendActionRequest } from "@/features/agent/frontend-action"; import { FrontendActionExecutor } from "@/features/agent/frontend-action/executor"; import { - appendStreamRenderToken, applySessionStreamEvent, createCompletedStreamRenderState, getDataString, markLastAssistantStreamDone, + markStreamRenderPending, readBodyString, toAgentChatMessages, toAgentUiMessages, @@ -62,10 +63,9 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben const collapseTimerRef = useRef(null); const mobileCollapseTimerRef = useRef(null); const sessionStreamAbortRef = useRef(null); - const sessionStreamIdRef = useRef(null); - const streamRenderChunkIdRef = useRef(0); const clientRef = useRef(createAgentApiClient()); const sessionIdRef = useRef(null); + const approvalModeRef = useRef("request"); const registryRef = useRef(null); const frontendActionEnabledRef = useRef(false); const onFrontendActionRef = useRef(onFrontendAction); @@ -82,6 +82,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben const [resumedStreaming, setResumedStreaming] = useState(false); const [modelOptions, setModelOptions] = useState([]); const [selectedModel, setSelectedModel] = useState(""); + const [approvalMode, setApprovalModeState] = useState("request"); const [streamRenderState, setStreamRenderState] = useState({}); const [permissionOverrides, setPermissionOverrides] = useState>({}); const [questionOverrides, setQuestionOverrides] = useState>({}); @@ -90,6 +91,11 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben sessionIdRef.current = sessionId; }, [sessionId]); + const setApprovalMode = useCallback((mode: AgentApprovalMode) => { + approvalModeRef.current = mode; + setApprovalModeState(mode); + }, []); + useEffect(() => { registryRef.current = registry; }, [registry]); @@ -148,11 +154,10 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben applySessionId(eventSessionId); if (part.type === "data-stream_token") { - appendStreamRenderToken( + markStreamRenderPending( part.data, chatRef.current.messages, - setStreamRenderState, - streamRenderChunkIdRef + setStreamRenderState ); return; } @@ -214,7 +219,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben trigger, messageId, session_id: readBodyString(body, "session_id") ?? sessionIdRef.current ?? undefined, - approval_mode: readBodyString(body, "approval_mode") ?? "request", + approval_mode: readBodyString(body, "approval_mode") ?? approvalModeRef.current, capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : [] } }; @@ -281,7 +286,6 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben const stopSessionStreamSubscription = useCallback(() => { sessionStreamAbortRef.current?.abort(); sessionStreamAbortRef.current = null; - sessionStreamIdRef.current = null; setResumedStreaming(false); }, []); @@ -418,11 +422,10 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben setSessionTitle(title); } } else if (event.type === "token") { - appendStreamRenderToken( + markStreamRenderPending( event.data, chatRef.current.messages, - setStreamRenderState, - streamRenderChunkIdRef + setStreamRenderState ); } else if (event.type === "ui_envelope") { const payload = parseUiEnvelopePayload(event.data); @@ -473,7 +476,6 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben const controller = new AbortController(); sessionStreamAbortRef.current = controller; - sessionStreamIdRef.current = nextSessionId; setResumedStreaming(true); void clientRef.current @@ -496,7 +498,6 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben .finally(() => { if (sessionStreamAbortRef.current === controller) { sessionStreamAbortRef.current = null; - sessionStreamIdRef.current = null; setResumedStreaming(false); void refreshSessionHistory(); } @@ -608,7 +609,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben async (nextSessionId: string) => { if (streaming) { showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令,完成后再删除会话。" }); - return; + return false; } try { @@ -623,6 +624,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben } await refreshSessionHistory(); + return true; } catch (error) { showMapNotice({ id: "agent-history-status", @@ -630,6 +632,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben title: "Agent 历史记录删除失败", message: error instanceof Error ? error.message : "无法删除这个历史会话。" }); + return false; } }, [mutateSessionHistory, refreshSessionHistory, resetDraftSession, streaming] @@ -664,12 +667,12 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben body: { session_id: sessionIdRef.current ?? undefined, model: selectedModel || undefined, - approval_mode: "request" + approval_mode: approvalMode } } ); }, - [chat, selectedModel, streaming] + [approvalMode, chat, selectedModel, streaming] ); const stopPrompt = useCallback(() => { @@ -788,6 +791,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben }, []); return { + approvalMode, collapsePanel, expandPanel, mobileOpen, @@ -811,6 +815,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben statusLabel, streamRenderState, selectedModel, + setApprovalMode, setSelectedModel, stopPrompt, streaming, diff --git a/features/workbench/hooks/use-workbench-map-controller.ts b/features/workbench/hooks/use-workbench-map-controller.ts new file mode 100644 index 0000000..9ca63f4 --- /dev/null +++ b/features/workbench/hooks/use-workbench-map-controller.ts @@ -0,0 +1,39 @@ +"use client"; + +import type { Map as MapLibreMap } from "maplibre-gl"; +import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react"; +import { getResponsiveWorkbenchPadding } from "../map/camera"; +import { WorkbenchMapController } from "../map/workbench-map-controller"; + +export function useWorkbenchMapController({ + mapRef, + mapReady, + leftPanelOpen, + rightPanelOpen +}: { + mapRef: RefObject; + mapReady: boolean; + leftPanelOpen: boolean; + rightPanelOpen: boolean; +}) { + const valuesRef = useRef({ mapReady, leftPanelOpen, rightPanelOpen }); + valuesRef.current = { mapReady, leftPanelOpen, rightPanelOpen }; + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new WorkbenchMapController({ + getMap: () => mapRef.current, + isReady: () => valuesRef.current.mapReady, + getPadding: () => { + const map = mapRef.current; + return map + ? getResponsiveWorkbenchPadding(map, valuesRef.current.leftPanelOpen, valuesRef.current.rightPanelOpen) + : { top: 48, right: 48, bottom: 48, left: 48 }; + } + }); + } + + const controller = controllerRef.current; + const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot); + useEffect(() => () => controller.destroy(), [controller]); + return { controller, state }; +} diff --git a/features/workbench/hooks/use-workbench-map.ts b/features/workbench/hooks/use-workbench-map.ts index 8be1675..db3cb22 100644 --- a/features/workbench/hooks/use-workbench-map.ts +++ b/features/workbench/hooks/use-workbench-map.ts @@ -17,6 +17,11 @@ import { waterNetworkInteractionLayers } from "../map/layers"; import { MAP_MAX_ZOOM } from "../map/map-layer-visuals"; +import { + VALUE_LABEL_LAYER_IDS, + VALUE_LABEL_SOURCE_ID, + createEmptyValueLabelCollection +} from "../map/value-label"; import { setSimulationLayersVisibility } from "../map/simulation-layers"; import { registerScadaImages, @@ -24,6 +29,11 @@ import { scadaLayers, type ScadaImageRegistrationResult } from "../map/scada"; +import { + SCADA_ANALYSIS_SOURCE_ID, + createEmptyScadaAnalysisCollection, + ensureScadaAnalysisLayers +} from "../map/scada-analysis"; import { createBaseStyle, createWaterNetworkSources, @@ -134,12 +144,33 @@ export function useWorkbenchMap({ map.addSource("outfalls", sources.outfalls); map.addSource("pumps", sources.pumps); map.addSource("scada", sources.scada); + map.addSource(SCADA_ANALYSIS_SOURCE_ID, { + type: "geojson", + data: createEmptyScadaAnalysisCollection() + }); map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea); map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations); + map.addSource(VALUE_LABEL_SOURCE_ID, { type: "geojson", data: createEmptyValueLabelCollection() }); simulationAnnotationLayers.filter((layer) => layer.id === "simulation-impact-fill").forEach((layer) => map.addLayer(layer)); waterNetworkBusinessLayers.forEach((layer) => map.addLayer(layer)); simulationAnnotationLayers.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill").forEach((layer) => map.addLayer(layer)); waterNetworkInteractionLayers.forEach((layer) => map.addLayer(layer)); + map.addLayer({ + id: VALUE_LABEL_LAYER_IDS[0], + type: "symbol", + source: VALUE_LABEL_SOURCE_ID, + filter: ["==", ["geometry-type"], "Point"], + layout: { "text-field": ["get", "label"], "text-size": 12, "text-offset": [0, -1.35], "text-anchor": "bottom" }, + paint: { "text-color": "#0F172A", "text-halo-color": "#FFFFFF", "text-halo-width": 2 } + }); + map.addLayer({ + id: VALUE_LABEL_LAYER_IDS[1], + type: "symbol", + source: VALUE_LABEL_SOURCE_ID, + filter: ["in", ["geometry-type"], ["literal", ["LineString", "MultiLineString"]]], + layout: { "symbol-placement": "line-center", "text-field": ["get", "label"], "text-size": 12 }, + paint: { "text-color": "#0F172A", "text-halo-color": "#FFFFFF", "text-halo-width": 2 } + }); let scadaRegistration: ScadaImageRegistrationResult = { status: "unavailable", failedImageIds: [] @@ -156,6 +187,7 @@ export function useWorkbenchMap({ simulationAnnotationLayers.filter((layer) => layer.type === "symbol").forEach((layer) => map.addLayer(layer)); waterNetworkHitLayers.forEach((layer) => map.addLayer(layer)); map.addLayer(presentationLayers[presentationLayers.length - 1]); + ensureScadaAnalysisLayers(map); } finally { setSimulationLayersVisibility(map, impactVisibleRef.current); setMapReady(true); diff --git a/features/workbench/index.ts b/features/workbench/index.ts index c4cd197..ad6c928 100644 --- a/features/workbench/index.ts +++ b/features/workbench/index.ts @@ -1 +1,15 @@ export { MapWorkbenchPage } from "./map-workbench-page"; +export { WorkbenchMapController } from "./map/workbench-map-controller"; +export type { + FeatureTarget, + WorkbenchMapCommands, + WorkbenchMapControllerState, + WorkbenchMapErrorCode +} from "./map/workbench-map-controller"; +export type { + LayerGroupId, + LayerGroupStylePatch, + LineLayerGroupStylePatch, + PointLayerGroupStylePatch, + ScadaLayerGroupStylePatch +} from "./map/layer-group-style"; diff --git a/features/workbench/layout/workbench-layout.ts b/features/workbench/layout/workbench-layout.ts index 1445765..f29f9f1 100644 --- a/features/workbench/layout/workbench-layout.ts +++ b/features/workbench/layout/workbench-layout.ts @@ -4,6 +4,7 @@ export const WORKBENCH_LAYOUT = { persistentConditionMinWidth: 1280, wideMinWidth: 1536, collapsedAgentWidth: 136, + maxAgentViewportRatio: 0.5, mapEdgeGap: 24, desktop: { agentWidth: 460, @@ -19,6 +20,13 @@ export const WORKBENCH_LAYOUT = { } } as const; +export function clampAgentPanelWidth(width: number, viewportWidth: number) { + const minWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth; + const maxWidth = viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio; + + return Math.round(Math.min(Math.max(width, minWidth), maxWidth)); +} + export type WorkbenchPanelState = { agentOpen: boolean; conditionOpen: boolean; diff --git a/features/workbench/map-workbench-page.tsx b/features/workbench/map-workbench-page.tsx index 7c90896..8de080d 100644 --- a/features/workbench/map-workbench-page.tsx +++ b/features/workbench/map-workbench-page.tsx @@ -1,7 +1,17 @@ "use client"; import type { Map as MapLibreMap } from "maplibre-gl"; -import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps, type CSSProperties } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ComponentProps, + type CSSProperties, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent +} from "react"; import { X } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { @@ -28,6 +38,7 @@ import { } from "@/features/map/core/components/map-control-styles"; import { cn } from "@/lib/utils"; import { AgentTaskTicker } from "./components/agent-task-ticker"; +import { MapDevPanel } from "./components/map-dev-panel"; import { FeaturePopover } from "./components/feature-popover"; import { ScheduledConditionFeed } from "./components/scheduled-condition-feed"; import { ToolbarPanel, type ExportViewPreset, type ToolbarPanelProps, type ToolbarToolId } from "./components/toolbar-panel"; @@ -40,11 +51,19 @@ import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session"; import { useWorkbenchAgent } from "./hooks/use-workbench-agent"; import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing"; import { useWorkbenchMap } from "./hooks/use-workbench-map"; +import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller"; import { toMapFeatureReference } from "./hooks/use-map-interactions"; import { useWorkbenchMeasurement, type WorkbenchMeasureMode, type WorkbenchMeasureUnit } from "./hooks/use-workbench-measurement"; -import { getWorkbenchBasemapTone, getWorkbenchLayoutCssVariables } from "./layout/workbench-layout"; +import { + WORKBENCH_LAYOUT, + clampAgentPanelWidth, + getWorkbenchBasemapTone, + getWorkbenchLayoutCssVariables, + getWorkbenchViewportLayout +} from "./layout/workbench-layout"; import { getResponsiveWorkbenchPadding } from "./map/camera"; import { exportMapViewImage } from "./map/export-view"; +import { parseScadaAnalysisItems } from "./map/scada-analysis"; import { BASE_LAYER_IDS, BASE_LAYER_OPTIONS, @@ -86,10 +105,18 @@ function getMsUntilNextHeaderDataTimeTick(date: Date) { export function MapWorkbenchPage() { const hasMapboxToken = Boolean(process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN); + const devPanelEnabled = process.env.NEXT_PUBLIC_ENABLE_DEV_PANEL === "true"; const mapContainerRef = useRef(null); + const desktopAgentPanelRef = useRef(null); + const agentPanelResizeLeftRef = useRef(0); + const agentPanelResizeCleanupRef = useRef<(() => void) | null>(null); const [detailFeature, setDetailFeature] = useState(null); + const [devPanelOpen, setDevPanelOpen] = useState(false); const [impactVisible, setImpactVisible] = useState(false); const [isLargeScreen, setIsLargeScreen] = useState(false); + const [viewportWidth, setViewportWidth] = useState(WORKBENCH_LAYOUT.desktopMinWidth); + const [agentPanelWidth, setAgentPanelWidth] = useState(null); + const [agentPanelResizing, setAgentPanelResizing] = useState(false); const [activeToolId, setActiveToolId] = useState(null); const [conditionFeedVisible, setConditionFeedVisible] = useState(false); const [conditionFeedMounted, setConditionFeedMounted] = useState(true); @@ -126,15 +153,27 @@ export function MapWorkbenchPage() { useEffect(() => { const mediaQuery = window.matchMedia("(min-width: 1024px)"); - const handleChange = () => setIsLargeScreen(mediaQuery.matches); + const handleChange = () => { + setIsLargeScreen(mediaQuery.matches); + setViewportWidth(window.innerWidth); + setAgentPanelWidth((currentWidth) => + currentWidth === null ? null : clampAgentPanelWidth(currentWidth, window.innerWidth) + ); + }; handleChange(); setConditionFeedVisible(window.innerWidth >= 1280); mediaQuery.addEventListener("change", handleChange); + window.addEventListener("resize", handleChange); - return () => mediaQuery.removeEventListener("change", handleChange); + return () => { + mediaQuery.removeEventListener("change", handleChange); + window.removeEventListener("resize", handleChange); + }; }, []); + useEffect(() => () => agentPanelResizeCleanupRef.current?.(), []); + useEffect(() => { let timeoutId: number; @@ -182,7 +221,7 @@ export function MapWorkbenchPage() { onFrontendAction: handleFrontendAction }); const leftPanelOpen = isLargeScreen && agent.panelOpen; - const rightPanelOpen = isLargeScreen && shouldShowConditionFeed; + const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed); const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({ containerRef: mapContainerRef, @@ -192,6 +231,12 @@ export function MapWorkbenchPage() { onSelectFeature: handleSelectFeature, selectedFeature: detailFeature }); + const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({ + mapRef, + mapReady, + leftPanelOpen, + rightPanelOpen + }); const activeAgentUiResults = useMemo( () => agentUiResults.filter((result) => result.sessionId === agent.sessionId), @@ -524,36 +569,50 @@ export function MapWorkbenchPage() { } async function handleFrontendAction(request: FrontendActionRequest, signal: AbortSignal) { - const map = mapRef.current; - if (request.name === "zoom_to_map") { - if (!map || !mapReady) throw new Error("MAP_NOT_READY"); - const trustedAction = toTrustedMapAction("zoom_to_map", request.params); - if (!trustedAction || trustedAction.type !== "zoom_to_map") throw new Error("INVALID_ZOOM_TO_MAP"); - const zoom = trustedAction.zoom ?? 18; - const duration = trustedAction.durationMs ?? 500; - map.easeTo({ - center: trustedAction.center, - zoom, - padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen), - duration + if (request.name === "render_scada_analysis") { + const items = parseScadaAnalysisItems(request.params.items); + if (!items) throw new Error("INVALID_SCADA_ANALYSIS"); + const result = await mapController.renderScadaAnalysis(items, signal); + const counts = result.level_counts; + showMapNotice({ + tone: "success", + title: "Agent SCADA 分析已渲染", + message: `已渲染 ${result.rendered_ids.length} 个,高 ${counts.high}、中 ${counts.medium}、低 ${counts.low}${result.missing_ids.length ? `,缺失 ${result.missing_ids.length} 个` : ""}。` }); - return { center: trustedAction.center, zoom, duration }; + return result; } - if (request.name === "locate_features") { - if (!map || !mapReady) throw new Error("MAP_NOT_READY"); const ids = request.params.ids as string[]; const featureType = String(request.params.feature_type); - const response = await fetch("/api/agent-locate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids, featureType }), signal }); const geojson = await response.json(); if (!response.ok) throw new Error(geojson.code ?? "WFS_FAILED"); - const features = geojson.features as Array<{ id?: string | number; properties?: Record; geometry: { coordinates: unknown } }>; if (!features.length) throw new Error("FEATURE_NOT_FOUND"); - if (map.getSource("agent-locate")) (map.getSource("agent-locate") as unknown as { setData: (data: unknown) => void }).setData(geojson); else { map.addSource("agent-locate", { type: "geojson", data: geojson }); map.addLayer({ id: "agent-locate-lines", type: "line", source: "agent-locate", filter: ["==", ["geometry-type"], "LineString"], paint: { "line-color": "#f97316", "line-width": 6 } }); map.addLayer({ id: "agent-locate-points", type: "circle", source: "agent-locate", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-color": "#f97316", "circle-radius": 8, "circle-stroke-color": "#fff", "circle-stroke-width": 2 } }); } - const coordinates: number[][] = []; const collect = (value: unknown) => { if (Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") coordinates.push(value as number[]); else if (Array.isArray(value)) value.forEach(collect); }; features.forEach((feature) => collect(feature.geometry.coordinates)); const bounds: [number, number, number, number] = [Math.min(...coordinates.map((c) => c[0])), Math.min(...coordinates.map((c) => c[1])), Math.max(...coordinates.map((c) => c[0])), Math.max(...coordinates.map((c) => c[1]))]; map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen), maxZoom: 18 }); const locatedIds = features.map((feature) => String(feature.id ?? feature.properties?.id ?? "")).filter(Boolean); return { locatedIds, missingIds: ids.filter((id) => !locatedIds.includes(id)), featureType, bounds }; - } - if (request.name === "view_history") { - const itemCount = (request.params.feature_infos as unknown[]).length; - setAgentUiResults((current) => [...current.slice(-5), { id: request.actionId, sessionId: request.sessionId, createdAt: Date.now(), envelope: { kind: "registered_component", schemaVersion: "agent-ui@1", component: "HistoryPanel", surface: "side_panel", props: request.params } }]); - return { component: "HistoryPanel", rendered: true, itemCount }; + if (request.name === "clear_scada_analysis") { + const result = mapController.clearScadaAnalysis(); + showMapNotice({ + tone: "info", + title: "Agent SCADA 分析已清除", + message: "地图已移除当前会话的 SCADA 分析结果。" + }); + return result; } throw new Error("UNSUPPORTED_FRONTEND_ACTION"); } + async function handleStartNewAgentSession() { + mapController.clearScadaAnalysis(); + await agent.startNewSession(); + } + + async function handleLoadAgentHistorySession(sessionId: string) { + if (sessionId !== agent.sessionId) { + mapController.clearScadaAnalysis(); + } + await agent.loadHistorySession(sessionId); + } + + async function handleDeleteAgentHistorySession(sessionId: string) { + const clearsActiveAnalysis = sessionId === agent.sessionId; + const deleted = await agent.deleteHistorySession(sessionId); + if (deleted && clearsActiveAnalysis) { + mapController.clearScadaAnalysis(); + } + } + async function handleAgentMapAction(action: string, params: unknown, sessionId: string, fallbackText?: string) { const trustedAction = toTrustedMapAction(action, params, fallbackText); if (!trustedAction) { @@ -701,13 +760,15 @@ export function MapWorkbenchPage() { streamRenderState: agent.streamRenderState, modelOptions: agent.modelOptions, selectedModel: agent.selectedModel, + approvalMode: agent.approvalMode, uiResults: activeAgentUiResults, onRefreshHistory: agent.refreshSessionHistory, - onStartNewSession: agent.startNewSession, - onLoadHistorySession: agent.loadHistorySession, + onStartNewSession: handleStartNewAgentSession, + onLoadHistorySession: handleLoadAgentHistorySession, onRenameHistorySession: agent.renameHistorySession, - onDeleteHistorySession: agent.deleteHistorySession, + onDeleteHistorySession: handleDeleteAgentHistorySession, onSelectModel: agent.setSelectedModel, + onApprovalModeChange: agent.setApprovalMode, onSubmitPrompt: agent.submitPrompt, onStopPrompt: agent.stopPrompt, onReplyPermission: agent.replyPermission, @@ -715,6 +776,62 @@ export function MapWorkbenchPage() { onRejectQuestion: agent.rejectQuestion }; + const resizeAgentPanel = useCallback((clientX: number) => { + setAgentPanelWidth(clampAgentPanelWidth(clientX - agentPanelResizeLeftRef.current, window.innerWidth)); + }, []); + + const handleAgentPanelResizeStart = useCallback((event: ReactPointerEvent) => { + if (event.button !== 0 || !desktopAgentPanelRef.current) { + return; + } + + event.preventDefault(); + agentPanelResizeLeftRef.current = desktopAgentPanelRef.current.getBoundingClientRect().left; + setAgentPanelResizing(true); + resizeAgentPanel(event.clientX); + + agentPanelResizeCleanupRef.current?.(); + const handlePointerMove = (pointerEvent: PointerEvent) => { + pointerEvent.preventDefault(); + resizeAgentPanel(pointerEvent.clientX); + }; + const stopResizing = () => { + agentPanelResizeCleanupRef.current?.(); + setAgentPanelResizing(false); + }; + const cleanup = () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", stopResizing); + window.removeEventListener("pointercancel", stopResizing); + agentPanelResizeCleanupRef.current = null; + }; + + agentPanelResizeCleanupRef.current = cleanup; + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", stopResizing); + window.addEventListener("pointercancel", stopResizing); + }, [resizeAgentPanel]); + + const handleAgentPanelResizeKeyDown = useCallback((event: ReactKeyboardEvent) => { + const currentWidth = desktopAgentPanelRef.current?.getBoundingClientRect().width ?? WORKBENCH_LAYOUT.desktop.agentWidth; + let nextWidth = currentWidth; + + if (event.key === "ArrowLeft") { + nextWidth -= 16; + } else if (event.key === "ArrowRight") { + nextWidth += 16; + } else if (event.key === "Home") { + nextWidth = getWorkbenchViewportLayout(window.innerWidth).agentWidth; + } else if (event.key === "End") { + nextWidth = window.innerWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio; + } else { + return; + } + + event.preventDefault(); + setAgentPanelWidth(clampAgentPanelWidth(nextWidth, window.innerWidth)); + }, []); + return (
{ @@ -745,6 +864,7 @@ export function MapWorkbenchPage() { setActiveToolId(null); }} onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)} + onToggleDevPanel={() => setDevPanelOpen((current) => !current)} onPreviewScenario={handlePreviewScenario} onCompareScenario={handleCompareScenario} onExportScenarioReport={handleExportScenarioReport} @@ -755,13 +875,37 @@ export function MapWorkbenchPage() { onExportConfig={handleExportConfig} /> -
+
{agent.panelOpen ? ( - + <> + +
span]:bg-blue-500 [&>span]:opacity-100" + )} + role="separator" + tabIndex={0} + title="拖拽调整 Agent 面板宽度" + onKeyDown={handleAgentPanelResizeKeyDown} + onPointerDown={handleAgentPanelResizeStart} + > + +
+ ) : ( -
+ {!devPanelOpen ?
-
+
: null} - {conditionFeedMounted ? ( + {conditionFeedMounted && !devPanelOpen ? (
) : null} -
+ {!devPanelOpen ?
-
+
: null} + + {devPanelEnabled && devPanelOpen ? ( + setDevPanelOpen(false)} + /> + ) : null}
{agent.mobileOpen ? ( diff --git a/features/workbench/map/camera.test.ts b/features/workbench/map/camera.test.ts index 8a77a3b..c402d72 100644 --- a/features/workbench/map/camera.test.ts +++ b/features/workbench/map/camera.test.ts @@ -50,7 +50,7 @@ describe("workbench camera padding", () => { }); describe("fitNetworkBounds", () => { - it("preserves the original global extent for initial and home views", () => { + it("uses the configured global extent for initial and home views", () => { const { fitBounds, map } = createMap(); fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW); @@ -58,13 +58,13 @@ describe("fitNetworkBounds", () => { expect(fitBounds).toHaveBeenCalledTimes(1); const [bounds, options] = fitBounds.mock.calls[0] ?? []; - expect(bounds[0][0]).toBeCloseTo(121.735034, 6); - expect(bounds[0][1]).toBeCloseTo(30.846365, 6); - expect(bounds[1][0]).toBeCloseTo(121.970518, 6); - expect(bounds[1][1]).toBeCloseTo(30.994738, 6); + expect(bounds[0][0]).toBeCloseTo(120.634831, 6); + expect(bounds[0][1]).toBeCloseTo(27.957404, 6); + expect(bounds[1][0]).toBeCloseTo(120.763461, 6); + expect(bounds[1][1]).toBeCloseTo(28.023994, 6); expect(options).toMatchObject({ duration: 600, - maxZoom: 12, + maxZoom: 13, padding: { top: 48, right: 48, bottom: 48, left: 48 } }); expect(options).not.toHaveProperty("zoom"); diff --git a/features/workbench/map/camera.ts b/features/workbench/map/camera.ts index 853d401..fd2ad36 100644 --- a/features/workbench/map/camera.ts +++ b/features/workbench/map/camera.ts @@ -12,7 +12,7 @@ export type NetworkViewParams = { }; const WEB_MERCATOR_RADIUS = 6378137; -const GLOBAL_VIEW_MAX_ZOOM = 12; +const GLOBAL_VIEW_MAX_ZOOM = 13; const GLOBAL_VIEW_PADDING: PaddingOptions = { top: 48, right: 48, bottom: 48, left: 48 }; export function getWorkbenchPadding(leftPanelOpen: boolean, rightPanelOpen: boolean): PaddingOptions { return getWorkbenchCameraPadding(1280, { diff --git a/features/workbench/map/feature-adapter.test.ts b/features/workbench/map/feature-adapter.test.ts index d1f8f35..891f42a 100644 --- a/features/workbench/map/feature-adapter.test.ts +++ b/features/workbench/map/feature-adapter.test.ts @@ -61,24 +61,22 @@ describe("drainage feature adapter", () => { ); }); - it("uses the promoted SCADA id for feature-state interactions", () => { + it("uses the promoted SCADA sensor id for feature-state interactions", () => { const detail = toDetailFeature({ - id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2", + id: "SCADA-001", sourceLayer: SOURCE_LAYERS.scada, properties: { - scada_id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2", - site_external_id: "36455", - name: "DY22东市南街环城南路西段交叉口", - external_id: "26825171", - kind: "water_quality" + sensor_id: "SCADA-001", + sensor_name: "DY22东市南街环城南路西段交叉口", + swmm_node: "J-1001" } } as unknown as MapGeoJSONFeature); expect(detail).toMatchObject({ - id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2", + id: "SCADA-001", layer: "scada", title: "DY22东市南街环城南路西段交叉口", - subtitle: "water_quality · 设备 26825171" + subtitle: "综合监测点 · SWMM 节点 J-1001" }); }); }); diff --git a/features/workbench/map/feature-adapter.ts b/features/workbench/map/feature-adapter.ts index 550e7e6..27e3d0c 100644 --- a/features/workbench/map/feature-adapter.ts +++ b/features/workbench/map/feature-adapter.ts @@ -3,12 +3,10 @@ import type { DetailFeature } from "../types"; import { formatValue } from "../utils/format-value"; import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources"; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - export function getFeatureId(feature: MapGeoJSONFeature) { const layer = getWaterNetworkSourceId(feature); const raw = layer === "scada" - ? feature.properties?.scada_id ?? (typeof feature.id === "string" && UUID_PATTERN.test(feature.id) ? feature.id : undefined) + ? feature.properties?.sensor_id ?? feature.id : feature.id ?? feature.properties?.id; return raw === undefined || raw === null ? "" : String(raw); @@ -72,8 +70,8 @@ function getFeaturePresentation( }; case "scada": return { - title: String(properties.name || `SCADA 测点 ${displayId}`), - subtitle: `${formatValue(properties.kind)} · 设备 ${formatValue(properties.external_id)}` + title: String(properties.sensor_name || `综合监测点 ${displayId}`), + subtitle: `综合监测点 · SWMM 节点 ${formatValue(properties.swmm_node)}` }; } } diff --git a/features/workbench/map/flow-overlay.test.ts b/features/workbench/map/flow-overlay.test.ts new file mode 100644 index 0000000..8f065c0 --- /dev/null +++ b/features/workbench/map/flow-overlay.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from "vitest"; +import { createFlowOverlayState, stopFlowAnimation } from "./flow-overlay"; + +describe("flow overlay lifecycle", () => { + it("starts empty so conduit data can be loaded once per map session", () => { + expect(createFlowOverlayState()).toEqual({ data: null, animationFrameId: null, overlay: null }); + }); + + it("cancels an active animation frame without clearing cached data", () => { + const cancelAnimationFrame = vi.fn(); + vi.stubGlobal("window", { cancelAnimationFrame }); + const state = createFlowOverlayState(); + state.data = []; + state.animationFrameId = 42; + stopFlowAnimation(state); + expect(cancelAnimationFrame).toHaveBeenCalledWith(42); + expect(state.animationFrameId).toBeNull(); + expect(state.data).toBeTruthy(); + vi.unstubAllGlobals(); + }); +}); diff --git a/features/workbench/map/flow-overlay.ts b/features/workbench/map/flow-overlay.ts new file mode 100644 index 0000000..8d9a415 --- /dev/null +++ b/features/workbench/map/flow-overlay.ts @@ -0,0 +1,99 @@ +import type { Feature, FeatureCollection, LineString, MultiLineString } from "geojson"; +import type { Map as MapLibreMap } from "maplibre-gl"; +import type { MapboxOverlay } from "@deck.gl/mapbox"; +import { WORKBENCH_INTERACTION_BEFORE_ID } from "./layers"; + +type FlowFeature = Feature; + +export type FlowOverlayState = { + data: FlowFeature[] | null; + animationFrameId: number | null; + overlay: MapboxOverlay | null; +}; + +export function createFlowOverlayState(): FlowOverlayState { + return { data: null, animationFrameId: null, overlay: null }; +} + +export async function showFlowOverlay( + map: MapLibreMap, + state: FlowOverlayState, + loadData: () => Promise, + reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches +) { + if (!state.data) { + const collection = await loadData(); + state.data = collection.features.filter(isFlowFeature); + } + + const [{ MapboxOverlay }, { PathLayer }, { FlowPathExtension }] = await Promise.all([ + import("@deck.gl/mapbox"), + import("@deck.gl/layers"), + import("./flow-path-extension") + ]); + const extension = new FlowPathExtension(); + let renderingError: Error | null = null; + const render = (phase: number) => { + const layer = new PathLayer({ + id: "workbench-network-flow", + beforeId: WORKBENCH_INTERACTION_BEFORE_ID, + data: state.data ?? [], + getPath: (feature: FlowFeature) => feature.geometry.type === "LineString" ? feature.geometry.coordinates : feature.geometry.coordinates.flat(), + getColor: [15, 118, 110, 210], + getWidth: (feature: FlowFeature) => Math.min(5, Math.max(1, Number(feature.properties?.diameter ?? 200) / 180)), + widthUnits: "pixels", + widthMinPixels: 1, + widthMaxPixels: 5, + capRounded: true, + jointRounded: true, + pickable: false, + parameters: { depthTest: false }, + extensions: [extension], + flowPhase: phase + } as never); + state.overlay?.setProps({ + layers: [layer], + onError: (error) => { + renderingError = error; + } + }); + }; + + if (!state.overlay) { + const overlay = new MapboxOverlay({ interleaved: true, layers: [] }); + map.addControl(overlay); + state.overlay = overlay; + } + + stopFlowAnimation(state); + render(0.2); + await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); + if (renderingError) throw renderingError; + if (!reducedMotion) { + const startedAt = performance.now(); + const tick = (now: number) => { + render(((now - startedAt) / 1800) % 1); + state.animationFrameId = window.requestAnimationFrame(tick); + }; + state.animationFrameId = window.requestAnimationFrame(tick); + } +} + +export function hideFlowOverlay(map: MapLibreMap, state: FlowOverlayState) { + stopFlowAnimation(state); + if (state.overlay) { + map.removeControl(state.overlay as never); + state.overlay = null; + } +} + +export function stopFlowAnimation(state: FlowOverlayState) { + if (state.animationFrameId !== null) { + window.cancelAnimationFrame(state.animationFrameId); + state.animationFrameId = null; + } +} + +function isFlowFeature(feature: Feature): feature is FlowFeature { + return feature.geometry?.type === "LineString" || feature.geometry?.type === "MultiLineString"; +} diff --git a/features/workbench/map/flow-path-extension.ts b/features/workbench/map/flow-path-extension.ts new file mode 100644 index 0000000..30eed1e --- /dev/null +++ b/features/workbench/map/flow-path-extension.ts @@ -0,0 +1,34 @@ +import { LayerExtension, type Layer } from "@deck.gl/core"; + +const flowPathUniforms = { + name: "flowPath", + fs: `layout(std140) uniform flowPathUniforms { float phase; } flowPath;`, + uniformTypes: { phase: "f32" } +} as const; + +export class FlowPathExtension extends LayerExtension { + static extensionName = "FlowPathExtension"; + static defaultProps = { flowPhase: { type: "number", value: 0 } }; + + getShaders() { + return { + modules: [flowPathUniforms], + inject: { + "fs:DECKGL_FILTER_COLOR": ` + float flowStripe = fract((geometry.uv.y * 0.16) - flowPath.phase); + float flowPulse = smoothstep(0.0, 0.18, flowStripe) * (1.0 - smoothstep(0.55, 0.82, flowStripe)); + color.a *= 0.22 + flowPulse * 0.78; + ` + } + }; + } + + draw(this: Layer) { + const state = this.state as unknown as { + model?: { shaderInputs?: { setProps: (props: { flowPath: { phase: number } }) => void } }; + }; + state.model?.shaderInputs?.setProps({ + flowPath: { phase: Number((this.props as Record).flowPhase ?? 0) } + }); + } +} diff --git a/features/workbench/map/layer-group-style.test.ts b/features/workbench/map/layer-group-style.test.ts new file mode 100644 index 0000000..d1db2b4 --- /dev/null +++ b/features/workbench/map/layer-group-style.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { validateLayerGroupStylePatch } from "./layer-group-style"; + +describe("layer group style validation", () => { + it("accepts geometry-aware style patches", () => { + expect(validateLayerGroupStylePatch("conduits", { color: "#0F766E", opacity: 0.5, widthMultiplier: 3, dash: "solid" })).toBe(true); + expect(validateLayerGroupStylePatch("junctions", { fillColor: "#0369A1", strokeColor: "#FFFFFF", radiusMultiplier: 0.5 })).toBe(true); + expect(validateLayerGroupStylePatch("scada", { iconMultiplier: 2, opacity: 1 })).toBe(true); + }); + + it("rejects cross-geometry fields and out-of-range values", () => { + expect(validateLayerGroupStylePatch("conduits", { radiusMultiplier: 1 })).toBe(false); + expect(validateLayerGroupStylePatch("junctions", { widthMultiplier: 1 })).toBe(false); + expect(validateLayerGroupStylePatch("scada", { iconMultiplier: 2.1 })).toBe(false); + expect(validateLayerGroupStylePatch("conduits", { color: "red" })).toBe(false); + expect(validateLayerGroupStylePatch("unknown", {})).toBe(false); + }); +}); diff --git a/features/workbench/map/layer-group-style.ts b/features/workbench/map/layer-group-style.ts new file mode 100644 index 0000000..d0d2d88 --- /dev/null +++ b/features/workbench/map/layer-group-style.ts @@ -0,0 +1,212 @@ +import type { Map as MapLibreMap } from "maplibre-gl"; +import { DRAINAGE_LAYER_VISUALS } from "./map-layer-visuals"; +import { MAP_STYLE_TOKENS } from "./map-colors"; + +export type LineLayerGroupStylePatch = { + color?: string; + opacity?: number; + widthMultiplier?: number; + dash?: "original" | "solid" | "dashed"; +}; + +export type PointLayerGroupStylePatch = { + fillColor?: string; + strokeColor?: string; + opacity?: number; + radiusMultiplier?: number; + strokeMultiplier?: number; +}; + +export type ScadaLayerGroupStylePatch = PointLayerGroupStylePatch & { + iconMultiplier?: number; +}; + +export type LayerGroupStylePatch = LineLayerGroupStylePatch | PointLayerGroupStylePatch | ScadaLayerGroupStylePatch; +export type LayerGroupGeometry = "line" | "point" | "scada"; + +export const LAYER_GROUP_GEOMETRIES = { + conduits: "line", + junctions: "point", + orifices: "line", + outfalls: "point", + pumps: "line", + scada: "scada" +} as const satisfies Record; + +export type LayerGroupId = keyof typeof LAYER_GROUP_GEOMETRIES; + +const ORIGINAL_DASH: Partial> = { + conduits: undefined, + orifices: [4, 2], + pumps: [6, 2, 1.5, 2] +}; + +const LINE_PREFIX: Record<"conduits" | "orifices" | "pumps", string> = { + conduits: "pipes", + orifices: "orifices", + pumps: "pumps" +}; + +export function validateLayerGroupStylePatch(groupId: string, patch: unknown): patch is LayerGroupStylePatch { + if (!(groupId in LAYER_GROUP_GEOMETRIES) || !patch || typeof patch !== "object" || Array.isArray(patch)) return false; + const geometry = LAYER_GROUP_GEOMETRIES[groupId as LayerGroupId]; + const values = patch as Record; + const allowed = geometry === "line" + ? ["color", "opacity", "widthMultiplier", "dash"] + : geometry === "scada" + ? ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier", "iconMultiplier"] + : ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier"]; + if (Object.keys(values).some((key) => !allowed.includes(key))) return false; + if ("color" in values && !isColor(values.color)) return false; + if ("fillColor" in values && !isColor(values.fillColor)) return false; + if ("strokeColor" in values && !isColor(values.strokeColor)) return false; + if ("opacity" in values && !inRange(values.opacity, 0, 1)) return false; + if ("widthMultiplier" in values && !inRange(values.widthMultiplier, 0.5, 3)) return false; + if ("radiusMultiplier" in values && !inRange(values.radiusMultiplier, 0.5, 3)) return false; + if ("strokeMultiplier" in values && !inRange(values.strokeMultiplier, 0.5, 3)) return false; + if ("iconMultiplier" in values && !inRange(values.iconMultiplier, 0.5, 2)) return false; + if ("dash" in values && !["original", "solid", "dashed"].includes(String(values.dash))) return false; + return true; +} + +export function applyLayerGroupStyle(map: MapLibreMap, groupId: LayerGroupId, patch: LayerGroupStylePatch) { + const geometry = LAYER_GROUP_GEOMETRIES[groupId]; + if (geometry === "line") applyLineStyle(map, groupId as "conduits" | "orifices" | "pumps", patch as LineLayerGroupStylePatch); + else if (geometry === "scada") applyScadaStyle(map, patch as ScadaLayerGroupStylePatch); + else applyPointStyle(map, groupId as "junctions" | "outfalls", patch as PointLayerGroupStylePatch); +} + +export function resetLayerGroupStyle(map: MapLibreMap, groupId: LayerGroupId) { + const geometry = LAYER_GROUP_GEOMETRIES[groupId]; + if (geometry === "line") resetLineStyle(map, groupId as "conduits" | "orifices" | "pumps"); + else if (geometry === "scada") resetScadaStyle(map); + else resetPointStyle(map, groupId as "junctions" | "outfalls"); +} + +function applyLineStyle(map: MapLibreMap, groupId: "conduits" | "orifices" | "pumps", patch: LineLayerGroupStylePatch) { + const prefix = LINE_PREFIX[groupId]; + const coreId = groupId === "conduits" ? "pipes-flow" : groupId; + if (patch.color !== undefined) setPaint(map, coreId, "line-color", stateColor(patch.color)); + if (patch.opacity !== undefined) setPaint(map, coreId, "line-opacity", patch.opacity); + if (patch.dash !== undefined) { + const dash = patch.dash === "solid" ? [1, 0] : patch.dash === "dashed" ? [3, 2] : ORIGINAL_DASH[groupId]; + setPaint(map, coreId, "line-dasharray", dash); + } + if (patch.widthMultiplier !== undefined) { + const visuals = DRAINAGE_LAYER_VISUALS[groupId]; + setPaint(map, coreId, "line-width", scale(visuals.width, patch.widthMultiplier)); + setPaint(map, `${prefix}-casing`, "line-width", scale(visuals.casingWidth, patch.widthMultiplier)); + setPaint(map, `${prefix}-hover-outline`, "line-width", scale(visuals.hoverOutlineWidth, patch.widthMultiplier)); + setPaint(map, `${prefix}-hover`, "line-width", scale(visuals.hoverWidth, patch.widthMultiplier)); + for (const id of [`${prefix}-selected-outer`, `${prefix}-selected-outline`, `${prefix}-selected-separator`]) { + setPaint(map, id, "line-gap-width", scale(visuals.selectedWidth, patch.widthMultiplier)); + } + } +} + +function resetLineStyle(map: MapLibreMap, groupId: "conduits" | "orifices" | "pumps") { + const prefix = LINE_PREFIX[groupId]; + const coreId = groupId === "conduits" ? "pipes-flow" : groupId; + const visuals = DRAINAGE_LAYER_VISUALS[groupId]; + const colors = { + conduits: MAP_STYLE_TOKENS.asset.conduit, + orifices: MAP_STYLE_TOKENS.asset.orifice, + pumps: MAP_STYLE_TOKENS.asset.pump + } as const; + setPaint(map, coreId, "line-color", stateColor(colors[groupId])); + setPaint(map, coreId, "line-opacity", MAP_STYLE_TOKENS.opacity.core); + setPaint(map, coreId, "line-dasharray", ORIGINAL_DASH[groupId]); + setPaint(map, coreId, "line-width", visuals.width); + setPaint(map, `${prefix}-casing`, "line-width", visuals.casingWidth); + setPaint(map, `${prefix}-hover-outline`, "line-width", visuals.hoverOutlineWidth); + setPaint(map, `${prefix}-hover`, "line-width", visuals.hoverWidth); + for (const id of [`${prefix}-selected-outer`, `${prefix}-selected-outline`, `${prefix}-selected-separator`]) { + setPaint(map, id, "line-gap-width", visuals.selectedWidth); + } +} + +function applyPointStyle(map: MapLibreMap, groupId: "junctions" | "outfalls", patch: PointLayerGroupStylePatch) { + const coreId = groupId; + if (patch.fillColor !== undefined) setPaint(map, coreId, "circle-color", stateColor(patch.fillColor)); + if (patch.strokeColor !== undefined) setPaint(map, coreId, "circle-stroke-color", stateColor(patch.strokeColor)); + if (patch.opacity !== undefined) setPaint(map, coreId, "circle-opacity", patch.opacity); + if (patch.radiusMultiplier !== undefined) { + const visual = DRAINAGE_LAYER_VISUALS[groupId]; + setPaint(map, coreId, "circle-radius", scale(visual.radius, patch.radiusMultiplier)); + setPaint(map, `${groupId}-halo`, "circle-radius", scale(visual.haloRadius, patch.radiusMultiplier)); + setPaint(map, `${groupId}-hover`, "circle-radius", scale(visual.hoverRadius, patch.radiusMultiplier)); + setPaint(map, `${groupId}-selected`, "circle-radius", scale(visual.selectedRadius, patch.radiusMultiplier)); + setPaint(map, `${groupId}-selected-outer`, "circle-radius", scale(visual.selectedRadius, patch.radiusMultiplier)); + } + if (patch.strokeMultiplier !== undefined) { + setPaint(map, coreId, "circle-stroke-width", 2 * patch.strokeMultiplier); + } +} + +function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "outfalls") { + const visual = DRAINAGE_LAYER_VISUALS[groupId]; + const colors = { + junctions: MAP_STYLE_TOKENS.asset.junction, + outfalls: MAP_STYLE_TOKENS.asset.outfall + } as const; + setPaint(map, groupId, "circle-color", groupId === "outfalls" ? MAP_STYLE_TOKENS.canvas.casing : stateColor(colors[groupId])); + setPaint(map, groupId, "circle-stroke-color", groupId === "outfalls" ? stateColor(colors[groupId]) : undefined); + setPaint(map, groupId, "circle-opacity", groupId === "outfalls" ? 1 : MAP_STYLE_TOKENS.opacity.core); + setPaint(map, groupId, "circle-radius", visual.radius); + setPaint(map, `${groupId}-halo`, "circle-radius", visual.haloRadius); + setPaint(map, `${groupId}-hover`, "circle-radius", visual.hoverRadius); + setPaint(map, `${groupId}-selected`, "circle-radius", visual.selectedRadius); + setPaint(map, `${groupId}-selected-outer`, "circle-radius", visual.selectedRadius); + setPaint(map, groupId, "circle-stroke-width", groupId === "outfalls" ? 2 : undefined); +} + +function applyScadaStyle(map: MapLibreMap, patch: ScadaLayerGroupStylePatch) { + if (patch.iconMultiplier !== undefined) setLayout(map, "scada-symbol", "icon-size", 0.75 * patch.iconMultiplier); + if (patch.opacity !== undefined) { + setPaint(map, "scada-symbol", "icon-opacity", patch.opacity); + setPaint(map, "scada-fallback", "circle-opacity", patch.opacity); + } + if (patch.fillColor !== undefined) setPaint(map, "scada-fallback", "circle-color", patch.fillColor); + if (patch.strokeColor !== undefined) setPaint(map, "scada-fallback", "circle-stroke-color", patch.strokeColor); + if (patch.radiusMultiplier !== undefined) setPaint(map, "scada-fallback", "circle-radius", scale(["interpolate", ["linear"], ["zoom"], 12, 5, 20, 8], patch.radiusMultiplier)); + if (patch.strokeMultiplier !== undefined) setPaint(map, "scada-fallback", "circle-stroke-width", 2 * patch.strokeMultiplier); +} + +function resetScadaStyle(map: MapLibreMap) { + setLayout(map, "scada-symbol", "icon-size", 0.75); + setPaint(map, "scada-symbol", "icon-opacity", 1); + setPaint(map, "scada-fallback", "circle-color", "#0E7490"); + setPaint(map, "scada-fallback", "circle-stroke-color", "#FFFFFF"); + setPaint(map, "scada-fallback", "circle-opacity", 1); + setPaint(map, "scada-fallback", "circle-radius", ["interpolate", ["linear"], ["zoom"], 12, 5, 20, 8]); + setPaint(map, "scada-fallback", "circle-stroke-width", 2); +} + +function scale(value: unknown, multiplier: number): unknown { + return ["*", value, multiplier]; +} + +function stateColor(color: string): unknown { + return [ + "case", + ["in", ["downcase", ["to-string", ["coalesce", ["get", "status"], ""]]], ["literal", ["closed", "off", "inactive"]]], + MAP_STYLE_TOKENS.state.inactive, + color + ]; +} + +function setPaint(map: MapLibreMap, layerId: string, property: string, value: unknown) { + if (map.getLayer(layerId)) map.setPaintProperty(layerId, property, (value ?? null) as never); +} + +function setLayout(map: MapLibreMap, layerId: string, property: string, value: unknown) { + if (map.getLayer(layerId)) map.setLayoutProperty(layerId, property, value as never); +} + +function inRange(value: unknown, min: number, max: number): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max; +} + +function isColor(value: unknown): value is string { + return typeof value === "string" && /^#[\da-f]{6}$/i.test(value); +} diff --git a/features/workbench/map/layers.test.ts b/features/workbench/map/layers.test.ts index fe896db..70e4594 100644 --- a/features/workbench/map/layers.test.ts +++ b/features/workbench/map/layers.test.ts @@ -82,7 +82,12 @@ describe("drainage network interaction layers", () => { expect(paint?.["line-color"]).toBe(MAP_STYLE_TOKENS.state.selected); expect(paint?.["line-gap-width"]).toBe(visuals.selectedWidth); expect(paint?.["line-width"]).toBe(visuals.selectedBorderWidth); - expect(paint?.["line-opacity"]).toEqual(["case", ["boolean", ["feature-state", "selected"], false], 1, 0]); + expect(paint?.["line-opacity"]).toEqual([ + "case", + ["any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "highlighted"], false]], + 1, + 0 + ]); }); const expectedPointOutlines = [ diff --git a/features/workbench/map/layers.ts b/features/workbench/map/layers.ts index c4475cd..a9602ac 100644 --- a/features/workbench/map/layers.ts +++ b/features/workbench/map/layers.ts @@ -9,12 +9,19 @@ export const INTERACTIVE_HIT_LAYER_IDS = [ const hoveredOpacity: ExpressionSpecification = [ "case", - ["all", ["boolean", ["feature-state", "hovered"], false], ["!", ["boolean", ["feature-state", "selected"], false]]], + [ + "all", + ["boolean", ["feature-state", "hovered"], false], + ["!", ["any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "highlighted"], false]]] + ], 1, 0 ]; const selectedOpacity: ExpressionSpecification = [ - "case", ["boolean", ["feature-state", "selected"], false], 1, 0 + "case", + ["any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "highlighted"], false]], + 1, + 0 ]; const stateColor = (sourceColor: string): ExpressionSpecification => [ "case", diff --git a/features/workbench/map/map-control-config.test.ts b/features/workbench/map/map-control-config.test.ts index 8c37d30..04454da 100644 --- a/features/workbench/map/map-control-config.test.ts +++ b/features/workbench/map/map-control-config.test.ts @@ -8,7 +8,7 @@ import { } from "./map-control-config"; describe("workbench source layer controls", () => { - it("creates one business control for each Lingang source", () => { + it("creates one business control for each GeoServer source", () => { const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY); expect(items.map((item) => item.id)).toEqual([ @@ -20,12 +20,16 @@ describe("workbench source layer controls", () => { "scada" ]); expect(items.slice(0, 5).map((item) => item.description)).toEqual([ - "lingang:geo_conduits_mat", - "lingang:geo_junctions_mat", - "lingang:geo_orifices_mat", - "lingang:geo_outfalls_mat", - "lingang:geo_pumps_mat" + "wenzhou:geo_conduits_mat", + "wenzhou:geo_junctions_mat", + "wenzhou:geo_orifices_mat", + "wenzhou:geo_outfalls_mat", + "wenzhou:geo_pumps_mat" ]); + expect(items.at(-1)).toMatchObject({ + label: "综合监测点", + description: "wenzhou:geo_scadas_mat" + }); }); it("finds every rendered layer that uses a source", () => { diff --git a/features/workbench/map/map-control-config.ts b/features/workbench/map/map-control-config.ts index 83207eb..184b6d8 100644 --- a/features/workbench/map/map-control-config.ts +++ b/features/workbench/map/map-control-config.ts @@ -2,6 +2,7 @@ import type { Map as MapLibreMap } from "maplibre-gl"; import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control"; import type { MapLayerControlItem } from "@/features/map/core/components/layer-control"; import type { MapLegendItem } from "@/features/map/core/components/legend"; +import { GEOSERVER_WORKSPACE } from "../../../lib/config"; import { MAP_STYLE_TOKENS } from "./map-colors"; import { SOURCE_LAYERS, @@ -33,12 +34,12 @@ export const INITIAL_LAYER_VISIBILITY: Record = { }; const SOURCE_CONTROL_LABELS: Record = { - conduits: { label: "管渠", description: `lingang:${SOURCE_LAYERS.conduits}` }, - junctions: { label: "检查井", description: `lingang:${SOURCE_LAYERS.junctions}` }, - orifices: { label: "孔口", description: `lingang:${SOURCE_LAYERS.orifices}` }, - outfalls: { label: "排放口", description: `lingang:${SOURCE_LAYERS.outfalls}` }, - pumps: { label: "泵", description: `lingang:${SOURCE_LAYERS.pumps}` }, - scada: { label: "SCADA 测点", description: `lingang:${SOURCE_LAYERS.scada}` } + conduits: { label: "管渠", description: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS.conduits}` }, + junctions: { label: "检查井", description: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS.junctions}` }, + orifices: { label: "孔口", description: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS.orifices}` }, + outfalls: { label: "排放口", description: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS.outfalls}` }, + pumps: { label: "泵", description: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS.pumps}` }, + scada: { label: "综合监测点", description: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS.scada}` } }; export const MAP_LEGEND_ITEMS: MapLegendItem[] = [ @@ -47,9 +48,7 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [ { id: "orifice", label: "孔口", color: MAP_STYLE_TOKENS.asset.orifice, shape: "dashed-line" }, { id: "pump", label: "泵", color: MAP_STYLE_TOKENS.asset.pump, shape: "dash-dot-line" }, { id: "outfall", label: "排放口", color: MAP_STYLE_TOKENS.asset.outfall, shape: "ring" }, - { id: "scada-quality", label: "水质仪", color: "#0F766E", imageSrc: "/icons/scada-water-quality.svg" }, - { id: "scada-level", label: "雷达液位计", color: "#2563EB", imageSrc: "/icons/scada-radar-level.svg" }, - { id: "scada-flow", label: "超声流量计", color: "#EA580C", imageSrc: "/icons/scada-ultrasonic-flow.svg" }, + { id: "scada-integrated", label: "综合监测点", color: "#0E7490", imageSrc: "/icons/scada-integrated-monitoring.svg" }, { id: "inactive", label: "停用 / 关闭", color: MAP_STYLE_TOKENS.state.inactive, shape: "line" }, { id: "impact-area", label: "风险影响范围", color: MAP_STYLE_TOKENS.state.risk, shape: "square" }, { id: "incident", label: "事故 / 爆管", color: MAP_STYLE_TOKENS.state.incident, shape: "dot" }, diff --git a/features/workbench/map/map-feature-query.test.ts b/features/workbench/map/map-feature-query.test.ts new file mode 100644 index 0000000..13272fc --- /dev/null +++ b/features/workbench/map/map-feature-query.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_CONDUIT_FEATURES, + MAP_FEATURE_ID_FIELDS, + createMapFeatureWfsUrl, + escapeCqlLiteral, + normalizeMapFeatureCollection, + parseMapFeatureQuery +} from "./map-feature-query"; + +describe("map feature WFS query", () => { + it("maps source identifiers to authoritative feature fields", () => { + expect(MAP_FEATURE_ID_FIELDS).toMatchObject({ conduits: "id", junctions: "id", scada: "sensor_id" }); + expect(createMapFeatureWfsUrl({ sourceId: "scada", featureIds: ["S-1"] }).searchParams.get("cql_filter")) + .toBe("sensor_id IN ('S-1')"); + }); + + it("escapes CQL literals and uses materialized WFS layers", () => { + expect(escapeCqlLiteral("a'b")).toBe("'a''b'"); + const url = createMapFeatureWfsUrl({ sourceId: "conduits", featureIds: ["a'b"] }); + expect(url.searchParams.get("typeNames")).toContain("geo_conduits_mat"); + expect(url.searchParams.get("srsName")).toBe("EPSG:4326"); + expect(url.searchParams.get("cql_filter")).toBe("id IN ('a''b')"); + }); + + it("limits ids and permits full-network requests only for conduits", () => { + expect(parseMapFeatureQuery({ sourceId: "conduits" })).toEqual({ ok: true, value: { sourceId: "conduits" } }); + expect(createMapFeatureWfsUrl({ sourceId: "conduits" }).searchParams.get("count")).toBe(String(MAX_CONDUIT_FEATURES)); + expect(parseMapFeatureQuery({ sourceId: "junctions" })).toMatchObject({ ok: false, status: 400 }); + expect(parseMapFeatureQuery({ sourceId: "unknown", featureIds: ["1"] })).toMatchObject({ ok: false, status: 422 }); + expect(parseMapFeatureQuery({ sourceId: "conduits", featureIds: Array.from({ length: 101 }, (_, index) => String(index)) })) + .toMatchObject({ ok: false, status: 400 }); + expect(parseMapFeatureQuery({ sourceId: "conduits", featureIds: ["x".repeat(129)] })) + .toMatchObject({ ok: false, status: 400 }); + }); + + it("preserves an empty result and rejects malformed upstream data", () => { + expect(normalizeMapFeatureCollection({ type: "FeatureCollection", features: [] }, 100)).toEqual({ type: "FeatureCollection", features: [] }); + expect(normalizeMapFeatureCollection({ features: [] }, 100)).toBeNull(); + }); +}); diff --git a/features/workbench/map/map-feature-query.ts b/features/workbench/map/map-feature-query.ts new file mode 100644 index 0000000..c63fae5 --- /dev/null +++ b/features/workbench/map/map-feature-query.ts @@ -0,0 +1,88 @@ +import type { FeatureCollection } from "geojson"; +import { GEOSERVER_WORKSPACE, MAP_URL } from "../../../lib/config"; +import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources"; + +export const MAP_FEATURE_ID_FIELDS: Record = { + conduits: "id", + junctions: "id", + orifices: "id", + outfalls: "id", + pumps: "id", + scada: "sensor_id" +}; + +export const MAX_MAP_FEATURE_IDS = 100; +export const MAX_MAP_FEATURE_ID_LENGTH = 128; +export const MAX_CONDUIT_FEATURES = 5000; + +export type MapFeatureQuery = { + sourceId: WaterNetworkSourceId; + featureIds?: string[]; +}; + +export type MapFeatureQueryValidation = + | { ok: true; value: MapFeatureQuery } + | { ok: false; code: "INVALID_REQUEST" | "UNSUPPORTED_SOURCE"; status: 400 | 422 }; + +export function parseMapFeatureQuery(body: unknown): MapFeatureQueryValidation { + if (!body || typeof body !== "object") { + return { ok: false, code: "INVALID_REQUEST", status: 400 }; + } + + const { sourceId, featureIds } = body as { sourceId?: unknown; featureIds?: unknown }; + if (typeof sourceId !== "string" || !(sourceId in SOURCE_LAYERS)) { + return { ok: false, code: "UNSUPPORTED_SOURCE", status: 422 }; + } + + if (featureIds === undefined) { + return sourceId === "conduits" + ? { ok: true, value: { sourceId } } + : { ok: false, code: "INVALID_REQUEST", status: 400 }; + } + + if ( + !Array.isArray(featureIds) || + featureIds.length < 1 || + featureIds.length > MAX_MAP_FEATURE_IDS || + !featureIds.every( + (id) => typeof id === "string" && id.trim().length > 0 && id.trim().length <= MAX_MAP_FEATURE_ID_LENGTH + ) + ) { + return { ok: false, code: "INVALID_REQUEST", status: 400 }; + } + + return { + ok: true, + value: { sourceId: sourceId as WaterNetworkSourceId, featureIds: featureIds.map((id) => id.trim()) } + }; +} + +export function escapeCqlLiteral(value: string) { + return `'${value.replaceAll("'", "''")}'`; +} + +export function createMapFeatureWfsUrl(query: MapFeatureQuery) { + const idField = MAP_FEATURE_ID_FIELDS[query.sourceId]; + const params = new URLSearchParams({ + service: "WFS", + version: "2.0.0", + request: "GetFeature", + typeNames: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS[query.sourceId]}`, + outputFormat: "application/json", + srsName: "EPSG:4326", + count: String(query.featureIds ? MAX_MAP_FEATURE_IDS : MAX_CONDUIT_FEATURES) + }); + + if (query.featureIds) { + params.set("cql_filter", `${idField} IN (${query.featureIds.map(escapeCqlLiteral).join(",")})`); + } + + return new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows?${params.toString()}`); +} + +export function normalizeMapFeatureCollection(value: unknown, maxFeatures: number): FeatureCollection | null { + if (!value || typeof value !== "object") return null; + const collection = value as FeatureCollection; + if (collection.type !== "FeatureCollection" || !Array.isArray(collection.features)) return null; + return { type: "FeatureCollection", features: collection.features.slice(0, maxFeatures) }; +} diff --git a/features/workbench/map/scada-analysis.test.ts b/features/workbench/map/scada-analysis.test.ts new file mode 100644 index 0000000..8736253 --- /dev/null +++ b/features/workbench/map/scada-analysis.test.ts @@ -0,0 +1,90 @@ +import type { FeatureCollection } from "geojson"; +import { describe, expect, it } from "vitest"; +import { + SCADA_ANALYSIS_LAYER_IDS, + SCADA_ANALYSIS_LEVEL_COLORS, + createScadaAnalysisCollection, + parseScadaAnalysisItems, + scadaAnalysisLayers +} from "./scada-analysis"; + +describe("SCADA analysis overlay", () => { + it("uses distinct semantic rings, status indicators, and legible priority labels", () => { + const casing = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.casing); + const level = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.level); + const indicator = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.indicator); + const label = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.label); + expect(SCADA_ANALYSIS_LEVEL_COLORS).toEqual({ + high: "#DC2626", + medium: "#F59E0B", + low: "#16A34A", + unrated: "#7C3AED" + }); + expect(casing).toMatchObject({ + type: "circle", + paint: { "circle-radius": 16, "circle-stroke-color": "#FFFFFF", "circle-stroke-width": 7 } + }); + expect(JSON.stringify(level)).toContain(SCADA_ANALYSIS_LEVEL_COLORS.high); + expect(JSON.stringify(level)).toContain(SCADA_ANALYSIS_LEVEL_COLORS.medium); + expect(JSON.stringify(level)).toContain(SCADA_ANALYSIS_LEVEL_COLORS.low); + expect(JSON.stringify(level)).toContain(SCADA_ANALYSIS_LEVEL_COLORS.unrated); + expect(level).toMatchObject({ paint: { "circle-stroke-width": 4 } }); + expect(indicator).toMatchObject({ + type: "circle", + paint: { + "circle-radius": 5, + "circle-translate": [11, -11], + "circle-stroke-color": "#FFFFFF", + "circle-stroke-width": 2 + } + }); + expect(label).toMatchObject({ + type: "symbol", + layout: { + "symbol-sort-key": ["get", "sort_priority"], + "text-field": ["get", "analysis_label"], + "text-size": 15, + "text-font": ["Noto Sans Regular"], + "text-allow-overlap": false + }, + paint: { "text-halo-color": "#FFFFFF", "text-halo-width": 3, "text-halo-blur": 0.5 } + }); + expect(JSON.stringify(label)).toContain("#991B1B"); + expect(JSON.stringify(label)).toContain("#92400E"); + expect(JSON.stringify(label)).toContain("#166534"); + }); + + it("validates one to one hundred unique IDs and fixed levels", () => { + expect(parseScadaAnalysisItems([{ sensor_id: " MP01 ", level: "high" }])).toEqual([ + { sensor_id: "MP01", level: "high" } + ]); + expect(parseScadaAnalysisItems([])).toBeNull(); + expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "high" }, { sensor_id: "MP01", level: "low" }])).toBeNull(); + expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "critical" }])).toBeNull(); + }); + + it("uses trusted WFS sensor IDs for localized labels and reports partial misses", () => { + const result = createScadaAnalysisCollection(scadaCollection, [ + { sensor_id: "MP01", level: "high" }, + { sensor_id: "MP02", level: "medium" }, + { sensor_id: "MP404", level: "low" } + ]); + expect(result.result).toEqual({ + rendered_ids: ["MP01", "MP02"], + missing_ids: ["MP404"], + level_counts: { high: 1, medium: 1, low: 0, unrated: 0 } + }); + expect(result.collection.features.map((feature) => feature.properties)).toEqual([ + expect.objectContaining({ sensor_id: "MP01", analysis_label: "MP01 · 高", sort_priority: 0 }), + expect.objectContaining({ sensor_id: "MP02", analysis_label: "MP02 · 中", sort_priority: 1 }) + ]); + }); +}); + +const scadaCollection: FeatureCollection = { + type: "FeatureCollection", + features: [ + { type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { sensor_id: "MP01" } }, + { type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { sensor_id: "MP02" } } + ] +}; diff --git a/features/workbench/map/scada-analysis.ts b/features/workbench/map/scada-analysis.ts new file mode 100644 index 0000000..e9b36f5 --- /dev/null +++ b/features/workbench/map/scada-analysis.ts @@ -0,0 +1,214 @@ +import type { Feature, FeatureCollection, Point } from "geojson"; +import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl"; + +export const SCADA_ANALYSIS_SOURCE_ID = "agent-scada-analysis"; +export const SCADA_ANALYSIS_LAYER_IDS = { + casing: "agent-scada-analysis-casing", + level: "agent-scada-analysis-level", + indicator: "agent-scada-analysis-indicator", + label: "agent-scada-analysis-label" +} as const; + +export const SCADA_ANALYSIS_LEVELS = ["high", "medium", "low", "unrated"] as const; +export type ScadaAnalysisLevel = (typeof SCADA_ANALYSIS_LEVELS)[number]; +export type ScadaAnalysisItem = { sensor_id: string; level: ScadaAnalysisLevel }; +export type ScadaAnalysisLevelCounts = Record; +export type ScadaAnalysisRenderResult = { + rendered_ids: string[]; + missing_ids: string[]; + level_counts: ScadaAnalysisLevelCounts; + fitted: true; +}; + +export const SCADA_ANALYSIS_LEVEL_COLORS: Record = { + high: "#DC2626", + medium: "#F59E0B", + low: "#16A34A", + unrated: "#7C3AED" +}; + +const SCADA_ANALYSIS_LABEL_COLORS: Record = { + high: "#991B1B", + medium: "#92400E", + low: "#166534", + unrated: "#5B21B6" +}; + +const levelColorExpression = ( + colors: Record +): ExpressionSpecification => [ + "match", + ["get", "analysis_level"], + "high", colors.high, + "medium", colors.medium, + "low", colors.low, + colors.unrated +]; + +export const SCADA_ANALYSIS_LEVEL_LABELS: Record = { + high: "高", + medium: "中", + low: "低", + unrated: "未分级" +}; + +const SCADA_ANALYSIS_SORT_PRIORITY: Record = { + high: 0, + medium: 1, + low: 2, + unrated: 3 +}; + +type Layer = NonNullable[number]; + +export const scadaAnalysisLayers: Layer[] = [ + { + id: SCADA_ANALYSIS_LAYER_IDS.casing, + type: "circle", + source: SCADA_ANALYSIS_SOURCE_ID, + paint: { + "circle-radius": 16, + "circle-color": "rgba(255,255,255,0)", + "circle-stroke-color": "#FFFFFF", + "circle-stroke-width": 7 + } + }, + { + id: SCADA_ANALYSIS_LAYER_IDS.level, + type: "circle", + source: SCADA_ANALYSIS_SOURCE_ID, + paint: { + "circle-radius": 16, + "circle-color": "rgba(255,255,255,0)", + "circle-stroke-color": levelColorExpression(SCADA_ANALYSIS_LEVEL_COLORS), + "circle-stroke-width": 4 + } + }, + { + id: SCADA_ANALYSIS_LAYER_IDS.indicator, + type: "circle", + source: SCADA_ANALYSIS_SOURCE_ID, + paint: { + "circle-radius": 5, + "circle-translate": [11, -11], + "circle-color": levelColorExpression(SCADA_ANALYSIS_LEVEL_COLORS), + "circle-stroke-color": "#FFFFFF", + "circle-stroke-width": 2 + } + }, + { + id: SCADA_ANALYSIS_LAYER_IDS.label, + type: "symbol", + source: SCADA_ANALYSIS_SOURCE_ID, + layout: { + "symbol-sort-key": ["get", "sort_priority"], + "text-field": ["get", "analysis_label"], + "text-size": 15, + "text-font": ["Noto Sans Regular"], + "text-letter-spacing": 0.015, + "text-offset": [0, -2.05], + "text-anchor": "bottom", + "text-allow-overlap": false, + "text-ignore-placement": false + }, + paint: { + "text-color": levelColorExpression(SCADA_ANALYSIS_LABEL_COLORS), + "text-halo-color": "#FFFFFF", + "text-halo-width": 3, + "text-halo-blur": 0.5 + } + } +]; + +export function ensureScadaAnalysisLayers( + map: Pick +): void { + scadaAnalysisLayers.forEach((layer, index) => { + if (map.getLayer(layer.id)) return; + const beforeId = scadaAnalysisLayers + .slice(index + 1) + .map((candidate) => candidate.id) + .find((candidateId) => Boolean(map.getLayer(candidateId))); + map.addLayer(layer, beforeId); + }); +} + +export function createEmptyScadaAnalysisCollection(): FeatureCollection { + return { type: "FeatureCollection", features: [] }; +} + +export function parseScadaAnalysisItems(value: unknown): ScadaAnalysisItem[] | null { + if (!Array.isArray(value) || value.length < 1 || value.length > 100) return null; + const items: ScadaAnalysisItem[] = []; + const seen = new Set(); + for (const entry of value) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; + const keys = Object.keys(entry); + if (keys.length !== 2 || !keys.includes("sensor_id") || !keys.includes("level")) return null; + const { sensor_id: rawSensorId, level } = entry as Record; + const sensorId = typeof rawSensorId === "string" ? rawSensorId.trim() : ""; + if ( + !sensorId || + sensorId.length > 128 || + !SCADA_ANALYSIS_LEVELS.includes(level as ScadaAnalysisLevel) || + seen.has(sensorId) + ) return null; + seen.add(sensorId); + items.push({ sensor_id: sensorId, level: level as ScadaAnalysisLevel }); + } + return items; +} + +export function createScadaAnalysisCollection( + collection: FeatureCollection, + items: ScadaAnalysisItem[] +): { collection: FeatureCollection; result: Omit } { + const featuresBySensorId = new Map>(); + collection.features.forEach((feature) => { + const sensorId = typeof feature.properties?.sensor_id === "string" + ? feature.properties.sensor_id.trim() + : ""; + if (sensorId && feature.geometry?.type === "Point" && !featuresBySensorId.has(sensorId)) { + featuresBySensorId.set(sensorId, feature as Feature); + } + }); + + const levelCounts = createEmptyScadaAnalysisLevelCounts(); + const renderedIds: string[] = []; + const missingIds: string[] = []; + const features: Array> = []; + items.forEach((item) => { + const feature = featuresBySensorId.get(item.sensor_id); + if (!feature) { + missingIds.push(item.sensor_id); + return; + } + const trustedSensorId = String(feature.properties?.sensor_id ?? "").trim(); + if (!trustedSensorId) { + missingIds.push(item.sensor_id); + return; + } + renderedIds.push(trustedSensorId); + levelCounts[item.level] += 1; + features.push({ + ...feature, + properties: { + ...feature.properties, + sensor_id: trustedSensorId, + analysis_level: item.level, + analysis_level_label: SCADA_ANALYSIS_LEVEL_LABELS[item.level], + analysis_label: `${trustedSensorId} · ${SCADA_ANALYSIS_LEVEL_LABELS[item.level]}`, + sort_priority: SCADA_ANALYSIS_SORT_PRIORITY[item.level] + } + }); + }); + + return { + collection: { type: "FeatureCollection", features }, + result: { rendered_ids: renderedIds, missing_ids: missingIds, level_counts: levelCounts } + }; +} + +export function createEmptyScadaAnalysisLevelCounts(): ScadaAnalysisLevelCounts { + return { high: 0, medium: 0, low: 0, unrated: 0 }; +} diff --git a/features/workbench/map/scada.test.ts b/features/workbench/map/scada.test.ts index 4f2ac1c..64589b5 100644 --- a/features/workbench/map/scada.test.ts +++ b/features/workbench/map/scada.test.ts @@ -12,13 +12,20 @@ import { import { waterNetworkLayers } from "./layers"; describe("SCADA map presentation", () => { - it("maps the three authoritative device types and falls back to unknown", () => { + it("maps the authoritative device types and falls back to unknown", () => { expect(SCADA_ICON_EXPRESSION).toEqual([ - "match", ["get", "kind"], - "water_quality", SCADA_IMAGE_IDS.waterQuality, - "radar_level", SCADA_IMAGE_IDS.radarLevel, - "ultrasonic_flow", SCADA_IMAGE_IDS.ultrasonicFlow, - SCADA_IMAGE_IDS.unknown + "case", + ["has", "kind"], + [ + "match", ["get", "kind"], + "water_quality", SCADA_IMAGE_IDS.waterQuality, + "radar_level", SCADA_IMAGE_IDS.radarLevel, + "ultrasonic_flow", SCADA_IMAGE_IDS.ultrasonicFlow, + "integrated_monitoring", SCADA_IMAGE_IDS.integratedMonitoring, + "综合监测点", SCADA_IMAGE_IDS.integratedMonitoring, + SCADA_IMAGE_IDS.integratedMonitoring + ], + SCADA_IMAGE_IDS.integratedMonitoring ]); }); @@ -50,10 +57,11 @@ describe("SCADA map presentation", () => { "circle-stroke-width": junctionSelectedPaint?.["circle-stroke-width"] } }); + expect(JSON.stringify((selectedLayer as { paint?: unknown })?.paint)).toContain("highlighted"); }); it("uses MapLibre-compatible PNG assets", () => { - expect(Object.values(SCADA_MAP_ICON_PATHS)).toHaveLength(4); + expect(Object.values(SCADA_MAP_ICON_PATHS)).toHaveLength(5); Object.values(SCADA_MAP_ICON_PATHS).forEach((path) => expect(path).toMatch(/\.png$/)); }); @@ -62,8 +70,8 @@ describe("SCADA map presentation", () => { const map = createImageMap(() => Promise.resolve({ data: image })); await expect(registerScadaImages(map)).resolves.toEqual({ status: "ready", failedImageIds: [] }); - expect(map.loadImage).toHaveBeenCalledTimes(4); - expect(map.addImage).toHaveBeenCalledTimes(4); + expect(map.loadImage).toHaveBeenCalledTimes(5); + expect(map.addImage).toHaveBeenCalledTimes(5); expect(map.addImage).toHaveBeenCalledWith(expect.any(String), image, { pixelRatio: 2 }); }); diff --git a/features/workbench/map/scada.ts b/features/workbench/map/scada.ts index 66e3aa7..af7bf64 100644 --- a/features/workbench/map/scada.ts +++ b/features/workbench/map/scada.ts @@ -2,13 +2,14 @@ import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } import { MAP_STYLE_TOKENS } from "./map-colors"; export const SCADA_SOURCE_ID = "scada"; -export const SCADA_SOURCE_LAYER = "scada_points"; +export const SCADA_SOURCE_LAYER = "geo_scadas_mat"; export const SCADA_HIT_LAYER_ID = "scada-hit"; export const SCADA_ICON_PATHS = { waterQuality: "/icons/scada-water-quality.svg", radarLevel: "/icons/scada-radar-level.svg", ultrasonicFlow: "/icons/scada-ultrasonic-flow.svg", + integratedMonitoring: "/icons/scada-integrated-monitoring.svg", unknown: "/icons/scada-unknown.svg" } as const; @@ -16,6 +17,7 @@ export const SCADA_MAP_ICON_PATHS = { waterQuality: "/icons/scada-water-quality.png", radarLevel: "/icons/scada-radar-level.png", ultrasonicFlow: "/icons/scada-ultrasonic-flow.png", + integratedMonitoring: "/icons/scada-integrated-monitoring.png", unknown: "/icons/scada-unknown.png" } as const; @@ -23,19 +25,34 @@ export const SCADA_IMAGE_IDS = { waterQuality: "scada-water-quality", radarLevel: "scada-radar-level", ultrasonicFlow: "scada-ultrasonic-flow", + integratedMonitoring: "scada-integrated-monitoring", unknown: "scada-unknown" } as const; -export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [ +const SCADA_KIND_ICON_EXPRESSION: ExpressionSpecification = [ "match", ["get", "kind"], "water_quality", SCADA_IMAGE_IDS.waterQuality, "radar_level", SCADA_IMAGE_IDS.radarLevel, "ultrasonic_flow", SCADA_IMAGE_IDS.ultrasonicFlow, - SCADA_IMAGE_IDS.unknown + "integrated_monitoring", SCADA_IMAGE_IDS.integratedMonitoring, + "综合监测点", SCADA_IMAGE_IDS.integratedMonitoring, + SCADA_IMAGE_IDS.integratedMonitoring +]; + +export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [ + "case", + ["has", "kind"], + SCADA_KIND_ICON_EXPRESSION, + SCADA_IMAGE_IDS.integratedMonitoring ]; const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification => [ - "case", ["boolean", ["feature-state", state], false], 1, 0 + "case", + state === "selected" + ? ["any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "highlighted"], false]] + : ["boolean", ["feature-state", state], false], + 1, + 0 ]; const SCADA_ICON_SIZE = 0.75; diff --git a/features/workbench/map/sources.test.ts b/features/workbench/map/sources.test.ts index c5f451d..03002af 100644 --- a/features/workbench/map/sources.test.ts +++ b/features/workbench/map/sources.test.ts @@ -3,14 +3,20 @@ import { describe, expect, it } from "vitest"; import { createBaseStyle, createWaterNetworkSources, SOURCE_LAYERS } from "./sources"; describe("createWaterNetworkSources", () => { - it("loads SCADA tiles from the lingang:scada_points GeoServer layer", () => { + it("loads SCADA tiles from the configured GeoServer workspace", () => { const scada = createWaterNetworkSources().scada; - expect(SOURCE_LAYERS.scada).toBe("scada_points"); + expect(SOURCE_LAYERS.scada).toBe("geo_scadas_mat"); expect(scada.tiles).toEqual([ - expect.stringContaining("/lingang:scada_points/point/WebMercatorQuad/") + expect.stringContaining("/wenzhou:geo_scadas_mat/point/WebMercatorQuad/") ]); - expect(scada.promoteId).toBe("scada_id"); + expect(scada.bounds).toEqual([ + 120.63483136963328, + 27.957404243937606, + 120.76346113516635, + 28.02399422971424 + ]); + expect(scada.promoteId).toBe("sensor_id"); }); }); diff --git a/features/workbench/map/sources.ts b/features/workbench/map/sources.ts index 0ca63c7..5c6ca8c 100644 --- a/features/workbench/map/sources.ts +++ b/features/workbench/map/sources.ts @@ -1,23 +1,30 @@ import type { VectorSourceSpecification, StyleSpecification } from "maplibre-gl"; -import { MAP_URL } from "../../../lib/config"; +import { GEOSERVER_WORKSPACE, MAP_URL } from "../../../lib/config"; import { MAP_STYLE_TOKENS } from "./map-colors"; export const WATER_NETWORK_GLOBAL_VIEW = { bbox3857: [ - 13551482, - 3612812.75, - 13577696, - 3632065.75 + 13429008, + 3243604.5, + 13443327, + 3251999.25 ] } as const; +const WATER_NETWORK_BOUNDS: [number, number, number, number] = [ + 120.63483136963328, + 27.957404243937606, + 120.76346113516635, + 28.02399422971424 +]; + export const SOURCE_LAYERS = { conduits: "geo_conduits_mat", junctions: "geo_junctions_mat", orifices: "geo_orifices_mat", outfalls: "geo_outfalls_mat", pumps: "geo_pumps_mat", - scada: "scada_points" + scada: "geo_scadas_mat" } as const; export const WATER_NETWORK_SOURCE_IDS = [ @@ -35,41 +42,41 @@ const GEOSERVER_WMTS_ROOT = `${MAP_URL}/gwc/service/wmts/rest`; export function createWaterNetworkSources() { return { - conduits: createLingangVectorSource( + conduits: createGeoServerVectorSource( SOURCE_LAYERS.conduits, "line", - [121.7350340307058, 30.84636502815, 121.97051839928491, 30.994737681416165] + WATER_NETWORK_BOUNDS ), - junctions: createLingangVectorSource( + junctions: createGeoServerVectorSource( SOURCE_LAYERS.junctions, "point", - [121.7350340307058, 30.84636502815, 121.97051839928491, 30.994737681416165] + WATER_NETWORK_BOUNDS ), - orifices: createLingangVectorSource( + orifices: createGeoServerVectorSource( SOURCE_LAYERS.orifices, "line", - [121.88611269518903, 30.919491577239235, 121.9347115520599, 30.952564332104696] + WATER_NETWORK_BOUNDS ), - outfalls: createLingangVectorSource( + outfalls: createGeoServerVectorSource( SOURCE_LAYERS.outfalls, "point", - [121.77154156385242, 30.85723317314842, 121.77231411499677, 30.859452151585806] + WATER_NETWORK_BOUNDS ), - pumps: createLingangVectorSource( + pumps: createGeoServerVectorSource( SOURCE_LAYERS.pumps, "line", - [121.73585149761436, 30.861759757577705, 121.9498481645973, 30.983213202338085] + WATER_NETWORK_BOUNDS ), - scada: createLingangVectorSource( + scada: createGeoServerVectorSource( SOURCE_LAYERS.scada, "point", - [121.7350340307058, 30.84636502815, 121.97051839928491, 30.994737681416165], - "scada_id" + WATER_NETWORK_BOUNDS, + "sensor_id" ) }; } -function createLingangVectorSource( +function createGeoServerVectorSource( sourceLayer: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS], style: "line" | "point", bounds: [number, number, number, number], @@ -79,7 +86,7 @@ function createLingangVectorSource( type: "vector", promoteId, tiles: [ - `${GEOSERVER_WMTS_ROOT}/lingang:${sourceLayer}/${style}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile` + `${GEOSERVER_WMTS_ROOT}/${GEOSERVER_WORKSPACE}:${sourceLayer}/${style}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile` ], bounds, minzoom: 0, diff --git a/features/workbench/map/value-label.test.ts b/features/workbench/map/value-label.test.ts new file mode 100644 index 0000000..7ca6331 --- /dev/null +++ b/features/workbench/map/value-label.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { createValueLabelCollection, formatFeatureValue, getNumericFeatureProperties } from "./value-label"; + +describe("workbench value labels", () => { + const feature = { + type: "Feature" as const, + geometry: { type: "Point" as const, coordinates: [120, 28] }, + properties: { diameter: 600.125, name: "P-1", invalid: Number.NaN, depth: 2 } + }; + + it("lists finite numeric properties only", () => { + expect(getNumericFeatureProperties(feature.properties)).toEqual([["depth", 2], ["diameter", 600.125]]); + }); + + it("formats precision and unit with hard limits", () => { + expect(formatFeatureValue(12.3456, 2, "mm")).toBe("12.35 mm"); + expect(formatFeatureValue(12.3456, 8, "")).toBe("12.346"); + }); + + it("creates a single controlled label feature", () => { + expect(createValueLabelCollection(feature, "diameter", 1, "mm").features[0]?.properties?.label).toBe("600.1 mm"); + expect(() => createValueLabelCollection(feature, "name", 1)).toThrow("INVALID_VALUE_PROPERTY"); + }); +}); diff --git a/features/workbench/map/value-label.ts b/features/workbench/map/value-label.ts new file mode 100644 index 0000000..cb56bb1 --- /dev/null +++ b/features/workbench/map/value-label.ts @@ -0,0 +1,41 @@ +import type { Feature, FeatureCollection, Geometry } from "geojson"; + +export const VALUE_LABEL_SOURCE_ID = "workbench-value-label"; +export const VALUE_LABEL_LAYER_IDS = ["workbench-value-label-point", "workbench-value-label-line"] as const; + +export function getNumericFeatureProperties(properties: Record | null | undefined) { + return Object.entries(properties ?? {}) + .filter((entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1])) + .sort(([a], [b]) => a.localeCompare(b)); +} + +export function formatFeatureValue(value: number, precision: number, unit = "") { + const safePrecision = Math.min(3, Math.max(0, Math.trunc(precision))); + const safeUnit = unit.trim().slice(0, 16); + return `${value.toFixed(safePrecision)}${safeUnit ? ` ${safeUnit}` : ""}`; +} + +export function createValueLabelCollection( + feature: Feature, + property: string, + precision: number, + unit = "" +): FeatureCollection { + const value = feature.properties?.[property]; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error("INVALID_VALUE_PROPERTY"); + } + + return { + type: "FeatureCollection", + features: [{ + type: "Feature", + geometry: feature.geometry as Geometry, + properties: { label: formatFeatureValue(value, precision, unit) } + }] + }; +} + +export function createEmptyValueLabelCollection(): FeatureCollection { + return { type: "FeatureCollection", features: [] }; +} diff --git a/features/workbench/map/workbench-map-controller.test.ts b/features/workbench/map/workbench-map-controller.test.ts new file mode 100644 index 0000000..47e0560 --- /dev/null +++ b/features/workbench/map/workbench-map-controller.test.ts @@ -0,0 +1,174 @@ +import type { FeatureCollection } from "geojson"; +import type { Map as MapLibreMap } from "maplibre-gl"; +import { describe, expect, it, vi } from "vitest"; +import { WorkbenchMapController } from "./workbench-map-controller"; +import { SCADA_ANALYSIS_LAYER_IDS, SCADA_ANALYSIS_SOURCE_ID } from "./scada-analysis"; + +describe("WorkbenchMapController", () => { + it("locates points with zoom 18 and keeps selected state untouched", async () => { + const map = createMap(); + const controller = createController(map, pointCollection); + await controller.locateAndHighlight({ sourceId: "junctions", featureId: "J-1" }); + expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [120.7, 28], zoom: 18 })); + expect(map.setFeatureState).toHaveBeenCalledWith(expect.objectContaining({ source: "junctions", id: "J-1" }), { highlighted: true }); + controller.clearHighlight(); + expect(map.removeFeatureState).toHaveBeenCalledWith(expect.anything(), "highlighted"); + expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "selected"); + }); + + it("fits line bounds and uses responsive workbench padding", async () => { + const map = createMap(); + const padding = { top: 50, right: 420, bottom: 50, left: 320 }; + const controller = createController(map, lineCollection, padding); + await controller.locateAndHighlight({ sourceId: "conduits", featureId: "C-1" }); + expect(map.fitBounds).toHaveBeenCalledWith([[120, 28], [121, 29]], expect.objectContaining({ padding, maxZoom: 18 })); + }); + + it("does not clear the last valid highlight when a target is missing", async () => { + const map = createMap(); + let collection = pointCollection; + const controller = new WorkbenchMapController({ + getMap: () => map as unknown as MapLibreMap, + isReady: () => true, + getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), + fetchFeatures: async () => collection + }); + await controller.highlight({ sourceId: "junctions", featureId: "J-1" }); + collection = { type: "FeatureCollection", features: [] }; + await controller.highlight({ sourceId: "junctions", featureId: "missing" }); + expect(controller.getSnapshot().target).toEqual({ sourceId: "junctions", featureId: "J-1" }); + expect(controller.getSnapshot().errorCode).toBe("FEATURE_NOT_FOUND"); + expect(map.removeFeatureState).not.toHaveBeenCalled(); + }); + + it("renders one SCADA point atomically and zooms to level 18", async () => { + const source = { setData: vi.fn() }; + const map = createMap(source); + const controller = createController(map, scadaPointCollection); + const result = await controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }]); + expect(source.setData).toHaveBeenCalledWith(expect.objectContaining({ + features: [expect.objectContaining({ properties: expect.objectContaining({ analysis_label: "MP01 · 高" }) })] + })); + expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [120.7, 28], zoom: 18 })); + expect(result).toEqual({ + rendered_ids: ["MP01"], + missing_ids: [], + level_counts: { high: 1, medium: 0, low: 0, unrated: 0 }, + fitted: true + }); + }); + + it("restores missing SCADA analysis presentation layers before reporting success", async () => { + const source = { setData: vi.fn() }; + const map = createMap(source, false); + const controller = createController(map, scadaPointCollection); + + await controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }]); + + expect(map.addLayer).toHaveBeenCalledTimes(Object.keys(SCADA_ANALYSIS_LAYER_IDS).length); + expect(map.addLayer.mock.calls.map(([layer]) => layer.id)).toEqual( + Object.values(SCADA_ANALYSIS_LAYER_IDS) + ); + expect(map.addLayer.mock.invocationCallOrder.at(-1)).toBeLessThan( + source.setData.mock.invocationCallOrder[0] + ); + }); + + it("fits multiple SCADA points with panel-safe padding and reports missing IDs", async () => { + const source = { setData: vi.fn() }; + const map = createMap(source); + const padding = { top: 50, right: 420, bottom: 50, left: 320 }; + const controller = createController(map, scadaPointCollection, padding); + const result = await controller.renderScadaAnalysis([ + { sensor_id: "MP01", level: "high" }, + { sensor_id: "MP02", level: "medium" }, + { sensor_id: "MP404", level: "low" } + ]); + expect(map.fitBounds).toHaveBeenCalledWith([[120.7, 28], [120.72, 28.02]], expect.objectContaining({ padding, maxZoom: 18 })); + expect(result.missing_ids).toEqual(["MP404"]); + expect(result.level_counts).toEqual({ high: 1, medium: 1, low: 0, unrated: 0 }); + }); + + it("preserves the last valid overlay when all IDs are missing or WFS fails", async () => { + const source = { setData: vi.fn() }; + const map = createMap(source); + let mode: "found" | "missing" | "failed" = "found"; + const controller = new WorkbenchMapController({ + getMap: () => map as unknown as MapLibreMap, + isReady: () => true, + getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), + fetchFeatures: async () => { + if (mode === "failed") throw new Error("network"); + return mode === "found" ? scadaPointCollection : { type: "FeatureCollection", features: [] }; + } + }); + await controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }]); + const lastValid = source.setData.mock.calls[0][0]; + mode = "missing"; + await expect(controller.renderScadaAnalysis([{ sensor_id: "MP404", level: "low" }])).rejects.toThrow("SCADA_FEATURES_NOT_FOUND"); + expect(source.setData).toHaveBeenCalledTimes(1); + expect(controller.getSnapshot().scadaAnalysis?.rendered_ids).toEqual(["MP01"]); + mode = "failed"; + await expect(controller.renderScadaAnalysis([{ sensor_id: "MP02", level: "low" }])).rejects.toThrow("WFS_UNAVAILABLE"); + expect(source.setData).toHaveBeenCalledTimes(1); + expect(source.setData.mock.calls[0][0]).toBe(lastValid); + }); + + it("replaces the previous overlay and clears it explicitly", async () => { + const source = { setData: vi.fn() }; + const map = createMap(source); + const controller = createController(map, scadaPointCollection); + await controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }]); + await controller.renderScadaAnalysis([{ sensor_id: "MP02", level: "low" }]); + expect(source.setData.mock.calls[1][0].features).toHaveLength(1); + expect(source.setData.mock.calls[1][0].features[0].properties.sensor_id).toBe("MP02"); + expect(controller.clearScadaAnalysis()).toEqual({ cleared: true }); + expect(source.setData.mock.calls[2][0]).toEqual({ type: "FeatureCollection", features: [] }); + expect(controller.getSnapshot().scadaAnalysis).toBeNull(); + }); + + it("keeps the existing overlay when the map is not ready", async () => { + const source = { setData: vi.fn() }; + const map = createMap(source); + let ready = true; + const controller = new WorkbenchMapController({ + getMap: () => map as unknown as MapLibreMap, + isReady: () => ready, + getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), + fetchFeatures: async () => scadaPointCollection + }); + await controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }]); + ready = false; + await expect(controller.renderScadaAnalysis([{ sensor_id: "MP02", level: "low" }])).rejects.toThrow("MAP_NOT_READY"); + expect(source.setData).toHaveBeenCalledTimes(1); + expect(controller.getSnapshot().scadaAnalysis?.rendered_ids).toEqual(["MP01"]); + }); +}); + +const pointCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { id: "J-1" } }] }; +const lineCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "LineString", coordinates: [[120, 28], [121, 29]] }, properties: { id: "C-1" } }] }; +const scadaPointCollection: FeatureCollection = { type: "FeatureCollection", features: [ + { type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { sensor_id: "MP01" } }, + { type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { sensor_id: "MP02" } } +] }; + +function createMap( + scadaSource?: { setData: ReturnType }, + analysisLayersReady = true +) { + return { + easeTo: vi.fn(), fitBounds: vi.fn(), setFeatureState: vi.fn(), removeFeatureState: vi.fn(), + getSource: vi.fn((id: string) => id === SCADA_ANALYSIS_SOURCE_ID ? scadaSource : undefined), + getLayer: vi.fn((id: string) => analysisLayersReady && Object.values(SCADA_ANALYSIS_LAYER_IDS).includes(id as never) ? { id } : undefined), + addLayer: vi.fn(), setPaintProperty: vi.fn(), setLayoutProperty: vi.fn(), removeControl: vi.fn() + }; +} + +function createController(map: ReturnType, collection: FeatureCollection, padding = { top: 48, right: 48, bottom: 48, left: 48 }) { + return new WorkbenchMapController({ + getMap: () => map as unknown as MapLibreMap, + isReady: () => true, + getPadding: () => padding, + fetchFeatures: vi.fn(async () => collection) + }); +} diff --git a/features/workbench/map/workbench-map-controller.ts b/features/workbench/map/workbench-map-controller.ts new file mode 100644 index 0000000..102e032 --- /dev/null +++ b/features/workbench/map/workbench-map-controller.ts @@ -0,0 +1,349 @@ +import type { Feature, FeatureCollection, GeoJsonProperties, Geometry, Position } from "geojson"; +import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions } from "maplibre-gl"; +import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources"; +import { + LAYER_GROUP_GEOMETRIES, + applyLayerGroupStyle as applyStyle, + resetLayerGroupStyle as resetStyle, + validateLayerGroupStylePatch, + type LayerGroupId, + type LayerGroupStylePatch +} from "./layer-group-style"; +import { createFlowOverlayState, hideFlowOverlay, showFlowOverlay, type FlowOverlayState } from "./flow-overlay"; +import { + VALUE_LABEL_SOURCE_ID, + createEmptyValueLabelCollection, + createValueLabelCollection +} from "./value-label"; +import { + SCADA_ANALYSIS_SOURCE_ID, + createEmptyScadaAnalysisCollection, + createScadaAnalysisCollection, + ensureScadaAnalysisLayers, + type ScadaAnalysisItem, + type ScadaAnalysisRenderResult +} from "./scada-analysis"; + +export type FeatureTarget = { sourceId: WaterNetworkSourceId; featureId: string }; +export type WorkbenchMapErrorCode = + | "MAP_NOT_READY" + | "FEATURE_NOT_FOUND" + | "WFS_UNAVAILABLE" + | "SCADA_FEATURES_NOT_FOUND" + | "ACTION_SUPERSEDED" + | "FLOW_UNAVAILABLE" + | "INVALID_STYLE_PATCH"; + +export type WorkbenchMapControllerState = { + target: FeatureTarget | null; + pending: boolean; + errorCode: WorkbenchMapErrorCode | null; + flowVisible: boolean; + styledGroups: LayerGroupId[]; + scadaAnalysis: ScadaAnalysisRenderResult | null; +}; + +export type ValueLabelRequest = FeatureTarget & { property: string; precision: number; unit?: string }; +export type WorkbenchMapCommands = { + locateAndHighlight: (target: FeatureTarget) => Promise; + highlight: (target: FeatureTarget) => Promise; + clearHighlight: () => void; + setFlowVisible: (visible: boolean) => Promise; + showValueLabel: (request: { target: FeatureTarget; property: string; precision: number; unit?: string }) => Promise; + clearValueLabel: () => void; + applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void; + resetLayerGroupStyle: (groupId: LayerGroupId) => void; + renderScadaAnalysis: (items: ScadaAnalysisItem[], signal?: AbortSignal) => Promise; + clearScadaAnalysis: () => { cleared: true }; + resetAll: () => void; +}; + +export type WorkbenchMapControllerOptions = { + getMap: () => MapLibreMap | null; + isReady: () => boolean; + getPadding: () => PaddingOptions; + fetchFeatures?: (sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) => Promise; + reducedMotion?: () => boolean; +}; + +export class WorkbenchMapController implements WorkbenchMapCommands { + private state: WorkbenchMapControllerState = { target: null, pending: false, errorCode: null, flowVisible: false, styledGroups: [], scadaAnalysis: null }; + private listeners = new Set<() => void>(); + private highlightedTarget: FeatureTarget | null = null; + private cachedFeatures = new Map(); + private flowState: FlowOverlayState = createFlowOverlayState(); + private scadaAnalysisRevision = 0; + + constructor(private readonly options: WorkbenchMapControllerOptions) {} + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getSnapshot = () => this.state; + + async locateAndHighlight(target: FeatureTarget) { + const feature = await this.resolveTarget(target); + const map = this.requireMap(); + if (!feature || !map) return; + const coordinates = collectCoordinates(feature.geometry); + if (!coordinates.length) return this.setError("FEATURE_NOT_FOUND"); + if (feature.geometry.type === "Point") { + map.easeTo({ center: coordinates[0] as [number, number], zoom: 18, padding: this.options.getPadding(), duration: 600 }); + } else { + const bounds = coordinates.reduce( + (acc, coordinate) => [Math.min(acc[0], coordinate[0]), Math.min(acc[1], coordinate[1]), Math.max(acc[2], coordinate[0]), Math.max(acc[3], coordinate[1])], + [Infinity, Infinity, -Infinity, -Infinity] + ); + map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: this.options.getPadding(), maxZoom: 18, duration: 600 }); + } + this.setHighlight(target); + } + + async highlight(target: FeatureTarget) { + const feature = await this.resolveTarget(target); + if (feature) this.setHighlight(target); + } + + clearHighlight() { + const map = this.options.getMap(); + if (map && this.highlightedTarget) { + map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted"); + } + this.highlightedTarget = null; + this.patchState({ target: null, errorCode: null }); + } + + async setFlowVisible(visible: boolean) { + const map = this.requireMap(); + if (!map) return; + if (!visible) { + hideFlowOverlay(map, this.flowState); + this.patchState({ flowVisible: false, errorCode: null }); + return; + } + this.patchState({ pending: true, errorCode: null }); + try { + await showFlowOverlay( + map, + this.flowState, + () => this.fetchFeatures("conduits"), + this.options.reducedMotion?.() + ); + this.patchState({ pending: false, flowVisible: true }); + } catch { + hideFlowOverlay(map, this.flowState); + this.patchState({ pending: false, flowVisible: false, errorCode: "FLOW_UNAVAILABLE" }); + } + } + + async showValueLabel({ target, property, precision, unit = "" }: { target: FeatureTarget; property: string; precision: number; unit?: string }) { + if (!Number.isInteger(precision) || precision < 0 || precision > 3 || unit.length > 16) return this.setError("INVALID_STYLE_PATCH"); + const feature = await this.resolveTarget(target); + const map = this.requireMap(); + if (!feature || !map) return; + try { + (map.getSource(VALUE_LABEL_SOURCE_ID) as GeoJSONSource | undefined)?.setData( + createValueLabelCollection(feature, property, precision, unit) + ); + this.patchState({ target, errorCode: null }); + } catch { + this.setError("INVALID_STYLE_PATCH"); + } + } + + clearValueLabel() { + const map = this.options.getMap(); + (map?.getSource(VALUE_LABEL_SOURCE_ID) as GeoJSONSource | undefined)?.setData(createEmptyValueLabelCollection()); + } + + applyLayerGroupStyle(groupId: LayerGroupId, patch: LayerGroupStylePatch) { + const map = this.requireMap(); + if (!map) return; + if (!validateLayerGroupStylePatch(groupId, patch)) return this.setError("INVALID_STYLE_PATCH"); + applyStyle(map, groupId, patch); + this.patchState({ styledGroups: [...new Set([...this.state.styledGroups, groupId])], errorCode: null }); + } + + resetLayerGroupStyle(groupId: LayerGroupId) { + const map = this.requireMap(); + if (!map || !(groupId in LAYER_GROUP_GEOMETRIES)) return; + resetStyle(map, groupId); + this.patchState({ styledGroups: this.state.styledGroups.filter((item) => item !== groupId), errorCode: null }); + } + + async renderScadaAnalysis(items: ScadaAnalysisItem[], signal?: AbortSignal): Promise { + const revision = ++this.scadaAnalysisRevision; + const map = this.options.getMap(); + if (!map || !this.options.isReady()) { + this.setError("MAP_NOT_READY"); + throw new Error("MAP_NOT_READY"); + } + this.patchState({ pending: true, errorCode: null }); + + let sourceCollection: FeatureCollection; + try { + sourceCollection = await this.fetchFeatures("scada", items.map((item) => item.sensor_id), signal); + } catch { + if (revision === this.scadaAnalysisRevision) { + this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" }); + } + throw new Error(revision === this.scadaAnalysisRevision ? "WFS_UNAVAILABLE" : "ACTION_SUPERSEDED"); + } + if (revision !== this.scadaAnalysisRevision) throw new Error("ACTION_SUPERSEDED"); + + const next = createScadaAnalysisCollection(sourceCollection, items); + if (next.collection.features.length === 0) { + this.patchState({ pending: false, errorCode: "SCADA_FEATURES_NOT_FOUND" }); + throw new Error("SCADA_FEATURES_NOT_FOUND"); + } + const source = map.getSource(SCADA_ANALYSIS_SOURCE_ID) as GeoJSONSource | undefined; + if (!source) { + this.patchState({ pending: false, errorCode: "MAP_NOT_READY" }); + throw new Error("MAP_NOT_READY"); + } + + try { + ensureScadaAnalysisLayers(map); + source.setData(next.collection); + } catch { + this.patchState({ pending: false, errorCode: "MAP_NOT_READY" }); + throw new Error("MAP_NOT_READY"); + } + const result: ScadaAnalysisRenderResult = { ...next.result, fitted: true }; + this.patchState({ pending: false, errorCode: null, scadaAnalysis: result }); + const coordinates = next.collection.features.map((feature) => feature.geometry.coordinates); + if (coordinates.length === 1) { + map.easeTo({ + center: [coordinates[0][0], coordinates[0][1]], + zoom: 18, + padding: this.options.getPadding(), + duration: this.options.reducedMotion?.() ? 0 : 600 + }); + } else { + const bounds = coordinates.reduce<[number, number, number, number]>( + (value, coordinate) => [ + Math.min(value[0], coordinate[0]), + Math.min(value[1], coordinate[1]), + Math.max(value[2], coordinate[0]), + Math.max(value[3], coordinate[1]) + ], + [Infinity, Infinity, -Infinity, -Infinity] + ); + map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { + padding: this.options.getPadding(), + maxZoom: 18, + duration: this.options.reducedMotion?.() ? 0 : 600 + }); + } + return result; + } + + clearScadaAnalysis() { + this.scadaAnalysisRevision += 1; + const map = this.options.getMap(); + (map?.getSource(SCADA_ANALYSIS_SOURCE_ID) as GeoJSONSource | undefined)?.setData( + createEmptyScadaAnalysisCollection() + ); + this.patchState({ scadaAnalysis: null, pending: false, errorCode: null }); + return { cleared: true as const }; + } + + resetAll() { + this.clearHighlight(); + this.clearValueLabel(); + this.clearScadaAnalysis(); + const map = this.options.getMap(); + if (map) { + hideFlowOverlay(map, this.flowState); + (Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[]).forEach((groupId) => resetStyle(map, groupId)); + } + this.patchState({ target: null, pending: false, errorCode: null, flowVisible: false, styledGroups: [], scadaAnalysis: null }); + } + + destroy() { + const map = this.options.getMap(); + if (map) hideFlowOverlay(map, this.flowState); + this.listeners.clear(); + } + + private async resolveTarget(target: FeatureTarget) { + const map = this.requireMap(); + if (!map) return null; + const key = `${target.sourceId}:${target.featureId}`; + const cached = this.cachedFeatures.get(key); + if (cached) return cached; + this.patchState({ pending: true, errorCode: null }); + try { + const collection = await this.fetchFeatures(target.sourceId, [target.featureId]); + const feature = collection.features[0]; + if (!feature) { + this.patchState({ pending: false, errorCode: "FEATURE_NOT_FOUND" }); + return null; + } + this.cachedFeatures.set(key, feature); + this.patchState({ pending: false }); + return feature; + } catch { + this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" }); + return null; + } + } + + private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) { + if (this.options.fetchFeatures) return this.options.fetchFeatures(sourceId, featureIds, signal); + const response = await fetch("/api/map-features", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sourceId, featureIds }), + signal + }); + const body = await response.json(); + if (!response.ok) throw new Error(body.code ?? "WFS_UNAVAILABLE"); + return body as FeatureCollection; + } + + private setHighlight(target: FeatureTarget) { + const map = this.requireMap(); + if (!map) return; + if (this.highlightedTarget) map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted"); + map.setFeatureState(toFeatureStateTarget(target), { highlighted: true }); + this.highlightedTarget = target; + this.clearValueLabel(); + this.patchState({ target, errorCode: null }); + } + + private requireMap() { + const map = this.options.getMap(); + if (!map || !this.options.isReady()) { + this.setError("MAP_NOT_READY"); + return null; + } + return map; + } + + private setError(errorCode: WorkbenchMapErrorCode) { + this.patchState({ errorCode }); + } + + private patchState(patch: Partial) { + this.state = { ...this.state, ...patch }; + this.listeners.forEach((listener) => listener()); + } +} + +function toFeatureStateTarget(target: FeatureTarget) { + return { source: target.sourceId, sourceLayer: SOURCE_LAYERS[target.sourceId], id: target.featureId }; +} + +function collectCoordinates(geometry: Geometry | null): Position[] { + if (!geometry || geometry.type === "GeometryCollection") return []; + const coordinates: Position[] = []; + const visit = (value: unknown) => { + if (Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") coordinates.push(value as Position); + else if (Array.isArray(value)) value.forEach(visit); + }; + visit(geometry.coordinates); + return coordinates; +} diff --git a/lib/config.ts b/lib/config.ts index 10e3c57..2070795 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,5 +1,10 @@ const DEFAULT_MAP_URL = "https://geoserver.waternetwork.cn/geoserver"; +const DEFAULT_GEOSERVER_WORKSPACE = "wenzhou"; export const MAP_URL = ( process.env.NEXT_PUBLIC_MAP_URL || DEFAULT_MAP_URL ).replace(/\/$/, ""); + +export const GEOSERVER_WORKSPACE = ( + process.env.NEXT_PUBLIC_GEOSERVER_WORKSPACE || DEFAULT_GEOSERVER_WORKSPACE +).trim().replace(/^\/+|\/+$/g, ""); diff --git a/package.json b/package.json index 4a9cdf0..f2f9645 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ }, "dependencies": { "@ai-sdk/react": "^4.0.9", + "@deck.gl/core": "9.3.7", + "@deck.gl/layers": "9.3.7", + "@deck.gl/mapbox": "9.3.7", "@radix-ui/react-accordion": "^1.2.14", "@radix-ui/react-collapsible": "^1.1.14", "@radix-ui/react-dialog": "^1.1.17", @@ -32,6 +35,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "edge-tts-ts": "^1.0.0", "katex": "^0.17.0", "lucide-react": "^0.468.0", "maplibre-gl": "^4.7.1", diff --git a/playwright.config.ts b/playwright.config.ts index 96e9af8..f2486cd 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,17 +1,19 @@ import { defineConfig } from "playwright/test"; +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000"; + export default defineConfig({ testDir: "./tests/browser", testMatch: "**/*.e2e.ts", timeout: 30_000, use: { - baseURL: "http://127.0.0.1:3000", + baseURL, viewport: { width: 1440, height: 900 } }, webServer: { command: "pnpm dev", reuseExistingServer: true, timeout: 120_000, - url: "http://127.0.0.1:3000" + url: baseURL } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68b31fd..fcc215c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,15 @@ importers: '@ai-sdk/react': specifier: ^4.0.9 version: 4.0.9(react@19.2.7)(zod@4.4.3) + '@deck.gl/core': + specifier: 9.3.7 + version: 9.3.7 + '@deck.gl/layers': + specifier: 9.3.7 + version: 9.3.7(@deck.gl/core@9.3.7)(@loaders.gl/core@4.4.3)(@luma.gl/core@9.3.6)(@luma.gl/engine@9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6))) + '@deck.gl/mapbox': + specifier: 9.3.7 + version: 9.3.7(@deck.gl/core@9.3.7)(@luma.gl/core@9.3.6)(@math.gl/web-mercator@4.1.0) '@radix-ui/react-accordion': specifier: ^1.2.14 version: 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -68,6 +77,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + edge-tts-ts: + specifier: ^1.0.0 + version: 1.0.0 katex: specifier: ^0.17.0 version: 0.17.0 @@ -191,6 +203,24 @@ packages: '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@deck.gl/core@9.3.7': + resolution: {integrity: sha512-l+YPm1I0Fwj+b7xsu8FvyDT5MGPgEPJe2cQJUpQNgG742b2goB4tyYpteUhNAOeVo1gmenvqv36ZJGNsGxipJA==} + + '@deck.gl/layers@9.3.7': + resolution: {integrity: sha512-T2CnkEU1QEqmusrSdot7+8Nl09gzntG5DvhaogyBbTjmABhTh4rUnS6dLOjf5/ju3aJ+Q58/FvDzgBBcK4V7kg==} + peerDependencies: + '@deck.gl/core': ~9.3.0 + '@loaders.gl/core': ^4.4.3 + '@luma.gl/core': ~9.3.3 + '@luma.gl/engine': ~9.3.3 + + '@deck.gl/mapbox@9.3.7': + resolution: {integrity: sha512-hamgwBS2BbB8DHOjYWwPMrFki+KhcedjoHv/9cUpQHnjfXUswzmyhAURuyowO/tAVJb3vF+CmMzaDVUTlI40Sw==} + peerDependencies: + '@deck.gl/core': ~9.3.0 + '@luma.gl/core': ~9.3.3 + '@math.gl/web-mercator': ^4.1.0 + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -438,6 +468,49 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@loaders.gl/core@4.4.3': + resolution: {integrity: sha512-D6kXFdpUtYwoqS5OQLLaLELkHlS3qIdcSgzVvsh/MinRteeVIITJojqEc/lmDx1zVR3j6WND7yPGF4i4aOwiOg==} + + '@loaders.gl/images@4.4.3': + resolution: {integrity: sha512-a4C6x+4GZUchf+IwpdBOvyGeLGSbGOVob+WSWfGUezEQ4gqu1zS1pn3lCX3ZTL5P+Ch3v6KKwPGdO5YdTd52Ew==} + peerDependencies: + '@loaders.gl/core': ~4.4.0 + + '@loaders.gl/loader-utils@4.4.3': + resolution: {integrity: sha512-5yt1OqZPYR3d+xFAqtzSKO24Hd7019KqN+iOWp7XBKPQBS+sAEhAnmSTnO1axo45IEi14J3FhMDk+ndqBoSKEQ==} + + '@loaders.gl/schema-utils@4.4.3': + resolution: {integrity: sha512-TggzQWkzf9pIYbj8I9RlTk33kwXslvDkY1nnw/OL/hika6qTls1G127qK5m9skiAjElk5ZkvQLQcwhOboZXZeA==} + peerDependencies: + '@loaders.gl/core': ~4.4.0 + + '@loaders.gl/schema@4.4.3': + resolution: {integrity: sha512-tDb/rOn44nHWvMFSOaAk1/pQcQcusxirGCM1O5BMJR0PQ71UrCHey56rsbcf7/wGhvyRwPtX+oiMzlJ9TmN8vQ==} + + '@loaders.gl/worker-utils@4.4.3': + resolution: {integrity: sha512-RZt/NwFXm6Kx3Fn/rqiW3Alse9Z0XtBktH/EFAp8W6LvsKuxpRMjULqGfybTu2waRXRYUNYZ6zGs9aOWbtDRLw==} + peerDependencies: + '@loaders.gl/core': ~4.4.0 + + '@luma.gl/core@9.3.6': + resolution: {integrity: sha512-eqHnCPh2xHYkdd9rEkiIIkGMixNiJkq1ROrnTob6npvmZNVHXL0ubIw5KueL7rqW0+J1tm+p2+TXZaCmn0sP7Q==} + + '@luma.gl/engine@9.3.6': + resolution: {integrity: sha512-NYTdhn2NaH/MjN8qXCl2ukrT1Ac9iTKu1mgN0wz11XRd1QYnlMNBXswRROD7IZ2PUsBWdmYHc9qE8wKBQqMuIA==} + peerDependencies: + '@luma.gl/core': ~9.3.0 + '@luma.gl/shadertools': ~9.3.0 + + '@luma.gl/shadertools@9.3.6': + resolution: {integrity: sha512-dDuD8lCOkAE1L7hJGZEyZAi7upR1HG3wEEIQ1gCLaTTbJWC7tNtnVz853lqUA+VXgjgS9NiQFFRcosiIZmW4Vg==} + peerDependencies: + '@luma.gl/core': ~9.3.0 + + '@luma.gl/webgl@9.3.6': + resolution: {integrity: sha512-tGm7FGWPmJKxGZMvkRPRi219x3tKmBxR7Uke09nTp2ujNak/O5V9xPIQ9lUBGTxqAb8U2yjKkXG8j+f/4b2+sw==} + peerDependencies: + '@luma.gl/core': ~9.3.0 + '@mapbox/geojson-rewind@0.5.2': resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==} hasBin: true @@ -466,6 +539,21 @@ packages: resolution: {integrity: sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==} hasBin: true + '@math.gl/core@4.1.0': + resolution: {integrity: sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==} + + '@math.gl/polygon@4.1.0': + resolution: {integrity: sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==} + + '@math.gl/sun@4.1.0': + resolution: {integrity: sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA==} + + '@math.gl/types@4.1.0': + resolution: {integrity: sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==} + + '@math.gl/web-mercator@4.1.0': + resolution: {integrity: sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw==} + '@mermaid-js/parser@1.1.1': resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} @@ -558,6 +646,15 @@ packages: '@oxc-project/types@0.137.0': resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + '@probe.gl/env@4.1.1': + resolution: {integrity: sha512-+68seNDMVsEegRB47pFA/Ws1Fjy8agcFYXxzorKToyPcD6zd+gZ5uhwoLd7TzsSw6Ydns//2KEszWn+EnNHTbA==} + + '@probe.gl/log@4.1.1': + resolution: {integrity: sha512-kcZs9BT44pL7hS1OkRGKYRXI/SN9KejUlPD+BY40DguRLzdC5tLG/28WGMyfKdn/51GT4a0p+0P8xvDn1Ez+Kg==} + + '@probe.gl/stats@4.1.1': + resolution: {integrity: sha512-4VpAyMHOqydSvPlEyHwXaE+AkIdR03nX+Qhlxsk2D/IW4OVmDZgIsvJB1cDzyEEtcfKcnaEbfXeiPgejBceT6g==} + '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} @@ -1130,6 +1227,12 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/command-line-args@5.2.3': + resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==} + + '@types/command-line-usage@5.0.4': + resolution: {integrity: sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -1268,6 +1371,12 @@ packages: '@types/node@22.19.21': resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==} + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/offscreencanvas@2019.7.3': + resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} + '@types/pbf@3.0.5': resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==} @@ -1532,6 +1641,10 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + apache-arrow@21.1.0: + resolution: {integrity: sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==} + hasBin: true + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -1546,6 +1659,10 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + array-back@6.2.3: + resolution: {integrity: sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==} + engines: {node: '>=12.17'} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -1673,6 +1790,10 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk-template@0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1719,6 +1840,23 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + command-line-args@6.0.2: + resolution: {integrity: sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ==} + engines: {node: '>=12.20'} + peerDependencies: + '@75lb/nature': latest + peerDependenciesMeta: + '@75lb/nature': + optional: true + + command-line-usage@7.0.4: + resolution: {integrity: sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==} + engines: {node: '>=12.20.0'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1994,9 +2132,16 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + earcut@2.2.4: + resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} + earcut@3.0.2: resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + edge-tts-ts@1.0.0: + resolution: {integrity: sha512-327gpuN0VjvMsuvbizqabzqMiYpdHb0Slt8/hyf+ridtSkOGN68oogLpFnm8KznunbCnoo3DWMTAn7j6sd3WrA==} + hasBin: true + electron-to-chromium@1.5.375: resolution: {integrity: sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==} @@ -2235,6 +2380,15 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-replace@5.0.2: + resolution: {integrity: sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==} + engines: {node: '>=14'} + peerDependencies: + '@75lb/nature': latest + peerDependenciesMeta: + '@75lb/nature': + optional: true + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2243,6 +2397,9 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} + flatbuffers@25.9.23: + resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} + flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} @@ -2607,6 +2764,11 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -2622,6 +2784,10 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true + json-bignum@0.0.3: + resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==} + engines: {node: '>=0.8'} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -2767,6 +2933,9 @@ packages: lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -3009,6 +3178,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mjolnir.js@3.0.1: + resolution: {integrity: sha512-/RMi8Jm3NKleOkVI8D2ai+1OVwtfRJsSVBVjbTXNm83nfsN4uORYaN3u1/hsg5CqVI+di8enTkvgDNDOywn6cQ==} + motion-dom@12.40.0: resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} @@ -3548,6 +3720,9 @@ packages: react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + sound-play@1.1.0: + resolution: {integrity: sha512-Bd/L0AoCwITFeOnpNLMsfPXrV5GG5NhrC/T6odveahYbhPZkdTnrFXRia9FCC5WBWdUTw1d+yvLBvi4wnD1xOA==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3651,6 +3826,10 @@ packages: peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + table-layout@4.1.1: + resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==} + engines: {node: '>=12.17'} + tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} @@ -3747,6 +3926,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + typical@7.3.0: + resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==} + engines: {node: '>=12.17'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -3754,6 +3937,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -3823,6 +4009,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@13.0.2: + resolution: {integrity: sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==} + hasBin: true + uuid@14.0.0: resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} hasBin: true @@ -3961,6 +4151,22 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wordwrapjs@5.1.1: + resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==} + engines: {node: '>=12.17'} + + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -4022,6 +4228,51 @@ snapshots: '@chevrotain/types@11.1.2': {} + '@deck.gl/core@9.3.7': + dependencies: + '@loaders.gl/core': 4.4.3 + '@loaders.gl/images': 4.4.3(@loaders.gl/core@4.4.3) + '@luma.gl/core': 9.3.6 + '@luma.gl/engine': 9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6)) + '@luma.gl/shadertools': 9.3.6(@luma.gl/core@9.3.6) + '@luma.gl/webgl': 9.3.6(@luma.gl/core@9.3.6) + '@math.gl/core': 4.1.0 + '@math.gl/sun': 4.1.0 + '@math.gl/types': 4.1.0 + '@math.gl/web-mercator': 4.1.0 + '@probe.gl/env': 4.1.1 + '@probe.gl/log': 4.1.1 + '@probe.gl/stats': 4.1.1 + '@types/offscreencanvas': 2019.7.3 + gl-matrix: 3.4.4 + mjolnir.js: 3.0.1 + transitivePeerDependencies: + - '@75lb/nature' + + '@deck.gl/layers@9.3.7(@deck.gl/core@9.3.7)(@loaders.gl/core@4.4.3)(@luma.gl/core@9.3.6)(@luma.gl/engine@9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6)))': + dependencies: + '@deck.gl/core': 9.3.7 + '@loaders.gl/core': 4.4.3 + '@loaders.gl/images': 4.4.3(@loaders.gl/core@4.4.3) + '@loaders.gl/schema': 4.4.3 + '@luma.gl/core': 9.3.6 + '@luma.gl/engine': 9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6)) + '@luma.gl/shadertools': 9.3.6(@luma.gl/core@9.3.6) + '@mapbox/tiny-sdf': 2.2.0 + '@math.gl/core': 4.1.0 + '@math.gl/polygon': 4.1.0 + '@math.gl/web-mercator': 4.1.0 + '@types/geojson': 7946.0.16 + earcut: 2.2.4 + transitivePeerDependencies: + - '@75lb/nature' + + '@deck.gl/mapbox@9.3.7(@deck.gl/core@9.3.7)(@luma.gl/core@9.3.6)(@math.gl/web-mercator@4.1.0)': + dependencies: + '@deck.gl/core': 9.3.7 + '@luma.gl/core': 9.3.6 + '@math.gl/web-mercator': 4.1.0 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -4252,6 +4503,82 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@loaders.gl/core@4.4.3': + dependencies: + '@loaders.gl/loader-utils': 4.4.3(@loaders.gl/core@4.4.3) + '@loaders.gl/schema': 4.4.3 + '@loaders.gl/schema-utils': 4.4.3(@loaders.gl/core@4.4.3) + '@loaders.gl/worker-utils': 4.4.3(@loaders.gl/core@4.4.3) + '@probe.gl/log': 4.1.1 + transitivePeerDependencies: + - '@75lb/nature' + + '@loaders.gl/images@4.4.3(@loaders.gl/core@4.4.3)': + dependencies: + '@loaders.gl/core': 4.4.3 + '@loaders.gl/loader-utils': 4.4.3(@loaders.gl/core@4.4.3) + transitivePeerDependencies: + - '@75lb/nature' + + '@loaders.gl/loader-utils@4.4.3(@loaders.gl/core@4.4.3)': + dependencies: + '@loaders.gl/schema': 4.4.3 + '@loaders.gl/worker-utils': 4.4.3(@loaders.gl/core@4.4.3) + '@probe.gl/log': 4.1.1 + '@probe.gl/stats': 4.1.1 + transitivePeerDependencies: + - '@75lb/nature' + - '@loaders.gl/core' + + '@loaders.gl/schema-utils@4.4.3(@loaders.gl/core@4.4.3)': + dependencies: + '@loaders.gl/core': 4.4.3 + '@loaders.gl/schema': 4.4.3 + '@types/geojson': 7946.0.16 + apache-arrow: 21.1.0 + transitivePeerDependencies: + - '@75lb/nature' + + '@loaders.gl/schema@4.4.3': + dependencies: + '@types/geojson': 7946.0.16 + apache-arrow: 21.1.0 + transitivePeerDependencies: + - '@75lb/nature' + + '@loaders.gl/worker-utils@4.4.3(@loaders.gl/core@4.4.3)': + dependencies: + '@loaders.gl/core': 4.4.3 + + '@luma.gl/core@9.3.6': + dependencies: + '@math.gl/types': 4.1.0 + '@probe.gl/env': 4.1.1 + '@probe.gl/log': 4.1.1 + '@probe.gl/stats': 4.1.1 + '@types/offscreencanvas': 2019.7.3 + + '@luma.gl/engine@9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6))': + dependencies: + '@luma.gl/core': 9.3.6 + '@luma.gl/shadertools': 9.3.6(@luma.gl/core@9.3.6) + '@math.gl/core': 4.1.0 + '@math.gl/types': 4.1.0 + '@probe.gl/log': 4.1.1 + '@probe.gl/stats': 4.1.1 + + '@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6)': + dependencies: + '@luma.gl/core': 9.3.6 + '@math.gl/core': 4.1.0 + '@math.gl/types': 4.1.0 + + '@luma.gl/webgl@9.3.6(@luma.gl/core@9.3.6)': + dependencies: + '@luma.gl/core': 9.3.6 + '@math.gl/types': 4.1.0 + '@probe.gl/env': 4.1.1 + '@mapbox/geojson-rewind@0.5.2': dependencies: get-stream: 6.0.1 @@ -4281,6 +4608,22 @@ snapshots: rw: 1.3.3 tinyqueue: 3.0.0 + '@math.gl/core@4.1.0': + dependencies: + '@math.gl/types': 4.1.0 + + '@math.gl/polygon@4.1.0': + dependencies: + '@math.gl/core': 4.1.0 + + '@math.gl/sun@4.1.0': {} + + '@math.gl/types@4.1.0': {} + + '@math.gl/web-mercator@4.1.0': + dependencies: + '@math.gl/core': 4.1.0 + '@mermaid-js/parser@1.1.1': dependencies: '@chevrotain/types': 11.1.2 @@ -4348,6 +4691,14 @@ snapshots: '@oxc-project/types@0.137.0': {} + '@probe.gl/env@4.1.1': {} + + '@probe.gl/log@4.1.1': + dependencies: + '@probe.gl/env': 4.1.1 + + '@probe.gl/stats@4.1.1': {} + '@radix-ui/number@1.1.2': {} '@radix-ui/primitive@1.1.4': {} @@ -4921,6 +5272,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/command-line-args@5.2.3': {} + + '@types/command-line-usage@5.0.4': {} + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -5084,6 +5439,12 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/offscreencanvas@2019.7.3': {} + '@types/pbf@3.0.5': {} '@types/react-dom@19.2.3(@types/react@19.2.17)': @@ -5349,6 +5710,20 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 + apache-arrow@21.1.0: + dependencies: + '@swc/helpers': 0.5.15 + '@types/command-line-args': 5.2.3 + '@types/command-line-usage': 5.0.4 + '@types/node': 24.13.3 + command-line-args: 6.0.2 + command-line-usage: 7.0.4 + flatbuffers: 25.9.23 + json-bignum: 0.0.3 + tslib: 2.8.1 + transitivePeerDependencies: + - '@75lb/nature' + arg@5.0.2: {} argparse@2.0.1: {} @@ -5359,6 +5734,8 @@ snapshots: aria-query@5.3.2: {} + array-back@6.2.3: {} + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -5507,6 +5884,10 @@ snapshots: chai@6.2.2: {} + chalk-template@0.4.0: + dependencies: + chalk: 4.1.2 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -5560,6 +5941,22 @@ snapshots: comma-separated-tokens@2.0.3: {} + command-line-args@6.0.2: + dependencies: + array-back: 6.2.3 + find-replace: 5.0.2 + lodash.camelcase: 4.3.0 + typical: 7.3.0 + + command-line-usage@7.0.4: + dependencies: + array-back: 6.2.3 + chalk-template: 0.4.0 + table-layout: 4.1.1 + typical: 7.3.0 + + commander@14.0.3: {} + commander@4.1.1: {} commander@7.2.0: {} @@ -5852,8 +6249,21 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + earcut@2.2.4: {} + earcut@3.0.2: {} + edge-tts-ts@1.0.0: + dependencies: + commander: 14.0.3 + isomorphic-ws: 5.0.0(ws@8.21.1) + sound-play: 1.1.0 + uuid: 13.0.2 + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + electron-to-chromium@1.5.375: {} emoji-regex@9.2.2: {} @@ -6228,6 +6638,8 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-replace@5.0.2: {} + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -6238,6 +6650,8 @@ snapshots: flatted: 3.4.2 keyv: 4.5.4 + flatbuffers@25.9.23: {} + flatted@3.4.2: {} for-each@0.3.5: @@ -6666,6 +7080,10 @@ snapshots: isexe@3.1.5: {} + isomorphic-ws@5.0.0(ws@8.21.1): + dependencies: + ws: 8.21.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -6683,6 +7101,8 @@ snapshots: dependencies: argparse: 2.0.1 + json-bignum@0.0.3: {} + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} @@ -6796,6 +7216,8 @@ snapshots: lodash-es@4.18.1: {} + lodash.camelcase@4.3.0: {} + lodash.merge@4.6.2: {} longest-streak@3.1.0: {} @@ -7310,6 +7732,8 @@ snapshots: minimist@1.2.8: {} + mjolnir.js@3.0.1: {} + motion-dom@12.40.0: dependencies: motion-utils: 12.39.0 @@ -7956,6 +8380,8 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + sound-play@1.1.0: {} + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} @@ -8095,6 +8521,11 @@ snapshots: react: 19.2.7 use-sync-external-store: 1.6.0(react@19.2.7) + table-layout@4.1.1: + dependencies: + array-back: 6.2.3 + wordwrapjs: 5.1.1 + tailwind-merge@3.6.0: {} tailwindcss-animate@1.0.7(tailwindcss@3.4.19): @@ -8216,6 +8647,8 @@ snapshots: typescript@5.9.3: {} + typical@7.3.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -8225,6 +8658,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.18.2: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -8330,6 +8765,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@13.0.2: {} + uuid@14.0.0: {} vfile-location@5.0.3: @@ -8451,6 +8888,10 @@ snapshots: word-wrap@1.2.5: {} + wordwrapjs@5.1.1: {} + + ws@8.21.1: {} + yocto-queue@0.1.0: {} zod@4.4.3: {} diff --git a/public/icons/scada-integrated-monitoring.png b/public/icons/scada-integrated-monitoring.png new file mode 100644 index 0000000..a77d77f Binary files /dev/null and b/public/icons/scada-integrated-monitoring.png differ diff --git a/public/icons/scada-integrated-monitoring.svg b/public/icons/scada-integrated-monitoring.svg new file mode 100644 index 0000000..e6f9fd7 --- /dev/null +++ b/public/icons/scada-integrated-monitoring.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/browser/agent-acrylic.e2e.ts b/tests/browser/agent-acrylic.e2e.ts new file mode 100644 index 0000000..5d109a3 --- /dev/null +++ b/tests/browser/agent-acrylic.e2e.ts @@ -0,0 +1,111 @@ +import { expect, test, type Locator } from "playwright/test"; + +test("agent header reveals the shell acrylic without nesting another filter", async ({ page }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(250); + + const topBar = page.locator("header.acrylic-navigation"); + const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]'); + const agentHeader = agentPanel.locator(".agent-panel-header"); + + await expect(topBar).toBeVisible(); + await expect(agentPanel).toBeVisible(); + await expect(agentHeader).toBeVisible(); + + await expectAcrylicSurface(topBar); + await expectAcrylicSurface(agentPanel); + await expectTransparentAcrylicContext(agentHeader); + await expectLightweightContextSurfaces(agentPanel.locator(".agent-panel-conversation")); + await expectSolidInternalSurfaces(agentPanel.locator(".agent-panel-band")); +}); + +async function expectAcrylicSurface(shell: Locator) { + const composition = await shell.evaluate((element) => { + const style = getComputedStyle(element); + const filteredDescendants: string[] = []; + + for (const descendant of element.querySelectorAll("*")) { + if (getComputedStyle(descendant).backdropFilter !== "none") { + filteredDescendants.push(descendant.className); + } + } + + return { + backdropFilter: style.backdropFilter, + backgroundColor: style.backgroundColor, + filteredDescendants, + transform: style.transform, + willChange: style.willChange + }; + }); + + expect(composition.backdropFilter).toContain("blur("); + expect(getAlpha(composition.backgroundColor)).toBeLessThan(1); + expect(composition.filteredDescendants).toEqual([]); + expect(composition.transform).toBe("none"); + expect(composition.willChange).toBe("auto"); +} + +function getAlpha(color: string) { + const match = color.match(/^rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)$/); + + return match ? Number(match[1]) : 1; +} + +async function expectTransparentAcrylicContext(header: Locator) { + const style = await header.evaluate((element) => { + const computedStyle = getComputedStyle(element); + + return { + backdropFilter: computedStyle.backdropFilter, + backgroundColor: computedStyle.backgroundColor, + className: element.className, + webkitBackdropFilter: computedStyle.getPropertyValue("-webkit-backdrop-filter") + }; + }); + + expect(style.backdropFilter).toBe("none"); + expect(style.backgroundColor).toBe("rgba(0, 0, 0, 0)"); + expect(["", "none"]).toContain(style.webkitBackdropFilter); + expect(String(style.className)).not.toContain("acrylic-"); +} + +async function expectLightweightContextSurfaces(surfaces: Locator) { + const surfaceStyles = await surfaces.evaluateAll((elements) => + elements.map((element) => { + const style = getComputedStyle(element); + + return { + backdropFilter: style.backdropFilter, + backgroundColor: style.backgroundColor + }; + }) + ); + + expect(surfaceStyles.length).toBeGreaterThan(0); + for (const style of surfaceStyles) { + expect(style.backdropFilter).toBe("none"); + expect(getAlpha(style.backgroundColor)).toBeLessThanOrEqual(0.12); + } +} + +async function expectSolidInternalSurfaces(surfaces: Locator) { + const surfaceStyles = await surfaces.evaluateAll((elements) => + elements.map((element) => { + const style = getComputedStyle(element); + + return { + backdropFilter: style.backdropFilter, + className: element.className, + webkitBackdropFilter: style.getPropertyValue("-webkit-backdrop-filter") + }; + }) + ); + + expect(surfaceStyles.length).toBeGreaterThan(0); + for (const style of surfaceStyles) { + expect(style.backdropFilter).toBe("none"); + expect(["", "none"]).toContain(style.webkitBackdropFilter); + expect(String(style.className)).not.toContain("acrylic-"); + } +} diff --git a/tests/browser/agent-panel-resize.e2e.ts b/tests/browser/agent-panel-resize.e2e.ts new file mode 100644 index 0000000..18c0b12 --- /dev/null +++ b/tests/browser/agent-panel-resize.e2e.ts @@ -0,0 +1,33 @@ +import { expect, test } from "playwright/test"; + +test("Agent panel resizes by drag without exceeding half the viewport", async ({ page }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + + const panel = page.locator('aside[aria-label="Agent 命令面板"]'); + const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" }); + await expect(panel).toBeVisible(); + await expect(resizeHandle).toBeVisible(); + + const handleBox = await resizeHandle.boundingBox(); + expect(handleBox).not.toBeNull(); + + await page.mouse.move(handleBox!.x + handleBox!.width / 2, handleBox!.y + handleBox!.height / 2); + await page.mouse.down(); + await page.mouse.move(1_200, handleBox!.y + handleBox!.height / 2); + await page.mouse.up(); + + await expect.poll(async () => (await panel.boundingBox())?.width).toBe(720); + + await resizeHandle.focus(); + await page.keyboard.press("Home"); + await expect.poll(async () => (await panel.boundingBox())?.width).toBe(460); + await page.keyboard.press("ArrowRight"); + await expect.poll(async () => (await panel.boundingBox())?.width).toBe(476); +}); + +test("Agent resize handle stays hidden in the mobile layout", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await expect(page.getByRole("separator", { name: "调整 Agent 面板宽度" })).toBeHidden(); +}); diff --git a/tests/browser/agent-voice-hydration.e2e.ts b/tests/browser/agent-voice-hydration.e2e.ts new file mode 100644 index 0000000..e490e5f --- /dev/null +++ b/tests/browser/agent-voice-hydration.e2e.ts @@ -0,0 +1,85 @@ +import { expect, test } from "playwright/test"; + +test("speech capability detection preserves the server hydration tree", async ({ page }) => { + const hydrationErrors: string[] = []; + + page.on("console", (message) => { + if (message.type() === "error" && /hydration|server rendered html/i.test(message.text())) { + hydrationErrors.push(message.text()); + } + }); + + await page.addInitScript(() => { + Object.defineProperty(window, "SpeechRecognition", { + configurable: true, + value: class SpeechRecognition {} + }); + }); + await page.goto("/", { waitUntil: "networkidle" }); + + await expect(page.getByRole("button", { name: "语音输入" })).toBeVisible(); + await expect(page.getByRole("button", { name: "权限批准模式" })).toBeVisible(); + expect(hydrationErrors).toEqual([]); +}); + +test("speech recognition failure clears the active animation and explains the error", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window, "SpeechRecognition", { + configurable: true, + value: class SpeechRecognition { + onerror: ((event: { error: string }) => void) | null = null; + onend: (() => void) | null = null; + + start() { + window.setTimeout(() => { + this.onerror?.({ error: "not-allowed" }); + this.onend?.(); + }, 20); + } + + stop() { + this.onend?.(); + } + + abort() {} + } + }); + }); + await page.goto("/", { waitUntil: "networkidle" }); + + await page.getByRole("button", { name: "语音输入" }).click(); + + await expect(page.getByRole("button", { name: "语音输入" })).toHaveAttribute("aria-pressed", "false"); + await expect(page.locator('[data-slot="voice-input-pulse"]')).toHaveCount(0); + await expect(page.getByText("无法使用麦克风,请在浏览器地址栏允许麦克风权限后重试")).toBeVisible(); +}); + +test("speech recognition writes final transcript into the prompt", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window, "SpeechRecognition", { + configurable: true, + value: class SpeechRecognition { + onresult: ((event: { resultIndex: number; results: Array & { isFinal: boolean }> }) => void) | null = null; + onend: (() => void) | null = null; + + start() { + window.setTimeout(() => { + const result = Object.assign([{ transcript: "检查城南泵站水位" }], { isFinal: true }); + this.onresult?.({ resultIndex: 0, results: [result] }); + }, 20); + } + + stop() { + this.onend?.(); + } + + abort() {} + } + }); + }); + await page.goto("/", { waitUntil: "networkidle" }); + + await page.getByRole("button", { name: "语音输入" }).click(); + + await expect(page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应")).toHaveValue("检查城南泵站水位"); +}); diff --git a/tests/browser/map-dev-panel.e2e.ts b/tests/browser/map-dev-panel.e2e.ts new file mode 100644 index 0000000..c4e697d --- /dev/null +++ b/tests/browser/map-dev-panel.e2e.ts @@ -0,0 +1,27 @@ +import { expect, test } from "playwright/test"; + +test("Dev Panel opens on desktop, owns the right workspace, and closes with Escape", async ({ page }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => Boolean(window.__waterNetworkMap)); + const trigger = page.getByRole("button", { name: "切换地图 Dev Panel" }); + await expect(trigger).toBeVisible(); + await expect(page.getByRole("navigation", { name: "地图工具" })).toBeVisible(); + await trigger.click(); + + const panel = page.getByTestId("map-dev-panel"); + await expect(panel).toBeVisible(); + await expect(panel).toHaveCSS("width", "400px"); + await expect(page.getByRole("navigation", { name: "地图工具" })).toBeHidden(); + await expect(panel.getByRole("button", { name: "定位并高亮" })).toBeVisible(); + await expect(panel.getByRole("button", { name: "重置全部" })).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(panel).toBeHidden(); +}); + +test("Dev entry is absent below the desktop breakpoint", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.getByRole("button", { name: "切换地图 Dev Panel" })).toBeHidden(); + await expect(page.getByTestId("map-dev-panel")).toHaveCount(0); +}); diff --git a/tests/browser/scada-analysis.e2e.ts b/tests/browser/scada-analysis.e2e.ts new file mode 100644 index 0000000..cbe85eb --- /dev/null +++ b/tests/browser/scada-analysis.e2e.ts @@ -0,0 +1,237 @@ +import { expect, test, type Page } from "playwright/test"; + +test("frontend actions replace SCADA analysis results and remain legible at 375px", async ({ page }) => { + const receipts: Array> = []; + await mockAgentActions(page, receipts); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => Boolean( + window.__waterNetworkMap?.getSource("agent-scada-analysis") + )); + await expect( + page.getByRole("button", { name: "选择 Agent 模型" }).filter({ visible: true }).first() + ).toContainText("测试模型"); + + await page.evaluate(() => { + const map = window.__waterNetworkMap; + [ + "agent-scada-analysis-label", + "agent-scada-analysis-indicator", + "agent-scada-analysis-level", + "agent-scada-analysis-casing" + ].forEach((id) => { + if (map?.getLayer(id)) map.removeLayer(id); + }); + }); + + const prompt = page.getByPlaceholder(/输入调度问题/).filter({ visible: true }).first(); + await prompt.fill("将 MP01 标为高、MP02 标为低并显示编号"); + await page.getByRole("button", { name: "发送 Agent 指令" }).filter({ visible: true }).first().click(); + + await expect(page.getByText("已渲染 1 个,高 0、中 0、低 1。")).toBeVisible(); + await page.waitForFunction(() => { + const source = window.__waterNetworkMap?.getSource("agent-scada-analysis") as unknown as { + _data?: { features?: Array<{ properties?: Record }> }; + }; + const features = source?._data?.features ?? []; + return features.length === 1 && features[0].properties?.sensor_id === "MP02"; + }); + await page.waitForFunction(() => { + const map = window.__waterNetworkMap; + return Boolean( + map?.isSourceLoaded("agent-scada-analysis") && + map.queryRenderedFeatures(undefined, { layers: ["agent-scada-analysis-level"] }).length > 0 && + map.queryRenderedFeatures(undefined, { layers: ["agent-scada-analysis-label"] }).length > 0 + ); + }); + const rendered = await page.evaluate(() => { + const map = window.__waterNetworkMap; + const source = map?.getSource("agent-scada-analysis") as unknown as { + _data?: { features?: Array<{ properties?: Record }> }; + }; + const feature = source?._data?.features?.[0]; + const labelLayer = map?.getLayer("agent-scada-analysis-label"); + const levelLayer = map?.getLayer("agent-scada-analysis-level"); + const indicatorLayer = map?.getLayer("agent-scada-analysis-indicator"); + return { + label: feature?.properties?.analysis_label, + labelLayer: labelLayer?.type, + labelSize: map?.getLayoutProperty("agent-scada-analysis-label", "text-size"), + labelFont: map?.getLayoutProperty("agent-scada-analysis-label", "text-font"), + level: feature?.properties?.analysis_level, + levelLayer: levelLayer?.type, + levelColors: map?.getPaintProperty("agent-scada-analysis-level", "circle-stroke-color"), + indicatorLayer: indicatorLayer?.type + }; + }); + expect(rendered).toEqual({ + label: "MP02 · 低", + labelLayer: "symbol", + labelSize: 15, + labelFont: ["Noto Sans Regular"], + level: "low", + levelLayer: "circle", + levelColors: [ + "match", + ["get", "analysis_level"], + "high", "#DC2626", + "medium", "#F59E0B", + "low", "#16A34A", + "#7C3AED" + ], + indicatorLayer: "circle" + }); + + await expect.poll(() => receipts.length).toBe(2); + expect(receipts).toEqual(expect.arrayContaining([ + expect.objectContaining({ status: "failed", error: expect.objectContaining({ code: "ACTION_SUPERSEDED" }) }), + expect.objectContaining({ + status: "succeeded", + output: expect.objectContaining({ rendered_ids: ["MP02"], fitted: true }) + }) + ])); + + await page.setViewportSize({ width: 375, height: 812 }); + await page.evaluate(() => window.__waterNetworkMap?.resize()); + await expect(page.getByText("已渲染 1 个,高 0、中 0、低 1。")).toBeVisible(); + expect(await page.evaluate(() => { + const source = window.__waterNetworkMap?.getSource("agent-scada-analysis") as unknown as { + _data?: { features?: Array<{ properties?: Record }> }; + }; + return { + label: source?._data?.features?.[0]?.properties?.analysis_label, + viewportWidth: document.documentElement.clientWidth + }; + })).toEqual({ label: "MP02 · 低", viewportWidth: 375 }); + + await page.setViewportSize({ width: 1440, height: 900 }); + await page.getByRole("button", { name: "新建 Agent 对话" }).filter({ visible: true }).click(); + await page.waitForFunction(() => { + const source = window.__waterNetworkMap?.getSource("agent-scada-analysis") as unknown as { + _data?: { features?: unknown[] }; + }; + return source?._data?.features?.length === 0; + }); + + const nextPrompt = page.getByPlaceholder(/输入调度问题/).filter({ visible: true }).first(); + await nextPrompt.fill("新一组分析:将 MP01 标为高、MP02 标为低并显示编号"); + await page.getByRole("button", { name: "发送 Agent 指令" }).filter({ visible: true }).first().click(); + await page.waitForFunction(() => { + const source = window.__waterNetworkMap?.getSource("agent-scada-analysis") as unknown as { + _data?: { features?: Array<{ properties?: Record }> }; + }; + const features = source?._data?.features ?? []; + return features.length === 1 && features[0].properties?.analysis_label === "MP02 · 低"; + }); + await page.waitForTimeout(250); + expect(await page.evaluate(() => { + const source = window.__waterNetworkMap?.getSource("agent-scada-analysis") as unknown as { + _data?: { features?: Array<{ properties?: Record }> }; + }; + return source?._data?.features?.map((feature) => feature.properties?.analysis_label); + })).toEqual(["MP02 · 低"]); + await expect.poll(() => receipts.length).toBe(4); +}); + +async function mockAgentActions(page: Page, receipts: Array>) { + let streamRequestCount = 0; + + await page.route("**/api/map-features", async (route) => { + const body = route.request().postDataJSON() as { featureIds?: string[] }; + const sensorId = body.featureIds?.[0] ?? ""; + if (sensorId === "MP01") await new Promise((resolve) => setTimeout(resolve, 150)); + await route.fulfill({ + json: { + type: "FeatureCollection", + features: [{ + type: "Feature", + geometry: { type: "Point", coordinates: sensorId === "MP01" ? [120.7, 28] : [120.72, 28.02] }, + properties: { sensor_id: sensorId } + }] + } + }); + }); + + await page.route("**/api/v1/agent/chat/**", async (route) => { + const url = new URL(route.request().url()); + const path = url.pathname; + if (/\/frontend-actions\/[^/]+\/result$/.test(path)) { + receipts.push(route.request().postDataJSON() as Record); + await route.fulfill({ json: { ok: true } }); + return; + } + if (path.endsWith("/frontend-action-registry")) { + await route.fulfill({ json: { + schema_version: "frontend-action-registry@1", + actions: [ + { id: "render_scada_analysis", version: "frontend-action@1" }, + { id: "clear_scada_analysis", version: "frontend-action@1" } + ] + } }); + return; + } + if (path.endsWith("/models")) { + await route.fulfill({ json: { + default_model: "test/model", + models: [{ id: "test/model", label: "测试模型", description: "SCADA 动作测试", icon: "bolt" }] + } }); + return; + } + if (path.endsWith("/sessions")) { + await route.fulfill({ json: { sessions: [] } }); + return; + } + if (path.endsWith("/ui-registry")) { + await route.fulfill({ json: { schema_version: "agent-ui-registry@1", components: [], actions: [] } }); + return; + } + if (path.endsWith("/stream") && route.request().method() === "POST") { + streamRequestCount += 1; + const now = Date.now(); + const sessionId = `scada-session-${streamRequestCount}`; + const chunks = [ + { type: "start", messageId: `assistant-scada-action-${streamRequestCount}` }, + frontendActionChunk(`action-${streamRequestCount}-first`, `call-${streamRequestCount}-first`, sessionId, "MP01", "high", now), + frontendActionChunk(`action-${streamRequestCount}-second`, `call-${streamRequestCount}-second`, sessionId, "MP02", "low", now), + { type: "text-start", id: "answer" }, + { type: "text-delta", id: "answer", delta: "已更新地图结果。" }, + { type: "text-end", id: "answer" }, + { type: "finish", finishReason: "stop" } + ]; + await route.fulfill({ + headers: { + "Cache-Control": "no-cache", + "Content-Type": "text/event-stream", + "X-Vercel-AI-UI-Message-Stream": "v1" + }, + body: chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + }); + return; + } + await route.fulfill({ json: {} }); + }); +} + +function frontendActionChunk( + actionId: string, + toolCallId: string, + sessionId: string, + sensorId: string, + level: "high" | "low", + now: number +) { + return { + type: "data-frontend_action", + data: { + version: "frontend-action@1", + actionId, + toolCallId, + sessionId, + session_id: sessionId, + name: "render_scada_analysis", + params: { items: [{ sensor_id: sensorId, level }] }, + issuedAt: now, + expiresAt: now + 15_000 + }, + transient: true + }; +}