/* ============================================================================= * 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(`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(`.${Y_GRID_CLASS}`); const labels = baked.svg.querySelector(`.${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(`.${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(`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(`.${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(`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( `.${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); }