Files
Aislo/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts
T
eomsangdonandClaude Opus 5 6080d122bb feat(B05/B06): 계획서 1·2·4·5·6·8·11 구현 + 유토곡선 오버레이 서브패널 전환(7)
- 4: volumeRange 기본 ±200㎥ 고정, 초과 시에만 확장 (B05·B06 공용)
- 11: 진행단계 오버레이 최상단 대시보드(🏠) 버튼 — 항상 활성, B01 이동
- 6: 편집 버튼 방향색 — 올림(▲⇧) --color-danger, 내림(▼⇩) 파랑 #5b8def, hover 배경 포함
- 2: 구조물 목록 5개 높이 상한 + 내부 스크롤, 선택 항목 scrollIntoView
- 5: 종단 렌더러 bottomInsetPx 옵션 신설 — B05만 X축·측점 라벨 17px 상향,
  ▼ 버튼과 오버랩 해소 (B06 무변화)
- 1: 구조물 측점 열 기본값 미표기 — 측점 값 7행·곡선 2행 빈 셀, 선택 오버레이가 값 표시
- 8: B06 진입 stale 재계산을 측점별 순차 루프에서 일괄 프리뷰 1회로 교체
  · preview API에 full_designs·rock_boundary_offsets 추가
  · B05 세션 초안(readAlignmentDraft) 우선 사용해 두 화면 계획선 일치
- 7: 유토곡선을 1:1 분할에서 패널 바닥 오버레이 서브패널로 전환
  · 종단도+테이블 배치 불변, 오버레이가 위를 덮음
  · 높이는 공용 createPanelResizer(위 경계, 세션 보존), splitBar·MASS_SPLIT 제거
  · 자체 가로 스크롤러 + 종단 스크롤러 양방향 동기화, 전용 ㎥ sticky Y축
  · buildStickyYAxis 정의처를 MassHaul 모듈로 이동(Panel과 공용)

typecheck·vite build·ruff 통과.

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

352 lines
14 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 = "배관";
/** 배관으로 볼 항목인가. 손으로 이름을 "배관"이라 적은 것도 관 지점 정본을 따르게 한다 —
* 그렇지 않으면 [초기화]로 지워지지 않고 배수유역도와도 어긋난 채 남는다(2026-08-02 사용자 보고). */
export function isPipeStation(station: { origin?: "user" | "pipe"; structure: string }): boolean {
return station.origin === "pipe" || station.structure.trim() === 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);
// 목록이 5개를 넘으면 내부 스크롤이 생긴다 — 선택(=수정 중) 항목이 스크롤 밖에
// 있으면 안 보이므로, 렌더 직후 그 항목만 보이는 위치로 데려온다.
if (station.id === editingId) item.scrollIntoView({ block: "nearest" });
});
}
/** 잔여거리는 측점 간격을 넘을 수 없다(넘으면 다음 측점이 됨). 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 (isPipeStation(stations[index])) 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([]);
},
};
}