단일 링 자료형이 곧 중첩·빈공간이었다. 아래 유역이 위 유역을 감싸면 구멍이 사라져 위 유역을 통째로 덮고(합성 실측 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>
187 lines
6.5 KiB
TypeScript
187 lines
6.5 KiB
TypeScript
/* =============================================================================
|
|
* 2D 지도 오버레이 도형 (B04 지도 · B05 배수유역도 공용)
|
|
*
|
|
* 유역 채움·번호 배지·분수령 파선·상류 세류망처럼 **배경 레이어 위에 얹는** 도형만 모았다.
|
|
* 좌표 변환과 레이어 렌더는 `B04_PreProcess_UI_MapRender.ts`가 맡는다 — 그 파일이 700줄
|
|
* 한계에 닿아 분리했다.
|
|
* ========================================================================== */
|
|
|
|
import { themeColor } from "@ui/ui_template_palette";
|
|
|
|
/** 상류 세류망 강조선 색. 정의처는 `ui_template_theme.css`(`--map-upstream`). */
|
|
const upstreamLineColor = (): string => themeColor("--map-upstream", "rgba(29, 78, 216, 0.95)");
|
|
import {
|
|
haloColor,
|
|
lonLatToScreen,
|
|
type Normalizer,
|
|
type ViewState,
|
|
} from "./B04_PreProcess_UI_MapRender";
|
|
|
|
export type FilledRing = {
|
|
ring: ReadonlyArray<readonly [number, number]>;
|
|
/**
|
|
* 조각·구멍을 모두 편 링 목록. 주면 even-odd로 한 번에 채워 **구멍이 뚫린다** —
|
|
* 아래 유역이 위 유역을 감싸는 도넛에서 위 유역을 덮지 않는다. 없으면 `ring` 하나만.
|
|
*/
|
|
rings?: ReadonlyArray<ReadonlyArray<readonly [number, number]>>;
|
|
/** 면적 중심에 얹을 번호. 없으면 라벨을 그리지 않는다. */
|
|
label?: string;
|
|
};
|
|
|
|
/**
|
|
* lon/lat 폴리곤 링을 파스텔 채움 + 테두리 + 중심 번호로 그린다.
|
|
* 사전 투영 캐시를 쓰지 않는 소량(유역 수 개) 오버레이 전용이라 매 프레임 변환해도 부담이 없다.
|
|
*/
|
|
export function drawFilledRing(
|
|
context: CanvasRenderingContext2D,
|
|
entry: FilledRing,
|
|
normalizer: Normalizer,
|
|
view: ViewState,
|
|
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();
|
|
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;
|
|
});
|
|
context.fillStyle = color;
|
|
context.fill("evenodd");
|
|
context.strokeStyle = color;
|
|
context.lineWidth = 1.6;
|
|
context.stroke();
|
|
if (!entry.label) return;
|
|
drawRingBadge(context, [sumX / entry.ring.length, sumY / entry.ring.length], color, entry.label);
|
|
}
|
|
|
|
/** 유역 번호 배지 한 개(라운드 사각 + 숫자). 화면 좌표를 그대로 받는다.
|
|
*
|
|
* 채움과 따로 떼어 둔 이유: 번호는 **맨 위에** 있어야 한다. 채움과 함께 그리면 그 위에
|
|
* 얹히는 등고선·화살표·관 마커에 가려 읽을 수 없다(2026-08-01 사용자 지시).
|
|
*
|
|
* 원이 아니라 사각인 이유: 지도 위 다른 숫자들도 전부 동그라미라 크기로만 갈렸다.
|
|
* 형태를 바꿔 한눈에 구분되게 했다 — 크기는 그대로다(2026-08-17 사용자 지시). */
|
|
export function drawRingBadge(
|
|
context: CanvasRenderingContext2D,
|
|
center: readonly [number, number],
|
|
color: string,
|
|
label: string,
|
|
): void {
|
|
const [centerX, centerY] = center;
|
|
const half = 11;
|
|
context.save();
|
|
context.beginPath();
|
|
context.roundRect(centerX - half, centerY - half, half * 2, half * 2, 6);
|
|
context.fillStyle = color;
|
|
context.fill();
|
|
context.strokeStyle = haloColor();
|
|
context.lineWidth = 1.5;
|
|
context.stroke();
|
|
context.font = "600 12px sans-serif";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
context.fillStyle = themeColor("--map-label-text", "#1f2937");
|
|
context.fillText(label, centerX, centerY);
|
|
context.restore();
|
|
}
|
|
|
|
/** 폴리곤 정점 평균의 화면 좌표 — 배지를 얹을 자리. */
|
|
/**
|
|
* 점이 조각·구멍으로 이루어진 유역 안에 있는가 — 링마다 홀짝을 뒤집는 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,
|
|
view: ViewState,
|
|
): [number, number] {
|
|
let sumX = 0;
|
|
let sumY = 0;
|
|
ring.forEach(([lon, lat]) => {
|
|
const [x, y] = lonLatToScreen(normalizer, view, lon, lat);
|
|
sumX += x;
|
|
sumY += y;
|
|
});
|
|
return [sumX / ring.length, sumY / ring.length];
|
|
}
|
|
|
|
/** 상류 세류망 강조 — B04 분석 오버레이와 B05 배수유역도가 같은 굵기·색으로 그린다. */
|
|
export function drawUpstreamLines(
|
|
context: CanvasRenderingContext2D,
|
|
lines: ReadonlyArray<ReadonlyArray<readonly [number, number]>>,
|
|
normalizer: Normalizer,
|
|
view: ViewState,
|
|
): void {
|
|
if (lines.length === 0) return;
|
|
context.save();
|
|
context.setLineDash([]);
|
|
context.lineWidth = 4;
|
|
context.lineCap = "round";
|
|
context.lineJoin = "round";
|
|
context.strokeStyle = upstreamLineColor();
|
|
lines.forEach((line) => {
|
|
if (line.length < 2) return;
|
|
context.beginPath();
|
|
line.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.stroke();
|
|
});
|
|
context.restore();
|
|
}
|
|
|
|
/** 유역 경계(분수령=능선)를 능선 스타일(갈색 파선)로 강조해 그린다. */
|
|
export function drawRidgeRing(
|
|
context: CanvasRenderingContext2D,
|
|
ring: ReadonlyArray<readonly [number, number]>,
|
|
normalizer: Normalizer,
|
|
view: ViewState,
|
|
): void {
|
|
if (ring.length < 3) return;
|
|
context.beginPath();
|
|
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();
|
|
context.save();
|
|
context.strokeStyle = themeColor("--map-basin-outline", "#92400e");
|
|
context.lineWidth = 1.8;
|
|
context.setLineDash([7, 4]);
|
|
context.stroke();
|
|
context.restore();
|
|
}
|