feat(sheet): 마스터 첫 쪽 도면 줄 · 칸 고르기 신호 onSelect

- 일위대가 줄 아래 도면 줄 s:drawing — 칸 글은 SheetOptions.drawingLabel(colId) · 없거나 빈 글이면 「없음」 · 읽기만
- 줄 차례(navRows) · MASTER_ROWS · KEY_UNIT 를 ops 로 옮김(Node 시험 가능)
- 칸을 고르면 SheetOptions.onSelect({ row, col, colId }) — 누르기 · 방향키 · 머리 칸
- 시험 test_sheet_drawing_select.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
This commit is contained in:
2026-09-27 11:14:10 +09:00
co-authored by Claude Opus 5.5
parent db46810f7b
commit 1ae10e64e2
6 changed files with 148 additions and 28 deletions
@@ -0,0 +1,64 @@
"""표 도면 줄 · 칸 고르기 신호 — `ui_template_sheet_ops.ts` 를 Node 로 바로 돌려 봄.
도면 줄 `s:drawing` = master 만 · 일위대가 줄 바로 아래(키보드 차례) · project 에는 없음.
`selectionOf` = 격자가 `onSelect` 로 넘기는 모양({row, col, colId} · 모르는 열은 col -1 · colId null).
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
OPS = ROOT / "ui_template" / "sheet" / "ui_template_sheet_ops.ts"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
SCRIPT = """
const { navRows, selectionOf } = await import(process.argv[1]);
const doc = JSON.parse(process.argv[2]);
console.log(JSON.stringify({
master: navRows(doc, "master"),
project: navRows(doc, "project"),
pick: [selectionOf(doc, "s:drawing", "c2"), selectionOf(doc, "d:r1", "c1"), selectionOf(doc, "s:unit", "zz")],
}));
"""
DOC = {
"양식": "시험",
"종류": "표",
"판": 1,
"열": [{"id": "c1", "머리": ["가"]}, {"id": "c2", "머리": ["나"]}],
"줄": [{"id": "r1", "값": {}}],
"합계줄": [{"id": "t1", "이름": "계", "식": "SUM"}],
}
def _run() -> dict:
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module", "-e",
SCRIPT, OPS.as_uri(), json.dumps(DOC, ensure_ascii=False)],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_drawing_row_under_price_master_only():
got = _run()
assert got["master"] == [
"s:unit", "s:formula", "s:desc", "s:price", "s:drawing", "d:r1", "t:t1",
] # fmt: skip
assert got["project"] == ["s:unit", "d:r1", "t:t1"]
def test_selection_shape():
assert _run()["pick"] == [
{"row": "s:drawing", "col": 1, "colId": "c2"},
{"row": "d:r1", "col": 0, "colId": "c1"},
{"row": "s:unit", "col": -1, "colId": None},
]
+17 -5
View File
@@ -1,8 +1,9 @@
/* =============================================================================
* ui_template_sheet.ts
* 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange})` → `{getDoc, setDoc, recalc, destroy}`.
* 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange, drawingLabel, onSelect})` → `{getDoc, setDoc, recalc, destroy}`.
*
* master = 시스템 관리자 양식 고치기 — 머리 · 단위 · 식 · 들어갈 것 · 일위대가 · 줄 · 열 더하기·지우기 · 손 열.
* 도면 줄(읽기만) = `drawingLabel` 글 · 칸을 고르면 `onSelect`.
* project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽.
* 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음.
* 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
@@ -17,6 +18,10 @@ import {
canDeleteRow,
deleteColumn,
deleteRow,
KEY_DRAWING,
KEY_UNIT,
navRows,
selectionOf,
type SheetMode,
setCell,
setColumnWidth,
@@ -25,22 +30,25 @@ import {
import { headDepth } from "./ui_template_sheet_header";
import { recalcSheet } from "./ui_template_sheet_recalc";
import {
KEY_UNIT,
drawingText,
keyEditable,
navRows,
type RenderState,
renderSheet,
} from "./ui_template_sheet_render";
import { st } from "./ui_template_sheet_text";
import type { SheetColumn, SheetDoc, SheetResult } from "./ui_template_sheet_types";
import type { SheetColumn, SheetDoc, SheetResult, SheetSelection } from "./ui_template_sheet_types";
import "./ui_template_sheet.css";
export type { SheetMode } from "./ui_template_sheet_ops";
export type { SheetDoc, SheetResult } from "./ui_template_sheet_types";
export type { SheetDoc, SheetResult, SheetSelection } from "./ui_template_sheet_types";
export interface SheetOptions {
mode: SheetMode;
onChange?: (doc: SheetDoc) => void;
/** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */
drawingLabel?: (colId: string) => string;
/** 칸을 고름 — 누르기 · 방향키 · Tab. */
onSelect?: (sel: SheetSelection) => void;
}
export interface SheetHandle {
@@ -58,6 +66,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
mode: opts.mode,
result: { 계산: {}, 합계: {}, 오류: [] },
sel: null,
drawingLabel: opts.drawingLabel,
};
const scroll = el("div", { className: "ui-sheet__scroll" });
const toolbar = el("div", { className: "ui-sheet__toolbar" });
@@ -151,6 +160,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
for (const cell of cells) cell.classList.add("is-selected");
if (reveal) cells[0]?.scrollIntoView({ block: "nearest", inline: "nearest" });
syncToolbar();
opts.onSelect?.(selectionOf(state.doc, r, c));
};
const move = (dr: number, dc: number): void => {
const keys = navRows(state.doc, state.mode);
@@ -171,6 +181,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
if (key === "s:formula") return col.식 ?? "";
if (key === "s:desc") return col.설명 ?? "";
if (key === "s:price") return col.일위대가 ?? "";
if (key === KEY_DRAWING) return drawingText(state, col.id);
const row = state.doc.줄.find((r) => `d:${r.id}` === key);
const formula = row?.식?.[col.id] ?? col.식;
if (formula) return `=${formula}`;
@@ -178,6 +189,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
return raw === null || raw === undefined ? "" : String(raw);
};
const write = (key: string, col: SheetColumn, text: string): void => {
if (key === KEY_DRAWING) return; // 읽기만
const value = text.trim();
if (key === KEY_UNIT) col.단위 = value || null;
else if (key === "s:formula") {
+29 -1
View File
@@ -5,13 +5,41 @@
* ⚠ 타입 말고는 import 하지 않음 — 시험이 Node 로 이 파일을 바로 돌림(`test_sheet_pages.py`).
* ========================================================================== */
import type { SheetCell, SheetColumn, SheetDoc, SheetRow } from "./ui_template_sheet_types";
import type {
SheetCell,
SheetColumn,
SheetDoc,
SheetRow,
SheetSelection,
} from "./ui_template_sheet_types";
export type SheetMode = "master" | "project";
export const ROW_FIXED_ALL = "전구간";
const PAGE_ROWS = 50;
export const KEY_UNIT = "s:unit";
export const KEY_DRAWING = "s:drawing";
/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 줄은 읽기만. */
export const MASTER_ROWS = ["s:formula", "s:desc", "s:price", KEY_DRAWING] as const;
export type MasterRowKey = (typeof MASTER_ROWS)[number];
/** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */
export function navRows(doc: SheetDoc, mode: SheetMode): string[] {
const special = mode === "master" ? [KEY_UNIT, ...MASTER_ROWS] : [KEY_UNIT];
return [
...special,
...orderedRows(doc).map((r) => `d:${r.id}`),
...(doc.합계줄 ?? []).map((t) => `t:${t.id}`),
];
}
/** 고른 칸 → 페이지에 알릴 모양. */
export function selectionOf(doc: SheetDoc, row: string, colId: string): SheetSelection {
const col = doc.열.findIndex((c) => c.id === colId);
return { row, col, colId: col >= 0 ? colId : null };
}
/** 겹치지 않는 새 id — `c1` · `c2` … */
function freshId(prefix: string, used: Iterable<string>): string {
const taken = new Set(used);
+28 -22
View File
@@ -3,8 +3,8 @@
* 표 그리기 — 문서 + 풀이 결과 → 쪽마다 `<table>`(머리 층 · 단위 줄 · 본문 · 합계 줄).
*
* 칸마다 `data-r`(줄 열쇠) · `data-c`(열 id) — 고치기 · 키보드는 격자(`ui_template_sheet.ts`)가 위임으로 받음.
* 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|formula|desc|price`.
* master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽).
* 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|formula|desc|price|drawing`.
* master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 · 도면 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
@@ -14,7 +14,10 @@ import {
cellEditable,
isBoundColumn,
isCalcColumn,
orderedRows,
KEY_DRAWING,
KEY_UNIT,
MASTER_ROWS,
type MasterRowKey,
pagePlan,
type SheetMode,
} from "./ui_template_sheet_ops";
@@ -22,8 +25,6 @@ import { groupDigits, isNotice, totalFormula } from "./ui_template_sheet_recalc"
import { st } from "./ui_template_sheet_text";
import type { SheetColumn, SheetDoc, SheetResult, SheetRow } from "./ui_template_sheet_types";
export const KEY_UNIT = "s:unit";
const MASTER_ROWS = ["s:formula", "s:desc", "s:price"] as const;
const GUTTER_MIN = 64;
const GUTTER_MAX = 140;
const DEFAULT_WIDTH = { 수: 72, 글: 96 };
@@ -33,8 +34,14 @@ export interface RenderState {
mode: SheetMode;
result: SheetResult;
sel: { r: string; c: string } | null;
/** 도면 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */
drawingLabel?: (colId: string) => string;
}
/** 도면 줄 칸 글. */
export const drawingText = (state: RenderState, colId: string): string =>
state.drawingLabel?.(colId)?.trim() || st("Drawing_None");
/** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */
const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map<string, number>): number =>
Math.max(
@@ -48,18 +55,9 @@ function headFont(): string {
return `600 ${rem * 0.82}px ${getComputedStyle(document.body).fontFamily}`;
}
/** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */
export function navRows(doc: SheetDoc, mode: SheetMode): string[] {
const special = mode === "master" ? [KEY_UNIT, ...MASTER_ROWS] : [KEY_UNIT];
return [
...special,
...orderedRows(doc).map((r) => `d:${r.id}`),
...(doc.합계줄 ?? []).map((t) => `t:${t.id}`),
];
}
/** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로). */
/** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로) · 도면 줄은 읽기만. */
export function keyEditable(state: RenderState, key: string, col: SheetColumn): boolean {
if (key === KEY_DRAWING) return false;
if (key.startsWith("s:")) return state.mode === "master";
if (key.startsWith("t:")) return false;
const row = state.doc.줄.find((r) => `d:${r.id}` === key);
@@ -88,7 +86,7 @@ export function renderSheet(state: RenderState): HTMLElement {
const names = columnNames(cols);
const font = headFont();
const mins = headMinWidths(cols, depth, font);
// 줄 머리 칸 폭 — 층 이름 · 단위 · 식 · 들어갈 것 · 일위대가 · 합계 이름이 안 잘리게
// 줄 머리 칸 폭 — 층 이름 · 단위 · 식 · 들어갈 것 · 일위대가 · 도면 · 합계 이름이 안 잘리게
const GUTTER = Math.min(
GUTTER_MAX,
Math.max(
@@ -99,6 +97,7 @@ export function renderSheet(state: RenderState): HTMLElement {
st("Row_Formula"),
st("Row_Desc"),
st("Row_Unit_Price"),
st("Row_Drawing"),
...(doc.합계줄 ?? []).map((t) => t.이름),
].map((label) => Math.ceil(textWidth(label, font)) + 16),
),
@@ -159,10 +158,15 @@ export function renderSheet(state: RenderState): HTMLElement {
return tr;
};
const masterRow = (key: (typeof MASTER_ROWS)[number]): HTMLElement => {
const label = { "s:formula": "Row_Formula", "s:desc": "Row_Desc", "s:price": "Row_Unit_Price" }[
key
] as "Row_Formula" | "Row_Desc" | "Row_Unit_Price";
const masterRow = (key: MasterRowKey): HTMLElement => {
const label = (
{
"s:formula": "Row_Formula",
"s:desc": "Row_Desc",
"s:price": "Row_Unit_Price",
"s:drawing": "Row_Drawing",
} as const
)[key];
const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] });
cols.forEach((col, j) => {
const text =
@@ -170,7 +174,9 @@ export function renderSheet(state: RenderState): HTMLElement {
? formulaLabel(col.식 ?? "", names, col.id)
: key === "s:desc"
? ""
: (col.일위대가 ?? "");
: key === KEY_DRAWING
? drawingText(state, col.id)
: (col.일위대가 ?? "");
const td = bodyCell(key, col, j, text);
td.classList.remove("is-num");
if (key === "s:formula" && col.식) {
@@ -16,6 +16,8 @@ const TEXT = {
Row_Formula: ["식", "Formula"],
Row_Desc: ["들어갈 것", "Source"],
Row_Unit_Price: ["일위대가", "Unit price"],
Row_Drawing: ["도면", "Drawing"],
Drawing_None: ["없음", "None"],
Kind_Bound: ["설계값", "Design"],
Kind_Calc: ["계산", "Calc"],
Kind_Hand: ["손 입력", "Manual"],
@@ -78,6 +78,14 @@ export interface SheetDoc {
보기?: SheetView;
}
/** 고른 칸 — `row` = 줄 열쇠(`d:<줄id>` · `t:<합계id>` · `s:unit|formula|desc|price|drawing`) ·
* `col` = 열 차례(0부터 · 없으면 -1) · `colId` = 열 id(없으면 null). */
export interface SheetSelection {
row: string;
col: number;
colId: string | null;
}
export interface SheetError {
줄: string;
열: string;