Files
Aislo/B05_Profile/B05_Profile_UI_Profile_Drag.ts
T

220 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B05_Profile_UI_Profile_Drag.ts
* 종단도 **높이 끌기** — 위쪽 경계 손잡이와, 메인 · 서브패널(유토곡선 · 표) 손잡이를
* 끄는 동안의 출처 추적.
*
* 종단도 한 벌(`A00_Common/design/design_profile.ts`)이 700줄을 넘겨 떼어낸 조각이다
* (계획서 24-2c). 본문 로직과 수치는 그대로다.
* ========================================================================== */
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 { 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;
/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */
const MIN_PANEL_HEIGHT = 180;
/** 접힘·높이는 화면 취향(①) — 두 보기가 **같은 키 하나**를 쓴다(계획서 24-2c). */
const HEIGHT_KEY = stateKey("profile-height") ?? "";
/** 옛 횡단 보기 위 종단 패널의 접힘 · 높이 값을 한 번 옮겨 받는다(새 키가 비었을 때만). */
export function adoptLegacyProfileKeys(): void {
const pairs = [
["section-panel-collapsed", "profile-collapsed"],
["section-panel-height", "profile-height"],
] as const;
for (const [from, to] of pairs) {
const old = readStateRaw(from);
if (old === null) continue;
if (readStateRaw(to) === null) writeStateRaw(to, old);
writeStateRaw(from, null);
}
}
/** 접기 손잡이 — 누르면 접고 펴며, 펼칠 때 다시 그린다. 처음 상태는 저장값. */
export function bindProfileCollapse(
root: HTMLElement,
handle: { root: HTMLElement; setOpen: (open: boolean) => void },
onOpen: () => void,
): void {
const setCollapsed = (collapsed: boolean): void => {
root.classList.toggle("is-collapsed", collapsed);
handle.setOpen(!collapsed);
writeStateRaw("profile-collapsed", String(collapsed));
if (!collapsed) onOpen();
};
handle.root.addEventListener("click", () =>
setCollapsed(!root.classList.contains("is-collapsed")),
);
setCollapsed(readStateRaw("profile-collapsed") === "true");
}
/**
* 계획고 ▲▼ 를 **누르고 있는 동안 세로 창을 고정**한다 — 끌어 올리는 동안 축까지 따라
* 움직이면 조작 감각이 깨진다. 손을 뗀 뒤 한 번만 다시 맞춘다(`redraw`).
*/
export function createElevationHold(
body: HTMLElement,
redraw: () => void,
): {
hold: (next: { min: number; max: number } | null) => { min: number; max: number } | undefined;
editing: () => boolean;
} {
/** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */
let elevationWindow: { min: number; max: number } | undefined;
let heightEditing = false;
body.addEventListener("pointerdown", (event) => {
if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return;
heightEditing = true;
const release = (): void => {
heightEditing = false;
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
redraw();
};
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
});
return {
hold(next) {
if (heightEditing) return elevationWindow;
elevationWindow = next ?? undefined;
return elevationWindow;
},
editing: () => heightEditing,
};
}
/** 겹침 판(표) 중 끌기가 보는 것만. */
interface SubPanel {
overlay: HTMLElement;
commitHeight: (height: number) => void;
}
export interface ProfileDragContext {
root: HTMLElement;
body: HTMLElement;
/** 유토곡선 판 — 그 리사이저 끌기 출처만 본다(자기 판이라 높이 나눔에 안 낌 · 354-1). */
massHaul: { overlay: HTMLElement };
tableOverlay: SubPanel & { isOpen: () => boolean };
/** 손을 뗀 뒤 한 번 다시 세운다. */
draw: () => void;
}
export interface ProfileDrag {
resizerRoot: HTMLElement;
isMainDragging: () => boolean;
isSubDragging: () => boolean;
mainDragRef: () => { chart: number; table: number } | null;
isMainDragCooldown: () => boolean;
clearMainDragCooldown: () => void;
/** 전체 재구성을 120ms 뒤로 미룬다(연속 호출은 마지막 한 번만). */
scheduleDraw: () => void;
cancelDraw: () => void;
dispose: () => void;
}
export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
const { root, body, massHaul, tableOverlay } = ctx;
let resizeTimer = 0;
/** 시계는 종단이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */
let timerView: Window = window;
const scheduleDraw = (): void => {
timerView.clearTimeout(resizeTimer);
timerView = viewOf(root);
resizeTimer = timerView.setTimeout(ctx.draw, 120);
};
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
// 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다.
const heightResizer = createPanelResizer({
axis: "vertical",
target: root,
cssVar: "--b05-profile-height",
direction: -1,
// 동적 하한: 종단 최소 + 열린 표 최소 높이(2026-08-05 사용자 확정) — 유토곡선 판 높이는
// chrome(뿌리 − 본문)에 이미 들어 있다(354-1).
// 패널을 아래로 끌면 종단이 먼저 줄고 → 서브패널들이 같이 줄고(draw의 캐스케이드) →
// 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단).
min: () => {
const chrome = Math.max(0, root.offsetHeight - body.clientHeight);
const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0;
return Math.max(
MIN_PANEL_HEIGHT,
chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + tableMin,
);
},
// 상한 = 가운데 칸 높이의 90%(위 칸 3D · 횡단 카드가 다 가려지지 않게) — Heights 와 한 식
max: () => profilePanelMaxHeight(root),
storageKey: HEIGHT_KEY,
});
/* ── 리사이저 드래그 출처 추적(2026-08-05 진동·복귀 버그 수정) ──────────────
* 메인 패널을 끄는 중엔 grow(자동 확장)가 포인터와 싸우면 안 되고, 서브패널을 끄는
* 중엔 캐스케이드의 임시 축소(인라인)가 드래그 변수 값을 덮으면 안 된다. */
let mainPanelDragging = false;
let subPanelDragging = false;
/** 메인 드래그 손을 뗀 뒤 **첫 전체 재구성(draw)까지** grow 금지 — 120ms 지연 draw는
* 플래그가 이미 풀린 채 돌아, 드래그 중 금지했던 자동 확대가 손 떼는 순간 발동해
* 포인터가 정한 높이를 되돌렸다(2026-08-06 분석 원인 4). draw()가 끝나며 푼다. */
let mainDragCooldown = false;
/** 메인 드래그 시작 시점의 두 영역(종단·테이블) 실제 높이 — 비례 연동 기준.
* 드래그 중 이 비율대로 같이 늘리고 줄인다(2026-08-06 사용자 확정). */
let mainDragRef: { chart: number; table: number } | null = null;
heightResizer.root.addEventListener("pointerdown", () => {
mainPanelDragging = true;
const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0;
mainDragRef = {
chart: Math.max(MIN_CHART_HEIGHT, body.clientHeight - tableH - MASSHAUL_HANDLE_GUTTER_PX),
table: tableH,
};
});
[massHaul.overlay, tableOverlay.overlay].forEach((overlay) =>
overlay.querySelector(".ui-resizer")?.addEventListener("pointerdown", () => {
subPanelDragging = true;
}),
);
const clearDragFlags = (): void => {
if (mainPanelDragging) mainDragCooldown = true;
if (mainPanelDragging && mainDragRef) {
// 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정
// 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전
// 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다).
if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight);
tableOverlay.overlay.style.height = "";
}
mainDragRef = null;
// 드래그 중엔 경량 동기화만 하므로, 손을 뗀 뒤 전체 재구성 1회를 예약한다.
if (mainPanelDragging || subPanelDragging) scheduleDraw();
mainPanelDragging = false;
subPanelDragging = false;
};
window.addEventListener("pointerup", clearDragFlags);
window.addEventListener("pointercancel", clearDragFlags);
return {
resizerRoot: heightResizer.root,
isMainDragging: () => mainPanelDragging,
isSubDragging: () => subPanelDragging,
mainDragRef: () => mainDragRef,
isMainDragCooldown: () => mainDragCooldown,
clearMainDragCooldown: () => {
mainDragCooldown = false;
},
scheduleDraw,
cancelDraw: () => timerView.clearTimeout(resizeTimer),
dispose() {
timerView.clearTimeout(resizeTimer);
heightResizer.dispose();
window.removeEventListener("pointerup", clearDragFlags);
window.removeEventListener("pointercancel", clearDragFlags);
},
};
}