diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 40ee6c62..363c9828 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -163,6 +163,18 @@ export async function saveStructures( }); } +/** 구 비정규 측점(자유 텍스트)을 구조물 정본으로 이관한다. 멱등 — 배관은 관 지점 + * 정본 소관이라 서버가 걸러내고, 이미 정본에 있는 (타입, 위치)는 건너뛴다. */ +export async function migrateLegacyStations( + projectId: string, + stations: Array<{ chainage_m: number; structure: string }>, +): Promise<{ status: string; migrated: number; revision: number }> { + return requestJson(`/projects/${projectId}/route/structures/migrate`, { + method: "POST", + body: JSON.stringify({ stations }), + }); +} + /** 종단도 마크 위치 = 기준점. 구간형도 chainage_m이 기준점이다(2026-08-17 사용자 * 확정: 기준점에 마킹 + 시작·종료 측점). 기준점이 없는 기존 저장분은 시점으로 본다. */ export function structureAnchorM(structure: StructureInstance): number { diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index 21c5c826..fa644a37 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -10,13 +10,16 @@ import logging from pathlib import Path +from typing import Any from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Repository import get_latest_route +from B05_Profile.B05_Profile_Structures_Migration import migrate_irregular_stations from B05_Profile.B05_Profile_Structures_Repository import ( StructureRevisionConflict, load_structures, @@ -134,6 +137,52 @@ async def read_structures(project_id: UUID) -> StructureListResponse | JSONRespo ) +class StructureMigrateRequest(BaseModel): + """구 비정규 측점 이관 요청 — 화면이 복원한 `{chainage_m, structure}` 목록 그대로.""" + + stations: list[dict[str, Any]] = Field(default_factory=list) + + +@router.post("/{project_id}/route/structures/migrate", response_model=None) +async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest) -> JSONResponse: + """구 비정규 측점(자유 텍스트)을 구조물 정본으로 옮긴다. 멱등 — 이미 정본에 있는 + (타입, 위치)는 건너뛰고, 배관은 관 지점 정본 소관이라 옮기지 않는다 + (2026-08-17 컨테이너 병합 3단계, 매핑은 `B05_Profile_Structures_Migration` 정의).""" + try: + root = await _project_root(project_id) + if root is None: + return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) + revision, existing = load_structures(root) + occupied = {(item.type_id, round(item.anchor_m(), 3)) for item in existing} + fresh = [ + item + for item in migrate_irregular_stations(payload.stations) + if (item.type_id, round(item.anchor_m(), 3)) not in occupied + ] + if not fresh: + return JSONResponse(content={"status": "success", "migrated": 0, "revision": revision}) + new_revision = save_structures( + root, + [*existing, *fresh], + base_revision=revision, + max_chainage_m=await _route_length(project_id), + ) + logger.info("B05 구 비정규 측점 이관: project_id=%s, %d건", project_id, len(fresh)) + return JSONResponse( + content={"status": "success", "migrated": len(fresh), "revision": new_revision} + ) + except LookupError: + return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) + except (StructureRevisionConflict, ValueError) as error: + return JSONResponse(status_code=400, content={"status": "error", "message": str(error)}) + except Exception: + logger.exception("B05 구 비정규 측점 이관 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "구조물 이관 중 오류가 발생했습니다."}, + ) + + @router.put("/{project_id}/route/structures", response_model=StructureSaveResponse) async def write_structures( project_id: UUID, payload: StructureSaveRequest diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 9be62c9c..368b1085 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -9,6 +9,7 @@ import { saveDetailPipePoints, type DetailBasin, type DetailBasinResponse, + type PipeFacility, type PipeSource, type VWorldMeta, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; @@ -79,8 +80,9 @@ export interface DrainagePanel { /** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우). * 현재 목록과 같으면 아무 일도 하지 않는다 — 되먹임 고리를 끊는 지점이다. */ setPipeChainages: (chainages: ReadonlyArray) => void; - /** 종단 테이블 우클릭으로 배관을 넣거나 지울 때. */ - addPipe: (chainageM: number) => void; + /** 종단 테이블 우클릭·통합 목록에서 계곡 통과 시설을 넣거나 지울 때. + * facility를 주면 그 종류로 추가한다(기본 배관). */ + addPipe: (chainageM: number, facility?: PipeFacility) => void; removePipe: (chainageM: number) => void; /** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */ savePipes: () => Promise; @@ -92,10 +94,16 @@ export interface DrainagePanel { } export interface DrainagePanelCallbacks { - /** 관 목록이 바뀔 때마다 누가거리 + 담당 유역의 배수 유효직경(mm)을 넘긴다 — - * 종단 테이블 구조물 라인 동기화 및 관경 자동 지정(D800 기본)용. */ + /** 관 목록이 바뀔 때마다 누가거리 + 담당 유역의 배수 유효직경(mm) + 시설 종류를 + * 넘긴다 — 종단 구조물 라인·통합 목록 동기화 및 관경 자동 지정(D800 기본)용. */ onPipesChanged?: ( - pipes: Array<{ chainage_m: number; effective_diameter_mm: number | null }>, + pipes: Array<{ + chainage_m: number; + effective_diameter_mm: number | null; + facility: PipeFacility; + start_m?: number; + end_m?: number; + }>, ) => void; /** 유역을 고르거나 풀 때 그 관의 누가거리(없으면 null)를 넘긴다 — 그래프·사이드 패널 동기화용. */ onBasinSelected?: (chainageM: number | null) => void; @@ -423,12 +431,17 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra // 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null. // 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고, // 그 지점은 세월교·물넘이로 별도 설계한다(임도설치규정 제12조). + // 시설 종류·구간도 함께 — 통합 목록·그래프 라벨이 세월교/BOX암거를 구분한다. pipeEditor.chainages().map((chainage) => { const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51); + const attributes = facilityStore.get(chainage); return { chainage_m: chainage, effective_diameter_mm: basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null, + facility: attributes?.facility ?? "pipe", + start_m: attributes?.start_m, + end_m: attributes?.end_m, }; }), ); @@ -714,7 +727,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra markedChainage = chainageM; scheduleDraw(); }, - addPipe(chainageM) { + addPipe(chainageM, facility) { + // 시설 종류를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다. + if (facility && facility !== "pipe") { + facilityStore.set(chainageM, { facility }); + } pipeEditor.addAtChainage(chainageM); }, removePipe(chainageM) { diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index acd5dcbe..d6537b51 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -44,8 +44,11 @@ import { irregularLabel, irregularStationId, isPipeStation, + structureLabel, type IrregularStation, } from "./B05_Profile_UI_IrregularStations"; +import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { pickPipeDiameter, PIPE_DEFAULT_TYPE } from "@config/config_frontend"; import { fetchSectionContext, type SectionDetailResponse, @@ -58,6 +61,7 @@ import { import { fetchStructures, fetchStructureTypes, + migrateLegacyStations, saveStructures, StructureConflictError, type StructureInstance, @@ -201,36 +205,46 @@ export async function renderB05Route(root: HTMLElement): Promise { void purgeOtherProjects(activeProjectId); const viewer = createRouteViewer(); + // 구 우클릭 메뉴의 자유 텍스트 구조물 → 레지스트리 타입(2026-08-17 이관 매핑과 동일). + const LEGACY_TYPE_IDS: Record = { + 기성막이: "revetment", + 대피로: "refuge", + 기타: "etc", + }; const profilePanel = createRouteProfilePanel( activeProjectId, (stationId) => { viewer.markers.selectStation(stationId); syncIrregularSelection(stationId); }, - // [초기선 복원] 시 추가한 비정규 측점도 함께 지운다. - () => panel.irregularStations.clear(), + // [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다). + () => clearProjectedStations(), { - // 관 매설 목록 ↔ 구조물 목록의 "배관" 항목을 한 방향으로 맞춘다. - // 정본은 배수유역도의 관 지점이며, 구조물 목록은 그것을 실체화한 것이다. - onPipesChanged: (pipes) => panel.irregularStations.setPipeStations(pipes), + // 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다. + // 정본은 배수유역도의 관 지점이며, 화면 목록은 그것을 실체화한 것이다. + onPipesChanged: (pipes) => { + applyIrregularStations(pipesToStations(pipes)); + panel.structures.setPipeFacilities( + pipes.map((pipe) => ({ + chainage_m: pipe.chainage_m, + facility: pipe.facility, + start_m: pipe.start_m, + end_m: pipe.end_m, + })), + ); + }, + // 그래프 측점선은 이제 배관(계곡 통과 시설) 투영뿐이다 — 수동 구조물은 + // 서클마크·구조물 배치 목록이 담당한다(2026-08-17 컨테이너 병합). onStructureMove: (from, to, station) => { - if (isPipeStation(station)) { - // 배관은 관 지점 정본을 거쳐야 세부유역까지 함께 다시 나뉜다. - profilePanel.drainage.movePipe(from, to); - return; - } - panel.irregularStations.moveByChainage(from, to); + if (isPipeStation(station)) profilePanel.drainage.movePipe(from, to); }, onStructureRemove: (station) => { - if (isPipeStation(station)) { - profilePanel.drainage.removePipe(station.chainage_m); - return; - } - panel.irregularStations.removeByChainage(station.chainage_m); + if (isPipeStation(station)) profilePanel.drainage.removePipe(station.chainage_m); }, onPipeAdd: (chainage) => profilePanel.drainage.addPipe(chainage), - // 우클릭 메뉴로 배관 외 구조물 추가(종단·배수유역도 공용) — 기본 옵션으로 넣는다. - onStructureAdd: (chainage, type) => panel.irregularStations.addStructure(chainage, type), + // 우클릭 메뉴의 구 항목(기성막이·대피로·기타) — 구조물 정본 타입으로 넣는다. + onStructureAdd: (chainage, type) => + panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"), onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)), // 배수유역도에서 유역을 고르면 그 관의 구조물 측점을 그래프·3D·사이드 패널에서도 고른다. onBasinSelected: (chainageM) => selectStationOfPipe(chainageM), @@ -284,7 +298,8 @@ export async function renderB05Route(root: HTMLElement): Promise { }, selectMarker: (id) => viewer.markers.selectStation(id), selectGraph: (id) => profilePanel.setSelectedStation(id), - selectSidebar: (chainageM) => panel.irregularStations.selectByChainage(chainageM), + // 통합 「구조물 배치」 목록의 계곡 통과 시설 항목을 강조한다(2026-08-17 통합). + selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM), selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM), markStation: (chainageM) => profilePanel.drainage.markStation(chainageM), }); @@ -337,22 +352,27 @@ export async function renderB05Route(root: HTMLElement): Promise { onDeletePoint: viewer.markers.deleteSelected, onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }), onInputChange: markStale, - onIrregularChange: (stations) => applyIrregularStations(stations), onStationDisplayChange: (offset) => profilePanel.setStationDisplay(offset), - onIrregularSelect: (station) => { + onStructuresChange: (next) => applyStructures(next), + // 계곡 통과 시설(A군) — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합). + onPipeFacilityAdd: (chainage, facility) => profilePanel.drainage.addPipe(chainage, facility), + onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage), + onPipeFacilitySelect: (chainage) => { if (selectionSyncing) return; selectionSyncing = true; try { + const station = irregularStations.find( + (entry) => Math.abs(entry.chainage_m - chainage) < 0.05, + ); const id = station ? irregularStationId(station.id) : null; viewer.markers.selectStation(id); profilePanel.setSelectedStation(id); - // 사이드 패널에서 배관 항목을 고르면 배수유역도의 그 유역도 함께 강조한다. + // 통합 목록에서 배관 항목을 고르면 배수유역도의 그 유역도 함께 강조한다. syncBasinHighlight(id); } finally { selectionSyncing = false; } }, - onStructuresChange: (next) => applyStructures(next), onStructureSelect: (structure) => { if (selectionSyncing) return; selectionSyncing = true; @@ -442,6 +462,54 @@ export async function renderB05Route(root: HTMLElement): Promise { viewer.renderStationLines(withUphill, roadWidths[panel.values().gradeClass] / 2); } + /** 시설 종류별 표시 이름 — 그래프·3D 라벨용(배관은 관종·관경까지). */ + const FACILITY_NAMES: Record = { + pipe: "배관", + box_culvert: "BOX암거", + ford_pavement: "물넘이포장", + ford_bridge: "세월교", + }; + + /** 관 매설 목록을 그래프·3D용 측점 목록으로 실체화한다(구 비정규 측점 투영의 후신). + * 배관은 유효직경으로 관경을 자동 지정하고, 다른 시설은 종류 이름을 라벨로 쓴다. */ + function pipesToStations( + pipes: Array<{ + chainage_m: number; + effective_diameter_mm: number | null; + facility: PipeFacility; + }>, + ): IrregularStation[] { + const interval = panel.values().stationInterval || 20; + return pipes.map((pipe) => { + const station = Math.floor(pipe.chainage_m / interval); + const remainder = pipe.chainage_m - station * interval; + const label = + pipe.facility === "pipe" + ? structureLabel({ + structureType: "배관", + pipeType: PIPE_DEFAULT_TYPE, + diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm), + }) + : FACILITY_NAMES[pipe.facility]; + return { + id: `pipe-${pipe.chainage_m.toFixed(2)}`, + station, + remainder, + chainage_m: pipe.chainage_m, + structure: label, + structureType: "배관", + origin: "pipe", + }; + }); + } + + /** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */ + function clearProjectedStations(): void { + irregularStations = []; + if (currentSectionDetail) renderStationLines(currentSectionDetail); + profilePanel.setIrregularStations([]); + } + /** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */ function applyIrregularStations(stations: IrregularStation[]): void { // 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 지운다. @@ -556,16 +624,27 @@ export async function renderB05Route(root: HTMLElement): Promise { profilePanel.setLoading(null); try { renderSections(detail, routeId); - // 복귀/최초 진입 시(클라이언트 목록이 비어 있을 때만) 확정된 비정규 측점을 사이드바에 복원한다. - // 재탐색 시엔 새 경로 detail에 비정규 측점이 없어(빈 목록으로 덮이지 않게) 이 가드로 건너뛴다. - if (!irregularStations.length) { - const restored = detail.longitudinal.stations - .filter((station) => station.kind === "irregular") - .map((station) => ({ - chainage_m: station.chainage_m, - structure: station.structure ?? "", - })); - if (restored.length) panel.irregularStations.setStations(restored); + // 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — + // 배관은 관 지점 정본이 복원하므로 서버가 걸러내고, 멱등이라 재진입에도 안전하다 + // (2026-08-17 컨테이너 병합 3단계). + const legacy = detail.longitudinal.stations + .filter((station) => station.kind === "irregular") + .map((station) => ({ + chainage_m: station.chainage_m, + structure: station.structure ?? "", + })); + if (legacy.length) { + void migrateLegacyStations(activeProjectId, legacy) + .then((result) => { + if (result.migrated > 0) { + showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`); + return refreshStructuresFromServer().then(() => undefined); + } + return undefined; + }) + .catch(() => { + /* 이관 실패는 치명적이지 않다 — 다음 진입에서 다시 시도된다. */ + }); } } catch (error) { // 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다. 조회는 성공했으므로 diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index 5a19f308..6ffd1d9c 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -1,10 +1,6 @@ import type { PlacedRoutePoint, RoutePointKind } from "./B05_Profile_UI_Markers"; -import { - createIrregularStationsSection, - type IrregularStation, - type IrregularStationsSection, -} from "./B05_Profile_UI_IrregularStations"; import type { StructureInstance } from "./B05_Profile_Api_Structures"; +import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { createStructuresSection, type StructuresSection } from "./B05_Profile_UI_Structures_Panel"; import { type ButtonVariant, createButton, createSelectField } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; @@ -69,14 +65,14 @@ interface PanelCallbacks { onDeletePoint: () => void; onRadiusChange: (radius: number) => void; onInputChange: () => void; - /** 비정규 측점 목록이 바뀔 때(추가·수정·삭제·리셋). */ - onIrregularChange: (stations: IrregularStation[]) => void; - /** 비정규 측점을 목록에서 선택/해제할 때 해당 측점(또는 null). */ - onIrregularSelect: (station: IrregularStation | null) => void; /** 구조물 목록(구조물군 B~G)이 바뀔 때 — 정본 저장·그래프 반영은 Page가 맡는다. */ onStructuresChange: (structures: StructureInstance[]) => void; /** 구조물을 목록에서 선택/해제할 때 해당 구조물(또는 null). */ onStructureSelect: (structure: StructureInstance | null) => void; + /** 계곡 통과 시설 추가·삭제·선택 — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합). */ + onPipeFacilityAdd: (chainageM: number, facility: PipeFacility) => void; + onPipeFacilityRemove: (chainageM: number) => void; + onPipeFacilitySelect: (chainageM: number) => void; /** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */ onStationDisplayChange: (offset: { station: number; cumulative: number }) => void; } @@ -349,21 +345,16 @@ export function createRoutePanel(callbacks: PanelCallbacks) { ), ); - // 비정규 측점(구조물 측점) — 측점번호+잔여거리로 추가/수정/삭제. 목록 변경은 Page로 올려 - // 그래프·테이블·3D에 반영한다. chainage 환산 기준인 측점간격은 실시간 조회한다. - const irregular = createIrregularStationsSection({ - getInterval: () => Number(stationInterval.value) || 20, - onChange: callbacks.onIrregularChange, - onSelect: callbacks.onIrregularSelect, - }); - - // 구조물 추가(구조물군 B~G) — 타입 목록과 옵션 칸은 서버 레지스트리에서 받아 그린다. - // 배관 등 계곡 통과 시설은 배수유역의 관 지점 정본이 담당한다. 위치 입출력은 - // 측점번호+잔여거리 표기라 비정규 측점과 같은 측점간격을 쓴다(2026-08-17). + // 「구조물 배치」 — 구 비정규 측점 섹션을 흡수한 단일 섹션(2026-08-17 컨테이너 병합). + // 타입 목록과 옵션 칸은 서버 레지스트리에서 받아 그리고, 계곡 통과 시설(A군)은 관 + // 지점 정본과 연동한다. 위치 입출력은 측점번호+잔여거리 두 칸이다. const structures = createStructuresSection({ onChange: callbacks.onStructuresChange, onSelect: callbacks.onStructureSelect, getInterval: () => Number(stationInterval.value) || 20, + onPipeAdd: callbacks.onPipeFacilityAdd, + onPipeRemove: callbacks.onPipeFacilityRemove, + onPipeSelect: callbacks.onPipeFacilitySelect, }); /** 등급·지형 선택에 맞춰 법정 기준값을 placeholder와 안내문에 반영한다. */ @@ -416,7 +407,6 @@ export function createRoutePanel(callbacks: PanelCallbacks) { gradeLine.root, contour.root, sectionOptions.root, - irregular.root, structures.root, routeCalc.root, selected.root, @@ -430,9 +420,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { return { root, viewControls, - /** 비정규 측점 섹션 API(목록 조회·선택·초기화). 아직 백엔드로 보내지 않는다(프론트 프리뷰). */ - irregularStations: irregular as IrregularStationsSection, - /** 구조물 섹션 API(타입 주입·목록 교체·선택·추가/이동/삭제). */ + /** 「구조물 배치」 섹션 API(타입 주입·목록 교체·계곡 시설 병합·선택·추가/이동/삭제). */ structures: structures as StructuresSection, /** 이어 공사 시작 기준(측점번호·누가거리 오프셋) 현재값. */ stationDisplayOffset, diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index fb5cdd3c..14aab46b 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -22,6 +22,7 @@ import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel"; +import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; import { mountStructureMarks } from "./B05_Profile_UI_Structures_Marks"; import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures"; @@ -208,10 +209,16 @@ function computeProfileLayout( /** 종단 테이블 구조물 라인·배수유역도가 Page로 올려 보내는 알림. */ export interface RouteProfilePanelCallbacks { - /** 관 매설 목록이 바뀜 — 구조물 목록의 "배관" 항목을 이 누가거리로 맞춘다. - * 유효직경(mm)을 함께 올려 관경 자동 지정에 쓴다. */ + /** 관 매설 목록이 바뀜 — 화면의 배관 투영·통합 목록을 이 목록으로 맞춘다. + * 유효직경(mm)은 관경 자동 지정, 시설 종류·구간은 통합 표시에 쓴다(2026-08-17). */ onPipesChanged?: ( - pipes: Array<{ chainage_m: number; effective_diameter_mm: number | null }>, + pipes: Array<{ + chainage_m: number; + effective_diameter_mm: number | null; + facility: PipeFacility; + start_m?: number; + end_m?: number; + }>, ) => void; /** 테이블에서 구조물 라인을 끌어 옮김. */ onStructureMove?: (fromChainageM: number, toChainageM: number, station: IrregularStation) => void; @@ -841,6 +848,8 @@ export function createRouteProfilePanel( x, chainageAt: chainageInverter(longitudinal, width, layout.originOffset), maxChainageM: maxChainageOf(longitudinal), + // 벌룬·툴팁 위치 표기는 측점번호+잔여거리(2026-08-17 사용자 확정). + stationIntervalM: stationInterval ?? 20, selectedId: selectedStructureId, onSelect: (structureId) => { selectedStructureId = structureId; diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index a4d15203..bdbaf790 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -15,6 +15,7 @@ import { type StructureInstance, type StructureType, } from "./B05_Profile_Api_Structures"; +import { formatStation } from "./B05_Profile_Util_Station"; /** 마크를 잡았다고 볼 여유(px). 원 반지름보다 조금 넉넉히 준다. */ const GRAB_SLACK_PX = 10; @@ -30,6 +31,8 @@ export interface StructureMarksOptions { /** x(px) → chainage. 마크를 끌 때 역변환. */ chainageAt: (px: number) => number; maxChainageM: number; + /** 측점간격(m) — 위치 표기를 측점번호+잔여거리로 한다(2026-08-17 사용자 확정). */ + stationIntervalM: number; selectedId: string | null; onSelect: (structureId: string | null) => void; onMove: (structureId: string, toChainageM: number) => void; @@ -47,10 +50,11 @@ function optionSummary(structure: StructureInstance, type: StructureType | undef .join(" · "); } -function positionText(structure: StructureInstance): string { +/** 위치 표기 — 측점번호+잔여거리(예: 3+18.0). 누가거리 표기는 쓰지 않는다(2026-08-17). */ +function positionText(structure: StructureInstance, intervalM: number): string { return 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, intervalM)} ~ ${formatStation(structure.end_m ?? 0, intervalM)}` + : formatStation(structure.chainage_m ?? 0, intervalM); } const SIDE_TEXT: Record = { @@ -119,7 +123,7 @@ export function mountStructureMarks(host: HTMLElement, options: StructureMarksOp mark.style.borderColor = type?.style?.color ?? "#888"; mark.style.color = type?.style?.color ?? "#888"; mark.textContent = type?.style?.abbr ?? "?"; - mark.title = `${type?.name ?? structure.type_id} ${positionText(structure)}`; + mark.title = `${type?.name ?? structure.type_id} ${positionText(structure, options.stationIntervalM)}`; mark.dataset.structureId = id; mark.addEventListener("click", (event) => { @@ -129,7 +133,8 @@ export function mountStructureMarks(host: HTMLElement, options: StructureMarksOp attachMarkDrag(mark, structure, options); layer.append(mark); - if (selected) layer.append(buildBalloon(structure, type, anchorPx, bottom)); + if (selected) + layer.append(buildBalloon(structure, type, anchorPx, bottom, options.stationIntervalM)); }); host.append(layer); @@ -141,6 +146,7 @@ function buildBalloon( type: StructureType | undefined, anchorPx: number, bottomPx: number, + stationIntervalM: number, ): HTMLElement { const balloon = document.createElement("div"); balloon.className = "b05-structure__balloon"; @@ -151,7 +157,7 @@ function buildBalloon( const title = document.createElement("strong"); title.textContent = type?.name ?? structure.type_id; const position = document.createElement("span"); - position.textContent = `${positionText(structure)} · ${SIDE_TEXT[structure.side] ?? ""}`; + position.textContent = `${positionText(structure, stationIntervalM)} · ${SIDE_TEXT[structure.side] ?? ""}`; balloon.append(title, position); const summary = optionSummary(structure, type); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index e2fd60e5..e25b982b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -1,12 +1,14 @@ /* ============================================================================= * B05_Profile_UI_Structures_Panel.ts - * 구조물 배치 사이드바 섹션 — 구조물군(A~G) → 타입 → 배치형태별 위치 → 옵션 입력. + * 「구조물 배치」 사이드바 섹션 — 구조물군(A~G) → 타입 → 위치(측점) → B05 옵션. * - * 타입 목록과 옵션 칸은 화면에 박아 두지 않고 서버 레지스트리(`GET /structure-types`)에서 - * 받아 그린다. 구조물 종류가 늘어도 이 파일을 고치지 않게 하려는 것이다. + * 구 「구조물 배치」(비정규 측점, 자유 텍스트)를 흡수한 단일 섹션이다(2026-08-17 + * 컨테이너 병합). 위치 입력은 측점번호+잔여거리 두 칸이고, 순서는 시작 측점 → + * 기준 측점 → 종료 측점이다(점형은 기준 측점만). 저장은 누가거리(m)로 한다. * - * 배관(구조물군 A)은 배수유역도의 관 지점이 정본이라 이 목록에서 다루지 않는다 — - * 기존 「구조물 배치」 섹션(비정규 측점)이 그대로 담당한다. + * 계곡 통과 시설(배관/BOX암거/물넘이/세월교, A군 managed_by)은 관 지점 정본 + * (`pipe_points.json`) 소관 — 여기서는 목록에 병합 표시하고 추가·삭제·선택을 + * 콜백으로 배수유역 패널에 넘긴다. 상세 치수는 B06/B07 몫이라 받지 않는다. * ========================================================================== */ import { @@ -18,7 +20,8 @@ import { type StructureSide, type StructureType, } from "./B05_Profile_Api_Structures"; -import { formatStation, parseStationText } from "./B05_Profile_Util_Station"; +import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { chainageToStation, formatStation } from "./B05_Profile_Util_Station"; /** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. */ const GROUP_LABELS: Record = { @@ -42,6 +45,14 @@ const SIDE_LABELS: Array<[StructureSide, string]> = [ /** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */ const DEFAULT_INTERVAL_LENGTH_M = 15; +/** 계곡 통과 시설 1건 — 관 지점 정본에서 온 병합 표시용 항목. */ +export interface PipeFacilityItem { + chainage_m: number; + facility: PipeFacility; + start_m?: number; + end_m?: number; +} + export interface StructuresSection { root: HTMLElement; /** 서버에서 받은 타입 목록을 채운다(최초 1회). */ @@ -49,6 +60,10 @@ export interface StructuresSection { /** 서버 정본으로 목록을 교체한다(진입·복원·저장 후). */ 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; /** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */ @@ -63,8 +78,14 @@ interface StructuresCallbacks { onChange: (structures: StructureInstance[]) => void; /** 목록에서 고르거나 해제할 때. */ onSelect: (structure: StructureInstance | null) => void; - /** 측점간격(m) — 측점번호+잔여거리 ↔ 누가거리 환산에 쓴다(비정규 측점과 동일). */ + /** 측점간격(m) — 측점번호+잔여거리 ↔ 누가거리 환산에 쓴다. */ getInterval: () => number; + /** 계곡 통과 시설 추가 — 관 지점 정본에 넣는다(배수유역 패널 경유). */ + onPipeAdd: (chainageM: number, facility: PipeFacility) => void; + /** 계곡 통과 시설 삭제. */ + onPipeRemove: (chainageM: number) => void; + /** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫). */ + onPipeSelect: (chainageM: number) => void; } function field(labelText: string, input: HTMLElement): HTMLLabelElement { @@ -90,12 +111,69 @@ function select(options: ReadonlyArray<[string, string]>): HTMLSelectElement { return element; } +/** 측점번호 + 잔여거리 두 칸 묶음 — 구 비정규 측점 UI와 같은 입력 방식(2026-08-17). */ +interface StationFields { + wrap: HTMLElement; + station: HTMLInputElement; + remainder: HTMLInputElement; + /** 두 칸을 누가거리로 읽는다. 비었으면 null, 형식 오류면 붉히고 null. */ + read: (intervalM: number, required: boolean) => number | null; + /** 누가거리를 두 칸에 나눠 싣는다. null이면 비운다. */ + write: (chainageM: number | null, intervalM: number) => void; + clearInvalid: () => void; +} + +function stationFields(labelText: string): StationFields { + const station = numberInput("1", "0"); + station.placeholder = "측점"; + const remainder = numberInput("0.1", "0"); + remainder.placeholder = "+m"; + const pair = document.createElement("div"); + pair.className = "b05-structure__station-pair"; + pair.append(station, remainder); + const wrap = field(labelText, pair); + const markInvalid = (bad: boolean): void => { + station.classList.toggle("is-invalid", bad); + remainder.classList.toggle("is-invalid", bad); + }; + return { + wrap, + station, + remainder, + read(intervalM, required) { + const stationText = station.value.trim(); + const remainderText = remainder.value.trim(); + if (!stationText && !remainderText) { + markInvalid(required); + return null; + } + const stationNo = stationText ? Number(stationText) : 0; + const rest = remainderText ? Number(remainderText) : 0; + const valid = + Number.isFinite(stationNo) && stationNo >= 0 && Number.isFinite(rest) && rest >= 0; + markInvalid(!valid); + return valid ? stationNo * intervalM + rest : null; + }, + write(chainageM, intervalM) { + if (chainageM === null) { + station.value = ""; + remainder.value = ""; + return; + } + const parts = chainageToStation(chainageM, intervalM); + station.value = String(parts.station); + remainder.value = parts.remainder.toFixed(1); + }, + clearInvalid: () => markInvalid(false), + }; +} + 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 = "구조물 추가"; + heading.textContent = "구조물 배치"; const body = document.createElement("div"); body.className = "b05-route__panel-body"; root.append(heading, body); @@ -103,29 +181,20 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu const groupSelect = select([]); const typeSelect = select([]); const sideSelect = select(SIDE_LABELS); - // 위치는 측점번호+잔여거리 표기("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(); + // 위치 입력 순서 = 시작 측점 → 기준 측점 → 종료 측점 (2026-08-17 사용자 확정). + // 점형은 기준 측점만 보인다. + const startFields = stationFields("시작 측점"); + const anchorFields = stationFields("기준 측점"); + const endFields = stationFields("종료 측점"); const offsetField = numberInput(); offsetField.value = "0"; const memoField = document.createElement("input"); memoField.type = "text"; memoField.placeholder = "메모(선택)"; - 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(anchorWrap, startWrap, endWrap); + positionRow.append(startFields.wrap, anchorFields.wrap, endFields.wrap); const sideRow = document.createElement("div"); sideRow.className = "b05-route__irregular-row"; @@ -155,9 +224,9 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu const help = document.createElement("p"); help.className = "b05-route__note"; help.textContent = - "구조물군과 종류를 고르고 측점(예: 3+18.0)을 입력해 추가합니다. 구간형은 기준점에 " + - "마크가 찍히고 시작~종료 측점을 받습니다(기준점을 비우면 시작 측점). 상세 치수는 " + - "B06/B07 단계에서 입력합니다. 배관 등 계곡 통과 시설은 배수유역 항목에서 관리합니다."; + "구조물군과 종류를 고르고 측점(측점번호 + 잔여거리)을 입력해 추가합니다. 구간형은 " + + "기준 측점에 마크가 찍힙니다(비우면 시작 측점). 계곡 통과 시설(배관·BOX암거·물넘이·" + + "세월교)은 배수유역 정본과 연동되고, 상세 치수는 B06/B07 단계에서 입력합니다."; body.append( field("구조물군", groupSelect), @@ -172,7 +241,10 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu let types: StructureType[] = []; let structures: StructureInstance[] = []; + let pipeFacilities: PipeFacilityItem[] = []; let editingId: string | null = null; + /** 목록에서 고른 계곡 통과 시설(누가거리 키). 삭제 버튼이 이쪽으로 동작한다. */ + let selectedPipeChainage: number | null = null; let optionInputs: Array<{ key: string; required: boolean; @@ -195,23 +267,17 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu 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"; + const type = currentType(); + const isInterval = !type?.managed_by && type?.placement === "interval"; + startFields.wrap.hidden = !isInterval; + endFields.wrap.hidden = !isInterval; + sideRow.hidden = !!type?.managed_by; + anchorFields.wrap.querySelector("span")!.textContent = isInterval + ? "기준 측점 (비우면 시작)" + : "기준 측점"; } /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 상세(detail) 옵션은 B06/B07 @@ -220,8 +286,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu const type = currentType(); optionInputs = []; optionRow.replaceChildren(); - const visible = type ? type.options.filter(isB05Option) : []; - if (!type || !visible.length) { + const visible = type && !type.managed_by ? type.options.filter(isB05Option) : []; + if (!visible.length) { optionRow.hidden = true; return; } @@ -238,7 +304,6 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu } 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"); @@ -257,10 +322,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu }); } - /** 고른 구조물군의 타입만 종류 목록에 채운다. */ + /** 고른 구조물군의 타입을 종류 목록에 채운다 — A군은 계곡 통과 시설(managed_by)도 + * 포함한다(추가 시 관 지점 정본으로 간다, 2026-08-17 통합). */ function syncTypeOptions(keepTypeId?: string): void { const group = groupSelect.value; - const candidates = types.filter((type) => type.group === group && !type.managed_by); + const candidates = types.filter((type) => type.group === group); typeSelect.replaceChildren(...candidates.map((type) => new Option(type.name, type.type_id))); if (keepTypeId && candidates.some((type) => type.type_id === keepTypeId)) { typeSelect.value = keepTypeId; @@ -271,14 +337,13 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu function syncButtons(): void { primary.textContent = editingId ? "수정" : "추가"; - removeButton.disabled = editingId === null; + removeButton.disabled = editingId === null && selectedPipeChainage === null; } - function labelOf(structure: StructureInstance): string { + function labelOfStructure(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" ? `${formatStation(structure.start_m ?? 0, step)}~${formatStation(structure.end_m ?? 0, step)}` @@ -286,37 +351,82 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu return `${position} · ${name}`; } + function labelOfPipe(pipe: PipeFacilityItem): string { + const type = typeMap().get(pipe.facility); + const name = type?.name ?? pipe.facility; + const step = interval(); + const position = + pipe.start_m !== undefined && pipe.end_m !== undefined && pipe.end_m > pipe.start_m + ? `${formatStation(pipe.start_m, step)}~${formatStation(pipe.end_m, step)}` + : formatStation(pipe.chainage_m, step); + return `${position} · ${name}`; + } + function renderList(): void { list.replaceChildren(); - if (!structures.length) { + // 두 정본을 하나의 목록으로 — 기준점 오름차순 병합 (2026-08-17 통합 표시). + const rows: Array< + | { kind: "structure"; anchor: number; structure: StructureInstance } + | { kind: "pipe"; anchor: number; pipe: PipeFacilityItem } + > = [ + ...structures.map((structure) => ({ + kind: "structure" as const, + anchor: structureAnchorM(structure), + structure, + })), + ...pipeFacilities.map((pipe) => ({ + kind: "pipe" as const, + anchor: pipe.chainage_m, + pipe, + })), + ].sort((left, right) => left.anchor - right.anchor); + + if (!rows.length) { const empty = document.createElement("li"); empty.className = "b05-route__irregular-empty"; - empty.textContent = "추가된 구조물이 없습니다."; + empty.textContent = "배치된 구조물이 없습니다."; list.append(empty); return; } - [...structures] - .sort((left, right) => structureAnchorM(left) - structureAnchorM(right)) - .forEach((structure) => { - const item = document.createElement("li"); - item.className = "b05-route__irregular-item"; + rows.forEach((row) => { + const item = document.createElement("li"); + item.className = "b05-route__irregular-item"; + const name = document.createElement("strong"); + const info = document.createElement("span"); + if (row.kind === "structure") { + const { structure } = row; item.classList.toggle("is-selected", structure.structure_id === editingId); - const name = document.createElement("strong"); - name.textContent = labelOf(structure); - const info = document.createElement("span"); + name.textContent = labelOfStructure(structure); const sideLabel = SIDE_LABELS.find(([value]) => value === structure.side)?.[1] ?? ""; info.textContent = [sideLabel, structure.memo].filter(Boolean).join(" · "); - item.append(name, info); item.addEventListener("click", () => loadForm(structure.structure_id === editingId ? null : structure), ); - list.append(item); if (structure.structure_id === editingId) item.scrollIntoView({ block: "nearest" }); - }); + } else { + const { pipe } = row; + item.classList.toggle( + "is-selected", + selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05, + ); + name.textContent = labelOfPipe(pipe); + info.textContent = "배수유역 연동"; + item.addEventListener("click", () => { + loadForm(null); + selectedPipeChainage = pipe.chainage_m; + syncButtons(); + renderList(); + callbacks.onPipeSelect(pipe.chainage_m); + }); + } + item.append(name, info); + list.append(item); + }); } function loadForm(target: StructureInstance | null): void { editingId = target?.structure_id ?? null; + selectedPipeChainage = null; const step = interval(); if (target) { const type = typeMap().get(target.type_id); @@ -324,28 +434,22 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu groupSelect.value = type.group; syncTypeOptions(target.type_id); } - 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) - : ""; + anchorFields.write(structureAnchorM(target), step); + startFields.write(target.start_m ?? null, step); + endFields.write(target.end_m ?? null, 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 = ""; + anchorFields.write(null, step); + startFields.write(null, step); + endFields.write(null, step); offsetField.value = "0"; memoField.value = ""; renderOptionFields(); } - [anchorField, startField, endField].forEach((input) => input.classList.remove("is-invalid")); + [anchorFields, startFields, endFields].forEach((fields) => fields.clearInvalid()); syncPlacementFields(); syncButtons(); renderList(); @@ -370,29 +474,42 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu function commit(): void { const type = currentType(); if (!type) return; - const placement = type.placement; + const step = interval(); + // 계곡 통과 시설 — 기준 측점만 받아 관 지점 정본으로 보낸다(시설 종류 = type_id). + if (type.managed_by) { + const anchor = anchorFields.read(step, true); + if (anchor === null) { + anchorFields.station.focus(); + return; + } + callbacks.onPipeAdd(anchor, type.type_id as PipeFacility); + loadForm(null); + return; + } + + const placement = type.placement; let anchor: number | null; let start: number | null = null; let end: number | null = null; if (placement === "interval") { - start = readStation(startField, true); - end = readStation(endField, true); + start = startFields.read(step, true); + end = endFields.read(step, true); if (start === null || end === null || end <= start) { - (start === null ? startField : endField).focus(); + (start === null ? startFields.station : endFields.station).focus(); return; } - // 기준점(마킹 위치) — 비우면 시작 측점. 시작~종료를 벗어나면 서버도 거절한다. - anchor = readStation(anchorField, false) ?? start; + // 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다. + anchor = anchorFields.read(step, false) ?? start; if (anchor < start || anchor > end) { - anchorField.classList.add("is-invalid"); - anchorField.focus(); + anchorFields.station.classList.add("is-invalid"); + anchorFields.station.focus(); return; } } else { - anchor = readStation(anchorField, true); + anchor = anchorFields.read(step, true); if (anchor === null) { - anchorField.focus(); + anchorFields.station.focus(); return; } } @@ -452,6 +569,14 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu }); primary.addEventListener("click", commit); removeButton.addEventListener("click", () => { + // 계곡 통과 시설이 골라져 있으면 관 지점 정본에서 지운다(배수유역 패널 경유). + 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); @@ -468,12 +593,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu root, setTypes(next) { types = next; - const groups = [...new Set(next.filter((type) => !type.managed_by).map((t) => t.group))]; + const groups = [...new Set(next.map((t) => t.group))]; groupSelect.replaceChildren( ...groups.map((group) => new Option(GROUP_LABELS[group] ?? group, group)), ); - // 배관(A군)은 배수유역 정본이 관리하므로 기본 선택은 그다음 군으로 둔다. - groupSelect.value = groups.includes("B") ? "B" : (groups[0] ?? ""); + groupSelect.value = groups.includes("A") ? "A" : (groups[0] ?? ""); syncTypeOptions(); }, setStructures(next) { @@ -484,6 +608,26 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu renderList(); }, getStructures: () => [...structures], + setPipeFacilities(pipes) { + pipeFacilities = pipes.map((pipe) => ({ ...pipe })); + if ( + selectedPipeChainage !== null && + !pipeFacilities.some((pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < 0.05) + ) { + selectedPipeChainage = null; + syncButtons(); + } + renderList(); + }, + selectPipeByChainage(chainageM) { + selectedPipeChainage = chainageM; + if (chainageM !== null && editingId) { + editingId = null; + callbacks.onSelect(null); + } + syncButtons(); + renderList(); + }, selectById(structureId) { if (structureId === null) { loadForm(null); @@ -493,8 +637,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu }, addAt(chainageM, typeId) { const type = typeMap().get(typeId); - // 배관 등 계곡 통과 시설은 관 지점 정본 소관이라 이 목록에 넣을 수 없다(서버도 거절). - if (!type || type.managed_by) return; + if (!type) return; + // 계곡 통과 시설은 관 지점 정본으로 바로 보낸다. + if (type.managed_by) { + callbacks.onPipeAdd(chainageM, typeId as PipeFacility); + return; + } const placement = placementOf(typeId); const step = interval(); @@ -505,12 +653,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu syncTypeOptions(typeId); typeSelect.value = typeId; editingId = null; - anchorField.value = formatStation(chainageM, step); - startField.value = placement === "interval" ? formatStation(chainageM, step) : ""; - endField.value = - placement === "interval" - ? formatStation(chainageM + DEFAULT_INTERVAL_LENGTH_M, step) - : ""; + anchorFields.write(chainageM, step); + startFields.write(placement === "interval" ? chainageM : null, step); + endFields.write( + placement === "interval" ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, + step, + ); sideSelect.value = "center"; offsetField.value = "0"; memoField.value = ""; @@ -520,7 +668,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu renderList(); root.scrollIntoView({ block: "nearest" }); const firstRequired = optionInputs.find((entry) => entry.required); - (firstRequired?.input ?? anchorField).focus(); + (firstRequired?.input ?? anchorFields.station).focus(); return; } diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index ef8174ad..f0a79932 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -109,3 +109,14 @@ border-color: #e05b5b; background: rgba(224, 91, 91, 0.08); } + +/* 측점번호 + 잔여거리 두 칸 입력(2026-08-17 통합 — 구 비정규 측점 UI와 같은 방식). */ +.b05-structure__station-pair { + display: flex; + gap: 4px; +} + +.b05-structure__station-pair input { + flex: 1; + min-width: 0; +}