fix(B06): 종단 Y축 눈금 정수화 + 최대·최소 표고 여유

- 0.25 비율 고정 5눈금(소수 표기) → 1·2·5×10^n 중 10칸에 가장 가까운
  간격으로 자동 선택. 눈금값이 딱 떨어져 표고를 읽을 수 있다.
- 라벨 겹침 방지로 눈금 간격 16px 하한(plotHeight/16 개수 상한).
- 자동 스케일(B05)은 표고 범위에 위아래 10%씩 여유 — 최고·최저점이
  축선에 붙지 않는다. 공통 Y스케일(B06 yScaleOptions)은 불변.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 17:49:28 +09:00
co-authored by Claude Opus 5
parent 76df1e9335
commit 2cea6203be
+44 -7
View File
@@ -30,6 +30,30 @@ 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,
@@ -202,7 +226,9 @@ export function createLongitudinalProfile(
const plotHeight = heightPx - LONG_PAD.top - padBottom;
const elevationSpan = yScaleOptions
? plotHeight / yScaleOptions.pixelsPerMeter
: Math.max(rawMax - rawMin, 1);
: // 자동 스케일(B05)은 최고·최저 표고가 위아래 축선에 딱 붙지 않게 10%씩 여유를 둔다
// (2026-08-23 사용자 지시). 공통 Y스케일(B06 yScaleOptions)은 그대로 둔다.
Math.max(rawMax - rawMin, 1) * 1.2;
const x = (chainage: number) =>
LONG_PAD.left + originOffsetPx + (chainage / maxChainage) * plotWidth;
const xInverse = inverseOf(x, maxChainage);
@@ -210,12 +236,23 @@ export function createLongitudinalProfile(
LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight;
const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations);
// Y축 눈금: 화면에 담긴 표고 범위를 10칸 안팎으로 나누되, 눈금값이 1·2·5 계열의
// 딱 떨어지는 수(범위가 아주 좁을 때만 소수)로 오게 한다 — 0.25 비율 고정 눈금은
// 표고가 소수로 나와 어느 지점인지 못 읽었다(2026-08-23 사용자 보고).
const yAxisTicks: Array<{ y: number; label: string }> = [];
for (const ratio of [0, 0.25, 0.5, 0.75, 1]) {
const gridY = LONG_PAD.top + ratio * plotHeight;
const displayed = elevationMid + elevationSpan / 2 - ratio * elevationSpan;
const rawValue = elevationMid + (displayed - elevationMid) / exaggeration;
yAxisTicks.push({ y: gridY, label: `${rawValue.toFixed(1)}m` });
const rawSpan = elevationSpan / exaggeration;
const rawTop = elevationMid + rawSpan / 2;
// 라벨 글자(최대 13px, sticky 축)가 겹치지 않게 눈금 간격은 16px 이상 띄운다.
const tickStep = niceTickStep(rawSpan, 10, Math.floor(plotHeight / 16));
const tickDecimals = Math.max(0, Math.ceil(-Math.log10(tickStep) - 1e-9));
for (
let value = Math.ceil((elevationMid - rawSpan / 2) / tickStep) * tickStep;
value <= rawTop + 1e-9;
value += tickStep
) {
const gridY = LONG_PAD.top + ((rawTop - value) / rawSpan) * plotHeight;
const label = `${value.toFixed(tickDecimals)}m`;
yAxisTicks.push({ y: gridY, label });
svg.append(
svgElement("line", {
x1: LONG_PAD.left,
@@ -224,7 +261,7 @@ export function createLongitudinalProfile(
y2: gridY,
class: "b06-chart__grid",
}),
svgText(`${rawValue.toFixed(1)}m`, {
svgText(label, {
x: LONG_PAD.left - 9,
y: gridY + 4,
"text-anchor": "end",