/* ============================================================================= * B06_Section_UI_Cross_View.ts * 개별 횡단면 카드 SVG 렌더러와 X:Y 1:1 스케일 지표 계산. * * 700줄 제한 대응으로 기존 단일 렌더러 파일에서 횡단 카드 렌더링만 분리했다. 공통 상수·유틸은 * `_UI_Section_Common`, 설계 지정 컨트롤/설계선 오버레이는 `_UI_Cross_Design`을 참조한다. * 횡단은 종/횡 공통 Y스케일을 쓰지 않고 카드마다 자체 1:1 ppm으로 스케일한다. * ========================================================================== */ import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch"; import { appendCrossAreaBands, type AreaHighlightSetter, buildAreaReadout, type CrossAreaKey, } from "./B06_Section_UI_Cross_Areas"; import { appendCrossDesignOverlay, appendPavementOverlay, appendRockBoundaryOverlay, buildDesignControls, buildRockBoundaryControl, sectionModeLabel, type RockBoundaryControl, } from "./B06_Section_UI_Cross_Design"; import { appendCulvertOverlay, computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert"; import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel"; import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert"; import { CROSS_HEIGHT, CROSS_PAD, CROSS_WIDTH, type DesignChangeHandler, emptyView, L, stationLabel, svgElement, svgText, validElevation, } from "./B06_Section_UI_Section_Common"; /** * 횡단 카드 요소. 선택 표시와 면적 강조를 **카드를 다시 만들지 않고** 갈아 끼우는 핸들을 단다 * — 카드를 새로 만들면 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화되기 때문이다. */ export interface CrossCardElement extends HTMLElement { applySelection?: (selected: boolean, areaKey: CrossAreaKey | null) => void; } // 절·성토 값 오버레이(그래프 상단 고정)가 계획고 선을 가리는 드문 경우를 대비해, Y 플롯 // 최대값에 더하는 상단 여유(px). 오버레이·라벨은 손대지 않고 데이터를 그만큼 아래로 내린다. // 오버레이가 칩 한 줄에서 표(머리글 + 절토 + 성토 3행)로 바뀌어 그만큼 키웠다. const AREA_OVERLAY_HEADROOM_PX = 58; interface CrossPlotMetrics { sourceSamples: SectionSample[]; minOffset: number; maxOffset: number; elevationMid: number; displaySpan: number; displayMax: number; pixelsPerMeter: number; heightPx: number; } /** * 횡단 카드의 X:Y 1:1 스케일 지표를 계산한다. * X pixels-per-meter는 반폭(offset 범위)이 카드 폭을 채우도록 고정하고, 같은 ppm을 Y에도 써서 * 형상 왜곡을 없앤다(과장=1 → 정확히 1:1). 카드 높이는 데이터가 요구하는 자연 높이에 최소 * `CROSS_HEIGHT`(250px) 바닥을 적용하며, `forcedHeightPx`가 오면 그 높이로 강제한다(같은 행 높이 통일). * 어느 경우든 displaySpan을 실제 plotHeight에 맞춰 되계산하므로 ppm(=1:1)은 그대로 보존되고, * 남는 세로 공간은 elevationMid를 중심으로 대칭 여백이 된다. */ function crossPlotMetrics( section: CrossSection, verticalExaggeration: number, widthPx: number, crossHalfWidth?: number, designElevation?: number, forcedHeightPx?: number, ): CrossPlotMetrics | null { const sourceSamples = section.samples.filter( (sample) => crossHalfWidth === undefined || Math.abs(sample.offset_m ?? 0) <= crossHalfWidth + 1e-6, ); const valid = sourceSamples.filter(validElevation); if (!valid.length) return null; const offsets = sourceSamples.map((sample) => sample.offset_m ?? 0); const minOffset = Math.min(...offsets, -1); const maxOffset = Math.max(...offsets, 1); const hasDesign = designElevation !== undefined && Number.isFinite(designElevation); const elevations = valid.map((sample) => sample.elevation_m); if (hasDesign) elevations.push(designElevation as number); const rawMin = Math.min(...elevations); const rawMax = Math.max(...elevations); const elevationMid = (rawMin + rawMax) / 2; const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5; const exaggeration = Math.max(verticalExaggeration, 0.1); const plotWidth = widthPx - CROSS_PAD.left - CROSS_PAD.right; const xSpan = Math.max(maxOffset - minOffset, 1e-6); const pixelsPerMeter = plotWidth / xSpan; const rawSpan = Math.max((rawMax - rawMin + padding * 2) * exaggeration, 1e-6); // 상단 오버레이 여유(headroom)를 자연 높이에 더해 카드가 그만큼 커지게 한다. const naturalHeight = rawSpan * pixelsPerMeter + CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX; // 강제 높이가 있으면 그것을, 없으면 자연 높이에 250px 바닥 적용. const heightPx = forcedHeightPx ?? Math.max(naturalHeight, CROSS_HEIGHT); const plotHeight = Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1e-6); // 실제 plotHeight에 맞춰 표시 표고폭을 되계산 → ppm(1:1) 보존, 여유분은 상하 대칭 여백. const displaySpan = plotHeight / pixelsPerMeter; // 늘린 세로 여유의 절반을 위쪽에 몰아 데이터·계획선을 오버레이 아래로 내린다(라벨 위치 불변). const headroomM = AREA_OVERLAY_HEADROOM_PX / pixelsPerMeter; const displayMax = elevationMid + displaySpan / 2 + headroomM / 2; return { sourceSamples, minOffset, maxOffset, elevationMid, displaySpan, displayMax, pixelsPerMeter, heightPx, }; } /** 같은 행 높이 통일을 위해 카드를 만들지 않고 자연(바닥 적용) 높이만 계산한다. */ export function crossCardNaturalHeight( section: CrossSection, verticalExaggeration: number, widthPx: number, crossHalfWidth?: number, designElevation?: number, ): number { return ( crossPlotMetrics(section, verticalExaggeration, widthPx, crossHalfWidth, designElevation) ?.heightPx ?? CROSS_HEIGHT ); } /** * 카드 SVG에 확대·축소 + 드래그 팬 + 더블클릭 원복을 붙인다(E-5). * * 확대·축소 대상은 **플롯 안 도형(지반선·설계선·면적 밴드·중심 십자선)뿐**이다. 축·눈금·축 * 이름은 제자리에 남고, 데이터 레이어(`plotLayer`)의 transform만 바뀐다(2026-08-08 사용자 * 지시). 예전에는 viewBox를 조작해 축과 글자까지 같이 커졌다. * 선 굵기는 `vector-effect: non-scaling-stroke`(CSS)라 배율과 무관하게 유지된다. * 최대 8배까지, 축소는 원배율까지만 허용한다. */ /** * 측점 **개별 표시 반폭** 세션 제어기(2026-08-06 사용자 지시). * Page가 세션 보관·저장값 복원·카드 갱신·확정 저장(cross_patches)을 연결해 구현한다. */ export interface StationWidthControl { /** 개별값(세션→저장값) 우선. 없으면 undefined — 카드가 전역 반폭으로 그린다. */ widthFor: (section: CrossSection) => number | undefined; adjust: (chainageM: number, deltaM: number) => void; reset: (chainageM: number) => void; } /** * 기슭막이 X 자리 제어(2026-08-21 사용자 ①) — 벽을 눌러 고르고 ◀/▶로 0.1m씩 민다. * 고른 벽은 여기 담아 두어 카드가 다시 그려져도 되살아난다. `select`는 값만 담고 * 다시 그리지 않는다 — 강조는 클래스만 갈아 끼워 줌·팬을 지킨다. */ export interface RevetOffsetControl { shiftFor: (section: CrossSection, role: RevetKey) => number; selectedFor: (section: CrossSection) => RevetKey | null; select: (chainageM: number, key: RevetKey | null) => void; adjust: (chainageM: number, role: RevetKey, deltaM: number) => void; reset: (chainageM: number, role: RevetKey) => void; } export function createCrossSectionCard( section: CrossSection, selected: boolean, verticalExaggeration: number, onSelect: (stationId: string) => void, stationInterval: number, crossHalfWidth?: number, widthPx = CROSS_WIDTH, forcedHeightPx?: number, designElevation?: number, onDesignChange?: DesignChangeHandler, rockBoundary?: RockBoundaryControl, /** 이 카드가 선택 상태일 때 되살릴 면적 강조(없으면 강조 없음). */ initialAreaKey?: CrossAreaKey | null, /** 면적 값을 눌렀을 때. 선택되지 않은 카드에서도 눌릴 수 있어 측점 id를 함께 넘긴다. */ onAreaSelect?: (stationId: string, key: CrossAreaKey | null) => void, /** 개별 표시 반폭 제어 — 있으면 카드 하단에 ◀/▶/↺ 버튼 그룹을 우측 맞춤으로 단다. */ stationWidth?: StationWidthControl, /** 기슭막이 X 자리 제어 — 있으면 벽이 선택 가능해지고 선택 시 ◀/▶/↺가 뜬다. */ revetOffset?: RevetOffsetControl, ): CrossCardElement { // 이 카드의 실효 표시 반폭 — 개별값이 전역 반폭보다 우선한다(2026-08-06). const effectiveHalfWidth = stationWidth?.widthFor(section) ?? crossHalfWidth; const card: CrossCardElement = document.createElement("article"); card.id = `cross-${section.station_id}`; card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`; card.tabIndex = 0; card.addEventListener("click", () => onSelect(section.station_id)); card.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") onSelect(section.station_id); }); // 선택 표시·면적 강조는 **DOM만 갈아 끼운다**. 카드를 새로 만들면 휠 줌·팬(viewBox)이 // 초기화돼 사용자가 맞춰 둔 배율이 날아간다(2026-08-02 사용자 지적). let isSelected = selected; let activeArea: CrossAreaKey | null = selected ? (initialAreaKey ?? null) : null; let setBandActive: AreaHighlightSetter = () => undefined; let setChipActive: AreaHighlightSetter = () => undefined; let activeRevet: RevetKey | null = revetOffset?.selectedFor(section) ?? null; let setRevetActive: RevetHighlightSetter = () => undefined; let showRevetControl: (visible: boolean) => void = () => undefined; /** 지금 그린 관 길이(m) — 조정창이 "관 길이 8m"으로 적는다. 배수관 측점이 아니면 null. */ let culvertPipeLengthM: number | null = null; /** * 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를 * 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도 * 같이 선택**된다 — 이미 선택된 카드 안에서 다른 구조물을 고를 때는 선택이 옮겨만 * 가고 카드 선택은 건드리지 않는다. */ const toggleRevet = (key: RevetKey): void => { const wasSelected = isSelected; activeRevet = activeRevet === key ? null : key; setRevetActive(activeRevet); showRevetControl(activeRevet !== null); revetOffset?.select(section.chainage_m, activeRevet); if (activeRevet !== null && activeArea !== null) { activeArea = null; setBandActive(null); setChipActive(null); } if (activeRevet !== null) { // 미선택 카드는 **카드 선택만** 부른다. 여기서 면적 비우기(onAreaSelect)를 먼저 // 부르면 그쪽이 카드를 선택해 버려, 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로 // 먹혀 아무 일도 일어나지 않는다(2026-08-21 화면 확인). 카드 선택 경로가 부모의 // 면적 강조를 이미 비운다. if (wasSelected) onAreaSelect?.(section.station_id, null); else onSelect(section.station_id); } }; card.applySelection = (nextSelected, areaKey) => { isSelected = nextSelected; card.classList.toggle("b06-cross-card--selected", nextSelected); activeArea = nextSelected ? (areaKey ?? null) : null; setBandActive(activeArea); setChipActive(activeArea); // 면적이 켜졌거나 카드가 선택에서 빠지면 구조물 선택은 풀린다(같은 레벨). if ((activeArea !== null || !nextSelected) && activeRevet !== null) { activeRevet = null; setRevetActive(null); showRevetControl(false); revetOffset?.select(section.chainage_m, null); } }; const toggleArea = (key: CrossAreaKey): void => { if (!isSelected) { // 아직 선택되지 않은 카드 — 부모가 카드를 고르면서 이 면적까지 한 번에 켠다. onAreaSelect?.(section.station_id, key); return; } activeArea = activeArea === key ? null : key; if (activeArea !== null && activeRevet !== null) { activeRevet = null; setRevetActive(null); showRevetControl(false); revetOffset?.select(section.chainage_m, null); } setBandActive(activeArea); setChipActive(activeArea); onAreaSelect?.(section.station_id, activeArea); }; // 제목행 1행 구조(E-2/E-3): 측점 위치표기 → 단면유형 pill → 지반유형 → 구조물 → 측점정보. const header = document.createElement("header"); const title = document.createElement("div"); title.className = "b06-cross-card__title"; const label = document.createElement("strong"); label.textContent = stationLabel(section.chainage_m, stationInterval); const chainage = document.createElement("span"); chainage.textContent = `${section.chainage_m.toFixed(1)}m`; title.append(label, chainage); // 단면유형 pill(자동 판정, 읽기 전용) — 구조물 pill과 동일 표기, 좌측 배치(E-3). const modePill = document.createElement("span"); modePill.className = "b06-cross-card__mode"; modePill.textContent = sectionModeLabel(section.design?.section_mode); modePill.title = L("B06_Design_Mode_Legend"); const meta = document.createElement("div"); meta.className = "b06-cross-card__meta"; if (section.structure) { const structure = document.createElement("span"); structure.className = "b06-cross-card__structure"; structure.textContent = section.structure; structure.title = `구조물: ${section.structure}`; meta.append(structure); } // 측점 종류는 시·종점(BP/EP)만 적는다 — "일반측점"은 대다수라 정보가 없다(2026-08-02 사용자 지시). if (section.kind === "bp" || section.kind === "ep") { const kind = document.createElement("span"); kind.textContent = L( section.kind === "ep" ? "B06_Profile_View_Kind_EP" : "B06_Profile_View_Kind_BP", ); meta.append(kind); } // 단면유형 pill은 지반유형 우측·우측 맞춤으로 배치(1번). 좌측 구분선은 지반유형 세그먼트에 준다(3번). modePill.classList.add("b06-cross-card__mode--right"); header.append(title); if (onDesignChange) { // 제목행: 측점 라벨 → (구분선) 지반유형 → 단면유형 pill(우측) → 구조물·kind (D-6/E-2/1·3번). const controls = buildDesignControls(section, onDesignChange); controls.groundSegment.classList.add("b06-cross-card__ground"); header.append(controls.groundSegment, modePill, meta); card.append(header); card.append(controls.bar); } else { header.append(modePill, meta); card.append(header); } const metrics = crossPlotMetrics( section, verticalExaggeration, widthPx, effectiveHalfWidth, designElevation, forcedHeightPx, ); if (!metrics) { card.append(emptyView(L("B06_Profile_View_NoCross"))); } else { const { sourceSamples, minOffset, maxOffset, elevationMid, displaySpan, displayMax, pixelsPerMeter, heightPx, } = metrics; const hasDesign = designElevation !== undefined && Number.isFinite(designElevation); const valid = sourceSamples.filter(validElevation); const exaggeration = Math.max(verticalExaggeration, 0.1); // 좌표 규약(+offset=좌, -offset=우)을 표준 횡단면도 관례에 맞춘다: 진행방향을 바라보는 // 시점이라 좌측(+offset)이 화면 왼쪽에 와야 B05 3D 방향 표시와 측구 방향이 일치한다(작업 C-3). const x = (offset: number) => CROSS_PAD.left + (maxOffset - offset) * pixelsPerMeter; const y = (elevation: number) => CROSS_PAD.top + (displayMax - elevation) * pixelsPerMeter; const svg = svgElement("svg", { class: "b06-section__chart", width: widthPx, height: heightPx, viewBox: `0 0 ${widthPx} ${heightPx}`, role: "img", "aria-label": `${section.label} ${L("B06_Profile_View_Cross")}`, }); svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" })); const xTicks = Array.from( { length: 7 }, (_, index) => minOffset + ((maxOffset - minOffset) * index) / 6, ); for (const tick of xTicks) { svg.append( svgElement("line", { x1: x(tick), y1: CROSS_PAD.top, x2: x(tick), y2: heightPx - CROSS_PAD.bottom, class: "b06-chart__grid", }), svgText(Math.abs(tick) < 1e-6 ? "0" : tick.toFixed(0), { x: x(tick), y: heightPx - CROSS_PAD.bottom + 16, "text-anchor": "middle", class: "b06-chart__tick", }), ); } const rawDisplaySpan = displaySpan / exaggeration; for (let index = 0; index < 5; index += 1) { const tick = elevationMid - rawDisplaySpan / 2 + (rawDisplaySpan * index) / 4; const displayTick = elevationMid + (tick - elevationMid) * exaggeration; svg.append( svgElement("line", { x1: CROSS_PAD.left, y1: y(displayTick), x2: widthPx - CROSS_PAD.right, y2: y(displayTick), class: "b06-chart__grid", }), svgText(tick.toFixed(1), { x: CROSS_PAD.left - 7, y: y(displayTick) + 3, "text-anchor": "end", class: "b06-chart__tick", }), ); } // 확대·축소·팬이 닿는 범위 = 이 레이어 안(2026-08-08 사용자 지시. 축·눈금·축 이름은 제자리). // clip 그룹은 고정하고 그 **안쪽** plotLayer만 transform으로 움직인다 — clip을 transform이 // 붙은 요소에 직접 걸면 자르는 창까지 같이 확대돼 플롯 밖으로 도형이 새어 나간다. // 표시 반폭 밖의 설계선·면적 밴드·포장·암 경계를 잘라 내는 몫도 이 clip이 겸한다 // (2026-08-06 사용자 지적 — 지면 샘플만 잘라서는 나머지가 플롯을 뚫고 나갔다). const clipId = `b06-cross-clip-${section.station_id}`; const clip = svgElement("clipPath", { id: clipId }); clip.append( svgElement("rect", { x: CROSS_PAD.left, y: CROSS_PAD.top, width: Math.max(widthPx - CROSS_PAD.left - CROSS_PAD.right, 1), height: Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1), }), ); const clipGroup = svgElement("g", { "clip-path": `url(#${clipId})` }); const plotLayer = svgElement("g", { class: "b06-chart__plot-layer" }); clipGroup.append(plotLayer); svg.append(clip, clipGroup); const segments: string[] = []; let current: string[] = []; for (const sample of sourceSamples) { if (!validElevation(sample)) { if (current.length > 1) segments.push(current.join(" ")); current = []; continue; } const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration; current.push(`${x(sample.offset_m ?? 0)},${y(elevated)}`); } if (current.length > 1) segments.push(current.join(" ")); segments.forEach((points) => plotLayer.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })), ); if (section.design) { const toDisplayY = (elevation: number): number => y(elevationMid + (elevation - elevationMid) * exaggeration); // 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다. // 선택 여부와 무관하게 항상 깐다 — 평소에는 투명이고, 선택이 바뀔 때 카드를 새로 만들지 // 않고 클래스만 켜면 되므로 줌·팬이 살아남는다. setBandActive = appendCrossAreaBands( plotLayer, section.design, sourceSamples, x, toDisplayY, toggleArea, ); // 배수관 세트 기하를 먼저 계산한다 — 설계선이 기슭막이 밖 성토 경사를 끊는 데 쓴다. const culvertLayout: ReturnType = computeCulvertLayout( section, sourceSamples, revetOffset ? { inlet: revetOffset.shiftFor(section, "inlet"), outlet: revetOffset.shiftFor(section, "outlet"), } : undefined, ); // 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다. appendPavementOverlay(plotLayer, section.design, x, toDisplayY); appendCrossDesignOverlay( plotLayer, section.design, x, toDisplayY, sourceSamples, culvertLayout?.designTrim ?? undefined, ); // 암 경계선은 지면선(지반선) 복사 + 오프셋 — 계획선 기준이 아님에 유의. if (rockBoundary && section.design.geometry_preset === "rock") { appendRockBoundaryOverlay( plotLayer, sourceSamples, rockBoundary.offsetFor(section), x, toDisplayY, ); } // 배수관 측점 세트(배관·기슭막이·보호공) — 기존 도형 위에 추가만 한다(2026-08-19). culvertPipeLengthM = culvertLayout?.pipe.lengthM ?? null; if (culvertLayout) { setRevetActive = appendCulvertOverlay( plotLayer, culvertLayout, x, toDisplayY, revetOffset ? toggleRevet : undefined, ); if (activeRevet) setRevetActive(activeRevet); } } const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => { if (!nearest || Math.abs(sample.offset_m ?? 0) < Math.abs(nearest.offset_m ?? 0)) return sample; return nearest; }, null); const centerX = x(0); // 십자선은 계획 노선(계획고) 위치를 가리킨다. 계획선이 없는 구 데이터는 지반고로 폴백한다. const centerElevation = hasDesign ? (designElevation as number) : (centerSample?.elevation_m ?? null); const centerY = centerElevation !== null ? y(elevationMid + (centerElevation - elevationMid) * exaggeration) : heightPx / 2; const centerMarkerClass = `b06-chart__center-marker${hasDesign ? " b06-chart__center-marker--design" : ""}`; // 중심 십자선은 계획 노선 자리를 가리키는 **데이터**라 도형 레이어에 넣어 함께 움직인다. plotLayer.append( svgElement("line", { x1: centerX, y1: centerY - 18, x2: centerX, y2: centerY + 18, class: centerMarkerClass, }), svgElement("line", { x1: centerX - 18, y1: centerY, x2: centerX + 18, y2: centerY, class: centerMarkerClass, }), ); svg.append( svgElement("line", { x1: CROSS_PAD.left, y1: heightPx - CROSS_PAD.bottom, x2: widthPx - CROSS_PAD.right, y2: heightPx - CROSS_PAD.bottom, class: "b06-chart__axis", }), svgElement("line", { x1: CROSS_PAD.left, y1: CROSS_PAD.top, x2: CROSS_PAD.left, y2: heightPx - CROSS_PAD.bottom, class: "b06-chart__axis", }), svgText(L("B06_Profile_View_CrossXAxis"), { x: widthPx / 2, y: heightPx - 8, "text-anchor": "middle", class: "b06-chart__axis-label", }), svgText(L("B06_Profile_View_ElevationAxis"), { x: 13, y: heightPx / 2, "text-anchor": "middle", transform: `rotate(-90 13 ${heightPx / 2})`, class: "b06-chart__axis-label", }), ); // 확대·축소 버튼, 드래그 팬, 더블클릭 원복(E-5). 도형 레이어 transform + non-scaling-stroke. const chartWrap = document.createElement("div"); chartWrap.className = "b06-cross-card__chart-wrap"; const zoomPan = attachZoomPan(svg, plotLayer, widthPx, heightPx); // 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다. const readout = buildAreaReadout(section.design, toggleArea); setChipActive = readout.setActive; // 구조물 위치 조정은 **도면 안 오버레이 창**으로 한다(2026-08-21 사용자 확정). // 벽이 서는 쪽(outward): 상단측(유입)이 좌측이면 유입 벽은 좌(+), 유출 벽은 우(−). // 화면 좌(◀)로 민다 = offset이 커진다 — 벽 기준 이동량으로 환산해 넘긴다. const outwardOf = (role: RevetKey): number => ((section.uphill_side ?? "left") === "left") === (role === "inlet") ? 1 : -1; const panel = buildStructurePanel({ shiftFor: (key) => revetOffset?.shiftFor(section, key) ?? 0, nudge: (key, screenDeltaM) => revetOffset?.adjust(section.chainage_m, key, screenDeltaM * outwardOf(key)), reset: (key) => revetOffset?.reset(section.chainage_m, key), pipeLengthM: () => culvertPipeLengthM, close: () => { if (activeRevet) toggleRevet(activeRevet); }, }); showRevetControl = (visible) => panel.show(visible ? activeRevet : null); showRevetControl(activeRevet !== null); chartWrap.append(svg, readout.root, buildZoomControls(zoomPan), panel.root); // 다시 그려지기 전에 켜져 있던 강조를 되살린다(부모가 들고 있던 값). if (activeArea) { setBandActive(activeArea); setChipActive(activeArea); } card.append(chartWrap); } const footer = document.createElement("footer"); const center = document.createElement("span"); center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`; footer.append(center); // 암 경계선 제어는 그래프 안이 아니라 **하단 정보 행 가운데**에 둔다 // (2026-08-02 사용자 지시). 그래프 안에 있으면 도면 위에 겹쳐 단면을 가렸다. 암 지반만. // 방위각 표기는 볼 일이 없어 삭제했다(2026-08-06 사용자 지시). if (rockBoundary && section.design?.geometry_preset === "rock") { const rockControl = buildRockBoundaryControl(section, rockBoundary); rockControl.classList.add("b06-cross-card__rockb"); footer.append(rockControl); } // 개별 표시 반폭 ◀/▶/↺ — 암 경계 그룹 우측에 우측 맞춤(2026-08-06 사용자 지시). // 숫자 표시는 두지 않고, 초기화는 전역 반폭으로 되돌린다. if (stationWidth) { const widthControl = document.createElement("div"); widthControl.className = "b06-cross-card__widthctl"; const makeButton = (label: string, title: string, onClick: () => void): HTMLButtonElement => { const button = document.createElement("button"); button.type = "button"; button.className = "b06-design__rockb-btn"; button.textContent = label; button.title = title; button.addEventListener("click", (event) => { // 카드 선택 클릭으로 번지면 재렌더로 줌·팬이 초기화된다. event.stopPropagation(); onClick(); }); return button; }; widthControl.append( makeButton("◀", L("B06_Cross_Width_Dec"), () => stationWidth.adjust(section.chainage_m, -1)), makeButton("▶", L("B06_Cross_Width_Inc"), () => stationWidth.adjust(section.chainage_m, 1)), makeButton("↺", L("B06_Cross_Width_Reset"), () => stationWidth.reset(section.chainage_m)), ); footer.append(widthControl); } card.append(footer); return card; }