feat: extend agent-driven map workbench

This commit is contained in:
2026-07-20 19:57:35 +08:00
parent 86e9b08235
commit 7fbd8a5618
63 changed files with 4506 additions and 457 deletions
+2
View File
@@ -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
+227
View File
@@ -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-*` 类。
+4 -3
View File
@@ -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 }); }
}
+30
View File
@@ -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 });
}
}
+59
View File
@@ -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);
}
}
+1 -2
View File
@@ -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 {
+554 -27
View File
@@ -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> | void;
@@ -83,6 +102,7 @@ type AgentCommandPanelProps = {
onRenameHistorySession?: (sessionId: string, title: string) => Promise<void> | void;
onDeleteHistorySession?: (sessionId: string) => Promise<void> | void;
onSelectModel?: (model: string) => void;
onApprovalModeChange?: (mode: AgentApprovalMode) => void;
onSubmitPrompt: (prompt: string) => Promise<void> | void;
onStopPrompt?: () => void;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | 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<string | null>(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({
)}
>
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
<header className="agent-panel-header acrylic-panel">
<header className="agent-panel-header">
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
<AgentPersona
@@ -289,6 +347,13 @@ export function AgentCommandPanel({
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
isSpeechSupported={isSpeechSupported}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={speak}
onPauseSpeech={pauseSpeech}
onResumeSpeech={resumeSpeech}
onStopSpeech={stopSpeech}
/>
) : (
<motion.div
@@ -349,13 +414,27 @@ export function AgentCommandPanel({
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
/>
</PromptInputBody>
<PromptInputFooter className="justify-end">
<PromptInputFooter className="justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<ApprovalModeSelect
value={approvalMode}
disabled={streaming}
onValueChange={onApprovalModeChange}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<AgentModelSelect
models={modelOptions}
value={selectedModel}
onValueChange={onSelectModel}
/>
{isRecognitionSupported ? (
<VoiceInputButton
isListening={isListening}
disabled={streaming}
onClick={isListening ? stopListening : () => startListening(showRecognitionError)}
/>
) : null}
<PromptInputSubmit
aria-label={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
title={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
@@ -376,6 +455,177 @@ export function AgentCommandPanel({
);
}
const VOICE_WAVE_BARS = [
{ height: 5, duration: 0.72, delay: 0.08 },
{ height: 10, duration: 0.58, delay: 0 },
{ height: 15, duration: 0.66, delay: 0.12 },
{ height: 9, duration: 0.62, delay: 0.04 },
{ height: 4, duration: 0.7, delay: 0.16 }
] as const;
function VoiceInputButton({
isListening,
disabled,
onClick
}: {
isListening: boolean;
disabled: boolean;
onClick: () => void;
}) {
const reduceMotion = useReducedMotion();
const label = isListening ? "停止语音输入" : "语音输入";
return (
<motion.button
type="button"
aria-label={label}
aria-pressed={isListening}
title={label}
className={cn(
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border",
"border-transparent bg-transparent text-blue-600 shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
"hover:bg-slate-50 hover:text-blue-700",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25 focus-visible:ring-offset-1",
"disabled:cursor-not-allowed disabled:opacity-45",
isListening &&
"bg-red-50 text-red-600 hover:bg-red-100 hover:text-red-700"
)}
disabled={disabled}
onClick={onClick}
whileTap={reduceMotion ? undefined : { scale: 0.94 }}
>
{isListening ? (
<motion.span
data-slot="voice-input-pulse"
className="pointer-events-none absolute inset-0 rounded-lg bg-red-400/20"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.9 }}
animate={reduceMotion ? { opacity: 0.38, scale: 1 } : { opacity: [0.4, 0], scale: [1, 1.28] }}
transition={reduceMotion ? { duration: 0.16 } : { duration: 1.7, ease: "easeOut", repeat: Infinity }}
/>
) : null}
<span className="relative grid h-5 w-5 place-items-center overflow-hidden">
<AnimatePresence initial={false} mode="wait">
{isListening ? (
<motion.span
key="voice-wave"
className="flex h-4 items-center gap-0.5"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.72 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.72 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
{VOICE_WAVE_BARS.map((bar) => (
<motion.span
key={bar.height}
className="w-px rounded-full bg-red-500"
style={{ height: bar.height }}
animate={
reduceMotion
? { scaleY: 0.72 }
: { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
}
transition={
reduceMotion
? { duration: 0.16 }
: { duration: bar.duration, delay: bar.delay, ease: "easeInOut", repeat: Infinity }
}
aria-hidden="true"
/>
))}
</motion.span>
) : (
<motion.span
key="voice-microphone"
className="grid place-items-center"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.72, y: 2 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.72, y: -2 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
<Mic
size={15}
strokeWidth={2.15}
className="transition-transform duration-200 group-hover:scale-105"
/>
</motion.span>
)}
</AnimatePresence>
</span>
</motion.button>
);
}
function ApprovalModeSelect({
value,
disabled,
onValueChange
}: {
value: AgentApprovalMode;
disabled: boolean;
onValueChange?: (mode: AgentApprovalMode) => void;
}) {
const requestApproval = value === "request";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className="h-8 shrink-0 gap-1.5 rounded-md border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
disabled={disabled || !onValueChange}
aria-label="权限批准模式"
>
{requestApproval ? <Shield size={14} aria-hidden="true" /> : <ShieldCheck size={14} aria-hidden="true" />}
<span>{requestApproval ? "请求批准" : "始终允许"}</span>
<ChevronsUpDown size={12} className="opacity-50" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
sideOffset={8}
className="agent-panel-control w-[220px] rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500"></DropdownMenuLabel>
<DropdownMenuItem
className={cn(
"items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
requestApproval && "border-blue-200 bg-blue-50 focus:bg-blue-50"
)}
onSelect={() => onValueChange?.("request")}
>
<Shield className="mt-0.5 text-blue-600" aria-hidden="true" />
<span>
<span className="block text-sm font-semibold text-slate-800"></span>
<span className="block text-xs leading-5 text-slate-500"></span>
</span>
{requestApproval ? <CheckCircle2 className="ml-auto mt-0.5 text-blue-600" aria-hidden="true" /> : null}
</DropdownMenuItem>
<DropdownMenuItem
className={cn(
"mt-1 items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
!requestApproval && "border-emerald-200 bg-emerald-50 focus:bg-emerald-50"
)}
onSelect={() => onValueChange?.("always")}
>
<ShieldCheck className="mt-0.5 text-emerald-600" aria-hidden="true" />
<span>
<span className="block text-sm font-semibold text-slate-800"></span>
<span className="block text-xs leading-5 text-slate-500"></span>
</span>
{!requestApproval ? <CheckCircle2 className="ml-auto mt-0.5 text-emerald-600" aria-hidden="true" /> : null}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
type AgentConversationScrollSnapshot = {
activeSessionId?: string | null;
lastUserMessageId: string | null;
@@ -593,27 +843,46 @@ function AgentEmptyState() {
return (
<section
aria-labelledby="agent-empty-state-title"
className="surface-reading mx-auto w-full max-w-sm rounded-2xl border border-slate-200/80 px-4 pb-4 pt-5 text-center shadow-sm"
className="surface-reading mx-auto w-full max-w-[360px] overflow-hidden rounded-2xl border border-slate-200/80 shadow-[0_1px_2px_rgba(15,23,42,0.06),0_12px_32px_rgba(15,23,42,0.07)]"
lang="zh-CN"
>
<div className="mx-auto grid h-16 w-16 place-items-center rounded-full border border-violet-100 bg-white shadow-[0_10px_28px_rgba(126,34,206,0.12)]">
<AgentReadyMark />
<div className="px-4 py-5">
<div className="flex items-center gap-4">
<div className="grid h-[72px] w-[72px] shrink-0 place-items-center rounded-2xl bg-[oklch(0.97_0.018_292)] shadow-[inset_0_0_0_1px_oklch(0.91_0.03_292),0_12px_30px_rgba(88,71,120,0.10)]">
<AgentReadyMark />
</div>
<div className="min-w-0 flex-1 text-left">
<h2
id="agent-empty-state-title"
className="text-balance text-lg font-semibold leading-7 text-slate-900"
>
</h2>
</div>
</div>
<p className="mt-5 text-pretty text-left text-sm leading-6 text-slate-500">
</p>
</div>
<h2 id="agent-empty-state-title" className="mt-4 text-lg font-semibold leading-7 text-slate-900">
</h2>
<p className="mx-auto mt-2 max-w-[30ch] text-sm leading-6 text-slate-500">
使
</p>
<ul className="mt-5 grid grid-cols-2 gap-2" aria-label="Agent 分析能力">
{AGENT_CAPABILITIES.map(({ icon: Icon, label }) => (
<ul
className="grid grid-cols-2 border-t border-slate-200/70 bg-slate-50/55"
aria-label="Agent 分析能力"
>
{AGENT_CAPABILITIES.map(({ icon: Icon, label }, index) => (
<li
key={label}
className="surface-control flex min-h-16 flex-col items-center justify-center gap-1.5 rounded-xl border border-slate-200/70 px-2 py-2.5 text-center shadow-[0_6px_18px_rgba(15,23,42,0.04)] sm:flex-row sm:gap-2 sm:px-2.5 sm:text-left"
className={cn(
"flex min-h-[72px] items-center gap-3 px-3.5 py-3 text-left",
index % 2 === 0 && "border-r border-slate-200/70",
index < 2 && "border-b border-slate-200/70"
)}
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-700">
<Icon size={17} strokeWidth={1.9} aria-hidden="true" />
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-600">
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
</span>
<span className="whitespace-nowrap text-xs font-semibold leading-5 text-slate-800">{label}</span>
<span className="text-xs font-medium leading-5 text-slate-700">{label}</span>
</li>
))}
</ul>
@@ -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> | void;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
isSpeechSupported: boolean;
speakingMessageId: string | null;
speechState: AgentSpeechState;
onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise<void> | void;
onPauseSpeech: () => void;
onResumeSpeech: () => void;
onStopSpeech: () => void;
}) {
return (
<motion.div className="flex w-full flex-col gap-4" variants={agentPanelListVariants} animate="animate">
@@ -667,13 +950,25 @@ function BackendMessageList({
) : null}
{message.content ? (
message.role === "assistant" ? (
<StreamingTokenResponse
className="leading-6"
content={message.content}
<AgentSpeechMessage
messageId={message.id}
streamDone={streamRenderState[message.id]?.done ?? !streaming}
streaming={streaming && index === messages.length - 1}
/>
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}
>
<StreamingTokenResponse
className="leading-6"
content={message.content}
messageId={message.id}
streamDone={streamRenderState[message.id]?.done ?? !streaming}
streaming={streaming && index === messages.length - 1}
/>
</AgentSpeechMessage>
) : (
<p className="whitespace-pre-wrap">{message.content}</p>
)
@@ -697,3 +992,235 @@ function BackendMessageList({
</motion.div>
);
}
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> | void;
onPause: () => void;
onResume: () => void;
onStop: () => void;
children: ReactNode;
}) {
const contentRef = useRef<HTMLDivElement | null>(null);
const lastSelectionRef = useRef<SpeechSelection | null>(null);
const [selection, setSelection] = useState<SpeechSelection | null>(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 (
<div>
<div
ref={contentRef}
onPointerUp={() => window.requestAnimationFrame(captureSelection)}
onKeyUp={() => window.requestAnimationFrame(captureSelection)}
>
{children}
</div>
{selectionAnchor && typeof document !== "undefined" ? createPortal(
<div
className="pointer-events-none fixed z-[80] -translate-x-1/2 -translate-y-full"
style={{ left: selectionAnchor.left, top: selectionAnchor.top }}
>
<AnimatePresence>
{selection ? (
<motion.button
key="speech-selection-action"
type="button"
className="glass-transient pointer-events-auto inline-flex h-10 origin-bottom items-center gap-2 rounded-xl border px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25"
initial={{ opacity: 0, y: 8, scale: 0.95 }}
animate={{
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.15, ease: [0.16, 1, 0.3, 1] }
}}
exit={{
opacity: 0,
y: 0,
scale: 0.95,
transition: { duration: 0.1, ease: [0.4, 0, 1, 1] }
}}
whileTap={{ scale: 0.96 }}
onMouseDown={(event) => event.preventDefault()}
onClick={speakFromSelection}
>
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-blue-50/75 text-blue-600" aria-hidden="true">
<Volume2 size={15} />
</span>
</motion.button>
) : null}
</AnimatePresence>
</div>,
document.body
) : null}
{isSupported && !disabled ? (
<div className="mt-2 flex min-h-8 items-center gap-1 border-t border-slate-100 pt-1.5">
{speechState === "idle" ? (
<SpeechIconButton label="语音播放" onClick={() => void Promise.resolve(onSpeak(messageId, speechText))}>
<Volume2 size={15} aria-hidden="true" />
</SpeechIconButton>
) : null}
{speechState === "loading" ? (
<>
<SpeechIconButton label="正在生成语音" disabled>
<LoaderCircle size={15} className="animate-spin text-blue-600" aria-hidden="true" />
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={14} fill="currentColor" aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
{speechState === "playing" ? (
<>
<SpeechIconButton label="暂停语音播放" tone="primary" onClick={onPause}>
<Pause size={15} fill="currentColor" aria-hidden="true" />
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={14} fill="currentColor" aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
{speechState === "paused" ? (
<>
<SpeechIconButton label="继续语音播放" tone="primary" onClick={onResume}>
<Play size={15} fill="currentColor" aria-hidden="true" />
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={14} fill="currentColor" aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
<span className="text-[11px] text-slate-400">
{speechState === "idle" ? "语音播放" : speechState === "loading" ? "正在生成" : speechState === "playing" ? "播放中" : "已暂停"}
</span>
</div>
) : null}
</div>
);
}
function SpeechIconButton({
label,
tone = "default",
disabled = false,
onClick,
children
}: {
label: string;
tone?: "default" | "primary" | "danger";
disabled?: boolean;
onClick?: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500",
"transition-[color,background-color,transform] hover:bg-slate-100 active:scale-95 disabled:cursor-wait",
tone === "primary" && "text-blue-600",
tone === "danger" && "text-red-600"
)}
>
{children}
</button>
);
}
+5 -1
View File
@@ -41,7 +41,11 @@ export function AgentPersona({
return (
<Persona
className={cn("shrink-0", className)}
className={cn(
"shrink-0",
variant === "obsidian" && "scale-110",
className
)}
onLoadError={() => setFailed(true)}
state={personaState}
variant={variant}
@@ -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<string, string>();
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
};
}
+1 -1
View File
@@ -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<string, unknown>; issuedAt: number; expiresAt: number };
+347
View File
@@ -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<AgentSpeechState>("idle");
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const currentAudioUrlRef = useRef<string | null>(null);
const audioUrlsRef = useRef<Set<string>>(new Set());
const fetchControllersRef = useRef<Set<AbortController>>(new Set());
const chunkUrlCacheRef = useRef<Map<number, string>>(new Map());
const chunkPromisesRef = useRef<Map<number, Promise<string>>>(new Map());
const chunksRef = useRef<string[]>([]);
const currentChunkIndexRef = useRef(0);
const playbackTokenRef = useRef(0);
const activeMessageIdRef = useRef<string | null>(null);
const playChunkRef = useRef<(chunkIndex: number, playbackToken: number) => Promise<void>>(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<BrowserSpeechRecognition | null>(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 "语音输入暂时不可用,请稍后重试";
}
}
+1 -12
View File
@@ -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";
-107
View File
@@ -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("<think>先分析需求</think>\n\n最终回答")).toEqual({
answer: "最终回答",
thought: "先分析需求",
thoughtComplete: true
});
});
it("supports streaming thought content before the closing tag arrives", () => {
expect(parseAssistantMessageSections("准备中...\n<think>继续推理中")).toEqual({
answer: "准备中...",
thought: "继续推理中",
thoughtComplete: false
});
});
it("merges multiple thought blocks into a single collapsed section", () => {
expect(
parseAssistantMessageSections(
"<think>第一段思考</think>\n答案开头\n<think>第二段思考</think>\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_call>{"tool":"locate_junctions","params":{"ids":["J1","J2"]}}</tool_call>\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_call>{"tool":"locate_pipes","params":{"ids":["P1"]}}</tool_call>\n文本2\n<tool_call>{"tool":"chart","params":{"title":"图"}}</tool_call>';
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_call>{"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<tool_c");
expect(result.segments).toEqual([{ type: "text", content: "正在分析..." }]);
});
it("handles malformed JSON gracefully", () => {
const result = parseContentWithToolCalls("前文\n<tool_call>{invalid json}</tool_call>\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);
});
});
-129
View File
@@ -1,129 +0,0 @@
export type AssistantMessageSections = {
answer: string;
thought: string | null;
thoughtComplete: boolean;
};
export type ToolCall = {
id: string;
tool: string;
params: Record<string, unknown>;
};
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 = /<think>([\s\S]*?)<\/think>/gi;
const THINK_OPEN_TAG = "<think>";
const THINK_CLOSE_TAG = "</think>";
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 = "<tool_call>";
const TOOL_CALL_PATTERN = /<tool_call>([\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<string, unknown>;
};
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 };
}
-8
View File
@@ -12,14 +12,6 @@ import type {
type RecordValue = Record<string, unknown>;
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)}`;
+24
View File
@@ -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水位 详情");
});
});
+111
View File
@@ -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;
}
+2 -6
View File
@@ -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;
@@ -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<WaterNetworkSourceId, string> = {
conduits: "管渠",
junctions: "检查井",
orifices: "孔口",
outfalls: "排放口",
pumps: "泵",
scada: "SCADA"
};
const ERROR_LABELS: Record<string, string> = {
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<WaterNetworkSourceId>("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<LayerGroupId>("conduits");
const [draft, setDraft] = useState<Record<string, string | number>>({
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 (
<aside
aria-label="地图开发面板"
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] flex-col overflow-hidden rounded-2xl border border-white/70 shadow-2xl lg:flex"
data-testid="map-dev-panel"
>
<div className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200/70 px-4">
<div>
<h2 className="text-sm font-semibold text-slate-950"> Dev Panel</h2>
<p className="text-[11px] text-slate-500"></p>
</div>
<button className="grid h-8 w-8 place-items-center rounded-lg text-slate-500 transition hover:bg-white/70 hover:text-slate-900 active:scale-95" type="button" aria-label="关闭 Dev Panel" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
{controllerState.errorCode ? (
<div role="alert" className="rounded-xl border border-red-200 bg-red-50/90 px-3 py-2 text-xs font-medium text-red-700">
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
</div>
) : null}
<PanelSection title="Feature" description="WFS 验证后执行独立高亮或相机定位。">
<div className="grid grid-cols-[120px_1fr] gap-2">
<Field label="业务源">
<select value={sourceId} onChange={(event) => setSourceId(event.target.value as WaterNetworkSourceId)} className={inputClassName}>
{WATER_NETWORK_SOURCE_IDS.map((id) => <option key={id} value={id}>{GROUP_LABELS[id]}</option>)}
</select>
</Field>
<Field label="Feature ID">
<input value={featureId} onChange={(event) => setFeatureId(event.target.value)} className={inputClassName} />
</Field>
</div>
<button type="button" disabled={!detailFeature} onClick={useSelectedFeature} className={secondaryButtonClassName}>使</button>
<div className="grid grid-cols-2 gap-2">
<button type="button" disabled={!target.featureId || controllerState.pending} onClick={() => void commands.highlight(target)} className={primaryButtonClassName}><Crosshair size={14} /></button>
<button type="button" disabled={!target.featureId || controllerState.pending} onClick={() => void commands.locateAndHighlight(target)} className={primaryButtonClassName}><LocateFixed size={14} /></button>
</div>
<button type="button" onClick={commands.clearHighlight} className={secondaryButtonClassName}></button>
</PanelSection>
<PanelSection title="全网水流" description="按管渠拓扑方向显示静态或动画流纹。">
<div className="grid grid-cols-2 gap-2">
<button type="button" disabled={controllerState.pending || controllerState.flowVisible} onClick={() => void commands.setFlowVisible(true)} className={primaryButtonClassName}></button>
<button type="button" disabled={!controllerState.flowVisible} onClick={() => void commands.setFlowVisible(false)} className={secondaryButtonClassName}></button>
</div>
</PanelSection>
<PanelSection title="数值标注" description="仅为当前测试目标显示一个数值属性。">
<Field label="数值属性">
{numericProperties.length ? (
<select value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName}>
{numericProperties.map(([key]) => <option key={key} value={key}>{key}</option>)}
</select>
) : <input value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName} />}
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="精度 0-3"><input type="number" min={0} max={3} value={precision} onChange={(event) => setPrecision(Number(event.target.value))} className={inputClassName} /></Field>
<Field label="单位"><input maxLength={16} value={unit} onChange={(event) => setUnit(event.target.value)} className={inputClassName} /></Field>
</div>
<div className="grid grid-cols-2 gap-2">
<button type="button" disabled={!target.featureId || !property || controllerState.pending} onClick={() => void commands.showValueLabel({ target, property, precision, unit })} className={primaryButtonClassName}></button>
<button type="button" onClick={commands.clearValueLabel} className={secondaryButtonClassName}></button>
</div>
</PanelSection>
<PanelSection title="样式编辑" description="覆盖业务组样式,交互语义色保持不变。">
<Field label="业务图层组">
<div className="relative">
<select value={groupId} onChange={(event) => setGroupId(event.target.value as LayerGroupId)} className={cn(inputClassName, "appearance-none pr-8")}>
{WATER_NETWORK_SOURCE_IDS.map((id) => <option key={id} value={id}>{GROUP_LABELS[id]}</option>)}
</select>
<ChevronDown size={14} className="pointer-events-none absolute right-2.5 top-2.5 text-slate-400" />
</div>
</Field>
{geometry === "line" ? (
<>
<ColorField label="颜色" value={String(draft.color)} onChange={(value) => setDraft((current) => ({ ...current, color: value }))} />
<RangeField label="透明度" min={0} max={1} step={0.05} value={Number(draft.opacity)} onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))} />
<RangeField label="宽度倍率" min={0.5} max={3} step={0.1} value={Number(draft.widthMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, widthMultiplier: value }))} />
<Field label="线型"><select value={draft.dash} onChange={(event) => setDraft((current) => ({ ...current, dash: event.target.value }))} className={inputClassName}><option value="original"></option><option value="solid">线</option><option value="dashed">线</option></select></Field>
</>
) : (
<>
<div className="grid grid-cols-2 gap-2"><ColorField label="填充" value={String(draft.fillColor)} onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))} /><ColorField label="描边" value={String(draft.strokeColor)} onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))} /></div>
<RangeField label="透明度" min={0} max={1} step={0.05} value={Number(draft.opacity)} onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))} />
<RangeField label="半径倍率" min={0.5} max={3} step={0.1} value={Number(draft.radiusMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, radiusMultiplier: value }))} />
<RangeField label="描边倍率" min={0.5} max={3} step={0.1} value={Number(draft.strokeMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, strokeMultiplier: value }))} />
{geometry === "scada" ? <RangeField label="图标倍率" min={0.5} max={2} step={0.1} value={Number(draft.iconMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, iconMultiplier: value }))} /> : null}
</>
)}
<div className="grid grid-cols-2 gap-2">
<button type="button" onClick={applyDraft} className={primaryButtonClassName}></button>
<button type="button" onClick={() => commands.resetLayerGroupStyle(groupId)} className={secondaryButtonClassName}></button>
</div>
</PanelSection>
</div>
<div className="shrink-0 border-t border-slate-200/70 p-3">
<button type="button" onClick={commands.resetAll} className={cn(secondaryButtonClassName, "w-full")}><RotateCcw size={14} /></button>
</div>
</aside>
);
}
function PanelSection({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
return <section className="space-y-2.5 border-b border-slate-200/70 px-1 pb-4 last:border-b-0 last:pb-1"><div><h3 className="text-xs font-semibold text-slate-900">{title}</h3><p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p></div>{children}</section>;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return <label className="block min-w-0"><span className="mb-1 block text-[11px] font-medium text-slate-600">{label}</span>{children}</label>;
}
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return <Field label={label}><div className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white/80 px-2"><input type="color" value={value} onChange={(event) => onChange(event.target.value.toUpperCase())} className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0" /><span className="text-xs font-medium text-slate-600">{value}</span></div></Field>;
}
function RangeField({ label, min, max, step, value, onChange }: { label: string; min: number; max: number; step: number; value: number; onChange: (value: number) => void }) {
return <Field label={`${label} · ${value}`}><input aria-label={label} type="range" min={min} max={max} step={step} value={value} onChange={(event) => onChange(Number(event.target.value))} className="h-6 w-full accent-blue-600" /></Field>;
}
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";
@@ -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({
<Droplets size={18} aria-hidden="true" />
</div>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950"></h1>
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950"></h1>
<p className="hidden truncate text-xs leading-4 text-slate-500 sm:block"></p>
</div>
</div>
@@ -128,6 +135,18 @@ export function WorkbenchTopBar({
</div>
<div className="flex shrink-0 items-center gap-1.5">
{devPanelEnabled ? <div className="hidden lg:block">
<button
type="button"
aria-label="切换地图 Dev Panel"
aria-pressed={devPanelOpen}
onClick={onToggleDevPanel}
className={cn("group px-2 active:scale-95", headerControlButtonClassName, devPanelOpen && "bg-blue-50/80 text-blue-700")}
>
<span className={cn(headerControlIconClassName, devPanelOpen && "bg-blue-600/10 text-blue-700")}><FlaskConical size={14} aria-hidden="true" /></span>
<span>Dev</span>
</button>
</div> : null}
<div className="lg:hidden">
<AlertMenu
compact
@@ -1,5 +1,5 @@
import type { UIMessage } from "ai";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import type { Dispatch, SetStateAction } from "react";
import type {
AgentChatMessage,
AgentPermissionReply,
@@ -48,13 +48,11 @@ export type QuestionOverride = {
};
type StreamRenderStateSetter = Dispatch<SetStateAction<AgentStreamRenderState>>;
type StreamRenderChunkIdRef = MutableRefObject<number>;
export function appendStreamRenderToken(
export function markStreamRenderPending(
data: Record<string, unknown>,
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<AgentStreamRenderState>((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 }
};
});
}
+21 -16
View File
@@ -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<number | null>(null);
const mobileCollapseTimerRef = useRef<number | null>(null);
const sessionStreamAbortRef = useRef<AbortController | null>(null);
const sessionStreamIdRef = useRef<string | null>(null);
const streamRenderChunkIdRef = useRef(0);
const clientRef = useRef(createAgentApiClient());
const sessionIdRef = useRef<string | null>(null);
const approvalModeRef = useRef<AgentApprovalMode>("request");
const registryRef = useRef<UIRegistry | null>(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<AgentModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState("");
const [approvalMode, setApprovalModeState] = useState<AgentApprovalMode>("request");
const [streamRenderState, setStreamRenderState] = useState<AgentStreamRenderState>({});
const [permissionOverrides, setPermissionOverrides] = useState<Record<string, PermissionOverride>>({});
const [questionOverrides, setQuestionOverrides] = useState<Record<string, QuestionOverride>>({});
@@ -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,
@@ -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<MapLibreMap | null>;
mapReady: boolean;
leftPanelOpen: boolean;
rightPanelOpen: boolean;
}) {
const valuesRef = useRef({ mapReady, leftPanelOpen, rightPanelOpen });
valuesRef.current = { mapReady, leftPanelOpen, rightPanelOpen };
const controllerRef = useRef<WorkbenchMapController | null>(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 };
}
@@ -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);
+14
View File
@@ -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";
@@ -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;
+197 -44
View File
@@ -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<HTMLDivElement | null>(null);
const desktopAgentPanelRef = useRef<HTMLDivElement | null>(null);
const agentPanelResizeLeftRef = useRef(0);
const agentPanelResizeCleanupRef = useRef<(() => void) | null>(null);
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
const [devPanelOpen, setDevPanelOpen] = useState(false);
const [impactVisible, setImpactVisible] = useState(false);
const [isLargeScreen, setIsLargeScreen] = useState(false);
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
const [agentPanelWidth, setAgentPanelWidth] = useState<number | null>(null);
const [agentPanelResizing, setAgentPanelResizing] = useState(false);
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(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<string, unknown>; 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<main
className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
@@ -738,6 +855,8 @@ export function MapWorkbenchPage() {
conditionFeedVisible={shouldShowConditionFeed}
taskTickerAvailable={taskTickerAvailable}
taskTickerVisible={taskTickerVisible}
devPanelEnabled={devPanelEnabled}
devPanelOpen={devPanelOpen}
onSelectScenario={handleSelectScenario}
onSelectAlert={handleSelectAlert}
onToggleConditionFeed={() => {
@@ -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}
/>
<div className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] 2xl:w-[var(--workbench-agent-width-wide)] lg:block">
<div
ref={desktopAgentPanelRef}
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] max-w-[50vw] 2xl:w-[var(--workbench-agent-width-wide)] lg:block"
style={agentPanelWidth === null ? undefined : { width: agentPanelWidth }}
>
{agent.panelOpen ? (
<AgentCommandPanel
{...agentPanelProps}
collapsing={agent.panelCollapsing}
onCollapse={agent.collapsePanel}
/>
<>
<AgentCommandPanel
{...agentPanelProps}
collapsing={agent.panelCollapsing}
onCollapse={agent.collapsePanel}
/>
<div
aria-label="调整 Agent 面板宽度"
aria-orientation="vertical"
aria-valuemax={Math.round(viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio)}
aria-valuemin={getWorkbenchViewportLayout(viewportWidth).agentWidth}
aria-valuenow={Math.round(agentPanelWidth ?? getWorkbenchViewportLayout(viewportWidth).agentWidth)}
className={cn(
"group pointer-events-auto absolute -right-3 top-4 bottom-4 z-10 flex w-6 cursor-ew-resize touch-none items-center justify-center outline-none",
agentPanelResizing && "[&>span]:bg-blue-500 [&>span]:opacity-100"
)}
role="separator"
tabIndex={0}
title="拖拽调整 Agent 面板宽度"
onKeyDown={handleAgentPanelResizeKeyDown}
onPointerDown={handleAgentPanelResizeStart}
>
<span className="h-14 w-1 rounded-full bg-slate-500/60 opacity-50 shadow-sm transition-[height,background-color,opacity] group-hover:h-20 group-hover:bg-blue-500 group-hover:opacity-100 group-focus-visible:h-20 group-focus-visible:bg-blue-500 group-focus-visible:opacity-100" />
</div>
</>
) : (
<AgentCollapsedRail
personaState={agent.personaState}
@@ -771,11 +915,11 @@ export function MapWorkbenchPage() {
)}
</div>
<div className="absolute right-2 top-24 z-20">
{!devPanelOpen ? <div className="absolute right-2 top-24 z-20">
<MapToolbar items={toolbarItems} className="hidden lg:block" />
</div>
</div> : null}
{conditionFeedMounted ? (
{conditionFeedMounted && !devPanelOpen ? (
<div className="absolute right-16 top-24 z-20 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
<ScheduledConditionFeed
conditions={scheduledConditions}
@@ -788,15 +932,24 @@ export function MapWorkbenchPage() {
onFocusRequestHandled={handleConditionFocusRequestHandled}
onSelectedConditionChange={setSelectedConditionId}
onExpandAgent={agent.expandPanel}
onLoadHistorySession={agent.loadHistorySession}
onLoadHistorySession={handleLoadAgentHistorySession}
onSubmitPrompt={agent.submitPrompt}
/>
</div>
) : null}
<div className="absolute right-16 top-24 z-20 hidden lg:block">
{!devPanelOpen ? <div className="absolute right-16 top-24 z-20 hidden lg:block">
<ToolbarPanel {...toolbarPanelProps} />
</div>
</div> : null}
{devPanelEnabled && devPanelOpen ? (
<MapDevPanel
commands={mapController}
controllerState={mapControllerState}
detailFeature={detailFeature}
onClose={() => setDevPanelOpen(false)}
/>
) : null}
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
{agent.mobileOpen ? (
+6 -6
View File
@@ -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");
+1 -1
View File
@@ -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, {
@@ -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"
});
});
});
+3 -5
View File
@@ -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)}`
};
}
}
@@ -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();
});
});
+99
View File
@@ -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<LineString | MultiLineString, { diameter?: number | string; [key: string]: unknown }>;
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<FeatureCollection>,
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<FlowFeature>({
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<void>((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";
}
@@ -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<string, unknown>).flowPhase ?? 0) }
});
}
}
@@ -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);
});
});
+212
View File
@@ -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<string, LayerGroupGeometry>;
export type LayerGroupId = keyof typeof LAYER_GROUP_GEOMETRIES;
const ORIGINAL_DASH: Partial<Record<LayerGroupId, number[] | undefined>> = {
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<string, unknown>;
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);
}
+6 -1
View File
@@ -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 = [
+9 -2
View File
@@ -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",
@@ -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", () => {
+8 -9
View File
@@ -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<string, boolean> = {
};
const SOURCE_CONTROL_LABELS: Record<WaterNetworkSourceId, { label: string; description: string }> = {
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" },
@@ -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();
});
});
@@ -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<WaterNetworkSourceId, "id" | "sensor_id"> = {
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) };
}
@@ -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" } }
]
};
+214
View File
@@ -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<ScadaAnalysisLevel, number>;
export type ScadaAnalysisRenderResult = {
rendered_ids: string[];
missing_ids: string[];
level_counts: ScadaAnalysisLevelCounts;
fitted: true;
};
export const SCADA_ANALYSIS_LEVEL_COLORS: Record<ScadaAnalysisLevel, string> = {
high: "#DC2626",
medium: "#F59E0B",
low: "#16A34A",
unrated: "#7C3AED"
};
const SCADA_ANALYSIS_LABEL_COLORS: Record<ScadaAnalysisLevel, string> = {
high: "#991B1B",
medium: "#92400E",
low: "#166534",
unrated: "#5B21B6"
};
const levelColorExpression = (
colors: Record<ScadaAnalysisLevel, string>
): ExpressionSpecification => [
"match",
["get", "analysis_level"],
"high", colors.high,
"medium", colors.medium,
"low", colors.low,
colors.unrated
];
export const SCADA_ANALYSIS_LEVEL_LABELS: Record<ScadaAnalysisLevel, string> = {
high: "高",
medium: "中",
low: "低",
unrated: "未分级"
};
const SCADA_ANALYSIS_SORT_PRIORITY: Record<ScadaAnalysisLevel, number> = {
high: 0,
medium: 1,
low: 2,
unrated: 3
};
type Layer = NonNullable<StyleSpecification["layers"]>[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<MapLibreMap, "addLayer" | "getLayer">
): 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<Point> {
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<string>();
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<string, unknown>;
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<Point>; result: Omit<ScadaAnalysisRenderResult, "fitted"> } {
const featuresBySensorId = new Map<string, Feature<Point>>();
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<Point>);
}
});
const levelCounts = createEmptyScadaAnalysisLevelCounts();
const renderedIds: string[] = [];
const missingIds: string[] = [];
const features: Array<Feature<Point>> = [];
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 };
}
+17 -9
View File
@@ -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 });
});
+21 -4
View File
@@ -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;
+10 -4
View File
@@ -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");
});
});
+28 -21
View File
@@ -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,
@@ -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");
});
});
+41
View File
@@ -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<string, unknown> | 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: [] };
}
@@ -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<typeof vi.fn> },
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<typeof createMap>, 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)
});
}
@@ -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<void>;
highlight: (target: FeatureTarget) => Promise<void>;
clearHighlight: () => void;
setFlowVisible: (visible: boolean) => Promise<void>;
showValueLabel: (request: { target: FeatureTarget; property: string; precision: number; unit?: string }) => Promise<void>;
clearValueLabel: () => void;
applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void;
resetLayerGroupStyle: (groupId: LayerGroupId) => void;
renderScadaAnalysis: (items: ScadaAnalysisItem[], signal?: AbortSignal) => Promise<ScadaAnalysisRenderResult>;
clearScadaAnalysis: () => { cleared: true };
resetAll: () => void;
};
export type WorkbenchMapControllerOptions = {
getMap: () => MapLibreMap | null;
isReady: () => boolean;
getPadding: () => PaddingOptions;
fetchFeatures?: (sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection>;
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<string, Feature>();
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<ScadaAnalysisRenderResult> {
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<WorkbenchMapControllerState>) {
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;
}
+5
View File
@@ -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, "");
+4
View File
@@ -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",
+4 -2
View File
@@ -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
}
});
+441
View File
@@ -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: {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<circle cx="32" cy="32" r="32" fill="#0E7490"/>
<circle cx="32" cy="32" r="30.5" stroke="#67E8F9" stroke-width="3"/>
<g stroke="#CFFAFE" stroke-width="3" stroke-linecap="round">
<path d="M32 13v8M50 26l-9 3M43 48l-6-9M21 48l6-9M14 26l9 3"/>
</g>
<g fill="#FFFFFF">
<circle cx="32" cy="12" r="3"/>
<circle cx="51" cy="26" r="3"/>
<circle cx="44" cy="49" r="3"/>
<circle cx="20" cy="49" r="3"/>
<circle cx="13" cy="26" r="3"/>
</g>
<circle cx="32" cy="31" r="11" fill="#155E75" stroke="#FFFFFF" stroke-width="3"/>
<path d="M24.5 32h4l2.5-6 3.5 12 2.5-6h2.5" stroke="#FFFFFF" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 762 B

+111
View File
@@ -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-");
}
}
+33
View File
@@ -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();
});
@@ -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<Array<{ transcript: string }> & { 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("检查城南泵站水位");
});
+27
View File
@@ -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);
});
+237
View File
@@ -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<Record<string, unknown>> = [];
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<string, unknown> }> };
};
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<string, unknown> }> };
};
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<string, unknown> }> };
};
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<string, unknown> }> };
};
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<string, unknown> }> };
};
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<Record<string, unknown>>) {
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<string, unknown>);
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
};
}