Compare commits
3
Commits
14c76231d5
...
f5e7312e3b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5e7312e3b | ||
|
|
c6e6e24aab | ||
|
|
adb53d9a13 |
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext";
|
||||||
|
import HealthRiskStatistics from "@components/olmap/HealthRiskAnalysis/HealthRiskStatistics";
|
||||||
|
import PredictDataPanel from "@components/olmap/HealthRiskAnalysis/PredictDataPanel";
|
||||||
|
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
||||||
|
import {
|
||||||
|
RAINBOW_COLORS,
|
||||||
|
RISK_BREAKS,
|
||||||
|
} from "@components/olmap/HealthRiskAnalysis/types";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<HealthRiskProvider>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="realtime"
|
||||||
|
hiddenButtons={["style"]}
|
||||||
|
HistoryPanel={PredictDataPanel}
|
||||||
|
/>
|
||||||
|
<Timeline />
|
||||||
|
<HealthRiskStatistics />
|
||||||
|
<Box className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10">
|
||||||
|
<StyleLegend
|
||||||
|
layerName="管道"
|
||||||
|
layerId="health-risk"
|
||||||
|
property="健康风险"
|
||||||
|
colors={RAINBOW_COLORS}
|
||||||
|
type="line"
|
||||||
|
dimensions={Array(RAINBOW_COLORS.length).fill(2)}
|
||||||
|
breaks={[0, ...RISK_BREAKS]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</HealthRiskProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
import BurstDetectionPanel from "@/components/olmap/BurstDetection/BurstDetectionPanel";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="burst_detection"
|
||||||
|
hiddenButtons={["style"]}
|
||||||
|
/>
|
||||||
|
<BurstDetectionPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
import BurstLocationPanel from "@/components/olmap/BurstLocation/BurstLocationPanel";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="burst_location"
|
||||||
|
hiddenButtons={["style"]}
|
||||||
|
/>
|
||||||
|
<BurstLocationPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
import BurstPipeAnalysisPanel from "@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="burst_analysis"
|
||||||
|
enableCompare
|
||||||
|
/>
|
||||||
|
<BurstPipeAnalysisPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
import WaterQualityPanel from "@/components/olmap/ContaminantSimulation/WaterQualityPanel";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="contaminant_analysis"
|
||||||
|
enableCompare
|
||||||
|
/>
|
||||||
|
<WaterQualityPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
import DMALeakDetectionPanel from "@/components/olmap/DMALeakDetection/DMALeakDetectionPanel";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="dma_leak_identification"
|
||||||
|
hiddenButtons={["style"]}
|
||||||
|
/>
|
||||||
|
<DMALeakDetectionPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
import FlushingAnalysisPanel from "@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar queryType="scheme" schemeType="flushing_analysis" />
|
||||||
|
<FlushingAnalysisPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import MapComponent from "@components/olmap/core/MapComponent";
|
||||||
|
|
||||||
|
export default function MapLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <MapComponent>{children}</MapComponent>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
import MonitoringPlaceOptimizationPanel from "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar hiddenButtons={["style"]} />
|
||||||
|
<MonitoringPlaceOptimizationPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-5
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import MapComponent from "@components/olmap/core/MapComponent";
|
|
||||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
@@ -22,8 +21,7 @@ export default function Home() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
<>
|
||||||
<MapComponent>
|
|
||||||
<MapToolbar queryType="realtime" />
|
<MapToolbar queryType="realtime" />
|
||||||
<Timeline />
|
<Timeline />
|
||||||
<SCADADeviceList
|
<SCADADeviceList
|
||||||
@@ -32,7 +30,6 @@ export default function Home() {
|
|||||||
selectedDeviceIds={selectedDeviceIds}
|
selectedDeviceIds={selectedDeviceIds}
|
||||||
/>
|
/>
|
||||||
<SCADADataPanel deviceIds={selectedDeviceIds} visible={panelVisible} />
|
<SCADADataPanel deviceIds={selectedDeviceIds} visible={panelVisible} />
|
||||||
</MapComponent>
|
</>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+2
-5
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import MapComponent from "@components/olmap/core/MapComponent";
|
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
|
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
|
||||||
@@ -21,8 +20,7 @@ export default function Home() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
<>
|
||||||
<MapComponent>
|
|
||||||
<MapToolbar hiddenButtons={["style"]} />
|
<MapToolbar hiddenButtons={["style"]} />
|
||||||
<SCADADeviceList
|
<SCADADeviceList
|
||||||
onDeviceClick={handleDeviceClick}
|
onDeviceClick={handleDeviceClick}
|
||||||
@@ -35,7 +33,6 @@ export default function Home() {
|
|||||||
visible={panelVisible}
|
visible={panelVisible}
|
||||||
showCleaning={true}
|
showCleaning={true}
|
||||||
/>
|
/>
|
||||||
</MapComponent>
|
</>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import MapComponent from "@components/olmap/core/MapComponent";
|
|
||||||
import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline";
|
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
|
||||||
import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext";
|
|
||||||
import HealthRiskStatistics from "@components/olmap/HealthRiskAnalysis/HealthRiskStatistics";
|
|
||||||
import PredictDataPanel from "@components/olmap/HealthRiskAnalysis/PredictDataPanel";
|
|
||||||
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
|
||||||
import {
|
|
||||||
RAINBOW_COLORS,
|
|
||||||
RISK_BREAKS,
|
|
||||||
} from "@components/olmap/HealthRiskAnalysis/types";
|
|
||||||
import { Box } from "@mui/material";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
|
||||||
<HealthRiskProvider>
|
|
||||||
<MapComponent>
|
|
||||||
<MapToolbar
|
|
||||||
queryType="realtime"
|
|
||||||
hiddenButtons={["style"]}
|
|
||||||
HistoryPanel={PredictDataPanel}
|
|
||||||
/>
|
|
||||||
<Timeline />
|
|
||||||
<HealthRiskStatistics />
|
|
||||||
<Box className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10">
|
|
||||||
<StyleLegend
|
|
||||||
layerName="管道"
|
|
||||||
layerId="health-risk"
|
|
||||||
property="健康风险"
|
|
||||||
colors={RAINBOW_COLORS}
|
|
||||||
type="line"
|
|
||||||
dimensions={Array(RAINBOW_COLORS.length).fill(2)}
|
|
||||||
breaks={[0, ...RISK_BREAKS]}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</MapComponent>
|
|
||||||
</HealthRiskProvider>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import MapComponent from "@components/olmap/core/MapComponent";
|
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
|
||||||
import BurstDetectionPanel from "@/components/olmap/BurstDetection/BurstDetectionPanel";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="relative h-full w-full overflow-hidden">
|
|
||||||
<MapComponent>
|
|
||||||
<MapToolbar queryType="scheme" schemeType="burst_detection" hiddenButtons={["style"]} />
|
|
||||||
<BurstDetectionPanel />
|
|
||||||
</MapComponent>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import MapComponent from "@components/olmap/core/MapComponent";
|
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
|
||||||
import BurstLocationPanel from "@/components/olmap/BurstLocation/BurstLocationPanel";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
|
||||||
<MapComponent>
|
|
||||||
<MapToolbar
|
|
||||||
queryType="scheme"
|
|
||||||
schemeType="burst_location"
|
|
||||||
hiddenButtons={["style"]}
|
|
||||||
/>
|
|
||||||
<BurstLocationPanel />
|
|
||||||
</MapComponent>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import MapComponent from "@components/olmap/core/MapComponent";
|
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
|
||||||
import BurstPipeAnalysisPanel from "@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
|
||||||
<MapComponent>
|
|
||||||
<MapToolbar
|
|
||||||
queryType="scheme"
|
|
||||||
schemeType="burst_analysis"
|
|
||||||
enableCompare
|
|
||||||
/>
|
|
||||||
<BurstPipeAnalysisPanel />
|
|
||||||
</MapComponent>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import MapComponent from "@components/olmap/core/MapComponent";
|
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
|
||||||
import WaterQualityPanel from "@/components/olmap/ContaminantSimulation/WaterQualityPanel";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
|
||||||
<MapComponent>
|
|
||||||
<MapToolbar
|
|
||||||
queryType="scheme"
|
|
||||||
schemeType="contaminant_analysis"
|
|
||||||
enableCompare
|
|
||||||
/>
|
|
||||||
<WaterQualityPanel />
|
|
||||||
</MapComponent>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import MapComponent from "@components/olmap/core/MapComponent";
|
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
|
||||||
import DMALeakDetectionPanel from "@/components/olmap/DMALeakDetection/DMALeakDetectionPanel";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
|
||||||
<MapComponent>
|
|
||||||
<MapToolbar
|
|
||||||
queryType="scheme"
|
|
||||||
schemeType="dma_leak_identification"
|
|
||||||
hiddenButtons={["style"]}
|
|
||||||
/>
|
|
||||||
<DMALeakDetectionPanel />
|
|
||||||
</MapComponent>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import MapComponent from "@components/olmap/core/MapComponent";
|
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
|
||||||
import FlushingAnalysisPanel from "@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
|
||||||
<MapComponent>
|
|
||||||
<MapToolbar queryType="scheme" schemeType="flushing_analysis" />
|
|
||||||
<FlushingAnalysisPanel />
|
|
||||||
</MapComponent>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import MapComponent from "@components/olmap/core/MapComponent";
|
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
|
||||||
import MonitoringPlaceOptimizationPanel from "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel";
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
|
||||||
<MapComponent>
|
|
||||||
<MapToolbar hiddenButtons={["style"]} />
|
|
||||||
<MonitoringPlaceOptimizationPanel />
|
|
||||||
</MapComponent>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import React from "react";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { AgentTurn } from "./AgentTurn";
|
||||||
|
|
||||||
|
jest.mock("next/image", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||||
|
<img {...props} alt={props.alt ?? ""} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("framer-motion", () => ({
|
||||||
|
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
motion: {
|
||||||
|
div: ({
|
||||||
|
children,
|
||||||
|
animate: _animate,
|
||||||
|
exit: _exit,
|
||||||
|
initial: _initial,
|
||||||
|
transition: _transition,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => (
|
||||||
|
<div {...props}>{children}</div>
|
||||||
|
),
|
||||||
|
span: ({
|
||||||
|
children,
|
||||||
|
animate: _animate,
|
||||||
|
transition: _transition,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement> & Record<string, unknown>) => (
|
||||||
|
<span {...props}>{children}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentMarkdownBlock", () => ({
|
||||||
|
MarkdownBlock: ({ children }: { children: string }) => (
|
||||||
|
<div>{children.split(/\n+/u).map((line) => <p key={line}>{line}</p>)}</div>
|
||||||
|
),
|
||||||
|
normalizeClipboardText: (value: string) => value.replace(/\s+$/u, ""),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("AgentTurn speech selection", () => {
|
||||||
|
it("shows a floating action and reads from the selected text", async () => {
|
||||||
|
const content = "第一段内容。\n\n第二段内容。";
|
||||||
|
const speechText = "第一段内容。\n第二段内容。";
|
||||||
|
const onSpeak = jest.fn();
|
||||||
|
const removeAllRanges = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AgentTurn
|
||||||
|
message={{ id: "assistant-1", role: "assistant", content }}
|
||||||
|
isStreaming={false}
|
||||||
|
messageSpeechState="idle"
|
||||||
|
onSpeak={onSpeak}
|
||||||
|
onPause={jest.fn()}
|
||||||
|
onResume={jest.fn()}
|
||||||
|
onStopSpeech={jest.fn()}
|
||||||
|
isTtsSupported
|
||||||
|
onCreateBranch={jest.fn()}
|
||||||
|
onReplyPermission={jest.fn()}
|
||||||
|
onReplyQuestion={jest.fn()}
|
||||||
|
onRejectQuestion={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedParagraph = screen.getByText("第二段内容。");
|
||||||
|
const selectedTextNode = selectedParagraph.firstChild as Text;
|
||||||
|
const range = {
|
||||||
|
commonAncestorContainer: selectedTextNode,
|
||||||
|
getBoundingClientRect: () => ({
|
||||||
|
width: 72,
|
||||||
|
height: 20,
|
||||||
|
top: 120,
|
||||||
|
right: 172,
|
||||||
|
bottom: 140,
|
||||||
|
left: 100,
|
||||||
|
x: 100,
|
||||||
|
y: 120,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
}),
|
||||||
|
} as unknown as Range;
|
||||||
|
const selection = {
|
||||||
|
rangeCount: 1,
|
||||||
|
isCollapsed: false,
|
||||||
|
getRangeAt: () => range,
|
||||||
|
toString: () => "第二段",
|
||||||
|
removeAllRanges,
|
||||||
|
} as unknown as Selection;
|
||||||
|
|
||||||
|
jest.spyOn(window, "getSelection").mockReturnValue(selection);
|
||||||
|
jest.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerUp(selectedParagraph);
|
||||||
|
|
||||||
|
const speechAction = await screen.findByRole("button", { name: "从这里开始朗读" });
|
||||||
|
fireEvent.click(speechAction);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSpeak).toHaveBeenCalledWith("assistant-1", speechText, {
|
||||||
|
startOffset: speechText.indexOf("第二段"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(removeAllRanges).toHaveBeenCalledTimes(1);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("button", { name: "从这里开始朗读" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
Button,
|
||||||
|
Grow,
|
||||||
IconButton,
|
IconButton,
|
||||||
Paper,
|
Paper,
|
||||||
|
Popper,
|
||||||
Stack,
|
Stack,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -41,6 +44,48 @@ import PauseRounded from "@mui/icons-material/PauseRounded";
|
|||||||
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
||||||
import StopRounded from "@mui/icons-material/StopRounded";
|
import StopRounded from "@mui/icons-material/StopRounded";
|
||||||
|
|
||||||
|
const floatingActionSurfaceSx = {
|
||||||
|
display: "flex",
|
||||||
|
gap: 0.5,
|
||||||
|
p: 0.5,
|
||||||
|
borderRadius: "16px",
|
||||||
|
bgcolor: alpha("#fff", 0.8),
|
||||||
|
backdropFilter: "blur(16px)",
|
||||||
|
border: `1px solid ${alpha("#fff", 0.9)}`,
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
|
||||||
|
overflow: "hidden",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const floatingActionTransitionTimeout = { enter: 150, exit: 120 } as const;
|
||||||
|
const floatingIconButtonSx = {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "text.secondary",
|
||||||
|
"&:hover": {
|
||||||
|
color: "#00acc1",
|
||||||
|
bgcolor: alpha("#00acc1", 0.1),
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
const floatingSpeechButtonSx = {
|
||||||
|
minHeight: 34,
|
||||||
|
px: 1.25,
|
||||||
|
color: "text.primary",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: 0,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
borderRadius: "12px",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: alpha("#00acc1", 0.1),
|
||||||
|
color: "#00acc1",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type SpeechSelection = {
|
||||||
|
startOffset: number;
|
||||||
|
anchorRect: DOMRect;
|
||||||
|
};
|
||||||
|
|
||||||
type AgentTurnProps = {
|
type AgentTurnProps = {
|
||||||
message: Message;
|
message: Message;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
@@ -180,10 +225,7 @@ export const AgentTurn = React.memo(
|
|||||||
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
|
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
|
||||||
const [isHovered, setIsHovered] = React.useState(false);
|
const [isHovered, setIsHovered] = React.useState(false);
|
||||||
const answerContentRef = React.useRef<HTMLDivElement | null>(null);
|
const answerContentRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const [selectedSpeechStart, setSelectedSpeechStart] = React.useState<{
|
const [speechSelection, setSpeechSelection] = React.useState<SpeechSelection | null>(null);
|
||||||
offset: number;
|
|
||||||
preview: string;
|
|
||||||
} | null>(null);
|
|
||||||
const isProgressComplete = message.progress?.some(
|
const isProgressComplete = message.progress?.some(
|
||||||
(item) => item.phase === "complete" && item.status === "completed",
|
(item) => item.phase === "complete" && item.status === "completed",
|
||||||
) ?? false;
|
) ?? false;
|
||||||
@@ -203,39 +245,94 @@ export const AgentTurn = React.memo(
|
|||||||
() => stripMarkdown(answerContent),
|
() => stripMarkdown(answerContent),
|
||||||
[answerContent],
|
[answerContent],
|
||||||
);
|
);
|
||||||
const handleCaptureSpeechSelection = React.useCallback(() => {
|
const captureSpeechSelection = React.useCallback(() => {
|
||||||
|
if (!isTtsSupported || isStreamingAssistant) {
|
||||||
|
setSpeechSelection(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
const container = answerContentRef.current;
|
const container = answerContentRef.current;
|
||||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !container) {
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !container) {
|
||||||
|
setSpeechSelection(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const range = selection.getRangeAt(0);
|
const range = selection.getRangeAt(0);
|
||||||
if (!container.contains(range.commonAncestorContainer)) {
|
if (!container.contains(range.commonAncestorContainer)) {
|
||||||
|
setSpeechSelection(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedText = selection.toString();
|
const selectedText = selection.toString();
|
||||||
const startOffset = findSpeechSelectionStartOffset(speechText, selectedText);
|
const startOffset = findSpeechSelectionStartOffset(speechText, selectedText);
|
||||||
if (startOffset === null) {
|
if (startOffset === null) {
|
||||||
setSelectedSpeechStart(null);
|
setSpeechSelection(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preview = selectedText.replace(/\s+/g, " ").trim().slice(0, 24);
|
const anchorRect = range.getBoundingClientRect();
|
||||||
setSelectedSpeechStart({
|
if (anchorRect.width === 0 && anchorRect.height === 0) {
|
||||||
offset: startOffset,
|
setSpeechSelection(null);
|
||||||
preview: preview.length === 24 ? `${preview}...` : preview,
|
return;
|
||||||
});
|
}
|
||||||
}, [speechText]);
|
|
||||||
|
setSpeechSelection({ startOffset, anchorRect });
|
||||||
|
}, [isStreamingAssistant, isTtsSupported, speechText]);
|
||||||
|
const handleCaptureSpeechSelection = React.useCallback(() => {
|
||||||
|
window.requestAnimationFrame(captureSpeechSelection);
|
||||||
|
}, [captureSpeechSelection]);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setSelectedSpeechStart(null);
|
setSpeechSelection(null);
|
||||||
}, [message.id, speechText]);
|
}, [message.id, speechText]);
|
||||||
const handleSpeakFromCurrentStart = () => {
|
React.useEffect(() => {
|
||||||
onSpeak(message.id, speechText, {
|
if (!speechSelection) return;
|
||||||
startOffset: selectedSpeechStart?.offset ?? 0,
|
|
||||||
});
|
const closeSpeechSelection = () => setSpeechSelection(null);
|
||||||
|
const handleSelectionChange = () => {
|
||||||
|
if (window.getSelection()?.isCollapsed) {
|
||||||
|
closeSpeechSelection();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeSpeechSelection();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("selectionchange", handleSelectionChange);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
window.addEventListener("resize", closeSpeechSelection);
|
||||||
|
window.addEventListener("scroll", closeSpeechSelection, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("selectionchange", handleSelectionChange);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener("resize", closeSpeechSelection);
|
||||||
|
window.removeEventListener("scroll", closeSpeechSelection, true);
|
||||||
|
};
|
||||||
|
}, [speechSelection]);
|
||||||
|
const handleSpeakMessage = () => {
|
||||||
|
onSpeak(message.id, speechText);
|
||||||
|
};
|
||||||
|
const handleSpeakFromSelection = () => {
|
||||||
|
if (!speechSelection) return;
|
||||||
|
|
||||||
|
onSpeak(message.id, speechText, {
|
||||||
|
startOffset: speechSelection.startOffset,
|
||||||
|
});
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
setSpeechSelection(null);
|
||||||
|
};
|
||||||
|
const speechSelectionAnchor = React.useMemo(
|
||||||
|
() => speechSelection
|
||||||
|
? {
|
||||||
|
getBoundingClientRect: () => speechSelection.anchorRect,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
[speechSelection],
|
||||||
|
);
|
||||||
|
const isSpeechSelectionOpen = Boolean(speechSelection);
|
||||||
const contentSegments: ContentSegment[] = useMemo(
|
const contentSegments: ContentSegment[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
!isUser && !isErrorMessage
|
!isUser && !isErrorMessage
|
||||||
@@ -385,9 +482,8 @@ export const AgentTurn = React.memo(
|
|||||||
|
|
||||||
<Box
|
<Box
|
||||||
ref={answerContentRef}
|
ref={answerContentRef}
|
||||||
onMouseUp={handleCaptureSpeechSelection}
|
onPointerUp={handleCaptureSpeechSelection}
|
||||||
onKeyUp={handleCaptureSpeechSelection}
|
onKeyUp={handleCaptureSpeechSelection}
|
||||||
onTouchEnd={handleCaptureSpeechSelection}
|
|
||||||
sx={{
|
sx={{
|
||||||
p: 1.5,
|
p: 1.5,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
@@ -457,6 +553,43 @@ export const AgentTurn = React.memo(
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Popper
|
||||||
|
open={isSpeechSelectionOpen}
|
||||||
|
anchorEl={speechSelectionAnchor}
|
||||||
|
placement="top"
|
||||||
|
transition
|
||||||
|
modifiers={[
|
||||||
|
{ name: "offset", options: { offset: [0, 8] } },
|
||||||
|
{ name: "flip", enabled: true },
|
||||||
|
{ name: "preventOverflow", options: { padding: 8 } },
|
||||||
|
]}
|
||||||
|
sx={{ zIndex: theme.zIndex.tooltip }}
|
||||||
|
>
|
||||||
|
{({ TransitionProps, placement }) => (
|
||||||
|
<Grow
|
||||||
|
{...TransitionProps}
|
||||||
|
timeout={floatingActionTransitionTimeout}
|
||||||
|
style={{
|
||||||
|
transformOrigin: placement.startsWith("bottom")
|
||||||
|
? "center top"
|
||||||
|
: "center bottom",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper elevation={4} sx={floatingActionSurfaceSx}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<VolumeUpRounded sx={{ fontSize: 16 }} />}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={handleSpeakFromSelection}
|
||||||
|
sx={floatingSpeechButtonSx}
|
||||||
|
>
|
||||||
|
从这里开始朗读
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</Grow>
|
||||||
|
)}
|
||||||
|
</Popper>
|
||||||
|
|
||||||
{visibleChartArtifacts.map((artifact) => (
|
{visibleChartArtifacts.map((artifact) => (
|
||||||
<ChatInlineChart
|
<ChatInlineChart
|
||||||
key={artifact.id}
|
key={artifact.id}
|
||||||
@@ -479,26 +612,21 @@ export const AgentTurn = React.memo(
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<AnimatePresence>
|
<Grow
|
||||||
{isHovered && !isStreaming && (
|
in={isHovered && !isStreaming}
|
||||||
<motion.div
|
timeout={floatingActionTransitionTimeout}
|
||||||
initial={{ opacity: 0, scale: 0.9, y: 5 }}
|
mountOnEnter
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
unmountOnExit
|
||||||
exit={{ opacity: 0, scale: 0.9, y: 5 }}
|
style={{ transformOrigin: "right bottom" }}
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
style={{ position: "absolute", top: -14, right: 12, zIndex: 10 }}
|
|
||||||
>
|
>
|
||||||
<Paper
|
<Paper
|
||||||
elevation={4}
|
elevation={4}
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
...floatingActionSurfaceSx,
|
||||||
gap: 0.5,
|
position: "absolute",
|
||||||
p: 0.5,
|
top: -14,
|
||||||
borderRadius: "16px",
|
right: 12,
|
||||||
bgcolor: alpha("#fff", 0.8),
|
zIndex: 10,
|
||||||
backdropFilter: "blur(16px)",
|
|
||||||
border: `1px solid ${alpha("#fff", 0.9)}`,
|
|
||||||
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip title="复制">
|
<Tooltip title="复制">
|
||||||
@@ -509,9 +637,8 @@ export const AgentTurn = React.memo(
|
|||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
normalizeClipboardText(message.content),
|
normalizeClipboardText(message.content),
|
||||||
);
|
);
|
||||||
// Could add a toast here
|
|
||||||
}}
|
}}
|
||||||
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
sx={floatingIconButtonSx}
|
||||||
>
|
>
|
||||||
<ContentCopyRounded sx={{ fontSize: 16 }} />
|
<ContentCopyRounded sx={{ fontSize: 16 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -520,18 +647,14 @@ export const AgentTurn = React.memo(
|
|||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
aria-label="拆分为新会话"
|
aria-label="拆分为新会话"
|
||||||
onClick={() => {
|
onClick={() => onCreateBranch(message.id)}
|
||||||
onCreateBranch(message.id);
|
sx={floatingIconButtonSx}
|
||||||
}}
|
|
||||||
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
|
||||||
>
|
>
|
||||||
<TbArrowsSplit2 size={16} />
|
<TbArrowsSplit2 size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Paper>
|
</Paper>
|
||||||
</motion.div>
|
</Grow>
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
</Paper>
|
</Paper>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -542,8 +665,8 @@ export const AgentTurn = React.memo(
|
|||||||
{messageSpeechState === "idle" ? (
|
{messageSpeechState === "idle" ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={handleSpeakFromCurrentStart}
|
onClick={handleSpeakMessage}
|
||||||
aria-label={selectedSpeechStart ? "从选中位置朗读" : "朗读消息"}
|
aria-label="朗读消息"
|
||||||
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
|
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
|
||||||
>
|
>
|
||||||
<VolumeUpRounded sx={{ fontSize: 16 }} />
|
<VolumeUpRounded sx={{ fontSize: 16 }} />
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import React from "react";
|
||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { GlobalChatbox } from "./GlobalChatbox";
|
||||||
|
|
||||||
|
const createSession = jest.fn();
|
||||||
|
let mockCurrentProjectId = "project-1";
|
||||||
|
|
||||||
|
jest.mock("@refinedev/core", () => ({
|
||||||
|
useNotification: () => ({ open: jest.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/chatModels", () => ({
|
||||||
|
fetchAgentModels: jest.fn(() => new Promise(() => {})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/store/projectStore", () => ({
|
||||||
|
useProjectStore: (selector: (state: { currentProjectId: string }) => unknown) =>
|
||||||
|
selector({ currentProjectId: mockCurrentProjectId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./globalChatboxVoice", () => ({
|
||||||
|
useSpeechSynthesis: () => ({
|
||||||
|
speechState: "idle",
|
||||||
|
speakingMessageId: null,
|
||||||
|
speak: jest.fn(),
|
||||||
|
pause: jest.fn(),
|
||||||
|
resume: jest.fn(),
|
||||||
|
stop: jest.fn(),
|
||||||
|
isSupported: true,
|
||||||
|
}),
|
||||||
|
useSpeechRecognition: () => ({
|
||||||
|
isListening: false,
|
||||||
|
start: jest.fn(),
|
||||||
|
stop: jest.fn(),
|
||||||
|
isSupported: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./hooks/useAgentToolActions", () => ({
|
||||||
|
useAgentToolActions: () => jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./hooks/useAgentChatSession", () => ({
|
||||||
|
useAgentChatSession: () => ({
|
||||||
|
messages: [],
|
||||||
|
chatSessions: [],
|
||||||
|
activeSessionId: undefined,
|
||||||
|
isHydrating: false,
|
||||||
|
loadingSessionId: null,
|
||||||
|
isStreaming: false,
|
||||||
|
sessionTitle: "新会话",
|
||||||
|
sendPrompt: jest.fn(),
|
||||||
|
createBranch: jest.fn(),
|
||||||
|
abort: jest.fn(),
|
||||||
|
replyPermission: jest.fn(),
|
||||||
|
replyQuestion: jest.fn(),
|
||||||
|
rejectQuestion: jest.fn(),
|
||||||
|
createSession,
|
||||||
|
renameSession: jest.fn(),
|
||||||
|
removeSession: jest.fn(),
|
||||||
|
switchSession: jest.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentHeader", () => ({
|
||||||
|
AgentHeader: () => <div>Agent header</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentHistoryPanel", () => ({
|
||||||
|
AgentHistoryPanel: () => <div>History</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentWorkspace", () => ({
|
||||||
|
AgentWorkspace: () => <div data-testid="agent-workspace">Workspace</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentComposer", () => ({
|
||||||
|
AgentComposer: React.forwardRef(function MockAgentComposer() {
|
||||||
|
return <div>Composer</div>;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./GlobalChatboxParts", () => ({
|
||||||
|
Blob: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("GlobalChatbox lifecycle", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
createSession.mockClear();
|
||||||
|
mockCurrentProjectId = "project-1";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.runOnlyPendingTimers();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps content mounted and preserves the session across close and reopen", async () => {
|
||||||
|
const { rerender } = render(<GlobalChatbox open onClose={jest.fn()} />);
|
||||||
|
|
||||||
|
act(() => jest.runOnlyPendingTimers());
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.getByTestId("agent-workspace")).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<GlobalChatbox open={false} onClose={jest.fn()} />);
|
||||||
|
act(() => jest.advanceTimersByTime(300));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("agent-workspace")).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<GlobalChatbox open onClose={jest.fn()} />);
|
||||||
|
act(() => jest.runOnlyPendingTimers());
|
||||||
|
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
mockCurrentProjectId = "project-2";
|
||||||
|
rerender(<GlobalChatbox open onClose={jest.fn()} />);
|
||||||
|
act(() => jest.runOnlyPendingTimers());
|
||||||
|
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,7 +42,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
const isNearBottomRef = useRef(true);
|
const isNearBottomRef = useRef(true);
|
||||||
const streamingScrollFrameRef = useRef<number | null>(null);
|
const streamingScrollFrameRef = useRef<number | null>(null);
|
||||||
const composerRef = useRef<AgentComposerHandle | null>(null);
|
const composerRef = useRef<AgentComposerHandle | null>(null);
|
||||||
const hasResetForOpenRef = useRef(false);
|
const initializedProjectIdRef = useRef<string | null | undefined>(undefined);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { open: openNotification } = useNotification();
|
const { open: openNotification } = useNotification();
|
||||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||||
@@ -154,6 +154,17 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
isNearBottomRef.current = isNearBottom;
|
isNearBottomRef.current = isNearBottom;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const resetConversationView = useCallback(() => {
|
||||||
|
composerRef.current?.clear();
|
||||||
|
setIsHistoryOpen(false);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
composerRef.current?.focus();
|
||||||
|
isNearBottomRef.current = true;
|
||||||
|
cancelStreamingScroll();
|
||||||
|
scrollToBottom("auto");
|
||||||
|
}, 0);
|
||||||
|
}, [cancelStreamingScroll, scrollToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
if (!isNearBottomRef.current) return;
|
if (!isNearBottomRef.current) return;
|
||||||
@@ -178,24 +189,18 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (
|
||||||
hasResetForOpenRef.current = false;
|
!open ||
|
||||||
|
isHydrating ||
|
||||||
|
initializedProjectIdRef.current === currentProjectId
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasResetForOpenRef.current || isHydrating) return;
|
|
||||||
hasResetForOpenRef.current = true;
|
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
initializedProjectIdRef.current = currentProjectId;
|
||||||
createSession();
|
createSession();
|
||||||
composerRef.current?.clear();
|
resetConversationView();
|
||||||
setIsHistoryOpen(false);
|
}, [createSession, currentProjectId, isHydrating, open, resetConversationView]);
|
||||||
composerRef.current?.focus();
|
|
||||||
isNearBottomRef.current = true;
|
|
||||||
cancelStreamingScroll();
|
|
||||||
scrollToBottom("auto");
|
|
||||||
}, 0);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [cancelStreamingScroll, createSession, isHydrating, open, scrollToBottom]);
|
|
||||||
|
|
||||||
const handleSend = useCallback(async (prompt: string) => {
|
const handleSend = useCallback(async (prompt: string) => {
|
||||||
if (isStreaming || isCheckingAuth) return;
|
if (isStreaming || isCheckingAuth) return;
|
||||||
@@ -230,14 +235,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
handleStopSpeech();
|
handleStopSpeech();
|
||||||
stopListening();
|
stopListening();
|
||||||
createSession();
|
createSession();
|
||||||
composerRef.current?.clear();
|
resetConversationView();
|
||||||
window.setTimeout(() => {
|
}, [createSession, handleStopSpeech, resetConversationView, stopListening]);
|
||||||
composerRef.current?.focus();
|
|
||||||
isNearBottomRef.current = true;
|
|
||||||
cancelStreamingScroll();
|
|
||||||
scrollToBottom("auto");
|
|
||||||
}, 0);
|
|
||||||
}, [cancelStreamingScroll, createSession, handleStopSpeech, scrollToBottom, stopListening]);
|
|
||||||
|
|
||||||
const handleHistoryToggle = useCallback(() => {
|
const handleHistoryToggle = useCallback(() => {
|
||||||
setIsHistoryOpen((prev) => !prev);
|
setIsHistoryOpen((prev) => !prev);
|
||||||
@@ -311,6 +310,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
hideBackdrop
|
hideBackdrop
|
||||||
disableScrollLock
|
disableScrollLock
|
||||||
disableEnforceFocus
|
disableEnforceFocus
|
||||||
|
ModalProps={{ keepMounted: true }}
|
||||||
sx={{
|
sx={{
|
||||||
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
|
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
|
|||||||
@@ -14,8 +14,21 @@ import mapboxStreets from "@assets/map/layers/mapbox-streets.png";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { MAPBOX_TOKEN, TIANDITU_TOKEN } from "@config/config";
|
import { MAPBOX_TOKEN, TIANDITU_TOKEN } from "@config/config";
|
||||||
import type { Map as OlMap } from "ol";
|
import type { Map as OlMap } from "ol";
|
||||||
|
import { markMapResourcePersistent } from "../mapLifecycle";
|
||||||
|
|
||||||
const INITIAL_LAYER = "mapbox-light";
|
const INITIAL_LAYER = "mapbox-light";
|
||||||
|
const BASE_LAYER_METADATA = [
|
||||||
|
{ id: "mapbox-light", name: "默认地图", img: mapboxLight.src },
|
||||||
|
{ id: "mapbox-satellite", name: "卫星地图", img: mapboxSatellite.src },
|
||||||
|
{
|
||||||
|
id: "mapbox-satellite-streets",
|
||||||
|
name: "卫星街道地图",
|
||||||
|
img: mapboxSatelliteStreet.src,
|
||||||
|
},
|
||||||
|
{ id: "mapbox-streets", name: "街道地图", img: mapboxStreets.src },
|
||||||
|
{ id: "tianditu-vector", name: "天地图矢量", img: mapboxOutdoors.src },
|
||||||
|
{ id: "tianditu-image", name: "天地图影像", img: mapboxSatellite.src },
|
||||||
|
] as const;
|
||||||
|
|
||||||
const createTileLayer = (url: string, attributions: string) =>
|
const createTileLayer = (url: string, attributions: string) =>
|
||||||
new TileLayer({
|
new TileLayer({
|
||||||
@@ -77,46 +90,37 @@ const createBaseLayerEntries = () => {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "mapbox-light",
|
...BASE_LAYER_METADATA[0],
|
||||||
name: "默认地图",
|
|
||||||
layer: lightMapLayer,
|
layer: lightMapLayer,
|
||||||
img: mapboxLight.src,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mapbox-satellite",
|
...BASE_LAYER_METADATA[1],
|
||||||
name: "卫星地图",
|
|
||||||
layer: satelliteLayer,
|
layer: satelliteLayer,
|
||||||
img: mapboxSatellite.src,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mapbox-satellite-streets",
|
...BASE_LAYER_METADATA[2],
|
||||||
name: "卫星街道地图",
|
|
||||||
layer: satelliteStreetsLayer,
|
layer: satelliteStreetsLayer,
|
||||||
img: mapboxSatelliteStreet.src,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mapbox-streets",
|
...BASE_LAYER_METADATA[3],
|
||||||
name: "街道地图",
|
|
||||||
layer: streetsLayer,
|
layer: streetsLayer,
|
||||||
img: mapboxStreets.src,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tianditu-vector",
|
...BASE_LAYER_METADATA[4],
|
||||||
name: "天地图矢量",
|
|
||||||
layer: new Group({
|
layer: new Group({
|
||||||
layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer],
|
layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer],
|
||||||
}),
|
}),
|
||||||
img: mapboxOutdoors.src,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tianditu-image",
|
...BASE_LAYER_METADATA[5],
|
||||||
name: "天地图影像",
|
|
||||||
layer: new Group({
|
layer: new Group({
|
||||||
layers: [tiandituImageLayer, tiandituImageAnnotationLayer],
|
layers: [tiandituImageLayer, tiandituImageAnnotationLayer],
|
||||||
}),
|
}),
|
||||||
img: mapboxSatellite.src,
|
|
||||||
},
|
},
|
||||||
];
|
].map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
layer: markMapResourcePersistent(entry.layer),
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const BaseLayers: React.FC = () => {
|
const BaseLayers: React.FC = () => {
|
||||||
@@ -158,11 +162,7 @@ const BaseLayers: React.FC = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseLayers = useMemo(() => createBaseLayerEntries().map(({ id, name, img }) => ({
|
const baseLayers = BASE_LAYER_METADATA;
|
||||||
id,
|
|
||||||
name,
|
|
||||||
img,
|
|
||||||
})), []);
|
|
||||||
|
|
||||||
const handleQuickSwitch = () => {
|
const handleQuickSwitch = () => {
|
||||||
const nextId =
|
const nextId =
|
||||||
@@ -194,6 +194,15 @@ const BaseLayers: React.FC = () => {
|
|||||||
}, 300);
|
}, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (hideTimer.current) {
|
||||||
|
clearTimeout(hideTimer.current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute right-17 bottom-11 z-20">
|
<div className="absolute right-17 bottom-11 z-20">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState, useRef } from "react";
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
import { useMap } from "../MapComponent";
|
import { useMap } from "../MapComponent";
|
||||||
import { ScaleLine } from "ol/control";
|
import { ScaleLine } from "ol/control";
|
||||||
|
import { markMapResourcePersistent } from "../mapLifecycle";
|
||||||
|
|
||||||
const Scale: React.FC = () => {
|
const Scale: React.FC = () => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
@@ -31,14 +32,16 @@ const Scale: React.FC = () => {
|
|||||||
updateZoomLevel();
|
updateZoomLevel();
|
||||||
|
|
||||||
// ScaleLine control
|
// ScaleLine control
|
||||||
const scaleControl = new ScaleLine({
|
const scaleControl = markMapResourcePersistent(
|
||||||
|
new ScaleLine({
|
||||||
target: scaleLineRef.current || undefined,
|
target: scaleLineRef.current || undefined,
|
||||||
units: "metric",
|
units: "metric",
|
||||||
bar: false,
|
bar: false,
|
||||||
steps: 4,
|
steps: 4,
|
||||||
text: true,
|
text: true,
|
||||||
minWidth: 64,
|
minWidth: 64,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
map.addControl(scaleControl);
|
map.addControl(scaleControl);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -17,24 +17,21 @@ import MapTools from "./MapTools";
|
|||||||
|
|
||||||
// 导入 DeckLayer
|
// 导入 DeckLayer
|
||||||
import { DeckLayer } from "@utils/layers";
|
import { DeckLayer } from "@utils/layers";
|
||||||
import VectorTileSource from "ol/source/VectorTile";
|
|
||||||
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
|
||||||
import MVT from "ol/format/MVT";
|
|
||||||
import { FlatStyleLike } from "ol/style/flat";
|
|
||||||
import { toLonLat } from "ol/proj";
|
import { toLonLat } from "ol/proj";
|
||||||
import { along, bearing, lineString, length, toMercator } from "@turf/turf";
|
import { along, bearing, lineString, length } from "@turf/turf";
|
||||||
import { Deck } from "@deck.gl/core";
|
import { Deck } from "@deck.gl/core";
|
||||||
import { TextLayer } from "@deck.gl/layers";
|
import { TextLayer } from "@deck.gl/layers";
|
||||||
import { TripsLayer } from "@deck.gl/geo-layers";
|
import { TripsLayer } from "@deck.gl/geo-layers";
|
||||||
import { CollisionFilterExtension } from "@deck.gl/extensions";
|
import { CollisionFilterExtension } from "@deck.gl/extensions";
|
||||||
import VectorSource from "ol/source/Vector";
|
|
||||||
import GeoJson from "ol/format/GeoJSON";
|
|
||||||
import VectorLayer from "ol/layer/Vector";
|
|
||||||
import { Icon, Style } from "ol/style.js";
|
|
||||||
import { FeatureLike } from "ol/Feature";
|
|
||||||
import { Point } from "ol/geom";
|
|
||||||
import { ContourLayer } from "deck.gl";
|
import { ContourLayer } from "deck.gl";
|
||||||
import { toM3h } from "@utils/units";
|
import { toM3h } from "@utils/units";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import {
|
||||||
|
cleanupTransientMapResources,
|
||||||
|
disposeMapResources,
|
||||||
|
markMapResourcePersistent,
|
||||||
|
} from "./mapLifecycle";
|
||||||
|
import { createOperationalMapResources } from "./operationalLayers";
|
||||||
|
|
||||||
interface MapComponentProps {
|
interface MapComponentProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -131,6 +128,7 @@ export const useData = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||||
|
const pathname = usePathname();
|
||||||
const project = useProject();
|
const project = useProject();
|
||||||
const MAP_WORKSPACE = project?.workspace || config.MAP_WORKSPACE;
|
const MAP_WORKSPACE = project?.workspace || config.MAP_WORKSPACE;
|
||||||
const MAP_EXTENT = (project?.extent || config.MAP_EXTENT) as [
|
const MAP_EXTENT = (project?.extent || config.MAP_EXTENT) as [
|
||||||
@@ -371,402 +369,20 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
debouncedUpdateDataRef.current = null;
|
debouncedUpdateDataRef.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
// 配置地图数据源、图层和样式
|
const operationalResources = useMemo(
|
||||||
const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE;
|
() =>
|
||||||
// 定义 SCADA 图层的样式函数,根据 type 字段选择不同图标
|
createOperationalMapResources({
|
||||||
const scadaStyle = (feature: any) => {
|
mapUrl: MAP_URL,
|
||||||
const type = feature.get("type");
|
workspace: MAP_WORKSPACE,
|
||||||
const scadaPressureIcon = "/icons/scada_pressure.svg";
|
extent: MAP_EXTENT,
|
||||||
const scadaFlowIcon = "/icons/scada_flow.svg";
|
persistent: true,
|
||||||
// 如果 type 不匹配,可以设置默认图标或不显示
|
|
||||||
return new Style({
|
|
||||||
image: new Icon({
|
|
||||||
src: type === "pipe_flow" ? scadaFlowIcon : scadaPressureIcon,
|
|
||||||
scale: 0.1, // 根据需要调整图标大小
|
|
||||||
anchor: [0.5, 0.5], // 图标锚点居中
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
// 定义 reservoirs 图层的样式函数,使用固定图标
|
|
||||||
const reservoirStyle = () => {
|
|
||||||
const reserviorIcon = "/icons/reservior.svg";
|
|
||||||
return new Style({
|
|
||||||
image: new Icon({
|
|
||||||
src: reserviorIcon,
|
|
||||||
scale: 0.1, // 根据需要调整图标大小
|
|
||||||
anchor: [0.5, 0.5], // 图标锚点居中
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
// 定义 tanks 图层的样式函数,使用固定图标
|
|
||||||
const tankStyle = () => {
|
|
||||||
const tankIcon = "/icons/tank.svg";
|
|
||||||
return new Style({
|
|
||||||
image: new Icon({
|
|
||||||
src: tankIcon,
|
|
||||||
scale: 0.1, // 根据需要调整图标大小
|
|
||||||
anchor: [0.5, 0.5], // 图标锚点居中
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const valveStyle = {
|
|
||||||
"icon-src": "/icons/valve.svg",
|
|
||||||
"icon-scale": 0.1,
|
|
||||||
};
|
|
||||||
// 定义 pumps 图层的样式函数,使用固定图标
|
|
||||||
const pumpStyle = function (feature: FeatureLike) {
|
|
||||||
const styles = [];
|
|
||||||
const pumpIcon = "/icons/pump.svg";
|
|
||||||
|
|
||||||
const geometry = feature.getGeometry();
|
|
||||||
const lineCoords =
|
|
||||||
geometry?.getType() === "LineString"
|
|
||||||
? (geometry as any).getCoordinates()
|
|
||||||
: null;
|
|
||||||
if (geometry) {
|
|
||||||
const lineCoordsWGS84 = lineCoords.map((coord: []) => {
|
|
||||||
const [lon, lat] = toLonLat(coord);
|
|
||||||
return [lon, lat];
|
|
||||||
});
|
|
||||||
// 计算中点
|
|
||||||
const lineStringFeature = lineString(lineCoordsWGS84);
|
|
||||||
const lineLength = length(lineStringFeature);
|
|
||||||
const midPoint = along(lineStringFeature, lineLength / 2).geometry
|
|
||||||
.coordinates;
|
|
||||||
// 在中点添加 icon 样式
|
|
||||||
const midPointMercator = toMercator(midPoint);
|
|
||||||
styles.push(
|
|
||||||
new Style({
|
|
||||||
geometry: new Point(midPointMercator),
|
|
||||||
image: new Icon({
|
|
||||||
src: pumpIcon,
|
|
||||||
scale: 0.12,
|
|
||||||
anchor: [0.5, 0.5],
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
|
[MAP_URL, MAP_WORKSPACE, MAP_EXTENT],
|
||||||
);
|
);
|
||||||
}
|
const { junctions: junctionSource, pipes: pipeSource } =
|
||||||
return styles;
|
operationalResources.sources;
|
||||||
};
|
const { junctions: junctionsLayer, pipes: pipesLayer } =
|
||||||
// 矢量瓦片数据源和图层
|
operationalResources.layers;
|
||||||
const junctionSource = new VectorTileSource({
|
|
||||||
url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_junctions@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`, // 替换为你的 MVT 瓦片服务 URL
|
|
||||||
format: new MVT(),
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
});
|
|
||||||
const pipeSource = new VectorTileSource({
|
|
||||||
url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_pipes@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`, // 替换为你的 MVT 瓦片服务 URL
|
|
||||||
format: new MVT(),
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
});
|
|
||||||
const valveSource = new VectorTileSource({
|
|
||||||
url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_valves@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`, // 替换为你的 MVT 瓦片服务 URL
|
|
||||||
format: new MVT(),
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
});
|
|
||||||
const reservoirSource = new VectorSource({
|
|
||||||
url: `${MAP_URL}/${MAP_WORKSPACE}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${MAP_WORKSPACE}:geo_reservoirs&outputFormat=application/json`,
|
|
||||||
format: new GeoJson(),
|
|
||||||
});
|
|
||||||
const pumpSource = new VectorSource({
|
|
||||||
url: `${MAP_URL}/${MAP_WORKSPACE}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${MAP_WORKSPACE}:geo_pumps&outputFormat=application/json`,
|
|
||||||
format: new GeoJson(),
|
|
||||||
});
|
|
||||||
const tankSource = new VectorSource({
|
|
||||||
url: `${MAP_URL}/${MAP_WORKSPACE}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${MAP_WORKSPACE}:geo_tanks&outputFormat=application/json`,
|
|
||||||
format: new GeoJson(),
|
|
||||||
});
|
|
||||||
const scadaSource = new VectorSource({
|
|
||||||
url: `${MAP_URL}/${MAP_WORKSPACE}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${MAP_WORKSPACE}:geo_scada&outputFormat=application/json`,
|
|
||||||
format: new GeoJson(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// WebGL 渲染优化显示
|
|
||||||
const junctionsLayer = new WebGLVectorTileLayer({
|
|
||||||
source: junctionSource as any, // 使用 WebGL 渲染
|
|
||||||
style: defaultFlatStyle,
|
|
||||||
extent: MAP_EXTENT, // 设置图层范围
|
|
||||||
maxZoom: 24,
|
|
||||||
minZoom: 11,
|
|
||||||
properties: {
|
|
||||||
name: "节点", // 设置图层名称
|
|
||||||
value: "junctions",
|
|
||||||
type: "point",
|
|
||||||
properties: [
|
|
||||||
// { name: "需求量", value: "demand" },
|
|
||||||
{ name: "高程", value: "elevation" },
|
|
||||||
// 计算属性
|
|
||||||
{ name: "实际需水量", value: "actual_demand" },
|
|
||||||
{ name: "水头", value: "total_head" },
|
|
||||||
{ name: "压力", value: "pressure" },
|
|
||||||
{ name: "水质", value: "quality" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const pipesLayer = new WebGLVectorTileLayer({
|
|
||||||
source: pipeSource as any, // 使用 WebGL 渲染
|
|
||||||
style: defaultFlatStyle,
|
|
||||||
extent: MAP_EXTENT, // 设置图层范围
|
|
||||||
maxZoom: 24,
|
|
||||||
minZoom: 11,
|
|
||||||
properties: {
|
|
||||||
name: "管道", // 设置图层名称
|
|
||||||
value: "pipes",
|
|
||||||
type: "linestring",
|
|
||||||
properties: [
|
|
||||||
{ name: "管径", value: "diameter" },
|
|
||||||
// { name: "粗糙度", value: "roughness" },
|
|
||||||
// { name: "局部损失", value: "minor_loss" },
|
|
||||||
// 计算属性
|
|
||||||
{ name: "流量", value: "flow" },
|
|
||||||
{ name: "摩阻系数", value: "friction" },
|
|
||||||
{ name: "水头损失", value: "headloss" },
|
|
||||||
{ name: "单位水头损失", value: "unit_headloss" },
|
|
||||||
{ name: "水质", value: "quality" },
|
|
||||||
{ name: "反应速率", value: "reaction" },
|
|
||||||
{ name: "设置值", value: "setting" },
|
|
||||||
{ name: "状态", value: "status" },
|
|
||||||
{ name: "流速", value: "velocity" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const valvesLayer = new WebGLVectorTileLayer({
|
|
||||||
source: valveSource as any,
|
|
||||||
style: valveStyle,
|
|
||||||
extent: MAP_EXTENT, // 设置图层范围
|
|
||||||
maxZoom: 24,
|
|
||||||
minZoom: 16,
|
|
||||||
properties: {
|
|
||||||
name: "阀门", // 设置图层名称
|
|
||||||
value: "valves",
|
|
||||||
type: "linestring",
|
|
||||||
properties: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const reservoirsLayer = new VectorLayer({
|
|
||||||
source: reservoirSource,
|
|
||||||
style: reservoirStyle,
|
|
||||||
extent: MAP_EXTENT, // 设置图层范围
|
|
||||||
maxZoom: 24,
|
|
||||||
minZoom: 11,
|
|
||||||
properties: {
|
|
||||||
name: "水库", // 设置图层名称
|
|
||||||
value: "reservoirs",
|
|
||||||
type: "point",
|
|
||||||
properties: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const pumpsLayer = new VectorLayer({
|
|
||||||
source: pumpSource,
|
|
||||||
style: pumpStyle,
|
|
||||||
extent: MAP_EXTENT, // 设置图层范围
|
|
||||||
maxZoom: 24,
|
|
||||||
minZoom: 11,
|
|
||||||
properties: {
|
|
||||||
name: "水泵", // 设置图层名称
|
|
||||||
value: "pumps",
|
|
||||||
type: "linestring",
|
|
||||||
properties: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const tanksLayer = new VectorLayer({
|
|
||||||
source: tankSource,
|
|
||||||
style: tankStyle,
|
|
||||||
extent: MAP_EXTENT, // 设置图层范围
|
|
||||||
maxZoom: 24,
|
|
||||||
minZoom: 11,
|
|
||||||
properties: {
|
|
||||||
name: "水箱", // 设置图层名称
|
|
||||||
value: "tanks",
|
|
||||||
type: "point",
|
|
||||||
properties: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const scadaLayer = new VectorLayer({
|
|
||||||
source: scadaSource,
|
|
||||||
style: scadaStyle,
|
|
||||||
extent: MAP_EXTENT, // 设置图层范围
|
|
||||||
maxZoom: 24,
|
|
||||||
minZoom: 11,
|
|
||||||
properties: {
|
|
||||||
name: "SCADA", // 设置图层名称
|
|
||||||
value: "scada",
|
|
||||||
type: "point",
|
|
||||||
properties: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const createOperationalLayers = () => {
|
|
||||||
const nextJunctionSource = new VectorTileSource({
|
|
||||||
url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_junctions@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`,
|
|
||||||
format: new MVT(),
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
});
|
|
||||||
const nextPipeSource = new VectorTileSource({
|
|
||||||
url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_pipes@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`,
|
|
||||||
format: new MVT(),
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
});
|
|
||||||
const nextJunctionsLayer = new WebGLVectorTileLayer({
|
|
||||||
source: nextJunctionSource as any,
|
|
||||||
style: defaultFlatStyle,
|
|
||||||
extent: MAP_EXTENT,
|
|
||||||
maxZoom: 24,
|
|
||||||
minZoom: 11,
|
|
||||||
properties: {
|
|
||||||
name: "节点",
|
|
||||||
value: "junctions",
|
|
||||||
type: "point",
|
|
||||||
properties: [
|
|
||||||
{ name: "高程", value: "elevation" },
|
|
||||||
{ name: "实际需水量", value: "actual_demand" },
|
|
||||||
{ name: "水头", value: "total_head" },
|
|
||||||
{ name: "压力", value: "pressure" },
|
|
||||||
{ name: "水质", value: "quality" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const nextPipesLayer = new WebGLVectorTileLayer({
|
|
||||||
source: nextPipeSource as any,
|
|
||||||
style: defaultFlatStyle,
|
|
||||||
extent: MAP_EXTENT,
|
|
||||||
maxZoom: 24,
|
|
||||||
minZoom: 11,
|
|
||||||
properties: {
|
|
||||||
name: "管道",
|
|
||||||
value: "pipes",
|
|
||||||
type: "linestring",
|
|
||||||
properties: [
|
|
||||||
{ name: "管径", value: "diameter" },
|
|
||||||
{ name: "流量", value: "flow" },
|
|
||||||
{ name: "摩阻系数", value: "friction" },
|
|
||||||
{ name: "水头损失", value: "headloss" },
|
|
||||||
{ name: "单位水头损失", value: "unit_headloss" },
|
|
||||||
{ name: "水质", value: "quality" },
|
|
||||||
{ name: "反应速率", value: "reaction" },
|
|
||||||
{ name: "设置值", value: "setting" },
|
|
||||||
{ name: "状态", value: "status" },
|
|
||||||
{ name: "流速", value: "velocity" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const nextValvesLayer = new WebGLVectorTileLayer({
|
|
||||||
source: valveSource as any,
|
|
||||||
style: valveStyle,
|
|
||||||
extent: MAP_EXTENT,
|
|
||||||
maxZoom: 24,
|
|
||||||
minZoom: 16,
|
|
||||||
properties: {
|
|
||||||
name: "阀门",
|
|
||||||
value: "valves",
|
|
||||||
type: "linestring",
|
|
||||||
properties: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const nextReservoirsLayer = new VectorLayer({
|
|
||||||
source: reservoirSource,
|
|
||||||
style: reservoirStyle,
|
|
||||||
extent: MAP_EXTENT,
|
|
||||||
maxZoom: 24,
|
|
||||||
minZoom: 11,
|
|
||||||
properties: {
|
|
||||||
name: "水库",
|
|
||||||
value: "reservoirs",
|
|
||||||
type: "point",
|
|
||||||
properties: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const nextPumpsLayer = new VectorLayer({
|
|
||||||
source: pumpSource,
|
|
||||||
style: pumpStyle,
|
|
||||||
extent: MAP_EXTENT,
|
|
||||||
maxZoom: 24,
|
|
||||||
minZoom: 11,
|
|
||||||
properties: {
|
|
||||||
name: "水泵",
|
|
||||||
value: "pumps",
|
|
||||||
type: "linestring",
|
|
||||||
properties: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const nextTanksLayer = new VectorLayer({
|
|
||||||
source: tankSource,
|
|
||||||
style: tankStyle,
|
|
||||||
extent: MAP_EXTENT,
|
|
||||||
maxZoom: 24,
|
|
||||||
minZoom: 11,
|
|
||||||
properties: {
|
|
||||||
name: "水箱",
|
|
||||||
value: "tanks",
|
|
||||||
type: "point",
|
|
||||||
properties: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const nextScadaLayer = new VectorLayer({
|
|
||||||
source: scadaSource,
|
|
||||||
style: scadaStyle,
|
|
||||||
extent: MAP_EXTENT,
|
|
||||||
maxZoom: 24,
|
|
||||||
minZoom: 11,
|
|
||||||
properties: {
|
|
||||||
name: "SCADA",
|
|
||||||
value: "scada",
|
|
||||||
type: "point",
|
|
||||||
properties: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const availableLayers: any[] = [];
|
|
||||||
config.MAP_AVAILABLE_LAYERS.forEach((layerValue) => {
|
|
||||||
switch (layerValue) {
|
|
||||||
case "junctions":
|
|
||||||
availableLayers.push(nextJunctionsLayer);
|
|
||||||
break;
|
|
||||||
case "pipes":
|
|
||||||
availableLayers.push(nextPipesLayer);
|
|
||||||
break;
|
|
||||||
case "valves":
|
|
||||||
availableLayers.push(nextValvesLayer);
|
|
||||||
break;
|
|
||||||
case "reservoirs":
|
|
||||||
availableLayers.push(nextReservoirsLayer);
|
|
||||||
break;
|
|
||||||
case "pumps":
|
|
||||||
availableLayers.push(nextPumpsLayer);
|
|
||||||
break;
|
|
||||||
case "tanks":
|
|
||||||
availableLayers.push(nextTanksLayer);
|
|
||||||
break;
|
|
||||||
case "scada":
|
|
||||||
availableLayers.push(nextScadaLayer);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
availableLayers.sort((a, b) => {
|
|
||||||
const order = [
|
|
||||||
"valves",
|
|
||||||
"junctions",
|
|
||||||
"scada",
|
|
||||||
"reservoirs",
|
|
||||||
"pumps",
|
|
||||||
"tanks",
|
|
||||||
"pipes",
|
|
||||||
].reverse();
|
|
||||||
const getValue = (layer: any) => {
|
|
||||||
const props = layer.get ? layer.get("properties") : undefined;
|
|
||||||
return (props && props.value) || layer.get?.("value") || "";
|
|
||||||
};
|
|
||||||
const aVal = getValue(a);
|
|
||||||
const bVal = getValue(b);
|
|
||||||
let ia = order.indexOf(aVal);
|
|
||||||
let ib = order.indexOf(bVal);
|
|
||||||
if (ia === -1) ia = order.length;
|
|
||||||
if (ib === -1) ib = order.length;
|
|
||||||
return ia - ib;
|
|
||||||
});
|
|
||||||
|
|
||||||
return availableLayers;
|
|
||||||
};
|
|
||||||
|
|
||||||
// The map and layer instances are intentionally rebuilt only when workspace or extent changes.
|
// The map and layer instances are intentionally rebuilt only when workspace or extent changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -775,6 +391,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isDisposingRef.current = false;
|
isDisposingRef.current = false;
|
||||||
|
const activeJunctionDataIds = junctionDataIds.current;
|
||||||
|
const activePipeDataIds = pipeDataIds.current;
|
||||||
|
|
||||||
const addTimeout = (callback: () => void, delay: number) => {
|
const addTimeout = (callback: () => void, delay: number) => {
|
||||||
const timerId = window.setTimeout(() => {
|
const timerId = window.setTimeout(() => {
|
||||||
@@ -927,58 +545,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
// 添加事件监听器
|
// 添加事件监听器
|
||||||
junctionsLayer.on("change:visible", handleJunctionVisibilityChange);
|
junctionsLayer.on("change:visible", handleJunctionVisibilityChange);
|
||||||
pipesLayer.on("change:visible", handlePipeVisibilityChange);
|
pipesLayer.on("change:visible", handlePipeVisibilityChange);
|
||||||
const availableLayers: any[] = [];
|
|
||||||
config.MAP_AVAILABLE_LAYERS.forEach((layerValue) => {
|
|
||||||
switch (layerValue) {
|
|
||||||
case "junctions":
|
|
||||||
availableLayers.push(junctionsLayer);
|
|
||||||
break;
|
|
||||||
case "pipes":
|
|
||||||
availableLayers.push(pipesLayer);
|
|
||||||
break;
|
|
||||||
case "valves":
|
|
||||||
availableLayers.push(valvesLayer);
|
|
||||||
break;
|
|
||||||
case "reservoirs":
|
|
||||||
availableLayers.push(reservoirsLayer);
|
|
||||||
break;
|
|
||||||
case "pumps":
|
|
||||||
availableLayers.push(pumpsLayer);
|
|
||||||
break;
|
|
||||||
case "tanks":
|
|
||||||
availableLayers.push(tanksLayer);
|
|
||||||
break;
|
|
||||||
case "scada":
|
|
||||||
availableLayers.push(scadaLayer);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// 重新排列图层顺序,确保顺序 点>线>面
|
|
||||||
availableLayers.sort((a, b) => {
|
|
||||||
// 明确顺序(点类优先),这里 valves 特殊处理
|
|
||||||
const order = [
|
|
||||||
"valves",
|
|
||||||
"junctions",
|
|
||||||
"scada",
|
|
||||||
"reservoirs",
|
|
||||||
"pumps",
|
|
||||||
"tanks",
|
|
||||||
"pipes",
|
|
||||||
].reverse();
|
|
||||||
// 取值时做安全检查,兼容不同写法(properties.value 或 直接 value)
|
|
||||||
const getValue = (layer: any) => {
|
|
||||||
const props = layer.get ? layer.get("properties") : undefined;
|
|
||||||
return (props && props.value) || layer.get?.("value") || "";
|
|
||||||
};
|
|
||||||
const aVal = getValue(a);
|
|
||||||
const bVal = getValue(b);
|
|
||||||
let ia = order.indexOf(aVal);
|
|
||||||
let ib = order.indexOf(bVal);
|
|
||||||
// 如果未在 order 中找到,放到末尾
|
|
||||||
if (ia === -1) ia = order.length;
|
|
||||||
if (ib === -1) ib = order.length;
|
|
||||||
return ia - ib;
|
|
||||||
});
|
|
||||||
const map = new OlMap({
|
const map = new OlMap({
|
||||||
target: mapRef.current,
|
target: mapRef.current,
|
||||||
view: new View({
|
view: new View({
|
||||||
@@ -986,9 +552,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
projection: "EPSG:3857",
|
projection: "EPSG:3857",
|
||||||
}),
|
}),
|
||||||
// 图层依面、线、点、标注次序添加
|
// 图层依面、线、点、标注次序添加
|
||||||
layers: availableLayers.slice(),
|
layers: operationalResources.orderedLayers.slice(),
|
||||||
controls: [],
|
controls: [],
|
||||||
});
|
});
|
||||||
|
map.getInteractions().forEach(markMapResourcePersistent);
|
||||||
setMap(map);
|
setMap(map);
|
||||||
|
|
||||||
// 恢复上次视图;如果没有则适配 MAP_EXTENT
|
// 恢复上次视图;如果没有则适配 MAP_EXTENT
|
||||||
@@ -1072,10 +639,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
controller: false, // 由 OpenLayers 控制视图
|
controller: false, // 由 OpenLayers 控制视图
|
||||||
layers: [],
|
layers: [],
|
||||||
});
|
});
|
||||||
const deckLayer = new DeckLayer(deck, canvasRef.current, {
|
const deckLayer = markMapResourcePersistent(
|
||||||
|
new DeckLayer(deck, canvasRef.current, {
|
||||||
name: "deckLayer",
|
name: "deckLayer",
|
||||||
value: "deckLayer",
|
value: "deckLayer",
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
deckLayerRef.current = deckLayer;
|
deckLayerRef.current = deckLayer;
|
||||||
setDeckLayer(deckLayer);
|
setDeckLayer(deckLayer);
|
||||||
map.addLayer(deckLayer);
|
map.addLayer(deckLayer);
|
||||||
@@ -1101,8 +670,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
}
|
}
|
||||||
deckLayerRef.current = null;
|
deckLayerRef.current = null;
|
||||||
setDeckLayer(undefined);
|
setDeckLayer(undefined);
|
||||||
map.setTarget(undefined);
|
// React Strict Mode re-runs effects with the same memoized layer instances.
|
||||||
map.dispose();
|
// Detach and clear them here, but leave final layer/source disposal to GC.
|
||||||
|
disposeMapResources(map, { disposeLayers: false });
|
||||||
|
activeJunctionDataIds.clear();
|
||||||
|
activePipeDataIds.clear();
|
||||||
|
tileJunctionDataBuffer.current = [];
|
||||||
|
tilePipeDataBuffer.current = [];
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [MAP_WORKSPACE, MAP_EXTENT]);
|
}, [MAP_WORKSPACE, MAP_EXTENT]);
|
||||||
@@ -1117,11 +691,15 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
if (!map || !compareMapRef.current || !compareCanvasRef.current) return;
|
if (!map || !compareMapRef.current || !compareCanvasRef.current) return;
|
||||||
|
|
||||||
isCompareDisposingRef.current = false;
|
isCompareDisposingRef.current = false;
|
||||||
const availableLayers = createOperationalLayers();
|
const compareResources = createOperationalMapResources({
|
||||||
|
mapUrl: MAP_URL,
|
||||||
|
workspace: MAP_WORKSPACE,
|
||||||
|
extent: MAP_EXTENT,
|
||||||
|
});
|
||||||
const nextCompareMap = new OlMap({
|
const nextCompareMap = new OlMap({
|
||||||
target: compareMapRef.current,
|
target: compareMapRef.current,
|
||||||
view: map.getView(),
|
view: map.getView(),
|
||||||
layers: availableLayers.slice(),
|
layers: compareResources.orderedLayers.slice(),
|
||||||
controls: [],
|
controls: [],
|
||||||
});
|
});
|
||||||
nextCompareMap.getAllLayers().forEach((layer) => {
|
nextCompareMap.getAllLayers().forEach((layer) => {
|
||||||
@@ -1180,8 +758,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
compareDeckLayerRef.current = null;
|
compareDeckLayerRef.current = null;
|
||||||
setCompareDeckLayer(undefined);
|
setCompareDeckLayer(undefined);
|
||||||
setCompareMap(undefined);
|
setCompareMap(undefined);
|
||||||
nextCompareMap.setTarget(undefined);
|
disposeMapResources(nextCompareMap);
|
||||||
nextCompareMap.dispose();
|
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isCompareMode, map]);
|
}, [isCompareMode, map]);
|
||||||
@@ -1197,6 +774,34 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
};
|
};
|
||||||
}, [compareMap, isCompareMode, map]);
|
}, [compareMap, isCompareMode, map]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentTime(-1);
|
||||||
|
setSelectedDate(new Date());
|
||||||
|
setSchemeName("");
|
||||||
|
setCurrentJunctionCalData([]);
|
||||||
|
setCurrentPipeCalData([]);
|
||||||
|
setCompareJunctionCalData([]);
|
||||||
|
setComparePipeCalData([]);
|
||||||
|
setCompareMode(false);
|
||||||
|
setShowJunctionTextLayer(false);
|
||||||
|
setShowPipeTextLayer(false);
|
||||||
|
setShowJunctionId(false);
|
||||||
|
setShowPipeId(false);
|
||||||
|
setShowContourLayer(false);
|
||||||
|
setContours([]);
|
||||||
|
setContourLayerAvailable(false);
|
||||||
|
setWaterflowLayerAvailable(false);
|
||||||
|
setShowWaterflowLayer(false);
|
||||||
|
setForceStyleAutoApplyVersion(0);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!map) return;
|
||||||
|
cleanupTransientMapResources(map);
|
||||||
|
deckLayerRef.current?.resetSessionLayers();
|
||||||
|
operationalResources.resetStyles();
|
||||||
|
};
|
||||||
|
}, [pathname, map, operationalResources]);
|
||||||
|
|
||||||
// 当数据变化时,更新 deck.gl 图层
|
// 当数据变化时,更新 deck.gl 图层
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncDeckOverlay = (
|
const syncDeckOverlay = (
|
||||||
@@ -1348,7 +953,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (junctionTextLayer && targetDeckLayer.getDeckLayerById("junctionTextLayer")) {
|
if (
|
||||||
|
junctionTextLayer &&
|
||||||
|
targetDeckLayer.getDeckLayerById("junctionTextLayer")
|
||||||
|
) {
|
||||||
targetDeckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer);
|
targetDeckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer);
|
||||||
} else if (junctionTextLayer) {
|
} else if (junctionTextLayer) {
|
||||||
targetDeckLayer.addDeckLayer(junctionTextLayer);
|
targetDeckLayer.addDeckLayer(junctionTextLayer);
|
||||||
@@ -1358,7 +966,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
} else if (pipeTextLayer) {
|
} else if (pipeTextLayer) {
|
||||||
targetDeckLayer.addDeckLayer(pipeTextLayer);
|
targetDeckLayer.addDeckLayer(pipeTextLayer);
|
||||||
}
|
}
|
||||||
if (contourLayer && targetDeckLayer.getDeckLayerById("junctionContourLayer")) {
|
if (
|
||||||
|
contourLayer &&
|
||||||
|
targetDeckLayer.getDeckLayerById("junctionContourLayer")
|
||||||
|
) {
|
||||||
targetDeckLayer.updateDeckLayer("junctionContourLayer", contourLayer);
|
targetDeckLayer.updateDeckLayer("junctionContourLayer", contourLayer);
|
||||||
} else if (contourLayer) {
|
} else if (contourLayer) {
|
||||||
targetDeckLayer.addDeckLayer(contourLayer);
|
targetDeckLayer.addDeckLayer(contourLayer);
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
cleanupTransientMapResources,
|
||||||
|
disposeMapResources,
|
||||||
|
isMapResourcePersistent,
|
||||||
|
markMapResourcePersistent,
|
||||||
|
} from "./mapLifecycle";
|
||||||
|
|
||||||
|
const createCollection = <T,>(items: T[]) => ({
|
||||||
|
getArray: () => items,
|
||||||
|
clear: () => items.splice(0, items.length),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createResource = () => {
|
||||||
|
const properties = new Map<string, unknown>();
|
||||||
|
return {
|
||||||
|
get: (key: string) => properties.get(key),
|
||||||
|
set: (key: string, value: unknown) => properties.set(key, value),
|
||||||
|
dispose: jest.fn(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("map lifecycle", () => {
|
||||||
|
it("preserves shared resources and releases session resources", () => {
|
||||||
|
const persistentLayer = markMapResourcePersistent(createResource());
|
||||||
|
const transientSource = { clear: jest.fn(), dispose: jest.fn() };
|
||||||
|
const transientLayer = {
|
||||||
|
...createResource(),
|
||||||
|
getSource: () => transientSource,
|
||||||
|
};
|
||||||
|
const persistentInteraction = markMapResourcePersistent(createResource());
|
||||||
|
const transientInteraction = createResource();
|
||||||
|
const persistentControl = markMapResourcePersistent(createResource());
|
||||||
|
const transientControl = createResource();
|
||||||
|
|
||||||
|
const layers = [persistentLayer, transientLayer];
|
||||||
|
const interactions = [persistentInteraction, transientInteraction];
|
||||||
|
const controls = [persistentControl, transientControl];
|
||||||
|
const overlays: ReturnType<typeof createResource>[] = [];
|
||||||
|
const map = {
|
||||||
|
getLayers: () => createCollection(layers),
|
||||||
|
removeLayer: (resource: unknown) => layers.splice(layers.indexOf(resource as never), 1),
|
||||||
|
getInteractions: () => createCollection(interactions),
|
||||||
|
removeInteraction: (resource: unknown) =>
|
||||||
|
interactions.splice(interactions.indexOf(resource as never), 1),
|
||||||
|
getControls: () => createCollection(controls),
|
||||||
|
removeControl: (resource: unknown) => controls.splice(controls.indexOf(resource as never), 1),
|
||||||
|
getOverlays: () => createCollection(overlays),
|
||||||
|
removeOverlay: (resource: unknown) => overlays.splice(overlays.indexOf(resource as never), 1),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
cleanupTransientMapResources(map);
|
||||||
|
|
||||||
|
expect(layers).toEqual([persistentLayer]);
|
||||||
|
expect(interactions).toEqual([persistentInteraction]);
|
||||||
|
expect(controls).toEqual([persistentControl]);
|
||||||
|
expect(transientSource.clear).toHaveBeenCalledTimes(1);
|
||||||
|
expect(transientSource.dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(transientLayer.dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(transientInteraction.dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(transientControl.dispose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks only explicitly shared resources as persistent", () => {
|
||||||
|
const resource = createResource();
|
||||||
|
expect(isMapResourcePersistent(resource)).toBe(false);
|
||||||
|
markMapResourcePersistent(resource);
|
||||||
|
expect(isMapResourcePersistent(resource)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps memoized layers reusable across Strict Mode effect cleanup", () => {
|
||||||
|
const source = { clear: jest.fn(), dispose: jest.fn() };
|
||||||
|
const layer = { ...createResource(), getSource: () => source };
|
||||||
|
const layers = [layer];
|
||||||
|
const interactions: ReturnType<typeof createResource>[] = [];
|
||||||
|
const controls: ReturnType<typeof createResource>[] = [];
|
||||||
|
const overlays: ReturnType<typeof createResource>[] = [];
|
||||||
|
const map = {
|
||||||
|
getLayers: () => createCollection(layers),
|
||||||
|
removeLayer: (resource: unknown) => layers.splice(layers.indexOf(resource as never), 1),
|
||||||
|
getInteractions: () => createCollection(interactions),
|
||||||
|
getControls: () => createCollection(controls),
|
||||||
|
getOverlays: () => createCollection(overlays),
|
||||||
|
setTarget: jest.fn(),
|
||||||
|
dispose: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
disposeMapResources(map, { disposeLayers: false });
|
||||||
|
|
||||||
|
expect(source.clear).toHaveBeenCalledTimes(1);
|
||||||
|
expect(source.dispose).not.toHaveBeenCalled();
|
||||||
|
expect(layer.dispose).not.toHaveBeenCalled();
|
||||||
|
expect(map.dispose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { Map as OlMap } from "ol";
|
||||||
|
|
||||||
|
const RESOURCE_SCOPE_KEY = "tjwater_resource_scope";
|
||||||
|
const PERSISTENT_SCOPE = "persistent";
|
||||||
|
|
||||||
|
type MapResource = {
|
||||||
|
get?: (key: string) => unknown;
|
||||||
|
set?: (key: string, value: unknown, silent?: boolean) => void;
|
||||||
|
dispose?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markMapResourcePersistent = <T extends MapResource>(resource: T): T => {
|
||||||
|
resource.set?.(RESOURCE_SCOPE_KEY, PERSISTENT_SCOPE, true);
|
||||||
|
return resource;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isMapResourcePersistent = (resource: MapResource) =>
|
||||||
|
resource.get?.(RESOURCE_SCOPE_KEY) === PERSISTENT_SCOPE;
|
||||||
|
|
||||||
|
const removeTransientResources = <T extends MapResource>(
|
||||||
|
resources: T[],
|
||||||
|
remove: (resource: T) => void,
|
||||||
|
) => {
|
||||||
|
[...resources].forEach((resource) => {
|
||||||
|
if (isMapResourcePersistent(resource)) return;
|
||||||
|
remove(resource);
|
||||||
|
resource.dispose?.();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const releaseLayer = (layer: any, dispose: boolean) => {
|
||||||
|
const childLayers = layer.getLayers?.().getArray?.();
|
||||||
|
if (Array.isArray(childLayers)) {
|
||||||
|
[...childLayers].forEach((childLayer) => releaseLayer(childLayer, dispose));
|
||||||
|
layer.getLayers().clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = layer.getSource?.();
|
||||||
|
try {
|
||||||
|
source?.clear?.();
|
||||||
|
} catch {
|
||||||
|
// Some third-party sources do not support explicit cache clearing.
|
||||||
|
}
|
||||||
|
if (dispose) {
|
||||||
|
try {
|
||||||
|
source?.dispose?.();
|
||||||
|
} catch {
|
||||||
|
// Source may already be disposed by its owning layer.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
layer.dispose?.();
|
||||||
|
} catch {
|
||||||
|
// Cleanup is deliberately idempotent for overlapping route unmounts.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cleanupTransientMapResources = (map: OlMap) => {
|
||||||
|
[...map.getLayers().getArray()].forEach((layer) => {
|
||||||
|
if (isMapResourcePersistent(layer)) return;
|
||||||
|
map.removeLayer(layer);
|
||||||
|
releaseLayer(layer, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
removeTransientResources(map.getInteractions().getArray(), (interaction) =>
|
||||||
|
map.removeInteraction(interaction),
|
||||||
|
);
|
||||||
|
removeTransientResources(map.getControls().getArray(), (control) =>
|
||||||
|
map.removeControl(control),
|
||||||
|
);
|
||||||
|
removeTransientResources(map.getOverlays().getArray(), (overlay) =>
|
||||||
|
map.removeOverlay(overlay),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const disposeMapResources = (
|
||||||
|
map: OlMap,
|
||||||
|
options: { disposeLayers?: boolean } = {},
|
||||||
|
) => {
|
||||||
|
const disposeLayers = options.disposeLayers ?? true;
|
||||||
|
[...map.getLayers().getArray()].forEach((layer) => {
|
||||||
|
map.removeLayer(layer);
|
||||||
|
releaseLayer(layer, disposeLayers);
|
||||||
|
});
|
||||||
|
map.getInteractions().clear();
|
||||||
|
map.getControls().clear();
|
||||||
|
map.getOverlays().clear();
|
||||||
|
map.setTarget(undefined);
|
||||||
|
map.dispose();
|
||||||
|
};
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { config } from "@/config/config";
|
||||||
|
import { along, lineString, length, toMercator } from "@turf/turf";
|
||||||
|
import type { FeatureLike } from "ol/Feature";
|
||||||
|
import MVT from "ol/format/MVT";
|
||||||
|
import { Point } from "ol/geom";
|
||||||
|
import type BaseLayer from "ol/layer/Base";
|
||||||
|
import VectorLayer from "ol/layer/Vector";
|
||||||
|
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||||
|
import { toLonLat } from "ol/proj";
|
||||||
|
import GeoJSON from "ol/format/GeoJSON";
|
||||||
|
import VectorSource from "ol/source/Vector";
|
||||||
|
import VectorTileSource from "ol/source/VectorTile";
|
||||||
|
import { Icon, Style } from "ol/style";
|
||||||
|
import type { FlatStyleLike } from "ol/style/flat";
|
||||||
|
|
||||||
|
import { markMapResourcePersistent } from "./mapLifecycle";
|
||||||
|
|
||||||
|
type MapExtent = [number, number, number, number];
|
||||||
|
|
||||||
|
type CreateOperationalLayersOptions = {
|
||||||
|
mapUrl: string;
|
||||||
|
workspace: string;
|
||||||
|
extent: MapExtent;
|
||||||
|
persistent?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultFlatStyle = config.MAP_DEFAULT_STYLE as FlatStyleLike;
|
||||||
|
const valveStyle = {
|
||||||
|
"icon-src": "/icons/valve.svg",
|
||||||
|
"icon-scale": 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const createIconStyle = (src: string, scale = 0.1) =>
|
||||||
|
new Style({ image: new Icon({ src, scale, anchor: [0.5, 0.5] }) });
|
||||||
|
|
||||||
|
const scadaStyle = (feature: FeatureLike) =>
|
||||||
|
createIconStyle(
|
||||||
|
feature.get("type") === "pipe_flow"
|
||||||
|
? "/icons/scada_flow.svg"
|
||||||
|
: "/icons/scada_pressure.svg",
|
||||||
|
);
|
||||||
|
|
||||||
|
const pumpStyle = (feature: FeatureLike) => {
|
||||||
|
const geometry = feature.getGeometry();
|
||||||
|
if (!geometry || geometry.getType() !== "LineString") return [];
|
||||||
|
|
||||||
|
const coordinates = (geometry as any)
|
||||||
|
.getCoordinates()
|
||||||
|
.map((coordinate: number[]) => toLonLat(coordinate));
|
||||||
|
if (coordinates.length < 2) return [];
|
||||||
|
|
||||||
|
const featureLine = lineString(coordinates);
|
||||||
|
const midpoint = along(featureLine, length(featureLine) / 2).geometry
|
||||||
|
.coordinates;
|
||||||
|
return [
|
||||||
|
new Style({
|
||||||
|
geometry: new Point(toMercator(midpoint)),
|
||||||
|
image: new Icon({
|
||||||
|
src: "/icons/pump.svg",
|
||||||
|
scale: 0.12,
|
||||||
|
anchor: [0.5, 0.5],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const pointProperties = [
|
||||||
|
{ name: "高程", value: "elevation" },
|
||||||
|
{ name: "实际需水量", value: "actual_demand" },
|
||||||
|
{ name: "水头", value: "total_head" },
|
||||||
|
{ name: "压力", value: "pressure" },
|
||||||
|
{ name: "水质", value: "quality" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const pipeProperties = [
|
||||||
|
{ name: "管径", value: "diameter" },
|
||||||
|
{ name: "流量", value: "flow" },
|
||||||
|
{ name: "摩阻系数", value: "friction" },
|
||||||
|
{ name: "水头损失", value: "headloss" },
|
||||||
|
{ name: "单位水头损失", value: "unit_headloss" },
|
||||||
|
{ name: "水质", value: "quality" },
|
||||||
|
{ name: "反应速率", value: "reaction" },
|
||||||
|
{ name: "设置值", value: "setting" },
|
||||||
|
{ name: "状态", value: "status" },
|
||||||
|
{ name: "流速", value: "velocity" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const createOperationalMapResources = ({
|
||||||
|
mapUrl,
|
||||||
|
workspace,
|
||||||
|
extent,
|
||||||
|
persistent = false,
|
||||||
|
}: CreateOperationalLayersOptions) => {
|
||||||
|
const vectorTileUrl = (name: string) =>
|
||||||
|
`${mapUrl}/gwc/service/tms/1.0.0/${workspace}:${name}@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`;
|
||||||
|
const vectorUrl = (name: string) =>
|
||||||
|
`${mapUrl}/${workspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${workspace}:${name}&outputFormat=application/json`;
|
||||||
|
|
||||||
|
const sources = {
|
||||||
|
junctions: new VectorTileSource({
|
||||||
|
url: vectorTileUrl("geo_junctions"),
|
||||||
|
format: new MVT(),
|
||||||
|
projection: "EPSG:3857",
|
||||||
|
}),
|
||||||
|
pipes: new VectorTileSource({
|
||||||
|
url: vectorTileUrl("geo_pipes"),
|
||||||
|
format: new MVT(),
|
||||||
|
projection: "EPSG:3857",
|
||||||
|
}),
|
||||||
|
valves: new VectorTileSource({
|
||||||
|
url: vectorTileUrl("geo_valves"),
|
||||||
|
format: new MVT(),
|
||||||
|
projection: "EPSG:3857",
|
||||||
|
}),
|
||||||
|
reservoirs: new VectorSource({
|
||||||
|
url: vectorUrl("geo_reservoirs"),
|
||||||
|
format: new GeoJSON(),
|
||||||
|
}),
|
||||||
|
pumps: new VectorSource({
|
||||||
|
url: vectorUrl("geo_pumps"),
|
||||||
|
format: new GeoJSON(),
|
||||||
|
}),
|
||||||
|
tanks: new VectorSource({
|
||||||
|
url: vectorUrl("geo_tanks"),
|
||||||
|
format: new GeoJSON(),
|
||||||
|
}),
|
||||||
|
scada: new VectorSource({
|
||||||
|
url: vectorUrl("geo_scada"),
|
||||||
|
format: new GeoJSON(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const layers = {
|
||||||
|
junctions: new WebGLVectorTileLayer({
|
||||||
|
source: sources.junctions as any,
|
||||||
|
style: defaultFlatStyle,
|
||||||
|
extent,
|
||||||
|
maxZoom: 24,
|
||||||
|
minZoom: 11,
|
||||||
|
properties: {
|
||||||
|
name: "节点",
|
||||||
|
value: "junctions",
|
||||||
|
type: "point",
|
||||||
|
properties: pointProperties,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
pipes: new WebGLVectorTileLayer({
|
||||||
|
source: sources.pipes as any,
|
||||||
|
style: defaultFlatStyle,
|
||||||
|
extent,
|
||||||
|
maxZoom: 24,
|
||||||
|
minZoom: 11,
|
||||||
|
properties: {
|
||||||
|
name: "管道",
|
||||||
|
value: "pipes",
|
||||||
|
type: "linestring",
|
||||||
|
properties: pipeProperties,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
valves: new WebGLVectorTileLayer({
|
||||||
|
source: sources.valves as any,
|
||||||
|
style: valveStyle,
|
||||||
|
extent,
|
||||||
|
maxZoom: 24,
|
||||||
|
minZoom: 16,
|
||||||
|
properties: {
|
||||||
|
name: "阀门",
|
||||||
|
value: "valves",
|
||||||
|
type: "linestring",
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
reservoirs: new VectorLayer({
|
||||||
|
source: sources.reservoirs,
|
||||||
|
style: () => createIconStyle("/icons/reservior.svg"),
|
||||||
|
extent,
|
||||||
|
maxZoom: 24,
|
||||||
|
minZoom: 11,
|
||||||
|
properties: {
|
||||||
|
name: "水库",
|
||||||
|
value: "reservoirs",
|
||||||
|
type: "point",
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
pumps: new VectorLayer({
|
||||||
|
source: sources.pumps,
|
||||||
|
style: pumpStyle,
|
||||||
|
extent,
|
||||||
|
maxZoom: 24,
|
||||||
|
minZoom: 11,
|
||||||
|
properties: {
|
||||||
|
name: "水泵",
|
||||||
|
value: "pumps",
|
||||||
|
type: "linestring",
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tanks: new VectorLayer({
|
||||||
|
source: sources.tanks,
|
||||||
|
style: () => createIconStyle("/icons/tank.svg"),
|
||||||
|
extent,
|
||||||
|
maxZoom: 24,
|
||||||
|
minZoom: 11,
|
||||||
|
properties: {
|
||||||
|
name: "水箱",
|
||||||
|
value: "tanks",
|
||||||
|
type: "point",
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
scada: new VectorLayer({
|
||||||
|
source: sources.scada,
|
||||||
|
style: scadaStyle,
|
||||||
|
extent,
|
||||||
|
maxZoom: 24,
|
||||||
|
minZoom: 11,
|
||||||
|
properties: {
|
||||||
|
name: "SCADA",
|
||||||
|
value: "scada",
|
||||||
|
type: "point",
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (persistent) {
|
||||||
|
Object.values(layers).forEach(markMapResourcePersistent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerById = layers as Record<string, BaseLayer>;
|
||||||
|
const enabledLayers = new Set(config.MAP_AVAILABLE_LAYERS);
|
||||||
|
const orderedLayers = [
|
||||||
|
"pipes",
|
||||||
|
"tanks",
|
||||||
|
"pumps",
|
||||||
|
"reservoirs",
|
||||||
|
"scada",
|
||||||
|
"junctions",
|
||||||
|
"valves",
|
||||||
|
]
|
||||||
|
.filter((id) => enabledLayers.has(id))
|
||||||
|
.map((id) => layerById[id]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sources,
|
||||||
|
layers,
|
||||||
|
orderedLayers,
|
||||||
|
resetStyles: () => {
|
||||||
|
layers.junctions.setStyle(defaultFlatStyle);
|
||||||
|
layers.pipes.setStyle(defaultFlatStyle);
|
||||||
|
layers.valves.setStyle(valveStyle);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -70,6 +70,12 @@ export class DeckLayer extends Layer {
|
|||||||
this.deck.setProps({ layers });
|
this.deck.setProps({ layers });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetSessionLayers(): void {
|
||||||
|
if (this.isDisposed) return;
|
||||||
|
this.userVisibility.clear();
|
||||||
|
this.deck.setProps({ layers: [] });
|
||||||
|
}
|
||||||
|
|
||||||
// 获取当前图层
|
// 获取当前图层
|
||||||
getDeckLayers(): any[] {
|
getDeckLayers(): any[] {
|
||||||
if (this.isDisposed) return [];
|
if (this.isDisposed) return [];
|
||||||
|
|||||||
Reference in New Issue
Block a user