feat(B05): 구조물 컨테이너 병합 3단계 — 섹션 통합·측점 두 칸 입력·구 UI 폐기

사용자 화면 피드백 3건(2026-08-17) 반영 + PLAN 3단계(이관·폐기).

- 「구조물 배치」 단일 섹션: 구 비정규 측점 섹션을 흡수·삭제하고 「구조물
  추가」를 개칭. 위치 입력은 측점번호+잔여거리 두 칸(구 비정규 UI 방식),
  순서 = 시작 측점 → 기준 측점 → 종료 측점(점형은 기준만, 비우면 시작).
- A그룹 제어 통합: A군 종류 목록에 계곡 통과 시설(배관/BOX암거/물넘이/세월교)
  표시 — 추가는 관 지점 정본 경유(onPipeAdd, 시설 종류 = type_id), 목록에
  "배수유역 연동"으로 병합 표시·선택 동기화·삭제. 노출형 횡단수로·개거는
  수동 구조물.
- 그래프 측점 표현: 서클마크 툴팁·벌룬 위치를 누가거리에서 측점번호+잔여
  거리 표기로 변경(mountStructureMarks에 측점간격 주입).
- 구 비정규 측점 이관: POST /route/structures/migrate 신설 — 기존 Migration
  매핑(배관 제외·기성막이→기슭막이·대피로→대피소) 사용, 정본 기존 (타입,
  기준점) 점유 검사로 멱등. Page 진입 시 구 확정분을 자동 이관하고 건수를
  토스트로 알린다. TDD 라우터 테스트 4건.
- 구 UI 폐기: onIrregularChange/Select 콜백 제거, 배관 투영은 Page의
  pipesToStations()가 직접 생성(시설 종류별 라벨). 그래프 측점선 드래그·
  삭제는 배관 전용으로 단일화, 구 우클릭 항목은 레지스트리 타입으로 매핑.
  drainage addPipe(chainage, facility?) 확장 — 통합 목록에서 시설 종류를
  지정해 추가하면 재계산 요청에 실려 정본까지 간다.

tmp/tests 92건·ruff·typecheck·build 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:28:07 +09:00
co-authored by Claude Opus 5
parent deaf51778f
commit 2028af6152
9 changed files with 489 additions and 170 deletions
+12
View File
@@ -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 {
@@ -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
+23 -6
View File
@@ -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<number>) => void;
/** 종단 테이블 우클릭으로 배관을 넣거나 지울 때. */
addPipe: (chainageM: number) => void;
/** 종단 테이블 우클릭·통합 목록에서 계곡 통과 시설을 넣거나 지울 때.
* facility를 주면 그 종류로 추가한다(기본 배관). */
addPipe: (chainageM: number, facility?: PipeFacility) => void;
removePipe: (chainageM: number) => void;
/** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */
savePipes: () => Promise<number>;
@@ -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) {
+112 -33
View File
@@ -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> {
void purgeOtherProjects(activeProjectId);
const viewer = createRouteViewer();
// 구 우클릭 메뉴의 자유 텍스트 구조물 → 레지스트리 타입(2026-08-17 이관 매핑과 동일).
const LEGACY_TYPE_IDS: Record<string, string> = {
: "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<void> {
},
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<void> {
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<void> {
viewer.renderStationLines(withUphill, roadWidths[panel.values().gradeClass] / 2);
}
/** 시설 종류별 표시 이름 — 그래프·3D 라벨용(배관은 관종·관경까지). */
const FACILITY_NAMES: Record<PipeFacility, string> = {
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<void> {
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) {
// 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다. 조회는 성공했으므로
+12 -24
View File
@@ -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,
+12 -3
View File
@@ -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;
+12 -6
View File
@@ -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<string, string> = {
@@ -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);
+246 -98
View File
@@ -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<string, string> = {
@@ -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;
}
@@ -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;
}