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 = { 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; 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; layerLabels?: Record; /** 범례 설정 per scale title (breaks/colours/size by value). */ legendOverrides?: Record; /** 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 = ({ 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 = {}; 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(null); const mapPropertyLabels: Record = { ...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(null); const mapRef = useRef(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; } | null>(null); const [pipeStartNodeId, setPipeStartNodeId] = useState(null); const [snappedNode, setSnappedNode] = useState<{ id: string; lngLat: [number, number]; } | null>(null); const snappedNodeRef = useRef(snappedNode); snappedNodeRef.current = snappedNode; const editModeRef = useRef(editMode); editModeRef.current = editMode; const crsRef = useRef(crs); crsRef.current = crs; const snapEnabledRef = useRef(snapEnabled); snapEnabledRef.current = snapEnabled; const pipeStartNodeIdRef = useRef(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(null); const boxSelectionRef = useRef<{ start: ScreenPoint; current: ScreenPoint; active: boolean; additive: boolean; } | null>(null); const hoverRef = useRef(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(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(null); const geoJsonUpdateFrameRef = useRef(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({ minX: 0, minY: 0, maxX: 100, maxY: 100, }); const latestGeojsonRef = useRef<{ nodes: FeatureCollection; links: FeatureCollection; arrows: FeatureCollection; } | 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 = {}; 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> = 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(); 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> = []; const arrowFeatures: Array> = []; 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, links: { type: 'FeatureCollection', features: linkFeatures, } satisfies FeatureCollection, arrows: { type: 'FeatureCollection', features: arrowFeatures, } satisfies FeatureCollection, }; }, [ 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 } | 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; 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; 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, additive: boolean ) => { if (!additive || target.type === 'gis-feature') { onMultiSelectChangeRef.current?.(() => ({ nodes: new Set(), links: new Set(), gisFeatures: [], })); onSelectTargetRef.current(target); return; } const current = multiSelectionRef.current ?? { nodes: new Set(), links: new Set(), 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(), links: new Set(), 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) || {}; 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(), links: new Set(), gisFeatures: [], }; const next: MultiSelectionState = { nodes: boxSelection.additive ? new Set(current.nodes) : new Set(), links: boxSelection.additive ? new Set(current.links) : new Set(), 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> = []; 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(); for (const f of geojson?.nodes.features ?? []) nodeAt.set(String(f.properties?.id), f.geometry.coordinates as [number, number]); const linkAt = new Map(); 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> = []; 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) || {}, 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) || {}, 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) || {}, 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 (
{editMode === 'select' && ( <> : 클릭 선택, Shift+드래그 박스 선택, 일반 드래그 이동을 지원합니다. )} {editMode === 'add-node' && ( <> : 지도에서 원하는 위치를 클릭하면 선택한 시설이 생성됩니다. )} {editMode === 'add-pipe' && ( <> :{' '} {pipeStartNodeId ? ( 시작 절점({pipeStartNodeId}) 선택됨 → 연결할 끝 절점을 클릭하세요. ) : ( '시작 절점을 먼저 클릭하세요.' )} )} {snapEnabled && ( )}
); };