fix(B06): 횡단면도 Y축 눈금도 정수·1·2·5 계열로 정의

- 5등분 고정(소수 표기) → 종단면도와 같은 niceTickStep 규칙 적용.
- niceTickStep을 _UI_Section_Common으로 옮겨 종·횡단이 공유(정의 한 곳).
- 횡단은 카드 높이가 낮아 라벨 간격 하한 14px(종단 16px)로 개수 상한 산정.
- 횡단 표고 여유는 기존 crossPlotMetrics의 8% 패딩 그대로.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 17:51:38 +09:00
co-authored by Claude Opus 5
parent 2cea6203be
commit c10fb3400e
3 changed files with 40 additions and 27 deletions
+15 -3
View File
@@ -43,6 +43,7 @@ import {
type DesignChangeHandler,
emptyView,
L,
niceTickStep,
stationLabel,
svgElement,
svgText,
@@ -317,9 +318,20 @@ export function createCrossSectionCard(
}),
);
}
// Y축 눈금: 표고 범위를 10칸 안팎으로 나누되 눈금값이 1·2·5 계열로 딱 떨어지게 한다
// (종단면도와 같은 규칙). 5등분 고정은 눈금값이 소수로 흘러 표고를 못 읽었다
// (2026-08-23 사용자 지시). 라벨 겹침 방지로 눈금 간격은 14px 이상 띄운다.
const rawDisplaySpan = displaySpan / exaggeration;
for (let index = 0; index < 5; index += 1) {
const tick = elevationMid - rawDisplaySpan / 2 + (rawDisplaySpan * index) / 4;
const rawBottom = elevationMid - rawDisplaySpan / 2;
const rawTop = elevationMid + rawDisplaySpan / 2;
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", {
@@ -329,7 +341,7 @@ export function createCrossSectionCard(
y2: y(displayTick),
class: "b06-chart__grid",
}),
svgText(tick.toFixed(1), {
svgText(tick.toFixed(tickDecimals), {
x: CROSS_PAD.left - 7,
y: y(displayTick) + 3,
"text-anchor": "end",
+1 -24
View File
@@ -15,6 +15,7 @@ import {
LONG_PAD,
LONG_WIDTH,
longitudinalMaxChainage,
niceTickStep,
stationLabel,
svgElement,
svgText,
@@ -30,30 +31,6 @@ function offsetStationLabel(chainageM: number, interval: number, stationOffset:
return `${Number(number) + stationOffset}+${remainder}`;
}
/**
* 눈금 간격을 1·2·5 ×10^n 중에서 고른다 — 눈금값이 딱 떨어지면서 눈금 개수가
* 목표(10칸)에 가장 가까운 것. 다만 라벨이 겹치지 않게 최대 개수(maxCount)를 넘지 않는다.
*/
function niceTickStep(span: number, targetCount: number, maxCount: number): number {
if (!(span > 0)) return 1;
const exponent = Math.floor(Math.log10(span / targetCount));
let best = Math.pow(10, exponent + 2);
let bestError = Infinity;
for (const power of [exponent - 1, exponent, exponent + 1, exponent + 2]) {
for (const mantissa of [1, 2, 5]) {
const step = mantissa * Math.pow(10, power);
const count = Math.floor(span / step) + 1;
if (count > Math.max(2, maxCount)) continue;
const error = Math.abs(count - targetCount);
if (error < bestError) {
bestError = error;
best = step;
}
}
}
return best;
}
export function longitudinalMinimumWidth(
data: LongitudinalSection,
configuredStationInterval?: number,
@@ -165,3 +165,27 @@ export function emptyView(message: string): HTMLElement {
empty.textContent = message;
return empty;
}
/**
* 눈금 간격을 1·2·5 ×10^n 중에서 고른다 — 눈금값이 딱 떨어지면서 눈금 개수가
* 목표(10칸)에 가장 가까운 것. 다만 라벨이 겹치지 않게 최대 개수(maxCount)를 넘지 않는다.
*/
export function niceTickStep(span: number, targetCount: number, maxCount: number): number {
if (!(span > 0)) return 1;
const exponent = Math.floor(Math.log10(span / targetCount));
let best = Math.pow(10, exponent + 2);
let bestError = Infinity;
for (const power of [exponent - 1, exponent, exponent + 1, exponent + 2]) {
for (const mantissa of [1, 2, 5]) {
const step = mantissa * Math.pow(10, power);
const count = Math.floor(span / step) + 1;
if (count > Math.max(2, maxCount)) continue;
const error = Math.abs(count - targetCount);
if (error < bestError) {
bestError = error;
best = step;
}
}
}
return best;
}