diff --git a/B06_Section/B06_Section_UI_Cross_Axes.ts b/B06_Section/B06_Section_UI_Cross_Axes.ts new file mode 100644 index 00000000..cccbf371 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Axes.ts @@ -0,0 +1,128 @@ +/* ============================================================================= + * 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; +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; + const yStep = niceTickStep( + rawSpan, + Y_TARGET_TICKS, + Math.floor((plotBottom - plotTop) / Y_LABEL_MIN_PX), + ); + 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", + }), + ); + } + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index fc749b46..169d95a7 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -35,6 +35,7 @@ import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel"; import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; import type { CrossWidthActions, ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom"; import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; +import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes"; import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert"; import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; @@ -44,7 +45,6 @@ import { type DesignChangeHandler, emptyView, L, - niceTickStep, stationLabel, svgElement, svgText, @@ -274,16 +274,8 @@ export function createCrossSectionCard( if (!metrics) { card.append(emptyView(L("B06_Profile_View_NoCross"))); } else { - const { - sourceSamples, - minOffset, - maxOffset, - elevationMid, - displaySpan, - displayMax, - pixelsPerMeter, - heightPx, - } = metrics; + const { sourceSamples, maxOffset, elevationMid, displayMax, pixelsPerMeter, heightPx } = + metrics; const hasDesign = designElevation !== undefined && Number.isFinite(designElevation); const valid = sourceSamples.filter(validElevation); const exaggeration = Math.max(verticalExaggeration, 0.1); @@ -301,61 +293,21 @@ export function createCrossSectionCard( }); 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", - }), - ); - } - // Y축 눈금: 표고 범위를 10칸 안팎으로 나누되 눈금값이 1·2·5 계열로 딱 떨어지게 한다 - // (종단면도와 같은 규칙). 5등분 고정은 눈금값이 소수로 흘러 표고를 못 읽었다 - // (2026-08-23 사용자 지시). 라벨 겹침 방지로 눈금 간격은 14px 이상 띄운다. - // 눈금 범위는 **실제로 보이는 플롯 상·하단**(y() 기준)에서 뽑는다 — elevationMid를 - // 중심으로 잡으면 displayMax의 면적표 여유(headroom)만큼 아래로 밀려 X축 라벨 줄 - // 아래에 격자선이 삐져나왔다(2026-08-23 사용자 보고). - const rawDisplaySpan = displaySpan / exaggeration; - const rawTop = elevationMid + (displayMax - elevationMid) / exaggeration; - const rawBottom = rawTop - rawDisplaySpan; - const plotHeightPx = heightPx - CROSS_PAD.top - CROSS_PAD.bottom; - const tickStep = niceTickStep(rawDisplaySpan, 10, Math.floor(plotHeightPx / 14)); - const tickDecimals = Math.max(0, Math.ceil(-Math.log10(tickStep) - 1e-9)); - for ( - let tick = Math.ceil(rawBottom / tickStep) * tickStep; - tick <= rawTop + 1e-9; - tick += tickStep - ) { - 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(tickDecimals), { - x: CROSS_PAD.left - 7, - y: y(displayTick) + 3, - "text-anchor": "end", - class: "b06-chart__tick", - }), - ); - } + // 축·격자는 별도 레이어에 그리고, 줌·팬이 바뀔 때마다 **보이는 범위로 다시** 그린다 + // (2026-08-23 사용자 보고: 확대하면 축 값과 도형이 어긋남). 눈금 개수·간격도 그때 + // 보이는 범위에 맞춰 다시 고른다. + const axisLayer = svgElement("g", { class: "b06-chart__axes" }) as SVGGElement; + svg.append(axisLayer); + const renderAxes = createCrossAxes(axisLayer, { + widthPx, + heightPx, + pixelsPerMeter, + maxOffset, + displayMax, + elevationMid, + exaggeration, + }); + renderAxes(cardZoomStates.get(section.station_id) ?? IDENTITY_VIEW); // 확대·축소·팬이 닿는 범위 = 이 레이어 안(2026-08-08 사용자 지시. 축·눈금·축 이름은 제자리). // clip 그룹은 고정하고 안쪽 plotLayer만 transform으로 움직인다 — clip을 transform @@ -537,7 +489,10 @@ export function createCrossSectionCard( widthPx, heightPx, cardZoomStates.get(section.station_id), - (state) => cardZoomStates.set(section.station_id, state), + (state) => { + cardZoomStates.set(section.station_id, state); + renderAxes(state); + }, ); // 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다. const readout = buildAreaReadout(section.design, toggleArea);