Files
Aislo/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts
T
eomsangdonandClaude Opus 5 32fd199dbf feat(B04/B05): 흐름 강도 범례 + 배관 라벨 가독성 + 선택 3자 동기화
- 흐름 강도 색띠 범례를 지도 우측에 세로로 세운다(B04 2D 지도·B05 배수유역도 공용).
  색은 화면과 같은 색띠를, 눈금은 같은 로그 정규화를 되돌려 적는다. 제목 "흐름강도".
- 배관 누가거리 라벨에 흰 테두리를 깔았다. --map-* 토큰은 다크 테마에서 바뀌지 않아
  색만으로는 위성사진·다크 배경에서 묻힌다.
- 배수유역 영역 · 종단 그래프 세로선 · 좌측 구조물 폼 · 3D 마커의 선택이 서로를
  갱신한다. B05_wf2_Route_UI_Selection.ts로 경로를 한곳에 모으고 isSyncing 가드로
  재진입을 막았다. 유역 강조는 origin === "pipe" 항목에만 붙는다.

700줄 규칙 유지를 위한 분리
- B04_wf1_Surface_UI_MapOverlays.ts 신설(유역 채움·번호 배지·분수령·상류 세류망)
- reconcilePipes / fetchDrainageLayers / fitViewToRoute → _Drainage_Parts.ts

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

310 lines
12 KiB
TypeScript

import { themeColor } from "@ui/ui_template_palette";
import type { VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import type { ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_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;
const ADD_SNAP_PX = 14;
export interface PipeEditor {
setContext(meta: VWorldMeta | null, points: ReadonlyArray<RoutePointLike>): void;
setPipes(pipes: ReadonlyArray<PipePoint>): void;
pipes(): ReadonlyArray<PipePoint>;
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;
/** 계획선 위 그 자리에 배관을 넣는다. 노선에서 멀면 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;
/** 화면 → 사업지 좌표계 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;
},
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;
onChange();
return true;
}
}
return false;
},
handleMove(view, screenX, screenY) {
if (draggingIndex === null) return false;
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;
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);
});
},
};
}