From deaf51778fe6808450ba094c8109f77467b4561d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 17 Aug 2026 09:06:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EB=B3=91=ED=95=A9=202?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=E2=80=94=20=EC=B8=A1=EC=A0=90=20=ED=91=9C?= =?UTF-8?q?=EA=B8=B0=C2=B7=EA=B8=B0=EC=A4=80=EC=A0=90=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=C2=B7=EA=B3=84=EA=B3=A1=20=ED=86=B5=EA=B3=BC=20=EC=8B=9C?= =?UTF-8?q?=EC=84=A4=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 2026-08-17 「B05 구조물 컨테이너 병합」 프론트엔드. - B05_Profile_Util_Station.ts 신설: 측점번호+잔여거리("3+18.0") ↔ 누가거리 변환·서식·해석. 누가거리 직접 입력도 허용, 부동소수 올림 보정. - 구조물 패널: 위치 입력을 측점 표기 텍스트로 재편. 점형 = 기준 측점 1칸, 구간형 = 기준점(비우면 시작)+시작+종료 3칸, 시작≤기준≤종료 검증과 잘못된 입력 붉힘. 목록 표기도 측점식. 구간형 마크 드래그는 기준점 기준으로 시작· 종료가 따라온다. 상세(detail) 옵션은 폼에서 숨긴다 — B05는 유무·종류·위치 단계(2026-08-17 사용자 확정). - 우클릭 메뉴 필터 완화: managed_by와 b05 phase 필수만 제외 — 상세 필수였던 타입(옹벽·돌쌓기 등)도 우클릭 한 번으로 추가된다. - B05_Profile_UI_Drainage_Facility.ts 신설(패널 700줄 제한 대응 분리): 관 마커 선택 시 시설 종류(배관/BOX암거/물넘이/세월교)·구간·세월교 관 종류/크기/수량 폼. FacilityStore가 시설 확장 정보를 보관하고 세부유역 재계산·저장 요청에 되붙인다(관 이동 후에도 최근접 승계 — 백엔드 carry와 같은 기준). 응답이 정본이라 apply()에서 전량 재구성. - B04_PreProcess_Api_Fetch.ts: PipeFacility·DetailPipeInput 타입, 요청·응답에 시설 필드 반영. npm run typecheck·npm run build 통과. 통합 서클마크 표시와 구 비정규 측점 이관·폐기(3단계)는 배관 측점선 체계 이전과 묶어 다음 작업 — PLAN.md 체크리스트 에 미완 사유 기록. Co-Authored-By: Claude Opus 5 (1M context) --- B04_PreProcess/B04_PreProcess_Api_Fetch.ts | 25 +- B05_Profile/B05_Profile_Api_Structures.ts | 15 +- .../B05_Profile_UI_Drainage_Facility.ts | 224 ++++++++++++++++++ B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 61 +++-- B05_Profile/B05_Profile_UI_Panel.ts | 4 +- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 13 +- .../B05_Profile_UI_Structures_Panel.ts | 151 ++++++++---- .../B05_Profile_UI_Style_Structures.css | 27 +++ B05_Profile/B05_Profile_Util_Station.ts | 54 +++++ 9 files changed, 502 insertions(+), 72 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Drainage_Facility.ts create mode 100644 B05_Profile/B05_Profile_Util_Station.ts diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index e850db11..e86f91cb 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -437,12 +437,33 @@ export async function fetchRoadInflow( /** 관이 그 자리에 있는 이유. 백엔드 `common_util_drainage_pipes`가 정의처다. */ export type PipeSource = "stream" | "spacing" | "user"; +/** 계곡 통과 시설 종류(2026-08-17 컨테이너 병합). 같은 계곡 교차점에서 유량·지형에 + * 따라 택일한다 — 정의처는 백엔드 `common_util_drainage_pipes`. 교량은 임도용이 아니다. */ +export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_bridge"; + +export const PIPE_FACILITY_LABELS: ReadonlyArray<[PipeFacility, string]> = [ + ["pipe", "배관"], + ["box_culvert", "BOX암거"], + ["ford_pavement", "물넘이포장"], + ["ford_bridge", "세월교"], +]; + export interface DetailPipePoint { chainage_m: number; lonlat: [number, number]; source: PipeSource; + /** 시설 종류 — 응답에서 생략되면 기본 배관. */ + facility?: PipeFacility; + /** 기준점 앞뒤 구간(유입·유출 부속 폭). 없으면 폭 0 — 사용자가 필요할 때 벌린다. */ + start_m?: number; + end_m?: number; + /** 유무·종류 수준의 시설 옵션(예: 세월교 관 종류/크기/수량). 상세 치수는 B06/B07. */ + options?: Record; } +/** 편집·저장 요청에 싣는 관 1건 — 좌표(lonlat)는 서버가 다시 계산하므로 뺀다. */ +export type DetailPipeInput = Omit; + export interface DetailBasin { index: number; chainage_m: number; @@ -504,7 +525,7 @@ export async function fetchDetailPipePoints(projectId: string): Promise, + points: DetailPipeInput[], ): Promise { return requestJson(`/projects/${projectId}/drainage/detail-basins`, { method: "POST", @@ -524,7 +545,7 @@ export async function resetDetailPipePoints(projectId: string): Promise, + points: DetailPipeInput[], ): Promise { return requestJson(`/projects/${projectId}/drainage/pipe-points`, { method: "PUT", diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 462cfcd5..40ee6c62 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -27,6 +27,14 @@ export interface StructureOptionField { default: string | number | null; /** 미확정 항목(기본값 없음) — 사용자가 값을 넣어야 저장된다. */ required?: boolean; + /** 입력 시점 — B05는 유무·종류·위치만 받고 상세 치수(detail)는 B06/B07에서 받는다 + * (2026-08-17 사용자 확정). detail이면 required여도 B05 폼에 그리지 않는다. */ + phase?: "b05" | "detail"; +} + +/** B05 배치 폼에 그릴 옵션인가 — 상세(detail)는 B06/B07 몫이라 숨긴다. */ +export function isB05Option(option: StructureOptionField): boolean { + return (option.phase ?? "b05") !== "detail"; } export interface StructureType { @@ -155,11 +163,10 @@ export async function saveStructures( }); } -/** 종단도 마크 위치 — 구간형은 기점(시점)에 찍는다(2026-08-16 사용자 확정). */ +/** 종단도 마크 위치 = 기준점. 구간형도 chainage_m이 기준점이다(2026-08-17 사용자 + * 확정: 기준점에 마킹 + 시작·종료 측점). 기준점이 없는 기존 저장분은 시점으로 본다. */ export function structureAnchorM(structure: StructureInstance): number { - return structure.placement === "interval" - ? (structure.start_m ?? 0) - : (structure.chainage_m ?? 0); + return structure.chainage_m ?? structure.start_m ?? 0; } /** 타입 정의의 기본값으로 옵션을 채운다(신규 추가·타입 변경 시). */ diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts new file mode 100644 index 00000000..a3fd8a27 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -0,0 +1,224 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Facility.ts + * 계곡 통과 시설 편집 소구획 — 선택한 관 지점의 시설 종류·구간·세월교 옵션. + * + * 같은 계곡 교차점에서 유량·지형에 따라 배관/BOX암거/물넘이포장/세월교를 택일한다 + * (2026-08-17 사용자 확정 — 교량은 임도용이 아니라 없다). 정본은 `pipe_points.json` + * 하나이고, 세부유역 계산은 기준점(chainage)만 읽으므로 종류를 바꿔도 계산은 같다. + * + * B05는 유무·종류·위치 단계라 여기서는 종류·구간·관 수량 수준만 받는다 — 날개벽 + * 규격 같은 상세 치수는 B06/B07 몫이다. 시설 확장 정보는 패널이 chainage 기준으로 + * 들고 다니며 요청에 되붙인다(`FacilityStore`). + * ========================================================================== */ + +import { + PIPE_FACILITY_LABELS, + type DetailPipeInput, + type PipeFacility, + type PipeSource, +} from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; + +/** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. */ +export interface FacilityAttributes { + facility: PipeFacility; + start_m?: number; + end_m?: number; + options?: Record; +} + +/** 관 이동·재계산 왕복에서 시설 정보를 잃지 않게 chainage 최근접으로 되붙이는 보관소. + * 백엔드 `carry_facility_attributes`와 같은 기준(정확 일치 → 최근접)이다. */ +export function createFacilityStore() { + let entries: Array<{ chainage: number; attributes: FacilityAttributes }> = []; + + return { + /** 서버 응답으로 전체를 다시 채운다 — 응답이 정본이다. */ + replaceFromResponse( + points: ReadonlyArray<{ + chainage_m: number; + facility?: PipeFacility; + start_m?: number; + end_m?: number; + options?: Record; + }>, + ): void { + entries = points + .filter((point) => point.facility && point.facility !== "pipe") + .map((point) => ({ + chainage: point.chainage_m, + attributes: { + facility: point.facility as PipeFacility, + start_m: point.start_m, + end_m: point.end_m, + options: point.options, + }, + })); + }, + /** 한 지점의 시설을 바꾼다(배관으로 되돌리면 엔트리 삭제). */ + set(chainageM: number, attributes: FacilityAttributes | null): void { + entries = entries.filter((entry) => Math.abs(entry.chainage - chainageM) > 0.005); + if (attributes && attributes.facility !== "pipe") { + entries.push({ chainage: chainageM, attributes }); + } + }, + get(chainageM: number): FacilityAttributes | null { + const hit = entries.find((entry) => Math.abs(entry.chainage - chainageM) <= 0.005); + return hit ? hit.attributes : null; + }, + /** 요청 목록에 시설 정보를 되붙인다. 관이 끌려 이동한 뒤에도(정확 일치 없음) + * 가장 가까운 엔트리를 한 번씩만 소비해 승계한다. */ + attach(points: ReadonlyArray<{ chainage_m: number; source: PipeSource }>): DetailPipeInput[] { + const pool = [...entries]; + return points.map((point) => { + if (!pool.length) return { ...point }; + let best = 0; + for (let index = 1; index < pool.length; index += 1) { + if ( + Math.abs(pool[index].chainage - point.chainage_m) < + Math.abs(pool[best].chainage - point.chainage_m) + ) + best = index; + } + const [entry] = pool.splice(best, 1); + return { ...point, ...entry.attributes }; + }); + }, + }; +} + +export type FacilityStore = ReturnType; + +interface FacilityEditorCallbacks { + /** 입력이 확정될 때(종류·구간·옵션 change) — 패널이 저장소 갱신·재계산을 맡는다. */ + onApply: (chainageM: number, attributes: FacilityAttributes | null) => void; +} + +export interface FacilityEditor { + root: HTMLElement; + /** 선택된 관 지점을 폼에 올린다. null이면 숨긴다. */ + sync: (chainageM: number | null, attributes: FacilityAttributes | null) => void; +} + +function labeled(text: string, input: HTMLElement): HTMLLabelElement { + const wrapper = document.createElement("label"); + wrapper.className = "b05-route__field"; + const caption = document.createElement("span"); + caption.textContent = text; + wrapper.append(caption, input); + return wrapper; +} + +/** 선택한 관 지점 아래 붙는 시설 편집 폼. */ +export function createFacilityEditor(callbacks: FacilityEditorCallbacks): FacilityEditor { + const root = document.createElement("div"); + root.className = "b05-drainage__facility"; + root.hidden = true; + + const facilitySelect = document.createElement("select"); + facilitySelect.replaceChildren( + ...PIPE_FACILITY_LABELS.map(([value, label]) => new Option(label, value)), + ); + + // 구간 — 기준점 앞뒤로 유입·유출 부속이 차지하는 범위(m). 비우면 폭 0(기준점만). + const startInput = document.createElement("input"); + startInput.type = "number"; + startInput.step = "0.1"; + startInput.min = "0"; + startInput.placeholder = "비우면 기준점"; + const endInput = document.createElement("input"); + endInput.type = "number"; + endInput.step = "0.1"; + endInput.min = "0"; + endInput.placeholder = "비우면 기준점"; + + // 세월교 전용 — 구체 내 배관 종류/크기/수량으로 폭을 정한다(2026-08-17 사용자 확정). + const pipeKindSelect = document.createElement("select"); + pipeKindSelect.replaceChildren( + ...["", "흄관", "VR관", "파형강관"].map( + (kind) => new Option(kind === "" ? "선택하세요" : kind, kind), + ), + ); + const diameterSelect = document.createElement("select"); + diameterSelect.replaceChildren( + ...["", "800", "1000", "1200", "1500"].map( + (size) => new Option(size === "" ? "선택하세요" : `Ø${size}`, size), + ), + ); + const countInput = document.createElement("input"); + countInput.type = "number"; + countInput.step = "1"; + countInput.min = "1"; + countInput.placeholder = "련"; + + const spanRow = document.createElement("div"); + spanRow.className = "b05-route__irregular-row"; + spanRow.append(labeled("시작 (m)", startInput), labeled("종료 (m)", endInput)); + const fordRow = document.createElement("div"); + fordRow.className = "b05-route__irregular-row"; + fordRow.append( + labeled("배관 종류", pipeKindSelect), + labeled("배관 크기", diameterSelect), + labeled("수량 (련)", countInput), + ); + + root.append(labeled("시설 종류", facilitySelect), spanRow, fordRow); + + let currentChainage: number | null = null; + let syncing = false; + + function readAttributes(): FacilityAttributes | null { + const facility = facilitySelect.value as PipeFacility; + if (facility === "pipe") return null; + const attributes: FacilityAttributes = { facility }; + const start = Number.parseFloat(startInput.value); + const end = Number.parseFloat(endInput.value); + if (Number.isFinite(start) && Number.isFinite(end)) { + attributes.start_m = Math.min(start, end); + attributes.end_m = Math.max(start, end); + } + if (facility === "ford_bridge") { + const options: Record = {}; + if (pipeKindSelect.value) options.pipe_kind = pipeKindSelect.value; + if (diameterSelect.value) options.pipe_diameter_mm = Number(diameterSelect.value); + const count = Number.parseInt(countInput.value, 10); + if (Number.isFinite(count) && count > 0) options.pipe_count = count; + if (Object.keys(options).length) attributes.options = options; + } + return attributes; + } + + function syncVisibility(): void { + const facility = facilitySelect.value as PipeFacility; + spanRow.hidden = facility === "pipe"; + fordRow.hidden = facility !== "ford_bridge"; + } + + function emit(): void { + if (syncing || currentChainage === null) return; + syncVisibility(); + callbacks.onApply(currentChainage, readAttributes()); + } + + [facilitySelect, startInput, endInput, pipeKindSelect, diameterSelect, countInput].forEach( + (input) => input.addEventListener("change", emit), + ); + + return { + root, + sync(chainageM, attributes) { + currentChainage = chainageM; + root.hidden = chainageM === null; + if (chainageM === null) return; + syncing = true; + facilitySelect.value = attributes?.facility ?? "pipe"; + startInput.value = attributes?.start_m !== undefined ? String(attributes.start_m) : ""; + endInput.value = attributes?.end_m !== undefined ? String(attributes.end_m) : ""; + const options = attributes?.options ?? {}; + pipeKindSelect.value = String(options.pipe_kind ?? ""); + diameterSelect.value = options.pipe_diameter_mm ? String(options.pipe_diameter_mm) : ""; + countInput.value = options.pipe_count ? String(options.pipe_count) : ""; + syncing = false; + syncVisibility(); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index a4dadac1..9be62c9c 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -25,12 +25,10 @@ import { } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { type RoutePoint } from "./B05_Profile_Api_Fetch"; import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows"; -import { - buildStrengthArray, - createFlowLegend, -} from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp"; +import { buildStrengthArray, createFlowLegend } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp"; import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples"; import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; +import { createFacilityEditor, createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import { createProgressCircle } from "@ui/ui_template_progress"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render"; @@ -182,7 +180,25 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra max: () => (root.parentElement?.clientWidth ?? window.innerWidth) * MAX_PANEL_WIDTH_RATIO, storageKey: WIDTH_KEY, }); - root.append(panelHandle.root, widthResizer.root, header, viewport, summary, basinList); + // 계곡 통과 시설(배관/BOX암거/물넘이/세월교) 확장 정보 — 계산기는 chainage만 다루므로 + // 여기 보관해 두고 요청마다 되붙인다(2026-08-17 컨테이너 병합). + const facilityStore = createFacilityStore(); + const facilityEditor = createFacilityEditor({ + onApply: (chainageM, attributes) => { + facilityStore.set(chainageM, attributes); + void analyze(); // 즉시 재계산·응답 반영 — 계산은 같지만 시설 표기가 정본 경로를 탄다. + }, + }); + + root.append( + panelHandle.root, + widthResizer.root, + header, + viewport, + facilityEditor.root, + summary, + basinList, + ); let projectId: string | null = null; let meta: VWorldMeta | null = null; @@ -334,11 +350,16 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage); - /** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼 활성화. */ + /** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼·시설 편집 폼 갱신. */ function syncPipeSelection(): void { const index = pipeEditor.selected(); deleteButton.disabled = index === null; - selectBasin(basinIndexOfPipe(basins, index === null ? null : pipeEditor.pipes()[index])); + const picked = index === null ? null : pipeEditor.pipes()[index]; + facilityEditor.sync( + picked ? picked.chainage_m : null, + picked ? facilityStore.get(picked.chainage_m) : null, + ); + selectBasin(basinIndexOfPipe(basins, picked ?? null)); } function scheduleDraw(): void { @@ -392,6 +413,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra reason: pipe.source, })), ); + // 시설 확장 정보(종류·구간·옵션)는 응답이 정본 — 통째로 다시 채운다. + facilityStore.replaceFromResponse(response.pipe_points); selectBasin(null, false); renderBasinList(); syncPipeSelection(); @@ -440,15 +463,18 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra * 옛 관이 살아난다(2026-08-02 사용자 보고). */ const resetPipes = (): Promise => run(() => resetDetailPipePoints(projectId as string)); - /** 세부유역을 다시 나눈다. 격자 해석은 하지 않으므로 즉시 끝난다. */ + /** 세부유역을 다시 나눈다. 격자 해석은 하지 않으므로 즉시 끝난다. + * 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */ const analyze = (): Promise => run(() => computeDetailBasins( projectId as string, - pipeEditor.pipes().map((pipe) => ({ - chainage_m: pipe.chainage_m, - source: (pipe.reason || "user") as PipeSource, - })), + facilityStore.attach( + pipeEditor.pipes().map((pipe) => ({ + chainage_m: pipe.chainage_m, + source: (pipe.reason || "user") as PipeSource, + })), + ), ), ); @@ -703,10 +729,13 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra if (!projectId) return 0; const response = await saveDetailPipePoints( projectId, - pipeEditor.pipes().map((pipe) => ({ - chainage_m: pipe.chainage_m, - source: (pipe.reason || "user") as PipeSource, - })), + // 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임). + facilityStore.attach( + pipeEditor.pipes().map((pipe) => ({ + chainage_m: pipe.chainage_m, + source: (pipe.reason || "user") as PipeSource, + })), + ), ); apply(response); scheduleDraw(); diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index 1318e2db..5a19f308 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -358,10 +358,12 @@ export function createRoutePanel(callbacks: PanelCallbacks) { }); // 구조물 추가(구조물군 B~G) — 타입 목록과 옵션 칸은 서버 레지스트리에서 받아 그린다. - // 배관은 위 비정규 측점 섹션이 관 지점 정본과 맞물려 계속 담당한다. + // 배관 등 계곡 통과 시설은 배수유역의 관 지점 정본이 담당한다. 위치 입출력은 + // 측점번호+잔여거리 표기라 비정규 측점과 같은 측점간격을 쓴다(2026-08-17). const structures = createStructuresSection({ onChange: callbacks.onStructuresChange, onSelect: callbacks.onStructureSelect, + getInterval: () => Number(stationInterval.value) || 20, }); /** 등급·지형 선택에 맞춰 법정 기준값을 placeholder와 안내문에 반영한다. */ diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 9870b172..fb5cdd3c 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -821,11 +821,16 @@ export function createRouteProfilePanel( onRemove: (station) => callbacks?.onStructureRemove?.(station), onAddPipe: (chainage) => callbacks?.onPipeAdd?.(chainage), onAddStructure: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type), - // 우클릭 한 번으로 끝나는 타입만 메뉴에 올린다 — 배관은 관 지점 정본 소관이라 - // 위의 [배관 추가]가 따로 맡고, 필수 제원이 있는 타입은 값 없이 넣으면 서버가 - // 거절하므로 사이드 폼에서 값을 받는다(2026-08-16 크로스체크 지적 1). + // 우클릭 한 번으로 끝나는 타입만 메뉴에 올린다 — 계곡 통과 시설(managed_by)은 + // 관 지점 정본 소관이라 위의 [배관 추가]가 따로 맡는다. 상세(detail) 필수는 + // B06/B07 몫이라 막지 않고, B05 단계(b05) 필수만 사이드 폼으로 보낸다 + // (2026-08-17 phase 분리 — B05는 유무·종류·위치 단계). structureTypes: structureTypes - .filter((type) => !type.managed_by && !type.options.some((option) => option.required)) + .filter( + (type) => + !type.managed_by && + !type.options.some((option) => option.required && (option.phase ?? "b05") !== "detail"), + ) .map((type) => ({ type_id: type.type_id, group: type.group, name: type.name })), onAddStructureType: (chainage, typeId) => callbacks?.onStructureTypeAdd?.(chainage, typeId), }); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 0db3bf85..e2fd60e5 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -11,12 +11,14 @@ import { defaultOptions, + isB05Option, structureAnchorM, type StructureInstance, type StructurePlacement, type StructureSide, type StructureType, } from "./B05_Profile_Api_Structures"; +import { formatStation, parseStationText } from "./B05_Profile_Util_Station"; /** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. */ const GROUP_LABELS: Record = { @@ -61,6 +63,8 @@ interface StructuresCallbacks { onChange: (structures: StructureInstance[]) => void; /** 목록에서 고르거나 해제할 때. */ onSelect: (structure: StructureInstance | null) => void; + /** 측점간격(m) — 측점번호+잔여거리 ↔ 누가거리 환산에 쓴다(비정규 측점과 동일). */ + getInterval: () => number; } function field(labelText: string, input: HTMLElement): HTMLLabelElement { @@ -99,21 +103,29 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu const groupSelect = select([]); const typeSelect = select([]); const sideSelect = select(SIDE_LABELS); - const startField = numberInput(); - startField.placeholder = "시점"; - const endField = numberInput(); - endField.placeholder = "종점"; + // 위치는 측점번호+잔여거리 표기("3+18.0")로 받는다 — 누가거리 직접 입력("76.5")도 허용. + const stationInput = (): HTMLInputElement => { + const input = document.createElement("input"); + input.type = "text"; + input.placeholder = "예: 3+18.0"; + input.inputMode = "decimal"; + return input; + }; + const anchorField = stationInput(); + const startField = stationInput(); + const endField = stationInput(); const offsetField = numberInput(); offsetField.value = "0"; const memoField = document.createElement("input"); memoField.type = "text"; memoField.placeholder = "메모(선택)"; - const startWrap = field("위치 (m)", startField); - const endWrap = field("종점 (m)", endField); + const anchorWrap = field("위치 (측점)", anchorField); + const startWrap = field("시작 측점", startField); + const endWrap = field("종료 측점", endField); const positionRow = document.createElement("div"); positionRow.className = "b05-route__irregular-row"; - positionRow.append(startWrap, endWrap); + positionRow.append(anchorWrap, startWrap, endWrap); const sideRow = document.createElement("div"); sideRow.className = "b05-route__irregular-row"; @@ -143,8 +155,9 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu const help = document.createElement("p"); help.className = "b05-route__note"; help.textContent = - "구조물군과 종류를 고르고 위치를 입력해 추가합니다. 구간형은 시점~종점을 받고, " + - "종단도에는 시점 위치에 표시됩니다. 배관은 위 배수유역 연동 항목에서 관리합니다."; + "구조물군과 종류를 고르고 측점(예: 3+18.0)을 입력해 추가합니다. 구간형은 기준점에 " + + "마크가 찍히고 시작~종료 측점을 받습니다(기준점을 비우면 시작 측점). 상세 치수는 " + + "B06/B07 단계에서 입력합니다. 배관 등 계곡 통과 시설은 배수유역 항목에서 관리합니다."; body.append( field("구조물군", groupSelect), @@ -180,25 +193,40 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu return typeMap().get(typeId)?.placement ?? "point"; } - /** 배치형태에 맞춰 위치 칸을 바꾼다 — 구간형만 종점을 받는다. */ - function syncPlacementFields(): void { - const placement = currentType()?.placement ?? "point"; - endWrap.hidden = placement !== "interval"; - startWrap.querySelector("span")!.textContent = - placement === "interval" ? "시점 (m)" : "위치 (m)"; + const interval = (): number => callbacks.getInterval(); + + /** 측점 텍스트("3+18.0"·"76.5")를 누가거리로 읽는다. 못 읽으면 칸을 붉히고 null. */ + function readStation(input: HTMLInputElement, required: boolean): number | null { + const parsed = parseStationText(input.value, interval()); + const invalid = parsed === null && (required || input.value.trim() !== ""); + input.classList.toggle("is-invalid", invalid); + return parsed; } - /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다. */ + /** 배치형태에 맞춰 위치 칸을 바꾼다 — 점형·부지형은 기준 측점 하나, + * 구간형은 기준점(마킹) + 시작·종료 측점(2026-08-17 사용자 확정). */ + function syncPlacementFields(): void { + const placement = currentType()?.placement ?? "point"; + const isInterval = placement === "interval"; + startWrap.hidden = !isInterval; + endWrap.hidden = !isInterval; + anchorWrap.querySelector("span")!.textContent = isInterval ? "기준점 (측점)" : "위치 (측점)"; + anchorField.placeholder = isInterval ? "비우면 시작 측점" : "예: 3+18.0"; + } + + /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 상세(detail) 옵션은 B06/B07 + * 몫이라 그리지 않는다(B05 = 유무·종류·위치 단계, 2026-08-17 사용자 확정). */ function renderOptionFields(values: Record = {}): void { const type = currentType(); optionInputs = []; optionRow.replaceChildren(); - if (!type || !type.options.length) { + const visible = type ? type.options.filter(isB05Option) : []; + if (!type || !visible.length) { optionRow.hidden = true; return; } optionRow.hidden = false; - type.options.forEach((option) => { + visible.forEach((option) => { const preset = values[option.key] ?? option.default ?? ""; let input: HTMLInputElement | HTMLSelectElement; if (option.input === "select") { @@ -249,10 +277,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu function labelOf(structure: StructureInstance): string { const type = typeMap().get(structure.type_id); const name = type?.name ?? structure.type_id; + const step = interval(); + // 표기는 측점번호+잔여거리(2026-08-17 사용자 확정). 구간형은 시작~종료를 보인다. const position = structure.placement === "interval" - ? `${(structure.start_m ?? 0).toFixed(1)}~${(structure.end_m ?? 0).toFixed(1)}m` - : `${(structure.chainage_m ?? 0).toFixed(1)}m`; + ? `${formatStation(structure.start_m ?? 0, step)}~${formatStation(structure.end_m ?? 0, step)}` + : formatStation(structure.chainage_m ?? 0, step); return `${position} · ${name}`; } @@ -287,27 +317,35 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu function loadForm(target: StructureInstance | null): void { editingId = target?.structure_id ?? null; + const step = interval(); if (target) { const type = typeMap().get(target.type_id); if (type) { groupSelect.value = type.group; syncTypeOptions(target.type_id); } - startField.value = String( - target.placement === "interval" ? (target.start_m ?? 0) : (target.chainage_m ?? 0), - ); - endField.value = String(target.end_m ?? ""); + anchorField.value = formatStation(structureAnchorM(target), step); + startField.value = + target.start_m !== null && target.start_m !== undefined + ? formatStation(target.start_m, step) + : ""; + endField.value = + target.end_m !== null && target.end_m !== undefined + ? formatStation(target.end_m, step) + : ""; sideSelect.value = target.side; offsetField.value = String(target.offset_m ?? 0); memoField.value = target.memo ?? ""; renderOptionFields(target.options); } else { + anchorField.value = ""; startField.value = ""; endField.value = ""; offsetField.value = "0"; memoField.value = ""; renderOptionFields(); } + [anchorField, startField, endField].forEach((input) => input.classList.remove("is-invalid")); syncPlacementFields(); syncButtons(); renderList(); @@ -332,21 +370,34 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu function commit(): void { const type = currentType(); if (!type) return; - const start = Number.parseFloat(startField.value); - if (!Number.isFinite(start) || start < 0) { - startField.focus(); - return; - } const placement = type.placement; + + let anchor: number | null; + let start: number | null = null; let end: number | null = null; if (placement === "interval") { - end = Number.parseFloat(endField.value); - if (!Number.isFinite(end) || end <= start) { - endField.focus(); + start = readStation(startField, true); + end = readStation(endField, true); + if (start === null || end === null || end <= start) { + (start === null ? startField : endField).focus(); + return; + } + // 기준점(마킹 위치) — 비우면 시작 측점. 시작~종료를 벗어나면 서버도 거절한다. + anchor = readStation(anchorField, false) ?? start; + if (anchor < start || anchor > end) { + anchorField.classList.add("is-invalid"); + anchorField.focus(); + return; + } + } else { + anchor = readStation(anchorField, true); + if (anchor === null) { + anchorField.focus(); return; } } // 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다. + // 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다). const missing = optionInputs.find((entry) => entry.required && entry.isEmpty()); if (missing) { missing.input.focus(); @@ -356,8 +407,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu const base = { type_id: type.type_id, placement, - chainage_m: placement === "interval" ? null : start, - start_m: placement === "interval" ? start : null, + chainage_m: anchor, + start_m: start, end_m: end, side: sideSelect.value as StructureSide, offset_m: Number(offsetField.value) || 0, @@ -442,21 +493,24 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu }, addAt(chainageM, typeId) { const type = typeMap().get(typeId); - // 배관은 관 지점 정본 소관이라 이 목록에 넣을 수 없다(서버도 거절한다). + // 배관 등 계곡 통과 시설은 관 지점 정본 소관이라 이 목록에 넣을 수 없다(서버도 거절). if (!type || type.managed_by) return; const placement = placementOf(typeId); + const step = interval(); - // 사용자가 값을 넣어야 하는 타입(미확정 제원·미협의 선택지)은 바로 저장하면 - // 서버가 거절하고, 화면에는 식별자 없는 유령 항목만 남는다. 그래서 위치만 채운 - // 폼을 열어 사용자가 값을 넣고 [추가]를 누르게 한다(2026-08-16 크로스체크 지적 1). - if (type.options.some((option) => option.required)) { + // B05 단계 필수 옵션이 있는 타입만 폼을 연다 — 상세(detail) 필수는 B06/B07 + // 몫이라 바로 추가해도 서버가 받는다(2026-08-17 phase 분리). + if (type.options.some((option) => option.required && isB05Option(option))) { groupSelect.value = type.group; syncTypeOptions(typeId); typeSelect.value = typeId; editingId = null; - startField.value = String(chainageM); + anchorField.value = formatStation(chainageM, step); + startField.value = placement === "interval" ? formatStation(chainageM, step) : ""; endField.value = - placement === "interval" ? String(chainageM + DEFAULT_INTERVAL_LENGTH_M) : ""; + placement === "interval" + ? formatStation(chainageM + DEFAULT_INTERVAL_LENGTH_M, step) + : ""; sideSelect.value = "center"; offsetField.value = "0"; memoField.value = ""; @@ -466,7 +520,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu renderList(); root.scrollIntoView({ block: "nearest" }); const firstRequired = optionInputs.find((entry) => entry.required); - (firstRequired?.input ?? startField).focus(); + (firstRequired?.input ?? anchorField).focus(); return; } @@ -474,7 +528,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu structure_id: null, type_id: typeId, placement, - chainage_m: placement === "interval" ? null : chainageM, + chainage_m: chainageM, start_m: placement === "interval" ? chainageM : null, end_m: placement === "interval" ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, side: "center", @@ -493,9 +547,16 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu if (index < 0) return false; const target = structures[index]; if (target.placement === "interval") { - // 구간은 길이를 유지한 채 통째로 옮긴다 — 기점만 끌면 종점이 따라온다. + // 구간은 길이를 유지한 채 통째로 옮긴다 — 기준점을 끌면 시작·종료가 따라온다. const length = (target.end_m ?? 0) - (target.start_m ?? 0); - structures[index] = { ...target, start_m: toChainageM, end_m: toChainageM + length }; + 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 }; } diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 16e58af7..ef8174ad 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -82,3 +82,30 @@ opacity: 0.7; font-style: italic; } + +/* ── 계곡 통과 시설 편집 폼 (배수유역 패널 하단, 2026-08-17 컨테이너 병합) ── + * 관 마커를 고르면 나타난다. 시설 종류(배관/BOX암거/물넘이/세월교)·구간·세월교 + * 관 옵션을 받고, 값이 바뀌면 즉시 재계산·정본 경로를 탄다. */ +.b05-drainage__facility { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 10px; + border-top: 1px solid var(--border-color, #3a3f4a); +} + +.b05-drainage__facility .b05-route__irregular-row { + display: flex; + gap: 6px; +} + +.b05-drainage__facility .b05-route__field { + flex: 1; + min-width: 0; +} + +/* 측점 입력이 형식에 안 맞을 때(예: "3+18.0"도 숫자도 아님) 칸을 붉힌다. */ +.b05-route__field input.is-invalid { + border-color: #e05b5b; + background: rgba(224, 91, 91, 0.08); +} diff --git a/B05_Profile/B05_Profile_Util_Station.ts b/B05_Profile/B05_Profile_Util_Station.ts new file mode 100644 index 00000000..da5ee30a --- /dev/null +++ b/B05_Profile/B05_Profile_Util_Station.ts @@ -0,0 +1,54 @@ +/* ============================================================================= + * B05_Profile_Util_Station.ts + * 측점 표기 공용 유틸 — 측점번호+잔여거리 ↔ 누가거리(chainage) 변환·서식. + * + * 구조물 위치는 저장은 누가거리(m)로 하되 **화면 입출력은 측점번호+잔여거리**로 한다 + * (2026-08-17 사용자 확정). 근거: 중심선측량 측점 20m + 구조물 지점 보조말뚝 + * (지식DB 설계제원_총괄 §8). 표기 예: "3+18.0" = 측점 3번에서 18.0m. + * + * 변환식은 비정규 측점 UI(`B05_Profile_UI_IrregularStations.ts`)와 같다 — + * chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 호출 시점에 받는다 + * (프로젝트 설정에 따라 바뀌므로 상수로 굳히지 않는다). + * ========================================================================== */ + +/** 측점번호 X + 잔여거리 XX(m) → 누가거리(m). */ +export function stationToChainage(station: number, remainder: number, intervalM: number): number { + return station * intervalM + remainder; +} + +/** 누가거리(m) → 측점번호 + 잔여거리. 잔여거리는 [0, 간격) 범위로 정규화한다. */ +export function chainageToStation( + chainageM: number, + intervalM: number, +): { station: number; remainder: number } { + if (!(intervalM > 0)) return { station: 0, remainder: Math.max(0, chainageM) }; + const clamped = Math.max(0, chainageM); + let station = Math.floor(clamped / intervalM); + let remainder = clamped - station * intervalM; + // 부동소수 잔여가 간격과 사실상 같으면 다음 측점의 0으로 올린다 (19.999… → 1+0.0). + if (intervalM - remainder < 0.005) { + station += 1; + remainder = 0; + } + return { station, remainder }; +} + +/** 누가거리 → "3+18.0" 표기. */ +export function formatStation(chainageM: number, intervalM: number): string { + const { station, remainder } = chainageToStation(chainageM, intervalM); + return `${station}+${remainder.toFixed(1)}`; +} + +/** "3+18.0" · "3 + 18" · "76.5"(누가거리 직접) 입력을 누가거리로 해석한다. + * 해석 불가하면 null — 호출부가 칸을 붉히고 진행을 멈춘다. */ +export function parseStationText(text: string, intervalM: number): number | null { + const trimmed = text.trim(); + if (!trimmed) return null; + const match = /^(\d+)\s*\+\s*(\d+(?:\.\d+)?)$/.exec(trimmed); + if (match) { + const chainage = stationToChainage(Number(match[1]), Number(match[2]), intervalM); + return Number.isFinite(chainage) ? chainage : null; + } + const direct = Number(trimmed); + return Number.isFinite(direct) && direct >= 0 ? direct : null; +}