diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Basins.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Basins.py index e1c2caa49..74a7695e4 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_Basins.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Basins.py @@ -93,6 +93,8 @@ def _payload( "max_spacing_m": DRAINAGE_PIPE_MAX_SPACING_M, "min_spacing_m": DRAINAGE_PIPE_MIN_SPACING_M, "saved": saved, + # 도로 1m 구간별 유입 면적 — 계획선을 색으로 칠하는 데 쓴다(B05와 같은 값). + "strength_profile": detail.strength_profile, "pipe_points": [ { "chainage_m": round(pipe.chainage_m, 2), diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Basins.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Basins.ts index 9b0f1f27e..c13700b78 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Basins.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Basins.ts @@ -15,6 +15,7 @@ * 확정 전에 화면을 떠나면 저장된 값으로 되돌아온다. * ========================================================================== */ +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 { @@ -65,11 +66,32 @@ function basinColor(index: number, alpha: number): string { /** 유역 번호 서클 반지름(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; + +/** 화면 좌표 폴리곤 안에 점이 있는지(홀짝 규칙). 유역을 눌러 고를 때 쓴다. */ +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; +} + 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; @@ -132,6 +154,8 @@ export interface DetailBasinOverlay { 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; @@ -150,15 +174,19 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl 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 menuElement = document.createElement("div"); - menuElement.className = "b04-map__context-menu"; - menuElement.hidden = true; + const menu = createMapContextMenu("b04-map"); const button = document.createElement("button"); button.type = "button"; @@ -180,7 +208,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl shown = !shown; partButton.classList.toggle("is-active", shown); partButton.setAttribute("aria-pressed", String(shown)); - if (!shown) closeMenu(); + if (!shown) menu.close(); onChange(); }); const partButtons = [partButton]; @@ -200,7 +228,16 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl .replace("{user}", String(count("user"))) .replace("{basins}", String(basins.length)) .replace("{source}", zSource || "-"); - return dirty ? `${line} · ${L("B04_Surface_Basin_Edited")}` : line; + 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 { @@ -245,35 +282,40 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl ); } + /** 화면 좌표를 담고 있는 세부유역 번호. 겹치면 면적이 작은 쪽을 고른다(안쪽 조각 우선). */ + 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 ring = basin.polygon_lonlat.map(([lon, lat]) => + lonLatToScreen(normalizerRef as Normalizer, view, lon, lat), + ); + if (!pointInRing(ring, 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 closeMenu(): void { - menuElement.hidden = true; - menuElement.replaceChildren(); - } - - function openMenu(x: number, y: number, items: Array<[string, () => void]>): void { - menuElement.replaceChildren(); - items.forEach(([label, action]) => { - const entry = document.createElement("button"); - entry.type = "button"; - entry.className = "b04-map__context-menu-item"; - entry.textContent = label; - entry.addEventListener("click", () => { - closeMenu(); - action(); - }); - menuElement.append(entry); - }); - menuElement.style.left = `${x}px`; - menuElement.style.top = `${y}px`; - menuElement.hidden = false; - } - /** 응답을 화면 상태로 옮긴다. */ function apply(response: Awaited>): void { pipes = response.pipe_points.map((point) => ({ @@ -284,6 +326,8 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl zSource = response.z_source; minSpacing = response.min_spacing_m; dirty = false; + // 유역 구성이 바뀌었으므로 이전 강조는 의미를 잃는다. + selectedBasin = null; say(summary()); } @@ -338,7 +382,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl button, partButtons, statusElement, - menuElement, + menuElement: menu.element, setProject(next) { projectId = next; pipes = []; @@ -357,16 +401,18 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl zSource = ""; dirty = false; dragging = null; + selectedBasin = null; requestSequence += 1; - closeMenu(); + menu.close(); say(""); }, handlePointerDown(view, x, y) { - closeMenu(); + 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) { @@ -374,23 +420,39 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl 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) { - closeMenu(); + menu.close(); if (!shown) return false; const hit = hitPipe(view, x, y); if (hit !== null) { - openMenu(x, y, [ + menu.open(x, y, [ [ L("B04_Surface_Basin_Menu_Delete"), () => { @@ -403,7 +465,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl } const chainage = snap(view, x, y); if (chainage === null) return false; - openMenu(x, y, [ + menu.open(x, y, [ [ L("B04_Surface_Basin_Menu_Add"), () => { @@ -431,6 +493,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl return response.pipe_count; }, draw(context, normalizer, view) { + normalizerRef = normalizer; if (!shown) return; if (normalizer && basins.length > 0) { // 유역 번호를 얹을 자리는 폴리곤을 그리면서 같이 모은다 — 화면 좌표를 두 번 계산하지 @@ -449,12 +512,19 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl else context.lineTo(px, py); }); context.closePath(); - context.fillStyle = basinColor(index, 0.22); + // 하나를 고르면 나머지는 옅게 물러난다 — 고른 유역의 경계를 눈으로 좇을 수 있게. + 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(); - context.strokeStyle = basinColor(index, 0.95); - context.lineWidth = 1.8; + context.strokeStyle = basinColor(index, muted ? 0.3 : 0.95); + context.lineWidth = selectedBasin === basin.index ? 2.8 : 1.8; context.stroke(); - labels.push({ index, number: basin.index, point: ringCentroid(ring) }); + if (!muted) labels.push({ index, number: basin.index, point: ringCentroid(ring) }); }); context.restore(); drawBasinNumbers(context, labels); @@ -483,7 +553,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl }, dispose() { requestSequence += 1; - closeMenu(); + menu.close(); }, }; } diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp.ts new file mode 100644 index 000000000..9449b87a0 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp.ts @@ -0,0 +1,102 @@ +/* ============================================================================= + * 도로 유입 강도 색띠 (B04 2D 지도 · B05 배수유역도 공용) + * + * 계획선 **1m 구간마다** 그 자리로 모이는 상류 면적을 색으로 칠한다. 두 화면이 같은 값을 + * 같은 색으로 보여야 대조가 되므로 색띠와 정규화 규칙을 여기 한 곳에 둔다. + * + * 색 단계는 로그 스케일이다. 계곡 한 지점이 사면보다 수백 배 크기 때문에 선형으로 칠하면 + * 몇 점만 빨갛고 나머지는 전부 파랑으로 뭉친다(2026-08-01 사용자 지시). + * ========================================================================== */ + +import { themeColor } from "@ui/ui_template_palette"; +import type { RoutePoint } from "./B04_wf1_Surface_UI_RouteSamples"; + +/** 강도 색띠 — 파랑(적음) → 빨강(많음). 정의처는 `ui_template_theme.css`. */ +const RAMP_TOKENS: ReadonlyArray<[name: string, fallback: string]> = [ + ["--map-flow-ramp-0", "#2563eb"], + ["--map-flow-ramp-1", "#06b6d4"], + ["--map-flow-ramp-2", "#22c55e"], + ["--map-flow-ramp-3", "#eab308"], + ["--map-flow-ramp-4", "#f97316"], + ["--map-flow-ramp-5", "#dc2626"], +]; + +/** 강도 선 굵기(px). 계획선(2.4)보다 굵어야 그 위에 얹힌 것으로 읽힌다. */ +export const STRENGTH_LINE_WIDTH = 5; + +/** `#rrggbb` → [r,g,b]. 색띠는 hex로만 정의한다(보간을 위해). */ +function parseHex(color: string): [number, number, number] { + const hex = color.trim().replace("#", ""); + const full = + hex.length === 3 + ? hex + .split("") + .map((c) => c + c) + .join("") + : hex; + const value = Number.parseInt(full.slice(0, 6), 16); + return [(value >> 16) & 255, (value >> 8) & 255, value & 255]; +} + +/** 0~1을 색띠 위에서 보간한다. */ +export function rampColor(t: number): string { + const clamped = Math.min(1, Math.max(0, t)); + const last = RAMP_TOKENS.length - 1; + const position = clamped * last; + const low = Math.min(last, Math.floor(position)); + const high = Math.min(last, low + 1); + const ratio = position - low; + const a = parseHex(themeColor(RAMP_TOKENS[low][0], RAMP_TOKENS[low][1])); + const b = parseHex(themeColor(RAMP_TOKENS[high][0], RAMP_TOKENS[high][1])); + const mix = (index: number): number => Math.round(a[index] + (b[index] - a[index]) * ratio); + return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`; +} + +/** 색띠 위치를 정하는 정규화(로그 스케일). 최대가 0이면 전부 0. */ +export function normalizeStrength(value: number, maximum: number): number { + if (maximum <= 0 || value <= 0) return 0; + return Math.log1p(value) / Math.log1p(maximum); +} + +/** 백엔드가 준 [누가거리, 면적] 목록을 인덱스=누가거리(m) 배열로 편다. */ +export function buildStrengthArray(profile: ReadonlyArray): { + strength: Float64Array; + maximum: number; +} { + const length = profile.reduce((max, [chainage]) => Math.max(max, chainage), 0); + const strength = new Float64Array(Math.floor(length) + 1); + profile.forEach(([chainage, area]) => { + const index = Math.round(chainage); + if (index >= 0 && index < strength.length) strength[index] = area; + }); + // 노선이 길면 점이 수만 개가 되므로 spread(Math.max(...))로 최대를 구하지 않는다. + const maximum = strength.reduce((max, value) => (value > max ? value : max), 0); + return { strength, maximum }; +} + +/** 1m 재표본 계획선 위에 강도 색을 칠한다. 화면 변환은 호출부가 넘긴다. */ +export function drawStrengthLine( + context: CanvasRenderingContext2D, + samples: ReadonlyArray, + strength: Float64Array, + maximum: number, + toScreen: (point: RoutePoint) => [number, number], +): void { + if (samples.length < 2 || strength.length === 0) return; + context.save(); + context.lineWidth = STRENGTH_LINE_WIDTH; + context.lineCap = "round"; + const limit = Math.min(strength.length, samples.length - 1); + for (let index = 0; index < limit; index += 1) { + const value = strength[index]; + if (value <= 0) continue; // 유입 없는 구간은 계획선 원래 색을 그대로 둔다 + const [x0, y0] = toScreen(samples[index]); + const [x1, y1] = toScreen(samples[index + 1]); + context.strokeStyle = rampColor(normalizeStrength(value, maximum)); + context.beginPath(); + context.moveTo(x0, y0); + context.lineTo(x1, y1); + context.stroke(); + } + context.restore(); +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts index 14b6fba57..b734e3b6a 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts @@ -20,24 +20,18 @@ import { type Normalizer, type ViewState, } from "./B04_wf1_Surface_UI_MapRender"; +import { + buildStrengthArray, + drawStrengthLine, + normalizeStrength, + rampColor, +} from "./B04_wf1_Surface_UI_FlowRamp"; import { pointAtChainage, resampleRoute, type RoutePoint } from "./B04_wf1_Surface_UI_RouteSamples"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -/** 강도 색띠 — 파랑(적음) → 빨강(많음). 정의처는 `ui_template_theme.css`. */ -const RAMP_TOKENS: ReadonlyArray<[name: string, fallback: string]> = [ - ["--map-flow-ramp-0", "#2563eb"], - ["--map-flow-ramp-1", "#06b6d4"], - ["--map-flow-ramp-2", "#22c55e"], - ["--map-flow-ramp-3", "#eab308"], - ["--map-flow-ramp-4", "#f97316"], - ["--map-flow-ramp-5", "#dc2626"], -]; - -/** 강도 선 굵기(px). 계획선(2.4)보다 굵어야 그 위에 얹힌 것으로 읽힌다. */ -const STRENGTH_LINE_WIDTH = 5; /** 마커 반지름(px) — 강도 최소~최대. 작게 둬서 지도를 가리지 않는다(사용자 지시). */ const MARKER_MIN_RADIUS = 4; const MARKER_MAX_RADIUS = 10; @@ -69,34 +63,6 @@ export interface FlowStrengthOverlay { dispose: () => void; } -/** `#rrggbb` → [r,g,b]. 색띠는 hex로만 정의한다(보간을 위해). */ -function parseHex(color: string): [number, number, number] { - const hex = color.trim().replace("#", ""); - const full = - hex.length === 3 - ? hex - .split("") - .map((c) => c + c) - .join("") - : hex; - const value = Number.parseInt(full.slice(0, 6), 16); - return [(value >> 16) & 255, (value >> 8) & 255, value & 255]; -} - -/** 0~1을 색띠 위에서 보간한다. */ -function rampColor(t: number): string { - const clamped = Math.min(1, Math.max(0, t)); - const last = RAMP_TOKENS.length - 1; - const position = clamped * last; - const low = Math.min(last, Math.floor(position)); - const high = Math.min(last, low + 1); - const ratio = position - low; - const a = parseHex(themeColor(RAMP_TOKENS[low][0], RAMP_TOKENS[low][1])); - const b = parseHex(themeColor(RAMP_TOKENS[high][0], RAMP_TOKENS[high][1])); - const mix = (index: number): number => Math.round(a[index] + (b[index] - a[index]) * ratio); - return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`; -} - /** 면적을 사람이 읽는 문구로. 1ha 이상은 ha로 줄인다. */ function formatArea(areaM2: number): string { return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`; @@ -161,28 +127,7 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve /** 로그 스케일 정규화 — 계곡 한 점이 사면보다 수백 배라 선형으로는 못 읽는다. */ function normalize(value: number): number { - if (maxStrength <= 0 || value <= 0) return 0; - return Math.log1p(value) / Math.log1p(maxStrength); - } - - function drawStrengthLine(context: CanvasRenderingContext2D, view: ViewState): void { - if (!meta || samples.length < 2 || strength.length === 0) return; - context.save(); - context.lineWidth = STRENGTH_LINE_WIDTH; - context.lineCap = "round"; - const limit = Math.min(strength.length, samples.length - 1); - for (let i = 0; i < limit; i += 1) { - const value = strength[i]; - if (value <= 0) continue; // 유입 없는 구간은 계획선 원래 색을 그대로 둔다 - const [x0, y0] = metricToScreen(meta, view, samples[i].x, samples[i].y); - const [x1, y1] = metricToScreen(meta, view, samples[i + 1].x, samples[i + 1].y); - context.strokeStyle = rampColor(normalize(value)); - context.beginPath(); - context.moveTo(x0, y0); - context.lineTo(x1, y1); - context.stroke(); - } - context.restore(); + return normalizeStrength(value, maxStrength); } function markerRadius(area: number): number { @@ -287,14 +232,9 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve projectId = nextProjectId; }, setData(profile, spots) { - const length = profile.reduce((max, [chainage]) => Math.max(max, chainage), 0); - strength = new Float64Array(Math.floor(length) + 1); - profile.forEach(([chainage, area]) => { - const index = Math.round(chainage); - if (index >= 0 && index < strength.length) strength[index] = area; - }); - // 노선이 길면 점이 수만 개가 되므로 spread(Math.max(...))로 최대를 구하지 않는다. - maxStrength = strength.reduce((max, value) => (value > max ? value : max), 0); + const built = buildStrengthArray(profile); + strength = built.strength; + maxStrength = built.maximum; hotspots = spots.map(([chainage, area, zone, rank]) => ({ chainage, area, zone, rank })); selected = null; selectionRings = []; @@ -364,7 +304,12 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve return true; }, draw(context, normalizer, view) { - if (shown) drawStrengthLine(context, view); + if (shown && meta) { + const mapMeta = meta; + drawStrengthLine(context, samples, strength, maxStrength, (point) => + metricToScreen(mapMeta, view, point.x, point.y), + ); + } // 집중점 마커와 그 유입 외곽선은 전용 토글이 켜졌을 때만 그린다. if (!markersShown) return; drawSelection(context, normalizer, view); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index acaf99e86..feca464e0 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -604,7 +604,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > CLICK_SLOP_PX) return; const rect = viewport.getBoundingClientRect(); const view = currentView(rect); - flowStrength.handleClick(normalizer, view, event.clientX - rect.left, event.clientY - rect.top); + 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(); diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index b6ef26e18..5e4e11b40 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -325,6 +325,8 @@ export interface DrainageBasinResponse { boundary_dropped: number; /** 유역 안쪽 상류 세류망 — 하이라이트 토글용. */ upstream_lonlat: Array>; + /** 도로 1m 구간별 유입 면적 — [누가거리, 면적]. 계획선 색칠에 쓴다(B04 지도와 같은 값). */ + strength_profile?: Array<[number, number]>; /** B04 해석 격자 한 변(m). */ grid_cell_m: number; /** 평균 흐름 화살표 — [x, y(사업지 CRS m), 방위(도), 도로도달, 셀 수]. */ diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 88761208d..27c1ff2d4 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -122,6 +122,8 @@ async def post_drainage_basins( "arrow_spacing_m": detail.arrow_spacing_m, # 유역 안쪽 상류 세류망 — 화면 강조 토글용(WGS84). "upstream_lonlat": detail.upstream_lonlat, + # 도로 1m 구간별 유입 면적 — 계획선을 색으로 칠하는 데 쓴다(B04와 같은 값). + "strength_profile": detail.strength_profile, # 계획선 위 배관 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록. "pipes": [_candidate_payload(pipe, to_lonlat) for pipe in detail.pipes], "basins": [ diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 0ccaadb70..44a1f6bab 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -2,7 +2,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { themeColor } from "@ui/ui_template_palette"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; +import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { fetchVWorldMeta, getVWorldMapUrl, @@ -32,68 +32,43 @@ import { type RoutePoint, } from "./B05_wf2_Route_Api_Fetch"; import { drawFlowArrows, type FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows"; +import { + buildStrengthArray, + drawStrengthLine, +} from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp"; +import { resampleRoute } from "../B04_wf1_Surface/B04_wf1_Surface_UI_RouteSamples"; import { createBoundaryEditor, type BoundaryOverrideEntry, } from "./B05_wf2_Route_UI_Drainage_Boundary"; import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes"; import { createProgressCircle } from "@ui/ui_template_progress"; +import { createMapContextMenu } from "@ui/ui_template_context_menu"; +import { + addLayerToggle, + arrowToggleColor, + basinColor, + bindPipeContextMenu, + COLLAPSED_KEY, + DRAINAGE_LAYERS, + LAYER_LABEL_KEYS, + layerColor, + MAX_PANEL_WIDTH_RATIO, + MIN_PANEL_WIDTH, + createMetricProjector, + renderBasinRows, + satelliteToggleColor, + strengthToggleColor, + upstreamToggleColor, + WIDTH_KEY, + type DrainageLayer, +} from "./B05_wf2_Route_UI_Drainage_Parts"; // 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널. // 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동). // 지도는 B04에서 분리한 렌더 엔진(B04_wf1_Surface_UI_MapRender)을 그대로 재사용해 // 사전 투영·LOD·뷰포트 컬링·커서 중심 줌 동작을 동일하게 얻는다. -/** 배수유역 산정의 근거가 되는 도엽 레이어. 3D는 쓰지 않는다(사용자 지시). - * 표고점은 유효 데이터가 적어 산정에서 제외했으므로 배경에도 띄우지 않는다(2026-07-31). */ -const DRAINAGE_LAYERS = DRAINAGE_SHEET_LAYERS; -type DrainageLayer = (typeof DRAINAGE_LAYERS)[number]; - -/** 색 값의 정의처는 `ui_template_theme.css`(`--map-*`)다. 여기는 이름만 잇는다. */ -const LAYER_COLOR_TOKENS: Record = { - 도엽_등고선: ["--map-sheet-contour", "#a5b4fc"], - 도엽_하천중심선: ["--map-sheet-stream", "#2563eb"], -}; - -const layerColor = (layer: DrainageLayer): string => themeColor(...LAYER_COLOR_TOKENS[layer]); - -const LAYER_LABEL_KEYS: Record = { - 도엽_등고선: "B05_Drainage_Layer_Contour", - 도엽_하천중심선: "B05_Drainage_Layer_Stream", -}; - -/** 도엽 레이어가 아닌 표시 토글의 띠 색 — 지도에 그려지는 선 색과 맞춘다. */ -const arrowToggleColor = (): string => themeColor("--map-flow-arrow", "#7c3aed"); -const upstreamToggleColor = (): string => themeColor("--map-upstream-toggle", "#1d4ed8"); -/** 위성사진은 선이 아니라 배경이라 맞출 선 색이 없다 — 중립 회색을 띠 색으로 쓴다. */ -const satelliteToggleColor = (): string => themeColor("--map-satellite-toggle", "#64748b"); - -const COLLAPSED_KEY = "b05-route-drainage-collapsed"; -/** 드래그로 조절한 패널 폭(px) 보관 키 — 브라우저 세션 동안만 유지한다. */ -const WIDTH_KEY = "b05-route-drainage-width"; -/** 지도가 담기는 최소 폭(px). CSS의 min-width와 같은 값. */ -const MIN_PANEL_WIDTH = 320; -/** 상한은 하단 패널 폭의 70%까지(사용자 지시) — 종단면도가 최소한 30%는 남아야 한다. */ -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종을 넘어가면 처음부터 다시 쓴다. */ -function basinColor(index: number): string { - const slot = (index - 1) % BASIN_COLOR_FALLBACKS.length; - return themeColor(`--map-basin-${slot + 1}`, BASIN_COLOR_FALLBACKS[slot]); -} - function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } @@ -133,12 +108,6 @@ export function createDrainagePanel(): DrainagePanel { analyzeButton.className = "b05-drainage__analyze"; analyzeButton.textContent = L("B05_Drainage_Btn_Analyze"); analyzeButton.title = L("B05_Drainage_Btn_Analyze_Tip"); - // 배관 편집 토글 — 켜면 계획선 클릭으로 배관 추가, 마커 드래그로 이동. - const editButton = document.createElement("button"); - editButton.type = "button"; - editButton.className = "b05-drainage__analyze b05-drainage__tool"; - editButton.textContent = L("B05_Drainage_Btn_PipeEdit"); - editButton.setAttribute("aria-pressed", "false"); // 선택된 배관 삭제 — 편집 모드에서 마커를 선택해야 활성화된다. const deleteButton = document.createElement("button"); deleteButton.type = "button"; @@ -150,6 +119,7 @@ export function createDrainagePanel(): DrainagePanel { autoButton.type = "button"; autoButton.className = "b05-drainage__analyze b05-drainage__tool"; autoButton.textContent = L("B05_Drainage_Btn_Auto"); + autoButton.title = L("B05_Drainage_Btn_Auto_Tip"); // 유역선 편집 토글 — 켜면 외곽선 위 핸들을 잡아 유역 경계를 손으로 고친다. const boundaryButton = document.createElement("button"); boundaryButton.type = "button"; @@ -157,7 +127,7 @@ export function createDrainagePanel(): DrainagePanel { boundaryButton.textContent = L("B05_Drainage_Btn_BoundaryEdit"); boundaryButton.title = L("B05_Drainage_Btn_BoundaryEdit_Tip"); boundaryButton.setAttribute("aria-pressed", "false"); - header.append(analyzeButton, editButton, deleteButton, autoButton, boundaryButton); + header.append(analyzeButton, deleteButton, autoButton, boundaryButton); const viewport = document.createElement("div"); viewport.className = "b05-drainage__viewport"; @@ -173,7 +143,9 @@ export function createDrainagePanel(): DrainagePanel { // 지도 정중앙 로딩 서클 — 배경도·도엽 레이어·유역 산정이 끝날 때까지 화면이 비어 보인다. const progress = createProgressCircle({ overlay: true }); progress.root.hidden = true; - viewport.append(backgroundImage, canvas, status, progress.root); + // 배관 추가·삭제 우클릭 메뉴 — 편집 토글을 없앤 대신 이쪽으로 옮겼다(B04 지도와 같은 조작). + const contextMenu = createMapContextMenu("b05-drainage"); + viewport.append(backgroundImage, canvas, status, progress.root, contextMenu.element); /** 진행률(0~1, 모르면 null)과 문구. label이 null이면 서클을 감춘다. */ function showProgress(ratio: number | null, label: string | null): void { @@ -206,12 +178,15 @@ export function createDrainagePanel(): DrainagePanel { let normalizer: Normalizer | null = null; let basins: DrainageBasin[] = []; let selectedBasin: number | null = null; - let editMode = false; // 배관 편집기 — 마커 선택/추가/이동/삭제 시 재그리기와 버튼 상태만 갱신한다. - const pipeEditor = createPipeEditor(() => { - syncPipeSelection(); - scheduleDraw(); - }); + const pipeEditor = createPipeEditor( + () => { + syncPipeSelection(); + scheduleDraw(); + }, + // 배치가 실제로 바뀐 순간(추가·삭제·이동 완료)에만 세부유역을 다시 나눈다. + () => void analyze(), + ); // 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다 // (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시). let mainBoundary: Array<[number, number]> = []; @@ -222,6 +197,11 @@ export function createDrainagePanel(): DrainagePanel { // 유역 안쪽 상류 세류망 — B04가 채택한 기준선을 그대로 받아 강조만 한다. let upstreamLines: Array> = []; let showUpstream = true; + // 계획선 위 유입 강도 색칠 — B04 지도와 같은 색띠·같은 값(2026-08-01 사용자 지시). + let strengthSamples: RoutePoint[] = []; + let strength: Float64Array = new Float64Array(0); + let maxStrength = 0; + let showStrength = true; let boundaryMode = false; const boundaryEditor = createBoundaryEditor(() => scheduleDraw()); let scale = 1; @@ -234,67 +214,59 @@ export function createDrainagePanel(): DrainagePanel { let canvasHeight = 0; let canvasDpr = 0; - /** 제목 우측 표시 토글 — 등고선·세류·흐름 화살표·상류 세류가 모두 같은 양식을 쓴다. */ - function addLayerToggle( - 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); - scheduleDraw(); - }); - layerButtons.append(button); - return button; - } - // 배경 위성사진 — 등고선 앞에 둔다(2026-08-01 사용자 지시). 사진이 어두워 유역 채움색이 // 묻힐 때 끄고 본다. 캔버스가 아니라 배경 이미지라 표시 여부만 직접 바꾼다. addLayerToggle( + layerButtons, L("B05_Drainage_Layer_Satellite"), satelliteToggleColor(), true, (next) => { backgroundImage.hidden = !next; + scheduleDraw(); }, L("B05_Drainage_Layer_Satellite_Tip"), ); DRAINAGE_LAYERS.forEach((layer) => { - addLayerToggle(L(LAYER_LABEL_KEYS[layer]), layerColor(layer), true, (next) => { + addLayerToggle(layerButtons, L(LAYER_LABEL_KEYS[layer]), layerColor(layer), true, (next) => { if (next) activeLayers.add(layer); else activeLayers.delete(layer); + scheduleDraw(); }); }); // 흐름 화살표 — 도면이 지저분해질 때 끄기 위한 토글(등고선·세류와 같은 줄·같은 양식). addLayerToggle( + layerButtons, L("B05_Drainage_Layer_Arrows"), arrowToggleColor(), showArrows, (next) => { showArrows = next; + scheduleDraw(); }, L("B05_Drainage_Layer_Arrows_Tip"), ); + // 유입 강도 색칠 — 노선 1m 구간별 상류 면적. B04 지도와 같은 색띠를 쓴다. + addLayerToggle( + layerButtons, + L("B05_Drainage_Layer_Strength"), + strengthToggleColor(), + showStrength, + (next) => { + showStrength = next; + scheduleDraw(); + }, + L("B05_Drainage_Layer_Strength_Tip"), + ); // 상류 세류선 강조 — 유역 판정의 기준선이라 항상 같은 굵기·색으로 얹는다. addLayerToggle( + layerButtons, L("B05_Drainage_Layer_Upstream"), upstreamToggleColor(), showUpstream, (next) => { showUpstream = next; + scheduleDraw(); }, L("B05_Drainage_Layer_Upstream_Tip"), ); @@ -361,24 +333,23 @@ export function createDrainagePanel(): DrainagePanel { context.strokeStyle = routeLineColor(); drawPreparedLayer(context, routeLayer, view, "dot"); } + // 유입 강도 색칠 — 계획선 위, 배관 마커 아래. 색띠는 B04 지도와 공용이다. + if (showStrength && meta && strength.length > 0) { + const { toScreen } = createMetricProjector(meta, view); + drawStrengthLine(context, strengthSamples, strength, maxStrength, (point) => + toScreen(point.x, point.y), + ); + } // 평균 흐름 화살표 — 유역 채움 위, 배관 마커 아래. 좌표는 사업지 CRS(m)라 // 도엽 메타로 바로 화면에 옮긴다(배관 마커와 같은 변환). if (showArrows && meta && flowArrows.length > 0) { - const spanX = meta.width_meters || 1; - const spanY = meta.height_meters || 1; - const pxPerMeter = (view.mapRect.width * view.scale) / spanX; - const ax = view.mapRect.width * view.scale; - const bx = view.mapRect.x * view.scale + (width / 2) * (1 - view.scale) + view.offsetX; - const ay = view.mapRect.height * view.scale; - const by = view.mapRect.y * view.scale + (height / 2) * (1 - view.scale) + view.offsetY; - const originX = meta.x_min; - const originY = meta.y_min; + const projector = createMetricProjector(meta, view); drawFlowArrows( context, flowArrows, arrowSpacingM, - pxPerMeter, - (x, y) => [((x - originX) / spanX) * ax + bx, (1 - (y - originY) / spanY) * ay + by], + projector.pxPerMeter, + projector.toScreen, view, ); } @@ -407,7 +378,7 @@ export function createDrainagePanel(): DrainagePanel { /** 마커 선택 ↔ 유역 목록 선택 동기화 + 삭제 버튼 활성화. */ function syncPipeSelection(): void { const index = pipeEditor.selected(); - deleteButton.disabled = !editMode || index === null; + deleteButton.disabled = index === null; const pipe = index === null ? null : pipeEditor.pipes()[index]; const basin = pipe ? basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51) @@ -425,47 +396,15 @@ export function createDrainagePanel(): DrainagePanel { } /** 유역 제원 목록을 다시 그린다. 항목을 누르면 해당 유역만 진하게 강조한다. */ + /** 유역 제원 목록을 다시 그린다. 항목을 누르면 그 유역만 진하게 강조한다. */ function renderBasinList(): void { - basinList.textContent = ""; - basinList.hidden = basins.length === 0; - basins.forEach((basin) => { - const row = document.createElement("button"); - row.type = "button"; - row.className = "b05-drainage__basin" + (selectedBasin === 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"; - // 관경은 수식 미확정이라 백엔드가 null을 주며, 확정 전까지 "미정"으로 표기한다. - const pipe = - basin.pipe_diameter_mm === null - ? L("B05_Drainage_Basin_Undecided") - : `Ø${Math.round(basin.pipe_diameter_mm)}mm`; - 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), - ); - row.append(badge, metrics); - row.addEventListener("click", () => { - selectedBasin = selectedBasin === basin.index ? null : basin.index; - renderBasinList(); - scheduleDraw(); - }); - basinList.append(row); + renderBasinRows(basinList, basins, selectedBasin, (index) => { + selectedBasin = selectedBasin === index ? null : index; + renderBasinList(); + scheduleDraw(); }); } - function formatArea(areaM2: number): string { - return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`; - } - /** 구조물 측점 후보 제안 + 유역 산정을 백엔드에 요청한다(계산은 전부 백엔드). * 편집된 배관이 있으면 그 누가거리로 확정 산정하고, auto=true면 자동 제안으로 되돌린다. */ async function analyze(auto = false): Promise { @@ -484,6 +423,11 @@ export function createDrainagePanel(): DrainagePanel { upstreamLines = (response.upstream_lonlat ?? []) as Array>; flowArrows = (response.flow_arrows ?? []) as FlowArrow[]; arrowSpacingM = response.arrow_spacing_m ?? 0; + const built = buildStrengthArray( + (response.strength_profile ?? []) as ReadonlyArray, + ); + strength = built.strength; + maxStrength = built.maximum; // 계획도로선·2차 유역 외곽선은 B04 산출물을 그대로 받는다 — 여기서 다시 계산하지 않는다. // 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함). pipeEditor.setPipes( @@ -509,12 +453,6 @@ export function createDrainagePanel(): DrainagePanel { } analyzeButton.addEventListener("click", () => void analyze()); - editButton.addEventListener("click", () => { - editMode = !editMode; - editButton.classList.toggle("is-active", editMode); - editButton.setAttribute("aria-pressed", String(editMode)); - syncPipeSelection(); - }); deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected()); autoButton.addEventListener("click", () => { pipeEditor.setPipes([]); @@ -629,7 +567,11 @@ export function createDrainagePanel(): DrainagePanel { }, { passive: false }, ); + bindPipeContextMenu(viewport, contextMenu, pipeEditor, currentView); + viewport.addEventListener("pointerdown", (event) => { + if (contextMenu.contains(event.target)) return; + contextMenu.close(); // 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다. if (event.button === 1) event.preventDefault(); const rect = viewport.getBoundingClientRect(); @@ -647,14 +589,7 @@ export function createDrainagePanel(): DrainagePanel { return; } // 배관 마커 클릭/추가가 처리되면 지도 팬은 시작하지 않는다. - if ( - pipeEditor.handleDown( - currentView(), - event.clientX - rect.left, - event.clientY - rect.top, - editMode, - ) - ) { + if (pipeEditor.handleDown(currentView(), event.clientX - rect.left, event.clientY - rect.top)) { viewport.setPointerCapture(event.pointerId); return; } @@ -720,6 +655,8 @@ export function createDrainagePanel(): DrainagePanel { setRoute(points) { routePoints = points; routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null; + // 강도 색칠은 백엔드가 준 1m 구간 값과 인덱스를 맞춰야 하므로 같은 규칙으로 다시 찍는다. + strengthSamples = resampleRoute(points); pipeEditor.setContext(meta, points); if (routeLayer) fitToRoute(); scheduleDraw(); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts new file mode 100644 index 000000000..c41518b1b --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts @@ -0,0 +1,201 @@ +/* ============================================================================= + * 배수유역도 패널 부품 (B05) + * + * 레이어 목록·색·보관 키 같은 상수와, 상태를 갖지 않는 DOM 조각(표시 토글 버튼, 유역 제원 + * 목록)을 모았다. 패널 본체(`B05_wf2_Route_UI_Drainage_Panel.ts`)가 700줄 한계에 닿아 + * 분리한 것으로, 여기 있는 것들은 패널의 내부 상태를 알지 못한다 — 전부 인자로 받는다. + * ========================================================================== */ + +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { themeColor } from "@ui/ui_template_palette"; +import { DRAINAGE_SHEET_LAYERS } from "../A00_Common/b_asset_cache"; +import type { VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; +import type { ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; +import type { MapContextMenu } from "@ui/ui_template_context_menu"; +import type { DrainageBasin } from "./B05_wf2_Route_Api_Fetch"; +import type { PipeEditor } from "./B05_wf2_Route_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 satelliteToggleColor = (): string => themeColor("--map-satellite-toggle", "#64748b"); + +export const COLLAPSED_KEY = "b05-route-drainage-collapsed"; +/** 드래그로 조절한 패널 폭(px) 보관 키 — 브라우저 세션 동안만 유지한다. */ +export const WIDTH_KEY = "b05-route-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`으로 번호를 돌려준다. */ +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"; + // 관경은 수식 미확정이라 백엔드가 null을 주며, 확정 전까지 "미정"으로 표기한다. + const pipe = + basin.pipe_diameter_mm === null + ? L("B05_Drainage_Basin_Undecided") + : `Ø${Math.round(basin.pipe_diameter_mm)}mm`; + 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)); + row.append(badge, metrics); + row.addEventListener("click", () => onPick(basin.index)); + container.append(row); + }); +} + +/** 사업지 좌표계(m) → 화면 px 변환기. 흐름 화살표와 강도 색칠이 같은 식을 쓴다 — + * 둘이 어긋나면 색은 계획선 위인데 화살표만 밀린 것처럼 보인다. */ +export function createMetricProjector( + meta: VWorldMeta, + view: ViewState, +): { toScreen: (x: number, y: number) => [number, number]; pxPerMeter: number } { + const spanX = meta.width_meters || 1; + const spanY = meta.height_meters || 1; + 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; + return { + toScreen: (x, y) => [ + ((x - meta.x_min) / spanX) * ax + bx, + (1 - (y - meta.y_min) / spanY) * ay + by, + ], + pxPerMeter: ax / spanX, + }; +} + +/** 배관 우클릭 메뉴를 지도 뷰포트에 붙인다 — 마커 위면 삭제, 계획선 위면 추가. + + * 배관 편집 토글을 없애면서 추가·삭제를 이쪽으로 옮겼다(2026-08-01 사용자 지시). 계획선을 + * 그냥 누르는 것으로 추가하면 유역을 고르려 할 때마다 배관이 생기기 때문이다. */ +export function bindPipeContextMenu( + viewport: HTMLElement, + menu: MapContextMenu, + editor: PipeEditor, + viewOf: () => ViewState, +): 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(); + menu.open(x, y, [[L("B05_Drainage_Menu_Add"), () => void editor.addAt(view, x, y)]]); + }); +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts index ab1b90ef6..8e67fcc9b 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts @@ -30,8 +30,14 @@ export interface PipeEditor { selected(): number | null; select(index: number | null): void; deleteSelected(): boolean; + /** 화면 좌표에 있는 마커 번호(없으면 null). 우클릭 메뉴가 무엇을 띄울지 정하는 데 쓴다. */ + hitAt(view: ViewState, screenX: number, screenY: number): number | null; + /** 그 자리에 배관을 넣을 수 있는지(계획선에 충분히 가까운지)만 본다. */ + canAddAt(view: ViewState, screenX: number, screenY: number): boolean; + /** 계획선 위 그 자리에 배관을 넣는다. 노선에서 멀면 false. */ + addAt(view: ViewState, screenX: number, screenY: number): boolean; /** 편집 상호작용. 처리했으면 true(패널은 지도 팬을 생략한다). */ - handleDown(view: ViewState, screenX: number, screenY: number, editMode: boolean): boolean; + handleDown(view: ViewState, screenX: number, screenY: number): boolean; handleMove(view: ViewState, screenX: number, screenY: number): boolean; handleUp(): boolean; draw( @@ -41,7 +47,12 @@ export interface PipeEditor { ): void; } -export function createPipeEditor(onChange: () => void): PipeEditor { +/** `onChange`는 화면 갱신용, `onCommit`은 배치가 실제로 바뀌었을 때(추가·삭제·이동 완료). + * 세부유역을 다시 나눠야 하는 시점이 정확히 `onCommit`이다(2026-08-01 사용자 지시). */ +export function createPipeEditor( + onChange: () => void, + onCommit: () => void = () => {}, +): PipeEditor { let meta: VWorldMeta | null = null; let route: Array<{ x: number; y: number; chainage: number }> = []; let totalChainage = 0; @@ -158,25 +169,26 @@ export function createPipeEditor(onChange: () => void): PipeEditor { pipeList.splice(selectedIndex, 1); selectedIndex = null; onChange(); + onCommit(); return true; }, - handleDown(view, screenX, screenY, editMode) { - dragMoved = false; - // 마커 클릭: 선택 (편집 모드 여부 무관), 편집 모드면 드래그 시작. + hitAt(view, screenX, screenY) { for (let i = pipeList.length - 1; i >= 0; i -= 1) { const xy = chainageToXY(pipeList[i].chainage_m); if (!xy) continue; const screen = metricToScreen(view, xy.x, xy.y); if (!screen) continue; - if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) { - selectedIndex = i; - if (editMode) draggingIndex = i; - onChange(); - return true; - } + if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) return i; } - if (!editMode) return false; - // 계획선 클릭: 그 지점에 배관 추가. + return null; + }, + canAddAt(view, screenX, screenY) { + const metric = screenToMetric(view, screenX, screenY); + if (!metric) return false; + const nearest = nearestChainage(metric.x, metric.y); + return !!nearest && nearest.distance * pxPerMeter(view) <= ADD_SNAP_PX; + }, + addAt(view, screenX, screenY) { const metric = screenToMetric(view, screenX, screenY); if (!metric) return false; const nearest = nearestChainage(metric.x, metric.y); @@ -186,10 +198,29 @@ export function createPipeEditor(onChange: () => void): PipeEditor { selectedIndex = pipeList.findIndex( (pipe) => Math.abs(pipe.chainage_m - nearest.chainage) < 1e-6, ); - draggingIndex = selectedIndex; onChange(); + onCommit(); return true; }, + handleDown(view, screenX, screenY) { + dragMoved = false; + // 마커를 잡으면 곧바로 끌 수 있다 — 편집 모드를 따로 켜지 않는다(2026-08-01 사용자 지시). + // 계획선을 그냥 누르는 것은 여기서 아무 일도 하지 않는다. 그렇게 두면 유역을 고르려고 + // 지도를 누를 때마다 배관이 생긴다. 추가는 우클릭 메뉴로만 한다. + for (let i = pipeList.length - 1; i >= 0; i -= 1) { + const xy = chainageToXY(pipeList[i].chainage_m); + if (!xy) continue; + const screen = metricToScreen(view, xy.x, xy.y); + if (!screen) continue; + if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) { + selectedIndex = i; + draggingIndex = i; + onChange(); + return true; + } + } + return false; + }, handleMove(view, screenX, screenY) { if (draggingIndex === null) return false; const metric = screenToMetric(view, screenX, screenY); @@ -209,6 +240,8 @@ export function createPipeEditor(onChange: () => void): PipeEditor { if (dragMoved) { sortPipes(); onChange(); + // 관을 옮겼으면 담당 구간이 달라진다 — 세부유역을 즉시 다시 나눈다. + onCommit(); } return true; }, diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 4053c8613..bf4b73ccc 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -924,6 +924,39 @@ opacity: 1; } +/* 배관 우클릭 메뉴 — 뷰포트 기준 절대 위치(B04 지도와 같은 조작). */ +.b05-drainage__context-menu { + position: absolute; + z-index: 6; + display: flex; + flex-direction: column; + min-width: 130px; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: 0 4px 16px rgb(0 0 0 / 25%); +} + +.b05-drainage__context-menu[hidden] { + display: none; +} + +.b05-drainage__context-menu-item { + padding: var(--spacing-8) var(--spacing-12); + border: 0; + border-radius: var(--radius-cards); + background: transparent; + color: var(--color-text-body); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.b05-drainage__context-menu-item:hover { + background: var(--color-surface-sunken); +} + .b05-drainage__viewport { position: relative; flex: 1 1 auto; diff --git a/common_util/common_util_drainage_detail.py b/common_util/common_util_drainage_detail.py index 71d40a8c6..1f927e920 100644 --- a/common_util/common_util_drainage_detail.py +++ b/common_util/common_util_drainage_detail.py @@ -69,6 +69,8 @@ class DrainageDetail: arrow_spacing_m: float = 0.0 # 유역 안쪽 상류 세류망(WGS84 lon/lat 조각들). 화면 강조 표시용 — 계산에는 쓰지 않는다. upstream_lonlat: list[list[list[float]]] = field(default_factory=list) + # 도로 1m 구간별 유입 면적(㎡) — [누가거리, 면적]. 계획선을 색으로 칠하는 데 쓴다. + strength_profile: list[list[float]] = field(default_factory=list) @dataclass @@ -154,6 +156,7 @@ def build_detail( flow_arrows=routing.flow_arrows, arrow_spacing_m=routing.arrow_spacing_m, upstream_lonlat=read_upstream_lines(directory), + strength_profile=build_strength_profile(routing), ) if not pipes: return detail @@ -283,6 +286,16 @@ def read_upstream_lines(directory: Path) -> list[list[list[float]]]: return lines +def build_strength_profile(routing: RoadRouting) -> list[list[float]]: + """도로 1m 구간별 유입 면적(㎡) 곡선을 응답용으로 정리한다. + + 값이 0인 구간은 빼고 보낸다 — 노선이 길면 대부분이 0이라 그대로 보내면 응답만 커진다. + 화면은 받은 구간만 색칠하고 나머지는 계획선 원래 색을 남긴다. + """ + curve = routing.strength_curve + return [[float(index), float(value)] for index, value in enumerate(curve.tolist()) if value > 0] + + # ── ⑨ 관 최소 개수 보충 ───────────────────────────────────────────────────── diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts new file mode 100644 index 000000000..cda675d83 --- /dev/null +++ b/ui_template/ui_template_context_menu.ts @@ -0,0 +1,57 @@ +/* ============================================================================= + * 지도 우클릭 메뉴 (B04 2D 지도 · B05 배수유역도 공용) + * + * 지도 위 객체를 우클릭했을 때 뜨는 작은 메뉴. 뷰포트의 자식으로 넣고 뷰포트 기준 절대 + * 위치로 띄운다. + * + * **주의**: 메뉴는 뷰포트 안에 있으므로 메뉴 위에서 난 pointer 이벤트가 지도 조작으로 + * 새어 들어간다. 걸러 내지 않으면 메뉴를 닫는 처리가 항목의 click보다 먼저 돌아 아무 일도 + * 일어나지 않는다(2026-08-01 B04에서 실제로 발생). 그래서 `contains()`를 함께 제공한다 — + * 지도 쪽 pointerdown/pointerup/contextmenu는 이 판정으로 먼저 걸러야 한다. + * ========================================================================== */ + +export interface MapContextMenu { + /** 뷰포트에 append할 요소. */ + element: HTMLElement; + /** 항목은 [문구, 눌렀을 때 할 일] 짝. 위치는 뷰포트 기준 px. */ + open: (x: number, y: number, items: Array<[label: string, action: () => void]>) => void; + close: () => void; + /** 이벤트가 메뉴 안에서 났는지. 지도 조작에서 걸러 내는 데 쓴다. */ + contains: (target: EventTarget | null) => boolean; +} + +/** `blockClass`는 페이지 CSS 블록명(예: `b04-map`, `b05-drainage`). */ +export function createMapContextMenu(blockClass: string): MapContextMenu { + const element = document.createElement("div"); + element.className = `${blockClass}__context-menu`; + element.hidden = true; + + function close(): void { + element.hidden = true; + // 항목까지 비운다 — 남겨 두면 다음에 열었을 때 옛 동작이 함께 걸린다. + element.replaceChildren(); + } + + return { + element, + open(x, y, items) { + element.replaceChildren(); + items.forEach(([label, action]) => { + const entry = document.createElement("button"); + entry.type = "button"; + entry.className = `${blockClass}__context-menu-item`; + entry.textContent = label; + entry.addEventListener("click", () => { + close(); + action(); + }); + element.append(entry); + }); + element.style.left = `${x}px`; + element.style.top = `${y}px`; + element.hidden = false; + }, + close, + contains: (target) => target instanceof Node && element.contains(target), + }; +} diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index a2e79df66..5b5f1e9c8 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -747,6 +747,11 @@ export const ui_locales = { "관끼리 최소 간격 {min}m 안에는 넣을 수 없습니다.", "Culverts cannot be closer than {min}m.", ], + /* {index}=유역 번호, {chainage}=관 측점, {area}=유역면적, {relief}=표고차, {flow}=유하장 */ + B04_Surface_Basin_Selected: [ + "선택 유역 {index} · 측점 {chainage}m — 면적 {area} · 표고차 {relief}m · 유하장 {flow}m", + "Basin {index} · Station {chainage}m — Area {area} · Relief {relief}m · Path {flow}m", + ], B04_Surface_Basin_Failed: [ "상세유역을 계산하지 못했습니다.", "Failed to compute the sub-basins.", @@ -818,9 +823,19 @@ export const ui_locales = { "B04에서 분석해 둔 배수유역을 불러와 관을 보충하고 세부유역을 나눕니다. 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.", "Loads the drainage analysis from B04, fills in culverts, and splits sub-basins. Run the analysis in B04 first if none exists.", ], - B05_Drainage_Btn_PipeEdit: ["배관 편집", "Edit culverts"], B05_Drainage_Btn_DeleteSelected: ["선택 삭제", "Delete selected"], - B05_Drainage_Btn_Auto: ["자동 제안", "Auto suggestion"], + B05_Drainage_Btn_Auto: ["초기화", "Reset"], + B05_Drainage_Btn_Auto_Tip: [ + "손으로 넣거나 옮긴 배관을 버리고 기본 관 + 자동 보충 배치로 되돌립니다.", + "Discards manual culvert edits and restores the automatic placement.", + ], + B05_Drainage_Menu_Add: ["배관 추가", "Add culvert"], + B05_Drainage_Menu_Delete: ["배관 삭제", "Remove culvert"], + B05_Drainage_Layer_Strength: ["흐름 강도", "Flow strength"], + B05_Drainage_Layer_Strength_Tip: [ + "노선 1m 구간마다 그 자리로 모이는 상류 면적을 색으로 칠합니다(B04 지도와 같은 색띠).", + "Colors each 1m stretch of the route by the upstream area draining into it (same ramp as the B04 map).", + ], B05_Drainage_Btn_BoundaryEdit: ["유역선 편집", "Edit basin outline"], B05_Drainage_Btn_BoundaryEdit_Tip: [ "2차 전체 배수유역 외곽선 위 포인트를 끌어 경계를 고칩니다. 옮긴 값은 종단 경로 확정 시 저장 여부를 묻습니다.",