fix(map): stabilize tiled style rendering

This commit is contained in:
2026-07-17 15:12:10 +08:00
parent 59447a100c
commit 589cf45aa7
28 changed files with 3884 additions and 2461 deletions
@@ -0,0 +1,63 @@
jest.mock("../MapComponent", () => ({
useData: jest.fn(),
useMap: jest.fn(),
}));
jest.mock("../mapLifecycle", () => ({
markMapResourcePersistent: <T,>(resource: T) => resource,
}));
jest.mock("ol/source/XYZ.js", () => ({
__esModule: true,
default: class MockXyzSource {
constructor(readonly options: unknown) {}
},
}));
jest.mock("ol/layer/Tile.js", () => ({
__esModule: true,
default: class MockTileLayer {
private readonly source: unknown;
constructor(options: any) {
this.source = options.source;
}
getSource() { return this.source; }
},
}));
jest.mock("ol/layer/Group", () => ({
__esModule: true,
default: class MockGroup {
private readonly layers: unknown[];
constructor(options: any) {
this.layers = options.layers;
}
getLayers() { return { getArray: () => this.layers }; }
},
}));
import {
createBaseLayerEntries,
createBaseLayerSources,
} from "./BaseLayers";
const getLeafSources = (layer: any): unknown[] => {
const childLayers = layer.getLayers?.().getArray?.();
if (Array.isArray(childLayers)) {
return childLayers.flatMap(getLeafSources);
}
return [layer.getSource?.()];
};
describe("base layer resources", () => {
it("creates independent layers backed by one shared source pool", () => {
const sources = createBaseLayerSources();
const primary = createBaseLayerEntries(sources);
const compare = createBaseLayerEntries(sources);
expect(primary).toHaveLength(compare.length);
primary.forEach((entry, index) => {
expect(entry.layer).not.toBe(compare[index].layer);
expect(getLeafSources(entry.layer)).toEqual(
getLeafSources(compare[index].layer),
);
});
});
});
@@ -30,91 +30,92 @@ const BASE_LAYER_METADATA = [
{ id: "tianditu-image", name: "天地图影像", img: mapboxSatellite.src },
] as const;
const createTileLayer = (url: string, attributions: string) =>
new TileLayer({
source: new XYZ({
url,
tileSize: 512,
maxZoom: 20,
projection: "EPSG:3857",
attributions,
}),
const createTileSource = (url: string, attributions: string) =>
new XYZ({
url,
tileSize: 512,
maxZoom: 20,
projection: "EPSG:3857",
attributions,
});
const createBaseLayerEntries = () => {
const streetsLayer = createTileLayer(
export const createBaseLayerSources = () => ({
streets: createTileSource(
`https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
);
const lightMapLayer = createTileLayer(
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
),
light: createTileSource(
`https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
);
const satelliteLayer = createTileLayer(
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
),
satellite: createTileSource(
`https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
);
const satelliteStreetsLayer = createTileLayer(
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
),
satelliteStreets: createTileSource(
`https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
);
const tiandituVectorLayer = new TileLayer({
source: new XYZ({
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
),
tiandituVector: new XYZ({
url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
const tiandituVectorAnnotationLayer = new TileLayer({
source: new XYZ({
}),
tiandituVectorAnnotation: new XYZ({
url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
const tiandituImageLayer = new TileLayer({
source: new XYZ({
}),
tiandituImage: new XYZ({
url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
const tiandituImageAnnotationLayer = new TileLayer({
source: new XYZ({
}),
tiandituImageAnnotation: new XYZ({
url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
}),
});
export type BaseLayerSources = ReturnType<typeof createBaseLayerSources>;
export const createBaseLayerEntries = (sources: BaseLayerSources) => {
const tileLayer = (source: XYZ) => new TileLayer({ source });
return [
{
...BASE_LAYER_METADATA[0],
layer: lightMapLayer,
layer: tileLayer(sources.light),
},
{
...BASE_LAYER_METADATA[1],
layer: satelliteLayer,
layer: tileLayer(sources.satellite),
},
{
...BASE_LAYER_METADATA[2],
layer: satelliteStreetsLayer,
layer: tileLayer(sources.satelliteStreets),
},
{
...BASE_LAYER_METADATA[3],
layer: streetsLayer,
layer: tileLayer(sources.streets),
},
{
...BASE_LAYER_METADATA[4],
layer: new Group({
layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer],
layers: [
tileLayer(sources.tiandituVector),
tileLayer(sources.tiandituVectorAnnotation),
],
}),
},
{
...BASE_LAYER_METADATA[5],
layer: new Group({
layers: [tiandituImageLayer, tiandituImageAnnotationLayer],
layers: [
tileLayer(sources.tiandituImage),
tileLayer(sources.tiandituImageAnnotation),
],
}),
},
].map((entry) => ({
@@ -130,6 +131,7 @@ const BaseLayers: React.FC = () => {
if (data?.maps?.length) return data.maps;
return map ? [map] : [];
}, [data?.maps, map]);
const sharedSources = useMemo(() => createBaseLayerSources(), []);
const layerSetsRef = useRef(new WeakMap<OlMap, ReturnType<typeof createBaseLayerEntries>>());
const [isShow, setShow] = useState(false);
const [isExpanded, setExpanded] = useState(false);
@@ -139,7 +141,7 @@ const BaseLayers: React.FC = () => {
maps.forEach((targetMap) => {
let layerEntries = layerSetsRef.current.get(targetMap);
if (!layerEntries) {
layerEntries = createBaseLayerEntries();
layerEntries = createBaseLayerEntries(sharedSources);
layerSetsRef.current.set(targetMap, layerEntries);
}
@@ -151,7 +153,7 @@ const BaseLayers: React.FC = () => {
layerInfo.layer.setVisible(layerInfo.id === activeId);
});
});
}, [activeId, maps]);
}, [activeId, maps, sharedSources]);
const changeMapLayers = (id: string) => {
maps.forEach((targetMap) => {
File diff suppressed because it is too large Load Diff
@@ -1,50 +1,38 @@
import React from "react";
import { Box, CircularProgress } from "@mui/material";
import StyleEditorForm from "./StyleEditorForm";
import { createDefaultLayerStyleState, createDefaultLayerStyleStates } from "./styleEditorPresets";
import { LayerStyleState, StyleConfig, StyleEditorPanelProps } from "./styleEditorTypes";
import type { StyleEditorPanelProps } from "./styleEditorTypes";
const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
isReady,
renderLayers,
selectedRenderLayer,
styleConfig,
setStyleConfig,
availableProperties,
onLayerChange,
onPropertyChange,
onClassificationMethodChange,
onSegmentsChange,
onCustomBreakChange,
onCustomBreakBlur,
onColorTypeChange,
onApply,
onReset,
...formProps
}) => {
if (!isReady) {
return <div>Loading...</div>;
return (
<Box
sx={{
position: "absolute",
top: 72,
left: 12,
zIndex: 1300,
width: 160,
height: 72,
display: "grid",
placeItems: "center",
bgcolor: "background.paper",
borderRadius: "12px",
boxShadow:
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
opacity: 0.95,
}}
>
<CircularProgress size={22} />
</Box>
);
}
return (
<StyleEditorForm
renderLayers={renderLayers}
selectedRenderLayer={selectedRenderLayer}
styleConfig={styleConfig}
setStyleConfig={setStyleConfig}
availableProperties={availableProperties}
onLayerChange={onLayerChange}
onPropertyChange={onPropertyChange}
onClassificationMethodChange={onClassificationMethodChange}
onSegmentsChange={onSegmentsChange}
onCustomBreakChange={onCustomBreakChange}
onCustomBreakBlur={onCustomBreakBlur}
onColorTypeChange={onColorTypeChange}
onApply={onApply}
onReset={onReset}
/>
);
return <StyleEditorForm {...formProps} />;
};
export default StyleEditorPanel;
export type { LayerStyleState, StyleConfig } from "./styleEditorTypes";
export { createDefaultLayerStyleState, createDefaultLayerStyleStates };
@@ -7,34 +7,31 @@ interface LegendStyleConfig {
layerId: string;
property: string;
colors: string[];
type: string; // 图例类型
dimensions: number[]; // 尺寸大小
breaks: number[]; // 分段值
labels?: string[]; // 可选标签(用于离散分类)
type: string;
dimensions: number[];
breaks: number[];
labels?: string[];
columns?: number;
itemsPerColumn?: number;
}
// 图例组件
// 该组件用于显示图层样式的图例,包含属性名称、颜色、尺寸和分段值等信息
// 通过传入的配置对象动态生成图例内容,适用于不同的样式配置
// 使用时需要确保传入的 colors、dimensions 和 breaks 数组长度一致
const StyleLegend: React.FC<LegendStyleConfig> = ({
layerName,
layerId,
property,
colors,
type, // 图例类型
type,
dimensions,
breaks,
labels,
columns = 1,
itemsPerColumn,
}) => {
const itemCount = Math.min(colors.length, dimensions.length, Math.max(0, breaks.length - 1));
return (
<Box
key={layerId}
className="bg-white p-3 rounded-xl max-w-xs opacity-95 transition-opacity duration-300 hover:opacity-100"
className="bg-white p-3 max-w-xs opacity-95 transition-opacity duration-300 hover:opacity-100"
sx={{ borderRadius: 1 }}
>
<Typography variant="subtitle2" gutterBottom>
{layerName} - {property}
@@ -56,39 +53,9 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
rowGap: 0.5,
}}
>
{[...Array(breaks.length)].map((_, index) => {
const color = colors[index]; // 默认颜色为黑色
const dimension = dimensions[index]; // 默认尺寸为16
// // 处理第一个区间(小于 breaks[0])
// if (index === 0) {
// return (
// <Box key={index} className="flex items-center gap-2 mb-1">
// <Box
// sx={
// type === "point"
// ? {
// width: dimension,
// height: dimension,
// borderRadius: "50%",
// backgroundColor: color,
// }
// : {
// width: 16,
// height: dimension,
// backgroundColor: color,
// border: `1px solid ${color}`,
// }
// }
// />
// <Typography variant="caption" className="text-xs">
// {"<"} {breaks[0]?.toFixed(1)}
// </Typography>
// </Box>
// );
// }
// 处理中间区间(breaks[index] - breaks[index + 1]
{Array.from({ length: itemCount }, (_, index) => {
const color = colors[index];
const dimension = dimensions[index];
if (index + 1 < breaks.length) {
const prevValue = breaks[index];
const currentValue = breaks[index + 1];
+58 -23
View File
@@ -107,6 +107,7 @@ const Timeline: React.FC<TimelineProps> = ({
const [calculatedInterval, setCalculatedInterval] =
useState<number>(stepMinutes); // 分钟
const [isCalculating, setIsCalculating] = useState<boolean>(false);
const [sliderPreviewTime, setSliderPreviewTime] = useState<number | null>(null);
// 计算时间轴范围
const minTime = timeRange
@@ -146,8 +147,8 @@ const Timeline: React.FC<TimelineProps> = ({
// 添加缓存引用
const nodeCacheRef = useRef<Map<string, any[]>>(new Map());
const linkCacheRef = useRef<Map<string, any[]>>(new Map());
// 添加防抖引用
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const frameRequestRevisionRef = useRef(0);
const frameAbortControllerRef = useRef<AbortController | null>(null);
const updateDataStates = useCallback(
(
@@ -202,6 +203,7 @@ const Timeline: React.FC<TimelineProps> = ({
target,
schemeName,
schemeType,
signal,
}: {
queryTime: Date;
junctionProperties: string;
@@ -210,6 +212,7 @@ const Timeline: React.FC<TimelineProps> = ({
target: "primary" | "compare";
schemeName?: string;
schemeType?: string;
signal?: AbortSignal;
}) => {
const query_time = queryTime.toISOString();
let nodeRecords: any = { results: [] };
@@ -233,10 +236,12 @@ const Timeline: React.FC<TimelineProps> = ({
nodePromise =
sourceType === "scheme" && schemeName
? apiFetch(
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`,
{ signal },
)
: apiFetch(
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`,
{ signal },
);
requests.push(nodePromise);
}
@@ -260,10 +265,12 @@ const Timeline: React.FC<TimelineProps> = ({
linkPromise =
sourceType === "scheme" && schemeName
? apiFetch(
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
{ signal },
)
: apiFetch(
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
{ signal },
);
requests.push(linkPromise);
}
@@ -309,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(
@@ -322,6 +333,11 @@ const Timeline: React.FC<TimelineProps> = ({
schemeName: string,
schemeType: string
) => {
const revision = frameRequestRevisionRef.current + 1;
frameRequestRevisionRef.current = revision;
frameAbortControllerRef.current?.abort();
const abortController = new AbortController();
frameAbortControllerRef.current = abortController;
const primarySourceType =
disableDateSelection && schemeName ? "scheme" : "realtime";
const tasks = [
@@ -333,6 +349,7 @@ const Timeline: React.FC<TimelineProps> = ({
target: "primary",
schemeName,
schemeType,
signal: abortController.signal,
}),
];
@@ -344,13 +361,29 @@ const Timeline: React.FC<TimelineProps> = ({
pipeProperties,
sourceType: "realtime",
target: "compare",
signal: abortController.signal,
})
);
}
await Promise.all(tasks);
try {
const frames = await Promise.all(tasks);
if (
abortController.signal.aborted ||
revision !== frameRequestRevisionRef.current
) {
return;
}
frames.forEach(({ nodeResults, linkResults, target }) => {
updateDataStates(nodeResults, linkResults, target);
});
} catch (error) {
if ((error as Error).name !== "AbortError") {
console.error("Timeline frame fetch failed:", error);
}
}
},
[disableDateSelection, fetchDataBySource, isCompareMode]
[disableDateSelection, fetchDataBySource, isCompareMode, updateDataStates]
);
// 格式化时间显示
@@ -427,15 +460,18 @@ const Timeline: React.FC<TimelineProps> = ({
if (timeRange && (value < minTime || value > maxTime)) {
return;
}
// 防抖设置currentTime,避免频繁触发数据获取
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
setCurrentTime(value);
}, 500); // 500ms 防抖延迟
setSliderPreviewTime(value);
},
[timeRange, minTime, maxTime, setCurrentTime],
[timeRange, minTime, maxTime],
);
const handleSliderChangeCommitted = useCallback(
(_event: Event | React.SyntheticEvent, newValue: number | number[]) => {
const value = Array.isArray(newValue) ? newValue[0] : newValue;
setSliderPreviewTime(null);
setCurrentTime(value);
},
[setCurrentTime],
);
// 播放控制
@@ -585,9 +621,7 @@ const Timeline: React.FC<TimelineProps> = ({
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
frameAbortControllerRef.current?.abort();
};
}, [durationMinutes, setCurrentTime, stepMinutes]);
@@ -731,7 +765,7 @@ const Timeline: React.FC<TimelineProps> = ({
<Draggable nodeRef={draggableRef} handle=".drag-handle">
<div
ref={draggableRef}
className="absolute bottom-4 left-1/2 z-10 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100"
className="absolute bottom-4 left-1/2 z-20 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100"
>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<Paper
@@ -937,12 +971,13 @@ const Timeline: React.FC<TimelineProps> = ({
<Box ref={timelineRef} sx={{ px: 2, position: "relative" }}>
<Slider
value={timelineCurrentTime}
value={sliderPreviewTime ?? timelineCurrentTime}
min={0}
max={durationMinutes}
step={stepMinutes}
marks={timeMarks}
onChange={handleSliderChange}
onChangeCommitted={handleSliderChangeCommitted}
valueLabelDisplay="auto"
valueLabelFormat={formatTime}
sx={{
@@ -138,6 +138,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
const styleEditor = useStyleEditor({
layerStyleStates,
setLayerStyleStates,
workspace: project?.workspace || config.MAP_WORKSPACE,
});
useToolbarChatActions({
@@ -803,10 +804,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
onClassificationMethodChange={styleEditor.handleClassificationMethodChange}
onSegmentsChange={styleEditor.handleSegmentsChange}
onCustomBreakChange={styleEditor.handleCustomBreakChange}
onCustomBreakBlur={styleEditor.handleCustomBreakBlur}
onColorTypeChange={styleEditor.handleColorTypeChange}
onApply={styleEditor.handleApply}
onReset={styleEditor.handleReset}
validationErrors={styleEditor.validationErrors}
isDirty={styleEditor.isDirty}
isApplying={styleEditor.isApplying}
/>
</div>
<ToolbarHistoryPanel
@@ -83,7 +83,7 @@ const DEFAULT_LAYER_STYLE_PRESETS: Record<
styleConfig: {
property: "pressure",
classificationMethod: "custom_breaks",
customBreaks: [16, 18, 20, 22, 24, 26],
customBreaks: [16, 18, 20, 22, 24, 26, 28],
customColors: [
"rgba(255, 0, 0, 1)",
"rgba(255, 127, 0, 1)",
@@ -137,7 +137,7 @@ const DEFAULT_LAYER_STYLE_PRESETS: Record<
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
customBreaks: [0.2, 0.4, 0.6, 0.8, 1.0, 1.2],
customBreaks: [0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4],
customColors: [],
},
legendConfig: {
@@ -3,16 +3,20 @@ import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import { LegendStyleConfig } from "./StyleLegend";
export type ClassificationMethod = "pretty_breaks" | "custom_breaks";
export type ColorType = "single" | "gradient" | "rainbow" | "custom";
export interface StyleConfig {
property: string;
classificationMethod: string;
classificationMethod: ClassificationMethod;
/** Number of rendered intervals. Boundaries always contain segments + 1 values. */
segments: number;
minSize: number;
maxSize: number;
minStrokeWidth: number;
maxStrokeWidth: number;
fixedStrokeWidth: number;
colorType: string;
colorType: ColorType;
singlePaletteIndex: number;
gradientPaletteIndex: number;
rainbowPaletteIndex: number;
@@ -24,6 +28,19 @@ export interface StyleConfig {
customColors?: string[];
}
export interface ResolvedLayerStyle {
boundaries: number[];
colors: string[];
dimensions: number[];
labels: string[];
isConstant: boolean;
}
export interface StyleValidationResult {
valid: boolean;
errors: string[];
}
export interface LayerStyleState {
layerId: string;
layerName: string;
@@ -37,6 +54,7 @@ export type DefaultLayerStyleId = "junctions" | "pipes";
export interface StyleEditorStateProps {
layerStyleStates: LayerStyleState[];
setLayerStyleStates: React.Dispatch<React.SetStateAction<LayerStyleState[]>>;
workspace: string;
}
export interface AvailableProperty {
@@ -50,15 +68,17 @@ export interface StyleEditorFormProps {
styleConfig: StyleConfig;
setStyleConfig: React.Dispatch<React.SetStateAction<StyleConfig>>;
availableProperties: AvailableProperty[];
onLayerChange: (index: number) => void;
onLayerChange: (layerId: string) => void;
onPropertyChange: (property: string) => void;
onClassificationMethodChange: (method: string) => void;
onClassificationMethodChange: (method: ClassificationMethod) => void;
onSegmentsChange: (segments: number) => void;
onCustomBreakChange: (index: number, value: string) => void;
onCustomBreakBlur: () => void;
onColorTypeChange: (colorType: string) => void;
onColorTypeChange: (colorType: ColorType) => void;
onApply: () => void;
onReset: () => void;
validationErrors: string[];
isDirty: boolean;
isApplying: boolean;
}
export interface StyleEditorPanelProps extends StyleEditorFormProps {
@@ -0,0 +1,107 @@
import { createDefaultLayerStyleState } from "./styleEditorPresets";
import {
buildDynamicStyleTemplate,
buildStyleVariables,
getDefaultCustomBreaks,
resolveLayerStyle,
requiresStyleApply,
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);
});
});
@@ -1,4 +1,4 @@
import { FlatStyleLike } from "ol/style/flat";
import type { FlatStyleLike, StyleVariables } from "ol/style/flat";
import { calculateClassification } from "@utils/breaksClassification";
import { parseColor } from "@utils/parseColor";
@@ -8,16 +8,34 @@ import {
RAINBOW_PALETTES,
SINGLE_COLOR_PALETTES,
} from "./styleEditorPresets";
import { StyleConfig } from "./styleEditorTypes";
import type {
ResolvedLayerStyle,
StyleConfig,
StyleValidationResult,
} from "./styleEditorTypes";
export const MIN_CLASS_COUNT = 2;
export const MAX_CLASS_COUNT = 10;
const clampIndex = (value: number, length: number) =>
Math.min(Math.max(Math.round(value) || 0, 0), Math.max(length - 1, 0));
const arraysEqual = <T>(left: readonly T[] = [], right: readonly T[] = []) =>
left.length === right.length && left.every((value, index) => value === right[index]);
const withOpacity = (color: string, opacity: number) => {
const parsed = parseColor(color);
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${opacity})`;
};
const formatBoundary = (value: number) =>
Number.isInteger(value) ? String(value) : Number(value.toFixed(3)).toString();
export const rgbaToHex = (rgba: string) => {
try {
const c = parseColor(rgba);
const toHex = (n: number) => {
const hex = Math.round(n).toString(16);
return hex.length === 1 ? `0${hex}` : hex;
};
return `#${toHex(c.r)}${toHex(c.g)}${toHex(c.b)}`;
const color = parseColor(rgba);
const toHex = (value: number) => Math.round(value).toString(16).padStart(2, "0");
return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`;
} catch {
return "#000000";
}
@@ -28,25 +46,71 @@ export const hexToRgba = (hex: string) => {
return result
? `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(
result[3],
16
16,
)}, 1)`
: "rgba(0, 0, 0, 1)";
};
export const getDefaultCustomColors = (
segments: number,
existingColors: string[] = []
existingColors: string[] = [],
) => {
const nextColors = [...existingColors];
const baseColors = RAINBOW_PALETTES[0].colors;
while (nextColors.length < segments) {
nextColors.push(baseColors[nextColors.length % baseColors.length]);
}
return nextColors.slice(0, segments);
};
const createEqualBoundaries = (minimum: number, maximum: number, segments: number) => {
if (minimum === maximum) {
return Array.from({ length: segments + 1 }, () => minimum);
}
return Array.from(
{ length: segments + 1 },
(_, index) => minimum + ((maximum - minimum) * index) / segments,
);
};
const normalizeCalculatedBoundaries = (
calculated: number[],
values: number[],
segments: number,
) => {
const finiteValues = values.filter(Number.isFinite).sort((a, b) => a - b);
if (finiteValues.length === 0) return [];
const minimum = finiteValues[0];
const maximum = finiteValues[finiteValues.length - 1];
if (minimum === maximum) return createEqualBoundaries(minimum, maximum, segments);
const candidates = Array.from(
new Set([minimum, ...calculated.filter(Number.isFinite), maximum]),
)
.filter((value) => value >= minimum && value <= maximum)
.sort((a, b) => a - b);
if (candidates.length === segments + 1) return candidates;
return createEqualBoundaries(minimum, maximum, segments);
};
export const resolveBoundaries = (
values: number[],
styleConfig: StyleConfig,
): number[] => {
const segments = Math.min(
MAX_CLASS_COUNT,
Math.max(MIN_CLASS_COUNT, Math.round(styleConfig.segments)),
);
if (styleConfig.classificationMethod === "custom_breaks") {
return [...(styleConfig.customBreaks || [])];
}
const finiteValues = values.filter(Number.isFinite);
if (finiteValues.length === 0) return [];
const calculated = calculateClassification(finiteValues, segments, "pretty_breaks");
return normalizeCalculatedBoundaries(calculated, finiteValues, segments);
};
export const getDefaultCustomBreaks = ({
segments,
property,
@@ -64,261 +128,255 @@ export const getDefaultCustomBreaks = ({
currentJunctionCalData?: any[];
currentPipeCalData?: any[];
}) => {
if (!layerId || !property) {
return Array.from({ length: segments }, () => 0);
let values: number[] = [];
if (layerId === "junctions" && property === "elevation" && elevationRange) {
values = elevationRange;
} else if (layerId === "pipes" && property === "diameter" && diameterRange) {
values = diameterRange;
} else if (layerId === "junctions") {
values = (currentJunctionCalData || []).map((item: any) => Number(item.value));
} else if (layerId === "pipes") {
values = (currentPipeCalData || []).map((item: any) => Number(item.value));
}
let dataArr: number[] = [];
const isElevation = layerId === "junctions" && property === "elevation";
const isDiameter = layerId === "pipes" && property === "diameter";
if (isElevation && elevationRange) {
dataArr = [elevationRange[0], elevationRange[1]];
} else if (isDiameter && diameterRange) {
dataArr = [diameterRange[0], diameterRange[1]];
} else if (layerId === "junctions" && currentJunctionCalData) {
dataArr = currentJunctionCalData.map((d: any) => d.value);
} else if (layerId === "pipes" && currentPipeCalData) {
dataArr = currentPipeCalData.map((d: any) => d.value);
const finiteValues = values.filter(Number.isFinite);
if (!property || finiteValues.length === 0) {
return Array.from({ length: segments + 1 }, (_, index) => index);
}
if (dataArr.length === 0) {
return Array.from({ length: segments }, () => 0);
const minimum = Math.min(...finiteValues);
const maximum = Math.max(...finiteValues);
if (minimum === maximum) {
const padding = Math.max(Math.abs(minimum) * 0.01, 1);
return createEqualBoundaries(minimum - padding, maximum + padding, segments);
}
const defaultBreaks = calculateClassification(
dataArr,
return normalizeCalculatedBoundaries(
calculateClassification(finiteValues, segments, "pretty_breaks"),
finiteValues,
segments,
"pretty_breaks"
).slice(0, segments);
while (defaultBreaks.length < segments) {
defaultBreaks.push(defaultBreaks[defaultBreaks.length - 1] ?? 0);
}
return defaultBreaks;
};
export const normalizeCustomBreaks = (breaks: number[], desired: number) => {
const nextBreaks = [...breaks]
.slice(0, desired)
.filter((value) => value >= 0)
.sort((a, b) => a - b);
while (nextBreaks.length < desired) {
nextBreaks.push(nextBreaks[nextBreaks.length - 1] ?? 0);
}
return nextBreaks;
};
export const addBreakExtrema = (breaks: number[], dataValues: number[]) => {
const nextBreaks = [...breaks];
const minValue = Math.max(
dataValues.reduce((min, value) => Math.min(min, value), Infinity),
0
);
const maxValue = dataValues.reduce(
(max, value) => Math.max(max, value),
-Infinity
);
if (!nextBreaks.includes(minValue)) {
nextBreaks.push(minValue);
}
if (!nextBreaks.includes(maxValue)) {
nextBreaks.push(maxValue);
}
nextBreaks.sort((a, b) => a - b);
return nextBreaks;
};
export const normalizeCustomBreaks = (breaks: number[], segments: number) => {
const finite = breaks.filter(Number.isFinite).slice(0, segments + 1);
if (finite.length === segments + 1) return finite;
if (finite.length >= 2) {
return createEqualBoundaries(finite[0], finite[finite.length - 1], segments);
}
return Array.from({ length: segments + 1 }, (_, index) => finite[0] ?? index);
};
export const validateStyleConfig = (styleConfig: StyleConfig): StyleValidationResult => {
const errors: string[] = [];
if (!styleConfig.property) errors.push("请选择分级属性");
if (
!Number.isInteger(styleConfig.segments) ||
styleConfig.segments < MIN_CLASS_COUNT ||
styleConfig.segments > MAX_CLASS_COUNT
) {
errors.push(`分类数量必须是 ${MIN_CLASS_COUNT}-${MAX_CLASS_COUNT} 的整数`);
}
if (styleConfig.classificationMethod === "custom_breaks") {
const boundaries = styleConfig.customBreaks || [];
if (boundaries.length !== styleConfig.segments + 1) {
errors.push(`需要 ${styleConfig.segments + 1} 个区间边界`);
} else if (boundaries.some((value) => !Number.isFinite(value))) {
errors.push("区间边界必须是有限数字");
} else if (boundaries.some((value, index) => index > 0 && value <= boundaries[index - 1])) {
errors.push("区间边界必须严格递增");
}
}
if (styleConfig.colorType === "custom") {
const colors = styleConfig.customColors || [];
if (colors.length !== styleConfig.segments) {
errors.push(`需要 ${styleConfig.segments} 个自定义颜色`);
} else if (colors.some((color) => {
try {
parseColor(color);
return false;
} catch {
return true;
}
})) {
errors.push("自定义颜色格式无效");
}
}
if (!Number.isFinite(styleConfig.opacity) || styleConfig.opacity < 0 || styleConfig.opacity > 1) {
errors.push("透明度必须在 0 到 1 之间");
}
const paletteIndexes: Array<[number, number]> = [
[styleConfig.singlePaletteIndex, SINGLE_COLOR_PALETTES.length],
[styleConfig.gradientPaletteIndex, GRADIENT_PALETTES.length],
[styleConfig.rainbowPaletteIndex, RAINBOW_PALETTES.length],
];
if (
paletteIndexes.some(
([index, length]) => !Number.isInteger(index) || index < 0 || index >= length,
)
) {
errors.push("色板索引无效");
}
if (
[
styleConfig.minSize,
styleConfig.maxSize,
styleConfig.minStrokeWidth,
styleConfig.maxStrokeWidth,
styleConfig.fixedStrokeWidth,
].some((value) => !Number.isFinite(value) || value <= 0)
) {
errors.push("符号尺寸必须大于 0");
}
if (styleConfig.minSize > styleConfig.maxSize) errors.push("节点最小尺寸不能大于最大尺寸");
if (styleConfig.minStrokeWidth > styleConfig.maxStrokeWidth) {
errors.push("管线最小宽度不能大于最大宽度");
}
return { valid: errors.length === 0, errors };
};
export const requiresStyleApply = (
applied: StyleConfig | undefined,
draft: StyleConfig,
) =>
!applied ||
applied.property !== draft.property ||
applied.classificationMethod !== draft.classificationMethod ||
applied.segments !== draft.segments ||
(draft.classificationMethod === "custom_breaks" &&
!arraysEqual(applied.customBreaks, draft.customBreaks));
export const resolveStyleColors = (
styleConfig: StyleConfig,
breaksLength: number
classCount = styleConfig.segments,
): string[] => {
if (styleConfig.colorType === "single") {
return Array.from(
{ length: breaksLength },
() => SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color
);
const palette = SINGLE_COLOR_PALETTES[
clampIndex(styleConfig.singlePaletteIndex, SINGLE_COLOR_PALETTES.length)
];
return Array.from({ length: classCount }, () => palette.color);
}
if (styleConfig.colorType === "gradient") {
const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex];
const startColor = parseColor(start);
const endColor = parseColor(end);
return Array.from({ length: breaksLength }, (_, index) => {
const ratio = breaksLength > 1 ? index / (breaksLength - 1) : 1;
const r = Math.round(startColor.r + (endColor.r - startColor.r) * ratio);
const g = Math.round(startColor.g + (endColor.g - startColor.g) * ratio);
const b = Math.round(startColor.b + (endColor.b - startColor.b) * ratio);
return `rgba(${r}, ${g}, ${b}, 1)`;
const palette = GRADIENT_PALETTES[
clampIndex(styleConfig.gradientPaletteIndex, GRADIENT_PALETTES.length)
];
const start = parseColor(palette.start);
const end = parseColor(palette.end);
return Array.from({ length: classCount }, (_, index) => {
const ratio = classCount > 1 ? index / (classCount - 1) : 0;
return `rgba(${Math.round(start.r + (end.r - start.r) * ratio)}, ${Math.round(
start.g + (end.g - start.g) * ratio,
)}, ${Math.round(start.b + (end.b - start.b) * ratio)}, 1)`;
});
}
if (styleConfig.colorType === "rainbow") {
const baseColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors;
const palette = RAINBOW_PALETTES[
clampIndex(styleConfig.rainbowPaletteIndex, RAINBOW_PALETTES.length)
];
return Array.from(
{ length: breaksLength },
(_, index) => baseColors[index % baseColors.length]
{ length: classCount },
(_, index) => palette.colors[index % palette.colors.length],
);
}
const customColors = styleConfig.customColors || [];
const reverseRainbowColors = RAINBOW_PALETTES[1].colors;
const result = [...customColors];
while (result.length < breaksLength) {
result.push(
reverseRainbowColors[
(result.length - customColors.length) % reverseRainbowColors.length
]
);
}
return result.slice(0, breaksLength);
};
export const getSizePreviewColors = (styleConfig: StyleConfig) => {
if (styleConfig.colorType === "single") {
const color = SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color;
return [color, color];
}
if (styleConfig.colorType === "gradient") {
const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex];
return [start, end];
}
if (styleConfig.colorType === "rainbow") {
const rainbowColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors;
return [rainbowColors[0], rainbowColors[rainbowColors.length - 1]];
}
const customColors = styleConfig.customColors || [];
return [
customColors[0] || "rgba(0,0,0,1)",
customColors[customColors.length - 1] || "rgba(0,0,0,1)",
];
return getDefaultCustomColors(classCount, styleConfig.customColors || []);
};
export const resolveDimensions = ({
layerType,
styleConfig,
breaksLength,
classCount = styleConfig.segments,
}: {
layerType: string;
styleConfig: StyleConfig;
breaksLength: number;
classCount?: number;
}) => {
const interpolate = (minimum: number, maximum: number, index: number) => {
const ratio = classCount > 1 ? index / (classCount - 1) : 0;
return minimum + (maximum - minimum) * ratio;
};
if (layerType === "linestring") {
if (styleConfig.adjustWidthByProperty) {
return Array.from({ length: breaksLength }, (_, index) => {
const ratio = index / (breaksLength - 1);
return (
styleConfig.minStrokeWidth +
(styleConfig.maxStrokeWidth - styleConfig.minStrokeWidth) * ratio
);
});
}
return Array.from(
{ length: breaksLength },
() => styleConfig.fixedStrokeWidth
return Array.from({ length: classCount }, (_, index) =>
styleConfig.adjustWidthByProperty
? interpolate(styleConfig.minStrokeWidth, styleConfig.maxStrokeWidth, index)
: styleConfig.fixedStrokeWidth,
);
}
return Array.from({ length: breaksLength }, (_, index) => {
const ratio = index / (breaksLength - 1);
return styleConfig.minSize + (styleConfig.maxSize - styleConfig.minSize) * ratio;
});
return Array.from({ length: classCount }, (_, index) =>
interpolate(styleConfig.minSize, styleConfig.maxSize, index),
);
};
export const buildDynamicStyle = ({
export const resolveLayerStyle = ({
layerType,
styleConfig,
breaks,
colors,
dimensions,
values,
}: {
layerType: string;
styleConfig: StyleConfig;
breaks: number[];
colors: string[];
dimensions: number[];
}): FlatStyleLike => {
const generateColorConditions = (property: string): any[] => {
const conditions: any[] = ["case"];
for (let index = 1; index < breaks.length; index++) {
if (property === "unit_headloss") {
conditions.push([
"<=",
["/", ["get", "unit_headloss"], ["/", ["get", "length"], 1000]],
breaks[index],
]);
} else {
conditions.push(["<=", ["get", property], breaks[index]]);
}
const colorObj = parseColor(colors[index - 1]);
conditions.push(
`rgba(${colorObj.r}, ${colorObj.g}, ${colorObj.b}, ${styleConfig.opacity})`
values: number[];
}): ResolvedLayerStyle | null => {
const boundaries = resolveBoundaries(values, styleConfig);
if (boundaries.length !== styleConfig.segments + 1) return null;
const colors = resolveStyleColors(styleConfig, styleConfig.segments);
const dimensions = resolveDimensions({ layerType, styleConfig });
const isConstant = boundaries.every((value) => value === boundaries[0]);
const labels = isConstant
? [`${formatBoundary(boundaries[0])}`]
: Array.from(
{ length: styleConfig.segments },
(_, index) => `${formatBoundary(boundaries[index])} - ${formatBoundary(boundaries[index + 1])}`,
);
}
const defaultColor = parseColor(colors[0]);
conditions.push(
`rgba(${defaultColor.r}, ${defaultColor.g}, ${defaultColor.b}, ${styleConfig.opacity})`
return { boundaries, colors, dimensions, labels, isConstant };
};
const valueExpression = (property: string): any[] => ["get", property];
const buildVariableCase = (property: string, classCount: number, variablePrefix: string) => {
const expression: any[] = ["case"];
for (let index = 0; index < classCount - 1; index += 1) {
expression.push(
["<=", valueExpression(property), ["var", `tj_break_${index + 1}`]],
["var", `${variablePrefix}_${index}`],
);
return conditions;
};
const generateDimensionConditions = (property: string): any[] => {
const conditions: any[] = ["case"];
for (let index = 0; index < breaks.length; index++) {
if (property === "unit_headloss") {
conditions.push([
"<=",
["/", ["get", "headloss"], ["get", "length"]],
breaks[index],
]);
} else {
conditions.push(["<=", ["get", property], breaks[index]]);
}
conditions.push(dimensions[index]);
}
conditions.push(dimensions[dimensions.length - 1]);
return conditions;
};
const generatePointDimensionConditions = (property: string): any[] => {
const conditions: any[] = ["case"];
for (let index = 0; index < breaks.length; index++) {
conditions.push(["<=", ["get", property], breaks[index]]);
conditions.push(["interpolate", ["linear"], ["zoom"], 12, 1, 24, dimensions[index]]);
}
conditions.push(dimensions[dimensions.length - 1]);
return conditions;
};
const dynamicStyle: FlatStyleLike = {};
if (layerType === "linestring") {
dynamicStyle["stroke-color"] = generateColorConditions(styleConfig.property);
dynamicStyle["stroke-width"] = generateDimensionConditions(styleConfig.property);
} else if (layerType === "point") {
dynamicStyle["circle-fill-color"] = generateColorConditions(styleConfig.property);
dynamicStyle["circle-radius"] = generatePointDimensionConditions(
styleConfig.property
);
dynamicStyle["circle-stroke-color"] = generateColorConditions(styleConfig.property);
dynamicStyle["circle-stroke-width"] = 2;
}
expression.push(["var", `${variablePrefix}_${classCount - 1}`]);
return expression;
};
return dynamicStyle;
export const buildDynamicStyleTemplate = ({
layerType,
property,
classCount,
}: {
layerType: string;
property: string;
classCount: number;
}): FlatStyleLike => {
const color = buildVariableCase(property, classCount, "tj_color");
const dimension = buildVariableCase(property, classCount, "tj_size");
if (layerType === "linestring") {
return { "stroke-color": color, "stroke-width": dimension };
}
return {
"circle-fill-color": color,
"circle-radius": ["interpolate", ["linear"], ["zoom"], 12, 1, 24, dimension],
"circle-stroke-color": color,
"circle-stroke-width": 2,
};
};
export const buildStyleVariables = (
styleConfig: StyleConfig,
resolvedStyle: ResolvedLayerStyle,
): StyleVariables => {
const variables: StyleVariables = {};
resolvedStyle.boundaries.slice(1, -1).forEach((boundary, index) => {
variables[`tj_break_${index + 1}`] = boundary;
});
resolvedStyle.colors.forEach((color, index) => {
variables[`tj_color_${index}`] = withOpacity(color, styleConfig.opacity);
});
resolvedStyle.dimensions.forEach((dimension, index) => {
variables[`tj_size_${index}`] = dimension;
});
return variables;
};
export const buildContourDefinitions = ({
@@ -329,20 +387,12 @@ export const buildContourDefinitions = ({
styleConfig: StyleConfig;
breaks: number[];
colors: string[];
}) => {
const contours = [];
for (let index = 0; index < breaks.length - 1; index++) {
const colorObj = parseColor(colors[index]);
contours.push({
}) =>
colors.map((color, index) => {
const parsed = parseColor(color);
return {
threshold: [breaks[index], breaks[index + 1]],
color: [
colorObj.r,
colorObj.g,
colorObj.b,
Math.round(styleConfig.opacity * 255),
],
color: [parsed.r, parsed.g, parsed.b, Math.round(styleConfig.opacity * 255)],
strokeWidth: 0,
});
}
return contours;
};
};
});
File diff suppressed because it is too large Load Diff