fix(ui): 유토곡선 상단 여유·사이드 컨테이너 외곽선·패널 리사이즈 부드럽게

- 유토곡선 SVG 상단 여백 padTop 옵션 신설(공용 렌더러), B05는 40px —
  범례·기준 버튼 오버레이(top 34px)와 커브 겹침 해소. B06은 기존 10px 유지
- 사이드 패널 컨테이너 공통 클래스(ui-sidebar-section): 외곽선을 본문 보조색
  45% 혼합으로 진하게 — 두 테마 모두 흐려 안 보이던 문제. B04/B05/B06 적용
- 하단 패널 리사이즈 끊김 해소: 끌리는 동안 프레임당 경량 높이 동기화
  (캐스케이드 계산 + 캔버스/차트 높이만), 무거운 차트·테이블 재구성은
  손 뗀 뒤 120ms 디바운스로 1회. 추가 리소스 없음 — 프레임당 비용 감소

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 19:51:10 +09:00
co-authored by Claude Fable 5
parent 69aed9d26c
commit 81825413ef
6 changed files with 108 additions and 60 deletions
+3 -2
View File
@@ -82,7 +82,8 @@ function buildSelectField(
/** 제목이 달린 사이드 패널 컨테이너. */
function buildGroup(title: string, ...children: HTMLElement[]): HTMLElement {
const root = document.createElement("section");
root.className = "b04-surface__group";
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
root.className = "b04-surface__group ui-sidebar-section";
const heading = document.createElement("h3");
heading.className = "b04-surface__panel-title";
heading.textContent = title;
@@ -170,7 +171,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
});
const inputGroup = document.createElement("section");
inputGroup.className = "b04-surface__group";
inputGroup.className = "b04-surface__group ui-sidebar-section";
const inputTitle = document.createElement("h3");
inputTitle.className = "b04-surface__panel-title";
inputTitle.textContent = L("B04_Surface_InputFiles");
+2 -1
View File
@@ -78,7 +78,8 @@ function L(key: keyof typeof ui_locales): string {
function section(title: string): { root: HTMLElement; body: HTMLElement } {
const root = document.createElement("section");
root.className = "b05-route__panel-section ui-collapsible";
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
root.className = "b05-route__panel-section ui-collapsible ui-sidebar-section";
const heading = document.createElement("h3");
heading.className = "ui-collapsible__title";
heading.textContent = title;
+82 -47
View File
@@ -316,6 +316,7 @@ export function createRouteProfilePanel(
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
let resizeTimer = 0;
let redrawPending = false;
let lightSyncPending = false;
/** 횡단 설계 프리뷰 디바운스 타이머와 최신 요청 번호(늦게 온 응답 버리기용). */
let crossPreviewTimer = 0;
let crossPreviewSeq = 0;
@@ -466,48 +467,17 @@ export function createRouteProfilePanel(
}, CROSS_PREVIEW_DEBOUNCE_MS);
}
function draw(): void {
if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return;
lastWidth = body.clientWidth;
lastHeight = body.clientHeight;
// 편집할 때마다 본문을 갈아끼우므로 보고 있던 가로 위치를 잃지 않게 되돌린다.
const scrollLeft = body.scrollLeft;
renderBalance();
const longitudinal = detail.longitudinal;
const availableWidth = Math.max(1, body.clientWidth - 15);
// 계획선(편집 가능) 상태에서는 측점 간격 기본값(기준×1.5)으로 펼치되 화면이 넓으면
// 폭맞춤으로 늘린다. 그 외(구버전·플레인 뷰)는 예전처럼 화면 폭에 맞춰 펼친다.
const stationIntervalM = stationInterval ?? alignment?.policy.station_interval_m;
const layout =
alignment && stationIntervalM
? computeProfileLayout(longitudinal, stationIntervalM, availableWidth)
: {
width: Math.max(
availableWidth,
longitudinalMinimumWidth(longitudinal, stationInterval),
),
originOffset: 0,
cellWidth: STATION_SPACING_PX - CELL_GAP_PX,
};
const { width, originOffset } = layout;
const canvas = document.createElement("div");
canvas.className = "b05-profile__canvas";
canvas.style.width = `${width}px`;
const x = chainageMapper(longitudinal, width, originOffset);
// 그래프 40% : 테이블 60% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다).
// 가로 스크롤바를 `overflow-x: scroll`로 항상 띄우므로 clientHeight에서 이미 빠져 있다.
/**
* 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지
* 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면
* 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기
* 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리).
*
* draw()(전체 재구성)와 리사이즈 중 경량 동기화가 **같은 계산**을 쓴다 — 끌 때는
* 이 함수만 프레임마다 돌리고, 무거운 차트·테이블 재구성은 손을 뗀 뒤 한 번만 한다.
*/
function applyHeightCascade(): { chartHeight: number } {
const available = Math.max(120, body.clientHeight);
// 서브패널은 종단도를 **밀고 올라온다**(2026-08-05 사용자 확정 — 덮지 않는다).
// · 둘 다 접힘: 종단도가 전체 높이(손잡이 띠 24px만 남김).
// · 하나만 펼침: 그 패널 높이만큼 종단도가 줄어든다. 테이블 높이는 고정.
// · 둘 다 펼침: 위→아래 = 종단도 → 테이블 → 유토곡선. 유토곡선 높이를 끌어 늘리면
// 테이블은 그대로 두고 종단도가 그만큼 줄어든다(리사이저 onResize → draw 재계산).
// 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지
// 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면
// 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기
// 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(아래 deficit 처리).
const massOpen = !massHaul.overlay.hidden;
const tableOpen = tableOverlay.isOpen();
let massHeight = massOpen ? massHaul.overlay.offsetHeight : 0;
@@ -546,10 +516,63 @@ export function createRouteProfilePanel(
// ResizeObserver가 새 높이로 draw를 다시 부른다 — 이번 프레임은 그대로 마저 그린다.
}
}
const chartHeight = Math.max(
MIN_CHART_HEIGHT,
available - massHeight - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX,
);
return {
chartHeight: Math.max(
MIN_CHART_HEIGHT,
available - massHeight - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX,
),
};
}
/** 리사이즈 중 프레임당 경량 동기화 — 차트·테이블 재구성 없이 높이만 맞춰 끊김을 없앤다.
* SVG는 잠깐 세로 스케일되지만, 손을 떼면(디바운스) 전체 재그리기가 정확히 다시 그린다. */
function syncHeightsLight(): void {
if (!detail) return;
const { chartHeight } = applyHeightCascade();
const canvas = body.firstElementChild as HTMLElement | null;
if (!canvas || !canvas.classList.contains("b05-profile__canvas")) return;
canvas.style.height = `${chartHeight}px`;
const chartWrap = canvas.querySelector<HTMLElement>(".b05-profile__chart");
if (chartWrap) {
chartWrap.style.height = `${chartHeight}px`;
const svg = chartWrap.querySelector("svg");
if (svg) svg.style.height = `${chartHeight}px`;
}
}
function draw(): void {
if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return;
lastWidth = body.clientWidth;
lastHeight = body.clientHeight;
// 편집할 때마다 본문을 갈아끼우므로 보고 있던 가로 위치를 잃지 않게 되돌린다.
const scrollLeft = body.scrollLeft;
renderBalance();
const longitudinal = detail.longitudinal;
const availableWidth = Math.max(1, body.clientWidth - 15);
// 계획선(편집 가능) 상태에서는 측점 간격 기본값(기준×1.5)으로 펼치되 화면이 넓으면
// 폭맞춤으로 늘린다. 그 외(구버전·플레인 뷰)는 예전처럼 화면 폭에 맞춰 펼친다.
const stationIntervalM = stationInterval ?? alignment?.policy.station_interval_m;
const layout =
alignment && stationIntervalM
? computeProfileLayout(longitudinal, stationIntervalM, availableWidth)
: {
width: Math.max(
availableWidth,
longitudinalMinimumWidth(longitudinal, stationInterval),
),
originOffset: 0,
cellWidth: STATION_SPACING_PX - CELL_GAP_PX,
};
const { width, originOffset } = layout;
const canvas = document.createElement("div");
canvas.className = "b05-profile__canvas";
canvas.style.width = `${width}px`;
const x = chainageMapper(longitudinal, width, originOffset);
// 그래프 40% : 테이블 60% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다).
// 가로 스크롤바를 `overflow-x: scroll`로 항상 띄우므로 clientHeight에서 이미 빠져 있다.
const { chartHeight } = applyHeightCascade();
const tableHeight = tableOverlay.contentHeight();
const table =
@@ -697,8 +720,11 @@ export function createRouteProfilePanel(
// 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다.
padLeft: LONG_PAD.left + originOffset,
padRight: LONG_PAD.right + originOffset,
// 축 선·눈금은 위 종단 그래프의 축과 같은 자리(62px)에 — 축이 두 개로 보이지 않게.
// 축 선·눈금은 위 종단 그래프의 축과 같은 자리에 — 축이 두 개로 보이지 않게.
axisX: LONG_PAD.left,
// 범례·기준 버튼 오버레이(top 34px)가 곡선 위에 떠서 그만큼 상단 여유를 준다
// (2026-08-05 사용자 보고: 버튼과 커브 겹침).
padTop: 40,
},
stationInterval: stationIntervalM ?? 1,
widthPx: width,
@@ -734,8 +760,17 @@ export function createRouteProfilePanel(
(Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1)
)
return;
// 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번.
// (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고)
if (!lightSyncPending) {
lightSyncPending = true;
requestAnimationFrame(() => {
lightSyncPending = false;
syncHeightsLight();
});
}
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(draw, 150);
resizeTimer = window.setTimeout(draw, 120);
});
resizeObserver.observe(body);
@@ -56,7 +56,8 @@ function L(key: keyof typeof ui_locales): string {
// B05 사이드패널 접기 컨테이너 템플릿 재사용(N-4-1): 제목 행 클릭 토글, 우측 ▾/▸ 캐럿.
function buildGroup(legend: string, collapsed = false): HTMLElement {
const group = document.createElement("section");
group.className = `b06-profile__group ui-collapsible${collapsed ? " is-collapsed" : ""}`;
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
group.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
const legendElement = document.createElement("h3");
legendElement.className = "b06-profile__group-legend ui-collapsible__title";
legendElement.textContent = legend;
+13 -9
View File
@@ -41,6 +41,8 @@ export interface MassHaulAxis {
* (2026-08-03 사용자 보고). 축은 여기(종단과 같은 자리)에, 데이터는 padLeft에 둔다.
*/
axisX?: number;
/** 그래프 위 여백(px). 생략하면 기본(10). B05는 범례 오버레이만큼 크게 준다. */
padTop?: number;
}
/** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */
@@ -313,7 +315,9 @@ export function createMassHaulChart(
const maxChainage = Math.max(axis.maxChainageM, 1);
const axisX = axis.axisX ?? axis.padLeft;
const plotWidth = widthPx - axis.padLeft - axis.padRight;
const plotHeight = heightPx - MASS_PAD_TOP - MASS_PAD_BOTTOM;
// B05 오버레이는 범례 버튼이 그래프 위(top 34px)에 떠서 상단 여유를 더 받는다.
const padTop = axis.padTop ?? MASS_PAD_TOP;
const plotHeight = heightPx - padTop - MASS_PAD_BOTTOM;
const x = (chainage: number) => axis.padLeft + (chainage / maxChainage) * plotWidth;
// 기준 버튼이 라디오가 되면서(택1 표시) Y 범위도 **표시 중인 곡선**으로 잡는다 —
@@ -321,13 +325,13 @@ export function createMassHaulChart(
const rangeSource = series.filter((entry) => visibleKeys.has(entry.key));
const { min, max } = volumeRange(rangeSource.length ? rangeSource : series);
const span = Math.max(max - min, 1e-6);
const y = (volume: number) => MASS_PAD_TOP + ((max - volume) / span) * plotHeight;
const y = (volume: number) => padTop + ((max - volume) / span) * plotHeight;
// 패널을 줄이면 그래프 몫이 60px대까지 내려간다 — 눈금 5개를 그대로 두면 라벨이 서로 겹친다.
const tickRatios = plotHeight < 110 ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1];
const axisTicks: Array<{ y: number; label: string }> = [];
for (const ratio of tickRatios) {
const gridY = MASS_PAD_TOP + ratio * plotHeight;
const gridY = padTop + ratio * plotHeight;
const value = max - ratio * span;
// 고정 축 라벨에는 단위를 붙인다 — 바로 위 종단 그래프의 표고축(`…m`)과 같은 자리에
// 서므로, 단위가 없으면 어느 축인지 읽히지 않는다(2026-08-03 사용자 지적).
@@ -404,14 +408,14 @@ export function createMassHaulChart(
marker.append(
svgElement("line", {
x1: stationX,
y1: MASS_PAD_TOP,
y1: padTop,
x2: stationX,
y2: heightPx - MASS_PAD_BOTTOM,
class: "b06-chart__station-hit",
}),
svgElement("line", {
x1: stationX,
y1: MASS_PAD_TOP,
y1: padTop,
x2: stationX,
y2: heightPx - MASS_PAD_BOTTOM,
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`,
@@ -458,7 +462,7 @@ export function createMassHaulChart(
const labelWidth = textWidth(text) + 10;
// 종점은 오른쪽 끝이라 라벨을 왼쪽으로 눕히고, 위아래는 플롯 안으로 클램프한다.
const labelX = Math.max(epX - 8, axis.padLeft + labelWidth);
const labelY = Math.min(Math.max(epY - 8, MASS_PAD_TOP + 12), heightPx - MASS_PAD_BOTTOM - 4);
const labelY = Math.min(Math.max(epY - 8, padTop + 12), heightPx - MASS_PAD_BOTTOM - 4);
svg.append(
svgElement("circle", { cx: epX, cy: epY, r: 3.5, class: "b06-masshaul__ep-dot" }),
svgElement("rect", {
@@ -487,7 +491,7 @@ export function createMassHaulChart(
y,
left: axis.padLeft,
right: widthPx - axis.padRight,
top: MASS_PAD_TOP,
top: padTop,
bottom: heightPx - MASS_PAD_BOTTOM,
curveYAt,
stationInterval,
@@ -511,7 +515,7 @@ export function createMassHaulChart(
anchorY: y(focus.cumulative_volume_m3),
left: axis.padLeft,
right: widthPx - axis.padRight,
top: MASS_PAD_TOP,
top: padTop,
bottom: heightPx - MASS_PAD_BOTTOM,
curveYAt,
});
@@ -520,7 +524,7 @@ export function createMassHaulChart(
svg.append(
svgElement("line", {
x1: axisX,
y1: MASS_PAD_TOP,
y1: padTop,
x2: axisX,
y2: heightPx - MASS_PAD_BOTTOM,
class: "b06-chart__axis",
+6
View File
@@ -239,3 +239,9 @@
flex: 1 1 0;
min-width: 0;
}
/* 사이드 패널 내부 컨테이너 공통 외곽선 — 기본 --color-border가 두 테마 모두 흐려
안 보인다는 보고(2026-08-05). 본문 보조색을 절반 섞어 어느 테마에서든 뚜렷하게. */
.ui-sidebar-section {
border-color: color-mix(in srgb, var(--color-border) 45%, var(--color-text-secondary));
}