perf(B05): 종단 세로 맞춤을 다시 그리기에서 변환으로 — 스크롤 중 실시간
사용자 지시(2026-09-04) — 「화면을 리프레시하는 느낌. 가장 강한 지연은 멈춘 뒤 끊김. 실시간처럼 되려면 근본적으로 표현을 바꿔야 하지 않나」. 확정: 변환 방식. 원인 — 세로 위치가 선 하나하나의 좌표에 박혀 있어, 세로 창이 바뀌면 그래프를 통째로 다시 만들 수밖에 없었음(SVG 요소 457개, 실측 34ms = 60Hz 두 프레임 누락). - 세로에 딸린 도형(눈금선·절성토 음영·지반선·계획선)을 `b06-chart__ywindow` 한 겹으로 묶고, 세로 창을 `translate`+`scale` 한 줄로 표현. 두 창의 대응이 1차식이라 정확히 겹침. - 자를 영역(clip)은 변환 밖, 글자는 변환 밖에서 자리만 옮김(늘어남 방지). 글자 내림 4px 은 변환 뒤에 더함 — 배율과 함께 커지면 선에서 떨어짐(실측 6.4px). - 선 굵기·점선 간격은 `vector-effect: non-scaling-stroke`(변환이 걸렸을 때만 적용해 B06 은 예전 그대로). - 눈금 간격이 어긋나면(배율 0.8~1.25 밖) **눈금층만** 다시 만듦(요소 20개 안팎). 눈금은 그릴 때의 좌표계에 놓아 같은 변환이 그대로 먹음. - 눈금 계산을 `yWindowTickValues` 로 빼 그리는 쪽과 갱신 쪽이 같은 눈금을 씀. - 패널은 스크롤마다 변환 갱신만 부름. 전체 재구성은 눈금으로도 못 살릴 때만 남김. 자체검증(공용 브라우저 임시 탭, 휠 20칸 4,800px): ① 전체 재구성 **0회**(고침 전 다회) ② 최대 프레임 **59.9 → 20.0ms**, 20ms 초과 0건 ③ 눈금 글자와 눈금선 간격 6.4 → **0.5px** ④ 지반선의 한 점을 변환으로 놓은 자리와 눈금이 말하는 자리가 **0.000px** 일치(배율 2.715 상태) ⑤ B06 종단은 변환 겹 3개 모두 변환 없음(항등) — 화면 불변 ⑥ 전체 시험 381 통과·17 건너뜀. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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과 같은 규칙).
|
||||
|
||||
@@ -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<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),
|
||||
};
|
||||
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>(".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<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>(`.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);
|
||||
}
|
||||
@@ -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", {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user