/* ============================================================================= * B05_Profile_UI_Structures_Marks.ts * 종단 그래프 아래 구조물 알약(pill) 레인 + 벌룬. * * 표기 규칙 (2026-08-17 사용자 확정): * - 구조물은 종류와 무관하게 **알약 하나**로 표시한다. 구간형은 기준점에 찍는다. * - 자동 배치 배수관도 세로 점선이 아니라 같은 알약으로 나온다 — 자동이든 수동이든 * 같은 구조물이기 때문이다. * - 레인은 그래프 편집 버튼(구간 ⬆⬇) 아래에 붙고 높이는 2행분뿐이다. 알약이 겹치지 * 않으면 세로 가운데 한 줄로, 겹칠 때만 두 줄로 쌓는다. * - 알약을 고르면 벌룬으로 종류·위치·제원을 띄우고, 구간형은 그때만 시~종점 띠를 * 그래프 폭에 맞춰 보여 준다. * * 그래프는 매 그리기마다 새로 만들어지므로 이 레인도 그때마다 다시 붙인다. * ========================================================================== */ import { structureAnchorM, type StructureInstance, type StructureType, } from "./B05_Profile_Api_Structures"; import { numberDuplicateNames } from "./B05_Profile_UI_Structures_List"; import { formatStation } from "./B05_Profile_Util_Station"; /** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로 * 보지 않고 시각적으로도 움직이지 않는다. 예전 10px 누적 방식은 손떨림이 쌓여 * 고르려다 옮겨지는 일이 잦았다(2026-08-19 사용자 보고 — 민감도 완화). */ const GRAB_SLACK_PX = 14; /** 겹쳐 두 줄로 갈릴 때의 알약 높이(px) — 레인 높이를 정하는 값이다. */ /** 알약 높이(px) — 2026-09-06 사용자: 「너무 커서 높이를 많이 차지함」. 24 → 18 로 낮춰 * 레인이 54 → 42px 가 되고 그만큼 그래프가 넓게 쓴다. 글자는 10px 로 함께 줄인다(CSS). */ const PILL_HEIGHT_PX = 18; /** 혼자 있는(가운데 한 줄) 알약 높이(px) — 겹칠 때보다 조금만 크게(옛 31 → 22). */ const SOLO_PILL_HEIGHT_PX = 22; /** 두 줄 사이 간격(px). */ const ROW_GAP_PX = 2; /** 레인 위아래 여백(px) — 그래프를 밀어내되 여유는 최소로. */ const LANE_PADDING_PX = 2; /** 두 알약이 겹친다고 볼 가로 거리(px). 3자 라벨 폭 기준. */ const OVERLAP_PX = 52; /** 레인 전체 높이(px) — 2행 구조를 항상 확보한다(항목이 하나여도 높이는 같다). * 큰(혼자) 알약도 이 안에 들어간다. 아래 서브패널 손잡이와의 사이는 CSS 여백을 * 절반으로 줄여 붙여 놓는다(2026-08-17 사용자 지시 2). */ export const STRUCTURE_LANE_HEIGHT_PX = PILL_HEIGHT_PX * 2 + ROW_GAP_PX + LANE_PADDING_PX * 2; export interface StructureMarksOptions { structures: ReadonlyArray; types: ReadonlyArray; /** chainage → x(px). 그래프·테이블과 같은 매핑. */ x: (chainageM: number) => number; /** x(px) → chainage. 마크를 끌 때 역변환. */ chainageAt: (px: number) => number; maxChainageM: number; /** 레인 가로 폭(px) — 그래프와 같은 폭이어야 스크롤이 함께 움직인다. */ widthPx: number; /** 그래프 Y축 띠 폭(px) — 레인도 같은 폭으로 축을 이어 붙여, 스크롤된 알약이 * 축 값 위로 새 나가지 않게 가린다(2026-08-17 사용자 지시 2). */ axisWidthPx: 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); } /** 알약 세로 자리 배정 — 이웃과 겹치는 것만 두 줄로 나누고, 나머지는 가운데에 둔다 * (2026-08-17 사용자 지시 2). 값 0 = 윗줄, 1 = 아랫줄, null = 가운데. */ function rowAssignments( structures: ReadonlyArray, x: (chainageM: number) => number, ): Map { const rows = new Map(); const sorted = [...structures].sort( (left, right) => structureAnchorM(left) - structureAnchorM(right), ); const centers = sorted.map((structure) => x(structureAnchorM(structure))); let row = 0; sorted.forEach((structure, index) => { const id = structure.structure_id ?? ""; const near = (index > 0 && centers[index] - centers[index - 1] < OVERLAP_PX) || (index < sorted.length - 1 && centers[index + 1] - centers[index] < OVERLAP_PX); if (!near) { // 앞뒤 어디와도 안 겹친다 — 혼자이므로 레인 세로 가운데. rows.set(id, null); row = 0; return; } // 겹치는 무리 안에서만 위·아래를 번갈아 쓴다. rows.set(id, row); row = row === 0 ? 1 : 0; }); return rows; } /** * 그래프 아래에 구조물 알약 레인을 만들어 돌려준다(호출부가 원하는 위치에 붙인다). */ export function buildStructureLane(options: StructureMarksOptions): HTMLElement { const lane = document.createElement("div"); lane.className = "b05-structure__lane"; lane.style.width = `${options.widthPx}px`; lane.style.height = `${STRUCTURE_LANE_HEIGHT_PX}px`; const typeMap = new Map(options.types.map((type) => [type.type_id, type])); const rows = rowAssignments(options.structures, options.x); // 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 시점에 가까운 순 // "이름 1/2…". 알약 라벨(약호)·툴팁·벌룬 제목이 모두 이 번호를 따른다. const anchorSorted = [...options.structures].sort( (left, right) => structureAnchorM(left) - structureAnchorM(right), ); const numberedNames = numberDuplicateNames( anchorSorted.map((entry) => typeMap.get(entry.type_id)?.name ?? entry.type_id), ); const displayNames = new Map( anchorSorted.map((entry, index) => [entry.structure_id ?? "", numberedNames[index]]), ); options.structures.forEach((structure) => { const id = structure.structure_id; if (!id) return; const type = typeMap.get(structure.type_id); const rawName = type?.name ?? structure.type_id; const displayName = displayNames.get(id) ?? rawName; // "옹벽 1"에서 붙은 번호만 떼어 약호 뒤에 잇는다(알약은 약호 표기 유지). const numberSuffix = displayName.startsWith(rawName) ? displayName.slice(rawName.length) : ""; const anchorPx = options.x(structureAnchorM(structure)); const selected = options.selectedId === id; // 이웃과 겹치는 알약만 위·아래 두 줄로 가르고, 혼자면 레인 세로 가운데에 크게 둔다. const row = rows.get(id) ?? null; const height = row === null ? SOLO_PILL_HEIGHT_PX : PILL_HEIGHT_PX; const top = row === null ? (STRUCTURE_LANE_HEIGHT_PX - SOLO_PILL_HEIGHT_PX) / 2 : LANE_PADDING_PX + row * (PILL_HEIGHT_PX + ROW_GAP_PX); // 구간형은 시~종점을 **늘 띠로** 펼친다(2026-09-07 사용자 지시 — B군을 넣어도 어디에 // 놓였는지 안 보였다). 예전에는 고른 동안만 폈는데, 측구·맹암거처럼 길게 이어지는 // 시설은 「어디부터 어디까지인가」가 곧 그 시설의 내용이라 늘 보여야 한다. // 고른 것은 진하게, 나머지는 옅게 — 레인 높이(42px)는 그대로다. if (structure.placement === "interval") { const band = document.createElement("div"); band.className = `b05-structure__band${selected ? " is-selected" : ""}`; 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`; // 알약 세로 가운데에 맞춘다 — 띠 두께가 골랐을 때만 4px 라 반값이 다르다. band.style.top = `${top + height / 2 - (selected ? 2 : 1.5)}px`; band.style.background = type?.style?.color ?? "#888"; lane.append(band); } const mark = document.createElement("button"); mark.type = "button"; mark.className = "b05-structure__mark"; mark.classList.toggle("is-selected", selected); mark.classList.toggle("is-solo", row === null); mark.style.left = `${anchorPx}px`; mark.style.top = `${top}px`; mark.style.borderColor = type?.style?.color ?? "#888"; mark.style.color = type?.style?.color ?? "#888"; // 라벨은 3자 이하 짧은 이름(레지스트리 style.abbr, 2026-08-17 지시 4) + 중복 번호. mark.textContent = `${type?.style?.abbr ?? "?"}${numberSuffix}`; mark.title = `${displayName} ${positionText(structure, options.stationIntervalM)}`; mark.dataset.structureId = id; mark.addEventListener("click", (event) => { event.stopPropagation(); options.onSelect(selected ? null : id); }); attachMarkDrag(mark, structure, options); lane.append(mark); if (selected) lane.append( buildBalloon(structure, type, anchorPx, row, options.stationIntervalM, displayName), ); }); // Y축 띠 — 그래프 축을 레인까지 이어 붙인다. sticky라 가로 스크롤에도 왼쪽에 // 머물며, 지나가는 알약을 덮어 축 값 영역으로 새 나가지 않게 한다. const axis = document.createElement("div"); axis.className = "b05-structure__axis"; const axisInner = document.createElement("div"); axisInner.className = "b05-structure__axis-inner"; axisInner.style.width = `${options.axisWidthPx}px`; axisInner.style.height = `${STRUCTURE_LANE_HEIGHT_PX}px`; // 레인이 무엇을 담는 줄인지 축 자리에 적어 준다(2026-08-17 사용자 지시 2). axisInner.textContent = "구조물"; axis.append(axisInner); lane.prepend(axis); return lane; } /** 고른 구조물의 값 벌룬. 유토곡선 벌룬과 같은 결(도형+텍스트)로 맞춘다. * 알약 **위**로 띄운다 — 아래로 펴면 도면 테이블에 가려 안 보인다(2026-08-18 * 사용자 지시). 2행 구조에서 1행(윗줄)은 좌측 위, 2행(아랫줄)은 우측 위로 * 전개해 서로 겹치지 않게 하고, 솔로 알약은 위 중앙이다. */ function buildBalloon( structure: StructureInstance, type: StructureType | undefined, anchorPx: number, row: number | null, stationIntervalM: number, /** 중복 번호가 붙은 표시 이름("옹벽 1") — 좌측 목록과 같은 표기(2026-08-19). */ displayName?: string, ): HTMLElement { const balloon = document.createElement("div"); balloon.className = "b05-structure__balloon"; balloon.classList.add(row === 0 ? "is-left" : row === 1 ? "is-right" : "is-center"); balloon.style.left = `${anchorPx}px`; balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX}px`; balloon.style.borderColor = type?.style?.color ?? "#888"; const title = document.createElement("strong"); title.textContent = displayName ?? type?.name ?? structure.type_id; const position = document.createElement("span"); position.textContent = positionText(structure, stationIntervalM); 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 startX = 0; /** 시작점에서 가장 멀리 벗어난 거리(px) — 누적이 아니라 변위라 손떨림이 안 쌓인다. */ let maxOffsetPx = 0; mark.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; dragging = true; startX = event.clientX; maxOffsetPx = 0; mark.setPointerCapture(event.pointerId); event.stopPropagation(); }); mark.addEventListener("pointermove", (event) => { if (!dragging) return; maxOffsetPx = Math.max(maxOffsetPx, Math.abs(event.clientX - startX)); // 여유를 벗어나기 전에는 시각적으로도 움직이지 않는다 — 클릭 중 마크가 흔들리면 // 이동으로 오해하기 쉽다(2026-08-19 민감도 완화). if (maxOffsetPx <= GRAB_SLACK_PX) return; const lane = mark.parentElement; if (!lane) return; const localX = event.clientX - lane.getBoundingClientRect().left; mark.style.left = `${localX}px`; }); mark.addEventListener("pointerup", (event) => { if (!dragging) return; dragging = false; mark.releasePointerCapture(event.pointerId); // 여유 안에서 뗐으면 이동이 아니라 클릭이다 — 고르려다 옮겨지면 곤란하다. if (maxOffsetPx <= GRAB_SLACK_PX) return; const lane = mark.parentElement; if (!lane) return; const localX = event.clientX - lane.getBoundingClientRect().left; const chainage = Math.min(Math.max(options.chainageAt(localX), 0), options.maxChainageM); options.onMove(structure.structure_id ?? "", Number(chainage.toFixed(2))); }); }