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
@@ -19,6 +19,11 @@ import {
export type FilledRing = {
ring: ReadonlyArray<readonly [number, number]>;
/**
* 조각·구멍을 모두 편 링 목록. 주면 even-odd로 한 번에 채워 **구멍이 뚫린다** —
* 아래 유역이 위 유역을 감싸는 도넛에서 위 유역을 덮지 않는다. 없으면 `ring` 하나만.
*/
rings?: ReadonlyArray<ReadonlyArray<readonly [number, number]>>;
/** 면적 중심에 얹을 번호. 없으면 라벨을 그리지 않는다. */
label?: string;
};
@@ -35,19 +40,27 @@ export function drawFilledRing(
color: string,
): void {
if (entry.ring.length < 3) return;
const rings = entry.rings?.length ? entry.rings : [entry.ring];
let sumX = 0;
let sumY = 0;
context.beginPath();
entry.ring.forEach(([lon, lat], index) => {
rings.forEach((ring) => {
if (ring.length < 3) return;
ring.forEach(([lon, lat], index) => {
const [x, y] = lonLatToScreen(normalizer, view, lon, lat);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.closePath();
});
// 번호 자리는 바깥 링만 보고 잡는다 — 구멍까지 섞으면 중심이 유역 밖으로 밀린다.
entry.ring.forEach(([lon, lat]) => {
const [x, y] = lonLatToScreen(normalizer, view, lon, lat);
sumX += x;
sumY += y;
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.closePath();
context.fillStyle = color;
context.fill();
context.fill("evenodd");
context.strokeStyle = color;
context.lineWidth = 1.6;
context.stroke();
@@ -87,6 +100,26 @@ export function drawRingBadge(
}
/** 폴리곤 정점 평균의 화면 좌표 — 배지를 얹을 자리. */
/**
* 점이 조각·구멍으로 이루어진 유역 안에 있는가 — 링마다 홀짝을 뒤집는 even-odd 판정.
* 구멍(안에 든 다른 유역) 안을 누르면 바깥 유역이 잡히지 않는다.
*/
export function pointInRings(
rings: ReadonlyArray<ReadonlyArray<readonly [number, number]>>,
x: number,
y: number,
): boolean {
let inside = false;
for (const ring of rings) {
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;
}
export function ringCenterOnScreen(
ring: ReadonlyArray<readonly [number, number]>,
normalizer: Normalizer,