① 표 블록 칸 차례 = 서버 _table 하나(범위는 조건 · 값 모두 이름·아래/위 · 숫자 하나면 같은 수 · 왼위 = 표 알약 칸) ② 항목 단가 바깥 값 주인 = Item itemOutsides · 같은 칸이면 그쪽이 이김 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
539 lines
20 KiB
TypeScript
539 lines
20 KiB
TypeScript
/* =============================================================================
|
||
* M01_SL_Model.ts
|
||
* 시트 로직 Model — 로직 한 벌 = 통합문서 하나 · 상자 하나 = 시트 하나 · 알약 = 이름 붙은 칸
|
||
* 선 = 받는 입력 알약 칸에 「'시트 이름'!칸」 참조 수식(여러 선이면 「+」 로 이음) · DOM 없음
|
||
* 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` 정함 1~5 그대로
|
||
*
|
||
* 모든 함수는 새 시트 로직을 돌려줌(받은 것은 안 바꿈) · 엔진(A00_Common/spreadsheet)은 가져다 쓰기만
|
||
*
|
||
* 약속 ① 표 블록 = 서버(SL_Router `_table`) 칸 차례 하나 — 머리 줄 포함 · 조건 칸 → 값 칸 · 범위는 조건이든 값이든
|
||
* 「이름·아래」 「이름·위」 두 칸(숫자 하나면 둘 다 그 수) · 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸
|
||
* 약속 ② 항목 상자 단가 = Item 의 칸별 목록(`itemOutsides`)이 바깥 값 주인 · 서버는 저장 때 그 목록도 받고 같은 칸이면 그쪽이 이김
|
||
* ========================================================================== */
|
||
|
||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||
import { quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address";
|
||
import { applyCommand } from "../../A00_Common/spreadsheet/spreadsheet_commands";
|
||
import type { Cell, Sheet, Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||
|
||
/** 상자 종류 — 입력값 · 고정값 · 표 · 계산 · 항목 · 다른 로직 · 결과 */
|
||
export type SlKind = "input" | "const" | "table" | "calc" | "item" | "logic" | "result";
|
||
|
||
/** 알약 = 이름 붙은 칸 */
|
||
export interface SlPill {
|
||
name: string;
|
||
/** 「B3」 */
|
||
cell: string;
|
||
unit?: string;
|
||
/** 바깥 값 칸 — 열 때 · 저장 때 마스터에서 넣음 */
|
||
outside?: boolean;
|
||
}
|
||
|
||
/** 상자 = 시트 하나 */
|
||
export interface SlNode {
|
||
id: string;
|
||
kind: SlKind;
|
||
name: string;
|
||
sheetId: string;
|
||
pillsIn: SlPill[];
|
||
pillsOut: SlPill[];
|
||
/** 표 키 · 요소 · 기존 로직 키 · 내 표 id */
|
||
ref?: string;
|
||
/** 바깥 값을 어디서 받나(서버 `/api/m01/sl/outside` 물음 모양) — 없으면 kind · ref 로 짐작 */
|
||
source?: SlSource;
|
||
/** 띠 단계 — 0 입력값 · 1 표 · 2 계산 · 3 항목 · 4 결과 */
|
||
stage: number;
|
||
}
|
||
|
||
/** 선 — 출력 알약 → 입력 알약 */
|
||
export interface SlEdge {
|
||
id: string;
|
||
from: { node: string; pill: string };
|
||
to: { node: string; pill: string };
|
||
}
|
||
|
||
/** 돈 로직 결과에서 꺼낼 칸 */
|
||
export type SlField = "노무비" | "재료비" | "경비" | "계";
|
||
|
||
/** 바깥 값 출처 — 서버 `/api/m01/sl/outside` 의 물음 한 줄(id 뺌) 그대로
|
||
* · 표 = 품셈 표 통째(머리 줄 + 줄 · 열 차례 조건 칸 → 값 칸 · 범위 칸은 「이름·아래」 「이름·위」 두 칸 — `rangeHead`)
|
||
* 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸(pillsOut · outside) — 고정 A1 아님 · Eval 이 그 칸부터 채움
|
||
* · 노임 · 자재 = 그 줄 단가 · 장비 = 기계경비 로직 부름 글 + 넘길 값 · 로직 = 기존 로직 결과 */
|
||
export type SlSource =
|
||
| { kind: "표"; table: string }
|
||
| { kind: "노임" | "자재"; key: string }
|
||
| { kind: "장비"; element: string; inputs: Record<string, unknown>; field?: SlField }
|
||
| { kind: "로직"; key: string; inputs: Record<string, unknown>; field?: SlField };
|
||
|
||
/** 바깥 값 칸 — id = 「상자 id!칸」(서버 답 id 와 짝) */
|
||
export interface SlOutside {
|
||
id: string;
|
||
node: string;
|
||
cell: string;
|
||
kind: "table" | "price" | "logic";
|
||
ref: string;
|
||
source?: SlSource;
|
||
}
|
||
|
||
/** 표 범위 조건 · 범위 값 — 한 칸을 두 칸(아래 · 위)으로 펼친 머리 이름 */
|
||
export const RANGE_LOW = "·아래";
|
||
export const RANGE_HIGH = "·위";
|
||
export const rangeHead = (name: string): [string, string] => [name + RANGE_LOW, name + RANGE_HIGH];
|
||
|
||
/** 내 표 — 품셈에 없는 표(사용자가 만듦) */
|
||
export interface SlUserTable {
|
||
id: string;
|
||
name: string;
|
||
cols: string[];
|
||
rows: (number | string | null)[][];
|
||
}
|
||
|
||
export interface SheetLogic {
|
||
key: string | null;
|
||
name: string;
|
||
resultUnit: string;
|
||
workbook: Workbook;
|
||
nodes: SlNode[];
|
||
edges: SlEdge[];
|
||
userTables?: SlUserTable[];
|
||
}
|
||
|
||
/** 알약 한쪽 끝 */
|
||
export interface SlEnd {
|
||
node: string;
|
||
pill: string;
|
||
}
|
||
|
||
const TEXT = {
|
||
Name: ["이름", "Name"],
|
||
Value: ["값", "Value"],
|
||
Note: ["설명", "Note"],
|
||
Sample: ["예시값", "Sample value"],
|
||
Fixed: ["늘 이 값", "Always this value"],
|
||
Cond: ["조건", "Condition"],
|
||
Found: ["결과", "Result"],
|
||
ValueA: ["값1", "Value 1"],
|
||
ValueB: ["값2", "Value 2"],
|
||
Times: ["값1 × 값2", "Value 1 × Value 2"],
|
||
Qty: ["수량", "Quantity"],
|
||
Price: ["단가", "Unit price"],
|
||
Amount: ["금액", "Amount"],
|
||
QtyNote: ["선으로 받음", "Comes in by a line"],
|
||
PriceNote: ["바깥 값 — 열 때 넣음", "Outside value — filled on open"],
|
||
LogicNote: ["다른 로직 결과 — 열 때 넣음", "Other logic's result — filled on open"],
|
||
Sum: ["계", "Total"],
|
||
PriceLabor: ["노무 단가", "Labor price"],
|
||
PriceMat: ["재료 단가", "Material price"],
|
||
PriceExp: ["경비 단가", "Expense price"],
|
||
Labor3: ["노무비", "Labor cost"],
|
||
Mat3: ["재료비", "Material cost"],
|
||
Exp3: ["경비", "Expense"],
|
||
Won: ["원", "KRW"],
|
||
NoPill: ["알약이 없음", "No such pill"],
|
||
Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"],
|
||
Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"],
|
||
Twice: ["이미 이어져 있음", "Already linked"],
|
||
NameLen: ["이름은 1~31 글자", "Name must be 1-31 characters"],
|
||
NameChar: [
|
||
"이름에 못 쓰는 글자 — [ ] : * ? / \\ 와 앞뒤 '",
|
||
"Name can't use [ ] : * ? / \\ or a leading/trailing '",
|
||
],
|
||
NameSame: ["같은 이름 상자가 있음", "A box with this name exists"],
|
||
NoBox: ["상자가 없음", "No such box"],
|
||
} as const;
|
||
|
||
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||
|
||
/** 비목 이름(결과 · 항목 칸) — 화면 글 아님 · 알약 이름 열쇠 */
|
||
const COSTS = ["노무비", "재료비", "경비"] as const;
|
||
|
||
/** 일위대가표가 읽는 출력 알약 이름(Item `USED` 와 같음) */
|
||
const USED = "사용 수량";
|
||
|
||
/* ── 작은 도움 ──────────────────────────────────────────────────────────── */
|
||
|
||
const STAGE: Record<SlKind, number> = {
|
||
input: 0,
|
||
const: 0,
|
||
table: 1,
|
||
logic: 1,
|
||
calc: 2,
|
||
item: 3,
|
||
result: 4,
|
||
};
|
||
|
||
const clone = <T>(v: T): T => structuredClone(v);
|
||
|
||
/** 다음 번호 — 있는 id 들(「n3」)보다 하나 큰 것 */
|
||
function nextId(prefix: string, ids: string[]): string {
|
||
const nums = ids
|
||
.filter((x) => x.startsWith(prefix))
|
||
.map((x) => Number(x.slice(prefix.length)))
|
||
.filter((n) => Number.isInteger(n));
|
||
return `${prefix}${Math.max(0, ...nums) + 1}`;
|
||
}
|
||
|
||
/** 시트 이름 규칙(엔진 `checkSheetName`) — 1~31 글자 · [ ] : * ? / \\ 와 앞뒤 ' 못 씀 */
|
||
const BAD_CHARS = /[[\]:*?/\\]/g;
|
||
const cleanName = (name: string): string =>
|
||
name
|
||
.replace(BAD_CHARS, "·")
|
||
.replace(/^'+|'+$/g, "")
|
||
.trim()
|
||
.slice(0, 31) || "상자";
|
||
|
||
/** 시트 이름 — 통합문서 안에서 하나(같으면 「 (2)」 · 대소문자 안 가림) */
|
||
function uniqueName(book: Workbook, name: string, self?: string): string {
|
||
const base = cleanName(name);
|
||
const used = new Set(book.시트.filter((s) => s.id !== self).map((s) => s.이름.toLowerCase()));
|
||
if (!used.has(base.toLowerCase())) return base;
|
||
for (let n = 2; ; n++) {
|
||
const tail = ` (${n})`;
|
||
const next = base.slice(0, 31 - tail.length) + tail;
|
||
if (!used.has(next.toLowerCase())) return next;
|
||
}
|
||
}
|
||
|
||
const sheetOf = (sl: SheetLogic, node: SlNode): Sheet => {
|
||
const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId);
|
||
if (!sheet) throw new Error(`없는 시트 ${node.sheetId}`);
|
||
return sheet;
|
||
};
|
||
|
||
const nodeOf = (sl: SheetLogic, id: string): SlNode | undefined =>
|
||
sl.nodes.find((n) => n.id === id);
|
||
|
||
const pillOf = (node: SlNode, dir: "in" | "out", name: string): SlPill | undefined =>
|
||
(dir === "in" ? node.pillsIn : node.pillsOut).find((p) => p.name === name);
|
||
|
||
/* ── 상자 틀 ─────────────────────────────────────────────────────────────── */
|
||
|
||
type Row = [string, Cell | null, string];
|
||
|
||
/** 종류별 틀 — 1행 머리(이름 · 값 · 설명) · 2행부터 알약 줄 */
|
||
function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; outs: SlPill[] } {
|
||
const v = (값: number | string): Cell => ({ 값 });
|
||
const f = (식: string): Cell => ({ 식 });
|
||
switch (kind) {
|
||
case "input":
|
||
return { rows: [[name, v(0), tl("Sample")]], ins: [], outs: [{ name, cell: "B2" }] };
|
||
case "const":
|
||
return { rows: [[name, v(0), tl("Fixed")]], ins: [], outs: [{ name, cell: "B2" }] };
|
||
case "table":
|
||
// 빈 시트 — 표를 고르면 Table(sub3)이 A1 블록 · 찾기 판 · 알약을 채움(머리 줄이 표 블록과 겹치지 않게)
|
||
return { rows: [], ins: [], outs: [] };
|
||
case "calc":
|
||
return {
|
||
rows: [
|
||
[tl("ValueA"), null, tl("QtyNote")],
|
||
[tl("ValueB"), null, tl("QtyNote")],
|
||
[tl("Found"), f("B2*B3"), tl("Times")],
|
||
],
|
||
ins: [
|
||
{ name: tl("ValueA"), cell: "B2" },
|
||
{ name: tl("ValueB"), cell: "B3" },
|
||
],
|
||
outs: [{ name: tl("Found"), cell: "B4" }],
|
||
};
|
||
case "item":
|
||
// 항목 틀은 머리 줄 없음 — Item(sub5) 노무 틀과 한 벌(`itemSheet`)
|
||
return { rows: [], ins: [], outs: [] };
|
||
case "logic":
|
||
return {
|
||
rows: [[tl("Found"), null, tl("LogicNote")]],
|
||
ins: [],
|
||
outs: [{ name: tl("Found"), cell: "B2", outside: true }],
|
||
};
|
||
case "result":
|
||
return {
|
||
rows: [
|
||
["노무비", null, tl("QtyNote")],
|
||
["재료비", null, tl("QtyNote")],
|
||
["경비", null, tl("QtyNote")],
|
||
[tl("Sum"), f("SUM(B2:B4)"), ""],
|
||
],
|
||
ins: COSTS.map((c, i) => ({ name: c, cell: `B${2 + i}` })),
|
||
outs: [{ name: "계", cell: "B5" }],
|
||
};
|
||
}
|
||
}
|
||
|
||
/** 항목 상자 틀 — `M01_SL_Item.ts` moneyLayout(노무)과 같은 칸 · 같은 알약(종류를 바꾸면 Item 이 다시 채움)
|
||
* B1 수량 · B2~B4 노무/재료/경비 단가(노무는 B2 만 바깥 값) · B5 단가 · B6 금액 · B7~B9 비목 · B10 사용 수량 */
|
||
function itemSheet(): { cells: Record<string, Cell>; rows: Row[]; ins: SlPill[]; outs: SlPill[] } {
|
||
const won = tl("Won");
|
||
const labels: [string, string][] = [
|
||
["A1", tl("Qty")],
|
||
["A2", tl("PriceLabor")],
|
||
["A3", tl("PriceMat")],
|
||
["A4", tl("PriceExp")],
|
||
["A5", tl("Price")],
|
||
["A6", tl("Amount")],
|
||
["A7", tl("Labor3")],
|
||
["A8", tl("Mat3")],
|
||
["A9", tl("Exp3")],
|
||
["A10", USED],
|
||
];
|
||
const cells: Record<string, Cell> = Object.fromEntries(labels.map(([k, v]) => [k, { 값: v }]));
|
||
Object.assign(cells, {
|
||
B3: { 값: 0 },
|
||
B4: { 값: 0 },
|
||
B5: { 식: "B2+B3+B4" },
|
||
B6: { 식: "ROUND(B1*B5,1)" },
|
||
B7: { 식: "ROUND(B1*B2,1)" },
|
||
B8: { 식: "ROUND(B1*B3,1)" },
|
||
B9: { 식: "ROUND(B1*B4,1)" },
|
||
B10: { 식: "B1" },
|
||
});
|
||
const out = (name: string, cell: string, unit = won): SlPill => ({ name, cell, unit });
|
||
return {
|
||
cells,
|
||
rows: [],
|
||
ins: [
|
||
{ name: tl("Qty"), cell: "B1", unit: "" },
|
||
{ name: tl("PriceLabor"), cell: "B2", unit: won, outside: true },
|
||
],
|
||
outs: [
|
||
out(tl("Price"), "B5"),
|
||
out(tl("Amount"), "B6"),
|
||
out(tl("Labor3"), "B7"),
|
||
out(tl("Mat3"), "B8"),
|
||
out(tl("Exp3"), "B9"),
|
||
out(USED, "B10", ""),
|
||
],
|
||
};
|
||
}
|
||
|
||
/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */
|
||
|
||
/** 빈 시트 로직 */
|
||
export function newLogic(name = "", resultUnit = "원/㎡"): SheetLogic {
|
||
return {
|
||
key: null,
|
||
name,
|
||
resultUnit,
|
||
workbook: { 종류: "통합문서", 판: 1, 열: "SL", 서식: [{}], 시트: [] },
|
||
nodes: [],
|
||
edges: [],
|
||
};
|
||
}
|
||
|
||
/** 상자 더하기 — 그 종류 틀(칸 A 이름 · B 값 · C 설명)로 시트를 만듦 */
|
||
export function addNode(
|
||
sl: SheetLogic,
|
||
kind: SlKind,
|
||
name: string,
|
||
): { sl: SheetLogic; node: SlNode } {
|
||
const out = clone(sl);
|
||
const book = out.workbook;
|
||
const sheetId = nextId(
|
||
"s",
|
||
book.시트.map((s) => s.id),
|
||
);
|
||
const title = uniqueName(book, name);
|
||
const item = kind === "item" ? itemSheet() : null;
|
||
const t = item ?? template(kind, title);
|
||
const 칸: Record<string, Cell> =
|
||
item?.cells ??
|
||
(kind === "table"
|
||
? {}
|
||
: { A1: { 값: tl("Name") }, B1: { 값: tl("Value") }, C1: { 값: tl("Note") } });
|
||
t.rows.forEach(([label, cell, note], i) => {
|
||
칸[`A${i + 2}`] = { 값: label };
|
||
if (cell) 칸[`B${i + 2}`] = cell;
|
||
if (note) 칸[`C${i + 2}`] = { 값: note };
|
||
});
|
||
book.시트.push({ id: sheetId, 이름: title, 칸 });
|
||
book.활성 ??= sheetId;
|
||
const node: SlNode = {
|
||
id: nextId(
|
||
"n",
|
||
out.nodes.map((n) => n.id),
|
||
),
|
||
kind,
|
||
name: title,
|
||
sheetId,
|
||
pillsIn: t.ins,
|
||
pillsOut: t.outs,
|
||
...(item ? { ref: JSON.stringify({ what: "노무" }) } : {}),
|
||
stage: STAGE[kind],
|
||
};
|
||
out.nodes.push(node);
|
||
return { sl: out, node };
|
||
}
|
||
|
||
/** 상자 지우기 — 시트 · 이어진 선 · 받던 참조 수식을 함께 지움 */
|
||
export function removeNode(sl: SheetLogic, id: string): SheetLogic {
|
||
const node = nodeOf(sl, id);
|
||
if (!node) return sl;
|
||
let out = sl;
|
||
for (const e of sl.edges.filter((x) => x.from.node === id || x.to.node === id))
|
||
out = disconnect(out, e.id);
|
||
out = clone(out);
|
||
out.nodes = out.nodes.filter((n) => n.id !== id);
|
||
out.workbook.시트 = out.workbook.시트.filter((s) => s.id !== node.sheetId);
|
||
if (out.workbook.활성 === node.sheetId) out.workbook.활성 = out.workbook.시트[0]?.id;
|
||
return out;
|
||
}
|
||
|
||
/* ── 선 ─────────────────────────────────────────────────────────────────── */
|
||
|
||
/** 받는 알약 칸 수식 = 그 알약으로 오는 선들의 참조를 「+」 로 · 선이 없으면 칸을 비움 */
|
||
function rewriteTarget(sl: SheetLogic, to: SlEnd): void {
|
||
const node = nodeOf(sl, to.node);
|
||
const pill = node && pillOf(node, "in", to.pill);
|
||
if (!node || !pill) return;
|
||
const refs = sl.edges
|
||
.filter((e) => e.to.node === to.node && e.to.pill === to.pill)
|
||
.map((e) => {
|
||
const src = nodeOf(sl, e.from.node);
|
||
const out = src && pillOf(src, "out", e.from.pill);
|
||
return src && out ? `${quoteSheet(sheetOf(sl, src).이름)}!${out.cell}` : null;
|
||
})
|
||
.filter((x): x is string => x !== null);
|
||
const sheet = sheetOf(sl, node);
|
||
const keepStyle = sheet.칸[pill.cell]?.서식;
|
||
if (refs.length)
|
||
sheet.칸[pill.cell] = { 식: refs.join("+"), ...(keepStyle ? { 서식: keepStyle } : {}) };
|
||
else if (keepStyle) sheet.칸[pill.cell] = { 서식: keepStyle };
|
||
else delete sheet.칸[pill.cell];
|
||
}
|
||
|
||
/** from 상자에서 선을 따라 to 상자에 닿나 */
|
||
function reaches(sl: SheetLogic, from: string, to: string): boolean {
|
||
const seen = new Set<string>();
|
||
const walk = (id: string): boolean => {
|
||
if (id === to) return true;
|
||
if (seen.has(id)) return false;
|
||
seen.add(id);
|
||
return sl.edges.filter((e) => e.from.node === id).some((e) => walk(e.to.node));
|
||
};
|
||
return walk(from);
|
||
}
|
||
|
||
/** 선 잇기 — 받는 알약 칸에 참조 수식 · 순환 · 없는 알약이면 막고 까닭(쉬운 말) */
|
||
export function connect(
|
||
sl: SheetLogic,
|
||
from: SlEnd,
|
||
to: SlEnd,
|
||
): { sl: SheetLogic; why: string | null } {
|
||
const a = nodeOf(sl, from.node);
|
||
const b = nodeOf(sl, to.node);
|
||
if (!a || !b || !pillOf(a, "out", from.pill) || !pillOf(b, "in", to.pill))
|
||
return { sl, why: tl("NoPill") };
|
||
if (a.id === b.id) return { sl, why: tl("Self") };
|
||
const same = (e: SlEdge): boolean =>
|
||
e.from.node === from.node &&
|
||
e.from.pill === from.pill &&
|
||
e.to.node === to.node &&
|
||
e.to.pill === to.pill;
|
||
if (sl.edges.some(same)) return { sl, why: tl("Twice") };
|
||
if (reaches(sl, b.id, a.id)) return { sl, why: tl("Loop") };
|
||
const out = clone(sl);
|
||
out.edges.push({
|
||
id: nextId(
|
||
"e",
|
||
out.edges.map((e) => e.id),
|
||
),
|
||
from: { ...from },
|
||
to: { ...to },
|
||
});
|
||
rewriteTarget(out, to);
|
||
return { sl: out, why: null };
|
||
}
|
||
|
||
/** 선 끊기 — 그 참조를 받는 칸 수식에서 뺌 */
|
||
export function disconnect(sl: SheetLogic, edgeId: string): SheetLogic {
|
||
const edge = sl.edges.find((e) => e.id === edgeId);
|
||
if (!edge) return sl;
|
||
const out = clone(sl);
|
||
out.edges = out.edges.filter((e) => e.id !== edgeId);
|
||
rewriteTarget(out, edge.to);
|
||
return out;
|
||
}
|
||
|
||
/* ── 알약 ───────────────────────────────────────────────────────────────── */
|
||
|
||
/** 알약 정하기 — 같은 칸이나 같은 이름이 있으면 바꿈(이름이 바뀌면 이어진 선도 따라감) */
|
||
export function setPill(
|
||
sl: SheetLogic,
|
||
nodeId: string,
|
||
cell: string,
|
||
dir: "in" | "out",
|
||
name: string,
|
||
extra: Pick<SlPill, "unit" | "outside"> = {},
|
||
): SheetLogic {
|
||
const out = clone(sl);
|
||
const node = nodeOf(out, nodeId);
|
||
if (!node) return sl;
|
||
const list = dir === "in" ? node.pillsIn : node.pillsOut;
|
||
const at = list.findIndex((p) => p.cell === cell || p.name === name);
|
||
const pill: SlPill = { name, cell: cell.toUpperCase(), ...extra };
|
||
if (at < 0) {
|
||
list.push(pill);
|
||
return out;
|
||
}
|
||
const old = list[at];
|
||
list[at] = pill;
|
||
for (const e of out.edges) {
|
||
const end = dir === "in" ? e.to : e.from;
|
||
if (end.node === nodeId && end.pill === old.name) end.pill = name;
|
||
}
|
||
// 칸이 바뀌면 선이 가리키던 칸도 새로 씀
|
||
if (old.cell !== pill.cell) {
|
||
const linked = out.edges.some((e) => e.to.node === nodeId && e.to.pill === name);
|
||
if (dir === "in" && linked) {
|
||
delete sheetOf(out, node).칸[old.cell];
|
||
rewriteTarget(out, { node: nodeId, pill: name });
|
||
} else if (dir === "out")
|
||
for (const e of out.edges.filter((x) => x.from.node === nodeId && x.from.pill === name))
|
||
rewriteTarget(out, e.to);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 알약 빼기 — 이어진 선도 끊음 */
|
||
export function removePill(
|
||
sl: SheetLogic,
|
||
nodeId: string,
|
||
dir: "in" | "out",
|
||
name: string,
|
||
): SheetLogic {
|
||
let out = sl;
|
||
for (const e of sl.edges) {
|
||
const end = dir === "in" ? e.to : e.from;
|
||
if (end.node === nodeId && end.pill === name) out = disconnect(out, e.id);
|
||
}
|
||
out = clone(out);
|
||
const node = nodeOf(out, nodeId);
|
||
if (!node) return out;
|
||
if (dir === "in") node.pillsIn = node.pillsIn.filter((p) => p.name !== name);
|
||
else node.pillsOut = node.pillsOut.filter((p) => p.name !== name);
|
||
return out;
|
||
}
|
||
|
||
/** 상자 · 시트 찾기(Eval · 화면이 씀) */
|
||
export function nodeSheet(sl: SheetLogic, nodeId: string): Sheet | null {
|
||
const node = nodeOf(sl, nodeId);
|
||
return node ? sheetOf(sl, node) : null;
|
||
}
|
||
|
||
/** 상자 이름 바꾸기 — 시트 이름도(엔진 시트 이름 명령) · 그 시트를 가리키던 선 수식 · 다른 수식도 따라감 */
|
||
export function renameNode(
|
||
sl: SheetLogic,
|
||
nodeId: string,
|
||
name: string,
|
||
): { sl: SheetLogic; why: string | null } {
|
||
const node = nodeOf(sl, nodeId);
|
||
if (!node) return { sl, why: tl("NoBox") };
|
||
const next = name.trim();
|
||
if (!next || next.length > 31) return { sl, why: tl("NameLen") };
|
||
if (/[[\]:*?/\\]/.test(next) || next.startsWith("'") || next.endsWith("'"))
|
||
return { sl, why: tl("NameChar") };
|
||
if (uniqueName(sl.workbook, next, node.sheetId) !== next) return { sl, why: tl("NameSame") };
|
||
const out = clone(sl);
|
||
applyCommand(out.workbook, { 종류: "시트이름", 시트: node.sheetId, 이름: next });
|
||
nodeOf(out, nodeId)!.name = next;
|
||
return { sl: out, why: null };
|
||
}
|