From 79fe2fb7c9c1f67d98e0586d2e87b15775753564 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 21:09:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(B04):=20=ED=91=9C=EB=B3=B8=20=ED=99=94?= =?UTF-8?q?=EC=82=B4=ED=91=9C=20+=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=20=EA=B0=88=EB=9E=98=EB=B3=84=20=ED=86=A0=EA=B8=80=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 화살표가 안 보이던 문제 1m 격자를 도엽 전체 배율로 보면 셀이 1~2px 다. 줌 상한이 8배라 셀이 화살표 최소 크기(7px)에 절대 못 미쳐 채움색만 보였다. -> 셀마다 그리지 않고 화면상 약 22px 간격이 되도록 건너뛰며 표본만 그린다. 화살표 크기는 그 간격에 맞춰 커진다. 흐름장을 읽는 표준 방식이다. -> 채움색 위에서도 읽히도록 흰 테두리를 한 겹 깔고 그 위에 그린다. 갈래별 토글 버튼 추가 (1차 유역 / 2차 유역 / 유역 방향) GIS 버튼 줄에 배수유역 전체 토글 옆으로 붙는다. 전체 토글이 꺼져 있으면 갈래 설정과 무관하게 아무것도 그리지 않는다. 그리는 순서는 유역방향(격자/화살표) -> 1차 유역 -> 2차 유역/관. Co-Authored-By: Claude Fable 5 --- .../B04_wf1_Surface_UI_MapViewer.ts | 2 +- .../B04_wf1_Surface_UI_Watershed.ts | 98 ++++++++++++++----- 2 files changed, 76 insertions(+), 24 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 58749d60..66c51074 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -227,7 +227,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (text) status.textContent = text; scheduleDraw(); }); - gisButtons.append(watershed.button); + gisButtons.append(watershed.button, ...watershed.partButtons); function updateImageTransform(): void { backgroundImages.forEach((image) => { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts index bf7ad5bf..8950ac7f 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -23,16 +23,29 @@ const FLOW_AWAY_FILL = "rgba(37, 99, 235, 0.22)"; const FLOW_AWAY_LINE = "rgba(255, 255, 255, 0.95)"; /** 등고선 TIN 밖이라 표고가 없어 판정하지 못한 셀 — 미도달(파랑)과 구분한다. */ const FLOW_UNKNOWN_FILL = "rgba(120, 113, 108, 0.18)"; -/** 셀이 이보다 작으면 화살표가 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */ +/** 화살표가 이보다 작으면 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */ const ARROW_MIN_PX = 7; +/** 화면상 화살표 간격 목표(px). 1m 격자를 도엽 배율로 보면 셀이 1~2px라 셀마다 그릴 수 없다. + * 이 간격이 되도록 셀을 건너뛰며 표본만 그린다 — 흐름장을 읽는 표준 방식이다. */ +const ARROW_SPACING_PX = 22; /** 2차 전체 배수유역 외곽선 = 분수령. */ const BASIN_RING_COLOR = "rgba(146, 64, 14, 0.95)"; /** 기본 관 마커. */ const PIPE_COLOR = "rgba(249, 115, 22, 0.95)"; +/** 개별로 켜고 끌 수 있는 오버레이 갈래. */ +const PARTS = [ + { key: "primary", label: "1차 유역", color: "#059669" }, + { key: "basin", label: "2차 유역", color: "#92400e" }, + { key: "flow", label: "유역 방향", color: "#2563eb" }, +] as const; +type PartKey = (typeof PARTS)[number]["key"]; + export interface WatershedOverlay { - /** 레이어 토글 버튼. 지도 헤더의 GIS 버튼 줄에 넣는다. */ + /** 분석 실행 + 전체 토글 버튼. 지도 헤더의 GIS 버튼 줄에 넣는다. */ button: HTMLButtonElement; + /** 갈래별 표시 토글 버튼(1차 유역 / 2차 유역 / 유역 방향). */ + partButtons: HTMLButtonElement[]; /** 켜져 있는지. draw() 호출 전에 확인한다. */ visible: () => boolean; /** 상태 문구(분석 요약 또는 오류). 없으면 빈 문자열. */ @@ -60,6 +73,24 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { "계획 노선(B03 업로드)과 도엽 등고선·세류선으로 배수유역을 분석합니다. " + "30초 안팎이 걸리며 결과는 영구저장소에 남습니다."; + // 갈래별 표시 여부. 전체 토글(button)이 꺼져 있으면 이 값과 무관하게 아무것도 안 그린다. + const shownParts: Record = { primary: true, basin: true, flow: true }; + const partButtons = PARTS.map((part) => { + const element = document.createElement("button"); + element.type = "button"; + element.className = "b04-map__layer-button b04-map__layer-button--gis is-active"; + element.textContent = part.label; + element.style.setProperty("--b04-layer-color", part.color); + element.setAttribute("aria-pressed", "true"); + 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( @@ -123,6 +154,10 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { 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([]); @@ -158,14 +193,18 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { 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 * (colStart + offset), + left + cellW * col, y, cellW, cellH, cellPx, + sampled ? arrowPx : 0, { sink, invalid, steps }, ); } @@ -182,6 +221,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { cellW: number, cellH: number, cellPx: number, + arrowPx: number, codes: { sink: number; invalid: number; steps: number }, ): void { const azimuth = code & 0x3f; @@ -199,7 +239,8 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { context.lineWidth = 0.5; context.strokeRect(x, y, cellW, cellH); } - if (cellPx < ARROW_MIN_PX || unanalyzed) return; + // arrowPx = 0 이면 표본에서 빠진 셀이라 채움만 하고 끝낸다. + if (arrowPx < ARROW_MIN_PX || unanalyzed) return; const stroke = reaches ? FLOW_TO_ROAD_LINE : FLOW_AWAY_LINE; const midX = x + cellW / 2; const midY = y + cellH / 2; @@ -207,7 +248,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { // 제자리(싱크) — 방향이 없으므로 점으로 표시한다. context.fillStyle = stroke; context.beginPath(); - context.arc(midX, midY, Math.max(1, cellPx * 0.12), 0, Math.PI * 2); + context.arc(midX, midY, Math.max(1, arrowPx * 0.12), 0, Math.PI * 2); context.fill(); return; } @@ -215,23 +256,32 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { const angle = (azimuth * 2 * Math.PI) / codes.steps; const unitX = Math.cos(angle); const unitY = Math.sin(angle); - const reach = cellPx * 0.38; + const reach = arrowPx * 0.38; const tipX = midX + unitX * reach; const tipY = midY + unitY * reach; - context.strokeStyle = stroke; - context.lineWidth = Math.max(0.6, cellPx * 0.09); - context.beginPath(); - context.moveTo(midX - unitX * reach, midY - unitY * reach); - context.lineTo(tipX, tipY); - context.stroke(); - // 촉 — 진행 방향 기준 좌우로 짧게 접는다. - const head = cellPx * 0.18; - context.beginPath(); - 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(); + // 표본 화살표는 채움색 위에서도 읽혀야 하므로 흰 테두리를 한 겹 깔고 그 위에 그린다. + 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차 배수유역 근거를 겹쳐 그린다 — 단계 검증용. */ @@ -372,6 +422,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { return { button, + partButtons, visible: () => shown && analysis !== null, status: () => statusText, reset() { @@ -387,9 +438,10 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { }, draw(context, map, view) { if (!shown || !analysis) return; - drawGridCells(context, map, view, analysis); - drawPrimaryRegion(context, map, view, analysis); - drawBasinAndPipes(context, map, view, analysis); + // 격자·화살표(유역 방향) → 1차 영역 → 2차 유역·관 순으로 아래에서 위로 쌓는다. + if (shownParts.flow) drawGridCells(context, map, view, analysis); + if (shownParts.primary) drawPrimaryRegion(context, map, view, analysis); + if (shownParts.basin) drawBasinAndPipes(context, map, view, analysis); }, }; }