Files
Aislo/B05_Profile/B05_Profile_UI_Drainage_Interact.ts
eomsangdonandClaude Opus 5 4e4bfa2354 feat(B02/B03/B04/B05): 계획노선 사용 범위 · 절단 여유 3m · 배수유역도 줌·측점 표기
- 계획노선 사용 범위: B02 등록에 시작·종료 누가거리 두 칸 추가, B01 수정 모달에서도
  변경. projects.route_start_m·route_end_m 신설(015_route_range.sql).
  load_design_route 가 범위 절단 → 서피스 트림 순서로 적용. 시작 >= 종료는 화면·서버
  양쪽에서 차단. 비우면 전 구간으로 종전과 같음.
- 서피스 절단 여유 기본값 30m → 3m (SURFACE_ROUTE_EDGE_TRIM_M).
- B04 지도·B05 배수유역도 줌 상한을 「화면 폭 20m」 기준으로 계산(고정 8배·16배 폐지).
  4배를 넘으면 배경 그림 흐림 보간 해제.
- 계획선 위 측점 눈금·번호 표기(측점번호+잔여거리). 관 마커와 겹치면 반대쪽으로 밀고,
  되꺾임 구간에서 라벨이 겹치면 건너뜀. 그리기 코드는 두 화면 공용.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 19:08:11 +09:00

168 lines
8.1 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Drainage_Interact.ts
* 배수유역 지도의 포인터 조작 — 휠 확대/축소, 가운데 버튼 팬, 배관 마커 잡기,
* 유역 폴리곤 고르기.
*
* 패널 본체(B05_Profile_UI_Drainage_Panel)가 700줄 한계에 닿아 분리했다.
* 화면 변환값(배율·오프셋)은 여전히 본체가 들고 있고 여기서는 접근자로만 읽고 쓴다 —
* 그리기·유역 목록·마커 편집기가 같은 값을 보던 구조를 그대로 두기 위함이다.
* ========================================================================== */
import {
computeMaxScale,
lonLatToScreen,
type Normalizer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
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;
/** 배율 상한을 도엽 실폭으로 계산하기 위한 메타 (2026-09-04). 없으면 종전 고정 상한. */
getMeta: () => VWorldMeta | null;
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 지도와 같은 방향이다.
// 상한은 「화면 폭 20m」로 계산한다 — B04 지도와 같은 규칙(2026-09-04 사용자 지시).
const view = currentView();
const maxScale = computeMaxScale(params.getMeta(), view.mapRect.width, view.width, 16);
const scale = Math.min(maxScale, 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);
}