Files
Aislo/common_util/common_util_chart_ywindow.ts
T
eomsangdonandClaude Opus 5 685d44ac39 perf(B05,B06): 유토곡선·B06 페이지도 세로 창을 실시간으로 따라오게
사용자 지적(2026-09-04) — 「유토곡선도 반영해야지. B06 페이지도 있잖아. 같은 로직일 텐데
왜 반영이 안 된 거야」. 그리는 쪽(종단 렌더러)은 공용이라 이미 바뀌어 있었고, **스크롤을
받아 창을 옮기는 쪽이 화면마다 따로**라 B05 만 연결돼 있던 것.

- 세로 창 모듈을 `common_util_chart_ywindow.ts` 로 옮겨 B05·B06 이 함께 씀.
  좌측 고정 축은 표식(`b05-profile__yaxis--ywindow`)이 붙은 것만 옮김 — B06 은 한 컨테이너에
  종단·유토곡선 축이 함께 살아 남의 축까지 옮기면 안 됨.
- 세로 과장(B06 조절값)을 각인·반영 — 예전에는 과장이 1일 때만 대상이었음.
- 유토곡선은 변환이 아니라 **곡선만 다시 그림**(스크롤마다). 곡선 위 말풍선·EP 표·측점
  점처럼 세로로 늘어나면 안 되는 것이 많아, 그것만 따로 옮기는 값이 다시 그리는 값보다 큼
  (유토 SVG 234개 = 종단의 1/3).
- B06 상단 패널을 통째로 다시 그리던 스크롤 처리를 걷어내고, 종단은 변환·유토곡선은
  제자리 교체로 바꿈. 700줄 제한에 맞춰 종단 조립을 `_View_Chart.ts`(116줄)로,
  유토곡선 조립기를 `_View_MassHaul.ts` 로 분리(727 → 718줄).

자체검증(공용 브라우저 임시 탭):
① B05 유토곡선 눈금이 스크롤을 따라 4.3㎥ → −1,348.8㎥ 로 이동(예전에는 안 따라옴)
② B06 종단 눈금 830/840/850m → 824/826/828m, 유토곡선 68.2㎥ → −3,375.1㎥ 로 동시 이동
③ B06 최대 프레임 **35.9 → 23.5ms**, 33ms 초과 1 → **0건**(중앙값 16.7ms 유지)
④ B05 최대 프레임 27.4ms, 33ms 초과 0건 ⑤ 전체 시험 381 통과·17 건너뜀.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:13:03 +09:00

190 lines
8.4 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.
/* =============================================================================
* common_util_chart_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;
/** 세로 창을 따라 움직이는 좌측 고정 축에 붙이는 표식 — 한 컨테이너에 축이 둘 이상일 때
* (B06: 종단 + 유토곡선) 남의 축까지 옮기지 않으려는 것이다. */
export const Y_AXIS_WINDOW_CLASS = "b05-profile__yaxis--ywindow";
/** 눈금 글자는 선보다 이만큼 아래에 앉는다(그리는 쪽과 같은 값). */
const TICK_TEXT_DROP_PX = 4;
interface Baked {
svg: SVGSVGElement;
center: number;
pxPerM: number;
zero: number;
top: number;
height: number;
/** 세로 과장 — 새 창의 px 환산에 그대로 곱한다(B06 조절값, B05 는 1). */
exaggeration: 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),
exaggeration: read(Y_WINDOW_ATTRS.exaggeration) || 1,
};
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>(`.${Y_AXIS_WINDOW_CLASS} .b05-profile__yaxis-inner`);
const ns = "http://www.w3.org/2000/svg";
const lines: SVGLineElement[] = [];
const texts: SVGTextElement[] = [];
const spans: HTMLElement[] = [];
// 눈금값은 **과장을 뺀** 표고 폭 기준이다(그리는 쪽 `rawSpan` 과 같은 값).
for (const { value, label } of yWindowTickValues(
center,
span / baked.exaggeration,
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.exaggeration * 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>(
`.${Y_AXIS_WINDOW_CLASS} .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);
}