/* ============================================================================= * 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([]); }, }; }