/* ============================================================================= * 배수유역도 패널 부품 (B05) * * 레이어 목록·색·보관 키 같은 상수와, 상태를 갖지 않는 DOM 조각(표시 토글 버튼, 유역 제원 * 목록)을 모았다. 패널 본체(`B05_Profile_UI_Drainage_Panel.ts`)가 700줄 한계에 닿아 * 분리한 것으로, 여기 있는 것들은 패널의 내부 상태를 알지 못한다 — 전부 인자로 받는다. * ========================================================================== */ import { stateKey } from "../A00_Common/b_page_state"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { themeColor } from "@ui/ui_template_palette"; import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { computeMapRect, computeRouteView, type GeoJsonCollection, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { normalizeStrength, rampColor } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp"; import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /** 배수유역 산정의 근거가 되는 도엽 레이어. 3D는 쓰지 않는다(사용자 지시). * 표고점은 유효 데이터가 적어 산정에서 제외했으므로 배경에도 띄우지 않는다(2026-07-31). */ export const DRAINAGE_LAYERS = DRAINAGE_SHEET_LAYERS; export type DrainageLayer = (typeof DRAINAGE_LAYERS)[number]; /** 색 값의 정의처는 `ui_template_theme.css`(`--map-*`)다. 여기는 이름만 잇는다. */ const LAYER_COLOR_TOKENS: Record = { 도엽_등고선: ["--map-sheet-contour", "#a5b4fc"], 도엽_하천중심선: ["--map-sheet-stream", "#2563eb"], }; export const layerColor = (layer: DrainageLayer): string => themeColor(...LAYER_COLOR_TOKENS[layer]); export const LAYER_LABEL_KEYS: Record = { 도엽_등고선: "B05_Drainage_Layer_Contour", 도엽_하천중심선: "B05_Drainage_Layer_Stream", }; /** 도엽 레이어가 아닌 표시 토글의 띠 색 — 지도에 그려지는 선 색과 맞춘다. */ export const arrowToggleColor = (): string => themeColor("--map-flow-arrow", "#7c3aed"); export const upstreamToggleColor = (): string => themeColor("--map-upstream-toggle", "#1d4ed8"); export const strengthToggleColor = (): string => themeColor("--map-flow-ramp-5", "#dc2626"); export const hotspotToggleColor = (): string => themeColor("--map-flow-ramp-4", "#f97316"); /** 위성사진은 선이 아니라 배경이라 맞출 선 색이 없다 — 중립 회색을 띠 색으로 쓴다. */ export const satelliteToggleColor = (): string => themeColor("--map-satellite-toggle", "#64748b"); /** 접힘·폭은 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ export const COLLAPSED_KEY = stateKey("drainage-collapsed") ?? ""; /** 드래그로 조절한 패널 폭(px) 보관 키 — 브라우저 세션 동안만 유지한다. */ export const WIDTH_KEY = stateKey("drainage-width") ?? ""; /** 지도가 담기는 최소 폭(px). CSS의 min-width와 같은 값. */ export const MIN_PANEL_WIDTH = 320; /** 상한은 하단 패널 폭의 70%까지(사용자 지시) — 종단면도가 최소한 30%는 남아야 한다. */ export const MAX_PANEL_WIDTH_RATIO = 0.7; /** 유역 오버레이 파스텔 색상 8종(정의처: `--map-basin-1`~`-8`). 번호 순으로 돌려쓴다. */ const BASIN_COLOR_FALLBACKS = [ "rgba(167, 216, 199, 0.45)", "rgba(247, 208, 168, 0.45)", "rgba(186, 199, 240, 0.45)", "rgba(241, 183, 199, 0.45)", "rgba(214, 226, 168, 0.45)", "rgba(202, 186, 227, 0.45)", "rgba(168, 214, 232, 0.45)", "rgba(240, 219, 168, 0.45)", ] as const; /** 유역 번호(1부터)에 대응하는 채움색. 8종을 넘어가면 처음부터 다시 쓴다. */ export function basinColor(index: number): string { const slot = (index - 1) % BASIN_COLOR_FALLBACKS.length; return themeColor(`--map-basin-${slot + 1}`, BASIN_COLOR_FALLBACKS[slot]); } /** 면적을 사람이 읽는 문구로. 1ha 이상은 ha로 줄인다. */ export function formatArea(areaM2: number): string { return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`; } /** 제목 우측 표시 토글 — 등고선·세류·흐름 화살표·상류 세류가 모두 같은 양식을 쓴다. */ export function addLayerToggle( container: HTMLElement, label: string, color: string, initial: boolean, onToggle: (next: boolean) => void, title?: string, ): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; button.className = "b05-drainage__layer-button" + (initial ? " is-active" : ""); button.textContent = label; button.style.setProperty("--b05-layer-color", color); button.setAttribute("aria-pressed", String(initial)); if (title) button.title = title; let active = initial; button.addEventListener("click", () => { active = !active; button.classList.toggle("is-active", active); button.setAttribute("aria-pressed", String(active)); onToggle(active); }); container.append(button); return button; } /** 유역 제원 목록을 다시 그린다. 항목을 누르면 `onPick`으로 번호를 돌려준다. */ /** 유역 추천을 한 조각 문구로 — 배관이면 규격 스냅 관경, BOX암거·세월교면 검토 표기. * 추천이 아직 없는(강우량표 전) 유역은 "미정"이다. */ function recommendationOf(basin: DetailBasin): string { if (basin.recommended_facility === "ford_bridge") return L("B05_Drainage_Basin_Bridge"); if (basin.recommended_facility === "box_culvert") return L("B05_Drainage_Basin_RecBox"); if (basin.recommended_diameter_mm == null) return L("B05_Drainage_Basin_Undecided"); return L("B05_Drainage_Basin_RecPipe").replace("{d}", String(basin.recommended_diameter_mm)); } export function renderBasinRows( container: HTMLElement, basins: ReadonlyArray, selected: number | null, onPick: (index: number) => void, ): void { container.textContent = ""; container.hidden = basins.length === 0; basins.forEach((basin) => { const row = document.createElement("button"); row.type = "button"; row.className = "b05-drainage__basin" + (selected === basin.index ? " is-selected" : ""); const badge = document.createElement("span"); badge.className = "b05-drainage__basin-index"; badge.textContent = String(basin.index); badge.style.background = basinColor(basin.index); const metrics = document.createElement("span"); metrics.className = "b05-drainage__basin-metrics"; // 유효직경은 강우량표(rainfall_table.json)가 생기기 전까지 null → "미정" 표기. // 값이 있으면 어떤 시설이든 `계산 유효직경 → 추천` 한 형식으로 적는다 — 세월교만 // 순서가 뒤집혀 있으면 같은 열을 훑을 때 읽는 방향이 달라진다(2026-08-17 사용자 지적). // 추천 근거는 유량뿐이고 지형 조건은 툴팁으로 안내한다. const pipe = basin.pipe_diameter_mm === null ? L("B05_Drainage_Basin_Undecided") : `Ø${Math.round(basin.pipe_diameter_mm)}mm → ${recommendationOf(basin)}`; metrics.textContent = L("B05_Drainage_Basin_Metrics") .replace("{area}", formatArea(basin.area_m2)) .replace("{relief}", basin.relief_m.toFixed(1)) .replace("{flow}", String(Math.round(basin.flow_length_m))) .replace("{pipe}", pipe); row.title = L("B05_Drainage_Basin_Chainage").replace("{chainage}", basin.chainage_m.toFixed(1)); // 산출 근거(도달시간·강우강도·설계유량)는 툴팁 둘째 줄로 붙인다 — 행이 길어지지 않게. if (basin.tc_minutes != null && basin.design_flow_m3s != null) { row.title += "\n" + L("B05_Drainage_Basin_Basis") .replace("{tc}", String(basin.tc_minutes)) .replace("{i}", String(basin.intensity_mm_hr ?? "-")) .replace("{q}", String(basin.design_flow_m3s)); } // 추천 근거의 한계를 같은 툴팁에 밝힌다 — 유량만 보고 고른 값이다. row.title += "\n" + L("B05_Drainage_Basin_RecNote"); row.append(badge, metrics); row.addEventListener("click", () => onPick(basin.index)); container.append(row); }); } /** 사업지 좌표계(m) → 화면 px 변환기. 흐름 화살표와 강도 색칠이 같은 식을 쓴다 — * 둘이 어긋나면 색은 계획선 위인데 화살표만 밀린 것처럼 보인다. */ // 미터 좌표 → 화면 좌표 변환기는 B04 지도와 공용이다(정의처: MapRender). export { createMetricProjector } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; /** 배관 우클릭 메뉴를 지도 뷰포트에 붙인다 — 마커 위면 삭제, 계획선 위면 추가. * 배관 편집 토글을 없애면서 추가·삭제를 이쪽으로 옮겼다(2026-08-01 사용자 지시). 계획선을 * 그냥 누르는 것으로 추가하면 유역을 고르려 할 때마다 배관이 생기기 때문이다. */ export function bindPipeContextMenu( viewport: HTMLElement, menu: MapContextMenu, editor: PipeEditor, viewOf: () => ViewState, /** 빈 자리 메뉴 항목 — 사이드 「구조물 배치」와 같은 구조물군 → 종류 2단 목록 * (2026-08-18 일원화, 종단그래프 메뉴와 동일). 없으면 옛 [배관 추가]만 띄운다. */ structureMenuItems?: (chainageM: number) => MapContextMenuItem[], ): void { viewport.addEventListener("contextmenu", (event) => { if (menu.contains(event.target)) { event.preventDefault(); return; } const rect = viewport.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; const view = viewOf(); const hit = editor.hitAt(view, x, y); if (hit !== null) { event.preventDefault(); menu.open(x, y, [ [ L("B05_Drainage_Menu_Delete"), () => { editor.select(hit); editor.deleteSelected(); }, ], ]); return; } // 계획선에서 먼 자리는 브라우저 기본 메뉴를 그대로 둔다. if (!editor.canAddAt(view, x, y)) return; event.preventDefault(); // 빈 자리 — 종단그래프와 같은 구조물군 → 종류 2단 메뉴(2026-08-18 일원화). // 선택 = 폼 자동 지정(사이드 addAt 경로, A군은 임시 배치까지). const chainage = editor.chainageAt(view, x, y); if (structureMenuItems && chainage !== null) { menu.open(x, y, structureMenuItems(chainage)); return; } menu.open(x, y, [[L("B05_Drainage_Menu_Add"), () => void editor.addAt(view, x, y)]]); }); } /** 유입 집중점 마커 — 계획선 위에서 물이 특히 많이 모이는 자리. 크기·색은 유입면적 로그 스케일. * B04 지도와 같은 색띠를 쓴다(같은 값을 다르게 보여 주면 안 된다). */ export function drawHotspots( context: CanvasRenderingContext2D, toScreen: (x: number, y: number) => [number, number], samples: ReadonlyArray<{ x: number; y: number }>, spots: ReadonlyArray<{ chainage: number; area: number }>, maximum: number, ): void { if (spots.length === 0 || samples.length === 0) return; context.save(); spots.forEach((spot) => { const index = Math.min(samples.length - 1, Math.max(0, Math.round(spot.chainage))); const [x, y] = toScreen(samples[index].x, samples[index].y); const ratio = normalizeStrength(spot.area, maximum); const radius = 3 + 5 * ratio; context.beginPath(); context.arc(x, y, radius, 0, Math.PI * 2); context.fillStyle = rampColor(ratio); context.fill(); context.lineWidth = 1.2; context.strokeStyle = themeColor("--map-halo", "rgba(255, 255, 255, 0.9)"); context.stroke(); }); context.restore(); } /** 화면 좌표 폴리곤 안에 점이 있는지(홀짝 규칙). 유역을 눌러 고를 때 쓴다. */ export function pointInRing(ring: ReadonlyArray<[number, number]>, x: number, y: number): boolean { let inside = false; for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) { const [xi, yi] = ring[index]; const [xj, yj] = ring[previous]; if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; } return inside; } /** 표시 토글 한 줄을 통째로 만든다 — 위성/도엽/화살표/강도/집중점/상류세류 6종. * 패널이 700줄 한계에 닿아 옮겼다. 상태는 갖지 않고 켜고 끌 때 넘겨받은 함수만 부른다. */ export function mountDrainageToggles( container: HTMLElement, handlers: { initial: { satellite: boolean; arrows: boolean; strength: boolean; hotspots: boolean; upstream: boolean; }; onSatellite: (next: boolean) => void; onSheetLayer: (layer: DrainageLayer, next: boolean) => void; onArrows: (next: boolean) => void; onStrength: (next: boolean) => void; onHotspots: (next: boolean) => void; onUpstream: (next: boolean) => void; }, ): void { // 배경 위성사진 — 등고선 앞에 둔다(2026-08-01 사용자 지시). 사진이 어두워 유역 채움색이 // 묻힐 때 끄고 본다. 캔버스가 아니라 배경 이미지라 표시 여부만 직접 바꾼다. // 기본 꺼짐(2026-08-18 사용자 지시) — 초기값은 패널이 정한다. addLayerToggle( container, L("B05_Drainage_Layer_Satellite"), satelliteToggleColor(), handlers.initial.satellite, handlers.onSatellite, L("B05_Drainage_Layer_Satellite_Tip"), ); DRAINAGE_LAYERS.forEach((layer) => { addLayerToggle(container, L(LAYER_LABEL_KEYS[layer]), layerColor(layer), true, (next) => handlers.onSheetLayer(layer, next), ); }); // 흐름 화살표 — 도면이 지저분해질 때 끄기 위한 토글(등고선·세류와 같은 줄·같은 양식). addLayerToggle( container, L("B05_Drainage_Layer_Arrows"), arrowToggleColor(), handlers.initial.arrows, handlers.onArrows, L("B05_Drainage_Layer_Arrows_Tip"), ); // 유입 강도 색칠 — 노선 1m 구간별 상류 면적. B04 지도와 같은 색띠를 쓴다. addLayerToggle( container, L("B05_Drainage_Layer_Strength"), strengthToggleColor(), handlers.initial.strength, handlers.onStrength, L("B05_Drainage_Layer_Strength_Tip"), ); // 유입 집중점 — 관을 어디에 둘지 판단하는 근거. 관 마커와 겹쳐 읽기 어려우므로 기본 꺼짐. addLayerToggle( container, L("B05_Drainage_Layer_Hotspots"), hotspotToggleColor(), handlers.initial.hotspots, handlers.onHotspots, L("B05_Drainage_Layer_Hotspots_Tip"), ); // 상류 세류선 강조 — 유역 판정의 기준선이라 항상 같은 굵기·색으로 얹는다. addLayerToggle( container, L("B05_Drainage_Layer_Upstream"), upstreamToggleColor(), handlers.initial.upstream, handlers.onUpstream, L("B05_Drainage_Layer_Upstream_Tip"), ); } /** 밖에서 바뀐 관 목록을 현재 목록과 맞춘다. 같으면 null — 되먹임 고리를 끊는 지점이다. * 다르면 새 목록을 돌려주되, 원래 생성 사유는 자리로 맞춰 이어 붙인다(기본/자동/수동 표시 보존). */ export function reconcilePipes( before: ReadonlyArray, chainages: ReadonlyArray, ): PipePoint[] | null { const next = [...chainages].map((value) => Math.round(value * 100) / 100).sort((a, b) => a - b); const current = before .map((pipe) => Math.round(pipe.chainage_m * 100) / 100) .sort((a, b) => a - b); if (next.length === current.length && next.every((value, index) => value === current[index])) { return null; } return next.map((chainage) => { const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < 0.51); return { chainage_m: chainage, reason: matched?.reason ?? "confirmed" }; }); } /** 배경지도 메타와 도엽 레이어를 한 번에 받아 온다. 한 레이어가 실패해도 나머지는 살린다. * 상태를 만지지 않는 I/O 전용 — 패널이 700줄 한계에 닿아 떼어냈다. */ export async function fetchDrainageLayers( projectId: string, onProgress: (ratio: number) => void, ): Promise<{ meta: VWorldMeta; layers: Array; }> { const meta = await fetchVWorldMeta(projectId, "satellite"); onProgress(1 / 3); const layers = await Promise.all( DRAINAGE_LAYERS.map(async (layer) => { try { // 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다. return [layer, await fetchCachedSheetLayer(projectId, layer)] as const; } catch { return [layer, null] as const; } }), ); return { meta, layers }; } /** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 배경 전체(배율 1)를 그대로 본다. * B05는 노선 주변 유역을 보는 화면이라 도로에 맞춰 확대한 상태로 연다(2026-08-01 사용자 지시). */ export function fitViewToRoute( meta: VWorldMeta | null, routePoints: ReadonlyArray<{ x: number; y: number }>, width: number, height: number, ): { scale: number; offsetX: number; offsetY: number } { if (!meta || routePoints.length < 2) return { scale: 1, offsetX: 0, offsetY: 0 }; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; routePoints.forEach((point) => { if (point.x < minX) minX = point.x; if (point.x > maxX) maxX = point.x; if (point.y < minY) minY = point.y; if (point.y > maxY) maxY = point.y; }); const view = computeRouteView( meta, { x_min: minX, x_max: maxX, y_min: minY, y_max: maxY }, Math.max(width, 1), Math.max(height, 1), ); return { scale: view.scale, offsetX: view.offsetX, offsetY: view.offsetY }; } /** 한 프레임의 화면 상태 — 크기가 바뀌었을 때 보던 자리를 지키는 데 쓴다. */ export interface ViewAnchor { width: number; height: number; scale: number; offsetX: number; offsetY: number; } /** * 패널 크기가 바뀌어도 **배율과 보던 자리를 그대로** 두고 지도를 더 보여 준다. * * `computeMapRect()`는 지도를 뷰포트에 맞춰 넣으므로(contain), 패널을 늘리면 지도가 함께 * 확대돼 방금 보던 지점이 어디로 갔는지 알 수 없게 된다(2026-08-02 사용자 지시). * 그래서 화면 변환 계수(ax·bx)가 그대로가 되도록 배율·오프셋을 되계산한다. 늘어난 만큼은 * 확대가 아니라 **더 넓은 범위**로 채워진다. */ export function preserveViewOnResize( meta: VWorldMeta | null, previous: ViewAnchor, nextWidth: number, nextHeight: number, ): { scale: number; offsetX: number; offsetY: number } { const before = computeMapRect(meta, previous.width, previous.height); const after = computeMapRect(meta, nextWidth, nextHeight); if (!(before.width > 0) || !(after.width > 0)) { return { scale: previous.scale, offsetX: previous.offsetX, offsetY: previous.offsetY }; } // 이전 프레임의 변환 계수. const ax = before.width * previous.scale; const bx = before.x * previous.scale + (previous.width / 2) * (1 - previous.scale) + previous.offsetX; const by = before.y * previous.scale + (previous.height / 2) * (1 - previous.scale) + previous.offsetY; // 같은 계수를 유지하는 새 배율·오프셋. mapRect가 지도 비율을 지키므로 x·y가 함께 맞는다. const scale = ax / after.width; return { scale, offsetX: bx - after.x * scale - (nextWidth / 2) * (1 - scale), offsetY: by - after.y * scale - (nextHeight / 2) * (1 - scale), }; } /** 뷰포트 크기 변화를 지켜보며 배율을 지킨다. 반환값은 정리용 `ResizeObserver`. */ export function observeViewportSize( viewport: HTMLElement, ports: { meta: () => VWorldMeta | null; anchor: () => ViewAnchor; apply: (next: { scale: number; offsetX: number; offsetY: number }) => void; redraw: () => void; }, ): ResizeObserver { let last: { width: number; height: number } | null = null; const observer = new ResizeObserver(() => { const rect = viewport.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width)); const height = Math.max(1, Math.floor(rect.height)); if (last && (last.width !== width || last.height !== height)) { const previous = ports.anchor(); ports.apply( preserveViewOnResize( ports.meta(), { ...previous, width: last.width, height: last.height }, width, height, ), ); } last = { width, height }; ports.redraw(); }); observer.observe(viewport); return observer; } /** 캔버스 버퍼를 뷰포트 크기·DPR에 맞춘다. 실제로 바뀔 때만 재할당한다(재할당 시 내용이 지워진다). */ export function fitCanvasToViewport( canvas: HTMLCanvasElement, width: number, height: number, previous: { width: number; height: number; dpr: number }, ): { width: number; height: number; dpr: number } { const dpr = window.devicePixelRatio || 1; if (width === previous.width && height === previous.height && dpr === previous.dpr) { return previous; } canvas.width = Math.floor(width * dpr); canvas.height = Math.floor(height * dpr); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; return { width, height, dpr }; } /** 진행률(0~1, 모르면 null)과 문구를 로딩 서클에 전달한다. label이 null이면 서클을 감춘다. */ export function createProgressReporter(progress: { root: HTMLElement; set: (ratio: number | null, label: string) => void; }): (ratio: number | null, label: string | null) => void { return (ratio, label) => { progress.root.hidden = label === null; if (label !== null) progress.set(ratio, label); }; } /** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */ export function pipeMarkerColor(basins: ReadonlyArray, chainageM: number): string { const basin = basins.find((item) => Math.abs(item.chainage_m - chainageM) < 0.51); if (!basin) return themeColor("--map-pipe-orphan", "#e5e7eb"); return basinColor(basin.index).replace(/0\.45\)$/, "1)"); } /** 배관 마커가 받는 유역 번호. 마커가 없거나 대응 유역이 없으면 null. */ export function basinIndexOfPipe( basins: ReadonlyArray, pipe: { chainage_m: number } | null, ): number | null { if (!pipe) return null; const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51); return basin ? basin.index : null; } /** 관 개수·세부유역 수·종단 Z 출처 한 줄. 유역이 없으면 줄 자체를 감춘다. */ export function summaryText( element: HTMLElement, pipeCount: number, basinCount: number, zSource: string, ): void { element.textContent = L("B05_Drainage_Summary") .replace("{pipes}", String(pipeCount)) .replace("{basins}", String(basinCount)) .replace("{source}", zSource || "-"); element.hidden = basinCount === 0; }