- 흐름 강도 색띠 범례를 지도 우측에 세로로 세운다(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>
310 lines
12 KiB
TypeScript
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);
|
|
});
|
|
},
|
|
};
|
|
}
|