사용자 보고(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>
206 lines
9.4 KiB
TypeScript
206 lines
9.4 KiB
TypeScript
/* =============================================================================
|
||
* 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);
|
||
}
|