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:
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
# ── ⑨ 관 최소 개수 보충 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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차 전체 배수유역 외곽선 위 포인트를 끌어 경계를 고칩니다. 옮긴 값은 종단 경로 확정 시 저장 여부를 묻습니다.",
|
||||
|
||||
Reference in New Issue
Block a user