Files
SPipe/src/Map.tsx
T
bjkim1 21ebb16fb7 SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음)
- README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리
- 패치내역을 docs/로 이동, _worklog.md 추가
- 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
2026-10-30 11:00:46 +09:00

2630 lines
101 KiB
TypeScript

import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { Feature, FeatureCollection, LineString, Point } from 'geojson';
import maplibregl from 'maplibre-gl';
import { MousePointer2, Plus, Link2, Magnet } from 'lucide-react';
import { selectNetworkFeaturesInBox, type ScreenPoint } from './mapSelection';
import {
VWORLD_BASE_TILE_URL,
VWORLD_WHITE_TILE_URL,
VWORLD_MIDNIGHT_TILE_URL,
VWORLD_SATELLITE_TILE_URL,
VWORLD_HYBRID_TILE_URL,
MapServiceError,
formatMapServiceError,
} from './mapServices';
import 'maplibre-gl/dist/maplibre-gl.css';
import type {
BasemapType,
DisplayMode,
EditMode,
SelectionTarget,
MultiSelectionState,
SimulationResult,
UserGisLayer,
LayerVisibilityState,
MapContextMenuEvent,
} from './types';
import type { NetworkModel } from './inpModel';
import type { LayerStyleConfig } from './LayerStyleModal';
import type { LayerLabelConfig } from './LayerLabelModal';
import { CRS, Extents, modelToLngLat, lngLatToModel } from './crs';
import {
DEFAULT_PRESSURE_BREAKS,
DEFAULT_VELOCITY_BREAKS,
DIVERGING_COLORS,
Legend,
LegendEditor,
applyLegendOverride,
sizeExpression,
type LegendOverride,
PRESSURE_COLORS,
VELOCITY_COLORS,
setActiveLegendScales,
stepColor,
type LegendScale,
} from './Legend';
import type { MapStyleSettings } from './StyleSettingsPanel';
import {
diameterToMillimeters,
flowToM3Day,
getDisplayUnits,
headToMeters,
lengthToMeters,
m3DayToFlow,
metersPerSecondToVelocity,
computeUnitHeadloss,
metersToLength,
metersToPressure,
pressureToMeters,
resolvePressureUnit,
velocityToMetersPerSecond,
type DisplayUnitMode,
createDisplayConverter,
} from './unitSystem';
// First stop must stay visible on the basemap (the old #dbeafe made low-flow pipes vanish).
const FLOW_COLORS = ['#7fb2f0', '#4a90e2', '#2563eb', '#1d4ed8', '#172554'] as const;
const FLOW_BREAKS_M3_DAY = [0, 100, 500, 2000, 10000] as const;
// Glyph font stack bundled in public/fonts (Latin, digits, symbols).
const LABEL_FONT = ['Noto Sans Regular'];
/** MapLibre text expression for a label field ('id', 'tag', 'id_tag' or a numeric property). */
function labelTextExpression(field: string): unknown {
const tag = ['to-string', ['coalesce', ['get', 'tag'], '']];
const id = ['to-string', ['get', 'id']];
if (field === 'id') return id;
if (field === 'tag') return tag;
if (field === 'id_tag') return ['case', ['>', ['length', tag], 0], ['concat', id, ' · ', tag], id];
return ['number-format', ['to-number', ['get', field], 0], { 'max-fraction-digits': 2 }];
}
const MAP_PROPERTY_LABELS: Record<string, string> = {
tag: '태그',
type: '유형',
elevation: '지반고 (m)',
pressure: '수압 (m)',
head: '동수두 (m)',
demand: '수요량 (m³/day)',
quality: '수질',
diffPressure: '수압 편차 (m)',
fromNode: '시작절점',
toNode: '끝절점',
length: '관로길이 (m)',
diameter: '관경 (mm)',
flow: '유량 (m³/day)',
velocity: '유속 (m/s)',
headloss: '단위손실수두 (‰, m/km)',
diffFlow: '유량 편차 (m³/day)',
};
export interface MapFocusRequest {
id: number;
lngLat?: [number, number];
target?: Exclude<SelectionTarget, null>;
zoom?: number;
}
interface MapProps {
wasmResult: SimulationResult | null;
baseResult?: SimulationResult | null;
networkModel: NetworkModel | null;
displayMode: DisplayMode;
displayUnitMode?: DisplayUnitMode;
selectedTarget: SelectionTarget;
editMode: EditMode;
nodeCreationType: 'JUNCTION' | 'RESERVOIR' | 'TANK';
crs: CRS;
basemap: BasemapType;
snapEnabled: boolean;
timeStepIndex?: number;
showFlowArrows?: boolean;
userLayers?: UserGisLayer[];
rasterOverlays?: import('./types').RasterOverlay[];
view3d?: import('./types').View3D;
layerVisibility?: LayerVisibilityState;
multiSelection?: MultiSelectionState;
boxSelectionEnabled?: boolean;
focusRequest?: MapFocusRequest | null;
styleSettings?: MapStyleSettings;
onMultiSelectChange?: (updater: (prev: MultiSelectionState) => MultiSelectionState) => void;
layerStyles?: Record<string, LayerStyleConfig>;
layerLabels?: Record<string, LayerLabelConfig>;
/** 범례 설정 per scale title (breaks/colours/size by value). */
legendOverrides?: Record<string, LegendOverride>;
/** Extra text labels (보정값 지도 표시, analysis results). */
annotations?: import('./types').MapAnnotation[];
onLegendOverride?: (title: string, override: LegendOverride | null) => void;
onSelectTarget: (target: SelectionTarget) => void;
onNodeDragEnd: (id: string, newX: number, newY: number) => void;
/** 꺾임점 편집: new vertex list (model coordinates) of a link. */
onVerticesChange?: (linkId: string, vertices: Array<{ x: number; y: number }>, message: string) => void;
onAddNodeAtCoords: (x: number, y: number) => void;
onAddPipeBetweenNodes: (node1Id: string, node2Id: string) => void;
onContextMenu?: (event: MapContextMenuEvent) => void;
onBasemapError?: (message: string) => void;
onCursorCoordsChange?: (coords: { x: number; y: number } | null) => void;
}
function computeBearing(p1: [number, number], p2: [number, number]): number {
const [lon1, lat1] = p1;
const [lon2, lat2] = p2;
const dLon = (lon2 - lon1) * (Math.PI / 180);
const y = Math.sin(dLon) * Math.cos(lat2 * (Math.PI / 180));
const x =
Math.cos(lat1 * (Math.PI / 180)) * Math.sin(lat2 * (Math.PI / 180)) -
Math.sin(lat1 * (Math.PI / 180)) *
Math.cos(lat2 * (Math.PI / 180)) *
Math.cos(dLon);
const brng = Math.atan2(y, x) * (180 / Math.PI);
return (brng + 360) % 360;
}
function computeExtents(
model: NetworkModel | null,
wasmResult: SimulationResult | null
): Extents {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
if (model) {
for (const n of Object.values(model.nodes)) {
if (n.x < minX) minX = n.x;
if (n.y < minY) minY = n.y;
if (n.x > maxX) maxX = n.x;
if (n.y > maxY) maxY = n.y;
}
}
if (wasmResult) {
for (const n of Object.values(wasmResult.nodes)) {
if (n.coordinates) {
if (n.coordinates.x < minX) minX = n.coordinates.x;
if (n.coordinates.y < minY) minY = n.coordinates.y;
if (n.coordinates.x > maxX) maxX = n.coordinates.x;
if (n.coordinates.y > maxY) maxY = n.coordinates.y;
}
}
}
if (minX === Infinity || minX === maxX) {
return { minX: 0, minY: 0, maxX: 100, maxY: 100 };
}
const marginX = (maxX - minX) * 0.15 || 10;
const marginY = (maxY - minY) * 0.15 || 10;
return {
minX: minX - marginX,
minY: minY - marginY,
maxX: maxX + marginX,
maxY: maxY + marginY,
};
}
export const MapViewer: React.FC<MapProps> = ({
wasmResult,
baseResult,
networkModel,
displayMode,
displayUnitMode = 'korean',
selectedTarget,
editMode,
nodeCreationType,
crs,
basemap,
snapEnabled,
timeStepIndex = 0,
showFlowArrows = true,
userLayers = [],
rasterOverlays,
view3d,
layerVisibility,
styleSettings,
multiSelection,
boxSelectionEnabled = false,
focusRequest,
onMultiSelectChange,
layerStyles,
layerLabels,
legendOverrides,
onLegendOverride,
annotations,
onSelectTarget,
onNodeDragEnd,
onVerticesChange,
onAddNodeAtCoords,
onAddPipeBetweenNodes,
onContextMenu,
onBasemapError,
onCursorCoordsChange,
}) => {
const sourceFlowUnit = networkModel?.options.units || wasmResult?.units.flow || 'LPS';
const specificGravity = networkModel?.options.specificGravity ?? 1;
// EPANET's pressure report unit (psi/kPa/m/bar/ft) can be set independently
// of the flow unit, so once a result exists, its own reported unit is used
// instead of guessing from the flow unit -- this drives both the pressure
// value conversion and the low-pressure color thresholds below.
const resultPressureUnit = resolvePressureUnit(wasmResult?.units?.pressure, sourceFlowUnit);
const displayUnits = getDisplayUnits(sourceFlowUnit, displayUnitMode, resultPressureUnit);
const units = createDisplayConverter(displayUnitMode, sourceFlowUnit, resultPressureUnit, specificGravity);
const displayPressure = units.pressure;
const displayHead = units.head;
const displayFlow = units.flow;
const displayLength = units.length;
const displayDiameter = units.diameter;
const displayVelocity = units.velocity;
// Legend breaks are defined in Korean SI units (m, m3/day, m/s); in
// 'original' mode they are converted into the model's own units, i.e. the
// inverse direction of `units` -- hence the opposite-mode converter.
const breakUnits = createDisplayConverter(
displayUnitMode === 'korean' ? 'original' : 'korean',
sourceFlowUnit,
resultPressureUnit,
specificGravity
);
const flowBreaks = FLOW_BREAKS_M3_DAY.map(breakUnits.toFlow);
const pressureBreaks = DEFAULT_PRESSURE_BREAKS.map(breakUnits.toPressure);
const headBreaks = [0, 25, 50, 75, 100].map(breakUnits.toLength);
const elevationBreaks = [0, 50, 100, 200, 500].map(breakUnits.toLength);
const velocityBreaks = DEFAULT_VELOCITY_BREAKS.map(breakUnits.toVelocity);
// Diverging scales: the middle class (-1..1 / -5..5) is "no change".
const diffPressureBreaks = [-10, -5, -1, 1, 5].map(breakUnits.toPressure);
const diffFlowBreaks = [-50, -25, -5, 5, 25].map(breakUnits.toFlow);
const headlossBreaks = [0, 1, 3, 5, 10];
// Map coloring for the current display mode. The map paints exactly these
// scales (stepColor) and the legend renders exactly these scales, so the two
// cannot drift apart. null scale = fixed color.
const resultsEnabled = layerVisibility ? layerVisibility.results.visible : true;
// Built-in scales keyed by title (the editor's 기본값), then the user's 범례 설정 on top.
const defaultScales: Record<string, LegendScale> = {};
const scale = (title: string, unit: string, colors: readonly string[], breaks: readonly number[], absolute = false): LegendScale => {
const base: LegendScale = { title, unit, colors, breaks, absolute };
defaultScales[title] = base;
return applyLegendOverride(base, legendOverrides?.[title]);
};
const pressureScale = scale('절점 수압', displayUnits.pressure, PRESSURE_COLORS, pressureBreaks);
const velocityScale = scale('관로 유속', displayUnits.velocity, VELOCITY_COLORS, velocityBreaks, true);
type Paint = { prop: string; scale: LegendScale } | string;
let nodePaint: Paint = '#3b82f6';
let linkPaint: Paint = '#111827';
if (!wasmResult && displayMode !== 'elevation') {
// No analysis yet: plain network (black pipes, blue nodes).
} else if (displayMode === 'elevation') {
nodePaint = { prop: 'elevation', scale: scale('지반고', displayUnits.head, PRESSURE_COLORS, elevationBreaks) };
linkPaint = '#c8ccd2';
} else if (displayMode === 'diff-pressure') {
nodePaint = resultsEnabled
? { prop: 'diffPressure', scale: scale('수압 편차', displayUnits.pressure, DIVERGING_COLORS, diffPressureBreaks) }
: '#3b82f6';
linkPaint = '#c8ccd2';
} else if (displayMode === 'diff-flow') {
nodePaint = '#c8ccd2';
linkPaint = { prop: 'diffFlow', scale: scale('유량 편차', displayUnits.flow, DIVERGING_COLORS, diffFlowBreaks) };
} else if (displayMode === 'pressure') {
// Default result view: node pressure + pipe velocity together.
nodePaint = { prop: 'pressure', scale: pressureScale };
linkPaint = { prop: 'velocity', scale: velocityScale };
} else if (displayMode === 'head') {
nodePaint = { prop: 'head', scale: scale('동수두', displayUnits.head, PRESSURE_COLORS, headBreaks) };
linkPaint = '#c8ccd2';
} else if (displayMode === 'quality') {
const q = scale('수질', 'mg/L', PRESSURE_COLORS, [0, 0.2, 0.5, 1.0, 2.0]);
nodePaint = { prop: 'quality', scale: q };
linkPaint = { prop: 'quality', scale: q };
} else if (displayMode === 'flow') {
nodePaint = '#454b53';
linkPaint = { prop: 'flow', scale: scale('관로 유량', displayUnits.flow, FLOW_COLORS, flowBreaks, true) };
} else if (displayMode === 'velocity') {
nodePaint = '#454b53';
linkPaint = { prop: 'velocity', scale: velocityScale };
} else {
nodePaint = '#454b53';
linkPaint = {
prop: displayMode,
scale: scale('단위 손실수두', `${displayUnits.length}/k${displayUnits.length}`, ['#d9a873', '#c68941', '#b06b00', '#935100', '#713d00'], headlossBreaks, true),
};
}
const paintedScales = [nodePaint, linkPaint].flatMap((p) => (typeof p === 'string' ? [] : [p.scale]))
.filter((sc, i, all) => all.indexOf(sc) === i);
// Before analysis nothing is color-coded; keep the legend visible with the
// default pressure/velocity scales and say they apply after the run.
const legendScales = paintedScales.length ? paintedScales : [pressureScale, velocityScale];
const legendNote = paintedScales.length ? undefined : '해석 실행 후 적용';
setActiveLegendScales(legendScales);
const nodeColorExpr = typeof nodePaint === 'string' ? nodePaint : stepColor(nodePaint.prop, nodePaint.scale);
const linkColorExpr = typeof linkPaint === 'string' ? linkPaint : stepColor(linkPaint.prop, linkPaint.scale);
const colorKey = JSON.stringify([nodeColorExpr, linkColorExpr]);
// 값에 따른 크기: radius/width follow the value when the scale asks for it.
const nodeSizeExpr = typeof nodePaint !== 'string' && nodePaint.scale.sizeByValue
? sizeExpression(nodePaint.prop, nodePaint.scale, styleSettings?.nodeRadius ?? 4.5)
: styleSettings?.nodeRadius ?? 4.5;
const linkWidthExpr = typeof linkPaint !== 'string' && linkPaint.scale.sizeByValue
? sizeExpression(linkPaint.prop, linkPaint.scale, styleSettings?.pipeWidth ?? 2)
: styleSettings?.pipeWidth ?? 2;
const sizeKey = JSON.stringify([nodeSizeExpr, linkWidthExpr]);
const [editingLegend, setEditingLegend] = useState<string | null>(null);
const mapPropertyLabels: Record<string, string> = {
...MAP_PROPERTY_LABELS,
elevation: `지반고 (${displayUnits.head})`,
pressure: `수압 (${displayUnits.pressure})`,
head: `동수두 (${displayUnits.head})`,
demand: `수요량 (${displayUnits.demand})`,
diffPressure: `수압 편차 (${displayUnits.pressure})`,
length: `관로길이 (${displayUnits.length})`,
diameter: `관경 (${displayUnits.diameter})`,
};
const mapContainer = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
// True once the map's initial 'load' fired (sources/layers exist). Do NOT use
// map.isStyleLoaded() for this: it is false whenever any basemap tile is
// still loading, and 'load' never fires again -- so an update arriving while
// tiles loaded (e.g. right after a CRS change) was dropped and the network
// stayed undrawn until the next edit.
const mapReadyRef = useRef(false);
const [hovered, setHovered] = useState<{
type: 'node' | 'link';
id: string;
props: Record<string, unknown>;
} | null>(null);
const [pipeStartNodeId, setPipeStartNodeId] = useState<string | null>(null);
const [snappedNode, setSnappedNode] = useState<{
id: string;
lngLat: [number, number];
} | null>(null);
const snappedNodeRef = useRef(snappedNode);
snappedNodeRef.current = snappedNode;
const editModeRef = useRef<EditMode>(editMode);
editModeRef.current = editMode;
const crsRef = useRef<CRS>(crs);
crsRef.current = crs;
const snapEnabledRef = useRef<boolean>(snapEnabled);
snapEnabledRef.current = snapEnabled;
const pipeStartNodeIdRef = useRef<string | null>(pipeStartNodeId);
pipeStartNodeIdRef.current = pipeStartNodeId;
const onAddNodeAtCoordsRef = useRef(onAddNodeAtCoords);
onAddNodeAtCoordsRef.current = onAddNodeAtCoords;
const onAddPipeBetweenNodesRef = useRef(onAddPipeBetweenNodes);
onAddPipeBetweenNodesRef.current = onAddPipeBetweenNodes;
const onSelectTargetRef = useRef(onSelectTarget);
onSelectTargetRef.current = onSelectTarget;
const onNodeDragEndRef = useRef(onNodeDragEnd);
onNodeDragEndRef.current = onNodeDragEnd;
const onVerticesChangeRef = useRef(onVerticesChange);
onVerticesChangeRef.current = onVerticesChange;
// Vertex handle being dragged: index into the vertex list ('mid' = insert before idx).
const draggingVertexRef = useRef<{ link: string; idx: number; kind: 'vertex' | 'mid'; startX: number; startY: number; moved: boolean } | null>(null);
const onContextMenuRef = useRef(onContextMenu);
onContextMenuRef.current = onContextMenu;
const onMultiSelectChangeRef = useRef(onMultiSelectChange);
onMultiSelectChangeRef.current = onMultiSelectChange;
const selectedTargetRef = useRef(selectedTarget);
selectedTargetRef.current = selectedTarget;
const multiSelectionRef = useRef(multiSelection);
multiSelectionRef.current = multiSelection;
const boxSelectionEnabledRef = useRef(boxSelectionEnabled);
boxSelectionEnabledRef.current = boxSelectionEnabled;
const networkModelRef = useRef(networkModel);
networkModelRef.current = networkModel;
const onBasemapErrorRef = useRef(onBasemapError);
onBasemapErrorRef.current = onBasemapError;
const onCursorCoordsChangeRef = useRef(onCursorCoordsChange);
onCursorCoordsChangeRef.current = onCursorCoordsChange;
const boxOverlayRef = useRef<HTMLDivElement>(null);
const boxSelectionRef = useRef<{
start: ScreenPoint;
current: ScreenPoint;
active: boolean;
additive: boolean;
} | null>(null);
const hoverRef = useRef<typeof hovered>(null);
// Hover card follows the cursor (viewport coords, position: fixed). Placed by
// writing style directly so mousemove does not re-render the whole map.
const hoverBoxRef = useRef<HTMLDivElement>(null);
const hoverPointRef = useRef<{ x: number; y: number } | null>(null);
const placeHoverBox = () => {
const el = hoverBoxRef.current;
const p = hoverPointRef.current;
if (!el || !p) return;
const offset = 14;
const w = el.offsetWidth;
const h = el.offsetHeight;
// Flip to the other side of the cursor near the window edges.
let x = p.x + offset;
let y = p.y + offset;
if (x + w > window.innerWidth - 8) x = p.x - offset - w;
if (y + h > window.innerHeight - 8) y = p.y - offset - h;
el.style.left = `${Math.max(8, x)}px`;
el.style.top = `${Math.max(8, y)}px`;
};
const sourceUpdateFrameRef = useRef<number | null>(null);
const geoJsonUpdateFrameRef = useRef<number | null>(null);
const vworldErrorShownRef = useRef(false);
const suppressNextClickRef = useRef(false);
const basemapRef = useRef(basemap);
basemapRef.current = basemap;
const layerVisibilityRef = useRef(layerVisibility);
layerVisibilityRef.current = layerVisibility;
const draggingNodeRef = useRef<{
id: string;
startX: number;
startY: number;
startLngLat: [number, number];
hasMoved: boolean;
} | null>(null);
const extentsRef = useRef<Extents>({
minX: 0,
minY: 0,
maxX: 100,
maxY: 100,
});
const latestGeojsonRef = useRef<{
nodes: FeatureCollection<Point>;
links: FeatureCollection<LineString>;
arrows: FeatureCollection<Point>;
} | null>(null);
useEffect(() => {
setPipeStartNodeId(null);
}, [editMode]);
const extents = useMemo(() => {
const ext = computeExtents(networkModel, wasmResult);
extentsRef.current = ext;
return ext;
}, [networkModel !== null, wasmResult !== null]);
// Build GeoJSON features using current CRS, Diff values, and Flow Arrows
const geojson = useMemo(() => {
if (!networkModel && !wasmResult) return null;
const nodeCoords: Record<string, { x: number; y: number }> = {};
if (networkModel) {
for (const n of Object.values(networkModel.nodes)) {
nodeCoords[n.id] = { x: n.x, y: n.y };
}
} else if (wasmResult) {
for (const n of Object.values(wasmResult.nodes)) {
if (n.coordinates) nodeCoords[n.id] = n.coordinates;
}
}
const isJunctionVisible = layerVisibility ? layerVisibility.nodes : true;
const isTankReservoirVisible = layerVisibility ? layerVisibility.tanksReservoirs : true;
const nodeFeatures: Array<Feature<Point>> = Object.entries(nodeCoords)
.filter(([id]) => {
const simNode = wasmResult?.nodes[id];
const modelNode = networkModel?.nodes[id];
const type = modelNode?.type || (simNode ? (simNode.type === 0 ? 'JUNCTION' : 'OTHER') : 'JUNCTION');
if (type === 'JUNCTION' && !isJunctionVisible) return false;
if ((type === 'TANK' || type === 'RESERVOIR') && !isTankReservoirVisible) return false;
return true;
})
.map(([id, c]) => {
const simNode = wasmResult?.nodes[id];
const modelNode = networkModel?.nodes[id];
const currentP = simNode?.timeSeries
? (simNode.timeSeries.pressure[timeStepIndex] ?? simNode.pressure)
: (simNode?.pressure ?? 0);
const currentH = simNode?.timeSeries
? (simNode.timeSeries.head[timeStepIndex] ?? simNode.head)
: (simNode?.head ?? 0);
const currentD = simNode?.timeSeries
? (simNode.timeSeries.demand[timeStepIndex] ?? simNode.demand)
: (simNode?.demand ?? (modelNode?.demand ?? 0));
const currentQ = simNode?.timeSeries?.quality
? (simNode.timeSeries.quality[timeStepIndex] ?? simNode.quality ?? 0)
: (simNode?.quality ?? 0);
const baseNode = baseResult?.nodes[id];
const baseP = baseNode?.timeSeries
? (baseNode.timeSeries.pressure[timeStepIndex] ?? baseNode.pressure)
: (baseNode?.pressure ?? currentP);
const diffPressure = Math.round(displayPressure(currentP - baseP) * 100) / 100;
return {
type: 'Feature' as const,
properties: {
id,
type:
modelNode?.type ||
(simNode
? simNode.type === 0
? 'JUNCTION'
: 'OTHER'
: 'JUNCTION'),
elevation: displayHead(modelNode?.elevation ?? 0),
pressure: displayPressure(currentP),
head: displayHead(currentH),
demand: displayFlow(currentD),
quality: currentQ,
diffPressure,
tag: modelNode?.tag ?? '',
},
geometry: {
type: 'Point' as const,
coordinates: modelToLngLat(c.x, c.y, crs, extents),
},
};
});
const linkKeys = new Set<string>();
if (networkModel) {
for (const id of Object.keys(networkModel.pipes)) linkKeys.add(id);
for (const id of Object.keys(networkModel.pumps)) linkKeys.add(id);
}
if (wasmResult) {
for (const id of Object.keys(wasmResult.links)) linkKeys.add(id);
}
const linkFeatures: Array<Feature<LineString>> = [];
const arrowFeatures: Array<Feature<Point>> = [];
const isPipeVisible = layerVisibility ? layerVisibility.pipes : true;
const isPumpVisible = layerVisibility ? layerVisibility.pumps : true;
const isValveVisible = layerVisibility ? layerVisibility.valves : true;
for (const id of linkKeys) {
const pipe = networkModel?.pipes[id];
const pump = networkModel?.pumps[id];
const valve = networkModel?.valves?.[id];
const simLink = wasmResult?.links[id];
const isPump = !!pump;
const isValve = !!valve;
const isPipe = !isPump && !isValve;
if (isPipe && !isPipeVisible) continue;
if (isPump && !isPumpVisible) continue;
if (isValve && !isValveVisible) continue;
const fromNodeId = pipe?.node1 || pump?.node1 || simLink?.fromNode;
const toNodeId = pipe?.node2 || pump?.node2 || simLink?.toNode;
if (!fromNodeId || !toNodeId) continue;
const fromCoord = nodeCoords[fromNodeId];
const toCoord = nodeCoords[toNodeId];
if (!fromCoord || !toCoord) continue;
const currentF = simLink?.timeSeries
? (simLink.timeSeries.flow[timeStepIndex] ?? simLink.flow)
: (simLink?.flow ?? 0);
const currentV = simLink?.timeSeries
? (simLink.timeSeries.velocity[timeStepIndex] ?? simLink.velocity)
: (simLink?.velocity ?? 0);
const currentHl = simLink?.timeSeries
? (simLink.timeSeries.headloss[timeStepIndex] ?? simLink.headloss)
: (simLink?.headloss ?? 0);
const currentQ = simLink?.timeSeries?.quality
? (simLink.timeSeries.quality[timeStepIndex] ?? simLink.quality ?? 0)
: (simLink?.quality ?? 0);
const baseLink = baseResult?.links[id];
const baseF = baseLink?.timeSeries
? (baseLink.timeSeries.flow[timeStepIndex] ?? baseLink.flow)
: (baseLink?.flow ?? currentF);
const diffFlow = Math.round(displayFlow(currentF - baseF) * 100) / 100;
const rawCoords = [
modelToLngLat(fromCoord.x, fromCoord.y, crs, extents),
// The model's own [VERTICES] follow edits immediately; the result's copy
// only reflects the last analysed INP.
...((networkModel?.vertices ? networkModel.vertices[id] ?? [] : simLink?.vertices ?? []).map((v) =>
modelToLngLat(v.x, v.y, crs, extents)
)),
modelToLngLat(toCoord.x, toCoord.y, crs, extents),
];
// EN_HEADLOSS is the TOTAL head difference across the whole pipe, which
// scales with pipe length -- comparing it directly across pipes of
// different lengths (map color scale, replacement-priority ranking)
// would bias toward longer pipes regardless of actual friction. The
// map's "headloss" property is the length-normalized unit headloss
// (‰, loss per 1000 length-units) instead; headlossTotal carries the
// total for anything that specifically needs it.
const displayLengthVal = displayLength(pipe?.length ?? 0);
const headlossTotal = displayHead(currentHl);
const unitHeadloss = computeUnitHeadloss(Math.abs(headlossTotal), displayLengthVal);
linkFeatures.push({
type: 'Feature' as const,
properties: {
id,
type: pump ? 'PUMP' : 'PIPE',
fromNode: fromNodeId,
toNode: toNodeId,
length: displayLengthVal,
diameter: displayDiameter(pipe?.diameter ?? 12),
flow: displayFlow(currentF),
velocity: displayVelocity(currentV),
headloss: unitHeadloss,
headlossTotal,
quality: currentQ,
diffFlow,
tag: pipe?.tag ?? '',
},
geometry: {
type: 'LineString' as const,
coordinates: rawCoords,
},
});
// Flow direction arrow at midpoint - drawn only when simulation result exists
if (wasmResult && rawCoords.length >= 2 && Math.abs(currentF) >= 0.001) {
const midIdx = Math.floor((rawCoords.length - 1) / 2);
const pStart = rawCoords[midIdx];
const pEnd = rawCoords[midIdx + 1];
let bearing = computeBearing(pStart, pEnd);
if (currentF < 0) {
bearing = (bearing + 180) % 360;
}
const midLng = (pStart[0] + pEnd[0]) / 2;
const midLat = (pStart[1] + pEnd[1]) / 2;
arrowFeatures.push({
type: 'Feature' as const,
properties: {
id: `arrow-${id}`,
bearing,
flow: currentF,
},
geometry: {
type: 'Point' as const,
coordinates: [midLng, midLat],
},
});
}
}
return {
nodes: {
type: 'FeatureCollection',
features: nodeFeatures,
} satisfies FeatureCollection<Point>,
links: {
type: 'FeatureCollection',
features: linkFeatures,
} satisfies FeatureCollection<LineString>,
arrows: {
type: 'FeatureCollection',
features: arrowFeatures,
} satisfies FeatureCollection<Point>,
};
}, [
networkModel,
wasmResult,
baseResult,
extents,
crs,
timeStepIndex,
layerVisibility,
displayUnitMode,
sourceFlowUnit,
specificGravity,
]);
// Initialize Map
useEffect(() => {
if (!mapContainer.current) return;
const initialBounds: [[number, number], [number, number]] =
crs === 'LOCAL'
? [
[-0.055, -0.055],
[0.055, 0.055],
]
: [
[126.9, 37.4],
[127.1, 37.6],
];
const map = new maplibregl.Map({
container: mapContainer.current,
// Hangul (and other CJK) label glyphs are drawn locally with this font
// instead of being fetched as glyph PBFs.
localIdeographFontFamily: "'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif",
style: {
version: 8,
// Without a glyphs URL MapLibre silently draws no text at all (this is
// why ID/property labels never appeared). Latin/digit/symbol ranges are
// bundled under public/fonts so labels work without internet access.
glyphs: `${window.location.origin}/fonts/{fontstack}/{range}.pbf`,
sources: {},
layers: [
{
id: 'background',
type: 'background',
paint: { 'background-color': '#f8fafc' },
},
],
},
bounds: initialBounds,
fitBoundsOptions: { padding: 40, maxZoom: 18, duration: 0 },
interactive: true,
dragPan: { linearity: 0.25, maxSpeed: 1400, deceleration: 3000 },
scrollZoom: true,
fadeDuration: 100,
preserveDrawingBuffer: true,
});
map.boxZoom.disable();
map.scrollZoom.setWheelZoomRate(1 / 450);
map.scrollZoom.setZoomRate(1 / 100);
map.getCanvas().style.cursor = 'crosshair';
map.on('error', (event) => {
if (!basemapRef.current.startsWith('vworld') || vworldErrorShownRef.current) return;
const sourceId = (event as unknown as { sourceId?: string }).sourceId;
if (sourceId && !sourceId.startsWith('basemap-vworld')) return;
const rawError = (event as unknown as { error?: Error }).error;
const rawMessage = rawError?.message.toLowerCase() ?? '';
const type = /401|403|forbidden|authori[sz]/.test(rawMessage)
? 'authorization'
: /429|quota|rate.?limit/.test(rawMessage)
? 'quota'
: /network|fetch|load|timeout/.test(rawMessage)
? 'network'
: 'service';
vworldErrorShownRef.current = true;
onBasemapErrorRef.current?.(
formatMapServiceError(new MapServiceError(rawError?.message || 'VWorld tile request failed', type))
);
});
mapRef.current = map;
map.on('load', () => {
mapReadyRef.current = true;
map.addSource('basemap-osm', {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
});
map.addSource('basemap-vworld', {
type: 'raster',
tiles: [VWORLD_BASE_TILE_URL],
tileSize: 256,
attribution: '© VWorld (국토교통부)',
});
map.addSource('basemap-vworld-white', {
type: 'raster',
tiles: [VWORLD_WHITE_TILE_URL],
tileSize: 256,
attribution: '© VWorld (국토교통부)',
});
map.addSource('basemap-vworld-midnight', {
type: 'raster',
tiles: [VWORLD_MIDNIGHT_TILE_URL],
tileSize: 256,
attribution: '© VWorld (국토교통부)',
});
map.addSource('basemap-vworld-satellite', {
type: 'raster',
tiles: [VWORLD_SATELLITE_TILE_URL],
tileSize: 256,
attribution: '© VWorld (국토교통부)',
});
map.addSource('basemap-vworld-hybrid', {
type: 'raster',
tiles: [VWORLD_HYBRID_TILE_URL],
tileSize: 256,
attribution: '© VWorld (국토교통부)',
});
map.addLayer({
id: 'basemap-osm-layer',
type: 'raster',
source: 'basemap-osm',
layout: { visibility: 'none' },
minzoom: 0,
maxzoom: 19,
});
map.addLayer({
id: 'basemap-vworld-layer',
type: 'raster',
source: 'basemap-vworld',
layout: { visibility: 'none' },
minzoom: 0,
maxzoom: 19,
});
map.addLayer({
id: 'basemap-vworld-white-layer',
type: 'raster',
source: 'basemap-vworld-white',
layout: { visibility: 'none' },
minzoom: 0,
maxzoom: 18,
});
map.addLayer({
id: 'basemap-vworld-midnight-layer',
type: 'raster',
source: 'basemap-vworld-midnight',
layout: { visibility: 'none' },
minzoom: 0,
maxzoom: 18,
});
map.addLayer({
id: 'basemap-vworld-satellite-layer',
type: 'raster',
source: 'basemap-vworld-satellite',
layout: { visibility: 'none' },
minzoom: 0,
maxzoom: 19,
});
map.addLayer({
id: 'basemap-vworld-hybrid-layer',
type: 'raster',
source: 'basemap-vworld-hybrid',
layout: { visibility: 'none' },
minzoom: 0,
maxzoom: 19,
});
// Generate crisp arrow icon for flow direction symbol layer
const arrowCanvas = document.createElement('canvas');
arrowCanvas.width = 24;
arrowCanvas.height = 24;
const actx = arrowCanvas.getContext('2d');
if (actx) {
actx.fillStyle = '#0f172a';
actx.strokeStyle = '#ffffff';
actx.lineWidth = 2;
actx.beginPath();
actx.moveTo(12, 2);
actx.lineTo(21, 20);
actx.lineTo(12, 16);
actx.lineTo(3, 20);
actx.closePath();
actx.fill();
actx.stroke();
map.addImage('flow-arrow-icon', actx.getImageData(0, 0, 24, 24));
}
// Network sources
map.addSource('links', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
});
map.addSource('flow-arrows', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
});
map.addSource('ghost-pipe', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
});
map.addSource('snap-indicator', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
});
map.addSource('annotations', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
});
map.addSource('nodes', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
});
// Links normal with diameter-based line-width
map.addLayer({
id: 'links-layer',
type: 'line',
source: 'links',
paint: {
'line-width': [
'interpolate',
['linear'],
['zoom'],
10, 0.9,
14, 1.4,
17, 2.2,
],
'line-color': '#94a3b8',
},
});
// Wide, nearly transparent interaction layer. Visual width stays precise
// while click and right-click selection remain practical.
map.addLayer({
id: 'links-hit-layer',
type: 'line',
source: 'links',
paint: {
'line-width': 12,
'line-color': '#000000',
// Fully transparent: hit-testing uses geometry, not opacity. At 0.01
// the wide strokes stacked into a gray shadow along dense pipes.
'line-opacity': 0,
},
});
// Flow direction arrows symbol layer
map.addLayer({
id: 'flow-arrows-layer',
type: 'symbol',
source: 'flow-arrows',
minzoom: 12,
layout: {
'icon-image': 'flow-arrow-icon',
'icon-size': [
'interpolate',
['linear'],
['zoom'],
12, 0.45,
14, 0.65,
16, 0.85,
18, 1.1,
],
'icon-rotate': ['get', 'bearing'],
'icon-rotation-alignment': 'map',
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'visibility': showFlowArrows ? 'visible' : 'none',
},
});
// Ghost pipe line
map.addLayer({
id: 'ghost-pipe-layer',
type: 'line',
source: 'ghost-pipe',
paint: {
'line-width': 2.5,
'line-color': '#2563eb',
'line-dasharray': [2, 2],
},
});
// Links selected highlight
map.addLayer({
id: 'links-selected-layer',
type: 'line',
source: 'links',
filter: ['in', ['get', 'id'], ['literal', []]],
paint: {
'line-width': 3.5,
'line-color': '#dc2093',
'line-opacity': 0.95,
},
});
// Nodes normal circle
map.addLayer({
id: 'nodes-layer',
type: 'circle',
source: 'nodes',
paint: {
'circle-radius': 4.5,
'circle-color': '#3b82f6',
'circle-stroke-width': 1.0,
'circle-stroke-color': '#ffffff',
},
});
// Expanded invisible node hit area. It sits above wide link hits so an
// overlapping junction always wins ordered hit-testing.
map.addLayer({
id: 'nodes-hit-layer',
type: 'circle',
source: 'nodes',
paint: {
'circle-radius': 12,
'circle-color': '#000000',
// Fully transparent (was 0.01): overlapping 12px hit circles stacked
// into a dark shadow behind dense node clusters.
'circle-opacity': 0,
},
});
// Nodes selected highlight ring
map.addLayer({
id: 'nodes-selected-layer',
type: 'circle',
source: 'nodes',
filter: ['in', ['get', 'id'], ['literal', []]],
paint: {
'circle-radius': 9,
'circle-color': 'rgba(220, 32, 147, 0.2)',
'circle-stroke-width': 2.0,
'circle-stroke-color': '#dc2093',
},
});
// 꺾임점 편집 handles of the selected link (filled = vertex, hollow = midpoint to insert).
map.addSource('vertex-handles', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({
id: 'vertex-handles-layer',
type: 'circle',
source: 'vertex-handles',
paint: {
'circle-radius': ['case', ['==', ['get', 'kind'], 'vertex'], 5, 3.5],
'circle-color': ['case', ['==', ['get', 'kind'], 'vertex'], '#f59e0b', '#ffffff'],
'circle-stroke-width': 1.5,
'circle-stroke-color': '#b45309',
},
});
map.on('mouseenter', 'vertex-handles-layer', () => { if (!draggingVertexRef.current) map.getCanvas().style.cursor = 'move'; });
map.on('mouseleave', 'vertex-handles-layer', () => { if (!draggingVertexRef.current) map.getCanvas().style.cursor = 'crosshair'; });
map.on('mousedown', 'vertex-handles-layer', (e) => {
if (editModeRef.current !== 'select' || !e.features?.length || e.originalEvent.button !== 0) return;
const f = e.features[0].properties as { link: string; idx: number; kind: 'vertex' | 'mid' };
e.preventDefault();
map.dragPan.disable();
map.getCanvas().style.cursor = 'grabbing';
draggingVertexRef.current = { link: String(f.link), idx: Number(f.idx), kind: f.kind, startX: e.point.x, startY: e.point.y, moved: false };
});
map.on('mousemove', (e) => {
const d = draggingVertexRef.current;
if (!d) return;
if (Math.abs(e.point.x - d.startX) > 3 || Math.abs(e.point.y - d.startY) > 3) d.moved = true;
if (!d.moved) return;
// Live preview: move the handle and the link's drawn line.
const handles = map.getSource('vertex-handles') as maplibregl.GeoJSONSource | undefined;
handles?.setData({ type: 'FeatureCollection', features: [{ type: 'Feature', properties: { kind: 'vertex' }, geometry: { type: 'Point', coordinates: [e.lngLat.lng, e.lngLat.lat] } }] });
const geo = latestGeojsonRef.current;
const linksSource = map.getSource('links') as maplibregl.GeoJSONSource | undefined;
if (geo && linksSource) {
linksSource.setData({
...geo.links,
features: geo.links.features.map((f) => {
if (String(f.properties?.id) !== d.link) return f;
const coords = [...f.geometry.coordinates];
if (d.kind === 'vertex') coords[d.idx + 1] = [e.lngLat.lng, e.lngLat.lat];
else coords.splice(d.idx + 1, 0, [e.lngLat.lng, e.lngLat.lat]);
return { ...f, geometry: { ...f.geometry, coordinates: coords } };
}),
});
}
});
map.on('dblclick', 'vertex-handles-layer', (e) => {
if (editModeRef.current !== 'select' || !e.features?.length) return;
const f = e.features[0].properties as { link: string; idx: number; kind: string };
if (f.kind !== 'vertex') return;
e.preventDefault();
const verts = networkModelRef.current?.vertices?.[String(f.link)] ?? [];
onVerticesChangeRef.current?.(String(f.link), verts.filter((_, k) => k !== Number(f.idx)), `관로 ${f.link}의 꺾임점을 지웠습니다.`);
});
// Snap indicator ring
map.addLayer({
id: 'snap-indicator-layer',
type: 'circle',
source: 'snap-indicator',
paint: {
'circle-radius': 12,
'circle-color': 'rgba(34, 197, 94, 0.2)',
'circle-stroke-width': 2,
'circle-stroke-color': '#16a34a',
},
});
// Nodes text label
map.addLayer({
id: 'nodes-label-layer',
type: 'symbol',
source: 'nodes',
layout: {
'text-field': ['get', 'id'],
'text-font': LABEL_FONT,
'text-size': 10,
'text-offset': [0, 1.2],
'text-anchor': 'top',
},
paint: {
'text-color': '#334155',
'text-halo-color': '#ffffff',
'text-halo-width': 1.0,
},
});
// Pipe text label (along the line); hidden until enabled in 라벨 설정.
map.addLayer({
id: 'links-label-layer',
type: 'symbol',
source: 'links',
layout: {
'symbol-placement': 'line',
'text-field': ['get', 'id'],
'text-font': LABEL_FONT,
'text-size': 10,
'text-offset': [0, -0.8],
visibility: 'none',
},
paint: {
'text-color': '#334155',
'text-halo-color': '#ffffff',
'text-halo-width': 1.0,
},
});
// Annotations (보정값 지도 표시 등): boxed text next to elements.
map.addLayer({
id: 'annotations-layer',
type: 'symbol',
source: 'annotations',
layout: {
'text-field': ['get', 'text'],
'text-font': LABEL_FONT,
'text-size': 10.5,
'text-anchor': ['case', ['==', ['get', 'kind'], 'label'], 'center', 'left'],
'text-offset': ['case', ['==', ['get', 'kind'], 'label'], ['literal', [0, 0]], ['literal', [0.8, -0.6]]],
'text-allow-overlap': true,
'text-justify': 'left',
},
paint: {
'text-color': ['coalesce', ['get', 'color'], '#0f172a'],
'text-halo-color': '#ffffff',
'text-halo-width': 1.6,
},
});
const updateHover = (
next: { type: 'node' | 'link'; id: string; props: Record<string, unknown> } | null
) => {
if (
hoverRef.current?.type === next?.type &&
hoverRef.current?.id === next?.id
) return;
hoverRef.current = next;
setHovered(next);
};
map.on('mouseenter', 'nodes-hit-layer', () => {
if (!draggingNodeRef.current) map.getCanvas().style.cursor = 'pointer';
});
map.on('mousemove', 'nodes-hit-layer', (e) => {
if (e.features && e.features.length > 0 && !draggingNodeRef.current) {
const props = e.features[0].properties as Record<string, unknown>;
hoverPointRef.current = { x: e.originalEvent.clientX, y: e.originalEvent.clientY };
updateHover({ type: 'node', id: String(props.id), props });
placeHoverBox();
}
});
map.on('mouseleave', 'nodes-hit-layer', () => {
if (!draggingNodeRef.current) map.getCanvas().style.cursor = 'crosshair';
updateHover(null);
});
map.on('mousemove', 'links-hit-layer', (e) => {
if (
e.features &&
e.features.length > 0 &&
!draggingNodeRef.current &&
editModeRef.current === 'select'
) {
map.getCanvas().style.cursor = 'pointer';
const props = e.features[0].properties as Record<string, unknown>;
hoverPointRef.current = { x: e.originalEvent.clientX, y: e.originalEvent.clientY };
updateHover({ type: 'link', id: String(props.id), props });
placeHoverBox();
}
});
map.on('mouseleave', 'links-hit-layer', () => {
if (!draggingNodeRef.current) map.getCanvas().style.cursor = 'crosshair';
updateHover(null);
});
map.on('mousedown', 'nodes-layer', (e) => {
if (editModeRef.current !== 'select') return;
const original = e.originalEvent;
if (
boxSelectionEnabledRef.current ||
original?.shiftKey ||
original?.ctrlKey ||
original?.metaKey
) return;
if (!e.features || e.features.length === 0) return;
const feature = e.features[0];
const nodeId = feature.properties?.id;
e.preventDefault();
map.getCanvas().style.cursor = 'grabbing';
map.dragPan.disable();
draggingNodeRef.current = {
id: String(nodeId),
startX: e.point.x,
startY: e.point.y,
startLngLat: [e.lngLat.lng, e.lngLat.lat],
hasMoved: false,
};
});
map.on('mousedown', (e) => {
const original = e.originalEvent;
if (
editModeRef.current !== 'select' ||
original.button !== 0 ||
(!boxSelectionEnabledRef.current && !original.shiftKey)
) return;
e.preventDefault();
map.dragPan.disable();
boxSelectionRef.current = {
start: { x: e.point.x, y: e.point.y },
current: { x: e.point.x, y: e.point.y },
active: false,
additive: original.shiftKey || original.ctrlKey || original.metaKey,
};
const overlay = boxOverlayRef.current;
if (overlay) {
overlay.style.display = 'block';
overlay.style.left = `${e.point.x}px`;
overlay.style.top = `${e.point.y}px`;
overlay.style.width = '0px';
overlay.style.height = '0px';
}
});
map.on('mousemove', (e) => {
const boxSelection = boxSelectionRef.current;
if (boxSelection) {
boxSelection.current = { x: e.point.x, y: e.point.y };
const dx = e.point.x - boxSelection.start.x;
const dy = e.point.y - boxSelection.start.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) boxSelection.active = true;
const overlay = boxOverlayRef.current;
if (overlay) {
const crossing = dx < 0;
overlay.style.left = `${Math.min(boxSelection.start.x, e.point.x)}px`;
overlay.style.top = `${Math.min(boxSelection.start.y, e.point.y)}px`;
overlay.style.width = `${Math.abs(dx)}px`;
overlay.style.height = `${Math.abs(dy)}px`;
overlay.style.borderColor = crossing ? '#16a34a' : '#2563eb';
overlay.style.borderStyle = crossing ? 'dashed' : 'solid';
overlay.style.background = crossing
? 'rgba(22, 163, 74, 0.14)'
: 'rgba(37, 99, 235, 0.14)';
}
return;
}
let currentTargetLngLat = [e.lngLat.lng, e.lngLat.lat] as [
number,
number
];
if (onCursorCoordsChangeRef.current) {
const [cx, cy] = lngLatToModel(e.lngLat.lng, e.lngLat.lat, crsRef.current, extentsRef.current);
onCursorCoordsChangeRef.current({ x: cx, y: cy });
}
const snapSource = map.getSource('snap-indicator') as
| maplibregl.GeoJSONSource
| undefined;
const shouldSnap =
snapEnabledRef.current &&
(editModeRef.current === 'add-node' ||
editModeRef.current === 'add-pipe' ||
draggingNodeRef.current !== null);
if (shouldSnap && latestGeojsonRef.current) {
const snapThresholdPixels = 16;
let closestNode: {
id: string;
coords: [number, number];
distPx: number;
} | null = null;
for (const feature of latestGeojsonRef.current.nodes.features) {
const coords = feature.geometry.coordinates as [number, number];
const screenPoint = map.project(coords);
const distPx = Math.hypot(
screenPoint.x - e.point.x,
screenPoint.y - e.point.y
);
if (distPx <= snapThresholdPixels) {
if (!closestNode || distPx < closestNode.distPx) {
closestNode = {
id: String(feature.properties?.id),
coords,
distPx,
};
}
}
}
if (closestNode) {
currentTargetLngLat = closestNode.coords;
if (snappedNodeRef.current?.id !== closestNode.id) {
const nextSnapped = { id: closestNode.id, lngLat: closestNode.coords };
snappedNodeRef.current = nextSnapped;
setSnappedNode(nextSnapped);
snapSource?.setData({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {},
geometry: {
type: 'Point',
coordinates: closestNode.coords,
},
},
],
});
}
} else if (snappedNodeRef.current) {
snappedNodeRef.current = null;
setSnappedNode(null);
snapSource?.setData({ type: 'FeatureCollection', features: [] });
}
} else if (snapSource && snappedNodeRef.current) {
snappedNodeRef.current = null;
setSnappedNode(null);
snapSource.setData({ type: 'FeatureCollection', features: [] });
}
// Ghost pipe
if (
editModeRef.current === 'add-pipe' &&
pipeStartNodeIdRef.current &&
latestGeojsonRef.current
) {
const startFeature = latestGeojsonRef.current.nodes.features.find(
(f) => f.properties?.id === pipeStartNodeIdRef.current
);
if (startFeature) {
const startCoord = startFeature.geometry.coordinates;
const ghostSource = map.getSource('ghost-pipe') as
| maplibregl.GeoJSONSource
| undefined;
if (ghostSource) {
ghostSource.setData({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {},
geometry: {
type: 'LineString',
coordinates: [startCoord, currentTargetLngLat],
},
},
],
});
}
}
}
// Dragging
if (!draggingNodeRef.current) return;
const dx = Math.abs(e.point.x - draggingNodeRef.current.startX);
const dy = Math.abs(e.point.y - draggingNodeRef.current.startY);
if (dx > 3 || dy > 3) {
draggingNodeRef.current.hasMoved = true;
}
const nodeId = draggingNodeRef.current.id;
const [newLon, newLat] = currentTargetLngLat;
const nodesSource = map.getSource('nodes') as maplibregl.GeoJSONSource;
const linksSource = map.getSource('links') as maplibregl.GeoJSONSource;
if (nodesSource && linksSource && latestGeojsonRef.current) {
const currentNodesData = latestGeojsonRef.current.nodes;
const currentLinksData = latestGeojsonRef.current.links;
const updatedNodeFeatures = currentNodesData.features.map((f) => {
if (f.properties?.id === nodeId) {
return {
...f,
geometry: {
...f.geometry,
coordinates: [newLon, newLat],
},
};
}
return f;
});
const updatedLinkFeatures = currentLinksData.features.map((f) => {
const isFrom = f.properties?.fromNode === nodeId;
const isTo = f.properties?.toNode === nodeId;
if (isFrom || isTo) {
const coords = [...f.geometry.coordinates];
if (isFrom) coords[0] = [newLon, newLat];
if (isTo) coords[coords.length - 1] = [newLon, newLat];
return {
...f,
geometry: {
...f.geometry,
coordinates: coords,
},
};
}
return f;
});
if (sourceUpdateFrameRef.current !== null) {
window.cancelAnimationFrame(sourceUpdateFrameRef.current);
}
sourceUpdateFrameRef.current = window.requestAnimationFrame(() => {
nodesSource.setData({
type: 'FeatureCollection',
features: updatedNodeFeatures,
});
linksSource.setData({
type: 'FeatureCollection',
features: updatedLinkFeatures,
});
sourceUpdateFrameRef.current = null;
});
}
});
map.on('mouseout', () => {
onCursorCoordsChangeRef.current?.(null);
});
const applyClickSelection = (
target: Exclude<SelectionTarget, null>,
additive: boolean
) => {
if (!additive || target.type === 'gis-feature') {
onMultiSelectChangeRef.current?.(() => ({
nodes: new Set<string>(),
links: new Set<string>(),
gisFeatures: [],
}));
onSelectTargetRef.current(target);
return;
}
const current = multiSelectionRef.current ?? {
nodes: new Set<string>(),
links: new Set<string>(),
gisFeatures: [],
};
const next: MultiSelectionState = {
nodes: new Set(current.nodes),
links: new Set(current.links),
gisFeatures: [...current.gisFeatures],
};
const active = selectedTargetRef.current;
if (active?.type === 'node') next.nodes.add(active.id);
if (active?.type === 'link') next.links.add(active.id);
const targetSet = target.type === 'node' ? next.nodes : next.links;
const wasSelected = targetSet.has(target.id);
if (wasSelected) targetSet.delete(target.id);
else targetSet.add(target.id);
onMultiSelectChangeRef.current?.(() => next);
if (!wasSelected) {
onSelectTargetRef.current(target);
} else {
const nextNode = next.nodes.values().next().value as string | undefined;
const nextLink = next.links.values().next().value as string | undefined;
onSelectTargetRef.current(
nextNode
? { type: 'node', id: nextNode }
: nextLink
? { type: 'link', id: nextLink }
: null
);
}
};
map.on('click', (e) => {
if (suppressNextClickRef.current) {
suppressNextClickRef.current = false;
return;
}
if (draggingNodeRef.current?.hasMoved) return;
const modifier =
e.originalEvent.shiftKey ||
e.originalEvent.ctrlKey ||
e.originalEvent.metaKey;
const nodeHitBox = [
{ x: e.point.x - 12, y: e.point.y - 12 },
{ x: e.point.x + 12, y: e.point.y + 12 },
] as unknown as [maplibregl.PointLike, maplibregl.PointLike];
const linkHitBox = [
{ x: e.point.x - 12, y: e.point.y - 12 },
{ x: e.point.x + 12, y: e.point.y + 12 },
] as unknown as [maplibregl.PointLike, maplibregl.PointLike];
const nodeFeatures = map.getLayer('nodes-hit-layer')
? map.queryRenderedFeatures(nodeHitBox, { layers: ['nodes-hit-layer'] })
: [];
const clickedNodeId = nodeFeatures.length > 0
? String(nodeFeatures[0].properties?.id)
: null;
if (editModeRef.current === 'add-node') {
const [newX, newY] = lngLatToModel(
e.lngLat.lng,
e.lngLat.lat,
crsRef.current,
extentsRef.current
);
onAddNodeAtCoordsRef.current(newX, newY);
return;
}
if (editModeRef.current === 'add-pipe') {
if (clickedNodeId) {
if (!pipeStartNodeIdRef.current) {
setPipeStartNodeId(clickedNodeId);
} else {
if (pipeStartNodeIdRef.current !== clickedNodeId) {
onAddPipeBetweenNodesRef.current(pipeStartNodeIdRef.current, clickedNodeId);
}
setPipeStartNodeId(null);
const ghostSource = map.getSource('ghost-pipe') as maplibregl.GeoJSONSource | undefined;
ghostSource?.setData({ type: 'FeatureCollection', features: [] });
}
} else {
setPipeStartNodeId(null);
const ghostSource = map.getSource('ghost-pipe') as maplibregl.GeoJSONSource | undefined;
ghostSource?.setData({ type: 'FeatureCollection', features: [] });
}
return;
}
if (clickedNodeId) {
applyClickSelection({ type: 'node', id: clickedNodeId }, modifier);
return;
}
const linkFeatures = map.getLayer('links-hit-layer')
? map.queryRenderedFeatures(linkHitBox, { layers: ['links-hit-layer'] })
: [];
if (linkFeatures.length > 0) {
applyClickSelection(
{ type: 'link', id: String(linkFeatures[0].properties?.id) },
modifier
);
return;
}
if (!modifier) {
onSelectTargetRef.current(null);
onMultiSelectChangeRef.current?.(() => ({
nodes: new Set<string>(),
links: new Set<string>(),
gisFeatures: [],
}));
}
});
// Prevent browser native context menu on canvas
map.getCanvas().addEventListener('contextmenu', (e) => {
e.preventDefault();
});
// Unified robust context menu handler (nodes, links, user GIS layers, canvas)
map.on('contextmenu', (e) => {
const canvasRect = map.getCanvas().getBoundingClientRect();
const clientX = e.originalEvent?.clientX ?? (e.point.x + canvasRect.left);
const clientY = e.originalEvent?.clientY ?? (e.point.y + canvasRect.top);
const [modelX, modelY] = lngLatToModel(
e.lngLat.lng,
e.lngLat.lat,
crsRef.current,
extentsRef.current
);
const ext = extentsRef.current;
const span = Math.max(ext.maxX - ext.minX, ext.maxY - ext.minY) || 100;
// Node and link hit areas are both 12 px; ordered evaluation guarantees
// a junction wins when a pipe terminates at the same screen point.
const p0 = { x: e.point.x - 12, y: e.point.y - 12 };
const p1 = { x: e.point.x + 12, y: e.point.y + 12 };
const hitBox = [p0, p1] as unknown as [maplibregl.PointLike, maplibregl.PointLike];
// 1. Expanded node hit area wins.
const visibility = layerVisibilityRef.current;
const isNodesVisible = visibility
? visibility.nodes || visibility.tanksReservoirs
: true;
if (isNodesVisible) {
let hitNodeId: string | null = null;
if (map.getLayer('nodes-hit-layer')) {
const nodeFeatures = map.queryRenderedFeatures(hitBox, { layers: ['nodes-hit-layer'] });
if (nodeFeatures.length > 0) {
hitNodeId = String(nodeFeatures[0].properties?.id);
}
}
if (hitNodeId) {
onSelectTargetRef.current({ type: 'node', id: hitNodeId });
const n = networkModelRef.current?.nodes[hitNodeId];
onContextMenuRef.current?.({
x: clientX,
y: clientY,
target: {
type: 'node',
id: hitNodeId,
lngLat: [e.lngLat.lng, e.lngLat.lat],
modelCoords: n ? [n.x, n.y] : [modelX, modelY],
},
});
return;
}
}
// 2. Wide rendered link hit layer, then a conservative model fallback.
const isPipesVisible = visibility ? visibility.pipes : true;
if (isPipesVisible) {
let hitLinkId: string | null = null;
if (map.getLayer('links-hit-layer')) {
const linkFeatures = map.queryRenderedFeatures(hitBox, { layers: ['links-hit-layer'] });
if (linkFeatures.length > 0) {
hitLinkId = String(linkFeatures[0].properties?.id);
}
}
const currentModel = networkModelRef.current;
if (!hitLinkId && currentModel) {
const pipeThreshold = span * 0.008;
let closestDist = Infinity;
for (const p of Object.values(currentModel.pipes)) {
const n1 = currentModel.nodes[p.node1];
const n2 = currentModel.nodes[p.node2];
if (!n1 || !n2) continue;
const l2 = (n2.x - n1.x) ** 2 + (n2.y - n1.y) ** 2;
let d = 0;
if (l2 === 0) {
d = Math.hypot(modelX - n1.x, modelY - n1.y);
} else {
let t = ((modelX - n1.x) * (n2.x - n1.x) + (modelY - n1.y) * (n2.y - n1.y)) / l2;
t = Math.max(0, Math.min(1, t));
const px = n1.x + t * (n2.x - n1.x);
const py = n1.y + t * (n2.y - n1.y);
d = Math.hypot(modelX - px, modelY - py);
}
if (d < pipeThreshold && d < closestDist) {
closestDist = d;
hitLinkId = p.id;
}
}
}
if (hitLinkId) {
onSelectTargetRef.current({ type: 'link', id: hitLinkId });
onContextMenuRef.current?.({
x: clientX,
y: clientY,
target: {
type: 'link',
id: hitLinkId,
lngLat: [e.lngLat.lng, e.lngLat.lat],
},
});
return;
}
}
// 3. Check if any user GIS layer was right-clicked
const userGisLayerIds = map
.getStyle()
?.layers?.filter((l) => l.id.startsWith('user-gis-'))
.map((l) => l.id) || [];
if (userGisLayerIds.length > 0) {
const gisFeatures = map.queryRenderedFeatures(hitBox, { layers: userGisLayerIds });
if (gisFeatures.length > 0) {
const f = gisFeatures[0];
const match = f.layer.id.match(/^user-gis-(?:fill|line|circle)-(.+)$/);
const layerId = match ? match[1] : '';
const featureId = f.id ?? 1;
const properties = (f.properties as Record<string, unknown>) || {};
onSelectTargetRef.current({
type: 'gis-feature',
layerId,
featureId,
properties,
geometryType: f.geometry.type,
});
onContextMenuRef.current?.({
x: clientX,
y: clientY,
target: {
type: 'gis-feature',
layerId,
featureId,
properties,
lngLat: [e.lngLat.lng, e.lngLat.lat],
},
});
return;
}
}
// 4. Empty map canvas right-click
onContextMenuRef.current?.({
x: clientX,
y: clientY,
target: {
type: 'canvas',
lngLat: [e.lngLat.lng, e.lngLat.lat],
modelCoords: [modelX, modelY],
},
});
});
});
const handleGlobalMouseUp = (e: MouseEvent) => {
const map = mapRef.current;
if (!map) return;
const boxSelection = boxSelectionRef.current;
if (boxSelection) {
const overlay = boxOverlayRef.current;
if (overlay) overlay.style.display = 'none';
map.dragPan.enable();
boxSelectionRef.current = null;
if (boxSelection.active && latestGeojsonRef.current) {
const screenNodes = latestGeojsonRef.current.nodes.features.map((feature) => {
const projected = map.project(feature.geometry.coordinates as [number, number]);
return {
id: String(feature.properties?.id),
point: { x: projected.x, y: projected.y },
};
});
const screenLinks = latestGeojsonRef.current.links.features.map((feature) => ({
id: String(feature.properties?.id),
points: feature.geometry.coordinates.map((coordinate) => {
const projected = map.project(coordinate as [number, number]);
return { x: projected.x, y: projected.y };
}),
}));
const selected = selectNetworkFeaturesInBox(
screenNodes,
screenLinks,
boxSelection.start,
boxSelection.current
);
const current = multiSelectionRef.current ?? {
nodes: new Set<string>(),
links: new Set<string>(),
gisFeatures: [],
};
const next: MultiSelectionState = {
nodes: boxSelection.additive ? new Set(current.nodes) : new Set<string>(),
links: boxSelection.additive ? new Set(current.links) : new Set<string>(),
gisFeatures: boxSelection.additive ? [...current.gisFeatures] : [],
};
if (boxSelection.additive) {
const active = selectedTargetRef.current;
if (active?.type === 'node') next.nodes.add(active.id);
if (active?.type === 'link') next.links.add(active.id);
}
selected.nodes.forEach((id) => next.nodes.add(id));
selected.links.forEach((id) => next.links.add(id));
onMultiSelectChangeRef.current?.(() => next);
const firstNode = next.nodes.values().next().value as string | undefined;
const firstLink = next.links.values().next().value as string | undefined;
onSelectTargetRef.current(
firstNode
? { type: 'node', id: firstNode }
: firstLink
? { type: 'link', id: firstLink }
: null
);
suppressNextClickRef.current = true;
}
return;
}
const vd = draggingVertexRef.current;
if (vd) {
draggingVertexRef.current = null;
map.dragPan.enable();
map.getCanvas().style.cursor = 'crosshair';
if (vd.moved) {
const rect = map.getCanvas().getBoundingClientRect();
const ll = map.unproject([e.clientX - rect.left, e.clientY - rect.top]);
const [x, y] = lngLatToModel(ll.lng, ll.lat, crsRef.current, extentsRef.current);
const pt = { x: Math.round(x * 1e6) / 1e6, y: Math.round(y * 1e6) / 1e6 };
const verts = [...(networkModelRef.current?.vertices?.[vd.link] ?? [])];
if (vd.kind === 'vertex') verts[vd.idx] = pt;
else verts.splice(vd.idx, 0, pt);
suppressNextClickRef.current = true;
onVerticesChangeRef.current?.(vd.link, verts, vd.kind === 'vertex' ? `관로 ${vd.link}의 꺾임점을 옮겼습니다.` : `관로 ${vd.link}에 꺾임점을 추가했습니다.`);
}
return;
}
if (!draggingNodeRef.current) return;
const { id, hasMoved } = draggingNodeRef.current;
map.getCanvas().style.cursor = 'crosshair';
map.dragPan.enable();
if (hasMoved) {
const canvasRect = map.getCanvas().getBoundingClientRect();
const point = [
e.clientX - canvasRect.left,
e.clientY - canvasRect.top,
] as [number, number];
const lngLat = map.unproject(point);
const [newX, newY] = lngLatToModel(
lngLat.lng,
lngLat.lat,
crsRef.current,
extentsRef.current
);
onNodeDragEndRef.current(id, newX, newY);
}
draggingNodeRef.current = null;
};
window.addEventListener('mouseup', handleGlobalMouseUp);
return () => {
window.removeEventListener('mouseup', handleGlobalMouseUp);
if (sourceUpdateFrameRef.current !== null) {
window.cancelAnimationFrame(sourceUpdateFrameRef.current);
sourceUpdateFrameRef.current = null;
}
map.remove();
mapRef.current = null;
};
}, []);
// Toggle Flow Arrows Layer Visibility
useEffect(() => {
if (!mapRef.current) return;
const map = mapRef.current;
if (map.getLayer('flow-arrows-layer')) {
map.setLayoutProperty(
'flow-arrows-layer',
'visibility',
showFlowArrows ? 'visible' : 'none'
);
}
}, [showFlowArrows]);
// Switch existing raster layers instead of recreating sources or styles.
useEffect(() => {
if (!mapRef.current) return;
const map = mapRef.current;
const applyBasemap = () => {
const layerIds = [
'basemap-osm-layer',
'basemap-vworld-layer',
'basemap-vworld-white-layer',
'basemap-vworld-midnight-layer',
'basemap-vworld-satellite-layer',
'basemap-vworld-hybrid-layer',
] as const;
if (layerIds.some((layerId) => !map.getLayer(layerId))) return;
const visibilityByLayer: Record<(typeof layerIds)[number], boolean> = {
'basemap-osm-layer': basemap === 'osm',
'basemap-vworld-layer': basemap === 'vworld',
'basemap-vworld-white-layer': basemap === 'vworld-white',
'basemap-vworld-midnight-layer': basemap === 'vworld-midnight',
'basemap-vworld-satellite-layer': basemap === 'vworld-satellite' || basemap === 'vworld-satellite-hybrid',
'basemap-vworld-hybrid-layer': basemap === 'vworld-satellite-hybrid',
};
for (const layerId of layerIds) {
map.setLayoutProperty(layerId, 'visibility', visibilityByLayer[layerId] ? 'visible' : 'none');
}
map.setPaintProperty(
'background',
'background-color',
basemap === 'blank' ? '#f8fafc' : '#e2e8f0'
);
if (basemap.startsWith('vworld')) vworldErrorShownRef.current = false;
};
if (mapReadyRef.current) applyBasemap();
else map.once('load', applyBasemap);
return () => {
map.off('load', applyBasemap);
};
}, [basemap]);
// CAD workspace default cursor.
useEffect(() => {
if (!mapRef.current) return;
mapRef.current.getCanvas().style.cursor = 'crosshair';
}, [editMode, boxSelectionEnabled]);
// Coalesce network data transfers to one worker update per animation frame.
useEffect(() => {
latestGeojsonRef.current = geojson;
if (!mapRef.current || !geojson) return;
const map = mapRef.current;
const applyGeoJson = () => {
const linksSource = map.getSource('links') as maplibregl.GeoJSONSource | undefined;
const nodesSource = map.getSource('nodes') as maplibregl.GeoJSONSource | undefined;
const arrowsSource = map.getSource('flow-arrows') as maplibregl.GeoJSONSource | undefined;
if (!linksSource || !nodesSource) return;
if (geoJsonUpdateFrameRef.current !== null) {
window.cancelAnimationFrame(geoJsonUpdateFrameRef.current);
}
geoJsonUpdateFrameRef.current = window.requestAnimationFrame(() => {
linksSource.setData(geojson.links);
nodesSource.setData(geojson.nodes);
arrowsSource?.setData(geojson.arrows);
geoJsonUpdateFrameRef.current = null;
});
};
if (mapReadyRef.current) applyGeoJson();
else map.once('load', applyGeoJson);
return () => {
map.off('load', applyGeoJson);
if (geoJsonUpdateFrameRef.current !== null) {
window.cancelAnimationFrame(geoJsonUpdateFrameRef.current);
geoJsonUpdateFrameRef.current = null;
}
};
}, [geojson]);
// Auto-fit bounds when CRS changes
useEffect(() => {
if (!mapRef.current || !geojson || geojson.nodes.features.length === 0)
return;
const map = mapRef.current;
const bounds = new maplibregl.LngLatBounds();
for (const f of geojson.nodes.features) {
bounds.extend(f.geometry.coordinates as [number, number]);
}
if (!bounds.isEmpty()) {
map.fitBounds(bounds, { padding: 50, maxZoom: 17, duration: 600 });
}
}, [crs]);
// Selection highlighting uses filters, avoiding full GeoJSON regeneration.
useEffect(() => {
if (!mapRef.current) return;
const map = mapRef.current;
const nodeIds = new Set(multiSelection?.nodes ?? []);
const linkIds = new Set(multiSelection?.links ?? []);
if (selectedTarget?.type === 'node') nodeIds.add(selectedTarget.id);
if (selectedTarget?.type === 'link') linkIds.add(selectedTarget.id);
if (pipeStartNodeId) nodeIds.add(pipeStartNodeId);
const applyFilters = () => {
if (map.getLayer('nodes-selected-layer')) {
map.setFilter('nodes-selected-layer', [
'in',
['get', 'id'],
['literal', Array.from(nodeIds)],
]);
}
if (map.getLayer('links-selected-layer')) {
map.setFilter('links-selected-layer', [
'in',
['get', 'id'],
['literal', Array.from(linkIds)],
]);
}
};
if (mapReadyRef.current) applyFilters();
else map.once('load', applyFilters);
return () => {
map.off('load', applyFilters);
};
}, [selectedTarget, multiSelection, pipeStartNodeId]);
// Camera movement occurs only for explicit search/table/context requests.
useEffect(() => {
if (!mapRef.current || !focusRequest) return;
const map = mapRef.current;
let center = focusRequest.lngLat;
const target = focusRequest.target;
const currentGeojson = latestGeojsonRef.current;
if (!center && target?.type === 'node' && currentGeojson) {
const feature = currentGeojson.nodes.features.find(
(candidate) => candidate.properties?.id === target.id
);
center = feature?.geometry.coordinates as [number, number] | undefined;
} else if (!center && target?.type === 'link' && currentGeojson) {
const feature = currentGeojson.links.features.find(
(candidate) => candidate.properties?.id === target.id
);
const coordinates = feature?.geometry.coordinates;
if (coordinates && coordinates.length > 0) {
if (coordinates.length === 1) {
center = coordinates[0] as [number, number];
} else if (coordinates.length === 2) {
const p1 = coordinates[0];
const p2 = coordinates[1];
center = [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2];
} else {
let totalLen = 0;
const segLens: number[] = [];
for (let i = 0; i < coordinates.length - 1; i++) {
const l = Math.hypot(coordinates[i + 1][0] - coordinates[i][0], coordinates[i + 1][1] - coordinates[i][1]);
segLens.push(l);
totalLen += l;
}
const half = totalLen / 2;
let acc = 0;
const pFirst = coordinates[0];
const pLast = coordinates[coordinates.length - 1];
center = [(pFirst[0] + pLast[0]) / 2, (pFirst[1] + pLast[1]) / 2];
for (let i = 0; i < segLens.length; i++) {
if (acc + segLens[i] >= half) {
const r = segLens[i] > 0 ? (half - acc) / segLens[i] : 0.5;
center = [
coordinates[i][0] + (coordinates[i + 1][0] - coordinates[i][0]) * r,
coordinates[i][1] + (coordinates[i + 1][1] - coordinates[i][1]) * r,
];
break;
}
acc += segLens[i];
}
}
}
}
if (!center) return;
const currentBounds = map.getBounds();
const isInside = currentBounds.contains(center);
const currentZoom = map.getZoom();
const targetZoom = focusRequest.zoom !== undefined
? focusRequest.zoom
: (isInside ? currentZoom : Math.min(Math.max(currentZoom, 14), 16));
map.stop();
map.easeTo({
center,
zoom: targetZoom,
duration: 450,
essential: true,
});
}, [focusRequest]);
useLayoutEffect(placeHoverBox, [hovered]);
// Handles for the selected link: its vertices plus midpoints of every leg.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const apply = () => {
const src = map.getSource('vertex-handles') as maplibregl.GeoJSONSource | undefined;
if (!src) return;
const features: Array<Feature<Point>> = [];
const id = selectedTarget?.type === 'link' ? selectedTarget.id : null;
const model = networkModel;
const link = id && model ? model.pipes[id] ?? model.pumps[id] ?? model.valves?.[id] : undefined;
if (link && model && onVerticesChange && editMode === 'select') {
const a = model.nodes[link.node1];
const b = model.nodes[link.node2];
if (a && b) {
const verts = model.vertices?.[link.id] ?? [];
const pts = [a, ...verts, b].map((p) => modelToLngLat(p.x, p.y, crs, extents));
verts.forEach((_, k) => features.push({ type: 'Feature', properties: { link: link.id, idx: k, kind: 'vertex' }, geometry: { type: 'Point', coordinates: pts[k + 1] } }));
for (let k = 0; k < pts.length - 1; k++) {
features.push({ type: 'Feature', properties: { link: link.id, idx: k, kind: 'mid' }, geometry: { type: 'Point', coordinates: [(pts[k][0] + pts[k + 1][0]) / 2, (pts[k][1] + pts[k + 1][1]) / 2] } });
}
}
}
src.setData({ type: 'FeatureCollection', features });
};
if (mapReadyRef.current) apply();
else map.once('load', apply);
return () => { map.off('load', apply); };
}, [selectedTarget, networkModel, crs, extents, editMode, onVerticesChange]);
// Annotation positions: node coordinates or the midpoint of a link's line.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const apply = () => {
const src = map.getSource('annotations') as maplibregl.GeoJSONSource | undefined;
if (!src) return;
const nodeAt = new Map<string, [number, number]>();
for (const f of geojson?.nodes.features ?? []) nodeAt.set(String(f.properties?.id), f.geometry.coordinates as [number, number]);
const linkAt = new Map<string, [number, number]>();
for (const f of geojson?.links.features ?? []) {
const c = f.geometry.coordinates as [number, number][];
if (c.length) {
const a = c[Math.floor((c.length - 1) / 2)];
const b = c[Math.ceil((c.length - 1) / 2)];
linkAt.set(String(f.properties?.id), [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]);
}
}
const features = (annotations ?? []).flatMap((a) => {
const at = a.type === 'node' ? nodeAt.get(a.id) : linkAt.get(a.id);
return at ? [{ type: 'Feature' as const, properties: { text: a.text, color: a.color ?? null, kind: 'annotation' }, geometry: { type: 'Point' as const, coordinates: at } }] : [];
});
// [LABELS] map label objects from the INP, at their model coordinates.
for (const l of networkModel?.extras?.labels ?? []) {
if (!l.text) continue;
features.push({ type: 'Feature' as const, properties: { text: l.text, color: '#334155', kind: 'label' }, geometry: { type: 'Point' as const, coordinates: modelToLngLat(l.x, l.y, crs, extents) as [number, number] } });
}
src.setData({ type: 'FeatureCollection', features });
};
if (mapReadyRef.current) apply();
else map.once('load', apply);
return () => { map.off('load', apply); };
}, [annotations, geojson, networkModel?.extras?.labels, crs, extents]);
// Apply 라벨 설정 (LayerPanel > label button) to the node / pipe label layers.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const apply = () => {
const targets: Array<[string, string, LayerLabelConfig | undefined, boolean]> = [
['nodes-label-layer', 'nodes', layerLabels?.nodes, true],
['links-label-layer', 'pipes', layerLabels?.pipes, false],
];
for (const [layerId, , cfg, shownByDefault] of targets) {
if (!map.getLayer(layerId)) continue;
const visible = cfg ? cfg.showLabel : shownByDefault;
map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none');
if (!visible) continue;
map.setLayoutProperty(layerId, 'text-field', labelTextExpression(cfg?.field ?? 'id'));
map.setLayoutProperty(layerId, 'text-size', cfg?.fontSize ?? 10);
map.setPaintProperty(layerId, 'text-color', cfg?.color ?? '#334155');
map.setPaintProperty(layerId, 'text-halo-color', cfg?.haloColor ?? '#ffffff');
map.setPaintProperty(layerId, 'text-halo-width', cfg && !cfg.halo ? 0 : 1);
}
};
if (mapReadyRef.current) apply();
else map.once('load', apply);
return () => { map.off('load', apply); };
}, [layerLabels]);
// Update Layer Styling & Color Coding based on displayMode
useEffect(() => {
if (!mapRef.current || !geojson) return;
const map = mapRef.current;
const applyStyle = () => {
if (!map.getLayer('nodes-layer') || !map.getLayer('links-layer')) return;
map.setPaintProperty('nodes-layer', 'circle-color', nodeColorExpr as string);
map.setPaintProperty('links-layer', 'line-color', linkColorExpr as string);
if (layerVisibility?.networkOpacity) {
map.setPaintProperty('nodes-layer', 'circle-opacity', layerVisibility.networkOpacity.nodes);
map.setPaintProperty('links-layer', 'line-opacity', layerVisibility.networkOpacity.pipes);
}
};
if (mapReadyRef.current) {
applyStyle();
} else {
map.once('load', applyStyle);
}
return () => {
map.off('load', applyStyle);
};
}, [colorKey, geojson, layerVisibility]);
// Apply Map Style Settings directly to MapLibre Paint Properties (Q2, Q3)
useEffect(() => {
if (!mapRef.current) return;
const map = mapRef.current;
const applyStyles = () => {
const s = styleSettings;
if (!s) return;
if (map.getLayer('nodes-layer')) {
map.setPaintProperty('nodes-layer', 'circle-opacity', s.nodeOpacity / 100);
map.setPaintProperty('nodes-layer', 'circle-stroke-opacity', s.nodeOpacity / 100);
map.setPaintProperty('nodes-layer', 'circle-radius', nodeSizeExpr as number);
}
if (map.getLayer('links-layer')) {
map.setPaintProperty('links-layer', 'line-opacity', s.pipeOpacity / 100);
map.setPaintProperty('links-layer', 'line-width', linkWidthExpr as number);
}
if (map.getLayer('flow-arrows-layer')) {
map.setLayoutProperty('flow-arrows-layer', 'visibility', s.showFlowArrows ? 'visible' : 'none');
}
const basemapLayerIds = [
'basemap-vworld-layer',
'basemap-vworld-white-layer',
'basemap-vworld-midnight-layer',
'basemap-vworld-satellite-layer',
'basemap-vworld-hybrid-layer',
'basemap-osm-layer',
];
for (const layerId of basemapLayerIds) {
if (map.getLayer(layerId)) {
map.setPaintProperty(layerId, 'raster-opacity', s.basemapOpacity / 100);
}
}
};
if (mapReadyRef.current) applyStyles();
else map.once('load', applyStyles);
}, [styleSettings, sizeKey]);
// 3D 보기: pitch, DEM terrain (terrarium tiles from the API) and node columns.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const apply = () => {
const on = !!view3d?.enabled;
const terrainId = view3d?.demId ? `dem-terrain-${view3d.demId}` : null;
for (const id of Object.keys(map.getStyle()?.sources ?? {})) {
if (id.startsWith('dem-terrain-') && (!on || id !== terrainId)) {
if (map.getTerrain()?.source === id) map.setTerrain(null);
map.removeSource(id);
}
}
if (on && terrainId) {
if (!map.getSource(terrainId)) {
map.addSource(terrainId, { type: 'raster-dem', tiles: [`${window.location.origin}/api/dem/${view3d!.demId}/terrain/{z}/{x}/{y}.png`], tileSize: 256, encoding: 'terrarium', maxzoom: 15 });
}
map.setTerrain({ source: terrainId, exaggeration: view3d!.exaggeration || 1 });
} else if (map.getTerrain()) map.setTerrain(null);
// Node columns: small squares extruded by a display value.
const colSrc = map.getSource('node-columns') as maplibregl.GeoJSONSource | undefined;
const features: Array<Feature<GeoJSON.Polygon>> = [];
if (on && view3d!.columns !== 'none' && geojson) {
const pts = geojson.nodes.features.map((f) => f.geometry.coordinates as [number, number]);
let w = Infinity, e = -Infinity, s = Infinity, n = -Infinity;
for (const [x, y] of pts) { w = Math.min(w, x); e = Math.max(e, x); s = Math.min(s, y); n = Math.max(n, y); }
const half = Math.max(e - w, n - s, 1e-4) * 0.004;
for (const f of geojson.nodes.features) {
const [x, y] = f.geometry.coordinates as [number, number];
const v = Number(f.properties?.[view3d!.columns]);
if (!Number.isFinite(v)) continue;
features.push({ type: 'Feature', properties: { h: Math.max(0, v * view3d!.columnScale), v }, geometry: { type: 'Polygon', coordinates: [[[x - half, y - half], [x + half, y - half], [x + half, y + half], [x - half, y + half], [x - half, y - half]]] } });
}
}
if (!colSrc) map.addSource('node-columns', { type: 'geojson', data: { type: 'FeatureCollection', features } });
else colSrc.setData({ type: 'FeatureCollection', features });
if (!map.getLayer('node-columns-layer')) {
map.addLayer({
id: 'node-columns-layer', type: 'fill-extrusion', source: 'node-columns',
paint: {
'fill-extrusion-height': ['get', 'h'],
'fill-extrusion-base': 0,
'fill-extrusion-opacity': 0.85,
'fill-extrusion-color': ['interpolate', ['linear'], ['get', 'v'], 0, '#dc2626', 15, '#f59e0b', 30, '#22c55e', 60, '#2563eb'],
},
});
}
const pitch = on ? 55 : 0;
if (Math.abs(map.getPitch() - pitch) > 1) map.easeTo({ pitch, duration: 600 });
};
if (mapReadyRef.current) apply();
else map.once('load', apply);
return () => { map.off('load', apply); };
}, [view3d, geojson]);
// Raster overlays: image sources below the GIS vector layers and the network.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const sync = () => {
const wanted = new Set((rasterOverlays ?? []).map((r) => r.id));
for (const l of map.getStyle()?.layers ?? []) {
if (l.id.startsWith('raster-ov-') && !wanted.has(l.id.slice('raster-ov-'.length))) map.removeLayer(l.id);
}
for (const id of Object.keys(map.getStyle()?.sources ?? {})) {
if (id.startsWith('raster-ov-src-') && !wanted.has(id.slice('raster-ov-src-'.length))) map.removeSource(id);
}
const before = (map.getStyle()?.layers ?? []).find((l) => l.id.startsWith('user-gis-'))?.id ?? (map.getLayer('links-layer') ? 'links-layer' : undefined);
for (const r of rasterOverlays ?? []) {
const srcId = `raster-ov-src-${r.id}`;
const layerId = `raster-ov-${r.id}`;
const coordinates = r.corners as [[number, number], [number, number], [number, number], [number, number]];
const src = map.getSource(srcId) as maplibregl.ImageSource | undefined;
if (!src) map.addSource(srcId, { type: 'image', url: r.url, coordinates });
else src.updateImage({ url: r.url, coordinates });
if (!map.getLayer(layerId)) map.addLayer({ id: layerId, type: 'raster', source: srcId, paint: { 'raster-opacity': r.opacity, 'raster-fade-duration': 0 } }, before);
map.setPaintProperty(layerId, 'raster-opacity', r.opacity);
map.setLayoutProperty(layerId, 'visibility', r.visible ? 'visible' : 'none');
}
};
if (mapReadyRef.current) sync();
else map.once('load', sync);
return () => { map.off('load', sync); };
}, [rasterOverlays]);
// Synchronize User GIS Layers with MapLibre
useEffect(() => {
if (!mapRef.current) return;
const map = mapRef.current;
const syncUserLayers = () => {
const activeLayerIds = new Set((userLayers || []).map((l) => l.id));
// Remove orphaned user layers
const style = map.getStyle();
if (style && style.layers) {
style.layers.forEach((l: maplibregl.LayerSpecification) => {
if (l.id.startsWith('user-gis-')) {
const match = l.id.match(/^user-gis-(?:fill|line|circle)-(.+)$/);
if (match && !activeLayerIds.has(match[1])) {
if (map.getLayer(l.id)) map.removeLayer(l.id);
}
}
});
}
if (style && style.sources) {
Object.keys(style.sources).forEach((srcId) => {
if (srcId.startsWith('user-gis-source-')) {
const layerId = srcId.replace('user-gis-source-', '');
if (!activeLayerIds.has(layerId)) {
if (map.getSource(srcId)) map.removeSource(srcId);
}
}
});
}
// Add or update active layers
(userLayers || []).forEach((layer) => {
const sourceId = `user-gis-source-${layer.id}`;
const fillId = `user-gis-fill-${layer.id}`;
const lineId = `user-gis-line-${layer.id}`;
const circleId = `user-gis-circle-${layer.id}`;
const existingSource = map.getSource(sourceId) as maplibregl.GeoJSONSource | undefined;
if (!existingSource) {
map.addSource(sourceId, {
type: 'geojson',
data: layer.featureCollection,
});
} else {
existingSource.setData(layer.featureCollection);
}
const visibility = layer.visible ? 'visible' : 'none';
// 1. Polygons
if (layer.geomTypes.some((t) => t.includes('Polygon'))) {
if (!map.getLayer(fillId)) {
map.addLayer(
{
id: fillId,
type: 'fill',
source: sourceId,
filter: ['in', ['geometry-type'], ['literal', ['Polygon', 'MultiPolygon']]],
layout: { visibility },
paint: {
'fill-color': layer.color,
'fill-opacity': layer.opacity * 0.35,
},
},
'links-layer'
);
map.on('click', fillId, (e: maplibregl.MapLayerMouseEvent) => {
if (!e.features || e.features.length === 0) return;
const f = e.features[0];
onSelectTargetRef.current({
type: 'gis-feature',
layerId: layer.id,
featureId: f.id ?? 1,
properties: (f.properties as Record<string, unknown>) || {},
geometryType: f.geometry.type,
});
});
} else {
map.setLayoutProperty(fillId, 'visibility', visibility);
map.setPaintProperty(fillId, 'fill-opacity', layer.opacity * 0.35);
map.setPaintProperty(fillId, 'fill-color', layer.color);
}
}
// 2. Lines & Polygon outlines
if (layer.geomTypes.some((t) => t.includes('Line') || t.includes('Polygon'))) {
if (!map.getLayer(lineId)) {
map.addLayer(
{
id: lineId,
type: 'line',
source: sourceId,
layout: { visibility, 'line-cap': 'round', 'line-join': 'round' },
paint: {
'line-color': layer.color,
'line-width': 2.5,
'line-opacity': layer.opacity,
},
},
'links-layer'
);
map.on('click', lineId, (e: maplibregl.MapLayerMouseEvent) => {
if (!e.features || e.features.length === 0) return;
const f = e.features[0];
onSelectTargetRef.current({
type: 'gis-feature',
layerId: layer.id,
featureId: f.id ?? 1,
properties: (f.properties as Record<string, unknown>) || {},
geometryType: f.geometry.type,
});
});
} else {
map.setLayoutProperty(lineId, 'visibility', visibility);
map.setPaintProperty(lineId, 'line-opacity', layer.opacity);
map.setPaintProperty(lineId, 'line-color', layer.color);
}
}
// 3. Points
if (layer.geomTypes.some((t) => t.includes('Point'))) {
if (!map.getLayer(circleId)) {
map.addLayer(
{
id: circleId,
type: 'circle',
source: sourceId,
filter: ['in', ['geometry-type'], ['literal', ['Point', 'MultiPoint']]],
layout: { visibility },
paint: {
'circle-color': layer.color,
'circle-radius': 5,
'circle-stroke-width': 1.5,
'circle-stroke-color': '#ffffff',
'circle-opacity': layer.opacity,
},
},
'nodes-layer'
);
map.on('click', circleId, (e: maplibregl.MapLayerMouseEvent) => {
if (!e.features || e.features.length === 0) return;
const f = e.features[0];
onSelectTargetRef.current({
type: 'gis-feature',
layerId: layer.id,
featureId: f.id ?? 1,
properties: (f.properties as Record<string, unknown>) || {},
geometryType: f.geometry.type,
});
});
} else {
map.setLayoutProperty(circleId, 'visibility', visibility);
map.setPaintProperty(circleId, 'circle-opacity', layer.opacity);
map.setPaintProperty(circleId, 'circle-color', layer.color);
}
}
});
};
if (mapReadyRef.current) {
syncUserLayers();
} else {
map.once('load', syncUserLayers);
}
}, [userLayers]);
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<div className="map-notice">
{editMode === 'select' && (
<>
<strong style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--s-1)' }}>
<MousePointer2 size={13} aria-hidden="true" />
선택/이동 모드
</strong>
: 클릭 선택, Shift+드래그 박스 선택, 일반 드래그 이동을 지원합니다.
</>
)}
{editMode === 'add-node' && (
<>
<strong style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--s-1)' }}>
<Plus size={13} aria-hidden="true" />
{nodeCreationType === 'RESERVOIR'
? '저수지'
: nodeCreationType === 'TANK'
? '탱크'
: '절점'} 작도 모드
</strong>
: 지도에서 원하는 위치를 클릭하면 선택한 시설이 생성됩니다.
</>
)}
{editMode === 'add-pipe' && (
<>
<strong style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--s-1)' }}>
<Link2 size={13} aria-hidden="true" />
관로 작도 모드
</strong>
:{' '}
{pipeStartNodeId ? (
<span style={{ color: '#2563eb', fontWeight: 'bold' }}>
시작 절점({pipeStartNodeId}) 선택됨 → 연결할 끝 절점을
클릭하세요.
</span>
) : (
'시작 절점을 먼저 클릭하세요.'
)}
</>
)}
{snapEnabled && (
<span
style={{ marginLeft: '8px', color: '#16a34a', fontWeight: 600 }}
>
<Magnet size={12} aria-hidden="true" style={{ display: 'inline', verticalAlign: 'middle', marginRight: '4px' }} />
스냅 활성화됨 {snappedNode && `(절점 ${snappedNode.id}에 스냅됨)`}
</span>
)}
</div>
<div ref={mapContainer} style={{ width: '100%', height: '100%' }} />
<div
ref={boxOverlayRef}
data-testid="map-selection-box"
aria-hidden="true"
style={{
display: 'none',
position: 'absolute',
zIndex: 25,
pointerEvents: 'none',
border: '1px solid #2563eb',
background: 'rgba(37, 99, 235, 0.14)',
}}
/>
{/* Legend (Q4) */}
<Legend scales={legendScales} note={legendNote} onEdit={onLegendOverride ? setEditingLegend : undefined} />
{editingLegend && onLegendOverride && (() => {
const current = legendScales.find((s) => s.title === editingLegend);
if (!current) return null;
const paint = [nodePaint, linkPaint].find((p) => typeof p !== 'string' && p.scale.title === editingLegend);
const isNode = typeof nodePaint !== 'string' && nodePaint.scale.title === editingLegend;
const prop = typeof paint === 'string' || !paint ? null : paint.prop;
const features = isNode ? geojson?.nodes.features : geojson?.links.features;
const values = prop && features ? features.map((f) => Number((f.properties as Record<string, unknown>)?.[prop])).filter(Number.isFinite) : [];
return (
<LegendEditor
scale={current}
defaultScale={defaultScales[editingLegend] ?? current}
values={values}
onSave={(ov) => onLegendOverride(editingLegend, ov)}
onClose={() => setEditingLegend(null)}
/>
);
})()}
{hovered && (
<div className="map-hover" ref={hoverBoxRef}>
<div
style={{
fontWeight: 'bold',
borderBottom: '1px solid #e2e8f0',
paddingBottom: '4px',
marginBottom: '4px',
color: '#0f172a',
}}
>
{hovered.type === 'node' ? '절점(Node)' : '관로(Link)'} {hovered.id}
</div>
{Object.entries(hovered.props)
.filter(([k]) => k !== 'id' && k !== 'selected')
.map(([k, v]) => (
<div
key={k}
style={{
display: 'flex',
justifyContent: 'space-between',
gap: '16px',
fontSize: '11px',
}}
>
<span style={{ color: '#64748b' }}>
{mapPropertyLabels[k] || k}:
</span>
<span style={{ fontFamily: 'monospace', fontWeight: 500 }}>
{typeof v === 'number' ? v.toFixed(2) : String(v)}
</span>
</div>
))}
</div>
)}
</div>
);
};