/* ============================================================================= * B05_Profile_UI_IrregularStations.ts * 비정규 측점(구조물 측점) 입력·목록·편집 사이드바 섹션. * * 사용자가 규칙 격자(측점 간격) 밖의 임의 위치(측점번호 X + 잔여거리 XX)에 구조물 측점을 * 추가한다. 지금은 구조물을 자유 텍스트로 받고, 값은 클라이언트에만 보관한다(백엔드 미영속). * 추가/수정/삭제/리셋이 일어날 때마다 `onChange`로 전체 목록을 알려 Page가 그래프·테이블·3D에 * 반영한다. 목록에서 항목을 고르면 `onSelect`로 chainage를 알려 하이라이트에 쓴다. * * chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 `getInterval()`로 실시간 조회한다. * ========================================================================== */ import { ESCAPE_ROUTE_DEFAULT_WIDTH_M, ESCAPE_ROUTE_WIDTHS_M, PIPE_DEFAULT_DIAMETER_MM, PIPE_DEFAULT_TYPE, PIPE_DIAMETERS_MM, PIPE_TYPES, STRUCTURE_ETC_DEFAULT_NAME, STRUCTURE_TYPES, pickPipeDiameter, type StructureType, } from "@config/config_frontend"; export interface IrregularStation { id: string; /** 측점번호 X. */ station: number; /** 잔여거리 XX (m). */ remainder: number; /** = station × 측점간격 + remainder. 그래프·테이블·3D의 X축 기준. */ chainage_m: number; /** 표시용 구조물 라벨 — structureLabel()이 만든다 (예: "파형강관 D800", "대피로 2.0m"). */ structure: string; /** 구조물 종류 드롭다운 값 (2026-08-05 사용자 확정). 구버전 항목은 undefined → "기타" 취급. */ structureType?: StructureType; /** 배관 하위 옵션 — 관종·직경. */ pipeType?: string; diameterMm?: number; /** 사용자가 관종·직경을 손으로 바꿨는가. true면 배수유역 재계산 때 자동 지정으로 덮지 않는다. */ userSized?: boolean; /** 대피로 하위 옵션 — 폭(m). */ escapeWidthM?: number; /** 기타 하위 옵션 — 이름 자유 텍스트. */ customName?: string; /** 어디서 온 항목인가. `pipe`는 배수유역도의 관 매설 지점이 투영된 것이라 손으로 * 지우거나 옮겨도 정본(`pipe_points.json`)을 거쳐야 한다(2026-08-01 사용자 지시). */ origin?: "user" | "pipe"; } /** 관 매설 지점이 구조물 목록에 실체화될 때 쓰는 종류 값(구 데이터 판정 키라 유지 — * 화면 표시 이름은 "배수관"이다, 2026-08-17 사용자 지시). */ export const PIPE_STRUCTURE_NAME = "배관"; /** 화면에 보이는 이름. */ export const PIPE_DISPLAY_NAME = "배수관"; /** 배수관으로 볼 항목인가. 드롭다운 종류가 "배관"이거나 손으로 이름을 "배관"·"배수관"이라 * 적은 것도 관 지점 정본을 따르게 한다 — 아니면 [초기화]로 지워지지 않고 배수유역도와 * 어긋난다. */ export function isPipeStation(station: { origin?: "user" | "pipe"; structure: string; structureType?: StructureType; }): boolean { const name = station.structure.trim(); return ( station.origin === "pipe" || station.structureType === PIPE_STRUCTURE_NAME || name === PIPE_STRUCTURE_NAME || name === PIPE_DISPLAY_NAME ); } /** 옵션 값들로 표시 라벨을 만든다 — 목록·종단 그래프·3D 마커가 같은 문자열을 쓴다. */ export function structureLabel(entry: { structureType?: StructureType; pipeType?: string; diameterMm?: number; escapeWidthM?: number; customName?: string; structure?: string; }): string { switch (entry.structureType) { case "배관": return `${entry.pipeType ?? PIPE_DEFAULT_TYPE} D${entry.diameterMm ?? PIPE_DEFAULT_DIAMETER_MM}`; case "기성막이": return "기성막이"; case "대피로": return `대피로 ${(entry.escapeWidthM ?? ESCAPE_ROUTE_DEFAULT_WIDTH_M).toFixed(1)}m`; case "기타": return entry.customName?.trim() || STRUCTURE_ETC_DEFAULT_NAME; default: return entry.structure ?? ""; } } 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; /** 배수유역도의 관 목록을 "배관" 구조물로 갈아 끼운다. 사용자가 손으로 넣은 항목은 건드리지 않는다. * effective_diameter_mm(배수 유효직경)를 주면 그 이상 첫 규격을 자동 지정한다(기본 D800). */ setPipeStations: ( pipes: ReadonlyArray, ) => void; /** 우클릭 메뉴에서 배관 외 구조물을 기본 옵션으로 추가한다(종단·배수유역도 공용). */ addStructure: (chainageM: number, structureType: "기성막이" | "대피로" | "기타") => 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: HTMLElement): 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; } function selectInput(options: ReadonlyArray): HTMLSelectElement { const select = document.createElement("select"); select.replaceChildren(...options.map((value) => new Option(value, value))); return select; } export function createIrregularStationsSection( callbacks: IrregularStationsCallbacks, ): IrregularStationsSection { const root = document.createElement("section"); // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시). root.className = "b05-route__panel-section ui-collapsible ui-sidebar-section"; 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 = "잔여거리"; // 구조물 종류 + 종류별 하위 옵션 (2026-08-05 사용자 확정. 목록은 config_frontend가 정본). // 첫 항목은 빈칸 — 리셋 시 "선택 안 됨" 상태를 표현한다(빈칸이면 하위 옵션 전체 숨김). const typeSelect = selectInput(["", ...STRUCTURE_TYPES]); const pipeTypeSelect = selectInput(PIPE_TYPES); const diameterSelect = document.createElement("select"); const widthSelect = selectInput(ESCAPE_ROUTE_WIDTHS_M.map((width) => width.toFixed(1))); const nameField = document.createElement("input"); nameField.type = "text"; nameField.placeholder = STRUCTURE_ETC_DEFAULT_NAME; const pipeTypeField = field("관종", pipeTypeSelect); const diameterField = field("직경", diameterSelect); const widthField = field("폭 (m)", widthSelect); const nameFieldWrap = field("이름", nameField); const subRow = document.createElement("div"); subRow.className = "b05-route__irregular-row"; subRow.append(pipeTypeField, diameterField, widthField, nameFieldWrap); /** 관종에 맞는 직경 목록으로 갈아 끼운다. 가능하면 현재 선택을 유지한다. */ function syncDiameterOptions(keep?: number): void { const sizes = PIPE_DIAMETERS_MM[pipeTypeSelect.value] ?? PIPE_DIAMETERS_MM[PIPE_DEFAULT_TYPE]; const wanted = keep ?? (Number(diameterSelect.value) || PIPE_DEFAULT_DIAMETER_MM); diameterSelect.replaceChildren( ...sizes.map((size) => new Option(`D${size}`, String(size), false, size === wanted)), ); if (!sizes.includes(wanted)) diameterSelect.value = String(pickPipeDiameter(pipeTypeSelect.value, wanted)); } /** 종류에 따라 보이는 하위 옵션을 바꾼다 — 배관: 관종+직경 / 대피로: 폭 / 기타: 이름. * 빈칸(선택 안 됨)이면 하위 옵션 전체를 숨긴다(리셋 직후 상태). */ function syncSubOptions(): void { const type = typeSelect.value as StructureType | ""; pipeTypeField.hidden = diameterField.hidden = type !== "배관"; widthField.hidden = type !== "대피로"; nameFieldWrap.hidden = type !== "기타"; // 기성막이는 하위 옵션 미정(추가반영 예정), 빈칸은 아무것도 안 보인다. subRow.hidden = type === "기성막이" || type === ""; } typeSelect.addEventListener("change", () => { syncSubOptions(); // 편집 중 구조물 타입을 바꾸면 기존 항목 수정이 아니라 **신규 추가**로 전환한다 // (2026-08-05 사용자 지시). 입력값은 그대로 두고 편집 상태만 푼다. if (editingId) { editingId = null; syncButtons(); renderList(); callbacks.onSelect(null); } }); pipeTypeSelect.addEventListener("change", () => syncDiameterOptions()); typeSelect.value = ""; // 초기·리셋 상태는 빈칸(2026-08-05 사용자 지시) pipeTypeSelect.value = PIPE_DEFAULT_TYPE; widthSelect.value = ESCAPE_ROUTE_DEFAULT_WIDTH_M.toFixed(1); syncDiameterOptions(PIPE_DEFAULT_DIAMETER_MM); syncSubOptions(); 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("구조물", typeSelect), subRow, 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) : ""; // 구버전 항목(structureType 없음)은 "기타"로 열고 이름칸에 기존 텍스트를 옮긴다. // 선택 해제·리셋이면 종류도 빈칸으로 — 하위 옵션이 전부 숨는다(2026-08-05 사용자 지시). const type = target ? (target.structureType ?? "기타") : ""; typeSelect.value = type; pipeTypeSelect.value = target?.pipeType ?? PIPE_DEFAULT_TYPE; syncDiameterOptions(target?.diameterMm ?? PIPE_DEFAULT_DIAMETER_MM); widthSelect.value = (target?.escapeWidthM ?? ESCAPE_ROUTE_DEFAULT_WIDTH_M).toFixed(1); nameField.value = target ? (target.customName ?? (target.structureType ? "" : target.structure)) : ""; // 배수유역 자동 배관(정본 투영)은 관종·직경만 바꿀 수 있다 — 타입 자체는 잠근다 // (2026-08-05 사용자 지시. 배관을 다른 구조물로 바꾸면 관 지점 정본과 어긋난다). typeSelect.disabled = !!target && isPipeStation(target); syncSubOptions(); syncButtons(); // 선택 강조(is-selected)가 클릭 즉시 따라오도록 목록을 다시 그린다(토글 해제 포함). renderList(); 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); // 같은 항목 재클릭 = 선택 해제(2026-08-04 사용자 지시) — 그래프·3D 강조도 함께 풀린다. item.addEventListener("click", () => loadForm(station.id === editingId ? null : 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; // 종류 미선택(빈칸)이면 추가하지 않는다 — 리셋 직후 상태. if (!typeSelect.value) { typeSelect.focus(); return; } // 드롭다운 선택값 → 옵션 필드 + 표시 라벨. 종류에 안 쓰는 필드는 넣지 않는다. const structureType = typeSelect.value as StructureType; const options: Partial = { structureType }; if (structureType === "배관") { options.pipeType = pipeTypeSelect.value; options.diameterMm = Number(diameterSelect.value) || PIPE_DEFAULT_DIAMETER_MM; } else if (structureType === "대피로") { options.escapeWidthM = Number(widthSelect.value) || ESCAPE_ROUTE_DEFAULT_WIDTH_M; } else if (structureType === "기타") { options.customName = nameField.value.trim() || STRUCTURE_ETC_DEFAULT_NAME; } const structure = structureLabel(options); const chainage_m = chainageOf(station, safeRemainder); if (editingId) { // 그 자리에서 변경(Object.assign)하면 Page가 보관한 이전 목록의 객체도 함께 바뀌어 // "이동 전 chainage"를 잃는다 → 옛 위치의 계획고·곡선 편집을 정리하지 못한다. // 새 객체로 교체해, Page가 이전 위치를 감지하고 그 편집을 지우게 한다. const index = stations.findIndex((entry) => entry.id === editingId); if (index >= 0) { const previous = stations[index]; // 배관 관종·직경을 손으로 바꿨으면 자동 지정이 다시 덮지 않게 표시한다. const userSized = structureType === "배관" && (previous.userSized || previous.pipeType !== options.pipeType || previous.diameterMm !== options.diameterMm); stations[index] = { ...previous, id: editingId, station, remainder: safeRemainder, chainage_m, structure, ...options, userSized, }; } } else { stations.push({ id: String(nextId++), station, remainder: safeRemainder, chainage_m, structure, ...options, 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(pipes) { // 배관 항목은 통째로 갈아 끼운다 — 정본은 배수유역도의 관 목록이다. // 단, 사용자가 관종·직경을 손으로 바꾼 항목(userSized)은 같은 자리에 다시 올 때 유지한다. const previousPipes = stations.filter(isPipeStation); const incomingChainages = pipes.map((pipe) => typeof pipe === "number" ? pipe : pipe.chainage_m, ); for (let index = stations.length - 1; index >= 0; index -= 1) { const entry = stations[index]; // 경로확정 정본(종단 병합분)에서 복원된 항목은 구조물 종류(structureType)가 없다. // 그 중 정본 관과 같은 자리(±0.5m)인 것은 관의 **복원 유령**이다 — 지금 투영되는 // 정본 관이 진짜이므로 걷어낸다. 같은 배관이 목록에 2개씩 뜨던 원인(2026-08-06 // 사용자 보고). 종류를 갖춘 사용자 추가 항목은 자리가 겹쳐도 건드리지 않는다. const restoredGhost = entry.structureType === undefined && incomingChainages.some((chainage) => Math.abs(entry.chainage_m - chainage) < 0.51); if (isPipeStation(entry) || restoredGhost) stations.splice(index, 1); } pipes.forEach((pipe) => { const chainage = typeof pipe === "number" ? pipe : pipe.chainage_m; const effective = typeof pipe === "number" ? null : (pipe.effective_diameter_mm ?? null); const { station, remainder } = splitChainage(chainage); const kept = previousPipes.find( (entry) => entry.userSized && Math.abs(entry.chainage_m - chainage) < 0.51, ); const pipeType = kept?.pipeType ?? PIPE_DEFAULT_TYPE; const diameterMm = kept?.diameterMm ?? pickPipeDiameter(pipeType, effective); const entry: IrregularStation = { id: String(nextId++), station, remainder, chainage_m: chainage, structure: "", structureType: PIPE_STRUCTURE_NAME, pipeType, diameterMm, userSized: kept?.userSized ?? false, origin: "pipe", }; entry.structure = structureLabel(entry); stations.push(entry); }); if (editingId && !stations.some((entry) => entry.id === editingId)) loadForm(null); renderList(); callbacks.onChange([...stations]); }, addStructure(chainageM, structureType) { const { station, remainder } = splitChainage(chainageM); const entry: IrregularStation = { id: String(nextId++), station, remainder, chainage_m: chainageM, structure: "", structureType, origin: "user", }; if (structureType === "대피로") entry.escapeWidthM = ESCAPE_ROUTE_DEFAULT_WIDTH_M; if (structureType === "기타") entry.customName = STRUCTURE_ETC_DEFAULT_NAME; entry.structure = structureLabel(entry); stations.push(entry); 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([]); }, }; }