feat(B05): 종단도 줌·Y 표시 표고창 조작구

- B05_Profile_UI_Profile_Zoom.ts 신규 — 배율 상태(가로 폭 배수·세로 배율·창 중심)와
  버튼 7개(+ − ⇕+ ⇕− ▲ ▼ ⤢). 자리는 요약줄 오른쪽 끝(그래프 우측 상단),
  설명은 툴팁, 클릭은 stopPropagation 으로 측점 선택에 번지지 않게 함
- X 줌은 폭 배수 — computeProfileLayout 에 zoomX 를 넘겨 측점 간격·폭맞춤 폭에 함께 곱함.
  그래프·테이블·편집 버튼층·구조물 레인이 같은 매핑을 쓰므로 넷의 x 정렬이 유지됨
- Y 는 표시 표고창(제안 A) — createLongitudinalProfile 에 elevationOffsetRatio 인자 추가,
  창 높이 = 전범위 ÷ 배율, ▲▼ 가 창 높이의 10%씩 중심 이동. 세로 스크롤 없음
- 지반선·계획선·절성토 음영을 플롯 사각형 clipPath 안으로 옮김 — 배율을 올려도
  선이 축·측점 라벨 위로 흘러나오지 않음 (B06 은 배율 1·오프셋 0 이라 표시 불변)
- 요약줄 렌더러에 trailing 자리 추가, .b05-profile__zoom* CSS 추가

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 07:23:34 +09:00
co-authored by Claude Opus 5
parent ecb3bcb9ce
commit 2d49c59342
7 changed files with 197 additions and 15 deletions
+37 -10
View File
@@ -50,7 +50,7 @@ export function longitudinalMinimumWidth(
* 부호가 바뀌는 지점에서 끊어 절토와 성토가 섞이지 않게 한다.
*/
function appendCutFillBands(
svg: SVGSVGElement,
svg: SVGElement,
profile: DesignProfile,
x: (chainage: number) => number,
planY: (index: number) => number,
@@ -168,6 +168,12 @@ export function createLongitudinalProfile(
* LONG_PAD.bottom을 직접 키우면 B06 종단면도 여백까지 변하므로 호출부 옵션으로 뒀다.
*/
bottomInsetPx = 0,
/**
* 세로 표시창의 중심을 위·아래로 옮기는 몫 — **창 높이 대비 비율**(0 = 현행 가운데,
* +0.1 = 창 높이의 10%만큼 위쪽을 본다). B05 종단도의 Y 레인지 조작구가 쓴다.
* 표고(m)가 아니라 비율이라 호출부가 노선 표고 범위를 몰라도 된다(2026-09-04).
*/
elevationOffsetRatio = 0,
): HTMLElement {
const samples = data.samples.filter(validElevation);
if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal"));
@@ -207,11 +213,13 @@ export function createLongitudinalProfile(
: // 자동 스케일(B05)은 최고·최저 표고가 위아래 축선에 딱 붙지 않게 10%씩 여유를 둔다
// (2026-08-23 사용자 지시). 공통 Y스케일(B06 yScaleOptions)은 그대로 둔다.
Math.max(rawMax - rawMin, 1) * 1.2;
// 화면에 담기는 표고 폭 = 전범위 ÷ 배율. 창 중심은 그 폭의 비율만큼 위·아래로 옮긴다.
const viewCenter = elevationMid + elevationOffsetRatio * (elevationSpan / exaggeration);
const x = (chainage: number) =>
LONG_PAD.left + originOffsetPx + (chainage / maxChainage) * plotWidth;
const xInverse = inverseOf(x, maxChainage);
const y = (elevation: number) =>
LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight;
LONG_PAD.top + ((viewCenter + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight;
const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations);
// Y축 눈금: 화면에 담긴 표고 범위를 10칸 안팎으로 나누되, 눈금값이 1·2·5 계열의
@@ -219,13 +227,13 @@ export function createLongitudinalProfile(
// 표고가 소수로 나와 어느 지점인지 못 읽었다(2026-08-23 사용자 보고).
const yAxisTicks: Array<{ y: number; label: string }> = [];
const rawSpan = elevationSpan / exaggeration;
const rawTop = elevationMid + rawSpan / 2;
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((elevationMid - rawSpan / 2) / tickStep) * tickStep;
let value = Math.ceil((viewCenter - rawSpan / 2) / tickStep) * tickStep;
value <= rawTop + 1e-9;
value += tickStep
) {
@@ -251,12 +259,29 @@ export function createLongitudinalProfile(
// 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(
svgElement("rect", {
x: LONG_PAD.left,
y: LONG_PAD.top,
width: Math.max(0, widthPx - LONG_PAD.left - LONG_PAD.right),
height: Math.max(0, plotHeight),
}),
);
const defs = svgElement("defs", {});
defs.append(clipPath);
const bandLayer = svgElement("g", { "clip-path": `url(#${clipId})` });
svg.append(defs, bandLayer);
// 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다.
const toY = (elevation: number) => y(elevationMid + (elevation - elevationMid) * exaggeration);
const toY = (elevation: number) => y(viewCenter + (elevation - viewCenter) * exaggeration);
for (const profile of designProfiles) {
if (profile.samples.length < 2) continue;
appendCutFillBands(
svg,
bandLayer,
profile,
x,
(index) => toY(profile.samples[index].elevation_m),
@@ -264,7 +289,7 @@ export function createLongitudinalProfile(
);
if (profile.balance_segments.length > 1) {
for (const segment of profile.balance_segments.slice(1)) {
svg.append(
bandLayer.append(
svgElement("line", {
x1: x(segment.start_chainage_m),
y1: LONG_PAD.top,
@@ -342,13 +367,14 @@ export function createLongitudinalProfile(
const points = samples
.map((sample) => {
const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration;
const elevated = viewCenter + (sample.elevation_m - viewCenter) * exaggeration;
return `${x(sample.chainage_m ?? 0)},${y(elevated)}`;
})
.join(" ");
const lineLayer = svgElement("g", { "clip-path": `url(#${clipId})` });
for (const profile of designProfiles) {
if (profile.samples.length < 2) continue;
svg.append(
lineLayer.append(
svgElement("polyline", {
points: profile.samples
.map((sample) => `${x(sample.chainage_m)},${toY(sample.elevation_m)}`)
@@ -357,8 +383,9 @@ export function createLongitudinalProfile(
}),
);
}
lineLayer.append(svgElement("polyline", { points, class: "b06-chart__profile" }));
svg.append(
svgElement("polyline", { points, class: "b06-chart__profile" }),
lineLayer,
svgElement("line", {
x1: LONG_PAD.left,
y1: heightPx - padBottom,