- 계획노선 사용 범위: 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>
168 lines
8.1 KiB
TypeScript
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);
|
|
}
|