Files
Aislo/common_util/common_util_chart_ywindow.ts
T
eomsangdonandClaude Opus 5 4a57272e72 fix(B05,B06): 종단 그래프를 왔다갔다하면 Y축 눈금이 비던 것
사용자 보고(2026-09-04) — 「종단 그래프 왔다갔다하면 Y축 값이 비는 경우가 있음」.
세로창을 변환으로 옮기면서 눈금을 **배율이 크게 바뀔 때만** 다시 만들게 해 뒀는데,
창이 위아래로 **이동만** 하면 배율은 그대로라 눈금이 한쪽으로 쓸려 나가고 그 자리가 빔.

- 새 창에 있어야 할 눈금 수와 화면에 남는 수가 다르면 눈금을 다시 만들게 함(요소 20개
  안팎이라 값싸다). 배율 문턱 판정은 그대로 둠.

자체검증(공용 브라우저 임시 탭): 확대 4칸 뒤 좌우로 6번(+900·+900·−600·+1400·−1800·+2400px)
오가며 눈금이 플롯을 덮는지 측정 — 눈금 6~11개가 항상 이어지고 **위·아래 빈틈 최대 37px**
(플롯 높이 329px, 창 여유 20%에 해당). 고치기 전에는 플롯 절반이 비었음.
전체 시험 381 통과·17 건너뜀.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:19:07 +09:00

206 lines
9.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;
}
/** 지금 그려져 있는 눈금 글자 중 새 창 안에 남는 개수. */
function countTicksInside(baked: Baked, scale: number, offset: number): number {
let count = 0;
baked.svg.querySelectorAll<SVGTextElement>(`text[${Y_WINDOW_BAKED_ATTR}]`).forEach((label) => {
const moved = scale * Number(label.getAttribute(Y_WINDOW_BAKED_ATTR)) + offset;
if (moved >= baked.top && moved <= baked.top + baked.height) count += 1;
});
return count;
}
/**
* 종단 그래프의 세로 창을 옮긴다. 대상 그래프가 없거나(고정 배율 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;
const offset = baked.zero * (1 - scale) + (center - baked.center) * pxPerM;
// 눈금을 새로 만들 때 — 둘 중 하나만 걸려도 만든다(요소 20개 안팎이라 싸다).
// ① 배율이 크게 벌어짐 — 눈금 간격이 원래 의도(1·2·5 계열)와 어긋난다.
// ② **새 창에 있어야 할 눈금 수와 지금 화면에 남은 수가 다름** — 창이 위아래로 이동만
// 하면 배율은 그대로인데 눈금이 한쪽으로 쓸려 나가 그 자리가 빈다(2026-09-04 사용자
// 보고: 「종단 그래프 왔다갔다하면 Y축 값이 비는 경우」).
const wanted = yWindowTickValues(center, span / baked.exaggeration, baked.height).length;
const staying = countTicksInside(baked, scale, offset);
let rebuiltTicks = false;
if (scale < TICK_KEEP_MIN || scale > TICK_KEEP_MAX || staying !== wanted) {
rebuiltTicks = rebuildTicks(baked, root, center, span);
}
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);
}