diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index f702240f..61006117 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -47,6 +47,7 @@ import { createMassHaulSummary, MASS_HAUL_MIN_HEIGHT, } from "@util/common_util_mass_haul_view"; +import { Y_WINDOW_BAKED_ATTR } from "../B06_Section/B06_Section_UI_Longitudinal"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import "@util/common_util_mass_haul.css"; @@ -102,6 +103,9 @@ export function buildStickyYAxis( const tick = document.createElement("span"); tick.className = "b05-profile__yaxis-tick"; tick.style.top = `${y}px`; + // 종단 그래프의 세로 창 변환이 이 눈금도 같이 옮긴다 — 그릴 때의 y를 남겨 둔다 + // (`_UI_Profile_YWindow`, 2026-09-04). 유토곡선 축에는 갱신이 오지 않아 무해하다. + tick.setAttribute(Y_WINDOW_BAKED_ATTR, String(y)); tick.textContent = label; inner.append(tick); }); diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 45fec08d..c8ba1d46 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -98,6 +98,7 @@ const CROSS_PREVIEW_DEBOUNCE_MS = 60; * 동작하도록 여기서 다시 내보낸다(2026-09-04). */ import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; +import { needsFullRedraw, type ElevationWindowResult } from "./B05_Profile_UI_Profile_YWindow"; export type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; export function createRouteProfilePanel( @@ -342,6 +343,8 @@ export function createRouteProfilePanel( /** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */ let elevationWindow: { min: number; max: number } | undefined; + /** 세로 창을 **다시 그리지 않고** 옮기는 갱신기 — 그릴 때마다 렌더러가 새로 준다. */ + let updateElevationWindow: (() => ElevationWindowResult | null) | null = null; /** 계획고 편집 버튼(▲▼)을 누르고 있는 중인가 — 그동안 Y 축을 고정한다. */ let heightEditing = false; const holdElevationRange = ( @@ -506,19 +509,25 @@ export function createRouteProfilePanel( selectedRuns: () => tools.selectedRuns(), stationIdAtStructure, redraw: draw, + setElevationWindowUpdater: (update) => { + updateElevationWindow = update; + }, }); } - /** 가로 스크롤이 이만큼 멈춰 있으면 보이는 구간이 정해진 것으로 보고 세로를 다시 맞춘다. - * 재구성이 실측 50ms 미만이라 160 → 80ms 로 줄였다 — 멈춘 뒤 따라오는 시간을 반으로 - * (2026-09-04 사용자 지시: 「응답이 늦게 쫓아온다」). */ + /** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ const SCROLL_SETTLE_MS = 80; - /** 마지막으로 세로를 맞춘 가로 위치 — 같은 자리면 다시 그리지 않는다(재구성 되먹임 차단). */ + /** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */ let settledScrollLeft = 0; let scrollSettleTimer = 0; - // 스크롤하는 내내 축이 출렁이면 어지럽다 — 멈춘 뒤에 한 번만 다시 맞춘다(2026-09-04). + // 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의 + // 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만 + // 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다. body.addEventListener("scroll", () => { + if (heightEditing) return; + const fitted = updateElevationWindow?.() ?? null; window.clearTimeout(scrollSettleTimer); + if (!needsFullRedraw(fitted)) return; scrollSettleTimer = window.setTimeout(() => { if (heightEditing || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; settledScrollLeft = body.scrollLeft; diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 6d4662c3..53035c34 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -28,6 +28,7 @@ import { } from "./B05_Profile_UI_Profile_Alignment"; import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit"; import { createRunHighlight } from "./B05_Profile_UI_Profile_RunHighlight"; +import { applyElevationWindow, type ElevationWindowResult } from "./B05_Profile_UI_Profile_YWindow"; import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten"; import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover"; import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul"; @@ -104,6 +105,9 @@ export interface ProfileRenderContext { stationIdAtStructure: (structureId: string | null) => string | null; /** 알약을 골랐을 때처럼 그리는 도중 다시 그려야 하는 자리. */ redraw: () => void; + /** 세로 창을 **다시 그리지 않고** 옮기는 갱신기를 패널에 넘긴다(2026-09-04). + * 가로 스크롤마다 이것만 부르면 그래프 재구성(실측 34ms)이 사라진다. */ + setElevationWindowUpdater: (update: (() => ElevationWindowResult | null) | null) => void; } /** 본문을 통째로 다시 그린다. 상세가 없거나 본문이 아직 0크기면 아무것도 하지 않는다. */ @@ -323,6 +327,21 @@ export function renderProfile(ctx: ProfileRenderContext): void { }, onMove: (structureId, toChainage) => callbacks?.onStructureMarkMove?.(structureId, toChainage), }); + // 세로 창 갱신기 — 가로로 스크롤할 때마다 패널이 이것을 부른다. 도형은 그대로 두고 + // 겹 하나의 변환만 갈아 끼우므로 매 프레임 불러도 된다(2026-09-04 사용자 확정). + ctx.setElevationWindowUpdater(() => { + const from = Math.max(0, toChainage(body.scrollLeft)); + const to = Math.min(maxChainageM, toChainage(body.scrollLeft + body.clientWidth)); + const next = ctx.holdElevationRange( + windowElevationRange( + [graphLongitudinal.samples, ...designProfiles.map((profile) => profile.samples)], + from, + to, + ) ?? null, + ); + return applyElevationWindow(chartWrap, next); + }); + // 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단). // 앵커(0크기 sticky)는 **첫 자식**이어야 한다 — SVG 뒤에 붙이면 흐름 위치가 차트 // 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙). diff --git a/B05_Profile/B05_Profile_UI_Profile_YWindow.ts b/B05_Profile/B05_Profile_UI_Profile_YWindow.ts new file mode 100644 index 00000000..2f561f58 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_YWindow.ts @@ -0,0 +1,176 @@ +/* ============================================================================= + * 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(`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(`.${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(".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(`.${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(`.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); +} diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index d942ac73..5cae3c89 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -25,6 +25,61 @@ import { } from "./B06_Section_UI_Section_Common"; /** 측점 라벨의 측점번호에 시작 측점 오프셋을 더한다(잔여거리는 그대로). */ +/** + * 세로 창 변환 규약 — 그리는 쪽(이 파일)과 갱신하는 쪽(B05 `_UI_Profile_YWindow`)이 함께 쓴다. + * + * 세로 위치를 좌표에 박아 두면 창이 바뀔 때마다 그래프를 통째로 다시 만들어야 한다 + * (실측 34ms · 60Hz 두 프레임). 세로에 딸린 도형만 한 겹으로 묶어 두면 **변환 한 줄**로 + * 창을 옮길 수 있다(2026-09-04 사용자 확정). + */ +/** 자동 세로 창의 여유 — 최고·최저가 축선에 붙지 않게 위아래로 10%씩(2026-08-23 사용자). */ +export const Y_WINDOW_SPAN_PAD = 1.2; +/** 세로 창 변환을 받는 겹 — 세로에 딸린 **도형만** 들어간다(글자는 늘어나므로 제외). */ +export const Y_WINDOW_CLASS = "b06-chart__ywindow"; +/** 눈금선 겹·눈금 글자 겹 — 창이 크게 바뀌면 이 둘만 다시 만든다(그래프는 그대로). */ +export const Y_GRID_CLASS = "b06-chart__ygrid"; +export const Y_TICK_CLASS = "b06-chart__yticks"; +/** 글자가 그릴 때의 y를 남기는 속성 — 변환 대신 이 값으로 자리만 옮긴다. */ +export const Y_WINDOW_BAKED_ATTR = "data-yw-y"; +/** 바깥에서 새 창의 변환을 계산하는 데 필요한 기준값(SVG 속성 이름). */ +export const Y_WINDOW_ATTRS = { + /** "1"이면 자동 세로 창(B05) — 고정 배율(B06)은 갱신 대상이 아니다. */ + auto: "data-yw-auto", + /** 그릴 때 쓴 창 중심 표고(m). */ + center: "data-yw-center", + /** 표고 1m당 화면 px. */ + pxPerM: "data-yw-pxm", + /** 창 중심 표고가 놓인 y(px). */ + zero: "data-yw-y0", + /** 플롯 상단 y(px)와 높이(px) — 창 밖으로 나간 글자를 감추는 데 쓴다. */ + top: "data-yw-top", + height: "data-yw-height", +} as const; + +/** + * 세로 눈금값 목록 — 창(중심·폭)에 대해 1·2·5 계열의 딱 떨어지는 표고를 고른다. + * 라벨 글자(최대 13px)가 겹치지 않게 눈금 간격은 16px 이상, 표고 눈금은 1m 아래로 + * 내려가지 않는다(2026-08-23 사용자 지시). + * + * 그릴 때(이 파일)와 창이 바뀌어 눈금만 다시 만들 때(B05 `_UI_Profile_YWindow`)가 + * **같은 눈금**을 써야 해서 여기로 뺐다(2026-09-04). + */ +export function yWindowTickValues( + center: number, + span: number, + plotHeight: number, +): Array<{ value: number; label: string }> { + const step = Math.max(niceTickStep(span, 10, Math.floor(plotHeight / 16)), 1); + const decimals = Math.max(0, Math.ceil(-Math.log10(step) - 1e-9)); + const ticks: Array<{ value: number; label: string }> = []; + const topValue = center + span / 2; + for (let value = Math.ceil((center - span / 2) / step) * step; value <= topValue + 1e-9;) { + ticks.push({ value, label: `${value.toFixed(decimals)}m` }); + value += step; + } + return ticks; +} + function offsetStationLabel(chainageM: number, interval: number, stationOffset: number): string { const base = stationLabel(chainageM, interval); if (!stationOffset) return base; @@ -220,7 +275,7 @@ export function createLongitudinalProfile( ? plotHeight / yScaleOptions.pixelsPerMeter : // 자동 스케일(B05)은 최고·최저 표고가 위아래 축선에 딱 붙지 않게 10%씩 여유를 둔다 // (2026-08-23 사용자 지시). 공통 Y스케일(B06 yScaleOptions)은 그대로 둔다. - Math.max(rawMax - rawMin, 1) * 1.2; + Math.max(rawMax - rawMin, 1) * Y_WINDOW_SPAN_PAD; // 화면에 담기는 표고 폭 = 전범위 ÷ 배율. 창 중심은 그 폭의 비율만큼 위·아래로 옮긴다. const viewCenter = elevationMid + elevationOffsetRatio * (elevationSpan / exaggeration); const x = (chainage: number) => @@ -228,47 +283,22 @@ export function createLongitudinalProfile( const xInverse = inverseOf(x, maxChainage); const y = (elevation: number) => LONG_PAD.top + ((viewCenter + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight; + // 세로 창 갱신에 필요한 기준값을 SVG에 남긴다 — 그리는 함수의 인자를 늘리지 않으려는 것. + // 매핑은 1차식이다: y = zero − (표고 − center) × pxPerM. + svg.setAttribute(Y_WINDOW_ATTRS.center, String(viewCenter)); + svg.setAttribute(Y_WINDOW_ATTRS.pxPerM, String((exaggeration * plotHeight) / elevationSpan)); + svg.setAttribute(Y_WINDOW_ATTRS.zero, String(LONG_PAD.top + plotHeight / 2)); + svg.setAttribute(Y_WINDOW_ATTRS.top, String(LONG_PAD.top)); + svg.setAttribute(Y_WINDOW_ATTRS.height, String(plotHeight)); + // 고정 배율(B06 공통 Y스케일)·세로 과장이 걸린 그래프는 창을 옮기지 않는다. + if (!yScaleOptions && exaggeration === 1) svg.setAttribute(Y_WINDOW_ATTRS.auto, "1"); const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations); // Y축 눈금: 화면에 담긴 표고 범위를 10칸 안팎으로 나누되, 눈금값이 1·2·5 계열의 // 딱 떨어지는 수(범위가 아주 좁을 때만 소수)로 오게 한다 — 0.25 비율 고정 눈금은 // 표고가 소수로 나와 어느 지점인지 못 읽었다(2026-08-23 사용자 보고). - const yAxisTicks: Array<{ y: number; label: string }> = []; - const rawSpan = elevationSpan / exaggeration; - const rawTop = viewCenter + rawSpan / 2; - // 라벨 글자(최대 13px, sticky 축)가 겹치지 않게 눈금 간격은 16px 이상 띄운다. - // 표고 눈금은 1m 아래로 내려가지 않는다(2026-08-23 사용자 지시). - const tickStep = Math.max(niceTickStep(rawSpan, 10, Math.floor(plotHeight / 16)), 1); - const tickDecimals = Math.max(0, Math.ceil(-Math.log10(tickStep) - 1e-9)); - for ( - let value = Math.ceil((viewCenter - rawSpan / 2) / tickStep) * tickStep; - value <= rawTop + 1e-9; - value += tickStep - ) { - const gridY = LONG_PAD.top + ((rawTop - value) / rawSpan) * plotHeight; - const label = `${value.toFixed(tickDecimals)}m`; - yAxisTicks.push({ y: gridY, label }); - svg.append( - svgElement("line", { - x1: LONG_PAD.left, - y1: gridY, - x2: widthPx - LONG_PAD.right, - y2: gridY, - class: "b06-chart__grid", - }), - svgText(label, { - x: LONG_PAD.left - 9, - y: gridY + 4, - "text-anchor": "end", - class: "b06-chart__tick", - }), - ); - } - // sticky Y축 오버레이가 SVG와 동일한 눈금을 쓰도록 전달(B05 전용, B06은 콜백 없음). - onYAxis?.({ padLeft: LONG_PAD.left, ticks: yAxisTicks }); - - // 표시 표고창(세로 배율·중심 이동) 밖으로 나간 지반선·계획선은 그리지 않는다 — - // 안 자르면 축 라벨·측점 라벨 띠 위로 선이 흘러나온다(2026-09-04). + // 표시 표고창 밖으로 나간 것은 그리지 않는다 — 눈금선·음영·프로파일선이 함께 쓴다. + // (예전에는 이 자리가 아래에 있었다. 눈금선도 세로 변환을 타므로 여기로 올렸다.) const clipId = `b06-long-clip-${Math.random().toString(36).slice(2, 9)}`; const clipPath = svgElement("clipPath", { id: clipId }); clipPath.append( @@ -281,15 +311,60 @@ export function createLongitudinalProfile( ); const defs = svgElement("defs", {}); defs.append(clipPath); + // 세로 창이 바뀌면 좌표를 다시 만들지 않고 **이 겹의 변환만** 갈아 끼운다(2026-09-04 + // 사용자 확정 — 「실시간처럼 되려면 표현을 바꿔야 한다」). 자를 영역은 변환 **밖**이라 + // 창이 움직여도 플롯 테두리는 제자리다. 글자는 늘어나므로 이 겹에 넣지 않는다. + const gridLayer = svgElement("g", { "clip-path": `url(#${clipId})` }); + const gridWindow = svgElement("g", { class: `${Y_WINDOW_CLASS} ${Y_GRID_CLASS}` }); + gridLayer.append(gridWindow); + const tickLayer = svgElement("g", { class: Y_TICK_CLASS }); + svg.append(defs, gridLayer, tickLayer); + + const yAxisTicks: Array<{ y: number; label: string }> = []; + const rawSpan = elevationSpan / exaggeration; + const rawTop = viewCenter + rawSpan / 2; + // 라벨 글자(최대 13px, sticky 축)가 겹치지 않게 눈금 간격은 16px 이상 띄운다. + // 표고 눈금은 1m 아래로 내려가지 않는다(2026-08-23 사용자 지시). + for (const { value, label } of yWindowTickValues(viewCenter, rawSpan, plotHeight)) { + const gridY = LONG_PAD.top + ((rawTop - value) / rawSpan) * plotHeight; + yAxisTicks.push({ y: gridY, label }); + gridWindow.append( + svgElement("line", { + x1: LONG_PAD.left, + y1: gridY, + x2: widthPx - LONG_PAD.right, + y2: gridY, + class: "b06-chart__grid", + }), + ); + tickLayer.append( + svgText(label, { + x: LONG_PAD.left - 9, + y: gridY + 4, + "text-anchor": "end", + class: "b06-chart__tick", + // 세로 창 변환이 글자를 늘리지 않고 **자리만** 옮기도록 그릴 때의 y를 남긴다. + // 남기는 값은 **눈금선의 y** — 글자를 내리는 4px 은 변환 뒤에 더해야 배율이 + // 커져도 선과 글자 사이가 벌어지지 않는다(2026-09-04 실측 6.4px 어긋남). + [Y_WINDOW_BAKED_ATTR]: String(gridY), + }), + ); + } + // sticky Y축 오버레이가 SVG와 동일한 눈금을 쓰도록 전달(B05 전용, B06은 콜백 없음). + onYAxis?.({ padLeft: LONG_PAD.left, ticks: yAxisTicks }); + const bandLayer = svgElement("g", { "clip-path": `url(#${clipId})` }); - svg.append(defs, bandLayer); + // 음영은 세로에 딸린 것 — 변환 겹 안. 균형 구역 경계선은 플롯 높이 전체라 밖에 둔다. + const bandWindow = svgElement("g", { class: Y_WINDOW_CLASS }); + bandLayer.append(bandWindow); + svg.append(bandLayer); // 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다. const toY = (elevation: number) => y(viewCenter + (elevation - viewCenter) * exaggeration); for (const profile of designProfiles) { if (profile.samples.length < 2) continue; appendCutFillBands( - bandLayer, + bandWindow, profile, x, (index) => toY(profile.samples[index].elevation_m), @@ -380,9 +455,11 @@ export function createLongitudinalProfile( }) .join(" "); const lineLayer = svgElement("g", { "clip-path": `url(#${clipId})` }); + const lineWindow = svgElement("g", { class: Y_WINDOW_CLASS }); + lineLayer.append(lineWindow); for (const profile of designProfiles) { if (profile.samples.length < 2) continue; - lineLayer.append( + lineWindow.append( svgElement("polyline", { points: profile.samples .map((sample) => `${x(sample.chainage_m)},${toY(sample.elevation_m)}`) @@ -391,7 +468,7 @@ export function createLongitudinalProfile( }), ); } - lineLayer.append(svgElement("polyline", { points, class: "b06-chart__profile" })); + lineWindow.append(svgElement("polyline", { points, class: "b06-chart__profile" })); svg.append( lineLayer, svgElement("line", { diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 2cf43267..2a4a874a 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -306,6 +306,12 @@ fill: var(--color-surface-raised); } +/* 세로 창을 변환으로 옮기는 겹(2026-09-04) — 세로로 눌리거나 늘어나도 선 굵기·점선 간격은 + 그대로여야 한다. 변환이 걸려 있을 때만 적용해 B06 종단·횡단은 예전 그대로 그려진다. */ +.b06-chart__ywindow[transform] * { + vector-effect: non-scaling-stroke; +} + .b06-chart__grid { stroke: var(--color-border); stroke-width: 1;