import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createProgressCircle } from "@ui/ui_template_progress"; import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { fetchGisGeoJson, fetchPlannedRoute, fetchVWorldMeta, getVWorldMapUrl, type VWorldMeta, } from "./B04_PreProcess_Api_Fetch"; import { niceScaleDistance } from "./B04_PreProcess_UI_Camera"; import { createDetailBasinOverlay } from "./B04_PreProcess_UI_Basins"; import { BACKGROUND_DEFAULT_ON, BACKGROUND_LAYERS, CLICK_SLOP_PX, CONTOUR_LABEL_DEFAULT_ON, CONTOUR_LABEL_KEYS, GIS_DEFAULT_ON, GIS_LAYERS, gisLayerColor, type BackgroundLayer, type GisLayer, } from "./B04_PreProcess_UI_MapLayers"; import { createFlowStrengthOverlay } from "./B04_PreProcess_UI_FlowStrength"; import { createWatershedOverlay } from "./B04_PreProcess_UI_Watershed"; import { computeMapRect, computeRouteView, createNormalizer, drawPreparedLabels, drawPreparedLayer, prepareLayer, prepareMetricPolyline, routeLineColor, ROUTE_LINE_WIDTH, type GeoJsonCollection, type MapRect, type Normalizer, type PlanBounds, type PreparedLayer, type ViewState, } from "./B04_PreProcess_UI_MapRender"; import type { WatershedAnalysis } from "./B04_PreProcess_Api_Fetch"; export interface SurfaceMapViewer { root: HTMLElement; /** routeBounds: 계획노선 평면 범위 — 초기 화면을 도로 중심으로 맞추는 데 쓴다. */ render: (projectId: string, routeBounds?: PlanBounds | null) => void; /** 관 매설 지점·세부유역을 영구저장한다. 저장한 관 개수를 돌려준다(모델 확정과 함께 호출). */ commitDrainage: () => Promise; dispose: () => void; } function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } export function createSurfaceMapViewer(): SurfaceMapViewer { const root = document.createElement("section"); root.className = "b04-map"; const header = document.createElement("div"); header.className = "b04-map__header"; const title = document.createElement("h3"); title.textContent = L("B04_Surface_Map_Title"); const controls = document.createElement("div"); controls.className = "b04-map__controls"; const backgroundGroup = document.createElement("div"); backgroundGroup.className = "b04-map__control-group"; const backgroundTitle = document.createElement("span"); backgroundTitle.textContent = L("B04_Surface_Map_Background"); const backgroundButtons = document.createElement("div"); backgroundButtons.className = "b04-map__layer-buttons"; backgroundGroup.append(backgroundTitle, backgroundButtons); const gisGroup = document.createElement("div"); gisGroup.className = "b04-map__control-group"; const gisTitle = document.createElement("span"); gisTitle.textContent = L("B04_Surface_Map_GisLayer"); const gisButtons = document.createElement("div"); gisButtons.className = "b04-map__layer-buttons"; gisGroup.append(gisTitle, gisButtons); const resetButton = document.createElement("button"); resetButton.type = "button"; resetButton.textContent = L("B04_Surface_Map_Reset"); controls.append(backgroundGroup, gisGroup, resetButton); header.append(title, controls); const viewport = document.createElement("div"); viewport.className = "b04-map__viewport"; const backgroundImages = new Map(); BACKGROUND_LAYERS.forEach((layer) => { const image = document.createElement("img"); image.className = "b04-map__image"; image.alt = L("B04_Surface_Map_ImageAlt"); image.draggable = false; backgroundImages.set(layer, image); }); const canvas = document.createElement("canvas"); canvas.className = "b04-map__canvas"; const empty = document.createElement("p"); empty.className = "b04-map__empty"; empty.textContent = L("B04_Surface_Map_Empty"); const status = document.createElement("span"); status.className = "b04-map__status"; // 지도 위 좌상단 묶음 — 배수유역 정보 자리다(2026-08-01 사용자 지시). // 배경지도가 복잡해 글자가 묻히므로 각 문구에 배경 칩을 깐다. const statusStack = document.createElement("div"); statusStack.className = "b04-map__status-stack"; // 흐름 강도 선택 요약 — 배수유역 상태 줄과 칸을 나눠 쓰면 서로 덮어쓴다. const flowStatus = document.createElement("span"); flowStatus.className = "b04-map__watershed-status"; flowStatus.hidden = true; // 객체 표시(지형지물 개수) 라벨은 우측 최하단으로 내린다 — 좌상단을 배수유역에 내주기 위함. const statusCorner = document.createElement("div"); statusCorner.className = "b04-map__status-corner"; statusCorner.append(status); // 우측 최상단 — 배수유역 "분석 중…" 진행 문구 자리(2026-08-01 사용자 지시). // 좌상단 결과 줄을 지우지 않도록 자리를 따로 준다. const statusTopRight = document.createElement("div"); statusTopRight.className = "b04-map__status-topright"; const scaleBar = document.createElement("div"); scaleBar.className = "b04-map__scale"; const scaleText = document.createElement("span"); scaleBar.append(scaleText); // 지도 정중앙 로딩 서클 — 도엽 레이어가 10종이라 다 받을 때까지 화면이 비어 보인다. const progress = createProgressCircle({ overlay: true }); progress.root.hidden = true; viewport.append( ...BACKGROUND_LAYERS.map((layer) => backgroundImages.get(layer)!), canvas, empty, statusStack, statusTopRight, statusCorner, scaleBar, progress.root, ); /** 진행률(0~1, 모르면 null)과 문구. label이 null이면 서클을 감춘다. */ function showProgress(ratio: number | null, label: string | null): void { progress.root.hidden = label === null; if (label !== null) progress.set(ratio, label); } root.append(header, viewport); let currentProjectId: string | null = null; let meta: VWorldMeta | null = null; // 초기 화면 기준이 되는 계획노선 범위(B03 CSV). 없으면 배경 전체를 보여준다. let routeBounds: PlanBounds | null = null; // 배수유역 오버레이가 lon/lat을 화면 좌표로 옮길 때 쓴다. 레이어 로드 시 1회 만든다. let normalizer: Normalizer | null = null; // 사전 투영된 렌더용 레이어. 원본 GeoJSON은 변형하지 않으며 투영 후에는 참조를 잡아두지 않는다. const preparedLayers = new Map(); const activeBackgrounds = new Set( BACKGROUND_LAYERS.filter((layer) => BACKGROUND_DEFAULT_ON[layer]), ); const activeGisLayers = new Set(GIS_LAYERS.filter((layer) => GIS_DEFAULT_ON[layer])); let showContourLabels = CONTOUR_LABEL_DEFAULT_ON; // 계획선(B03 업로드 계획노선) — 사업지 좌표계(m) 폴리라인을 배경 지도 위에 겹친다. let routeLayer: PreparedLayer | null = null; let showRoute = true; let scale = 1; let offsetX = 0; let offsetY = 0; let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; /** 좌버튼을 누른 자리. 뗄 때까지 이만큼 이하로 움직였으면 클릭으로 본다. */ let clickStart: { x: number; y: number } | null = null; let loadSequence = 0; // rAF 스로틀: 팬/줌 이벤트는 상태만 갱신하고 프레임당 1회만 드로잉한다. // LOD(MapRender) 덕에 프레임 렌더 비용이 낮아 매 프레임 직접 렌더가 항상 완전한 화면을 보장한다. let frameHandle = 0; // 캔버스 버퍼는 크기가 실제로 변할 때만 재할당한다(재할당 시 내용이 지워지므로 매 프레임 금지). let canvasWidth = 0; let canvasHeight = 0; let canvasDpr = 0; function makeLayerButton( label: string, activeLayers: Set, layer: T, color?: string, ): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; const initialActive = activeLayers.has(layer); button.className = "b04-map__layer-button" + (initialActive ? " is-active" : ""); button.textContent = label; button.setAttribute("aria-pressed", String(initialActive)); if (color) { button.classList.add("b04-map__layer-button--gis"); button.style.setProperty("--b04-layer-color", color); } button.addEventListener("click", () => { if (activeLayers.has(layer)) activeLayers.delete(layer); else activeLayers.add(layer); const isActive = activeLayers.has(layer); button.classList.toggle("is-active", isActive); button.setAttribute("aria-pressed", String(isActive)); syncLayerVisibility(); }); return button; } const backgroundLabels: Record = { white: L("B04_Surface_Map_White"), satellite: L("B04_Surface_Map_Satellite"), hybrid: L("B04_Surface_Map_Hybrid"), }; BACKGROUND_LAYERS.forEach((layer) => { backgroundButtons.append(makeLayerButton(backgroundLabels[layer], activeBackgrounds, layer)); }); const gisLabels: Record = { 지적도: L("B04_Surface_Map_Cadastral"), 행정구역_시군구: L("B04_Surface_Map_Sigungu"), 행정구역_읍면동: L("B04_Surface_Map_Eupmyeondong"), 등고선: L("B04_Surface_Map_Contour"), 도엽_등고선: L("B04_Surface_Map_SheetContour"), 도엽_하천중심선: L("B04_Surface_Map_SheetStream"), 도엽_표고점: L("B04_Surface_Map_SheetElevPoint"), 도엽_성절토: L("B04_Surface_Map_SheetCutFill"), 도엽_옹벽석축: L("B04_Surface_Map_SheetWall"), }; // 계획선 토글 — 도엽 레이어와 같은 양식으로 레이어 줄 맨 앞에 둔다. 색은 B05 배수유역도와 // 같은 주황(정의처: MapRender). 계획노선을 아직 올리지 않았으면 버튼이 눌리지 않는다. const routeButton = document.createElement("button"); routeButton.type = "button"; routeButton.className = "b04-map__layer-button b04-map__layer-button--gis is-active"; routeButton.textContent = L("B04_Surface_Map_PlannedRoute"); routeButton.style.setProperty("--b04-layer-color", routeLineColor()); routeButton.setAttribute("aria-pressed", "true"); routeButton.addEventListener("click", () => { showRoute = !showRoute; routeButton.classList.toggle("is-active", showRoute); routeButton.setAttribute("aria-pressed", String(showRoute)); scheduleDraw(); }); gisButtons.append(routeButton); GIS_LAYERS.forEach((layer) => { gisButtons.append( makeLayerButton(gisLabels[layer], activeGisLayers, layer, gisLayerColor(layer)), ); }); // 등고 라벨 보기/숨기기 (등고선·도엽 등고선의 계곡선 수치 표기) const contourLabelButton = document.createElement("button"); contourLabelButton.type = "button"; contourLabelButton.className = "b04-map__layer-button" + (CONTOUR_LABEL_DEFAULT_ON ? " is-active" : ""); contourLabelButton.textContent = L("B04_Surface_Map_ContourLabel"); contourLabelButton.setAttribute("aria-pressed", String(CONTOUR_LABEL_DEFAULT_ON)); contourLabelButton.addEventListener("click", () => { showContourLabels = !showContourLabels; contourLabelButton.classList.toggle("is-active", showContourLabels); contourLabelButton.setAttribute("aria-pressed", String(showContourLabels)); scheduleDraw(); }); gisButtons.append(contourLabelButton); // 배수유역 분석 오버레이 — 계산은 백엔드가 하고 여기서는 겹쳐 그리기만 한다. // 상태 문구는 오버레이 전용 줄에 쓴다 — 지도 자체 상태(레이어 로딩)와 같은 칸을 쓰면 // 나중에 끝난 쪽이 상대 문구를 지워 버린다. // 분석이 끝나면 강도 곡선·유입 집중점을 흐름 강도 오버레이로 넘긴다. // 이 콜백은 갈래 토글을 누를 때도 불리므로, **분석 결과가 실제로 바뀐 경우에만** 넘긴다. // 매번 넘기면 고른 집중점과 유입 외곽선이 버튼 한 번에 소리 없이 지워진다. let lastAnalysis: WatershedAnalysis | null = null; const watershed = createWatershedOverlay(() => { const analysis = watershed.analysis(); if (analysis !== lastAnalysis) { lastAnalysis = analysis; if (analysis) flowStrength.setData(analysis.strength_profile, analysis.inflow_hotspots ?? []); else flowStrength.clear(); } scheduleDraw(); }); const watershedGroup = document.createElement("div"); watershedGroup.className = "b04-map__control-group"; const watershedTitle = document.createElement("span"); watershedTitle.textContent = "배수유역"; const watershedButtons = document.createElement("div"); watershedButtons.className = "b04-map__layer-buttons"; // 도로 유입 흐름 강도 — 배수유역 분석 결과를 받아 계획선 위에 색·마커로 얹는다. const flowStrength = createFlowStrengthOverlay(() => { flowStatus.textContent = flowStrength.status(); flowStatus.hidden = flowStatus.textContent === ""; scheduleDraw(); }); // 상세 배수유역 — 관 매설 지점 편집과 세부유역 분할. 계산은 버튼을 눌렀을 때만 돈다. const detailBasins = createDetailBasinOverlay(() => { scheduleDraw(); }); watershedButtons.append( watershed.button, ...watershed.partButtons, flowStrength.button, flowStrength.markerButton, detailBasins.button, ...detailBasins.partButtons, ); watershedGroup.append(watershedTitle, watershedButtons); controls.insertBefore(watershedGroup, resetButton); // 안내·결과 문구는 컨트롤 줄이 아니라 지도 위에 얹는다 — 컨트롤 영역 세로 공간을 먹지 않는다. statusStack.append(watershed.statusElement, flowStatus, detailBasins.statusElement); statusTopRight.append(watershed.busyElement); // 우클릭 메뉴는 뷰포트 기준 절대 위치라 뷰포트 안에 넣는다. // 강도 색띠 범례도 같은 자리에 얹는다(CSS가 우측 세로로 세운다). viewport.append(detailBasins.menuElement, flowStrength.legendElement); function updateImageTransform(): void { backgroundImages.forEach((image) => { image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; }); } function syncLayerVisibility(): void { backgroundImages.forEach((image, layer) => { image.hidden = !activeBackgrounds.has(layer); }); empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0; scheduleDraw(); } /** 계획도로가 화면 중앙에 오고 도로 전체 + 여유 200m가 보이도록 맞춘다(B05와 같은 규칙). * * 라이다 범위에 맞추던 것을 도로 기준으로 바꿨다 — 3D(라이다)와 2D(지도)는 다루는 범위가 * 달라, 라이다에 맞추면 배경을 넓게 받아도 보이는 범위가 늘 같았다(2026-08-01 사용자 지시). * 계획노선이 없으면 배경 전체를 그대로 보여준다. */ function fitRouteView(): void { const rect = viewport.getBoundingClientRect(); const view = computeRouteView( meta, routeBounds, Math.max(rect.width, 1), Math.max(rect.height, 1), ); scale = view.scale; offsetX = view.offsetX; offsetY = view.offsetY; } function resetView(): void { fitRouteView(); updateImageTransform(); scheduleDraw(); } function drawScaleBar(mapRect: MapRect): void { if (!meta || mapRect.width <= 0) { scaleBar.hidden = true; return; } const metersPerPixel = meta.width_meters / mapRect.width / scale; const meters = niceScaleDistance(100 * metersPerPixel); const pixels = meters / metersPerPixel; scaleBar.hidden = false; scaleBar.style.width = `${pixels}px`; scaleText.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`; } // 등고선(전국 gpkg·도엽)은 선 수가 많아 가장 아래에 얇게 깔아 다른 레이어 판독을 방해하지 않게 한다. const isContourLayer = (layer: GisLayer): boolean => layer === "등고선" || layer === "도엽_등고선"; const DRAW_ORDER = [...GIS_LAYERS].sort((a, b) => isContourLayer(a) ? -1 : isContourLayer(b) ? 1 : 0, ); function drawVectorLayer(): void { const rect = viewport.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width)); const height = Math.max(1, Math.floor(rect.height)); const dpr = window.devicePixelRatio || 1; // 버퍼 재할당은 캔버스 내용을 지우므로 크기가 실제로 변할 때만 수행한다. if (width !== canvasWidth || height !== canvasHeight || dpr !== canvasDpr) { canvasWidth = width; canvasHeight = height; canvasDpr = dpr; canvas.width = Math.floor(width * dpr); canvas.height = Math.floor(height * dpr); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; } const context = canvas.getContext("2d"); if (!context) return; context.setTransform(dpr, 0, 0, dpr, 0, 0); context.clearRect(0, 0, width, height); const mapRect = computeMapRect(meta, width, height); const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect }; DRAW_ORDER.forEach((layer) => { if (!activeGisLayers.has(layer)) return; const prepared = preparedLayers.get(layer); if (!prepared) return; context.lineWidth = isContourLayer(layer) ? 0.7 : 1.5; context.strokeStyle = gisLayerColor(layer); drawPreparedLayer(context, prepared, view, layer === "도엽_표고점" ? "x" : "dot"); }); if (showContourLabels) { context.font = "600 13px sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; (Object.keys(CONTOUR_LABEL_KEYS) as GisLayer[]).forEach((layer) => { if (!activeGisLayers.has(layer)) return; const prepared = preparedLayers.get(layer); if (prepared) drawPreparedLabels(context, prepared, view, gisLayerColor(layer)); }); } // 배수유역 오버레이는 GIS 레이어 위에 얹는다 — 격자·화살표가 등고선을 덮어야 읽힌다. if (normalizer) watershed.draw(context, normalizer, view); // 계획선은 맨 위에 둔다 — 다른 레이어에 덮이면 노선이 어디로 지나는지 읽을 수 없다. if (showRoute && routeLayer) { context.lineWidth = ROUTE_LINE_WIDTH; context.strokeStyle = routeLineColor(); drawPreparedLayer(context, routeLayer, view, "dot"); } // 흐름 강도(도로 색·유입 집중점 마커)는 계획선 위에 얹는다. flowStrength.draw(context, normalizer, view); // 세부유역 채움과 관 마커는 그 위 — 편집 대상이라 다른 레이어에 가려지면 집을 수 없다. detailBasins.draw(context, normalizer, view); // 유입 집중점(집중유역)은 맨 마지막 — 켰을 때 무엇에도 가리지 않아야 고를 수 있다. flowStrength.drawTop(context, normalizer, view); updateImageTransform(); drawScaleBar(mapRect); } /** 팬/줌 등 연속 이벤트에서는 프레임당 1회만 실제 드로잉이 일어나게 한다. */ function scheduleDraw(): void { if (frameHandle) return; frameHandle = window.requestAnimationFrame(() => { frameHandle = 0; drawVectorLayer(); }); } async function loadLayers(): Promise { if (!currentProjectId) return; const projectId = currentProjectId; const sequence = ++loadSequence; backgroundImages.forEach((image) => image.removeAttribute("src")); meta = null; preparedLayers.clear(); routeLayer = null; resetView(); status.textContent = L("B04_Surface_Map_Loading"); showProgress(0, L("B04_Surface_Map_Loading")); try { // 계획선은 지도 메타와 같은 좌표계라 함께 받아 둔다. 실패해도 지도는 그대로 그린다. const [nextMeta, planned] = await Promise.all([ fetchVWorldMeta(projectId, "satellite"), fetchPlannedRoute(projectId).catch(() => ({ status: "error", points: [] })), ]); // 레이어가 끝나는 대로 진행률을 올린다 — 10종을 다 받을 때까지 화면이 비어 있어서다. let done = 0; const loadedLayers = await Promise.all( GIS_LAYERS.map(async (layer) => { try { // 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다. const data = ( layer.startsWith("도엽_") ? await fetchCachedSheetLayer(projectId, layer) : await fetchGisGeoJson(projectId, layer) ) as GeoJsonCollection; return [layer, data] as const; } catch { return [layer, null] as const; } finally { done += 1; if (sequence === loadSequence) { showProgress(done / GIS_LAYERS.length, `도엽 레이어 ${done}/${GIS_LAYERS.length}`); } } }), ); if (sequence !== loadSequence) return; meta = nextMeta; // 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다. normalizer = createNormalizer(nextMeta); routeLayer = planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null; // 흐름 강도는 계획선 위에 칠하므로 같은 점 목록·같은 메타를 쓴다(어긋나면 색이 밀린다). flowStrength.setRoute(planned.points, nextMeta); // 관 마커도 같은 계획선 위에 스냅한다 — 목록이 다르면 마커가 노선을 벗어난다. detailBasins.setRoute(planned.points, nextMeta); // 계획노선을 아직 올리지 않은 프로젝트에서는 켤 것이 없으니 버튼을 잠근다. routeButton.disabled = routeLayer === null; routeButton.title = routeLayer === null ? L("B04_Surface_Map_PlannedRouteEmpty") : ""; let featureCount = 0; loadedLayers.forEach(([layer, data]) => { if (!data) return; featureCount += data.features?.length ?? 0; preparedLayers.set(layer, prepareLayer(data, normalizer!, CONTOUR_LABEL_KEYS[layer])); }); BACKGROUND_LAYERS.forEach((layer) => { // 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다. backgroundImages.get(layer)!.src = getVWorldMapUrl(projectId, layer); }); status.textContent = L("B04_Surface_Map_Features").replace( "{count}", featureCount.toLocaleString(), ); resetView(); syncLayerVisibility(); showProgress(null, null); } catch (error) { if (sequence !== loadSequence) return; status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed"); showProgress(null, null); } } resetButton.addEventListener("click", resetView); viewport.addEventListener( "wheel", (event) => { event.preventDefault(); const prevScale = scale; // 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). 일반 스크롤과 반대 방향이다. scale = Math.min(8, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87))); // 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정. // screen = center + (base - center)·scale + offset 이므로, // 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale). const ratio = scale / prevScale; const rect = viewport.getBoundingClientRect(); const cursorX = event.clientX - rect.left - rect.width / 2; const cursorY = event.clientY - rect.top - rect.height / 2; offsetX = cursorX * (1 - ratio) + offsetX * ratio; offsetY = cursorY * (1 - ratio) + offsetY * ratio; scheduleDraw(); }, { passive: false }, ); /** 현재 화면 상태(팬·줌·지도 사각형). 마커 히트 판정과 그리기가 같은 값을 봐야 한다. */ function currentView(rect: DOMRect): ViewState { const width = Math.max(1, Math.floor(rect.width)); const height = Math.max(1, Math.floor(rect.height)); return { width, height, scale, offsetX, offsetY, mapRect: computeMapRect(meta, width, height) }; } /** 이벤트가 우클릭 메뉴 안에서 났는지. 메뉴는 뷰포트의 자식이라 지도 조작과 섞인다. */ function inContextMenu(event: Event): boolean { const target = event.target; return target instanceof Node && detailBasins.menuElement.contains(target); } viewport.addEventListener("contextmenu", (event) => { if (inContextMenu(event)) { // 메뉴 위에서 다시 우클릭하면 브라우저 메뉴만 막고 메뉴는 그대로 둔다. event.preventDefault(); return; } const rect = viewport.getBoundingClientRect(); const opened = detailBasins.handleContextMenu( currentView(rect), event.clientX - rect.left, event.clientY - rect.top, ); // 계획선이나 관 마커 위에서만 전용 메뉴를 띄운다 — 그 밖은 브라우저 메뉴를 그대로 둔다. if (opened) event.preventDefault(); }); viewport.addEventListener("pointerdown", (event) => { // 우클릭 메뉴 위에서 누른 것은 지도 조작이 아니다. 여기서 걸러 내지 않으면 메뉴를 // 닫는 처리가 먼저 돌아 항목의 click이 영영 발생하지 않는다(추가·삭제가 안 되던 원인). if (inContextMenu(event)) return; // 중간 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹쳐 페이지 전체를 // 흔들므로 기본 동작을 차단하고 지도 팬으로만 사용한다. if (event.button === 1) event.preventDefault(); // 관 마커를 잡았으면 끌기로 넘어간다 — 지도 팬도, 마커 선택도 하지 않는다. if (event.button === 0) { const rect = viewport.getBoundingClientRect(); if ( detailBasins.handlePointerDown( currentView(rect), event.clientX - rect.left, event.clientY - rect.top, ) ) { clickStart = null; viewport.setPointerCapture(event.pointerId); return; } } // 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 화면 위 객체를 고르는 데만 쓴다 // (좌버튼 드래그가 팬까지 겸하면 객체를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시). // 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다. // 누른 자리를 기억해 두었다가 pointerup에서 "끌지 않고 눌렀다 뗐다"면 클릭으로 처리한다. // 터치·펜은 팬도 겸하므로 여기서 반환하지 않고 기록만 남긴다 — 그래야 손가락으로도 마커를 // 고를 수 있다(끌었으면 이동량 판정에서 걸러진다). if (event.button === 0) clickStart = { x: event.clientX, y: event.clientY }; if (event.pointerType === "mouse" && event.button !== 1) { // 마우스 좌버튼은 선택 전용 — 팬을 시작하지 않는다. return; } dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY }; viewport.style.cursor = "grabbing"; viewport.setPointerCapture(event.pointerId); }); viewport.addEventListener("pointerup", (event) => { if (inContextMenu(event)) return; // 관 마커를 끌던 중이었으면 그것으로 끝낸다 — 집중점 선택까지 겹쳐 일어나면 안 된다. if (detailBasins.handlePointerUp()) return; const start = clickStart; clickStart = null; if (!start || event.button !== 0) return; if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > CLICK_SLOP_PX) return; const rect = viewport.getBoundingClientRect(); const view = currentView(rect); const localX = event.clientX - rect.left; const localY = event.clientY - rect.top; // 세부유역을 먼저 본다 — 유역 채움이 화면을 넓게 덮으므로 집중점 선택보다 앞선다. if (detailBasins.handleClick(view, localX, localY)) return; flowStrength.handleClick(normalizer, view, localX, localY); }); viewport.addEventListener("pointermove", (event) => { const rect = viewport.getBoundingClientRect(); if ( detailBasins.handlePointerMove( currentView(rect), event.clientX - rect.left, event.clientY - rect.top, ) ) { return; } if (!dragStart) return; offsetX = dragStart.offsetX + event.clientX - dragStart.x; offsetY = dragStart.offsetY + event.clientY - dragStart.y; scheduleDraw(); }); const stopDragging = (): void => { dragStart = null; viewport.style.removeProperty("cursor"); }; viewport.addEventListener("pointerup", stopDragging); viewport.addEventListener("pointercancel", stopDragging); const resizeObserver = new ResizeObserver(scheduleDraw); resizeObserver.observe(viewport); return { root, // 초기 화면은 계획노선 기준이다(라이다 범위는 쓰지 않는다). render(projectId, nextRouteBounds) { currentProjectId = projectId; routeBounds = nextRouteBounds ?? null; watershed.reset(); watershed.setProject(projectId); flowStrength.clear(); flowStrength.setProject(projectId); detailBasins.clear(); detailBasins.setProject(projectId); void loadLayers(); }, /** 모델 확정과 함께 관 매설 지점·세부유역을 영구저장한다. */ commitDrainage: () => detailBasins.commit(), dispose() { loadSequence += 1; if (frameHandle) { window.cancelAnimationFrame(frameHandle); frameHandle = 0; } resizeObserver.disconnect(); flowStrength.dispose(); detailBasins.dispose(); }, }; }