Files
Aislo/B05_wf2_Route/B05_wf2_Route_UI_Profile_Structures.ts
T
eomsangdonandClaude Opus 5 b023698e38 refactor(B04/B05): 배수유역 저장소·API 일원화 + 종단 테이블 구조물 라인 연동
데이터 흐름 문제
- B04 해석 산출물 7개(7.2MB)가 B05로 전량 복사되고 있었고, 관 지점 정본
  (pipe_points.json)은 B05가 아예 참조하지 않았다. B04에서 확정한 관이 B05에
  보이지 않고 진입할 때마다 자동 배치로 되돌아갔다.
- 사본은 "B05 편집이 원본을 덮어쓴다"를 막으려던 것인데, 외곽선 편집을 제거한
  뒤로 B05는 아무것도 저장하지 않아 존재 이유가 사라졌다.

일원화
- B05 전용 모듈 3개 삭제: Engine_Drainage_Store / Engine_Drainage_Basin /
  Router_Drainage. 두 화면이 B04_wf1_Surface/drainage/ 한 폴더를 직접 읽는다.
- POST /drainage/basins 폐기 → GET/PUT /drainage/pipe-points ·
  POST /drainage/detail-basins 한 벌로 통합. 응답에 route_lonlat ·
  main_polygon_lonlat · flow_arrows · upstream_lonlat · inflow_hotspots를 추가해
  B05가 그리는 데 필요한 값을 같은 응답으로 받는다.
- 경로 확정 시 관 지점을 B04와 같은 저장소에 커밋한다.

B05 화면
- 유역 폴리곤을 눌러 고르기(지도에서도 선택), [집중유역] 토글, 관 개수·유역 수·
  종단 Z 출처 요약 줄 추가.
- 관 마커는 좌클릭으로만 잡는다(우클릭은 메뉴, 가운데 버튼은 팬).
- 유역 목록은 3행까지 보이고 나머지는 스크롤.

종단 테이블 구조물 라인
- 관 매설 지점을 "배관" 구조물로 실체화(origin: "pipe"). 정본은 관 지점 파일이며
  목록은 그 투영이라, 어디서 옮기든 pipeEditor 한 경로로 전파된다.
- 테이블 위 세로선을 끌어 이동(배관은 세부유역까지 재산정), 우클릭으로 배관
  추가/구조물 삭제.

정리
- B05_wf2_Route/drainage/ 사본과 debug/(삭제된 Subdivide 엔진 검증 스크립트) 삭제.
- config DRAINAGE_B05_DIRNAME 제거.

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

129 lines
5.8 KiB
TypeScript

/* =============================================================================
* 종단 테이블 구조물 배치 라인 (B05)
*
* 구조물 측점을 테이블 위에 세로선으로 얹고, 그 선을 끌어 위치를 옮긴다. 우클릭하면 그 자리에
* 배관을 넣거나 선을 지운다.
*
* 배관 선은 배수유역도의 관 매설 지점이 투영된 것이다(`origin: "pipe"`). 그래서 여기서 옮기면
* 관 지점 정본(`pipe_points.json`)이 바뀌고 세부유역도 함께 다시 나뉜다 — 값을 두 곳에 따로
* 쌓지 않기 위해서다(2026-08-01 사용자 지시).
* ========================================================================== */
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { irregularLabel, type IrregularStation } from "./B05_wf2_Route_UI_IrregularStations";
/** 선을 잡았다고 볼 좌우 여유(px). 선 자체는 얇아 그대로는 집기 어렵다. */
const GRAB_SLACK_PX = 6;
/** 이만큼(px) 이하로 움직였다 뗐으면 이동이 아니라 고르기로 본다. */
const DRAG_SLOP_PX = 3;
export interface StructureLineOptions {
/** 테이블에 얹을 구조물 측점 목록. */
stations: ReadonlyArray<IrregularStation>;
/** chainage → x(px). 테이블·그래프와 같은 매핑을 쓴다. */
x: (chainageM: number) => number;
/** x(px) → chainage. 선을 끌 때 역변환에 쓴다. */
chainageAt: (px: number) => number;
/** 노선 총 연장(m). 이 범위를 벗어난 자리로는 옮기지 않는다. */
maxChainageM: number;
/** 선을 옮겼을 때. 옛 누가거리와 새 누가거리를 넘긴다. */
onMove: (fromChainageM: number, toChainageM: number, station: IrregularStation) => void;
/** 선을 지울 때. */
onRemove: (station: IrregularStation) => void;
/** 빈 자리에서 우클릭해 배관을 넣을 때. */
onAddPipe: (chainageM: number) => void;
/** 선을 눌렀을 때(끌지 않음) — 값 열 오버레이 선택과 맞춘다. */
onSelect?: (station: IrregularStation) => void;
}
/**
* 테이블 요소 위에 구조물 라인 레이어를 얹는다. 테이블은 매 그리기마다 새로 만들어지므로
* 이 함수도 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다.
*/
export function mountStructureLines(table: HTMLElement, options: StructureLineOptions): void {
const layer = document.createElement("div");
layer.className = "b05-profile-table__structures";
const menu = createMapContextMenu("b05-profile-table");
/** 끌고 있는 선. null이면 잡은 것이 없다. */
let dragging: { station: IrregularStation; element: HTMLElement; startX: number } | null = null;
let moved = false;
function clamp(chainageM: number): number {
return Math.min(Math.max(chainageM, 0), options.maxChainageM);
}
options.stations.forEach((station) => {
const line = document.createElement("div");
line.className =
"b05-profile-table__structure" + (station.origin === "pipe" ? " is-pipe" : " is-user");
line.style.left = `${options.x(station.chainage_m)}px`;
line.title = `${irregularLabel(station)} · ${station.structure || "구조물"}`;
const label = document.createElement("span");
label.className = "b05-profile-table__structure-label";
label.textContent = station.structure || "구조물";
line.append(label);
line.addEventListener("pointerdown", (event) => {
// 이동은 좌클릭 전용 — 우클릭은 메뉴다(2026-08-01 사용자 지시).
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
menu.close();
dragging = { station, element: line, startX: event.clientX };
moved = false;
line.setPointerCapture(event.pointerId);
});
line.addEventListener("pointermove", (event) => {
if (!dragging || dragging.station.id !== station.id) return;
if (Math.abs(event.clientX - dragging.startX) > DRAG_SLOP_PX) moved = true;
const rect = table.getBoundingClientRect();
line.style.left = `${options.x(clamp(options.chainageAt(event.clientX - rect.left)))}px`;
});
line.addEventListener("pointerup", (event) => {
if (!dragging || dragging.station.id !== station.id) return;
const rect = table.getBoundingClientRect();
const next = clamp(options.chainageAt(event.clientX - rect.left));
dragging = null;
if (!moved) {
// 끌지 않고 눌렀다 뗐으면 고르기다 — 원래 자리로 되돌린다.
line.style.left = `${options.x(station.chainage_m)}px`;
options.onSelect?.(station);
return;
}
options.onMove(station.chainage_m, Number(next.toFixed(2)), station);
});
line.addEventListener("contextmenu", (event) => {
event.preventDefault();
event.stopPropagation();
const rect = table.getBoundingClientRect();
menu.open(event.clientX - rect.left, event.clientY - rect.top, [
[station.origin === "pipe" ? "배관 삭제" : "구조물 삭제", () => options.onRemove(station)],
]);
});
layer.append(line);
});
// 빈 자리 우클릭 — 그 누가거리에 배관을 넣는다.
table.addEventListener("contextmenu", (event) => {
if (menu.contains(event.target)) {
event.preventDefault();
return;
}
const rect = table.getBoundingClientRect();
const chainage = clamp(options.chainageAt(event.clientX - rect.left));
event.preventDefault();
menu.open(event.clientX - rect.left, event.clientY - rect.top, [
["배관 추가", () => options.onAddPipe(Number(chainage.toFixed(2)))],
]);
});
table.addEventListener("pointerdown", (event) => {
if (!menu.contains(event.target)) menu.close();
});
table.append(layer, menu.element);
}
/** 선을 잡았다고 볼 여유 — 스타일에서 선 폭을 정할 때 함께 쓴다. */
export const STRUCTURE_GRAB_SLACK_PX = GRAB_SLACK_PX;