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
This commit is contained in:
2026-10-01 23:30:18 +09:00
co-authored by Claude Opus 5.5
parent e75a99f7db
commit 3c78322350
6 changed files with 504 additions and 2 deletions
@@ -320,7 +320,7 @@ export function renderDiagram(
return { x: b.x, y: b.y, w: n?.offsetWidth ?? 180, h: n?.offsetHeight ?? 80 };
});
const r = view.getBoundingClientRect();
Object.assign(st, fitView(rects, { w: r.width, h: r.height }));
Object.assign(st, fitView(rects, { w: r.width, h: r.height }, 40, opts.fitMin));
applyView();
drawLines();
};
@@ -122,6 +122,8 @@ export interface DiagramOpts {
readOnly?: boolean;
/** 바뀌면 다시 맞춤(자리를 옮겨 끼운 뒤 · 새 로직) */
fitKey?: number;
/** 저절로 맞출 때 가장 작은 확대 — 없으면 FIT_MIN(좁은 패널은 더 작게) */
fitMin?: number;
}
/* ── 알약 · 자리 ────────────────────────────────────────────────────── */
@@ -193,13 +195,14 @@ export function fitView(
rects: { x: number; y: number; w: number; h: number }[],
view: { w: number; h: number },
pad = 40,
min = FIT_MIN,
): { zoom: number; panX: number; panY: number } {
if (!rects.length) return { zoom: 1, panX: 0, panY: 0 };
const x0 = Math.min(...rects.map((r) => r.x)) - pad;
const y0 = Math.min(...rects.map((r) => r.y)) - pad;
const x1 = Math.max(...rects.map((r) => r.x + r.w)) + pad;
const y1 = Math.max(...rects.map((r) => r.y + r.h)) + pad;
const zoom = clampZoom(Math.max(FIT_MIN, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5)));
const zoom = clampZoom(Math.max(min, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5)));
/** 남으면 가운데 · 넘치면 왼쪽(위) 끝부터 */
const place = (size: number, from: number, to: number): number =>
Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom;
@@ -0,0 +1,151 @@
/* 오른쪽 흐름도 패널 — 부르는 쪽 칸 안에서 100% */
.m02-dfp {
position: relative;
display: flex;
width: 100%;
min-width: 0;
height: 100%;
border-left: 1px solid var(--color-border);
background: var(--color-canvas);
}
.m02-dfp__open {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
padding: var(--spacing-8) var(--spacing-12) var(--spacing-12);
}
.m02-dfp__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-dfp__title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m02-dfp__tools {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
}
/* 좁은 패널 — 다이어그램 판 높이를 패널에 맞춤 */
.m02-dfp .m02-dfdg__view {
height: max(320px, 45vh);
}
.m02-dfp__lock {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.m02-dfp__cost > summary {
cursor: pointer;
font-weight: 600;
padding: var(--spacing-4) 0;
}
/* 왼쪽 가장자리 끌기 */
.m02-dfp__grip {
position: absolute;
top: 0;
bottom: 0;
left: -4px;
z-index: 2;
width: 8px;
cursor: col-resize;
touch-action: none;
}
.m02-dfp__grip:hover,
.m02-dfp__grip:focus-visible,
.m02-dfp.is-resizing .m02-dfp__grip {
background: color-mix(in srgb, var(--color-accent) 35%, transparent);
outline: none;
}
.m02-dfp.is-resizing {
user-select: none;
}
/* 접기 = 얇은 띠 */
.m02-dfp__strip {
width: 100%;
padding: var(--spacing-12) 0;
border: 0;
background: var(--color-surface);
color: var(--color-text-secondary);
font: inherit;
writing-mode: vertical-rl;
cursor: pointer;
}
.m02-dfp__strip:hover,
.m02-dfp__strip:focus-visible {
color: var(--color-accent);
}
.m02-dfp__strip[hidden],
.m02-dfp__open[hidden],
.m02-dfp__grip[hidden] {
display: none;
}
/* 크게 보기 — 화면 전체 */
.m02-dfp-full {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
overflow: auto;
background: var(--color-canvas);
outline: none;
}
.m02-dfp-full__head {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.m02-dfp-full__head strong {
flex: 1;
font-size: var(--text-body);
}
.m02-dfp-full__diagram .m02-dfdg__view {
height: max(420px, 60vh);
}
.m02-dfp-full__lower {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: var(--spacing-16);
align-items: start;
}
.m02-dfp-full__lower h3 {
margin: 0 0 var(--spacing-8);
font-size: var(--text-body);
}
@media (max-width: 900px) {
.m02-dfp-full__lower {
grid-template-columns: minmax(0, 1fr);
}
}
+262
View File
@@ -0,0 +1,262 @@
/* =============================================================================
* 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,
};
}
@@ -53,6 +53,8 @@ export interface PaperOpts {
onChange(lg: CfLogic): void;
/** 다른 로직 입력이 부르는 로직 — 없으면 흐름도 저장본(DF-) 만 */
logics?(key: string): Promise<CfLogic | null>;
/** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */
fitMin?(): number;
}
export interface Paper {
@@ -304,6 +306,7 @@ export function createPaper(opts: PaperOpts): Paper {
breaks: broken,
readOnly,
fitKey,
fitMin: opts.fitMin?.(),
});
drawPanel();
renderSummary(summaryHost, lg, result, { change: editExample, select: choose }, { values });
+83
View File
@@ -0,0 +1,83 @@
"""오른쪽 흐름도 패널(`M02_DF_Panel.ts`) 순수 부분 — 끌어서 정한 너비 · 저장해 둔 너비.
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림(부품 · 판은 빈 흉내 · 순수 함수만).
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_Panel.ts"
RUNNER = r"""
const fs = require("fs");
const path = require("path");
const [root, file] = process.argv.slice(1);
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const stub = new Proxy({}, { get: () => () => undefined });
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => stub);
const m = box.exports;
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = jobs.map((j) =>
j.op === "drag" ? m.dragWidth(j.start, j.startX, j.x, j.viewport)
: j.op === "stored" ? m.storedWidth(j.raw)
: j.op === "consts" ? [m.PANEL_MIN, m.STRIP_WIDTH]
: null,
);
process.stdout.write(JSON.stringify(out));
"""
def run(jobs: list[dict]) -> list:
if shutil.which("node") is None:
pytest.skip("node 가 없음")
done = subprocess.run(
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
input=json.dumps(jobs, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
def test_왼쪽_가장자리_끌기_너비():
[consts, wider, narrower, floor, ceil] = run(
[
{"op": "consts"},
# 왼쪽으로 100 끌면 넓어짐
{"op": "drag", "start": 480, "startX": 1000, "x": 900, "viewport": 1600},
# 오른쪽으로 끌면 좁아짐
{"op": "drag", "start": 480, "startX": 1000, "x": 1060, "viewport": 1600},
# 너무 좁게 — 가장 작은 너비에서 멈춤
{"op": "drag", "start": 480, "startX": 1000, "x": 1400, "viewport": 1600},
# 너무 넓게 — 화면의 85% 에서 멈춤
{"op": "drag", "start": 480, "startX": 1000, "x": -2000, "viewport": 1600},
]
)
min_width, strip = consts
assert (min_width, strip) == (320, 40)
assert wider == 580 and narrower == 420
assert floor == min_width and ceil == 1360
def test_저장해_둔_너비():
got = run(
[
{"op": "stored", "raw": "640"},
{"op": "stored", "raw": "640.6"},
{"op": "stored", "raw": None},
{"op": "stored", "raw": "abc"},
{"op": "stored", "raw": "100"},
]
)
assert got == [640, 641, None, None, None]