diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index 791befddd..06db1bfe5 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -471,39 +471,45 @@ html:is([data-burger="on"], .is-bm-route) .app-burger { display: inline-flex; } .app-header.is-drawer-open .app-drawer { display: flex; flex-direction: column; - gap: var(--spacing-16); + gap: var(--spacing-12); position: fixed; top: var(--app-header-h); right: 0; bottom: 0; width: min(240px, 100vw); - padding: var(--spacing-16); + padding: var(--spacing-16) 0; overflow-y: auto; background: var(--color-surface); border-left: 1px solid var(--color-border); box-shadow: var(--shadow-lg); } +/* 서랍 간격 8/12 — 묶음 안 8 · 묶음 사이 12 + 선 + 12 · 줄 높이 마우스 40 · 손가락 44 · 글 왼쪽 16(389-2) */ .app-drawer__nav { display: flex; flex-direction: column; - gap: var(--spacing-4); + gap: var(--spacing-8); } -.app-drawer__nav > * { justify-content: flex-start; min-height: 44px; } +.app-drawer__nav > * { justify-content: flex-start; margin-inline: 8px; padding-left: 7px; min-height: var(--spacing-40); } .app-drawer__site { display: block; } -.app-drawer__site > summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; font-weight: 600; } -.app-drawer__site > nav { margin-top: var(--spacing-4); } +.app-drawer__site > summary { min-height: var(--spacing-40); display: flex; align-items: center; padding-left: 16px; cursor: pointer; font-weight: 600; } +.app-drawer__site > nav { margin-top: var(--spacing-8); } +.app-drawer__steps .ui-workflow-layout__step { min-height: var(--spacing-40); margin-inline: 8px; width: auto; padding-left: 7px; } +.app-drawer__steps .ui-workflow-layout__step--home { margin-bottom: 0; } +html[data-touch="on"] :is(.app-drawer__nav > *, .app-drawer__site > summary, .app-drawer__steps .ui-workflow-layout__step, .app-drawer__row > *) { min-height: 44px; } .app-drawer__row { display: flex; flex-wrap: wrap; gap: var(--spacing-8); + padding-inline: 16px; } +.app-drawer__row > * { min-height: var(--spacing-40); } .app-drawer__group { display: flex; flex-direction: column; gap: var(--spacing-8); } .app-drawer__group + .app-drawer__group { - padding-top: var(--spacing-16); + padding-top: var(--spacing-12); border-top: 1px solid var(--color-border); } .app-drawer__steps:empty { display: none; } @@ -511,9 +517,11 @@ html:is([data-burger="on"], .is-bm-route) .app-burger { display: inline-flex; } display: flex; flex-direction: column; gap: var(--spacing-8); + padding-left: 16px; color: var(--color-text-secondary); font-size: var(--text-caption); } +.app-drawer__touch .app-drawer__row { padding-inline: 0; } .app-drawer__touch [aria-checked="true"] { background: var(--color-mist-violet); color: var(--color-accent); @@ -562,11 +570,13 @@ html.is-bm-route .app-header.is-drawer-open .app-drawer { border-left: 0; border-right: 2px solid var(--color-accent); } +.app-drawer__brand { flex-direction: row; align-items: center; min-height: 44px; padding-left: 16px; } +html.is-bm-route .app-header.is-drawer-open .app-drawer { padding-top: var(--spacing-8); } html.is-bm-route .app-drawer__brand { - min-height: 44px; - padding-left: calc(var(--app-burger-inset) - var(--spacing-16)); + min-height: var(--app-burger-w); + padding-left: var(--app-burger-inset); } -html.is-bm-route .app-drawer__brand .app-brand { align-self: flex-end; } +html.is-bm-route[data-touch="on"] .app-drawer__brand { min-height: 44px; } `; export function injectShellStyles(): void { diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index de543f245..3a3c1e66b 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -53,11 +53,8 @@ export const STATE_REGISTRY = { legacy: () => "b05-route-profile-collapsed", }, "profile-height": { bucket: "pref", scope: "global", legacy: () => "b05-route-profile-height" }, - "drainage-collapsed": { - bucket: "pref", - scope: "global", - legacy: () => "b05-route-drainage-collapsed", - }, + /** 배수유역 판 접힘 — 기억 없음 = 닫힘(392-1 · 옛 "false" 기억 버림). */ + "drainage-collapsed": { bucket: "pref", scope: "global", version: 2 }, "drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" }, /** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */ "m02-combo-panel-collapsed": { bucket: "pref", scope: "global" }, @@ -77,6 +74,8 @@ export const STATE_REGISTRY = { "routeedit-locks": { bucket: "pref", scope: "project" }, /** B05 계획노선 편집 창 — 분석 지형(las · sheet). 확정 지표면은 안 바꿈 */ "routeedit-terrain": { bucket: "pref", scope: "project" }, + /** B05 계획노선 편집 창 — 측점 스냅 간격(0.1 · 1 · 10 · station) */ + "routeedit-snap-step": { bucket: "pref", scope: "project" }, "masshaul-open": { bucket: "pref", scope: "global", diff --git a/A00_Common/design/design_page.css b/A00_Common/design/design_page.css index 7813704b5..d4b2ff545 100644 --- a/A00_Common/design/design_page.css +++ b/A00_Common/design/design_page.css @@ -55,13 +55,20 @@ font-size: var(--btn-bar-font-size); } -/* 유토곡선 · 배수유역 판 [창 분할](계획서 325-3) — 종단 [창 분할]과 같은 양식 · 두 판 머리줄 맨 앞. */ +/* 유토곡선 · 배수유역 판 [창 분할](계획서 325-3) — 종단 [창 분할]과 같은 양식 · 유토는 판 좌상단에 띄움. */ .b05-subpanel__popout { flex: none; padding: var(--btn-bar-padding); font-size: var(--btn-bar-font-size); } +.b05-profile__masshaul-overlay > .b05-subpanel__popout { + position: absolute; + top: 4px; + left: var(--spacing-8); + z-index: 4; +} + /* 새 창으로 뗀 유토곡선 · 배수유역 판 — 창 전체를 채움 · 높이 · 폭 끌기와 접기 손잡이는 감춤. */ .b05-profile__masshaul-overlay.is-popout, .b05-drainage.is-popout { @@ -72,7 +79,6 @@ } .b05-profile__masshaul-overlay.is-popout > .ui-resizer, -.b05-profile__masshaul-overlay.is-popout .b05-profile__masshaul-handle, .b05-drainage.is-popout > .ui-resizer, .b05-drainage.is-popout > .ui-workflow-overlay__handle { display: none; diff --git a/A00_Common/design/design_panel.ts b/A00_Common/design/design_panel.ts index 0c3418605..14a6d4bd4 100644 --- a/A00_Common/design/design_panel.ts +++ b/A00_Common/design/design_panel.ts @@ -75,7 +75,7 @@ export function part(ctx: DesignPanelContext, name: string): T { export function createSectionFrame( view: DesignView, title: string, - collapsed = false, + collapsed = true, ): { root: HTMLElement; body: HTMLElement } { const root = document.createElement("section"); const heading = document.createElement("h3"); diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 6481c3a03..ee077f1fd 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -145,8 +145,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { // 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도 // 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고). bodyWrap.append(balanceBar, body, progress.root); - // 유토곡선 — 종단 칸 아래 자기 판(354-1) · 테이블 — 종단 칸 바닥 겹침 판. - // 둘 다 위 경계 리사이저로 높이를 조절한다. + // 유토곡선 · 테이블 — 종단 칸 바닥 겹침 판(2026-08-04 · 392-2) · 위 경계 리사이저로 높이 조절. // 서브패널 리사이저를 끄는 동안엔 메인 드래그와 같은 **경량 동기화**만 프레임마다 돌리고 // (차트 SVG·테이블 재생성은 무거워 덜컹였다 — 2026-08-06 분석 원인 3), 전체 재구성은 // 손을 뗀 뒤(clearDragFlags) 1회만 한다. 펼침·범례 토글 등 드래그 밖 알림은 전체 draw. @@ -155,7 +154,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { else draw(); }; // 유토곡선 · 테이블 오버레이 · 유토 배지는 `design_profile_overlays` 몫(700줄 한계). - const { massHaul, tableOverlay, column } = createProfileOverlays({ + const { massHaul, tableOverlay } = createProfileOverlays({ projectId, routeId: () => routeId, bodyWrap, @@ -192,7 +191,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { callbacks?.onStructureTypeAdd?.(chainage, typeId), ), }) ?? null; - content.append(column); + content.append(bodyWrap); if (drainagePanel) content.append(drainagePanel.root); const drag = createProfileDrag({ root, @@ -386,7 +385,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { /** * 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지 - * 줄고 → ② 테이블이 최소까지 줄고(유토곡선은 자기 판 · 354-1) → ③ 둘 다 최소면 + * 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면 * 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기 * 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리). * @@ -396,6 +395,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { const heights = createHeightCascade({ root, body, + massHaul, tableOverlay, isMainDragging: drag.isMainDragging, isSubDragging: drag.isSubDragging, diff --git a/A00_Common/design/design_profile_overlays.ts b/A00_Common/design/design_profile_overlays.ts index f5735dcb2..cf21adcb0 100644 --- a/A00_Common/design/design_profile_overlays.ts +++ b/A00_Common/design/design_profile_overlays.ts @@ -1,8 +1,7 @@ /* ============================================================================= * design_profile_overlays.ts - * 종단도 겹침 판(테이블) · 종단 열 아래 유토곡선 판 · 유토 총괄 배지(213-3 · 700줄 한계로 뗌). - * 만들어 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫. - * 종단 열(`column`) = [종단 칸(`bodyWrap`), 유토곡선 판](354-1) — 종단도가 content 왼쪽에 붙인다. + * 종단도 바닥 오버레이 서브패널 — 유토곡선 · 테이블 · 유토 총괄 배지(213-3 · 700줄 한계로 뗌). + * 만들어 `bodyWrap` 에 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫. * ========================================================================== */ import { attachMassHaulBadgeDrag, createMassHaulBadge } from "@util/common_util_mass_haul_badge"; @@ -17,7 +16,7 @@ import { export interface ProfileOverlaysDeps { projectId: string; routeId: () => number | null; - /** 종단 칸(요약 줄 · 본문 · 서클을 담은 칸) — 표 겹침 판이 그 위를 덮는다. */ + /** 종단 열(요약 줄 · 본문 · 서클을 담은 칸) — 오버레이가 그 위를 덮는다. */ bodyWrap: HTMLElement; /** 종단 스크롤러 — 오버레이 가로 스크롤을 이것과 맞춘다. */ body: HTMLElement; @@ -55,11 +54,8 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) { }, haulPrefetch, ); - // 유토곡선 = 종단 칸 아래 자기 판(354-1 · 배수유역도처럼) — 종단 [창 분할]에도 함께 감(content 안). - const column = document.createElement("div"); - column.className = "b05-route-profile__column"; - column.append(bodyWrap, massHaul.overlay); - // 테이블은 종단 칸 바닥 겹침 판(2026-08-05 사용자 지시). + bodyWrap.append(massHaul.overlay, massHaul.handle); + // 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시). const tableOverlay = createProfileTableOverlay(onChange); bodyWrap.append(tableOverlay.overlay, tableOverlay.handle); tableOverlay.attachScrollSync(body); @@ -67,5 +63,5 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) { // 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다. body.addEventListener("contextmenu", (event) => event.preventDefault()); massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault()); - return { massHaul, tableOverlay, column }; + return { massHaul, tableOverlay }; } diff --git a/A00_Common/design/design_profile_popout.ts b/A00_Common/design/design_profile_popout.ts index 311681235..304bdb07f 100644 --- a/A00_Common/design/design_profile_popout.ts +++ b/A00_Common/design/design_profile_popout.ts @@ -131,7 +131,7 @@ export function attachProfilePopout( }); } -/** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 두 판 머리줄 맨 앞(계획서 325-3 · 354-1). */ +/** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 유토 판 좌상단 · 배수 머리줄 맨 앞(계획서 325-3). */ export function attachSubPanelPopouts(profile: DesignProfile): void { const subButton = (title: string): HTMLButtonElement => { const button = document.createElement("button"); @@ -141,20 +141,23 @@ export function attachSubPanelPopouts(profile: DesignProfile): void { }; const massOverlay = profile.root.querySelector(".b05-profile__masshaul-overlay"); - const massHead = massOverlay?.querySelector(".b05-profile__masshaul-head"); - if (massOverlay && massHead) { + const massHandle = profile.root.querySelector(".b05-profile__masshaul-handle"); + if (massOverlay && massHandle) { const button = subButton("유토곡선 창 분할"); - // 판 머리줄 맨 앞 — 접어도 남아 닿는다(354-1). - massHead.prepend(button); - const column = massOverlay.parentElement; + massOverlay.append(button); attachPopout(profile, { piece: massOverlay, button, name: "aislo-masshaul-popout", features: "popup,width=1400,height=420", docTitle: "유토곡선 — Aislo", - // 제자리 = 종단 열 끝(종단 칸 아래 자기 판). - home: () => column?.append(massOverlay), + // 제자리 = 유토 손잡이 바로 앞(종단 본문 바닥 겹침 판). + home: () => massHandle.before(massOverlay), + onMove: (popped) => { + // 뗀 동안 메인 손잡이는 숨김 · 같이 줄이기 인라인 높이는 비움(새 창은 창 높이를 다 씀). + massHandle.hidden = popped; + massOverlay.style.height = ""; + }, }); } diff --git a/A00_Common/device_context.ts b/A00_Common/device_context.ts index 4ea4e6d42..cf07adf04 100644 --- a/A00_Common/device_context.ts +++ b/A00_Common/device_context.ts @@ -51,12 +51,14 @@ export function readTouchMode(): TouchMode { } } -const coarseQuery = window.matchMedia("(pointer: coarse)"); +// window 없는 곳(서버 Node 묶음) = 건너뜀 +const hasWindow = typeof window !== "undefined"; +const coarseQuery = hasWindow ? window.matchMedia("(pointer: coarse)") : null; let current: DeviceContext | null = null; let lastInput = ""; function read(): DeviceContext { - const pointer = coarseQuery.matches ? "coarse" : "fine"; + const pointer = coarseQuery?.matches ? "coarse" : "fine"; const touchMode = readTouchMode(); const touch = touchMode === "on" || (touchMode === "auto" && pointer === "coarse"); return { @@ -105,7 +107,7 @@ function init(): void { for (const [name, px] of Object.entries(SIZE_BREAKS)) html.style.setProperty(`--bp-${name}`, `${px}px`); apply(); - coarseQuery.addEventListener("change", apply); + coarseQuery?.addEventListener("change", apply); window.addEventListener("resize", apply); window.visualViewport?.addEventListener("resize", apply); screen.orientation?.addEventListener("change", apply); @@ -120,4 +122,4 @@ function init(): void { ); } -init(); +if (hasWindow) init(); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index ab255052d..a559d0664 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -556,9 +556,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra panelHandle.root.addEventListener("click", () => setCollapsed(!root.classList.contains("is-collapsed")), ); - // 상세 배수유역 정보는 페이지에 들어오면 바로 보여야 한다 — 저장값이 없으면 펼침이 기본이다 - // (같은 페이지의 하단 종단 패널과 같은 규칙). - setCollapsed(readStateRaw("drainage-collapsed") === "true"); + // 저장값이 없으면 닫힘이 기본(392-1) — 사용자가 펼친 기억("false")만 펼친다. + setCollapsed(readStateRaw("drainage-collapsed") !== "false"); return { root, diff --git a/B05_Profile/B05_Profile_UI_Profile_Drag.ts b/B05_Profile/B05_Profile_UI_Profile_Drag.ts index bb6fb193b..4bb99cbf0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Drag.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Drag.ts @@ -10,11 +10,12 @@ import { createPanelResizer } from "@ui/ui_template_resizer"; import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights"; +import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul"; import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay"; import { viewOf } from "../common_util/common_util_view_window"; const MIN_CHART_HEIGHT = 100; -/** 접힌 테이블 손잡이가 앉을 본문 아래 전용 띠 — 곡선 행 클릭을 가리지 않게. +/** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게. * 구조물 알약 레인이 그 위에 붙은 뒤로는 사이가 떠 보여 절반으로 줄였다 * (2026-08-17 사용자 지시 2). */ const MASSHAUL_HANDLE_GUTTER_PX = 13; @@ -92,7 +93,7 @@ export function createElevationHold( }; } -/** 겹침 판(표) 중 끌기가 보는 것만. */ +/** 겹침 판(유토곡선 · 표) 중 끌기가 보는 것만. */ interface SubPanel { overlay: HTMLElement; commitHeight: (height: number) => void; @@ -101,8 +102,7 @@ interface SubPanel { export interface ProfileDragContext { root: HTMLElement; body: HTMLElement; - /** 유토곡선 판 — 그 리사이저 끌기 출처만 본다(자기 판이라 높이 나눔에 안 낌 · 354-1). */ - massHaul: { overlay: HTMLElement }; + massHaul: SubPanel; tableOverlay: SubPanel & { isOpen: () => boolean }; /** 손을 뗀 뒤 한 번 다시 세운다. */ draw: () => void; @@ -112,7 +112,7 @@ export interface ProfileDrag { resizerRoot: HTMLElement; isMainDragging: () => boolean; isSubDragging: () => boolean; - mainDragRef: () => { chart: number; table: number } | null; + mainDragRef: () => { chart: number; mass: number; table: number } | null; isMainDragCooldown: () => boolean; clearMainDragCooldown: () => void; /** 전체 재구성을 120ms 뒤로 미룬다(연속 호출은 마지막 한 번만). */ @@ -133,21 +133,23 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { }; // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 // 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다. + // 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2). + const massDocked = (): boolean => !massHaul.overlay.hidden && root.contains(massHaul.overlay); const heightResizer = createPanelResizer({ axis: "vertical", target: root, cssVar: "--b05-profile-height", direction: -1, - // 동적 하한: 종단 최소 + 열린 표 최소 높이(2026-08-05 사용자 확정) — 유토곡선 판 높이는 - // chrome(뿌리 − 본문)에 이미 들어 있다(354-1). + // 동적 하한: 종단 최소 + 열린 서브패널들의 최소 높이 합(2026-08-05 사용자 확정). // 패널을 아래로 끌면 종단이 먼저 줄고 → 서브패널들이 같이 줄고(draw의 캐스케이드) → // 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단). min: () => { const chrome = Math.max(0, root.offsetHeight - body.clientHeight); + const massMin = massDocked() ? MASSHAUL_MIN_HEIGHT : 0; const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0; return Math.max( MIN_PANEL_HEIGHT, - chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + tableMin, + chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin, ); }, // 상한 = 가운데 칸 높이의 90%(위 칸 3D · 횡단 카드가 다 가려지지 않게) — Heights 와 한 식 @@ -164,14 +166,19 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { * 플래그가 이미 풀린 채 돌아, 드래그 중 금지했던 자동 확대가 손 떼는 순간 발동해 * 포인터가 정한 높이를 되돌렸다(2026-08-06 분석 원인 4). draw()가 끝나며 푼다. */ let mainDragCooldown = false; - /** 메인 드래그 시작 시점의 두 영역(종단·테이블) 실제 높이 — 비례 연동 기준. + /** 메인 드래그 시작 시점의 3영역(종단·유토곡선·테이블) 실제 높이 — 비례 연동 기준. * 드래그 중 이 비율대로 같이 늘리고 줄인다(2026-08-06 사용자 확정). */ - let mainDragRef: { chart: number; table: number } | null = null; + let mainDragRef: { chart: number; mass: number; table: number } | null = null; heightResizer.root.addEventListener("pointerdown", () => { mainPanelDragging = true; + const massH = massDocked() ? massHaul.overlay.offsetHeight : 0; const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0; mainDragRef = { - chart: Math.max(MIN_CHART_HEIGHT, body.clientHeight - tableH - MASSHAUL_HANDLE_GUTTER_PX), + chart: Math.max( + MIN_CHART_HEIGHT, + body.clientHeight - massH - tableH - MASSHAUL_HANDLE_GUTTER_PX, + ), + mass: massH, table: tableH, }; }); @@ -186,7 +193,9 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { // 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정 // 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전 // 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다). + if (massDocked()) massHaul.commitHeight(massHaul.overlay.offsetHeight); if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight); + massHaul.overlay.style.height = ""; tableOverlay.overlay.style.height = ""; } mainDragRef = null; diff --git a/B05_Profile/B05_Profile_UI_Profile_Heights.ts b/B05_Profile/B05_Profile_UI_Profile_Heights.ts index 3e299ea31..fe6035427 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Heights.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Heights.ts @@ -1,7 +1,6 @@ /* ============================================================================= * B05_Profile_UI_Profile_Heights.ts - * 종단 패널의 높이 배분 — 그래프·테이블이 한 본문 높이를 나눠 갖는 규칙. - * 유토곡선은 종단 열 아래 자기 판이라 본문 높이에서 이미 빠져 있다 — 나눔에 안 낀다(354-1). + * 종단 패널의 높이 배분 — 그래프·유토곡선·테이블이 한 패널 높이를 나눠 갖는 규칙. * * 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다. * 판정 기준은 **저장 높이**(리사이저 변수)이며 화면 높이가 아니다 — 화면 높이로 재면 @@ -9,13 +8,14 @@ * 드래그 플래그·상세 데이터 유무는 본체가 계속 들고 있고 여기서는 접근자로만 읽는다. * ========================================================================== */ +import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul"; import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay"; import { STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks"; import { frameOf } from "../common_util/common_util_view_window"; /** 그래프가 이보다 낮아지면 종단선을 읽을 수 없다 — 어떤 배분에서도 지키는 하한(px). */ const MIN_CHART_HEIGHT = 100; -/** 접힌 테이블 손잡이가 차지하는 바닥 여백(px). */ +/** 접힌 유토곡선 손잡이가 차지하는 바닥 여백(px). */ const MASSHAUL_HANDLE_GUTTER_PX = 13; /** 패널 자동 확대 상한 — 메인 리사이저 max와 같은 식이어야 한다(부모 높이 비율). */ const MAX_PANEL_HEIGHT_RATIO = 0.9; @@ -32,15 +32,21 @@ export interface HeightCascadeDeps { root: HTMLElement; /** 본문(그래프+서브패널이 들어가는 스크롤 영역). */ body: HTMLElement; + massHaul: { + overlay: HTMLElement; + desiredHeight: () => number; + syncHandle: () => void; + }; tableOverlay: { overlay: HTMLElement; isOpen: () => boolean; desiredHeight: () => number; + setBottomOffset: (value: number) => void; }; isMainDragging: () => boolean; isSubDragging: () => boolean; - /** 메인 드래그 시작 시점의 두 영역 비율(없으면 null). */ - mainDragRef: () => { chart: number; table: number } | null; + /** 메인 드래그 시작 시점의 3영역 비율(없으면 null). */ + mainDragRef: () => { chart: number; mass: number; table: number } | null; /** 손 뗀 직후 첫 재구성까지는 자동 확대를 막는다. */ isMainDragCooldown: () => boolean; /** 상세 데이터가 아직 없으면 경량 동기화를 건너뛴다. */ @@ -61,17 +67,21 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { // 구조물 알약 레인이 그래프 아래 한 줄을 차지한다 — 그만큼 먼저 떼어 놓고 나눈다 // (2026-08-17 사용자 지시 6: 그래프가 밀려도 되지만 여유는 최소). const available = Math.max(120, deps.body.clientHeight - STRUCTURE_LANE_HEIGHT_PX); + // 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2). + const massOpen = !deps.massHaul.overlay.hidden && deps.root.contains(deps.massHaul.overlay); const tableOpen = deps.tableOverlay.isOpen(); // 판정 기준은 항상 **저장 높이**(리사이저 변수·기본값)다 — 임시 축소가 반영된 화면 // 높이(offsetHeight)로 재면 "줄임 → 충분해 보임 → 풀림 → 부족 → 다시 줄임"이 // 호출마다 번갈아 도는 진동이 되고, 풀리는 호출의 deficit 처리가 메인 패널을 // 멋대로 키운다(2026-08-06 분석: 튐·자동 확대의 핵심 원인). 저장 높이 기준이면 // 몇 번을 호출해도 같은 답이 나온다(멱등). + const massDesired = massOpen ? deps.massHaul.desiredHeight() : 0; const tableDesired = tableOpen ? deps.tableOverlay.desiredHeight() : 0; + let massHeight = massDesired; let tableOverlayHeight = tableDesired; const budget = Math.max(0, available - MIN_CHART_HEIGHT - MASSHAUL_HANDLE_GUTTER_PX); - // ── 메인 드래그 중: 비례 연동(2026-08-06 사용자 확정) ───────────── - // 드래그 시작 시점 비율(deps.mainDragRef())대로 종단·테이블을 같이 늘리고 + // ── 메인 드래그 중: 3영역 비례 연동(2026-08-06 사용자 확정) ───────────── + // 드래그 시작 시점 비율(deps.mainDragRef())대로 종단·유토곡선·테이블을 같이 늘리고 // 줄인다. 최소에 닿은 영역은 거기서 멈추고 남은 영역끼리 다시 비례 배분한다. // 손을 떼면 clearDragFlags가 이 결과를 저장 높이로 확정한다. const dragRef = deps.mainDragRef(); @@ -79,12 +89,15 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { const content = Math.max(0, available - MASSHAUL_HANDLE_GUTTER_PX); const items = [ { key: "chart", ref: Math.max(1, dragRef.chart), min: MIN_CHART_HEIGHT }, + ...(massOpen + ? [{ key: "mass", ref: Math.max(1, dragRef.mass), min: MASSHAUL_MIN_HEIGHT }] + : []), ...(tableOpen ? [{ key: "table", ref: Math.max(1, dragRef.table), min: TABLE_OVERLAY_MIN_HEIGHT }] : []), ]; // 고정 → 재배분 반복: 축소 배율로 최소를 뚫는 항목을 최소에 고정하고, 남은 - // 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 2개라 최대 2회에 끝난다. + // 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 3개라 최대 3회에 끝난다. const out: Record = {}; let pool = content; let active = items; @@ -103,34 +116,50 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { active = active.filter((item) => !pinned.includes(item)); } // 서브패널 상한(개별 리사이저 max와 같은 비율)은 안전상 유지 — 넘치면 종단이 흡수. + massHeight = massOpen ? Math.min(out.mass ?? 0, Math.round(available * 0.8)) : 0; tableOverlayHeight = tableOpen ? Math.min(out.table ?? 0, Math.round(available * 0.75)) : 0; + if (massOpen) deps.massHaul.overlay.style.height = `${massHeight}px`; if (tableOpen) deps.tableOverlay.overlay.style.height = `${tableOverlayHeight}px`; - return { chartHeight: Math.max(MIN_CHART_HEIGHT, content - tableOverlayHeight) }; + // 손잡이는 바뀐 인라인 높이의 위 경계를 즉시 따라간다. + deps.massHaul.syncHandle(); + deps.tableOverlay.setBottomOffset(massHeight); + return { + chartHeight: Math.max(MIN_CHART_HEIGHT, content - massHeight - tableOverlayHeight), + }; } if (deps.isSubDragging()) { // 서브패널 리사이저를 끄는 중이면 그 높이가 **사용자 의도**다 — 임시 축소(인라인)를 // 걷어 변수(드래그 값)가 그대로 보이게 하고, 자리가 모자라면 아래 deficit 처리로 // 메인 패널을 키운다. 변수는 드래그마다 갱신되므로 저장 높이 = 드래그 값이다. + deps.massHaul.overlay.style.height = ""; deps.tableOverlay.overlay.style.height = ""; - } else if (tableDesired > budget) { - // ② 줄이기 — 줄일 수 있는 여유분만큼 축소, 최소 높이 하한. + } else if (massDesired + tableDesired > budget) { + // ② 같이 줄이기 — 줄일 수 있는 여유분에 비례해 축소, 각자 최소 높이 하한. // 자리가 다시 늘면 ratio가 매끄럽게 0으로 줄어 저장 높이로 연속 복귀한다. + const massMin = massOpen ? MASSHAUL_MIN_HEIGHT : 0; const tableMin = tableOpen ? TABLE_OVERLAY_MIN_HEIGHT : 0; - const shrinkable = tableDesired - tableMin; - const over = tableDesired - budget; + const shrinkable = massDesired - massMin + (tableDesired - tableMin); + const over = massDesired + tableDesired - budget; const ratio = shrinkable > 0 ? Math.min(1, over / shrinkable) : 1; + massHeight = Math.round(massDesired - (massDesired - massMin) * ratio); tableOverlayHeight = Math.round(tableDesired - (tableDesired - tableMin) * ratio); // 임시 축소는 인라인 높이로만 — 리사이저 저장값(--변수·세션)은 건드리지 않아 // 패널을 다시 키우면 원래 높이로 돌아온다. + if (massOpen) deps.massHaul.overlay.style.height = `${massHeight}px`; if (tableOpen) deps.tableOverlay.overlay.style.height = `${tableOverlayHeight}px`; } else { // 공간 충분 — 임시 축소 해제(저장 높이로 복귀). + deps.massHaul.overlay.style.height = ""; deps.tableOverlay.overlay.style.height = ""; } + // 접힌 유토곡선 손잡이는 바닥 고정(순서상 테이블 아래에서 나옴), 접힌 테이블 손잡이는 + // 열린 유토곡선 위 경계(TableOverlay.syncHandlePosition). + deps.tableOverlay.setBottomOffset(massHeight); // ③' 최소까지 줄여도 모자라면(서브패널 펼침·서브패널 드래그로 자리가 부족한 경우) // 메인 패널을 키운다. 단 **메인 패널을 끄는 중과 손 뗀 직후 첫 재구성까지**는 절대 // 안 된다 — 포인터가 정한 높이를 되돌려 서로 밀고 당기는 진동이 생긴다. - const deficit = MIN_CHART_HEIGHT + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available; + const deficit = + MIN_CHART_HEIGHT + massHeight + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available; if (deficit > 1 && allowGrow && !deps.isMainDragging() && !deps.isMainDragCooldown()) { // 상한은 메인 리사이저 max와 **같은 식**이어야 한다 — 예전 window 92% 상한은 // 리사이저 상한(부모 90%)보다 높아, 자동 확대 직후 손잡이를 잡는 순간 낮은 @@ -145,7 +174,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { return { chartHeight: Math.max( MIN_CHART_HEIGHT, - available - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX, + available - massHeight - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX, ), }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 6a4b5b778..a24eda3d4 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -1,6 +1,6 @@ /* ============================================================================= * B05_Profile_UI_Profile_MassHaul.ts - * B05 유토곡선 — 종단 열 아래 **자기 판**(354-1 · 배수유역도처럼 손잡이 · 높이 · 접기 따로). + * B05 유토곡선 — 종단면 패널 바닥에 붙는 **오버레이 서브패널**. * * ── B06과 같은 그림이어야 한다 (2026-08-03 사용자 확정) ────────────── * 이 프로그램은 파일 입력 시 기본 설정값으로 B06 몫까지 미리 계산해 둔다 — 종횡단 상세 @@ -14,11 +14,12 @@ * 같은 영구저장소(`longitudinal_sections.data.mass_haul`)를 읽으므로, B06에서 횡단을 * 고쳐 임시저장/확정하면 B05는 재조회만으로 같은 곡선을 받는다. * - * ── 배치 (354-1 · 요구 235-7 「유토곡선도 분리」) ───────────────────── - * 종단 열(그래프 · 표) 아래 칸에 따로 선다 — 종단도 · 표 위를 덮지 않고 높이 나눔 · 표 쌓기에도 - * 안 낀다. 높이는 판 위 경계 리사이저(`createPanelResizer`)로 조절하고 세션 동안 유지한다. - * 접으면 손잡이만 남는다(367-2). 곡선은 늘 노선 전체를 한 화면에 맞춰 그린다 — 종단 측점과 - * 맞물리는 보기 · 스크롤 동기는 없앰(367-1). + * ── 배치 (2026-08-04 사용자 확정: 오버레이 전환 · 392-2 되살림) ────────── + * 종단도·테이블 배치를 **건드리지 않고**, 패널 바닥에 붙는 오버레이가 그 위를 덮는다. 높이는 + * 오버레이 위 경계의 리사이저(메인 하단 패널과 같은 `createPanelResizer`)로 조절하고 + * 세션 동안 유지한다. 메인 패널을 끌면 종단 · 유토곡선 · 테이블이 같은 비율로 늘고 준다. + * 곡선은 늘 노선 전체를 한 화면에 맞춰 그린다 — 종단 측점과 맞물리는 보기 · 스크롤 동기는 + * 없앰(367-1). * ========================================================================== */ import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; @@ -61,10 +62,12 @@ import { frameOf } from "../common_util/common_util_view_window"; const OVERLAY_HEIGHT_KEY = stateKey("masshaul-height") ?? ""; /** 오버레이 높이 하한/기본값(px). 곡선 + 요약 막대가 읽히는 최소 크기다. */ const OVERLAY_MIN_HEIGHT = 160; +/** 유토곡선 오버레이 최소 높이 — 메인 패널 하한 계산(Profile_Panel)이 함께 쓴다. */ +export const MASSHAUL_MIN_HEIGHT = OVERLAY_MIN_HEIGHT; const OVERLAY_DEFAULT_HEIGHT = 280; /** 전체 맞춤(토량 분배) 보기의 우측 여백(px) — 마지막 점의 값 글자가 잘리지 않을 만큼만. */ const MASS_FIT_PAD_RIGHT = 12; -/** 판이 종단 열을 다 차지하지 않게 남기는 상한 비율. */ +/** 오버레이가 패널 본문을 다 덮지 않게 남기는 상한 비율. */ const OVERLAY_MAX_RATIO = 0.8; /** 리사이저가 높이를 담는 CSS 변수 — CSS 기본값(280px)은 OVERLAY_DEFAULT_HEIGHT와 같아야 한다. */ const HEIGHT_VAR = "--b05-masshaul-height"; @@ -139,13 +142,21 @@ export interface RouteMassHaulDrawParams { } export interface RouteMassHaulPanel { - /** 판 머리줄의 접기 손잡이(펼치기/접기) — 판 안에 있다. */ + /** 종단면 패널 바닥에 붙는 2차 슬라이드 손잡이(펼치기/접기). */ handle: HTMLElement; - /** 유토곡선 판 — 호출한 쪽이 종단 열 아래(`b05-route-profile__column` 끝)에 붙인다. */ + /** 오버레이 서브패널 — 호출한 쪽이 `position: relative`인 패널 본문 래퍼에 붙인다. */ overlay: HTMLElement; isOpen(): boolean; + /** 저장된 오버레이 높이(px) — 리사이저 변수(세션)나 기본값. 임시 축소(인라인)는 무시한다. + * Panel의 높이 캐스케이드가 호출마다 같은 판정을 내리는 기준(2026-08-06 진동 수정). */ + desiredHeight(): number; + /** 저장 높이를 확정한다(리사이저와 같은 변수+세션 기록) — 메인 패널 비례 연동이 + * 드래그를 마친 높이를 앞으로의 기준으로 못 박는 데 쓴다(2026-08-06 사용자 확정). */ + commitHeight(px: number): void; + /** 손잡이를 오버레이 위 경계로 다시 맞춘다 — 캐스케이드가 인라인 높이를 바꾼 직후 호출. */ + syncHandle(): void; setContext(next: RouteMassHaulContext | null): void; - /** 판 내용을 다시 그린다. 접혀 있으면 손잡이만 남기고 끝낸다. */ + /** 오버레이 내용을 다시 그린다. 접혀 있으면 오버레이만 숨기고 끝낸다. */ draw(params: RouteMassHaulDrawParams): void; } @@ -190,12 +201,8 @@ export function createRouteMassHaulPanel( handle.className = "b05-profile__masshaul-handle"; handle.append(handleControl.root); handleControl.root.setAttribute("aria-label", "유토곡선 패널"); - // 머리줄 — [창 분할]([attachSubPanelPopouts] 가 맨 앞에 붙임 · 펼친 때만 보임) · 손잡이(접으면 판 위 경계에 매달림 · 367-2). - const head = document.createElement("div"); - head.className = "b05-profile__masshaul-head"; - head.append(handle); - // 판 뼈대 — 위 경계 리사이저 + 머리줄 + 안내 문구 자리 + 가로 스크롤러 + 범례. + // 오버레이 뼈대 — 위 경계 리사이저 + 안내 문구 자리 + 가로 스크롤러 + 범례. // (요약 막대는 2026-09-06 종단 상단줄로 올라갔다.) const overlay = document.createElement("div"); overlay.className = "b05-profile__masshaul-overlay"; @@ -222,7 +229,8 @@ export function createRouteMassHaulPanel( max: () => (overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO, storageKey: OVERLAY_HEIGHT_KEY, onResize: () => { - // 전체 redraw는 프레임당 한 번만. + // 손잡이는 경계를 즉시 따라가고(가벼움), 전체 redraw는 프레임당 한 번만. + syncHandlePosition(); if (resizeRedrawPending) return; resizeRedrawPending = true; frameOf(overlay, () => { @@ -237,10 +245,16 @@ export function createRouteMassHaulPanel( pendingChip.className = "b05-profile__masshaul-pending"; pendingChip.textContent = "다시 계산 중…"; pendingChip.hidden = true; - overlay.append(resizer.root, head, bar, scroll, legendLayer, pendingChip); + overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip); - /** 곡선을 그리는가 — 펼침이거나 새 창으로 뗌(뗀 창엔 접기가 없음 · 354-1). */ - const shown = (): boolean => open || overlay.classList.contains("is-popout"); + /** + * 손잡이를 오버레이 **위 경계**에 태운다(2026-08-04 사용자 지시 — 펼치면 같이 올라와 + * 테이블 영역을 침범해도 된다). 접혀 있으면 패널 바닥으로 돌아온다 — 유토곡선은 서브패널 + * 순서상 맨 아래라 테이블이 열려 있어도 버튼은 바닥에 남는다(2026-08-05 사용자 확정). + */ + function syncHandlePosition(): void { + handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px"; + } function applyOpen(next: boolean): void { open = next; @@ -249,14 +263,14 @@ export function createRouteMassHaulPanel( // 툴팁은 setOpen이 일반 문구로 덮으므로 매번 유토곡선용으로 다시 밝힌다. handleControl.root.title = next ? "유토곡선 접기" : "유토곡선 펼치기"; handle.classList.toggle("is-open", next); - // 접으면 손잡이만 남는다(배수유역도처럼 손잡이는 늘 닿음) - overlay.classList.toggle("is-collapsed", !next); + overlay.hidden = !next; + syncHandlePosition(); onChanged(); } handleControl.setOpen(open); handleControl.root.title = open ? "유토곡선 접기" : "유토곡선 펼치기"; handle.classList.toggle("is-open", open); - overlay.classList.toggle("is-collapsed", !open); + overlay.hidden = !open; handleControl.root.addEventListener("click", () => applyOpen(!open)); function toggleSeries(key: string): void { @@ -321,10 +335,11 @@ export function createRouteMassHaulPanel( } function drawNow(params: RouteMassHaulDrawParams): void { - overlay.classList.toggle("is-collapsed", !open); + overlay.hidden = !open; + syncHandlePosition(); // 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다. // 펼친 경우에는 아래에서 낸 계열로 총괄값을 낸다(계산 한 번). - if (!shown()) { + if (!open) { reportSummary(params); return; } @@ -444,7 +459,18 @@ export function createRouteMassHaulPanel( return { handle, overlay, - isOpen: shown, + isOpen: () => open, + desiredHeight() { + const raw = parseFloat(overlay.style.getPropertyValue(HEIGHT_VAR)); + return Number.isFinite(raw) && raw > 0 ? raw : OVERLAY_DEFAULT_HEIGHT; + }, + commitHeight(px) { + const next = Math.round(px); + overlay.style.setProperty(HEIGHT_VAR, `${next}px`); + writeStateRaw("masshaul-height", String(next)); + syncHandlePosition(); + }, + syncHandle: () => syncHandlePosition(), setContext(next) { context = next; }, diff --git a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts index 0ae4b90c5..7c2d15b89 100644 --- a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts +++ b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts @@ -4,8 +4,10 @@ * 오버레이 서브패널로 감싼다(2026-08-05 사용자 지시). * * - 접힘/펼침 표준 삼각형 손잡이 + 위 경계 리사이저 + 세션 보존. - * - 종단 칸 바닥에 붙는다 — 유토곡선은 그 아래 자기 판이라 쌓지 않는다(354-1). - * - 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다. + * - 유토곡선 오버레이가 함께 열리면 그 **위**에 쌓인다 — 위→아래 순서가 + * 종단도 → 테이블 → 유토곡선이 되도록 setBottomOffset으로 바닥 간격을 받는다. + * - 접혔을 때 손잡이는 유토곡선 손잡이와 가로로 나란히(일렬) 놓인다(CSS에서 + * 가로 오프셋). 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다. * ========================================================================== */ import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; @@ -52,6 +54,8 @@ export interface RouteProfileTableOverlay { setTable: (table: HTMLElement | null) => void; /** 종단 그래프 스크롤러와 가로 스크롤 양방향 동기화. */ attachScrollSync: (main: HTMLElement) => void; + /** 유토곡선 오버레이가 차지한 바닥 높이(px). 그 위로 쌓인다. */ + setBottomOffset: (px: number) => void; } export function createProfileTableOverlay(onChanged: () => void): RouteProfileTableOverlay { @@ -69,6 +73,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa // 상태 선언은 리사이저 생성보다 먼저 — 세션 높이 복원이 생성 중 onResize를 부른다 // (유토곡선 TDZ 크래시와 같은 함정, 2026-08-04 확인). let open = readStateRaw("table-open") !== "false"; // 기본 펼침(기존 테이블 상시 표시 유지) + let bottomOffset = 0; let resizeRedrawPending = false; const resizer = createPanelResizer({ axis: "vertical", @@ -97,9 +102,11 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa // 유토곡선 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(종단 스크롤도 함께 움직인다). attachWheelHorizontalScroll(scroll); - /** 손잡이는 오버레이 위 경계를, 접히면 종단 칸 바닥을 따라간다(2026-08-05 사용자 지시). */ + /** 손잡이는 오버레이 위 경계를, 접히면 열린 유토곡선의 위 경계(bottomOffset)를 따라간다 + * — 접힌 버튼이 펼쳐진 패널 수평선에 놓인다(2026-08-05 사용자 지시). */ function syncHandlePosition(): void { - handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px"; + handle.style.bottom = open ? `${bottomOffset + overlay.offsetHeight}px` : `${bottomOffset}px`; + overlay.style.bottom = `${bottomOffset}px`; } function applyOpen(next: boolean): void { @@ -147,5 +154,9 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa syncedMain = main; linkScrollLeft(main, scroll); }, + setBottomOffset(px) { + bottomOffset = px; + syncHandlePosition(); + }, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 540c6e57a..e74c3bf2e 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -91,6 +91,7 @@ import { import type { DesignPageHost } from "../A00_Common/design/design_page"; import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft"; import { bindRouteShift } from "./B05_Profile_UI_RouteEdit_Shift"; +import { bindStationSnap, paintStationSnap } from "./B05_Profile_UI_RouteEdit_SnapStep"; import * as fit from "./logic/B05_Profile_RouteEdit_StationFit"; import "./B05_Profile_UI_Style_RouteEdit.css"; @@ -173,11 +174,7 @@ export async function mountRouteEditor( curveSpans(line, curveInfo), ); }; - const setStationSnap = (on: boolean): void => { - stationSnap = on; - stationSnapButton.classList.toggle("is-active", on); - stationSnapButton.setAttribute("aria-pressed", String(on)); - }; + const setStationSnap = (on: boolean) => paintStationSnap(stationSnapButton, (stationSnap = on)); /** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */ let history: RouteEditHistory | null = null; let meta: VWorldMeta | null = null; @@ -264,8 +261,8 @@ export async function mountRouteEditor( const slope = createSlopeLabels({ projectId, isClosed: () => closed, onChange: () => draw() }); /** 1m 간격 등고선 켬끔 — 기본 꺼짐(계획서 18 ②). */ const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw()); - stationSnapButton.addEventListener("click", () => { - setStationSnap(!stationSnap); + const snapStepM = bindStationSnap(chrome, projectId, stationIntervalM, (toggle) => { + if (toggle) setStationSnap(!stationSnap); if (stationSnap && history) applyEdit("곡선 가운데(MC)를 일반측점에 맞췄습니다."); // 전체 · 한 걸음 draw(); }); @@ -416,7 +413,7 @@ export async function mountRouteEditor( violationCount: nodeInfo.filter((node) => node.violations.length).length, minRadiusM, fresh: nodeInfo.length === 0, - offStation: stationSnap ? fit.offStationCount(plannedLine, curveInfo, stationIntervalM) : 0, + offStation: stationSnap ? fit.offStationCount(plannedLine, curveInfo, snapStepM()) : 0, }); // ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ── @@ -450,7 +447,7 @@ export async function mountRouteEditor( /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. * 측점 스냅 켬이면 고친 자리 앞 곡선부터 MC 를 일반측점에(366-3 · 되살림 `fitting=false` 은 안 맞춤). */ function applyEdit(message: string, record = true, fitting = true): void { - const limits = { minRadiusM, limitRadiusM, limitArcM, intervalM: stationIntervalM }; + const limits = { minRadiusM, limitRadiusM, limitArcM, intervalM: snapStepM() }; const from = fit.fitStart(history?.current?.(), snapshotNow()); if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), limits, from); markEdited(); @@ -574,6 +571,7 @@ export async function mountRouteEditor( getMeta: () => meta, // 돌린 지도에서는 손이 민 방향과 그림이 움직일 방향이 다르다 — 거꾸로 돌려 넘긴다. unrotateDelta: rotation.unrotateDelta, + rotateWheel: rotation.wheel, // Ctrl+휠 = 5° 돌리기(390-3) draw, }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 6c1273f5e..d55e2e80a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -110,6 +110,16 @@ export function createRouteEditChrome( + +