新增自动应用样式功能;强制计算后自动应用默认样式

This commit is contained in:
2026-05-29 10:02:26 +08:00
parent a4f0ffcd32
commit 0e82c080df
9 changed files with 2201 additions and 2002 deletions
@@ -0,0 +1,590 @@
import ApplyIcon from "@mui/icons-material/Check";
import ColorLensIcon from "@mui/icons-material/ColorLens";
import ResetIcon from "@mui/icons-material/Refresh";
import {
Box,
Button,
Checkbox,
FormControl,
FormControlLabel,
InputLabel,
MenuItem,
Select,
Slider,
TextField,
Typography,
} from "@mui/material";
import React from "react";
import {
CLASSIFICATION_METHODS,
COLOR_TYPE_OPTIONS,
GRADIENT_PALETTES,
RAINBOW_PALETTES,
SINGLE_COLOR_PALETTES,
} from "./styleEditorPresets";
import { StyleEditorFormProps } from "./styleEditorTypes";
import {
getSizePreviewColors,
hexToRgba,
resolveStyleColors,
rgbaToHex,
} from "./styleEditorUtils";
const StyleEditorForm: React.FC<StyleEditorFormProps> = ({
renderLayers,
selectedRenderLayer,
styleConfig,
setStyleConfig,
availableProperties,
onLayerChange,
onPropertyChange,
onClassificationMethodChange,
onSegmentsChange,
onCustomBreakChange,
onCustomBreakBlur,
onColorTypeChange,
onApply,
onReset,
}) => {
const renderColorSetting = () => {
if (styleConfig.colorType === "single") {
return (
<FormControl variant="standard" fullWidth margin="dense" className="mt-3">
<InputLabel></InputLabel>
<Select
value={styleConfig.singlePaletteIndex}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
singlePaletteIndex: Number(e.target.value),
}))
}
>
{SINGLE_COLOR_PALETTES.map((palette, index) => (
<MenuItem key={index} value={index}>
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
<Box
sx={{
width: "80%",
height: 16,
borderRadius: 2,
background: palette.color,
marginRight: 1,
border: "1px solid #ccc",
}}
/>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
);
}
if (styleConfig.colorType === "gradient") {
return (
<FormControl variant="standard" fullWidth margin="dense" className="mt-3">
<InputLabel></InputLabel>
<Select
value={styleConfig.gradientPaletteIndex}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
gradientPaletteIndex: Number(e.target.value),
}))
}
>
{GRADIENT_PALETTES.map((palette, index) => {
const previewColors = resolveStyleColors(
{ ...styleConfig, colorType: "gradient", gradientPaletteIndex: index },
styleConfig.segments + 1
);
return (
<MenuItem key={index} value={index}>
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
<Box
sx={{
width: "80%",
height: 16,
borderRadius: 2,
display: "flex",
overflow: "hidden",
marginRight: 1,
border: "1px solid #ccc",
}}
>
{previewColors.map((color, colorIndex) => (
<Box
key={colorIndex}
sx={{ flex: 1, backgroundColor: color }}
/>
))}
</Box>
</Box>
</MenuItem>
);
})}
</Select>
</FormControl>
);
}
if (styleConfig.colorType === "rainbow") {
return (
<FormControl variant="standard" fullWidth margin="dense" className="mt-3">
<InputLabel></InputLabel>
<Select
value={styleConfig.rainbowPaletteIndex}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
rainbowPaletteIndex: Number(e.target.value),
}))
}
>
{RAINBOW_PALETTES.map((palette, index) => {
const previewColors = Array.from(
{ length: styleConfig.segments + 1 },
(_, colorIndex) => palette.colors[colorIndex % palette.colors.length]
);
return (
<MenuItem key={index} value={index}>
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
<Typography sx={{ marginRight: 1 }}>{palette.name}</Typography>
<Box
sx={{
width: "60%",
height: 16,
borderRadius: 2,
display: "flex",
border: "1px solid #ccc",
overflow: "hidden",
}}
>
{previewColors.map((color, colorIndex) => (
<Box
key={colorIndex}
sx={{ flex: 1, backgroundColor: color }}
/>
))}
</Box>
</Box>
</MenuItem>
);
})}
</Select>
</FormControl>
);
}
if (styleConfig.colorType === "custom") {
return (
<Box className="mt-3">
<Typography variant="subtitle2" gutterBottom>
</Typography>
<Box
className="flex flex-col gap-2"
sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "4px" }}
>
{Array.from({ length: styleConfig.segments }).map((_, index) => {
const color = styleConfig.customColors?.[index] || "rgba(0,0,0,1)";
return (
<Box key={index} className="flex items-center gap-2">
<Typography variant="caption" sx={{ width: 40 }}>
{index + 1}
</Typography>
<input
type="color"
value={rgbaToHex(color)}
onChange={(e) => {
const nextColor = hexToRgba(e.target.value);
setStyleConfig((prev) => {
const nextColors = [...(prev.customColors || [])];
while (nextColors.length < prev.segments) {
nextColors.push("rgba(0,0,0,1)");
}
nextColors[index] = nextColor;
return { ...prev, customColors: nextColors };
});
}}
style={{
width: "100%",
height: "32px",
cursor: "pointer",
border: "1px solid #ccc",
borderRadius: "4px",
}}
/>
</Box>
);
})}
</Box>
</Box>
);
}
return null;
};
const renderSizeSetting = () => {
const previewColors = getSizePreviewColors(styleConfig);
if (selectedRenderLayer?.get("type") === "point") {
return (
<Box className="mt-3">
<Typography gutterBottom>
: {styleConfig.minSize} - {styleConfig.maxSize}
</Typography>
<Box className="flex items-center gap-4">
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.minSize}
onChange={(_, value) =>
setStyleConfig((prev) => ({ ...prev, minSize: value as number }))
}
min={2}
max={8}
step={1}
size="small"
/>
</Box>
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.maxSize}
onChange={(_, value) =>
setStyleConfig((prev) => ({ ...prev, maxSize: value as number }))
}
min={10}
max={16}
step={1}
size="small"
/>
</Box>
</Box>
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
<Typography variant="caption">:</Typography>
<Box
sx={{
width: styleConfig.minSize,
height: styleConfig.minSize,
borderRadius: "50%",
backgroundColor: previewColors[0],
}}
/>
<Typography variant="caption"></Typography>
<Box
sx={{
width: styleConfig.maxSize,
height: styleConfig.maxSize,
borderRadius: "50%",
backgroundColor: previewColors[previewColors.length - 1],
}}
/>
</Box>
</Box>
);
}
if (selectedRenderLayer?.get("type") === "linestring") {
return (
<Box className="mt-3">
<FormControlLabel
control={
<Checkbox
checked={styleConfig.adjustWidthByProperty}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
adjustWidthByProperty: e.target.checked,
}))
}
disabled={styleConfig.colorType === "single"}
/>
}
label="根据数值分段调整线条宽度"
/>
{styleConfig.adjustWidthByProperty ? (
<>
<Typography gutterBottom>
线: {styleConfig.minStrokeWidth} - {styleConfig.maxStrokeWidth}
px
</Typography>
<Box className="flex items-center gap-4">
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.minStrokeWidth}
onChange={(_, value) =>
setStyleConfig((prev) => ({
...prev,
minStrokeWidth: value as number,
}))
}
min={1}
max={4}
step={0.5}
size="small"
/>
</Box>
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.maxStrokeWidth}
onChange={(_, value) =>
setStyleConfig((prev) => ({
...prev,
maxStrokeWidth: value as number,
}))
}
min={6}
max={12}
step={0.5}
size="small"
/>
</Box>
</Box>
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
<Typography variant="caption">:</Typography>
<Box
sx={{
width: 50,
height: styleConfig.minStrokeWidth,
backgroundColor: previewColors[0],
border: `1px solid ${previewColors[0]}`,
borderRadius: 1,
}}
/>
<Typography variant="caption"></Typography>
<Box
sx={{
width: 50,
height: styleConfig.maxStrokeWidth,
backgroundColor: previewColors[previewColors.length - 1],
border: `1px solid ${previewColors[previewColors.length - 1]}`,
borderRadius: 1,
}}
/>
</Box>
</>
) : (
<>
<Typography gutterBottom>
线: {styleConfig.fixedStrokeWidth}px
</Typography>
<Slider
value={styleConfig.fixedStrokeWidth}
onChange={(_, value) =>
setStyleConfig((prev) => ({
...prev,
fixedStrokeWidth: value as number,
}))
}
min={1}
max={10}
step={0.5}
size="small"
/>
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
<Typography variant="caption">:</Typography>
<Box
sx={{
width: 50,
height: styleConfig.fixedStrokeWidth,
backgroundColor: previewColors[0],
border: `1px solid ${previewColors[0]}`,
borderRadius: 1,
}}
/>
</Box>
</>
)}
</Box>
);
}
return null;
};
return (
<div className="absolute top-20 left-4 bg-white p-4 rounded-xl shadow-lg opacity-95 hover:opacity-100 transition-opacity w-80 z-1300">
<FormControl variant="standard" fullWidth margin="dense">
<InputLabel></InputLabel>
<Select
value={selectedRenderLayer ? renderLayers.indexOf(selectedRenderLayer) : ""}
onChange={(e) => onLayerChange(e.target.value as number)}
>
{renderLayers.map((layer, index) => (
<MenuItem key={index} value={index}>
{layer.get("name")}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl variant="standard" fullWidth margin="dense">
<InputLabel></InputLabel>
<Select
value={styleConfig.property}
onChange={(e) => onPropertyChange(e.target.value)}
disabled={!selectedRenderLayer}
>
{availableProperties.map((property) => (
<MenuItem key={property.name} value={property.value}>
{property.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl variant="standard" fullWidth margin="dense">
<InputLabel></InputLabel>
<Select
value={styleConfig.classificationMethod}
onChange={(e) => onClassificationMethodChange(e.target.value)}
>
{CLASSIFICATION_METHODS.map((method) => (
<MenuItem key={method.value} value={method.value}>
{method.name}
</MenuItem>
))}
</Select>
</FormControl>
<Box className="mt-3">
<Typography gutterBottom>: {styleConfig.segments}</Typography>
<Slider
value={styleConfig.segments}
onChange={(_, value) => onSegmentsChange(value as number)}
min={2}
max={10}
step={1}
marks
size="small"
/>
</Box>
{styleConfig.classificationMethod === "custom_breaks" && (
<Box className="mt-3 p-2 bg-gray-50 rounded">
<Typography variant="subtitle2" gutterBottom>
{">="} 0
</Typography>
<Box
className="flex flex-col gap-2"
sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "12px" }}
>
{Array.from({ length: styleConfig.segments }).map((_, index) => (
<TextField
key={index}
label={`阈值 ${index + 1}`}
type="number"
size="small"
slotProps={{ input: { inputProps: { min: 0, step: 0.1 } } }}
value={styleConfig.customBreaks?.[index] ?? ""}
onChange={(e) => onCustomBreakChange(index, e.target.value)}
onBlur={onCustomBreakBlur}
/>
))}
</Box>
</Box>
)}
<FormControl variant="standard" fullWidth margin="dense">
<InputLabel>
<ColorLensIcon className="mr-1" />
</InputLabel>
<Select
value={styleConfig.colorType}
onChange={(e) => onColorTypeChange(e.target.value)}
>
{COLOR_TYPE_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
{renderColorSetting()}
</FormControl>
{renderSizeSetting()}
<Box className="mt-3">
<Typography gutterBottom>
: {(styleConfig.opacity * 100).toFixed(0)}%
</Typography>
<Slider
value={styleConfig.opacity}
onChange={(_, value) =>
setStyleConfig((prev) => ({ ...prev, opacity: value as number }))
}
min={0.1}
max={1}
step={0.05}
size="small"
/>
</Box>
<FormControlLabel
control={
<Checkbox
checked={styleConfig.showId}
onChange={(e) =>
setStyleConfig((prev) => ({ ...prev, showId: e.target.checked }))
}
/>
}
label="显示 ID(缩放 >=15 级时显示)"
/>
<FormControlLabel
control={
<Checkbox
checked={styleConfig.showLabels}
onChange={(e) =>
setStyleConfig((prev) => ({ ...prev, showLabels: e.target.checked }))
}
/>
}
label="显示属性(缩放 >=15 级时显示)"
/>
<div className="my-3"></div>
<Box className="flex gap-2">
<Button
variant="contained"
color="primary"
onClick={onApply}
disabled={!selectedRenderLayer || !styleConfig.property}
startIcon={<ApplyIcon />}
fullWidth
>
</Button>
<Button
variant="outlined"
onClick={onReset}
disabled={!selectedRenderLayer}
startIcon={<ResetIcon />}
fullWidth
>
</Button>
</Box>
</div>
);
};
export default StyleEditorForm;
File diff suppressed because it is too large Load Diff
@@ -85,6 +85,7 @@ const Timeline: React.FC<TimelineProps> = ({
const isCompareMode = data?.isCompareMode ?? false;
const junctionText = data?.junctionText ?? "";
const pipeText = data?.pipeText ?? "";
const setForceStyleAutoApplyVersion = data?.setForceStyleAutoApplyVersion;
const { open } = useNotification();
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒
@@ -657,6 +658,7 @@ const Timeline: React.FC<TimelineProps> = ({
});
// 清空当天当前时刻及之后的缓存并重新获取数据
clearCacheAndRefetch(calculationDate, calculationTime);
setForceStyleAutoApplyVersion?.((prev) => prev + 1);
} else {
open?.({
type: "error",
+5 -76
View File
@@ -14,7 +14,8 @@ import VectorLayer from "ol/layer/Vector";
import { Style, Stroke, Fill, Circle } from "ol/style";
import Feature from "ol/Feature";
import StyleEditorPanel from "./StyleEditorPanel";
import { LayerStyleState } from "./StyleEditorPanel";
import { createDefaultLayerStyleStates } from "./styleEditorPresets";
import { LayerStyleState } from "./styleEditorTypes";
import StyleLegend from "./StyleLegend"; // 引入图例组件
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
import { useNotification } from "@refinedev/core";
@@ -90,81 +91,9 @@ const Toolbar: React.FC<ToolbarProps> = ({
});
// 样式状态管理 - 在 Toolbar 中管理,带有默认样式
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>([
{
isActive: false, // 默认不激活,不显示图例
layerId: "junctions",
layerName: "节点",
styleConfig: {
property: "pressure",
classificationMethod: "custom_breaks",
customBreaks: [16, 18, 20, 22, 24, 26],
customColors: [
"rgba(255, 0, 0, 1)",
"rgba(255, 127, 0, 1)",
"rgba(255, 215, 0, 1)",
"rgba(199, 224, 0, 1)",
"rgba(76, 175, 80, 1)",
"rgba(0, 158, 115, 1)",
],
segments: 6,
minSize: 4,
maxSize: 12,
minStrokeWidth: 2,
maxStrokeWidth: 8,
fixedStrokeWidth: 3,
colorType: "rainbow",
singlePaletteIndex: 0,
gradientPaletteIndex: 0,
rainbowPaletteIndex: 0,
showLabels: false,
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
},
legendConfig: {
layerId: "junctions",
layerName: "节点",
property: "压力", // 暂时为空,等计算后更新
colors: [],
type: "point",
dimensions: [],
breaks: [],
},
},
{
isActive: false, // 默认不激活,不显示图例
layerId: "pipes",
layerName: "管道",
styleConfig: {
property: "flow",
classificationMethod: "pretty_breaks",
segments: 6,
minSize: 4,
maxSize: 12,
minStrokeWidth: 2,
maxStrokeWidth: 8,
fixedStrokeWidth: 3,
colorType: "gradient",
singlePaletteIndex: 0,
gradientPaletteIndex: 0,
rainbowPaletteIndex: 0,
showLabels: false,
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
},
legendConfig: {
layerId: "pipes",
layerName: "管道",
property: "流量", // 暂时为空,等计算后更新
colors: [],
type: "linestring",
dimensions: [],
breaks: [],
},
},
]);
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>(
() => createDefaultLayerStyleStates()
);
// 计算激活的图例配置
const activeLegendConfigs = layerStyleStates
@@ -0,0 +1,200 @@
import { LayerStyleState, StyleConfig, DefaultLayerStyleId } from "./styleEditorTypes";
export const SINGLE_COLOR_PALETTES = [
{ color: "rgba(51, 153, 204, 1)" },
{ color: "rgba(255, 138, 92, 1)" },
{ color: "rgba(204, 51, 51, 1)" },
{ color: "rgba(255, 235, 59, 1)" },
{ color: "rgba(44, 160, 44, 1)" },
{ color: "rgba(227, 119, 194, 1)" },
{ color: "rgba(148, 103, 189, 1)" },
];
export const GRADIENT_PALETTES = [
{
name: "蓝-红",
start: "rgba(51, 153, 204, 1)",
end: "rgba(204, 51, 51, 1)",
},
{
name: "黄-绿",
start: "rgba(255, 235, 59, 1)",
end: "rgba(44, 160, 44, 1)",
},
{
name: "粉-紫",
start: "rgba(227, 119, 194, 1)",
end: "rgba(148, 103, 189, 1)",
},
];
export const RAINBOW_PALETTES = [
{
name: "正向彩虹",
colors: [
"rgba(255, 0, 0, 1)",
"rgba(255, 127, 0, 1)",
"rgba(255, 215, 0, 1)",
"rgba(199, 224, 0, 1)",
"rgba(76, 175, 80, 1)",
"rgba(0, 158, 115, 1)",
"rgba(0, 188, 212, 1)",
"rgba(33, 150, 243, 1)",
"rgba(63, 81, 181, 1)",
"rgba(142, 68, 173, 1)",
],
},
{
name: "反向彩虹",
colors: [
"rgba(142, 68, 173, 1)",
"rgba(63, 81, 181, 1)",
"rgba(33, 150, 243, 1)",
"rgba(0, 188, 212, 1)",
"rgba(0, 158, 115, 1)",
"rgba(76, 175, 80, 1)",
"rgba(199, 224, 0, 1)",
"rgba(255, 215, 0, 1)",
"rgba(255, 127, 0, 1)",
"rgba(255, 0, 0, 1)",
],
},
];
export const CLASSIFICATION_METHODS = [
{ name: "优雅分段", value: "pretty_breaks" },
{ name: "自定义", value: "custom_breaks" },
];
export const COLOR_TYPE_OPTIONS = [
{ label: "单一色", value: "single" },
{ label: "渐进色", value: "gradient" },
{ label: "离散彩虹", value: "rainbow" },
{ label: "自定义", value: "custom" },
];
const DEFAULT_LAYER_STYLE_PRESETS: Record<
DefaultLayerStyleId,
Omit<LayerStyleState, "isActive">
> = {
junctions: {
layerId: "junctions",
layerName: "节点",
styleConfig: {
property: "pressure",
classificationMethod: "custom_breaks",
customBreaks: [16, 18, 20, 22, 24, 26],
customColors: [
"rgba(255, 0, 0, 1)",
"rgba(255, 127, 0, 1)",
"rgba(255, 215, 0, 1)",
"rgba(199, 224, 0, 1)",
"rgba(76, 175, 80, 1)",
"rgba(0, 158, 115, 1)",
],
segments: 6,
minSize: 4,
maxSize: 12,
minStrokeWidth: 2,
maxStrokeWidth: 8,
fixedStrokeWidth: 3,
colorType: "rainbow",
singlePaletteIndex: 0,
gradientPaletteIndex: 0,
rainbowPaletteIndex: 0,
showLabels: true,
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
},
legendConfig: {
layerId: "junctions",
layerName: "节点",
property: "压力",
colors: [],
type: "point",
dimensions: [],
breaks: [],
},
},
pipes: {
layerId: "pipes",
layerName: "管道",
styleConfig: {
property: "velocity",
classificationMethod: "custom_breaks",
segments: 6,
minSize: 4,
maxSize: 12,
minStrokeWidth: 2,
maxStrokeWidth: 8,
fixedStrokeWidth: 3,
colorType: "gradient",
singlePaletteIndex: 0,
gradientPaletteIndex: 0,
rainbowPaletteIndex: 0,
showLabels: true,
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
customBreaks: [0.2, 0.4, 0.6, 0.8, 1.0, 1.2],
customColors: [],
},
legendConfig: {
layerId: "pipes",
layerName: "管道",
property: "流速",
colors: [],
type: "linestring",
dimensions: [],
breaks: [],
},
},
};
export const createEmptyStyleConfig = (): StyleConfig => ({
property: "",
classificationMethod: "pretty_breaks",
segments: 5,
minSize: 4,
maxSize: 12,
minStrokeWidth: 2,
maxStrokeWidth: 6,
fixedStrokeWidth: 3,
colorType: "single",
singlePaletteIndex: 0,
gradientPaletteIndex: 0,
rainbowPaletteIndex: 0,
showLabels: false,
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
customBreaks: [],
customColors: [],
});
export const createDefaultLayerStyleState = (
layerId: DefaultLayerStyleId
): LayerStyleState => {
const preset = DEFAULT_LAYER_STYLE_PRESETS[layerId];
return {
...preset,
styleConfig: {
...preset.styleConfig,
customBreaks: [...(preset.styleConfig.customBreaks || [])],
customColors: [...(preset.styleConfig.customColors || [])],
},
legendConfig: {
...preset.legendConfig,
colors: [...preset.legendConfig.colors],
dimensions: [...preset.legendConfig.dimensions],
breaks: [...preset.legendConfig.breaks],
},
isActive: false,
};
};
export const createDefaultLayerStyleStates = (): LayerStyleState[] => [
createDefaultLayerStyleState("junctions"),
createDefaultLayerStyleState("pipes"),
];
@@ -0,0 +1,62 @@
import React from "react";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import { LegendStyleConfig } from "./StyleLegend";
export interface StyleConfig {
property: string;
classificationMethod: string;
segments: number;
minSize: number;
maxSize: number;
minStrokeWidth: number;
maxStrokeWidth: number;
fixedStrokeWidth: number;
colorType: string;
singlePaletteIndex: number;
gradientPaletteIndex: number;
rainbowPaletteIndex: number;
showLabels: boolean;
showId: boolean;
opacity: number;
adjustWidthByProperty: boolean;
customBreaks?: number[];
customColors?: string[];
}
export interface LayerStyleState {
layerId: string;
layerName: string;
styleConfig: StyleConfig;
legendConfig: LegendStyleConfig;
isActive: boolean;
}
export type DefaultLayerStyleId = "junctions" | "pipes";
export interface StyleEditorPanelProps {
layerStyleStates: LayerStyleState[];
setLayerStyleStates: React.Dispatch<React.SetStateAction<LayerStyleState[]>>;
}
export interface AvailableProperty {
name: string;
value: string;
}
export interface StyleEditorFormProps {
renderLayers: WebGLVectorTileLayer[];
selectedRenderLayer?: WebGLVectorTileLayer;
styleConfig: StyleConfig;
setStyleConfig: React.Dispatch<React.SetStateAction<StyleConfig>>;
availableProperties: AvailableProperty[];
onLayerChange: (index: number) => void;
onPropertyChange: (property: string) => void;
onClassificationMethodChange: (method: string) => void;
onSegmentsChange: (segments: number) => void;
onCustomBreakChange: (index: number, value: string) => void;
onCustomBreakBlur: () => void;
onColorTypeChange: (colorType: string) => void;
onApply: () => void;
onReset: () => void;
}
@@ -0,0 +1,348 @@
import { FlatStyleLike } from "ol/style/flat";
import { calculateClassification } from "@utils/breaks_classification";
import { parseColor } from "@utils/parseColor";
import {
GRADIENT_PALETTES,
RAINBOW_PALETTES,
SINGLE_COLOR_PALETTES,
} from "./styleEditorPresets";
import { StyleConfig } from "./styleEditorTypes";
export const rgbaToHex = (rgba: string) => {
try {
const c = parseColor(rgba);
const toHex = (n: number) => {
const hex = Math.round(n).toString(16);
return hex.length === 1 ? `0${hex}` : hex;
};
return `#${toHex(c.r)}${toHex(c.g)}${toHex(c.b)}`;
} catch {
return "#000000";
}
};
export const hexToRgba = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(
result[3],
16
)}, 1)`
: "rgba(0, 0, 0, 1)";
};
export const getDefaultCustomColors = (
segments: number,
existingColors: string[] = []
) => {
const nextColors = [...existingColors];
const baseColors = RAINBOW_PALETTES[0].colors;
while (nextColors.length < segments) {
nextColors.push(baseColors[nextColors.length % baseColors.length]);
}
return nextColors.slice(0, segments);
};
export const getDefaultCustomBreaks = ({
segments,
property,
layerId,
elevationRange,
diameterRange,
currentJunctionCalData,
currentPipeCalData,
}: {
segments: number;
property: string;
layerId?: string;
elevationRange?: [number, number];
diameterRange?: [number, number];
currentJunctionCalData?: any[];
currentPipeCalData?: any[];
}) => {
if (!layerId || !property) {
return Array.from({ length: segments }, () => 0);
}
let dataArr: number[] = [];
const isElevation = layerId === "junctions" && property === "elevation";
const isDiameter = layerId === "pipes" && property === "diameter";
if (isElevation && elevationRange) {
dataArr = [elevationRange[0], elevationRange[1]];
} else if (isDiameter && diameterRange) {
dataArr = [diameterRange[0], diameterRange[1]];
} else if (layerId === "junctions" && currentJunctionCalData) {
dataArr = currentJunctionCalData.map((d: any) => d.value);
} else if (layerId === "pipes" && currentPipeCalData) {
dataArr = currentPipeCalData.map((d: any) => d.value);
}
if (dataArr.length === 0) {
return Array.from({ length: segments }, () => 0);
}
const defaultBreaks = calculateClassification(
dataArr,
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 resolveStyleColors = (
styleConfig: StyleConfig,
breaksLength: number
): string[] => {
if (styleConfig.colorType === "single") {
return Array.from(
{ length: breaksLength },
() => SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color
);
}
if (styleConfig.colorType === "gradient") {
const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex];
const startColor = parseColor(start);
const endColor = parseColor(end);
return Array.from({ length: breaksLength }, (_, index) => {
const ratio = breaksLength > 1 ? index / (breaksLength - 1) : 1;
const r = Math.round(startColor.r + (endColor.r - startColor.r) * ratio);
const g = Math.round(startColor.g + (endColor.g - startColor.g) * ratio);
const b = Math.round(startColor.b + (endColor.b - startColor.b) * ratio);
return `rgba(${r}, ${g}, ${b}, 1)`;
});
}
if (styleConfig.colorType === "rainbow") {
const baseColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors;
return Array.from(
{ length: breaksLength },
(_, index) => baseColors[index % baseColors.length]
);
}
const customColors = styleConfig.customColors || [];
const reverseRainbowColors = RAINBOW_PALETTES[1].colors;
const result = [...customColors];
while (result.length < breaksLength) {
result.push(
reverseRainbowColors[
(result.length - customColors.length) % reverseRainbowColors.length
]
);
}
return result.slice(0, breaksLength);
};
export const getSizePreviewColors = (styleConfig: StyleConfig) => {
if (styleConfig.colorType === "single") {
const color = SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color;
return [color, color];
}
if (styleConfig.colorType === "gradient") {
const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex];
return [start, end];
}
if (styleConfig.colorType === "rainbow") {
const rainbowColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors;
return [rainbowColors[0], rainbowColors[rainbowColors.length - 1]];
}
const customColors = styleConfig.customColors || [];
return [
customColors[0] || "rgba(0,0,0,1)",
customColors[customColors.length - 1] || "rgba(0,0,0,1)",
];
};
export const resolveDimensions = ({
layerType,
styleConfig,
breaksLength,
}: {
layerType: string;
styleConfig: StyleConfig;
breaksLength: number;
}) => {
if (layerType === "linestring") {
if (styleConfig.adjustWidthByProperty) {
return Array.from({ length: breaksLength }, (_, index) => {
const ratio = index / (breaksLength - 1);
return (
styleConfig.minStrokeWidth +
(styleConfig.maxStrokeWidth - styleConfig.minStrokeWidth) * ratio
);
});
}
return Array.from(
{ length: breaksLength },
() => styleConfig.fixedStrokeWidth
);
}
return Array.from({ length: breaksLength }, (_, index) => {
const ratio = index / (breaksLength - 1);
return styleConfig.minSize + (styleConfig.maxSize - styleConfig.minSize) * ratio;
});
};
export const buildDynamicStyle = ({
layerType,
styleConfig,
breaks,
colors,
dimensions,
}: {
layerType: string;
styleConfig: StyleConfig;
breaks: number[];
colors: string[];
dimensions: number[];
}): FlatStyleLike => {
const generateColorConditions = (property: string): any[] => {
const conditions: any[] = ["case"];
for (let index = 1; index < breaks.length; index++) {
if (property === "unit_headloss") {
conditions.push([
"<=",
["/", ["get", "unit_headloss"], ["/", ["get", "length"], 1000]],
breaks[index],
]);
} else {
conditions.push(["<=", ["get", property], breaks[index]]);
}
const colorObj = parseColor(colors[index - 1]);
conditions.push(
`rgba(${colorObj.r}, ${colorObj.g}, ${colorObj.b}, ${styleConfig.opacity})`
);
}
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") {
dynamicStyle["stroke-color"] = generateColorConditions(styleConfig.property);
dynamicStyle["stroke-width"] = generateDimensionConditions(styleConfig.property);
} else if (layerType === "point") {
dynamicStyle["circle-fill-color"] = generateColorConditions(styleConfig.property);
dynamicStyle["circle-radius"] = generatePointDimensionConditions(
styleConfig.property
);
dynamicStyle["circle-stroke-color"] = generateColorConditions(styleConfig.property);
dynamicStyle["circle-stroke-width"] = 2;
}
return dynamicStyle;
};
export const buildContourDefinitions = ({
styleConfig,
breaks,
colors,
}: {
styleConfig: StyleConfig;
breaks: number[];
colors: string[];
}) => {
const contours = [];
for (let index = 0; index < breaks.length - 1; index++) {
const colorObj = parseColor(colors[index]);
contours.push({
threshold: [breaks[index], breaks[index + 1]],
color: [
colorObj.r,
colorObj.g,
colorObj.b,
Math.round(styleConfig.opacity * 255),
],
strokeWidth: 0,
});
}
return contours;
};
@@ -0,0 +1,944 @@
import { useNotification } from "@refinedev/core";
import { VectorTile } from "ol";
import type { Map as OlMap } from "ol";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import VectorTileSource from "ol/source/VectorTile";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FlatStyleLike } from "ol/style/flat";
import { config } from "@/config/config";
import { useData, useMap } from "../MapComponent";
import {
createDefaultLayerStyleState,
createEmptyStyleConfig,
} from "./styleEditorPresets";
import {
addBreakExtrema,
buildContourDefinitions,
buildDynamicStyle,
getDefaultCustomBreaks,
getDefaultCustomColors,
normalizeCustomBreaks,
resolveDimensions,
resolveStyleColors,
} from "./styleEditorUtils";
import {
AvailableProperty,
LayerStyleState,
StyleEditorPanelProps,
} from "./styleEditorTypes";
import { LegendStyleConfig } from "./StyleLegend";
import { calculateClassification } from "@utils/breaks_classification";
const UNIT_HEADLOSS_RANGE: [number, number] = [0, 5];
export const useStyleEditor = ({
layerStyleStates,
setLayerStyleStates,
}: StyleEditorPanelProps) => {
const map = useMap();
const data = useData();
const { open } = useNotification();
const currentJunctionCalData = data?.currentJunctionCalData;
const currentPipeCalData = data?.currentPipeCalData;
const compareJunctionCalData = data?.compareJunctionCalData;
const comparePipeCalData = data?.comparePipeCalData;
const compareMap = data?.compareMap;
const activeMaps = useMemo<OlMap[]>(
() => (data?.maps?.length ? data.maps : map ? [map] : []),
[data?.maps, map]
);
const junctionText = data?.junctionText ?? "";
const pipeText = data?.pipeText ?? "";
const setShowJunctionTextLayer = data?.setShowJunctionTextLayer;
const setShowPipeTextLayer = data?.setShowPipeTextLayer;
const setShowJunctionId = data?.setShowJunctionId;
const setShowPipeId = data?.setShowPipeId;
const setContourLayerAvailable = data?.setContourLayerAvailable;
const setWaterflowLayerAvailable = data?.setWaterflowLayerAvailable;
const setJunctionText = data?.setJunctionText;
const setPipeText = data?.setPipeText;
const setContours = data?.setContours;
const diameterRange = data?.diameterRange;
const elevationRange = data?.elevationRange;
const forceStyleAutoApplyVersion = data?.forceStyleAutoApplyVersion ?? 0;
const [applyJunctionStyle, setApplyJunctionStyle] = useState(false);
const [applyPipeStyle, setApplyPipeStyle] = useState(false);
const [styleUpdateTrigger, setStyleUpdateTrigger] = useState(0);
const prevStyleUpdateTriggerRef = useRef(0);
const lastForceStyleAutoApplyVersionRef = useRef(0);
const [renderLayers, setRenderLayers] = useState<WebGLVectorTileLayer[]>([]);
const [selectedRenderLayer, setSelectedRenderLayer] =
useState<WebGLVectorTileLayer>();
const [styleConfig, setStyleConfig] = useState(createEmptyStyleConfig);
const latestLayerStyleStatesRef = useRef(layerStyleStates);
const tileLoadListenersRef = useRef<
Map<string, { source: VectorTileSource; listener: (event: any) => void }>
>(new Map());
useEffect(() => {
latestLayerStyleStatesRef.current = layerStyleStates;
}, [layerStyleStates]);
const getRenderLayersById = useCallback(
(layerId: string) =>
activeMaps.flatMap((targetMap) =>
targetMap
.getAllLayers()
.filter((layer) => layer.get("value") === layerId)
.filter(
(layer): layer is WebGLVectorTileLayer =>
layer instanceof WebGLVectorTileLayer
)
),
[activeMaps]
);
const getMapKey = useCallback((targetMap: OlMap, layerId: string) => {
const mapUid = (targetMap as unknown as { ol_uid?: string }).ol_uid || "map";
return `${mapUid}:${layerId}`;
}, []);
const getDataForMap = useCallback(
(targetMap: OlMap, layerId: string) => {
if (layerId === "junctions") {
return targetMap === compareMap
? compareJunctionCalData || []
: currentJunctionCalData || [];
}
if (layerId === "pipes") {
return targetMap === compareMap
? comparePipeCalData || []
: currentPipeCalData || [];
}
return [];
},
[
compareJunctionCalData,
compareMap,
comparePipeCalData,
currentJunctionCalData,
currentPipeCalData,
]
);
const availableProperties = useMemo<AvailableProperty[]>(() => {
if (!selectedRenderLayer) {
return [];
}
return (selectedRenderLayer.get("properties") || []) as AvailableProperty[];
}, [selectedRenderLayer]);
const getBreakDefaults = useCallback(
(segments: number, property: string, layer = selectedRenderLayer) =>
getDefaultCustomBreaks({
segments,
property,
layerId: layer?.get("value"),
elevationRange,
diameterRange,
currentJunctionCalData,
currentPipeCalData,
}),
[
currentJunctionCalData,
currentPipeCalData,
diameterRange,
elevationRange,
selectedRenderLayer,
]
);
const saveLayerStyle = useCallback(
(
layerId?: string,
newLegendConfig?: LegendStyleConfig,
overrideStyleConfig = styleConfig
) => {
if (!overrideStyleConfig.property || !layerId) {
return;
}
const layerName =
newLegendConfig?.layerName ||
selectedRenderLayer?.get("name") ||
`图层${layerId}`;
const property = availableProperties.find(
(item) => item.value === overrideStyleConfig.property
);
const legendConfig: LegendStyleConfig = newLegendConfig || {
layerId,
layerName,
property: property?.name || overrideStyleConfig.property,
colors: [],
type: selectedRenderLayer?.get("type") || "point",
dimensions: [],
breaks: [],
};
const newStyleState: LayerStyleState = {
layerId,
layerName,
styleConfig: { ...overrideStyleConfig },
legendConfig: { ...legendConfig },
isActive: true,
};
setLayerStyleStates((prev) => {
const existingIndex = prev.findIndex((state) => state.layerId === layerId);
if (existingIndex !== -1) {
const existingState = prev[existingIndex];
if (
JSON.stringify(existingState.styleConfig) ===
JSON.stringify(newStyleState.styleConfig) &&
JSON.stringify(existingState.legendConfig) ===
JSON.stringify(newStyleState.legendConfig) &&
existingState.layerName === newStyleState.layerName &&
existingState.isActive === newStyleState.isActive
) {
return prev;
}
const updated = [...prev];
updated[existingIndex] = newStyleState;
return updated;
}
return [...prev, newStyleState];
});
},
[availableProperties, selectedRenderLayer, setLayerStyleStates, styleConfig]
);
const applyContourLayerStyle = useCallback(
(layerStyleConfig: LayerStyleState, breaks?: number[]) => {
if (!breaks || breaks.length === 0 || !setContours) {
return;
}
const colors = resolveStyleColors(layerStyleConfig.styleConfig, breaks.length);
setContours(
buildContourDefinitions({
styleConfig: layerStyleConfig.styleConfig,
breaks,
colors,
})
);
},
[setContours]
);
const applyLayerStyle = useCallback(
(layerStyleConfig: LayerStyleState, breaks?: number[]) => {
if (!breaks || breaks.length === 0) {
return;
}
const nextStyleConfig = layerStyleConfig.styleConfig;
const targetLayers = getRenderLayersById(layerStyleConfig.layerId);
const renderLayer = targetLayers[0];
if (!renderLayer || !nextStyleConfig.property) {
return;
}
const layerType = renderLayer.get("type") as string;
const colors = resolveStyleColors(nextStyleConfig, breaks.length);
const dimensions = resolveDimensions({
layerType,
styleConfig: nextStyleConfig,
breaksLength: breaks.length,
});
const dynamicStyle = buildDynamicStyle({
layerType,
styleConfig: nextStyleConfig,
breaks,
colors,
dimensions,
});
targetLayers.forEach((targetLayer) => {
targetLayer.setStyle(dynamicStyle);
});
const layerId = renderLayer.get("value");
const initLayerStyleState = latestLayerStyleStatesRef.current.find(
(state) => state.layerId === layerId
);
const legendConfig: LegendStyleConfig = {
layerName: initLayerStyleState?.layerName || `图层${layerId}`,
layerId,
property: initLayerStyleState?.legendConfig.property || "",
colors,
type: layerType,
dimensions,
breaks,
};
setTimeout(() => {
saveLayerStyle(layerId, legendConfig, nextStyleConfig);
}, 100);
},
[getRenderLayersById, saveLayerStyle]
);
const applyClassificationStyle = useCallback(
(layerType: "junctions" | "pipes", fallbackStyleConfig?: LayerStyleState["styleConfig"]) => {
const layerStyleState = latestLayerStyleStatesRef.current.find(
(state) => state.layerId === layerType
);
const effectiveStyleConfig = layerStyleState?.styleConfig || fallbackStyleConfig;
if (!effectiveStyleConfig) {
return;
}
const isElevation =
layerType === "junctions" && effectiveStyleConfig.property === "elevation";
const isDiameter =
layerType === "pipes" && effectiveStyleConfig.property === "diameter";
const isUnitHeadloss =
layerType === "pipes" && effectiveStyleConfig.property === "unit_headloss";
const dataValues =
layerType === "junctions"
? isElevation && elevationRange
? [elevationRange[0], elevationRange[1]]
: currentJunctionCalData?.map((item: any) => item.value) || []
: isDiameter && diameterRange
? [diameterRange[0], diameterRange[1]]
: isUnitHeadloss
? [UNIT_HEADLOSS_RANGE[0], UNIT_HEADLOSS_RANGE[1]]
: currentPipeCalData?.map((item: any) => item.value) || [];
const canApply =
layerType === "junctions"
? dataValues.length > 0
: dataValues.length > 0 || isUnitHeadloss;
if (!canApply || dataValues.length === 0) {
return;
}
const segments = effectiveStyleConfig.segments ?? 5;
let breaks =
effectiveStyleConfig.classificationMethod === "custom_breaks"
? normalizeCustomBreaks(effectiveStyleConfig.customBreaks || [], segments)
: calculateClassification(
dataValues,
segments,
effectiveStyleConfig.classificationMethod
);
if (breaks.length === 0) {
return;
}
breaks = addBreakExtrema(breaks, dataValues);
const styleStateToApply =
layerStyleState ||
({
layerId: layerType,
layerName: layerType === "junctions" ? "节点" : "管道",
styleConfig: effectiveStyleConfig,
legendConfig: {
layerId: layerType,
layerName: layerType === "junctions" ? "节点" : "管道",
property: effectiveStyleConfig.property,
colors: [],
type: layerType === "junctions" ? "point" : "linestring",
dimensions: [],
breaks: [],
},
isActive: true,
} as LayerStyleState);
applyLayerStyle(styleStateToApply, breaks);
if (layerType === "junctions") {
applyContourLayerStyle(styleStateToApply, breaks);
}
},
[
applyContourLayerStyle,
applyLayerStyle,
currentJunctionCalData,
currentPipeCalData,
diameterRange,
elevationRange,
]
);
const updateVectorTileSource = useCallback(
(targetMap: OlMap, layerId: string, property: string, records: any[]) => {
const vectorTileSources = targetMap
.getAllLayers()
.filter((layer) => layer.get("value") === layerId)
.map((layer) => layer.getSource() as VectorTileSource)
.filter((source) => source);
if (!vectorTileSources.length) {
return;
}
const dataMap = new Map<string, number>();
records.forEach((record: any) => {
dataMap.set(record.ID, record.value || 0);
});
vectorTileSources.forEach((vectorTileSource) => {
const sourceTiles = (vectorTileSource as any).sourceTiles_;
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 = dataMap.get(featureId);
if (value === undefined) {
return;
}
renderFeature.properties_[property] =
property === "flow" ? Math.abs(value) : value;
});
});
});
},
[]
);
const attachVectorTileSourceLoadedEvent = useCallback(
(targetMap: OlMap, layerId: string, property: string, records: any[]) => {
const vectorTileSource = targetMap
.getAllLayers()
.filter((layer) => layer.get("value") === layerId)
.map((layer) => layer.getSource() as VectorTileSource)
.filter((source) => source)[0];
if (!vectorTileSource) {
return;
}
const dataMap = new Map<string, number>();
records.forEach((record: any) => {
dataMap.set(record.ID, record.value || 0);
});
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) => {
const featureId = renderFeature.get("id");
const value = dataMap.get(featureId);
if (value === undefined) {
return;
}
renderFeature.properties_[property] =
property === "flow" ? Math.abs(value) : value;
});
} catch (error) {
console.error("Error processing tile load event:", error);
}
};
const listenerKey = getMapKey(targetMap, layerId);
vectorTileSource.on("tileloadend", listener);
tileLoadListenersRef.current.set(listenerKey, {
source: vectorTileSource,
listener,
});
},
[getMapKey]
);
const removeVectorTileSourceLoadedEvent = useCallback(
(targetMap: OlMap, layerId: string) => {
const listenerKey = getMapKey(targetMap, layerId);
const listenerState = tileLoadListenersRef.current.get(listenerKey);
if (listenerState) {
listenerState.source.un("tileloadend", listenerState.listener);
tileLoadListenersRef.current.delete(listenerKey);
}
},
[getMapKey]
);
const handleApply = useCallback(() => {
if (!selectedRenderLayer || !styleConfig.property) {
return;
}
const layerId = selectedRenderLayer.get("value");
const property = styleConfig.property;
if (styleConfig.classificationMethod === "custom_breaks") {
const expected = styleConfig.segments;
const custom = styleConfig.customBreaks || [];
if (
custom.length !== expected ||
custom.some((value) => value === undefined || value === null || isNaN(value))
) {
open?.({
type: "error",
message: `请设置 ${expected} 个有效的自定义阈值(数字)`,
});
return;
}
if (custom.some((value) => value < 0)) {
open?.({ type: "error", message: "自定义阈值必须大于等于 0" });
return;
}
setStyleConfig((prev) => ({
...prev,
customBreaks: [...(prev.customBreaks || [])]
.slice(0, expected)
.sort((a, b) => a - b),
}));
}
if (layerId === "junctions") {
setJunctionText?.(property);
setShowJunctionTextLayer?.(styleConfig.showLabels);
setShowJunctionId?.(styleConfig.showId);
setApplyJunctionStyle(true);
if (property === "pressure") {
setContourLayerAvailable?.(true);
}
saveLayerStyle(layerId);
open?.({
type: "success",
message: "节点图层样式设置成功,等待数据更新。",
});
}
if (layerId === "pipes") {
setPipeText?.(property);
setShowPipeTextLayer?.(styleConfig.showLabels);
setShowPipeId?.(styleConfig.showId);
setApplyPipeStyle(true);
setWaterflowLayerAvailable?.(true);
saveLayerStyle(layerId);
open?.({
type: "success",
message: "管道图层样式设置成功,等待数据更新。",
});
}
setStyleUpdateTrigger((prev) => prev + 1);
}, [
open,
saveLayerStyle,
selectedRenderLayer,
setContourLayerAvailable,
setJunctionText,
setPipeText,
setShowJunctionId,
setShowJunctionTextLayer,
setShowPipeId,
setShowPipeTextLayer,
setWaterflowLayerAvailable,
styleConfig,
]);
const handleReset = useCallback(() => {
if (!selectedRenderLayer) {
return;
}
const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE;
const layerId = selectedRenderLayer.get("value");
getRenderLayersById(layerId).forEach((targetLayer) => {
targetLayer.setStyle(defaultFlatStyle);
});
setLayerStyleStates((prev) => prev.filter((state) => state.layerId !== layerId));
if (layerId === "junctions") {
setApplyJunctionStyle(false);
setShowJunctionTextLayer?.(false);
setShowJunctionId?.(false);
setJunctionText?.("");
setContours?.([]);
setContourLayerAvailable?.(false);
} else if (layerId === "pipes") {
setApplyPipeStyle(false);
setShowPipeTextLayer?.(false);
setShowPipeId?.(false);
setPipeText?.("");
setWaterflowLayerAvailable?.(false);
}
}, [
getRenderLayersById,
selectedRenderLayer,
setContourLayerAvailable,
setContours,
setJunctionText,
setLayerStyleStates,
setPipeText,
setShowJunctionId,
setShowJunctionTextLayer,
setShowPipeId,
setShowPipeTextLayer,
setWaterflowLayerAvailable,
]);
const handleLayerChange = useCallback(
(index: number) => {
const newLayer = index >= 0 ? renderLayers[index] : undefined;
setSelectedRenderLayer(newLayer);
if (!newLayer) {
return;
}
const layerId = newLayer.get("value");
const cachedStyleState = layerStyleStates.find((state) => state.layerId === layerId);
if (cachedStyleState) {
setStyleConfig(cachedStyleState.styleConfig);
return;
}
setStyleConfig((prev) => ({
...prev,
property: "",
customBreaks:
prev.classificationMethod === "custom_breaks"
? getBreakDefaults(prev.segments, "", newLayer)
: prev.customBreaks,
customColors: getDefaultCustomColors(prev.segments, prev.customColors),
}));
},
[getBreakDefaults, layerStyleStates, renderLayers]
);
const handlePropertyChange = useCallback(
(property: string) => {
setStyleConfig((prev) => ({
...prev,
property,
customBreaks:
prev.classificationMethod === "custom_breaks"
? getBreakDefaults(prev.segments, property)
: prev.customBreaks,
}));
},
[getBreakDefaults]
);
const handleClassificationMethodChange = useCallback(
(classificationMethod: string) => {
setStyleConfig((prev) => ({
...prev,
classificationMethod,
customBreaks:
classificationMethod === "custom_breaks"
? getBreakDefaults(prev.segments, prev.property)
: prev.customBreaks,
}));
},
[getBreakDefaults]
);
const handleSegmentsChange = useCallback(
(segments: number) => {
setStyleConfig((prev) => {
const newCustomColors = [...(prev.customColors || [])];
return {
...prev,
segments,
customBreaks:
prev.classificationMethod === "custom_breaks"
? getBreakDefaults(segments, prev.property)
: prev.customBreaks,
customColors: getDefaultCustomColors(segments, newCustomColors),
};
});
},
[getBreakDefaults]
);
const handleCustomBreakChange = useCallback(
(index: number, value: string) => {
const nextValue = parseFloat(value);
setStyleConfig((prev) => {
const nextBreaks = [...(prev.customBreaks || [])];
while (nextBreaks.length < prev.segments) {
nextBreaks.push(0);
}
nextBreaks[index] = isNaN(nextValue) ? 0 : Math.max(0, nextValue);
return { ...prev, customBreaks: nextBreaks };
});
},
[]
);
const handleCustomBreakBlur = useCallback(() => {
setStyleConfig((prev) => ({
...prev,
customBreaks: [...(prev.customBreaks || [])]
.slice(0, prev.segments + 1)
.sort((a, b) => a - b),
}));
}, []);
const handleColorTypeChange = useCallback((colorType: string) => {
setStyleConfig((prev) => {
let customColors = prev.customColors;
if (colorType === "custom" && (!customColors || customColors.length === 0)) {
customColors = getDefaultCustomColors(prev.segments, []);
}
return {
...prev,
colorType,
adjustWidthByProperty: colorType === "single" ? true : prev.adjustWidthByProperty,
customColors,
};
});
}, []);
useEffect(() => {
if (
forceStyleAutoApplyVersion <= 0 ||
forceStyleAutoApplyVersion === lastForceStyleAutoApplyVersionRef.current
) {
return;
}
lastForceStyleAutoApplyVersionRef.current = forceStyleAutoApplyVersion;
const defaultJunctionStyleState = {
...createDefaultLayerStyleState("junctions"),
isActive: true,
};
const defaultPipeStyleState = {
...createDefaultLayerStyleState("pipes"),
isActive: true,
};
setLayerStyleStates((prev) => {
const nextStates = [...prev];
[defaultJunctionStyleState, defaultPipeStyleState].forEach((defaultState) => {
const index = nextStates.findIndex((state) => state.layerId === defaultState.layerId);
if (index === -1) {
nextStates.push(defaultState);
} else {
nextStates[index] = defaultState;
}
});
return nextStates;
});
setJunctionText?.(defaultJunctionStyleState.styleConfig.property);
setPipeText?.(defaultPipeStyleState.styleConfig.property);
setShowJunctionTextLayer?.(defaultJunctionStyleState.styleConfig.showLabels);
setShowPipeTextLayer?.(defaultPipeStyleState.styleConfig.showLabels);
setShowJunctionId?.(defaultJunctionStyleState.styleConfig.showId);
setShowPipeId?.(defaultPipeStyleState.styleConfig.showId);
setContourLayerAvailable?.(
defaultJunctionStyleState.styleConfig.property === "pressure"
);
setWaterflowLayerAvailable?.(
defaultPipeStyleState.styleConfig.property === "flow"
);
setApplyJunctionStyle(true);
setApplyPipeStyle(true);
const selectedLayerId = selectedRenderLayer?.get("value");
if (selectedLayerId === "junctions") {
setStyleConfig(defaultJunctionStyleState.styleConfig);
} else if (selectedLayerId === "pipes") {
setStyleConfig(defaultPipeStyleState.styleConfig);
}
}, [
forceStyleAutoApplyVersion,
selectedRenderLayer,
setContourLayerAvailable,
setJunctionText,
setLayerStyleStates,
setPipeText,
setShowJunctionId,
setShowJunctionTextLayer,
setShowPipeId,
setShowPipeTextLayer,
setWaterflowLayerAvailable,
]);
useEffect(() => {
const isUserTrigger = styleUpdateTrigger !== prevStyleUpdateTriggerRef.current;
prevStyleUpdateTriggerRef.current = styleUpdateTrigger;
const updateJunctionStyle = () => {
const junctionStyleState = latestLayerStyleStatesRef.current.find(
(state) => state.layerId === "junctions"
);
const isElevation =
junctionStyleState?.styleConfig.property === "elevation";
applyClassificationStyle("junctions", junctionStyleState?.styleConfig);
if (isElevation) {
activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
});
return;
}
activeMaps.forEach((targetMap) => {
const targetData = getDataForMap(targetMap, "junctions");
if (!targetData || targetData.length === 0) {
return;
}
updateVectorTileSource(targetMap, "junctions", junctionText, targetData);
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
attachVectorTileSourceLoadedEvent(
targetMap,
"junctions",
junctionText,
targetData
);
});
};
const updatePipeStyle = () => {
const pipeStyleState = latestLayerStyleStatesRef.current.find(
(state) => state.layerId === "pipes"
);
const isDiameter = pipeStyleState?.styleConfig.property === "diameter";
applyClassificationStyle("pipes", pipeStyleState?.styleConfig);
if (isDiameter) {
activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
});
return;
}
activeMaps.forEach((targetMap) => {
const targetData = getDataForMap(targetMap, "pipes");
if (!targetData || targetData.length === 0) {
return;
}
updateVectorTileSource(targetMap, "pipes", pipeText, targetData);
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
attachVectorTileSourceLoadedEvent(targetMap, "pipes", pipeText, targetData);
});
};
if (isUserTrigger) {
if (selectedRenderLayer?.get("value") === "junctions") {
updateJunctionStyle();
} else if (selectedRenderLayer?.get("value") === "pipes") {
updatePipeStyle();
}
return;
}
const isElevation = junctionText === "elevation";
const isDiameter = pipeText === "diameter";
if (
applyJunctionStyle &&
((currentJunctionCalData && currentJunctionCalData.length > 0) || isElevation)
) {
updateJunctionStyle();
}
if (
applyPipeStyle &&
((currentPipeCalData && currentPipeCalData.length > 0) || isDiameter)
) {
updatePipeStyle();
}
if (!applyJunctionStyle) {
activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
});
}
if (!applyPipeStyle) {
activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
});
}
// This effect is intentionally driven by explicit style triggers and data snapshots.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
styleUpdateTrigger,
applyJunctionStyle,
applyPipeStyle,
currentJunctionCalData,
currentPipeCalData,
compareJunctionCalData,
comparePipeCalData,
activeMaps,
]);
useEffect(() => {
return () => {
activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
});
};
}, [activeMaps, removeVectorTileSourceLoadedEvent]);
useEffect(() => {
if (!map) {
return;
}
const updateVisibleLayers = () => {
const layers = map.getAllLayers();
const webGLVectorTileLayers = layers.filter(
(layer) =>
layer.get("value") === "junctions" || layer.get("value") === "pipes"
) as WebGLVectorTileLayer[];
setRenderLayers(webGLVectorTileLayers);
};
updateVisibleLayers();
}, [map]);
return {
isReady: Boolean(data),
renderLayers,
selectedRenderLayer,
styleConfig,
setStyleConfig,
availableProperties,
handleLayerChange,
handlePropertyChange,
handleClassificationMethodChange,
handleSegmentsChange,
handleCustomBreakChange,
handleCustomBreakBlur,
handleColorTypeChange,
handleApply,
handleReset,
};
};
+7 -1
View File
@@ -85,6 +85,8 @@ interface DataContextType {
maps?: OlMap[];
diameterRange?: [number, number];
elevationRange?: [number, number];
forceStyleAutoApplyVersion?: number;
setForceStyleAutoApplyVersion?: React.Dispatch<React.SetStateAction<number>>;
}
// 跨组件传递
@@ -184,7 +186,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const [showPipeId, setShowPipeId] = useState(false); // 控制管道ID显示
const [showContourLayer, setShowContourLayer] = useState(false); // 控制等高线图层显示
const [junctionText, setJunctionText] = useState("pressure");
const [pipeText, setPipeText] = useState("flow");
const [pipeText, setPipeText] = useState("velocity");
const [contours, setContours] = useState<any[]>([]);
const flowAnimation = useRef(false); // 添加动画控制标志
const [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示
@@ -263,6 +265,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const [elevationRange, setElevationRange] = useState<
[number, number] | undefined
>();
const [forceStyleAutoApplyVersion, setForceStyleAutoApplyVersion] =
useState(0);
const toggleCompareMode = useCallback(() => {
setCompareMode((prev) => !prev);
@@ -1526,6 +1530,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
maps,
diameterRange,
elevationRange,
forceStyleAutoApplyVersion,
setForceStyleAutoApplyVersion,
}}
>
<MapContext.Provider value={map}>