/* ============================================================================= * B06_Section_UI_Cross_Axes.ts * 횡단 카드의 X·Y 축(격자선 + 눈금값). 확대·축소·팬을 하면 **눈금이 따라 다시 그려진다** * (2026-08-23 사용자 보고: 줌인 후 축 값과 도형이 어긋남). * * 도형은 `plotLayer`의 transform(scale·translate)으로 움직이고 축은 제자리에 남으므로 * (2026-08-08 규칙), 축이 맞으려면 **현재 화면에 보이는 범위**를 역산해 그 범위의 눈금을 * 다시 만들어야 한다. 눈금값은 종단면도와 같은 1·2·5 계열(`niceTickStep`)이다. * ========================================================================== */ import { CROSS_PAD, niceTickStep, svgElement, svgText } from "./B06_Section_UI_Section_Common"; import type { ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom"; /** 원배율·이동 없음 — 처음 그릴 때 쓴다. */ export const IDENTITY_VIEW: ZoomPanState = { scale: 1, tx: 0, ty: 0 }; export interface CrossAxesOptions { widthPx: number; heightPx: number; /** 1m당 픽셀 — X·Y 공통(횡단은 등축). */ pixelsPerMeter: number; /** x(offset) = CROSS_PAD.left + (maxOffset - offset) * pixelsPerMeter */ maxOffset: number; /** y(elevation) = CROSS_PAD.top + (displayMax - elevation) * pixelsPerMeter */ displayMax: number; /** 표고 과장 기준점과 배율 — 화면 표고 → 실제 표고 환산에 쓴다. */ elevationMid: number; exaggeration: number; } /** X 눈금 라벨("-12" 등)이 겹치지 않는 최소 간격(px). */ const X_LABEL_MIN_PX = 48; /** Y 눈금 라벨이 겹치지 않는 최소 간격(px). */ const Y_LABEL_MIN_PX = 14; const X_TARGET_TICKS = 7; /** 표고(Y) 눈금 최소 간격(m) — 1m 아래 단위는 표기하지 않는다. */ const MIN_ELEVATION_STEP_M = 1; const Y_TARGET_TICKS = 10; /** * 축 레이어를 그리는 함수를 만든다. 줌·팬이 바뀔 때마다 그 상태로 다시 부르면 된다. * 레이어 내용은 매번 통째로 갈아 끼운다(카드 하나당 눈금 20개 남짓이라 충분히 싸다). */ export function createCrossAxes( layer: SVGGElement, options: CrossAxesOptions, ): (view: ZoomPanState) => void { const { widthPx, heightPx, pixelsPerMeter, maxOffset, displayMax } = options; const plotLeft = CROSS_PAD.left; const plotRight = widthPx - CROSS_PAD.right; const plotTop = CROSS_PAD.top; const plotBottom = heightPx - CROSS_PAD.bottom; const x = (offset: number) => plotLeft + (maxOffset - offset) * pixelsPerMeter; const y = (displayElevation: number) => plotTop + (displayMax - displayElevation) * pixelsPerMeter; return (view) => { const { scale, tx, ty } = view; layer.replaceChildren(); // 화면 px → 데이터 값 역산(도형 레이어의 transform을 거꾸로 푼다). const offsetAt = (px: number) => maxOffset - ((px - tx) / scale - plotLeft) / pixelsPerMeter; const displayAt = (py: number) => displayMax - ((py - ty) / scale - plotTop) / pixelsPerMeter; const toRaw = (display: number) => options.elevationMid + (display - options.elevationMid) / options.exaggeration; // ── X축: 왼쪽 화면 끝이 큰 offset(좌측), 오른쪽 끝이 작은 offset(우측). const offsetHigh = offsetAt(plotLeft); const offsetLow = offsetAt(plotRight); const offsetSpan = offsetHigh - offsetLow; const xStep = niceTickStep( offsetSpan, X_TARGET_TICKS, Math.floor((plotRight - plotLeft) / X_LABEL_MIN_PX), ); const xDecimals = Math.max(0, Math.ceil(-Math.log10(xStep) - 1e-9)); for ( let offset = Math.ceil(offsetLow / xStep) * xStep; offset <= offsetHigh + 1e-9; offset += xStep ) { const screenX = tx + scale * x(offset); const label = Math.abs(offset) < 1e-9 ? "0" : offset.toFixed(xDecimals); layer.append( svgElement("line", { x1: screenX, y1: plotTop, x2: screenX, y2: plotBottom, class: "b06-chart__grid", }), svgText(label, { x: screenX, y: plotBottom + 16, "text-anchor": "middle", class: "b06-chart__tick", }), ); } // ── Y축: 화면 위쪽이 높은 표고. 라벨은 과장을 되돌린 **실제 표고**다. const rawTop = toRaw(displayAt(plotTop)); const rawBottom = toRaw(displayAt(plotBottom)); const rawSpan = rawTop - rawBottom; // 표고 눈금은 **1m 아래로 내려가지 않는다**(2026-08-23 사용자 지시) — 0.5m 표기는 // 도면에서 읽을 일이 없다. 확대해도 1m 간격까지만 촘촘해진다. const yStep = Math.max( niceTickStep(rawSpan, Y_TARGET_TICKS, Math.floor((plotBottom - plotTop) / Y_LABEL_MIN_PX)), MIN_ELEVATION_STEP_M, ); const yDecimals = Math.max(0, Math.ceil(-Math.log10(yStep) - 1e-9)); for (let raw = Math.ceil(rawBottom / yStep) * yStep; raw <= rawTop + 1e-9; raw += yStep) { const display = options.elevationMid + (raw - options.elevationMid) * options.exaggeration; const screenY = ty + scale * y(display); layer.append( svgElement("line", { x1: plotLeft, y1: screenY, x2: plotRight, y2: screenY, class: "b06-chart__grid", }), svgText(raw.toFixed(yDecimals), { x: plotLeft - 7, y: screenY + 3, "text-anchor": "end", class: "b06-chart__tick", }), ); } }; }