import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { themeColor } from "@ui/ui_template_palette"; import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_wf1_Surface_Api_Fetch"; import { drawFlowArrows } from "./B04_wf1_Surface_UI_FlowArrows"; import type { Normalizer, ViewState } from "./B04_wf1_Surface_UI_MapRender"; import { drawUpstreamLines } from "./B04_wf1_Surface_UI_MapOverlays"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /* ============================================================================= * 배수유역 분석 오버레이 (B04 — 관리자 확인용) * * 2D 배경지도 위에 배수유역 분석 결과를 겹쳐 그린다. 계산은 백엔드가 하고 여기서는 * 그리기만 한다. 30초 안팎이 걸리는 요청이라 버튼을 눌렀을 때만 돈다. * * 겹쳐 그리는 것 * · 해석 격자 — 1차 영역에 걸치는 셀만, 흰 선 * · 셀별 흐름 — 도로 도달 적색 / 미도달 파랑 채움 + 백색 화살표 / 표고없음 회색 * · 상류 세류망(굵은 파랑) · 하류망(회색 파선) · 1차 영역(초록 채움) * · 2차 전체 배수유역 외곽선(갈색 파선) · 기본 관 위치 * ========================================================================== */ /* 색 값의 정의처는 `ui_template_theme.css`(`--map-*`)다 — 여기서 값을 새로 정하지 않는다. (인자로 준 값은 CSS가 아직 안 붙은 첫 프레임 대비용 안전값) */ // 해석 격자 셀 선 — 등고선·세류 위에 얹으므로 흰색으로 둔다. const gridLineColor = (): string => themeColor("--map-grid-line", "rgba(255, 255, 255, 0.55)"); // 흐름 판정 색 — 도로로 물이 오는 셀은 적색, 오지 않는 셀은 파랑 채움 + 백색 화살표. const flowToRoadFill = (): string => themeColor("--map-flow-to-road-fill", "rgba(220, 38, 38, 0.28)"); const flowToRoadLine = (): string => themeColor("--map-flow-to-road-line", "rgba(153, 27, 27, 0.95)"); const flowAwayFill = (): string => themeColor("--map-flow-away-fill", "rgba(37, 99, 235, 0.22)"); const flowAwayLine = (): string => themeColor("--map-flow-away-line", "rgba(255, 255, 255, 0.95)"); /** 등고선 TIN 밖이라 표고가 없어 판정하지 못한 셀 — 미도달(파랑)과 구분한다. */ const flowUnknownFill = (): string => themeColor("--map-flow-unknown-fill", "rgba(120, 113, 108, 0.18)"); /** 화살표가 이보다 작으면 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */ const ARROW_MIN_PX = 7; /** 화면상 화살표 간격 목표(px). 1m 격자를 도엽 배율로 보면 셀이 1~2px라 셀마다 그릴 수 없다. * 이 간격이 되도록 셀을 건너뛰며 표본만 그린다 — 흐름장을 읽는 표준 방식이다. */ const ARROW_SPACING_PX = 22; /** 2차 전체 배수유역 외곽선 = 분수령. */ const basinRingColor = (): string => themeColor("--map-basin-ring", "rgba(146, 64, 14, 0.95)"); /** 기본 관 마커. */ const pipeMarkerColor = (): string => themeColor("--map-pipe-marker", "rgba(249, 115, 22, 0.95)"); /** 개별로 켜고 끌 수 있는 오버레이 갈래. 문구는 locale, 색은 theme.css가 정의처다. */ const PARTS = [ { key: "primary", labelKey: "B04_Surface_Watershed_Part_Primary", token: ["--map-primary-region", "#059669"], }, { key: "basin", labelKey: "B04_Surface_Watershed_Part_Basin", token: ["--map-basin-outline", "#92400e"], }, { key: "flow", labelKey: "B04_Surface_Watershed_Part_Flow", token: ["--map-flow-region", "#2563eb"], }, { key: "arrows", labelKey: "B04_Surface_Watershed_Part_Arrows", token: ["--map-flow-arrow", "#7c3aed"], }, ] as const satisfies ReadonlyArray<{ key: string; labelKey: keyof typeof ui_locales; token: readonly [string, string]; }>; type PartKey = (typeof PARTS)[number]["key"]; export interface WatershedOverlay { /** 분석 실행 + 전체 토글 버튼. 지도 헤더의 GIS 버튼 줄에 넣는다. */ button: HTMLButtonElement; /** 갈래별 표시 토글 버튼(1차 유역 / 2차 유역 / 유역 방향 / 평균 흐름). */ partButtons: HTMLButtonElement[]; /** 배수유역 **결과** 줄(좌상단). 지도 자체 상태(레이어 로딩 등)와 섞이면 서로 덮어쓴다. */ statusElement: HTMLElement; /** 분석 **진행 중** 줄(우상단). 결과를 지우지 않고 따로 뜬다. */ busyElement: HTMLElement; /** 켜져 있는지. draw() 호출 전에 확인한다. */ visible: () => boolean; /** 상태 문구(분석 요약 또는 오류). 없으면 빈 문자열. */ status: () => string; /** 마지막으로 받은 분석 결과. 흐름 강도 오버레이가 강도 곡선·유입 집중점을 여기서 가져간다. */ analysis: () => WatershedAnalysis | null; /** 프로젝트가 바뀌면 받아 둔 분석 결과를 버린다. */ reset: () => void; /** 현재 프로젝트를 알려 준다. 지정 전에는 버튼이 아무 일도 하지 않는다. */ setProject: (projectId: string) => void; draw: (context: CanvasRenderingContext2D, map: Normalizer, view: ViewState) => void; } export function createWatershedOverlay(onChange: () => void): WatershedOverlay { let analysis: WatershedAnalysis | null = null; let shown = false; let statusText = ""; let flowCache: { source: string; bytes: Uint8Array } | null = null; let busy = false; const statusElement = document.createElement("span"); statusElement.className = "b04-map__watershed-status"; statusElement.hidden = true; // 진행 문구는 결과 문구와 자리를 나눈다 — 결과는 좌상단에 남겨 두고 "분석 중…"만 우상단에 // 띄운다(2026-08-01 사용자 지시). 한 줄을 같이 쓰면 분석을 돌릴 때마다 직전 결과가 지워진다. const busyElement = document.createElement("span"); busyElement.className = "b04-map__watershed-status"; busyElement.hidden = true; /** 상태 줄을 갱신한다. 빈 문자열이면 줄 자체를 숨긴다. */ function say(text: string): void { statusText = text; statusElement.textContent = text; statusElement.hidden = text === ""; } /** 진행 중 문구(우상단). 빈 문자열이면 감춘다. */ function sayBusy(text: string): void { busyElement.textContent = text; busyElement.hidden = text === ""; } const button = document.createElement("button"); button.type = "button"; button.className = "b04-map__layer-button b04-map__layer-button--gis"; button.textContent = L("B04_Surface_Watershed_Btn"); button.style.setProperty("--b04-layer-color", themeColor("--color-danger", "#dc2626")); button.setAttribute("aria-pressed", "false"); button.title = L("B04_Surface_Watershed_Btn_Tip"); // 갈래별 표시 여부. 전체 토글(button)이 꺼져 있으면 이 값과 무관하게 아무것도 안 그린다. // 1차 유역·유역 방향은 격자가 지도를 덮어 판독을 방해하므로 기본 꺼짐(2026-08-01 사용자 지시). const shownParts: Record = { primary: false, basin: true, flow: false, arrows: true, }; const partButtons = PARTS.map((part) => { const element = document.createElement("button"); element.type = "button"; const initialActive = shownParts[part.key]; element.className = "b04-map__layer-button b04-map__layer-button--gis" + (initialActive ? " is-active" : ""); element.textContent = L(part.labelKey); element.style.setProperty("--b04-layer-color", themeColor(part.token[0], part.token[1])); element.setAttribute("aria-pressed", String(initialActive)); element.addEventListener("click", () => { shownParts[part.key] = !shownParts[part.key]; element.classList.toggle("is-active", shownParts[part.key]); element.setAttribute("aria-pressed", String(shownParts[part.key])); onChange(); }); return element; }); let projectId: string | null = null; function strokeLonLat( context: CanvasRenderingContext2D, line: ReadonlyArray, map: Normalizer, view: ViewState, ): void { if (line.length < 2) return; const ax = view.mapRect.width * view.scale; const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; const ay = view.mapRect.height * view.scale; const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; context.beginPath(); line.forEach(([lon, lat], index) => { const x = ((lon - map.lonMin) / map.lonRange) * ax + bx; const y = (1 - (lat - map.latMin) / map.latRange) * ay + by; if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); context.stroke(); } /** 흐름 방향 바이트를 셀 순서대로 디코드한다(캐시 — 매 프레임 다시 풀지 않는다). */ function flowBytes(region: WatershedAnalysis): Uint8Array | null { if (!region.flow) return null; if (flowCache?.source === region.flow.data) return flowCache.bytes; const binary = atob(region.flow.data); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); flowCache = { source: region.flow.data, bytes }; return bytes; } /** 1차 영역에 걸쳐 실제로 생성된 셀만 그린다. * * bbox 전체를 채우지 않는다 — 백엔드가 준 행별 구간(row_spans)만 그린다. 흐름 판정이 * 있으면 셀마다 방향 화살표를 얹고, 도로에 물이 닿는 셀은 적색·닿지 않으면 파랑으로 * 칠한다. 셀이 화면에서 작아지면 화살표가 안 보이므로 채움색만 남긴다. */ function drawGridCells( context: CanvasRenderingContext2D, map: Normalizer, view: ViewState, region: WatershedAnalysis, ): void { const ring = region.grid.bbox_lonlat; if (ring.length < 4) return; const lons = ring.map(([lon]) => lon); const lats = ring.map(([, lat]) => lat); const ax = view.mapRect.width * view.scale; const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; const ay = view.mapRect.height * view.scale; const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; const left = ((Math.min(...lons) - map.lonMin) / map.lonRange) * ax + bx; const right = ((Math.max(...lons) - map.lonMin) / map.lonRange) * ax + bx; const top = (1 - (Math.max(...lats) - map.latMin) / map.latRange) * ay + by; const bottom = (1 - (Math.min(...lats) - map.latMin) / map.latRange) * ay + by; const { rows, cols, row_spans: spans } = region.grid; const cellW = (right - left) / Math.max(cols, 1); const cellH = (bottom - top) / Math.max(rows, 1); const cellPx = Math.min(Math.abs(cellW), Math.abs(cellH)); const bytes = flowBytes(region); // 1m 격자를 도엽 전체 배율로 보면 셀이 1~2px라 셀마다 화살표를 그리면 아무것도 안 보인다. // 화면에서 대략 ARROW_SPACING_PX 간격이 되도록 셀을 건너뛰며 표본만 그린다. const stride = Math.max(1, Math.ceil(ARROW_SPACING_PX / Math.max(cellPx, 0.01))); const arrowPx = cellPx * stride; context.save(); context.setLineDash([]); context.lineCap = "round"; let cursor = 0; // row_spans를 훑은 순서 = 흐름 바이트 순서 spans.forEach(([row, colStart, colEnd]) => { const count = colEnd - colStart + 1; const base = cursor; cursor += count; const y = top + cellH * row; if (y + cellH < -40 || y > view.height + 40) return; const x = left + cellW * colStart; const width = cellW * count; if (x + width < -40 || x > view.width + 40) return; if (!bytes) { // 흐름 판정 전 — 격자만 흰 선으로 보여 준다. if (cellPx >= 2) { context.strokeStyle = gridLineColor(); context.lineWidth = 0.5; context.beginPath(); for (let col = colStart; col <= colEnd; col += 1) { context.rect(left + cellW * col, y, cellW, cellH); } context.stroke(); } else { context.fillStyle = "rgba(255, 255, 255, 0.2)"; context.fillRect(x, y, width, cellH); } return; } const sink = region.flow?.sink_code ?? 32; const invalid = region.flow?.invalid_code ?? 33; const steps = region.flow?.azimuth_steps ?? 32; for (let offset = 0; offset < count; offset += 1) { const col = colStart + offset; // 화살표는 표본만 그린다 — 격자가 촘촘하면 셀마다 그려 봐야 뭉개져서 안 보인다. const sampled = row % stride === 0 && col % stride === 0; drawFlowCell( context, bytes[base + offset], left + cellW * col, y, cellW, cellH, cellPx, sampled ? arrowPx : 0, { sink, invalid, steps }, ); } }); context.restore(); } /** 셀 하나 — 도달 여부로 칠하고, 충분히 크면 32방위 흐름 화살표를 얹는다. */ function drawFlowCell( context: CanvasRenderingContext2D, code: number, x: number, y: number, cellW: number, cellH: number, cellPx: number, arrowPx: number, codes: { sink: number; invalid: number; steps: number }, ): void { const azimuth = code & 0x3f; const reaches = (code & 0x80) !== 0; // 표고가 없어 판정 못한 셀 — 미도달(파랑 채움)과 구분해야 오독이 없다. const unanalyzed = azimuth === codes.invalid; context.fillStyle = unanalyzed ? flowUnknownFill() : reaches ? flowToRoadFill() : flowAwayFill(); context.fillRect(x, y, cellW, cellH); if (cellPx >= 2) { context.strokeStyle = gridLineColor(); context.lineWidth = 0.5; context.strokeRect(x, y, cellW, cellH); } // arrowPx = 0 이면 표본에서 빠진 셀이라 채움만 하고 끝낸다. if (arrowPx < ARROW_MIN_PX || unanalyzed) return; const stroke = reaches ? flowToRoadLine() : flowAwayLine(); const midX = x + cellW / 2; const midY = y + cellH / 2; if (azimuth === codes.sink) { // 제자리(싱크) — 방향이 없으므로 점으로 표시한다. context.fillStyle = stroke; context.beginPath(); context.arc(midX, midY, Math.max(1, arrowPx * 0.12), 0, Math.PI * 2); context.fill(); return; } // 코드 0 = 화면 오른쪽(+x), 시계방향(캔버스 y는 아래가 +). const angle = (azimuth * 2 * Math.PI) / codes.steps; const unitX = Math.cos(angle); const unitY = Math.sin(angle); const reach = arrowPx * 0.38; const tipX = midX + unitX * reach; const tipY = midY + unitY * reach; // 표본 화살표는 채움색 위에서도 읽혀야 하므로 흰 테두리를 한 겹 깔고 그 위에 그린다. const width = Math.max(1, arrowPx * 0.08); const head = arrowPx * 0.18; const stem: [number, number][] = [ [midX - unitX * reach, midY - unitY * reach], [tipX, tipY], ]; for (const [color, lineWidth] of [ ["rgba(255, 255, 255, 0.85)", width + 1.6] as const, [stroke, width] as const, ]) { context.strokeStyle = color; context.lineWidth = lineWidth; context.beginPath(); context.moveTo(stem[0][0], stem[0][1]); context.lineTo(stem[1][0], stem[1][1]); // 촉 — 진행 방향 기준 좌우로 짧게 접는다. context.moveTo(tipX, tipY); context.lineTo(tipX - (unitX + unitY * 0.7) * head, tipY - (unitY - unitX * 0.7) * head); context.moveTo(tipX, tipY); context.lineTo(tipX - (unitX - unitY * 0.7) * head, tipY - (unitY + unitX * 0.7) * head); context.stroke(); } } /** 1차 배수유역 근거를 겹쳐 그린다 — 단계 검증용. */ function drawPrimaryRegion( context: CanvasRenderingContext2D, map: Normalizer, view: ViewState, region: WatershedAnalysis, ): void { context.save(); // 1차 배수유역 = 상류 세류망의 반경 버퍼 합집합. (격자는 draw()에서 먼저 깔았다) context.setLineDash([]); context.lineWidth = 2; context.strokeStyle = "rgba(5, 150, 105, 0.95)"; context.fillStyle = "rgba(16, 185, 129, 0.12)"; region.region_rings.forEach((ring) => { strokeLonLat(context, ring, map, view); context.fill(); }); // ③ 도로 아래로 이어진 하류망 — 판정이 맞는지 대조하도록 회색 파선으로 남긴다. context.setLineDash([6, 5]); context.lineWidth = 2; context.strokeStyle = "rgba(120, 113, 108, 0.85)"; region.downstream_lines.forEach((line) => strokeLonLat(context, line, map, view)); context.restore(); // ④ 채택된 상류망 = 1차 영역의 기준선. 가장 굵게, 맨 위에. // 그리기는 B05 배수유역도와 같은 공용 렌더러에 맡긴다. drawUpstreamLines(context, region.upstream_lines, map, view); } /** B05에도 같이 쓰는 평균 흐름 화살표. 그리기는 공용 렌더러에 맡긴다. */ function drawMeanArrows( context: CanvasRenderingContext2D, map: Normalizer, view: ViewState, region: WatershedAnalysis, ): void { // 격자 bbox의 경도 폭과 실폭(m)으로 1m당 픽셀을 환산한다. const lons = region.grid.bbox_lonlat.map(([lon]) => lon); const spanLon = Math.max(...lons) - Math.min(...lons); if (!(spanLon > 0) || !(region.grid.width_m > 0)) return; const ax = view.mapRect.width * view.scale; const pxPerMeter = ((spanLon / map.lonRange) * ax) / region.grid.width_m; const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; const ay = view.mapRect.height * view.scale; const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; drawFlowArrows( context, region.flow_arrows ?? [], region.arrow_spacing_m ?? 0, pxPerMeter, (lon, lat) => [ ((lon - map.lonMin) / map.lonRange) * ax + bx, (1 - (lat - map.latMin) / map.latRange) * ay + by, ], view, ); } /** ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 ⑧ 기본 관 위치. */ function drawBasinAndPipes( context: CanvasRenderingContext2D, map: Normalizer, view: ViewState, region: WatershedAnalysis, ): void { context.save(); if (region.basin_polygon_lonlat.length > 2) { context.setLineDash([8, 5]); context.lineWidth = 2.5; context.strokeStyle = basinRingColor(); strokeLonLat(context, region.basin_polygon_lonlat, map, view); } context.setLineDash([]); const ax = view.mapRect.width * view.scale; const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; const ay = view.mapRect.height * view.scale; const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; region.pipes.forEach((pipe, index) => { const x = ((pipe.lon - map.lonMin) / map.lonRange) * ax + bx; const y = (1 - (pipe.lat - map.latMin) / map.latRange) * ay + by; context.beginPath(); context.arc(x, y, 7, 0, Math.PI * 2); context.fillStyle = pipeMarkerColor(); context.fill(); context.lineWidth = 1.5; const markerText = themeColor("--map-marker-text", "#111827"); context.strokeStyle = markerText; context.stroke(); context.fillStyle = markerText; context.font = "bold 10px sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; context.fillText(String(index + 1), x, y); }); context.restore(); } function regionSummary(region: WatershedAnalysis): string { const cells = region.grid.cells.toLocaleString(); const outside = region.road_outside_m > 0 ? ` · 노선 이탈 ${Math.round(region.road_outside_m)}m` : ""; const unknown = region.flow && region.flow.unanalyzed > 0 ? ` / 표고없음 ${region.flow.unanalyzed.toLocaleString()}(회)` : ""; const burned = region.flow && region.flow.burned > 0 ? ` · 세류망 새김 ${region.flow.burned.toLocaleString()}셀` : ""; const flow = region.flow ? ` · 흐름 도로도달 ${region.flow.reaches_road.toLocaleString()}(적) / ` + `미도달 ${region.flow.no_road.toLocaleString()}(청)${unknown}, ` + `최외곽 출발 ${region.flow.outer_seeds.toLocaleString()} + ` + `내부 보충 ${region.flow.interior_seeds.toLocaleString()}${burned}` : " · 흐름 판정 없음"; const expansion = region.expansion ? ` · 확장 ${region.expansion.rounds}회` + `(${region.expansion.initial_cells.toLocaleString()}→${cells}셀, ` + `${region.expansion.closed ? "닫힘" : "상한 도달"})` : ""; const basin = region.basin_area_m2 ? ` · 2차 유역 ${formatArea(region.basin_area_m2)}, 기본 관 ${region.pipes.length}개` : ""; return ( `1차 영역(반경 ${region.radius_m}m): 상류망 ${region.upstream_lines.length}조각 채택 / ` + `하류망 ${region.downstream_lines.length}조각·미연결 ${region.no_contact_count}개 제외 · ` + `격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}개${outside}${expansion}${basin}${flow}` ); } function formatArea(areaM2: number): string { return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`; } /** 분석 결과를 받아 화면에 올린다. * `refresh=false`면 영구저장소에 남은 결과를 그대로 받아 즉시 끝나므로 지도를 열 때 * 자동으로 부른다. `refresh=true`(재산정 버튼)면 처음부터 다시 계산한다. */ async function loadAnalysis(refresh: boolean): Promise { if (!projectId || busy) return; busy = true; button.disabled = true; button.textContent = L( refresh ? "B04_Surface_Watershed_Btn_Analyzing" : "B04_Surface_Watershed_Btn_Loading", ); // 진행 문구는 우상단 전용 줄에 띄운다 — 좌상단의 직전 결과를 지우지 않는다. sayBusy( L( refresh ? "B04_Surface_Watershed_Status_Analyzing" : "B04_Surface_Watershed_Status_Loading", ), ); const started = performance.now(); try { analysis = await fetchWatershedAnalysis(projectId, refresh); shown = true; button.classList.add("is-active"); button.setAttribute("aria-pressed", "true"); const seconds = ((performance.now() - started) / 1000).toFixed(1); const origin = analysis.from_cache ? L("B04_Surface_Watershed_Origin_Cached") : L("B04_Surface_Watershed_Origin_Recomputed").replace("{seconds}", seconds); say(`[${origin}] ${regionSummary(analysis)}`); } catch (error) { analysis = null; shown = false; button.classList.remove("is-active"); button.setAttribute("aria-pressed", "false"); const message = error instanceof Error ? error.message : L("B04_Surface_Watershed_LoadFailed"); // 저장분이 아직 없는 것은 오류가 아니다 — 무엇을 눌러야 하는지 알려 준다. say( refresh ? L("B04_Surface_Watershed_Failed").replace("{message}", message) : L("B04_Surface_Watershed_NoSaved"), ); } finally { busy = false; sayBusy(""); // 끝났으면 진행 문구를 거둔다 — 결과는 좌상단 줄에 남는다 button.disabled = false; button.textContent = L("B04_Surface_Watershed_Btn"); onChange(); } } // 재산정 버튼은 언제나 처음부터 다시 분석한다. // (저장분을 자동으로 띄우게 바꾼 뒤로 이 버튼이 표시 토글로 먼저 걸려, 눌러도 아무 일이 // 없는 것처럼 보였다 — 2026-08-01. 보이기/숨기기는 갈래별 버튼이 맡는다.) button.addEventListener("click", () => { void loadAnalysis(true); }); return { button, partButtons, statusElement, busyElement, visible: () => shown && analysis !== null, status: () => statusText, analysis: () => analysis, reset() { analysis = null; flowCache = null; shown = false; say(""); button.classList.remove("is-active"); button.setAttribute("aria-pressed", "false"); }, setProject(next: string) { projectId = next; // 저장분이 있으면 즉시 올린다 — 없으면 조용히 넘어가고, 재산정 버튼을 누르면 계산한다. void loadAnalysis(false); }, draw(context, map, view) { if (!shown || !analysis) return; // 격자·화살표(유역 방향) → 1차 영역 → 2차 유역·관 순으로 아래에서 위로 쌓는다. if (shownParts.flow) drawGridCells(context, map, view, analysis); if (shownParts.primary) drawPrimaryRegion(context, map, view, analysis); if (shownParts.arrows) drawMeanArrows(context, map, view, analysis); if (shownParts.basin) drawBasinAndPipes(context, map, view, analysis); }, }; }