Files
Aislo/B05_Profile/B05_Profile_UI_Drainage_Interact.ts
T
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

161 lines
7.6 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Drainage_Interact.ts
* 배수유역 지도의 포인터 조작 — 휠 확대/축소, 가운데 버튼 팬, 배관 마커 잡기,
* 유역 폴리곤 고르기.
*
* 패널 본체(B05_Profile_UI_Drainage_Panel)가 700줄 한계에 닿아 분리했다.
* 화면 변환값(배율·오프셋)은 여전히 본체가 들고 있고 여기서는 접근자로만 읽고 쓴다 —
* 그리기·유역 목록·마커 편집기가 같은 값을 보던 구조를 그대로 두기 위함이다.
* ========================================================================== */
import {
lonLatToScreen,
type Normalizer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { pointInRings } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import type { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import type { createMapContextMenu } from "@ui/ui_template_context_menu";
/** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */
const CLICK_SLOP_PX = 4;
export interface DrainageInteractParams {
viewport: HTMLElement;
contextMenu: ReturnType<typeof createMapContextMenu>;
pipeEditor: ReturnType<typeof createPipeEditor>;
/** 현재 화면 변환(배율·오프셋·지도 사각형). 본체의 것을 그대로 쓴다. */
currentView: () => ViewState;
getScale: () => number;
setScale: (value: number) => void;
getOffset: () => { x: number; y: number };
setOffset: (x: number, y: number) => void;
scheduleDraw: () => void;
/** 유역 고르기에 필요한 현재 상태 — 좌표 변환기가 아직 없으면 고르지 않는다. */
getNormalizer: () => Normalizer | null;
getBasins: () => ReadonlyArray<DetailBasin>;
getSelectedBasin: () => number | null;
/** 유역 강조를 바꾸는 유일한 자리(본체 selectBasin). */
selectBasin: (index: number | null) => void;
}
/** 뷰포트에 포인터·휠 조작을 붙인다. 반환값은 없다 — 리스너는 뷰포트와 수명이 같다. */
export function bindDrainageInteractions(params: DrainageInteractParams): void {
const { viewport, contextMenu, pipeEditor, currentView, scheduleDraw } = params;
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
/** 좌클릭을 시작한 자리 — 끌지 않고 뗐을 때만 유역 고르기로 본다. */
let basinClickStart: { x: number; y: number } | null = null;
/** 마커를 잡은 좌클릭 — 이 경우 유역 고르기로 넘기지 않는다. */
let pipeClickStart: { x: number; y: number } | null = null;
viewport.addEventListener(
"wheel",
(event) => {
event.preventDefault();
const prevScale = params.getScale();
// 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). B04 2D 지도와 같은 방향이다.
const scale = Math.min(16, Math.max(0.5, prevScale * (event.deltaY > 0 ? 1.15 : 0.87)));
params.setScale(scale);
// 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작).
const ratio = scale / prevScale;
const rect = viewport.getBoundingClientRect();
const cursorX = event.clientX - rect.left - rect.width / 2;
const cursorY = event.clientY - rect.top - rect.height / 2;
const offset = params.getOffset();
params.setOffset(
cursorX * (1 - ratio) + offset.x * ratio,
cursorY * (1 - ratio) + offset.y * ratio,
);
scheduleDraw();
},
{ passive: false },
);
viewport.addEventListener("pointerdown", (event) => {
if (contextMenu.contains(event.target)) return;
contextMenu.close();
// 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다.
if (event.button === 1) event.preventDefault();
const rect = viewport.getBoundingClientRect();
// 배관 마커는 **좌클릭으로만** 잡는다 — 우클릭은 메뉴, 가운데 버튼은 팬 전용이다
// (2026-08-01 사용자 지시).
if (
event.button === 0 &&
pipeEditor.handleDown(currentView(), event.clientX - rect.left, event.clientY - rect.top)
) {
// 마커를 잡은 좌클릭이므로 유역 고르기로는 넘기지 않는다.
pipeClickStart = { x: event.clientX, y: event.clientY };
viewport.setPointerCapture(event.pointerId);
return;
}
// 마커를 못 잡은 좌클릭은 유역 고르기 후보로 기억한다(끌지 않고 뗐을 때만 고른다).
if (event.button === 0) basinClickStart = { x: event.clientX, y: event.clientY };
// 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 유역선 핸들·배관 마커를 고르고 끄는 데만 쓴다
// (좌버튼이 팬까지 겸하면 마커를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시).
// 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다.
if (event.pointerType === "mouse" && event.button !== 1) return;
const offset = params.getOffset();
dragStart = { x: event.clientX, y: event.clientY, offsetX: offset.x, offsetY: offset.y };
viewport.style.cursor = "grabbing";
viewport.setPointerCapture(event.pointerId);
});
viewport.addEventListener("pointermove", (event) => {
const rect = viewport.getBoundingClientRect();
// 배관 드래그 중이면 마커 이동(계획선 스냅)만 처리한다.
if (pipeEditor.handleMove(currentView(), event.clientX - rect.left, event.clientY - rect.top))
return;
if (!dragStart) return;
params.setOffset(
dragStart.offsetX + event.clientX - dragStart.x,
dragStart.offsetY + event.clientY - dragStart.y,
);
scheduleDraw();
});
/** 유역 폴리곤을 눌러 고른다. 유역 밖을 누르면 강조를 푼다. */
function pickBasinAt(x: number, y: number): void {
const normalizer = params.getNormalizer();
if (!normalizer) return;
const view = currentView();
let hit: number | null = null;
let smallest = Number.POSITIVE_INFINITY;
params.getBasins().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(normalizer, view, lon, lat)),
);
if (!pointInRings(rings, x, y)) return;
// 겹치면 면적이 작은 쪽을 고른다(안쪽 조각 우선).
if (basin.area_m2 < smallest) {
smallest = basin.area_m2;
hit = basin.index;
}
});
params.selectBasin(hit === params.getSelectedBasin() ? null : hit);
}
const stopDragging = (): void => {
pipeEditor.handleUp();
dragStart = null;
viewport.style.removeProperty("cursor");
};
viewport.addEventListener("pointerup", (event) => {
const start = basinClickStart;
basinClickStart = null;
const dragged = pipeClickStart !== null;
pipeClickStart = null;
if (!dragged && start && event.button === 0) {
const rect = viewport.getBoundingClientRect();
// 끌었으면 지도 조작이지 고르기가 아니다.
if (Math.hypot(event.clientX - start.x, event.clientY - start.y) <= CLICK_SLOP_PX) {
pickBasinAt(event.clientX - rect.left, event.clientY - rect.top);
}
}
stopDragging();
});
viewport.addEventListener("pointercancel", stopDragging);
}