// 횡단 Y축 눈금이 플롯 안(상단~X축선)에만 놓이는지 확인 — crossPlotMetrics + Cross_View 로직 복제. // 실행: node tmp/tests/test_cross_y_ticks.mjs import assert from "node:assert/strict"; const CROSS_PAD = { left: 58, right: 20, top: 10, bottom: 52 }; const CROSS_HEIGHT = 250; const AREA_OVERLAY_HEADROOM_PX = 58; function niceTickStep(span, targetCount, maxCount) { 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; } function ticksOf({ rawMin, rawMax, halfWidth, widthPx = 630, exaggeration = 1 }) { const minOffset = -halfWidth; const maxOffset = halfWidth; const elevationMid = (rawMin + rawMax) / 2; const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5; const pixelsPerMeter = (widthPx - CROSS_PAD.left - CROSS_PAD.right) / (maxOffset - minOffset); const rawSpan = Math.max((rawMax - rawMin + padding * 2) * Math.max(exaggeration, 0.1), 1e-6); const naturalHeight = rawSpan * pixelsPerMeter + CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX; const heightPx = Math.max(naturalHeight, CROSS_HEIGHT); const displaySpan = Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1e-6) / pixelsPerMeter; const displayMax = elevationMid + displaySpan / 2 + AREA_OVERLAY_HEADROOM_PX / pixelsPerMeter / 2; const y = (elevation) => CROSS_PAD.top + (displayMax - elevation) * pixelsPerMeter; const rawDisplaySpan = displaySpan / exaggeration; const rawTop = elevationMid + (displayMax - elevationMid) / exaggeration; const rawBottom = rawTop - rawDisplaySpan; const plotHeightPx = heightPx - CROSS_PAD.top - CROSS_PAD.bottom; // 표고 눈금 최소 1m(2026-08-23) — 화면 로직과 같게 맞춘다. const step = Math.max(niceTickStep(rawDisplaySpan, 10, Math.floor(plotHeightPx / 14)), 1); const out = []; for (let tick = Math.ceil(rawBottom / step) * step; tick <= rawTop + 1e-9; tick += step) { out.push({ tick, y: y(elevationMid + (tick - elevationMid) * exaggeration) }); } return { out, step, top: CROSS_PAD.top, bottom: heightPx - CROSS_PAD.bottom, heightPx }; } const CASES = [ { rawMin: 536.0, rawMax: 541.0, halfWidth: 12 }, // 스크린샷과 비슷한 성토 단면 { rawMin: 530.0, rawMax: 530.4, halfWidth: 6 }, // 거의 평탄(1m 눈금 두세 줄만 나온다) { rawMin: 500.0, rawMax: 528.0, halfWidth: 20 }, // 급경사 절토 { rawMin: 536.0, rawMax: 541.0, halfWidth: 12, exaggeration: 2 }, // 과장 2배 ]; for (const c of CASES) { const { out, step, top, bottom } = ticksOf(c); assert.ok(out.length >= 2, `눈금 부족: ${JSON.stringify(c)} → ${out.length}`); assert.ok(step >= 1 - 1e-9, `1m 미만 눈금: ${step}`); for (const t of out) { assert.ok(Math.abs(t.tick - Math.round(t.tick)) < 1e-6, `정수 아닌 표고 눈금 ${t.tick}`); } for (const t of out) { assert.ok( t.y >= top - 1e-6 && t.y <= bottom + 1e-6, `플롯 밖 눈금 ${t.tick.toFixed(2)} y=${t.y.toFixed(1)} (허용 ${top}~${bottom.toFixed(1)})`, ); } const spacing = out.length > 1 ? Math.abs(out[1].y - out[0].y) : Infinity; assert.ok(spacing >= 14 - 1e-6, `눈금 간격 과밀 ${spacing.toFixed(1)}px (step=${step})`); } console.log("ok");