Files
Aislo/B05_Profile/B05_Profile_UI_Profile_YWindow.ts
T
eomsangdonandClaude Opus 5 8dca4bbfb3 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>
2026-09-04 21:56:25 +09:00

177 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B05_Profile_UI_Profile_YWindow.ts
* 종단 그래프의 **세로 창을 변환으로 갈아 끼운다** — 다시 그리지 않는다(2026-09-04 확정).
*
* 예전에는 스크롤을 멈출 때마다 그래프를 통째로 새로 만들었다(SVG 요소 457개, 실측
* 34ms = 60Hz 두 프레임 누락). 사용자에게는 「화면을 리프레시하는 느낌」으로 왔다.
*
* 세로 매핑은 1차식이라(`y = zero (표고 center) × pxPerM`) 옛 창과 새 창의 관계도
* 1차식이다. 그래서 도형을 건드리지 않고 겹 하나에 `translate`+`scale` 만 걸면 정확히
* 겹친다. 글자는 늘어나므로 변환에 넣지 않고 자리만 옮긴다(눈금 10개 안팎).
*
* 창이 많이 벌어져 눈금 간격이 원래 의도(1·2·5 계열)와 어긋나면 **눈금만** 다시 만든다.
* 그때도 눈금은 **그릴 때의 좌표계**에 놓아 같은 변환이 그대로 먹게 한다.
* ========================================================================== */
import {
Y_GRID_CLASS,
Y_TICK_CLASS,
Y_WINDOW_ATTRS,
Y_WINDOW_BAKED_ATTR,
Y_WINDOW_CLASS,
Y_WINDOW_SPAN_PAD,
yWindowTickValues,
} from "../B06_Section/B06_Section_UI_Longitudinal";
/** 눈금을 다시 만드는 문턱 — 그릴 때 대비 세로 배율이 이 밖으로 나가면 간격이 어긋난다. */
const TICK_KEEP_MIN = 0.8;
const TICK_KEEP_MAX = 1.25;
/** 플롯 안에 남아야 하는 최소 눈금 수. */
const TICK_KEEP_MIN_COUNT = 4;
/** 눈금 글자는 선보다 이만큼 아래에 앉는다(그리는 쪽과 같은 값). */
const TICK_TEXT_DROP_PX = 4;
interface Baked {
svg: SVGSVGElement;
center: number;
pxPerM: number;
zero: number;
top: number;
height: number;
}
export interface ElevationWindowResult {
/** 그릴 때 대비 세로 배율(1이면 그릴 때와 같은 창). */
scale: number;
/** 플롯 안에 남아 있는 눈금 수. */
visibleTicks: number;
/** 이번 호출에서 눈금을 다시 만들었는가. */
rebuiltTicks: boolean;
}
function readBaked(root: ParentNode): Baked | null {
const svg = root.querySelector<SVGSVGElement>(`svg[${Y_WINDOW_ATTRS.auto}="1"]`);
if (!svg) return null;
const read = (name: string): number => Number(svg.getAttribute(name));
const baked: Baked = {
svg,
center: read(Y_WINDOW_ATTRS.center),
pxPerM: read(Y_WINDOW_ATTRS.pxPerM),
zero: read(Y_WINDOW_ATTRS.zero),
top: read(Y_WINDOW_ATTRS.top),
height: read(Y_WINDOW_ATTRS.height),
};
const numbers = [baked.center, baked.pxPerM, baked.zero, baked.top, baked.height];
if (!numbers.every(Number.isFinite) || baked.pxPerM <= 0 || baked.height <= 0) return null;
return baked;
}
/** 눈금선·눈금 글자·좌측 고정 축을 새 창의 눈금값으로 다시 만든다(그래프는 건드리지 않는다).
*
* 자리는 **그릴 때의 좌표계**로 잡는다 — 그래야 아래 변환이 눈금에도 그대로 먹는다. */
function rebuildTicks(baked: Baked, root: ParentNode, center: number, span: number): boolean {
const grid = baked.svg.querySelector<SVGGElement>(`.${Y_GRID_CLASS}`);
const labels = baked.svg.querySelector<SVGGElement>(`.${Y_TICK_CLASS}`);
if (!grid || !labels) return false;
const sampleLine = grid.querySelector("line");
const sampleText = labels.querySelector("text");
if (!sampleLine || !sampleText) return false;
const x1 = sampleLine.getAttribute("x1") ?? "0";
const x2 = sampleLine.getAttribute("x2") ?? "0";
const lineClass = sampleLine.getAttribute("class") ?? "";
const textX = sampleText.getAttribute("x") ?? "0";
const textClass = sampleText.getAttribute("class") ?? "";
const axis = root.querySelector<HTMLElement>(".b05-profile__yaxis-inner");
const ns = "http://www.w3.org/2000/svg";
const lines: SVGLineElement[] = [];
const texts: SVGTextElement[] = [];
const spans: HTMLElement[] = [];
for (const { value, label } of yWindowTickValues(center, span, baked.height)) {
const y = baked.zero - (value - baked.center) * baked.pxPerM;
const line = document.createElementNS(ns, "line");
line.setAttribute("x1", x1);
line.setAttribute("x2", x2);
line.setAttribute("y1", String(y));
line.setAttribute("y2", String(y));
line.setAttribute("class", lineClass);
lines.push(line);
const text = document.createElementNS(ns, "text");
text.setAttribute("x", textX);
text.setAttribute("y", String(y + TICK_TEXT_DROP_PX));
text.setAttribute("text-anchor", "end");
text.setAttribute("class", textClass);
text.setAttribute(Y_WINDOW_BAKED_ATTR, String(y));
text.textContent = label;
texts.push(text);
if (axis) {
const span = document.createElement("span");
span.className = "b05-profile__yaxis-tick";
span.style.top = `${y}px`;
span.setAttribute(Y_WINDOW_BAKED_ATTR, String(y));
span.textContent = label;
spans.push(span);
}
}
grid.replaceChildren(...lines);
labels.replaceChildren(...texts);
if (axis) axis.replaceChildren(...spans);
return true;
}
/**
* 종단 그래프의 세로 창을 옮긴다. 대상 그래프가 없거나(고정 배율 B06) 창이 없으면 null.
* 좌측 고정 축(`b05-profile__yaxis-tick`)도 같은 식으로 따라 옮긴다.
*/
export function applyElevationWindow(
root: ParentNode,
range: { min: number; max: number } | undefined,
): ElevationWindowResult | null {
const baked = readBaked(root);
if (!baked || !range) return null;
// 새 창 — 그리는 쪽과 **같은 규칙**으로 만든다(같은 상수를 같이 쓴다).
const span = Math.max(range.max - range.min, 1) * Y_WINDOW_SPAN_PAD;
const center = (range.min + range.max) / 2;
const pxPerM = baked.height / span;
const scale = pxPerM / baked.pxPerM;
// 배율이 크게 벌어지면 눈금 간격이 원래 의도와 어긋난다 — 눈금만 새로 만든다(요소 20개
// 안팎). 그래프 전체를 다시 만들던 예전 방식이 34ms 끊김의 원인이었다.
let rebuiltTicks = false;
if (scale < TICK_KEEP_MIN || scale > TICK_KEEP_MAX) {
rebuiltTicks = rebuildTicks(baked, root, center, span);
}
const offset = baked.zero * (1 - scale) + (center - baked.center) * pxPerM;
const transform = `translate(0 ${offset}) scale(1 ${scale})`;
baked.svg.querySelectorAll<SVGGElement>(`.${Y_WINDOW_CLASS}`).forEach((layer) => {
layer.setAttribute("transform", transform);
});
const place = (bakedY: number): number => scale * bakedY + offset;
const inside = (y: number): boolean => y >= baked.top && y <= baked.top + baked.height;
let visibleTicks = 0;
baked.svg.querySelectorAll<SVGTextElement>(`text[${Y_WINDOW_BAKED_ATTR}]`).forEach((label) => {
const moved = place(Number(label.getAttribute(Y_WINDOW_BAKED_ATTR)));
// 글자를 내리는 몫은 **변환 뒤에** 더한다 — 배율과 함께 커지면 선에서 떨어져 보인다.
label.setAttribute("y", String(moved + TICK_TEXT_DROP_PX));
// 플롯 밖으로 나간 눈금 글자는 감춘다 — 축 라벨·측점 띠 위로 흘러나오면 안 된다.
label.style.display = inside(moved) ? "" : "none";
if (inside(moved)) visibleTicks += 1;
});
root
.querySelectorAll<HTMLElement>(`.b05-profile__yaxis-tick[${Y_WINDOW_BAKED_ATTR}]`)
.forEach((tick) => {
const moved = place(Number(tick.getAttribute(Y_WINDOW_BAKED_ATTR)));
tick.style.top = `${moved}px`;
tick.style.display = inside(moved) ? "" : "none";
});
return { scale, visibleTicks, rebuiltTicks };
}
/** 눈금이 더는 쓸 만하지 않은가 — 패널이 그래프 전체를 다시 만들지 판단하는 기준. */
export function needsFullRedraw(result: ElevationWindowResult | null): boolean {
return !result || (result.visibleTicks < TICK_KEEP_MIN_COUNT && !result.rebuiltTicks);
}