Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Check.ts
|
||||
* 로직 만들기 판 — 검사(정함 7 · 설계초안 4절)
|
||||
* ① gaps(lb, tables) — 표 조건 칸에 묶인 입력값 선택 목록 조합 × 표 줄 → 해당 없음 · 중복 목록(브라우저 셈)
|
||||
* ② lookupHits(lb, tables, values) — 예시값으로 표마다 걸린 줄(노랑) · 해당 없음 · 중복
|
||||
* ③ runExpect(lb, tables, rows) — 기대값 시험표 · 예시값마다 시험 계산 · 통과 / 실패
|
||||
* 시험표 줄은 sessionStorage 초안만(저장 안 함)
|
||||
*
|
||||
* 줄 찾기 = 엔진 `master_formula._match · find` 와 같은 규칙(범위규칙 넷 · 빈 칸은 무관 ·
|
||||
* 여러 줄이면 위 끝 가장 작은 줄 · 결과 칸 있는 줄) — 엔진을 바꾸면 이 파일도 같이 바꿈.
|
||||
* DOM 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CalcAnswer, LogicRow } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { lbCalcLater, type LbTables } from "./M01_LB_Api";
|
||||
import { writeLogic, type LbLogic, type LbLookup } from "./M01_LB_Blocks";
|
||||
|
||||
type Cell = string | number | boolean | null | [number | null, number | null];
|
||||
export interface CheckTable {
|
||||
키?: string;
|
||||
범위규칙?: string;
|
||||
조건?: Record<string, string>;
|
||||
값칸?: Record<string, string>;
|
||||
줄: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/* ── 엔진과 같은 줄 맞추기 ─────────────────────────────────────────── */
|
||||
|
||||
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
||||
|
||||
function match(
|
||||
table: CheckTable,
|
||||
row: Record<string, unknown>,
|
||||
cond: string,
|
||||
want: unknown,
|
||||
): boolean {
|
||||
if (!(cond in row)) return true; // 빈 조건 칸 = 무관
|
||||
const have = row[cond] as Cell;
|
||||
if (table.조건?.[cond] === "범위") {
|
||||
if (!Array.isArray(have) || !isNum(want)) return false;
|
||||
const [low, high] = have;
|
||||
const [head, ...more] = (table.범위규칙 ?? "초과·이하").split(" · ");
|
||||
const rule = head.split(" (")[0];
|
||||
const above = (strict: boolean): boolean => low === null || (strict ? want > low : want >= low);
|
||||
if (rule === "이상·이하") return above(false) && (high === null || want <= high);
|
||||
if (rule === "초과·이하" || rule === "이하")
|
||||
return above(true) && (high === null || want <= high);
|
||||
if (rule === "이상·미만") {
|
||||
const tops = table.줄
|
||||
.map((r) => r[cond])
|
||||
.filter((c): c is [number | null, number] => Array.isArray(c) && c[1] !== null)
|
||||
.map((c) => c[1]);
|
||||
const closed =
|
||||
more.some((x) => x.includes("이하 포함")) && tops.length > 0 && high === Math.max(...tops);
|
||||
return above(false) && (high === null || want < high || (closed && want === high));
|
||||
}
|
||||
return false; // 모르는 범위규칙 — 엔진은 멈춤
|
||||
}
|
||||
if (isNum(have) && isNum(want)) return have === want;
|
||||
return typeof have === "string" && typeof want === "string" && have === want;
|
||||
}
|
||||
|
||||
/** 엔진 `find` 처럼 맞는 줄 차례 — 하나면 그 줄 · 0 · 2+ 는 그대로 돌려줌 */
|
||||
export function findRows(table: CheckTable, conds: Record<string, unknown>, want = ""): number[] {
|
||||
let hits = table.줄
|
||||
.map((row, i) => ({ row, i }))
|
||||
.filter(({ row }) => Object.entries(conds).every(([c, v]) => match(table, row, c, v)));
|
||||
const first = (table.범위규칙 ?? "").split(" · ")[0];
|
||||
if (hits.length > 1 && (first === "이하" || first === "이상·이하")) {
|
||||
for (const c of Object.keys(conds)) {
|
||||
if (table.조건?.[c] !== "범위") continue;
|
||||
const ups = hits.filter(({ row }) => row[c]).map(({ row }) => (row[c] as Cell[])[1]);
|
||||
if (!ups.length || ups.includes(null)) continue;
|
||||
const low = Math.min(...(ups as number[]));
|
||||
hits = hits.filter(({ row }) => !row[c] || (row[c] as Cell[])[1] === low);
|
||||
}
|
||||
}
|
||||
if (hits.length > 1 && want) hits = hits.filter(({ row }) => want in row);
|
||||
return hits.map((h) => h.i);
|
||||
}
|
||||
|
||||
/* ── ① 빈틈 · 중복 ──────────────────────────────────────────────────── */
|
||||
|
||||
export interface Gap {
|
||||
table: string;
|
||||
/** 어느 표 조회에서 나왔나(화면 번호) */
|
||||
lookup: string;
|
||||
conds: Record<string, unknown>;
|
||||
kind: "없음" | "중복";
|
||||
/** 중복이면 걸린 줄 차례 */
|
||||
rows: number[];
|
||||
}
|
||||
|
||||
/** 조합마다 조건 값 — 입력값은 선택 목록 · 고정값은 그 값 · 그 밖(계산값 등)은 셀 수 없어 null */
|
||||
function combos(lb: LbLogic, lk: LbLookup): Record<string, unknown>[] | null {
|
||||
const lists: [string, unknown[]][] = [];
|
||||
for (const { col, from } of lk.conds) {
|
||||
if (from.k === "fixed") lists.push([col, [from.value]]);
|
||||
else if (from.k === "input") {
|
||||
const spec = lb.inputs.find((x) => x.이름 === from.name);
|
||||
if (!spec?.고르기?.length) return null;
|
||||
lists.push([col, spec.고르기]);
|
||||
} else return null;
|
||||
}
|
||||
let out: Record<string, unknown>[] = [{}];
|
||||
for (const [col, values] of lists)
|
||||
out = out.flatMap((o) => values.map((v) => ({ ...o, [col]: v })));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 입력값 선택 목록 조합마다 표 줄 대조 — 해당 없음 · 중복만 돌려줌(셀 수 없는 표 조회는 건너뜀) */
|
||||
export function gaps(lb: LbLogic, tables: Record<string, unknown>): Gap[] {
|
||||
const out: Gap[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const lk of lb.lookups) {
|
||||
const table = tables[lk.table] as CheckTable | undefined;
|
||||
const sets = table?.줄 ? combos(lb, lk) : null;
|
||||
if (!table || !sets) continue;
|
||||
for (const conds of sets) {
|
||||
const rows = findRows(table, conds, lk.col);
|
||||
if (rows.length === 1) continue;
|
||||
const tag = `${lk.table}|${JSON.stringify(conds)}|${rows.join(",")}`;
|
||||
if (seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
out.push({
|
||||
table: lk.table,
|
||||
lookup: lk.id,
|
||||
conds,
|
||||
kind: rows.length ? "중복" : "없음",
|
||||
rows,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 셀 수 없어 빈틈 검사를 건너뛴 표 조회(계산값 · 숫자 입력에 묶인 조건) */
|
||||
export function gapSkipped(lb: LbLogic, tables: Record<string, unknown>): string[] {
|
||||
return lb.lookups.filter((lk) => !tables[lk.table] || !combos(lb, lk)).map((lk) => lk.id);
|
||||
}
|
||||
|
||||
/* ── ② 예시값으로 걸린 줄 ─────────────────────────────────────────── */
|
||||
|
||||
/** 표 조회 번호 → 걸린 줄 차례 · 「없음」 · 「중복」 · null(계산값에 묶여 화면이 모름) */
|
||||
export type LbHit = number | null | "없음" | "중복";
|
||||
|
||||
export function lookupHits(
|
||||
lb: LbLogic,
|
||||
tables: Record<string, unknown>,
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, LbHit> {
|
||||
const out: Record<string, LbHit> = {};
|
||||
for (const lk of lb.lookups) {
|
||||
const table = tables[lk.table] as CheckTable | undefined;
|
||||
const conds: Record<string, unknown> = {};
|
||||
let known = !!table?.줄;
|
||||
for (const { col, from } of lk.conds) {
|
||||
if (from.k === "fixed") conds[col] = from.value;
|
||||
else if (from.k === "input" && values[from.name] !== undefined)
|
||||
conds[col] = values[from.name];
|
||||
else known = false;
|
||||
}
|
||||
if (!known || !table) {
|
||||
out[lk.id] = null;
|
||||
continue;
|
||||
}
|
||||
const rows = findRows(table, conds, lk.col);
|
||||
out[lk.id] = rows.length === 1 ? rows[0] : rows.length ? "중복" : "없음";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── ③ 기대값 시험표 ───────────────────────────────────────────────── */
|
||||
|
||||
export interface ExpectRow {
|
||||
/** 예시값 */
|
||||
inputs: Record<string, unknown>;
|
||||
/** 볼 값 — 「계」(합계) · 「결과」 · 항목 이름(수량) · 「항목 이름 금액」 */
|
||||
expect: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ExpectResult {
|
||||
ok: boolean;
|
||||
got: Record<string, number>;
|
||||
/** 계산 멈춤 · 서버 오류 까닭(쉬운 말은 부른 쪽이 다듬음) */
|
||||
why: string | null;
|
||||
}
|
||||
|
||||
/** 계산 답 → 볼 값 모음 */
|
||||
export function pickValues(ans: CalcAnswer): Record<string, number> {
|
||||
const got: Record<string, number> = {};
|
||||
if (!ans.ok) return got;
|
||||
if (ans.sums && isNum(ans.sums["계"])) got["계"] = ans.sums["계"];
|
||||
if (isNum(ans.result)) got["결과"] = ans.result;
|
||||
for (const line of ans.lines ?? []) {
|
||||
if (isNum(line.수량)) got[line.이름] = line.수량;
|
||||
if (isNum(line.금액)) got[`${line.이름} 금액`] = line.금액;
|
||||
}
|
||||
return got;
|
||||
}
|
||||
|
||||
/** 기대값과 같은가 — 기대값에 적은 자릿수의 반 올림 안쪽이면 같음(52,437 ↔ 52,437.06) */
|
||||
export function sameAsExpect(got: number | undefined, expect: number): boolean {
|
||||
if (!isNum(got)) return false;
|
||||
const places = (String(expect).split(".")[1] ?? "").length;
|
||||
return Math.abs(got - expect) <= 0.5 * 10 ** -places + 1e-9;
|
||||
}
|
||||
|
||||
type CalcFn = (
|
||||
row: LogicRow,
|
||||
inputs: Record<string, unknown>,
|
||||
tables: LbTables,
|
||||
) => Promise<CalcAnswer>;
|
||||
|
||||
/** LB Api 계산을 한 번 기다림 — 차례로 부르므로 「최신만」 에 안 걸림 */
|
||||
const calcOnce: CalcFn = (row, inputs, tables) =>
|
||||
new Promise((resolve, reject) =>
|
||||
lbCalcLater(row, inputs, tables, (ans, plain) =>
|
||||
ans ? resolve(ans) : reject(new Error(plain ?? "")),
|
||||
),
|
||||
);
|
||||
|
||||
/** 줄마다 시험 계산(차례로 · 하나씩) — 로직은 블록을 글로 되돌린 줄 · `calc` 는 시험에서 바꿔 끼움 */
|
||||
export async function runExpect(
|
||||
lb: LbLogic,
|
||||
tables: LbTables,
|
||||
rows: ExpectRow[],
|
||||
calc: CalcFn = calcOnce,
|
||||
): Promise<ExpectResult[]> {
|
||||
const row = writeLogic(lb);
|
||||
const out: ExpectResult[] = [];
|
||||
for (const r of rows) {
|
||||
try {
|
||||
const ans = await calc(row, r.inputs, tables);
|
||||
const got = pickValues(ans);
|
||||
const ok = ans.ok && Object.entries(r.expect).every(([k, v]) => sameAsExpect(got[k], v));
|
||||
out.push({ ok, got, why: ans.ok ? null : ans.reason });
|
||||
} catch (e) {
|
||||
out.push({ ok: false, got: {}, why: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── 시험표 초안(sessionStorage · 로직마다) ───────────────────────────── */
|
||||
|
||||
const draftKey = (logic: string): string => `m01.lb.expect.${logic || "새"}`;
|
||||
|
||||
export function loadExpectRows(logic: string): ExpectRow[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(draftKey(logic));
|
||||
const rows = raw ? (JSON.parse(raw) as ExpectRow[]) : [];
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveExpectRows(logic: string, rows: ExpectRow[]): void {
|
||||
try {
|
||||
if (rows.length) sessionStorage.setItem(draftKey(logic), JSON.stringify(rows));
|
||||
else sessionStorage.removeItem(draftKey(logic));
|
||||
} catch {
|
||||
/* 저장 칸이 막힌 브라우저 — 이번 화면에서만 씀 */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/* 로직 만들기 판 — 왼쪽 원문 */
|
||||
.m01-lbsrc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-width: 0;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-lbsrc p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m01-lbsrc__muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m01-lbsrc__muted:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m01-lbsrc__preview {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--color-warning) 14%, var(--color-canvas));
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.m01-lbsrc__table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-lbsrc__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-lbsrc__users {
|
||||
margin-left: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--color-accent);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.m01-lbsrc__warn {
|
||||
color: var(--color-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 엑셀 같은 격자 */
|
||||
.m01-lbsrc__grid {
|
||||
max-height: 22rem;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid th,
|
||||
.m01-lbsrc__grid td {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--color-paper);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid thead th span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid .m01-lbsrc__no {
|
||||
width: 2.5rem;
|
||||
background: var(--color-paper);
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid td {
|
||||
cursor: cell;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid td.is-value {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid td.is-col {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid tr.is-hit td {
|
||||
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid td.is-edited {
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent);
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid td.is-bad {
|
||||
box-shadow: inset 0 0 0 2px var(--color-danger);
|
||||
}
|
||||
|
||||
.m01-lbsrc__input {
|
||||
width: 100%;
|
||||
min-width: 4rem;
|
||||
padding: 0 var(--spacing-4);
|
||||
border: 1px solid var(--color-accent);
|
||||
font: inherit;
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 칸 역할 칩 — 조건 파랑 · 고정 회색 · 결과 초록 · 무관 점선 */
|
||||
.m01-lbsrc__chip {
|
||||
margin-top: var(--spacing-4);
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: var(--text-caption);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-lbsrc__chip.is-cond {
|
||||
color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.m01-lbsrc__chip.is-fixed {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-lbsrc__chip.is-result {
|
||||
color: var(--color-success, #2f6b4f);
|
||||
background: color-mix(in srgb, var(--color-success, #2f6b4f) 12%, transparent);
|
||||
}
|
||||
|
||||
.m01-lbsrc__chip.is-free {
|
||||
color: var(--color-text-muted);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.m01-lbsrc__notes summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-lbsrc__notes ol {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
padding-left: var(--spacing-16);
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-lbsrc__notes li.is-used {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-lbsrc__tag {
|
||||
margin-left: var(--spacing-8);
|
||||
padding: 0 var(--spacing-4);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||
color: var(--color-accent);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
/* =============================================================================
|
||||
* 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),
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user