Files
Aislo/B05_Profile/B05_Profile_UI_Drainage_Interact.ts
T
eomsangdonandClaude Opus 5 77f833bb25 feat(B05): 보호공 개편·종단 기준 기본 접힘·700줄 파일 분리 3건
지시 1 — 사이드 「종단 설계 기준」 컨테이너를 기본 접힘으로. section()에
collapsed 인자를 더해 초기 is-collapsed만 붙이고 토글은 전역 collapsible이
그대로 맡는다(B06 buildGroup과 같은 패턴).

지시 4 — 배수관 유입·유출 바닥 보호를 "보호공" 한 축으로 개편:
- 구 돌붙임 있음/없음 + 표면처리(찰/메) 두 축 → 돌붙임(찰)/돌붙임(메)/도수로.
  도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이
  아니라 이 선택지로 받는다(별표2 유출구~원지반 도달 의무)
- "없음" 삭제 — 물이 흐르는 자리라 보호공은 필수(사용자 확정). 기본값은
  유입 돌붙임(찰)·유출 돌붙임(메)
- 치수는 배타 — 돌붙임이면 면적(㎡, 기본 10), 도수로면 폭(m, 기본 1.0)
- 저장 키 개명 *_pitching → *_protection, *_pitching_finish 삭제,
  *_protection_width_m 신설. 독립 기슭막이(D4)도 같은 축
- 구 저장분은 parse_pipe_points가 읽는 순간 새 키로 옮긴다("없음"은 부위
  기본값으로 승격). 폼 write()도 구 키를 폴백으로 읽어 structures.json 몫을 덮는다
- revetment.pitching_area_m2가 기본값과 required를 동시에 갖던 모순도 정리

지시 5 — 700줄 초과 파일 분리(순수 이동 + import 정리, 공개 인터페이스 불변):
- Drainage_Facility 724 → 401 (+_Facility_Fields 354)
- Structures_Panel 753 → 695 (+_Structures_List 113)
- Drainage_Panel 822 → 693 (+_Drainage_Chrome 150, +_Drainage_Interact 159)
- Page 1031 → 911 (+_Page_Helpers 144) — 아직 초과
- Profile_Panel 1152 — 미착수. 두 파일은 가변 클로저 상태를 곳곳에서 직접
  읽고 써 상태 승격이 선행돼야 하며, 프론트 테스트가 없어 순수 이동으로는
  떼어낼 수 없다. 분리안은 PLAN.md 3-3에 기록

검증: pytest tmp/tests 107 passed·7 skipped(신규 보호공 마이그레이션 6건 포함),
npm run typecheck 무오류, npm run build 성공(374 modules).

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

160 lines
7.4 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 { pointInRing } from "./B05_Profile_UI_Drainage_Parts";
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 ring = basin.polygon_lonlat.map(([lon, lat]) =>
lonLatToScreen(normalizer, view, lon, lat),
);
if (!pointInRing(ring, 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);
}