Files
Aislo/B04_PreProcess/B04_PreProcess_UI_Basins.ts
eomsangdonandClaude Opus 5 f1bcee7857 feat(B04): 세부유역 경계를 링 목록(외곽+구멍)으로 넓혀 중첩·빈공간 해소
단일 링 자료형이 곧 중첩·빈공간이었다. 아래 유역이 위 유역을 감싸면 구멍이 사라져 위
유역을 통째로 덮고(합성 실측 256㎡ 전량), 한 관의 유역이 두 조각이면 작은 쪽이 사라져
빈공간이 됐다(144㎡).

- `polygon_parts()` 신설 — 조각마다 [외곽 링, 구멍 링...], 넓은 조각부터.
- `WatershedBasin.boundary_parts` 로 교체. `boundary_xy`(가장 넓은 조각의 외곽)와
  `boundary_rings`(편 링 목록)는 파생 속성이라 옛 소비처는 그대로 동작한다.
- API 에 `polygon_rings_lonlat` 추가. 저장 GeoJSON 은 구멍을 가진 Polygon, 조각이
  여럿이면 MultiPolygon.
- 캔버스 4곳(B04 유역화면 채움·선택, B05 배수유역도 채움·선택)은 even-odd 로 한 번에
  채우고 `pointInRings()` 로 판정 — 구멍 안을 눌러도 바깥 유역이 잡히지 않는다.
  중복이던 지역 `pointInRing` 은 삭제하고 공용 것으로 통일.

검증: 단위 5건 신규(도넛 구멍 보존·조각 2개 보존·파생 속성·빈 경계·조립 경로 전체에서
링 2개), 전체 168 passed, typecheck 통과. 브라우저 — API 가 유역 22개에 링 목록을 실어
보내고, 캔버스 even-odd 실측(가운데 알파 0 / 고리 255)으로 구멍이 실제로 뚫린다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:40:21 +09:00

563 lines
22 KiB
TypeScript

/* =============================================================================
* 상세 배수유역 오버레이 (B04 2D 지도 — 관리자 검토용)
*
* 계획선 위 **관 매설 지점**을 편집하고, 그 관마다 물이 모이는 **세부유역**을 겹쳐 그린다.
*
* · 기본 관 = 도로 × 상류 세류선 교차점 (백엔드 분석 산출물)
* · 자동 보충 = 관 최대 간격을 넘는 구간에 최소 개수로 채운 자리
* · 수동 = 계획선 위에서 우클릭해 넣었거나, 끌어서 옮긴 관
*
* 경계는 여기서 긋지 않는다. 백엔드가 1m 격자 셀마다 "이 셀 물이 어느 도로 셀로 가는가"를
* 이미 풀어 두었고, 도로 셀은 종단 내리막을 따라 담당 관으로 묶인다. 그래서 같은 관으로
* 묶인 셀 덩어리의 바깥선이 곧 세부유역 경계다 — 화면은 그 폴리곤을 받아 칠하기만 한다.
*
* 재계산은 **버튼을 눌렀을 때만** 돈다(2026-08-01 사용자 지시). 편집 중에는 마커만 움직이고,
* 확정 전에 화면을 떠나면 저장된 값으로 되돌아온다.
* ========================================================================== */
import { pointInRings } from "./B04_PreProcess_UI_MapOverlays";
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 {
computeDetailBasins,
fetchDetailPipePoints,
saveDetailPipePoints,
type DetailBasin,
type PipeSource,
type VWorldMeta,
} from "./B04_PreProcess_Api_Fetch";
import {
haloColor,
lonLatToScreen,
metricToScreen,
type Normalizer,
type ViewState,
} from "./B04_PreProcess_UI_MapRender";
import {
nearestChainage,
pointAtChainage,
resampleRoute,
type RoutePoint,
} from "./B04_PreProcess_UI_RouteSamples";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 관 마커 반지름(px)과 잡기 여유(px). 유입 집중점 마커보다 조금 크게 둬서 구분된다. */
const PIPE_RADIUS = 8;
const PIPE_HIT_SLACK = 5;
/** 계획선에서 이보다 멀면 "노선 위 우클릭"으로 보지 않는다(px). */
const ROUTE_HIT_PX = 14;
/** 관 생성 사유별 색. 정의처는 `ui_template_theme.css`. */
const PIPE_COLORS: Record<PipeSource, [token: string, fallback: string]> = {
stream: ["--map-pipe-marker", "#f97316"],
spacing: ["--map-pipe-auto", "#0ea5e9"],
user: ["--map-pipe-user", "#16a34a"],
};
/** 세부유역 채움 — 이웃끼리 구분만 되면 되므로 색상환을 균등 분할해 돌려 쓴다. */
function basinColor(index: number, alpha: number): string {
const hue = (index * 137.508) % 360; // 황금각 — 인접 유역이 비슷한 색으로 붙지 않는다
return `hsla(${hue.toFixed(0)}, 70%, 55%, ${alpha})`;
}
/** 유역 번호 서클 반지름(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;
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;
let cx = 0;
let cy = 0;
for (let index = 0; index < ring.length; index += 1) {
const [x0, y0] = ring[index];
const [x1, y1] = ring[(index + 1) % ring.length];
const cross = x0 * y1 - x1 * y0;
twiceArea += cross;
cx += (x0 + x1) * cross;
cy += (y0 + y1) * cross;
}
if (Math.abs(twiceArea) < 1e-6) {
const sum = ring.reduce((acc, [x, y]) => [acc[0] + x, acc[1] + y] as [number, number], [0, 0]);
return [sum[0] / ring.length, sum[1] / ring.length];
}
return [cx / (3 * twiceArea), cy / (3 * twiceArea)];
}
/** 유역 번호를 서클 숫자로 얹는다. 번호는 시점에서 종점 순(백엔드가 누가거리 순으로 준다). */
function drawBasinNumbers(
context: CanvasRenderingContext2D,
labels: ReadonlyArray<{ index: number; number: number; point: [number, number] }>,
): void {
if (labels.length === 0) return;
context.save();
context.textAlign = "center";
context.textBaseline = "middle";
context.font = "bold 13px sans-serif";
labels.forEach(({ index, number, point: [x, y] }) => {
context.beginPath();
context.arc(x, y, BASIN_NUMBER_RADIUS, 0, Math.PI * 2);
context.fillStyle = basinColor(index, 0.92);
context.fill();
context.lineWidth = 2;
context.strokeStyle = haloColor();
context.stroke();
context.fillStyle = haloColor();
context.fillText(String(number), x, y);
});
context.restore();
}
export interface DetailBasinOverlay {
/** 세부유역 재계산 버튼. */
button: HTMLButtonElement;
/** 표시 토글 — 관 마커와 세부유역을 함께 켜고 끈다(하나로 묶임). */
partButtons: HTMLButtonElement[];
statusElement: HTMLElement;
/** 우클릭 메뉴. 지도 뷰포트에 얹는다(뷰포트 기준 절대 위치). */
menuElement: HTMLElement;
setProject: (projectId: string) => void;
/** 계획선(사업지 좌표계 m)과 배경지도 메타. 둘이 있어야 화면 좌표를 낼 수 있다. */
setRoute: (points: ReadonlyArray<RoutePoint>, meta: VWorldMeta | null) => void;
clear: () => void;
/** 마커를 잡았으면 true(지도 팬 대신 마커 끌기로 넘어간다). */
handlePointerDown: (view: ViewState, x: number, y: number) => boolean;
handlePointerMove: (view: ViewState, x: number, y: number) => boolean;
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;
dispose: () => void;
}
export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverlay {
let projectId: string | null = null;
let meta: VWorldMeta | null = null;
let samples: RoutePoint[] = [];
let pipes: PipeMarker[] = [];
let basins: DetailBasin[] = [];
let zSource = "";
let minSpacing = 20;
let busy = false;
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 menu = createMapContextMenu("b04-map");
const button = document.createElement("button");
button.type = "button";
button.className = "b04-map__layer-button b04-map__layer-button--gis";
button.textContent = L("B04_Surface_Basin_Btn");
button.style.setProperty("--b04-layer-color", themeColor("--map-pipe-user", "#16a34a"));
button.title = L("B04_Surface_Basin_Btn_Tip");
// 관 마커와 세부유역은 늘 같이 본다 — 따로 끄면 관을 옮겨도 유역이 안 보여 판단할 수 없다.
// 그래서 토글은 하나뿐이다(2026-08-01 사용자 지시).
let shown = true;
const partButton = document.createElement("button");
partButton.type = "button";
partButton.className = "b04-map__layer-button b04-map__layer-button--gis is-active";
partButton.textContent = L("B04_Surface_Basin_Part_Basins");
partButton.style.setProperty("--b04-layer-color", themeColor("--map-pipe-user", "#16a34a"));
partButton.setAttribute("aria-pressed", "true");
partButton.addEventListener("click", () => {
shown = !shown;
partButton.classList.toggle("is-active", shown);
partButton.setAttribute("aria-pressed", String(shown));
if (!shown) menu.close();
onChange();
});
const partButtons = [partButton];
function say(text: string): void {
statusElement.textContent = text;
statusElement.hidden = text === "";
}
function summary(): string {
const count = (source: PipeSource): number =>
pipes.filter((pipe) => pipe.source === source).length;
const line = L("B04_Surface_Basin_Summary")
.replace("{pipes}", String(pipes.length))
.replace("{stream}", String(count("stream")))
.replace("{spacing}", String(count("spacing")))
.replace("{user}", String(count("user")))
.replace("{basins}", String(basins.length))
.replace("{source}", zSource || "-");
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 {
if (!meta) return null;
const point = pointAtChainage(samples, chainage);
return point ? metricToScreen(meta, view, point.x, point.y) : null;
}
/** 마커 히트 판정. 가장 가까운 것 하나. */
function hitPipe(view: ViewState, x: number, y: number): number | null {
let hit: number | null = null;
let best = Number.POSITIVE_INFINITY;
pipes.forEach((pipe, index) => {
const screen = pipeScreen(view, pipe.chainage);
if (!screen) return;
const distance = Math.hypot(screen[0] - x, screen[1] - y);
if (distance <= PIPE_RADIUS + PIPE_HIT_SLACK && distance < best) {
hit = index;
best = distance;
}
});
return hit;
}
/** 화면 좌표를 계획선 위 누가거리로 스냅한다. 계획선에서 멀면 null. */
function snap(view: ViewState, x: number, y: number): number | null {
if (!meta) return null;
const found = nearestChainage(
samples,
(point) => metricToScreen(meta as VWorldMeta, view, point.x, point.y),
x,
y,
);
return found && found.distance <= ROUTE_HIT_PX ? found.chainage : null;
}
/** 관끼리 최소 간격을 지키는지 본다(자기 자신은 제외). */
function tooClose(chainage: number, exceptIndex: number | null): boolean {
return pipes.some(
(pipe, index) =>
index !== exceptIndex && Math.abs(pipe.chainage - chainage) < minSpacing - 1e-6,
);
}
/** 화면 좌표를 담고 있는 세부유역 번호. 겹치면 면적이 작은 쪽을 고른다(안쪽 조각 우선). */
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 rings = (basin.polygon_rings_lonlat ?? [basin.polygon_lonlat]).map((ring) =>
ring.map(([lon, lat]) => lonLatToScreen(normalizerRef as Normalizer, view, lon, lat)),
);
if (!pointInRings(rings, 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 apply(response: Awaited<ReturnType<typeof fetchDetailPipePoints>>): void {
pipes = response.pipe_points.map((point) => ({
chainage: point.chainage_m,
source: point.source,
}));
basins = response.basins;
zSource = response.z_source;
minSpacing = response.min_spacing_m;
dirty = false;
// 유역 구성이 바뀌었으므로 이전 강조는 의미를 잃는다.
selectedBasin = null;
say(summary());
}
/** 세부유역을 다시 나눈다. 편집 중인 관 목록을 그대로 보낸다. */
async function recompute(): Promise<void> {
if (!projectId || busy) return;
busy = true;
button.disabled = true;
button.textContent = L("B04_Surface_Basin_Btn_Busy");
const sequence = ++requestSequence;
try {
const response = await computeDetailBasins(
projectId,
pipes.map((pipe) => ({ chainage_m: pipe.chainage, source: pipe.source })),
);
if (sequence !== requestSequence) return;
apply(response);
} catch (error) {
if (sequence !== requestSequence) return;
say(error instanceof Error ? error.message : L("B04_Surface_Basin_Failed"));
} finally {
busy = false;
button.disabled = false;
button.textContent = L("B04_Surface_Basin_Btn");
onChange();
}
}
/** 저장분(없으면 자동 생성분)을 불러온다. 지도를 열 때 조용히 돈다. */
async function loadSaved(): Promise<void> {
if (!projectId) return;
const sequence = ++requestSequence;
try {
const response = await fetchDetailPipePoints(projectId);
if (sequence !== requestSequence) return;
apply(response);
} catch {
if (sequence !== requestSequence) return;
// 배수유역 분석 전이면 여기서 실패하는 것이 정상이다 — 조용히 비워 둔다.
pipes = [];
basins = [];
say("");
}
onChange();
}
button.addEventListener("click", () => {
void recompute();
});
return {
button,
partButtons,
statusElement,
menuElement: menu.element,
setProject(next) {
projectId = next;
pipes = [];
basins = [];
dirty = false;
say("");
void loadSaved();
},
setRoute(points, nextMeta) {
meta = nextMeta;
samples = resampleRoute(points);
},
clear() {
pipes = [];
basins = [];
zSource = "";
dirty = false;
dragging = null;
selectedBasin = null;
requestSequence += 1;
menu.close();
say("");
},
handlePointerDown(view, x, y) {
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) {
if (dragging === null) return false;
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) {
menu.close();
if (!shown) return false;
const hit = hitPipe(view, x, y);
if (hit !== null) {
menu.open(x, y, [
[
L("B04_Surface_Basin_Menu_Delete"),
() => {
pipes.splice(hit, 1);
markEdited();
},
],
]);
return true;
}
const chainage = snap(view, x, y);
if (chainage === null) return false;
menu.open(x, y, [
[
L("B04_Surface_Basin_Menu_Add"),
() => {
if (tooClose(chainage, null)) {
say(L("B04_Surface_Basin_TooClose").replace("{min}", String(minSpacing)));
onChange();
return;
}
pipes.push({ chainage, source: "user" });
pipes.sort((left, right) => left.chainage - right.chainage);
markEdited();
},
],
]);
return true;
},
async commit() {
if (!projectId) return 0;
const response = await saveDetailPipePoints(
projectId,
pipes.map((pipe) => ({ chainage_m: pipe.chainage, source: pipe.source })),
);
apply(response);
onChange();
return response.pipe_count;
},
draw(context, normalizer, view) {
normalizerRef = normalizer;
if (!shown) return;
// 유역 번호는 **맨 마지막에** 얹는다 — 채움과 함께 그리면 관 마커에 가려진다
// (2026-08-01 사용자 지시). 자리는 채움을 그리면서 같이 모아 둔다.
const labels: Array<{ index: number; number: number; point: [number, number] }> = [];
if (normalizer && basins.length > 0) {
// 유역 번호를 얹을 자리는 폴리곤을 그리면서 같이 모은다 — 화면 좌표를 두 번 계산하지
// 않는다. 번호는 채움 위에 한꺼번에 얹어야 이웃 유역 채움에 덮이지 않는다.
context.save();
context.lineJoin = "round";
basins.forEach((basin, index) => {
if (basin.polygon_lonlat.length < 3) return;
const ring = basin.polygon_lonlat.map(([lon, lat]) =>
lonLatToScreen(normalizer, view, lon, lat),
);
// 조각·구멍을 한 경로에 담아 even-odd로 채운다 — 구멍이 실제로 뚫린다.
const rings = (basin.polygon_rings_lonlat ?? [basin.polygon_lonlat]).map((part) =>
part.map(([lon, lat]) => lonLatToScreen(normalizer, view, lon, lat)),
);
context.beginPath();
rings.forEach((part) => {
part.forEach(([px, py], order) => {
if (order === 0) context.moveTo(px, py);
else context.lineTo(px, py);
});
context.closePath();
});
// 하나를 고르면 나머지는 옅게 물러난다 — 고른 유역의 경계를 눈으로 좇을 수 있게.
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("evenodd");
context.strokeStyle = basinColor(index, muted ? 0.3 : 0.95);
context.lineWidth = selectedBasin === basin.index ? 2.8 : 1.8;
context.stroke();
if (!muted) labels.push({ index, number: basin.index, point: ringCentroid(ring) });
});
context.restore();
}
if (pipes.length === 0) {
drawBasinNumbers(context, labels);
return;
}
context.save();
context.textAlign = "center";
context.textBaseline = "middle";
pipes.forEach((pipe, index) => {
const screen = pipeScreen(view, pipe.chainage);
if (!screen) return;
const [x, y] = screen;
const [token, fallback] = PIPE_COLORS[pipe.source];
context.beginPath();
context.arc(x, y, PIPE_RADIUS, 0, Math.PI * 2);
context.fillStyle = themeColor(token, fallback);
context.fill();
context.lineWidth = index === dragging ? 3 : 1.6;
context.strokeStyle = haloColor();
context.stroke();
context.font = "bold 10px sans-serif";
context.fillStyle = themeColor("--map-marker-text", "#111827");
context.fillText(String(index + 1), x, y);
});
context.restore();
// 유역 번호는 맨 마지막 — 관 마커에도 가리지 않게 한다(2026-08-01 사용자 지시).
drawBasinNumbers(context, labels);
},
dispose() {
requestSequence += 1;
menu.close();
},
};
}