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
+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;
}