/* ============================================================================= * B05_Profile_UI_Structures_Marks.ts * 종단 그래프 위 구조물 서클마크·벌룬 오버레이. * * 표기 규칙 (2026-08-16 사용자 확정): * - 배치형태와 무관하게 **서클마크 하나**로 표시한다. 구간형은 기점(시점)에 찍는다. * - 마크를 고르면 벌룬으로 종류·위치·제원을 띄우고, 구간형은 그때만 시~종점 구간을 * 띠로 펼쳐 보여 준다(평상시에는 띠를 그리지 않는다). * * 그래프는 매 그리기마다 새로 만들어지므로 이 오버레이도 그때마다 다시 붙인다. * ========================================================================== */ import { structureAnchorM, type StructureInstance, type StructureType, } from "./B05_Profile_Api_Structures"; import { formatStation } from "./B05_Profile_Util_Station"; /** 마크를 잡았다고 볼 여유(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; /** 측점간격(m) — 위치 표기를 측점번호+잔여거리로 한다(2026-08-17 사용자 확정). */ stationIntervalM: 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(" · "); } /** 위치 표기 — 측점번호+잔여거리(예: 3+18.0). 누가거리 표기는 쓰지 않는다(2026-08-17). */ function positionText(structure: StructureInstance, intervalM: number): string { return structure.placement === "interval" ? `${formatStation(structure.start_m ?? 0, intervalM)} ~ ${formatStation(structure.end_m ?? 0, intervalM)}` : formatStation(structure.chainage_m ?? 0, intervalM); } 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, options.stationIntervalM)}`; 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, options.stationIntervalM)); }); host.append(layer); } /** 고른 구조물의 값 벌룬. 유토곡선 벌룬과 같은 결(도형+텍스트)로 맞춘다. */ function buildBalloon( structure: StructureInstance, type: StructureType | undefined, anchorPx: number, bottomPx: number, stationIntervalM: 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, stationIntervalM)} · ${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))); }); }