fix(B05 종단도): 392-2 하단 판 높이 제어 10-14 방식 되살림 — 유토곡선 = 종단 칸 바닥 덮는 판 · 세 영역 같은 비율

- 354-1 자기 판 · 머리줄(f79a33ef3) · 367-2 접음 높이 0 · 367-6 범례 비킴을 손으로 되돌림(이력 그대로)
- 판 끌기 = 종단 · 유토 · 표 세 영역 같은 비율 · 놓을 때 유토 · 표 높이 확정
- 유토 손잡이 = 판 위 경계 밖 · 표 손잡이와 ±44 px 나란히 · 접기 = 숨김 · 표 판 = 열린 유토 위 쌓임
- 남김 = 유토 · 배수 [창 분할](유토는 판 좌상단 = 펼친 때만) · 367-1 늘 전체 맞춤 · 367-5 · 354-3 · 325-2
- 시험 test_354_1 다시 씀(세 영역 비례 · 손잡이 자리) · test_367_6 다시 씀(손잡이 = 판 밖 · 나란히) · 224_1b · 224_1c · 230_2 · 367_5 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
This commit is contained in:
2026-10-17 17:22:47 +09:00
co-authored by Claude Opus 5.5
parent ed3f454c69
commit 8b72720d09
17 changed files with 385 additions and 299 deletions
+8 -2
View File
@@ -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;
+5 -5
View File
@@ -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,
+6 -10
View File
@@ -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 };
}
+11 -8
View File
@@ -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<HTMLElement>(".b05-profile__masshaul-overlay");
const massHead = massOverlay?.querySelector<HTMLElement>(".b05-profile__masshaul-head");
if (massOverlay && massHead) {
const massHandle = profile.root.querySelector<HTMLElement>(".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 = "";
},
});
}
+20 -11
View File
@@ -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;
+44 -15
View File
@@ -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<string, number> = {};
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,
),
};
}
+51 -25
View File
@@ -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;
},
@@ -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();
},
};
}
+18 -71
View File
@@ -43,73 +43,26 @@
}
/* 본문 칸을 세로로 쌓아 그래프 스크롤러 아래에 손잡이가 붙게 한다.
테이블 겹침 판의 절대배치 기준이기도 하다. */
오버레이의 절대배치 기준이기도 하다. */
.b05-route-profile__body-wrap {
position: relative;
flex-direction: column;
}
/* 종단 열 = 종단 칸 + 그 아래 유토곡선 판(354-1) — 배수유역도는 열 오른쪽. */
.b05-route-profile__column {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
}
/* 유토곡선 판 — 종단 칸 아래 자기 칸(354-1). 종단도 · 테이블을 덮지 않는다.
높이는 위 경계 리사이저가 --b05-masshaul-height로 조절한다. */
/* 오버레이 서브패널 — 패널 바닥(접기 손잡이 위)에 붙어 종단도·테이블 위를 덮는다
(2026-08-04 사용자 확정). 높이는 위 경계 리사이저가 --b05-masshaul-height로 조절한다. */
.b05-profile__masshaul-overlay {
position: relative;
position: absolute;
right: 0;
bottom: 0; /* 손잡이는 표준 삼각형(중앙, z-index 6)이라 자리를 차지하지 않는다 */
left: 0;
z-index: 5;
display: flex;
flex: none;
flex-direction: column;
height: var(--b05-masshaul-height, 280px);
border-top: 1px solid var(--color-border);
background: var(--color-surface-raised);
}
/* 머리줄 — 펼침에선 곡선 위 여백(padTop)에 떠 자리를 안 먹고, 접으면 판에 머리줄만 남는다. */
.b05-profile__masshaul-head {
position: absolute;
top: 4px;
right: 0;
left: var(--spacing-8);
/* 고정 Y축(z5 · 불투명) 위 — 아래면 [창 분할]이 축 띠에 가려짐 */
z-index: 6;
display: flex;
align-items: center;
min-height: 24px;
pointer-events: none;
}
.b05-profile__masshaul-head > * {
pointer-events: auto;
}
/* 접음 = 판 높이 0 · 손잡이만 판 위 경계에 매달림(배수유역 접힘 꼴 · 367-2). */
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) {
height: 0;
}
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > :not(.b05-profile__masshaul-head) {
display: none;
}
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > .b05-profile__masshaul-head {
top: auto;
bottom: 100%;
}
/* [창 분할]은 펼친 때만(367-2) — 새 창으로 뗀 [창 결합]은 그대로. */
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) .b05-subpanel__popout {
display: none;
}
/* 종단 패널을 접으면 판도 함께 숨는다(배수유역도와 같음). */
.b05-route-profile.is-collapsed .b05-profile__masshaul-overlay {
display: none;
box-shadow: 0 -4px 12px rgb(0 0 0 / 18%);
}
/* 공용 리사이저는 절대배치라 오버레이 위 경계에 그대로 앉는다. */
@@ -143,26 +96,18 @@
}
/* 범례 오버레이 — 유토곡선 우상단에 절대배치(B06과 같은 문법). 요약 막대 아래에 앉힌다.
공용 범례(.b06-masshaul__legend)가 이미 absolute + right 배치라 top만 이 래퍼가 정한다.
왼쪽 끝 = 손잡이(중앙 +44 · 폭 72) 오른쪽 — 좁으면 범례가 줄을 바꿔 손잡이를 피함(367-6). */
공용 범례(.b06-masshaul__legend)가 이미 absolute + right 배치라 top만 이 래퍼가 정한다. */
.b05-profile__masshaul-legend {
position: absolute;
top: 4px;
right: 0;
left: calc(50% + 92px);
left: 0;
z-index: 3;
height: 0;
pointer-events: none;
}
.b05-profile__masshaul-legend .b06-masshaul__legend {
left: 0;
flex-wrap: wrap;
justify-content: flex-end;
pointer-events: none;
}
.b05-profile__masshaul-legend .b06-masshaul__legend > * {
pointer-events: auto;
}
@@ -195,12 +140,13 @@
font-size: var(--text-caption);
}
/* 접기 손잡이 — 다른 패널들과 **같은 표준 삼각형 손잡이**를 그대로 쓴다
(2026-08-04 사용자 지시). 판 머리줄에 앉아 접어도 닿는다(354-1). 테이블 손잡이와
겹치지 않게 중앙에서 우측으로 비켜 세운다(2026-08-05 사용자 지시 — 좌: 테이블, 우: 유토곡선). */
/* 2차 슬라이드 손잡이 — 다른 패널들과 **같은 표준 삼각형 손잡이**를 그대로 쓴다
(2026-08-04 사용자 지시). 패널 본문 바닥에 붙고, 오버레이(z-index 5) 위에 뜬다.
접혔을 때 테이블 손잡이와 **일렬**로 놓이도록 중앙에서 우측으로 비켜 세운다
(2026-08-05 사용자 지시 — 좌: 테이블, 우: 유토곡선). */
.b05-profile__masshaul-handle {
position: absolute;
top: 0;
bottom: 0;
left: calc(50% + 44px);
z-index: 6;
transform: translateX(-50%);
@@ -215,7 +161,8 @@
}
/* ── 테이블 오버레이 서브패널 (2026-08-05 사용자 지시) ──────────────────────
종단 칸 바닥에 붙어 종단도 위를 덮는다. 유토곡선은 그 아래 자기 판(354-1). */
유토곡선과 같은 뼈대. 유토곡선이 열려 있으면 그 위에 쌓인다(bottom은 JS가 조절).
위→아래 순서: 종단도 → 테이블 → 유토곡선. */
.b05-profile__table-overlay {
position: absolute;
right: 0;
@@ -1,6 +1,6 @@
/* 224-1b 유토 판 높이 나눔 · 방향키 판정 — 진짜 높이 나눔(`createHeightCascade`)과 방향키 판정
/* 224-1b 유토 판 「붙어 있음」 판정 — 진짜 높이 나눔(`createHeightCascade`)과 방향키 판정
* (`arrowsBelongToProfile` 본문)을 가짜 DOM 위에서 돌려 JSON 으로 냄.
* · 유토곡선 = 종단 열 아래 자기 판(354-1) — 본문 높이에서 이미 빠져 나눔에 안 낌(입력에도 없음)
* · 열림 && 뿌리 안 = 자리 차지 · 열림 && 새 창(뿌리 밖) = 자리 0 · 인라인 높이 안 씀
* · 방향키 = 마지막 누름이 종단 뿌리 · 뗀 유토 · 뗀 배수 판 안이면 종단도 몫 */
const fs = require("fs");
const path = require("path");
@@ -44,27 +44,33 @@ new Function(
)(
heightsExports,
(id) =>
id.includes("TableOverlay")
? { TABLE_OVERLAY_MIN_HEIGHT: 120 }
: id.includes("Structures_Marks")
? { STRUCTURE_LANE_HEIGHT_PX: 40 }
: { frameOf: () => 0 },
id.includes("MassHaul")
? { MASSHAUL_MIN_HEIGHT: 160 }
: id.includes("TableOverlay")
? { TABLE_OVERLAY_MIN_HEIGHT: 120 }
: id.includes("Structures_Marks")
? { STRUCTURE_LANE_HEIGHT_PX: 40 }
: { frameOf: () => 0 },
{ innerHeight: 900 },
);
function run(tableDesired) {
function run(docked) {
const root = el("b05-route-profile");
root.offsetHeight = 500;
const body = root.append(el());
body.clientHeight = 400;
const overlay = el("b05-profile__table-overlay");
const overlay = el("b05-profile__masshaul-overlay");
if (docked) body.append(overlay);
else el("popup-body").append(overlay); // 새 창 body
const cascade = heightsExports.createHeightCascade({
root,
body,
massHaul: { overlay, desiredHeight: () => 300, syncHandle() {} },
tableOverlay: {
overlay,
isOpen: () => tableDesired > 0,
desiredHeight: () => tableDesired,
overlay: el(),
isOpen: () => false,
desiredHeight: () => 0,
setBottomOffset() {},
},
isMainDragging: () => false,
isSubDragging: () => false,
@@ -93,9 +99,8 @@ const card = el("b06-cross-card");
process.stdout.write(
JSON.stringify({
tableClosed: run(0),
tableFits: run(200),
tableShrinks: run(300),
docked: run(true),
popped: run(false),
arrows: {
profile: arrows(profileChild),
mass: arrows(massChild),
@@ -1,8 +1,8 @@
/* 224-1c 유토곡선 · 배수유역 [창 분할] — 진짜 `attachSubPanelPopouts` · `attachProfilePopout`
* (design_profile_popout.ts)를 가짜 DOM · 가짜 창 위에서 돌려 JSON 으로 냄.
* · 단추 자리 · 글 · 클래스 · 툴팁 · 창 이름 셋 다름
* · 단추 = 두 판 머리줄 맨 앞(유토는 접어도 남는 머리줄 · 354-1)
* · 뗌 = 새 창 body · 배수 rehome · 붙임(단추 · 새 창 닫힘 · dispose) = 제자리(종단 열 끝 · content 끝)
* · 뗌 = 새 창 body · 유토 손잡이 숨김 · 인라인 높이 비움 · 배수 rehome
* · 붙임(단추 · 새 창 닫힘 · dispose) = 제자리(유토 손잡이 앞 · content 끝)
* · 종단 새 창 안의 단추 = 그 창에서 엶 */
const fs = require("fs");
const path = require("path");
@@ -122,25 +122,21 @@ new Function("exports", "require", "window", "document", js)(
mainDoc,
);
// ---- 종단 뿌리 = [content = [열 = [종단 칸, 유토 판 = [머리줄 = [손잡이]]], 배수 판 = [머리줄 = [제목]]]] ----
// ---- 종단 뿌리 = [content = [bodyWrap = [유토 판, 유토 손잡이], 배수 판 = [머리줄 = [제목]]]] ----
const slot = el("div");
mainDoc.body.append(slot);
const root = el("div", "b05-route-profile");
slot.append(root);
const content = el("div", "b05-route-profile__content");
const column = el("div", "b05-route-profile__column");
const bodyWrap = el("div", "b05-route-profile__body-wrap");
const bodyWrap = el("div");
const massOverlay = el("div", "b05-profile__masshaul-overlay");
const massHead = el("div", "b05-profile__masshaul-head");
const massHandle = el("div", "b05-profile__masshaul-handle");
massHead.append(massHandle);
massOverlay.append(el("div", "ui-resizer"), massHead);
column.append(bodyWrap, massOverlay);
bodyWrap.append(massOverlay, massHandle);
const drainageRoot = el("aside", "b05-drainage");
const header = el("div", "b05-drainage__header");
header.append(el("h3"));
drainageRoot.append(header);
content.append(column, drainageRoot);
content.append(bodyWrap, drainageRoot);
root.append(content);
let rehomes = 0;
let disposed = 0;
@@ -154,18 +150,20 @@ const profileButton = el("button");
mod.attachProfilePopout(profile, slot, profileButton);
mod.attachSubPanelPopouts(profile);
const massButton = massHead.children[0];
const massButton = massOverlay.children.find((c) => c.tagName === "BUTTON");
const drainButton = header.children[0];
const btn = (b) => ({ text: b.textContent, title: b.title, className: b.className, type: b.type });
const out = { mass: btn(massButton), drainage: btn(drainButton), profile: btn(profileButton) };
out.massButtonInHead = massButton.tagName === "BUTTON" && massHead.children[1] === massHandle;
// 유토 뗌
massOverlay.style.height = "123px";
massButton.click();
const massPopup = massOverlay.parent?.doc?.defaultView;
out.massOpened = {
inPopupBody: massOverlay.parent === massPopup?.document.body,
isPopout: massOverlay.classList.contains("is-popout"),
handleHidden: massHandle.hidden,
inline: massOverlay.style.height,
text: massButton.textContent,
docTitle: massPopup?.document.title,
};
@@ -177,10 +175,11 @@ out.drainOpened = {
rehomes,
text: drainButton.textContent,
};
// 유토 새 창 닫힘 → 제자리(종단 열 끝)
// 유토 새 창 닫힘 → 제자리(손잡이 바로 앞)
massPopup.fire("pagehide");
out.massClosed = {
order: column.children.map((c) => c.className),
order: bodyWrap.children.map((c) => c.className),
handleHidden: massHandle.hidden,
isPopout: massOverlay.classList.contains("is-popout"),
text: massButton.textContent,
};
@@ -206,8 +205,10 @@ profile.dispose();
out.disposed = {
closed: [profilePopup.closed, massPopup2.closed, drainPopup2.closed],
profileInSlot: root.parent === slot,
massInColumnEnd: column.children[column.children.length - 1] === massOverlay,
massBeforeHandle:
bodyWrap.children.indexOf(massOverlay) === bodyWrap.children.indexOf(massHandle) - 1,
drainInContent: drainageRoot.parent === content,
handleHidden: massHandle.hidden,
original: disposed,
};
out.opened = opened;
+15 -17
View File
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
"""224-1b(PLAN 325-2 · 354-1) 유토곡선 판 높이 나눔 · 방향키.
"""224-1b(PLAN 325-2) 유토곡선 판 「붙어 있음」 판정 · 방향키.
- 유토곡선 = 종단 열 아래 자기 판(354-1) — 높이 나눔 · 끌기에 안 낌(본문 높이에서 이미 빠짐) ·
인라인 임시 축소도 안 씀. 나눔은 그래프 · 표 둘.
- 높이 나눔 · 끌기는 「열림 && 종단 뿌리 안」만 자리를 줌 — 새 창으로 뗀 유토 판은 메인 자리 0 ·
임시 축소 인라인 높이도 안 씀(뗀 창 높이가 굳지 않게).
- 뗀 유토 판(`is-popout`)은 메인 보이는 구간 세로 범위(viewRange)를 안 씀 — 노선 전체 세로.
- 방향키 ←→ 판정은 마지막 누름이 종단 뿌리 · 뗀 유토 · 뗀 배수 판 안이면 종단도 몫.
@@ -33,12 +33,13 @@ def r():
return json.loads(proc.stdout)
def test_유토_판은_나눔에_안_낌(r):
"""본문 400 − 알약 40 = 360 · 손잡이 띠 13 → 표 없음 그래프 347."""
assert r["tableClosed"] == {"chartHeight": 347, "inline": ""}
assert r["tableFits"] == {"chartHeight": 147, "inline": ""}
# 표 300 은 예산 247 넘어 247 로 줄고 그래프 100
assert r["tableShrinks"] == {"chartHeight": 100, "inline": "247px"}
def test_붙은_유토는_자리를_차지(r):
"""본문 400 − 알약 40 = 360 · 유토 300 은 예산 247 넘어 247 로 줄고 그래프 100."""
assert r["docked"] == {"chartHeight": 100, "inline": "247px"}
def test_뗀_유토는_자리_0_인라인_없음(r):
assert r["popped"] == {"chartHeight": 347, "inline": ""}
def test_방향키_판정(r):
@@ -51,15 +52,12 @@ def test_방향키_판정(r):
}
def test_끌기도_유토_판_안_셈():
def test_끌기도_같은_판정():
drag = (B05 / "B05_Profile_UI_Profile_Drag.ts").read_text(encoding="utf-8")
heights = (B05 / "B05_Profile_UI_Profile_Heights.ts").read_text(encoding="utf-8")
for src in (drag, heights):
assert "massDocked" not in src and "MASSHAUL_MIN_HEIGHT" not in src
assert "mass:" not in src
# 유토 판 리사이저 끌기 출처만 봄
assert drag.count("massHaul.overlay") == 1
assert "massHaul" not in heights
assert "massHaul.overlay.hidden" in drag # 판정 함수 한 곳에만
assert drag.count("massHaul.overlay.hidden") == 1
assert drag.count("massDocked()") == 3
assert "root.contains(massHaul.overlay)" in drag
def test_뗀_유토는_노선_전체_세로():
@@ -1,10 +1,10 @@
# -*- coding: utf-8 -*-
"""224-1c(PLAN 325-3) 유토곡선 · 배수유역 [창 분할] — 종단 [창 분할]과 같은 양식 · 따로 새 창.
- 단추 = 두 판 머리줄 맨 앞(유토는 펼친 때만 보임 · 367-2) · 글 [창 분할]/[창 결합] · 툴팁에 판 이름.
- 단추 = 유토 판 좌상단 · 배수 머리줄 맨 앞 · 글 [창 분할]/[창 결합] · 툴팁에 판 이름.
- 창 이름 셋 다름(같으면 앞 창 body 를 덮어 조각이 사라짐).
- 뗌 = 새 창 body · 뗀 유토 판 손잡이 · 리사이저는 CSS 로 감춤 · 배수 지도 관찰 rehome.
- 붙임(단추 · 새 창 닫힘 · 페이지 떠남) = 제자리 — 종단 열 끝(종단 칸 아래) · 배수 content 끝.
- 뗌 = 새 창 body · 유토 손잡이 숨김 · 인라인 높이 비움 · 배수 지도 관찰 rehome.
- 붙임(단추 · 새 창 닫힘 · 페이지 떠남) = 제자리 — 유토 손잡이 바로 앞 · 배수 content 끝.
- 종단 새 창 안에서 다시 떼면 그 창이 새 창을 엶(메인 창 팝업 차단 피함).
`helper_224_1c_subpanel_popout.cjs` 가 진짜 TS 를 가짜 DOM · 가짜 창 위에서 돌림.
@@ -51,13 +51,14 @@ def test_단추_글_툴팁_클래스(r):
"type": "button",
}
assert "b05-route-profile__popout" in r["profile"]["className"]
assert r["massButtonInHead"] is True
def test_유토_뗌(r):
assert r["massOpened"] == {
"inPopupBody": True,
"isPopout": True,
"handleHidden": True,
"inline": "",
"text": "창 결합",
"docTitle": "유토곡선 — Aislo",
}
@@ -69,7 +70,8 @@ def test_배수_뗌은_rehome(r):
def test_새_창_닫힘과_단추로_제자리(r):
assert r["massClosed"] == {
"order": ["b05-route-profile__body-wrap", "b05-profile__masshaul-overlay"],
"order": ["b05-profile__masshaul-overlay", "b05-profile__masshaul-handle"],
"handleHidden": False,
"isPopout": False,
"text": "창 분할",
}
@@ -88,8 +90,9 @@ def test_페이지_떠나면_셋_다_닫고_제자리(r):
assert r["disposed"] == {
"closed": [True, True, True],
"profileInSlot": True,
"massInColumnEnd": True,
"massBeforeHandle": True,
"drainInContent": True,
"handleHidden": False,
"original": 1,
}
@@ -101,5 +104,4 @@ def test_배선과_양식():
block = re.search(r"\n\.b05-subpanel__popout \{([^}]*)\}", css).group(1)
assert "var(--btn-bar-padding)" in block and "var(--btn-bar-font-size)" in block
assert ".b05-profile__masshaul-overlay.is-popout," in css
assert ".b05-profile__masshaul-overlay.is-popout .b05-profile__masshaul-handle," in css
assert ".b05-drainage.is-popout {" in css
+1 -2
View File
@@ -67,8 +67,7 @@ def test_높이_맞춤(r):
def test_표_유토곡선_크기_끌기_다시_그림(r):
assert r["table"] == {"changed": 1, "handles": 2, "mainTimers": 0}
# 유토 판 손잡이는 머리줄에 앉아 끌기 때 따라 옮길 것이 없음(354-1)
assert r["massHaul"] == {"changed": 1, "handles": 0, "mainTimers": 0}
assert r["massHaul"] == {"changed": 1, "handles": 2, "mainTimers": 0}
def test_유토곡선_세로창_미끄러짐(r):
+112 -67
View File
@@ -1,14 +1,17 @@
# -*- coding: utf-8 -*-
"""354-1 유토곡선 = 배수유역도처럼 자기 판(요구 235-7).
"""354-1 → 392-2 유토곡선 판 = 10-14 판(9edaec82e) 되살림(요구 242-10).
- 종단 열(`b05-route-profile__column`) = [종단 칸(bodyWrap), 유토 판] · content 안이라 종단 [창 분할]
(`.is-popout > :not(content)` 숨김)에도 같이 감 · B05 · B06 이 같은 `design_profile` 하나.
- 유토 판은 흐름 칸(겹침 아님) · 자기 리사이저 높이 · 접으면 높이 0 · 손잡이만 매달림 · [창 분할] 숨김(367-2).
- 높이 나눔 · 표 쌓기에서 빠짐(표 bottomOffset 없음) · X 동기화 없음(367-1 늘 전체 맞춤).
- 유토곡선 = 종단 칸(bodyWrap) 바닥 덮는 판(absolute · z5) · 자기 판(종단 열 아래 칸) 지움.
- 메인 판 끌기 = 종단 · 유토 · 표 세 영역 같은 비율 · 놓을 때 유토 · 표 높이 확정(commitHeight).
- 유토 손잡이 = 판 위 경계 밖(bottom = 판 높이) · 표 손잡이와 ±44 px 나란히 · 접기 = 숨김.
- 표 판 = 열린 유토 위에 쌓임(setBottomOffset).
- 남김 = 유토 [창 분할](판 안 좌상단 = 펼친 때만 보임) · 367-1 늘 전체 맞춤 · 325-2 뿌리 안 판정.
"""
import json
import os
import re
import subprocess
from pathlib import Path
HERE = os.path.dirname(os.path.abspath(__file__))
@@ -25,73 +28,115 @@ def css_block(css: str, selector: str) -> str:
return m.group(1)
def test_종단_열_아래_자기_칸():
# 진짜 높이 나눔(createHeightCascade) — 메인 끌기 중 세 영역 비례
NODE = r"""
const path = require("path"), fs = require("fs");
const root = process.argv[1];
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const src = fs.readFileSync(path.join(root, "B05_Profile", "B05_Profile_UI_Profile_Heights.ts"), "utf8");
const js = ts.transpileModule(src, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
const exp = {};
new Function("exports", "require", "window", js)(exp, (id) =>
id.includes("MassHaul") ? { MASSHAUL_MIN_HEIGHT: 160 }
: id.includes("TableOverlay") ? { TABLE_OVERLAY_MIN_HEIGHT: 120 }
: id.includes("Structures_Marks") ? { STRUCTURE_LANE_HEIGHT_PX: 40 } : { frameOf: () => 0 },
{ innerHeight: 900 });
const el = () => { const n = { hidden: false, style: { height: "" }, parent: null };
n.append = (k) => ((k.parent = n), k);
n.contains = (o) => { for (let a = o; a; a = a.parent) if (a === n) return true; return false; };
return n; };
function run(bodyH) {
const rootEl = el(); rootEl.offsetHeight = bodyH + 100;
const body = rootEl.append(el()); body.clientHeight = bodyH;
const mass = body.append(el()), table = body.append(el());
const seen = { handle: 0, offset: null };
const c = exp.createHeightCascade({
root: rootEl, body,
massHaul: { overlay: mass, desiredHeight: () => 200, syncHandle: () => seen.handle++ },
tableOverlay: { overlay: table, isOpen: () => true, desiredHeight: () => 200,
setBottomOffset: (v) => (seen.offset = v) },
isMainDragging: () => true, isSubDragging: () => false,
// 끌기 시작 = 종단 400 · 유토 200 · 표 200
mainDragRef: () => ({ chart: 400, mass: 200, table: 200 }),
isMainDragCooldown: () => true, hasDetail: () => true,
});
const { chartHeight } = c.apply(false);
return { chart: chartHeight, mass: mass.style.height, table: table.style.height, ...seen };
}
// 본문 − 알약 40 − 손잡이 띠 13 = 나눌 자리 · 800 → 1.0 배 · 1200 → 1.5 배
process.stdout.write(JSON.stringify({ same: run(853), grow: run(1253) }));
"""
def test_메인_끌기_세_영역_같은_비율():
res = subprocess.run(
["node", "-e", NODE, str(ROOT)],
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
out = json.loads(res.stdout)
assert out["same"] == {
"chart": 400,
"mass": "200px",
"table": "200px",
"handle": 1,
"offset": 200,
}
# 1.5 배 — 셋 다 같은 비율 · 표는 유토 위에 쌓임(바닥 간격 = 유토 높이)
assert out["grow"] == {
"chart": 600,
"mass": "300px",
"table": "300px",
"handle": 1,
"offset": 300,
}
def test_놓을_때_높이_확정_뿌리_안_판정():
drag = read("B05_Profile/B05_Profile_UI_Profile_Drag.ts")
assert "if (massDocked()) massHaul.commitHeight(massHaul.overlay.offsetHeight);" in drag
assert "if (tableOverlay.isOpen()) tableOverlay.commitHeight(" in drag
# 325-2 「열림 && 뿌리 안」 한 곳 · 하한에 유토 최소
assert drag.count("massHaul.overlay.hidden") == 1
assert "root.contains(massHaul.overlay)" in drag
assert "const massMin = massDocked() ? MASSHAUL_MIN_HEIGHT : 0;" in drag
heights = read("B05_Profile/B05_Profile_UI_Profile_Heights.ts")
assert '{ key: "mass", ref: Math.max(1, dragRef.mass), min: MASSHAUL_MIN_HEIGHT }' in heights
def test_종단_칸_바닥_덮는_판():
overlays = read("A00_Common/design/design_profile_overlays.ts")
assert 'column.className = "b05-route-profile__column";' in overlays
assert "column.append(bodyWrap, massHaul.overlay);" in overlays
assert "bodyWrap.append(massHaul.overlay" not in overlays
assert "bodyWrap.append(massHaul.overlay, massHaul.handle);" in overlays
assert "b05-route-profile__column" not in overlays
assert "massHaul.attachScrollSync" not in overlays # 367-1 측점 연계 없앰
profile = read("A00_Common/design/design_profile.ts")
assert "content.append(column);" in profile
assert "content.append(bodyWrap);" in profile
assert sum(1 for _ in profile.splitlines()) <= 700
def test_종단_창_분할에도_안_숨음():
page = read("A00_Common/design/design_page.css")
assert ".b05-route-profile.is-popout > :not(.b05-route-profile__content)" in page
# 유토 판은 뿌리 바로 아래가 아니라 content > column 안
overlays = read("A00_Common/design/design_profile_overlays.ts")
assert "root.append" not in overlays
def test_흐름_칸_자기_높이_접으면_머리줄():
css = read("B05_Profile/B05_Profile_UI_Style_MassHaul.css")
panel = css_block(css, ".b05-profile__masshaul-overlay")
assert "position: relative;" in panel and "absolute" not in panel
assert "height: var(--b05-masshaul-height, 280px);" in panel
assert "flex: none;" in panel
column = css_block(css, ".b05-route-profile__column")
assert "flex-direction: column;" in column
hide = css_block(
css,
".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > :not(.b05-profile__masshaul-head)",
)
assert "display: none;" in hide
# 367-2 접음 = 높이 0 · 손잡이 판 위 경계에 매달림 · [창 분할] 숨김(뗀 창 [창 결합] 그대로)
folded = css_block(css, ".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout)")
assert "height: 0;" in folded
head = css_block(
css,
".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > .b05-profile__masshaul-head",
)
assert "top: auto;" in head and "bottom: 100%;" in head
assert "display: none;" in css_block(
css, ".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) .b05-subpanel__popout"
)
assert ".is-popout .b05-subpanel__popout" not in css
assert "display: none;" in css_block(
css, ".b05-route-profile.is-collapsed .b05-profile__masshaul-overlay"
)
assert "position: absolute;" in panel and "bottom: 0;" in panel and "z-index: 5;" in panel
assert "masshaul-head" not in css and "is-collapsed:not(.is-popout)" not in css
def test_손잡이_자리_접기_숨김():
css = read("B05_Profile/B05_Profile_UI_Style_MassHaul.css")
assert "left: calc(50% + 44px);" in css_block(css, ".b05-profile__masshaul-handle")
assert "left: calc(50% - 44px);" in css_block(css, ".b05-profile__table-handle")
mass = read("B05_Profile/B05_Profile_UI_Profile_MassHaul.ts")
assert "head.append(handle);" in mass
assert "overlay.append(resizer.root, head, bar, scroll, legendLayer, pendingChip);" in mass
assert 'overlay.classList.toggle("is-collapsed", !next);' in mass
assert "overlay.hidden" not in mass
# 새 창으로 뗀 판은 접힘이어도 그림
assert 'const shown = (): boolean => open || overlay.classList.contains("is-popout");' in mass
def test_창_분할_단추는_머리줄_맨_앞():
popout = read("A00_Common/design/design_profile_popout.ts")
assert "massHead.prepend(button);" in popout
assert "home: () => column?.append(massOverlay)," in popout
page = read("A00_Common/design/design_page.css")
assert ".b05-profile__masshaul-overlay > .b05-subpanel__popout" not in page
def test_높이_나눔_표_쌓기에서_빠짐():
heights = read("B05_Profile/B05_Profile_UI_Profile_Heights.ts")
# 손잡이 = 판 위 경계 밖 · 접으면 바닥 · 접기 = 판 숨김
assert 'handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px";' in mass
assert "overlay.hidden = !open;" in mass and "overlay.hidden = !next;" in mass
table = read("B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts")
assert "massHaul" not in heights
assert "setBottomOffset" not in heights and "setBottomOffset" not in table
assert "bottomOffset" not in table
assert "overlay.style.bottom = `${bottomOffset}px`;" in table
def test_창_분할_판_좌상단():
popout = read("A00_Common/design/design_profile_popout.ts")
assert "massOverlay.append(button);" in popout
assert "home: () => massHandle.before(massOverlay)," in popout
page = read("A00_Common/design/design_page.css")
assert "position: absolute;" in css_block(
page, ".b05-profile__masshaul-overlay > .b05-subpanel__popout"
)
@@ -2,7 +2,7 @@
B06 꼴(가운데 카드 칸 + 아래 종단 판)을 진짜 CSS 로 세우고 판을 펼쳤다 접은 **바로 뒤**
카드 자리 elementFromPoint = 카드. 높이 transition 이 있으면 그 순간 판이 아직 펼친 높이라
`b05-route-profile__column` 이 누름을 받음(그려지지 않는 탭에선 전환이 시작도 안 해 그대로 멈춤).
종단 판(`b05-route-profile__*`)이 누름을 받음(그려지지 않는 탭에선 전환이 시작도 안 해 그대로 멈춤).
"""
from pathlib import Path
@@ -24,9 +24,9 @@ HTML = """
</div></div>
<div class="design-page__profile">
<section class="b05-route-profile" style="--b05-profile-height:600px">
<div class="b05-route-profile__content"><div class="b05-route-profile__column">
<div class="b05-route-profile__content">
<div class="b05-route-profile__body-wrap"><div class="b05-route-profile__body"></div></div>
</div></div>
</div>
</section>
</div>
</div>
@@ -1,8 +1,8 @@
"""367-6 유토곡선 손잡이 · 범례 안 겹침 — 실제 브라우저(서버 없이 · 진짜 CSS).
"""367-6 → 392-2 유토곡선 손잡이 · 범례 안 겹침 — 실제 브라우저(서버 없이 · 진짜 CSS).
유토 판 머리줄(손잡이 = 중앙 +44)과 범례(오른쪽 붙임)를 진짜 CSS 로 세우고 판 폭마다
범례 단추 셋 · 손잡이 가운데 elementFromPoint = 그 단추. 좁은 폭 = 배수유역 판이 펼친 B06 ·
B05 종단 열(≈ 434px) · 더 좁은 열 · 넓은 폭 = 배수유역 접은 칸(1162px) — 넓을 땐 한 줄 그대로.
10-14 판(392-2) — 유토 손잡이는 판 위 경계 **밖**(bottom = 판 높이 · JS 가 넣음)이라 판 안
범례(오른쪽 붙임)와 안 겹침 · 접힌 표 손잡이(바닥 간격 = 유토 높이)와 ±44 px 나란히.
판 폭마다 범례 단추 셋 · 두 손잡이 가운데 elementFromPoint = 그 단추.
"""
from pathlib import Path
@@ -15,26 +15,33 @@ CSS = [
"B05_Profile/B05_Profile_UI_Style_MassHaul.css",
]
BUTTONS = ["도형 위치 초기화", "횡단 기준", "토량 분배"]
HTML = """
<div class="b05-profile__masshaul-overlay" style="width:{w}px;margin-top:40px">
<div class="b05-profile__masshaul-head"><div class="b05-profile__masshaul-handle">
HANDLE = """<div class="b05-profile__{k}-handle" style="bottom:280px">
<button class="ui-workflow-overlay__handle ui-workflow-overlay__handle--bottom is-labeled">
<span class="ui-workflow-overlay__handle-icon"></span>
<span class="ui-workflow-overlay__handle-label">유토곡선</span></button>
</div></div>
<div class="b05-profile__masshaul-scroll"></div>
<div class="b05-profile__masshaul-legend"><div class="b06-masshaul__legend">
<button class="b06-masshaul__legend-item b06-masshaul__legend-item--reset">도형 위치 초기화</button>
<button class="b06-masshaul__legend-item"><span class="b06-masshaul__legend-swatch"></span><span>횡단 기준</span></button>
<button class="b06-masshaul__legend-item"><span class="b06-masshaul__legend-swatch"></span><span>토량 분배</span></button>
</div></div>
</div>
"""
<span class="ui-workflow-overlay__handle-label">{label}</span></button></div>"""
HTML = (
"""
<div class="b05-route-profile__body-wrap" style="display:flex;width:{w}px;height:520px">
<div class="b05-profile__masshaul-overlay">
<div class="b05-profile__masshaul-scroll"></div>
<div class="b05-profile__masshaul-legend"><div class="b06-masshaul__legend">
<button class="b06-masshaul__legend-item b06-masshaul__legend-item--reset">도형 위치 초기화</button>
<button class="b06-masshaul__legend-item"><span class="b06-masshaul__legend-swatch"></span><span>횡단 기준</span></button>
<button class="b06-masshaul__legend-item"><span class="b06-masshaul__legend-swatch"></span><span>토량 분배</span></button>
</div></div>
</div>
"""
+ HANDLE.replace("{k}", "masshaul").replace("{label}", "유토곡선")
+ HANDLE.replace("{k}", "table").replace("{label}", "테이블")
+ "\n</div>"
)
PROBE = """() => [...document.querySelectorAll('button')].map((b) => {
const r = b.getBoundingClientRect();
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return [b.textContent.trim(), b === hit || b.contains(hit), Math.round(r.top)];
return [b.textContent.trim(), b === hit || b.contains(hit), Math.round(r.top),
Math.round(r.left + r.width / 2), Math.round(b.parentElement.getBoundingClientRect().bottom)];
})"""
TOP = "Math.round(document.querySelector('.b05-profile__masshaul-overlay').getBoundingClientRect().top)"
def test_legend_buttons_clear_of_handle():
@@ -47,9 +54,11 @@ def test_legend_buttons_clear_of_handle():
for width in (360, 434, 700, 1162):
page.set_content(f"<style>body{{margin:0}}{css}</style>{HTML.format(w=width)}")
rows = page.evaluate(PROBE)
assert [r[0] for r in rows] == ["유토곡선", *BUTTONS]
assert [r[0] for r in rows] == [*BUTTONS, "유토곡선", "테이블"]
assert all(r[1] for r in rows), (width, rows)
if width == 1162:
# 넓을 땐 지금 모양 — 범례 한 줄
assert len({r[2] for r in rows[1:]}) == 1, rows
mass, table = rows[3], rows[4]
# 손잡이 = 판 위 경계 밖(손잡이 바닥 = 판 위) · 두 손잡이 한 줄 · 가운데 ±44
assert mass[4] == page.evaluate(TOP), (width, rows)
assert mass[2] == table[2]
assert mass[3] - table[3] == 88
browser.close()