Compare commits
19
Commits
f5e7312e3b
...
0d559f6130
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d559f6130 | ||
|
|
7af90e495d | ||
|
|
a52c04204d | ||
|
|
08152ff978 | ||
|
|
589cf45aa7 | ||
|
|
59447a100c | ||
|
|
4adbcc1c4c | ||
|
|
202f18332f | ||
|
|
acf13639ef | ||
|
|
d986e563a6 | ||
|
|
d8ee2e1f0c | ||
|
|
fddb0ceb34 | ||
|
|
3d7b594682 | ||
|
|
eb8950c89a | ||
|
|
ef2b045306 | ||
|
|
209da0d295 | ||
|
|
dfaee645ff | ||
|
|
d90ca7c951 | ||
|
|
041b4ef89d |
@@ -4,7 +4,6 @@ import { Refine, type AuthProvider } from "@refinedev/core";
|
|||||||
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
||||||
import {
|
import {
|
||||||
RefineSnackbarProvider,
|
RefineSnackbarProvider,
|
||||||
useNotificationProvider,
|
|
||||||
} from "@refinedev/mui";
|
} from "@refinedev/mui";
|
||||||
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
@@ -18,6 +17,7 @@ import { ProjectProvider } from "@/contexts/ProjectContext";
|
|||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
|
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
|
||||||
|
|
||||||
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
||||||
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
|
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
|
||||||
@@ -165,7 +165,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
<Refine
|
<Refine
|
||||||
routerProvider={routerProvider}
|
routerProvider={routerProvider}
|
||||||
dataProvider={dataProvider}
|
dataProvider={dataProvider}
|
||||||
notificationProvider={useNotificationProvider}
|
notificationProvider={useAppNotificationProvider}
|
||||||
authProvider={authProvider}
|
authProvider={authProvider}
|
||||||
resources={[
|
resources={[
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
import { parseApplyLayerStylePayload } from "./toolCallStyleHelpers";
|
||||||
|
|
||||||
|
describe("parseApplyLayerStylePayload", () => {
|
||||||
|
it("accepts a valid snake_case interval contract", () => {
|
||||||
|
expect(
|
||||||
|
parseApplyLayerStylePayload({
|
||||||
|
layer_id: "pipes",
|
||||||
|
style_config: {
|
||||||
|
property: "velocity",
|
||||||
|
classification_method: "custom_breaks",
|
||||||
|
segments: 3,
|
||||||
|
custom_breaks: [0, 1, 2, 3],
|
||||||
|
color_type: "custom",
|
||||||
|
custom_colors: ["#000000", "#777777", "#ffffff"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
layerId: "pipes",
|
||||||
|
resetToDefault: false,
|
||||||
|
styleConfig: { segments: 3, customBreaks: [0, 1, 2, 3] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid class counts and array cardinalities", () => {
|
||||||
|
expect(
|
||||||
|
parseApplyLayerStylePayload({
|
||||||
|
layer_id: "pipes",
|
||||||
|
style_config: { segments: 1, property: "velocity" },
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseApplyLayerStylePayload({
|
||||||
|
layer_id: "junctions",
|
||||||
|
style_config: {
|
||||||
|
segments: 3,
|
||||||
|
custom_breaks: [0, 1, 2],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps camelCase compatibility", () => {
|
||||||
|
expect(
|
||||||
|
parseApplyLayerStylePayload({
|
||||||
|
layerId: "junctions",
|
||||||
|
styleConfig: { opacity: 0.5, colorType: "gradient" },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ layerId: "junctions", styleConfig: { opacity: 0.5 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { StyleConfig, DefaultLayerStyleId } from "@components/olmap/core/Controls/styleEditorTypes";
|
import type {
|
||||||
|
ClassificationMethod,
|
||||||
|
ColorType,
|
||||||
|
StyleConfig,
|
||||||
|
DefaultLayerStyleId,
|
||||||
|
} from "@components/olmap/core/Controls/styleEditorTypes";
|
||||||
|
|
||||||
export type ApplyLayerStyleActionPayload = {
|
export type ApplyLayerStyleActionPayload = {
|
||||||
layerId: DefaultLayerStyleId;
|
layerId: DefaultLayerStyleId;
|
||||||
@@ -48,6 +53,23 @@ const asStringArray = (value: unknown): string[] | undefined =>
|
|||||||
.filter((item): item is string => item !== undefined)
|
.filter((item): item is string => item !== undefined)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const asClassificationMethod = (value: unknown): ClassificationMethod | undefined => {
|
||||||
|
const normalized = asString(value);
|
||||||
|
return normalized === "pretty_breaks" || normalized === "custom_breaks"
|
||||||
|
? normalized
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const asColorType = (value: unknown): ColorType | undefined => {
|
||||||
|
const normalized = asString(value);
|
||||||
|
return normalized === "single" ||
|
||||||
|
normalized === "gradient" ||
|
||||||
|
normalized === "rainbow" ||
|
||||||
|
normalized === "custom"
|
||||||
|
? normalized
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export const normalizeStyleLayerId = (value: unknown): DefaultLayerStyleId | null => {
|
export const normalizeStyleLayerId = (value: unknown): DefaultLayerStyleId | null => {
|
||||||
const normalized = asString(value)?.toLowerCase();
|
const normalized = asString(value)?.toLowerCase();
|
||||||
if (normalized === "junctions" || normalized === "pipes") {
|
if (normalized === "junctions" || normalized === "pipes") {
|
||||||
@@ -77,13 +99,25 @@ export const parseApplyLayerStylePayload = (
|
|||||||
? (params.styleConfig as Record<string, unknown>)
|
? (params.styleConfig as Record<string, unknown>)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const classificationValue =
|
||||||
|
rawStyleConfig?.classification_method ?? rawStyleConfig?.classificationMethod;
|
||||||
|
const colorTypeValue = rawStyleConfig?.color_type ?? rawStyleConfig?.colorType;
|
||||||
|
const segmentsValue = rawStyleConfig?.segments;
|
||||||
|
const segments = asNumber(segmentsValue);
|
||||||
|
if (
|
||||||
|
(classificationValue !== undefined && !asClassificationMethod(classificationValue)) ||
|
||||||
|
(colorTypeValue !== undefined && !asColorType(colorTypeValue)) ||
|
||||||
|
(segmentsValue !== undefined &&
|
||||||
|
(!Number.isInteger(segments) || (segments as number) < 2 || (segments as number) > 10))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const styleConfig: Partial<StyleConfig> | undefined = rawStyleConfig
|
const styleConfig: Partial<StyleConfig> | undefined = rawStyleConfig
|
||||||
? {
|
? {
|
||||||
property: asString(rawStyleConfig.property),
|
property: asString(rawStyleConfig.property),
|
||||||
classificationMethod: asString(
|
classificationMethod: asClassificationMethod(classificationValue),
|
||||||
rawStyleConfig.classification_method ?? rawStyleConfig.classificationMethod,
|
segments,
|
||||||
),
|
|
||||||
segments: asNumber(rawStyleConfig.segments),
|
|
||||||
minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize),
|
minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize),
|
||||||
maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize),
|
maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize),
|
||||||
minStrokeWidth: asNumber(
|
minStrokeWidth: asNumber(
|
||||||
@@ -95,7 +129,7 @@ export const parseApplyLayerStylePayload = (
|
|||||||
fixedStrokeWidth: asNumber(
|
fixedStrokeWidth: asNumber(
|
||||||
rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth,
|
rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth,
|
||||||
),
|
),
|
||||||
colorType: asString(rawStyleConfig.color_type ?? rawStyleConfig.colorType),
|
colorType: asColorType(colorTypeValue),
|
||||||
singlePaletteIndex: asNumber(
|
singlePaletteIndex: asNumber(
|
||||||
rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex,
|
rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex,
|
||||||
),
|
),
|
||||||
@@ -121,6 +155,49 @@ export const parseApplyLayerStylePayload = (
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
if (styleConfig) {
|
||||||
|
const numericValues = [
|
||||||
|
styleConfig.minSize,
|
||||||
|
styleConfig.maxSize,
|
||||||
|
styleConfig.minStrokeWidth,
|
||||||
|
styleConfig.maxStrokeWidth,
|
||||||
|
styleConfig.fixedStrokeWidth,
|
||||||
|
].filter((value): value is number => value !== undefined);
|
||||||
|
if (numericValues.some((value) => value <= 0)) return null;
|
||||||
|
const paletteIndexes: Array<[number | undefined, number]> = [
|
||||||
|
[styleConfig.singlePaletteIndex, 7],
|
||||||
|
[styleConfig.gradientPaletteIndex, 3],
|
||||||
|
[styleConfig.rainbowPaletteIndex, 2],
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
paletteIndexes.some(
|
||||||
|
([index, length]) =>
|
||||||
|
index !== undefined &&
|
||||||
|
(!Number.isInteger(index) || index < 0 || index >= length),
|
||||||
|
)
|
||||||
|
) return null;
|
||||||
|
if (
|
||||||
|
styleConfig.opacity !== undefined &&
|
||||||
|
(styleConfig.opacity < 0 || styleConfig.opacity > 1)
|
||||||
|
) return null;
|
||||||
|
if (
|
||||||
|
styleConfig.customBreaks &&
|
||||||
|
styleConfig.customBreaks.some(
|
||||||
|
(value, index, values) => index > 0 && value <= values[index - 1],
|
||||||
|
)
|
||||||
|
) return null;
|
||||||
|
if (
|
||||||
|
styleConfig.segments !== undefined &&
|
||||||
|
styleConfig.customBreaks &&
|
||||||
|
styleConfig.customBreaks.length !== styleConfig.segments + 1
|
||||||
|
) return null;
|
||||||
|
if (
|
||||||
|
styleConfig.segments !== undefined &&
|
||||||
|
styleConfig.customColors &&
|
||||||
|
styleConfig.customColors.length !== styleConfig.segments
|
||||||
|
) return null;
|
||||||
|
}
|
||||||
|
|
||||||
const hasStyleOverrides =
|
const hasStyleOverrides =
|
||||||
styleConfig &&
|
styleConfig &&
|
||||||
Object.values(styleConfig).some((value) =>
|
Object.values(styleConfig).some((value) =>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import {
|
||||||
|
buildBurstDetectionRequest,
|
||||||
|
createBurstDetectionAnalysisParametersState,
|
||||||
|
parseScadaFrequencyMinutes,
|
||||||
|
resolvePressureSamplingInterval,
|
||||||
|
} from "./AnalysisParameters";
|
||||||
|
|
||||||
|
describe("burst detection request", () => {
|
||||||
|
it("requests the latest complete monitoring time by default", () => {
|
||||||
|
const state = createBurstDetectionAnalysisParametersState();
|
||||||
|
state.schemeName = " latest-case ";
|
||||||
|
|
||||||
|
expect(buildBurstDetectionRequest(state, "fengyang")).toEqual({
|
||||||
|
network: "fengyang",
|
||||||
|
scheme_name: "latest-case",
|
||||||
|
sampling_interval_minutes: 15,
|
||||||
|
});
|
||||||
|
expect(state.detectionMode).toBe("latest");
|
||||||
|
expect(state.targetTime?.minute() % 15).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends one target time for historical replay", () => {
|
||||||
|
const targetTime = dayjs("2026-06-20T13:30:00+08:00");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildBurstDetectionRequest(
|
||||||
|
{
|
||||||
|
schemeName: "history-case",
|
||||||
|
detectionMode: "historical",
|
||||||
|
targetTime,
|
||||||
|
samplingIntervalMinutes: 30,
|
||||||
|
samplingIntervalSource: "manual",
|
||||||
|
},
|
||||||
|
"fengyang",
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
network: "fengyang",
|
||||||
|
scheme_name: "history-case",
|
||||||
|
sampling_interval_minutes: 30,
|
||||||
|
target_time: targetTime.toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the dominant pressure SCADA frequency as the editable default", () => {
|
||||||
|
expect(parseScadaFrequencyMinutes("0:15:00")).toBe(15);
|
||||||
|
expect(parseScadaFrequencyMinutes("1:00:00")).toBe(60);
|
||||||
|
expect(
|
||||||
|
resolvePressureSamplingInterval([
|
||||||
|
{ type: "pressure", transmission_frequency: "0:15:00" },
|
||||||
|
{ type: "pressure", transmission_frequency: "0:15:00" },
|
||||||
|
{ type: "pressure", transmission_frequency: "0:30:00" },
|
||||||
|
{ type: "pipe_flow", transmission_frequency: "1:00:00" },
|
||||||
|
]),
|
||||||
|
).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo, useState, useCallback } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|
||||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
CircularProgress,
|
|
||||||
Collapse,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
IconButton,
|
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
||||||
@@ -23,7 +18,7 @@ import { useNotification } from "@refinedev/core";
|
|||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import "dayjs/locale/zh-cn";
|
import "dayjs/locale/zh-cn";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { NETWORK_NAME, config } from "@config/config";
|
import { NETWORK_NAME } from "@config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
import { BurstDetectionResult } from "./types";
|
import { BurstDetectionResult } from "./types";
|
||||||
|
|
||||||
@@ -33,183 +28,149 @@ interface Props {
|
|||||||
onStateChange?: (state: BurstDetectionAnalysisParametersState) => void;
|
onStateChange?: (state: BurstDetectionAnalysisParametersState) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchemeItem {
|
|
||||||
scheme_id: number;
|
|
||||||
scheme_name: string;
|
|
||||||
scheme_type: string;
|
|
||||||
create_time: string;
|
|
||||||
scheme_start_time: string;
|
|
||||||
scheme_detail?: {
|
|
||||||
modify_total_duration: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BurstDetectionAnalysisParametersState {
|
export interface BurstDetectionAnalysisParametersState {
|
||||||
schemeName: string;
|
schemeName: string;
|
||||||
dataSource: "monitoring" | "simulation";
|
detectionMode: "latest" | "historical";
|
||||||
schemes: SchemeItem[];
|
targetTime: Dayjs | null;
|
||||||
selectedSchemeId: number | "";
|
samplingIntervalMinutes: number;
|
||||||
scadaStart: Dayjs | null;
|
samplingIntervalSource: "metadata" | "manual";
|
||||||
scadaEnd: Dayjs | null;
|
|
||||||
mu: number;
|
|
||||||
pointsPerDay: number;
|
|
||||||
nEstimators: number;
|
|
||||||
contaminationInput: string;
|
|
||||||
advancedOpen: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ScadaInfoItem {
|
||||||
|
type?: string;
|
||||||
|
transmission_frequency?: string | number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentQuarterHour = () => {
|
||||||
|
const now = dayjs().second(0).millisecond(0);
|
||||||
|
return now.minute(Math.floor(now.minute() / 15) * 15);
|
||||||
|
};
|
||||||
|
|
||||||
export const createBurstDetectionAnalysisParametersState =
|
export const createBurstDetectionAnalysisParametersState =
|
||||||
(): BurstDetectionAnalysisParametersState => ({
|
(): BurstDetectionAnalysisParametersState => ({
|
||||||
schemeName: `Burst_Detection_${Date.now()}`,
|
schemeName: `Burst_Detection_${Date.now()}`,
|
||||||
dataSource: "monitoring",
|
detectionMode: "latest",
|
||||||
schemes: [],
|
targetTime: currentQuarterHour(),
|
||||||
selectedSchemeId: "",
|
samplingIntervalMinutes: 15,
|
||||||
scadaStart: dayjs().subtract(3, "day"),
|
samplingIntervalSource: "metadata",
|
||||||
scadaEnd: dayjs(),
|
|
||||||
mu: 100,
|
|
||||||
pointsPerDay: 96,
|
|
||||||
nEstimators: 50,
|
|
||||||
contaminationInput: "auto",
|
|
||||||
advancedOpen: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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> = ({
|
const AnalysisParameters: React.FC<Props> = ({
|
||||||
onResult,
|
onResult,
|
||||||
state,
|
state,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const [parametersState, setParametersState, setFormField] = useControllableObjectState(
|
const [parametersState, setParametersState, setFormField] =
|
||||||
|
useControllableObjectState(
|
||||||
state,
|
state,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
createBurstDetectionAnalysisParametersState(),
|
createBurstDetectionAnalysisParametersState(),
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
schemeName,
|
schemeName,
|
||||||
dataSource,
|
detectionMode,
|
||||||
schemes,
|
targetTime,
|
||||||
selectedSchemeId,
|
samplingIntervalMinutes,
|
||||||
scadaStart,
|
samplingIntervalSource,
|
||||||
scadaEnd,
|
|
||||||
mu,
|
|
||||||
pointsPerDay,
|
|
||||||
nEstimators,
|
|
||||||
contaminationInput,
|
|
||||||
advancedOpen,
|
|
||||||
} = parametersState;
|
} = parametersState;
|
||||||
const [schemeLoading, setSchemeLoading] = useState(false);
|
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const isSimulationMode = dataSource === "simulation";
|
const [frequencyLoading, setFrequencyLoading] = useState(false);
|
||||||
|
|
||||||
const applySchemeTimeRange = useCallback((scheme: SchemeItem) => {
|
useEffect(() => {
|
||||||
const start = dayjs(scheme.scheme_start_time);
|
if (samplingIntervalSource !== "metadata") return;
|
||||||
const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600;
|
let active = true;
|
||||||
const end = start.add(durationSeconds, "second");
|
setFrequencyLoading(true);
|
||||||
|
api
|
||||||
setParametersState((previous) => ({
|
.get("/api/v1/getallscadainfo/", { params: { network: NETWORK_NAME } })
|
||||||
...previous,
|
.then((response) => {
|
||||||
scadaStart: start,
|
if (!active) return;
|
||||||
scadaEnd: end,
|
const interval = resolvePressureSamplingInterval(
|
||||||
}));
|
response.data as ScadaInfoItem[],
|
||||||
}, [setParametersState]);
|
|
||||||
|
|
||||||
const fetchSchemes = useCallback(
|
|
||||||
async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => {
|
|
||||||
if (schemeLoading || (!force && schemes.length > 0)) return;
|
|
||||||
|
|
||||||
setSchemeLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
|
||||||
params: { network: NETWORK_NAME },
|
|
||||||
});
|
|
||||||
const burstSchemes = (response.data as SchemeItem[]).filter(
|
|
||||||
(scheme) => scheme.scheme_type === "burst_analysis",
|
|
||||||
);
|
);
|
||||||
|
setParametersState((previous) =>
|
||||||
setFormField("schemes", burstSchemes);
|
previous.samplingIntervalSource === "metadata"
|
||||||
|
? { ...previous, samplingIntervalMinutes: interval }
|
||||||
if (selectedSchemeId) {
|
: previous,
|
||||||
const matchedScheme = burstSchemes.find(
|
|
||||||
(scheme) => scheme.scheme_id === selectedSchemeId,
|
|
||||||
);
|
);
|
||||||
if (matchedScheme) {
|
})
|
||||||
applySchemeTimeRange(matchedScheme);
|
.catch(() => {
|
||||||
} else {
|
// Keep the 15-minute fallback when SCADA metadata is unavailable.
|
||||||
setFormField("selectedSchemeId", "");
|
})
|
||||||
}
|
.finally(() => {
|
||||||
}
|
if (active) setFrequencyLoading(false);
|
||||||
|
|
||||||
if (notify) {
|
|
||||||
open?.({
|
|
||||||
type: "success",
|
|
||||||
message: "方案列表已刷新",
|
|
||||||
description: `当前可选爆管分析方案 ${burstSchemes.length} 个`,
|
|
||||||
});
|
});
|
||||||
}
|
return () => {
|
||||||
} catch (error: any) {
|
active = false;
|
||||||
open?.({
|
|
||||||
type: "error",
|
|
||||||
message: "刷新方案失败",
|
|
||||||
description:
|
|
||||||
error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSchemeLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId, setFormField],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDataSourceChange = (value: "monitoring" | "simulation") => {
|
|
||||||
setFormField("dataSource", value);
|
|
||||||
if (value === "simulation") {
|
|
||||||
void fetchSchemes();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
}, [samplingIntervalSource, setParametersState]);
|
||||||
|
|
||||||
const handleSchemeSelect = (schemeId: number) => {
|
const samplingIntervalValid =
|
||||||
setFormField("selectedSchemeId", schemeId);
|
Number.isInteger(samplingIntervalMinutes) &&
|
||||||
const scheme = schemes.find((item) => item.scheme_id === schemeId);
|
samplingIntervalMinutes > 0 &&
|
||||||
if (scheme) {
|
1440 % samplingIntervalMinutes === 0;
|
||||||
applySchemeTimeRange(scheme);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const timeWindowValid = useMemo(() => {
|
const isValid = useMemo(
|
||||||
if (!scadaStart || !scadaEnd) return false;
|
() =>
|
||||||
return scadaEnd.diff(scadaStart, "day", true) >= 2;
|
schemeName.trim().length > 0 &&
|
||||||
}, [scadaEnd, scadaStart]);
|
samplingIntervalValid &&
|
||||||
|
(detectionMode === "latest" || Boolean(targetTime?.isValid())),
|
||||||
const contaminationValue = useMemo(() => {
|
[detectionMode, samplingIntervalValid, schemeName, targetTime],
|
||||||
const normalized = contaminationInput.trim().toLowerCase();
|
);
|
||||||
if (!normalized || normalized === "auto") {
|
|
||||||
return "auto" as const;
|
|
||||||
}
|
|
||||||
const parsed = Number(normalized);
|
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= 0.5) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}, [contaminationInput]);
|
|
||||||
|
|
||||||
const isValid =
|
|
||||||
Boolean(scadaStart && scadaEnd) &&
|
|
||||||
timeWindowValid &&
|
|
||||||
Number.isFinite(mu) &&
|
|
||||||
mu > 0 &&
|
|
||||||
Number.isFinite(pointsPerDay) &&
|
|
||||||
pointsPerDay > 0 &&
|
|
||||||
Number.isFinite(nEstimators) &&
|
|
||||||
nEstimators > 0 &&
|
|
||||||
contaminationValue !== null &&
|
|
||||||
(dataSource !== "simulation" || Boolean(selectedSchemeId));
|
|
||||||
|
|
||||||
const handleRun = async () => {
|
const handleRun = async () => {
|
||||||
if (!isValid || !scadaStart || !scadaEnd || contaminationValue === null) {
|
if (!isValid) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "参数不完整",
|
message: "参数不完整",
|
||||||
description: "请检查时间范围(至少2天)和高级参数是否填写正确。",
|
description: "请输入方案名称,并检查历史目标时间。",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -219,50 +180,23 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
key: "burst-detection-analysis-progress",
|
key: "burst-detection-analysis-progress",
|
||||||
type: "progress",
|
type: "progress",
|
||||||
message: "正在执行爆管侦测",
|
message: "正在执行爆管侦测",
|
||||||
description: "正在读取数据并计算异常分数。",
|
description: "正在读取目标时刻及前 14 天同刻基线。",
|
||||||
undoableTimeout: 3,
|
undoableTimeout: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const selectedScheme =
|
const response = await api.post(
|
||||||
dataSource === "simulation"
|
"/api/v1/burst-detection/detect/",
|
||||||
? schemes.find((item) => item.scheme_id === selectedSchemeId)
|
buildBurstDetectionRequest(parametersState, NETWORK_NAME),
|
||||||
: undefined;
|
);
|
||||||
|
onResult(response.data as BurstDetectionResult);
|
||||||
const response = await api.post("/api/v1/burst-detection/detect/", {
|
|
||||||
network: NETWORK_NAME,
|
|
||||||
data_source: dataSource,
|
|
||||||
scheme_name: schemeName.trim() || undefined,
|
|
||||||
scada_start: scadaStart.toISOString(),
|
|
||||||
scada_end: scadaEnd.toISOString(),
|
|
||||||
mu,
|
|
||||||
points_per_day: pointsPerDay,
|
|
||||||
iforest_params: {
|
|
||||||
n_estimators: nEstimators,
|
|
||||||
contamination: contaminationValue,
|
|
||||||
},
|
|
||||||
simulation_scheme_name: selectedScheme?.scheme_name,
|
|
||||||
simulation_scheme_type: selectedScheme?.scheme_type,
|
|
||||||
});
|
|
||||||
|
|
||||||
onResult({
|
|
||||||
...(response.data as BurstDetectionResult),
|
|
||||||
scheme_name: schemeName.trim() || (response.data as BurstDetectionResult).scheme_name,
|
|
||||||
algorithm_params: {
|
|
||||||
mu,
|
|
||||||
points_per_day: pointsPerDay,
|
|
||||||
iforest_params: {
|
|
||||||
n_estimators: nEstimators,
|
|
||||||
contamination: contaminationValue,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
open?.({
|
open?.({
|
||||||
key: "burst-detection-analysis-success",
|
key: "burst-detection-analysis-success",
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "爆管侦测完成",
|
message: "爆管侦测完成",
|
||||||
description: `共识别 ${response.data.summary?.anomaly_day_count ?? 0} 个异常日。`,
|
description: response.data.summary?.burst_detected
|
||||||
|
? "目标时刻存在异常信号,请优先复核相关测点。"
|
||||||
|
: "目标时刻未发现爆管异常。",
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
open?.({
|
open?.({
|
||||||
@@ -277,7 +211,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col flex-1 min-h-0">
|
<Box className="flex min-h-0 flex-1 flex-col">
|
||||||
<Box className="flex flex-col gap-3">
|
<Box className="flex flex-col gap-3">
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||||
@@ -294,211 +228,89 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||||
数据来源
|
侦测方式
|
||||||
</Typography>
|
</Typography>
|
||||||
<FormControl fullWidth size="small">
|
<FormControl fullWidth size="small">
|
||||||
<Select
|
<Select
|
||||||
value={dataSource}
|
value={detectionMode}
|
||||||
onChange={(e) => handleDataSourceChange(e.target.value as "monitoring" | "simulation")}
|
onChange={(event) =>
|
||||||
|
setFormField(
|
||||||
|
"detectionMode",
|
||||||
|
event.target.value as "latest" | "historical",
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<MenuItem value="monitoring">监测数据</MenuItem>
|
<MenuItem value="latest">检测最新数据</MenuItem>
|
||||||
<MenuItem value="simulation">模拟方案</MenuItem>
|
<MenuItem value="historical">历史时刻回放</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{isSimulationMode && (
|
{detectionMode === "historical" ? (
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
|
||||||
选择爆管分析方案
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
|
||||||
<FormControl fullWidth size="small">
|
|
||||||
<Select
|
|
||||||
value={selectedSchemeId}
|
|
||||||
onChange={(e) => handleSchemeSelect(Number(e.target.value))}
|
|
||||||
disabled={schemeLoading}
|
|
||||||
displayEmpty
|
|
||||||
>
|
|
||||||
<MenuItem value="" disabled>
|
|
||||||
请选择方案
|
|
||||||
</MenuItem>
|
|
||||||
{schemes.map((scheme) => (
|
|
||||||
<MenuItem key={scheme.scheme_id} value={scheme.scheme_id}>
|
|
||||||
{scheme.scheme_name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="primary"
|
|
||||||
onClick={() => void fetchSchemes({ force: true, notify: true })}
|
|
||||||
disabled={schemeLoading}
|
|
||||||
aria-label="刷新爆管分析方案"
|
|
||||||
sx={{
|
|
||||||
border: "1px solid",
|
|
||||||
borderColor: "divider",
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{schemeLoading ? (
|
|
||||||
<CircularProgress size={18} color="inherit" />
|
|
||||||
) : (
|
|
||||||
<RefreshIcon fontSize="small" />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<LocalizationProvider
|
<LocalizationProvider
|
||||||
dateAdapter={AdapterDayjs}
|
dateAdapter={AdapterDayjs}
|
||||||
adapterLocale="zh-cn"
|
adapterLocale="zh-cn"
|
||||||
localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText}
|
localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText}
|
||||||
>
|
>
|
||||||
<Box className="grid grid-cols-2 gap-2">
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||||
侦测开始时间
|
目标时刻
|
||||||
</Typography>
|
</Typography>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
value={scadaStart}
|
value={targetTime}
|
||||||
onChange={(value) => setFormField("scadaStart", value)}
|
onChange={(value) => setFormField("targetTime", value)}
|
||||||
maxDateTime={scadaEnd ? scadaEnd.subtract(2, "day") : undefined}
|
maxDateTime={dayjs()}
|
||||||
disabled={isSimulationMode}
|
minutesStep={15}
|
||||||
format="YYYY-MM-DD HH:mm"
|
format="YYYY-MM-DD HH:mm"
|
||||||
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
|
||||||
侦测结束时间
|
|
||||||
</Typography>
|
|
||||||
<DateTimePicker
|
|
||||||
value={scadaEnd}
|
|
||||||
onChange={(value) => setFormField("scadaEnd", value)}
|
|
||||||
minDateTime={scadaStart ? scadaStart.add(2, "day") : undefined}
|
|
||||||
disabled={isSimulationMode}
|
|
||||||
format="YYYY-MM-DD HH:mm"
|
|
||||||
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</LocalizationProvider>
|
</LocalizationProvider>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Box className="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-900">
|
<TextField
|
||||||
当前页面为展示版:手动触发一次侦测,展示异常日、最新测点排名和结果表格,不做定时轮询。
|
type="number"
|
||||||
</Box>
|
label="采样间隔(分钟)"
|
||||||
|
value={samplingIntervalMinutes}
|
||||||
<Box
|
onChange={(event) => {
|
||||||
sx={{
|
setParametersState((previous) => ({
|
||||||
border: "1px solid",
|
...previous,
|
||||||
borderColor: "grey.200",
|
samplingIntervalMinutes: Number(event.target.value),
|
||||||
borderRadius: 1,
|
samplingIntervalSource: "manual",
|
||||||
overflow: "hidden",
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
size="small"
|
||||||
<Box
|
fullWidth
|
||||||
role="button"
|
error={!samplingIntervalValid}
|
||||||
tabIndex={0}
|
inputProps={{ min: 1, max: 1440, step: 1 }}
|
||||||
onClick={() => setFormField("advancedOpen", !advancedOpen)}
|
helperText={
|
||||||
onKeyDown={(event) => {
|
samplingIntervalValid
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
? `${frequencyLoading ? "正在读取 SCADA 频率" : samplingIntervalSource === "metadata" ? "默认取自压力 SCADA 频率" : "已手动设置"},每天 ${1440 / samplingIntervalMinutes} 个采样点`
|
||||||
setFormField("advancedOpen", !advancedOpen);
|
: "请输入能整除 1440 分钟的正整数,例如 1、5、10、15、30 或 60。"
|
||||||
}
|
}
|
||||||
}}
|
/>
|
||||||
sx={{
|
|
||||||
display: "flex",
|
{detectionMode === "latest" ? (
|
||||||
alignItems: "center",
|
<Box className="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-900">
|
||||||
justifyContent: "space-between",
|
系统自动读取目标时刻及前 14 天同一时刻数据。每天使用截至该时刻的
|
||||||
px: 1.25,
|
24 小时压力序列提取扰动特征,仅判定目标时刻是否异常。
|
||||||
py: 0.75,
|
</Box>
|
||||||
cursor: "pointer",
|
) : null}
|
||||||
backgroundColor: "transparent",
|
<Typography variant="caption" color="text.secondary">
|
||||||
"&:hover": { backgroundColor: "action.hover" },
|
当前口径:{samplingIntervalMinutes || "-"} 分钟采样、
|
||||||
}}
|
{samplingIntervalValid ? 1440 / samplingIntervalMinutes : "-"} 点/天、14
|
||||||
>
|
个参考日;缺失数据的测点不会插值,将从本次分析中排除。
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
高级参数
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<ExpandMoreIcon
|
|
||||||
sx={{
|
|
||||||
transform: advancedOpen ? "rotate(180deg)" : "rotate(0deg)",
|
|
||||||
transition: "transform 0.2s ease",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Collapse in={advancedOpen} timeout="auto" unmountOnExit>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
px: 1.25,
|
|
||||||
pt: 1.25,
|
|
||||||
pb: 1.25,
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box className="flex flex-col gap-3">
|
|
||||||
<TextField
|
|
||||||
type="number"
|
|
||||||
label="频域截断系数"
|
|
||||||
value={mu}
|
|
||||||
onChange={(event) => setFormField("mu", Number(event.target.value))}
|
|
||||||
size="small"
|
|
||||||
fullWidth
|
|
||||||
inputProps={{ min: 1 }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
type="number"
|
|
||||||
label="每日采样点数"
|
|
||||||
value={pointsPerDay}
|
|
||||||
onChange={(event) => setFormField("pointsPerDay", Number(event.target.value))}
|
|
||||||
size="small"
|
|
||||||
fullWidth
|
|
||||||
inputProps={{ min: 1 }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
type="number"
|
|
||||||
label="孤立森林树数量"
|
|
||||||
value={nEstimators}
|
|
||||||
onChange={(event) => setFormField("nEstimators", Number(event.target.value))}
|
|
||||||
size="small"
|
|
||||||
fullWidth
|
|
||||||
inputProps={{ min: 1 }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="异常比例"
|
|
||||||
value={contaminationInput}
|
|
||||||
onChange={(event) => setFormField("contaminationInput", event.target.value)}
|
|
||||||
size="small"
|
|
||||||
fullWidth
|
|
||||||
helperText="填写 auto 或 0~0.5 之间的小数。"
|
|
||||||
error={contaminationValue === null}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Collapse>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="mt-auto pt-3 flex gap-2">
|
<Box className="mt-auto flex gap-2 pt-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
fullWidth
|
fullWidth
|
||||||
disabled={running}
|
disabled={running}
|
||||||
sx={{ textTransform: "none", fontWeight: 500 }}
|
onClick={() =>
|
||||||
onClick={() => {
|
setParametersState(createBurstDetectionAnalysisParametersState())
|
||||||
setParametersState((previous) => ({
|
}
|
||||||
...previous,
|
|
||||||
schemeName: `Burst_Detection_${Date.now()}`,
|
|
||||||
scadaStart: dayjs().subtract(3, "day"),
|
|
||||||
scadaEnd: dayjs(),
|
|
||||||
mu: 100,
|
|
||||||
pointsPerDay: 96,
|
|
||||||
nEstimators: 50,
|
|
||||||
contaminationInput: "auto",
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
@@ -506,11 +318,13 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
fullWidth
|
fullWidth
|
||||||
disabled={!isValid || running}
|
disabled={!isValid || running}
|
||||||
onClick={handleRun}
|
onClick={() => void handleRun()}
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
|
||||||
sx={{ textTransform: "none", fontWeight: 500 }}
|
|
||||||
>
|
>
|
||||||
{running ? <CircularProgress size={20} color="inherit" /> : "开始侦测"}
|
{running
|
||||||
|
? "侦测中..."
|
||||||
|
: detectionMode === "latest"
|
||||||
|
? "侦测最新数据"
|
||||||
|
: "回放目标时刻"}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -5,23 +5,23 @@ import { Box, Button, Chip, Tooltip, Typography } from "@mui/material";
|
|||||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||||
import { zhCN } from "@mui/x-data-grid/locales";
|
import { zhCN } from "@mui/x-data-grid/locales";
|
||||||
import {
|
import {
|
||||||
|
CheckCircleOutline as CheckCircleIcon,
|
||||||
|
ErrorOutline as ErrorOutlineIcon,
|
||||||
FormatListBulleted,
|
FormatListBulleted,
|
||||||
InfoOutlined as InfoOutlinedIcon,
|
InfoOutlined as InfoOutlinedIcon,
|
||||||
Room as RoomIcon,
|
Room as RoomIcon,
|
||||||
ShowChart as ShowChartIcon,
|
ShowChart as ShowChartIcon,
|
||||||
CheckCircleOutline as CheckCircleIcon,
|
|
||||||
ErrorOutline as ErrorOutlineIcon,
|
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import ReactECharts from "echarts-for-react";
|
import ReactECharts from "echarts-for-react";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useMap } from "@components/olmap/core/MapComponent";
|
|
||||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
|
||||||
import { GeoJSON } from "ol/format";
|
|
||||||
import Feature from "ol/Feature";
|
import Feature from "ol/Feature";
|
||||||
|
import { GeoJSON } from "ol/format";
|
||||||
import VectorLayer from "ol/layer/Vector";
|
import VectorLayer from "ol/layer/Vector";
|
||||||
import VectorSource from "ol/source/Vector";
|
import VectorSource from "ol/source/Vector";
|
||||||
import { Circle, Fill, Stroke, Style } from "ol/style";
|
import { Circle, Fill, Stroke, Style } from "ol/style";
|
||||||
import { bbox, featureCollection } from "@turf/turf";
|
import { bbox, featureCollection } from "@turf/turf";
|
||||||
|
import { useMap } from "@components/olmap/core/MapComponent";
|
||||||
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
import { BurstDetectionResult, BurstDetectionRow } from "./types";
|
import { BurstDetectionResult, BurstDetectionRow } from "./types";
|
||||||
|
|
||||||
export interface BurstDetectionResultsState {
|
export interface BurstDetectionResultsState {
|
||||||
@@ -29,9 +29,7 @@ export interface BurstDetectionResultsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const createBurstDetectionResultsState =
|
export const createBurstDetectionResultsState =
|
||||||
(): BurstDetectionResultsState => ({
|
(): BurstDetectionResultsState => ({ selectedDay: null });
|
||||||
selectedDay: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
result: BurstDetectionResult | null;
|
result: BurstDetectionResult | null;
|
||||||
@@ -46,54 +44,33 @@ interface MetricCardProps {
|
|||||||
tone: "blue" | "orange" | "purple" | "green";
|
tone: "blue" | "orange" | "purple" | "green";
|
||||||
}
|
}
|
||||||
|
|
||||||
const toneStyles: Record<
|
const toneStyles: Record<MetricCardProps["tone"], string> = {
|
||||||
MetricCardProps["tone"],
|
blue: "border-blue-200 from-blue-50 to-blue-100 text-blue-900",
|
||||||
{ bg: string; border: string; text: string; darkText: string }
|
orange: "border-orange-200 from-orange-50 to-orange-100 text-orange-900",
|
||||||
> = {
|
purple: "border-purple-200 from-purple-50 to-purple-100 text-purple-900",
|
||||||
blue: {
|
green: "border-green-200 from-green-50 to-green-100 text-green-900",
|
||||||
bg: "from-blue-50 to-blue-100",
|
|
||||||
border: "border-blue-200",
|
|
||||||
text: "text-blue-700",
|
|
||||||
darkText: "text-blue-900",
|
|
||||||
},
|
|
||||||
orange: {
|
|
||||||
bg: "from-orange-50 to-orange-100",
|
|
||||||
border: "border-orange-200",
|
|
||||||
text: "text-orange-700",
|
|
||||||
darkText: "text-orange-900",
|
|
||||||
},
|
|
||||||
purple: {
|
|
||||||
bg: "from-purple-50 to-purple-100",
|
|
||||||
border: "border-purple-200",
|
|
||||||
text: "text-purple-700",
|
|
||||||
darkText: "text-purple-900",
|
|
||||||
},
|
|
||||||
green: {
|
|
||||||
bg: "from-green-50 to-green-100",
|
|
||||||
border: "border-green-200",
|
|
||||||
text: "text-green-700",
|
|
||||||
darkText: "text-green-900",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => {
|
const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => (
|
||||||
const style = toneStyles[tone];
|
<Box
|
||||||
return (
|
className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${toneStyles[tone]}`}
|
||||||
<Box className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${style.bg} ${style.border}`}>
|
>
|
||||||
<Typography variant="caption" className={`mb-1 block text-xs font-semibold uppercase tracking-wide ${style.text}`}>
|
<Typography variant="caption" className="mb-1 block font-semibold">
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" className={`font-bold ${style.darkText}`}>
|
<Typography variant="body2" className="font-bold">
|
||||||
{value}
|
{value}
|
||||||
</Typography>
|
</Typography>
|
||||||
{hint ? (
|
{hint ? (
|
||||||
<Typography variant="caption" className={`mt-0.5 block text-xs opacity-80 ${style.text}`}>
|
<Typography variant="caption" className="mt-0.5 block opacity-75">
|
||||||
{hint}
|
{hint}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
const formatDateTime = (value?: string) =>
|
||||||
|
value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
|
||||||
|
|
||||||
const EmptyState = () => (
|
const EmptyState = () => (
|
||||||
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center">
|
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center">
|
||||||
@@ -104,42 +81,27 @@ const EmptyState = () => (
|
|||||||
等待侦测结果
|
等待侦测结果
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" className="max-w-xs text-gray-500">
|
<Typography variant="body2" className="max-w-xs text-gray-500">
|
||||||
提交一次爆管侦测后,这里会展示异常天数、分数趋势、最新测点排名和结果表格。
|
提交侦测后,这里会展示目标时刻状态、前 14 天参考分数和异常测点。
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
const getScoreLevel = (score: number) => {
|
const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => {
|
||||||
if (score <= -0.6) return { label: "高风险", color: "error" as const };
|
|
||||||
if (score <= -0.2) return { label: "需关注", color: "warning" as const };
|
|
||||||
return { label: "正常", color: "success" as const };
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDateTime = (value?: string) => (value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-");
|
|
||||||
|
|
||||||
const DetectionResults: React.FC<Props> = ({
|
|
||||||
result,
|
|
||||||
state,
|
|
||||||
onStateChange,
|
|
||||||
}) => {
|
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||||
const [internalResultsState, setInternalResultsState] =
|
const [internalState, setInternalState] = useState<BurstDetectionResultsState>(
|
||||||
useState<BurstDetectionResultsState>(createBurstDetectionResultsState);
|
createBurstDetectionResultsState,
|
||||||
const resultsState = state ?? internalResultsState;
|
);
|
||||||
const selectedDay = resultsState.selectedDay;
|
const resultsState = state ?? internalState;
|
||||||
const setSelectedDay = (value: number | null) => {
|
const setSelectedDay = (selectedDay: number | null) => {
|
||||||
const nextState = { ...resultsState, selectedDay: value };
|
const nextState = { selectedDay };
|
||||||
if (state === undefined) {
|
if (state === undefined) setInternalState(nextState);
|
||||||
setInternalResultsState(nextState);
|
|
||||||
}
|
|
||||||
onStateChange?.(nextState);
|
onStateChange?.(nextState);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
const layer = new VectorLayer({
|
const layer = new VectorLayer({
|
||||||
source: new VectorSource(),
|
source: new VectorSource(),
|
||||||
style: new Style({
|
style: new Style({
|
||||||
@@ -154,12 +116,11 @@ const DetectionResults: React.FC<Props> = ({
|
|||||||
properties: {
|
properties: {
|
||||||
name: "爆管侦测高亮",
|
name: "爆管侦测高亮",
|
||||||
value: "burst_detection_highlight",
|
value: "burst_detection_highlight",
|
||||||
|
queryable: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
map.addLayer(layer);
|
map.addLayer(layer);
|
||||||
highlightLayerRef.current = layer;
|
highlightLayerRef.current = layer;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
highlightLayerRef.current = null;
|
highlightLayerRef.current = null;
|
||||||
map.removeLayer(layer);
|
map.removeLayer(layer);
|
||||||
@@ -173,58 +134,67 @@ const DetectionResults: React.FC<Props> = ({
|
|||||||
highlightFeatures.forEach((feature) => source.addFeature(feature));
|
highlightFeatures.forEach((feature) => source.addFeature(feature));
|
||||||
}, [highlightFeatures]);
|
}, [highlightFeatures]);
|
||||||
|
|
||||||
const defaultSelectedDay = useMemo(
|
const sortedRows = useMemo(
|
||||||
() =>
|
() => [...(result?.rows ?? [])].sort((a, b) => a.Day - b.Day),
|
||||||
result?.summary?.most_anomalous_day ??
|
|
||||||
result?.summary?.latest_day?.Day ??
|
|
||||||
result?.rows[0]?.Day ??
|
|
||||||
null,
|
|
||||||
[result],
|
[result],
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeSelectedDay = selectedDay ?? defaultSelectedDay;
|
const timestampForRow = (row: BurstDetectionRow) => {
|
||||||
|
if (row.Timestamp) return row.Timestamp;
|
||||||
|
const start = dayjs(result?.scada_window?.start);
|
||||||
|
return start.isValid() ? start.add(row.Day, "day").toISOString() : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const selectedRow = useMemo<BurstDetectionRow | null>(() => {
|
const scoreThreshold = result?.summary.score_threshold ?? 0;
|
||||||
if (!result || activeSelectedDay === null) return null;
|
const scoreSeries = sortedRows.map((row) => ({
|
||||||
return result.rows.find((row) => row.Day === activeSelectedDay) ?? null;
|
day: row.Day,
|
||||||
}, [activeSelectedDay, result]);
|
value: [
|
||||||
|
timestampForRow(row)
|
||||||
const scoreSeries = useMemo(
|
? dayjs(timestampForRow(row)).format("MM-DD HH:mm")
|
||||||
() =>
|
: `第 ${row.Day} 天`,
|
||||||
result?.rows.map((row) => ({
|
Number(row.Score.toFixed(4)),
|
||||||
value: [row.Day, Number(row.Score.toFixed(4))],
|
],
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: row.IsBurst ? "#ef4444" : row.Score <= -0.2 ? "#f59e0b" : "#10b981",
|
color:
|
||||||
|
row.Role === "target"
|
||||||
|
? row.IsBurst
|
||||||
|
? "#ef4444"
|
||||||
|
: "#2563eb"
|
||||||
|
: "#94a3b8",
|
||||||
},
|
},
|
||||||
})) ?? [],
|
symbolSize: row.Role === "target" ? 11 : 7,
|
||||||
[result],
|
}));
|
||||||
);
|
|
||||||
|
|
||||||
const rankingSeries = useMemo(
|
const rankingSeries = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...(result?.summary?.latest_sensor_rankings ?? [])]
|
[...(result?.summary.latest_sensor_rankings ?? [])]
|
||||||
.sort((a, b) => a.latest_high_frequency_value - b.latest_high_frequency_value)
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.standardized_deviation ?? a.latest_high_frequency_value) -
|
||||||
|
(b.standardized_deviation ?? b.latest_high_frequency_value),
|
||||||
|
)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
name: item.sensor_node,
|
name: item.sensor_node,
|
||||||
value: Number(item.latest_high_frequency_value.toFixed(4)),
|
value: Number(
|
||||||
|
(item.standardized_deviation ?? item.latest_high_frequency_value).toFixed(3),
|
||||||
|
),
|
||||||
})),
|
})),
|
||||||
[result],
|
[result],
|
||||||
);
|
);
|
||||||
|
|
||||||
const locateSensors = async (sensorIds: string[]) => {
|
const locateSensors = async (sensorIds: string[]) => {
|
||||||
if (!map || sensorIds.length === 0) return;
|
if (!map || sensorIds.length === 0) return;
|
||||||
|
|
||||||
let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat");
|
let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat");
|
||||||
if (features.length === 0) {
|
if (features.length === 0) {
|
||||||
features = await queryFeaturesByIds(sensorIds, "geo_junctions");
|
features = await queryFeaturesByIds(sensorIds, "geo_junctions");
|
||||||
}
|
}
|
||||||
if (features.length === 0) return;
|
if (features.length === 0) return;
|
||||||
|
|
||||||
setHighlightFeatures(features);
|
setHighlightFeatures(features);
|
||||||
|
const format = new GeoJSON();
|
||||||
const geojsonFormat = new GeoJSON();
|
const geojsonFeatures = features.map((feature) =>
|
||||||
const geojsonFeatures = features.map((feature) => geojsonFormat.writeFeatureObject(feature));
|
format.writeFeatureObject(feature),
|
||||||
// @ts-ignore turf typing with ol geojson objects
|
);
|
||||||
|
// @ts-ignore turf accepts OpenLayers GeoJSON feature objects
|
||||||
const extent = bbox(featureCollection(geojsonFeatures));
|
const extent = bbox(featureCollection(geojsonFeatures));
|
||||||
map.getView().fit(extent, {
|
map.getView().fit(extent, {
|
||||||
maxZoom: 18,
|
maxZoom: 18,
|
||||||
@@ -233,60 +203,50 @@ const DetectionResults: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!result) {
|
if (!result) return <EmptyState />;
|
||||||
return <EmptyState />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestDay = result.summary?.latest_day;
|
const targetRow = sortedRows.find((row) => row.Role === "target") ?? sortedRows.at(-1);
|
||||||
const latestLevel = latestDay ? getScoreLevel(latestDay.Score) : getScoreLevel(0);
|
|
||||||
const mostAnomalousRow = result.rows.find((row) => row.Day === result.summary?.most_anomalous_day) ?? null;
|
|
||||||
const mostAnomalousLevel = getScoreLevel(mostAnomalousRow?.Score ?? 0);
|
|
||||||
const isBurstDetected = result.summary.burst_detected;
|
const isBurstDetected = result.summary.burst_detected;
|
||||||
|
const targetRank = result.summary.target_rank;
|
||||||
|
const excludedCount = result.data_quality?.excluded_sensors.length ?? 0;
|
||||||
|
|
||||||
const chartOption = {
|
const chartOption = {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: "axis",
|
trigger: "axis",
|
||||||
formatter: (params: Array<{ data: { value: [number, number] } }>) => {
|
formatter: (params: Array<{ data: { day: number; value: [string, number] } }>) => {
|
||||||
const point = params[0]?.data?.value;
|
const data = params[0]?.data;
|
||||||
if (!point) return "-";
|
return data
|
||||||
return `侦测日第 ${point[0]} 天<br/>异常分数:${point[1]}`;
|
? `${data.value[0]}<br/>${data.day === 15 ? "目标时刻" : "参考日"}<br/>异常分数:${data.value[1]}`
|
||||||
|
: "-";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
grid: { top: 30, left: 40, right: 20, bottom: 35 },
|
grid: { top: 30, left: 48, right: 20, bottom: 48 },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: "category",
|
type: "category",
|
||||||
name: "侦测日",
|
name: "同刻日期",
|
||||||
data: result.rows.map((row) => row.Day),
|
boundaryGap: false,
|
||||||
axisLabel: { fontSize: 10 },
|
data: scoreSeries.map((item) => item.value[0]),
|
||||||
},
|
axisLabel: { fontSize: 10, interval: 2, rotate: 25 },
|
||||||
yAxis: {
|
|
||||||
type: "value",
|
|
||||||
name: "异常分数",
|
|
||||||
axisLabel: { fontSize: 10 },
|
|
||||||
},
|
},
|
||||||
|
yAxis: { type: "value", name: "异常分数", axisLabel: { fontSize: 10 } },
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
type: "line",
|
type: "line",
|
||||||
smooth: true,
|
|
||||||
symbolSize: 8,
|
|
||||||
data: scoreSeries,
|
data: scoreSeries,
|
||||||
lineStyle: { color: "#2563eb", width: 2 },
|
lineStyle: { color: "#94a3b8", width: 2 },
|
||||||
markLine: {
|
markLine: {
|
||||||
symbol: "none",
|
symbol: "none",
|
||||||
lineStyle: { type: "dashed", color: "#94a3b8" },
|
lineStyle: { type: "dashed", color: "#ef4444" },
|
||||||
data: [{ yAxis: 0 }],
|
data: [{ yAxis: scoreThreshold, name: "报警阈值" }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const rankingOption = {
|
const rankingOption = {
|
||||||
tooltip: {
|
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
|
||||||
trigger: "axis",
|
grid: { top: 12, left: 82, right: 20, bottom: 25 },
|
||||||
axisPointer: { type: "shadow" },
|
xAxis: { type: "value", name: "标准化偏离", axisLabel: { fontSize: 10 } },
|
||||||
},
|
|
||||||
grid: { top: 20, left: 70, right: 20, bottom: 20 },
|
|
||||||
xAxis: { type: "value", axisLabel: { fontSize: 10 } },
|
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: "category",
|
type: "category",
|
||||||
data: rankingSeries.map((item) => item.name),
|
data: rankingSeries.map((item) => item.name),
|
||||||
@@ -297,9 +257,7 @@ const DetectionResults: React.FC<Props> = ({
|
|||||||
type: "bar",
|
type: "bar",
|
||||||
data: rankingSeries.map((item) => ({
|
data: rankingSeries.map((item) => ({
|
||||||
value: item.value,
|
value: item.value,
|
||||||
itemStyle: {
|
itemStyle: { color: item.value < 0 ? "#ef4444" : "#f59e0b" },
|
||||||
color: item.value <= -0.6 ? "#ef4444" : item.value <= -0.2 ? "#f59e0b" : "#10b981",
|
|
||||||
},
|
|
||||||
})),
|
})),
|
||||||
barWidth: 14,
|
barWidth: 14,
|
||||||
},
|
},
|
||||||
@@ -308,323 +266,176 @@ const DetectionResults: React.FC<Props> = ({
|
|||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
{
|
{
|
||||||
field: "Day",
|
field: "Timestamp",
|
||||||
headerName: "侦测日",
|
headerName: "同刻日期",
|
||||||
width: 96,
|
minWidth: 145,
|
||||||
valueFormatter: (value?: number) => (typeof value === "number" ? `第 ${value} 天` : "-"),
|
flex: 1,
|
||||||
|
valueGetter: (_value, row) => formatDateTime(timestampForRow(row)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "Role",
|
||||||
|
headerName: "角色",
|
||||||
|
width: 90,
|
||||||
|
valueFormatter: (value?: string) => (value === "target" ? "目标" : "参考"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "Score",
|
field: "Score",
|
||||||
headerName: "异常分数",
|
headerName: "异常分数",
|
||||||
width: 120,
|
width: 110,
|
||||||
valueFormatter: (value?: number) => (typeof value === "number" ? value.toFixed(4) : "-"),
|
valueFormatter: (value?: number) =>
|
||||||
|
typeof value === "number" ? value.toFixed(4) : "-",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "IsBurst",
|
field: "IsBurst",
|
||||||
headerName: "判定结果",
|
headerName: "目标判定",
|
||||||
width: 120,
|
width: 110,
|
||||||
renderCell: ({ value }) => {
|
renderCell: ({ value, row }) =>
|
||||||
const level = value ? { label: "爆管异常", color: "error" as const } : { label: "正常", color: "success" as const };
|
row.Role === "target" || row.Day === result.day_count ? (
|
||||||
return <Chip size="small" label={level.label} color={level.color} variant="outlined" />;
|
<Chip
|
||||||
},
|
size="small"
|
||||||
|
label={value ? "爆管异常" : "正常"}
|
||||||
|
color={value ? "error" : "success"}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
参考
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const tableRows = sortedRows.map((row) => ({ id: row.Day, ...row }));
|
||||||
const rows = result.rows.map((row) => ({ id: row.Day, ...row }));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="h-full overflow-auto p-1">
|
<Box className="h-full overflow-auto p-1">
|
||||||
<Box className="mb-4 space-y-3">
|
<Box className="mb-4 space-y-3">
|
||||||
{/* Status Banner */}
|
|
||||||
<Box
|
<Box
|
||||||
className={`rounded-lg px-4 py-3 flex items-center gap-3 border ${isBurstDetected
|
className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
|
||||||
? "bg-red-50 border-red-100 text-red-900"
|
isBurstDetected
|
||||||
: "bg-green-50 border-green-100 text-green-900"
|
? "border-red-100 bg-red-50 text-red-900"
|
||||||
|
: "border-green-100 bg-green-50 text-green-900"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isBurstDetected ? (
|
{isBurstDetected ? <ErrorOutlineIcon /> : <CheckCircleIcon />}
|
||||||
<ErrorOutlineIcon className="text-red-600" />
|
|
||||||
) : (
|
|
||||||
<CheckCircleIcon className="text-green-600" />
|
|
||||||
)}
|
|
||||||
<Box className="flex-1">
|
<Box className="flex-1">
|
||||||
<Typography variant="subtitle2" className="font-bold">
|
<Typography variant="subtitle2" className="font-bold">
|
||||||
{isBurstDetected
|
{isBurstDetected ? "目标时刻侦测到爆管异常" : "目标时刻未侦测到爆管异常"}
|
||||||
? `侦测到异常信号 (共 ${result.summary.anomaly_day_count} 天)`
|
|
||||||
: "未侦测到爆管异常"}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" className="opacity-80">
|
<Typography variant="caption" className="opacity-80">
|
||||||
{isBurstDetected
|
目标:{formatDateTime(result.target_time ?? result.summary.target_time)};分数越低越异常
|
||||||
? "建议检查异常日期的压力波动情况"
|
|
||||||
: "当前时间窗口内数据特征平稳,符合历史模式"}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Header */}
|
<Box className="flex items-center justify-between gap-2 px-1">
|
||||||
<Box className="flex items-center justify-between px-1">
|
<Typography variant="h6" className="min-w-0 flex-1 font-bold text-gray-900">
|
||||||
<Box className="flex items-center gap-2">
|
爆管侦测结果
|
||||||
<Box className="h-4 w-1 rounded-full bg-blue-600" />
|
|
||||||
<Typography variant="h6" className="truncate font-bold text-gray-900" sx={{ fontSize: "1.1rem" }}>
|
|
||||||
{result.scheme_name || "爆管侦测结果"}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
|
||||||
<Box className="flex items-center gap-2">
|
|
||||||
{result.username ? (
|
|
||||||
<Chip
|
|
||||||
label={result.username}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
height: 24,
|
|
||||||
backgroundColor: "#f3f4f6",
|
|
||||||
color: "#4b5563",
|
|
||||||
border: "none",
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<RoomIcon />}
|
startIcon={<RoomIcon />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
locateSensors(result.summary.latest_sensor_rankings.map((item) => item.sensor_node).slice(0, 5))
|
void locateSensors(
|
||||||
|
result.summary.latest_sensor_rankings
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((item) => item.sensor_node),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
sx={{
|
sx={{ flexShrink: 0, whiteSpace: "nowrap" }}
|
||||||
height: 24,
|
|
||||||
minWidth: 0,
|
|
||||||
padding: "0 8px",
|
|
||||||
borderColor: "#bfdbfe",
|
|
||||||
color: "#2563eb",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
"&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
定位
|
定位异常测点
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Configuration Summary */}
|
|
||||||
<Box className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-gray-100 bg-gray-50/50 px-3 py-2 text-xs text-gray-600">
|
|
||||||
<Box className="flex items-center gap-1.5">
|
|
||||||
<Box className="h-1.5 w-1.5 rounded-full bg-blue-400" />
|
|
||||||
<span className="font-medium text-gray-700">时间窗口:</span>
|
|
||||||
<span className="font-mono text-gray-600">
|
|
||||||
{formatDateTime(result.scada_window?.start)} ~ {formatDateTime(result.scada_window?.end)}
|
|
||||||
</span>
|
|
||||||
</Box>
|
|
||||||
<Box className="flex items-center gap-1.5">
|
|
||||||
<Box className="h-1.5 w-1.5 rounded-full bg-purple-400" />
|
|
||||||
<span className="font-medium text-gray-700">数据来源:</span>
|
|
||||||
<span className="text-gray-600">
|
|
||||||
{(() => {
|
|
||||||
const ds = result.data_source;
|
|
||||||
const os = result.observed_source;
|
|
||||||
if (ds === "simulation") return "模拟数据";
|
|
||||||
if (ds === "monitoring") return "监测数据";
|
|
||||||
if (os === "simulation_scheme_timerange") return "模拟数据";
|
|
||||||
if (os === "backend_timerange") return "监测数据";
|
|
||||||
return os || "-";
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Metrics Grid */}
|
|
||||||
<Box className="grid grid-cols-2 gap-3">
|
<Box className="grid grid-cols-2 gap-3">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="异常天数"
|
label="目标异常分数"
|
||||||
value={`${result.summary.anomaly_day_count} / ${result.day_count}`}
|
value={targetRow ? targetRow.Score.toFixed(4) : "-"}
|
||||||
hint={`异常日:${result.summary.anomaly_days.join(", ") || "无"}`}
|
hint={`报警阈值 ≤ ${scoreThreshold.toFixed(2)}`}
|
||||||
tone={result.summary.anomaly_day_count > 0 ? "orange" : "green"}
|
tone={isBurstDetected ? "orange" : "green"}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="最异常日"
|
label="目标异常排名"
|
||||||
value={
|
value={targetRank ? `${targetRank} / ${result.day_count}` : "-"}
|
||||||
result.summary.burst_detected && result.summary.most_anomalous_day
|
hint="在目标日与 14 个参考日中排序"
|
||||||
? `第 ${result.summary.most_anomalous_day} 天`
|
|
||||||
: "无"
|
|
||||||
}
|
|
||||||
hint={
|
|
||||||
result.summary.burst_detected && mostAnomalousRow
|
|
||||||
? `分数 ${mostAnomalousRow.Score.toFixed(4)} · ${mostAnomalousLevel.label}`
|
|
||||||
: "-"
|
|
||||||
}
|
|
||||||
tone="purple"
|
tone="purple"
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="最新状态"
|
label="参考区间"
|
||||||
value={latestLevel.label}
|
value={`${formatDateTime(result.reference_window?.start)} ~ ${formatDateTime(result.reference_window?.end)}`}
|
||||||
hint={latestDay ? `第 ${latestDay.Day} 天 · 分数 ${latestDay.Score.toFixed(4)}` : "-"}
|
hint={`${result.reference_window?.day_count ?? 14} 个同刻参考日`}
|
||||||
tone={latestLevel.color === "success" ? "green" : "orange"}
|
tone="blue"
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="测点 / 样本"
|
label="有效 / 排除测点"
|
||||||
value={`${result.sensor_nodes.length} / ${result.sample_count}`}
|
value={`${result.sensor_nodes.length} / ${excludedCount}`}
|
||||||
hint={`每日采样点数:${result.points_per_day}`}
|
hint={`${result.sampling_interval_minutes ?? 15} 分钟采样,${result.points_per_day} 点/天`}
|
||||||
tone="blue"
|
tone="blue"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Score Trend Chart */}
|
|
||||||
<Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
|
<Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
|
||||||
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3">
|
<Box className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
|
||||||
<Box className="flex items-center gap-2">
|
<Box className="flex items-center gap-2">
|
||||||
<ShowChartIcon className="h-5 w-5 text-blue-600" />
|
<ShowChartIcon className="text-blue-600" />
|
||||||
<Typography variant="subtitle1" className="font-bold text-gray-800">
|
<Typography variant="subtitle1" className="font-bold">
|
||||||
异常分数趋势
|
15 天同刻异常分数
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Tooltip title="分数越小越异常,0 以下通常意味着更值得关注。">
|
<Tooltip title="灰色点为前 14 天参考,最后一个点为本次目标。">
|
||||||
<InfoOutlinedIcon fontSize="small" className="text-gray-400" />
|
<InfoOutlinedIcon fontSize="small" className="text-gray-400" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ height: 250, px: 1.5, py: 1 }}>
|
<Box sx={{ height: 270, px: 1.5, py: 1 }}>
|
||||||
<ReactECharts
|
<ReactECharts
|
||||||
option={chartOption}
|
option={chartOption}
|
||||||
style={{ height: "100%", width: "100%" }}
|
style={{ height: "100%", width: "100%" }}
|
||||||
onEvents={{
|
onEvents={{
|
||||||
click: (params: { data?: { value?: [number, number] } }) => {
|
click: (params: { data?: { day?: number } }) =>
|
||||||
const day = params?.data?.value?.[0];
|
setSelectedDay(params.data?.day ?? null),
|
||||||
if (typeof day === "number") {
|
|
||||||
setSelectedDay(day);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Selected Day Interpretation */}
|
{rankingSeries.length > 0 ? (
|
||||||
{/* <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
|
<Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
|
||||||
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3">
|
<Box className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
|
||||||
<Typography variant="subtitle1" className="font-bold text-gray-800">
|
<Typography variant="subtitle1" className="font-bold">
|
||||||
选中日解读
|
目标测点压力偏离
|
||||||
</Typography>
|
</Typography>
|
||||||
{selectedRow ? (
|
<Typography variant="caption" color="text.secondary">
|
||||||
<Chip
|
负值越小,压降相对历史越明显
|
||||||
size="small"
|
|
||||||
label={`第 ${selectedRow.Day} 天`}
|
|
||||||
sx={{
|
|
||||||
height: 22,
|
|
||||||
backgroundColor: "rgba(37, 99, 235, 0.08)",
|
|
||||||
color: "#2563eb",
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
border: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
{selectedRow ? (
|
|
||||||
<Box className="space-y-3 px-4 py-3">
|
|
||||||
<Box className="flex items-center gap-2">
|
|
||||||
<Chip
|
|
||||||
label={getScoreLevel(selectedRow.Score).label}
|
|
||||||
color={getScoreLevel(selectedRow.Score).color}
|
|
||||||
variant="filled"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2" className="text-gray-700">
|
|
||||||
异常分数:<span className="font-semibold">{selectedRow.Score.toFixed(4)}</span>
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" className="text-gray-700">
|
|
||||||
模型判定:{selectedRow.IsBurst ? "异常日(Prediction = -1)" : "正常日(Prediction = 1)"}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" className="text-gray-700">
|
|
||||||
解读建议:
|
|
||||||
{selectedRow.Score <= -0.6
|
|
||||||
? "高风险异常,建议优先复核对应测点的原始压力曲线与现场工况。"
|
|
||||||
: selectedRow.Score <= -0.2
|
|
||||||
? "存在可疑波动,建议结合相邻测点和调度记录进一步确认。"
|
|
||||||
: "未见明显异常,可作为基线日参考。"}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
<Box sx={{ height: 280, px: 1.5, py: 1 }}>
|
||||||
<Typography variant="body2" className="px-4 py-3 text-gray-500">
|
|
||||||
请在趋势图或表格中选择一天查看详细解释。
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box> */}
|
|
||||||
|
|
||||||
{/* Latest Sensor Rankings */}
|
|
||||||
{/* <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
|
|
||||||
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3">
|
|
||||||
<Typography variant="subtitle1" className="font-bold text-gray-800">
|
|
||||||
最新测点高频特征排名
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" className="text-gray-500">
|
|
||||||
仅展示最新一天
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ height: 260, px: 1.5, py: 1 }}>
|
|
||||||
<ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} />
|
<ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box className="flex flex-wrap gap-2 border-t border-gray-100 px-4 py-3">
|
|
||||||
{result.summary.latest_sensor_rankings.slice(0, 5).map((item) => (
|
|
||||||
<Button
|
|
||||||
key={item.sensor_node}
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => locateSensors([item.sensor_node])}
|
|
||||||
sx={{
|
|
||||||
borderColor: "#bfdbfe",
|
|
||||||
color: "#2563eb",
|
|
||||||
"&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.sensor_node}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box> */}
|
) : null}
|
||||||
|
|
||||||
{/* Results Table */}
|
|
||||||
<Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
|
<Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
|
||||||
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3">
|
<Box className="flex items-center gap-2 border-b border-gray-100 px-4 py-3">
|
||||||
<Box className="flex items-center gap-2">
|
<FormatListBulleted className="text-blue-600" />
|
||||||
<FormatListBulleted className="h-5 w-5 text-blue-600" />
|
<Typography variant="subtitle1" className="font-bold">
|
||||||
<Typography variant="subtitle1" className="font-bold text-gray-800">
|
同刻对照明细
|
||||||
结果表格
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Chip
|
<Box sx={{ height: 360, px: 1, py: 1 }}>
|
||||||
size="small"
|
|
||||||
label={`${rows.length} 条`}
|
|
||||||
sx={{
|
|
||||||
height: 22,
|
|
||||||
backgroundColor: "rgba(37, 99, 235, 0.08)",
|
|
||||||
color: "#2563eb",
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
border: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ height: 320, px: 1, py: 1 }}>
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={tableRows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
columnBufferPx={100}
|
|
||||||
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
|
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
|
||||||
initialState={{
|
pageSizeOptions={[15]}
|
||||||
pagination: { paginationModel: { pageSize: 50, page: 0 } },
|
initialState={{ pagination: { paginationModel: { pageSize: 15, page: 0 } } }}
|
||||||
}}
|
|
||||||
pageSizeOptions={[50]}
|
|
||||||
hideFooterSelectedRowCount
|
|
||||||
sx={{
|
|
||||||
border: "none",
|
|
||||||
"& .MuiDataGrid-cell": { borderColor: "#f0f0f0" },
|
|
||||||
"& .MuiDataGrid-columnHeaders": { backgroundColor: "#fafafa" },
|
|
||||||
"& .MuiDataGrid-row:hover": { backgroundColor: "#f8fafc" },
|
|
||||||
// Hide the rows per page selector since it's fixed to 50
|
|
||||||
"& .MuiTablePagination-selectLabel": { display: "none" },
|
|
||||||
"& .MuiTablePagination-input": { display: "none" },
|
|
||||||
}}
|
|
||||||
disableRowSelectionOnClick
|
disableRowSelectionOnClick
|
||||||
onRowClick={(params) => setSelectedDay(Number(params.row.Day))}
|
onRowClick={(params) => setSelectedDay(Number(params.row.Day))}
|
||||||
|
getRowClassName={(params) =>
|
||||||
|
params.row.Day === resultsState.selectedDay ? "bg-blue-50" : ""
|
||||||
|
}
|
||||||
|
sx={{ border: "none" }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -69,6 +69,16 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||||
const setSchemes = onSchemesChange || setInternalSchemes;
|
const setSchemes = onSchemesChange || setInternalSchemes;
|
||||||
|
const sortedSchemes = useMemo(
|
||||||
|
() =>
|
||||||
|
schemes
|
||||||
|
.slice()
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
|
||||||
|
),
|
||||||
|
[schemes],
|
||||||
|
);
|
||||||
|
|
||||||
const buildDisplayResult = (
|
const buildDisplayResult = (
|
||||||
scheme: Pick<BurstDetectionSchemeRecord, "scheme_name" | "username" | "create_time">,
|
scheme: Pick<BurstDetectionSchemeRecord, "scheme_name" | "username" | "create_time">,
|
||||||
@@ -105,6 +115,12 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
username: payload?.username ?? scheme.username,
|
username: payload?.username ?? scheme.username,
|
||||||
create_time: payload?.create_time ?? scheme.create_time,
|
create_time: payload?.create_time ?? scheme.create_time,
|
||||||
algorithm_params: payload?.algorithm_params ?? detail?.algorithm_params,
|
algorithm_params: payload?.algorithm_params ?? detail?.algorithm_params,
|
||||||
|
requested_target_time: payload?.requested_target_time,
|
||||||
|
target_time: payload?.target_time,
|
||||||
|
reference_window: payload?.reference_window,
|
||||||
|
sampling_interval_minutes: payload?.sampling_interval_minutes,
|
||||||
|
daily_scores: payload?.daily_scores,
|
||||||
|
data_quality: payload?.data_quality,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -214,7 +230,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="flex-1 overflow-auto">
|
<Box className="flex-1 overflow-auto">
|
||||||
{schemes.length === 0 ? (
|
{sortedSchemes.length === 0 ? (
|
||||||
<Box className="flex h-full flex-col items-center justify-center text-center text-gray-400">
|
<Box className="flex h-full flex-col items-center justify-center text-center text-gray-400">
|
||||||
<Typography variant="body2">暂无侦测方案</Typography>
|
<Typography variant="body2">暂无侦测方案</Typography>
|
||||||
<Typography variant="caption" className="mt-1">
|
<Typography variant="caption" className="mt-1">
|
||||||
@@ -224,9 +240,9 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
) : (
|
) : (
|
||||||
<Box className="space-y-2 p-2">
|
<Box className="space-y-2 p-2">
|
||||||
<Typography variant="caption" className="px-2 text-gray-500">
|
<Typography variant="caption" className="px-2 text-gray-500">
|
||||||
共 {schemes.length} 条记录
|
共 {sortedSchemes.length} 条记录
|
||||||
</Typography>
|
</Typography>
|
||||||
{schemes.map((scheme) => {
|
{sortedSchemes.map((scheme) => {
|
||||||
const summary = scheme.scheme_detail?.result_summary;
|
const summary = scheme.scheme_detail?.result_summary;
|
||||||
const payload = scheme.scheme_detail?.result_payload;
|
const payload = scheme.scheme_detail?.result_payload;
|
||||||
const isBurst = payload?.summary?.burst_detected ?? summary?.burst_detected ?? false;
|
const isBurst = payload?.summary?.burst_detected ?? summary?.burst_detected ?? false;
|
||||||
@@ -235,6 +251,9 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
const mostAnomalousDay =
|
const mostAnomalousDay =
|
||||||
payload?.summary?.most_anomalous_day ?? summary?.most_anomalous_day ?? "-";
|
payload?.summary?.most_anomalous_day ?? summary?.most_anomalous_day ?? "-";
|
||||||
const sensorCount = payload?.sensor_nodes?.length ?? scheme.scheme_detail?.sensor_nodes?.length ?? 0;
|
const sensorCount = payload?.sensor_nodes?.length ?? scheme.scheme_detail?.sensor_nodes?.length ?? 0;
|
||||||
|
const targetTime = payload?.target_time ?? payload?.summary?.target_time;
|
||||||
|
const targetScore = payload?.summary?.target_score ?? summary?.target_score;
|
||||||
|
const targetRank = payload?.summary?.target_rank ?? summary?.target_rank;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={scheme.scheme_id} variant="outlined" className="transition-shadow hover:shadow-md">
|
<Card key={scheme.scheme_id} variant="outlined" className="transition-shadow hover:shadow-md">
|
||||||
@@ -283,18 +302,22 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
<Box className="grid grid-cols-3 gap-2">
|
<Box className="grid grid-cols-3 gap-2">
|
||||||
<Box className="rounded bg-gray-50 p-2">
|
<Box className="rounded bg-gray-50 p-2">
|
||||||
<Typography variant="caption" className="text-gray-500">
|
<Typography variant="caption" className="text-gray-500">
|
||||||
异常天数
|
{targetTime ? "目标时刻" : "异常天数"}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" className="font-semibold text-gray-900">
|
<Typography variant="body2" className="font-semibold text-gray-900">
|
||||||
{anomalyDayCount}
|
{targetTime ? dayjs(targetTime).format("MM-DD HH:mm") : anomalyDayCount}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box className="rounded bg-gray-50 p-2">
|
<Box className="rounded bg-gray-50 p-2">
|
||||||
<Typography variant="caption" className="text-gray-500">
|
<Typography variant="caption" className="text-gray-500">
|
||||||
最异常日
|
{targetTime ? "目标分数" : "最异常日"}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" className="font-semibold text-gray-900">
|
<Typography variant="body2" className="font-semibold text-gray-900">
|
||||||
{isBurst
|
{targetTime
|
||||||
|
? typeof targetScore === "number"
|
||||||
|
? targetScore.toFixed(4)
|
||||||
|
: "-"
|
||||||
|
: isBurst
|
||||||
? typeof mostAnomalousDay === "number"
|
? typeof mostAnomalousDay === "number"
|
||||||
? `第 ${mostAnomalousDay} 天`
|
? `第 ${mostAnomalousDay} 天`
|
||||||
: mostAnomalousDay
|
: mostAnomalousDay
|
||||||
@@ -303,10 +326,12 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
<Box className="rounded bg-gray-50 p-2">
|
<Box className="rounded bg-gray-50 p-2">
|
||||||
<Typography variant="caption" className="text-gray-500">
|
<Typography variant="caption" className="text-gray-500">
|
||||||
测点数
|
{targetTime ? "异常排名" : "测点数"}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" className="font-semibold text-gray-900">
|
<Typography variant="body2" className="font-semibold text-gray-900">
|
||||||
{sensorCount}
|
{targetTime && targetRank
|
||||||
|
? `${targetRank} / ${payload?.day_count ?? 15}`
|
||||||
|
: sensorCount}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -326,6 +351,8 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
if (ds === "monitoring") return "监测数据";
|
if (ds === "monitoring") return "监测数据";
|
||||||
if (os === "simulation_scheme_timerange") return "模拟数据";
|
if (os === "simulation_scheme_timerange") return "模拟数据";
|
||||||
if (os === "backend_timerange") return "监测数据";
|
if (os === "backend_timerange") return "监测数据";
|
||||||
|
if (os === "latest_monitoring") return "最新监测数据";
|
||||||
|
if (os === "historical_monitoring") return "历史监测回放";
|
||||||
return os || "-";
|
return os || "-";
|
||||||
})()}
|
})()}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -344,14 +371,16 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
|
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
|
||||||
<Typography variant="caption" className="text-gray-600">
|
<Typography variant="caption" className="text-gray-600">
|
||||||
算法参数:
|
侦测口径:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" className="font-medium text-gray-900">
|
<Typography variant="caption" className="font-medium text-gray-900">
|
||||||
频域截断系数:{scheme.scheme_detail?.algorithm_params?.mu ?? payload?.algorithm_params?.mu ?? "-"}
|
频域系数:{scheme.scheme_detail?.algorithm_params?.mu ?? payload?.algorithm_params?.mu ?? "-"}
|
||||||
,每日采样点数:
|
,每日采样:
|
||||||
{scheme.scheme_detail?.algorithm_params?.points_per_day ??
|
{scheme.scheme_detail?.algorithm_params?.points_per_day ??
|
||||||
payload?.algorithm_params?.points_per_day ??
|
payload?.algorithm_params?.points_per_day ??
|
||||||
"-"}
|
"-"}
|
||||||
|
点,阈值:
|
||||||
|
{payload?.summary?.score_threshold ?? "-"}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ export interface BurstDetectionRow {
|
|||||||
Score: number;
|
Score: number;
|
||||||
Prediction: number;
|
Prediction: number;
|
||||||
IsBurst: boolean;
|
IsBurst: boolean;
|
||||||
|
Timestamp?: string;
|
||||||
|
Role?: "reference" | "target";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BurstDetectionSensorRanking {
|
export interface BurstDetectionSensorRanking {
|
||||||
sensor_node: string;
|
sensor_node: string;
|
||||||
latest_high_frequency_value: number;
|
latest_high_frequency_value: number;
|
||||||
|
historical_mean?: number;
|
||||||
|
historical_std?: number;
|
||||||
|
standardized_deviation?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BurstDetectionSummary {
|
export interface BurstDetectionSummary {
|
||||||
@@ -17,6 +22,11 @@ export interface BurstDetectionSummary {
|
|||||||
anomaly_days: number[];
|
anomaly_days: number[];
|
||||||
anomaly_day_count: number;
|
anomaly_day_count: number;
|
||||||
latest_sensor_rankings: BurstDetectionSensorRanking[];
|
latest_sensor_rankings: BurstDetectionSensorRanking[];
|
||||||
|
target_score?: number;
|
||||||
|
score_threshold?: number;
|
||||||
|
target_rank?: number;
|
||||||
|
target_time?: string;
|
||||||
|
reference_day_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BurstDetectionAlgorithmParams {
|
export interface BurstDetectionAlgorithmParams {
|
||||||
@@ -27,6 +37,7 @@ export interface BurstDetectionAlgorithmParams {
|
|||||||
contamination?: number | "auto";
|
contamination?: number | "auto";
|
||||||
random_state?: number;
|
random_state?: number;
|
||||||
};
|
};
|
||||||
|
score_threshold?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BurstDetectionResult {
|
export interface BurstDetectionResult {
|
||||||
@@ -51,6 +62,25 @@ export interface BurstDetectionResult {
|
|||||||
type?: string;
|
type?: string;
|
||||||
};
|
};
|
||||||
algorithm_params?: BurstDetectionAlgorithmParams;
|
algorithm_params?: BurstDetectionAlgorithmParams;
|
||||||
|
requested_target_time?: string | null;
|
||||||
|
target_time?: string;
|
||||||
|
reference_window?: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
day_count: number;
|
||||||
|
};
|
||||||
|
sampling_interval_minutes?: number;
|
||||||
|
daily_scores?: Array<{
|
||||||
|
timestamp: string;
|
||||||
|
role: "reference" | "target";
|
||||||
|
score: number;
|
||||||
|
raw_prediction: number;
|
||||||
|
}>;
|
||||||
|
data_quality?: {
|
||||||
|
included_sensors: string[];
|
||||||
|
excluded_sensors: Array<{ sensor_node: string; reason: string }>;
|
||||||
|
minimum_required_sensors: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BurstDetectionSchemeDetail {
|
export interface BurstDetectionSchemeDetail {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface SchemeItem {
|
|||||||
scheme_start_time: string;
|
scheme_start_time: string;
|
||||||
scheme_detail?: {
|
scheme_detail?: {
|
||||||
modify_total_duration: number;
|
modify_total_duration: number;
|
||||||
|
burst_ID?: string[] | string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +129,9 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
const burstSchemes = (response.data as SchemeItem[]).filter(
|
const burstSchemes = (response.data as SchemeItem[]).filter(
|
||||||
(scheme) => scheme.scheme_type === "burst_analysis",
|
(scheme) => scheme.scheme_type === "burst_analysis",
|
||||||
|
).sort(
|
||||||
|
(a, b) =>
|
||||||
|
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
|
||||||
);
|
);
|
||||||
|
|
||||||
setFormField("schemes", burstSchemes);
|
setFormField("schemes", burstSchemes);
|
||||||
@@ -234,7 +238,24 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
onResult(response.data as BurstLocationResult);
|
const resultPayload = response.data as BurstLocationResult;
|
||||||
|
const selectedBurstIds = normalizeBurstIds(selectedScheme?.scheme_detail?.burst_ID);
|
||||||
|
onResult(
|
||||||
|
selectedBurstIds.length > 0
|
||||||
|
? {
|
||||||
|
...resultPayload,
|
||||||
|
simulation_scheme: {
|
||||||
|
...resultPayload.simulation_scheme,
|
||||||
|
name: resultPayload.simulation_scheme?.name ?? selectedScheme?.scheme_name,
|
||||||
|
type: resultPayload.simulation_scheme?.type ?? selectedScheme?.scheme_type,
|
||||||
|
burst_ids:
|
||||||
|
resultPayload.simulation_scheme?.burst_ids?.length
|
||||||
|
? resultPayload.simulation_scheme.burst_ids
|
||||||
|
: selectedBurstIds,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: resultPayload,
|
||||||
|
);
|
||||||
open?.({
|
open?.({
|
||||||
key: "burst-location-analysis-success",
|
key: "burst-location-analysis-success",
|
||||||
type: "success",
|
type: "success",
|
||||||
@@ -500,3 +521,11 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default AnalysisParameters;
|
export default AnalysisParameters;
|
||||||
|
|
||||||
|
const normalizeBurstIds = (value: string[] | string | undefined) => {
|
||||||
|
if (!value) return [];
|
||||||
|
const values = Array.isArray(value) ? value : [value];
|
||||||
|
return Array.from(
|
||||||
|
new Set(values.map((item) => String(item).trim()).filter(Boolean)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
Button,
|
Button,
|
||||||
|
Link,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
FormatListBulleted,
|
FormatListBulleted,
|
||||||
@@ -183,6 +184,7 @@ const LocationResults: React.FC<Props> = ({ result }) => {
|
|||||||
properties: {
|
properties: {
|
||||||
name: "爆管定位高亮",
|
name: "爆管定位高亮",
|
||||||
value: "burst_location_highlight",
|
value: "burst_location_highlight",
|
||||||
|
queryable: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
map.addLayer(layer);
|
map.addLayer(layer);
|
||||||
@@ -251,6 +253,7 @@ const LocationResults: React.FC<Props> = ({ result }) => {
|
|||||||
);
|
);
|
||||||
const sourceLabel = getDataSourceLabel(result);
|
const sourceLabel = getDataSourceLabel(result);
|
||||||
const normalDataDescription = getNormalDataDescription(result);
|
const normalDataDescription = getNormalDataDescription(result);
|
||||||
|
const simulationBurstIds = result.simulation_scheme?.burst_ids ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="h-full overflow-auto p-1">
|
<Box className="h-full overflow-auto p-1">
|
||||||
@@ -287,6 +290,7 @@ const LocationResults: React.FC<Props> = ({ result }) => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<LocationOnIcon />}
|
startIcon={<LocationOnIcon />}
|
||||||
onClick={() => locatePipes([result.located_pipe])}
|
onClick={() => locatePipes([result.located_pipe])}
|
||||||
|
disabled={!result.located_pipe}
|
||||||
sx={{
|
sx={{
|
||||||
height: 24,
|
height: 24,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
@@ -348,6 +352,55 @@ const LocationResults: React.FC<Props> = ({ result }) => {
|
|||||||
爆管方案: {result.simulation_scheme.name}
|
爆管方案: {result.simulation_scheme.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
|
{simulationBurstIds.length > 0 ? (
|
||||||
|
<Box className="mt-1 flex flex-wrap items-center gap-1">
|
||||||
|
<Typography variant="caption" className="text-purple-600">
|
||||||
|
模拟管段:
|
||||||
|
</Typography>
|
||||||
|
{simulationBurstIds.map((pipeId) => (
|
||||||
|
<Link
|
||||||
|
key={pipeId}
|
||||||
|
component="button"
|
||||||
|
variant="caption"
|
||||||
|
onClick={() => locatePipes([pipeId])}
|
||||||
|
title={pipeId}
|
||||||
|
sx={{
|
||||||
|
maxWidth: 132,
|
||||||
|
color: "#7c3aed",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: "22px",
|
||||||
|
cursor: "pointer",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
textDecoration: "underline",
|
||||||
|
textUnderlineOffset: "2px",
|
||||||
|
"&:hover": {
|
||||||
|
color: "#5b21b6",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pipeId}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
startIcon={<LocationOnIcon />}
|
||||||
|
onClick={() => locatePipes(simulationBurstIds)}
|
||||||
|
sx={{
|
||||||
|
minWidth: 0,
|
||||||
|
px: 0.5,
|
||||||
|
py: 0,
|
||||||
|
color: "#7c3aed",
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
定位全部
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -13,8 +13,12 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
|
Link,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { Info as InfoIcon } from "@mui/icons-material";
|
import {
|
||||||
|
Info as InfoIcon,
|
||||||
|
LocationOn as LocationOnIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
@@ -24,6 +28,14 @@ import { useNotification } from "@refinedev/core";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { NETWORK_NAME, config } from "@config/config";
|
import { NETWORK_NAME, config } from "@config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import { useMap } from "@components/olmap/core/MapComponent";
|
||||||
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
|
import { GeoJSON } from "ol/format";
|
||||||
|
import Feature from "ol/Feature";
|
||||||
|
import VectorLayer from "ol/layer/Vector";
|
||||||
|
import VectorSource from "ol/source/Vector";
|
||||||
|
import { Stroke, Style, Circle, Fill } from "ol/style";
|
||||||
|
import { bbox, featureCollection } from "@turf/turf";
|
||||||
import {
|
import {
|
||||||
BurstLocationResult,
|
BurstLocationResult,
|
||||||
BurstLocationSchemeDetail,
|
BurstLocationSchemeDetail,
|
||||||
@@ -43,6 +55,7 @@ export interface BurstLocationSchemeQueryState {
|
|||||||
queryAll: boolean;
|
queryAll: boolean;
|
||||||
queryDate: Dayjs | null;
|
queryDate: Dayjs | null;
|
||||||
expandedId: number | null;
|
expandedId: number | null;
|
||||||
|
simulationBurstIdsByName: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBurstLocationSchemeQueryState =
|
export const createBurstLocationSchemeQueryState =
|
||||||
@@ -50,6 +63,7 @@ export const createBurstLocationSchemeQueryState =
|
|||||||
queryAll: true,
|
queryAll: true,
|
||||||
queryDate: dayjs(),
|
queryDate: dayjs(),
|
||||||
expandedId: null,
|
expandedId: null,
|
||||||
|
simulationBurstIdsByName: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const SchemeQuery: React.FC<Props> = ({
|
const SchemeQuery: React.FC<Props> = ({
|
||||||
@@ -60,16 +74,117 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
onStateChange,
|
onStateChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
|
const map = useMap();
|
||||||
|
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||||
|
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||||
const [queryState, , setQueryField] = useControllableObjectState(
|
const [queryState, , setQueryField] = useControllableObjectState(
|
||||||
state,
|
state,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
createBurstLocationSchemeQueryState(),
|
createBurstLocationSchemeQueryState(),
|
||||||
);
|
);
|
||||||
const { queryAll, queryDate, expandedId } = queryState;
|
const { queryAll, queryDate, expandedId } = queryState;
|
||||||
|
const simulationBurstIdsByName = queryState.simulationBurstIdsByName ?? {};
|
||||||
const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]);
|
const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||||
const setSchemes = onSchemesChange || setInternalSchemes;
|
const setSchemes = onSchemesChange || setInternalSchemes;
|
||||||
|
const sortedSchemes = useMemo(
|
||||||
|
() =>
|
||||||
|
schemes
|
||||||
|
.slice()
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
|
||||||
|
),
|
||||||
|
[schemes],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const layer = new VectorLayer({
|
||||||
|
source: new VectorSource(),
|
||||||
|
style: new Style({
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: "#a855f7",
|
||||||
|
width: 6,
|
||||||
|
}),
|
||||||
|
image: new Circle({
|
||||||
|
radius: 8,
|
||||||
|
fill: new Fill({ color: "#a855f7" }),
|
||||||
|
stroke: new Stroke({ color: "#fff", width: 2 }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
properties: {
|
||||||
|
name: "爆管定位模拟管段高亮",
|
||||||
|
value: "burst_location_simulation_pipe_highlight",
|
||||||
|
queryable: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
map.addLayer(layer);
|
||||||
|
highlightLayerRef.current = layer;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
highlightLayerRef.current = null;
|
||||||
|
map.removeLayer(layer);
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const source = highlightLayerRef.current?.getSource();
|
||||||
|
if (!source) return;
|
||||||
|
source.clear();
|
||||||
|
highlightFeatures.forEach((feature) => source.addFeature(feature));
|
||||||
|
}, [highlightFeatures]);
|
||||||
|
|
||||||
|
const locatePipes = async (pipeIds: string[]) => {
|
||||||
|
const uniquePipeIds = Array.from(new Set(pipeIds.filter(Boolean)));
|
||||||
|
if (!uniquePipeIds.length || !map) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let features = await queryFeaturesByIds(uniquePipeIds, "geo_pipes_mat");
|
||||||
|
if (features.length === 0) {
|
||||||
|
features = await queryFeaturesByIds(uniquePipeIds, "geo_pipes");
|
||||||
|
}
|
||||||
|
if (features.length === 0) return;
|
||||||
|
|
||||||
|
setHighlightFeatures(features);
|
||||||
|
const geojsonFormat = new GeoJSON();
|
||||||
|
const geojsonFeatures = features.map((feature) =>
|
||||||
|
geojsonFormat.writeFeatureObject(feature),
|
||||||
|
);
|
||||||
|
// @ts-ignore turf typing with ol geojson objects
|
||||||
|
const extent = bbox(featureCollection(geojsonFeatures));
|
||||||
|
map.getView().fit(extent, {
|
||||||
|
maxZoom: 19,
|
||||||
|
duration: 1000,
|
||||||
|
padding: [100, 100, 100, 100],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Locate failed", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSimulationBurstIds = (payload?: BurstLocationResult) => {
|
||||||
|
const directIds = payload?.simulation_scheme?.burst_ids ?? [];
|
||||||
|
if (directIds.length > 0) return directIds;
|
||||||
|
const simulationSchemeName = payload?.simulation_scheme?.name;
|
||||||
|
return simulationSchemeName
|
||||||
|
? simulationBurstIdsByName[simulationSchemeName] ?? []
|
||||||
|
: [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const enrichResultWithSimulationBurstIds = (payload: BurstLocationResult) => {
|
||||||
|
const simulationBurstIds = getSimulationBurstIds(payload);
|
||||||
|
if (simulationBurstIds.length === 0) return payload;
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
simulation_scheme: {
|
||||||
|
...payload.simulation_scheme,
|
||||||
|
burst_ids: simulationBurstIds,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const buildDisplayResult = (
|
const buildDisplayResult = (
|
||||||
scheme: Pick<BurstSchemeRecord, "scheme_name" | "username" | "create_time">,
|
scheme: Pick<BurstSchemeRecord, "scheme_name" | "username" | "create_time">,
|
||||||
@@ -115,9 +230,30 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
params.query_date = queryDate.startOf("day").toISOString();
|
params.query_date = queryDate.startOf("day").toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.get(url, { params });
|
const [response, simulationResponse] = await Promise.all([
|
||||||
|
api.get(url, { params }),
|
||||||
|
api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||||
|
params: { network: NETWORK_NAME },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
const nextSchemes = response.data as BurstSchemeRecord[];
|
const nextSchemes = response.data as BurstSchemeRecord[];
|
||||||
setSchemes(nextSchemes);
|
const nextSimulationBurstIdsByName = Object.fromEntries(
|
||||||
|
(simulationResponse.data as BurstSimulationSchemeItem[])
|
||||||
|
.filter((scheme) => scheme.scheme_type === "burst_analysis")
|
||||||
|
.map((scheme) => [
|
||||||
|
scheme.scheme_name,
|
||||||
|
normalizeBurstIds(scheme.scheme_detail?.burst_ID),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
setQueryField("simulationBurstIdsByName", nextSimulationBurstIdsByName);
|
||||||
|
setSchemes(
|
||||||
|
nextSchemes.map((scheme) =>
|
||||||
|
enrichSchemeWithSimulationBurstIds(
|
||||||
|
scheme,
|
||||||
|
nextSimulationBurstIdsByName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "查询成功",
|
message: "查询成功",
|
||||||
@@ -157,7 +293,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
if (!normalizedResult) {
|
if (!normalizedResult) {
|
||||||
throw new Error("方案详情缺少定位结果数据");
|
throw new Error("方案详情缺少定位结果数据");
|
||||||
}
|
}
|
||||||
onViewResult(normalizedResult);
|
onViewResult(enrichResultWithSimulationBurstIds(normalizedResult));
|
||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "方案加载成功",
|
message: "方案加载成功",
|
||||||
@@ -211,7 +347,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box className="flex-1 overflow-auto">
|
<Box className="flex-1 overflow-auto">
|
||||||
{schemes.length === 0 ? (
|
{sortedSchemes.length === 0 ? (
|
||||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
||||||
<Box className="mb-4">
|
<Box className="mb-4">
|
||||||
<svg
|
<svg
|
||||||
@@ -248,12 +384,13 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
) : (
|
) : (
|
||||||
<Box className="space-y-2 p-2">
|
<Box className="space-y-2 p-2">
|
||||||
<Typography variant="caption" className="text-gray-500 px-2">
|
<Typography variant="caption" className="text-gray-500 px-2">
|
||||||
共 {schemes.length} 条记录
|
共 {sortedSchemes.length} 条记录
|
||||||
</Typography>
|
</Typography>
|
||||||
{schemes.map((scheme) => {
|
{sortedSchemes.map((scheme) => {
|
||||||
const summary = scheme.scheme_detail?.result_summary;
|
const summary = scheme.scheme_detail?.result_summary;
|
||||||
const payload = scheme.scheme_detail?.result_payload;
|
const payload = scheme.scheme_detail?.result_payload;
|
||||||
const locatedPipe = payload?.located_pipe ?? summary?.located_pipe ?? "-";
|
const locatedPipe = payload?.located_pipe ?? summary?.located_pipe ?? "-";
|
||||||
|
const simulationBurstIds = getSimulationBurstIds(payload);
|
||||||
const leakage =
|
const leakage =
|
||||||
payload?.burst_leakage ?? scheme.scheme_detail?.algorithm_params?.burst_leakage;
|
payload?.burst_leakage ?? scheme.scheme_detail?.algorithm_params?.burst_leakage;
|
||||||
|
|
||||||
@@ -330,6 +467,52 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
{locatedPipe}
|
{locatedPipe}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
{simulationBurstIds.length > 0 ? (
|
||||||
|
<Box className="grid grid-cols-[78px_1fr] items-start gap-x-2">
|
||||||
|
<Typography variant="caption" className="mt-1 text-gray-600">
|
||||||
|
模拟管段:
|
||||||
|
</Typography>
|
||||||
|
<Box className="flex flex-wrap gap-1">
|
||||||
|
{simulationBurstIds.map((pipeId) => (
|
||||||
|
<Link
|
||||||
|
key={pipeId}
|
||||||
|
component="button"
|
||||||
|
variant="caption"
|
||||||
|
onClick={() => locatePipes([pipeId])}
|
||||||
|
title={pipeId}
|
||||||
|
sx={{
|
||||||
|
maxWidth: 132,
|
||||||
|
color: "#7c3aed",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: "22px",
|
||||||
|
cursor: "pointer",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
textDecoration: "underline",
|
||||||
|
textUnderlineOffset: "2px",
|
||||||
|
"&:hover": {
|
||||||
|
color: "#5b21b6",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pipeId}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<Tooltip title="定位全部模拟管段">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
onClick={() => locatePipes(simulationBurstIds)}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<LocationOnIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
|
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
|
||||||
<Typography variant="caption" className="text-gray-600">
|
<Typography variant="caption" className="text-gray-600">
|
||||||
漏损量:
|
漏损量:
|
||||||
@@ -373,3 +556,46 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default SchemeQuery;
|
export default SchemeQuery;
|
||||||
|
|
||||||
|
interface BurstSimulationSchemeItem {
|
||||||
|
scheme_name: string;
|
||||||
|
scheme_type: string;
|
||||||
|
scheme_detail?: {
|
||||||
|
burst_ID?: string[] | string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeBurstIds = (value: string[] | string | undefined) => {
|
||||||
|
if (!value) return [];
|
||||||
|
const values = Array.isArray(value) ? value : [value];
|
||||||
|
return Array.from(
|
||||||
|
new Set(values.map((item) => String(item).trim()).filter(Boolean)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const enrichSchemeWithSimulationBurstIds = (
|
||||||
|
scheme: BurstSchemeRecord,
|
||||||
|
simulationBurstIdsByName: Record<string, string[]>,
|
||||||
|
) => {
|
||||||
|
const payload = scheme.scheme_detail?.result_payload;
|
||||||
|
const simulationSchemeName = payload?.simulation_scheme?.name;
|
||||||
|
const simulationBurstIds = simulationSchemeName
|
||||||
|
? simulationBurstIdsByName[simulationSchemeName] ?? []
|
||||||
|
: [];
|
||||||
|
if (!payload || simulationBurstIds.length === 0) return scheme;
|
||||||
|
if (payload.simulation_scheme?.burst_ids?.length) return scheme;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...scheme,
|
||||||
|
scheme_detail: {
|
||||||
|
...scheme.scheme_detail,
|
||||||
|
result_payload: {
|
||||||
|
...payload,
|
||||||
|
simulation_scheme: {
|
||||||
|
...payload.simulation_scheme,
|
||||||
|
burst_ids: simulationBurstIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface BurstLocationResult {
|
|||||||
simulation_scheme?: {
|
simulation_scheme?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
burst_ids?: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
properties: {
|
properties: {
|
||||||
name: "高亮管道",
|
name: "高亮管道",
|
||||||
value: "highlight_pipeline",
|
value: "highlight_pipeline",
|
||||||
|
queryable: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ const LocationResults: React.FC<LocationResultsProps> = ({
|
|||||||
properties: {
|
properties: {
|
||||||
name: "爆管管段高亮",
|
name: "爆管管段高亮",
|
||||||
value: "burst_pipe_highlight",
|
value: "burst_pipe_highlight",
|
||||||
|
queryable: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ import { Point } from "ol/geom";
|
|||||||
import { toLonLat } from "ol/proj";
|
import { toLonLat } from "ol/proj";
|
||||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||||
import { SchemaItem, SchemeRecord } from "./types";
|
import { SchemaItem, SchemeRecord } from "./types";
|
||||||
|
import {
|
||||||
|
getPipeDiameterDisplay,
|
||||||
|
type PipeDiameterMap,
|
||||||
|
} from "./schemePipeDiameters";
|
||||||
|
|
||||||
interface SchemeQueryProps {
|
interface SchemeQueryProps {
|
||||||
schemes?: SchemeRecord[];
|
schemes?: SchemeRecord[];
|
||||||
@@ -109,6 +113,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
|
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素
|
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素
|
||||||
|
const [pipeDiametersByScheme, setPipeDiametersByScheme] = useState<
|
||||||
|
Record<number, PipeDiameterMap>
|
||||||
|
>({});
|
||||||
|
const [loadingDiameterByScheme, setLoadingDiameterByScheme] = useState<
|
||||||
|
Record<number, boolean>
|
||||||
|
>({});
|
||||||
|
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
|
|
||||||
@@ -128,7 +138,13 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filteredSchemes = useMemo(() => {
|
const filteredSchemes = useMemo(() => {
|
||||||
return schemes.filter((scheme) => scheme.type === SCHEME_TYPE);
|
return schemes
|
||||||
|
.filter((scheme) => scheme.type === SCHEME_TYPE)
|
||||||
|
.slice()
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
moment(b.create_time).valueOf() - moment(a.create_time).valueOf(),
|
||||||
|
);
|
||||||
}, [schemes]);
|
}, [schemes]);
|
||||||
|
|
||||||
const handleQuery = async () => {
|
const handleQuery = async () => {
|
||||||
@@ -168,6 +184,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
? "没有找到任何方案"
|
? "没有找到任何方案"
|
||||||
: `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`,
|
: `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
open?.({
|
||||||
|
type: "success",
|
||||||
|
message: "查询成功",
|
||||||
|
description: `共找到 ${filteredResults.length} 条方案记录`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("查询请求失败:", error);
|
console.error("查询请求失败:", error);
|
||||||
@@ -203,6 +225,80 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (expandedId === null || pipeDiametersByScheme[expandedId]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheme = filteredSchemes.find((scheme) => scheme.id === expandedId);
|
||||||
|
const pipeIds = scheme?.schemeDetail?.burst_ID ?? [];
|
||||||
|
if (pipeIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadingDiameterByScheme((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[expandedId]: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const loadPipeDiameters = async () => {
|
||||||
|
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat");
|
||||||
|
const foundPipeIds = new Set(
|
||||||
|
features.map((feature) => String(feature.getProperties().id)),
|
||||||
|
);
|
||||||
|
const missingPipeIds = pipeIds.filter(
|
||||||
|
(pipeId) => !foundPipeIds.has(pipeId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingPipeIds.length > 0) {
|
||||||
|
const fallbackFeatures = await queryFeaturesByIds(
|
||||||
|
missingPipeIds,
|
||||||
|
"geo_pipes",
|
||||||
|
);
|
||||||
|
features = [...features, ...fallbackFeatures];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDiameters: PipeDiameterMap = Object.fromEntries(
|
||||||
|
pipeIds.map((pipeId) => [pipeId, null]),
|
||||||
|
);
|
||||||
|
|
||||||
|
features.forEach((feature) => {
|
||||||
|
const properties = feature.getProperties();
|
||||||
|
const pipeId = String(properties.id);
|
||||||
|
const diameter = Number(properties.diameter);
|
||||||
|
nextDiameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPipeDiametersByScheme((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[expandedId]: nextDiameters,
|
||||||
|
}));
|
||||||
|
setLoadingDiameterByScheme((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[expandedId]: false,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPipeDiameters().catch((error) => {
|
||||||
|
console.error("查询管径失败:", error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoadingDiameterByScheme((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[expandedId]: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [expandedId, filteredSchemes, pipeDiametersByScheme]);
|
||||||
|
|
||||||
// 内部的方案查询函数
|
// 内部的方案查询函数
|
||||||
const handleViewDetails = (id: number) => {
|
const handleViewDetails = (id: number) => {
|
||||||
const scheme = filteredSchemes.find((s) => s.id === id);
|
const scheme = filteredSchemes.find((s) => s.id === id);
|
||||||
@@ -301,6 +397,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
properties: {
|
properties: {
|
||||||
name: "爆管管段高亮",
|
name: "爆管管段高亮",
|
||||||
value: "burst_pipe_highlight",
|
value: "burst_pipe_highlight",
|
||||||
|
queryable: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -562,7 +659,11 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
variant="caption"
|
variant="caption"
|
||||||
className="font-medium text-gray-900"
|
className="font-medium text-gray-900"
|
||||||
>
|
>
|
||||||
560 mm
|
{getPipeDiameterDisplay(
|
||||||
|
scheme.schemeDetail?.burst_ID,
|
||||||
|
pipeDiametersByScheme[scheme.id],
|
||||||
|
!!loadingDiameterByScheme[scheme.id],
|
||||||
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box className="flex items-center gap-2">
|
<Box className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -546,6 +546,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
properties: {
|
properties: {
|
||||||
name: "阀门节点高亮",
|
name: "阀门节点高亮",
|
||||||
value: "valve_node_highlight",
|
value: "valve_node_highlight",
|
||||||
|
queryable: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { getPipeDiameterDisplay } from "./schemePipeDiameters";
|
||||||
|
|
||||||
|
describe("getPipeDiameterDisplay", () => {
|
||||||
|
it("shows the actual diameter for one burst pipe", () => {
|
||||||
|
expect(getPipeDiameterDisplay(["P-1"], { "P-1": 315 })).toBe("315 mm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows pipe IDs with diameters for multiple burst pipes", () => {
|
||||||
|
expect(
|
||||||
|
getPipeDiameterDisplay(["P-1", "P-2"], {
|
||||||
|
"P-1": 315,
|
||||||
|
"P-2": 800,
|
||||||
|
}),
|
||||||
|
).toBe("P-1: 315 mm;P-2: 800 mm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fall back to a fixed diameter when a diameter is missing", () => {
|
||||||
|
expect(getPipeDiameterDisplay(["P-1"], {})).toBe("N/A");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export type PipeDiameterMap = Record<string, number | null | undefined>;
|
||||||
|
|
||||||
|
export const getPipeDiameterDisplay = (
|
||||||
|
pipeIds: string[] | undefined,
|
||||||
|
diameters: PipeDiameterMap | undefined,
|
||||||
|
loading = false,
|
||||||
|
): string => {
|
||||||
|
if (loading) {
|
||||||
|
return "查询中...";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pipeIds?.length) {
|
||||||
|
return "N/A";
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = pipeIds.map((pipeId) => {
|
||||||
|
const diameter = diameters?.[pipeId];
|
||||||
|
const displayValue =
|
||||||
|
typeof diameter === "number" && Number.isFinite(diameter)
|
||||||
|
? `${diameter} mm`
|
||||||
|
: "N/A";
|
||||||
|
|
||||||
|
return pipeIds.length === 1 ? displayValue : `${pipeId}: ${displayValue}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return values.join(";");
|
||||||
|
};
|
||||||
@@ -158,6 +158,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
properties: {
|
properties: {
|
||||||
name: "污染源节点",
|
name: "污染源节点",
|
||||||
value: "contaminant_source_highlight",
|
value: "contaminant_source_highlight",
|
||||||
|
queryable: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
properties: {
|
properties: {
|
||||||
name: "污染源高亮",
|
name: "污染源高亮",
|
||||||
value: "contaminant_source_highlight",
|
value: "contaminant_source_highlight",
|
||||||
|
queryable: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,7 +203,13 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filteredSchemes = useMemo(() => {
|
const filteredSchemes = useMemo(() => {
|
||||||
return schemes.filter((scheme) => scheme.type === SCHEME_TYPE);
|
return schemes
|
||||||
|
.filter((scheme) => scheme.type === SCHEME_TYPE)
|
||||||
|
.slice()
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
moment(b.create_time).valueOf() - moment(a.create_time).valueOf(),
|
||||||
|
);
|
||||||
}, [schemes]);
|
}, [schemes]);
|
||||||
|
|
||||||
const handleQuery = async () => {
|
const handleQuery = async () => {
|
||||||
@@ -243,6 +250,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
? "没有找到任何方案"
|
? "没有找到任何方案"
|
||||||
: `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`,
|
: `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
open?.({
|
||||||
|
type: "success",
|
||||||
|
message: "查询成功",
|
||||||
|
description: `共找到 ${filteredResults.length} 条方案记录`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("查询请求失败:", error);
|
console.error("查询请求失败:", error);
|
||||||
|
|||||||
@@ -74,18 +74,31 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
advancedOpen,
|
advancedOpen,
|
||||||
} = parametersState;
|
} = parametersState;
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
|
const [qSumInput, setQSumInput] = useState(() => String(qSum));
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setQSumInput(String(qSum));
|
||||||
|
}, [qSum]);
|
||||||
|
|
||||||
|
const parsedQSum = Number(qSumInput);
|
||||||
|
const qSumIsValid =
|
||||||
|
qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 360;
|
||||||
|
|
||||||
const isValid = useMemo(() => {
|
const isValid = useMemo(() => {
|
||||||
if (!schemeName.trim() || !startTime || !endTime) return false;
|
if (!schemeName.trim() || !startTime || !endTime) return false;
|
||||||
return startTime.isBefore(endTime) && qSum >= 360;
|
return startTime.isBefore(endTime) && qSumIsValid;
|
||||||
}, [schemeName, startTime, endTime, qSum]);
|
}, [schemeName, startTime, endTime, qSumIsValid]);
|
||||||
|
|
||||||
const handleRun = async () => {
|
const handleRun = async () => {
|
||||||
if (!isValid || !startTime || !endTime) {
|
if (!isValid || !startTime || !endTime) {
|
||||||
open?.({ type: "error", message: "请完善参数并确认时间范围合法" });
|
open?.({
|
||||||
|
type: "error",
|
||||||
|
message: "请完善参数并确认时间范围合法",
|
||||||
|
description: !qSumIsValid ? `总漏损流量需不小于 360 ${FLOW_DISPLAY_UNIT}` : undefined,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setFormField("qSum", parsedQSum);
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
open?.({
|
open?.({
|
||||||
key: "dma-leak-analysis-progress",
|
key: "dma-leak-analysis-progress",
|
||||||
@@ -213,12 +226,22 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
<TextField
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
size="small"
|
size="small"
|
||||||
value={qSum}
|
value={qSumInput}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = Number(e.target.value);
|
const rawValue = e.target.value;
|
||||||
setFormField("qSum", Number.isNaN(value) ? 1440 : Math.max(360, value));
|
setQSumInput(rawValue);
|
||||||
|
const value = Number(rawValue);
|
||||||
|
if (rawValue.trim() !== "" && Number.isFinite(value)) {
|
||||||
|
setFormField("qSum", value);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
inputProps={{ min: 360, step: 10 }}
|
inputProps={{ min: 360, step: 10 }}
|
||||||
|
error={qSumInput.trim() !== "" && !qSumIsValid}
|
||||||
|
helperText={
|
||||||
|
qSumInput.trim() !== "" && !qSumIsValid
|
||||||
|
? `需不小于 360 ${FLOW_DISPLAY_UNIT}`
|
||||||
|
: " "
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -65,6 +65,16 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||||
const setSchemes = onSchemesChange || setInternalSchemes;
|
const setSchemes = onSchemesChange || setInternalSchemes;
|
||||||
|
const sortedSchemes = useMemo(
|
||||||
|
() =>
|
||||||
|
schemes
|
||||||
|
.slice()
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
|
||||||
|
),
|
||||||
|
[schemes],
|
||||||
|
);
|
||||||
|
|
||||||
const handleQuery = async () => {
|
const handleQuery = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -78,6 +88,21 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
const nextSchemes = response.data as LeakageSchemeRecord[];
|
const nextSchemes = response.data as LeakageSchemeRecord[];
|
||||||
setSchemes(nextSchemes);
|
setSchemes(nextSchemes);
|
||||||
|
if (nextSchemes.length === 0) {
|
||||||
|
open?.({
|
||||||
|
type: "error",
|
||||||
|
message: "查询结果",
|
||||||
|
description: queryAll
|
||||||
|
? "没有找到任何方案"
|
||||||
|
: `${queryDate?.format("YYYY-MM-DD")} 没有找到相关方案`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
open?.({
|
||||||
|
type: "success",
|
||||||
|
message: "查询成功",
|
||||||
|
description: `共找到 ${nextSchemes.length} 条方案记录`,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
@@ -144,7 +169,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box className="flex-1 overflow-auto">
|
<Box className="flex-1 overflow-auto">
|
||||||
{schemes.length === 0 ? (
|
{sortedSchemes.length === 0 ? (
|
||||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
||||||
<Box className="mb-4">
|
<Box className="mb-4">
|
||||||
<svg
|
<svg
|
||||||
@@ -181,9 +206,9 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
) : (
|
) : (
|
||||||
<Box className="space-y-2 p-2">
|
<Box className="space-y-2 p-2">
|
||||||
<Typography variant="caption" className="text-gray-500 px-2">
|
<Typography variant="caption" className="text-gray-500 px-2">
|
||||||
共 {schemes.length} 条记录
|
共 {sortedSchemes.length} 条记录
|
||||||
</Typography>
|
</Typography>
|
||||||
{schemes.map((scheme) => (
|
{sortedSchemes.map((scheme) => (
|
||||||
<Card key={scheme.scheme_id} variant="outlined" className="hover:shadow-md transition-shadow">
|
<Card key={scheme.scheme_id} variant="outlined" className="hover:shadow-md transition-shadow">
|
||||||
<CardContent className="p-3 pb-2 last:pb-3">
|
<CardContent className="p-3 pb-2 last:pb-3">
|
||||||
<Box className="flex items-start justify-between gap-2 mb-2">
|
<Box className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { Map as OlMap, VectorTile } from "ol";
|
import { Map as OlMap } from "ol";
|
||||||
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||||
import VectorTileSource from "ol/source/VectorTile";
|
import VectorTileSource from "ol/source/VectorTile";
|
||||||
import { FlatStyleLike } from "ol/style/flat";
|
import { FlatStyleLike } from "ol/style/flat";
|
||||||
|
|
||||||
import { config } from "@/config/config";
|
import { config } from "@/config/config";
|
||||||
|
import {
|
||||||
|
VectorTileStyleSession,
|
||||||
|
versionedPropertyCase,
|
||||||
|
} from "@components/olmap/core/vectorTileStyleSession";
|
||||||
import { getAreaColor } from "./utils";
|
import { getAreaColor } from "./utils";
|
||||||
|
|
||||||
const JUNCTION_LAYER_VALUE = "junctions";
|
const JUNCTION_LAYER_VALUE = "junctions";
|
||||||
@@ -83,40 +87,6 @@ export const applyJunctionAreaRender = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const applyFeatureAreaIndex = (renderFeature: any) => {
|
|
||||||
const featureId = String(renderFeature.get("id") ?? "");
|
|
||||||
const areaIndex = nodeAreaIndexMap.get(featureId);
|
|
||||||
if (areaIndex !== undefined) {
|
|
||||||
renderFeature.properties_[propertyKey] = areaIndex;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sourceTiles = (source as any).sourceTiles_;
|
|
||||||
if (sourceTiles) {
|
|
||||||
Object.values(sourceTiles).forEach((vectorTile: any) => {
|
|
||||||
const renderFeatures = vectorTile.getFeatures();
|
|
||||||
if (!renderFeatures || renderFeatures.length === 0) return;
|
|
||||||
renderFeatures.forEach((renderFeature: any) => {
|
|
||||||
applyFeatureAreaIndex(renderFeature);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const listener = (event: any) => {
|
|
||||||
try {
|
|
||||||
if (!(event.tile instanceof VectorTile)) return;
|
|
||||||
const renderFeatures = event.tile.getFeatures();
|
|
||||||
if (!renderFeatures || renderFeatures.length === 0) return;
|
|
||||||
renderFeatures.forEach((renderFeature: any) => {
|
|
||||||
applyFeatureAreaIndex(renderFeature);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error applying junction area render:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
source.on("tileloadend", listener);
|
|
||||||
|
|
||||||
const fillCases: any[] = [];
|
const fillCases: any[] = [];
|
||||||
areaIds.forEach((areaId, index) => {
|
areaIds.forEach((areaId, index) => {
|
||||||
fillCases.push(
|
fillCases.push(
|
||||||
@@ -131,14 +101,34 @@ export const applyJunctionAreaRender = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
junctionLayer.set(RENDER_OWNER_KEY, ownerId);
|
junctionLayer.set(RENDER_OWNER_KEY, ownerId);
|
||||||
junctionLayer.setStyle({
|
const session = new VectorTileStyleSession({
|
||||||
|
layer: junctionLayer,
|
||||||
|
source,
|
||||||
|
propertyKey,
|
||||||
|
defaultStyle: config.MAP_DEFAULT_STYLE as FlatStyleLike,
|
||||||
|
buildStyle: (statePropertyKey, versionKey, version) =>
|
||||||
|
({
|
||||||
...config.MAP_DEFAULT_STYLE,
|
...config.MAP_DEFAULT_STYLE,
|
||||||
"circle-fill-color": ["case", ...fillCases, defaultFillColor],
|
"circle-fill-color": versionedPropertyCase(
|
||||||
"circle-stroke-color": ["case", ...fillCases, defaultStrokeColor],
|
statePropertyKey,
|
||||||
} as FlatStyleLike);
|
versionKey,
|
||||||
|
version,
|
||||||
|
fillCases,
|
||||||
|
defaultFillColor,
|
||||||
|
),
|
||||||
|
"circle-stroke-color": versionedPropertyCase(
|
||||||
|
statePropertyKey,
|
||||||
|
versionKey,
|
||||||
|
version,
|
||||||
|
fillCases,
|
||||||
|
defaultStrokeColor,
|
||||||
|
),
|
||||||
|
}) as FlatStyleLike,
|
||||||
|
});
|
||||||
|
session.commit(nodeAreaIndexMap);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
source.un("tileloadend", listener);
|
session.dispose();
|
||||||
if (junctionLayer.get(RENDER_OWNER_KEY) === ownerId) {
|
if (junctionLayer.get(RENDER_OWNER_KEY) === ownerId) {
|
||||||
junctionLayer.unset(RENDER_OWNER_KEY, true);
|
junctionLayer.unset(RENDER_OWNER_KEY, true);
|
||||||
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
|
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
properties: {
|
properties: {
|
||||||
name: "FlushingHighlight",
|
name: "FlushingHighlight",
|
||||||
|
queryable: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -108,6 +108,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||||
const setSchemes = onSchemesChange || setInternalSchemes;
|
const setSchemes = onSchemesChange || setInternalSchemes;
|
||||||
|
const sortedSchemes = useMemo(
|
||||||
|
() =>
|
||||||
|
schemes
|
||||||
|
.slice()
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
moment(b.create_time).valueOf() - moment(a.create_time).valueOf(),
|
||||||
|
),
|
||||||
|
[schemes],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@@ -169,6 +179,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
properties: {
|
properties: {
|
||||||
name: "FlushingQueryResultHighlight",
|
name: "FlushingQueryResultHighlight",
|
||||||
|
queryable: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -285,6 +296,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
message: "未找到相关方案",
|
message: "未找到相关方案",
|
||||||
description: "请尝试更改查询条件",
|
description: "请尝试更改查询条件",
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
open?.({
|
||||||
|
type: "success",
|
||||||
|
message: "查询成功",
|
||||||
|
description: `共找到 ${filteredResults.length} 条方案记录`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("查询请求失败:", error);
|
console.error("查询请求失败:", error);
|
||||||
@@ -387,7 +404,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
{/* Results List */}
|
{/* Results List */}
|
||||||
<Box className="flex-1 overflow-auto">
|
<Box className="flex-1 overflow-auto">
|
||||||
{schemes.length === 0 ? (
|
{sortedSchemes.length === 0 ? (
|
||||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
||||||
<Box className="mb-4">
|
<Box className="mb-4">
|
||||||
<svg
|
<svg
|
||||||
@@ -424,9 +441,9 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<Box className="space-y-2 p-2">
|
<Box className="space-y-2 p-2">
|
||||||
<Typography variant="caption" className="text-gray-500 px-2">
|
<Typography variant="caption" className="text-gray-500 px-2">
|
||||||
共 {schemes.length} 条记录
|
共 {sortedSchemes.length} 条记录
|
||||||
</Typography>
|
</Typography>
|
||||||
{schemes.map((scheme) => (
|
{sortedSchemes.map((scheme) => (
|
||||||
<Card
|
<Card
|
||||||
key={scheme.id}
|
key={scheme.id}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||||
|
import type VectorTileSource from "ol/source/VectorTile";
|
||||||
|
import type { FlatStyleLike } from "ol/style/flat";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -30,6 +32,11 @@ import { FiSkipBack, FiSkipForward } from "react-icons/fi";
|
|||||||
import { config, NETWORK_NAME } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
import { useMap } from "@components/olmap/core/MapComponent";
|
import { useMap } from "@components/olmap/core/MapComponent";
|
||||||
|
import { useTimelineTimeConfig } from "@components/olmap/core/Controls/useTimelineTimeConfig";
|
||||||
|
import {
|
||||||
|
VectorTileStyleSession,
|
||||||
|
versionedPropertyCase,
|
||||||
|
} from "@components/olmap/core/vectorTileStyleSession";
|
||||||
import { useHealthRisk } from "./HealthRiskContext";
|
import { useHealthRisk } from "./HealthRiskContext";
|
||||||
import {
|
import {
|
||||||
PredictionResult,
|
PredictionResult,
|
||||||
@@ -38,10 +45,11 @@ import {
|
|||||||
RISK_BREAKS,
|
RISK_BREAKS,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
// 辅助函数:将日期向下取整到最近的15分钟
|
// 辅助函数:将日期向下取整到配置的水力时间步长
|
||||||
const getRoundedDate = (date: Date): Date => {
|
const getRoundedDate = (date: Date, stepMinutes: number): Date => {
|
||||||
|
const safeStep = stepMinutes > 0 ? stepMinutes : 15;
|
||||||
const minutes = date.getHours() * 60 + date.getMinutes();
|
const minutes = date.getHours() * 60 + date.getMinutes();
|
||||||
const roundedMinutes = Math.floor(minutes / 15) * 15;
|
const roundedMinutes = Math.floor(minutes / safeStep) * safeStep;
|
||||||
const roundedDate = new Date(date);
|
const roundedDate = new Date(date);
|
||||||
roundedDate.setHours(
|
roundedDate.setHours(
|
||||||
Math.floor(roundedMinutes / 60),
|
Math.floor(roundedMinutes / 60),
|
||||||
@@ -52,6 +60,50 @@ const getRoundedDate = (date: Date): Date => {
|
|||||||
return roundedDate;
|
return roundedDate;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildHealthRiskStyle = (
|
||||||
|
propertyKey: string,
|
||||||
|
versionKey: string,
|
||||||
|
version: number,
|
||||||
|
): FlatStyleLike => {
|
||||||
|
const colorCases: any[] = [];
|
||||||
|
const widthCases: any[] = [];
|
||||||
|
RISK_BREAKS.forEach((breakValue, index) => {
|
||||||
|
colorCases.push(
|
||||||
|
["<=", ["get", propertyKey], breakValue],
|
||||||
|
RAINBOW_COLORS[index],
|
||||||
|
);
|
||||||
|
widthCases.push(
|
||||||
|
["<=", ["get", propertyKey], breakValue],
|
||||||
|
2 + (1 - index / (RISK_BREAKS.length - 1)) * 4,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
"stroke-color": versionedPropertyCase(
|
||||||
|
propertyKey,
|
||||||
|
versionKey,
|
||||||
|
version,
|
||||||
|
colorCases,
|
||||||
|
"rgba(128, 128, 128, 1)",
|
||||||
|
),
|
||||||
|
"stroke-width": versionedPropertyCase(
|
||||||
|
propertyKey,
|
||||||
|
versionKey,
|
||||||
|
version,
|
||||||
|
widthCases,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSurvivalProbabilityAtYear = (
|
||||||
|
survivalFunction: SurvivalFunction,
|
||||||
|
index: number,
|
||||||
|
) => {
|
||||||
|
const values = survivalFunction.y;
|
||||||
|
if (values.length === 0) return 1;
|
||||||
|
return values[Math.max(0, Math.min(index, values.length - 1))];
|
||||||
|
};
|
||||||
|
|
||||||
interface TimelineProps {
|
interface TimelineProps {
|
||||||
schemeDate?: Date;
|
schemeDate?: Date;
|
||||||
timeRange?: { start: Date; end: Date };
|
timeRange?: { start: Date; end: Date };
|
||||||
@@ -91,9 +143,10 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒
|
const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒
|
||||||
const [isPredicting, setIsPredicting] = useState<boolean>(false);
|
const [isPredicting, setIsPredicting] = useState<boolean>(false);
|
||||||
|
const [sliderPreviewYear, setSliderPreviewYear] = useState<number | null>(null);
|
||||||
|
const { stepMinutes } = useTimelineTimeConfig();
|
||||||
|
|
||||||
// 使用 ref 存储当前的健康数据,供事件监听器读取,避免重复绑定
|
const healthStyleSessionRef = useRef<VectorTileStyleSession | null>(null);
|
||||||
const healthDataRef = useRef<Map<string, number>>(new Map());
|
|
||||||
|
|
||||||
// 计算时间轴范围 (4-73)
|
// 计算时间轴范围 (4-73)
|
||||||
const minTime = 4;
|
const minTime = 4;
|
||||||
@@ -102,9 +155,6 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 添加防抖引用
|
|
||||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
// 时间刻度数组 (4-73,每3个单位一个刻度)
|
// 时间刻度数组 (4-73,每3个单位一个刻度)
|
||||||
const valueMarks = Array.from({ length: 24 }, (_, i) => ({
|
const valueMarks = Array.from({ length: 24 }, (_, i) => ({
|
||||||
value: 4 + i * 3,
|
value: 4 + i * 3,
|
||||||
@@ -126,15 +176,18 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
if (value < minTime || value > maxTime) {
|
if (value < minTime || value > maxTime) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 防抖设置currentYear,避免频繁触发数据获取
|
setSliderPreviewYear(value);
|
||||||
if (debounceRef.current) {
|
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
}
|
|
||||||
debounceRef.current = setTimeout(() => {
|
|
||||||
setCurrentYear(value);
|
|
||||||
}, 500); // 500ms 防抖延迟
|
|
||||||
},
|
},
|
||||||
[minTime, maxTime, setCurrentYear],
|
[minTime, maxTime],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSliderChangeCommitted = useCallback(
|
||||||
|
(_event: Event | React.SyntheticEvent, newValue: number | number[]) => {
|
||||||
|
const value = Array.isArray(newValue) ? newValue[0] : newValue;
|
||||||
|
setSliderPreviewYear(null);
|
||||||
|
setCurrentYear(value);
|
||||||
|
},
|
||||||
|
[setCurrentYear],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 播放控制
|
// 播放控制
|
||||||
@@ -200,11 +253,14 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
}, [minTime, maxTime, setCurrentYear]);
|
}, [minTime, maxTime, setCurrentYear]);
|
||||||
|
|
||||||
// 日期时间选择处理
|
// 日期时间选择处理
|
||||||
const handleDateTimeChange = useCallback((newDate: Date | null) => {
|
const handleDateTimeChange = useCallback(
|
||||||
|
(newDate: Date | null) => {
|
||||||
if (newDate) {
|
if (newDate) {
|
||||||
setSelectedDateTime(getRoundedDate(newDate));
|
setSelectedDateTime(getRoundedDate(newDate, stepMinutes));
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[stepMinutes],
|
||||||
|
);
|
||||||
|
|
||||||
// 播放间隔改变处理
|
// 播放间隔改变处理
|
||||||
const handleIntervalChange = useCallback(
|
const handleIntervalChange = useCallback(
|
||||||
@@ -227,19 +283,16 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
[isPlaying, maxTime, minTime, setCurrentYear],
|
[isPlaying, maxTime, minTime, setCurrentYear],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 组件加载时设置初始时间为当前时间的最近15分钟
|
// 组件加载时设置初始时间为当前时间的配置时间步长
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedDateTime(getRoundedDate(new Date()));
|
setSelectedDateTime(getRoundedDate(new Date(), stepMinutes));
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
}
|
}
|
||||||
if (debounceRef.current) {
|
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, [stepMinutes]);
|
||||||
|
|
||||||
// 获取地图实例
|
// 获取地图实例
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
@@ -255,143 +308,50 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
) ?? null;
|
) ?? null;
|
||||||
}, [map]);
|
}, [map]);
|
||||||
|
|
||||||
// 根据索引从 survival_function 中获取生存概率
|
|
||||||
const getSurvivalProbabilityAtYear = useCallback(
|
|
||||||
(survivalFunc: SurvivalFunction, index: number): number => {
|
|
||||||
const { y } = survivalFunc;
|
|
||||||
if (y.length === 0) return 1;
|
|
||||||
|
|
||||||
// 确保索引在范围内
|
|
||||||
const safeIndex = Math.max(0, Math.min(index, y.length - 1));
|
|
||||||
return y[safeIndex];
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 更新管道图层中的 healthRisk 属性
|
|
||||||
const updatePipeHealthData = useCallback(
|
|
||||||
(healthData: Map<string, number>) => {
|
|
||||||
if (!pipeLayer) return;
|
|
||||||
const source = pipeLayer.getSource() as any;
|
|
||||||
if (!source) return;
|
|
||||||
|
|
||||||
const sourceTiles = source.sourceTiles_;
|
|
||||||
if (!sourceTiles) return;
|
|
||||||
|
|
||||||
Object.values(sourceTiles).forEach((vectorTile: any) => {
|
|
||||||
const renderFeatures = vectorTile.getFeatures();
|
|
||||||
if (!renderFeatures || renderFeatures.length === 0) return;
|
|
||||||
renderFeatures.forEach((renderFeature: any) => {
|
|
||||||
const featureId = renderFeature.get("id");
|
|
||||||
const value = healthData.get(featureId);
|
|
||||||
if (value !== undefined) {
|
|
||||||
renderFeature.properties_["healthRisk"] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[pipeLayer],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 监听瓦片加载,为新瓦片设置 healthRisk 属性
|
|
||||||
// 只在 pipeLayer 变化时绑定一次,通过 ref 获取最新数据
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pipeLayer) return;
|
if (!pipeLayer) return;
|
||||||
const source = pipeLayer.getSource() as any;
|
const source = pipeLayer.getSource() as VectorTileSource | null;
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
|
|
||||||
const listener = (event: any) => {
|
const defaultFlatStyle = config.MAP_DEFAULT_STYLE as FlatStyleLike;
|
||||||
const vectorTile = event.tile;
|
healthStyleSessionRef.current?.dispose();
|
||||||
const renderFeatures = vectorTile.getFeatures();
|
healthStyleSessionRef.current = new VectorTileStyleSession({
|
||||||
if (!renderFeatures || renderFeatures.length === 0) return;
|
layer: pipeLayer,
|
||||||
|
source,
|
||||||
const healthData = healthDataRef.current;
|
propertyKey: "healthRisk",
|
||||||
renderFeatures.forEach((renderFeature: any) => {
|
defaultStyle: defaultFlatStyle,
|
||||||
const featureId = renderFeature.get("id");
|
buildStyle: buildHealthRiskStyle,
|
||||||
const value = healthData.get(featureId);
|
map,
|
||||||
if (value !== undefined) {
|
buffered: true,
|
||||||
renderFeature.properties_["healthRisk"] = value;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
source.on("tileloadend", listener);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
source.un("tileloadend", listener);
|
healthStyleSessionRef.current?.dispose();
|
||||||
|
healthStyleSessionRef.current = null;
|
||||||
|
pipeLayer.setStyle(defaultFlatStyle);
|
||||||
};
|
};
|
||||||
}, [pipeLayer]);
|
}, [map, pipeLayer]);
|
||||||
|
|
||||||
// 应用样式到管道图层
|
useEffect(() => {
|
||||||
const applyPipeHealthStyle = useCallback(() => {
|
const session = healthStyleSessionRef.current;
|
||||||
if (!pipeLayer || predictionResults.length === 0) {
|
if (!session) return;
|
||||||
|
if (predictionResults.length === 0) {
|
||||||
|
session.reset();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为每条管道计算当前年份的生存概率
|
|
||||||
const pipeHealthData = new Map<string, number>();
|
const pipeHealthData = new Map<string, number>();
|
||||||
predictionResults.forEach((result) => {
|
predictionResults.forEach((result) => {
|
||||||
const probability = getSurvivalProbabilityAtYear(
|
const probability = getSurvivalProbabilityAtYear(
|
||||||
result.survival_function,
|
result.survival_function,
|
||||||
currentYear - 4, // 使用索引 (0-based)
|
currentYear - 4,
|
||||||
);
|
);
|
||||||
pipeHealthData.set(result.link_id, probability);
|
pipeHealthData.set(result.link_id, probability);
|
||||||
});
|
});
|
||||||
|
session.commit(pipeHealthData);
|
||||||
|
}, [currentYear, pipeLayer, predictionResults]);
|
||||||
|
|
||||||
// 更新 ref 数据
|
// 缩放期间暂停时间轴,避免视图变化与自动播放同时推进。
|
||||||
healthDataRef.current = pipeHealthData;
|
|
||||||
|
|
||||||
// 更新图层数据
|
|
||||||
updatePipeHealthData(pipeHealthData);
|
|
||||||
|
|
||||||
// 获取所有概率值用于分类
|
|
||||||
const probabilities = Array.from(pipeHealthData.values());
|
|
||||||
if (probabilities.length === 0) return;
|
|
||||||
|
|
||||||
// 使用等距分段,从0-1分为十类
|
|
||||||
const breaks = RISK_BREAKS;
|
|
||||||
|
|
||||||
// 生成彩虹色(从紫色到红色,低生存概率=高风险=红色)
|
|
||||||
const colors = RAINBOW_COLORS;
|
|
||||||
|
|
||||||
// 构建 WebGL 样式表达式
|
|
||||||
const colorCases: any[] = [];
|
|
||||||
const widthCases: any[] = [];
|
|
||||||
|
|
||||||
breaks.forEach((breakValue, index) => {
|
|
||||||
const colorStr = colors[index];
|
|
||||||
// 线宽根据健康风险调整:低生存概率(高风险)用粗线
|
|
||||||
const width = 2 + (1 - index / (breaks.length - 1)) * 4;
|
|
||||||
|
|
||||||
colorCases.push(["<=", ["get", "healthRisk"], breakValue], colorStr);
|
|
||||||
widthCases.push(["<=", ["get", "healthRisk"], breakValue], width);
|
|
||||||
});
|
|
||||||
// console.log(
|
|
||||||
// `应用健康风险样式,年份: ${currentYear}, 分段: ${breaks.length}`,
|
|
||||||
// );
|
|
||||||
// console.log("颜色表达式:", colorCases);
|
|
||||||
// console.log("宽度表达式:", widthCases);
|
|
||||||
// 应用样式到图层
|
|
||||||
pipeLayer.setStyle({
|
|
||||||
"stroke-color": ["case", ...colorCases, "rgba(128, 128, 128, 1)"],
|
|
||||||
"stroke-width": ["case", ...widthCases, 2],
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
pipeLayer,
|
|
||||||
predictionResults,
|
|
||||||
currentYear,
|
|
||||||
getSurvivalProbabilityAtYear,
|
|
||||||
updatePipeHealthData,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 监听依赖变化,更新样式
|
|
||||||
useEffect(() => {
|
|
||||||
if (predictionResults.length > 0 && pipeLayer) {
|
|
||||||
applyPipeHealthStyle();
|
|
||||||
}
|
|
||||||
}, [applyPipeHealthStyle, pipeLayer, predictionResults.length]);
|
|
||||||
|
|
||||||
// 这里防止地图缩放时,瓦片重新加载引起的属性更新出错
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 监听地图缩放事件,缩放时停止播放
|
// 监听地图缩放事件,缩放时停止播放
|
||||||
if (map) {
|
if (map) {
|
||||||
@@ -513,7 +473,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
}
|
}
|
||||||
format="YYYY-MM-DD HH:mm"
|
format="YYYY-MM-DD HH:mm"
|
||||||
views={["year", "month", "day", "hours", "minutes"]}
|
views={["year", "month", "day", "hours", "minutes"]}
|
||||||
minutesStep={15}
|
minutesStep={stepMinutes}
|
||||||
sx={{ width: 200 }}
|
sx={{ width: 200 }}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
textField: {
|
textField: {
|
||||||
@@ -634,12 +594,13 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
|
|
||||||
<Box ref={timelineRef} sx={{ px: 2, position: "relative" }}>
|
<Box ref={timelineRef} sx={{ px: 2, position: "relative" }}>
|
||||||
<Slider
|
<Slider
|
||||||
value={currentYear}
|
value={sliderPreviewYear ?? currentYear}
|
||||||
min={minTime}
|
min={minTime}
|
||||||
max={maxTime} // 4-73的范围
|
max={maxTime} // 4-73的范围
|
||||||
step={1} // 每1个单位一个步进
|
step={1} // 每1个单位一个步进
|
||||||
marks={valueMarks} // 显示刻度
|
marks={valueMarks} // 显示刻度
|
||||||
onChange={handleSliderChange}
|
onChange={handleSliderChange}
|
||||||
|
onChangeCommitted={handleSliderChangeCommitted}
|
||||||
valueLabelDisplay="auto"
|
valueLabelDisplay="auto"
|
||||||
sx={{
|
sx={{
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -107,6 +107,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
const schemes =
|
const schemes =
|
||||||
externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||||
const setSchemes = onSchemesChange || setInternalSchemes;
|
const setSchemes = onSchemesChange || setInternalSchemes;
|
||||||
|
const sortedSchemes = useMemo(
|
||||||
|
() =>
|
||||||
|
schemes
|
||||||
|
.slice()
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
moment(b.create_time).valueOf() - moment(a.create_time).valueOf(),
|
||||||
|
),
|
||||||
|
[schemes],
|
||||||
|
);
|
||||||
|
|
||||||
// 格式化简短日期
|
// 格式化简短日期
|
||||||
const formatShortDate = (timeStr: string) => {
|
const formatShortDate = (timeStr: string) => {
|
||||||
@@ -133,6 +143,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
properties: {
|
properties: {
|
||||||
name: "传感器高亮",
|
name: "传感器高亮",
|
||||||
value: "sensor_highlight",
|
value: "sensor_highlight",
|
||||||
|
queryable: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,6 +214,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
? "没有找到任何方案"
|
? "没有找到任何方案"
|
||||||
: `${queryDate?.format("YYYY-MM-DD")} 没有找到相关方案`,
|
: `${queryDate?.format("YYYY-MM-DD")} 没有找到相关方案`,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
open?.({
|
||||||
|
type: "success",
|
||||||
|
message: "查询成功",
|
||||||
|
description: `共找到 ${filteredResults.length} 条方案记录`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("查询请求失败:", error);
|
console.error("查询请求失败:", error);
|
||||||
@@ -313,7 +330,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
{/* 结果列表 */}
|
{/* 结果列表 */}
|
||||||
<Box className="flex-1 overflow-auto">
|
<Box className="flex-1 overflow-auto">
|
||||||
{schemes.length === 0 ? (
|
{sortedSchemes.length === 0 ? (
|
||||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
||||||
<Box className="mb-4">
|
<Box className="mb-4">
|
||||||
<svg
|
<svg
|
||||||
@@ -350,9 +367,9 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<Box className="space-y-2 p-2">
|
<Box className="space-y-2 p-2">
|
||||||
<Typography variant="caption" className="text-gray-500 px-2">
|
<Typography variant="caption" className="text-gray-500 px-2">
|
||||||
共 {schemes.length} 条记录
|
共 {sortedSchemes.length} 条记录
|
||||||
</Typography>
|
</Typography>
|
||||||
{schemes.map((scheme) => (
|
{sortedSchemes.map((scheme) => (
|
||||||
<Card
|
<Card
|
||||||
key={scheme.id}
|
key={scheme.id}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@@ -557,7 +557,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "数据清洗失败",
|
message: "数据清洗失败",
|
||||||
description: err.response?.data?.message || err.message || "未知错误",
|
description:
|
||||||
|
err.response?.data?.detail ||
|
||||||
|
err.response?.data?.message ||
|
||||||
|
err.message ||
|
||||||
|
"未知错误",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsCleaning(false);
|
setIsCleaning(false);
|
||||||
|
|||||||
@@ -645,7 +645,11 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "数据清洗失败",
|
message: "数据清洗失败",
|
||||||
description: err.response?.data?.message || err.message || "未知错误",
|
description:
|
||||||
|
err.response?.data?.detail ||
|
||||||
|
err.response?.data?.message ||
|
||||||
|
err.message ||
|
||||||
|
"未知错误",
|
||||||
});
|
});
|
||||||
setIsCleaning(false);
|
setIsCleaning(false);
|
||||||
}
|
}
|
||||||
@@ -708,6 +712,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
properties: {
|
properties: {
|
||||||
name: "SCADA 选中高亮",
|
name: "SCADA 选中高亮",
|
||||||
value: "scada_selected_highlight",
|
value: "scada_selected_highlight",
|
||||||
|
queryable: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -813,8 +818,12 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
variant="persistent"
|
variant="persistent"
|
||||||
hideBackdrop
|
hideBackdrop
|
||||||
sx={{
|
sx={{
|
||||||
width: isExpanded ? 360 : 0,
|
position: "absolute",
|
||||||
flexShrink: 0,
|
inset: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
pointerEvents: "none",
|
||||||
"& .MuiDrawer-paper": {
|
"& .MuiDrawer-paper": {
|
||||||
width: 360,
|
width: 360,
|
||||||
height: "860px",
|
height: "860px",
|
||||||
@@ -827,8 +836,9 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
|
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
|
||||||
backdropFilter: "blur(8px)",
|
backdropFilter: "blur(8px)",
|
||||||
opacity: 0.95,
|
opacity: 0.95,
|
||||||
transition: "all 0.3s ease-in-out",
|
transition: "transform 0.3s ease-in-out, opacity 0.3s ease-in-out",
|
||||||
border: "none",
|
border: "none",
|
||||||
|
pointerEvents: "auto",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
jest.mock("../MapComponent", () => ({
|
||||||
|
useData: jest.fn(),
|
||||||
|
useMap: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock("../mapLifecycle", () => ({
|
||||||
|
markMapResourcePersistent: <T,>(resource: T) => resource,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/source/XYZ.js", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class MockXyzSource {
|
||||||
|
constructor(readonly options: unknown) {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock("ol/layer/Tile.js", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class MockTileLayer {
|
||||||
|
private readonly source: unknown;
|
||||||
|
constructor(options: any) {
|
||||||
|
this.source = options.source;
|
||||||
|
}
|
||||||
|
getSource() { return this.source; }
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock("ol/layer/Group", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class MockGroup {
|
||||||
|
private readonly layers: unknown[];
|
||||||
|
constructor(options: any) {
|
||||||
|
this.layers = options.layers;
|
||||||
|
}
|
||||||
|
getLayers() { return { getArray: () => this.layers }; }
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createBaseLayerEntries,
|
||||||
|
createBaseLayerSources,
|
||||||
|
} from "./BaseLayers";
|
||||||
|
|
||||||
|
const getLeafSources = (layer: any): unknown[] => {
|
||||||
|
const childLayers = layer.getLayers?.().getArray?.();
|
||||||
|
if (Array.isArray(childLayers)) {
|
||||||
|
return childLayers.flatMap(getLeafSources);
|
||||||
|
}
|
||||||
|
return [layer.getSource?.()];
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("base layer resources", () => {
|
||||||
|
it("creates independent layers backed by one shared source pool", () => {
|
||||||
|
const sources = createBaseLayerSources();
|
||||||
|
const primary = createBaseLayerEntries(sources);
|
||||||
|
const compare = createBaseLayerEntries(sources);
|
||||||
|
|
||||||
|
expect(primary).toHaveLength(compare.length);
|
||||||
|
primary.forEach((entry, index) => {
|
||||||
|
expect(entry.layer).not.toBe(compare[index].layer);
|
||||||
|
expect(getLeafSources(entry.layer)).toEqual(
|
||||||
|
getLeafSources(compare[index].layer),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,91 +30,92 @@ const BASE_LAYER_METADATA = [
|
|||||||
{ id: "tianditu-image", name: "天地图影像", img: mapboxSatellite.src },
|
{ id: "tianditu-image", name: "天地图影像", img: mapboxSatellite.src },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const createTileLayer = (url: string, attributions: string) =>
|
const createTileSource = (url: string, attributions: string) =>
|
||||||
new TileLayer({
|
new XYZ({
|
||||||
source: new XYZ({
|
|
||||||
url,
|
url,
|
||||||
tileSize: 512,
|
tileSize: 512,
|
||||||
maxZoom: 20,
|
maxZoom: 20,
|
||||||
projection: "EPSG:3857",
|
projection: "EPSG:3857",
|
||||||
attributions,
|
attributions,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const createBaseLayerEntries = () => {
|
export const createBaseLayerSources = () => ({
|
||||||
const streetsLayer = createTileLayer(
|
streets: createTileSource(
|
||||||
`https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
`https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
||||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
|
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
||||||
);
|
),
|
||||||
const lightMapLayer = createTileLayer(
|
light: createTileSource(
|
||||||
`https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
`https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
||||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
|
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
||||||
);
|
),
|
||||||
const satelliteLayer = createTileLayer(
|
satellite: createTileSource(
|
||||||
`https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
`https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
||||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
|
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
||||||
);
|
),
|
||||||
const satelliteStreetsLayer = createTileLayer(
|
satelliteStreets: createTileSource(
|
||||||
`https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
`https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
||||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
|
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
||||||
);
|
),
|
||||||
|
tiandituVector: new XYZ({
|
||||||
const tiandituVectorLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
||||||
projection: "EPSG:3857",
|
projection: "EPSG:3857",
|
||||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||||
}),
|
}),
|
||||||
});
|
tiandituVectorAnnotation: new XYZ({
|
||||||
const tiandituVectorAnnotationLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
||||||
projection: "EPSG:3857",
|
projection: "EPSG:3857",
|
||||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||||
}),
|
}),
|
||||||
});
|
tiandituImage: new XYZ({
|
||||||
const tiandituImageLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
||||||
projection: "EPSG:3857",
|
projection: "EPSG:3857",
|
||||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||||
}),
|
}),
|
||||||
});
|
tiandituImageAnnotation: new XYZ({
|
||||||
const tiandituImageAnnotationLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
||||||
projection: "EPSG:3857",
|
projection: "EPSG:3857",
|
||||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type BaseLayerSources = ReturnType<typeof createBaseLayerSources>;
|
||||||
|
|
||||||
|
export const createBaseLayerEntries = (sources: BaseLayerSources) => {
|
||||||
|
const tileLayer = (source: XYZ) => new TileLayer({ source });
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...BASE_LAYER_METADATA[0],
|
...BASE_LAYER_METADATA[0],
|
||||||
layer: lightMapLayer,
|
layer: tileLayer(sources.light),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...BASE_LAYER_METADATA[1],
|
...BASE_LAYER_METADATA[1],
|
||||||
layer: satelliteLayer,
|
layer: tileLayer(sources.satellite),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...BASE_LAYER_METADATA[2],
|
...BASE_LAYER_METADATA[2],
|
||||||
layer: satelliteStreetsLayer,
|
layer: tileLayer(sources.satelliteStreets),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...BASE_LAYER_METADATA[3],
|
...BASE_LAYER_METADATA[3],
|
||||||
layer: streetsLayer,
|
layer: tileLayer(sources.streets),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...BASE_LAYER_METADATA[4],
|
...BASE_LAYER_METADATA[4],
|
||||||
layer: new Group({
|
layer: new Group({
|
||||||
layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer],
|
layers: [
|
||||||
|
tileLayer(sources.tiandituVector),
|
||||||
|
tileLayer(sources.tiandituVectorAnnotation),
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...BASE_LAYER_METADATA[5],
|
...BASE_LAYER_METADATA[5],
|
||||||
layer: new Group({
|
layer: new Group({
|
||||||
layers: [tiandituImageLayer, tiandituImageAnnotationLayer],
|
layers: [
|
||||||
|
tileLayer(sources.tiandituImage),
|
||||||
|
tileLayer(sources.tiandituImageAnnotation),
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
].map((entry) => ({
|
].map((entry) => ({
|
||||||
@@ -130,6 +131,7 @@ const BaseLayers: React.FC = () => {
|
|||||||
if (data?.maps?.length) return data.maps;
|
if (data?.maps?.length) return data.maps;
|
||||||
return map ? [map] : [];
|
return map ? [map] : [];
|
||||||
}, [data?.maps, map]);
|
}, [data?.maps, map]);
|
||||||
|
const sharedSources = useMemo(() => createBaseLayerSources(), []);
|
||||||
const layerSetsRef = useRef(new WeakMap<OlMap, ReturnType<typeof createBaseLayerEntries>>());
|
const layerSetsRef = useRef(new WeakMap<OlMap, ReturnType<typeof createBaseLayerEntries>>());
|
||||||
const [isShow, setShow] = useState(false);
|
const [isShow, setShow] = useState(false);
|
||||||
const [isExpanded, setExpanded] = useState(false);
|
const [isExpanded, setExpanded] = useState(false);
|
||||||
@@ -139,7 +141,7 @@ const BaseLayers: React.FC = () => {
|
|||||||
maps.forEach((targetMap) => {
|
maps.forEach((targetMap) => {
|
||||||
let layerEntries = layerSetsRef.current.get(targetMap);
|
let layerEntries = layerSetsRef.current.get(targetMap);
|
||||||
if (!layerEntries) {
|
if (!layerEntries) {
|
||||||
layerEntries = createBaseLayerEntries();
|
layerEntries = createBaseLayerEntries(sharedSources);
|
||||||
layerSetsRef.current.set(targetMap, layerEntries);
|
layerSetsRef.current.set(targetMap, layerEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +153,7 @@ const BaseLayers: React.FC = () => {
|
|||||||
layerInfo.layer.setVisible(layerInfo.id === activeId);
|
layerInfo.layer.setVisible(layerInfo.id === activeId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [activeId, maps]);
|
}, [activeId, maps, sharedSources]);
|
||||||
|
|
||||||
const changeMapLayers = (id: string) => {
|
const changeMapLayers = (id: string) => {
|
||||||
maps.forEach((targetMap) => {
|
maps.forEach((targetMap) => {
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useRef } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import Draggable from "react-draggable";
|
import Draggable from "react-draggable";
|
||||||
import { Close } from "@mui/icons-material";
|
import { Close } from "@mui/icons-material";
|
||||||
import { IconButton, Tooltip } from "@mui/material";
|
import {
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
IconButton,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { SelectChangeEvent } from "@mui/material/Select";
|
||||||
|
|
||||||
interface BaseProperty {
|
interface BaseProperty {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -20,7 +30,29 @@ interface TableProperty {
|
|||||||
rows: (string | number)[][]; // 每行的数据
|
rows: (string | number)[][]; // 每行的数据
|
||||||
}
|
}
|
||||||
|
|
||||||
type PropertyItem = BaseProperty | TableProperty;
|
interface SelectProperty {
|
||||||
|
type: "select";
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
options: { label: string; value: string }[];
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
saving?: boolean;
|
||||||
|
onSave: (value: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextProperty {
|
||||||
|
type: "text";
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
saving?: boolean;
|
||||||
|
helperText?: string;
|
||||||
|
onSave: (value: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PropertyItem = BaseProperty | TableProperty | SelectProperty | TextProperty;
|
||||||
|
|
||||||
interface PropertyPanelProps {
|
interface PropertyPanelProps {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -36,6 +68,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const draggableRef = useRef<HTMLDivElement>(null);
|
const draggableRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [draftValues, setDraftValues] = useState<Record<string, string>>({});
|
||||||
const headerActionSx = {
|
const headerActionSx = {
|
||||||
color: "common.white",
|
color: "common.white",
|
||||||
backgroundColor: "rgba(255,255,255,0.08)",
|
backgroundColor: "rgba(255,255,255,0.08)",
|
||||||
@@ -56,6 +89,26 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|||||||
|
|
||||||
const isImportantKeys = ["ID", "类型", "Name", "面积", "长度"];
|
const isImportantKeys = ["ID", "类型", "Name", "面积", "长度"];
|
||||||
|
|
||||||
|
const handleSelectChange = (
|
||||||
|
key: string,
|
||||||
|
event: SelectChangeEvent,
|
||||||
|
) => {
|
||||||
|
setDraftValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: event.target.value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTextChange = (
|
||||||
|
key: string,
|
||||||
|
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
|
) => {
|
||||||
|
setDraftValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: event.target.value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
// 统计属性数量(表格型按行数计入)
|
// 统计属性数量(表格型按行数计入)
|
||||||
const totalProps = id
|
const totalProps = id
|
||||||
? 2 +
|
? 2 +
|
||||||
@@ -200,6 +253,151 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ("type" in property && property.type === "select") {
|
||||||
|
const selected = property as SelectProperty;
|
||||||
|
const draftKey = `${id ?? "unknown"}:${selected.label}`;
|
||||||
|
const draftValue = draftValues[draftKey] ?? selected.value ?? "";
|
||||||
|
const hasChanged = draftValue !== selected.value;
|
||||||
|
const getOptionLabel = (value: string) =>
|
||||||
|
selected.options.find((option) => option.value === value)
|
||||||
|
?.label ?? value;
|
||||||
|
const canSave =
|
||||||
|
hasChanged &&
|
||||||
|
draftValue !== "" &&
|
||||||
|
!selected.disabled &&
|
||||||
|
!selected.saving;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`select-${index}`}
|
||||||
|
className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="font-medium text-xs uppercase tracking-wide text-gray-600">
|
||||||
|
{selected.label}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center justify-end gap-2 flex-1">
|
||||||
|
<FormControl size="small" sx={{ minWidth: 118 }}>
|
||||||
|
<Select
|
||||||
|
value={draftValue}
|
||||||
|
displayEmpty
|
||||||
|
disabled={selected.disabled || selected.saving}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleSelectChange(draftKey, event)
|
||||||
|
}
|
||||||
|
renderValue={(value) =>
|
||||||
|
value
|
||||||
|
? getOptionLabel(String(value))
|
||||||
|
: selected.placeholder || "-"
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
height: 32,
|
||||||
|
fontSize: 13,
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="" disabled>
|
||||||
|
{selected.placeholder || "未设置"}
|
||||||
|
</MenuItem>
|
||||||
|
{selected.options.map((option) => (
|
||||||
|
<MenuItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
disabled={!canSave}
|
||||||
|
onClick={() => selected.onSave(draftValue)}
|
||||||
|
sx={{
|
||||||
|
minWidth: 58,
|
||||||
|
height: 32,
|
||||||
|
fontSize: 13,
|
||||||
|
boxShadow: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected.saving ? (
|
||||||
|
<CircularProgress size={16} color="inherit" />
|
||||||
|
) : (
|
||||||
|
"保存"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("type" in property && property.type === "text") {
|
||||||
|
const editable = property as TextProperty;
|
||||||
|
const draftKey = `${id ?? "unknown"}:${editable.label}`;
|
||||||
|
const draftValue = draftValues[draftKey] ?? editable.value ?? "";
|
||||||
|
const hasChanged = draftValue !== editable.value;
|
||||||
|
const canSave =
|
||||||
|
hasChanged &&
|
||||||
|
!editable.disabled &&
|
||||||
|
!editable.saving;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`text-${index}`}
|
||||||
|
className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<span className="font-medium text-xs uppercase tracking-wide text-gray-600 pt-2">
|
||||||
|
{editable.label}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col items-end gap-1 flex-1">
|
||||||
|
<div className="flex items-center justify-end gap-2 w-full">
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={draftValue}
|
||||||
|
placeholder={editable.placeholder}
|
||||||
|
disabled={editable.disabled || editable.saving}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleTextChange(draftKey, event)
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
width: 118,
|
||||||
|
"& .MuiInputBase-input": {
|
||||||
|
height: 15,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
disabled={!canSave}
|
||||||
|
onClick={() => editable.onSave(draftValue)}
|
||||||
|
sx={{
|
||||||
|
minWidth: 58,
|
||||||
|
height: 32,
|
||||||
|
fontSize: 13,
|
||||||
|
boxShadow: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editable.saving ? (
|
||||||
|
<CircularProgress size={16} color="inherit" />
|
||||||
|
) : (
|
||||||
|
"保存"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{editable.helperText && (
|
||||||
|
<span className="text-xs text-gray-500 text-right">
|
||||||
|
{editable.helperText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 普通属性
|
// 普通属性
|
||||||
const base = property as BaseProperty;
|
const base = property as BaseProperty;
|
||||||
const isImportant = isImportantKeys.includes(base.label);
|
const isImportant = isImportantKeys.includes(base.label);
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import ApplyIcon from "@mui/icons-material/Check";
|
import ApplyIcon from "@mui/icons-material/Check";
|
||||||
import ColorLensIcon from "@mui/icons-material/ColorLens";
|
import PaletteIcon from "@mui/icons-material/PaletteOutlined";
|
||||||
import ResetIcon from "@mui/icons-material/Refresh";
|
import ResetIcon from "@mui/icons-material/Refresh";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
|
IconButton,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
Slider,
|
Slider,
|
||||||
TextField,
|
TextField,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
@@ -23,14 +27,74 @@ import {
|
|||||||
RAINBOW_PALETTES,
|
RAINBOW_PALETTES,
|
||||||
SINGLE_COLOR_PALETTES,
|
SINGLE_COLOR_PALETTES,
|
||||||
} from "./styleEditorPresets";
|
} from "./styleEditorPresets";
|
||||||
import { StyleEditorFormProps } from "./styleEditorTypes";
|
import type {
|
||||||
|
ClassificationMethod,
|
||||||
|
ColorType,
|
||||||
|
StyleEditorFormProps,
|
||||||
|
} from "./styleEditorTypes";
|
||||||
import {
|
import {
|
||||||
getSizePreviewColors,
|
getDefaultCustomColors,
|
||||||
hexToRgba,
|
hexToRgba,
|
||||||
resolveStyleColors,
|
resolveStyleColors,
|
||||||
rgbaToHex,
|
rgbaToHex,
|
||||||
} from "./styleEditorUtils";
|
} from "./styleEditorUtils";
|
||||||
|
|
||||||
|
const sectionSx = { px: 2, py: 1.5 } as const;
|
||||||
|
const sliderSx = { mx: 1, width: "calc(100% - 16px)" } as const;
|
||||||
|
|
||||||
|
type PalettePreview = { name: string; colors: string[]; index: number };
|
||||||
|
|
||||||
|
const getPalettePreviews = (colorType: ColorType): PalettePreview[] => {
|
||||||
|
switch (colorType) {
|
||||||
|
case "single":
|
||||||
|
return SINGLE_COLOR_PALETTES.map((palette, index) => ({
|
||||||
|
name: `单色 ${index + 1}`,
|
||||||
|
colors: [palette.color],
|
||||||
|
index,
|
||||||
|
}));
|
||||||
|
case "gradient":
|
||||||
|
return GRADIENT_PALETTES.map((palette, index) => ({
|
||||||
|
name: palette.name,
|
||||||
|
colors: [palette.start, palette.end],
|
||||||
|
index,
|
||||||
|
}));
|
||||||
|
case "rainbow":
|
||||||
|
return RAINBOW_PALETTES.map((palette, index) => ({
|
||||||
|
name: palette.name,
|
||||||
|
colors: palette.colors,
|
||||||
|
index,
|
||||||
|
}));
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectedPaletteIndex = (styleConfig: StyleEditorFormProps["styleConfig"]) => {
|
||||||
|
switch (styleConfig.colorType) {
|
||||||
|
case "single":
|
||||||
|
return styleConfig.singlePaletteIndex;
|
||||||
|
case "gradient":
|
||||||
|
return styleConfig.gradientPaletteIndex;
|
||||||
|
case "rainbow":
|
||||||
|
return styleConfig.rainbowPaletteIndex;
|
||||||
|
default:
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectPalette = (colorType: ColorType, index: number) => {
|
||||||
|
switch (colorType) {
|
||||||
|
case "single":
|
||||||
|
return { singlePaletteIndex: index };
|
||||||
|
case "gradient":
|
||||||
|
return { gradientPaletteIndex: index };
|
||||||
|
case "rainbow":
|
||||||
|
return { rainbowPaletteIndex: index };
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const StyleEditorForm: React.FC<StyleEditorFormProps> = ({
|
const StyleEditorForm: React.FC<StyleEditorFormProps> = ({
|
||||||
renderLayers,
|
renderLayers,
|
||||||
selectedRenderLayer,
|
selectedRenderLayer,
|
||||||
@@ -42,417 +106,173 @@ const StyleEditorForm: React.FC<StyleEditorFormProps> = ({
|
|||||||
onClassificationMethodChange,
|
onClassificationMethodChange,
|
||||||
onSegmentsChange,
|
onSegmentsChange,
|
||||||
onCustomBreakChange,
|
onCustomBreakChange,
|
||||||
onCustomBreakBlur,
|
|
||||||
onColorTypeChange,
|
onColorTypeChange,
|
||||||
onApply,
|
onApply,
|
||||||
onReset,
|
onReset,
|
||||||
|
validationErrors,
|
||||||
|
isDirty,
|
||||||
|
isApplying,
|
||||||
}) => {
|
}) => {
|
||||||
const renderColorSetting = () => {
|
const layerType = selectedRenderLayer?.get("type");
|
||||||
if (styleConfig.colorType === "single") {
|
const previewColors = resolveStyleColors(styleConfig);
|
||||||
return (
|
const selectedProperty = availableProperties.some(
|
||||||
<FormControl variant="standard" fullWidth margin="dense" className="mt-3">
|
(property) => property.value === styleConfig.property,
|
||||||
<InputLabel>单一色方案</InputLabel>
|
)
|
||||||
<Select
|
? styleConfig.property
|
||||||
value={styleConfig.singlePaletteIndex}
|
: "";
|
||||||
onChange={(e) =>
|
|
||||||
setStyleConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
singlePaletteIndex: Number(e.target.value),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{SINGLE_COLOR_PALETTES.map((palette, index) => (
|
|
||||||
<MenuItem key={index} value={index}>
|
|
||||||
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: "80%",
|
|
||||||
height: 16,
|
|
||||||
borderRadius: 2,
|
|
||||||
background: palette.color,
|
|
||||||
marginRight: 1,
|
|
||||||
border: "1px solid #ccc",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (styleConfig.colorType === "gradient") {
|
const setCustomColor = (index: number, hex: string) => {
|
||||||
return (
|
setStyleConfig((previous) => {
|
||||||
<FormControl variant="standard" fullWidth margin="dense" className="mt-3">
|
const customColors = getDefaultCustomColors(
|
||||||
<InputLabel>渐进色方案</InputLabel>
|
previous.segments,
|
||||||
<Select
|
previous.customColors,
|
||||||
value={styleConfig.gradientPaletteIndex}
|
|
||||||
onChange={(e) =>
|
|
||||||
setStyleConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
gradientPaletteIndex: Number(e.target.value),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{GRADIENT_PALETTES.map((palette, index) => {
|
|
||||||
const previewColors = resolveStyleColors(
|
|
||||||
{ ...styleConfig, colorType: "gradient", gradientPaletteIndex: index },
|
|
||||||
styleConfig.segments + 1
|
|
||||||
);
|
);
|
||||||
return (
|
customColors[index] = hexToRgba(hex);
|
||||||
<MenuItem key={index} value={index}>
|
return { ...previous, customColors };
|
||||||
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: "80%",
|
|
||||||
height: 16,
|
|
||||||
borderRadius: 2,
|
|
||||||
display: "flex",
|
|
||||||
overflow: "hidden",
|
|
||||||
marginRight: 1,
|
|
||||||
border: "1px solid #ccc",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{previewColors.map((color, colorIndex) => (
|
|
||||||
<Box
|
|
||||||
key={colorIndex}
|
|
||||||
sx={{ flex: 1, backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (styleConfig.colorType === "rainbow") {
|
|
||||||
return (
|
|
||||||
<FormControl variant="standard" fullWidth margin="dense" className="mt-3">
|
|
||||||
<InputLabel>离散彩虹方案</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={styleConfig.rainbowPaletteIndex}
|
|
||||||
onChange={(e) =>
|
|
||||||
setStyleConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
rainbowPaletteIndex: Number(e.target.value),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{RAINBOW_PALETTES.map((palette, index) => {
|
|
||||||
const previewColors = Array.from(
|
|
||||||
{ length: styleConfig.segments + 1 },
|
|
||||||
(_, colorIndex) => palette.colors[colorIndex % palette.colors.length]
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<MenuItem key={index} value={index}>
|
|
||||||
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
|
|
||||||
<Typography sx={{ marginRight: 1 }}>{palette.name}</Typography>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: "60%",
|
|
||||||
height: 16,
|
|
||||||
borderRadius: 2,
|
|
||||||
display: "flex",
|
|
||||||
border: "1px solid #ccc",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{previewColors.map((color, colorIndex) => (
|
|
||||||
<Box
|
|
||||||
key={colorIndex}
|
|
||||||
sx={{ flex: 1, backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (styleConfig.colorType === "custom") {
|
|
||||||
return (
|
|
||||||
<Box className="mt-3">
|
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
|
||||||
自定义颜色
|
|
||||||
</Typography>
|
|
||||||
<Box
|
|
||||||
className="flex flex-col gap-2"
|
|
||||||
sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "4px" }}
|
|
||||||
>
|
|
||||||
{Array.from({ length: styleConfig.segments }).map((_, index) => {
|
|
||||||
const color = styleConfig.customColors?.[index] || "rgba(0,0,0,1)";
|
|
||||||
return (
|
|
||||||
<Box key={index} className="flex items-center gap-2">
|
|
||||||
<Typography variant="caption" sx={{ width: 40 }}>
|
|
||||||
分段{index + 1}
|
|
||||||
</Typography>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={rgbaToHex(color)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const nextColor = hexToRgba(e.target.value);
|
|
||||||
setStyleConfig((prev) => {
|
|
||||||
const nextColors = [...(prev.customColors || [])];
|
|
||||||
while (nextColors.length < prev.segments) {
|
|
||||||
nextColors.push("rgba(0,0,0,1)");
|
|
||||||
}
|
|
||||||
nextColors[index] = nextColor;
|
|
||||||
return { ...prev, customColors: nextColors };
|
|
||||||
});
|
});
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "32px",
|
|
||||||
cursor: "pointer",
|
|
||||||
border: "1px solid #ccc",
|
|
||||||
borderRadius: "4px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderSizeSetting = () => {
|
const renderPalette = () => {
|
||||||
const previewColors = getSizePreviewColors(styleConfig);
|
const palettes = getPalettePreviews(styleConfig.colorType);
|
||||||
|
if (palettes.length === 0) return null;
|
||||||
|
const selectedIndex = getSelectedPaletteIndex(styleConfig);
|
||||||
|
|
||||||
if (selectedRenderLayer?.get("type") === "point") {
|
|
||||||
return (
|
return (
|
||||||
<Box className="mt-3">
|
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 1 }}>
|
||||||
<Typography gutterBottom>
|
{palettes.map((palette) => (
|
||||||
点大小范围: {styleConfig.minSize} - {styleConfig.maxSize} 像素
|
<Tooltip key={palette.name} title={palette.name} placement="top">
|
||||||
</Typography>
|
|
||||||
<Box className="flex items-center gap-4">
|
|
||||||
<Box className="flex-1">
|
|
||||||
<Typography variant="caption" gutterBottom>
|
|
||||||
最小值
|
|
||||||
</Typography>
|
|
||||||
<Slider
|
|
||||||
value={styleConfig.minSize}
|
|
||||||
onChange={(_, value) =>
|
|
||||||
setStyleConfig((prev) => ({ ...prev, minSize: value as number }))
|
|
||||||
}
|
|
||||||
min={2}
|
|
||||||
max={8}
|
|
||||||
step={1}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box className="flex-1">
|
|
||||||
<Typography variant="caption" gutterBottom>
|
|
||||||
最大值
|
|
||||||
</Typography>
|
|
||||||
<Slider
|
|
||||||
value={styleConfig.maxSize}
|
|
||||||
onChange={(_, value) =>
|
|
||||||
setStyleConfig((prev) => ({ ...prev, maxSize: value as number }))
|
|
||||||
}
|
|
||||||
min={10}
|
|
||||||
max={16}
|
|
||||||
step={1}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
|
|
||||||
<Typography variant="caption">预览:</Typography>
|
|
||||||
<Box
|
<Box
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
aria-label={palette.name}
|
||||||
|
onClick={() =>
|
||||||
|
setStyleConfig((previous) => ({
|
||||||
|
...previous,
|
||||||
|
...selectPalette(previous.colorType, palette.index),
|
||||||
|
}))
|
||||||
|
}
|
||||||
sx={{
|
sx={{
|
||||||
width: styleConfig.minSize,
|
display: "flex",
|
||||||
height: styleConfig.minSize,
|
height: 30,
|
||||||
borderRadius: "50%",
|
p: 0.5,
|
||||||
backgroundColor: previewColors[0],
|
overflow: "hidden",
|
||||||
|
cursor: "pointer",
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: selectedIndex === palette.index ? "primary.main" : "divider",
|
||||||
|
borderRadius: 1,
|
||||||
|
boxShadow: selectedIndex === palette.index ? "0 0 0 1px currentColor" : "none",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<Typography variant="caption">到</Typography>
|
{palette.colors.map((color, index) => (
|
||||||
<Box
|
<Box key={`${color}-${index}`} sx={{ flex: 1, bgcolor: color }} />
|
||||||
sx={{
|
))}
|
||||||
width: styleConfig.maxSize,
|
|
||||||
height: styleConfig.maxSize,
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: previewColors[previewColors.length - 1],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
if (selectedRenderLayer?.get("type") === "linestring") {
|
|
||||||
return (
|
return (
|
||||||
<Box className="mt-3">
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={styleConfig.adjustWidthByProperty}
|
|
||||||
onChange={(e) =>
|
|
||||||
setStyleConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
adjustWidthByProperty: e.target.checked,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={styleConfig.colorType === "single"}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="根据数值分段调整线条宽度"
|
|
||||||
/>
|
|
||||||
{styleConfig.adjustWidthByProperty ? (
|
|
||||||
<>
|
|
||||||
<Typography gutterBottom>
|
|
||||||
线条宽度范围: {styleConfig.minStrokeWidth} - {styleConfig.maxStrokeWidth}
|
|
||||||
px
|
|
||||||
</Typography>
|
|
||||||
<Box className="flex items-center gap-4">
|
|
||||||
<Box className="flex-1">
|
|
||||||
<Typography variant="caption" gutterBottom>
|
|
||||||
最小值
|
|
||||||
</Typography>
|
|
||||||
<Slider
|
|
||||||
value={styleConfig.minStrokeWidth}
|
|
||||||
onChange={(_, value) =>
|
|
||||||
setStyleConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
minStrokeWidth: value as number,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
min={1}
|
|
||||||
max={4}
|
|
||||||
step={0.5}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box className="flex-1">
|
|
||||||
<Typography variant="caption" gutterBottom>
|
|
||||||
最大值
|
|
||||||
</Typography>
|
|
||||||
<Slider
|
|
||||||
value={styleConfig.maxStrokeWidth}
|
|
||||||
onChange={(_, value) =>
|
|
||||||
setStyleConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
maxStrokeWidth: value as number,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
min={6}
|
|
||||||
max={12}
|
|
||||||
step={0.5}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
|
|
||||||
<Typography variant="caption">预览:</Typography>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 50,
|
position: "absolute",
|
||||||
height: styleConfig.minStrokeWidth,
|
top: 72,
|
||||||
backgroundColor: previewColors[0],
|
left: 12,
|
||||||
border: `1px solid ${previewColors[0]}`,
|
zIndex: 1300,
|
||||||
borderRadius: 1,
|
width: "min(380px, calc(100vw - 24px))",
|
||||||
|
maxHeight: "calc(100dvh - 92px)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
boxShadow:
|
||||||
|
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
|
||||||
|
backdropFilter: "blur(8px)",
|
||||||
|
opacity: 0.95,
|
||||||
|
transition: "opacity 0.3s ease-in-out",
|
||||||
|
overflow: "hidden",
|
||||||
|
"&:hover": {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<Typography variant="caption">到</Typography>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 50,
|
px: 2.5,
|
||||||
height: styleConfig.maxStrokeWidth,
|
py: 1.5,
|
||||||
backgroundColor: previewColors[previewColors.length - 1],
|
display: "flex",
|
||||||
border: `1px solid ${previewColors[previewColors.length - 1]}`,
|
alignItems: "center",
|
||||||
borderRadius: 1,
|
gap: 1,
|
||||||
|
bgcolor: "rgba(255,255,255,0.98)",
|
||||||
|
color: "text.primary",
|
||||||
|
borderBottom: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</Box>
|
<PaletteIcon fontSize="small" sx={{ color: "#257DD4" }} />
|
||||||
</>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, flex: 1 }}>
|
||||||
) : (
|
图层样式
|
||||||
<>
|
|
||||||
<Typography gutterBottom>
|
|
||||||
固定线条宽度: {styleConfig.fixedStrokeWidth}px
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Slider
|
{isDirty && (
|
||||||
value={styleConfig.fixedStrokeWidth}
|
<Chip
|
||||||
onChange={(_, value) =>
|
label="待应用"
|
||||||
setStyleConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
fixedStrokeWidth: value as number,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
min={1}
|
|
||||||
max={10}
|
|
||||||
step={0.5}
|
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
|
||||||
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
|
|
||||||
<Typography variant="caption">预览:</Typography>
|
|
||||||
<Box
|
|
||||||
sx={{
|
sx={{
|
||||||
width: 50,
|
height: 24,
|
||||||
height: styleConfig.fixedStrokeWidth,
|
color: "warning.dark",
|
||||||
backgroundColor: previewColors[0],
|
bgcolor: "warning.50",
|
||||||
border: `1px solid ${previewColors[0]}`,
|
border: "1px solid",
|
||||||
borderRadius: 1,
|
borderColor: "warning.200",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
<Box sx={{ overflowY: "auto", overflowX: "hidden", minHeight: 0 }}>
|
||||||
};
|
<Box sx={sectionSx}>
|
||||||
|
<Typography variant="overline" color="text.secondary">
|
||||||
return (
|
图层与数据
|
||||||
<div className="absolute top-20 left-4 bg-white p-4 rounded-xl shadow-lg opacity-95 hover:opacity-100 transition-opacity w-80 z-1300">
|
</Typography>
|
||||||
<FormControl variant="standard" fullWidth margin="dense">
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25, mt: 0.5 }}>
|
||||||
<InputLabel>选择图层</InputLabel>
|
<FormControl size="small" fullWidth>
|
||||||
|
<InputLabel>图层</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={selectedRenderLayer ? renderLayers.indexOf(selectedRenderLayer) : ""}
|
label="图层"
|
||||||
onChange={(e) => onLayerChange(e.target.value as number)}
|
value={selectedRenderLayer?.get("value") || ""}
|
||||||
|
onChange={(event) => onLayerChange(String(event.target.value))}
|
||||||
>
|
>
|
||||||
{renderLayers.map((layer, index) => (
|
{renderLayers.map((layer) => (
|
||||||
<MenuItem key={index} value={index}>
|
<MenuItem key={layer.get("value")} value={layer.get("value")}>
|
||||||
{layer.get("name")}
|
{layer.get("name")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormControl size="small" fullWidth>
|
||||||
<FormControl variant="standard" fullWidth margin="dense">
|
<InputLabel>属性</InputLabel>
|
||||||
<InputLabel>分级属性</InputLabel>
|
|
||||||
<Select
|
<Select
|
||||||
value={styleConfig.property}
|
label="属性"
|
||||||
onChange={(e) => onPropertyChange(e.target.value)}
|
value={selectedProperty}
|
||||||
disabled={!selectedRenderLayer}
|
onChange={(event) => onPropertyChange(String(event.target.value))}
|
||||||
>
|
>
|
||||||
{availableProperties.map((property) => (
|
{availableProperties.map((property) => (
|
||||||
<MenuItem key={property.name} value={property.value}>
|
<MenuItem key={property.value} value={property.value}>
|
||||||
{property.name}
|
{property.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormControl size="small" fullWidth>
|
||||||
<FormControl variant="standard" fullWidth margin="dense">
|
<InputLabel>分级方法</InputLabel>
|
||||||
<InputLabel>分类方法</InputLabel>
|
|
||||||
<Select
|
<Select
|
||||||
|
label="分级方法"
|
||||||
value={styleConfig.classificationMethod}
|
value={styleConfig.classificationMethod}
|
||||||
onChange={(e) => onClassificationMethodChange(e.target.value)}
|
onChange={(event) =>
|
||||||
|
onClassificationMethodChange(event.target.value as ClassificationMethod)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{CLASSIFICATION_METHODS.map((method) => (
|
{CLASSIFICATION_METHODS.map((method) => (
|
||||||
<MenuItem key={method.value} value={method.value}>
|
<MenuItem key={method.value} value={method.value}>
|
||||||
@@ -461,53 +281,28 @@ const StyleEditorForm: React.FC<StyleEditorFormProps> = ({
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<Box className="mt-3">
|
|
||||||
<Typography gutterBottom>分类数量: {styleConfig.segments}</Typography>
|
|
||||||
<Slider
|
|
||||||
value={styleConfig.segments}
|
|
||||||
onChange={(_, value) => onSegmentsChange(value as number)}
|
|
||||||
min={2}
|
|
||||||
max={10}
|
|
||||||
step={1}
|
|
||||||
marks
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{styleConfig.classificationMethod === "custom_breaks" && (
|
|
||||||
<Box className="mt-3 p-2 bg-gray-50 rounded">
|
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
|
||||||
手动设置区间阈值(按升序填写,最小值 {">="} 0)
|
|
||||||
</Typography>
|
|
||||||
<Box
|
|
||||||
className="flex flex-col gap-2"
|
|
||||||
sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "12px" }}
|
|
||||||
>
|
|
||||||
{Array.from({ length: styleConfig.segments }).map((_, index) => (
|
|
||||||
<TextField
|
<TextField
|
||||||
key={index}
|
|
||||||
label={`阈值 ${index + 1}`}
|
|
||||||
type="number"
|
|
||||||
size="small"
|
size="small"
|
||||||
slotProps={{ input: { inputProps: { min: 0, step: 0.1 } } }}
|
label="分类数量"
|
||||||
value={styleConfig.customBreaks?.[index] ?? ""}
|
type="number"
|
||||||
onChange={(e) => onCustomBreakChange(index, e.target.value)}
|
value={styleConfig.segments}
|
||||||
onBlur={onCustomBreakBlur}
|
onChange={(event) => onSegmentsChange(Number(event.target.value))}
|
||||||
|
slotProps={{ htmlInput: { min: 2, max: 10, step: 1 } }}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
|
|
||||||
<FormControl variant="standard" fullWidth margin="dense">
|
<Divider />
|
||||||
<InputLabel>
|
<Box sx={sectionSx}>
|
||||||
<ColorLensIcon className="mr-1" />
|
<Typography variant="overline" color="text.secondary">
|
||||||
颜色方案
|
分级与色带
|
||||||
</InputLabel>
|
</Typography>
|
||||||
|
<FormControl size="small" fullWidth sx={{ mt: 0.5, mb: 1 }}>
|
||||||
|
<InputLabel>颜色方案</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
|
label="颜色方案"
|
||||||
value={styleConfig.colorType}
|
value={styleConfig.colorType}
|
||||||
onChange={(e) => onColorTypeChange(e.target.value)}
|
onChange={(event) => onColorTypeChange(event.target.value as ColorType)}
|
||||||
>
|
>
|
||||||
{COLOR_TYPE_OPTIONS.map((option) => (
|
{COLOR_TYPE_OPTIONS.map((option) => (
|
||||||
<MenuItem key={option.value} value={option.value}>
|
<MenuItem key={option.value} value={option.value}>
|
||||||
@@ -515,75 +310,195 @@ const StyleEditorForm: React.FC<StyleEditorFormProps> = ({
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
{renderColorSetting()}
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
{renderPalette()}
|
||||||
|
|
||||||
{renderSizeSetting()}
|
{styleConfig.classificationMethod === "custom_breaks" && (
|
||||||
|
<Box sx={{ mt: 1.25, display: "grid", gap: 0.75 }}>
|
||||||
<Box className="mt-3">
|
{Array.from({ length: styleConfig.segments }, (_, index) => (
|
||||||
<Typography gutterBottom>
|
<Box
|
||||||
透明度: {(styleConfig.opacity * 100).toFixed(0)}%
|
key={index}
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns:
|
||||||
|
styleConfig.colorType === "custom" ? "32px 1fr 12px 1fr" : "12px 1fr 12px 1fr",
|
||||||
|
gap: 0.75,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{styleConfig.colorType === "custom" ? (
|
||||||
|
<Tooltip title={`区间 ${index + 1} 颜色`}>
|
||||||
|
<Box
|
||||||
|
component="input"
|
||||||
|
type="color"
|
||||||
|
aria-label={`区间 ${index + 1} 颜色`}
|
||||||
|
value={rgbaToHex(styleConfig.customColors?.[index] || previewColors[index])}
|
||||||
|
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setCustomColor(index, event.target.value)
|
||||||
|
}
|
||||||
|
sx={{ width: 32, height: 30, p: 0, border: 0, bgcolor: "transparent" }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ width: 10, height: 24, bgcolor: previewColors[index], borderRadius: 0.5 }} />
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={Number.isFinite(styleConfig.customBreaks?.[index]) ? styleConfig.customBreaks?.[index] : ""}
|
||||||
|
onChange={(event) => onCustomBreakChange(index, event.target.value)}
|
||||||
|
inputProps={{ "aria-label": `区间 ${index + 1} 下界`, step: 0.1 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary" textAlign="center">
|
||||||
|
至
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={Number.isFinite(styleConfig.customBreaks?.[index + 1]) ? styleConfig.customBreaks?.[index + 1] : ""}
|
||||||
|
onChange={(event) => onCustomBreakChange(index + 1, event.target.value)}
|
||||||
|
inputProps={{ "aria-label": `区间 ${index + 1} 上界`, step: 0.1 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Box sx={sectionSx}>
|
||||||
|
<Typography variant="overline" color="text.secondary">
|
||||||
|
符号
|
||||||
|
</Typography>
|
||||||
|
{layerType === "point" ? (
|
||||||
|
<Box sx={{ mt: 0.5 }}>
|
||||||
|
<Typography variant="caption">
|
||||||
|
节点半径 {styleConfig.minSize} - {styleConfig.maxSize}px
|
||||||
|
</Typography>
|
||||||
|
<Slider
|
||||||
|
value={[styleConfig.minSize, styleConfig.maxSize]}
|
||||||
|
onChange={(_, value) => {
|
||||||
|
const [minSize, maxSize] = value as number[];
|
||||||
|
setStyleConfig((previous) => ({ ...previous, minSize, maxSize }));
|
||||||
|
}}
|
||||||
|
min={2}
|
||||||
|
max={16}
|
||||||
|
step={1}
|
||||||
|
size="small"
|
||||||
|
sx={sliderSx}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ mt: 0.5 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={styleConfig.adjustWidthByProperty}
|
||||||
|
onChange={(event) =>
|
||||||
|
setStyleConfig((previous) => ({
|
||||||
|
...previous,
|
||||||
|
adjustWidthByProperty: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="body2">按数值调整线宽</Typography>}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption">
|
||||||
|
{styleConfig.adjustWidthByProperty
|
||||||
|
? `${styleConfig.minStrokeWidth} - ${styleConfig.maxStrokeWidth}px`
|
||||||
|
: `${styleConfig.fixedStrokeWidth}px`}
|
||||||
|
</Typography>
|
||||||
|
<Slider
|
||||||
|
value={
|
||||||
|
styleConfig.adjustWidthByProperty
|
||||||
|
? [styleConfig.minStrokeWidth, styleConfig.maxStrokeWidth]
|
||||||
|
: styleConfig.fixedStrokeWidth
|
||||||
|
}
|
||||||
|
onChange={(_, value) =>
|
||||||
|
setStyleConfig((previous) =>
|
||||||
|
Array.isArray(value)
|
||||||
|
? { ...previous, minStrokeWidth: value[0], maxStrokeWidth: value[1] }
|
||||||
|
: { ...previous, fixedStrokeWidth: value },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
min={1}
|
||||||
|
max={12}
|
||||||
|
step={0.5}
|
||||||
|
size="small"
|
||||||
|
sx={sliderSx}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption">透明度 {Math.round(styleConfig.opacity * 100)}%</Typography>
|
||||||
<Slider
|
<Slider
|
||||||
value={styleConfig.opacity}
|
value={styleConfig.opacity}
|
||||||
onChange={(_, value) =>
|
onChange={(_, value) =>
|
||||||
setStyleConfig((prev) => ({ ...prev, opacity: value as number }))
|
setStyleConfig((previous) => ({ ...previous, opacity: value as number }))
|
||||||
}
|
}
|
||||||
min={0.1}
|
min={0.1}
|
||||||
max={1}
|
max={1}
|
||||||
step={0.05}
|
step={0.05}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={sliderSx}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Box sx={{ ...sectionSx, display: "flex", gap: 2 }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={styleConfig.showId}
|
size="small"
|
||||||
onChange={(e) =>
|
|
||||||
setStyleConfig((prev) => ({ ...prev, showId: e.target.checked }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="显示 ID(缩放 >=15 级时显示)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={styleConfig.showLabels}
|
checked={styleConfig.showLabels}
|
||||||
onChange={(e) =>
|
onChange={(event) =>
|
||||||
setStyleConfig((prev) => ({ ...prev, showLabels: e.target.checked }))
|
setStyleConfig((previous) => ({ ...previous, showLabels: event.target.checked }))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label="显示属性(缩放 >=15 级时显示)"
|
label={<Typography variant="body2">数值标注</Typography>}
|
||||||
/>
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={styleConfig.showId}
|
||||||
|
onChange={(event) =>
|
||||||
|
setStyleConfig((previous) => ({ ...previous, showId: event.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="body2">设施 ID</Typography>}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<div className="my-3"></div>
|
<Divider />
|
||||||
|
<Box sx={{ px: 2, py: 1.25 }}>
|
||||||
<Box className="flex gap-2">
|
{validationErrors[0] && (
|
||||||
|
<Typography variant="caption" color="error" sx={{ display: "block", mb: 0.75 }}>
|
||||||
|
{validationErrors[0]}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
|
||||||
|
<Tooltip title="恢复默认样式">
|
||||||
|
<IconButton size="small" onClick={onReset} aria-label="恢复默认样式">
|
||||||
|
<ResetIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
size="small"
|
||||||
onClick={onApply}
|
|
||||||
disabled={!selectedRenderLayer || !styleConfig.property}
|
|
||||||
startIcon={<ApplyIcon />}
|
startIcon={<ApplyIcon />}
|
||||||
fullWidth
|
onClick={onApply}
|
||||||
|
disabled={!isDirty || validationErrors.length > 0 || isApplying}
|
||||||
>
|
>
|
||||||
应用
|
{isApplying ? "应用中" : "应用"}
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onReset}
|
|
||||||
disabled={!selectedRenderLayer}
|
|
||||||
startIcon={<ResetIcon />}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
重置
|
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,38 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { Box, CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
import StyleEditorForm from "./StyleEditorForm";
|
import StyleEditorForm from "./StyleEditorForm";
|
||||||
import { createDefaultLayerStyleState, createDefaultLayerStyleStates } from "./styleEditorPresets";
|
import type { StyleEditorPanelProps } from "./styleEditorTypes";
|
||||||
import { LayerStyleState, StyleConfig, StyleEditorPanelProps } from "./styleEditorTypes";
|
|
||||||
|
|
||||||
const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
||||||
isReady,
|
isReady,
|
||||||
renderLayers,
|
...formProps
|
||||||
selectedRenderLayer,
|
|
||||||
styleConfig,
|
|
||||||
setStyleConfig,
|
|
||||||
availableProperties,
|
|
||||||
onLayerChange,
|
|
||||||
onPropertyChange,
|
|
||||||
onClassificationMethodChange,
|
|
||||||
onSegmentsChange,
|
|
||||||
onCustomBreakChange,
|
|
||||||
onCustomBreakBlur,
|
|
||||||
onColorTypeChange,
|
|
||||||
onApply,
|
|
||||||
onReset,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
return <div>Loading...</div>;
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 72,
|
||||||
|
left: 12,
|
||||||
|
zIndex: 1300,
|
||||||
|
width: 160,
|
||||||
|
height: 72,
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
borderRadius: "12px",
|
||||||
|
boxShadow:
|
||||||
|
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
|
||||||
|
opacity: 0.95,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={22} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <StyleEditorForm {...formProps} />;
|
||||||
<StyleEditorForm
|
|
||||||
renderLayers={renderLayers}
|
|
||||||
selectedRenderLayer={selectedRenderLayer}
|
|
||||||
styleConfig={styleConfig}
|
|
||||||
setStyleConfig={setStyleConfig}
|
|
||||||
availableProperties={availableProperties}
|
|
||||||
onLayerChange={onLayerChange}
|
|
||||||
onPropertyChange={onPropertyChange}
|
|
||||||
onClassificationMethodChange={onClassificationMethodChange}
|
|
||||||
onSegmentsChange={onSegmentsChange}
|
|
||||||
onCustomBreakChange={onCustomBreakChange}
|
|
||||||
onCustomBreakBlur={onCustomBreakBlur}
|
|
||||||
onColorTypeChange={onColorTypeChange}
|
|
||||||
onApply={onApply}
|
|
||||||
onReset={onReset}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default StyleEditorPanel;
|
export default StyleEditorPanel;
|
||||||
export type { LayerStyleState, StyleConfig } from "./styleEditorTypes";
|
|
||||||
export { createDefaultLayerStyleState, createDefaultLayerStyleStates };
|
|
||||||
|
|||||||
@@ -7,34 +7,31 @@ interface LegendStyleConfig {
|
|||||||
layerId: string;
|
layerId: string;
|
||||||
property: string;
|
property: string;
|
||||||
colors: string[];
|
colors: string[];
|
||||||
type: string; // 图例类型
|
type: string;
|
||||||
dimensions: number[]; // 尺寸大小
|
dimensions: number[];
|
||||||
breaks: number[]; // 分段值
|
breaks: number[];
|
||||||
labels?: string[]; // 可选标签(用于离散分类)
|
labels?: string[];
|
||||||
columns?: number;
|
columns?: number;
|
||||||
itemsPerColumn?: number;
|
itemsPerColumn?: number;
|
||||||
}
|
}
|
||||||
// 图例组件
|
|
||||||
// 该组件用于显示图层样式的图例,包含属性名称、颜色、尺寸和分段值等信息
|
|
||||||
// 通过传入的配置对象动态生成图例内容,适用于不同的样式配置
|
|
||||||
// 使用时需要确保传入的 colors、dimensions 和 breaks 数组长度一致
|
|
||||||
|
|
||||||
const StyleLegend: React.FC<LegendStyleConfig> = ({
|
const StyleLegend: React.FC<LegendStyleConfig> = ({
|
||||||
layerName,
|
layerName,
|
||||||
layerId,
|
layerId,
|
||||||
property,
|
property,
|
||||||
colors,
|
colors,
|
||||||
type, // 图例类型
|
type,
|
||||||
dimensions,
|
dimensions,
|
||||||
breaks,
|
breaks,
|
||||||
labels,
|
labels,
|
||||||
columns = 1,
|
columns = 1,
|
||||||
itemsPerColumn,
|
itemsPerColumn,
|
||||||
}) => {
|
}) => {
|
||||||
|
const itemCount = Math.min(colors.length, dimensions.length, Math.max(0, breaks.length - 1));
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={layerId}
|
key={layerId}
|
||||||
className="bg-white p-3 rounded-xl max-w-xs opacity-95 transition-opacity duration-300 hover:opacity-100"
|
className="bg-white p-3 max-w-xs opacity-95 transition-opacity duration-300 hover:opacity-100"
|
||||||
|
sx={{ borderRadius: 1 }}
|
||||||
>
|
>
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
{layerName} - {property}
|
{layerName} - {property}
|
||||||
@@ -56,39 +53,9 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
|
|||||||
rowGap: 0.5,
|
rowGap: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[...Array(breaks.length)].map((_, index) => {
|
{Array.from({ length: itemCount }, (_, index) => {
|
||||||
const color = colors[index]; // 默认颜色为黑色
|
const color = colors[index];
|
||||||
const dimension = dimensions[index]; // 默认尺寸为16
|
const dimension = dimensions[index];
|
||||||
|
|
||||||
// // 处理第一个区间(小于 breaks[0])
|
|
||||||
// if (index === 0) {
|
|
||||||
// return (
|
|
||||||
// <Box key={index} className="flex items-center gap-2 mb-1">
|
|
||||||
// <Box
|
|
||||||
// sx={
|
|
||||||
// type === "point"
|
|
||||||
// ? {
|
|
||||||
// width: dimension,
|
|
||||||
// height: dimension,
|
|
||||||
// borderRadius: "50%",
|
|
||||||
// backgroundColor: color,
|
|
||||||
// }
|
|
||||||
// : {
|
|
||||||
// width: 16,
|
|
||||||
// height: dimension,
|
|
||||||
// backgroundColor: color,
|
|
||||||
// border: `1px solid ${color}`,
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// />
|
|
||||||
// <Typography variant="caption" className="text-xs">
|
|
||||||
// {"<"} {breaks[0]?.toFixed(1)}
|
|
||||||
// </Typography>
|
|
||||||
// </Box>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 处理中间区间(breaks[index] - breaks[index + 1])
|
|
||||||
if (index + 1 < breaks.length) {
|
if (index + 1 < breaks.length) {
|
||||||
const prevValue = breaks[index];
|
const prevValue = breaks[index];
|
||||||
const currentValue = breaks[index + 1];
|
const currentValue = breaks[index + 1];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import Draggable from "react-draggable";
|
import Draggable from "react-draggable";
|
||||||
|
|
||||||
@@ -30,6 +30,13 @@ import { useData } from "../MapComponent";
|
|||||||
import { config, NETWORK_NAME } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
import { useMap } from "../MapComponent";
|
import { useMap } from "../MapComponent";
|
||||||
|
import {
|
||||||
|
formatTimelineTime,
|
||||||
|
getRoundedCurrentTimelineMinutes,
|
||||||
|
getTimelineDisabledRangePercentages,
|
||||||
|
normalizeTimelineMinutes,
|
||||||
|
} from "./timelineTime";
|
||||||
|
import { useTimelineTimeConfig } from "./useTimelineTimeConfig";
|
||||||
|
|
||||||
interface TimelineProps {
|
interface TimelineProps {
|
||||||
schemeDate?: Date;
|
schemeDate?: Date;
|
||||||
@@ -58,6 +65,13 @@ const timelineIconButtonSx = {
|
|||||||
|
|
||||||
const NOOP_SET_CURRENT_TIME = (_: any) => undefined;
|
const NOOP_SET_CURRENT_TIME = (_: any) => undefined;
|
||||||
const NOOP_SET_SELECTED_DATE = (_: any) => undefined;
|
const NOOP_SET_SELECTED_DATE = (_: any) => undefined;
|
||||||
|
const BASE_CALCULATED_INTERVAL_OPTIONS = [
|
||||||
|
{ value: 1440, label: "1 天" },
|
||||||
|
{ value: 60, label: "1 小时" },
|
||||||
|
{ value: 30, label: "30 分钟" },
|
||||||
|
{ value: 15, label: "15 分钟" },
|
||||||
|
{ value: 5, label: "5 分钟" },
|
||||||
|
];
|
||||||
|
|
||||||
const Timeline: React.FC<TimelineProps> = ({
|
const Timeline: React.FC<TimelineProps> = ({
|
||||||
schemeDate,
|
schemeDate,
|
||||||
@@ -87,18 +101,39 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
const pipeText = data?.pipeText ?? "";
|
const pipeText = data?.pipeText ?? "";
|
||||||
const setForceStyleAutoApplyVersion = data?.setForceStyleAutoApplyVersion;
|
const setForceStyleAutoApplyVersion = data?.setForceStyleAutoApplyVersion;
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
|
const { durationMinutes, stepMinutes } = useTimelineTimeConfig();
|
||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒
|
const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒
|
||||||
const [calculatedInterval, setCalculatedInterval] = useState<number>(15); // 分钟
|
const [calculatedInterval, setCalculatedInterval] =
|
||||||
|
useState<number>(stepMinutes); // 分钟
|
||||||
const [isCalculating, setIsCalculating] = useState<boolean>(false);
|
const [isCalculating, setIsCalculating] = useState<boolean>(false);
|
||||||
|
const [sliderPreviewTime, setSliderPreviewTime] = useState<number | null>(null);
|
||||||
|
|
||||||
// 计算时间轴范围
|
// 计算时间轴范围
|
||||||
const minTime = timeRange
|
const minTime = timeRange
|
||||||
? timeRange.start.getHours() * 60 + timeRange.start.getMinutes()
|
? normalizeTimelineMinutes(
|
||||||
|
timeRange.start.getHours() * 60 + timeRange.start.getMinutes(),
|
||||||
|
0,
|
||||||
|
durationMinutes,
|
||||||
|
)
|
||||||
: 0;
|
: 0;
|
||||||
const maxTime = timeRange
|
const maxTime = timeRange
|
||||||
? timeRange.end.getHours() * 60 + timeRange.end.getMinutes()
|
? normalizeTimelineMinutes(
|
||||||
: 1440;
|
timeRange.end.getHours() * 60 + timeRange.end.getMinutes(),
|
||||||
|
0,
|
||||||
|
durationMinutes,
|
||||||
|
)
|
||||||
|
: durationMinutes;
|
||||||
|
const timelineCurrentTime = normalizeTimelineMinutes(
|
||||||
|
currentTime,
|
||||||
|
minTime,
|
||||||
|
maxTime,
|
||||||
|
);
|
||||||
|
const disabledRangePercentages = getTimelineDisabledRangePercentages(
|
||||||
|
minTime,
|
||||||
|
maxTime,
|
||||||
|
durationMinutes,
|
||||||
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (schemeDate) {
|
if (schemeDate) {
|
||||||
setSelectedDate(schemeDate);
|
setSelectedDate(schemeDate);
|
||||||
@@ -112,8 +147,8 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
// 添加缓存引用
|
// 添加缓存引用
|
||||||
const nodeCacheRef = useRef<Map<string, any[]>>(new Map());
|
const nodeCacheRef = useRef<Map<string, any[]>>(new Map());
|
||||||
const linkCacheRef = useRef<Map<string, any[]>>(new Map());
|
const linkCacheRef = useRef<Map<string, any[]>>(new Map());
|
||||||
// 添加防抖引用
|
const frameRequestRevisionRef = useRef(0);
|
||||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
const frameAbortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const updateDataStates = useCallback(
|
const updateDataStates = useCallback(
|
||||||
(
|
(
|
||||||
@@ -168,6 +203,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
target,
|
target,
|
||||||
schemeName,
|
schemeName,
|
||||||
schemeType,
|
schemeType,
|
||||||
|
signal,
|
||||||
}: {
|
}: {
|
||||||
queryTime: Date;
|
queryTime: Date;
|
||||||
junctionProperties: string;
|
junctionProperties: string;
|
||||||
@@ -176,6 +212,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
target: "primary" | "compare";
|
target: "primary" | "compare";
|
||||||
schemeName?: string;
|
schemeName?: string;
|
||||||
schemeType?: string;
|
schemeType?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
}) => {
|
}) => {
|
||||||
const query_time = queryTime.toISOString();
|
const query_time = queryTime.toISOString();
|
||||||
let nodeRecords: any = { results: [] };
|
let nodeRecords: any = { results: [] };
|
||||||
@@ -199,10 +236,12 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
nodePromise =
|
nodePromise =
|
||||||
sourceType === "scheme" && schemeName
|
sourceType === "scheme" && schemeName
|
||||||
? apiFetch(
|
? apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`
|
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`,
|
||||||
|
{ signal },
|
||||||
)
|
)
|
||||||
: apiFetch(
|
: apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`
|
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`,
|
||||||
|
{ signal },
|
||||||
);
|
);
|
||||||
requests.push(nodePromise);
|
requests.push(nodePromise);
|
||||||
}
|
}
|
||||||
@@ -226,10 +265,12 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
linkPromise =
|
linkPromise =
|
||||||
sourceType === "scheme" && schemeName
|
sourceType === "scheme" && schemeName
|
||||||
? apiFetch(
|
? apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`
|
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
|
||||||
|
{ signal },
|
||||||
)
|
)
|
||||||
: apiFetch(
|
: apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`
|
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
|
||||||
|
{ signal },
|
||||||
);
|
);
|
||||||
requests.push(linkPromise);
|
requests.push(linkPromise);
|
||||||
}
|
}
|
||||||
@@ -275,9 +316,13 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDataStates(nodeRecords.results || [], linkRecords.results || [], target);
|
return {
|
||||||
|
target,
|
||||||
|
nodeResults: nodeRecords.results || [],
|
||||||
|
linkResults: linkRecords.results || [],
|
||||||
|
};
|
||||||
},
|
},
|
||||||
[buildCacheKey, updateDataStates]
|
[buildCacheKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchFrameData = useCallback(
|
const fetchFrameData = useCallback(
|
||||||
@@ -288,6 +333,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
schemeName: string,
|
schemeName: string,
|
||||||
schemeType: string
|
schemeType: string
|
||||||
) => {
|
) => {
|
||||||
|
const revision = frameRequestRevisionRef.current + 1;
|
||||||
|
frameRequestRevisionRef.current = revision;
|
||||||
|
frameAbortControllerRef.current?.abort();
|
||||||
|
const abortController = new AbortController();
|
||||||
|
frameAbortControllerRef.current = abortController;
|
||||||
const primarySourceType =
|
const primarySourceType =
|
||||||
disableDateSelection && schemeName ? "scheme" : "realtime";
|
disableDateSelection && schemeName ? "scheme" : "realtime";
|
||||||
const tasks = [
|
const tasks = [
|
||||||
@@ -299,6 +349,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
target: "primary",
|
target: "primary",
|
||||||
schemeName,
|
schemeName,
|
||||||
schemeType,
|
schemeType,
|
||||||
|
signal: abortController.signal,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -310,37 +361,62 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
pipeProperties,
|
pipeProperties,
|
||||||
sourceType: "realtime",
|
sourceType: "realtime",
|
||||||
target: "compare",
|
target: "compare",
|
||||||
|
signal: abortController.signal,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(tasks);
|
try {
|
||||||
|
const frames = await Promise.all(tasks);
|
||||||
|
if (
|
||||||
|
abortController.signal.aborted ||
|
||||||
|
revision !== frameRequestRevisionRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
frames.forEach(({ nodeResults, linkResults, target }) => {
|
||||||
|
updateDataStates(nodeResults, linkResults, target);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as Error).name !== "AbortError") {
|
||||||
|
console.error("Timeline frame fetch failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[disableDateSelection, fetchDataBySource, isCompareMode]
|
[disableDateSelection, fetchDataBySource, isCompareMode, updateDataStates]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 时间刻度数组 (每5分钟一个刻度)
|
|
||||||
const timeMarks = Array.from({ length: 288 }, (_, i) => ({
|
|
||||||
value: i * 5,
|
|
||||||
label: i % 24 === 0 ? formatTime(i * 5) : "",
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 格式化时间显示
|
// 格式化时间显示
|
||||||
function formatTime(minutes: number): string {
|
const formatTime = useCallback((minutes: number): string => {
|
||||||
const hours = Math.floor(minutes / 60);
|
return formatTimelineTime(minutes, minTime, maxTime);
|
||||||
const mins = minutes % 60;
|
}, [maxTime, minTime]);
|
||||||
return `${hours.toString().padStart(2, "0")}:${mins
|
|
||||||
.toString()
|
|
||||||
.padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentTimeToDate(selectedDate: Date, minutes: number): Date {
|
const timeMarks = useMemo(() => {
|
||||||
|
const marks = [];
|
||||||
|
const markInterval = 120;
|
||||||
|
for (let value = 0; value <= durationMinutes; value += markInterval) {
|
||||||
|
marks.push({
|
||||||
|
value,
|
||||||
|
label: formatTime(value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (marks.at(-1)?.value !== durationMinutes) {
|
||||||
|
marks.push({
|
||||||
|
value: durationMinutes,
|
||||||
|
label: formatTime(durationMinutes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return marks;
|
||||||
|
}, [durationMinutes, formatTime]);
|
||||||
|
|
||||||
|
const currentTimeToDate = useCallback((selectedDate: Date, minutes: number): Date => {
|
||||||
const date = new Date(selectedDate);
|
const date = new Date(selectedDate);
|
||||||
const hours = Math.floor(minutes / 60);
|
const normalizedMinutes = normalizeTimelineMinutes(minutes, minTime, maxTime);
|
||||||
const mins = minutes % 60;
|
const hours = Math.floor(normalizedMinutes / 60);
|
||||||
|
const mins = normalizedMinutes % 60;
|
||||||
date.setHours(hours, mins, 0, 0);
|
date.setHours(hours, mins, 0, 0);
|
||||||
return date;
|
return date;
|
||||||
}
|
}, [maxTime, minTime]);
|
||||||
|
|
||||||
// 播放时间间隔选项
|
// 播放时间间隔选项
|
||||||
const intervalOptions = [
|
const intervalOptions = [
|
||||||
@@ -350,13 +426,31 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
{ value: 20000, label: "20秒" },
|
{ value: 20000, label: "20秒" },
|
||||||
];
|
];
|
||||||
// 强制计算时间段选项
|
// 强制计算时间段选项
|
||||||
const calculatedIntervalOptions = [
|
const resolvedCalculatedIntervalOptions = useMemo(() => {
|
||||||
{ value: 1440, label: "1 天" },
|
const options = BASE_CALCULATED_INTERVAL_OPTIONS.filter(
|
||||||
{ value: 60, label: "1 小时" },
|
(option) => option.value >= stepMinutes,
|
||||||
{ value: 30, label: "30 分钟" },
|
);
|
||||||
{ value: 15, label: "15 分钟" },
|
if (!options.some((option) => option.value === stepMinutes)) {
|
||||||
{ value: 5, label: "5 分钟" },
|
options.push({ value: stepMinutes, label: `${stepMinutes} 分钟` });
|
||||||
];
|
}
|
||||||
|
return options.sort((a, b) => b.value - a.value);
|
||||||
|
}, [stepMinutes]);
|
||||||
|
|
||||||
|
const advanceTimelineTime = useCallback(
|
||||||
|
(previousTime: number, direction: 1 | -1 = 1) => {
|
||||||
|
const baseTime = Number.isFinite(previousTime) ? previousTime : minTime;
|
||||||
|
let next = baseTime + stepMinutes * direction;
|
||||||
|
if (timeRange) {
|
||||||
|
if (next > maxTime) next = minTime;
|
||||||
|
if (next < minTime) next = maxTime;
|
||||||
|
} else {
|
||||||
|
if (next > durationMinutes) next = 0;
|
||||||
|
if (next < 0) next = durationMinutes;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
[durationMinutes, maxTime, minTime, stepMinutes, timeRange],
|
||||||
|
);
|
||||||
|
|
||||||
// 处理时间轴滑动
|
// 处理时间轴滑动
|
||||||
const handleSliderChange = useCallback(
|
const handleSliderChange = useCallback(
|
||||||
@@ -366,15 +460,18 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
if (timeRange && (value < minTime || value > maxTime)) {
|
if (timeRange && (value < minTime || value > maxTime)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 防抖设置currentTime,避免频繁触发数据获取
|
setSliderPreviewTime(value);
|
||||||
if (debounceRef.current) {
|
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
}
|
|
||||||
debounceRef.current = setTimeout(() => {
|
|
||||||
setCurrentTime(value);
|
|
||||||
}, 500); // 500ms 防抖延迟
|
|
||||||
},
|
},
|
||||||
[timeRange, minTime, maxTime, setCurrentTime],
|
[timeRange, minTime, maxTime],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSliderChangeCommitted = useCallback(
|
||||||
|
(_event: Event | React.SyntheticEvent, newValue: number | number[]) => {
|
||||||
|
const value = Array.isArray(newValue) ? newValue[0] : newValue;
|
||||||
|
setSliderPreviewTime(null);
|
||||||
|
setCurrentTime(value);
|
||||||
|
},
|
||||||
|
[setCurrentTime],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 播放控制
|
// 播放控制
|
||||||
@@ -391,17 +488,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
|
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
setCurrentTime((prev) => {
|
setCurrentTime((prev) => {
|
||||||
let next = prev + 15;
|
return advanceTimelineTime(prev);
|
||||||
if (timeRange) {
|
|
||||||
if (next > maxTime) next = minTime;
|
|
||||||
} else {
|
|
||||||
if (next >= 1440) next = 0;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
}, playInterval);
|
}, playInterval);
|
||||||
}
|
}
|
||||||
}, [isPlaying, playInterval, timeRange, maxTime, minTime, setCurrentTime]);
|
}, [advanceTimelineTime, isPlaying, playInterval, setCurrentTime]);
|
||||||
|
|
||||||
const handlePause = useCallback(() => {
|
const handlePause = useCallback(() => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -414,14 +505,14 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
const handleStop = useCallback(() => {
|
const handleStop = useCallback(() => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
// 设置为当前时间
|
// 设置为当前时间
|
||||||
const currentTime = new Date();
|
setCurrentTime(
|
||||||
const minutes = currentTime.getHours() * 60 + currentTime.getMinutes();
|
getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes),
|
||||||
setCurrentTime(minutes); // 组件卸载时重置时间
|
); // 重置为当前时间
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
intervalRef.current = null;
|
intervalRef.current = null;
|
||||||
}
|
}
|
||||||
}, [setCurrentTime]);
|
}, [durationMinutes, setCurrentTime, stepMinutes]);
|
||||||
|
|
||||||
// 步进控制
|
// 步进控制
|
||||||
const handleDayStepBackward = useCallback(() => {
|
const handleDayStepBackward = useCallback(() => {
|
||||||
@@ -440,27 +531,15 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
}, [setSelectedDate]);
|
}, [setSelectedDate]);
|
||||||
const handleStepBackward = useCallback(() => {
|
const handleStepBackward = useCallback(() => {
|
||||||
setCurrentTime((prev) => {
|
setCurrentTime((prev) => {
|
||||||
let next = prev - 15;
|
return advanceTimelineTime(prev, -1);
|
||||||
if (timeRange) {
|
|
||||||
if (next < minTime) next = maxTime;
|
|
||||||
} else {
|
|
||||||
if (next < 0) next += 1440;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
}, [timeRange, minTime, maxTime, setCurrentTime]);
|
}, [advanceTimelineTime, setCurrentTime]);
|
||||||
|
|
||||||
const handleStepForward = useCallback(() => {
|
const handleStepForward = useCallback(() => {
|
||||||
setCurrentTime((prev) => {
|
setCurrentTime((prev) => {
|
||||||
let next = prev + 15;
|
return advanceTimelineTime(prev);
|
||||||
if (timeRange) {
|
|
||||||
if (next > maxTime) next = minTime;
|
|
||||||
} else {
|
|
||||||
if (next >= 1440) next = 0;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
}, [timeRange, minTime, maxTime, setCurrentTime]);
|
}, [advanceTimelineTime, setCurrentTime]);
|
||||||
|
|
||||||
// 日期选择处理
|
// 日期选择处理
|
||||||
const handleDateChange = useCallback((newDate: Date | null) => {
|
const handleDateChange = useCallback((newDate: Date | null) => {
|
||||||
@@ -480,18 +559,12 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
setCurrentTime((prev) => {
|
setCurrentTime((prev) => {
|
||||||
let next = prev + 15;
|
return advanceTimelineTime(prev);
|
||||||
if (timeRange) {
|
|
||||||
if (next > maxTime) next = minTime;
|
|
||||||
} else {
|
|
||||||
if (next >= 1440) next = 0;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
}, newInterval);
|
}, newInterval);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isPlaying, timeRange, maxTime, minTime, setCurrentTime],
|
[advanceTimelineTime, isPlaying, setCurrentTime],
|
||||||
);
|
);
|
||||||
// 计算时间段改变处理
|
// 计算时间段改变处理
|
||||||
const handleCalculatedIntervalChange = useCallback((event: any) => {
|
const handleCalculatedIntervalChange = useCallback((event: any) => {
|
||||||
@@ -499,6 +572,10 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
setCalculatedInterval(newInterval);
|
setCalculatedInterval(newInterval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCalculatedInterval(stepMinutes);
|
||||||
|
}, [stepMinutes]);
|
||||||
|
|
||||||
// 添加 useEffect 来监听 currentTime 和 selectedDate 的变化,并获取数据
|
// 添加 useEffect 来监听 currentTime 和 selectedDate 的变化,并获取数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 首次加载时,如果 selectedDate 或 currentTime 未定义,则跳过执行,避免报错
|
// 首次加载时,如果 selectedDate 或 currentTime 未定义,则跳过执行,避免报错
|
||||||
@@ -514,7 +591,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
fetchFrameData(
|
fetchFrameData(
|
||||||
currentTimeToDate(selectedDate, currentTime),
|
currentTimeToDate(selectedDate, timelineCurrentTime),
|
||||||
junctionText,
|
junctionText,
|
||||||
pipeText,
|
pipeText,
|
||||||
schemeName,
|
schemeName,
|
||||||
@@ -526,6 +603,8 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
junctionText,
|
junctionText,
|
||||||
pipeText,
|
pipeText,
|
||||||
currentTime,
|
currentTime,
|
||||||
|
currentTimeToDate,
|
||||||
|
timelineCurrentTime,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
schemeName,
|
schemeName,
|
||||||
schemeType,
|
schemeType,
|
||||||
@@ -534,21 +613,17 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
// 组件卸载时清理定时器和防抖
|
// 组件卸载时清理定时器和防抖
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 设置为当前时间
|
// 设置为当前时间
|
||||||
const currentTime = new Date();
|
setCurrentTime(
|
||||||
const minutes = currentTime.getHours() * 60 + currentTime.getMinutes();
|
getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes),
|
||||||
// 找到最近的前15分钟刻度
|
); // 初始化为当前时间
|
||||||
const roundedMinutes = Math.floor(minutes / 15) * 15;
|
|
||||||
setCurrentTime(roundedMinutes); // 组件卸载时重置时间
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
}
|
}
|
||||||
if (debounceRef.current) {
|
frameAbortControllerRef.current?.abort();
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [setCurrentTime]);
|
}, [durationMinutes, setCurrentTime, stepMinutes]);
|
||||||
|
|
||||||
// 当 timeRange 改变时,设置 currentTime 到 minTime
|
// 当 timeRange 改变时,设置 currentTime 到 minTime
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -603,7 +678,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
clearCache(linkCacheRef);
|
clearCache(linkCacheRef);
|
||||||
// 重新获取当前时刻的新数据
|
// 重新获取当前时刻的新数据
|
||||||
fetchFrameData(
|
fetchFrameData(
|
||||||
currentTimeToDate(selectedDate, currentTime),
|
currentTimeToDate(selectedDate, timelineCurrentTime),
|
||||||
junctionText,
|
junctionText,
|
||||||
pipeText,
|
pipeText,
|
||||||
schemeName,
|
schemeName,
|
||||||
@@ -622,7 +697,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
|
|
||||||
// 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变
|
// 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变
|
||||||
const calculationDate = selectedDate;
|
const calculationDate = selectedDate;
|
||||||
const calculationTime = currentTime;
|
const calculationTime = timelineCurrentTime;
|
||||||
const calculationDateTime = currentTimeToDate(
|
const calculationDateTime = currentTimeToDate(
|
||||||
calculationDate,
|
calculationDate,
|
||||||
calculationTime
|
calculationTime
|
||||||
@@ -690,7 +765,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
<Draggable nodeRef={draggableRef} handle=".drag-handle">
|
<Draggable nodeRef={draggableRef} handle=".drag-handle">
|
||||||
<div
|
<div
|
||||||
ref={draggableRef}
|
ref={draggableRef}
|
||||||
className="absolute bottom-4 left-1/2 z-10 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100"
|
className="absolute bottom-4 left-1/2 z-20 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100"
|
||||||
>
|
>
|
||||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
||||||
<Paper
|
<Paper
|
||||||
@@ -860,7 +935,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
label="强制计算时间段"
|
label="强制计算时间段"
|
||||||
onChange={handleCalculatedIntervalChange}
|
onChange={handleCalculatedIntervalChange}
|
||||||
>
|
>
|
||||||
{calculatedIntervalOptions.map((option) => (
|
{resolvedCalculatedIntervalOptions.map((option) => (
|
||||||
<MenuItem key={option.value} value={option.value}>
|
<MenuItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -890,18 +965,19 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
color: "primary.main",
|
color: "primary.main",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatTime(currentTime)}
|
{formatTime(timelineCurrentTime)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Box ref={timelineRef} sx={{ px: 2, position: "relative" }}>
|
<Box ref={timelineRef} sx={{ px: 2, position: "relative" }}>
|
||||||
<Slider
|
<Slider
|
||||||
value={currentTime}
|
value={sliderPreviewTime ?? timelineCurrentTime}
|
||||||
min={0}
|
min={0}
|
||||||
max={1440} // 24:00 = 1440分钟
|
max={durationMinutes}
|
||||||
step={15} // 每15分钟一个步进
|
step={stepMinutes}
|
||||||
marks={timeMarks.filter((_, index) => index % 12 === 0)} // 每小时显示一个标记
|
marks={timeMarks}
|
||||||
onChange={handleSliderChange}
|
onChange={handleSliderChange}
|
||||||
|
onChangeCommitted={handleSliderChangeCommitted}
|
||||||
valueLabelDisplay="auto"
|
valueLabelDisplay="auto"
|
||||||
valueLabelFormat={formatTime}
|
valueLabelFormat={formatTime}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -942,43 +1018,46 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
/>
|
/>
|
||||||
{/* 禁用区域遮罩 */}
|
{/* 禁用区域遮罩 */}
|
||||||
{timeRange && (
|
{timeRange && (
|
||||||
<>
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
top: "30%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
height: "20px",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* 左侧禁用区域 */}
|
{/* 左侧禁用区域 */}
|
||||||
{minTime > 0 && (
|
{minTime > 0 && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: "14px",
|
left: 0,
|
||||||
top: "30%",
|
width: `${disabledRangePercentages.leftWidth}%`,
|
||||||
transform: "translateY(-50%)",
|
height: "100%",
|
||||||
width: `${(minTime / 1440) * 856 + 2}px`,
|
|
||||||
height: "20px",
|
|
||||||
backgroundColor: "rgba(189, 189, 189, 0.4)",
|
backgroundColor: "rgba(189, 189, 189, 0.4)",
|
||||||
pointerEvents: "none",
|
|
||||||
backdropFilter: "blur(1px)",
|
backdropFilter: "blur(1px)",
|
||||||
borderRadius: "2.5px",
|
borderRadius: "2.5px",
|
||||||
rounded: "true",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* 右侧禁用区域 */}
|
{/* 右侧禁用区域 */}
|
||||||
{maxTime < 1440 && (
|
{maxTime < durationMinutes && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: `${16 + (maxTime / 1440) * 856}px`,
|
left: `${disabledRangePercentages.rightLeft}%`,
|
||||||
top: "30%",
|
width: `${disabledRangePercentages.rightWidth}%`,
|
||||||
transform: "translateY(-50%)",
|
height: "100%",
|
||||||
width: `${((1440 - maxTime) / 1440) * 856}px`,
|
|
||||||
height: "20px",
|
|
||||||
backgroundColor: "rgba(189, 189, 189, 0.4)",
|
backgroundColor: "rgba(189, 189, 189, 0.4)",
|
||||||
pointerEvents: "none",
|
|
||||||
backdropFilter: "blur(1px)",
|
backdropFilter: "blur(1px)",
|
||||||
borderRadius: "2.5px",
|
borderRadius: "2.5px",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import {
|
|||||||
import { useToolbarChatActions } from "./useToolbarChatActions";
|
import { useToolbarChatActions } from "./useToolbarChatActions";
|
||||||
import { useStyleEditor } from "./useStyleEditor";
|
import { useStyleEditor } from "./useStyleEditor";
|
||||||
|
|
||||||
import { config } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
|
import { useProject } from "@/contexts/ProjectContext";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
|
||||||
// 添加接口定义隐藏按钮的props
|
// 添加接口定义隐藏按钮的props
|
||||||
@@ -37,6 +38,52 @@ interface ToolbarProps {
|
|||||||
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
|
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
|
||||||
enableCompare?: boolean;
|
enableCompare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LinkStatus = "OPEN" | "CLOSED" | "ACTIVE";
|
||||||
|
type ValveProperties = {
|
||||||
|
vType: string | null;
|
||||||
|
setting: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValveLayer = (layerId: string | undefined) =>
|
||||||
|
layerId === "geo_valves_mat" || layerId === "geo_valves";
|
||||||
|
|
||||||
|
const isLinkStatus = (value: unknown): value is LinkStatus =>
|
||||||
|
value === "OPEN" || value === "CLOSED" || value === "ACTIVE";
|
||||||
|
|
||||||
|
const normalizeValveSetting = (value: unknown): string | null => {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateValveSetting = (
|
||||||
|
valveType: string | null,
|
||||||
|
value: string,
|
||||||
|
): string | null => {
|
||||||
|
const normalizedType = valveType?.toUpperCase();
|
||||||
|
const trimmedValue = value.trim();
|
||||||
|
const numericTypes = new Set(["PRV", "PSV", "PBV", "FCV", "TCV"]);
|
||||||
|
|
||||||
|
if (normalizedType === "GPV") {
|
||||||
|
return trimmedValue ? null : "GPV 阀门设置值必须是非空曲线 ID。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numericTypes.has(normalizedType ?? "")) {
|
||||||
|
if (!trimmedValue) {
|
||||||
|
return "阀门设置值必须是 0 或正数。";
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericValue = Number(trimmedValue);
|
||||||
|
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||||
|
return "阀门设置值必须是有限数字,且大于或等于 0。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmedValue ? null : "阀门设置值不能为空。";
|
||||||
|
};
|
||||||
|
|
||||||
const Toolbar: React.FC<ToolbarProps> = ({
|
const Toolbar: React.FC<ToolbarProps> = ({
|
||||||
hiddenButtons,
|
hiddenButtons,
|
||||||
queryType,
|
queryType,
|
||||||
@@ -46,6 +93,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const data = useData();
|
const data = useData();
|
||||||
|
const project = useProject();
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const [activeTools, setActiveTools] = useState<string[]>([]);
|
const [activeTools, setActiveTools] = useState<string[]>([]);
|
||||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||||
@@ -58,6 +106,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
const currentTime = data?.currentTime;
|
const currentTime = data?.currentTime;
|
||||||
const selectedDate = data?.selectedDate;
|
const selectedDate = data?.selectedDate;
|
||||||
const schemeName = data?.schemeName;
|
const schemeName = data?.schemeName;
|
||||||
|
const networkName = project?.networkName || NETWORK_NAME;
|
||||||
const isCompareMode = data?.isCompareMode ?? false;
|
const isCompareMode = data?.isCompareMode ?? false;
|
||||||
const toggleCompareMode = data?.toggleCompareMode;
|
const toggleCompareMode = data?.toggleCompareMode;
|
||||||
const canToggleCompare = Boolean(
|
const canToggleCompare = Boolean(
|
||||||
@@ -89,6 +138,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
const styleEditor = useStyleEditor({
|
const styleEditor = useStyleEditor({
|
||||||
layerStyleStates,
|
layerStyleStates,
|
||||||
setLayerStyleStates,
|
setLayerStyleStates,
|
||||||
|
workspace: project?.workspace || config.MAP_WORKSPACE,
|
||||||
});
|
});
|
||||||
|
|
||||||
useToolbarChatActions({
|
useToolbarChatActions({
|
||||||
@@ -140,6 +190,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
properties: {
|
properties: {
|
||||||
name: "属性查询高亮图层", // 设置图层名称
|
name: "属性查询高亮图层", // 设置图层名称
|
||||||
value: "info_highlight_layer",
|
value: "info_highlight_layer",
|
||||||
|
queryable: false,
|
||||||
type: "multigeometry",
|
type: "multigeometry",
|
||||||
properties: [],
|
properties: [],
|
||||||
},
|
},
|
||||||
@@ -325,6 +376,261 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
const [computedProperties, setComputedProperties] = useState<
|
const [computedProperties, setComputedProperties] = useState<
|
||||||
Record<string, any>
|
Record<string, any>
|
||||||
>({});
|
>({});
|
||||||
|
const [valveStatus, setValveStatus] = useState<LinkStatus | null>(null);
|
||||||
|
const [isValveStatusLoading, setIsValveStatusLoading] = useState(false);
|
||||||
|
const [isValveStatusSaving, setIsValveStatusSaving] = useState(false);
|
||||||
|
const [valveProperties, setValveProperties] = useState<ValveProperties>({
|
||||||
|
vType: null,
|
||||||
|
setting: null,
|
||||||
|
});
|
||||||
|
const [isValvePropertiesLoading, setIsValvePropertiesLoading] =
|
||||||
|
useState(false);
|
||||||
|
const [isValveSettingSaving, setIsValveSettingSaving] = useState(false);
|
||||||
|
|
||||||
|
const selectedFeature = highlightFeatures[0];
|
||||||
|
const selectedFeatureLayer = selectedFeature
|
||||||
|
?.getId()
|
||||||
|
?.toString()
|
||||||
|
.split(".")[0];
|
||||||
|
const selectedFeatureId = selectedFeature?.getProperties?.().id;
|
||||||
|
const selectedFeatureValveType = selectedFeature?.getProperties?.().v_type;
|
||||||
|
const selectedValveType =
|
||||||
|
valveProperties.vType ??
|
||||||
|
(selectedFeatureValveType ? String(selectedFeatureValveType) : null);
|
||||||
|
const selectedValveId =
|
||||||
|
showPropertyPanel && isValveLayer(selectedFeatureLayer) && selectedFeatureId
|
||||||
|
? String(selectedFeatureId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedValveId) {
|
||||||
|
setValveStatus(null);
|
||||||
|
setIsValveStatusLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const queryValveStatus = async () => {
|
||||||
|
setIsValveStatusLoading(true);
|
||||||
|
setValveStatus(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
network: networkName,
|
||||||
|
link: selectedValveId,
|
||||||
|
});
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.BACKEND_URL}/api/v1/getstatus/?${params.toString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`getstatus failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!cancelled) {
|
||||||
|
setValveStatus(isLinkStatus(data?.status) ? data.status : null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error querying valve status:", error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setValveStatus(null);
|
||||||
|
open?.({
|
||||||
|
type: "error",
|
||||||
|
message: "读取阀门开关状态失败。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsValveStatusLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
queryValveStatus();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [networkName, open, selectedValveId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedValveId) {
|
||||||
|
setValveProperties({ vType: null, setting: null });
|
||||||
|
setIsValvePropertiesLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const queryValveProperties = async () => {
|
||||||
|
setIsValvePropertiesLoading(true);
|
||||||
|
setValveProperties({ vType: null, setting: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
network: networkName,
|
||||||
|
valve: selectedValveId,
|
||||||
|
});
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.BACKEND_URL}/api/v1/getvalveproperties/?${params.toString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`getvalveproperties failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!cancelled) {
|
||||||
|
setValveProperties({
|
||||||
|
vType: data?.v_type ? String(data.v_type) : null,
|
||||||
|
setting: normalizeValveSetting(data?.setting),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error querying valve properties:", error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setValveProperties({ vType: null, setting: null });
|
||||||
|
open?.({
|
||||||
|
type: "error",
|
||||||
|
message: "读取阀门设置值失败。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsValvePropertiesLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
queryValveProperties();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [networkName, open, selectedValveId]);
|
||||||
|
|
||||||
|
const handleValveStatusSave = useCallback(
|
||||||
|
async (nextStatus: string) => {
|
||||||
|
if (!selectedValveId || !isLinkStatus(nextStatus)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsValveStatusSaving(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
network: networkName,
|
||||||
|
link: selectedValveId,
|
||||||
|
});
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.BACKEND_URL}/api/v1/setstatus/?${params.toString()}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status: nextStatus }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`setstatus failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setValveStatus(nextStatus);
|
||||||
|
open?.({
|
||||||
|
type: "success",
|
||||||
|
message: "阀门开关状态已更新。",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating valve status:", error);
|
||||||
|
open?.({
|
||||||
|
type: "error",
|
||||||
|
message: "阀门开关状态更新失败。",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsValveStatusSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[networkName, open, selectedValveId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleValveSettingSave = useCallback(
|
||||||
|
async (nextSetting: string) => {
|
||||||
|
if (!selectedValveId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valveStatus === "OPEN" || valveStatus === "CLOSED") {
|
||||||
|
open?.({
|
||||||
|
type: "error",
|
||||||
|
message: "开启或关闭状态下不能编辑阀门设置值。",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationMessage = validateValveSetting(
|
||||||
|
selectedValveType,
|
||||||
|
nextSetting,
|
||||||
|
);
|
||||||
|
if (validationMessage) {
|
||||||
|
open?.({
|
||||||
|
type: "error",
|
||||||
|
message: validationMessage,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedSetting = nextSetting.trim();
|
||||||
|
setIsValveSettingSaving(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
network: networkName,
|
||||||
|
valve: selectedValveId,
|
||||||
|
});
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.BACKEND_URL}/api/v1/setvalveproperties/?${params.toString()}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ setting: trimmedSetting }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`setvalveproperties failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setValveProperties((prev) => ({
|
||||||
|
...prev,
|
||||||
|
setting: trimmedSetting,
|
||||||
|
}));
|
||||||
|
open?.({
|
||||||
|
type: "success",
|
||||||
|
message: "阀门设置值已更新。",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating valve setting:", error);
|
||||||
|
open?.({
|
||||||
|
type: "error",
|
||||||
|
message: "阀门设置值更新失败。",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsValveSettingSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
networkName,
|
||||||
|
open,
|
||||||
|
selectedValveId,
|
||||||
|
selectedValveType,
|
||||||
|
valveStatus,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// 添加 useEffect 来查询计算属性
|
// 添加 useEffect 来查询计算属性
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (highlightFeatures.length === 0 || !selectedDate || !showPropertyPanel) {
|
if (highlightFeatures.length === 0 || !selectedDate || !showPropertyPanel) {
|
||||||
@@ -387,8 +693,43 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
}, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]);
|
}, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]);
|
||||||
|
|
||||||
const propertyPanelData = useMemo(
|
const propertyPanelData = useMemo(
|
||||||
() => buildFeatureProperties(highlightFeatures[0], computedProperties),
|
() =>
|
||||||
[highlightFeatures, computedProperties],
|
buildFeatureProperties(
|
||||||
|
selectedFeature,
|
||||||
|
computedProperties,
|
||||||
|
selectedValveId
|
||||||
|
? {
|
||||||
|
value: valveStatus,
|
||||||
|
loading: isValveStatusLoading,
|
||||||
|
saving: isValveStatusSaving,
|
||||||
|
onSave: handleValveStatusSave,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
selectedValveId
|
||||||
|
? {
|
||||||
|
value: valveProperties.setting,
|
||||||
|
vType: selectedValveType,
|
||||||
|
loading: isValvePropertiesLoading,
|
||||||
|
saving: isValveSettingSaving,
|
||||||
|
status: valveStatus,
|
||||||
|
onSave: handleValveSettingSave,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
[
|
||||||
|
selectedFeature,
|
||||||
|
computedProperties,
|
||||||
|
selectedValveId,
|
||||||
|
valveStatus,
|
||||||
|
valveProperties,
|
||||||
|
selectedValveType,
|
||||||
|
isValveStatusLoading,
|
||||||
|
isValveStatusSaving,
|
||||||
|
isValvePropertiesLoading,
|
||||||
|
isValveSettingSaving,
|
||||||
|
handleValveStatusSave,
|
||||||
|
handleValveSettingSave,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -463,10 +804,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
onClassificationMethodChange={styleEditor.handleClassificationMethodChange}
|
onClassificationMethodChange={styleEditor.handleClassificationMethodChange}
|
||||||
onSegmentsChange={styleEditor.handleSegmentsChange}
|
onSegmentsChange={styleEditor.handleSegmentsChange}
|
||||||
onCustomBreakChange={styleEditor.handleCustomBreakChange}
|
onCustomBreakChange={styleEditor.handleCustomBreakChange}
|
||||||
onCustomBreakBlur={styleEditor.handleCustomBreakBlur}
|
|
||||||
onColorTypeChange={styleEditor.handleColorTypeChange}
|
onColorTypeChange={styleEditor.handleColorTypeChange}
|
||||||
onApply={styleEditor.handleApply}
|
onApply={styleEditor.handleApply}
|
||||||
onReset={styleEditor.handleReset}
|
onReset={styleEditor.handleReset}
|
||||||
|
validationErrors={styleEditor.validationErrors}
|
||||||
|
isDirty={styleEditor.isDirty}
|
||||||
|
isApplying={styleEditor.isApplying}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ToolbarHistoryPanel
|
<ToolbarHistoryPanel
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const DEFAULT_LAYER_STYLE_PRESETS: Record<
|
|||||||
styleConfig: {
|
styleConfig: {
|
||||||
property: "pressure",
|
property: "pressure",
|
||||||
classificationMethod: "custom_breaks",
|
classificationMethod: "custom_breaks",
|
||||||
customBreaks: [16, 18, 20, 22, 24, 26],
|
customBreaks: [16, 18, 20, 22, 24, 26, 28],
|
||||||
customColors: [
|
customColors: [
|
||||||
"rgba(255, 0, 0, 1)",
|
"rgba(255, 0, 0, 1)",
|
||||||
"rgba(255, 127, 0, 1)",
|
"rgba(255, 127, 0, 1)",
|
||||||
@@ -137,7 +137,7 @@ const DEFAULT_LAYER_STYLE_PRESETS: Record<
|
|||||||
showId: false,
|
showId: false,
|
||||||
opacity: 0.9,
|
opacity: 0.9,
|
||||||
adjustWidthByProperty: true,
|
adjustWidthByProperty: true,
|
||||||
customBreaks: [0.2, 0.4, 0.6, 0.8, 1.0, 1.2],
|
customBreaks: [0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4],
|
||||||
customColors: [],
|
customColors: [],
|
||||||
},
|
},
|
||||||
legendConfig: {
|
legendConfig: {
|
||||||
|
|||||||
@@ -3,16 +3,20 @@ import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
|||||||
|
|
||||||
import { LegendStyleConfig } from "./StyleLegend";
|
import { LegendStyleConfig } from "./StyleLegend";
|
||||||
|
|
||||||
|
export type ClassificationMethod = "pretty_breaks" | "custom_breaks";
|
||||||
|
export type ColorType = "single" | "gradient" | "rainbow" | "custom";
|
||||||
|
|
||||||
export interface StyleConfig {
|
export interface StyleConfig {
|
||||||
property: string;
|
property: string;
|
||||||
classificationMethod: string;
|
classificationMethod: ClassificationMethod;
|
||||||
|
/** Number of rendered intervals. Boundaries always contain segments + 1 values. */
|
||||||
segments: number;
|
segments: number;
|
||||||
minSize: number;
|
minSize: number;
|
||||||
maxSize: number;
|
maxSize: number;
|
||||||
minStrokeWidth: number;
|
minStrokeWidth: number;
|
||||||
maxStrokeWidth: number;
|
maxStrokeWidth: number;
|
||||||
fixedStrokeWidth: number;
|
fixedStrokeWidth: number;
|
||||||
colorType: string;
|
colorType: ColorType;
|
||||||
singlePaletteIndex: number;
|
singlePaletteIndex: number;
|
||||||
gradientPaletteIndex: number;
|
gradientPaletteIndex: number;
|
||||||
rainbowPaletteIndex: number;
|
rainbowPaletteIndex: number;
|
||||||
@@ -24,6 +28,19 @@ export interface StyleConfig {
|
|||||||
customColors?: string[];
|
customColors?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResolvedLayerStyle {
|
||||||
|
boundaries: number[];
|
||||||
|
colors: string[];
|
||||||
|
dimensions: number[];
|
||||||
|
labels: string[];
|
||||||
|
isConstant: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StyleValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface LayerStyleState {
|
export interface LayerStyleState {
|
||||||
layerId: string;
|
layerId: string;
|
||||||
layerName: string;
|
layerName: string;
|
||||||
@@ -37,6 +54,7 @@ export type DefaultLayerStyleId = "junctions" | "pipes";
|
|||||||
export interface StyleEditorStateProps {
|
export interface StyleEditorStateProps {
|
||||||
layerStyleStates: LayerStyleState[];
|
layerStyleStates: LayerStyleState[];
|
||||||
setLayerStyleStates: React.Dispatch<React.SetStateAction<LayerStyleState[]>>;
|
setLayerStyleStates: React.Dispatch<React.SetStateAction<LayerStyleState[]>>;
|
||||||
|
workspace: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AvailableProperty {
|
export interface AvailableProperty {
|
||||||
@@ -50,15 +68,17 @@ export interface StyleEditorFormProps {
|
|||||||
styleConfig: StyleConfig;
|
styleConfig: StyleConfig;
|
||||||
setStyleConfig: React.Dispatch<React.SetStateAction<StyleConfig>>;
|
setStyleConfig: React.Dispatch<React.SetStateAction<StyleConfig>>;
|
||||||
availableProperties: AvailableProperty[];
|
availableProperties: AvailableProperty[];
|
||||||
onLayerChange: (index: number) => void;
|
onLayerChange: (layerId: string) => void;
|
||||||
onPropertyChange: (property: string) => void;
|
onPropertyChange: (property: string) => void;
|
||||||
onClassificationMethodChange: (method: string) => void;
|
onClassificationMethodChange: (method: ClassificationMethod) => void;
|
||||||
onSegmentsChange: (segments: number) => void;
|
onSegmentsChange: (segments: number) => void;
|
||||||
onCustomBreakChange: (index: number, value: string) => void;
|
onCustomBreakChange: (index: number, value: string) => void;
|
||||||
onCustomBreakBlur: () => void;
|
onColorTypeChange: (colorType: ColorType) => void;
|
||||||
onColorTypeChange: (colorType: string) => void;
|
|
||||||
onApply: () => void;
|
onApply: () => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
|
validationErrors: string[];
|
||||||
|
isDirty: boolean;
|
||||||
|
isApplying: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StyleEditorPanelProps extends StyleEditorFormProps {
|
export interface StyleEditorPanelProps extends StyleEditorFormProps {
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { createDefaultLayerStyleState } from "./styleEditorPresets";
|
||||||
|
import {
|
||||||
|
buildDynamicStyleTemplate,
|
||||||
|
buildStyleVariables,
|
||||||
|
getDefaultCustomBreaks,
|
||||||
|
hydrateStoredLayerStyleStates,
|
||||||
|
resolveLayerStyle,
|
||||||
|
requiresStyleApply,
|
||||||
|
selectStoredLayerStyles,
|
||||||
|
validateStyleConfig,
|
||||||
|
} from "./styleEditorUtils";
|
||||||
|
|
||||||
|
describe("styleEditorUtils", () => {
|
||||||
|
it("uses N intervals, N+1 boundaries and N visual values", () => {
|
||||||
|
const styleConfig = {
|
||||||
|
...createDefaultLayerStyleState("pipes").styleConfig,
|
||||||
|
classificationMethod: "pretty_breaks" as const,
|
||||||
|
segments: 4,
|
||||||
|
};
|
||||||
|
const resolved = resolveLayerStyle({
|
||||||
|
layerType: "linestring",
|
||||||
|
styleConfig,
|
||||||
|
values: [0, 1, 2, 3, 4, 5],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved?.boundaries).toHaveLength(5);
|
||||||
|
expect(resolved?.colors).toHaveLength(4);
|
||||||
|
expect(resolved?.dimensions).toHaveLength(4);
|
||||||
|
expect(resolved?.labels).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts signed custom boundaries and rejects unordered boundaries", () => {
|
||||||
|
const base = createDefaultLayerStyleState("junctions").styleConfig;
|
||||||
|
const valid = {
|
||||||
|
...base,
|
||||||
|
segments: 3,
|
||||||
|
customBreaks: [-5, 0, 5, 10],
|
||||||
|
customColors: base.customColors?.slice(0, 3),
|
||||||
|
};
|
||||||
|
expect(validateStyleConfig(valid)).toEqual({ valid: true, errors: [] });
|
||||||
|
expect(
|
||||||
|
validateStyleConfig({ ...valid, customBreaks: [-5, 0, 0, 10] }).valid,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates deterministic defaults and collapses constant data labels", () => {
|
||||||
|
const defaults = getDefaultCustomBreaks({
|
||||||
|
segments: 3,
|
||||||
|
property: "pressure",
|
||||||
|
layerId: "junctions",
|
||||||
|
currentJunctionCalData: [{ value: 20 }, { value: 20 }],
|
||||||
|
});
|
||||||
|
expect(defaults).toHaveLength(4);
|
||||||
|
expect(
|
||||||
|
defaults.every(
|
||||||
|
(value, index) => index === 0 || value > defaults[index - 1],
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const styleConfig = {
|
||||||
|
...createDefaultLayerStyleState("junctions").styleConfig,
|
||||||
|
classificationMethod: "pretty_breaks" as const,
|
||||||
|
segments: 3,
|
||||||
|
};
|
||||||
|
const resolved = resolveLayerStyle({
|
||||||
|
layerType: "point",
|
||||||
|
styleConfig,
|
||||||
|
values: [20, 20],
|
||||||
|
});
|
||||||
|
expect(resolved?.isConstant).toBe(true);
|
||||||
|
expect(resolved?.labels).toEqual(["20"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates variable-only visual templates with shader-safe names", () => {
|
||||||
|
const styleConfig = createDefaultLayerStyleState("pipes").styleConfig;
|
||||||
|
const resolved = resolveLayerStyle({
|
||||||
|
layerType: "linestring",
|
||||||
|
styleConfig,
|
||||||
|
values: [],
|
||||||
|
});
|
||||||
|
expect(resolved).not.toBeNull();
|
||||||
|
const template = buildDynamicStyleTemplate({
|
||||||
|
layerType: "linestring",
|
||||||
|
property: styleConfig.property,
|
||||||
|
classCount: styleConfig.segments,
|
||||||
|
});
|
||||||
|
const variables = buildStyleVariables(styleConfig, resolved!);
|
||||||
|
const serialized = JSON.stringify({ template, variables });
|
||||||
|
expect(serialized).toContain("tj_color_0");
|
||||||
|
expect(serialized).not.toContain("__");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires Apply only for structural changes", () => {
|
||||||
|
const applied = createDefaultLayerStyleState("pipes").styleConfig;
|
||||||
|
expect(requiresStyleApply(applied, { ...applied, opacity: 0.4 })).toBe(false);
|
||||||
|
expect(
|
||||||
|
requiresStyleApply(applied, { ...applied, minStrokeWidth: 1 }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
requiresStyleApply(applied, { ...applied, property: "flow" }),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
requiresStyleApply(applied, {
|
||||||
|
...applied,
|
||||||
|
customBreaks: [...(applied.customBreaks || []), 2],
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hydrates workspace settings as inactive drafts", () => {
|
||||||
|
const storedPipeStyle = {
|
||||||
|
...createDefaultLayerStyleState("pipes").styleConfig,
|
||||||
|
property: "flow",
|
||||||
|
showLabels: false,
|
||||||
|
};
|
||||||
|
const restored = hydrateStoredLayerStyleStates(
|
||||||
|
{
|
||||||
|
version: 2,
|
||||||
|
layers: { pipes: storedPipeStyle },
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(restored.find((state) => state.layerId === "pipes")?.styleConfig).toEqual(
|
||||||
|
storedPipeStyle,
|
||||||
|
);
|
||||||
|
expect(restored.every((state) => !state.isActive)).toBe(true);
|
||||||
|
expect(selectStoredLayerStyles(restored).pipes).toEqual(storedPipeStyle);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,23 +1,44 @@
|
|||||||
import { FlatStyleLike } from "ol/style/flat";
|
import type { FlatStyleLike, StyleVariables } from "ol/style/flat";
|
||||||
|
|
||||||
import { calculateClassification } from "@utils/breaksClassification";
|
import { calculateClassification } from "@utils/breaksClassification";
|
||||||
import { parseColor } from "@utils/parseColor";
|
import { parseColor } from "@utils/parseColor";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
createDefaultLayerStyleStates,
|
||||||
GRADIENT_PALETTES,
|
GRADIENT_PALETTES,
|
||||||
RAINBOW_PALETTES,
|
RAINBOW_PALETTES,
|
||||||
SINGLE_COLOR_PALETTES,
|
SINGLE_COLOR_PALETTES,
|
||||||
} from "./styleEditorPresets";
|
} from "./styleEditorPresets";
|
||||||
import { StyleConfig } from "./styleEditorTypes";
|
import type {
|
||||||
|
DefaultLayerStyleId,
|
||||||
|
LayerStyleState,
|
||||||
|
ResolvedLayerStyle,
|
||||||
|
StyleConfig,
|
||||||
|
StyleValidationResult,
|
||||||
|
} from "./styleEditorTypes";
|
||||||
|
|
||||||
|
export const MIN_CLASS_COUNT = 2;
|
||||||
|
export const MAX_CLASS_COUNT = 10;
|
||||||
|
|
||||||
|
const clampIndex = (value: number, length: number) =>
|
||||||
|
Math.min(Math.max(Math.round(value) || 0, 0), Math.max(length - 1, 0));
|
||||||
|
|
||||||
|
const arraysEqual = <T>(left: readonly T[] = [], right: readonly T[] = []) =>
|
||||||
|
left.length === right.length && left.every((value, index) => value === right[index]);
|
||||||
|
|
||||||
|
const withOpacity = (color: string, opacity: number) => {
|
||||||
|
const parsed = parseColor(color);
|
||||||
|
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${opacity})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBoundary = (value: number) =>
|
||||||
|
Number.isInteger(value) ? String(value) : Number(value.toFixed(3)).toString();
|
||||||
|
|
||||||
export const rgbaToHex = (rgba: string) => {
|
export const rgbaToHex = (rgba: string) => {
|
||||||
try {
|
try {
|
||||||
const c = parseColor(rgba);
|
const color = parseColor(rgba);
|
||||||
const toHex = (n: number) => {
|
const toHex = (value: number) => Math.round(value).toString(16).padStart(2, "0");
|
||||||
const hex = Math.round(n).toString(16);
|
return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`;
|
||||||
return hex.length === 1 ? `0${hex}` : hex;
|
|
||||||
};
|
|
||||||
return `#${toHex(c.r)}${toHex(c.g)}${toHex(c.b)}`;
|
|
||||||
} catch {
|
} catch {
|
||||||
return "#000000";
|
return "#000000";
|
||||||
}
|
}
|
||||||
@@ -28,25 +49,71 @@ export const hexToRgba = (hex: string) => {
|
|||||||
return result
|
return result
|
||||||
? `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(
|
? `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(
|
||||||
result[3],
|
result[3],
|
||||||
16
|
16,
|
||||||
)}, 1)`
|
)}, 1)`
|
||||||
: "rgba(0, 0, 0, 1)";
|
: "rgba(0, 0, 0, 1)";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDefaultCustomColors = (
|
export const getDefaultCustomColors = (
|
||||||
segments: number,
|
segments: number,
|
||||||
existingColors: string[] = []
|
existingColors: string[] = [],
|
||||||
) => {
|
) => {
|
||||||
const nextColors = [...existingColors];
|
const nextColors = [...existingColors];
|
||||||
const baseColors = RAINBOW_PALETTES[0].colors;
|
const baseColors = RAINBOW_PALETTES[0].colors;
|
||||||
|
|
||||||
while (nextColors.length < segments) {
|
while (nextColors.length < segments) {
|
||||||
nextColors.push(baseColors[nextColors.length % baseColors.length]);
|
nextColors.push(baseColors[nextColors.length % baseColors.length]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextColors.slice(0, segments);
|
return nextColors.slice(0, segments);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createEqualBoundaries = (minimum: number, maximum: number, segments: number) => {
|
||||||
|
if (minimum === maximum) {
|
||||||
|
return Array.from({ length: segments + 1 }, () => minimum);
|
||||||
|
}
|
||||||
|
return Array.from(
|
||||||
|
{ length: segments + 1 },
|
||||||
|
(_, index) => minimum + ((maximum - minimum) * index) / segments,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeCalculatedBoundaries = (
|
||||||
|
calculated: number[],
|
||||||
|
values: number[],
|
||||||
|
segments: number,
|
||||||
|
) => {
|
||||||
|
const finiteValues = values.filter(Number.isFinite).sort((a, b) => a - b);
|
||||||
|
if (finiteValues.length === 0) return [];
|
||||||
|
const minimum = finiteValues[0];
|
||||||
|
const maximum = finiteValues[finiteValues.length - 1];
|
||||||
|
if (minimum === maximum) return createEqualBoundaries(minimum, maximum, segments);
|
||||||
|
|
||||||
|
const candidates = Array.from(
|
||||||
|
new Set([minimum, ...calculated.filter(Number.isFinite), maximum]),
|
||||||
|
)
|
||||||
|
.filter((value) => value >= minimum && value <= maximum)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
if (candidates.length === segments + 1) return candidates;
|
||||||
|
return createEqualBoundaries(minimum, maximum, segments);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveBoundaries = (
|
||||||
|
values: number[],
|
||||||
|
styleConfig: StyleConfig,
|
||||||
|
): number[] => {
|
||||||
|
const segments = Math.min(
|
||||||
|
MAX_CLASS_COUNT,
|
||||||
|
Math.max(MIN_CLASS_COUNT, Math.round(styleConfig.segments)),
|
||||||
|
);
|
||||||
|
if (styleConfig.classificationMethod === "custom_breaks") {
|
||||||
|
return [...(styleConfig.customBreaks || [])];
|
||||||
|
}
|
||||||
|
const finiteValues = values.filter(Number.isFinite);
|
||||||
|
if (finiteValues.length === 0) return [];
|
||||||
|
const calculated = calculateClassification(finiteValues, segments, "pretty_breaks");
|
||||||
|
return normalizeCalculatedBoundaries(calculated, finiteValues, segments);
|
||||||
|
};
|
||||||
|
|
||||||
export const getDefaultCustomBreaks = ({
|
export const getDefaultCustomBreaks = ({
|
||||||
segments,
|
segments,
|
||||||
property,
|
property,
|
||||||
@@ -64,261 +131,293 @@ export const getDefaultCustomBreaks = ({
|
|||||||
currentJunctionCalData?: any[];
|
currentJunctionCalData?: any[];
|
||||||
currentPipeCalData?: any[];
|
currentPipeCalData?: any[];
|
||||||
}) => {
|
}) => {
|
||||||
if (!layerId || !property) {
|
let values: number[] = [];
|
||||||
return Array.from({ length: segments }, () => 0);
|
if (layerId === "junctions" && property === "elevation" && elevationRange) {
|
||||||
|
values = elevationRange;
|
||||||
|
} else if (layerId === "pipes" && property === "diameter" && diameterRange) {
|
||||||
|
values = diameterRange;
|
||||||
|
} else if (layerId === "junctions") {
|
||||||
|
values = (currentJunctionCalData || []).map((item: any) => Number(item.value));
|
||||||
|
} else if (layerId === "pipes") {
|
||||||
|
values = (currentPipeCalData || []).map((item: any) => Number(item.value));
|
||||||
}
|
}
|
||||||
|
const finiteValues = values.filter(Number.isFinite);
|
||||||
let dataArr: number[] = [];
|
if (!property || finiteValues.length === 0) {
|
||||||
|
return Array.from({ length: segments + 1 }, (_, index) => index);
|
||||||
const isElevation = layerId === "junctions" && property === "elevation";
|
|
||||||
const isDiameter = layerId === "pipes" && property === "diameter";
|
|
||||||
|
|
||||||
if (isElevation && elevationRange) {
|
|
||||||
dataArr = [elevationRange[0], elevationRange[1]];
|
|
||||||
} else if (isDiameter && diameterRange) {
|
|
||||||
dataArr = [diameterRange[0], diameterRange[1]];
|
|
||||||
} else if (layerId === "junctions" && currentJunctionCalData) {
|
|
||||||
dataArr = currentJunctionCalData.map((d: any) => d.value);
|
|
||||||
} else if (layerId === "pipes" && currentPipeCalData) {
|
|
||||||
dataArr = currentPipeCalData.map((d: any) => d.value);
|
|
||||||
}
|
}
|
||||||
|
const minimum = Math.min(...finiteValues);
|
||||||
if (dataArr.length === 0) {
|
const maximum = Math.max(...finiteValues);
|
||||||
return Array.from({ length: segments }, () => 0);
|
if (minimum === maximum) {
|
||||||
|
const padding = Math.max(Math.abs(minimum) * 0.01, 1);
|
||||||
|
return createEqualBoundaries(minimum - padding, maximum + padding, segments);
|
||||||
}
|
}
|
||||||
|
return normalizeCalculatedBoundaries(
|
||||||
const defaultBreaks = calculateClassification(
|
calculateClassification(finiteValues, segments, "pretty_breaks"),
|
||||||
dataArr,
|
finiteValues,
|
||||||
segments,
|
segments,
|
||||||
"pretty_breaks"
|
|
||||||
).slice(0, segments);
|
|
||||||
|
|
||||||
while (defaultBreaks.length < segments) {
|
|
||||||
defaultBreaks.push(defaultBreaks[defaultBreaks.length - 1] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultBreaks;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const normalizeCustomBreaks = (breaks: number[], desired: number) => {
|
|
||||||
const nextBreaks = [...breaks]
|
|
||||||
.slice(0, desired)
|
|
||||||
.filter((value) => value >= 0)
|
|
||||||
.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
while (nextBreaks.length < desired) {
|
|
||||||
nextBreaks.push(nextBreaks[nextBreaks.length - 1] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextBreaks;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const addBreakExtrema = (breaks: number[], dataValues: number[]) => {
|
|
||||||
const nextBreaks = [...breaks];
|
|
||||||
const minValue = Math.max(
|
|
||||||
dataValues.reduce((min, value) => Math.min(min, value), Infinity),
|
|
||||||
0
|
|
||||||
);
|
);
|
||||||
const maxValue = dataValues.reduce(
|
|
||||||
(max, value) => Math.max(max, value),
|
|
||||||
-Infinity
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!nextBreaks.includes(minValue)) {
|
|
||||||
nextBreaks.push(minValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nextBreaks.includes(maxValue)) {
|
|
||||||
nextBreaks.push(maxValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
nextBreaks.sort((a, b) => a - b);
|
|
||||||
return nextBreaks;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const normalizeCustomBreaks = (breaks: number[], segments: number) => {
|
||||||
|
const finite = breaks.filter(Number.isFinite).slice(0, segments + 1);
|
||||||
|
if (finite.length === segments + 1) return finite;
|
||||||
|
if (finite.length >= 2) {
|
||||||
|
return createEqualBoundaries(finite[0], finite[finite.length - 1], segments);
|
||||||
|
}
|
||||||
|
return Array.from({ length: segments + 1 }, (_, index) => finite[0] ?? index);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateStyleConfig = (styleConfig: StyleConfig): StyleValidationResult => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (!styleConfig.property) errors.push("请选择分级属性");
|
||||||
|
if (
|
||||||
|
!Number.isInteger(styleConfig.segments) ||
|
||||||
|
styleConfig.segments < MIN_CLASS_COUNT ||
|
||||||
|
styleConfig.segments > MAX_CLASS_COUNT
|
||||||
|
) {
|
||||||
|
errors.push(`分类数量必须是 ${MIN_CLASS_COUNT}-${MAX_CLASS_COUNT} 的整数`);
|
||||||
|
}
|
||||||
|
if (styleConfig.classificationMethod === "custom_breaks") {
|
||||||
|
const boundaries = styleConfig.customBreaks || [];
|
||||||
|
if (boundaries.length !== styleConfig.segments + 1) {
|
||||||
|
errors.push(`需要 ${styleConfig.segments + 1} 个区间边界`);
|
||||||
|
} else if (boundaries.some((value) => !Number.isFinite(value))) {
|
||||||
|
errors.push("区间边界必须是有限数字");
|
||||||
|
} else if (boundaries.some((value, index) => index > 0 && value <= boundaries[index - 1])) {
|
||||||
|
errors.push("区间边界必须严格递增");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (styleConfig.colorType === "custom") {
|
||||||
|
const colors = styleConfig.customColors || [];
|
||||||
|
if (colors.length !== styleConfig.segments) {
|
||||||
|
errors.push(`需要 ${styleConfig.segments} 个自定义颜色`);
|
||||||
|
} else if (colors.some((color) => {
|
||||||
|
try {
|
||||||
|
parseColor(color);
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
})) {
|
||||||
|
errors.push("自定义颜色格式无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(styleConfig.opacity) || styleConfig.opacity < 0 || styleConfig.opacity > 1) {
|
||||||
|
errors.push("透明度必须在 0 到 1 之间");
|
||||||
|
}
|
||||||
|
const paletteIndexes: Array<[number, number]> = [
|
||||||
|
[styleConfig.singlePaletteIndex, SINGLE_COLOR_PALETTES.length],
|
||||||
|
[styleConfig.gradientPaletteIndex, GRADIENT_PALETTES.length],
|
||||||
|
[styleConfig.rainbowPaletteIndex, RAINBOW_PALETTES.length],
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
paletteIndexes.some(
|
||||||
|
([index, length]) => !Number.isInteger(index) || index < 0 || index >= length,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
errors.push("色板索引无效");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
styleConfig.minSize,
|
||||||
|
styleConfig.maxSize,
|
||||||
|
styleConfig.minStrokeWidth,
|
||||||
|
styleConfig.maxStrokeWidth,
|
||||||
|
styleConfig.fixedStrokeWidth,
|
||||||
|
].some((value) => !Number.isFinite(value) || value <= 0)
|
||||||
|
) {
|
||||||
|
errors.push("符号尺寸必须大于 0");
|
||||||
|
}
|
||||||
|
if (styleConfig.minSize > styleConfig.maxSize) errors.push("节点最小尺寸不能大于最大尺寸");
|
||||||
|
if (styleConfig.minStrokeWidth > styleConfig.maxStrokeWidth) {
|
||||||
|
errors.push("管线最小宽度不能大于最大宽度");
|
||||||
|
}
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hydrateStoredLayerStyleStates = (
|
||||||
|
document: unknown,
|
||||||
|
expectedVersion: number,
|
||||||
|
): LayerStyleState[] => {
|
||||||
|
const defaults = createDefaultLayerStyleStates();
|
||||||
|
if (!document || typeof document !== "object") return defaults;
|
||||||
|
|
||||||
|
const storedDocument = document as {
|
||||||
|
version?: number;
|
||||||
|
layers?: Partial<Record<DefaultLayerStyleId, StyleConfig>>;
|
||||||
|
};
|
||||||
|
if (storedDocument.version !== expectedVersion || !storedDocument.layers) {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaults.map((state) => {
|
||||||
|
const stored = storedDocument.layers?.[state.layerId as DefaultLayerStyleId];
|
||||||
|
if (!stored || !validateStyleConfig(stored).valid) return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
styleConfig: {
|
||||||
|
...stored,
|
||||||
|
customBreaks: [...(stored.customBreaks || [])],
|
||||||
|
customColors: [...(stored.customColors || [])],
|
||||||
|
},
|
||||||
|
legendConfig: { ...state.legendConfig, property: stored.property },
|
||||||
|
isActive: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectStoredLayerStyles = (states: LayerStyleState[]) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
states
|
||||||
|
.filter((state) => state.layerId === "junctions" || state.layerId === "pipes")
|
||||||
|
.map((state) => [state.layerId, state.styleConfig]),
|
||||||
|
) as Partial<Record<DefaultLayerStyleId, StyleConfig>>;
|
||||||
|
|
||||||
|
export const requiresStyleApply = (
|
||||||
|
applied: StyleConfig | undefined,
|
||||||
|
draft: StyleConfig,
|
||||||
|
) =>
|
||||||
|
!applied ||
|
||||||
|
applied.property !== draft.property ||
|
||||||
|
applied.classificationMethod !== draft.classificationMethod ||
|
||||||
|
applied.segments !== draft.segments ||
|
||||||
|
(draft.classificationMethod === "custom_breaks" &&
|
||||||
|
!arraysEqual(applied.customBreaks, draft.customBreaks));
|
||||||
|
|
||||||
export const resolveStyleColors = (
|
export const resolveStyleColors = (
|
||||||
styleConfig: StyleConfig,
|
styleConfig: StyleConfig,
|
||||||
breaksLength: number
|
classCount = styleConfig.segments,
|
||||||
): string[] => {
|
): string[] => {
|
||||||
if (styleConfig.colorType === "single") {
|
if (styleConfig.colorType === "single") {
|
||||||
return Array.from(
|
const palette = SINGLE_COLOR_PALETTES[
|
||||||
{ length: breaksLength },
|
clampIndex(styleConfig.singlePaletteIndex, SINGLE_COLOR_PALETTES.length)
|
||||||
() => SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color
|
];
|
||||||
);
|
return Array.from({ length: classCount }, () => palette.color);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (styleConfig.colorType === "gradient") {
|
if (styleConfig.colorType === "gradient") {
|
||||||
const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex];
|
const palette = GRADIENT_PALETTES[
|
||||||
const startColor = parseColor(start);
|
clampIndex(styleConfig.gradientPaletteIndex, GRADIENT_PALETTES.length)
|
||||||
const endColor = parseColor(end);
|
];
|
||||||
|
const start = parseColor(palette.start);
|
||||||
return Array.from({ length: breaksLength }, (_, index) => {
|
const end = parseColor(palette.end);
|
||||||
const ratio = breaksLength > 1 ? index / (breaksLength - 1) : 1;
|
return Array.from({ length: classCount }, (_, index) => {
|
||||||
const r = Math.round(startColor.r + (endColor.r - startColor.r) * ratio);
|
const ratio = classCount > 1 ? index / (classCount - 1) : 0;
|
||||||
const g = Math.round(startColor.g + (endColor.g - startColor.g) * ratio);
|
return `rgba(${Math.round(start.r + (end.r - start.r) * ratio)}, ${Math.round(
|
||||||
const b = Math.round(startColor.b + (endColor.b - startColor.b) * ratio);
|
start.g + (end.g - start.g) * ratio,
|
||||||
return `rgba(${r}, ${g}, ${b}, 1)`;
|
)}, ${Math.round(start.b + (end.b - start.b) * ratio)}, 1)`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (styleConfig.colorType === "rainbow") {
|
if (styleConfig.colorType === "rainbow") {
|
||||||
const baseColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors;
|
const palette = RAINBOW_PALETTES[
|
||||||
return Array.from(
|
clampIndex(styleConfig.rainbowPaletteIndex, RAINBOW_PALETTES.length)
|
||||||
{ length: breaksLength },
|
|
||||||
(_, index) => baseColors[index % baseColors.length]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const customColors = styleConfig.customColors || [];
|
|
||||||
const reverseRainbowColors = RAINBOW_PALETTES[1].colors;
|
|
||||||
const result = [...customColors];
|
|
||||||
|
|
||||||
while (result.length < breaksLength) {
|
|
||||||
result.push(
|
|
||||||
reverseRainbowColors[
|
|
||||||
(result.length - customColors.length) % reverseRainbowColors.length
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.slice(0, breaksLength);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSizePreviewColors = (styleConfig: StyleConfig) => {
|
|
||||||
if (styleConfig.colorType === "single") {
|
|
||||||
const color = SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color;
|
|
||||||
return [color, color];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (styleConfig.colorType === "gradient") {
|
|
||||||
const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex];
|
|
||||||
return [start, end];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (styleConfig.colorType === "rainbow") {
|
|
||||||
const rainbowColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors;
|
|
||||||
return [rainbowColors[0], rainbowColors[rainbowColors.length - 1]];
|
|
||||||
}
|
|
||||||
|
|
||||||
const customColors = styleConfig.customColors || [];
|
|
||||||
return [
|
|
||||||
customColors[0] || "rgba(0,0,0,1)",
|
|
||||||
customColors[customColors.length - 1] || "rgba(0,0,0,1)",
|
|
||||||
];
|
];
|
||||||
|
return Array.from(
|
||||||
|
{ length: classCount },
|
||||||
|
(_, index) => palette.colors[index % palette.colors.length],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return getDefaultCustomColors(classCount, styleConfig.customColors || []);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveDimensions = ({
|
export const resolveDimensions = ({
|
||||||
layerType,
|
layerType,
|
||||||
styleConfig,
|
styleConfig,
|
||||||
breaksLength,
|
classCount = styleConfig.segments,
|
||||||
}: {
|
}: {
|
||||||
layerType: string;
|
layerType: string;
|
||||||
styleConfig: StyleConfig;
|
styleConfig: StyleConfig;
|
||||||
breaksLength: number;
|
classCount?: number;
|
||||||
}) => {
|
}) => {
|
||||||
|
const interpolate = (minimum: number, maximum: number, index: number) => {
|
||||||
|
const ratio = classCount > 1 ? index / (classCount - 1) : 0;
|
||||||
|
return minimum + (maximum - minimum) * ratio;
|
||||||
|
};
|
||||||
if (layerType === "linestring") {
|
if (layerType === "linestring") {
|
||||||
if (styleConfig.adjustWidthByProperty) {
|
return Array.from({ length: classCount }, (_, index) =>
|
||||||
return Array.from({ length: breaksLength }, (_, index) => {
|
styleConfig.adjustWidthByProperty
|
||||||
const ratio = index / (breaksLength - 1);
|
? interpolate(styleConfig.minStrokeWidth, styleConfig.maxStrokeWidth, index)
|
||||||
return (
|
: styleConfig.fixedStrokeWidth,
|
||||||
styleConfig.minStrokeWidth +
|
|
||||||
(styleConfig.maxStrokeWidth - styleConfig.minStrokeWidth) * ratio
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(
|
|
||||||
{ length: breaksLength },
|
|
||||||
() => styleConfig.fixedStrokeWidth
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return Array.from({ length: classCount }, (_, index) =>
|
||||||
return Array.from({ length: breaksLength }, (_, index) => {
|
interpolate(styleConfig.minSize, styleConfig.maxSize, index),
|
||||||
const ratio = index / (breaksLength - 1);
|
);
|
||||||
return styleConfig.minSize + (styleConfig.maxSize - styleConfig.minSize) * ratio;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildDynamicStyle = ({
|
export const resolveLayerStyle = ({
|
||||||
layerType,
|
layerType,
|
||||||
styleConfig,
|
styleConfig,
|
||||||
breaks,
|
values,
|
||||||
colors,
|
|
||||||
dimensions,
|
|
||||||
}: {
|
}: {
|
||||||
layerType: string;
|
layerType: string;
|
||||||
styleConfig: StyleConfig;
|
styleConfig: StyleConfig;
|
||||||
breaks: number[];
|
values: number[];
|
||||||
colors: string[];
|
}): ResolvedLayerStyle | null => {
|
||||||
dimensions: number[];
|
const boundaries = resolveBoundaries(values, styleConfig);
|
||||||
|
if (boundaries.length !== styleConfig.segments + 1) return null;
|
||||||
|
const colors = resolveStyleColors(styleConfig, styleConfig.segments);
|
||||||
|
const dimensions = resolveDimensions({ layerType, styleConfig });
|
||||||
|
const isConstant = boundaries.every((value) => value === boundaries[0]);
|
||||||
|
const labels = isConstant
|
||||||
|
? [`${formatBoundary(boundaries[0])}`]
|
||||||
|
: Array.from(
|
||||||
|
{ length: styleConfig.segments },
|
||||||
|
(_, index) => `${formatBoundary(boundaries[index])} - ${formatBoundary(boundaries[index + 1])}`,
|
||||||
|
);
|
||||||
|
return { boundaries, colors, dimensions, labels, isConstant };
|
||||||
|
};
|
||||||
|
|
||||||
|
const valueExpression = (property: string): any[] => ["get", property];
|
||||||
|
|
||||||
|
const buildVariableCase = (property: string, classCount: number, variablePrefix: string) => {
|
||||||
|
const expression: any[] = ["case"];
|
||||||
|
for (let index = 0; index < classCount - 1; index += 1) {
|
||||||
|
expression.push(
|
||||||
|
["<=", valueExpression(property), ["var", `tj_break_${index + 1}`]],
|
||||||
|
["var", `${variablePrefix}_${index}`],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expression.push(["var", `${variablePrefix}_${classCount - 1}`]);
|
||||||
|
return expression;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildDynamicStyleTemplate = ({
|
||||||
|
layerType,
|
||||||
|
property,
|
||||||
|
classCount,
|
||||||
|
}: {
|
||||||
|
layerType: string;
|
||||||
|
property: string;
|
||||||
|
classCount: number;
|
||||||
}): FlatStyleLike => {
|
}): FlatStyleLike => {
|
||||||
const generateColorConditions = (property: string): any[] => {
|
const color = buildVariableCase(property, classCount, "tj_color");
|
||||||
const conditions: any[] = ["case"];
|
const dimension = buildVariableCase(property, classCount, "tj_size");
|
||||||
for (let index = 1; index < breaks.length; index++) {
|
|
||||||
if (property === "unit_headloss") {
|
|
||||||
conditions.push([
|
|
||||||
"<=",
|
|
||||||
["/", ["get", "unit_headloss"], ["/", ["get", "length"], 1000]],
|
|
||||||
breaks[index],
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
conditions.push(["<=", ["get", property], breaks[index]]);
|
|
||||||
}
|
|
||||||
const colorObj = parseColor(colors[index - 1]);
|
|
||||||
conditions.push(
|
|
||||||
`rgba(${colorObj.r}, ${colorObj.g}, ${colorObj.b}, ${styleConfig.opacity})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const defaultColor = parseColor(colors[0]);
|
|
||||||
conditions.push(
|
|
||||||
`rgba(${defaultColor.r}, ${defaultColor.g}, ${defaultColor.b}, ${styleConfig.opacity})`
|
|
||||||
);
|
|
||||||
return conditions;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateDimensionConditions = (property: string): any[] => {
|
|
||||||
const conditions: any[] = ["case"];
|
|
||||||
for (let index = 0; index < breaks.length; index++) {
|
|
||||||
if (property === "unit_headloss") {
|
|
||||||
conditions.push([
|
|
||||||
"<=",
|
|
||||||
["/", ["get", "headloss"], ["get", "length"]],
|
|
||||||
breaks[index],
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
conditions.push(["<=", ["get", property], breaks[index]]);
|
|
||||||
}
|
|
||||||
conditions.push(dimensions[index]);
|
|
||||||
}
|
|
||||||
conditions.push(dimensions[dimensions.length - 1]);
|
|
||||||
return conditions;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generatePointDimensionConditions = (property: string): any[] => {
|
|
||||||
const conditions: any[] = ["case"];
|
|
||||||
for (let index = 0; index < breaks.length; index++) {
|
|
||||||
conditions.push(["<=", ["get", property], breaks[index]]);
|
|
||||||
conditions.push(["interpolate", ["linear"], ["zoom"], 12, 1, 24, dimensions[index]]);
|
|
||||||
}
|
|
||||||
conditions.push(dimensions[dimensions.length - 1]);
|
|
||||||
return conditions;
|
|
||||||
};
|
|
||||||
|
|
||||||
const dynamicStyle: FlatStyleLike = {};
|
|
||||||
|
|
||||||
if (layerType === "linestring") {
|
if (layerType === "linestring") {
|
||||||
dynamicStyle["stroke-color"] = generateColorConditions(styleConfig.property);
|
return { "stroke-color": color, "stroke-width": dimension };
|
||||||
dynamicStyle["stroke-width"] = generateDimensionConditions(styleConfig.property);
|
|
||||||
} else if (layerType === "point") {
|
|
||||||
dynamicStyle["circle-fill-color"] = generateColorConditions(styleConfig.property);
|
|
||||||
dynamicStyle["circle-radius"] = generatePointDimensionConditions(
|
|
||||||
styleConfig.property
|
|
||||||
);
|
|
||||||
dynamicStyle["circle-stroke-color"] = generateColorConditions(styleConfig.property);
|
|
||||||
dynamicStyle["circle-stroke-width"] = 2;
|
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
"circle-fill-color": color,
|
||||||
|
"circle-radius": ["interpolate", ["linear"], ["zoom"], 12, 1, 24, dimension],
|
||||||
|
"circle-stroke-color": color,
|
||||||
|
"circle-stroke-width": 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return dynamicStyle;
|
export const buildStyleVariables = (
|
||||||
|
styleConfig: StyleConfig,
|
||||||
|
resolvedStyle: ResolvedLayerStyle,
|
||||||
|
): StyleVariables => {
|
||||||
|
const variables: StyleVariables = {};
|
||||||
|
resolvedStyle.boundaries.slice(1, -1).forEach((boundary, index) => {
|
||||||
|
variables[`tj_break_${index + 1}`] = boundary;
|
||||||
|
});
|
||||||
|
resolvedStyle.colors.forEach((color, index) => {
|
||||||
|
variables[`tj_color_${index}`] = withOpacity(color, styleConfig.opacity);
|
||||||
|
});
|
||||||
|
resolvedStyle.dimensions.forEach((dimension, index) => {
|
||||||
|
variables[`tj_size_${index}`] = dimension;
|
||||||
|
});
|
||||||
|
return variables;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildContourDefinitions = ({
|
export const buildContourDefinitions = ({
|
||||||
@@ -329,20 +428,12 @@ export const buildContourDefinitions = ({
|
|||||||
styleConfig: StyleConfig;
|
styleConfig: StyleConfig;
|
||||||
breaks: number[];
|
breaks: number[];
|
||||||
colors: string[];
|
colors: string[];
|
||||||
}) => {
|
}) =>
|
||||||
const contours = [];
|
colors.map((color, index) => {
|
||||||
for (let index = 0; index < breaks.length - 1; index++) {
|
const parsed = parseColor(color);
|
||||||
const colorObj = parseColor(colors[index]);
|
return {
|
||||||
contours.push({
|
|
||||||
threshold: [breaks[index], breaks[index + 1]],
|
threshold: [breaks[index], breaks[index + 1]],
|
||||||
color: [
|
color: [parsed.r, parsed.g, parsed.b, Math.round(styleConfig.opacity * 255)],
|
||||||
colorObj.r,
|
|
||||||
colorObj.g,
|
|
||||||
colorObj.b,
|
|
||||||
Math.round(styleConfig.opacity * 255),
|
|
||||||
],
|
|
||||||
strokeWidth: 0,
|
strokeWidth: 0,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
|
||||||
return contours;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
coerceTimelineDurationMinutes,
|
||||||
|
formatTimelineTime,
|
||||||
|
getRoundedCurrentTimelineMinutes,
|
||||||
|
getTimelineDisabledRangePercentages,
|
||||||
|
normalizeTimelineMinutes,
|
||||||
|
parseTimelineDurationMinutes,
|
||||||
|
} from "./timelineTime";
|
||||||
|
|
||||||
|
describe("timelineTime", () => {
|
||||||
|
it("normalizes invalid minutes before formatting", () => {
|
||||||
|
expect(formatTimelineTime(-1)).toBe("00:00");
|
||||||
|
expect(formatTimelineTime(Number.NaN)).toBe("00:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps minutes to the configured range", () => {
|
||||||
|
expect(normalizeTimelineMinutes(-1, 60, 120)).toBe(60);
|
||||||
|
expect(normalizeTimelineMinutes(180, 60, 120)).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds the current time down to the timeline step", () => {
|
||||||
|
expect(getRoundedCurrentTimelineMinutes(new Date("2026-07-16T10:29:00"))).toBe(615);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds the current time down to a custom timeline step", () => {
|
||||||
|
expect(
|
||||||
|
getRoundedCurrentTimelineMinutes(
|
||||||
|
new Date("2026-07-16T10:29:00"),
|
||||||
|
60,
|
||||||
|
),
|
||||||
|
).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses EPANET-style duration values as minutes", () => {
|
||||||
|
expect(parseTimelineDurationMinutes("0:05")).toBe(5);
|
||||||
|
expect(parseTimelineDurationMinutes("1:00")).toBe(60);
|
||||||
|
expect(parseTimelineDurationMinutes("24:00")).toBe(1440);
|
||||||
|
expect(parseTimelineDurationMinutes("0:05:00")).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back for invalid, missing, and zero duration values", () => {
|
||||||
|
expect(coerceTimelineDurationMinutes("invalid", 1440)).toBe(1440);
|
||||||
|
expect(coerceTimelineDurationMinutes(undefined, 1440)).toBe(1440);
|
||||||
|
expect(coerceTimelineDurationMinutes("0:00", 1440)).toBe(1440);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates disabled range as full-day percentages", () => {
|
||||||
|
expect(getTimelineDisabledRangePercentages(360, 1080)).toEqual({
|
||||||
|
leftWidth: 25,
|
||||||
|
rightLeft: 75,
|
||||||
|
rightWidth: 25,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
export const TIMELINE_MINUTES_PER_DAY = 1440;
|
||||||
|
export const TIMELINE_STEP_MINUTES = 15;
|
||||||
|
|
||||||
|
export const parseTimelineDurationMinutes = (
|
||||||
|
value: unknown,
|
||||||
|
): number | undefined => {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = value
|
||||||
|
.trim()
|
||||||
|
.split(":")
|
||||||
|
.map((part) => Number(part));
|
||||||
|
|
||||||
|
if (
|
||||||
|
(parts.length !== 2 && parts.length !== 3) ||
|
||||||
|
parts.some((part) => !Number.isFinite(part) || part < 0)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hours, minutes, seconds = 0] = parts;
|
||||||
|
if (minutes >= 60 || seconds >= 60) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hours * 60 + minutes + Math.floor(seconds / 60);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const coerceTimelineDurationMinutes = (
|
||||||
|
value: unknown,
|
||||||
|
fallbackMinutes: number,
|
||||||
|
) => {
|
||||||
|
const minutes = parseTimelineDurationMinutes(value);
|
||||||
|
return minutes && minutes > 0 ? minutes : fallbackMinutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeTimelineMinutes = (
|
||||||
|
minutes: number | undefined,
|
||||||
|
minTime = 0,
|
||||||
|
maxTime = TIMELINE_MINUTES_PER_DAY,
|
||||||
|
) => {
|
||||||
|
if (typeof minutes !== "number" || !Number.isFinite(minutes)) {
|
||||||
|
return minTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(Math.max(minutes, minTime), maxTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRoundedCurrentTimelineMinutes = (
|
||||||
|
date = new Date(),
|
||||||
|
stepMinutes = TIMELINE_STEP_MINUTES,
|
||||||
|
maxMinutes = TIMELINE_MINUTES_PER_DAY,
|
||||||
|
) => {
|
||||||
|
const safeStep = stepMinutes > 0 ? stepMinutes : TIMELINE_STEP_MINUTES;
|
||||||
|
const minutes = date.getHours() * 60 + date.getMinutes();
|
||||||
|
return normalizeTimelineMinutes(
|
||||||
|
Math.floor(minutes / safeStep) * safeStep,
|
||||||
|
0,
|
||||||
|
maxMinutes,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatTimelineTime = (
|
||||||
|
minutes: number | undefined,
|
||||||
|
minTime = 0,
|
||||||
|
maxTime = TIMELINE_MINUTES_PER_DAY,
|
||||||
|
) => {
|
||||||
|
const normalizedMinutes = normalizeTimelineMinutes(minutes, minTime, maxTime);
|
||||||
|
const hours = Math.floor(normalizedMinutes / 60);
|
||||||
|
const mins = normalizedMinutes % 60;
|
||||||
|
return `${hours.toString().padStart(2, "0")}:${mins
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTimelineDisabledRangePercentages = (
|
||||||
|
minTime: number,
|
||||||
|
maxTime: number,
|
||||||
|
totalMinutes = TIMELINE_MINUTES_PER_DAY,
|
||||||
|
) => {
|
||||||
|
const safeTotalMinutes =
|
||||||
|
totalMinutes > 0 ? totalMinutes : TIMELINE_MINUTES_PER_DAY;
|
||||||
|
const rangeStart = normalizeTimelineMinutes(minTime, 0, safeTotalMinutes);
|
||||||
|
const rangeEnd = normalizeTimelineMinutes(maxTime, 0, safeTotalMinutes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
leftWidth: (rangeStart / safeTotalMinutes) * 100,
|
||||||
|
rightLeft: (rangeEnd / safeTotalMinutes) * 100,
|
||||||
|
rightWidth:
|
||||||
|
((safeTotalMinutes - rangeEnd) / safeTotalMinutes) * 100,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -16,7 +16,33 @@ type ToolbarTableProperty = {
|
|||||||
rows: (string | number)[][];
|
rows: (string | number)[][];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ToolbarPropertyItem = ToolbarBaseProperty | ToolbarTableProperty;
|
type ToolbarSelectProperty = {
|
||||||
|
type: "select";
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
options: { label: string; value: string }[];
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
saving?: boolean;
|
||||||
|
onSave: (value: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToolbarTextProperty = {
|
||||||
|
type: "text";
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
saving?: boolean;
|
||||||
|
helperText?: string;
|
||||||
|
onSave: (value: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolbarPropertyItem =
|
||||||
|
| ToolbarBaseProperty
|
||||||
|
| ToolbarTableProperty
|
||||||
|
| ToolbarSelectProperty
|
||||||
|
| ToolbarTextProperty;
|
||||||
|
|
||||||
export type ToolbarPropertyPanelData = {
|
export type ToolbarPropertyPanelData = {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -24,6 +50,46 @@ export type ToolbarPropertyPanelData = {
|
|||||||
properties?: ToolbarPropertyItem[];
|
properties?: ToolbarPropertyItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ValveStatusPropertyOptions = {
|
||||||
|
value: string | null;
|
||||||
|
loading?: boolean;
|
||||||
|
saving?: boolean;
|
||||||
|
onSave: (value: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ValveSettingPropertyOptions = {
|
||||||
|
value: string | null;
|
||||||
|
vType: string | null;
|
||||||
|
loading?: boolean;
|
||||||
|
saving?: boolean;
|
||||||
|
status?: string | null;
|
||||||
|
onSave: (value: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValveSettingHelperText = (
|
||||||
|
vType: string | null,
|
||||||
|
status?: string | null,
|
||||||
|
) => {
|
||||||
|
if (status === "OPEN" || status === "CLOSED") {
|
||||||
|
return "开启/关闭状态下 EPANET 会忽略阀门设置值";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (vType?.toUpperCase()) {
|
||||||
|
case "PRV":
|
||||||
|
case "PSV":
|
||||||
|
case "PBV":
|
||||||
|
return "压力设置值,需为 0 或正数";
|
||||||
|
case "FCV":
|
||||||
|
return "流量设置值,需为 0 或正数";
|
||||||
|
case "TCV":
|
||||||
|
return "损失系数,需为 0 或正数";
|
||||||
|
case "GPV":
|
||||||
|
return "水头损失曲线 ID";
|
||||||
|
default:
|
||||||
|
return "阀门类型相关设置值";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getFeatureHistoryType = (feature: Feature): string | null => {
|
const getFeatureHistoryType = (feature: Feature): string | null => {
|
||||||
const layerId = feature.getId()?.toString().split(".")[0] || "";
|
const layerId = feature.getId()?.toString().split(".")[0] || "";
|
||||||
if (layerId.includes("pipe")) return "pipe";
|
if (layerId.includes("pipe")) return "pipe";
|
||||||
@@ -56,6 +122,8 @@ export const inferHistoryFeatureInfos = (
|
|||||||
export const buildFeatureProperties = (
|
export const buildFeatureProperties = (
|
||||||
highlightFeature: Feature | undefined,
|
highlightFeature: Feature | undefined,
|
||||||
computedProperties: Record<string, any>,
|
computedProperties: Record<string, any>,
|
||||||
|
valveStatus?: ValveStatusPropertyOptions,
|
||||||
|
valveSetting?: ValveSettingPropertyOptions,
|
||||||
): ToolbarPropertyPanelData => {
|
): ToolbarPropertyPanelData => {
|
||||||
if (!highlightFeature) return {};
|
if (!highlightFeature) return {};
|
||||||
|
|
||||||
@@ -267,6 +335,12 @@ export const buildFeatureProperties = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (layer === "geo_valves_mat" || layer === "geo_valves") {
|
if (layer === "geo_valves_mat" || layer === "geo_valves") {
|
||||||
|
const valveType = valveSetting?.vType ?? properties.v_type;
|
||||||
|
const isValveSettingDisabled =
|
||||||
|
valveSetting?.loading ||
|
||||||
|
valveSetting?.status === "OPEN" ||
|
||||||
|
valveSetting?.status === "CLOSED";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: properties.id,
|
id: properties.id,
|
||||||
type: "阀门",
|
type: "阀门",
|
||||||
@@ -280,12 +354,47 @@ export const buildFeatureProperties = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "阀门类型",
|
label: "阀门类型",
|
||||||
value: properties.v_type,
|
value: valveType,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "局部损失",
|
label: "局部损失",
|
||||||
value: properties.minor_loss?.toFixed?.(2),
|
value: properties.minor_loss?.toFixed?.(2),
|
||||||
},
|
},
|
||||||
|
...(valveStatus
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "select" as const,
|
||||||
|
label: "开关状态",
|
||||||
|
value: valveStatus.value ?? "",
|
||||||
|
options: [
|
||||||
|
{ label: "开启", value: "OPEN" },
|
||||||
|
{ label: "关闭", value: "CLOSED" },
|
||||||
|
{ label: "激活", value: "ACTIVE" },
|
||||||
|
],
|
||||||
|
placeholder: valveStatus.loading ? "加载中" : "未设置",
|
||||||
|
disabled: valveStatus.loading,
|
||||||
|
saving: valveStatus.saving,
|
||||||
|
onSave: valveStatus.onSave,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(valveSetting
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
label: "阀门设置值",
|
||||||
|
value: valveSetting.value ?? "",
|
||||||
|
placeholder: valveSetting.loading ? "加载中" : "未设置",
|
||||||
|
disabled: isValveSettingDisabled,
|
||||||
|
saving: valveSetting.saving,
|
||||||
|
helperText: getValveSettingHelperText(
|
||||||
|
valveType,
|
||||||
|
valveSetting.status,
|
||||||
|
),
|
||||||
|
onSave: valveSetting.onSave,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
|||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { useTimelineTimeConfig, toTimelineTimeConfig } from "./useTimelineTimeConfig";
|
||||||
|
|
||||||
|
const apiFetch = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("@/lib/apiFetch", () => ({
|
||||||
|
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/contexts/ProjectContext", () => ({
|
||||||
|
useProject: () => ({ networkName: "test-network" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useTimelineTimeConfig", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives duration and step from backend time properties", async () => {
|
||||||
|
apiFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
DURATION: "24:00",
|
||||||
|
"HYDRAULIC TIMESTEP": "1:00",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTimelineTimeConfig());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.stepMinutes).toBe(60);
|
||||||
|
});
|
||||||
|
expect(result.current.durationMinutes).toBe(1440);
|
||||||
|
expect(String(apiFetch.mock.calls[0][0])).toContain(
|
||||||
|
"/api/v1/gettimeproperties/?network=test-network",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back when fetching time properties fails", async () => {
|
||||||
|
apiFetch.mockRejectedValueOnce(new Error("network failure"));
|
||||||
|
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTimelineTimeConfig());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(result.current).toMatchObject({
|
||||||
|
durationMinutes: 1440,
|
||||||
|
stepMinutes: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses wrapped times payloads", () => {
|
||||||
|
expect(
|
||||||
|
toTimelineTimeConfig({
|
||||||
|
times: {
|
||||||
|
DURATION: "24:00",
|
||||||
|
"HYDRAULIC TIMESTEP": "0:05",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
durationMinutes: 1440,
|
||||||
|
stepMinutes: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
|
import { useProject } from "@/contexts/ProjectContext";
|
||||||
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import {
|
||||||
|
coerceTimelineDurationMinutes,
|
||||||
|
TIMELINE_MINUTES_PER_DAY,
|
||||||
|
TIMELINE_STEP_MINUTES,
|
||||||
|
} from "./timelineTime";
|
||||||
|
|
||||||
|
export interface TimelineTimeConfig {
|
||||||
|
durationMinutes: number;
|
||||||
|
stepMinutes: number;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TIMELINE_TIME_CONFIG: TimelineTimeConfig = {
|
||||||
|
durationMinutes: TIMELINE_MINUTES_PER_DAY,
|
||||||
|
stepMinutes: TIMELINE_STEP_MINUTES,
|
||||||
|
properties: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveTimeProperties = (data: unknown): Record<string, unknown> => {
|
||||||
|
if (!data || typeof data !== "object") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = data as Record<string, unknown>;
|
||||||
|
if (record.times && typeof record.times === "object") {
|
||||||
|
return record.times as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toTimelineTimeConfig = (data: unknown): TimelineTimeConfig => {
|
||||||
|
const properties = resolveTimeProperties(data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
durationMinutes: coerceTimelineDurationMinutes(
|
||||||
|
properties.DURATION,
|
||||||
|
TIMELINE_MINUTES_PER_DAY,
|
||||||
|
),
|
||||||
|
stepMinutes: coerceTimelineDurationMinutes(
|
||||||
|
properties["HYDRAULIC TIMESTEP"],
|
||||||
|
TIMELINE_STEP_MINUTES,
|
||||||
|
),
|
||||||
|
properties,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTimelineTimeConfig = (): TimelineTimeConfig => {
|
||||||
|
const project = useProject();
|
||||||
|
const networkName = project?.networkName || NETWORK_NAME;
|
||||||
|
const [timeConfig, setTimeConfig] = useState<TimelineTimeConfig>(
|
||||||
|
DEFAULT_TIMELINE_TIME_CONFIG,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!networkName) {
|
||||||
|
setTimeConfig(DEFAULT_TIMELINE_TIME_CONFIG);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
const fetchTimeProperties = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.BACKEND_URL}/api/v1/gettimeproperties/?network=${encodeURIComponent(
|
||||||
|
networkName,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch time properties: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (isMounted) {
|
||||||
|
setTimeConfig(toTimelineTimeConfig(data));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to load timeline time properties:", error);
|
||||||
|
if (isMounted) {
|
||||||
|
setTimeConfig(DEFAULT_TIMELINE_TIME_CONFIG);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTimeProperties();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [networkName]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
durationMinutes: timeConfig.durationMinutes,
|
||||||
|
stepMinutes: timeConfig.stepMinutes,
|
||||||
|
properties: timeConfig.properties,
|
||||||
|
}),
|
||||||
|
[timeConfig],
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,7 +10,7 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useRef,
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Map as OlMap, VectorTile } from "ol";
|
import { Map as OlMap } from "ol";
|
||||||
import View from "ol/View.js";
|
import View from "ol/View.js";
|
||||||
import "ol/ol.css";
|
import "ol/ol.css";
|
||||||
import MapTools from "./MapTools";
|
import MapTools from "./MapTools";
|
||||||
@@ -24,18 +24,54 @@ import { TextLayer } from "@deck.gl/layers";
|
|||||||
import { TripsLayer } from "@deck.gl/geo-layers";
|
import { TripsLayer } from "@deck.gl/geo-layers";
|
||||||
import { CollisionFilterExtension } from "@deck.gl/extensions";
|
import { CollisionFilterExtension } from "@deck.gl/extensions";
|
||||||
import { ContourLayer } from "deck.gl";
|
import { ContourLayer } from "deck.gl";
|
||||||
import { toM3h } from "@utils/units";
|
import { isLpsFlowProperty, toM3h } from "@utils/units";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
cleanupTransientMapResources,
|
cleanupTransientMapResources,
|
||||||
disposeMapResources,
|
disposeMapResources,
|
||||||
markMapResourcePersistent,
|
markMapResourcePersistent,
|
||||||
} from "./mapLifecycle";
|
} from "./mapLifecycle";
|
||||||
import { createOperationalMapResources } from "./operationalLayers";
|
import {
|
||||||
|
createOperationalMapResources,
|
||||||
|
createOperationalMapSources,
|
||||||
|
} from "./operationalLayers";
|
||||||
|
import { getRoundedCurrentTimelineMinutes } from "./Controls/timelineTime";
|
||||||
|
import { useTimelineTimeConfig } from "./Controls/useTimelineTimeConfig";
|
||||||
|
import {
|
||||||
|
TileFeatureIndex,
|
||||||
|
clipLineStringPartsToExtent,
|
||||||
|
coordinatesToLonLat,
|
||||||
|
lineStringFromFlatCoordinates,
|
||||||
|
type TileFeatureInstance,
|
||||||
|
} from "./tileFeatureIndex";
|
||||||
|
|
||||||
interface MapComponentProps {
|
interface MapComponentProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMPARE_OPEN_START_MARK = "tjwater:compare-open-start";
|
||||||
|
const COMPARE_OPEN_READY_MARK = "tjwater:compare-open-ready";
|
||||||
|
const COMPARE_OPEN_MEASURE = "tjwater:compare-open";
|
||||||
|
|
||||||
|
const markCompareOpenStart = () => {
|
||||||
|
performance.clearMarks(COMPARE_OPEN_START_MARK);
|
||||||
|
performance.clearMarks(COMPARE_OPEN_READY_MARK);
|
||||||
|
performance.clearMeasures(COMPARE_OPEN_MEASURE);
|
||||||
|
performance.mark(COMPARE_OPEN_START_MARK);
|
||||||
|
};
|
||||||
|
|
||||||
|
const markCompareOpenReady = () => {
|
||||||
|
if (performance.getEntriesByName(COMPARE_OPEN_START_MARK).length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performance.mark(COMPARE_OPEN_READY_MARK);
|
||||||
|
performance.measure(
|
||||||
|
COMPARE_OPEN_MEASURE,
|
||||||
|
COMPARE_OPEN_START_MARK,
|
||||||
|
COMPARE_OPEN_READY_MARK,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface DataContextType {
|
interface DataContextType {
|
||||||
currentTime?: number; // 当前时间
|
currentTime?: number; // 当前时间
|
||||||
setCurrentTime?: React.Dispatch<React.SetStateAction<number>>;
|
setCurrentTime?: React.Dispatch<React.SetStateAction<number>>;
|
||||||
@@ -120,6 +156,57 @@ function debounce<F extends (...args: any[]) => any>(
|
|||||||
return debounced;
|
return debounced;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const indexCalculationRecords = (records: any[]) =>
|
||||||
|
new Map(records.map((record) => [String(record.ID), record]));
|
||||||
|
|
||||||
|
const mergeJunctionValues = (
|
||||||
|
features: any[],
|
||||||
|
records: any[],
|
||||||
|
property: string,
|
||||||
|
) => {
|
||||||
|
const recordsById = indexCalculationRecords(records);
|
||||||
|
return features.map((feature) => {
|
||||||
|
const record = recordsById.get(String(feature.id));
|
||||||
|
if (!record) return feature;
|
||||||
|
const value = isLpsFlowProperty(property)
|
||||||
|
? toM3h(record.value, "lps")
|
||||||
|
: record.value;
|
||||||
|
return { ...feature, [property]: value };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergePipeValues = (
|
||||||
|
features: any[],
|
||||||
|
records: any[],
|
||||||
|
property: string,
|
||||||
|
) => {
|
||||||
|
const recordsById = indexCalculationRecords(records);
|
||||||
|
const isFlow = property === "flow";
|
||||||
|
return features.map((feature) => {
|
||||||
|
const record = recordsById.get(String(feature.id));
|
||||||
|
if (!record) return feature;
|
||||||
|
const value = isFlow ? toM3h(record.value, "lps") : record.value;
|
||||||
|
const reverseFlow = isFlow && record.value < 0;
|
||||||
|
return {
|
||||||
|
...feature,
|
||||||
|
[property]: isFlow ? Math.abs(value) : value,
|
||||||
|
flowFlag: reverseFlow ? -1 : 1,
|
||||||
|
path: reverseFlow ? [...feature.path].reverse() : feature.path,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNumericRange = (values: number[]): [number, number] | undefined => {
|
||||||
|
let min = Infinity;
|
||||||
|
let max = -Infinity;
|
||||||
|
values.forEach((value) => {
|
||||||
|
if (!Number.isFinite(value)) return;
|
||||||
|
min = Math.min(min, value);
|
||||||
|
max = Math.max(max, value);
|
||||||
|
});
|
||||||
|
return min === Infinity ? undefined : [min, max];
|
||||||
|
};
|
||||||
|
|
||||||
export const useMap = () => {
|
export const useMap = () => {
|
||||||
return useContext(MapContext);
|
return useContext(MapContext);
|
||||||
};
|
};
|
||||||
@@ -139,6 +226,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
];
|
];
|
||||||
const MAP_URL = config.MAP_URL;
|
const MAP_URL = config.MAP_URL;
|
||||||
const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key
|
const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key
|
||||||
|
const { durationMinutes, stepMinutes } = useTimelineTimeConfig();
|
||||||
|
|
||||||
const mapRef = useRef<HTMLDivElement | null>(null);
|
const mapRef = useRef<HTMLDivElement | null>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
@@ -148,14 +236,15 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
const compareDeckLayerRef = useRef<DeckLayer | null>(null);
|
const compareDeckLayerRef = useRef<DeckLayer | null>(null);
|
||||||
const isDisposingRef = useRef(false);
|
const isDisposingRef = useRef(false);
|
||||||
const isCompareDisposingRef = useRef(false);
|
const isCompareDisposingRef = useRef(false);
|
||||||
const pendingTimeoutsRef = useRef<number[]>([]);
|
|
||||||
|
|
||||||
const [map, setMap] = useState<OlMap>();
|
const [map, setMap] = useState<OlMap>();
|
||||||
const [deckLayer, setDeckLayer] = useState<DeckLayer>();
|
const [deckLayer, setDeckLayer] = useState<DeckLayer>();
|
||||||
const [compareMap, setCompareMap] = useState<OlMap>();
|
const [compareMap, setCompareMap] = useState<OlMap>();
|
||||||
const [compareDeckLayer, setCompareDeckLayer] = useState<DeckLayer>();
|
const [compareDeckLayer, setCompareDeckLayer] = useState<DeckLayer>();
|
||||||
// currentCalData 用于存储当前计算结果
|
// currentCalData 用于存储当前计算结果
|
||||||
const [currentTime, setCurrentTime] = useState<number>(-1); // 默认选择当前时间
|
const [currentTime, setCurrentTime] = useState<number>(
|
||||||
|
getRoundedCurrentTimelineMinutes,
|
||||||
|
); // 默认选择当前时间
|
||||||
// const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17"));
|
// const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17"));
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date()); // 默认今天
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date()); // 默认今天
|
||||||
const [schemeName, setSchemeName] = useState<string>(""); // 当前方案名称
|
const [schemeName, setSchemeName] = useState<string>(""); // 当前方案名称
|
||||||
@@ -169,14 +258,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
);
|
);
|
||||||
const [comparePipeCalData, setComparePipeCalData] = useState<any[]>([]);
|
const [comparePipeCalData, setComparePipeCalData] = useState<any[]>([]);
|
||||||
const [isCompareMode, setCompareMode] = useState(false);
|
const [isCompareMode, setCompareMode] = useState(false);
|
||||||
// junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染
|
// junctionData 为当前层级和视口内按 ID 去重的节点数据;pipeData 为管道标签数据。
|
||||||
// currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值
|
// pipeFragments 保留当前层级和视口内每个瓦片管道片段,水流动画按 instanceKey 渲染,不能按业务 ID 去重。
|
||||||
const [junctionData, setJunctionDataState] = useState<any[]>([]);
|
const [junctionData, setJunctionDataState] = useState<any[]>([]);
|
||||||
const [pipeData, setPipeDataState] = useState<any[]>([]);
|
const [pipeData, setPipeDataState] = useState<any[]>([]);
|
||||||
const junctionDataIds = useRef(new Set<string>());
|
const [pipeFragments, setPipeFragments] = useState<any[]>([]);
|
||||||
const pipeDataIds = useRef(new Set<string>());
|
const junctionIndexRef = useRef<TileFeatureIndex | null>(null);
|
||||||
const tileJunctionDataBuffer = useRef<any[]>([]);
|
const pipeIndexRef = useRef<TileFeatureIndex | null>(null);
|
||||||
const tilePipeDataBuffer = useRef<any[]>([]);
|
|
||||||
|
|
||||||
const [showJunctionTextLayer, setShowJunctionTextLayer] = useState(false); // 控制节点文本图层显示
|
const [showJunctionTextLayer, setShowJunctionTextLayer] = useState(false); // 控制节点文本图层显示
|
||||||
const [showPipeTextLayer, setShowPipeTextLayer] = useState(false); // 控制管道文本图层显示
|
const [showPipeTextLayer, setShowPipeTextLayer] = useState(false); // 控制管道文本图层显示
|
||||||
@@ -186,7 +274,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
const [junctionText, setJunctionText] = useState("pressure");
|
const [junctionText, setJunctionText] = useState("pressure");
|
||||||
const [pipeText, setPipeText] = useState("velocity");
|
const [pipeText, setPipeText] = useState("velocity");
|
||||||
const [contours, setContours] = useState<any[]>([]);
|
const [contours, setContours] = useState<any[]>([]);
|
||||||
const flowAnimation = useRef(false); // 添加动画控制标志
|
|
||||||
const [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示
|
const [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示
|
||||||
const [isWaterflowLayerAvailable, setWaterflowLayerAvailable] =
|
const [isWaterflowLayerAvailable, setWaterflowLayerAvailable] =
|
||||||
useState(false); // 控制等高线图层显示
|
useState(false); // 控制等高线图层显示
|
||||||
@@ -194,68 +281,40 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
const [currentZoom, setCurrentZoom] = useState(11); // 当前缩放级别
|
const [currentZoom, setCurrentZoom] = useState(11); // 当前缩放级别
|
||||||
|
|
||||||
// 实时合并计算结果到基础地理数据中
|
// 实时合并计算结果到基础地理数据中
|
||||||
const mergedJunctionData = useMemo(() => {
|
const mergedJunctionData = useMemo(
|
||||||
const nodeMap = new Map(currentJunctionCalData.map((r: any) => [r.ID, r]));
|
() =>
|
||||||
return junctionData.map((j) => {
|
mergeJunctionValues(junctionData, currentJunctionCalData, junctionText),
|
||||||
const record = nodeMap.get(j.id);
|
[junctionData, currentJunctionCalData, junctionText],
|
||||||
let val = record ? record.value : undefined;
|
);
|
||||||
// 在这合并时将实际需水量从 LPS 转换为大写表示
|
const mergedPipeData = useMemo(
|
||||||
if (val !== undefined && junctionText === "actualdemand") {
|
() => mergePipeValues(pipeData, currentPipeCalData, pipeText),
|
||||||
val = toM3h(val, "lps");
|
[pipeData, currentPipeCalData, pipeText],
|
||||||
}
|
);
|
||||||
return record ? { ...j, [junctionText]: val } : j;
|
const mergedPipeFragments = useMemo(
|
||||||
});
|
() => mergePipeValues(pipeFragments, currentPipeCalData, pipeText),
|
||||||
}, [junctionData, currentJunctionCalData, junctionText]);
|
[pipeFragments, currentPipeCalData, pipeText],
|
||||||
|
);
|
||||||
const mergedPipeData = useMemo(() => {
|
const mergedCompareJunctionData = useMemo(
|
||||||
const linkMap = new Map(currentPipeCalData.map((r: any) => [r.ID, r]));
|
() =>
|
||||||
return pipeData.map((p) => {
|
isCompareMode
|
||||||
const record = linkMap.get(p.id);
|
? mergeJunctionValues(junctionData, compareJunctionCalData, junctionText)
|
||||||
if (!record) return p;
|
: [],
|
||||||
const isFlow = pipeText === "flow";
|
[isCompareMode, junctionData, compareJunctionCalData, junctionText],
|
||||||
let val = record.value;
|
);
|
||||||
if (val !== undefined && isFlow) {
|
const mergedComparePipeData = useMemo(
|
||||||
val = toM3h(val, "lps");
|
() =>
|
||||||
}
|
isCompareMode
|
||||||
return {
|
? mergePipeValues(pipeData, comparePipeCalData, pipeText)
|
||||||
...p,
|
: [],
|
||||||
[pipeText]: isFlow ? Math.abs(val) : val,
|
[isCompareMode, pipeData, comparePipeCalData, pipeText],
|
||||||
flowFlag: isFlow && record.value < 0 ? -1 : 1,
|
);
|
||||||
path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path,
|
const mergedComparePipeFragments = useMemo(
|
||||||
};
|
() =>
|
||||||
});
|
isCompareMode
|
||||||
}, [pipeData, currentPipeCalData, pipeText]);
|
? mergePipeValues(pipeFragments, comparePipeCalData, pipeText)
|
||||||
|
: [],
|
||||||
const mergedCompareJunctionData = useMemo(() => {
|
[isCompareMode, pipeFragments, comparePipeCalData, pipeText],
|
||||||
const nodeMap = new Map(compareJunctionCalData.map((r: any) => [r.ID, r]));
|
);
|
||||||
return junctionData.map((j) => {
|
|
||||||
const record = nodeMap.get(j.id);
|
|
||||||
let val = record ? record.value : undefined;
|
|
||||||
if (val !== undefined && junctionText === "actualdemand") {
|
|
||||||
val = toM3h(val, "lps");
|
|
||||||
}
|
|
||||||
return record ? { ...j, [junctionText]: val } : j;
|
|
||||||
});
|
|
||||||
}, [junctionData, compareJunctionCalData, junctionText]);
|
|
||||||
|
|
||||||
const mergedComparePipeData = useMemo(() => {
|
|
||||||
const linkMap = new Map(comparePipeCalData.map((r: any) => [r.ID, r]));
|
|
||||||
return pipeData.map((p) => {
|
|
||||||
const record = linkMap.get(p.id);
|
|
||||||
if (!record) return p;
|
|
||||||
const isFlow = pipeText === "flow";
|
|
||||||
let val = record.value;
|
|
||||||
if (val !== undefined && isFlow) {
|
|
||||||
val = toM3h(val, "lps");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
[pipeText]: isFlow ? Math.abs(val) : val,
|
|
||||||
flowFlag: isFlow && record.value < 0 ? -1 : 1,
|
|
||||||
path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [pipeData, comparePipeCalData, pipeText]);
|
|
||||||
|
|
||||||
const [diameterRange, setDiameterRange] = useState<
|
const [diameterRange, setDiameterRange] = useState<
|
||||||
[number, number] | undefined
|
[number, number] | undefined
|
||||||
@@ -267,7 +326,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
useState(0);
|
useState(0);
|
||||||
|
|
||||||
const toggleCompareMode = useCallback(() => {
|
const toggleCompareMode = useCallback(() => {
|
||||||
setCompareMode((prev) => !prev);
|
setCompareMode((prev) => {
|
||||||
|
if (!prev) markCompareOpenStart();
|
||||||
|
return !prev;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const maps = useMemo(
|
const maps = useMemo(
|
||||||
@@ -284,91 +346,126 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
[compareDeckLayer, deckLayer, isCompareMode],
|
[compareDeckLayer, deckLayer, isCompareMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setJunctionData = (newData: any[]) => {
|
const buildPipeFragments = useCallback((instance: TileFeatureInstance) => {
|
||||||
const uniqueNewData = newData.filter((item) => {
|
const tileCoordinates = lineStringFromFlatCoordinates(
|
||||||
if (!item || !item.id) return false;
|
instance.flatCoordinates,
|
||||||
if (!junctionDataIds.current.has(item.id)) {
|
instance.stride,
|
||||||
junctionDataIds.current.add(item.id);
|
);
|
||||||
return true;
|
const clippedParts = clipLineStringPartsToExtent(
|
||||||
}
|
tileCoordinates,
|
||||||
return false;
|
instance.tileExtent,
|
||||||
});
|
);
|
||||||
if (uniqueNewData.length > 0) {
|
return clippedParts.flatMap((clippedCoordinates, partIndex) => {
|
||||||
setJunctionDataState((prev) => prev.concat(uniqueNewData));
|
const path = coordinatesToLonLat(clippedCoordinates);
|
||||||
setElevationRange((prev) => {
|
const lineStringFeature = lineString(path);
|
||||||
const elevations = uniqueNewData
|
const fragmentLength = length(lineStringFeature);
|
||||||
.map((d) => d.elevation)
|
if (fragmentLength <= 0) return [];
|
||||||
.filter((v) => typeof v === "number");
|
|
||||||
if (elevations.length === 0) return prev;
|
|
||||||
|
|
||||||
let newMin = elevations[0];
|
const timestamps = [0];
|
||||||
let newMax = elevations[0];
|
let cumulativeLength = 0;
|
||||||
for (let i = 1; i < elevations.length; i++) {
|
for (let i = 1; i < path.length; i += 1) {
|
||||||
if (elevations[i] < newMin) newMin = elevations[i];
|
cumulativeLength += length(lineString([path[i - 1], path[i]]));
|
||||||
if (elevations[i] > newMax) newMax = elevations[i];
|
timestamps.push((cumulativeLength / fragmentLength) * 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!prev) {
|
const midPoint = along(lineStringFeature, fragmentLength / 2).geometry
|
||||||
return [newMin, newMax];
|
.coordinates;
|
||||||
|
const prevPoint = along(lineStringFeature, fragmentLength * 0.49).geometry
|
||||||
|
.coordinates;
|
||||||
|
const nextPoint = along(lineStringFeature, fragmentLength * 0.51).geometry
|
||||||
|
.coordinates;
|
||||||
|
let lineAngle = bearing(prevPoint, nextPoint);
|
||||||
|
lineAngle = -lineAngle + 90;
|
||||||
|
if (lineAngle < -90 || lineAngle > 90) {
|
||||||
|
lineAngle += 180;
|
||||||
}
|
}
|
||||||
return [Math.min(prev[0], newMin), Math.max(prev[1], newMax)];
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
instanceKey: `${instance.instanceKey}/${partIndex}`,
|
||||||
|
id: instance.featureId,
|
||||||
|
diameter: instance.properties.diameter || 0,
|
||||||
|
length: instance.properties.length || fragmentLength * 1000,
|
||||||
|
path,
|
||||||
|
position: midPoint,
|
||||||
|
angle: lineAngle,
|
||||||
|
timestamps,
|
||||||
|
fragmentLength,
|
||||||
|
},
|
||||||
|
];
|
||||||
});
|
});
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
|
const publishActiveTileSnapshot = useCallback(
|
||||||
|
(targetMap: OlMap) => {
|
||||||
|
const zoom = targetMap.getView().getZoom() ?? 0;
|
||||||
|
const junctionSnapshot = junctionIndexRef.current?.getSnapshot(
|
||||||
|
targetMap,
|
||||||
|
zoom,
|
||||||
|
);
|
||||||
|
const pipeSnapshot = pipeIndexRef.current?.getSnapshot(targetMap, zoom);
|
||||||
|
|
||||||
|
const nextJunctionData = Array.from(
|
||||||
|
junctionSnapshot?.instancesById.values() ?? [],
|
||||||
|
)
|
||||||
|
.map((instances) => instances[0])
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((instance) => {
|
||||||
|
const [x, y] = lineStringFromFlatCoordinates(
|
||||||
|
instance.flatCoordinates,
|
||||||
|
instance.stride,
|
||||||
|
)[0];
|
||||||
|
return {
|
||||||
|
id: instance.featureId,
|
||||||
|
instanceKey: instance.instanceKey,
|
||||||
|
position: toLonLat([x, y]),
|
||||||
|
elevation: instance.properties.elevation || 0,
|
||||||
|
demand: instance.properties.demand || 0,
|
||||||
};
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
||||||
|
|
||||||
const setPipeData = (newData: any[]) => {
|
const nextPipeFragments = (pipeSnapshot?.instances ?? [])
|
||||||
const uniqueNewData = newData.filter((item) => {
|
.filter((instance) => instance.geometryType.includes("Line"))
|
||||||
if (!item || !item.id) return false;
|
.flatMap(buildPipeFragments)
|
||||||
if (!pipeDataIds.current.has(item.id)) {
|
.sort((a, b) => a.instanceKey.localeCompare(b.instanceKey));
|
||||||
pipeDataIds.current.add(item.id);
|
|
||||||
return true;
|
const labelById = new Map<string, any>();
|
||||||
|
nextPipeFragments.forEach((fragment) => {
|
||||||
|
const previous = labelById.get(fragment.id);
|
||||||
|
if (
|
||||||
|
!previous ||
|
||||||
|
fragment.fragmentLength > previous.fragmentLength ||
|
||||||
|
(fragment.fragmentLength === previous.fragmentLength &&
|
||||||
|
fragment.instanceKey.localeCompare(previous.instanceKey) < 0)
|
||||||
|
) {
|
||||||
|
labelById.set(fragment.id, fragment);
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
if (uniqueNewData.length > 0) {
|
const nextPipeLabels = Array.from(labelById.values()).sort((a, b) =>
|
||||||
setPipeDataState((prev) => prev.concat(uniqueNewData));
|
String(a.id).localeCompare(String(b.id)),
|
||||||
setDiameterRange((prev) => {
|
|
||||||
const diameters = uniqueNewData
|
|
||||||
.map((d) => d.diameter)
|
|
||||||
.filter((v) => typeof v === "number");
|
|
||||||
if (diameters.length === 0) return prev;
|
|
||||||
|
|
||||||
let newMin = diameters[0];
|
|
||||||
let newMax = diameters[0];
|
|
||||||
for (let i = 1; i < diameters.length; i++) {
|
|
||||||
if (diameters[i] < newMin) newMin = diameters[i];
|
|
||||||
if (diameters[i] > newMax) newMax = diameters[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!prev) {
|
|
||||||
return [newMin, newMax];
|
|
||||||
}
|
|
||||||
return [Math.min(prev[0], newMin), Math.max(prev[1], newMax)];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const debouncedUpdateDataRef = useRef<DebouncedFunction<() => void> | null>(
|
|
||||||
null,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
setJunctionDataState(nextJunctionData);
|
||||||
debouncedUpdateDataRef.current = debounce(() => {
|
setPipeFragments(nextPipeFragments);
|
||||||
if (tileJunctionDataBuffer.current.length > 0) {
|
setPipeDataState(nextPipeLabels);
|
||||||
setJunctionData(tileJunctionDataBuffer.current);
|
setElevationRange(
|
||||||
tileJunctionDataBuffer.current = [];
|
getNumericRange(nextJunctionData.map((item) => item.elevation)),
|
||||||
}
|
);
|
||||||
if (tilePipeDataBuffer.current.length > 0) {
|
setDiameterRange(
|
||||||
setPipeData(tilePipeDataBuffer.current);
|
getNumericRange(nextPipeLabels.map((item) => item.diameter)),
|
||||||
tilePipeDataBuffer.current = [];
|
);
|
||||||
}
|
},
|
||||||
}, 100);
|
[buildPipeFragments],
|
||||||
|
);
|
||||||
return () => {
|
const operationalSources = useMemo(
|
||||||
debouncedUpdateDataRef.current?.cancel();
|
() =>
|
||||||
debouncedUpdateDataRef.current = null;
|
createOperationalMapSources({
|
||||||
};
|
mapUrl: MAP_URL,
|
||||||
}, []);
|
workspace: MAP_WORKSPACE,
|
||||||
|
}),
|
||||||
|
[MAP_URL, MAP_WORKSPACE],
|
||||||
|
);
|
||||||
const operationalResources = useMemo(
|
const operationalResources = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createOperationalMapResources({
|
createOperationalMapResources({
|
||||||
@@ -376,8 +473,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
workspace: MAP_WORKSPACE,
|
workspace: MAP_WORKSPACE,
|
||||||
extent: MAP_EXTENT,
|
extent: MAP_EXTENT,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
|
sources: operationalSources,
|
||||||
}),
|
}),
|
||||||
[MAP_URL, MAP_WORKSPACE, MAP_EXTENT],
|
[MAP_URL, MAP_WORKSPACE, MAP_EXTENT, operationalSources],
|
||||||
);
|
);
|
||||||
const { junctions: junctionSource, pipes: pipeSource } =
|
const { junctions: junctionSource, pipes: pipeSource } =
|
||||||
operationalResources.sources;
|
operationalResources.sources;
|
||||||
@@ -391,60 +489,15 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isDisposingRef.current = false;
|
isDisposingRef.current = false;
|
||||||
const activeJunctionDataIds = junctionDataIds.current;
|
|
||||||
const activePipeDataIds = pipeDataIds.current;
|
|
||||||
|
|
||||||
const addTimeout = (callback: () => void, delay: number) => {
|
junctionIndexRef.current = new TileFeatureIndex("junctions", junctionSource);
|
||||||
const timerId = window.setTimeout(() => {
|
pipeIndexRef.current = new TileFeatureIndex("pipes", pipeSource);
|
||||||
pendingTimeoutsRef.current = pendingTimeoutsRef.current.filter(
|
|
||||||
(id) => id !== timerId,
|
|
||||||
);
|
|
||||||
if (isDisposingRef.current) return;
|
|
||||||
callback();
|
|
||||||
}, delay);
|
|
||||||
pendingTimeoutsRef.current.push(timerId);
|
|
||||||
return timerId;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearPendingTimeouts = () => {
|
|
||||||
pendingTimeoutsRef.current.forEach((id) => clearTimeout(id));
|
|
||||||
pendingTimeoutsRef.current = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
// 缓存 junction、pipe 数据,提供给 deck.gl 提供坐标供标签显示
|
|
||||||
const handleJunctionTileLoadEnd = (event: any) => {
|
const handleJunctionTileLoadEnd = (event: any) => {
|
||||||
if (isDisposingRef.current) return;
|
if (isDisposingRef.current) return;
|
||||||
try {
|
try {
|
||||||
if (event.tile instanceof VectorTile) {
|
junctionIndexRef.current?.registerTile(event.tile);
|
||||||
const renderFeatures = event.tile.getFeatures();
|
scheduleActiveTileSnapshot();
|
||||||
const data = new Map();
|
|
||||||
|
|
||||||
renderFeatures.forEach((renderFeature: any) => {
|
|
||||||
const props = renderFeature.getProperties();
|
|
||||||
const featureId = props.id;
|
|
||||||
if (featureId && !junctionDataIds.current.has(featureId)) {
|
|
||||||
const geometry = renderFeature.getGeometry();
|
|
||||||
if (geometry) {
|
|
||||||
const coordinates = geometry.getFlatCoordinates();
|
|
||||||
const coordWGS84 = toLonLat(coordinates);
|
|
||||||
data.set(featureId, {
|
|
||||||
id: featureId,
|
|
||||||
position: coordWGS84,
|
|
||||||
elevation: props.elevation || 0,
|
|
||||||
demand: props.demand || 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const uniqueData = Array.from(data.values());
|
|
||||||
if (uniqueData.length > 0) {
|
|
||||||
uniqueData.forEach((item) =>
|
|
||||||
tileJunctionDataBuffer.current.push(item),
|
|
||||||
);
|
|
||||||
debouncedUpdateDataRef.current?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Junction tile load error:", error);
|
console.error("Junction tile load error:", error);
|
||||||
}
|
}
|
||||||
@@ -452,86 +505,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
const handlePipeTileLoadEnd = (event: any) => {
|
const handlePipeTileLoadEnd = (event: any) => {
|
||||||
if (isDisposingRef.current) return;
|
if (isDisposingRef.current) return;
|
||||||
try {
|
try {
|
||||||
if (event.tile instanceof VectorTile) {
|
pipeIndexRef.current?.registerTile(event.tile);
|
||||||
const renderFeatures = event.tile.getFeatures();
|
scheduleActiveTileSnapshot();
|
||||||
const data = new Map();
|
|
||||||
|
|
||||||
renderFeatures.forEach((renderFeature: any) => {
|
|
||||||
try {
|
|
||||||
const props = renderFeature.getProperties();
|
|
||||||
const featureId = props.id;
|
|
||||||
if (featureId && !pipeDataIds.current.has(featureId)) {
|
|
||||||
const geometry = renderFeature.getGeometry();
|
|
||||||
if (geometry) {
|
|
||||||
const flatCoordinates = geometry.getFlatCoordinates();
|
|
||||||
const stride = geometry.getStride(); // 获取步长,通常为 2
|
|
||||||
// 重建为 LineString GeoJSON 格式的 coordinates: [[x1, y1], [x2, y2], ...]
|
|
||||||
const lineCoords = [];
|
|
||||||
for (let i = 0; i < flatCoordinates.length; i += stride) {
|
|
||||||
lineCoords.push([
|
|
||||||
flatCoordinates[i],
|
|
||||||
flatCoordinates[i + 1],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
const lineCoordsWGS84 = lineCoords.map((coord) => {
|
|
||||||
const [lon, lat] = toLonLat(coord);
|
|
||||||
return [lon, lat];
|
|
||||||
});
|
|
||||||
// 添加验证:确保至少有 2 个坐标点
|
|
||||||
if (lineCoordsWGS84.length < 2) return; // 跳过此特征
|
|
||||||
// 计算中点
|
|
||||||
const lineStringFeature = lineString(lineCoordsWGS84);
|
|
||||||
const lineLength = length(lineStringFeature);
|
|
||||||
const midPoint = along(lineStringFeature, lineLength / 2)
|
|
||||||
.geometry.coordinates;
|
|
||||||
// 计算角度
|
|
||||||
const prevPoint = along(lineStringFeature, lineLength * 0.49)
|
|
||||||
.geometry.coordinates;
|
|
||||||
const nextPoint = along(lineStringFeature, lineLength * 0.51)
|
|
||||||
.geometry.coordinates;
|
|
||||||
let lineAngle = bearing(prevPoint, nextPoint);
|
|
||||||
lineAngle = -lineAngle + 90;
|
|
||||||
if (lineAngle < -90 || lineAngle > 90) {
|
|
||||||
lineAngle += 180;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算时间戳(可选)
|
|
||||||
const numSegments = lineCoordsWGS84.length - 1;
|
|
||||||
const timestamps = [0];
|
|
||||||
if (numSegments > 0) {
|
|
||||||
for (let i = 1; i <= numSegments; i++) {
|
|
||||||
timestamps.push((i / numSegments) * 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data.set(featureId, {
|
|
||||||
id: featureId,
|
|
||||||
diameter: props.diameter || 0,
|
|
||||||
length: props.length || 0,
|
|
||||||
path: lineCoordsWGS84, // 使用重建后的坐标
|
|
||||||
position: midPoint,
|
|
||||||
angle: lineAngle,
|
|
||||||
timestamps,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (geomError) {
|
|
||||||
console.error("Geometry calculation error:", geomError);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const uniqueData = Array.from(data.values());
|
|
||||||
if (uniqueData.length > 0) {
|
|
||||||
uniqueData.forEach((item) => tilePipeDataBuffer.current.push(item));
|
|
||||||
debouncedUpdateDataRef.current?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Pipe tile load error:", error);
|
console.error("Pipe tile load error:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
junctionSource.on("tileloadend", handleJunctionTileLoadEnd);
|
|
||||||
pipeSource.on("tileloadend", handlePipeTileLoadEnd);
|
|
||||||
// 监听 junctionsLayer 的 visible 变化
|
// 监听 junctionsLayer 的 visible 变化
|
||||||
const handleJunctionVisibilityChange = () => {
|
const handleJunctionVisibilityChange = () => {
|
||||||
const isVisible = junctionsLayer.getVisible();
|
const isVisible = junctionsLayer.getVisible();
|
||||||
@@ -555,6 +534,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
layers: operationalResources.orderedLayers.slice(),
|
layers: operationalResources.orderedLayers.slice(),
|
||||||
controls: [],
|
controls: [],
|
||||||
});
|
});
|
||||||
|
const scheduleActiveTileSnapshot = debounce(
|
||||||
|
() => publishActiveTileSnapshot(map),
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
junctionSource.on("tileloadend", handleJunctionTileLoadEnd);
|
||||||
|
pipeSource.on("tileloadend", handlePipeTileLoadEnd);
|
||||||
map.getInteractions().forEach(markMapResourcePersistent);
|
map.getInteractions().forEach(markMapResourcePersistent);
|
||||||
setMap(map);
|
setMap(map);
|
||||||
|
|
||||||
@@ -592,14 +577,18 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
duration: 1000,
|
duration: 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 持久化视图(中心点 + 缩放),防抖写入 localStorage
|
// 视图稳定后同步 Deck 数据并持久化,避免移动过程中重复扫描瓦片。
|
||||||
const persistView = debounce(() => {
|
const handleViewChange = debounce(() => {
|
||||||
if (isDisposingRef.current) return;
|
if (isDisposingRef.current) return;
|
||||||
try {
|
|
||||||
const view = map.getView();
|
const view = map.getView();
|
||||||
|
const zoom = view.getZoom() || 0;
|
||||||
|
setCurrentZoom(zoom);
|
||||||
|
junctionIndexRef.current?.scanLoadedTiles();
|
||||||
|
pipeIndexRef.current?.scanLoadedTiles();
|
||||||
|
scheduleActiveTileSnapshot();
|
||||||
|
try {
|
||||||
const center = view.getCenter();
|
const center = view.getCenter();
|
||||||
const zoom = view.getZoom();
|
if (center) {
|
||||||
if (center && typeof zoom === "number") {
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
MAP_VIEW_STORAGE_KEY,
|
MAP_VIEW_STORAGE_KEY,
|
||||||
JSON.stringify({ center, zoom }),
|
JSON.stringify({ center, zoom }),
|
||||||
@@ -609,21 +598,16 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
console.warn("Save map view failed", err);
|
console.warn("Save map view failed", err);
|
||||||
}
|
}
|
||||||
}, 250);
|
}, 250);
|
||||||
|
|
||||||
// 监听缩放变化并持久化,同时更新 currentZoom
|
|
||||||
const handleViewChange = () => {
|
|
||||||
addTimeout(() => {
|
|
||||||
const zoom = map.getView().getZoom() || 0;
|
|
||||||
setCurrentZoom(zoom);
|
|
||||||
persistView();
|
|
||||||
}, 250);
|
|
||||||
};
|
|
||||||
map.getView().on("change", handleViewChange);
|
map.getView().on("change", handleViewChange);
|
||||||
|
|
||||||
// 初始化当前缩放级别并强制触发瓦片加载
|
// 初始化当前缩放级别并强制触发瓦片加载
|
||||||
addTimeout(() => {
|
const initializeTimer = window.setTimeout(() => {
|
||||||
|
if (isDisposingRef.current) return;
|
||||||
const initialZoom = map.getView().getZoom() || 11;
|
const initialZoom = map.getView().getZoom() || 11;
|
||||||
setCurrentZoom(initialZoom);
|
setCurrentZoom(initialZoom);
|
||||||
|
junctionIndexRef.current?.scanLoadedTiles();
|
||||||
|
pipeIndexRef.current?.scanLoadedTiles();
|
||||||
|
scheduleActiveTileSnapshot();
|
||||||
// 强制触发地图渲染,让瓦片加载事件触发
|
// 强制触发地图渲染,让瓦片加载事件触发
|
||||||
map.render();
|
map.render();
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -652,9 +636,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
// 清理函数
|
// 清理函数
|
||||||
return () => {
|
return () => {
|
||||||
isDisposingRef.current = true;
|
isDisposingRef.current = true;
|
||||||
clearPendingTimeouts();
|
window.clearTimeout(initializeTimer);
|
||||||
debouncedUpdateDataRef.current?.cancel();
|
scheduleActiveTileSnapshot.cancel();
|
||||||
persistView.cancel();
|
handleViewChange.cancel();
|
||||||
junctionSource.un("tileloadend", handleJunctionTileLoadEnd);
|
junctionSource.un("tileloadend", handleJunctionTileLoadEnd);
|
||||||
pipeSource.un("tileloadend", handlePipeTileLoadEnd);
|
pipeSource.un("tileloadend", handlePipeTileLoadEnd);
|
||||||
map.getView().un("change", handleViewChange);
|
map.getView().un("change", handleViewChange);
|
||||||
@@ -673,10 +657,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
// React Strict Mode re-runs effects with the same memoized layer instances.
|
// React Strict Mode re-runs effects with the same memoized layer instances.
|
||||||
// Detach and clear them here, but leave final layer/source disposal to GC.
|
// Detach and clear them here, but leave final layer/source disposal to GC.
|
||||||
disposeMapResources(map, { disposeLayers: false });
|
disposeMapResources(map, { disposeLayers: false });
|
||||||
activeJunctionDataIds.clear();
|
junctionIndexRef.current = null;
|
||||||
activePipeDataIds.clear();
|
pipeIndexRef.current = null;
|
||||||
tileJunctionDataBuffer.current = [];
|
setJunctionDataState([]);
|
||||||
tilePipeDataBuffer.current = [];
|
setPipeDataState([]);
|
||||||
|
setPipeFragments([]);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [MAP_WORKSPACE, MAP_EXTENT]);
|
}, [MAP_WORKSPACE, MAP_EXTENT]);
|
||||||
@@ -695,6 +680,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
mapUrl: MAP_URL,
|
mapUrl: MAP_URL,
|
||||||
workspace: MAP_WORKSPACE,
|
workspace: MAP_WORKSPACE,
|
||||||
extent: MAP_EXTENT,
|
extent: MAP_EXTENT,
|
||||||
|
sources: operationalSources,
|
||||||
});
|
});
|
||||||
const nextCompareMap = new OlMap({
|
const nextCompareMap = new OlMap({
|
||||||
target: compareMapRef.current,
|
target: compareMapRef.current,
|
||||||
@@ -702,6 +688,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
layers: compareResources.orderedLayers.slice(),
|
layers: compareResources.orderedLayers.slice(),
|
||||||
controls: [],
|
controls: [],
|
||||||
});
|
});
|
||||||
|
const handleCompareRenderComplete = () => markCompareOpenReady();
|
||||||
|
nextCompareMap.once("rendercomplete", handleCompareRenderComplete);
|
||||||
nextCompareMap.getAllLayers().forEach((layer) => {
|
nextCompareMap.getAllLayers().forEach((layer) => {
|
||||||
const layerId = layer.get("value");
|
const layerId = layer.get("value");
|
||||||
if (!layerId) return;
|
if (!layerId) return;
|
||||||
@@ -744,6 +732,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
return () => {
|
return () => {
|
||||||
isCompareDisposingRef.current = true;
|
isCompareDisposingRef.current = true;
|
||||||
window.clearTimeout(resizeTimerId);
|
window.clearTimeout(resizeTimerId);
|
||||||
|
nextCompareMap.un("rendercomplete", handleCompareRenderComplete);
|
||||||
if (
|
if (
|
||||||
compareDeckLayerRef.current &&
|
compareDeckLayerRef.current &&
|
||||||
!compareDeckLayerRef.current.isDisposedLayer()
|
!compareDeckLayerRef.current.isDisposedLayer()
|
||||||
@@ -758,7 +747,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
compareDeckLayerRef.current = null;
|
compareDeckLayerRef.current = null;
|
||||||
setCompareDeckLayer(undefined);
|
setCompareDeckLayer(undefined);
|
||||||
setCompareMap(undefined);
|
setCompareMap(undefined);
|
||||||
disposeMapResources(nextCompareMap);
|
disposeMapResources(nextCompareMap, { disposeSources: false });
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isCompareMode, map]);
|
}, [isCompareMode, map]);
|
||||||
@@ -775,7 +764,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
}, [compareMap, isCompareMode, map]);
|
}, [compareMap, isCompareMode, map]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentTime(-1);
|
setCurrentTime(
|
||||||
|
getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes),
|
||||||
|
);
|
||||||
setSelectedDate(new Date());
|
setSelectedDate(new Date());
|
||||||
setSchemeName("");
|
setSchemeName("");
|
||||||
setCurrentJunctionCalData([]);
|
setCurrentJunctionCalData([]);
|
||||||
@@ -800,7 +791,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
deckLayerRef.current?.resetSessionLayers();
|
deckLayerRef.current?.resetSessionLayers();
|
||||||
operationalResources.resetStyles();
|
operationalResources.resetStyles();
|
||||||
};
|
};
|
||||||
}, [pathname, map, operationalResources]);
|
}, [durationMinutes, pathname, map, operationalResources, stepMinutes]);
|
||||||
|
|
||||||
// 当数据变化时,更新 deck.gl 图层
|
// 当数据变化时,更新 deck.gl 图层
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1009,11 +1000,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
|
|
||||||
// 控制流动动画开关
|
// 控制流动动画开关
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
flowAnimation.current = pipeText === "flow" && currentPipeCalData.length > 0;
|
const hasFlowData = pipeText === "flow" && currentPipeCalData.length > 0;
|
||||||
const shouldShowWaterflow =
|
const shouldShowWaterflow =
|
||||||
isWaterflowLayerAvailable &&
|
isWaterflowLayerAvailable &&
|
||||||
showWaterflowLayer &&
|
showWaterflowLayer &&
|
||||||
flowAnimation.current &&
|
hasFlowData &&
|
||||||
currentZoom >= 12 &&
|
currentZoom >= 12 &&
|
||||||
currentZoom <= 24;
|
currentZoom <= 24;
|
||||||
|
|
||||||
@@ -1021,13 +1012,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
|
|
||||||
const syncWaterflowLayer = (
|
const syncWaterflowLayer = (
|
||||||
targetDeckLayer: DeckLayer | null,
|
targetDeckLayer: DeckLayer | null,
|
||||||
targetPipeData: any[],
|
targetPipeFragments: any[],
|
||||||
disposing: boolean,
|
disposing: boolean,
|
||||||
) => {
|
) => {
|
||||||
if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) {
|
if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!shouldShowWaterflow || targetPipeData.length === 0) {
|
if (!shouldShowWaterflow || targetPipeFragments.length === 0) {
|
||||||
targetDeckLayer.removeDeckLayer("waterflowLayer");
|
targetDeckLayer.removeDeckLayer("waterflowLayer");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1039,7 +1030,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
const waterflowLayer = new TripsLayer({
|
const waterflowLayer = new TripsLayer({
|
||||||
id: "waterflowLayer",
|
id: "waterflowLayer",
|
||||||
name: "水流",
|
name: "水流",
|
||||||
data: targetPipeData,
|
data: targetPipeFragments,
|
||||||
|
getObjectId: (d: any) => d.instanceKey,
|
||||||
getPath: (d) => d.path,
|
getPath: (d) => d.path,
|
||||||
getTimestamps: (d) => d.timestamps,
|
getTimestamps: (d) => d.timestamps,
|
||||||
getColor: [0, 220, 255],
|
getColor: [0, 220, 255],
|
||||||
@@ -1061,13 +1053,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
const animate = () => {
|
const animate = () => {
|
||||||
syncWaterflowLayer(
|
syncWaterflowLayer(
|
||||||
deckLayerRef.current,
|
deckLayerRef.current,
|
||||||
mergedPipeData,
|
mergedPipeFragments,
|
||||||
isDisposingRef.current,
|
isDisposingRef.current,
|
||||||
);
|
);
|
||||||
if (isCompareMode) {
|
if (isCompareMode) {
|
||||||
syncWaterflowLayer(
|
syncWaterflowLayer(
|
||||||
compareDeckLayerRef.current,
|
compareDeckLayerRef.current,
|
||||||
mergedComparePipeData,
|
mergedComparePipeFragments,
|
||||||
isCompareDisposingRef.current,
|
isCompareDisposingRef.current,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1086,8 +1078,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
}, [
|
}, [
|
||||||
currentPipeCalData,
|
currentPipeCalData,
|
||||||
currentZoom,
|
currentZoom,
|
||||||
mergedPipeData,
|
mergedPipeFragments,
|
||||||
mergedComparePipeData,
|
mergedComparePipeFragments,
|
||||||
isCompareMode,
|
isCompareMode,
|
||||||
pipeText,
|
pipeText,
|
||||||
isWaterflowLayerAvailable,
|
isWaterflowLayerAvailable,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
jest.mock("ol/layer/WebGLVectorTile", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class WebGLVectorTileLayer {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { LayerStyleController } from "./layerStyleController";
|
||||||
|
|
||||||
|
describe("LayerStyleController", () => {
|
||||||
|
it("rebuilds only when the structural signature changes", () => {
|
||||||
|
const layer = {
|
||||||
|
getSource: () => null,
|
||||||
|
updateStyleVariables: jest.fn(),
|
||||||
|
setStyle: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
const controller = new LayerStyleController({
|
||||||
|
layer,
|
||||||
|
defaultStyle: { "stroke-color": "gray" },
|
||||||
|
});
|
||||||
|
const base = {
|
||||||
|
property: "pressure",
|
||||||
|
classCount: 5,
|
||||||
|
template: { "stroke-color": ["var", "tj_color_0"] } as any,
|
||||||
|
variables: { tj_color_0: "red" },
|
||||||
|
};
|
||||||
|
|
||||||
|
controller.applyNative(base);
|
||||||
|
controller.applyNative({ ...base, variables: { tj_color_0: "blue" } });
|
||||||
|
expect(layer.setStyle).toHaveBeenCalledTimes(1);
|
||||||
|
expect(layer.updateStyleVariables).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
controller.applyNative({ ...base, classCount: 6 });
|
||||||
|
expect(layer.setStyle).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolates primary and comparison values on a shared vector source", () => {
|
||||||
|
const properties: Record<string, unknown> = { id: "J-1" };
|
||||||
|
const feature = {
|
||||||
|
properties,
|
||||||
|
properties_: properties,
|
||||||
|
get: (key: string) => properties[key],
|
||||||
|
};
|
||||||
|
const listeners = new Set<(event: any) => void>();
|
||||||
|
const source = {
|
||||||
|
sourceTiles_: { loaded: { getFeatures: () => [feature] } },
|
||||||
|
on: (_type: string, listener: (event: any) => void) => listeners.add(listener),
|
||||||
|
un: (_type: string, listener: (event: any) => void) => listeners.delete(listener),
|
||||||
|
};
|
||||||
|
const createLayer = () => ({
|
||||||
|
getSource: () => source,
|
||||||
|
on: jest.fn(),
|
||||||
|
un: jest.fn(),
|
||||||
|
getOpacity: () => 1,
|
||||||
|
setOpacity: jest.fn(),
|
||||||
|
setStyle: jest.fn(),
|
||||||
|
updateStyleVariables: jest.fn(),
|
||||||
|
});
|
||||||
|
const primary = new LayerStyleController({
|
||||||
|
layer: createLayer() as any,
|
||||||
|
defaultStyle: { "circle-fill-color": "gray" },
|
||||||
|
stateNamespace: "primary",
|
||||||
|
});
|
||||||
|
const compare = new LayerStyleController({
|
||||||
|
layer: createLayer() as any,
|
||||||
|
defaultStyle: { "circle-fill-color": "gray" },
|
||||||
|
stateNamespace: "compare",
|
||||||
|
});
|
||||||
|
const options = {
|
||||||
|
property: "pressure",
|
||||||
|
classCount: 5,
|
||||||
|
template: { "circle-fill-color": ["get", "pressure"] } as any,
|
||||||
|
variables: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
primary.applyRuntime(options, new Map([["J-1", 0.25]]));
|
||||||
|
compare.applyRuntime(options, new Map([["J-1", 0.75]]));
|
||||||
|
|
||||||
|
expect(properties.primary_pressure).toBe(0.25);
|
||||||
|
expect(properties.compare_pressure).toBe(0.75);
|
||||||
|
expect(listeners.size).toBe(2);
|
||||||
|
|
||||||
|
primary.dispose();
|
||||||
|
expect(listeners.size).toBe(1);
|
||||||
|
expect(properties.compare_pressure).toBe(0.75);
|
||||||
|
compare.dispose();
|
||||||
|
expect(listeners.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type OlMap from "ol/Map";
|
||||||
|
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||||
|
import type VectorTileSource from "ol/source/VectorTile";
|
||||||
|
import type { FlatStyleLike, StyleVariables } from "ol/style/flat";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildVersionedFlatStyle,
|
||||||
|
sanitizeStyleIdentifier,
|
||||||
|
VectorTileStyleSession,
|
||||||
|
} from "./vectorTileStyleSession";
|
||||||
|
|
||||||
|
type FeatureStateValue = string | number | boolean | null;
|
||||||
|
|
||||||
|
type ApplyStyleOptions = {
|
||||||
|
property: string;
|
||||||
|
classCount: number;
|
||||||
|
template: FlatStyleLike;
|
||||||
|
variables: StyleVariables;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LayerStyleControllerOptions = {
|
||||||
|
layer: WebGLVectorTileLayer;
|
||||||
|
defaultStyle: FlatStyleLike;
|
||||||
|
map?: OlMap;
|
||||||
|
stateNamespace?: "primary" | "compare";
|
||||||
|
};
|
||||||
|
|
||||||
|
export class LayerStyleController {
|
||||||
|
private readonly layer: WebGLVectorTileLayer;
|
||||||
|
private readonly source: VectorTileSource | null;
|
||||||
|
private readonly defaultStyle: FlatStyleLike;
|
||||||
|
private readonly map?: OlMap;
|
||||||
|
private readonly stateNamespace?: "primary" | "compare";
|
||||||
|
private session: VectorTileStyleSession | null = null;
|
||||||
|
private templateSignature = "";
|
||||||
|
|
||||||
|
constructor(options: LayerStyleControllerOptions) {
|
||||||
|
this.layer = options.layer;
|
||||||
|
this.source = options.layer.getSource() as VectorTileSource | null;
|
||||||
|
this.defaultStyle = options.defaultStyle;
|
||||||
|
this.map = options.map;
|
||||||
|
this.stateNamespace = options.stateNamespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyNative(options: ApplyStyleOptions) {
|
||||||
|
this.disposeSession();
|
||||||
|
const signature = `native:${options.property}:${options.classCount}`;
|
||||||
|
this.layer.updateStyleVariables(options.variables);
|
||||||
|
if (signature !== this.templateSignature) {
|
||||||
|
this.layer.setStyle(options.template);
|
||||||
|
this.templateSignature = signature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyRuntime(
|
||||||
|
options: ApplyStyleOptions,
|
||||||
|
stateById: ReadonlyMap<string, FeatureStateValue>,
|
||||||
|
) {
|
||||||
|
if (!this.source) return;
|
||||||
|
const signature = `runtime:${options.property}:${options.classCount}`;
|
||||||
|
const statePropertyKey = sanitizeStyleIdentifier(
|
||||||
|
this.stateNamespace
|
||||||
|
? `${this.stateNamespace}_${options.property}`
|
||||||
|
: options.property,
|
||||||
|
);
|
||||||
|
const buildStyle = (
|
||||||
|
statePropertyKey: string,
|
||||||
|
versionKey: string,
|
||||||
|
version: number,
|
||||||
|
) =>
|
||||||
|
buildVersionedFlatStyle(
|
||||||
|
options.template,
|
||||||
|
this.defaultStyle,
|
||||||
|
options.property,
|
||||||
|
statePropertyKey,
|
||||||
|
versionKey,
|
||||||
|
version,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!this.session || this.session.propertyKey !== statePropertyKey) {
|
||||||
|
this.disposeSession();
|
||||||
|
this.session = new VectorTileStyleSession({
|
||||||
|
layer: this.layer,
|
||||||
|
source: this.source,
|
||||||
|
propertyKey: statePropertyKey,
|
||||||
|
defaultStyle: this.defaultStyle,
|
||||||
|
buildStyle,
|
||||||
|
variables: options.variables,
|
||||||
|
map: this.map,
|
||||||
|
buffered: Boolean(this.map),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.session.setBuildStyle(buildStyle);
|
||||||
|
}
|
||||||
|
this.session.updateStyleVariables(options.variables);
|
||||||
|
this.templateSignature = signature;
|
||||||
|
return this.session.commit(stateById);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVariables(variables: StyleVariables) {
|
||||||
|
if (this.session) {
|
||||||
|
this.session.updateStyleVariables(variables);
|
||||||
|
} else {
|
||||||
|
this.layer.updateStyleVariables(variables);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasTemplate() {
|
||||||
|
return this.templateSignature.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.disposeSession();
|
||||||
|
this.templateSignature = "";
|
||||||
|
this.layer.setStyle(this.defaultStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.disposeSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
private disposeSession() {
|
||||||
|
this.session?.dispose();
|
||||||
|
this.session = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,4 +91,29 @@ describe("map lifecycle", () => {
|
|||||||
expect(layer.dispose).not.toHaveBeenCalled();
|
expect(layer.dispose).not.toHaveBeenCalled();
|
||||||
expect(map.dispose).toHaveBeenCalledTimes(1);
|
expect(map.dispose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("detaches a comparison map without clearing shared sources", () => {
|
||||||
|
const source = { clear: jest.fn(), dispose: jest.fn() };
|
||||||
|
const layer = { ...createResource(), getSource: () => source };
|
||||||
|
const layers = [layer];
|
||||||
|
const interactions: ReturnType<typeof createResource>[] = [];
|
||||||
|
const controls: ReturnType<typeof createResource>[] = [];
|
||||||
|
const overlays: ReturnType<typeof createResource>[] = [];
|
||||||
|
const map = {
|
||||||
|
getLayers: () => createCollection(layers),
|
||||||
|
removeLayer: (resource: unknown) => layers.splice(layers.indexOf(resource as never), 1),
|
||||||
|
getInteractions: () => createCollection(interactions),
|
||||||
|
getControls: () => createCollection(controls),
|
||||||
|
getOverlays: () => createCollection(overlays),
|
||||||
|
setTarget: jest.fn(),
|
||||||
|
dispose: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
disposeMapResources(map, { disposeSources: false });
|
||||||
|
|
||||||
|
expect(source.clear).not.toHaveBeenCalled();
|
||||||
|
expect(source.dispose).not.toHaveBeenCalled();
|
||||||
|
expect(layer.dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(map.dispose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,25 +28,34 @@ const removeTransientResources = <T extends MapResource>(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const releaseLayer = (layer: any, dispose: boolean) => {
|
type ReleaseLayerOptions = {
|
||||||
|
disposeLayer: boolean;
|
||||||
|
disposeSource: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const releaseLayer = (layer: any, options: ReleaseLayerOptions) => {
|
||||||
const childLayers = layer.getLayers?.().getArray?.();
|
const childLayers = layer.getLayers?.().getArray?.();
|
||||||
if (Array.isArray(childLayers)) {
|
if (Array.isArray(childLayers)) {
|
||||||
[...childLayers].forEach((childLayer) => releaseLayer(childLayer, dispose));
|
[...childLayers].forEach((childLayer) => releaseLayer(childLayer, options));
|
||||||
layer.getLayers().clear();
|
layer.getLayers().clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = layer.getSource?.();
|
const source = layer.getSource?.();
|
||||||
|
if (options.disposeSource) {
|
||||||
try {
|
try {
|
||||||
source?.clear?.();
|
source?.clear?.();
|
||||||
} catch {
|
} catch {
|
||||||
// Some third-party sources do not support explicit cache clearing.
|
// Some third-party sources do not support explicit cache clearing.
|
||||||
}
|
}
|
||||||
if (dispose) {
|
if (options.disposeLayer) {
|
||||||
try {
|
try {
|
||||||
source?.dispose?.();
|
source?.dispose?.();
|
||||||
} catch {
|
} catch {
|
||||||
// Source may already be disposed by its owning layer.
|
// Source may already be disposed by its owning layer.
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options.disposeLayer) {
|
||||||
try {
|
try {
|
||||||
layer.dispose?.();
|
layer.dispose?.();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -59,7 +68,7 @@ export const cleanupTransientMapResources = (map: OlMap) => {
|
|||||||
[...map.getLayers().getArray()].forEach((layer) => {
|
[...map.getLayers().getArray()].forEach((layer) => {
|
||||||
if (isMapResourcePersistent(layer)) return;
|
if (isMapResourcePersistent(layer)) return;
|
||||||
map.removeLayer(layer);
|
map.removeLayer(layer);
|
||||||
releaseLayer(layer, true);
|
releaseLayer(layer, { disposeLayer: true, disposeSource: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
removeTransientResources(map.getInteractions().getArray(), (interaction) =>
|
removeTransientResources(map.getInteractions().getArray(), (interaction) =>
|
||||||
@@ -75,12 +84,16 @@ export const cleanupTransientMapResources = (map: OlMap) => {
|
|||||||
|
|
||||||
export const disposeMapResources = (
|
export const disposeMapResources = (
|
||||||
map: OlMap,
|
map: OlMap,
|
||||||
options: { disposeLayers?: boolean } = {},
|
options: { disposeLayers?: boolean; disposeSources?: boolean } = {},
|
||||||
) => {
|
) => {
|
||||||
const disposeLayers = options.disposeLayers ?? true;
|
const disposeLayers = options.disposeLayers ?? true;
|
||||||
|
const disposeSources = options.disposeSources ?? true;
|
||||||
[...map.getLayers().getArray()].forEach((layer) => {
|
[...map.getLayers().getArray()].forEach((layer) => {
|
||||||
map.removeLayer(layer);
|
map.removeLayer(layer);
|
||||||
releaseLayer(layer, disposeLayers);
|
releaseLayer(layer, {
|
||||||
|
disposeLayer: disposeLayers,
|
||||||
|
disposeSource: disposeSources,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
map.getInteractions().clear();
|
map.getInteractions().clear();
|
||||||
map.getControls().clear();
|
map.getControls().clear();
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
jest.mock("@turf/turf", () => ({
|
||||||
|
along: jest.fn(),
|
||||||
|
lineString: jest.fn(),
|
||||||
|
length: jest.fn(),
|
||||||
|
toMercator: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock("ol/format/MVT", () => ({ __esModule: true, default: class MVT {} }));
|
||||||
|
jest.mock("ol/format/GeoJSON", () => ({ __esModule: true, default: class GeoJSON {} }));
|
||||||
|
jest.mock("ol/geom", () => ({ Point: class Point {} }));
|
||||||
|
jest.mock("ol/proj", () => ({ toLonLat: jest.fn() }));
|
||||||
|
jest.mock("ol/style", () => ({ Icon: class Icon {}, Style: class Style {} }));
|
||||||
|
jest.mock("ol/source/Vector", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class VectorSource {
|
||||||
|
constructor(readonly options: unknown) {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock("ol/source/VectorTile", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class VectorTileSource {
|
||||||
|
constructor(readonly options: unknown) {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/layer/Vector", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class MockVectorLayer {
|
||||||
|
private readonly source: unknown;
|
||||||
|
private readonly properties = new Map<string, unknown>();
|
||||||
|
constructor(options: any) {
|
||||||
|
this.source = options.source;
|
||||||
|
Object.entries(options.properties || {}).forEach(([key, value]) =>
|
||||||
|
this.properties.set(key, value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
getSource() { return this.source; }
|
||||||
|
get(key: string) { return this.properties.get(key); }
|
||||||
|
set(key: string, value: unknown) { this.properties.set(key, value); }
|
||||||
|
setStyle() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock("ol/layer/WebGLVectorTile", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class MockWebGlVectorTileLayer {
|
||||||
|
private readonly source: unknown;
|
||||||
|
private readonly properties = new Map<string, unknown>();
|
||||||
|
constructor(options: any) {
|
||||||
|
this.source = options.source;
|
||||||
|
Object.entries(options.properties || {}).forEach(([key, value]) =>
|
||||||
|
this.properties.set(key, value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
getSource() { return this.source; }
|
||||||
|
get(key: string) { return this.properties.get(key); }
|
||||||
|
set(key: string, value: unknown) { this.properties.set(key, value); }
|
||||||
|
setStyle() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createOperationalMapResources,
|
||||||
|
createOperationalMapSources,
|
||||||
|
} from "./operationalLayers";
|
||||||
|
|
||||||
|
describe("operational map resources", () => {
|
||||||
|
it("shares sources while keeping per-map layer instances independent", () => {
|
||||||
|
const options = {
|
||||||
|
mapUrl: "https://maps.example.test/geoserver",
|
||||||
|
workspace: "test_workspace",
|
||||||
|
extent: [0, 0, 100, 100] as [number, number, number, number],
|
||||||
|
};
|
||||||
|
const sources = createOperationalMapSources(options);
|
||||||
|
const primary = createOperationalMapResources({ ...options, sources });
|
||||||
|
const compare = createOperationalMapResources({ ...options, sources });
|
||||||
|
|
||||||
|
Object.keys(sources).forEach((sourceId) => {
|
||||||
|
const key = sourceId as keyof typeof sources;
|
||||||
|
expect(primary.sources[key]).toBe(compare.sources[key]);
|
||||||
|
expect(primary.layers[key]).not.toBe(compare.layers[key]);
|
||||||
|
expect(primary.layers[key].getSource()).toBe(sources[key]);
|
||||||
|
expect(compare.layers[key].getSource()).toBe(sources[key]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,11 +17,25 @@ import { markMapResourcePersistent } from "./mapLifecycle";
|
|||||||
|
|
||||||
type MapExtent = [number, number, number, number];
|
type MapExtent = [number, number, number, number];
|
||||||
|
|
||||||
type CreateOperationalLayersOptions = {
|
type CreateOperationalMapSourcesOptions = {
|
||||||
mapUrl: string;
|
mapUrl: string;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateOperationalLayersOptions = CreateOperationalMapSourcesOptions & {
|
||||||
extent: MapExtent;
|
extent: MapExtent;
|
||||||
persistent?: boolean;
|
persistent?: boolean;
|
||||||
|
sources?: OperationalMapSources;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OperationalMapSources = {
|
||||||
|
junctions: VectorTileSource;
|
||||||
|
pipes: VectorTileSource;
|
||||||
|
valves: VectorTileSource;
|
||||||
|
reservoirs: VectorSource;
|
||||||
|
pumps: VectorSource;
|
||||||
|
tanks: VectorSource;
|
||||||
|
scada: VectorSource;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultFlatStyle = config.MAP_DEFAULT_STYLE as FlatStyleLike;
|
const defaultFlatStyle = config.MAP_DEFAULT_STYLE as FlatStyleLike;
|
||||||
@@ -85,18 +99,16 @@ const pipeProperties = [
|
|||||||
{ name: "流速", value: "velocity" },
|
{ name: "流速", value: "velocity" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const createOperationalMapResources = ({
|
export const createOperationalMapSources = ({
|
||||||
mapUrl,
|
mapUrl,
|
||||||
workspace,
|
workspace,
|
||||||
extent,
|
}: CreateOperationalMapSourcesOptions): OperationalMapSources => {
|
||||||
persistent = false,
|
|
||||||
}: CreateOperationalLayersOptions) => {
|
|
||||||
const vectorTileUrl = (name: string) =>
|
const vectorTileUrl = (name: string) =>
|
||||||
`${mapUrl}/gwc/service/tms/1.0.0/${workspace}:${name}@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`;
|
`${mapUrl}/gwc/service/tms/1.0.0/${workspace}:${name}@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`;
|
||||||
const vectorUrl = (name: string) =>
|
const vectorUrl = (name: string) =>
|
||||||
`${mapUrl}/${workspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${workspace}:${name}&outputFormat=application/json`;
|
`${mapUrl}/${workspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${workspace}:${name}&outputFormat=application/json`;
|
||||||
|
|
||||||
const sources = {
|
return {
|
||||||
junctions: new VectorTileSource({
|
junctions: new VectorTileSource({
|
||||||
url: vectorTileUrl("geo_junctions"),
|
url: vectorTileUrl("geo_junctions"),
|
||||||
format: new MVT(),
|
format: new MVT(),
|
||||||
@@ -129,6 +141,15 @@ export const createOperationalMapResources = ({
|
|||||||
format: new GeoJSON(),
|
format: new GeoJSON(),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createOperationalMapResources = ({
|
||||||
|
mapUrl,
|
||||||
|
workspace,
|
||||||
|
extent,
|
||||||
|
persistent = false,
|
||||||
|
sources = createOperationalMapSources({ mapUrl, workspace }),
|
||||||
|
}: CreateOperationalLayersOptions) => {
|
||||||
|
|
||||||
const layers = {
|
const layers = {
|
||||||
junctions: new WebGLVectorTileLayer({
|
junctions: new WebGLVectorTileLayer({
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
jest.mock("ol/extent", () => ({
|
||||||
|
intersects: (a: number[], b: number[]) =>
|
||||||
|
a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1],
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/proj", () => ({
|
||||||
|
toLonLat: (coordinate: number[]) => coordinate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
TileFeatureIndex,
|
||||||
|
clipLineStringPartsToExtent,
|
||||||
|
lineStringFromFlatCoordinates,
|
||||||
|
} from "./tileFeatureIndex";
|
||||||
|
|
||||||
|
describe("tileFeatureIndex geometry helpers", () => {
|
||||||
|
it("clips a line to the tile core extent without dropping both sides", () => {
|
||||||
|
const clipped = clipLineStringPartsToExtent(
|
||||||
|
[
|
||||||
|
[-10, 5],
|
||||||
|
[5, 5],
|
||||||
|
[15, 5],
|
||||||
|
],
|
||||||
|
[0, 0, 10, 10],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(clipped).toEqual([
|
||||||
|
[
|
||||||
|
[0, 5],
|
||||||
|
[5, 5],
|
||||||
|
[10, 5],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves all coordinates from a flat line with stride", () => {
|
||||||
|
expect(lineStringFromFlatCoordinates([0, 1, 2, 3, 4, 5], 2)).toEqual([
|
||||||
|
[0, 1],
|
||||||
|
[2, 3],
|
||||||
|
[4, 5],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps disconnected clipped line parts separate", () => {
|
||||||
|
expect(
|
||||||
|
clipLineStringPartsToExtent(
|
||||||
|
[
|
||||||
|
[-5, 2],
|
||||||
|
[5, 2],
|
||||||
|
[15, 2],
|
||||||
|
[15, 8],
|
||||||
|
[5, 8],
|
||||||
|
[-5, 8],
|
||||||
|
],
|
||||||
|
[0, 0, 10, 10],
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
[
|
||||||
|
[0, 2],
|
||||||
|
[5, 2],
|
||||||
|
[10, 2],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[10, 8],
|
||||||
|
[5, 8],
|
||||||
|
[0, 8],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps multiple tile instances for the same feature id", () => {
|
||||||
|
const makeFeature = (flatCoordinates: number[]) => ({
|
||||||
|
getProperties: () => ({ id: "P-1", diameter: 100 }),
|
||||||
|
getGeometry: () => ({
|
||||||
|
getType: () => "LineString",
|
||||||
|
getFlatCoordinates: () => flatCoordinates,
|
||||||
|
getStride: () => 2,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const makeTile = (x: number, flatCoordinates: number[]) => ({
|
||||||
|
getTileCoord: () => [14, x, 5],
|
||||||
|
getFeatures: () => [makeFeature(flatCoordinates)],
|
||||||
|
});
|
||||||
|
const source = {
|
||||||
|
sourceTiles_: {
|
||||||
|
a: makeTile(1, [0, 0, 10, 0]),
|
||||||
|
b: makeTile(2, [10, 0, 20, 0]),
|
||||||
|
},
|
||||||
|
getTileGrid: () => ({
|
||||||
|
getTileCoordExtent: ([, x]: [number, number, number]) =>
|
||||||
|
x === 1 ? [0, -10, 10, 10] : [10, -10, 20, 10],
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const index = new TileFeatureIndex("pipes", source);
|
||||||
|
const snapshot = index.getSnapshot(undefined, 14);
|
||||||
|
|
||||||
|
expect(snapshot.instances).toHaveLength(2);
|
||||||
|
expect(snapshot.instancesById.get("P-1")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import { intersects } from "ol/extent";
|
||||||
|
import type { Extent } from "ol/extent";
|
||||||
|
import type OlMap from "ol/Map";
|
||||||
|
import { toLonLat } from "ol/proj";
|
||||||
|
import type VectorTileSource from "ol/source/VectorTile";
|
||||||
|
import type { Coordinate } from "ol/coordinate";
|
||||||
|
import {
|
||||||
|
getLoadedVectorTiles,
|
||||||
|
getVectorTileFeatureId,
|
||||||
|
getVectorTileFeatureProperties,
|
||||||
|
} from "./vectorTileUtils";
|
||||||
|
|
||||||
|
type TileKey = string;
|
||||||
|
|
||||||
|
export type TileFeatureInstance = {
|
||||||
|
instanceKey: string;
|
||||||
|
z: number;
|
||||||
|
featureId: string;
|
||||||
|
properties: Record<string, any>;
|
||||||
|
geometryType: string;
|
||||||
|
tileExtent: Extent;
|
||||||
|
flatCoordinates: number[];
|
||||||
|
stride: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TileFeatureSnapshot = {
|
||||||
|
instancesById: Map<string, TileFeatureInstance[]>;
|
||||||
|
instances: TileFeatureInstance[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTileCoord = (tile: any): [number, number, number] | null => {
|
||||||
|
const coord =
|
||||||
|
typeof tile.getTileCoord === "function"
|
||||||
|
? tile.getTileCoord()
|
||||||
|
: tile.tileCoord ?? tile.tileCoord_;
|
||||||
|
if (!Array.isArray(coord) || coord.length < 3) return null;
|
||||||
|
return [Number(coord[0]), Number(coord[1]), Number(coord[2])];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTileKey = (sourceKey: string, z: number, x: number, y: number) =>
|
||||||
|
`${sourceKey}/${z}/${x}/${y}`;
|
||||||
|
|
||||||
|
const getInstanceKey = (
|
||||||
|
sourceKey: string,
|
||||||
|
z: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
featureOrdinal: number,
|
||||||
|
) => `${sourceKey}/${z}/${x}/${y}/${featureOrdinal}`;
|
||||||
|
|
||||||
|
const normalizeExtent = (extent: Extent): Extent => [
|
||||||
|
Math.min(extent[0], extent[2]),
|
||||||
|
Math.min(extent[1], extent[3]),
|
||||||
|
Math.max(extent[0], extent[2]),
|
||||||
|
Math.max(extent[1], extent[3]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const getTileGrid = (source: VectorTileSource, map?: OlMap) => {
|
||||||
|
const sourceAny = source as any;
|
||||||
|
const projection = map?.getView().getProjection();
|
||||||
|
if (projection && typeof sourceAny.getTileGridForProjection === "function") {
|
||||||
|
return sourceAny.getTileGridForProjection(projection);
|
||||||
|
}
|
||||||
|
return typeof sourceAny.getTileGrid === "function"
|
||||||
|
? sourceAny.getTileGrid()
|
||||||
|
: sourceAny.tileGrid ?? sourceAny.tileGrid_;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTileExtent = (
|
||||||
|
source: VectorTileSource,
|
||||||
|
tile: any,
|
||||||
|
z: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): Extent | null => {
|
||||||
|
const tileGrid = getTileGrid(source);
|
||||||
|
if (tileGrid && typeof tileGrid.getTileCoordExtent === "function") {
|
||||||
|
return normalizeExtent(tileGrid.getTileCoordExtent([z, x, y]) as Extent);
|
||||||
|
}
|
||||||
|
const extent =
|
||||||
|
typeof tile.getExtent === "function"
|
||||||
|
? tile.getExtent()
|
||||||
|
: tile.extent ?? tile.extent_;
|
||||||
|
return Array.isArray(extent) ? normalizeExtent(extent as Extent) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFeatureGeometry = (renderFeature: any) => {
|
||||||
|
if (typeof renderFeature.getGeometry === "function") {
|
||||||
|
return renderFeature.getGeometry();
|
||||||
|
}
|
||||||
|
return renderFeature;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGeometryType = (geometry: any): string => {
|
||||||
|
if (typeof geometry.getType === "function") return String(geometry.getType());
|
||||||
|
if (typeof geometry.getType === "string") return geometry.getType;
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFlatCoordinates = (geometry: any): number[] => {
|
||||||
|
if (typeof geometry.getFlatCoordinates === "function") {
|
||||||
|
return Array.from(geometry.getFlatCoordinates() ?? []);
|
||||||
|
}
|
||||||
|
if (Array.isArray(geometry.flatCoordinates)) {
|
||||||
|
return geometry.flatCoordinates.slice();
|
||||||
|
}
|
||||||
|
if (Array.isArray(geometry.flatCoordinates_)) {
|
||||||
|
return geometry.flatCoordinates_.slice();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStride = (geometry: any) =>
|
||||||
|
typeof geometry.getStride === "function"
|
||||||
|
? Number(geometry.getStride())
|
||||||
|
: Number(geometry.stride ?? geometry.stride_ ?? 2);
|
||||||
|
|
||||||
|
export const lineStringFromFlatCoordinates = (
|
||||||
|
flatCoordinates: number[],
|
||||||
|
stride: number,
|
||||||
|
): Coordinate[] => {
|
||||||
|
const coordinates: Coordinate[] = [];
|
||||||
|
for (let i = 0; i + 1 < flatCoordinates.length; i += stride) {
|
||||||
|
coordinates.push([flatCoordinates[i], flatCoordinates[i + 1]]);
|
||||||
|
}
|
||||||
|
return coordinates;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clipSegmentToExtent = (
|
||||||
|
start: Coordinate,
|
||||||
|
end: Coordinate,
|
||||||
|
extent: Extent,
|
||||||
|
): [Coordinate, Coordinate] | null => {
|
||||||
|
const [minX, minY, maxX, maxY] = extent;
|
||||||
|
const dx = end[0] - start[0];
|
||||||
|
const dy = end[1] - start[1];
|
||||||
|
let t0 = 0;
|
||||||
|
let t1 = 1;
|
||||||
|
|
||||||
|
const update = (p: number, q: number) => {
|
||||||
|
if (p === 0) return q >= 0;
|
||||||
|
const r = q / p;
|
||||||
|
if (p < 0) {
|
||||||
|
if (r > t1) return false;
|
||||||
|
if (r > t0) t0 = r;
|
||||||
|
} else {
|
||||||
|
if (r < t0) return false;
|
||||||
|
if (r < t1) t1 = r;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
!update(-dx, start[0] - minX) ||
|
||||||
|
!update(dx, maxX - start[0]) ||
|
||||||
|
!update(-dy, start[1] - minY) ||
|
||||||
|
!update(dy, maxY - start[1])
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
[start[0] + t0 * dx, start[1] + t0 * dy],
|
||||||
|
[start[0] + t1 * dx, start[1] + t1 * dy],
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const sameCoordinate = (a: Coordinate, b: Coordinate) =>
|
||||||
|
a[0] === b[0] && a[1] === b[1];
|
||||||
|
|
||||||
|
export const clipLineStringPartsToExtent = (
|
||||||
|
coordinates: Coordinate[],
|
||||||
|
extent: Extent,
|
||||||
|
): Coordinate[][] => {
|
||||||
|
const parts: Coordinate[][] = [];
|
||||||
|
let currentPart: Coordinate[] = [];
|
||||||
|
for (let i = 1; i < coordinates.length; i += 1) {
|
||||||
|
const segment = clipSegmentToExtent(
|
||||||
|
coordinates[i - 1],
|
||||||
|
coordinates[i],
|
||||||
|
extent,
|
||||||
|
);
|
||||||
|
if (!segment) {
|
||||||
|
if (currentPart.length >= 2) parts.push(currentPart);
|
||||||
|
currentPart = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [start, end] = segment;
|
||||||
|
if (
|
||||||
|
currentPart.length > 0 &&
|
||||||
|
!sameCoordinate(currentPart[currentPart.length - 1], start)
|
||||||
|
) {
|
||||||
|
if (currentPart.length >= 2) parts.push(currentPart);
|
||||||
|
currentPart = [];
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
currentPart.length === 0 ||
|
||||||
|
!sameCoordinate(currentPart[currentPart.length - 1], start)
|
||||||
|
) {
|
||||||
|
currentPart.push(start);
|
||||||
|
}
|
||||||
|
if (!sameCoordinate(start, end)) {
|
||||||
|
currentPart.push(end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentPart.length >= 2) parts.push(currentPart);
|
||||||
|
return parts;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const coordinatesToLonLat = (
|
||||||
|
coordinates: Coordinate[],
|
||||||
|
): [number, number][] =>
|
||||||
|
coordinates.map((coordinate) => {
|
||||||
|
const [lon, lat] = toLonLat(coordinate);
|
||||||
|
return [lon, lat];
|
||||||
|
});
|
||||||
|
|
||||||
|
export class TileFeatureIndex {
|
||||||
|
private readonly instancesByTile = new Map<
|
||||||
|
TileKey,
|
||||||
|
TileFeatureInstance[]
|
||||||
|
>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly sourceKey: string,
|
||||||
|
private readonly source: VectorTileSource,
|
||||||
|
) {
|
||||||
|
this.scanLoadedTiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
scanLoadedTiles() {
|
||||||
|
const activeTileKeys = new Set<TileKey>();
|
||||||
|
getLoadedVectorTiles(this.source).forEach((tile) => {
|
||||||
|
const tileKey = this.registerTile(tile);
|
||||||
|
if (tileKey) activeTileKeys.add(tileKey);
|
||||||
|
});
|
||||||
|
this.pruneMissingTiles(activeTileKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerTile(tile: any): TileKey | null {
|
||||||
|
if (typeof tile?.getFeatures !== "function") return null;
|
||||||
|
const coord = getTileCoord(tile);
|
||||||
|
if (!coord) return null;
|
||||||
|
const [z, x, y] = coord;
|
||||||
|
const extent = getTileExtent(this.source, tile, z, x, y);
|
||||||
|
if (!extent) return null;
|
||||||
|
|
||||||
|
const tileKey = getTileKey(this.sourceKey, z, x, y);
|
||||||
|
const tileInstances: TileFeatureInstance[] = [];
|
||||||
|
const renderFeatures = tile.getFeatures() ?? [];
|
||||||
|
renderFeatures.forEach((renderFeature: any, featureOrdinal: number) => {
|
||||||
|
const properties = getVectorTileFeatureProperties(renderFeature);
|
||||||
|
const featureId = getVectorTileFeatureId(renderFeature);
|
||||||
|
if (!featureId) return;
|
||||||
|
const geometry = getFeatureGeometry(renderFeature);
|
||||||
|
if (!geometry) return;
|
||||||
|
const flatCoordinates = getFlatCoordinates(geometry);
|
||||||
|
const stride = getStride(geometry);
|
||||||
|
if (flatCoordinates.length < 2 || stride < 2) return;
|
||||||
|
|
||||||
|
const instanceKey = getInstanceKey(
|
||||||
|
this.sourceKey,
|
||||||
|
z,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
featureOrdinal,
|
||||||
|
);
|
||||||
|
tileInstances.push({
|
||||||
|
instanceKey,
|
||||||
|
z,
|
||||||
|
featureId,
|
||||||
|
properties,
|
||||||
|
geometryType: getGeometryType(geometry),
|
||||||
|
tileExtent: extent,
|
||||||
|
flatCoordinates,
|
||||||
|
stride,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tileInstances.length === 0) {
|
||||||
|
this.instancesByTile.delete(tileKey);
|
||||||
|
} else {
|
||||||
|
this.instancesByTile.set(tileKey, tileInstances);
|
||||||
|
}
|
||||||
|
return tileKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneMissingTiles(activeTileKeys: Set<TileKey>) {
|
||||||
|
Array.from(this.instancesByTile.keys()).forEach((tileKey) => {
|
||||||
|
if (!activeTileKeys.has(tileKey)) {
|
||||||
|
this.instancesByTile.delete(tileKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(map: OlMap | undefined, zoom: number): TileFeatureSnapshot {
|
||||||
|
const tileGrid = getTileGrid(this.source, map);
|
||||||
|
const resolution = map?.getView().getResolution();
|
||||||
|
const targetZ =
|
||||||
|
tileGrid && resolution !== undefined
|
||||||
|
? tileGrid.getZForResolution(resolution, (this.source as any).zDirection)
|
||||||
|
: Math.max(0, Math.round(zoom));
|
||||||
|
const viewExtent = map?.getView().calculateExtent(map.getSize());
|
||||||
|
const instances = Array.from(this.instancesByTile.values())
|
||||||
|
.flat()
|
||||||
|
.filter((instance) => instance.z === targetZ)
|
||||||
|
.filter(
|
||||||
|
(instance) =>
|
||||||
|
!viewExtent || intersects(instance.tileExtent, viewExtent),
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.instanceKey.localeCompare(b.instanceKey));
|
||||||
|
|
||||||
|
const instancesById = new Map<string, TileFeatureInstance[]>();
|
||||||
|
instances.forEach((instance) => {
|
||||||
|
const featureInstances = instancesById.get(instance.featureId);
|
||||||
|
if (featureInstances) featureInstances.push(instance);
|
||||||
|
else instancesById.set(instance.featureId, [instance]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { instancesById, instances };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
jest.mock("ol/layer/WebGLVectorTile", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class WebGLVectorTileLayer {
|
||||||
|
opacity: number;
|
||||||
|
visible: boolean;
|
||||||
|
renderer = { renderComplete: true };
|
||||||
|
source: any;
|
||||||
|
style: any;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
constructor(options: any = {}) {
|
||||||
|
this.opacity = options.opacity ?? 1;
|
||||||
|
this.visible = options.visible ?? true;
|
||||||
|
this.source = options.source;
|
||||||
|
this.style = options.style;
|
||||||
|
this.properties = options.properties ?? {};
|
||||||
|
}
|
||||||
|
getOpacity() { return this.opacity; }
|
||||||
|
setOpacity(value: number) { this.opacity = value; }
|
||||||
|
getVisible() { return this.visible; }
|
||||||
|
setVisible(value: boolean) { this.visible = value; }
|
||||||
|
getExtent() { return undefined; }
|
||||||
|
getMinZoom() { return 0; }
|
||||||
|
getMaxZoom() { return 24; }
|
||||||
|
getMinResolution() { return 0; }
|
||||||
|
getMaxResolution() { return Infinity; }
|
||||||
|
getPreload() { return 0; }
|
||||||
|
setPreload() {}
|
||||||
|
getZIndex() { return undefined; }
|
||||||
|
setZIndex() {}
|
||||||
|
on() {}
|
||||||
|
un() {}
|
||||||
|
setStyle(style: any) {
|
||||||
|
this.style = style;
|
||||||
|
this.renderer = { renderComplete: true };
|
||||||
|
}
|
||||||
|
updateStyleVariables() {}
|
||||||
|
getRenderer() { return this.renderer; }
|
||||||
|
dispose() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||||
|
import { VectorTileStyleSession } from "./vectorTileStyleSession";
|
||||||
|
|
||||||
|
const makeFeature = (id: string) => {
|
||||||
|
const properties: Record<string, unknown> = { id };
|
||||||
|
return {
|
||||||
|
properties,
|
||||||
|
properties_: properties,
|
||||||
|
get: (key: string) => properties[key],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeTile = (...features: ReturnType<typeof makeFeature>[]) => ({
|
||||||
|
getFeatures: () => features,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VectorTileStyleSession", () => {
|
||||||
|
it("fans one feature state out to every loaded and future tile instance", () => {
|
||||||
|
const featureA = makeFeature("P-1");
|
||||||
|
const featureB = makeFeature("P-1");
|
||||||
|
const listeners = new Set<(event: any) => void>();
|
||||||
|
const source = {
|
||||||
|
sourceTiles_: {
|
||||||
|
a: makeTile(featureA),
|
||||||
|
b: makeTile(featureB),
|
||||||
|
},
|
||||||
|
on: (_type: string, listener: (event: any) => void) => listeners.add(listener),
|
||||||
|
un: (_type: string, listener: (event: any) => void) => listeners.delete(listener),
|
||||||
|
} as any;
|
||||||
|
const layer = {
|
||||||
|
on: jest.fn(),
|
||||||
|
un: jest.fn(),
|
||||||
|
getOpacity: () => 1,
|
||||||
|
setOpacity: jest.fn(),
|
||||||
|
setStyle: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
const session = new VectorTileStyleSession({
|
||||||
|
layer,
|
||||||
|
source,
|
||||||
|
propertyKey: "healthRisk",
|
||||||
|
defaultStyle: { "stroke-color": "blue" },
|
||||||
|
buildStyle: () => ({ "stroke-color": "red" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
session.commit(new Map([["P-1", 0.25]]));
|
||||||
|
|
||||||
|
expect(featureA.properties.healthRisk).toBe(0.25);
|
||||||
|
expect(featureB.properties.healthRisk).toBe(0.25);
|
||||||
|
expect(layer.setStyle).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const futureFeature = makeFeature("P-1");
|
||||||
|
listeners.forEach((listener) => listener({ tile: makeTile(futureFeature) }));
|
||||||
|
expect(futureFeature.properties.healthRisk).toBe(0.25);
|
||||||
|
|
||||||
|
session.commit(new Map());
|
||||||
|
expect(featureA.properties).not.toHaveProperty("healthRisk");
|
||||||
|
expect(featureB.properties).not.toHaveProperty("healthRisk");
|
||||||
|
|
||||||
|
session.dispose();
|
||||||
|
expect(listeners.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the active layer visible until the standby renderer is ready", async () => {
|
||||||
|
const feature = makeFeature("P-1");
|
||||||
|
const listeners = new Set<(event: any) => void>();
|
||||||
|
const source = {
|
||||||
|
sourceTiles_: { a: makeTile(feature) },
|
||||||
|
on: (_type: string, listener: (event: any) => void) => listeners.add(listener),
|
||||||
|
un: (_type: string, listener: (event: any) => void) => listeners.delete(listener),
|
||||||
|
} as any;
|
||||||
|
const baseLayer = new WebGLVectorTileLayer({
|
||||||
|
source,
|
||||||
|
style: { "stroke-color": "blue" },
|
||||||
|
}) as any;
|
||||||
|
const layers = [baseLayer];
|
||||||
|
const postrenderListeners = new Set<() => void>();
|
||||||
|
const map = {
|
||||||
|
getLayers: () => ({
|
||||||
|
getArray: () => layers,
|
||||||
|
getLength: () => layers.length,
|
||||||
|
insertAt: (index: number, layer: any) => layers.splice(index, 0, layer),
|
||||||
|
}),
|
||||||
|
removeLayer: (layer: any) => {
|
||||||
|
const index = layers.indexOf(layer);
|
||||||
|
if (index >= 0) layers.splice(index, 1);
|
||||||
|
},
|
||||||
|
on: (_type: string, listener: () => void) => postrenderListeners.add(listener),
|
||||||
|
un: (_type: string, listener: () => void) => postrenderListeners.delete(listener),
|
||||||
|
render: () => postrenderListeners.forEach((listener) => listener()),
|
||||||
|
} as any;
|
||||||
|
const styleKeys: string[] = [];
|
||||||
|
const session = new VectorTileStyleSession({
|
||||||
|
layer: baseLayer,
|
||||||
|
source,
|
||||||
|
map,
|
||||||
|
buffered: true,
|
||||||
|
propertyKey: "healthRisk",
|
||||||
|
defaultStyle: { "stroke-color": "blue" },
|
||||||
|
buildStyle: (propertyKey, versionKey) => {
|
||||||
|
styleKeys.push(propertyKey, versionKey);
|
||||||
|
return { "stroke-color": "red" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const commit = session.commit(new Map([["P-1", 0.25]]));
|
||||||
|
expect(baseLayer.getOpacity()).toBe(1);
|
||||||
|
await commit;
|
||||||
|
|
||||||
|
expect(layers).toHaveLength(2);
|
||||||
|
expect(baseLayer.getOpacity()).toBe(0);
|
||||||
|
expect(layers[1].getOpacity()).toBe(1);
|
||||||
|
expect(styleKeys).toEqual([
|
||||||
|
"healthRisk_buffer_1",
|
||||||
|
"healthRisk_render_version_buffer_1",
|
||||||
|
]);
|
||||||
|
expect(styleKeys.every((key) => !key.includes("__"))).toBe(true);
|
||||||
|
session.dispose();
|
||||||
|
expect(layers).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not replace a renderer while an obsolete buffer is still building", async () => {
|
||||||
|
const feature = makeFeature("P-1");
|
||||||
|
const source = {
|
||||||
|
sourceTiles_: { a: makeTile(feature) },
|
||||||
|
on: jest.fn(),
|
||||||
|
un: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
const baseLayer = new WebGLVectorTileLayer({
|
||||||
|
source,
|
||||||
|
style: { "stroke-color": "blue" },
|
||||||
|
}) as any;
|
||||||
|
const layers = [baseLayer];
|
||||||
|
const postrenderListeners = new Set<() => void>();
|
||||||
|
const map = {
|
||||||
|
getLayers: () => ({
|
||||||
|
getArray: () => layers,
|
||||||
|
getLength: () => layers.length,
|
||||||
|
insertAt: (index: number, layer: any) => layers.splice(index, 0, layer),
|
||||||
|
}),
|
||||||
|
removeLayer: jest.fn(),
|
||||||
|
on: (_type: string, listener: () => void) => postrenderListeners.add(listener),
|
||||||
|
un: (_type: string, listener: () => void) => postrenderListeners.delete(listener),
|
||||||
|
render: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
const session = new VectorTileStyleSession({
|
||||||
|
layer: baseLayer,
|
||||||
|
source,
|
||||||
|
map,
|
||||||
|
buffered: true,
|
||||||
|
propertyKey: "healthRisk",
|
||||||
|
defaultStyle: { "stroke-color": "blue" },
|
||||||
|
buildStyle: () => ({ "stroke-color": "red" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstCommit = session.commit(new Map([["P-1", 0.25]]));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
const standbyLayer = layers[1] as any;
|
||||||
|
standbyLayer.renderer = { renderComplete: false };
|
||||||
|
const setStyle = jest.spyOn(standbyLayer, "setStyle");
|
||||||
|
|
||||||
|
const latestCommit = session.commit(new Map([["P-1", 0.75]]));
|
||||||
|
expect(setStyle).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
postrenderListeners.forEach((listener) => listener());
|
||||||
|
await expect(firstCommit).resolves.toBe(false);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(setStyle).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
postrenderListeners.forEach((listener) => listener());
|
||||||
|
postrenderListeners.forEach((listener) => listener());
|
||||||
|
await expect(latestCommit).resolves.toBe(true);
|
||||||
|
expect(feature.properties.healthRisk_buffer_1).toBe(0.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes generated shader identifiers and mirrors variable updates", () => {
|
||||||
|
const source = { sourceTiles_: {}, on: jest.fn(), un: jest.fn() } as any;
|
||||||
|
const layer = {
|
||||||
|
on: jest.fn(),
|
||||||
|
un: jest.fn(),
|
||||||
|
getOpacity: () => 1,
|
||||||
|
setOpacity: jest.fn(),
|
||||||
|
setStyle: jest.fn(),
|
||||||
|
updateStyleVariables: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
const keys: string[] = [];
|
||||||
|
const session = new VectorTileStyleSession({
|
||||||
|
layer,
|
||||||
|
source,
|
||||||
|
propertyKey: "healthRisk__unsafe",
|
||||||
|
defaultStyle: { "stroke-color": "gray" },
|
||||||
|
buildStyle: (propertyKey, versionKey) => {
|
||||||
|
keys.push(propertyKey, versionKey);
|
||||||
|
return { "stroke-color": "red" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
session.updateStyleVariables({ tj_color_0: "red" });
|
||||||
|
session.commit(new Map());
|
||||||
|
|
||||||
|
expect(layer.updateStyleVariables).toHaveBeenCalledWith({ tj_color_0: "red" });
|
||||||
|
expect(keys.every((key) => !key.includes("__"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import type OlMap from "ol/Map";
|
||||||
|
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||||
|
import type VectorTileSource from "ol/source/VectorTile";
|
||||||
|
import type { FlatStyleLike, StyleVariables } from "ol/style/flat";
|
||||||
|
import {
|
||||||
|
getLoadedVectorTiles,
|
||||||
|
getVectorTileFeatureId,
|
||||||
|
getVectorTileFeatureProperties,
|
||||||
|
} from "./vectorTileUtils";
|
||||||
|
|
||||||
|
type FeatureStateValue = string | number | boolean | null;
|
||||||
|
type StyleBuilder = (
|
||||||
|
propertyKey: string,
|
||||||
|
versionKey: string,
|
||||||
|
version: number,
|
||||||
|
) => FlatStyleLike;
|
||||||
|
|
||||||
|
type VectorTileStyleSessionOptions = {
|
||||||
|
layer: WebGLVectorTileLayer;
|
||||||
|
source: VectorTileSource;
|
||||||
|
propertyKey: string;
|
||||||
|
defaultStyle: FlatStyleLike;
|
||||||
|
buildStyle: StyleBuilder;
|
||||||
|
map?: OlMap;
|
||||||
|
buffered?: boolean;
|
||||||
|
variables?: StyleVariables;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INTERNAL_BUFFER_LAYER = "internalRenderBuffer";
|
||||||
|
const BUFFER_READY_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
export class VectorTileStyleSession {
|
||||||
|
private readonly layer: WebGLVectorTileLayer;
|
||||||
|
private readonly source: VectorTileSource;
|
||||||
|
readonly propertyKey: string;
|
||||||
|
private readonly versionKey: string;
|
||||||
|
private readonly defaultStyle: FlatStyleLike;
|
||||||
|
private readonly map?: OlMap;
|
||||||
|
private readonly buffered: boolean;
|
||||||
|
private buildStyle: StyleBuilder;
|
||||||
|
private styleVariables: StyleVariables;
|
||||||
|
private unbufferedState = new Map<string, FeatureStateValue>();
|
||||||
|
private version = 0;
|
||||||
|
private disposed = false;
|
||||||
|
private commitRevision = 0;
|
||||||
|
private bufferedCommitQueue: Promise<void> = Promise.resolve();
|
||||||
|
private readonly slotStates = [
|
||||||
|
new Map<string, FeatureStateValue>(),
|
||||||
|
new Map<string, FeatureStateValue>(),
|
||||||
|
];
|
||||||
|
private readonly slotVersions = [0, 0];
|
||||||
|
private activeSlot = 0;
|
||||||
|
private activeLayer: WebGLVectorTileLayer;
|
||||||
|
private bufferLayer: WebGLVectorTileLayer | null = null;
|
||||||
|
private readonly displayOpacity: number;
|
||||||
|
private readonly listener: (event: any) => void;
|
||||||
|
private readonly visibilityListener: () => void;
|
||||||
|
|
||||||
|
constructor(options: VectorTileStyleSessionOptions) {
|
||||||
|
this.layer = options.layer;
|
||||||
|
this.source = options.source;
|
||||||
|
this.propertyKey = sanitizeStyleIdentifier(options.propertyKey);
|
||||||
|
this.versionKey = `${this.propertyKey}_render_version`;
|
||||||
|
this.defaultStyle = options.defaultStyle;
|
||||||
|
this.buildStyle = options.buildStyle;
|
||||||
|
this.styleVariables = { ...(options.variables || {}) };
|
||||||
|
this.map = options.map;
|
||||||
|
this.buffered = Boolean(options.buffered && options.map);
|
||||||
|
this.activeLayer = this.layer;
|
||||||
|
this.displayOpacity = this.layer.getOpacity();
|
||||||
|
this.listener = (event: any) => {
|
||||||
|
try {
|
||||||
|
if (typeof event.tile?.getFeatures !== "function") return;
|
||||||
|
this.applyTile(event.tile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Vector tile style session load error:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.visibilityListener = () => {
|
||||||
|
this.bufferLayer?.setVisible(this.layer.getVisible());
|
||||||
|
};
|
||||||
|
this.source.on("tileloadend", this.listener);
|
||||||
|
this.layer.on("change:visible", this.visibilityListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
commit(
|
||||||
|
stateById: ReadonlyMap<string, FeatureStateValue>,
|
||||||
|
): Promise<boolean> | void {
|
||||||
|
if (this.disposed) return;
|
||||||
|
if (this.buffered) {
|
||||||
|
return this.commitBuffered(stateById, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.version += 1;
|
||||||
|
this.unbufferedState = this.normalizeState(stateById);
|
||||||
|
this.applyLoadedTiles();
|
||||||
|
this.layer.setStyle(
|
||||||
|
this.buildStyle(this.propertyKey, this.versionKey, this.version),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setBuildStyle(buildStyle: StyleBuilder) {
|
||||||
|
this.buildStyle = buildStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStyleVariables(variables: StyleVariables) {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.styleVariables = { ...this.styleVariables, ...variables };
|
||||||
|
this.layer.updateStyleVariables(variables);
|
||||||
|
this.bufferLayer?.updateStyleVariables(variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): Promise<boolean> | void {
|
||||||
|
if (this.disposed) return;
|
||||||
|
if (this.buffered) {
|
||||||
|
return this.commitBuffered(new Map(), true);
|
||||||
|
}
|
||||||
|
this.version += 1;
|
||||||
|
this.unbufferedState = new Map();
|
||||||
|
this.applyLoadedTiles();
|
||||||
|
this.layer.setStyle(this.defaultStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.commitRevision += 1;
|
||||||
|
this.source.un("tileloadend", this.listener);
|
||||||
|
this.layer.un("change:visible", this.visibilityListener);
|
||||||
|
if (this.bufferLayer && this.map) {
|
||||||
|
this.map.removeLayer(this.bufferLayer);
|
||||||
|
this.bufferLayer.dispose();
|
||||||
|
this.bufferLayer = null;
|
||||||
|
}
|
||||||
|
this.layer.setOpacity(this.displayOpacity);
|
||||||
|
this.disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeState(stateById: ReadonlyMap<string, FeatureStateValue>) {
|
||||||
|
return new Map(
|
||||||
|
Array.from(stateById.entries()).map(([id, value]) => [String(id), value]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSlotPropertyKey(slot: number) {
|
||||||
|
return `${this.propertyKey}_buffer_${slot}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSlotVersionKey(slot: number) {
|
||||||
|
return `${this.versionKey}_buffer_${slot}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private commitBuffered(
|
||||||
|
stateById: ReadonlyMap<string, FeatureStateValue>,
|
||||||
|
useDefaultStyle: boolean,
|
||||||
|
) {
|
||||||
|
if (!this.map || this.disposed) return Promise.resolve(false);
|
||||||
|
const revision = this.commitRevision + 1;
|
||||||
|
this.commitRevision = revision;
|
||||||
|
const normalizedState = this.normalizeState(stateById);
|
||||||
|
const commitPromise = this.bufferedCommitQueue.then(() => {
|
||||||
|
if (this.disposed || revision !== this.commitRevision) return false;
|
||||||
|
return this.prepareBufferedCommit(
|
||||||
|
normalizedState,
|
||||||
|
useDefaultStyle,
|
||||||
|
revision,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this.bufferedCommitQueue = commitPromise.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wake a pending readiness check so an obsolete build can exit before the
|
||||||
|
// next style replaces its renderer.
|
||||||
|
this.map.render();
|
||||||
|
return commitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareBufferedCommit(
|
||||||
|
stateById: Map<string, FeatureStateValue>,
|
||||||
|
useDefaultStyle: boolean,
|
||||||
|
revision: number,
|
||||||
|
) {
|
||||||
|
if (!this.map || this.disposed || revision !== this.commitRevision) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const targetSlot = this.activeSlot === 0 ? 1 : 0;
|
||||||
|
this.version += 1;
|
||||||
|
this.slotVersions[targetSlot] = this.version;
|
||||||
|
this.slotStates[targetSlot] = stateById;
|
||||||
|
this.applyLoadedTiles();
|
||||||
|
|
||||||
|
const standbyLayer = this.getStandbyLayer();
|
||||||
|
standbyLayer.setVisible(this.layer.getVisible());
|
||||||
|
standbyLayer.setOpacity(0);
|
||||||
|
standbyLayer.setStyle(
|
||||||
|
useDefaultStyle
|
||||||
|
? this.defaultStyle
|
||||||
|
: this.buildStyle(
|
||||||
|
this.getSlotPropertyKey(targetSlot),
|
||||||
|
this.getSlotVersionKey(targetSlot),
|
||||||
|
this.version,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
this.map.render();
|
||||||
|
|
||||||
|
const ready = await this.waitUntilReady(standbyLayer, revision);
|
||||||
|
if (!ready || this.disposed || revision !== this.commitRevision) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousLayer = this.activeLayer;
|
||||||
|
standbyLayer.setOpacity(this.displayOpacity);
|
||||||
|
previousLayer.setOpacity(0);
|
||||||
|
this.activeLayer = standbyLayer;
|
||||||
|
this.activeSlot = targetSlot;
|
||||||
|
this.map.render();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStandbyLayer(): WebGLVectorTileLayer {
|
||||||
|
const map = this.map;
|
||||||
|
if (!map) return this.layer;
|
||||||
|
if (!this.bufferLayer) {
|
||||||
|
const bufferLayer = new WebGLVectorTileLayer<any, any>({
|
||||||
|
source: this.source,
|
||||||
|
style: this.defaultStyle,
|
||||||
|
extent: this.layer.getExtent(),
|
||||||
|
minZoom: this.layer.getMinZoom(),
|
||||||
|
maxZoom: this.layer.getMaxZoom(),
|
||||||
|
minResolution: this.layer.getMinResolution(),
|
||||||
|
maxResolution: this.layer.getMaxResolution(),
|
||||||
|
opacity: 0,
|
||||||
|
visible: this.layer.getVisible(),
|
||||||
|
variables: this.styleVariables,
|
||||||
|
properties: {
|
||||||
|
[INTERNAL_BUFFER_LAYER]: true,
|
||||||
|
queryable: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
bufferLayer.setPreload(this.layer.getPreload());
|
||||||
|
const zIndex = this.layer.getZIndex();
|
||||||
|
if (zIndex !== undefined) bufferLayer.setZIndex(zIndex);
|
||||||
|
const layers = map.getLayers();
|
||||||
|
const baseIndex = layers.getArray().indexOf(this.layer);
|
||||||
|
layers.insertAt(baseIndex >= 0 ? baseIndex + 1 : layers.getLength(), bufferLayer);
|
||||||
|
this.bufferLayer = bufferLayer;
|
||||||
|
}
|
||||||
|
return this.activeLayer === this.layer ? this.bufferLayer! : this.layer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitUntilReady(layer: WebGLVectorTileLayer, revision: number) {
|
||||||
|
if (!this.map) return Promise.resolve(true);
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
let consecutiveReadyFrames = 0;
|
||||||
|
let settled = false;
|
||||||
|
const finish = (ready: boolean) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
this.map?.un("postrender", checkReady);
|
||||||
|
resolve(ready);
|
||||||
|
};
|
||||||
|
const checkReady = () => {
|
||||||
|
if (this.disposed || revision !== this.commitRevision) {
|
||||||
|
finish(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const renderer = layer.getRenderer() as any;
|
||||||
|
consecutiveReadyFrames = renderer?.renderComplete
|
||||||
|
? consecutiveReadyFrames + 1
|
||||||
|
: 0;
|
||||||
|
if (consecutiveReadyFrames >= 2) {
|
||||||
|
finish(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.map?.render();
|
||||||
|
};
|
||||||
|
const timeoutId = window.setTimeout(
|
||||||
|
() => finish(false),
|
||||||
|
BUFFER_READY_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
this.map!.on("postrender", checkReady);
|
||||||
|
this.map!.render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyLoadedTiles() {
|
||||||
|
getLoadedVectorTiles(this.source).forEach((tile) => this.applyTile(tile));
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyTile(tile: any) {
|
||||||
|
if (this.buffered) {
|
||||||
|
this.slotVersions.forEach((version, slot) => {
|
||||||
|
if (version > 0) {
|
||||||
|
this.applyTileState(
|
||||||
|
tile,
|
||||||
|
this.slotStates[slot],
|
||||||
|
this.getSlotPropertyKey(slot),
|
||||||
|
this.getSlotVersionKey(slot),
|
||||||
|
version,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.applyTileState(
|
||||||
|
tile,
|
||||||
|
this.unbufferedState,
|
||||||
|
this.propertyKey,
|
||||||
|
this.versionKey,
|
||||||
|
this.version,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyTileState(
|
||||||
|
tile: any,
|
||||||
|
stateById: ReadonlyMap<string, FeatureStateValue>,
|
||||||
|
propertyKey: string,
|
||||||
|
versionKey: string,
|
||||||
|
version: number,
|
||||||
|
) {
|
||||||
|
const renderFeatures = tile.getFeatures?.();
|
||||||
|
if (!renderFeatures || renderFeatures.length === 0) return;
|
||||||
|
renderFeatures.forEach((renderFeature: any) => {
|
||||||
|
const featureId = getVectorTileFeatureId(renderFeature);
|
||||||
|
if (!featureId) return;
|
||||||
|
const properties = getVectorTileFeatureProperties(renderFeature);
|
||||||
|
const value = stateById.get(featureId);
|
||||||
|
if (value === undefined) {
|
||||||
|
delete properties[propertyKey];
|
||||||
|
properties[versionKey] = version;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
properties[propertyKey] = value;
|
||||||
|
properties[versionKey] = version;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sanitizeStyleIdentifier = (value: string) => {
|
||||||
|
const normalized = value
|
||||||
|
.replace(/[^A-Za-z0-9_]/g, "_")
|
||||||
|
.replace(/_+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "");
|
||||||
|
const prefixed = /^[A-Za-z]/.test(normalized) ? normalized : `tj_${normalized}`;
|
||||||
|
return prefixed || "tj_state";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const versionedPropertyCase = (
|
||||||
|
propertyKey: string,
|
||||||
|
versionKey: string,
|
||||||
|
version: number,
|
||||||
|
cases: any[],
|
||||||
|
fallback: any,
|
||||||
|
) => [
|
||||||
|
"case",
|
||||||
|
["all", ["==", ["get", versionKey], version], ["has", propertyKey]],
|
||||||
|
["case", ...cases, fallback],
|
||||||
|
fallback,
|
||||||
|
];
|
||||||
|
|
||||||
|
const remapFlatStyleProperty = (
|
||||||
|
style: FlatStyleLike,
|
||||||
|
fromProperty: string,
|
||||||
|
toProperty: string,
|
||||||
|
): FlatStyleLike => {
|
||||||
|
const remap = (value: any): any => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const next = value.map(remap);
|
||||||
|
if (
|
||||||
|
(next[0] === "get" || next[0] === "has") &&
|
||||||
|
next[1] === fromProperty
|
||||||
|
) {
|
||||||
|
next[1] = toProperty;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, nested]) => [key, remap(nested)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
return remap(style) as FlatStyleLike;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildVersionedFlatStyle = (
|
||||||
|
style: FlatStyleLike,
|
||||||
|
fallbackStyle: FlatStyleLike,
|
||||||
|
sourcePropertyKey: string,
|
||||||
|
propertyKey: string,
|
||||||
|
versionKey: string,
|
||||||
|
version: number,
|
||||||
|
): FlatStyleLike => {
|
||||||
|
const remappedStyle = remapFlatStyleProperty(
|
||||||
|
style,
|
||||||
|
sourcePropertyKey,
|
||||||
|
propertyKey,
|
||||||
|
);
|
||||||
|
const guarded: Record<string, any> = {};
|
||||||
|
Object.entries(remappedStyle as Record<string, any>).forEach(
|
||||||
|
([key, value]) => {
|
||||||
|
guarded[key] = [
|
||||||
|
"case",
|
||||||
|
["all", ["==", ["get", versionKey], version], ["has", propertyKey]],
|
||||||
|
value,
|
||||||
|
(fallbackStyle as Record<string, any>)[key] ?? value,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return guarded as FlatStyleLike;
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type VectorTileSource from "ol/source/VectorTile";
|
||||||
|
|
||||||
|
export const getLoadedVectorTiles = (source: VectorTileSource): any[] => {
|
||||||
|
const sourceTiles = (source as any).sourceTiles_;
|
||||||
|
if (!sourceTiles) return [];
|
||||||
|
return sourceTiles instanceof Map
|
||||||
|
? Array.from(sourceTiles.values())
|
||||||
|
: Object.values(sourceTiles);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getVectorTileFeatureProperties = (
|
||||||
|
feature: any,
|
||||||
|
): Record<string, any> =>
|
||||||
|
feature.properties_ ?? feature.getProperties?.() ?? {};
|
||||||
|
|
||||||
|
export const getVectorTileFeatureId = (feature: any): string | null => {
|
||||||
|
const properties = getVectorTileFeatureProperties(feature);
|
||||||
|
const id =
|
||||||
|
feature.get?.("id") ??
|
||||||
|
feature.get?.("ID") ??
|
||||||
|
properties.id ??
|
||||||
|
properties.ID;
|
||||||
|
return id === undefined || id === null || id === "" ? null : String(id);
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { useSnackbar } from "@refinedev/mui";
|
||||||
|
|
||||||
|
import { useAppNotificationProvider } from "./useAppNotificationProvider";
|
||||||
|
|
||||||
|
jest.mock("@refinedev/mui", () => ({
|
||||||
|
useSnackbar: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedUseSnackbar = useSnackbar as jest.MockedFunction<typeof useSnackbar>;
|
||||||
|
|
||||||
|
const TestComponent = ({
|
||||||
|
cancelMutation,
|
||||||
|
}: {
|
||||||
|
cancelMutation?: () => void;
|
||||||
|
}) => {
|
||||||
|
const notificationProvider = useAppNotificationProvider();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
notificationProvider.open({
|
||||||
|
key: "analysis-progress",
|
||||||
|
type: "progress",
|
||||||
|
message: "分析中",
|
||||||
|
undoableTimeout: 3,
|
||||||
|
cancelMutation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open progress
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useAppNotificationProvider", () => {
|
||||||
|
const enqueueSnackbar = jest.fn();
|
||||||
|
const closeSnackbar = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockedUseSnackbar.mockReturnValue({
|
||||||
|
enqueueSnackbar,
|
||||||
|
closeSnackbar,
|
||||||
|
} as unknown as ReturnType<typeof useSnackbar>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render an undo action for progress notifications without cancellation", async () => {
|
||||||
|
render(<TestComponent />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open progress" }));
|
||||||
|
|
||||||
|
expect(enqueueSnackbar).toHaveBeenCalledTimes(1);
|
||||||
|
expect(enqueueSnackbar.mock.calls[0][1]).toMatchObject({
|
||||||
|
key: "analysis-progress",
|
||||||
|
preventDuplicate: true,
|
||||||
|
autoHideDuration: 3000,
|
||||||
|
});
|
||||||
|
expect(enqueueSnackbar.mock.calls[0][1]).not.toHaveProperty("action");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps undo action for progress notifications with cancellation", async () => {
|
||||||
|
const cancelMutation = jest.fn();
|
||||||
|
render(<TestComponent cancelMutation={cancelMutation} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open progress" }));
|
||||||
|
|
||||||
|
const options = enqueueSnackbar.mock.calls[0][1];
|
||||||
|
expect(options.action).toEqual(expect.any(Function));
|
||||||
|
|
||||||
|
const undoAction = render(options.action("progress-key"));
|
||||||
|
fireEvent.click(within(undoAction.container).getByRole("button"));
|
||||||
|
|
||||||
|
expect(cancelMutation).toHaveBeenCalledTimes(1);
|
||||||
|
expect(closeSnackbar).toHaveBeenCalledWith("progress-key");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import type { NotificationProvider } from "@refinedev/core";
|
||||||
|
import { useSnackbar } from "@refinedev/mui";
|
||||||
|
import UndoOutlined from "@mui/icons-material/UndoOutlined";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
|
||||||
|
type ProgressNotificationProps = {
|
||||||
|
undoableTimeout: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProgressNotification = ({
|
||||||
|
undoableTimeout,
|
||||||
|
message,
|
||||||
|
}: ProgressNotificationProps) => {
|
||||||
|
const [progress, setProgress] = useState(100);
|
||||||
|
const [timeCount, setTimeCount] = useState(undoableTimeout);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (undoableTimeout <= 0 || timeCount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressStep = 100 / undoableTimeout;
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setTimeCount((previous) => Math.max(previous - 1, 0));
|
||||||
|
setProgress((previous) => Math.max(previous - progressStep, 0));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [timeCount, undoableTimeout]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box sx={{ position: "relative", display: "inline-flex" }}>
|
||||||
|
<CircularProgress
|
||||||
|
color="inherit"
|
||||||
|
variant="determinate"
|
||||||
|
value={progress}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
position: "absolute",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography component="div">{timeCount}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
marginLeft: "10px",
|
||||||
|
maxWidth: { xs: "150px", md: "100%" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2">{message}</Typography>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAppNotificationProvider = (): NotificationProvider => {
|
||||||
|
const { closeSnackbar, enqueueSnackbar } = useSnackbar();
|
||||||
|
|
||||||
|
return {
|
||||||
|
open: ({
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
undoableTimeout,
|
||||||
|
key,
|
||||||
|
cancelMutation,
|
||||||
|
description,
|
||||||
|
}) => {
|
||||||
|
if (type === "progress") {
|
||||||
|
const options: NonNullable<Parameters<typeof enqueueSnackbar>[1]> = {
|
||||||
|
preventDuplicate: true,
|
||||||
|
key,
|
||||||
|
autoHideDuration: (undoableTimeout ?? 0) * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cancelMutation) {
|
||||||
|
options.action = (snackbarKey) => (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
cancelMutation();
|
||||||
|
closeSnackbar(snackbarKey);
|
||||||
|
}}
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
<UndoOutlined />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueueSnackbar(
|
||||||
|
<ProgressNotification
|
||||||
|
undoableTimeout={undoableTimeout ?? 0}
|
||||||
|
message={message}
|
||||||
|
/>,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueueSnackbar(
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" component="h6">
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" component="p">
|
||||||
|
{message}
|
||||||
|
</Typography>
|
||||||
|
</Box>,
|
||||||
|
{
|
||||||
|
key,
|
||||||
|
variant: type,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
close: (key) => {
|
||||||
|
closeSnackbar(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -58,6 +58,10 @@ const MAP_CONFIG = {
|
|||||||
bufferUnits: "meters" as const,
|
bufferUnits: "meters" as const,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const isQueryableVectorLayer = (layer: unknown): layer is VectorLayer => {
|
||||||
|
return layer instanceof VectorLayer && layer.get("queryable") !== false;
|
||||||
|
};
|
||||||
|
|
||||||
// ========== 辅助函数 ==========
|
// ========== 辅助函数 ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -413,7 +417,7 @@ const handleMapClickSelectFeatures = async (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
hitTolerance: MAP_CONFIG.hitTolerance,
|
hitTolerance: MAP_CONFIG.hitTolerance,
|
||||||
layerFilter: (layer) => layer instanceof VectorLayer,
|
layerFilter: isQueryableVectorLayer,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { FLOW_DISPLAY_UNIT, isLpsFlowProperty, toM3h } from "./units";
|
||||||
|
|
||||||
|
describe("flow display units", () => {
|
||||||
|
it("uses cubic meters per hour as the flow display unit", () => {
|
||||||
|
expect(FLOW_DISPLAY_UNIT).toBe("m³/h");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts L/s values to m³/h", () => {
|
||||||
|
expect(toM3h(10, "lps")).toBe(36);
|
||||||
|
expect(toM3h(10, "L/s")).toBe(36);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes computed properties that arrive from the backend in L/s", () => {
|
||||||
|
expect(isLpsFlowProperty("flow")).toBe(true);
|
||||||
|
expect(isLpsFlowProperty("actual_demand")).toBe(true);
|
||||||
|
expect(isLpsFlowProperty("actualdemand")).toBe(true);
|
||||||
|
expect(isLpsFlowProperty("pressure")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
export const FLOW_DISPLAY_UNIT = "m³/h";
|
export const FLOW_DISPLAY_UNIT = "m³/h";
|
||||||
const M3H_FACTOR = 3600;
|
const M3H_FACTOR = 3600;
|
||||||
|
const LPS_FLOW_PROPERTIES = new Set(["flow", "actual_demand", "actualdemand"]);
|
||||||
|
|
||||||
|
export const isLpsFlowProperty = (property: string) =>
|
||||||
|
LPS_FLOW_PROPERTIES.has(property);
|
||||||
|
|
||||||
export const toM3h = (value: number, sourceUnit: string = "m³/s") => {
|
export const toM3h = (value: number, sourceUnit: string = "m³/s") => {
|
||||||
if (!Number.isFinite(value)) return Number.NaN;
|
if (!Number.isFinite(value)) return Number.NaN;
|
||||||
|
|||||||
Reference in New Issue
Block a user