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:
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user