From 81825413ef3d5efc0847965ec483b102e1fdbbfd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 5 Aug 2026 19:51:10 +0900 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0?= =?UTF-8?q?=20=EC=83=81=EB=8B=A8=20=EC=97=AC=EC=9C=A0=C2=B7=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=EC=99=B8=EA=B3=BD=EC=84=A0=C2=B7=ED=8C=A8=EB=84=90=20=EB=A6=AC?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88=20=EB=B6=80=EB=93=9C=EB=9F=BD?= =?UTF-8?q?=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 유토곡선 SVG 상단 여백 padTop 옵션 신설(공용 렌더러), B05는 40px — 범례·기준 버튼 오버레이(top 34px)와 커브 겹침 해소. B06은 기존 10px 유지 - 사이드 패널 컨테이너 공통 클래스(ui-sidebar-section): 외곽선을 본문 보조색 45% 혼합으로 진하게 — 두 테마 모두 흐려 안 보이던 문제. B04/B05/B06 적용 - 하단 패널 리사이즈 끊김 해소: 끌리는 동안 프레임당 경량 높이 동기화 (캐스케이드 계산 + 캔버스/차트 높이만), 무거운 차트·테이블 재구성은 손 뗀 뒤 120ms 디바운스로 1회. 추가 리소스 없음 — 프레임당 비용 감소 Co-Authored-By: Claude Fable 5 --- B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 5 +- B05_wf2_Route/B05_wf2_Route_UI_Panel.ts | 3 +- .../B05_wf2_Route_UI_Profile_Panel.ts | 129 +++++++++++------- .../B06_wf3_ProfileCross_UI_Page.ts | 3 +- common_util/common_util_mass_haul_view.ts | 22 +-- ui_template/ui_template_overlay.css | 6 + 6 files changed, 108 insertions(+), 60 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index ca84f234..a1af9765 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -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 { }); 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"); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts index 2cf7d21e..42306706 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts @@ -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; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 61a1f32c..37c31a1b 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -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(".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); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index ecd1aabb..21038b71 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -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; diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index dc7fc34b..84498fef 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -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", diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index 5f77ce32..58a153a7 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -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)); +}