단일 링 자료형이 곧 중첩·빈공간이었다. 아래 유역이 위 유역을 감싸면 구멍이 사라져 위 유역을 통째로 덮고(합성 실측 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>
161 lines
7.6 KiB
TypeScript
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);
|
|
}
|