feat: migrate drainage frontend to Vite
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ScheduledConditionRecord, ScheduledConditionTaskId } from "../types";
|
||||
import { AgentTaskTicker } from "./agent-task-ticker";
|
||||
|
||||
function createRunningCondition(id: string, taskId: ScheduledConditionTaskId): ScheduledConditionRecord {
|
||||
return {
|
||||
id,
|
||||
kind: "condition",
|
||||
taskId,
|
||||
scheduledAt: "2026-07-08T10:00:00+08:00",
|
||||
title: id,
|
||||
summary: "Running condition",
|
||||
status: "running",
|
||||
riskLevel: "normal",
|
||||
updatedAt: Date.parse("2026-07-08T10:01:00+08:00"),
|
||||
sessionId: id,
|
||||
durationMinutes: 10
|
||||
};
|
||||
}
|
||||
|
||||
function setDocumentVisibility(visibilityState: DocumentVisibilityState) {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
value: visibilityState
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}
|
||||
|
||||
function getActiveConditionId(container: HTMLElement) {
|
||||
const activeCards = container.querySelectorAll<HTMLElement>('article[aria-hidden="false"]');
|
||||
|
||||
return activeCards.item(activeCards.length - 1).dataset.conditionId;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
setDocumentVisibility("visible");
|
||||
});
|
||||
|
||||
describe("AgentTaskTicker page visibility", () => {
|
||||
it("does not rotate or retain exiting cards while the page is hidden", () => {
|
||||
vi.useFakeTimers();
|
||||
setDocumentVisibility("visible");
|
||||
|
||||
const conditions = [
|
||||
createRunningCondition("condition-a", "scada-diagnosis"),
|
||||
createRunningCondition("condition-b", "smart-dispatch"),
|
||||
createRunningCondition("condition-c", "pump-energy"),
|
||||
createRunningCondition("condition-d", "network-simulation")
|
||||
];
|
||||
const { container } = render(<AgentTaskTicker conditions={conditions} />);
|
||||
const initialConditionId = getActiveConditionId(container);
|
||||
|
||||
act(() => {
|
||||
setDocumentVisibility("hidden");
|
||||
vi.advanceTimersByTime(18_000);
|
||||
});
|
||||
|
||||
expect(getActiveConditionId(container)).toBe(initialConditionId);
|
||||
expect(container.querySelectorAll("article")).toHaveLength(3);
|
||||
|
||||
act(() => {
|
||||
setDocumentVisibility("visible");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(6_000);
|
||||
});
|
||||
|
||||
expect(getActiveConditionId(container)).not.toBe(initialConditionId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
getRunningWorkflowDefinition,
|
||||
getRunningWorkflowState
|
||||
} from "../data/running-workflows";
|
||||
import type {
|
||||
ScheduledConditionRecord
|
||||
} from "../types";
|
||||
|
||||
export type ConditionStepTickerItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type ConditionStepTickerView = {
|
||||
title: string;
|
||||
progressLabel: string;
|
||||
progressValue: number;
|
||||
currentStep: ConditionStepTickerItem | null;
|
||||
};
|
||||
|
||||
export type TickerStackCard = {
|
||||
condition: ScheduledConditionRecord;
|
||||
view: ConditionStepTickerView;
|
||||
stackIndex: number;
|
||||
};
|
||||
|
||||
const TICKER_STACK_DEPTH = 3;
|
||||
|
||||
export function selectTickerCondition(
|
||||
runningConditions: ScheduledConditionRecord[],
|
||||
rotationIndex: number
|
||||
): ScheduledConditionRecord | null {
|
||||
if (runningConditions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return runningConditions[rotationIndex % runningConditions.length] ?? runningConditions[0] ?? null;
|
||||
}
|
||||
|
||||
export function createVisibleTickerCards(
|
||||
runningConditions: ScheduledConditionRecord[],
|
||||
rotationIndex: number,
|
||||
nowMs: number
|
||||
): TickerStackCard[] {
|
||||
if (runningConditions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const visibleCount = Math.min(runningConditions.length, TICKER_STACK_DEPTH);
|
||||
|
||||
return Array.from({ length: visibleCount }, (_, stackIndex) => {
|
||||
const conditionIndex = (rotationIndex + stackIndex) % runningConditions.length;
|
||||
const condition = runningConditions[conditionIndex] ?? runningConditions[0];
|
||||
|
||||
return {
|
||||
condition,
|
||||
view: createConditionStepView(condition, nowMs, runningConditions.length),
|
||||
stackIndex
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createConditionStepView(
|
||||
condition: ScheduledConditionRecord,
|
||||
nowMs: number,
|
||||
runningConditionCount: number
|
||||
): ConditionStepTickerView {
|
||||
const workflow = getRunningWorkflowDefinition(condition);
|
||||
const workflowState = getRunningWorkflowState(condition, workflow, nowMs);
|
||||
const currentStep = workflow.steps[workflowState.currentStepIndex];
|
||||
const currentStepItem: ConditionStepTickerItem | null = currentStep
|
||||
? {
|
||||
id: currentStep.id,
|
||||
title: `步骤 ${workflowState.currentStepIndex + 1}/${workflow.steps.length} ${currentStep.title}`,
|
||||
detail: currentStep.detail
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
title: runningConditionCount > 1
|
||||
? `${workflow.title} · ${condition.title} · ${runningConditionCount} 个执行中`
|
||||
: `${workflow.title} · ${condition.title}`,
|
||||
progressLabel: `${workflowState.progress}%`,
|
||||
progressValue: workflowState.progress,
|
||||
currentStep: currentStepItem
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ScheduledConditionRecord, ScheduledConditionTaskId } from "../types";
|
||||
import { createVisibleTickerCards } from "./agent-task-ticker-utils";
|
||||
|
||||
function createRunningCondition(id: string, taskId: ScheduledConditionTaskId): ScheduledConditionRecord {
|
||||
return {
|
||||
id,
|
||||
kind: "condition",
|
||||
taskId,
|
||||
scheduledAt: "2026-07-08T10:00:00+08:00",
|
||||
title: id,
|
||||
summary: "Running condition",
|
||||
status: "running",
|
||||
riskLevel: "normal",
|
||||
updatedAt: Date.parse("2026-07-08T10:01:00+08:00"),
|
||||
sessionId: id,
|
||||
durationMinutes: 10
|
||||
};
|
||||
}
|
||||
|
||||
describe("createVisibleTickerCards", () => {
|
||||
it("wraps the final running condition back to the first cards", () => {
|
||||
const conditions = [
|
||||
createRunningCondition("condition-a", "scada-diagnosis"),
|
||||
createRunningCondition("condition-b", "smart-dispatch"),
|
||||
createRunningCondition("condition-c", "pump-energy"),
|
||||
createRunningCondition("condition-d", "network-simulation")
|
||||
];
|
||||
|
||||
const cards = createVisibleTickerCards(
|
||||
conditions,
|
||||
conditions.length - 1,
|
||||
Date.parse("2026-07-08T10:01:00+08:00")
|
||||
);
|
||||
|
||||
expect(cards).toHaveLength(3);
|
||||
expect(cards.map((card) => card.condition.id)).toEqual([
|
||||
"condition-d",
|
||||
"condition-a",
|
||||
"condition-b"
|
||||
]);
|
||||
expect(cards.map((card) => card.stackIndex)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("does not duplicate the same task to fill stack slots", () => {
|
||||
const conditions = [
|
||||
createRunningCondition("condition-a", "scada-diagnosis")
|
||||
];
|
||||
|
||||
const cards = createVisibleTickerCards(
|
||||
conditions,
|
||||
0,
|
||||
Date.parse("2026-07-08T10:01:00+08:00")
|
||||
);
|
||||
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards.map((card) => card.condition.id)).toEqual(["condition-a"]);
|
||||
});
|
||||
|
||||
it("uses two stack slots for two running tasks", () => {
|
||||
const conditions = [
|
||||
createRunningCondition("condition-a", "scada-diagnosis"),
|
||||
createRunningCondition("condition-b", "smart-dispatch")
|
||||
];
|
||||
|
||||
const cards = createVisibleTickerCards(
|
||||
conditions,
|
||||
1,
|
||||
Date.parse("2026-07-08T10:01:00+08:00")
|
||||
);
|
||||
|
||||
expect(cards).toHaveLength(2);
|
||||
expect(cards.map((card) => card.condition.id)).toEqual([
|
||||
"condition-b",
|
||||
"condition-a"
|
||||
]);
|
||||
expect(cards.map((card) => card.stackIndex)).toEqual([0, 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,504 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
ScheduledConditionRecord
|
||||
} from "@/features/workbench/types";
|
||||
import {
|
||||
createVisibleTickerCards,
|
||||
type TickerStackCard
|
||||
} from "./agent-task-ticker-utils";
|
||||
|
||||
type AgentTaskTickerProps = {
|
||||
conditions: ScheduledConditionRecord[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type TickerTransitionDirection = -1 | 1;
|
||||
|
||||
const tickerEase = [0.22, 1, 0.36, 1] as const;
|
||||
const tickerSwitchAnimationMs = 380;
|
||||
const tickerCardVariants: Variants = {
|
||||
initial: (direction: TickerTransitionDirection) => ({
|
||||
opacity: 0.62,
|
||||
x: 0,
|
||||
y: direction > 0 ? 36 : -28,
|
||||
rotate: 0
|
||||
}),
|
||||
exit: (direction: TickerTransitionDirection) => ({
|
||||
opacity: [1, 1, 0],
|
||||
x: 0,
|
||||
y: direction > 0 ? 36 : -28,
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
times: [0, 0.65, 1],
|
||||
ease: [0.5, 0, 0.2, 1]
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const tickerContentVariants: Variants = {
|
||||
initial: {
|
||||
clipPath: "inset(0 100% 0 0)"
|
||||
},
|
||||
animate: {
|
||||
clipPath: "inset(0 0% 0 0)",
|
||||
transition: {
|
||||
duration: 0.18,
|
||||
ease: tickerEase
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
clipPath: "inset(0 0 0 100%)",
|
||||
transition: {
|
||||
duration: 0.14,
|
||||
ease: [0.5, 0, 0.2, 1]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tickerIndicatorVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
x: 4,
|
||||
scale: 0.96
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.16,
|
||||
ease: tickerEase
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
x: 4,
|
||||
scale: 0.96,
|
||||
transition: {
|
||||
duration: 0.12,
|
||||
ease: [0.5, 0, 0.2, 1]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function AgentTaskTicker({
|
||||
conditions,
|
||||
className
|
||||
}: AgentTaskTickerProps) {
|
||||
const sectionRef = useRef<HTMLElement | null>(null);
|
||||
const isTaskSwitchLockedRef = useRef(false);
|
||||
const taskSwitchUnlockTimeoutRef = useRef<number | null>(null);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const [rotationIndex, setRotationIndex] = useState(0);
|
||||
const [transitionDirection, setTransitionDirection] = useState<TickerTransitionDirection>(1);
|
||||
const [taskSwitchAnimation, setTaskSwitchAnimation] = useState(() => ({
|
||||
id: 0,
|
||||
active: false
|
||||
}));
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isDocumentVisible, setIsDocumentVisible] = useState(() => (
|
||||
typeof document === "undefined" || document.visibilityState === "visible"
|
||||
));
|
||||
const [visibilityRevision, setVisibilityRevision] = useState(0);
|
||||
const runningConditions = conditions;
|
||||
const visibleTickerCards = useMemo(
|
||||
() => createVisibleTickerCards(runningConditions, rotationIndex, nowMs),
|
||||
[runningConditions, rotationIndex, nowMs]
|
||||
);
|
||||
const hasMultipleRunningTasks = runningConditions.length > 1;
|
||||
const activeIndicatorIndex = runningConditions.length > 0 ? rotationIndex % runningConditions.length : 0;
|
||||
const tickerLabel = hasMultipleRunningTasks
|
||||
? `工况任务步骤,${runningConditions.length} 个执行中,可滚动切换`
|
||||
: "工况任务步骤";
|
||||
|
||||
const releaseTaskSwitchLock = useCallback(() => {
|
||||
if (taskSwitchUnlockTimeoutRef.current !== null) {
|
||||
window.clearTimeout(taskSwitchUnlockTimeoutRef.current);
|
||||
taskSwitchUnlockTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
isTaskSwitchLockedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const finishTaskSwitchAnimation = useCallback(() => {
|
||||
setTaskSwitchAnimation((current) => (
|
||||
current.active ? { ...current, active: false } : current
|
||||
));
|
||||
}, []);
|
||||
|
||||
const lockTaskSwitch = useCallback(() => {
|
||||
isTaskSwitchLockedRef.current = true;
|
||||
|
||||
if (taskSwitchUnlockTimeoutRef.current !== null) {
|
||||
window.clearTimeout(taskSwitchUnlockTimeoutRef.current);
|
||||
}
|
||||
|
||||
taskSwitchUnlockTimeoutRef.current = window.setTimeout(() => {
|
||||
isTaskSwitchLockedRef.current = false;
|
||||
taskSwitchUnlockTimeoutRef.current = null;
|
||||
finishTaskSwitchAnimation();
|
||||
}, tickerSwitchAnimationMs);
|
||||
}, [finishTaskSwitchAnimation]);
|
||||
|
||||
const shiftTask = useCallback((direction: TickerTransitionDirection) => {
|
||||
if (runningConditions.length <= 1 || isTaskSwitchLockedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
lockTaskSwitch();
|
||||
setTransitionDirection(direction);
|
||||
setTaskSwitchAnimation((current) => ({
|
||||
id: current.id + 1,
|
||||
active: true
|
||||
}));
|
||||
setRotationIndex((current) => (
|
||||
(current + direction + runningConditions.length) % runningConditions.length
|
||||
));
|
||||
}, [lockTaskSwitch, runningConditions.length]);
|
||||
|
||||
const showPreviousTask = useCallback(() => {
|
||||
shiftTask(-1);
|
||||
}, [shiftTask]);
|
||||
|
||||
const showNextTask = useCallback(() => {
|
||||
shiftTask(1);
|
||||
}, [shiftTask]);
|
||||
|
||||
const handleWheel = useCallback((event: WheelEvent) => {
|
||||
if (!hasMultipleRunningTasks || event.deltaY === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (event.deltaY > 0) {
|
||||
showNextTask();
|
||||
return;
|
||||
}
|
||||
|
||||
showPreviousTask();
|
||||
}, [hasMultipleRunningTasks, showNextTask, showPreviousTask]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDocumentVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshNow = () => setNowMs(Date.now());
|
||||
|
||||
refreshNow();
|
||||
const intervalId = window.setInterval(() => {
|
||||
refreshNow();
|
||||
}, 1_000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [isDocumentVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
setRotationIndex(0);
|
||||
releaseTaskSwitchLock();
|
||||
finishTaskSwitchAnimation();
|
||||
}, [finishTaskSwitchAnimation, releaseTaskSwitchLock, runningConditions.length]);
|
||||
|
||||
useEffect(() => releaseTaskSwitchLock, [releaseTaskSwitchLock]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
const isVisible = document.visibilityState === "visible";
|
||||
|
||||
releaseTaskSwitchLock();
|
||||
finishTaskSwitchAnimation();
|
||||
setIsHovered(false);
|
||||
setIsDocumentVisible(isVisible);
|
||||
setVisibilityRevision((current) => current + 1);
|
||||
|
||||
if (isVisible) {
|
||||
setNowMs(Date.now());
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
}, [finishTaskSwitchAnimation, releaseTaskSwitchLock]);
|
||||
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
|
||||
section.addEventListener("wheel", handleWheel, { passive: false });
|
||||
|
||||
return () => section.removeEventListener("wheel", handleWheel);
|
||||
}, [handleWheel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMultipleRunningTasks || isHovered || !isDocumentVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
|
||||
shiftTask(1);
|
||||
}, 6_000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [hasMultipleRunningTasks, isDocumentVisible, isHovered, runningConditions.length, shiftTask]);
|
||||
|
||||
if (visibleTickerCards.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
className={cn(
|
||||
"agent-task-ticker pointer-events-auto relative h-[78px] overflow-visible",
|
||||
className
|
||||
)}
|
||||
aria-label={tickerLabel}
|
||||
title={tickerLabel}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<AnimatePresence
|
||||
key={`ticker-presence-${visibilityRevision}`}
|
||||
custom={transitionDirection}
|
||||
mode="popLayout"
|
||||
initial={false}
|
||||
>
|
||||
{visibleTickerCards
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((card) => (
|
||||
<TickerTaskCard
|
||||
key={`ticker-stack-slot-${card.stackIndex}-${card.stackIndex === 0 ? taskSwitchAnimation.id : 0}`}
|
||||
card={card}
|
||||
isActive={card.stackIndex === 0}
|
||||
animateContentChanges={card.stackIndex === 0 && !taskSwitchAnimation.active}
|
||||
transitionDirection={transitionDirection}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<div className="pointer-events-none absolute right-3 top-8 z-40 -translate-y-1/2" aria-hidden="true">
|
||||
<AnimatePresence initial={false}>
|
||||
{isHovered && hasMultipleRunningTasks ? (
|
||||
<motion.div
|
||||
key="task-indicator"
|
||||
className="agent-task-ticker-indicator surface-control flex flex-col gap-1 rounded-full border px-1 py-1"
|
||||
variants={tickerIndicatorVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
>
|
||||
{runningConditions.map((runningCondition, index) => (
|
||||
<span
|
||||
key={runningCondition.id}
|
||||
className={cn(
|
||||
"block h-1.5 w-1.5 rounded-sm transition-colors duration-150",
|
||||
index === activeIndicatorIndex ? "bg-blue-600" : "bg-slate-300"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TickerTaskCard({
|
||||
card,
|
||||
isActive,
|
||||
animateContentChanges,
|
||||
transitionDirection
|
||||
}: {
|
||||
card: TickerStackCard;
|
||||
isActive: boolean;
|
||||
animateContentChanges: boolean;
|
||||
transitionDirection: TickerTransitionDirection;
|
||||
}) {
|
||||
const { condition, view, stackIndex } = card;
|
||||
const stackStyle = getTickerStackStyle(stackIndex);
|
||||
const contentKey = view.currentStep
|
||||
? `${condition.id}-${view.currentStep.id}`
|
||||
: `${condition.id}-pending`;
|
||||
|
||||
return (
|
||||
<motion.article
|
||||
layout="position"
|
||||
className={cn(
|
||||
"absolute inset-x-0 top-0 h-16 overflow-hidden rounded-[22px] px-3 py-2",
|
||||
isActive ? "glass-transient z-30" : "surface-control pointer-events-none z-10 border"
|
||||
)}
|
||||
style={{ transformOrigin: "center top" }}
|
||||
custom={transitionDirection}
|
||||
variants={tickerCardVariants}
|
||||
initial="initial"
|
||||
animate={{
|
||||
opacity: stackStyle.opacity,
|
||||
x: stackStyle.x,
|
||||
y: stackStyle.y,
|
||||
rotate: stackStyle.rotate,
|
||||
transition: {
|
||||
duration: isActive ? 0.34 : 0.3,
|
||||
ease: tickerEase
|
||||
}
|
||||
}}
|
||||
exit="exit"
|
||||
aria-hidden={!isActive}
|
||||
data-condition-id={condition.id}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full",
|
||||
isActive ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
>
|
||||
<AnimatePresence key={condition.id} initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={contentKey}
|
||||
className="h-full overflow-hidden"
|
||||
variants={tickerContentVariants}
|
||||
initial={animateContentChanges ? "initial" : false}
|
||||
animate={animateContentChanges ? "animate" : undefined}
|
||||
exit={animateContentChanges ? "exit" : undefined}
|
||||
>
|
||||
<TickerTaskCardContent view={view} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
|
||||
function TickerTaskCardContent({ view }: { view: TickerStackCard["view"] }) {
|
||||
if (!view.currentStep) {
|
||||
return (
|
||||
<div className="grid h-full grid-cols-[14px_minmax(0,1fr)] items-center gap-3 pr-4">
|
||||
<TickerStatusDot progressValue={view.progressValue} />
|
||||
<div className="min-w-0">
|
||||
<span className="block truncate text-[13px] font-semibold leading-5 text-slate-500">
|
||||
{view.title}
|
||||
</span>
|
||||
<TickerProgressBar progressLabel={view.progressLabel} muted />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-cols-[14px_minmax(0,1fr)] items-center gap-3 pr-5">
|
||||
<TickerStatusDot progressValue={view.progressValue} />
|
||||
<div className="flex min-w-0 flex-col justify-center">
|
||||
<div className="min-w-0 pr-1">
|
||||
<span className="block truncate text-[13px] font-semibold leading-4 text-slate-500">
|
||||
{view.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0" title={`${view.currentStep.title}:${view.currentStep.detail}`}>
|
||||
<div className="grid min-w-0 grid-cols-[auto_4px_minmax(0,1fr)] items-center gap-2">
|
||||
<span className="min-w-0 truncate text-[13px] font-bold leading-5 text-slate-950">
|
||||
{view.currentStep.title}
|
||||
</span>
|
||||
<span className="h-1 w-1 rounded-full bg-slate-300" aria-hidden="true" />
|
||||
<span className="min-w-0 truncate text-[13px] font-medium leading-5 text-slate-500">
|
||||
{view.currentStep.detail}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<TickerProgressBar progressLabel={view.progressLabel} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TickerProgressBar({
|
||||
progressLabel,
|
||||
muted = false
|
||||
}: {
|
||||
progressLabel: string;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 h-0.5 overflow-hidden rounded-full",
|
||||
muted ? "bg-slate-200/60" : "bg-slate-200/80"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<motion.span
|
||||
className={cn("block h-full rounded-full", muted ? "bg-blue-300" : "bg-blue-500")}
|
||||
animate={{ width: progressLabel }}
|
||||
initial={false}
|
||||
transition={{ duration: 0.34, ease: tickerEase }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TickerStatusDot({ progressValue }: { progressValue?: number }) {
|
||||
const progress = typeof progressValue === "number" ? Math.min(100, Math.max(0, progressValue)) : 0;
|
||||
const dashOffset = 44 - (44 * progress) / 100;
|
||||
return (
|
||||
<span className="grid h-3.5 w-3.5 place-items-center" aria-hidden="true">
|
||||
<svg className="h-3.5 w-3.5 -rotate-90" viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill="none" strokeWidth="2" className="stroke-blue-100/90" />
|
||||
<motion.circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="7"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
className="stroke-blue-600"
|
||||
strokeDasharray="44"
|
||||
animate={{ strokeDashoffset: dashOffset }}
|
||||
initial={false}
|
||||
transition={{ duration: 0.34, ease: tickerEase }}
|
||||
/>
|
||||
<circle cx="8" cy="8" r="3.5" className="fill-blue-600" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getTickerStackStyle(stackIndex: number) {
|
||||
if (stackIndex === 1) {
|
||||
return {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 7,
|
||||
rotate: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (stackIndex === 2) {
|
||||
return {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 14,
|
||||
rotate: 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotate: 0
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getConditionReportRiskLevel } from "./condition-report-risk";
|
||||
|
||||
describe("getConditionReportRiskLevel", () => {
|
||||
it("maps error and warning statuses to their operational risk levels", () => {
|
||||
expect(getConditionReportRiskLevel("error", "normal")).toBe("critical");
|
||||
expect(getConditionReportRiskLevel("warning", "normal")).toBe("attention");
|
||||
});
|
||||
|
||||
it("preserves the explicit risk level for lifecycle-only statuses", () => {
|
||||
expect(getConditionReportRiskLevel("completed", "normal")).toBe("normal");
|
||||
expect(getConditionReportRiskLevel("completed", "attention")).toBe("attention");
|
||||
expect(getConditionReportRiskLevel("running", "critical")).toBe("critical");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type {
|
||||
ScheduledConditionRiskLevel,
|
||||
ScheduledConditionStatus
|
||||
} from "@/features/workbench/types";
|
||||
|
||||
export function getConditionReportRiskLevel(
|
||||
status: ScheduledConditionStatus,
|
||||
riskLevel: ScheduledConditionRiskLevel
|
||||
): ScheduledConditionRiskLevel {
|
||||
if (status === "error") {
|
||||
return "critical";
|
||||
}
|
||||
|
||||
if (status === "warning") {
|
||||
return "attention";
|
||||
}
|
||||
|
||||
return riskLevel;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ReactNode, SVGProps } from "react";
|
||||
|
||||
export type DrainageFeatureIconProps = SVGProps<SVGSVGElement> & {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
type DrainageIconFrameProps = DrainageFeatureIconProps & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function DrainageIconFrame({ children, size = 20, ...props }: DrainageIconFrameProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConduitFeatureIcon(props: DrainageFeatureIconProps) {
|
||||
return (
|
||||
<DrainageIconFrame {...props}>
|
||||
<circle cx="3.5" cy="12" r="1.75" />
|
||||
<circle cx="20.5" cy="12" r="1.75" />
|
||||
<path d="M5.25 9.5h13.5M5.25 14.5h13.5" />
|
||||
</DrainageIconFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function JunctionFeatureIcon(props: DrainageFeatureIconProps) {
|
||||
return (
|
||||
<DrainageIconFrame {...props}>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<circle cx="12" cy="12" r="1.75" />
|
||||
<path d="M12 2.5V7M2.5 12H7M17 12h4.5M12 17v4.5" />
|
||||
</DrainageIconFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScadaFeatureIcon(props: DrainageFeatureIconProps) {
|
||||
return (
|
||||
<DrainageIconFrame {...props}>
|
||||
<circle cx="12" cy="7.5" r="1.75" />
|
||||
<path d="M8.25 4.25a5.25 5.25 0 0 0 0 6.5M15.75 4.25a5.25 5.25 0 0 1 0 6.5" />
|
||||
<path d="M5.25 2.25a9 9 0 0 0 0 10.5M18.75 2.25a9 9 0 0 1 0 10.5" />
|
||||
<path d="M12 9.25v5.25M3.5 18h4l1.5-3 2.75 6 2.5-5 1.25 2h5" />
|
||||
</DrainageIconFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrificeFeatureIcon(props: DrainageFeatureIconProps) {
|
||||
return (
|
||||
<DrainageIconFrame {...props}>
|
||||
<path d="M2.5 12H9M15 12h6.5" />
|
||||
<path d="M9.5 4v5.35M9.5 14.65V20M14.5 4v5.35M14.5 14.65V20" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</DrainageIconFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function PumpFeatureIcon(props: DrainageFeatureIconProps) {
|
||||
return (
|
||||
<DrainageIconFrame {...props}>
|
||||
<circle cx="9.5" cy="13.5" r="6" />
|
||||
<circle cx="9.5" cy="13.5" r="1.25" />
|
||||
<path d="M9.5 12.25V8.5M10.75 13.5h3.75M8.65 14.4 6.2 16.85" />
|
||||
<path d="M9.5 7.5V3.5h10v5M3.5 13.5H1.75" />
|
||||
<path d="m17 6 2.5 2.5L22 6" />
|
||||
</DrainageIconFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function OutfallFeatureIcon(props: DrainageFeatureIconProps) {
|
||||
return (
|
||||
<DrainageIconFrame {...props}>
|
||||
<path d="M4 4.5h10v7h5" />
|
||||
<path d="m16.5 8.75 2.75 2.75-2.75 2.75" />
|
||||
<path d="M3 18c1.5-1.25 3-1.25 4.5 0s3 1.25 4.5 0 3-1.25 4.5 0 3 1.25 4.5 0M5 21c1.15-.75 2.3-.75 3.45 0s2.3.75 3.45 0 2.3-.75 3.45 0 2.3.75 3.45 0" />
|
||||
</DrainageIconFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Copy, Target, TriangleAlert, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { showMapNotice } from "@/features/map/core/components/notice-actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SCADA_ICON_PATHS } from "../map/scada";
|
||||
import type { DetailFeature } from "../types";
|
||||
import {
|
||||
formatFeaturePropertyValue,
|
||||
getFeatureInsightMetrics,
|
||||
getLocalizedFeatureProperties
|
||||
} from "../utils/feature-properties";
|
||||
import { FeaturePropertyList } from "./feature-property-list";
|
||||
|
||||
type FeatureInsightPanelProps = {
|
||||
feature: DetailFeature | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelProps) {
|
||||
const [copiedScadaUuid, setCopiedScadaUuid] = useState(false);
|
||||
const copyResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (copyResetTimeoutRef.current) {
|
||||
clearTimeout(copyResetTimeoutRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const metrics = useMemo(() => {
|
||||
if (!feature) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getFeatureInsightMetrics(feature.layer, feature.properties);
|
||||
}, [feature]);
|
||||
|
||||
const propertyEntries = useMemo(() => {
|
||||
if (!feature) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return feature.layer === "scada"
|
||||
? []
|
||||
: getLocalizedFeatureProperties(feature.properties);
|
||||
}, [feature]);
|
||||
|
||||
const scadaQueryUuid = feature?.layer === "scada"
|
||||
? getCopyablePropertyValue(feature.properties.sensor_id ?? feature.id)
|
||||
: null;
|
||||
|
||||
const handleCopyScadaUuid = async () => {
|
||||
if (!scadaQueryUuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!navigator.clipboard) {
|
||||
throw new Error("Clipboard API unavailable");
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(scadaQueryUuid);
|
||||
setCopiedScadaUuid(true);
|
||||
|
||||
if (copyResetTimeoutRef.current) {
|
||||
clearTimeout(copyResetTimeoutRef.current);
|
||||
}
|
||||
|
||||
copyResetTimeoutRef.current = setTimeout(() => setCopiedScadaUuid(false), 1500);
|
||||
} catch {
|
||||
showMapNotice({
|
||||
tone: "error",
|
||||
title: "无法复制 UUID",
|
||||
message: "请手动选择查询 UUID 后复制。"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="acrylic-panel pointer-events-auto flex h-full min-h-0 flex-col rounded border">
|
||||
<div className="border-b border-slate-200/80 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium tracking-[0.14em] text-slate-500">要素详情</p>
|
||||
<h2 className="mt-1 text-lg font-semibold text-slate-950">
|
||||
{feature ? feature.title : "选择一个管网对象"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭详情"
|
||||
title="关闭详情"
|
||||
onClick={onClose}
|
||||
className="surface-control grid h-9 w-9 place-items-center rounded border text-slate-500 hover:text-slate-900"
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{feature ? feature.subtitle : "点击地图上的管线或节点后,这里会展示属性、SCADA 摘要和 Agent 解释。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{feature ? (
|
||||
<>
|
||||
{feature.layer === "scada" ? (
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-900">监测数据</h3>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{metrics.map(({ label, value }, index) => (
|
||||
<div
|
||||
key={label}
|
||||
className={cn(
|
||||
"surface-reading rounded border p-3",
|
||||
feature.layer === "scada" && index === metrics.length - 1 && "col-span-2"
|
||||
)}
|
||||
>
|
||||
<p className="text-xs text-slate-500">{label}</p>
|
||||
<p className={cn(
|
||||
"mt-1 truncate text-sm font-semibold text-slate-900 tabular-nums",
|
||||
value === "暂无" && "font-medium text-slate-400"
|
||||
)}>{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{feature.layer === "scada" ? (
|
||||
<section className={cn("mt-4 rounded border p-3", getScadaPresentation(feature.properties.kind).sectionClassName)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={getScadaPresentation(feature.properties.kind).iconPath}
|
||||
alt=""
|
||||
width={42}
|
||||
height={42}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className={cn("truncate text-sm font-semibold", getScadaPresentation(feature.properties.kind).labelClassName)}>{formatFeaturePropertyValue("sensor_name", feature.properties.sensor_name)}</p>
|
||||
<p className="mt-0.5 truncate text-xs text-slate-500 tabular-nums">{formatFeaturePropertyValue("sensor_id", feature.properties.sensor_id ?? feature.id)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{scadaQueryUuid ? (
|
||||
<div className="mt-3 rounded border border-white/70 bg-white/65 px-3 py-2 shadow-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold text-slate-500">传感器 ID</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
|
||||
title={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
|
||||
onClick={() => void handleCopyScadaUuid()}
|
||||
className="relative grid h-8 w-8 shrink-0 place-items-center rounded border border-slate-200/80 bg-white/80 text-slate-500 transition-[background-color,color,transform] duration-150 hover:text-slate-950 active:scale-95 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
||||
>
|
||||
<Copy size={14} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copiedScadaUuid ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
|
||||
<Check size={15} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copiedScadaUuid ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 break-all font-mono text-xs font-semibold leading-5 text-slate-900">{scadaQueryUuid}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">SCADA 时序查询 sensor_id</p>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="mt-2 text-sm leading-6 text-slate-700">设备类型为综合监测点,数据源为 geo_scadas_mat。</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="material-tone-warning mt-4 rounded border border-orange-100 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-orange-900">
|
||||
<TriangleAlert size={16} aria-hidden="true" />
|
||||
Agent 解释
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-700">
|
||||
该对象来自 GeoServer MVT。当前仅执行前端预览,不会写入样式、模型参数或调度工单。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{feature.layer !== "scada" ? (
|
||||
<section className="mt-4">
|
||||
<h3 className="text-sm font-semibold text-slate-900">属性</h3>
|
||||
<div className="mt-2">
|
||||
<FeaturePropertyList entries={propertyEntries} />
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<div className="surface-reading grid h-12 w-12 place-items-center rounded border text-slate-500">
|
||||
<Target size={22} aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-slate-800">等待地图选择</p>
|
||||
<p className="mt-1 max-w-[260px] text-sm leading-6 text-slate-500">
|
||||
选择要素后,可查看基础属性、模拟入口与 Agent 会话联动结果。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="sr-only" aria-live="polite">{copiedScadaUuid ? "编号 UUID 已复制" : ""}</span>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function getCopyablePropertyValue(value: unknown) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
if (typeof value === "number" || typeof value === "bigint") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getScadaPresentation(rawKind: unknown) {
|
||||
const kind = String(rawKind ?? "");
|
||||
if (kind === "water_quality") return { iconPath: SCADA_ICON_PATHS.waterQuality, sectionClassName: "material-tone-normal border-teal-200", labelClassName: "text-teal-800" };
|
||||
if (kind === "radar_level") return { iconPath: SCADA_ICON_PATHS.radarLevel, sectionClassName: "material-tone-info border-blue-200", labelClassName: "text-blue-800" };
|
||||
if (kind === "ultrasonic_flow") return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, sectionClassName: "material-tone-warning border-orange-200", labelClassName: "text-orange-800" };
|
||||
if (kind === "integrated_monitoring" || kind === "综合监测点") return { iconPath: SCADA_ICON_PATHS.integratedMonitoring, sectionClassName: "material-tone-normal border-cyan-200", labelClassName: "text-cyan-800" };
|
||||
return { iconPath: SCADA_ICON_PATHS.integratedMonitoring, sectionClassName: "material-tone-normal border-cyan-200", labelClassName: "text-cyan-800" };
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Copy, Database, X } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
MAP_FOCUS_SURFACE_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import { showMapNotice } from "@/features/map/core/components/notice-actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WaterNetworkSourceId } from "../map/sources";
|
||||
import type { DetailFeature } from "../types";
|
||||
import {
|
||||
getFeaturePanelModel,
|
||||
type FeaturePanelBadgeTone
|
||||
} from "../utils/feature-properties";
|
||||
import {
|
||||
ConduitFeatureIcon,
|
||||
JunctionFeatureIcon,
|
||||
OrificeFeatureIcon,
|
||||
OutfallFeatureIcon,
|
||||
PumpFeatureIcon,
|
||||
ScadaFeatureIcon,
|
||||
type DrainageFeatureIconProps
|
||||
} from "./drainage-feature-icons";
|
||||
import { FeaturePropertyList } from "./feature-property-list";
|
||||
|
||||
type FeaturePopoverProps = {
|
||||
feature: DetailFeature | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const FEATURE_LAYER_META: Record<
|
||||
WaterNetworkSourceId,
|
||||
{
|
||||
icon: ComponentType<DrainageFeatureIconProps>;
|
||||
label: string;
|
||||
headerClassName: string;
|
||||
labelClassName: string;
|
||||
iconClassName: string;
|
||||
}
|
||||
> = {
|
||||
conduits: { icon: ConduitFeatureIcon, label: "管渠", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" },
|
||||
junctions: { icon: JunctionFeatureIcon, label: "检查井", headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800", iconClassName: "bg-sky-700 text-white ring-sky-900/10" },
|
||||
orifices: { icon: OrificeFeatureIcon, label: "孔口", headerClassName: "bg-slate-50/70", labelClassName: "text-slate-700", iconClassName: "bg-slate-600 text-white ring-slate-900/10" },
|
||||
outfalls: { icon: OutfallFeatureIcon, label: "排放口", headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800", iconClassName: "bg-slate-700 text-white ring-slate-900/10" },
|
||||
pumps: { icon: PumpFeatureIcon, label: "泵", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-cyan-800 text-white ring-cyan-950/10" },
|
||||
scada: { icon: ScadaFeatureIcon, label: "SCADA 测点", headerClassName: "bg-cyan-50/70", labelClassName: "text-cyan-800", iconClassName: "bg-cyan-800 text-white ring-cyan-950/10" }
|
||||
};
|
||||
|
||||
const BADGE_CLASS_NAMES: Record<FeaturePanelBadgeTone, string> = {
|
||||
active: "bg-emerald-50 text-emerald-700 ring-emerald-600/15",
|
||||
inactive: "bg-slate-100 text-slate-600 ring-slate-500/15",
|
||||
warning: "bg-orange-50 text-orange-700 ring-orange-600/15",
|
||||
critical: "bg-red-50 text-red-700 ring-red-600/15",
|
||||
info: "bg-blue-50 text-blue-700 ring-blue-600/15"
|
||||
};
|
||||
|
||||
const BADGE_DOT_CLASS_NAMES: Record<FeaturePanelBadgeTone, string> = {
|
||||
active: "bg-emerald-500",
|
||||
inactive: "bg-slate-400",
|
||||
warning: "bg-orange-500",
|
||||
critical: "bg-red-500",
|
||||
info: "bg-blue-500"
|
||||
};
|
||||
|
||||
export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const copyResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (copyResetTimeoutRef.current) {
|
||||
clearTimeout(copyResetTimeoutRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!feature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const panelModel = getFeaturePanelModel(feature.layer, feature.properties, feature.id);
|
||||
const layerMeta = FEATURE_LAYER_META[feature.layer];
|
||||
const LayerIcon = layerMeta.icon;
|
||||
const copied = copiedId === panelModel.id;
|
||||
const canCopy = panelModel.id !== "暂无";
|
||||
|
||||
const handleCopyId = async () => {
|
||||
try {
|
||||
if (!navigator.clipboard) {
|
||||
throw new Error("Clipboard API unavailable");
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(panelModel.id);
|
||||
setCopiedId(panelModel.id);
|
||||
|
||||
if (copyResetTimeoutRef.current) {
|
||||
clearTimeout(copyResetTimeoutRef.current);
|
||||
}
|
||||
|
||||
copyResetTimeoutRef.current = setTimeout(() => setCopiedId(null), 1500);
|
||||
} catch {
|
||||
showMapNotice({
|
||||
tone: "error",
|
||||
title: "无法复制编号",
|
||||
message: "请手动选择编号后复制。"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] -translate-x-1/2 overflow-hidden",
|
||||
MAP_FOCUS_SURFACE_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<header className={cn("px-4 pb-3 pt-4", layerMeta.headerClassName)}>
|
||||
<div className="grid grid-cols-[48px_minmax(0,1fr)_auto] items-start gap-3">
|
||||
<span className={cn("grid h-12 w-12 place-items-center rounded-full ring-1", layerMeta.iconClassName)} aria-hidden="true">
|
||||
<LayerIcon size={24} />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-nowrap items-center gap-1.5">
|
||||
<span className={cn("min-w-0 truncate text-xs font-semibold", layerMeta.labelClassName)}>{layerMeta.label}</span>
|
||||
{panelModel.badge ? (
|
||||
<span className={cn("inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-semibold leading-4 ring-1 ring-inset", BADGE_CLASS_NAMES[panelModel.badge.tone])}>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", BADGE_DOT_CLASS_NAMES[panelModel.badge.tone])} aria-hidden="true" />
|
||||
{panelModel.badge.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<h3 className="mt-1 truncate text-base font-semibold text-slate-950">{feature.title}</h3>
|
||||
<p className="mt-0.5 truncate text-xs text-slate-600 tabular-nums">编号 {panelModel.id}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
{canCopy ? (
|
||||
<button type="button" aria-label={copied ? "编号已复制" : "复制编号"} title={copied ? "编号已复制" : "复制编号"} onClick={() => void handleCopyId()} className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
||||
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
|
||||
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" aria-label="关闭要素信息" title="关闭要素信息" onClick={onClose} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="px-4 py-3">
|
||||
{panelModel.attributes.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-500">
|
||||
<Database size={14} aria-hidden="true" />
|
||||
设施属性
|
||||
</div>
|
||||
<FeaturePropertyList entries={panelModel.attributes} variant="popover" />
|
||||
</>
|
||||
) : (
|
||||
<p className="py-3 text-center text-sm text-slate-500">暂无可展示属性</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{copied ? "编号已复制" : ""}
|
||||
</span>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LocalizedFeatureProperty } from "../utils/feature-properties";
|
||||
|
||||
type FeaturePropertyListProps = {
|
||||
entries: LocalizedFeatureProperty[];
|
||||
variant?: "insight" | "popover";
|
||||
};
|
||||
|
||||
export function FeaturePropertyList({
|
||||
entries,
|
||||
variant = "insight"
|
||||
}: FeaturePropertyListProps) {
|
||||
if (entries.length === 0) {
|
||||
return <p className="py-3 text-center text-sm text-slate-500">暂无可展示属性</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className={cn(
|
||||
"surface-reading divide-y divide-slate-100 border",
|
||||
variant === "popover" ? "rounded-xl px-3" : "rounded"
|
||||
)}>
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className={cn(
|
||||
"grid",
|
||||
variant === "popover"
|
||||
? "min-h-10 grid-cols-[96px_minmax(0,1fr)] items-baseline gap-3 py-2.5"
|
||||
: "grid-cols-[110px_minmax(0,1fr)] gap-2 px-3 py-2 text-sm"
|
||||
)}
|
||||
>
|
||||
<dt className={cn(
|
||||
"text-slate-500",
|
||||
variant === "popover" ? "text-xs font-medium leading-5" : "truncate"
|
||||
)}>
|
||||
{entry.label}
|
||||
</dt>
|
||||
<dd className={cn(
|
||||
"font-medium text-slate-800",
|
||||
variant === "popover"
|
||||
? "break-words text-right text-sm font-semibold leading-5 tabular-nums"
|
||||
: "truncate",
|
||||
entry.value === "暂无" && "font-medium text-slate-400"
|
||||
)}>
|
||||
{entry.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
type IconButtonProps = {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export function IconButton({ label, children, onClick, active = false }: IconButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
className={`grid h-10 w-10 place-items-center rounded border transition ${
|
||||
active
|
||||
? "border-slate-800 bg-slate-900 text-white"
|
||||
: "surface-control border-transparent text-slate-700 hover:bg-white"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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-hidden 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";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Loader2,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
ScheduledConditionItem,
|
||||
ScheduledConditionStatus,
|
||||
ScheduledWorkOrderItem,
|
||||
ScheduledWorkOrderStage
|
||||
} from "@/features/workbench/types";
|
||||
|
||||
export const CONDITION_FILTER_ALL = "__all__";
|
||||
|
||||
export const statusLabels: Record<ScheduledConditionStatus, string> = {
|
||||
completed: "完成",
|
||||
running: "执行中",
|
||||
warning: "关注",
|
||||
error: "异常",
|
||||
pending: "预约"
|
||||
};
|
||||
|
||||
export const statusClassNames: Record<ScheduledConditionStatus, string> = {
|
||||
completed: "border-green-100 bg-green-50 text-green-700",
|
||||
running: "border-blue-100 bg-blue-50 text-blue-700",
|
||||
warning: "border-orange-100 bg-orange-50 text-orange-700",
|
||||
error: "border-red-100 bg-red-50 text-red-700",
|
||||
pending: "border-slate-200 bg-slate-100 text-slate-600"
|
||||
};
|
||||
|
||||
const statusIcons: Record<ScheduledConditionStatus, typeof CheckCircle2> = {
|
||||
completed: CheckCircle2,
|
||||
running: Loader2,
|
||||
warning: AlertTriangle,
|
||||
error: XCircle,
|
||||
pending: Clock3
|
||||
};
|
||||
|
||||
const statusIconClassNames: Record<ScheduledConditionStatus, string> = {
|
||||
completed: "bg-green-50 text-green-700",
|
||||
running: "bg-blue-50 text-blue-700",
|
||||
warning: "bg-orange-50 text-orange-700",
|
||||
error: "bg-red-50 text-red-700",
|
||||
pending: "bg-slate-100 text-slate-600"
|
||||
};
|
||||
|
||||
const conditionStatusOrder: ScheduledConditionStatus[] = ["running", "warning", "error", "completed", "pending"];
|
||||
|
||||
export type ConditionTaskFilterValue = typeof CONDITION_FILTER_ALL | string;
|
||||
export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionStatus;
|
||||
|
||||
export type ConditionTaskFilterOption = {
|
||||
value: ConditionTaskFilterValue;
|
||||
label: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type ConditionStatusFilterOption = {
|
||||
value: ScheduledConditionStatus;
|
||||
label: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export function groupScheduledConditions(conditions: ScheduledConditionItem[]) {
|
||||
const recentConditions = conditions
|
||||
.filter((condition) => condition.kind === "condition")
|
||||
.sort((a, b) => getConditionTime(b) - getConditionTime(a));
|
||||
|
||||
const futureWorkOrders = conditions
|
||||
.filter((condition) => condition.kind === "work_order")
|
||||
.sort((a, b) => getConditionTime(a) - getConditionTime(b));
|
||||
|
||||
return { recentConditions, futureWorkOrders };
|
||||
}
|
||||
|
||||
export function createTaskFilterOptions(conditions: ScheduledConditionItem[]): ConditionTaskFilterOption[] {
|
||||
const taskCounts = new Map<string, number>();
|
||||
|
||||
conditions.forEach((condition) => {
|
||||
taskCounts.set(condition.title, (taskCounts.get(condition.title) ?? 0) + 1);
|
||||
});
|
||||
|
||||
return [...taskCounts.entries()]
|
||||
.map(([label, count]) => ({ value: label, label, count }))
|
||||
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, "zh-CN"));
|
||||
}
|
||||
|
||||
export function createStatusFilterOptions(conditions: ScheduledConditionItem[]): ConditionStatusFilterOption[] {
|
||||
const statusCounts = new Map<ScheduledConditionStatus, number>();
|
||||
|
||||
conditions.forEach((condition) => {
|
||||
statusCounts.set(condition.status, (statusCounts.get(condition.status) ?? 0) + 1);
|
||||
});
|
||||
|
||||
return conditionStatusOrder.map((status) => ({
|
||||
value: status,
|
||||
label: statusLabels[status],
|
||||
count: statusCounts.get(status) ?? 0
|
||||
}));
|
||||
}
|
||||
|
||||
export function getConditionTime(condition: ScheduledConditionItem) {
|
||||
return Date.parse(condition.scheduledAt);
|
||||
}
|
||||
|
||||
export function formatTime(value: string) {
|
||||
const match = value.match(/T(\d{2}:\d{2})/);
|
||||
return match?.[1] ?? value;
|
||||
}
|
||||
|
||||
export function formatTimeRange(value: string, durationMinutes: number | undefined) {
|
||||
const startTime = formatTime(value);
|
||||
if (typeof durationMinutes !== "number") {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
const endDate = new Date(Date.parse(value) + durationMinutes * 60_000);
|
||||
const endTime = `${endDate.getHours().toString().padStart(2, "0")}:${endDate.getMinutes().toString().padStart(2, "0")}`;
|
||||
return `${startTime} - ${endTime}`;
|
||||
}
|
||||
|
||||
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionStatus) {
|
||||
if (status === "running") {
|
||||
return "执行中";
|
||||
}
|
||||
|
||||
if (typeof durationMinutes !== "number") {
|
||||
return "未记录";
|
||||
}
|
||||
|
||||
return `${durationMinutes} 分钟`;
|
||||
}
|
||||
|
||||
export function getRiskLabel(riskLevel: ScheduledConditionItem["riskLevel"]) {
|
||||
if (riskLevel === "critical") {
|
||||
return "高风险";
|
||||
}
|
||||
|
||||
if (riskLevel === "attention") {
|
||||
return "关注";
|
||||
}
|
||||
|
||||
return "正常";
|
||||
}
|
||||
|
||||
export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) {
|
||||
return (
|
||||
condition.stages.find((stage) => stage.status === "current") ??
|
||||
condition.stages.find((stage) => stage.status === "pending") ??
|
||||
condition.stages.at(-1) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorkOrderStagePillClassName(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||
if (stageId === "execution") {
|
||||
return "border-blue-100 bg-blue-50 text-blue-700";
|
||||
}
|
||||
|
||||
if (stageId === "reply") {
|
||||
return "border-green-100 bg-green-50 text-green-700";
|
||||
}
|
||||
|
||||
return "border-indigo-100 bg-indigo-50 text-indigo-700";
|
||||
}
|
||||
|
||||
export function getStatusIcon(status: ScheduledConditionStatus) {
|
||||
return statusIcons[status];
|
||||
}
|
||||
|
||||
export function getStatusIconClassName(status: ScheduledConditionStatus) {
|
||||
return statusIconClassNames[status];
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CalendarClock,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
History,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { MAP_TOOL_PANEL_SURFACE_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
|
||||
import { showMapNotice } from "@/features/map/core/components/notice-actions";
|
||||
import { isGeneratedScheduledConditionSessionId } from "@/features/workbench/data/scheduled-conditions";
|
||||
import type { ScheduledConditionItem } from "@/features/workbench/types";
|
||||
import { createConditionConversationPrompt } from "@/features/workbench/utils/scheduled-condition-prompts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
|
||||
import {
|
||||
CONDITION_FILTER_ALL,
|
||||
createStatusFilterOptions,
|
||||
createTaskFilterOptions,
|
||||
formatTime,
|
||||
getConditionTime,
|
||||
getStatusIcon,
|
||||
getStatusIconClassName,
|
||||
groupScheduledConditions,
|
||||
type ConditionStatusFilterOption,
|
||||
type ConditionStatusFilterValue,
|
||||
type ConditionTaskFilterOption,
|
||||
type ConditionTaskFilterValue
|
||||
} from "./scheduled-condition-feed-utils";
|
||||
|
||||
const CONDITION_CONTENT_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
|
||||
const CONDITION_CONTROL_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
|
||||
|
||||
type ScheduledConditionFeedProps = {
|
||||
conditions?: ScheduledConditionItem[];
|
||||
expanded?: boolean;
|
||||
focusRequest?: ScheduledConditionFeedFocusRequest | null;
|
||||
loading?: boolean;
|
||||
visible?: boolean;
|
||||
selectedConditionId?: string | null;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
onFocusRequestHandled?: (requestId: number) => void;
|
||||
onSelectedConditionChange?: (conditionId: string | null) => void;
|
||||
onExpandAgent: () => void;
|
||||
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
|
||||
onSubmitPrompt: (prompt: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type ScheduledConditionFeedFocusRequest = {
|
||||
conditionId: string;
|
||||
requestId: number;
|
||||
};
|
||||
|
||||
export function ScheduledConditionFeed({
|
||||
conditions = [],
|
||||
expanded: controlledExpanded,
|
||||
focusRequest,
|
||||
loading = false,
|
||||
visible = true,
|
||||
selectedConditionId: controlledSelectedConditionId,
|
||||
onExpandedChange,
|
||||
onFocusRequestHandled,
|
||||
onSelectedConditionChange,
|
||||
onExpandAgent,
|
||||
onLoadHistorySession,
|
||||
onSubmitPrompt
|
||||
}: ScheduledConditionFeedProps) {
|
||||
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
|
||||
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<string | null>(null);
|
||||
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
|
||||
const [statusFilter, setStatusFilter] = useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
||||
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
||||
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
||||
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
|
||||
() => groupScheduledConditions(conditions),
|
||||
[conditions]
|
||||
);
|
||||
const allTimelineConditions = useMemo(
|
||||
() => [...allRecentConditions, ...allFutureWorkOrders],
|
||||
[allFutureWorkOrders, allRecentConditions]
|
||||
);
|
||||
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
|
||||
const taskFilteredConditions = useMemo(
|
||||
() => conditions.filter((condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter),
|
||||
[conditions, taskFilter]
|
||||
);
|
||||
const statusFilterOptions = useMemo(() => createStatusFilterOptions(taskFilteredConditions), [taskFilteredConditions]);
|
||||
const taskFilteredConditionCount = taskFilteredConditions.length;
|
||||
const filteredConditions = useMemo(
|
||||
() =>
|
||||
taskFilteredConditions.filter(
|
||||
(condition) => statusFilter === CONDITION_FILTER_ALL || condition.status === statusFilter
|
||||
),
|
||||
[statusFilter, taskFilteredConditions]
|
||||
);
|
||||
const { recentConditions, futureWorkOrders } = useMemo(() => groupScheduledConditions(filteredConditions), [filteredConditions]);
|
||||
const timelineConditions = useMemo(
|
||||
() => [...recentConditions, ...futureWorkOrders],
|
||||
[futureWorkOrders, recentConditions]
|
||||
);
|
||||
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
||||
const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length;
|
||||
const filteredConditionCount = recentConditions.length + futureWorkOrders.length;
|
||||
const headerSummary = loading
|
||||
? "正在加载工况任务"
|
||||
: expanded && filterActive
|
||||
? `筛选 ${filteredConditionCount}/${totalConditionCount} 条 · 预约 ${futureWorkOrders.length}/${allFutureWorkOrders.length} 条`
|
||||
: expanded
|
||||
? `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条`
|
||||
: `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条`;
|
||||
const selectedCondition =
|
||||
timelineConditions.find((condition) => condition.id === selectedConditionId) ??
|
||||
recentConditions[0] ??
|
||||
futureWorkOrders[0] ??
|
||||
null;
|
||||
const focusConditionId = focusRequest?.conditionId;
|
||||
const focusRequestId = focusRequest?.requestId;
|
||||
const updateExpanded = useCallback(
|
||||
(nextExpanded: boolean) => {
|
||||
if (controlledExpanded === undefined) {
|
||||
setUncontrolledExpanded(nextExpanded);
|
||||
}
|
||||
|
||||
onExpandedChange?.(nextExpanded);
|
||||
},
|
||||
[controlledExpanded, onExpandedChange]
|
||||
);
|
||||
const updateSelectedConditionId = useCallback(
|
||||
(nextConditionId: string | null) => {
|
||||
if (controlledSelectedConditionId === undefined) {
|
||||
setUncontrolledSelectedConditionId(nextConditionId);
|
||||
}
|
||||
|
||||
onSelectedConditionChange?.(nextConditionId);
|
||||
},
|
||||
[controlledSelectedConditionId, onSelectedConditionChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (taskFilter === CONDITION_FILTER_ALL) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!taskFilterOptions.some((option) => option.value === taskFilter)) {
|
||||
setTaskFilter(CONDITION_FILTER_ALL);
|
||||
}
|
||||
}, [taskFilter, taskFilterOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSelectedConditionId = selectedCondition?.id ?? null;
|
||||
if (selectedConditionId !== nextSelectedConditionId) {
|
||||
updateSelectedConditionId(nextSelectedConditionId);
|
||||
}
|
||||
}, [expanded, selectedCondition?.id, selectedConditionId, updateSelectedConditionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusConditionId || focusRequestId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetCondition = allTimelineConditions.find((condition) => condition.id === focusConditionId);
|
||||
if (!targetCondition) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTaskFilter(CONDITION_FILTER_ALL);
|
||||
setStatusFilter(CONDITION_FILTER_ALL);
|
||||
updateSelectedConditionId(targetCondition.id);
|
||||
updateExpanded(true);
|
||||
onFocusRequestHandled?.(focusRequestId);
|
||||
}, [allTimelineConditions, focusConditionId, focusRequestId, onFocusRequestHandled, updateExpanded, updateSelectedConditionId]);
|
||||
|
||||
function handleToggleExpanded() {
|
||||
updateExpanded(!expanded);
|
||||
updateSelectedConditionId(selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null);
|
||||
}
|
||||
|
||||
function handleSelectTimelineCondition(conditionId: string) {
|
||||
updateSelectedConditionId(conditionId);
|
||||
if (!expanded) {
|
||||
updateExpanded(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleContinueConversation(condition: ScheduledConditionItem) {
|
||||
if (condition.kind === "work_order") {
|
||||
return;
|
||||
}
|
||||
|
||||
onExpandAgent();
|
||||
|
||||
if (!condition.sessionId) {
|
||||
showMapNotice({
|
||||
id: "scheduled-condition-missing-session",
|
||||
tone: "error",
|
||||
title: "无法打开历史会话",
|
||||
message: "这条工况没有可加载的会话记录。"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGeneratedScheduledConditionSessionId(condition.sessionId)) {
|
||||
void Promise.resolve(onSubmitPrompt(createConditionConversationPrompt(condition)))
|
||||
.then(() => {
|
||||
showMapNotice({
|
||||
id: "scheduled-condition-generated-session",
|
||||
tone: "success",
|
||||
title: "已发起工况对话",
|
||||
message: "已将当前工况上下文发送到 Agent 面板。"
|
||||
});
|
||||
})
|
||||
.catch((submitError) => {
|
||||
showMapNotice({
|
||||
id: "scheduled-condition-generated-session",
|
||||
tone: "error",
|
||||
title: "工况对话发起失败",
|
||||
message: submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void Promise.resolve(onLoadHistorySession(condition.sessionId));
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="工况任务"
|
||||
aria-hidden={!visible}
|
||||
className={cn(
|
||||
"scheduled-feed-panel-shell pointer-events-auto max-w-[calc(100vw-6rem)] overflow-hidden p-3 motion-reduce:transition-none",
|
||||
visible ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit",
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
|
||||
"rounded-2xl"
|
||||
)}
|
||||
>
|
||||
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", CONDITION_CONTROL_SURFACE_CLASS_NAME)}>
|
||||
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", "rounded-lg")}>
|
||||
<CalendarClock size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">工况任务</h2>
|
||||
<p className="mt-0.5 truncate text-xs text-slate-500">{headerSummary}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={handleToggleExpanded}
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center text-slate-500 transition hover:bg-blue-50 hover:text-blue-700",
|
||||
"rounded-lg"
|
||||
)}
|
||||
>
|
||||
<ChevronDown
|
||||
size={17}
|
||||
aria-hidden="true"
|
||||
className={cn("transition-transform duration-150", expanded && "rotate-180")}
|
||||
/>
|
||||
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"scheduled-feed-layout mt-3 grid min-h-0",
|
||||
expanded ? "scheduled-feed-layout-expanded" : "scheduled-feed-layout-collapsed"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden",
|
||||
expanded ? "scheduled-feed-detail-enter" : "pointer-events-none opacity-0"
|
||||
)}
|
||||
aria-hidden={!expanded}
|
||||
>
|
||||
{loading && expanded ? (
|
||||
<ConditionDetailSkeleton />
|
||||
) : expanded && selectedCondition ? (
|
||||
<ConditionDetailPanel
|
||||
condition={selectedCondition}
|
||||
onContinueConversation={() => handleContinueConversation(selectedCondition)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 overflow-hidden">
|
||||
<ConditionTimelinePanel
|
||||
expanded={expanded}
|
||||
loading={loading}
|
||||
recentConditions={recentConditions}
|
||||
futureWorkOrders={futureWorkOrders}
|
||||
taskFilter={taskFilter}
|
||||
statusFilter={statusFilter}
|
||||
taskFilterOptions={taskFilterOptions}
|
||||
statusFilterOptions={statusFilterOptions}
|
||||
selectedConditionId={expanded ? selectedCondition?.id ?? null : null}
|
||||
totalConditionCount={totalConditionCount}
|
||||
taskFilteredConditionCount={taskFilteredConditionCount}
|
||||
filteredConditionCount={filteredConditionCount}
|
||||
onTaskFilterChange={setTaskFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
onResetFilters={() => {
|
||||
setTaskFilter(CONDITION_FILTER_ALL);
|
||||
setStatusFilter(CONDITION_FILTER_ALL);
|
||||
}}
|
||||
onSelectCondition={handleSelectTimelineCondition}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type ConditionTimelinePanelProps = {
|
||||
expanded: boolean;
|
||||
loading: boolean;
|
||||
recentConditions: ScheduledConditionItem[];
|
||||
futureWorkOrders: ScheduledConditionItem[];
|
||||
taskFilter: ConditionTaskFilterValue;
|
||||
statusFilter: ConditionStatusFilterValue;
|
||||
taskFilterOptions: ConditionTaskFilterOption[];
|
||||
statusFilterOptions: ConditionStatusFilterOption[];
|
||||
selectedConditionId: string | null;
|
||||
totalConditionCount: number;
|
||||
taskFilteredConditionCount: number;
|
||||
filteredConditionCount: number;
|
||||
onTaskFilterChange: (value: ConditionTaskFilterValue) => void;
|
||||
onStatusFilterChange: (value: ConditionStatusFilterValue) => void;
|
||||
onResetFilters: () => void;
|
||||
onSelectCondition: (conditionId: string) => void;
|
||||
};
|
||||
|
||||
function ConditionTimelinePanel({
|
||||
expanded,
|
||||
loading,
|
||||
recentConditions,
|
||||
futureWorkOrders,
|
||||
taskFilter,
|
||||
statusFilter,
|
||||
taskFilterOptions,
|
||||
statusFilterOptions,
|
||||
selectedConditionId,
|
||||
totalConditionCount,
|
||||
taskFilteredConditionCount,
|
||||
filteredConditionCount,
|
||||
onTaskFilterChange,
|
||||
onStatusFilterChange,
|
||||
onResetFilters,
|
||||
onSelectCondition
|
||||
}: ConditionTimelinePanelProps) {
|
||||
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-busy={loading}
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden p-2.5",
|
||||
expanded ? "h-[calc(100dvh-14rem)] max-h-[calc(100dvh-14rem)]" : "h-[420px] max-h-[420px]",
|
||||
CONDITION_CONTENT_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
{expanded && !loading ? (
|
||||
<ConditionFilterBar
|
||||
taskFilter={taskFilter}
|
||||
statusFilter={statusFilter}
|
||||
taskFilterOptions={taskFilterOptions}
|
||||
statusFilterOptions={statusFilterOptions}
|
||||
filteredConditionCount={filteredConditionCount}
|
||||
totalConditionCount={totalConditionCount}
|
||||
taskFilteredConditionCount={taskFilteredConditionCount}
|
||||
onTaskFilterChange={onTaskFilterChange}
|
||||
onStatusFilterChange={onStatusFilterChange}
|
||||
onResetFilters={onResetFilters}
|
||||
/>
|
||||
) : null}
|
||||
{expanded && !loading && futureWorkOrders.length > 0 ? (
|
||||
<section className="mb-2">
|
||||
<ConditionGroupHeader icon={ClipboardList} title="预约任务" count={futureWorkOrders.length} compact />
|
||||
<div className="scheduled-feed-scroll max-h-[132px] overflow-y-auto pr-1">
|
||||
<ConditionRows
|
||||
conditions={futureWorkOrders}
|
||||
selectedConditionId={selectedConditionId}
|
||||
onSelectCondition={onSelectCondition}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<ConditionGroupHeader icon={History} title="最近工况" count={loading ? 0 : recentConditions.length} />
|
||||
<div className="scheduled-feed-scroll -mr-2 min-h-0 flex-1 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5">
|
||||
{loading ? (
|
||||
<ConditionTimelineSkeleton rows={expanded ? 7 : 6} />
|
||||
) : recentConditions.length > 0 ? (
|
||||
<ConditionRows
|
||||
conditions={recentConditions}
|
||||
selectedConditionId={selectedConditionId}
|
||||
onSelectCondition={onSelectCondition}
|
||||
/>
|
||||
) : (
|
||||
<ConditionEmptyState
|
||||
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
|
||||
description={filterActive ? "调整任务或状态筛选后查看其他工况记录。" : "新的工况任务完成后会出现在这里。"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type ConditionFilterBarProps = {
|
||||
taskFilter: ConditionTaskFilterValue;
|
||||
statusFilter: ConditionStatusFilterValue;
|
||||
taskFilterOptions: ConditionTaskFilterOption[];
|
||||
statusFilterOptions: ConditionStatusFilterOption[];
|
||||
filteredConditionCount: number;
|
||||
totalConditionCount: number;
|
||||
taskFilteredConditionCount: number;
|
||||
onTaskFilterChange: (value: ConditionTaskFilterValue) => void;
|
||||
onStatusFilterChange: (value: ConditionStatusFilterValue) => void;
|
||||
onResetFilters: () => void;
|
||||
};
|
||||
|
||||
function ConditionFilterBar({
|
||||
taskFilter,
|
||||
statusFilter,
|
||||
taskFilterOptions,
|
||||
statusFilterOptions,
|
||||
filteredConditionCount,
|
||||
totalConditionCount,
|
||||
taskFilteredConditionCount,
|
||||
onTaskFilterChange,
|
||||
onStatusFilterChange,
|
||||
onResetFilters
|
||||
}: ConditionFilterBarProps) {
|
||||
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
||||
|
||||
return (
|
||||
<div className={cn(CONDITION_CONTENT_SURFACE_CLASS_NAME, "mb-2 p-2")}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 px-1">
|
||||
<span className="text-xs font-semibold text-slate-700">筛选</span>
|
||||
<span className="shrink-0 text-xs font-semibold text-slate-400">
|
||||
{filteredConditionCount}/{totalConditionCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ConditionFilterDropdown
|
||||
label="任务"
|
||||
value={taskFilter}
|
||||
onValueChange={onTaskFilterChange}
|
||||
options={[
|
||||
{ value: CONDITION_FILTER_ALL, label: "全部任务", count: totalConditionCount },
|
||||
...taskFilterOptions
|
||||
]}
|
||||
/>
|
||||
<ConditionFilterDropdown
|
||||
label="状态"
|
||||
value={statusFilter}
|
||||
onValueChange={(value) => onStatusFilterChange(value as ConditionStatusFilterValue)}
|
||||
options={[
|
||||
{ value: CONDITION_FILTER_ALL, label: "全部状态", count: taskFilteredConditionCount },
|
||||
...statusFilterOptions
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{filterActive ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResetFilters}
|
||||
className="surface-control mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200/70 px-2 text-xs font-semibold text-blue-700 transition-[background-color,transform] hover:bg-blue-50 active:scale-95"
|
||||
>
|
||||
<XCircle size={12} aria-hidden="true" />
|
||||
重置筛选
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ConditionFilterDropdownProps<TValue extends string> = {
|
||||
label: string;
|
||||
value: TValue;
|
||||
options: {
|
||||
value: TValue;
|
||||
label: string;
|
||||
count: number;
|
||||
}[];
|
||||
onValueChange: (value: TValue) => void;
|
||||
};
|
||||
|
||||
function ConditionFilterDropdown<TValue extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onValueChange
|
||||
}: ConditionFilterDropdownProps<TValue>) {
|
||||
const selectedOption = options.find((option) => option.value === value) ?? options[0];
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<span className="mb-1 block text-xs font-semibold text-slate-400">{label}</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"surface-reading group flex h-9 w-full min-w-0 items-center gap-2 border border-slate-200/70 px-2 text-left text-xs outline-hidden transition-colors hover:bg-blue-50 focus-visible:border-blue-200 focus-visible:ring-2 focus-visible:ring-blue-100 data-[state=open]:border-blue-200 data-[state=open]:bg-blue-50 data-[state=open]:ring-2 data-[state=open]:ring-blue-100",
|
||||
"rounded-xl"
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-slate-800" title={selectedOption?.label}>
|
||||
{selectedOption?.label ?? "全部"}
|
||||
</span>
|
||||
<span className="block text-xs leading-3 text-slate-400">{selectedOption?.count ?? 0} 条</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
aria-hidden="true"
|
||||
className="shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600"
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="surface-reading scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-slate-200/70 p-1.5 text-slate-700 shadow-md shadow-slate-900/10"
|
||||
>
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={(nextValue) => onValueChange(nextValue as TValue)}>
|
||||
{options.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={cn(
|
||||
"my-0.5 min-h-9 gap-2 border border-transparent bg-transparent py-1.5 pl-7 pr-2 text-xs transition focus:bg-blue-50 focus:text-blue-800 data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50 data-[state=checked]:text-blue-800",
|
||||
"rounded-xl"
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-semibold">{option.label}</span>
|
||||
<span className="surface-control shrink-0 rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4 text-slate-500">
|
||||
{option.count}
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionEmptyState({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="surface-reading flex min-h-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-200/80 px-4 py-5 text-center">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-slate-100 text-slate-400">
|
||||
<ChevronRight size={15} aria-hidden="true" />
|
||||
</span>
|
||||
<p className="mt-2 text-xs font-semibold text-slate-700">{title}</p>
|
||||
<p className="mt-1 text-xs leading-4 text-slate-500">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionTimelineSkeleton({ rows }: { rows: number }) {
|
||||
return (
|
||||
<div className="space-y-1.5" role="status" aria-label="工况任务加载中">
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid min-h-[60px] grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl px-1 py-1"
|
||||
>
|
||||
<span className="mt-2 h-4 w-9 animate-pulse rounded-lg bg-slate-200/80" />
|
||||
<span className="relative flex min-h-12 justify-center pt-1.5">
|
||||
{index < rows - 1 ? (
|
||||
<span className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200" aria-hidden="true" />
|
||||
) : null}
|
||||
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
|
||||
</span>
|
||||
<span className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2 py-1.5">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 space-y-1.5">
|
||||
<span className="block h-3.5 w-24 animate-pulse rounded-full bg-slate-200/90" />
|
||||
<span className="block h-3 w-full max-w-[260px] animate-pulse rounded-full bg-slate-100" />
|
||||
</span>
|
||||
<span className="h-5 w-12 shrink-0 animate-pulse rounded-full bg-slate-100" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionDetailSkeleton() {
|
||||
return (
|
||||
<DetailScroll>
|
||||
<div
|
||||
className={cn(
|
||||
"surface-reading relative min-h-[420px] overflow-hidden border border-slate-200/70 px-4 py-3",
|
||||
"rounded-xl"
|
||||
)}
|
||||
role="status"
|
||||
aria-label="工况详情加载中"
|
||||
>
|
||||
<div className="flex gap-2.5">
|
||||
<span className={cn("h-9 w-9 shrink-0 animate-pulse bg-slate-100", "rounded-lg")} />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<span className="h-4 w-12 animate-pulse rounded-full bg-slate-200" />
|
||||
<span className="h-5 w-14 animate-pulse rounded-full bg-blue-100" />
|
||||
</div>
|
||||
<span className="block h-4 w-44 animate-pulse rounded-full bg-slate-200" />
|
||||
<span className="block h-3 w-full animate-pulse rounded-full bg-slate-100" />
|
||||
<span className="block h-3 w-4/5 animate-pulse rounded-full bg-slate-100" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3">
|
||||
<span className="block h-3.5 w-28 animate-pulse rounded-full bg-slate-200" />
|
||||
<span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-slate-100" />
|
||||
<span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-slate-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DetailScroll>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailScroll({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type ConditionGroupHeaderProps = {
|
||||
icon: typeof History;
|
||||
title: string;
|
||||
count: number;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
function ConditionGroupHeader({ icon: Icon, title, count, compact = false }: ConditionGroupHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500", compact ? "mb-1" : "mb-2")}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon size={13} aria-hidden="true" />
|
||||
<span>{title}</span>
|
||||
</span>
|
||||
<span>{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ConditionRowsProps = {
|
||||
conditions: ScheduledConditionItem[];
|
||||
selectedConditionId: string | null;
|
||||
onSelectCondition: (conditionId: string) => void;
|
||||
};
|
||||
|
||||
function ConditionRows({ conditions, selectedConditionId, onSelectCondition }: ConditionRowsProps) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{conditions.map((condition, index) => (
|
||||
<ConditionTimelineRow
|
||||
key={condition.id}
|
||||
condition={condition}
|
||||
selected={selectedConditionId === condition.id}
|
||||
isLast={index === conditions.length - 1}
|
||||
onSelect={() => onSelectCondition(condition.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ConditionTimelineRowProps = {
|
||||
condition: ScheduledConditionItem;
|
||||
selected: boolean;
|
||||
isLast: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
function ConditionTimelineRow({ condition, selected, isLast, onSelect }: ConditionTimelineRowProps) {
|
||||
const StatusIcon = getStatusIcon(condition.status);
|
||||
const isWorkOrder = condition.kind === "work_order";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl border px-1 py-1 text-left outline-hidden transition-colors focus-visible:bg-blue-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-200",
|
||||
selected ? "surface-reading border-blue-100 text-blue-900" : "border-transparent hover:bg-slate-50"
|
||||
)}
|
||||
>
|
||||
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">{formatTime(condition.scheduledAt)}</span>
|
||||
<span
|
||||
className="relative flex min-h-12 justify-center pt-1.5"
|
||||
>
|
||||
{!isLast ? (
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200 transition-colors group-hover:bg-blue-200 group-focus-visible:bg-blue-300"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"relative z-10 grid h-6 w-6 place-items-center rounded-full ring-4 ring-white",
|
||||
selected
|
||||
? "bg-blue-600 text-white group-focus-visible:ring-blue-100"
|
||||
: isWorkOrder
|
||||
? "bg-blue-50 text-blue-700 group-focus-visible:bg-blue-100 group-focus-visible:text-blue-800"
|
||||
: getStatusIconClassName(condition.status)
|
||||
)}
|
||||
>
|
||||
<StatusIcon size={12} aria-hidden="true" className={condition.status === "running" ? "animate-spin" : undefined} />
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 px-2 py-1.5 transition-colors",
|
||||
selected
|
||||
? "text-blue-900"
|
||||
: "text-slate-700"
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-semibold leading-4">{condition.title}</span>
|
||||
<span className="block truncate text-xs leading-4 text-slate-500">{condition.summary}</span>
|
||||
</span>
|
||||
<StatusPill condition={condition} className="shrink-0 text-xs" />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import {
|
||||
Activity,
|
||||
Camera,
|
||||
Circle,
|
||||
Copy,
|
||||
Database,
|
||||
Dot,
|
||||
Download,
|
||||
Keyboard,
|
||||
Layers3,
|
||||
MapPinned,
|
||||
Pentagon,
|
||||
Redo2,
|
||||
RefreshCw,
|
||||
Route,
|
||||
Ruler,
|
||||
Share2,
|
||||
Trash2,
|
||||
Undo2,
|
||||
Waypoints,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
MapAnnotationPanel,
|
||||
type MapAnnotationItem,
|
||||
type MapAnnotationShareAction
|
||||
} from "@/features/map/core/components/annotation-panel";
|
||||
import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
|
||||
import {
|
||||
MapActionRow,
|
||||
MapControlPanel,
|
||||
MapMetricTile,
|
||||
MapPanelSection
|
||||
} from "@/features/map/core/components/control-panel";
|
||||
import { MapDrawToolbar } from "@/features/map/core/components/draw-toolbar";
|
||||
import { MapLayerPanel } from "@/features/map/core/components/layer-panel";
|
||||
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
|
||||
import { MapLegendList, type MapLegendItem } from "@/features/map/core/components/legend";
|
||||
import { MapMeasurePanel } from "@/features/map/core/components/measure-panel";
|
||||
import {
|
||||
type WorkbenchDrawingAnnotation,
|
||||
type WorkbenchDrawMode
|
||||
} from "../hooks/use-workbench-drawing";
|
||||
import {
|
||||
type WorkbenchMeasurementResult,
|
||||
type WorkbenchMeasureMode,
|
||||
type WorkbenchMeasureUnit
|
||||
} from "../hooks/use-workbench-measurement";
|
||||
import { waterNetworkToolbarItems } from "../map/toolbar-config";
|
||||
|
||||
export type ToolbarToolId = "layers" | "measure" | "analysis" | "annotation" | "more";
|
||||
export type ExportViewPreset = "current" | "4k";
|
||||
|
||||
type MeasureModeOption = {
|
||||
id: WorkbenchMeasureMode;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
const MEASURE_MODES: MeasureModeOption[] = [
|
||||
{ id: "distance", label: "距离", icon: Route },
|
||||
{ id: "area", label: "面积", icon: Pentagon },
|
||||
{ id: "segment", label: "管段", icon: Waypoints }
|
||||
];
|
||||
|
||||
const MEASURE_UNITS: Array<{ id: WorkbenchMeasureUnit; label: string }> = [
|
||||
{ id: "m", label: "m" },
|
||||
{ id: "km", label: "km" }
|
||||
];
|
||||
|
||||
const ANNOTATION_SHARE_ACTIONS: MapAnnotationShareAction[] = [
|
||||
{
|
||||
id: "export",
|
||||
icon: Share2,
|
||||
label: "导出标注",
|
||||
description: "GeoJSON / 截图报告"
|
||||
}
|
||||
];
|
||||
|
||||
const TOOL_PANEL_EXIT_MS = 160;
|
||||
|
||||
const DRAW_TOOL_OPTIONS: Array<{
|
||||
id: WorkbenchDrawMode | "undo" | "redo" | "delete" | "clear";
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
tone?: "danger";
|
||||
}> = [
|
||||
{ id: "point", label: "点标注", icon: Dot },
|
||||
{ id: "line", label: "线标注", icon: Route },
|
||||
{ id: "polygon", label: "范围标注", icon: Pentagon },
|
||||
{ id: "circle", label: "圆形标注", icon: Circle },
|
||||
{ id: "undo", label: "撤销", icon: Undo2 },
|
||||
{ id: "redo", label: "重做", icon: Redo2 },
|
||||
{ id: "delete", label: "删除选中", icon: Trash2, tone: "danger" },
|
||||
{ id: "clear", label: "清空全部", icon: Trash2, tone: "danger" }
|
||||
];
|
||||
|
||||
export type ToolbarPanelProps = {
|
||||
activeToolId: ToolbarToolId | null;
|
||||
activeMeasureModeId: WorkbenchMeasureMode;
|
||||
activeMeasureUnitId: WorkbenchMeasureUnit;
|
||||
measuring: boolean;
|
||||
measureResult: WorkbenchMeasurementResult;
|
||||
measureEnabled: boolean;
|
||||
hasMeasurePoints: boolean;
|
||||
activeDrawToolId: WorkbenchDrawMode | null;
|
||||
drawingEnabled: boolean;
|
||||
drawingAnnotations: WorkbenchDrawingAnnotation[];
|
||||
canUndoDrawing: boolean;
|
||||
canRedoDrawing: boolean;
|
||||
canDeleteSelectedDrawing: boolean;
|
||||
hasDrawingFeatures: boolean;
|
||||
layerItems: MapLayerControlItem[];
|
||||
legendItems: MapLegendItem[];
|
||||
baseLayerOptions: BaseLayerOption[];
|
||||
activeBaseLayerId: string;
|
||||
onSelectMeasureMode: (id: WorkbenchMeasureMode) => void;
|
||||
onSelectMeasureUnit: (id: WorkbenchMeasureUnit) => void;
|
||||
onStartMeasure: () => void;
|
||||
onStopMeasure: () => void;
|
||||
onClearMeasure: () => void;
|
||||
onCopyMeasure: () => void;
|
||||
onSelectDrawTool: (id: WorkbenchDrawMode | null) => void;
|
||||
onUndoDrawing: () => void;
|
||||
onRedoDrawing: () => void;
|
||||
onDeleteSelectedDrawing: () => void;
|
||||
onClearDrawing: () => void;
|
||||
onExportAnnotations: () => void;
|
||||
onSelectBaseLayer: (id: string) => void;
|
||||
onToggleLayer: (id: string, visible: boolean) => void;
|
||||
onExportView: (preset: ExportViewPreset) => void;
|
||||
onRefreshTiles: () => void;
|
||||
onShowDataStatus: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
panelWidthClassName?: string;
|
||||
};
|
||||
|
||||
export function ToolbarPanel({
|
||||
activeToolId,
|
||||
activeMeasureModeId,
|
||||
activeMeasureUnitId,
|
||||
measuring,
|
||||
measureResult,
|
||||
measureEnabled,
|
||||
hasMeasurePoints,
|
||||
activeDrawToolId,
|
||||
drawingEnabled,
|
||||
drawingAnnotations,
|
||||
canUndoDrawing,
|
||||
canRedoDrawing,
|
||||
canDeleteSelectedDrawing,
|
||||
hasDrawingFeatures,
|
||||
layerItems,
|
||||
legendItems,
|
||||
baseLayerOptions,
|
||||
activeBaseLayerId,
|
||||
onSelectMeasureMode,
|
||||
onSelectMeasureUnit,
|
||||
onStartMeasure,
|
||||
onStopMeasure,
|
||||
onClearMeasure,
|
||||
onCopyMeasure,
|
||||
onSelectDrawTool,
|
||||
onUndoDrawing,
|
||||
onRedoDrawing,
|
||||
onDeleteSelectedDrawing,
|
||||
onClearDrawing,
|
||||
onExportAnnotations,
|
||||
onSelectBaseLayer,
|
||||
onToggleLayer,
|
||||
onExportView,
|
||||
onRefreshTiles,
|
||||
onShowDataStatus,
|
||||
onShowShortcuts,
|
||||
onExportConfig,
|
||||
panelWidthClassName
|
||||
}: ToolbarPanelProps) {
|
||||
const annotationItems = useMemo<MapAnnotationItem[]>(
|
||||
() =>
|
||||
drawingAnnotations.map((annotation) => ({
|
||||
id: annotation.id,
|
||||
icon: getDrawingAnnotationIcon(annotation.kind),
|
||||
label: annotation.label,
|
||||
description: annotation.description,
|
||||
status: annotation.hidden ? "隐藏" : "显示",
|
||||
selected: annotation.selected,
|
||||
muted: annotation.hidden,
|
||||
onClick: annotation.onToggleVisibility
|
||||
})),
|
||||
[drawingAnnotations]
|
||||
);
|
||||
const annotationShareActions = useMemo(
|
||||
() =>
|
||||
ANNOTATION_SHARE_ACTIONS.map((action) => ({
|
||||
...action,
|
||||
onClick: action.id === "export" ? onExportAnnotations : action.onClick
|
||||
})),
|
||||
[onExportAnnotations]
|
||||
);
|
||||
|
||||
const [renderedToolId, setRenderedToolId] = useState<ToolbarToolId | null>(activeToolId);
|
||||
const panelVisible = Boolean(activeToolId);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeToolId) {
|
||||
setRenderedToolId(activeToolId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!renderedToolId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exitTimer = window.setTimeout(() => {
|
||||
setRenderedToolId(null);
|
||||
}, TOOL_PANEL_EXIT_MS);
|
||||
|
||||
return () => window.clearTimeout(exitTimer);
|
||||
}, [activeToolId, renderedToolId]);
|
||||
|
||||
if (!renderedToolId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeTool = waterNetworkToolbarItems.find((item) => item.id === renderedToolId);
|
||||
const Icon = activeTool?.icon ?? Layers3;
|
||||
|
||||
if (renderedToolId === "layers") {
|
||||
return (
|
||||
<MapControlPanel
|
||||
key="layers"
|
||||
title="图层"
|
||||
description={`${layerItems.filter((item) => item.visible).length}/${layerItems.length} 个业务图层可见`}
|
||||
icon={Icon}
|
||||
widthClassName={panelWidthClassName}
|
||||
visible={panelVisible}
|
||||
>
|
||||
<MapLayerPanel
|
||||
layerItems={layerItems}
|
||||
baseLayerOptions={baseLayerOptions}
|
||||
activeBaseLayerId={activeBaseLayerId}
|
||||
onSelectBaseLayer={onSelectBaseLayer}
|
||||
onToggleLayer={onToggleLayer}
|
||||
/>
|
||||
</MapControlPanel>
|
||||
);
|
||||
}
|
||||
|
||||
if (renderedToolId === "measure") {
|
||||
return (
|
||||
<MapControlPanel key="measure" title="测量" description="距离与范围测量" icon={Icon} widthClassName={panelWidthClassName ?? "w-[312px]"} visible={panelVisible}>
|
||||
<MapMeasurePanel
|
||||
modes={MEASURE_MODES}
|
||||
activeModeId={activeMeasureModeId}
|
||||
resultLabel={measureResult.label}
|
||||
resultValue={measureResult.value}
|
||||
resultUnit={measureResult.unit}
|
||||
metrics={measureResult.metrics}
|
||||
units={MEASURE_UNITS}
|
||||
activeUnitId={activeMeasureUnitId}
|
||||
actions={[
|
||||
{
|
||||
id: measuring ? "stop" : "start",
|
||||
icon: Ruler,
|
||||
label: measuring ? "停止测量" : "开始测量",
|
||||
description:
|
||||
activeMeasureModeId === "segment"
|
||||
? measuring
|
||||
? "暂停管线选择"
|
||||
: "点击管线选择资产"
|
||||
: measuring
|
||||
? "暂停地图取点"
|
||||
: "在地图上连续点击取点",
|
||||
variant: measuring ? "default" : "primary",
|
||||
disabled: !measureEnabled,
|
||||
onClick: measuring ? onStopMeasure : onStartMeasure
|
||||
},
|
||||
{
|
||||
id: "copy",
|
||||
icon: Copy,
|
||||
label: "复制结果",
|
||||
description: "复制当前读数",
|
||||
disabled: !hasMeasurePoints,
|
||||
onClick: onCopyMeasure
|
||||
},
|
||||
{
|
||||
id: "clear",
|
||||
icon: Trash2,
|
||||
label: "清除测量",
|
||||
description: activeMeasureModeId === "segment" ? "清空已选管段" : "移除临时测量线",
|
||||
tone: "danger",
|
||||
disabled: !hasMeasurePoints,
|
||||
onClick: onClearMeasure
|
||||
}
|
||||
]}
|
||||
onSelectMode={(id) => onSelectMeasureMode(id as WorkbenchMeasureMode)}
|
||||
onSelectUnit={(id) => onSelectMeasureUnit(id as WorkbenchMeasureUnit)}
|
||||
/>
|
||||
</MapControlPanel>
|
||||
);
|
||||
}
|
||||
|
||||
if (renderedToolId === "analysis") {
|
||||
return (
|
||||
<MapControlPanel key="analysis" title="分析" description="图例与当前覆盖" icon={Icon} widthClassName={panelWidthClassName ?? "w-[316px]"} visible={panelVisible}>
|
||||
<AnalysisPanel legendItems={legendItems} />
|
||||
</MapControlPanel>
|
||||
);
|
||||
}
|
||||
|
||||
if (renderedToolId === "annotation") {
|
||||
return (
|
||||
<MapControlPanel key="annotation" title="标注" description="创建与管理地图标注" icon={Icon} widthClassName={panelWidthClassName ?? "w-[312px]"} visible={panelVisible}>
|
||||
<MapPanelSection title="绘制">
|
||||
<MapDrawToolbar
|
||||
variant="panel"
|
||||
items={DRAW_TOOL_OPTIONS.map((item) => ({
|
||||
...item,
|
||||
active: activeDrawToolId === item.id,
|
||||
disabled:
|
||||
!drawingEnabled ||
|
||||
(item.id === "undo" && !canUndoDrawing) ||
|
||||
(item.id === "redo" && !canRedoDrawing) ||
|
||||
(item.id === "delete" && !canDeleteSelectedDrawing) ||
|
||||
(item.id === "clear" && !canUndoDrawing && !hasDrawingFeatures),
|
||||
onClick: () => {
|
||||
if (item.id === "undo") {
|
||||
onUndoDrawing();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.id === "redo") {
|
||||
onRedoDrawing();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.id === "delete") {
|
||||
onDeleteSelectedDrawing();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.id === "clear") {
|
||||
onClearDrawing();
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectDrawTool(activeDrawToolId === item.id ? null : item.id);
|
||||
}
|
||||
}))}
|
||||
/>
|
||||
</MapPanelSection>
|
||||
<MapAnnotationPanel annotations={annotationItems} shareActions={annotationShareActions} />
|
||||
</MapControlPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MapControlPanel key="more" title="更多" description={activeTool?.description} icon={Icon} widthClassName={panelWidthClassName ?? "w-[292px]"} visible={panelVisible}>
|
||||
<MorePanel
|
||||
onExportView={onExportView}
|
||||
onRefreshTiles={onRefreshTiles}
|
||||
onShowDataStatus={onShowDataStatus}
|
||||
onShowShortcuts={onShowShortcuts}
|
||||
onExportConfig={onExportConfig}
|
||||
/>
|
||||
</MapControlPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalysisPanel({ legendItems }: { legendItems: MapLegendItem[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<MapPanelSection title="图例">
|
||||
<MapLegendList items={legendItems} showStatusDot />
|
||||
</MapPanelSection>
|
||||
|
||||
<MapPanelSection title="当前覆盖">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MapMetricTile label="影响范围" value="未开启" />
|
||||
<MapMetricTile label="风险预览" value="待生成" />
|
||||
</div>
|
||||
<MapActionRow icon={Activity} label="模拟结果" description="影响面、事故点与标签" />
|
||||
<MapActionRow icon={MapPinned} label="选中对象" description="点击管线或节点查看详情" />
|
||||
</MapPanelSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MorePanelProps = {
|
||||
onExportView: (preset: ExportViewPreset) => void;
|
||||
onRefreshTiles: () => void;
|
||||
onShowDataStatus: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
};
|
||||
|
||||
function MorePanel({
|
||||
onExportView,
|
||||
onRefreshTiles,
|
||||
onShowDataStatus,
|
||||
onShowShortcuts,
|
||||
onExportConfig
|
||||
}: MorePanelProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<MapPanelSection title="导出视图">
|
||||
<MapActionRow icon={Camera} label="当前分辨率" description="按当前地图画布尺寸导出" onClick={() => onExportView("current")} />
|
||||
<MapActionRow icon={Camera} label="4K 分辨率" description="长边约 3840px 的 PNG" onClick={() => onExportView("4k")} />
|
||||
</MapPanelSection>
|
||||
|
||||
<MapPanelSection title="数据">
|
||||
<MapActionRow icon={RefreshCw} label="刷新瓦片" description="请求地图重新渲染" onClick={onRefreshTiles} />
|
||||
<MapActionRow icon={Database} label="数据状态" description="查看当前数据源状态" status="查看" onClick={onShowDataStatus} />
|
||||
</MapPanelSection>
|
||||
|
||||
<MapPanelSection title="系统">
|
||||
<MapActionRow icon={Keyboard} label="快捷键" description="查看地图操作键位" onClick={onShowShortcuts} />
|
||||
<MapActionRow icon={Download} label="导出配置" description="保存当前图层与工具状态" onClick={onExportConfig} />
|
||||
</MapPanelSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDrawingAnnotationIcon(kind: WorkbenchDrawMode) {
|
||||
if (kind === "line") {
|
||||
return Route;
|
||||
}
|
||||
|
||||
if (kind === "polygon") {
|
||||
return Pentagon;
|
||||
}
|
||||
|
||||
if (kind === "circle") {
|
||||
return Circle;
|
||||
}
|
||||
|
||||
return MapPinned;
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileText,
|
||||
Keyboard,
|
||||
LogOut,
|
||||
PlayCircle,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
UserRound,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||
import {
|
||||
compactHeaderTextClassName,
|
||||
headerControlButtonClassName,
|
||||
headerControlIconClassName
|
||||
} from "./workbench-top-bar-styles";
|
||||
|
||||
const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
|
||||
active: "运行中",
|
||||
draft: "草案",
|
||||
review: "待复核"
|
||||
};
|
||||
|
||||
const menuSurfaceClassName = cn(
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
"acrylic-panel border p-2 text-slate-900"
|
||||
);
|
||||
const menuReadableRowClassName =
|
||||
"surface-well focus:bg-blue-50 focus:text-slate-950";
|
||||
export function ScenarioMenu({
|
||||
open,
|
||||
onOpenChange,
|
||||
scenarios,
|
||||
activeScenarioId,
|
||||
activeScenarioName,
|
||||
onSelectScenario,
|
||||
onPreviewScenario,
|
||||
onCompareScenario,
|
||||
onExportScenarioReport
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
scenarios: WorkbenchScenario[];
|
||||
activeScenarioId: string;
|
||||
activeScenarioName: string;
|
||||
onSelectScenario: (scenarioId: string) => void;
|
||||
onPreviewScenario: () => void;
|
||||
onCompareScenario: () => void;
|
||||
onExportScenarioReport: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("group max-w-[220px] px-2", headerControlButtonClassName)}
|
||||
aria-label="切换模拟方案"
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
<SlidersHorizontal size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={cn(compactHeaderTextClassName, "truncate")}>场景 · {activeScenarioName}</span>
|
||||
<ChevronDown size={14} className="hidden shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600 2xl:block" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className={cn("w-[340px]", menuSurfaceClassName)}>
|
||||
<MenuHeader title="场景控制" meta={`当前:${activeScenarioName}`} />
|
||||
<MenuSectionLabel>场景列表</MenuSectionLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={activeScenarioId}
|
||||
onValueChange={(nextScenarioId) => {
|
||||
onSelectScenario(nextScenarioId);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{scenarios.map((scenario) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={scenario.id}
|
||||
value={scenario.id}
|
||||
className={cn(
|
||||
"my-0.5 items-start border border-transparent py-2 pr-2 transition data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50/80",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
menuReadableRowClassName
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-semibold text-slate-900">{scenario.name}</span>
|
||||
<span className="shrink-0 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-xs font-medium text-slate-500">
|
||||
{scenarioStatusLabels[scenario.status]}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-slate-500">{scenario.description}</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<MenuSeparator />
|
||||
<MenuSectionLabel>场景命令</MenuSectionLabel>
|
||||
<MenuAction icon={PlayCircle} label="预览影响范围" description="打开模拟图层与影响区" tone="primary" onSelect={onPreviewScenario} />
|
||||
<MenuAction icon={SlidersHorizontal} label="比较候选方案" description="影响、阀门与保供风险差异" onSelect={onCompareScenario} />
|
||||
<MenuAction icon={FileText} label="导出场景报告" description="生成调度复核材料" onSelect={onExportScenarioReport} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertMenu({
|
||||
open,
|
||||
onOpenChange,
|
||||
alerts,
|
||||
compact = false,
|
||||
conditionFeedVisible,
|
||||
onSelectAlert,
|
||||
onToggleConditionFeed,
|
||||
onAnalyzeAlertsWithAgent
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
alerts: WorkbenchAlert[];
|
||||
compact?: boolean;
|
||||
conditionFeedVisible: boolean;
|
||||
onSelectAlert: (alert: WorkbenchAlert) => void;
|
||||
onToggleConditionFeed: () => void;
|
||||
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
||||
}) {
|
||||
const hasAlerts = alerts.length > 0;
|
||||
const latestAlertTime = alerts[0]?.time;
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group relative px-2",
|
||||
headerControlButtonClassName,
|
||||
hasAlerts && "hover:bg-red-50/60 hover:text-red-800 data-[state=open]:bg-red-50/70 data-[state=open]:text-red-800",
|
||||
compact && "h-10 w-10 justify-center px-0"
|
||||
)}
|
||||
aria-label={`查看异常处置面板,当前 ${alerts.length} 条待复核工况`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
hasAlerts &&
|
||||
"bg-red-50 text-red-700 group-hover:bg-red-50 group-hover:text-red-800 group-data-[state=open]:bg-red-50 group-data-[state=open]:text-red-800"
|
||||
)}
|
||||
>
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compact ? "sr-only" : compactHeaderTextClassName}>异常处置</span>
|
||||
{compact ? (
|
||||
<span className={cn("absolute right-0 top-0 grid h-4 min-w-4 place-items-center rounded-full border px-1 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
|
||||
{alerts.length}
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
|
||||
{alerts.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}>
|
||||
<AlertQueueSummary
|
||||
count={alerts.length}
|
||||
latestAlertTime={latestAlertTime}
|
||||
conditionFeedVisible={conditionFeedVisible}
|
||||
/>
|
||||
<MenuSectionLabel>待复核工况</MenuSectionLabel>
|
||||
<div className="scheduled-feed-scroll max-h-[min(52dvh,420px)] overflow-y-auto pr-1">
|
||||
{hasAlerts ? alerts.map((alert) => (
|
||||
<DropdownMenuItem
|
||||
key={alert.id}
|
||||
className={cn(
|
||||
"group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-red-100",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
menuReadableRowClassName
|
||||
)}
|
||||
onSelect={() => onSelectAlert(alert)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-red-300 ring-2 ring-red-50" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span>
|
||||
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-slate-400 tabular-nums">{alert.time}</span>
|
||||
</span>
|
||||
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{alert.description}</span>
|
||||
</span>
|
||||
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-red-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)) : (
|
||||
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
||||
<div className="min-w-0">
|
||||
<CheckCircle2 size={18} className="mx-auto text-emerald-600" aria-hidden="true" />
|
||||
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待复核工况</p>
|
||||
<p className="mt-0.5 text-xs leading-4 text-slate-500">新的工况提醒会进入这里。</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-slate-200/70 pt-2">
|
||||
<MenuPlainButton
|
||||
label={conditionFeedVisible ? "工况面板已打开" : "打开工况面板"}
|
||||
description="查看时间线与处置详情"
|
||||
disabled={conditionFeedVisible}
|
||||
onClick={() => {
|
||||
onToggleConditionFeed();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
<MenuPrimaryButton
|
||||
disabled={!hasAlerts}
|
||||
icon={Bot}
|
||||
label={hasAlerts ? "工况汇总" : "等待提醒"}
|
||||
onClick={() => {
|
||||
void onAnalyzeAlertsWithAgent();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserMenu({
|
||||
open,
|
||||
onOpenChange,
|
||||
user,
|
||||
onRefreshTiles,
|
||||
onShowDataStatus,
|
||||
onShowShortcuts,
|
||||
onExportConfig
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
user: WorkbenchUser;
|
||||
onRefreshTiles: () => void;
|
||||
onShowDataStatus: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
aria-label="打开用户菜单"
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
<UserRound size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={cn(compactHeaderTextClassName, "text-sm font-semibold")}>{user.name}</span>
|
||||
<ChevronDown size={14} className="hidden shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600 2xl:block" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className={cn("w-72", menuSurfaceClassName)}>
|
||||
<MenuHeader title={user.name} meta={user.role} icon={ShieldCheck} />
|
||||
<MenuSectionLabel>会话控制</MenuSectionLabel>
|
||||
<MenuAction icon={Activity} label="查看运行状态" description="检查数据源与运行健康度" onSelect={onShowDataStatus} />
|
||||
<MenuAction icon={RefreshCw} label="刷新地图瓦片" description="请求地图重新渲染业务图层" onSelect={onRefreshTiles} />
|
||||
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
|
||||
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
|
||||
<MenuSeparator />
|
||||
<DropdownMenuItem disabled className={cn("px-2 py-2 text-slate-400", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
||||
<LogOut size={15} aria-hidden="true" />
|
||||
退出登录
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertQueueSummary({
|
||||
count,
|
||||
latestAlertTime,
|
||||
conditionFeedVisible
|
||||
}: {
|
||||
count: number;
|
||||
latestAlertTime?: string;
|
||||
conditionFeedVisible: boolean;
|
||||
}) {
|
||||
const hasAlerts = count > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("surface-reading overflow-hidden px-3 py-3", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
hasAlerts ? "bg-red-50 text-red-700" : "bg-emerald-50 text-emerald-700"
|
||||
)}
|
||||
>
|
||||
{hasAlerts ? <Bell size={16} aria-hidden="true" /> : <CheckCircle2 size={16} aria-hidden="true" />}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold leading-5 text-slate-950">异常处置</h2>
|
||||
<p className="mt-0.5 text-xs leading-5 text-slate-500">
|
||||
{hasAlerts ? `${count} 条需复核${latestAlertTime ? ` · 最近 ${latestAlertTime}` : ""}` : "当前无待复核工况"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold leading-5",
|
||||
hasAlerts ? "bg-red-50 text-red-800 ring-1 ring-red-100" : "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100"
|
||||
)}
|
||||
>
|
||||
{hasAlerts ? "需复核" : "正常"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs leading-4 text-slate-500">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", conditionFeedVisible ? "bg-blue-500" : "bg-slate-300")} aria-hidden="true" />
|
||||
<span className="truncate">{conditionFeedVisible ? "工况面板已显示,可查看详情。" : "点击条目可打开工况任务。"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuPlainButton({
|
||||
label,
|
||||
description,
|
||||
disabled = false,
|
||||
onClick
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex h-11 min-w-0 flex-col justify-center border px-3 text-left transition-colors disabled:cursor-default disabled:opacity-70",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
disabled
|
||||
? "border-slate-200 bg-slate-50 text-slate-500"
|
||||
: "border-slate-200 bg-white text-slate-800 hover:border-blue-100 hover:bg-blue-50/55 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-100"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-xs font-semibold leading-4">{label}</span>
|
||||
<span className="truncate text-xs font-medium leading-4 text-slate-500">{description}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuPrimaryButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
disabled = false,
|
||||
onClick
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void | Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex h-11 shrink-0 items-center justify-center gap-2 border px-3 text-xs font-semibold transition-colors disabled:cursor-not-allowed",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
disabled
|
||||
? "border-slate-200 bg-slate-50 text-slate-400"
|
||||
: "border-blue-200 bg-blue-600 text-white shadow-lg shadow-blue-600/20 hover:bg-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-200"
|
||||
)}
|
||||
>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
<span className="max-w-[92px] truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuHeader({
|
||||
title,
|
||||
meta,
|
||||
tone = "default",
|
||||
icon: Icon
|
||||
}: {
|
||||
title: string;
|
||||
meta: string;
|
||||
tone?: "default" | "warning";
|
||||
icon?: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("surface-control flex items-center justify-between gap-3 border px-2 py-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{Icon ? <Icon size={13} className="shrink-0 text-slate-500" aria-hidden="true" /> : null}
|
||||
<span className="truncate text-sm font-semibold text-slate-950">{title}</span>
|
||||
</div>
|
||||
<p className={cn("mt-0.5 truncate text-xs text-slate-500", tone === "warning" && "text-red-600")}>{meta}</p>
|
||||
</div>
|
||||
{tone === "warning" ? <span className="h-2 w-2 shrink-0 rounded-full bg-red-500" aria-hidden="true" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuSectionLabel({ children }: { children: ReactNode }) {
|
||||
return <DropdownMenuLabel className="px-2 pb-1 pt-2.5 text-xs font-semibold uppercase text-slate-500">{children}</DropdownMenuLabel>;
|
||||
}
|
||||
|
||||
function MenuSeparator() {
|
||||
return <DropdownMenuSeparator className="my-2 bg-slate-200/70" />;
|
||||
}
|
||||
|
||||
function MenuAction({
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
tone = "default",
|
||||
onSelect
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
description: string;
|
||||
tone?: "default" | "primary" | "warning";
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"my-0.5 items-center border border-transparent px-2 py-2 transition",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
menuReadableRowClassName
|
||||
)}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-7 w-7 shrink-0 place-items-center border",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
tone === "primary" && "border-blue-100 bg-blue-50 text-blue-700",
|
||||
tone === "warning" && "border-orange-100 bg-orange-50 text-orange-700",
|
||||
tone === "default" && "border-slate-100 bg-slate-50 text-slate-600"
|
||||
)}
|
||||
>
|
||||
<Icon size={15} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold leading-5 text-slate-900">{label}</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-slate-500">{description}</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const headerControlButtonClassName =
|
||||
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
|
||||
|
||||
export const headerControlIconClassName =
|
||||
"grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
|
||||
|
||||
export const compactHeaderTextClassName = "hidden 2xl:inline";
|
||||
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
CalendarClock,
|
||||
Clock3,
|
||||
Droplets,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FlaskConical,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import {
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
|
||||
import {
|
||||
compactHeaderTextClassName,
|
||||
headerControlButtonClassName,
|
||||
headerControlIconClassName
|
||||
} from "./workbench-top-bar-styles";
|
||||
|
||||
export type WorkbenchTopBarProps = {
|
||||
dataTime: string;
|
||||
modelName: string;
|
||||
scenarios: WorkbenchScenario[];
|
||||
activeScenarioId: string;
|
||||
alerts: WorkbenchAlert[];
|
||||
user: WorkbenchUser;
|
||||
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;
|
||||
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
||||
onShowDataStatus: () => void;
|
||||
onRefreshTiles: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
};
|
||||
|
||||
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
|
||||
|
||||
export function WorkbenchTopBar({
|
||||
dataTime,
|
||||
modelName,
|
||||
scenarios,
|
||||
activeScenarioId,
|
||||
alerts,
|
||||
user,
|
||||
conditionFeedVisible,
|
||||
taskTickerAvailable,
|
||||
taskTickerVisible,
|
||||
devPanelEnabled,
|
||||
devPanelOpen,
|
||||
onSelectScenario,
|
||||
onSelectAlert,
|
||||
onToggleConditionFeed,
|
||||
onToggleTaskTicker,
|
||||
onToggleDevPanel,
|
||||
onPreviewScenario,
|
||||
onCompareScenario,
|
||||
onExportScenarioReport,
|
||||
onAnalyzeAlertsWithAgent,
|
||||
onShowDataStatus,
|
||||
onRefreshTiles,
|
||||
onShowShortcuts,
|
||||
onExportConfig
|
||||
}: WorkbenchTopBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
||||
const activeScenario = scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
|
||||
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
|
||||
setOpenMenu(open ? menuId : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b px-3 sm:px-4 md:px-5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||
<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>
|
||||
<p className="hidden truncate text-xs leading-4 text-slate-500 sm:block">调度工作台</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden min-w-0 items-center gap-2 text-sm text-slate-700 lg:flex 2xl:gap-4">
|
||||
<HeaderReadout icon={Clock3} label="数据时间" value={dataTime} />
|
||||
<HeaderDivider />
|
||||
<HeaderReadout icon={Box} label="模型版本" value={modelName} />
|
||||
<HeaderDivider />
|
||||
<TaskTickerToggle
|
||||
available={taskTickerAvailable}
|
||||
visible={taskTickerVisible}
|
||||
onToggle={onToggleTaskTicker}
|
||||
/>
|
||||
{taskTickerAvailable ? <HeaderDivider /> : null}
|
||||
<ConditionFeedToggle visible={conditionFeedVisible} onToggle={onToggleConditionFeed} />
|
||||
<HeaderDivider />
|
||||
<AlertMenu
|
||||
open={openMenu === "alerts"}
|
||||
onOpenChange={createMenuOpenChange("alerts")}
|
||||
alerts={alerts}
|
||||
conditionFeedVisible={conditionFeedVisible}
|
||||
onSelectAlert={onSelectAlert}
|
||||
onToggleConditionFeed={onToggleConditionFeed}
|
||||
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
|
||||
/>
|
||||
<HeaderDivider />
|
||||
{activeScenario ? (
|
||||
<ScenarioMenu
|
||||
open={openMenu === "scenario"}
|
||||
onOpenChange={createMenuOpenChange("scenario")}
|
||||
scenarios={scenarios}
|
||||
activeScenarioId={activeScenario.id}
|
||||
activeScenarioName={activeScenario.name}
|
||||
onSelectScenario={onSelectScenario}
|
||||
onPreviewScenario={onPreviewScenario}
|
||||
onCompareScenario={onCompareScenario}
|
||||
onExportScenarioReport={onExportScenarioReport}
|
||||
/>
|
||||
) : null}
|
||||
</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 className={compactHeaderTextClassName}>Dev</span>
|
||||
</button>
|
||||
</div> : null}
|
||||
<div className="lg:hidden">
|
||||
<AlertMenu
|
||||
compact
|
||||
open={openMenu === "compact-alerts"}
|
||||
onOpenChange={createMenuOpenChange("compact-alerts")}
|
||||
alerts={alerts}
|
||||
conditionFeedVisible={conditionFeedVisible}
|
||||
onSelectAlert={onSelectAlert}
|
||||
onToggleConditionFeed={onToggleConditionFeed}
|
||||
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
|
||||
/>
|
||||
</div>
|
||||
<UserMenu
|
||||
open={openMenu === "user"}
|
||||
onOpenChange={createMenuOpenChange("user")}
|
||||
user={user}
|
||||
onRefreshTiles={onRefreshTiles}
|
||||
onShowDataStatus={onShowDataStatus}
|
||||
onShowShortcuts={onShowShortcuts}
|
||||
onExportConfig={onExportConfig}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskTickerToggle({
|
||||
available,
|
||||
visible,
|
||||
onToggle
|
||||
}: {
|
||||
available: boolean;
|
||||
visible: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const Icon = visible ? EyeOff : Eye;
|
||||
|
||||
if (!available) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
||||
aria-pressed={visible}
|
||||
title={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"group px-2",
|
||||
headerControlButtonClassName,
|
||||
visible && "bg-blue-50/80 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
visible && "bg-blue-600/10 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>任务浮条</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionFeedToggle({
|
||||
visible,
|
||||
onToggle
|
||||
}: {
|
||||
visible: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={visible}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"group px-2",
|
||||
headerControlButtonClassName,
|
||||
visible && "bg-blue-50/80 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
visible && "bg-blue-600/10 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<CalendarClock size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>工况任务</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderReadout({ icon: Icon, label, value }: { icon?: LucideIcon; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
|
||||
{Icon ? <Icon size={14} className="shrink-0 text-blue-700" aria-hidden="true" /> : null}
|
||||
<span className="hidden text-xs font-medium text-slate-500 2xl:inline">{label}</span>
|
||||
<span className="max-w-16 truncate text-sm font-semibold text-slate-800 2xl:max-w-28">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderDivider() {
|
||||
return <div className="h-5 w-px shrink-0 bg-slate-200/80" />;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type {
|
||||
ScheduledConditionRecord,
|
||||
ScheduledConditionStatus,
|
||||
ScheduledConditionTaskId
|
||||
} from "@/features/workbench/types";
|
||||
|
||||
export type RunningWorkflowStepDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type RunningWorkflowDefinition = {
|
||||
title: string;
|
||||
reportLabel: string;
|
||||
steps: RunningWorkflowStepDefinition[];
|
||||
};
|
||||
|
||||
export type RunningWorkflowStepStatus = "done" | "current" | "pending";
|
||||
|
||||
export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, RunningWorkflowDefinition> = {
|
||||
"scada-diagnosis": {
|
||||
title: "SCADA 诊断流程",
|
||||
reportLabel: "生成诊断报告",
|
||||
steps: [
|
||||
{
|
||||
id: "ingest-telemetry",
|
||||
title: "读取 SCADA 遥测",
|
||||
detail: "拉取压力、流量和关键阀门测点,校验时间戳与回传频率。"
|
||||
},
|
||||
{
|
||||
id: "freshness-check",
|
||||
title: "检查新鲜度完整率",
|
||||
detail: "按测点组计算缺测、延迟和连续异常样本比例。"
|
||||
},
|
||||
{
|
||||
id: "consistency-check",
|
||||
title: "压流一致性诊断",
|
||||
detail: "比对压力变化、流量变化和相邻测点趋势是否符合水力逻辑。"
|
||||
},
|
||||
{
|
||||
id: "isolate-sources",
|
||||
title: "标记异常数据源",
|
||||
detail: "圈定疑似通信异常或越界测点,给后续模拟和调度提供屏蔽建议。"
|
||||
},
|
||||
{
|
||||
id: "diagnosis-report",
|
||||
title: "整理诊断结论",
|
||||
detail: "汇总数据质量、异常证据和是否允许进入后续工况任务。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"smart-dispatch": {
|
||||
title: "智能调度流程",
|
||||
reportLabel: "生成调度指令",
|
||||
steps: [
|
||||
{
|
||||
id: "read-condition",
|
||||
title: "读取实时工况",
|
||||
detail: "汇总目标分区压力、流量、泵站出水和阀门开度。"
|
||||
},
|
||||
{
|
||||
id: "candidate-actions",
|
||||
title: "生成候选动作",
|
||||
detail: "枚举泵站目标压力、阀门边界和高频复核测点的可执行动作。"
|
||||
},
|
||||
{
|
||||
id: "simulate-impact",
|
||||
title: "模拟影响范围",
|
||||
detail: "预估指令执行后的压力改善、分区压差和关键用户影响。"
|
||||
},
|
||||
{
|
||||
id: "rank-actions",
|
||||
title: "排序调度方案",
|
||||
detail: "按改善幅度、操作代价和数据可信度筛选建议指令。"
|
||||
},
|
||||
{
|
||||
id: "dispatch-draft",
|
||||
title: "形成复核清单",
|
||||
detail: "输出待调度员确认的管网要素调整指令和复核条件。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"network-simulation": {
|
||||
title: "定时模拟流程",
|
||||
reportLabel: "生成模拟报告",
|
||||
steps: [
|
||||
{
|
||||
id: "sync-boundary",
|
||||
title: "同步模型边界",
|
||||
detail: "读取最新泵站启停、阀门开度、需水量和 SCADA 边界条件。"
|
||||
},
|
||||
{
|
||||
id: "run-hydraulic-model",
|
||||
title: "运行水力模型",
|
||||
detail: "滚动计算全网节点压力、管段流量和分区边界平衡。"
|
||||
},
|
||||
{
|
||||
id: "compare-telemetry",
|
||||
title: "比对实测偏差",
|
||||
detail: "将模拟压力、流量与在线测点回传值进行偏差校验。"
|
||||
},
|
||||
{
|
||||
id: "locate-deviation",
|
||||
title: "定位偏差来源",
|
||||
detail: "识别模型参数、边界条件或遥测源导致的主要偏差区域。"
|
||||
},
|
||||
{
|
||||
id: "simulation-report",
|
||||
title: "输出模型复核",
|
||||
detail: "给出可信度、关注指标和是否允许用于调度决策。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pump-energy": {
|
||||
title: "泵站能耗流程",
|
||||
reportLabel: "生成能耗报告",
|
||||
steps: [
|
||||
{
|
||||
id: "read-pump-load",
|
||||
title: "读取泵组负载",
|
||||
detail: "采集泵站频率、电流、出水压力、出水量和启停记录。"
|
||||
},
|
||||
{
|
||||
id: "calculate-energy",
|
||||
title: "计算单位电耗",
|
||||
detail: "按当前窗口排水量折算单位排水电耗和效率偏差。"
|
||||
},
|
||||
{
|
||||
id: "compare-efficiency",
|
||||
title: "比对经济区间",
|
||||
detail: "结合泵组效率曲线判断当前组合是否处于低效并联区间。"
|
||||
},
|
||||
{
|
||||
id: "pressure-guard",
|
||||
title: "校验供压约束",
|
||||
detail: "复核节能建议不会引发末端低压或分区边界异常。"
|
||||
},
|
||||
{
|
||||
id: "energy-advice",
|
||||
title: "形成节能建议",
|
||||
detail: "输出启停优化、压力设定和继续观察条件。"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export function getRunningWorkflowDefinition(condition: ScheduledConditionRecord) {
|
||||
return runningWorkflowDefinitions[condition.taskId];
|
||||
}
|
||||
|
||||
export function getRunningWorkflowState(
|
||||
condition: ScheduledConditionRecord,
|
||||
workflow: RunningWorkflowDefinition,
|
||||
nowMs: number
|
||||
) {
|
||||
if (condition.status !== "running") {
|
||||
return { currentStepIndex: workflow.steps.length - 1, progress: getCompletedProgress(condition.status) };
|
||||
}
|
||||
|
||||
const scheduledAtMs = Date.parse(condition.scheduledAt);
|
||||
const startedAtMs = Number.isFinite(scheduledAtMs) ? scheduledAtMs : nowMs;
|
||||
const durationMs = Math.max(condition.durationMinutes ?? 1, 1) * 60_000;
|
||||
const elapsedMs = Math.max(0, nowMs - startedAtMs);
|
||||
const rawRatio = elapsedMs / durationMs;
|
||||
const runningRatio = Math.min(Math.max(rawRatio, 0), 0.999);
|
||||
const currentStepIndex = Math.min(
|
||||
workflow.steps.length - 1,
|
||||
Math.floor(runningRatio * workflow.steps.length)
|
||||
);
|
||||
const progress = Math.min(99, Math.max(0, Math.floor(runningRatio * 100)));
|
||||
|
||||
return { currentStepIndex, progress };
|
||||
}
|
||||
|
||||
export function getWorkflowStepStatus(
|
||||
condition: ScheduledConditionRecord,
|
||||
stepIndex: number,
|
||||
currentStepIndex: number
|
||||
): RunningWorkflowStepStatus {
|
||||
if (condition.status !== "running") {
|
||||
return "done";
|
||||
}
|
||||
|
||||
if (stepIndex < currentStepIndex) {
|
||||
return "done";
|
||||
}
|
||||
|
||||
if (stepIndex === currentStepIndex) {
|
||||
return "current";
|
||||
}
|
||||
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function getCompletedProgress(status: ScheduledConditionStatus) {
|
||||
if (status === "error") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 100;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createOperationalScheduledConditions } from "./scheduled-conditions";
|
||||
|
||||
describe("createOperationalScheduledConditions", () => {
|
||||
it("keeps a scheduled condition running during the previous gap window", () => {
|
||||
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T11:43:30+08:00"));
|
||||
const runningCondition = conditions.find(
|
||||
(condition) => condition.kind === "condition" && condition.status === "running"
|
||||
);
|
||||
|
||||
expect(runningCondition?.title).toBe("SCADA 数据诊断");
|
||||
expect(runningCondition?.durationMinutes).toBe(5);
|
||||
expect(runningCondition?.scheduledAt).toContain("T11:40:");
|
||||
});
|
||||
|
||||
it("rolls the running condition into history when the next interval starts", () => {
|
||||
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T11:45:30+08:00"));
|
||||
const previousCondition = conditions.find(
|
||||
(condition) => condition.kind === "condition" && condition.id.includes("scada-diagnosis-202607071140")
|
||||
);
|
||||
const currentCondition = conditions.find(
|
||||
(condition) => condition.kind === "condition" && condition.id.includes("scada-diagnosis-202607071145")
|
||||
);
|
||||
|
||||
expect(previousCondition?.status).not.toBe("running");
|
||||
expect(previousCondition?.durationMinutes).toBe(5);
|
||||
expect(currentCondition?.status).toBe("running");
|
||||
expect(currentCondition?.durationMinutes).toBe(5);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
import type { WorkbenchScenario, WorkbenchUser } from "../types";
|
||||
|
||||
export const WORKBENCH_SCENARIOS: WorkbenchScenario[] = [
|
||||
{
|
||||
id: "scenario-a",
|
||||
name: "方案 A",
|
||||
description: "关闭事故点上下游阀门,优先保障核心片区压力。",
|
||||
status: "active"
|
||||
},
|
||||
{
|
||||
id: "scenario-b",
|
||||
name: "方案 B",
|
||||
description: "扩大隔离范围,降低抢修期间二次爆管风险。",
|
||||
status: "review"
|
||||
},
|
||||
{
|
||||
id: "scenario-c",
|
||||
name: "夜间保供",
|
||||
description: "按夜间低峰工况调整泵站与分区调度策略。",
|
||||
status: "draft"
|
||||
}
|
||||
];
|
||||
|
||||
export const WORKBENCH_USER: WorkbenchUser = {
|
||||
name: "调度员",
|
||||
role: "排水运行调度中心"
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { MapAnnotation } from "../types";
|
||||
|
||||
export const mapAnnotations: MapAnnotation[] = [
|
||||
{
|
||||
id: "burst",
|
||||
label: "爆管位置",
|
||||
coordinate: [121.5215, 30.9084],
|
||||
kind: "burst"
|
||||
},
|
||||
{
|
||||
id: "dn600",
|
||||
label: "DN600",
|
||||
coordinate: [121.512, 30.9102],
|
||||
kind: "pipe-label"
|
||||
},
|
||||
{
|
||||
id: "east-gate",
|
||||
label: "东直门小区",
|
||||
coordinate: [121.545, 30.951],
|
||||
kind: "district"
|
||||
},
|
||||
{
|
||||
id: "impact",
|
||||
label: "影响范围 约 1.82 km²",
|
||||
coordinate: [121.567, 30.896],
|
||||
kind: "tooltip"
|
||||
}
|
||||
];
|
||||
|
||||
export const impactAreaPolygon = {
|
||||
type: "FeatureCollection" as const,
|
||||
features: [
|
||||
{
|
||||
type: "Feature" as const,
|
||||
properties: {
|
||||
id: "impact-area",
|
||||
label: "影响范围",
|
||||
area: "1.82 km²"
|
||||
},
|
||||
geometry: {
|
||||
type: "Polygon" as const,
|
||||
coordinates: [
|
||||
[
|
||||
[121.5205, 30.9084],
|
||||
[121.548, 30.928],
|
||||
[121.604, 30.918],
|
||||
[121.626, 30.886],
|
||||
[121.584, 30.862],
|
||||
[121.538, 30.873],
|
||||
[121.506, 30.895],
|
||||
[121.5205, 30.9084]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const annotationPoints = {
|
||||
type: "FeatureCollection" as const,
|
||||
features: mapAnnotations.map((annotation) => ({
|
||||
type: "Feature" as const,
|
||||
properties: {
|
||||
id: annotation.id,
|
||||
label: annotation.label,
|
||||
kind: annotation.kind
|
||||
},
|
||||
geometry: {
|
||||
type: "Point" as const,
|
||||
coordinates: annotation.coordinate
|
||||
}
|
||||
}))
|
||||
};
|
||||
@@ -0,0 +1,475 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type {
|
||||
AgentChatMessage,
|
||||
AgentPermissionReply,
|
||||
AgentPermissionStatus,
|
||||
AgentQuestionRequest,
|
||||
AgentStreamRenderState
|
||||
} from "@/features/agent";
|
||||
import type { AgentSessionStreamEvent } from "@/features/agent/api/client";
|
||||
import {
|
||||
applyPermissionResponse,
|
||||
applyQuestionResponse,
|
||||
toTodoUpdate,
|
||||
upsertPermission,
|
||||
upsertProgress,
|
||||
upsertQuestion
|
||||
} from "@/features/agent/session-state";
|
||||
|
||||
type AgentUiDataParts = {
|
||||
progress: Record<string, unknown>;
|
||||
todo_update: Record<string, unknown>;
|
||||
permission_request: Record<string, unknown>;
|
||||
permission_response: Record<string, unknown>;
|
||||
question_request: Record<string, unknown>;
|
||||
question_response: Record<string, unknown>;
|
||||
stream_token: Record<string, unknown>;
|
||||
tool_call: Record<string, unknown>;
|
||||
frontend_action: Record<string, unknown>;
|
||||
frontend_action_result: Record<string, unknown>;
|
||||
ui_envelope: Record<string, unknown>;
|
||||
session_title: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AgentUiMessage = UIMessage<unknown, AgentUiDataParts>;
|
||||
|
||||
export type AgentDataPart = Extract<AgentUiMessage["parts"][number], { type: `data-${string}` }>;
|
||||
|
||||
export type PermissionOverride = {
|
||||
status: AgentPermissionStatus;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type QuestionOverride = {
|
||||
status: AgentQuestionRequest["status"];
|
||||
answers?: string[][];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type StreamRenderStateSetter = Dispatch<SetStateAction<AgentStreamRenderState>>;
|
||||
|
||||
export function markStreamRenderPending(
|
||||
data: Record<string, unknown>,
|
||||
messages: AgentUiMessage[],
|
||||
setStreamRenderState: StreamRenderStateSetter
|
||||
) {
|
||||
const text = getDataString(data, "content");
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = getDataString(data, "message_id") ?? getLastAssistantUiMessageId(messages);
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStreamRenderState((current) => {
|
||||
if (current[messageId]?.done === false) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[messageId]: { done: false }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function createCompletedStreamRenderState(messages: AgentUiMessage[]): AgentStreamRenderState {
|
||||
return messages.reduce<AgentStreamRenderState>((next, message) => {
|
||||
if (message.role === "assistant") {
|
||||
next[message.id] = { done: true };
|
||||
}
|
||||
return next;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function markLastAssistantStreamDone(
|
||||
messages: AgentUiMessage[],
|
||||
setStreamRenderState: StreamRenderStateSetter
|
||||
) {
|
||||
const messageId = getLastAssistantUiMessageId(messages);
|
||||
setStreamRenderState((current) => {
|
||||
if (!messageId) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(current).map(([id, state]) => [id, { ...state, done: true }])
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[messageId]: { done: true }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function toAgentChatMessages(
|
||||
messages: AgentUiMessage[],
|
||||
permissionOverrides: Record<string, PermissionOverride>,
|
||||
questionOverrides: Record<string, QuestionOverride>
|
||||
): AgentChatMessage[] {
|
||||
return messages.flatMap((message) => {
|
||||
if (message.role !== "user" && message.role !== "assistant") {
|
||||
return [];
|
||||
}
|
||||
|
||||
let next: AgentChatMessage = {
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: collectMessageText(message)
|
||||
};
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (!isAgentDataPart(part)) {
|
||||
continue;
|
||||
}
|
||||
if (part.type === "data-progress") {
|
||||
next = {
|
||||
...next,
|
||||
progress: upsertProgress(next.progress, part.data)
|
||||
};
|
||||
} else if (part.type === "data-todo_update") {
|
||||
const todoUpdate = toTodoUpdate(part.data);
|
||||
if (todoUpdate) {
|
||||
next = {
|
||||
...next,
|
||||
todos: todoUpdate
|
||||
};
|
||||
}
|
||||
} else if (part.type === "data-permission_request") {
|
||||
next = {
|
||||
...next,
|
||||
permissions: upsertPermission(next.permissions, part.data)
|
||||
};
|
||||
} else if (part.type === "data-permission_response") {
|
||||
next = {
|
||||
...next,
|
||||
permissions: applyPermissionResponse(next.permissions, part.data)
|
||||
};
|
||||
} else if (part.type === "data-question_request") {
|
||||
next = {
|
||||
...next,
|
||||
questions: upsertQuestion(next.questions, part.data)
|
||||
};
|
||||
} else if (part.type === "data-question_response") {
|
||||
next = {
|
||||
...next,
|
||||
questions: applyQuestionResponse(next.questions, part.data)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return [applyQuestionOverrides(applyPermissionOverrides(next, permissionOverrides), questionOverrides)];
|
||||
});
|
||||
}
|
||||
|
||||
export function toAgentUiMessages(messages: unknown[]): AgentUiMessage[] {
|
||||
return messages.flatMap((message) => {
|
||||
if (isAgentUiMessage(message)) {
|
||||
return [message];
|
||||
}
|
||||
const legacyMessage = toAgentUiMessageFromLegacy(message);
|
||||
return legacyMessage ? [legacyMessage] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function applySessionStreamEvent(messages: AgentUiMessage[], event: AgentSessionStreamEvent): AgentUiMessage[] {
|
||||
if (event.type === "state") {
|
||||
return toAgentUiMessages(event.messages);
|
||||
}
|
||||
|
||||
if (event.type === "token") {
|
||||
const token = getDataString(event.data, "content");
|
||||
return token ? updateLastAssistantUiMessage(messages, (message) => appendTextToUiMessage(message, token)) : messages;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "progress" ||
|
||||
event.type === "todo_update" ||
|
||||
event.type === "permission_request" ||
|
||||
event.type === "permission_response" ||
|
||||
event.type === "question_request" ||
|
||||
event.type === "question_response" ||
|
||||
event.type === "ui_envelope" ||
|
||||
event.type === "session_title"
|
||||
) {
|
||||
return updateLastAssistantUiMessage(messages, (message) =>
|
||||
upsertUiDataPart(message, event.type, event.data)
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === "error") {
|
||||
const message = getDataString(event.data, "message") ?? "Agent stream failed";
|
||||
return updateLastAssistantUiMessage(messages, (item) =>
|
||||
appendTextToUiMessage(item, item.parts.some((part) => part.type === "text") ? `\n\n错误:${message}` : `错误:${message}`)
|
||||
);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function toPermissionStatus(reply: AgentPermissionReply): AgentPermissionStatus {
|
||||
if (reply === "always") {
|
||||
return "approved_always";
|
||||
}
|
||||
if (reply === "once") {
|
||||
return "approved_once";
|
||||
}
|
||||
return "rejected";
|
||||
}
|
||||
|
||||
export function getDataString(data: unknown, key: string) {
|
||||
if (typeof data !== "object" || data === null) {
|
||||
return undefined;
|
||||
}
|
||||
const value = (data as Record<string, unknown>)[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function readBodyString(body: unknown, key: string) {
|
||||
if (typeof body !== "object" || body === null) {
|
||||
return undefined;
|
||||
}
|
||||
const value = (body as Record<string, unknown>)[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function getLastAssistantUiMessageId(messages: AgentUiMessage[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (messages[index].role === "assistant") {
|
||||
return messages[index].id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isAgentUiMessage(value: unknown): value is AgentUiMessage {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
const message = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof message.id === "string" &&
|
||||
(message.role === "user" || message.role === "assistant") &&
|
||||
Array.isArray(message.parts)
|
||||
);
|
||||
}
|
||||
|
||||
function toAgentUiMessageFromLegacy(value: unknown): AgentUiMessage | null {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof message.id !== "string" ||
|
||||
(message.role !== "user" && message.role !== "assistant")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: AgentUiMessage["parts"] = [];
|
||||
if (typeof message.content === "string" && message.content) {
|
||||
parts.push({ type: "text", text: message.content });
|
||||
}
|
||||
appendLegacyDataParts(parts, "progress", message.progress);
|
||||
appendLegacyDataParts(parts, "permission_request", message.permissions);
|
||||
appendLegacyDataParts(parts, "question_request", message.questions);
|
||||
appendLegacyDataParts(parts, "todo_update", message.todos);
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
parts
|
||||
} as AgentUiMessage;
|
||||
}
|
||||
|
||||
function appendLegacyDataParts(
|
||||
parts: AgentUiMessage["parts"],
|
||||
eventType: string,
|
||||
value: unknown
|
||||
) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
values.forEach((item, index) => {
|
||||
if (typeof item !== "object" || item === null) {
|
||||
return;
|
||||
}
|
||||
const data = normalizeLegacyDataPart(eventType, item as Record<string, unknown>);
|
||||
const id =
|
||||
getDataString(data, "id") ??
|
||||
getDataString(data, "request_id") ??
|
||||
`${eventType}-${index}`;
|
||||
parts.push({
|
||||
type: `data-${eventType}`,
|
||||
id,
|
||||
data
|
||||
} as AgentDataPart);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLegacyDataPart(eventType: string, value: Record<string, unknown>) {
|
||||
if (eventType === "progress") {
|
||||
return {
|
||||
...value,
|
||||
started_at: value.started_at ?? value.startedAt,
|
||||
ended_at: value.ended_at ?? value.endedAt,
|
||||
elapsed_ms: value.elapsed_ms ?? value.elapsedMs,
|
||||
duration_ms: value.duration_ms ?? value.durationMs
|
||||
};
|
||||
}
|
||||
|
||||
if (eventType === "permission_request" || eventType === "question_request") {
|
||||
return {
|
||||
...value,
|
||||
session_id: value.session_id ?? value.sessionId,
|
||||
request_id: value.request_id ?? value.requestId,
|
||||
created_at: value.created_at ?? value.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
if (eventType === "todo_update") {
|
||||
return {
|
||||
...value,
|
||||
session_id: value.session_id ?? value.sessionId,
|
||||
message_id: value.message_id ?? value.messageId,
|
||||
created_at: value.created_at ?? value.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function updateLastAssistantUiMessage(
|
||||
messages: AgentUiMessage[],
|
||||
updater: (message: AgentUiMessage) => AgentUiMessage
|
||||
) {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (messages[index].role === "assistant") {
|
||||
const next = [...messages];
|
||||
next[index] = updater(messages[index]);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
return [...messages, updater(createAssistantUiMessage())];
|
||||
}
|
||||
|
||||
function createAssistantUiMessage(): AgentUiMessage {
|
||||
return {
|
||||
id: `assistant-${Date.now().toString(36)}`,
|
||||
role: "assistant",
|
||||
parts: []
|
||||
} as AgentUiMessage;
|
||||
}
|
||||
|
||||
function appendTextToUiMessage(message: AgentUiMessage, text: string): AgentUiMessage {
|
||||
const textIndex = message.parts.findIndex((part) => part.type === "text");
|
||||
if (textIndex === -1) {
|
||||
return {
|
||||
...message,
|
||||
parts: [{ type: "text", text }, ...message.parts]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
parts: message.parts.map((part, index) =>
|
||||
index === textIndex && part.type === "text"
|
||||
? { ...part, text: `${part.text}${text}` }
|
||||
: part
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function upsertUiDataPart(
|
||||
message: AgentUiMessage,
|
||||
eventType: string,
|
||||
data: Record<string, unknown>
|
||||
): AgentUiMessage {
|
||||
const type = `data-${eventType}`;
|
||||
const id =
|
||||
getDataString(data, "id") ??
|
||||
getDataString(data, "request_id") ??
|
||||
getDataString(data, "envelope_id");
|
||||
const existingIndex =
|
||||
id === undefined
|
||||
? -1
|
||||
: message.parts.findIndex((part) => part.type === type && "id" in part && part.id === id);
|
||||
const part = (id ? { type, id, data } : { type, data }) as AgentDataPart;
|
||||
|
||||
if (existingIndex === -1) {
|
||||
return {
|
||||
...message,
|
||||
parts: [...message.parts, part]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
parts: message.parts.map((item, index) => (index === existingIndex ? part : item))
|
||||
};
|
||||
}
|
||||
|
||||
function applyPermissionOverrides(
|
||||
message: AgentChatMessage,
|
||||
permissionOverrides: Record<string, PermissionOverride>
|
||||
) {
|
||||
if (!message.permissions?.length) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
permissions: message.permissions.map((permission) => {
|
||||
const override = permissionOverrides[permission.requestId];
|
||||
return override
|
||||
? {
|
||||
...permission,
|
||||
status: override.status,
|
||||
error: override.error,
|
||||
repliedAt: override.status === "submitting" || override.status === "error" ? permission.repliedAt : Date.now()
|
||||
}
|
||||
: permission;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function applyQuestionOverrides(
|
||||
message: AgentChatMessage,
|
||||
questionOverrides: Record<string, QuestionOverride>
|
||||
) {
|
||||
if (!message.questions?.length) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
questions: message.questions.map((question) => {
|
||||
const override = questionOverrides[question.requestId];
|
||||
return override
|
||||
? {
|
||||
...question,
|
||||
status: override.status,
|
||||
answers: override.answers ?? question.answers,
|
||||
error: override.error,
|
||||
repliedAt: override.status === "submitting" || override.status === "error" ? question.repliedAt : Date.now()
|
||||
}
|
||||
: question;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function collectMessageText(message: AgentUiMessage) {
|
||||
return message.parts
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function isAgentDataPart(part: AgentUiMessage["parts"][number]): part is AgentDataPart {
|
||||
return typeof part.type === "string" && part.type.startsWith("data-") && "data" in part;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearMapFeatureInteractionState,
|
||||
setMapFeatureInteractionState,
|
||||
toMapFeatureReference
|
||||
} from "./use-map-interactions";
|
||||
|
||||
describe("map feature interaction state", () => {
|
||||
const feature = { source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" } as const;
|
||||
|
||||
it("maps selected business features to promoted vector feature references", () => {
|
||||
expect(toMapFeatureReference({ id: "junction-7", layer: "junctions" })).toEqual(feature);
|
||||
expect(toMapFeatureReference(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("sets hover and selected through feature-state", () => {
|
||||
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
|
||||
setMapFeatureInteractionState(map, feature, { selected: true, hovered: false });
|
||||
expect(map.setFeatureState).toHaveBeenCalledWith(
|
||||
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
|
||||
{ selected: true, hovered: false }
|
||||
);
|
||||
});
|
||||
|
||||
it("clears only the requested state key", () => {
|
||||
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
|
||||
clearMapFeatureInteractionState(map, feature, "selected");
|
||||
expect(map.removeFeatureState).toHaveBeenCalledWith(
|
||||
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
|
||||
"selected"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap, MapLayerMouseEvent } from "maplibre-gl";
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
import type { DetailFeature } from "../types";
|
||||
import {
|
||||
getFeatureId,
|
||||
getWaterNetworkSourceId,
|
||||
toDetailFeature
|
||||
} from "../map/feature-adapter";
|
||||
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
|
||||
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources";
|
||||
import { SCADA_HIT_LAYER_ID } from "../map/scada";
|
||||
|
||||
export type MapFeatureInteractionState = {
|
||||
source: WaterNetworkSourceId;
|
||||
sourceLayer: string;
|
||||
id: string;
|
||||
hovered: boolean;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
type FeatureStateMap = Pick<MapLibreMap, "setFeatureState" | "removeFeatureState">;
|
||||
type FeatureReference = Pick<MapFeatureInteractionState, "source" | "sourceLayer" | "id">;
|
||||
|
||||
export function toMapFeatureReference(
|
||||
feature: Pick<DetailFeature, "id" | "layer"> | null
|
||||
): FeatureReference | null {
|
||||
if (!feature?.id) return null;
|
||||
return { source: feature.layer, sourceLayer: SOURCE_LAYERS[feature.layer], id: feature.id };
|
||||
}
|
||||
|
||||
export function setMapFeatureInteractionState(
|
||||
map: FeatureStateMap,
|
||||
feature: FeatureReference,
|
||||
state: Partial<Pick<MapFeatureInteractionState, "hovered" | "selected">>
|
||||
) {
|
||||
map.setFeatureState(
|
||||
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
|
||||
state
|
||||
);
|
||||
}
|
||||
|
||||
export function clearMapFeatureInteractionState(
|
||||
map: FeatureStateMap,
|
||||
feature: FeatureReference,
|
||||
key?: "hovered" | "selected"
|
||||
) {
|
||||
map.removeFeatureState(
|
||||
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
|
||||
key
|
||||
);
|
||||
}
|
||||
|
||||
type UseMapInteractionsOptions = {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
onSelectFeature: (feature: DetailFeature) => void;
|
||||
selectedFeature: DetailFeature | null;
|
||||
};
|
||||
|
||||
export function useMapInteractions({
|
||||
mapRef,
|
||||
mapReady,
|
||||
onSelectFeature,
|
||||
selectedFeature
|
||||
}: UseMapInteractionsOptions) {
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) return;
|
||||
|
||||
let hoveredFeature: FeatureReference | null = null;
|
||||
|
||||
const clearHoveredFeature = () => {
|
||||
if (!hoveredFeature) return;
|
||||
clearMapFeatureInteractionState(map, hoveredFeature, "hovered");
|
||||
hoveredFeature = null;
|
||||
};
|
||||
|
||||
const handleMouseMove = (event: MapLayerMouseEvent) => {
|
||||
const feature = event.features?.[0];
|
||||
const source = feature && getWaterNetworkSourceId(feature);
|
||||
const id = feature && getFeatureId(feature);
|
||||
if (!source || !id) return;
|
||||
|
||||
const next = { source, sourceLayer: SOURCE_LAYERS[source], id };
|
||||
if (selectedFeature?.id === id && selectedFeature.layer === source) {
|
||||
clearHoveredFeature();
|
||||
return;
|
||||
}
|
||||
if (hoveredFeature?.id === id && hoveredFeature.source === source) return;
|
||||
|
||||
clearHoveredFeature();
|
||||
setMapFeatureInteractionState(map, next, { hovered: true });
|
||||
hoveredFeature = next;
|
||||
map.getCanvas().style.cursor = "pointer";
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearHoveredFeature();
|
||||
map.getCanvas().style.cursor = "";
|
||||
};
|
||||
|
||||
const handleClick = (event: MapLayerMouseEvent) => {
|
||||
const feature = event.features?.[0];
|
||||
if (feature) onSelectFeature(toDetailFeature(feature));
|
||||
};
|
||||
|
||||
const hitLayerIds = [SCADA_HIT_LAYER_ID, ...INTERACTIVE_HIT_LAYER_IDS];
|
||||
map.on("mousemove", hitLayerIds, handleMouseMove);
|
||||
map.on("mouseleave", hitLayerIds, handleMouseLeave);
|
||||
map.on("click", hitLayerIds, handleClick);
|
||||
|
||||
return () => {
|
||||
clearHoveredFeature();
|
||||
map.off("mousemove", hitLayerIds, handleMouseMove);
|
||||
map.off("mouseleave", hitLayerIds, handleMouseLeave);
|
||||
map.off("click", hitLayerIds, handleClick);
|
||||
};
|
||||
}, [mapRef, mapReady, onSelectFeature, selectedFeature]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) return;
|
||||
const next = toMapFeatureReference(selectedFeature);
|
||||
if (next) setMapFeatureInteractionState(map, next, { selected: true, hovered: false });
|
||||
return () => {
|
||||
if (next) clearMapFeatureInteractionState(map, next, "selected");
|
||||
};
|
||||
}, [mapReady, mapRef, selectedFeature]);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { setSimulationLayersVisibility } from "../map/simulation-layers";
|
||||
|
||||
type UseSimulationLayerVisibilityOptions = {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export function useSimulationLayerVisibility({
|
||||
mapRef,
|
||||
mapReady,
|
||||
visible
|
||||
}: UseSimulationLayerVisibilityOptions) {
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSimulationLayersVisibility(map, visible);
|
||||
}, [mapRef, mapReady, visible]);
|
||||
}
|
||||
@@ -0,0 +1,884 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import useSWR from "swr";
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { showMapNotice } from "@/features/map/core/components/notice-actions";
|
||||
import type {
|
||||
AgentApprovalMode,
|
||||
AgentChatMessage,
|
||||
AgentModelOption,
|
||||
AgentPermissionReply,
|
||||
AgentPermissionRequest,
|
||||
AgentQuestionRequest,
|
||||
AgentStreamRenderState
|
||||
} from "@/features/agent";
|
||||
import {
|
||||
createAgentApiClient,
|
||||
type AgentSessionStreamEvent
|
||||
} from "@/features/agent/api/client";
|
||||
import {
|
||||
agentBootstrapKey,
|
||||
agentSessionsKey,
|
||||
fetchAgentBootstrap,
|
||||
fetchAgentSessions
|
||||
} from "@/features/agent/api/swr";
|
||||
import {
|
||||
isUiEnvelopeAllowed,
|
||||
parseUiEnvelopePayload,
|
||||
type UIEnvelopePayload,
|
||||
type UIRegistry
|
||||
} from "@/features/agent/ui-envelope";
|
||||
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
|
||||
import { FrontendActionExecutor } from "@/features/agent/frontend-action/executor";
|
||||
import {
|
||||
applySessionStreamEvent,
|
||||
createCompletedStreamRenderState,
|
||||
getDataString,
|
||||
markLastAssistantStreamDone,
|
||||
markStreamRenderPending,
|
||||
readBodyString,
|
||||
toAgentChatMessages,
|
||||
toAgentUiMessages,
|
||||
toPermissionStatus,
|
||||
type AgentDataPart,
|
||||
type AgentUiMessage,
|
||||
type PermissionOverride,
|
||||
type QuestionOverride
|
||||
} from "./agent-session-message-state";
|
||||
|
||||
const AGENT_PANEL_COLLAPSE_MS = 180;
|
||||
|
||||
type AgentRuntimeAvailability = "connecting" | "connected" | "unavailable" | "failed";
|
||||
|
||||
type UseWorkbenchAgentOptions = {
|
||||
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
|
||||
onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkbenchAgentOptions) {
|
||||
const collapseTimerRef = useRef<number | null>(null);
|
||||
const mobileCollapseTimerRef = useRef<number | null>(null);
|
||||
const sessionStreamAbortRef = useRef<AbortController | null>(null);
|
||||
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);
|
||||
const processedEnvelopeIdsRef = useRef(new Set<string>());
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
const [panelCollapsing, setPanelCollapsing] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [registry, setRegistry] = useState<UIRegistry | null>(null);
|
||||
const [sessionTitle, setSessionTitle] = useState("新对话");
|
||||
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
|
||||
const [runtimeAvailability, setRuntimeAvailability] = useState<AgentRuntimeAvailability>("connecting");
|
||||
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>>({});
|
||||
|
||||
useEffect(() => {
|
||||
sessionIdRef.current = sessionId;
|
||||
}, [sessionId]);
|
||||
|
||||
const setApprovalMode = useCallback((mode: AgentApprovalMode) => {
|
||||
approvalModeRef.current = mode;
|
||||
setApprovalModeState(mode);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
registryRef.current = registry;
|
||||
}, [registry]);
|
||||
|
||||
useEffect(() => {
|
||||
onFrontendActionRef.current = onFrontendAction;
|
||||
}, [onFrontendAction]);
|
||||
|
||||
const applySessionId = useCallback((nextSessionId: string | undefined) => {
|
||||
if (!nextSessionId || sessionIdRef.current === nextSessionId) {
|
||||
return;
|
||||
}
|
||||
sessionIdRef.current = nextSessionId;
|
||||
setSessionId(nextSessionId);
|
||||
}, []);
|
||||
|
||||
const clearSessionId = useCallback(() => {
|
||||
sessionIdRef.current = null;
|
||||
setSessionId(null);
|
||||
}, []);
|
||||
|
||||
const resolveRenderRef = useCallback((renderRef: string, nextSessionId: string) => {
|
||||
return clientRef.current.resolveRenderRef(renderRef, nextSessionId);
|
||||
}, []);
|
||||
|
||||
const frontendActionExecutor = useMemo(
|
||||
() => new FrontendActionExecutor(
|
||||
(request, signal) => onFrontendActionRef.current(request, signal),
|
||||
(nextSessionId, actionId, result) => clientRef.current.submitFrontendActionResult(nextSessionId, actionId, result)
|
||||
),
|
||||
[]
|
||||
);
|
||||
const handleFrontendAction = useCallback(
|
||||
(value: unknown) => frontendActionExecutor.handle(value, sessionIdRef.current),
|
||||
[frontendActionExecutor]
|
||||
);
|
||||
|
||||
const claimEnvelope = useCallback((payload: UIEnvelopePayload, activeSessionId: string) => {
|
||||
if (payload.session_id !== activeSessionId) return false;
|
||||
const key = `${activeSessionId}:${payload.envelope_id}`;
|
||||
if (processedEnvelopeIdsRef.current.has(key)) return false;
|
||||
processedEnvelopeIdsRef.current.add(key);
|
||||
if (processedEnvelopeIdsRef.current.size > 256) {
|
||||
const oldest = processedEnvelopeIdsRef.current.values().next().value;
|
||||
if (oldest) processedEnvelopeIdsRef.current.delete(oldest);
|
||||
}
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const handleDataPart = useCallback(
|
||||
(part: AgentDataPart) => {
|
||||
const eventSessionId = getDataString(part.data, "session_id");
|
||||
if (eventSessionId && sessionIdRef.current && eventSessionId !== sessionIdRef.current) {
|
||||
return;
|
||||
}
|
||||
applySessionId(eventSessionId);
|
||||
|
||||
if (part.type === "data-stream_token") {
|
||||
markStreamRenderPending(
|
||||
part.data,
|
||||
chatRef.current.messages,
|
||||
setStreamRenderState
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.type === "data-progress") {
|
||||
const title = getDataString(part.data, "title");
|
||||
if (title) {
|
||||
setStatusLabel(title);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (part.type === "data-frontend_action") { void handleFrontendAction(part.data); return; }
|
||||
|
||||
if (part.type === "data-session_title") {
|
||||
const title = getDataString(part.data, "title");
|
||||
if (title) {
|
||||
setSessionTitle(title);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.type !== "data-ui_envelope") {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = parseUiEnvelopePayload(part.data);
|
||||
const activeSessionId = getDataString(part.data, "session_id") ?? sessionIdRef.current;
|
||||
if (!payload || !activeSessionId || !isUiEnvelopeAllowed(payload.envelope, registryRef.current)) {
|
||||
showMapNotice({
|
||||
tone: "warning",
|
||||
title: "Agent 展示已降级",
|
||||
message: "收到的 UIEnvelope 未通过前端白名单校验。"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!claimEnvelope(payload, activeSessionId)) return;
|
||||
|
||||
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
|
||||
showMapNotice({
|
||||
tone: "error",
|
||||
title: "Agent UIEnvelope 处理失败",
|
||||
message: error instanceof Error ? error.message : "工作台处理 Agent 展示结果失败。"
|
||||
});
|
||||
});
|
||||
},
|
||||
[applySessionId, claimEnvelope, handleFrontendAction, onUiEnvelope]
|
||||
);
|
||||
|
||||
const transport = useMemo(
|
||||
() =>
|
||||
new DefaultChatTransport<AgentUiMessage>({
|
||||
api: `${env.DRAINAGE_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`,
|
||||
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
|
||||
return {
|
||||
body: {
|
||||
...body,
|
||||
id,
|
||||
messages,
|
||||
trigger,
|
||||
messageId,
|
||||
session_id: readBodyString(body, "session_id") ?? sessionIdRef.current ?? undefined,
|
||||
approval_mode: readBodyString(body, "approval_mode") ?? approvalModeRef.current,
|
||||
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : []
|
||||
}
|
||||
};
|
||||
}
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const chat = useChat<AgentUiMessage>({
|
||||
transport,
|
||||
onData: (part) => handleDataPart(part as AgentDataPart),
|
||||
onError: (error) => {
|
||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||
setRuntimeAvailability("failed");
|
||||
setStatusLabel("Agent 后端请求失败");
|
||||
showMapNotice({
|
||||
id: "agent-stream-status",
|
||||
tone: "error",
|
||||
title: "Agent 请求失败",
|
||||
message: error.message || "请确认后端服务已启动。"
|
||||
});
|
||||
},
|
||||
onFinish: () => {
|
||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||
setStatusLabel("Agent 分析完成");
|
||||
}
|
||||
});
|
||||
const chatRef = useRef(chat);
|
||||
|
||||
useEffect(() => {
|
||||
chatRef.current = chat;
|
||||
}, [chat]);
|
||||
|
||||
const {
|
||||
data: backendConnection,
|
||||
error: backendConnectionError
|
||||
} = useSWRImmutable(agentBootstrapKey, () => fetchAgentBootstrap(clientRef.current), {
|
||||
shouldRetryOnError: false
|
||||
});
|
||||
const {
|
||||
data: sessionHistory = [],
|
||||
isLoading: sessionHistoryLoading,
|
||||
isValidating: sessionHistoryValidating,
|
||||
mutate: mutateSessionHistory
|
||||
} = useSWR(agentSessionsKey, () => fetchAgentSessions(clientRef.current), {
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false
|
||||
});
|
||||
|
||||
const clearCollapseTimer = useCallback(() => {
|
||||
if (collapseTimerRef.current !== null) {
|
||||
window.clearTimeout(collapseTimerRef.current);
|
||||
collapseTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearMobileCollapseTimer = useCallback(() => {
|
||||
if (mobileCollapseTimerRef.current !== null) {
|
||||
window.clearTimeout(mobileCollapseTimerRef.current);
|
||||
mobileCollapseTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopSessionStreamSubscription = useCallback(() => {
|
||||
sessionStreamAbortRef.current?.abort();
|
||||
sessionStreamAbortRef.current = null;
|
||||
setResumedStreaming(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearCollapseTimer();
|
||||
clearMobileCollapseTimer();
|
||||
stopSessionStreamSubscription();
|
||||
frontendActionExecutor.cancelAll();
|
||||
chatRef.current.stop();
|
||||
};
|
||||
}, [clearCollapseTimer, clearMobileCollapseTimer, frontendActionExecutor, stopSessionStreamSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!backendConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
registryRef.current = backendConnection.registry;
|
||||
frontendActionEnabledRef.current = Boolean(backendConnection.frontendActionRegistry);
|
||||
setRegistry(backendConnection.registry);
|
||||
setModelOptions(backendConnection.models);
|
||||
setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || "");
|
||||
if (chatRef.current.status === "ready") {
|
||||
setRuntimeAvailability("connected");
|
||||
setStatusLabel("准备就绪");
|
||||
}
|
||||
}, [backendConnection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!backendConnectionError) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRuntimeAvailability("unavailable");
|
||||
setStatusLabel("Agent 后端不可用");
|
||||
}, [backendConnectionError]);
|
||||
|
||||
const collapsePanel = useCallback(() => {
|
||||
clearCollapseTimer();
|
||||
|
||||
if (prefersReducedMotion()) {
|
||||
setPanelOpen(false);
|
||||
setPanelCollapsing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPanelCollapsing(true);
|
||||
collapseTimerRef.current = window.setTimeout(() => {
|
||||
setPanelOpen(false);
|
||||
setPanelCollapsing(false);
|
||||
collapseTimerRef.current = null;
|
||||
}, AGENT_PANEL_COLLAPSE_MS);
|
||||
}, [clearCollapseTimer]);
|
||||
|
||||
const expandPanel = useCallback(() => {
|
||||
clearCollapseTimer();
|
||||
setPanelCollapsing(false);
|
||||
setPanelOpen(true);
|
||||
}, [clearCollapseTimer]);
|
||||
|
||||
const openMobilePanel = useCallback(() => {
|
||||
clearMobileCollapseTimer();
|
||||
setMobilePanelCollapsing(false);
|
||||
setMobileOpen(true);
|
||||
}, [clearMobileCollapseTimer]);
|
||||
|
||||
const closeMobilePanel = useCallback(() => {
|
||||
clearMobileCollapseTimer();
|
||||
|
||||
if (prefersReducedMotion()) {
|
||||
setMobileOpen(false);
|
||||
setMobilePanelCollapsing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setMobilePanelCollapsing(true);
|
||||
mobileCollapseTimerRef.current = window.setTimeout(() => {
|
||||
setMobileOpen(false);
|
||||
setMobilePanelCollapsing(false);
|
||||
mobileCollapseTimerRef.current = null;
|
||||
}, AGENT_PANEL_COLLAPSE_MS);
|
||||
}, [clearMobileCollapseTimer]);
|
||||
|
||||
const streaming = chat.status === "submitted" || chat.status === "streaming" || resumedStreaming;
|
||||
const messages = useMemo(
|
||||
() => toAgentChatMessages(chat.messages, permissionOverrides, questionOverrides),
|
||||
[chat.messages, permissionOverrides, questionOverrides]
|
||||
);
|
||||
const personaState = useMemo(
|
||||
() => deriveAgentPersonaState(messages, {
|
||||
chatStatus: chat.status,
|
||||
runtimeAvailability,
|
||||
statusLabel
|
||||
}),
|
||||
[chat.status, messages, runtimeAvailability, statusLabel]
|
||||
);
|
||||
|
||||
const refreshSessionHistory = useCallback(async () => {
|
||||
try {
|
||||
await mutateSessionHistory();
|
||||
} catch (error) {
|
||||
showMapNotice({
|
||||
id: "agent-history-status",
|
||||
tone: "error",
|
||||
title: "Agent 历史记录加载失败",
|
||||
message: error instanceof Error ? error.message : "无法获取 Agent 会话历史。"
|
||||
});
|
||||
}
|
||||
}, [mutateSessionHistory]);
|
||||
|
||||
const handleSessionStreamEvent = useCallback(
|
||||
(event: AgentSessionStreamEvent, expectedSessionId: string) => {
|
||||
if (event.type === "state") {
|
||||
if (sessionIdRef.current !== expectedSessionId && sessionIdRef.current !== event.sessionId) {
|
||||
return;
|
||||
}
|
||||
const nextMessages = toAgentUiMessages(event.messages);
|
||||
chatRef.current.setMessages(nextMessages);
|
||||
setStreamRenderState(createCompletedStreamRenderState(nextMessages));
|
||||
setResumedStreaming(event.isStreaming);
|
||||
setStatusLabel(event.isStreaming ? "Agent 会话流已恢复" : "Agent 历史会话已打开");
|
||||
return;
|
||||
}
|
||||
|
||||
const eventSessionId = event.sessionId;
|
||||
if (eventSessionId && eventSessionId !== sessionIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "session_title") {
|
||||
const title = getDataString(event.data, "title");
|
||||
if (title) {
|
||||
setSessionTitle(title);
|
||||
}
|
||||
} else if (event.type === "token") {
|
||||
markStreamRenderPending(
|
||||
event.data,
|
||||
chatRef.current.messages,
|
||||
setStreamRenderState
|
||||
);
|
||||
} else if (event.type === "ui_envelope") {
|
||||
const payload = parseUiEnvelopePayload(event.data);
|
||||
const activeSessionId = eventSessionId ?? sessionIdRef.current;
|
||||
if (!payload || !activeSessionId || !isUiEnvelopeAllowed(payload.envelope, registryRef.current)) {
|
||||
showMapNotice({
|
||||
tone: "warning",
|
||||
title: "Agent 展示已降级",
|
||||
message: "收到的 UIEnvelope 未通过前端白名单校验。"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!claimEnvelope(payload, activeSessionId)) return;
|
||||
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
|
||||
showMapNotice({
|
||||
tone: "error",
|
||||
title: "Agent UIEnvelope 处理失败",
|
||||
message: error instanceof Error ? error.message : "工作台处理 Agent 展示结果失败。"
|
||||
});
|
||||
});
|
||||
} else if (event.type === "frontend_action") {
|
||||
void handleFrontendAction(event.data);
|
||||
} else if (event.type === "progress") {
|
||||
const title = getDataString(event.data, "title");
|
||||
if (title) {
|
||||
setStatusLabel(title);
|
||||
}
|
||||
} else if (event.type === "done") {
|
||||
setResumedStreaming(false);
|
||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||
setStatusLabel("Agent 分析完成");
|
||||
void refreshSessionHistory();
|
||||
} else if (event.type === "error") {
|
||||
setResumedStreaming(false);
|
||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||
setStatusLabel("Agent 后端请求失败");
|
||||
void refreshSessionHistory();
|
||||
}
|
||||
|
||||
chatRef.current.setMessages((current) => applySessionStreamEvent(current, event));
|
||||
},
|
||||
[claimEnvelope, handleFrontendAction, onUiEnvelope, refreshSessionHistory]
|
||||
);
|
||||
|
||||
const startSessionStreamSubscription = useCallback(
|
||||
(nextSessionId: string) => {
|
||||
stopSessionStreamSubscription();
|
||||
|
||||
const controller = new AbortController();
|
||||
sessionStreamAbortRef.current = controller;
|
||||
setResumedStreaming(true);
|
||||
|
||||
void clientRef.current
|
||||
.streamSession(nextSessionId, {
|
||||
signal: controller.signal,
|
||||
onEvent: (event) => handleSessionStreamEvent(event, nextSessionId)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setRuntimeAvailability("failed");
|
||||
setStatusLabel("Agent 会话流恢复失败");
|
||||
showMapNotice({
|
||||
id: "agent-stream-status",
|
||||
tone: "error",
|
||||
title: "Agent 会话流恢复失败",
|
||||
message: error instanceof Error ? error.message : "无法恢复这个运行中的会话。"
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (sessionStreamAbortRef.current === controller) {
|
||||
sessionStreamAbortRef.current = null;
|
||||
setResumedStreaming(false);
|
||||
void refreshSessionHistory();
|
||||
}
|
||||
});
|
||||
},
|
||||
[handleSessionStreamEvent, refreshSessionHistory, stopSessionStreamSubscription]
|
||||
);
|
||||
|
||||
const loadHistorySession = useCallback(
|
||||
async (nextSessionId: string) => {
|
||||
if (streaming) {
|
||||
chat.stop();
|
||||
stopSessionStreamSubscription();
|
||||
}
|
||||
|
||||
try {
|
||||
const loadedSession = await clientRef.current.loadSession(nextSessionId);
|
||||
if (!loadedSession) {
|
||||
showMapNotice({
|
||||
id: "agent-history-status",
|
||||
tone: "warning",
|
||||
title: "Agent 历史记录不可用",
|
||||
message: "这个历史会话不存在或已被删除。"
|
||||
});
|
||||
await refreshSessionHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
applySessionId(loadedSession.sessionId);
|
||||
setSessionTitle(loadedSession.title);
|
||||
setStatusLabel("Agent 历史会话已打开");
|
||||
setPermissionOverrides({});
|
||||
setQuestionOverrides({});
|
||||
const nextMessages = toAgentUiMessages(loadedSession.messages);
|
||||
chat.setMessages(nextMessages);
|
||||
setStreamRenderState(createCompletedStreamRenderState(nextMessages));
|
||||
if (loadedSession.runStatus === "running" || loadedSession.isStreaming) {
|
||||
startSessionStreamSubscription(loadedSession.sessionId);
|
||||
}
|
||||
await refreshSessionHistory();
|
||||
} catch (error) {
|
||||
showMapNotice({
|
||||
id: "agent-history-status",
|
||||
tone: "error",
|
||||
title: "Agent 历史记录打开失败",
|
||||
message: error instanceof Error ? error.message : "无法打开这个历史会话。"
|
||||
});
|
||||
}
|
||||
},
|
||||
[applySessionId, chat, refreshSessionHistory, startSessionStreamSubscription, stopSessionStreamSubscription, streaming]
|
||||
);
|
||||
|
||||
const renameHistorySession = useCallback(
|
||||
async (nextSessionId: string, title: string) => {
|
||||
const normalizedTitle = title.trim();
|
||||
if (!normalizedTitle) {
|
||||
showMapNotice({ tone: "warning", message: "对话主题不能为空。" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await clientRef.current.updateSessionTitle(nextSessionId, normalizedTitle, {
|
||||
isTitleManuallyEdited: true
|
||||
});
|
||||
await mutateSessionHistory(
|
||||
(current = []) =>
|
||||
current.map((session) =>
|
||||
session.id === nextSessionId
|
||||
? {
|
||||
...session,
|
||||
title: normalizedTitle,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
: session
|
||||
),
|
||||
{ revalidate: false }
|
||||
);
|
||||
if (sessionIdRef.current === nextSessionId) {
|
||||
setSessionTitle(normalizedTitle);
|
||||
}
|
||||
await refreshSessionHistory();
|
||||
} catch (error) {
|
||||
showMapNotice({
|
||||
id: "agent-history-status",
|
||||
tone: "error",
|
||||
title: "Agent 对话重命名失败",
|
||||
message: error instanceof Error ? error.message : "无法更新这个对话主题。"
|
||||
});
|
||||
}
|
||||
},
|
||||
[mutateSessionHistory, refreshSessionHistory]
|
||||
);
|
||||
|
||||
const resetDraftSession = useCallback(
|
||||
(nextStatusLabel = "Agent 新对话已准备") => {
|
||||
clearSessionId();
|
||||
setSessionTitle("新对话");
|
||||
setStatusLabel(nextStatusLabel);
|
||||
setRuntimeAvailability("connected");
|
||||
setPermissionOverrides({});
|
||||
setQuestionOverrides({});
|
||||
setStreamRenderState({});
|
||||
chat.setMessages([]);
|
||||
},
|
||||
[chat, clearSessionId]
|
||||
);
|
||||
|
||||
const deleteHistorySession = useCallback(
|
||||
async (nextSessionId: string) => {
|
||||
if (streaming) {
|
||||
showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令,完成后再删除会话。" });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await clientRef.current.deleteSession(nextSessionId);
|
||||
await mutateSessionHistory(
|
||||
(current = []) => current.filter((session) => session.id !== nextSessionId),
|
||||
{ revalidate: false }
|
||||
);
|
||||
|
||||
if (sessionIdRef.current === nextSessionId) {
|
||||
resetDraftSession();
|
||||
}
|
||||
|
||||
await refreshSessionHistory();
|
||||
return true;
|
||||
} catch (error) {
|
||||
showMapNotice({
|
||||
id: "agent-history-status",
|
||||
tone: "error",
|
||||
title: "Agent 历史记录删除失败",
|
||||
message: error instanceof Error ? error.message : "无法删除这个历史会话。"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[mutateSessionHistory, refreshSessionHistory, resetDraftSession, streaming]
|
||||
);
|
||||
|
||||
const startNewSession = useCallback(async () => {
|
||||
if (chat.status === "submitted" || chat.status === "streaming") {
|
||||
chat.stop();
|
||||
}
|
||||
stopSessionStreamSubscription();
|
||||
|
||||
resetDraftSession();
|
||||
}, [chat, resetDraftSession, stopSessionStreamSubscription]);
|
||||
|
||||
const submitPrompt = useCallback(
|
||||
async (prompt: string) => {
|
||||
const normalizedPrompt = prompt.trim();
|
||||
if (!normalizedPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (streaming) {
|
||||
showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令。" });
|
||||
return;
|
||||
}
|
||||
|
||||
setStatusLabel("Agent 正在分析");
|
||||
|
||||
await chat.sendMessage(
|
||||
{ text: normalizedPrompt },
|
||||
{
|
||||
body: {
|
||||
session_id: sessionIdRef.current ?? undefined,
|
||||
model: selectedModel || undefined,
|
||||
approval_mode: approvalMode
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[approvalMode, chat, selectedModel, streaming]
|
||||
);
|
||||
|
||||
const stopPrompt = useCallback(() => {
|
||||
chat.stop();
|
||||
stopSessionStreamSubscription();
|
||||
const activeSessionId = sessionIdRef.current;
|
||||
if (activeSessionId) {
|
||||
void clientRef.current.abort(activeSessionId).catch(() => undefined);
|
||||
}
|
||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||
}, [chat, stopSessionStreamSubscription]);
|
||||
|
||||
const replyPermission = useCallback(async (permission: AgentPermissionRequest, reply: AgentPermissionReply) => {
|
||||
setPermissionOverrides((current) => ({
|
||||
...current,
|
||||
[permission.requestId]: { status: "submitting" }
|
||||
}));
|
||||
|
||||
try {
|
||||
await clientRef.current.replyPermission(permission.requestId, {
|
||||
sessionId: permission.sessionId,
|
||||
reply
|
||||
});
|
||||
setPermissionOverrides((current) => ({
|
||||
...current,
|
||||
[permission.requestId]: {
|
||||
status: toPermissionStatus(reply)
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "权限请求提交失败。";
|
||||
setPermissionOverrides((current) => ({
|
||||
...current,
|
||||
[permission.requestId]: {
|
||||
status: "error",
|
||||
error: message
|
||||
}
|
||||
}));
|
||||
showMapNotice({
|
||||
id: "agent-permission-status",
|
||||
tone: "error",
|
||||
title: "Agent 权限提交失败",
|
||||
message
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const replyQuestion = useCallback(async (question: AgentQuestionRequest, answers: string[][]) => {
|
||||
setQuestionOverrides((current) => ({
|
||||
...current,
|
||||
[question.requestId]: { status: "submitting" }
|
||||
}));
|
||||
|
||||
try {
|
||||
await clientRef.current.replyQuestion(question.requestId, {
|
||||
sessionId: question.sessionId,
|
||||
answers
|
||||
});
|
||||
setQuestionOverrides((current) => ({
|
||||
...current,
|
||||
[question.requestId]: {
|
||||
status: "answered",
|
||||
answers
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "问题回复提交失败。";
|
||||
setQuestionOverrides((current) => ({
|
||||
...current,
|
||||
[question.requestId]: {
|
||||
status: "error",
|
||||
error: message
|
||||
}
|
||||
}));
|
||||
showMapNotice({
|
||||
id: "agent-question-status",
|
||||
tone: "error",
|
||||
title: "Agent 问题提交失败",
|
||||
message
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const rejectQuestion = useCallback(async (question: AgentQuestionRequest) => {
|
||||
setQuestionOverrides((current) => ({
|
||||
...current,
|
||||
[question.requestId]: { status: "submitting" }
|
||||
}));
|
||||
|
||||
try {
|
||||
await clientRef.current.rejectQuestion(question.requestId, {
|
||||
sessionId: question.sessionId
|
||||
});
|
||||
setQuestionOverrides((current) => ({
|
||||
...current,
|
||||
[question.requestId]: {
|
||||
status: "rejected"
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "问题跳过提交失败。";
|
||||
setQuestionOverrides((current) => ({
|
||||
...current,
|
||||
[question.requestId]: {
|
||||
status: "error",
|
||||
error: message
|
||||
}
|
||||
}));
|
||||
showMapNotice({
|
||||
id: "agent-question-status",
|
||||
tone: "error",
|
||||
title: "Agent 问题提交失败",
|
||||
message
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
approvalMode,
|
||||
collapsePanel,
|
||||
expandPanel,
|
||||
mobileOpen,
|
||||
mobilePanelCollapsing,
|
||||
messages,
|
||||
modelOptions,
|
||||
openMobilePanel,
|
||||
closeMobilePanel,
|
||||
panelCollapsing,
|
||||
panelOpen,
|
||||
personaState,
|
||||
refreshSessionHistory,
|
||||
startNewSession,
|
||||
loadHistorySession,
|
||||
renameHistorySession,
|
||||
deleteHistorySession,
|
||||
sessionHistory,
|
||||
sessionHistoryLoading: sessionHistoryLoading || sessionHistoryValidating,
|
||||
sessionId,
|
||||
sessionTitle,
|
||||
statusLabel,
|
||||
streamRenderState,
|
||||
selectedModel,
|
||||
setApprovalMode,
|
||||
setSelectedModel,
|
||||
stopPrompt,
|
||||
streaming,
|
||||
rejectQuestion,
|
||||
replyPermission,
|
||||
replyQuestion,
|
||||
resolveRenderRef,
|
||||
submitPrompt
|
||||
};
|
||||
}
|
||||
|
||||
function deriveAgentPersonaState(
|
||||
messages: AgentChatMessage[],
|
||||
{
|
||||
chatStatus,
|
||||
runtimeAvailability,
|
||||
statusLabel
|
||||
}: {
|
||||
chatStatus: string;
|
||||
runtimeAvailability: AgentRuntimeAvailability;
|
||||
statusLabel: string;
|
||||
}
|
||||
): PersonaState {
|
||||
if (
|
||||
runtimeAvailability === "unavailable" ||
|
||||
runtimeAvailability === "failed" ||
|
||||
/失败|不可用|错误|降级/.test(statusLabel)
|
||||
) {
|
||||
return "asleep";
|
||||
}
|
||||
|
||||
if (hasPendingOperatorInput(messages)) {
|
||||
return "listening";
|
||||
}
|
||||
|
||||
if (
|
||||
chatStatus === "submitted" ||
|
||||
chatStatus === "streaming" ||
|
||||
hasRunningSessionWork(messages) ||
|
||||
runtimeAvailability === "connecting"
|
||||
) {
|
||||
return "thinking";
|
||||
}
|
||||
|
||||
return "idle";
|
||||
}
|
||||
|
||||
function hasPendingOperatorInput(messages: AgentChatMessage[]) {
|
||||
return messages.some(
|
||||
(message) =>
|
||||
message.permissions?.some((permission) => permission.status === "pending" || permission.status === "error") ||
|
||||
message.questions?.some((question) => question.status === "pending" || question.status === "error")
|
||||
);
|
||||
}
|
||||
|
||||
function hasRunningSessionWork(messages: AgentChatMessage[]) {
|
||||
const latestProgress = messages.flatMap((message) => message.progress ?? []).at(-1);
|
||||
const latestTodo = messages.flatMap((message) => message.todos?.todos ?? []).at(-1);
|
||||
|
||||
return latestProgress?.status === "running" || latestTodo?.status === "in_progress";
|
||||
}
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
DRAWING_INTERACTIVE_LAYER_IDS,
|
||||
DRAWING_SOURCE_IDS,
|
||||
createCircleFeature,
|
||||
createLineFeature,
|
||||
createPointFeature,
|
||||
createPolygonFeature,
|
||||
emptyDrawingFeatureCollection,
|
||||
ensureDrawingLayers,
|
||||
setSelectedDrawingFeatureId,
|
||||
setDrawingSourceData,
|
||||
type DrawingFeatureCollection
|
||||
} from "../map/drawing-layers";
|
||||
import {
|
||||
createDrawingId,
|
||||
createDrawingLabel,
|
||||
createFeatureFromDraft,
|
||||
formatDrawingTime,
|
||||
getDraftPointCount,
|
||||
getDrawingKindLabel,
|
||||
getVisibleFeatureCollection,
|
||||
type DrawingDraftState,
|
||||
type WorkbenchDrawMode
|
||||
} from "../map/drawing-model";
|
||||
import { getDistanceMeters, isSameCoordinate } from "../map/geo";
|
||||
|
||||
export type { WorkbenchDrawMode };
|
||||
|
||||
export type WorkbenchDrawingAnnotation = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
kind: WorkbenchDrawMode;
|
||||
hidden: boolean;
|
||||
selected: boolean;
|
||||
onToggleVisibility: () => void;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
type UseWorkbenchDrawingOptions = {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
activeMode: WorkbenchDrawMode | null;
|
||||
onModeChange: (mode: WorkbenchDrawMode | null) => void;
|
||||
};
|
||||
|
||||
export function useWorkbenchDrawing({
|
||||
mapRef,
|
||||
mapReady,
|
||||
activeMode,
|
||||
onModeChange
|
||||
}: UseWorkbenchDrawingOptions) {
|
||||
const [featureCollection, setFeatureCollection] = useState<DrawingFeatureCollection>(emptyDrawingFeatureCollection);
|
||||
const [hiddenFeatureIds, setHiddenFeatureIds] = useState<Set<string>>(() => new Set());
|
||||
const [selectedFeatureId, setSelectedFeatureId] = useState<string | null>(null);
|
||||
const [, setHistoryVersion] = useState(0);
|
||||
const [draftPointCount, setDraftPointCount] = useState(0);
|
||||
const featureCollectionRef = useRef(featureCollection);
|
||||
const hiddenFeatureIdsRef = useRef(hiddenFeatureIds);
|
||||
const undoStackRef = useRef<DrawingFeatureCollection[]>([]);
|
||||
const redoStackRef = useRef<DrawingFeatureCollection[]>([]);
|
||||
const draftRef = useRef<DrawingDraftState | null>(null);
|
||||
const featureCounterRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
featureCollectionRef.current = featureCollection;
|
||||
}, [featureCollection]);
|
||||
|
||||
useEffect(() => {
|
||||
hiddenFeatureIdsRef.current = hiddenFeatureIds;
|
||||
}, [hiddenFeatureIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureDrawingLayers(map);
|
||||
setDrawingSourceData(map, DRAWING_SOURCE_IDS.features, getVisibleFeatureCollection(featureCollectionRef.current, hiddenFeatureIdsRef.current));
|
||||
setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, emptyDrawingFeatureCollection);
|
||||
}, [mapReady, mapRef]);
|
||||
|
||||
const clearDraft = useCallback(() => {
|
||||
draftRef.current = null;
|
||||
const map = mapRef.current;
|
||||
if (map) {
|
||||
setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, emptyDrawingFeatureCollection);
|
||||
}
|
||||
}, [mapRef]);
|
||||
|
||||
const updateFeatures = useCallback(
|
||||
(nextCollection: DrawingFeatureCollection) => {
|
||||
featureCollectionRef.current = nextCollection;
|
||||
setFeatureCollection(nextCollection);
|
||||
|
||||
const map = mapRef.current;
|
||||
if (map) {
|
||||
setDrawingSourceData(map, DRAWING_SOURCE_IDS.features, getVisibleFeatureCollection(nextCollection, hiddenFeatureIdsRef.current));
|
||||
}
|
||||
},
|
||||
[mapRef]
|
||||
);
|
||||
|
||||
const commitFeatures = useCallback(
|
||||
(nextCollection: DrawingFeatureCollection) => {
|
||||
undoStackRef.current.push(featureCollectionRef.current);
|
||||
redoStackRef.current = [];
|
||||
updateFeatures(nextCollection);
|
||||
setHistoryVersion((current) => current + 1);
|
||||
},
|
||||
[updateFeatures]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDrawingSourceData(map, DRAWING_SOURCE_IDS.features, getVisibleFeatureCollection(featureCollection, hiddenFeatureIds));
|
||||
}, [featureCollection, hiddenFeatureIds, mapReady, mapRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedDrawingFeatureId(map, selectedFeatureId);
|
||||
}, [mapReady, mapRef, selectedFeatureId]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map || activeMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeMap = map;
|
||||
|
||||
function handleDrawingClick(event: MapMouseEvent) {
|
||||
const feature = activeMap.queryRenderedFeatures(event.point, {
|
||||
layers: DRAWING_INTERACTIVE_LAYER_IDS
|
||||
})[0];
|
||||
const id = feature?.properties?.id;
|
||||
if (typeof id === "string" && id) {
|
||||
setSelectedFeatureId((current) => (current === id ? null : id));
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
activeMap.getCanvas().style.cursor = "pointer";
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
activeMap.getCanvas().style.cursor = "";
|
||||
}
|
||||
|
||||
DRAWING_INTERACTIVE_LAYER_IDS.forEach((layerId) => {
|
||||
if (activeMap.getLayer(layerId)) {
|
||||
activeMap.on("click", layerId, handleDrawingClick);
|
||||
activeMap.on("mouseenter", layerId, handleMouseEnter);
|
||||
activeMap.on("mouseleave", layerId, handleMouseLeave);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
DRAWING_INTERACTIVE_LAYER_IDS.forEach((layerId) => {
|
||||
if (activeMap.getLayer(layerId)) {
|
||||
activeMap.off("click", layerId, handleDrawingClick);
|
||||
activeMap.off("mouseenter", layerId, handleMouseEnter);
|
||||
activeMap.off("mouseleave", layerId, handleMouseLeave);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [activeMode, mapReady, mapRef]);
|
||||
|
||||
const updateDraft = useCallback(
|
||||
(draft: DrawingDraftState | null) => {
|
||||
draftRef.current = draft;
|
||||
|
||||
const map = mapRef.current;
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!draft || getDraftPointCount(draft) === 0) {
|
||||
setDraftPointCount(0);
|
||||
setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, emptyDrawingFeatureCollection);
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftPointCount(getDraftPointCount(draft));
|
||||
|
||||
const draftFeatures: DrawingFeatureCollection["features"] =
|
||||
draft.mode === "circle"
|
||||
? [createPointFeature("draft-circle-center", draft.center, "圆心")]
|
||||
: draft.coordinates.map((coordinates, index) =>
|
||||
createPointFeature(`draft-vertex-${index}`, coordinates, `顶点 ${index + 1}`)
|
||||
);
|
||||
|
||||
if (draft.mode !== "circle" && draft.coordinates.length >= 2) {
|
||||
draftFeatures.push(createLineFeature("draft-line", draft.coordinates, "绘制草稿"));
|
||||
}
|
||||
|
||||
if (draft.mode === "polygon" && draft.coordinates.length >= 3) {
|
||||
draftFeatures.push(createPolygonFeature("draft-polygon", draft.coordinates, "范围草稿"));
|
||||
}
|
||||
|
||||
if (draft.mode === "circle" && draft.edge) {
|
||||
draftFeatures.push(createPointFeature("draft-circle-edge", draft.edge, "半径"));
|
||||
draftFeatures.push(createLineFeature("draft-circle-radius", [draft.center, draft.edge], "圆形半径"));
|
||||
draftFeatures.push(createCircleFeature("draft-circle", draft.center, getDistanceMeters(draft.center, draft.edge), "圆形草稿"));
|
||||
}
|
||||
|
||||
setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, {
|
||||
type: "FeatureCollection",
|
||||
features: draftFeatures
|
||||
});
|
||||
},
|
||||
[mapRef]
|
||||
);
|
||||
|
||||
const finishDraft = useCallback(() => {
|
||||
const draft = draftRef.current;
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minimumPoints = draft.mode === "line" ? 2 : draft.mode === "polygon" ? 3 : 2;
|
||||
const draftPointCount = getDraftPointCount(draft);
|
||||
if (draftPointCount < minimumPoints) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = createDrawingId(draft.mode, featureCounterRef.current + 1);
|
||||
featureCounterRef.current += 1;
|
||||
const label = createDrawingLabel(draft.mode, featureCounterRef.current);
|
||||
const feature = createFeatureFromDraft(id, draft, label);
|
||||
|
||||
if (!feature) {
|
||||
return;
|
||||
}
|
||||
|
||||
commitFeatures({
|
||||
type: "FeatureCollection",
|
||||
features: [...featureCollectionRef.current.features, feature]
|
||||
});
|
||||
updateDraft(null);
|
||||
onModeChange(null);
|
||||
}, [commitFeatures, onModeChange, updateDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map || !activeMode) {
|
||||
clearDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
const drawingMode = activeMode;
|
||||
const canvas = map.getCanvas();
|
||||
const previousCursor = canvas.style.cursor;
|
||||
canvas.style.cursor = "crosshair";
|
||||
|
||||
if (drawingMode !== "point") {
|
||||
map.doubleClickZoom.disable();
|
||||
}
|
||||
|
||||
function handleClick(event: MapMouseEvent) {
|
||||
event.preventDefault();
|
||||
const coordinates: [number, number] = [event.lngLat.lng, event.lngLat.lat];
|
||||
|
||||
if (drawingMode === "point") {
|
||||
const nextNumber = featureCounterRef.current + 1;
|
||||
const id = createDrawingId(drawingMode, nextNumber);
|
||||
featureCounterRef.current = nextNumber;
|
||||
const feature = createPointFeature(id, coordinates, createDrawingLabel("point", nextNumber));
|
||||
commitFeatures({
|
||||
type: "FeatureCollection",
|
||||
features: [...featureCollectionRef.current.features, feature]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (drawingMode === "circle") {
|
||||
const currentDraft = draftRef.current?.mode === "circle" ? draftRef.current : null;
|
||||
if (!currentDraft) {
|
||||
updateDraft({
|
||||
mode: "circle",
|
||||
center: coordinates
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateDraft({
|
||||
mode: "circle",
|
||||
center: currentDraft.center,
|
||||
edge: coordinates
|
||||
});
|
||||
finishDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
const draftMode: DrawingDraftState["mode"] = drawingMode === "polygon" ? "polygon" : "line";
|
||||
const currentDraft = draftRef.current?.mode === draftMode ? draftRef.current : { mode: draftMode, coordinates: [] };
|
||||
if (isSameCoordinate(currentDraft.coordinates[currentDraft.coordinates.length - 1], coordinates)) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDraft({
|
||||
mode: draftMode,
|
||||
coordinates: [...currentDraft.coordinates, coordinates]
|
||||
});
|
||||
}
|
||||
|
||||
function handleDoubleClick(event: MapMouseEvent) {
|
||||
event.preventDefault();
|
||||
finishDraft();
|
||||
}
|
||||
|
||||
function handleMouseMove(event: MapMouseEvent) {
|
||||
if (drawingMode !== "circle") {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDraft = draftRef.current;
|
||||
if (currentDraft?.mode !== "circle") {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDraft({
|
||||
mode: "circle",
|
||||
center: currentDraft.center,
|
||||
edge: [event.lngLat.lng, event.lngLat.lat]
|
||||
});
|
||||
}
|
||||
|
||||
map.on("click", handleClick);
|
||||
map.on("dblclick", handleDoubleClick);
|
||||
map.on("mousemove", handleMouseMove);
|
||||
|
||||
return () => {
|
||||
map.off("click", handleClick);
|
||||
map.off("dblclick", handleDoubleClick);
|
||||
map.off("mousemove", handleMouseMove);
|
||||
canvas.style.cursor = previousCursor;
|
||||
|
||||
if (drawingMode !== "point") {
|
||||
map.doubleClickZoom.enable();
|
||||
}
|
||||
};
|
||||
}, [activeMode, clearDraft, commitFeatures, finishDraft, mapReady, mapRef, updateDraft]);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
const draft = draftRef.current;
|
||||
if (draft && getDraftPointCount(draft) > 0) {
|
||||
if (draft.mode === "circle") {
|
||||
updateDraft(draft.edge ? { mode: "circle", center: draft.center } : null);
|
||||
} else {
|
||||
updateDraft({
|
||||
mode: draft.mode,
|
||||
coordinates: draft.coordinates.slice(0, -1)
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const previousCollection = undoStackRef.current.pop();
|
||||
if (!previousCollection) {
|
||||
return;
|
||||
}
|
||||
|
||||
redoStackRef.current.push(featureCollectionRef.current);
|
||||
updateFeatures(previousCollection);
|
||||
setHistoryVersion((current) => current + 1);
|
||||
}, [updateDraft, updateFeatures]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
const nextCollection = redoStackRef.current.pop();
|
||||
if (!nextCollection) {
|
||||
return;
|
||||
}
|
||||
|
||||
undoStackRef.current.push(featureCollectionRef.current);
|
||||
updateFeatures(nextCollection);
|
||||
setHistoryVersion((current) => current + 1);
|
||||
}, [updateFeatures]);
|
||||
|
||||
const deleteSelected = useCallback(() => {
|
||||
if (!selectedFeatureId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFeatures = featureCollectionRef.current.features.filter((feature) => feature.properties.id !== selectedFeatureId);
|
||||
if (nextFeatures.length === featureCollectionRef.current.features.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFeatureId(null);
|
||||
setHiddenFeatureIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(selectedFeatureId);
|
||||
return next;
|
||||
});
|
||||
commitFeatures({
|
||||
type: "FeatureCollection",
|
||||
features: nextFeatures
|
||||
});
|
||||
}, [commitFeatures, selectedFeatureId]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
clearDraft();
|
||||
setHiddenFeatureIds(new Set());
|
||||
setSelectedFeatureId(null);
|
||||
commitFeatures(emptyDrawingFeatureCollection);
|
||||
onModeChange(null);
|
||||
}, [clearDraft, commitFeatures, onModeChange]);
|
||||
|
||||
const toggleVisibility = useCallback((featureId: string) => {
|
||||
setHiddenFeatureIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(featureId)) {
|
||||
next.delete(featureId);
|
||||
} else {
|
||||
next.add(featureId);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const exportGeoJSON = useCallback(() => {
|
||||
const data = JSON.stringify(featureCollectionRef.current, null, 2);
|
||||
const blob = new Blob([data], { type: "application/geo+json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "workbench-annotations.geojson";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}, []);
|
||||
|
||||
const annotations = useMemo<WorkbenchDrawingAnnotation[]>(
|
||||
() =>
|
||||
featureCollection.features.map((feature) => ({
|
||||
id: feature.properties.id,
|
||||
label: feature.properties.label,
|
||||
description: `${getDrawingKindLabel(feature.properties.kind)} · ${formatDrawingTime(feature.properties.createdAt)}`,
|
||||
kind: feature.properties.kind,
|
||||
hidden: hiddenFeatureIds.has(feature.properties.id),
|
||||
selected: selectedFeatureId === feature.properties.id,
|
||||
onToggleVisibility: () => toggleVisibility(feature.properties.id),
|
||||
onSelect: () => setSelectedFeatureId(feature.properties.id)
|
||||
})),
|
||||
[featureCollection, hiddenFeatureIds, selectedFeatureId, toggleVisibility]
|
||||
);
|
||||
|
||||
return {
|
||||
annotations,
|
||||
canUndo: Boolean(draftPointCount || undoStackRef.current.length),
|
||||
canRedo: Boolean(redoStackRef.current.length),
|
||||
canDeleteSelected: Boolean(selectedFeatureId),
|
||||
hasFeatures: featureCollection.features.length > 0,
|
||||
undo,
|
||||
redo,
|
||||
deleteSelected,
|
||||
clear,
|
||||
exportGeoJSON
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
"use client";
|
||||
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
import maplibregl, { type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { env } from "@/shared/config/env";
|
||||
import type { DetailFeature } from "../types";
|
||||
import {
|
||||
SIMULATION_SOURCE_IDS,
|
||||
simulationAnnotationLayers,
|
||||
simulationSources
|
||||
} from "../map/annotation-layers";
|
||||
import { fitNetworkBounds } from "../map/camera";
|
||||
import {
|
||||
waterNetworkBusinessLayers,
|
||||
waterNetworkHitLayers,
|
||||
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,
|
||||
scadaFallbackLayers,
|
||||
scadaLayers,
|
||||
type ScadaImageRegistrationResult
|
||||
} from "../map/scada";
|
||||
import {
|
||||
SCADA_ANALYSIS_SOURCE_ID,
|
||||
createEmptyScadaAnalysisCollection,
|
||||
ensureScadaAnalysisLayers
|
||||
} from "../map/scada-analysis";
|
||||
import {
|
||||
createBaseStyle,
|
||||
createWaterNetworkSources,
|
||||
WATER_NETWORK_GLOBAL_VIEW
|
||||
} from "../map/sources";
|
||||
import { useMapInteractions } from "./use-map-interactions";
|
||||
import { useSimulationLayerVisibility } from "./use-simulation-layer-visibility";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__waterNetworkMap?: MapLibreMap;
|
||||
}
|
||||
}
|
||||
|
||||
type UseWorkbenchMapOptions = {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
leftPanelOpen: boolean;
|
||||
rightPanelOpen: boolean;
|
||||
impactVisible: boolean;
|
||||
onSelectFeature: (feature: DetailFeature) => void;
|
||||
selectedFeature: DetailFeature | null;
|
||||
};
|
||||
|
||||
type UseWorkbenchMapResult = {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
mapError: string | null;
|
||||
sourceStatuses: WorkbenchSourceStatus[];
|
||||
fitNetworkBounds: () => void;
|
||||
};
|
||||
|
||||
export type WorkbenchSourceStatusValue = "loading" | "online" | "degraded" | "offline";
|
||||
|
||||
export type WorkbenchSourceStatus = {
|
||||
id: string;
|
||||
sourceName: string;
|
||||
status: WorkbenchSourceStatusValue;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const SOURCE_STATUS_LABELS: Record<string, string> = {
|
||||
"mapbox-base": "Mapbox 底图",
|
||||
"geoserver-mvt": "GeoServer MVT",
|
||||
"scada-icons": "SCADA 图标",
|
||||
"annotation-source": "业务标注源"
|
||||
};
|
||||
|
||||
const SOURCE_GROUP_BY_ID: Record<string, string> = {
|
||||
"mapbox-light": "mapbox-base",
|
||||
"mapbox-satellite": "mapbox-base",
|
||||
conduits: "geoserver-mvt",
|
||||
junctions: "geoserver-mvt",
|
||||
orifices: "geoserver-mvt",
|
||||
outfalls: "geoserver-mvt",
|
||||
pumps: "geoserver-mvt",
|
||||
scada: "geoserver-mvt",
|
||||
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
|
||||
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
|
||||
};
|
||||
|
||||
export function useWorkbenchMap({
|
||||
containerRef,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
impactVisible,
|
||||
onSelectFeature,
|
||||
selectedFeature
|
||||
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
const impactVisibleRef = useRef(impactVisible);
|
||||
const layoutOpenRef = useRef({ leftPanelOpen, rightPanelOpen });
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [mapError, setMapError] = useState<string | null>(null);
|
||||
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
|
||||
|
||||
useEffect(() => {
|
||||
layoutOpenRef.current = { leftPanelOpen, rightPanelOpen };
|
||||
}, [leftPanelOpen, rightPanelOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
impactVisibleRef.current = impactVisible;
|
||||
}, [impactVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || mapRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapboxToken = env.DRAINAGE_MAPBOX_ACCESS_TOKEN || undefined;
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: createBaseStyle(mapboxToken),
|
||||
pitch: 0,
|
||||
bearing: 0,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
preserveDrawingBuffer: true,
|
||||
attributionControl: false
|
||||
});
|
||||
|
||||
window.__waterNetworkMap = map;
|
||||
|
||||
map.on("load", async () => {
|
||||
map.resize();
|
||||
const sources = createWaterNetworkSources();
|
||||
map.addSource("conduits", sources.conduits);
|
||||
map.addSource("junctions", sources.junctions);
|
||||
map.addSource("orifices", sources.orifices);
|
||||
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: []
|
||||
};
|
||||
|
||||
try {
|
||||
scadaRegistration = await registerScadaImages(map);
|
||||
} finally {
|
||||
try {
|
||||
const presentationLayers = scadaRegistration.status === "unavailable"
|
||||
? scadaFallbackLayers
|
||||
: scadaLayers;
|
||||
presentationLayers.slice(0, -1).forEach((layer) => map.addLayer(layer));
|
||||
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);
|
||||
updateSourceStatus(setSourceStatuses, "annotation-source", "online", "业务标注源已就绪。");
|
||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||
}
|
||||
}
|
||||
|
||||
if (scadaRegistration.status !== "ready") {
|
||||
updateSourceStatus(
|
||||
setSourceStatuses,
|
||||
"scada-icons",
|
||||
"degraded",
|
||||
scadaRegistration.status === "degraded"
|
||||
? "部分 SCADA 分类图标加载失败,已使用通用图标。"
|
||||
: "SCADA 图标加载失败,已使用通用点位显示。"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const resizeMap = () => map.resize();
|
||||
window.addEventListener("resize", resizeMap);
|
||||
window.setTimeout(resizeMap, 0);
|
||||
window.setTimeout(resizeMap, 300);
|
||||
window.setTimeout(resizeMap, 1000);
|
||||
|
||||
map.on("sourcedata", (event) => {
|
||||
updateStatusFromSourceEvent(event, "online", setSourceStatuses);
|
||||
});
|
||||
|
||||
map.on("sourcedataabort", (event) => {
|
||||
updateStatusFromSourceEvent(event, "degraded", setSourceStatuses);
|
||||
});
|
||||
|
||||
map.on("error", (event) => {
|
||||
const message = event.error?.message ?? "地图渲染异常";
|
||||
if (!message.includes("AbortError")) {
|
||||
const sourceGroupId = getSourceGroupFromErrorEvent(event);
|
||||
if (sourceGroupId) {
|
||||
updateSourceStatus(setSourceStatuses, sourceGroupId, "offline", getSourceErrorMessage(sourceGroupId, message));
|
||||
} else {
|
||||
setMapError(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", resizeMap);
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
delete window.__waterNetworkMap;
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
useMapInteractions({
|
||||
mapRef,
|
||||
mapReady,
|
||||
onSelectFeature,
|
||||
selectedFeature
|
||||
});
|
||||
|
||||
useSimulationLayerVisibility({
|
||||
mapRef,
|
||||
mapReady,
|
||||
visible: impactVisible
|
||||
});
|
||||
|
||||
function fitToNetworkBounds() {
|
||||
const map = mapRef.current;
|
||||
if (map) {
|
||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mapRef,
|
||||
mapReady,
|
||||
mapError,
|
||||
sourceStatuses: Object.values(sourceStatuses),
|
||||
fitNetworkBounds: fitToNetworkBounds
|
||||
};
|
||||
}
|
||||
|
||||
function updateStatusFromSourceEvent(
|
||||
event: MapSourceDataEvent,
|
||||
status: WorkbenchSourceStatusValue,
|
||||
setSourceStatuses: (updater: (current: Record<string, WorkbenchSourceStatus>) => Record<string, WorkbenchSourceStatus>) => void
|
||||
) {
|
||||
const sourceGroupId = SOURCE_GROUP_BY_ID[event.sourceId];
|
||||
if (!sourceGroupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "online" && !event.isSourceLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSourceStatus(setSourceStatuses, sourceGroupId, status, getSourceStatusMessage(sourceGroupId, status));
|
||||
}
|
||||
|
||||
function updateSourceStatus(
|
||||
setSourceStatuses: (updater: (current: Record<string, WorkbenchSourceStatus>) => Record<string, WorkbenchSourceStatus>) => void,
|
||||
sourceGroupId: string,
|
||||
status: WorkbenchSourceStatusValue,
|
||||
message: string
|
||||
) {
|
||||
setSourceStatuses((current) => {
|
||||
const previous = current[sourceGroupId];
|
||||
if (previous?.status === status && previous.message === message) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[sourceGroupId]: {
|
||||
id: sourceGroupId,
|
||||
sourceName: SOURCE_STATUS_LABELS[sourceGroupId] ?? sourceGroupId,
|
||||
status,
|
||||
message
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getSourceGroupFromErrorEvent(event: { sourceId?: string; error?: { message?: string } }) {
|
||||
if (event.sourceId && SOURCE_GROUP_BY_ID[event.sourceId]) {
|
||||
return SOURCE_GROUP_BY_ID[event.sourceId];
|
||||
}
|
||||
|
||||
const message = event.error?.message ?? "";
|
||||
if (message.includes("geoserver.waternetwork.cn")) {
|
||||
return "geoserver-mvt";
|
||||
}
|
||||
|
||||
if (message.includes("api.mapbox.com") || message.includes("mapbox")) {
|
||||
return "mapbox-base";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceStatusValue) {
|
||||
if (sourceGroupId === "mapbox-base") {
|
||||
return status === "online" ? "底图瓦片已恢复正常。" : "底图瓦片请求中断,业务图层仍可继续使用。";
|
||||
}
|
||||
|
||||
if (sourceGroupId === "geoserver-mvt") {
|
||||
return status === "online" ? "排水管网矢量瓦片已加载。" : "排水管网瓦片请求中断,当前视图可能不完整。";
|
||||
}
|
||||
|
||||
if (sourceGroupId === "annotation-source") {
|
||||
return status === "online" ? "业务标注和影响范围已加载。" : "业务标注源加载中断,模拟标注可能暂不可见。";
|
||||
}
|
||||
|
||||
return status === "online" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
|
||||
}
|
||||
|
||||
function getSourceErrorMessage(sourceGroupId: string, errorMessage: string) {
|
||||
if (sourceGroupId === "mapbox-base") {
|
||||
return `底图瓦片请求失败:${errorMessage}`;
|
||||
}
|
||||
|
||||
if (sourceGroupId === "geoserver-mvt") {
|
||||
return `GeoServer 矢量瓦片请求失败:${errorMessage}`;
|
||||
}
|
||||
|
||||
if (sourceGroupId === "annotation-source") {
|
||||
return `业务标注源请求失败:${errorMessage}`;
|
||||
}
|
||||
|
||||
return errorMessage;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap, MapGeoJSONFeature, MapMouseEvent } from "maplibre-gl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
createMeasurementFeatureCollection,
|
||||
emptyMeasurementFeatureCollection,
|
||||
ensureMeasurementLayers,
|
||||
setSelectedMeasurementPipeIds,
|
||||
setMeasurementSourceData
|
||||
} from "../map/measurement-layers";
|
||||
import { getFeatureId } from "../map/feature-adapter";
|
||||
import {
|
||||
getLastSegmentDistanceMeters,
|
||||
getLineDistanceMeters,
|
||||
getPolygonAreaSquareMeters,
|
||||
isSameCoordinate,
|
||||
type LngLatTuple
|
||||
} from "../map/geo";
|
||||
|
||||
export type WorkbenchMeasureMode = "distance" | "area" | "segment";
|
||||
export type WorkbenchMeasureUnit = "m" | "km";
|
||||
export type WorkbenchMeasurementResult = {
|
||||
label: string;
|
||||
value: string;
|
||||
unit: string;
|
||||
metrics: Array<{ label: string; value: string }>;
|
||||
};
|
||||
|
||||
type UseWorkbenchMeasurementOptions = {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
mode: WorkbenchMeasureMode;
|
||||
unit: WorkbenchMeasureUnit;
|
||||
};
|
||||
|
||||
const SNAP_RADIUS_PIXELS = 14;
|
||||
const SNAP_LAYER_IDS = ["pipes-flow"];
|
||||
|
||||
type SelectedPipe = {
|
||||
id: string;
|
||||
lengthMeters?: number;
|
||||
};
|
||||
|
||||
export function useWorkbenchMeasurement({
|
||||
mapRef,
|
||||
mapReady,
|
||||
mode,
|
||||
unit
|
||||
}: UseWorkbenchMeasurementOptions) {
|
||||
const [coordinates, setCoordinates] = useState<LngLatTuple[]>([]);
|
||||
const [selectedPipes, setSelectedPipes] = useState<SelectedPipe[]>([]);
|
||||
const [measuring, setMeasuring] = useState(false);
|
||||
const coordinatesRef = useRef(coordinates);
|
||||
const selectedPipesRef = useRef(selectedPipes);
|
||||
|
||||
useEffect(() => {
|
||||
coordinatesRef.current = coordinates;
|
||||
}, [coordinates]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedPipesRef.current = selectedPipes;
|
||||
}, [selectedPipes]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureMeasurementLayers(map);
|
||||
setMeasurementSourceData(map, emptyMeasurementFeatureCollection);
|
||||
}, [mapReady, mapRef]);
|
||||
|
||||
const syncMeasurementSource = useCallback(
|
||||
(nextCoordinates: LngLatTuple[], nextMode = mode) => {
|
||||
const map = mapRef.current;
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMeasurementSourceData(
|
||||
map,
|
||||
nextMode !== "segment" && nextCoordinates.length > 0
|
||||
? createMeasurementFeatureCollection(nextCoordinates, nextMode)
|
||||
: emptyMeasurementFeatureCollection
|
||||
);
|
||||
},
|
||||
[mapRef, mode]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
syncMeasurementSource(coordinates, mode);
|
||||
}, [coordinates, mode, syncMeasurementSource]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedMeasurementPipeIds(
|
||||
map,
|
||||
mode === "segment" ? selectedPipes.map((pipe) => pipe.id) : []
|
||||
);
|
||||
}, [mapReady, mapRef, mode, selectedPipes]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map || !measuring) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeMap = map;
|
||||
const canvas = map.getCanvas();
|
||||
const previousCursor = canvas.style.cursor;
|
||||
canvas.style.cursor = "crosshair";
|
||||
|
||||
function handleClick(event: MapMouseEvent) {
|
||||
event.preventDefault();
|
||||
if (mode === "segment") {
|
||||
const selectedPipe = getSelectedPipe(activeMap, event);
|
||||
if (selectedPipe) {
|
||||
setSelectedPipes((current) => addUniquePipe(current, selectedPipe));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const coordinate: LngLatTuple = [event.lngLat.lng, event.lngLat.lat];
|
||||
setCoordinates((current) => {
|
||||
if (isSameCoordinate(current[current.length - 1], coordinate)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return [...current, coordinate];
|
||||
});
|
||||
}
|
||||
|
||||
map.on("click", handleClick);
|
||||
|
||||
return () => {
|
||||
map.off("click", handleClick);
|
||||
canvas.style.cursor = previousCursor;
|
||||
};
|
||||
}, [mapReady, mapRef, measuring, mode]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setCoordinates([]);
|
||||
setSelectedPipes([]);
|
||||
setMeasuring(false);
|
||||
syncMeasurementSource([]);
|
||||
}, [syncMeasurementSource]);
|
||||
|
||||
const copyResult = useCallback(() => {
|
||||
if (!navigator.clipboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
void navigator.clipboard.writeText(getResultText(mode, unit, coordinatesRef.current, selectedPipesRef.current));
|
||||
}, [mode, unit]);
|
||||
|
||||
const result = useMemo<WorkbenchMeasurementResult>(() => {
|
||||
const distanceMeters = getLineDistanceMeters(coordinates);
|
||||
const areaSquareMeters = mode === "area" ? getPolygonAreaSquareMeters(coordinates) : 0;
|
||||
const segmentCount = Math.max(coordinates.length - 1, 0);
|
||||
const lastSegmentMeters = getLastSegmentDistanceMeters(coordinates);
|
||||
const selectedPipeLengthMeters = selectedPipes.reduce((total, pipe) => total + (pipe.lengthMeters ?? 0), 0);
|
||||
|
||||
if (mode === "area") {
|
||||
return {
|
||||
label: "当前面积",
|
||||
value: formatArea(areaSquareMeters, unit),
|
||||
unit: unit === "km" ? "km²" : "m²",
|
||||
metrics: [
|
||||
{ label: "顶点", value: String(coordinates.length) },
|
||||
{ label: "周长", value: `${formatDistance(distanceMeters, unit)} ${unit}` }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === "segment") {
|
||||
return {
|
||||
label: "资产长度",
|
||||
value: formatDistance(selectedPipeLengthMeters, unit),
|
||||
unit,
|
||||
metrics: [
|
||||
{ label: "管段", value: `${selectedPipes.length} 段` }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: "当前长度",
|
||||
value: formatDistance(distanceMeters, unit),
|
||||
unit,
|
||||
metrics: [
|
||||
{ label: "段数", value: String(segmentCount) },
|
||||
{ label: "末段", value: `${formatDistance(lastSegmentMeters, unit)} ${unit}` }
|
||||
]
|
||||
};
|
||||
}, [coordinates, mode, selectedPipes, unit]);
|
||||
|
||||
return {
|
||||
measuring,
|
||||
result,
|
||||
hasPoints: mode === "segment" ? selectedPipes.length > 0 : coordinates.length > 0,
|
||||
start: () => setMeasuring(true),
|
||||
stop: () => setMeasuring(false),
|
||||
clear,
|
||||
copyResult
|
||||
};
|
||||
}
|
||||
|
||||
function getResultText(
|
||||
mode: WorkbenchMeasureMode,
|
||||
unit: WorkbenchMeasureUnit,
|
||||
coordinates: LngLatTuple[],
|
||||
selectedPipes: SelectedPipe[]
|
||||
) {
|
||||
const distanceMeters = getLineDistanceMeters(coordinates);
|
||||
|
||||
if (mode === "area") {
|
||||
return `面积:${formatArea(getPolygonAreaSquareMeters(coordinates), unit)} ${unit === "km" ? "km²" : "m²"}`;
|
||||
}
|
||||
|
||||
if (mode === "segment") {
|
||||
const lengthMeters = selectedPipes.reduce((total, pipe) => total + (pipe.lengthMeters ?? 0), 0);
|
||||
return `资产长度:${formatDistance(lengthMeters, unit)} ${unit}`;
|
||||
}
|
||||
|
||||
return `长度:${formatDistance(distanceMeters, unit)} ${unit}`;
|
||||
}
|
||||
|
||||
function getSelectedPipe(map: MapLibreMap, event: MapMouseEvent): SelectedPipe | null {
|
||||
const point = event.point;
|
||||
const features = map.queryRenderedFeatures(
|
||||
[
|
||||
[point.x - SNAP_RADIUS_PIXELS, point.y - SNAP_RADIUS_PIXELS],
|
||||
[point.x + SNAP_RADIUS_PIXELS, point.y + SNAP_RADIUS_PIXELS]
|
||||
],
|
||||
{ layers: SNAP_LAYER_IDS }
|
||||
);
|
||||
|
||||
for (const feature of features) {
|
||||
const selectedPipe = toSelectedPipe(feature);
|
||||
if (selectedPipe) {
|
||||
return selectedPipe;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toSelectedPipe(feature: MapGeoJSONFeature): SelectedPipe | null {
|
||||
const id = getFeatureId(feature);
|
||||
if (!id || feature.layer.id !== "pipes-flow") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
lengthMeters: getNumericProperty(feature.properties?.length)
|
||||
};
|
||||
}
|
||||
|
||||
function getNumericProperty(value: unknown) {
|
||||
const numberValue = Number(value);
|
||||
return Number.isFinite(numberValue) && numberValue > 0 ? numberValue : undefined;
|
||||
}
|
||||
|
||||
function addUniquePipe(current: SelectedPipe[], next: SelectedPipe) {
|
||||
if (current.some((pipe) => pipe.id === next.id)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return [...current, next];
|
||||
}
|
||||
|
||||
function formatDistance(valueMeters: number, unit: WorkbenchMeasureUnit) {
|
||||
const value = unit === "km" ? valueMeters / 1000 : valueMeters;
|
||||
return value.toLocaleString("zh-CN", {
|
||||
maximumFractionDigits: unit === "km" ? 2 : 1,
|
||||
minimumFractionDigits: 0
|
||||
});
|
||||
}
|
||||
|
||||
function formatArea(valueSquareMeters: number, unit: WorkbenchMeasureUnit) {
|
||||
const value = unit === "km" ? valueSquareMeters / 1_000_000 : valueSquareMeters;
|
||||
return value.toLocaleString("zh-CN", {
|
||||
maximumFractionDigits: unit === "km" ? 3 : 1,
|
||||
minimumFractionDigits: 0
|
||||
});
|
||||
}
|
||||
@@ -0,0 +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";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getWorkbenchBasemapTone } from "./workbench-layout";
|
||||
|
||||
describe("workbench basemap surface tone", () => {
|
||||
it.each([
|
||||
["mapbox-light", "light"],
|
||||
["mapbox-satellite", "satellite"],
|
||||
["none", "none"],
|
||||
["unknown", "none"]
|
||||
])("maps %s to %s", (baseLayerId, expectedTone) => {
|
||||
expect(getWorkbenchBasemapTone(baseLayerId)).toBe(expectedTone);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
export const WORKBENCH_LAYOUT = {
|
||||
headerHeight: 56,
|
||||
desktopMinWidth: 1024,
|
||||
persistentConditionMinWidth: 1280,
|
||||
wideMinWidth: 1536,
|
||||
collapsedAgentWidth: 72,
|
||||
maxAgentViewportRatio: 0.5,
|
||||
mapEdgeGap: 24,
|
||||
desktop: {
|
||||
agentWidth: 460,
|
||||
conditionWidth: 432,
|
||||
toolbarWidth: 48,
|
||||
tickerWidth: 420
|
||||
},
|
||||
wide: {
|
||||
agentWidth: 500,
|
||||
conditionWidth: 432,
|
||||
toolbarWidth: 48,
|
||||
tickerWidth: 460
|
||||
}
|
||||
} 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;
|
||||
};
|
||||
|
||||
export type WorkbenchBasemapTone = "light" | "satellite" | "none";
|
||||
|
||||
export type WorkbenchViewportLayout = {
|
||||
agentWidth: number;
|
||||
conditionWidth: number;
|
||||
toolbarWidth: number;
|
||||
tickerWidth: number;
|
||||
};
|
||||
|
||||
export function getWorkbenchViewportLayout(viewportWidth: number): WorkbenchViewportLayout {
|
||||
return viewportWidth >= WORKBENCH_LAYOUT.wideMinWidth
|
||||
? WORKBENCH_LAYOUT.wide
|
||||
: WORKBENCH_LAYOUT.desktop;
|
||||
}
|
||||
|
||||
export function getWorkbenchBasemapTone(activeBaseLayerId: string): WorkbenchBasemapTone {
|
||||
if (activeBaseLayerId === "mapbox-light") {
|
||||
return "light";
|
||||
}
|
||||
|
||||
if (activeBaseLayerId === "mapbox-satellite") {
|
||||
return "satellite";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}
|
||||
|
||||
export function getWorkbenchCameraPadding(viewportWidth: number, panels: WorkbenchPanelState) {
|
||||
if (viewportWidth < WORKBENCH_LAYOUT.desktopMinWidth) {
|
||||
return { top: 72, right: 24, bottom: 72, left: 24 };
|
||||
}
|
||||
|
||||
const layout = getWorkbenchViewportLayout(viewportWidth);
|
||||
const agentWidth = panels.agentOpen ? layout.agentWidth : WORKBENCH_LAYOUT.collapsedAgentWidth;
|
||||
const conditionWidth = panels.conditionOpen ? layout.conditionWidth : 0;
|
||||
|
||||
return {
|
||||
top: WORKBENCH_LAYOUT.headerHeight + 16,
|
||||
right: WORKBENCH_LAYOUT.mapEdgeGap + layout.toolbarWidth + conditionWidth,
|
||||
bottom: 32,
|
||||
left: WORKBENCH_LAYOUT.mapEdgeGap + agentWidth
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkbenchLayoutCssVariables() {
|
||||
return {
|
||||
"--workbench-agent-width": `${WORKBENCH_LAYOUT.desktop.agentWidth}px`,
|
||||
"--workbench-agent-width-wide": `${WORKBENCH_LAYOUT.wide.agentWidth}px`,
|
||||
"--workbench-condition-width": `${WORKBENCH_LAYOUT.desktop.conditionWidth}px`,
|
||||
"--workbench-condition-width-wide": `${WORKBENCH_LAYOUT.wide.conditionWidth}px`,
|
||||
"--workbench-toolbar-width": `${WORKBENCH_LAYOUT.desktop.toolbarWidth}px`,
|
||||
"--workbench-ticker-width": `${WORKBENCH_LAYOUT.desktop.tickerWidth}px`,
|
||||
"--workbench-ticker-width-wide": `${WORKBENCH_LAYOUT.wide.tickerWidth}px`
|
||||
} as const;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
import type { GeoJSONSourceSpecification, StyleSpecification } from "maplibre-gl";
|
||||
import { annotationPoints, impactAreaPolygon } from "../data/workbench-simulation";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
|
||||
export const SIMULATION_SOURCE_IDS = {
|
||||
impactArea: "simulation-impact-area",
|
||||
annotations: "simulation-annotations"
|
||||
} as const;
|
||||
|
||||
export const simulationSources = {
|
||||
impactArea: {
|
||||
type: "geojson",
|
||||
data: impactAreaPolygon
|
||||
} satisfies GeoJSONSourceSpecification,
|
||||
annotations: {
|
||||
type: "geojson",
|
||||
data: annotationPoints
|
||||
} satisfies GeoJSONSourceSpecification
|
||||
};
|
||||
|
||||
export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
||||
{
|
||||
id: "simulation-impact-fill",
|
||||
type: "fill",
|
||||
source: SIMULATION_SOURCE_IDS.impactArea,
|
||||
paint: {
|
||||
"fill-color": MAP_STYLE_TOKENS.state.risk,
|
||||
"fill-opacity": MAP_STYLE_TOKENS.opacity.area
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-impact-outline",
|
||||
type: "line",
|
||||
source: SIMULATION_SOURCE_IDS.impactArea,
|
||||
paint: {
|
||||
"line-color": MAP_STYLE_TOKENS.state.risk,
|
||||
"line-width": 1.5,
|
||||
"line-dasharray": [2, 2],
|
||||
"line-opacity": 0.72
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-burst-halo",
|
||||
type: "circle",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "burst"],
|
||||
paint: {
|
||||
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||
"circle-radius": 18,
|
||||
"circle-stroke-color": MAP_STYLE_TOKENS.state.incident,
|
||||
"circle-stroke-width": 3,
|
||||
"circle-opacity": 0.88
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-burst-point",
|
||||
type: "circle",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "burst"],
|
||||
paint: {
|
||||
"circle-color": MAP_STYLE_TOKENS.state.incident,
|
||||
"circle-radius": 7,
|
||||
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||
"circle-stroke-width": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-pipe-label",
|
||||
type: "symbol",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "pipe-label"],
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 13,
|
||||
"text-font": ["Open Sans Bold"],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
"text-color": MAP_STYLE_TOKENS.label.inverse,
|
||||
"text-halo-color": MAP_STYLE_TOKENS.state.agent,
|
||||
"text-halo-width": 9
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-impact-label",
|
||||
type: "symbol",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "tooltip"],
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 14,
|
||||
"text-font": ["Open Sans Bold"],
|
||||
"text-offset": [0, 0],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
"text-color": MAP_STYLE_TOKENS.state.risk,
|
||||
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||
"text-halo-width": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-district-label",
|
||||
type: "symbol",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "district"],
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 13,
|
||||
"text-font": ["Open Sans Regular"],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
"text-color": MAP_STYLE_TOKENS.label.secondary,
|
||||
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||
"text-halo-width": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-burst-label",
|
||||
type: "symbol",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "burst"],
|
||||
layout: {
|
||||
"text-field": "爆管位置\nDN600 给水管线\n压力:0.18 MPa\n时间:09:00",
|
||||
"text-size": 12,
|
||||
"text-font": ["Open Sans Regular"],
|
||||
"text-offset": [5.2, -3.4],
|
||||
"text-anchor": "left",
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
"text-color": MAP_STYLE_TOKENS.label.primary,
|
||||
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||
"text-halo-width": 5
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export const simulationLayerIds = simulationAnnotationLayers.map((layer) => layer.id);
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fitNetworkBounds,
|
||||
getResponsiveWorkbenchPadding,
|
||||
getWorkbenchPadding
|
||||
} from "./camera";
|
||||
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
|
||||
|
||||
function createMap(width = 1280, height = 720) {
|
||||
const fitBounds = vi.fn();
|
||||
const map = {
|
||||
fitBounds,
|
||||
getCanvas: () => ({ clientWidth: width, clientHeight: height, width, height })
|
||||
} as unknown as MapLibreMap;
|
||||
|
||||
return { fitBounds, map };
|
||||
}
|
||||
|
||||
describe("workbench camera padding", () => {
|
||||
it.each([
|
||||
{ agentOpen: false, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 96 } },
|
||||
{ agentOpen: true, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 484 } },
|
||||
{ agentOpen: false, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 96 } },
|
||||
{ agentOpen: true, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 484 } }
|
||||
])("covers agent=$agentOpen and condition=$conditionOpen", ({ agentOpen, conditionOpen, expected }) => {
|
||||
expect(getWorkbenchPadding(agentOpen, conditionOpen)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("caps combined padding on constrained desktop widths", () => {
|
||||
const { map } = createMap(1024, 720);
|
||||
const padding = getResponsiveWorkbenchPadding(map, true, true);
|
||||
|
||||
expect(padding.left + padding.right).toBeLessThanOrEqual(Math.floor(1024 * 0.58) + 1);
|
||||
expect(padding.top + padding.bottom).toBeLessThanOrEqual(Math.floor(720 * 0.58) + 1);
|
||||
expect(padding.left).toBeGreaterThan(padding.top);
|
||||
expect(padding.right).toBeGreaterThan(padding.top);
|
||||
});
|
||||
|
||||
it("uses mobile-safe padding below the dock breakpoint", () => {
|
||||
const { map } = createMap(375, 667);
|
||||
|
||||
expect(getResponsiveWorkbenchPadding(map, true, true)).toEqual({
|
||||
top: 72,
|
||||
right: 24,
|
||||
bottom: 72,
|
||||
left: 24
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitNetworkBounds", () => {
|
||||
it("uses the configured global extent for initial and home views", () => {
|
||||
const { fitBounds, map } = createMap();
|
||||
|
||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||
|
||||
expect(fitBounds).toHaveBeenCalledTimes(1);
|
||||
const [bounds, options] = fitBounds.mock.calls[0] ?? [];
|
||||
|
||||
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: 13,
|
||||
padding: { top: 96, right: 48, bottom: 48, left: 48 }
|
||||
});
|
||||
expect(options).not.toHaveProperty("zoom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import {
|
||||
getWorkbenchCameraPadding
|
||||
} from "../layout/workbench-layout";
|
||||
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
|
||||
|
||||
type LngLatBounds = [[number, number], [number, number]];
|
||||
type WebMercatorBbox = readonly [number, number, number, number];
|
||||
|
||||
export type NetworkViewParams = {
|
||||
bbox3857: WebMercatorBbox;
|
||||
};
|
||||
|
||||
const WEB_MERCATOR_RADIUS = 6378137;
|
||||
const GLOBAL_VIEW_MAX_ZOOM = 13;
|
||||
const GLOBAL_VIEW_PADDING: PaddingOptions = { top: 96, right: 48, bottom: 48, left: 48 };
|
||||
export function getWorkbenchPadding(leftPanelOpen: boolean, rightPanelOpen: boolean): PaddingOptions {
|
||||
return getWorkbenchCameraPadding(1280, {
|
||||
agentOpen: leftPanelOpen,
|
||||
conditionOpen: rightPanelOpen
|
||||
});
|
||||
}
|
||||
|
||||
export function getResponsiveWorkbenchPadding(
|
||||
map: MapLibreMap,
|
||||
leftPanelOpen: boolean,
|
||||
rightPanelOpen: boolean
|
||||
): PaddingOptions {
|
||||
const width = getMapViewportSize(map).width;
|
||||
return getResponsivePadding(
|
||||
map,
|
||||
getWorkbenchCameraPadding(width, {
|
||||
agentOpen: leftPanelOpen,
|
||||
conditionOpen: rightPanelOpen
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function fitNetworkBounds(
|
||||
map: MapLibreMap,
|
||||
viewParams: NetworkViewParams = WATER_NETWORK_GLOBAL_VIEW,
|
||||
padding: PaddingOptions = GLOBAL_VIEW_PADDING
|
||||
) {
|
||||
map.fitBounds(
|
||||
getLngLatBoundsFromBbox3857(viewParams.bbox3857),
|
||||
{
|
||||
padding: getResponsivePadding(map, padding),
|
||||
maxZoom: GLOBAL_VIEW_MAX_ZOOM,
|
||||
duration: 600
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions): PaddingOptions {
|
||||
const { width, height } = getMapViewportSize(map);
|
||||
const horizontalPadding = padding.left + padding.right;
|
||||
const verticalPadding = padding.top + padding.bottom;
|
||||
const maxHorizontalPadding = width * 0.58;
|
||||
const maxVerticalPadding = height * 0.58;
|
||||
const horizontalScale = horizontalPadding > 0 ? maxHorizontalPadding / horizontalPadding : 1;
|
||||
const verticalScale = verticalPadding > 0 ? maxVerticalPadding / verticalPadding : 1;
|
||||
const scale = Math.min(1, horizontalScale, verticalScale);
|
||||
|
||||
if (scale >= 1) {
|
||||
return padding;
|
||||
}
|
||||
|
||||
return {
|
||||
top: Math.round(padding.top * scale),
|
||||
right: Math.round(padding.right * scale),
|
||||
bottom: Math.round(padding.bottom * scale),
|
||||
left: Math.round(padding.left * scale)
|
||||
};
|
||||
}
|
||||
|
||||
function getMapViewportSize(map: MapLibreMap) {
|
||||
const canvas = map.getCanvas();
|
||||
return {
|
||||
width: canvas.clientWidth || canvas.width,
|
||||
height: canvas.clientHeight || canvas.height
|
||||
};
|
||||
}
|
||||
|
||||
function getLngLatBoundsFromBbox3857(bbox3857: WebMercatorBbox): LngLatBounds {
|
||||
const [minX, minY, maxX, maxY] = bbox3857;
|
||||
|
||||
return [
|
||||
webMercatorToLngLat(minX, minY),
|
||||
webMercatorToLngLat(maxX, maxY)
|
||||
];
|
||||
}
|
||||
|
||||
function webMercatorToLngLat(x: number, y: number): [number, number] {
|
||||
const lng = (x / WEB_MERCATOR_RADIUS) * (180 / Math.PI);
|
||||
const lat = (2 * Math.atan(Math.exp(y / WEB_MERCATOR_RADIUS)) - Math.PI / 2) * (180 / Math.PI);
|
||||
|
||||
return [lng, lat];
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compositeHexColor,
|
||||
createCoolorsContrastUrl,
|
||||
getContrastRatio
|
||||
} from "./color-contrast";
|
||||
import {
|
||||
MAP_STYLE_TOKENS
|
||||
} from "./map-colors";
|
||||
import { createBaseStyle } from "./sources";
|
||||
|
||||
const sourceColors = Object.values(MAP_STYLE_TOKENS.asset);
|
||||
const mapBackgrounds = [
|
||||
MAP_STYLE_TOKENS.canvas.primary,
|
||||
MAP_STYLE_TOKENS.canvas.secondary,
|
||||
MAP_STYLE_TOKENS.canvas.secondaryAlt
|
||||
];
|
||||
|
||||
describe("map color contrast", () => {
|
||||
it("calculates WCAG contrast and alpha composites", () => {
|
||||
expect(getContrastRatio("#000000", "#FFFFFF")).toBe(21);
|
||||
expect(compositeHexColor("#0F766E", "#F9FAFB", 0.88)).toBe("#2B867F");
|
||||
expect(() => compositeHexColor("#0F766E", "#F9FAFB", 1.01)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("generates a direct Coolors contrast-checker link", () => {
|
||||
expect(createCoolorsContrastUrl("#0F766E", "#F9FAFB")).toBe(
|
||||
"https://coolors.co/contrast-checker/0f766e-f9fafb"
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps rendered source colors above 3:1 on every map background", () => {
|
||||
sourceColors.forEach((sourceColor) => {
|
||||
mapBackgrounds.forEach((background) => {
|
||||
const renderedColor = compositeHexColor(
|
||||
sourceColor,
|
||||
background,
|
||||
MAP_STYLE_TOKENS.opacity.core
|
||||
);
|
||||
|
||||
expect(
|
||||
getContrastRatio(renderedColor, background),
|
||||
createCoolorsContrastUrl(renderedColor, background)
|
||||
).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps selected outlines above 4.5:1 on every map background", () => {
|
||||
sourceColors.forEach(() => {
|
||||
mapBackgrounds.forEach((background) => {
|
||||
const selectedColor = MAP_STYLE_TOKENS.state.selected;
|
||||
expect(
|
||||
getContrastRatio(selectedColor, background),
|
||||
createCoolorsContrastUrl(selectedColor, background)
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps closed conduits above 3:1 and applies the primary map background", () => {
|
||||
mapBackgrounds.forEach((background) => {
|
||||
const renderedColor = compositeHexColor(
|
||||
MAP_STYLE_TOKENS.state.inactive,
|
||||
background,
|
||||
MAP_STYLE_TOKENS.opacity.core
|
||||
);
|
||||
expect(getContrastRatio(renderedColor, background)).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
const backgroundLayer = createBaseStyle().layers?.find((layer) => layer.id === "background");
|
||||
if (backgroundLayer?.type !== "background") {
|
||||
throw new Error("Expected the base style to contain a background layer.");
|
||||
}
|
||||
expect(backgroundLayer.paint?.["background-color"]).toBe(MAP_STYLE_TOKENS.canvas.primary);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
const HEX_COLOR_PATTERN = /^#?([0-9a-f]{6})$/i;
|
||||
|
||||
export function getRelativeLuminance(color: string) {
|
||||
const [red, green, blue] = parseHexColor(color).map((channel) => {
|
||||
const normalized = channel / 255;
|
||||
return normalized <= 0.04045
|
||||
? normalized / 12.92
|
||||
: ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||
}
|
||||
|
||||
export function getContrastRatio(firstColor: string, secondColor: string) {
|
||||
const firstLuminance = getRelativeLuminance(firstColor);
|
||||
const secondLuminance = getRelativeLuminance(secondColor);
|
||||
const lighter = Math.max(firstLuminance, secondLuminance);
|
||||
const darker = Math.min(firstLuminance, secondLuminance);
|
||||
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
export function compositeHexColor(
|
||||
foreground: string,
|
||||
background: string,
|
||||
opacity: number
|
||||
) {
|
||||
if (opacity < 0 || opacity > 1) {
|
||||
throw new RangeError("Opacity must be between 0 and 1.");
|
||||
}
|
||||
|
||||
const foregroundChannels = parseHexColor(foreground);
|
||||
const backgroundChannels = parseHexColor(background);
|
||||
const compositeChannels = foregroundChannels.map((channel, index) =>
|
||||
Math.round(channel * opacity + backgroundChannels[index] * (1 - opacity))
|
||||
);
|
||||
|
||||
return `#${compositeChannels
|
||||
.map((channel) => channel.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.toUpperCase()}`;
|
||||
}
|
||||
|
||||
export function createCoolorsContrastUrl(foreground: string, background: string) {
|
||||
const foregroundHex = toPathHex(foreground);
|
||||
const backgroundHex = toPathHex(background);
|
||||
return `https://coolors.co/contrast-checker/${foregroundHex}-${backgroundHex}`;
|
||||
}
|
||||
|
||||
function parseHexColor(color: string) {
|
||||
const match = color.match(HEX_COLOR_PATTERN);
|
||||
if (!match) {
|
||||
throw new TypeError(`Expected a six-digit HEX color, received "${color}".`);
|
||||
}
|
||||
|
||||
return match[1].match(/.{2}/g)!.map((channel) => Number.parseInt(channel, 16));
|
||||
}
|
||||
|
||||
function toPathHex(color: string) {
|
||||
parseHexColor(color);
|
||||
return color.replace("#", "").toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import type {
|
||||
FilterSpecification,
|
||||
GeoJSONSource,
|
||||
GeoJSONSourceSpecification,
|
||||
StyleSpecification
|
||||
} from "maplibre-gl";
|
||||
import type { FeatureCollection, Geometry, LineString, Point, Polygon } from "geojson";
|
||||
import { createCircleCoordinates } from "./geo";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
import { WORKBENCH_INTERACTION_BEFORE_ID } from "./layers";
|
||||
|
||||
export const DRAWING_SOURCE_IDS = {
|
||||
features: "workbench-drawing-features",
|
||||
draft: "workbench-drawing-draft"
|
||||
} as const;
|
||||
|
||||
export const DRAWING_INTERACTIVE_LAYER_IDS = [
|
||||
"workbench-drawing-selected-polygon-outline",
|
||||
"workbench-drawing-selected-line",
|
||||
"workbench-drawing-selected-point",
|
||||
"workbench-drawing-polygon-fill",
|
||||
"workbench-drawing-polygon-outline",
|
||||
"workbench-drawing-line",
|
||||
"workbench-drawing-point"
|
||||
];
|
||||
|
||||
export type DrawingFeatureProperties = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "point" | "line" | "polygon" | "circle";
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type DrawingFeatureCollection = FeatureCollection<Geometry, DrawingFeatureProperties>;
|
||||
|
||||
export const emptyDrawingFeatureCollection: DrawingFeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: []
|
||||
};
|
||||
|
||||
export const drawingSources = {
|
||||
features: {
|
||||
type: "geojson",
|
||||
data: emptyDrawingFeatureCollection
|
||||
} satisfies GeoJSONSourceSpecification,
|
||||
draft: {
|
||||
type: "geojson",
|
||||
data: emptyDrawingFeatureCollection
|
||||
} satisfies GeoJSONSourceSpecification
|
||||
};
|
||||
|
||||
export const drawingLayers: StyleSpecification["layers"] = [
|
||||
{
|
||||
id: "workbench-drawing-polygon-fill",
|
||||
type: "fill",
|
||||
source: DRAWING_SOURCE_IDS.features,
|
||||
filter: ["==", ["geometry-type"], "Polygon"],
|
||||
paint: {
|
||||
"fill-color": MAP_STYLE_TOKENS.tool.geometry,
|
||||
"fill-opacity": 0.16
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-polygon-outline",
|
||||
type: "line",
|
||||
source: DRAWING_SOURCE_IDS.features,
|
||||
filter: ["==", ["geometry-type"], "Polygon"],
|
||||
paint: {
|
||||
"line-color": MAP_STYLE_TOKENS.tool.geometry,
|
||||
"line-width": 2,
|
||||
"line-opacity": 0.82
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-line",
|
||||
type: "line",
|
||||
source: DRAWING_SOURCE_IDS.features,
|
||||
filter: ["==", ["geometry-type"], "LineString"],
|
||||
paint: {
|
||||
"line-color": MAP_STYLE_TOKENS.tool.geometry,
|
||||
"line-width": 3,
|
||||
"line-opacity": 0.88
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-point",
|
||||
type: "circle",
|
||||
source: DRAWING_SOURCE_IDS.features,
|
||||
filter: ["==", ["geometry-type"], "Point"],
|
||||
paint: {
|
||||
"circle-color": MAP_STYLE_TOKENS.tool.geometry,
|
||||
"circle-radius": 6,
|
||||
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||
"circle-stroke-width": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-selected-polygon-outline",
|
||||
type: "line",
|
||||
source: DRAWING_SOURCE_IDS.features,
|
||||
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", ["get", "id"], ""]],
|
||||
paint: {
|
||||
"line-color": MAP_STYLE_TOKENS.state.selected,
|
||||
"line-width": 4,
|
||||
"line-opacity": 0.86
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-selected-line",
|
||||
type: "line",
|
||||
source: DRAWING_SOURCE_IDS.features,
|
||||
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "id"], ""]],
|
||||
paint: {
|
||||
"line-color": MAP_STYLE_TOKENS.state.selected,
|
||||
"line-width": 6,
|
||||
"line-opacity": 0.72
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-selected-point",
|
||||
type: "circle",
|
||||
source: DRAWING_SOURCE_IDS.features,
|
||||
filter: ["all", ["==", ["geometry-type"], "Point"], ["==", ["get", "id"], ""]],
|
||||
paint: {
|
||||
"circle-color": MAP_STYLE_TOKENS.state.selected,
|
||||
"circle-radius": 10,
|
||||
"circle-opacity": 0.2,
|
||||
"circle-stroke-color": MAP_STYLE_TOKENS.state.selected,
|
||||
"circle-stroke-width": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-draft-polygon-fill",
|
||||
type: "fill",
|
||||
source: DRAWING_SOURCE_IDS.draft,
|
||||
filter: ["==", ["geometry-type"], "Polygon"],
|
||||
paint: {
|
||||
"fill-color": MAP_STYLE_TOKENS.tool.geometry,
|
||||
"fill-opacity": 0.12
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-draft-line",
|
||||
type: "line",
|
||||
source: DRAWING_SOURCE_IDS.draft,
|
||||
filter: ["any", ["==", ["geometry-type"], "LineString"], ["==", ["geometry-type"], "Polygon"]],
|
||||
paint: {
|
||||
"line-color": MAP_STYLE_TOKENS.tool.geometry,
|
||||
"line-width": 2,
|
||||
"line-dasharray": [2, 2],
|
||||
"line-opacity": 0.9
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-draft-vertex",
|
||||
type: "circle",
|
||||
source: DRAWING_SOURCE_IDS.draft,
|
||||
filter: ["==", ["geometry-type"], "Point"],
|
||||
paint: {
|
||||
"circle-color": MAP_STYLE_TOKENS.state.selected,
|
||||
"circle-radius": 4,
|
||||
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||
"circle-stroke-width": 1.5
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export function ensureDrawingLayers(map: {
|
||||
getSource: (id: string) => unknown;
|
||||
addSource: (id: string, source: GeoJSONSourceSpecification) => void;
|
||||
getLayer: (id: string) => unknown;
|
||||
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number], beforeId?: string) => void;
|
||||
}) {
|
||||
if (!map.getSource(DRAWING_SOURCE_IDS.features)) {
|
||||
map.addSource(DRAWING_SOURCE_IDS.features, drawingSources.features);
|
||||
}
|
||||
|
||||
if (!map.getSource(DRAWING_SOURCE_IDS.draft)) {
|
||||
map.addSource(DRAWING_SOURCE_IDS.draft, drawingSources.draft);
|
||||
}
|
||||
|
||||
drawingLayers.forEach((layer) => {
|
||||
if (!map.getLayer(layer.id)) {
|
||||
map.addLayer(layer, WORKBENCH_INTERACTION_BEFORE_ID);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function setDrawingSourceData(
|
||||
map: { getSource: (id: string) => unknown },
|
||||
sourceId: string,
|
||||
data: DrawingFeatureCollection
|
||||
) {
|
||||
const source = map.getSource(sourceId) as GeoJSONSource | undefined;
|
||||
source?.setData(data);
|
||||
}
|
||||
|
||||
export function setSelectedDrawingFeatureId(
|
||||
map: { getLayer: (id: string) => unknown; setFilter: (id: string, filter?: FilterSpecification) => void },
|
||||
featureId: string | null
|
||||
) {
|
||||
const id = featureId ?? "";
|
||||
|
||||
[
|
||||
["workbench-drawing-selected-polygon-outline", "Polygon"],
|
||||
["workbench-drawing-selected-line", "LineString"],
|
||||
["workbench-drawing-selected-point", "Point"]
|
||||
].forEach(([layerId, geometryType]) => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setFilter(layerId, ["all", ["==", ["geometry-type"], geometryType], ["==", ["get", "id"], id]]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createPointFeature(
|
||||
id: string,
|
||||
coordinates: [number, number],
|
||||
label: string
|
||||
): DrawingFeatureCollection["features"][number] {
|
||||
return {
|
||||
type: "Feature",
|
||||
id,
|
||||
properties: createDrawingProperties(id, label, "point"),
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates
|
||||
} satisfies Point
|
||||
};
|
||||
}
|
||||
|
||||
export function createLineFeature(
|
||||
id: string,
|
||||
coordinates: [number, number][],
|
||||
label: string
|
||||
): DrawingFeatureCollection["features"][number] {
|
||||
return {
|
||||
type: "Feature",
|
||||
id,
|
||||
properties: createDrawingProperties(id, label, "line"),
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates
|
||||
} satisfies LineString
|
||||
};
|
||||
}
|
||||
|
||||
export function createPolygonFeature(
|
||||
id: string,
|
||||
coordinates: [number, number][],
|
||||
label: string
|
||||
): DrawingFeatureCollection["features"][number] {
|
||||
return {
|
||||
type: "Feature",
|
||||
id,
|
||||
properties: createDrawingProperties(id, label, "polygon"),
|
||||
geometry: {
|
||||
type: "Polygon",
|
||||
coordinates: [[...coordinates, coordinates[0]]]
|
||||
} satisfies Polygon
|
||||
};
|
||||
}
|
||||
|
||||
export function createCircleFeature(
|
||||
id: string,
|
||||
center: [number, number],
|
||||
radiusMeters: number,
|
||||
label: string
|
||||
): DrawingFeatureCollection["features"][number] {
|
||||
const coordinates = createCircleCoordinates(center, radiusMeters);
|
||||
|
||||
return {
|
||||
type: "Feature",
|
||||
id,
|
||||
properties: createDrawingProperties(id, label, "circle"),
|
||||
geometry: {
|
||||
type: "Polygon",
|
||||
coordinates: [coordinates]
|
||||
} satisfies Polygon
|
||||
};
|
||||
}
|
||||
|
||||
function createDrawingProperties(
|
||||
id: string,
|
||||
label: string,
|
||||
kind: DrawingFeatureProperties["kind"]
|
||||
): DrawingFeatureProperties {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
kind,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
createCircleFeature,
|
||||
createLineFeature,
|
||||
createPolygonFeature,
|
||||
type DrawingFeatureCollection
|
||||
} from "./drawing-layers";
|
||||
import { getDistanceMeters, type LngLatTuple } from "./geo";
|
||||
|
||||
export type WorkbenchDrawMode = "point" | "line" | "polygon" | "circle";
|
||||
|
||||
export type DrawingDraftState =
|
||||
| {
|
||||
mode: "line" | "polygon";
|
||||
coordinates: LngLatTuple[];
|
||||
}
|
||||
| {
|
||||
mode: "circle";
|
||||
center: LngLatTuple;
|
||||
edge?: LngLatTuple;
|
||||
};
|
||||
|
||||
export function createDrawingId(mode: WorkbenchDrawMode, index: number) {
|
||||
return `${mode}-${index.toString().padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
export function createDrawingLabel(mode: WorkbenchDrawMode, index: number) {
|
||||
return `${getDrawingKindLabel(mode)} ${index.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function createFeatureFromDraft(
|
||||
id: string,
|
||||
draft: DrawingDraftState,
|
||||
label: string
|
||||
): DrawingFeatureCollection["features"][number] | null {
|
||||
if (draft.mode === "line") {
|
||||
return createLineFeature(id, draft.coordinates, label);
|
||||
}
|
||||
|
||||
if (draft.mode === "polygon") {
|
||||
return createPolygonFeature(id, draft.coordinates, label);
|
||||
}
|
||||
|
||||
if (draft.mode !== "circle" || !draft.edge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createCircleFeature(id, draft.center, getDistanceMeters(draft.center, draft.edge), label);
|
||||
}
|
||||
|
||||
export function getDraftPointCount(draft: DrawingDraftState) {
|
||||
if (draft.mode === "circle") {
|
||||
return draft.edge ? 2 : 1;
|
||||
}
|
||||
|
||||
return draft.coordinates.length;
|
||||
}
|
||||
|
||||
export function getDrawingKindLabel(kind: WorkbenchDrawMode) {
|
||||
if (kind === "point") {
|
||||
return "点标注";
|
||||
}
|
||||
|
||||
if (kind === "line") {
|
||||
return "线标注";
|
||||
}
|
||||
|
||||
if (kind === "circle") {
|
||||
return "圆形标注";
|
||||
}
|
||||
|
||||
return "范围标注";
|
||||
}
|
||||
|
||||
export function getVisibleFeatureCollection(
|
||||
featureCollection: DrawingFeatureCollection,
|
||||
hiddenFeatureIds: Set<string>
|
||||
): DrawingFeatureCollection {
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: featureCollection.features.filter((feature) => !hiddenFeatureIds.has(feature.properties.id))
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDrawingTime(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import maplibregl, { type Map as MapLibreMap, type StyleSpecification } from "maplibre-gl";
|
||||
import type { MapFeatureInteractionState } from "../hooks/use-map-interactions";
|
||||
|
||||
const MAX_EXPORT_DIMENSION = 4096;
|
||||
const EXPORT_IDLE_TIMEOUT_MS = 6000;
|
||||
|
||||
export type ExportMapViewOptions = {
|
||||
scale?: number;
|
||||
targetLongEdge?: number;
|
||||
filename?: string;
|
||||
selectedFeature?: Pick<MapFeatureInteractionState, "source" | "sourceLayer" | "id"> | null;
|
||||
};
|
||||
|
||||
export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEdge, filename, selectedFeature }: ExportMapViewOptions = {}) {
|
||||
const canvas = map.getCanvas();
|
||||
const sourceWidth = canvas.clientWidth || canvas.width;
|
||||
const sourceHeight = canvas.clientHeight || canvas.height;
|
||||
|
||||
if (!sourceWidth || !sourceHeight) {
|
||||
throw new Error("地图画布尺寸不可用。");
|
||||
}
|
||||
|
||||
const requestedScale = targetLongEdge ? targetLongEdge / Math.max(sourceWidth, sourceHeight) : scale ?? 1;
|
||||
const exportScale = Math.max(1, Math.min(requestedScale, MAX_EXPORT_DIMENSION / sourceWidth, MAX_EXPORT_DIMENSION / sourceHeight));
|
||||
const exportWidth = Math.round(sourceWidth * exportScale);
|
||||
const exportHeight = Math.round(sourceHeight * exportScale);
|
||||
const container = createExportContainer(exportWidth, exportHeight);
|
||||
const style = cloneMapStyle(map.getStyle());
|
||||
const bounds = map.getBounds();
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
const exportMap = new maplibregl.Map({
|
||||
container,
|
||||
style,
|
||||
center: map.getCenter(),
|
||||
zoom: map.getZoom(),
|
||||
pitch: map.getPitch(),
|
||||
bearing: map.getBearing(),
|
||||
preserveDrawingBuffer: true,
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
fadeDuration: 0
|
||||
});
|
||||
|
||||
try {
|
||||
await onceMapEvent(exportMap, "load");
|
||||
exportMap.fitBounds(bounds, {
|
||||
padding: 0,
|
||||
duration: 0
|
||||
});
|
||||
if (selectedFeature) {
|
||||
exportMap.setFeatureState(
|
||||
{ source: selectedFeature.source, sourceLayer: selectedFeature.sourceLayer, id: selectedFeature.id },
|
||||
{ selected: true, hovered: false }
|
||||
);
|
||||
}
|
||||
await waitForMapIdle(exportMap);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.download = filename ?? getDefaultExportFilename(getExportLabel(targetLongEdge, exportScale));
|
||||
link.href = exportMap.getCanvas().toDataURL("image/png");
|
||||
link.click();
|
||||
|
||||
return {
|
||||
width: exportWidth,
|
||||
height: exportHeight,
|
||||
scale: exportScale
|
||||
};
|
||||
} finally {
|
||||
exportMap.remove();
|
||||
container.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function createExportContainer(width: number, height: number) {
|
||||
const container = document.createElement("div");
|
||||
container.style.position = "fixed";
|
||||
container.style.left = "-10000px";
|
||||
container.style.top = "0";
|
||||
container.style.width = `${width}px`;
|
||||
container.style.height = `${height}px`;
|
||||
container.style.pointerEvents = "none";
|
||||
container.style.opacity = "0";
|
||||
return container;
|
||||
}
|
||||
|
||||
function cloneMapStyle(style: StyleSpecification) {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(style);
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(style)) as StyleSpecification;
|
||||
}
|
||||
|
||||
function onceMapEvent(map: MapLibreMap, eventName: "load" | "idle") {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
map.once(eventName, () => resolve());
|
||||
map.once("error", (event) => reject(event.error ?? new Error("地图导出渲染失败。")));
|
||||
});
|
||||
}
|
||||
|
||||
function waitForMapIdle(map: MapLibreMap) {
|
||||
return Promise.race([
|
||||
onceMapEvent(map, "idle"),
|
||||
new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, EXPORT_IDLE_TIMEOUT_MS);
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
function getExportLabel(targetLongEdge: number | undefined, scale: number) {
|
||||
if (targetLongEdge === 3840) {
|
||||
return "4k";
|
||||
}
|
||||
|
||||
return `${scale.toFixed(1)}x`;
|
||||
}
|
||||
|
||||
function getDefaultExportFilename(label: string) {
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
||||
return `drainage-network-view-${label}-${timestamp}.png`;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { MapGeoJSONFeature } from "maplibre-gl";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toDetailFeature } from "./feature-adapter";
|
||||
import { SOURCE_LAYERS } from "./sources";
|
||||
|
||||
describe("drainage feature adapter", () => {
|
||||
it.each([
|
||||
{
|
||||
layer: "conduits",
|
||||
sourceLayer: SOURCE_LAYERS.conduits,
|
||||
properties: { id: "C-1", diameter: 600, length: 42.5 },
|
||||
title: "管渠 C-1",
|
||||
subtitle: "DN600 · 42.50 m"
|
||||
},
|
||||
{
|
||||
layer: "junctions",
|
||||
sourceLayer: SOURCE_LAYERS.junctions,
|
||||
properties: { id: "J-1", max_depth: 4.2, invert_elevation: -1.8 },
|
||||
title: "检查井 J-1",
|
||||
subtitle: "最大深度 4.20 m · 井底高程 -1.80 m"
|
||||
},
|
||||
{
|
||||
layer: "orifices",
|
||||
sourceLayer: SOURCE_LAYERS.orifices,
|
||||
properties: { id: "O-1", orifice_type: "SIDE", shape: "RECT_CLOSED" },
|
||||
title: "孔口 O-1",
|
||||
subtitle: "类型 SIDE · 断面 RECT_CLOSED"
|
||||
},
|
||||
{
|
||||
layer: "pumps",
|
||||
sourceLayer: SOURCE_LAYERS.pumps,
|
||||
properties: { id: "P-1", status: "ON", pump_curve: "CURVE-1" },
|
||||
title: "泵 P-1",
|
||||
subtitle: "状态 ON · 曲线 CURVE-1"
|
||||
},
|
||||
{
|
||||
layer: "outfalls",
|
||||
sourceLayer: SOURCE_LAYERS.outfalls,
|
||||
properties: { id: "OF-1", outfall_type: "FREE", invert_elevation: -5.67 },
|
||||
title: "排放口 OF-1",
|
||||
subtitle: "类型 FREE · 井底高程 -5.67 m"
|
||||
}
|
||||
] as const)("adapts $layer features", ({ layer, sourceLayer, properties, title, subtitle }) => {
|
||||
const detail = toDetailFeature({
|
||||
sourceLayer,
|
||||
properties
|
||||
} as unknown as MapGeoJSONFeature);
|
||||
|
||||
expect(detail).toMatchObject({
|
||||
id: properties.id,
|
||||
layer,
|
||||
title,
|
||||
subtitle,
|
||||
properties
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects features outside the drainage network sources", () => {
|
||||
expect(() => toDetailFeature({ sourceLayer: "other" } as unknown as MapGeoJSONFeature)).toThrow(
|
||||
"Unsupported water network source layer: other"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the promoted SCADA sensor id for feature-state interactions", () => {
|
||||
const detail = toDetailFeature({
|
||||
id: "SCADA-001",
|
||||
sourceLayer: SOURCE_LAYERS.scada,
|
||||
properties: {
|
||||
sensor_id: "SCADA-001",
|
||||
sensor_name: "DY22东市南街环城南路西段交叉口",
|
||||
swmm_node: "J-1001"
|
||||
}
|
||||
} as unknown as MapGeoJSONFeature);
|
||||
|
||||
expect(detail).toMatchObject({
|
||||
id: "SCADA-001",
|
||||
layer: "scada",
|
||||
title: "DY22东市南街环城南路西段交叉口",
|
||||
subtitle: "综合监测点 · SWMM 节点 J-1001"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { MapGeoJSONFeature } from "maplibre-gl";
|
||||
import type { DetailFeature } from "../types";
|
||||
import { formatValue } from "../utils/format-value";
|
||||
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
|
||||
|
||||
export function getFeatureId(feature: MapGeoJSONFeature) {
|
||||
const layer = getWaterNetworkSourceId(feature);
|
||||
const raw = layer === "scada"
|
||||
? feature.properties?.sensor_id ?? feature.id
|
||||
: feature.id ?? feature.properties?.id;
|
||||
|
||||
return raw === undefined || raw === null ? "" : String(raw);
|
||||
}
|
||||
|
||||
export function getWaterNetworkSourceId(feature: MapGeoJSONFeature): WaterNetworkSourceId | null {
|
||||
const entry = Object.entries(SOURCE_LAYERS).find(([, sourceLayer]) => sourceLayer === feature.sourceLayer);
|
||||
return (entry?.[0] as WaterNetworkSourceId | undefined) ?? null;
|
||||
}
|
||||
|
||||
export function toDetailFeature(feature: MapGeoJSONFeature): DetailFeature {
|
||||
const layer = getWaterNetworkSourceId(feature);
|
||||
if (!layer) {
|
||||
throw new Error(`Unsupported water network source layer: ${feature.sourceLayer ?? "unknown"}`);
|
||||
}
|
||||
|
||||
const id = getFeatureId(feature);
|
||||
const properties = feature.properties ?? {};
|
||||
const presentation = getFeaturePresentation(layer, id, properties);
|
||||
|
||||
return {
|
||||
id,
|
||||
layer,
|
||||
...presentation,
|
||||
properties
|
||||
};
|
||||
}
|
||||
|
||||
function getFeaturePresentation(
|
||||
layer: WaterNetworkSourceId,
|
||||
id: string,
|
||||
properties: Record<string, unknown>
|
||||
) {
|
||||
const displayId = id || "未命名";
|
||||
|
||||
switch (layer) {
|
||||
case "conduits":
|
||||
return {
|
||||
title: `管渠 ${displayId}`,
|
||||
subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.length)} m`
|
||||
};
|
||||
case "junctions":
|
||||
return {
|
||||
title: `检查井 ${displayId}`,
|
||||
subtitle: `最大深度 ${formatValue(properties.max_depth)} m · 井底高程 ${formatValue(properties.invert_elevation)} m`
|
||||
};
|
||||
case "orifices":
|
||||
return {
|
||||
title: `孔口 ${displayId}`,
|
||||
subtitle: `类型 ${formatValue(properties.orifice_type)} · 断面 ${formatValue(properties.shape)}`
|
||||
};
|
||||
case "pumps":
|
||||
return {
|
||||
title: `泵 ${displayId}`,
|
||||
subtitle: `状态 ${formatValue(properties.status)} · 曲线 ${formatValue(properties.pump_curve)}`
|
||||
};
|
||||
case "outfalls":
|
||||
return {
|
||||
title: `排放口 ${displayId}`,
|
||||
subtitle: `类型 ${formatValue(properties.outfall_type)} · 井底高程 ${formatValue(properties.invert_elevation)} m`
|
||||
};
|
||||
case "scada":
|
||||
return {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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,96 @@
|
||||
export type LngLatTuple = [number, number];
|
||||
|
||||
const EARTH_RADIUS_METERS = 6371008.8;
|
||||
const SAME_COORDINATE_EPSILON = 0.0000001;
|
||||
|
||||
export function getDistanceMeters(from: LngLatTuple, to: LngLatTuple) {
|
||||
const fromLatitude = toRadians(from[1]);
|
||||
const toLatitude = toRadians(to[1]);
|
||||
const deltaLatitude = toRadians(to[1] - from[1]);
|
||||
const deltaLongitude = toRadians(to[0] - from[0]);
|
||||
const a =
|
||||
Math.sin(deltaLatitude / 2) ** 2 +
|
||||
Math.cos(fromLatitude) * Math.cos(toLatitude) * Math.sin(deltaLongitude / 2) ** 2;
|
||||
|
||||
return 2 * EARTH_RADIUS_METERS * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
export function getLineDistanceMeters(coordinates: LngLatTuple[]) {
|
||||
return coordinates.reduce((total, coordinate, index) => {
|
||||
if (index === 0) {
|
||||
return total;
|
||||
}
|
||||
|
||||
return total + getDistanceMeters(coordinates[index - 1], coordinate);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function getLastSegmentDistanceMeters(coordinates: LngLatTuple[]) {
|
||||
if (coordinates.length < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return getDistanceMeters(coordinates[coordinates.length - 2], coordinates[coordinates.length - 1]);
|
||||
}
|
||||
|
||||
export function getPolygonAreaSquareMeters(coordinates: LngLatTuple[]) {
|
||||
if (coordinates.length < 3) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const closedCoordinates = [...coordinates, coordinates[0]];
|
||||
const area = closedCoordinates.slice(0, -1).reduce((total, coordinate, index) => {
|
||||
const nextCoordinate = closedCoordinates[index + 1];
|
||||
return (
|
||||
total +
|
||||
toRadians(nextCoordinate[0] - coordinate[0]) *
|
||||
(2 + Math.sin(toRadians(coordinate[1])) + Math.sin(toRadians(nextCoordinate[1])))
|
||||
);
|
||||
}, 0);
|
||||
|
||||
return Math.abs((area * EARTH_RADIUS_METERS * EARTH_RADIUS_METERS) / 2);
|
||||
}
|
||||
|
||||
export function createCircleCoordinates(center: LngLatTuple, radiusMeters: number, steps = 48) {
|
||||
const angularDistance = radiusMeters / EARTH_RADIUS_METERS;
|
||||
const centerLongitude = toRadians(center[0]);
|
||||
const centerLatitude = toRadians(center[1]);
|
||||
const coordinates: LngLatTuple[] = [];
|
||||
|
||||
for (let index = 0; index <= steps; index += 1) {
|
||||
const bearing = (2 * Math.PI * index) / steps;
|
||||
const latitude = Math.asin(
|
||||
Math.sin(centerLatitude) * Math.cos(angularDistance) +
|
||||
Math.cos(centerLatitude) * Math.sin(angularDistance) * Math.cos(bearing)
|
||||
);
|
||||
const longitude =
|
||||
centerLongitude +
|
||||
Math.atan2(
|
||||
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(centerLatitude),
|
||||
Math.cos(angularDistance) - Math.sin(centerLatitude) * Math.sin(latitude)
|
||||
);
|
||||
|
||||
coordinates.push([toDegrees(longitude), toDegrees(latitude)]);
|
||||
}
|
||||
|
||||
return coordinates;
|
||||
}
|
||||
|
||||
export function isSameCoordinate(previous: LngLatTuple | undefined, next: LngLatTuple) {
|
||||
if (!previous) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
Math.abs(previous[0] - next[0]) < SAME_COORDINATE_EPSILON &&
|
||||
Math.abs(previous[1] - next[1]) < SAME_COORDINATE_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
export function toRadians(value: number) {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function toDegrees(value: number) {
|
||||
return (value * 180) / Math.PI;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
INTERACTIVE_HIT_LAYER_IDS,
|
||||
waterNetworkLayers
|
||||
} from "./layers";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
import { DRAINAGE_LAYER_VISUALS, MAP_MAX_ZOOM } from "./map-layer-visuals";
|
||||
|
||||
describe("drainage network interaction layers", () => {
|
||||
it("adds one invisible hit layer for every interactive source", () => {
|
||||
expect(INTERACTIVE_HIT_LAYER_IDS).toEqual([
|
||||
"conduits-hit",
|
||||
"junctions-hit",
|
||||
"orifices-hit",
|
||||
"pumps-hit",
|
||||
"outfalls-hit"
|
||||
]);
|
||||
|
||||
const hitLayers = INTERACTIVE_HIT_LAYER_IDS.map((layerId) => {
|
||||
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
|
||||
expect(layer).toBeDefined();
|
||||
return layer!;
|
||||
});
|
||||
|
||||
expect(hitLayers.map((layer) => "source" in layer ? layer.source : null)).toEqual([
|
||||
"conduits",
|
||||
"junctions",
|
||||
"orifices",
|
||||
"pumps",
|
||||
"outfalls"
|
||||
]);
|
||||
|
||||
hitLayers.forEach((layer) => {
|
||||
if (layer.type === "line") {
|
||||
expect(layer.paint?.["line-width"]).toBe(DRAINAGE_LAYER_VISUALS.conduits.hitWidth);
|
||||
expect(layer.paint?.["line-opacity"]).toBe(0);
|
||||
} else if (layer.type === "circle") {
|
||||
expect(layer.paint?.["circle-radius"]).toBe(DRAINAGE_LAYER_VISUALS.junctions.hitRadius);
|
||||
expect(layer.paint?.["circle-opacity"]).toBe(0);
|
||||
} else {
|
||||
throw new Error(`Unexpected hit layer type: ${layer.type}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("orders smaller facilities above conduits for overlapping hits", () => {
|
||||
const layerIds = waterNetworkLayers.map((layer) => layer.id);
|
||||
const hitLayerIndexes = INTERACTIVE_HIT_LAYER_IDS.map((layerId) => layerIds.indexOf(layerId));
|
||||
|
||||
expect(hitLayerIndexes).toEqual([...hitLayerIndexes].sort((first, second) => first - second));
|
||||
expect(hitLayerIndexes.every((index) => index >= 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("provides persistent selected layers for every source", () => {
|
||||
const selectedLayerIds = [
|
||||
"pipes-selected-outline",
|
||||
"junctions-selected",
|
||||
"orifices-selected-outline",
|
||||
"pumps-selected-outline",
|
||||
"outfalls-selected"
|
||||
];
|
||||
|
||||
selectedLayerIds.forEach((layerId) => {
|
||||
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
|
||||
expect(layer && "filter" in layer ? layer.filter : undefined).toBeUndefined();
|
||||
const paint = layer?.paint as Record<string, unknown> | undefined;
|
||||
expect(JSON.stringify(paint)).toContain("feature-state");
|
||||
expect(JSON.stringify(paint)).toContain("selected");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders selected features as opaque hollow outlines", () => {
|
||||
const expectedLineOutlines = [
|
||||
["pipes-selected-outline", DRAINAGE_LAYER_VISUALS.conduits],
|
||||
["orifices-selected-outline", DRAINAGE_LAYER_VISUALS.orifices],
|
||||
["pumps-selected-outline", DRAINAGE_LAYER_VISUALS.pumps]
|
||||
] as const;
|
||||
|
||||
expectedLineOutlines.forEach(([layerId, visuals]) => {
|
||||
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
|
||||
const paint = layer?.paint as Record<string, unknown> | undefined;
|
||||
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",
|
||||
["any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "highlighted"], false]],
|
||||
1,
|
||||
0
|
||||
]);
|
||||
});
|
||||
|
||||
const expectedPointOutlines = [
|
||||
"junctions-selected",
|
||||
"outfalls-selected"
|
||||
] as const;
|
||||
expectedPointOutlines.forEach((layerId) => {
|
||||
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
|
||||
const paint = layer?.paint as Record<string, unknown> | undefined;
|
||||
expect(paint?.["circle-color"]).toBe(MAP_STYLE_TOKENS.canvas.transparent);
|
||||
expect(JSON.stringify(paint?.["circle-opacity"])).toContain("feature-state");
|
||||
expect(paint?.["circle-stroke-color"]).toBe(MAP_STYLE_TOKENS.state.selected);
|
||||
expect(JSON.stringify(paint?.["circle-stroke-opacity"])).toContain("selected");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps source geometry readable through zoom level 24", () => {
|
||||
expect(MAP_MAX_ZOOM).toBe(24);
|
||||
expect(DRAINAGE_LAYER_VISUALS.junctions.minzoom).toBe(12);
|
||||
expect(DRAINAGE_LAYER_VISUALS.junctions.radius).toEqual([
|
||||
"interpolate", ["linear"], ["zoom"], 12, 0.6, 14, 1, 15, 1.5, 16, 3, 20, 5.5, 24, 8
|
||||
]);
|
||||
expect(DRAINAGE_LAYER_VISUALS.outfalls.radius).toEqual([
|
||||
"interpolate", ["linear"], ["zoom"], 11, 3, 16, 5, 20, 7, 24, 9
|
||||
]);
|
||||
expect(DRAINAGE_LAYER_VISUALS.orifices.width).toEqual([
|
||||
"interpolate", ["linear"], ["zoom"], 11, 1.6, 17, 4.5, 22, 6, 24, 7
|
||||
]);
|
||||
expect(DRAINAGE_LAYER_VISUALS.pumps.width).toEqual([
|
||||
"interpolate", ["linear"], ["zoom"], 11, 2.4, 17, 6.2, 22, 8, 24, 9
|
||||
]);
|
||||
expect((DRAINAGE_LAYER_VISUALS.conduits.width as unknown[]).at(-2)).toBe(24);
|
||||
expect((DRAINAGE_LAYER_VISUALS.conduits.hitWidth as unknown[]).at(-1)).toBe(24);
|
||||
expect((DRAINAGE_LAYER_VISUALS.junctions.hitRadius as unknown[]).at(-1)).toBe(14);
|
||||
});
|
||||
|
||||
it("prioritizes continuous conduits before full junction detail at low zoom", () => {
|
||||
expect(DRAINAGE_LAYER_VISUALS.conduits.width).toEqual([
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
9, 1.1,
|
||||
13, 2,
|
||||
17, ["+", 2.5, ["/", ["coalesce", ["get", "diameter"], 100], 360]],
|
||||
22, ["+", 3.5, ["/", ["coalesce", ["get", "diameter"], 100], 300]],
|
||||
24, ["+", 4, ["/", ["coalesce", ["get", "diameter"], 100], 280]]
|
||||
]);
|
||||
expect(DRAINAGE_LAYER_VISUALS.junctions.haloOpacity).toEqual([
|
||||
"interpolate", ["linear"], ["zoom"], 12, 0, 14, 0, 15, 1
|
||||
]);
|
||||
|
||||
const junctionHalo = waterNetworkLayers.find((layer) => layer.id === "junctions-halo");
|
||||
const orifices = waterNetworkLayers.find((layer) => layer.id === "orifices");
|
||||
const pumps = waterNetworkLayers.find((layer) => layer.id === "pumps");
|
||||
expect((junctionHalo?.paint as Record<string, unknown>)?.["circle-opacity"]).toBe(DRAINAGE_LAYER_VISUALS.junctions.haloOpacity);
|
||||
expect((orifices?.paint as Record<string, unknown>)?.["line-dasharray"]).toEqual([4, 2]);
|
||||
expect((pumps?.paint as Record<string, unknown>)?.["line-dasharray"]).toEqual([6, 2, 1.5, 2]);
|
||||
});
|
||||
|
||||
it("uses the centralized geometry tokens in rendered layers", () => {
|
||||
const expectedGeometry = [
|
||||
["pipes-flow", "line-width", DRAINAGE_LAYER_VISUALS.conduits.width],
|
||||
["pipes-selected-outline", "line-width", DRAINAGE_LAYER_VISUALS.conduits.selectedBorderWidth],
|
||||
["junctions", "circle-radius", DRAINAGE_LAYER_VISUALS.junctions.radius],
|
||||
["junctions-selected", "circle-radius", DRAINAGE_LAYER_VISUALS.junctions.selectedRadius],
|
||||
["orifices", "line-width", DRAINAGE_LAYER_VISUALS.orifices.width],
|
||||
["orifices-selected-outline", "line-width", DRAINAGE_LAYER_VISUALS.orifices.selectedBorderWidth],
|
||||
["pumps", "line-width", DRAINAGE_LAYER_VISUALS.pumps.width],
|
||||
["pumps-selected-outline", "line-width", DRAINAGE_LAYER_VISUALS.pumps.selectedBorderWidth],
|
||||
["outfalls", "circle-radius", DRAINAGE_LAYER_VISUALS.outfalls.radius],
|
||||
["outfalls-selected", "circle-radius", DRAINAGE_LAYER_VISUALS.outfalls.selectedRadius]
|
||||
] as const;
|
||||
|
||||
expectedGeometry.forEach(([layerId, paintProperty, visualToken]) => {
|
||||
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
|
||||
const paint = layer?.paint as Record<string, unknown> | undefined;
|
||||
expect(paint?.[paintProperty]).toBe(visualToken);
|
||||
});
|
||||
|
||||
["junctions-halo", "junctions", "junctions-hover", "junctions-hit"].forEach((layerId) => {
|
||||
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
|
||||
expect(layer && "minzoom" in layer ? layer.minzoom : undefined).toBe(12);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
|
||||
import { DRAINAGE_LAYER_VISUALS } from "./map-layer-visuals";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
import { SOURCE_LAYERS } from "./sources";
|
||||
|
||||
export const INTERACTIVE_HIT_LAYER_IDS = [
|
||||
"conduits-hit", "junctions-hit", "orifices-hit", "pumps-hit", "outfalls-hit"
|
||||
] as const;
|
||||
|
||||
const hoveredOpacity: ExpressionSpecification = [
|
||||
"case",
|
||||
[
|
||||
"all",
|
||||
["boolean", ["feature-state", "hovered"], false],
|
||||
["!", ["any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "highlighted"], false]]]
|
||||
],
|
||||
1,
|
||||
0
|
||||
];
|
||||
const selectedOpacity: ExpressionSpecification = [
|
||||
"case",
|
||||
["any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "highlighted"], false]],
|
||||
1,
|
||||
0
|
||||
];
|
||||
const stateColor = (sourceColor: string): ExpressionSpecification => [
|
||||
"case",
|
||||
["in", ["downcase", ["to-string", ["coalesce", ["get", "status"], ""]]], ["literal", ["closed", "off", "inactive"]]],
|
||||
MAP_STYLE_TOKENS.state.inactive,
|
||||
sourceColor
|
||||
];
|
||||
const opacityTransition = { duration: 120, delay: 0 } as const;
|
||||
|
||||
type Layer = NonNullable<StyleSpecification["layers"]>[number];
|
||||
type AuthoredLayer = { id: string; type: string; [key: string]: unknown };
|
||||
const layers: AuthoredLayer[] = [];
|
||||
|
||||
// Business sources: white casing, then category-colored core.
|
||||
layers.push(
|
||||
{ id: "pipes-casing", type: "line", source: "conduits", "source-layer": SOURCE_LAYERS.conduits, minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-width": DRAINAGE_LAYER_VISUALS.conduits.casingWidth, "line-opacity": MAP_STYLE_TOKENS.opacity.casing } },
|
||||
{ id: "pipes-flow", type: "line", source: "conduits", "source-layer": SOURCE_LAYERS.conduits, minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom, paint: { "line-color": stateColor(MAP_STYLE_TOKENS.asset.conduit), "line-width": DRAINAGE_LAYER_VISUALS.conduits.width, "line-opacity": MAP_STYLE_TOKENS.opacity.core } },
|
||||
{ id: "junctions-halo", type: "circle", source: "junctions", "source-layer": SOURCE_LAYERS.junctions, minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.casing, "circle-radius": DRAINAGE_LAYER_VISUALS.junctions.haloRadius, "circle-opacity": DRAINAGE_LAYER_VISUALS.junctions.haloOpacity } },
|
||||
{ id: "junctions", type: "circle", source: "junctions", "source-layer": SOURCE_LAYERS.junctions, minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom, paint: { "circle-color": stateColor(MAP_STYLE_TOKENS.asset.junction), "circle-radius": DRAINAGE_LAYER_VISUALS.junctions.radius, "circle-opacity": MAP_STYLE_TOKENS.opacity.core } },
|
||||
{ id: "orifices-casing", type: "line", source: "orifices", "source-layer": SOURCE_LAYERS.orifices, minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-width": DRAINAGE_LAYER_VISUALS.orifices.casingWidth, "line-opacity": 1 } },
|
||||
{ id: "orifices", type: "line", source: "orifices", "source-layer": SOURCE_LAYERS.orifices, minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom, paint: { "line-color": stateColor(MAP_STYLE_TOKENS.asset.orifice), "line-width": DRAINAGE_LAYER_VISUALS.orifices.width, "line-dasharray": [4, 2], "line-opacity": MAP_STYLE_TOKENS.opacity.core } },
|
||||
{ id: "pumps-casing", type: "line", source: "pumps", "source-layer": SOURCE_LAYERS.pumps, minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-width": DRAINAGE_LAYER_VISUALS.pumps.casingWidth, "line-opacity": 1 } },
|
||||
{ id: "pumps", type: "line", source: "pumps", "source-layer": SOURCE_LAYERS.pumps, minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom, paint: { "line-color": stateColor(MAP_STYLE_TOKENS.asset.pump), "line-width": DRAINAGE_LAYER_VISUALS.pumps.width, "line-dasharray": [6, 2, 1.5, 2], "line-opacity": MAP_STYLE_TOKENS.opacity.core } },
|
||||
{ id: "outfalls-halo", type: "circle", source: "outfalls", "source-layer": SOURCE_LAYERS.outfalls, minzoom: DRAINAGE_LAYER_VISUALS.outfalls.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.casing, "circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.haloRadius, "circle-opacity": 1 } },
|
||||
{ id: "outfalls", type: "circle", source: "outfalls", "source-layer": SOURCE_LAYERS.outfalls, minzoom: DRAINAGE_LAYER_VISUALS.outfalls.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.casing, "circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.radius, "circle-opacity": 1, "circle-stroke-color": stateColor(MAP_STYLE_TOKENS.asset.outfall), "circle-stroke-width": 2 } }
|
||||
);
|
||||
|
||||
const lineInteractions = [
|
||||
["pipes", "conduits", SOURCE_LAYERS.conduits, DRAINAGE_LAYER_VISUALS.conduits, MAP_STYLE_TOKENS.asset.conduit],
|
||||
["orifices", "orifices", SOURCE_LAYERS.orifices, DRAINAGE_LAYER_VISUALS.orifices, MAP_STYLE_TOKENS.asset.orifice],
|
||||
["pumps", "pumps", SOURCE_LAYERS.pumps, DRAINAGE_LAYER_VISUALS.pumps, MAP_STYLE_TOKENS.asset.pump]
|
||||
] as const;
|
||||
|
||||
lineInteractions.forEach(([prefix, source, sourceLayer, visual, color]) => {
|
||||
layers.push(
|
||||
{ id: `${prefix}-hover-outline`, type: "line", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-width": visual.hoverOutlineWidth, "line-opacity": hoveredOpacity, "line-opacity-transition": opacityTransition } },
|
||||
{ id: `${prefix}-hover`, type: "line", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "line-color": stateColor(color), "line-width": visual.hoverWidth, "line-opacity": hoveredOpacity, "line-opacity-transition": opacityTransition } },
|
||||
{ id: `${prefix}-selected-outer`, type: "line", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-gap-width": visual.selectedWidth, "line-width": 4.5, "line-opacity": selectedOpacity, "line-opacity-transition": opacityTransition } },
|
||||
{ id: `${prefix}-selected-outline`, type: "line", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.state.selected, "line-gap-width": visual.selectedWidth, "line-width": visual.selectedBorderWidth, "line-opacity": selectedOpacity, "line-opacity-transition": opacityTransition } },
|
||||
{ id: `${prefix}-selected-separator`, type: "line", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-gap-width": visual.selectedWidth, "line-width": 1, "line-offset": -1.5, "line-opacity": selectedOpacity, "line-opacity-transition": opacityTransition } }
|
||||
);
|
||||
});
|
||||
|
||||
const pointInteractions = [
|
||||
["junctions", "junctions", SOURCE_LAYERS.junctions, DRAINAGE_LAYER_VISUALS.junctions, MAP_STYLE_TOKENS.asset.junction],
|
||||
["outfalls", "outfalls", SOURCE_LAYERS.outfalls, DRAINAGE_LAYER_VISUALS.outfalls, MAP_STYLE_TOKENS.asset.outfall]
|
||||
] as const;
|
||||
pointInteractions.forEach(([prefix, source, sourceLayer, visual, color]) => {
|
||||
layers.push(
|
||||
{ id: `${prefix}-hover`, type: "circle", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "circle-color": stateColor(color), "circle-radius": visual.hoverRadius, "circle-opacity": hoveredOpacity, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 2, "circle-opacity-transition": opacityTransition, "circle-stroke-opacity": hoveredOpacity, "circle-stroke-opacity-transition": opacityTransition } },
|
||||
{ id: `${prefix}-selected-outer`, type: "circle", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-radius": visual.selectedRadius, "circle-opacity": selectedOpacity, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 6, "circle-stroke-opacity": selectedOpacity, "circle-opacity-transition": opacityTransition, "circle-stroke-opacity-transition": opacityTransition } },
|
||||
{ id: `${prefix}-selected`, type: "circle", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-radius": visual.selectedRadius, "circle-opacity": selectedOpacity, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": 3, "circle-stroke-opacity": selectedOpacity, "circle-opacity-transition": opacityTransition, "circle-stroke-opacity-transition": opacityTransition } }
|
||||
);
|
||||
});
|
||||
|
||||
// Transparent hit layers stay last so pointer targeting is independent of paint order.
|
||||
layers.push(
|
||||
{ id: "conduits-hit", type: "line", source: "conduits", "source-layer": SOURCE_LAYERS.conduits, minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.transparent, "line-width": DRAINAGE_LAYER_VISUALS.conduits.hitWidth, "line-opacity": 0 } },
|
||||
{ id: "junctions-hit", type: "circle", source: "junctions", "source-layer": SOURCE_LAYERS.junctions, minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-radius": DRAINAGE_LAYER_VISUALS.junctions.hitRadius, "circle-opacity": 0 } },
|
||||
{ id: "orifices-hit", type: "line", source: "orifices", "source-layer": SOURCE_LAYERS.orifices, minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.transparent, "line-width": DRAINAGE_LAYER_VISUALS.orifices.hitWidth, "line-opacity": 0 } },
|
||||
{ id: "pumps-hit", type: "line", source: "pumps", "source-layer": SOURCE_LAYERS.pumps, minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.transparent, "line-width": DRAINAGE_LAYER_VISUALS.pumps.hitWidth, "line-opacity": 0 } },
|
||||
{ id: "outfalls-hit", type: "circle", source: "outfalls", "source-layer": SOURCE_LAYERS.outfalls, minzoom: DRAINAGE_LAYER_VISUALS.outfalls.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.hitRadius, "circle-opacity": 0 } }
|
||||
);
|
||||
|
||||
export const waterNetworkLayers: StyleSpecification["layers"] = layers as Layer[];
|
||||
|
||||
const interactionIndex = layers.findIndex((layer) => layer.id === "pipes-hover-outline");
|
||||
const hitIndex = layers.findIndex((layer) => layer.id === INTERACTIVE_HIT_LAYER_IDS[0]);
|
||||
|
||||
export const waterNetworkBusinessLayers = layers.slice(0, interactionIndex) as Layer[];
|
||||
export const waterNetworkInteractionLayers = layers.slice(interactionIndex, hitIndex) as Layer[];
|
||||
export const waterNetworkHitLayers = layers.slice(hitIndex) as Layer[];
|
||||
export const WORKBENCH_INTERACTION_BEFORE_ID = "pipes-hover-outline";
|
||||
@@ -0,0 +1,38 @@
|
||||
/** Runtime map colors. Keep literal colors in this file only. */
|
||||
export const MAP_STYLE_TOKENS = {
|
||||
canvas: {
|
||||
primary: "#F9FAFB",
|
||||
secondary: "#F0F1F3",
|
||||
secondaryAlt: "#FAFBFC",
|
||||
casing: "#FFFFFF",
|
||||
transparent: "rgba(0,0,0,0)"
|
||||
},
|
||||
asset: {
|
||||
conduit: "#0F766E",
|
||||
junction: "#0369A1",
|
||||
orifice: "#475569",
|
||||
pump: "#155E75",
|
||||
outfall: "#334155"
|
||||
},
|
||||
state: {
|
||||
selected: "#2563EB",
|
||||
inactive: "#64748B",
|
||||
risk: "#C2410C",
|
||||
incident: "#B91C1C",
|
||||
success: "#15803D",
|
||||
agent: "#6D28D9"
|
||||
},
|
||||
tool: {
|
||||
geometry: "#334155"
|
||||
},
|
||||
label: {
|
||||
primary: "#0F172A",
|
||||
secondary: "#334155",
|
||||
inverse: "#FFFFFF"
|
||||
},
|
||||
opacity: {
|
||||
core: 0.9,
|
||||
casing: 1,
|
||||
area: 0.14
|
||||
}
|
||||
} as const;
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { waterNetworkLayers } from "./layers";
|
||||
import {
|
||||
INITIAL_LAYER_VISIBILITY,
|
||||
createLayerControlItems,
|
||||
getWorkbenchLayerIds
|
||||
} from "./map-control-config";
|
||||
|
||||
describe("workbench source layer controls", () => {
|
||||
it("creates one business control for each GeoServer source", () => {
|
||||
const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY);
|
||||
|
||||
expect(items.map((item) => item.id)).toEqual([
|
||||
"conduits",
|
||||
"junctions",
|
||||
"orifices",
|
||||
"outfalls",
|
||||
"pumps",
|
||||
"scada"
|
||||
]);
|
||||
expect(items.slice(0, 5).map((item) => item.description)).toEqual([
|
||||
"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", () => {
|
||||
const map = {
|
||||
getStyle: () => ({ version: 8, sources: {}, layers: waterNetworkLayers })
|
||||
} as Pick<MapLibreMap, "getStyle">;
|
||||
|
||||
expect(getWorkbenchLayerIds(map, "orifices")).toEqual([
|
||||
"orifices-casing",
|
||||
"orifices",
|
||||
"orifices-hover-outline",
|
||||
"orifices-hover",
|
||||
"orifices-selected-outer",
|
||||
"orifices-selected-outline",
|
||||
"orifices-selected-separator",
|
||||
"orifices-hit"
|
||||
]);
|
||||
expect(getWorkbenchLayerIds(map, "outfalls")).toEqual([
|
||||
"outfalls-halo",
|
||||
"outfalls",
|
||||
"outfalls-hover",
|
||||
"outfalls-selected-outer",
|
||||
"outfalls-selected",
|
||||
"outfalls-hit"
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
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,
|
||||
WATER_NETWORK_SOURCE_IDS,
|
||||
type WaterNetworkSourceId
|
||||
} from "./sources";
|
||||
|
||||
export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
|
||||
simulation: [
|
||||
"simulation-impact-fill",
|
||||
"simulation-impact-outline",
|
||||
"simulation-burst-halo",
|
||||
"simulation-burst-point",
|
||||
"simulation-pipe-label",
|
||||
"simulation-impact-label",
|
||||
"simulation-district-label",
|
||||
"simulation-burst-label"
|
||||
]
|
||||
};
|
||||
|
||||
export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
|
||||
conduits: true,
|
||||
junctions: true,
|
||||
orifices: true,
|
||||
outfalls: true,
|
||||
pumps: true,
|
||||
scada: true,
|
||||
simulation: false
|
||||
};
|
||||
|
||||
const SOURCE_CONTROL_LABELS: Record<WaterNetworkSourceId, { label: string; description: string }> = {
|
||||
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[] = [
|
||||
{ id: "conduit", label: "管渠(线宽表示口径)", color: MAP_STYLE_TOKENS.asset.conduit, shape: "line" },
|
||||
{ id: "junction", label: "检查井", color: MAP_STYLE_TOKENS.asset.junction, shape: "dot" },
|
||||
{ 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-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" },
|
||||
{ id: "agent", label: "Agent 推断", color: MAP_STYLE_TOKENS.state.agent, shape: "dot" },
|
||||
{ id: "selected", label: "当前选中", color: MAP_STYLE_TOKENS.state.selected, shape: "ring" }
|
||||
];
|
||||
|
||||
export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const;
|
||||
|
||||
export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
|
||||
{
|
||||
id: "mapbox-light",
|
||||
label: "浅色",
|
||||
description: "道路底图",
|
||||
swatchClassName: "bg-gradient-to-br from-slate-50 via-slate-100 to-blue-100"
|
||||
},
|
||||
{
|
||||
id: "mapbox-satellite",
|
||||
label: "影像",
|
||||
description: "卫星街道",
|
||||
swatchClassName: "bg-gradient-to-br from-emerald-950 via-emerald-700 to-yellow-200"
|
||||
},
|
||||
{
|
||||
id: "none",
|
||||
label: "无底图",
|
||||
description: "仅业务图层",
|
||||
swatchClassName: "bg-slate-100"
|
||||
}
|
||||
];
|
||||
|
||||
export function createLayerControlItems(layerVisibility: Record<string, boolean>): MapLayerControlItem[] {
|
||||
return WATER_NETWORK_SOURCE_IDS.map((sourceId) => ({
|
||||
id: sourceId,
|
||||
...SOURCE_CONTROL_LABELS[sourceId],
|
||||
visible: layerVisibility[sourceId]
|
||||
}));
|
||||
}
|
||||
|
||||
export function getWorkbenchLayerIds(
|
||||
map: Pick<MapLibreMap, "getStyle">,
|
||||
layerControlId: string
|
||||
) {
|
||||
if (isWaterNetworkSourceId(layerControlId)) {
|
||||
return map
|
||||
.getStyle()
|
||||
.layers.filter((layer) => "source" in layer && layer.source === layerControlId)
|
||||
.map((layer) => layer.id);
|
||||
}
|
||||
|
||||
return WORKBENCH_LAYER_GROUPS[layerControlId] ?? [];
|
||||
}
|
||||
|
||||
function isWaterNetworkSourceId(value: string): value is WaterNetworkSourceId {
|
||||
return (WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -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,101 @@
|
||||
import type { StyleSpecification } from "maplibre-gl";
|
||||
|
||||
type LayerSpecification = StyleSpecification["layers"][number];
|
||||
type LineLayerSpecification = Extract<LayerSpecification, { type: "line" }>;
|
||||
type CircleLayerSpecification = Extract<LayerSpecification, { type: "circle" }>;
|
||||
type LineWidth = NonNullable<LineLayerSpecification["paint"]>["line-width"];
|
||||
type CircleRadius = NonNullable<CircleLayerSpecification["paint"]>["circle-radius"];
|
||||
|
||||
export const MAP_MAX_ZOOM = 24;
|
||||
|
||||
const lineHitWidth = [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
11,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
22,
|
||||
22,
|
||||
24,
|
||||
24
|
||||
] as LineWidth;
|
||||
|
||||
const pointHitRadius = [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
12,
|
||||
9,
|
||||
16,
|
||||
10,
|
||||
20,
|
||||
12,
|
||||
24,
|
||||
14
|
||||
] as CircleRadius;
|
||||
|
||||
export const DRAINAGE_LAYER_VISUALS = {
|
||||
conduits: {
|
||||
minzoom: 9,
|
||||
casingWidth: [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
9, 1.7,
|
||||
13, 2.8,
|
||||
17, ["+", 4.5, ["/", ["coalesce", ["get", "diameter"], 100], 300]],
|
||||
22, ["+", 6, ["/", ["coalesce", ["get", "diameter"], 100], 260]],
|
||||
24, ["+", 7, ["/", ["coalesce", ["get", "diameter"], 100], 240]]
|
||||
] as LineWidth,
|
||||
width: [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
9, 1.1,
|
||||
13, 2,
|
||||
17, ["+", 2.5, ["/", ["coalesce", ["get", "diameter"], 100], 360]],
|
||||
22, ["+", 3.5, ["/", ["coalesce", ["get", "diameter"], 100], 300]],
|
||||
24, ["+", 4, ["/", ["coalesce", ["get", "diameter"], 100], 280]]
|
||||
] as LineWidth,
|
||||
hoverOutlineWidth: ["interpolate", ["linear"], ["zoom"], 9, 5.5, 13, 8, 17, 12.5, 22, 15, 24, 17] as LineWidth,
|
||||
hoverWidth: ["interpolate", ["linear"], ["zoom"], 9, 3, 13, 5.2, 17, 9, 22, 11.5, 24, 13] as LineWidth,
|
||||
selectedWidth: ["interpolate", ["linear"], ["zoom"], 9, 4, 13, 6.5, 17, 10.5, 22, 13.5, 24, 16] as LineWidth,
|
||||
selectedBorderWidth: ["interpolate", ["linear"], ["zoom"], 9, 1.2, 13, 1.5, 17, 2, 22, 2.5, 24, 3] as LineWidth,
|
||||
hitWidth: lineHitWidth
|
||||
},
|
||||
junctions: {
|
||||
minzoom: 12,
|
||||
haloRadius: ["interpolate", ["linear"], ["zoom"], 12, 1.2, 14, 1.8, 15, 2.8, 16, 4.5, 20, 7, 24, 9.5] as CircleRadius,
|
||||
haloOpacity: ["interpolate", ["linear"], ["zoom"], 12, 0, 14, 0, 15, 1] as const,
|
||||
radius: ["interpolate", ["linear"], ["zoom"], 12, 0.6, 14, 1, 15, 1.5, 16, 3, 20, 5.5, 24, 8] as CircleRadius,
|
||||
hoverRadius: ["interpolate", ["linear"], ["zoom"], 12, 5, 16, 7, 20, 10, 24, 12] as CircleRadius,
|
||||
selectedRadius: ["interpolate", ["linear"], ["zoom"], 12, 5.5, 16, 7.5, 20, 10.5, 24, 13] as CircleRadius,
|
||||
hitRadius: pointHitRadius
|
||||
},
|
||||
orifices: {
|
||||
minzoom: 11,
|
||||
casingWidth: ["interpolate", ["linear"], ["zoom"], 11, 3.2, 17, 7.5, 22, 9, 24, 10] as LineWidth,
|
||||
width: ["interpolate", ["linear"], ["zoom"], 11, 1.6, 17, 4.5, 22, 6, 24, 7] as LineWidth,
|
||||
hoverOutlineWidth: ["interpolate", ["linear"], ["zoom"], 11, 6, 17, 10.5, 22, 12.5, 24, 14] as LineWidth,
|
||||
hoverWidth: ["interpolate", ["linear"], ["zoom"], 11, 4, 17, 7, 22, 9, 24, 10.5] as LineWidth,
|
||||
selectedWidth: ["interpolate", ["linear"], ["zoom"], 11, 5.5, 17, 9, 22, 12, 24, 14] as LineWidth,
|
||||
selectedBorderWidth: ["interpolate", ["linear"], ["zoom"], 11, 1.25, 17, 1.75, 22, 2.25, 24, 2.5] as LineWidth,
|
||||
hitWidth: lineHitWidth
|
||||
},
|
||||
pumps: {
|
||||
minzoom: 11,
|
||||
casingWidth: ["interpolate", ["linear"], ["zoom"], 11, 4, 17, 9.5, 22, 11, 24, 12] as LineWidth,
|
||||
width: ["interpolate", ["linear"], ["zoom"], 11, 2.4, 17, 6.2, 22, 8, 24, 9] as LineWidth,
|
||||
hoverOutlineWidth: ["interpolate", ["linear"], ["zoom"], 11, 7, 17, 12, 22, 14, 24, 16] as LineWidth,
|
||||
hoverWidth: ["interpolate", ["linear"], ["zoom"], 11, 4.5, 17, 8.5, 22, 10.5, 24, 12] as LineWidth,
|
||||
selectedWidth: ["interpolate", ["linear"], ["zoom"], 11, 6, 17, 10, 22, 13, 24, 15] as LineWidth,
|
||||
selectedBorderWidth: ["interpolate", ["linear"], ["zoom"], 11, 1.25, 17, 1.75, 22, 2.25, 24, 2.5] as LineWidth,
|
||||
hitWidth: lineHitWidth
|
||||
},
|
||||
outfalls: {
|
||||
minzoom: 11,
|
||||
haloRadius: ["interpolate", ["linear"], ["zoom"], 11, 5, 16, 7, 20, 9, 24, 11] as CircleRadius,
|
||||
radius: ["interpolate", ["linear"], ["zoom"], 11, 3, 16, 5, 20, 7, 24, 9] as CircleRadius,
|
||||
hoverRadius: ["interpolate", ["linear"], ["zoom"], 11, 6, 16, 8, 20, 11, 24, 13] as CircleRadius,
|
||||
selectedRadius: ["interpolate", ["linear"], ["zoom"], 11, 7.5, 16, 9.5, 20, 12.5, 24, 15] as CircleRadius,
|
||||
hitRadius: pointHitRadius
|
||||
}
|
||||
} as const;
|
||||
@@ -0,0 +1,155 @@
|
||||
import type {
|
||||
FilterSpecification,
|
||||
GeoJSONSource,
|
||||
GeoJSONSourceSpecification,
|
||||
StyleSpecification
|
||||
} from "maplibre-gl";
|
||||
import type { FeatureCollection, Geometry, LineString, Point, Polygon } from "geojson";
|
||||
import { SOURCE_LAYERS } from "./sources";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
import { WORKBENCH_INTERACTION_BEFORE_ID } from "./layers";
|
||||
|
||||
export const MEASUREMENT_SOURCE_ID = "workbench-measurement";
|
||||
export const MEASUREMENT_SELECTED_PIPES_LAYER_ID = "workbench-measurement-selected-pipes";
|
||||
|
||||
export type MeasurementFeatureCollection = FeatureCollection<Geometry, { kind: "vertex" | "line" | "polygon" }>;
|
||||
|
||||
export const emptyMeasurementFeatureCollection: MeasurementFeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: []
|
||||
};
|
||||
|
||||
export const measurementSource = {
|
||||
type: "geojson",
|
||||
data: emptyMeasurementFeatureCollection
|
||||
} satisfies GeoJSONSourceSpecification;
|
||||
|
||||
export const measurementLayers: StyleSpecification["layers"] = [
|
||||
{
|
||||
id: MEASUREMENT_SELECTED_PIPES_LAYER_ID,
|
||||
type: "line",
|
||||
source: "conduits",
|
||||
"source-layer": SOURCE_LAYERS.conduits,
|
||||
minzoom: 9,
|
||||
filter: ["in", ["get", "id"], ["literal", []]],
|
||||
paint: {
|
||||
"line-color": MAP_STYLE_TOKENS.state.selected,
|
||||
"line-width": ["interpolate", ["linear"], ["zoom"], 9, 4, 13, 7, 17, 11],
|
||||
"line-opacity": 0.68
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-measurement-polygon-fill",
|
||||
type: "fill",
|
||||
source: MEASUREMENT_SOURCE_ID,
|
||||
filter: ["==", ["get", "kind"], "polygon"],
|
||||
paint: {
|
||||
"fill-color": MAP_STYLE_TOKENS.tool.geometry,
|
||||
"fill-opacity": 0.12
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-measurement-line",
|
||||
type: "line",
|
||||
source: MEASUREMENT_SOURCE_ID,
|
||||
filter: ["any", ["==", ["get", "kind"], "line"], ["==", ["get", "kind"], "polygon"]],
|
||||
paint: {
|
||||
"line-color": MAP_STYLE_TOKENS.tool.geometry,
|
||||
"line-width": 2.5,
|
||||
"line-dasharray": [2, 1.5],
|
||||
"line-opacity": 0.9
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-measurement-vertex",
|
||||
type: "circle",
|
||||
source: MEASUREMENT_SOURCE_ID,
|
||||
filter: ["==", ["get", "kind"], "vertex"],
|
||||
paint: {
|
||||
"circle-color": MAP_STYLE_TOKENS.state.selected,
|
||||
"circle-radius": 4,
|
||||
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||
"circle-stroke-width": 1.5
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export function ensureMeasurementLayers(map: {
|
||||
getSource: (id: string) => unknown;
|
||||
addSource: (id: string, source: GeoJSONSourceSpecification) => void;
|
||||
getLayer: (id: string) => unknown;
|
||||
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number], beforeId?: string) => void;
|
||||
}) {
|
||||
if (!map.getSource(MEASUREMENT_SOURCE_ID)) {
|
||||
map.addSource(MEASUREMENT_SOURCE_ID, measurementSource);
|
||||
}
|
||||
|
||||
measurementLayers.forEach((layer) => {
|
||||
if (!map.getLayer(layer.id)) {
|
||||
map.addLayer(layer, WORKBENCH_INTERACTION_BEFORE_ID);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function setMeasurementSourceData(
|
||||
map: { getSource: (id: string) => unknown },
|
||||
data: MeasurementFeatureCollection
|
||||
) {
|
||||
const source = map.getSource(MEASUREMENT_SOURCE_ID) as GeoJSONSource | undefined;
|
||||
source?.setData(data);
|
||||
}
|
||||
|
||||
export function setSelectedMeasurementPipeIds(
|
||||
map: { getLayer: (id: string) => unknown; setFilter: (id: string, filter?: FilterSpecification) => void },
|
||||
pipeIds: string[]
|
||||
) {
|
||||
if (!map.getLayer(MEASUREMENT_SELECTED_PIPES_LAYER_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.setFilter(MEASUREMENT_SELECTED_PIPES_LAYER_ID, ["in", ["get", "id"], ["literal", pipeIds]]);
|
||||
}
|
||||
|
||||
export function createMeasurementFeatureCollection(
|
||||
coordinates: [number, number][],
|
||||
mode: "distance" | "area" | "segment"
|
||||
): MeasurementFeatureCollection {
|
||||
const features: MeasurementFeatureCollection["features"] = coordinates.map((coordinate, index) => ({
|
||||
type: "Feature",
|
||||
id: `measurement-vertex-${index}`,
|
||||
properties: { kind: "vertex" },
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: coordinate
|
||||
} satisfies Point
|
||||
}));
|
||||
|
||||
if (coordinates.length >= 2) {
|
||||
features.push({
|
||||
type: "Feature",
|
||||
id: "measurement-line",
|
||||
properties: { kind: "line" },
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates
|
||||
} satisfies LineString
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "area" && coordinates.length >= 3) {
|
||||
features.push({
|
||||
type: "Feature",
|
||||
id: "measurement-polygon",
|
||||
properties: { kind: "polygon" },
|
||||
geometry: {
|
||||
type: "Polygon",
|
||||
coordinates: [[...coordinates, coordinates[0]]]
|
||||
} satisfies Polygon
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features
|
||||
};
|
||||
}
|
||||
@@ -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: "#F43F5E",
|
||||
medium: "#F59E0B",
|
||||
low: "#10B981",
|
||||
unrated: "#8B5CF6"
|
||||
});
|
||||
expect(casing).toMatchObject({
|
||||
type: "circle",
|
||||
paint: { "circle-radius": 20, "circle-stroke-color": "#FFFFFF", "circle-stroke-width": 8 }
|
||||
});
|
||||
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-radius": 20, "circle-stroke-width": 5 } });
|
||||
expect(indicator).toMatchObject({
|
||||
type: "circle",
|
||||
paint: {
|
||||
"circle-radius": 6,
|
||||
"circle-translate": [14, -14],
|
||||
"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": 18,
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-allow-overlap": false
|
||||
},
|
||||
paint: { "text-halo-color": "#FFFFFF", "text-halo-width": 4, "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" } }
|
||||
]
|
||||
};
|
||||
@@ -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: "#F43F5E",
|
||||
medium: "#F59E0B",
|
||||
low: "#10B981",
|
||||
unrated: "#8B5CF6"
|
||||
};
|
||||
|
||||
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": 20,
|
||||
"circle-color": "rgba(255,255,255,0)",
|
||||
"circle-stroke-color": "#FFFFFF",
|
||||
"circle-stroke-width": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
id: SCADA_ANALYSIS_LAYER_IDS.level,
|
||||
type: "circle",
|
||||
source: SCADA_ANALYSIS_SOURCE_ID,
|
||||
paint: {
|
||||
"circle-radius": 20,
|
||||
"circle-color": "rgba(255,255,255,0)",
|
||||
"circle-stroke-color": levelColorExpression(SCADA_ANALYSIS_LEVEL_COLORS),
|
||||
"circle-stroke-width": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
id: SCADA_ANALYSIS_LAYER_IDS.indicator,
|
||||
type: "circle",
|
||||
source: SCADA_ANALYSIS_SOURCE_ID,
|
||||
paint: {
|
||||
"circle-radius": 6,
|
||||
"circle-translate": [14, -14],
|
||||
"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": 18,
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-letter-spacing": 0.015,
|
||||
"text-offset": [0, -2.2],
|
||||
"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": 4,
|
||||
"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 };
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
SCADA_HIT_LAYER_ID,
|
||||
SCADA_ICON_EXPRESSION,
|
||||
SCADA_IMAGE_IDS,
|
||||
SCADA_MAP_ICON_PATHS,
|
||||
registerScadaImages,
|
||||
scadaFallbackLayers,
|
||||
scadaLayers
|
||||
} from "./scada";
|
||||
import { waterNetworkLayers } from "./layers";
|
||||
|
||||
describe("SCADA map presentation", () => {
|
||||
it("maps the authoritative device types and falls back to unknown", () => {
|
||||
expect(SCADA_ICON_EXPRESSION).toEqual([
|
||||
"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
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the hit target last in normal and fallback presentations", () => {
|
||||
expect(scadaLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID);
|
||||
expect(scadaFallbackLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID);
|
||||
});
|
||||
|
||||
it("keeps the icon and selected ring consistent with junction selection", () => {
|
||||
const symbolLayer = scadaLayers.find((layer) => layer.id === "scada-symbol");
|
||||
const outerLayer = scadaLayers.find((layer) => layer.id === "scada-selected-outer");
|
||||
const selectedLayer = scadaLayers.find((layer) => layer.id === "scada-selected");
|
||||
const junctionOuterLayer = waterNetworkLayers.find((layer) => layer.id === "junctions-selected-outer");
|
||||
const junctionSelectedLayer = waterNetworkLayers.find((layer) => layer.id === "junctions-selected");
|
||||
const junctionOuterPaint = junctionOuterLayer?.paint as Record<string, unknown> | undefined;
|
||||
const junctionSelectedPaint = junctionSelectedLayer?.paint as Record<string, unknown> | undefined;
|
||||
|
||||
expect(symbolLayer).toMatchObject({ type: "symbol", layout: { "icon-size": 0.75 } });
|
||||
expect(outerLayer).toMatchObject({
|
||||
type: "circle",
|
||||
paint: {
|
||||
"circle-radius": 12.5,
|
||||
"circle-stroke-width": junctionOuterPaint?.["circle-stroke-width"]
|
||||
}
|
||||
});
|
||||
expect(selectedLayer).toMatchObject({
|
||||
type: "circle",
|
||||
paint: {
|
||||
"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(5);
|
||||
Object.values(SCADA_MAP_ICON_PATHS).forEach((path) => expect(path).toMatch(/\.png$/));
|
||||
});
|
||||
|
||||
it("registers all map images at two-times pixel density", async () => {
|
||||
const image = { width: 64, height: 64 };
|
||||
const map = createImageMap(() => Promise.resolve({ data: image }));
|
||||
|
||||
await expect(registerScadaImages(map)).resolves.toEqual({ status: "ready", failedImageIds: [] });
|
||||
expect(map.loadImage).toHaveBeenCalledTimes(5);
|
||||
expect(map.addImage).toHaveBeenCalledTimes(5);
|
||||
expect(map.addImage).toHaveBeenCalledWith(expect.any(String), image, { pixelRatio: 2 });
|
||||
});
|
||||
|
||||
it("falls back to the unknown image when one category fails", async () => {
|
||||
const image = { width: 64, height: 64 };
|
||||
const map = createImageMap((path) => path.includes("radar-level")
|
||||
? Promise.reject(new Error("decode failed"))
|
||||
: Promise.resolve({ data: image }));
|
||||
|
||||
await expect(registerScadaImages(map)).resolves.toEqual({
|
||||
status: "degraded",
|
||||
failedImageIds: [SCADA_IMAGE_IDS.radarLevel]
|
||||
});
|
||||
expect(map.addImage).toHaveBeenCalledWith(SCADA_IMAGE_IDS.radarLevel, image, { pixelRatio: 2 });
|
||||
});
|
||||
|
||||
it("returns unavailable instead of rejecting when the fallback image fails", async () => {
|
||||
const map = createImageMap(() => Promise.reject(new Error("unsupported image")));
|
||||
|
||||
await expect(registerScadaImages(map)).resolves.toEqual({
|
||||
status: "unavailable",
|
||||
failedImageIds: Object.values(SCADA_IMAGE_IDS)
|
||||
});
|
||||
expect(scadaFallbackLayers[0]?.id).toBe("scada-fallback");
|
||||
});
|
||||
});
|
||||
|
||||
function createImageMap(loadImage: (path: string) => Promise<{ data: { width: number; height: number } }>) {
|
||||
const loadedImageIds = new Set<string>();
|
||||
return {
|
||||
loadImage: vi.fn(loadImage),
|
||||
hasImage: vi.fn((imageId: string) => loadedImageIds.has(imageId)),
|
||||
addImage: vi.fn((imageId: string) => loadedImageIds.add(imageId))
|
||||
} as unknown as Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage"> & {
|
||||
loadImage: ReturnType<typeof vi.fn>;
|
||||
addImage: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
|
||||
export const SCADA_SOURCE_ID = "scada";
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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,
|
||||
"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",
|
||||
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;
|
||||
const SCADA_SELECTED_RADIUS = 12.5;
|
||||
const SCADA_SELECTED_OUTER_STROKE_WIDTH = 6;
|
||||
const SCADA_SELECTED_STROKE_WIDTH = 3;
|
||||
|
||||
type Layer = NonNullable<StyleSpecification["layers"]>[number];
|
||||
|
||||
export type ScadaImageRegistrationResult = {
|
||||
status: "ready" | "degraded" | "unavailable";
|
||||
failedImageIds: string[];
|
||||
};
|
||||
|
||||
const scadaSymbolLayer: Layer = {
|
||||
id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12,
|
||||
layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": SCADA_ICON_SIZE, "icon-allow-overlap": true, "icon-ignore-placement": true }
|
||||
};
|
||||
|
||||
const scadaFallbackLayer: Layer = {
|
||||
id: "scada-fallback", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12,
|
||||
paint: { "circle-radius": ["interpolate", ["linear"], ["zoom"], 12, 5, 20, 8], "circle-color": "#0E7490", "circle-stroke-color": "white", "circle-stroke-width": 2 }
|
||||
};
|
||||
|
||||
const scadaInteractionLayers: Layer[] = [
|
||||
{ id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 14, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "#0E7490", "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } },
|
||||
{ id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": SCADA_SELECTED_RADIUS, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": SCADA_SELECTED_OUTER_STROKE_WIDTH, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
|
||||
{ id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": SCADA_SELECTED_RADIUS, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": SCADA_SELECTED_STROKE_WIDTH, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
|
||||
{ id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 16, "circle-color": "rgba(0,0,0,0)", "circle-opacity": 0 } }
|
||||
];
|
||||
|
||||
export const scadaLayers: Layer[] = [
|
||||
scadaSymbolLayer,
|
||||
...scadaInteractionLayers
|
||||
];
|
||||
|
||||
export const scadaFallbackLayers: Layer[] = [
|
||||
scadaFallbackLayer,
|
||||
...scadaInteractionLayers
|
||||
];
|
||||
|
||||
export async function registerScadaImages(
|
||||
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">
|
||||
): Promise<ScadaImageRegistrationResult> {
|
||||
let unknown: Awaited<ReturnType<typeof loadImage>>;
|
||||
|
||||
try {
|
||||
unknown = await loadImage(map, SCADA_MAP_ICON_PATHS.unknown);
|
||||
addImageIfMissing(map, SCADA_IMAGE_IDS.unknown, unknown);
|
||||
} catch {
|
||||
return { status: "unavailable", failedImageIds: Object.values(SCADA_IMAGE_IDS) };
|
||||
}
|
||||
|
||||
const failedImageIds: string[] = [];
|
||||
for (const [key, path] of Object.entries(SCADA_MAP_ICON_PATHS)) {
|
||||
if (key === "unknown") continue;
|
||||
const imageId = SCADA_IMAGE_IDS[key as Exclude<keyof typeof SCADA_IMAGE_IDS, "unknown">];
|
||||
if (map.hasImage(imageId)) continue;
|
||||
|
||||
try {
|
||||
const image = await loadImage(map, path);
|
||||
addImageIfMissing(map, imageId, image);
|
||||
} catch {
|
||||
failedImageIds.push(imageId);
|
||||
try {
|
||||
addImageIfMissing(map, imageId, unknown);
|
||||
} catch {
|
||||
return { status: "unavailable", failedImageIds };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: failedImageIds.length > 0 ? "degraded" : "ready",
|
||||
failedImageIds
|
||||
};
|
||||
}
|
||||
|
||||
async function loadImage(map: Pick<MapLibreMap, "loadImage">, path: string) {
|
||||
const response = await map.loadImage(path);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
function addImageIfMissing(
|
||||
map: Pick<MapLibreMap, "addImage" | "hasImage">,
|
||||
imageId: string,
|
||||
image: Awaited<ReturnType<typeof loadImage>>
|
||||
) {
|
||||
if (!map.hasImage(imageId)) map.addImage(imageId, image, { pixelRatio: 2 });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { simulationLayerIds } from "./annotation-layers";
|
||||
|
||||
export function setSimulationLayersVisibility(map: MapLibreMap, visible: boolean) {
|
||||
const visibility = visible ? "visible" : "none";
|
||||
|
||||
simulationLayerIds.forEach((layerId) => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, "visibility", visibility);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { RasterLayerSpecification } from "maplibre-gl";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createBaseStyle, createWaterNetworkSources, SOURCE_LAYERS } from "./sources";
|
||||
|
||||
describe("createWaterNetworkSources", () => {
|
||||
it("loads SCADA tiles from the configured GeoServer workspace", () => {
|
||||
const scada = createWaterNetworkSources().scada;
|
||||
|
||||
expect(SOURCE_LAYERS.scada).toBe("geo_scadas_mat");
|
||||
expect(scada.tiles).toEqual([
|
||||
expect.stringContaining("/wenzhou:geo_scadas_mat/point/WebMercatorQuad/")
|
||||
]);
|
||||
expect(scada.bounds).toEqual([
|
||||
120.63483136963328,
|
||||
27.957404243937606,
|
||||
120.76346113516635,
|
||||
28.02399422971424
|
||||
]);
|
||||
expect(scada.promoteId).toBe("sensor_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBaseStyle", () => {
|
||||
it("calibrates light and satellite rasters for solid workbench surfaces", () => {
|
||||
const style = createBaseStyle("test-token");
|
||||
const light = style.layers.find((layer) => layer.id === "mapbox-light") as RasterLayerSpecification;
|
||||
const satellite = style.layers.find((layer) => layer.id === "mapbox-satellite") as RasterLayerSpecification;
|
||||
|
||||
expect(light.paint).toMatchObject({
|
||||
"raster-opacity": 0.96,
|
||||
"raster-saturation": -0.1,
|
||||
"raster-contrast": 0.06
|
||||
});
|
||||
expect(satellite.paint).toMatchObject({
|
||||
"raster-opacity": 1,
|
||||
"raster-saturation": -0.24,
|
||||
"raster-contrast": -0.06
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { VectorSourceSpecification, StyleSpecification } from "maplibre-gl";
|
||||
import { GEOSERVER_WORKSPACE, MAP_URL } from "../../../lib/config";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
|
||||
export const WATER_NETWORK_GLOBAL_VIEW = {
|
||||
bbox3857: [
|
||||
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: "geo_scadas_mat"
|
||||
} as const;
|
||||
|
||||
export const WATER_NETWORK_SOURCE_IDS = [
|
||||
"conduits",
|
||||
"junctions",
|
||||
"orifices",
|
||||
"outfalls",
|
||||
"pumps",
|
||||
"scada"
|
||||
] as const;
|
||||
|
||||
export type WaterNetworkSourceId = (typeof WATER_NETWORK_SOURCE_IDS)[number];
|
||||
|
||||
const GEOSERVER_WMTS_ROOT = `${MAP_URL}/gwc/service/wmts/rest`;
|
||||
|
||||
export function createWaterNetworkSources() {
|
||||
return {
|
||||
conduits: createGeoServerVectorSource(
|
||||
SOURCE_LAYERS.conduits,
|
||||
"line",
|
||||
WATER_NETWORK_BOUNDS
|
||||
),
|
||||
junctions: createGeoServerVectorSource(
|
||||
SOURCE_LAYERS.junctions,
|
||||
"point",
|
||||
WATER_NETWORK_BOUNDS
|
||||
),
|
||||
orifices: createGeoServerVectorSource(
|
||||
SOURCE_LAYERS.orifices,
|
||||
"line",
|
||||
WATER_NETWORK_BOUNDS
|
||||
),
|
||||
outfalls: createGeoServerVectorSource(
|
||||
SOURCE_LAYERS.outfalls,
|
||||
"point",
|
||||
WATER_NETWORK_BOUNDS
|
||||
),
|
||||
pumps: createGeoServerVectorSource(
|
||||
SOURCE_LAYERS.pumps,
|
||||
"line",
|
||||
WATER_NETWORK_BOUNDS
|
||||
),
|
||||
scada: createGeoServerVectorSource(
|
||||
SOURCE_LAYERS.scada,
|
||||
"point",
|
||||
WATER_NETWORK_BOUNDS,
|
||||
"sensor_id"
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function createGeoServerVectorSource(
|
||||
sourceLayer: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS],
|
||||
style: "line" | "point",
|
||||
bounds: [number, number, number, number],
|
||||
promoteId = "id"
|
||||
): VectorSourceSpecification {
|
||||
return {
|
||||
type: "vector",
|
||||
promoteId,
|
||||
tiles: [
|
||||
`${GEOSERVER_WMTS_ROOT}/${GEOSERVER_WORKSPACE}:${sourceLayer}/${style}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
|
||||
],
|
||||
bounds,
|
||||
minzoom: 0,
|
||||
maxzoom: 24
|
||||
};
|
||||
}
|
||||
|
||||
export function createBaseStyle(mapboxToken?: string): StyleSpecification {
|
||||
const sources: StyleSpecification["sources"] = {};
|
||||
const layers: StyleSpecification["layers"] = [
|
||||
{
|
||||
id: "background",
|
||||
type: "background",
|
||||
paint: {
|
||||
"background-color": MAP_STYLE_TOKENS.canvas.primary
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (mapboxToken) {
|
||||
sources["mapbox-light"] = {
|
||||
type: "raster",
|
||||
tiles: [
|
||||
`https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/512/{z}/{x}/{y}@2x?access_token=${mapboxToken}`
|
||||
],
|
||||
tileSize: 512,
|
||||
attribution:
|
||||
'© <a href="https://www.mapbox.com/about/maps/">Mapbox</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
};
|
||||
sources["mapbox-satellite"] = {
|
||||
type: "raster",
|
||||
tiles: [
|
||||
`https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/512/{z}/{x}/{y}@2x?access_token=${mapboxToken}`
|
||||
],
|
||||
tileSize: 512,
|
||||
attribution:
|
||||
'© <a href="https://www.mapbox.com/about/maps/">Mapbox</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
};
|
||||
|
||||
layers.push({
|
||||
id: "mapbox-light",
|
||||
type: "raster",
|
||||
source: "mapbox-light",
|
||||
paint: {
|
||||
"raster-opacity": 0.96,
|
||||
"raster-saturation": -0.1,
|
||||
"raster-contrast": 0.06
|
||||
}
|
||||
});
|
||||
layers.push({
|
||||
id: "mapbox-satellite",
|
||||
type: "raster",
|
||||
source: "mapbox-satellite",
|
||||
layout: {
|
||||
visibility: "none"
|
||||
},
|
||||
paint: {
|
||||
"raster-opacity": 1,
|
||||
"raster-saturation": -0.24,
|
||||
"raster-contrast": -0.06
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
version: 8,
|
||||
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||
sources,
|
||||
layers
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { BarChart3, Layers3, MapPinned, MoreHorizontal, Ruler } from "lucide-react";
|
||||
import type { MapToolbarItem } from "@/features/map/core/components/toolbar";
|
||||
|
||||
export const waterNetworkToolbarItems: MapToolbarItem[] = [
|
||||
{
|
||||
id: "layers",
|
||||
label: "图层",
|
||||
description: "管理地图图层",
|
||||
icon: Layers3
|
||||
},
|
||||
{
|
||||
id: "measure",
|
||||
label: "测量",
|
||||
description: "距离与范围测量",
|
||||
icon: Ruler
|
||||
},
|
||||
{
|
||||
id: "analysis",
|
||||
label: "分析",
|
||||
description: "查看地图分析图例",
|
||||
icon: BarChart3
|
||||
},
|
||||
{
|
||||
id: "annotation",
|
||||
label: "标注",
|
||||
description: "管理地图标注",
|
||||
icon: MapPinned
|
||||
},
|
||||
{
|
||||
id: "more",
|
||||
label: "更多",
|
||||
description: "更多地图操作",
|
||||
icon: MoreHorizontal
|
||||
}
|
||||
];
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,354 @@
|
||||
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 {
|
||||
MAX_CONDUIT_FEATURES,
|
||||
MAX_MAP_FEATURE_IDS,
|
||||
createMapFeatureWfsUrl,
|
||||
normalizeMapFeatureCollection
|
||||
} from "./map-feature-query";
|
||||
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(createMapFeatureWfsUrl({ sourceId, featureIds }), { signal });
|
||||
const body = await response.json();
|
||||
const collection = normalizeMapFeatureCollection(
|
||||
body,
|
||||
featureIds ? MAX_MAP_FEATURE_IDS : MAX_CONDUIT_FEATURES
|
||||
);
|
||||
if (!response.ok || !collection) throw new Error("WFS_UNAVAILABLE");
|
||||
return collection;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import type { WaterNetworkSourceId } from "./map/sources";
|
||||
|
||||
export type DetailFeature = {
|
||||
id: string;
|
||||
layer: WaterNetworkSourceId;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type WorkbenchMode = "simulation" | "feature-detail";
|
||||
|
||||
export type WorkbenchScenario = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: "draft" | "active" | "review";
|
||||
};
|
||||
|
||||
export type WorkbenchAlert = {
|
||||
id: string;
|
||||
description: string;
|
||||
time: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type WorkbenchUser = {
|
||||
name: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
export type ScheduledConditionStatus = "pending" | "running" | "completed" | "warning" | "error";
|
||||
|
||||
export type ScheduledConditionRiskLevel = "normal" | "attention" | "critical";
|
||||
|
||||
export type ScheduledConditionTaskId = "scada-diagnosis" | "smart-dispatch" | "pump-energy" | "network-simulation";
|
||||
|
||||
export type ScheduledConditionSolutionOption = {
|
||||
id: string;
|
||||
title: string;
|
||||
scenario: string;
|
||||
action: string;
|
||||
tradeoff: string;
|
||||
};
|
||||
|
||||
export type ScheduledConditionAnalysisInsight = {
|
||||
summary: string;
|
||||
notes: string[];
|
||||
escalationCriteria?: string[];
|
||||
solutionOptions?: ScheduledConditionSolutionOption[];
|
||||
};
|
||||
|
||||
export type ScheduledConditionKpi = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
baseline?: string;
|
||||
threshold?: string;
|
||||
status: ScheduledConditionRiskLevel;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ScheduledConditionReport = {
|
||||
title: string;
|
||||
conclusion: string;
|
||||
sections: {
|
||||
title: string;
|
||||
items: string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
type ScheduledConditionBase = {
|
||||
id: string;
|
||||
scheduledAt: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
status: ScheduledConditionStatus;
|
||||
riskLevel: ScheduledConditionRiskLevel;
|
||||
updatedAt: number;
|
||||
detail?: string;
|
||||
recommendation?: string;
|
||||
durationMinutes?: number;
|
||||
};
|
||||
|
||||
export type ScheduledConditionRecord = ScheduledConditionBase & {
|
||||
kind: "condition";
|
||||
taskId: ScheduledConditionTaskId;
|
||||
sessionId: string;
|
||||
evidence?: string[];
|
||||
modelName?: string;
|
||||
analysisInsight?: ScheduledConditionAnalysisInsight;
|
||||
kpis?: ScheduledConditionKpi[];
|
||||
report?: ScheduledConditionReport;
|
||||
};
|
||||
|
||||
export type ScheduledWorkOrderItem = ScheduledConditionBase & {
|
||||
kind: "work_order";
|
||||
code: string;
|
||||
source: string;
|
||||
location: string;
|
||||
dispatcher: string;
|
||||
assignee: string;
|
||||
priority: "normal" | "urgent";
|
||||
replyWindowMinutes: number;
|
||||
stages: ScheduledWorkOrderStage[];
|
||||
replyRequirements: string[];
|
||||
evidence?: never;
|
||||
modelName?: never;
|
||||
sessionId?: never;
|
||||
};
|
||||
|
||||
export type ScheduledWorkOrderStageId = "dispatch" | "execution" | "reply";
|
||||
|
||||
export type ScheduledWorkOrderStageStatus = "done" | "current" | "pending";
|
||||
|
||||
export type ScheduledWorkOrderStage = {
|
||||
id: ScheduledWorkOrderStageId;
|
||||
title: string;
|
||||
status: ScheduledWorkOrderStageStatus;
|
||||
owner: string;
|
||||
timeLabel: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ScheduledConditionItem = ScheduledConditionRecord | ScheduledWorkOrderItem;
|
||||
|
||||
export type MapAnnotation = {
|
||||
id: string;
|
||||
label: string;
|
||||
coordinate: [number, number];
|
||||
kind: "burst" | "pipe-label" | "district" | "tooltip";
|
||||
};
|
||||
|
||||
export type WorkbenchMap = MapLibreMap;
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatFeaturePropertyValue,
|
||||
getFeatureInsightMetrics,
|
||||
getFeaturePanelModel,
|
||||
getFeaturePanelProperties,
|
||||
getLocalizedFeatureProperties,
|
||||
getScadaMetricProperties
|
||||
} from "./feature-properties";
|
||||
|
||||
describe("feature panel properties", () => {
|
||||
it.each([
|
||||
["pumps", ["id", "status", "startup_depth", "shutoff_depth"]],
|
||||
["conduits", ["id", "length", "diameter"]],
|
||||
["junctions", ["id", "invert_elevation", "max_depth"]],
|
||||
["orifices", ["id", "discharge_coeff", "gated", "shape", "geom1", "geom2"]],
|
||||
["outfalls", ["id", "invert_elevation", "outfall_type"]]
|
||||
] as const)("shows the configured %s fields", (layer, expectedKeys) => {
|
||||
const entries = getFeaturePanelProperties(layer, {}, "feature-id");
|
||||
|
||||
expect(entries.map((entry) => entry.key)).toEqual(expectedKeys);
|
||||
expect(entries[0].value).toBe("feature-id");
|
||||
});
|
||||
|
||||
it("formats conduit diameter from meters to millimeters", () => {
|
||||
const entries = getFeaturePanelProperties("conduits", {
|
||||
id: "C-1",
|
||||
length: 42.5,
|
||||
diameter: 0.6
|
||||
});
|
||||
|
||||
expect(entries.map(({ label, value }) => [label, value])).toEqual([
|
||||
["编号", "C-1"],
|
||||
["长度", "42.50 m"],
|
||||
["管径", "600 mm"]
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds insight metrics from the layer configuration", () => {
|
||||
const metrics = getFeatureInsightMetrics("conduits", {
|
||||
diameter: 0.6,
|
||||
length: 42.5,
|
||||
roughness: 0.013,
|
||||
status: "OPEN"
|
||||
});
|
||||
|
||||
expect(metrics).toEqual([
|
||||
{ label: "管径", value: "600 mm" },
|
||||
{ label: "长度", value: "42.50 m" },
|
||||
{ label: "粗糙系数", value: "0.01" },
|
||||
{ label: "状态", value: "开启" }
|
||||
]);
|
||||
});
|
||||
|
||||
it("localizes free outfall type", () => {
|
||||
const entries = getFeaturePanelProperties("outfalls", {
|
||||
id: "OF-1",
|
||||
invert_elevation: -5.67,
|
||||
outfall_type: "FREE"
|
||||
});
|
||||
|
||||
expect(entries.map((entry) => entry.value)).toEqual(["OF-1", "-5.67 m", "自由出流"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
layer: "pumps",
|
||||
properties: { id: "P-1", status: "ON", startup_depth: 1.2, shutoff_depth: 0.4 },
|
||||
badge: { label: "运行", tone: "active" },
|
||||
attributeKeys: ["startup_depth", "shutoff_depth"]
|
||||
},
|
||||
{
|
||||
layer: "orifices",
|
||||
properties: { id: "O-1", gated: "YES", discharge_coeff: 0.65, shape: "RECT_CLOSED" },
|
||||
badge: { label: "有闸门", tone: "info" },
|
||||
attributeKeys: ["discharge_coeff", "shape", "geom1", "geom2"]
|
||||
}
|
||||
] as const)("promotes the $layer badge without repeating it", ({ layer, properties, badge, attributeKeys }) => {
|
||||
const model = getFeaturePanelModel(layer, properties);
|
||||
|
||||
expect(model.id).toBe(properties.id);
|
||||
expect(model.badge).toEqual(badge);
|
||||
expect(model.attributes.map((entry) => entry.key)).toEqual(attributeKeys);
|
||||
expect(model.attributes.every((entry) => entry.value !== properties.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the map feature id when the properties omit it", () => {
|
||||
const model = getFeaturePanelModel("junctions", { max_depth: 3.4 }, "map-feature-id");
|
||||
|
||||
expect(model.id).toBe("map-feature-id");
|
||||
expect(model.badge).toBeUndefined();
|
||||
expect(model.attributes.map((entry) => entry.key)).toEqual(["invert_elevation", "max_depth"]);
|
||||
});
|
||||
|
||||
it("shows SCADA facility properties without monitoring readings", () => {
|
||||
const model = getFeaturePanelModel("scada", {
|
||||
sensor_id: "SCADA-001",
|
||||
sensor_name: "DY22东市南街环城南路西段交叉口",
|
||||
kind: "integrated_monitoring",
|
||||
swmm_node: "J-1024",
|
||||
topology_order: 17,
|
||||
upstream_node_count: 8,
|
||||
distance_to_wwtp_m: 1260,
|
||||
COD_mg_L: 23.45,
|
||||
氨氮_mg_L: 1.26,
|
||||
电导率_uS_cm: 746,
|
||||
液位_m: 2.18,
|
||||
流量_m3_s: 0.37
|
||||
});
|
||||
|
||||
expect(model.id).toBe("SCADA-001");
|
||||
expect(model.badge).toBeUndefined();
|
||||
expect(model.attributes.map((entry) => entry.key)).toEqual([
|
||||
"kind",
|
||||
"swmm_node",
|
||||
"topology_order",
|
||||
"upstream_node_count",
|
||||
"distance_to_wwtp_m"
|
||||
]);
|
||||
expect(model.attributes.map((entry) => entry.value)).toEqual([
|
||||
"综合监测点",
|
||||
"J-1024",
|
||||
"17",
|
||||
"8",
|
||||
"1260 m"
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats the five integrated SCADA metrics with localized labels and units", () => {
|
||||
const metrics = getScadaMetricProperties({
|
||||
cod_mg_l: 23.45,
|
||||
氨氮_mg_L: 1.26,
|
||||
电导率_uS_cm: 746,
|
||||
液位_m: 2.18,
|
||||
流量_m3_s: 0.37
|
||||
});
|
||||
|
||||
expect(metrics.map(({ label, value }) => [label, value])).toEqual([
|
||||
["COD", "23.45 mg/L"],
|
||||
["氨氮", "1.26 mg/L"],
|
||||
["电导率", "746 μS/cm"],
|
||||
["液位", "2.18 m"],
|
||||
["流量", "0.37 m³/s"]
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits the internal SCADA clustering size from user-facing properties", () => {
|
||||
const entries = getLocalizedFeatureProperties({
|
||||
name: "THC雷达液位计(MQTT)",
|
||||
cluster_size: 29,
|
||||
external_id: "DEVICE-002"
|
||||
});
|
||||
|
||||
expect(entries.map((entry) => entry.key)).toEqual(["name", "external_id"]);
|
||||
});
|
||||
|
||||
it("labels uniformly sampled SCADA locations", () => {
|
||||
expect(formatFeaturePropertyValue("location_source", "uniform_farthest_point_demo"))
|
||||
.toBe("空间均匀采样模拟位置");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
import type { WaterNetworkSourceId } from "../map/sources";
|
||||
import { formatValue } from "./format-value";
|
||||
|
||||
export type LocalizedFeatureProperty = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type FeaturePanelBadgeTone = "active" | "inactive" | "warning" | "critical" | "info";
|
||||
|
||||
export type FeaturePanelBadge = {
|
||||
label: string;
|
||||
tone: FeaturePanelBadgeTone;
|
||||
};
|
||||
|
||||
export type FeaturePanelModel = {
|
||||
id: string;
|
||||
badge?: FeaturePanelBadge;
|
||||
attributes: LocalizedFeatureProperty[];
|
||||
};
|
||||
|
||||
export type FeatureInsightMetric = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const PROPERTY_LABELS: Record<string, string> = {
|
||||
fid: "GeoServer 要素 ID",
|
||||
id: "编号",
|
||||
scada_id: "编号 UUID",
|
||||
sensor_id: "传感器 ID",
|
||||
sensor_name: "传感器名称",
|
||||
swmm_node: "SWMM 节点",
|
||||
topology_order: "拓扑序",
|
||||
distance_to_wwtp_m: "距污水厂距离",
|
||||
upstream_node_count: "上游节点数",
|
||||
upstream_sensors: "上游传感器",
|
||||
downstream_path: "下游路径",
|
||||
x: "X 坐标",
|
||||
y: "Y 坐标",
|
||||
cod_mg_l: "COD",
|
||||
氨氮_mg_l: "氨氮",
|
||||
电导率_us_cm: "电导率",
|
||||
液位_m: "液位",
|
||||
流量_m3_s: "流量",
|
||||
gid: "要素编号",
|
||||
name: "名称",
|
||||
code: "编码",
|
||||
type: "类型",
|
||||
node1: "起点节点",
|
||||
node2: "终点节点",
|
||||
from_node: "起点节点",
|
||||
to_node: "终点节点",
|
||||
diameter: "管径",
|
||||
dn: "管径",
|
||||
length: "长度",
|
||||
roughness: "粗糙系数",
|
||||
shape: "断面形状",
|
||||
max_depth: "最大深度",
|
||||
invert_elevation: "井底高程",
|
||||
orifice_type: "孔口类型",
|
||||
discharge_coeff: "流量系数",
|
||||
gated: "闸门",
|
||||
offset_height: "偏移高度",
|
||||
geom1: "几何参数 1",
|
||||
geom2: "几何参数 2",
|
||||
pump_curve: "泵曲线",
|
||||
startup_depth: "启动水深",
|
||||
shutoff_depth: "停泵水深",
|
||||
outfall_type: "排放类型",
|
||||
stage_data: "阶段数据",
|
||||
status: "状态",
|
||||
material: "材质",
|
||||
elevation: "高程",
|
||||
demand: "需水量",
|
||||
pressure: "压力",
|
||||
flow: "流量",
|
||||
velocity: "流速",
|
||||
zone: "分区",
|
||||
district: "片区",
|
||||
source: "来源",
|
||||
updated_at: "更新时间",
|
||||
point_id: "测点 ID",
|
||||
point_external_id: "测点编号",
|
||||
point_name: "测点名称",
|
||||
device_id: "设备 ID",
|
||||
device_external_id: "设备编号",
|
||||
device_type_id: "设备类型 ID",
|
||||
device_type_name: "设备类型",
|
||||
device_icon_code: "设备图标编码",
|
||||
active: "运行状态",
|
||||
external_id: "设备编号",
|
||||
junction_id: "关联检查井",
|
||||
kind: "设备类型",
|
||||
location_source: "位置来源",
|
||||
site_external_id: "站点编号",
|
||||
synced_at: "同步时间"
|
||||
};
|
||||
|
||||
const PROPERTY_ORDER: Record<string, number> = {
|
||||
fid: 9,
|
||||
id: 10,
|
||||
scada_id: 10,
|
||||
sensor_id: 10,
|
||||
sensor_name: 12,
|
||||
swmm_node: 13,
|
||||
topology_order: 14,
|
||||
distance_to_wwtp_m: 15,
|
||||
upstream_node_count: 16,
|
||||
upstream_sensors: 17,
|
||||
downstream_path: 18,
|
||||
cod_mg_l: 20,
|
||||
氨氮_mg_l: 21,
|
||||
电导率_us_cm: 22,
|
||||
液位_m: 23,
|
||||
流量_m3_s: 24,
|
||||
gid: 11,
|
||||
name: 12,
|
||||
code: 13,
|
||||
diameter: 20,
|
||||
dn: 20,
|
||||
length: 21,
|
||||
roughness: 22,
|
||||
max_depth: 30,
|
||||
invert_elevation: 31,
|
||||
orifice_type: 32,
|
||||
outfall_type: 32,
|
||||
shape: 33,
|
||||
pump_curve: 34,
|
||||
material: 23,
|
||||
node1: 24,
|
||||
from_node: 24,
|
||||
node2: 25,
|
||||
to_node: 25,
|
||||
elevation: 30,
|
||||
demand: 31,
|
||||
pressure: 32,
|
||||
flow: 33,
|
||||
velocity: 34,
|
||||
status: 90
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
on: "运行",
|
||||
off: "停止",
|
||||
active: "运行中",
|
||||
inactive: "停用",
|
||||
online: "在线",
|
||||
offline: "离线",
|
||||
open: "开启",
|
||||
closed: "关闭",
|
||||
normal: "正常",
|
||||
warning: "告警",
|
||||
critical: "严重"
|
||||
};
|
||||
|
||||
const OUTFALL_TYPE_LABELS: Record<string, string> = {
|
||||
free: "自由出流"
|
||||
};
|
||||
|
||||
const GATED_LABELS: Record<string, string> = {
|
||||
no: "否",
|
||||
false: "否",
|
||||
"0": "否",
|
||||
yes: "是",
|
||||
true: "是",
|
||||
"1": "是"
|
||||
};
|
||||
|
||||
const SCADA_KIND_LABELS: Record<string, string> = {
|
||||
water_quality: "水质监测",
|
||||
radar_level: "雷达液位",
|
||||
ultrasonic_flow: "超声流量",
|
||||
integrated_monitoring: "综合监测点"
|
||||
};
|
||||
|
||||
export const SCADA_METRIC_PROPERTY_KEYS = [
|
||||
"COD_mg_L",
|
||||
"氨氮_mg_L",
|
||||
"电导率_uS_cm",
|
||||
"液位_m",
|
||||
"流量_m3_s"
|
||||
] as const;
|
||||
|
||||
const PROPERTY_UNITS: Record<string, string> = {
|
||||
cod_mg_l: "mg/L",
|
||||
氨氮_mg_l: "mg/L",
|
||||
电导率_us_cm: "μS/cm",
|
||||
液位_m: "m",
|
||||
流量_m3_s: "m³/s"
|
||||
};
|
||||
|
||||
const FEATURE_PANEL_PROPERTY_KEYS: Record<WaterNetworkSourceId, readonly string[]> = {
|
||||
pumps: ["id", "status", "startup_depth", "shutoff_depth"],
|
||||
conduits: ["id", "length", "diameter"],
|
||||
junctions: ["id", "invert_elevation", "max_depth"],
|
||||
orifices: ["id", "discharge_coeff", "gated", "shape", "geom1", "geom2"],
|
||||
outfalls: ["id", "invert_elevation", "outfall_type"],
|
||||
scada: [
|
||||
"sensor_id",
|
||||
"kind",
|
||||
"swmm_node",
|
||||
"topology_order",
|
||||
"upstream_node_count",
|
||||
"distance_to_wwtp_m"
|
||||
]
|
||||
};
|
||||
|
||||
const FEATURE_PANEL_BADGE_KEYS: Partial<Record<WaterNetworkSourceId, string>> = {
|
||||
pumps: "status",
|
||||
orifices: "gated",
|
||||
scada: undefined
|
||||
};
|
||||
|
||||
type FeatureInsightMetricDefinition = {
|
||||
label: string;
|
||||
key?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
const FEATURE_INSIGHT_METRICS: Record<
|
||||
Exclude<WaterNetworkSourceId, "scada">,
|
||||
readonly FeatureInsightMetricDefinition[]
|
||||
> = {
|
||||
conduits: [
|
||||
{ label: "管径", key: "diameter" },
|
||||
{ label: "长度", key: "length" },
|
||||
{ label: "粗糙系数", key: "roughness" },
|
||||
{ label: "状态", key: "status" }
|
||||
],
|
||||
junctions: [
|
||||
{ label: "最大深度", key: "max_depth" },
|
||||
{ label: "井底高程", key: "invert_elevation" },
|
||||
{ label: "对象类型", value: "检查井" },
|
||||
{ label: "状态", value: "在线" }
|
||||
],
|
||||
orifices: [
|
||||
{ label: "孔口类型", key: "orifice_type" },
|
||||
{ label: "断面形状", key: "shape" },
|
||||
{ label: "流量系数", key: "discharge_coeff" },
|
||||
{ label: "闸门", key: "gated" }
|
||||
],
|
||||
pumps: [
|
||||
{ label: "状态", key: "status" },
|
||||
{ label: "泵曲线", key: "pump_curve" },
|
||||
{ label: "启动水深", key: "startup_depth" },
|
||||
{ label: "停泵水深", key: "shutoff_depth" }
|
||||
],
|
||||
outfalls: [
|
||||
{ label: "排放类型", key: "outfall_type" },
|
||||
{ label: "井底高程", key: "invert_elevation" },
|
||||
{ label: "阶段数据", key: "stage_data" },
|
||||
{ label: "状态", value: "在线" }
|
||||
]
|
||||
};
|
||||
|
||||
export function getLocalizedFeatureProperties(properties: Record<string, unknown>): LocalizedFeatureProperty[] {
|
||||
return Object.entries(properties).filter(([key]) => normalizePropertyKey(key) !== "cluster_size").map(([key, value]) => ({
|
||||
key,
|
||||
label: getFeaturePropertyLabel(key),
|
||||
value: formatFeaturePropertyValue(key, value)
|
||||
})).sort((first, second) => getPropertyOrder(first.key) - getPropertyOrder(second.key));
|
||||
}
|
||||
|
||||
export function getFeaturePanelProperties(
|
||||
layer: WaterNetworkSourceId,
|
||||
properties: Record<string, unknown>,
|
||||
featureId?: string
|
||||
): LocalizedFeatureProperty[] {
|
||||
return FEATURE_PANEL_PROPERTY_KEYS[layer].map((key) => {
|
||||
const propertyValue = getPropertyValue(properties, key);
|
||||
const value = key === "id" || key === "scada_id" || key === "sensor_id" ? propertyValue ?? featureId : propertyValue;
|
||||
|
||||
return {
|
||||
key,
|
||||
label: getFeaturePropertyLabel(key),
|
||||
value: formatFeaturePropertyValue(key, value)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getScadaMetricProperties(
|
||||
properties: Record<string, unknown>
|
||||
): LocalizedFeatureProperty[] {
|
||||
return SCADA_METRIC_PROPERTY_KEYS.map((key) => ({
|
||||
key,
|
||||
label: getFeaturePropertyLabel(key),
|
||||
value: formatFeaturePropertyValue(key, getPropertyValue(properties, key))
|
||||
}));
|
||||
}
|
||||
|
||||
export function getFeatureInsightMetrics(
|
||||
layer: WaterNetworkSourceId,
|
||||
properties: Record<string, unknown>
|
||||
): FeatureInsightMetric[] {
|
||||
if (layer === "scada") {
|
||||
return getScadaMetricProperties(properties).map(({ label, value }) => ({ label, value }));
|
||||
}
|
||||
|
||||
return FEATURE_INSIGHT_METRICS[layer].map((metric) => ({
|
||||
label: metric.label,
|
||||
value: metric.key
|
||||
? formatFeaturePropertyValue(metric.key, getPropertyValue(properties, metric.key))
|
||||
: metric.value ?? "暂无"
|
||||
}));
|
||||
}
|
||||
|
||||
export function getFeaturePanelModel(
|
||||
layer: WaterNetworkSourceId,
|
||||
properties: Record<string, unknown>,
|
||||
featureId?: string
|
||||
): FeaturePanelModel {
|
||||
const entries = getFeaturePanelProperties(layer, properties, featureId);
|
||||
const idKey = layer === "scada" ? "sensor_id" : "id";
|
||||
const id = entries.find((entry) => entry.key === idKey)?.value ?? "暂无";
|
||||
const badgeKey = FEATURE_PANEL_BADGE_KEYS[layer];
|
||||
const badgeEntry = badgeKey ? entries.find((entry) => entry.key === badgeKey) : undefined;
|
||||
|
||||
return {
|
||||
id,
|
||||
badge: badgeEntry ? getFeaturePanelBadge(layer, badgeEntry.value) : undefined,
|
||||
attributes: entries.filter((entry) => entry.key !== idKey && entry.key !== badgeKey)
|
||||
};
|
||||
}
|
||||
|
||||
export function getFeaturePropertyLabel(key: string) {
|
||||
const normalizedKey = normalizePropertyKey(key);
|
||||
return PROPERTY_LABELS[normalizedKey] ?? key;
|
||||
}
|
||||
|
||||
export function formatFeaturePropertyValue(key: string, value: unknown) {
|
||||
const normalizedKey = normalizePropertyKey(key);
|
||||
|
||||
if (isStatusKey(key) && typeof value === "string") {
|
||||
return STATUS_LABELS[value.toLowerCase()] ?? value;
|
||||
}
|
||||
|
||||
if (normalizedKey === "outfall_type" && typeof value === "string") {
|
||||
return OUTFALL_TYPE_LABELS[value.toLowerCase()] ?? value;
|
||||
}
|
||||
|
||||
if (normalizedKey === "gated" && value !== null && value !== undefined && value !== "") {
|
||||
return GATED_LABELS[String(value).toLowerCase()] ?? formatValue(value);
|
||||
}
|
||||
|
||||
if (normalizedKey === "active" && typeof value === "boolean") {
|
||||
return value ? "在线" : "离线";
|
||||
}
|
||||
|
||||
if (normalizedKey === "location_source" && value === "density_cluster_demo") {
|
||||
return "密度聚类模拟位置";
|
||||
}
|
||||
|
||||
if (normalizedKey === "location_source" && value === "uniform_farthest_point_demo") {
|
||||
return "空间均匀采样模拟位置";
|
||||
}
|
||||
|
||||
if (normalizedKey === "kind" && typeof value === "string") {
|
||||
return SCADA_KIND_LABELS[value] ?? value;
|
||||
}
|
||||
|
||||
const unit = PROPERTY_UNITS[normalizedKey];
|
||||
if (unit && value !== null && value !== undefined && value !== "") {
|
||||
return `${formatValue(value)} ${unit}`;
|
||||
}
|
||||
|
||||
if ((normalizedKey === "diameter" || normalizedKey === "dn") && value !== null && value !== undefined && value !== "") {
|
||||
const diameterInMeters = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(diameterInMeters)
|
||||
? `${formatValue(diameterInMeters * 1000)} mm`
|
||||
: formatValue(value);
|
||||
}
|
||||
|
||||
if (["length", "elevation", "invert_elevation", "max_depth", "startup_depth", "shutoff_depth", "distance_to_wwtp_m"].includes(normalizedKey)
|
||||
&& value !== null && value !== undefined && value !== "") {
|
||||
return `${formatValue(value)} m`;
|
||||
}
|
||||
|
||||
return formatValue(value);
|
||||
}
|
||||
|
||||
function getPropertyOrder(key: string) {
|
||||
return PROPERTY_ORDER[normalizePropertyKey(key)] ?? 100;
|
||||
}
|
||||
|
||||
function normalizePropertyKey(key: string) {
|
||||
return key.trim().replaceAll("-", "_").toLowerCase();
|
||||
}
|
||||
|
||||
function getPropertyValue(properties: Record<string, unknown>, key: string) {
|
||||
if (Object.prototype.hasOwnProperty.call(properties, key)) {
|
||||
return properties[key];
|
||||
}
|
||||
|
||||
const normalizedKey = normalizePropertyKey(key);
|
||||
const matchingKey = Object.keys(properties).find((propertyKey) => normalizePropertyKey(propertyKey) === normalizedKey);
|
||||
return matchingKey ? properties[matchingKey] : undefined;
|
||||
}
|
||||
|
||||
function isStatusKey(key: string) {
|
||||
return normalizePropertyKey(key) === "status";
|
||||
}
|
||||
|
||||
function getFeaturePanelBadge(
|
||||
layer: WaterNetworkSourceId,
|
||||
value: string
|
||||
): FeaturePanelBadge {
|
||||
if (layer === "orifices") {
|
||||
return value === "是"
|
||||
? { label: "有闸门", tone: "info" }
|
||||
: value === "否"
|
||||
? { label: "无闸门", tone: "inactive" }
|
||||
: { label: `闸门 ${value}`, tone: "inactive" };
|
||||
}
|
||||
|
||||
const normalizedValue = value.toLowerCase();
|
||||
|
||||
if (["运行", "运行中", "在线", "开启", "正常"].includes(value)) {
|
||||
return { label: value, tone: "active" };
|
||||
}
|
||||
|
||||
if (["停止", "停用", "离线", "关闭"].includes(value)) {
|
||||
return { label: value, tone: "inactive" };
|
||||
}
|
||||
|
||||
if (normalizedValue === "warning" || value === "告警") {
|
||||
return { label: value, tone: "warning" };
|
||||
}
|
||||
|
||||
if (normalizedValue === "critical" || value === "严重") {
|
||||
return { label: value, tone: "critical" };
|
||||
}
|
||||
|
||||
return { label: value, tone: "inactive" };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function formatValue(value: unknown) {
|
||||
if (typeof value === "number") {
|
||||
return Number.isInteger(value) ? value.toString() : value.toFixed(2);
|
||||
}
|
||||
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "暂无";
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { getRunningWorkflowDefinition } from "@/features/workbench/data/running-workflows";
|
||||
import type { ScheduledConditionItem, ScheduledConditionStatus, WorkbenchAlert } from "@/features/workbench/types";
|
||||
|
||||
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
|
||||
|
||||
const statusLabels: Record<ScheduledConditionStatus, string> = {
|
||||
completed: "完成",
|
||||
running: "执行中",
|
||||
warning: "关注",
|
||||
error: "异常",
|
||||
pending: "预约"
|
||||
};
|
||||
|
||||
export function createConditionConversationPrompt(condition: ScheduledConditionItem) {
|
||||
const workflow =
|
||||
condition.kind === "condition"
|
||||
? getRunningWorkflowDefinition(condition)
|
||||
: null;
|
||||
const evidence = condition.evidence?.map((item, index) => `${index + 1}. ${item}`).join("\n") || "无";
|
||||
const kpis =
|
||||
condition.kind === "condition" && condition.kpis?.length
|
||||
? condition.kpis
|
||||
.map((kpi, index) => {
|
||||
const value = `${kpi.value}${kpi.unit ?? ""}`;
|
||||
const threshold = kpi.threshold ? `,阈值:${kpi.threshold}` : "";
|
||||
return `${index + 1}. ${kpi.label}:${value},状态:${getRiskLabel(kpi.status)}${threshold}`;
|
||||
})
|
||||
.join("\n")
|
||||
: "无";
|
||||
const report =
|
||||
condition.kind === "condition" && condition.report
|
||||
? [
|
||||
condition.report.conclusion,
|
||||
...condition.report.sections.map(
|
||||
(section) => `${section.title}:${section.items.join(";")}`
|
||||
)
|
||||
].join("\n")
|
||||
: null;
|
||||
|
||||
return [
|
||||
"请继续分析这条工况任务,并基于当前上下文给出下一步调度建议。",
|
||||
"",
|
||||
`时间:${formatTime(condition.scheduledAt)}`,
|
||||
"类型:历史工况",
|
||||
workflow ? `任务工作流:${workflow.title}` : null,
|
||||
workflow ? `工作流步骤:${workflow.steps.map((step, index) => `${index + 1}. ${step.title}`).join(";")}` : null,
|
||||
`标题:${condition.title}`,
|
||||
`状态:${statusLabels[condition.status]}`,
|
||||
`风险:${getRiskLabel(condition.riskLevel)}`,
|
||||
`摘要:${condition.summary}`,
|
||||
condition.detail ? `详情:${condition.detail}` : null,
|
||||
`关键证据:\n${evidence}`,
|
||||
`关键指标:\n${kpis}`,
|
||||
report ? `工况报告:\n${report}` : null,
|
||||
condition.recommendation ? `已有建议:${condition.recommendation}` : null,
|
||||
condition.sessionId ? `sessionId:${condition.sessionId}` : null,
|
||||
"",
|
||||
"请先说明当前判断,再列出需要人工确认的事项和建议动作。"
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function createAlertQueueConversationPrompt({
|
||||
alerts,
|
||||
conditions
|
||||
}: {
|
||||
alerts: WorkbenchAlert[];
|
||||
conditions: ScheduledConditionItem[];
|
||||
}) {
|
||||
const alertSummaries = alerts.map((alert, index) => {
|
||||
const condition = findConditionForAlert(alert, conditions);
|
||||
const baseSummary = [
|
||||
`${index + 1}. ${alert.title}`,
|
||||
"状态:待复核",
|
||||
`时间:${alert.time}`,
|
||||
`描述:${alert.description}`
|
||||
];
|
||||
|
||||
if (!condition) {
|
||||
return baseSummary.join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
...baseSummary,
|
||||
`关联工况:${condition.title}`,
|
||||
`工况状态:${statusLabels[condition.status]}`,
|
||||
`风险等级:${getRiskLabel(condition.riskLevel)}`,
|
||||
condition.detail ? `工况详情:${condition.detail}` : null,
|
||||
condition.evidence?.length ? `关键证据:\n${condition.evidence.map((item, evidenceIndex) => `${evidenceIndex + 1}. ${item}`).join("\n")}` : null,
|
||||
condition.recommendation ? `已有建议:${condition.recommendation}` : null,
|
||||
condition.kind === "condition" && condition.analysisInsight?.solutionOptions?.length
|
||||
? `已有处置方案:\n${condition.analysisInsight.solutionOptions.map((option, optionIndex) => `${optionIndex + 1}. ${option.title}:${option.action};代价:${option.tradeoff}`).join("\n")}`
|
||||
: null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
});
|
||||
|
||||
return [
|
||||
"请作为排水管网调度 Agent,汇总当前待复核工况队列。",
|
||||
"",
|
||||
"目标:",
|
||||
"1. 汇总这一组待复核工况之间的关联关系和处理顺序。",
|
||||
"2. 列出支撑判断的关键证据和不确定性。",
|
||||
"3. 给出处置方案,包含建议动作、适用条件、影响范围和需要人工确认的事项。",
|
||||
"4. 不要自动执行阀门、泵站或工单动作,先给调度员确认。",
|
||||
"",
|
||||
`待复核工况数量:${alerts.length}`,
|
||||
"",
|
||||
"待复核工况上下文:",
|
||||
alertSummaries.join("\n\n"),
|
||||
"",
|
||||
"请先给出总体判断,再按工况列出处置建议,最后列出需要补充核查的数据。"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function findConditionForAlert(alert: WorkbenchAlert, conditions: ScheduledConditionItem[]) {
|
||||
if (!alert.id.startsWith(SCHEDULED_CONDITION_ALERT_ID_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const conditionId = alert.id.slice(SCHEDULED_CONDITION_ALERT_ID_PREFIX.length);
|
||||
return conditions.find((condition) => condition.id === conditionId) ?? null;
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
const match = value.match(/T(\d{2}:\d{2})/);
|
||||
return match?.[1] ?? value;
|
||||
}
|
||||
|
||||
function getRiskLabel(riskLevel: ScheduledConditionItem["riskLevel"]) {
|
||||
if (riskLevel === "critical") {
|
||||
return "需重点复核";
|
||||
}
|
||||
|
||||
if (riskLevel === "attention") {
|
||||
return "关注";
|
||||
}
|
||||
|
||||
return "正常";
|
||||
}
|
||||
Reference in New Issue
Block a user