19 Commits
Author SHA1 Message Date
jiang 0d559f6130 fix(scada): show backend detail on cleaning errors
Build Push and Deploy / docker-image (push) Successful in 1m3s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-07-17 16:27:40 +08:00
jiang 7af90e495d feat(burst-detection): update analysis parameters 2026-07-17 16:24:04 +08:00
jiang a52c04204d refactor(admin): simplify system management UI 2026-07-17 16:22:16 +08:00
jiang 08152ff978 fix(map): restore saved styles as inactive drafts 2026-07-17 15:28:44 +08:00
jiang 589cf45aa7 fix(map): stabilize tiled style rendering 2026-07-17 15:12:10 +08:00
jiang 59447a100c fix(scada): align device panel collapse animation 2026-07-17 14:25:03 +08:00
jiang 4adbcc1c4c fix(dma): defer leakage flow validation 2026-07-17 11:45:58 +08:00
jiang 202f18332f fix(notification): clarify query and progress feedback 2026-07-17 10:48:58 +08:00
jiang acf13639ef fix(map): allow selecting features under overlays 2026-07-17 10:21:16 +08:00
jiang d986e563a6 fix(map): convert actual demand display units 2026-07-17 10:13:50 +08:00
jiang d8ee2e1f0c fix(burst): show actual pipe diameters 2026-07-17 10:01:06 +08:00
jiang fddb0ceb34 fix(map): restrict waterflow layer to flow 2026-07-16 18:02:29 +08:00
jiang 3d7b594682 style(map): align valve setting input 2026-07-16 17:55:58 +08:00
jiang eb8950c89a feat(map): add valve setting editor 2026-07-16 17:48:16 +08:00
jiang ef2b045306 fix(map): show simulation pipe ids 2026-07-16 14:50:22 +08:00
jiang 209da0d295 fix(map): sort scheme queries by time 2026-07-16 14:29:29 +08:00
jiang dfaee645ff fix(timeline): use backend timestep 2026-07-16 14:16:07 +08:00
jiang d90ca7c951 fix(timeline): align range mask with slider 2026-07-16 11:38:22 +08:00
jiang 041b4ef89d fix(timeline): prevent negative initial time 2026-07-16 11:31:51 +08:00
65 changed files with 6733 additions and 3815 deletions
+2 -2
View File
@@ -4,7 +4,6 @@ import { Refine, type AuthProvider } from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import {
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 } });
});
});
+83 -6
View File
@@ -1,4 +1,9 @@
import type { StyleConfig, DefaultLayerStyleId } from "@components/olmap/core/Controls/styleEditorTypes";
import type {
ClassificationMethod,
ColorType,
StyleConfig,
DefaultLayerStyleId,
} from "@components/olmap/core/Controls/styleEditorTypes";
export type ApplyLayerStyleActionPayload = {
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,44 +28,78 @@ 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> = ({
@@ -79,137 +108,69 @@ const AnalysisParameters: React.FC<Props> = ({
onStateChange,
}) => {
const { open } = useNotification();
const [parametersState, setParametersState, setFormField] = useControllableObjectState(
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[],
);
setFormField("schemes", burstSchemes);
if (selectedSchemeId) {
const matchedScheme = burstSchemes.find(
(scheme) => scheme.scheme_id === selectedSchemeId,
setParametersState((previous) =>
previous.samplingIntervalSource === "metadata"
? { ...previous, samplingIntervalMinutes: interval }
: previous,
);
if (matchedScheme) {
applySchemeTimeRange(matchedScheme);
} else {
setFormField("selectedSchemeId", "");
}
}
if (notify) {
open?.({
type: "success",
message: "方案列表已刷新",
description: `当前可选爆管分析方案 ${burstSchemes.length}`,
})
.catch(() => {
// Keep the 15-minute fallback when SCADA metadata is unavailable.
})
.finally(() => {
if (active) setFrequencyLoading(false);
});
}
} 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 handleDataSourceChange = (value: "monitoring" | "simulation") => {
setFormField("dataSource", value);
if (value === "simulation") {
void fetchSchemes();
}
return () => {
active = false;
};
}, [samplingIntervalSource, setParametersState]);
const handleSchemeSelect = (schemeId: number) => {
setFormField("selectedSchemeId", schemeId);
const scheme = schemes.find((item) => item.scheme_id === schemeId);
if (scheme) {
applySchemeTimeRange(scheme);
}
};
const samplingIntervalValid =
Number.isInteger(samplingIntervalMinutes) &&
samplingIntervalMinutes > 0 &&
1440 % samplingIntervalMinutes === 0;
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 isValid = useMemo(
() =>
schemeName.trim().length > 0 &&
samplingIntervalValid &&
(detectionMode === "latest" || Boolean(targetTime?.isValid())),
[detectionMode, samplingIntervalValid, schemeName, targetTime],
);
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>
)}
{detectionMode === "historical" ? (
<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}
value={targetTime}
onChange={(value) => setFormField("targetTime", value)}
maxDateTime={dayjs()}
minutesStep={15}
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>
) : null}
<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",
<TextField
type="number"
label="采样间隔(分钟)"
value={samplingIntervalMinutes}
onChange={(event) => {
setParametersState((previous) => ({
...previous,
samplingIntervalMinutes: Number(event.target.value),
samplingIntervalSource: "manual",
}));
}}
>
<Box
role="button"
tabIndex={0}
onClick={() => setFormField("advancedOpen", !advancedOpen)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
setFormField("advancedOpen", !advancedOpen);
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。"
}
}}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
px: 1.25,
py: 0.75,
cursor: "pointer",
backgroundColor: "transparent",
"&:hover": { backgroundColor: "action.hover" },
}}
>
<Typography variant="body2" color="text.secondary">
/>
{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>
<ExpandMoreIcon
sx={{
transform: advancedOpen ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.2s ease",
}}
/>
</Box>
<Collapse in={advancedOpen} timeout="auto" unmountOnExit>
<Box
sx={{
px: 1.25,
pt: 1.25,
pb: 1.25,
backgroundColor: "transparent",
}}
>
<Box className="flex flex-col gap-3">
<TextField
type="number"
label="频域截断系数"
value={mu}
onChange={(event) => setFormField("mu", Number(event.target.value))}
size="small"
fullWidth
inputProps={{ min: 1 }}
/>
<TextField
type="number"
label="每日采样点数"
value={pointsPerDay}
onChange={(event) => setFormField("pointsPerDay", Number(event.target.value))}
size="small"
fullWidth
inputProps={{ min: 1 }}
/>
<TextField
type="number"
label="孤立森林树数量"
value={nEstimators}
onChange={(event) => setFormField("nEstimators", Number(event.target.value))}
size="small"
fullWidth
inputProps={{ min: 1 }}
/>
<TextField
label="异常比例"
value={contaminationInput}
onChange={(event) => setFormField("contaminationInput", event.target.value)}
size="small"
fullWidth
helperText="填写 auto 或 0~0.5 之间的小数。"
error={contaminationValue === null}
/>
</Box>
</Box>
</Collapse>
</Box>
</Box>
<Box 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}`}>
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 ${style.darkText}`}>
<Typography variant="body2" className="font-bold">
{value}
</Typography>
{hint ? (
<Typography variant="caption" className={`mt-0.5 block text-xs opacity-80 ${style.text}`}>
<Typography variant="caption" className="mt-0.5 block opacity-75">
{hint}
</Typography>
) : 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 || "爆管侦测结果"}
<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>
</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))
void locateSensors(
result.summary.latest_sensor_rankings
.slice(0, 5)
.map((item) => item.sensor_node),
)
}
sx={{
height: 24,
minWidth: 0,
padding: "0 8px",
borderColor: "#bfdbfe",
color: "#2563eb",
fontSize: "0.75rem",
"&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
}}
sx={{ flexShrink: 0, whiteSpace: "nowrap" }}
>
</Button>
</Box>
</Box>
{/* Configuration Summary */}
<Box className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-gray-100 bg-gray-50/50 px-3 py-2 text-xs text-gray-600">
<Box className="flex items-center gap-1.5">
<Box className="h-1.5 w-1.5 rounded-full bg-blue-400" />
<span className="font-medium text-gray-700"></span>
<span className="font-mono text-gray-600">
{formatDateTime(result.scada_window?.start)} ~ {formatDateTime(result.scada_window?.end)}
</span>
</Box>
<Box className="flex items-center gap-1.5">
<Box className="h-1.5 w-1.5 rounded-full bg-purple-400" />
<span className="font-medium text-gray-700"></span>
<span className="text-gray-600">
{(() => {
const ds = result.data_source;
const os = result.observed_source;
if (ds === "simulation") return "模拟数据";
if (ds === "monitoring") return "监测数据";
if (os === "simulation_scheme_timerange") return "模拟数据";
if (os === "backend_timerange") return "监测数据";
return os || "-";
})()}
</span>
</Box>
</Box>
{/* Metrics Grid */}
<Box className="grid grid-cols-2 gap-3">
<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">
选中日解读
{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>
{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>
</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> */}
{/* 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 }}>
<Box sx={{ height: 280, 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> */}
) : null}
{/* 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">
<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>
<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>
<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,18 +302,22 @@ 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
{targetTime
? typeof targetScore === "number"
? targetScore.toFixed(4)
: "-"
: isBurst
? typeof mostAnomalousDay === "number"
? `${mostAnomalousDay}`
: mostAnomalousDay
@@ -303,10 +326,12 @@ const SchemeQuery: React.FC<Props> = ({
</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 mmP-2: 800 mm");
});
it("does not fall back to a fixed diameter when a diameter is missing", () => {
expect(getPipeDiameterDisplay(["P-1"], {})).toBe("N/A");
});
});
@@ -0,0 +1,27 @@
export type PipeDiameterMap = Record<string, number | null | undefined>;
export const getPipeDiameterDisplay = (
pipeIds: string[] | undefined,
diameters: PipeDiameterMap | undefined,
loading = false,
): string => {
if (loading) {
return "查询中...";
}
if (!pipeIds?.length) {
return "N/A";
}
const values = pipeIds.map((pipeId) => {
const diameter = diameters?.[pipeId];
const displayValue =
typeof diameter === "number" && Number.isFinite(diameter)
? `${diameter} mm`
: "N/A";
return pipeIds.length === 1 ? displayValue : `${pipeId}: ${displayValue}`;
});
return values.join("");
};
@@ -158,6 +158,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
properties: {
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({
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": ["case", ...fillCases, defaultFillColor],
"circle-stroke-color": ["case", ...fillCases, defaultStrokeColor],
} as FlatStyleLike);
"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) => {
const handleDateTimeChange = useCallback(
(newDate: Date | null) => {
if (newDate) {
setSelectedDateTime(getRoundedDate(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;
}
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,
});
};
source.on("tileloadend", listener);
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);
+14 -4
View File
@@ -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({
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);
@@ -1,17 +1,21 @@
import ApplyIcon from "@mui/icons-material/Check";
import ColorLensIcon from "@mui/icons-material/ColorLens";
import PaletteIcon from "@mui/icons-material/PaletteOutlined";
import ResetIcon from "@mui/icons-material/Refresh";
import {
Box,
Button,
Checkbox,
Chip,
Divider,
FormControl,
FormControlLabel,
IconButton,
InputLabel,
MenuItem,
Select,
Slider,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import React from "react";
@@ -23,14 +27,74 @@ import {
RAINBOW_PALETTES,
SINGLE_COLOR_PALETTES,
} from "./styleEditorPresets";
import { StyleEditorFormProps } from "./styleEditorTypes";
import type {
ClassificationMethod,
ColorType,
StyleEditorFormProps,
} from "./styleEditorTypes";
import {
getSizePreviewColors,
getDefaultCustomColors,
hexToRgba,
resolveStyleColors,
rgbaToHex,
} from "./styleEditorUtils";
const sectionSx = { px: 2, py: 1.5 } as const;
const sliderSx = { mx: 1, width: "calc(100% - 16px)" } as const;
type PalettePreview = { name: string; colors: string[]; index: number };
const getPalettePreviews = (colorType: ColorType): PalettePreview[] => {
switch (colorType) {
case "single":
return SINGLE_COLOR_PALETTES.map((palette, index) => ({
name: `单色 ${index + 1}`,
colors: [palette.color],
index,
}));
case "gradient":
return GRADIENT_PALETTES.map((palette, index) => ({
name: palette.name,
colors: [palette.start, palette.end],
index,
}));
case "rainbow":
return RAINBOW_PALETTES.map((palette, index) => ({
name: palette.name,
colors: palette.colors,
index,
}));
default:
return [];
}
};
const getSelectedPaletteIndex = (styleConfig: StyleEditorFormProps["styleConfig"]) => {
switch (styleConfig.colorType) {
case "single":
return styleConfig.singlePaletteIndex;
case "gradient":
return styleConfig.gradientPaletteIndex;
case "rainbow":
return styleConfig.rainbowPaletteIndex;
default:
return -1;
}
};
const selectPalette = (colorType: ColorType, index: number) => {
switch (colorType) {
case "single":
return { singlePaletteIndex: index };
case "gradient":
return { gradientPaletteIndex: index };
case "rainbow":
return { rainbowPaletteIndex: index };
default:
return {};
}
};
const StyleEditorForm: React.FC<StyleEditorFormProps> = ({
renderLayers,
selectedRenderLayer,
@@ -42,417 +106,173 @@ const StyleEditorForm: React.FC<StyleEditorFormProps> = ({
onClassificationMethodChange,
onSegmentsChange,
onCustomBreakChange,
onCustomBreakBlur,
onColorTypeChange,
onApply,
onReset,
validationErrors,
isDirty,
isApplying,
}) => {
const renderColorSetting = () => {
if (styleConfig.colorType === "single") {
return (
<FormControl variant="standard" fullWidth margin="dense" className="mt-3">
<InputLabel></InputLabel>
<Select
value={styleConfig.singlePaletteIndex}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
singlePaletteIndex: Number(e.target.value),
}))
}
>
{SINGLE_COLOR_PALETTES.map((palette, index) => (
<MenuItem key={index} value={index}>
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
<Box
sx={{
width: "80%",
height: 16,
borderRadius: 2,
background: palette.color,
marginRight: 1,
border: "1px solid #ccc",
}}
/>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
);
}
const layerType = selectedRenderLayer?.get("type");
const previewColors = resolveStyleColors(styleConfig);
const selectedProperty = availableProperties.some(
(property) => property.value === styleConfig.property,
)
? styleConfig.property
: "";
if (styleConfig.colorType === "gradient") {
return (
<FormControl variant="standard" fullWidth margin="dense" className="mt-3">
<InputLabel></InputLabel>
<Select
value={styleConfig.gradientPaletteIndex}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
gradientPaletteIndex: Number(e.target.value),
}))
}
>
{GRADIENT_PALETTES.map((palette, index) => {
const previewColors = resolveStyleColors(
{ ...styleConfig, colorType: "gradient", gradientPaletteIndex: index },
styleConfig.segments + 1
const setCustomColor = (index: number, hex: string) => {
setStyleConfig((previous) => {
const customColors = getDefaultCustomColors(
previous.segments,
previous.customColors,
);
return (
<MenuItem key={index} value={index}>
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
<Box
sx={{
width: "80%",
height: 16,
borderRadius: 2,
display: "flex",
overflow: "hidden",
marginRight: 1,
border: "1px solid #ccc",
}}
>
{previewColors.map((color, colorIndex) => (
<Box
key={colorIndex}
sx={{ flex: 1, backgroundColor: color }}
/>
))}
</Box>
</Box>
</MenuItem>
);
})}
</Select>
</FormControl>
);
}
if (styleConfig.colorType === "rainbow") {
return (
<FormControl variant="standard" fullWidth margin="dense" className="mt-3">
<InputLabel></InputLabel>
<Select
value={styleConfig.rainbowPaletteIndex}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
rainbowPaletteIndex: Number(e.target.value),
}))
}
>
{RAINBOW_PALETTES.map((palette, index) => {
const previewColors = Array.from(
{ length: styleConfig.segments + 1 },
(_, colorIndex) => palette.colors[colorIndex % palette.colors.length]
);
return (
<MenuItem key={index} value={index}>
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
<Typography sx={{ marginRight: 1 }}>{palette.name}</Typography>
<Box
sx={{
width: "60%",
height: 16,
borderRadius: 2,
display: "flex",
border: "1px solid #ccc",
overflow: "hidden",
}}
>
{previewColors.map((color, colorIndex) => (
<Box
key={colorIndex}
sx={{ flex: 1, backgroundColor: color }}
/>
))}
</Box>
</Box>
</MenuItem>
);
})}
</Select>
</FormControl>
);
}
if (styleConfig.colorType === "custom") {
return (
<Box className="mt-3">
<Typography variant="subtitle2" gutterBottom>
</Typography>
<Box
className="flex flex-col gap-2"
sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "4px" }}
>
{Array.from({ length: styleConfig.segments }).map((_, index) => {
const color = styleConfig.customColors?.[index] || "rgba(0,0,0,1)";
return (
<Box key={index} className="flex items-center gap-2">
<Typography variant="caption" sx={{ width: 40 }}>
{index + 1}
</Typography>
<input
type="color"
value={rgbaToHex(color)}
onChange={(e) => {
const nextColor = hexToRgba(e.target.value);
setStyleConfig((prev) => {
const nextColors = [...(prev.customColors || [])];
while (nextColors.length < prev.segments) {
nextColors.push("rgba(0,0,0,1)");
}
nextColors[index] = nextColor;
return { ...prev, customColors: nextColors };
customColors[index] = hexToRgba(hex);
return { ...previous, customColors };
});
}}
style={{
width: "100%",
height: "32px",
cursor: "pointer",
border: "1px solid #ccc",
borderRadius: "4px",
}}
/>
</Box>
);
})}
</Box>
</Box>
);
}
return null;
};
const renderSizeSetting = () => {
const previewColors = getSizePreviewColors(styleConfig);
const renderPalette = () => {
const palettes = getPalettePreviews(styleConfig.colorType);
if (palettes.length === 0) return null;
const selectedIndex = getSelectedPaletteIndex(styleConfig);
if (selectedRenderLayer?.get("type") === "point") {
return (
<Box className="mt-3">
<Typography gutterBottom>
: {styleConfig.minSize} - {styleConfig.maxSize}
</Typography>
<Box className="flex items-center gap-4">
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.minSize}
onChange={(_, value) =>
setStyleConfig((prev) => ({ ...prev, minSize: value as number }))
}
min={2}
max={8}
step={1}
size="small"
/>
</Box>
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.maxSize}
onChange={(_, value) =>
setStyleConfig((prev) => ({ ...prev, maxSize: value as number }))
}
min={10}
max={16}
step={1}
size="small"
/>
</Box>
</Box>
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
<Typography variant="caption">:</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 1 }}>
{palettes.map((palette) => (
<Tooltip key={palette.name} title={palette.name} placement="top">
<Box
component="button"
type="button"
aria-label={palette.name}
onClick={() =>
setStyleConfig((previous) => ({
...previous,
...selectPalette(previous.colorType, palette.index),
}))
}
sx={{
width: styleConfig.minSize,
height: styleConfig.minSize,
borderRadius: "50%",
backgroundColor: previewColors[0],
display: "flex",
height: 30,
p: 0.5,
overflow: "hidden",
cursor: "pointer",
bgcolor: "background.paper",
border: "1px solid",
borderColor: selectedIndex === palette.index ? "primary.main" : "divider",
borderRadius: 1,
boxShadow: selectedIndex === palette.index ? "0 0 0 1px currentColor" : "none",
}}
/>
<Typography variant="caption"></Typography>
<Box
sx={{
width: styleConfig.maxSize,
height: styleConfig.maxSize,
borderRadius: "50%",
backgroundColor: previewColors[previewColors.length - 1],
}}
/>
>
{palette.colors.map((color, index) => (
<Box key={`${color}-${index}`} sx={{ flex: 1, bgcolor: color }} />
))}
</Box>
</Tooltip>
))}
</Box>
);
}
};
if (selectedRenderLayer?.get("type") === "linestring") {
return (
<Box className="mt-3">
<FormControlLabel
control={
<Checkbox
checked={styleConfig.adjustWidthByProperty}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
adjustWidthByProperty: e.target.checked,
}))
}
disabled={styleConfig.colorType === "single"}
/>
}
label="根据数值分段调整线条宽度"
/>
{styleConfig.adjustWidthByProperty ? (
<>
<Typography gutterBottom>
线: {styleConfig.minStrokeWidth} - {styleConfig.maxStrokeWidth}
px
</Typography>
<Box className="flex items-center gap-4">
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.minStrokeWidth}
onChange={(_, value) =>
setStyleConfig((prev) => ({
...prev,
minStrokeWidth: value as number,
}))
}
min={1}
max={4}
step={0.5}
size="small"
/>
</Box>
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.maxStrokeWidth}
onChange={(_, value) =>
setStyleConfig((prev) => ({
...prev,
maxStrokeWidth: value as number,
}))
}
min={6}
max={12}
step={0.5}
size="small"
/>
</Box>
</Box>
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
<Typography variant="caption">:</Typography>
<Box
sx={{
width: 50,
height: styleConfig.minStrokeWidth,
backgroundColor: previewColors[0],
border: `1px solid ${previewColors[0]}`,
borderRadius: 1,
position: "absolute",
top: 72,
left: 12,
zIndex: 1300,
width: "min(380px, calc(100vw - 24px))",
maxHeight: "calc(100dvh - 92px)",
display: "flex",
flexDirection: "column",
bgcolor: "background.paper",
border: "none",
borderRadius: "12px",
boxShadow:
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
backdropFilter: "blur(8px)",
opacity: 0.95,
transition: "opacity 0.3s ease-in-out",
overflow: "hidden",
"&:hover": {
opacity: 1,
},
}}
/>
<Typography variant="caption"></Typography>
>
<Box
sx={{
width: 50,
height: styleConfig.maxStrokeWidth,
backgroundColor: previewColors[previewColors.length - 1],
border: `1px solid ${previewColors[previewColors.length - 1]}`,
borderRadius: 1,
px: 2.5,
py: 1.5,
display: "flex",
alignItems: "center",
gap: 1,
bgcolor: "rgba(255,255,255,0.98)",
color: "text.primary",
borderBottom: "1px solid",
borderColor: "divider",
}}
/>
</Box>
</>
) : (
<>
<Typography gutterBottom>
线: {styleConfig.fixedStrokeWidth}px
>
<PaletteIcon fontSize="small" sx={{ color: "#257DD4" }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, flex: 1 }}>
</Typography>
<Slider
value={styleConfig.fixedStrokeWidth}
onChange={(_, value) =>
setStyleConfig((prev) => ({
...prev,
fixedStrokeWidth: value as number,
}))
}
min={1}
max={10}
step={0.5}
{isDirty && (
<Chip
label="待应用"
size="small"
/>
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
<Typography variant="caption">:</Typography>
<Box
sx={{
width: 50,
height: styleConfig.fixedStrokeWidth,
backgroundColor: previewColors[0],
border: `1px solid ${previewColors[0]}`,
borderRadius: 1,
height: 24,
color: "warning.dark",
bgcolor: "warning.50",
border: "1px solid",
borderColor: "warning.200",
}}
/>
</Box>
</>
)}
</Box>
);
}
return null;
};
return (
<div className="absolute top-20 left-4 bg-white p-4 rounded-xl shadow-lg opacity-95 hover:opacity-100 transition-opacity w-80 z-1300">
<FormControl variant="standard" fullWidth margin="dense">
<InputLabel></InputLabel>
<Box sx={{ overflowY: "auto", overflowX: "hidden", minHeight: 0 }}>
<Box sx={sectionSx}>
<Typography variant="overline" color="text.secondary">
</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25, mt: 0.5 }}>
<FormControl size="small" fullWidth>
<InputLabel></InputLabel>
<Select
value={selectedRenderLayer ? renderLayers.indexOf(selectedRenderLayer) : ""}
onChange={(e) => onLayerChange(e.target.value as number)}
label="图层"
value={selectedRenderLayer?.get("value") || ""}
onChange={(event) => onLayerChange(String(event.target.value))}
>
{renderLayers.map((layer, index) => (
<MenuItem key={index} value={index}>
{renderLayers.map((layer) => (
<MenuItem key={layer.get("value")} value={layer.get("value")}>
{layer.get("name")}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl variant="standard" fullWidth margin="dense">
<InputLabel></InputLabel>
<FormControl size="small" fullWidth>
<InputLabel></InputLabel>
<Select
value={styleConfig.property}
onChange={(e) => onPropertyChange(e.target.value)}
disabled={!selectedRenderLayer}
label="属性"
value={selectedProperty}
onChange={(event) => onPropertyChange(String(event.target.value))}
>
{availableProperties.map((property) => (
<MenuItem key={property.name} value={property.value}>
<MenuItem key={property.value} value={property.value}>
{property.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl variant="standard" fullWidth margin="dense">
<InputLabel></InputLabel>
<FormControl size="small" fullWidth>
<InputLabel></InputLabel>
<Select
label="分级方法"
value={styleConfig.classificationMethod}
onChange={(e) => onClassificationMethodChange(e.target.value)}
onChange={(event) =>
onClassificationMethodChange(event.target.value as ClassificationMethod)
}
>
{CLASSIFICATION_METHODS.map((method) => (
<MenuItem key={method.value} value={method.value}>
@@ -461,53 +281,28 @@ const StyleEditorForm: React.FC<StyleEditorFormProps> = ({
))}
</Select>
</FormControl>
<Box className="mt-3">
<Typography gutterBottom>: {styleConfig.segments}</Typography>
<Slider
value={styleConfig.segments}
onChange={(_, value) => onSegmentsChange(value as number)}
min={2}
max={10}
step={1}
marks
size="small"
/>
</Box>
{styleConfig.classificationMethod === "custom_breaks" && (
<Box className="mt-3 p-2 bg-gray-50 rounded">
<Typography variant="subtitle2" gutterBottom>
{">="} 0
</Typography>
<Box
className="flex flex-col gap-2"
sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "12px" }}
>
{Array.from({ length: styleConfig.segments }).map((_, index) => (
<TextField
key={index}
label={`阈值 ${index + 1}`}
type="number"
size="small"
slotProps={{ input: { inputProps: { min: 0, step: 0.1 } } }}
value={styleConfig.customBreaks?.[index] ?? ""}
onChange={(e) => onCustomBreakChange(index, e.target.value)}
onBlur={onCustomBreakBlur}
label="分类数量"
type="number"
value={styleConfig.segments}
onChange={(event) => onSegmentsChange(Number(event.target.value))}
slotProps={{ htmlInput: { min: 2, max: 10, step: 1 } }}
/>
))}
</Box>
</Box>
)}
<FormControl variant="standard" fullWidth margin="dense">
<InputLabel>
<ColorLensIcon className="mr-1" />
</InputLabel>
<Divider />
<Box sx={sectionSx}>
<Typography variant="overline" color="text.secondary">
</Typography>
<FormControl size="small" fullWidth sx={{ mt: 0.5, mb: 1 }}>
<InputLabel></InputLabel>
<Select
label="颜色方案"
value={styleConfig.colorType}
onChange={(e) => onColorTypeChange(e.target.value)}
onChange={(event) => onColorTypeChange(event.target.value as ColorType)}
>
{COLOR_TYPE_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
@@ -515,75 +310,195 @@ const StyleEditorForm: React.FC<StyleEditorFormProps> = ({
</MenuItem>
))}
</Select>
{renderColorSetting()}
</FormControl>
{renderPalette()}
{renderSizeSetting()}
<Box className="mt-3">
<Typography gutterBottom>
: {(styleConfig.opacity * 100).toFixed(0)}%
{styleConfig.classificationMethod === "custom_breaks" && (
<Box sx={{ mt: 1.25, display: "grid", gap: 0.75 }}>
{Array.from({ length: styleConfig.segments }, (_, index) => (
<Box
key={index}
sx={{
display: "grid",
gridTemplateColumns:
styleConfig.colorType === "custom" ? "32px 1fr 12px 1fr" : "12px 1fr 12px 1fr",
gap: 0.75,
alignItems: "center",
}}
>
{styleConfig.colorType === "custom" ? (
<Tooltip title={`区间 ${index + 1} 颜色`}>
<Box
component="input"
type="color"
aria-label={`区间 ${index + 1} 颜色`}
value={rgbaToHex(styleConfig.customColors?.[index] || previewColors[index])}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setCustomColor(index, event.target.value)
}
sx={{ width: 32, height: 30, p: 0, border: 0, bgcolor: "transparent" }}
/>
</Tooltip>
) : (
<Box sx={{ width: 10, height: 24, bgcolor: previewColors[index], borderRadius: 0.5 }} />
)}
<TextField
size="small"
type="number"
value={Number.isFinite(styleConfig.customBreaks?.[index]) ? styleConfig.customBreaks?.[index] : ""}
onChange={(event) => onCustomBreakChange(index, event.target.value)}
inputProps={{ "aria-label": `区间 ${index + 1} 下界`, step: 0.1 }}
/>
<Typography variant="caption" color="text.secondary" textAlign="center">
</Typography>
<TextField
size="small"
type="number"
value={Number.isFinite(styleConfig.customBreaks?.[index + 1]) ? styleConfig.customBreaks?.[index + 1] : ""}
onChange={(event) => onCustomBreakChange(index + 1, event.target.value)}
inputProps={{ "aria-label": `区间 ${index + 1} 上界`, step: 0.1 }}
/>
</Box>
))}
</Box>
)}
</Box>
<Divider />
<Box sx={sectionSx}>
<Typography variant="overline" color="text.secondary">
</Typography>
{layerType === "point" ? (
<Box sx={{ mt: 0.5 }}>
<Typography variant="caption">
{styleConfig.minSize} - {styleConfig.maxSize}px
</Typography>
<Slider
value={[styleConfig.minSize, styleConfig.maxSize]}
onChange={(_, value) => {
const [minSize, maxSize] = value as number[];
setStyleConfig((previous) => ({ ...previous, minSize, maxSize }));
}}
min={2}
max={16}
step={1}
size="small"
sx={sliderSx}
/>
</Box>
) : (
<Box sx={{ mt: 0.5 }}>
<FormControlLabel
control={
<Checkbox
size="small"
checked={styleConfig.adjustWidthByProperty}
onChange={(event) =>
setStyleConfig((previous) => ({
...previous,
adjustWidthByProperty: event.target.checked,
}))
}
/>
}
label={<Typography variant="body2">线</Typography>}
/>
<Typography variant="caption">
{styleConfig.adjustWidthByProperty
? `${styleConfig.minStrokeWidth} - ${styleConfig.maxStrokeWidth}px`
: `${styleConfig.fixedStrokeWidth}px`}
</Typography>
<Slider
value={
styleConfig.adjustWidthByProperty
? [styleConfig.minStrokeWidth, styleConfig.maxStrokeWidth]
: styleConfig.fixedStrokeWidth
}
onChange={(_, value) =>
setStyleConfig((previous) =>
Array.isArray(value)
? { ...previous, minStrokeWidth: value[0], maxStrokeWidth: value[1] }
: { ...previous, fixedStrokeWidth: value },
)
}
min={1}
max={12}
step={0.5}
size="small"
sx={sliderSx}
/>
</Box>
)}
<Typography variant="caption"> {Math.round(styleConfig.opacity * 100)}%</Typography>
<Slider
value={styleConfig.opacity}
onChange={(_, value) =>
setStyleConfig((prev) => ({ ...prev, opacity: value as number }))
setStyleConfig((previous) => ({ ...previous, opacity: value as number }))
}
min={0.1}
max={1}
step={0.05}
size="small"
sx={sliderSx}
/>
</Box>
<Divider />
<Box sx={{ ...sectionSx, display: "flex", gap: 2 }}>
<FormControlLabel
control={
<Checkbox
checked={styleConfig.showId}
onChange={(e) =>
setStyleConfig((prev) => ({ ...prev, showId: e.target.checked }))
}
/>
}
label="显示 ID(缩放 >=15 级时显示)"
/>
<FormControlLabel
control={
<Checkbox
size="small"
checked={styleConfig.showLabels}
onChange={(e) =>
setStyleConfig((prev) => ({ ...prev, showLabels: e.target.checked }))
onChange={(event) =>
setStyleConfig((previous) => ({ ...previous, showLabels: event.target.checked }))
}
/>
}
label="显示属性(缩放 >=15 级时显示)"
label={<Typography variant="body2"></Typography>}
/>
<FormControlLabel
control={
<Checkbox
size="small"
checked={styleConfig.showId}
onChange={(event) =>
setStyleConfig((previous) => ({ ...previous, showId: event.target.checked }))
}
/>
}
label={<Typography variant="body2"> ID</Typography>}
/>
</Box>
</Box>
<div className="my-3"></div>
<Box className="flex gap-2">
<Divider />
<Box sx={{ px: 2, py: 1.25 }}>
{validationErrors[0] && (
<Typography variant="caption" color="error" sx={{ display: "block", mb: 0.75 }}>
{validationErrors[0]}
</Typography>
)}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
<Tooltip title="恢复默认样式">
<IconButton size="small" onClick={onReset} aria-label="恢复默认样式">
<ResetIcon fontSize="small" />
</IconButton>
</Tooltip>
<Button
variant="contained"
color="primary"
onClick={onApply}
disabled={!selectedRenderLayer || !styleConfig.property}
size="small"
startIcon={<ApplyIcon />}
fullWidth
onClick={onApply}
disabled={!isDirty || validationErrors.length > 0 || isApplying}
>
</Button>
<Button
variant="outlined"
onClick={onReset}
disabled={!selectedRenderLayer}
startIcon={<ResetIcon />}
fullWidth
>
{isApplying ? "应用中" : "应用"}
</Button>
</Box>
</div>
</Box>
</Box>
);
};
@@ -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];
+197 -118
View File
@@ -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>
+347 -4
View File
@@ -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;
return Array.from(
{ length: breaksLength },
(_, index) => baseColors[index % baseColors.length]
);
}
const customColors = styleConfig.customColors || [];
const reverseRainbowColors = RAINBOW_PALETTES[1].colors;
const result = [...customColors];
while (result.length < breaksLength) {
result.push(
reverseRainbowColors[
(result.length - customColors.length) % reverseRainbowColors.length
]
);
}
return result.slice(0, breaksLength);
};
export const getSizePreviewColors = (styleConfig: StyleConfig) => {
if (styleConfig.colorType === "single") {
const color = SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color;
return [color, color];
}
if (styleConfig.colorType === "gradient") {
const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex];
return [start, end];
}
if (styleConfig.colorType === "rainbow") {
const rainbowColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors;
return [rainbowColors[0], rainbowColors[rainbowColors.length - 1]];
}
const customColors = styleConfig.customColors || [];
return [
customColors[0] || "rgba(0,0,0,1)",
customColors[customColors.length - 1] || "rgba(0,0,0,1)",
const palette = RAINBOW_PALETTES[
clampIndex(styleConfig.rainbowPaletteIndex, RAINBOW_PALETTES.length)
];
return Array.from(
{ length: classCount },
(_, index) => palette.colors[index % palette.colors.length],
);
}
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[];
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])}`,
);
return { boundaries, colors, dimensions, labels, isConstant };
};
const valueExpression = (property: string): any[] => ["get", property];
const buildVariableCase = (property: string, classCount: number, variablePrefix: string) => {
const expression: any[] = ["case"];
for (let index = 0; index < classCount - 1; index += 1) {
expression.push(
["<=", valueExpression(property), ["var", `tj_break_${index + 1}`]],
["var", `${variablePrefix}_${index}`],
);
}
expression.push(["var", `${variablePrefix}_${classCount - 1}`]);
return expression;
};
export const buildDynamicStyleTemplate = ({
layerType,
property,
classCount,
}: {
layerType: string;
property: string;
classCount: number;
}): FlatStyleLike => {
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})`
);
}
const defaultColor = parseColor(colors[0]);
conditions.push(
`rgba(${defaultColor.r}, ${defaultColor.g}, ${defaultColor.b}, ${styleConfig.opacity})`
);
return conditions;
};
const generateDimensionConditions = (property: string): any[] => {
const conditions: any[] = ["case"];
for (let index = 0; index < breaks.length; index++) {
if (property === "unit_headloss") {
conditions.push([
"<=",
["/", ["get", "headloss"], ["get", "length"]],
breaks[index],
]);
} else {
conditions.push(["<=", ["get", property], breaks[index]]);
}
conditions.push(dimensions[index]);
}
conditions.push(dimensions[dimensions.length - 1]);
return conditions;
};
const generatePointDimensionConditions = (property: string): any[] => {
const conditions: any[] = ["case"];
for (let index = 0; index < breaks.length; index++) {
conditions.push(["<=", ["get", property], breaks[index]]);
conditions.push(["interpolate", ["linear"], ["zoom"], 12, 1, 24, dimensions[index]]);
}
conditions.push(dimensions[dimensions.length - 1]);
return conditions;
};
const dynamicStyle: FlatStyleLike = {};
const color = buildVariableCase(property, classCount, "tj_color");
const dimension = buildVariableCase(property, classCount, "tj_size");
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;
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,
};
};
return dynamicStyle;
export const buildStyleVariables = (
styleConfig: StyleConfig,
resolvedStyle: ResolvedLayerStyle,
): StyleVariables => {
const variables: StyleVariables = {};
resolvedStyle.boundaries.slice(1, -1).forEach((boundary, index) => {
variables[`tj_break_${index + 1}`] = boundary;
});
resolvedStyle.colors.forEach((color, index) => {
variables[`tj_color_${index}`] = withOpacity(color, styleConfig.opacity);
});
resolvedStyle.dimensions.forEach((dimension, index) => {
variables[`tj_size_${index}`] = dimension;
});
return variables;
};
export const buildContourDefinitions = ({
@@ -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],
);
};
+301 -309
View File
@@ -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;
}
return false;
});
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;
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 [];
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];
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);
}
if (!prev) {
return [newMin, newMax];
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 [Math.min(prev[0], newMin), Math.max(prev[1], newMax)];
return [
{
instanceKey: `${instance.instanceKey}/${partIndex}`,
id: instance.featureId,
diameter: instance.properties.diameter || 0,
length: instance.properties.length || fragmentLength * 1000,
path,
position: midPoint,
angle: lineAngle,
timestamps,
fragmentLength,
},
];
});
}
}, []);
const publishActiveTileSnapshot = useCallback(
(targetMap: OlMap) => {
const zoom = targetMap.getView().getZoom() ?? 0;
const junctionSnapshot = junctionIndexRef.current?.getSnapshot(
targetMap,
zoom,
);
const pipeSnapshot = pipeIndexRef.current?.getSnapshot(targetMap, zoom);
const nextJunctionData = Array.from(
junctionSnapshot?.instancesById.values() ?? [],
)
.map((instances) => instances[0])
.filter(Boolean)
.map((instance) => {
const [x, y] = lineStringFromFlatCoordinates(
instance.flatCoordinates,
instance.stride,
)[0];
return {
id: instance.featureId,
instanceKey: instance.instanceKey,
position: toLonLat([x, y]),
elevation: instance.properties.elevation || 0,
demand: instance.properties.demand || 0,
};
})
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
const setPipeData = (newData: any[]) => {
const uniqueNewData = newData.filter((item) => {
if (!item || !item.id) return false;
if (!pipeDataIds.current.has(item.id)) {
pipeDataIds.current.add(item.id);
return true;
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);
}
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,
const nextPipeLabels = Array.from(labelById.values()).sort((a, b) =>
String(a.id).localeCompare(String(b.id)),
);
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;
};
}, []);
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;
try {
const view = map.getView();
const zoom = view.getZoom() || 0;
setCurrentZoom(zoom);
junctionIndexRef.current?.scanLoadedTiles();
pipeIndexRef.current?.scanLoadedTiles();
scheduleActiveTileSnapshot();
try {
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);
});
});
+19 -6
View File
@@ -28,25 +28,34 @@ const removeTransientResources = <T extends MapResource>(
});
};
const releaseLayer = (layer: any, dispose: boolean) => {
type ReleaseLayerOptions = {
disposeLayer: boolean;
disposeSource: boolean;
};
const releaseLayer = (layer: any, options: ReleaseLayerOptions) => {
const childLayers = layer.getLayers?.().getArray?.();
if (Array.isArray(childLayers)) {
[...childLayers].forEach((childLayer) => releaseLayer(childLayer, dispose));
[...childLayers].forEach((childLayer) => releaseLayer(childLayer, options));
layer.getLayers().clear();
}
const source = layer.getSource?.();
if (options.disposeSource) {
try {
source?.clear?.();
} catch {
// Some third-party sources do not support explicit cache clearing.
}
if (dispose) {
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]);
});
});
});
+27 -6
View File
@@ -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);
},
};
};
+5 -1
View File
@@ -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,
}
);
+19
View File
@@ -0,0 +1,19 @@
import { FLOW_DISPLAY_UNIT, isLpsFlowProperty, toM3h } from "./units";
describe("flow display units", () => {
it("uses cubic meters per hour as the flow display unit", () => {
expect(FLOW_DISPLAY_UNIT).toBe("m³/h");
});
it("converts L/s values to m³/h", () => {
expect(toM3h(10, "lps")).toBe(36);
expect(toM3h(10, "L/s")).toBe(36);
});
it("recognizes computed properties that arrive from the backend in L/s", () => {
expect(isLpsFlowProperty("flow")).toBe(true);
expect(isLpsFlowProperty("actual_demand")).toBe(true);
expect(isLpsFlowProperty("actualdemand")).toBe(true);
expect(isLpsFlowProperty("pressure")).toBe(false);
});
});
+4
View File
@@ -1,5 +1,9 @@
export const FLOW_DISPLAY_UNIT = "m³/h";
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;