Files
Aislo/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts
T
2026-07-24 17:52:01 +09:00

274 lines
10 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;
}
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;
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 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] = {
id: editingId,
station,
remainder: safeRemainder,
chainage_m,
structure,
};
}
} else {
stations.push({
id: String(nextId++),
station,
remainder: safeRemainder,
chainage_m,
structure,
});
}
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) {
const interval = intervalMax();
stations.length = 0;
seed.forEach((entry) => {
const stationNo = Math.floor((entry.chainage_m + 1e-6) / interval);
const remainder = Number((entry.chainage_m - stationNo * interval).toFixed(3));
stations.push({
id: String(nextId++),
station: stationNo,
remainder,
chainage_m: entry.chainage_m,
structure: entry.structure,
});
});
loadForm(null);
renderList();
callbacks.onChange([...stations]);
},
clear() {
stations.length = 0;
loadForm(null);
renderList();
callbacks.onChange([]);
},
};
}