- 두 보기 같은 오른쪽 판 · 같은 켬끔 값(drainage-visible) · 단추는 도구줄 오른쪽(Profile_SideTool)
- B06 배수유역도 노선 평면선 = 최신 노선 캐시(없으면 한 번 읽기)
- 최소 흙덮기 강제 여부를 두 보기가 한 칸(profile-enforce-min-cover)에서 봄 — B06 막기 동작
- 계획고 초안 b05-profile-alignment-draft:{노선} → aislo:draft:alignment-draft:{프로젝트}:{노선}(옛 값 옮겨 받음) · 되돌리기 사진 · [초기화] 쓸기 포함
- 세로 창 고정을 _Profile_Drag 로(design_profile 684줄) · 시험 test_design_profile_single 6건
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
223 lines
9.7 KiB
TypeScript
223 lines
9.7 KiB
TypeScript
/* =============================================================================
|
|
* 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 { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
|
|
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
|
|
|
|
const MIN_CHART_HEIGHT = 100;
|
|
/** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게.
|
|
* 구조물 알약 레인이 그 위에 붙은 뒤로는 사이가 떠 보여 절반으로 줄였다
|
|
* (2026-08-17 사용자 지시 2). */
|
|
const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
|
/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */
|
|
const MIN_PANEL_HEIGHT = 180;
|
|
/** 상한은 위 칸(3D · 횡단 카드)이 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */
|
|
const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
|
|
|
/** 접힘·높이는 화면 취향(①) — 두 보기가 **같은 키 하나**를 쓴다(계획서 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;
|
|
massHaul: SubPanel;
|
|
tableOverlay: SubPanel & { isOpen: () => boolean };
|
|
/** 손을 뗀 뒤 한 번 다시 세운다. */
|
|
draw: () => void;
|
|
}
|
|
|
|
export interface ProfileDrag {
|
|
resizerRoot: HTMLElement;
|
|
isMainDragging: () => boolean;
|
|
isSubDragging: () => boolean;
|
|
mainDragRef: () => { chart: number; mass: 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;
|
|
const scheduleDraw = (): void => {
|
|
window.clearTimeout(resizeTimer);
|
|
resizeTimer = window.setTimeout(ctx.draw, 120);
|
|
};
|
|
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
|
|
// 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다.
|
|
const heightResizer = createPanelResizer({
|
|
axis: "vertical",
|
|
target: root,
|
|
cssVar: "--b05-profile-height",
|
|
direction: -1,
|
|
// 동적 하한: 종단 최소 + 열린 서브패널들의 최소 높이 합(2026-08-05 사용자 확정).
|
|
// 패널을 아래로 끌면 종단이 먼저 줄고 → 서브패널들이 같이 줄고(draw의 캐스케이드) →
|
|
// 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단).
|
|
min: () => {
|
|
const chrome = Math.max(0, root.offsetHeight - body.clientHeight);
|
|
const massMin = massHaul.overlay.hidden ? 0 : MASSHAUL_MIN_HEIGHT;
|
|
const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0;
|
|
return Math.max(
|
|
MIN_PANEL_HEIGHT,
|
|
chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin,
|
|
);
|
|
},
|
|
max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
|
|
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;
|
|
/** 메인 드래그 시작 시점의 3영역(종단·유토곡선·테이블) 실제 높이 — 비례 연동 기준.
|
|
* 드래그 중 이 비율대로 같이 늘리고 줄인다(2026-08-06 사용자 확정). */
|
|
let mainDragRef: { chart: number; mass: number; table: number } | null = null;
|
|
heightResizer.root.addEventListener("pointerdown", () => {
|
|
mainPanelDragging = true;
|
|
const massH = massHaul.overlay.hidden ? 0 : massHaul.overlay.offsetHeight;
|
|
const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0;
|
|
mainDragRef = {
|
|
chart: Math.max(
|
|
MIN_CHART_HEIGHT,
|
|
body.clientHeight - massH - tableH - MASSHAUL_HANDLE_GUTTER_PX,
|
|
),
|
|
mass: massH,
|
|
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 (!massHaul.overlay.hidden) massHaul.commitHeight(massHaul.overlay.offsetHeight);
|
|
if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight);
|
|
massHaul.overlay.style.height = "";
|
|
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: () => window.clearTimeout(resizeTimer),
|
|
dispose() {
|
|
window.clearTimeout(resizeTimer);
|
|
heightResizer.dispose();
|
|
window.removeEventListener("pointerup", clearDragFlags);
|
|
window.removeEventListener("pointercancel", clearDragFlags);
|
|
},
|
|
};
|
|
}
|