Compare commits
19
Commits
f5e7312e3b
...
0d559f6130
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d559f6130 | ||
|
|
7af90e495d | ||
|
|
a52c04204d | ||
|
|
08152ff978 | ||
|
|
589cf45aa7 | ||
|
|
59447a100c | ||
|
|
4adbcc1c4c | ||
|
|
202f18332f | ||
|
|
acf13639ef | ||
|
|
d986e563a6 | ||
|
|
d8ee2e1f0c | ||
|
|
fddb0ceb34 | ||
|
|
3d7b594682 | ||
|
|
eb8950c89a | ||
|
|
ef2b045306 | ||
|
|
209da0d295 | ||
|
|
dfaee645ff | ||
|
|
d90ca7c951 | ||
|
|
041b4ef89d |
@@ -4,7 +4,6 @@ import { Refine, type AuthProvider } from "@refinedev/core";
|
||||
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
||||
import {
|
||||
RefineSnackbarProvider,
|
||||
useNotificationProvider,
|
||||
} from "@refinedev/mui";
|
||||
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
@@ -18,6 +17,7 @@ import { ProjectProvider } from "@/contexts/ProjectContext";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { config } from "@config/config";
|
||||
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
|
||||
|
||||
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
||||
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
|
||||
@@ -165,7 +165,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
<Refine
|
||||
routerProvider={routerProvider}
|
||||
dataProvider={dataProvider}
|
||||
notificationProvider={useNotificationProvider}
|
||||
notificationProvider={useAppNotificationProvider}
|
||||
authProvider={authProvider}
|
||||
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 } });
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
layerId: DefaultLayerStyleId;
|
||||
@@ -48,6 +53,23 @@ const asStringArray = (value: unknown): string[] | undefined =>
|
||||
.filter((item): item is string => item !== 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 => {
|
||||
const normalized = asString(value)?.toLowerCase();
|
||||
if (normalized === "junctions" || normalized === "pipes") {
|
||||
@@ -77,13 +99,25 @@ export const parseApplyLayerStylePayload = (
|
||||
? (params.styleConfig as Record<string, unknown>)
|
||||
: 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
|
||||
? {
|
||||
property: asString(rawStyleConfig.property),
|
||||
classificationMethod: asString(
|
||||
rawStyleConfig.classification_method ?? rawStyleConfig.classificationMethod,
|
||||
),
|
||||
segments: asNumber(rawStyleConfig.segments),
|
||||
classificationMethod: asClassificationMethod(classificationValue),
|
||||
segments,
|
||||
minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize),
|
||||
maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize),
|
||||
minStrokeWidth: asNumber(
|
||||
@@ -95,7 +129,7 @@ export const parseApplyLayerStylePayload = (
|
||||
fixedStrokeWidth: asNumber(
|
||||
rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth,
|
||||
),
|
||||
colorType: asString(rawStyleConfig.color_type ?? rawStyleConfig.colorType),
|
||||
colorType: asColorType(colorTypeValue),
|
||||
singlePaletteIndex: asNumber(
|
||||
rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex,
|
||||
),
|
||||
@@ -121,6 +155,49 @@ export const parseApplyLayerStylePayload = (
|
||||
}
|
||||
: 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 =
|
||||
styleConfig &&
|
||||
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";
|
||||
|
||||
import React, { useMemo, useState, useCallback } from "react";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
FormControl,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField,
|
||||
Typography,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
||||
@@ -23,7 +18,7 @@ import { useNotification } from "@refinedev/core";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
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 { BurstDetectionResult } from "./types";
|
||||
|
||||
@@ -33,183 +28,149 @@ interface Props {
|
||||
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 {
|
||||
schemeName: string;
|
||||
dataSource: "monitoring" | "simulation";
|
||||
schemes: SchemeItem[];
|
||||
selectedSchemeId: number | "";
|
||||
scadaStart: Dayjs | null;
|
||||
scadaEnd: Dayjs | null;
|
||||
mu: number;
|
||||
pointsPerDay: number;
|
||||
nEstimators: number;
|
||||
contaminationInput: string;
|
||||
advancedOpen: boolean;
|
||||
detectionMode: "latest" | "historical";
|
||||
targetTime: Dayjs | null;
|
||||
samplingIntervalMinutes: number;
|
||||
samplingIntervalSource: "metadata" | "manual";
|
||||
}
|
||||
|
||||
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 =
|
||||
(): BurstDetectionAnalysisParametersState => ({
|
||||
schemeName: `Burst_Detection_${Date.now()}`,
|
||||
dataSource: "monitoring",
|
||||
schemes: [],
|
||||
selectedSchemeId: "",
|
||||
scadaStart: dayjs().subtract(3, "day"),
|
||||
scadaEnd: dayjs(),
|
||||
mu: 100,
|
||||
pointsPerDay: 96,
|
||||
nEstimators: 50,
|
||||
contaminationInput: "auto",
|
||||
advancedOpen: false,
|
||||
detectionMode: "latest",
|
||||
targetTime: currentQuarterHour(),
|
||||
samplingIntervalMinutes: 15,
|
||||
samplingIntervalSource: "metadata",
|
||||
});
|
||||
|
||||
export const parseScadaFrequencyMinutes = (
|
||||
value: string | number | null | undefined,
|
||||
): number | null => {
|
||||
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> = ({
|
||||
onResult,
|
||||
state,
|
||||
onStateChange,
|
||||
}) => {
|
||||
const { open } = useNotification();
|
||||
const [parametersState, setParametersState, setFormField] = useControllableObjectState(
|
||||
state,
|
||||
onStateChange,
|
||||
createBurstDetectionAnalysisParametersState(),
|
||||
);
|
||||
const [parametersState, setParametersState, setFormField] =
|
||||
useControllableObjectState(
|
||||
state,
|
||||
onStateChange,
|
||||
createBurstDetectionAnalysisParametersState(),
|
||||
);
|
||||
const {
|
||||
schemeName,
|
||||
dataSource,
|
||||
schemes,
|
||||
selectedSchemeId,
|
||||
scadaStart,
|
||||
scadaEnd,
|
||||
mu,
|
||||
pointsPerDay,
|
||||
nEstimators,
|
||||
contaminationInput,
|
||||
advancedOpen,
|
||||
detectionMode,
|
||||
targetTime,
|
||||
samplingIntervalMinutes,
|
||||
samplingIntervalSource,
|
||||
} = parametersState;
|
||||
const [schemeLoading, setSchemeLoading] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const isSimulationMode = dataSource === "simulation";
|
||||
const [frequencyLoading, setFrequencyLoading] = useState(false);
|
||||
|
||||
const applySchemeTimeRange = useCallback((scheme: SchemeItem) => {
|
||||
const start = dayjs(scheme.scheme_start_time);
|
||||
const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600;
|
||||
const end = start.add(durationSeconds, "second");
|
||||
|
||||
setParametersState((previous) => ({
|
||||
...previous,
|
||||
scadaStart: start,
|
||||
scadaEnd: end,
|
||||
}));
|
||||
}, [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",
|
||||
useEffect(() => {
|
||||
if (samplingIntervalSource !== "metadata") return;
|
||||
let active = true;
|
||||
setFrequencyLoading(true);
|
||||
api
|
||||
.get("/api/v1/getallscadainfo/", { params: { network: NETWORK_NAME } })
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
const interval = resolvePressureSamplingInterval(
|
||||
response.data as ScadaInfoItem[],
|
||||
);
|
||||
setParametersState((previous) =>
|
||||
previous.samplingIntervalSource === "metadata"
|
||||
? { ...previous, samplingIntervalMinutes: interval }
|
||||
: previous,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the 15-minute fallback when SCADA metadata is unavailable.
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setFrequencyLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [samplingIntervalSource, setParametersState]);
|
||||
|
||||
setFormField("schemes", burstSchemes);
|
||||
const samplingIntervalValid =
|
||||
Number.isInteger(samplingIntervalMinutes) &&
|
||||
samplingIntervalMinutes > 0 &&
|
||||
1440 % samplingIntervalMinutes === 0;
|
||||
|
||||
if (selectedSchemeId) {
|
||||
const matchedScheme = burstSchemes.find(
|
||||
(scheme) => scheme.scheme_id === selectedSchemeId,
|
||||
);
|
||||
if (matchedScheme) {
|
||||
applySchemeTimeRange(matchedScheme);
|
||||
} else {
|
||||
setFormField("selectedSchemeId", "");
|
||||
}
|
||||
}
|
||||
|
||||
if (notify) {
|
||||
open?.({
|
||||
type: "success",
|
||||
message: "方案列表已刷新",
|
||||
description: `当前可选爆管分析方案 ${burstSchemes.length} 个`,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "刷新方案失败",
|
||||
description:
|
||||
error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表",
|
||||
});
|
||||
} finally {
|
||||
setSchemeLoading(false);
|
||||
}
|
||||
},
|
||||
[applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId, setFormField],
|
||||
const isValid = useMemo(
|
||||
() =>
|
||||
schemeName.trim().length > 0 &&
|
||||
samplingIntervalValid &&
|
||||
(detectionMode === "latest" || Boolean(targetTime?.isValid())),
|
||||
[detectionMode, samplingIntervalValid, schemeName, targetTime],
|
||||
);
|
||||
|
||||
const handleDataSourceChange = (value: "monitoring" | "simulation") => {
|
||||
setFormField("dataSource", value);
|
||||
if (value === "simulation") {
|
||||
void fetchSchemes();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSchemeSelect = (schemeId: number) => {
|
||||
setFormField("selectedSchemeId", schemeId);
|
||||
const scheme = schemes.find((item) => item.scheme_id === schemeId);
|
||||
if (scheme) {
|
||||
applySchemeTimeRange(scheme);
|
||||
}
|
||||
};
|
||||
|
||||
const timeWindowValid = useMemo(() => {
|
||||
if (!scadaStart || !scadaEnd) return false;
|
||||
return scadaEnd.diff(scadaStart, "day", true) >= 2;
|
||||
}, [scadaEnd, scadaStart]);
|
||||
|
||||
const contaminationValue = useMemo(() => {
|
||||
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 () => {
|
||||
if (!isValid || !scadaStart || !scadaEnd || contaminationValue === null) {
|
||||
if (!isValid) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "参数不完整",
|
||||
description: "请检查时间范围(至少2天)和高级参数是否填写正确。",
|
||||
description: "请输入方案名称,并检查历史目标时间。",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -219,50 +180,23 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
key: "burst-detection-analysis-progress",
|
||||
type: "progress",
|
||||
message: "正在执行爆管侦测",
|
||||
description: "正在读取数据并计算异常分数。",
|
||||
description: "正在读取目标时刻及前 14 天同刻基线。",
|
||||
undoableTimeout: 3,
|
||||
});
|
||||
|
||||
try {
|
||||
const selectedScheme =
|
||||
dataSource === "simulation"
|
||||
? schemes.find((item) => item.scheme_id === selectedSchemeId)
|
||||
: undefined;
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await api.post(
|
||||
"/api/v1/burst-detection/detect/",
|
||||
buildBurstDetectionRequest(parametersState, NETWORK_NAME),
|
||||
);
|
||||
onResult(response.data as BurstDetectionResult);
|
||||
open?.({
|
||||
key: "burst-detection-analysis-success",
|
||||
type: "success",
|
||||
message: "爆管侦测完成",
|
||||
description: `共识别 ${response.data.summary?.anomaly_day_count ?? 0} 个异常日。`,
|
||||
description: response.data.summary?.burst_detected
|
||||
? "目标时刻存在异常信号,请优先复核相关测点。"
|
||||
: "目标时刻未发现爆管异常。",
|
||||
});
|
||||
} catch (error: any) {
|
||||
open?.({
|
||||
@@ -277,7 +211,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
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>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
@@ -294,211 +228,89 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
数据来源
|
||||
侦测方式
|
||||
</Typography>
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={dataSource}
|
||||
onChange={(e) => handleDataSourceChange(e.target.value as "monitoring" | "simulation")}
|
||||
value={detectionMode}
|
||||
onChange={(event) =>
|
||||
setFormField(
|
||||
"detectionMode",
|
||||
event.target.value as "latest" | "historical",
|
||||
)
|
||||
}
|
||||
>
|
||||
<MenuItem value="monitoring">监测数据</MenuItem>
|
||||
<MenuItem value="simulation">模拟方案</MenuItem>
|
||||
<MenuItem value="latest">检测最新数据</MenuItem>
|
||||
<MenuItem value="historical">历史时刻回放</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{isSimulationMode && (
|
||||
<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
|
||||
dateAdapter={AdapterDayjs}
|
||||
adapterLocale="zh-cn"
|
||||
localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText}
|
||||
>
|
||||
<Box className="grid grid-cols-2 gap-2">
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
侦测开始时间
|
||||
</Typography>
|
||||
<DateTimePicker
|
||||
value={scadaStart}
|
||||
onChange={(value) => setFormField("scadaStart", value)}
|
||||
maxDateTime={scadaEnd ? scadaEnd.subtract(2, "day") : undefined}
|
||||
disabled={isSimulationMode}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<Box className="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-900">
|
||||
当前页面为展示版:手动触发一次侦测,展示异常日、最新测点排名和结果表格,不做定时轮询。
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
border: "1px solid",
|
||||
borderColor: "grey.200",
|
||||
borderRadius: 1,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setFormField("advancedOpen", !advancedOpen)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
setFormField("advancedOpen", !advancedOpen);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
px: 1.25,
|
||||
py: 0.75,
|
||||
cursor: "pointer",
|
||||
backgroundColor: "transparent",
|
||||
"&:hover": { backgroundColor: "action.hover" },
|
||||
}}
|
||||
{detectionMode === "historical" ? (
|
||||
<LocalizationProvider
|
||||
dateAdapter={AdapterDayjs}
|
||||
adapterLocale="zh-cn"
|
||||
localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
高级参数
|
||||
</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>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
目标时刻
|
||||
</Typography>
|
||||
<DateTimePicker
|
||||
value={targetTime}
|
||||
onChange={(value) => setFormField("targetTime", value)}
|
||||
maxDateTime={dayjs()}
|
||||
minutesStep={15}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
||||
/>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
</LocalizationProvider>
|
||||
) : null}
|
||||
|
||||
<TextField
|
||||
type="number"
|
||||
label="采样间隔(分钟)"
|
||||
value={samplingIntervalMinutes}
|
||||
onChange={(event) => {
|
||||
setParametersState((previous) => ({
|
||||
...previous,
|
||||
samplingIntervalMinutes: Number(event.target.value),
|
||||
samplingIntervalSource: "manual",
|
||||
}));
|
||||
}}
|
||||
size="small"
|
||||
fullWidth
|
||||
error={!samplingIntervalValid}
|
||||
inputProps={{ min: 1, max: 1440, step: 1 }}
|
||||
helperText={
|
||||
samplingIntervalValid
|
||||
? `${frequencyLoading ? "正在读取 SCADA 频率" : samplingIntervalSource === "metadata" ? "默认取自压力 SCADA 频率" : "已手动设置"},每天 ${1440 / samplingIntervalMinutes} 个采样点`
|
||||
: "请输入能整除 1440 分钟的正整数,例如 1、5、10、15、30 或 60。"
|
||||
}
|
||||
/>
|
||||
|
||||
{detectionMode === "latest" ? (
|
||||
<Box className="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-900">
|
||||
系统自动读取目标时刻及前 14 天同一时刻数据。每天使用截至该时刻的
|
||||
24 小时压力序列提取扰动特征,仅判定目标时刻是否异常。
|
||||
</Box>
|
||||
) : null}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
当前口径:{samplingIntervalMinutes || "-"} 分钟采样、
|
||||
{samplingIntervalValid ? 1440 / samplingIntervalMinutes : "-"} 点/天、14
|
||||
个参考日;缺失数据的测点不会插值,将从本次分析中排除。
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box className="mt-auto pt-3 flex gap-2">
|
||||
<Box className="mt-auto flex gap-2 pt-3">
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
disabled={running}
|
||||
sx={{ textTransform: "none", fontWeight: 500 }}
|
||||
onClick={() => {
|
||||
setParametersState((previous) => ({
|
||||
...previous,
|
||||
schemeName: `Burst_Detection_${Date.now()}`,
|
||||
scadaStart: dayjs().subtract(3, "day"),
|
||||
scadaEnd: dayjs(),
|
||||
mu: 100,
|
||||
pointsPerDay: 96,
|
||||
nEstimators: 50,
|
||||
contaminationInput: "auto",
|
||||
}));
|
||||
}}
|
||||
onClick={() =>
|
||||
setParametersState(createBurstDetectionAnalysisParametersState())
|
||||
}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
@@ -506,11 +318,13 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={!isValid || running}
|
||||
onClick={handleRun}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
sx={{ textTransform: "none", fontWeight: 500 }}
|
||||
onClick={() => void handleRun()}
|
||||
>
|
||||
{running ? <CircularProgress size={20} color="inherit" /> : "开始侦测"}
|
||||
{running
|
||||
? "侦测中..."
|
||||
: detectionMode === "latest"
|
||||
? "侦测最新数据"
|
||||
: "回放目标时刻"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -5,23 +5,23 @@ import { Box, Button, Chip, Tooltip, Typography } from "@mui/material";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import { zhCN } from "@mui/x-data-grid/locales";
|
||||
import {
|
||||
CheckCircleOutline as CheckCircleIcon,
|
||||
ErrorOutline as ErrorOutlineIcon,
|
||||
FormatListBulleted,
|
||||
InfoOutlined as InfoOutlinedIcon,
|
||||
Room as RoomIcon,
|
||||
ShowChart as ShowChartIcon,
|
||||
CheckCircleOutline as CheckCircleIcon,
|
||||
ErrorOutline as ErrorOutlineIcon,
|
||||
} from "@mui/icons-material";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
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 { GeoJSON } from "ol/format";
|
||||
import VectorLayer from "ol/layer/Vector";
|
||||
import VectorSource from "ol/source/Vector";
|
||||
import { Circle, Fill, Stroke, Style } from "ol/style";
|
||||
import { bbox, featureCollection } from "@turf/turf";
|
||||
import { useMap } from "@components/olmap/core/MapComponent";
|
||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||
import { BurstDetectionResult, BurstDetectionRow } from "./types";
|
||||
|
||||
export interface BurstDetectionResultsState {
|
||||
@@ -29,9 +29,7 @@ export interface BurstDetectionResultsState {
|
||||
}
|
||||
|
||||
export const createBurstDetectionResultsState =
|
||||
(): BurstDetectionResultsState => ({
|
||||
selectedDay: null,
|
||||
});
|
||||
(): BurstDetectionResultsState => ({ selectedDay: null });
|
||||
|
||||
interface Props {
|
||||
result: BurstDetectionResult | null;
|
||||
@@ -46,54 +44,33 @@ interface MetricCardProps {
|
||||
tone: "blue" | "orange" | "purple" | "green";
|
||||
}
|
||||
|
||||
const toneStyles: Record<
|
||||
MetricCardProps["tone"],
|
||||
{ bg: string; border: string; text: string; darkText: string }
|
||||
> = {
|
||||
blue: {
|
||||
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 toneStyles: Record<MetricCardProps["tone"], string> = {
|
||||
blue: "border-blue-200 from-blue-50 to-blue-100 text-blue-900",
|
||||
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",
|
||||
green: "border-green-200 from-green-50 to-green-100 text-green-900",
|
||||
};
|
||||
|
||||
const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => {
|
||||
const style = toneStyles[tone];
|
||||
return (
|
||||
<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}`}>
|
||||
{label}
|
||||
const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => (
|
||||
<Box
|
||||
className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${toneStyles[tone]}`}
|
||||
>
|
||||
<Typography variant="caption" className="mb-1 block font-semibold">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" className="font-bold">
|
||||
{value}
|
||||
</Typography>
|
||||
{hint ? (
|
||||
<Typography variant="caption" className="mt-0.5 block opacity-75">
|
||||
{hint}
|
||||
</Typography>
|
||||
<Typography variant="body2" className={`font-bold ${style.darkText}`}>
|
||||
{value}
|
||||
</Typography>
|
||||
{hint ? (
|
||||
<Typography variant="caption" className={`mt-0.5 block text-xs opacity-80 ${style.text}`}>
|
||||
{hint}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const formatDateTime = (value?: string) =>
|
||||
value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
|
||||
|
||||
const EmptyState = () => (
|
||||
<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 variant="body2" className="max-w-xs text-gray-500">
|
||||
提交一次爆管侦测后,这里会展示异常天数、分数趋势、最新测点排名和结果表格。
|
||||
提交侦测后,这里会展示目标时刻状态、前 14 天参考分数和异常测点。
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const getScoreLevel = (score: number) => {
|
||||
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 DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => {
|
||||
const map = useMap();
|
||||
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||
const [internalResultsState, setInternalResultsState] =
|
||||
useState<BurstDetectionResultsState>(createBurstDetectionResultsState);
|
||||
const resultsState = state ?? internalResultsState;
|
||||
const selectedDay = resultsState.selectedDay;
|
||||
const setSelectedDay = (value: number | null) => {
|
||||
const nextState = { ...resultsState, selectedDay: value };
|
||||
if (state === undefined) {
|
||||
setInternalResultsState(nextState);
|
||||
}
|
||||
const [internalState, setInternalState] = useState<BurstDetectionResultsState>(
|
||||
createBurstDetectionResultsState,
|
||||
);
|
||||
const resultsState = state ?? internalState;
|
||||
const setSelectedDay = (selectedDay: number | null) => {
|
||||
const nextState = { selectedDay };
|
||||
if (state === undefined) setInternalState(nextState);
|
||||
onStateChange?.(nextState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const layer = new VectorLayer({
|
||||
source: new VectorSource(),
|
||||
style: new Style({
|
||||
@@ -154,12 +116,11 @@ const DetectionResults: React.FC<Props> = ({
|
||||
properties: {
|
||||
name: "爆管侦测高亮",
|
||||
value: "burst_detection_highlight",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer(layer);
|
||||
highlightLayerRef.current = layer;
|
||||
|
||||
return () => {
|
||||
highlightLayerRef.current = null;
|
||||
map.removeLayer(layer);
|
||||
@@ -173,58 +134,67 @@ const DetectionResults: React.FC<Props> = ({
|
||||
highlightFeatures.forEach((feature) => source.addFeature(feature));
|
||||
}, [highlightFeatures]);
|
||||
|
||||
const defaultSelectedDay = useMemo(
|
||||
() =>
|
||||
result?.summary?.most_anomalous_day ??
|
||||
result?.summary?.latest_day?.Day ??
|
||||
result?.rows[0]?.Day ??
|
||||
null,
|
||||
const sortedRows = useMemo(
|
||||
() => [...(result?.rows ?? [])].sort((a, b) => a.Day - b.Day),
|
||||
[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>(() => {
|
||||
if (!result || activeSelectedDay === null) return null;
|
||||
return result.rows.find((row) => row.Day === activeSelectedDay) ?? null;
|
||||
}, [activeSelectedDay, result]);
|
||||
|
||||
const scoreSeries = useMemo(
|
||||
() =>
|
||||
result?.rows.map((row) => ({
|
||||
value: [row.Day, Number(row.Score.toFixed(4))],
|
||||
const scoreThreshold = result?.summary.score_threshold ?? 0;
|
||||
const scoreSeries = sortedRows.map((row) => ({
|
||||
day: row.Day,
|
||||
value: [
|
||||
timestampForRow(row)
|
||||
? dayjs(timestampForRow(row)).format("MM-DD HH:mm")
|
||||
: `第 ${row.Day} 天`,
|
||||
Number(row.Score.toFixed(4)),
|
||||
],
|
||||
itemStyle: {
|
||||
color: row.IsBurst ? "#ef4444" : row.Score <= -0.2 ? "#f59e0b" : "#10b981",
|
||||
color:
|
||||
row.Role === "target"
|
||||
? row.IsBurst
|
||||
? "#ef4444"
|
||||
: "#2563eb"
|
||||
: "#94a3b8",
|
||||
},
|
||||
})) ?? [],
|
||||
[result],
|
||||
);
|
||||
symbolSize: row.Role === "target" ? 11 : 7,
|
||||
}));
|
||||
|
||||
const rankingSeries = useMemo(
|
||||
() =>
|
||||
[...(result?.summary?.latest_sensor_rankings ?? [])]
|
||||
.sort((a, b) => a.latest_high_frequency_value - b.latest_high_frequency_value)
|
||||
[...(result?.summary.latest_sensor_rankings ?? [])]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.standardized_deviation ?? a.latest_high_frequency_value) -
|
||||
(b.standardized_deviation ?? b.latest_high_frequency_value),
|
||||
)
|
||||
.map((item) => ({
|
||||
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],
|
||||
);
|
||||
|
||||
const locateSensors = async (sensorIds: string[]) => {
|
||||
if (!map || sensorIds.length === 0) return;
|
||||
|
||||
let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat");
|
||||
if (features.length === 0) {
|
||||
features = await queryFeaturesByIds(sensorIds, "geo_junctions");
|
||||
}
|
||||
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 format = new GeoJSON();
|
||||
const geojsonFeatures = features.map((feature) =>
|
||||
format.writeFeatureObject(feature),
|
||||
);
|
||||
// @ts-ignore turf accepts OpenLayers GeoJSON feature objects
|
||||
const extent = bbox(featureCollection(geojsonFeatures));
|
||||
map.getView().fit(extent, {
|
||||
maxZoom: 18,
|
||||
@@ -233,60 +203,50 @@ const DetectionResults: React.FC<Props> = ({
|
||||
});
|
||||
};
|
||||
|
||||
if (!result) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
if (!result) return <EmptyState />;
|
||||
|
||||
const latestDay = result.summary?.latest_day;
|
||||
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 targetRow = sortedRows.find((row) => row.Role === "target") ?? sortedRows.at(-1);
|
||||
const isBurstDetected = result.summary.burst_detected;
|
||||
const targetRank = result.summary.target_rank;
|
||||
const excludedCount = result.data_quality?.excluded_sensors.length ?? 0;
|
||||
|
||||
const chartOption = {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
formatter: (params: Array<{ data: { value: [number, number] } }>) => {
|
||||
const point = params[0]?.data?.value;
|
||||
if (!point) return "-";
|
||||
return `侦测日第 ${point[0]} 天<br/>异常分数:${point[1]}`;
|
||||
formatter: (params: Array<{ data: { day: number; value: [string, number] } }>) => {
|
||||
const data = params[0]?.data;
|
||||
return data
|
||||
? `${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: {
|
||||
type: "category",
|
||||
name: "侦测日",
|
||||
data: result.rows.map((row) => row.Day),
|
||||
axisLabel: { fontSize: 10 },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "异常分数",
|
||||
axisLabel: { fontSize: 10 },
|
||||
name: "同刻日期",
|
||||
boundaryGap: false,
|
||||
data: scoreSeries.map((item) => item.value[0]),
|
||||
axisLabel: { fontSize: 10, interval: 2, rotate: 25 },
|
||||
},
|
||||
yAxis: { type: "value", name: "异常分数", axisLabel: { fontSize: 10 } },
|
||||
series: [
|
||||
{
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbolSize: 8,
|
||||
data: scoreSeries,
|
||||
lineStyle: { color: "#2563eb", width: 2 },
|
||||
lineStyle: { color: "#94a3b8", width: 2 },
|
||||
markLine: {
|
||||
symbol: "none",
|
||||
lineStyle: { type: "dashed", color: "#94a3b8" },
|
||||
data: [{ yAxis: 0 }],
|
||||
lineStyle: { type: "dashed", color: "#ef4444" },
|
||||
data: [{ yAxis: scoreThreshold, name: "报警阈值" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const rankingOption = {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "shadow" },
|
||||
},
|
||||
grid: { top: 20, left: 70, right: 20, bottom: 20 },
|
||||
xAxis: { type: "value", axisLabel: { fontSize: 10 } },
|
||||
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
|
||||
grid: { top: 12, left: 82, right: 20, bottom: 25 },
|
||||
xAxis: { type: "value", name: "标准化偏离", axisLabel: { fontSize: 10 } },
|
||||
yAxis: {
|
||||
type: "category",
|
||||
data: rankingSeries.map((item) => item.name),
|
||||
@@ -297,9 +257,7 @@ const DetectionResults: React.FC<Props> = ({
|
||||
type: "bar",
|
||||
data: rankingSeries.map((item) => ({
|
||||
value: item.value,
|
||||
itemStyle: {
|
||||
color: item.value <= -0.6 ? "#ef4444" : item.value <= -0.2 ? "#f59e0b" : "#10b981",
|
||||
},
|
||||
itemStyle: { color: item.value < 0 ? "#ef4444" : "#f59e0b" },
|
||||
})),
|
||||
barWidth: 14,
|
||||
},
|
||||
@@ -308,323 +266,176 @@ const DetectionResults: React.FC<Props> = ({
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "Day",
|
||||
headerName: "侦测日",
|
||||
width: 96,
|
||||
valueFormatter: (value?: number) => (typeof value === "number" ? `第 ${value} 天` : "-"),
|
||||
field: "Timestamp",
|
||||
headerName: "同刻日期",
|
||||
minWidth: 145,
|
||||
flex: 1,
|
||||
valueGetter: (_value, row) => formatDateTime(timestampForRow(row)),
|
||||
},
|
||||
{
|
||||
field: "Role",
|
||||
headerName: "角色",
|
||||
width: 90,
|
||||
valueFormatter: (value?: string) => (value === "target" ? "目标" : "参考"),
|
||||
},
|
||||
{
|
||||
field: "Score",
|
||||
headerName: "异常分数",
|
||||
width: 120,
|
||||
valueFormatter: (value?: number) => (typeof value === "number" ? value.toFixed(4) : "-"),
|
||||
width: 110,
|
||||
valueFormatter: (value?: number) =>
|
||||
typeof value === "number" ? value.toFixed(4) : "-",
|
||||
},
|
||||
{
|
||||
field: "IsBurst",
|
||||
headerName: "判定结果",
|
||||
width: 120,
|
||||
renderCell: ({ value }) => {
|
||||
const level = value ? { label: "爆管异常", color: "error" as const } : { label: "正常", color: "success" as const };
|
||||
return <Chip size="small" label={level.label} color={level.color} variant="outlined" />;
|
||||
},
|
||||
headerName: "目标判定",
|
||||
width: 110,
|
||||
renderCell: ({ value, row }) =>
|
||||
row.Role === "target" || row.Day === result.day_count ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={value ? "爆管异常" : "正常"}
|
||||
color={value ? "error" : "success"}
|
||||
variant="outlined"
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
参考
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const rows = result.rows.map((row) => ({ id: row.Day, ...row }));
|
||||
const tableRows = sortedRows.map((row) => ({ id: row.Day, ...row }));
|
||||
|
||||
return (
|
||||
<Box className="h-full overflow-auto p-1">
|
||||
<Box className="mb-4 space-y-3">
|
||||
{/* Status Banner */}
|
||||
<Box
|
||||
className={`rounded-lg px-4 py-3 flex items-center gap-3 border ${isBurstDetected
|
||||
? "bg-red-50 border-red-100 text-red-900"
|
||||
: "bg-green-50 border-green-100 text-green-900"
|
||||
}`}
|
||||
className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
|
||||
isBurstDetected
|
||||
? "border-red-100 bg-red-50 text-red-900"
|
||||
: "border-green-100 bg-green-50 text-green-900"
|
||||
}`}
|
||||
>
|
||||
{isBurstDetected ? (
|
||||
<ErrorOutlineIcon className="text-red-600" />
|
||||
) : (
|
||||
<CheckCircleIcon className="text-green-600" />
|
||||
)}
|
||||
{isBurstDetected ? <ErrorOutlineIcon /> : <CheckCircleIcon />}
|
||||
<Box className="flex-1">
|
||||
<Typography variant="subtitle2" className="font-bold">
|
||||
{isBurstDetected
|
||||
? `侦测到异常信号 (共 ${result.summary.anomaly_day_count} 天)`
|
||||
: "未侦测到爆管异常"}
|
||||
{isBurstDetected ? "目标时刻侦测到爆管异常" : "目标时刻未侦测到爆管异常"}
|
||||
</Typography>
|
||||
<Typography variant="caption" className="opacity-80">
|
||||
{isBurstDetected
|
||||
? "建议检查异常日期的压力波动情况"
|
||||
: "当前时间窗口内数据特征平稳,符合历史模式"}
|
||||
目标:{formatDateTime(result.target_time ?? result.summary.target_time)};分数越低越异常
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Header */}
|
||||
<Box className="flex items-center justify-between px-1">
|
||||
<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>
|
||||
</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
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<RoomIcon />}
|
||||
onClick={() =>
|
||||
locateSensors(result.summary.latest_sensor_rankings.map((item) => item.sensor_node).slice(0, 5))
|
||||
}
|
||||
sx={{
|
||||
height: 24,
|
||||
minWidth: 0,
|
||||
padding: "0 8px",
|
||||
borderColor: "#bfdbfe",
|
||||
color: "#2563eb",
|
||||
fontSize: "0.75rem",
|
||||
"&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
|
||||
}}
|
||||
>
|
||||
定位
|
||||
</Button>
|
||||
</Box>
|
||||
<Box className="flex items-center justify-between gap-2 px-1">
|
||||
<Typography variant="h6" className="min-w-0 flex-1 font-bold text-gray-900">
|
||||
爆管侦测结果
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<RoomIcon />}
|
||||
onClick={() =>
|
||||
void locateSensors(
|
||||
result.summary.latest_sensor_rankings
|
||||
.slice(0, 5)
|
||||
.map((item) => item.sensor_node),
|
||||
)
|
||||
}
|
||||
sx={{ flexShrink: 0, whiteSpace: "nowrap" }}
|
||||
>
|
||||
定位异常测点
|
||||
</Button>
|
||||
</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">
|
||||
<MetricCard
|
||||
label="异常天数"
|
||||
value={`${result.summary.anomaly_day_count} / ${result.day_count}`}
|
||||
hint={`异常日:${result.summary.anomaly_days.join(", ") || "无"}`}
|
||||
tone={result.summary.anomaly_day_count > 0 ? "orange" : "green"}
|
||||
label="目标异常分数"
|
||||
value={targetRow ? targetRow.Score.toFixed(4) : "-"}
|
||||
hint={`报警阈值 ≤ ${scoreThreshold.toFixed(2)}`}
|
||||
tone={isBurstDetected ? "orange" : "green"}
|
||||
/>
|
||||
<MetricCard
|
||||
label="最异常日"
|
||||
value={
|
||||
result.summary.burst_detected && result.summary.most_anomalous_day
|
||||
? `第 ${result.summary.most_anomalous_day} 天`
|
||||
: "无"
|
||||
}
|
||||
hint={
|
||||
result.summary.burst_detected && mostAnomalousRow
|
||||
? `分数 ${mostAnomalousRow.Score.toFixed(4)} · ${mostAnomalousLevel.label}`
|
||||
: "-"
|
||||
}
|
||||
label="目标异常排名"
|
||||
value={targetRank ? `${targetRank} / ${result.day_count}` : "-"}
|
||||
hint="在目标日与 14 个参考日中排序"
|
||||
tone="purple"
|
||||
/>
|
||||
<MetricCard
|
||||
label="最新状态"
|
||||
value={latestLevel.label}
|
||||
hint={latestDay ? `第 ${latestDay.Day} 天 · 分数 ${latestDay.Score.toFixed(4)}` : "-"}
|
||||
tone={latestLevel.color === "success" ? "green" : "orange"}
|
||||
label="参考区间"
|
||||
value={`${formatDateTime(result.reference_window?.start)} ~ ${formatDateTime(result.reference_window?.end)}`}
|
||||
hint={`${result.reference_window?.day_count ?? 14} 个同刻参考日`}
|
||||
tone="blue"
|
||||
/>
|
||||
<MetricCard
|
||||
label="测点 / 样本"
|
||||
value={`${result.sensor_nodes.length} / ${result.sample_count}`}
|
||||
hint={`每日采样点数:${result.points_per_day}`}
|
||||
label="有效 / 排除测点"
|
||||
value={`${result.sensor_nodes.length} / ${excludedCount}`}
|
||||
hint={`${result.sampling_interval_minutes ?? 15} 分钟采样,${result.points_per_day} 点/天`}
|
||||
tone="blue"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Score Trend Chart */}
|
||||
<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">
|
||||
<ShowChartIcon className="h-5 w-5 text-blue-600" />
|
||||
<Typography variant="subtitle1" className="font-bold text-gray-800">
|
||||
异常分数趋势
|
||||
<ShowChartIcon className="text-blue-600" />
|
||||
<Typography variant="subtitle1" className="font-bold">
|
||||
15 天同刻异常分数
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tooltip title="分数越小越异常,0 以下通常意味着更值得关注。">
|
||||
<Tooltip title="灰色点为前 14 天参考,最后一个点为本次目标。">
|
||||
<InfoOutlinedIcon fontSize="small" className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ height: 250, px: 1.5, py: 1 }}>
|
||||
<Box sx={{ height: 270, px: 1.5, py: 1 }}>
|
||||
<ReactECharts
|
||||
option={chartOption}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
onEvents={{
|
||||
click: (params: { data?: { value?: [number, number] } }) => {
|
||||
const day = params?.data?.value?.[0];
|
||||
if (typeof day === "number") {
|
||||
setSelectedDay(day);
|
||||
}
|
||||
},
|
||||
click: (params: { data?: { day?: number } }) =>
|
||||
setSelectedDay(params.data?.day ?? null),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Selected Day Interpretation */}
|
||||
{/* <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>
|
||||
{selectedRow ? (
|
||||
<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>
|
||||
{rankingSeries.length > 0 ? (
|
||||
<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 px-4 py-3">
|
||||
<Typography variant="subtitle1" className="font-bold">
|
||||
目标测点压力偏离
|
||||
</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 variant="caption" color="text.secondary">
|
||||
负值越小,压降相对历史越明显
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" className="px-4 py-3 text-gray-500">
|
||||
请在趋势图或表格中选择一天查看详细解释。
|
||||
</Typography>
|
||||
)}
|
||||
</Box> */}
|
||||
<Box sx={{ height: 280, px: 1.5, py: 1 }}>
|
||||
<ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* 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%" }} />
|
||||
</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> */}
|
||||
|
||||
{/* Results Table */}
|
||||
<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">
|
||||
<FormatListBulleted className="h-5 w-5 text-blue-600" />
|
||||
<Typography variant="subtitle1" className="font-bold text-gray-800">
|
||||
结果表格
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
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 className="flex items-center gap-2 border-b border-gray-100 px-4 py-3">
|
||||
<FormatListBulleted className="text-blue-600" />
|
||||
<Typography variant="subtitle1" className="font-bold">
|
||||
同刻对照明细
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ height: 320, px: 1, py: 1 }}>
|
||||
<Box sx={{ height: 360, px: 1, py: 1 }}>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
rows={tableRows}
|
||||
columns={columns}
|
||||
columnBufferPx={100}
|
||||
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
|
||||
initialState={{
|
||||
pagination: { paginationModel: { pageSize: 50, 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" },
|
||||
}}
|
||||
pageSizeOptions={[15]}
|
||||
initialState={{ pagination: { paginationModel: { pageSize: 15, page: 0 } } }}
|
||||
disableRowSelectionOnClick
|
||||
onRowClick={(params) => setSelectedDay(Number(params.row.Day))}
|
||||
getRowClassName={(params) =>
|
||||
params.row.Day === resultsState.selectedDay ? "bg-blue-50" : ""
|
||||
}
|
||||
sx={{ border: "none" }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -69,6 +69,16 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||
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 = (
|
||||
scheme: Pick<BurstDetectionSchemeRecord, "scheme_name" | "username" | "create_time">,
|
||||
@@ -105,6 +115,12 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
username: payload?.username ?? scheme.username,
|
||||
create_time: payload?.create_time ?? scheme.create_time,
|
||||
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 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">
|
||||
<Typography variant="body2">暂无侦测方案</Typography>
|
||||
<Typography variant="caption" className="mt-1">
|
||||
@@ -224,9 +240,9 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
) : (
|
||||
<Box className="space-y-2 p-2">
|
||||
<Typography variant="caption" className="px-2 text-gray-500">
|
||||
共 {schemes.length} 条记录
|
||||
共 {sortedSchemes.length} 条记录
|
||||
</Typography>
|
||||
{schemes.map((scheme) => {
|
||||
{sortedSchemes.map((scheme) => {
|
||||
const summary = scheme.scheme_detail?.result_summary;
|
||||
const payload = scheme.scheme_detail?.result_payload;
|
||||
const isBurst = payload?.summary?.burst_detected ?? summary?.burst_detected ?? false;
|
||||
@@ -235,6 +251,9 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
const mostAnomalousDay =
|
||||
payload?.summary?.most_anomalous_day ?? summary?.most_anomalous_day ?? "-";
|
||||
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 (
|
||||
<Card key={scheme.scheme_id} variant="outlined" className="transition-shadow hover:shadow-md">
|
||||
@@ -283,30 +302,36 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
<Box className="grid grid-cols-3 gap-2">
|
||||
<Box className="rounded bg-gray-50 p-2">
|
||||
<Typography variant="caption" className="text-gray-500">
|
||||
异常天数
|
||||
{targetTime ? "目标时刻" : "异常天数"}
|
||||
</Typography>
|
||||
<Typography variant="body2" className="font-semibold text-gray-900">
|
||||
{anomalyDayCount}
|
||||
{targetTime ? dayjs(targetTime).format("MM-DD HH:mm") : anomalyDayCount}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box className="rounded bg-gray-50 p-2">
|
||||
<Typography variant="caption" className="text-gray-500">
|
||||
最异常日
|
||||
{targetTime ? "目标分数" : "最异常日"}
|
||||
</Typography>
|
||||
<Typography variant="body2" className="font-semibold text-gray-900">
|
||||
{isBurst
|
||||
? typeof mostAnomalousDay === "number"
|
||||
? `第 ${mostAnomalousDay} 天`
|
||||
: mostAnomalousDay
|
||||
: "无"}
|
||||
{targetTime
|
||||
? typeof targetScore === "number"
|
||||
? targetScore.toFixed(4)
|
||||
: "-"
|
||||
: isBurst
|
||||
? typeof mostAnomalousDay === "number"
|
||||
? `第 ${mostAnomalousDay} 天`
|
||||
: mostAnomalousDay
|
||||
: "无"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box className="rounded bg-gray-50 p-2">
|
||||
<Typography variant="caption" className="text-gray-500">
|
||||
测点数
|
||||
{targetTime ? "异常排名" : "测点数"}
|
||||
</Typography>
|
||||
<Typography variant="body2" className="font-semibold text-gray-900">
|
||||
{sensorCount}
|
||||
{targetTime && targetRank
|
||||
? `${targetRank} / ${payload?.day_count ?? 15}`
|
||||
: sensorCount}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -326,6 +351,8 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
if (ds === "monitoring") return "监测数据";
|
||||
if (os === "simulation_scheme_timerange") return "模拟数据";
|
||||
if (os === "backend_timerange") return "监测数据";
|
||||
if (os === "latest_monitoring") return "最新监测数据";
|
||||
if (os === "historical_monitoring") return "历史监测回放";
|
||||
return os || "-";
|
||||
})()}
|
||||
</Typography>
|
||||
@@ -344,14 +371,16 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
</Box>
|
||||
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
|
||||
<Typography variant="caption" className="text-gray-600">
|
||||
算法参数:
|
||||
侦测口径:
|
||||
</Typography>
|
||||
<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 ??
|
||||
payload?.algorithm_params?.points_per_day ??
|
||||
"-"}
|
||||
点,阈值:
|
||||
{payload?.summary?.score_threshold ?? "-"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -3,11 +3,16 @@ export interface BurstDetectionRow {
|
||||
Score: number;
|
||||
Prediction: number;
|
||||
IsBurst: boolean;
|
||||
Timestamp?: string;
|
||||
Role?: "reference" | "target";
|
||||
}
|
||||
|
||||
export interface BurstDetectionSensorRanking {
|
||||
sensor_node: string;
|
||||
latest_high_frequency_value: number;
|
||||
historical_mean?: number;
|
||||
historical_std?: number;
|
||||
standardized_deviation?: number;
|
||||
}
|
||||
|
||||
export interface BurstDetectionSummary {
|
||||
@@ -17,6 +22,11 @@ export interface BurstDetectionSummary {
|
||||
anomaly_days: number[];
|
||||
anomaly_day_count: number;
|
||||
latest_sensor_rankings: BurstDetectionSensorRanking[];
|
||||
target_score?: number;
|
||||
score_threshold?: number;
|
||||
target_rank?: number;
|
||||
target_time?: string;
|
||||
reference_day_count?: number;
|
||||
}
|
||||
|
||||
export interface BurstDetectionAlgorithmParams {
|
||||
@@ -27,6 +37,7 @@ export interface BurstDetectionAlgorithmParams {
|
||||
contamination?: number | "auto";
|
||||
random_state?: number;
|
||||
};
|
||||
score_threshold?: number;
|
||||
}
|
||||
|
||||
export interface BurstDetectionResult {
|
||||
@@ -51,6 +62,25 @@ export interface BurstDetectionResult {
|
||||
type?: string;
|
||||
};
|
||||
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 {
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface SchemeItem {
|
||||
scheme_start_time: string;
|
||||
scheme_detail?: {
|
||||
modify_total_duration: number;
|
||||
burst_ID?: string[] | string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,6 +129,9 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
});
|
||||
const burstSchemes = (response.data as SchemeItem[]).filter(
|
||||
(scheme) => scheme.scheme_type === "burst_analysis",
|
||||
).sort(
|
||||
(a, b) =>
|
||||
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
|
||||
);
|
||||
|
||||
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?.({
|
||||
key: "burst-location-analysis-success",
|
||||
type: "success",
|
||||
@@ -500,3 +521,11 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
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,
|
||||
TableRow,
|
||||
Button,
|
||||
Link,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
FormatListBulleted,
|
||||
@@ -183,6 +184,7 @@ const LocationResults: React.FC<Props> = ({ result }) => {
|
||||
properties: {
|
||||
name: "爆管定位高亮",
|
||||
value: "burst_location_highlight",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
map.addLayer(layer);
|
||||
@@ -251,6 +253,7 @@ const LocationResults: React.FC<Props> = ({ result }) => {
|
||||
);
|
||||
const sourceLabel = getDataSourceLabel(result);
|
||||
const normalDataDescription = getNormalDataDescription(result);
|
||||
const simulationBurstIds = result.simulation_scheme?.burst_ids ?? [];
|
||||
|
||||
return (
|
||||
<Box className="h-full overflow-auto p-1">
|
||||
@@ -287,6 +290,7 @@ const LocationResults: React.FC<Props> = ({ result }) => {
|
||||
variant="outlined"
|
||||
startIcon={<LocationOnIcon />}
|
||||
onClick={() => locatePipes([result.located_pipe])}
|
||||
disabled={!result.located_pipe}
|
||||
sx={{
|
||||
height: 24,
|
||||
minWidth: 0,
|
||||
@@ -348,6 +352,55 @@ const LocationResults: React.FC<Props> = ({ result }) => {
|
||||
爆管方案: {result.simulation_scheme.name}
|
||||
</Typography>
|
||||
) : 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>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -13,8 +13,12 @@ import {
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Link,
|
||||
} 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 { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
@@ -24,6 +28,14 @@ import { useNotification } from "@refinedev/core";
|
||||
import { api } from "@/lib/api";
|
||||
import { NETWORK_NAME, config } from "@config/config";
|
||||
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 {
|
||||
BurstLocationResult,
|
||||
BurstLocationSchemeDetail,
|
||||
@@ -43,6 +55,7 @@ export interface BurstLocationSchemeQueryState {
|
||||
queryAll: boolean;
|
||||
queryDate: Dayjs | null;
|
||||
expandedId: number | null;
|
||||
simulationBurstIdsByName: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export const createBurstLocationSchemeQueryState =
|
||||
@@ -50,6 +63,7 @@ export const createBurstLocationSchemeQueryState =
|
||||
queryAll: true,
|
||||
queryDate: dayjs(),
|
||||
expandedId: null,
|
||||
simulationBurstIdsByName: {},
|
||||
});
|
||||
|
||||
const SchemeQuery: React.FC<Props> = ({
|
||||
@@ -60,16 +74,117 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
onStateChange,
|
||||
}) => {
|
||||
const { open } = useNotification();
|
||||
const map = useMap();
|
||||
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||
const [queryState, , setQueryField] = useControllableObjectState(
|
||||
state,
|
||||
onStateChange,
|
||||
createBurstLocationSchemeQueryState(),
|
||||
);
|
||||
const { queryAll, queryDate, expandedId } = queryState;
|
||||
const simulationBurstIdsByName = queryState.simulationBurstIdsByName ?? {};
|
||||
const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||
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 = (
|
||||
scheme: Pick<BurstSchemeRecord, "scheme_name" | "username" | "create_time">,
|
||||
@@ -115,9 +230,30 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
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[];
|
||||
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?.({
|
||||
type: "success",
|
||||
message: "查询成功",
|
||||
@@ -157,7 +293,7 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
if (!normalizedResult) {
|
||||
throw new Error("方案详情缺少定位结果数据");
|
||||
}
|
||||
onViewResult(normalizedResult);
|
||||
onViewResult(enrichResultWithSimulationBurstIds(normalizedResult));
|
||||
open?.({
|
||||
type: "success",
|
||||
message: "方案加载成功",
|
||||
@@ -211,7 +347,7 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
<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="mb-4">
|
||||
<svg
|
||||
@@ -248,12 +384,13 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
) : (
|
||||
<Box className="space-y-2 p-2">
|
||||
<Typography variant="caption" className="text-gray-500 px-2">
|
||||
共 {schemes.length} 条记录
|
||||
共 {sortedSchemes.length} 条记录
|
||||
</Typography>
|
||||
{schemes.map((scheme) => {
|
||||
{sortedSchemes.map((scheme) => {
|
||||
const summary = scheme.scheme_detail?.result_summary;
|
||||
const payload = scheme.scheme_detail?.result_payload;
|
||||
const locatedPipe = payload?.located_pipe ?? summary?.located_pipe ?? "-";
|
||||
const simulationBurstIds = getSimulationBurstIds(payload);
|
||||
const leakage =
|
||||
payload?.burst_leakage ?? scheme.scheme_detail?.algorithm_params?.burst_leakage;
|
||||
|
||||
@@ -330,6 +467,52 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
{locatedPipe}
|
||||
</Typography>
|
||||
</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">
|
||||
<Typography variant="caption" className="text-gray-600">
|
||||
漏损量:
|
||||
@@ -373,3 +556,46 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
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?: {
|
||||
name?: string;
|
||||
type?: string;
|
||||
burst_ids?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -198,6 +198,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
properties: {
|
||||
name: "高亮管道",
|
||||
value: "highlight_pipeline",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ const LocationResults: React.FC<LocationResultsProps> = ({
|
||||
properties: {
|
||||
name: "爆管管段高亮",
|
||||
value: "burst_pipe_highlight",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ import { Point } from "ol/geom";
|
||||
import { toLonLat } from "ol/proj";
|
||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||
import { SchemaItem, SchemeRecord } from "./types";
|
||||
import {
|
||||
getPipeDiameterDisplay,
|
||||
type PipeDiameterMap,
|
||||
} from "./schemePipeDiameters";
|
||||
|
||||
interface SchemeQueryProps {
|
||||
schemes?: SchemeRecord[];
|
||||
@@ -109,6 +113,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素
|
||||
const [pipeDiametersByScheme, setPipeDiametersByScheme] = useState<
|
||||
Record<number, PipeDiameterMap>
|
||||
>({});
|
||||
const [loadingDiameterByScheme, setLoadingDiameterByScheme] = useState<
|
||||
Record<number, boolean>
|
||||
>({});
|
||||
|
||||
const { open } = useNotification();
|
||||
|
||||
@@ -128,7 +138,13 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
const handleQuery = async () => {
|
||||
@@ -168,6 +184,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
? "没有找到任何方案"
|
||||
: `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`,
|
||||
});
|
||||
} else {
|
||||
open?.({
|
||||
type: "success",
|
||||
message: "查询成功",
|
||||
description: `共找到 ${filteredResults.length} 条方案记录`,
|
||||
});
|
||||
}
|
||||
} catch (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 scheme = filteredSchemes.find((s) => s.id === id);
|
||||
@@ -301,6 +397,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
properties: {
|
||||
name: "爆管管段高亮",
|
||||
value: "burst_pipe_highlight",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -562,7 +659,11 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
variant="caption"
|
||||
className="font-medium text-gray-900"
|
||||
>
|
||||
560 mm
|
||||
{getPipeDiameterDisplay(
|
||||
scheme.schemeDetail?.burst_ID,
|
||||
pipeDiametersByScheme[scheme.id],
|
||||
!!loadingDiameterByScheme[scheme.id],
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box className="flex items-center gap-2">
|
||||
|
||||
@@ -546,6 +546,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
properties: {
|
||||
name: "阀门节点高亮",
|
||||
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 mm;P-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: {
|
||||
name: "污染源节点",
|
||||
value: "contaminant_source_highlight",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
properties: {
|
||||
name: "污染源高亮",
|
||||
value: "contaminant_source_highlight",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -202,7 +203,13 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
const handleQuery = async () => {
|
||||
@@ -243,6 +250,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
? "没有找到任何方案"
|
||||
: `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`,
|
||||
});
|
||||
} else {
|
||||
open?.({
|
||||
type: "success",
|
||||
message: "查询成功",
|
||||
description: `共找到 ${filteredResults.length} 条方案记录`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("查询请求失败:", error);
|
||||
|
||||
@@ -74,18 +74,31 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
advancedOpen,
|
||||
} = parametersState;
|
||||
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(() => {
|
||||
if (!schemeName.trim() || !startTime || !endTime) return false;
|
||||
return startTime.isBefore(endTime) && qSum >= 360;
|
||||
}, [schemeName, startTime, endTime, qSum]);
|
||||
return startTime.isBefore(endTime) && qSumIsValid;
|
||||
}, [schemeName, startTime, endTime, qSumIsValid]);
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!isValid || !startTime || !endTime) {
|
||||
open?.({ type: "error", message: "请完善参数并确认时间范围合法" });
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "请完善参数并确认时间范围合法",
|
||||
description: !qSumIsValid ? `总漏损流量需不小于 360 ${FLOW_DISPLAY_UNIT}` : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setFormField("qSum", parsedQSum);
|
||||
setRunning(true);
|
||||
open?.({
|
||||
key: "dma-leak-analysis-progress",
|
||||
@@ -213,12 +226,22 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
<TextField
|
||||
type="number"
|
||||
size="small"
|
||||
value={qSum}
|
||||
value={qSumInput}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
setFormField("qSum", Number.isNaN(value) ? 1440 : Math.max(360, value));
|
||||
const rawValue = e.target.value;
|
||||
setQSumInput(rawValue);
|
||||
const value = Number(rawValue);
|
||||
if (rawValue.trim() !== "" && Number.isFinite(value)) {
|
||||
setFormField("qSum", value);
|
||||
}
|
||||
}}
|
||||
inputProps={{ min: 360, step: 10 }}
|
||||
error={qSumInput.trim() !== "" && !qSumIsValid}
|
||||
helperText={
|
||||
qSumInput.trim() !== "" && !qSumIsValid
|
||||
? `需不小于 360 ${FLOW_DISPLAY_UNIT}`
|
||||
: " "
|
||||
}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -65,6 +65,16 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
@@ -78,6 +88,21 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
});
|
||||
const nextSchemes = response.data as LeakageSchemeRecord[];
|
||||
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) {
|
||||
open?.({
|
||||
type: "error",
|
||||
@@ -144,7 +169,7 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
<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="mb-4">
|
||||
<svg
|
||||
@@ -181,9 +206,9 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
) : (
|
||||
<Box className="space-y-2 p-2">
|
||||
<Typography variant="caption" className="text-gray-500 px-2">
|
||||
共 {schemes.length} 条记录
|
||||
共 {sortedSchemes.length} 条记录
|
||||
</Typography>
|
||||
{schemes.map((scheme) => (
|
||||
{sortedSchemes.map((scheme) => (
|
||||
<Card key={scheme.scheme_id} variant="outlined" className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-3 pb-2 last:pb-3">
|
||||
<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 VectorTileSource from "ol/source/VectorTile";
|
||||
import { FlatStyleLike } from "ol/style/flat";
|
||||
|
||||
import { config } from "@/config/config";
|
||||
import {
|
||||
VectorTileStyleSession,
|
||||
versionedPropertyCase,
|
||||
} from "@components/olmap/core/vectorTileStyleSession";
|
||||
import { getAreaColor } from "./utils";
|
||||
|
||||
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[] = [];
|
||||
areaIds.forEach((areaId, index) => {
|
||||
fillCases.push(
|
||||
@@ -131,14 +101,34 @@ export const applyJunctionAreaRender = (
|
||||
);
|
||||
|
||||
junctionLayer.set(RENDER_OWNER_KEY, ownerId);
|
||||
junctionLayer.setStyle({
|
||||
...config.MAP_DEFAULT_STYLE,
|
||||
"circle-fill-color": ["case", ...fillCases, defaultFillColor],
|
||||
"circle-stroke-color": ["case", ...fillCases, defaultStrokeColor],
|
||||
} as FlatStyleLike);
|
||||
const session = new VectorTileStyleSession({
|
||||
layer: junctionLayer,
|
||||
source,
|
||||
propertyKey,
|
||||
defaultStyle: config.MAP_DEFAULT_STYLE as FlatStyleLike,
|
||||
buildStyle: (statePropertyKey, versionKey, version) =>
|
||||
({
|
||||
...config.MAP_DEFAULT_STYLE,
|
||||
"circle-fill-color": versionedPropertyCase(
|
||||
statePropertyKey,
|
||||
versionKey,
|
||||
version,
|
||||
fillCases,
|
||||
defaultFillColor,
|
||||
),
|
||||
"circle-stroke-color": versionedPropertyCase(
|
||||
statePropertyKey,
|
||||
versionKey,
|
||||
version,
|
||||
fillCases,
|
||||
defaultStrokeColor,
|
||||
),
|
||||
}) as FlatStyleLike,
|
||||
});
|
||||
session.commit(nodeAreaIndexMap);
|
||||
|
||||
return () => {
|
||||
source.un("tileloadend", listener);
|
||||
session.dispose();
|
||||
if (junctionLayer.get(RENDER_OWNER_KEY) === ownerId) {
|
||||
junctionLayer.unset(RENDER_OWNER_KEY, true);
|
||||
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
|
||||
|
||||
@@ -185,6 +185,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
zIndex: 1000,
|
||||
properties: {
|
||||
name: "FlushingHighlight",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
import {
|
||||
@@ -108,6 +108,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
|
||||
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||
const setSchemes = onSchemesChange || setInternalSchemes;
|
||||
const sortedSchemes = useMemo(
|
||||
() =>
|
||||
schemes
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
moment(b.create_time).valueOf() - moment(a.create_time).valueOf(),
|
||||
),
|
||||
[schemes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
@@ -169,6 +179,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
zIndex: 1000,
|
||||
properties: {
|
||||
name: "FlushingQueryResultHighlight",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -285,6 +296,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
message: "未找到相关方案",
|
||||
description: "请尝试更改查询条件",
|
||||
});
|
||||
} else {
|
||||
open?.({
|
||||
type: "success",
|
||||
message: "查询成功",
|
||||
description: `共找到 ${filteredResults.length} 条方案记录`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("查询请求失败:", error);
|
||||
@@ -387,7 +404,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
|
||||
{/* Results List */}
|
||||
<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="mb-4">
|
||||
<svg
|
||||
@@ -424,9 +441,9 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
) : (
|
||||
<Box className="space-y-2 p-2">
|
||||
<Typography variant="caption" className="text-gray-500 px-2">
|
||||
共 {schemes.length} 条记录
|
||||
共 {sortedSchemes.length} 条记录
|
||||
</Typography>
|
||||
{schemes.map((scheme) => (
|
||||
{sortedSchemes.map((scheme) => (
|
||||
<Card
|
||||
key={scheme.id}
|
||||
variant="outlined"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||
import type VectorTileSource from "ol/source/VectorTile";
|
||||
import type { FlatStyleLike } from "ol/style/flat";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -30,6 +32,11 @@ import { FiSkipBack, FiSkipForward } from "react-icons/fi";
|
||||
import { config, NETWORK_NAME } from "@/config/config";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
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 {
|
||||
PredictionResult,
|
||||
@@ -38,10 +45,11 @@ import {
|
||||
RISK_BREAKS,
|
||||
} 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 roundedMinutes = Math.floor(minutes / 15) * 15;
|
||||
const roundedMinutes = Math.floor(minutes / safeStep) * safeStep;
|
||||
const roundedDate = new Date(date);
|
||||
roundedDate.setHours(
|
||||
Math.floor(roundedMinutes / 60),
|
||||
@@ -52,6 +60,50 @@ const getRoundedDate = (date: Date): Date => {
|
||||
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 {
|
||||
schemeDate?: Date;
|
||||
timeRange?: { start: Date; end: Date };
|
||||
@@ -91,9 +143,10 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒
|
||||
const [isPredicting, setIsPredicting] = useState<boolean>(false);
|
||||
const [sliderPreviewYear, setSliderPreviewYear] = useState<number | null>(null);
|
||||
const { stepMinutes } = useTimelineTimeConfig();
|
||||
|
||||
// 使用 ref 存储当前的健康数据,供事件监听器读取,避免重复绑定
|
||||
const healthDataRef = useRef<Map<string, number>>(new Map());
|
||||
const healthStyleSessionRef = useRef<VectorTileStyleSession | null>(null);
|
||||
|
||||
// 计算时间轴范围 (4-73)
|
||||
const minTime = 4;
|
||||
@@ -102,9 +155,6 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 添加防抖引用
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 时间刻度数组 (4-73,每3个单位一个刻度)
|
||||
const valueMarks = Array.from({ length: 24 }, (_, i) => ({
|
||||
value: 4 + i * 3,
|
||||
@@ -126,15 +176,18 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
if (value < minTime || value > maxTime) {
|
||||
return;
|
||||
}
|
||||
// 防抖设置currentYear,避免频繁触发数据获取
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setCurrentYear(value);
|
||||
}, 500); // 500ms 防抖延迟
|
||||
setSliderPreviewYear(value);
|
||||
},
|
||||
[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]);
|
||||
|
||||
// 日期时间选择处理
|
||||
const handleDateTimeChange = useCallback((newDate: Date | null) => {
|
||||
if (newDate) {
|
||||
setSelectedDateTime(getRoundedDate(newDate));
|
||||
}
|
||||
}, []);
|
||||
const handleDateTimeChange = useCallback(
|
||||
(newDate: Date | null) => {
|
||||
if (newDate) {
|
||||
setSelectedDateTime(getRoundedDate(newDate, stepMinutes));
|
||||
}
|
||||
},
|
||||
[stepMinutes],
|
||||
);
|
||||
|
||||
// 播放间隔改变处理
|
||||
const handleIntervalChange = useCallback(
|
||||
@@ -227,19 +283,16 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
[isPlaying, maxTime, minTime, setCurrentYear],
|
||||
);
|
||||
|
||||
// 组件加载时设置初始时间为当前时间的最近15分钟
|
||||
// 组件加载时设置初始时间为当前时间的配置时间步长
|
||||
useEffect(() => {
|
||||
setSelectedDateTime(getRoundedDate(new Date()));
|
||||
setSelectedDateTime(getRoundedDate(new Date(), stepMinutes));
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [stepMinutes]);
|
||||
|
||||
// 获取地图实例
|
||||
const map = useMap();
|
||||
@@ -255,143 +308,50 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
) ?? null;
|
||||
}, [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(() => {
|
||||
if (!pipeLayer) return;
|
||||
const source = pipeLayer.getSource() as any;
|
||||
const source = pipeLayer.getSource() as VectorTileSource | null;
|
||||
if (!source) return;
|
||||
|
||||
const listener = (event: any) => {
|
||||
const vectorTile = event.tile;
|
||||
const renderFeatures = vectorTile.getFeatures();
|
||||
if (!renderFeatures || renderFeatures.length === 0) return;
|
||||
|
||||
const healthData = healthDataRef.current;
|
||||
renderFeatures.forEach((renderFeature: any) => {
|
||||
const featureId = renderFeature.get("id");
|
||||
const value = healthData.get(featureId);
|
||||
if (value !== undefined) {
|
||||
renderFeature.properties_["healthRisk"] = value;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
source.on("tileloadend", listener);
|
||||
const defaultFlatStyle = config.MAP_DEFAULT_STYLE as FlatStyleLike;
|
||||
healthStyleSessionRef.current?.dispose();
|
||||
healthStyleSessionRef.current = new VectorTileStyleSession({
|
||||
layer: pipeLayer,
|
||||
source,
|
||||
propertyKey: "healthRisk",
|
||||
defaultStyle: defaultFlatStyle,
|
||||
buildStyle: buildHealthRiskStyle,
|
||||
map,
|
||||
buffered: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
source.un("tileloadend", listener);
|
||||
healthStyleSessionRef.current?.dispose();
|
||||
healthStyleSessionRef.current = null;
|
||||
pipeLayer.setStyle(defaultFlatStyle);
|
||||
};
|
||||
}, [pipeLayer]);
|
||||
}, [map, pipeLayer]);
|
||||
|
||||
// 应用样式到管道图层
|
||||
const applyPipeHealthStyle = useCallback(() => {
|
||||
if (!pipeLayer || predictionResults.length === 0) {
|
||||
useEffect(() => {
|
||||
const session = healthStyleSessionRef.current;
|
||||
if (!session) return;
|
||||
if (predictionResults.length === 0) {
|
||||
session.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// 为每条管道计算当前年份的生存概率
|
||||
const pipeHealthData = new Map<string, number>();
|
||||
predictionResults.forEach((result) => {
|
||||
const probability = getSurvivalProbabilityAtYear(
|
||||
result.survival_function,
|
||||
currentYear - 4, // 使用索引 (0-based)
|
||||
currentYear - 4,
|
||||
);
|
||||
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(() => {
|
||||
// 监听地图缩放事件,缩放时停止播放
|
||||
if (map) {
|
||||
@@ -513,7 +473,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
views={["year", "month", "day", "hours", "minutes"]}
|
||||
minutesStep={15}
|
||||
minutesStep={stepMinutes}
|
||||
sx={{ width: 200 }}
|
||||
slotProps={{
|
||||
textField: {
|
||||
@@ -634,12 +594,13 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
|
||||
<Box ref={timelineRef} sx={{ px: 2, position: "relative" }}>
|
||||
<Slider
|
||||
value={currentYear}
|
||||
value={sliderPreviewYear ?? currentYear}
|
||||
min={minTime}
|
||||
max={maxTime} // 4-73的范围
|
||||
step={1} // 每1个单位一个步进
|
||||
marks={valueMarks} // 显示刻度
|
||||
onChange={handleSliderChange}
|
||||
onChangeCommitted={handleSliderChangeCommitted}
|
||||
valueLabelDisplay="auto"
|
||||
sx={{
|
||||
zIndex: 10,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -107,6 +107,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
const schemes =
|
||||
externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||
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) => {
|
||||
@@ -133,6 +143,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
properties: {
|
||||
name: "传感器高亮",
|
||||
value: "sensor_highlight",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -203,6 +214,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
? "没有找到任何方案"
|
||||
: `${queryDate?.format("YYYY-MM-DD")} 没有找到相关方案`,
|
||||
});
|
||||
} else {
|
||||
open?.({
|
||||
type: "success",
|
||||
message: "查询成功",
|
||||
description: `共找到 ${filteredResults.length} 条方案记录`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("查询请求失败:", error);
|
||||
@@ -313,7 +330,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
|
||||
{/* 结果列表 */}
|
||||
<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="mb-4">
|
||||
<svg
|
||||
@@ -350,9 +367,9 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
) : (
|
||||
<Box className="space-y-2 p-2">
|
||||
<Typography variant="caption" className="text-gray-500 px-2">
|
||||
共 {schemes.length} 条记录
|
||||
共 {sortedSchemes.length} 条记录
|
||||
</Typography>
|
||||
{schemes.map((scheme) => (
|
||||
{sortedSchemes.map((scheme) => (
|
||||
<Card
|
||||
key={scheme.id}
|
||||
variant="outlined"
|
||||
|
||||
@@ -557,7 +557,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "数据清洗失败",
|
||||
description: err.response?.data?.message || err.message || "未知错误",
|
||||
description:
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"未知错误",
|
||||
});
|
||||
} finally {
|
||||
setIsCleaning(false);
|
||||
|
||||
@@ -645,7 +645,11 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "数据清洗失败",
|
||||
description: err.response?.data?.message || err.message || "未知错误",
|
||||
description:
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"未知错误",
|
||||
});
|
||||
setIsCleaning(false);
|
||||
}
|
||||
@@ -708,6 +712,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
||||
properties: {
|
||||
name: "SCADA 选中高亮",
|
||||
value: "scada_selected_highlight",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -813,8 +818,12 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
||||
variant="persistent"
|
||||
hideBackdrop
|
||||
sx={{
|
||||
width: isExpanded ? 360 : 0,
|
||||
flexShrink: 0,
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
pointerEvents: "none",
|
||||
"& .MuiDrawer-paper": {
|
||||
width: 360,
|
||||
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)",
|
||||
backdropFilter: "blur(8px)",
|
||||
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",
|
||||
pointerEvents: "auto",
|
||||
"&:hover": {
|
||||
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 },
|
||||
] as const;
|
||||
|
||||
const createTileLayer = (url: string, attributions: string) =>
|
||||
new TileLayer({
|
||||
source: new XYZ({
|
||||
url,
|
||||
tileSize: 512,
|
||||
maxZoom: 20,
|
||||
projection: "EPSG:3857",
|
||||
attributions,
|
||||
}),
|
||||
const createTileSource = (url: string, attributions: string) =>
|
||||
new XYZ({
|
||||
url,
|
||||
tileSize: 512,
|
||||
maxZoom: 20,
|
||||
projection: "EPSG:3857",
|
||||
attributions,
|
||||
});
|
||||
|
||||
const createBaseLayerEntries = () => {
|
||||
const streetsLayer = createTileLayer(
|
||||
export const createBaseLayerSources = () => ({
|
||||
streets: createTileSource(
|
||||
`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>'
|
||||
);
|
||||
const lightMapLayer = createTileLayer(
|
||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
||||
),
|
||||
light: createTileSource(
|
||||
`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>'
|
||||
);
|
||||
const satelliteLayer = createTileLayer(
|
||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
||||
),
|
||||
satellite: createTileSource(
|
||||
`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>'
|
||||
);
|
||||
const satelliteStreetsLayer = createTileLayer(
|
||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
||||
),
|
||||
satelliteStreets: createTileSource(
|
||||
`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>'
|
||||
);
|
||||
|
||||
const tiandituVectorLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
||||
),
|
||||
tiandituVector: 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}`,
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||
}),
|
||||
});
|
||||
const tiandituVectorAnnotationLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
}),
|
||||
tiandituVectorAnnotation: 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}`,
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||
}),
|
||||
});
|
||||
const tiandituImageLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
}),
|
||||
tiandituImage: 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}`,
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||
}),
|
||||
});
|
||||
const tiandituImageAnnotationLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
}),
|
||||
tiandituImageAnnotation: 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}`,
|
||||
projection: "EPSG:3857",
|
||||
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 [
|
||||
{
|
||||
...BASE_LAYER_METADATA[0],
|
||||
layer: lightMapLayer,
|
||||
layer: tileLayer(sources.light),
|
||||
},
|
||||
{
|
||||
...BASE_LAYER_METADATA[1],
|
||||
layer: satelliteLayer,
|
||||
layer: tileLayer(sources.satellite),
|
||||
},
|
||||
{
|
||||
...BASE_LAYER_METADATA[2],
|
||||
layer: satelliteStreetsLayer,
|
||||
layer: tileLayer(sources.satelliteStreets),
|
||||
},
|
||||
{
|
||||
...BASE_LAYER_METADATA[3],
|
||||
layer: streetsLayer,
|
||||
layer: tileLayer(sources.streets),
|
||||
},
|
||||
{
|
||||
...BASE_LAYER_METADATA[4],
|
||||
layer: new Group({
|
||||
layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer],
|
||||
layers: [
|
||||
tileLayer(sources.tiandituVector),
|
||||
tileLayer(sources.tiandituVectorAnnotation),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
...BASE_LAYER_METADATA[5],
|
||||
layer: new Group({
|
||||
layers: [tiandituImageLayer, tiandituImageAnnotationLayer],
|
||||
layers: [
|
||||
tileLayer(sources.tiandituImage),
|
||||
tileLayer(sources.tiandituImageAnnotation),
|
||||
],
|
||||
}),
|
||||
},
|
||||
].map((entry) => ({
|
||||
@@ -130,6 +131,7 @@ const BaseLayers: React.FC = () => {
|
||||
if (data?.maps?.length) return data.maps;
|
||||
return map ? [map] : [];
|
||||
}, [data?.maps, map]);
|
||||
const sharedSources = useMemo(() => createBaseLayerSources(), []);
|
||||
const layerSetsRef = useRef(new WeakMap<OlMap, ReturnType<typeof createBaseLayerEntries>>());
|
||||
const [isShow, setShow] = useState(false);
|
||||
const [isExpanded, setExpanded] = useState(false);
|
||||
@@ -139,7 +141,7 @@ const BaseLayers: React.FC = () => {
|
||||
maps.forEach((targetMap) => {
|
||||
let layerEntries = layerSetsRef.current.get(targetMap);
|
||||
if (!layerEntries) {
|
||||
layerEntries = createBaseLayerEntries();
|
||||
layerEntries = createBaseLayerEntries(sharedSources);
|
||||
layerSetsRef.current.set(targetMap, layerEntries);
|
||||
}
|
||||
|
||||
@@ -151,7 +153,7 @@ const BaseLayers: React.FC = () => {
|
||||
layerInfo.layer.setVisible(layerInfo.id === activeId);
|
||||
});
|
||||
});
|
||||
}, [activeId, maps]);
|
||||
}, [activeId, maps, sharedSources]);
|
||||
|
||||
const changeMapLayers = (id: string) => {
|
||||
maps.forEach((targetMap) => {
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import Draggable from "react-draggable";
|
||||
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 {
|
||||
label: string;
|
||||
@@ -20,7 +30,29 @@ interface TableProperty {
|
||||
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 {
|
||||
id?: string;
|
||||
@@ -36,6 +68,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const [draftValues, setDraftValues] = useState<Record<string, string>>({});
|
||||
const headerActionSx = {
|
||||
color: "common.white",
|
||||
backgroundColor: "rgba(255,255,255,0.08)",
|
||||
@@ -56,6 +89,26 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
||||
|
||||
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
|
||||
? 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 isImportant = isImportantKeys.includes(base.label);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,50 +1,38 @@
|
||||
import React from "react";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
import StyleEditorForm from "./StyleEditorForm";
|
||||
import { createDefaultLayerStyleState, createDefaultLayerStyleStates } from "./styleEditorPresets";
|
||||
import { LayerStyleState, StyleConfig, StyleEditorPanelProps } from "./styleEditorTypes";
|
||||
import type { StyleEditorPanelProps } from "./styleEditorTypes";
|
||||
|
||||
const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
||||
isReady,
|
||||
renderLayers,
|
||||
selectedRenderLayer,
|
||||
styleConfig,
|
||||
setStyleConfig,
|
||||
availableProperties,
|
||||
onLayerChange,
|
||||
onPropertyChange,
|
||||
onClassificationMethodChange,
|
||||
onSegmentsChange,
|
||||
onCustomBreakChange,
|
||||
onCustomBreakBlur,
|
||||
onColorTypeChange,
|
||||
onApply,
|
||||
onReset,
|
||||
...formProps
|
||||
}) => {
|
||||
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 (
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
return <StyleEditorForm {...formProps} />;
|
||||
};
|
||||
|
||||
export default StyleEditorPanel;
|
||||
export type { LayerStyleState, StyleConfig } from "./styleEditorTypes";
|
||||
export { createDefaultLayerStyleState, createDefaultLayerStyleStates };
|
||||
|
||||
@@ -7,34 +7,31 @@ interface LegendStyleConfig {
|
||||
layerId: string;
|
||||
property: string;
|
||||
colors: string[];
|
||||
type: string; // 图例类型
|
||||
dimensions: number[]; // 尺寸大小
|
||||
breaks: number[]; // 分段值
|
||||
labels?: string[]; // 可选标签(用于离散分类)
|
||||
type: string;
|
||||
dimensions: number[];
|
||||
breaks: number[];
|
||||
labels?: string[];
|
||||
columns?: number;
|
||||
itemsPerColumn?: number;
|
||||
}
|
||||
// 图例组件
|
||||
// 该组件用于显示图层样式的图例,包含属性名称、颜色、尺寸和分段值等信息
|
||||
// 通过传入的配置对象动态生成图例内容,适用于不同的样式配置
|
||||
// 使用时需要确保传入的 colors、dimensions 和 breaks 数组长度一致
|
||||
|
||||
const StyleLegend: React.FC<LegendStyleConfig> = ({
|
||||
layerName,
|
||||
layerId,
|
||||
property,
|
||||
colors,
|
||||
type, // 图例类型
|
||||
type,
|
||||
dimensions,
|
||||
breaks,
|
||||
labels,
|
||||
columns = 1,
|
||||
itemsPerColumn,
|
||||
}) => {
|
||||
const itemCount = Math.min(colors.length, dimensions.length, Math.max(0, breaks.length - 1));
|
||||
return (
|
||||
<Box
|
||||
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>
|
||||
{layerName} - {property}
|
||||
@@ -56,39 +53,9 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
|
||||
rowGap: 0.5,
|
||||
}}
|
||||
>
|
||||
{[...Array(breaks.length)].map((_, index) => {
|
||||
const color = colors[index]; // 默认颜色为黑色
|
||||
const dimension = dimensions[index]; // 默认尺寸为16
|
||||
|
||||
// // 处理第一个区间(小于 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])
|
||||
{Array.from({ length: itemCount }, (_, index) => {
|
||||
const color = colors[index];
|
||||
const dimension = dimensions[index];
|
||||
if (index + 1 < breaks.length) {
|
||||
const prevValue = breaks[index];
|
||||
const currentValue = breaks[index + 1];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"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 Draggable from "react-draggable";
|
||||
|
||||
@@ -30,6 +30,13 @@ import { useData } from "../MapComponent";
|
||||
import { config, NETWORK_NAME } from "@/config/config";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { useMap } from "../MapComponent";
|
||||
import {
|
||||
formatTimelineTime,
|
||||
getRoundedCurrentTimelineMinutes,
|
||||
getTimelineDisabledRangePercentages,
|
||||
normalizeTimelineMinutes,
|
||||
} from "./timelineTime";
|
||||
import { useTimelineTimeConfig } from "./useTimelineTimeConfig";
|
||||
|
||||
interface TimelineProps {
|
||||
schemeDate?: Date;
|
||||
@@ -58,6 +65,13 @@ const timelineIconButtonSx = {
|
||||
|
||||
const NOOP_SET_CURRENT_TIME = (_: 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> = ({
|
||||
schemeDate,
|
||||
@@ -87,18 +101,39 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
const pipeText = data?.pipeText ?? "";
|
||||
const setForceStyleAutoApplyVersion = data?.setForceStyleAutoApplyVersion;
|
||||
const { open } = useNotification();
|
||||
const { durationMinutes, stepMinutes } = useTimelineTimeConfig();
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
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 [sliderPreviewTime, setSliderPreviewTime] = useState<number | null>(null);
|
||||
|
||||
// 计算时间轴范围
|
||||
const minTime = timeRange
|
||||
? timeRange.start.getHours() * 60 + timeRange.start.getMinutes()
|
||||
? normalizeTimelineMinutes(
|
||||
timeRange.start.getHours() * 60 + timeRange.start.getMinutes(),
|
||||
0,
|
||||
durationMinutes,
|
||||
)
|
||||
: 0;
|
||||
const maxTime = timeRange
|
||||
? timeRange.end.getHours() * 60 + timeRange.end.getMinutes()
|
||||
: 1440;
|
||||
? normalizeTimelineMinutes(
|
||||
timeRange.end.getHours() * 60 + timeRange.end.getMinutes(),
|
||||
0,
|
||||
durationMinutes,
|
||||
)
|
||||
: durationMinutes;
|
||||
const timelineCurrentTime = normalizeTimelineMinutes(
|
||||
currentTime,
|
||||
minTime,
|
||||
maxTime,
|
||||
);
|
||||
const disabledRangePercentages = getTimelineDisabledRangePercentages(
|
||||
minTime,
|
||||
maxTime,
|
||||
durationMinutes,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (schemeDate) {
|
||||
setSelectedDate(schemeDate);
|
||||
@@ -112,8 +147,8 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
// 添加缓存引用
|
||||
const nodeCacheRef = useRef<Map<string, any[]>>(new Map());
|
||||
const linkCacheRef = useRef<Map<string, any[]>>(new Map());
|
||||
// 添加防抖引用
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const frameRequestRevisionRef = useRef(0);
|
||||
const frameAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const updateDataStates = useCallback(
|
||||
(
|
||||
@@ -168,6 +203,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
target,
|
||||
schemeName,
|
||||
schemeType,
|
||||
signal,
|
||||
}: {
|
||||
queryTime: Date;
|
||||
junctionProperties: string;
|
||||
@@ -176,6 +212,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
target: "primary" | "compare";
|
||||
schemeName?: string;
|
||||
schemeType?: string;
|
||||
signal?: AbortSignal;
|
||||
}) => {
|
||||
const query_time = queryTime.toISOString();
|
||||
let nodeRecords: any = { results: [] };
|
||||
@@ -199,10 +236,12 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
nodePromise =
|
||||
sourceType === "scheme" && schemeName
|
||||
? 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(
|
||||
`${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);
|
||||
}
|
||||
@@ -226,10 +265,12 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
linkPromise =
|
||||
sourceType === "scheme" && schemeName
|
||||
? 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(
|
||||
`${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);
|
||||
}
|
||||
@@ -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(
|
||||
@@ -288,6 +333,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
schemeName: string,
|
||||
schemeType: string
|
||||
) => {
|
||||
const revision = frameRequestRevisionRef.current + 1;
|
||||
frameRequestRevisionRef.current = revision;
|
||||
frameAbortControllerRef.current?.abort();
|
||||
const abortController = new AbortController();
|
||||
frameAbortControllerRef.current = abortController;
|
||||
const primarySourceType =
|
||||
disableDateSelection && schemeName ? "scheme" : "realtime";
|
||||
const tasks = [
|
||||
@@ -299,6 +349,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
target: "primary",
|
||||
schemeName,
|
||||
schemeType,
|
||||
signal: abortController.signal,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -310,37 +361,62 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
pipeProperties,
|
||||
sourceType: "realtime",
|
||||
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 hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return `${hours.toString().padStart(2, "0")}:${mins
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
const formatTime = useCallback((minutes: number): string => {
|
||||
return formatTimelineTime(minutes, minTime, maxTime);
|
||||
}, [maxTime, minTime]);
|
||||
|
||||
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 hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
const normalizedMinutes = normalizeTimelineMinutes(minutes, minTime, maxTime);
|
||||
const hours = Math.floor(normalizedMinutes / 60);
|
||||
const mins = normalizedMinutes % 60;
|
||||
date.setHours(hours, mins, 0, 0);
|
||||
return date;
|
||||
}
|
||||
}, [maxTime, minTime]);
|
||||
|
||||
// 播放时间间隔选项
|
||||
const intervalOptions = [
|
||||
@@ -350,13 +426,31 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
{ value: 20000, label: "20秒" },
|
||||
];
|
||||
// 强制计算时间段选项
|
||||
const calculatedIntervalOptions = [
|
||||
{ value: 1440, label: "1 天" },
|
||||
{ value: 60, label: "1 小时" },
|
||||
{ value: 30, label: "30 分钟" },
|
||||
{ value: 15, label: "15 分钟" },
|
||||
{ value: 5, label: "5 分钟" },
|
||||
];
|
||||
const resolvedCalculatedIntervalOptions = useMemo(() => {
|
||||
const options = BASE_CALCULATED_INTERVAL_OPTIONS.filter(
|
||||
(option) => option.value >= stepMinutes,
|
||||
);
|
||||
if (!options.some((option) => option.value === stepMinutes)) {
|
||||
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(
|
||||
@@ -366,15 +460,18 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
if (timeRange && (value < minTime || value > maxTime)) {
|
||||
return;
|
||||
}
|
||||
// 防抖设置currentTime,避免频繁触发数据获取
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setCurrentTime(value);
|
||||
}, 500); // 500ms 防抖延迟
|
||||
setSliderPreviewTime(value);
|
||||
},
|
||||
[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(() => {
|
||||
setCurrentTime((prev) => {
|
||||
let next = prev + 15;
|
||||
if (timeRange) {
|
||||
if (next > maxTime) next = minTime;
|
||||
} else {
|
||||
if (next >= 1440) next = 0;
|
||||
}
|
||||
return next;
|
||||
return advanceTimelineTime(prev);
|
||||
});
|
||||
}, playInterval);
|
||||
}
|
||||
}, [isPlaying, playInterval, timeRange, maxTime, minTime, setCurrentTime]);
|
||||
}, [advanceTimelineTime, isPlaying, playInterval, setCurrentTime]);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
@@ -414,14 +505,14 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
const handleStop = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
// 设置为当前时间
|
||||
const currentTime = new Date();
|
||||
const minutes = currentTime.getHours() * 60 + currentTime.getMinutes();
|
||||
setCurrentTime(minutes); // 组件卸载时重置时间
|
||||
setCurrentTime(
|
||||
getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes),
|
||||
); // 重置为当前时间
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}, [setCurrentTime]);
|
||||
}, [durationMinutes, setCurrentTime, stepMinutes]);
|
||||
|
||||
// 步进控制
|
||||
const handleDayStepBackward = useCallback(() => {
|
||||
@@ -440,27 +531,15 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
}, [setSelectedDate]);
|
||||
const handleStepBackward = useCallback(() => {
|
||||
setCurrentTime((prev) => {
|
||||
let next = prev - 15;
|
||||
if (timeRange) {
|
||||
if (next < minTime) next = maxTime;
|
||||
} else {
|
||||
if (next < 0) next += 1440;
|
||||
}
|
||||
return next;
|
||||
return advanceTimelineTime(prev, -1);
|
||||
});
|
||||
}, [timeRange, minTime, maxTime, setCurrentTime]);
|
||||
}, [advanceTimelineTime, setCurrentTime]);
|
||||
|
||||
const handleStepForward = useCallback(() => {
|
||||
setCurrentTime((prev) => {
|
||||
let next = prev + 15;
|
||||
if (timeRange) {
|
||||
if (next > maxTime) next = minTime;
|
||||
} else {
|
||||
if (next >= 1440) next = 0;
|
||||
}
|
||||
return next;
|
||||
return advanceTimelineTime(prev);
|
||||
});
|
||||
}, [timeRange, minTime, maxTime, setCurrentTime]);
|
||||
}, [advanceTimelineTime, setCurrentTime]);
|
||||
|
||||
// 日期选择处理
|
||||
const handleDateChange = useCallback((newDate: Date | null) => {
|
||||
@@ -480,18 +559,12 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = setInterval(() => {
|
||||
setCurrentTime((prev) => {
|
||||
let next = prev + 15;
|
||||
if (timeRange) {
|
||||
if (next > maxTime) next = minTime;
|
||||
} else {
|
||||
if (next >= 1440) next = 0;
|
||||
}
|
||||
return next;
|
||||
return advanceTimelineTime(prev);
|
||||
});
|
||||
}, newInterval);
|
||||
}
|
||||
},
|
||||
[isPlaying, timeRange, maxTime, minTime, setCurrentTime],
|
||||
[advanceTimelineTime, isPlaying, setCurrentTime],
|
||||
);
|
||||
// 计算时间段改变处理
|
||||
const handleCalculatedIntervalChange = useCallback((event: any) => {
|
||||
@@ -499,6 +572,10 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
setCalculatedInterval(newInterval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCalculatedInterval(stepMinutes);
|
||||
}, [stepMinutes]);
|
||||
|
||||
// 添加 useEffect 来监听 currentTime 和 selectedDate 的变化,并获取数据
|
||||
useEffect(() => {
|
||||
// 首次加载时,如果 selectedDate 或 currentTime 未定义,则跳过执行,避免报错
|
||||
@@ -514,7 +591,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
// return;
|
||||
// }
|
||||
fetchFrameData(
|
||||
currentTimeToDate(selectedDate, currentTime),
|
||||
currentTimeToDate(selectedDate, timelineCurrentTime),
|
||||
junctionText,
|
||||
pipeText,
|
||||
schemeName,
|
||||
@@ -526,6 +603,8 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
junctionText,
|
||||
pipeText,
|
||||
currentTime,
|
||||
currentTimeToDate,
|
||||
timelineCurrentTime,
|
||||
selectedDate,
|
||||
schemeName,
|
||||
schemeType,
|
||||
@@ -534,21 +613,17 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
// 组件卸载时清理定时器和防抖
|
||||
useEffect(() => {
|
||||
// 设置为当前时间
|
||||
const currentTime = new Date();
|
||||
const minutes = currentTime.getHours() * 60 + currentTime.getMinutes();
|
||||
// 找到最近的前15分钟刻度
|
||||
const roundedMinutes = Math.floor(minutes / 15) * 15;
|
||||
setCurrentTime(roundedMinutes); // 组件卸载时重置时间
|
||||
setCurrentTime(
|
||||
getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes),
|
||||
); // 初始化为当前时间
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
frameAbortControllerRef.current?.abort();
|
||||
};
|
||||
}, [setCurrentTime]);
|
||||
}, [durationMinutes, setCurrentTime, stepMinutes]);
|
||||
|
||||
// 当 timeRange 改变时,设置 currentTime 到 minTime
|
||||
useEffect(() => {
|
||||
@@ -603,7 +678,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
clearCache(linkCacheRef);
|
||||
// 重新获取当前时刻的新数据
|
||||
fetchFrameData(
|
||||
currentTimeToDate(selectedDate, currentTime),
|
||||
currentTimeToDate(selectedDate, timelineCurrentTime),
|
||||
junctionText,
|
||||
pipeText,
|
||||
schemeName,
|
||||
@@ -622,7 +697,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
|
||||
// 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变
|
||||
const calculationDate = selectedDate;
|
||||
const calculationTime = currentTime;
|
||||
const calculationTime = timelineCurrentTime;
|
||||
const calculationDateTime = currentTimeToDate(
|
||||
calculationDate,
|
||||
calculationTime
|
||||
@@ -690,7 +765,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
<Draggable nodeRef={draggableRef} handle=".drag-handle">
|
||||
<div
|
||||
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">
|
||||
<Paper
|
||||
@@ -860,7 +935,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
label="强制计算时间段"
|
||||
onChange={handleCalculatedIntervalChange}
|
||||
>
|
||||
{calculatedIntervalOptions.map((option) => (
|
||||
{resolvedCalculatedIntervalOptions.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
@@ -890,18 +965,19 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
color: "primary.main",
|
||||
}}
|
||||
>
|
||||
{formatTime(currentTime)}
|
||||
{formatTime(timelineCurrentTime)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Box ref={timelineRef} sx={{ px: 2, position: "relative" }}>
|
||||
<Slider
|
||||
value={currentTime}
|
||||
value={sliderPreviewTime ?? timelineCurrentTime}
|
||||
min={0}
|
||||
max={1440} // 24:00 = 1440分钟
|
||||
step={15} // 每15分钟一个步进
|
||||
marks={timeMarks.filter((_, index) => index % 12 === 0)} // 每小时显示一个标记
|
||||
max={durationMinutes}
|
||||
step={stepMinutes}
|
||||
marks={timeMarks}
|
||||
onChange={handleSliderChange}
|
||||
onChangeCommitted={handleSliderChangeCommitted}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={formatTime}
|
||||
sx={{
|
||||
@@ -942,43 +1018,46 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
/>
|
||||
{/* 禁用区域遮罩 */}
|
||||
{timeRange && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: "30%",
|
||||
transform: "translateY(-50%)",
|
||||
height: "20px",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{/* 左侧禁用区域 */}
|
||||
{minTime > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: "14px",
|
||||
top: "30%",
|
||||
transform: "translateY(-50%)",
|
||||
width: `${(minTime / 1440) * 856 + 2}px`,
|
||||
height: "20px",
|
||||
left: 0,
|
||||
width: `${disabledRangePercentages.leftWidth}%`,
|
||||
height: "100%",
|
||||
backgroundColor: "rgba(189, 189, 189, 0.4)",
|
||||
pointerEvents: "none",
|
||||
backdropFilter: "blur(1px)",
|
||||
borderRadius: "2.5px",
|
||||
rounded: "true",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* 右侧禁用区域 */}
|
||||
{maxTime < 1440 && (
|
||||
{maxTime < durationMinutes && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: `${16 + (maxTime / 1440) * 856}px`,
|
||||
top: "30%",
|
||||
transform: "translateY(-50%)",
|
||||
width: `${((1440 - maxTime) / 1440) * 856}px`,
|
||||
height: "20px",
|
||||
left: `${disabledRangePercentages.rightLeft}%`,
|
||||
width: `${disabledRangePercentages.rightWidth}%`,
|
||||
height: "100%",
|
||||
backgroundColor: "rgba(189, 189, 189, 0.4)",
|
||||
pointerEvents: "none",
|
||||
backdropFilter: "blur(1px)",
|
||||
borderRadius: "2.5px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -26,7 +26,8 @@ import {
|
||||
import { useToolbarChatActions } from "./useToolbarChatActions";
|
||||
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";
|
||||
|
||||
// 添加接口定义隐藏按钮的props
|
||||
@@ -37,6 +38,52 @@ interface ToolbarProps {
|
||||
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
|
||||
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> = ({
|
||||
hiddenButtons,
|
||||
queryType,
|
||||
@@ -46,6 +93,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
}) => {
|
||||
const map = useMap();
|
||||
const data = useData();
|
||||
const project = useProject();
|
||||
const { open } = useNotification();
|
||||
const [activeTools, setActiveTools] = useState<string[]>([]);
|
||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||
@@ -58,6 +106,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
const currentTime = data?.currentTime;
|
||||
const selectedDate = data?.selectedDate;
|
||||
const schemeName = data?.schemeName;
|
||||
const networkName = project?.networkName || NETWORK_NAME;
|
||||
const isCompareMode = data?.isCompareMode ?? false;
|
||||
const toggleCompareMode = data?.toggleCompareMode;
|
||||
const canToggleCompare = Boolean(
|
||||
@@ -89,6 +138,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
const styleEditor = useStyleEditor({
|
||||
layerStyleStates,
|
||||
setLayerStyleStates,
|
||||
workspace: project?.workspace || config.MAP_WORKSPACE,
|
||||
});
|
||||
|
||||
useToolbarChatActions({
|
||||
@@ -140,6 +190,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
properties: {
|
||||
name: "属性查询高亮图层", // 设置图层名称
|
||||
value: "info_highlight_layer",
|
||||
queryable: false,
|
||||
type: "multigeometry",
|
||||
properties: [],
|
||||
},
|
||||
@@ -325,6 +376,261 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
const [computedProperties, setComputedProperties] = useState<
|
||||
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(() => {
|
||||
if (highlightFeatures.length === 0 || !selectedDate || !showPropertyPanel) {
|
||||
@@ -387,8 +693,43 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
}, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]);
|
||||
|
||||
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) {
|
||||
@@ -463,10 +804,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
onClassificationMethodChange={styleEditor.handleClassificationMethodChange}
|
||||
onSegmentsChange={styleEditor.handleSegmentsChange}
|
||||
onCustomBreakChange={styleEditor.handleCustomBreakChange}
|
||||
onCustomBreakBlur={styleEditor.handleCustomBreakBlur}
|
||||
onColorTypeChange={styleEditor.handleColorTypeChange}
|
||||
onApply={styleEditor.handleApply}
|
||||
onReset={styleEditor.handleReset}
|
||||
validationErrors={styleEditor.validationErrors}
|
||||
isDirty={styleEditor.isDirty}
|
||||
isApplying={styleEditor.isApplying}
|
||||
/>
|
||||
</div>
|
||||
<ToolbarHistoryPanel
|
||||
|
||||
@@ -83,7 +83,7 @@ const DEFAULT_LAYER_STYLE_PRESETS: Record<
|
||||
styleConfig: {
|
||||
property: "pressure",
|
||||
classificationMethod: "custom_breaks",
|
||||
customBreaks: [16, 18, 20, 22, 24, 26],
|
||||
customBreaks: [16, 18, 20, 22, 24, 26, 28],
|
||||
customColors: [
|
||||
"rgba(255, 0, 0, 1)",
|
||||
"rgba(255, 127, 0, 1)",
|
||||
@@ -137,7 +137,7 @@ const DEFAULT_LAYER_STYLE_PRESETS: Record<
|
||||
showId: false,
|
||||
opacity: 0.9,
|
||||
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: [],
|
||||
},
|
||||
legendConfig: {
|
||||
|
||||
@@ -3,16 +3,20 @@ import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||
|
||||
import { LegendStyleConfig } from "./StyleLegend";
|
||||
|
||||
export type ClassificationMethod = "pretty_breaks" | "custom_breaks";
|
||||
export type ColorType = "single" | "gradient" | "rainbow" | "custom";
|
||||
|
||||
export interface StyleConfig {
|
||||
property: string;
|
||||
classificationMethod: string;
|
||||
classificationMethod: ClassificationMethod;
|
||||
/** Number of rendered intervals. Boundaries always contain segments + 1 values. */
|
||||
segments: number;
|
||||
minSize: number;
|
||||
maxSize: number;
|
||||
minStrokeWidth: number;
|
||||
maxStrokeWidth: number;
|
||||
fixedStrokeWidth: number;
|
||||
colorType: string;
|
||||
colorType: ColorType;
|
||||
singlePaletteIndex: number;
|
||||
gradientPaletteIndex: number;
|
||||
rainbowPaletteIndex: number;
|
||||
@@ -24,6 +28,19 @@ export interface StyleConfig {
|
||||
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 {
|
||||
layerId: string;
|
||||
layerName: string;
|
||||
@@ -37,6 +54,7 @@ export type DefaultLayerStyleId = "junctions" | "pipes";
|
||||
export interface StyleEditorStateProps {
|
||||
layerStyleStates: LayerStyleState[];
|
||||
setLayerStyleStates: React.Dispatch<React.SetStateAction<LayerStyleState[]>>;
|
||||
workspace: string;
|
||||
}
|
||||
|
||||
export interface AvailableProperty {
|
||||
@@ -50,15 +68,17 @@ export interface StyleEditorFormProps {
|
||||
styleConfig: StyleConfig;
|
||||
setStyleConfig: React.Dispatch<React.SetStateAction<StyleConfig>>;
|
||||
availableProperties: AvailableProperty[];
|
||||
onLayerChange: (index: number) => void;
|
||||
onLayerChange: (layerId: string) => void;
|
||||
onPropertyChange: (property: string) => void;
|
||||
onClassificationMethodChange: (method: string) => void;
|
||||
onClassificationMethodChange: (method: ClassificationMethod) => void;
|
||||
onSegmentsChange: (segments: number) => void;
|
||||
onCustomBreakChange: (index: number, value: string) => void;
|
||||
onCustomBreakBlur: () => void;
|
||||
onColorTypeChange: (colorType: string) => void;
|
||||
onColorTypeChange: (colorType: ColorType) => void;
|
||||
onApply: () => void;
|
||||
onReset: () => void;
|
||||
validationErrors: string[];
|
||||
isDirty: boolean;
|
||||
isApplying: boolean;
|
||||
}
|
||||
|
||||
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 { parseColor } from "@utils/parseColor";
|
||||
|
||||
import {
|
||||
createDefaultLayerStyleStates,
|
||||
GRADIENT_PALETTES,
|
||||
RAINBOW_PALETTES,
|
||||
SINGLE_COLOR_PALETTES,
|
||||
} 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) => {
|
||||
try {
|
||||
const c = parseColor(rgba);
|
||||
const toHex = (n: number) => {
|
||||
const hex = Math.round(n).toString(16);
|
||||
return hex.length === 1 ? `0${hex}` : hex;
|
||||
};
|
||||
return `#${toHex(c.r)}${toHex(c.g)}${toHex(c.b)}`;
|
||||
const color = parseColor(rgba);
|
||||
const toHex = (value: number) => Math.round(value).toString(16).padStart(2, "0");
|
||||
return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`;
|
||||
} catch {
|
||||
return "#000000";
|
||||
}
|
||||
@@ -28,25 +49,71 @@ export const hexToRgba = (hex: string) => {
|
||||
return result
|
||||
? `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(
|
||||
result[3],
|
||||
16
|
||||
16,
|
||||
)}, 1)`
|
||||
: "rgba(0, 0, 0, 1)";
|
||||
};
|
||||
|
||||
export const getDefaultCustomColors = (
|
||||
segments: number,
|
||||
existingColors: string[] = []
|
||||
existingColors: string[] = [],
|
||||
) => {
|
||||
const nextColors = [...existingColors];
|
||||
const baseColors = RAINBOW_PALETTES[0].colors;
|
||||
|
||||
while (nextColors.length < segments) {
|
||||
nextColors.push(baseColors[nextColors.length % baseColors.length]);
|
||||
}
|
||||
|
||||
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 = ({
|
||||
segments,
|
||||
property,
|
||||
@@ -64,261 +131,293 @@ export const getDefaultCustomBreaks = ({
|
||||
currentJunctionCalData?: any[];
|
||||
currentPipeCalData?: any[];
|
||||
}) => {
|
||||
if (!layerId || !property) {
|
||||
return Array.from({ length: segments }, () => 0);
|
||||
let values: number[] = [];
|
||||
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));
|
||||
}
|
||||
|
||||
let dataArr: number[] = [];
|
||||
|
||||
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 finiteValues = values.filter(Number.isFinite);
|
||||
if (!property || finiteValues.length === 0) {
|
||||
return Array.from({ length: segments + 1 }, (_, index) => index);
|
||||
}
|
||||
|
||||
if (dataArr.length === 0) {
|
||||
return Array.from({ length: segments }, () => 0);
|
||||
const minimum = Math.min(...finiteValues);
|
||||
const maximum = Math.max(...finiteValues);
|
||||
if (minimum === maximum) {
|
||||
const padding = Math.max(Math.abs(minimum) * 0.01, 1);
|
||||
return createEqualBoundaries(minimum - padding, maximum + padding, segments);
|
||||
}
|
||||
|
||||
const defaultBreaks = calculateClassification(
|
||||
dataArr,
|
||||
return normalizeCalculatedBoundaries(
|
||||
calculateClassification(finiteValues, segments, "pretty_breaks"),
|
||||
finiteValues,
|
||||
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 = (
|
||||
styleConfig: StyleConfig,
|
||||
breaksLength: number
|
||||
classCount = styleConfig.segments,
|
||||
): string[] => {
|
||||
if (styleConfig.colorType === "single") {
|
||||
return Array.from(
|
||||
{ length: breaksLength },
|
||||
() => SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color
|
||||
);
|
||||
const palette = SINGLE_COLOR_PALETTES[
|
||||
clampIndex(styleConfig.singlePaletteIndex, SINGLE_COLOR_PALETTES.length)
|
||||
];
|
||||
return Array.from({ length: classCount }, () => palette.color);
|
||||
}
|
||||
|
||||
if (styleConfig.colorType === "gradient") {
|
||||
const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex];
|
||||
const startColor = parseColor(start);
|
||||
const endColor = parseColor(end);
|
||||
|
||||
return Array.from({ length: breaksLength }, (_, index) => {
|
||||
const ratio = breaksLength > 1 ? index / (breaksLength - 1) : 1;
|
||||
const r = Math.round(startColor.r + (endColor.r - startColor.r) * ratio);
|
||||
const g = Math.round(startColor.g + (endColor.g - startColor.g) * ratio);
|
||||
const b = Math.round(startColor.b + (endColor.b - startColor.b) * ratio);
|
||||
return `rgba(${r}, ${g}, ${b}, 1)`;
|
||||
const palette = GRADIENT_PALETTES[
|
||||
clampIndex(styleConfig.gradientPaletteIndex, GRADIENT_PALETTES.length)
|
||||
];
|
||||
const start = parseColor(palette.start);
|
||||
const end = parseColor(palette.end);
|
||||
return Array.from({ length: classCount }, (_, index) => {
|
||||
const ratio = classCount > 1 ? index / (classCount - 1) : 0;
|
||||
return `rgba(${Math.round(start.r + (end.r - start.r) * ratio)}, ${Math.round(
|
||||
start.g + (end.g - start.g) * ratio,
|
||||
)}, ${Math.round(start.b + (end.b - start.b) * ratio)}, 1)`;
|
||||
});
|
||||
}
|
||||
|
||||
if (styleConfig.colorType === "rainbow") {
|
||||
const baseColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors;
|
||||
const palette = RAINBOW_PALETTES[
|
||||
clampIndex(styleConfig.rainbowPaletteIndex, RAINBOW_PALETTES.length)
|
||||
];
|
||||
return Array.from(
|
||||
{ length: breaksLength },
|
||||
(_, index) => baseColors[index % baseColors.length]
|
||||
{ length: classCount },
|
||||
(_, index) => palette.colors[index % palette.colors.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 getDefaultCustomColors(classCount, styleConfig.customColors || []);
|
||||
};
|
||||
|
||||
export const resolveDimensions = ({
|
||||
layerType,
|
||||
styleConfig,
|
||||
breaksLength,
|
||||
classCount = styleConfig.segments,
|
||||
}: {
|
||||
layerType: string;
|
||||
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 (styleConfig.adjustWidthByProperty) {
|
||||
return Array.from({ length: breaksLength }, (_, index) => {
|
||||
const ratio = index / (breaksLength - 1);
|
||||
return (
|
||||
styleConfig.minStrokeWidth +
|
||||
(styleConfig.maxStrokeWidth - styleConfig.minStrokeWidth) * ratio
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
{ length: breaksLength },
|
||||
() => styleConfig.fixedStrokeWidth
|
||||
return Array.from({ length: classCount }, (_, index) =>
|
||||
styleConfig.adjustWidthByProperty
|
||||
? interpolate(styleConfig.minStrokeWidth, styleConfig.maxStrokeWidth, index)
|
||||
: styleConfig.fixedStrokeWidth,
|
||||
);
|
||||
}
|
||||
|
||||
return Array.from({ length: breaksLength }, (_, index) => {
|
||||
const ratio = index / (breaksLength - 1);
|
||||
return styleConfig.minSize + (styleConfig.maxSize - styleConfig.minSize) * ratio;
|
||||
});
|
||||
return Array.from({ length: classCount }, (_, index) =>
|
||||
interpolate(styleConfig.minSize, styleConfig.maxSize, index),
|
||||
);
|
||||
};
|
||||
|
||||
export const buildDynamicStyle = ({
|
||||
export const resolveLayerStyle = ({
|
||||
layerType,
|
||||
styleConfig,
|
||||
breaks,
|
||||
colors,
|
||||
dimensions,
|
||||
values,
|
||||
}: {
|
||||
layerType: string;
|
||||
styleConfig: StyleConfig;
|
||||
breaks: number[];
|
||||
colors: string[];
|
||||
dimensions: number[];
|
||||
}): FlatStyleLike => {
|
||||
const generateColorConditions = (property: string): any[] => {
|
||||
const conditions: any[] = ["case"];
|
||||
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})`
|
||||
values: number[];
|
||||
}): ResolvedLayerStyle | null => {
|
||||
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])}`,
|
||||
);
|
||||
}
|
||||
const defaultColor = parseColor(colors[0]);
|
||||
conditions.push(
|
||||
`rgba(${defaultColor.r}, ${defaultColor.g}, ${defaultColor.b}, ${styleConfig.opacity})`
|
||||
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}`],
|
||||
);
|
||||
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") {
|
||||
dynamicStyle["stroke-color"] = generateColorConditions(styleConfig.property);
|
||||
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;
|
||||
}
|
||||
expression.push(["var", `${variablePrefix}_${classCount - 1}`]);
|
||||
return expression;
|
||||
};
|
||||
|
||||
return dynamicStyle;
|
||||
export const buildDynamicStyleTemplate = ({
|
||||
layerType,
|
||||
property,
|
||||
classCount,
|
||||
}: {
|
||||
layerType: string;
|
||||
property: string;
|
||||
classCount: number;
|
||||
}): FlatStyleLike => {
|
||||
const color = buildVariableCase(property, classCount, "tj_color");
|
||||
const dimension = buildVariableCase(property, classCount, "tj_size");
|
||||
if (layerType === "linestring") {
|
||||
return { "stroke-color": color, "stroke-width": dimension };
|
||||
}
|
||||
return {
|
||||
"circle-fill-color": color,
|
||||
"circle-radius": ["interpolate", ["linear"], ["zoom"], 12, 1, 24, dimension],
|
||||
"circle-stroke-color": color,
|
||||
"circle-stroke-width": 2,
|
||||
};
|
||||
};
|
||||
|
||||
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 = ({
|
||||
@@ -329,20 +428,12 @@ export const buildContourDefinitions = ({
|
||||
styleConfig: StyleConfig;
|
||||
breaks: number[];
|
||||
colors: string[];
|
||||
}) => {
|
||||
const contours = [];
|
||||
for (let index = 0; index < breaks.length - 1; index++) {
|
||||
const colorObj = parseColor(colors[index]);
|
||||
contours.push({
|
||||
}) =>
|
||||
colors.map((color, index) => {
|
||||
const parsed = parseColor(color);
|
||||
return {
|
||||
threshold: [breaks[index], breaks[index + 1]],
|
||||
color: [
|
||||
colorObj.r,
|
||||
colorObj.g,
|
||||
colorObj.b,
|
||||
Math.round(styleConfig.opacity * 255),
|
||||
],
|
||||
color: [parsed.r, parsed.g, parsed.b, Math.round(styleConfig.opacity * 255)],
|
||||
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)[][];
|
||||
};
|
||||
|
||||
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 = {
|
||||
id?: string;
|
||||
@@ -24,6 +50,46 @@ export type ToolbarPropertyPanelData = {
|
||||
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 layerId = feature.getId()?.toString().split(".")[0] || "";
|
||||
if (layerId.includes("pipe")) return "pipe";
|
||||
@@ -56,6 +122,8 @@ export const inferHistoryFeatureInfos = (
|
||||
export const buildFeatureProperties = (
|
||||
highlightFeature: Feature | undefined,
|
||||
computedProperties: Record<string, any>,
|
||||
valveStatus?: ValveStatusPropertyOptions,
|
||||
valveSetting?: ValveSettingPropertyOptions,
|
||||
): ToolbarPropertyPanelData => {
|
||||
if (!highlightFeature) return {};
|
||||
|
||||
@@ -267,6 +335,12 @@ export const buildFeatureProperties = (
|
||||
}
|
||||
|
||||
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 {
|
||||
id: properties.id,
|
||||
type: "阀门",
|
||||
@@ -280,12 +354,47 @@ export const buildFeatureProperties = (
|
||||
},
|
||||
{
|
||||
label: "阀门类型",
|
||||
value: properties.v_type,
|
||||
value: valveType,
|
||||
},
|
||||
{
|
||||
label: "局部损失",
|
||||
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],
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import React, {
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Map as OlMap, VectorTile } from "ol";
|
||||
import { Map as OlMap } from "ol";
|
||||
import View from "ol/View.js";
|
||||
import "ol/ol.css";
|
||||
import MapTools from "./MapTools";
|
||||
@@ -24,18 +24,54 @@ import { TextLayer } from "@deck.gl/layers";
|
||||
import { TripsLayer } from "@deck.gl/geo-layers";
|
||||
import { CollisionFilterExtension } from "@deck.gl/extensions";
|
||||
import { ContourLayer } from "deck.gl";
|
||||
import { toM3h } from "@utils/units";
|
||||
import { isLpsFlowProperty, toM3h } from "@utils/units";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
cleanupTransientMapResources,
|
||||
disposeMapResources,
|
||||
markMapResourcePersistent,
|
||||
} 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 {
|
||||
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 {
|
||||
currentTime?: number; // 当前时间
|
||||
setCurrentTime?: React.Dispatch<React.SetStateAction<number>>;
|
||||
@@ -120,6 +156,57 @@ function debounce<F extends (...args: any[]) => any>(
|
||||
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 = () => {
|
||||
return useContext(MapContext);
|
||||
};
|
||||
@@ -139,6 +226,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
];
|
||||
const MAP_URL = config.MAP_URL;
|
||||
const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key
|
||||
const { durationMinutes, stepMinutes } = useTimelineTimeConfig();
|
||||
|
||||
const mapRef = useRef<HTMLDivElement | 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 isDisposingRef = useRef(false);
|
||||
const isCompareDisposingRef = useRef(false);
|
||||
const pendingTimeoutsRef = useRef<number[]>([]);
|
||||
|
||||
const [map, setMap] = useState<OlMap>();
|
||||
const [deckLayer, setDeckLayer] = useState<DeckLayer>();
|
||||
const [compareMap, setCompareMap] = useState<OlMap>();
|
||||
const [compareDeckLayer, setCompareDeckLayer] = useState<DeckLayer>();
|
||||
// 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()); // 默认今天
|
||||
const [schemeName, setSchemeName] = useState<string>(""); // 当前方案名称
|
||||
@@ -169,14 +258,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
);
|
||||
const [comparePipeCalData, setComparePipeCalData] = useState<any[]>([]);
|
||||
const [isCompareMode, setCompareMode] = useState(false);
|
||||
// junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染
|
||||
// currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值
|
||||
// junctionData 为当前层级和视口内按 ID 去重的节点数据;pipeData 为管道标签数据。
|
||||
// pipeFragments 保留当前层级和视口内每个瓦片管道片段,水流动画按 instanceKey 渲染,不能按业务 ID 去重。
|
||||
const [junctionData, setJunctionDataState] = useState<any[]>([]);
|
||||
const [pipeData, setPipeDataState] = useState<any[]>([]);
|
||||
const junctionDataIds = useRef(new Set<string>());
|
||||
const pipeDataIds = useRef(new Set<string>());
|
||||
const tileJunctionDataBuffer = useRef<any[]>([]);
|
||||
const tilePipeDataBuffer = useRef<any[]>([]);
|
||||
const [pipeFragments, setPipeFragments] = useState<any[]>([]);
|
||||
const junctionIndexRef = useRef<TileFeatureIndex | null>(null);
|
||||
const pipeIndexRef = useRef<TileFeatureIndex | null>(null);
|
||||
|
||||
const [showJunctionTextLayer, setShowJunctionTextLayer] = useState(false); // 控制节点文本图层显示
|
||||
const [showPipeTextLayer, setShowPipeTextLayer] = useState(false); // 控制管道文本图层显示
|
||||
@@ -186,7 +274,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
const [junctionText, setJunctionText] = useState("pressure");
|
||||
const [pipeText, setPipeText] = useState("velocity");
|
||||
const [contours, setContours] = useState<any[]>([]);
|
||||
const flowAnimation = useRef(false); // 添加动画控制标志
|
||||
const [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示
|
||||
const [isWaterflowLayerAvailable, setWaterflowLayerAvailable] =
|
||||
useState(false); // 控制等高线图层显示
|
||||
@@ -194,68 +281,40 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
const [currentZoom, setCurrentZoom] = useState(11); // 当前缩放级别
|
||||
|
||||
// 实时合并计算结果到基础地理数据中
|
||||
const mergedJunctionData = useMemo(() => {
|
||||
const nodeMap = new Map(currentJunctionCalData.map((r: any) => [r.ID, r]));
|
||||
return junctionData.map((j) => {
|
||||
const record = nodeMap.get(j.id);
|
||||
let val = record ? record.value : undefined;
|
||||
// 在这合并时将实际需水量从 LPS 转换为大写表示
|
||||
if (val !== undefined && junctionText === "actualdemand") {
|
||||
val = toM3h(val, "lps");
|
||||
}
|
||||
return record ? { ...j, [junctionText]: val } : j;
|
||||
});
|
||||
}, [junctionData, currentJunctionCalData, junctionText]);
|
||||
|
||||
const mergedPipeData = useMemo(() => {
|
||||
const linkMap = new Map(currentPipeCalData.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, currentPipeCalData, pipeText]);
|
||||
|
||||
const mergedCompareJunctionData = useMemo(() => {
|
||||
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 mergedJunctionData = useMemo(
|
||||
() =>
|
||||
mergeJunctionValues(junctionData, currentJunctionCalData, junctionText),
|
||||
[junctionData, currentJunctionCalData, junctionText],
|
||||
);
|
||||
const mergedPipeData = useMemo(
|
||||
() => mergePipeValues(pipeData, currentPipeCalData, pipeText),
|
||||
[pipeData, currentPipeCalData, pipeText],
|
||||
);
|
||||
const mergedPipeFragments = useMemo(
|
||||
() => mergePipeValues(pipeFragments, currentPipeCalData, pipeText),
|
||||
[pipeFragments, currentPipeCalData, pipeText],
|
||||
);
|
||||
const mergedCompareJunctionData = useMemo(
|
||||
() =>
|
||||
isCompareMode
|
||||
? mergeJunctionValues(junctionData, compareJunctionCalData, junctionText)
|
||||
: [],
|
||||
[isCompareMode, junctionData, compareJunctionCalData, junctionText],
|
||||
);
|
||||
const mergedComparePipeData = useMemo(
|
||||
() =>
|
||||
isCompareMode
|
||||
? mergePipeValues(pipeData, comparePipeCalData, pipeText)
|
||||
: [],
|
||||
[isCompareMode, pipeData, comparePipeCalData, pipeText],
|
||||
);
|
||||
const mergedComparePipeFragments = useMemo(
|
||||
() =>
|
||||
isCompareMode
|
||||
? mergePipeValues(pipeFragments, comparePipeCalData, pipeText)
|
||||
: [],
|
||||
[isCompareMode, pipeFragments, comparePipeCalData, pipeText],
|
||||
);
|
||||
|
||||
const [diameterRange, setDiameterRange] = useState<
|
||||
[number, number] | undefined
|
||||
@@ -267,7 +326,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
useState(0);
|
||||
|
||||
const toggleCompareMode = useCallback(() => {
|
||||
setCompareMode((prev) => !prev);
|
||||
setCompareMode((prev) => {
|
||||
if (!prev) markCompareOpenStart();
|
||||
return !prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const maps = useMemo(
|
||||
@@ -284,91 +346,126 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
[compareDeckLayer, deckLayer, isCompareMode],
|
||||
);
|
||||
|
||||
const setJunctionData = (newData: any[]) => {
|
||||
const uniqueNewData = newData.filter((item) => {
|
||||
if (!item || !item.id) return false;
|
||||
if (!junctionDataIds.current.has(item.id)) {
|
||||
junctionDataIds.current.add(item.id);
|
||||
return true;
|
||||
const buildPipeFragments = useCallback((instance: TileFeatureInstance) => {
|
||||
const tileCoordinates = lineStringFromFlatCoordinates(
|
||||
instance.flatCoordinates,
|
||||
instance.stride,
|
||||
);
|
||||
const clippedParts = clipLineStringPartsToExtent(
|
||||
tileCoordinates,
|
||||
instance.tileExtent,
|
||||
);
|
||||
return clippedParts.flatMap((clippedCoordinates, partIndex) => {
|
||||
const path = coordinatesToLonLat(clippedCoordinates);
|
||||
const lineStringFeature = lineString(path);
|
||||
const fragmentLength = length(lineStringFeature);
|
||||
if (fragmentLength <= 0) return [];
|
||||
|
||||
const timestamps = [0];
|
||||
let cumulativeLength = 0;
|
||||
for (let i = 1; i < path.length; i += 1) {
|
||||
cumulativeLength += length(lineString([path[i - 1], path[i]]));
|
||||
timestamps.push((cumulativeLength / fragmentLength) * 10);
|
||||
}
|
||||
return false;
|
||||
|
||||
const midPoint = along(lineStringFeature, fragmentLength / 2).geometry
|
||||
.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 [
|
||||
{
|
||||
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,
|
||||
},
|
||||
];
|
||||
});
|
||||
if (uniqueNewData.length > 0) {
|
||||
setJunctionDataState((prev) => prev.concat(uniqueNewData));
|
||||
setElevationRange((prev) => {
|
||||
const elevations = uniqueNewData
|
||||
.map((d) => d.elevation)
|
||||
.filter((v) => typeof v === "number");
|
||||
if (elevations.length === 0) return prev;
|
||||
|
||||
let newMin = elevations[0];
|
||||
let newMax = elevations[0];
|
||||
for (let i = 1; i < elevations.length; i++) {
|
||||
if (elevations[i] < newMin) newMin = elevations[i];
|
||||
if (elevations[i] > newMax) newMax = elevations[i];
|
||||
}
|
||||
|
||||
if (!prev) {
|
||||
return [newMin, newMax];
|
||||
}
|
||||
return [Math.min(prev[0], newMin), Math.max(prev[1], newMax)];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const setPipeData = (newData: any[]) => {
|
||||
const uniqueNewData = newData.filter((item) => {
|
||||
if (!item || !item.id) return false;
|
||||
if (!pipeDataIds.current.has(item.id)) {
|
||||
pipeDataIds.current.add(item.id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (uniqueNewData.length > 0) {
|
||||
setPipeDataState((prev) => prev.concat(uniqueNewData));
|
||||
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(() => {
|
||||
debouncedUpdateDataRef.current = debounce(() => {
|
||||
if (tileJunctionDataBuffer.current.length > 0) {
|
||||
setJunctionData(tileJunctionDataBuffer.current);
|
||||
tileJunctionDataBuffer.current = [];
|
||||
}
|
||||
if (tilePipeDataBuffer.current.length > 0) {
|
||||
setPipeData(tilePipeDataBuffer.current);
|
||||
tilePipeDataBuffer.current = [];
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
debouncedUpdateDataRef.current?.cancel();
|
||||
debouncedUpdateDataRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 nextPipeFragments = (pipeSnapshot?.instances ?? [])
|
||||
.filter((instance) => instance.geometryType.includes("Line"))
|
||||
.flatMap(buildPipeFragments)
|
||||
.sort((a, b) => a.instanceKey.localeCompare(b.instanceKey));
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
const nextPipeLabels = Array.from(labelById.values()).sort((a, b) =>
|
||||
String(a.id).localeCompare(String(b.id)),
|
||||
);
|
||||
|
||||
setJunctionDataState(nextJunctionData);
|
||||
setPipeFragments(nextPipeFragments);
|
||||
setPipeDataState(nextPipeLabels);
|
||||
setElevationRange(
|
||||
getNumericRange(nextJunctionData.map((item) => item.elevation)),
|
||||
);
|
||||
setDiameterRange(
|
||||
getNumericRange(nextPipeLabels.map((item) => item.diameter)),
|
||||
);
|
||||
},
|
||||
[buildPipeFragments],
|
||||
);
|
||||
const operationalSources = useMemo(
|
||||
() =>
|
||||
createOperationalMapSources({
|
||||
mapUrl: MAP_URL,
|
||||
workspace: MAP_WORKSPACE,
|
||||
}),
|
||||
[MAP_URL, MAP_WORKSPACE],
|
||||
);
|
||||
const operationalResources = useMemo(
|
||||
() =>
|
||||
createOperationalMapResources({
|
||||
@@ -376,8 +473,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
workspace: MAP_WORKSPACE,
|
||||
extent: MAP_EXTENT,
|
||||
persistent: true,
|
||||
sources: operationalSources,
|
||||
}),
|
||||
[MAP_URL, MAP_WORKSPACE, MAP_EXTENT],
|
||||
[MAP_URL, MAP_WORKSPACE, MAP_EXTENT, operationalSources],
|
||||
);
|
||||
const { junctions: junctionSource, pipes: pipeSource } =
|
||||
operationalResources.sources;
|
||||
@@ -391,60 +489,15 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
return;
|
||||
}
|
||||
isDisposingRef.current = false;
|
||||
const activeJunctionDataIds = junctionDataIds.current;
|
||||
const activePipeDataIds = pipeDataIds.current;
|
||||
|
||||
const addTimeout = (callback: () => void, delay: number) => {
|
||||
const timerId = window.setTimeout(() => {
|
||||
pendingTimeoutsRef.current = pendingTimeoutsRef.current.filter(
|
||||
(id) => id !== timerId,
|
||||
);
|
||||
if (isDisposingRef.current) return;
|
||||
callback();
|
||||
}, delay);
|
||||
pendingTimeoutsRef.current.push(timerId);
|
||||
return timerId;
|
||||
};
|
||||
junctionIndexRef.current = new TileFeatureIndex("junctions", junctionSource);
|
||||
pipeIndexRef.current = new TileFeatureIndex("pipes", pipeSource);
|
||||
|
||||
const clearPendingTimeouts = () => {
|
||||
pendingTimeoutsRef.current.forEach((id) => clearTimeout(id));
|
||||
pendingTimeoutsRef.current = [];
|
||||
};
|
||||
|
||||
// 缓存 junction、pipe 数据,提供给 deck.gl 提供坐标供标签显示
|
||||
const handleJunctionTileLoadEnd = (event: any) => {
|
||||
if (isDisposingRef.current) return;
|
||||
try {
|
||||
if (event.tile instanceof VectorTile) {
|
||||
const renderFeatures = event.tile.getFeatures();
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
junctionIndexRef.current?.registerTile(event.tile);
|
||||
scheduleActiveTileSnapshot();
|
||||
} catch (error) {
|
||||
console.error("Junction tile load error:", error);
|
||||
}
|
||||
@@ -452,86 +505,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
const handlePipeTileLoadEnd = (event: any) => {
|
||||
if (isDisposingRef.current) return;
|
||||
try {
|
||||
if (event.tile instanceof VectorTile) {
|
||||
const renderFeatures = event.tile.getFeatures();
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
pipeIndexRef.current?.registerTile(event.tile);
|
||||
scheduleActiveTileSnapshot();
|
||||
} catch (error) {
|
||||
console.error("Pipe tile load error:", error);
|
||||
}
|
||||
};
|
||||
junctionSource.on("tileloadend", handleJunctionTileLoadEnd);
|
||||
pipeSource.on("tileloadend", handlePipeTileLoadEnd);
|
||||
// 监听 junctionsLayer 的 visible 变化
|
||||
const handleJunctionVisibilityChange = () => {
|
||||
const isVisible = junctionsLayer.getVisible();
|
||||
@@ -555,6 +534,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
layers: operationalResources.orderedLayers.slice(),
|
||||
controls: [],
|
||||
});
|
||||
const scheduleActiveTileSnapshot = debounce(
|
||||
() => publishActiveTileSnapshot(map),
|
||||
50,
|
||||
);
|
||||
junctionSource.on("tileloadend", handleJunctionTileLoadEnd);
|
||||
pipeSource.on("tileloadend", handlePipeTileLoadEnd);
|
||||
map.getInteractions().forEach(markMapResourcePersistent);
|
||||
setMap(map);
|
||||
|
||||
@@ -592,14 +577,18 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
duration: 1000,
|
||||
});
|
||||
}
|
||||
// 持久化视图(中心点 + 缩放),防抖写入 localStorage
|
||||
const persistView = debounce(() => {
|
||||
// 视图稳定后同步 Deck 数据并持久化,避免移动过程中重复扫描瓦片。
|
||||
const handleViewChange = debounce(() => {
|
||||
if (isDisposingRef.current) return;
|
||||
const view = map.getView();
|
||||
const zoom = view.getZoom() || 0;
|
||||
setCurrentZoom(zoom);
|
||||
junctionIndexRef.current?.scanLoadedTiles();
|
||||
pipeIndexRef.current?.scanLoadedTiles();
|
||||
scheduleActiveTileSnapshot();
|
||||
try {
|
||||
const view = map.getView();
|
||||
const center = view.getCenter();
|
||||
const zoom = view.getZoom();
|
||||
if (center && typeof zoom === "number") {
|
||||
if (center) {
|
||||
localStorage.setItem(
|
||||
MAP_VIEW_STORAGE_KEY,
|
||||
JSON.stringify({ center, zoom }),
|
||||
@@ -609,21 +598,16 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
console.warn("Save map view failed", err);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
// 监听缩放变化并持久化,同时更新 currentZoom
|
||||
const handleViewChange = () => {
|
||||
addTimeout(() => {
|
||||
const zoom = map.getView().getZoom() || 0;
|
||||
setCurrentZoom(zoom);
|
||||
persistView();
|
||||
}, 250);
|
||||
};
|
||||
map.getView().on("change", handleViewChange);
|
||||
|
||||
// 初始化当前缩放级别并强制触发瓦片加载
|
||||
addTimeout(() => {
|
||||
const initializeTimer = window.setTimeout(() => {
|
||||
if (isDisposingRef.current) return;
|
||||
const initialZoom = map.getView().getZoom() || 11;
|
||||
setCurrentZoom(initialZoom);
|
||||
junctionIndexRef.current?.scanLoadedTiles();
|
||||
pipeIndexRef.current?.scanLoadedTiles();
|
||||
scheduleActiveTileSnapshot();
|
||||
// 强制触发地图渲染,让瓦片加载事件触发
|
||||
map.render();
|
||||
}, 100);
|
||||
@@ -652,9 +636,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
// 清理函数
|
||||
return () => {
|
||||
isDisposingRef.current = true;
|
||||
clearPendingTimeouts();
|
||||
debouncedUpdateDataRef.current?.cancel();
|
||||
persistView.cancel();
|
||||
window.clearTimeout(initializeTimer);
|
||||
scheduleActiveTileSnapshot.cancel();
|
||||
handleViewChange.cancel();
|
||||
junctionSource.un("tileloadend", handleJunctionTileLoadEnd);
|
||||
pipeSource.un("tileloadend", handlePipeTileLoadEnd);
|
||||
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.
|
||||
// Detach and clear them here, but leave final layer/source disposal to GC.
|
||||
disposeMapResources(map, { disposeLayers: false });
|
||||
activeJunctionDataIds.clear();
|
||||
activePipeDataIds.clear();
|
||||
tileJunctionDataBuffer.current = [];
|
||||
tilePipeDataBuffer.current = [];
|
||||
junctionIndexRef.current = null;
|
||||
pipeIndexRef.current = null;
|
||||
setJunctionDataState([]);
|
||||
setPipeDataState([]);
|
||||
setPipeFragments([]);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [MAP_WORKSPACE, MAP_EXTENT]);
|
||||
@@ -695,6 +680,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
mapUrl: MAP_URL,
|
||||
workspace: MAP_WORKSPACE,
|
||||
extent: MAP_EXTENT,
|
||||
sources: operationalSources,
|
||||
});
|
||||
const nextCompareMap = new OlMap({
|
||||
target: compareMapRef.current,
|
||||
@@ -702,6 +688,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
layers: compareResources.orderedLayers.slice(),
|
||||
controls: [],
|
||||
});
|
||||
const handleCompareRenderComplete = () => markCompareOpenReady();
|
||||
nextCompareMap.once("rendercomplete", handleCompareRenderComplete);
|
||||
nextCompareMap.getAllLayers().forEach((layer) => {
|
||||
const layerId = layer.get("value");
|
||||
if (!layerId) return;
|
||||
@@ -744,6 +732,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
return () => {
|
||||
isCompareDisposingRef.current = true;
|
||||
window.clearTimeout(resizeTimerId);
|
||||
nextCompareMap.un("rendercomplete", handleCompareRenderComplete);
|
||||
if (
|
||||
compareDeckLayerRef.current &&
|
||||
!compareDeckLayerRef.current.isDisposedLayer()
|
||||
@@ -758,7 +747,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
compareDeckLayerRef.current = null;
|
||||
setCompareDeckLayer(undefined);
|
||||
setCompareMap(undefined);
|
||||
disposeMapResources(nextCompareMap);
|
||||
disposeMapResources(nextCompareMap, { disposeSources: false });
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isCompareMode, map]);
|
||||
@@ -775,7 +764,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
}, [compareMap, isCompareMode, map]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentTime(-1);
|
||||
setCurrentTime(
|
||||
getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes),
|
||||
);
|
||||
setSelectedDate(new Date());
|
||||
setSchemeName("");
|
||||
setCurrentJunctionCalData([]);
|
||||
@@ -800,7 +791,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
deckLayerRef.current?.resetSessionLayers();
|
||||
operationalResources.resetStyles();
|
||||
};
|
||||
}, [pathname, map, operationalResources]);
|
||||
}, [durationMinutes, pathname, map, operationalResources, stepMinutes]);
|
||||
|
||||
// 当数据变化时,更新 deck.gl 图层
|
||||
useEffect(() => {
|
||||
@@ -1009,11 +1000,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
|
||||
// 控制流动动画开关
|
||||
useEffect(() => {
|
||||
flowAnimation.current = pipeText === "flow" && currentPipeCalData.length > 0;
|
||||
const hasFlowData = pipeText === "flow" && currentPipeCalData.length > 0;
|
||||
const shouldShowWaterflow =
|
||||
isWaterflowLayerAvailable &&
|
||||
showWaterflowLayer &&
|
||||
flowAnimation.current &&
|
||||
hasFlowData &&
|
||||
currentZoom >= 12 &&
|
||||
currentZoom <= 24;
|
||||
|
||||
@@ -1021,13 +1012,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
|
||||
const syncWaterflowLayer = (
|
||||
targetDeckLayer: DeckLayer | null,
|
||||
targetPipeData: any[],
|
||||
targetPipeFragments: any[],
|
||||
disposing: boolean,
|
||||
) => {
|
||||
if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) {
|
||||
return;
|
||||
}
|
||||
if (!shouldShowWaterflow || targetPipeData.length === 0) {
|
||||
if (!shouldShowWaterflow || targetPipeFragments.length === 0) {
|
||||
targetDeckLayer.removeDeckLayer("waterflowLayer");
|
||||
return;
|
||||
}
|
||||
@@ -1039,7 +1030,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
const waterflowLayer = new TripsLayer({
|
||||
id: "waterflowLayer",
|
||||
name: "水流",
|
||||
data: targetPipeData,
|
||||
data: targetPipeFragments,
|
||||
getObjectId: (d: any) => d.instanceKey,
|
||||
getPath: (d) => d.path,
|
||||
getTimestamps: (d) => d.timestamps,
|
||||
getColor: [0, 220, 255],
|
||||
@@ -1061,13 +1053,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
const animate = () => {
|
||||
syncWaterflowLayer(
|
||||
deckLayerRef.current,
|
||||
mergedPipeData,
|
||||
mergedPipeFragments,
|
||||
isDisposingRef.current,
|
||||
);
|
||||
if (isCompareMode) {
|
||||
syncWaterflowLayer(
|
||||
compareDeckLayerRef.current,
|
||||
mergedComparePipeData,
|
||||
mergedComparePipeFragments,
|
||||
isCompareDisposingRef.current,
|
||||
);
|
||||
}
|
||||
@@ -1086,8 +1078,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
}, [
|
||||
currentPipeCalData,
|
||||
currentZoom,
|
||||
mergedPipeData,
|
||||
mergedComparePipeData,
|
||||
mergedPipeFragments,
|
||||
mergedComparePipeFragments,
|
||||
isCompareMode,
|
||||
pipeText,
|
||||
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(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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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?.();
|
||||
if (Array.isArray(childLayers)) {
|
||||
[...childLayers].forEach((childLayer) => releaseLayer(childLayer, dispose));
|
||||
[...childLayers].forEach((childLayer) => releaseLayer(childLayer, options));
|
||||
layer.getLayers().clear();
|
||||
}
|
||||
|
||||
const source = layer.getSource?.();
|
||||
try {
|
||||
source?.clear?.();
|
||||
} catch {
|
||||
// Some third-party sources do not support explicit cache clearing.
|
||||
}
|
||||
if (dispose) {
|
||||
if (options.disposeSource) {
|
||||
try {
|
||||
source?.dispose?.();
|
||||
source?.clear?.();
|
||||
} catch {
|
||||
// Source may already be disposed by its owning layer.
|
||||
// Some third-party sources do not support explicit cache clearing.
|
||||
}
|
||||
if (options.disposeLayer) {
|
||||
try {
|
||||
source?.dispose?.();
|
||||
} catch {
|
||||
// Source may already be disposed by its owning layer.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.disposeLayer) {
|
||||
try {
|
||||
layer.dispose?.();
|
||||
} catch {
|
||||
@@ -59,7 +68,7 @@ export const cleanupTransientMapResources = (map: OlMap) => {
|
||||
[...map.getLayers().getArray()].forEach((layer) => {
|
||||
if (isMapResourcePersistent(layer)) return;
|
||||
map.removeLayer(layer);
|
||||
releaseLayer(layer, true);
|
||||
releaseLayer(layer, { disposeLayer: true, disposeSource: true });
|
||||
});
|
||||
|
||||
removeTransientResources(map.getInteractions().getArray(), (interaction) =>
|
||||
@@ -75,12 +84,16 @@ export const cleanupTransientMapResources = (map: OlMap) => {
|
||||
|
||||
export const disposeMapResources = (
|
||||
map: OlMap,
|
||||
options: { disposeLayers?: boolean } = {},
|
||||
options: { disposeLayers?: boolean; disposeSources?: boolean } = {},
|
||||
) => {
|
||||
const disposeLayers = options.disposeLayers ?? true;
|
||||
const disposeSources = options.disposeSources ?? true;
|
||||
[...map.getLayers().getArray()].forEach((layer) => {
|
||||
map.removeLayer(layer);
|
||||
releaseLayer(layer, disposeLayers);
|
||||
releaseLayer(layer, {
|
||||
disposeLayer: disposeLayers,
|
||||
disposeSource: disposeSources,
|
||||
});
|
||||
});
|
||||
map.getInteractions().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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,11 +17,25 @@ import { markMapResourcePersistent } from "./mapLifecycle";
|
||||
|
||||
type MapExtent = [number, number, number, number];
|
||||
|
||||
type CreateOperationalLayersOptions = {
|
||||
type CreateOperationalMapSourcesOptions = {
|
||||
mapUrl: string;
|
||||
workspace: string;
|
||||
};
|
||||
|
||||
type CreateOperationalLayersOptions = CreateOperationalMapSourcesOptions & {
|
||||
extent: MapExtent;
|
||||
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;
|
||||
@@ -85,18 +99,16 @@ const pipeProperties = [
|
||||
{ name: "流速", value: "velocity" },
|
||||
];
|
||||
|
||||
export const createOperationalMapResources = ({
|
||||
export const createOperationalMapSources = ({
|
||||
mapUrl,
|
||||
workspace,
|
||||
extent,
|
||||
persistent = false,
|
||||
}: CreateOperationalLayersOptions) => {
|
||||
}: CreateOperationalMapSourcesOptions): OperationalMapSources => {
|
||||
const vectorTileUrl = (name: string) =>
|
||||
`${mapUrl}/gwc/service/tms/1.0.0/${workspace}:${name}@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`;
|
||||
const vectorUrl = (name: string) =>
|
||||
`${mapUrl}/${workspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${workspace}:${name}&outputFormat=application/json`;
|
||||
|
||||
const sources = {
|
||||
return {
|
||||
junctions: new VectorTileSource({
|
||||
url: vectorTileUrl("geo_junctions"),
|
||||
format: new MVT(),
|
||||
@@ -129,6 +141,15 @@ export const createOperationalMapResources = ({
|
||||
format: new GeoJSON(),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const createOperationalMapResources = ({
|
||||
mapUrl,
|
||||
workspace,
|
||||
extent,
|
||||
persistent = false,
|
||||
sources = createOperationalMapSources({ mapUrl, workspace }),
|
||||
}: CreateOperationalLayersOptions) => {
|
||||
|
||||
const layers = {
|
||||
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);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -58,6 +58,10 @@ const MAP_CONFIG = {
|
||||
bufferUnits: "meters" 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,
|
||||
layerFilter: (layer) => layer instanceof VectorLayer,
|
||||
layerFilter: isQueryableVectorLayer,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
export const FLOW_DISPLAY_UNIT = "m³/h";
|
||||
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") => {
|
||||
if (!Number.isFinite(value)) return Number.NaN;
|
||||
|
||||
Reference in New Issue
Block a user