feat(B05): 구조물 수동 추가 프론트 — 패널·종단도 서클마크·벌룬·정본 연동

구조물군 B~G 전체를 화면에서 수동 배치할 수 있게 한다. 타입 목록·옵션 폼은
서버 레지스트리(GET /structure-types)에서 받아 그리므로 구조물이 늘어도
프론트 코드는 그대로다.

- B05_Profile_Api_Structures.ts: 레지스트리·정본 CRUD 클라이언트.
  409(판번호 충돌)를 전용 오류로 구분, 구간형 기점 앵커 헬퍼.
- B05_Profile_UI_Structures_Panel.ts: 사이드바 「구조물 추가」 섹션.
  구조물군→종류→위치(점형/구간형)·설치측·이격·옵션 동적 폼·메모.
- B05_Profile_UI_Structures_Marks.ts: 종단 그래프 서클마크 오버레이.
  전 배치형태 = 마크 하나(구간형은 기점), 선택 시 벌룬 + 구간 띠 확장
  (2026-08-16 사용자 확정 표기 규칙). 드래그 이동·겹침 스택.
- B05_Profile_UI_Style_Structures.css: 마크·띠·벌룬 스타일.
- Profile_Panel: 마크 레이어 장착·타입 주입·선택 동기화 API 추가.
  우클릭 메뉴(Profile_Structures)에 레지스트리 타입 추가 항목.
- Page: 진입 시 타입·정본 로드, 변경 즉시 정본 저장(직렬화 큐),
  409 시 최신본 재적재 안내, 그래프↔사이드 선택 양방향 동기화.
  정본 주입은 onChange를 울리지 않아 저장 루프를 차단.

tsc --noEmit 통과. 기존 배관·배수유역·종단 편집 흐름은 손대지 않았다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 22:30:36 +09:00
co-authored by Claude Fable 5
parent 50e5626a95
commit 61108f540d
8 changed files with 1100 additions and 4 deletions
+169
View File
@@ -0,0 +1,169 @@
/* =============================================================================
* B05_Profile_Api_Structures.ts
* 구조물 타입 레지스트리·구조물 정본 API 클라이언트.
*
* 백엔드 계약 (B05_Profile_Structures_Router.py):
* GET /api/projects/structure-types → 타입 레지스트리
* GET /api/projects/{project_id}/route/structures → 목록 + 판번호
* PUT /api/projects/{project_id}/route/structures → 목록 전체 덮어쓰기
*
* 타입 정의를 화면에 박아 두지 않는다 — 레지스트리 파일 하나만 고치면 폼까지 따라오게
* 하려는 것이라, 목록은 반드시 서버에서 받아 온다.
* ========================================================================== */
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
/** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */
export type StructurePlacement = "point" | "interval" | "site";
/** 노선 기준 설치 측. 횡단(B06) 연계에 쓴다. */
export type StructureSide = "left" | "right" | "center" | "cross";
export interface StructureOptionField {
key: string;
label: string;
input: "select" | "number" | "text";
choices: string[];
unit: string | null;
default: string | number | null;
}
export interface StructureType {
type_id: string;
group: string;
name: string;
placement: StructurePlacement;
options: StructureOptionField[];
style: { color?: string; abbr?: string };
drawing_views: string[];
/** 다른 정본이 관리하는 타입(배관 = pipe_points.json) — 구조물 목록에 넣지 않는다. */
managed_by: string | null;
reference_only: boolean;
enabled: boolean;
}
export interface StructureInstance {
structure_id?: string | null;
type_id: string;
placement: StructurePlacement;
chainage_m?: number | null;
start_m?: number | null;
end_m?: number | null;
side: StructureSide;
offset_m: number;
options: Record<string, string | number>;
memo: string;
placement_source: "manual" | "suggested" | "automatic";
status: "draft" | "confirmed";
revision: number;
geometry: Record<string, unknown> | null;
}
interface StructureTypesResponse {
status: string;
schema_version: number;
types: StructureType[];
}
export interface StructureListResponse {
status: string;
project_id: string;
revision: number;
structures: StructureInstance[];
}
export interface StructureSaveResponse {
status: string;
project_id: string;
revision: number;
count: number;
invalidated_downstream: boolean;
}
/** 다른 창이 먼저 저장해 판번호가 어긋났다 — 화면이 최신본을 다시 받아야 한다. */
export class StructureConflictError extends Error {
constructor(
message: string,
readonly currentRevision: number,
) {
super(message);
this.name = "StructureConflictError";
}
}
async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
try {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
...init,
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message =
(payload && typeof payload.message === "string" && payload.message) ||
`요청이 실패했습니다 (${response.status}).`;
if (response.status === 409) {
throw new StructureConflictError(message, Number(payload?.revision ?? 0));
}
throw new Error(message);
}
return payload as T;
} finally {
window.clearTimeout(timer);
}
}
/** 타입 레지스트리는 서버 배포 중에 바뀌지 않으므로 탭 수명 동안 한 번만 받는다. */
let typesCache: Promise<StructureType[]> | null = null;
export function fetchStructureTypes(): Promise<StructureType[]> {
if (!typesCache) {
typesCache = requestJson<StructureTypesResponse>("/projects/structure-types", {
method: "GET",
})
.then((payload) => payload.types.filter((type) => type.enabled))
.catch((error) => {
typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다.
throw error;
});
}
return typesCache;
}
export async function fetchStructures(projectId: string): Promise<StructureListResponse> {
return requestJson<StructureListResponse>(`/projects/${projectId}/route/structures`, {
method: "GET",
});
}
export async function saveStructures(
projectId: string,
baseRevision: number,
structures: StructureInstance[],
): Promise<StructureSaveResponse> {
return requestJson<StructureSaveResponse>(`/projects/${projectId}/route/structures`, {
method: "PUT",
body: JSON.stringify({ base_revision: baseRevision, structures }),
});
}
/** 종단도 마크 위치 — 구간형은 기점(시점)에 찍는다(2026-08-16 사용자 확정). */
export function structureAnchorM(structure: StructureInstance): number {
return structure.placement === "interval"
? (structure.start_m ?? 0)
: (structure.chainage_m ?? 0);
}
/** 타입 정의의 기본값으로 옵션을 채운다(신규 추가·타입 변경 시). */
export function defaultOptions(type: StructureType): Record<string, string | number> {
const options: Record<string, string | number> = {};
type.options.forEach((field) => {
if (field.default !== null && field.default !== undefined) options[field.key] = field.default;
else if (field.input === "select" && field.choices.length)
options[field.key] = field.choices[0];
});
return options;
}
+83
View File
@@ -55,7 +55,15 @@ import {
invalidateSectionDetail,
loadSectionDetail,
} from "../B06_Section/B06_Section_Section_Store";
import {
fetchStructures,
fetchStructureTypes,
saveStructures,
StructureConflictError,
type StructureInstance,
} from "./B05_Profile_Api_Structures";
import "./B05_Profile_UI_Style.css";
import "./B05_Profile_UI_Style_Structures.css";
type GradeClass = RoutePanelValues["gradeClass"];
type RoadWidths = Record<GradeClass, number>;
@@ -226,6 +234,11 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)),
// 배수유역도에서 유역을 고르면 그 관의 구조물 측점을 그래프·3D·사이드 패널에서도 고른다.
onBasinSelected: (chainageM) => selectStationOfPipe(chainageM),
// 구조물 서클마크 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다.
onStructureSelect: (structureId) => panel.structures.selectById(structureId),
onStructureMarkMove: (structureId, toChainage) =>
panel.structures.moveById(structureId, toChainage),
onStructureTypeAdd: (chainage, typeId) => panel.structures.addAt(chainage, typeId),
},
);
@@ -339,6 +352,16 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
selectionSyncing = false;
}
},
onStructuresChange: (next) => applyStructures(next),
onStructureSelect: (structure) => {
if (selectionSyncing) return;
selectionSyncing = true;
try {
profilePanel.setSelectedStructure(structure?.structure_id ?? null);
} finally {
selectionSyncing = false;
}
},
});
/** 입력·마커 변경 시 측점 라인만 다시 그린다 — 확정 게이트는 폐지(B06 통합 확정). */
@@ -436,6 +459,64 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
);
}
/* ── 구조물 정본(structures.json) ────────────────────────────────────
* 사이드 목록이 바뀌면 곧바로 서버 정본에 저장한다 — 화면에만 남겨 두면 새로고침에
* 사라지고, 다른 창과도 어긋난다. 판번호가 밀리면(다른 창이 먼저 저장) 최신본을
* 받아 화면을 맞추고 사용자에게 알린다. */
let structureRevision = 0;
let structureSaving: Promise<void> = Promise.resolve();
function applyStructures(next: StructureInstance[]): void {
profilePanel.setStructures(next);
// 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다.
structureSaving = structureSaving.then(() => persistStructures(next));
}
async function persistStructures(next: StructureInstance[]): Promise<void> {
if (restoring) return;
try {
const saved = await saveStructures(activeProjectId, structureRevision, next);
structureRevision = saved.revision;
// 서버가 새 항목에 식별자를 붙이므로 그 결과로 화면 목록을 맞춘다.
const stored = await fetchStructures(activeProjectId);
structureRevision = stored.revision;
panel.structures.setStructures(stored.structures);
profilePanel.setStructures(stored.structures);
} catch (error) {
if (error instanceof StructureConflictError) {
const stored = await fetchStructures(activeProjectId).catch(() => null);
if (stored) {
structureRevision = stored.revision;
panel.structures.setStructures(stored.structures);
profilePanel.setStructures(stored.structures);
}
showToast("다른 창에서 구조물이 먼저 저장되어 최신 내용으로 되돌렸습니다.", "error");
return;
}
showToast(error instanceof Error ? error.message : "구조물 저장에 실패했습니다.", "error");
}
}
/** 진입·새로고침 때 타입 레지스트리와 구조물 정본을 받아 화면에 채운다. */
async function loadStructures(): Promise<void> {
try {
const [types, stored] = await Promise.all([
fetchStructureTypes(),
fetchStructures(activeProjectId),
]);
panel.structures.setTypes(types);
profilePanel.setStructureTypes(types);
structureRevision = stored.revision;
panel.structures.setStructures(stored.structures);
profilePanel.setStructures(stored.structures);
} catch (error) {
showToast(
error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.",
"error",
);
}
}
function renderSections(detail: SectionDetailResponse, routeId?: number): void {
currentSectionDetail = detail;
profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId);
@@ -731,6 +812,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
renderLatest(latestResponse);
if (currentSectionDetail) renderStationLines(currentSectionDetail);
}
// 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후).
await loadStructures();
advanceLoading("");
} catch (error) {
showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error");
+16
View File
@@ -4,6 +4,8 @@ import {
type IrregularStation,
type IrregularStationsSection,
} from "./B05_Profile_UI_IrregularStations";
import type { StructureInstance } from "./B05_Profile_Api_Structures";
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";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
@@ -71,6 +73,10 @@ interface PanelCallbacks {
onIrregularChange: (stations: IrregularStation[]) => void;
/** 비정규 측점을 목록에서 선택/해제할 때 해당 측점(또는 null). */
onIrregularSelect: (station: IrregularStation | null) => void;
/** 구조물 목록(구조물군 B~G)이 바뀔 때 — 정본 저장·그래프 반영은 Page가 맡는다. */
onStructuresChange: (structures: StructureInstance[]) => void;
/** 구조물을 목록에서 선택/해제할 때 해당 구조물(또는 null). */
onStructureSelect: (structure: StructureInstance | null) => void;
/** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */
onStationDisplayChange: (offset: { station: number; cumulative: number }) => void;
}
@@ -351,6 +357,13 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
onSelect: callbacks.onIrregularSelect,
});
// 구조물 추가(구조물군 B~G) — 타입 목록과 옵션 칸은 서버 레지스트리에서 받아 그린다.
// 배관은 위 비정규 측점 섹션이 관 지점 정본과 맞물려 계속 담당한다.
const structures = createStructuresSection({
onChange: callbacks.onStructuresChange,
onSelect: callbacks.onStructureSelect,
});
/** 등급·지형 선택에 맞춰 법정 기준값을 placeholder와 안내문에 반영한다. */
function syncCriteria(): void {
const criteria = PROFILE_CRITERIA[gradeClass.value as RoutePanelValues["gradeClass"]];
@@ -402,6 +415,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
contour.root,
sectionOptions.root,
irregular.root,
structures.root,
routeCalc.root,
selected.root,
actionRow,
@@ -416,6 +430,8 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
viewControls,
/** 비정규 측점 섹션 API(목록 조회·선택·초기화). 아직 백엔드로 보내지 않는다(프론트 프리뷰). */
irregularStations: irregular as IrregularStationsSection,
/** 구조물 섹션 API(타입 주입·목록 교체·선택·추가/이동/삭제). */
structures: structures as StructuresSection,
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋) 현재값. */
stationDisplayOffset,
values(): RoutePanelValues {
@@ -22,6 +22,8 @@ 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 { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
import { mountStructureMarks } from "./B05_Profile_UI_Structures_Marks";
import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures";
import {
createProfileTableOverlay,
@@ -221,6 +223,12 @@ export interface RouteProfilePanelCallbacks {
onStructureAdd?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void;
/** 구조물 라인을 눌러 고름. */
onIrregularSelect?: (station: IrregularStation) => void;
/** 종단 그래프에서 구조물 서클마크를 고름(해제면 null). */
onStructureSelect?: (structureId: string | null) => void;
/** 종단 그래프에서 구조물 서클마크를 끌어 옮김. */
onStructureMarkMove?: (structureId: string, toChainageM: number) => void;
/** 종단 그래프 우클릭으로 레지스트리 타입을 지정해 구조물을 넣음. */
onStructureTypeAdd?: (chainageM: number, typeId: string) => void;
/** 배수유역도에서 유역을 고름 — 그 관의 누가거리(해제면 null). 그래프·사이드 패널을 맞춘다. */
onBasinSelected?: (chainageM: number | null) => void;
}
@@ -369,6 +377,10 @@ export function createRouteProfilePanel(
let stationInterval: number | undefined;
let routeId: number | null = null;
let irregularStations: IrregularStation[] = [];
// 구조물 정본(structures.json) 목록과 타입 레지스트리 — 그래프 서클마크·벌룬에 쓴다.
let structures: StructureInstance[] = [];
let structureTypes: StructureType[] = [];
let selectedStructureId: string | null = null;
// 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지).
let stationDisplay = { station: 0, cumulative: 0 };
let base: AlignmentBase | null = null;
@@ -809,6 +821,28 @@ export function createRouteProfilePanel(
onRemove: (station) => callbacks?.onStructureRemove?.(station),
onAddPipe: (chainage) => callbacks?.onPipeAdd?.(chainage),
onAddStructure: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type),
structureTypes: structureTypes.map((type) => ({
type_id: type.type_id,
group: type.group,
name: type.name,
})),
onAddStructureType: (chainage, typeId) => callbacks?.onStructureTypeAdd?.(chainage, typeId),
});
// 구조물 정본 서클마크 — 배치형태와 무관하게 마크 하나, 고르면 벌룬(+구간 띠).
mountStructureMarks(chartWrap, {
structures,
types: structureTypes,
x,
chainageAt: chainageInverter(longitudinal, width, layout.originOffset),
maxChainageM: maxChainageOf(longitudinal),
selectedId: selectedStructureId,
onSelect: (structureId) => {
selectedStructureId = structureId;
callbacks?.onStructureSelect?.(structureId);
draw();
},
onMove: (structureId, toChainage) =>
callbacks?.onStructureMarkMove?.(structureId, toChainage),
});
// 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단).
// 앵커(0크기 sticky)는 **첫 자식**이어야 한다 — SVG 뒤에 붙이면 흐름 위치가 차트
@@ -972,6 +1006,24 @@ export function createRouteProfilePanel(
irregularStations = stations;
draw();
},
/** 구조물 타입 레지스트리를 받아 마크 색·약호·우클릭 메뉴에 쓴다(최초 1회). */
setStructureTypes(types: StructureType[]) {
structureTypes = types;
draw();
},
/** 구조물 정본 목록을 반영해 그래프 서클마크를 다시 그린다. */
setStructures(next: StructureInstance[]) {
structures = next;
if (selectedStructureId && !next.some((s) => s.structure_id === selectedStructureId)) {
selectedStructureId = null;
}
draw();
},
/** 사이드 목록에서 고른 구조물을 그래프 마크 선택에 맞춘다. */
setSelectedStructure(structureId: string | null) {
selectedStructureId = structureId;
draw();
},
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
setStationDisplay(next: { station: number; cumulative: number }) {
stationDisplay = next;
@@ -30,8 +30,15 @@ export interface StructureLineOptions {
onAddPipe: (chainageM: number) => void;
/** 빈 자리에서 우클릭해 배관 외 구조물(기성막이/대피로/기타)을 넣을 때. */
onAddStructure?: (chainageM: number, structureType: "기성막이" | "대피로" | "기타") => void;
/** 레지스트리 타입 목록(구조물군·이름). 우클릭 메뉴의 "구조물 추가" 항목이 된다. */
structureTypes?: ReadonlyArray<{ type_id: string; group: string; name: string }>;
/** 레지스트리 타입을 골라 넣을 때. */
onAddStructureType?: (chainageM: number, typeId: string) => void;
}
/** 우클릭 메뉴에 한 번에 늘어놓을 타입 수. 넘으면 메뉴가 화면을 넘긴다 — 나머지는 사이드에서. */
const MENU_TYPE_LIMIT = 12;
/**
* 그래프 칸에 우클릭 메뉴를 붙인다. 그래프는 매 그리기마다 새로 만들어지므로 이 함수도
* 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다.
@@ -60,12 +67,21 @@ export function mountStructureMenu(host: HTMLElement, options: StructureLineOpti
event.preventDefault();
const at = Number(chainage.toFixed(2));
// 빈 자리 — 구조물 종류별 추가 항목(2026-08-05 사용자 지시. 배관은 관 지점 정본 경유).
// 레지스트리 타입이 오면 그 목록을 쓰고(구조물군 B~G), 아직 못 받았으면 구 항목을 쓴다.
const registryItems = (options.structureTypes ?? [])
.slice(0, MENU_TYPE_LIMIT)
.map((type): [string, () => void] => [
`${type.name} 추가`,
() => options.onAddStructureType?.(at, type.type_id),
]);
const addItems: Array<[string, () => void]> = [
["배관 추가", () => options.onAddPipe(at)],
...(["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [
`${type === "기타" ? "기타 구조물" : type} 추가`,
() => options.onAddStructure?.(at, type),
]),
...(registryItems.length
? registryItems
: (["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [
`${type === "기타" ? "기타 구조물" : type} 추가`,
() => options.onAddStructure?.(at, type),
])),
];
menu.open(localX, event.clientY - rect.top, [
...(near
@@ -0,0 +1,210 @@
/* =============================================================================
* B05_Profile_UI_Structures_Marks.ts
* 종단 그래프 위 구조물 서클마크·벌룬 오버레이.
*
* 표기 규칙 (2026-08-16 사용자 확정):
* - 배치형태와 무관하게 **서클마크 하나**로 표시한다. 구간형은 기점(시점)에 찍는다.
* - 마크를 고르면 벌룬으로 종류·위치·제원을 띄우고, 구간형은 그때만 시~종점 구간을
* 띠로 펼쳐 보여 준다(평상시에는 띠를 그리지 않는다).
*
* 그래프는 매 그리기마다 새로 만들어지므로 이 오버레이도 그때마다 다시 붙인다.
* ========================================================================== */
import {
structureAnchorM,
type StructureInstance,
type StructureType,
} from "./B05_Profile_Api_Structures";
/** 마크를 잡았다고 볼 여유(px). 원 반지름보다 조금 넉넉히 준다. */
const GRAB_SLACK_PX = 10;
/** 마크가 겹칠 때 위로 띄우는 간격(px). 같은 자리 구조물이 서로를 가리지 않게 한다. */
const STACK_STEP_PX = 22;
const STACK_BASE_PX = 10;
export interface StructureMarksOptions {
structures: ReadonlyArray<StructureInstance>;
types: ReadonlyArray<StructureType>;
/** chainage → x(px). 그래프·테이블과 같은 매핑. */
x: (chainageM: number) => number;
/** x(px) → chainage. 마크를 끌 때 역변환. */
chainageAt: (px: number) => number;
maxChainageM: number;
selectedId: string | null;
onSelect: (structureId: string | null) => void;
onMove: (structureId: string, toChainageM: number) => void;
}
function optionSummary(structure: StructureInstance, type: StructureType | undefined): string {
if (!type) return "";
return type.options
.map((option) => {
const value = structure.options?.[option.key];
if (value === undefined || value === "") return null;
return `${option.label} ${value}${option.unit ?? ""}`;
})
.filter(Boolean)
.join(" · ");
}
function positionText(structure: StructureInstance): 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`;
}
const SIDE_TEXT: Record<string, string> = {
left: "좌측",
right: "우측",
center: "중심",
cross: "횡단",
};
/** 같은 자리에 겹친 마크를 위로 쌓아 올릴 높이를 정한다. */
function stackOffsets(
structures: ReadonlyArray<StructureInstance>,
x: (chainageM: number) => number,
): Map<string, number> {
const offsets = new Map<string, number>();
const used: Array<{ px: number; level: number }> = [];
[...structures]
.sort((left, right) => structureAnchorM(left) - structureAnchorM(right))
.forEach((structure) => {
const px = x(structureAnchorM(structure));
const conflicts = used.filter((entry) => Math.abs(entry.px - px) < STACK_STEP_PX);
const level = conflicts.length ? Math.max(...conflicts.map((e) => e.level)) + 1 : 0;
used.push({ px, level });
offsets.set(structure.structure_id ?? "", STACK_BASE_PX + level * STACK_STEP_PX);
});
return offsets;
}
/**
* 그래프 칸에 구조물 마크 레이어를 붙인다.
*/
export function mountStructureMarks(host: HTMLElement, options: StructureMarksOptions): void {
const layer = document.createElement("div");
layer.className = "b05-structure__layer";
const typeMap = new Map(options.types.map((type) => [type.type_id, type]));
const offsets = stackOffsets(options.structures, options.x);
options.structures.forEach((structure) => {
const id = structure.structure_id;
if (!id) return;
const type = typeMap.get(structure.type_id);
const anchorPx = options.x(structureAnchorM(structure));
const bottom = offsets.get(id) ?? STACK_BASE_PX;
const selected = options.selectedId === id;
// 구간형은 고른 동안만 시~종점을 띠로 펼친다(2026-08-16 사용자 확정).
if (selected && structure.placement === "interval") {
const band = document.createElement("div");
band.className = "b05-structure__band";
const startPx = options.x(structure.start_m ?? 0);
const endPx = options.x(structure.end_m ?? 0);
band.style.left = `${Math.min(startPx, endPx)}px`;
band.style.width = `${Math.max(Math.abs(endPx - startPx), 2)}px`;
band.style.bottom = `${bottom}px`;
band.style.background = type?.style?.color ?? "#888";
layer.append(band);
}
const mark = document.createElement("button");
mark.type = "button";
mark.className = "b05-structure__mark";
mark.classList.toggle("is-selected", selected);
mark.style.left = `${anchorPx}px`;
mark.style.bottom = `${bottom}px`;
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.dataset.structureId = id;
mark.addEventListener("click", (event) => {
event.stopPropagation();
options.onSelect(selected ? null : id);
});
attachMarkDrag(mark, structure, options);
layer.append(mark);
if (selected) layer.append(buildBalloon(structure, type, anchorPx, bottom));
});
host.append(layer);
}
/** 고른 구조물의 값 벌룬. 유토곡선 벌룬과 같은 결(도형+텍스트)로 맞춘다. */
function buildBalloon(
structure: StructureInstance,
type: StructureType | undefined,
anchorPx: number,
bottomPx: number,
): HTMLElement {
const balloon = document.createElement("div");
balloon.className = "b05-structure__balloon";
balloon.style.left = `${anchorPx}px`;
balloon.style.bottom = `${bottomPx + STACK_STEP_PX}px`;
balloon.style.borderColor = type?.style?.color ?? "#888";
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] ?? ""}`;
balloon.append(title, position);
const summary = optionSummary(structure, type);
if (summary) {
const detail = document.createElement("span");
detail.textContent = summary;
balloon.append(detail);
}
if (structure.memo) {
const memo = document.createElement("span");
memo.className = "b05-structure__balloon-memo";
memo.textContent = structure.memo;
balloon.append(memo);
}
return balloon;
}
/** 마크를 좌우로 끌어 위치를 옮긴다. 구간형은 길이를 유지한 채 통째로 움직인다. */
function attachMarkDrag(
mark: HTMLElement,
structure: StructureInstance,
options: StructureMarksOptions,
): void {
let dragging = false;
let movedPx = 0;
mark.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
dragging = true;
movedPx = 0;
mark.setPointerCapture(event.pointerId);
event.stopPropagation();
});
mark.addEventListener("pointermove", (event) => {
if (!dragging) return;
movedPx += Math.abs(event.movementX);
const host = mark.parentElement?.parentElement;
if (!host) return;
const localX = event.clientX - host.getBoundingClientRect().left;
mark.style.left = `${localX}px`;
});
mark.addEventListener("pointerup", (event) => {
if (!dragging) return;
dragging = false;
mark.releasePointerCapture(event.pointerId);
// 손이 떨린 정도(수 px)는 이동이 아니라 클릭으로 본다 — 고르려다 옮겨지면 곤란하다.
if (movedPx <= GRAB_SLACK_PX) return;
const host = mark.parentElement?.parentElement;
if (!host) return;
const localX = event.clientX - host.getBoundingClientRect().left;
const chainage = Math.min(Math.max(options.chainageAt(localX), 0), options.maxChainageM);
options.onMove(structure.structure_id ?? "", Number(chainage.toFixed(2)));
});
}
@@ -0,0 +1,466 @@
/* =============================================================================
* B05_Profile_UI_Structures_Panel.ts
* 구조물 배치 사이드바 섹션 — 구조물군(A~G) → 타입 → 배치형태별 위치 → 옵션 입력.
*
* 타입 목록과 옵션 칸은 화면에 박아 두지 않고 서버 레지스트리(`GET /structure-types`)에서
* 받아 그린다. 구조물 종류가 늘어도 이 파일을 고치지 않게 하려는 것이다.
*
* 배관(구조물군 A)은 배수유역도의 관 지점이 정본이라 이 목록에서 다루지 않는다 —
* 기존 「구조물 배치」 섹션(비정규 측점)이 그대로 담당한다.
* ========================================================================== */
import {
defaultOptions,
structureAnchorM,
type StructureInstance,
type StructurePlacement,
type StructureSide,
type StructureType,
} from "./B05_Profile_Api_Structures";
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. */
const GROUP_LABELS: Record<string, string> = {
A: "A 횡단배수",
B: "B 종단배수",
C: "C 사면안정",
D: "D 계류·사방",
E: "E 안전·부대·용지",
F: "F 생태·녹화",
G: "G 노면공",
: "기타",
};
const SIDE_LABELS: Array<[StructureSide, string]> = [
["left", "좌측"],
["right", "우측"],
["center", "중심"],
["cross", "횡단"],
];
/** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */
const DEFAULT_INTERVAL_LENGTH_M = 15;
export interface StructuresSection {
root: HTMLElement;
/** 서버에서 받은 타입 목록을 채운다(최초 1회). */
setTypes: (types: StructureType[]) => void;
/** 서버 정본으로 목록을 교체한다(진입·복원·저장 후). */
setStructures: (structures: StructureInstance[]) => void;
getStructures: () => StructureInstance[];
/** 종단도·3D에서 고른 구조물을 폼에 올린다. null이면 선택 해제. */
selectById: (structureId: string | null) => void;
/** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */
addAt: (chainageM: number, typeId: string) => void;
/** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */
moveById: (structureId: string, toChainageM: number) => boolean;
removeById: (structureId: string) => boolean;
}
interface StructuresCallbacks {
/** 목록이 바뀔 때(추가·수정·삭제) 전체 목록을 넘긴다. */
onChange: (structures: StructureInstance[]) => void;
/** 목록에서 고르거나 해제할 때. */
onSelect: (structure: StructureInstance | null) => void;
}
function field(labelText: string, input: HTMLElement): HTMLLabelElement {
const wrapper = document.createElement("label");
wrapper.className = "b05-route__field";
const caption = document.createElement("span");
caption.textContent = labelText;
wrapper.append(caption, input);
return wrapper;
}
function numberInput(step = "0.1", min = "0"): HTMLInputElement {
const input = document.createElement("input");
input.type = "number";
input.step = step;
input.min = min;
return input;
}
function select(options: ReadonlyArray<[string, string]>): HTMLSelectElement {
const element = document.createElement("select");
element.replaceChildren(...options.map(([value, label]) => new Option(label, value)));
return element;
}
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 = "구조물 추가";
const body = document.createElement("div");
body.className = "b05-route__panel-body";
root.append(heading, body);
const groupSelect = select([]);
const typeSelect = select([]);
const sideSelect = select(SIDE_LABELS);
const startField = numberInput();
startField.placeholder = "시점";
const endField = numberInput();
endField.placeholder = "종점";
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 positionRow = document.createElement("div");
positionRow.className = "b05-route__irregular-row";
positionRow.append(startWrap, endWrap);
const sideRow = document.createElement("div");
sideRow.className = "b05-route__irregular-row";
sideRow.append(field("설치측", sideSelect), field("이격 (m)", offsetField));
// 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다.
const optionRow = document.createElement("div");
optionRow.className = "b05-route__irregular-row";
const primary = document.createElement("button");
primary.type = "button";
primary.className = "b05-route__irregular-btn is-primary";
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "b05-route__irregular-btn is-danger";
removeButton.textContent = "삭제";
const resetButton = document.createElement("button");
resetButton.type = "button";
resetButton.className = "b05-route__irregular-btn";
resetButton.textContent = "리셋";
const actions = document.createElement("div");
actions.className = "b05-route__irregular-actions";
actions.append(primary, removeButton, resetButton);
const list = document.createElement("ul");
list.className = "b05-route__irregular-list";
const help = document.createElement("p");
help.className = "b05-route__note";
help.textContent =
"구조물군과 종류를 고르고 위치를 입력해 추가합니다. 구간형은 시점~종점을 받고, " +
"종단도에는 시점 위치에 표시됩니다. 배관은 위 배수유역 연동 항목에서 관리합니다.";
body.append(
field("구조물군", groupSelect),
field("종류", typeSelect),
positionRow,
sideRow,
optionRow,
actions,
list,
help,
);
let types: StructureType[] = [];
let structures: StructureInstance[] = [];
let editingId: string | null = null;
let optionInputs: Array<{ key: string; read: () => string | number }> = [];
function typeMap(): Map<string, StructureType> {
return new Map(types.map((type) => [type.type_id, type]));
}
function currentType(): StructureType | null {
return typeMap().get(typeSelect.value) ?? null;
}
function placementOf(typeId: string): StructurePlacement {
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)";
}
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다. */
function renderOptionFields(values: Record<string, string | number> = {}): void {
const type = currentType();
optionInputs = [];
optionRow.replaceChildren();
if (!type || !type.options.length) {
optionRow.hidden = true;
return;
}
optionRow.hidden = false;
type.options.forEach((option) => {
const preset = values[option.key] ?? option.default ?? "";
let input: HTMLInputElement | HTMLSelectElement;
if (option.input === "select") {
input = select(option.choices.map((choice) => [choice, choice] as [string, string]));
input.value = String(preset || option.choices[0] || "");
} else if (option.input === "number") {
input = numberInput("0.1", "0");
input.value = String(preset ?? "");
} else {
input = document.createElement("input");
(input as HTMLInputElement).type = "text";
input.value = String(preset ?? "");
}
const label = option.unit ? `${option.label} (${option.unit})` : option.label;
optionRow.append(field(label, input));
optionInputs.push({
key: option.key,
read: () => (option.input === "number" ? Number(input.value) || 0 : input.value),
});
});
}
/** 고른 구조물군의 타입만 종류 목록에 채운다. */
function syncTypeOptions(keepTypeId?: string): void {
const group = groupSelect.value;
const candidates = types.filter((type) => type.group === group && !type.managed_by);
typeSelect.replaceChildren(...candidates.map((type) => new Option(type.name, type.type_id)));
if (keepTypeId && candidates.some((type) => type.type_id === keepTypeId)) {
typeSelect.value = keepTypeId;
}
syncPlacementFields();
renderOptionFields();
}
function syncButtons(): void {
primary.textContent = editingId ? "수정" : "추가";
removeButton.disabled = editingId === null;
}
function labelOf(structure: StructureInstance): string {
const type = typeMap().get(structure.type_id);
const name = type?.name ?? structure.type_id;
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`;
return `${position} · ${name}`;
}
function renderList(): void {
list.replaceChildren();
if (!structures.length) {
const empty = document.createElement("li");
empty.className = "b05-route__irregular-empty";
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";
item.classList.toggle("is-selected", structure.structure_id === editingId);
const name = document.createElement("strong");
name.textContent = labelOf(structure);
const info = document.createElement("span");
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" });
});
}
function loadForm(target: StructureInstance | null): void {
editingId = target?.structure_id ?? null;
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 ?? "");
sideSelect.value = target.side;
offsetField.value = String(target.offset_m ?? 0);
memoField.value = target.memo ?? "";
renderOptionFields(target.options);
} else {
startField.value = "";
endField.value = "";
offsetField.value = "0";
memoField.value = "";
renderOptionFields();
}
syncPlacementFields();
syncButtons();
renderList();
callbacks.onSelect(target);
}
function readOptions(): Record<string, string | number> {
const values: Record<string, string | number> = {};
optionInputs.forEach((input) => {
values[input.key] = input.read();
});
return values;
}
function emit(): void {
renderList();
callbacks.onChange([...structures]);
}
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 end: number | null = null;
if (placement === "interval") {
end = Number.parseFloat(endField.value);
if (!Number.isFinite(end) || end <= start) {
endField.focus();
return;
}
}
const base = {
type_id: type.type_id,
placement,
chainage_m: placement === "interval" ? null : start,
start_m: placement === "interval" ? start : null,
end_m: end,
side: sideSelect.value as StructureSide,
offset_m: Number(offsetField.value) || 0,
options: readOptions(),
memo: memoField.value.trim(),
placement_source: "manual" as const,
status: "draft" as const,
revision: 0,
geometry: null,
};
if (editingId) {
const index = structures.findIndex((entry) => entry.structure_id === editingId);
if (index >= 0) structures[index] = { ...structures[index], ...base };
} else {
structures.push({ ...base, structure_id: null });
}
loadForm(null);
emit();
}
groupSelect.addEventListener("change", () => {
syncTypeOptions();
// 종류가 바뀌면 기존 항목 수정이 아니라 새 항목 추가로 넘어간다(옵션 스키마가 달라진다).
if (editingId) {
editingId = null;
syncButtons();
renderList();
callbacks.onSelect(null);
}
});
typeSelect.addEventListener("change", () => {
syncPlacementFields();
renderOptionFields();
if (editingId) {
editingId = null;
syncButtons();
renderList();
callbacks.onSelect(null);
}
});
primary.addEventListener("click", commit);
removeButton.addEventListener("click", () => {
if (!editingId) return;
const index = structures.findIndex((entry) => entry.structure_id === editingId);
if (index >= 0) structures.splice(index, 1);
loadForm(null);
emit();
});
resetButton.addEventListener("click", () => loadForm(null));
body.insertBefore(field("메모", memoField), actions);
syncButtons();
renderList();
return {
root,
setTypes(next) {
types = next;
const groups = [...new Set(next.filter((type) => !type.managed_by).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] ?? "");
syncTypeOptions();
},
setStructures(next) {
// 서버 정본 주입 — onChange를 울리지 않는다. 울리면 Page가 다시 저장을 걸어
// 저장→재조회→주입→저장의 무한 고리가 된다(변경 알림은 사용자 조작에서만).
structures = next.map((entry) => ({ ...entry }));
loadForm(null);
renderList();
},
getStructures: () => [...structures],
selectById(structureId) {
if (structureId === null) {
loadForm(null);
return;
}
loadForm(structures.find((entry) => entry.structure_id === structureId) ?? null);
},
addAt(chainageM, typeId) {
const type = typeMap().get(typeId);
if (!type || type.managed_by) return;
const placement = placementOf(typeId);
structures.push({
structure_id: null,
type_id: typeId,
placement,
chainage_m: placement === "interval" ? null : chainageM,
start_m: placement === "interval" ? chainageM : null,
end_m: placement === "interval" ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null,
side: "center",
offset_m: 0,
options: defaultOptions(type),
memo: "",
placement_source: "manual",
status: "draft",
revision: 0,
geometry: null,
});
emit();
},
moveById(structureId, toChainageM) {
const index = structures.findIndex((entry) => entry.structure_id === structureId);
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 };
} else {
structures[index] = { ...target, chainage_m: toChainageM };
}
emit();
return true;
},
removeById(structureId) {
const index = structures.findIndex((entry) => entry.structure_id === structureId);
if (index < 0) return false;
structures.splice(index, 1);
if (editingId === structureId) loadForm(null);
emit();
return true;
},
};
}
@@ -0,0 +1,84 @@
/* =============================================================================
* B05 구조물 서클마크·벌룬 오버레이 스타일.
*
* 종단 그래프(.b05-profile__chart) 위에 얹는 레이어다. 그래프 자체의 조작(측점선 끌기,
* 우클릭 메뉴)을 막지 않도록 레이어는 이벤트를 통과시키고, 마크·벌룬만 이벤트를 받는다.
* ========================================================================== */
.b05-structure__layer {
position: absolute;
inset: 0;
pointer-events: none;
overflow: visible;
z-index: 3;
}
/* 서클마크 — 배치형태와 무관하게 하나. 구간형은 기점에 찍힌다. */
.b05-structure__mark {
position: absolute;
transform: translateX(-50%);
width: 18px;
height: 18px;
padding: 0;
border-radius: 50%;
border: 2px solid currentColor;
background: var(--color-surface, #fff);
font-size: 10px;
font-weight: 700;
line-height: 1;
cursor: grab;
pointer-events: auto;
display: flex;
align-items: center;
justify-content: center;
}
.b05-structure__mark:hover {
filter: brightness(0.92);
}
.b05-structure__mark.is-selected {
border-width: 3px;
box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 25%, transparent);
}
.b05-structure__mark:active {
cursor: grabbing;
}
/* 구간 띠 — 고른 동안에만 시~종점을 펼쳐 보여 준다. */
.b05-structure__band {
position: absolute;
height: 4px;
border-radius: 2px;
opacity: 0.55;
pointer-events: none;
}
/* 값 벌룬 — 유토곡선 벌룬과 같은 결(테두리 도형 + 여러 줄 텍스트). */
.b05-structure__balloon {
position: absolute;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 1px;
padding: 4px 8px;
border: 1.5px solid currentColor;
border-radius: 6px;
background: var(--color-surface, #fff);
box-shadow: 0 2px 6px rgb(0 0 0 / 18%);
font-size: 11px;
line-height: 1.35;
white-space: nowrap;
pointer-events: none;
z-index: 4;
}
.b05-structure__balloon strong {
font-size: 11.5px;
}
.b05-structure__balloon-memo {
opacity: 0.7;
font-style: italic;
}