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:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user