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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
// 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다. 조회는 성공했으므로
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user