Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-10-01 13:07:18 +09:00
2 changed files with 716 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
.m01-slpanel {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
font-size: var(--text-body);
color: var(--color-text-body);
}
.m01-slpanel-head,
.m01-slpanel-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m01-slpanel-bar {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m01-slpanel-cell {
padding: 2px var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-pills);
background: var(--color-surface-raised);
}
.m01-slpanel-note {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m01-slpanel .ui-btn,
.m01-slpanel .ui-input {
min-height: 44px;
font-size: var(--text-body);
}
.m01-slpanel-name {
flex: none;
width: 12em;
}
.m01-slpanel-msg {
margin: 0;
padding: var(--spacing-8) var(--spacing-12);
border-left: 4px solid var(--color-warning);
background: var(--color-surface-raised);
font-weight: var(--font-weight-medium);
}
.m01-slpanel-sheet {
position: relative;
min-height: 260px;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
overflow: hidden;
}
.m01-slpanel-pills {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.m01-slpanel-pill,
.m01-slpanel-dir {
min-height: 44px;
padding: 0 var(--spacing-12);
border: 2px solid;
border-radius: var(--radius-pills);
background: var(--color-surface);
color: inherit;
font: inherit;
font-weight: var(--font-weight-medium);
cursor: pointer;
}
.m01-slpanel-dir {
display: inline-flex;
align-items: center;
min-height: 0;
cursor: default;
}
.m01-slpanel-pill:focus-visible {
outline: 2px solid var(--color-focus-ring);
}
.m01-slpanel-pill.is-on {
background: var(--color-surface-raised);
font-weight: var(--font-weight-bold);
}
.is-in {
border-color: #3b82f6;
}
.is-out {
border-color: #f59e0b;
}
.m01-slpanel-info {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
}
.m01-slpanel-infoline {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.m01-slpanel-infoline > .m01-slpanel-note {
min-width: 6em;
}
/* 칸 위 알약 표시 — 색 + 글자(입력 · 출력) 둘 다 */
.m01-slpanel .aislo-cell[data-sl-pill="in"] {
box-shadow: inset 0 0 0 2px #3b82f6;
}
.m01-slpanel .aislo-cell[data-sl-pill="out"] {
box-shadow: inset 0 0 0 2px #f59e0b;
}
.m01-slpanel .aislo-cell[data-sl-pill]::after {
content: attr(data-sl-name);
position: absolute;
top: 0;
right: 0;
padding: 0 4px;
border-radius: 0 0 0 6px;
background: var(--color-surface);
color: var(--color-text);
font-size: 10px;
line-height: 14px;
pointer-events: none;
}
.m01-slpanel .aislo-cell[data-sl-pill="in"]::after {
border: 1px solid #3b82f6;
}
.m01-slpanel .aislo-cell[data-sl-pill="out"]::after {
border: 1px solid #f59e0b;
}
.m01-slpanel .aislo-cell[data-sl-lock]::before {
content: "🔒 " attr(data-sl-lock);
position: absolute;
bottom: 0;
left: 0;
padding: 0 4px;
background: var(--color-surface);
color: var(--color-text);
font-size: 10px;
line-height: 14px;
pointer-events: none;
}
.m01-slpanel .aislo-cell[data-sl-err] {
outline: 2px dashed var(--color-danger);
outline-offset: -2px;
}
+544
View File
@@ -0,0 +1,544 @@
/* =============================================================================
* M01_SL_Panel.ts
* 시트 로직 아래 시트 판 — 고른 상자의 시트를 우리 스프레드시트 편집기로 · 칸 → 입력/출력 알약 · 칸 위 알약 표시 ·
* 받는 알약 칸 잠금 · 틀 단추(진짜 엑셀 수식) · 틀린 수식/안 이어진 입력 알약 한 줄
* 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` · M01_SL_Model 타입 그대로 · 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴)
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import type {
CalcValue,
CalcValues,
SpreadsheetHandle,
Workbook,
} from "../../A00_Common/spreadsheet/spreadsheet";
import { disconnect, type SheetLogic, type SlEdge, type SlNode, type SlPill } from "./M01_SL_Model";
import "./M01_SL_Panel.css";
const TEXT = {
Cell: ["고른 칸", "Selected cell"],
PillIn: ["입력 알약으로", "Make input pill"],
PillOut: ["출력 알약으로", "Make output pill"],
AskName: ["알약 이름", "Pill name"],
Ok: ["정함", "Set"],
Cancel: ["취소", "Cancel"],
Frames: ["틀", "Templates"],
FrameMore: ["할증", "Markup"],
FrameDiv: ["나누기", "Divide"],
FrameIf: ["만약", "If"],
FrameMin: ["최소", "Min"],
FrameMax: ["최대", "Max"],
FrameUp: ["올림", "Round up"],
FrameLook: ["표에서 찾기", "Look up in table"],
FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"],
In: ["입력", "In"],
Out: ["출력", "Out"],
Lock: ["선", "Wire"],
Pills: ["알약", "Pills"],
NoPills: [
"아직 알약이 없음 — 칸을 골라 알약으로 정함",
"No pills yet — pick a cell and make it a pill",
],
Value: ["값", "Value"],
Unit: ["단위", "Unit"],
From: ["어디서 오나", "Comes from"],
To: ["어디로 가나", "Goes to"],
NoFrom: ["아직 이어지지 않음", "Not connected yet"],
NoTo: ["아직 어디로도 안 감", "Goes nowhere yet"],
Close: ["닫기", "Close"],
ReadOnly: ["표준은 읽기만", "Read only"],
Loading: ["시트를 불러오는 중", "Loading sheet"],
BreakAsk: [
"이 칸은 선으로 들어오는 값입니다. 선을 끊고 직접 고칠까요?",
"This cell receives a wire. Cut the wire and edit it yourself?",
],
BreakOk: ["선을 끊고 고침", "Cut and edit"],
Cannot: ["이 고침은 지금 할 수 없음", "This change cannot be made now"],
Problem: ["⚠ ", "⚠ "],
Unwired: [" 입력 알약이 아직 이어지지 않음", " input pill is not connected yet"],
Err_DIV0: ["0으로 나눔", "divides by zero"],
Err_NAME: ["알 수 없는 이름", "unknown name"],
Err_REF: ["없는 칸을 가리킴", "points to a missing cell"],
Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"],
Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"],
Err_NA: ["찾는 값이 없음", "value not found"],
Err_OTHER: ["수식이 틀림", "formula is wrong"],
} as const;
const tp = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */
const FRAMES: [keyof typeof TEXT, string][] = [
["FrameMore", "=B1*(1+B2/100)"],
["FrameDiv", "=B1/B2"],
["FrameIf", '=IF(B3="급",10,0)'],
["FrameMin", "=MIN(B1,B2)"],
["FrameMax", "=MAX(B1,B2)"],
["FrameUp", "=ROUNDUP(B4,3)"],
["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"],
];
const ERR_KEY: Record<string, keyof typeof TEXT> = {
"#DIV/0!": "Err_DIV0",
"#NAME?": "Err_NAME",
"#REF!": "Err_REF",
"#VALUE!": "Err_VALUE",
"#CYCLE!": "Err_CYCLE",
"#N/A": "Err_NA",
};
type Handlers = {
change(sl: SheetLogic): void;
pill(nodeId: string, cell: string, dir: "in" | "out", name: string): void;
};
type SheetModule = typeof import("../../A00_Common/spreadsheet/spreadsheet");
interface State {
nodeId: string;
sl: SheetLogic;
on: Handlers;
readOnly: boolean;
handle: SpreadsheetHandle | null;
/** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */
snap: CalcValues;
/** 편집기가 마지막으로 낸 문서(활성 빼고) — 같으면 다시 안 끼움 */
json: string;
cell: string;
info: { dir: "in" | "out"; name: string } | null;
asking: "in" | "out" | null;
busy: boolean;
parts: {
cell: HTMLElement;
bar: HTMLElement;
msg: HTMLElement;
sheet: HTMLElement;
pills: HTMLElement;
info: HTMLElement;
};
observer: MutationObserver | null;
redraw(): void;
}
const states = new WeakMap<HTMLElement, State>();
let sheetModule: Promise<SheetModule> | null = null;
/** 편집기는 M02 처럼 늦게 불러옴 — 정적 import 금지 */
const loadSheet = (): Promise<SheetModule> =>
(sheetModule ??= import("../../A00_Common/spreadsheet/spreadsheet"));
const norm = (book: Workbook): string => JSON.stringify({ ...book, 활성: null, 계산값: null });
const nodeOf = (st: State): SlNode | undefined => st.sl.nodes.find((n) => n.id === st.nodeId);
const shown = (st: State): Workbook => ({
...st.sl.workbook,
활성: nodeOf(st)?.sheetId ?? st.sl.workbook.활성,
});
/* ------------------------------ 값 · 말 ------------------------------ */
function valueText(v: CalcValue | number | string | boolean | undefined | null): string {
if (v === undefined || v === null) return "";
if (typeof v !== "object") return String(v);
if ("수" in v) return v.수;
if ("글" in v) return v.글;
if ("참" in v) return v.참 ? "TRUE" : "FALSE";
return v.오류;
}
function valueAt(st: State, addr: string): string {
const node = nodeOf(st);
const sheet = st.sl.workbook.시트.find((s) => s.id === node?.sheetId);
const cell = sheet?.칸[addr];
if (!cell) return "";
if (cell.식 === undefined) return valueText(cell.값);
return valueText(st.snap[sheet!.id]?.[addr]);
}
const edgesIn = (st: State, pill: string): SlEdge[] =>
st.sl.edges.filter((e) => e.to.node === st.nodeId && e.to.pill === pill);
const edgesOut = (st: State, pill: string): SlEdge[] =>
st.sl.edges.filter((e) => e.from.node === st.nodeId && e.from.pill === pill);
/** 선으로 값을 받는 칸 — 직접 고치기 전에 물음 */
function lockedCells(st: State): Map<string, SlEdge> {
const out = new Map<string, SlEdge>();
for (const p of nodeOf(st)?.pillsIn ?? []) {
const e = edgesIn(st, p.name)[0];
if (e) out.set(p.cell, e);
}
return out;
}
/** 틀린 수식 · 이어지지 않은 입력 알약 — 칸 → 까닭 */
function problems(st: State): Map<string, string> {
const out = new Map<string, string>();
const node = nodeOf(st);
if (!node || !st.handle) return out;
const sheet = st.sl.workbook.시트.find((s) => s.id === node.sheetId);
const snap = st.snap[node.sheetId] ?? {};
for (const [addr, v] of Object.entries(snap)) {
if (typeof v === "object" && "오류" in v) {
out.set(addr, `${addr} ${tp(ERR_KEY[v.오류] ?? "Err_OTHER")}`);
}
}
for (const p of node.pillsIn) {
const filled = sheet?.칸[p.cell]?.식 !== undefined;
if (!edgesIn(st, p.name).length && !filled && node.kind !== "input" && node.kind !== "const") {
out.set(p.cell, `${p.name}(${p.cell})${tp("Unwired")}`);
}
}
return out;
}
/** 계산값 새로 받고 표시 다시 — 편집기가 바뀐 뒤 · 처음 뜬 뒤에만 */
function refreshSnap(st: State): void {
if (!st.handle) return;
st.snap = st.handle.recalc();
st.redraw();
}
/* ------------------------------ 칸 위 표시 ------------------------------ */
function applyMarks(st: State): void {
const node = nodeOf(st);
const grid = st.parts.sheet;
if (!node) return;
const pill = new Map<string, { dir: "in" | "out"; p: SlPill }>();
for (const p of node.pillsIn) pill.set(p.cell, { dir: "in", p });
for (const p of node.pillsOut) pill.set(p.cell, { dir: "out", p });
const lock = lockedCells(st);
const bad = problems(st);
for (const c of grid.querySelectorAll<HTMLElement>(".aislo-cell")) {
const addr = c.dataset.addr ?? "";
const hit = pill.get(addr);
for (const a of ["slPill", "slName", "slLock", "slErr"]) delete c.dataset[a];
if (hit) {
c.dataset.slPill = hit.dir;
c.dataset.slName = `${tp(hit.dir === "in" ? "In" : "Out")} ${hit.p.name}`;
}
if (lock.has(addr)) c.dataset.slLock = tp("Lock");
if (bad.has(addr)) c.dataset.slErr = "1";
}
const first = [...bad.values()][0];
st.parts.msg.textContent = first
? `${tp("Problem")}${first}${bad.size > 1 ? ` (+${bad.size - 1})` : ""}`
: "";
st.parts.msg.hidden = !first;
}
/* ------------------------------ 위 줄 · 알약 목록 ------------------------------ */
function nameDefault(st: State): string {
const row = st.cell.replace(/^[A-Z]+/, "");
const sheet = st.sl.workbook.시트.find((s) => s.id === nodeOf(st)?.sheetId);
const a = sheet?.칸[`A${row}`];
return a ? valueText(a.값 ?? a.식) : "";
}
function drawBar(st: State): void {
const { bar, cell } = st.parts;
cell.textContent = `${tp("Cell")} ${st.cell}`;
const frag: (HTMLElement | string)[] = [];
if (st.readOnly) {
bar.replaceChildren(el("span", { className: "m01-slpanel-note", text: tp("ReadOnly") }));
return;
}
const ask = (dir: "in" | "out"): void => {
st.asking = dir;
drawBar(st);
};
const pillRow = el("div", {
className: "m01-slpanel-row",
children: [
createButton({ label: tp("PillIn"), variant: "ghost", size: "sm", onClick: () => ask("in") }),
createButton({
label: tp("PillOut"),
variant: "ghost",
size: "sm",
onClick: () => ask("out"),
}),
],
});
frag.push(pillRow);
if (st.asking) {
const dir = st.asking;
const input = el("input", {
className: "ui-input m01-slpanel-name",
attrs: { type: "text", "aria-label": tp("AskName") },
});
input.value = nameDefault(st);
const done = (): void => {
const name = input.value.trim();
if (!name) return;
st.asking = null;
st.on.pill(st.nodeId, st.cell, dir, name);
drawBar(st);
};
input.addEventListener("keydown", (e) => e.key === "Enter" && done());
frag.push(
el("div", {
className: "m01-slpanel-row",
children: [
el("span", {
className: `m01-slpanel-dir is-${dir}`,
text: tp(dir === "in" ? "In" : "Out"),
}),
input,
createButton({ label: tp("Ok"), variant: "filled", size: "sm", onClick: done }),
createButton({
label: tp("Cancel"),
variant: "ghost",
size: "sm",
onClick: () => ((st.asking = null), drawBar(st)),
}),
],
}),
);
queueMicrotask(() => input.focus());
}
frag.push(
el("div", {
className: "m01-slpanel-row",
children: [
el("span", { className: "m01-slpanel-note", text: tp("Frames") }),
...FRAMES.map(([key, formula]) =>
createButton({
label: tp(key),
variant: "ghost",
size: "sm",
onClick: () => putFormula(st, formula),
}),
),
el("span", { className: "m01-slpanel-note", text: tp("FrameHint") }),
],
}),
);
bar.replaceChildren(...frag.filter((x): x is HTMLElement => typeof x !== "string"));
}
function drawPills(st: State): void {
const node = nodeOf(st);
const chips: HTMLElement[] = [];
const add = (dir: "in" | "out", p: SlPill): void => {
const b = el("button", {
className: `m01-slpanel-pill is-${dir}${st.info?.name === p.name && st.info.dir === dir ? " is-on" : ""}`,
text: `${tp(dir === "in" ? "In" : "Out")} · ${p.name} (${p.cell})`,
attrs: { type: "button" },
});
b.addEventListener("click", () => ((st.info = { dir, name: p.name }), drawPills(st)));
chips.push(b);
};
for (const p of node?.pillsIn ?? []) add("in", p);
for (const p of node?.pillsOut ?? []) add("out", p);
st.parts.pills.replaceChildren(
...(chips.length
? chips
: [el("span", { className: "m01-slpanel-note", text: tp("NoPills") })]),
);
drawInfo(st);
}
function drawInfo(st: State): void {
const box = st.parts.info;
const node = nodeOf(st);
const p = (st.info?.dir === "in" ? node?.pillsIn : node?.pillsOut)?.find(
(x) => x.name === st.info?.name,
);
if (!st.info || !p || !node) {
box.hidden = true;
box.replaceChildren();
return;
}
const dir = st.info.dir;
const nameOf = (id: string): string => st.sl.nodes.find((n) => n.id === id)?.name ?? "";
const links = (dir === "in" ? edgesIn(st, p.name) : edgesOut(st, p.name)).map((e) => {
const end = dir === "in" ? e.from : e.to;
return `${nameOf(end.node)} · ${end.pill}`;
});
const rows: [string, string][] = [
[tp("Value"), valueAt(st, p.cell)],
[tp("Unit"), p.unit ?? ""],
[
tp(dir === "in" ? "From" : "To"),
links.length ? links.join(", ") : tp(dir === "in" ? "NoFrom" : "NoTo"),
],
];
box.hidden = false;
box.replaceChildren(
el("strong", { text: `${tp(dir === "in" ? "In" : "Out")} · ${p.name} (${p.cell})` }),
...rows.map(([k, v]) =>
el("div", {
className: "m01-slpanel-infoline",
children: [el("span", { className: "m01-slpanel-note", text: k }), v],
}),
),
createButton({
label: tp("Close"),
variant: "ghost",
size: "sm",
onClick: () => ((st.info = null), drawPills(st)),
}),
);
}
/* ------------------------------ 편집기 ------------------------------ */
/** 틀 수식을 고른 칸에 넣음 — 수식 입력줄에 쳐서 확정하는 길(되돌리기 · 검사 그대로) */
function putFormula(st: State, formula: string): void {
const root = st.parts.sheet;
const bar = root.querySelector<HTMLTextAreaElement>(".ss-formula");
const addr = root.querySelector<HTMLInputElement>(".ss-addr");
if (!bar || !addr || st.readOnly) return;
const at = st.cell;
bar.focus();
bar.value = formula;
bar.dispatchEvent(new Event("input", { bubbles: true }));
bar.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }),
);
addr.value = at;
addr.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
);
}
/** 편집기가 문서를 냄 — 선 받는 칸을 직접 고쳤으면 먼저 물음 */
async function onBook(st: State, book: Workbook): Promise<void> {
if (st.busy) return;
const prev = st.sl;
const node = nodeOf(st);
const next: SheetLogic = { ...prev, workbook: { ...book, 활성: prev.workbook.활성 } };
const before = prev.workbook.시트.find((s) => s.id === node?.sheetId);
const after = book.시트.find((s) => s.id === node?.sheetId);
const hit = [...lockedCells(st)].find(
([addr]) => JSON.stringify(before?.칸[addr]) !== JSON.stringify(after?.칸[addr]),
);
if (!hit) {
st.json = norm(book);
st.sl = next;
st.on.change(next);
return void setTimeout(() => refreshSnap(st), 0);
}
st.busy = true;
const ok = await showConfirmDialog(tp("BreakAsk"), tp("BreakOk"));
st.busy = false;
if (!ok) {
st.json = norm(shown(st));
return st.handle?.setDoc(structuredClone(shown(st)));
}
try {
const cut = disconnect(next, hit[1].id);
const sheet = cut.workbook.시트.find((s) => s.id === node?.sheetId);
const mine = after?.칸[hit[0]];
if (sheet) {
if (mine) sheet.칸[hit[0]] = mine;
else delete sheet.칸[hit[0]];
}
st.json = norm(cut.workbook);
st.sl = cut;
st.on.change(cut);
setTimeout(() => refreshSnap(st), 0);
} catch {
showToast(tp("Cannot"), "error");
st.handle?.setDoc(structuredClone(shown(st)));
}
}
async function mountSheet(st: State): Promise<void> {
const { createSpreadsheet } = await loadSheet();
if (!st.parts.sheet.isConnected || st.handle) return;
st.parts.sheet.replaceChildren();
const book = structuredClone(shown(st)); // 편집기가 제자리에서 고치므로 사본을 줌
st.json = norm(book);
st.handle = createSpreadsheet(st.parts.sheet, book, {
preview: true,
tabs: false,
readOnly: st.readOnly,
onChange: (b) => void onBook(st, b),
onSelect: (s) => {
st.cell = s.칸;
st.parts.cell.textContent = `${tp("Cell")} ${st.cell}`;
if (st.asking) drawBar(st);
},
});
st.observer = new MutationObserver(() => requestAnimationFrame(() => applyMarks(st)));
st.observer.observe(st.parts.sheet, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["data-addr"],
});
refreshSnap(st);
}
/** 아래 시트 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */
export function renderPanel(
host: HTMLElement,
sl: SheetLogic,
nodeId: string,
on: Handlers,
opts: { readOnly?: boolean } = {},
): void {
const old = states.get(host);
const readOnly = !!opts.readOnly;
if (old && old.nodeId === nodeId && old.readOnly === readOnly) {
old.sl = sl;
old.on = on;
if (old.handle && norm(shown(old)) !== old.json) {
old.json = norm(shown(old));
old.handle.setDoc(structuredClone(shown(old)));
setTimeout(() => refreshSnap(old), 0);
return;
}
return old.redraw();
}
if (old) {
old.observer?.disconnect();
old.handle?.destroy();
}
const parts: State["parts"] = {
cell: el("span", { className: "m01-slpanel-cell", text: `${tp("Cell")} A1` }),
bar: el("div", { className: "m01-slpanel-bar" }),
msg: el("p", { className: "m01-slpanel-msg", attrs: { role: "status" } }),
sheet: el("div", { className: "m01-slpanel-sheet", text: tp("Loading") }),
pills: el("div", { className: "m01-slpanel-pills" }),
info: el("div", { className: "m01-slpanel-info" }),
};
const st: State = {
nodeId,
sl,
on,
readOnly,
handle: null,
snap: {},
json: "",
cell: "A1",
info: null,
asking: null,
busy: false,
parts,
observer: null,
redraw: () => {
drawBar(st);
drawPills(st);
applyMarks(st);
},
};
states.set(host, st);
const node = nodeOf(st);
host.classList.add("m01-slpanel");
host.replaceChildren(
el("div", {
className: "m01-slpanel-head",
children: [el("strong", { text: node?.name ?? "" }), parts.cell],
}),
parts.bar,
parts.msg,
parts.sheet,
parts.pills,
parts.info,
);
parts.msg.hidden = true;
st.redraw();
void mountSheet(st);
}