import { themeColor } from "@ui/ui_template_palette"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; // 배관(관 매설) 지점 편집기 — 배수유역 패널의 계획선 위 마커 표시·추가·이동·삭제. // 마커 위치의 단일 소스는 누가거리(chainage)다. 화면 좌표는 매 프레임 노선 // 폴리라인(사업지 좌표계 m)을 따라 보간해 구하므로 확대/이동과 무관하게 정확하다. /** 배관 지점 1개. reason: stream(세류 교차)/spacing(300m 보충)/confirmed(사용자 확정). */ export interface PipePoint { chainage_m: number; reason: string; } interface RoutePointLike { x: number; y: number; chainage_m?: number; } /** 마커 히트 판정 반경(px)과 계획선 추가 클릭 허용 거리(px). */ const HIT_RADIUS_PX = 12; /** 마커 이동이 시작되는 최소 끌기 거리(px) — 이 안에서는 클릭(선택)으로 본다. */ const DRAG_START_PX = 8; const ADD_SNAP_PX = 14; export interface PipeEditor { setContext(meta: VWorldMeta | null, points: ReadonlyArray): void; setPipes(pipes: ReadonlyArray): void; pipes(): ReadonlyArray; chainages(): number[]; selected(): number | null; select(index: number | null): void; deleteSelected(): boolean; /** 화면 좌표에 있는 마커 번호(없으면 null). 우클릭 메뉴가 무엇을 띄울지 정하는 데 쓴다. */ hitAt(view: ViewState, screenX: number, screenY: number): number | null; /** 누가거리로 바로 옮긴다(종단 테이블에서 라인을 끌었을 때). */ moveTo(index: number, chainageM: number): void; /** 누가거리로 바로 넣는다(종단 테이블 우클릭). */ addAtChainage(chainageM: number): void; /** 그 자리에 배관을 넣을 수 있는지(계획선에 충분히 가까운지)만 본다. */ canAddAt(view: ViewState, screenX: number, screenY: number): boolean; /** 화면 좌표를 계획선 위 누가거리로 투영한다. 계획선에서 멀면 null(구조물 추가 메뉴용). */ chainageAt(view: ViewState, screenX: number, screenY: number): number | null; /** 계획선 위 그 자리에 배관을 넣는다. 노선에서 멀면 false. */ addAt(view: ViewState, screenX: number, screenY: number): boolean; /** 편집 상호작용. 처리했으면 true(패널은 지도 팬을 생략한다). */ handleDown(view: ViewState, screenX: number, screenY: number): boolean; handleMove(view: ViewState, screenX: number, screenY: number): boolean; handleUp(): boolean; draw( context: CanvasRenderingContext2D, view: ViewState, colorOf: (chainage: number, position: number) => string, ): void; } /** `onChange`는 화면 갱신용, `onCommit`은 배치가 실제로 바뀌었을 때(추가·삭제·이동 완료). * 세부유역을 다시 나눠야 하는 시점이 정확히 `onCommit`이다(2026-08-01 사용자 지시). */ export function createPipeEditor( onChange: () => void, onCommit: () => void = () => {}, ): PipeEditor { let meta: VWorldMeta | null = null; let route: Array<{ x: number; y: number; chainage: number }> = []; let totalChainage = 0; let pipeList: PipePoint[] = []; let selectedIndex: number | null = null; let draggingIndex: number | null = null; let dragMoved = false; /** 마커를 잡은 화면 좌표 — 이 자리에서 DRAG_START_PX를 벗어나야 이동이 시작된다 * (2026-08-19 사용자 보고: 고르려다 관이 옮겨지는 민감도 문제). */ let dragDownAt: { x: number; y: number } | null = null; /** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */ function screenToMetric( view: ViewState, sx: number, sy: number, ): { x: number; y: number } | null { if (!meta) return null; const ax = view.mapRect.width * view.scale; const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; const ay = view.mapRect.height * view.scale; const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; if (!ax || !ay) return null; const nx = (sx - bx) / ax; const ny = (sy - by) / ay; return { x: meta.x_min + nx * (meta.width_meters || 1), y: meta.y_min + (1 - ny) * (meta.height_meters || 1), }; } function metricToScreen(view: ViewState, x: number, y: number): { x: number; y: number } | null { if (!meta) return null; const nx = (x - meta.x_min) / (meta.width_meters || 1); const ny = 1 - (y - meta.y_min) / (meta.height_meters || 1); const ax = view.mapRect.width * view.scale; const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; const ay = view.mapRect.height * view.scale; const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; return { x: nx * ax + bx, y: ny * ay + by }; } /** 1m가 화면에서 몇 px인지 (거리 판정용). */ function pxPerMeter(view: ViewState): number { if (!meta) return 1; return (view.mapRect.width * view.scale) / (meta.width_meters || 1); } function chainageToXY(chainage: number): { x: number; y: number } | null { if (route.length < 2) return null; if (chainage <= route[0].chainage) return { x: route[0].x, y: route[0].y }; for (let i = 1; i < route.length; i += 1) { const prev = route[i - 1]; const next = route[i]; if (chainage > next.chainage) continue; const span = next.chainage - prev.chainage || 1; const t = (chainage - prev.chainage) / span; return { x: prev.x + (next.x - prev.x) * t, y: prev.y + (next.y - prev.y) * t }; } const last = route[route.length - 1]; return { x: last.x, y: last.y }; } /** 사업지 좌표에서 노선 최근접 지점의 누가거리와 이탈 거리(m). */ function nearestChainage(x: number, y: number): { chainage: number; distance: number } | null { if (route.length < 2) return null; let best: { chainage: number; distance: number } | null = null; for (let i = 1; i < route.length; i += 1) { const a = route[i - 1]; const b = route[i]; const dx = b.x - a.x; const dy = b.y - a.y; const lengthSq = dx * dx + dy * dy || 1; const t = Math.max(0, Math.min(1, ((x - a.x) * dx + (y - a.y) * dy) / lengthSq)); const px = a.x + dx * t; const py = a.y + dy * t; const distance = Math.hypot(x - px, y - py); const chainage = a.chainage + (b.chainage - a.chainage) * t; if (!best || distance < best.distance) best = { chainage, distance }; } return best; } function sortPipes(): void { const selected = selectedIndex === null ? null : pipeList[selectedIndex]; pipeList.sort((a, b) => a.chainage_m - b.chainage_m); selectedIndex = selected === null ? null : pipeList.indexOf(selected); } return { setContext(nextMeta, points) { meta = nextMeta; let cumulative = 0; route = points.map((point, index) => { if (index > 0) { const prev = points[index - 1]; cumulative += Math.hypot(point.x - prev.x, point.y - prev.y); } return { x: point.x, y: point.y, chainage: point.chainage_m ?? cumulative }; }); totalChainage = route.length > 0 ? route[route.length - 1].chainage : 0; }, setPipes(pipes) { pipeList = pipes.map((pipe) => ({ ...pipe })); sortPipes(); selectedIndex = null; draggingIndex = null; }, pipes: () => pipeList, chainages: () => pipeList.map((pipe) => Math.round(pipe.chainage_m * 100) / 100), selected: () => selectedIndex, select(index) { selectedIndex = index; }, deleteSelected() { if (selectedIndex === null) return false; pipeList.splice(selectedIndex, 1); selectedIndex = null; onChange(); onCommit(); return true; }, hitAt(view, screenX, screenY) { for (let i = pipeList.length - 1; i >= 0; i -= 1) { const xy = chainageToXY(pipeList[i].chainage_m); if (!xy) continue; const screen = metricToScreen(view, xy.x, xy.y); if (!screen) continue; if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) return i; } return null; }, moveTo(index, chainageM) { const pipe = pipeList[index]; if (!pipe) return; pipe.chainage_m = Math.max(0, Math.min(totalChainage, chainageM)); pipe.reason = "confirmed"; sortPipes(); onChange(); onCommit(); }, addAtChainage(chainageM) { const clamped = Math.max(0, Math.min(totalChainage, chainageM)); pipeList.push({ chainage_m: clamped, reason: "confirmed" }); sortPipes(); selectedIndex = pipeList.findIndex((pipe) => Math.abs(pipe.chainage_m - clamped) < 1e-6); onChange(); onCommit(); }, canAddAt(view, screenX, screenY) { const metric = screenToMetric(view, screenX, screenY); if (!metric) return false; const nearest = nearestChainage(metric.x, metric.y); return !!nearest && nearest.distance * pxPerMeter(view) <= ADD_SNAP_PX; }, chainageAt(view, screenX, screenY) { const metric = screenToMetric(view, screenX, screenY); if (!metric) return null; const nearest = nearestChainage(metric.x, metric.y); if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return null; return Number(nearest.chainage.toFixed(2)); }, addAt(view, screenX, screenY) { const metric = screenToMetric(view, screenX, screenY); if (!metric) return false; const nearest = nearestChainage(metric.x, metric.y); if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return false; pipeList.push({ chainage_m: nearest.chainage, reason: "confirmed" }); sortPipes(); selectedIndex = pipeList.findIndex( (pipe) => Math.abs(pipe.chainage_m - nearest.chainage) < 1e-6, ); onChange(); onCommit(); return true; }, handleDown(view, screenX, screenY) { dragMoved = false; // 마커를 잡으면 곧바로 끌 수 있다 — 편집 모드를 따로 켜지 않는다(2026-08-01 사용자 지시). // 계획선을 그냥 누르는 것은 여기서 아무 일도 하지 않는다. 그렇게 두면 유역을 고르려고 // 지도를 누를 때마다 배관이 생긴다. 추가는 우클릭 메뉴로만 한다. for (let i = pipeList.length - 1; i >= 0; i -= 1) { const xy = chainageToXY(pipeList[i].chainage_m); if (!xy) continue; const screen = metricToScreen(view, xy.x, xy.y); if (!screen) continue; if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) { selectedIndex = i; draggingIndex = i; dragDownAt = { x: screenX, y: screenY }; onChange(); return true; } } return false; }, handleMove(view, screenX, screenY) { if (draggingIndex === null) return false; // 잡은 자리에서 충분히 끌기 전에는 이동하지 않는다 — 클릭 판정 여유(2026-08-19). if (dragDownAt && Math.hypot(screenX - dragDownAt.x, screenY - dragDownAt.y) < DRAG_START_PX) return true; dragDownAt = null; const metric = screenToMetric(view, screenX, screenY); if (!metric) return true; const nearest = nearestChainage(metric.x, metric.y); if (!nearest) return true; const clamped = Math.max(0, Math.min(totalChainage, nearest.chainage)); pipeList[draggingIndex].chainage_m = clamped; pipeList[draggingIndex].reason = "confirmed"; dragMoved = true; onChange(); return true; }, handleUp() { if (draggingIndex === null) return false; draggingIndex = null; dragDownAt = null; if (dragMoved) { sortPipes(); onChange(); // 관을 옮겼으면 담당 구간이 달라진다 — 세부유역을 즉시 다시 나눈다. onCommit(); } return true; }, draw(context, view, colorOf) { pipeList.forEach((pipe, position) => { const xy = chainageToXY(pipe.chainage_m); if (!xy) return; const screen = metricToScreen(view, xy.x, xy.y); if (!screen) return; const isSelected = position === selectedIndex; const radius = isSelected ? 9 : 7; context.beginPath(); context.arc(screen.x, screen.y, radius, 0, Math.PI * 2); context.fillStyle = colorOf(pipe.chainage_m, position); context.fill(); context.lineWidth = isSelected ? 2.5 : 1.5; const markerText = themeColor("--map-marker-text", "#111827"); context.strokeStyle = isSelected ? markerText : themeColor("--map-marker-outline", "#374151"); context.stroke(); context.fillStyle = markerText; context.font = "bold 10px sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; context.fillText(String(position + 1), screen.x, screen.y); // 누가거리 라벨 — 마커 우상단. 위성사진·다크 배경에서도 읽히도록 흰 테두리를 깔고 // 그 위에 글자를 얹는다. 색만 테마 토큰으로 바꾸면 어두운 배경에서 묻힌다 // (2026-08-02 사용자 지시). const label = `${pipe.chainage_m.toFixed(0)}m`; const labelX = screen.x + radius + 3; const labelY = screen.y - radius; context.font = "600 10px sans-serif"; context.textAlign = "left"; context.lineJoin = "round"; context.lineWidth = 3; context.strokeStyle = themeColor("--map-halo", "rgba(255, 255, 255, 0.9)"); context.strokeText(label, labelX, labelY); context.fillStyle = themeColor("--map-label-text", "#1f2937"); context.fillText(label, labelX, labelY); }); }, }; }