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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user