perf(B05): 종단 세로 맞춤을 다시 그리기에서 변환으로 — 스크롤 중 실시간

사용자 지시(2026-09-04) — 「화면을 리프레시하는 느낌. 가장 강한 지연은 멈춘 뒤 끊김.
실시간처럼 되려면 근본적으로 표현을 바꿔야 하지 않나」. 확정: 변환 방식.

원인 — 세로 위치가 선 하나하나의 좌표에 박혀 있어, 세로 창이 바뀌면 그래프를 통째로
다시 만들 수밖에 없었음(SVG 요소 457개, 실측 34ms = 60Hz 두 프레임 누락).

- 세로에 딸린 도형(눈금선·절성토 음영·지반선·계획선)을 `b06-chart__ywindow` 한 겹으로
  묶고, 세로 창을 `translate`+`scale` 한 줄로 표현. 두 창의 대응이 1차식이라 정확히 겹침.
- 자를 영역(clip)은 변환 밖, 글자는 변환 밖에서 자리만 옮김(늘어남 방지).
  글자 내림 4px 은 변환 뒤에 더함 — 배율과 함께 커지면 선에서 떨어짐(실측 6.4px).
- 선 굵기·점선 간격은 `vector-effect: non-scaling-stroke`(변환이 걸렸을 때만 적용해
  B06 은 예전 그대로).
- 눈금 간격이 어긋나면(배율 0.8~1.25 밖) **눈금층만** 다시 만듦(요소 20개 안팎).
  눈금은 그릴 때의 좌표계에 놓아 같은 변환이 그대로 먹음.
- 눈금 계산을 `yWindowTickValues` 로 빼 그리는 쪽과 갱신 쪽이 같은 눈금을 씀.
- 패널은 스크롤마다 변환 갱신만 부름. 전체 재구성은 눈금으로도 못 살릴 때만 남김.

자체검증(공용 브라우저 임시 탭, 휠 20칸 4,800px):
① 전체 재구성 **0회**(고침 전 다회) ② 최대 프레임 **59.9 → 20.0ms**, 20ms 초과 0건
③ 눈금 글자와 눈금선 간격 6.4 → **0.5px** ④ 지반선의 한 점을 변환으로 놓은 자리와
눈금이 말하는 자리가 **0.000px** 일치(배율 2.715 상태) ⑤ B06 종단은 변환 겹 3개 모두
변환 없음(항등) — 화면 불변 ⑥ 전체 시험 381 통과·17 건너뜀.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 21:56:25 +09:00
co-authored by Claude Opus 5
parent db3503fd95
commit 8dca4bbfb3
6 changed files with 337 additions and 46 deletions
+118 -41
View File
@@ -25,6 +25,61 @@ import {
} from "./B06_Section_UI_Section_Common";
/** 측점 라벨의 측점번호에 시작 측점 오프셋을 더한다(잔여거리는 그대로). */
/**
* 세로 창 변환 규약 — 그리는 쪽(이 파일)과 갱신하는 쪽(B05 `_UI_Profile_YWindow`)이 함께 쓴다.
*
* 세로 위치를 좌표에 박아 두면 창이 바뀔 때마다 그래프를 통째로 다시 만들어야 한다
* (실측 34ms · 60Hz 두 프레임). 세로에 딸린 도형만 한 겹으로 묶어 두면 **변환 한 줄**로
* 창을 옮길 수 있다(2026-09-04 사용자 확정).
*/
/** 자동 세로 창의 여유 — 최고·최저가 축선에 붙지 않게 위아래로 10%씩(2026-08-23 사용자). */
export const Y_WINDOW_SPAN_PAD = 1.2;
/** 세로 창 변환을 받는 겹 — 세로에 딸린 **도형만** 들어간다(글자는 늘어나므로 제외). */
export const Y_WINDOW_CLASS = "b06-chart__ywindow";
/** 눈금선 겹·눈금 글자 겹 — 창이 크게 바뀌면 이 둘만 다시 만든다(그래프는 그대로). */
export const Y_GRID_CLASS = "b06-chart__ygrid";
export const Y_TICK_CLASS = "b06-chart__yticks";
/** 글자가 그릴 때의 y를 남기는 속성 — 변환 대신 이 값으로 자리만 옮긴다. */
export const Y_WINDOW_BAKED_ATTR = "data-yw-y";
/** 바깥에서 새 창의 변환을 계산하는 데 필요한 기준값(SVG 속성 이름). */
export const Y_WINDOW_ATTRS = {
/** "1"이면 자동 세로 창(B05) — 고정 배율(B06)은 갱신 대상이 아니다. */
auto: "data-yw-auto",
/** 그릴 때 쓴 창 중심 표고(m). */
center: "data-yw-center",
/** 표고 1m당 화면 px. */
pxPerM: "data-yw-pxm",
/** 창 중심 표고가 놓인 y(px). */
zero: "data-yw-y0",
/** 플롯 상단 y(px)와 높이(px) — 창 밖으로 나간 글자를 감추는 데 쓴다. */
top: "data-yw-top",
height: "data-yw-height",
} as const;
/**
* 세로 눈금값 목록 — 창(중심·폭)에 대해 1·2·5 계열의 딱 떨어지는 표고를 고른다.
* 라벨 글자(최대 13px)가 겹치지 않게 눈금 간격은 16px 이상, 표고 눈금은 1m 아래로
* 내려가지 않는다(2026-08-23 사용자 지시).
*
* 그릴 때(이 파일)와 창이 바뀌어 눈금만 다시 만들 때(B05 `_UI_Profile_YWindow`)가
* **같은 눈금**을 써야 해서 여기로 뺐다(2026-09-04).
*/
export function yWindowTickValues(
center: number,
span: number,
plotHeight: number,
): Array<{ value: number; label: string }> {
const step = Math.max(niceTickStep(span, 10, Math.floor(plotHeight / 16)), 1);
const decimals = Math.max(0, Math.ceil(-Math.log10(step) - 1e-9));
const ticks: Array<{ value: number; label: string }> = [];
const topValue = center + span / 2;
for (let value = Math.ceil((center - span / 2) / step) * step; value <= topValue + 1e-9;) {
ticks.push({ value, label: `${value.toFixed(decimals)}m` });
value += step;
}
return ticks;
}
function offsetStationLabel(chainageM: number, interval: number, stationOffset: number): string {
const base = stationLabel(chainageM, interval);
if (!stationOffset) return base;
@@ -220,7 +275,7 @@ export function createLongitudinalProfile(
? plotHeight / yScaleOptions.pixelsPerMeter
: // 자동 스케일(B05)은 최고·최저 표고가 위아래 축선에 딱 붙지 않게 10%씩 여유를 둔다
// (2026-08-23 사용자 지시). 공통 Y스케일(B06 yScaleOptions)은 그대로 둔다.
Math.max(rawMax - rawMin, 1) * 1.2;
Math.max(rawMax - rawMin, 1) * Y_WINDOW_SPAN_PAD;
// 화면에 담기는 표고 폭 = 전범위 ÷ 배율. 창 중심은 그 폭의 비율만큼 위·아래로 옮긴다.
const viewCenter = elevationMid + elevationOffsetRatio * (elevationSpan / exaggeration);
const x = (chainage: number) =>
@@ -228,47 +283,22 @@ export function createLongitudinalProfile(
const xInverse = inverseOf(x, maxChainage);
const y = (elevation: number) =>
LONG_PAD.top + ((viewCenter + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight;
// 세로 창 갱신에 필요한 기준값을 SVG에 남긴다 — 그리는 함수의 인자를 늘리지 않으려는 것.
// 매핑은 1차식이다: y = zero − (표고 − center) × pxPerM.
svg.setAttribute(Y_WINDOW_ATTRS.center, String(viewCenter));
svg.setAttribute(Y_WINDOW_ATTRS.pxPerM, String((exaggeration * plotHeight) / elevationSpan));
svg.setAttribute(Y_WINDOW_ATTRS.zero, String(LONG_PAD.top + plotHeight / 2));
svg.setAttribute(Y_WINDOW_ATTRS.top, String(LONG_PAD.top));
svg.setAttribute(Y_WINDOW_ATTRS.height, String(plotHeight));
// 고정 배율(B06 공통 Y스케일)·세로 과장이 걸린 그래프는 창을 옮기지 않는다.
if (!yScaleOptions && exaggeration === 1) svg.setAttribute(Y_WINDOW_ATTRS.auto, "1");
const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations);
// Y축 눈금: 화면에 담긴 표고 범위를 10칸 안팎으로 나누되, 눈금값이 1·2·5 계열의
// 딱 떨어지는 수(범위가 아주 좁을 때만 소수)로 오게 한다 — 0.25 비율 고정 눈금은
// 표고가 소수로 나와 어느 지점인지 못 읽었다(2026-08-23 사용자 보고).
const yAxisTicks: Array<{ y: number; label: string }> = [];
const rawSpan = elevationSpan / exaggeration;
const rawTop = viewCenter + rawSpan / 2;
// 라벨 글자(최대 13px, sticky 축)가 겹치지 않게 눈금 간격은 16px 이상 띄운다.
// 표고 눈금은 1m 아래로 내려가지 않는다(2026-08-23 사용자 지시).
const tickStep = Math.max(niceTickStep(rawSpan, 10, Math.floor(plotHeight / 16)), 1);
const tickDecimals = Math.max(0, Math.ceil(-Math.log10(tickStep) - 1e-9));
for (
let value = Math.ceil((viewCenter - 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,
y1: gridY,
x2: widthPx - LONG_PAD.right,
y2: gridY,
class: "b06-chart__grid",
}),
svgText(label, {
x: LONG_PAD.left - 9,
y: gridY + 4,
"text-anchor": "end",
class: "b06-chart__tick",
}),
);
}
// sticky Y축 오버레이가 SVG와 동일한 눈금을 쓰도록 전달(B05 전용, B06은 콜백 없음).
onYAxis?.({ padLeft: LONG_PAD.left, ticks: yAxisTicks });
// 표시 표고창(세로 배율·중심 이동) 밖으로 나간 지반선·계획선은 그리지 않는다 —
// 안 자르면 축 라벨·측점 라벨 띠 위로 선이 흘러나온다(2026-09-04).
// 표시 표고창 밖으로 나간 것은 그리지 않는다 — 눈금선·음영·프로파일선이 함께 쓴다.
// (예전에는 이 자리가 아래에 있었다. 눈금선도 세로 변환을 타므로 여기로 올렸다.)
const clipId = `b06-long-clip-${Math.random().toString(36).slice(2, 9)}`;
const clipPath = svgElement("clipPath", { id: clipId });
clipPath.append(
@@ -281,15 +311,60 @@ export function createLongitudinalProfile(
);
const defs = svgElement("defs", {});
defs.append(clipPath);
// 세로 창이 바뀌면 좌표를 다시 만들지 않고 **이 겹의 변환만** 갈아 끼운다(2026-09-04
// 사용자 확정 — 「실시간처럼 되려면 표현을 바꿔야 한다」). 자를 영역은 변환 **밖**이라
// 창이 움직여도 플롯 테두리는 제자리다. 글자는 늘어나므로 이 겹에 넣지 않는다.
const gridLayer = svgElement("g", { "clip-path": `url(#${clipId})` });
const gridWindow = svgElement("g", { class: `${Y_WINDOW_CLASS} ${Y_GRID_CLASS}` });
gridLayer.append(gridWindow);
const tickLayer = svgElement("g", { class: Y_TICK_CLASS });
svg.append(defs, gridLayer, tickLayer);
const yAxisTicks: Array<{ y: number; label: string }> = [];
const rawSpan = elevationSpan / exaggeration;
const rawTop = viewCenter + rawSpan / 2;
// 라벨 글자(최대 13px, sticky 축)가 겹치지 않게 눈금 간격은 16px 이상 띄운다.
// 표고 눈금은 1m 아래로 내려가지 않는다(2026-08-23 사용자 지시).
for (const { value, label } of yWindowTickValues(viewCenter, rawSpan, plotHeight)) {
const gridY = LONG_PAD.top + ((rawTop - value) / rawSpan) * plotHeight;
yAxisTicks.push({ y: gridY, label });
gridWindow.append(
svgElement("line", {
x1: LONG_PAD.left,
y1: gridY,
x2: widthPx - LONG_PAD.right,
y2: gridY,
class: "b06-chart__grid",
}),
);
tickLayer.append(
svgText(label, {
x: LONG_PAD.left - 9,
y: gridY + 4,
"text-anchor": "end",
class: "b06-chart__tick",
// 세로 창 변환이 글자를 늘리지 않고 **자리만** 옮기도록 그릴 때의 y를 남긴다.
// 남기는 값은 **눈금선의 y** — 글자를 내리는 4px 은 변환 뒤에 더해야 배율이
// 커져도 선과 글자 사이가 벌어지지 않는다(2026-09-04 실측 6.4px 어긋남).
[Y_WINDOW_BAKED_ATTR]: String(gridY),
}),
);
}
// sticky Y축 오버레이가 SVG와 동일한 눈금을 쓰도록 전달(B05 전용, B06은 콜백 없음).
onYAxis?.({ padLeft: LONG_PAD.left, ticks: yAxisTicks });
const bandLayer = svgElement("g", { "clip-path": `url(#${clipId})` });
svg.append(defs, bandLayer);
// 음영은 세로에 딸린 것 — 변환 겹 안. 균형 구역 경계선은 플롯 높이 전체라 밖에 둔다.
const bandWindow = svgElement("g", { class: Y_WINDOW_CLASS });
bandLayer.append(bandWindow);
svg.append(bandLayer);
// 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다.
const toY = (elevation: number) => y(viewCenter + (elevation - viewCenter) * exaggeration);
for (const profile of designProfiles) {
if (profile.samples.length < 2) continue;
appendCutFillBands(
bandLayer,
bandWindow,
profile,
x,
(index) => toY(profile.samples[index].elevation_m),
@@ -380,9 +455,11 @@ export function createLongitudinalProfile(
})
.join(" ");
const lineLayer = svgElement("g", { "clip-path": `url(#${clipId})` });
const lineWindow = svgElement("g", { class: Y_WINDOW_CLASS });
lineLayer.append(lineWindow);
for (const profile of designProfiles) {
if (profile.samples.length < 2) continue;
lineLayer.append(
lineWindow.append(
svgElement("polyline", {
points: profile.samples
.map((sample) => `${x(sample.chainage_m)},${toY(sample.elevation_m)}`)
@@ -391,7 +468,7 @@ export function createLongitudinalProfile(
}),
);
}
lineLayer.append(svgElement("polyline", { points, class: "b06-chart__profile" }));
lineWindow.append(svgElement("polyline", { points, class: "b06-chart__profile" }));
svg.append(
lineLayer,
svgElement("line", {
@@ -306,6 +306,12 @@
fill: var(--color-surface-raised);
}
/* 세로 창을 변환으로 옮기는 겹(2026-09-04) — 세로로 눌리거나 늘어나도 선 굵기·점선 간격은
그대로여야 한다. 변환이 걸려 있을 때만 적용해 B06 종단·횡단은 예전 그대로 그려진다. */
.b06-chart__ywindow[transform] * {
vector-effect: non-scaling-stroke;
}
.b06-chart__grid {
stroke: var(--color-border);
stroke-width: 1;