- 토량분배를 켜면 유토곡선이 종단과의 X 맞춤·세로 창을 버리고 스크롤러 안쪽 폭·전 구간 Y 로 그려 곡선 전체가 한 화면에 들어옴(평형선·운반 블록을 노선 전체로 읽는 그림이라). 끄면 예전대로 종단과 맞물린 가로 스크롤로 복귀. - 요약 막대가 상단줄로 올라간 뒤 남던 상단 여백 축소 — 범례 top 34px → 4px, 차트 padTop 40 → 30(범례 높이 24 + 여유). - 종단 테이블 오버레이 높이 상한 신설 — 6행 × 행 30px + 24px(=204px). 12행에서 6행으로 줄인 뒤 같은 높이를 나눠 가지며 행이 부풀던 것을 막음. 레이아웃 제어(리사이저·패널 비례 연동)는 그대로 두고 상한만 걸음. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
172 lines
8.0 KiB
TypeScript
172 lines
8.0 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_Profile_TableOverlay.ts
|
|
* 하단 종단 패널의 12행 도면식 테이블을 **유토곡선과 같은 형태**의 바닥 고정
|
|
* 오버레이 서브패널로 감싼다(2026-08-05 사용자 지시).
|
|
*
|
|
* - 접힘/펼침 표준 삼각형 손잡이 + 위 경계 리사이저 + 세션 보존.
|
|
* - 유토곡선 오버레이가 함께 열리면 그 **위**에 쌓인다 — 위→아래 순서가
|
|
* 종단도 → 테이블 → 유토곡선이 되도록 setBottomOffset으로 바닥 간격을 받는다.
|
|
* - 접혔을 때 손잡이는 유토곡선 손잡이와 가로로 나란히(일렬) 놓인다(CSS에서
|
|
* 가로 오프셋). 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다.
|
|
* ========================================================================== */
|
|
|
|
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
|
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
|
import { createPanelResizer } from "@ui/ui_template_resizer";
|
|
import { TABLE_ROW_COUNT } from "./B05_Profile_UI_Profile_Layout";
|
|
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
|
|
|
|
/* 펼침·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
|
|
const HEIGHT_KEY = stateKey("table-height") ?? "";
|
|
const OVERLAY_MIN_HEIGHT = 140;
|
|
/** 테이블 오버레이 최소 높이 — 메인 패널 하한 계산(Profile_Panel)이 함께 쓴다. */
|
|
export const TABLE_OVERLAY_MIN_HEIGHT = OVERLAY_MIN_HEIGHT;
|
|
const OVERLAY_MAX_RATIO = 0.75;
|
|
/** 행 하나의 높이 상한(px). 행이 이보다 커지면 글자만 커지고 읽을 내용은 그대로다 —
|
|
* 12행에서 6행으로 줄인 뒤 같은 높이를 나눠 가지며 행이 두 배로 부푼 것을 막는다
|
|
* (2026-09-06 사용자 지시: 「최대값 제한, 레이아웃 제어는 현재 유지」). */
|
|
const MAX_ROW_HEIGHT = 30;
|
|
/** 오버레이 높이 상한 = 6행 × 행 상한 + 가로 스크롤바·경계 몫. */
|
|
const OVERLAY_MAX_HEIGHT = TABLE_ROW_COUNT * MAX_ROW_HEIGHT + 24;
|
|
/** CSS 기본 높이 — `var(--b05-table-height, 204px)`(_Style_MassHaul.css)와 같아야 한다. */
|
|
const OVERLAY_DEFAULT_HEIGHT = OVERLAY_MAX_HEIGHT;
|
|
/** 어느 경로로 들어온 높이든 상한을 넘지 않게 한다(리사이저·패널 비례 연동 공용). */
|
|
const clampHeight = (px: number): number => Math.min(px, OVERLAY_MAX_HEIGHT);
|
|
/** 리사이저가 높이를 담는 CSS 변수. */
|
|
const HEIGHT_VAR = "--b05-table-height";
|
|
|
|
export interface RouteProfileTableOverlay {
|
|
/** 접힘 손잡이(패널 바닥 띠에 얹는다). */
|
|
handle: HTMLElement;
|
|
/** 테이블을 담는 바닥 고정 오버레이. */
|
|
overlay: HTMLElement;
|
|
isOpen: () => boolean;
|
|
/** 저장된 오버레이 높이(px) — 리사이저 변수(세션)나 기본값. 임시 축소(인라인)는 무시한다.
|
|
* Panel의 높이 캐스케이드가 호출마다 같은 판정을 내리는 기준(2026-08-06 진동 수정). */
|
|
desiredHeight: () => number;
|
|
/** 저장 높이를 확정한다(리사이저와 같은 변수+세션 기록) — 메인 패널 비례 연동이
|
|
* 드래그를 마친 높이를 앞으로의 기준으로 못 박는 데 쓴다(2026-08-06 사용자 확정). */
|
|
commitHeight: (px: number) => void;
|
|
/** 오버레이 내용 높이(px) — draw()가 테이블 행 높이를 정하는 기준. */
|
|
contentHeight: () => number;
|
|
/** draw()가 만든 테이블 요소를 넣는다(null이면 비움). */
|
|
setTable: (table: HTMLElement | null) => void;
|
|
/** 종단 그래프 스크롤러와 가로 스크롤 양방향 동기화. */
|
|
attachScrollSync: (main: HTMLElement) => void;
|
|
/** 유토곡선 오버레이가 차지한 바닥 높이(px). 그 위로 쌓인다. */
|
|
setBottomOffset: (px: number) => void;
|
|
}
|
|
|
|
export function createProfileTableOverlay(onChanged: () => void): RouteProfileTableOverlay {
|
|
const handleControl = createWorkflowPanelHandle("bottom", "down", "테이블");
|
|
const handle = document.createElement("div");
|
|
handle.className = "b05-profile__table-handle";
|
|
handle.append(handleControl.root);
|
|
handleControl.root.setAttribute("aria-label", "테이블 패널");
|
|
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "b05-profile__table-overlay";
|
|
const scroll = document.createElement("div");
|
|
scroll.className = "b05-profile__table-scroll";
|
|
|
|
// 상태 선언은 리사이저 생성보다 먼저 — 세션 높이 복원이 생성 중 onResize를 부른다
|
|
// (유토곡선 TDZ 크래시와 같은 함정, 2026-08-04 확인).
|
|
let open = readStateRaw("table-open") !== "false"; // 기본 펼침(기존 테이블 상시 표시 유지)
|
|
let bottomOffset = 0;
|
|
let resizeRedrawPending = false;
|
|
const resizer = createPanelResizer({
|
|
axis: "vertical",
|
|
target: overlay,
|
|
cssVar: HEIGHT_VAR,
|
|
direction: -1,
|
|
min: OVERLAY_MIN_HEIGHT,
|
|
max: () =>
|
|
clampHeight((overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO),
|
|
storageKey: HEIGHT_KEY,
|
|
onResize: () => {
|
|
syncHandlePosition();
|
|
if (resizeRedrawPending) return;
|
|
resizeRedrawPending = true;
|
|
requestAnimationFrame(() => {
|
|
resizeRedrawPending = false;
|
|
onChanged();
|
|
});
|
|
},
|
|
});
|
|
overlay.append(resizer.root, scroll);
|
|
|
|
// 테이블 영역 우클릭은 브라우저 기본 메뉴를 막는다(2026-08-05 사용자 지시).
|
|
scroll.addEventListener("contextmenu", (event) => event.preventDefault());
|
|
|
|
// 유토곡선 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(종단 스크롤도 함께 움직인다).
|
|
attachWheelHorizontalScroll(scroll);
|
|
|
|
/** 손잡이는 오버레이 위 경계를, 접히면 열린 유토곡선의 위 경계(bottomOffset)를 따라간다
|
|
* — 접힌 버튼이 펼쳐진 패널 수평선에 놓인다(2026-08-05 사용자 지시). */
|
|
function syncHandlePosition(): void {
|
|
handle.style.bottom = open ? `${bottomOffset + overlay.offsetHeight}px` : `${bottomOffset}px`;
|
|
overlay.style.bottom = `${bottomOffset}px`;
|
|
}
|
|
|
|
function applyOpen(next: boolean): void {
|
|
open = next;
|
|
writeStateRaw("table-open", String(next));
|
|
handleControl.setOpen(next);
|
|
handleControl.root.title = next ? "테이블 접기" : "테이블 펼치기";
|
|
handle.classList.toggle("is-open", next);
|
|
overlay.hidden = !next;
|
|
syncHandlePosition();
|
|
onChanged();
|
|
}
|
|
handleControl.setOpen(open);
|
|
handleControl.root.title = open ? "테이블 접기" : "테이블 펼치기";
|
|
handle.classList.toggle("is-open", open);
|
|
overlay.hidden = !open;
|
|
handleControl.root.addEventListener("click", () => applyOpen(!open));
|
|
|
|
// 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다(유토곡선과 동일).
|
|
let syncing = false;
|
|
function mirror(from: HTMLElement, to: HTMLElement): void {
|
|
from.addEventListener("scroll", () => {
|
|
if (syncing) return;
|
|
syncing = true;
|
|
to.scrollLeft = from.scrollLeft;
|
|
syncing = false;
|
|
});
|
|
}
|
|
let syncedMain: HTMLElement | null = null;
|
|
|
|
return {
|
|
handle,
|
|
overlay,
|
|
isOpen: () => open,
|
|
desiredHeight: () => {
|
|
const raw = parseFloat(overlay.style.getPropertyValue(HEIGHT_VAR));
|
|
return clampHeight(Number.isFinite(raw) && raw > 0 ? raw : OVERLAY_DEFAULT_HEIGHT);
|
|
},
|
|
commitHeight: (px) => {
|
|
const next = Math.round(clampHeight(px));
|
|
overlay.style.setProperty(HEIGHT_VAR, `${next}px`);
|
|
writeStateRaw("table-height", String(next));
|
|
syncHandlePosition();
|
|
},
|
|
contentHeight: () => Math.max(0, overlay.offsetHeight - 6),
|
|
setTable(table) {
|
|
const keepScroll = scroll.scrollLeft;
|
|
scroll.replaceChildren(...(table ? [table] : []));
|
|
scroll.scrollLeft = keepScroll;
|
|
syncHandlePosition();
|
|
},
|
|
attachScrollSync(main) {
|
|
if (syncedMain === main) return;
|
|
syncedMain = main;
|
|
mirror(main, scroll);
|
|
mirror(scroll, main);
|
|
},
|
|
setBottomOffset(px) {
|
|
bottomOffset = px;
|
|
syncHandlePosition();
|
|
},
|
|
};
|
|
}
|