19 Commits
Author SHA1 Message Date
jiang 0d559f6130 fix(scada): show backend detail on cleaning errors
Build Push and Deploy / docker-image (push) Successful in 1m3s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-07-17 16:27:40 +08:00
jiang 7af90e495d feat(burst-detection): update analysis parameters 2026-07-17 16:24:04 +08:00
jiang a52c04204d refactor(admin): simplify system management UI 2026-07-17 16:22:16 +08:00
jiang 08152ff978 fix(map): restore saved styles as inactive drafts 2026-07-17 15:28:44 +08:00
jiang 589cf45aa7 fix(map): stabilize tiled style rendering 2026-07-17 15:12:10 +08:00
jiang 59447a100c fix(scada): align device panel collapse animation 2026-07-17 14:25:03 +08:00
jiang 4adbcc1c4c fix(dma): defer leakage flow validation 2026-07-17 11:45:58 +08:00
jiang 202f18332f fix(notification): clarify query and progress feedback 2026-07-17 10:48:58 +08:00
jiang acf13639ef fix(map): allow selecting features under overlays 2026-07-17 10:21:16 +08:00
jiang d986e563a6 fix(map): convert actual demand display units 2026-07-17 10:13:50 +08:00
jiang d8ee2e1f0c fix(burst): show actual pipe diameters 2026-07-17 10:01:06 +08:00
jiang fddb0ceb34 fix(map): restrict waterflow layer to flow 2026-07-16 18:02:29 +08:00
jiang 3d7b594682 style(map): align valve setting input 2026-07-16 17:55:58 +08:00
jiang eb8950c89a feat(map): add valve setting editor 2026-07-16 17:48:16 +08:00
jiang ef2b045306 fix(map): show simulation pipe ids 2026-07-16 14:50:22 +08:00
jiang 209da0d295 fix(map): sort scheme queries by time 2026-07-16 14:29:29 +08:00
jiang dfaee645ff fix(timeline): use backend timestep 2026-07-16 14:16:07 +08:00
jiang d90ca7c951 fix(timeline): align range mask with slider 2026-07-16 11:38:22 +08:00
jiang 041b4ef89d fix(timeline): prevent negative initial time 2026-07-16 11:31:51 +08:00
65 changed files with 6733 additions and 3815 deletions
+2 -2
View File
@@ -4,7 +4,6 @@ import { Refine, type AuthProvider } from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar"; import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import { import {
RefineSnackbarProvider, RefineSnackbarProvider,
useNotificationProvider,
} from "@refinedev/mui"; } from "@refinedev/mui";
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react"; import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
@@ -18,6 +17,7 @@ import { ProjectProvider } from "@/contexts/ProjectContext";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config"; import { config } from "@config/config";
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
import { LiaNetworkWiredSolid } from "react-icons/lia"; import { LiaNetworkWiredSolid } from "react-icons/lia";
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb"; import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
@@ -165,7 +165,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
<Refine <Refine
routerProvider={routerProvider} routerProvider={routerProvider}
dataProvider={dataProvider} dataProvider={dataProvider}
notificationProvider={useNotificationProvider} notificationProvider={useAppNotificationProvider}
authProvider={authProvider} authProvider={authProvider}
resources={[ resources={[
{ {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
import { parseApplyLayerStylePayload } from "./toolCallStyleHelpers";
describe("parseApplyLayerStylePayload", () => {
it("accepts a valid snake_case interval contract", () => {
expect(
parseApplyLayerStylePayload({
layer_id: "pipes",
style_config: {
property: "velocity",
classification_method: "custom_breaks",
segments: 3,
custom_breaks: [0, 1, 2, 3],
color_type: "custom",
custom_colors: ["#000000", "#777777", "#ffffff"],
},
}),
).toMatchObject({
layerId: "pipes",
resetToDefault: false,
styleConfig: { segments: 3, customBreaks: [0, 1, 2, 3] },
});
});
it("rejects invalid class counts and array cardinalities", () => {
expect(
parseApplyLayerStylePayload({
layer_id: "pipes",
style_config: { segments: 1, property: "velocity" },
}),
).toBeNull();
expect(
parseApplyLayerStylePayload({
layer_id: "junctions",
style_config: {
segments: 3,
custom_breaks: [0, 1, 2],
},
}),
).toBeNull();
});
it("keeps camelCase compatibility", () => {
expect(
parseApplyLayerStylePayload({
layerId: "junctions",
styleConfig: { opacity: 0.5, colorType: "gradient" },
}),
).toMatchObject({ layerId: "junctions", styleConfig: { opacity: 0.5 } });
});
});
+83 -6
View File
@@ -1,4 +1,9 @@
import type { StyleConfig, DefaultLayerStyleId } from "@components/olmap/core/Controls/styleEditorTypes"; import type {
ClassificationMethod,
ColorType,
StyleConfig,
DefaultLayerStyleId,
} from "@components/olmap/core/Controls/styleEditorTypes";
export type ApplyLayerStyleActionPayload = { export type ApplyLayerStyleActionPayload = {
layerId: DefaultLayerStyleId; layerId: DefaultLayerStyleId;
@@ -48,6 +53,23 @@ const asStringArray = (value: unknown): string[] | undefined =>
.filter((item): item is string => item !== undefined) .filter((item): item is string => item !== undefined)
: undefined; : undefined;
const asClassificationMethod = (value: unknown): ClassificationMethod | undefined => {
const normalized = asString(value);
return normalized === "pretty_breaks" || normalized === "custom_breaks"
? normalized
: undefined;
};
const asColorType = (value: unknown): ColorType | undefined => {
const normalized = asString(value);
return normalized === "single" ||
normalized === "gradient" ||
normalized === "rainbow" ||
normalized === "custom"
? normalized
: undefined;
};
export const normalizeStyleLayerId = (value: unknown): DefaultLayerStyleId | null => { export const normalizeStyleLayerId = (value: unknown): DefaultLayerStyleId | null => {
const normalized = asString(value)?.toLowerCase(); const normalized = asString(value)?.toLowerCase();
if (normalized === "junctions" || normalized === "pipes") { if (normalized === "junctions" || normalized === "pipes") {
@@ -77,13 +99,25 @@ export const parseApplyLayerStylePayload = (
? (params.styleConfig as Record<string, unknown>) ? (params.styleConfig as Record<string, unknown>)
: null; : null;
const classificationValue =
rawStyleConfig?.classification_method ?? rawStyleConfig?.classificationMethod;
const colorTypeValue = rawStyleConfig?.color_type ?? rawStyleConfig?.colorType;
const segmentsValue = rawStyleConfig?.segments;
const segments = asNumber(segmentsValue);
if (
(classificationValue !== undefined && !asClassificationMethod(classificationValue)) ||
(colorTypeValue !== undefined && !asColorType(colorTypeValue)) ||
(segmentsValue !== undefined &&
(!Number.isInteger(segments) || (segments as number) < 2 || (segments as number) > 10))
) {
return null;
}
const styleConfig: Partial<StyleConfig> | undefined = rawStyleConfig const styleConfig: Partial<StyleConfig> | undefined = rawStyleConfig
? { ? {
property: asString(rawStyleConfig.property), property: asString(rawStyleConfig.property),
classificationMethod: asString( classificationMethod: asClassificationMethod(classificationValue),
rawStyleConfig.classification_method ?? rawStyleConfig.classificationMethod, segments,
),
segments: asNumber(rawStyleConfig.segments),
minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize), minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize),
maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize), maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize),
minStrokeWidth: asNumber( minStrokeWidth: asNumber(
@@ -95,7 +129,7 @@ export const parseApplyLayerStylePayload = (
fixedStrokeWidth: asNumber( fixedStrokeWidth: asNumber(
rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth, rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth,
), ),
colorType: asString(rawStyleConfig.color_type ?? rawStyleConfig.colorType), colorType: asColorType(colorTypeValue),
singlePaletteIndex: asNumber( singlePaletteIndex: asNumber(
rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex, rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex,
), ),
@@ -121,6 +155,49 @@ export const parseApplyLayerStylePayload = (
} }
: undefined; : undefined;
if (styleConfig) {
const numericValues = [
styleConfig.minSize,
styleConfig.maxSize,
styleConfig.minStrokeWidth,
styleConfig.maxStrokeWidth,
styleConfig.fixedStrokeWidth,
].filter((value): value is number => value !== undefined);
if (numericValues.some((value) => value <= 0)) return null;
const paletteIndexes: Array<[number | undefined, number]> = [
[styleConfig.singlePaletteIndex, 7],
[styleConfig.gradientPaletteIndex, 3],
[styleConfig.rainbowPaletteIndex, 2],
];
if (
paletteIndexes.some(
([index, length]) =>
index !== undefined &&
(!Number.isInteger(index) || index < 0 || index >= length),
)
) return null;
if (
styleConfig.opacity !== undefined &&
(styleConfig.opacity < 0 || styleConfig.opacity > 1)
) return null;
if (
styleConfig.customBreaks &&
styleConfig.customBreaks.some(
(value, index, values) => index > 0 && value <= values[index - 1],
)
) return null;
if (
styleConfig.segments !== undefined &&
styleConfig.customBreaks &&
styleConfig.customBreaks.length !== styleConfig.segments + 1
) return null;
if (
styleConfig.segments !== undefined &&
styleConfig.customColors &&
styleConfig.customColors.length !== styleConfig.segments
) return null;
}
const hasStyleOverrides = const hasStyleOverrides =
styleConfig && styleConfig &&
Object.values(styleConfig).some((value) => Object.values(styleConfig).some((value) =>
@@ -0,0 +1,57 @@
import dayjs from "dayjs";
import {
buildBurstDetectionRequest,
createBurstDetectionAnalysisParametersState,
parseScadaFrequencyMinutes,
resolvePressureSamplingInterval,
} from "./AnalysisParameters";
describe("burst detection request", () => {
it("requests the latest complete monitoring time by default", () => {
const state = createBurstDetectionAnalysisParametersState();
state.schemeName = " latest-case ";
expect(buildBurstDetectionRequest(state, "fengyang")).toEqual({
network: "fengyang",
scheme_name: "latest-case",
sampling_interval_minutes: 15,
});
expect(state.detectionMode).toBe("latest");
expect(state.targetTime?.minute() % 15).toBe(0);
});
it("sends one target time for historical replay", () => {
const targetTime = dayjs("2026-06-20T13:30:00+08:00");
expect(
buildBurstDetectionRequest(
{
schemeName: "history-case",
detectionMode: "historical",
targetTime,
samplingIntervalMinutes: 30,
samplingIntervalSource: "manual",
},
"fengyang",
),
).toEqual({
network: "fengyang",
scheme_name: "history-case",
sampling_interval_minutes: 30,
target_time: targetTime.toISOString(),
});
});
it("uses the dominant pressure SCADA frequency as the editable default", () => {
expect(parseScadaFrequencyMinutes("0:15:00")).toBe(15);
expect(parseScadaFrequencyMinutes("1:00:00")).toBe(60);
expect(
resolvePressureSamplingInterval([
{ type: "pressure", transmission_frequency: "0:15:00" },
{ type: "pressure", transmission_frequency: "0:15:00" },
{ type: "pressure", transmission_frequency: "0:30:00" },
{ type: "pipe_flow", transmission_frequency: "1:00:00" },
]),
).toBe(15);
});
});
@@ -1,19 +1,14 @@
"use client"; "use client";
import React, { useMemo, useState, useCallback } from "react"; import React, { useEffect, useMemo, useState } from "react";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import RefreshIcon from "@mui/icons-material/Refresh";
import { import {
Box, Box,
Button, Button,
CircularProgress,
Collapse,
FormControl, FormControl,
MenuItem, MenuItem,
Select, Select,
TextField, TextField,
Typography, Typography,
IconButton,
} from "@mui/material"; } from "@mui/material";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
@@ -23,7 +18,7 @@ import { useNotification } from "@refinedev/core";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/zh-cn"; import "dayjs/locale/zh-cn";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { NETWORK_NAME, config } from "@config/config"; import { NETWORK_NAME } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { BurstDetectionResult } from "./types"; import { BurstDetectionResult } from "./types";
@@ -33,44 +28,78 @@ interface Props {
onStateChange?: (state: BurstDetectionAnalysisParametersState) => void; onStateChange?: (state: BurstDetectionAnalysisParametersState) => void;
} }
export interface SchemeItem {
scheme_id: number;
scheme_name: string;
scheme_type: string;
create_time: string;
scheme_start_time: string;
scheme_detail?: {
modify_total_duration: number;
};
}
export interface BurstDetectionAnalysisParametersState { export interface BurstDetectionAnalysisParametersState {
schemeName: string; schemeName: string;
dataSource: "monitoring" | "simulation"; detectionMode: "latest" | "historical";
schemes: SchemeItem[]; targetTime: Dayjs | null;
selectedSchemeId: number | ""; samplingIntervalMinutes: number;
scadaStart: Dayjs | null; samplingIntervalSource: "metadata" | "manual";
scadaEnd: Dayjs | null;
mu: number;
pointsPerDay: number;
nEstimators: number;
contaminationInput: string;
advancedOpen: boolean;
} }
interface ScadaInfoItem {
type?: string;
transmission_frequency?: string | number | null;
}
const currentQuarterHour = () => {
const now = dayjs().second(0).millisecond(0);
return now.minute(Math.floor(now.minute() / 15) * 15);
};
export const createBurstDetectionAnalysisParametersState = export const createBurstDetectionAnalysisParametersState =
(): BurstDetectionAnalysisParametersState => ({ (): BurstDetectionAnalysisParametersState => ({
schemeName: `Burst_Detection_${Date.now()}`, schemeName: `Burst_Detection_${Date.now()}`,
dataSource: "monitoring", detectionMode: "latest",
schemes: [], targetTime: currentQuarterHour(),
selectedSchemeId: "", samplingIntervalMinutes: 15,
scadaStart: dayjs().subtract(3, "day"), samplingIntervalSource: "metadata",
scadaEnd: dayjs(), });
mu: 100,
pointsPerDay: 96, export const parseScadaFrequencyMinutes = (
nEstimators: 50, value: string | number | null | undefined,
contaminationInput: "auto", ): number | null => {
advancedOpen: false, if (typeof value === "number") {
return Number.isInteger(value) && value > 0 ? value : null;
}
if (!value) return null;
const normalized = value.trim();
const dayMatch = normalized.match(/^(\d+)\s+days?,\s*(.+)$/i);
const days = dayMatch ? Number(dayMatch[1]) : 0;
const timePart = dayMatch ? dayMatch[2] : normalized;
const parts = timePart.split(":").map(Number);
if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) {
return null;
}
const minutes = days * 1440 + parts[0] * 60 + parts[1] + parts[2] / 60;
return Number.isInteger(minutes) && minutes > 0 ? minutes : null;
};
export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
const counts = new Map<number, number>();
items
.filter((item) => item.type?.toLowerCase() === "pressure")
.forEach((item) => {
const minutes = parseScadaFrequencyMinutes(item.transmission_frequency);
if (minutes && 1440 % minutes === 0) {
counts.set(minutes, (counts.get(minutes) ?? 0) + 1);
}
});
return [...counts.entries()].sort(
([minutesA, countA], [minutesB, countB]) =>
countB - countA || minutesA - minutesB,
)[0]?.[0] ?? 15;
};
export const buildBurstDetectionRequest = (
parameters: BurstDetectionAnalysisParametersState,
network: string,
) => ({
network,
scheme_name: parameters.schemeName.trim(),
sampling_interval_minutes: parameters.samplingIntervalMinutes,
...(parameters.detectionMode === "historical" && parameters.targetTime
? { target_time: parameters.targetTime.toISOString() }
: {}),
}); });
const AnalysisParameters: React.FC<Props> = ({ const AnalysisParameters: React.FC<Props> = ({
@@ -79,137 +108,69 @@ const AnalysisParameters: React.FC<Props> = ({
onStateChange, onStateChange,
}) => { }) => {
const { open } = useNotification(); const { open } = useNotification();
const [parametersState, setParametersState, setFormField] = useControllableObjectState( const [parametersState, setParametersState, setFormField] =
useControllableObjectState(
state, state,
onStateChange, onStateChange,
createBurstDetectionAnalysisParametersState(), createBurstDetectionAnalysisParametersState(),
); );
const { const {
schemeName, schemeName,
dataSource, detectionMode,
schemes, targetTime,
selectedSchemeId, samplingIntervalMinutes,
scadaStart, samplingIntervalSource,
scadaEnd,
mu,
pointsPerDay,
nEstimators,
contaminationInput,
advancedOpen,
} = parametersState; } = parametersState;
const [schemeLoading, setSchemeLoading] = useState(false);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const isSimulationMode = dataSource === "simulation"; const [frequencyLoading, setFrequencyLoading] = useState(false);
const applySchemeTimeRange = useCallback((scheme: SchemeItem) => { useEffect(() => {
const start = dayjs(scheme.scheme_start_time); if (samplingIntervalSource !== "metadata") return;
const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600; let active = true;
const end = start.add(durationSeconds, "second"); setFrequencyLoading(true);
api
setParametersState((previous) => ({ .get("/api/v1/getallscadainfo/", { params: { network: NETWORK_NAME } })
...previous, .then((response) => {
scadaStart: start, if (!active) return;
scadaEnd: end, const interval = resolvePressureSamplingInterval(
})); response.data as ScadaInfoItem[],
}, [setParametersState]);
const fetchSchemes = useCallback(
async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => {
if (schemeLoading || (!force && schemes.length > 0)) return;
setSchemeLoading(true);
try {
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params: { network: NETWORK_NAME },
});
const burstSchemes = (response.data as SchemeItem[]).filter(
(scheme) => scheme.scheme_type === "burst_analysis",
); );
setParametersState((previous) =>
setFormField("schemes", burstSchemes); previous.samplingIntervalSource === "metadata"
? { ...previous, samplingIntervalMinutes: interval }
if (selectedSchemeId) { : previous,
const matchedScheme = burstSchemes.find(
(scheme) => scheme.scheme_id === selectedSchemeId,
); );
if (matchedScheme) { })
applySchemeTimeRange(matchedScheme); .catch(() => {
} else { // Keep the 15-minute fallback when SCADA metadata is unavailable.
setFormField("selectedSchemeId", ""); })
} .finally(() => {
} if (active) setFrequencyLoading(false);
if (notify) {
open?.({
type: "success",
message: "方案列表已刷新",
description: `当前可选爆管分析方案 ${burstSchemes.length}`,
}); });
} return () => {
} catch (error: any) { active = false;
open?.({
type: "error",
message: "刷新方案失败",
description:
error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表",
});
} finally {
setSchemeLoading(false);
}
},
[applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId, setFormField],
);
const handleDataSourceChange = (value: "monitoring" | "simulation") => {
setFormField("dataSource", value);
if (value === "simulation") {
void fetchSchemes();
}
}; };
}, [samplingIntervalSource, setParametersState]);
const handleSchemeSelect = (schemeId: number) => { const samplingIntervalValid =
setFormField("selectedSchemeId", schemeId); Number.isInteger(samplingIntervalMinutes) &&
const scheme = schemes.find((item) => item.scheme_id === schemeId); samplingIntervalMinutes > 0 &&
if (scheme) { 1440 % samplingIntervalMinutes === 0;
applySchemeTimeRange(scheme);
}
};
const timeWindowValid = useMemo(() => { const isValid = useMemo(
if (!scadaStart || !scadaEnd) return false; () =>
return scadaEnd.diff(scadaStart, "day", true) >= 2; schemeName.trim().length > 0 &&
}, [scadaEnd, scadaStart]); samplingIntervalValid &&
(detectionMode === "latest" || Boolean(targetTime?.isValid())),
const contaminationValue = useMemo(() => { [detectionMode, samplingIntervalValid, schemeName, targetTime],
const normalized = contaminationInput.trim().toLowerCase(); );
if (!normalized || normalized === "auto") {
return "auto" as const;
}
const parsed = Number(normalized);
if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= 0.5) {
return null;
}
return parsed;
}, [contaminationInput]);
const isValid =
Boolean(scadaStart && scadaEnd) &&
timeWindowValid &&
Number.isFinite(mu) &&
mu > 0 &&
Number.isFinite(pointsPerDay) &&
pointsPerDay > 0 &&
Number.isFinite(nEstimators) &&
nEstimators > 0 &&
contaminationValue !== null &&
(dataSource !== "simulation" || Boolean(selectedSchemeId));
const handleRun = async () => { const handleRun = async () => {
if (!isValid || !scadaStart || !scadaEnd || contaminationValue === null) { if (!isValid) {
open?.({ open?.({
type: "error", type: "error",
message: "参数不完整", message: "参数不完整",
description: "请检查时间范围(至少2天)和高级参数是否填写正确。", description: "请输入方案名称,并检查历史目标时间。",
}); });
return; return;
} }
@@ -219,50 +180,23 @@ const AnalysisParameters: React.FC<Props> = ({
key: "burst-detection-analysis-progress", key: "burst-detection-analysis-progress",
type: "progress", type: "progress",
message: "正在执行爆管侦测", message: "正在执行爆管侦测",
description: "正在读取数据并计算异常分数。", description: "正在读取目标时刻及前 14 天同刻基线。",
undoableTimeout: 3, undoableTimeout: 3,
}); });
try { try {
const selectedScheme = const response = await api.post(
dataSource === "simulation" "/api/v1/burst-detection/detect/",
? schemes.find((item) => item.scheme_id === selectedSchemeId) buildBurstDetectionRequest(parametersState, NETWORK_NAME),
: undefined; );
onResult(response.data as BurstDetectionResult);
const response = await api.post("/api/v1/burst-detection/detect/", {
network: NETWORK_NAME,
data_source: dataSource,
scheme_name: schemeName.trim() || undefined,
scada_start: scadaStart.toISOString(),
scada_end: scadaEnd.toISOString(),
mu,
points_per_day: pointsPerDay,
iforest_params: {
n_estimators: nEstimators,
contamination: contaminationValue,
},
simulation_scheme_name: selectedScheme?.scheme_name,
simulation_scheme_type: selectedScheme?.scheme_type,
});
onResult({
...(response.data as BurstDetectionResult),
scheme_name: schemeName.trim() || (response.data as BurstDetectionResult).scheme_name,
algorithm_params: {
mu,
points_per_day: pointsPerDay,
iforest_params: {
n_estimators: nEstimators,
contamination: contaminationValue,
},
},
});
open?.({ open?.({
key: "burst-detection-analysis-success", key: "burst-detection-analysis-success",
type: "success", type: "success",
message: "爆管侦测完成", message: "爆管侦测完成",
description: `共识别 ${response.data.summary?.anomaly_day_count ?? 0} 个异常日。`, description: response.data.summary?.burst_detected
? "目标时刻存在异常信号,请优先复核相关测点。"
: "目标时刻未发现爆管异常。",
}); });
} catch (error: any) { } catch (error: any) {
open?.({ open?.({
@@ -277,7 +211,7 @@ const AnalysisParameters: React.FC<Props> = ({
}; };
return ( return (
<Box className="flex flex-col flex-1 min-h-0"> <Box className="flex min-h-0 flex-1 flex-col">
<Box className="flex flex-col gap-3"> <Box className="flex flex-col gap-3">
<Box> <Box>
<Typography variant="subtitle2" className="mb-1 font-medium"> <Typography variant="subtitle2" className="mb-1 font-medium">
@@ -294,211 +228,89 @@ const AnalysisParameters: React.FC<Props> = ({
<Box> <Box>
<Typography variant="subtitle2" className="mb-1 font-medium"> <Typography variant="subtitle2" className="mb-1 font-medium">
</Typography> </Typography>
<FormControl fullWidth size="small"> <FormControl fullWidth size="small">
<Select <Select
value={dataSource} value={detectionMode}
onChange={(e) => handleDataSourceChange(e.target.value as "monitoring" | "simulation")} onChange={(event) =>
setFormField(
"detectionMode",
event.target.value as "latest" | "historical",
)
}
> >
<MenuItem value="monitoring"></MenuItem> <MenuItem value="latest"></MenuItem>
<MenuItem value="simulation"></MenuItem> <MenuItem value="historical"></MenuItem>
</Select> </Select>
</FormControl> </FormControl>
</Box> </Box>
{isSimulationMode && ( {detectionMode === "historical" ? (
<Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<FormControl fullWidth size="small">
<Select
value={selectedSchemeId}
onChange={(e) => handleSchemeSelect(Number(e.target.value))}
disabled={schemeLoading}
displayEmpty
>
<MenuItem value="" disabled>
</MenuItem>
{schemes.map((scheme) => (
<MenuItem key={scheme.scheme_id} value={scheme.scheme_id}>
{scheme.scheme_name}
</MenuItem>
))}
</Select>
</FormControl>
<IconButton
size="small"
color="primary"
onClick={() => void fetchSchemes({ force: true, notify: true })}
disabled={schemeLoading}
aria-label="刷新爆管分析方案"
sx={{
border: "1px solid",
borderColor: "divider",
borderRadius: 1,
}}
>
{schemeLoading ? (
<CircularProgress size={18} color="inherit" />
) : (
<RefreshIcon fontSize="small" />
)}
</IconButton>
</Box>
</Box>
)}
<LocalizationProvider <LocalizationProvider
dateAdapter={AdapterDayjs} dateAdapter={AdapterDayjs}
adapterLocale="zh-cn" adapterLocale="zh-cn"
localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText} localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText}
> >
<Box className="grid grid-cols-2 gap-2">
<Box> <Box>
<Typography variant="subtitle2" className="mb-1 font-medium"> <Typography variant="subtitle2" className="mb-1 font-medium">
</Typography> </Typography>
<DateTimePicker <DateTimePicker
value={scadaStart} value={targetTime}
onChange={(value) => setFormField("scadaStart", value)} onChange={(value) => setFormField("targetTime", value)}
maxDateTime={scadaEnd ? scadaEnd.subtract(2, "day") : undefined} maxDateTime={dayjs()}
disabled={isSimulationMode} minutesStep={15}
format="YYYY-MM-DD HH:mm" format="YYYY-MM-DD HH:mm"
slotProps={{ textField: { size: "small", fullWidth: true } }} slotProps={{ textField: { size: "small", fullWidth: true } }}
/> />
</Box> </Box>
<Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
</Typography>
<DateTimePicker
value={scadaEnd}
onChange={(value) => setFormField("scadaEnd", value)}
minDateTime={scadaStart ? scadaStart.add(2, "day") : undefined}
disabled={isSimulationMode}
format="YYYY-MM-DD HH:mm"
slotProps={{ textField: { size: "small", fullWidth: true } }}
/>
</Box>
</Box>
</LocalizationProvider> </LocalizationProvider>
) : null}
<Box className="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-900"> <TextField
type="number"
</Box> label="采样间隔(分钟)"
value={samplingIntervalMinutes}
<Box onChange={(event) => {
sx={{ setParametersState((previous) => ({
border: "1px solid", ...previous,
borderColor: "grey.200", samplingIntervalMinutes: Number(event.target.value),
borderRadius: 1, samplingIntervalSource: "manual",
overflow: "hidden", }));
}} }}
> size="small"
<Box fullWidth
role="button" error={!samplingIntervalValid}
tabIndex={0} inputProps={{ min: 1, max: 1440, step: 1 }}
onClick={() => setFormField("advancedOpen", !advancedOpen)} helperText={
onKeyDown={(event) => { samplingIntervalValid
if (event.key === "Enter" || event.key === " ") { ? `${frequencyLoading ? "正在读取 SCADA 频率" : samplingIntervalSource === "metadata" ? "默认取自压力 SCADA 频率" : "已手动设置"},每天 ${1440 / samplingIntervalMinutes} 个采样点`
setFormField("advancedOpen", !advancedOpen); : "请输入能整除 1440 分钟的正整数,例如 1、5、10、15、30 或 60。"
} }
}} />
sx={{
display: "flex", {detectionMode === "latest" ? (
alignItems: "center", <Box className="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-900">
justifyContent: "space-between", 14 使
px: 1.25, 24
py: 0.75, </Box>
cursor: "pointer", ) : null}
backgroundColor: "transparent", <Typography variant="caption" color="text.secondary">
"&:hover": { backgroundColor: "action.hover" }, {samplingIntervalMinutes || "-"}
}} {samplingIntervalValid ? 1440 / samplingIntervalMinutes : "-"} /14
>
<Typography variant="body2" color="text.secondary">
</Typography> </Typography>
<ExpandMoreIcon
sx={{
transform: advancedOpen ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.2s ease",
}}
/>
</Box>
<Collapse in={advancedOpen} timeout="auto" unmountOnExit>
<Box
sx={{
px: 1.25,
pt: 1.25,
pb: 1.25,
backgroundColor: "transparent",
}}
>
<Box className="flex flex-col gap-3">
<TextField
type="number"
label="频域截断系数"
value={mu}
onChange={(event) => setFormField("mu", Number(event.target.value))}
size="small"
fullWidth
inputProps={{ min: 1 }}
/>
<TextField
type="number"
label="每日采样点数"
value={pointsPerDay}
onChange={(event) => setFormField("pointsPerDay", Number(event.target.value))}
size="small"
fullWidth
inputProps={{ min: 1 }}
/>
<TextField
type="number"
label="孤立森林树数量"
value={nEstimators}
onChange={(event) => setFormField("nEstimators", Number(event.target.value))}
size="small"
fullWidth
inputProps={{ min: 1 }}
/>
<TextField
label="异常比例"
value={contaminationInput}
onChange={(event) => setFormField("contaminationInput", event.target.value)}
size="small"
fullWidth
helperText="填写 auto 或 0~0.5 之间的小数。"
error={contaminationValue === null}
/>
</Box>
</Box>
</Collapse>
</Box>
</Box> </Box>
<Box className="mt-auto pt-3 flex gap-2"> <Box className="mt-auto flex gap-2 pt-3">
<Button <Button
variant="outlined" variant="outlined"
fullWidth fullWidth
disabled={running} disabled={running}
sx={{ textTransform: "none", fontWeight: 500 }} onClick={() =>
onClick={() => { setParametersState(createBurstDetectionAnalysisParametersState())
setParametersState((previous) => ({ }
...previous,
schemeName: `Burst_Detection_${Date.now()}`,
scadaStart: dayjs().subtract(3, "day"),
scadaEnd: dayjs(),
mu: 100,
pointsPerDay: 96,
nEstimators: 50,
contaminationInput: "auto",
}));
}}
> >
</Button> </Button>
@@ -506,11 +318,13 @@ const AnalysisParameters: React.FC<Props> = ({
variant="contained" variant="contained"
fullWidth fullWidth
disabled={!isValid || running} disabled={!isValid || running}
onClick={handleRun} onClick={() => void handleRun()}
className="bg-blue-600 hover:bg-blue-700"
sx={{ textTransform: "none", fontWeight: 500 }}
> >
{running ? <CircularProgress size={20} color="inherit" /> : "开始侦测"} {running
? "侦测中..."
: detectionMode === "latest"
? "侦测最新数据"
: "回放目标时刻"}
</Button> </Button>
</Box> </Box>
</Box> </Box>
@@ -5,23 +5,23 @@ import { Box, Button, Chip, Tooltip, Typography } from "@mui/material";
import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { zhCN } from "@mui/x-data-grid/locales"; import { zhCN } from "@mui/x-data-grid/locales";
import { import {
CheckCircleOutline as CheckCircleIcon,
ErrorOutline as ErrorOutlineIcon,
FormatListBulleted, FormatListBulleted,
InfoOutlined as InfoOutlinedIcon, InfoOutlined as InfoOutlinedIcon,
Room as RoomIcon, Room as RoomIcon,
ShowChart as ShowChartIcon, ShowChart as ShowChartIcon,
CheckCircleOutline as CheckCircleIcon,
ErrorOutline as ErrorOutlineIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import ReactECharts from "echarts-for-react"; import ReactECharts from "echarts-for-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { GeoJSON } from "ol/format";
import Feature from "ol/Feature"; import Feature from "ol/Feature";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector"; import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector"; import VectorSource from "ol/source/Vector";
import { Circle, Fill, Stroke, Style } from "ol/style"; import { Circle, Fill, Stroke, Style } from "ol/style";
import { bbox, featureCollection } from "@turf/turf"; import { bbox, featureCollection } from "@turf/turf";
import { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { BurstDetectionResult, BurstDetectionRow } from "./types"; import { BurstDetectionResult, BurstDetectionRow } from "./types";
export interface BurstDetectionResultsState { export interface BurstDetectionResultsState {
@@ -29,9 +29,7 @@ export interface BurstDetectionResultsState {
} }
export const createBurstDetectionResultsState = export const createBurstDetectionResultsState =
(): BurstDetectionResultsState => ({ (): BurstDetectionResultsState => ({ selectedDay: null });
selectedDay: null,
});
interface Props { interface Props {
result: BurstDetectionResult | null; result: BurstDetectionResult | null;
@@ -46,54 +44,33 @@ interface MetricCardProps {
tone: "blue" | "orange" | "purple" | "green"; tone: "blue" | "orange" | "purple" | "green";
} }
const toneStyles: Record< const toneStyles: Record<MetricCardProps["tone"], string> = {
MetricCardProps["tone"], blue: "border-blue-200 from-blue-50 to-blue-100 text-blue-900",
{ bg: string; border: string; text: string; darkText: string } orange: "border-orange-200 from-orange-50 to-orange-100 text-orange-900",
> = { purple: "border-purple-200 from-purple-50 to-purple-100 text-purple-900",
blue: { green: "border-green-200 from-green-50 to-green-100 text-green-900",
bg: "from-blue-50 to-blue-100",
border: "border-blue-200",
text: "text-blue-700",
darkText: "text-blue-900",
},
orange: {
bg: "from-orange-50 to-orange-100",
border: "border-orange-200",
text: "text-orange-700",
darkText: "text-orange-900",
},
purple: {
bg: "from-purple-50 to-purple-100",
border: "border-purple-200",
text: "text-purple-700",
darkText: "text-purple-900",
},
green: {
bg: "from-green-50 to-green-100",
border: "border-green-200",
text: "text-green-700",
darkText: "text-green-900",
},
}; };
const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => { const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => (
const style = toneStyles[tone]; <Box
return ( className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${toneStyles[tone]}`}
<Box className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${style.bg} ${style.border}`}> >
<Typography variant="caption" className={`mb-1 block text-xs font-semibold uppercase tracking-wide ${style.text}`}> <Typography variant="caption" className="mb-1 block font-semibold">
{label} {label}
</Typography> </Typography>
<Typography variant="body2" className={`font-bold ${style.darkText}`}> <Typography variant="body2" className="font-bold">
{value} {value}
</Typography> </Typography>
{hint ? ( {hint ? (
<Typography variant="caption" className={`mt-0.5 block text-xs opacity-80 ${style.text}`}> <Typography variant="caption" className="mt-0.5 block opacity-75">
{hint} {hint}
</Typography> </Typography>
) : null} ) : null}
</Box> </Box>
); );
};
const formatDateTime = (value?: string) =>
value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
const EmptyState = () => ( const EmptyState = () => (
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center"> <Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center">
@@ -104,42 +81,27 @@ const EmptyState = () => (
</Typography> </Typography>
<Typography variant="body2" className="max-w-xs text-gray-500"> <Typography variant="body2" className="max-w-xs text-gray-500">
14
</Typography> </Typography>
</Box> </Box>
); );
const getScoreLevel = (score: number) => { const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => {
if (score <= -0.6) return { label: "高风险", color: "error" as const };
if (score <= -0.2) return { label: "需关注", color: "warning" as const };
return { label: "正常", color: "success" as const };
};
const formatDateTime = (value?: string) => (value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-");
const DetectionResults: React.FC<Props> = ({
result,
state,
onStateChange,
}) => {
const map = useMap(); const map = useMap();
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null); const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
const [internalResultsState, setInternalResultsState] = const [internalState, setInternalState] = useState<BurstDetectionResultsState>(
useState<BurstDetectionResultsState>(createBurstDetectionResultsState); createBurstDetectionResultsState,
const resultsState = state ?? internalResultsState; );
const selectedDay = resultsState.selectedDay; const resultsState = state ?? internalState;
const setSelectedDay = (value: number | null) => { const setSelectedDay = (selectedDay: number | null) => {
const nextState = { ...resultsState, selectedDay: value }; const nextState = { selectedDay };
if (state === undefined) { if (state === undefined) setInternalState(nextState);
setInternalResultsState(nextState);
}
onStateChange?.(nextState); onStateChange?.(nextState);
}; };
useEffect(() => { useEffect(() => {
if (!map) return; if (!map) return;
const layer = new VectorLayer({ const layer = new VectorLayer({
source: new VectorSource(), source: new VectorSource(),
style: new Style({ style: new Style({
@@ -154,12 +116,11 @@ const DetectionResults: React.FC<Props> = ({
properties: { properties: {
name: "爆管侦测高亮", name: "爆管侦测高亮",
value: "burst_detection_highlight", value: "burst_detection_highlight",
queryable: false,
}, },
}); });
map.addLayer(layer); map.addLayer(layer);
highlightLayerRef.current = layer; highlightLayerRef.current = layer;
return () => { return () => {
highlightLayerRef.current = null; highlightLayerRef.current = null;
map.removeLayer(layer); map.removeLayer(layer);
@@ -173,58 +134,67 @@ const DetectionResults: React.FC<Props> = ({
highlightFeatures.forEach((feature) => source.addFeature(feature)); highlightFeatures.forEach((feature) => source.addFeature(feature));
}, [highlightFeatures]); }, [highlightFeatures]);
const defaultSelectedDay = useMemo( const sortedRows = useMemo(
() => () => [...(result?.rows ?? [])].sort((a, b) => a.Day - b.Day),
result?.summary?.most_anomalous_day ??
result?.summary?.latest_day?.Day ??
result?.rows[0]?.Day ??
null,
[result], [result],
); );
const activeSelectedDay = selectedDay ?? defaultSelectedDay; const timestampForRow = (row: BurstDetectionRow) => {
if (row.Timestamp) return row.Timestamp;
const start = dayjs(result?.scada_window?.start);
return start.isValid() ? start.add(row.Day, "day").toISOString() : undefined;
};
const selectedRow = useMemo<BurstDetectionRow | null>(() => { const scoreThreshold = result?.summary.score_threshold ?? 0;
if (!result || activeSelectedDay === null) return null; const scoreSeries = sortedRows.map((row) => ({
return result.rows.find((row) => row.Day === activeSelectedDay) ?? null; day: row.Day,
}, [activeSelectedDay, result]); value: [
timestampForRow(row)
const scoreSeries = useMemo( ? dayjs(timestampForRow(row)).format("MM-DD HH:mm")
() => : `${row.Day}`,
result?.rows.map((row) => ({ Number(row.Score.toFixed(4)),
value: [row.Day, Number(row.Score.toFixed(4))], ],
itemStyle: { itemStyle: {
color: row.IsBurst ? "#ef4444" : row.Score <= -0.2 ? "#f59e0b" : "#10b981", color:
row.Role === "target"
? row.IsBurst
? "#ef4444"
: "#2563eb"
: "#94a3b8",
}, },
})) ?? [], symbolSize: row.Role === "target" ? 11 : 7,
[result], }));
);
const rankingSeries = useMemo( const rankingSeries = useMemo(
() => () =>
[...(result?.summary?.latest_sensor_rankings ?? [])] [...(result?.summary.latest_sensor_rankings ?? [])]
.sort((a, b) => a.latest_high_frequency_value - b.latest_high_frequency_value) .sort(
(a, b) =>
(a.standardized_deviation ?? a.latest_high_frequency_value) -
(b.standardized_deviation ?? b.latest_high_frequency_value),
)
.map((item) => ({ .map((item) => ({
name: item.sensor_node, name: item.sensor_node,
value: Number(item.latest_high_frequency_value.toFixed(4)), value: Number(
(item.standardized_deviation ?? item.latest_high_frequency_value).toFixed(3),
),
})), })),
[result], [result],
); );
const locateSensors = async (sensorIds: string[]) => { const locateSensors = async (sensorIds: string[]) => {
if (!map || sensorIds.length === 0) return; if (!map || sensorIds.length === 0) return;
let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat"); let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat");
if (features.length === 0) { if (features.length === 0) {
features = await queryFeaturesByIds(sensorIds, "geo_junctions"); features = await queryFeaturesByIds(sensorIds, "geo_junctions");
} }
if (features.length === 0) return; if (features.length === 0) return;
setHighlightFeatures(features); setHighlightFeatures(features);
const format = new GeoJSON();
const geojsonFormat = new GeoJSON(); const geojsonFeatures = features.map((feature) =>
const geojsonFeatures = features.map((feature) => geojsonFormat.writeFeatureObject(feature)); format.writeFeatureObject(feature),
// @ts-ignore turf typing with ol geojson objects );
// @ts-ignore turf accepts OpenLayers GeoJSON feature objects
const extent = bbox(featureCollection(geojsonFeatures)); const extent = bbox(featureCollection(geojsonFeatures));
map.getView().fit(extent, { map.getView().fit(extent, {
maxZoom: 18, maxZoom: 18,
@@ -233,60 +203,50 @@ const DetectionResults: React.FC<Props> = ({
}); });
}; };
if (!result) { if (!result) return <EmptyState />;
return <EmptyState />;
}
const latestDay = result.summary?.latest_day; const targetRow = sortedRows.find((row) => row.Role === "target") ?? sortedRows.at(-1);
const latestLevel = latestDay ? getScoreLevel(latestDay.Score) : getScoreLevel(0);
const mostAnomalousRow = result.rows.find((row) => row.Day === result.summary?.most_anomalous_day) ?? null;
const mostAnomalousLevel = getScoreLevel(mostAnomalousRow?.Score ?? 0);
const isBurstDetected = result.summary.burst_detected; const isBurstDetected = result.summary.burst_detected;
const targetRank = result.summary.target_rank;
const excludedCount = result.data_quality?.excluded_sensors.length ?? 0;
const chartOption = { const chartOption = {
tooltip: { tooltip: {
trigger: "axis", trigger: "axis",
formatter: (params: Array<{ data: { value: [number, number] } }>) => { formatter: (params: Array<{ data: { day: number; value: [string, number] } }>) => {
const point = params[0]?.data?.value; const data = params[0]?.data;
if (!point) return "-"; return data
return `侦测日第 ${point[0]}<br/>异常分数:${point[1]}`; ? `${data.value[0]}<br/>${data.day === 15 ? "目标时刻" : "参考日"}<br/>异常分数:${data.value[1]}`
: "-";
}, },
}, },
grid: { top: 30, left: 40, right: 20, bottom: 35 }, grid: { top: 30, left: 48, right: 20, bottom: 48 },
xAxis: { xAxis: {
type: "category", type: "category",
name: "侦测日", name: "同刻日期",
data: result.rows.map((row) => row.Day), boundaryGap: false,
axisLabel: { fontSize: 10 }, data: scoreSeries.map((item) => item.value[0]),
}, axisLabel: { fontSize: 10, interval: 2, rotate: 25 },
yAxis: {
type: "value",
name: "异常分数",
axisLabel: { fontSize: 10 },
}, },
yAxis: { type: "value", name: "异常分数", axisLabel: { fontSize: 10 } },
series: [ series: [
{ {
type: "line", type: "line",
smooth: true,
symbolSize: 8,
data: scoreSeries, data: scoreSeries,
lineStyle: { color: "#2563eb", width: 2 }, lineStyle: { color: "#94a3b8", width: 2 },
markLine: { markLine: {
symbol: "none", symbol: "none",
lineStyle: { type: "dashed", color: "#94a3b8" }, lineStyle: { type: "dashed", color: "#ef4444" },
data: [{ yAxis: 0 }], data: [{ yAxis: scoreThreshold, name: "报警阈值" }],
}, },
}, },
], ],
}; };
const rankingOption = { const rankingOption = {
tooltip: { tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
trigger: "axis", grid: { top: 12, left: 82, right: 20, bottom: 25 },
axisPointer: { type: "shadow" }, xAxis: { type: "value", name: "标准化偏离", axisLabel: { fontSize: 10 } },
},
grid: { top: 20, left: 70, right: 20, bottom: 20 },
xAxis: { type: "value", axisLabel: { fontSize: 10 } },
yAxis: { yAxis: {
type: "category", type: "category",
data: rankingSeries.map((item) => item.name), data: rankingSeries.map((item) => item.name),
@@ -297,9 +257,7 @@ const DetectionResults: React.FC<Props> = ({
type: "bar", type: "bar",
data: rankingSeries.map((item) => ({ data: rankingSeries.map((item) => ({
value: item.value, value: item.value,
itemStyle: { itemStyle: { color: item.value < 0 ? "#ef4444" : "#f59e0b" },
color: item.value <= -0.6 ? "#ef4444" : item.value <= -0.2 ? "#f59e0b" : "#10b981",
},
})), })),
barWidth: 14, barWidth: 14,
}, },
@@ -308,323 +266,176 @@ const DetectionResults: React.FC<Props> = ({
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
field: "Day", field: "Timestamp",
headerName: "侦测日", headerName: "同刻日期",
width: 96, minWidth: 145,
valueFormatter: (value?: number) => (typeof value === "number" ? `${value}` : "-"), flex: 1,
valueGetter: (_value, row) => formatDateTime(timestampForRow(row)),
},
{
field: "Role",
headerName: "角色",
width: 90,
valueFormatter: (value?: string) => (value === "target" ? "目标" : "参考"),
}, },
{ {
field: "Score", field: "Score",
headerName: "异常分数", headerName: "异常分数",
width: 120, width: 110,
valueFormatter: (value?: number) => (typeof value === "number" ? value.toFixed(4) : "-"), valueFormatter: (value?: number) =>
typeof value === "number" ? value.toFixed(4) : "-",
}, },
{ {
field: "IsBurst", field: "IsBurst",
headerName: "判定结果", headerName: "目标判定",
width: 120, width: 110,
renderCell: ({ value }) => { renderCell: ({ value, row }) =>
const level = value ? { label: "爆管异常", color: "error" as const } : { label: "正常", color: "success" as const }; row.Role === "target" || row.Day === result.day_count ? (
return <Chip size="small" label={level.label} color={level.color} variant="outlined" />; <Chip
}, size="small"
label={value ? "爆管异常" : "正常"}
color={value ? "error" : "success"}
variant="outlined"
/>
) : (
<Typography variant="caption" color="text.secondary">
</Typography>
),
}, },
]; ];
const tableRows = sortedRows.map((row) => ({ id: row.Day, ...row }));
const rows = result.rows.map((row) => ({ id: row.Day, ...row }));
return ( return (
<Box className="h-full overflow-auto p-1"> <Box className="h-full overflow-auto p-1">
<Box className="mb-4 space-y-3"> <Box className="mb-4 space-y-3">
{/* Status Banner */}
<Box <Box
className={`rounded-lg px-4 py-3 flex items-center gap-3 border ${isBurstDetected className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
? "bg-red-50 border-red-100 text-red-900" isBurstDetected
: "bg-green-50 border-green-100 text-green-900" ? "border-red-100 bg-red-50 text-red-900"
: "border-green-100 bg-green-50 text-green-900"
}`} }`}
> >
{isBurstDetected ? ( {isBurstDetected ? <ErrorOutlineIcon /> : <CheckCircleIcon />}
<ErrorOutlineIcon className="text-red-600" />
) : (
<CheckCircleIcon className="text-green-600" />
)}
<Box className="flex-1"> <Box className="flex-1">
<Typography variant="subtitle2" className="font-bold"> <Typography variant="subtitle2" className="font-bold">
{isBurstDetected {isBurstDetected ? "目标时刻侦测到爆管异常" : "目标时刻未侦测到爆管异常"}
? `侦测到异常信号 (共 ${result.summary.anomaly_day_count} 天)`
: "未侦测到爆管异常"}
</Typography> </Typography>
<Typography variant="caption" className="opacity-80"> <Typography variant="caption" className="opacity-80">
{isBurstDetected {formatDateTime(result.target_time ?? result.summary.target_time)}
? "建议检查异常日期的压力波动情况"
: "当前时间窗口内数据特征平稳,符合历史模式"}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
{/* Header */} <Box className="flex items-center justify-between gap-2 px-1">
<Box className="flex items-center justify-between px-1"> <Typography variant="h6" className="min-w-0 flex-1 font-bold text-gray-900">
<Box className="flex items-center gap-2">
<Box className="h-4 w-1 rounded-full bg-blue-600" />
<Typography variant="h6" className="truncate font-bold text-gray-900" sx={{ fontSize: "1.1rem" }}>
{result.scheme_name || "爆管侦测结果"}
</Typography> </Typography>
</Box>
<Box className="flex items-center gap-2">
{result.username ? (
<Chip
label={result.username}
size="small"
sx={{
height: 24,
backgroundColor: "#f3f4f6",
color: "#4b5563",
border: "none",
fontWeight: 500,
}}
/>
) : null}
<Button <Button
size="small" size="small"
variant="outlined" variant="outlined"
startIcon={<RoomIcon />} startIcon={<RoomIcon />}
onClick={() => onClick={() =>
locateSensors(result.summary.latest_sensor_rankings.map((item) => item.sensor_node).slice(0, 5)) void locateSensors(
result.summary.latest_sensor_rankings
.slice(0, 5)
.map((item) => item.sensor_node),
)
} }
sx={{ sx={{ flexShrink: 0, whiteSpace: "nowrap" }}
height: 24,
minWidth: 0,
padding: "0 8px",
borderColor: "#bfdbfe",
color: "#2563eb",
fontSize: "0.75rem",
"&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
}}
> >
</Button> </Button>
</Box> </Box>
</Box>
{/* Configuration Summary */}
<Box className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-gray-100 bg-gray-50/50 px-3 py-2 text-xs text-gray-600">
<Box className="flex items-center gap-1.5">
<Box className="h-1.5 w-1.5 rounded-full bg-blue-400" />
<span className="font-medium text-gray-700"></span>
<span className="font-mono text-gray-600">
{formatDateTime(result.scada_window?.start)} ~ {formatDateTime(result.scada_window?.end)}
</span>
</Box>
<Box className="flex items-center gap-1.5">
<Box className="h-1.5 w-1.5 rounded-full bg-purple-400" />
<span className="font-medium text-gray-700"></span>
<span className="text-gray-600">
{(() => {
const ds = result.data_source;
const os = result.observed_source;
if (ds === "simulation") return "模拟数据";
if (ds === "monitoring") return "监测数据";
if (os === "simulation_scheme_timerange") return "模拟数据";
if (os === "backend_timerange") return "监测数据";
return os || "-";
})()}
</span>
</Box>
</Box>
{/* Metrics Grid */}
<Box className="grid grid-cols-2 gap-3"> <Box className="grid grid-cols-2 gap-3">
<MetricCard <MetricCard
label="异常数" label="目标异常数"
value={`${result.summary.anomaly_day_count} / ${result.day_count}`} value={targetRow ? targetRow.Score.toFixed(4) : "-"}
hint={`异常日:${result.summary.anomaly_days.join(", ") || "无"}`} hint={`报警阈值 ≤ ${scoreThreshold.toFixed(2)}`}
tone={result.summary.anomaly_day_count > 0 ? "orange" : "green"} tone={isBurstDetected ? "orange" : "green"}
/> />
<MetricCard <MetricCard
label="最异常日" label="目标异常排名"
value={ value={targetRank ? `${targetRank} / ${result.day_count}` : "-"}
result.summary.burst_detected && result.summary.most_anomalous_day hint="在目标日与 14 个参考日中排序"
? `${result.summary.most_anomalous_day}`
: "无"
}
hint={
result.summary.burst_detected && mostAnomalousRow
? `分数 ${mostAnomalousRow.Score.toFixed(4)} · ${mostAnomalousLevel.label}`
: "-"
}
tone="purple" tone="purple"
/> />
<MetricCard <MetricCard
label="最新状态" label="参考区间"
value={latestLevel.label} value={`${formatDateTime(result.reference_window?.start)} ~ ${formatDateTime(result.reference_window?.end)}`}
hint={latestDay ? `${latestDay.Day} 天 · 分数 ${latestDay.Score.toFixed(4)}` : "-"} hint={`${result.reference_window?.day_count ?? 14} 个同刻参考日`}
tone={latestLevel.color === "success" ? "green" : "orange"} tone="blue"
/> />
<MetricCard <MetricCard
label="测点 / 样本" label="有效 / 排除测点"
value={`${result.sensor_nodes.length} / ${result.sample_count}`} value={`${result.sensor_nodes.length} / ${excludedCount}`}
hint={`每日采样点数:${result.points_per_day}`} hint={`${result.sampling_interval_minutes ?? 15} 分钟采样,${result.points_per_day} 点/天`}
tone="blue" tone="blue"
/> />
</Box> </Box>
</Box> </Box>
{/* Score Trend Chart */}
<Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> <Box className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<Box className="flex items-center gap-2"> <Box className="flex items-center gap-2">
<ShowChartIcon className="h-5 w-5 text-blue-600" /> <ShowChartIcon className="text-blue-600" />
<Typography variant="subtitle1" className="font-bold text-gray-800"> <Typography variant="subtitle1" className="font-bold">
15
</Typography> </Typography>
</Box> </Box>
<Tooltip title="分数越小越异常,0 以下通常意味着更值得关注。"> <Tooltip title="灰色点为前 14 天参考,最后一个点为本次目标。">
<InfoOutlinedIcon fontSize="small" className="text-gray-400" /> <InfoOutlinedIcon fontSize="small" className="text-gray-400" />
</Tooltip> </Tooltip>
</Box> </Box>
<Box sx={{ height: 250, px: 1.5, py: 1 }}> <Box sx={{ height: 270, px: 1.5, py: 1 }}>
<ReactECharts <ReactECharts
option={chartOption} option={chartOption}
style={{ height: "100%", width: "100%" }} style={{ height: "100%", width: "100%" }}
onEvents={{ onEvents={{
click: (params: { data?: { value?: [number, number] } }) => { click: (params: { data?: { day?: number } }) =>
const day = params?.data?.value?.[0]; setSelectedDay(params.data?.day ?? null),
if (typeof day === "number") {
setSelectedDay(day);
}
},
}} }}
/> />
</Box> </Box>
</Box> </Box>
{/* Selected Day Interpretation */} {rankingSeries.length > 0 ? (
{/* <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> <Box className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<Typography variant="subtitle1" className="font-bold text-gray-800"> <Typography variant="subtitle1" className="font-bold">
选中日解读
</Typography> </Typography>
{selectedRow ? ( <Typography variant="caption" color="text.secondary">
<Chip
size="small"
label={`第 ${selectedRow.Day} 天`}
sx={{
height: 22,
backgroundColor: "rgba(37, 99, 235, 0.08)",
color: "#2563eb",
fontWeight: 600,
fontSize: "0.75rem",
border: "none",
}}
/>
) : null}
</Box>
{selectedRow ? (
<Box className="space-y-3 px-4 py-3">
<Box className="flex items-center gap-2">
<Chip
label={getScoreLevel(selectedRow.Score).label}
color={getScoreLevel(selectedRow.Score).color}
variant="filled"
/>
</Box>
<Typography variant="body2" className="text-gray-700">
异常分数:<span className="font-semibold">{selectedRow.Score.toFixed(4)}</span>
</Typography>
<Typography variant="body2" className="text-gray-700">
模型判定:{selectedRow.IsBurst ? "异常日(Prediction = -1" : "正常日(Prediction = 1"}
</Typography>
<Typography variant="body2" className="text-gray-700">
解读建议:
{selectedRow.Score <= -0.6
? "高风险异常,建议优先复核对应测点的原始压力曲线与现场工况。"
: selectedRow.Score <= -0.2
? "存在可疑波动,建议结合相邻测点和调度记录进一步确认。"
: "未见明显异常,可作为基线日参考。"}
</Typography> </Typography>
</Box> </Box>
) : ( <Box sx={{ height: 280, px: 1.5, py: 1 }}>
<Typography variant="body2" className="px-4 py-3 text-gray-500">
请在趋势图或表格中选择一天查看详细解释。
</Typography>
)}
</Box> */}
{/* Latest Sensor Rankings */}
{/* <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3">
<Typography variant="subtitle1" className="font-bold text-gray-800">
最新测点高频特征排名
</Typography>
<Typography variant="caption" className="text-gray-500">
仅展示最新一天
</Typography>
</Box>
<Box sx={{ height: 260, px: 1.5, py: 1 }}>
<ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} /> <ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} />
</Box> </Box>
<Box className="flex flex-wrap gap-2 border-t border-gray-100 px-4 py-3">
{result.summary.latest_sensor_rankings.slice(0, 5).map((item) => (
<Button
key={item.sensor_node}
size="small"
variant="outlined"
onClick={() => locateSensors([item.sensor_node])}
sx={{
borderColor: "#bfdbfe",
color: "#2563eb",
"&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
}}
>
{item.sensor_node}
</Button>
))}
</Box> </Box>
</Box> */} ) : null}
{/* Results Table */}
<Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> <Box className="flex items-center gap-2 border-b border-gray-100 px-4 py-3">
<Box className="flex items-center gap-2"> <FormatListBulleted className="text-blue-600" />
<FormatListBulleted className="h-5 w-5 text-blue-600" /> <Typography variant="subtitle1" className="font-bold">
<Typography variant="subtitle1" className="font-bold text-gray-800">
</Typography> </Typography>
</Box> </Box>
<Chip <Box sx={{ height: 360, px: 1, py: 1 }}>
size="small"
label={`${rows.length}`}
sx={{
height: 22,
backgroundColor: "rgba(37, 99, 235, 0.08)",
color: "#2563eb",
fontWeight: 600,
fontSize: "0.75rem",
border: "none",
}}
/>
</Box>
<Box sx={{ height: 320, px: 1, py: 1 }}>
<DataGrid <DataGrid
rows={rows} rows={tableRows}
columns={columns} columns={columns}
columnBufferPx={100}
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText} localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
initialState={{ pageSizeOptions={[15]}
pagination: { paginationModel: { pageSize: 50, page: 0 } }, initialState={{ pagination: { paginationModel: { pageSize: 15, page: 0 } } }}
}}
pageSizeOptions={[50]}
hideFooterSelectedRowCount
sx={{
border: "none",
"& .MuiDataGrid-cell": { borderColor: "#f0f0f0" },
"& .MuiDataGrid-columnHeaders": { backgroundColor: "#fafafa" },
"& .MuiDataGrid-row:hover": { backgroundColor: "#f8fafc" },
// Hide the rows per page selector since it's fixed to 50
"& .MuiTablePagination-selectLabel": { display: "none" },
"& .MuiTablePagination-input": { display: "none" },
}}
disableRowSelectionOnClick disableRowSelectionOnClick
onRowClick={(params) => setSelectedDay(Number(params.row.Day))} onRowClick={(params) => setSelectedDay(Number(params.row.Day))}
getRowClassName={(params) =>
params.row.Day === resultsState.selectedDay ? "bg-blue-50" : ""
}
sx={{ border: "none" }}
/> />
</Box> </Box>
</Box> </Box>
@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useMemo, useState } from "react";
import { import {
Box, Box,
Button, Button,
@@ -69,6 +69,16 @@ const SchemeQuery: React.FC<Props> = ({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
const setSchemes = onSchemesChange || setInternalSchemes; const setSchemes = onSchemesChange || setInternalSchemes;
const sortedSchemes = useMemo(
() =>
schemes
.slice()
.sort(
(a, b) =>
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
),
[schemes],
);
const buildDisplayResult = ( const buildDisplayResult = (
scheme: Pick<BurstDetectionSchemeRecord, "scheme_name" | "username" | "create_time">, scheme: Pick<BurstDetectionSchemeRecord, "scheme_name" | "username" | "create_time">,
@@ -105,6 +115,12 @@ const SchemeQuery: React.FC<Props> = ({
username: payload?.username ?? scheme.username, username: payload?.username ?? scheme.username,
create_time: payload?.create_time ?? scheme.create_time, create_time: payload?.create_time ?? scheme.create_time,
algorithm_params: payload?.algorithm_params ?? detail?.algorithm_params, algorithm_params: payload?.algorithm_params ?? detail?.algorithm_params,
requested_target_time: payload?.requested_target_time,
target_time: payload?.target_time,
reference_window: payload?.reference_window,
sampling_interval_minutes: payload?.sampling_interval_minutes,
daily_scores: payload?.daily_scores,
data_quality: payload?.data_quality,
}; };
}; };
@@ -214,7 +230,7 @@ const SchemeQuery: React.FC<Props> = ({
</Box> </Box>
<Box className="flex-1 overflow-auto"> <Box className="flex-1 overflow-auto">
{schemes.length === 0 ? ( {sortedSchemes.length === 0 ? (
<Box className="flex h-full flex-col items-center justify-center text-center text-gray-400"> <Box className="flex h-full flex-col items-center justify-center text-center text-gray-400">
<Typography variant="body2"></Typography> <Typography variant="body2"></Typography>
<Typography variant="caption" className="mt-1"> <Typography variant="caption" className="mt-1">
@@ -224,9 +240,9 @@ const SchemeQuery: React.FC<Props> = ({
) : ( ) : (
<Box className="space-y-2 p-2"> <Box className="space-y-2 p-2">
<Typography variant="caption" className="px-2 text-gray-500"> <Typography variant="caption" className="px-2 text-gray-500">
{schemes.length} {sortedSchemes.length}
</Typography> </Typography>
{schemes.map((scheme) => { {sortedSchemes.map((scheme) => {
const summary = scheme.scheme_detail?.result_summary; const summary = scheme.scheme_detail?.result_summary;
const payload = scheme.scheme_detail?.result_payload; const payload = scheme.scheme_detail?.result_payload;
const isBurst = payload?.summary?.burst_detected ?? summary?.burst_detected ?? false; const isBurst = payload?.summary?.burst_detected ?? summary?.burst_detected ?? false;
@@ -235,6 +251,9 @@ const SchemeQuery: React.FC<Props> = ({
const mostAnomalousDay = const mostAnomalousDay =
payload?.summary?.most_anomalous_day ?? summary?.most_anomalous_day ?? "-"; payload?.summary?.most_anomalous_day ?? summary?.most_anomalous_day ?? "-";
const sensorCount = payload?.sensor_nodes?.length ?? scheme.scheme_detail?.sensor_nodes?.length ?? 0; const sensorCount = payload?.sensor_nodes?.length ?? scheme.scheme_detail?.sensor_nodes?.length ?? 0;
const targetTime = payload?.target_time ?? payload?.summary?.target_time;
const targetScore = payload?.summary?.target_score ?? summary?.target_score;
const targetRank = payload?.summary?.target_rank ?? summary?.target_rank;
return ( return (
<Card key={scheme.scheme_id} variant="outlined" className="transition-shadow hover:shadow-md"> <Card key={scheme.scheme_id} variant="outlined" className="transition-shadow hover:shadow-md">
@@ -283,18 +302,22 @@ const SchemeQuery: React.FC<Props> = ({
<Box className="grid grid-cols-3 gap-2"> <Box className="grid grid-cols-3 gap-2">
<Box className="rounded bg-gray-50 p-2"> <Box className="rounded bg-gray-50 p-2">
<Typography variant="caption" className="text-gray-500"> <Typography variant="caption" className="text-gray-500">
{targetTime ? "目标时刻" : "异常天数"}
</Typography> </Typography>
<Typography variant="body2" className="font-semibold text-gray-900"> <Typography variant="body2" className="font-semibold text-gray-900">
{anomalyDayCount} {targetTime ? dayjs(targetTime).format("MM-DD HH:mm") : anomalyDayCount}
</Typography> </Typography>
</Box> </Box>
<Box className="rounded bg-gray-50 p-2"> <Box className="rounded bg-gray-50 p-2">
<Typography variant="caption" className="text-gray-500"> <Typography variant="caption" className="text-gray-500">
{targetTime ? "目标分数" : "最异常日"}
</Typography> </Typography>
<Typography variant="body2" className="font-semibold text-gray-900"> <Typography variant="body2" className="font-semibold text-gray-900">
{isBurst {targetTime
? typeof targetScore === "number"
? targetScore.toFixed(4)
: "-"
: isBurst
? typeof mostAnomalousDay === "number" ? typeof mostAnomalousDay === "number"
? `${mostAnomalousDay}` ? `${mostAnomalousDay}`
: mostAnomalousDay : mostAnomalousDay
@@ -303,10 +326,12 @@ const SchemeQuery: React.FC<Props> = ({
</Box> </Box>
<Box className="rounded bg-gray-50 p-2"> <Box className="rounded bg-gray-50 p-2">
<Typography variant="caption" className="text-gray-500"> <Typography variant="caption" className="text-gray-500">
{targetTime ? "异常排名" : "测点数"}
</Typography> </Typography>
<Typography variant="body2" className="font-semibold text-gray-900"> <Typography variant="body2" className="font-semibold text-gray-900">
{sensorCount} {targetTime && targetRank
? `${targetRank} / ${payload?.day_count ?? 15}`
: sensorCount}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
@@ -326,6 +351,8 @@ const SchemeQuery: React.FC<Props> = ({
if (ds === "monitoring") return "监测数据"; if (ds === "monitoring") return "监测数据";
if (os === "simulation_scheme_timerange") return "模拟数据"; if (os === "simulation_scheme_timerange") return "模拟数据";
if (os === "backend_timerange") return "监测数据"; if (os === "backend_timerange") return "监测数据";
if (os === "latest_monitoring") return "最新监测数据";
if (os === "historical_monitoring") return "历史监测回放";
return os || "-"; return os || "-";
})()} })()}
</Typography> </Typography>
@@ -344,14 +371,16 @@ const SchemeQuery: React.FC<Props> = ({
</Box> </Box>
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
<Typography variant="caption" className="text-gray-600"> <Typography variant="caption" className="text-gray-600">
: :
</Typography> </Typography>
<Typography variant="caption" className="font-medium text-gray-900"> <Typography variant="caption" className="font-medium text-gray-900">
{scheme.scheme_detail?.algorithm_params?.mu ?? payload?.algorithm_params?.mu ?? "-"} {scheme.scheme_detail?.algorithm_params?.mu ?? payload?.algorithm_params?.mu ?? "-"}
{scheme.scheme_detail?.algorithm_params?.points_per_day ?? {scheme.scheme_detail?.algorithm_params?.points_per_day ??
payload?.algorithm_params?.points_per_day ?? payload?.algorithm_params?.points_per_day ??
"-"} "-"}
{payload?.summary?.score_threshold ?? "-"}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
@@ -3,11 +3,16 @@ export interface BurstDetectionRow {
Score: number; Score: number;
Prediction: number; Prediction: number;
IsBurst: boolean; IsBurst: boolean;
Timestamp?: string;
Role?: "reference" | "target";
} }
export interface BurstDetectionSensorRanking { export interface BurstDetectionSensorRanking {
sensor_node: string; sensor_node: string;
latest_high_frequency_value: number; latest_high_frequency_value: number;
historical_mean?: number;
historical_std?: number;
standardized_deviation?: number;
} }
export interface BurstDetectionSummary { export interface BurstDetectionSummary {
@@ -17,6 +22,11 @@ export interface BurstDetectionSummary {
anomaly_days: number[]; anomaly_days: number[];
anomaly_day_count: number; anomaly_day_count: number;
latest_sensor_rankings: BurstDetectionSensorRanking[]; latest_sensor_rankings: BurstDetectionSensorRanking[];
target_score?: number;
score_threshold?: number;
target_rank?: number;
target_time?: string;
reference_day_count?: number;
} }
export interface BurstDetectionAlgorithmParams { export interface BurstDetectionAlgorithmParams {
@@ -27,6 +37,7 @@ export interface BurstDetectionAlgorithmParams {
contamination?: number | "auto"; contamination?: number | "auto";
random_state?: number; random_state?: number;
}; };
score_threshold?: number;
} }
export interface BurstDetectionResult { export interface BurstDetectionResult {
@@ -51,6 +62,25 @@ export interface BurstDetectionResult {
type?: string; type?: string;
}; };
algorithm_params?: BurstDetectionAlgorithmParams; algorithm_params?: BurstDetectionAlgorithmParams;
requested_target_time?: string | null;
target_time?: string;
reference_window?: {
start: string;
end: string;
day_count: number;
};
sampling_interval_minutes?: number;
daily_scores?: Array<{
timestamp: string;
role: "reference" | "target";
score: number;
raw_prediction: number;
}>;
data_quality?: {
included_sensors: string[];
excluded_sensors: Array<{ sensor_node: string; reason: string }>;
minimum_required_sensors: number;
};
} }
export interface BurstDetectionSchemeDetail { export interface BurstDetectionSchemeDetail {
@@ -43,6 +43,7 @@ export interface SchemeItem {
scheme_start_time: string; scheme_start_time: string;
scheme_detail?: { scheme_detail?: {
modify_total_duration: number; modify_total_duration: number;
burst_ID?: string[] | string;
}; };
} }
@@ -128,6 +129,9 @@ const AnalysisParameters: React.FC<Props> = ({
}); });
const burstSchemes = (response.data as SchemeItem[]).filter( const burstSchemes = (response.data as SchemeItem[]).filter(
(scheme) => scheme.scheme_type === "burst_analysis", (scheme) => scheme.scheme_type === "burst_analysis",
).sort(
(a, b) =>
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
); );
setFormField("schemes", burstSchemes); setFormField("schemes", burstSchemes);
@@ -234,7 +238,24 @@ const AnalysisParameters: React.FC<Props> = ({
}, },
); );
onResult(response.data as BurstLocationResult); const resultPayload = response.data as BurstLocationResult;
const selectedBurstIds = normalizeBurstIds(selectedScheme?.scheme_detail?.burst_ID);
onResult(
selectedBurstIds.length > 0
? {
...resultPayload,
simulation_scheme: {
...resultPayload.simulation_scheme,
name: resultPayload.simulation_scheme?.name ?? selectedScheme?.scheme_name,
type: resultPayload.simulation_scheme?.type ?? selectedScheme?.scheme_type,
burst_ids:
resultPayload.simulation_scheme?.burst_ids?.length
? resultPayload.simulation_scheme.burst_ids
: selectedBurstIds,
},
}
: resultPayload,
);
open?.({ open?.({
key: "burst-location-analysis-success", key: "burst-location-analysis-success",
type: "success", type: "success",
@@ -500,3 +521,11 @@ const AnalysisParameters: React.FC<Props> = ({
}; };
export default AnalysisParameters; export default AnalysisParameters;
const normalizeBurstIds = (value: string[] | string | undefined) => {
if (!value) return [];
const values = Array.isArray(value) ? value : [value];
return Array.from(
new Set(values.map((item) => String(item).trim()).filter(Boolean)),
);
};
@@ -13,6 +13,7 @@ import {
TableHead, TableHead,
TableRow, TableRow,
Button, Button,
Link,
} from "@mui/material"; } from "@mui/material";
import { import {
FormatListBulleted, FormatListBulleted,
@@ -183,6 +184,7 @@ const LocationResults: React.FC<Props> = ({ result }) => {
properties: { properties: {
name: "爆管定位高亮", name: "爆管定位高亮",
value: "burst_location_highlight", value: "burst_location_highlight",
queryable: false,
}, },
}); });
map.addLayer(layer); map.addLayer(layer);
@@ -251,6 +253,7 @@ const LocationResults: React.FC<Props> = ({ result }) => {
); );
const sourceLabel = getDataSourceLabel(result); const sourceLabel = getDataSourceLabel(result);
const normalDataDescription = getNormalDataDescription(result); const normalDataDescription = getNormalDataDescription(result);
const simulationBurstIds = result.simulation_scheme?.burst_ids ?? [];
return ( return (
<Box className="h-full overflow-auto p-1"> <Box className="h-full overflow-auto p-1">
@@ -287,6 +290,7 @@ const LocationResults: React.FC<Props> = ({ result }) => {
variant="outlined" variant="outlined"
startIcon={<LocationOnIcon />} startIcon={<LocationOnIcon />}
onClick={() => locatePipes([result.located_pipe])} onClick={() => locatePipes([result.located_pipe])}
disabled={!result.located_pipe}
sx={{ sx={{
height: 24, height: 24,
minWidth: 0, minWidth: 0,
@@ -348,6 +352,55 @@ const LocationResults: React.FC<Props> = ({ result }) => {
: {result.simulation_scheme.name} : {result.simulation_scheme.name}
</Typography> </Typography>
) : null} ) : null}
{simulationBurstIds.length > 0 ? (
<Box className="mt-1 flex flex-wrap items-center gap-1">
<Typography variant="caption" className="text-purple-600">
:
</Typography>
{simulationBurstIds.map((pipeId) => (
<Link
key={pipeId}
component="button"
variant="caption"
onClick={() => locatePipes([pipeId])}
title={pipeId}
sx={{
maxWidth: 132,
color: "#7c3aed",
fontSize: "0.75rem",
fontWeight: 700,
lineHeight: "22px",
cursor: "pointer",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
textDecoration: "underline",
textUnderlineOffset: "2px",
"&:hover": {
color: "#5b21b6",
},
}}
>
{pipeId}
</Link>
))}
<Button
size="small"
variant="text"
startIcon={<LocationOnIcon />}
onClick={() => locatePipes(simulationBurstIds)}
sx={{
minWidth: 0,
px: 0.5,
py: 0,
color: "#7c3aed",
fontSize: "0.72rem",
}}
>
</Button>
</Box>
) : null}
</Box> </Box>
</Box> </Box>
@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { import {
Box, Box,
Button, Button,
@@ -13,8 +13,12 @@ import {
IconButton, IconButton,
Tooltip, Tooltip,
Typography, Typography,
Link,
} from "@mui/material"; } from "@mui/material";
import { Info as InfoIcon } from "@mui/icons-material"; import {
Info as InfoIcon,
LocationOn as LocationOnIcon,
} from "@mui/icons-material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
@@ -24,6 +28,14 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { NETWORK_NAME, config } from "@config/config"; import { NETWORK_NAME, config } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { GeoJSON } from "ol/format";
import Feature from "ol/Feature";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Stroke, Style, Circle, Fill } from "ol/style";
import { bbox, featureCollection } from "@turf/turf";
import { import {
BurstLocationResult, BurstLocationResult,
BurstLocationSchemeDetail, BurstLocationSchemeDetail,
@@ -43,6 +55,7 @@ export interface BurstLocationSchemeQueryState {
queryAll: boolean; queryAll: boolean;
queryDate: Dayjs | null; queryDate: Dayjs | null;
expandedId: number | null; expandedId: number | null;
simulationBurstIdsByName: Record<string, string[]>;
} }
export const createBurstLocationSchemeQueryState = export const createBurstLocationSchemeQueryState =
@@ -50,6 +63,7 @@ export const createBurstLocationSchemeQueryState =
queryAll: true, queryAll: true,
queryDate: dayjs(), queryDate: dayjs(),
expandedId: null, expandedId: null,
simulationBurstIdsByName: {},
}); });
const SchemeQuery: React.FC<Props> = ({ const SchemeQuery: React.FC<Props> = ({
@@ -60,16 +74,117 @@ const SchemeQuery: React.FC<Props> = ({
onStateChange, onStateChange,
}) => { }) => {
const { open } = useNotification(); const { open } = useNotification();
const map = useMap();
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
const [queryState, , setQueryField] = useControllableObjectState( const [queryState, , setQueryField] = useControllableObjectState(
state, state,
onStateChange, onStateChange,
createBurstLocationSchemeQueryState(), createBurstLocationSchemeQueryState(),
); );
const { queryAll, queryDate, expandedId } = queryState; const { queryAll, queryDate, expandedId } = queryState;
const simulationBurstIdsByName = queryState.simulationBurstIdsByName ?? {};
const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]); const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
const setSchemes = onSchemesChange || setInternalSchemes; const setSchemes = onSchemesChange || setInternalSchemes;
const sortedSchemes = useMemo(
() =>
schemes
.slice()
.sort(
(a, b) =>
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
),
[schemes],
);
useEffect(() => {
if (!map) return;
const layer = new VectorLayer({
source: new VectorSource(),
style: new Style({
stroke: new Stroke({
color: "#a855f7",
width: 6,
}),
image: new Circle({
radius: 8,
fill: new Fill({ color: "#a855f7" }),
stroke: new Stroke({ color: "#fff", width: 2 }),
}),
}),
properties: {
name: "爆管定位模拟管段高亮",
value: "burst_location_simulation_pipe_highlight",
queryable: false,
},
});
map.addLayer(layer);
highlightLayerRef.current = layer;
return () => {
highlightLayerRef.current = null;
map.removeLayer(layer);
};
}, [map]);
useEffect(() => {
const source = highlightLayerRef.current?.getSource();
if (!source) return;
source.clear();
highlightFeatures.forEach((feature) => source.addFeature(feature));
}, [highlightFeatures]);
const locatePipes = async (pipeIds: string[]) => {
const uniquePipeIds = Array.from(new Set(pipeIds.filter(Boolean)));
if (!uniquePipeIds.length || !map) return;
try {
let features = await queryFeaturesByIds(uniquePipeIds, "geo_pipes_mat");
if (features.length === 0) {
features = await queryFeaturesByIds(uniquePipeIds, "geo_pipes");
}
if (features.length === 0) return;
setHighlightFeatures(features);
const geojsonFormat = new GeoJSON();
const geojsonFeatures = features.map((feature) =>
geojsonFormat.writeFeatureObject(feature),
);
// @ts-ignore turf typing with ol geojson objects
const extent = bbox(featureCollection(geojsonFeatures));
map.getView().fit(extent, {
maxZoom: 19,
duration: 1000,
padding: [100, 100, 100, 100],
});
} catch (error) {
console.error("Locate failed", error);
}
};
const getSimulationBurstIds = (payload?: BurstLocationResult) => {
const directIds = payload?.simulation_scheme?.burst_ids ?? [];
if (directIds.length > 0) return directIds;
const simulationSchemeName = payload?.simulation_scheme?.name;
return simulationSchemeName
? simulationBurstIdsByName[simulationSchemeName] ?? []
: [];
};
const enrichResultWithSimulationBurstIds = (payload: BurstLocationResult) => {
const simulationBurstIds = getSimulationBurstIds(payload);
if (simulationBurstIds.length === 0) return payload;
return {
...payload,
simulation_scheme: {
...payload.simulation_scheme,
burst_ids: simulationBurstIds,
},
};
};
const buildDisplayResult = ( const buildDisplayResult = (
scheme: Pick<BurstSchemeRecord, "scheme_name" | "username" | "create_time">, scheme: Pick<BurstSchemeRecord, "scheme_name" | "username" | "create_time">,
@@ -115,9 +230,30 @@ const SchemeQuery: React.FC<Props> = ({
params.query_date = queryDate.startOf("day").toISOString(); params.query_date = queryDate.startOf("day").toISOString();
} }
const response = await api.get(url, { params }); const [response, simulationResponse] = await Promise.all([
api.get(url, { params }),
api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params: { network: NETWORK_NAME },
}),
]);
const nextSchemes = response.data as BurstSchemeRecord[]; const nextSchemes = response.data as BurstSchemeRecord[];
setSchemes(nextSchemes); const nextSimulationBurstIdsByName = Object.fromEntries(
(simulationResponse.data as BurstSimulationSchemeItem[])
.filter((scheme) => scheme.scheme_type === "burst_analysis")
.map((scheme) => [
scheme.scheme_name,
normalizeBurstIds(scheme.scheme_detail?.burst_ID),
]),
);
setQueryField("simulationBurstIdsByName", nextSimulationBurstIdsByName);
setSchemes(
nextSchemes.map((scheme) =>
enrichSchemeWithSimulationBurstIds(
scheme,
nextSimulationBurstIdsByName,
),
),
);
open?.({ open?.({
type: "success", type: "success",
message: "查询成功", message: "查询成功",
@@ -157,7 +293,7 @@ const SchemeQuery: React.FC<Props> = ({
if (!normalizedResult) { if (!normalizedResult) {
throw new Error("方案详情缺少定位结果数据"); throw new Error("方案详情缺少定位结果数据");
} }
onViewResult(normalizedResult); onViewResult(enrichResultWithSimulationBurstIds(normalizedResult));
open?.({ open?.({
type: "success", type: "success",
message: "方案加载成功", message: "方案加载成功",
@@ -211,7 +347,7 @@ const SchemeQuery: React.FC<Props> = ({
</Box> </Box>
</Box> </Box>
<Box className="flex-1 overflow-auto"> <Box className="flex-1 overflow-auto">
{schemes.length === 0 ? ( {sortedSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400"> <Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4"> <Box className="mb-4">
<svg <svg
@@ -248,12 +384,13 @@ const SchemeQuery: React.FC<Props> = ({
) : ( ) : (
<Box className="space-y-2 p-2"> <Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2"> <Typography variant="caption" className="text-gray-500 px-2">
{schemes.length} {sortedSchemes.length}
</Typography> </Typography>
{schemes.map((scheme) => { {sortedSchemes.map((scheme) => {
const summary = scheme.scheme_detail?.result_summary; const summary = scheme.scheme_detail?.result_summary;
const payload = scheme.scheme_detail?.result_payload; const payload = scheme.scheme_detail?.result_payload;
const locatedPipe = payload?.located_pipe ?? summary?.located_pipe ?? "-"; const locatedPipe = payload?.located_pipe ?? summary?.located_pipe ?? "-";
const simulationBurstIds = getSimulationBurstIds(payload);
const leakage = const leakage =
payload?.burst_leakage ?? scheme.scheme_detail?.algorithm_params?.burst_leakage; payload?.burst_leakage ?? scheme.scheme_detail?.algorithm_params?.burst_leakage;
@@ -330,6 +467,52 @@ const SchemeQuery: React.FC<Props> = ({
{locatedPipe} {locatedPipe}
</Typography> </Typography>
</Box> </Box>
{simulationBurstIds.length > 0 ? (
<Box className="grid grid-cols-[78px_1fr] items-start gap-x-2">
<Typography variant="caption" className="mt-1 text-gray-600">
:
</Typography>
<Box className="flex flex-wrap gap-1">
{simulationBurstIds.map((pipeId) => (
<Link
key={pipeId}
component="button"
variant="caption"
onClick={() => locatePipes([pipeId])}
title={pipeId}
sx={{
maxWidth: 132,
color: "#7c3aed",
fontSize: "0.75rem",
fontWeight: 700,
lineHeight: "22px",
cursor: "pointer",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
textDecoration: "underline",
textUnderlineOffset: "2px",
"&:hover": {
color: "#5b21b6",
},
}}
>
{pipeId}
</Link>
))}
<Tooltip title="定位全部模拟管段">
<IconButton
size="small"
color="secondary"
onClick={() => locatePipes(simulationBurstIds)}
className="h-6 w-6 p-0"
>
<LocationOnIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
) : null}
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
<Typography variant="caption" className="text-gray-600"> <Typography variant="caption" className="text-gray-600">
: :
@@ -373,3 +556,46 @@ const SchemeQuery: React.FC<Props> = ({
}; };
export default SchemeQuery; export default SchemeQuery;
interface BurstSimulationSchemeItem {
scheme_name: string;
scheme_type: string;
scheme_detail?: {
burst_ID?: string[] | string;
};
}
const normalizeBurstIds = (value: string[] | string | undefined) => {
if (!value) return [];
const values = Array.isArray(value) ? value : [value];
return Array.from(
new Set(values.map((item) => String(item).trim()).filter(Boolean)),
);
};
const enrichSchemeWithSimulationBurstIds = (
scheme: BurstSchemeRecord,
simulationBurstIdsByName: Record<string, string[]>,
) => {
const payload = scheme.scheme_detail?.result_payload;
const simulationSchemeName = payload?.simulation_scheme?.name;
const simulationBurstIds = simulationSchemeName
? simulationBurstIdsByName[simulationSchemeName] ?? []
: [];
if (!payload || simulationBurstIds.length === 0) return scheme;
if (payload.simulation_scheme?.burst_ids?.length) return scheme;
return {
...scheme,
scheme_detail: {
...scheme.scheme_detail,
result_payload: {
...payload,
simulation_scheme: {
...payload.simulation_scheme,
burst_ids: simulationBurstIds,
},
},
},
};
};
@@ -37,6 +37,7 @@ export interface BurstLocationResult {
simulation_scheme?: { simulation_scheme?: {
name?: string; name?: string;
type?: string; type?: string;
burst_ids?: string[];
}; };
} }
@@ -198,6 +198,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
properties: { properties: {
name: "高亮管道", name: "高亮管道",
value: "highlight_pipeline", value: "highlight_pipeline",
queryable: false,
}, },
}); });
@@ -140,6 +140,7 @@ const LocationResults: React.FC<LocationResultsProps> = ({
properties: { properties: {
name: "爆管管段高亮", name: "爆管管段高亮",
value: "burst_pipe_highlight", value: "burst_pipe_highlight",
queryable: false,
}, },
}); });
@@ -51,6 +51,10 @@ import { Point } from "ol/geom";
import { toLonLat } from "ol/proj"; import { toLonLat } from "ol/proj";
import Timeline from "@components/olmap/core/Controls/Timeline"; import Timeline from "@components/olmap/core/Controls/Timeline";
import { SchemaItem, SchemeRecord } from "./types"; import { SchemaItem, SchemeRecord } from "./types";
import {
getPipeDiameterDisplay,
type PipeDiameterMap,
} from "./schemePipeDiameters";
interface SchemeQueryProps { interface SchemeQueryProps {
schemes?: SchemeRecord[]; schemes?: SchemeRecord[];
@@ -109,6 +113,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]); const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素 const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素
const [pipeDiametersByScheme, setPipeDiametersByScheme] = useState<
Record<number, PipeDiameterMap>
>({});
const [loadingDiameterByScheme, setLoadingDiameterByScheme] = useState<
Record<number, boolean>
>({});
const { open } = useNotification(); const { open } = useNotification();
@@ -128,7 +138,13 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
}; };
const filteredSchemes = useMemo(() => { const filteredSchemes = useMemo(() => {
return schemes.filter((scheme) => scheme.type === SCHEME_TYPE); return schemes
.filter((scheme) => scheme.type === SCHEME_TYPE)
.slice()
.sort(
(a, b) =>
moment(b.create_time).valueOf() - moment(a.create_time).valueOf(),
);
}, [schemes]); }, [schemes]);
const handleQuery = async () => { const handleQuery = async () => {
@@ -168,6 +184,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
? "没有找到任何方案" ? "没有找到任何方案"
: `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`, : `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`,
}); });
} else {
open?.({
type: "success",
message: "查询成功",
description: `共找到 ${filteredResults.length} 条方案记录`,
});
} }
} catch (error) { } catch (error) {
console.error("查询请求失败:", error); console.error("查询请求失败:", error);
@@ -203,6 +225,80 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
} }
}; };
useEffect(() => {
if (expandedId === null || pipeDiametersByScheme[expandedId]) {
return;
}
const scheme = filteredSchemes.find((scheme) => scheme.id === expandedId);
const pipeIds = scheme?.schemeDetail?.burst_ID ?? [];
if (pipeIds.length === 0) {
return;
}
let cancelled = false;
setLoadingDiameterByScheme((previous) => ({
...previous,
[expandedId]: true,
}));
const loadPipeDiameters = async () => {
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat");
const foundPipeIds = new Set(
features.map((feature) => String(feature.getProperties().id)),
);
const missingPipeIds = pipeIds.filter(
(pipeId) => !foundPipeIds.has(pipeId),
);
if (missingPipeIds.length > 0) {
const fallbackFeatures = await queryFeaturesByIds(
missingPipeIds,
"geo_pipes",
);
features = [...features, ...fallbackFeatures];
}
const nextDiameters: PipeDiameterMap = Object.fromEntries(
pipeIds.map((pipeId) => [pipeId, null]),
);
features.forEach((feature) => {
const properties = feature.getProperties();
const pipeId = String(properties.id);
const diameter = Number(properties.diameter);
nextDiameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
});
if (cancelled) {
return;
}
setPipeDiametersByScheme((previous) => ({
...previous,
[expandedId]: nextDiameters,
}));
setLoadingDiameterByScheme((previous) => ({
...previous,
[expandedId]: false,
}));
};
loadPipeDiameters().catch((error) => {
console.error("查询管径失败:", error);
if (!cancelled) {
setLoadingDiameterByScheme((previous) => ({
...previous,
[expandedId]: false,
}));
}
});
return () => {
cancelled = true;
};
}, [expandedId, filteredSchemes, pipeDiametersByScheme]);
// 内部的方案查询函数 // 内部的方案查询函数
const handleViewDetails = (id: number) => { const handleViewDetails = (id: number) => {
const scheme = filteredSchemes.find((s) => s.id === id); const scheme = filteredSchemes.find((s) => s.id === id);
@@ -301,6 +397,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
properties: { properties: {
name: "爆管管段高亮", name: "爆管管段高亮",
value: "burst_pipe_highlight", value: "burst_pipe_highlight",
queryable: false,
}, },
}); });
@@ -562,7 +659,11 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
variant="caption" variant="caption"
className="font-medium text-gray-900" className="font-medium text-gray-900"
> >
560 mm {getPipeDiameterDisplay(
scheme.schemeDetail?.burst_ID,
pipeDiametersByScheme[scheme.id],
!!loadingDiameterByScheme[scheme.id],
)}
</Typography> </Typography>
</Box> </Box>
<Box className="flex items-center gap-2"> <Box className="flex items-center gap-2">
@@ -546,6 +546,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
properties: { properties: {
name: "阀门节点高亮", name: "阀门节点高亮",
value: "valve_node_highlight", value: "valve_node_highlight",
queryable: false,
}, },
}); });
@@ -0,0 +1,20 @@
import { getPipeDiameterDisplay } from "./schemePipeDiameters";
describe("getPipeDiameterDisplay", () => {
it("shows the actual diameter for one burst pipe", () => {
expect(getPipeDiameterDisplay(["P-1"], { "P-1": 315 })).toBe("315 mm");
});
it("shows pipe IDs with diameters for multiple burst pipes", () => {
expect(
getPipeDiameterDisplay(["P-1", "P-2"], {
"P-1": 315,
"P-2": 800,
}),
).toBe("P-1: 315 mmP-2: 800 mm");
});
it("does not fall back to a fixed diameter when a diameter is missing", () => {
expect(getPipeDiameterDisplay(["P-1"], {})).toBe("N/A");
});
});
@@ -0,0 +1,27 @@
export type PipeDiameterMap = Record<string, number | null | undefined>;
export const getPipeDiameterDisplay = (
pipeIds: string[] | undefined,
diameters: PipeDiameterMap | undefined,
loading = false,
): string => {
if (loading) {
return "查询中...";
}
if (!pipeIds?.length) {
return "N/A";
}
const values = pipeIds.map((pipeId) => {
const diameter = diameters?.[pipeId];
const displayValue =
typeof diameter === "number" && Number.isFinite(diameter)
? `${diameter} mm`
: "N/A";
return pipeIds.length === 1 ? displayValue : `${pipeId}: ${displayValue}`;
});
return values.join("");
};
@@ -158,6 +158,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
properties: { properties: {
name: "污染源节点", name: "污染源节点",
value: "contaminant_source_highlight", value: "contaminant_source_highlight",
queryable: false,
}, },
}); });
@@ -166,6 +166,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
properties: { properties: {
name: "污染源高亮", name: "污染源高亮",
value: "contaminant_source_highlight", value: "contaminant_source_highlight",
queryable: false,
}, },
}); });
@@ -202,7 +203,13 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
}; };
const filteredSchemes = useMemo(() => { const filteredSchemes = useMemo(() => {
return schemes.filter((scheme) => scheme.type === SCHEME_TYPE); return schemes
.filter((scheme) => scheme.type === SCHEME_TYPE)
.slice()
.sort(
(a, b) =>
moment(b.create_time).valueOf() - moment(a.create_time).valueOf(),
);
}, [schemes]); }, [schemes]);
const handleQuery = async () => { const handleQuery = async () => {
@@ -243,6 +250,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
? "没有找到任何方案" ? "没有找到任何方案"
: `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`, : `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`,
}); });
} else {
open?.({
type: "success",
message: "查询成功",
description: `共找到 ${filteredResults.length} 条方案记录`,
});
} }
} catch (error) { } catch (error) {
console.error("查询请求失败:", error); console.error("查询请求失败:", error);
@@ -74,18 +74,31 @@ const AnalysisParameters: React.FC<Props> = ({
advancedOpen, advancedOpen,
} = parametersState; } = parametersState;
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [qSumInput, setQSumInput] = useState(() => String(qSum));
React.useEffect(() => {
setQSumInput(String(qSum));
}, [qSum]);
const parsedQSum = Number(qSumInput);
const qSumIsValid =
qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 360;
const isValid = useMemo(() => { const isValid = useMemo(() => {
if (!schemeName.trim() || !startTime || !endTime) return false; if (!schemeName.trim() || !startTime || !endTime) return false;
return startTime.isBefore(endTime) && qSum >= 360; return startTime.isBefore(endTime) && qSumIsValid;
}, [schemeName, startTime, endTime, qSum]); }, [schemeName, startTime, endTime, qSumIsValid]);
const handleRun = async () => { const handleRun = async () => {
if (!isValid || !startTime || !endTime) { if (!isValid || !startTime || !endTime) {
open?.({ type: "error", message: "请完善参数并确认时间范围合法" }); open?.({
type: "error",
message: "请完善参数并确认时间范围合法",
description: !qSumIsValid ? `总漏损流量需不小于 360 ${FLOW_DISPLAY_UNIT}` : undefined,
});
return; return;
} }
setFormField("qSum", parsedQSum);
setRunning(true); setRunning(true);
open?.({ open?.({
key: "dma-leak-analysis-progress", key: "dma-leak-analysis-progress",
@@ -213,12 +226,22 @@ const AnalysisParameters: React.FC<Props> = ({
<TextField <TextField
type="number" type="number"
size="small" size="small"
value={qSum} value={qSumInput}
onChange={(e) => { onChange={(e) => {
const value = Number(e.target.value); const rawValue = e.target.value;
setFormField("qSum", Number.isNaN(value) ? 1440 : Math.max(360, value)); setQSumInput(rawValue);
const value = Number(rawValue);
if (rawValue.trim() !== "" && Number.isFinite(value)) {
setFormField("qSum", value);
}
}} }}
inputProps={{ min: 360, step: 10 }} inputProps={{ min: 360, step: 10 }}
error={qSumInput.trim() !== "" && !qSumIsValid}
helperText={
qSumInput.trim() !== "" && !qSumIsValid
? `需不小于 360 ${FLOW_DISPLAY_UNIT}`
: " "
}
/> />
<Box <Box
sx={{ sx={{
@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useMemo, useState } from "react";
import { import {
Box, Box,
Button, Button,
@@ -65,6 +65,16 @@ const SchemeQuery: React.FC<Props> = ({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
const setSchemes = onSchemesChange || setInternalSchemes; const setSchemes = onSchemesChange || setInternalSchemes;
const sortedSchemes = useMemo(
() =>
schemes
.slice()
.sort(
(a, b) =>
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
),
[schemes],
);
const handleQuery = async () => { const handleQuery = async () => {
setLoading(true); setLoading(true);
@@ -78,6 +88,21 @@ const SchemeQuery: React.FC<Props> = ({
}); });
const nextSchemes = response.data as LeakageSchemeRecord[]; const nextSchemes = response.data as LeakageSchemeRecord[];
setSchemes(nextSchemes); setSchemes(nextSchemes);
if (nextSchemes.length === 0) {
open?.({
type: "error",
message: "查询结果",
description: queryAll
? "没有找到任何方案"
: `${queryDate?.format("YYYY-MM-DD")} 没有找到相关方案`,
});
} else {
open?.({
type: "success",
message: "查询成功",
description: `共找到 ${nextSchemes.length} 条方案记录`,
});
}
} catch (error: any) { } catch (error: any) {
open?.({ open?.({
type: "error", type: "error",
@@ -144,7 +169,7 @@ const SchemeQuery: React.FC<Props> = ({
</Box> </Box>
</Box> </Box>
<Box className="flex-1 overflow-auto"> <Box className="flex-1 overflow-auto">
{schemes.length === 0 ? ( {sortedSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400"> <Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4"> <Box className="mb-4">
<svg <svg
@@ -181,9 +206,9 @@ const SchemeQuery: React.FC<Props> = ({
) : ( ) : (
<Box className="space-y-2 p-2"> <Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2"> <Typography variant="caption" className="text-gray-500 px-2">
{schemes.length} {sortedSchemes.length}
</Typography> </Typography>
{schemes.map((scheme) => ( {sortedSchemes.map((scheme) => (
<Card key={scheme.scheme_id} variant="outlined" className="hover:shadow-md transition-shadow"> <Card key={scheme.scheme_id} variant="outlined" className="hover:shadow-md transition-shadow">
<CardContent className="p-3 pb-2 last:pb-3"> <CardContent className="p-3 pb-2 last:pb-3">
<Box className="flex items-start justify-between gap-2 mb-2"> <Box className="flex items-start justify-between gap-2 mb-2">
@@ -1,9 +1,13 @@
import { Map as OlMap, VectorTile } from "ol"; import { Map as OlMap } from "ol";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import VectorTileSource from "ol/source/VectorTile"; import VectorTileSource from "ol/source/VectorTile";
import { FlatStyleLike } from "ol/style/flat"; import { FlatStyleLike } from "ol/style/flat";
import { config } from "@/config/config"; import { config } from "@/config/config";
import {
VectorTileStyleSession,
versionedPropertyCase,
} from "@components/olmap/core/vectorTileStyleSession";
import { getAreaColor } from "./utils"; import { getAreaColor } from "./utils";
const JUNCTION_LAYER_VALUE = "junctions"; const JUNCTION_LAYER_VALUE = "junctions";
@@ -83,40 +87,6 @@ export const applyJunctionAreaRender = (
} }
}); });
const applyFeatureAreaIndex = (renderFeature: any) => {
const featureId = String(renderFeature.get("id") ?? "");
const areaIndex = nodeAreaIndexMap.get(featureId);
if (areaIndex !== undefined) {
renderFeature.properties_[propertyKey] = areaIndex;
}
};
const sourceTiles = (source as any).sourceTiles_;
if (sourceTiles) {
Object.values(sourceTiles).forEach((vectorTile: any) => {
const renderFeatures = vectorTile.getFeatures();
if (!renderFeatures || renderFeatures.length === 0) return;
renderFeatures.forEach((renderFeature: any) => {
applyFeatureAreaIndex(renderFeature);
});
});
}
const listener = (event: any) => {
try {
if (!(event.tile instanceof VectorTile)) return;
const renderFeatures = event.tile.getFeatures();
if (!renderFeatures || renderFeatures.length === 0) return;
renderFeatures.forEach((renderFeature: any) => {
applyFeatureAreaIndex(renderFeature);
});
} catch (error) {
console.error("Error applying junction area render:", error);
}
};
source.on("tileloadend", listener);
const fillCases: any[] = []; const fillCases: any[] = [];
areaIds.forEach((areaId, index) => { areaIds.forEach((areaId, index) => {
fillCases.push( fillCases.push(
@@ -131,14 +101,34 @@ export const applyJunctionAreaRender = (
); );
junctionLayer.set(RENDER_OWNER_KEY, ownerId); junctionLayer.set(RENDER_OWNER_KEY, ownerId);
junctionLayer.setStyle({ const session = new VectorTileStyleSession({
layer: junctionLayer,
source,
propertyKey,
defaultStyle: config.MAP_DEFAULT_STYLE as FlatStyleLike,
buildStyle: (statePropertyKey, versionKey, version) =>
({
...config.MAP_DEFAULT_STYLE, ...config.MAP_DEFAULT_STYLE,
"circle-fill-color": ["case", ...fillCases, defaultFillColor], "circle-fill-color": versionedPropertyCase(
"circle-stroke-color": ["case", ...fillCases, defaultStrokeColor], statePropertyKey,
} as FlatStyleLike); versionKey,
version,
fillCases,
defaultFillColor,
),
"circle-stroke-color": versionedPropertyCase(
statePropertyKey,
versionKey,
version,
fillCases,
defaultStrokeColor,
),
}) as FlatStyleLike,
});
session.commit(nodeAreaIndexMap);
return () => { return () => {
source.un("tileloadend", listener); session.dispose();
if (junctionLayer.get(RENDER_OWNER_KEY) === ownerId) { if (junctionLayer.get(RENDER_OWNER_KEY) === ownerId) {
junctionLayer.unset(RENDER_OWNER_KEY, true); junctionLayer.unset(RENDER_OWNER_KEY, true);
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike); junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
@@ -185,6 +185,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
zIndex: 1000, zIndex: 1000,
properties: { properties: {
name: "FlushingHighlight", name: "FlushingHighlight",
queryable: false,
}, },
}); });
@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { import {
@@ -108,6 +108,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
const setSchemes = onSchemesChange || setInternalSchemes; const setSchemes = onSchemesChange || setInternalSchemes;
const sortedSchemes = useMemo(
() =>
schemes
.slice()
.sort(
(a, b) =>
moment(b.create_time).valueOf() - moment(a.create_time).valueOf(),
),
[schemes],
);
useEffect(() => { useEffect(() => {
if (!map) return; if (!map) return;
@@ -169,6 +179,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
zIndex: 1000, zIndex: 1000,
properties: { properties: {
name: "FlushingQueryResultHighlight", name: "FlushingQueryResultHighlight",
queryable: false,
}, },
}); });
@@ -285,6 +296,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
message: "未找到相关方案", message: "未找到相关方案",
description: "请尝试更改查询条件", description: "请尝试更改查询条件",
}); });
} else {
open?.({
type: "success",
message: "查询成功",
description: `共找到 ${filteredResults.length} 条方案记录`,
});
} }
} catch (error) { } catch (error) {
console.error("查询请求失败:", error); console.error("查询请求失败:", error);
@@ -387,7 +404,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
{/* Results List */} {/* Results List */}
<Box className="flex-1 overflow-auto"> <Box className="flex-1 overflow-auto">
{schemes.length === 0 ? ( {sortedSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400"> <Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4"> <Box className="mb-4">
<svg <svg
@@ -424,9 +441,9 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
) : ( ) : (
<Box className="space-y-2 p-2"> <Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2"> <Typography variant="caption" className="text-gray-500 px-2">
{schemes.length} {sortedSchemes.length}
</Typography> </Typography>
{schemes.map((scheme) => ( {sortedSchemes.map((scheme) => (
<Card <Card
key={scheme.id} key={scheme.id}
variant="outlined" variant="outlined"
@@ -3,6 +3,8 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import type VectorTileSource from "ol/source/VectorTile";
import type { FlatStyleLike } from "ol/style/flat";
import { import {
Box, Box,
@@ -30,6 +32,11 @@ import { FiSkipBack, FiSkipForward } from "react-icons/fi";
import { config, NETWORK_NAME } from "@/config/config"; import { config, NETWORK_NAME } from "@/config/config";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { useMap } from "@components/olmap/core/MapComponent"; import { useMap } from "@components/olmap/core/MapComponent";
import { useTimelineTimeConfig } from "@components/olmap/core/Controls/useTimelineTimeConfig";
import {
VectorTileStyleSession,
versionedPropertyCase,
} from "@components/olmap/core/vectorTileStyleSession";
import { useHealthRisk } from "./HealthRiskContext"; import { useHealthRisk } from "./HealthRiskContext";
import { import {
PredictionResult, PredictionResult,
@@ -38,10 +45,11 @@ import {
RISK_BREAKS, RISK_BREAKS,
} from "./types"; } from "./types";
// 辅助函数:将日期向下取整到最近的15分钟 // 辅助函数:将日期向下取整到配置的水力时间步长
const getRoundedDate = (date: Date): Date => { const getRoundedDate = (date: Date, stepMinutes: number): Date => {
const safeStep = stepMinutes > 0 ? stepMinutes : 15;
const minutes = date.getHours() * 60 + date.getMinutes(); const minutes = date.getHours() * 60 + date.getMinutes();
const roundedMinutes = Math.floor(minutes / 15) * 15; const roundedMinutes = Math.floor(minutes / safeStep) * safeStep;
const roundedDate = new Date(date); const roundedDate = new Date(date);
roundedDate.setHours( roundedDate.setHours(
Math.floor(roundedMinutes / 60), Math.floor(roundedMinutes / 60),
@@ -52,6 +60,50 @@ const getRoundedDate = (date: Date): Date => {
return roundedDate; return roundedDate;
}; };
const buildHealthRiskStyle = (
propertyKey: string,
versionKey: string,
version: number,
): FlatStyleLike => {
const colorCases: any[] = [];
const widthCases: any[] = [];
RISK_BREAKS.forEach((breakValue, index) => {
colorCases.push(
["<=", ["get", propertyKey], breakValue],
RAINBOW_COLORS[index],
);
widthCases.push(
["<=", ["get", propertyKey], breakValue],
2 + (1 - index / (RISK_BREAKS.length - 1)) * 4,
);
});
return {
"stroke-color": versionedPropertyCase(
propertyKey,
versionKey,
version,
colorCases,
"rgba(128, 128, 128, 1)",
),
"stroke-width": versionedPropertyCase(
propertyKey,
versionKey,
version,
widthCases,
2,
),
};
};
const getSurvivalProbabilityAtYear = (
survivalFunction: SurvivalFunction,
index: number,
) => {
const values = survivalFunction.y;
if (values.length === 0) return 1;
return values[Math.max(0, Math.min(index, values.length - 1))];
};
interface TimelineProps { interface TimelineProps {
schemeDate?: Date; schemeDate?: Date;
timeRange?: { start: Date; end: Date }; timeRange?: { start: Date; end: Date };
@@ -91,9 +143,10 @@ const Timeline: React.FC<TimelineProps> = ({
const [isPlaying, setIsPlaying] = useState<boolean>(false); const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒 const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒
const [isPredicting, setIsPredicting] = useState<boolean>(false); const [isPredicting, setIsPredicting] = useState<boolean>(false);
const [sliderPreviewYear, setSliderPreviewYear] = useState<number | null>(null);
const { stepMinutes } = useTimelineTimeConfig();
// 使用 ref 存储当前的健康数据,供事件监听器读取,避免重复绑定 const healthStyleSessionRef = useRef<VectorTileStyleSession | null>(null);
const healthDataRef = useRef<Map<string, number>>(new Map());
// 计算时间轴范围 (4-73) // 计算时间轴范围 (4-73)
const minTime = 4; const minTime = 4;
@@ -102,9 +155,6 @@ const Timeline: React.FC<TimelineProps> = ({
const intervalRef = useRef<NodeJS.Timeout | null>(null); const intervalRef = useRef<NodeJS.Timeout | null>(null);
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
// 添加防抖引用
const debounceRef = useRef<NodeJS.Timeout | null>(null);
// 时间刻度数组 (4-73,每3个单位一个刻度) // 时间刻度数组 (4-73,每3个单位一个刻度)
const valueMarks = Array.from({ length: 24 }, (_, i) => ({ const valueMarks = Array.from({ length: 24 }, (_, i) => ({
value: 4 + i * 3, value: 4 + i * 3,
@@ -126,15 +176,18 @@ const Timeline: React.FC<TimelineProps> = ({
if (value < minTime || value > maxTime) { if (value < minTime || value > maxTime) {
return; return;
} }
// 防抖设置currentYear,避免频繁触发数据获取 setSliderPreviewYear(value);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
setCurrentYear(value);
}, 500); // 500ms 防抖延迟
}, },
[minTime, maxTime, setCurrentYear], [minTime, maxTime],
);
const handleSliderChangeCommitted = useCallback(
(_event: Event | React.SyntheticEvent, newValue: number | number[]) => {
const value = Array.isArray(newValue) ? newValue[0] : newValue;
setSliderPreviewYear(null);
setCurrentYear(value);
},
[setCurrentYear],
); );
// 播放控制 // 播放控制
@@ -200,11 +253,14 @@ const Timeline: React.FC<TimelineProps> = ({
}, [minTime, maxTime, setCurrentYear]); }, [minTime, maxTime, setCurrentYear]);
// 日期时间选择处理 // 日期时间选择处理
const handleDateTimeChange = useCallback((newDate: Date | null) => { const handleDateTimeChange = useCallback(
(newDate: Date | null) => {
if (newDate) { if (newDate) {
setSelectedDateTime(getRoundedDate(newDate)); setSelectedDateTime(getRoundedDate(newDate, stepMinutes));
} }
}, []); },
[stepMinutes],
);
// 播放间隔改变处理 // 播放间隔改变处理
const handleIntervalChange = useCallback( const handleIntervalChange = useCallback(
@@ -227,19 +283,16 @@ const Timeline: React.FC<TimelineProps> = ({
[isPlaying, maxTime, minTime, setCurrentYear], [isPlaying, maxTime, minTime, setCurrentYear],
); );
// 组件加载时设置初始时间为当前时间的最近15分钟 // 组件加载时设置初始时间为当前时间的配置时间步长
useEffect(() => { useEffect(() => {
setSelectedDateTime(getRoundedDate(new Date())); setSelectedDateTime(getRoundedDate(new Date(), stepMinutes));
return () => { return () => {
if (intervalRef.current) { if (intervalRef.current) {
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
} }
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
}; };
}, []); }, [stepMinutes]);
// 获取地图实例 // 获取地图实例
const map = useMap(); const map = useMap();
@@ -255,143 +308,50 @@ const Timeline: React.FC<TimelineProps> = ({
) ?? null; ) ?? null;
}, [map]); }, [map]);
// 根据索引从 survival_function 中获取生存概率
const getSurvivalProbabilityAtYear = useCallback(
(survivalFunc: SurvivalFunction, index: number): number => {
const { y } = survivalFunc;
if (y.length === 0) return 1;
// 确保索引在范围内
const safeIndex = Math.max(0, Math.min(index, y.length - 1));
return y[safeIndex];
},
[],
);
// 更新管道图层中的 healthRisk 属性
const updatePipeHealthData = useCallback(
(healthData: Map<string, number>) => {
if (!pipeLayer) return;
const source = pipeLayer.getSource() as any;
if (!source) return;
const sourceTiles = source.sourceTiles_;
if (!sourceTiles) return;
Object.values(sourceTiles).forEach((vectorTile: any) => {
const renderFeatures = vectorTile.getFeatures();
if (!renderFeatures || renderFeatures.length === 0) return;
renderFeatures.forEach((renderFeature: any) => {
const featureId = renderFeature.get("id");
const value = healthData.get(featureId);
if (value !== undefined) {
renderFeature.properties_["healthRisk"] = value;
}
});
});
},
[pipeLayer],
);
// 监听瓦片加载,为新瓦片设置 healthRisk 属性
// 只在 pipeLayer 变化时绑定一次,通过 ref 获取最新数据
useEffect(() => { useEffect(() => {
if (!pipeLayer) return; if (!pipeLayer) return;
const source = pipeLayer.getSource() as any; const source = pipeLayer.getSource() as VectorTileSource | null;
if (!source) return; if (!source) return;
const listener = (event: any) => { const defaultFlatStyle = config.MAP_DEFAULT_STYLE as FlatStyleLike;
const vectorTile = event.tile; healthStyleSessionRef.current?.dispose();
const renderFeatures = vectorTile.getFeatures(); healthStyleSessionRef.current = new VectorTileStyleSession({
if (!renderFeatures || renderFeatures.length === 0) return; layer: pipeLayer,
source,
const healthData = healthDataRef.current; propertyKey: "healthRisk",
renderFeatures.forEach((renderFeature: any) => { defaultStyle: defaultFlatStyle,
const featureId = renderFeature.get("id"); buildStyle: buildHealthRiskStyle,
const value = healthData.get(featureId); map,
if (value !== undefined) { buffered: true,
renderFeature.properties_["healthRisk"] = value;
}
}); });
};
source.on("tileloadend", listener);
return () => { return () => {
source.un("tileloadend", listener); healthStyleSessionRef.current?.dispose();
healthStyleSessionRef.current = null;
pipeLayer.setStyle(defaultFlatStyle);
}; };
}, [pipeLayer]); }, [map, pipeLayer]);
// 应用样式到管道图层 useEffect(() => {
const applyPipeHealthStyle = useCallback(() => { const session = healthStyleSessionRef.current;
if (!pipeLayer || predictionResults.length === 0) { if (!session) return;
if (predictionResults.length === 0) {
session.reset();
return; return;
} }
// 为每条管道计算当前年份的生存概率
const pipeHealthData = new Map<string, number>(); const pipeHealthData = new Map<string, number>();
predictionResults.forEach((result) => { predictionResults.forEach((result) => {
const probability = getSurvivalProbabilityAtYear( const probability = getSurvivalProbabilityAtYear(
result.survival_function, result.survival_function,
currentYear - 4, // 使用索引 (0-based) currentYear - 4,
); );
pipeHealthData.set(result.link_id, probability); pipeHealthData.set(result.link_id, probability);
}); });
session.commit(pipeHealthData);
}, [currentYear, pipeLayer, predictionResults]);
// 更新 ref 数据 // 缩放期间暂停时间轴,避免视图变化与自动播放同时推进。
healthDataRef.current = pipeHealthData;
// 更新图层数据
updatePipeHealthData(pipeHealthData);
// 获取所有概率值用于分类
const probabilities = Array.from(pipeHealthData.values());
if (probabilities.length === 0) return;
// 使用等距分段,从0-1分为十类
const breaks = RISK_BREAKS;
// 生成彩虹色(从紫色到红色,低生存概率=高风险=红色)
const colors = RAINBOW_COLORS;
// 构建 WebGL 样式表达式
const colorCases: any[] = [];
const widthCases: any[] = [];
breaks.forEach((breakValue, index) => {
const colorStr = colors[index];
// 线宽根据健康风险调整:低生存概率(高风险)用粗线
const width = 2 + (1 - index / (breaks.length - 1)) * 4;
colorCases.push(["<=", ["get", "healthRisk"], breakValue], colorStr);
widthCases.push(["<=", ["get", "healthRisk"], breakValue], width);
});
// console.log(
// `应用健康风险样式,年份: ${currentYear}, 分段: ${breaks.length}`,
// );
// console.log("颜色表达式:", colorCases);
// console.log("宽度表达式:", widthCases);
// 应用样式到图层
pipeLayer.setStyle({
"stroke-color": ["case", ...colorCases, "rgba(128, 128, 128, 1)"],
"stroke-width": ["case", ...widthCases, 2],
});
}, [
pipeLayer,
predictionResults,
currentYear,
getSurvivalProbabilityAtYear,
updatePipeHealthData,
]);
// 监听依赖变化,更新样式
useEffect(() => {
if (predictionResults.length > 0 && pipeLayer) {
applyPipeHealthStyle();
}
}, [applyPipeHealthStyle, pipeLayer, predictionResults.length]);
// 这里防止地图缩放时,瓦片重新加载引起的属性更新出错
useEffect(() => { useEffect(() => {
// 监听地图缩放事件,缩放时停止播放 // 监听地图缩放事件,缩放时停止播放
if (map) { if (map) {
@@ -513,7 +473,7 @@ const Timeline: React.FC<TimelineProps> = ({
} }
format="YYYY-MM-DD HH:mm" format="YYYY-MM-DD HH:mm"
views={["year", "month", "day", "hours", "minutes"]} views={["year", "month", "day", "hours", "minutes"]}
minutesStep={15} minutesStep={stepMinutes}
sx={{ width: 200 }} sx={{ width: 200 }}
slotProps={{ slotProps={{
textField: { textField: {
@@ -634,12 +594,13 @@ const Timeline: React.FC<TimelineProps> = ({
<Box ref={timelineRef} sx={{ px: 2, position: "relative" }}> <Box ref={timelineRef} sx={{ px: 2, position: "relative" }}>
<Slider <Slider
value={currentYear} value={sliderPreviewYear ?? currentYear}
min={minTime} min={minTime}
max={maxTime} // 4-73的范围 max={maxTime} // 4-73的范围
step={1} // 每1个单位一个步进 step={1} // 每1个单位一个步进
marks={valueMarks} // 显示刻度 marks={valueMarks} // 显示刻度
onChange={handleSliderChange} onChange={handleSliderChange}
onChangeCommitted={handleSliderChangeCommitted}
valueLabelDisplay="auto" valueLabelDisplay="auto"
sx={{ sx={{
zIndex: 10, zIndex: 10,
@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { import {
Box, Box,
Button, Button,
@@ -107,6 +107,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
const schemes = const schemes =
externalSchemes !== undefined ? externalSchemes : internalSchemes; externalSchemes !== undefined ? externalSchemes : internalSchemes;
const setSchemes = onSchemesChange || setInternalSchemes; const setSchemes = onSchemesChange || setInternalSchemes;
const sortedSchemes = useMemo(
() =>
schemes
.slice()
.sort(
(a, b) =>
moment(b.create_time).valueOf() - moment(a.create_time).valueOf(),
),
[schemes],
);
// 格式化简短日期 // 格式化简短日期
const formatShortDate = (timeStr: string) => { const formatShortDate = (timeStr: string) => {
@@ -133,6 +143,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
properties: { properties: {
name: "传感器高亮", name: "传感器高亮",
value: "sensor_highlight", value: "sensor_highlight",
queryable: false,
}, },
}); });
@@ -203,6 +214,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
? "没有找到任何方案" ? "没有找到任何方案"
: `${queryDate?.format("YYYY-MM-DD")} 没有找到相关方案`, : `${queryDate?.format("YYYY-MM-DD")} 没有找到相关方案`,
}); });
} else {
open?.({
type: "success",
message: "查询成功",
description: `共找到 ${filteredResults.length} 条方案记录`,
});
} }
} catch (error) { } catch (error) {
console.error("查询请求失败:", error); console.error("查询请求失败:", error);
@@ -313,7 +330,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
{/* 结果列表 */} {/* 结果列表 */}
<Box className="flex-1 overflow-auto"> <Box className="flex-1 overflow-auto">
{schemes.length === 0 ? ( {sortedSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400"> <Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4"> <Box className="mb-4">
<svg <svg
@@ -350,9 +367,9 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
) : ( ) : (
<Box className="space-y-2 p-2"> <Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2"> <Typography variant="caption" className="text-gray-500 px-2">
{schemes.length} {sortedSchemes.length}
</Typography> </Typography>
{schemes.map((scheme) => ( {sortedSchemes.map((scheme) => (
<Card <Card
key={scheme.id} key={scheme.id}
variant="outlined" variant="outlined"
@@ -557,7 +557,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
open?.({ open?.({
type: "error", type: "error",
message: "数据清洗失败", message: "数据清洗失败",
description: err.response?.data?.message || err.message || "未知错误", description:
err.response?.data?.detail ||
err.response?.data?.message ||
err.message ||
"未知错误",
}); });
} finally { } finally {
setIsCleaning(false); setIsCleaning(false);
+14 -4
View File
@@ -645,7 +645,11 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
open?.({ open?.({
type: "error", type: "error",
message: "数据清洗失败", message: "数据清洗失败",
description: err.response?.data?.message || err.message || "未知错误", description:
err.response?.data?.detail ||
err.response?.data?.message ||
err.message ||
"未知错误",
}); });
setIsCleaning(false); setIsCleaning(false);
} }
@@ -708,6 +712,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
properties: { properties: {
name: "SCADA 选中高亮", name: "SCADA 选中高亮",
value: "scada_selected_highlight", value: "scada_selected_highlight",
queryable: false,
}, },
}); });
@@ -813,8 +818,12 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
variant="persistent" variant="persistent"
hideBackdrop hideBackdrop
sx={{ sx={{
width: isExpanded ? 360 : 0, position: "absolute",
flexShrink: 0, inset: 0,
width: "100%",
height: "100%",
overflow: "hidden",
pointerEvents: "none",
"& .MuiDrawer-paper": { "& .MuiDrawer-paper": {
width: 360, width: 360,
height: "860px", height: "860px",
@@ -827,8 +836,9 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)", "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
backdropFilter: "blur(8px)", backdropFilter: "blur(8px)",
opacity: 0.95, opacity: 0.95,
transition: "all 0.3s ease-in-out", transition: "transform 0.3s ease-in-out, opacity 0.3s ease-in-out",
border: "none", border: "none",
pointerEvents: "auto",
"&:hover": { "&:hover": {
opacity: 1, opacity: 1,
}, },
@@ -0,0 +1,63 @@
jest.mock("../MapComponent", () => ({
useData: jest.fn(),
useMap: jest.fn(),
}));
jest.mock("../mapLifecycle", () => ({
markMapResourcePersistent: <T,>(resource: T) => resource,
}));
jest.mock("ol/source/XYZ.js", () => ({
__esModule: true,
default: class MockXyzSource {
constructor(readonly options: unknown) {}
},
}));
jest.mock("ol/layer/Tile.js", () => ({
__esModule: true,
default: class MockTileLayer {
private readonly source: unknown;
constructor(options: any) {
this.source = options.source;
}
getSource() { return this.source; }
},
}));
jest.mock("ol/layer/Group", () => ({
__esModule: true,
default: class MockGroup {
private readonly layers: unknown[];
constructor(options: any) {
this.layers = options.layers;
}
getLayers() { return { getArray: () => this.layers }; }
},
}));
import {
createBaseLayerEntries,
createBaseLayerSources,
} from "./BaseLayers";
const getLeafSources = (layer: any): unknown[] => {
const childLayers = layer.getLayers?.().getArray?.();
if (Array.isArray(childLayers)) {
return childLayers.flatMap(getLeafSources);
}
return [layer.getSource?.()];
};
describe("base layer resources", () => {
it("creates independent layers backed by one shared source pool", () => {
const sources = createBaseLayerSources();
const primary = createBaseLayerEntries(sources);
const compare = createBaseLayerEntries(sources);
expect(primary).toHaveLength(compare.length);
primary.forEach((entry, index) => {
expect(entry.layer).not.toBe(compare[index].layer);
expect(getLeafSources(entry.layer)).toEqual(
getLeafSources(compare[index].layer),
);
});
});
});
@@ -30,91 +30,92 @@ const BASE_LAYER_METADATA = [
{ id: "tianditu-image", name: "天地图影像", img: mapboxSatellite.src }, { id: "tianditu-image", name: "天地图影像", img: mapboxSatellite.src },
] as const; ] as const;
const createTileLayer = (url: string, attributions: string) => const createTileSource = (url: string, attributions: string) =>
new TileLayer({ new XYZ({
source: new XYZ({
url, url,
tileSize: 512, tileSize: 512,
maxZoom: 20, maxZoom: 20,
projection: "EPSG:3857", projection: "EPSG:3857",
attributions, attributions,
}),
}); });
const createBaseLayerEntries = () => { export const createBaseLayerSources = () => ({
const streetsLayer = createTileLayer( streets: createTileSource(
`https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, `https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>' '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
); ),
const lightMapLayer = createTileLayer( light: createTileSource(
`https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, `https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>' '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
); ),
const satelliteLayer = createTileLayer( satellite: createTileSource(
`https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, `https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>' '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
); ),
const satelliteStreetsLayer = createTileLayer( satelliteStreets: createTileSource(
`https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>' '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
); ),
tiandituVector: new XYZ({
const tiandituVectorLayer = new TileLayer({
source: new XYZ({
url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857", projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}), }),
}); tiandituVectorAnnotation: new XYZ({
const tiandituVectorAnnotationLayer = new TileLayer({
source: new XYZ({
url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857", projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}), }),
}); tiandituImage: new XYZ({
const tiandituImageLayer = new TileLayer({
source: new XYZ({
url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857", projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}), }),
}); tiandituImageAnnotation: new XYZ({
const tiandituImageAnnotationLayer = new TileLayer({
source: new XYZ({
url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857", projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}), }),
}); });
export type BaseLayerSources = ReturnType<typeof createBaseLayerSources>;
export const createBaseLayerEntries = (sources: BaseLayerSources) => {
const tileLayer = (source: XYZ) => new TileLayer({ source });
return [ return [
{ {
...BASE_LAYER_METADATA[0], ...BASE_LAYER_METADATA[0],
layer: lightMapLayer, layer: tileLayer(sources.light),
}, },
{ {
...BASE_LAYER_METADATA[1], ...BASE_LAYER_METADATA[1],
layer: satelliteLayer, layer: tileLayer(sources.satellite),
}, },
{ {
...BASE_LAYER_METADATA[2], ...BASE_LAYER_METADATA[2],
layer: satelliteStreetsLayer, layer: tileLayer(sources.satelliteStreets),
}, },
{ {
...BASE_LAYER_METADATA[3], ...BASE_LAYER_METADATA[3],
layer: streetsLayer, layer: tileLayer(sources.streets),
}, },
{ {
...BASE_LAYER_METADATA[4], ...BASE_LAYER_METADATA[4],
layer: new Group({ layer: new Group({
layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer], layers: [
tileLayer(sources.tiandituVector),
tileLayer(sources.tiandituVectorAnnotation),
],
}), }),
}, },
{ {
...BASE_LAYER_METADATA[5], ...BASE_LAYER_METADATA[5],
layer: new Group({ layer: new Group({
layers: [tiandituImageLayer, tiandituImageAnnotationLayer], layers: [
tileLayer(sources.tiandituImage),
tileLayer(sources.tiandituImageAnnotation),
],
}), }),
}, },
].map((entry) => ({ ].map((entry) => ({
@@ -130,6 +131,7 @@ const BaseLayers: React.FC = () => {
if (data?.maps?.length) return data.maps; if (data?.maps?.length) return data.maps;
return map ? [map] : []; return map ? [map] : [];
}, [data?.maps, map]); }, [data?.maps, map]);
const sharedSources = useMemo(() => createBaseLayerSources(), []);
const layerSetsRef = useRef(new WeakMap<OlMap, ReturnType<typeof createBaseLayerEntries>>()); const layerSetsRef = useRef(new WeakMap<OlMap, ReturnType<typeof createBaseLayerEntries>>());
const [isShow, setShow] = useState(false); const [isShow, setShow] = useState(false);
const [isExpanded, setExpanded] = useState(false); const [isExpanded, setExpanded] = useState(false);
@@ -139,7 +141,7 @@ const BaseLayers: React.FC = () => {
maps.forEach((targetMap) => { maps.forEach((targetMap) => {
let layerEntries = layerSetsRef.current.get(targetMap); let layerEntries = layerSetsRef.current.get(targetMap);
if (!layerEntries) { if (!layerEntries) {
layerEntries = createBaseLayerEntries(); layerEntries = createBaseLayerEntries(sharedSources);
layerSetsRef.current.set(targetMap, layerEntries); layerSetsRef.current.set(targetMap, layerEntries);
} }
@@ -151,7 +153,7 @@ const BaseLayers: React.FC = () => {
layerInfo.layer.setVisible(layerInfo.id === activeId); layerInfo.layer.setVisible(layerInfo.id === activeId);
}); });
}); });
}, [activeId, maps]); }, [activeId, maps, sharedSources]);
const changeMapLayers = (id: string) => { const changeMapLayers = (id: string) => {
maps.forEach((targetMap) => { maps.forEach((targetMap) => {
@@ -1,9 +1,19 @@
"use client"; "use client";
import React, { useRef } from "react"; import React, { useRef, useState } from "react";
import Draggable from "react-draggable"; import Draggable from "react-draggable";
import { Close } from "@mui/icons-material"; import { Close } from "@mui/icons-material";
import { IconButton, Tooltip } from "@mui/material"; import {
Button,
CircularProgress,
FormControl,
IconButton,
MenuItem,
Select,
TextField,
Tooltip,
} from "@mui/material";
import type { SelectChangeEvent } from "@mui/material/Select";
interface BaseProperty { interface BaseProperty {
label: string; label: string;
@@ -20,7 +30,29 @@ interface TableProperty {
rows: (string | number)[][]; // 每行的数据 rows: (string | number)[][]; // 每行的数据
} }
type PropertyItem = BaseProperty | TableProperty; interface SelectProperty {
type: "select";
label: string;
value: string;
options: { label: string; value: string }[];
placeholder?: string;
disabled?: boolean;
saving?: boolean;
onSave: (value: string) => Promise<void>;
}
interface TextProperty {
type: "text";
label: string;
value: string;
placeholder?: string;
disabled?: boolean;
saving?: boolean;
helperText?: string;
onSave: (value: string) => Promise<void>;
}
type PropertyItem = BaseProperty | TableProperty | SelectProperty | TextProperty;
interface PropertyPanelProps { interface PropertyPanelProps {
id?: string; id?: string;
@@ -36,6 +68,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
onClose, onClose,
}) => { }) => {
const draggableRef = useRef<HTMLDivElement>(null); const draggableRef = useRef<HTMLDivElement>(null);
const [draftValues, setDraftValues] = useState<Record<string, string>>({});
const headerActionSx = { const headerActionSx = {
color: "common.white", color: "common.white",
backgroundColor: "rgba(255,255,255,0.08)", backgroundColor: "rgba(255,255,255,0.08)",
@@ -56,6 +89,26 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
const isImportantKeys = ["ID", "类型", "Name", "面积", "长度"]; const isImportantKeys = ["ID", "类型", "Name", "面积", "长度"];
const handleSelectChange = (
key: string,
event: SelectChangeEvent,
) => {
setDraftValues((prev) => ({
...prev,
[key]: event.target.value,
}));
};
const handleTextChange = (
key: string,
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
setDraftValues((prev) => ({
...prev,
[key]: event.target.value,
}));
};
// 统计属性数量(表格型按行数计入) // 统计属性数量(表格型按行数计入)
const totalProps = id const totalProps = id
? 2 + ? 2 +
@@ -200,6 +253,151 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
); );
} }
if ("type" in property && property.type === "select") {
const selected = property as SelectProperty;
const draftKey = `${id ?? "unknown"}:${selected.label}`;
const draftValue = draftValues[draftKey] ?? selected.value ?? "";
const hasChanged = draftValue !== selected.value;
const getOptionLabel = (value: string) =>
selected.options.find((option) => option.value === value)
?.label ?? value;
const canSave =
hasChanged &&
draftValue !== "" &&
!selected.disabled &&
!selected.saving;
return (
<div
key={`select-${index}`}
className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100"
>
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-gray-600">
{selected.label}
</span>
<div className="flex items-center justify-end gap-2 flex-1">
<FormControl size="small" sx={{ minWidth: 118 }}>
<Select
value={draftValue}
displayEmpty
disabled={selected.disabled || selected.saving}
onChange={(event) =>
handleSelectChange(draftKey, event)
}
renderValue={(value) =>
value
? getOptionLabel(String(value))
: selected.placeholder || "-"
}
sx={{
height: 32,
fontSize: 13,
backgroundColor: "white",
}}
>
<MenuItem value="" disabled>
{selected.placeholder || "未设置"}
</MenuItem>
{selected.options.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
<Button
size="small"
variant="contained"
disabled={!canSave}
onClick={() => selected.onSave(draftValue)}
sx={{
minWidth: 58,
height: 32,
fontSize: 13,
boxShadow: "none",
}}
>
{selected.saving ? (
<CircularProgress size={16} color="inherit" />
) : (
"保存"
)}
</Button>
</div>
</div>
</div>
);
}
if ("type" in property && property.type === "text") {
const editable = property as TextProperty;
const draftKey = `${id ?? "unknown"}:${editable.label}`;
const draftValue = draftValues[draftKey] ?? editable.value ?? "";
const hasChanged = draftValue !== editable.value;
const canSave =
hasChanged &&
!editable.disabled &&
!editable.saving;
return (
<div
key={`text-${index}`}
className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100"
>
<div className="flex items-start justify-between gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-gray-600 pt-2">
{editable.label}
</span>
<div className="flex flex-col items-end gap-1 flex-1">
<div className="flex items-center justify-end gap-2 w-full">
<TextField
size="small"
value={draftValue}
placeholder={editable.placeholder}
disabled={editable.disabled || editable.saving}
onChange={(event) =>
handleTextChange(draftKey, event)
}
sx={{
width: 118,
"& .MuiInputBase-input": {
height: 15,
fontSize: 13,
},
backgroundColor: "white",
}}
/>
<Button
size="small"
variant="contained"
disabled={!canSave}
onClick={() => editable.onSave(draftValue)}
sx={{
minWidth: 58,
height: 32,
fontSize: 13,
boxShadow: "none",
}}
>
{editable.saving ? (
<CircularProgress size={16} color="inherit" />
) : (
"保存"
)}
</Button>
</div>
{editable.helperText && (
<span className="text-xs text-gray-500 text-right">
{editable.helperText}
</span>
)}
</div>
</div>
</div>
);
}
// 普通属性 // 普通属性
const base = property as BaseProperty; const base = property as BaseProperty;
const isImportant = isImportantKeys.includes(base.label); const isImportant = isImportantKeys.includes(base.label);
@@ -1,17 +1,21 @@
import ApplyIcon from "@mui/icons-material/Check"; import ApplyIcon from "@mui/icons-material/Check";
import ColorLensIcon from "@mui/icons-material/ColorLens"; import PaletteIcon from "@mui/icons-material/PaletteOutlined";
import ResetIcon from "@mui/icons-material/Refresh"; import ResetIcon from "@mui/icons-material/Refresh";
import { import {
Box, Box,
Button, Button,
Checkbox, Checkbox,
Chip,
Divider,
FormControl, FormControl,
FormControlLabel, FormControlLabel,
IconButton,
InputLabel, InputLabel,
MenuItem, MenuItem,
Select, Select,
Slider, Slider,
TextField, TextField,
Tooltip,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import React from "react"; import React from "react";
@@ -23,14 +27,74 @@ import {
RAINBOW_PALETTES, RAINBOW_PALETTES,
SINGLE_COLOR_PALETTES, SINGLE_COLOR_PALETTES,
} from "./styleEditorPresets"; } from "./styleEditorPresets";
import { StyleEditorFormProps } from "./styleEditorTypes"; import type {
ClassificationMethod,
ColorType,
StyleEditorFormProps,
} from "./styleEditorTypes";
import { import {
getSizePreviewColors, getDefaultCustomColors,
hexToRgba, hexToRgba,
resolveStyleColors, resolveStyleColors,
rgbaToHex, rgbaToHex,
} from "./styleEditorUtils"; } from "./styleEditorUtils";
const sectionSx = { px: 2, py: 1.5 } as const;
const sliderSx = { mx: 1, width: "calc(100% - 16px)" } as const;
type PalettePreview = { name: string; colors: string[]; index: number };
const getPalettePreviews = (colorType: ColorType): PalettePreview[] => {
switch (colorType) {
case "single":
return SINGLE_COLOR_PALETTES.map((palette, index) => ({
name: `单色 ${index + 1}`,
colors: [palette.color],
index,
}));
case "gradient":
return GRADIENT_PALETTES.map((palette, index) => ({
name: palette.name,
colors: [palette.start, palette.end],
index,
}));
case "rainbow":
return RAINBOW_PALETTES.map((palette, index) => ({
name: palette.name,
colors: palette.colors,
index,
}));
default:
return [];
}
};
const getSelectedPaletteIndex = (styleConfig: StyleEditorFormProps["styleConfig"]) => {
switch (styleConfig.colorType) {
case "single":
return styleConfig.singlePaletteIndex;
case "gradient":
return styleConfig.gradientPaletteIndex;
case "rainbow":
return styleConfig.rainbowPaletteIndex;
default:
return -1;
}
};
const selectPalette = (colorType: ColorType, index: number) => {
switch (colorType) {
case "single":
return { singlePaletteIndex: index };
case "gradient":
return { gradientPaletteIndex: index };
case "rainbow":
return { rainbowPaletteIndex: index };
default:
return {};
}
};
const StyleEditorForm: React.FC<StyleEditorFormProps> = ({ const StyleEditorForm: React.FC<StyleEditorFormProps> = ({
renderLayers, renderLayers,
selectedRenderLayer, selectedRenderLayer,
@@ -42,417 +106,173 @@ const StyleEditorForm: React.FC<StyleEditorFormProps> = ({
onClassificationMethodChange, onClassificationMethodChange,
onSegmentsChange, onSegmentsChange,
onCustomBreakChange, onCustomBreakChange,
onCustomBreakBlur,
onColorTypeChange, onColorTypeChange,
onApply, onApply,
onReset, onReset,
validationErrors,
isDirty,
isApplying,
}) => { }) => {
const renderColorSetting = () => { const layerType = selectedRenderLayer?.get("type");
if (styleConfig.colorType === "single") { const previewColors = resolveStyleColors(styleConfig);
return ( const selectedProperty = availableProperties.some(
<FormControl variant="standard" fullWidth margin="dense" className="mt-3"> (property) => property.value === styleConfig.property,
<InputLabel></InputLabel> )
<Select ? styleConfig.property
value={styleConfig.singlePaletteIndex} : "";
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
singlePaletteIndex: Number(e.target.value),
}))
}
>
{SINGLE_COLOR_PALETTES.map((palette, index) => (
<MenuItem key={index} value={index}>
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
<Box
sx={{
width: "80%",
height: 16,
borderRadius: 2,
background: palette.color,
marginRight: 1,
border: "1px solid #ccc",
}}
/>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
);
}
if (styleConfig.colorType === "gradient") { const setCustomColor = (index: number, hex: string) => {
return ( setStyleConfig((previous) => {
<FormControl variant="standard" fullWidth margin="dense" className="mt-3"> const customColors = getDefaultCustomColors(
<InputLabel></InputLabel> previous.segments,
<Select previous.customColors,
value={styleConfig.gradientPaletteIndex}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
gradientPaletteIndex: Number(e.target.value),
}))
}
>
{GRADIENT_PALETTES.map((palette, index) => {
const previewColors = resolveStyleColors(
{ ...styleConfig, colorType: "gradient", gradientPaletteIndex: index },
styleConfig.segments + 1
); );
return ( customColors[index] = hexToRgba(hex);
<MenuItem key={index} value={index}> return { ...previous, customColors };
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
<Box
sx={{
width: "80%",
height: 16,
borderRadius: 2,
display: "flex",
overflow: "hidden",
marginRight: 1,
border: "1px solid #ccc",
}}
>
{previewColors.map((color, colorIndex) => (
<Box
key={colorIndex}
sx={{ flex: 1, backgroundColor: color }}
/>
))}
</Box>
</Box>
</MenuItem>
);
})}
</Select>
</FormControl>
);
}
if (styleConfig.colorType === "rainbow") {
return (
<FormControl variant="standard" fullWidth margin="dense" className="mt-3">
<InputLabel></InputLabel>
<Select
value={styleConfig.rainbowPaletteIndex}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
rainbowPaletteIndex: Number(e.target.value),
}))
}
>
{RAINBOW_PALETTES.map((palette, index) => {
const previewColors = Array.from(
{ length: styleConfig.segments + 1 },
(_, colorIndex) => palette.colors[colorIndex % palette.colors.length]
);
return (
<MenuItem key={index} value={index}>
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
<Typography sx={{ marginRight: 1 }}>{palette.name}</Typography>
<Box
sx={{
width: "60%",
height: 16,
borderRadius: 2,
display: "flex",
border: "1px solid #ccc",
overflow: "hidden",
}}
>
{previewColors.map((color, colorIndex) => (
<Box
key={colorIndex}
sx={{ flex: 1, backgroundColor: color }}
/>
))}
</Box>
</Box>
</MenuItem>
);
})}
</Select>
</FormControl>
);
}
if (styleConfig.colorType === "custom") {
return (
<Box className="mt-3">
<Typography variant="subtitle2" gutterBottom>
</Typography>
<Box
className="flex flex-col gap-2"
sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "4px" }}
>
{Array.from({ length: styleConfig.segments }).map((_, index) => {
const color = styleConfig.customColors?.[index] || "rgba(0,0,0,1)";
return (
<Box key={index} className="flex items-center gap-2">
<Typography variant="caption" sx={{ width: 40 }}>
{index + 1}
</Typography>
<input
type="color"
value={rgbaToHex(color)}
onChange={(e) => {
const nextColor = hexToRgba(e.target.value);
setStyleConfig((prev) => {
const nextColors = [...(prev.customColors || [])];
while (nextColors.length < prev.segments) {
nextColors.push("rgba(0,0,0,1)");
}
nextColors[index] = nextColor;
return { ...prev, customColors: nextColors };
}); });
}}
style={{
width: "100%",
height: "32px",
cursor: "pointer",
border: "1px solid #ccc",
borderRadius: "4px",
}}
/>
</Box>
);
})}
</Box>
</Box>
);
}
return null;
}; };
const renderSizeSetting = () => { const renderPalette = () => {
const previewColors = getSizePreviewColors(styleConfig); const palettes = getPalettePreviews(styleConfig.colorType);
if (palettes.length === 0) return null;
const selectedIndex = getSelectedPaletteIndex(styleConfig);
if (selectedRenderLayer?.get("type") === "point") {
return ( return (
<Box className="mt-3"> <Box sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 1 }}>
<Typography gutterBottom> {palettes.map((palette) => (
: {styleConfig.minSize} - {styleConfig.maxSize} <Tooltip key={palette.name} title={palette.name} placement="top">
</Typography>
<Box className="flex items-center gap-4">
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.minSize}
onChange={(_, value) =>
setStyleConfig((prev) => ({ ...prev, minSize: value as number }))
}
min={2}
max={8}
step={1}
size="small"
/>
</Box>
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.maxSize}
onChange={(_, value) =>
setStyleConfig((prev) => ({ ...prev, maxSize: value as number }))
}
min={10}
max={16}
step={1}
size="small"
/>
</Box>
</Box>
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
<Typography variant="caption">:</Typography>
<Box <Box
component="button"
type="button"
aria-label={palette.name}
onClick={() =>
setStyleConfig((previous) => ({
...previous,
...selectPalette(previous.colorType, palette.index),
}))
}
sx={{ sx={{
width: styleConfig.minSize, display: "flex",
height: styleConfig.minSize, height: 30,
borderRadius: "50%", p: 0.5,
backgroundColor: previewColors[0], overflow: "hidden",
cursor: "pointer",
bgcolor: "background.paper",
border: "1px solid",
borderColor: selectedIndex === palette.index ? "primary.main" : "divider",
borderRadius: 1,
boxShadow: selectedIndex === palette.index ? "0 0 0 1px currentColor" : "none",
}} }}
/> >
<Typography variant="caption"></Typography> {palette.colors.map((color, index) => (
<Box <Box key={`${color}-${index}`} sx={{ flex: 1, bgcolor: color }} />
sx={{ ))}
width: styleConfig.maxSize,
height: styleConfig.maxSize,
borderRadius: "50%",
backgroundColor: previewColors[previewColors.length - 1],
}}
/>
</Box> </Box>
</Tooltip>
))}
</Box> </Box>
); );
} };
if (selectedRenderLayer?.get("type") === "linestring") {
return ( return (
<Box className="mt-3">
<FormControlLabel
control={
<Checkbox
checked={styleConfig.adjustWidthByProperty}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
adjustWidthByProperty: e.target.checked,
}))
}
disabled={styleConfig.colorType === "single"}
/>
}
label="根据数值分段调整线条宽度"
/>
{styleConfig.adjustWidthByProperty ? (
<>
<Typography gutterBottom>
线: {styleConfig.minStrokeWidth} - {styleConfig.maxStrokeWidth}
px
</Typography>
<Box className="flex items-center gap-4">
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.minStrokeWidth}
onChange={(_, value) =>
setStyleConfig((prev) => ({
...prev,
minStrokeWidth: value as number,
}))
}
min={1}
max={4}
step={0.5}
size="small"
/>
</Box>
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.maxStrokeWidth}
onChange={(_, value) =>
setStyleConfig((prev) => ({
...prev,
maxStrokeWidth: value as number,
}))
}
min={6}
max={12}
step={0.5}
size="small"
/>
</Box>
</Box>
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
<Typography variant="caption">:</Typography>
<Box <Box
sx={{ sx={{
width: 50, position: "absolute",
height: styleConfig.minStrokeWidth, top: 72,
backgroundColor: previewColors[0], left: 12,
border: `1px solid ${previewColors[0]}`, zIndex: 1300,
borderRadius: 1, width: "min(380px, calc(100vw - 24px))",
maxHeight: "calc(100dvh - 92px)",
display: "flex",
flexDirection: "column",
bgcolor: "background.paper",
border: "none",
borderRadius: "12px",
boxShadow:
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
backdropFilter: "blur(8px)",
opacity: 0.95,
transition: "opacity 0.3s ease-in-out",
overflow: "hidden",
"&:hover": {
opacity: 1,
},
}} }}
/> >
<Typography variant="caption"></Typography>
<Box <Box
sx={{ sx={{
width: 50, px: 2.5,
height: styleConfig.maxStrokeWidth, py: 1.5,
backgroundColor: previewColors[previewColors.length - 1], display: "flex",
border: `1px solid ${previewColors[previewColors.length - 1]}`, alignItems: "center",
borderRadius: 1, gap: 1,
bgcolor: "rgba(255,255,255,0.98)",
color: "text.primary",
borderBottom: "1px solid",
borderColor: "divider",
}} }}
/> >
</Box> <PaletteIcon fontSize="small" sx={{ color: "#257DD4" }} />
</> <Typography variant="subtitle1" sx={{ fontWeight: 600, flex: 1 }}>
) : (
<>
<Typography gutterBottom>
线: {styleConfig.fixedStrokeWidth}px
</Typography> </Typography>
<Slider {isDirty && (
value={styleConfig.fixedStrokeWidth} <Chip
onChange={(_, value) => label="待应用"
setStyleConfig((prev) => ({
...prev,
fixedStrokeWidth: value as number,
}))
}
min={1}
max={10}
step={0.5}
size="small" size="small"
/>
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
<Typography variant="caption">:</Typography>
<Box
sx={{ sx={{
width: 50, height: 24,
height: styleConfig.fixedStrokeWidth, color: "warning.dark",
backgroundColor: previewColors[0], bgcolor: "warning.50",
border: `1px solid ${previewColors[0]}`, border: "1px solid",
borderRadius: 1, borderColor: "warning.200",
}} }}
/> />
</Box>
</>
)} )}
</Box> </Box>
);
}
return null; <Box sx={{ overflowY: "auto", overflowX: "hidden", minHeight: 0 }}>
}; <Box sx={sectionSx}>
<Typography variant="overline" color="text.secondary">
return (
<div className="absolute top-20 left-4 bg-white p-4 rounded-xl shadow-lg opacity-95 hover:opacity-100 transition-opacity w-80 z-1300"> </Typography>
<FormControl variant="standard" fullWidth margin="dense"> <Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25, mt: 0.5 }}>
<InputLabel></InputLabel> <FormControl size="small" fullWidth>
<InputLabel></InputLabel>
<Select <Select
value={selectedRenderLayer ? renderLayers.indexOf(selectedRenderLayer) : ""} label="图层"
onChange={(e) => onLayerChange(e.target.value as number)} value={selectedRenderLayer?.get("value") || ""}
onChange={(event) => onLayerChange(String(event.target.value))}
> >
{renderLayers.map((layer, index) => ( {renderLayers.map((layer) => (
<MenuItem key={index} value={index}> <MenuItem key={layer.get("value")} value={layer.get("value")}>
{layer.get("name")} {layer.get("name")}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
</FormControl> </FormControl>
<FormControl size="small" fullWidth>
<FormControl variant="standard" fullWidth margin="dense"> <InputLabel></InputLabel>
<InputLabel></InputLabel>
<Select <Select
value={styleConfig.property} label="属性"
onChange={(e) => onPropertyChange(e.target.value)} value={selectedProperty}
disabled={!selectedRenderLayer} onChange={(event) => onPropertyChange(String(event.target.value))}
> >
{availableProperties.map((property) => ( {availableProperties.map((property) => (
<MenuItem key={property.name} value={property.value}> <MenuItem key={property.value} value={property.value}>
{property.name} {property.name}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
</FormControl> </FormControl>
<FormControl size="small" fullWidth>
<FormControl variant="standard" fullWidth margin="dense"> <InputLabel></InputLabel>
<InputLabel></InputLabel>
<Select <Select
label="分级方法"
value={styleConfig.classificationMethod} value={styleConfig.classificationMethod}
onChange={(e) => onClassificationMethodChange(e.target.value)} onChange={(event) =>
onClassificationMethodChange(event.target.value as ClassificationMethod)
}
> >
{CLASSIFICATION_METHODS.map((method) => ( {CLASSIFICATION_METHODS.map((method) => (
<MenuItem key={method.value} value={method.value}> <MenuItem key={method.value} value={method.value}>
@@ -461,53 +281,28 @@ const StyleEditorForm: React.FC<StyleEditorFormProps> = ({
))} ))}
</Select> </Select>
</FormControl> </FormControl>
<Box className="mt-3">
<Typography gutterBottom>: {styleConfig.segments}</Typography>
<Slider
value={styleConfig.segments}
onChange={(_, value) => onSegmentsChange(value as number)}
min={2}
max={10}
step={1}
marks
size="small"
/>
</Box>
{styleConfig.classificationMethod === "custom_breaks" && (
<Box className="mt-3 p-2 bg-gray-50 rounded">
<Typography variant="subtitle2" gutterBottom>
{">="} 0
</Typography>
<Box
className="flex flex-col gap-2"
sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "12px" }}
>
{Array.from({ length: styleConfig.segments }).map((_, index) => (
<TextField <TextField
key={index}
label={`阈值 ${index + 1}`}
type="number"
size="small" size="small"
slotProps={{ input: { inputProps: { min: 0, step: 0.1 } } }} label="分类数量"
value={styleConfig.customBreaks?.[index] ?? ""} type="number"
onChange={(e) => onCustomBreakChange(index, e.target.value)} value={styleConfig.segments}
onBlur={onCustomBreakBlur} onChange={(event) => onSegmentsChange(Number(event.target.value))}
slotProps={{ htmlInput: { min: 2, max: 10, step: 1 } }}
/> />
))}
</Box> </Box>
</Box> </Box>
)}
<FormControl variant="standard" fullWidth margin="dense"> <Divider />
<InputLabel> <Box sx={sectionSx}>
<ColorLensIcon className="mr-1" /> <Typography variant="overline" color="text.secondary">
</InputLabel> </Typography>
<FormControl size="small" fullWidth sx={{ mt: 0.5, mb: 1 }}>
<InputLabel></InputLabel>
<Select <Select
label="颜色方案"
value={styleConfig.colorType} value={styleConfig.colorType}
onChange={(e) => onColorTypeChange(e.target.value)} onChange={(event) => onColorTypeChange(event.target.value as ColorType)}
> >
{COLOR_TYPE_OPTIONS.map((option) => ( {COLOR_TYPE_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}> <MenuItem key={option.value} value={option.value}>
@@ -515,75 +310,195 @@ const StyleEditorForm: React.FC<StyleEditorFormProps> = ({
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
{renderColorSetting()}
</FormControl> </FormControl>
{renderPalette()}
{renderSizeSetting()} {styleConfig.classificationMethod === "custom_breaks" && (
<Box sx={{ mt: 1.25, display: "grid", gap: 0.75 }}>
<Box className="mt-3"> {Array.from({ length: styleConfig.segments }, (_, index) => (
<Typography gutterBottom> <Box
: {(styleConfig.opacity * 100).toFixed(0)}% key={index}
sx={{
display: "grid",
gridTemplateColumns:
styleConfig.colorType === "custom" ? "32px 1fr 12px 1fr" : "12px 1fr 12px 1fr",
gap: 0.75,
alignItems: "center",
}}
>
{styleConfig.colorType === "custom" ? (
<Tooltip title={`区间 ${index + 1} 颜色`}>
<Box
component="input"
type="color"
aria-label={`区间 ${index + 1} 颜色`}
value={rgbaToHex(styleConfig.customColors?.[index] || previewColors[index])}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setCustomColor(index, event.target.value)
}
sx={{ width: 32, height: 30, p: 0, border: 0, bgcolor: "transparent" }}
/>
</Tooltip>
) : (
<Box sx={{ width: 10, height: 24, bgcolor: previewColors[index], borderRadius: 0.5 }} />
)}
<TextField
size="small"
type="number"
value={Number.isFinite(styleConfig.customBreaks?.[index]) ? styleConfig.customBreaks?.[index] : ""}
onChange={(event) => onCustomBreakChange(index, event.target.value)}
inputProps={{ "aria-label": `区间 ${index + 1} 下界`, step: 0.1 }}
/>
<Typography variant="caption" color="text.secondary" textAlign="center">
</Typography> </Typography>
<TextField
size="small"
type="number"
value={Number.isFinite(styleConfig.customBreaks?.[index + 1]) ? styleConfig.customBreaks?.[index + 1] : ""}
onChange={(event) => onCustomBreakChange(index + 1, event.target.value)}
inputProps={{ "aria-label": `区间 ${index + 1} 上界`, step: 0.1 }}
/>
</Box>
))}
</Box>
)}
</Box>
<Divider />
<Box sx={sectionSx}>
<Typography variant="overline" color="text.secondary">
</Typography>
{layerType === "point" ? (
<Box sx={{ mt: 0.5 }}>
<Typography variant="caption">
{styleConfig.minSize} - {styleConfig.maxSize}px
</Typography>
<Slider
value={[styleConfig.minSize, styleConfig.maxSize]}
onChange={(_, value) => {
const [minSize, maxSize] = value as number[];
setStyleConfig((previous) => ({ ...previous, minSize, maxSize }));
}}
min={2}
max={16}
step={1}
size="small"
sx={sliderSx}
/>
</Box>
) : (
<Box sx={{ mt: 0.5 }}>
<FormControlLabel
control={
<Checkbox
size="small"
checked={styleConfig.adjustWidthByProperty}
onChange={(event) =>
setStyleConfig((previous) => ({
...previous,
adjustWidthByProperty: event.target.checked,
}))
}
/>
}
label={<Typography variant="body2">线</Typography>}
/>
<Typography variant="caption">
{styleConfig.adjustWidthByProperty
? `${styleConfig.minStrokeWidth} - ${styleConfig.maxStrokeWidth}px`
: `${styleConfig.fixedStrokeWidth}px`}
</Typography>
<Slider
value={
styleConfig.adjustWidthByProperty
? [styleConfig.minStrokeWidth, styleConfig.maxStrokeWidth]
: styleConfig.fixedStrokeWidth
}
onChange={(_, value) =>
setStyleConfig((previous) =>
Array.isArray(value)
? { ...previous, minStrokeWidth: value[0], maxStrokeWidth: value[1] }
: { ...previous, fixedStrokeWidth: value },
)
}
min={1}
max={12}
step={0.5}
size="small"
sx={sliderSx}
/>
</Box>
)}
<Typography variant="caption"> {Math.round(styleConfig.opacity * 100)}%</Typography>
<Slider <Slider
value={styleConfig.opacity} value={styleConfig.opacity}
onChange={(_, value) => onChange={(_, value) =>
setStyleConfig((prev) => ({ ...prev, opacity: value as number })) setStyleConfig((previous) => ({ ...previous, opacity: value as number }))
} }
min={0.1} min={0.1}
max={1} max={1}
step={0.05} step={0.05}
size="small" size="small"
sx={sliderSx}
/> />
</Box> </Box>
<Divider />
<Box sx={{ ...sectionSx, display: "flex", gap: 2 }}>
<FormControlLabel <FormControlLabel
control={ control={
<Checkbox <Checkbox
checked={styleConfig.showId} size="small"
onChange={(e) =>
setStyleConfig((prev) => ({ ...prev, showId: e.target.checked }))
}
/>
}
label="显示 ID(缩放 >=15 级时显示)"
/>
<FormControlLabel
control={
<Checkbox
checked={styleConfig.showLabels} checked={styleConfig.showLabels}
onChange={(e) => onChange={(event) =>
setStyleConfig((prev) => ({ ...prev, showLabels: e.target.checked })) setStyleConfig((previous) => ({ ...previous, showLabels: event.target.checked }))
} }
/> />
} }
label="显示属性(缩放 >=15 级时显示)" label={<Typography variant="body2"></Typography>}
/> />
<FormControlLabel
control={
<Checkbox
size="small"
checked={styleConfig.showId}
onChange={(event) =>
setStyleConfig((previous) => ({ ...previous, showId: event.target.checked }))
}
/>
}
label={<Typography variant="body2"> ID</Typography>}
/>
</Box>
</Box>
<div className="my-3"></div> <Divider />
<Box sx={{ px: 2, py: 1.25 }}>
<Box className="flex gap-2"> {validationErrors[0] && (
<Typography variant="caption" color="error" sx={{ display: "block", mb: 0.75 }}>
{validationErrors[0]}
</Typography>
)}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
<Tooltip title="恢复默认样式">
<IconButton size="small" onClick={onReset} aria-label="恢复默认样式">
<ResetIcon fontSize="small" />
</IconButton>
</Tooltip>
<Button <Button
variant="contained" variant="contained"
color="primary" size="small"
onClick={onApply}
disabled={!selectedRenderLayer || !styleConfig.property}
startIcon={<ApplyIcon />} startIcon={<ApplyIcon />}
fullWidth onClick={onApply}
disabled={!isDirty || validationErrors.length > 0 || isApplying}
> >
{isApplying ? "应用中" : "应用"}
</Button>
<Button
variant="outlined"
onClick={onReset}
disabled={!selectedRenderLayer}
startIcon={<ResetIcon />}
fullWidth
>
</Button> </Button>
</Box> </Box>
</div> </Box>
</Box>
); );
}; };
@@ -1,50 +1,38 @@
import React from "react"; import React from "react";
import { Box, CircularProgress } from "@mui/material";
import StyleEditorForm from "./StyleEditorForm"; import StyleEditorForm from "./StyleEditorForm";
import { createDefaultLayerStyleState, createDefaultLayerStyleStates } from "./styleEditorPresets"; import type { StyleEditorPanelProps } from "./styleEditorTypes";
import { LayerStyleState, StyleConfig, StyleEditorPanelProps } from "./styleEditorTypes";
const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
isReady, isReady,
renderLayers, ...formProps
selectedRenderLayer,
styleConfig,
setStyleConfig,
availableProperties,
onLayerChange,
onPropertyChange,
onClassificationMethodChange,
onSegmentsChange,
onCustomBreakChange,
onCustomBreakBlur,
onColorTypeChange,
onApply,
onReset,
}) => { }) => {
if (!isReady) { if (!isReady) {
return <div>Loading...</div>; return (
<Box
sx={{
position: "absolute",
top: 72,
left: 12,
zIndex: 1300,
width: 160,
height: 72,
display: "grid",
placeItems: "center",
bgcolor: "background.paper",
borderRadius: "12px",
boxShadow:
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
opacity: 0.95,
}}
>
<CircularProgress size={22} />
</Box>
);
} }
return ( return <StyleEditorForm {...formProps} />;
<StyleEditorForm
renderLayers={renderLayers}
selectedRenderLayer={selectedRenderLayer}
styleConfig={styleConfig}
setStyleConfig={setStyleConfig}
availableProperties={availableProperties}
onLayerChange={onLayerChange}
onPropertyChange={onPropertyChange}
onClassificationMethodChange={onClassificationMethodChange}
onSegmentsChange={onSegmentsChange}
onCustomBreakChange={onCustomBreakChange}
onCustomBreakBlur={onCustomBreakBlur}
onColorTypeChange={onColorTypeChange}
onApply={onApply}
onReset={onReset}
/>
);
}; };
export default StyleEditorPanel; export default StyleEditorPanel;
export type { LayerStyleState, StyleConfig } from "./styleEditorTypes";
export { createDefaultLayerStyleState, createDefaultLayerStyleStates };
@@ -7,34 +7,31 @@ interface LegendStyleConfig {
layerId: string; layerId: string;
property: string; property: string;
colors: string[]; colors: string[];
type: string; // 图例类型 type: string;
dimensions: number[]; // 尺寸大小 dimensions: number[];
breaks: number[]; // 分段值 breaks: number[];
labels?: string[]; // 可选标签(用于离散分类) labels?: string[];
columns?: number; columns?: number;
itemsPerColumn?: number; itemsPerColumn?: number;
} }
// 图例组件
// 该组件用于显示图层样式的图例,包含属性名称、颜色、尺寸和分段值等信息
// 通过传入的配置对象动态生成图例内容,适用于不同的样式配置
// 使用时需要确保传入的 colors、dimensions 和 breaks 数组长度一致
const StyleLegend: React.FC<LegendStyleConfig> = ({ const StyleLegend: React.FC<LegendStyleConfig> = ({
layerName, layerName,
layerId, layerId,
property, property,
colors, colors,
type, // 图例类型 type,
dimensions, dimensions,
breaks, breaks,
labels, labels,
columns = 1, columns = 1,
itemsPerColumn, itemsPerColumn,
}) => { }) => {
const itemCount = Math.min(colors.length, dimensions.length, Math.max(0, breaks.length - 1));
return ( return (
<Box <Box
key={layerId} key={layerId}
className="bg-white p-3 rounded-xl max-w-xs opacity-95 transition-opacity duration-300 hover:opacity-100" className="bg-white p-3 max-w-xs opacity-95 transition-opacity duration-300 hover:opacity-100"
sx={{ borderRadius: 1 }}
> >
<Typography variant="subtitle2" gutterBottom> <Typography variant="subtitle2" gutterBottom>
{layerName} - {property} {layerName} - {property}
@@ -56,39 +53,9 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
rowGap: 0.5, rowGap: 0.5,
}} }}
> >
{[...Array(breaks.length)].map((_, index) => { {Array.from({ length: itemCount }, (_, index) => {
const color = colors[index]; // 默认颜色为黑色 const color = colors[index];
const dimension = dimensions[index]; // 默认尺寸为16 const dimension = dimensions[index];
// // 处理第一个区间(小于 breaks[0])
// if (index === 0) {
// return (
// <Box key={index} className="flex items-center gap-2 mb-1">
// <Box
// sx={
// type === "point"
// ? {
// width: dimension,
// height: dimension,
// borderRadius: "50%",
// backgroundColor: color,
// }
// : {
// width: 16,
// height: dimension,
// backgroundColor: color,
// border: `1px solid ${color}`,
// }
// }
// />
// <Typography variant="caption" className="text-xs">
// {"<"} {breaks[0]?.toFixed(1)}
// </Typography>
// </Box>
// );
// }
// 处理中间区间(breaks[index] - breaks[index + 1]
if (index + 1 < breaks.length) { if (index + 1 < breaks.length) {
const prevValue = breaks[index]; const prevValue = breaks[index];
const currentValue = breaks[index + 1]; const currentValue = breaks[index + 1];
+197 -118
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import Draggable from "react-draggable"; import Draggable from "react-draggable";
@@ -30,6 +30,13 @@ import { useData } from "../MapComponent";
import { config, NETWORK_NAME } from "@/config/config"; import { config, NETWORK_NAME } from "@/config/config";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { useMap } from "../MapComponent"; import { useMap } from "../MapComponent";
import {
formatTimelineTime,
getRoundedCurrentTimelineMinutes,
getTimelineDisabledRangePercentages,
normalizeTimelineMinutes,
} from "./timelineTime";
import { useTimelineTimeConfig } from "./useTimelineTimeConfig";
interface TimelineProps { interface TimelineProps {
schemeDate?: Date; schemeDate?: Date;
@@ -58,6 +65,13 @@ const timelineIconButtonSx = {
const NOOP_SET_CURRENT_TIME = (_: any) => undefined; const NOOP_SET_CURRENT_TIME = (_: any) => undefined;
const NOOP_SET_SELECTED_DATE = (_: any) => undefined; const NOOP_SET_SELECTED_DATE = (_: any) => undefined;
const BASE_CALCULATED_INTERVAL_OPTIONS = [
{ value: 1440, label: "1 天" },
{ value: 60, label: "1 小时" },
{ value: 30, label: "30 分钟" },
{ value: 15, label: "15 分钟" },
{ value: 5, label: "5 分钟" },
];
const Timeline: React.FC<TimelineProps> = ({ const Timeline: React.FC<TimelineProps> = ({
schemeDate, schemeDate,
@@ -87,18 +101,39 @@ const Timeline: React.FC<TimelineProps> = ({
const pipeText = data?.pipeText ?? ""; const pipeText = data?.pipeText ?? "";
const setForceStyleAutoApplyVersion = data?.setForceStyleAutoApplyVersion; const setForceStyleAutoApplyVersion = data?.setForceStyleAutoApplyVersion;
const { open } = useNotification(); const { open } = useNotification();
const { durationMinutes, stepMinutes } = useTimelineTimeConfig();
const [isPlaying, setIsPlaying] = useState<boolean>(false); const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒 const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒
const [calculatedInterval, setCalculatedInterval] = useState<number>(15); // 分钟 const [calculatedInterval, setCalculatedInterval] =
useState<number>(stepMinutes); // 分钟
const [isCalculating, setIsCalculating] = useState<boolean>(false); const [isCalculating, setIsCalculating] = useState<boolean>(false);
const [sliderPreviewTime, setSliderPreviewTime] = useState<number | null>(null);
// 计算时间轴范围 // 计算时间轴范围
const minTime = timeRange const minTime = timeRange
? timeRange.start.getHours() * 60 + timeRange.start.getMinutes() ? normalizeTimelineMinutes(
timeRange.start.getHours() * 60 + timeRange.start.getMinutes(),
0,
durationMinutes,
)
: 0; : 0;
const maxTime = timeRange const maxTime = timeRange
? timeRange.end.getHours() * 60 + timeRange.end.getMinutes() ? normalizeTimelineMinutes(
: 1440; timeRange.end.getHours() * 60 + timeRange.end.getMinutes(),
0,
durationMinutes,
)
: durationMinutes;
const timelineCurrentTime = normalizeTimelineMinutes(
currentTime,
minTime,
maxTime,
);
const disabledRangePercentages = getTimelineDisabledRangePercentages(
minTime,
maxTime,
durationMinutes,
);
useEffect(() => { useEffect(() => {
if (schemeDate) { if (schemeDate) {
setSelectedDate(schemeDate); setSelectedDate(schemeDate);
@@ -112,8 +147,8 @@ const Timeline: React.FC<TimelineProps> = ({
// 添加缓存引用 // 添加缓存引用
const nodeCacheRef = useRef<Map<string, any[]>>(new Map()); const nodeCacheRef = useRef<Map<string, any[]>>(new Map());
const linkCacheRef = useRef<Map<string, any[]>>(new Map()); const linkCacheRef = useRef<Map<string, any[]>>(new Map());
// 添加防抖引用 const frameRequestRevisionRef = useRef(0);
const debounceRef = useRef<NodeJS.Timeout | null>(null); const frameAbortControllerRef = useRef<AbortController | null>(null);
const updateDataStates = useCallback( const updateDataStates = useCallback(
( (
@@ -168,6 +203,7 @@ const Timeline: React.FC<TimelineProps> = ({
target, target,
schemeName, schemeName,
schemeType, schemeType,
signal,
}: { }: {
queryTime: Date; queryTime: Date;
junctionProperties: string; junctionProperties: string;
@@ -176,6 +212,7 @@ const Timeline: React.FC<TimelineProps> = ({
target: "primary" | "compare"; target: "primary" | "compare";
schemeName?: string; schemeName?: string;
schemeType?: string; schemeType?: string;
signal?: AbortSignal;
}) => { }) => {
const query_time = queryTime.toISOString(); const query_time = queryTime.toISOString();
let nodeRecords: any = { results: [] }; let nodeRecords: any = { results: [] };
@@ -199,10 +236,12 @@ const Timeline: React.FC<TimelineProps> = ({
nodePromise = nodePromise =
sourceType === "scheme" && schemeName sourceType === "scheme" && schemeName
? apiFetch( ? apiFetch(
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}` `${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`,
{ signal },
) )
: apiFetch( : apiFetch(
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}` `${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`,
{ signal },
); );
requests.push(nodePromise); requests.push(nodePromise);
} }
@@ -226,10 +265,12 @@ const Timeline: React.FC<TimelineProps> = ({
linkPromise = linkPromise =
sourceType === "scheme" && schemeName sourceType === "scheme" && schemeName
? apiFetch( ? apiFetch(
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}` `${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
{ signal },
) )
: apiFetch( : apiFetch(
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}` `${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
{ signal },
); );
requests.push(linkPromise); requests.push(linkPromise);
} }
@@ -275,9 +316,13 @@ const Timeline: React.FC<TimelineProps> = ({
); );
} }
updateDataStates(nodeRecords.results || [], linkRecords.results || [], target); return {
target,
nodeResults: nodeRecords.results || [],
linkResults: linkRecords.results || [],
};
}, },
[buildCacheKey, updateDataStates] [buildCacheKey]
); );
const fetchFrameData = useCallback( const fetchFrameData = useCallback(
@@ -288,6 +333,11 @@ const Timeline: React.FC<TimelineProps> = ({
schemeName: string, schemeName: string,
schemeType: string schemeType: string
) => { ) => {
const revision = frameRequestRevisionRef.current + 1;
frameRequestRevisionRef.current = revision;
frameAbortControllerRef.current?.abort();
const abortController = new AbortController();
frameAbortControllerRef.current = abortController;
const primarySourceType = const primarySourceType =
disableDateSelection && schemeName ? "scheme" : "realtime"; disableDateSelection && schemeName ? "scheme" : "realtime";
const tasks = [ const tasks = [
@@ -299,6 +349,7 @@ const Timeline: React.FC<TimelineProps> = ({
target: "primary", target: "primary",
schemeName, schemeName,
schemeType, schemeType,
signal: abortController.signal,
}), }),
]; ];
@@ -310,37 +361,62 @@ const Timeline: React.FC<TimelineProps> = ({
pipeProperties, pipeProperties,
sourceType: "realtime", sourceType: "realtime",
target: "compare", target: "compare",
signal: abortController.signal,
}) })
); );
} }
await Promise.all(tasks); try {
const frames = await Promise.all(tasks);
if (
abortController.signal.aborted ||
revision !== frameRequestRevisionRef.current
) {
return;
}
frames.forEach(({ nodeResults, linkResults, target }) => {
updateDataStates(nodeResults, linkResults, target);
});
} catch (error) {
if ((error as Error).name !== "AbortError") {
console.error("Timeline frame fetch failed:", error);
}
}
}, },
[disableDateSelection, fetchDataBySource, isCompareMode] [disableDateSelection, fetchDataBySource, isCompareMode, updateDataStates]
); );
// 时间刻度数组 (每5分钟一个刻度)
const timeMarks = Array.from({ length: 288 }, (_, i) => ({
value: i * 5,
label: i % 24 === 0 ? formatTime(i * 5) : "",
}));
// 格式化时间显示 // 格式化时间显示
function formatTime(minutes: number): string { const formatTime = useCallback((minutes: number): string => {
const hours = Math.floor(minutes / 60); return formatTimelineTime(minutes, minTime, maxTime);
const mins = minutes % 60; }, [maxTime, minTime]);
return `${hours.toString().padStart(2, "0")}:${mins
.toString()
.padStart(2, "0")}`;
}
function currentTimeToDate(selectedDate: Date, minutes: number): Date { const timeMarks = useMemo(() => {
const marks = [];
const markInterval = 120;
for (let value = 0; value <= durationMinutes; value += markInterval) {
marks.push({
value,
label: formatTime(value),
});
}
if (marks.at(-1)?.value !== durationMinutes) {
marks.push({
value: durationMinutes,
label: formatTime(durationMinutes),
});
}
return marks;
}, [durationMinutes, formatTime]);
const currentTimeToDate = useCallback((selectedDate: Date, minutes: number): Date => {
const date = new Date(selectedDate); const date = new Date(selectedDate);
const hours = Math.floor(minutes / 60); const normalizedMinutes = normalizeTimelineMinutes(minutes, minTime, maxTime);
const mins = minutes % 60; const hours = Math.floor(normalizedMinutes / 60);
const mins = normalizedMinutes % 60;
date.setHours(hours, mins, 0, 0); date.setHours(hours, mins, 0, 0);
return date; return date;
} }, [maxTime, minTime]);
// 播放时间间隔选项 // 播放时间间隔选项
const intervalOptions = [ const intervalOptions = [
@@ -350,13 +426,31 @@ const Timeline: React.FC<TimelineProps> = ({
{ value: 20000, label: "20秒" }, { value: 20000, label: "20秒" },
]; ];
// 强制计算时间段选项 // 强制计算时间段选项
const calculatedIntervalOptions = [ const resolvedCalculatedIntervalOptions = useMemo(() => {
{ value: 1440, label: "1 天" }, const options = BASE_CALCULATED_INTERVAL_OPTIONS.filter(
{ value: 60, label: "1 小时" }, (option) => option.value >= stepMinutes,
{ value: 30, label: "30 分钟" }, );
{ value: 15, label: "15 分钟" }, if (!options.some((option) => option.value === stepMinutes)) {
{ value: 5, label: "5 分钟" }, options.push({ value: stepMinutes, label: `${stepMinutes} 分钟` });
]; }
return options.sort((a, b) => b.value - a.value);
}, [stepMinutes]);
const advanceTimelineTime = useCallback(
(previousTime: number, direction: 1 | -1 = 1) => {
const baseTime = Number.isFinite(previousTime) ? previousTime : minTime;
let next = baseTime + stepMinutes * direction;
if (timeRange) {
if (next > maxTime) next = minTime;
if (next < minTime) next = maxTime;
} else {
if (next > durationMinutes) next = 0;
if (next < 0) next = durationMinutes;
}
return next;
},
[durationMinutes, maxTime, minTime, stepMinutes, timeRange],
);
// 处理时间轴滑动 // 处理时间轴滑动
const handleSliderChange = useCallback( const handleSliderChange = useCallback(
@@ -366,15 +460,18 @@ const Timeline: React.FC<TimelineProps> = ({
if (timeRange && (value < minTime || value > maxTime)) { if (timeRange && (value < minTime || value > maxTime)) {
return; return;
} }
// 防抖设置currentTime,避免频繁触发数据获取 setSliderPreviewTime(value);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
setCurrentTime(value);
}, 500); // 500ms 防抖延迟
}, },
[timeRange, minTime, maxTime, setCurrentTime], [timeRange, minTime, maxTime],
);
const handleSliderChangeCommitted = useCallback(
(_event: Event | React.SyntheticEvent, newValue: number | number[]) => {
const value = Array.isArray(newValue) ? newValue[0] : newValue;
setSliderPreviewTime(null);
setCurrentTime(value);
},
[setCurrentTime],
); );
// 播放控制 // 播放控制
@@ -391,17 +488,11 @@ const Timeline: React.FC<TimelineProps> = ({
intervalRef.current = setInterval(() => { intervalRef.current = setInterval(() => {
setCurrentTime((prev) => { setCurrentTime((prev) => {
let next = prev + 15; return advanceTimelineTime(prev);
if (timeRange) {
if (next > maxTime) next = minTime;
} else {
if (next >= 1440) next = 0;
}
return next;
}); });
}, playInterval); }, playInterval);
} }
}, [isPlaying, playInterval, timeRange, maxTime, minTime, setCurrentTime]); }, [advanceTimelineTime, isPlaying, playInterval, setCurrentTime]);
const handlePause = useCallback(() => { const handlePause = useCallback(() => {
setIsPlaying(false); setIsPlaying(false);
@@ -414,14 +505,14 @@ const Timeline: React.FC<TimelineProps> = ({
const handleStop = useCallback(() => { const handleStop = useCallback(() => {
setIsPlaying(false); setIsPlaying(false);
// 设置为当前时间 // 设置为当前时间
const currentTime = new Date(); setCurrentTime(
const minutes = currentTime.getHours() * 60 + currentTime.getMinutes(); getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes),
setCurrentTime(minutes); // 组件卸载时重置时间 ); // 重置为当前时间
if (intervalRef.current) { if (intervalRef.current) {
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
intervalRef.current = null; intervalRef.current = null;
} }
}, [setCurrentTime]); }, [durationMinutes, setCurrentTime, stepMinutes]);
// 步进控制 // 步进控制
const handleDayStepBackward = useCallback(() => { const handleDayStepBackward = useCallback(() => {
@@ -440,27 +531,15 @@ const Timeline: React.FC<TimelineProps> = ({
}, [setSelectedDate]); }, [setSelectedDate]);
const handleStepBackward = useCallback(() => { const handleStepBackward = useCallback(() => {
setCurrentTime((prev) => { setCurrentTime((prev) => {
let next = prev - 15; return advanceTimelineTime(prev, -1);
if (timeRange) {
if (next < minTime) next = maxTime;
} else {
if (next < 0) next += 1440;
}
return next;
}); });
}, [timeRange, minTime, maxTime, setCurrentTime]); }, [advanceTimelineTime, setCurrentTime]);
const handleStepForward = useCallback(() => { const handleStepForward = useCallback(() => {
setCurrentTime((prev) => { setCurrentTime((prev) => {
let next = prev + 15; return advanceTimelineTime(prev);
if (timeRange) {
if (next > maxTime) next = minTime;
} else {
if (next >= 1440) next = 0;
}
return next;
}); });
}, [timeRange, minTime, maxTime, setCurrentTime]); }, [advanceTimelineTime, setCurrentTime]);
// 日期选择处理 // 日期选择处理
const handleDateChange = useCallback((newDate: Date | null) => { const handleDateChange = useCallback((newDate: Date | null) => {
@@ -480,18 +559,12 @@ const Timeline: React.FC<TimelineProps> = ({
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => { intervalRef.current = setInterval(() => {
setCurrentTime((prev) => { setCurrentTime((prev) => {
let next = prev + 15; return advanceTimelineTime(prev);
if (timeRange) {
if (next > maxTime) next = minTime;
} else {
if (next >= 1440) next = 0;
}
return next;
}); });
}, newInterval); }, newInterval);
} }
}, },
[isPlaying, timeRange, maxTime, minTime, setCurrentTime], [advanceTimelineTime, isPlaying, setCurrentTime],
); );
// 计算时间段改变处理 // 计算时间段改变处理
const handleCalculatedIntervalChange = useCallback((event: any) => { const handleCalculatedIntervalChange = useCallback((event: any) => {
@@ -499,6 +572,10 @@ const Timeline: React.FC<TimelineProps> = ({
setCalculatedInterval(newInterval); setCalculatedInterval(newInterval);
}, []); }, []);
useEffect(() => {
setCalculatedInterval(stepMinutes);
}, [stepMinutes]);
// 添加 useEffect 来监听 currentTime 和 selectedDate 的变化,并获取数据 // 添加 useEffect 来监听 currentTime 和 selectedDate 的变化,并获取数据
useEffect(() => { useEffect(() => {
// 首次加载时,如果 selectedDate 或 currentTime 未定义,则跳过执行,避免报错 // 首次加载时,如果 selectedDate 或 currentTime 未定义,则跳过执行,避免报错
@@ -514,7 +591,7 @@ const Timeline: React.FC<TimelineProps> = ({
// return; // return;
// } // }
fetchFrameData( fetchFrameData(
currentTimeToDate(selectedDate, currentTime), currentTimeToDate(selectedDate, timelineCurrentTime),
junctionText, junctionText,
pipeText, pipeText,
schemeName, schemeName,
@@ -526,6 +603,8 @@ const Timeline: React.FC<TimelineProps> = ({
junctionText, junctionText,
pipeText, pipeText,
currentTime, currentTime,
currentTimeToDate,
timelineCurrentTime,
selectedDate, selectedDate,
schemeName, schemeName,
schemeType, schemeType,
@@ -534,21 +613,17 @@ const Timeline: React.FC<TimelineProps> = ({
// 组件卸载时清理定时器和防抖 // 组件卸载时清理定时器和防抖
useEffect(() => { useEffect(() => {
// 设置为当前时间 // 设置为当前时间
const currentTime = new Date(); setCurrentTime(
const minutes = currentTime.getHours() * 60 + currentTime.getMinutes(); getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes),
// 找到最近的前15分钟刻度 ); // 初始化为当前时间
const roundedMinutes = Math.floor(minutes / 15) * 15;
setCurrentTime(roundedMinutes); // 组件卸载时重置时间
return () => { return () => {
if (intervalRef.current) { if (intervalRef.current) {
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
} }
if (debounceRef.current) { frameAbortControllerRef.current?.abort();
clearTimeout(debounceRef.current);
}
}; };
}, [setCurrentTime]); }, [durationMinutes, setCurrentTime, stepMinutes]);
// 当 timeRange 改变时,设置 currentTime 到 minTime // 当 timeRange 改变时,设置 currentTime 到 minTime
useEffect(() => { useEffect(() => {
@@ -603,7 +678,7 @@ const Timeline: React.FC<TimelineProps> = ({
clearCache(linkCacheRef); clearCache(linkCacheRef);
// 重新获取当前时刻的新数据 // 重新获取当前时刻的新数据
fetchFrameData( fetchFrameData(
currentTimeToDate(selectedDate, currentTime), currentTimeToDate(selectedDate, timelineCurrentTime),
junctionText, junctionText,
pipeText, pipeText,
schemeName, schemeName,
@@ -622,7 +697,7 @@ const Timeline: React.FC<TimelineProps> = ({
// 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变 // 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变
const calculationDate = selectedDate; const calculationDate = selectedDate;
const calculationTime = currentTime; const calculationTime = timelineCurrentTime;
const calculationDateTime = currentTimeToDate( const calculationDateTime = currentTimeToDate(
calculationDate, calculationDate,
calculationTime calculationTime
@@ -690,7 +765,7 @@ const Timeline: React.FC<TimelineProps> = ({
<Draggable nodeRef={draggableRef} handle=".drag-handle"> <Draggable nodeRef={draggableRef} handle=".drag-handle">
<div <div
ref={draggableRef} ref={draggableRef}
className="absolute bottom-4 left-1/2 z-10 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100" className="absolute bottom-4 left-1/2 z-20 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100"
> >
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<Paper <Paper
@@ -860,7 +935,7 @@ const Timeline: React.FC<TimelineProps> = ({
label="强制计算时间段" label="强制计算时间段"
onChange={handleCalculatedIntervalChange} onChange={handleCalculatedIntervalChange}
> >
{calculatedIntervalOptions.map((option) => ( {resolvedCalculatedIntervalOptions.map((option) => (
<MenuItem key={option.value} value={option.value}> <MenuItem key={option.value} value={option.value}>
{option.label} {option.label}
</MenuItem> </MenuItem>
@@ -890,18 +965,19 @@ const Timeline: React.FC<TimelineProps> = ({
color: "primary.main", color: "primary.main",
}} }}
> >
{formatTime(currentTime)} {formatTime(timelineCurrentTime)}
</Typography> </Typography>
</Stack> </Stack>
<Box ref={timelineRef} sx={{ px: 2, position: "relative" }}> <Box ref={timelineRef} sx={{ px: 2, position: "relative" }}>
<Slider <Slider
value={currentTime} value={sliderPreviewTime ?? timelineCurrentTime}
min={0} min={0}
max={1440} // 24:00 = 1440分钟 max={durationMinutes}
step={15} // 每15分钟一个步进 step={stepMinutes}
marks={timeMarks.filter((_, index) => index % 12 === 0)} // 每小时显示一个标记 marks={timeMarks}
onChange={handleSliderChange} onChange={handleSliderChange}
onChangeCommitted={handleSliderChangeCommitted}
valueLabelDisplay="auto" valueLabelDisplay="auto"
valueLabelFormat={formatTime} valueLabelFormat={formatTime}
sx={{ sx={{
@@ -942,43 +1018,46 @@ const Timeline: React.FC<TimelineProps> = ({
/> />
{/* 禁用区域遮罩 */} {/* 禁用区域遮罩 */}
{timeRange && ( {timeRange && (
<> <Box
sx={{
position: "absolute",
left: 16,
right: 16,
top: "30%",
transform: "translateY(-50%)",
height: "20px",
pointerEvents: "none",
}}
>
{/* 左侧禁用区域 */} {/* 左侧禁用区域 */}
{minTime > 0 && ( {minTime > 0 && (
<Box <Box
sx={{ sx={{
position: "absolute", position: "absolute",
left: "14px", left: 0,
top: "30%", width: `${disabledRangePercentages.leftWidth}%`,
transform: "translateY(-50%)", height: "100%",
width: `${(minTime / 1440) * 856 + 2}px`,
height: "20px",
backgroundColor: "rgba(189, 189, 189, 0.4)", backgroundColor: "rgba(189, 189, 189, 0.4)",
pointerEvents: "none",
backdropFilter: "blur(1px)", backdropFilter: "blur(1px)",
borderRadius: "2.5px", borderRadius: "2.5px",
rounded: "true",
}} }}
/> />
)} )}
{/* 右侧禁用区域 */} {/* 右侧禁用区域 */}
{maxTime < 1440 && ( {maxTime < durationMinutes && (
<Box <Box
sx={{ sx={{
position: "absolute", position: "absolute",
left: `${16 + (maxTime / 1440) * 856}px`, left: `${disabledRangePercentages.rightLeft}%`,
top: "30%", width: `${disabledRangePercentages.rightWidth}%`,
transform: "translateY(-50%)", height: "100%",
width: `${((1440 - maxTime) / 1440) * 856}px`,
height: "20px",
backgroundColor: "rgba(189, 189, 189, 0.4)", backgroundColor: "rgba(189, 189, 189, 0.4)",
pointerEvents: "none",
backdropFilter: "blur(1px)", backdropFilter: "blur(1px)",
borderRadius: "2.5px", borderRadius: "2.5px",
}} }}
/> />
)} )}
</> </Box>
)} )}
</Box> </Box>
</Box> </Box>
+347 -4
View File
@@ -26,7 +26,8 @@ import {
import { useToolbarChatActions } from "./useToolbarChatActions"; import { useToolbarChatActions } from "./useToolbarChatActions";
import { useStyleEditor } from "./useStyleEditor"; import { useStyleEditor } from "./useStyleEditor";
import { config } from "@/config/config"; import { config, NETWORK_NAME } from "@/config/config";
import { useProject } from "@/contexts/ProjectContext";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
// 添加接口定义隐藏按钮的props // 添加接口定义隐藏按钮的props
@@ -37,6 +38,52 @@ interface ToolbarProps {
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板 HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
enableCompare?: boolean; enableCompare?: boolean;
} }
type LinkStatus = "OPEN" | "CLOSED" | "ACTIVE";
type ValveProperties = {
vType: string | null;
setting: string | null;
};
const isValveLayer = (layerId: string | undefined) =>
layerId === "geo_valves_mat" || layerId === "geo_valves";
const isLinkStatus = (value: unknown): value is LinkStatus =>
value === "OPEN" || value === "CLOSED" || value === "ACTIVE";
const normalizeValveSetting = (value: unknown): string | null => {
if (value === undefined || value === null) return null;
return String(value);
};
const validateValveSetting = (
valveType: string | null,
value: string,
): string | null => {
const normalizedType = valveType?.toUpperCase();
const trimmedValue = value.trim();
const numericTypes = new Set(["PRV", "PSV", "PBV", "FCV", "TCV"]);
if (normalizedType === "GPV") {
return trimmedValue ? null : "GPV 阀门设置值必须是非空曲线 ID。";
}
if (numericTypes.has(normalizedType ?? "")) {
if (!trimmedValue) {
return "阀门设置值必须是 0 或正数。";
}
const numericValue = Number(trimmedValue);
if (!Number.isFinite(numericValue) || numericValue < 0) {
return "阀门设置值必须是有限数字,且大于或等于 0。";
}
return null;
}
return trimmedValue ? null : "阀门设置值不能为空。";
};
const Toolbar: React.FC<ToolbarProps> = ({ const Toolbar: React.FC<ToolbarProps> = ({
hiddenButtons, hiddenButtons,
queryType, queryType,
@@ -46,6 +93,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
}) => { }) => {
const map = useMap(); const map = useMap();
const data = useData(); const data = useData();
const project = useProject();
const { open } = useNotification(); const { open } = useNotification();
const [activeTools, setActiveTools] = useState<string[]>([]); const [activeTools, setActiveTools] = useState<string[]>([]);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
@@ -58,6 +106,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
const currentTime = data?.currentTime; const currentTime = data?.currentTime;
const selectedDate = data?.selectedDate; const selectedDate = data?.selectedDate;
const schemeName = data?.schemeName; const schemeName = data?.schemeName;
const networkName = project?.networkName || NETWORK_NAME;
const isCompareMode = data?.isCompareMode ?? false; const isCompareMode = data?.isCompareMode ?? false;
const toggleCompareMode = data?.toggleCompareMode; const toggleCompareMode = data?.toggleCompareMode;
const canToggleCompare = Boolean( const canToggleCompare = Boolean(
@@ -89,6 +138,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
const styleEditor = useStyleEditor({ const styleEditor = useStyleEditor({
layerStyleStates, layerStyleStates,
setLayerStyleStates, setLayerStyleStates,
workspace: project?.workspace || config.MAP_WORKSPACE,
}); });
useToolbarChatActions({ useToolbarChatActions({
@@ -140,6 +190,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
properties: { properties: {
name: "属性查询高亮图层", // 设置图层名称 name: "属性查询高亮图层", // 设置图层名称
value: "info_highlight_layer", value: "info_highlight_layer",
queryable: false,
type: "multigeometry", type: "multigeometry",
properties: [], properties: [],
}, },
@@ -325,6 +376,261 @@ const Toolbar: React.FC<ToolbarProps> = ({
const [computedProperties, setComputedProperties] = useState< const [computedProperties, setComputedProperties] = useState<
Record<string, any> Record<string, any>
>({}); >({});
const [valveStatus, setValveStatus] = useState<LinkStatus | null>(null);
const [isValveStatusLoading, setIsValveStatusLoading] = useState(false);
const [isValveStatusSaving, setIsValveStatusSaving] = useState(false);
const [valveProperties, setValveProperties] = useState<ValveProperties>({
vType: null,
setting: null,
});
const [isValvePropertiesLoading, setIsValvePropertiesLoading] =
useState(false);
const [isValveSettingSaving, setIsValveSettingSaving] = useState(false);
const selectedFeature = highlightFeatures[0];
const selectedFeatureLayer = selectedFeature
?.getId()
?.toString()
.split(".")[0];
const selectedFeatureId = selectedFeature?.getProperties?.().id;
const selectedFeatureValveType = selectedFeature?.getProperties?.().v_type;
const selectedValveType =
valveProperties.vType ??
(selectedFeatureValveType ? String(selectedFeatureValveType) : null);
const selectedValveId =
showPropertyPanel && isValveLayer(selectedFeatureLayer) && selectedFeatureId
? String(selectedFeatureId)
: null;
useEffect(() => {
if (!selectedValveId) {
setValveStatus(null);
setIsValveStatusLoading(false);
return;
}
let cancelled = false;
const queryValveStatus = async () => {
setIsValveStatusLoading(true);
setValveStatus(null);
try {
const params = new URLSearchParams({
network: networkName,
link: selectedValveId,
});
const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/getstatus/?${params.toString()}`,
);
if (!response.ok) {
throw new Error(`getstatus failed: ${response.status}`);
}
const data = await response.json();
if (!cancelled) {
setValveStatus(isLinkStatus(data?.status) ? data.status : null);
}
} catch (error) {
console.error("Error querying valve status:", error);
if (!cancelled) {
setValveStatus(null);
open?.({
type: "error",
message: "读取阀门开关状态失败。",
});
}
} finally {
if (!cancelled) {
setIsValveStatusLoading(false);
}
}
};
queryValveStatus();
return () => {
cancelled = true;
};
}, [networkName, open, selectedValveId]);
useEffect(() => {
if (!selectedValveId) {
setValveProperties({ vType: null, setting: null });
setIsValvePropertiesLoading(false);
return;
}
let cancelled = false;
const queryValveProperties = async () => {
setIsValvePropertiesLoading(true);
setValveProperties({ vType: null, setting: null });
try {
const params = new URLSearchParams({
network: networkName,
valve: selectedValveId,
});
const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/getvalveproperties/?${params.toString()}`,
);
if (!response.ok) {
throw new Error(`getvalveproperties failed: ${response.status}`);
}
const data = await response.json();
if (!cancelled) {
setValveProperties({
vType: data?.v_type ? String(data.v_type) : null,
setting: normalizeValveSetting(data?.setting),
});
}
} catch (error) {
console.error("Error querying valve properties:", error);
if (!cancelled) {
setValveProperties({ vType: null, setting: null });
open?.({
type: "error",
message: "读取阀门设置值失败。",
});
}
} finally {
if (!cancelled) {
setIsValvePropertiesLoading(false);
}
}
};
queryValveProperties();
return () => {
cancelled = true;
};
}, [networkName, open, selectedValveId]);
const handleValveStatusSave = useCallback(
async (nextStatus: string) => {
if (!selectedValveId || !isLinkStatus(nextStatus)) {
return;
}
setIsValveStatusSaving(true);
try {
const params = new URLSearchParams({
network: networkName,
link: selectedValveId,
});
const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/setstatus/?${params.toString()}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ status: nextStatus }),
},
);
if (!response.ok) {
throw new Error(`setstatus failed: ${response.status}`);
}
setValveStatus(nextStatus);
open?.({
type: "success",
message: "阀门开关状态已更新。",
});
} catch (error) {
console.error("Error updating valve status:", error);
open?.({
type: "error",
message: "阀门开关状态更新失败。",
});
} finally {
setIsValveStatusSaving(false);
}
},
[networkName, open, selectedValveId],
);
const handleValveSettingSave = useCallback(
async (nextSetting: string) => {
if (!selectedValveId) {
return;
}
if (valveStatus === "OPEN" || valveStatus === "CLOSED") {
open?.({
type: "error",
message: "开启或关闭状态下不能编辑阀门设置值。",
});
return;
}
const validationMessage = validateValveSetting(
selectedValveType,
nextSetting,
);
if (validationMessage) {
open?.({
type: "error",
message: validationMessage,
});
return;
}
const trimmedSetting = nextSetting.trim();
setIsValveSettingSaving(true);
try {
const params = new URLSearchParams({
network: networkName,
valve: selectedValveId,
});
const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/setvalveproperties/?${params.toString()}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ setting: trimmedSetting }),
},
);
if (!response.ok) {
throw new Error(`setvalveproperties failed: ${response.status}`);
}
setValveProperties((prev) => ({
...prev,
setting: trimmedSetting,
}));
open?.({
type: "success",
message: "阀门设置值已更新。",
});
} catch (error) {
console.error("Error updating valve setting:", error);
open?.({
type: "error",
message: "阀门设置值更新失败。",
});
} finally {
setIsValveSettingSaving(false);
}
},
[
networkName,
open,
selectedValveId,
selectedValveType,
valveStatus,
],
);
// 添加 useEffect 来查询计算属性 // 添加 useEffect 来查询计算属性
useEffect(() => { useEffect(() => {
if (highlightFeatures.length === 0 || !selectedDate || !showPropertyPanel) { if (highlightFeatures.length === 0 || !selectedDate || !showPropertyPanel) {
@@ -387,8 +693,43 @@ const Toolbar: React.FC<ToolbarProps> = ({
}, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]); }, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]);
const propertyPanelData = useMemo( const propertyPanelData = useMemo(
() => buildFeatureProperties(highlightFeatures[0], computedProperties), () =>
[highlightFeatures, computedProperties], buildFeatureProperties(
selectedFeature,
computedProperties,
selectedValveId
? {
value: valveStatus,
loading: isValveStatusLoading,
saving: isValveStatusSaving,
onSave: handleValveStatusSave,
}
: undefined,
selectedValveId
? {
value: valveProperties.setting,
vType: selectedValveType,
loading: isValvePropertiesLoading,
saving: isValveSettingSaving,
status: valveStatus,
onSave: handleValveSettingSave,
}
: undefined,
),
[
selectedFeature,
computedProperties,
selectedValveId,
valveStatus,
valveProperties,
selectedValveType,
isValveStatusLoading,
isValveStatusSaving,
isValvePropertiesLoading,
isValveSettingSaving,
handleValveStatusSave,
handleValveSettingSave,
],
); );
if (!data) { if (!data) {
@@ -463,10 +804,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
onClassificationMethodChange={styleEditor.handleClassificationMethodChange} onClassificationMethodChange={styleEditor.handleClassificationMethodChange}
onSegmentsChange={styleEditor.handleSegmentsChange} onSegmentsChange={styleEditor.handleSegmentsChange}
onCustomBreakChange={styleEditor.handleCustomBreakChange} onCustomBreakChange={styleEditor.handleCustomBreakChange}
onCustomBreakBlur={styleEditor.handleCustomBreakBlur}
onColorTypeChange={styleEditor.handleColorTypeChange} onColorTypeChange={styleEditor.handleColorTypeChange}
onApply={styleEditor.handleApply} onApply={styleEditor.handleApply}
onReset={styleEditor.handleReset} onReset={styleEditor.handleReset}
validationErrors={styleEditor.validationErrors}
isDirty={styleEditor.isDirty}
isApplying={styleEditor.isApplying}
/> />
</div> </div>
<ToolbarHistoryPanel <ToolbarHistoryPanel
@@ -83,7 +83,7 @@ const DEFAULT_LAYER_STYLE_PRESETS: Record<
styleConfig: { styleConfig: {
property: "pressure", property: "pressure",
classificationMethod: "custom_breaks", classificationMethod: "custom_breaks",
customBreaks: [16, 18, 20, 22, 24, 26], customBreaks: [16, 18, 20, 22, 24, 26, 28],
customColors: [ customColors: [
"rgba(255, 0, 0, 1)", "rgba(255, 0, 0, 1)",
"rgba(255, 127, 0, 1)", "rgba(255, 127, 0, 1)",
@@ -137,7 +137,7 @@ const DEFAULT_LAYER_STYLE_PRESETS: Record<
showId: false, showId: false,
opacity: 0.9, opacity: 0.9,
adjustWidthByProperty: true, adjustWidthByProperty: true,
customBreaks: [0.2, 0.4, 0.6, 0.8, 1.0, 1.2], customBreaks: [0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4],
customColors: [], customColors: [],
}, },
legendConfig: { legendConfig: {
@@ -3,16 +3,20 @@ import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import { LegendStyleConfig } from "./StyleLegend"; import { LegendStyleConfig } from "./StyleLegend";
export type ClassificationMethod = "pretty_breaks" | "custom_breaks";
export type ColorType = "single" | "gradient" | "rainbow" | "custom";
export interface StyleConfig { export interface StyleConfig {
property: string; property: string;
classificationMethod: string; classificationMethod: ClassificationMethod;
/** Number of rendered intervals. Boundaries always contain segments + 1 values. */
segments: number; segments: number;
minSize: number; minSize: number;
maxSize: number; maxSize: number;
minStrokeWidth: number; minStrokeWidth: number;
maxStrokeWidth: number; maxStrokeWidth: number;
fixedStrokeWidth: number; fixedStrokeWidth: number;
colorType: string; colorType: ColorType;
singlePaletteIndex: number; singlePaletteIndex: number;
gradientPaletteIndex: number; gradientPaletteIndex: number;
rainbowPaletteIndex: number; rainbowPaletteIndex: number;
@@ -24,6 +28,19 @@ export interface StyleConfig {
customColors?: string[]; customColors?: string[];
} }
export interface ResolvedLayerStyle {
boundaries: number[];
colors: string[];
dimensions: number[];
labels: string[];
isConstant: boolean;
}
export interface StyleValidationResult {
valid: boolean;
errors: string[];
}
export interface LayerStyleState { export interface LayerStyleState {
layerId: string; layerId: string;
layerName: string; layerName: string;
@@ -37,6 +54,7 @@ export type DefaultLayerStyleId = "junctions" | "pipes";
export interface StyleEditorStateProps { export interface StyleEditorStateProps {
layerStyleStates: LayerStyleState[]; layerStyleStates: LayerStyleState[];
setLayerStyleStates: React.Dispatch<React.SetStateAction<LayerStyleState[]>>; setLayerStyleStates: React.Dispatch<React.SetStateAction<LayerStyleState[]>>;
workspace: string;
} }
export interface AvailableProperty { export interface AvailableProperty {
@@ -50,15 +68,17 @@ export interface StyleEditorFormProps {
styleConfig: StyleConfig; styleConfig: StyleConfig;
setStyleConfig: React.Dispatch<React.SetStateAction<StyleConfig>>; setStyleConfig: React.Dispatch<React.SetStateAction<StyleConfig>>;
availableProperties: AvailableProperty[]; availableProperties: AvailableProperty[];
onLayerChange: (index: number) => void; onLayerChange: (layerId: string) => void;
onPropertyChange: (property: string) => void; onPropertyChange: (property: string) => void;
onClassificationMethodChange: (method: string) => void; onClassificationMethodChange: (method: ClassificationMethod) => void;
onSegmentsChange: (segments: number) => void; onSegmentsChange: (segments: number) => void;
onCustomBreakChange: (index: number, value: string) => void; onCustomBreakChange: (index: number, value: string) => void;
onCustomBreakBlur: () => void; onColorTypeChange: (colorType: ColorType) => void;
onColorTypeChange: (colorType: string) => void;
onApply: () => void; onApply: () => void;
onReset: () => void; onReset: () => void;
validationErrors: string[];
isDirty: boolean;
isApplying: boolean;
} }
export interface StyleEditorPanelProps extends StyleEditorFormProps { export interface StyleEditorPanelProps extends StyleEditorFormProps {
@@ -0,0 +1,130 @@
import { createDefaultLayerStyleState } from "./styleEditorPresets";
import {
buildDynamicStyleTemplate,
buildStyleVariables,
getDefaultCustomBreaks,
hydrateStoredLayerStyleStates,
resolveLayerStyle,
requiresStyleApply,
selectStoredLayerStyles,
validateStyleConfig,
} from "./styleEditorUtils";
describe("styleEditorUtils", () => {
it("uses N intervals, N+1 boundaries and N visual values", () => {
const styleConfig = {
...createDefaultLayerStyleState("pipes").styleConfig,
classificationMethod: "pretty_breaks" as const,
segments: 4,
};
const resolved = resolveLayerStyle({
layerType: "linestring",
styleConfig,
values: [0, 1, 2, 3, 4, 5],
});
expect(resolved?.boundaries).toHaveLength(5);
expect(resolved?.colors).toHaveLength(4);
expect(resolved?.dimensions).toHaveLength(4);
expect(resolved?.labels).toHaveLength(4);
});
it("accepts signed custom boundaries and rejects unordered boundaries", () => {
const base = createDefaultLayerStyleState("junctions").styleConfig;
const valid = {
...base,
segments: 3,
customBreaks: [-5, 0, 5, 10],
customColors: base.customColors?.slice(0, 3),
};
expect(validateStyleConfig(valid)).toEqual({ valid: true, errors: [] });
expect(
validateStyleConfig({ ...valid, customBreaks: [-5, 0, 0, 10] }).valid,
).toBe(false);
});
it("creates deterministic defaults and collapses constant data labels", () => {
const defaults = getDefaultCustomBreaks({
segments: 3,
property: "pressure",
layerId: "junctions",
currentJunctionCalData: [{ value: 20 }, { value: 20 }],
});
expect(defaults).toHaveLength(4);
expect(
defaults.every(
(value, index) => index === 0 || value > defaults[index - 1],
),
).toBe(true);
const styleConfig = {
...createDefaultLayerStyleState("junctions").styleConfig,
classificationMethod: "pretty_breaks" as const,
segments: 3,
};
const resolved = resolveLayerStyle({
layerType: "point",
styleConfig,
values: [20, 20],
});
expect(resolved?.isConstant).toBe(true);
expect(resolved?.labels).toEqual(["20"]);
});
it("generates variable-only visual templates with shader-safe names", () => {
const styleConfig = createDefaultLayerStyleState("pipes").styleConfig;
const resolved = resolveLayerStyle({
layerType: "linestring",
styleConfig,
values: [],
});
expect(resolved).not.toBeNull();
const template = buildDynamicStyleTemplate({
layerType: "linestring",
property: styleConfig.property,
classCount: styleConfig.segments,
});
const variables = buildStyleVariables(styleConfig, resolved!);
const serialized = JSON.stringify({ template, variables });
expect(serialized).toContain("tj_color_0");
expect(serialized).not.toContain("__");
});
it("requires Apply only for structural changes", () => {
const applied = createDefaultLayerStyleState("pipes").styleConfig;
expect(requiresStyleApply(applied, { ...applied, opacity: 0.4 })).toBe(false);
expect(
requiresStyleApply(applied, { ...applied, minStrokeWidth: 1 }),
).toBe(false);
expect(
requiresStyleApply(applied, { ...applied, property: "flow" }),
).toBe(true);
expect(
requiresStyleApply(applied, {
...applied,
customBreaks: [...(applied.customBreaks || []), 2],
}),
).toBe(true);
});
it("hydrates workspace settings as inactive drafts", () => {
const storedPipeStyle = {
...createDefaultLayerStyleState("pipes").styleConfig,
property: "flow",
showLabels: false,
};
const restored = hydrateStoredLayerStyleStates(
{
version: 2,
layers: { pipes: storedPipeStyle },
},
2,
);
expect(restored.find((state) => state.layerId === "pipes")?.styleConfig).toEqual(
storedPipeStyle,
);
expect(restored.every((state) => !state.isActive)).toBe(true);
expect(selectStoredLayerStyles(restored).pipes).toEqual(storedPipeStyle);
});
});
@@ -1,23 +1,44 @@
import { FlatStyleLike } from "ol/style/flat"; import type { FlatStyleLike, StyleVariables } from "ol/style/flat";
import { calculateClassification } from "@utils/breaksClassification"; import { calculateClassification } from "@utils/breaksClassification";
import { parseColor } from "@utils/parseColor"; import { parseColor } from "@utils/parseColor";
import { import {
createDefaultLayerStyleStates,
GRADIENT_PALETTES, GRADIENT_PALETTES,
RAINBOW_PALETTES, RAINBOW_PALETTES,
SINGLE_COLOR_PALETTES, SINGLE_COLOR_PALETTES,
} from "./styleEditorPresets"; } from "./styleEditorPresets";
import { StyleConfig } from "./styleEditorTypes"; import type {
DefaultLayerStyleId,
LayerStyleState,
ResolvedLayerStyle,
StyleConfig,
StyleValidationResult,
} from "./styleEditorTypes";
export const MIN_CLASS_COUNT = 2;
export const MAX_CLASS_COUNT = 10;
const clampIndex = (value: number, length: number) =>
Math.min(Math.max(Math.round(value) || 0, 0), Math.max(length - 1, 0));
const arraysEqual = <T>(left: readonly T[] = [], right: readonly T[] = []) =>
left.length === right.length && left.every((value, index) => value === right[index]);
const withOpacity = (color: string, opacity: number) => {
const parsed = parseColor(color);
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${opacity})`;
};
const formatBoundary = (value: number) =>
Number.isInteger(value) ? String(value) : Number(value.toFixed(3)).toString();
export const rgbaToHex = (rgba: string) => { export const rgbaToHex = (rgba: string) => {
try { try {
const c = parseColor(rgba); const color = parseColor(rgba);
const toHex = (n: number) => { const toHex = (value: number) => Math.round(value).toString(16).padStart(2, "0");
const hex = Math.round(n).toString(16); return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`;
return hex.length === 1 ? `0${hex}` : hex;
};
return `#${toHex(c.r)}${toHex(c.g)}${toHex(c.b)}`;
} catch { } catch {
return "#000000"; return "#000000";
} }
@@ -28,25 +49,71 @@ export const hexToRgba = (hex: string) => {
return result return result
? `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt( ? `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(
result[3], result[3],
16 16,
)}, 1)` )}, 1)`
: "rgba(0, 0, 0, 1)"; : "rgba(0, 0, 0, 1)";
}; };
export const getDefaultCustomColors = ( export const getDefaultCustomColors = (
segments: number, segments: number,
existingColors: string[] = [] existingColors: string[] = [],
) => { ) => {
const nextColors = [...existingColors]; const nextColors = [...existingColors];
const baseColors = RAINBOW_PALETTES[0].colors; const baseColors = RAINBOW_PALETTES[0].colors;
while (nextColors.length < segments) { while (nextColors.length < segments) {
nextColors.push(baseColors[nextColors.length % baseColors.length]); nextColors.push(baseColors[nextColors.length % baseColors.length]);
} }
return nextColors.slice(0, segments); return nextColors.slice(0, segments);
}; };
const createEqualBoundaries = (minimum: number, maximum: number, segments: number) => {
if (minimum === maximum) {
return Array.from({ length: segments + 1 }, () => minimum);
}
return Array.from(
{ length: segments + 1 },
(_, index) => minimum + ((maximum - minimum) * index) / segments,
);
};
const normalizeCalculatedBoundaries = (
calculated: number[],
values: number[],
segments: number,
) => {
const finiteValues = values.filter(Number.isFinite).sort((a, b) => a - b);
if (finiteValues.length === 0) return [];
const minimum = finiteValues[0];
const maximum = finiteValues[finiteValues.length - 1];
if (minimum === maximum) return createEqualBoundaries(minimum, maximum, segments);
const candidates = Array.from(
new Set([minimum, ...calculated.filter(Number.isFinite), maximum]),
)
.filter((value) => value >= minimum && value <= maximum)
.sort((a, b) => a - b);
if (candidates.length === segments + 1) return candidates;
return createEqualBoundaries(minimum, maximum, segments);
};
export const resolveBoundaries = (
values: number[],
styleConfig: StyleConfig,
): number[] => {
const segments = Math.min(
MAX_CLASS_COUNT,
Math.max(MIN_CLASS_COUNT, Math.round(styleConfig.segments)),
);
if (styleConfig.classificationMethod === "custom_breaks") {
return [...(styleConfig.customBreaks || [])];
}
const finiteValues = values.filter(Number.isFinite);
if (finiteValues.length === 0) return [];
const calculated = calculateClassification(finiteValues, segments, "pretty_breaks");
return normalizeCalculatedBoundaries(calculated, finiteValues, segments);
};
export const getDefaultCustomBreaks = ({ export const getDefaultCustomBreaks = ({
segments, segments,
property, property,
@@ -64,261 +131,293 @@ export const getDefaultCustomBreaks = ({
currentJunctionCalData?: any[]; currentJunctionCalData?: any[];
currentPipeCalData?: any[]; currentPipeCalData?: any[];
}) => { }) => {
if (!layerId || !property) { let values: number[] = [];
return Array.from({ length: segments }, () => 0); if (layerId === "junctions" && property === "elevation" && elevationRange) {
values = elevationRange;
} else if (layerId === "pipes" && property === "diameter" && diameterRange) {
values = diameterRange;
} else if (layerId === "junctions") {
values = (currentJunctionCalData || []).map((item: any) => Number(item.value));
} else if (layerId === "pipes") {
values = (currentPipeCalData || []).map((item: any) => Number(item.value));
} }
const finiteValues = values.filter(Number.isFinite);
let dataArr: number[] = []; if (!property || finiteValues.length === 0) {
return Array.from({ length: segments + 1 }, (_, index) => index);
const isElevation = layerId === "junctions" && property === "elevation";
const isDiameter = layerId === "pipes" && property === "diameter";
if (isElevation && elevationRange) {
dataArr = [elevationRange[0], elevationRange[1]];
} else if (isDiameter && diameterRange) {
dataArr = [diameterRange[0], diameterRange[1]];
} else if (layerId === "junctions" && currentJunctionCalData) {
dataArr = currentJunctionCalData.map((d: any) => d.value);
} else if (layerId === "pipes" && currentPipeCalData) {
dataArr = currentPipeCalData.map((d: any) => d.value);
} }
const minimum = Math.min(...finiteValues);
if (dataArr.length === 0) { const maximum = Math.max(...finiteValues);
return Array.from({ length: segments }, () => 0); if (minimum === maximum) {
const padding = Math.max(Math.abs(minimum) * 0.01, 1);
return createEqualBoundaries(minimum - padding, maximum + padding, segments);
} }
return normalizeCalculatedBoundaries(
const defaultBreaks = calculateClassification( calculateClassification(finiteValues, segments, "pretty_breaks"),
dataArr, finiteValues,
segments, segments,
"pretty_breaks"
).slice(0, segments);
while (defaultBreaks.length < segments) {
defaultBreaks.push(defaultBreaks[defaultBreaks.length - 1] ?? 0);
}
return defaultBreaks;
};
export const normalizeCustomBreaks = (breaks: number[], desired: number) => {
const nextBreaks = [...breaks]
.slice(0, desired)
.filter((value) => value >= 0)
.sort((a, b) => a - b);
while (nextBreaks.length < desired) {
nextBreaks.push(nextBreaks[nextBreaks.length - 1] ?? 0);
}
return nextBreaks;
};
export const addBreakExtrema = (breaks: number[], dataValues: number[]) => {
const nextBreaks = [...breaks];
const minValue = Math.max(
dataValues.reduce((min, value) => Math.min(min, value), Infinity),
0
); );
const maxValue = dataValues.reduce(
(max, value) => Math.max(max, value),
-Infinity
);
if (!nextBreaks.includes(minValue)) {
nextBreaks.push(minValue);
}
if (!nextBreaks.includes(maxValue)) {
nextBreaks.push(maxValue);
}
nextBreaks.sort((a, b) => a - b);
return nextBreaks;
}; };
export const normalizeCustomBreaks = (breaks: number[], segments: number) => {
const finite = breaks.filter(Number.isFinite).slice(0, segments + 1);
if (finite.length === segments + 1) return finite;
if (finite.length >= 2) {
return createEqualBoundaries(finite[0], finite[finite.length - 1], segments);
}
return Array.from({ length: segments + 1 }, (_, index) => finite[0] ?? index);
};
export const validateStyleConfig = (styleConfig: StyleConfig): StyleValidationResult => {
const errors: string[] = [];
if (!styleConfig.property) errors.push("请选择分级属性");
if (
!Number.isInteger(styleConfig.segments) ||
styleConfig.segments < MIN_CLASS_COUNT ||
styleConfig.segments > MAX_CLASS_COUNT
) {
errors.push(`分类数量必须是 ${MIN_CLASS_COUNT}-${MAX_CLASS_COUNT} 的整数`);
}
if (styleConfig.classificationMethod === "custom_breaks") {
const boundaries = styleConfig.customBreaks || [];
if (boundaries.length !== styleConfig.segments + 1) {
errors.push(`需要 ${styleConfig.segments + 1} 个区间边界`);
} else if (boundaries.some((value) => !Number.isFinite(value))) {
errors.push("区间边界必须是有限数字");
} else if (boundaries.some((value, index) => index > 0 && value <= boundaries[index - 1])) {
errors.push("区间边界必须严格递增");
}
}
if (styleConfig.colorType === "custom") {
const colors = styleConfig.customColors || [];
if (colors.length !== styleConfig.segments) {
errors.push(`需要 ${styleConfig.segments} 个自定义颜色`);
} else if (colors.some((color) => {
try {
parseColor(color);
return false;
} catch {
return true;
}
})) {
errors.push("自定义颜色格式无效");
}
}
if (!Number.isFinite(styleConfig.opacity) || styleConfig.opacity < 0 || styleConfig.opacity > 1) {
errors.push("透明度必须在 0 到 1 之间");
}
const paletteIndexes: Array<[number, number]> = [
[styleConfig.singlePaletteIndex, SINGLE_COLOR_PALETTES.length],
[styleConfig.gradientPaletteIndex, GRADIENT_PALETTES.length],
[styleConfig.rainbowPaletteIndex, RAINBOW_PALETTES.length],
];
if (
paletteIndexes.some(
([index, length]) => !Number.isInteger(index) || index < 0 || index >= length,
)
) {
errors.push("色板索引无效");
}
if (
[
styleConfig.minSize,
styleConfig.maxSize,
styleConfig.minStrokeWidth,
styleConfig.maxStrokeWidth,
styleConfig.fixedStrokeWidth,
].some((value) => !Number.isFinite(value) || value <= 0)
) {
errors.push("符号尺寸必须大于 0");
}
if (styleConfig.minSize > styleConfig.maxSize) errors.push("节点最小尺寸不能大于最大尺寸");
if (styleConfig.minStrokeWidth > styleConfig.maxStrokeWidth) {
errors.push("管线最小宽度不能大于最大宽度");
}
return { valid: errors.length === 0, errors };
};
export const hydrateStoredLayerStyleStates = (
document: unknown,
expectedVersion: number,
): LayerStyleState[] => {
const defaults = createDefaultLayerStyleStates();
if (!document || typeof document !== "object") return defaults;
const storedDocument = document as {
version?: number;
layers?: Partial<Record<DefaultLayerStyleId, StyleConfig>>;
};
if (storedDocument.version !== expectedVersion || !storedDocument.layers) {
return defaults;
}
return defaults.map((state) => {
const stored = storedDocument.layers?.[state.layerId as DefaultLayerStyleId];
if (!stored || !validateStyleConfig(stored).valid) return state;
return {
...state,
styleConfig: {
...stored,
customBreaks: [...(stored.customBreaks || [])],
customColors: [...(stored.customColors || [])],
},
legendConfig: { ...state.legendConfig, property: stored.property },
isActive: false,
};
});
};
export const selectStoredLayerStyles = (states: LayerStyleState[]) =>
Object.fromEntries(
states
.filter((state) => state.layerId === "junctions" || state.layerId === "pipes")
.map((state) => [state.layerId, state.styleConfig]),
) as Partial<Record<DefaultLayerStyleId, StyleConfig>>;
export const requiresStyleApply = (
applied: StyleConfig | undefined,
draft: StyleConfig,
) =>
!applied ||
applied.property !== draft.property ||
applied.classificationMethod !== draft.classificationMethod ||
applied.segments !== draft.segments ||
(draft.classificationMethod === "custom_breaks" &&
!arraysEqual(applied.customBreaks, draft.customBreaks));
export const resolveStyleColors = ( export const resolveStyleColors = (
styleConfig: StyleConfig, styleConfig: StyleConfig,
breaksLength: number classCount = styleConfig.segments,
): string[] => { ): string[] => {
if (styleConfig.colorType === "single") { if (styleConfig.colorType === "single") {
return Array.from( const palette = SINGLE_COLOR_PALETTES[
{ length: breaksLength }, clampIndex(styleConfig.singlePaletteIndex, SINGLE_COLOR_PALETTES.length)
() => SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color ];
); return Array.from({ length: classCount }, () => palette.color);
} }
if (styleConfig.colorType === "gradient") { if (styleConfig.colorType === "gradient") {
const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex]; const palette = GRADIENT_PALETTES[
const startColor = parseColor(start); clampIndex(styleConfig.gradientPaletteIndex, GRADIENT_PALETTES.length)
const endColor = parseColor(end); ];
const start = parseColor(palette.start);
return Array.from({ length: breaksLength }, (_, index) => { const end = parseColor(palette.end);
const ratio = breaksLength > 1 ? index / (breaksLength - 1) : 1; return Array.from({ length: classCount }, (_, index) => {
const r = Math.round(startColor.r + (endColor.r - startColor.r) * ratio); const ratio = classCount > 1 ? index / (classCount - 1) : 0;
const g = Math.round(startColor.g + (endColor.g - startColor.g) * ratio); return `rgba(${Math.round(start.r + (end.r - start.r) * ratio)}, ${Math.round(
const b = Math.round(startColor.b + (endColor.b - startColor.b) * ratio); start.g + (end.g - start.g) * ratio,
return `rgba(${r}, ${g}, ${b}, 1)`; )}, ${Math.round(start.b + (end.b - start.b) * ratio)}, 1)`;
}); });
} }
if (styleConfig.colorType === "rainbow") { if (styleConfig.colorType === "rainbow") {
const baseColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors; const palette = RAINBOW_PALETTES[
return Array.from( clampIndex(styleConfig.rainbowPaletteIndex, RAINBOW_PALETTES.length)
{ length: breaksLength },
(_, index) => baseColors[index % baseColors.length]
);
}
const customColors = styleConfig.customColors || [];
const reverseRainbowColors = RAINBOW_PALETTES[1].colors;
const result = [...customColors];
while (result.length < breaksLength) {
result.push(
reverseRainbowColors[
(result.length - customColors.length) % reverseRainbowColors.length
]
);
}
return result.slice(0, breaksLength);
};
export const getSizePreviewColors = (styleConfig: StyleConfig) => {
if (styleConfig.colorType === "single") {
const color = SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color;
return [color, color];
}
if (styleConfig.colorType === "gradient") {
const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex];
return [start, end];
}
if (styleConfig.colorType === "rainbow") {
const rainbowColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors;
return [rainbowColors[0], rainbowColors[rainbowColors.length - 1]];
}
const customColors = styleConfig.customColors || [];
return [
customColors[0] || "rgba(0,0,0,1)",
customColors[customColors.length - 1] || "rgba(0,0,0,1)",
]; ];
return Array.from(
{ length: classCount },
(_, index) => palette.colors[index % palette.colors.length],
);
}
return getDefaultCustomColors(classCount, styleConfig.customColors || []);
}; };
export const resolveDimensions = ({ export const resolveDimensions = ({
layerType, layerType,
styleConfig, styleConfig,
breaksLength, classCount = styleConfig.segments,
}: { }: {
layerType: string; layerType: string;
styleConfig: StyleConfig; styleConfig: StyleConfig;
breaksLength: number; classCount?: number;
}) => { }) => {
const interpolate = (minimum: number, maximum: number, index: number) => {
const ratio = classCount > 1 ? index / (classCount - 1) : 0;
return minimum + (maximum - minimum) * ratio;
};
if (layerType === "linestring") { if (layerType === "linestring") {
if (styleConfig.adjustWidthByProperty) { return Array.from({ length: classCount }, (_, index) =>
return Array.from({ length: breaksLength }, (_, index) => { styleConfig.adjustWidthByProperty
const ratio = index / (breaksLength - 1); ? interpolate(styleConfig.minStrokeWidth, styleConfig.maxStrokeWidth, index)
return ( : styleConfig.fixedStrokeWidth,
styleConfig.minStrokeWidth +
(styleConfig.maxStrokeWidth - styleConfig.minStrokeWidth) * ratio
);
});
}
return Array.from(
{ length: breaksLength },
() => styleConfig.fixedStrokeWidth
); );
} }
return Array.from({ length: classCount }, (_, index) =>
return Array.from({ length: breaksLength }, (_, index) => { interpolate(styleConfig.minSize, styleConfig.maxSize, index),
const ratio = index / (breaksLength - 1); );
return styleConfig.minSize + (styleConfig.maxSize - styleConfig.minSize) * ratio;
});
}; };
export const buildDynamicStyle = ({ export const resolveLayerStyle = ({
layerType, layerType,
styleConfig, styleConfig,
breaks, values,
colors,
dimensions,
}: { }: {
layerType: string; layerType: string;
styleConfig: StyleConfig; styleConfig: StyleConfig;
breaks: number[]; values: number[];
colors: string[]; }): ResolvedLayerStyle | null => {
dimensions: number[]; const boundaries = resolveBoundaries(values, styleConfig);
if (boundaries.length !== styleConfig.segments + 1) return null;
const colors = resolveStyleColors(styleConfig, styleConfig.segments);
const dimensions = resolveDimensions({ layerType, styleConfig });
const isConstant = boundaries.every((value) => value === boundaries[0]);
const labels = isConstant
? [`${formatBoundary(boundaries[0])}`]
: Array.from(
{ length: styleConfig.segments },
(_, index) => `${formatBoundary(boundaries[index])} - ${formatBoundary(boundaries[index + 1])}`,
);
return { boundaries, colors, dimensions, labels, isConstant };
};
const valueExpression = (property: string): any[] => ["get", property];
const buildVariableCase = (property: string, classCount: number, variablePrefix: string) => {
const expression: any[] = ["case"];
for (let index = 0; index < classCount - 1; index += 1) {
expression.push(
["<=", valueExpression(property), ["var", `tj_break_${index + 1}`]],
["var", `${variablePrefix}_${index}`],
);
}
expression.push(["var", `${variablePrefix}_${classCount - 1}`]);
return expression;
};
export const buildDynamicStyleTemplate = ({
layerType,
property,
classCount,
}: {
layerType: string;
property: string;
classCount: number;
}): FlatStyleLike => { }): FlatStyleLike => {
const generateColorConditions = (property: string): any[] => { const color = buildVariableCase(property, classCount, "tj_color");
const conditions: any[] = ["case"]; const dimension = buildVariableCase(property, classCount, "tj_size");
for (let index = 1; index < breaks.length; index++) {
if (property === "unit_headloss") {
conditions.push([
"<=",
["/", ["get", "unit_headloss"], ["/", ["get", "length"], 1000]],
breaks[index],
]);
} else {
conditions.push(["<=", ["get", property], breaks[index]]);
}
const colorObj = parseColor(colors[index - 1]);
conditions.push(
`rgba(${colorObj.r}, ${colorObj.g}, ${colorObj.b}, ${styleConfig.opacity})`
);
}
const defaultColor = parseColor(colors[0]);
conditions.push(
`rgba(${defaultColor.r}, ${defaultColor.g}, ${defaultColor.b}, ${styleConfig.opacity})`
);
return conditions;
};
const generateDimensionConditions = (property: string): any[] => {
const conditions: any[] = ["case"];
for (let index = 0; index < breaks.length; index++) {
if (property === "unit_headloss") {
conditions.push([
"<=",
["/", ["get", "headloss"], ["get", "length"]],
breaks[index],
]);
} else {
conditions.push(["<=", ["get", property], breaks[index]]);
}
conditions.push(dimensions[index]);
}
conditions.push(dimensions[dimensions.length - 1]);
return conditions;
};
const generatePointDimensionConditions = (property: string): any[] => {
const conditions: any[] = ["case"];
for (let index = 0; index < breaks.length; index++) {
conditions.push(["<=", ["get", property], breaks[index]]);
conditions.push(["interpolate", ["linear"], ["zoom"], 12, 1, 24, dimensions[index]]);
}
conditions.push(dimensions[dimensions.length - 1]);
return conditions;
};
const dynamicStyle: FlatStyleLike = {};
if (layerType === "linestring") { if (layerType === "linestring") {
dynamicStyle["stroke-color"] = generateColorConditions(styleConfig.property); return { "stroke-color": color, "stroke-width": dimension };
dynamicStyle["stroke-width"] = generateDimensionConditions(styleConfig.property);
} else if (layerType === "point") {
dynamicStyle["circle-fill-color"] = generateColorConditions(styleConfig.property);
dynamicStyle["circle-radius"] = generatePointDimensionConditions(
styleConfig.property
);
dynamicStyle["circle-stroke-color"] = generateColorConditions(styleConfig.property);
dynamicStyle["circle-stroke-width"] = 2;
} }
return {
"circle-fill-color": color,
"circle-radius": ["interpolate", ["linear"], ["zoom"], 12, 1, 24, dimension],
"circle-stroke-color": color,
"circle-stroke-width": 2,
};
};
return dynamicStyle; export const buildStyleVariables = (
styleConfig: StyleConfig,
resolvedStyle: ResolvedLayerStyle,
): StyleVariables => {
const variables: StyleVariables = {};
resolvedStyle.boundaries.slice(1, -1).forEach((boundary, index) => {
variables[`tj_break_${index + 1}`] = boundary;
});
resolvedStyle.colors.forEach((color, index) => {
variables[`tj_color_${index}`] = withOpacity(color, styleConfig.opacity);
});
resolvedStyle.dimensions.forEach((dimension, index) => {
variables[`tj_size_${index}`] = dimension;
});
return variables;
}; };
export const buildContourDefinitions = ({ export const buildContourDefinitions = ({
@@ -329,20 +428,12 @@ export const buildContourDefinitions = ({
styleConfig: StyleConfig; styleConfig: StyleConfig;
breaks: number[]; breaks: number[];
colors: string[]; colors: string[];
}) => { }) =>
const contours = []; colors.map((color, index) => {
for (let index = 0; index < breaks.length - 1; index++) { const parsed = parseColor(color);
const colorObj = parseColor(colors[index]); return {
contours.push({
threshold: [breaks[index], breaks[index + 1]], threshold: [breaks[index], breaks[index + 1]],
color: [ color: [parsed.r, parsed.g, parsed.b, Math.round(styleConfig.opacity * 255)],
colorObj.r,
colorObj.g,
colorObj.b,
Math.round(styleConfig.opacity * 255),
],
strokeWidth: 0, strokeWidth: 0,
});
}
return contours;
}; };
});
@@ -0,0 +1,54 @@
import {
coerceTimelineDurationMinutes,
formatTimelineTime,
getRoundedCurrentTimelineMinutes,
getTimelineDisabledRangePercentages,
normalizeTimelineMinutes,
parseTimelineDurationMinutes,
} from "./timelineTime";
describe("timelineTime", () => {
it("normalizes invalid minutes before formatting", () => {
expect(formatTimelineTime(-1)).toBe("00:00");
expect(formatTimelineTime(Number.NaN)).toBe("00:00");
});
it("clamps minutes to the configured range", () => {
expect(normalizeTimelineMinutes(-1, 60, 120)).toBe(60);
expect(normalizeTimelineMinutes(180, 60, 120)).toBe(120);
});
it("rounds the current time down to the timeline step", () => {
expect(getRoundedCurrentTimelineMinutes(new Date("2026-07-16T10:29:00"))).toBe(615);
});
it("rounds the current time down to a custom timeline step", () => {
expect(
getRoundedCurrentTimelineMinutes(
new Date("2026-07-16T10:29:00"),
60,
),
).toBe(600);
});
it("parses EPANET-style duration values as minutes", () => {
expect(parseTimelineDurationMinutes("0:05")).toBe(5);
expect(parseTimelineDurationMinutes("1:00")).toBe(60);
expect(parseTimelineDurationMinutes("24:00")).toBe(1440);
expect(parseTimelineDurationMinutes("0:05:00")).toBe(5);
});
it("falls back for invalid, missing, and zero duration values", () => {
expect(coerceTimelineDurationMinutes("invalid", 1440)).toBe(1440);
expect(coerceTimelineDurationMinutes(undefined, 1440)).toBe(1440);
expect(coerceTimelineDurationMinutes("0:00", 1440)).toBe(1440);
});
it("calculates disabled range as full-day percentages", () => {
expect(getTimelineDisabledRangePercentages(360, 1080)).toEqual({
leftWidth: 25,
rightLeft: 75,
rightWidth: 25,
});
});
});
@@ -0,0 +1,94 @@
export const TIMELINE_MINUTES_PER_DAY = 1440;
export const TIMELINE_STEP_MINUTES = 15;
export const parseTimelineDurationMinutes = (
value: unknown,
): number | undefined => {
if (typeof value !== "string") {
return undefined;
}
const parts = value
.trim()
.split(":")
.map((part) => Number(part));
if (
(parts.length !== 2 && parts.length !== 3) ||
parts.some((part) => !Number.isFinite(part) || part < 0)
) {
return undefined;
}
const [hours, minutes, seconds = 0] = parts;
if (minutes >= 60 || seconds >= 60) {
return undefined;
}
return hours * 60 + minutes + Math.floor(seconds / 60);
};
export const coerceTimelineDurationMinutes = (
value: unknown,
fallbackMinutes: number,
) => {
const minutes = parseTimelineDurationMinutes(value);
return minutes && minutes > 0 ? minutes : fallbackMinutes;
};
export const normalizeTimelineMinutes = (
minutes: number | undefined,
minTime = 0,
maxTime = TIMELINE_MINUTES_PER_DAY,
) => {
if (typeof minutes !== "number" || !Number.isFinite(minutes)) {
return minTime;
}
return Math.min(Math.max(minutes, minTime), maxTime);
};
export const getRoundedCurrentTimelineMinutes = (
date = new Date(),
stepMinutes = TIMELINE_STEP_MINUTES,
maxMinutes = TIMELINE_MINUTES_PER_DAY,
) => {
const safeStep = stepMinutes > 0 ? stepMinutes : TIMELINE_STEP_MINUTES;
const minutes = date.getHours() * 60 + date.getMinutes();
return normalizeTimelineMinutes(
Math.floor(minutes / safeStep) * safeStep,
0,
maxMinutes,
);
};
export const formatTimelineTime = (
minutes: number | undefined,
minTime = 0,
maxTime = TIMELINE_MINUTES_PER_DAY,
) => {
const normalizedMinutes = normalizeTimelineMinutes(minutes, minTime, maxTime);
const hours = Math.floor(normalizedMinutes / 60);
const mins = normalizedMinutes % 60;
return `${hours.toString().padStart(2, "0")}:${mins
.toString()
.padStart(2, "0")}`;
};
export const getTimelineDisabledRangePercentages = (
minTime: number,
maxTime: number,
totalMinutes = TIMELINE_MINUTES_PER_DAY,
) => {
const safeTotalMinutes =
totalMinutes > 0 ? totalMinutes : TIMELINE_MINUTES_PER_DAY;
const rangeStart = normalizeTimelineMinutes(minTime, 0, safeTotalMinutes);
const rangeEnd = normalizeTimelineMinutes(maxTime, 0, safeTotalMinutes);
return {
leftWidth: (rangeStart / safeTotalMinutes) * 100,
rightLeft: (rangeEnd / safeTotalMinutes) * 100,
rightWidth:
((safeTotalMinutes - rangeEnd) / safeTotalMinutes) * 100,
};
};
@@ -16,7 +16,33 @@ type ToolbarTableProperty = {
rows: (string | number)[][]; rows: (string | number)[][];
}; };
export type ToolbarPropertyItem = ToolbarBaseProperty | ToolbarTableProperty; type ToolbarSelectProperty = {
type: "select";
label: string;
value: string;
options: { label: string; value: string }[];
placeholder?: string;
disabled?: boolean;
saving?: boolean;
onSave: (value: string) => Promise<void>;
};
type ToolbarTextProperty = {
type: "text";
label: string;
value: string;
placeholder?: string;
disabled?: boolean;
saving?: boolean;
helperText?: string;
onSave: (value: string) => Promise<void>;
};
export type ToolbarPropertyItem =
| ToolbarBaseProperty
| ToolbarTableProperty
| ToolbarSelectProperty
| ToolbarTextProperty;
export type ToolbarPropertyPanelData = { export type ToolbarPropertyPanelData = {
id?: string; id?: string;
@@ -24,6 +50,46 @@ export type ToolbarPropertyPanelData = {
properties?: ToolbarPropertyItem[]; properties?: ToolbarPropertyItem[];
}; };
export type ValveStatusPropertyOptions = {
value: string | null;
loading?: boolean;
saving?: boolean;
onSave: (value: string) => Promise<void>;
};
export type ValveSettingPropertyOptions = {
value: string | null;
vType: string | null;
loading?: boolean;
saving?: boolean;
status?: string | null;
onSave: (value: string) => Promise<void>;
};
const getValveSettingHelperText = (
vType: string | null,
status?: string | null,
) => {
if (status === "OPEN" || status === "CLOSED") {
return "开启/关闭状态下 EPANET 会忽略阀门设置值";
}
switch (vType?.toUpperCase()) {
case "PRV":
case "PSV":
case "PBV":
return "压力设置值,需为 0 或正数";
case "FCV":
return "流量设置值,需为 0 或正数";
case "TCV":
return "损失系数,需为 0 或正数";
case "GPV":
return "水头损失曲线 ID";
default:
return "阀门类型相关设置值";
}
};
const getFeatureHistoryType = (feature: Feature): string | null => { const getFeatureHistoryType = (feature: Feature): string | null => {
const layerId = feature.getId()?.toString().split(".")[0] || ""; const layerId = feature.getId()?.toString().split(".")[0] || "";
if (layerId.includes("pipe")) return "pipe"; if (layerId.includes("pipe")) return "pipe";
@@ -56,6 +122,8 @@ export const inferHistoryFeatureInfos = (
export const buildFeatureProperties = ( export const buildFeatureProperties = (
highlightFeature: Feature | undefined, highlightFeature: Feature | undefined,
computedProperties: Record<string, any>, computedProperties: Record<string, any>,
valveStatus?: ValveStatusPropertyOptions,
valveSetting?: ValveSettingPropertyOptions,
): ToolbarPropertyPanelData => { ): ToolbarPropertyPanelData => {
if (!highlightFeature) return {}; if (!highlightFeature) return {};
@@ -267,6 +335,12 @@ export const buildFeatureProperties = (
} }
if (layer === "geo_valves_mat" || layer === "geo_valves") { if (layer === "geo_valves_mat" || layer === "geo_valves") {
const valveType = valveSetting?.vType ?? properties.v_type;
const isValveSettingDisabled =
valveSetting?.loading ||
valveSetting?.status === "OPEN" ||
valveSetting?.status === "CLOSED";
return { return {
id: properties.id, id: properties.id,
type: "阀门", type: "阀门",
@@ -280,12 +354,47 @@ export const buildFeatureProperties = (
}, },
{ {
label: "阀门类型", label: "阀门类型",
value: properties.v_type, value: valveType,
}, },
{ {
label: "局部损失", label: "局部损失",
value: properties.minor_loss?.toFixed?.(2), value: properties.minor_loss?.toFixed?.(2),
}, },
...(valveStatus
? [
{
type: "select" as const,
label: "开关状态",
value: valveStatus.value ?? "",
options: [
{ label: "开启", value: "OPEN" },
{ label: "关闭", value: "CLOSED" },
{ label: "激活", value: "ACTIVE" },
],
placeholder: valveStatus.loading ? "加载中" : "未设置",
disabled: valveStatus.loading,
saving: valveStatus.saving,
onSave: valveStatus.onSave,
},
]
: []),
...(valveSetting
? [
{
type: "text" as const,
label: "阀门设置值",
value: valveSetting.value ?? "",
placeholder: valveSetting.loading ? "加载中" : "未设置",
disabled: isValveSettingDisabled,
saving: valveSetting.saving,
helperText: getValveSettingHelperText(
valveType,
valveSetting.status,
),
onSave: valveSetting.onSave,
},
]
: []),
], ],
}; };
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
import { renderHook, waitFor } from "@testing-library/react";
import { useTimelineTimeConfig, toTimelineTimeConfig } from "./useTimelineTimeConfig";
const apiFetch = jest.fn();
jest.mock("@/lib/apiFetch", () => ({
apiFetch: (...args: unknown[]) => apiFetch(...args),
}));
jest.mock("@/contexts/ProjectContext", () => ({
useProject: () => ({ networkName: "test-network" }),
}));
describe("useTimelineTimeConfig", () => {
beforeEach(() => {
apiFetch.mockReset();
});
it("derives duration and step from backend time properties", async () => {
apiFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
DURATION: "24:00",
"HYDRAULIC TIMESTEP": "1:00",
}),
});
const { result } = renderHook(() => useTimelineTimeConfig());
await waitFor(() => {
expect(result.current.stepMinutes).toBe(60);
});
expect(result.current.durationMinutes).toBe(1440);
expect(String(apiFetch.mock.calls[0][0])).toContain(
"/api/v1/gettimeproperties/?network=test-network",
);
});
it("falls back when fetching time properties fails", async () => {
apiFetch.mockRejectedValueOnce(new Error("network failure"));
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
const { result } = renderHook(() => useTimelineTimeConfig());
await waitFor(() => {
expect(apiFetch).toHaveBeenCalledTimes(1);
});
expect(result.current).toMatchObject({
durationMinutes: 1440,
stepMinutes: 15,
});
warnSpy.mockRestore();
});
it("parses wrapped times payloads", () => {
expect(
toTimelineTimeConfig({
times: {
DURATION: "24:00",
"HYDRAULIC TIMESTEP": "0:05",
},
}),
).toMatchObject({
durationMinutes: 1440,
stepMinutes: 5,
});
});
});
@@ -0,0 +1,105 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { config, NETWORK_NAME } from "@/config/config";
import { useProject } from "@/contexts/ProjectContext";
import { apiFetch } from "@/lib/apiFetch";
import {
coerceTimelineDurationMinutes,
TIMELINE_MINUTES_PER_DAY,
TIMELINE_STEP_MINUTES,
} from "./timelineTime";
export interface TimelineTimeConfig {
durationMinutes: number;
stepMinutes: number;
properties: Record<string, unknown>;
}
const DEFAULT_TIMELINE_TIME_CONFIG: TimelineTimeConfig = {
durationMinutes: TIMELINE_MINUTES_PER_DAY,
stepMinutes: TIMELINE_STEP_MINUTES,
properties: {},
};
const resolveTimeProperties = (data: unknown): Record<string, unknown> => {
if (!data || typeof data !== "object") {
return {};
}
const record = data as Record<string, unknown>;
if (record.times && typeof record.times === "object") {
return record.times as Record<string, unknown>;
}
return record;
};
export const toTimelineTimeConfig = (data: unknown): TimelineTimeConfig => {
const properties = resolveTimeProperties(data);
return {
durationMinutes: coerceTimelineDurationMinutes(
properties.DURATION,
TIMELINE_MINUTES_PER_DAY,
),
stepMinutes: coerceTimelineDurationMinutes(
properties["HYDRAULIC TIMESTEP"],
TIMELINE_STEP_MINUTES,
),
properties,
};
};
export const useTimelineTimeConfig = (): TimelineTimeConfig => {
const project = useProject();
const networkName = project?.networkName || NETWORK_NAME;
const [timeConfig, setTimeConfig] = useState<TimelineTimeConfig>(
DEFAULT_TIMELINE_TIME_CONFIG,
);
useEffect(() => {
if (!networkName) {
setTimeConfig(DEFAULT_TIMELINE_TIME_CONFIG);
return;
}
let isMounted = true;
const fetchTimeProperties = async () => {
try {
const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/gettimeproperties/?network=${encodeURIComponent(
networkName,
)}`,
);
if (!response.ok) {
throw new Error(`Failed to fetch time properties: ${response.status}`);
}
const data = await response.json();
if (isMounted) {
setTimeConfig(toTimelineTimeConfig(data));
}
} catch (error) {
console.warn("Failed to load timeline time properties:", error);
if (isMounted) {
setTimeConfig(DEFAULT_TIMELINE_TIME_CONFIG);
}
}
};
fetchTimeProperties();
return () => {
isMounted = false;
};
}, [networkName]);
return useMemo(
() => ({
durationMinutes: timeConfig.durationMinutes,
stepMinutes: timeConfig.stepMinutes,
properties: timeConfig.properties,
}),
[timeConfig],
);
};
+301 -309
View File
@@ -10,7 +10,7 @@ import React, {
useCallback, useCallback,
useRef, useRef,
} from "react"; } from "react";
import { Map as OlMap, VectorTile } from "ol"; import { Map as OlMap } from "ol";
import View from "ol/View.js"; import View from "ol/View.js";
import "ol/ol.css"; import "ol/ol.css";
import MapTools from "./MapTools"; import MapTools from "./MapTools";
@@ -24,18 +24,54 @@ 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 { ContourLayer } from "deck.gl"; import { ContourLayer } from "deck.gl";
import { toM3h } from "@utils/units"; import { isLpsFlowProperty, toM3h } from "@utils/units";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { import {
cleanupTransientMapResources, cleanupTransientMapResources,
disposeMapResources, disposeMapResources,
markMapResourcePersistent, markMapResourcePersistent,
} from "./mapLifecycle"; } from "./mapLifecycle";
import { createOperationalMapResources } from "./operationalLayers"; import {
createOperationalMapResources,
createOperationalMapSources,
} from "./operationalLayers";
import { getRoundedCurrentTimelineMinutes } from "./Controls/timelineTime";
import { useTimelineTimeConfig } from "./Controls/useTimelineTimeConfig";
import {
TileFeatureIndex,
clipLineStringPartsToExtent,
coordinatesToLonLat,
lineStringFromFlatCoordinates,
type TileFeatureInstance,
} from "./tileFeatureIndex";
interface MapComponentProps { interface MapComponentProps {
children?: React.ReactNode; children?: React.ReactNode;
} }
const COMPARE_OPEN_START_MARK = "tjwater:compare-open-start";
const COMPARE_OPEN_READY_MARK = "tjwater:compare-open-ready";
const COMPARE_OPEN_MEASURE = "tjwater:compare-open";
const markCompareOpenStart = () => {
performance.clearMarks(COMPARE_OPEN_START_MARK);
performance.clearMarks(COMPARE_OPEN_READY_MARK);
performance.clearMeasures(COMPARE_OPEN_MEASURE);
performance.mark(COMPARE_OPEN_START_MARK);
};
const markCompareOpenReady = () => {
if (performance.getEntriesByName(COMPARE_OPEN_START_MARK).length === 0) {
return;
}
performance.mark(COMPARE_OPEN_READY_MARK);
performance.measure(
COMPARE_OPEN_MEASURE,
COMPARE_OPEN_START_MARK,
COMPARE_OPEN_READY_MARK,
);
};
interface DataContextType { interface DataContextType {
currentTime?: number; // 当前时间 currentTime?: number; // 当前时间
setCurrentTime?: React.Dispatch<React.SetStateAction<number>>; setCurrentTime?: React.Dispatch<React.SetStateAction<number>>;
@@ -120,6 +156,57 @@ function debounce<F extends (...args: any[]) => any>(
return debounced; return debounced;
} }
const indexCalculationRecords = (records: any[]) =>
new Map(records.map((record) => [String(record.ID), record]));
const mergeJunctionValues = (
features: any[],
records: any[],
property: string,
) => {
const recordsById = indexCalculationRecords(records);
return features.map((feature) => {
const record = recordsById.get(String(feature.id));
if (!record) return feature;
const value = isLpsFlowProperty(property)
? toM3h(record.value, "lps")
: record.value;
return { ...feature, [property]: value };
});
};
const mergePipeValues = (
features: any[],
records: any[],
property: string,
) => {
const recordsById = indexCalculationRecords(records);
const isFlow = property === "flow";
return features.map((feature) => {
const record = recordsById.get(String(feature.id));
if (!record) return feature;
const value = isFlow ? toM3h(record.value, "lps") : record.value;
const reverseFlow = isFlow && record.value < 0;
return {
...feature,
[property]: isFlow ? Math.abs(value) : value,
flowFlag: reverseFlow ? -1 : 1,
path: reverseFlow ? [...feature.path].reverse() : feature.path,
};
});
};
const getNumericRange = (values: number[]): [number, number] | undefined => {
let min = Infinity;
let max = -Infinity;
values.forEach((value) => {
if (!Number.isFinite(value)) return;
min = Math.min(min, value);
max = Math.max(max, value);
});
return min === Infinity ? undefined : [min, max];
};
export const useMap = () => { export const useMap = () => {
return useContext(MapContext); return useContext(MapContext);
}; };
@@ -139,6 +226,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
]; ];
const MAP_URL = config.MAP_URL; const MAP_URL = config.MAP_URL;
const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key
const { durationMinutes, stepMinutes } = useTimelineTimeConfig();
const mapRef = useRef<HTMLDivElement | null>(null); const mapRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -148,14 +236,15 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const compareDeckLayerRef = useRef<DeckLayer | null>(null); const compareDeckLayerRef = useRef<DeckLayer | null>(null);
const isDisposingRef = useRef(false); const isDisposingRef = useRef(false);
const isCompareDisposingRef = useRef(false); const isCompareDisposingRef = useRef(false);
const pendingTimeoutsRef = useRef<number[]>([]);
const [map, setMap] = useState<OlMap>(); const [map, setMap] = useState<OlMap>();
const [deckLayer, setDeckLayer] = useState<DeckLayer>(); const [deckLayer, setDeckLayer] = useState<DeckLayer>();
const [compareMap, setCompareMap] = useState<OlMap>(); const [compareMap, setCompareMap] = useState<OlMap>();
const [compareDeckLayer, setCompareDeckLayer] = useState<DeckLayer>(); const [compareDeckLayer, setCompareDeckLayer] = useState<DeckLayer>();
// currentCalData 用于存储当前计算结果 // currentCalData 用于存储当前计算结果
const [currentTime, setCurrentTime] = useState<number>(-1); // 默认选择当前时间 const [currentTime, setCurrentTime] = useState<number>(
getRoundedCurrentTimelineMinutes,
); // 默认选择当前时间
// const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17")); // const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17"));
const [selectedDate, setSelectedDate] = useState<Date>(new Date()); // 默认今天 const [selectedDate, setSelectedDate] = useState<Date>(new Date()); // 默认今天
const [schemeName, setSchemeName] = useState<string>(""); // 当前方案名称 const [schemeName, setSchemeName] = useState<string>(""); // 当前方案名称
@@ -169,14 +258,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
); );
const [comparePipeCalData, setComparePipeCalData] = useState<any[]>([]); const [comparePipeCalData, setComparePipeCalData] = useState<any[]>([]);
const [isCompareMode, setCompareMode] = useState(false); const [isCompareMode, setCompareMode] = useState(false);
// junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染 // junctionData 为当前层级和视口内按 ID 去重的节点数据;pipeData 为管道标签数据。
// currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值 // pipeFragments 保留当前层级和视口内每个瓦片管道片段,水流动画按 instanceKey 渲染,不能按业务 ID 去重。
const [junctionData, setJunctionDataState] = useState<any[]>([]); const [junctionData, setJunctionDataState] = useState<any[]>([]);
const [pipeData, setPipeDataState] = useState<any[]>([]); const [pipeData, setPipeDataState] = useState<any[]>([]);
const junctionDataIds = useRef(new Set<string>()); const [pipeFragments, setPipeFragments] = useState<any[]>([]);
const pipeDataIds = useRef(new Set<string>()); const junctionIndexRef = useRef<TileFeatureIndex | null>(null);
const tileJunctionDataBuffer = useRef<any[]>([]); const pipeIndexRef = useRef<TileFeatureIndex | null>(null);
const tilePipeDataBuffer = useRef<any[]>([]);
const [showJunctionTextLayer, setShowJunctionTextLayer] = useState(false); // 控制节点文本图层显示 const [showJunctionTextLayer, setShowJunctionTextLayer] = useState(false); // 控制节点文本图层显示
const [showPipeTextLayer, setShowPipeTextLayer] = useState(false); // 控制管道文本图层显示 const [showPipeTextLayer, setShowPipeTextLayer] = useState(false); // 控制管道文本图层显示
@@ -186,7 +274,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const [junctionText, setJunctionText] = useState("pressure"); const [junctionText, setJunctionText] = useState("pressure");
const [pipeText, setPipeText] = useState("velocity"); const [pipeText, setPipeText] = useState("velocity");
const [contours, setContours] = useState<any[]>([]); const [contours, setContours] = useState<any[]>([]);
const flowAnimation = useRef(false); // 添加动画控制标志
const [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示 const [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示
const [isWaterflowLayerAvailable, setWaterflowLayerAvailable] = const [isWaterflowLayerAvailable, setWaterflowLayerAvailable] =
useState(false); // 控制等高线图层显示 useState(false); // 控制等高线图层显示
@@ -194,68 +281,40 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const [currentZoom, setCurrentZoom] = useState(11); // 当前缩放级别 const [currentZoom, setCurrentZoom] = useState(11); // 当前缩放级别
// 实时合并计算结果到基础地理数据中 // 实时合并计算结果到基础地理数据中
const mergedJunctionData = useMemo(() => { const mergedJunctionData = useMemo(
const nodeMap = new Map(currentJunctionCalData.map((r: any) => [r.ID, r])); () =>
return junctionData.map((j) => { mergeJunctionValues(junctionData, currentJunctionCalData, junctionText),
const record = nodeMap.get(j.id); [junctionData, currentJunctionCalData, junctionText],
let val = record ? record.value : undefined; );
// 在这合并时将实际需水量从 LPS 转换为大写表示 const mergedPipeData = useMemo(
if (val !== undefined && junctionText === "actualdemand") { () => mergePipeValues(pipeData, currentPipeCalData, pipeText),
val = toM3h(val, "lps"); [pipeData, currentPipeCalData, pipeText],
} );
return record ? { ...j, [junctionText]: val } : j; const mergedPipeFragments = useMemo(
}); () => mergePipeValues(pipeFragments, currentPipeCalData, pipeText),
}, [junctionData, currentJunctionCalData, junctionText]); [pipeFragments, currentPipeCalData, pipeText],
);
const mergedPipeData = useMemo(() => { const mergedCompareJunctionData = useMemo(
const linkMap = new Map(currentPipeCalData.map((r: any) => [r.ID, r])); () =>
return pipeData.map((p) => { isCompareMode
const record = linkMap.get(p.id); ? mergeJunctionValues(junctionData, compareJunctionCalData, junctionText)
if (!record) return p; : [],
const isFlow = pipeText === "flow"; [isCompareMode, junctionData, compareJunctionCalData, junctionText],
let val = record.value; );
if (val !== undefined && isFlow) { const mergedComparePipeData = useMemo(
val = toM3h(val, "lps"); () =>
} isCompareMode
return { ? mergePipeValues(pipeData, comparePipeCalData, pipeText)
...p, : [],
[pipeText]: isFlow ? Math.abs(val) : val, [isCompareMode, pipeData, comparePipeCalData, pipeText],
flowFlag: isFlow && record.value < 0 ? -1 : 1, );
path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path, const mergedComparePipeFragments = useMemo(
}; () =>
}); isCompareMode
}, [pipeData, currentPipeCalData, pipeText]); ? mergePipeValues(pipeFragments, comparePipeCalData, pipeText)
: [],
const mergedCompareJunctionData = useMemo(() => { [isCompareMode, pipeFragments, comparePipeCalData, pipeText],
const nodeMap = new Map(compareJunctionCalData.map((r: any) => [r.ID, r])); );
return junctionData.map((j) => {
const record = nodeMap.get(j.id);
let val = record ? record.value : undefined;
if (val !== undefined && junctionText === "actualdemand") {
val = toM3h(val, "lps");
}
return record ? { ...j, [junctionText]: val } : j;
});
}, [junctionData, compareJunctionCalData, junctionText]);
const mergedComparePipeData = useMemo(() => {
const linkMap = new Map(comparePipeCalData.map((r: any) => [r.ID, r]));
return pipeData.map((p) => {
const record = linkMap.get(p.id);
if (!record) return p;
const isFlow = pipeText === "flow";
let val = record.value;
if (val !== undefined && isFlow) {
val = toM3h(val, "lps");
}
return {
...p,
[pipeText]: isFlow ? Math.abs(val) : val,
flowFlag: isFlow && record.value < 0 ? -1 : 1,
path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path,
};
});
}, [pipeData, comparePipeCalData, pipeText]);
const [diameterRange, setDiameterRange] = useState< const [diameterRange, setDiameterRange] = useState<
[number, number] | undefined [number, number] | undefined
@@ -267,7 +326,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
useState(0); useState(0);
const toggleCompareMode = useCallback(() => { const toggleCompareMode = useCallback(() => {
setCompareMode((prev) => !prev); setCompareMode((prev) => {
if (!prev) markCompareOpenStart();
return !prev;
});
}, []); }, []);
const maps = useMemo( const maps = useMemo(
@@ -284,91 +346,126 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
[compareDeckLayer, deckLayer, isCompareMode], [compareDeckLayer, deckLayer, isCompareMode],
); );
const setJunctionData = (newData: any[]) => { const buildPipeFragments = useCallback((instance: TileFeatureInstance) => {
const uniqueNewData = newData.filter((item) => { const tileCoordinates = lineStringFromFlatCoordinates(
if (!item || !item.id) return false; instance.flatCoordinates,
if (!junctionDataIds.current.has(item.id)) { instance.stride,
junctionDataIds.current.add(item.id); );
return true; const clippedParts = clipLineStringPartsToExtent(
} tileCoordinates,
return false; instance.tileExtent,
}); );
if (uniqueNewData.length > 0) { return clippedParts.flatMap((clippedCoordinates, partIndex) => {
setJunctionDataState((prev) => prev.concat(uniqueNewData)); const path = coordinatesToLonLat(clippedCoordinates);
setElevationRange((prev) => { const lineStringFeature = lineString(path);
const elevations = uniqueNewData const fragmentLength = length(lineStringFeature);
.map((d) => d.elevation) if (fragmentLength <= 0) return [];
.filter((v) => typeof v === "number");
if (elevations.length === 0) return prev;
let newMin = elevations[0]; const timestamps = [0];
let newMax = elevations[0]; let cumulativeLength = 0;
for (let i = 1; i < elevations.length; i++) { for (let i = 1; i < path.length; i += 1) {
if (elevations[i] < newMin) newMin = elevations[i]; cumulativeLength += length(lineString([path[i - 1], path[i]]));
if (elevations[i] > newMax) newMax = elevations[i]; timestamps.push((cumulativeLength / fragmentLength) * 10);
} }
if (!prev) { const midPoint = along(lineStringFeature, fragmentLength / 2).geometry
return [newMin, newMax]; .coordinates;
const prevPoint = along(lineStringFeature, fragmentLength * 0.49).geometry
.coordinates;
const nextPoint = along(lineStringFeature, fragmentLength * 0.51).geometry
.coordinates;
let lineAngle = bearing(prevPoint, nextPoint);
lineAngle = -lineAngle + 90;
if (lineAngle < -90 || lineAngle > 90) {
lineAngle += 180;
} }
return [Math.min(prev[0], newMin), Math.max(prev[1], newMax)];
return [
{
instanceKey: `${instance.instanceKey}/${partIndex}`,
id: instance.featureId,
diameter: instance.properties.diameter || 0,
length: instance.properties.length || fragmentLength * 1000,
path,
position: midPoint,
angle: lineAngle,
timestamps,
fragmentLength,
},
];
}); });
} }, []);
const publishActiveTileSnapshot = useCallback(
(targetMap: OlMap) => {
const zoom = targetMap.getView().getZoom() ?? 0;
const junctionSnapshot = junctionIndexRef.current?.getSnapshot(
targetMap,
zoom,
);
const pipeSnapshot = pipeIndexRef.current?.getSnapshot(targetMap, zoom);
const nextJunctionData = Array.from(
junctionSnapshot?.instancesById.values() ?? [],
)
.map((instances) => instances[0])
.filter(Boolean)
.map((instance) => {
const [x, y] = lineStringFromFlatCoordinates(
instance.flatCoordinates,
instance.stride,
)[0];
return {
id: instance.featureId,
instanceKey: instance.instanceKey,
position: toLonLat([x, y]),
elevation: instance.properties.elevation || 0,
demand: instance.properties.demand || 0,
}; };
})
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
const setPipeData = (newData: any[]) => { const nextPipeFragments = (pipeSnapshot?.instances ?? [])
const uniqueNewData = newData.filter((item) => { .filter((instance) => instance.geometryType.includes("Line"))
if (!item || !item.id) return false; .flatMap(buildPipeFragments)
if (!pipeDataIds.current.has(item.id)) { .sort((a, b) => a.instanceKey.localeCompare(b.instanceKey));
pipeDataIds.current.add(item.id);
return true; const labelById = new Map<string, any>();
nextPipeFragments.forEach((fragment) => {
const previous = labelById.get(fragment.id);
if (
!previous ||
fragment.fragmentLength > previous.fragmentLength ||
(fragment.fragmentLength === previous.fragmentLength &&
fragment.instanceKey.localeCompare(previous.instanceKey) < 0)
) {
labelById.set(fragment.id, fragment);
} }
return false;
}); });
if (uniqueNewData.length > 0) { const nextPipeLabels = Array.from(labelById.values()).sort((a, b) =>
setPipeDataState((prev) => prev.concat(uniqueNewData)); String(a.id).localeCompare(String(b.id)),
setDiameterRange((prev) => {
const diameters = uniqueNewData
.map((d) => d.diameter)
.filter((v) => typeof v === "number");
if (diameters.length === 0) return prev;
let newMin = diameters[0];
let newMax = diameters[0];
for (let i = 1; i < diameters.length; i++) {
if (diameters[i] < newMin) newMin = diameters[i];
if (diameters[i] > newMax) newMax = diameters[i];
}
if (!prev) {
return [newMin, newMax];
}
return [Math.min(prev[0], newMin), Math.max(prev[1], newMax)];
});
}
};
const debouncedUpdateDataRef = useRef<DebouncedFunction<() => void> | null>(
null,
); );
useEffect(() => { setJunctionDataState(nextJunctionData);
debouncedUpdateDataRef.current = debounce(() => { setPipeFragments(nextPipeFragments);
if (tileJunctionDataBuffer.current.length > 0) { setPipeDataState(nextPipeLabels);
setJunctionData(tileJunctionDataBuffer.current); setElevationRange(
tileJunctionDataBuffer.current = []; getNumericRange(nextJunctionData.map((item) => item.elevation)),
} );
if (tilePipeDataBuffer.current.length > 0) { setDiameterRange(
setPipeData(tilePipeDataBuffer.current); getNumericRange(nextPipeLabels.map((item) => item.diameter)),
tilePipeDataBuffer.current = []; );
} },
}, 100); [buildPipeFragments],
);
return () => { const operationalSources = useMemo(
debouncedUpdateDataRef.current?.cancel(); () =>
debouncedUpdateDataRef.current = null; createOperationalMapSources({
}; mapUrl: MAP_URL,
}, []); workspace: MAP_WORKSPACE,
}),
[MAP_URL, MAP_WORKSPACE],
);
const operationalResources = useMemo( const operationalResources = useMemo(
() => () =>
createOperationalMapResources({ createOperationalMapResources({
@@ -376,8 +473,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
workspace: MAP_WORKSPACE, workspace: MAP_WORKSPACE,
extent: MAP_EXTENT, extent: MAP_EXTENT,
persistent: true, persistent: true,
sources: operationalSources,
}), }),
[MAP_URL, MAP_WORKSPACE, MAP_EXTENT], [MAP_URL, MAP_WORKSPACE, MAP_EXTENT, operationalSources],
); );
const { junctions: junctionSource, pipes: pipeSource } = const { junctions: junctionSource, pipes: pipeSource } =
operationalResources.sources; operationalResources.sources;
@@ -391,60 +489,15 @@ 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) => { junctionIndexRef.current = new TileFeatureIndex("junctions", junctionSource);
const timerId = window.setTimeout(() => { pipeIndexRef.current = new TileFeatureIndex("pipes", pipeSource);
pendingTimeoutsRef.current = pendingTimeoutsRef.current.filter(
(id) => id !== timerId,
);
if (isDisposingRef.current) return;
callback();
}, delay);
pendingTimeoutsRef.current.push(timerId);
return timerId;
};
const clearPendingTimeouts = () => {
pendingTimeoutsRef.current.forEach((id) => clearTimeout(id));
pendingTimeoutsRef.current = [];
};
// 缓存 junction、pipe 数据,提供给 deck.gl 提供坐标供标签显示
const handleJunctionTileLoadEnd = (event: any) => { const handleJunctionTileLoadEnd = (event: any) => {
if (isDisposingRef.current) return; if (isDisposingRef.current) return;
try { try {
if (event.tile instanceof VectorTile) { junctionIndexRef.current?.registerTile(event.tile);
const renderFeatures = event.tile.getFeatures(); scheduleActiveTileSnapshot();
const data = new Map();
renderFeatures.forEach((renderFeature: any) => {
const props = renderFeature.getProperties();
const featureId = props.id;
if (featureId && !junctionDataIds.current.has(featureId)) {
const geometry = renderFeature.getGeometry();
if (geometry) {
const coordinates = geometry.getFlatCoordinates();
const coordWGS84 = toLonLat(coordinates);
data.set(featureId, {
id: featureId,
position: coordWGS84,
elevation: props.elevation || 0,
demand: props.demand || 0,
});
}
}
});
const uniqueData = Array.from(data.values());
if (uniqueData.length > 0) {
uniqueData.forEach((item) =>
tileJunctionDataBuffer.current.push(item),
);
debouncedUpdateDataRef.current?.();
}
}
} catch (error) { } catch (error) {
console.error("Junction tile load error:", error); console.error("Junction tile load error:", error);
} }
@@ -452,86 +505,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const handlePipeTileLoadEnd = (event: any) => { const handlePipeTileLoadEnd = (event: any) => {
if (isDisposingRef.current) return; if (isDisposingRef.current) return;
try { try {
if (event.tile instanceof VectorTile) { pipeIndexRef.current?.registerTile(event.tile);
const renderFeatures = event.tile.getFeatures(); scheduleActiveTileSnapshot();
const data = new Map();
renderFeatures.forEach((renderFeature: any) => {
try {
const props = renderFeature.getProperties();
const featureId = props.id;
if (featureId && !pipeDataIds.current.has(featureId)) {
const geometry = renderFeature.getGeometry();
if (geometry) {
const flatCoordinates = geometry.getFlatCoordinates();
const stride = geometry.getStride(); // 获取步长,通常为 2
// 重建为 LineString GeoJSON 格式的 coordinates: [[x1, y1], [x2, y2], ...]
const lineCoords = [];
for (let i = 0; i < flatCoordinates.length; i += stride) {
lineCoords.push([
flatCoordinates[i],
flatCoordinates[i + 1],
]);
}
const lineCoordsWGS84 = lineCoords.map((coord) => {
const [lon, lat] = toLonLat(coord);
return [lon, lat];
});
// 添加验证:确保至少有 2 个坐标点
if (lineCoordsWGS84.length < 2) return; // 跳过此特征
// 计算中点
const lineStringFeature = lineString(lineCoordsWGS84);
const lineLength = length(lineStringFeature);
const midPoint = along(lineStringFeature, lineLength / 2)
.geometry.coordinates;
// 计算角度
const prevPoint = along(lineStringFeature, lineLength * 0.49)
.geometry.coordinates;
const nextPoint = along(lineStringFeature, lineLength * 0.51)
.geometry.coordinates;
let lineAngle = bearing(prevPoint, nextPoint);
lineAngle = -lineAngle + 90;
if (lineAngle < -90 || lineAngle > 90) {
lineAngle += 180;
}
// 计算时间戳(可选)
const numSegments = lineCoordsWGS84.length - 1;
const timestamps = [0];
if (numSegments > 0) {
for (let i = 1; i <= numSegments; i++) {
timestamps.push((i / numSegments) * 10);
}
}
data.set(featureId, {
id: featureId,
diameter: props.diameter || 0,
length: props.length || 0,
path: lineCoordsWGS84, // 使用重建后的坐标
position: midPoint,
angle: lineAngle,
timestamps,
});
}
}
} catch (geomError) {
console.error("Geometry calculation error:", geomError);
}
});
const uniqueData = Array.from(data.values());
if (uniqueData.length > 0) {
uniqueData.forEach((item) => tilePipeDataBuffer.current.push(item));
debouncedUpdateDataRef.current?.();
}
}
} catch (error) { } catch (error) {
console.error("Pipe tile load error:", error); console.error("Pipe tile load error:", error);
} }
}; };
junctionSource.on("tileloadend", handleJunctionTileLoadEnd);
pipeSource.on("tileloadend", handlePipeTileLoadEnd);
// 监听 junctionsLayer 的 visible 变化 // 监听 junctionsLayer 的 visible 变化
const handleJunctionVisibilityChange = () => { const handleJunctionVisibilityChange = () => {
const isVisible = junctionsLayer.getVisible(); const isVisible = junctionsLayer.getVisible();
@@ -555,6 +534,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
layers: operationalResources.orderedLayers.slice(), layers: operationalResources.orderedLayers.slice(),
controls: [], controls: [],
}); });
const scheduleActiveTileSnapshot = debounce(
() => publishActiveTileSnapshot(map),
50,
);
junctionSource.on("tileloadend", handleJunctionTileLoadEnd);
pipeSource.on("tileloadend", handlePipeTileLoadEnd);
map.getInteractions().forEach(markMapResourcePersistent); map.getInteractions().forEach(markMapResourcePersistent);
setMap(map); setMap(map);
@@ -592,14 +577,18 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
duration: 1000, duration: 1000,
}); });
} }
// 持久化视图(中心点 + 缩放),防抖写入 localStorage // 视图稳定后同步 Deck 数据并持久化,避免移动过程中重复扫描瓦片。
const persistView = debounce(() => { const handleViewChange = debounce(() => {
if (isDisposingRef.current) return; if (isDisposingRef.current) return;
try {
const view = map.getView(); const view = map.getView();
const zoom = view.getZoom() || 0;
setCurrentZoom(zoom);
junctionIndexRef.current?.scanLoadedTiles();
pipeIndexRef.current?.scanLoadedTiles();
scheduleActiveTileSnapshot();
try {
const center = view.getCenter(); const center = view.getCenter();
const zoom = view.getZoom(); if (center) {
if (center && typeof zoom === "number") {
localStorage.setItem( localStorage.setItem(
MAP_VIEW_STORAGE_KEY, MAP_VIEW_STORAGE_KEY,
JSON.stringify({ center, zoom }), JSON.stringify({ center, zoom }),
@@ -609,21 +598,16 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
console.warn("Save map view failed", err); console.warn("Save map view failed", err);
} }
}, 250); }, 250);
// 监听缩放变化并持久化,同时更新 currentZoom
const handleViewChange = () => {
addTimeout(() => {
const zoom = map.getView().getZoom() || 0;
setCurrentZoom(zoom);
persistView();
}, 250);
};
map.getView().on("change", handleViewChange); map.getView().on("change", handleViewChange);
// 初始化当前缩放级别并强制触发瓦片加载 // 初始化当前缩放级别并强制触发瓦片加载
addTimeout(() => { const initializeTimer = window.setTimeout(() => {
if (isDisposingRef.current) return;
const initialZoom = map.getView().getZoom() || 11; const initialZoom = map.getView().getZoom() || 11;
setCurrentZoom(initialZoom); setCurrentZoom(initialZoom);
junctionIndexRef.current?.scanLoadedTiles();
pipeIndexRef.current?.scanLoadedTiles();
scheduleActiveTileSnapshot();
// 强制触发地图渲染,让瓦片加载事件触发 // 强制触发地图渲染,让瓦片加载事件触发
map.render(); map.render();
}, 100); }, 100);
@@ -652,9 +636,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// 清理函数 // 清理函数
return () => { return () => {
isDisposingRef.current = true; isDisposingRef.current = true;
clearPendingTimeouts(); window.clearTimeout(initializeTimer);
debouncedUpdateDataRef.current?.cancel(); scheduleActiveTileSnapshot.cancel();
persistView.cancel(); handleViewChange.cancel();
junctionSource.un("tileloadend", handleJunctionTileLoadEnd); junctionSource.un("tileloadend", handleJunctionTileLoadEnd);
pipeSource.un("tileloadend", handlePipeTileLoadEnd); pipeSource.un("tileloadend", handlePipeTileLoadEnd);
map.getView().un("change", handleViewChange); map.getView().un("change", handleViewChange);
@@ -673,10 +657,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// React Strict Mode re-runs effects with the same memoized layer instances. // React Strict Mode re-runs effects with the same memoized layer instances.
// Detach and clear them here, but leave final layer/source disposal to GC. // Detach and clear them here, but leave final layer/source disposal to GC.
disposeMapResources(map, { disposeLayers: false }); disposeMapResources(map, { disposeLayers: false });
activeJunctionDataIds.clear(); junctionIndexRef.current = null;
activePipeDataIds.clear(); pipeIndexRef.current = null;
tileJunctionDataBuffer.current = []; setJunctionDataState([]);
tilePipeDataBuffer.current = []; setPipeDataState([]);
setPipeFragments([]);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [MAP_WORKSPACE, MAP_EXTENT]); }, [MAP_WORKSPACE, MAP_EXTENT]);
@@ -695,6 +680,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
mapUrl: MAP_URL, mapUrl: MAP_URL,
workspace: MAP_WORKSPACE, workspace: MAP_WORKSPACE,
extent: MAP_EXTENT, extent: MAP_EXTENT,
sources: operationalSources,
}); });
const nextCompareMap = new OlMap({ const nextCompareMap = new OlMap({
target: compareMapRef.current, target: compareMapRef.current,
@@ -702,6 +688,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
layers: compareResources.orderedLayers.slice(), layers: compareResources.orderedLayers.slice(),
controls: [], controls: [],
}); });
const handleCompareRenderComplete = () => markCompareOpenReady();
nextCompareMap.once("rendercomplete", handleCompareRenderComplete);
nextCompareMap.getAllLayers().forEach((layer) => { nextCompareMap.getAllLayers().forEach((layer) => {
const layerId = layer.get("value"); const layerId = layer.get("value");
if (!layerId) return; if (!layerId) return;
@@ -744,6 +732,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
return () => { return () => {
isCompareDisposingRef.current = true; isCompareDisposingRef.current = true;
window.clearTimeout(resizeTimerId); window.clearTimeout(resizeTimerId);
nextCompareMap.un("rendercomplete", handleCompareRenderComplete);
if ( if (
compareDeckLayerRef.current && compareDeckLayerRef.current &&
!compareDeckLayerRef.current.isDisposedLayer() !compareDeckLayerRef.current.isDisposedLayer()
@@ -758,7 +747,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
compareDeckLayerRef.current = null; compareDeckLayerRef.current = null;
setCompareDeckLayer(undefined); setCompareDeckLayer(undefined);
setCompareMap(undefined); setCompareMap(undefined);
disposeMapResources(nextCompareMap); disposeMapResources(nextCompareMap, { disposeSources: false });
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCompareMode, map]); }, [isCompareMode, map]);
@@ -775,7 +764,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
}, [compareMap, isCompareMode, map]); }, [compareMap, isCompareMode, map]);
useEffect(() => { useEffect(() => {
setCurrentTime(-1); setCurrentTime(
getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes),
);
setSelectedDate(new Date()); setSelectedDate(new Date());
setSchemeName(""); setSchemeName("");
setCurrentJunctionCalData([]); setCurrentJunctionCalData([]);
@@ -800,7 +791,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
deckLayerRef.current?.resetSessionLayers(); deckLayerRef.current?.resetSessionLayers();
operationalResources.resetStyles(); operationalResources.resetStyles();
}; };
}, [pathname, map, operationalResources]); }, [durationMinutes, pathname, map, operationalResources, stepMinutes]);
// 当数据变化时,更新 deck.gl 图层 // 当数据变化时,更新 deck.gl 图层
useEffect(() => { useEffect(() => {
@@ -1009,11 +1000,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// 控制流动动画开关 // 控制流动动画开关
useEffect(() => { useEffect(() => {
flowAnimation.current = pipeText === "flow" && currentPipeCalData.length > 0; const hasFlowData = pipeText === "flow" && currentPipeCalData.length > 0;
const shouldShowWaterflow = const shouldShowWaterflow =
isWaterflowLayerAvailable && isWaterflowLayerAvailable &&
showWaterflowLayer && showWaterflowLayer &&
flowAnimation.current && hasFlowData &&
currentZoom >= 12 && currentZoom >= 12 &&
currentZoom <= 24; currentZoom <= 24;
@@ -1021,13 +1012,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const syncWaterflowLayer = ( const syncWaterflowLayer = (
targetDeckLayer: DeckLayer | null, targetDeckLayer: DeckLayer | null,
targetPipeData: any[], targetPipeFragments: any[],
disposing: boolean, disposing: boolean,
) => { ) => {
if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) { if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) {
return; return;
} }
if (!shouldShowWaterflow || targetPipeData.length === 0) { if (!shouldShowWaterflow || targetPipeFragments.length === 0) {
targetDeckLayer.removeDeckLayer("waterflowLayer"); targetDeckLayer.removeDeckLayer("waterflowLayer");
return; return;
} }
@@ -1039,7 +1030,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const waterflowLayer = new TripsLayer({ const waterflowLayer = new TripsLayer({
id: "waterflowLayer", id: "waterflowLayer",
name: "水流", name: "水流",
data: targetPipeData, data: targetPipeFragments,
getObjectId: (d: any) => d.instanceKey,
getPath: (d) => d.path, getPath: (d) => d.path,
getTimestamps: (d) => d.timestamps, getTimestamps: (d) => d.timestamps,
getColor: [0, 220, 255], getColor: [0, 220, 255],
@@ -1061,13 +1053,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const animate = () => { const animate = () => {
syncWaterflowLayer( syncWaterflowLayer(
deckLayerRef.current, deckLayerRef.current,
mergedPipeData, mergedPipeFragments,
isDisposingRef.current, isDisposingRef.current,
); );
if (isCompareMode) { if (isCompareMode) {
syncWaterflowLayer( syncWaterflowLayer(
compareDeckLayerRef.current, compareDeckLayerRef.current,
mergedComparePipeData, mergedComparePipeFragments,
isCompareDisposingRef.current, isCompareDisposingRef.current,
); );
} }
@@ -1086,8 +1078,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
}, [ }, [
currentPipeCalData, currentPipeCalData,
currentZoom, currentZoom,
mergedPipeData, mergedPipeFragments,
mergedComparePipeData, mergedComparePipeFragments,
isCompareMode, isCompareMode,
pipeText, pipeText,
isWaterflowLayerAvailable, isWaterflowLayerAvailable,
@@ -0,0 +1,87 @@
jest.mock("ol/layer/WebGLVectorTile", () => ({
__esModule: true,
default: class WebGLVectorTileLayer {},
}));
import { LayerStyleController } from "./layerStyleController";
describe("LayerStyleController", () => {
it("rebuilds only when the structural signature changes", () => {
const layer = {
getSource: () => null,
updateStyleVariables: jest.fn(),
setStyle: jest.fn(),
} as any;
const controller = new LayerStyleController({
layer,
defaultStyle: { "stroke-color": "gray" },
});
const base = {
property: "pressure",
classCount: 5,
template: { "stroke-color": ["var", "tj_color_0"] } as any,
variables: { tj_color_0: "red" },
};
controller.applyNative(base);
controller.applyNative({ ...base, variables: { tj_color_0: "blue" } });
expect(layer.setStyle).toHaveBeenCalledTimes(1);
expect(layer.updateStyleVariables).toHaveBeenCalledTimes(2);
controller.applyNative({ ...base, classCount: 6 });
expect(layer.setStyle).toHaveBeenCalledTimes(2);
});
it("isolates primary and comparison values on a shared vector source", () => {
const properties: Record<string, unknown> = { id: "J-1" };
const feature = {
properties,
properties_: properties,
get: (key: string) => properties[key],
};
const listeners = new Set<(event: any) => void>();
const source = {
sourceTiles_: { loaded: { getFeatures: () => [feature] } },
on: (_type: string, listener: (event: any) => void) => listeners.add(listener),
un: (_type: string, listener: (event: any) => void) => listeners.delete(listener),
};
const createLayer = () => ({
getSource: () => source,
on: jest.fn(),
un: jest.fn(),
getOpacity: () => 1,
setOpacity: jest.fn(),
setStyle: jest.fn(),
updateStyleVariables: jest.fn(),
});
const primary = new LayerStyleController({
layer: createLayer() as any,
defaultStyle: { "circle-fill-color": "gray" },
stateNamespace: "primary",
});
const compare = new LayerStyleController({
layer: createLayer() as any,
defaultStyle: { "circle-fill-color": "gray" },
stateNamespace: "compare",
});
const options = {
property: "pressure",
classCount: 5,
template: { "circle-fill-color": ["get", "pressure"] } as any,
variables: {},
};
primary.applyRuntime(options, new Map([["J-1", 0.25]]));
compare.applyRuntime(options, new Map([["J-1", 0.75]]));
expect(properties.primary_pressure).toBe(0.25);
expect(properties.compare_pressure).toBe(0.75);
expect(listeners.size).toBe(2);
primary.dispose();
expect(listeners.size).toBe(1);
expect(properties.compare_pressure).toBe(0.75);
compare.dispose();
expect(listeners.size).toBe(0);
});
});
@@ -0,0 +1,126 @@
import type OlMap from "ol/Map";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import type VectorTileSource from "ol/source/VectorTile";
import type { FlatStyleLike, StyleVariables } from "ol/style/flat";
import {
buildVersionedFlatStyle,
sanitizeStyleIdentifier,
VectorTileStyleSession,
} from "./vectorTileStyleSession";
type FeatureStateValue = string | number | boolean | null;
type ApplyStyleOptions = {
property: string;
classCount: number;
template: FlatStyleLike;
variables: StyleVariables;
};
type LayerStyleControllerOptions = {
layer: WebGLVectorTileLayer;
defaultStyle: FlatStyleLike;
map?: OlMap;
stateNamespace?: "primary" | "compare";
};
export class LayerStyleController {
private readonly layer: WebGLVectorTileLayer;
private readonly source: VectorTileSource | null;
private readonly defaultStyle: FlatStyleLike;
private readonly map?: OlMap;
private readonly stateNamespace?: "primary" | "compare";
private session: VectorTileStyleSession | null = null;
private templateSignature = "";
constructor(options: LayerStyleControllerOptions) {
this.layer = options.layer;
this.source = options.layer.getSource() as VectorTileSource | null;
this.defaultStyle = options.defaultStyle;
this.map = options.map;
this.stateNamespace = options.stateNamespace;
}
applyNative(options: ApplyStyleOptions) {
this.disposeSession();
const signature = `native:${options.property}:${options.classCount}`;
this.layer.updateStyleVariables(options.variables);
if (signature !== this.templateSignature) {
this.layer.setStyle(options.template);
this.templateSignature = signature;
}
}
applyRuntime(
options: ApplyStyleOptions,
stateById: ReadonlyMap<string, FeatureStateValue>,
) {
if (!this.source) return;
const signature = `runtime:${options.property}:${options.classCount}`;
const statePropertyKey = sanitizeStyleIdentifier(
this.stateNamespace
? `${this.stateNamespace}_${options.property}`
: options.property,
);
const buildStyle = (
statePropertyKey: string,
versionKey: string,
version: number,
) =>
buildVersionedFlatStyle(
options.template,
this.defaultStyle,
options.property,
statePropertyKey,
versionKey,
version,
);
if (!this.session || this.session.propertyKey !== statePropertyKey) {
this.disposeSession();
this.session = new VectorTileStyleSession({
layer: this.layer,
source: this.source,
propertyKey: statePropertyKey,
defaultStyle: this.defaultStyle,
buildStyle,
variables: options.variables,
map: this.map,
buffered: Boolean(this.map),
});
} else {
this.session.setBuildStyle(buildStyle);
}
this.session.updateStyleVariables(options.variables);
this.templateSignature = signature;
return this.session.commit(stateById);
}
updateVariables(variables: StyleVariables) {
if (this.session) {
this.session.updateStyleVariables(variables);
} else {
this.layer.updateStyleVariables(variables);
}
}
hasTemplate() {
return this.templateSignature.length > 0;
}
reset() {
this.disposeSession();
this.templateSignature = "";
this.layer.setStyle(this.defaultStyle);
}
dispose() {
this.disposeSession();
}
private disposeSession() {
this.session?.dispose();
this.session = null;
}
}
@@ -91,4 +91,29 @@ describe("map lifecycle", () => {
expect(layer.dispose).not.toHaveBeenCalled(); expect(layer.dispose).not.toHaveBeenCalled();
expect(map.dispose).toHaveBeenCalledTimes(1); expect(map.dispose).toHaveBeenCalledTimes(1);
}); });
it("detaches a comparison map without clearing shared sources", () => {
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, { disposeSources: false });
expect(source.clear).not.toHaveBeenCalled();
expect(source.dispose).not.toHaveBeenCalled();
expect(layer.dispose).toHaveBeenCalledTimes(1);
expect(map.dispose).toHaveBeenCalledTimes(1);
});
}); });
+19 -6
View File
@@ -28,25 +28,34 @@ const removeTransientResources = <T extends MapResource>(
}); });
}; };
const releaseLayer = (layer: any, dispose: boolean) => { type ReleaseLayerOptions = {
disposeLayer: boolean;
disposeSource: boolean;
};
const releaseLayer = (layer: any, options: ReleaseLayerOptions) => {
const childLayers = layer.getLayers?.().getArray?.(); const childLayers = layer.getLayers?.().getArray?.();
if (Array.isArray(childLayers)) { if (Array.isArray(childLayers)) {
[...childLayers].forEach((childLayer) => releaseLayer(childLayer, dispose)); [...childLayers].forEach((childLayer) => releaseLayer(childLayer, options));
layer.getLayers().clear(); layer.getLayers().clear();
} }
const source = layer.getSource?.(); const source = layer.getSource?.();
if (options.disposeSource) {
try { try {
source?.clear?.(); source?.clear?.();
} catch { } catch {
// Some third-party sources do not support explicit cache clearing. // Some third-party sources do not support explicit cache clearing.
} }
if (dispose) { if (options.disposeLayer) {
try { try {
source?.dispose?.(); source?.dispose?.();
} catch { } catch {
// Source may already be disposed by its owning layer. // Source may already be disposed by its owning layer.
} }
}
}
if (options.disposeLayer) {
try { try {
layer.dispose?.(); layer.dispose?.();
} catch { } catch {
@@ -59,7 +68,7 @@ export const cleanupTransientMapResources = (map: OlMap) => {
[...map.getLayers().getArray()].forEach((layer) => { [...map.getLayers().getArray()].forEach((layer) => {
if (isMapResourcePersistent(layer)) return; if (isMapResourcePersistent(layer)) return;
map.removeLayer(layer); map.removeLayer(layer);
releaseLayer(layer, true); releaseLayer(layer, { disposeLayer: true, disposeSource: true });
}); });
removeTransientResources(map.getInteractions().getArray(), (interaction) => removeTransientResources(map.getInteractions().getArray(), (interaction) =>
@@ -75,12 +84,16 @@ export const cleanupTransientMapResources = (map: OlMap) => {
export const disposeMapResources = ( export const disposeMapResources = (
map: OlMap, map: OlMap,
options: { disposeLayers?: boolean } = {}, options: { disposeLayers?: boolean; disposeSources?: boolean } = {},
) => { ) => {
const disposeLayers = options.disposeLayers ?? true; const disposeLayers = options.disposeLayers ?? true;
const disposeSources = options.disposeSources ?? true;
[...map.getLayers().getArray()].forEach((layer) => { [...map.getLayers().getArray()].forEach((layer) => {
map.removeLayer(layer); map.removeLayer(layer);
releaseLayer(layer, disposeLayers); releaseLayer(layer, {
disposeLayer: disposeLayers,
disposeSource: disposeSources,
});
}); });
map.getInteractions().clear(); map.getInteractions().clear();
map.getControls().clear(); map.getControls().clear();
@@ -0,0 +1,84 @@
jest.mock("@turf/turf", () => ({
along: jest.fn(),
lineString: jest.fn(),
length: jest.fn(),
toMercator: jest.fn(),
}));
jest.mock("ol/format/MVT", () => ({ __esModule: true, default: class MVT {} }));
jest.mock("ol/format/GeoJSON", () => ({ __esModule: true, default: class GeoJSON {} }));
jest.mock("ol/geom", () => ({ Point: class Point {} }));
jest.mock("ol/proj", () => ({ toLonLat: jest.fn() }));
jest.mock("ol/style", () => ({ Icon: class Icon {}, Style: class Style {} }));
jest.mock("ol/source/Vector", () => ({
__esModule: true,
default: class VectorSource {
constructor(readonly options: unknown) {}
},
}));
jest.mock("ol/source/VectorTile", () => ({
__esModule: true,
default: class VectorTileSource {
constructor(readonly options: unknown) {}
},
}));
jest.mock("ol/layer/Vector", () => ({
__esModule: true,
default: class MockVectorLayer {
private readonly source: unknown;
private readonly properties = new Map<string, unknown>();
constructor(options: any) {
this.source = options.source;
Object.entries(options.properties || {}).forEach(([key, value]) =>
this.properties.set(key, value),
);
}
getSource() { return this.source; }
get(key: string) { return this.properties.get(key); }
set(key: string, value: unknown) { this.properties.set(key, value); }
setStyle() {}
},
}));
jest.mock("ol/layer/WebGLVectorTile", () => ({
__esModule: true,
default: class MockWebGlVectorTileLayer {
private readonly source: unknown;
private readonly properties = new Map<string, unknown>();
constructor(options: any) {
this.source = options.source;
Object.entries(options.properties || {}).forEach(([key, value]) =>
this.properties.set(key, value),
);
}
getSource() { return this.source; }
get(key: string) { return this.properties.get(key); }
set(key: string, value: unknown) { this.properties.set(key, value); }
setStyle() {}
},
}));
import {
createOperationalMapResources,
createOperationalMapSources,
} from "./operationalLayers";
describe("operational map resources", () => {
it("shares sources while keeping per-map layer instances independent", () => {
const options = {
mapUrl: "https://maps.example.test/geoserver",
workspace: "test_workspace",
extent: [0, 0, 100, 100] as [number, number, number, number],
};
const sources = createOperationalMapSources(options);
const primary = createOperationalMapResources({ ...options, sources });
const compare = createOperationalMapResources({ ...options, sources });
Object.keys(sources).forEach((sourceId) => {
const key = sourceId as keyof typeof sources;
expect(primary.sources[key]).toBe(compare.sources[key]);
expect(primary.layers[key]).not.toBe(compare.layers[key]);
expect(primary.layers[key].getSource()).toBe(sources[key]);
expect(compare.layers[key].getSource()).toBe(sources[key]);
});
});
});
+27 -6
View File
@@ -17,11 +17,25 @@ import { markMapResourcePersistent } from "./mapLifecycle";
type MapExtent = [number, number, number, number]; type MapExtent = [number, number, number, number];
type CreateOperationalLayersOptions = { type CreateOperationalMapSourcesOptions = {
mapUrl: string; mapUrl: string;
workspace: string; workspace: string;
};
type CreateOperationalLayersOptions = CreateOperationalMapSourcesOptions & {
extent: MapExtent; extent: MapExtent;
persistent?: boolean; persistent?: boolean;
sources?: OperationalMapSources;
};
export type OperationalMapSources = {
junctions: VectorTileSource;
pipes: VectorTileSource;
valves: VectorTileSource;
reservoirs: VectorSource;
pumps: VectorSource;
tanks: VectorSource;
scada: VectorSource;
}; };
const defaultFlatStyle = config.MAP_DEFAULT_STYLE as FlatStyleLike; const defaultFlatStyle = config.MAP_DEFAULT_STYLE as FlatStyleLike;
@@ -85,18 +99,16 @@ const pipeProperties = [
{ name: "流速", value: "velocity" }, { name: "流速", value: "velocity" },
]; ];
export const createOperationalMapResources = ({ export const createOperationalMapSources = ({
mapUrl, mapUrl,
workspace, workspace,
extent, }: CreateOperationalMapSourcesOptions): OperationalMapSources => {
persistent = false,
}: CreateOperationalLayersOptions) => {
const vectorTileUrl = (name: string) => const vectorTileUrl = (name: string) =>
`${mapUrl}/gwc/service/tms/1.0.0/${workspace}:${name}@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`; `${mapUrl}/gwc/service/tms/1.0.0/${workspace}:${name}@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`;
const vectorUrl = (name: string) => const vectorUrl = (name: string) =>
`${mapUrl}/${workspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${workspace}:${name}&outputFormat=application/json`; `${mapUrl}/${workspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${workspace}:${name}&outputFormat=application/json`;
const sources = { return {
junctions: new VectorTileSource({ junctions: new VectorTileSource({
url: vectorTileUrl("geo_junctions"), url: vectorTileUrl("geo_junctions"),
format: new MVT(), format: new MVT(),
@@ -129,6 +141,15 @@ export const createOperationalMapResources = ({
format: new GeoJSON(), format: new GeoJSON(),
}), }),
}; };
};
export const createOperationalMapResources = ({
mapUrl,
workspace,
extent,
persistent = false,
sources = createOperationalMapSources({ mapUrl, workspace }),
}: CreateOperationalLayersOptions) => {
const layers = { const layers = {
junctions: new WebGLVectorTileLayer({ junctions: new WebGLVectorTileLayer({
@@ -0,0 +1,101 @@
jest.mock("ol/extent", () => ({
intersects: (a: number[], b: number[]) =>
a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1],
}));
jest.mock("ol/proj", () => ({
toLonLat: (coordinate: number[]) => coordinate,
}));
import {
TileFeatureIndex,
clipLineStringPartsToExtent,
lineStringFromFlatCoordinates,
} from "./tileFeatureIndex";
describe("tileFeatureIndex geometry helpers", () => {
it("clips a line to the tile core extent without dropping both sides", () => {
const clipped = clipLineStringPartsToExtent(
[
[-10, 5],
[5, 5],
[15, 5],
],
[0, 0, 10, 10],
);
expect(clipped).toEqual([
[
[0, 5],
[5, 5],
[10, 5],
],
]);
});
it("preserves all coordinates from a flat line with stride", () => {
expect(lineStringFromFlatCoordinates([0, 1, 2, 3, 4, 5], 2)).toEqual([
[0, 1],
[2, 3],
[4, 5],
]);
});
it("keeps disconnected clipped line parts separate", () => {
expect(
clipLineStringPartsToExtent(
[
[-5, 2],
[5, 2],
[15, 2],
[15, 8],
[5, 8],
[-5, 8],
],
[0, 0, 10, 10],
),
).toEqual([
[
[0, 2],
[5, 2],
[10, 2],
],
[
[10, 8],
[5, 8],
[0, 8],
],
]);
});
it("keeps multiple tile instances for the same feature id", () => {
const makeFeature = (flatCoordinates: number[]) => ({
getProperties: () => ({ id: "P-1", diameter: 100 }),
getGeometry: () => ({
getType: () => "LineString",
getFlatCoordinates: () => flatCoordinates,
getStride: () => 2,
}),
});
const makeTile = (x: number, flatCoordinates: number[]) => ({
getTileCoord: () => [14, x, 5],
getFeatures: () => [makeFeature(flatCoordinates)],
});
const source = {
sourceTiles_: {
a: makeTile(1, [0, 0, 10, 0]),
b: makeTile(2, [10, 0, 20, 0]),
},
getTileGrid: () => ({
getTileCoordExtent: ([, x]: [number, number, number]) =>
x === 1 ? [0, -10, 10, 10] : [10, -10, 20, 10],
}),
} as any;
const index = new TileFeatureIndex("pipes", source);
const snapshot = index.getSnapshot(undefined, 14);
expect(snapshot.instances).toHaveLength(2);
expect(snapshot.instancesById.get("P-1")).toHaveLength(2);
});
});
@@ -0,0 +1,322 @@
import { intersects } from "ol/extent";
import type { Extent } from "ol/extent";
import type OlMap from "ol/Map";
import { toLonLat } from "ol/proj";
import type VectorTileSource from "ol/source/VectorTile";
import type { Coordinate } from "ol/coordinate";
import {
getLoadedVectorTiles,
getVectorTileFeatureId,
getVectorTileFeatureProperties,
} from "./vectorTileUtils";
type TileKey = string;
export type TileFeatureInstance = {
instanceKey: string;
z: number;
featureId: string;
properties: Record<string, any>;
geometryType: string;
tileExtent: Extent;
flatCoordinates: number[];
stride: number;
};
type TileFeatureSnapshot = {
instancesById: Map<string, TileFeatureInstance[]>;
instances: TileFeatureInstance[];
};
const getTileCoord = (tile: any): [number, number, number] | null => {
const coord =
typeof tile.getTileCoord === "function"
? tile.getTileCoord()
: tile.tileCoord ?? tile.tileCoord_;
if (!Array.isArray(coord) || coord.length < 3) return null;
return [Number(coord[0]), Number(coord[1]), Number(coord[2])];
};
const getTileKey = (sourceKey: string, z: number, x: number, y: number) =>
`${sourceKey}/${z}/${x}/${y}`;
const getInstanceKey = (
sourceKey: string,
z: number,
x: number,
y: number,
featureOrdinal: number,
) => `${sourceKey}/${z}/${x}/${y}/${featureOrdinal}`;
const normalizeExtent = (extent: Extent): Extent => [
Math.min(extent[0], extent[2]),
Math.min(extent[1], extent[3]),
Math.max(extent[0], extent[2]),
Math.max(extent[1], extent[3]),
];
const getTileGrid = (source: VectorTileSource, map?: OlMap) => {
const sourceAny = source as any;
const projection = map?.getView().getProjection();
if (projection && typeof sourceAny.getTileGridForProjection === "function") {
return sourceAny.getTileGridForProjection(projection);
}
return typeof sourceAny.getTileGrid === "function"
? sourceAny.getTileGrid()
: sourceAny.tileGrid ?? sourceAny.tileGrid_;
};
const getTileExtent = (
source: VectorTileSource,
tile: any,
z: number,
x: number,
y: number,
): Extent | null => {
const tileGrid = getTileGrid(source);
if (tileGrid && typeof tileGrid.getTileCoordExtent === "function") {
return normalizeExtent(tileGrid.getTileCoordExtent([z, x, y]) as Extent);
}
const extent =
typeof tile.getExtent === "function"
? tile.getExtent()
: tile.extent ?? tile.extent_;
return Array.isArray(extent) ? normalizeExtent(extent as Extent) : null;
};
const getFeatureGeometry = (renderFeature: any) => {
if (typeof renderFeature.getGeometry === "function") {
return renderFeature.getGeometry();
}
return renderFeature;
};
const getGeometryType = (geometry: any): string => {
if (typeof geometry.getType === "function") return String(geometry.getType());
if (typeof geometry.getType === "string") return geometry.getType;
return "";
};
const getFlatCoordinates = (geometry: any): number[] => {
if (typeof geometry.getFlatCoordinates === "function") {
return Array.from(geometry.getFlatCoordinates() ?? []);
}
if (Array.isArray(geometry.flatCoordinates)) {
return geometry.flatCoordinates.slice();
}
if (Array.isArray(geometry.flatCoordinates_)) {
return geometry.flatCoordinates_.slice();
}
return [];
};
const getStride = (geometry: any) =>
typeof geometry.getStride === "function"
? Number(geometry.getStride())
: Number(geometry.stride ?? geometry.stride_ ?? 2);
export const lineStringFromFlatCoordinates = (
flatCoordinates: number[],
stride: number,
): Coordinate[] => {
const coordinates: Coordinate[] = [];
for (let i = 0; i + 1 < flatCoordinates.length; i += stride) {
coordinates.push([flatCoordinates[i], flatCoordinates[i + 1]]);
}
return coordinates;
};
const clipSegmentToExtent = (
start: Coordinate,
end: Coordinate,
extent: Extent,
): [Coordinate, Coordinate] | null => {
const [minX, minY, maxX, maxY] = extent;
const dx = end[0] - start[0];
const dy = end[1] - start[1];
let t0 = 0;
let t1 = 1;
const update = (p: number, q: number) => {
if (p === 0) return q >= 0;
const r = q / p;
if (p < 0) {
if (r > t1) return false;
if (r > t0) t0 = r;
} else {
if (r < t0) return false;
if (r < t1) t1 = r;
}
return true;
};
if (
!update(-dx, start[0] - minX) ||
!update(dx, maxX - start[0]) ||
!update(-dy, start[1] - minY) ||
!update(dy, maxY - start[1])
) {
return null;
}
return [
[start[0] + t0 * dx, start[1] + t0 * dy],
[start[0] + t1 * dx, start[1] + t1 * dy],
];
};
const sameCoordinate = (a: Coordinate, b: Coordinate) =>
a[0] === b[0] && a[1] === b[1];
export const clipLineStringPartsToExtent = (
coordinates: Coordinate[],
extent: Extent,
): Coordinate[][] => {
const parts: Coordinate[][] = [];
let currentPart: Coordinate[] = [];
for (let i = 1; i < coordinates.length; i += 1) {
const segment = clipSegmentToExtent(
coordinates[i - 1],
coordinates[i],
extent,
);
if (!segment) {
if (currentPart.length >= 2) parts.push(currentPart);
currentPart = [];
continue;
}
const [start, end] = segment;
if (
currentPart.length > 0 &&
!sameCoordinate(currentPart[currentPart.length - 1], start)
) {
if (currentPart.length >= 2) parts.push(currentPart);
currentPart = [];
}
if (
currentPart.length === 0 ||
!sameCoordinate(currentPart[currentPart.length - 1], start)
) {
currentPart.push(start);
}
if (!sameCoordinate(start, end)) {
currentPart.push(end);
}
}
if (currentPart.length >= 2) parts.push(currentPart);
return parts;
};
export const coordinatesToLonLat = (
coordinates: Coordinate[],
): [number, number][] =>
coordinates.map((coordinate) => {
const [lon, lat] = toLonLat(coordinate);
return [lon, lat];
});
export class TileFeatureIndex {
private readonly instancesByTile = new Map<
TileKey,
TileFeatureInstance[]
>();
constructor(
private readonly sourceKey: string,
private readonly source: VectorTileSource,
) {
this.scanLoadedTiles();
}
scanLoadedTiles() {
const activeTileKeys = new Set<TileKey>();
getLoadedVectorTiles(this.source).forEach((tile) => {
const tileKey = this.registerTile(tile);
if (tileKey) activeTileKeys.add(tileKey);
});
this.pruneMissingTiles(activeTileKeys);
}
registerTile(tile: any): TileKey | null {
if (typeof tile?.getFeatures !== "function") return null;
const coord = getTileCoord(tile);
if (!coord) return null;
const [z, x, y] = coord;
const extent = getTileExtent(this.source, tile, z, x, y);
if (!extent) return null;
const tileKey = getTileKey(this.sourceKey, z, x, y);
const tileInstances: TileFeatureInstance[] = [];
const renderFeatures = tile.getFeatures() ?? [];
renderFeatures.forEach((renderFeature: any, featureOrdinal: number) => {
const properties = getVectorTileFeatureProperties(renderFeature);
const featureId = getVectorTileFeatureId(renderFeature);
if (!featureId) return;
const geometry = getFeatureGeometry(renderFeature);
if (!geometry) return;
const flatCoordinates = getFlatCoordinates(geometry);
const stride = getStride(geometry);
if (flatCoordinates.length < 2 || stride < 2) return;
const instanceKey = getInstanceKey(
this.sourceKey,
z,
x,
y,
featureOrdinal,
);
tileInstances.push({
instanceKey,
z,
featureId,
properties,
geometryType: getGeometryType(geometry),
tileExtent: extent,
flatCoordinates,
stride,
});
});
if (tileInstances.length === 0) {
this.instancesByTile.delete(tileKey);
} else {
this.instancesByTile.set(tileKey, tileInstances);
}
return tileKey;
}
private pruneMissingTiles(activeTileKeys: Set<TileKey>) {
Array.from(this.instancesByTile.keys()).forEach((tileKey) => {
if (!activeTileKeys.has(tileKey)) {
this.instancesByTile.delete(tileKey);
}
});
}
getSnapshot(map: OlMap | undefined, zoom: number): TileFeatureSnapshot {
const tileGrid = getTileGrid(this.source, map);
const resolution = map?.getView().getResolution();
const targetZ =
tileGrid && resolution !== undefined
? tileGrid.getZForResolution(resolution, (this.source as any).zDirection)
: Math.max(0, Math.round(zoom));
const viewExtent = map?.getView().calculateExtent(map.getSize());
const instances = Array.from(this.instancesByTile.values())
.flat()
.filter((instance) => instance.z === targetZ)
.filter(
(instance) =>
!viewExtent || intersects(instance.tileExtent, viewExtent),
)
.sort((a, b) => a.instanceKey.localeCompare(b.instanceKey));
const instancesById = new Map<string, TileFeatureInstance[]>();
instances.forEach((instance) => {
const featureInstances = instancesById.get(instance.featureId);
if (featureInstances) featureInstances.push(instance);
else instancesById.set(instance.featureId, [instance]);
});
return { instancesById, instances };
}
}
@@ -0,0 +1,245 @@
jest.mock("ol/layer/WebGLVectorTile", () => ({
__esModule: true,
default: class WebGLVectorTileLayer {
opacity: number;
visible: boolean;
renderer = { renderComplete: true };
source: any;
style: any;
properties: Record<string, unknown>;
constructor(options: any = {}) {
this.opacity = options.opacity ?? 1;
this.visible = options.visible ?? true;
this.source = options.source;
this.style = options.style;
this.properties = options.properties ?? {};
}
getOpacity() { return this.opacity; }
setOpacity(value: number) { this.opacity = value; }
getVisible() { return this.visible; }
setVisible(value: boolean) { this.visible = value; }
getExtent() { return undefined; }
getMinZoom() { return 0; }
getMaxZoom() { return 24; }
getMinResolution() { return 0; }
getMaxResolution() { return Infinity; }
getPreload() { return 0; }
setPreload() {}
getZIndex() { return undefined; }
setZIndex() {}
on() {}
un() {}
setStyle(style: any) {
this.style = style;
this.renderer = { renderComplete: true };
}
updateStyleVariables() {}
getRenderer() { return this.renderer; }
dispose() {}
},
}));
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import { VectorTileStyleSession } from "./vectorTileStyleSession";
const makeFeature = (id: string) => {
const properties: Record<string, unknown> = { id };
return {
properties,
properties_: properties,
get: (key: string) => properties[key],
};
};
const makeTile = (...features: ReturnType<typeof makeFeature>[]) => ({
getFeatures: () => features,
});
describe("VectorTileStyleSession", () => {
it("fans one feature state out to every loaded and future tile instance", () => {
const featureA = makeFeature("P-1");
const featureB = makeFeature("P-1");
const listeners = new Set<(event: any) => void>();
const source = {
sourceTiles_: {
a: makeTile(featureA),
b: makeTile(featureB),
},
on: (_type: string, listener: (event: any) => void) => listeners.add(listener),
un: (_type: string, listener: (event: any) => void) => listeners.delete(listener),
} as any;
const layer = {
on: jest.fn(),
un: jest.fn(),
getOpacity: () => 1,
setOpacity: jest.fn(),
setStyle: jest.fn(),
} as any;
const session = new VectorTileStyleSession({
layer,
source,
propertyKey: "healthRisk",
defaultStyle: { "stroke-color": "blue" },
buildStyle: () => ({ "stroke-color": "red" }),
});
session.commit(new Map([["P-1", 0.25]]));
expect(featureA.properties.healthRisk).toBe(0.25);
expect(featureB.properties.healthRisk).toBe(0.25);
expect(layer.setStyle).toHaveBeenCalledTimes(1);
const futureFeature = makeFeature("P-1");
listeners.forEach((listener) => listener({ tile: makeTile(futureFeature) }));
expect(futureFeature.properties.healthRisk).toBe(0.25);
session.commit(new Map());
expect(featureA.properties).not.toHaveProperty("healthRisk");
expect(featureB.properties).not.toHaveProperty("healthRisk");
session.dispose();
expect(listeners.size).toBe(0);
});
it("keeps the active layer visible until the standby renderer is ready", async () => {
const feature = makeFeature("P-1");
const listeners = new Set<(event: any) => void>();
const source = {
sourceTiles_: { a: makeTile(feature) },
on: (_type: string, listener: (event: any) => void) => listeners.add(listener),
un: (_type: string, listener: (event: any) => void) => listeners.delete(listener),
} as any;
const baseLayer = new WebGLVectorTileLayer({
source,
style: { "stroke-color": "blue" },
}) as any;
const layers = [baseLayer];
const postrenderListeners = new Set<() => void>();
const map = {
getLayers: () => ({
getArray: () => layers,
getLength: () => layers.length,
insertAt: (index: number, layer: any) => layers.splice(index, 0, layer),
}),
removeLayer: (layer: any) => {
const index = layers.indexOf(layer);
if (index >= 0) layers.splice(index, 1);
},
on: (_type: string, listener: () => void) => postrenderListeners.add(listener),
un: (_type: string, listener: () => void) => postrenderListeners.delete(listener),
render: () => postrenderListeners.forEach((listener) => listener()),
} as any;
const styleKeys: string[] = [];
const session = new VectorTileStyleSession({
layer: baseLayer,
source,
map,
buffered: true,
propertyKey: "healthRisk",
defaultStyle: { "stroke-color": "blue" },
buildStyle: (propertyKey, versionKey) => {
styleKeys.push(propertyKey, versionKey);
return { "stroke-color": "red" };
},
});
const commit = session.commit(new Map([["P-1", 0.25]]));
expect(baseLayer.getOpacity()).toBe(1);
await commit;
expect(layers).toHaveLength(2);
expect(baseLayer.getOpacity()).toBe(0);
expect(layers[1].getOpacity()).toBe(1);
expect(styleKeys).toEqual([
"healthRisk_buffer_1",
"healthRisk_render_version_buffer_1",
]);
expect(styleKeys.every((key) => !key.includes("__"))).toBe(true);
session.dispose();
expect(layers).toHaveLength(1);
});
it("does not replace a renderer while an obsolete buffer is still building", async () => {
const feature = makeFeature("P-1");
const source = {
sourceTiles_: { a: makeTile(feature) },
on: jest.fn(),
un: jest.fn(),
} as any;
const baseLayer = new WebGLVectorTileLayer({
source,
style: { "stroke-color": "blue" },
}) as any;
const layers = [baseLayer];
const postrenderListeners = new Set<() => void>();
const map = {
getLayers: () => ({
getArray: () => layers,
getLength: () => layers.length,
insertAt: (index: number, layer: any) => layers.splice(index, 0, layer),
}),
removeLayer: jest.fn(),
on: (_type: string, listener: () => void) => postrenderListeners.add(listener),
un: (_type: string, listener: () => void) => postrenderListeners.delete(listener),
render: jest.fn(),
} as any;
const session = new VectorTileStyleSession({
layer: baseLayer,
source,
map,
buffered: true,
propertyKey: "healthRisk",
defaultStyle: { "stroke-color": "blue" },
buildStyle: () => ({ "stroke-color": "red" }),
});
const firstCommit = session.commit(new Map([["P-1", 0.25]]));
await Promise.resolve();
await Promise.resolve();
const standbyLayer = layers[1] as any;
standbyLayer.renderer = { renderComplete: false };
const setStyle = jest.spyOn(standbyLayer, "setStyle");
const latestCommit = session.commit(new Map([["P-1", 0.75]]));
expect(setStyle).not.toHaveBeenCalled();
postrenderListeners.forEach((listener) => listener());
await expect(firstCommit).resolves.toBe(false);
await Promise.resolve();
await Promise.resolve();
expect(setStyle).toHaveBeenCalledTimes(1);
postrenderListeners.forEach((listener) => listener());
postrenderListeners.forEach((listener) => listener());
await expect(latestCommit).resolves.toBe(true);
expect(feature.properties.healthRisk_buffer_1).toBe(0.75);
});
it("normalizes generated shader identifiers and mirrors variable updates", () => {
const source = { sourceTiles_: {}, on: jest.fn(), un: jest.fn() } as any;
const layer = {
on: jest.fn(),
un: jest.fn(),
getOpacity: () => 1,
setOpacity: jest.fn(),
setStyle: jest.fn(),
updateStyleVariables: jest.fn(),
} as any;
const keys: string[] = [];
const session = new VectorTileStyleSession({
layer,
source,
propertyKey: "healthRisk__unsafe",
defaultStyle: { "stroke-color": "gray" },
buildStyle: (propertyKey, versionKey) => {
keys.push(propertyKey, versionKey);
return { "stroke-color": "red" };
},
});
session.updateStyleVariables({ tj_color_0: "red" });
session.commit(new Map());
expect(layer.updateStyleVariables).toHaveBeenCalledWith({ tj_color_0: "red" });
expect(keys.every((key) => !key.includes("__"))).toBe(true);
});
});
@@ -0,0 +1,414 @@
import type OlMap from "ol/Map";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import type VectorTileSource from "ol/source/VectorTile";
import type { FlatStyleLike, StyleVariables } from "ol/style/flat";
import {
getLoadedVectorTiles,
getVectorTileFeatureId,
getVectorTileFeatureProperties,
} from "./vectorTileUtils";
type FeatureStateValue = string | number | boolean | null;
type StyleBuilder = (
propertyKey: string,
versionKey: string,
version: number,
) => FlatStyleLike;
type VectorTileStyleSessionOptions = {
layer: WebGLVectorTileLayer;
source: VectorTileSource;
propertyKey: string;
defaultStyle: FlatStyleLike;
buildStyle: StyleBuilder;
map?: OlMap;
buffered?: boolean;
variables?: StyleVariables;
};
const INTERNAL_BUFFER_LAYER = "internalRenderBuffer";
const BUFFER_READY_TIMEOUT_MS = 5000;
export class VectorTileStyleSession {
private readonly layer: WebGLVectorTileLayer;
private readonly source: VectorTileSource;
readonly propertyKey: string;
private readonly versionKey: string;
private readonly defaultStyle: FlatStyleLike;
private readonly map?: OlMap;
private readonly buffered: boolean;
private buildStyle: StyleBuilder;
private styleVariables: StyleVariables;
private unbufferedState = new Map<string, FeatureStateValue>();
private version = 0;
private disposed = false;
private commitRevision = 0;
private bufferedCommitQueue: Promise<void> = Promise.resolve();
private readonly slotStates = [
new Map<string, FeatureStateValue>(),
new Map<string, FeatureStateValue>(),
];
private readonly slotVersions = [0, 0];
private activeSlot = 0;
private activeLayer: WebGLVectorTileLayer;
private bufferLayer: WebGLVectorTileLayer | null = null;
private readonly displayOpacity: number;
private readonly listener: (event: any) => void;
private readonly visibilityListener: () => void;
constructor(options: VectorTileStyleSessionOptions) {
this.layer = options.layer;
this.source = options.source;
this.propertyKey = sanitizeStyleIdentifier(options.propertyKey);
this.versionKey = `${this.propertyKey}_render_version`;
this.defaultStyle = options.defaultStyle;
this.buildStyle = options.buildStyle;
this.styleVariables = { ...(options.variables || {}) };
this.map = options.map;
this.buffered = Boolean(options.buffered && options.map);
this.activeLayer = this.layer;
this.displayOpacity = this.layer.getOpacity();
this.listener = (event: any) => {
try {
if (typeof event.tile?.getFeatures !== "function") return;
this.applyTile(event.tile);
} catch (error) {
console.error("Vector tile style session load error:", error);
}
};
this.visibilityListener = () => {
this.bufferLayer?.setVisible(this.layer.getVisible());
};
this.source.on("tileloadend", this.listener);
this.layer.on("change:visible", this.visibilityListener);
}
commit(
stateById: ReadonlyMap<string, FeatureStateValue>,
): Promise<boolean> | void {
if (this.disposed) return;
if (this.buffered) {
return this.commitBuffered(stateById, false);
}
this.version += 1;
this.unbufferedState = this.normalizeState(stateById);
this.applyLoadedTiles();
this.layer.setStyle(
this.buildStyle(this.propertyKey, this.versionKey, this.version),
);
}
setBuildStyle(buildStyle: StyleBuilder) {
this.buildStyle = buildStyle;
}
updateStyleVariables(variables: StyleVariables) {
if (this.disposed) return;
this.styleVariables = { ...this.styleVariables, ...variables };
this.layer.updateStyleVariables(variables);
this.bufferLayer?.updateStyleVariables(variables);
}
reset(): Promise<boolean> | void {
if (this.disposed) return;
if (this.buffered) {
return this.commitBuffered(new Map(), true);
}
this.version += 1;
this.unbufferedState = new Map();
this.applyLoadedTiles();
this.layer.setStyle(this.defaultStyle);
}
dispose() {
if (this.disposed) return;
this.commitRevision += 1;
this.source.un("tileloadend", this.listener);
this.layer.un("change:visible", this.visibilityListener);
if (this.bufferLayer && this.map) {
this.map.removeLayer(this.bufferLayer);
this.bufferLayer.dispose();
this.bufferLayer = null;
}
this.layer.setOpacity(this.displayOpacity);
this.disposed = true;
}
private normalizeState(stateById: ReadonlyMap<string, FeatureStateValue>) {
return new Map(
Array.from(stateById.entries()).map(([id, value]) => [String(id), value]),
);
}
private getSlotPropertyKey(slot: number) {
return `${this.propertyKey}_buffer_${slot}`;
}
private getSlotVersionKey(slot: number) {
return `${this.versionKey}_buffer_${slot}`;
}
private commitBuffered(
stateById: ReadonlyMap<string, FeatureStateValue>,
useDefaultStyle: boolean,
) {
if (!this.map || this.disposed) return Promise.resolve(false);
const revision = this.commitRevision + 1;
this.commitRevision = revision;
const normalizedState = this.normalizeState(stateById);
const commitPromise = this.bufferedCommitQueue.then(() => {
if (this.disposed || revision !== this.commitRevision) return false;
return this.prepareBufferedCommit(
normalizedState,
useDefaultStyle,
revision,
);
});
this.bufferedCommitQueue = commitPromise.then(
() => undefined,
() => undefined,
);
// Wake a pending readiness check so an obsolete build can exit before the
// next style replaces its renderer.
this.map.render();
return commitPromise;
}
private async prepareBufferedCommit(
stateById: Map<string, FeatureStateValue>,
useDefaultStyle: boolean,
revision: number,
) {
if (!this.map || this.disposed || revision !== this.commitRevision) {
return false;
}
const targetSlot = this.activeSlot === 0 ? 1 : 0;
this.version += 1;
this.slotVersions[targetSlot] = this.version;
this.slotStates[targetSlot] = stateById;
this.applyLoadedTiles();
const standbyLayer = this.getStandbyLayer();
standbyLayer.setVisible(this.layer.getVisible());
standbyLayer.setOpacity(0);
standbyLayer.setStyle(
useDefaultStyle
? this.defaultStyle
: this.buildStyle(
this.getSlotPropertyKey(targetSlot),
this.getSlotVersionKey(targetSlot),
this.version,
),
);
this.map.render();
const ready = await this.waitUntilReady(standbyLayer, revision);
if (!ready || this.disposed || revision !== this.commitRevision) {
return false;
}
const previousLayer = this.activeLayer;
standbyLayer.setOpacity(this.displayOpacity);
previousLayer.setOpacity(0);
this.activeLayer = standbyLayer;
this.activeSlot = targetSlot;
this.map.render();
return true;
}
private getStandbyLayer(): WebGLVectorTileLayer {
const map = this.map;
if (!map) return this.layer;
if (!this.bufferLayer) {
const bufferLayer = new WebGLVectorTileLayer<any, any>({
source: this.source,
style: this.defaultStyle,
extent: this.layer.getExtent(),
minZoom: this.layer.getMinZoom(),
maxZoom: this.layer.getMaxZoom(),
minResolution: this.layer.getMinResolution(),
maxResolution: this.layer.getMaxResolution(),
opacity: 0,
visible: this.layer.getVisible(),
variables: this.styleVariables,
properties: {
[INTERNAL_BUFFER_LAYER]: true,
queryable: false,
},
});
bufferLayer.setPreload(this.layer.getPreload());
const zIndex = this.layer.getZIndex();
if (zIndex !== undefined) bufferLayer.setZIndex(zIndex);
const layers = map.getLayers();
const baseIndex = layers.getArray().indexOf(this.layer);
layers.insertAt(baseIndex >= 0 ? baseIndex + 1 : layers.getLength(), bufferLayer);
this.bufferLayer = bufferLayer;
}
return this.activeLayer === this.layer ? this.bufferLayer! : this.layer;
}
private waitUntilReady(layer: WebGLVectorTileLayer, revision: number) {
if (!this.map) return Promise.resolve(true);
return new Promise<boolean>((resolve) => {
let consecutiveReadyFrames = 0;
let settled = false;
const finish = (ready: boolean) => {
if (settled) return;
settled = true;
window.clearTimeout(timeoutId);
this.map?.un("postrender", checkReady);
resolve(ready);
};
const checkReady = () => {
if (this.disposed || revision !== this.commitRevision) {
finish(false);
return;
}
const renderer = layer.getRenderer() as any;
consecutiveReadyFrames = renderer?.renderComplete
? consecutiveReadyFrames + 1
: 0;
if (consecutiveReadyFrames >= 2) {
finish(true);
return;
}
this.map?.render();
};
const timeoutId = window.setTimeout(
() => finish(false),
BUFFER_READY_TIMEOUT_MS,
);
this.map!.on("postrender", checkReady);
this.map!.render();
});
}
private applyLoadedTiles() {
getLoadedVectorTiles(this.source).forEach((tile) => this.applyTile(tile));
}
private applyTile(tile: any) {
if (this.buffered) {
this.slotVersions.forEach((version, slot) => {
if (version > 0) {
this.applyTileState(
tile,
this.slotStates[slot],
this.getSlotPropertyKey(slot),
this.getSlotVersionKey(slot),
version,
);
}
});
return;
}
this.applyTileState(
tile,
this.unbufferedState,
this.propertyKey,
this.versionKey,
this.version,
);
}
private applyTileState(
tile: any,
stateById: ReadonlyMap<string, FeatureStateValue>,
propertyKey: string,
versionKey: string,
version: number,
) {
const renderFeatures = tile.getFeatures?.();
if (!renderFeatures || renderFeatures.length === 0) return;
renderFeatures.forEach((renderFeature: any) => {
const featureId = getVectorTileFeatureId(renderFeature);
if (!featureId) return;
const properties = getVectorTileFeatureProperties(renderFeature);
const value = stateById.get(featureId);
if (value === undefined) {
delete properties[propertyKey];
properties[versionKey] = version;
return;
}
properties[propertyKey] = value;
properties[versionKey] = version;
});
}
}
export const sanitizeStyleIdentifier = (value: string) => {
const normalized = value
.replace(/[^A-Za-z0-9_]/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
const prefixed = /^[A-Za-z]/.test(normalized) ? normalized : `tj_${normalized}`;
return prefixed || "tj_state";
};
export const versionedPropertyCase = (
propertyKey: string,
versionKey: string,
version: number,
cases: any[],
fallback: any,
) => [
"case",
["all", ["==", ["get", versionKey], version], ["has", propertyKey]],
["case", ...cases, fallback],
fallback,
];
const remapFlatStyleProperty = (
style: FlatStyleLike,
fromProperty: string,
toProperty: string,
): FlatStyleLike => {
const remap = (value: any): any => {
if (Array.isArray(value)) {
const next = value.map(remap);
if (
(next[0] === "get" || next[0] === "has") &&
next[1] === fromProperty
) {
next[1] = toProperty;
}
return next;
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, nested]) => [key, remap(nested)]),
);
}
return value;
};
return remap(style) as FlatStyleLike;
};
export const buildVersionedFlatStyle = (
style: FlatStyleLike,
fallbackStyle: FlatStyleLike,
sourcePropertyKey: string,
propertyKey: string,
versionKey: string,
version: number,
): FlatStyleLike => {
const remappedStyle = remapFlatStyleProperty(
style,
sourcePropertyKey,
propertyKey,
);
const guarded: Record<string, any> = {};
Object.entries(remappedStyle as Record<string, any>).forEach(
([key, value]) => {
guarded[key] = [
"case",
["all", ["==", ["get", versionKey], version], ["has", propertyKey]],
value,
(fallbackStyle as Record<string, any>)[key] ?? value,
];
},
);
return guarded as FlatStyleLike;
};
@@ -0,0 +1,24 @@
import type VectorTileSource from "ol/source/VectorTile";
export const getLoadedVectorTiles = (source: VectorTileSource): any[] => {
const sourceTiles = (source as any).sourceTiles_;
if (!sourceTiles) return [];
return sourceTiles instanceof Map
? Array.from(sourceTiles.values())
: Object.values(sourceTiles);
};
export const getVectorTileFeatureProperties = (
feature: any,
): Record<string, any> =>
feature.properties_ ?? feature.getProperties?.() ?? {};
export const getVectorTileFeatureId = (feature: any): string | null => {
const properties = getVectorTileFeatureProperties(feature);
const id =
feature.get?.("id") ??
feature.get?.("ID") ??
properties.id ??
properties.ID;
return id === undefined || id === null || id === "" ? null : String(id);
};
@@ -0,0 +1,78 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { useSnackbar } from "@refinedev/mui";
import { useAppNotificationProvider } from "./useAppNotificationProvider";
jest.mock("@refinedev/mui", () => ({
useSnackbar: jest.fn(),
}));
const mockedUseSnackbar = useSnackbar as jest.MockedFunction<typeof useSnackbar>;
const TestComponent = ({
cancelMutation,
}: {
cancelMutation?: () => void;
}) => {
const notificationProvider = useAppNotificationProvider();
return (
<button
type="button"
onClick={() =>
notificationProvider.open({
key: "analysis-progress",
type: "progress",
message: "分析中",
undoableTimeout: 3,
cancelMutation,
})
}
>
Open progress
</button>
);
};
describe("useAppNotificationProvider", () => {
const enqueueSnackbar = jest.fn();
const closeSnackbar = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockedUseSnackbar.mockReturnValue({
enqueueSnackbar,
closeSnackbar,
} as unknown as ReturnType<typeof useSnackbar>);
});
it("does not render an undo action for progress notifications without cancellation", async () => {
render(<TestComponent />);
fireEvent.click(screen.getByRole("button", { name: "Open progress" }));
expect(enqueueSnackbar).toHaveBeenCalledTimes(1);
expect(enqueueSnackbar.mock.calls[0][1]).toMatchObject({
key: "analysis-progress",
preventDuplicate: true,
autoHideDuration: 3000,
});
expect(enqueueSnackbar.mock.calls[0][1]).not.toHaveProperty("action");
});
it("keeps undo action for progress notifications with cancellation", async () => {
const cancelMutation = jest.fn();
render(<TestComponent cancelMutation={cancelMutation} />);
fireEvent.click(screen.getByRole("button", { name: "Open progress" }));
const options = enqueueSnackbar.mock.calls[0][1];
expect(options.action).toEqual(expect.any(Function));
const undoAction = render(options.action("progress-key"));
fireEvent.click(within(undoAction.container).getByRole("button"));
expect(cancelMutation).toHaveBeenCalledTimes(1);
expect(closeSnackbar).toHaveBeenCalledWith("progress-key");
});
});
@@ -0,0 +1,137 @@
"use client";
import React, { useEffect, useState } from "react";
import type { NotificationProvider } from "@refinedev/core";
import { useSnackbar } from "@refinedev/mui";
import UndoOutlined from "@mui/icons-material/UndoOutlined";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
type ProgressNotificationProps = {
undoableTimeout: number;
message: string;
};
const ProgressNotification = ({
undoableTimeout,
message,
}: ProgressNotificationProps) => {
const [progress, setProgress] = useState(100);
const [timeCount, setTimeCount] = useState(undoableTimeout);
useEffect(() => {
if (undoableTimeout <= 0 || timeCount <= 0) {
return;
}
const progressStep = 100 / undoableTimeout;
const timer = window.setInterval(() => {
setTimeCount((previous) => Math.max(previous - 1, 0));
setProgress((previous) => Math.max(previous - progressStep, 0));
}, 1000);
return () => {
window.clearInterval(timer);
};
}, [timeCount, undoableTimeout]);
return (
<>
<Box sx={{ position: "relative", display: "inline-flex" }}>
<CircularProgress
color="inherit"
variant="determinate"
value={progress}
/>
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography component="div">{timeCount}</Typography>
</Box>
</Box>
<Box
sx={{
marginLeft: "10px",
maxWidth: { xs: "150px", md: "100%" },
}}
>
<Typography variant="subtitle2">{message}</Typography>
</Box>
</>
);
};
export const useAppNotificationProvider = (): NotificationProvider => {
const { closeSnackbar, enqueueSnackbar } = useSnackbar();
return {
open: ({
message,
type,
undoableTimeout,
key,
cancelMutation,
description,
}) => {
if (type === "progress") {
const options: NonNullable<Parameters<typeof enqueueSnackbar>[1]> = {
preventDuplicate: true,
key,
autoHideDuration: (undoableTimeout ?? 0) * 1000,
};
if (cancelMutation) {
options.action = (snackbarKey) => (
<IconButton
onClick={() => {
cancelMutation();
closeSnackbar(snackbarKey);
}}
color="inherit"
>
<UndoOutlined />
</IconButton>
);
}
enqueueSnackbar(
<ProgressNotification
undoableTimeout={undoableTimeout ?? 0}
message={message}
/>,
options,
);
return;
}
enqueueSnackbar(
<Box>
<Typography variant="subtitle2" component="h6">
{description}
</Typography>
<Typography variant="caption" component="p">
{message}
</Typography>
</Box>,
{
key,
variant: type,
},
);
},
close: (key) => {
closeSnackbar(key);
},
};
};
+5 -1
View File
@@ -58,6 +58,10 @@ const MAP_CONFIG = {
bufferUnits: "meters" as const, bufferUnits: "meters" as const,
} as const; } as const;
const isQueryableVectorLayer = (layer: unknown): layer is VectorLayer => {
return layer instanceof VectorLayer && layer.get("queryable") !== false;
};
// ========== 辅助函数 ========== // ========== 辅助函数 ==========
/** /**
@@ -413,7 +417,7 @@ const handleMapClickSelectFeatures = async (
}, },
{ {
hitTolerance: MAP_CONFIG.hitTolerance, hitTolerance: MAP_CONFIG.hitTolerance,
layerFilter: (layer) => layer instanceof VectorLayer, layerFilter: isQueryableVectorLayer,
} }
); );
+19
View File
@@ -0,0 +1,19 @@
import { FLOW_DISPLAY_UNIT, isLpsFlowProperty, toM3h } from "./units";
describe("flow display units", () => {
it("uses cubic meters per hour as the flow display unit", () => {
expect(FLOW_DISPLAY_UNIT).toBe("m³/h");
});
it("converts L/s values to m³/h", () => {
expect(toM3h(10, "lps")).toBe(36);
expect(toM3h(10, "L/s")).toBe(36);
});
it("recognizes computed properties that arrive from the backend in L/s", () => {
expect(isLpsFlowProperty("flow")).toBe(true);
expect(isLpsFlowProperty("actual_demand")).toBe(true);
expect(isLpsFlowProperty("actualdemand")).toBe(true);
expect(isLpsFlowProperty("pressure")).toBe(false);
});
});
+4
View File
@@ -1,5 +1,9 @@
export const FLOW_DISPLAY_UNIT = "m³/h"; export const FLOW_DISPLAY_UNIT = "m³/h";
const M3H_FACTOR = 3600; const M3H_FACTOR = 3600;
const LPS_FLOW_PROPERTIES = new Set(["flow", "actual_demand", "actualdemand"]);
export const isLpsFlowProperty = (property: string) =>
LPS_FLOW_PROPERTIES.has(property);
export const toM3h = (value: number, sourceUnit: string = "m³/s") => { export const toM3h = (value: number, sourceUnit: string = "m³/s") => {
if (!Number.isFinite(value)) return Number.NaN; if (!Number.isFinite(value)) return Number.NaN;