fix(B06): 횡단 줌·팬에 X·Y 축 눈금 동기화

도형만 transform으로 확대되고 축은 고정이라 확대·이동 후 눈금값이 실제 위치와
어긋났다(2026-08-23 사용자 보고). 축·격자를 별도 레이어(_UI_Cross_Axes)로 옮기고
줌·팬 상태가 바뀔 때마다 보이는 범위를 역산해 다시 그린다.

- 눈금 개수·간격도 그때 보이는 범위로 다시 고른다(1·2·5 계열, X 목표 7·Y 목표 10,
  라벨 겹침 하한 X 48px·Y 14px). 확대할수록 촘촘·소수 단위로 자동 전환.
- X 눈금이 7등분 고정(비정수 간격)이던 것도 같은 규칙으로 정리.
- Cross_View는 축 코드가 빠져 622줄.

자체검증: node tmp/tests/test_cross_axes_zoom.mjs (원배율·4배 확대에서 눈금이
플롯 안에 있고, 같은 offset의 도형 자리와 눈금 자리가 일치), tsc --noEmit 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 18:20:22 +09:00
co-authored by Claude Opus 5
parent f2ad5508f5
commit 0d80d03b96
2 changed files with 150 additions and 67 deletions
+128
View File
@@ -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",
}),
);
}
};
}
+22 -67
View File
@@ -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);