274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
/* =============================================================================
|
||
* 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([]);
|
||
},
|
||
};
|
||
}
|