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