2026-08-28 사용자 확정 스펙. 물넘이포장 — 노면을 판 자리로 그린다 - 서버가 _ford_pavement_set(월류 폭·월류 높이·바닥 경사)을 만들고 물넘이만 span 연동을 켜 **범위 안 측점 전부**에 얹는다. 깊이가 없으면 None으로 두어 화면이 그리지 않는다(수치를 지어내지 않는다). - 횡단도: 기존 계획고 점선 + 물넘이 바닥 실선 + 진한 회색 빗금 포장. 깊이는 노선 중심 기준, 바닥은 유입(상단측)이 높게 기운다. 경사를 비우면 그 측점의 노면 횡단경사를 쓴다. - 물넘이 폼에 "바닥 경사 유입→유출(%)" 칸을 추가하고, 월류 폭 기본값 리터럴을 config_frontend 상수로 모아 서버 값과 짝지었다. 포장 — 사용자가 구간으로 지정한다 - 구조물 레지스트리 G군 pavement_concrete 를 되살려 기준측점 + 길이 + 전/후로 받는다(기슭막이와 같은 폼). 길이 기본값은 0 = 미지정. - pavement_ranges/paved_at 이 구간을 판정하고, enforce_pavement_ranges 가 저장분이 비포장이어도 구간 안이면 포장으로 다시 계산한다(사용자 조작값 승계). - **종단경사 자동 포장 적용을 없앴다** — paved=suggested 3곳 제거. 별표1-2 상한 초과 경고(pavement_suggested 배지·근거 문구)는 그대로 남는다. - 포장 구간이 물넘이를 통째로 품으면 모달로 알리고 앞/뒤로 나눠 저장하고, 끝만 걸치면 값을 고치라고 안내하고 멈춘다. 700줄 제한: _compute_default_designs 를 Router_Design 으로 옮겼다(657/306줄). 검증: pytest 238 passed / 7 skipped(신규 9건). 공용 브라우저 실측 — 물넘이 임시 투입 시 240m 카드에만 파임 3요소가 그려지고(일반 포장 박스 0), 바닥이 계획고보다 0.4m 아래·노면 전폭 4.0m에 1.5% 기울기, 220·260은 비포장 유지. 겹침 규칙은 브라우저에서 모듈을 직접 불러 3분할·안내·취소를 확인했다. 실측용 임시 관 지점은 매번 원래 정본 4건으로 복구했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1035 lines
48 KiB
TypeScript
1035 lines
48 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_Structures_Panel.ts
|
|
* 「구조물 배치」 사이드바 섹션 — 구조물군(A~G) → 타입 → 위치(측점) → B05 옵션.
|
|
*
|
|
* 구 「구조물 배치」(비정규 측점, 자유 텍스트)를 흡수한 단일 섹션이다(2026-08-17
|
|
* 컨테이너 병합). 위치 입력은 측점번호+잔여거리 두 칸이고, 순서는 시작 측점 →
|
|
* 기준 측점 → 종료 측점이다(점형은 기준 측점만). 저장은 누가거리(m)로 한다.
|
|
*
|
|
* 계곡 통과 시설(배관/BOX암거/물넘이/세월교, A군 managed_by)도 **같은 폼**에서
|
|
* 편집한다 — 자동 배치 배관이든 수동 배관이든 같은 구조물이라 별도 UI를 두지
|
|
* 않는다(2026-08-17 사용자 지시). 저장소만 관 지점 정본(`pipe_points.json`)이라
|
|
* 추가·수정·삭제를 콜백으로 배수유역 패널에 넘긴다. 부속 옵션(유형·집수정·
|
|
* 기슭막이·돌붙임·날개벽)은 옵션 자리의 서브폼이 그린다. 상세 치수는 B06/B07 몫.
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
defaultOptions,
|
|
isB05Option,
|
|
structureAnchorM,
|
|
type StructureInstance,
|
|
type StructurePlacement,
|
|
type StructureType,
|
|
} from "./B05_Profile_Api_Structures";
|
|
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import {
|
|
createFacilityOptionsForm,
|
|
type FacilityAttributes,
|
|
} from "./B05_Profile_UI_Drainage_Facility";
|
|
import { field, numberInput, select, stationFields } from "./B05_Profile_UI_Structures_Fields";
|
|
import { renderStructureList } from "./B05_Profile_UI_Structures_List";
|
|
import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement";
|
|
import { formatStation } from "./B05_Profile_Util_Station";
|
|
|
|
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다.
|
|
* 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */
|
|
export const GROUP_LABELS: Record<string, string> = {
|
|
A: "A 횡단배수",
|
|
B: "B 종단배수",
|
|
C: "C 사면안정",
|
|
D: "D 계류·사방",
|
|
E: "E 안전·부대·용지",
|
|
F: "F 생태·녹화",
|
|
G: "G 노면공",
|
|
호환: "기타",
|
|
};
|
|
|
|
/** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */
|
|
const DEFAULT_INTERVAL_LENGTH_M = 15;
|
|
|
|
/** 계곡 통과 시설 1건 — 관 지점 정본에서 온 병합 표시·폼 편집용 항목. */
|
|
export interface PipeFacilityItem {
|
|
chainage_m: number;
|
|
facility: PipeFacility;
|
|
start_m?: number;
|
|
end_m?: number;
|
|
/** 자동 배치 출처(stream/spacing/user) — 배관 유형(계곡부형/보완형) 제안 근거. */
|
|
source?: PipeSource;
|
|
/** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면 계산의 입력. */
|
|
design_flow_m3s?: number | null;
|
|
/** 부속 옵션(유형·집수정·기슭막이·돌붙임·날개벽·세월교 관 등). */
|
|
options?: Record<string, string | number>;
|
|
}
|
|
|
|
export interface StructuresSection {
|
|
root: HTMLElement;
|
|
/** 배치된 구조물 목록(<ul>) — 섹션 본문이 아니라 사이드 하단 고정 영역에 붙인다
|
|
* (2026-08-18 사용자 지시: 폼 길이에 밀려 목록이 화면 밖으로 나가던 문제). */
|
|
listRoot: HTMLElement;
|
|
/** 서버에서 받은 타입 목록을 채운다(최초 1회). */
|
|
setTypes: (types: StructureType[]) => void;
|
|
/** 서버 정본으로 목록을 교체한다(진입·복원·저장 후). */
|
|
setStructures: (structures: StructureInstance[]) => void;
|
|
getStructures: () => StructureInstance[];
|
|
/** 계곡 통과 시설 목록을 병합 표시한다(정본 = pipe_points, 배수유역 패널 경유). */
|
|
setPipeFacilities: (pipes: PipeFacilityItem[]) => void;
|
|
/** 그래프·3D에서 고른 계곡 통과 시설을 목록에서 강조한다(null = 해제). */
|
|
selectPipeByChainage: (chainageM: number | null) => void;
|
|
/** 종단도·3D에서 고른 구조물을 폼에 올린다. null이면 선택 해제. */
|
|
selectById: (structureId: string | null) => void;
|
|
/** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */
|
|
addAt: (chainageM: number, typeId: string) => void;
|
|
/** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */
|
|
moveById: (structureId: string, toChainageM: number) => boolean;
|
|
removeById: (structureId: string) => boolean;
|
|
}
|
|
|
|
interface StructuresCallbacks {
|
|
/** 목록이 바뀔 때(추가·수정·삭제) 전체 목록을 넘긴다. */
|
|
onChange: (structures: StructureInstance[]) => void;
|
|
/** 목록에서 고르거나 해제할 때. */
|
|
onSelect: (structure: StructureInstance | null) => void;
|
|
/** 측점간격(m) — 측점번호+잔여거리 ↔ 누가거리 환산에 쓴다. */
|
|
getInterval: () => number;
|
|
/** 계곡 통과 시설 추가 — 관 지점 정본에 넣는다(배수유역 패널 경유).
|
|
* attributes에 시설 종류·구간·부속 옵션이 담긴다. */
|
|
onPipeAdd: (chainageM: number, attributes: FacilityAttributes) => void;
|
|
/** 계곡 통과 시설 수정 — 기준점 이동·구간·부속 옵션 반영(관 지점 정본 경유). */
|
|
onPipeUpdate: (
|
|
fromChainageM: number,
|
|
toChainageM: number,
|
|
attributes: FacilityAttributes,
|
|
) => void;
|
|
/** 계곡 통과 시설 삭제. */
|
|
onPipeRemove: (chainageM: number) => void;
|
|
/** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫).
|
|
* null = 재클릭 해제 — 전 화면(그래프·3D·배수유역도) 선택도 함께 푼다(2026-08-18). */
|
|
onPipeSelect: (chainageM: number | null) => void;
|
|
}
|
|
|
|
export function createStructuresSection(callbacks: StructuresCallbacks): StructuresSection {
|
|
const root = document.createElement("section");
|
|
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 groupSelect = select([]);
|
|
const typeSelect = select([]);
|
|
// 위치 입력 순서 = 시작 측점 → 기준 측점 → 종료 측점 (2026-08-17 사용자 확정).
|
|
// 점형은 기준 측점만 보인다.
|
|
// 측점 표기 전역 규칙(2026-08-18): 잔여거리는 [0, 간격) — 넘치면 측점번호로
|
|
// 올려 표시한다(1+30 → 2+10). 정규화는 stationFields가 입력 확정 때 처리.
|
|
const startFields = stationFields("시작 측점", () => callbacks.getInterval());
|
|
const anchorFields = stationFields("기준 측점", () => callbacks.getInterval());
|
|
const endFields = stationFields("종료 측점", () => callbacks.getInterval());
|
|
const memoField = document.createElement("input");
|
|
memoField.type = "text";
|
|
memoField.placeholder = "메모(선택)";
|
|
|
|
// 측점 범위(계산 표시) — C군 사면안정처럼 길이 옵션으로 구간이 정해지는 타입은
|
|
// 시작·종료를 직접 입력하지 않고 기준 측점 + 길이로 계산해 보여만 준다
|
|
// (2026-08-19 사용자 지시). 표기는 하단 구조물 목록과 같은 시작 ~ 종료 측점.
|
|
const rangeValue = document.createElement("span");
|
|
rangeValue.className = "b05-structure__range-value";
|
|
const rangeWrap = field("측점 범위", rangeValue);
|
|
|
|
// 측점은 3행 — 한 행이 [라벨][측점번호][잔여거리](2026-08-17 사용자 지시 2).
|
|
// 범위 계산 타입은 [측점 범위][기준 측점]만 보인다(범위 행이 기준 측점 위).
|
|
const positionRow = document.createElement("div");
|
|
positionRow.className = "b05-structure__position-row";
|
|
positionRow.append(rangeWrap, startFields.wrap, anchorFields.wrap, endFields.wrap);
|
|
|
|
// 기본 인터페이스는 2열 격자(2026-08-17 사용자 지시 1).
|
|
const typeRow = document.createElement("div");
|
|
typeRow.className = "b05-structure__grid";
|
|
typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect));
|
|
|
|
// 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다.
|
|
const optionRow = document.createElement("div");
|
|
optionRow.className = "b05-structure__grid";
|
|
|
|
// 계곡 통과 시설(배관 등)의 부속 옵션 서브폼 — 일반 구조물의 옵션 칸과 같은
|
|
// 자리에서, 같은 흐름(종류 선택 → 옵션 → 추가/수정)으로 편집한다(2026-08-17
|
|
// 사용자 지시 — 자동·수동 배관은 같은 구조물, 별도 UI 금지). 기슭막이 길이를
|
|
// 넣으면 시작·종료 측점이 기준 − 앞 ~ 기준 + 뒤로 자동 채워진다.
|
|
const facilityOptions = createFacilityOptionsForm();
|
|
|
|
/** 이 타입이 서브폼(계곡 통과 시설 부속)을 쓰는지 — 쓰면 레지스트리 옵션 칸 대신
|
|
* 서브폼이 그린다. 독립 기슭막이(D4)는 서브폼을 떠나 C군과 같은 레지스트리 옵션
|
|
* 방식(측점 범위 + 기준측점 전/후)으로 옮겼다(2026-08-19 사용자 지시 3). */
|
|
function facilityFormKind(type: StructureType | null): PipeFacility | null {
|
|
if (!type) return null;
|
|
return type.managed_by ? (type.type_id as PipeFacility) : null;
|
|
}
|
|
|
|
/** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이
|
|
* 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만
|
|
* 준다(2026-08-19 사용자 지시). */
|
|
function hasComputedRange(type: StructureType | null): boolean {
|
|
return (
|
|
!!type &&
|
|
!type.managed_by &&
|
|
type.placement === "interval" &&
|
|
type.options.some((option) => option.key === "length_m" && isB05Option(option))
|
|
);
|
|
}
|
|
|
|
/** 길이 옵션(length_m) 값(m). 비었거나 0 이하면 null — 범위를 계산할 수 없다. */
|
|
function readLengthM(): number | null {
|
|
const entry = optionInputs.find((input) => input.key === "length_m");
|
|
if (!entry || entry.isEmpty()) return null;
|
|
const length = Number(entry.read());
|
|
return Number.isFinite(length) && length > 0 ? length : null;
|
|
}
|
|
|
|
/** 기준측점 전(before_m, m) — 기준 측점에서 시점 쪽으로 물러나는 몫. 비우면 0
|
|
* (= 기준이 곧 시작). 총길이를 넘길 수 없다 — 넘치면 총길이로 자른다(후 0). */
|
|
function readBeforeM(): number {
|
|
const entry = optionInputs.find((input) => input.key === "before_m");
|
|
if (!entry || entry.isEmpty()) return 0;
|
|
const before = Number(entry.read());
|
|
if (!Number.isFinite(before) || before < 0) return 0;
|
|
const length = readLengthM();
|
|
return length !== null && before > length ? length : before;
|
|
}
|
|
|
|
/** 기준측점 전·후 중 나중에 고친 쪽 — 길이가 바뀔 때 이쪽 값을 지키며 재배분한다. */
|
|
let lastSplitEdit: "before_m" | "after_m" = "before_m";
|
|
|
|
/** 길이·전·후 연동(2026-08-19 사용자 지시): 전 + 후 = 길이. 나중에 입력된 쪽이
|
|
* 살아남고 반대쪽이 길이 − 그 값으로 맞춰진다. `normalizeSource`는 입력 확정
|
|
* (change) 때만 true — 타이핑(input) 중에 그 칸을 다시 쓰면 입력이 끊긴다. */
|
|
function syncSplitInputs(
|
|
source: "length" | "before_m" | "after_m",
|
|
normalizeSource: boolean,
|
|
): void {
|
|
const before = optionInputs.find((entry) => entry.key === "before_m");
|
|
const after = optionInputs.find((entry) => entry.key === "after_m");
|
|
if (before && after) {
|
|
if (source !== "length") lastSplitEdit = source;
|
|
const length = readLengthM() ?? 0;
|
|
const keepEntry = source === "after_m" ? after : source === "before_m" ? before : null;
|
|
const keep = keepEntry ?? (lastSplitEdit === "after_m" ? after : before);
|
|
const raw = keep.isEmpty() ? 0 : Number(keep.read());
|
|
const kept = Math.min(Math.max(Number.isFinite(raw) ? raw : 0, 0), length);
|
|
if (normalizeSource) keep.input.value = kept.toFixed(1);
|
|
(keep === before ? after : before).input.value = Math.max(length - kept, 0).toFixed(1);
|
|
}
|
|
syncRangeDisplay();
|
|
}
|
|
|
|
/** 측점 범위 표시 갱신 — 시작 = 기준 − 전길이, 종료 = 시작 + 길이(2026-08-19
|
|
* 사용자 지시: 전/후 분할, 후길이 = 길이 − 전길이 자동). 계산 불가면 "—". */
|
|
function syncRangeDisplay(): void {
|
|
const computed = hasComputedRange(currentType());
|
|
rangeWrap.hidden = !computed;
|
|
if (!computed) return;
|
|
const step = interval();
|
|
const anchor = anchorFields.read(step, false);
|
|
const length = readLengthM();
|
|
const start = anchor === null ? null : anchor - readBeforeM();
|
|
rangeValue.textContent =
|
|
start !== null && length !== null && start > -0.005
|
|
? `${formatStation(Math.max(start, 0), step)} ~ ${formatStation(Math.max(start, 0) + length, step)}`
|
|
: "—";
|
|
}
|
|
|
|
const primary = document.createElement("button");
|
|
primary.type = "button";
|
|
primary.className = "b05-route__irregular-btn is-primary";
|
|
const removeButton = document.createElement("button");
|
|
removeButton.type = "button";
|
|
removeButton.className = "b05-route__irregular-btn is-danger";
|
|
removeButton.textContent = "삭제";
|
|
const resetButton = document.createElement("button");
|
|
resetButton.type = "button";
|
|
resetButton.className = "b05-route__irregular-btn";
|
|
resetButton.textContent = "리셋";
|
|
const actions = document.createElement("div");
|
|
actions.className = "b05-route__irregular-actions";
|
|
actions.append(primary, removeButton, resetButton);
|
|
|
|
// 목록은 이 섹션 본문에 붙이지 않는다 — 폼이 길어지면 스크롤 밖으로 밀려 안 보였다.
|
|
// 패널(B05_Profile_UI_Panel)이 하단 고정 영역에 배치한다(2026-08-18 사용자 지시).
|
|
const list = document.createElement("ul");
|
|
list.className = "b05-route__irregular-list";
|
|
// 사용법 설명 문단은 두지 않는다 — 공간 대비 의미 없음(2026-08-17 사용자 지시,
|
|
// 필요 시 별도 설명 페이지로).
|
|
|
|
// 측점 그룹과 옵션 나열 사이 구분선(2026-08-17 사용자 지시 2).
|
|
const positionDivider = document.createElement("hr");
|
|
positionDivider.className = "b05-structure__divider";
|
|
|
|
body.append(typeRow, positionRow, positionDivider, optionRow, facilityOptions.root, actions);
|
|
|
|
let types: StructureType[] = [];
|
|
let structures: StructureInstance[] = [];
|
|
let pipeFacilities: PipeFacilityItem[] = [];
|
|
let editingId: string | null = null;
|
|
/** 목록에서 고른 계곡 통과 시설(누가거리 키). 삭제 버튼이 이쪽으로 동작한다. */
|
|
let selectedPipeChainage: number | null = null;
|
|
/** 임시 배치 중인 A군 시설의 누가거리(2026-08-18) — 측점을 넣는 순간 관 지점에
|
|
* 미리 넣어 세부유역·설계유량 미리보기를 얻고, [추가]로만 확정한다. 취소(삭제·
|
|
* 리셋·다른 항목 선택·종류 변경)하면 관을 물려 추가 직전 상태로 되돌린다. */
|
|
let tempPipeChainage: number | null = null;
|
|
/** 위치 입력이 "확정"됐는지 — 측점번호+잔여거리를 다 받고 입력군을 빠져나간
|
|
* 순간(또는 폼에 위치를 실어 연 순간)에만 true. 옵션 활성화 시점도 임시 배치와
|
|
* 같은 이 순간이다(2026-08-18 사용자 지시). */
|
|
let positionConfirmed = false;
|
|
let optionInputs: Array<{
|
|
key: string;
|
|
required: boolean;
|
|
input: HTMLInputElement | HTMLSelectElement;
|
|
read: () => string | number;
|
|
isEmpty: () => boolean;
|
|
}> = [];
|
|
|
|
function typeMap(): Map<string, StructureType> {
|
|
return new Map(types.map((type) => [type.type_id, type]));
|
|
}
|
|
|
|
function currentType(): StructureType | null {
|
|
return typeMap().get(typeSelect.value) ?? null;
|
|
}
|
|
|
|
function placementOf(typeId: string): StructurePlacement {
|
|
return typeMap().get(typeId)?.placement ?? "point";
|
|
}
|
|
|
|
const interval = (): number => callbacks.getInterval();
|
|
|
|
/** 배치형태·관리 주체에 맞춰 위치 칸을 바꾼다. 계곡 통과 시설(배수관 등)은 점형이라
|
|
* 기준 측점 하나만 받는다(2026-08-17 사용자 지시 2 — 시작·종료 불필요).
|
|
* 범위 계산 타입(C군 등)도 기준 측점만 받고 범위는 표시 행이 대신한다(2026-08-19).
|
|
* 종류 미선택(빈값)이면 입력 칸 전체를 숨긴다 — 리셋 직후 상태. */
|
|
function syncPlacementFields(): void {
|
|
const type = currentType();
|
|
const managed = !!type?.managed_by;
|
|
const isInterval =
|
|
!!type && !managed && type.placement === "interval" && !hasComputedRange(type);
|
|
positionRow.hidden = !type;
|
|
positionDivider.hidden = !type;
|
|
startFields.wrap.hidden = !isInterval;
|
|
endFields.wrap.hidden = !isInterval;
|
|
primary.disabled = !type;
|
|
anchorFields.wrap.querySelector("span")!.textContent = isInterval
|
|
? "기준 측점 (비우면 시작)"
|
|
: "기준 측점";
|
|
syncRangeDisplay();
|
|
}
|
|
|
|
/** 종류에 맞춰 부속 옵션 서브폼을 켠다(계곡 통과 시설·독립 기슭막이, 값은 인자로). */
|
|
function syncFacilityForm(
|
|
options: Record<string, string | number> = {},
|
|
designFlow: number | null = null,
|
|
): void {
|
|
facilityOptions.setFacility(facilityFormKind(currentType()), options, designFlow);
|
|
syncOptionLock();
|
|
}
|
|
|
|
/** 위치(측점)가 채워졌는지 — 상세 옵션을 여는 조건(2026-08-18 입력 순서 가이드).
|
|
* 범위 계산 타입은 기준 측점 하나가 곧 위치다(2026-08-19). */
|
|
function hasPosition(): boolean {
|
|
const type = currentType();
|
|
if (!type) return false;
|
|
const step = interval();
|
|
if (!type.managed_by && type.placement === "interval" && !hasComputedRange(type)) {
|
|
return startFields.read(step, false) !== null && endFields.read(step, false) !== null;
|
|
}
|
|
return anchorFields.read(step, false) !== null;
|
|
}
|
|
|
|
/** 측점 미입력 상태에서는 상세 옵션(레지스트리 옵션 칸·부속 서브폼)을 잠근다 —
|
|
* 입력 순서 가이드(2026-08-18 사용자 지시). 자동 배치 시설은 측점을 갖고 폼에
|
|
* 올라오므로 항상 열려 있다. */
|
|
function syncOptionLock(): void {
|
|
const locked = !(positionConfirmed && hasPosition());
|
|
optionInputs.forEach((entry) => {
|
|
entry.input.disabled = locked;
|
|
});
|
|
optionRow.classList.toggle("is-locked", locked);
|
|
facilityOptions.root.inert = locked;
|
|
facilityOptions.root.classList.toggle("is-locked", locked);
|
|
}
|
|
|
|
/** A군이면 기준 측점(측점번호+잔여거리)으로 임시 배치를 만들거나 옮긴다
|
|
* (세부유역 미리보기, 2026-08-18). 측점 두 칸을 **다 쓰고 빠져나온 뒤**에만
|
|
* 부른다 — 측점번호만 넣고 거리 칸으로 넘어가는 순간 나가면 재계산 동기화가
|
|
* 입력을 끊는다(2026-08-18 사용자 보고). */
|
|
function commitTempPipe(): void {
|
|
const type = currentType();
|
|
if (!type?.managed_by) return;
|
|
const anchor = anchorFields.read(interval(), false);
|
|
// 기존 관(임시 아님)을 수정 중일 때 기준점 이동은 [수정]으로만 반영한다.
|
|
if (anchor === null || (selectedPipeChainage !== null && tempPipeChainage === null)) return;
|
|
const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility };
|
|
if (tempPipeChainage === null) {
|
|
tempPipeChainage = anchor;
|
|
callbacks.onPipeAdd(anchor, attributes);
|
|
} else if (Math.abs(tempPipeChainage - anchor) > 0.005) {
|
|
const from = tempPipeChainage;
|
|
tempPipeChainage = anchor;
|
|
callbacks.onPipeUpdate(from, anchor, attributes);
|
|
}
|
|
syncButtons();
|
|
}
|
|
|
|
/** 측점 입력 변경 — 값을 지우면 확정이 풀려 옵션이 즉시 다시 잠긴다.
|
|
* 확정(활성화)은 입력군 이탈 시 commitPosition이 한다. */
|
|
function handleStationInput(): void {
|
|
if (!hasPosition()) positionConfirmed = false;
|
|
syncOptionLock();
|
|
syncRangeDisplay();
|
|
}
|
|
|
|
/** 위치 입력 확정 — 측점번호+잔여거리를 다 받고 입력군을 빠져나온 순간.
|
|
* 옵션 활성화와 A군 임시 배치가 이 한 지점에서 같이 나간다(2026-08-18). */
|
|
function commitPosition(): void {
|
|
positionConfirmed = hasPosition();
|
|
commitTempPipe();
|
|
syncOptionLock();
|
|
syncRangeDisplay();
|
|
}
|
|
|
|
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 상세(detail) 옵션은 B06/B07
|
|
* 몫이라 그리지 않는다(B05 = 유무·종류·위치 단계, 2026-08-17 사용자 확정). */
|
|
function renderOptionFields(values: Record<string, string | number> = {}): void {
|
|
const type = currentType();
|
|
optionInputs = [];
|
|
optionRow.replaceChildren();
|
|
// 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다.
|
|
const visible = type && !facilityFormKind(type) ? type.options.filter(isB05Option) : [];
|
|
if (!visible.length) {
|
|
optionRow.hidden = true;
|
|
syncOptionLock();
|
|
return;
|
|
}
|
|
optionRow.hidden = false;
|
|
visible.forEach((option) => {
|
|
const preset = values[option.key] ?? option.default ?? "";
|
|
let input: HTMLInputElement | HTMLSelectElement;
|
|
if (option.input === "select") {
|
|
// 빈("선택하세요") 항목은 두지 않는다 — 첫 항목이 곧 기본값이고, 기본값은
|
|
// 구조물별로 레지스트리에서 지정한다(2026-08-17 사용자 지시 1).
|
|
const choices = option.choices.map((choice) => [choice, choice] as [string, string]);
|
|
input = select(choices);
|
|
input.value = String(preset || (option.choices[0] ?? ""));
|
|
} else if (option.input === "number") {
|
|
input = numberInput("0.1", "0");
|
|
input.value = String(preset ?? "");
|
|
if (option.required) (input as HTMLInputElement).placeholder = "필수 입력";
|
|
} else {
|
|
input = document.createElement("input");
|
|
(input as HTMLInputElement).type = "text";
|
|
input.value = String(preset ?? "");
|
|
}
|
|
const label = option.unit ? `${option.label} (${option.unit})` : option.label;
|
|
optionRow.append(field(label, input));
|
|
optionInputs.push({
|
|
key: option.key,
|
|
required: !!option.required,
|
|
input,
|
|
read: () => (option.input === "number" ? Number(input.value) || 0 : input.value),
|
|
isEmpty: () => input.value.trim() === "",
|
|
});
|
|
// 길이·기준측점 전/후가 바뀌면 서로 연동하고 측점 범위 표시가 즉시 따라온다
|
|
// (범위 계산 타입, 2026-08-19). 전·후는 **나중에 고친 쪽**이 살아남고 반대쪽이
|
|
// 길이 − 그 값으로 맞춰진다. 길이를 고치면 나중에 고쳤던 쪽을 지키며 재배분.
|
|
if (option.key === "length_m" || option.key === "before_m" || option.key === "after_m") {
|
|
const source = (option.key === "length_m" ? "length" : option.key) as
|
|
"length" | "before_m" | "after_m";
|
|
const refresh = (normalize: boolean): void => {
|
|
input.classList.remove("is-invalid");
|
|
syncSplitInputs(source, normalize);
|
|
};
|
|
input.addEventListener("input", () => refresh(false));
|
|
input.addEventListener("change", () => refresh(true));
|
|
}
|
|
});
|
|
syncOptionLock();
|
|
syncRangeDisplay();
|
|
}
|
|
|
|
/** 고른 구조물군의 타입을 종류 목록에 채운다 — A군은 계곡 통과 시설(managed_by)도
|
|
* 포함한다(추가 시 관 지점 정본으로 간다, 2026-08-17 통합). 첫 항목은 빈칸 —
|
|
* 초기·리셋 상태를 "안 고름"으로 드러낸다. */
|
|
function syncTypeOptions(keepTypeId?: string): void {
|
|
const group = groupSelect.value;
|
|
const candidates = group ? types.filter((type) => type.group === group) : [];
|
|
typeSelect.replaceChildren(
|
|
new Option("— 선택 —", ""),
|
|
...candidates.map((type) => new Option(type.name, type.type_id)),
|
|
);
|
|
typeSelect.value =
|
|
keepTypeId && candidates.some((type) => type.type_id === keepTypeId) ? keepTypeId : "";
|
|
syncPlacementFields();
|
|
renderOptionFields();
|
|
syncFacilityForm();
|
|
}
|
|
|
|
/** 임시 배치를 물리고 추가 직전 상태로 되돌린다(관 제거 → 세부유역 재분할). */
|
|
function cancelTempPipe(): void {
|
|
if (tempPipeChainage === null) return;
|
|
const chainage = tempPipeChainage;
|
|
tempPipeChainage = null;
|
|
if (selectedPipeChainage !== null && Math.abs(selectedPipeChainage - chainage) < 0.51) {
|
|
selectedPipeChainage = null;
|
|
}
|
|
callbacks.onPipeRemove(chainage);
|
|
}
|
|
|
|
/** 폼 전체 초기화 — 리셋 버튼과 선택 해제가 같은 상태를 공유한다(2026-08-18
|
|
* 사용자 지시: 해제 시 구조물군·종류·측점까지 리셋과 동일하게 비운다). */
|
|
function resetForm(): void {
|
|
const hadPipe = selectedPipeChainage !== null || tempPipeChainage !== null;
|
|
cancelTempPipe();
|
|
groupSelect.value = "";
|
|
syncTypeOptions();
|
|
loadForm(null);
|
|
// 시설이 골라져 있었으면 전 화면(그래프·3D·배수유역도) 선택도 함께 푼다.
|
|
if (hadPipe) callbacks.onPipeSelect(null);
|
|
}
|
|
|
|
function syncButtons(): void {
|
|
primary.textContent =
|
|
tempPipeChainage !== null
|
|
? "추가"
|
|
: editingId || selectedPipeChainage !== null
|
|
? "수정"
|
|
: "추가";
|
|
removeButton.disabled =
|
|
editingId === null && selectedPipeChainage === null && tempPipeChainage === null;
|
|
}
|
|
|
|
function renderList(): void {
|
|
renderStructureList({
|
|
list,
|
|
structures,
|
|
pipeFacilities,
|
|
typeMap: typeMap(),
|
|
intervalM: interval(),
|
|
editingId,
|
|
selectedPipeChainage,
|
|
// 재선택 = 해제(2026-08-18 복구) — 해제는 리셋과 같은 전체 초기화다.
|
|
onSelectStructure: (structure) => {
|
|
if (structure.structure_id !== null && structure.structure_id === editingId) resetForm();
|
|
else loadForm(structure);
|
|
},
|
|
onSelectPipe: (pipe) => {
|
|
const isSame =
|
|
selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05;
|
|
if (isSame) {
|
|
resetForm();
|
|
} else {
|
|
loadPipeForm(pipe);
|
|
callbacks.onPipeSelect(pipe.chainage_m);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
function loadForm(target: StructureInstance | null): void {
|
|
// 다른 항목으로 넘어가면 임시 배치(A군 미리보기)는 취소된다.
|
|
cancelTempPipe();
|
|
editingId = target?.structure_id ?? null;
|
|
selectedPipeChainage = null;
|
|
// 위치가 실려 열리는 폼(기존 항목)은 확정 상태, 빈 폼은 미확정으로 시작한다.
|
|
positionConfirmed = target !== null;
|
|
const step = interval();
|
|
if (target) {
|
|
const type = typeMap().get(target.type_id);
|
|
if (type) {
|
|
groupSelect.value = type.group;
|
|
syncTypeOptions(target.type_id);
|
|
}
|
|
anchorFields.write(structureAnchorM(target), step);
|
|
startFields.write(target.start_m ?? null, step);
|
|
endFields.write(target.end_m ?? null, step);
|
|
memoField.value = target.memo ?? "";
|
|
renderOptionFields(target.options);
|
|
// 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된
|
|
// 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19).
|
|
if (target.start_m != null && target.end_m != null && target.end_m > target.start_m) {
|
|
const lengthEntry = optionInputs.find((entry) => entry.key === "length_m");
|
|
if (lengthEntry && lengthEntry.isEmpty()) {
|
|
lengthEntry.input.value = (target.end_m - target.start_m).toFixed(1);
|
|
}
|
|
const beforeEntry = optionInputs.find((entry) => entry.key === "before_m");
|
|
if (beforeEntry && target.options.before_m === undefined) {
|
|
// 옵션이 없던 시절 항목은 기본값(5)이 아니라 저장된 기준−시작을 그대로 쓴다
|
|
// — 0이어도 명시해야 기본값이 범위를 옆으로 밀지 않는다.
|
|
const before = Math.max(structureAnchorM(target) - target.start_m, 0);
|
|
beforeEntry.input.value = before.toFixed(1);
|
|
}
|
|
const afterEntry = optionInputs.find((entry) => entry.key === "after_m");
|
|
if (afterEntry && target.options.after_m === undefined) {
|
|
const after = Math.max(target.end_m - structureAnchorM(target), 0);
|
|
afterEntry.input.value = after.toFixed(1);
|
|
}
|
|
}
|
|
syncFacilityForm(target.options);
|
|
} else {
|
|
anchorFields.write(null, step);
|
|
startFields.write(null, step);
|
|
endFields.write(null, step);
|
|
memoField.value = "";
|
|
renderOptionFields();
|
|
syncFacilityForm();
|
|
}
|
|
[anchorFields, startFields, endFields].forEach((fields) => fields.clearInvalid());
|
|
syncPlacementFields();
|
|
syncButtons();
|
|
renderList();
|
|
callbacks.onSelect(target);
|
|
}
|
|
|
|
/** 계곡 통과 시설을 폼에 올린다 — 일반 구조물과 같은 흐름(종류·측점·옵션·수정).
|
|
* 정본이 관 지점이라 editingId 대신 선택 누가거리로 추적한다. */
|
|
function loadPipeForm(pipe: PipeFacilityItem): void {
|
|
// 임시 배치 중인 그 관이 재계산을 거쳐 되돌아온 경우 — 사용자가 폼에 입력하는
|
|
// 중이므로 폼을 다시 그리지 않는다(입력이 지워진다, 2026-08-18 사용자 보고).
|
|
// 재계산으로 갱신된 설계유량만 살짝 반영하고 선택 표시를 맞춘다.
|
|
// 관은 계획선에 스냅되며 입력 누가거리와 살짝 어긋날 수 있다 — 0.51m까지 같은
|
|
// 관으로 본다(다른 곳의 관 매칭 기준과 동일).
|
|
const isTemp = tempPipeChainage !== null && Math.abs(pipe.chainage_m - tempPipeChainage) < 0.51;
|
|
if (isTemp) {
|
|
tempPipeChainage = pipe.chainage_m;
|
|
selectedPipeChainage = pipe.chainage_m;
|
|
facilityOptions.setDesignFlow(pipe.design_flow_m3s ?? null);
|
|
syncButtons();
|
|
renderList();
|
|
return;
|
|
}
|
|
// 다른 항목으로 넘어가는 것이므로 임시 배치를 취소한다.
|
|
cancelTempPipe();
|
|
const hadStructure = editingId !== null;
|
|
editingId = null;
|
|
selectedPipeChainage = pipe.chainage_m;
|
|
positionConfirmed = true; // 기존 관 — 위치가 실려 열린다.
|
|
const step = interval();
|
|
const type = typeMap().get(pipe.facility);
|
|
if (type) {
|
|
groupSelect.value = type.group;
|
|
syncTypeOptions(pipe.facility);
|
|
}
|
|
anchorFields.write(pipe.chainage_m, step);
|
|
// 계곡 통과 시설은 점형 — 시작·종료 칸은 감춰지므로 비워 둔다.
|
|
startFields.write(null, step);
|
|
endFields.write(null, step);
|
|
memoField.value = "";
|
|
syncFacilityForm(pipe.options ?? {}, pipe.design_flow_m3s ?? null);
|
|
[anchorFields, startFields, endFields].forEach((fields) => fields.clearInvalid());
|
|
syncPlacementFields();
|
|
syncButtons();
|
|
renderList();
|
|
if (hadStructure) callbacks.onSelect(null);
|
|
}
|
|
|
|
function readOptions(): Record<string, string | number> {
|
|
// 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다.
|
|
if (facilityFormKind(currentType())) return facilityOptions.readOptions();
|
|
const values: Record<string, string | number> = {};
|
|
optionInputs.forEach((input) => {
|
|
// 빈 칸은 아예 넣지 않는다 — 빈 숫자를 0으로 저장하면 "0으로 확정"과 구분이 안 된다.
|
|
if (input.isEmpty()) return;
|
|
values[input.key] = input.read();
|
|
});
|
|
return values;
|
|
}
|
|
|
|
function emit(): void {
|
|
renderList();
|
|
callbacks.onChange([...structures]);
|
|
}
|
|
|
|
async function commit(): Promise<void> {
|
|
const type = currentType();
|
|
if (!type) return;
|
|
const step = interval();
|
|
|
|
// 계곡 통과 시설 — 기준 측점 + 구간 + 부속 옵션을 관 지점 정본으로 보낸다
|
|
// (시설 종류 = type_id). 목록에서 골라 온 경우는 수정(기준점 이동 포함)이다.
|
|
if (type.managed_by) {
|
|
const anchor = anchorFields.read(step, true);
|
|
if (anchor === null) {
|
|
anchorFields.station.focus();
|
|
return;
|
|
}
|
|
// 배수관 등 계곡 통과 시설은 점형 — 기준 측점 하나뿐이다(2026-08-17 지시 2).
|
|
const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility };
|
|
const options = facilityOptions.readOptions();
|
|
if (Object.keys(options).length) attributes.options = options;
|
|
if (tempPipeChainage !== null) {
|
|
// 임시 배치 확정([추가]) — 관은 이미 미리보기로 들어가 있으니 옵션·위치만 반영.
|
|
const from = tempPipeChainage;
|
|
tempPipeChainage = null;
|
|
callbacks.onPipeUpdate(from, anchor, attributes);
|
|
} else if (selectedPipeChainage !== null) {
|
|
callbacks.onPipeUpdate(selectedPipeChainage, anchor, attributes);
|
|
} else {
|
|
callbacks.onPipeAdd(anchor, attributes);
|
|
}
|
|
loadForm(null);
|
|
return;
|
|
}
|
|
|
|
const placement = type.placement;
|
|
let anchor: number | null;
|
|
let start: number | null = null;
|
|
let end: number | null = null;
|
|
if (placement === "interval" && hasComputedRange(type)) {
|
|
// 범위 계산 타입(C군 등) — 시작 = 기준 − 전길이, 종료 = 시작 + 길이
|
|
// (2026-08-19 사용자 지시: 전/후 분할). 전길이가 기준을 넘어 시작이 음수면 거절.
|
|
anchor = anchorFields.read(step, true);
|
|
if (anchor === null) {
|
|
anchorFields.station.focus();
|
|
return;
|
|
}
|
|
const length = readLengthM();
|
|
if (length === null) {
|
|
optionInputs.find((entry) => entry.key === "length_m")?.input.focus();
|
|
return;
|
|
}
|
|
const before = readBeforeM();
|
|
if (anchor - before < -0.005) {
|
|
const beforeInput = optionInputs.find((entry) => entry.key === "before_m")?.input;
|
|
beforeInput?.classList.add("is-invalid");
|
|
beforeInput?.focus();
|
|
return;
|
|
}
|
|
start = Math.max(anchor - before, 0);
|
|
end = start + length;
|
|
} else if (placement === "interval") {
|
|
start = startFields.read(step, true);
|
|
end = endFields.read(step, true);
|
|
if (start === null || end === null || end <= start) {
|
|
(start === null ? startFields.station : endFields.station).focus();
|
|
return;
|
|
}
|
|
// 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다.
|
|
anchor = anchorFields.read(step, false) ?? start;
|
|
if (anchor < start || anchor > end) {
|
|
anchorFields.station.classList.add("is-invalid");
|
|
anchorFields.station.focus();
|
|
return;
|
|
}
|
|
} else {
|
|
anchor = anchorFields.read(step, true);
|
|
if (anchor === null) {
|
|
anchorFields.station.focus();
|
|
return;
|
|
}
|
|
}
|
|
// 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다.
|
|
// 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다).
|
|
const missing = optionInputs.find((entry) => entry.required && entry.isEmpty());
|
|
if (missing) {
|
|
missing.input.focus();
|
|
return;
|
|
}
|
|
|
|
const base = {
|
|
type_id: type.type_id,
|
|
placement,
|
|
chainage_m: anchor,
|
|
start_m: start,
|
|
end_m: end,
|
|
options: readOptions(),
|
|
memo: memoField.value.trim(),
|
|
placement_source: "manual" as const,
|
|
status: "draft" as const,
|
|
revision: 0,
|
|
geometry: null,
|
|
};
|
|
|
|
// 포장 구간은 물넘이포장과 겹칠 수 없다 — 통째로 품으면 나누고, 끝에 걸치면 멈춘다
|
|
// (2026-08-28 사용자 확정). 다른 타입은 원래 구간 한 벌 그대로다.
|
|
const spans =
|
|
type.type_id === "pavement_concrete" && start !== null && end !== null
|
|
? await splitPavementRange(start, end, pipeFacilities)
|
|
: [{ start, end }];
|
|
if (!spans) return;
|
|
const records = spans.map((span) => ({
|
|
...base,
|
|
start_m: span.start,
|
|
end_m: span.end,
|
|
chainage_m:
|
|
span.start === null || span.end === null
|
|
? anchor
|
|
: Math.min(Math.max(anchor as number, span.start), span.end),
|
|
}));
|
|
|
|
if (editingId) {
|
|
const index = structures.findIndex((entry) => entry.structure_id === editingId);
|
|
if (index >= 0) structures[index] = { ...structures[index], ...records[0] };
|
|
records.slice(1).forEach((record) => structures.push({ ...record, structure_id: null }));
|
|
} else {
|
|
records.forEach((record) => structures.push({ ...record, structure_id: null }));
|
|
}
|
|
loadForm(null);
|
|
emit();
|
|
}
|
|
|
|
groupSelect.addEventListener("change", () => {
|
|
cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18).
|
|
syncTypeOptions();
|
|
// 종류가 바뀌면 기존 항목 수정이 아니라 새 항목 추가로 넘어간다(옵션 스키마가 달라진다).
|
|
if (editingId) {
|
|
editingId = null;
|
|
syncButtons();
|
|
renderList();
|
|
callbacks.onSelect(null);
|
|
}
|
|
});
|
|
typeSelect.addEventListener("change", () => {
|
|
cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18).
|
|
syncPlacementFields();
|
|
renderOptionFields();
|
|
// 배수관·BOX암거·세월교·독립 기슭막이는 부속 옵션 서브폼이 따라 열려야 한다 —
|
|
// 수동 추가든 자동 배치 지점 편집이든 같은 옵션을 받는다(2026-08-17 사용자 지시:
|
|
// 자동은 통수단면으로 자리를 잡아 준 것일 뿐 같은 구조물이다).
|
|
syncFacilityForm();
|
|
if (editingId) {
|
|
editingId = null;
|
|
syncButtons();
|
|
renderList();
|
|
callbacks.onSelect(null);
|
|
}
|
|
});
|
|
primary.addEventListener("click", () => void commit());
|
|
removeButton.addEventListener("click", () => {
|
|
// 임시 배치 중이면 취소와 같다 — 관을 물리고 추가 직전 상태로(2026-08-18).
|
|
if (tempPipeChainage !== null) {
|
|
resetForm();
|
|
return;
|
|
}
|
|
// 계곡 통과 시설이 골라져 있으면 관 지점 정본에서 지운다(배수유역 패널 경유).
|
|
if (selectedPipeChainage !== null) {
|
|
callbacks.onPipeRemove(selectedPipeChainage);
|
|
selectedPipeChainage = null;
|
|
syncButtons();
|
|
renderList();
|
|
return;
|
|
}
|
|
if (!editingId) return;
|
|
const index = structures.findIndex((entry) => entry.structure_id === editingId);
|
|
if (index >= 0) structures.splice(index, 1);
|
|
loadForm(null);
|
|
emit();
|
|
});
|
|
// 리셋은 폼만 아니라 구조물군·종류도 빈칸으로 되돌린다(2026-08-17 사용자 지시).
|
|
// 임시 배치 취소도 겸한다(2026-08-18).
|
|
resetButton.addEventListener("click", resetForm);
|
|
// 측점 입력 감시 — 옵션 잠금 해제 판정(2026-08-18).
|
|
[anchorFields, startFields, endFields].forEach((fields) =>
|
|
[fields.station, fields.remainder].forEach((input) =>
|
|
input.addEventListener("change", handleStationInput),
|
|
),
|
|
);
|
|
// 측점번호+잔여거리가 **둘 다 확정된 순간** 임시 배치·옵션 활성화가 바로 나간다
|
|
// (2026-08-18 사용자 지시 — 입력군을 떠나기 전이라도). 잔여거리 값이 확정되면
|
|
// 즉시, 측점번호 수정은 잔여거리가 이미 있을 때만 즉시.
|
|
anchorFields.remainder.addEventListener("change", () => commitPosition());
|
|
anchorFields.station.addEventListener("change", () => {
|
|
if (anchorFields.remainder.value.trim() !== "") commitPosition();
|
|
});
|
|
// 그 외(잔여거리를 안 쓰는 정측점 입력 등)는 입력군에서 포커스가 완전히 빠져나간
|
|
// 뒤에 확정한다 — 측점번호만 넣고 잔여거리 칸으로 옮기는 중간에는 나가지 않는다.
|
|
// Enter로도 바로 나갈 수 있다.
|
|
positionRow.addEventListener("focusout", () => {
|
|
// 다음 포커스 대상이 확정된 다음 프레임에 판단한다(relatedTarget은 브라우저에
|
|
// 따라 비어 온다).
|
|
window.requestAnimationFrame(() => {
|
|
if (positionRow.contains(document.activeElement)) return;
|
|
commitPosition();
|
|
});
|
|
});
|
|
/** 지금 보이는 위치 입력 칸들(화면 순서). Tab 흐름 판정에 쓴다. */
|
|
function visiblePositionInputs(): HTMLInputElement[] {
|
|
return [startFields, anchorFields, endFields]
|
|
.filter((fields) => !fields.wrap.hidden)
|
|
.flatMap((fields) => [fields.station, fields.remainder]);
|
|
}
|
|
|
|
/** 상세 옵션의 첫 입력 칸으로 포커스 — 없으면 메모로. */
|
|
function focusFirstDetailInput(): void {
|
|
const target =
|
|
optionInputs.find((entry) => !entry.input.disabled && !optionRow.hidden)?.input ??
|
|
facilityOptions.root.querySelector<HTMLElement>(
|
|
"input:not(:disabled), select:not(:disabled)",
|
|
) ??
|
|
memoField;
|
|
target.focus();
|
|
}
|
|
|
|
positionRow.addEventListener("keydown", (event) => {
|
|
// Enter = 입력 확정 — 포커스를 빼서 focusout 경로 하나로 처리한다(측점번호+
|
|
// 잔여거리 합산값이 그대로 임시 배치 좌표가 된다).
|
|
if (event.key === "Enter" && document.activeElement instanceof HTMLElement) {
|
|
document.activeElement.blur();
|
|
}
|
|
// Tab = 화면 순서(위→아래)대로 다음 입력인 상세 옵션 첫 칸으로. 위치 확정으로
|
|
// 옵션 잠금이 풀리는 시점이 브라우저 기본 탭 계산보다 늦어, disabled 상태를 본
|
|
// 탭이 메모까지 건너뛰던 문제(2026-08-19 사용자 보고).
|
|
if (event.key === "Tab" && !event.shiftKey) {
|
|
const inputs = visiblePositionInputs();
|
|
if (document.activeElement === inputs[inputs.length - 1]) {
|
|
event.preventDefault();
|
|
(document.activeElement as HTMLElement).blur(); // change → 위치 확정 → 잠금 해제
|
|
window.requestAnimationFrame(focusFirstDetailInput);
|
|
}
|
|
}
|
|
});
|
|
|
|
body.insertBefore(field("메모", memoField), actions);
|
|
syncButtons();
|
|
renderList();
|
|
|
|
return {
|
|
root,
|
|
listRoot: list,
|
|
setTypes(next) {
|
|
types = next;
|
|
const groups = [...new Set(next.map((t) => t.group))];
|
|
// 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다.
|
|
groupSelect.replaceChildren(
|
|
new Option("— 선택 —", ""),
|
|
...groups.map((group) => new Option(GROUP_LABELS[group] ?? group, group)),
|
|
);
|
|
groupSelect.value = "";
|
|
syncTypeOptions();
|
|
},
|
|
setStructures(next) {
|
|
// 서버 정본 주입 — onChange를 울리지 않는다. 울리면 Page가 다시 저장을 걸어
|
|
// 저장→재조회→주입→저장의 무한 고리가 된다(변경 알림은 사용자 조작에서만).
|
|
structures = next.map((entry) => ({ ...entry }));
|
|
loadForm(null);
|
|
renderList();
|
|
},
|
|
getStructures: () => [...structures],
|
|
setPipeFacilities(pipes) {
|
|
pipeFacilities = pipes.map((pipe) => ({ ...pipe }));
|
|
if (tempPipeChainage !== null) {
|
|
// 스냅·재계산으로 좌표가 조금 옮겨진 임시 관을 계속 따라간다(0.51m 기준).
|
|
const hit = pipeFacilities.find(
|
|
(pipe) => Math.abs(pipe.chainage_m - tempPipeChainage!) < 0.51,
|
|
);
|
|
if (hit) {
|
|
tempPipeChainage = hit.chainage_m;
|
|
if (selectedPipeChainage !== null) selectedPipeChainage = hit.chainage_m;
|
|
// 재계산이 끝나 도착한 담당 유역 설계유량을 입력 중인 폼에 반영한다 —
|
|
// 물넘이·세월교 월류 높이가 이 값으로 계산된다(2026-08-18 진단: 재계산
|
|
// 후 선택 통지가 조용히 끝나 폼이 유량을 못 받던 문제).
|
|
facilityOptions.setDesignFlow(hit.design_flow_m3s ?? null);
|
|
} else {
|
|
// 임시 배치 관이 재계산에서 사라졌다 — 임시 상태만 정리(관은 이미 없다).
|
|
tempPipeChainage = null;
|
|
syncButtons();
|
|
}
|
|
}
|
|
if (
|
|
selectedPipeChainage !== null &&
|
|
!pipeFacilities.some((pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < 0.05)
|
|
) {
|
|
// 고르고 있던 관이 사라졌다(삭제·재계산 이동) — 폼도 함께 닫는다.
|
|
selectedPipeChainage = null;
|
|
facilityOptions.setFacility(null);
|
|
syncButtons();
|
|
}
|
|
renderList();
|
|
},
|
|
selectPipeByChainage(chainageM) {
|
|
if (chainageM === null) {
|
|
// 외부 해제(빈 공간 클릭 등) — 임시 배치는 취소, 선택 중이던 시설도 구조물군·
|
|
// 종류까지 완전 리셋(2026-08-18 사용자 지시). 아무것도 안 골랐으면 무시.
|
|
if (tempPipeChainage !== null || selectedPipeChainage !== null) resetForm();
|
|
return;
|
|
}
|
|
// 그래프·3D·배수유역도에서 고른 관도 목록 강조 + 폼 로드까지 — 어느 경로든
|
|
// 같은 편집 화면이 열린다(2026-08-17 전역 선택 동기화).
|
|
const pipe = pipeFacilities.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.05);
|
|
if (!pipe) return;
|
|
loadPipeForm(pipe);
|
|
},
|
|
selectById(structureId) {
|
|
if (structureId === null) {
|
|
// 그래프·3D 빈 공간 클릭 등 외부 해제 — 선택 중이었으면 구조물군·종류까지
|
|
// 완전 리셋(2026-08-18 사용자 지시). 선택 없는 신규 작성 중에는 두지 않는다.
|
|
if (editingId !== null || selectedPipeChainage !== null || tempPipeChainage !== null) {
|
|
resetForm();
|
|
}
|
|
return;
|
|
}
|
|
loadForm(structures.find((entry) => entry.structure_id === structureId) ?? null);
|
|
},
|
|
addAt(chainageM, typeId) {
|
|
const type = typeMap().get(typeId);
|
|
if (!type) return;
|
|
// 우클릭 추가 = 즉시 추가 폐지(2026-08-18 사용자 지시 — 옵션 확대로 바로 넣을
|
|
// 수 없는 타입이 생겼다). 폼에 종류·측점을 자동으로 실어 옵션을 활성화하고,
|
|
// [추가]로 확정한다. A군은 측점이 실리는 순간 임시 배치(세부유역 미리보기)까지
|
|
// 자동으로 진행된다.
|
|
cancelTempPipe();
|
|
groupSelect.value = type.group;
|
|
syncTypeOptions(typeId);
|
|
typeSelect.value = typeId;
|
|
editingId = null;
|
|
selectedPipeChainage = null;
|
|
positionConfirmed = true; // 우클릭은 측점이 확정돼 들어온다 — 옵션도 바로 연다.
|
|
const placement = placementOf(typeId);
|
|
const step = interval();
|
|
// 범위 계산 타입은 시작·종료를 쓰지 않는다 — 길이 옵션이 정한다(2026-08-19).
|
|
const isInterval = !type.managed_by && placement === "interval" && !hasComputedRange(type);
|
|
anchorFields.write(chainageM, step);
|
|
startFields.write(isInterval ? chainageM : null, step);
|
|
endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step);
|
|
memoField.value = "";
|
|
syncPlacementFields();
|
|
renderOptionFields(type.managed_by ? undefined : defaultOptions(type));
|
|
// BOX암거는 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을 실어 폼을
|
|
// 연다 — [추가]만 눌러도 하류가 제원을 받는다(2026-08-17 사용자 확정 유지).
|
|
syncFacilityForm(typeId === "box_culvert" ? defaultOptions(type) : {});
|
|
// 임시 배치도 즉시 진행(A군).
|
|
if (type.managed_by) commitTempPipe();
|
|
syncButtons();
|
|
renderList();
|
|
root.scrollIntoView({ block: "nearest" });
|
|
const firstRequired = optionInputs.find((entry) => entry.required);
|
|
(firstRequired?.input ?? anchorFields.station).focus();
|
|
},
|
|
moveById(structureId, toChainageM) {
|
|
const index = structures.findIndex((entry) => entry.structure_id === structureId);
|
|
if (index < 0) return false;
|
|
const target = structures[index];
|
|
if (target.placement === "interval") {
|
|
// 구간은 길이를 유지한 채 통째로 옮긴다 — 기준점을 끌면 시작·종료가 따라온다.
|
|
const length = (target.end_m ?? 0) - (target.start_m ?? 0);
|
|
const anchorOffset = structureAnchorM(target) - (target.start_m ?? 0);
|
|
const start = toChainageM - anchorOffset;
|
|
structures[index] = {
|
|
...target,
|
|
chainage_m: toChainageM,
|
|
start_m: start,
|
|
end_m: start + length,
|
|
};
|
|
} else {
|
|
structures[index] = { ...target, chainage_m: toChainageM };
|
|
}
|
|
emit();
|
|
return true;
|
|
},
|
|
removeById(structureId) {
|
|
const index = structures.findIndex((entry) => entry.structure_id === structureId);
|
|
if (index < 0) return false;
|
|
structures.splice(index, 1);
|
|
if (editingId === structureId) loadForm(null);
|
|
emit();
|
|
return true;
|
|
},
|
|
};
|
|
}
|