feat(B05/B06): 하단 간격·접힘 거터·B06 sticky Y축·배관 곡선 상시 표기(27~30)

- 27: 종단 X라벨↔버튼 간격 70%로(bottomInset 19→15)
- 28: 사이드 패널 접힘 시 본문 24px 거터 — 손잡이가 Y축 라벨을 덮지 않음(B04~B07 공용)
- 29: B06 종단·유토곡선 가로 스크롤 고정 Y축 — buildStickyYAxis 재사용, yaxis CSS
  정의처를 MassHaul css로 이동(B05·B06 공용). 앵커를 첫 자식으로 넣고 유토축 inner를
  종단 높이만큼 내려 자기 구간만 덮음. 스크롤 600px 블리드 테스트 통과
- 30: 배관 측점 곡선 L·R 항상 표기(R 필수), 이웃 곡선 셀 근접 시 배관 셀만 우측 비킴,
  선택 오버레이는 한 열 원위치 유지

Playwright: sticky 앵커 2개(x 고정), 거터 24px, gap 7px, 곡선 셀 8, 오류 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 19:53:42 +09:00
co-authored by Claude Opus 5
parent 8c95f8e7f2
commit 6bcd6d1209
6 changed files with 97 additions and 39 deletions
@@ -561,8 +561,9 @@ export function createRouteProfilePanel(
if (target) callbacks?.onStructureMove?.(target.chainage_m, toChainage, target);
},
// 계획고 편집 ▼ 버튼(바닥 2px + 높이 17px)과 측점 라벨이 겹치지 않게 X축·라벨을
// 버튼 높이만큼 올린다(2026-08-04 사용자 지시). B06은 편집 버튼이 없어 0 유지.
19,
// 올린다. 19px는 라벨-버튼 사이가 너무 벌어져 70% 수준(15px)으로 줄였다
// (2026-08-04 사용자 지시). B06은 편집 버튼이 없어 0 유지.
15,
),
);
// 구조물 우클릭 메뉴 — 측점선 위면 삭제, 빈 자리면 배관 추가. 이동도 여기(측점선 끌기)서 한다.
@@ -18,7 +18,11 @@ import type {
ProfileAlignment,
} from "./B05_wf2_Route_UI_Profile_Alignment";
import { stationLabel } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common";
import { irregularStationId, type IrregularStation } from "./B05_wf2_Route_UI_IrregularStations";
import {
irregularStationId,
isPipeStation,
type IrregularStation,
} from "./B05_wf2_Route_UI_IrregularStations";
export interface ProfileTableOptions {
alignment: ProfileAlignment;
@@ -297,6 +301,8 @@ function buildCurveRows(
centers: number[],
/** 구조물 측점 chainage 키(소수 3자리) — 이 열의 곡선 입력은 기본 표기하지 않는다. */
structureStationKeys: ReadonlySet<string>,
/** 배관 구조물 chainage 키 — R이 필수라 곡선 L·R은 **항상** 표기한다(2026-08-04 지시). */
pipeStationKeys: ReadonlySet<string>,
): HTMLElement[] {
const { alignment, onCurveRadiusChange } = options;
const lengthRow = createRow(
@@ -313,13 +319,25 @@ function buildCurveRows(
alignment.curves.map((curve) => [curve.chainage_m.toFixed(3), curve]),
);
// 곡선 값이 든 셀들의 오른쪽 끝 — 배관 곡선 셀이 이웃 곡선 구간과 근접해 겹치면
// 배관 쪽만 오른쪽으로 비켜 세운다(선택 하이라이트 오버레이는 원위치·한 열 그대로).
let lastContentRight = Number.NEGATIVE_INFINITY;
alignment.stations.forEach((station, index) => {
const lengthCell = element("span", "b05-profile-table__curve");
const radiusCell = element("span", "b05-profile-table__curve");
const key = station.chainage_m.toFixed(3);
const isPipe = pipeStationKeys.has(key);
// 구조물 측점 열은 곡선 입력도 기본 표기하지 않는다(빈 칸으로 격자만 유지).
const curve = structureStationKeys.has(station.chainage_m.toFixed(3))
? undefined
: curveByChainage.get(station.chainage_m.toFixed(3));
// 예외: 배관 측점은 R이 필수 입력이라 곡선 L·R을 항상 보인다.
const curve = structureStationKeys.has(key) && !isPipe ? undefined : curveByChainage.get(key);
let center = centers[index];
if (curve) {
const half = options.cellWidth / 2;
if (isPipe && center - half < lastContentRight + 2) {
center = lastContentRight + 2 + half;
}
lastContentRight = center + half;
}
if (curve) {
// L·R은 L = R × |대수차| 로 연동된다. 둘 다 입력 가능하고, 어느 쪽을 고쳐도 R로 환산해
// 같은 파이프라인(onCurveRadiusChange)을 태운다. 재계산 후 마지막에 입력한 값이 반영된다.
@@ -343,8 +361,8 @@ function buildCurveRows(
if (curve.omitted) cell.classList.add("is-omitted");
});
}
placeCell(lengthRow, centers[index], lengthCell);
placeCell(radiusRow, centers[index], radiusCell);
placeCell(lengthRow, center, lengthCell);
placeCell(radiusRow, center, radiusCell);
});
function curveInput(
@@ -530,6 +548,12 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
const structureStationKeys = new Set(
(options.irregularStations ?? []).map((entry) => entry.chainage_m.toFixed(3)),
);
// 배관 구조물은 R이 필수라 곡선 행만은 기본 표기 예외다(2026-08-04 사용자 지시).
const pipeStationKeys = new Set(
(options.irregularStations ?? [])
.filter((entry) => isPipeStation(entry))
.map((entry) => entry.chainage_m.toFixed(3)),
);
const selectedIrregularEntry = options.irregularStations?.find(
(entry) => irregularStationId(entry.id) === options.selectedStationId,
);
@@ -560,7 +584,7 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
});
table.append(row);
});
table.append(...buildCurveRows(options, centers, structureStationKeys));
table.append(...buildCurveRows(options, centers, structureStationKeys, pipeStationKeys));
// 선택된 측점(규칙·비정규 공용)을 **값 열 오버레이**로 강조한다.
// 규칙 측점은 종점 이동을 반영한 `centers[index]`, 비정규 측점은 `x(chainage)`를 중심으로 쓴다.
+2 -30
View File
@@ -120,36 +120,8 @@
min-height: 0;
}
/* 가로 스크롤에도 고정되는 Y축(표고 눈금). 0크기 sticky 앵커에 축을 절대배치해 레이아웃 불변. */
.b05-profile__yaxis {
position: sticky;
left: 0;
z-index: 3;
width: 0;
height: 0;
}
.b05-profile__yaxis-inner {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
/* 자기 그래프 영역 밖으로 눈금이 새면 아래 그래프의 축처럼 읽힌다. */
overflow: hidden;
/* SVG 배경(b06-chart__bg)과 같은 색으로 스크롤되는 그래프를 가려 값 누출을 막는다. */
background: var(--color-surface-raised);
border-right: 1px solid var(--color-text-secondary);
pointer-events: none;
}
.b05-profile__yaxis-tick {
position: absolute;
right: 6px;
color: var(--color-text-secondary);
font-size: 10px;
white-space: nowrap;
transform: translateY(-50%);
}
/* 가로 스크롤 고정 Y축(.b05-profile__yaxis*)의 정의처는
B05_wf2_Route_UI_Style_MassHaul.css다 — buildStickyYAxis를 쓰는 B05·B06이 함께 얻는다. */
.b05-route-profile.is-collapsed .b05-route-profile__body-wrap,
.b05-route-profile.is-collapsed .b05-route-profile__body,
@@ -6,6 +6,39 @@
* 여기서는 종단면 패널 안에서 그것들이 놓이는 **자리** 잡는다.
* ========================================================================== */
/* 가로 스크롤에도 고정되는 Y축(눈금 오버레이) buildStickyYAxis( 파일을 import하는
_UI_Profile_MassHaul.ts) 만든다. B05 종단·유토곡선과 B06 종단·유토곡선이 함께 쓰므로
여기가 정의처다. 0크기 sticky 앵커에 축을 절대배치해 레이아웃 불변. */
.b05-profile__yaxis {
position: sticky;
left: 0;
z-index: 3;
width: 0;
height: 0;
}
.b05-profile__yaxis-inner {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
/* 자기 그래프 영역 밖으로 눈금이 새면 아래 그래프의 축처럼 읽힌다. */
overflow: hidden;
/* SVG 배경(b06-chart__bg)과 같은 색으로 스크롤되는 그래프를 가려 값 누출을 막는다. */
background: var(--color-surface-raised);
border-right: 1px solid var(--color-text-secondary);
pointer-events: none;
}
.b05-profile__yaxis-tick {
position: absolute;
right: 6px;
color: var(--color-text-secondary);
font-size: 10px;
white-space: nowrap;
transform: translateY(-50%);
}
/* 본문 칸을 세로로 쌓아 그래프 스크롤러 아래에 손잡이가 붙게 한다.
오버레이의 절대배치 기준이기도 하다. */
.b05-route-profile__body-wrap {
@@ -15,6 +15,8 @@
import { createPanelResizer } from "@ui/ui_template_resizer";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
// 가로 스크롤 고정 Y축 — B05와 같은 오버레이를 쓴다(정의처: B05 MassHaul 모듈 + 그 CSS).
import { buildStickyYAxis } from "../B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul";
import type {
CrossSection,
EarthworkConversion,
@@ -470,6 +472,10 @@ export function createSectionView(
// 종단도 높이가 줄면 Y스케일도 그 높이로 다시 잡아야 표고가 잘리지 않는다.
cachedYScale = calculateYScale(detail, heights.long);
// Y축 눈금을 렌더러에서 받아 가로 스크롤 고정 오버레이로 얹는다(2026-08-04 사용자
// 지시 — 테이블 행 이름표처럼 스크롤해도 계속 보이게, B05와 같은 방식).
let longAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
const nodes: Element[] = [
unwrapChart(
createLongitudinalProfile(
@@ -483,6 +489,10 @@ export function createSectionView(
heights.long,
minWidth,
detail.longitudinal.design_profiles ?? [],
0,
(axis) => {
longAxis = axis;
},
),
),
];
@@ -522,10 +532,22 @@ export function createSectionView(
minWidth,
(stationId) => selectStation(stationId, true),
haulPlan,
(axis) => {
massAxis = axis;
},
),
);
}
chartWrap.replaceChildren(...nodes);
// 고정 Y축 오버레이 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너
// 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다). 유토곡선 축은 종단 높이만큼
// 안쪽(inner)을 내려 자기 그래프 구간만 덮는다 — 불투명 배경이 지나간 눈금 노출을 막는다.
if (massAxis) {
const overlay = buildStickyYAxis(massAxis, heights.mass);
(overlay.firstElementChild as HTMLElement).style.top = `${heights.long}px`;
chartWrap.prepend(overlay);
}
if (longAxis) chartWrap.prepend(buildStickyYAxis(longAxis, heights.long));
panel.querySelector(".b06-masshaul__legend")?.remove();
panel.querySelector(".b06-masshaul__summary")?.remove();
@@ -117,6 +117,12 @@
padding-left: min(var(--wf-left-panel-width), calc(100vw - var(--spacing-48)));
}
/* 접힘 시에는 남은 손잡이(24px) 폭만큼 거터를 남긴다 손잡이가 그래프 Y축 라벨
본문 글자를 덮지 않게(2026-08-04 사용자 보고: 531.4m 라벨 가려짐). */
.ui-workflow-layout:not(.is-options-open) .ui-workflow-layout__body {
padding-left: var(--spacing-24);
}
@media (max-width: 860px) {
.ui-workflow-layout.is-options-open .ui-workflow-layout__body {
padding-left: 0;