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>
This commit is contained in:
2026-09-03 12:40:21 +09:00
co-authored by Claude Opus 5
parent d140d04766
commit f1bcee7857
8 changed files with 116 additions and 35 deletions
+16 -19
View File
@@ -15,6 +15,7 @@
* 확정 전에 화면을 떠나면 저장된 값으로 되돌아온다.
* ========================================================================== */
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";
@@ -71,17 +72,6 @@ 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;
@@ -289,10 +279,11 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
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),
// 구멍 안(= 안에 든 다른 유역)을 누르면 바깥 유역이 잡히지 않는다.
const rings = (basin.polygon_rings_lonlat ?? [basin.polygon_lonlat]).map((ring) =>
ring.map(([lon, lat]) => lonLatToScreen(normalizerRef as Normalizer, view, lon, lat)),
);
if (!pointInRing(ring, x, y)) return;
if (!pointInRings(rings, x, y)) return;
if (basin.area_m2 < smallest) {
smallest = basin.area_m2;
hit = basin.index;
@@ -508,12 +499,18 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
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();
ring.forEach(([px, py], order) => {
if (order === 0) context.moveTo(px, py);
else context.lineTo(px, py);
rings.forEach((part) => {
part.forEach(([px, py], order) => {
if (order === 0) context.moveTo(px, py);
else context.lineTo(px, py);
});
context.closePath();
});
context.closePath();
// 하나를 고르면 나머지는 옅게 물러난다 — 고른 유역의 경계를 눈으로 좇을 수 있게.
const muted = selectedBasin !== null && selectedBasin !== basin.index;
const alpha = muted
@@ -522,7 +519,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
? BASIN_ALPHA_SELECTED
: BASIN_ALPHA_PLAIN;
context.fillStyle = basinColor(index, alpha);
context.fill();
context.fill("evenodd");
context.strokeStyle = basinColor(index, muted ? 0.3 : 0.95);
context.lineWidth = selectedBasin === basin.index ? 2.8 : 1.8;
context.stroke();