From 61108f540def4ceab4cc17a280ee50cca850f6a7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 16 Aug 2026 22:30:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=88=98=EB=8F=99=20=EC=B6=94=EA=B0=80=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=20=E2=80=94=20=ED=8C=A8=EB=84=90=C2=B7=EC=A2=85?= =?UTF-8?q?=EB=8B=A8=EB=8F=84=20=EC=84=9C=ED=81=B4=EB=A7=88=ED=81=AC=C2=B7?= =?UTF-8?q?=EB=B2=8C=EB=A3=AC=C2=B7=EC=A0=95=EB=B3=B8=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구조물군 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 --- B05_Profile/B05_Profile_Api_Structures.ts | 169 +++++++ B05_Profile/B05_Profile_UI_Page.ts | 83 ++++ B05_Profile/B05_Profile_UI_Panel.ts | 16 + B05_Profile/B05_Profile_UI_Profile_Panel.ts | 52 ++ .../B05_Profile_UI_Profile_Structures.ts | 24 +- .../B05_Profile_UI_Structures_Marks.ts | 210 ++++++++ .../B05_Profile_UI_Structures_Panel.ts | 466 ++++++++++++++++++ .../B05_Profile_UI_Style_Structures.css | 84 ++++ 8 files changed, 1100 insertions(+), 4 deletions(-) create mode 100644 B05_Profile/B05_Profile_Api_Structures.ts create mode 100644 B05_Profile/B05_Profile_UI_Structures_Marks.ts create mode 100644 B05_Profile/B05_Profile_UI_Structures_Panel.ts create mode 100644 B05_Profile/B05_Profile_UI_Style_Structures.css diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts new file mode 100644 index 00000000..3cbf1e06 --- /dev/null +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -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; + memo: string; + placement_source: "manual" | "suggested" | "automatic"; + status: "draft" | "confirmed"; + revision: number; + geometry: Record | 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(path: string, init: RequestInit = {}): Promise { + 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 | null = null; + +export function fetchStructureTypes(): Promise { + if (!typesCache) { + typesCache = requestJson("/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 { + return requestJson(`/projects/${projectId}/route/structures`, { + method: "GET", + }); +} + +export async function saveStructures( + projectId: string, + baseRevision: number, + structures: StructureInstance[], +): Promise { + return requestJson(`/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 { + const options: Record = {}; + 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; +} diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index e67265c6..3bc7c1e3 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -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; @@ -226,6 +234,11 @@ export async function renderB05Route(root: HTMLElement): Promise { 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 { 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 { ); } + /* ── 구조물 정본(structures.json) ──────────────────────────────────── + * 사이드 목록이 바뀌면 곧바로 서버 정본에 저장한다 — 화면에만 남겨 두면 새로고침에 + * 사라지고, 다른 창과도 어긋난다. 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 + * 받아 화면을 맞추고 사용자에게 알린다. */ + let structureRevision = 0; + let structureSaving: Promise = Promise.resolve(); + + function applyStructures(next: StructureInstance[]): void { + profilePanel.setStructures(next); + // 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다. + structureSaving = structureSaving.then(() => persistStructures(next)); + } + + async function persistStructures(next: StructureInstance[]): Promise { + 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 { + 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 { renderLatest(latestResponse); if (currentSectionDetail) renderStationLines(currentSectionDetail); } + // 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후). + await loadStructures(); advanceLoading(""); } catch (error) { showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error"); diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index a74a278e..1318e2db 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -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 { diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 5335d0b8..f2346b80 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -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; diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index 4bbd504c..0a949c1f 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -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 diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts new file mode 100644 index 00000000..a4d15203 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -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; + types: ReadonlyArray; + /** 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 = { + left: "좌측", + right: "우측", + center: "중심", + cross: "횡단", +}; + +/** 같은 자리에 겹친 마크를 위로 쌓아 올릴 높이를 정한다. */ +function stackOffsets( + structures: ReadonlyArray, + x: (chainageM: number) => number, +): Map { + const offsets = new Map(); + 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))); + }); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts new file mode 100644 index 00000000..de5724da --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -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 = { + 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 { + 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 = {}): 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 { + const values: Record = {}; + 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; + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css new file mode 100644 index 00000000..16e58af7 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -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; +}