Merge remote-tracking branch 'origin/dev' into sub_laptop_6
This commit is contained in:
@@ -67,6 +67,8 @@ config/corridor_node/
|
||||
config/server_calc_node/
|
||||
# 구조물도 식 풀이 번들 — `npm run build:formula` 산출물(2026-09-13).
|
||||
config/formula_node/
|
||||
# 산출근거 스프레드시트 재계산 번들 — `npm run build:spreadsheet` 산출물.
|
||||
config/spreadsheet_node/
|
||||
|
||||
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
|
||||
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/* spreadsheet.css (주인 D) — 부품 틀 · 주소 상자 · 수식 입력줄 · 칸 편집기 · 채우기 미리보기 · 알림 */
|
||||
|
||||
.ss {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
font-size: 13px;
|
||||
color: #1f2328;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ss-grid-host {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ss-bar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: stretch;
|
||||
height: 26px;
|
||||
border-bottom: 1px solid #d0d7de;
|
||||
}
|
||||
|
||||
.ss-addr {
|
||||
width: 96px;
|
||||
padding: 0 6px;
|
||||
border: none;
|
||||
border-right: 1px solid #d0d7de;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ss-addr:focus {
|
||||
box-shadow: inset 0 0 0 2px #217346;
|
||||
}
|
||||
|
||||
.ss-fx {
|
||||
align-self: center;
|
||||
padding: 0 8px;
|
||||
color: #6e7781;
|
||||
font-style: italic;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ss-formula {
|
||||
flex: 1;
|
||||
padding: 4px 6px;
|
||||
border: none;
|
||||
border-left: 1px solid #d0d7de;
|
||||
font: inherit;
|
||||
line-height: 18px;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ss--readonly .ss-formula {
|
||||
color: #57606a;
|
||||
}
|
||||
|
||||
/* 칸 편집기 = 숨은 textarea — 쉴 때는 활성 칸 자리에 투명하게(한글 조합 창 자리) */
|
||||
.ss-input {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 1px 3px;
|
||||
border: 2px solid #217346;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
font: inherit;
|
||||
line-height: 1.25;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ss-input--idle {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ss-fill-preview {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed #57606a;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ss-toast {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 40px;
|
||||
z-index: 20;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
background: #24292f;
|
||||
color: #fff;
|
||||
}
|
||||
@@ -6,10 +6,38 @@
|
||||
* 키 · 마우스)을 잇음. 한글 조합 = 숨은 textarea 에 늘 초점 · compositionstart 로 편집 시작(keydown 글자 방식 금지).
|
||||
* ⚠ M02 만 `import()` 로 불러옴 — 정적 import 금지(다른 페이지 번들에 안 섞이게).
|
||||
* 저장은 부른 쪽 몫(`onChange` 로 문서 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
|
||||
* 0 계약 머리 — 몸은 D 가 채움.
|
||||
* 화면 차례: 도구 모음(E) · 주소 상자 + 수식 입력줄(D) · 격자(C) · 시트 탭(E).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CalcValues, Workbook } from "./spreadsheet_types";
|
||||
import "./spreadsheet.css";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address";
|
||||
import { attachClipboard } from "./spreadsheet_clipboard";
|
||||
import { applyCommand, CommandError } from "./spreadsheet_commands";
|
||||
import { createEditor } from "./spreadsheet_editor";
|
||||
import { createCalcEngine } from "./spreadsheet_graph";
|
||||
import { createGrid } from "./spreadsheet_grid";
|
||||
import { createHistory } from "./spreadsheet_history";
|
||||
import { attachKeys } from "./spreadsheet_keys";
|
||||
import { attachMenu } from "./spreadsheet_menu";
|
||||
import { attachMouse } from "./spreadsheet_mouse";
|
||||
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection";
|
||||
import { mountTabs } from "./spreadsheet_tabs";
|
||||
import { mountToolbar } from "./spreadsheet_toolbar";
|
||||
import type {
|
||||
CalcValues,
|
||||
Command,
|
||||
CommandEffect,
|
||||
SheetCellAddress,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
import type {
|
||||
EditorHandle,
|
||||
GridHandle,
|
||||
PartHandle,
|
||||
Selection,
|
||||
SpreadsheetContext,
|
||||
} from "./spreadsheet_view_types";
|
||||
|
||||
export type { Workbook, CalcValues } from "./spreadsheet_types";
|
||||
|
||||
@@ -32,14 +60,197 @@ export interface SpreadsheetHandle {
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet: 아직 없음(D)");
|
||||
/** 이어 붙이는 남의 부품 — 시험 틀이 빈 몸 부품을 가짜로 바꿔 끼우는 자리(화면 코드는 그대로 씀) */
|
||||
export const parts = {
|
||||
createCalcEngine,
|
||||
createHistory,
|
||||
applyCommand,
|
||||
createGrid,
|
||||
mountToolbar,
|
||||
mountTabs,
|
||||
attachMenu,
|
||||
attachClipboard,
|
||||
};
|
||||
|
||||
/** 움직이는 끝(닻에서 먼 쪽) — 행열 전체면 활성 칸 쪽 */
|
||||
function farCorner(sel: Selection): { r: number; c: number } {
|
||||
const g = sel.범위[0];
|
||||
const a = sel.기준;
|
||||
const whole = (lo: number, hi: number, last: number): boolean => lo === 0 && hi === last;
|
||||
return {
|
||||
r: whole(g.r0, g.r1, LAST_R)
|
||||
? sel.활성.r
|
||||
: Math.abs(g.r1 - a.r) >= Math.abs(a.r - g.r0)
|
||||
? g.r1
|
||||
: g.r0,
|
||||
c: whole(g.c0, g.c1, LAST_C)
|
||||
? sel.활성.c
|
||||
: Math.abs(g.c1 - a.c) >= Math.abs(a.c - g.c0)
|
||||
? g.c1
|
||||
: g.c0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSpreadsheet(
|
||||
_host: HTMLElement,
|
||||
_book: Workbook,
|
||||
_opts: SpreadsheetOptions,
|
||||
host: HTMLElement,
|
||||
book: Workbook,
|
||||
opts: SpreadsheetOptions = {},
|
||||
): SpreadsheetHandle {
|
||||
return todo();
|
||||
const readOnly = !!opts.readOnly;
|
||||
const root = el("div", { className: readOnly ? "ss ss--readonly" : "ss" });
|
||||
host.append(root);
|
||||
const firstSheet = (b: Workbook) => b.시트.find((s) => s.id === b.활성) ?? b.시트[0];
|
||||
/** 시트마다 마지막 고름(탭을 오가도 그대로) */
|
||||
const memory = new Map<string, Selection>();
|
||||
|
||||
const ctx: SpreadsheetContext = {
|
||||
root,
|
||||
book,
|
||||
readOnly,
|
||||
engine: parts.createCalcEngine(book),
|
||||
history: parts.createHistory(),
|
||||
selection: selectCell(firstSheet(book), 0, 0),
|
||||
sheet: () => ctx.book.시트.find((s) => s.id === ctx.selection.시트) ?? ctx.book.시트[0],
|
||||
dispatch,
|
||||
undo: () => replay(ctx.history.undo()),
|
||||
redo: () => replay(ctx.history.redo()),
|
||||
select,
|
||||
showSheet,
|
||||
grid: null as unknown as GridHandle,
|
||||
editor: null as unknown as EditorHandle,
|
||||
};
|
||||
|
||||
const grid = parts.createGrid(ctx);
|
||||
ctx.grid = grid;
|
||||
const ed = createEditor(ctx);
|
||||
ctx.editor = ed;
|
||||
const toolbar: PartHandle | null = readOnly ? null : parts.mountToolbar(ctx);
|
||||
const tabs = parts.mountTabs(ctx);
|
||||
const menu: PartHandle | null = readOnly ? null : parts.attachMenu(ctx);
|
||||
const clip = parts.attachClipboard(ctx);
|
||||
const others = [toolbar, tabs, menu, clip].filter((p): p is PartHandle => !!p);
|
||||
|
||||
grid.root.classList.add("ss-grid-host");
|
||||
for (const node of [toolbar?.root, ed.bar, grid.root, tabs.root]) if (node) root.append(node);
|
||||
const detachKeys = attachKeys(ctx, ed);
|
||||
const detachMouse = attachMouse(ctx, ed);
|
||||
const toast = el("div", { className: "ss-toast" });
|
||||
let toastTimer = 0;
|
||||
|
||||
grid.render();
|
||||
select(ctx.selection);
|
||||
|
||||
function notify(message: string): void {
|
||||
toast.textContent = message;
|
||||
root.append(toast);
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = window.setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
function apply(command: Command): CommandEffect | null {
|
||||
try {
|
||||
return parts.applyCommand(ctx.book, command);
|
||||
} catch (e) {
|
||||
if (e instanceof CommandError) return (notify(e.message), null);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function dispatch(command: Command): void {
|
||||
if (readOnly) return;
|
||||
const eff = apply(command);
|
||||
if (!eff) return;
|
||||
ctx.history.push(command, eff.undo);
|
||||
after(command, eff);
|
||||
}
|
||||
|
||||
function replay(command: Command | null): void {
|
||||
if (readOnly || !command || ed.editing()) return;
|
||||
const eff = apply(command);
|
||||
if (eff) after(command, eff);
|
||||
}
|
||||
|
||||
function after(command: Command, eff: CommandEffect): void {
|
||||
if (eff.rebuild) {
|
||||
ctx.engine.rebuild(ctx.book);
|
||||
if (!ctx.book.시트.some((s) => s.id === ctx.selection.시트)) {
|
||||
const s = firstSheet(ctx.book);
|
||||
ctx.selection = memory.get(s.id) ?? selectCell(s, 0, 0);
|
||||
}
|
||||
grid.render();
|
||||
} else {
|
||||
const again = ctx.engine.update(eff.cells);
|
||||
if (command.종류 === "칸") grid.invalidate([...again, ...touched(command.시트, command.칸)]);
|
||||
else grid.render();
|
||||
}
|
||||
select(ctx.selection);
|
||||
opts.onChange?.(structuredClone(ctx.book));
|
||||
}
|
||||
|
||||
/** 칸 명령이 건드린 칸(서식만 바뀐 칸도 다시 그리게) */
|
||||
function touched(sheet: string, cells: Record<string, unknown>): SheetCellAddress[] {
|
||||
const out: SheetCellAddress[] = [];
|
||||
for (const key of Object.keys(cells)) {
|
||||
const a = parseA1(key);
|
||||
if (a) out.push({ 시트: sheet, ...a });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function select(selection: Selection, reveal = false): void {
|
||||
ctx.selection = selection;
|
||||
memory.set(selection.시트, selection);
|
||||
grid.renderSelection();
|
||||
if (reveal) {
|
||||
const far = farCorner(selection);
|
||||
grid.reveal(far.r, far.c);
|
||||
}
|
||||
ed.sync();
|
||||
for (const p of others) p.refresh();
|
||||
const g = selection.범위[0];
|
||||
opts.onSelect?.({
|
||||
시트: selection.시트,
|
||||
칸: toA1(selection.활성.r, selection.활성.c),
|
||||
범위: g ? rangeToA1(g) : toA1(selection.활성.r, selection.활성.c),
|
||||
});
|
||||
}
|
||||
|
||||
function showSheet(sheetId: string): void {
|
||||
const sheet = ctx.book.시트.find((s) => s.id === sheetId);
|
||||
if (!sheet || sheetId === ctx.selection.시트) return;
|
||||
ed.commit();
|
||||
ctx.book.활성 = sheetId;
|
||||
ctx.selection = memory.get(sheetId) ?? selectCell(sheet, 0, 0);
|
||||
grid.render();
|
||||
select(ctx.selection, true);
|
||||
ed.focus();
|
||||
}
|
||||
|
||||
return {
|
||||
getDoc: () => structuredClone(ctx.book),
|
||||
setDoc(next: Workbook) {
|
||||
ed.cancel();
|
||||
ctx.book = next;
|
||||
ctx.engine.rebuild(next);
|
||||
ctx.history.clear();
|
||||
memory.clear();
|
||||
ctx.selection = selectCell(firstSheet(next), 0, 0);
|
||||
grid.render();
|
||||
select(ctx.selection);
|
||||
},
|
||||
recalc() {
|
||||
ctx.engine.rebuild(ctx.book);
|
||||
grid.render();
|
||||
return ctx.engine.snapshot();
|
||||
},
|
||||
destroy() {
|
||||
clearTimeout(toastTimer);
|
||||
detachKeys();
|
||||
detachMouse();
|
||||
for (const p of others) p.destroy();
|
||||
ed.destroy();
|
||||
grid.destroy();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,55 +1,115 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_address.ts (주인 A)
|
||||
* 주소 — A1 ↔ 행열(0 부터) · 범위 글 · 열 글자 · 시트 이름 따옴표 · Map 열쇠.
|
||||
* 0 계약 머리 — 몸은 A 가 채움. 계약은 `spreadsheet_types.ts`.
|
||||
* 계약은 `spreadsheet_types.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type { CellAddress, CellRange } from "./spreadsheet_types";
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet_address: 아직 없음(A)");
|
||||
};
|
||||
|
||||
/** 0 → `A` · 27 → `AB` */
|
||||
export function colName(_c: number): string {
|
||||
return todo();
|
||||
export function colName(c: number): string {
|
||||
let s = "";
|
||||
for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26)) {
|
||||
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** `AB` → 27 · 못 읽으면 -1 (대소문자 가리지 않음) */
|
||||
export function colIndex(_letters: string): number {
|
||||
return todo();
|
||||
export function colIndex(letters: string): number {
|
||||
if (!/^[A-Za-z]{1,3}$/.test(letters)) return -1;
|
||||
let n = 0;
|
||||
for (const ch of letters.toUpperCase()) n = n * 26 + ch.charCodeAt(0) - 64;
|
||||
return n <= MAX_COLS ? n - 1 : -1;
|
||||
}
|
||||
|
||||
/** 행 글(1 부터) → 0 부터 · 못 읽으면 -1 */
|
||||
export function rowIndex(digits: string): number {
|
||||
const n = /^\d{1,7}$/.test(digits) ? Number(digits) : 0;
|
||||
return n >= 1 && n <= MAX_ROWS ? n - 1 : -1;
|
||||
}
|
||||
|
||||
/** (30, 4) → `E31` */
|
||||
export function toA1(_r: number, _c: number): string {
|
||||
return todo();
|
||||
export function toA1(r: number, c: number): string {
|
||||
return colName(c) + (r + 1);
|
||||
}
|
||||
|
||||
/** `E31` · `$E$31` → {r:30, c:4} · 못 읽으면 null */
|
||||
export function parseA1(_text: string): CellAddress | null {
|
||||
return todo();
|
||||
export function parseA1(text: string): CellAddress | null {
|
||||
const m = /^\$?([A-Za-z]{1,3})\$?(\d+)$/.exec(text.trim());
|
||||
if (!m) return null;
|
||||
const c = colIndex(m[1]);
|
||||
const r = rowIndex(m[2]);
|
||||
return c < 0 || r < 0 ? null : { r, c };
|
||||
}
|
||||
|
||||
/** 칸 하나면 `E31` · 아니면 `C3:AD3` · 행 전체 `3:3` · 열 전체 `A:A` */
|
||||
export function rangeToA1(_range: CellRange): string {
|
||||
return todo();
|
||||
export function rangeToA1(range: CellRange): string {
|
||||
const { r0, c0, r1, c1 } = range;
|
||||
if (c0 === 0 && c1 === MAX_COLS - 1) return `${r0 + 1}:${r1 + 1}`;
|
||||
if (r0 === 0 && r1 === MAX_ROWS - 1) return `${colName(c0)}:${colName(c1)}`;
|
||||
const a = toA1(r0, c0);
|
||||
return r0 === r1 && c0 === c1 ? a : `${a}:${toA1(r1, c1)}`;
|
||||
}
|
||||
|
||||
/** 닫힌 범위로 세움(뒤집힌 것 바로) */
|
||||
export function normRange(r0: number, c0: number, r1: number, c1: number): CellRange {
|
||||
return {
|
||||
r0: Math.min(r0, r1),
|
||||
c0: Math.min(c0, c1),
|
||||
r1: Math.max(r0, r1),
|
||||
c1: Math.max(c0, c1),
|
||||
};
|
||||
}
|
||||
|
||||
/** `C3:AD3` · `E31` · `A:A` · `3:5` → 범위(뒤집힌 글은 바로 세움) · 못 읽으면 null */
|
||||
export function parseRange(_text: string): CellRange | null {
|
||||
return todo();
|
||||
export function parseRange(text: string): CellRange | null {
|
||||
const parts = text.trim().split(":");
|
||||
if (parts.length > 2) return null;
|
||||
const [a, b = a] = parts;
|
||||
const p = parseA1(a);
|
||||
const q = parseA1(b);
|
||||
if (p && q) return normRange(p.r, p.c, q.r, q.c);
|
||||
if (parts.length !== 2) return null;
|
||||
const strip = (s: string) => s.replace(/^\$/, "");
|
||||
const ca = colIndex(strip(a));
|
||||
const cb = colIndex(strip(b));
|
||||
if (ca >= 0 && cb >= 0) return normRange(0, ca, MAX_ROWS - 1, cb);
|
||||
const ra = rowIndex(strip(a));
|
||||
const rb = rowIndex(strip(b));
|
||||
if (ra >= 0 && rb >= 0) return normRange(ra, 0, rb, MAX_COLS - 1);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function inRange(range: CellRange, r: number, c: number): boolean {
|
||||
return r >= range.r0 && r <= range.r1 && c >= range.c0 && c <= range.c1;
|
||||
}
|
||||
|
||||
export function rangesOverlap(a: CellRange, b: CellRange): boolean {
|
||||
return a.r0 <= b.r1 && b.r0 <= a.r1 && a.c0 <= b.c1 && b.c0 <= a.c1;
|
||||
}
|
||||
|
||||
/** `a` 가 `b` 안에 통째로 듦 */
|
||||
export function rangeInside(a: CellRange, b: CellRange): boolean {
|
||||
return a.r0 >= b.r0 && a.r1 <= b.r1 && a.c0 >= b.c0 && a.c1 <= b.c1;
|
||||
}
|
||||
|
||||
/** 식에 넣을 시트 이름 — 글자 · 숫자 · _ 밖이 있으면 `'…'`(안의 `'` 는 `''`) */
|
||||
export function quoteSheet(_name: string): string {
|
||||
return todo();
|
||||
export function quoteSheet(name: string): string {
|
||||
const plain =
|
||||
/^[\p{L}_][\p{L}\p{N}_]*$/u.test(name) &&
|
||||
!/^[A-Za-z]{1,3}\d+$/.test(name) && // 칸 주소처럼 보이는 이름
|
||||
!/^(TRUE|FALSE)$/i.test(name) &&
|
||||
!/^R\d*C\d*$/i.test(name); // R1C1 처럼 보이는 이름
|
||||
return plain ? name : `'${name.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
/** Map 열쇠 — r × MAX_COLS + c */
|
||||
export function cellId(_r: number, _c: number): number {
|
||||
return todo();
|
||||
export function cellId(r: number, c: number): number {
|
||||
return r * MAX_COLS + c;
|
||||
}
|
||||
|
||||
export function fromCellId(_id: number): CellAddress {
|
||||
return todo();
|
||||
export function fromCellId(id: number): CellAddress {
|
||||
return { r: Math.floor(id / MAX_COLS), c: id % MAX_COLS };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_autofit.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 열 머리 경계를 두 번 눌러 폭 자동 맞춤 — 그 열 안 글자 수로 어림(엑셀 「글자 단위」).
|
||||
* 진짜 픽셀 값은 C(격자)가 쓰는 글꼴로 재야 더 정확 — `measure` 자리에 C 의 실제 재기 함수를
|
||||
* 나중에 꽂을 수 있게 열어 둠(안 주면 아래 어림 규칙을 씀).
|
||||
*
|
||||
* 잇는 법(D · sub7 · C 격자 머리) — 열 머리 경계에서 두 번 누르면(dblclick)
|
||||
* `autofitColumn(ctx, col)` 을 부름. C 가 완성되면 `measure` 자리에 캔버스 `measureText` 를 넘겨 더 정밀하게.
|
||||
*
|
||||
* ── 이미 계약에 있어 새 파일이 필요 없는 것(붙일 자리만 확인) ──────────────────────
|
||||
* · 여러 범위(Ctrl+누르기) — `spreadsheet_view_types.ts` `Selection.범위` 가 이미 배열(D `spreadsheet_selection.ts` 가 씀).
|
||||
* · 눈금선 끄기 — `Command`(`종류:"눈금선"`) · `Sheet.보기?.눈금선` 이미 있음(A 가 처리).
|
||||
* · 확대 · 축소 — 값 자체를 저장하지 않는 화면 전용 CSS 배율(C 격자 뿌리에 transform) · 계약 밖.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Workbook } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
export type TextMeasurer = (text: string) => number;
|
||||
|
||||
const PADDING = 2;
|
||||
const MIN_WIDTH = 4;
|
||||
|
||||
/** 글꼴을 모를 때 어림 — 한글 · 한자 · 전각은 1.9 배 폭 · 나머지는 1 배. */
|
||||
const defaultMeasure: TextMeasurer = (text) => {
|
||||
let width = 0;
|
||||
for (const ch of text) width += ch.codePointAt(0)! >= 0x1100 ? 1.9 : 1;
|
||||
return width;
|
||||
};
|
||||
|
||||
const colFromKey = (key: string): number => {
|
||||
const m = /^([A-Z]+)\d+$/.exec(key);
|
||||
if (!m) return -1;
|
||||
let col = 0;
|
||||
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
return col - 1;
|
||||
};
|
||||
|
||||
/** 그 열에 든 칸 글자(식 칸은 식 글로 어림 — 계산값은 여기서 모름) 중 가장 넓은 것 + 여백. */
|
||||
export function estimateColumnWidth(
|
||||
book: Workbook,
|
||||
sheetId: string,
|
||||
col: number,
|
||||
measure: TextMeasurer = defaultMeasure,
|
||||
): number {
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
if (!sheet) return MIN_WIDTH;
|
||||
let widest = 0;
|
||||
for (const [key, cell] of Object.entries(sheet.칸)) {
|
||||
if (colFromKey(key) !== col) continue;
|
||||
const text = cell.식 !== undefined ? cell.식 : cell.값 !== undefined ? String(cell.값) : "";
|
||||
if (text) widest = Math.max(widest, measure(text));
|
||||
}
|
||||
return Math.max(MIN_WIDTH, Math.ceil(widest + PADDING));
|
||||
}
|
||||
|
||||
/** 폭을 자동 맞춤 값으로 바꿈(열넓이 명령 하나). */
|
||||
export function autofitColumn(ctx: SpreadsheetContext, col: number, measure?: TextMeasurer): void {
|
||||
const width = estimateColumnWidth(ctx.book, ctx.selection.시트, col, measure);
|
||||
ctx.dispatch({ 종류: "열폭", 시트: ctx.selection.시트, 열: [col], 폭: width });
|
||||
}
|
||||
@@ -4,32 +4,691 @@
|
||||
* 병합 규칙(엑셀): 병합 일부를 가르는 칸 쓰기 · 옮기기는 막음(오류 던짐 → 화면이 알림) ·
|
||||
* 병합 안에 행열을 넣으면 병합이 늘고 지우면 줆 · 병합하면 왼위 칸 값만 남김.
|
||||
* 서식 표는 같은 서식을 한 번만 둠(칸은 번호) · 안 쓰는 서식 정리는 저장 전에(`compactStyles`).
|
||||
* 0 계약 머리 — 몸은 A 가 채움.
|
||||
* 행열 · 옮기기 · 시트 지우기는 바뀐 시트를 통째로 되돌림(`시트통째`) — 다른 시트 식 글까지 한 번에.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Command, CommandEffect, Sheet, Workbook } from "./spreadsheet_types";
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet_commands: 아직 없음(A)");
|
||||
};
|
||||
import {
|
||||
colName,
|
||||
inRange,
|
||||
normRange,
|
||||
parseA1,
|
||||
parseRange,
|
||||
rangeInside,
|
||||
rangesOverlap,
|
||||
rangeToA1,
|
||||
toA1,
|
||||
} from "./spreadsheet_address";
|
||||
import { shiftForCommand, shiftSpan } from "./spreadsheet_refshift";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type {
|
||||
Cell,
|
||||
CellRange,
|
||||
CellStyle,
|
||||
Command,
|
||||
CommandEffect,
|
||||
Sheet,
|
||||
SheetCellAddress,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
|
||||
/** 막힌 명령(병합 가름 · 마지막 시트 지우기 · 같은 시트 이름 …) — 화면이 `message` 를 알림 */
|
||||
export class CommandError extends Error {}
|
||||
|
||||
export function applyCommand(_book: Workbook, _command: Command): CommandEffect {
|
||||
return todo();
|
||||
type Of<K extends Command["종류"]> = Extract<Command, { 종류: K }>;
|
||||
|
||||
const clone = <T>(x: T): T => structuredClone(x);
|
||||
|
||||
function sheetOf(book: Workbook, id: string): Sheet {
|
||||
const s = book.시트.find((x) => x.id === id);
|
||||
if (!s) throw new CommandError(`없는 시트: ${id}`);
|
||||
return s;
|
||||
}
|
||||
|
||||
const mergesOf = (s: Sheet): CellRange[] =>
|
||||
(s.병합 ?? []).map((t) => parseRange(t)).filter((r): r is CellRange => r !== null);
|
||||
|
||||
function setMerges(s: Sheet, list: CellRange[]) {
|
||||
if (list.length) s.병합 = list.map(rangeToA1);
|
||||
else delete s.병합;
|
||||
}
|
||||
|
||||
/** 칸 열쇠 중 범위 안 것 */
|
||||
function cellsIn(s: Sheet, ranges: CellRange[]): [string, number, number][] {
|
||||
const out: [string, number, number][] = [];
|
||||
for (const a1 of Object.keys(s.칸)) {
|
||||
const at = parseA1(a1);
|
||||
if (at && ranges.some((rg) => inRange(rg, at.r, at.c))) out.push([a1, at.r, at.c]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const isFull = (rg: CellRange, rows: boolean) =>
|
||||
rows ? rg.c0 === 0 && rg.c1 === MAX_COLS - 1 : rg.r0 === 0 && rg.r1 === MAX_ROWS - 1;
|
||||
|
||||
function sortKeys(v: unknown): unknown {
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return v;
|
||||
const o = v as Record<string, unknown>;
|
||||
return Object.fromEntries(
|
||||
Object.keys(o)
|
||||
.sort()
|
||||
.map((k) => [k, sortKeys(o[k])]),
|
||||
);
|
||||
}
|
||||
const styleKey = (s: CellStyle) => JSON.stringify(sortKeys(s));
|
||||
|
||||
function internStyle(book: Workbook, style: CellStyle): number {
|
||||
const key = styleKey(style);
|
||||
const i = book.서식.findIndex((s) => styleKey(s) === key);
|
||||
if (i >= 0) return i;
|
||||
book.서식.push(style);
|
||||
return book.서식.length - 1;
|
||||
}
|
||||
|
||||
function setComments(s: Sheet, notes: Record<string, string>) {
|
||||
if (Object.keys(notes).length) s.comments = notes;
|
||||
else delete s.comments;
|
||||
}
|
||||
|
||||
/** 시트 통째 서식 */
|
||||
const sheetStyle = (s: Sheet) => s.기본?.서식 ?? 0;
|
||||
|
||||
/** 칸 자리에서 물려받는 서식(칸 서식 없을 때) — 행 · 열 · 시트 · 0 */
|
||||
const inherited = (s: Sheet, r: number, c: number) =>
|
||||
s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? sheetStyle(s);
|
||||
|
||||
/** 시트 `기본` 한 칸 — undefined 면 지움 · 빈 `기본` 도 지움 */
|
||||
function setSheetDefault(s: Sheet, key: "서식" | "열폭" | "행높이", value: number | undefined) {
|
||||
const d = (s.기본 ??= {});
|
||||
if (value === undefined) delete d[key];
|
||||
else d[key] = value;
|
||||
if (!Object.keys(d).length) delete s.기본;
|
||||
}
|
||||
|
||||
const addr = (시트: string, r: number, c: number): SheetCellAddress => ({ 시트, r, c });
|
||||
|
||||
/** 바뀐 시트만 통째 되돌림 짝 */
|
||||
function restoreChanged(book: Workbook, old: Sheet[]): Command[] {
|
||||
const out: Command[] = [];
|
||||
for (const o of old) {
|
||||
const now = book.시트.find((x) => x.id === o.id);
|
||||
if (now && JSON.stringify(now) !== JSON.stringify(o)) out.push({ 종류: "시트통째", 시트: o });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 모든 식 글을 명령에 맞춰 고침 — 시트 이름을 바꾸기 전에 부름 */
|
||||
function rewriteAll(book: Workbook, command: Command) {
|
||||
for (const s of book.시트)
|
||||
for (const cell of Object.values(s.칸))
|
||||
if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, command, book);
|
||||
}
|
||||
|
||||
function checkSheetName(book: Workbook, name: string, self?: string) {
|
||||
if (!name.trim() || name.length > 31) throw new CommandError("시트 이름은 1~31 글자");
|
||||
if (/[[\]:*?/\\]/.test(name) || name.startsWith("'") || name.endsWith("'"))
|
||||
throw new CommandError("시트 이름에 못 쓰는 글자: [ ] : * ? / \\ 와 앞뒤 '");
|
||||
if (book.시트.some((s) => s.id !== self && s.이름.toLowerCase() === name.toLowerCase()))
|
||||
throw new CommandError(`같은 이름 시트가 있음: ${name}`);
|
||||
}
|
||||
|
||||
// ── 칸 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function setCells(book: Workbook, cmd: Of<"칸">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const merges = mergesOf(s);
|
||||
const plan: [string, number, number, Cell | null][] = [];
|
||||
for (const [key, cell] of Object.entries(cmd.칸)) {
|
||||
const at = parseA1(key);
|
||||
if (!at) throw new CommandError(`칸 주소 틀림: ${key}`);
|
||||
if (cell && (cell.값 !== undefined || cell.식 !== undefined))
|
||||
if (merges.some((m) => inRange(m, at.r, at.c) && (m.r0 !== at.r || m.c0 !== at.c)))
|
||||
throw new CommandError("병합한 칸은 왼위 칸에만 값을 넣음");
|
||||
plan.push([toA1(at.r, at.c), at.r, at.c, cell]);
|
||||
}
|
||||
const prev: Record<string, Cell | null> = {};
|
||||
for (const [a1, , , cell] of plan) {
|
||||
prev[a1] = s.칸[a1] ? clone(s.칸[a1]) : null;
|
||||
if (cell) s.칸[a1] = clone(cell);
|
||||
else delete s.칸[a1];
|
||||
}
|
||||
return {
|
||||
undo: { 종류: "칸", 시트: s.id, 칸: prev },
|
||||
cells: plan.map(([, r, c]) => addr(s.id, r, c)),
|
||||
rebuild: false,
|
||||
};
|
||||
}
|
||||
|
||||
function clearContents(book: Workbook, cmd: Of<"내용지움">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const prev: Record<string, Cell | null> = {};
|
||||
const cells: SheetCellAddress[] = [];
|
||||
for (const [a1, r, c] of cellsIn(s, cmd.범위)) {
|
||||
const cell = s.칸[a1];
|
||||
if (cell.값 === undefined && cell.식 === undefined) continue;
|
||||
prev[a1] = clone(cell);
|
||||
if (cell.서식 === undefined) delete s.칸[a1];
|
||||
else s.칸[a1] = { 서식: cell.서식 };
|
||||
cells.push(addr(s.id, r, c));
|
||||
}
|
||||
return { undo: { 종류: "칸", 시트: s.id, 칸: prev }, cells, rebuild: false };
|
||||
}
|
||||
|
||||
// ── 서식 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 범위 서식 바꿈 — `change(지금 번호) → 새 번호`. 행 · 열 전체는 행 · 열 서식 + 든 칸. */
|
||||
function restyle(
|
||||
book: Workbook,
|
||||
sheet: string,
|
||||
ranges: CellRange[],
|
||||
change: (i: number) => number,
|
||||
): CommandEffect {
|
||||
const s = sheetOf(book, sheet);
|
||||
const before = clone(s);
|
||||
const memo = new Map<number, number>();
|
||||
const f = (i: number) => {
|
||||
if (!memo.has(i)) memo.set(i, change(i));
|
||||
return memo.get(i)!;
|
||||
};
|
||||
const prev: Record<string, Cell | null> = {};
|
||||
const cells: SheetCellAddress[] = [];
|
||||
let whole = false;
|
||||
const setInfo = (axis: "행" | "열", key: string) => {
|
||||
const table = (s[axis] ??= {});
|
||||
const info = (table[key] ??= {});
|
||||
const idx = f(info.서식 ?? sheetStyle(s));
|
||||
if (idx !== sheetStyle(s)) info.서식 = idx;
|
||||
else delete info.서식;
|
||||
if (!Object.keys(info).length) delete table[key];
|
||||
};
|
||||
const touch = (r: number, c: number) => {
|
||||
const a1 = toA1(r, c);
|
||||
if (a1 in prev) return;
|
||||
const cell = s.칸[a1];
|
||||
prev[a1] = cell ? clone(cell) : null;
|
||||
const next: Cell = { ...cell };
|
||||
const idx = f(cell?.서식 ?? inherited(s, r, c));
|
||||
if (idx === inherited(s, r, c)) delete next.서식;
|
||||
else next.서식 = idx;
|
||||
if (Object.keys(next).length) s.칸[a1] = next;
|
||||
else delete s.칸[a1];
|
||||
cells.push(addr(s.id, r, c));
|
||||
};
|
||||
for (const rg of ranges) {
|
||||
const fullRows = isFull(rg, true);
|
||||
const fullCols = isFull(rg, false);
|
||||
if (fullRows && fullCols) {
|
||||
whole = true;
|
||||
const idx = f(sheetStyle(s));
|
||||
setSheetDefault(s, "서식", idx || undefined);
|
||||
for (const k of Object.keys(s.행 ?? {})) setInfo("행", k);
|
||||
for (const k of Object.keys(s.열 ?? {})) setInfo("열", k);
|
||||
} else if (fullRows || fullCols) {
|
||||
whole = true;
|
||||
for (let i = fullRows ? rg.r0 : rg.c0; i <= (fullRows ? rg.r1 : rg.c1); i++)
|
||||
setInfo(fullRows ? "행" : "열", fullRows ? String(i + 1) : colName(i));
|
||||
} else {
|
||||
if ((rg.r1 - rg.r0 + 1) * (rg.c1 - rg.c0 + 1) > 1_000_000)
|
||||
throw new CommandError("범위가 너무 큼");
|
||||
for (let r = rg.r0; r <= rg.r1; r++) for (let c = rg.c0; c <= rg.c1; c++) touch(r, c);
|
||||
continue;
|
||||
}
|
||||
for (const [, r, c] of cellsIn(s, [rg])) touch(r, c);
|
||||
}
|
||||
if (s.행 && !Object.keys(s.행).length) delete s.행;
|
||||
if (s.열 && !Object.keys(s.열).length) delete s.열;
|
||||
return whole
|
||||
? { undo: { 종류: "시트통째", 시트: before }, cells, rebuild: true }
|
||||
: { undo: { 종류: "칸", 시트: s.id, 칸: prev }, cells, rebuild: false };
|
||||
}
|
||||
|
||||
function patchStyle(book: Workbook, cmd: Of<"서식">): CommandEffect {
|
||||
return restyle(book, cmd.시트, cmd.범위, (i) => {
|
||||
const next: Record<string, unknown> = { ...book.서식[i] };
|
||||
for (const [k, v] of Object.entries(cmd.바꿀)) {
|
||||
if (v === null) delete next[k];
|
||||
else if (v !== undefined) next[k] = clone(v);
|
||||
}
|
||||
return internStyle(book, next as CellStyle);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 행열 넣기 · 지우기 ──────────────────────────────────────────────────────
|
||||
|
||||
function insertDelete(
|
||||
book: Workbook,
|
||||
cmd: Of<"행넣기" | "행지우기" | "열넣기" | "열지우기">,
|
||||
): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const rows = cmd.종류.startsWith("행");
|
||||
const insert = cmd.종류.endsWith("넣기");
|
||||
const max = rows ? MAX_ROWS : MAX_COLS;
|
||||
const { at, 수: n } = cmd;
|
||||
if (n < 1 || at < 0 || at >= max || (!insert && at + n > max))
|
||||
throw new CommandError("넣기 · 지우기 자리가 틀림");
|
||||
const pos = (a1: string) => {
|
||||
const p = parseA1(a1)!;
|
||||
return rows ? p.r : p.c;
|
||||
};
|
||||
if (insert && Object.keys(s.칸).some((a1) => pos(a1) + n >= max))
|
||||
throw new CommandError("격자 끝 칸이 밀려 나감 — 끝 쪽 칸을 먼저 지움");
|
||||
const old = book.시트.map(clone);
|
||||
rewriteAll(book, cmd);
|
||||
const span = (i: number) => shiftSpan(i, i, insert, at, n, max)?.[0];
|
||||
|
||||
const cells: Record<string, Cell> = {};
|
||||
for (const [a1, cell] of Object.entries(s.칸)) {
|
||||
const p = parseA1(a1)!;
|
||||
const i = span(rows ? p.r : p.c);
|
||||
if (i !== undefined) cells[rows ? toA1(i, p.c) : toA1(p.r, i)] = cell;
|
||||
}
|
||||
s.칸 = cells;
|
||||
if (s.comments) {
|
||||
const notes: Record<string, string> = {};
|
||||
for (const [a1, text] of Object.entries(s.comments)) {
|
||||
const p = parseA1(a1);
|
||||
const i = p && span(rows ? p.r : p.c);
|
||||
if (p && i !== undefined && i !== null) notes[rows ? toA1(i, p.c) : toA1(p.r, i)] = text;
|
||||
}
|
||||
setComments(s, notes);
|
||||
}
|
||||
|
||||
const merges: CellRange[] = [];
|
||||
for (const m of mergesOf(s)) {
|
||||
const sp = rows
|
||||
? shiftSpan(m.r0, m.r1, insert, at, n, max)
|
||||
: shiftSpan(m.c0, m.c1, insert, at, n, max);
|
||||
if (!sp) continue;
|
||||
const next = rows ? { ...m, r0: sp[0], r1: sp[1] } : { ...m, c0: sp[0], c1: sp[1] };
|
||||
if (next.r0 !== next.r1 || next.c0 !== next.c1) merges.push(next);
|
||||
}
|
||||
setMerges(s, merges);
|
||||
|
||||
const axis = rows ? "행" : "열";
|
||||
const table = s[axis];
|
||||
if (table) {
|
||||
const next: Record<string, (typeof table)[string]> = {};
|
||||
for (const [k, v] of Object.entries(table)) {
|
||||
const i = span(rows ? Number(k) - 1 : parseA1(`${k}1`)!.c);
|
||||
if (i !== undefined) next[rows ? String(i + 1) : colName(i)] = v;
|
||||
}
|
||||
if (Object.keys(next).length) s[axis] = next;
|
||||
else delete s[axis];
|
||||
}
|
||||
|
||||
// 넣은 줄은 위(왼) 줄 서식 · 폭 · 높이를 따라감(엑셀 「위와 같은 서식」) — 첫 줄 앞 넣기는 안 따라감
|
||||
if (insert && at > 0) {
|
||||
for (const [a1, cell] of Object.entries(s.칸)) {
|
||||
const p = parseA1(a1)!;
|
||||
if ((rows ? p.r : p.c) !== at - 1 || cell.서식 === undefined) continue;
|
||||
for (let k = 0; k < n; k++)
|
||||
s.칸[rows ? toA1(at + k, p.c) : toA1(p.r, at + k)] = { 서식: cell.서식 };
|
||||
}
|
||||
const info = s[axis]?.[rows ? String(at) : colName(at - 1)];
|
||||
if (info) {
|
||||
const { 숨김: _hidden, ...look } = info;
|
||||
if (Object.keys(look).length)
|
||||
for (let k = 0; k < n; k++)
|
||||
s[axis]![rows ? String(at + k + 1) : colName(at + k)] = { ...look };
|
||||
}
|
||||
}
|
||||
|
||||
const key = rows ? "행" : "열";
|
||||
const frozen = s.틀고정?.[key];
|
||||
if (frozen && at < frozen)
|
||||
s.틀고정![key] = insert ? frozen + n : frozen - (Math.min(frozen, at + n) - at);
|
||||
|
||||
return { undo: { 종류: "묶음", 명령: restoreChanged(book, old) }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
// ── 옮기기 (잘라 붙이기) ─────────────────────────────────────────────────────
|
||||
|
||||
function move(book: Workbook, cmd: Of<"옮기기">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const t = sheetOf(book, cmd.대상시트);
|
||||
const src = normRange(cmd.범위.r0, cmd.범위.c0, cmd.범위.r1, cmd.범위.c1);
|
||||
const dr = cmd.r - src.r0;
|
||||
const dc = cmd.c - src.c0;
|
||||
const dst: CellRange = { r0: cmd.r, c0: cmd.c, r1: src.r1 + dr, c1: src.c1 + dc };
|
||||
if (dst.r0 < 0 || dst.c0 < 0 || dst.r1 >= MAX_ROWS || dst.c1 >= MAX_COLS)
|
||||
throw new CommandError("옮길 자리가 격자 밖");
|
||||
const cuts = (list: CellRange[], rg: CellRange) =>
|
||||
list.some((m) => rangesOverlap(m, rg) && !rangeInside(m, rg));
|
||||
if (cuts(mergesOf(s), src) || cuts(mergesOf(t), dst))
|
||||
throw new CommandError("병합 일부를 가르는 옮기기는 못 함");
|
||||
if (s === t && dr === 0 && dc === 0)
|
||||
return { undo: { 종류: "묶음", 명령: [] }, cells: [], rebuild: false };
|
||||
|
||||
const old = book.시트.map(clone);
|
||||
const moved: [number, number, Cell][] = [];
|
||||
for (const [a1, r, c] of cellsIn(s, [src])) {
|
||||
const cell = s.칸[a1];
|
||||
if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, cmd, book, t.id);
|
||||
moved.push([r + dr, c + dc, cell]);
|
||||
delete s.칸[a1];
|
||||
}
|
||||
rewriteAll(book, cmd);
|
||||
for (const [a1] of cellsIn(t, [dst])) delete t.칸[a1];
|
||||
for (const [r, c, cell] of moved) t.칸[toA1(r, c)] = cell;
|
||||
// 메모도 칸과 같이 — 덮은 자리 메모는 지움
|
||||
const carriedNotes: [string, string][] = [];
|
||||
const srcNotes = { ...s.comments };
|
||||
for (const [a1, text] of Object.entries(srcNotes)) {
|
||||
const p = parseA1(a1);
|
||||
if (p && inRange(src, p.r, p.c)) {
|
||||
carriedNotes.push([toA1(p.r + dr, p.c + dc), text]);
|
||||
delete srcNotes[a1];
|
||||
}
|
||||
}
|
||||
setComments(s, srcNotes);
|
||||
const dstNotes = { ...t.comments };
|
||||
for (const a1 of Object.keys(dstNotes)) {
|
||||
const p = parseA1(a1);
|
||||
if (p && inRange(dst, p.r, p.c)) delete dstNotes[a1];
|
||||
}
|
||||
setComments(t, { ...dstNotes, ...Object.fromEntries(carriedNotes) });
|
||||
|
||||
const sm = mergesOf(s);
|
||||
const carried = sm.filter((m) => rangeInside(m, src));
|
||||
setMerges(
|
||||
s,
|
||||
sm.filter((m) => !rangeInside(m, src)),
|
||||
);
|
||||
setMerges(t, [
|
||||
...mergesOf(t).filter((m) => !rangeInside(m, dst)),
|
||||
...carried.map((m) => ({ r0: m.r0 + dr, c0: m.c0 + dc, r1: m.r1 + dr, c1: m.c1 + dc })),
|
||||
]);
|
||||
return { undo: { 종류: "묶음", 명령: restoreChanged(book, old) }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
// ── 병합 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function merge(book: Workbook, cmd: Of<"병합">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const rg = normRange(cmd.범위.r0, cmd.범위.c0, cmd.범위.r1, cmd.범위.c1);
|
||||
if (rg.r0 === rg.r1 && rg.c0 === rg.c1)
|
||||
return { undo: { 종류: "묶음", 명령: [] }, cells: [], rebuild: false };
|
||||
const list = mergesOf(s);
|
||||
if (list.some((m) => rangesOverlap(m, rg) && !rangeInside(m, rg)))
|
||||
throw new CommandError("다른 병합과 걸침");
|
||||
const absorbed = list.filter((m) => rangeInside(m, rg));
|
||||
const prev: Record<string, Cell | null> = {};
|
||||
const cells: SheetCellAddress[] = [];
|
||||
for (const [a1, r, c] of cellsIn(s, [rg])) {
|
||||
const cell = s.칸[a1];
|
||||
if ((r === rg.r0 && c === rg.c0) || (cell.값 === undefined && cell.식 === undefined)) continue;
|
||||
prev[a1] = clone(cell);
|
||||
if (cell.서식 === undefined) delete s.칸[a1];
|
||||
else s.칸[a1] = { 서식: cell.서식 };
|
||||
cells.push(addr(s.id, r, c));
|
||||
}
|
||||
setMerges(s, [...list.filter((m) => !rangeInside(m, rg)), rg]);
|
||||
const undo: Command[] = [
|
||||
{ 종류: "병합풀기", 시트: s.id, 범위: rg },
|
||||
...absorbed.map((m): Command => ({ 종류: "병합", 시트: s.id, 범위: m })),
|
||||
{ 종류: "칸", 시트: s.id, 칸: prev },
|
||||
];
|
||||
return { undo: { 종류: "묶음", 명령: undo }, cells, rebuild: false };
|
||||
}
|
||||
|
||||
function unmerge(book: Workbook, cmd: Of<"병합풀기">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const list = mergesOf(s);
|
||||
const gone = list.filter((m) => rangesOverlap(m, cmd.범위));
|
||||
setMerges(
|
||||
s,
|
||||
list.filter((m) => !rangesOverlap(m, cmd.범위)),
|
||||
);
|
||||
const undo = gone.map((m): Command => ({ 종류: "병합", 시트: s.id, 범위: m }));
|
||||
return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: false };
|
||||
}
|
||||
|
||||
// ── 행 · 열 모양 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** 행 · 열 정보 한 속성 바꿈 → 원래 값끼리 묶은 되돌림 짝 */
|
||||
function setAxis<V>(
|
||||
s: Sheet,
|
||||
axis: "행" | "열",
|
||||
idx: number[],
|
||||
prop: "폭" | "높이" | "숨김",
|
||||
value: V | null,
|
||||
undoOf: (idx: number[], prev: V | null) => Command,
|
||||
): Command {
|
||||
const table = ((s as unknown as Record<string, Record<string, Record<string, unknown>>>)[axis] ??=
|
||||
{});
|
||||
const groups = new Map<string, number[]>();
|
||||
for (const i of idx) {
|
||||
const k = axis === "열" ? colName(i) : String(i + 1);
|
||||
const prev = table[k]?.[prop];
|
||||
const g = JSON.stringify(prev ?? null);
|
||||
groups.set(g, [...(groups.get(g) ?? []), i]);
|
||||
const info = (table[k] ??= {});
|
||||
if (value === null || value === false) delete info[prop];
|
||||
else info[prop] = value;
|
||||
if (!Object.keys(info).length) delete table[k];
|
||||
}
|
||||
if (!Object.keys(table).length) delete s[axis];
|
||||
return { 종류: "묶음", 명령: [...groups].map(([g, list]) => undoOf(list, JSON.parse(g))) };
|
||||
}
|
||||
|
||||
/** 모두 고르기 폭 · 높이 — 시트 `기본` 에 두고 줄마다 값은 지움(엑셀 기본 폭 · 높이) */
|
||||
function sheetSize(s: Sheet, axis: "행" | "열", value: number | null): CommandEffect {
|
||||
const before = clone(s);
|
||||
const prop = axis === "열" ? "폭" : "높이";
|
||||
setSheetDefault(s, axis === "열" ? "열폭" : "행높이", value ?? undefined);
|
||||
const table = s[axis] as Record<string, Record<string, unknown>> | undefined;
|
||||
for (const [k, info] of Object.entries(table ?? {})) {
|
||||
delete info[prop];
|
||||
if (!Object.keys(info).length) delete table![k];
|
||||
}
|
||||
if (table && !Object.keys(table).length) delete s[axis];
|
||||
return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false };
|
||||
}
|
||||
|
||||
// ── 시트 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function addSheet(book: Workbook, cmd: Of<"시트더하기">): CommandEffect {
|
||||
if (book.시트.some((s) => s.id === cmd.시트.id))
|
||||
throw new CommandError(`같은 id 시트가 있음: ${cmd.시트.id}`);
|
||||
checkSheetName(book, cmd.시트.이름);
|
||||
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, clone(cmd.시트));
|
||||
return { undo: { 종류: "시트지우기", 시트: cmd.시트.id }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
function removeSheet(book: Workbook, cmd: Of<"시트지우기">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
if (book.시트.length === 1) throw new CommandError("마지막 시트는 못 지움");
|
||||
const at = book.시트.indexOf(s);
|
||||
const old = book.시트.map(clone);
|
||||
rewriteAll(book, cmd);
|
||||
book.시트.splice(at, 1);
|
||||
if (book.활성 === s.id) book.활성 = book.시트[Math.min(at, book.시트.length - 1)].id;
|
||||
const undo: Command[] = [
|
||||
{ 종류: "시트더하기", 시트: old[at], 자리: at },
|
||||
...restoreChanged(book, old),
|
||||
];
|
||||
return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
function renameSheet(book: Workbook, cmd: Of<"시트이름">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
checkSheetName(book, cmd.이름, s.id);
|
||||
const prev = s.이름;
|
||||
rewriteAll(book, cmd);
|
||||
s.이름 = cmd.이름;
|
||||
return { undo: { 종류: "시트이름", 시트: s.id, 이름: prev }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
function moveSheet(book: Workbook, cmd: Of<"시트옮기기">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const from = book.시트.indexOf(s);
|
||||
book.시트.splice(from, 1);
|
||||
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, s);
|
||||
return { undo: { 종류: "시트옮기기", 시트: s.id, 자리: from }, cells: [], rebuild: false };
|
||||
}
|
||||
|
||||
function replaceSheet(book: Workbook, cmd: Of<"시트통째">): CommandEffect {
|
||||
const at = book.시트.findIndex((x) => x.id === cmd.시트.id);
|
||||
if (at < 0) throw new CommandError(`없는 시트: ${cmd.시트.id}`);
|
||||
const prev = book.시트[at];
|
||||
book.시트[at] = clone(cmd.시트);
|
||||
const undo: Of<"시트통째"> = { 종류: "시트통째", 시트: prev };
|
||||
if (cmd.서식) {
|
||||
undo.서식 = book.서식;
|
||||
book.서식 = clone(cmd.서식);
|
||||
}
|
||||
return { undo, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
function batch(book: Workbook, cmd: Of<"묶음">): CommandEffect {
|
||||
const done: CommandEffect[] = [];
|
||||
try {
|
||||
for (const c of cmd.명령) done.push(applyCommand(book, c));
|
||||
} catch (e) {
|
||||
for (const d of done.reverse()) applyCommand(book, d.undo);
|
||||
throw e;
|
||||
}
|
||||
return {
|
||||
undo: { 종류: "묶음", 명령: done.map((d) => d.undo).reverse() },
|
||||
cells: done.flatMap((d) => d.cells),
|
||||
rebuild: done.some((d) => d.rebuild),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 들머리 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function applyCommand(book: Workbook, command: Command): CommandEffect {
|
||||
const plain = (undo: Command): CommandEffect => ({ undo, cells: [], rebuild: false });
|
||||
switch (command.종류) {
|
||||
case "칸":
|
||||
return setCells(book, command);
|
||||
case "내용지움":
|
||||
return clearContents(book, command);
|
||||
case "서식":
|
||||
return patchStyle(book, command);
|
||||
case "서식지움":
|
||||
return restyle(book, command.시트, command.범위, () => 0);
|
||||
case "행넣기":
|
||||
case "행지우기":
|
||||
case "열넣기":
|
||||
case "열지우기":
|
||||
return insertDelete(book, command);
|
||||
case "옮기기":
|
||||
return move(book, command);
|
||||
case "병합":
|
||||
return merge(book, command);
|
||||
case "병합풀기":
|
||||
return unmerge(book, command);
|
||||
case "열폭": {
|
||||
const s = sheetOf(book, command.시트);
|
||||
if (new Set(command.열).size >= MAX_COLS) return sheetSize(s, "열", command.폭);
|
||||
return plain(
|
||||
setAxis(s, "열", command.열, "폭", command.폭, (열, 폭) => ({
|
||||
종류: "열폭",
|
||||
시트: s.id,
|
||||
열,
|
||||
폭,
|
||||
})),
|
||||
);
|
||||
}
|
||||
case "행높이": {
|
||||
const s = sheetOf(book, command.시트);
|
||||
if (new Set(command.행).size >= MAX_ROWS) return sheetSize(s, "행", command.높이);
|
||||
return plain(
|
||||
setAxis(s, "행", command.행, "높이", command.높이, (행, 높이) => ({
|
||||
종류: "행높이",
|
||||
시트: s.id,
|
||||
행,
|
||||
높이,
|
||||
})),
|
||||
);
|
||||
}
|
||||
case "숨김": {
|
||||
const s = sheetOf(book, command.시트);
|
||||
const { 축 } = command;
|
||||
return plain(
|
||||
setAxis(s, 축, command.번호, "숨김", command.숨김, (번호, prev) => ({
|
||||
종류: "숨김",
|
||||
시트: s.id,
|
||||
축,
|
||||
번호,
|
||||
숨김: prev ?? false,
|
||||
})),
|
||||
);
|
||||
}
|
||||
case "틀고정": {
|
||||
const s = sheetOf(book, command.시트);
|
||||
const prev = { 행: s.틀고정?.행 ?? 0, 열: s.틀고정?.열 ?? 0 };
|
||||
const next: Sheet["틀고정"] = {};
|
||||
if (command.행 > 0) next.행 = command.행;
|
||||
if (command.열 > 0) next.열 = command.열;
|
||||
if (Object.keys(next).length) s.틀고정 = next;
|
||||
else delete s.틀고정;
|
||||
return plain({ 종류: "틀고정", 시트: s.id, ...prev });
|
||||
}
|
||||
case "눈금선": {
|
||||
const s = sheetOf(book, command.시트);
|
||||
const prev = s.보기?.눈금선 ?? true;
|
||||
if (command.보임) {
|
||||
delete s.보기?.눈금선;
|
||||
if (s.보기 && !Object.keys(s.보기).length) delete s.보기;
|
||||
} else (s.보기 ??= {}).눈금선 = false;
|
||||
return plain({ 종류: "눈금선", 시트: s.id, 보임: prev });
|
||||
}
|
||||
case "시트더하기":
|
||||
return addSheet(book, command);
|
||||
case "시트지우기":
|
||||
return removeSheet(book, command);
|
||||
case "시트이름":
|
||||
return renameSheet(book, command);
|
||||
case "시트옮기기":
|
||||
return moveSheet(book, command);
|
||||
case "시트통째":
|
||||
return replaceSheet(book, command);
|
||||
case "묶음":
|
||||
return batch(book, command);
|
||||
}
|
||||
}
|
||||
|
||||
/** 빈 통합문서 — 시트 하나(`s1` · 이름은 부른 쪽) · 서식 [{}] */
|
||||
export function emptyWorkbook(_column: string, _sheetName: string): Workbook {
|
||||
return todo();
|
||||
export function emptyWorkbook(column: string, sheetName: string): Workbook {
|
||||
return {
|
||||
종류: "통합문서",
|
||||
판: 1,
|
||||
열: column,
|
||||
서식: [{}],
|
||||
시트: [emptySheet("s1", sheetName)],
|
||||
활성: "s1",
|
||||
};
|
||||
}
|
||||
|
||||
export function emptySheet(_id: string, _name: string): Sheet {
|
||||
return todo();
|
||||
export function emptySheet(id: string, name: string): Sheet {
|
||||
return { id, 이름: name, 칸: {} };
|
||||
}
|
||||
|
||||
/** 안 쓰는 서식을 빼고 번호를 다시 매김 — 저장 직전(되돌리기 짝은 안 만듦) */
|
||||
export function compactStyles(_book: Workbook): void {
|
||||
return todo();
|
||||
export function compactStyles(book: Workbook): void {
|
||||
const next: CellStyle[] = [];
|
||||
const byKey = new Map<string, number>();
|
||||
const remap = new Map<number, number>();
|
||||
const map = (i: number) => {
|
||||
let j = remap.get(i);
|
||||
if (j === undefined) {
|
||||
const style = book.서식[i] ?? {};
|
||||
const k = styleKey(style);
|
||||
j = byKey.get(k);
|
||||
if (j === undefined) {
|
||||
j = next.push(style) - 1;
|
||||
byKey.set(k, j);
|
||||
}
|
||||
remap.set(i, j);
|
||||
}
|
||||
return j;
|
||||
};
|
||||
map(0);
|
||||
for (const s of book.시트) {
|
||||
for (const cell of Object.values(s.칸)) if (cell.서식 !== undefined) cell.서식 = map(cell.서식);
|
||||
for (const info of [...Object.values(s.행 ?? {}), ...Object.values(s.열 ?? {}), s.기본 ?? {}])
|
||||
if (info.서식 !== undefined) info.서식 = map(info.서식);
|
||||
}
|
||||
book.서식 = next;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_comments.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 메모(풍선 설명) · 칸 위쪽 빨간 세모 표시. 저장 자리 — 브레인 답(2026-09-27):
|
||||
* `Sheet.comments?: Record<string, string>` (열쇠 = A1 주소 · 값 = 메모 글 · 빈 글이면 지움).
|
||||
* 이 칸은 sub3 가 0 계약(spreadsheet_types.ts)에 보태는 중 — 여기서는 고치지 않고 모양만 가정
|
||||
* (`SheetWithComments` 로 좁혀 씀 · sub3 커밋이 오면 구조가 그대로 맞음).
|
||||
*
|
||||
* 문서를 바꾸는 길은 명령뿐(view_types.ts 머리) — 「메모」 전용 명령이 아직 없어 `시트통째`
|
||||
* (있는 시트를 통째로 바꿔 넣는 명령)로 얹음. 메모 전용 명령이 생기면 이 파일의 `applyComments`
|
||||
* 한 곳만 고치면 됨.
|
||||
*
|
||||
* 행 · 열 넣기 · 지우기 때 메모 주소 옮김 — 지금은 안 함(사각지대로 남김). A 가
|
||||
* `spreadsheet_refshift.ts` 를 완성하면 그 파일의 참조 옮김 함수(예: `shiftAddress` 류)를
|
||||
* 이 파일 `remapComments` 자리에 불러 A1 주소들을 옮기게 잇기(지우는 행 · 열에 있던 메모는
|
||||
* 참조 지움과 같은 규칙으로 버림). 지금은 `remapComments` 를 D · A 가 채울 자리로 비워 둠.
|
||||
*
|
||||
* 잇는 법(D · sub7 · C 격자) — `attachComments(ctx)` 를 한 번 붙이고:
|
||||
* 1) C 가 칸을 그릴 때 `hasComment(sheet, r, c)` 가 참이면 칸 위 오른쪽에 빨간 세모(4~6px)를 그림.
|
||||
* 2) 그 칸에 마우스가 올라가면(hover) `showBalloon(r, c)` · 벗어나면 `hideBalloon()`.
|
||||
* 3) 우클릭 메뉴 [메모 삽입 · 메모 편집 · 메모 삭제] 는 `setComment` · `deleteComment` 를 부름.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Sheet, Workbook } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
|
||||
/** 0 계약이 아직 안 실은 칸 — sub3 커밋 뒤엔 진짜 `Sheet` 가 이 모양을 그대로 가짐. */
|
||||
export interface SheetWithComments extends Sheet {
|
||||
comments?: Record<string, string>;
|
||||
}
|
||||
|
||||
const a1 = (r: number, c: number): string => {
|
||||
let col = c + 1;
|
||||
let letters = "";
|
||||
while (col > 0) {
|
||||
const rem = (col - 1) % 26;
|
||||
letters = String.fromCharCode(65 + rem) + letters;
|
||||
col = Math.floor((col - 1) / 26);
|
||||
}
|
||||
return `${letters}${r + 1}`;
|
||||
};
|
||||
|
||||
/** 자리 이동(행열 넣기 · 지우기) 뒤 메모 주소를 옮김 — A `spreadsheet_refshift` 완성 뒤 채울 자리.
|
||||
* `shift` 는 옛 주소 → 새 주소(지워진 자리면 null). 지금은 호출부가 없어 안 씀(사각지대 기록용). */
|
||||
export function remapComments(
|
||||
comments: Record<string, string>,
|
||||
shift: (old: { r: number; c: number }) => { r: number; c: number } | null,
|
||||
): Record<string, string> {
|
||||
const next: Record<string, string> = {};
|
||||
for (const [key, text] of Object.entries(comments)) {
|
||||
const at = parseA1(key);
|
||||
const moved = shift(at);
|
||||
if (moved) next[a1(moved.r, moved.c)] = text;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function parseA1(key: string): { r: number; c: number } {
|
||||
const m = /^([A-Z]+)(\d+)$/.exec(key);
|
||||
if (!m) return { r: 0, c: 0 };
|
||||
let col = 0;
|
||||
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
return { r: Number(m[2]) - 1, c: col - 1 };
|
||||
}
|
||||
|
||||
export function getComment(sheet: Sheet, r: number, c: number): string | null {
|
||||
const comments = (sheet as SheetWithComments).comments;
|
||||
return comments?.[a1(r, c)] ?? null;
|
||||
}
|
||||
|
||||
export function hasComment(sheet: Sheet, r: number, c: number): boolean {
|
||||
return getComment(sheet, r, c) !== null;
|
||||
}
|
||||
|
||||
export function listComments(sheet: Sheet): { r: number; c: number; 글: string }[] {
|
||||
const comments = (sheet as SheetWithComments).comments ?? {};
|
||||
return Object.entries(comments).map(([key, 글]) => ({ ...parseA1(key), 글 }));
|
||||
}
|
||||
|
||||
/** `시트통째` 명령으로 메모 칸만 바꿔 넣음 — 그 밖 시트 내용은 그대로. */
|
||||
function applyComments(ctx: SpreadsheetContext, next: Record<string, string>): void {
|
||||
const sheet = ctx.sheet() as SheetWithComments;
|
||||
const cleaned: Record<string, string> = {};
|
||||
for (const [key, text] of Object.entries(next)) if (text) cleaned[key] = text;
|
||||
const nextSheet: SheetWithComments = { ...sheet, comments: cleaned };
|
||||
ctx.dispatch({ 종류: "시트통째", 시트: nextSheet });
|
||||
}
|
||||
|
||||
/** 글이 비면 메모를 지움(값과 같은 규칙 — `spreadsheet_types.ts` 「빈 칸 안 적음」). */
|
||||
export function setComment(ctx: SpreadsheetContext, r: number, c: number, text: string): void {
|
||||
const sheet = ctx.sheet() as SheetWithComments;
|
||||
const next = { ...(sheet.comments ?? {}) };
|
||||
if (text) next[a1(r, c)] = text;
|
||||
else delete next[a1(r, c)];
|
||||
applyComments(ctx, next);
|
||||
}
|
||||
|
||||
export function deleteComment(ctx: SpreadsheetContext, r: number, c: number): void {
|
||||
setComment(ctx, r, c, "");
|
||||
}
|
||||
|
||||
export interface CommentsHandle extends PartHandle {
|
||||
showBalloon(r: number, c: number): void;
|
||||
hideBalloon(): void;
|
||||
}
|
||||
|
||||
/** 풍선 뿌리 하나를 만들어 `ctx.root` 에 붙임 — 세모 그리기 자체는 C(격자)가 `hasComment` 로 판단해서 함. */
|
||||
export function attachComments(ctx: SpreadsheetContext): CommentsHandle {
|
||||
const balloon = el("div", { className: "spreadsheet-comment-balloon", attrs: { hidden: "" } });
|
||||
ctx.root.append(balloon);
|
||||
|
||||
return {
|
||||
root: balloon,
|
||||
showBalloon(r: number, c: number): void {
|
||||
const text = getComment(ctx.sheet(), r, c);
|
||||
if (!text) return void this.hideBalloon();
|
||||
balloon.textContent = text;
|
||||
balloon.removeAttribute("hidden");
|
||||
const box = ctx.grid.cellBox(r, c);
|
||||
balloon.style.left = `${box.x + box.w}px`;
|
||||
balloon.style.top = `${box.y}px`;
|
||||
},
|
||||
hideBalloon(): void {
|
||||
balloon.setAttribute("hidden", "");
|
||||
},
|
||||
refresh(): void {
|
||||
/* 문서가 바뀌면 지금 보이는 풍선 글도 새로 — 열린 풍선이 없으면 할 일 없음 */
|
||||
},
|
||||
destroy(): void {
|
||||
balloon.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 시트 전체 메모 개수(시험 · 실무 대조용) */
|
||||
export function countComments(book: Workbook, sheetId: string): number {
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
return sheet ? Object.keys((sheet as SheetWithComments).comments ?? {}).length : 0;
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_editor.ts (주인 D)
|
||||
* 칸 편집기 · 수식 입력줄 · 주소 상자 · 한글 조합 · 참조 가리키기(식 편집 중 칸 · 범위를 눌러 넣기) ·
|
||||
* 참조 칸 색 테두리.
|
||||
* 한글: 숨은 textarea(`ta`) 가 늘 활성 칸 자리에서 초점을 쥠 → compositionstart · input 에서 편집을
|
||||
* 시작하고 같은 textarea 가 그대로 칸 편집기가 됨(첫 글자 조합이 안 끊김 · keydown 글자 방식 금지).
|
||||
* 방식(엑셀): enter = 바로 쳐서 시작(방향키가 확정 · 식이면 가리키기) · edit = F2 · 두 번 누르기 ·
|
||||
* 수식 입력줄(방향키가 글자 사이를 옮김). F2 가 둘을 바꿈.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { moveFormula } from "./spreadsheet_refshift";
|
||||
import { colName, parseA1, parseRange, rangeToA1, toA1 } from "./spreadsheet_address";
|
||||
import { tokenize } from "./spreadsheet_parser";
|
||||
import { cellsOf, move, selectCell } from "./spreadsheet_selection";
|
||||
import type { Cell, CellAddress, CellStyle, Sheet } from "./spreadsheet_types";
|
||||
import type {
|
||||
CellBox,
|
||||
EditorHandle,
|
||||
RefHighlight,
|
||||
Selection,
|
||||
SpreadsheetContext,
|
||||
} from "./spreadsheet_view_types";
|
||||
|
||||
export interface Editor extends EditorHandle {
|
||||
/** 숨은 textarea = 칸 편집기(늘 초점) */
|
||||
ta: HTMLTextAreaElement;
|
||||
/** 주소 상자 + 수식 입력줄 한 줄 */
|
||||
bar: HTMLElement;
|
||||
formula: HTMLTextAreaElement;
|
||||
mode(): "enter" | "edit";
|
||||
toggleMode(): void;
|
||||
/** 지금 글자 자리에 참조를 넣을 수 있나(식 · 연산자 뒤) */
|
||||
pointable(): boolean;
|
||||
/** 가리킨 고름을 식 글에 넣음(같은 가리키기면 바꿔 적음) */
|
||||
point(sel: Selection): void;
|
||||
/** 방향키 가리키기 — 지금 가리킨 곳(없으면 편집 칸)에서 */
|
||||
pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void;
|
||||
/** 고름 칸 전부에 같은 입력(Ctrl+Enter) — 식은 활성 칸 기준 상대 이동 */
|
||||
commitAll(): void;
|
||||
/** 초점 되찾기(스크롤 없이) */
|
||||
focus(): void;
|
||||
/** 편집 중이면 칸 자리 다시(스크롤 · 폭 바뀜) */
|
||||
place(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
interface EditState {
|
||||
sheet: string;
|
||||
at: CellAddress;
|
||||
mode: "enter" | "edit";
|
||||
original: string;
|
||||
/** 가리키기 중 — 식 글 [start, end) 가 가리킨 참조 */
|
||||
point: { start: number; end: number; sel: Selection } | null;
|
||||
}
|
||||
|
||||
/** 수 입력 — 1,234 · -1.5e3 · 50% (엑셀 입력 규칙) · 아니면 null */
|
||||
export function parseNumber(text: string): number | null {
|
||||
let t = text.trim();
|
||||
const pct = t.endsWith("%");
|
||||
if (pct) t = t.slice(0, -1);
|
||||
if (!/\d/.test(t) || !/^[+-]?(\d{1,3}(,\d{3})+|\d*)(\.\d*)?([eE][+-]?\d+)?$/.test(t)) return null;
|
||||
const n = Number(t.replace(/,/g, ""));
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return pct ? n / 100 : n;
|
||||
}
|
||||
|
||||
const looksTyped = (s: string): boolean =>
|
||||
(s.length > 1 && s[0] === "=") ||
|
||||
s[0] === "'" ||
|
||||
parseNumber(s) !== null ||
|
||||
/^(true|false)$/i.test(s);
|
||||
|
||||
/** 친 글 → 칸(서식은 두고) · 지울 칸이면 null */
|
||||
export function textToCell(text: string, old?: Cell): Cell | null {
|
||||
const base: Cell = old?.서식 !== undefined ? { 서식: old.서식 } : {};
|
||||
if (text === "") return old?.서식 !== undefined ? base : null;
|
||||
if (text.length > 1 && text[0] === "=") return { ...base, 식: text.slice(1) };
|
||||
if (text[0] === "'") return { ...base, 값: text.slice(1) };
|
||||
const n = parseNumber(text);
|
||||
if (n !== null) return { ...base, 값: n };
|
||||
if (/^(true|false)$/i.test(text)) return { ...base, 값: text.toUpperCase() === "TRUE" };
|
||||
return { ...base, 값: text };
|
||||
}
|
||||
|
||||
/** 칸 → 편집 글(식은 `=` 붙임 · 수처럼 읽힐 글은 `'` 붙임) */
|
||||
export function cellToText(cell: Cell | undefined): string {
|
||||
if (!cell) return "";
|
||||
if (cell.식 !== undefined) return "=" + cell.식;
|
||||
const v = cell.값;
|
||||
if (v === undefined) return "";
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
if (typeof v === "number") return String(v);
|
||||
return looksTyped(v) ? "'" + v : v;
|
||||
}
|
||||
|
||||
/** 참조 넣을 자리 — 식이고 caret 앞(빈칸 건너)이 연산자 · 괄호 · 쉼표 · `=` */
|
||||
export function insertableAt(text: string, pos: number): boolean {
|
||||
if (text[0] !== "=") return false;
|
||||
if (pos < text.length && /[A-Za-z0-9$_.!']/.test(text[pos])) return false;
|
||||
let i = pos - 1;
|
||||
while (i >= 0 && text[i] === " ") i--;
|
||||
return i >= 0 && "=(,+-*/^&<>:;".includes(text[i]);
|
||||
}
|
||||
|
||||
/** 식 글 참조 → 색 테두리 목록(같은 글이면 같은 색) */
|
||||
export function refHighlights(
|
||||
text: string,
|
||||
host: string,
|
||||
sheetId: (name: string) => string | null,
|
||||
): RefHighlight[] {
|
||||
if (text[0] !== "=") return [];
|
||||
const out: RefHighlight[] = [];
|
||||
const colors = new Map<string, number>();
|
||||
for (const t of tokenize(text.slice(1))) {
|
||||
if (t.kind !== "ref") continue;
|
||||
const bang = t.text.lastIndexOf("!");
|
||||
let sheet: string | null = host;
|
||||
if (bang >= 0) {
|
||||
const raw = t.text.slice(0, bang);
|
||||
const name = raw[0] === "'" ? raw.slice(1, -1).replace(/''/g, "'") : raw;
|
||||
sheet = sheetId(name);
|
||||
}
|
||||
const range = parseRange(t.text.slice(bang + 1).replace(/\$/g, ""));
|
||||
if (!sheet || !range) continue;
|
||||
const key = t.text.toUpperCase();
|
||||
if (!colors.has(key)) colors.set(key, colors.size);
|
||||
out.push({ 시트: sheet, 범위: range, 색번호: colors.get(key)! });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 칸 서식(칸 → 행 → 열 → 기본) */
|
||||
function styleAt(ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number): CellStyle {
|
||||
const idx =
|
||||
sheet.칸[toA1(r, c)]?.서식 ??
|
||||
sheet.행?.[String(r + 1)]?.서식 ??
|
||||
sheet.열?.[colName(c)]?.서식 ??
|
||||
0;
|
||||
return ctx.book.서식[idx] ?? {};
|
||||
}
|
||||
|
||||
/** editorSlot 칸 자리 → 층 안 자리 */
|
||||
export function boxStyle(node: HTMLElement, box: CellBox): void {
|
||||
node.style.left = `${box.x}px`;
|
||||
node.style.top = `${box.y}px`;
|
||||
node.style.minWidth = `${box.w}px`;
|
||||
node.style.minHeight = `${box.h}px`;
|
||||
}
|
||||
|
||||
export function createEditor(ctx: SpreadsheetContext): Editor {
|
||||
const ta = el("textarea", {
|
||||
className: "ss-input ss-input--idle",
|
||||
attrs: { spellcheck: "false", autocomplete: "off", rows: "1", "aria-label": "칸 입력" },
|
||||
});
|
||||
const addr = el("input", {
|
||||
className: "ss-addr",
|
||||
attrs: { spellcheck: "false", "aria-label": "주소 상자" },
|
||||
});
|
||||
const formula = el("textarea", {
|
||||
className: "ss-formula",
|
||||
attrs: { spellcheck: "false", rows: "1", "aria-label": "수식 입력줄" },
|
||||
});
|
||||
const bar = el("div", {
|
||||
className: "ss-bar",
|
||||
children: [addr, el("span", { className: "ss-fx", text: "fx" }), formula],
|
||||
});
|
||||
formula.readOnly = ctx.readOnly;
|
||||
ta.readOnly = ctx.readOnly;
|
||||
|
||||
let st: EditState | null = null;
|
||||
|
||||
const src = (): HTMLTextAreaElement => (document.activeElement === formula ? formula : ta);
|
||||
const text = (): string => src().value;
|
||||
const sheetOf = (id: string): Sheet => ctx.book.시트.find((s) => s.id === id) ?? ctx.sheet();
|
||||
const sheetId = (name: string): string | null =>
|
||||
ctx.book.시트.find((s) => s.이름.toUpperCase() === name.toUpperCase())?.id ?? null;
|
||||
|
||||
function place(): void {
|
||||
const at = st?.at ?? ctx.selection.활성;
|
||||
const { layer, box } = ctx.grid.editorSlot(at.r, at.c);
|
||||
if (ta.parentElement !== layer) {
|
||||
const had = document.activeElement === ta;
|
||||
layer.append(ta);
|
||||
if (had) focus();
|
||||
}
|
||||
boxStyle(ta, box);
|
||||
if (!st) return;
|
||||
const s = styleAt(ctx, sheetOf(st.sheet), at.r, at.c);
|
||||
ta.style.fontFamily = s.글꼴 ?? "";
|
||||
ta.style.fontSize = s.크기 ? `${s.크기}pt` : "";
|
||||
ta.style.fontWeight = s.굵게 ? "bold" : "";
|
||||
ta.style.fontStyle = s.기울임 ? "italic" : "";
|
||||
ta.style.textAlign = s.가로 === "right" || s.가로 === "center" ? s.가로 : "";
|
||||
// 글이 길면 오른쪽 · 아래로 늘림(엑셀)
|
||||
ta.style.width = "0";
|
||||
ta.style.height = "0";
|
||||
ta.style.width = `${Math.max(box.w, ta.scrollWidth + 4)}px`;
|
||||
ta.style.height = `${Math.max(box.h, ta.scrollHeight)}px`;
|
||||
}
|
||||
|
||||
function focus(): void {
|
||||
if (document.activeElement !== ta) ta.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function highlight(): void {
|
||||
const t = st ? text() : "";
|
||||
ctx.grid.setRefHighlights(st ? refHighlights(t, st.sheet, sheetId) : []);
|
||||
}
|
||||
|
||||
/** 편집 시작 — `value` 없으면 textarea 에 이미 든 글(조합 · 바로 친 글자) 그대로 */
|
||||
function start(mode: "enter" | "edit", value?: string, from: HTMLTextAreaElement = ta): void {
|
||||
if (ctx.readOnly || st) return;
|
||||
const sel = ctx.selection;
|
||||
const sheet = ctx.sheet();
|
||||
const original = cellToText(sheet.칸[toA1(sel.활성.r, sel.활성.c)]);
|
||||
st = { sheet: sheet.id, at: { ...sel.활성 }, mode, original, point: null };
|
||||
if (value !== undefined) from.value = value;
|
||||
(from === ta ? formula : ta).value = from.value;
|
||||
ta.classList.remove("ss-input--idle");
|
||||
ctx.root.classList.add("ss--editing");
|
||||
place();
|
||||
highlight();
|
||||
if (from === ta && value !== undefined) ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||
}
|
||||
|
||||
function end(): void {
|
||||
st = null;
|
||||
ta.value = "";
|
||||
ta.classList.add("ss-input--idle");
|
||||
ta.removeAttribute("style");
|
||||
ctx.root.classList.remove("ss--editing");
|
||||
ctx.grid.setRefHighlights([]);
|
||||
sync();
|
||||
focus();
|
||||
}
|
||||
|
||||
function write(cells: CellAddress[], value: string, from: CellAddress): void {
|
||||
if (!st) return;
|
||||
const sheet = sheetOf(st.sheet);
|
||||
const out: Record<string, Cell | null> = {};
|
||||
for (const a of cells) {
|
||||
let v = value;
|
||||
if (v.length > 1 && v[0] === "=" && (a.r !== from.r || a.c !== from.c))
|
||||
v = "=" + moveFormula(v.slice(1), a.r - from.r, a.c - from.c);
|
||||
const key = toA1(a.r, a.c);
|
||||
const next = textToCell(v, sheet.칸[key]);
|
||||
if (next === null && !sheet.칸[key]) continue;
|
||||
out[key] = next;
|
||||
}
|
||||
if (Object.keys(out).length) ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: out });
|
||||
}
|
||||
|
||||
function commit(): void {
|
||||
if (!st) return;
|
||||
const value = text();
|
||||
if (value !== st.original) write([st.at], value, st.at);
|
||||
end();
|
||||
}
|
||||
|
||||
function commitAll(): void {
|
||||
if (!st) return;
|
||||
write(cellsOf(sheetOf(st.sheet), ctx.selection), text(), st.at);
|
||||
end();
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
if (!st) return;
|
||||
end();
|
||||
}
|
||||
|
||||
function sync(): void {
|
||||
const sel = ctx.selection;
|
||||
addr.value = toA1(sel.활성.r, sel.활성.c);
|
||||
if (st) return;
|
||||
formula.value = cellToText(ctx.sheet().칸[toA1(sel.활성.r, sel.활성.c)]);
|
||||
place();
|
||||
}
|
||||
|
||||
function pointable(): boolean {
|
||||
if (!st) return false;
|
||||
const node = src();
|
||||
if (node.selectionStart !== node.selectionEnd) return false;
|
||||
const pos = node.selectionEnd;
|
||||
if (st.point && st.point.end === pos) return true;
|
||||
st.point = null;
|
||||
return insertableAt(node.value, pos);
|
||||
}
|
||||
|
||||
function point(sel: Selection): void {
|
||||
if (!st) return;
|
||||
const node = src();
|
||||
const other = node === ta ? formula : ta;
|
||||
const pos = node.selectionEnd;
|
||||
const p = st.point ?? { start: pos, end: pos, sel };
|
||||
const g = sel.범위[0];
|
||||
const ref = rangeToA1(g);
|
||||
node.value = node.value.slice(0, p.start) + ref + node.value.slice(p.end);
|
||||
other.value = node.value;
|
||||
st.point = { start: p.start, end: p.start + ref.length, sel };
|
||||
node.setSelectionRange(st.point.end, st.point.end);
|
||||
place();
|
||||
highlight();
|
||||
ctx.grid.reveal(sel.활성.r === g.r0 ? g.r1 : g.r0, sel.활성.c === g.c0 ? g.c1 : g.c0);
|
||||
}
|
||||
|
||||
function pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void {
|
||||
if (!st) return;
|
||||
const sheet = sheetOf(st.sheet);
|
||||
const from = st.point?.sel ?? selectCell(sheet, st.at.r, st.at.c);
|
||||
point(move(sheet, from, dr, dc, { extend, jump }));
|
||||
}
|
||||
|
||||
// ── 한글 조합 · 바로 치기 → 편집 시작 ─────────────────────────────────────
|
||||
ta.addEventListener("compositionstart", () => {
|
||||
if (!st) start("enter");
|
||||
});
|
||||
ta.addEventListener("input", () => {
|
||||
if (!st) return start("enter");
|
||||
formula.value = ta.value;
|
||||
if (st.point && ta.selectionEnd !== st.point.end) st.point = null;
|
||||
place();
|
||||
highlight();
|
||||
});
|
||||
// 수식 입력줄 — 누르면 edit 방식으로 시작
|
||||
formula.addEventListener("focus", () => {
|
||||
if (!st && !ctx.readOnly) start("edit", formula.value, formula);
|
||||
});
|
||||
formula.addEventListener("input", () => {
|
||||
if (!st) return start("edit", undefined, formula);
|
||||
ta.value = formula.value;
|
||||
if (st.point && formula.selectionEnd !== st.point.end) st.point = null;
|
||||
place();
|
||||
highlight();
|
||||
});
|
||||
// 주소 상자 — `B5` · `A1:C3` · `시트!B5` 로 가기
|
||||
addr.addEventListener("focus", () => addr.select());
|
||||
addr.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
sync();
|
||||
focus();
|
||||
}
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
goTo(addr.value.trim());
|
||||
focus();
|
||||
});
|
||||
|
||||
function goTo(value: string): void {
|
||||
const bang = value.lastIndexOf("!");
|
||||
let sheet = ctx.sheet();
|
||||
if (bang >= 0) {
|
||||
const raw = value.slice(0, bang);
|
||||
const id = sheetId(raw[0] === "'" ? raw.slice(1, -1).replace(/''/g, "'") : raw);
|
||||
if (!id) return sync();
|
||||
if (id !== sheet.id) ctx.showSheet(id);
|
||||
sheet = ctx.sheet();
|
||||
}
|
||||
const body = value.slice(bang + 1).replace(/\$/g, "");
|
||||
const one = parseA1(body);
|
||||
const g = parseRange(body);
|
||||
if (!g) return sync();
|
||||
if (one) return ctx.select(selectCell(sheet, one.r, one.c), true);
|
||||
const at = { r: g.r0, c: g.c0 };
|
||||
ctx.select({ 시트: sheet.id, 범위: [g], 활성: at, 기준: { ...at } }, true);
|
||||
}
|
||||
|
||||
return {
|
||||
ta,
|
||||
bar,
|
||||
formula,
|
||||
editing: () => st !== null,
|
||||
begin(initial?: string) {
|
||||
if (initial === undefined) {
|
||||
const sel = ctx.selection;
|
||||
start("edit", cellToText(ctx.sheet().칸[toA1(sel.활성.r, sel.활성.c)]));
|
||||
} else start("enter", initial);
|
||||
focus();
|
||||
},
|
||||
commit,
|
||||
commitAll,
|
||||
cancel,
|
||||
sync,
|
||||
mode: () => st?.mode ?? "enter",
|
||||
toggleMode() {
|
||||
if (st) ((st.mode = st.mode === "enter" ? "edit" : "enter"), (st.point = null));
|
||||
},
|
||||
pointable,
|
||||
point,
|
||||
pointMove,
|
||||
focus,
|
||||
place() {
|
||||
if (st) place();
|
||||
},
|
||||
destroy() {
|
||||
ta.remove();
|
||||
bar.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,9 +3,24 @@
|
||||
* 나무 풀기 · 오류 값 전파 · 형 바꾸기(엑셀 규칙). B 의 함수들이 형 바꾸기 도우미를 씀.
|
||||
* 빈 칸 = 수에서 0 · 글에서 "" · `"12"+1 = 13` · `TRUE+1 = 2` · 글이 수로 안 읽히면 `#VALUE!`.
|
||||
* 무리수(SQRT · 삼각 · PI · 소수 거듭제곱)는 double → 유효 15 자리 십진 → 분수(B · A 같이 `fromDouble`).
|
||||
* 0 계약 머리 — 몸은 A 가 채움.
|
||||
* 오류 전파 = 왼쪽부터 처음 만난 오류. 비교 = 수 < 글 < 참거짓 · 글은 대소문자 가리지 않음.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
add,
|
||||
cmp,
|
||||
div,
|
||||
frac,
|
||||
fracToString,
|
||||
mul,
|
||||
parseDecimal,
|
||||
roundAt,
|
||||
sub,
|
||||
toFrac,
|
||||
ZERO,
|
||||
} from "@ui/sheet/ui_template_sheet_frac";
|
||||
import { normRange } from "./spreadsheet_address";
|
||||
import { getFunction } from "./spreadsheet_functions";
|
||||
import type {
|
||||
CalcValue,
|
||||
ErrorCode,
|
||||
@@ -14,45 +29,276 @@ import type {
|
||||
EvalResult,
|
||||
FormulaNode,
|
||||
Frac,
|
||||
RangeValue,
|
||||
Scalar,
|
||||
} from "./spreadsheet_types";
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet_eval: 아직 없음(A)");
|
||||
};
|
||||
|
||||
export function evaluate(_node: FormulaNode, _ctx: EvalContext): EvalResult {
|
||||
return todo();
|
||||
export function err(code: ErrorCode, why?: string): ErrorValue {
|
||||
return why === undefined ? { error: code } : { error: code, why };
|
||||
}
|
||||
|
||||
export function err(_code: ErrorCode, _why?: string): ErrorValue {
|
||||
return todo();
|
||||
export function isError(value: unknown): value is ErrorValue {
|
||||
return typeof value === "object" && value !== null && "error" in value;
|
||||
}
|
||||
|
||||
export function isError(_value: unknown): _value is ErrorValue {
|
||||
return todo();
|
||||
export function isFrac(value: unknown): value is Frac {
|
||||
return typeof value === "object" && value !== null && "n" in value && "d" in value;
|
||||
}
|
||||
|
||||
export function isRange(value: unknown): value is RangeValue {
|
||||
return typeof value === "object" && value !== null && (value as RangeValue).kind === "range";
|
||||
}
|
||||
|
||||
const ONE = frac(1n);
|
||||
const HUNDRED = frac(100n);
|
||||
const BIG_D = 10n ** 40n;
|
||||
|
||||
/** 분모가 너무 커지면(나눗셈 사슬) 30 자리 십진으로 굳힘 */
|
||||
export function tame(x: Frac): Frac {
|
||||
return x.d > BIG_D ? parseDecimal(fracToString(x)) : x;
|
||||
}
|
||||
|
||||
/** 범위 → 칸 하나(1×1 일 때만) · 아니면 `#VALUE!` */
|
||||
function single(value: EvalResult): Scalar {
|
||||
if (!isRange(value)) return value;
|
||||
return value.rows === 1 && value.cols === 1
|
||||
? value.at(0, 0)
|
||||
: err("#VALUE!", "범위를 값 하나로 못 씀");
|
||||
}
|
||||
|
||||
/** 엔진이 만든 범위 — 적힌 자리(끝 자름 전) · 암묵 교차에 씀 */
|
||||
export interface PlacedRange extends RangeValue {
|
||||
r0: number;
|
||||
c0: number;
|
||||
r1: number;
|
||||
c1: number;
|
||||
}
|
||||
|
||||
/** 범위 → 칸 하나 — 1×1 이면 그 칸 · 아니면 식 든 행(한 열 범위) · 열(한 행 범위)과 겹친 칸(엑셀 암묵 교차) */
|
||||
export function intersect(value: EvalResult, at: { r: number; c: number }): Scalar {
|
||||
if (!isRange(value) || (value.rows === 1 && value.cols === 1)) return single(value);
|
||||
const p = value as Partial<PlacedRange>;
|
||||
if (p.r0 !== undefined && p.c0 !== undefined && p.r1 !== undefined && p.c1 !== undefined) {
|
||||
if (p.c0 === p.c1 && at.r >= p.r0 && at.r <= p.r1) return value.at(at.r - p.r0, 0);
|
||||
if (p.r0 === p.r1 && at.c >= p.c0 && at.c <= p.c1) return value.at(0, at.c - p.c0);
|
||||
}
|
||||
return err("#VALUE!", "범위가 식 든 행 · 열과 안 겹침");
|
||||
}
|
||||
|
||||
/** 수로 — 빈 칸 0 · 참거짓 1/0 · 수 글 · 아니면 `#VALUE!` · 범위는 칸 하나일 때만 */
|
||||
export function asNumber(_value: EvalResult): Frac | ErrorValue {
|
||||
return todo();
|
||||
export function asNumber(value: EvalResult): Frac | ErrorValue {
|
||||
const v = single(value);
|
||||
if (v === null) return ZERO;
|
||||
if (typeof v === "boolean") return v ? ONE : ZERO;
|
||||
if (typeof v === "string") {
|
||||
const t = v.trim();
|
||||
const pct = t.endsWith("%");
|
||||
try {
|
||||
const n = parseDecimal(pct ? t.slice(0, -1) : t);
|
||||
return pct ? div(n, HUNDRED) : n;
|
||||
} catch {
|
||||
return err("#VALUE!", `수로 못 읽는 글: "${v}"`);
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** 일반 형식 글 — 유효 15 자리 · 1e15 넘거나 1e-9 밑이면 `1.5E+18` 꼴(엑셀 `&` 와 같게) */
|
||||
export function numberText(x: Frac): string {
|
||||
if (x.n === 0n) return "0";
|
||||
const d = parseFloat(fracToString(x));
|
||||
const e = Math.floor(Math.log10(Math.abs(d)));
|
||||
if (e >= 15 || e < -9) {
|
||||
const [m, ex] = d.toExponential(14).split("e");
|
||||
const mant = m.replace(/\.?0+$/, "");
|
||||
const n = Number(ex);
|
||||
return `${mant}E${n < 0 ? "-" : "+"}${String(Math.abs(n)).padStart(2, "0")}`;
|
||||
}
|
||||
return fracToString(roundAt(x, 14 - e, "round"));
|
||||
}
|
||||
|
||||
/** 글로 — 수는 일반 형식 글(엑셀 `&` 와 같게) · 참거짓 `TRUE`/`FALSE` */
|
||||
export function asText(_value: EvalResult): string | ErrorValue {
|
||||
return todo();
|
||||
export function asText(value: EvalResult): string | ErrorValue {
|
||||
const v = single(value);
|
||||
if (v === null) return "";
|
||||
if (typeof v === "string" || isError(v)) return v;
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
return numberText(v);
|
||||
}
|
||||
|
||||
export function asBool(_value: EvalResult): boolean | ErrorValue {
|
||||
return todo();
|
||||
export function asBool(value: EvalResult): boolean | ErrorValue {
|
||||
const v = single(value);
|
||||
if (v === null) return false;
|
||||
if (typeof v === "boolean" || isError(v)) return v;
|
||||
if (typeof v === "string") {
|
||||
const t = v.toUpperCase();
|
||||
return t === "TRUE"
|
||||
? true
|
||||
: t === "FALSE"
|
||||
? false
|
||||
: err("#VALUE!", `참거짓으로 못 읽는 글: "${v}"`);
|
||||
}
|
||||
return v.n !== 0n;
|
||||
}
|
||||
|
||||
/** double → 유효 15 자리 십진 → 분수 · NaN · 무한은 `#NUM!` */
|
||||
export function fromDouble(_x: number): Frac | ErrorValue {
|
||||
return todo();
|
||||
export function fromDouble(x: number): Frac | ErrorValue {
|
||||
if (!Number.isFinite(x)) return err("#NUM!", "수가 너무 크거나 뜻이 없음");
|
||||
return x === 0 ? ZERO : parseDecimal(x.toPrecision(15));
|
||||
}
|
||||
|
||||
export const toDouble = (x: Frac): number => parseFloat(fracToString(x));
|
||||
|
||||
/** 저장 모양 — 수는 십진 글(30 자리까지) */
|
||||
export function toCalcValue(_value: Scalar): CalcValue {
|
||||
return todo();
|
||||
export function toCalcValue(value: Scalar): CalcValue {
|
||||
if (value === null) return { 수: "0" };
|
||||
if (typeof value === "string") return { 글: value };
|
||||
if (typeof value === "boolean") return { 참: value };
|
||||
if (isError(value))
|
||||
return value.why ? { 오류: value.error, 까닭: value.why } : { 오류: value.error };
|
||||
return { 수: fracToString(value) };
|
||||
}
|
||||
|
||||
// ── 연산 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function power(a: Frac, b: Frac): Frac | ErrorValue {
|
||||
if (a.n === 0n) {
|
||||
if (b.n === 0n) return err("#NUM!", "0^0");
|
||||
return b.n < 0n ? err("#DIV/0!", "0 의 음수 거듭제곱") : ZERO;
|
||||
}
|
||||
if (b.d === 1n) {
|
||||
const e = b.n < 0n ? -b.n : b.n;
|
||||
const bits = BigInt(a.n.toString(2).length + a.d.toString(2).length);
|
||||
// ponytail: 4,000 비트 넘는 정확 거듭제곱은 double 로 — 실무 식엔 없음
|
||||
if (bits * e <= 4000n) {
|
||||
const r = frac(a.n ** e, a.d ** e);
|
||||
return tame(b.n < 0n ? div(ONE, r) : r);
|
||||
}
|
||||
} else if (a.n < 0n) return err("#NUM!", "음수의 소수 거듭제곱");
|
||||
return fromDouble(Math.pow(toDouble(a), toDouble(b)));
|
||||
}
|
||||
|
||||
function arith(op: string, a: Frac, b: Frac): Frac | ErrorValue {
|
||||
switch (op) {
|
||||
case "+":
|
||||
return tame(add(a, b));
|
||||
case "-":
|
||||
return tame(sub(a, b));
|
||||
case "*":
|
||||
return tame(mul(a, b));
|
||||
case "/":
|
||||
return b.n === 0n ? err("#DIV/0!", "0 으로 나눔") : tame(div(a, b));
|
||||
default:
|
||||
return power(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
const RANK = (v: Scalar) => (isFrac(v) ? 0 : typeof v === "string" ? 1 : 2);
|
||||
|
||||
/** 엑셀 비교 — 빈 칸은 맞은편 형의 빈 값(0 · "" · FALSE) · 형이 다르면 수 < 글 < 참거짓 */
|
||||
export function compareValues(a: Scalar, b: Scalar): number {
|
||||
const blank = (other: Scalar): Scalar =>
|
||||
isFrac(other)
|
||||
? ZERO
|
||||
: typeof other === "string"
|
||||
? ""
|
||||
: typeof other === "boolean"
|
||||
? false
|
||||
: ZERO;
|
||||
if (a === null) a = blank(b);
|
||||
if (b === null) b = blank(a);
|
||||
const ra = RANK(a);
|
||||
const rb = RANK(b);
|
||||
if (ra !== rb) return ra < rb ? -1 : 1;
|
||||
if (isFrac(a)) return cmp(a, b as Frac);
|
||||
if (typeof a === "string") {
|
||||
const x = a.toLowerCase();
|
||||
const y = (b as string).toLowerCase();
|
||||
return x === y ? 0 : x < y ? -1 : 1;
|
||||
}
|
||||
return a === b ? 0 : a ? 1 : -1;
|
||||
}
|
||||
|
||||
function compareOp(op: string, a: Scalar, b: Scalar): boolean {
|
||||
const c = compareValues(a, b);
|
||||
return op === "="
|
||||
? c === 0
|
||||
: op === "<>"
|
||||
? c !== 0
|
||||
: op === "<"
|
||||
? c < 0
|
||||
: op === "<="
|
||||
? c <= 0
|
||||
: op === ">"
|
||||
? c > 0
|
||||
: c >= 0;
|
||||
}
|
||||
|
||||
// ── 풀기 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function evaluate(node: FormulaNode, ctx: EvalContext): EvalResult {
|
||||
switch (node.type) {
|
||||
case "number":
|
||||
return node.value;
|
||||
case "string":
|
||||
return node.value;
|
||||
case "bool":
|
||||
return node.value;
|
||||
case "error":
|
||||
return err(node.code);
|
||||
case "name":
|
||||
return err("#NAME?", `모르는 이름: ${node.name}`);
|
||||
case "cell":
|
||||
case "range": {
|
||||
const sheet = node.sheet === null ? ctx.sheet : ctx.sheetId(node.sheet);
|
||||
if (sheet === null) return err("#REF!", `없는 시트: ${node.sheet}`);
|
||||
if (node.type === "cell") return ctx.cell(sheet, node.ref.r, node.ref.c);
|
||||
return ctx.range(sheet, normRange(node.from.r, node.from.c, node.to.r, node.to.c));
|
||||
}
|
||||
case "unary": {
|
||||
const v = intersect(evaluate(node.arg, ctx), ctx);
|
||||
if (node.op === "+") return v;
|
||||
const n = asNumber(v);
|
||||
if (isError(n)) return n;
|
||||
return node.op === "-" ? frac(-n.n, n.d) : div(n, HUNDRED);
|
||||
}
|
||||
case "binary": {
|
||||
const left = intersect(evaluate(node.left, ctx), ctx);
|
||||
const right = intersect(evaluate(node.right, ctx), ctx);
|
||||
if (node.op === "&") {
|
||||
const a = asText(left);
|
||||
if (isError(a)) return a;
|
||||
const b = asText(right);
|
||||
return isError(b) ? b : a + b;
|
||||
}
|
||||
if (PLAIN_OPS.has(node.op)) {
|
||||
const a = asNumber(left);
|
||||
if (isError(a)) return a;
|
||||
const b = asNumber(right);
|
||||
return isError(b) ? b : arith(node.op, a, b);
|
||||
}
|
||||
if (isError(left)) return left;
|
||||
return isError(right) ? right : compareOp(node.op, left, right);
|
||||
}
|
||||
case "call": {
|
||||
const fn = getFunction(node.name);
|
||||
if (!fn) return err("#NAME?", `모르는 함수: ${node.name}`);
|
||||
const n = node.args.length;
|
||||
if (n < fn.min || (fn.max !== undefined && n > fn.max)) {
|
||||
const want = fn.max === fn.min ? `${fn.min}` : `${fn.min}~${fn.max ?? ""}`;
|
||||
return err("#VALUE!", `${node.name} 인자 수 ${n} (받는 수 ${want})`);
|
||||
}
|
||||
return fn.call(
|
||||
node.args.map((a) => () => evaluate(a, ctx)),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PLAIN_OPS = new Set(["+", "-", "*", "/", "^"]);
|
||||
|
||||
/** 수로 온 입력 값 → 풀이 값 */
|
||||
export function inputValue(v: number | string | boolean): Scalar {
|
||||
return typeof v === "number" ? toFrac(v) : v;
|
||||
}
|
||||
|
||||
@@ -2,21 +2,104 @@
|
||||
* spreadsheet_fill.ts (주인 B)
|
||||
* 채우기 핸들 — 원본 범위를 대상 범위로 늘림: 수 하나 = 복사 · 수 둘 이상 = 등차 연속 ·
|
||||
* 글 끝 번호(`구-01` → `구-02`) · 식 = 상대 참조 옮김(A `moveFormula`) · 서식 같이.
|
||||
* 결과는 `{ 종류: "칸" }` 명령에 넣을 칸 뭉치. 0 계약 머리 — 몸은 B 가 채움.
|
||||
* 결과는 `{ 종류: "칸" }` 명령에 넣을 칸 뭉치.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Cell, CellRange, Workbook } from "./spreadsheet_types";
|
||||
import type { Cell, CellRange, FillDirection, Workbook } from "./spreadsheet_types";
|
||||
import { moveFormula } from "./spreadsheet_refshift";
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet_fill: 아직 없음(B)");
|
||||
};
|
||||
/** 0 → `A` · 27 → `AB`(엔진 A 의 `spreadsheet_address.ts` 와 같은 규칙 · 채우기는 자기 것을 씀) */
|
||||
function colLetters(c: number): string {
|
||||
let n = c + 1;
|
||||
let s = "";
|
||||
while (n > 0) {
|
||||
const rem = (n - 1) % 26;
|
||||
s = String.fromCharCode(65 + rem) + s;
|
||||
n = Math.floor((n - 1) / 26);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function a1(r: number, c: number): string {
|
||||
return `${colLetters(c)}${r + 1}`;
|
||||
}
|
||||
|
||||
function detectDirection(source: CellRange, target: CellRange): FillDirection {
|
||||
if (target.r1 > source.r1 && target.c0 === source.c0 && target.c1 === source.c1) return "down";
|
||||
if (target.r0 < source.r0 && target.c0 === source.c0 && target.c1 === source.c1) return "up";
|
||||
if (target.c1 > source.c1 && target.r0 === source.r0 && target.r1 === source.r1) return "right";
|
||||
return "left";
|
||||
}
|
||||
|
||||
/** 글 끝 숫자를 옮김(`구-01`,step 1 → `구-02`) · 끝 숫자가 없으면 그대로 */
|
||||
function incrementTrailingNumber(text: string, step: number): string {
|
||||
const m = /^(.*?)(\d+)$/.exec(text);
|
||||
if (!m) return text;
|
||||
const [, prefix, digits] = m;
|
||||
const next = BigInt(digits) + BigInt(step);
|
||||
const clamped = next < 0n ? 0n : next;
|
||||
return prefix + clamped.toString().padStart(digits.length, "0");
|
||||
}
|
||||
|
||||
/** `target` 은 `source` 를 한 방향으로 늘린 범위(방향은 둘을 견줘 앎) · A1 → 칸(null = 비움) */
|
||||
export function fillCells(
|
||||
_book: Workbook,
|
||||
_sheet: string,
|
||||
_source: CellRange,
|
||||
_target: CellRange,
|
||||
book: Workbook,
|
||||
sheet: string,
|
||||
source: CellRange,
|
||||
target: CellRange,
|
||||
): Record<string, Cell | null> {
|
||||
return todo();
|
||||
const sheetObj = book.시트.find((s) => s.id === sheet);
|
||||
const out: Record<string, Cell | null> = {};
|
||||
if (!sheetObj) return out;
|
||||
|
||||
const direction = detectDirection(source, target);
|
||||
const vertical = direction === "down" || direction === "up";
|
||||
const lineCount = vertical ? source.c1 - source.c0 + 1 : source.r1 - source.r0 + 1;
|
||||
|
||||
for (let li = 0; li < lineCount; li++) {
|
||||
const fixed = vertical ? source.c0 + li : source.r0 + li;
|
||||
const sourceStart = vertical ? source.r0 : source.c0;
|
||||
const sourceEnd = vertical ? source.r1 : source.c1;
|
||||
const targetStart = vertical ? target.r0 : target.c0;
|
||||
const targetEnd = vertical ? target.r1 : target.c1;
|
||||
const lineLen = sourceEnd - sourceStart + 1;
|
||||
|
||||
const key = (pos: number): string => (vertical ? a1(pos, fixed) : a1(fixed, pos));
|
||||
const sourceCells: (Cell | undefined)[] = [];
|
||||
for (let p = sourceStart; p <= sourceEnd; p++) sourceCells.push(sheetObj.칸[key(p)]);
|
||||
|
||||
const allNumeric = sourceCells.every(
|
||||
(c) => c !== undefined && c.식 === undefined && typeof c.값 === "number",
|
||||
);
|
||||
const nums = allNumeric ? sourceCells.map((c) => c!.값 as number) : [];
|
||||
const step = nums.length > 1 ? (nums[nums.length - 1] - nums[0]) / (nums.length - 1) : 0;
|
||||
|
||||
for (let p = targetStart; p <= targetEnd; p++) {
|
||||
if (p >= sourceStart && p <= sourceEnd) continue;
|
||||
const rel = p - sourceStart;
|
||||
const slot = ((rel % lineLen) + lineLen) % lineLen;
|
||||
const period = (rel - slot) / lineLen;
|
||||
const template = sourceCells[slot];
|
||||
const outKey = key(p);
|
||||
if (template === undefined) {
|
||||
out[outKey] = null;
|
||||
continue;
|
||||
}
|
||||
const cell: Cell = {};
|
||||
if (template.서식 !== undefined) cell.서식 = template.서식;
|
||||
if (allNumeric) {
|
||||
cell.값 = nums[0] + step * rel;
|
||||
} else if (template.식 !== undefined) {
|
||||
const dr = vertical ? period * lineLen : 0;
|
||||
const dc = vertical ? 0 : period * lineLen;
|
||||
cell.식 = moveFormula(template.식, dr, dc);
|
||||
} else if (typeof template.값 === "string" && lineLen === 1) {
|
||||
cell.값 = incrementTrailingNumber(template.값, rel);
|
||||
} else if (template.값 !== undefined) {
|
||||
cell.값 = template.값;
|
||||
}
|
||||
out[outKey] = cell;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_find.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 찾기 · 바꾸기 — 대소문자 구분 · 전체 칸 일치 · 「수식 안」(식 글 속 글자도 뒤짐) 옵션.
|
||||
* 값 칸은 `값`(수 · 참거짓은 글로 바꿔 견줌) · 식 칸은 기본으로 식 글(`=` 뺀 것)을 뒤짐(계산값은
|
||||
* 이 계층에서 모름 — CalcEngine 은 C·D 쪽에만 있음). 바꾸기는 `dispatch({종류:"칸", …})` 하나로만.
|
||||
*
|
||||
* 잇는 법(D · sub7) — 1단계 격자 · 편집기가 서면:
|
||||
* 1) 도구 모음이나 Ctrl+F 단축키로 작은 패널을 띄우고 `createFindPanel(ctx)` 를 그 안에 붙임.
|
||||
* 2) 패널의 「다음 찾기」 는 `findNext` 결과로 `ctx.select` + `ctx.grid.reveal` 호출.
|
||||
* 3) 「모두 바꾸기」 버튼은 `replaceAll` 을 부르고 끝나면 바뀐 수를 토스트로.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CellInput, SheetCellAddress, Workbook } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
export interface FindOptions {
|
||||
대소문자?: boolean;
|
||||
전체칸?: boolean;
|
||||
/** 식 칸은 식 글(수식) 속에서도 찾음 — 꺼지면 값 칸만 뒤짐(식 칸은 건너뜀) */
|
||||
수식안?: boolean;
|
||||
}
|
||||
|
||||
const norm = (text: string, opts: FindOptions | undefined): string =>
|
||||
opts?.대소문자 ? text : text.toLowerCase();
|
||||
|
||||
/** 칸 하나를 글로 — 식 칸은 `수식안` 이 있어야 식 글, 없으면 후보에서 빠짐(null). */
|
||||
function cellText(
|
||||
book: Workbook,
|
||||
sheetId: string,
|
||||
r: number,
|
||||
c: number,
|
||||
opts?: FindOptions,
|
||||
): string | null {
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
const cell = sheet?.칸[a1(r, c)];
|
||||
if (!cell) return null;
|
||||
if (cell.식 !== undefined) return opts?.수식안 ? cell.식 : null;
|
||||
const v = cell.값;
|
||||
if (v === undefined) return null;
|
||||
return typeof v === "boolean" ? (v ? "TRUE" : "FALSE") : String(v);
|
||||
}
|
||||
|
||||
function a1(r: number, c: number): string {
|
||||
let col = c + 1;
|
||||
let letters = "";
|
||||
while (col > 0) {
|
||||
const rem = (col - 1) % 26;
|
||||
letters = String.fromCharCode(65 + rem) + letters;
|
||||
col = Math.floor((col - 1) / 26);
|
||||
}
|
||||
return `${letters}${r + 1}`;
|
||||
}
|
||||
|
||||
const matches = (haystack: string, query: string, opts: FindOptions | undefined): boolean => {
|
||||
const h = norm(haystack, opts);
|
||||
const q = norm(query, opts);
|
||||
return opts?.전체칸 ? h === q : h.includes(q);
|
||||
};
|
||||
|
||||
/** 시트 하나 안 찾는 순서 — 왼위부터 오른아래로(행 우선). */
|
||||
function* cellsInOrder(book: Workbook, sheetId: string): Generator<SheetCellAddress> {
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
if (!sheet) return;
|
||||
const addrs = Object.keys(sheet.칸)
|
||||
.map((key) => parseA1(key))
|
||||
.sort((x, y) => x.r - y.r || x.c - y.c);
|
||||
for (const { r, c } of addrs) yield { 시트: sheetId, r, c };
|
||||
}
|
||||
|
||||
function parseA1(key: string): { r: number; c: number } {
|
||||
const m = /^([A-Z]+)(\d+)$/.exec(key);
|
||||
if (!m) return { r: 0, c: 0 };
|
||||
let col = 0;
|
||||
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
return { r: Number(m[2]) - 1, c: col - 1 };
|
||||
}
|
||||
|
||||
/** 통합문서 전체(활성 시트 먼저 뒤지고 싶으면 `sheetOrder` 로 순서를 줌)에서 다 찾음. */
|
||||
export function findAll(
|
||||
book: Workbook,
|
||||
query: string,
|
||||
opts?: FindOptions,
|
||||
sheetOrder?: string[],
|
||||
): SheetCellAddress[] {
|
||||
if (!query) return [];
|
||||
const ids = sheetOrder ?? book.시트.map((s) => s.id);
|
||||
const out: SheetCellAddress[] = [];
|
||||
for (const id of ids) {
|
||||
for (const at of cellsInOrder(book, id)) {
|
||||
const text = cellText(book, at.시트, at.r, at.c, opts);
|
||||
if (text !== null && matches(text, query, opts)) out.push(at);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `from` 다음 칸부터 한 바퀴 돌아 처음 걸리는 것(순환) · 없으면 null. */
|
||||
export function findNext(
|
||||
book: Workbook,
|
||||
from: SheetCellAddress,
|
||||
query: string,
|
||||
opts?: FindOptions,
|
||||
): SheetCellAddress | null {
|
||||
const all = findAll(book, query, opts, [
|
||||
from.시트,
|
||||
...book.시트.map((s) => s.id).filter((id) => id !== from.시트),
|
||||
]);
|
||||
if (all.length === 0) return null;
|
||||
const idx = all.findIndex((m) => m.시트 === from.시트 && m.r === from.r && m.c === from.c);
|
||||
return all[(idx + 1) % all.length];
|
||||
}
|
||||
|
||||
/** 한 칸 바꾸기 — 식 칸(`수식안`)이면 식 글 속 글자를 바꿈 · 값 칸이면 값 전체(전체칸)나 부분(글만)을 바꿈. */
|
||||
export function replaceOne(
|
||||
ctx: SpreadsheetContext,
|
||||
at: SheetCellAddress,
|
||||
query: string,
|
||||
replacement: string,
|
||||
opts?: FindOptions,
|
||||
): void {
|
||||
const sheet = ctx.book.시트.find((s) => s.id === at.시트);
|
||||
const cell = sheet?.칸[a1(at.r, at.c)];
|
||||
if (!sheet || !cell) return;
|
||||
const next: { 값?: CellInput; 식?: string; 서식?: number } = { 서식: cell.서식 };
|
||||
if (cell.식 !== undefined && opts?.수식안) {
|
||||
next.식 = replaceText(cell.식, query, replacement, opts);
|
||||
} else if (cell.값 !== undefined) {
|
||||
const text = typeof cell.값 === "boolean" ? (cell.값 ? "TRUE" : "FALSE") : String(cell.값);
|
||||
next.값 = replaceText(text, query, replacement, opts);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
ctx.dispatch({ 종류: "칸", 시트: at.시트, 칸: { [a1(at.r, at.c)]: next } });
|
||||
}
|
||||
|
||||
function replaceText(
|
||||
text: string,
|
||||
query: string,
|
||||
replacement: string,
|
||||
opts: FindOptions | undefined,
|
||||
): string {
|
||||
if (opts?.전체칸) return matches(text, query, opts) ? replacement : text;
|
||||
if (opts?.대소문자) return text.split(query).join(replacement);
|
||||
const re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
||||
return text.replace(re, replacement);
|
||||
}
|
||||
|
||||
/** 모두 바꾸기 — 한 번의 되돌리기로 묶어 적용 · 바뀐 칸 수를 돌려줌. */
|
||||
export function replaceAll(
|
||||
ctx: SpreadsheetContext,
|
||||
query: string,
|
||||
replacement: string,
|
||||
opts?: FindOptions,
|
||||
): number {
|
||||
const hits = findAll(ctx.book, query, opts);
|
||||
if (hits.length === 0) return 0;
|
||||
const bySheet = new Map<string, SheetCellAddress[]>();
|
||||
for (const at of hits) bySheet.set(at.시트, [...(bySheet.get(at.시트) ?? []), at]);
|
||||
const commands = [...bySheet.entries()].map(([sheetId, ats]) => {
|
||||
const sheet = ctx.book.시트.find((s) => s.id === sheetId)!;
|
||||
const 칸: Record<string, { 값?: CellInput; 식?: string; 서식?: number }> = {};
|
||||
for (const at of ats) {
|
||||
const cell = sheet.칸[a1(at.r, at.c)];
|
||||
if (!cell) continue;
|
||||
if (cell.식 !== undefined && opts?.수식안) {
|
||||
칸[a1(at.r, at.c)] = {
|
||||
식: replaceText(cell.식, query, replacement, opts),
|
||||
서식: cell.서식,
|
||||
};
|
||||
} else if (cell.값 !== undefined) {
|
||||
const text = typeof cell.값 === "boolean" ? (cell.값 ? "TRUE" : "FALSE") : String(cell.값);
|
||||
칸[a1(at.r, at.c)] = { 값: replaceText(text, query, replacement, opts), 서식: cell.서식 };
|
||||
}
|
||||
}
|
||||
return { 종류: "칸" as const, 시트: sheetId, 칸 };
|
||||
});
|
||||
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
|
||||
return hits.length;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_format_painter.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 서식 붓 — 활성 칸의 서식을 묻혀 다른 범위에 바름. 한 번 누르면 한 번만 · 두 번 누르면(sticky)
|
||||
* 끌 때까지 계속. 서식 찾기는 칸 → 행 → 열 → 기본(0) 차례(`spreadsheet_types.ts` `Cell.서식` 규칙).
|
||||
*
|
||||
* 잇는 법(D · sub7 · E 도구 모음) — 도구 모음에 버튼을 두고 `createFormatPainter(ctx)` 하나를 붙임.
|
||||
* 한 번 누르면 `pick()` → 커서를 붓 모양으로 → 범위를 고르면(mouseup) `paint(range)`.
|
||||
* 두 번 누르면 `pick()` 뒤 `sticky=true` 로 두고 Esc 나 버튼을 다시 누르면 꺼짐(`active()` 로 표시).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CellRange, CellStyle, Workbook } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const EMPTY_STYLE: CellStyle = {};
|
||||
|
||||
/** 칸 하나의 실제 서식 — 칸 자기 서식 없으면 행 서식 · 없으면 열 서식 · 없으면 기본(0). */
|
||||
export function resolveCellStyle(book: Workbook, sheetId: string, r: number, c: number): CellStyle {
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
if (!sheet) return EMPTY_STYLE;
|
||||
const idx = cellStyleIndex(sheet, r, c);
|
||||
return book.서식[idx] ?? EMPTY_STYLE;
|
||||
}
|
||||
|
||||
function cellStyleIndex(
|
||||
sheet: {
|
||||
칸: Record<string, { 서식?: number }>;
|
||||
열?: Record<string, { 서식?: number }>;
|
||||
행?: Record<string, { 서식?: number }>;
|
||||
},
|
||||
r: number,
|
||||
c: number,
|
||||
): number {
|
||||
const key = a1(r, c);
|
||||
const cellIdx = sheet.칸[key]?.서식;
|
||||
if (cellIdx !== undefined) return cellIdx;
|
||||
const rowIdx = sheet.행?.[String(r + 1)]?.서식;
|
||||
if (rowIdx !== undefined) return rowIdx;
|
||||
const colIdx = sheet.열?.[colLetters(c)]?.서식;
|
||||
if (colIdx !== undefined) return colIdx;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function colLetters(c: number): string {
|
||||
let col = c + 1;
|
||||
let letters = "";
|
||||
while (col > 0) {
|
||||
const rem = (col - 1) % 26;
|
||||
letters = String.fromCharCode(65 + rem) + letters;
|
||||
col = Math.floor((col - 1) / 26);
|
||||
}
|
||||
return letters;
|
||||
}
|
||||
|
||||
const a1 = (r: number, c: number): string => `${colLetters(c)}${r + 1}`;
|
||||
|
||||
export interface FormatPainterHandle extends PartHandle {
|
||||
/** 지금 활성 칸의 서식을 붓에 묻힘 */
|
||||
pick(): void;
|
||||
/** 묻힌 서식이 있으면 범위에 바름 · `sticky` 가 아니면 한 번 쓰고 스스로 끔 */
|
||||
paint(range: CellRange, sticky?: boolean): void;
|
||||
active(): boolean;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export function createFormatPainter(ctx: SpreadsheetContext): FormatPainterHandle {
|
||||
let picked: CellStyle | null = null;
|
||||
let sticky = false;
|
||||
|
||||
return {
|
||||
root: null,
|
||||
pick(): void {
|
||||
const at = ctx.selection.활성;
|
||||
picked = resolveCellStyle(ctx.book, ctx.selection.시트, at.r, at.c);
|
||||
},
|
||||
paint(range: CellRange, keepOn = false): void {
|
||||
if (!picked) return;
|
||||
sticky = keepOn;
|
||||
ctx.dispatch({ 종류: "서식", 시트: ctx.selection.시트, 범위: [range], 바꿀: { ...picked } });
|
||||
if (!sticky) picked = null;
|
||||
},
|
||||
active(): boolean {
|
||||
return picked !== null;
|
||||
},
|
||||
clear(): void {
|
||||
picked = null;
|
||||
sticky = false;
|
||||
},
|
||||
refresh(): void {
|
||||
/* 상태 없는 표시만 씀 — 버튼 눌림은 active() 로 E 가 그림 */
|
||||
},
|
||||
destroy(): void {
|
||||
picked = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_func_help.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 함수 자동 완성 목록 · 인자 도움말 — 1단계 함수 48 개(`spreadsheet_types.ts` 머리 「1단계 함수」)
|
||||
* 이름 · 인자 모양 · 한 줄 뜻. B `spreadsheet_functions.ts`(실제 풀이)와는 따로 — 이 표는 화면 도움말 전용
|
||||
* 정적 자료라 B 가 아직 없어도 씀. B 가 완성돼도 이름 목록이 갈리지 않게 이 표를 그대로 둠.
|
||||
*
|
||||
* 잇는 법(D · sub7 · 편집기) — 식 입력 중 `=` 뒤 낱말을 `suggestFunctions` 로 목록을 보여주고
|
||||
* `(` 를 친 뒤엔 `funcHelp(name)` 로 인자 도움말 풍선을 씀(지금 몇 번째 인자인지는 D 가 콤마를 세어 넘김).
|
||||
* ========================================================================== */
|
||||
|
||||
export interface FuncHelp {
|
||||
이름: string;
|
||||
/** 인자 모양 — 대괄호는 생략 가능 */
|
||||
꼴: string;
|
||||
뜻: string;
|
||||
}
|
||||
|
||||
const TABLE: FuncHelp[] = [
|
||||
{ 이름: "SUM", 꼴: "SUM(수1, [수2], …)", 뜻: "수를 더함" },
|
||||
{ 이름: "PRODUCT", 꼴: "PRODUCT(수1, [수2], …)", 뜻: "수를 곱함" },
|
||||
{ 이름: "SUMPRODUCT", 꼴: "SUMPRODUCT(범위1, [범위2], …)", 뜻: "같은 자리끼리 곱한 뒤 더함" },
|
||||
{ 이름: "ROUND", 꼴: "ROUND(수, 자릿수)", 뜻: "반올림" },
|
||||
{ 이름: "ROUNDUP", 꼴: "ROUNDUP(수, 자릿수)", 뜻: "올림" },
|
||||
{ 이름: "ROUNDDOWN", 꼴: "ROUNDDOWN(수, 자릿수)", 뜻: "내림" },
|
||||
{ 이름: "INT", 꼴: "INT(수)", 뜻: "정수로 내림(음수는 작은 쪽)" },
|
||||
{ 이름: "TRUNC", 꼴: "TRUNC(수, [자릿수])", 뜻: "소수점을 버림(0 쪽으로)" },
|
||||
{ 이름: "CEILING", 꼴: "CEILING(수, 배수)", 뜻: "배수 단위로 올림" },
|
||||
{ 이름: "FLOOR", 꼴: "FLOOR(수, 배수)", 뜻: "배수 단위로 내림" },
|
||||
{ 이름: "ABS", 꼴: "ABS(수)", 뜻: "절댓값" },
|
||||
{ 이름: "MOD", 꼴: "MOD(수, 나눌수)", 뜻: "나눈 나머지" },
|
||||
{ 이름: "POWER", 꼴: "POWER(수, 지수)", 뜻: "거듭제곱" },
|
||||
{ 이름: "SQRT", 꼴: "SQRT(수)", 뜻: "제곱근" },
|
||||
{ 이름: "PI", 꼴: "PI()", 뜻: "원주율" },
|
||||
{ 이름: "SIN", 꼴: "SIN(각도값)", 뜻: "사인(라디안)" },
|
||||
{ 이름: "COS", 꼴: "COS(각도값)", 뜻: "코사인(라디안)" },
|
||||
{ 이름: "TAN", 꼴: "TAN(각도값)", 뜻: "탄젠트(라디안)" },
|
||||
{ 이름: "ASIN", 꼴: "ASIN(수)", 뜻: "아크사인(라디안)" },
|
||||
{ 이름: "ACOS", 꼴: "ACOS(수)", 뜻: "아크코사인(라디안)" },
|
||||
{ 이름: "ATAN", 꼴: "ATAN(수)", 뜻: "아크탄젠트(라디안)" },
|
||||
{ 이름: "RADIANS", 꼴: "RADIANS(각도)", 뜻: "도 → 라디안" },
|
||||
{ 이름: "DEGREES", 꼴: "DEGREES(라디안)", 뜻: "라디안 → 도" },
|
||||
{ 이름: "MIN", 꼴: "MIN(수1, [수2], …)", 뜻: "가장 작은 수" },
|
||||
{ 이름: "MAX", 꼴: "MAX(수1, [수2], …)", 뜻: "가장 큰 수" },
|
||||
{ 이름: "AVERAGE", 꼴: "AVERAGE(수1, [수2], …)", 뜻: "평균" },
|
||||
{ 이름: "IF", 꼴: "IF(조건, 참일때, [거짓일때])", 뜻: "조건에 따라 값을 고름" },
|
||||
{ 이름: "AND", 꼴: "AND(조건1, [조건2], …)", 뜻: "모두 참이면 참" },
|
||||
{ 이름: "OR", 꼴: "OR(조건1, [조건2], …)", 뜻: "하나라도 참이면 참" },
|
||||
{ 이름: "NOT", 꼴: "NOT(조건)", 뜻: "참 · 거짓을 뒤집음" },
|
||||
{ 이름: "IFERROR", 꼴: "IFERROR(식, 오류일때)", 뜻: "오류면 대신 값을 씀" },
|
||||
{ 이름: "CONCATENATE", 꼴: "CONCATENATE(글1, [글2], …)", 뜻: "글을 이어 붙임" },
|
||||
{ 이름: "FIXED", 꼴: "FIXED(수, [소수자리], [쉼표뺌])", 뜻: "소수 자리를 정해 글로 바꿈" },
|
||||
{ 이름: "TEXT", 꼴: "TEXT(값, 형식)", 뜻: "형식 코드로 값을 글로 바꿈" },
|
||||
{ 이름: "LEN", 꼴: "LEN(글)", 뜻: "글자 수" },
|
||||
{ 이름: "LEFT", 꼴: "LEFT(글, [글자수])", 뜻: "왼쪽부터 글자를 뗌" },
|
||||
{ 이름: "RIGHT", 꼴: "RIGHT(글, [글자수])", 뜻: "오른쪽부터 글자를 뗌" },
|
||||
{ 이름: "MID", 꼴: "MID(글, 시작, 글자수)", 뜻: "가운데 글자를 뗌" },
|
||||
{ 이름: "VALUE", 꼴: "VALUE(글)", 뜻: "숫자 모양 글을 수로 바꿈" },
|
||||
{
|
||||
이름: "VLOOKUP",
|
||||
꼴: "VLOOKUP(찾을값, 표범위, 열번호, [정확히])",
|
||||
뜻: "표 왼쪽에서 찾아 그 행의 값",
|
||||
},
|
||||
{
|
||||
이름: "HLOOKUP",
|
||||
꼴: "HLOOKUP(찾을값, 표범위, 행번호, [정확히])",
|
||||
뜻: "표 위쪽에서 찾아 그 열의 값",
|
||||
},
|
||||
{ 이름: "INDEX", 꼴: "INDEX(범위, 행번호, [열번호])", 뜻: "범위 안 자리 값" },
|
||||
{ 이름: "MATCH", 꼴: "MATCH(찾을값, 범위, [맞춤꼴])", 뜻: "범위 안 자리 번호" },
|
||||
{ 이름: "CHOOSE", 꼴: "CHOOSE(순번, 값1, [값2], …)", 뜻: "순번에 맞는 값을 고름" },
|
||||
{ 이름: "COUNT", 꼴: "COUNT(값1, [값2], …)", 뜻: "수가 든 칸 개수" },
|
||||
{ 이름: "COUNTA", 꼴: "COUNTA(값1, [값2], …)", 뜻: "비어 있지 않은 칸 개수" },
|
||||
{ 이름: "COUNTIF", 꼴: "COUNTIF(범위, 조건)", 뜻: "조건에 맞는 칸 개수" },
|
||||
{ 이름: "SUMIF", 꼴: "SUMIF(범위, 조건, [더할범위])", 뜻: "조건에 맞는 칸(또는 짝 칸)의 합" },
|
||||
];
|
||||
|
||||
const BY_NAME = new Map(TABLE.map((f) => [f.이름, f]));
|
||||
|
||||
/** `=` 뒤 지금 치는 낱말(대소문자 안 가림)로 시작하는 함수 이름 — 가나다(알파벳)순. */
|
||||
export function suggestFunctions(prefix: string): FuncHelp[] {
|
||||
const p = prefix.toUpperCase();
|
||||
if (!p) return [];
|
||||
return TABLE.filter((f) => f.이름.startsWith(p));
|
||||
}
|
||||
|
||||
/** 함수 하나의 도움말(없으면 null) — 이름은 대소문자 안 가림. */
|
||||
export function funcHelp(name: string): FuncHelp | null {
|
||||
return BY_NAME.get(name.toUpperCase()) ?? null;
|
||||
}
|
||||
|
||||
/** 1단계 함수 48 개 다 있는지(시험용) */
|
||||
export function knownFunctionCount(): number {
|
||||
return TABLE.length;
|
||||
}
|
||||
@@ -6,20 +6,19 @@
|
||||
* 글 CONCATENATE FIXED TEXT LEN LEFT RIGHT MID VALUE · 찾기 VLOOKUP HLOOKUP INDEX MATCH CHOOSE ·
|
||||
* 세기 COUNT COUNTA COUNTIF SUMIF. 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만).
|
||||
* 없는 이름 = `#NAME?` · 인자 수가 min · max 밖이면 A 의 eval 이 `#VALUE!` + 까닭(엑셀은 입력을 막음).
|
||||
* 0 계약 머리 — 몸은 B 가 채움.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SheetFunction } from "./spreadsheet_types";
|
||||
import { MATH_FUNCTIONS } from "./spreadsheet_functions_math";
|
||||
import { MISC_FUNCTIONS } from "./spreadsheet_functions_misc";
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet_functions: 아직 없음(B)");
|
||||
};
|
||||
const TABLE: Record<string, SheetFunction> = { ...MATH_FUNCTIONS, ...MISC_FUNCTIONS };
|
||||
|
||||
export function getFunction(_name: string): SheetFunction | undefined {
|
||||
return todo();
|
||||
export function getFunction(name: string): SheetFunction | undefined {
|
||||
return TABLE[name.toUpperCase()];
|
||||
}
|
||||
|
||||
/** 아는 함수 이름 전부(가나다 · 자동 완성은 2단계) */
|
||||
export function functionNames(): string[] {
|
||||
return todo();
|
||||
return Object.keys(TABLE).sort();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_functions_math.ts (주인 B)
|
||||
* 수학 · 삼각 · 끝수 함수 + 함수 표 전체가 쓰는 형 바꾸기 도우미(공용은 여기서 export).
|
||||
* 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만 씀 · 속에 안 넣음).
|
||||
* 무리수(SQRT · 삼각 · PI · 소수 거듭제곱)는 double → 유효 15 자리 십진 → 분수.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
ZERO,
|
||||
add,
|
||||
cmp,
|
||||
div,
|
||||
frac,
|
||||
mul,
|
||||
parseDecimal,
|
||||
roundAt,
|
||||
sub,
|
||||
toInteger,
|
||||
type Frac,
|
||||
} from "@ui/sheet/ui_template_sheet_frac";
|
||||
import type {
|
||||
ErrorCode,
|
||||
ErrorValue,
|
||||
EvalResult,
|
||||
LazyArg,
|
||||
RangeValue,
|
||||
Scalar,
|
||||
SheetFunction,
|
||||
} from "./spreadsheet_types";
|
||||
|
||||
const ONE = frac(1n);
|
||||
|
||||
// ── 공용 도우미(math · misc 둘 다 씀) ────────────────────────────────────────
|
||||
|
||||
export function mkErr(code: ErrorCode, why?: string): ErrorValue {
|
||||
return why === undefined ? { error: code } : { error: code, why };
|
||||
}
|
||||
|
||||
export function isErr(v: unknown): v is ErrorValue {
|
||||
return typeof v === "object" && v !== null && "error" in (v as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function isFrac(v: unknown): v is Frac {
|
||||
return typeof v === "object" && v !== null && "n" in (v as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function isRange(v: EvalResult): v is RangeValue {
|
||||
return typeof v === "object" && v !== null && (v as RangeValue).kind === "range";
|
||||
}
|
||||
|
||||
/** 범위면 1×1 일 때만 칸 하나로 · 아니면 `#VALUE!` */
|
||||
export function toScalar(v: EvalResult): Scalar | ErrorValue {
|
||||
if (!isRange(v)) return v;
|
||||
if (v.rows === 1 && v.cols === 1) return v.at(0, 0);
|
||||
return mkErr("#VALUE!", "범위를 칸 하나 자리에 씀");
|
||||
}
|
||||
|
||||
/** 빈 칸 0 · 참거짓 1/0 · 수 글 · 아니면 `#VALUE!` */
|
||||
export function toNumber(v: Scalar): Frac | ErrorValue {
|
||||
if (v === null) return ZERO;
|
||||
if (isErr(v)) return v;
|
||||
if (typeof v === "boolean") return v ? ONE : ZERO;
|
||||
if (typeof v === "string") {
|
||||
try {
|
||||
return parseDecimal(v);
|
||||
} catch {
|
||||
return mkErr("#VALUE!", `글을 수로 못 읽음: ${v}`);
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** 인자 하나(범위는 1×1 만) → 수 */
|
||||
export function num(v: EvalResult): Frac | ErrorValue {
|
||||
const s = toScalar(v);
|
||||
return isErr(s) ? s : toNumber(s);
|
||||
}
|
||||
|
||||
/** 글로 — 수는 일반 형식(간단 십진) · 참거짓 TRUE/FALSE */
|
||||
export function toText(v: Scalar): string | ErrorValue {
|
||||
if (v === null) return "";
|
||||
if (isErr(v)) return v;
|
||||
if (typeof v === "string") return v;
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
return generalNumberText(v);
|
||||
}
|
||||
|
||||
export function str(v: EvalResult): string | ErrorValue {
|
||||
const s = toScalar(v);
|
||||
return isErr(s) ? s : toText(s);
|
||||
}
|
||||
|
||||
export function toBool(v: Scalar): boolean | ErrorValue {
|
||||
if (v === null) return false;
|
||||
if (isErr(v)) return v;
|
||||
if (typeof v === "boolean") return v;
|
||||
if (typeof v === "string") {
|
||||
const up = v.trim().toUpperCase();
|
||||
if (up === "TRUE") return true;
|
||||
if (up === "FALSE") return false;
|
||||
return mkErr("#VALUE!", `글을 참거짓으로 못 읽음: ${v}`);
|
||||
}
|
||||
return cmp(v, ZERO) !== 0;
|
||||
}
|
||||
|
||||
export function bool(v: EvalResult): boolean | ErrorValue {
|
||||
const s = toScalar(v);
|
||||
return isErr(s) ? s : toBool(s);
|
||||
}
|
||||
|
||||
/** double → 유효 15 자리 십진 → 분수 · NaN · 무한은 `#NUM!` */
|
||||
export function fromDouble(x: number): Frac | ErrorValue {
|
||||
if (!Number.isFinite(x)) return mkErr("#NUM!");
|
||||
if (x === 0) return ZERO;
|
||||
return parseDecimal(x.toPrecision(15));
|
||||
}
|
||||
|
||||
export function fracToDouble(x: Frac): number {
|
||||
return Number(x.n) / Number(x.d);
|
||||
}
|
||||
|
||||
/** 엑셀 「일반」 형식과 같은 십진 글(수식 `&` · TEXT 안 형식 자리가 없을 때) */
|
||||
export function generalNumberText(x: Frac): string {
|
||||
const scaled = toInteger(mul(x, frac(10n ** 15n)), "round");
|
||||
const negative = scaled < 0n;
|
||||
const digits = (negative ? -scaled : scaled).toString().padStart(16, "0");
|
||||
const whole = digits.slice(0, -15).replace(/^0+(?=\d)/, "");
|
||||
const fraction = digits.slice(-15).replace(/0+$/, "");
|
||||
const body = fraction ? `${whole}.${fraction}` : whole;
|
||||
return negative && body !== "0" ? `-${body}` : body;
|
||||
}
|
||||
|
||||
/** 인자 목록에서 수만 모음 — 범위는 수 아닌 값 조용히 건너뜀 · 낱값은 형 바꿈(실패시 strict 면 오류)
|
||||
* `propagateErrors` = 범위 · 낱값 오류를 그대로 돌려줄지(COUNT 는 false) */
|
||||
export function collectNumbers(
|
||||
args: LazyArg[],
|
||||
propagateErrors: boolean,
|
||||
strictScalar: boolean,
|
||||
): Frac[] | ErrorValue {
|
||||
const out: Frac[] = [];
|
||||
for (const a of args) {
|
||||
const v = a();
|
||||
if (isRange(v)) {
|
||||
for (let r = 0; r < v.rows; r++) {
|
||||
for (let c = 0; c < v.cols; c++) {
|
||||
const cell = v.at(r, c);
|
||||
if (isErr(cell)) {
|
||||
if (propagateErrors) return cell;
|
||||
continue;
|
||||
}
|
||||
if (isFrac(cell)) out.push(cell);
|
||||
}
|
||||
}
|
||||
} else if (isErr(v)) {
|
||||
if (propagateErrors) return v;
|
||||
} else if (v !== null) {
|
||||
const n = toNumber(v);
|
||||
if (isErr(n)) {
|
||||
if (strictScalar) return n;
|
||||
} else {
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function digitsArg(args: LazyArg[], index: number, fallback: number): number | ErrorValue {
|
||||
if (index >= args.length) return fallback;
|
||||
const n = num(args[index]());
|
||||
return isErr(n) ? n : Math.trunc(fracToDouble(n));
|
||||
}
|
||||
|
||||
function unaryDouble(fn: (x: number) => number): SheetFunction {
|
||||
return {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
return fromDouble(fn(fracToDouble(n)));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 함수 표 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const MATH_FUNCTIONS: Record<string, SheetFunction> = {
|
||||
SUM: {
|
||||
min: 0,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
if (isErr(values)) return values;
|
||||
return values.reduce(add, ZERO);
|
||||
},
|
||||
},
|
||||
PRODUCT: {
|
||||
min: 0,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
if (isErr(values)) return values;
|
||||
return values.length === 0 ? ZERO : values.reduce(mul, ONE);
|
||||
},
|
||||
},
|
||||
SUMPRODUCT: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const arrays = args.map((a) => a());
|
||||
const grids = arrays.map((v) =>
|
||||
isRange(v) ? v : { rows: 1, cols: 1, at: () => (isErr(v) ? v : (v as Scalar)) },
|
||||
);
|
||||
const rows = grids[0].rows;
|
||||
const cols = grids[0].cols;
|
||||
for (const g of grids) {
|
||||
if (g.rows !== rows || g.cols !== cols) return mkErr("#VALUE!", "배열 크기가 다름");
|
||||
}
|
||||
let sum = ZERO;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
let prod = ONE;
|
||||
for (const g of grids) {
|
||||
const n = toNumber(g.at(r, c));
|
||||
if (isErr(n)) return n;
|
||||
prod = mul(prod, n);
|
||||
}
|
||||
sum = add(sum, prod);
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
},
|
||||
},
|
||||
ROUND: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const d = digitsArg(args, 1, 0);
|
||||
if (isErr(d)) return d;
|
||||
return roundAt(n, d, "round");
|
||||
},
|
||||
},
|
||||
ROUNDUP: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const d = digitsArg(args, 1, 0);
|
||||
if (isErr(d)) return d;
|
||||
return roundAt(n, d, "away");
|
||||
},
|
||||
},
|
||||
ROUNDDOWN: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const d = digitsArg(args, 1, 0);
|
||||
if (isErr(d)) return d;
|
||||
return roundAt(n, d, "trunc");
|
||||
},
|
||||
},
|
||||
INT: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
return frac(toInteger(n, "floor"));
|
||||
},
|
||||
},
|
||||
TRUNC: {
|
||||
min: 1,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const d = digitsArg(args, 1, 0);
|
||||
if (isErr(d)) return d;
|
||||
return roundAt(n, d, "trunc");
|
||||
},
|
||||
},
|
||||
CEILING: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const sig = num(args[1]());
|
||||
if (isErr(sig)) return sig;
|
||||
if (cmp(sig, ZERO) === 0) return ZERO;
|
||||
if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(sig, ZERO) > 0) {
|
||||
return mkErr("#NUM!", "수 · 기준의 부호가 다름");
|
||||
}
|
||||
return mul(frac(toInteger(div(n, sig), "away")), sig);
|
||||
},
|
||||
},
|
||||
FLOOR: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const sig = num(args[1]());
|
||||
if (isErr(sig)) return sig;
|
||||
if (cmp(sig, ZERO) === 0) return ZERO;
|
||||
if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(sig, ZERO) > 0) {
|
||||
return mkErr("#NUM!", "수 · 기준의 부호가 다름");
|
||||
}
|
||||
return mul(frac(toInteger(div(n, sig), "trunc")), sig);
|
||||
},
|
||||
},
|
||||
ABS: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
return frac(n.n < 0n ? -n.n : n.n, n.d);
|
||||
},
|
||||
},
|
||||
MOD: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const d = num(args[1]());
|
||||
if (isErr(d)) return d;
|
||||
if (cmp(d, ZERO) === 0) return mkErr("#DIV/0!");
|
||||
const q = frac(toInteger(div(n, d), "floor"));
|
||||
return sub(n, mul(d, q));
|
||||
},
|
||||
},
|
||||
POWER: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const p = num(args[1]());
|
||||
if (isErr(p)) return p;
|
||||
if (p.d === 1n) {
|
||||
const exp = p.n;
|
||||
if (exp >= 0n) return frac(n.n ** exp, n.d ** exp);
|
||||
if (n.n === 0n) return mkErr("#DIV/0!");
|
||||
return frac(n.d ** -exp, n.n ** -exp);
|
||||
}
|
||||
return fromDouble(Math.pow(fracToDouble(n), fracToDouble(p)));
|
||||
},
|
||||
},
|
||||
SQRT: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
if (cmp(n, ZERO) < 0) return mkErr("#NUM!");
|
||||
return fromDouble(Math.sqrt(fracToDouble(n)));
|
||||
},
|
||||
},
|
||||
PI: { min: 0, max: 0, call: () => fromDouble(Math.PI) },
|
||||
SIN: unaryDouble(Math.sin),
|
||||
COS: unaryDouble(Math.cos),
|
||||
TAN: unaryDouble(Math.tan),
|
||||
ASIN: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const x = fracToDouble(n);
|
||||
if (x < -1 || x > 1) return mkErr("#NUM!");
|
||||
return fromDouble(Math.asin(x));
|
||||
},
|
||||
},
|
||||
ACOS: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const x = fracToDouble(n);
|
||||
if (x < -1 || x > 1) return mkErr("#NUM!");
|
||||
return fromDouble(Math.acos(x));
|
||||
},
|
||||
},
|
||||
ATAN: unaryDouble(Math.atan),
|
||||
RADIANS: unaryDouble((x) => (x * Math.PI) / 180),
|
||||
DEGREES: unaryDouble((x) => (x * 180) / Math.PI),
|
||||
MIN: {
|
||||
min: 0,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
if (isErr(values)) return values;
|
||||
return values.length === 0 ? ZERO : values.reduce((a, b) => (cmp(a, b) <= 0 ? a : b));
|
||||
},
|
||||
},
|
||||
MAX: {
|
||||
min: 0,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
if (isErr(values)) return values;
|
||||
return values.length === 0 ? ZERO : values.reduce((a, b) => (cmp(a, b) >= 0 ? a : b));
|
||||
},
|
||||
},
|
||||
AVERAGE: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
if (isErr(values)) return values;
|
||||
if (values.length === 0) return mkErr("#DIV/0!");
|
||||
return div(values.reduce(add, ZERO), frac(BigInt(values.length)));
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,527 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_functions_misc.ts (주인 B)
|
||||
* 논리 · 글 · 찾기 · 세기 함수. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
|
||||
* 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
ZERO,
|
||||
add,
|
||||
cmp,
|
||||
frac,
|
||||
mul,
|
||||
parseDecimal,
|
||||
roundAt,
|
||||
toInteger,
|
||||
type Frac,
|
||||
} from "@ui/sheet/ui_template_sheet_frac";
|
||||
import type { ErrorValue, LazyArg, RangeValue, Scalar, SheetFunction } from "./spreadsheet_types";
|
||||
import { formatValue } from "./spreadsheet_numfmt";
|
||||
import {
|
||||
bool,
|
||||
collectNumbers,
|
||||
fracToDouble,
|
||||
isErr,
|
||||
isFrac,
|
||||
isRange,
|
||||
mkErr,
|
||||
num,
|
||||
str,
|
||||
toBool,
|
||||
toNumber,
|
||||
toScalar,
|
||||
toText,
|
||||
} from "./spreadsheet_functions_math";
|
||||
|
||||
const ONE = frac(1n);
|
||||
|
||||
// ── 값 견줌(찾기 · 세기 함수 공용) ───────────────────────────────────────────
|
||||
|
||||
function typeRank(v: Scalar): number {
|
||||
if (v === null) return -1;
|
||||
if (isFrac(v)) return 0;
|
||||
if (typeof v === "string") return 1;
|
||||
if (typeof v === "boolean") return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function compareScalars(a: Scalar, b: Scalar): number {
|
||||
if (isFrac(a) && isFrac(b)) return cmp(a, b);
|
||||
if (typeof a === "string" && typeof b === "string") {
|
||||
const la = a.toUpperCase();
|
||||
const lb = b.toUpperCase();
|
||||
return la < lb ? -1 : la > lb ? 1 : 0;
|
||||
}
|
||||
if (typeof a === "boolean" && typeof b === "boolean") return a === b ? 0 : a ? 1 : -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function equalScalars(a: Scalar, b: Scalar): boolean {
|
||||
return typeRank(a) === typeRank(b) && compareScalars(a, b) === 0;
|
||||
}
|
||||
|
||||
function wrapScalar(v: Scalar): RangeValue {
|
||||
return { kind: "range", rows: 1, cols: 1, at: () => v };
|
||||
}
|
||||
|
||||
// ── 조건 글(`>10` · `<=5` · `사과*`) ─────────────────────────────────────────
|
||||
|
||||
function wildcardToRegex(text: string): RegExp {
|
||||
const escaped = text
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".");
|
||||
return new RegExp(`^${escaped}$`, "i");
|
||||
}
|
||||
|
||||
function parseCriteria(crit: Scalar): (cell: Scalar) => boolean {
|
||||
if (crit === null) return (cell) => cell === null;
|
||||
if (typeof crit === "boolean") return (cell) => typeof cell === "boolean" && cell === crit;
|
||||
if (isFrac(crit)) return (cell) => isFrac(cell) && cmp(cell, crit) === 0;
|
||||
if (isErr(crit)) return () => false;
|
||||
const m = /^(<=|>=|<>|<|>|=)?(.*)$/.exec(crit);
|
||||
const op = m?.[1] ?? "";
|
||||
const rest = m?.[2] ?? crit;
|
||||
let numRest: Frac | null = null;
|
||||
try {
|
||||
numRest = rest.trim() === "" ? null : parseDecimal(rest);
|
||||
} catch {
|
||||
numRest = null;
|
||||
}
|
||||
if (numRest !== null) {
|
||||
const nr = numRest;
|
||||
return (cell) => {
|
||||
if (!isFrac(cell)) return false;
|
||||
const c = cmp(cell, nr);
|
||||
if (op === "<=") return c <= 0;
|
||||
if (op === ">=") return c >= 0;
|
||||
if (op === "<>") return c !== 0;
|
||||
if (op === "<") return c < 0;
|
||||
if (op === ">") return c > 0;
|
||||
return c === 0;
|
||||
};
|
||||
}
|
||||
const pat = wildcardToRegex(rest);
|
||||
if (op === "<>") return (cell) => !(typeof cell === "string" && pat.test(cell));
|
||||
return (cell) => typeof cell === "string" && pat.test(cell);
|
||||
}
|
||||
|
||||
// ── 숫자 → 고정 소수 글(FIXED) ───────────────────────────────────────────────
|
||||
|
||||
function groupThousands(digits: string): string {
|
||||
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
function formatFixed(n: Frac, decimals: number, commas: boolean): string {
|
||||
const d = Math.max(0, decimals);
|
||||
const rounded = roundAt(n, d, "round");
|
||||
const negative = cmp(rounded, ZERO) < 0;
|
||||
const magnitude = negative ? frac(-rounded.n, rounded.d) : rounded;
|
||||
const scaled = toInteger(mul(magnitude, frac(10n ** BigInt(d))), "round");
|
||||
const digits = scaled.toString().padStart(d + 1, "0");
|
||||
let whole = d > 0 ? digits.slice(0, -d) : digits;
|
||||
const fraction = d > 0 ? digits.slice(-d) : "";
|
||||
if (commas) whole = groupThousands(whole);
|
||||
const body = fraction ? `${whole}.${fraction}` : whole;
|
||||
return negative ? `-${body}` : body;
|
||||
}
|
||||
|
||||
// ── 함수 표 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function collectBools(args: LazyArg[]): boolean[] | ErrorValue {
|
||||
const out: boolean[] = [];
|
||||
for (const a of args) {
|
||||
const v = a();
|
||||
if (isRange(v)) {
|
||||
for (let r = 0; r < v.rows; r++) {
|
||||
for (let c = 0; c < v.cols; c++) {
|
||||
const cell = v.at(r, c);
|
||||
if (isErr(cell)) return cell;
|
||||
if (typeof cell === "boolean") out.push(cell);
|
||||
else if (isFrac(cell)) out.push(cmp(cell, ZERO) !== 0);
|
||||
}
|
||||
}
|
||||
} else if (isErr(v)) {
|
||||
return v;
|
||||
} else if (v !== null) {
|
||||
const b = toBool(v);
|
||||
if (isErr(b)) return b;
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const MISC_FUNCTIONS: Record<string, SheetFunction> = {
|
||||
IF: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const c = bool(args[0]());
|
||||
if (isErr(c)) return c;
|
||||
if (c) return args[1]();
|
||||
return args.length > 2 ? args[2]() : false;
|
||||
},
|
||||
},
|
||||
AND: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const bs = collectBools(args);
|
||||
if (isErr(bs)) return bs;
|
||||
if (bs.length === 0) return mkErr("#VALUE!");
|
||||
return bs.every(Boolean);
|
||||
},
|
||||
},
|
||||
OR: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const bs = collectBools(args);
|
||||
if (isErr(bs)) return bs;
|
||||
if (bs.length === 0) return mkErr("#VALUE!");
|
||||
return bs.some(Boolean);
|
||||
},
|
||||
},
|
||||
NOT: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const b = bool(args[0]());
|
||||
return isErr(b) ? b : !b;
|
||||
},
|
||||
},
|
||||
IFERROR: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const v = args[0]();
|
||||
if (!isRange(v) && isErr(v)) return args[1]();
|
||||
return v;
|
||||
},
|
||||
},
|
||||
CONCATENATE: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
let out = "";
|
||||
for (const a of args) {
|
||||
const v = a();
|
||||
if (isRange(v)) {
|
||||
for (let r = 0; r < v.rows; r++) {
|
||||
for (let c = 0; c < v.cols; c++) {
|
||||
const t = toText(v.at(r, c));
|
||||
if (isErr(t)) return t;
|
||||
out += t;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const t = str(v);
|
||||
if (isErr(t)) return t;
|
||||
out += t;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
},
|
||||
FIXED: {
|
||||
min: 1,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const decArg = args.length > 1 ? num(args[1]()) : frac(2n);
|
||||
if (isErr(decArg)) return decArg;
|
||||
const decimals = Math.trunc(fracToDouble(decArg));
|
||||
const noCommaArg = args.length > 2 ? bool(args[2]()) : false;
|
||||
if (isErr(noCommaArg)) return noCommaArg;
|
||||
return formatFixed(n, decimals, !noCommaArg);
|
||||
},
|
||||
},
|
||||
TEXT: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const v = toScalar(args[0]());
|
||||
if (isErr(v)) return v;
|
||||
const code = str(args[1]());
|
||||
if (isErr(code)) return code;
|
||||
return formatValue(v, code).글;
|
||||
},
|
||||
},
|
||||
LEN: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const s = str(args[0]());
|
||||
return isErr(s) ? s : frac(BigInt(s.length));
|
||||
},
|
||||
},
|
||||
LEFT: {
|
||||
min: 1,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const s = str(args[0]());
|
||||
if (isErr(s)) return s;
|
||||
const nArg = args.length > 1 ? num(args[1]()) : ONE;
|
||||
if (isErr(nArg)) return nArg;
|
||||
const n = Math.max(0, Math.trunc(fracToDouble(nArg)));
|
||||
return s.slice(0, n);
|
||||
},
|
||||
},
|
||||
RIGHT: {
|
||||
min: 1,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const s = str(args[0]());
|
||||
if (isErr(s)) return s;
|
||||
const nArg = args.length > 1 ? num(args[1]()) : ONE;
|
||||
if (isErr(nArg)) return nArg;
|
||||
const n = Math.max(0, Math.trunc(fracToDouble(nArg)));
|
||||
return n === 0 ? "" : s.slice(-n);
|
||||
},
|
||||
},
|
||||
MID: {
|
||||
min: 3,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const s = str(args[0]());
|
||||
if (isErr(s)) return s;
|
||||
const startN = num(args[1]());
|
||||
if (isErr(startN)) return startN;
|
||||
const lenN = num(args[2]());
|
||||
if (isErr(lenN)) return lenN;
|
||||
const start = Math.trunc(fracToDouble(startN));
|
||||
const len = Math.max(0, Math.trunc(fracToDouble(lenN)));
|
||||
if (start < 1) return mkErr("#VALUE!");
|
||||
return s.slice(start - 1, start - 1 + len);
|
||||
},
|
||||
},
|
||||
VALUE: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const s = str(args[0]());
|
||||
if (isErr(s)) return s;
|
||||
const trimmed = s.trim();
|
||||
const pct = trimmed.endsWith("%");
|
||||
const body = pct ? trimmed.slice(0, -1) : trimmed;
|
||||
try {
|
||||
const v = parseDecimal(body);
|
||||
return pct ? { n: v.n, d: v.d * 100n } : v;
|
||||
} catch {
|
||||
return mkErr("#VALUE!", `글을 수로 못 읽음: ${s}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
VLOOKUP: {
|
||||
min: 3,
|
||||
max: 4,
|
||||
call: (args) => {
|
||||
const lookup = toScalar(args[0]());
|
||||
if (isErr(lookup)) return lookup;
|
||||
const table = args[1]();
|
||||
if (!isRange(table)) return mkErr("#VALUE!");
|
||||
const colN = num(args[2]());
|
||||
if (isErr(colN)) return colN;
|
||||
const col = Math.trunc(fracToDouble(colN)) - 1;
|
||||
if (col < 0 || col >= table.cols) return mkErr("#REF!");
|
||||
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
|
||||
if (isErr(approxRaw)) return approxRaw;
|
||||
if (approxRaw) {
|
||||
let best = -1;
|
||||
for (let r = 0; r < table.rows; r++) {
|
||||
const key = table.at(r, 0);
|
||||
if (typeRank(key) !== typeRank(lookup)) {
|
||||
if (best >= 0) break;
|
||||
continue;
|
||||
}
|
||||
if (compareScalars(key, lookup) <= 0) best = r;
|
||||
else break;
|
||||
}
|
||||
if (best < 0) return mkErr("#N/A");
|
||||
return table.at(best, col);
|
||||
}
|
||||
for (let r = 0; r < table.rows; r++) {
|
||||
if (equalScalars(table.at(r, 0), lookup)) return table.at(r, col);
|
||||
}
|
||||
return mkErr("#N/A");
|
||||
},
|
||||
},
|
||||
HLOOKUP: {
|
||||
min: 3,
|
||||
max: 4,
|
||||
call: (args) => {
|
||||
const lookup = toScalar(args[0]());
|
||||
if (isErr(lookup)) return lookup;
|
||||
const table = args[1]();
|
||||
if (!isRange(table)) return mkErr("#VALUE!");
|
||||
const rowN = num(args[2]());
|
||||
if (isErr(rowN)) return rowN;
|
||||
const row = Math.trunc(fracToDouble(rowN)) - 1;
|
||||
if (row < 0 || row >= table.rows) return mkErr("#REF!");
|
||||
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
|
||||
if (isErr(approxRaw)) return approxRaw;
|
||||
if (approxRaw) {
|
||||
let best = -1;
|
||||
for (let c = 0; c < table.cols; c++) {
|
||||
const key = table.at(0, c);
|
||||
if (typeRank(key) !== typeRank(lookup)) {
|
||||
if (best >= 0) break;
|
||||
continue;
|
||||
}
|
||||
if (compareScalars(key, lookup) <= 0) best = c;
|
||||
else break;
|
||||
}
|
||||
if (best < 0) return mkErr("#N/A");
|
||||
return table.at(row, best);
|
||||
}
|
||||
for (let c = 0; c < table.cols; c++) {
|
||||
if (equalScalars(table.at(0, c), lookup)) return table.at(row, c);
|
||||
}
|
||||
return mkErr("#N/A");
|
||||
},
|
||||
},
|
||||
INDEX: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const rangeArg = args[0]();
|
||||
if (isErr(rangeArg)) return rangeArg;
|
||||
const range = isRange(rangeArg) ? rangeArg : wrapScalar(rangeArg);
|
||||
const rowN = num(args[1]());
|
||||
if (isErr(rowN)) return rowN;
|
||||
const row = Math.trunc(fracToDouble(rowN));
|
||||
const colN = args.length > 2 ? num(args[2]()) : frac(range.cols === 1 ? 1n : 0n);
|
||||
if (isErr(colN)) return colN;
|
||||
const col = Math.trunc(fracToDouble(colN));
|
||||
if (row === 0 && col === 0) return mkErr("#VALUE!");
|
||||
if (row === 0) {
|
||||
if (col < 1 || col > range.cols) return mkErr("#REF!");
|
||||
const c = col - 1;
|
||||
return { kind: "range", rows: range.rows, cols: 1, at: (r: number) => range.at(r, c) };
|
||||
}
|
||||
if (col === 0) {
|
||||
if (row < 1 || row > range.rows) return mkErr("#REF!");
|
||||
const r = row - 1;
|
||||
return {
|
||||
kind: "range",
|
||||
rows: 1,
|
||||
cols: range.cols,
|
||||
at: (_r: number, c: number) => range.at(r, c),
|
||||
};
|
||||
}
|
||||
if (row < 1 || row > range.rows || col < 1 || col > range.cols) return mkErr("#REF!");
|
||||
return range.at(row - 1, col - 1);
|
||||
},
|
||||
},
|
||||
MATCH: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const lookup = toScalar(args[0]());
|
||||
if (isErr(lookup)) return lookup;
|
||||
const rangeArg = args[1]();
|
||||
if (!isRange(rangeArg)) return mkErr("#VALUE!");
|
||||
if (rangeArg.rows !== 1 && rangeArg.cols !== 1) {
|
||||
return mkErr("#VALUE!", "한 줄 · 한 칸짜리 범위만");
|
||||
}
|
||||
const n = rangeArg.rows === 1 ? rangeArg.cols : rangeArg.rows;
|
||||
const at = (i: number): Scalar =>
|
||||
rangeArg.rows === 1 ? rangeArg.at(0, i) : rangeArg.at(i, 0);
|
||||
const modeN = args.length > 2 ? num(args[2]()) : ONE;
|
||||
if (isErr(modeN)) return modeN;
|
||||
const mode = Math.trunc(fracToDouble(modeN));
|
||||
if (mode === 0) {
|
||||
for (let i = 0; i < n; i++) if (equalScalars(at(i), lookup)) return frac(BigInt(i + 1));
|
||||
return mkErr("#N/A");
|
||||
}
|
||||
let best = -1;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const key = at(i);
|
||||
if (typeRank(key) !== typeRank(lookup)) {
|
||||
if (best >= 0) break;
|
||||
continue;
|
||||
}
|
||||
const c = compareScalars(key, lookup);
|
||||
if (mode > 0 ? c <= 0 : c >= 0) best = i;
|
||||
else break;
|
||||
}
|
||||
if (best < 0) return mkErr("#N/A");
|
||||
return frac(BigInt(best + 1));
|
||||
},
|
||||
},
|
||||
CHOOSE: {
|
||||
min: 2,
|
||||
call: (args) => {
|
||||
const idxN = num(args[0]());
|
||||
if (isErr(idxN)) return idxN;
|
||||
const idx = Math.trunc(fracToDouble(idxN));
|
||||
if (idx < 1 || idx > args.length - 1) return mkErr("#VALUE!");
|
||||
return args[idx]();
|
||||
},
|
||||
},
|
||||
COUNT: {
|
||||
min: 0,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, false, false);
|
||||
return isErr(values) ? values : frac(BigInt(values.length));
|
||||
},
|
||||
},
|
||||
COUNTA: {
|
||||
min: 0,
|
||||
call: (args) => {
|
||||
let count = 0;
|
||||
for (const a of args) {
|
||||
const v = a();
|
||||
if (isRange(v)) {
|
||||
for (let r = 0; r < v.rows; r++) {
|
||||
for (let c = 0; c < v.cols; c++) if (v.at(r, c) !== null) count++;
|
||||
}
|
||||
} else if (v !== null) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return frac(BigInt(count));
|
||||
},
|
||||
},
|
||||
COUNTIF: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const crit = toScalar(args[1]());
|
||||
if (isErr(crit)) return crit;
|
||||
const pred = parseCriteria(crit);
|
||||
let count = 0;
|
||||
for (let r = 0; r < range.rows; r++) {
|
||||
for (let c = 0; c < range.cols; c++) if (pred(range.at(r, c))) count++;
|
||||
}
|
||||
return frac(BigInt(count));
|
||||
},
|
||||
},
|
||||
SUMIF: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const crit = toScalar(args[1]());
|
||||
if (isErr(crit)) return crit;
|
||||
const pred = parseCriteria(crit);
|
||||
const sumRange = args.length > 2 ? args[2]() : range;
|
||||
if (!isRange(sumRange)) return mkErr("#VALUE!");
|
||||
if (sumRange.rows !== range.rows || sumRange.cols !== range.cols) {
|
||||
return mkErr("#VALUE!", "합 범위 크기가 다름");
|
||||
}
|
||||
let total = ZERO;
|
||||
for (let r = 0; r < range.rows; r++) {
|
||||
for (let c = 0; c < range.cols; c++) {
|
||||
if (!pred(range.at(r, c))) continue;
|
||||
const n = toNumber(sumRange.at(r, c));
|
||||
if (isErr(n)) return n;
|
||||
total = add(total, n);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,17 +1,359 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_graph.ts (주인 A)
|
||||
* 계산 엔진 — 식마다 가리키는 칸 · 범위로 의존 그래프 · 바뀐 칸에 딸린 칸만 차례로 다시 풂.
|
||||
* 순환은 고리의 칸마다 `#CYCLE!`(까닭 = 고리 칸 목록) · 반복 계산 안 함 · 나머지 칸은 계속 풂.
|
||||
* 식 나무는 식 글마다 캐시. 0 계약 머리 — 몸은 A 가 채움.
|
||||
* 순환은 고리의 칸마다 `#CYCLE!`(까닭 = 고리 칸 목록) · 반복 계산 안 함 · 나머지 칸은 계속 풂
|
||||
* (고리를 읽는 칸은 오류 전파로 `#CYCLE!`). 식 나무는 식 글마다 캐시.
|
||||
* 풀이는 필요할 때 끌어 풂(앞 칸이 안 풀렸으면 먼저) — 차례는 저절로 위상 순서.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CalcEngine, Workbook } from "./spreadsheet_types";
|
||||
import { FormulaError, ZERO } from "@ui/sheet/ui_template_sheet_frac";
|
||||
import { cellId, fromCellId, inRange, parseA1, toA1 } from "./spreadsheet_address";
|
||||
import { err, evaluate, inputValue, intersect, toCalcValue } from "./spreadsheet_eval";
|
||||
import type { PlacedRange } from "./spreadsheet_eval";
|
||||
import { parseFormula } from "./spreadsheet_parser";
|
||||
import type {
|
||||
CalcEngine,
|
||||
CalcValues,
|
||||
Cell,
|
||||
CellRange,
|
||||
EvalContext,
|
||||
EvalResult,
|
||||
FormulaNode,
|
||||
ParseResult,
|
||||
Scalar,
|
||||
SheetCellAddress,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet_graph: 아직 없음(A)");
|
||||
};
|
||||
interface SheetIndex {
|
||||
id: string;
|
||||
cells: Map<number, Cell>;
|
||||
/** 값 든 칸의 가장 먼 행 · 열 — 행 · 열 전체 범위를 여기까지만 읽음 */
|
||||
maxR: number;
|
||||
maxC: number;
|
||||
}
|
||||
|
||||
interface Dep {
|
||||
sheet: string;
|
||||
range: CellRange;
|
||||
}
|
||||
|
||||
const DONE = 2;
|
||||
const ACTIVE = 1;
|
||||
|
||||
const keyOf = (sheet: string, id: number) => `${sheet}!${id}`;
|
||||
|
||||
function splitKey(key: string): SheetCellAddress {
|
||||
const i = key.lastIndexOf("!");
|
||||
return { 시트: key.slice(0, i), ...fromCellId(Number(key.slice(i + 1))) };
|
||||
}
|
||||
|
||||
/** 나무 안 칸 · 범위 참조(시트 이름 → id · 없는 시트는 뺌) */
|
||||
function collectRefs(
|
||||
node: FormulaNode,
|
||||
host: string,
|
||||
sheetId: (n: string) => string | null,
|
||||
out: Dep[],
|
||||
) {
|
||||
switch (node.type) {
|
||||
case "cell":
|
||||
case "range": {
|
||||
const sheet = node.sheet === null ? host : sheetId(node.sheet);
|
||||
if (sheet === null) return;
|
||||
const [a, b] = node.type === "cell" ? [node.ref, node.ref] : [node.from, node.to];
|
||||
out.push({
|
||||
sheet,
|
||||
range: {
|
||||
r0: Math.min(a.r, b.r),
|
||||
c0: Math.min(a.c, b.c),
|
||||
r1: Math.max(a.r, b.r),
|
||||
c1: Math.max(a.c, b.c),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "unary":
|
||||
return collectRefs(node.arg, host, sheetId, out);
|
||||
case "binary":
|
||||
collectRefs(node.left, host, sheetId, out);
|
||||
return collectRefs(node.right, host, sheetId, out);
|
||||
case "call":
|
||||
for (const a of node.args) collectRefs(a, host, sheetId, out);
|
||||
}
|
||||
}
|
||||
|
||||
/** 문서를 참조로 쥠 — 만들 때 전부 풂. */
|
||||
export function createCalcEngine(_book: Workbook): CalcEngine {
|
||||
return todo();
|
||||
export function createCalcEngine(book: Workbook): CalcEngine {
|
||||
const parsed = new Map<string, ParseResult>();
|
||||
let sheets = new Map<string, SheetIndex>();
|
||||
let names = new Map<string, string>();
|
||||
/** 식 칸 → 가리키는 칸 · 범위 */
|
||||
let precs = new Map<string, Dep[]>();
|
||||
/** 칸 하나 → 그 칸을 가리키는 식 칸 */
|
||||
let cellDeps = new Map<string, Set<string>>();
|
||||
/** 시트 → 범위를 가리키는 식 칸 */
|
||||
let rangeDeps = new Map<string, Map<string, CellRange[]>>();
|
||||
let vals = new Map<string, Scalar>();
|
||||
let state = new Map<string, number>();
|
||||
const stack: string[] = [];
|
||||
const cycle = new Map<string, string>();
|
||||
|
||||
const sheetId = (name: string) => names.get(name.toLowerCase()) ?? null;
|
||||
|
||||
const parse = (text: string) => {
|
||||
let p = parsed.get(text);
|
||||
if (!p) parsed.set(text, (p = parseFormula(text)));
|
||||
return p;
|
||||
};
|
||||
|
||||
function unlink(key: string) {
|
||||
for (const d of precs.get(key) ?? []) {
|
||||
if (d.range.r0 === d.range.r1 && d.range.c0 === d.range.c1)
|
||||
cellDeps.get(keyOf(d.sheet, cellId(d.range.r0, d.range.c0)))?.delete(key);
|
||||
else rangeDeps.get(d.sheet)?.delete(key);
|
||||
}
|
||||
precs.delete(key);
|
||||
}
|
||||
|
||||
/** 칸 하나를 색인에 올림(식이면 가리키는 곳 잇기) */
|
||||
function index(sheet: SheetIndex, id: number, cell: Cell | undefined) {
|
||||
const key = keyOf(sheet.id, id);
|
||||
unlink(key);
|
||||
vals.delete(key);
|
||||
state.delete(key);
|
||||
cycle.delete(key);
|
||||
if (!cell || (cell.식 === undefined && cell.값 === undefined)) {
|
||||
sheet.cells.delete(id);
|
||||
return;
|
||||
}
|
||||
sheet.cells.set(id, cell);
|
||||
const { r, c } = fromCellId(id);
|
||||
sheet.maxR = Math.max(sheet.maxR, r);
|
||||
sheet.maxC = Math.max(sheet.maxC, c);
|
||||
if (cell.식 === undefined) {
|
||||
vals.set(key, inputValue(cell.값!));
|
||||
return;
|
||||
}
|
||||
const p = parse(cell.식);
|
||||
const deps: Dep[] = [];
|
||||
if (p.ok) collectRefs(p.node, sheet.id, sheetId, deps);
|
||||
precs.set(key, deps);
|
||||
for (const d of deps) {
|
||||
if (d.range.r0 === d.range.r1 && d.range.c0 === d.range.c1) {
|
||||
const k = keyOf(d.sheet, cellId(d.range.r0, d.range.c0));
|
||||
let set = cellDeps.get(k);
|
||||
if (!set) cellDeps.set(k, (set = new Set()));
|
||||
set.add(key);
|
||||
} else {
|
||||
let m = rangeDeps.get(d.sheet);
|
||||
if (!m) rangeDeps.set(d.sheet, (m = new Map()));
|
||||
let list = m.get(key);
|
||||
if (!list) m.set(key, (list = []));
|
||||
list.push(d.range);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rebuild(next: Workbook) {
|
||||
book = next;
|
||||
sheets = new Map();
|
||||
names = new Map();
|
||||
precs = new Map();
|
||||
cellDeps = new Map();
|
||||
rangeDeps = new Map();
|
||||
vals = new Map();
|
||||
state = new Map();
|
||||
cycle.clear();
|
||||
for (const s of book.시트) {
|
||||
sheets.set(s.id, { id: s.id, cells: new Map(), maxR: 0, maxC: 0 });
|
||||
names.set(s.이름.toLowerCase(), s.id);
|
||||
}
|
||||
for (const s of book.시트) {
|
||||
const idx = sheets.get(s.id)!;
|
||||
for (const [a1, cell] of Object.entries(s.칸)) {
|
||||
const at = parseA1(a1);
|
||||
if (at) index(idx, cellId(at.r, at.c), cell);
|
||||
}
|
||||
}
|
||||
for (const key of precs.keys()) ensure(key);
|
||||
}
|
||||
|
||||
function ctxFor(sheet: string, r: number, c: number): EvalContext {
|
||||
return { sheet, r, c, cell: read, range, sheetId };
|
||||
}
|
||||
|
||||
function read(sheet: string, r: number, c: number): Scalar {
|
||||
const key = keyOf(sheet, cellId(r, c));
|
||||
if (precs.has(key)) return compute(key);
|
||||
return vals.get(key) ?? null;
|
||||
}
|
||||
|
||||
function range(sheet: string, rg: CellRange): PlacedRange {
|
||||
const s = sheets.get(sheet);
|
||||
// ponytail: 행 · 열 전체 범위는 값 든 끝까지만 — INDEX(A:A, 끝 너머)는 #REF! 로 갈림
|
||||
const r1 = Math.min(rg.r1, Math.max(rg.r0, s?.maxR ?? 0));
|
||||
const c1 = Math.min(rg.c1, Math.max(rg.c0, s?.maxC ?? 0));
|
||||
return {
|
||||
kind: "range",
|
||||
rows: r1 - rg.r0 + 1,
|
||||
cols: c1 - rg.c0 + 1,
|
||||
at: (i, j) => read(sheet, rg.r0 + i, rg.c0 + j),
|
||||
...rg,
|
||||
};
|
||||
}
|
||||
|
||||
/** 가리키는 식 칸들(범위는 넓이 · 든 칸 수 중 작은 쪽으로 훑음) */
|
||||
function formulaPrecs(key: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const d of precs.get(key) ?? []) {
|
||||
const s = sheets.get(d.sheet);
|
||||
if (!s) continue;
|
||||
const r1 = Math.min(d.range.r1, s.maxR);
|
||||
const c1 = Math.min(d.range.c1, s.maxC);
|
||||
if ((r1 - d.range.r0 + 1) * (c1 - d.range.c0 + 1) <= s.cells.size) {
|
||||
for (let r = d.range.r0; r <= r1; r++)
|
||||
for (let c = d.range.c0; c <= c1; c++) {
|
||||
const k = keyOf(d.sheet, cellId(r, c));
|
||||
if (precs.has(k)) out.push(k);
|
||||
}
|
||||
} else
|
||||
for (const id of s.cells.keys()) {
|
||||
const k = keyOf(d.sheet, id);
|
||||
const { r, c } = fromCellId(id);
|
||||
if (precs.has(k) && inRange(d.range, r, c)) out.push(k);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 앞 식 칸부터 반복으로 풂 — 깊은 사슬에도 스택이 안 넘침 · 고리는 compute 가 잡음 */
|
||||
function ensure(key: string): Scalar {
|
||||
const todo: [string, boolean][] = [[key, false]];
|
||||
const seen = new Set<string>();
|
||||
while (todo.length) {
|
||||
const [k, ready] = todo.pop()!;
|
||||
if (state.get(k) === DONE) continue;
|
||||
if (ready) {
|
||||
compute(k);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
todo.push([k, true]);
|
||||
for (const d of formulaPrecs(k))
|
||||
if (!seen.has(d) && state.get(d) !== DONE) todo.push([d, false]);
|
||||
}
|
||||
return vals.get(key) ?? null;
|
||||
}
|
||||
|
||||
/** 식 칸 하나 풂 — 풀이 중인 칸을 다시 만나면 그 사이가 고리 */
|
||||
function compute(key: string): Scalar {
|
||||
const st = state.get(key);
|
||||
if (st === DONE) return vals.get(key) ?? null;
|
||||
if (st === ACTIVE) {
|
||||
const ring = stack.slice(stack.indexOf(key));
|
||||
const why = `순환 참조: ${ring.map(label).join(" → ")} → ${label(key)}`;
|
||||
for (const k of ring) if (!cycle.has(k)) cycle.set(k, why);
|
||||
return err("#CYCLE!", why);
|
||||
}
|
||||
state.set(key, ACTIVE);
|
||||
stack.push(key);
|
||||
const { 시트, r, c } = splitKey(key);
|
||||
const cell = sheets.get(시트)!.cells.get(cellId(r, c))!;
|
||||
let v: Scalar;
|
||||
try {
|
||||
v = run(cell.식!, ctxFor(시트, r, c));
|
||||
} catch (e) {
|
||||
state.delete(key);
|
||||
throw e;
|
||||
} finally {
|
||||
stack.pop();
|
||||
}
|
||||
const why = cycle.get(key);
|
||||
if (why) v = err("#CYCLE!", why);
|
||||
vals.set(key, v);
|
||||
state.set(key, DONE);
|
||||
return v;
|
||||
}
|
||||
|
||||
function label(key: string) {
|
||||
const { 시트, r, c } = splitKey(key);
|
||||
const s = book.시트.find((x) => x.id === 시트);
|
||||
return book.시트.length > 1 && s ? `${s.이름}!${toA1(r, c)}` : toA1(r, c);
|
||||
}
|
||||
|
||||
function run(text: string, ctx: EvalContext): Scalar {
|
||||
const p = parse(text);
|
||||
if (!p.ok) return err("#NAME?", `식을 못 읽음: ${p.message} (${p.at + 1}번째 글자)`);
|
||||
let v: EvalResult;
|
||||
try {
|
||||
v = evaluate(p.node, ctx);
|
||||
} catch (e) {
|
||||
if (e instanceof FormulaError && e.message.startsWith("0 으로"))
|
||||
return err("#DIV/0!", e.message);
|
||||
return err("#VALUE!", e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
const one = intersect(v, ctx);
|
||||
return one === null ? ZERO : one;
|
||||
}
|
||||
|
||||
function dependents(key: string, out: Set<string>) {
|
||||
for (const k of cellDeps.get(key) ?? []) out.add(k);
|
||||
const { 시트, r, c } = splitKey(key);
|
||||
for (const [k, list] of rangeDeps.get(시트) ?? [])
|
||||
if (list.some((rg) => inRange(rg, r, c))) out.add(k);
|
||||
}
|
||||
|
||||
rebuild(book);
|
||||
|
||||
return {
|
||||
update(changed) {
|
||||
const dirty = new Set<string>();
|
||||
for (const a of changed) {
|
||||
const s = sheets.get(a.시트);
|
||||
const sheet = book.시트.find((x) => x.id === a.시트);
|
||||
if (!s || !sheet) continue;
|
||||
const id = cellId(a.r, a.c);
|
||||
index(s, id, sheet.칸[toA1(a.r, a.c)]);
|
||||
dirty.add(keyOf(a.시트, id));
|
||||
}
|
||||
// 딸린 칸을 끝까지 모음(고리였던 칸도 다시)
|
||||
const queue = [...dirty];
|
||||
while (queue.length) {
|
||||
const next = new Set<string>();
|
||||
dependents(queue.pop()!, next);
|
||||
for (const k of next)
|
||||
if (!dirty.has(k)) {
|
||||
dirty.add(k);
|
||||
queue.push(k);
|
||||
}
|
||||
}
|
||||
for (const k of dirty)
|
||||
if (precs.has(k)) {
|
||||
state.delete(k);
|
||||
cycle.delete(k);
|
||||
}
|
||||
for (const k of dirty) if (precs.has(k)) ensure(k);
|
||||
return [...dirty].map(splitKey);
|
||||
},
|
||||
rebuild,
|
||||
value(sheet, r, c) {
|
||||
const key = keyOf(sheet, cellId(r, c));
|
||||
return precs.has(key) ? ensure(key) : (vals.get(key) ?? null);
|
||||
},
|
||||
precedents(sheet, r, c) {
|
||||
return (precs.get(keyOf(sheet, cellId(r, c))) ?? []).map((d) => ({
|
||||
시트: d.sheet,
|
||||
범위: d.range,
|
||||
}));
|
||||
},
|
||||
snapshot() {
|
||||
const out: CalcValues = {};
|
||||
for (const key of precs.keys()) {
|
||||
const { 시트, r, c } = splitKey(key);
|
||||
(out[시트] ??= {})[toA1(r, c)] = toCalcValue(ensure(key));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_history.ts (주인 A)
|
||||
* 되돌리기 · 다시 — 명령과 되돌림 짝을 쌓음(메모리만 · 캐시에 안 넣음).
|
||||
* 0 계약 머리 — 몸은 A 가 채움.
|
||||
* undo() 는 되돌림 짝을 · redo() 는 원래 명령을 돌려줌 — 적용은 부른 쪽(`applyCommand`).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { History } from "./spreadsheet_types";
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet_history: 아직 없음(A)");
|
||||
};
|
||||
import type { Command, History } from "./spreadsheet_types";
|
||||
|
||||
/** `limit` = 쌓을 수(넘으면 오래된 것부터 버림) */
|
||||
export function createHistory(_limit?: number): History {
|
||||
return todo();
|
||||
export function createHistory(limit = 100): History {
|
||||
let done: [Command, Command][] = [];
|
||||
let undone: [Command, Command][] = [];
|
||||
return {
|
||||
push(command, undo) {
|
||||
done.push([command, undo]);
|
||||
if (done.length > limit) done.shift();
|
||||
undone = [];
|
||||
},
|
||||
undo() {
|
||||
const pair = done.pop();
|
||||
if (!pair) return null;
|
||||
undone.push(pair);
|
||||
return pair[1];
|
||||
},
|
||||
redo() {
|
||||
const pair = undone.pop();
|
||||
if (!pair) return null;
|
||||
done.push(pair);
|
||||
return pair[0];
|
||||
},
|
||||
canUndo: () => done.length > 0,
|
||||
canRedo: () => undone.length > 0,
|
||||
clear() {
|
||||
done = [];
|
||||
undone = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_keys.ts (주인 D)
|
||||
* 엑셀 단축키 지도 — 숨은 textarea · 수식 입력줄 keydown 하나로 받음.
|
||||
* 편집 중: Enter/Tab 확정 이동 · Alt+Enter 줄바꿈 · Ctrl+Enter 고름 전부 · Esc · F2 방식 바꿈 ·
|
||||
* enter 방식 방향키 = 확정 이동(식 참조 자리면 가리키기).
|
||||
* 고름 중: 방향(Shift 늘림 · Ctrl 끝) · Home · Ctrl+Home/End · PgUp/PgDn · Enter/Tab(고름 안 돌기) ·
|
||||
* F2 · Delete · Backspace · Ctrl+A · Shift/Ctrl+Space · Ctrl+Z/Y · Ctrl+D/R.
|
||||
* 글자 키는 막지 않음 — textarea 의 input · compositionstart 가 편집을 엶(한글 첫 글자).
|
||||
* 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { fillCells } from "./spreadsheet_fill";
|
||||
import {
|
||||
cycle,
|
||||
LAST_R,
|
||||
move,
|
||||
selectAll,
|
||||
selectCell,
|
||||
selectCols,
|
||||
selectRows,
|
||||
selectSpan,
|
||||
usedEnd,
|
||||
} from "./spreadsheet_selection";
|
||||
import type { Editor } from "./spreadsheet_editor";
|
||||
import type { Selection, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const ARROWS: Record<string, [number, number]> = {
|
||||
ArrowUp: [-1, 0],
|
||||
ArrowDown: [1, 0],
|
||||
ArrowLeft: [0, -1],
|
||||
ArrowRight: [0, 1],
|
||||
};
|
||||
|
||||
export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void {
|
||||
const go = (sel: Selection): void => ctx.select(sel, true);
|
||||
|
||||
/** 확정 뒤 한 칸 — 고름이 여러 칸이면 그 안에서 돎 */
|
||||
function next(dr: number, dc: number): void {
|
||||
const sheet = ctx.sheet();
|
||||
go(cycle(sheet, ctx.selection, dr, dc) ?? move(sheet, ctx.selection, dr, dc));
|
||||
}
|
||||
|
||||
function editingKey(e: KeyboardEvent, inBar: boolean): boolean {
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
switch (e.key) {
|
||||
case "Enter": {
|
||||
const node = e.target as HTMLTextAreaElement;
|
||||
if (e.altKey) {
|
||||
node.setRangeText("\n", node.selectionStart, node.selectionEnd, "end");
|
||||
node.dispatchEvent(new Event("input"));
|
||||
return true;
|
||||
}
|
||||
if (ctrl) return (ed.commitAll(), true);
|
||||
ed.commit();
|
||||
next(e.shiftKey ? -1 : 1, 0);
|
||||
return true;
|
||||
}
|
||||
case "Tab":
|
||||
ed.commit();
|
||||
next(0, e.shiftKey ? -1 : 1);
|
||||
return true;
|
||||
case "Escape":
|
||||
ed.cancel();
|
||||
return true;
|
||||
case "F2":
|
||||
ed.toggleMode();
|
||||
return true;
|
||||
}
|
||||
const arrow = ARROWS[e.key];
|
||||
if (!arrow || inBar || ed.mode() === "edit") return false;
|
||||
if (ed.pointable()) {
|
||||
ed.pointMove(arrow[0], arrow[1], e.shiftKey, ctrl);
|
||||
return true;
|
||||
}
|
||||
if (e.shiftKey || ctrl) return false; // 글자 고르기 · 낱말 건너기는 textarea 에 맡김
|
||||
ed.commit();
|
||||
go(move(ctx.sheet(), ctx.selection, arrow[0], arrow[1]));
|
||||
return true;
|
||||
}
|
||||
|
||||
function navKey(e: KeyboardEvent): boolean {
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
const sheet = ctx.sheet();
|
||||
const sel = ctx.selection;
|
||||
const arrow = ARROWS[e.key];
|
||||
if (arrow && !e.altKey) {
|
||||
go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl }));
|
||||
return true;
|
||||
}
|
||||
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
if (ctrl && !e.altKey) {
|
||||
switch (k) {
|
||||
case "z":
|
||||
e.shiftKey ? ctx.redo() : ctx.undo();
|
||||
return true;
|
||||
case "y":
|
||||
ctx.redo();
|
||||
return true;
|
||||
case "a":
|
||||
go(selectAll(sheet, sel.활성));
|
||||
return true;
|
||||
case "Home":
|
||||
go(e.shiftKey ? selectSpan(sheet, sel.기준, { r: 0, c: 0 }) : selectCell(sheet, 0, 0));
|
||||
return true;
|
||||
case "End": {
|
||||
const end = usedEnd(sheet);
|
||||
go(e.shiftKey ? selectSpan(sheet, sel.기준, end) : selectCell(sheet, end.r, end.c));
|
||||
return true;
|
||||
}
|
||||
case " ": {
|
||||
const g = sel.범위[0];
|
||||
go(selectCols(sheet, g.c0, g.c1, sel.활성.c));
|
||||
return true;
|
||||
}
|
||||
case "d":
|
||||
case "r":
|
||||
fill(k === "d");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
next(e.shiftKey ? -1 : 1, 0);
|
||||
return true;
|
||||
case "Tab":
|
||||
next(0, e.shiftKey ? -1 : 1);
|
||||
return true;
|
||||
case "F2":
|
||||
ed.begin();
|
||||
return true;
|
||||
case "Delete":
|
||||
if (!ctx.readOnly) ctx.dispatch({ 종류: "내용지움", 시트: sheet.id, 범위: sel.범위 });
|
||||
return true;
|
||||
case "Backspace":
|
||||
ed.begin("");
|
||||
return true;
|
||||
case "Home":
|
||||
go(
|
||||
e.shiftKey
|
||||
? selectSpan(sheet, sel.기준, { r: sel.기준.r, c: 0 })
|
||||
: selectCell(sheet, sel.활성.r, 0),
|
||||
);
|
||||
return true;
|
||||
case "PageDown":
|
||||
case "PageUp": {
|
||||
const v = ctx.grid.visibleRange();
|
||||
const n = Math.max(1, v.r1 - v.r0) * (e.key === "PageDown" ? 1 : -1);
|
||||
const r = Math.min(LAST_R, Math.max(0, sel.활성.r + n));
|
||||
go(selectCell(sheet, r, sel.활성.c));
|
||||
return true;
|
||||
}
|
||||
case " ":
|
||||
if (!e.shiftKey) return false;
|
||||
go(selectRows(sheet, sel.범위[0].r0, sel.범위[0].r1, sel.활성.r));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Ctrl+D 아래로 · Ctrl+R 오른쪽으로 — 고름 첫 행(열)을 나머지에 */
|
||||
function fill(down: boolean): void {
|
||||
if (ctx.readOnly) return;
|
||||
const sheet = ctx.sheet();
|
||||
const g = ctx.selection.범위[0];
|
||||
const src = down ? { ...g, r1: g.r0 } : { ...g, c1: g.c0 };
|
||||
if ((down ? g.r1 : g.c1) === (down ? src.r1 : src.c1)) return;
|
||||
ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: fillCells(ctx.book, sheet.id, src, g) });
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.isComposing || e.keyCode === 229) return; // 한글 조합 중 — 브라우저 몫
|
||||
const inBar = e.target === ed.formula;
|
||||
const done = ed.editing() ? editingKey(e, inBar) : !inBar && navKey(e);
|
||||
if (done) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
ed.ta.addEventListener("keydown", onKey);
|
||||
ed.formula.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
ed.ta.removeEventListener("keydown", onKey);
|
||||
ed.formula.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_mouse.ts (주인 D)
|
||||
* 마우스 — 칸 · 범위 끌기 · Shift 늘림 · 행열 머리 · 모서리(전체) · 식 편집 중 참조 가리키기 ·
|
||||
* 채우기 핸들 끌기(늘림 · 안으로 끌면 지움) · 두 번 누르기(칸 편집 · 핸들이면 옆 열 끝까지 아래로) ·
|
||||
* 우클릭 자리(고름 밖이면 그 칸을 고름 — 메뉴는 E).
|
||||
* 머리 끝(폭 · 높이 끌기)은 C 몫 — 여기서 건드리지 않음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { fillCells } from "./spreadsheet_fill";
|
||||
import {
|
||||
hasValue,
|
||||
inRange,
|
||||
LAST_C,
|
||||
LAST_R,
|
||||
selectAll,
|
||||
selectCell,
|
||||
selectCols,
|
||||
selectRows,
|
||||
selectSpan,
|
||||
} from "./spreadsheet_selection";
|
||||
import { boxStyle, type Editor } from "./spreadsheet_editor";
|
||||
import type { CellAddress, CellRange } from "./spreadsheet_types";
|
||||
import type { HitResult, Selection, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
/** 채우기 끌기 목표 — 늘림(fill) · 안으로 줄임(clear = 지울 부분) */
|
||||
export function fillTarget(
|
||||
src: CellRange,
|
||||
p: CellAddress,
|
||||
): { target: CellRange; clear: CellRange | null } {
|
||||
if (inRange(src, p.r, p.c)) {
|
||||
const up = src.r1 - p.r;
|
||||
const left = src.c1 - p.c;
|
||||
if (up === 0 && left === 0) return { target: src, clear: null };
|
||||
if (up >= left) return { target: { ...src, r1: p.r }, clear: { ...src, r0: p.r + 1 } };
|
||||
return { target: { ...src, c1: p.c }, clear: { ...src, c0: p.c + 1 } };
|
||||
}
|
||||
const down = p.r - src.r1;
|
||||
const upOut = src.r0 - p.r;
|
||||
const right = p.c - src.c1;
|
||||
const leftOut = src.c0 - p.c;
|
||||
const best = Math.max(down, upOut, right, leftOut);
|
||||
if (best === down) return { target: { ...src, r1: p.r }, clear: null };
|
||||
if (best === upOut) return { target: { ...src, r0: p.r }, clear: null };
|
||||
if (best === right) return { target: { ...src, c1: p.c }, clear: null };
|
||||
return { target: { ...src, c0: p.c }, clear: null };
|
||||
}
|
||||
|
||||
export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void {
|
||||
const root = ctx.grid.root;
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "ss-fill-preview";
|
||||
let stopDrag: (() => void) | null = null;
|
||||
|
||||
/** 끌기 — 누른 채 움직이면 `onMove(칸)` · 떼면 `onUp` · 격자 밖이면 가장자리 칸(스크롤 따라감) */
|
||||
function drag(onMove: (p: HitResult) => void, onUp?: () => void): void {
|
||||
const move = (e: MouseEvent): void => {
|
||||
const box = root.getBoundingClientRect();
|
||||
const x = Math.min(box.right - 2, Math.max(box.left + 2, e.clientX));
|
||||
const y = Math.min(box.bottom - 2, Math.max(box.top + 2, e.clientY));
|
||||
const hit = ctx.grid.hitTest(x, y);
|
||||
if (!hit || hit.r < 0 || hit.c < 0) return;
|
||||
onMove(hit);
|
||||
if (x !== e.clientX || y !== e.clientY) ctx.grid.reveal(hit.r, hit.c);
|
||||
};
|
||||
const up = (): void => {
|
||||
stopDrag?.();
|
||||
onUp?.();
|
||||
};
|
||||
stopDrag = () => {
|
||||
window.removeEventListener("mousemove", move);
|
||||
window.removeEventListener("mouseup", up);
|
||||
stopDrag = null;
|
||||
};
|
||||
window.addEventListener("mousemove", move);
|
||||
window.addEventListener("mouseup", up);
|
||||
}
|
||||
|
||||
const inSelection = (r: number, c: number): boolean =>
|
||||
ctx.selection.범위.some((g) => inRange(g, r, c));
|
||||
|
||||
function showPreview(g: CellRange | null): void {
|
||||
if (!g) return preview.remove();
|
||||
const a = ctx.grid.editorSlot(g.r0, g.c0);
|
||||
const b = ctx.grid.editorSlot(Math.min(g.r1, LAST_R), Math.min(g.c1, LAST_C));
|
||||
if (preview.parentElement !== a.layer) a.layer.append(preview);
|
||||
boxStyle(preview, {
|
||||
x: a.box.x,
|
||||
y: a.box.y,
|
||||
w: b.box.x + b.box.w - a.box.x,
|
||||
h: b.box.y + b.box.h - a.box.y,
|
||||
});
|
||||
}
|
||||
|
||||
function fillDrag(): void {
|
||||
const sheet = ctx.sheet();
|
||||
const src = ctx.selection.범위[0];
|
||||
let result = { target: src, clear: null as CellRange | null };
|
||||
drag(
|
||||
(p) => {
|
||||
result = fillTarget(src, p);
|
||||
showPreview(result.clear ?? result.target);
|
||||
},
|
||||
() => {
|
||||
showPreview(null);
|
||||
applyFill(sheet.id, src, result.target, result.clear);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function applyFill(
|
||||
sheet: string,
|
||||
src: CellRange,
|
||||
target: CellRange,
|
||||
clear: CellRange | null,
|
||||
): void {
|
||||
const at = ctx.selection.활성;
|
||||
const sel = (g: CellRange): Selection => ({
|
||||
시트: sheet,
|
||||
범위: [g],
|
||||
활성: at,
|
||||
기준: { ...at },
|
||||
});
|
||||
if (clear) {
|
||||
ctx.dispatch({ 종류: "내용지움", 시트: sheet, 범위: [clear] });
|
||||
ctx.select(sel(target));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target.r0 === src.r0 &&
|
||||
target.r1 === src.r1 &&
|
||||
target.c0 === src.c0 &&
|
||||
target.c1 === src.c1
|
||||
)
|
||||
return;
|
||||
ctx.dispatch({ 종류: "칸", 시트: sheet, 칸: fillCells(ctx.book, sheet, src, target) });
|
||||
ctx.select(sel(target));
|
||||
}
|
||||
|
||||
/** 핸들 두 번 누르기 — 왼쪽(없으면 오른쪽) 열 값이 이어진 끝까지 아래로 */
|
||||
function fillDown(): void {
|
||||
const sheet = ctx.sheet();
|
||||
const src = ctx.selection.범위[0];
|
||||
const side = [src.c0 - 1, src.c1 + 1].find(
|
||||
(c) => c >= 0 && c <= LAST_C && hasValue(sheet, src.r1 + 1, c),
|
||||
);
|
||||
if (side === undefined) return;
|
||||
let r = src.r1 + 1;
|
||||
while (r < LAST_R && hasValue(sheet, r + 1, side)) r++;
|
||||
applyFill(sheet.id, src, { ...src, r1: r }, null);
|
||||
}
|
||||
|
||||
function onDown(e: MouseEvent): void {
|
||||
if (e.target === ed.ta) return; // 편집 칸 안 — 글자 자리 옮김
|
||||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||||
if (!hit || hit.kind === "colEdge" || hit.kind === "rowEdge") return;
|
||||
const sheet = ctx.sheet();
|
||||
if (e.button === 2) {
|
||||
if (ed.editing() || (hit.kind === "cell" && inSelection(hit.r, hit.c))) return;
|
||||
if (hit.kind === "cell") ctx.select(selectCell(sheet, hit.r, hit.c));
|
||||
else if (hit.kind === "rowHead" && !inSelection(hit.r, ctx.selection.활성.c))
|
||||
ctx.select(selectRows(sheet, hit.r, hit.r));
|
||||
else if (hit.kind === "colHead" && !inSelection(ctx.selection.활성.r, hit.c))
|
||||
ctx.select(selectCols(sheet, hit.c, hit.c));
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault(); // 초점은 편집기 · 수식 입력줄에 둠
|
||||
if (ed.editing()) {
|
||||
if (hit.kind === "cell" && ed.pointable()) {
|
||||
const at = { r: hit.r, c: hit.c };
|
||||
ed.point(selectCell(sheet, hit.r, hit.c));
|
||||
drag((p) => ed.point(selectSpan(sheet, at, { r: p.r, c: p.c })));
|
||||
return;
|
||||
}
|
||||
ed.commit();
|
||||
}
|
||||
ed.focus();
|
||||
if (ctx.readOnly && hit.kind === "fillHandle") hit.kind = "cell";
|
||||
switch (hit.kind) {
|
||||
case "fillHandle":
|
||||
return fillDrag();
|
||||
case "corner":
|
||||
return ctx.select(selectAll(sheet));
|
||||
case "rowHead": {
|
||||
const a = e.shiftKey ? ctx.selection.기준.r : hit.r;
|
||||
ctx.select(selectRows(sheet, a, hit.r, a));
|
||||
return drag((p) => ctx.select(selectRows(sheet, a, p.r, a)));
|
||||
}
|
||||
case "colHead": {
|
||||
const a = e.shiftKey ? ctx.selection.기준.c : hit.c;
|
||||
ctx.select(selectCols(sheet, a, hit.c, a));
|
||||
return drag((p) => ctx.select(selectCols(sheet, a, p.c, a)));
|
||||
}
|
||||
case "cell": {
|
||||
const first = e.shiftKey
|
||||
? selectSpan(sheet, ctx.selection.기준, hit)
|
||||
: selectCell(sheet, hit.r, hit.c);
|
||||
ctx.select(first);
|
||||
const anchor = first.기준;
|
||||
let last = `${hit.r},${hit.c}`;
|
||||
return drag((p) => {
|
||||
if (`${p.r},${p.c}` === last) return;
|
||||
last = `${p.r},${p.c}`;
|
||||
ctx.select(selectSpan(sheet, anchor, p));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDbl(e: MouseEvent): void {
|
||||
if (e.target === ed.ta || ctx.readOnly) return;
|
||||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||||
if (!hit) return;
|
||||
if (hit.kind === "fillHandle") return fillDown();
|
||||
if (hit.kind === "cell" && !ed.editing()) ed.begin();
|
||||
}
|
||||
|
||||
root.addEventListener("mousedown", onDown);
|
||||
root.addEventListener("dblclick", onDbl);
|
||||
return () => {
|
||||
stopDrag?.();
|
||||
preview.remove();
|
||||
root.removeEventListener("mousedown", onDown);
|
||||
root.removeEventListener("dblclick", onDbl);
|
||||
};
|
||||
}
|
||||
@@ -4,16 +4,288 @@
|
||||
* 구역 `;`(양 · 음 · 0 · 글) · `"글"` · `\x` · `_x`(폭만큼 띄움) · `*x`(채움) · `[Red]` 등 색 · `%` · `@` · `E+00`.
|
||||
* 실무 형식(`0.00` · `_-* #,##0.00_-;\-* #,##0.00_-;_-* "-"_-;_-@_-` · `0.00_);[Red]\(0.00\)` …)은 모두 맞아야 함.
|
||||
* 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 · 시간 형식은 1단계 밖(글 그대로).
|
||||
* 0 계약 머리 — 몸은 B 가 채움.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { FormattedText, Scalar } from "./spreadsheet_types";
|
||||
import {
|
||||
ZERO,
|
||||
cmp,
|
||||
div,
|
||||
frac,
|
||||
fracToString,
|
||||
mul,
|
||||
roundAt,
|
||||
toInteger,
|
||||
type Frac,
|
||||
} from "@ui/sheet/ui_template_sheet_frac";
|
||||
import type { ErrorValue, FormattedText, Scalar } from "./spreadsheet_types";
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet_numfmt: 아직 없음(B)");
|
||||
};
|
||||
function isErrorScalar(v: Scalar): v is ErrorValue {
|
||||
return (
|
||||
typeof v === "object" && v !== null && "error" in (v as unknown as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
function groupThousands(digits: string): string {
|
||||
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
// ── 구역 나누기(`;` · 따옴표 · `\` 뒤는 안 나눔) ─────────────────────────────
|
||||
|
||||
function splitSections(code: string): string[] {
|
||||
const sections: string[] = [];
|
||||
let cur = "";
|
||||
let inQuote = false;
|
||||
for (let i = 0; i < code.length; i++) {
|
||||
const ch = code[i];
|
||||
if (ch === '"') {
|
||||
inQuote = !inQuote;
|
||||
cur += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\" && i + 1 < code.length) {
|
||||
cur += ch + code[i + 1];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ";" && !inQuote) {
|
||||
sections.push(cur);
|
||||
cur = "";
|
||||
continue;
|
||||
}
|
||||
cur += ch;
|
||||
}
|
||||
sections.push(cur);
|
||||
return sections;
|
||||
}
|
||||
|
||||
const COLOR_NAMES = ["black", "white", "red", "green", "blue", "yellow", "magenta", "cyan"];
|
||||
|
||||
function extractColor(section: string): { rest: string; color?: string } {
|
||||
let color: string | undefined;
|
||||
const rest = section.replace(/\[([^\]]+)\]/g, (_m, inner: string) => {
|
||||
const lower = inner.toLowerCase();
|
||||
if (COLOR_NAMES.includes(lower)) color = lower;
|
||||
return ""; // 조건 태그(`[>100]` 등)는 1단계에서 무시
|
||||
});
|
||||
return { rest, color };
|
||||
}
|
||||
|
||||
// ── 낱말 나누기 ──────────────────────────────────────────────────────────
|
||||
|
||||
type Tok =
|
||||
| { k: "digit"; ch: "0" | "#" | "?" }
|
||||
| { k: "comma" }
|
||||
| { k: "point" }
|
||||
| { k: "percent" }
|
||||
| { k: "text" }
|
||||
| { k: "lit"; ch: string };
|
||||
|
||||
function tokenize(s: string): { toks: Tok[]; fillChar?: string } {
|
||||
const toks: Tok[] = [];
|
||||
let fillChar: string | undefined;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (ch === '"') {
|
||||
let j = i + 1;
|
||||
while (j < s.length && s[j] !== '"') {
|
||||
toks.push({ k: "lit", ch: s[j] });
|
||||
j++;
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\" && i + 1 < s.length) {
|
||||
toks.push({ k: "lit", ch: s[i + 1] });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "_" && i + 1 < s.length) {
|
||||
toks.push({ k: "lit", ch: " " });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "*" && i + 1 < s.length) {
|
||||
fillChar = s[i + 1];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "0" || ch === "#" || ch === "?") {
|
||||
toks.push({ k: "digit", ch });
|
||||
continue;
|
||||
}
|
||||
if (ch === ",") {
|
||||
toks.push({ k: "comma" });
|
||||
continue;
|
||||
}
|
||||
if (ch === ".") {
|
||||
toks.push({ k: "point" });
|
||||
continue;
|
||||
}
|
||||
if (ch === "%") {
|
||||
toks.push({ k: "percent" });
|
||||
continue;
|
||||
}
|
||||
if (ch === "@") {
|
||||
toks.push({ k: "text" });
|
||||
continue;
|
||||
}
|
||||
toks.push({ k: "lit", ch });
|
||||
}
|
||||
return { toks, fillChar };
|
||||
}
|
||||
|
||||
// ── 수 구역 그리기 ───────────────────────────────────────────────────────
|
||||
|
||||
function renderNumericSection(toks: Tok[], magnitude: Frac): string {
|
||||
const pointIdx = toks.findIndex((t) => t.k === "point");
|
||||
const hasPercent = toks.some((t) => t.k === "percent");
|
||||
const scaledMag = hasPercent ? mul(magnitude, frac(100n)) : magnitude;
|
||||
|
||||
let lastDigit = -1;
|
||||
toks.forEach((t, i) => {
|
||||
if (t.k === "digit") lastDigit = i;
|
||||
});
|
||||
|
||||
let scale = 0;
|
||||
for (let i = lastDigit + 1; i < toks.length; i++) if (toks[i].k === "comma") scale++;
|
||||
|
||||
let hasGrouping = false;
|
||||
const introEnd = pointIdx >= 0 ? pointIdx : toks.length;
|
||||
for (let i = 0; i < introEnd && i <= lastDigit; i++)
|
||||
if (toks[i].k === "comma") hasGrouping = true;
|
||||
|
||||
const intRun = pointIdx >= 0 ? toks.slice(0, pointIdx) : toks;
|
||||
const fracRun = pointIdx >= 0 ? toks.slice(pointIdx + 1) : [];
|
||||
const intDigitToks = intRun.filter((t): t is Extract<Tok, { k: "digit" }> => t.k === "digit");
|
||||
const fracDigitToks = fracRun.filter((t): t is Extract<Tok, { k: "digit" }> => t.k === "digit");
|
||||
|
||||
const scaleDiv = scale > 0 ? 1000n ** BigInt(scale) : 1n;
|
||||
const adjusted = scaleDiv === 1n ? scaledMag : div(scaledMag, frac(scaleDiv));
|
||||
|
||||
const rounded = roundAt(adjusted, fracDigitToks.length, "round");
|
||||
const scaleUp = frac(10n ** BigInt(fracDigitToks.length));
|
||||
const scaledInt = toInteger(mul(rounded, scaleUp), "round");
|
||||
const allDigits = scaledInt.toString().padStart(fracDigitToks.length + 1, "0");
|
||||
const rawIntStr =
|
||||
fracDigitToks.length > 0 ? allDigits.slice(0, -fracDigitToks.length) : allDigits;
|
||||
const rawFracStr = fracDigitToks.length > 0 ? allDigits.slice(-fracDigitToks.length) : "";
|
||||
|
||||
let intStr = rawIntStr;
|
||||
if (intDigitToks.length > intStr.length) {
|
||||
const need = intDigitToks.length - intStr.length;
|
||||
let pad = "";
|
||||
for (let i = 0; i < need; i++) pad += intDigitToks[i].ch === "0" ? "0" : "";
|
||||
intStr = pad + intStr;
|
||||
}
|
||||
if (hasGrouping) intStr = groupThousands(intStr);
|
||||
|
||||
let keepUntil = fracDigitToks.length;
|
||||
for (let i = fracDigitToks.length - 1; i >= 0; i--) {
|
||||
if (fracDigitToks[i].ch === "0") break;
|
||||
if (rawFracStr[i] !== "0") break;
|
||||
keepUntil = i;
|
||||
}
|
||||
const fracStr = rawFracStr.slice(0, keepUntil);
|
||||
|
||||
let out = "";
|
||||
let intEmitted = false;
|
||||
let fracEmitted = false;
|
||||
for (let i = 0; i < toks.length; i++) {
|
||||
const t = toks[i];
|
||||
if (t.k === "digit" || t.k === "comma") {
|
||||
const inFrac = pointIdx >= 0 && i > pointIdx;
|
||||
if (inFrac) {
|
||||
if (!fracEmitted) {
|
||||
out += fracStr;
|
||||
fracEmitted = true;
|
||||
}
|
||||
} else if (!intEmitted) {
|
||||
out += intStr;
|
||||
intEmitted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (t.k === "point") {
|
||||
if (fracStr.length > 0) out += ".";
|
||||
continue;
|
||||
}
|
||||
if (t.k === "percent") {
|
||||
out += "%";
|
||||
continue;
|
||||
}
|
||||
if (t.k === "text") continue; // 수 구역엔 의미 없음(방어)
|
||||
out += t.ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function generalFormat(value: Frac): string {
|
||||
return fracToString(value);
|
||||
}
|
||||
|
||||
function chooseSection(sections: string[], value: Frac): { code: string; forceMinus: boolean } {
|
||||
const isNeg = cmp(value, ZERO) < 0;
|
||||
const isZero = cmp(value, ZERO) === 0;
|
||||
if (sections.length === 1) return { code: sections[0], forceMinus: isNeg };
|
||||
if (sections.length === 2) {
|
||||
return isNeg
|
||||
? { code: sections[1], forceMinus: false }
|
||||
: { code: sections[0], forceMinus: false };
|
||||
}
|
||||
if (isZero) return { code: sections[2] ?? sections[0], forceMinus: false };
|
||||
if (isNeg) return { code: sections[1], forceMinus: false };
|
||||
return { code: sections[0], forceMinus: false };
|
||||
}
|
||||
|
||||
function formatNumber(value: Frac, code: string | undefined): FormattedText {
|
||||
const trimmed = code?.trim();
|
||||
if (!trimmed || trimmed.toLowerCase() === "general") {
|
||||
return { 글: generalFormat(value), 정렬: "right" };
|
||||
}
|
||||
const sections = splitSections(trimmed);
|
||||
const { code: chosen, forceMinus } = chooseSection(sections, value);
|
||||
const { rest, color } = extractColor(chosen);
|
||||
const { toks, fillChar } = tokenize(rest);
|
||||
const extra: Partial<FormattedText> = {};
|
||||
if (color) extra.색 = color;
|
||||
if (fillChar) extra.채움글 = fillChar;
|
||||
if (!toks.some((t) => t.k === "digit")) {
|
||||
let out = "";
|
||||
for (const t of toks) if (t.k === "lit") out += t.ch;
|
||||
return { 글: out, 정렬: "right", ...extra };
|
||||
}
|
||||
const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value;
|
||||
const body = renderNumericSection(toks, magnitude);
|
||||
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
|
||||
}
|
||||
|
||||
function formatText(value: string, code: string | undefined): FormattedText {
|
||||
const trimmed = code?.trim();
|
||||
if (!trimmed) return { 글: value, 정렬: "left" };
|
||||
const sections = splitSections(trimmed);
|
||||
// 구역 하나뿐(`;` 없음) = 그 구역이 수 · 글 모두에 적용(`General` 과 같은 이치) · 2~3 구역엔 글 자리가 없어 그대로.
|
||||
const textCode =
|
||||
sections.length >= 4 ? sections[3] : sections.length === 1 ? sections[0] : undefined;
|
||||
if (textCode === undefined) return { 글: value, 정렬: "left" };
|
||||
const { rest, color } = extractColor(textCode);
|
||||
const { toks, fillChar } = tokenize(rest);
|
||||
const extra: Partial<FormattedText> = {};
|
||||
if (color) extra.색 = color;
|
||||
if (fillChar) extra.채움글 = fillChar;
|
||||
let out = "";
|
||||
for (const t of toks) {
|
||||
if (t.k === "text") out += value;
|
||||
else if (t.k === "lit") out += t.ch;
|
||||
else if (t.k === "percent") out += "%";
|
||||
}
|
||||
return { 글: out, 정렬: "left", ...extra };
|
||||
}
|
||||
|
||||
/** `code` 없음 = 일반(수는 유효 11 자리 안쪽 · 칸 폭은 격자 몫) · 오류 값은 코드 글 그대로 */
|
||||
export function formatValue(_value: Scalar, _code: string | undefined): FormattedText {
|
||||
return todo();
|
||||
export function formatValue(value: Scalar, code: string | undefined): FormattedText {
|
||||
if (isErrorScalar(value)) return { 글: value.error, 정렬: "center" };
|
||||
if (value === null) return { 글: "", 정렬: "right" };
|
||||
if (typeof value === "boolean") return { 글: value ? "TRUE" : "FALSE", 정렬: "center" };
|
||||
if (typeof value === "string") return formatText(value, code);
|
||||
return formatNumber(value, code);
|
||||
}
|
||||
|
||||
@@ -4,26 +4,310 @@
|
||||
* 우선순위는 엑셀과 같음: 범위 `:` > 부호 `-` `+` > `%` > `^` > `* /` > `+ -` > `&` > 비교
|
||||
* (부호가 `^` 보다 먼저 — `-2^2 = 4`). 같은 단은 왼쪽부터(`^` 도 왼쪽부터 · 엑셀과 같음).
|
||||
* 글 `"…"` 안 `""` = 따옴표 하나 · 시트 `'이름'!A1` · `이름!A1` · 오류 값 글자 · TRUE/FALSE.
|
||||
* 0 계약 머리 — 몸은 A 가 채움.
|
||||
* 참조 낱말 풀기(`parseRef` · `renderRef`)는 참조 옮김(`spreadsheet_refshift.ts`)이 같이 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { FormulaNode, ParseResult, Token } from "./spreadsheet_types";
|
||||
import { parseDecimal, ZERO } from "@ui/sheet/ui_template_sheet_frac";
|
||||
import { colIndex, colName, quoteSheet, rowIndex } from "./spreadsheet_address";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type { ErrorCode, FormulaNode, ParseResult, RefCell, Token } from "./spreadsheet_types";
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet_parser: 아직 없음(A)");
|
||||
};
|
||||
// ── 참조 낱말 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 앞 `=` 뺀 식 글 → 낱말(빈칸 낱말 포함 · 끝까지 · 못 읽는 글은 `unknown`) — 편집기 색칠 · 가리키기용 */
|
||||
export function tokenize(_formula: string): Token[] {
|
||||
return todo();
|
||||
const IDC = "[\\p{L}\\p{N}_.]";
|
||||
const END = `(?!${IDC}|\\()`;
|
||||
const CELL = "(\\$?)([A-Za-z]{1,3})(\\$?)(\\d+)";
|
||||
const RE_PREFIX = /^(?:'((?:[^']|'')+)'|([\p{L}\p{N}_.]+))!/u;
|
||||
const RE_AREA = new RegExp(`^${CELL}(?::${CELL})?${END}`, "u");
|
||||
const RE_COLS = new RegExp(`^(\\$?)([A-Za-z]{1,3}):(\\$?)([A-Za-z]{1,3})${END}`, "u");
|
||||
const RE_ROWS = new RegExp(`^(\\$?)(\\d+):(\\$?)(\\d+)${END}`, "u");
|
||||
const RE_ERROR = /^#(NULL!|DIV\/0!|VALUE!|REF!|NAME\?|NUM!|N\/A|CYCLE!)/i;
|
||||
const RE_IDENT = /^[\p{L}_\\][\p{L}\p{N}_.]*/u;
|
||||
|
||||
/** 참조 낱말 하나 — `kind` cell = 칸 · area = `A1:B2` · cols = `A:C` · rows = `3:5`. */
|
||||
export interface RefParts {
|
||||
/** 적힌 시트 머리 글 그대로(`'돌-골막이'!`) · 없으면 "" */
|
||||
prefix: string;
|
||||
sheet: string | null;
|
||||
kind: "cell" | "area" | "cols" | "rows";
|
||||
a: RefCell;
|
||||
b: RefCell;
|
||||
}
|
||||
|
||||
/** 참조 글 → 조각 · 참조가 아니면 null(주소가 격자 밖이어도 null → 이름) */
|
||||
export function parseRef(text: string): RefParts | null {
|
||||
const pm = RE_PREFIX.exec(text);
|
||||
const prefix = pm ? pm[0] : "";
|
||||
const sheet = pm ? (pm[1] !== undefined ? pm[1].replace(/''/g, "'") : pm[2]) : null;
|
||||
const body = text.slice(prefix.length);
|
||||
const pt = (sr: string, col: number, sc: string, row: number): RefCell => ({
|
||||
r: row,
|
||||
c: col,
|
||||
absR: sc === "$",
|
||||
absC: sr === "$",
|
||||
});
|
||||
let m = RE_AREA.exec(body);
|
||||
if (m && m[0].length === body.length) {
|
||||
const a = pt(m[1], colIndex(m[2]), m[3], rowIndex(m[4]));
|
||||
const b = m[5] === undefined ? { ...a } : pt(m[5], colIndex(m[6]), m[7], rowIndex(m[8]));
|
||||
if (a.r < 0 || a.c < 0 || b.r < 0 || b.c < 0) return null;
|
||||
return { prefix, sheet, kind: m[5] === undefined ? "cell" : "area", a, b };
|
||||
}
|
||||
m = RE_COLS.exec(body);
|
||||
if (m && m[0].length === body.length) {
|
||||
const c0 = colIndex(m[2]);
|
||||
const c1 = colIndex(m[4]);
|
||||
if (c0 < 0 || c1 < 0) return null;
|
||||
return {
|
||||
prefix,
|
||||
sheet,
|
||||
kind: "cols",
|
||||
a: pt(m[1], c0, "", 0),
|
||||
b: pt(m[3], c1, "", MAX_ROWS - 1),
|
||||
};
|
||||
}
|
||||
m = RE_ROWS.exec(body);
|
||||
if (m && m[0].length === body.length) {
|
||||
const r0 = rowIndex(m[2]);
|
||||
const r1 = rowIndex(m[4]);
|
||||
if (r0 < 0 || r1 < 0) return null;
|
||||
return {
|
||||
prefix,
|
||||
sheet,
|
||||
kind: "rows",
|
||||
a: pt("", 0, m[1], r0),
|
||||
b: pt("", MAX_COLS - 1, m[3], r1),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const cellText = (p: RefCell): string =>
|
||||
`${p.absC ? "$" : ""}${colName(p.c)}${p.absR ? "$" : ""}${p.r + 1}`;
|
||||
|
||||
/** 조각 → 참조 글(시트 머리는 `prefix` 그대로) */
|
||||
export function renderRef(p: RefParts): string {
|
||||
if (p.kind === "cols")
|
||||
return `${p.prefix}${p.a.absC ? "$" : ""}${colName(p.a.c)}:${p.b.absC ? "$" : ""}${colName(p.b.c)}`;
|
||||
if (p.kind === "rows")
|
||||
return `${p.prefix}${p.a.absR ? "$" : ""}${p.a.r + 1}:${p.b.absR ? "$" : ""}${p.b.r + 1}`;
|
||||
return p.prefix + cellText(p.a) + (p.kind === "area" ? `:${cellText(p.b)}` : "");
|
||||
}
|
||||
|
||||
// ── 낱말 나누기 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** 앞 `=` 뺀 식 글 → 낱말(빈칸 낱말 포함 · 끝까지 · 못 읽는 글은 `unknown`) — 편집기 색칠 · 가리키기용.
|
||||
* 이름(정의 안 된 글자 묶음)도 `unknown` — 파서가 이름 나무로 읽음. */
|
||||
export function tokenize(formula: string): Token[] {
|
||||
const out: Token[] = [];
|
||||
let i = 0;
|
||||
let ref: string | null;
|
||||
const push = (kind: Token["kind"], len: number) => {
|
||||
out.push({ kind, text: formula.slice(i, i + len), start: i, end: i + len });
|
||||
i += len;
|
||||
};
|
||||
while (i < formula.length) {
|
||||
const rest = formula.slice(i);
|
||||
const ch = rest[0];
|
||||
let m: RegExpExecArray | null;
|
||||
if ((m = /^\s+/.exec(rest))) push("space", m[0].length);
|
||||
else if (ch === '"') {
|
||||
const s = /^"(?:[^"]|"")*"?/.exec(rest)!;
|
||||
push("string", s[0].length);
|
||||
} else if ((m = /^([\p{L}_][\p{L}\p{N}_.]*)\(/u.exec(rest)) && !/^(TRUE|FALSE)$/i.test(m[1]))
|
||||
push("func", m[1].length);
|
||||
else if ((ref = refAt(rest))) push(/#REF!$/i.test(ref) ? "error" : "ref", ref.length);
|
||||
else if ((m = /^(TRUE|FALSE)(?![\p{L}\p{N}_.(])/iu.exec(rest))) push("bool", m[0].length);
|
||||
else if ((m = RE_ERROR.exec(rest))) push("error", m[0].length);
|
||||
else if ((m = /^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/.exec(rest))) push("number", m[0].length);
|
||||
else if ((m = RE_IDENT.exec(rest))) push("unknown", m[0].length);
|
||||
else if ((m = /^(<>|<=|>=|[-+*/^&=<>%:])/.exec(rest))) push("op", m[0].length);
|
||||
else if (ch === "(" || ch === ")") push("paren", 1);
|
||||
else if (ch === ",") push("comma", 1);
|
||||
else push("unknown", 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 머리 이 자리에서 시작하는 참조(또는 `시트!#REF!`) 글 */
|
||||
function refAt(rest: string): string | null {
|
||||
const head = RE_PREFIX.exec(rest)?.[0] ?? "";
|
||||
const body = rest.slice(head.length);
|
||||
if (head && /^#REF!/i.test(body)) return head + body.slice(0, 5);
|
||||
for (const re of [RE_AREA, RE_COLS, RE_ROWS]) {
|
||||
const m = re.exec(body);
|
||||
if (m && parseRef(head + m[0])) return head + m[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 나무 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Fail {
|
||||
constructor(
|
||||
readonly message: string,
|
||||
readonly at: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
const COMPARE = ["=", "<>", "<", "<=", ">", ">="];
|
||||
|
||||
/** 앞 `=` 뺀 식 글 → 나무. 앞에 `=` 가 붙어 와도 됨. 함수 이름은 대문자로. */
|
||||
export function parseFormula(_formula: string): ParseResult {
|
||||
return todo();
|
||||
export function parseFormula(formula: string): ParseResult {
|
||||
const text = formula.startsWith("=") ? formula.slice(1) : formula;
|
||||
const toks = tokenize(text).filter((t) => t.kind !== "space");
|
||||
let p = 0;
|
||||
const peek = (): Token | undefined => toks[p];
|
||||
const isOp = (...ops: string[]) => {
|
||||
const t = toks[p];
|
||||
return !!t && t.kind === "op" && ops.includes(t.text);
|
||||
};
|
||||
const where = () => (toks[p] ? toks[p].start : text.length);
|
||||
|
||||
const binary = (ops: string[], next: () => FormulaNode) => (): FormulaNode => {
|
||||
let left = next();
|
||||
while (isOp(...ops)) {
|
||||
const op = toks[p++].text as "+";
|
||||
left = { type: "binary", op, left, right: next() };
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const primary = (): FormulaNode => {
|
||||
const t = peek();
|
||||
if (!t) throw new Fail("식이 끝남", text.length);
|
||||
p++;
|
||||
switch (t.kind) {
|
||||
case "number":
|
||||
return { type: "number", value: parseDecimal(t.text), text: t.text };
|
||||
case "string":
|
||||
if (t.text.length < 2 || !t.text.endsWith('"')) throw new Fail("따옴표가 안 닫힘", t.start);
|
||||
return { type: "string", value: t.text.slice(1, -1).replace(/""/g, '"') };
|
||||
case "bool":
|
||||
return { type: "bool", value: t.text.toUpperCase() === "TRUE" };
|
||||
case "error":
|
||||
return {
|
||||
type: "error",
|
||||
code: t.text.slice(t.text.lastIndexOf("#")).toUpperCase() as ErrorCode,
|
||||
};
|
||||
case "ref": {
|
||||
const r = parseRef(t.text)!;
|
||||
return r.kind === "cell"
|
||||
? { type: "cell", sheet: r.sheet, ref: r.a }
|
||||
: { type: "range", sheet: r.sheet, from: r.a, to: r.b };
|
||||
}
|
||||
case "func": {
|
||||
const name = t.text.toUpperCase().replace(/^_XLFN\./, "");
|
||||
p++; // `(`
|
||||
const args: FormulaNode[] = [];
|
||||
if (peek()?.text === ")") {
|
||||
p++;
|
||||
return { type: "call", name, args };
|
||||
}
|
||||
for (;;) {
|
||||
const k = peek();
|
||||
// 빈 인자(`IF(A1,,1)`) = 0 · 글은 비움
|
||||
if (k && (k.kind === "comma" || k.text === ")"))
|
||||
args.push({ type: "number", value: ZERO, text: "" });
|
||||
else args.push(compare());
|
||||
const s = toks[p++];
|
||||
if (s?.kind === "comma") continue;
|
||||
if (s?.text === ")") return { type: "call", name, args };
|
||||
throw new Fail("함수 괄호가 안 닫힘", s ? s.start : text.length);
|
||||
}
|
||||
}
|
||||
case "paren": {
|
||||
if (t.text !== "(") break;
|
||||
const inner = compare();
|
||||
if (peek()?.text !== ")") throw new Fail("괄호가 안 닫힘", where());
|
||||
p++;
|
||||
return inner;
|
||||
}
|
||||
case "unknown":
|
||||
if (RE_IDENT.exec(t.text)?.[0] === t.text) return { type: "name", name: t.text };
|
||||
}
|
||||
throw new Fail(`읽지 못한 글: ${t.text}`, t.start);
|
||||
};
|
||||
const unary = (): FormulaNode => {
|
||||
if (isOp("-", "+")) {
|
||||
const op = toks[p++].text as "-";
|
||||
return { type: "unary", op, arg: unary() };
|
||||
}
|
||||
return primary();
|
||||
};
|
||||
const percent = (): FormulaNode => {
|
||||
let node = unary();
|
||||
while (isOp("%")) {
|
||||
p++;
|
||||
node = { type: "unary", op: "%", arg: node };
|
||||
}
|
||||
return node;
|
||||
};
|
||||
const power = binary(["^"], percent);
|
||||
const mul = binary(["*", "/"], power);
|
||||
const add = binary(["+", "-"], mul);
|
||||
const concat = binary(["&"], add);
|
||||
const compare = binary(COMPARE, concat);
|
||||
|
||||
try {
|
||||
if (!toks.length) throw new Fail("빈 식", 0);
|
||||
const node = compare();
|
||||
if (p < toks.length) throw new Fail(`남은 글: ${toks[p].text}`, toks[p].start);
|
||||
return { ok: true, node };
|
||||
} catch (e) {
|
||||
if (e instanceof Fail) return { ok: false, message: e.message, at: e.at };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** 나무 → 앞 `=` 뺀 식 글(참조 옮김 뒤 다시 적기) — 수는 원래 글(`text`) 그대로 */
|
||||
export function formulaToText(_node: FormulaNode): string {
|
||||
return todo();
|
||||
// ── 나무 → 글 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const PREC: Record<string, number> = { "&": 2, "+": 3, "-": 3, "*": 4, "/": 4, "^": 5 };
|
||||
for (const op of COMPARE) PREC[op] = 1;
|
||||
|
||||
function prec(node: FormulaNode): number {
|
||||
if (node.type === "binary") return PREC[node.op];
|
||||
if (node.type === "unary") return node.op === "%" ? 6 : 7;
|
||||
return 8;
|
||||
}
|
||||
|
||||
const sheetHead = (sheet: string | null) => (sheet === null ? "" : `${quoteSheet(sheet)}!`);
|
||||
|
||||
/** 나무 → 앞 `=` 뺀 식 글(참조 옮김 뒤 다시 적기) — 수는 원래 글(`text`) 그대로 · 괄호는 필요한 곳만 */
|
||||
export function formulaToText(node: FormulaNode): string {
|
||||
const wrap = (child: FormulaNode, need: boolean) =>
|
||||
need ? `(${formulaToText(child)})` : formulaToText(child);
|
||||
switch (node.type) {
|
||||
case "number":
|
||||
return node.text;
|
||||
case "string":
|
||||
return `"${node.value.replace(/"/g, '""')}"`;
|
||||
case "bool":
|
||||
return node.value ? "TRUE" : "FALSE";
|
||||
case "error":
|
||||
return node.code;
|
||||
case "name":
|
||||
return node.name;
|
||||
case "cell":
|
||||
return sheetHead(node.sheet) + cellText(node.ref);
|
||||
case "range": {
|
||||
const { from, to } = node;
|
||||
const kind =
|
||||
from.r === 0 && to.r === MAX_ROWS - 1
|
||||
? "cols"
|
||||
: from.c === 0 && to.c === MAX_COLS - 1
|
||||
? "rows"
|
||||
: "area";
|
||||
return renderRef({ prefix: sheetHead(node.sheet), sheet: node.sheet, kind, a: from, b: to });
|
||||
}
|
||||
case "unary":
|
||||
return node.op === "%"
|
||||
? `${wrap(node.arg, prec(node.arg) < 6)}%`
|
||||
: `${node.op}${wrap(node.arg, prec(node.arg) < 7)}`;
|
||||
case "binary": {
|
||||
const p = PREC[node.op];
|
||||
return `${wrap(node.left, prec(node.left) < p)}${node.op}${wrap(node.right, prec(node.right) <= p)}`;
|
||||
}
|
||||
case "call":
|
||||
return `${node.name}(${node.args.map(formulaToText).join(",")})`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_paste_special.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 붙여넣기 골라서 — 값만 · 서식만 · 수식만(Ctrl+Shift+V). `ClipBlock`(0 계약) 을 받아
|
||||
* 대상 왼위(`at`)에 맞게 명령을 만듦 — 서식은 `서식` 명령(칸마다 하나 · A 가 표 번호를 매김) ·
|
||||
* 값 · 식은 `칸` 명령(대상 칸의 지금 서식 번호는 그대로 둠). 참조 옮김은 「원점」 이 있을 때만
|
||||
* `$` 없는 A1 참조를 얕게 옮김(진짜 옮김은 A `spreadsheet_refshift` 몫 — 여긴 메뉴용 편의).
|
||||
*
|
||||
* 잇는 법(D · sub7) — 클립보드(E)가 만든 `ClipBlock` 을 들고:
|
||||
* 1) 우클릭 메뉴 · Ctrl+Shift+V 로 모드 고르는 작은 팝업을 띄움(값만 · 서식만 · 수식만).
|
||||
* 2) 고른 모드로 `pasteSpecial(ctx, block, ctx.selection.활성, mode, sourceOrigin?)` 호출.
|
||||
* 3) 「값만」 은 원본이 아직 화면에 있을 때만 계산값을 살릴 수 있음 — `sourceOrigin` 을 줌(없으면 식 칸은 건너뜀).
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
Cell,
|
||||
CellAddress,
|
||||
CellRange,
|
||||
CellStyle,
|
||||
ClipBlock,
|
||||
ClipCell,
|
||||
Command,
|
||||
Frac,
|
||||
Scalar,
|
||||
} from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
import { fracToString } from "@ui/sheet/ui_template_sheet_frac";
|
||||
|
||||
export type PasteSpecialMode = "값" | "서식" | "수식";
|
||||
|
||||
const a1 = (r: number, c: number): string => {
|
||||
let col = c + 1;
|
||||
let letters = "";
|
||||
while (col > 0) {
|
||||
const rem = (col - 1) % 26;
|
||||
letters = String.fromCharCode(65 + rem) + letters;
|
||||
col = Math.floor((col - 1) / 26);
|
||||
}
|
||||
return `${letters}${r + 1}`;
|
||||
};
|
||||
|
||||
/** `$` 없는 A1 참조만 (dr, dc) 만큼 옮김 — 절대참조 · 다른 시트 이름은 손대지 않음(안전한 최소). */
|
||||
export function shiftRelativeRefs(formula: string, dr: number, dc: number): string {
|
||||
return formula.replace(
|
||||
/(\$?)([A-Z]{1,3})(\$?)(\d+)/g,
|
||||
(whole, absC, colLetters, absR, rowDigits) => {
|
||||
if (absC === "$" && absR === "$") return whole;
|
||||
let col = 0;
|
||||
for (const ch of colLetters) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
const nextCol = absC === "$" ? col : col + dc;
|
||||
const nextRow = absR === "$" ? Number(rowDigits) : Number(rowDigits) + dr;
|
||||
if (nextCol < 1 || nextRow < 1) return "#REF!";
|
||||
let letters = "";
|
||||
let n = nextCol;
|
||||
while (n > 0) {
|
||||
const rem = (n - 1) % 26;
|
||||
letters = String.fromCharCode(65 + rem) + letters;
|
||||
n = Math.floor((n - 1) / 26);
|
||||
}
|
||||
return `${absC}${letters}${absR}${nextRow}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export interface PasteSpecialSource {
|
||||
시트: string;
|
||||
r: number;
|
||||
c: number;
|
||||
}
|
||||
|
||||
/** `block` 을 `at` 에 골라 붙임. `sourceOrigin` 은 「값만」 때 식 칸을 계산값으로 굳히는 데 씀(없으면 식 칸은 건너뜀). */
|
||||
export function pasteSpecial(
|
||||
ctx: SpreadsheetContext,
|
||||
block: ClipBlock,
|
||||
at: CellAddress,
|
||||
mode: PasteSpecialMode,
|
||||
sourceOrigin?: PasteSpecialSource,
|
||||
): void {
|
||||
const sheetId = ctx.selection.시트;
|
||||
const sheet = ctx.sheet();
|
||||
const dr = at.r - (block.원점?.r ?? at.r);
|
||||
const dc = at.c - (block.원점?.c ?? at.c);
|
||||
const commands: Command[] = [];
|
||||
const 칸: Record<string, Cell | null> = {};
|
||||
|
||||
for (const [key, clip] of Object.entries(block.칸)) {
|
||||
const [rowStr, colStr] = key.split(",");
|
||||
const rr = at.r + Number(rowStr);
|
||||
const cc = at.c + Number(colStr);
|
||||
if (rr < 0 || cc < 0) continue;
|
||||
const target = a1(rr, cc);
|
||||
const existing = sheet.칸[target];
|
||||
|
||||
if (mode === "서식") {
|
||||
if (clip.서식) commands.push(styleCommand(sheetId, rr, cc, clip.서식));
|
||||
continue;
|
||||
}
|
||||
if (mode === "수식") {
|
||||
if (clip.식 === undefined) continue;
|
||||
const moved = block.원점 ? shiftRelativeRefs(clip.식, dr, dc) : clip.식;
|
||||
칸[target] = { 식: moved, 서식: existing?.서식 };
|
||||
continue;
|
||||
}
|
||||
// mode === "값"
|
||||
const value = resolveClipValue(clip, sourceOrigin, Number(rowStr), Number(colStr), ctx);
|
||||
if (value === undefined) continue;
|
||||
칸[target] = { 값: value, 서식: existing?.서식 };
|
||||
}
|
||||
|
||||
if (Object.keys(칸).length > 0) commands.push({ 종류: "칸", 시트: sheetId, 칸 });
|
||||
if (commands.length === 0) return;
|
||||
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
|
||||
}
|
||||
|
||||
function styleCommand(sheetId: string, r: number, c: number, style: CellStyle): Command {
|
||||
const 범위: CellRange = { r0: r, c0: c, r1: r, c1: c };
|
||||
return { 종류: "서식", 시트: sheetId, 범위: [범위], 바꿀: { ...style } };
|
||||
}
|
||||
|
||||
function resolveClipValue(
|
||||
clip: ClipCell,
|
||||
origin: PasteSpecialSource | undefined,
|
||||
dr: number,
|
||||
dc: number,
|
||||
ctx: SpreadsheetContext,
|
||||
): Cell["값"] | undefined {
|
||||
if (clip.식 !== undefined) {
|
||||
if (!origin) return undefined;
|
||||
const scalar = ctx.engine.value(origin.시트, origin.r + dr, origin.c + dc);
|
||||
return scalarFrom(scalar);
|
||||
}
|
||||
return clip.값;
|
||||
}
|
||||
|
||||
function scalarFrom(value: Scalar): Cell["값"] | undefined {
|
||||
if (value === null) return undefined;
|
||||
if (typeof value === "string" || typeof value === "boolean") return value;
|
||||
if (typeof value === "object" && "n" in value && "d" in value)
|
||||
return Number(fracToString(value as Frac));
|
||||
return undefined;
|
||||
}
|
||||
@@ -2,15 +2,11 @@
|
||||
* spreadsheet_recalc.ts (주인 A)
|
||||
* 통합문서 한 벌 풀이 — 서버 Node 진입(`common_util_spreadsheet_node.ts`) · 시험이 부름.
|
||||
* 화면과 같은 엔진(`createCalcEngine`)을 한 번 돌려 `snapshot()` — 계산이 두 벌이 되지 않게.
|
||||
* 0 계약 머리 — 몸은 A 가 채움.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createCalcEngine } from "./spreadsheet_graph";
|
||||
import type { CalcValues, Workbook } from "./spreadsheet_types";
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet_recalc: 아직 없음(A)");
|
||||
};
|
||||
|
||||
export function recalcWorkbook(_book: Workbook): CalcValues {
|
||||
return todo();
|
||||
export function recalcWorkbook(book: Workbook): CalcValues {
|
||||
return createCalcEngine(book).snapshot();
|
||||
}
|
||||
|
||||
@@ -1,33 +1,179 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_refshift.ts (주인 A)
|
||||
* 참조 옮김 — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 바꾸기 때 식 글을 고쳐 적음(엑셀 규칙) ·
|
||||
* 복사 · 채우기 때 상대 참조만 옮김 · 구글 R1C1 식 → A1.
|
||||
* 참조 옮김 — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 바꾸기 · 시트 지우기 때 식 글을 고쳐 적음
|
||||
* (엑셀 규칙) · 복사 · 채우기 때 상대 참조만 옮김 · 구글 R1C1 식 → A1.
|
||||
* 지운 칸 · 격자 밖으로 나간 참조는 `#REF!` · 범위는 남은 만큼 줄어듦(끝까지 지우면 `#REF!`).
|
||||
* 0 계약 머리 — 몸은 A 가 채움.
|
||||
* 낱말 단위로 참조만 바꿔 끼움 — 빈칸 · 괄호 · 수 글은 적힌 그대로 둠.
|
||||
*
|
||||
* 엑셀 규칙표:
|
||||
* 넣기 — 넣는 자리 이후를 가리키면 밀림 · 범위가 넣는 자리를 품으면 늘어남(첫 행 앞 넣기는 통째 밀림)
|
||||
* 지우기 — 지운 칸 하나 = `#REF!` · 범위는 남은 만큼 · 전부 지우면 `#REF!` · `$` 도 똑같이 옮김
|
||||
* 옮기기 — 옮긴 덩어리 안을 가리키던 참조(범위는 통째 들 때만)는 따라감 · 덮어쓴 자리를 가리키면 `#REF!`
|
||||
* 복사 — 상대만 (dr, dc) · 행 · 열 전체 범위는 그 축을 안 옮김
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CellAddress, Command, Workbook } from "./spreadsheet_types";
|
||||
import { quoteSheet, rangeInside } from "./spreadsheet_address";
|
||||
import { parseRef, renderRef, tokenize } from "./spreadsheet_parser";
|
||||
import type { RefParts } from "./spreadsheet_parser";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type { CellAddress, CellRange, Command, Workbook } from "./spreadsheet_types";
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet_refshift: 아직 없음(A)");
|
||||
};
|
||||
const REF_ERR = "#REF!";
|
||||
|
||||
/** 행열 · 옮기기 · 시트이름 명령에 맞춰 식 글 고침(식이 든 시트 id = `host`) — 안 바뀌면 같은 글 */
|
||||
/** 참조 낱말만 `fn` 으로 바꿔 끼움(null = `#REF!`) */
|
||||
function mapRefs(formula: string, fn: (p: RefParts) => RefParts | string | null): string {
|
||||
let out = "";
|
||||
for (const t of tokenize(formula)) {
|
||||
const p = t.kind === "ref" ? parseRef(t.text) : null;
|
||||
if (!p) {
|
||||
out += t.text;
|
||||
continue;
|
||||
}
|
||||
const q = fn(p);
|
||||
out += q === null ? REF_ERR : typeof q === "string" ? q : renderRef(q);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 넣기 · 지우기 한 축 — [lo, hi] → 새 [lo, hi] · 다 지워지면 null */
|
||||
export function shiftSpan(
|
||||
lo: number,
|
||||
hi: number,
|
||||
insert: boolean,
|
||||
at: number,
|
||||
n: number,
|
||||
max: number,
|
||||
): [number, number] | null {
|
||||
if (insert) {
|
||||
if (lo >= at) [lo, hi] = [lo + n, hi + n];
|
||||
else if (hi >= at) hi += n;
|
||||
if (lo >= max) return null;
|
||||
return [lo, Math.min(hi, max - 1)];
|
||||
}
|
||||
const end = at + n - 1;
|
||||
if (lo >= at && hi <= end) return null;
|
||||
return [lo < at ? lo : lo > end ? lo - n : at, hi < at ? hi : hi > end ? hi - n : at - 1];
|
||||
}
|
||||
|
||||
const sheetIdOf = (book: Workbook, name: string) =>
|
||||
book.시트.find((s) => s.이름.toLowerCase() === name.toLowerCase())?.id ?? null;
|
||||
|
||||
const box = (p: RefParts): CellRange => ({
|
||||
r0: Math.min(p.a.r, p.b.r),
|
||||
c0: Math.min(p.a.c, p.b.c),
|
||||
r1: Math.max(p.a.r, p.b.r),
|
||||
c1: Math.max(p.a.c, p.b.c),
|
||||
});
|
||||
|
||||
/** 행열 · 옮기기 · 시트이름 · 시트지우기 명령에 맞춰 식 글 고침(식이 든 시트 id = `host`) — 안 바뀌면 같은 글.
|
||||
* `book` 은 명령 적용 **전** 문서(시트 이름으로 id 를 찾음). `newHost` = 옮기기로 식 칸 자체가 간 시트. */
|
||||
export function shiftForCommand(
|
||||
_formula: string,
|
||||
_host: string,
|
||||
_command: Command,
|
||||
_book: Workbook,
|
||||
formula: string,
|
||||
host: string,
|
||||
command: Command,
|
||||
book: Workbook,
|
||||
newHost: string = host,
|
||||
): string {
|
||||
return todo();
|
||||
if (command.종류 === "묶음")
|
||||
return command.명령.reduce((f, c) => shiftForCommand(f, host, c, book, newHost), formula);
|
||||
const target = (p: RefParts) => (p.sheet === null ? host : sheetIdOf(book, p.sheet));
|
||||
const nameOf = (id: string) => book.시트.find((s) => s.id === id)?.이름 ?? "";
|
||||
|
||||
switch (command.종류) {
|
||||
case "행넣기":
|
||||
case "행지우기":
|
||||
case "열넣기":
|
||||
case "열지우기": {
|
||||
const rows = command.종류.startsWith("행");
|
||||
const insert = command.종류.endsWith("넣기");
|
||||
return mapRefs(formula, (p) => {
|
||||
if (target(p) !== command.시트) return p;
|
||||
if (rows ? p.kind === "cols" : p.kind === "rows") return p; // 다른 축 전체 범위
|
||||
const [lo, hi] = rows
|
||||
? [p.a.r, p.b.r].sort((x, y) => x - y)
|
||||
: [p.a.c, p.b.c].sort((x, y) => x - y);
|
||||
const span = shiftSpan(lo, hi, insert, command.at, command.수, rows ? MAX_ROWS : MAX_COLS);
|
||||
if (!span) return null;
|
||||
const q = structuredClone(p);
|
||||
// 적힌 차례(뒤집힌 범위)는 바로 세움 — 엑셀도 다시 적을 때 바로 세움
|
||||
if (rows) [q.a.r, q.b.r] = span;
|
||||
else [q.a.c, q.b.c] = span;
|
||||
return q;
|
||||
});
|
||||
}
|
||||
case "옮기기": {
|
||||
const src = command.범위;
|
||||
const dr = command.r - src.r0;
|
||||
const dc = command.c - src.c0;
|
||||
const dst: CellRange = { r0: command.r, c0: command.c, r1: src.r1 + dr, c1: src.c1 + dc };
|
||||
return mapRefs(formula, (p) => {
|
||||
const t = target(p);
|
||||
if (t === null) return p;
|
||||
const b = box(p);
|
||||
let to = t;
|
||||
const q = structuredClone(p);
|
||||
if (t === command.시트 && p.kind !== "cols" && p.kind !== "rows" && rangeInside(b, src)) {
|
||||
to = command.대상시트;
|
||||
for (const pt of [q.a, q.b]) {
|
||||
pt.r += dr;
|
||||
pt.c += dc;
|
||||
}
|
||||
} else if (t === command.대상시트 && rangeInside(b, dst)) return null;
|
||||
if (to === t && newHost === host) return q;
|
||||
q.prefix = to === newHost ? "" : `${quoteSheet(nameOf(to))}!`;
|
||||
return q;
|
||||
});
|
||||
}
|
||||
case "시트이름":
|
||||
return mapRefs(formula, (p) =>
|
||||
p.sheet !== null && target(p) === command.시트
|
||||
? { ...p, prefix: `${quoteSheet(command.이름)}!` }
|
||||
: p,
|
||||
);
|
||||
case "시트지우기":
|
||||
return mapRefs(formula, (p) => (p.sheet !== null && target(p) === command.시트 ? null : p));
|
||||
default:
|
||||
return formula;
|
||||
}
|
||||
}
|
||||
|
||||
/** 복사 · 채우기 — 상대 참조만 (dr, dc) 옮김 · 격자 밖은 `#REF!` */
|
||||
export function moveFormula(_formula: string, _dr: number, _dc: number): string {
|
||||
return todo();
|
||||
export function moveFormula(formula: string, dr: number, dc: number): string {
|
||||
return mapRefs(formula, (p) => {
|
||||
const q = structuredClone(p);
|
||||
for (const pt of [q.a, q.b]) {
|
||||
if (!pt.absR && p.kind !== "cols") pt.r += dr;
|
||||
if (!pt.absC && p.kind !== "rows") pt.c += dc;
|
||||
if (pt.r < 0 || pt.r >= MAX_ROWS || pt.c < 0 || pt.c >= MAX_COLS) return null;
|
||||
}
|
||||
return q;
|
||||
});
|
||||
}
|
||||
|
||||
/** 구글 `data-sheets-formula`(`=2*R[0]C[-1]`) → 붙일 자리 기준 A1 식(앞 `=` 뺌) */
|
||||
export function r1c1ToA1(_formula: string, _at: CellAddress): string {
|
||||
return todo();
|
||||
export function r1c1ToA1(formula: string, at: CellAddress): string {
|
||||
const text = formula.startsWith("=") ? formula.slice(1) : formula;
|
||||
const axis = (part: string | undefined, base: number): [number, boolean] =>
|
||||
part === undefined || part === ""
|
||||
? [base, false]
|
||||
: part.startsWith("[")
|
||||
? [base + Number(part.slice(1, -1)), false]
|
||||
: [Number(part) - 1, true];
|
||||
// 글 · 따옴표 시트 이름은 건너뜀 · 이름 한가운데(`ROUND`)는 안 건드림
|
||||
return text.replace(
|
||||
/("(?:[^"]|"")*"|'(?:[^']|'')*')|(?<![\p{L}\p{N}_.$])R(\[-?\d+\]|\d+)?C(\[-?\d+\]|\d+)?(?![\p{L}\p{N}_.(])/gu,
|
||||
(whole, quoted: string | undefined, rp: string | undefined, cp: string | undefined) => {
|
||||
if (quoted) return whole;
|
||||
const [r, absR] = axis(rp, at.r);
|
||||
const [c, absC] = axis(cp, at.c);
|
||||
if (r < 0 || r >= MAX_ROWS || c < 0 || c >= MAX_COLS) return REF_ERR;
|
||||
return renderRef({
|
||||
prefix: "",
|
||||
sheet: null,
|
||||
kind: "cell",
|
||||
a: { r, c, absR, absC },
|
||||
b: { r, c, absR, absC },
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_selection.ts (주인 D)
|
||||
* 고름 모델 — 한 칸 · 범위 · 행열 전체 · 전체 · 방향키(Shift 늘림 · Ctrl 끝으로) · 병합으로 늘림 ·
|
||||
* 고름 안 Enter/Tab 돌기. DOM 모름(시험이 그대로 부름). 엑셀 동작이 기준.
|
||||
* ========================================================================== */
|
||||
|
||||
import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type { CellAddress, CellRange, Sheet } from "./spreadsheet_types";
|
||||
import type { Selection } from "./spreadsheet_view_types";
|
||||
|
||||
export const LAST_R = MAX_ROWS - 1;
|
||||
export const LAST_C = MAX_COLS - 1;
|
||||
|
||||
export function merges(sheet: Sheet): CellRange[] {
|
||||
const out: CellRange[] = [];
|
||||
for (const t of sheet.병합 ?? []) {
|
||||
const m = parseRange(t);
|
||||
if (m) out.push(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const overlaps = (a: CellRange, b: CellRange): boolean =>
|
||||
a.r0 <= b.r1 && b.r0 <= a.r1 && a.c0 <= b.c1 && b.c0 <= a.c1;
|
||||
|
||||
export const inRange = (g: CellRange, r: number, c: number): boolean =>
|
||||
r >= g.r0 && r <= g.r1 && c >= g.c0 && c <= g.c1;
|
||||
|
||||
/** 병합 일부를 걸친 범위 → 병합 전체를 품게 늘림(늘린 뒤 새로 걸친 것도) */
|
||||
export function expand(range: CellRange, list: CellRange[]): CellRange {
|
||||
const g = { ...range };
|
||||
for (let grew = true; grew;) {
|
||||
grew = false;
|
||||
for (const m of list) {
|
||||
if (!overlaps(g, m)) continue;
|
||||
if (m.r0 < g.r0 || m.r1 > g.r1 || m.c0 < g.c0 || m.c1 > g.c1) {
|
||||
g.r0 = Math.min(g.r0, m.r0);
|
||||
g.r1 = Math.max(g.r1, m.r1);
|
||||
g.c0 = Math.min(g.c0, m.c0);
|
||||
g.c1 = Math.max(g.c1, m.c1);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/** 칸이 든 병합(없으면 칸 하나) */
|
||||
export function mergeAt(sheet: Sheet, r: number, c: number): CellRange {
|
||||
return merges(sheet).find((m) => inRange(m, r, c)) ?? { r0: r, c0: c, r1: r, c1: c };
|
||||
}
|
||||
|
||||
export const rowHidden = (sheet: Sheet, r: number): boolean => !!sheet.행?.[String(r + 1)]?.숨김;
|
||||
|
||||
export function colHidden(sheet: Sheet, c: number): boolean {
|
||||
const cols = sheet.열;
|
||||
if (!cols) return false;
|
||||
return !!cols[colName(c)]?.숨김;
|
||||
}
|
||||
|
||||
export function hasValue(sheet: Sheet, r: number, c: number): boolean {
|
||||
const cell = sheet.칸[toA1(r, c)];
|
||||
return !!cell && (cell.식 !== undefined || (cell.값 !== undefined && cell.값 !== ""));
|
||||
}
|
||||
|
||||
export function span(a: CellAddress, b: CellAddress): CellRange {
|
||||
return {
|
||||
r0: Math.min(a.r, b.r),
|
||||
c0: Math.min(a.c, b.c),
|
||||
r1: Math.max(a.r, b.r),
|
||||
c1: Math.max(a.c, b.c),
|
||||
};
|
||||
}
|
||||
|
||||
/** 칸 하나 고름 — 병합 안이면 병합 전체 · 활성은 병합 왼위 */
|
||||
export function selectCell(sheet: Sheet, r: number, c: number): Selection {
|
||||
const m = mergeAt(sheet, r, c);
|
||||
const at = { r: m.r0, c: m.c0 };
|
||||
return { 시트: sheet.id, 범위: [m], 활성: at, 기준: { ...at } };
|
||||
}
|
||||
|
||||
/** 닻(기준)에서 `to` 까지 — 활성은 닻 그대로(엑셀 Shift) */
|
||||
export function selectSpan(sheet: Sheet, anchor: CellAddress, to: CellAddress): Selection {
|
||||
const g = expand(span(anchor, to), merges(sheet));
|
||||
return { 시트: sheet.id, 범위: [g], 활성: { ...anchor }, 기준: { ...anchor } };
|
||||
}
|
||||
|
||||
/** 행 전체(r0~r1) · 열 전체(c0~c1) · 전체 — 활성은 첫 보이는 칸 · 걸친 병합만큼 늘림(엑셀) */
|
||||
export function selectRows(sheet: Sheet, a: number, b: number, active?: number): Selection {
|
||||
const r0 = Math.min(a, b);
|
||||
const at = { r: active ?? r0, c: firstVisible(sheet, "c") };
|
||||
return {
|
||||
시트: sheet.id,
|
||||
범위: [expand({ r0, r1: Math.max(a, b), c0: 0, c1: LAST_C }, merges(sheet))],
|
||||
활성: at,
|
||||
기준: { ...at },
|
||||
};
|
||||
}
|
||||
|
||||
export function selectCols(sheet: Sheet, a: number, b: number, active?: number): Selection {
|
||||
const c0 = Math.min(a, b);
|
||||
const at = { r: firstVisible(sheet, "r"), c: active ?? c0 };
|
||||
return {
|
||||
시트: sheet.id,
|
||||
범위: [expand({ r0: 0, r1: LAST_R, c0, c1: Math.max(a, b) }, merges(sheet))],
|
||||
활성: at,
|
||||
기준: { ...at },
|
||||
};
|
||||
}
|
||||
|
||||
export function selectAll(sheet: Sheet, active?: CellAddress): Selection {
|
||||
const at = active ?? { r: firstVisible(sheet, "r"), c: firstVisible(sheet, "c") };
|
||||
return {
|
||||
시트: sheet.id,
|
||||
범위: [{ r0: 0, c0: 0, r1: LAST_R, c1: LAST_C }],
|
||||
활성: at,
|
||||
기준: { ...at },
|
||||
};
|
||||
}
|
||||
|
||||
function firstVisible(sheet: Sheet, axis: "r" | "c"): number {
|
||||
let i = 0;
|
||||
while (i < 200 && (axis === "r" ? rowHidden(sheet, i) : colHidden(sheet, i))) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
/** 한 칸 옮김 — 숨김 건너뜀 · 끝에서 멈춤 */
|
||||
function step(sheet: Sheet, at: CellAddress, dr: number, dc: number): CellAddress {
|
||||
let { r, c } = at;
|
||||
do {
|
||||
r += dr;
|
||||
c += dc;
|
||||
} while (
|
||||
r >= 0 &&
|
||||
r <= LAST_R &&
|
||||
c >= 0 &&
|
||||
c <= LAST_C &&
|
||||
((dr && rowHidden(sheet, r)) || (dc && colHidden(sheet, c)))
|
||||
);
|
||||
if (r < 0 || r > LAST_R || c < 0 || c > LAST_C) return at;
|
||||
return { r, c };
|
||||
}
|
||||
|
||||
/** 한 줄(행 또는 열)에서 값 든 자리 — 오름차순 */
|
||||
function occupied(sheet: Sheet, at: CellAddress, vertical: boolean): number[] {
|
||||
const out: number[] = [];
|
||||
for (const key of Object.keys(sheet.칸)) {
|
||||
const a = parseA1(key);
|
||||
if (!a || !hasValue(sheet, a.r, a.c)) continue;
|
||||
if (vertical ? a.c === at.c : a.r === at.r) out.push(vertical ? a.r : a.c);
|
||||
}
|
||||
return out.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** Ctrl+방향 — 값 사이를 뛰어 끝으로(엑셀: 값 칸 뭉치의 끝 · 빈 칸이면 다음 값 · 없으면 시트 끝) */
|
||||
function jump(sheet: Sheet, at: CellAddress, dr: number, dc: number): CellAddress {
|
||||
const vertical = dr !== 0;
|
||||
const d = vertical ? dr : dc;
|
||||
const pos = vertical ? at.r : at.c;
|
||||
const edge = d > 0 ? (vertical ? LAST_R : LAST_C) : 0;
|
||||
const set = new Set(occupied(sheet, at, vertical));
|
||||
const has = (i: number): boolean => set.has(i);
|
||||
const put = (i: number): CellAddress => (vertical ? { r: i, c: at.c } : { r: at.r, c: i });
|
||||
if (pos === edge) return at;
|
||||
if (has(pos) && has(pos + d)) {
|
||||
let i = pos;
|
||||
while (i !== edge && has(i + d)) i += d;
|
||||
return put(i);
|
||||
}
|
||||
const sorted = [...set].sort((a, b) => a - b);
|
||||
const next = d > 0 ? sorted.find((i) => i > pos) : sorted.reverse().find((i) => i < pos);
|
||||
return put(next ?? edge);
|
||||
}
|
||||
|
||||
export interface MoveOptions {
|
||||
/** Shift — 닻은 두고 움직이는 끝만 */
|
||||
extend?: boolean;
|
||||
/** Ctrl — 값 끝으로 */
|
||||
jump?: boolean;
|
||||
}
|
||||
|
||||
/** 방향키 · Home 류 공통 — 새 고름 */
|
||||
export function move(
|
||||
sheet: Sheet,
|
||||
sel: Selection,
|
||||
dr: number,
|
||||
dc: number,
|
||||
opts: MoveOptions = {},
|
||||
): Selection {
|
||||
const list = merges(sheet);
|
||||
if (!opts.extend) {
|
||||
const from = sel.활성;
|
||||
const m = mergeAt(sheet, from.r, from.c);
|
||||
// 병합에서 나갈 때는 병합 끝에서 한 칸
|
||||
const edge = { r: dr > 0 ? m.r1 : m.r0, c: dc > 0 ? m.c1 : m.c0 };
|
||||
const to = opts.jump ? jump(sheet, edge, dr, dc) : step(sheet, edge, dr, dc);
|
||||
if (to.r === edge.r && to.c === edge.c) return selectCell(sheet, from.r, from.c);
|
||||
return selectCell(sheet, to.r, to.c);
|
||||
}
|
||||
const g = sel.범위[0] ?? span(sel.활성, sel.활성);
|
||||
const anchor = sel.기준;
|
||||
// 움직이는 끝 = 닻에서 먼 쪽
|
||||
const far = {
|
||||
r: Math.abs(g.r1 - anchor.r) >= Math.abs(anchor.r - g.r0) ? g.r1 : g.r0,
|
||||
c: Math.abs(g.c1 - anchor.c) >= Math.abs(anchor.c - g.c0) ? g.c1 : g.c0,
|
||||
};
|
||||
let to = opts.jump ? jump(sheet, far, dr, dc) : step(sheet, far, dr, dc);
|
||||
// 줄이는 쪽이면 병합 너머까지 한 번에
|
||||
for (let guard = 0; guard < 50; guard++) {
|
||||
const next = expand(span(anchor, to), list);
|
||||
if (!sameRange(next, g) || to === far) break;
|
||||
to = step(sheet, to, dr, dc);
|
||||
}
|
||||
return {
|
||||
시트: sheet.id,
|
||||
범위: [expand(span(anchor, to), list)],
|
||||
활성: { ...sel.활성 },
|
||||
기준: { ...anchor },
|
||||
};
|
||||
}
|
||||
|
||||
export const sameRange = (a: CellRange, b: CellRange): boolean =>
|
||||
a.r0 === b.r0 && a.c0 === b.c0 && a.r1 === b.r1 && a.c1 === b.c1;
|
||||
|
||||
/** 쓰인 칸 끝(Ctrl+End) */
|
||||
export function usedEnd(sheet: Sheet): CellAddress {
|
||||
let r = 0;
|
||||
let c = 0;
|
||||
for (const key of Object.keys(sheet.칸)) {
|
||||
const a = parseA1(key);
|
||||
if (!a) continue;
|
||||
r = Math.max(r, a.r);
|
||||
c = Math.max(c, a.c);
|
||||
}
|
||||
return { r, c };
|
||||
}
|
||||
|
||||
/** 고름 안 Enter/Tab 돌기 — 범위가 한 칸(또는 병합 하나)이면 null(밖으로 옮김) */
|
||||
export function cycle(sheet: Sheet, sel: Selection, dr: number, dc: number): Selection | null {
|
||||
const g = sel.범위[0];
|
||||
if (!g) return null;
|
||||
const list = merges(sheet);
|
||||
if (list.some((m) => sameRange(m, g)) || (g.r0 === g.r1 && g.c0 === g.c1)) return null;
|
||||
// 행 전체 · 열 전체는 쓰인 칸 끝까지만 돎
|
||||
const end = usedEnd(sheet);
|
||||
const box = {
|
||||
...g,
|
||||
r1: g.r1 === LAST_R ? Math.max(end.r, g.r0) : g.r1,
|
||||
c1: g.c1 === LAST_C ? Math.max(end.c, g.c0) : g.c1,
|
||||
};
|
||||
let { r, c } = sel.활성;
|
||||
const cur = mergeAt(sheet, r, c);
|
||||
const rows = box.r1 - box.r0 + 1;
|
||||
const cols = box.c1 - box.c0 + 1;
|
||||
for (let n = 0; n < rows * cols; n++) {
|
||||
if (dr) {
|
||||
r += dr;
|
||||
if (r > box.r1) ((r = box.r0), (c = c + 1 > box.c1 ? box.c0 : c + 1));
|
||||
if (r < box.r0) ((r = box.r1), (c = c - 1 < box.c0 ? box.c1 : c - 1));
|
||||
} else {
|
||||
c += dc;
|
||||
if (c > box.c1) ((c = box.c0), (r = r + 1 > box.r1 ? box.r0 : r + 1));
|
||||
if (c < box.c0) ((c = box.c1), (r = r - 1 < box.r0 ? box.r1 : r - 1));
|
||||
}
|
||||
if (rowHidden(sheet, r) || colHidden(sheet, c)) continue;
|
||||
const m = mergeAt(sheet, r, c);
|
||||
if (m.r0 !== r || m.c0 !== c || sameRange(m, cur)) continue;
|
||||
return { ...sel, 범위: sel.범위.map((x) => ({ ...x })), 활성: { r, c } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 고름 안 칸 전부(행열 전체는 쓰인 끝까지) — Ctrl+Enter · 지우기 대상 */
|
||||
export function cellsOf(sheet: Sheet, sel: Selection): CellAddress[] {
|
||||
const end = usedEnd(sheet);
|
||||
const out: CellAddress[] = [];
|
||||
for (const g of sel.범위) {
|
||||
const r1 = g.r1 === LAST_R ? Math.max(end.r, g.r0) : g.r1;
|
||||
const c1 = g.c1 === LAST_C ? Math.max(end.c, g.c0) : g.c1;
|
||||
for (let r = g.r0; r <= r1; r++) for (let c = g.c0; c <= c1; c++) out.push({ r, c });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export interface Cell {
|
||||
값?: CellInput;
|
||||
/** A1 식 — 앞 `=` 뺌(`ROUNDDOWN((G31+J31/2)*P31,2)`). 행열을 넣고 지우면 글을 고쳐 적음. */
|
||||
식?: string;
|
||||
/** `서식` 표 번호 — 없으면 행 · 열 서식 · 그다음 0(기본). */
|
||||
/** `서식` 표 번호 — 없으면 행 · 열 서식 · 시트 `기본.서식` · 그다음 0(기본). */
|
||||
서식?: number;
|
||||
}
|
||||
|
||||
@@ -142,6 +142,11 @@ export interface Sheet {
|
||||
/** 위에서 고정할 행 수 · 왼쪽에서 고정할 열 수. */
|
||||
틀고정?: { 행?: number; 열?: number };
|
||||
보기?: { 눈금선?: boolean };
|
||||
/** 시트 통째(모두 고르기) — 서식 번호 · 열 폭(글자 단위) · 행 높이(pt). 칸 · 행 · 열 값이 없을 때 씀 ·
|
||||
* 없으면 통합문서 `기본`. */
|
||||
기본?: { 서식?: number; 열폭?: number; 행높이?: number };
|
||||
/** 칸 메모 — A1 열쇠 → 메모 글. 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 메모는 지움). */
|
||||
comments?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */
|
||||
|
||||
@@ -149,6 +149,7 @@ body > canvas[data-id="canvas"] {
|
||||
flex-direction: column;
|
||||
background: var(--cad-chrome-raised);
|
||||
border-bottom: 1px solid var(--cad-line);
|
||||
transition: height 120ms ease;
|
||||
}
|
||||
.cad-ribbon-tabs {
|
||||
display: flex;
|
||||
@@ -157,6 +158,21 @@ body > canvas[data-id="canvas"] {
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid var(--cad-line);
|
||||
}
|
||||
.cad-ribbon-tabs__collapse {
|
||||
margin-left: auto;
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
align-self: center;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
.cad-ribbon-tabs__collapse:hover {
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-ribbon-tabs button {
|
||||
padding: 0 14px;
|
||||
border: 1px solid transparent;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { QuickAccessBar } from './QuickAccessBar';
|
||||
import { Ribbon } from '../ribbon/Ribbon';
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { useCadRefresh } from './use-cad-refresh';
|
||||
import { isRibbonCollapsed } from './ui-state';
|
||||
import { getActiveToolActor } from '../state';
|
||||
import type { Tool } from '../tools';
|
||||
import { ViewControls } from './ViewControls';
|
||||
@@ -22,6 +23,7 @@ export const Toolbar: FC = () => {
|
||||
const [commandLineVisible, setCommandLineVisible] = useState(false);
|
||||
|
||||
const activeTool = (getActiveToolActor()?.getSnapshot()?.context?.type ?? null) as Tool | null;
|
||||
const ribbonCollapsed = isRibbonCollapsed();
|
||||
|
||||
useEffect(() => {
|
||||
// 편 상태의 폭은 App.css의 `--cad-panel-width` 기본값을 쓴다. 여기 숫자를 박아
|
||||
@@ -32,8 +34,11 @@ export const Toolbar: FC = () => {
|
||||
'--cad-command-height',
|
||||
commandLineVisible ? '86px' : '0px'
|
||||
);
|
||||
// 리본 접힘 — 탭 줄(26px)만 남기고 패널 줄 자리를 걷는다(`Ribbon.tsx` 가 안 그림).
|
||||
if (ribbonCollapsed) document.documentElement.style.setProperty('--cad-ribbon-height', '26px');
|
||||
else document.documentElement.style.removeProperty('--cad-ribbon-height');
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}, [panelCollapsed, commandLineVisible]);
|
||||
}, [panelCollapsed, commandLineVisible, ribbonCollapsed]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -12,6 +12,8 @@ let inspectorCollapsed = false;
|
||||
let quickPropertiesVisible = false;
|
||||
let activeRibbonTab = 'home';
|
||||
let blockLibraryVisible = false;
|
||||
/** 호스트(M02)가 처음부터 접어 실을 때 true — 탭을 누르면 펼침(`setActiveRibbonTab`). */
|
||||
let ribbonCollapsed = false;
|
||||
|
||||
const notify = () => notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
|
||||
@@ -20,12 +22,18 @@ export const isInspectorCollapsed = () => inspectorCollapsed;
|
||||
export const isQuickPropertiesVisible = () => quickPropertiesVisible;
|
||||
export const getActiveRibbonTab = () => activeRibbonTab;
|
||||
export const isBlockLibraryVisible = () => blockLibraryVisible;
|
||||
export const isRibbonCollapsed = () => ribbonCollapsed;
|
||||
|
||||
export function setActiveRibbonTab(tabId: string): void {
|
||||
activeRibbonTab = tabId;
|
||||
notify();
|
||||
}
|
||||
|
||||
export function setRibbonCollapsed(collapsed: boolean): void {
|
||||
ribbonCollapsed = collapsed;
|
||||
notify();
|
||||
}
|
||||
|
||||
export function openInspector(tab: InspectorTab): void {
|
||||
inspectorTab = tab;
|
||||
inspectorCollapsed = false;
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ export async function getEntitiesAndLayersFromJsonObject(
|
||||
if (groupId) entity.groupId = groupId;
|
||||
}
|
||||
let layers = data.layers;
|
||||
if (data.layers.length === 0) {
|
||||
if (!layers || layers.length === 0) {
|
||||
layers = [getNewLayer()];
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
setHostTitle,
|
||||
setLayers,
|
||||
} from '../state.ts';
|
||||
import { setRibbonCollapsed } from '../components/ui-state.ts';
|
||||
import { toast } from 'react-toastify';
|
||||
import { runCommandInput } from '../commands/run-command.ts';
|
||||
import { setRecoveryScope } from '../helpers/autosave.ts';
|
||||
@@ -57,6 +58,8 @@ interface DrawingLoadMessage {
|
||||
hostTitle?: string;
|
||||
/** 저장(💾 · Ctrl+S)을 부모 [저장]에 맡기는가. */
|
||||
hostSave?: boolean;
|
||||
/** 리본을 접힌 채로 여는가 — 없으면(B07) 펼침 그대로. */
|
||||
ribbonCollapsed?: boolean;
|
||||
}
|
||||
|
||||
interface DrawingSaveRequestMessage {
|
||||
@@ -207,6 +210,7 @@ export function registerAisloDrawingBridge() {
|
||||
setHostReadOnly(event.data.readOnly === true);
|
||||
setHostTitle(event.data.hostTitle ?? 'B07 상세 설계');
|
||||
setHostSave(event.data.hostSave === true);
|
||||
setRibbonCollapsed(event.data.ribbonCollapsed === true);
|
||||
if (event.data.frameEdit) applyFramePreview();
|
||||
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
|
||||
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
|
||||
|
||||
@@ -3,7 +3,12 @@ import { type FC, type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import type { CadCommand } from '../commands/command.types';
|
||||
import { getCommandById } from '../commands/registry';
|
||||
import { runCommand } from '../commands/run-command';
|
||||
import { getActiveRibbonTab, setActiveRibbonTab } from '../components/ui-state';
|
||||
import {
|
||||
getActiveRibbonTab,
|
||||
isRibbonCollapsed,
|
||||
setActiveRibbonTab,
|
||||
setRibbonCollapsed,
|
||||
} from '../components/ui-state';
|
||||
import type { Tool } from '../tools';
|
||||
import { RIBBON_TABS, type RibbonPanel, type RibbonWidget } from './ribbon.config';
|
||||
|
||||
@@ -147,6 +152,7 @@ const Panel: FC<{
|
||||
export const Ribbon: FC<RibbonProps> = ({ activeTool, widgets, onCommandRun }) => {
|
||||
const activeTabId = getActiveRibbonTab();
|
||||
const activeTab = RIBBON_TABS.find((tab) => tab.id === activeTabId) ?? RIBBON_TABS[0];
|
||||
const collapsed = isRibbonCollapsed();
|
||||
|
||||
const handleRun = (command: CadCommand) => {
|
||||
runCommand(command);
|
||||
@@ -161,23 +167,36 @@ export const Ribbon: FC<RibbonProps> = ({ activeTool, widgets, onCommandRun }) =
|
||||
type="button"
|
||||
key={tab.id}
|
||||
data-active={tab.id === activeTab.id}
|
||||
onClick={() => setActiveRibbonTab(tab.id)}
|
||||
onClick={() => {
|
||||
setActiveRibbonTab(tab.id);
|
||||
if (collapsed) setRibbonCollapsed(false);
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="cad-ribbon-tabs__collapse"
|
||||
onClick={() => setRibbonCollapsed(!collapsed)}
|
||||
title={collapsed ? '리본 펼치기' : '리본 접기'}
|
||||
>
|
||||
{collapsed ? '⌄' : '⌃'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="cad-ribbon-panels">
|
||||
{activeTab.panels.map((panel) => (
|
||||
<Panel
|
||||
key={panel.label}
|
||||
panel={panel}
|
||||
activeTool={activeTool}
|
||||
widgets={widgets}
|
||||
onRun={handleRun}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="cad-ribbon-panels">
|
||||
{activeTab.panels.map((panel) => (
|
||||
<Panel
|
||||
key={panel.label}
|
||||
panel={panel}
|
||||
activeTool={activeTool}
|
||||
widgets={widgets}
|
||||
onRun={handleRun}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,8 +7,9 @@ import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
/** 층 이름 — 서버와 같은 글 */
|
||||
export type Layer = "system" | "company" | "personal" | "project";
|
||||
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure */
|
||||
export type Kind = "table" | "drawing" | "structure";
|
||||
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure ·
|
||||
* 「상세 산출근거」 = basis(구조물집계표 열마다 한 통합문서) */
|
||||
export type Kind = "table" | "drawing" | "structure" | "basis";
|
||||
|
||||
export interface TemplateInfo {
|
||||
종류: Kind;
|
||||
@@ -95,7 +96,7 @@ export const deleteLayerTemplate = (
|
||||
/* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) ---
|
||||
* 읽기 · 저장 · 지우기는 위 readTemplate · saveTemplate · deleteTemplate 에 "structure" */
|
||||
|
||||
/** 구조물 도면 문서 — `도면` = 도면 양식과 같은 CAD 문서 · `산출근거` = 표 양식과 같은 표 문서 */
|
||||
/** 구조물 도면 문서 — `도면` = 도면 양식과 같은 CAD 문서 · 산출근거는 따로(`basis` · 같은 열 id) */
|
||||
export interface StructureDoc {
|
||||
양식: "구조물도면";
|
||||
종류: "structure";
|
||||
@@ -103,14 +104,13 @@ export interface StructureDoc {
|
||||
열: string;
|
||||
도번: string;
|
||||
도면: { entities: unknown[] } & Record<string, unknown>;
|
||||
산출근거: { 열: unknown[]; 줄: unknown[] } & Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 시스템 층 구조물 도면 목록 */
|
||||
export const listStructures = async (): Promise<TemplateInfo[]> =>
|
||||
(await listTemplates("system", null)).filter((t) => t.종류 === "structure");
|
||||
|
||||
/** 새로 — 도번 자동 · A1 도각을 깐 빈 도면 + 빈 산출근거 표 · 이미 있으면 StaleError */
|
||||
/** 새로 — 도번 자동 · 빈 도면 (+ 없으면 빈 산출근거 통합문서) · 이미 있으면 StaleError */
|
||||
export const createStructure = (column: string): Promise<TemplateDoc> =>
|
||||
call(`/structures/${enc(column)}`, { method: "POST" });
|
||||
|
||||
@@ -118,6 +118,11 @@ export const createStructure = (column: string): Promise<TemplateDoc> =>
|
||||
export const fetchStructureNumbers = (): Promise<Record<string, string>> =>
|
||||
call("/structures/numbers");
|
||||
|
||||
/* --- ⚠ 임시 — [스프레드시트 시험] 단추(1단계 검증 뒤 지움) · 저장 자리 = 서버 tmp 견본 --- */
|
||||
export const readSpreadsheetTrial = (): Promise<{ 문서: unknown }> => call("/spreadsheet-trial");
|
||||
export const saveSpreadsheetTrial = (문서: unknown): Promise<{ 문서: unknown }> =>
|
||||
call("/spreadsheet-trial", { method: "PUT", body: JSON.stringify({ 문서 }) });
|
||||
|
||||
/* --- 프로젝트 층 단추 다섯 --- */
|
||||
const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`;
|
||||
const post = (path: string, body: object = {}): Promise<unknown> =>
|
||||
|
||||
@@ -189,7 +189,13 @@ export function mountDrawingTemplate(
|
||||
null,
|
||||
!readOnly,
|
||||
{},
|
||||
{ recoveryScope, readOnly, hostTitle: "도면 양식 편집", hostSave: true },
|
||||
{
|
||||
recoveryScope,
|
||||
readOnly,
|
||||
hostTitle: "도면 양식 편집",
|
||||
hostSave: true,
|
||||
ribbonCollapsed: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from common_util import common_util_spreadsheet as spreadsheet
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
|
||||
@@ -57,3 +61,34 @@ def put_template(kind: str, name: str, body: SaveBody) -> dict:
|
||||
def delete_template(kind: str, name: str, 판: str | None = None) -> dict:
|
||||
_call(store.delete, kind, name, 판)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── ⚠ 임시(스프레드시트 시험 단추 · 사용자 지시 · 1단계 검증 뒤 지움) ──────────
|
||||
# 저장 자리 = `tmp/spreadsheet_trial.json`(git 밖) · 없으면 시트 셋 빈 통합문서.
|
||||
TRIAL_PATH = Path(__file__).resolve().parent.parent / "tmp" / "spreadsheet_trial.json"
|
||||
|
||||
|
||||
def _trial_doc() -> dict[str, Any]:
|
||||
sheets = [{"id": f"s{i}", "이름": f"시트{i}", "칸": {}} for i in (1, 2, 3)]
|
||||
return {"종류": "통합문서", "판": 1, "열": "시험", "서식": [{}], "시트": sheets, "활성": "s1"}
|
||||
|
||||
|
||||
@router.get("/spreadsheet-trial")
|
||||
def get_spreadsheet_trial() -> dict:
|
||||
if not TRIAL_PATH.is_file():
|
||||
return {"문서": _trial_doc()}
|
||||
return {"문서": json.loads(TRIAL_PATH.read_text(encoding="utf-8"))}
|
||||
|
||||
|
||||
class TrialBody(BaseModel):
|
||||
문서: dict[str, Any]
|
||||
|
||||
|
||||
@router.put("/spreadsheet-trial")
|
||||
def put_spreadsheet_trial(body: TrialBody) -> dict:
|
||||
try:
|
||||
doc = spreadsheet.with_server_values(body.문서)
|
||||
except spreadsheet.RecalcError as e:
|
||||
raise HTTPException(status_code=503, detail=str(e)) from e
|
||||
atomic_write_json(TRIAL_PATH, doc)
|
||||
return {"문서": doc}
|
||||
|
||||
@@ -22,6 +22,7 @@ from B06_Section.B06_Section_Repository import (
|
||||
get_cross_section_designs,
|
||||
get_workflow_route_context,
|
||||
)
|
||||
from common_util import common_util_spreadsheet as spreadsheet
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool, run_with_connection
|
||||
@@ -260,6 +261,12 @@ async def save_layer_template(
|
||||
if kind == "table" and fill.is_fillable(document):
|
||||
# 채운 표가 와도 설계값은 안 받아 적음 — 양식 + 손 값만(5장 · 브라우저 값을 믿지 않음)
|
||||
document = fill.strip_design(document)
|
||||
if kind == "basis":
|
||||
# 산출근거 계산값은 서버가 같은 TS 엔진을 Node 로 돌려 새로 적음(브라우저 값 버림 · 5장 ②)
|
||||
try:
|
||||
document = await asyncio.to_thread(spreadsheet.with_server_values, document)
|
||||
except spreadsheet.RecalcError as error:
|
||||
raise HTTPException(status_code=503, detail=str(error)) from error
|
||||
try:
|
||||
version = await asyncio.to_thread(
|
||||
layers.write_template,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/* ⚠ 임시 — [스프레드시트 시험] 화면(1단계 검증 뒤 지움) */
|
||||
.m02-trial {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m02-trial__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.m02-trial__note {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.m02-trial__sheet {
|
||||
width: 100%;
|
||||
height: 75vh;
|
||||
min-height: 420px;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Spreadsheet_Trial.ts
|
||||
* ⚠ 임시 — [스프레드시트 시험] 단추(사용자 지시 · 스프레드시트 1단계 검증 뒤 지움:
|
||||
* 이 파일 · `M02_MasterTemplete_UI_Main.ts` 의 시험 단추 · Api_Fetch `*SpreadsheetTrial` ·
|
||||
* Router `/spreadsheet-trial` · 저장 자리 `tmp/spreadsheet_trial.json`).
|
||||
* 빈 통합문서(시트 셋)를 메인 칸에 넓게 엶 · [시험 저장] = 서버가 같은 엔진으로 다시 풀어 tmp 에 적음.
|
||||
* 글자는 이 파일에 둠.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import { readSpreadsheetTrial, saveSpreadsheetTrial } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import "./M02_MasterTemplete_Spreadsheet_Trial.css";
|
||||
|
||||
const TEXT = {
|
||||
save: "시험 저장",
|
||||
note: "임시 시험 화면 — 저장은 서버 tmp 견본 자리(작업 양식과 무관)",
|
||||
saved: "시험 통합문서를 저장했음(서버 재계산)",
|
||||
failed: "스프레드시트 시험 실패 — {value}",
|
||||
};
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
|
||||
export interface TrialHandle {
|
||||
getDoc: () => unknown;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
/** `onDirty` = 고침 있음 · 저장 뒤 없음(페이지의 떠날 때 물음과 이음) */
|
||||
export function mountSpreadsheetTrial(
|
||||
host: HTMLElement,
|
||||
onDirty: (dirty: boolean) => void,
|
||||
): TrialHandle {
|
||||
let sheet: SpreadsheetHandle | null = null;
|
||||
let alive = true;
|
||||
const save = createButton({ label: TEXT.save, variant: "filled" });
|
||||
const area = el("div", { className: "m02-trial__sheet" });
|
||||
const root = el("div", {
|
||||
className: "m02-trial",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m02-trial__bar",
|
||||
children: [el("span", { className: "m02-trial__note", text: TEXT.note }), save],
|
||||
}),
|
||||
area,
|
||||
],
|
||||
});
|
||||
host.replaceChildren(root);
|
||||
const fail = (error: unknown): void =>
|
||||
showToast(TEXT.failed.replace("{value}", why(error)), "error");
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [{ createSpreadsheet }, got] = await Promise.all([
|
||||
import("../A00_Common/spreadsheet/spreadsheet"),
|
||||
readSpreadsheetTrial(),
|
||||
]);
|
||||
if (!alive) return;
|
||||
sheet = createSpreadsheet(area, got.문서 as Workbook, { onChange: () => onDirty(true) });
|
||||
} catch (error) {
|
||||
if (!alive) return;
|
||||
area.replaceChildren(el("p", { className: "m02-trial__empty", text: why(error) }));
|
||||
fail(error);
|
||||
}
|
||||
})();
|
||||
|
||||
save.addEventListener("click", async () => {
|
||||
if (!sheet) return;
|
||||
save.disabled = true;
|
||||
try {
|
||||
await saveSpreadsheetTrial(sheet.getDoc());
|
||||
onDirty(false);
|
||||
showToast(TEXT.saved, "success");
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
} finally {
|
||||
save.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
getDoc: () => sheet?.getDoc() ?? null,
|
||||
destroy: () => {
|
||||
alive = false;
|
||||
sheet?.destroy();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,14 +14,22 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util import common_util_spreadsheet as spreadsheet
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template"
|
||||
KINDS = layers.KINDS # 시험은 FOLDER 를 사본으로 바꿈
|
||||
# 구조물 도면 = 구조물집계표 열 id 마다 하나(파일 이름 = 열 id) · 도각 없는 전용 화면(그린 만큼이 설계 영역)
|
||||
# 산출근거 = 같은 열 id 로 따로 한 파일(`basis/<열 id>.json` · 스프레드시트 통합문서)
|
||||
STRUCTURE_TABLE = "구조물집계표"
|
||||
_BASIS_HEAD = (("work", "공종"), ("spec", "규격"), ("detail", "산출 내역"), ("unit", "단위"))
|
||||
_BASIS_HEAD = ("공종", "규격", "산출 내역", "단위", "수량(m당)")
|
||||
_BASIS_HEAD_STYLE = {
|
||||
"굵게": True,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {side: {"선": "thin"} for side in ("위", "아래", "왼", "오른")},
|
||||
}
|
||||
_NUMBER = re.compile(r"^구-(\d+)$")
|
||||
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
|
||||
# ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로
|
||||
@@ -80,10 +88,14 @@ def _check_skeleton(kind: str, doc: Any) -> None:
|
||||
|
||||
def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
|
||||
"""`version` 이 빈 글이면 새로 만듦(이미 있으면 409) · 아니면 그 판일 때만 덮어씀.
|
||||
구조물집계표를 저장하면 — 새 열의 빈 구조물 도면도 같이 만듦(잠금 밖에서 · `create_structure`
|
||||
가 다시 잠금을 잡으므로 안에서 부르면 죽음)."""
|
||||
구조물집계표를 저장하면 — 새 열의 빈 구조물 도면 · 산출근거도 같이 만듦(잠금 밖에서 ·
|
||||
`create_structure` 가 다시 잠금을 잡으므로 안에서 부르면 죽음).
|
||||
산출근거(basis)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음
|
||||
(브라우저 값 버림 · 5장 ②)."""
|
||||
path = _path(kind, name)
|
||||
_check_skeleton(kind, doc)
|
||||
if kind == "basis":
|
||||
doc = _server_values(doc)
|
||||
with _LOCK:
|
||||
have = version_of(path.read_bytes()) if path.is_file() else ""
|
||||
if have != (version or ""):
|
||||
@@ -125,16 +137,17 @@ def _number(doc: Any) -> int:
|
||||
return int(found.group(1)) if found else 0
|
||||
|
||||
|
||||
def create_structure(column: str) -> dict[str, Any]:
|
||||
"""열 id 하나의 구조물 도면 새로 — 도번 = 있는 것 중 가장 큰 번호 + 1 · 이미 있으면 409."""
|
||||
path = _path("structure", column)
|
||||
def _column_ids() -> list[str]:
|
||||
table = read("table", STRUCTURE_TABLE)["문서"]
|
||||
if column not in {col.get("id") for col in table.get("열", []) if isinstance(col, dict)}:
|
||||
return [col["id"] for col in table.get("열", []) if isinstance(col, dict) and col.get("id")]
|
||||
|
||||
|
||||
def create_structure(column: str) -> dict[str, Any]:
|
||||
"""열 id 하나의 구조물 도면 새로 — 도번 = 있는 것 중 가장 큰 번호 + 1 · 이미 있으면 409.
|
||||
산출근거 파일이 없으면 빈 통합문서도 같이 만듦."""
|
||||
path = _path("structure", column)
|
||||
if column not in _column_ids():
|
||||
raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 열 「{column}」")
|
||||
basis = [{"id": key, "머리": [label], "단위": None, "꼴": "글"} for key, label in _BASIS_HEAD]
|
||||
basis.append({"id": "qty", "머리": ["수량(m당)"], "단위": None, "꼴": "수"})
|
||||
# 빈 줄 몇 개를 미리 둠 — 「줄 더하기」 안 눌러도 바로 입력하게
|
||||
basis_rows = [{"id": str(i), "값": {}} for i in range(1, 4)]
|
||||
with _LOCK:
|
||||
if path.is_file():
|
||||
raise StoreError(409, f"이미 있는 구조물 도면 「{column}」")
|
||||
@@ -146,21 +159,107 @@ def create_structure(column: str) -> dict[str, Any]:
|
||||
"열": column,
|
||||
"도번": f"구-{number:02d}",
|
||||
"도면": {"format": 6, "entities": []},
|
||||
"산출근거": {"양식": "산출근거", "종류": "표", "판": 1, "열": basis, "줄": basis_rows},
|
||||
}
|
||||
atomic_write_json(path, doc)
|
||||
ensure_basis(column)
|
||||
return read("structure", column)
|
||||
|
||||
|
||||
# ── 산출근거 ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _col_letters(index: int) -> str:
|
||||
letters = ""
|
||||
index += 1
|
||||
while index:
|
||||
index, rest = divmod(index - 1, 26)
|
||||
letters = chr(65 + rest) + letters
|
||||
return letters
|
||||
|
||||
|
||||
def basis_doc(column: str, old: Any = None) -> dict[str, Any]:
|
||||
"""빈 산출근거 통합문서 — 1 행 머리(공종 · 규격 · 산출 내역 · 단위 · 수량(m당)).
|
||||
`old` = 옛 구조물 문서 `산출근거` 표(열 · 줄) — 머리 글 · 칸 값 · 열 폭을 옮김
|
||||
(옛 열 식은 없어 안 옮김)."""
|
||||
columns = old.get("열") if isinstance(old, dict) and isinstance(old.get("열"), list) else None
|
||||
if columns:
|
||||
heads = [
|
||||
" ".join(str(h) for h in (c.get("머리") or []) if h) or c.get("id", "") for c in columns
|
||||
]
|
||||
else:
|
||||
heads, columns = list(_BASIS_HEAD), []
|
||||
cells: dict[str, Any] = {
|
||||
f"{_col_letters(i)}1": {"값": head, "서식": 1} for i, head in enumerate(heads) if head
|
||||
}
|
||||
for r, row in enumerate(old.get("줄", []) if columns else [], start=2):
|
||||
values = row.get("값", {}) if isinstance(row, dict) else {}
|
||||
for i, col in enumerate(columns):
|
||||
value = values.get(col.get("id"))
|
||||
if value not in (None, ""):
|
||||
cells[f"{_col_letters(i)}{r}"] = {"값": value}
|
||||
widths = ((old or {}).get("보기") or {}).get("열너비") or {} if columns else {}
|
||||
# 옛 폭은 px · 새 폭은 엑셀 글자 단위(숫자 폭 7px 어림)
|
||||
info = {
|
||||
_col_letters(i): {"폭": round(widths[c["id"]] / 7, 1)}
|
||||
for i, c in enumerate(columns)
|
||||
if isinstance(widths.get(c.get("id")), (int, float))
|
||||
}
|
||||
sheet: dict[str, Any] = {"id": "s1", "이름": "산출근거", "칸": cells}
|
||||
if info:
|
||||
sheet["열"] = info
|
||||
return {
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": column,
|
||||
"서식": [{}, _BASIS_HEAD_STYLE],
|
||||
"시트": [sheet],
|
||||
"활성": "s1",
|
||||
}
|
||||
|
||||
|
||||
def ensure_basis(column: str, old: Any = None) -> bool:
|
||||
"""산출근거 파일이 없으면 만듦(식 없는 새 문서라 `계산값` 없음 · 첫 [저장] 때 서버가 적음)."""
|
||||
path = _path("basis", column)
|
||||
with _LOCK:
|
||||
if path.is_file():
|
||||
return False
|
||||
atomic_write_json(path, basis_doc(column, old))
|
||||
return True
|
||||
|
||||
|
||||
def _server_values(doc: Any) -> dict[str, Any]:
|
||||
try:
|
||||
return spreadsheet.with_server_values(doc)
|
||||
except spreadsheet.RecalcError as e:
|
||||
raise StoreError(503, str(e)) from e
|
||||
|
||||
|
||||
def migrate_basis() -> dict[str, list[str]]:
|
||||
"""한 번 — 옛 구조물 문서 안 `산출근거` 표를 `basis/<열 id>.json` 으로 옮기고
|
||||
구조물 문서에서 뺌 · 그 뒤 산출근거가 없는 집계표 열은 빈 통합문서.
|
||||
두 번 돌려도 같음(있는 파일은 안 덮음)."""
|
||||
moved: list[str] = []
|
||||
for name, doc in _structures():
|
||||
if not isinstance(doc, dict) or "산출근거" not in doc:
|
||||
continue
|
||||
ensure_basis(name, doc["산출근거"])
|
||||
rest = {key: value for key, value in doc.items() if key != "산출근거"}
|
||||
with _LOCK:
|
||||
atomic_write_json(_path("structure", name), rest)
|
||||
moved.append(name)
|
||||
made = [column for column in _column_ids() if ensure_basis(column)]
|
||||
return {"옮김": moved, "새로": made}
|
||||
|
||||
|
||||
def backfill_missing_structures() -> list[str]:
|
||||
"""구조물집계표 열마다 빈 구조물 도면이 있게 — 없는 열만 열 차례로 새로 만듦(도번 이어서).
|
||||
"""구조물집계표 열마다 빈 구조물 도면 · 산출근거가 있게 — 없는 열만 열 차례로 새로
|
||||
만듦(도번 이어서).
|
||||
그 사이 남이 만들었거나(409) 열 규칙에 안 맞으면(404) 그 열만 건너뜀."""
|
||||
table = read("table", STRUCTURE_TABLE)["문서"]
|
||||
have = {name for name, _ in _structures()}
|
||||
made: list[str] = []
|
||||
for col in table.get("열", []):
|
||||
cid = col.get("id") if isinstance(col, dict) else None
|
||||
if not cid or cid in have:
|
||||
for cid in _column_ids():
|
||||
if cid in have:
|
||||
ensure_basis(cid)
|
||||
continue
|
||||
try:
|
||||
create_structure(cid)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Structure.ts
|
||||
* 구조물집계표 아래 설계 컨테이너 — 고른 열의 구조물 도면 미리보기 · [도면 수정] · 상세 산출근거 표.
|
||||
* 구조물집계표 아래 설계 컨테이너 — 고른 열의 구조물 도면 미리보기 · [도면 수정] · 상세 산출근거.
|
||||
* 카드 둘(도면 정보 · 상세 산출근거)은 좌측 패널과 같은 공용 ui-sidebar 틀.
|
||||
* 상세 산출근거 = 스프레드시트(`A00_Common/spreadsheet/`) · 열마다 따로 파일(`basis/<열 id>.json`) ·
|
||||
* 부품은 이 카드를 열 때만 `import()` 로 받음(다른 페이지 번들에 안 섞임) · [산출근거 저장] 때 서버가 다시 풂.
|
||||
*
|
||||
* 구조물 도면은 집계표 열마다 하나(`resources/master_template/structure/<열 id>.json`) ·
|
||||
* 서버가 열마다 미리 만들어 둠(도번 자동 · 새 열도 집계표 저장 때 서버가 같이 만듦 — `M02_MasterTemplete_Store.py`).
|
||||
@@ -11,7 +13,7 @@
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { createSheet, type SheetDoc, type SheetHandle } from "@ui/sheet/ui_template_sheet";
|
||||
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import {
|
||||
fetchStructureNumbers,
|
||||
readTemplate,
|
||||
@@ -36,17 +38,20 @@ const TEXT = {
|
||||
basis: "상세 산출근거",
|
||||
noNumber: "도번 없음",
|
||||
noDrawing: "도면 없음",
|
||||
noBasis: "표 없음 — 도면이 아직 없음",
|
||||
noBasis: "산출근거 없음",
|
||||
noSheet: "스프레드시트를 못 띄움 — {value}",
|
||||
saved: "산출근거를 저장했음",
|
||||
dirty: "저장 안 한 산출근거 고침이 있음 — 버리고 넘어갈까?",
|
||||
kept: "열을 바꾸지 않음 — 산출근거를 먼저 저장할 것",
|
||||
stale: "그 사이 구조물 도면이 바뀜 — 열을 다시 고를 것",
|
||||
stale: "그 사이 산출근거가 바뀜 — 열을 다시 고를 것",
|
||||
readFailed: "구조물 도면을 못 읽음 — {value}",
|
||||
saveFailed: "저장 못 함 — {value}",
|
||||
};
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const fill = (text: string, value: string): string => text.replace("{value}", value);
|
||||
/** 스프레드시트는 산출근거 카드를 열 때만 받음 */
|
||||
const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet");
|
||||
|
||||
/** 집계표 열 — 머리 글만 씀 */
|
||||
export interface StructureColumn {
|
||||
@@ -83,9 +88,10 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
let column: string | null = null;
|
||||
let columnLabel = "";
|
||||
let doc: StructureDoc | null = null;
|
||||
let version = "";
|
||||
let basisDoc: Workbook | null = null;
|
||||
let basisVersion = "";
|
||||
let preview: DrawingTemplateHandle | null = null;
|
||||
let basis: SheetHandle | null = null;
|
||||
let basis: SpreadsheetHandle | null = null;
|
||||
let basisDirty = false;
|
||||
let busy = false;
|
||||
let seq = 0;
|
||||
@@ -138,26 +144,42 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
if (!inner?.head || inner.getElementById("m02-struct-hide")) return;
|
||||
const style = inner.createElement("style");
|
||||
style.id = "m02-struct-hide";
|
||||
style.textContent = ".cad-titlebar, .cad-ribbon { display: none !important; }";
|
||||
style.textContent =
|
||||
".cad-titlebar, .cad-ribbon, .cad-inspector { display: none !important; }";
|
||||
inner.head.append(style);
|
||||
};
|
||||
frame.addEventListener("load", apply);
|
||||
apply();
|
||||
};
|
||||
|
||||
/** 미리보기 · 산출근거 표 — 문서가 없으면 없음 글(서버가 열마다 미리 만들어 둬 보통은 없음) */
|
||||
const paintBody = (): void => {
|
||||
/** 산출근거 스프레드시트 — 부품을 늦게 받음 · 그 사이 열이 바뀌면 버림 */
|
||||
const paintBasis = async (mine: number): Promise<void> => {
|
||||
if (!basisDoc) return void basisHost.replaceChildren(note(TEXT.noBasis));
|
||||
try {
|
||||
const { createSpreadsheet } = await loadSpreadsheet();
|
||||
if (mine !== seq || !basisDoc) return;
|
||||
basis = createSpreadsheet(basisHost, basisDoc, {
|
||||
readOnly: !opts.isAdmin,
|
||||
onChange: () => (basisDirty = true),
|
||||
});
|
||||
} catch (error) {
|
||||
if (mine === seq) basisHost.replaceChildren(note(fill(TEXT.noSheet, why(error))));
|
||||
}
|
||||
};
|
||||
|
||||
/** 미리보기 · 산출근거 — 문서가 없으면 없음 글(서버가 열마다 미리 만들어 둬 보통은 없음) */
|
||||
const paintBody = (mine: number): void => {
|
||||
drop();
|
||||
number.textContent = doc?.도번 || numbers[column ?? ""] || TEXT.noNumber;
|
||||
edit.hidden = !opts.isAdmin;
|
||||
saveBasis.hidden = !opts.isAdmin || !doc;
|
||||
saveBasis.hidden = !opts.isAdmin || !basisDoc;
|
||||
basisHost.replaceChildren();
|
||||
void paintBasis(mine);
|
||||
if (!doc) {
|
||||
previewHost.replaceChildren(note(TEXT.noDrawing));
|
||||
basisHost.replaceChildren(note(TEXT.noBasis));
|
||||
return;
|
||||
}
|
||||
previewHost.replaceChildren();
|
||||
basisHost.replaceChildren();
|
||||
// 미리보기 — 도면 양식과 같은 부품을 읽기만으로. 자동백업 칸은 편집 화면과 갈라 둠.
|
||||
preview = mountDrawingTemplate(previewHost, doc.도면 as unknown as DrawingTemplateDoc, {
|
||||
readOnly: true,
|
||||
@@ -166,10 +188,6 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
});
|
||||
const frame = previewHost.querySelector("iframe");
|
||||
if (frame) hideToolbar(frame);
|
||||
basis = createSheet(basisHost, doc.산출근거 as unknown as SheetDoc, {
|
||||
mode: "master",
|
||||
onChange: () => (basisDirty = true),
|
||||
});
|
||||
};
|
||||
|
||||
async function load(col: StructureColumn | null): Promise<void> {
|
||||
@@ -187,20 +205,23 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
columnName.textContent = columnLabel;
|
||||
number.textContent = numbers[col.id] ?? TEXT.noNumber;
|
||||
doc = null;
|
||||
version = "";
|
||||
basisDoc = null;
|
||||
basisVersion = "";
|
||||
previewHost.replaceChildren();
|
||||
basisHost.replaceChildren();
|
||||
try {
|
||||
const got = await readTemplate("system", "structure", col.id, null);
|
||||
if (mine !== seq) return;
|
||||
doc = got.문서 as StructureDoc;
|
||||
version = got.판 ?? "";
|
||||
} catch (error) {
|
||||
if (mine !== seq) return;
|
||||
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
|
||||
if (numbers[col.id]) showToast(fill(TEXT.readFailed, why(error)), "error");
|
||||
const [drawing, sheet] = await Promise.allSettled([
|
||||
readTemplate("system", "structure", col.id, null),
|
||||
readTemplate("system", "basis", col.id, null),
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
if (drawing.status === "fulfilled") doc = drawing.value.문서 as StructureDoc;
|
||||
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
|
||||
else if (numbers[col.id]) showToast(fill(TEXT.readFailed, why(drawing.reason)), "error");
|
||||
if (sheet.status === "fulfilled") {
|
||||
basisDoc = sheet.value.문서 as Workbook;
|
||||
basisVersion = sheet.value.판 ?? "";
|
||||
}
|
||||
paintBody();
|
||||
paintBody(mine);
|
||||
}
|
||||
|
||||
const show = (colId: string | null, columns: StructureColumn[]): void => {
|
||||
@@ -216,17 +237,15 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
|
||||
saveBasis.addEventListener("click", async () => {
|
||||
const at = column;
|
||||
if (!at || !doc || !basis || busy) return;
|
||||
if (!at || !basis || busy) return;
|
||||
busy = true;
|
||||
saveBasis.disabled = true;
|
||||
try {
|
||||
const next: StructureDoc = {
|
||||
...doc,
|
||||
산출근거: basis.getDoc() as unknown as StructureDoc["산출근거"],
|
||||
};
|
||||
const info = await saveTemplate("system", "structure", at, null, version, next);
|
||||
version = info.판;
|
||||
doc = next;
|
||||
// 계산값은 서버가 같은 엔진으로 다시 풀어 적음(보낸 값은 버림)
|
||||
const next = basis.getDoc();
|
||||
const info = await saveTemplate("system", "basis", at, null, basisVersion, next);
|
||||
basisVersion = info.판;
|
||||
basisDoc = next;
|
||||
basisDirty = false;
|
||||
showToast(TEXT.saved, "success");
|
||||
} catch (error) {
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
type StructureColumn,
|
||||
type StructurePanelHandle,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
// ⚠ 임시 — [스프레드시트 시험] 단추(1단계 검증 뒤 지움)
|
||||
import { mountSpreadsheetTrial } from "./M02_MasterTemplete_Spreadsheet_Trial";
|
||||
|
||||
export interface Selection {
|
||||
layer: Layer;
|
||||
@@ -77,6 +79,9 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") });
|
||||
const reload = createButton({ label: L("M02_Reload"), variant: "ghost" });
|
||||
const save = createButton({ label: L("M02_Save"), variant: "filled" });
|
||||
// ⚠ 임시 — [스프레드시트 시험] 단추(사용자 지시 · 1단계 검증 뒤 지움)
|
||||
const trial = createButton({ label: "스프레드시트 시험", variant: "ghost" });
|
||||
trial.hidden = !isAdmin;
|
||||
const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴
|
||||
const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } });
|
||||
const host = el("div", { className: "m02-main__host" });
|
||||
@@ -84,7 +89,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
const root = el("div", {
|
||||
className: "m02-main",
|
||||
children: [
|
||||
el("div", { className: "m02-main__head", children: [title, slot, reload, save] }),
|
||||
el("div", { className: "m02-main__head", children: [title, slot, reload, save, trial] }),
|
||||
notice,
|
||||
host,
|
||||
extra,
|
||||
@@ -269,6 +274,18 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
}
|
||||
|
||||
save.addEventListener("click", () => void doSave());
|
||||
// ⚠ 임시 — [스프레드시트 시험] 빈 통합문서(시트 셋)를 메인 칸에 넓게 엶 · 저장은 서버 tmp 견본 자리
|
||||
trial.addEventListener("click", async () => {
|
||||
if (!(await confirmLeave())) return;
|
||||
seq += 1;
|
||||
dirty = false;
|
||||
drop();
|
||||
showNotice([]);
|
||||
sel = null;
|
||||
bar();
|
||||
title.textContent = "스프레드시트 시험";
|
||||
editor = mountSpreadsheetTrial(host, (d) => (dirty = d));
|
||||
});
|
||||
reload.addEventListener("click", async () => {
|
||||
if (sel && (await confirmLeave())) void open(sel);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""M02 양식 층 — 네 층의 자리 · 읽기 · 쓰기 · 복사 · manifest.
|
||||
|
||||
층 넷 (PLAN 10-5):
|
||||
system `resources/master_template/{table,drawing,structure}/<이름>.json` (git)
|
||||
company `storage/{회사}/templates/{table,drawing,structure}/`
|
||||
personal `storage/{회사}/{사용자}/templates/{table,drawing,structure}/`
|
||||
project `storage/{회사}/{사용자}/{프로젝트}/templates/{table,drawing,structure}/`
|
||||
system `resources/master_template/{table,drawing,structure,basis}/<이름>.json` (git)
|
||||
company `storage/{회사}/templates/{table,drawing,structure,basis}/`
|
||||
personal `storage/{회사}/{사용자}/templates/{table,drawing,structure,basis}/`
|
||||
project `storage/{회사}/{사용자}/{프로젝트}/templates/{table,drawing,structure,basis}/`
|
||||
+ 초기 사본 `templates/_initial/`
|
||||
|
||||
- 층 폴더마다 `manifest.json` — 그 폴더에 든 양식의 출처 `{"table/이름": {층, 이름, 판, 적용일}}`.
|
||||
@@ -26,7 +26,8 @@ from common_util.common_util_json import atomic_write_json
|
||||
from config import config_system
|
||||
|
||||
LAYERS = ("system", "company", "personal", "project")
|
||||
KINDS = ("table", "drawing", "structure")
|
||||
# basis = 산출근거 통합문서(구조물집계표 열마다 · 스프레드시트 `A00_Common/spreadsheet/`)
|
||||
KINDS = ("table", "drawing", "structure", "basis")
|
||||
TEMPLATES_DIRNAME = "templates"
|
||||
INITIAL_DIRNAME = "_initial"
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
@@ -165,13 +166,14 @@ def read_template(layer_dir: str | Path, kind: str, name: str) -> dict[str, Any]
|
||||
_SKELETON = {
|
||||
"table": ("열",),
|
||||
"drawing": ("entities",),
|
||||
"structure": ("도면.entities", "산출근거.열"),
|
||||
"structure": ("도면.entities",),
|
||||
"basis": ("시트",),
|
||||
}
|
||||
|
||||
|
||||
def check_skeleton(kind: str, document: Any) -> None:
|
||||
"""뼈대 없는 문서는 거절 — 표 = `열` 목록 · 도면 = `entities` 목록 ·
|
||||
구조물 도면 = `도면.entities` · `산출근거.열` 목록(빈 `{}` 저장 막기)."""
|
||||
구조물 도면 = `도면.entities` 목록 · 산출근거 = `시트` 목록(빈 `{}` 저장 막기)."""
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError("양식 문서는 JSON 객체여야 합니다.")
|
||||
for key in _SKELETON.get(kind, ()):
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""산출근거 스프레드시트(통합문서) 서버 재계산 — 화면과 같은 TS 엔진을 Node 로 돌리는 껍데기.
|
||||
|
||||
CLAUDE.md 5장 ② — 계산은 `A00_Common/spreadsheet/` 한 벌. 여기는 번들을 부르기만 함
|
||||
(`npm run build:spreadsheet` → `config/spreadsheet_node/`). [저장] 때 브라우저가 보낸 `계산값` 은
|
||||
버리고 이 결과를 정본으로 적음.
|
||||
|
||||
결과 한 권 = `{시트 id: {A1: {수|글|참|오류}}}`(식 칸만) · 수는 십진 글.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_node_bundle import ROOT, build_bundle, run_bundle_json
|
||||
|
||||
BUNDLE = ROOT / "config" / "spreadsheet_node" / "common_util_spreadsheet_node.js"
|
||||
NPM_SCRIPT = "build:spreadsheet"
|
||||
ENTRY = ROOT / "common_util" / "common_util_spreadsheet_node.ts"
|
||||
SOURCES = (ROOT / "A00_Common" / "spreadsheet", ROOT / "ui_template" / "sheet")
|
||||
|
||||
|
||||
class RecalcError(Exception):
|
||||
"""번들 빌드 · 실행 실패 또는 엔진이 그 통합문서를 못 풂 — 저장을 막음(검산 없이 적지 않음)."""
|
||||
|
||||
|
||||
def _stale() -> bool:
|
||||
"""번들이 없거나 엔진 TS 원본보다 오래됐으면 참 — 낡으면 화면과 서버가 다른 값을 냄."""
|
||||
if not BUNDLE.is_file():
|
||||
return True
|
||||
built_at = BUNDLE.stat().st_mtime
|
||||
paths = [ENTRY, *(p for folder in SOURCES for p in folder.glob("*.ts"))]
|
||||
return any(p.stat().st_mtime > built_at for p in paths)
|
||||
|
||||
|
||||
def recalc_workbooks(books: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""통합문서 여럿 → 입력 차례 그대로 `계산값`. 하나라도 못 풀면 `RecalcError`."""
|
||||
if _stale() and not build_bundle(NPM_SCRIPT):
|
||||
raise RecalcError("스프레드시트 계산 번들을 못 만듦")
|
||||
out = run_bundle_json(BUNDLE, NPM_SCRIPT, {"books": books})
|
||||
if out is None:
|
||||
raise RecalcError("스프레드시트 계산 번들 실행 실패")
|
||||
values: list[dict[str, Any]] = []
|
||||
for result in out["results"]:
|
||||
if not result.get("ok"):
|
||||
raise RecalcError(f"스프레드시트 계산 실패 — {result.get('까닭')}")
|
||||
values.append(result["계산값"])
|
||||
return values
|
||||
|
||||
|
||||
def with_server_values(book: dict[str, Any]) -> dict[str, Any]:
|
||||
"""[저장] 직전 — 브라우저 `계산값` 은 버리고 서버 풀이로 새로 적은 사본."""
|
||||
clean = copy.deepcopy(book)
|
||||
clean.pop("계산값", None)
|
||||
clean["계산값"] = recalc_workbooks([clean])[0]
|
||||
return clean
|
||||
@@ -0,0 +1,33 @@
|
||||
/* =============================================================================
|
||||
* common_util_spreadsheet_node.ts
|
||||
* 산출근거 스프레드시트 풀이를 **서버가** 돌리는 진입점 — [저장] 때 정본 `계산값`.
|
||||
*
|
||||
* 풀이는 화면이 쓰는 `A00_Common/spreadsheet/spreadsheet_recalc.ts` 그대로 — 여기에는 계산이 없음(CLAUDE.md 5장 ②).
|
||||
* 파이썬 껍데기 = `common_util_spreadsheet.py` · 번들 = `npm run build:spreadsheet`.
|
||||
*
|
||||
* 실행: node <번들> <입력.json> <출력.json>
|
||||
* 입력 { books: Workbook[] }
|
||||
* 출력 { results: ({ ok: true, 계산값 } | { ok: false, 까닭 })[] } — 입력 차례 그대로 · 한 권이 죽어도 나머지는 풂
|
||||
* 끝 코드: 0 성공 / 2 인자 오류
|
||||
* ⚠ `A00_Common/spreadsheet/` 밖에 둠 — node:fs 가 브라우저 번들로 새지 않게.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc";
|
||||
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
const [inputPath, outputPath] = process.argv.slice(2);
|
||||
if (!inputPath || !outputPath) {
|
||||
console.error("사용법: node <번들> <입력.json> <출력.json>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8")) as { books?: Workbook[] };
|
||||
const results = (input.books ?? []).map((book) => {
|
||||
try {
|
||||
return { ok: true, 계산값: recalcWorkbook(book) };
|
||||
} catch (error) {
|
||||
return { ok: false, 까닭: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
});
|
||||
writeFileSync(outputPath, JSON.stringify({ results }));
|
||||
+2
-1
@@ -5,10 +5,11 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner",
|
||||
"build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:b07-cad",
|
||||
"build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:spreadsheet && npm run build:b07-cad",
|
||||
"build:corridor": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B05_Profile/B05_Profile_Corridor_Node.ts --outDir ../config/corridor_node",
|
||||
"build:server-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B06_Section/B06_Section_Server_Calc_Node.ts --outDir ../config/server_calc_node",
|
||||
"build:formula": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_sheet_recalc_node.ts --outDir ../config/formula_node",
|
||||
"build:spreadsheet": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_spreadsheet_node.ts --outDir ../config/spreadsheet_node",
|
||||
"install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install",
|
||||
"build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build",
|
||||
"preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "be",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "bg",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "bm_area",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "bm_len",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "bx_len",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "bx_wing",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "cs",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "ec",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "etc",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "fb",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "fp_l",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "fp_w",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "gr_curb",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "gr_rail",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "gr_sign",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "h_break",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "h_ford_basin",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "h_intake",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "h_pg_rail",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "h_pv_rail",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "h_rip_c",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "h_rip_m",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "h_rockfall",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "h_soil_ditch",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "h_stone_bed",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "h_stone_ch",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "h_waste",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "memo",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "mr",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "ms",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "no",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "od_cnt",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "od_len",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "pg_in",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "pp_cp",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "pp_len",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "ps",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "pv_a",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "pv_b",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "pv_jt",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "pv_l",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "pv_w",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "rf",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "rv",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "rw",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "sg",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "sta",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "ta",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "wy",
|
||||
"서식": [
|
||||
{},
|
||||
{
|
||||
"굵게": true,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {
|
||||
"위": {
|
||||
"선": "thin"
|
||||
},
|
||||
"아래": {
|
||||
"선": "thin"
|
||||
},
|
||||
"왼": {
|
||||
"선": "thin"
|
||||
},
|
||||
"오른": {
|
||||
"선": "thin"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "산출근거",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "공종",
|
||||
"서식": 1
|
||||
},
|
||||
"B1": {
|
||||
"값": "규격",
|
||||
"서식": 1
|
||||
},
|
||||
"C1": {
|
||||
"값": "산출 내역",
|
||||
"서식": 1
|
||||
},
|
||||
"D1": {
|
||||
"값": "단위",
|
||||
"서식": 1
|
||||
},
|
||||
"E1": {
|
||||
"값": "수량(m당)",
|
||||
"서식": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1"
|
||||
}
|
||||
@@ -7,66 +7,5 @@
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
},
|
||||
"산출근거": {
|
||||
"양식": "산출근거",
|
||||
"종류": "표",
|
||||
"판": 1,
|
||||
"열": [
|
||||
{
|
||||
"id": "work",
|
||||
"머리": [
|
||||
"공종"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "spec",
|
||||
"머리": [
|
||||
"규격"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "detail",
|
||||
"머리": [
|
||||
"산출 내역"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "unit",
|
||||
"머리": [
|
||||
"단위"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "qty",
|
||||
"머리": [
|
||||
"수량(m당)"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "수"
|
||||
}
|
||||
],
|
||||
"줄": [
|
||||
{
|
||||
"id": "1",
|
||||
"값": {}
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"값": {}
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"값": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,66 +7,5 @@
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
},
|
||||
"산출근거": {
|
||||
"양식": "산출근거",
|
||||
"종류": "표",
|
||||
"판": 1,
|
||||
"열": [
|
||||
{
|
||||
"id": "work",
|
||||
"머리": [
|
||||
"공종"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "spec",
|
||||
"머리": [
|
||||
"규격"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "detail",
|
||||
"머리": [
|
||||
"산출 내역"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "unit",
|
||||
"머리": [
|
||||
"단위"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "qty",
|
||||
"머리": [
|
||||
"수량(m당)"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "수"
|
||||
}
|
||||
],
|
||||
"줄": [
|
||||
{
|
||||
"id": "1",
|
||||
"값": {}
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"값": {}
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"값": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,66 +7,5 @@
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
},
|
||||
"산출근거": {
|
||||
"양식": "산출근거",
|
||||
"종류": "표",
|
||||
"판": 1,
|
||||
"열": [
|
||||
{
|
||||
"id": "work",
|
||||
"머리": [
|
||||
"공종"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "spec",
|
||||
"머리": [
|
||||
"규격"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "detail",
|
||||
"머리": [
|
||||
"산출 내역"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "unit",
|
||||
"머리": [
|
||||
"단위"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "qty",
|
||||
"머리": [
|
||||
"수량(m당)"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "수"
|
||||
}
|
||||
],
|
||||
"줄": [
|
||||
{
|
||||
"id": "1",
|
||||
"값": {}
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"값": {}
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"값": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,66 +7,5 @@
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
},
|
||||
"산출근거": {
|
||||
"양식": "산출근거",
|
||||
"종류": "표",
|
||||
"판": 1,
|
||||
"열": [
|
||||
{
|
||||
"id": "work",
|
||||
"머리": [
|
||||
"공종"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "spec",
|
||||
"머리": [
|
||||
"규격"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "detail",
|
||||
"머리": [
|
||||
"산출 내역"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "unit",
|
||||
"머리": [
|
||||
"단위"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "qty",
|
||||
"머리": [
|
||||
"수량(m당)"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "수"
|
||||
}
|
||||
],
|
||||
"줄": [
|
||||
{
|
||||
"id": "1",
|
||||
"값": {}
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"값": {}
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"값": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,66 +7,5 @@
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": []
|
||||
},
|
||||
"산출근거": {
|
||||
"양식": "산출근거",
|
||||
"종류": "표",
|
||||
"판": 1,
|
||||
"열": [
|
||||
{
|
||||
"id": "work",
|
||||
"머리": [
|
||||
"공종"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "spec",
|
||||
"머리": [
|
||||
"규격"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "detail",
|
||||
"머리": [
|
||||
"산출 내역"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "unit",
|
||||
"머리": [
|
||||
"단위"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "글"
|
||||
},
|
||||
{
|
||||
"id": "qty",
|
||||
"머리": [
|
||||
"수량(m당)"
|
||||
],
|
||||
"단위": null,
|
||||
"꼴": "수"
|
||||
}
|
||||
],
|
||||
"줄": [
|
||||
{
|
||||
"id": "1",
|
||||
"값": {}
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"값": {}
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"값": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user