- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
2630 lines
101 KiB
TypeScript
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>
|
|
);
|
|
};
|