- 한몸으로 동작하는 두 페이지라 한 커밋으로 처리 (상호 참조 다수) - B05 37파일 + B06 20파일 접두사 개명 (git mv, 이력 보존) - 참조 치환 91파일: import 경로, 라우트 슬러그(b05-profile/b06-section), 라우트 키(B05_PROFILE/B06_SECTION), B03 자동 체인, storage 상수, pyproject 제외 경로 - 로직 변경 없음. typecheck·백엔드 import 검증 통과 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
559 lines
24 KiB
TypeScript
559 lines
24 KiB
TypeScript
/* =============================================================================
|
||
* 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";
|
||
}
|
||
|
||
/** 관 매설 지점이 구조물 목록에 실체화될 때 쓰는 종류 이름. */
|
||
export const PIPE_STRUCTURE_NAME = "배관";
|
||
|
||
/** 배관으로 볼 항목인가. 드롭다운 종류가 "배관"이거나 손으로 이름을 "배관"이라 적은 것도
|
||
* 관 지점 정본을 따르게 한다 — 아니면 [초기화]로 지워지지 않고 배수유역도와 어긋난다. */
|
||
export function isPipeStation(station: {
|
||
origin?: "user" | "pipe";
|
||
structure: string;
|
||
structureType?: StructureType;
|
||
}): boolean {
|
||
return (
|
||
station.origin === "pipe" ||
|
||
station.structureType === PIPE_STRUCTURE_NAME ||
|
||
station.structure.trim() === PIPE_STRUCTURE_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([]);
|
||
},
|
||
};
|
||
}
|