사용자 지시(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>
177 lines
7.8 KiB
TypeScript
177 lines
7.8 KiB
TypeScript
/* =============================================================================
|
||
* 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);
|
||
}
|