Files
Aislo/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.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

343 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B05_wf2_Route_UI_IrregularStations.ts
* 비정규 측점(구조물 측점) 입력·목록·편집 사이드바 섹션.
*
* 사용자가 규칙 격자(측점 간격) 밖의 임의 위치(측점번호 X + 잔여거리 XX)에 구조물 측점을
* 추가한다. 지금은 구조물을 자유 텍스트로 받고, 값은 클라이언트에만 보관한다(백엔드 미영속).
* 추가/수정/삭제/리셋이 일어날 때마다 `onChange`로 전체 목록을 알려 Page가 그래프·테이블·3D에
* 반영한다. 목록에서 항목을 고르면 `onSelect`로 chainage를 알려 하이라이트에 쓴다.
*
* chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 `getInterval()`로 실시간 조회한다.
* ========================================================================== */
export interface IrregularStation {
id: string;
/** 측점번호 X. */
station: number;
/** 잔여거리 XX (m). */
remainder: number;
/** = station × 측점간격 + remainder. 그래프·테이블·3D의 X축 기준. */
chainage_m: number;
/** 구조물 설명 (당분간 자유 텍스트). */
structure: string;
/** 어디서 온 항목인가. `pipe`는 배수유역도의 관 매설 지점이 투영된 것이라 손으로
* 지우거나 옮겨도 정본(`pipe_points.json`)을 거쳐야 한다(2026-08-01 사용자 지시). */
origin?: "user" | "pipe";
}
/** 관 매설 지점이 구조물 목록에 실체화될 때 쓰는 이름. 향후 드롭다운으로 바꾼다. */
export const PIPE_STRUCTURE_NAME = "배관";
export interface IrregularStationsSection {
root: HTMLElement;
getStations: () => IrregularStation[];
/** chainage로 목록 항목을 골라 폼에 로드한다(그래프·3D에서 선택 시). null이면 선택 해제. */
selectByChainage: (chainageM: number | null) => void;
/** 외부(백엔드 복귀)에서 목록을 통째로 채운다(측점번호·잔여거리는 chainage로 역산). */
setStations: (seed: Array<{ chainage_m: number; structure: string }>) => void;
/** 배수유역도의 관 목록을 "배관" 구조물로 갈아 끼운다. 사용자가 손으로 넣은 항목은 건드리지 않는다. */
setPipeStations: (chainages: ReadonlyArray<number>) => void;
/** 누가거리로 항목을 찾아 지운다(종단 테이블 우클릭). 지웠으면 그 항목을 돌려준다. */
removeByChainage: (chainageM: number) => IrregularStation | null;
/** 누가거리로 항목을 옮긴다(종단 테이블 라인 드래그). 옮겼으면 true. */
moveByChainage: (fromChainageM: number, toChainageM: number) => boolean;
clear: () => void;
}
interface IrregularStationsCallbacks {
/** 측점간격(m). chainage 환산에 쓴다. */
getInterval: () => number;
/** 목록이 바뀔 때(추가·수정·삭제·리셋) 전체 목록을 넘긴다. */
onChange: (stations: IrregularStation[]) => void;
/** 목록에서 항목을 선택/해제할 때 해당 측점(또는 null)을 넘긴다. */
onSelect: (station: IrregularStation | null) => void;
}
/** 비정규 측점의 그래프·3D·테이블 공용 식별자. 주입 측점의 station_id로도 쓴다. */
export function irregularStationId(id: string): string {
return `irregular:${id}`;
}
/** 측점번호+잔여거리 표기 (예: 3+18.0). */
export function irregularLabel(station: IrregularStation): string {
return `${station.station}+${station.remainder.toFixed(1)}`;
}
function field(labelText: string, input: HTMLInputElement): HTMLLabelElement {
const wrapper = document.createElement("label");
wrapper.className = "b05-route__field";
const caption = document.createElement("span");
caption.textContent = labelText;
wrapper.append(caption, input);
return wrapper;
}
function numberInput(step: string, min: string): HTMLInputElement {
const input = document.createElement("input");
input.type = "number";
input.step = step;
input.min = min;
return input;
}
export function createIrregularStationsSection(
callbacks: IrregularStationsCallbacks,
): IrregularStationsSection {
const root = document.createElement("section");
root.className = "b05-route__panel-section ui-collapsible";
const heading = document.createElement("h3");
heading.className = "ui-collapsible__title";
heading.textContent = "구조물 배치";
const body = document.createElement("div");
body.className = "b05-route__panel-body";
root.append(heading, body);
const stationField = numberInput("1", "0");
stationField.placeholder = "측점번호";
const remainderField = numberInput("0.1", "0");
remainderField.placeholder = "잔여거리";
const structureField = document.createElement("input");
structureField.type = "text";
structureField.placeholder = "구조물 (예: 배수구조물, 옹벽)";
const stationRow = document.createElement("div");
stationRow.className = "b05-route__irregular-row";
stationRow.append(field("측점번호", stationField), field("잔여거리 (m)", remainderField));
const primary = document.createElement("button");
primary.type = "button";
primary.className = "b05-route__irregular-btn is-primary";
const remove = document.createElement("button");
remove.type = "button";
remove.className = "b05-route__irregular-btn is-danger";
remove.textContent = "삭제";
const reset = document.createElement("button");
reset.type = "button";
reset.className = "b05-route__irregular-btn";
reset.textContent = "리셋";
const actions = document.createElement("div");
actions.className = "b05-route__irregular-actions";
actions.append(primary, remove, reset);
const list = document.createElement("ul");
list.className = "b05-route__irregular-list";
const help = document.createElement("p");
help.className = "b05-route__note";
help.textContent =
"구조물을 설치할 위치를 측점번호+잔여거리로 추가합니다. 목록에서 고르면 수정·삭제할 수 있습니다.";
// 측점번호/잔여거리를 최상단에 둔다(위치가 먼저, 그다음 구조물).
body.append(stationRow, field("구조물", structureField), actions, list, help);
const stations: IrregularStation[] = [];
let editingId: string | null = null;
let nextId = 1;
function chainageOf(station: number, remainder: number): number {
const interval = callbacks.getInterval();
return station * (interval > 0 ? interval : 20) + remainder;
}
/** 누가거리를 측점번호+잔여거리로 되돌린다(외부 주입·이동 공용). */
function splitChainage(chainageM: number): { station: number; remainder: number } {
const interval = intervalMax();
const station = Math.floor((chainageM + 1e-6) / interval);
return {
station,
remainder: Number((chainageM - station * interval).toFixed(3)),
};
}
function syncButtons(): void {
primary.textContent = editingId ? "수정" : "추가";
remove.disabled = editingId === null;
}
function loadForm(target: IrregularStation | null): void {
editingId = target?.id ?? null;
stationField.value = target ? String(target.station) : "";
remainderField.value = target ? String(target.remainder) : "";
structureField.value = target?.structure ?? "";
syncButtons();
callbacks.onSelect(target ?? null);
}
function renderList(): void {
list.replaceChildren();
if (!stations.length) {
const empty = document.createElement("li");
empty.className = "b05-route__irregular-empty";
empty.textContent = "추가된 비정규 측점이 없습니다.";
list.append(empty);
return;
}
[...stations]
.sort((a, b) => a.chainage_m - b.chainage_m)
.forEach((station) => {
const item = document.createElement("li");
item.className = "b05-route__irregular-item";
item.classList.toggle("is-selected", station.id === editingId);
const name = document.createElement("strong");
name.textContent = irregularLabel(station);
const info = document.createElement("span");
info.textContent = station.structure || "(구조물 미입력)";
item.append(name, info);
item.addEventListener("click", () => loadForm(station));
list.append(item);
});
}
/** 잔여거리는 측점 간격을 넘을 수 없다(넘으면 다음 측점이 됨). max·검증에 함께 쓴다. */
function intervalMax(): number {
const interval = callbacks.getInterval();
return interval > 0 ? interval : 20;
}
function commit(): void {
const station = Number.parseInt(stationField.value, 10);
const remainder = Number.parseFloat(remainderField.value || "0");
if (!Number.isFinite(station) || station < 0) {
stationField.focus();
return;
}
// 잔여거리는 [0, 측점간격] 범위로 클램프한다.
const safeRemainder = Number.isFinite(remainder)
? Math.min(Math.max(remainder, 0), intervalMax())
: 0;
const structure = structureField.value.trim();
const chainage_m = chainageOf(station, safeRemainder);
if (editingId) {
// 그 자리에서 변경(Object.assign)하면 Page가 보관한 이전 목록의 객체도 함께 바뀌어
// "이동 전 chainage"를 잃는다 → 옛 위치의 계획고·곡선 편집을 정리하지 못한다.
// 새 객체로 교체해, Page가 이전 위치를 감지하고 그 편집을 지우게 한다.
const index = stations.findIndex((entry) => entry.id === editingId);
if (index >= 0) {
stations[index] = {
...stations[index],
id: editingId,
station,
remainder: safeRemainder,
chainage_m,
structure,
};
}
} else {
stations.push({
id: String(nextId++),
station,
remainder: safeRemainder,
chainage_m,
structure,
origin: "user",
});
}
loadForm(null);
renderList();
callbacks.onChange([...stations]);
}
// 측점 간격은 옵션에서 바뀔 수 있으므로, 잔여거리의 max를 입력 직전에 현재 간격으로 맞춘다.
const syncRemainderMax = (): void => {
remainderField.max = String(intervalMax());
};
remainderField.addEventListener("focus", syncRemainderMax);
remainderField.addEventListener("input", syncRemainderMax);
syncRemainderMax();
primary.addEventListener("click", commit);
remove.addEventListener("click", () => {
if (!editingId) return;
const index = stations.findIndex((entry) => entry.id === editingId);
if (index >= 0) stations.splice(index, 1);
loadForm(null);
renderList();
callbacks.onChange([...stations]);
});
reset.addEventListener("click", () => loadForm(null));
syncButtons();
renderList();
return {
root,
getStations: () => [...stations],
selectByChainage(chainageM) {
if (chainageM === null) {
loadForm(null);
return;
}
const target = stations.find((entry) => Math.abs(entry.chainage_m - chainageM) < 1e-6);
loadForm(target ?? null);
},
setStations(seed) {
stations.length = 0;
seed.forEach((entry) => {
const { station, remainder } = splitChainage(entry.chainage_m);
stations.push({
id: String(nextId++),
station,
remainder,
chainage_m: entry.chainage_m,
structure: entry.structure,
origin: "user",
});
});
loadForm(null);
renderList();
callbacks.onChange([...stations]);
},
setPipeStations(chainages) {
// 배관 항목은 통째로 갈아 끼운다 — 정본은 배수유역도의 관 목록이다.
for (let index = stations.length - 1; index >= 0; index -= 1) {
if (stations[index].origin === "pipe") stations.splice(index, 1);
}
chainages.forEach((chainage) => {
const { station, remainder } = splitChainage(chainage);
stations.push({
id: String(nextId++),
station,
remainder,
chainage_m: chainage,
structure: PIPE_STRUCTURE_NAME,
origin: "pipe",
});
});
if (editingId && !stations.some((entry) => entry.id === editingId)) loadForm(null);
renderList();
callbacks.onChange([...stations]);
},
removeByChainage(chainageM) {
const index = stations.findIndex((entry) => Math.abs(entry.chainage_m - chainageM) < 0.51);
if (index < 0) return null;
const [removed] = stations.splice(index, 1);
if (editingId === removed.id) loadForm(null);
renderList();
callbacks.onChange([...stations]);
return removed;
},
moveByChainage(fromChainageM, toChainageM) {
const index = stations.findIndex(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < 0.51,
);
if (index < 0) return false;
const { station, remainder } = splitChainage(toChainageM);
// 그 자리에서 고치지 않고 새 객체로 교체한다 — Page가 옛 위치의 계획고 편집을 정리해야 한다.
stations[index] = {
...stations[index],
station,
remainder,
chainage_m: toChainageM,
};
renderList();
callbacks.onChange([...stations]);
return true;
},
clear() {
stations.length = 0;
loadForm(null);
renderList();
callbacks.onChange([]);
},
};
}