Files
Aislo/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts
T
eomsangdonandClaude Opus 5.5 3c78322350 feat(M02): 오른쪽 흐름도 패널 M02_DF_Panel.ts — mountFlowPanel(host, opts) → set · destroy · expand(PLAN 36-3)
- 세로: 머리(제목 · 크게 보기 · 접기) · 실행 취소 · 다이어그램 · 상세 내용 · 원가 · 검증(접기) · 저장 단추 없음
- 왼쪽 가장자리 끌기 · 화살표 키 → onResize(px) · 마지막 너비 localStorage · 접기 = 얇은 띠 「흐름도 ▸」
- 크게 보기 = 같은 판을 화면 전체로 옮겨 끼움 · 닫으면 패널로(고친 것 그대로) · Esc · Ctrl+Z
- 좁은 패널은 저절로 맞춤 바닥 0.5(한눈에) · 크게 보기는 0.9 — 다이어그램 opts.fitMin
- 시험 test_m02_df_panel.py 2 통과 · typecheck · DataFlow 전부 검사 초록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 23:30:18 +09:00

263 lines
9.5 KiB
TypeScript

/* =============================================================================
* M02_DF_Panel.ts
* 오른쪽 흐름도 패널(흐름도 적용 정함 5) — mountFlowPanel(host, opts) → { set, destroy, expand }
* 패널 안 세로: 머리(제목 · 「크게 보기」 · 접기) → 다이어그램(+ 입력 · 함수 · 출력 · 실행 취소 · 맞추기) →
* 상세 내용(상자 · 알약 · 선 판) → 원가 · 검증 칸(접기). 저장 단추 없음(페이지 [저장]) · 바뀔 때마다 onChange.
* 너비 = 부르는 쪽 칸 안 100% · 왼쪽 가장자리 끌기 → onResize(px) · 마지막 너비 localStorage ·
* 접기 = 얇은 띠(「흐름도 ▸」) · 크게 보기 = 화면 전체(같은 흐름 객체 · 닫으면 패널로 · 고친 것 그대로).
* 고치는 핵심은 `M02_DF_Paper.ts`(판 DOM 을 옮겨 끼움).
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import type { CfLogic } from "./M02_DF_Model";
import { createPaper } from "./M02_DF_Paper";
import "./M02_DF_Panel.css";
const TEXT = {
Big: ["크게 보기", "Full screen"],
Fold: ["접기", "Collapse"],
Strip: ["흐름도 ▸", "Flow ▸"],
StripTitle: ["흐름도 펴기", "Open the flow panel"],
Close: ["닫기", "Close"],
Detail: ["상세 내용", "Details"],
Cost: ["원가 · 검증", "Cost · check"],
Grip: ["패널 너비 — 끌거나 화살표 키", "Panel width — drag or arrow keys"],
} as const;
const tf = (key: keyof typeof TEXT): string =>
TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0];
export interface FlowPanelOpts {
logic: CfLogic;
readOnly?: boolean;
title: string;
/** 고칠 때마다 — 객체 그대로(페이지 캐시에 쌓음 · [저장] 은 페이지) */
onChange(lg: CfLogic): void;
/** 다른 로직 입력이 부르는 로직 */
logics?(key: string): Promise<CfLogic | null>;
/** 패널 너비가 바뀜(끌기 · 접기 · 펴기) — 부르는 쪽이 칸 너비를 맞춤 */
onResize?(px: number): void;
}
export interface FlowPanel {
/** 밖에서 바꿈(고친 것 버리기 · 다른 로직 열기) */
set(lg: CfLogic): void;
destroy(): void;
/** 크게 보기 열기 */
expand(): void;
}
/* ── 너비(DOM 없음) ─────────────────────────────────────────────────── */
export const PANEL_MIN = 320;
export const STRIP_WIDTH = 40;
/** 좁은 패널에서 저절로 맞출 때 가장 작은 확대 */
export const PANEL_FIT_MIN = 0.5;
const WIDTH_KEY = "m02.dfpanel.width";
const FOLD_KEY = "m02.dfpanel.folded";
/** 끌어서 정한 너비 — 왼쪽 가장자리를 왼쪽으로 끌면 넓어짐 · 가장 작게 PANEL_MIN · 가장 크게 화면의 85% */
export function dragWidth(start: number, startX: number, x: number, viewport: number): number {
const max = Math.max(PANEL_MIN, Math.round(viewport * 0.85));
return Math.min(max, Math.max(PANEL_MIN, Math.round(start + (startX - x))));
}
/** 저장해 둔 너비 — 숫자가 아니거나 너무 작으면 null */
export function storedWidth(raw: string | null): number | null {
const n = Number(raw);
return raw && Number.isFinite(n) && n >= PANEL_MIN ? Math.round(n) : null;
}
const readStore = (key: string): string | null => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
};
const writeStore = (key: string, value: string): void => {
try {
localStorage.setItem(key, value);
} catch {
/* 저장 칸이 막힌 브라우저 — 이번 화면에서만 */
}
};
/* ── 패널 ───────────────────────────────────────────────────────────── */
export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPanel {
const paper = createPaper({
logic: opts.logic,
readOnly: opts.readOnly,
onChange: opts.onChange,
logics: opts.logics,
// 좁은 패널은 「한눈에」 가 되게 더 작게 · 크게 보기는 글이 읽히게 90% 아래로 안 내림
fitMin: () => (full ? 0.9 : PANEL_FIT_MIN),
});
const { diagram, detail, summary, note, undo } = paper.parts;
let width = storedWidth(readStore(WIDTH_KEY));
let folded = readStore(FOLD_KEY) === "1";
let full: HTMLElement | null = null;
const tell = (): void => opts.onResize?.(folded ? STRIP_WIDTH : (width ?? host.clientWidth));
const sm = (label: string, onClick: () => void, variant: "ghost" | "filled" = "ghost") =>
createButton({ label, size: "sm", variant, onClick });
/* 펼친 패널 */
const costBox = el("details", {
className: "m02-dfp__cost",
attrs: { open: "" },
children: [el("summary", { text: tf("Cost") })],
});
const tools = el("div", { className: "m02-dfp__tools", children: [undo] });
const open = el("div", {
className: "m02-dfp__open",
children: [
el("header", {
className: "m02-dfp__head",
children: [
el("strong", { className: "m02-dfp__title", text: opts.title }),
sm(tf("Big"), () => expand()),
sm(tf("Fold"), () => setFolded(true)),
],
}),
tools,
note,
diagram,
detail,
costBox,
],
});
costBox.append(summary);
/* 접힌 띠 */
const strip = el("button", {
className: "m02-dfp__strip",
text: tf("Strip"),
attrs: { type: "button", title: tf("StripTitle"), "aria-label": tf("StripTitle") },
});
strip.addEventListener("click", () => setFolded(false));
/* 왼쪽 가장자리 — 끌기 · 화살표 키 */
const grip = el("div", {
className: "m02-dfp__grip",
attrs: {
role: "separator",
"aria-orientation": "vertical",
"aria-label": tf("Grip"),
tabindex: "0",
},
});
const setWidth = (px: number, save: boolean): void => {
width = px;
if (save) writeStore(WIDTH_KEY, String(px));
tell();
};
grip.addEventListener("pointerdown", (ev) => {
if (ev.button !== 0) return;
ev.preventDefault();
const start = root.getBoundingClientRect().width;
const startX = ev.clientX;
root.classList.add("is-resizing");
const move = (m: PointerEvent): void =>
setWidth(dragWidth(start, startX, m.clientX, window.innerWidth), false);
const up = (u: PointerEvent): void => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
root.classList.remove("is-resizing");
setWidth(dragWidth(start, startX, u.clientX, window.innerWidth), true);
paper.refit();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
grip.addEventListener("keydown", (ev) => {
const step = ev.shiftKey ? 80 : 20;
const d = ev.key === "ArrowLeft" ? step : ev.key === "ArrowRight" ? -step : 0;
if (!d) return;
ev.preventDefault();
const now = root.getBoundingClientRect().width;
setWidth(dragWidth(now, 0, -d, window.innerWidth), true);
});
const root = el("aside", { className: "m02-dfp", children: [grip, open, strip] });
root.addEventListener("keydown", (ev) => {
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) {
if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return;
ev.preventDefault();
paper.undo();
}
});
const setFolded = (on: boolean): void => {
folded = on;
writeStore(FOLD_KEY, on ? "1" : "0");
root.classList.toggle("is-folded", on);
open.hidden = on;
strip.hidden = !on;
grip.hidden = on;
tell();
if (!on) paper.refit();
};
/* 크게 보기 — 같은 판을 화면 전체로 옮겨 끼우고 닫으면 되돌림 */
const expand = (): void => {
if (full) return;
const close = sm(tf("Close"), () => shrink());
full = el("div", {
className: "m02-dfp-full",
attrs: { role: "dialog", "aria-modal": "true", "aria-label": opts.title, tabindex: "-1" },
children: [
el("header", {
className: "m02-dfp-full__head",
children: [el("strong", { text: opts.title }), undo, close],
}),
note,
el("div", { className: "m02-dfp-full__diagram", children: [diagram] }),
el("div", {
className: "m02-dfp-full__lower",
children: [
el("section", { children: [el("h3", { text: tf("Detail") }), detail] }),
el("section", { children: [el("h3", { text: tf("Cost") }), summary] }),
],
}),
],
});
full.addEventListener("keydown", (ev) => {
if (ev.key === "Escape" && !ev.defaultPrevented) shrink();
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) {
if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return;
ev.preventDefault();
paper.undo();
}
});
document.body.append(full);
full.focus();
paper.refit();
};
const shrink = (): void => {
if (!full) return;
full.remove();
full = null;
tools.append(undo);
open.insertBefore(note, diagram);
open.insertBefore(diagram, detail);
open.insertBefore(detail, costBox);
costBox.append(summary);
paper.refit();
};
host.replaceChildren(root);
setFolded(folded);
return {
set: (lg) => paper.set(lg),
destroy: () => {
shrink();
root.remove();
},
expand,
};
}