/* ============================================================================= * 상세 배수유역 오버레이 (B04 2D 지도 — 관리자 검토용) * * 계획선 위 **관 매설 지점**을 편집하고, 그 관마다 물이 모이는 **세부유역**을 겹쳐 그린다. * * · 기본 관 = 도로 × 상류 세류선 교차점 (백엔드 분석 산출물) * · 자동 보충 = 관 최대 간격을 넘는 구간에 최소 개수로 채운 자리 * · 수동 = 계획선 위에서 우클릭해 넣었거나, 끌어서 옮긴 관 * * 경계는 여기서 긋지 않는다. 백엔드가 1m 격자 셀마다 "이 셀 물이 어느 도로 셀로 가는가"를 * 이미 풀어 두었고, 도로 셀은 종단 내리막을 따라 담당 관으로 묶인다. 그래서 같은 관으로 * 묶인 셀 덩어리의 바깥선이 곧 세부유역 경계다 — 화면은 그 폴리곤을 받아 칠하기만 한다. * * 재계산은 **버튼을 눌렀을 때만** 돈다(2026-08-01 사용자 지시). 편집 중에는 마커만 움직이고, * 확정 전에 화면을 떠나면 저장된 값으로 되돌아온다. * ========================================================================== */ import { pointInRings } from "./B04_PreProcess_UI_MapOverlays"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { themeColor } from "@ui/ui_template_palette"; import { computeDetailBasins, fetchDetailPipePoints, saveDetailPipePoints, type DetailBasin, type PipeSource, type VWorldMeta, } from "./B04_PreProcess_Api_Fetch"; import { haloColor, lonLatToScreen, metricToScreen, type Normalizer, type ViewState, } from "./B04_PreProcess_UI_MapRender"; import { nearestChainage, pointAtChainage, resampleRoute, type RoutePoint, } from "./B04_PreProcess_UI_RouteSamples"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /** 관 마커 반지름(px)과 잡기 여유(px). 유입 집중점 마커보다 조금 크게 둬서 구분된다. */ const PIPE_RADIUS = 8; const PIPE_HIT_SLACK = 5; /** 계획선에서 이보다 멀면 "노선 위 우클릭"으로 보지 않는다(px). */ const ROUTE_HIT_PX = 14; /** 관 생성 사유별 색. 정의처는 `ui_template_theme.css`. */ const PIPE_COLORS: Record = { stream: ["--map-pipe-marker", "#f97316"], spacing: ["--map-pipe-auto", "#0ea5e9"], user: ["--map-pipe-user", "#16a34a"], }; /** 세부유역 채움 — 이웃끼리 구분만 되면 되므로 색상환을 균등 분할해 돌려 쓴다. */ function basinColor(index: number, alpha: number): string { const hue = (index * 137.508) % 360; // 황금각 — 인접 유역이 비슷한 색으로 붙지 않는다 return `hsla(${hue.toFixed(0)}, 70%, 55%, ${alpha})`; } /** 유역 번호 서클 반지름(px). 관 마커(8)보다 커야 둘이 겹쳐도 구분된다. */ const BASIN_NUMBER_RADIUS = 12; /** 유역 채움 투명도 — 아무것도 안 골랐을 때 / 고른 것 / 고르지 않은 나머지. */ const BASIN_ALPHA_PLAIN = 0.22; const BASIN_ALPHA_SELECTED = 0.38; const BASIN_ALPHA_MUTED = 0.06; interface PipeMarker { chainage: number; source: PipeSource; } /** 면적을 사람이 읽는 문구로. 1ha 이상은 ha로 줄인다. */ function formatArea(areaM2: number): string { return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`; } /** 폴리곤 면적 중심(화면 px). 면적이 0에 가까우면 정점 평균으로 물러난다. */ function ringCentroid(ring: ReadonlyArray<[number, number]>): [number, number] { let twiceArea = 0; let cx = 0; let cy = 0; for (let index = 0; index < ring.length; index += 1) { const [x0, y0] = ring[index]; const [x1, y1] = ring[(index + 1) % ring.length]; const cross = x0 * y1 - x1 * y0; twiceArea += cross; cx += (x0 + x1) * cross; cy += (y0 + y1) * cross; } if (Math.abs(twiceArea) < 1e-6) { const sum = ring.reduce((acc, [x, y]) => [acc[0] + x, acc[1] + y] as [number, number], [0, 0]); return [sum[0] / ring.length, sum[1] / ring.length]; } return [cx / (3 * twiceArea), cy / (3 * twiceArea)]; } /** 유역 번호를 서클 숫자로 얹는다. 번호는 시점에서 종점 순(백엔드가 누가거리 순으로 준다). */ function drawBasinNumbers( context: CanvasRenderingContext2D, labels: ReadonlyArray<{ index: number; number: number; point: [number, number] }>, ): void { if (labels.length === 0) return; context.save(); context.textAlign = "center"; context.textBaseline = "middle"; context.font = "bold 13px sans-serif"; labels.forEach(({ index, number, point: [x, y] }) => { context.beginPath(); context.arc(x, y, BASIN_NUMBER_RADIUS, 0, Math.PI * 2); context.fillStyle = basinColor(index, 0.92); context.fill(); context.lineWidth = 2; context.strokeStyle = haloColor(); context.stroke(); context.fillStyle = haloColor(); context.fillText(String(number), x, y); }); context.restore(); } export interface DetailBasinOverlay { /** 세부유역 재계산 버튼. */ button: HTMLButtonElement; /** 표시 토글 — 관 마커와 세부유역을 함께 켜고 끈다(하나로 묶임). */ partButtons: HTMLButtonElement[]; statusElement: HTMLElement; /** 우클릭 메뉴. 지도 뷰포트에 얹는다(뷰포트 기준 절대 위치). */ menuElement: HTMLElement; setProject: (projectId: string) => void; /** 계획선(사업지 좌표계 m)과 배경지도 메타. 둘이 있어야 화면 좌표를 낼 수 있다. */ setRoute: (points: ReadonlyArray, meta: VWorldMeta | null) => void; clear: () => void; /** 마커를 잡았으면 true(지도 팬 대신 마커 끌기로 넘어간다). */ handlePointerDown: (view: ViewState, x: number, y: number) => boolean; handlePointerMove: (view: ViewState, x: number, y: number) => boolean; handlePointerUp: () => boolean; /** 우클릭 메뉴를 띄웠으면 true. */ handleContextMenu: (view: ViewState, x: number, y: number) => boolean; /** 세부유역을 고르거나 골랐던 것을 풀었으면 true(다른 오버레이가 같은 클릭을 먹지 않게). */ handleClick: (view: ViewState, x: number, y: number) => boolean; /** 모델 확정 시점에 관 지점과 세부유역을 영구저장한다. */ commit: () => Promise; draw: (context: CanvasRenderingContext2D, normalizer: Normalizer | null, view: ViewState) => void; dispose: () => void; } export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverlay { let projectId: string | null = null; let meta: VWorldMeta | null = null; let samples: RoutePoint[] = []; let pipes: PipeMarker[] = []; let basins: DetailBasin[] = []; let zSource = ""; let minSpacing = 20; let busy = false; let dirty = false; /** 끌고 있는 마커 번호. null이면 잡은 것이 없다. */ let dragging: number | null = null; /** 이번 끌기에서 마커가 실제로 움직였는지 — 안 움직였으면 "고르기"로 본다. */ let dragMoved = false; /** 고른 세부유역 번호(백엔드 index). null이면 전부 같은 진하기로 보여 준다. */ let selectedBasin: number | null = null; /** 마지막 프레임의 좌표 변환기 — 유역을 눌러 고를 때 폴리곤을 화면으로 옮기는 데 쓴다. */ let normalizerRef: Normalizer | null = null; let requestSequence = 0; const statusElement = document.createElement("span"); statusElement.className = "b04-map__watershed-status"; statusElement.hidden = true; const menu = createMapContextMenu("b04-map"); const button = document.createElement("button"); button.type = "button"; button.className = "b04-map__layer-button b04-map__layer-button--gis"; button.textContent = L("B04_Surface_Basin_Btn"); button.style.setProperty("--b04-layer-color", themeColor("--map-pipe-user", "#16a34a")); button.title = L("B04_Surface_Basin_Btn_Tip"); // 관 마커와 세부유역은 늘 같이 본다 — 따로 끄면 관을 옮겨도 유역이 안 보여 판단할 수 없다. // 그래서 토글은 하나뿐이다(2026-08-01 사용자 지시). let shown = true; const partButton = document.createElement("button"); partButton.type = "button"; partButton.className = "b04-map__layer-button b04-map__layer-button--gis is-active"; partButton.textContent = L("B04_Surface_Basin_Part_Basins"); partButton.style.setProperty("--b04-layer-color", themeColor("--map-pipe-user", "#16a34a")); partButton.setAttribute("aria-pressed", "true"); partButton.addEventListener("click", () => { shown = !shown; partButton.classList.toggle("is-active", shown); partButton.setAttribute("aria-pressed", String(shown)); if (!shown) menu.close(); onChange(); }); const partButtons = [partButton]; function say(text: string): void { statusElement.textContent = text; statusElement.hidden = text === ""; } function summary(): string { const count = (source: PipeSource): number => pipes.filter((pipe) => pipe.source === source).length; const line = L("B04_Surface_Basin_Summary") .replace("{pipes}", String(pipes.length)) .replace("{stream}", String(count("stream"))) .replace("{spacing}", String(count("spacing"))) .replace("{user}", String(count("user"))) .replace("{basins}", String(basins.length)) .replace("{source}", zSource || "-"); const picked = basins.find((basin) => basin.index === selectedBasin); const detail = picked ? ` · ${L("B04_Surface_Basin_Selected") .replace("{index}", String(picked.index)) .replace("{chainage}", picked.chainage_m.toFixed(1)) .replace("{area}", formatArea(picked.area_m2)) .replace("{relief}", picked.relief_m.toFixed(1)) .replace("{flow}", String(Math.round(picked.flow_length_m)))}` : ""; return (dirty ? `${line} · ${L("B04_Surface_Basin_Edited")}` : line) + detail; } function pipeScreen(view: ViewState, chainage: number): [number, number] | null { if (!meta) return null; const point = pointAtChainage(samples, chainage); return point ? metricToScreen(meta, view, point.x, point.y) : null; } /** 마커 히트 판정. 가장 가까운 것 하나. */ function hitPipe(view: ViewState, x: number, y: number): number | null { let hit: number | null = null; let best = Number.POSITIVE_INFINITY; pipes.forEach((pipe, index) => { const screen = pipeScreen(view, pipe.chainage); if (!screen) return; const distance = Math.hypot(screen[0] - x, screen[1] - y); if (distance <= PIPE_RADIUS + PIPE_HIT_SLACK && distance < best) { hit = index; best = distance; } }); return hit; } /** 화면 좌표를 계획선 위 누가거리로 스냅한다. 계획선에서 멀면 null. */ function snap(view: ViewState, x: number, y: number): number | null { if (!meta) return null; const found = nearestChainage( samples, (point) => metricToScreen(meta as VWorldMeta, view, point.x, point.y), x, y, ); return found && found.distance <= ROUTE_HIT_PX ? found.chainage : null; } /** 관끼리 최소 간격을 지키는지 본다(자기 자신은 제외). */ function tooClose(chainage: number, exceptIndex: number | null): boolean { return pipes.some( (pipe, index) => index !== exceptIndex && Math.abs(pipe.chainage - chainage) < minSpacing - 1e-6, ); } /** 화면 좌표를 담고 있는 세부유역 번호. 겹치면 면적이 작은 쪽을 고른다(안쪽 조각 우선). */ function basinAt(view: ViewState, x: number, y: number): number | null { if (!normalizerRef) return null; let hit: number | null = null; let smallest = Number.POSITIVE_INFINITY; basins.forEach((basin) => { if (basin.polygon_lonlat.length < 3) return; // 구멍 안(= 안에 든 다른 유역)을 누르면 바깥 유역이 잡히지 않는다. const rings = (basin.polygon_rings_lonlat ?? [basin.polygon_lonlat]).map((ring) => ring.map(([lon, lat]) => lonLatToScreen(normalizerRef as Normalizer, view, lon, lat)), ); if (!pointInRings(rings, x, y)) return; if (basin.area_m2 < smallest) { smallest = basin.area_m2; hit = basin.index; } }); return hit; } /** 관 마커를 고르면 그 관이 받는 유역을 강조한다(둘은 같은 누가거리로 짝지어져 있다). */ function selectBasinOfPipe(pipeIndex: number): void { const pipe = pipes[pipeIndex]; if (!pipe) return; const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage) < 0.51); selectedBasin = basin && basin.index !== selectedBasin ? basin.index : null; say(summary()); } function markEdited(): void { dirty = true; say(summary()); onChange(); } /** 응답을 화면 상태로 옮긴다. */ function apply(response: Awaited>): void { pipes = response.pipe_points.map((point) => ({ chainage: point.chainage_m, source: point.source, })); basins = response.basins; zSource = response.z_source; minSpacing = response.min_spacing_m; dirty = false; // 유역 구성이 바뀌었으므로 이전 강조는 의미를 잃는다. selectedBasin = null; say(summary()); } /** 세부유역을 다시 나눈다. 편집 중인 관 목록을 그대로 보낸다. */ async function recompute(): Promise { if (!projectId || busy) return; busy = true; button.disabled = true; button.textContent = L("B04_Surface_Basin_Btn_Busy"); const sequence = ++requestSequence; try { const response = await computeDetailBasins( projectId, pipes.map((pipe) => ({ chainage_m: pipe.chainage, source: pipe.source })), ); if (sequence !== requestSequence) return; apply(response); } catch (error) { if (sequence !== requestSequence) return; say(error instanceof Error ? error.message : L("B04_Surface_Basin_Failed")); } finally { busy = false; button.disabled = false; button.textContent = L("B04_Surface_Basin_Btn"); onChange(); } } /** 저장분(없으면 자동 생성분)을 불러온다. 지도를 열 때 조용히 돈다. */ async function loadSaved(): Promise { if (!projectId) return; const sequence = ++requestSequence; try { const response = await fetchDetailPipePoints(projectId); if (sequence !== requestSequence) return; apply(response); } catch { if (sequence !== requestSequence) return; // 배수유역 분석 전이면 여기서 실패하는 것이 정상이다 — 조용히 비워 둔다. pipes = []; basins = []; say(""); } onChange(); } button.addEventListener("click", () => { void recompute(); }); return { button, partButtons, statusElement, menuElement: menu.element, setProject(next) { projectId = next; pipes = []; basins = []; dirty = false; say(""); void loadSaved(); }, setRoute(points, nextMeta) { meta = nextMeta; samples = resampleRoute(points); }, clear() { pipes = []; basins = []; zSource = ""; dirty = false; dragging = null; selectedBasin = null; requestSequence += 1; menu.close(); say(""); }, handlePointerDown(view, x, y) { menu.close(); if (!shown || pipes.length === 0) return false; const hit = hitPipe(view, x, y); if (hit === null) return false; dragging = hit; dragMoved = false; return true; }, handlePointerMove(view, x, y) { if (dragging === null) return false; const chainage = snap(view, x, y); // 계획선에서 벗어난 위치는 무시한다 — 관은 늘 노선 위에 있어야 한다. if (chainage === null || tooClose(chainage, dragging)) return true; if (Math.abs(pipes[dragging].chainage - chainage) < 1e-6) return true; pipes[dragging] = { chainage, source: "user" }; pipes.sort((left, right) => left.chainage - right.chainage); dragging = pipes.findIndex((pipe) => pipe.chainage === chainage); dragMoved = true; markEdited(); return true; }, handlePointerUp() { if (dragging === null) return false; const released = dragging; dragging = null; // 잡았다가 그대로 뗐으면 옮긴 것이 아니라 고른 것이다 — 그 관이 받는 유역을 강조한다. if (!dragMoved) selectBasinOfPipe(released); onChange(); return true; }, handleClick(view, x, y) { if (!shown) return false; const hit = basinAt(view, x, y); // 유역 밖을 누르면 강조를 푼다. 이미 아무것도 안 골랐으면 다른 오버레이에 클릭을 넘긴다. if (hit === null && selectedBasin === null) return false; selectedBasin = hit === selectedBasin ? null : hit; say(summary()); onChange(); return true; }, handleContextMenu(view, x, y) { menu.close(); if (!shown) return false; const hit = hitPipe(view, x, y); if (hit !== null) { menu.open(x, y, [ [ L("B04_Surface_Basin_Menu_Delete"), () => { pipes.splice(hit, 1); markEdited(); }, ], ]); return true; } const chainage = snap(view, x, y); if (chainage === null) return false; menu.open(x, y, [ [ L("B04_Surface_Basin_Menu_Add"), () => { if (tooClose(chainage, null)) { say(L("B04_Surface_Basin_TooClose").replace("{min}", String(minSpacing))); onChange(); return; } pipes.push({ chainage, source: "user" }); pipes.sort((left, right) => left.chainage - right.chainage); markEdited(); }, ], ]); return true; }, async commit() { if (!projectId) return 0; const response = await saveDetailPipePoints( projectId, pipes.map((pipe) => ({ chainage_m: pipe.chainage, source: pipe.source })), ); apply(response); onChange(); return response.pipe_count; }, draw(context, normalizer, view) { normalizerRef = normalizer; if (!shown) return; // 유역 번호는 **맨 마지막에** 얹는다 — 채움과 함께 그리면 관 마커에 가려진다 // (2026-08-01 사용자 지시). 자리는 채움을 그리면서 같이 모아 둔다. const labels: Array<{ index: number; number: number; point: [number, number] }> = []; if (normalizer && basins.length > 0) { // 유역 번호를 얹을 자리는 폴리곤을 그리면서 같이 모은다 — 화면 좌표를 두 번 계산하지 // 않는다. 번호는 채움 위에 한꺼번에 얹어야 이웃 유역 채움에 덮이지 않는다. context.save(); context.lineJoin = "round"; basins.forEach((basin, index) => { if (basin.polygon_lonlat.length < 3) return; const ring = basin.polygon_lonlat.map(([lon, lat]) => lonLatToScreen(normalizer, view, lon, lat), ); // 조각·구멍을 한 경로에 담아 even-odd로 채운다 — 구멍이 실제로 뚫린다. const rings = (basin.polygon_rings_lonlat ?? [basin.polygon_lonlat]).map((part) => part.map(([lon, lat]) => lonLatToScreen(normalizer, view, lon, lat)), ); context.beginPath(); rings.forEach((part) => { part.forEach(([px, py], order) => { if (order === 0) context.moveTo(px, py); else context.lineTo(px, py); }); context.closePath(); }); // 하나를 고르면 나머지는 옅게 물러난다 — 고른 유역의 경계를 눈으로 좇을 수 있게. const muted = selectedBasin !== null && selectedBasin !== basin.index; const alpha = muted ? BASIN_ALPHA_MUTED : selectedBasin === basin.index ? BASIN_ALPHA_SELECTED : BASIN_ALPHA_PLAIN; context.fillStyle = basinColor(index, alpha); context.fill("evenodd"); context.strokeStyle = basinColor(index, muted ? 0.3 : 0.95); context.lineWidth = selectedBasin === basin.index ? 2.8 : 1.8; context.stroke(); if (!muted) labels.push({ index, number: basin.index, point: ringCentroid(ring) }); }); context.restore(); } if (pipes.length === 0) { drawBasinNumbers(context, labels); return; } context.save(); context.textAlign = "center"; context.textBaseline = "middle"; pipes.forEach((pipe, index) => { const screen = pipeScreen(view, pipe.chainage); if (!screen) return; const [x, y] = screen; const [token, fallback] = PIPE_COLORS[pipe.source]; context.beginPath(); context.arc(x, y, PIPE_RADIUS, 0, Math.PI * 2); context.fillStyle = themeColor(token, fallback); context.fill(); context.lineWidth = index === dragging ? 3 : 1.6; context.strokeStyle = haloColor(); context.stroke(); context.font = "bold 10px sans-serif"; context.fillStyle = themeColor("--map-marker-text", "#111827"); context.fillText(String(index + 1), x, y); }); context.restore(); // 유역 번호는 맨 마지막 — 관 마커에도 가리지 않게 한다(2026-08-01 사용자 지시). drawBasinNumbers(context, labels); }, dispose() { requestSequence += 1; menu.close(); }, }; }