feat(B04/B05): 유역 선택 강조, B05 배관 상시 드래그·자동 재산정, 강도 색칠 공용화

B04
- 세부유역을 눌러 고르면 그 유역만 진하게, 나머지는 옅게 물러난다. 관 마커를
  눌러도 그 관이 받는 유역이 골라지고, 유역 밖을 누르면 강조가 풀린다.
- 고른 유역의 제원(측점·면적·표고차·유하장)을 상태 줄에 표기한다.

B05
- "배관 편집" 토글을 없애고 마커를 언제나 끌 수 있게 했다. 계획선을 그냥 누르는
  것으로는 배관이 생기지 않는다 — 유역을 고르려 할 때마다 배관이 생기기 때문이다.
  추가·삭제는 B04와 같은 우클릭 메뉴로 옮겼다.
- 배관을 옮기거나 넣거나 지우면 세부유역을 즉시 다시 나눈다(격자 해석 없음).
- "자동 제안" 버튼 이름을 "초기화"로 바꿨다.
- 노선 유입 강도 색칠을 B04 지도와 같은 색띠로 추가했다(토글 포함).

공용화
- common_util_drainage_detail: 1m 구간별 유입 면적 곡선을 응답에 포함(B04·B05 동일).
- B04_wf1_Surface_UI_FlowRamp.ts: 색띠·정규화·강도선 렌더러를 두 화면이 공유.
- ui_template_context_menu.ts: 지도 우클릭 메뉴를 공용 부품으로 분리.
- B05_wf2_Route_UI_Drainage_Parts.ts 신설로 패널을 700줄 규칙 안으로 되돌림(677줄).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 22:36:02 +09:00
co-authored by Claude Opus 5
parent 83f23aa43f
commit 8c37f2f6f3
14 changed files with 698 additions and 282 deletions
@@ -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),
+109 -39
View File
@@ -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<number>;
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<ReturnType<typeof fetchDetailPipePoints>>): 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();
},
};
}
@@ -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<readonly [number, number]>): {
strength: Float64Array<ArrayBuffer>;
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<RoutePoint>,
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();
}
@@ -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);
@@ -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();
+2
View File
@@ -325,6 +325,8 @@ export interface DrainageBasinResponse {
boundary_dropped: number;
/** 유역 안쪽 상류 세류망 — 하이라이트 토글용. */
upstream_lonlat: Array<Array<[number, number]>>;
/** 도로 1m 구간별 유입 면적 — [누가거리, 면적]. 계획선 색칠에 쓴다(B04 지도와 같은 값). */
strength_profile?: Array<[number, number]>;
/** B04 해석 격자 한 변(m). */
grid_cell_m: number;
/** 평균 흐름 화살표 — [x, y(사업지 CRS m), 방위(도), 도로도달, 셀 수]. */
@@ -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": [
+92 -155
View File
@@ -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<DrainageLayer, [name: string, fallback: string]> = {
도엽_등고선: ["--map-sheet-contour", "#a5b4fc"],
도엽_하천중심선: ["--map-sheet-stream", "#2563eb"],
};
const layerColor = (layer: DrainageLayer): string => themeColor(...LAYER_COLOR_TOKENS[layer]);
const LAYER_LABEL_KEYS: Record<DrainageLayer, keyof typeof ui_locales> = {
도엽_등고선: "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<Array<[number, number]>> = [];
let showUpstream = true;
// 계획선 위 유입 강도 색칠 — B04 지도와 같은 색띠·같은 값(2026-08-01 사용자 지시).
let strengthSamples: RoutePoint[] = [];
let strength: Float64Array<ArrayBuffer> = 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<void> {
@@ -484,6 +423,11 @@ export function createDrainagePanel(): DrainagePanel {
upstreamLines = (response.upstream_lonlat ?? []) as Array<Array<[number, number]>>;
flowArrows = (response.flow_arrows ?? []) as FlowArrow[];
arrowSpacingM = response.arrow_spacing_m ?? 0;
const built = buildStrengthArray(
(response.strength_profile ?? []) as ReadonlyArray<readonly [number, number]>,
);
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();
@@ -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<DrainageLayer, [name: string, fallback: string]> = {
도엽_등고선: ["--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<DrainageLayer, keyof typeof ui_locales> = {
도엽_등고선: "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<DrainageBasin>,
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)]]);
});
}
@@ -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;
},
+33
View File
@@ -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;
@@ -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]
# ── ⑨ 관 최소 개수 보충 ─────────────────────────────────────────────────────
+57
View File
@@ -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),
};
}
+17 -2
View File
@@ -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차 전체 배수유역 외곽선 위 포인트를 끌어 경계를 고칩니다. 옮긴 값은 종단 경로 확정 시 저장 여부를 묻습니다.",