- renderSource: 표 이름 · 원문번호 · 기준 · 격자 · 역할 칩 · 걸린 줄 · 해당 없음/중복 · 칸 고침(on.edit) · 주 · 사용처 - Check: runExpect(lb, tables, rows) · 같은 조건 두 칸 조회는 빈틈 한 번 - 시험 test_m01_lb_check.py 9 통과 · LogicBuilder 전부 검사 초록 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
/* =============================================================================
|
||
* M01_LB_Source.ts
|
||
* 로직 만들기 판 — 왼쪽 원문(정함 5 · 7 · 설계초안 6절)
|
||
* 로직이 쓰는 원문 표마다: 표 이름 · 원문번호 · 기준 · 엑셀 같은 격자(칸 머리 역할 칩 조건 / 고정 / 결과)
|
||
* · 예시값으로 걸린 줄 노랑 · 해당 없음 / 중복 한 줄 · 칸 눌러 고침(메모리 사본 — on.edit) · 주 목록 · 사용처.
|
||
* 표 고침은 미리 보기 — 저장하지 않음(정함 5). 화면 글에 키 · 식 안 씀(정함 9).
|
||
* ========================================================================== */
|
||
|
||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||
import { el } from "@ui/ui_template_elements";
|
||
import { tableUsers, type LbTables } from "./M01_LB_Api";
|
||
import type { LbLogic, LbLookup, TableEdit } from "./M01_LB_Blocks";
|
||
import type { LbHit } from "./M01_LB_Check";
|
||
import "./M01_LB_Source.css";
|
||
|
||
const TEXT = {
|
||
Preview: [
|
||
"표 고침은 미리 보기 — 저장은 결정 뒤",
|
||
"Table edits are a preview — saving comes later",
|
||
],
|
||
Users: ["사용처", "Used by"],
|
||
UsersN: ["이 표를 쓰는 로직 {n} 개", "{n} logics use this table"],
|
||
UsersNone: ["이 표를 쓰는 로직 없음", "No logic uses this table"],
|
||
UsersFail: ["사용처를 불러오지 못함", "Couldn't load users"],
|
||
Loading: ["불러오는 중", "Loading"],
|
||
Missing: ["표를 불러오지 못함", "Couldn't load the table"],
|
||
NoRow: ["해당 없음 — 예시값에 맞는 줄이 없음", "No match — no row fits the sample values"],
|
||
DupRow: [
|
||
"중복 — 예시값에 맞는 줄이 둘 이상",
|
||
"Duplicate — more than one row fits the sample values",
|
||
],
|
||
Notes: ["주", "Notes"],
|
||
NoteUsed: ["계산에 쓰임", "Used in the calculation"],
|
||
Cond: ["조건", "Condition"],
|
||
Fixed: ["고정", "Fixed"],
|
||
Result: ["결과", "Result"],
|
||
Free: ["무관", "Any"],
|
||
RoleHint: ["눌러 역할 바꿈", "Tap to change the role"],
|
||
EditHint: [
|
||
"칸을 눌러 고침 · Enter 로 끝 · Esc 로 취소",
|
||
"Tap a cell to edit · Enter to finish · Esc to cancel",
|
||
],
|
||
Edited: ["고친 칸", "Edited cell"],
|
||
} as const;
|
||
|
||
const ts = (key: keyof typeof TEXT, n?: number): string =>
|
||
TEXT[key][currentLanguageIndex as 0 | 1].replace("{n}", String(n ?? ""));
|
||
|
||
export type LbRole = "조건" | "고정" | "결과";
|
||
type Cell = string | number | boolean | null | [number | null, number | null] | undefined;
|
||
|
||
interface SourceTable {
|
||
키?: string;
|
||
이름?: string;
|
||
원문번호?: string;
|
||
기준?: string;
|
||
범위규칙?: string;
|
||
조건?: Record<string, string>;
|
||
값칸?: Record<string, string>;
|
||
주?: string[];
|
||
용도?: { 로직키?: string[] };
|
||
줄: Record<string, Cell>[];
|
||
}
|
||
|
||
export interface SourceOn {
|
||
edit(e: TableEdit): void;
|
||
role(table: string, col: string, role: LbRole): void;
|
||
}
|
||
|
||
/* ── 칸 보이기 · 읽기 ─────────────────────────────────────────────── */
|
||
|
||
const isRange = (v: unknown): v is [number | null, number | null] =>
|
||
Array.isArray(v) && v.length === 2;
|
||
|
||
/** 칸 글 — 범위 [a, b] 는 「a~b」 · 끝이 없으면 「~b」 · 「a~」 */
|
||
export function showCell(v: Cell): string {
|
||
if (v === null || v === undefined) return "";
|
||
if (isRange(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`;
|
||
return String(v);
|
||
}
|
||
|
||
/** 고친 글 → 칸 값 — 원래 칸이 범위면 범위 · 수면 수 · 아니면 글 · 못 읽으면 null */
|
||
export function parseCell(text: string, before: Cell): TableEdit["value"] | null {
|
||
const t = text.trim();
|
||
const num = (s: string): number | null | undefined => {
|
||
const c = s.replace(/,/g, "").trim();
|
||
if (!c) return null;
|
||
return /^-?\d+(\.\d+)?$/.test(c) ? Number(c) : undefined;
|
||
};
|
||
if (isRange(before) || /^[^~~]*[~~][^~~]*$/.test(t)) {
|
||
const parts = t.split(/[~~]/);
|
||
if (parts.length !== 2) return null;
|
||
const [a, b] = parts.map(num);
|
||
if (a === undefined || b === undefined || (a === null && b === null)) return null;
|
||
if (a !== null && b !== null && a > b) return null;
|
||
return [a, b];
|
||
}
|
||
if (!t) return null;
|
||
const n = num(t);
|
||
if (typeof before === "number" || (typeof n === "number" && typeof before !== "string")) {
|
||
return typeof n === "number" ? n : null;
|
||
}
|
||
return t;
|
||
}
|
||
|
||
/* ── 역할 · 주 · 걸린 줄 ─────────────────────────────────────────── */
|
||
|
||
/** 표 칸 하나의 역할 — 이 로직의 표 조회가 그 칸을 어떻게 쓰나(첫 조회 기준) · 안 쓰면 null */
|
||
export function roleOf(lookups: LbLookup[], col: string): LbRole | null {
|
||
for (const lk of lookups) {
|
||
if (lk.col === col) return "결과";
|
||
const c = lk.conds.find((x) => x.col === col);
|
||
if (c) return c.from.k === "fixed" ? "고정" : "조건";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const NEXT: Record<LbRole, LbRole> = { 조건: "고정", 고정: "결과", 결과: "조건" };
|
||
|
||
/** 로직 글에 적힌 주 — 「13-4-1 주③」 은 그 원문번호 표의 ③ · 번호 없는 「주③」 은 모든 표 */
|
||
export function usedNotes(row: unknown): Map<string, Set<string>> {
|
||
const out = new Map<string, Set<string>>();
|
||
const add = (where: string, mark: string): void => {
|
||
const set = out.get(where) ?? new Set<string>();
|
||
set.add(mark);
|
||
out.set(where, set);
|
||
};
|
||
const walk = (v: unknown): void => {
|
||
if (typeof v === "string") {
|
||
for (const m of v.matchAll(/(\d+(?:-\d+)+)?\s*주\s*([①-⑳])/g)) add(m[1] ?? "*", m[2]);
|
||
} else if (Array.isArray(v)) v.forEach(walk);
|
||
else if (v && typeof v === "object") Object.values(v).forEach(walk);
|
||
};
|
||
walk(row);
|
||
return out;
|
||
}
|
||
|
||
/** 표에 걸린 줄들 · 해당 없음 · 중복 — 표 조회 번호로 찾고 없으면 표 키로 */
|
||
function hitsOf(
|
||
key: string,
|
||
lookups: LbLookup[],
|
||
hits: Record<string, LbHit | undefined>,
|
||
): { rows: Set<number>; none: boolean; dup: boolean } {
|
||
const got = lookups.map((lk) => hits[lk.id] ?? hits[key] ?? null);
|
||
return {
|
||
rows: new Set(got.filter((h): h is number => typeof h === "number")),
|
||
none: got.includes("없음"),
|
||
dup: got.includes("중복"),
|
||
};
|
||
}
|
||
|
||
/* ── 그리기 ─────────────────────────────────────────────────────── */
|
||
|
||
/** 처음 본 표 값 — 고친 칸 표시용(화면이 열려 있는 동안) */
|
||
const originals = new Map<string, string[][]>();
|
||
|
||
function snapshot(key: string, table: SourceTable, cols: string[]): string[][] {
|
||
const had = originals.get(key);
|
||
if (had && had.length === table.줄.length) return had;
|
||
const shot = table.줄.map((r) => cols.map((c) => showCell(r[c])));
|
||
originals.set(key, shot);
|
||
return shot;
|
||
}
|
||
|
||
function roleChip(
|
||
key: string,
|
||
col: string,
|
||
role: LbRole | null,
|
||
isCond: boolean,
|
||
on: SourceOn,
|
||
): HTMLElement {
|
||
const shown = role ?? (isCond ? null : undefined);
|
||
if (shown === undefined) return el("span");
|
||
const label = shown
|
||
? ts(shown === "조건" ? "Cond" : shown === "고정" ? "Fixed" : "Result")
|
||
: ts("Free");
|
||
const chip = el("button", {
|
||
className: `m01-lbsrc__chip is-${shown ? { 조건: "cond", 고정: "fixed", 결과: "result" }[shown] : "free"}`,
|
||
text: label,
|
||
attrs: { type: "button", title: ts("RoleHint") },
|
||
});
|
||
chip.addEventListener("click", (ev) => {
|
||
ev.stopPropagation();
|
||
on.role(key, col, shown ? NEXT[shown] : "조건");
|
||
});
|
||
return chip;
|
||
}
|
||
|
||
function editCell(
|
||
td: HTMLTableCellElement,
|
||
before: Cell,
|
||
done: (v: TableEdit["value"]) => void,
|
||
): void {
|
||
if (td.querySelector("input")) return;
|
||
const input = el("input", { className: "m01-lbsrc__input", attrs: { type: "text" } });
|
||
input.value = showCell(before);
|
||
const text = td.textContent ?? "";
|
||
td.replaceChildren(input);
|
||
input.focus();
|
||
input.select();
|
||
let closed = false;
|
||
const close = (save: boolean): void => {
|
||
if (closed) return;
|
||
closed = true;
|
||
const value = save ? parseCell(input.value, before) : null;
|
||
const same = value !== null && showCell(value) === showCell(before);
|
||
td.textContent = value === null || same ? text : showCell(value);
|
||
td.classList.toggle("is-bad", save && value === null && input.value.trim() !== text);
|
||
if (value !== null && !same) done(value);
|
||
};
|
||
input.addEventListener("keydown", (ev) => {
|
||
if (ev.key === "Enter") close(true);
|
||
if (ev.key === "Escape") close(false);
|
||
ev.stopPropagation();
|
||
});
|
||
input.addEventListener("blur", () => close(true));
|
||
}
|
||
|
||
function renderTable(
|
||
key: string,
|
||
table: SourceTable | undefined,
|
||
lb: LbLogic,
|
||
hits: Record<string, LbHit | undefined>,
|
||
notes: Map<string, Set<string>>,
|
||
on: SourceOn,
|
||
): HTMLElement {
|
||
const lookups = lb.lookups.filter((lk) => lk.table === key);
|
||
if (!table?.줄) {
|
||
return el("section", {
|
||
className: "m01-lbsrc__table",
|
||
children: [el("p", { className: "m01-lbsrc__muted", text: ts("Missing") })],
|
||
});
|
||
}
|
||
const conds = Object.keys(table.조건 ?? {});
|
||
const vals = Object.keys(table.값칸 ?? {});
|
||
const cols = [...conds, ...vals];
|
||
const orig = snapshot(key, table, cols);
|
||
const hit = hitsOf(key, lookups, hits);
|
||
|
||
const users = (table.용도?.로직키 ?? []).length;
|
||
const userBtn = el("button", {
|
||
className: "m01-lbsrc__users",
|
||
text: users ? ts("UsersN", users) : ts("UsersNone"),
|
||
attrs: { type: "button" },
|
||
});
|
||
const userList = el("p", { className: "m01-lbsrc__muted" });
|
||
userBtn.addEventListener("click", () => {
|
||
userList.textContent = ts("Loading");
|
||
tableUsers(key)
|
||
.then((list) => {
|
||
userBtn.textContent = list.length ? ts("UsersN", list.length) : ts("UsersNone");
|
||
userList.textContent = list.map((u) => u.이름).join(" · ");
|
||
})
|
||
.catch(() => (userList.textContent = ts("UsersFail")));
|
||
});
|
||
|
||
const head = el("tr", {
|
||
children: [
|
||
el("th", { className: "m01-lbsrc__no", text: "" }),
|
||
...cols.map((c) => {
|
||
const unit = table.값칸?.[c];
|
||
return el("th", {
|
||
className: vals.includes(c) ? "is-value" : "",
|
||
children: [
|
||
el("span", { text: unit ? `${c} (${unit})` : c }),
|
||
roleChip(key, c, roleOf(lookups, c), conds.includes(c), on),
|
||
],
|
||
});
|
||
}),
|
||
],
|
||
});
|
||
const body = table.줄.map((row, i) => {
|
||
const tr = el("tr", {
|
||
className: hit.rows.has(i) ? "is-hit" : "",
|
||
children: [
|
||
el("td", { className: "m01-lbsrc__no", text: String(i + 1) }),
|
||
...cols.map((c, j) => {
|
||
const td = el("td", {
|
||
className: [
|
||
vals.includes(c) ? "is-value" : "",
|
||
lookups.some((lk) => lk.col === c) ? "is-col" : "",
|
||
showCell(row[c]) !== orig[i]?.[j] ? "is-edited" : "",
|
||
].join(" "),
|
||
text: showCell(row[c]),
|
||
});
|
||
td.addEventListener("click", () =>
|
||
editCell(td, row[c], (value) => {
|
||
td.classList.toggle("is-edited", showCell(value) !== orig[i]?.[j]);
|
||
on.edit({ table: key, row: i, col: c, value });
|
||
}),
|
||
);
|
||
return td;
|
||
}),
|
||
],
|
||
});
|
||
return tr;
|
||
});
|
||
|
||
const marks = new Set([...(notes.get(table.원문번호 ?? "") ?? []), ...(notes.get("*") ?? [])]);
|
||
const noteList = (table.주 ?? []).map((n) => {
|
||
const used = marks.has(n.trim().charAt(0));
|
||
return el("li", {
|
||
className: used ? "is-used" : "",
|
||
children: [
|
||
el("span", { text: n }),
|
||
...(used ? [el("span", { className: "m01-lbsrc__tag", text: ts("NoteUsed") })] : []),
|
||
],
|
||
});
|
||
});
|
||
|
||
return el("section", {
|
||
className: "m01-lbsrc__table",
|
||
children: [
|
||
el("div", {
|
||
className: "m01-lbsrc__head",
|
||
children: [
|
||
el("strong", { text: table.이름 ?? "" }),
|
||
el("span", {
|
||
className: "m01-lbsrc__muted",
|
||
text: [table.원문번호, table.기준].filter(Boolean).join(" · "),
|
||
}),
|
||
userBtn,
|
||
],
|
||
}),
|
||
userList,
|
||
...(hit.none ? [el("p", { className: "m01-lbsrc__warn", text: ts("NoRow") })] : []),
|
||
...(hit.dup ? [el("p", { className: "m01-lbsrc__warn", text: ts("DupRow") })] : []),
|
||
el("div", {
|
||
className: "m01-lbsrc__grid",
|
||
children: [
|
||
el("table", {
|
||
children: [el("thead", { children: [head] }), el("tbody", { children: body })],
|
||
}),
|
||
],
|
||
}),
|
||
...(noteList.length
|
||
? [
|
||
el("details", {
|
||
className: "m01-lbsrc__notes",
|
||
attrs: marks.size ? { open: "" } : {},
|
||
children: [el("summary", { text: ts("Notes") }), el("ol", { children: noteList })],
|
||
}),
|
||
]
|
||
: []),
|
||
],
|
||
});
|
||
}
|
||
|
||
/** 왼쪽 원문 — 표 조회가 쓰는 표마다 한 덩어리 · hits = 표 조회 번호(또는 표 키) → 걸린 줄 */
|
||
export function renderSource(
|
||
host: HTMLElement,
|
||
lb: LbLogic,
|
||
tables: LbTables,
|
||
hits: Record<string, LbHit | undefined>,
|
||
on: SourceOn,
|
||
): void {
|
||
const keys = [...new Set(lb.lookups.map((lk) => lk.table))];
|
||
const notes = usedNotes(lb.doc?.row);
|
||
host.replaceChildren(
|
||
el("div", {
|
||
className: "m01-lbsrc",
|
||
children: [
|
||
el("p", { className: "m01-lbsrc__preview", text: ts("Preview") }),
|
||
el("p", { className: "m01-lbsrc__muted", text: ts("EditHint") }),
|
||
...keys.map((k) =>
|
||
renderTable(k, tables[k] as SourceTable | undefined, lb, hits, notes, on),
|
||
),
|
||
],
|
||
}),
|
||
);
|
||
}
|