Files
Aislo/B05_Profile/B05_Profile_UI_IrregularStations.ts
T

565 lines
24 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_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<number | { chainage_m: number; effective_diameter_mm?: number | null }>,
) => 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<string>): 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<IrregularStation> = { 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([]);
},
};
}