wip(801-1): 수식 그 자리 고침 · 한 마디 줄 · 로직 부름 기호 · 반올림 · 안 탄 갈래 숨김 — 요구 664 로 멈춤(메인 안 합침)
- 서버: 한 마디 줄(표 찾기 · 요소 · 로직 부름 하나) = 줄 기호 · 「적용 · 값 · 기준」 칸 글 대신 조건 값 기호 · 로직 부름 기호(master_equation_sign) · 값 식 숫자 단위 · 있는 계산 변수 연결 바꿈 = 그 줄 식 · 식 속 원자 키보드 글(입력글) - 화면: 박스 없는 조판 · 반올림 감싸개 · 안 탄 만약 숨김(감싸개 글로 구조 지킴) · 보이는 식 그 자리 고침(contenteditable · 원자 data-tok) · 값 숫자 그 자리 고침(valueEdit) - 시험 test_801_1 7 통과 · 넓은 왕복 199 로직 중 13 걸림(식 300 자 상한 · 요소 EQ/MO 글 · 표 칸 Q 기호 맞물림) · test_796_2 옛 createFormulaCell 기대 안 고침(멈춤) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,9 +44,23 @@ def _typed(eq: dict, row: dict) -> dict:
|
|||||||
for line in eq.get("줄") or []:
|
for line in eq.get("줄") or []:
|
||||||
if line.get("식"):
|
if line.get("식"):
|
||||||
line["입력글"] = me.keyboard_of(fx.to_name(str(line["식"]), row), row)
|
line["입력글"] = me.keyboard_of(fx.to_name(str(line["식"]), row), row)
|
||||||
|
_atoms(line.get("이름식"), lambda raw: me.keyboard_of(fx.to_name(raw, row), row))
|
||||||
return eq
|
return eq
|
||||||
|
|
||||||
|
|
||||||
|
def _atoms(tree, word) -> None:
|
||||||
|
"""식 속 표 찾기 · 요소 · 로직 부름 마디 `입력글` = 키보드 글(801 그 자리 고침 · 블록 속 나무 빼고)."""
|
||||||
|
if isinstance(tree, dict):
|
||||||
|
if tree.get("식") and (tree.get("꼴") == "블록" or tree.get("갈래") in ("찾기", "요소")):
|
||||||
|
tree["입력글"] = word(str(tree["식"]))
|
||||||
|
for k, v in tree.items():
|
||||||
|
if k != "나무":
|
||||||
|
_atoms(v, word)
|
||||||
|
elif isinstance(tree, list):
|
||||||
|
for v in tree:
|
||||||
|
_atoms(v, word)
|
||||||
|
|
||||||
|
|
||||||
def export_row(whole, row: dict, inputs: dict | None = None) -> dict:
|
def export_row(whole, row: dict, inputs: dict | None = None) -> dict:
|
||||||
"""로직 줄(정본 · 사본) → 수식 — 마스터에 그 줄을 얹어 셈."""
|
"""로직 줄(정본 · 사본) → 수식 — 마스터에 그 줄을 얹어 셈."""
|
||||||
eq = meq.export(me.swap(whole, row), str(row.get("키")), _given(inputs))
|
eq = meq.export(me.swap(whole, row), str(row.get("키")), _given(inputs))
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
* 그림 = 공통 수식 부품(`ui_template_equation*.ts`) · 서버 나무 = `POST /api/m01/logic/equation`(796-1)
|
* 그림 = 공통 수식 부품(`ui_template_equation*.ts`) · 서버 나무 = `POST /api/m01/logic/equation`(796-1)
|
||||||
* 위 = 옵션 가로(도구줄 + 값 칸 · 바꾸면 다시 셈) · 합계 · 고침 도구(시스템 관리자만)
|
* 위 = 옵션 가로(도구줄 + 값 칸 · 바꾸면 다시 셈) · 합계 · 고침 도구(시스템 관리자만)
|
||||||
* 배치(800-2) = 왼쪽 수식 영역(줄 구분 없이 식이 이어짐 + 소계 표) · 오른쪽 변수 정의 영역(고른 옵션 갈래의 변수만 · 값 · 단위 · DB 열쇠 없음)
|
* 배치(800-2) = 왼쪽 수식 영역(줄 구분 없이 식이 이어짐 + 소계 표) · 오른쪽 변수 정의 영역(고른 옵션 갈래의 변수만 · 값 · 단위 · DB 열쇠 없음)
|
||||||
* 고침(800-3) = 수식 영역의 식 칸을 눌러 그 자리에서(공용 `sheet_formula_cell` · 칠 때마다 `/equation/preview` 검사 · 틀리면 그 자리 빨강 + 까닭)
|
* 고침(801) = 보이는 조판 식 그 자리에서(`ui_template_equation_edit` · 칠 때마다 `/equation/preview` 검사 · 틀리면 그 자리 빨강 + 까닭)
|
||||||
|
* · 값 식 숫자 = 그 변수 값(`valueEdit` — 입력 = 옵션 값 · 중간 = 그 줄 식 · 수 · 표 찾기 · 요소 = 이 식의 그 자리 수)
|
||||||
|
* · 기호를 누르면 정의 줄이 열림(이름 · 기호 · 첨자 · 계산 변수는 연결 바꿈)
|
||||||
* · 새 기호를 쓰면 정의 영역에 그 기호 줄이 생겨 연결 지정(`_EquationDefs` · 고침 `연결`) · 이름 · 기호 · 첨자 고침 · 지움도 정의 줄에서
|
* · 새 기호를 쓰면 정의 영역에 그 기호 줄이 생겨 연결 지정(`_EquationDefs` · 고침 `연결`) · 이름 · 기호 · 첨자 고침 · 지움도 정의 줄에서
|
||||||
* · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인)
|
* · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인)
|
||||||
* 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음)
|
* 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음)
|
||||||
@@ -26,7 +28,6 @@ import {
|
|||||||
type EqNode,
|
type EqNode,
|
||||||
type EqTree,
|
type EqTree,
|
||||||
} from "@ui/ui_template_equation";
|
} from "@ui/ui_template_equation";
|
||||||
import { lineId } from "@ui/ui_template_equation_line";
|
|
||||||
import {
|
import {
|
||||||
el,
|
el,
|
||||||
hideLoadingOverlay,
|
hideLoadingOverlay,
|
||||||
@@ -38,7 +39,8 @@ import { createInputField } from "@ui/ui_template_input";
|
|||||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||||
import { createText } from "@ui/ui_template_text";
|
import { createText } from "@ui/ui_template_text";
|
||||||
import { createToolbar } from "@ui/ui_template_toolbar";
|
import { createToolbar } from "@ui/ui_template_toolbar";
|
||||||
import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_formula_cell";
|
import type { FormulaVerdict } from "../A00_Common/sheet_formula_cell";
|
||||||
|
import { editEq } from "@ui/ui_template_equation_edit";
|
||||||
import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values";
|
import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values";
|
||||||
import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||||
import { ApiError, type LogicRow } from "./M01_MasterData_UI_Logic_Api";
|
import { ApiError, type LogicRow } from "./M01_MasterData_UI_Logic_Api";
|
||||||
@@ -50,7 +52,13 @@ import {
|
|||||||
} from "./M01_MasterData_UI_LogicLab_EquationDefs";
|
} from "./M01_MasterData_UI_LogicLab_EquationDefs";
|
||||||
import { openAddDialog } from "./M01_MasterData_UI_LogicLab_EquationVar";
|
import { openAddDialog } from "./M01_MasterData_UI_LogicLab_EquationVar";
|
||||||
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
|
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
|
||||||
import { formulaEdit, inputsOf, unknownOf, type EqEdit } from "./logic/M01_Logic_Equation_Model";
|
import {
|
||||||
|
formulaEdit,
|
||||||
|
inputsOf,
|
||||||
|
unknownOf,
|
||||||
|
valueEdit,
|
||||||
|
type EqEdit,
|
||||||
|
} from "./logic/M01_Logic_Equation_Model";
|
||||||
import "./M01_MasterData_UI_LogicLab_Equation.css";
|
import "./M01_MasterData_UI_LogicLab_Equation.css";
|
||||||
|
|
||||||
const TEXT = {
|
const TEXT = {
|
||||||
@@ -70,8 +78,8 @@ const TEXT = {
|
|||||||
Options: ["옵션", "Options"],
|
Options: ["옵션", "Options"],
|
||||||
Wait: ["수식을 불러오는 중", "Loading equation"],
|
Wait: ["수식을 불러오는 중", "Loading equation"],
|
||||||
Hint: [
|
Hint: [
|
||||||
"식을 눌러 바로 고침 · 키보드 + − × ÷ / ( )",
|
"식을 눌러 그 자리 고침(+ − × ÷ / ( ) _ ^ · Enter 넣음 · Esc 버림) · 기호 = 연결 · 숫자 = 값",
|
||||||
"Click a formula to edit · keyboard + − × ÷ / ( )",
|
"Click a formula to edit in place · symbol = link · number = value",
|
||||||
],
|
],
|
||||||
Apply: ["연결 적용 · 식 넣기", "Apply links and formula"],
|
Apply: ["연결 적용 · 식 넣기", "Apply links and formula"],
|
||||||
Leave: ["저장 안 한 수식 고침 {n} 건 — 버리고 나감?", "Discard {n} unsaved equation edits?"],
|
Leave: ["저장 안 한 수식 고침 {n} 건 — 버리고 나감?", "Discard {n} unsaved equation edits?"],
|
||||||
@@ -171,6 +179,7 @@ export function createLabEquation(opt: LabEquationOptions) {
|
|||||||
let before: Record<string, unknown> | null = null;
|
let before: Record<string, unknown> | null = null;
|
||||||
let why = "";
|
let why = "";
|
||||||
let editingLine: string | null = null;
|
let editingLine: string | null = null;
|
||||||
|
let editAt: { x: number; y: number } | null = null;
|
||||||
let defEditing: string | null = null;
|
let defEditing: string | null = null;
|
||||||
let pending: PendingLink | null = null;
|
let pending: PendingLink | null = null;
|
||||||
let defsBox: HTMLElement = el("div");
|
let defsBox: HTMLElement = el("div");
|
||||||
@@ -178,7 +187,6 @@ export function createLabEquation(opt: LabEquationOptions) {
|
|||||||
let timer = 0;
|
let timer = 0;
|
||||||
let paintedBlocked = false;
|
let paintedBlocked = false;
|
||||||
const open = new Set<string>();
|
const open = new Set<string>();
|
||||||
const drafts = new Map<string, string>();
|
|
||||||
const canEdit = (): boolean => opt.admin && !opt.blocked();
|
const canEdit = (): boolean => opt.admin && !opt.blocked();
|
||||||
const page = opt.page;
|
const page = opt.page;
|
||||||
const vals = (): Record<string, string> => page?.values() ?? inputs;
|
const vals = (): Record<string, string> => page?.values() ?? inputs;
|
||||||
@@ -407,7 +415,6 @@ export function createLabEquation(opt: LabEquationOptions) {
|
|||||||
if (missing) return;
|
if (missing) return;
|
||||||
const bad = await commit([...list, formulaEdit(p.line, p.text)]);
|
const bad = await commit([...list, formulaEdit(p.line, p.text)]);
|
||||||
if (bad.length) return [...p.rows.values()].forEach((d) => d.say(bad.join(" · ")));
|
if (bad.length) return [...p.rows.values()].forEach((d) => d.say(bad.join(" · ")));
|
||||||
drafts.delete(lineId(p.line));
|
|
||||||
[pending, editingLine] = [null, null];
|
[pending, editingLine] = [null, null];
|
||||||
paint();
|
paint();
|
||||||
}
|
}
|
||||||
@@ -433,43 +440,66 @@ export function createLabEquation(opt: LabEquationOptions) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 수식 영역의 식 칸 자리에 들어가는 식 칸 — 바꾸면 미리 셈 · 되면 고침 목록에 굳힘 */
|
/** 수식 영역 — 그린 식 그 자리 고침 · 칠 때마다 미리 셈 · Enter = 고침 목록에 굳힘 */
|
||||||
function editor(line: EqLine, id: string): HTMLElement {
|
function editor(line: EqLine, _id: string, host: HTMLElement): void {
|
||||||
const shown = line.입력글 ?? line.식;
|
const say = el("span", { className: "ui-eq__bad" });
|
||||||
|
host.closest(".ui-eq__name")?.after(say);
|
||||||
const close = (): void => {
|
const close = (): void => {
|
||||||
[editingLine, pending] = [null, null];
|
[editingLine, pending] = [null, null];
|
||||||
paint();
|
paint();
|
||||||
};
|
};
|
||||||
const cell = createFormulaCell({
|
const show = (ok: boolean, list: string[]): void => {
|
||||||
key: id,
|
host.dataset.bad = ok ? "" : "1";
|
||||||
spot: id,
|
say.className = ok ? "ui-eq__note" : "ui-eq__bad";
|
||||||
shown,
|
say.textContent = list.join(" · ");
|
||||||
origin: shown,
|
};
|
||||||
drafts,
|
let mine: AbortController | null = null;
|
||||||
check: check(line),
|
editEq(host, {
|
||||||
put: (text) => {
|
at: editAt,
|
||||||
if (text === null) return pending ? undefined : close();
|
onInput: (text) => {
|
||||||
void commit([formulaEdit(line, text)]).then((bad) => {
|
mine?.abort();
|
||||||
if (bad.length) showToast(bad.join(" · "), "error");
|
const me = (mine = new AbortController());
|
||||||
else close();
|
check(line)(text, me.signal)
|
||||||
});
|
.then((v) => me === mine && show(v.되나, (v.되나 ? v.알림 : v.까닭) ?? []))
|
||||||
|
.catch(() => undefined);
|
||||||
},
|
},
|
||||||
className: "m01-eq__fx",
|
onCommit: (text) => {
|
||||||
|
if (pending) return void applyPending();
|
||||||
|
void commit([formulaEdit(line, text)]).then((bad) =>
|
||||||
|
bad.length ? show(false, bad) : close(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onCancel: () => (pending ? undefined : close()),
|
||||||
});
|
});
|
||||||
const input = cell.querySelector("input");
|
|
||||||
// 안 바꾸고 다른 데를 누르면 닫음 · 바꾼 글이 있거나 연결 지정 중이면 그대로
|
|
||||||
cell.addEventListener("focusout", () =>
|
|
||||||
window.setTimeout(() => {
|
|
||||||
if (editingLine === id && !pending && !cell.contains(document.activeElement)) {
|
|
||||||
if ((input?.value.trim() ?? shown) === shown) close();
|
|
||||||
}
|
|
||||||
}, 120),
|
|
||||||
);
|
|
||||||
queueMicrotask(() => input?.focus());
|
|
||||||
return cell;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const showDef = (node: EqNode): void => flashEqDef(defsBox, node);
|
/** 값 숫자 고침 — 입력 = 옵션 값 · 그 밖 = 고침 목록 */
|
||||||
|
function onValue(line: EqLine, leaf: EqNode, text: string): void {
|
||||||
|
const got = valueEdit(line, leaf, text);
|
||||||
|
if (typeof got === "string") return showToast(got, "error");
|
||||||
|
if ("input" in got) {
|
||||||
|
if (page) return page.setValue(got.input, text);
|
||||||
|
inputs[got.input] = text;
|
||||||
|
drawOptions();
|
||||||
|
return askAgain();
|
||||||
|
}
|
||||||
|
void commit(got.edits).then((bad) => bad.length && showToast(bad.join(" · "), "error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기호를 누름 — 정의 줄로 · 고칠 수 있으면 그 줄 고침 열림 */
|
||||||
|
function showVar(node: EqNode): void {
|
||||||
|
const sign = defSign({
|
||||||
|
기호: node.기호 || node.열쇠 || "",
|
||||||
|
위: typeof node.위 === "string" ? node.위 : "",
|
||||||
|
아래: typeof node.아래 === "string" ? node.아래 : "",
|
||||||
|
});
|
||||||
|
const def = eq?.정의?.find((d) => defSign(d) === sign);
|
||||||
|
if (canEdit() && def?.고침 && defEditing !== sign) {
|
||||||
|
defEditing = sign;
|
||||||
|
drawDefs();
|
||||||
|
}
|
||||||
|
flashEqDef(defsBox, node);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 그림 ──────────────────────────────────────────────────────────
|
// ── 그림 ──────────────────────────────────────────────────────────
|
||||||
function drawBar(): void {
|
function drawBar(): void {
|
||||||
@@ -529,15 +559,16 @@ export function createLabEquation(opt: LabEquationOptions) {
|
|||||||
path: "",
|
path: "",
|
||||||
open,
|
open,
|
||||||
resultUnit: eq.결과단위,
|
resultUnit: eq.결과단위,
|
||||||
onVar: edit ? undefined : showDef, // 고칠 수 있으면 식 어디를 눌러도 그 자리 고침(변수 칩 포함)
|
onVar: showVar, // 기호 = 정의 줄(연결) · 그 밖 식 자리 = 그 자리 고침
|
||||||
editing: editingLine,
|
editing: editingLine,
|
||||||
onEdit: edit
|
onEdit: edit
|
||||||
? (_line, id) => {
|
? (_line, id, at) => {
|
||||||
editingLine = id;
|
[editingLine, editAt] = [id, at];
|
||||||
paint();
|
paint();
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
editor: edit ? editor : undefined,
|
editor: edit ? editor : undefined,
|
||||||
|
onValue: edit ? onValue : undefined,
|
||||||
after: (line) => (line.갈래 === "호표" ? (sum?.inline.get(line.열쇠) ?? null) : null),
|
after: (line) => (line.갈래 === "호표" ? (sum?.inline.get(line.열쇠) ?? null) : null),
|
||||||
}),
|
}),
|
||||||
...(sum ? [sum.tail] : []), // 799-1 · 800-4 묶음 소계 · 끝수 · 합계
|
...(sum ? [sum.tail] : []), // 799-1 · 800-4 묶음 소계 · 끝수 · 합계
|
||||||
@@ -576,7 +607,6 @@ export function createLabEquation(opt: LabEquationOptions) {
|
|||||||
async function discard(): Promise<void> {
|
async function discard(): Promise<void> {
|
||||||
edits = [];
|
edits = [];
|
||||||
[editingLine, defEditing, pending] = [null, null, null];
|
[editingLine, defEditing, pending] = [null, null, null];
|
||||||
drafts.clear();
|
|
||||||
persist();
|
persist();
|
||||||
await refresh();
|
await refresh();
|
||||||
drawOptions();
|
drawOptions();
|
||||||
@@ -595,7 +625,6 @@ export function createLabEquation(opt: LabEquationOptions) {
|
|||||||
inputs = {};
|
inputs = {};
|
||||||
edits = page ? page.edits() : (loadCache()[nextKey] ?? []);
|
edits = page ? page.edits() : (loadCache()[nextKey] ?? []);
|
||||||
open.clear();
|
open.clear();
|
||||||
drafts.clear();
|
|
||||||
paint();
|
paint();
|
||||||
drawOptions();
|
drawOptions();
|
||||||
void refresh().then(drawOptions);
|
void refresh().then(drawOptions);
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
* M01_MasterData/logic/M01_Logic_Equation_Model.ts
|
* M01_MasterData/logic/M01_Logic_Equation_Model.ts
|
||||||
* 수식 탭 계산(796-2 · 화면 · fetch 없음) — 줄 → 고침 자리 · 식 칸 아이콘 끼움 · 옵션 값 꼴 · 변수 고침 요청.
|
* 수식 탭 계산(796-2 · 화면 · fetch 없음) — 줄 → 고침 자리 · 식 칸 아이콘 끼움 · 옵션 값 꼴 · 변수 고침 요청.
|
||||||
* 서버 고침 꼴 = `master_equation_edit.py` 머리(할일 식 · 더함 · 지움 · 변수 · 표시) — 여기서는 요청 목록만 만듦
|
* 서버 고침 꼴 = `master_equation_edit.py` 머리(할일 식 · 더함 · 지움 · 변수 · 표시) — 여기서는 요청 목록만 만듦
|
||||||
|
* 값 숫자 고침(801 · `valueEdit`) = 입력 → 옵션 값 · 중간 → 그 줄 식 = 그 수 · 수 · 표 찾기 · 요소 → 이 줄 식의 그 자리 = 그 수
|
||||||
* 셈 · 검사는 서버 한 벌(③ 서버 단독) — 브라우저는 모양만
|
* 셈 · 검사는 서버 한 벌(③ 서버 단독) — 브라우저는 모양만
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import type { EqLine } from "@ui/ui_template_equation";
|
import type { EqLine, EqNode } from "@ui/ui_template_equation";
|
||||||
|
import { textOf } from "@ui/ui_template_equation_text";
|
||||||
|
|
||||||
export interface EqEdit {
|
export interface EqEdit {
|
||||||
할일: "식" | "더함" | "지움" | "변수" | "표시" | "연결";
|
할일: "식" | "더함" | "지움" | "변수" | "표시" | "연결";
|
||||||
@@ -121,3 +123,17 @@ export function unknownOf(reasons: string[]): string[] {
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 값 식 숫자 고침 → 옵션 값(입력) | 고침 목록 | 까닭 글 */
|
||||||
|
export function valueEdit(
|
||||||
|
line: EqLine,
|
||||||
|
leaf: EqNode,
|
||||||
|
text: string,
|
||||||
|
): { input: string } | { edits: EqEdit[] } | string {
|
||||||
|
if (!/^-?\d+(\.\d+)?$/.test(text)) return "값은 수로 적을 것";
|
||||||
|
if (leaf.꼴 === "변수" && leaf.갈래 === "입력") return { input: leaf.열쇠 ?? "" };
|
||||||
|
if (leaf.꼴 === "변수" && leaf.갈래 === "중간")
|
||||||
|
return { edits: [{ 할일: "식", 자리: `중간:${leaf.열쇠 ?? ""}`, 식: text }] };
|
||||||
|
const tree = line.갈래 === "호표" ? line.이름식?.우 : line.이름식; // 호표 식 = 수량 쪽
|
||||||
|
return { edits: [formulaEdit(line, textOf(tree, new Map([[leaf, text]])))] };
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import master_auto as ma
|
import master_auto as ma
|
||||||
import master_cut as mcut
|
import master_cut as mcut
|
||||||
|
import master_equation_sign as msign
|
||||||
import master_equation_view as view
|
import master_equation_view as view
|
||||||
import master_formula as mf
|
import master_formula as mf
|
||||||
import master_material as mm
|
import master_material as mm
|
||||||
@@ -345,7 +346,8 @@ def values(tree: dict) -> dict:
|
|||||||
if kind in ("변수", "블록"):
|
if kind in ("변수", "블록"):
|
||||||
value = tree.get("값")
|
value = tree.get("값")
|
||||||
got = _num(value) if value is not None else {"꼴": "글", "글": "?"}
|
got = _num(value) if value is not None else {"꼴": "글", "글": "?"}
|
||||||
return {**got, "열쇠": tree.get("열쇠"), "갈래": tree.get("갈래") or "블록"}
|
unit = tree.get("단위") or tree.get("결과단위") or "" # 값 식 숫자 단위(801-4)
|
||||||
|
return {**got, "열쇠": tree.get("열쇠"), "갈래": tree.get("갈래") or "블록", "단위": unit}
|
||||||
if kind == "조건":
|
if kind == "조건":
|
||||||
if tree.get("걸림"):
|
if tree.get("걸림"):
|
||||||
return values(tree["참" if tree["걸림"] == 1 else "거짓"])
|
return values(tree["참" if tree["걸림"] == 1 else "거짓"])
|
||||||
@@ -462,7 +464,7 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict:
|
|||||||
total = {k: cut.get(k) for k in mf.BUNDLE}
|
total = {k: cut.get(k) for k in mf.BUNDLE}
|
||||||
if depth == 0:
|
if depth == 0:
|
||||||
sub = {"소계": subtotals(row, cut, total)}
|
sub = {"소계": subtotals(row, cut, total)}
|
||||||
out = msym.apply( # 기호 먼저(800-1) → 정의는 기호 씌운 줄로(800-2)
|
out = msym.apply( # 기호 먼저(800-1) → 한 마디 줄 · 로직 부름 기호(801) → 정의(800-2)
|
||||||
{
|
{
|
||||||
"열쇠": row.get("키"),
|
"열쇠": row.get("키"),
|
||||||
"이름": row.get("이름") or "",
|
"이름": row.get("이름") or "",
|
||||||
@@ -474,6 +476,7 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict:
|
|||||||
**sub,
|
**sub,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
msign.settle(out)
|
||||||
if depth == 0:
|
if depth == 0:
|
||||||
out["정의"] = view.definitions(out_lines, row, out["변수표"], _unused(row))
|
out["정의"] = view.definitions(out_lines, row, out["변수표"], _unused(row))
|
||||||
return _clean(out)
|
return _clean(out)
|
||||||
|
|||||||
@@ -414,7 +414,13 @@ def apply(master, row: dict, edits: list, words=None) -> tuple[dict, list[str],
|
|||||||
_show(new, key, ed, why)
|
_show(new, key, ed, why)
|
||||||
elif job == "연결":
|
elif job == "연결":
|
||||||
add, bad = link.plan(master, ed)
|
add, bad = link.plan(master, ed)
|
||||||
_add(new, add, why, spots, words) if add else why.append(bad)
|
mid = key in {str(s.get("이름")) for s in new.get("중간") or []}
|
||||||
|
if add and mid and add.get("식"): # 있는 계산 변수 연결 바꿈(801) = 그 줄 식
|
||||||
|
_set_formula(new, {"자리": f"중간:{key}", "식": add["식"]}, why, spots, words)
|
||||||
|
elif add and mid:
|
||||||
|
why.append(f"「{key}」 계산 변수는 입력으로 못 바꿈 — 표 · 요소 · 로직 · 상수")
|
||||||
|
else:
|
||||||
|
_add(new, add, why, spots, words) if add else why.append(bad)
|
||||||
else:
|
else:
|
||||||
why.append(f"모르는 할일 「{job}」")
|
why.append(f"모르는 할일 「{job}」")
|
||||||
except (mf.FormulaError, KeyError, TypeError, ValueError) as e:
|
except (mf.FormulaError, KeyError, TypeError, ValueError) as e:
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""수식 마디 기호 다듬기(PLAN 801-1 · 801-2 · 요구 662-4) — 기호 씌운(`master_symbol.apply`) 뒤 · 정의 영역 앞.
|
||||||
|
|
||||||
|
보이기만 — 셈 · 엔진 열쇠 · 식(`식` 칸) 안 바꿈.
|
||||||
|
- 한 마디 줄: 중간 · 결과 줄 식이 표 찾기 · 요소 · 로직 부르기 한 마디뿐이면 그 마디 = 줄 기호 · 줄 `한마디`
|
||||||
|
(화면은 「q = 0.7」 — 표 칸 글 「적용」 · 로직 이름은 정의 영역에만).
|
||||||
|
- 로직 부르기 마디 = 기호(부른 로직 결과 기호 + 아래 = 로직 이름 앞말) — 이름은 정의 영역 · 펼침 머리.
|
||||||
|
- 뜻 없는 표 칸 글(`PLAIN` — 적용 · 값 · 기준)로 지은 표 찾기 기호 = 그 행 조건 값(없으면 표 이름)으로.
|
||||||
|
- 한 로직 안 겹침 = 아래첨자 끝 숫자(`master_symbol.apply` 와 같은 꼴).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import master_symbol as msym
|
||||||
|
|
||||||
|
PLAIN = {"적용", "값", "기준"}
|
||||||
|
LEAF = ("찾기", "요소")
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(n: dict) -> str:
|
||||||
|
return f"{n.get('기호') or ''}_{n.get('아래') or ''}^{n.get('위') or ''}"
|
||||||
|
|
||||||
|
|
||||||
|
def _nodes(tree, out: list[dict]) -> list[dict]:
|
||||||
|
"""변수 · 블록 마디(블록 속 나무는 따로 다듬음 — 안 들어감)."""
|
||||||
|
if isinstance(tree, list):
|
||||||
|
for one in tree:
|
||||||
|
_nodes(one, out)
|
||||||
|
elif isinstance(tree, dict):
|
||||||
|
if tree.get("꼴") in ("변수", "블록"):
|
||||||
|
out.append(tree)
|
||||||
|
for k, v in tree.items():
|
||||||
|
if k != "나무" and isinstance(v, (dict, list)):
|
||||||
|
_nodes(v, out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def lone(line: dict) -> bool:
|
||||||
|
"""줄 식이 표 찾기 · 요소 · 로직 부르기 한 마디뿐인 중간 · 결과 줄."""
|
||||||
|
tree = line.get("이름식") or {}
|
||||||
|
one = tree.get("꼴") == "블록" or (tree.get("꼴") == "변수" and tree.get("갈래") in LEAF)
|
||||||
|
return line.get("갈래") in ("중간", "결과") and one and isinstance(line.get("결과"), dict)
|
||||||
|
|
||||||
|
|
||||||
|
def _block_sign(node: dict) -> tuple[str, str, str]:
|
||||||
|
sub = node.get("나무") or {}
|
||||||
|
last = next((x for x in reversed(sub.get("줄") or []) if x.get("갈래") == "결과"), None)
|
||||||
|
head = (last or {}).get("결과") or {}
|
||||||
|
sign = head.get("기호") if msym.is_symbol(str(head.get("기호") or "")) else ""
|
||||||
|
sign = sign or msym.rule(str(node.get("결과단위") or ""), "결과")[0]
|
||||||
|
return sign, msym.short(str(node.get("이름") or node.get("열쇠") or "")), ""
|
||||||
|
|
||||||
|
|
||||||
|
def _find_sign(node: dict) -> tuple[str, str, str]:
|
||||||
|
said = [str(v) for v in (node.get("행") or {}).values() if isinstance(v, str) and v]
|
||||||
|
name = said[-1] if said else str(node.get("표이름") or "")
|
||||||
|
node["칸글"] = True # 정의 이름 = 표 · 조건 값(칸 글 아님)
|
||||||
|
node["이름"] = " ".join(x for x in (str(node.get("표이름") or ""), *said[-1:]) if x)
|
||||||
|
if msym.is_symbol(name):
|
||||||
|
return name, "", "표"
|
||||||
|
sign, low, _ = msym.lookup(name, "찾기")
|
||||||
|
return sign, low, "표"
|
||||||
|
|
||||||
|
|
||||||
|
def settle(out: dict) -> dict:
|
||||||
|
"""내보낸 수식 한 벌(한 깊이) 다듬기 — 제자리 고침 · 같은 것 돌려줌."""
|
||||||
|
lines = out.get("줄") or []
|
||||||
|
for line in lines:
|
||||||
|
if lone(line):
|
||||||
|
line["한마디"] = True
|
||||||
|
head = line["결과"]
|
||||||
|
line["이름식"] |= {k: head.get(k) or "" for k in ("기호", "아래", "위")}
|
||||||
|
nodes = _nodes(lines, [])
|
||||||
|
heads = [x["결과"] for x in lines if isinstance(x.get("결과"), dict)]
|
||||||
|
used = {_sign(n) for n in [*nodes, *heads, *(out.get("변수표") or [])]}
|
||||||
|
done: dict[str, tuple[str, str, str]] = {}
|
||||||
|
for line in lines:
|
||||||
|
skip = line["이름식"] if line.get("한마디") else None
|
||||||
|
for node in _nodes(line.get("이름식"), []):
|
||||||
|
if node is skip:
|
||||||
|
continue
|
||||||
|
if node.get("꼴") == "블록":
|
||||||
|
want = _block_sign
|
||||||
|
elif node.get("갈래") == "찾기" and node.get("칸") in PLAIN:
|
||||||
|
want = _find_sign
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
key = str(node.get("식") or node.get("열쇠"))
|
||||||
|
if key not in done:
|
||||||
|
used.discard(_sign(node))
|
||||||
|
sign, low, up = want(node)
|
||||||
|
n, base = 1, low
|
||||||
|
while _sign({"기호": sign, "아래": low, "위": up}) in used:
|
||||||
|
n += 1
|
||||||
|
low = base[: msym.SUBMAX - len(str(n))] + str(n)
|
||||||
|
used.add(_sign({"기호": sign, "아래": low, "위": up}))
|
||||||
|
done[key] = (sign, low, up)
|
||||||
|
node["기호"], node["아래"], node["위"] = done[key]
|
||||||
|
return out
|
||||||
@@ -76,8 +76,8 @@ def definitions(lines: list[dict], row: dict, rows: list[dict], unused) -> list[
|
|||||||
leaf[str(line.get("열쇠"))] = {"갈래": "상수", "글": tree.get("글")}
|
leaf[str(line.get("열쇠"))] = {"갈래": "상수", "글": tree.get("글")}
|
||||||
elif tree.get("갈래") in ("찾기", "요소") or tree.get("꼴") == "블록": # 한 마디 식
|
elif tree.get("갈래") in ("찾기", "요소") or tree.get("꼴") == "블록": # 한 마디 식
|
||||||
leaf[str(line.get("열쇠"))] = tree
|
leaf[str(line.get("열쇠"))] = tree
|
||||||
found = ([head] if head and line.get("갈래") in ("중간", "결과") else []) + visible(
|
found = ([head] if head and line.get("갈래") in ("중간", "결과") else []) + (
|
||||||
tree, []
|
[] if line.get("한마디") else visible(tree, []) # 한 마디 줄 = 줄 변수 하나(801)
|
||||||
)
|
)
|
||||||
for node in found:
|
for node in found:
|
||||||
key = str(node.get("열쇠") or "")
|
key = str(node.get("열쇠") or "")
|
||||||
@@ -97,7 +97,7 @@ def definitions(lines: list[dict], row: dict, rows: list[dict], unused) -> list[
|
|||||||
if kind == "블록" and not leaf.get(key):
|
if kind == "블록" and not leaf.get(key):
|
||||||
link = "로직 부르기" # 이름이 곧 그 로직 — 되풀이 안 함
|
link = "로직 부르기" # 이름이 곧 그 로직 — 되풀이 안 함
|
||||||
name = (look.get("이름") or key) if mine else look.get("이름") or ""
|
name = (look.get("이름") or key) if mine else look.get("이름") or ""
|
||||||
name = node.get("칸") or name if kind == "찾기" else name
|
name = node.get("칸") or name if kind == "찾기" and not node.get("칸글") else name
|
||||||
one = {
|
one = {
|
||||||
"열쇠": key if mine else "",
|
"열쇠": key if mine else "",
|
||||||
"기호": look.get("기호") or name, # 줄 참조도 씌운 기호(master_symbol 한 곳)
|
"기호": look.get("기호") or name, # 줄 참조도 씌운 기호(master_symbol 한 곳)
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""PLAN 801-1 · 801-2 · 801-4 — 수식 = 발표 자료 꼴 · 보이는 식 그 자리 고침 (서버 · 글 꼴 TS 를 node 로).
|
||||||
|
|
||||||
|
한 마디 줄 「q = 0.7」(표 칸 글 0) · 로직 부름 = 기호 · 반올림 감싸개 · 안 탄 갈래 안 보임 ·
|
||||||
|
값 식 숫자 단위 · 나무 → 키보드 식(`ui_template_equation_text.ts`) 왕복 = 금액 같음 ·
|
||||||
|
값 고침 = 그 변수 값 · 기호 고침(연결) = 그 줄 식.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.encoders import decimal_encoder
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path[:0] = [str(ROOT), str(ROOT / "resources/master_data/scripts")]
|
||||||
|
|
||||||
|
from M01_MasterData import M01_MasterData_Equation as eq # noqa: E402
|
||||||
|
from M01_MasterData import M01_MasterData_Store as store # noqa: E402
|
||||||
|
|
||||||
|
import master_formula as mf # noqa: E402
|
||||||
|
|
||||||
|
TEXT = ROOT / "ui_template/ui_template_equation_text.ts"
|
||||||
|
MODEL = ROOT / "M01_MasterData/logic/M01_Logic_Equation_Model.ts"
|
||||||
|
PLAIN = ("적용", "값", "기준")
|
||||||
|
KEYS = ("GF000098", "GF000099", "GC000211", "GC001148", "GC000996", "GC000998", "GC000999")
|
||||||
|
OFF = {"폼타이": "아님", "측압": "3t/㎡"}
|
||||||
|
needs_node = pytest.mark.skipif(
|
||||||
|
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read(key, inputs=None):
|
||||||
|
return eq.read(key, inputs or {})["수식"]
|
||||||
|
|
||||||
|
|
||||||
|
def _ts(call: str, data) -> object:
|
||||||
|
"""글 꼴 TS(+ 모델 `valueEdit`)를 node 로 — import 줄 걷고 이어 붙임."""
|
||||||
|
src = "\n".join(
|
||||||
|
re.sub(r"^import[^;]*;\n", "", p.read_text(encoding="utf-8"), flags=re.M)
|
||||||
|
for p in (TEXT, MODEL)
|
||||||
|
)
|
||||||
|
script = f"{src}\nconst DATA = {json.dumps(data, ensure_ascii=False, default=decimal_encoder)};\nconsole.log(JSON.stringify({call}));"
|
||||||
|
got = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||||
|
["node", "--experimental-strip-types", "--no-warnings",
|
||||||
|
"--input-type=module-typescript"],
|
||||||
|
input=script, capture_output=True, text=True, encoding="utf-8", timeout=120,
|
||||||
|
) # fmt: skip
|
||||||
|
assert got.returncode == 0, got.stderr[-2000:]
|
||||||
|
return json.loads(got.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(n):
|
||||||
|
return (n.get("기호"), n.get("아래") or "", n.get("위") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _nodes(tree, out):
|
||||||
|
if isinstance(tree, dict):
|
||||||
|
if tree.get("꼴") in ("변수", "블록"):
|
||||||
|
out.append(tree)
|
||||||
|
for k, v in tree.items():
|
||||||
|
if k != "나무":
|
||||||
|
_nodes(v, out)
|
||||||
|
elif isinstance(tree, list):
|
||||||
|
for v in tree:
|
||||||
|
_nodes(v, out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_한마디_줄_표_칸_글_0():
|
||||||
|
lines = {x["열쇠"]: x for x in _read("GF000098")["줄"]}
|
||||||
|
for key in ("q", "K", "f", "E", "Cm"):
|
||||||
|
line = lines[key]
|
||||||
|
assert line["한마디"] and _sign(line["이름식"]) == _sign(line["결과"])
|
||||||
|
assert "적용" not in json.dumps(_sign(line["이름식"]), ensure_ascii=False)
|
||||||
|
defs = _read("GF000098")["정의"]
|
||||||
|
assert [d["기호"] for d in defs] == ["q", "K", "f", "E", "Cm", "Q"] # 표 찾기 따로 줄 없음
|
||||||
|
|
||||||
|
|
||||||
|
def test_로직_부름_기호_뜻없는_칸_글_0():
|
||||||
|
got = _read("GF000099")
|
||||||
|
lines = {x["열쇠"]: x for x in got["줄"]}
|
||||||
|
assert lines["작업량"]["한마디"] and lines["작업량"]["이름식"]["꼴"] == "블록"
|
||||||
|
assert _sign(lines["작업량"]["이름식"]) == _sign(lines["작업량"]["결과"])
|
||||||
|
for key in KEYS:
|
||||||
|
for node in _nodes([x.get("이름식") for x in _read(key, OFF)["줄"]], []):
|
||||||
|
text = "".join(str(x) for x in _sign(node))
|
||||||
|
assert not re.search(r"[가-힣]", node.get("기호") or ""), (key, node.get("기호"))
|
||||||
|
if node.get("갈래") == "찾기":
|
||||||
|
assert not any(w in text for w in PLAIN), (key, text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_값_식_단위():
|
||||||
|
res = next(x for x in _read("GF000098")["줄"] if x["갈래"] == "결과")
|
||||||
|
units = [n.get("단위") for n in _nodes(res["값식"], []) + [res["값식"]]]
|
||||||
|
flat = json.dumps(res["값식"], ensure_ascii=False, default=str)
|
||||||
|
assert '"단위": "초"' in flat or "초" in units
|
||||||
|
|
||||||
|
|
||||||
|
@needs_node
|
||||||
|
def test_반올림_감싸개_안_탄_갈래_안_보임():
|
||||||
|
res = next(x for x in _read("GF000098")["줄"] if x["갈래"] == "결과")
|
||||||
|
assert res["이름식"]["꼴"] == "함수" and res["이름식"]["이름"] == "반올림" # 셈 그대로
|
||||||
|
leaves = _ts("leavesOf(DATA).map((n) => n.글 ?? n.기호)", res["값식"])
|
||||||
|
assert leaves[-1] != "3" and len(leaves) == 6 # 3,600 · q · K · f · E · Cm — 자리 3 없음
|
||||||
|
line = next(x for x in _read("GC000211", OFF)["줄"] if x["열쇠"].startswith("폼타이"))
|
||||||
|
shown = _ts("leavesOf(DATA).map((n) => n.글 ?? n.기호)", line["이름식"])
|
||||||
|
assert "12.7㎜" not in json.dumps(shown, ensure_ascii=False) # 안 탄 만약 조건 · 갈래 없음
|
||||||
|
|
||||||
|
|
||||||
|
@needs_node
|
||||||
|
def test_나무_글_왕복_금액_같음():
|
||||||
|
for key in KEYS:
|
||||||
|
got = _read(key, OFF)
|
||||||
|
lines = [x for x in got["줄"] if x.get("이름식")]
|
||||||
|
texts = _ts(
|
||||||
|
"DATA.map((l) => textOf(l.갈래 === '호표' ? l.이름식.우 : l.이름식))",
|
||||||
|
lines,
|
||||||
|
)
|
||||||
|
edits = [
|
||||||
|
{"할일": "식", "자리": _spot(line), "식": text} for line, text in zip(lines, texts)
|
||||||
|
]
|
||||||
|
back = eq.preview(key, edits, OFF)
|
||||||
|
assert back["되나"], (key, back["까닭"])
|
||||||
|
# 같은 엔진 식(마디 나무) = 같은 셈 — 금액은 고친 중간 줄 표시 자리(요구 658)가 붙어 따로 안 봄
|
||||||
|
old = store.loaded()[1].logic(key)
|
||||||
|
for part, field in (("중간", "식"), ("호표", "수량"), ("덧줄", "식")):
|
||||||
|
for a, b in zip(old.get(part) or [], back["줄"].get(part) or []):
|
||||||
|
assert mf.parse(str(a[field])) == mf.parse(str(b[field])), (key, a, b)
|
||||||
|
if "결과" in old:
|
||||||
|
assert mf.parse(old["결과"]["식"]) == mf.parse(back["줄"]["결과"]["식"])
|
||||||
|
|
||||||
|
|
||||||
|
def _spot(line):
|
||||||
|
kind = line["갈래"]
|
||||||
|
if kind in ("호표", "덧줄"):
|
||||||
|
return f"{kind}:{line.get('차례', 0)}"
|
||||||
|
return "결과" if kind == "결과" else f"중간:{line['열쇠']}"
|
||||||
|
|
||||||
|
|
||||||
|
@needs_node
|
||||||
|
def test_값_고침_그_변수_값():
|
||||||
|
lines = _read("GF000098")["줄"]
|
||||||
|
q = next(x for x in lines if x["열쇠"] == "q")
|
||||||
|
res = next(x for x in lines if x["갈래"] == "결과")
|
||||||
|
got = _ts("valueEdit(DATA.q, DATA.q.결과, '0.8')", {"q": q})
|
||||||
|
assert got == {"edits": [{"할일": "식", "자리": "중간:q", "식": "0.8"}]}
|
||||||
|
after = eq.preview("GF000098", got["edits"], {})
|
||||||
|
assert float(after["후"]["결과"]) == pytest.approx(66.528)
|
||||||
|
lit = _ts(
|
||||||
|
"valueEdit(DATA, leavesOf(DATA.이름식)[0], '3601').edits[0]",
|
||||||
|
res,
|
||||||
|
) # 결과 식의 3,600 → 3,601 · 반올림 감싸개 그대로
|
||||||
|
assert lit["자리"] == "결과" and lit["식"].startswith("반올림(3601 ")
|
||||||
|
after = eq.preview("GF000098", [lit], {})
|
||||||
|
assert after["되나"] and after["후"]["결과"] != after["전"]["결과"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_기호_고침_연결_바꿈():
|
||||||
|
ed = {"할일": "연결", "열쇠": "q", "갈래": "상수", "값": "0.8"}
|
||||||
|
got = eq.preview("GF000098", [ed], {})
|
||||||
|
assert got["되나"] and float(got["후"]["결과"]) == pytest.approx(66.528)
|
||||||
|
q = next(d for d in got["수식"]["정의"] if d["기호"] == "q")
|
||||||
|
assert q["연결"] == ["상수 — 0.8"]
|
||||||
|
bad = eq.preview("GF000098", [{"할일": "연결", "열쇠": "q", "갈래": "입력"}], {})
|
||||||
|
assert not bad["되나"]
|
||||||
@@ -171,15 +171,13 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 네 칸 상자 — 결과명 = 이름 식 = 값 식 = 결과 값 */
|
/* 네 마디 — 결과명 = 기호 식 = 값 식 = 결과 값(801 — 박스 · 테두리 없는 조판 한 줄) */
|
||||||
.ui-eq__box {
|
.ui-eq__box {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-4);
|
gap: var(--spacing-4);
|
||||||
padding: var(--spacing-4) var(--spacing-8);
|
padding: 2px var(--spacing-4);
|
||||||
border: 1px solid var(--color-border-strong);
|
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--color-surface-raised);
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -206,24 +204,43 @@
|
|||||||
font-size: var(--text-caption);
|
font-size: var(--text-caption);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 바로 고침 — 이름 식 칸을 누르면 그 자리에서 식 칸 */
|
/* 바로 고침(801) — 보이는 식 그 자리(커서) · 값 숫자 그 자리 */
|
||||||
.ui-eq__name.is-edit {
|
.ui-eq__name.is-edit {
|
||||||
cursor: text;
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-eq__name.is-edit:hover {
|
.ui-eq__name.is-edit:hover,
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ui-eq__name.is-open {
|
.ui-eq__name.is-open {
|
||||||
flex: 1 1 24rem;
|
background: var(--color-accent-soft);
|
||||||
align-items: flex-start;
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-eq__name.is-open > * {
|
.ui-eq__edit {
|
||||||
flex: 1 1 auto;
|
display: inline-flex;
|
||||||
min-width: 0;
|
align-items: center;
|
||||||
|
gap: 0 var(--spacing-4);
|
||||||
|
min-width: 1ch;
|
||||||
|
outline: none;
|
||||||
|
caret-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-eq__edit[data-bad="1"] {
|
||||||
|
text-decoration: underline wavy var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-eq__note {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-eq .is-value {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-eq .is-value:hover,
|
||||||
|
.ui-eq .is-value[data-editing] {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 하위 로직 블록 — 접힘 */
|
/* 하위 로직 블록 — 접힘 */
|
||||||
|
|||||||
@@ -6,10 +6,14 @@
|
|||||||
* 크기 = 글자 `--text-body` · 첨자 0.7 배 · 분수 안 글 같은 크기 · 변수 칩 높이는 글 줄
|
* 크기 = 글자 `--text-body` · 첨자 0.7 배 · 분수 안 글 같은 크기 · 변수 칩 높이는 글 줄
|
||||||
* 상태 = 고르는 변수 `is-pick`(눌림 가능 · 머리 `data-size` 없음 — 글 속 부품) · 걸리지 않은 가지는 마우스 글로만
|
* 상태 = 고르는 변수 `is-pick`(눌림 가능 · 머리 `data-size` 없음 — 글 속 부품) · 걸리지 않은 가지는 마우스 글로만
|
||||||
* 쓰는 법 = `renderEq(tree, { onVar, onBlock })` → 인라인 요소 · 줄 · 풀이 · 블록 = `ui_template_equation_line.ts`
|
* 쓰는 법 = `renderEq(tree, { onVar, onBlock })` → 인라인 요소 · 줄 · 풀이 · 블록 = `ui_template_equation_line.ts`
|
||||||
|
* 801 = 박스 없는 조판 · 만약 = 탄 갈래만 · 반올림 감싸개 안 보임 · 로직 부름 = 기호 · 값 식 숫자 뒤 단위 ·
|
||||||
|
* 바로 고침 표지(원자 `data-tok` · 감싸개 `data-pre` · `data-post` — 글 꼴 바꿈 = `ui_template_equation_text.ts` ·
|
||||||
|
* `ui_template_equation_edit.ts`)
|
||||||
* 대신하는 옛 변종 = `createCalcFormula` 의 `^…^` · `_…_` 글(분수 없음 · 그 부품은 그대로 둠)
|
* 대신하는 옛 변종 = `createCalcFormula` 의 `^…^` · `_…_` 글(분수 없음 · 그 부품은 그대로 둠)
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import "./ui_template_equation.css";
|
import "./ui_template_equation.css";
|
||||||
|
import { HIDDEN, taken, textOf } from "./ui_template_equation_text";
|
||||||
|
|
||||||
/** 서버 나무 마디 — 꼴마다 쓰는 칸이 다름(변수 `위` · `아래` 는 첨자 글 · 분수 `위` · `아래` 는 마디) */
|
/** 서버 나무 마디 — 꼴마다 쓰는 칸이 다름(변수 `위` · `아래` 는 첨자 글 · 분수 `위` · `아래` 는 마디) */
|
||||||
export interface EqNode {
|
export interface EqNode {
|
||||||
@@ -25,6 +29,8 @@ export interface EqNode {
|
|||||||
단위?: string;
|
단위?: string;
|
||||||
갈래?: string;
|
갈래?: string;
|
||||||
식?: string;
|
식?: string;
|
||||||
|
/** 표 찾기 · 요소 · 로직 부름 키보드 글(801 · 서버 `_atoms`) */
|
||||||
|
입력글?: string;
|
||||||
칸?: string | null;
|
칸?: string | null;
|
||||||
표?: string;
|
표?: string;
|
||||||
표이름?: string;
|
표이름?: string;
|
||||||
@@ -142,6 +148,8 @@ export interface EqLine {
|
|||||||
원래?: unknown;
|
원래?: unknown;
|
||||||
지금?: unknown;
|
지금?: unknown;
|
||||||
뺌?: boolean;
|
뺌?: boolean;
|
||||||
|
/** 801 — 식이 표 찾기 · 요소 · 로직 부름 한 마디뿐(「q = 0.7」 · 그 마디 기호 = 줄 기호) */
|
||||||
|
한마디?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EqHandlers {
|
export interface EqHandlers {
|
||||||
@@ -211,7 +219,7 @@ export function plainEq(node: EqNode | undefined): string {
|
|||||||
return sign + low + up;
|
return sign + low + up;
|
||||||
}
|
}
|
||||||
case "블록":
|
case "블록":
|
||||||
return node.이름 || node.열쇠 || "";
|
return node.기호 ? plainEq({ ...node, 꼴: "변수" }) : node.이름 || node.열쇠 || "";
|
||||||
case "연산":
|
case "연산":
|
||||||
return `${plainEq(node.좌)} ${SHOW[node.기호 ?? ""] ?? node.기호} ${plainEq(node.우)}`;
|
return `${plainEq(node.좌)} ${SHOW[node.기호 ?? ""] ?? node.기호} ${plainEq(node.우)}`;
|
||||||
case "분수":
|
case "분수":
|
||||||
@@ -222,9 +230,13 @@ export function plainEq(node: EqNode | undefined): string {
|
|||||||
return `${plainEq(node.안)}%`;
|
return `${plainEq(node.안)}%`;
|
||||||
case "음":
|
case "음":
|
||||||
return `−${plainEq(node.안)}`;
|
return `−${plainEq(node.안)}`;
|
||||||
case "조건":
|
case "조건": {
|
||||||
|
const pick = taken(node);
|
||||||
|
if (pick) return plainEq(pick);
|
||||||
return `만약(${plainEq(node.조건)}, ${plainEq(node.참)}, ${plainEq(node.거짓)})`;
|
return `만약(${plainEq(node.조건)}, ${plainEq(node.참)}, ${plainEq(node.거짓)})`;
|
||||||
|
}
|
||||||
case "함수":
|
case "함수":
|
||||||
|
if (HIDDEN.has(node.이름 ?? "")) return plainEq(node.인자?.[0]);
|
||||||
return `${node.이름}(${(node.인자 ?? []).map(plainEq).join(", ")})`;
|
return `${node.이름}(${(node.인자 ?? []).map(plainEq).join(", ")})`;
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
@@ -234,6 +246,7 @@ export function plainEq(node: EqNode | undefined): string {
|
|||||||
/** 변수 한 개 — 눌리면 `onVar` */
|
/** 변수 한 개 — 눌리면 `onVar` */
|
||||||
function variable(node: EqNode, h: EqHandlers): HTMLElement {
|
function variable(node: EqNode, h: EqHandlers): HTMLElement {
|
||||||
const box = span("ui-eq__var", symbolOf(node));
|
const box = span("ui-eq__var", symbolOf(node));
|
||||||
|
box.dataset.tok = textOf(node);
|
||||||
box.dataset.kind = node.갈래 ?? "";
|
box.dataset.kind = node.갈래 ?? "";
|
||||||
box.dataset.eqVar = node.열쇠 ?? "";
|
box.dataset.eqVar = node.열쇠 ?? "";
|
||||||
const value = eqText(node.값);
|
const value = eqText(node.값);
|
||||||
@@ -256,6 +269,17 @@ function variable(node: EqNode, h: EqHandlers): HTMLElement {
|
|||||||
|
|
||||||
function block(node: EqNode, h: EqHandlers): HTMLElement {
|
function block(node: EqNode, h: EqHandlers): HTMLElement {
|
||||||
const at = (h.blocks ?? []).push(node);
|
const at = (h.blocks ?? []).push(node);
|
||||||
|
if (node.기호) {
|
||||||
|
// 801 — 로직 부름 = 기호(이름은 정의 영역 · 펼침 머리) · 누르면 펼침
|
||||||
|
const sym = variable(
|
||||||
|
{ ...node, 갈래: "블록" },
|
||||||
|
{ onVar: h.onBlock ? () => h.onBlock?.(node) : undefined },
|
||||||
|
);
|
||||||
|
sym.dataset.tok = textOf(node);
|
||||||
|
sym.dataset.eqBlock = node.열쇠 ?? "";
|
||||||
|
sym.title = `${node.이름 ?? ""} ${eqText(node.값)}`.trim();
|
||||||
|
return sym;
|
||||||
|
}
|
||||||
const value = eqText(node.값);
|
const value = eqText(node.값);
|
||||||
const chip = span(
|
const chip = span(
|
||||||
"ui-eq__block",
|
"ui-eq__block",
|
||||||
@@ -279,14 +303,24 @@ function block(node: EqNode, h: EqHandlers): HTMLElement {
|
|||||||
return chip;
|
return chip;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 수식 나무 → 인라인 요소 */
|
/** 고침 표지 — 감싸개 앞 · 뒤 글(그림에 안 보이는 식 조각) */
|
||||||
export function renderEq(node: EqNode | undefined, h: EqHandlers = {}): HTMLElement {
|
const wrapped = (pre: string, post: string, kid: HTMLElement): HTMLElement => {
|
||||||
|
const box = span("ui-eq__expr", kid);
|
||||||
|
box.dataset.pre = pre;
|
||||||
|
box.dataset.post = post;
|
||||||
|
return box;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 수식 나무 → 인라인 요소(`top` = 줄 맨 위 — 숨긴 감싸개 안 연산에 괄호 안 씀) */
|
||||||
|
export function renderEq(node: EqNode | undefined, h: EqHandlers = {}, top = true): HTMLElement {
|
||||||
if (!node) return span("ui-eq__expr");
|
if (!node) return span("ui-eq__expr");
|
||||||
const sub = (child: EqNode | string | undefined): HTMLElement =>
|
const sub = (child: EqNode | string | undefined): HTMLElement =>
|
||||||
typeof child === "string" ? span("ui-eq__text", child) : renderEq(child, h);
|
typeof child === "string" ? span("ui-eq__text", child) : renderEq(child, h, false);
|
||||||
switch (node.꼴) {
|
switch (node.꼴) {
|
||||||
case "수":
|
case "수": {
|
||||||
return span("ui-eq__num", node.글 ?? "");
|
const num = span("ui-eq__num", node.글 ?? "");
|
||||||
|
return node.단위 ? span("ui-eq__expr", num, span("ui-eq__unit", node.단위)) : num;
|
||||||
|
}
|
||||||
case "글":
|
case "글":
|
||||||
return span("ui-eq__str", node.따옴 ? `'${node.글 ?? ""}'` : (node.글 ?? ""));
|
return span("ui-eq__str", node.따옴 ? `'${node.글 ?? ""}'` : (node.글 ?? ""));
|
||||||
case "변수":
|
case "변수":
|
||||||
@@ -308,17 +342,24 @@ export function renderEq(node: EqNode | undefined, h: EqHandlers = {}): HTMLElem
|
|||||||
}
|
}
|
||||||
case "괄호":
|
case "괄호":
|
||||||
return span("ui-eq__paren", span("ui-eq__inner", sub(node.안)));
|
return span("ui-eq__paren", span("ui-eq__inner", sub(node.안)));
|
||||||
case "퍼센트":
|
case "퍼센트": {
|
||||||
return span("ui-eq__expr", sub(node.안), span("ui-eq__op", "%"));
|
const pct = span("ui-eq__op", "%");
|
||||||
|
pct.dataset.tok = "/ 100";
|
||||||
|
return span("ui-eq__expr", sub(node.안), pct);
|
||||||
|
}
|
||||||
case "음":
|
case "음":
|
||||||
return span("ui-eq__expr", span("ui-eq__op", "−"), sub(node.안));
|
return span("ui-eq__expr", span("ui-eq__op", "−"), sub(node.안));
|
||||||
case "조건": {
|
case "조건": {
|
||||||
const pick = node.걸림 ?? 0;
|
if (taken(node)) {
|
||||||
|
// 801-4 — 탄 갈래만(안 탄 갈래 · 조건은 안 보임 · 고칠 때 구조는 감싸개 글로 지킴)
|
||||||
|
const [c, yes, no] = [node.조건, node.참, node.거짓].map((x) => textOf(x));
|
||||||
|
return node.걸림 === 1
|
||||||
|
? wrapped(`만약(${c}, `, `, ${no})`, sub(node.참))
|
||||||
|
: wrapped(`만약(${c}, ${yes}, `, ")", sub(node.거짓));
|
||||||
|
}
|
||||||
const chip = span("ui-eq__cond", span("ui-eq__cond-key", "만약"), sub(node.조건));
|
const chip = span("ui-eq__cond", span("ui-eq__cond-key", "만약"), sub(node.조건));
|
||||||
chip.title = `참 ${plainEq(node.참)} · 거짓 ${plainEq(node.거짓)}`;
|
chip.title = `참 ${plainEq(node.참)} · 거짓 ${plainEq(node.거짓)}`;
|
||||||
if (pick === 1 || pick === 2)
|
const all = span(
|
||||||
return span("ui-eq__expr", chip, sub(pick === 1 ? node.참 : node.거짓));
|
|
||||||
return span(
|
|
||||||
"ui-eq__expr",
|
"ui-eq__expr",
|
||||||
chip,
|
chip,
|
||||||
span("ui-eq__op", "참"),
|
span("ui-eq__op", "참"),
|
||||||
@@ -326,8 +367,20 @@ export function renderEq(node: EqNode | undefined, h: EqHandlers = {}): HTMLElem
|
|||||||
span("ui-eq__op", "거짓"),
|
span("ui-eq__op", "거짓"),
|
||||||
sub(node.거짓),
|
sub(node.거짓),
|
||||||
);
|
);
|
||||||
|
all.dataset.tok = textOf(node);
|
||||||
|
return all;
|
||||||
}
|
}
|
||||||
case "함수": {
|
case "함수": {
|
||||||
|
if (HIDDEN.has(node.이름 ?? "") && node.인자?.length) {
|
||||||
|
// 801-2 — 반올림 · 자리 감싸개 안 보임(셈 그대로)
|
||||||
|
const [first, ...rest] = node.인자;
|
||||||
|
const inner =
|
||||||
|
first?.꼴 === "연산" && !top
|
||||||
|
? span("ui-eq__paren", span("ui-eq__inner", sub(first)))
|
||||||
|
: sub(first);
|
||||||
|
const tail = rest.map((x) => `, ${textOf(x)}`).join("");
|
||||||
|
return wrapped(`${node.이름}(`, `${tail})`, inner);
|
||||||
|
}
|
||||||
const args = span("ui-eq__expr");
|
const args = span("ui-eq__expr");
|
||||||
(node.인자 ?? []).forEach((a, i) =>
|
(node.인자 ?? []).forEach((a, i) =>
|
||||||
args.append(...(i ? [span("ui-eq__op", ","), sub(a)] : [sub(a)])),
|
args.append(...(i ? [span("ui-eq__op", ","), sub(a)] : [sub(a)])),
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* ui_template_equation_edit.ts
|
||||||
|
* 부품 「수식」 바로 고침(801-1 · 요구 662-1 · 2) — 보이는 조판 수식 그 자리에서 고침(따로 열리는 칸 · 창 없음).
|
||||||
|
* 기호 식 = `editEq(host, …)` — 그린 식이 그대로 글 고침 자리(커서 · 지움 · + − × ÷ / ( ) · `_` 아래첨자 · `^` 위첨자)
|
||||||
|
* · 변수 · 표 찾기 · 요소 · 로직 부름 · 숨긴 감싸개 = 원자(통째 지움 · 누르면 연결) · Enter 넣음 · Esc 버림
|
||||||
|
* · 분수 · 첨자 꼴은 넣은 뒤 서버 나무로 다시 그림(`/` = 분수 · `Q_d` = 첨자)
|
||||||
|
* 값 식 = `editNumber(span, …)` — 숫자 하나를 그 자리에서(Enter · 다른 데 누름 = 넣음 · Esc 버림)
|
||||||
|
* 글 꼴 = `serializeEq` — 그림 DOM → 키보드 한 줄 식(원자 `data-tok` · 감싸개 `data-pre` · `data-post` · 분수 · 괄호)
|
||||||
|
* 상태 = 고치는 자리 `data-editing` · 틀린 식 `data-bad`(빨강 밑줄)
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
const OP = /^[×÷−+\-*/=<>≤≥≠,]$|^(그리고|또는)$/;
|
||||||
|
|
||||||
|
/** 숫자 글 → 키보드 수(천 단위 쉼표 걷음) */
|
||||||
|
const plain = (text: string): string =>
|
||||||
|
text.replace(/ /g, " ").replace(/(\d),(?=\d{3}(?!\d))/g, "$1");
|
||||||
|
|
||||||
|
/** 그림 DOM → 키보드 한 줄 식 */
|
||||||
|
export function serializeEq(node: Node): string {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) return plain(node.textContent ?? "");
|
||||||
|
if (!(node instanceof HTMLElement)) return "";
|
||||||
|
const kids = (el: Element | null): string =>
|
||||||
|
el ? [...el.childNodes].map(serializeEq).join("") : "";
|
||||||
|
const d = node.dataset;
|
||||||
|
if (d.tok !== undefined) return ` ${d.tok} `;
|
||||||
|
if (d.pre !== undefined) return `${d.pre}${kids(node)}${d.post ?? ""}`;
|
||||||
|
const cls = node.classList;
|
||||||
|
if (cls.contains("ui-eq__unit") || node.tagName === "BR") return "";
|
||||||
|
if (cls.contains("ui-eq__frac")) {
|
||||||
|
const [top, low] = [node.children[0] ?? null, node.children[1] ?? null];
|
||||||
|
return `(${kids(top)}) / (${kids(low)})`;
|
||||||
|
}
|
||||||
|
if (cls.contains("ui-eq__paren")) return `(${kids(node)})`;
|
||||||
|
if (cls.contains("ui-eq__op")) {
|
||||||
|
const op = node.textContent ?? "";
|
||||||
|
return OP.test(op) ? ` ${op} ` : op;
|
||||||
|
}
|
||||||
|
return kids(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 한 줄 식 글 다듬기(빈칸 하나로) */
|
||||||
|
export const tidyEq = (text: string): string => text.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
export interface EqEditHooks {
|
||||||
|
/** 칠 때마다(쉼 뒤) — 검사 · 미리 셈 */
|
||||||
|
onInput: (text: string) => void;
|
||||||
|
/** Enter — 넣음 */
|
||||||
|
onCommit: (text: string) => void;
|
||||||
|
/** Esc · 안 바꾸고 다른 데 누름 — 닫음 */
|
||||||
|
onCancel: () => void;
|
||||||
|
/** 누른 자리(그 자리에 커서) */
|
||||||
|
at?: { x: number; y: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 그린 식(`host` 안)을 그 자리 고침으로 — 처음 글 돌려줌 */
|
||||||
|
export function editEq(host: HTMLElement, h: EqEditHooks): string {
|
||||||
|
host.contentEditable = "true";
|
||||||
|
host.spellcheck = false;
|
||||||
|
host.dataset.editing = "1";
|
||||||
|
host.querySelectorAll<HTMLElement>("[data-tok]").forEach((a) => {
|
||||||
|
a.contentEditable = "false";
|
||||||
|
});
|
||||||
|
const first = tidyEq(serializeEq(host));
|
||||||
|
const now = (): string => tidyEq(serializeEq(host));
|
||||||
|
let timer = 0;
|
||||||
|
host.addEventListener("beforeinput", (ev) => {
|
||||||
|
if (ev.inputType === "insertText" && ev.data === "*") {
|
||||||
|
ev.preventDefault();
|
||||||
|
document.execCommand("insertText", false, "×");
|
||||||
|
}
|
||||||
|
if (ev.inputType === "insertParagraph" || ev.inputType === "insertLineBreak")
|
||||||
|
ev.preventDefault();
|
||||||
|
});
|
||||||
|
host.addEventListener("input", () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
timer = window.setTimeout(() => h.onInput(now()), 250);
|
||||||
|
});
|
||||||
|
host.addEventListener("keydown", (ev) => {
|
||||||
|
if (ev.key === "Enter") {
|
||||||
|
ev.preventDefault();
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
h.onCommit(now());
|
||||||
|
} else if (ev.key === "Escape") {
|
||||||
|
ev.preventDefault();
|
||||||
|
h.onCancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
host.addEventListener("focusout", () =>
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!host.isConnected || host.contains(document.activeElement)) return;
|
||||||
|
if (now() === first) h.onCancel();
|
||||||
|
}, 150),
|
||||||
|
);
|
||||||
|
queueMicrotask(() => {
|
||||||
|
host.focus();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const pt = h.at;
|
||||||
|
const range =
|
||||||
|
pt && document.caretRangeFromPoint ? document.caretRangeFromPoint(pt.x, pt.y) : null;
|
||||||
|
if (range && host.contains(range.startContainer)) {
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
} else {
|
||||||
|
sel?.selectAllChildren(host);
|
||||||
|
sel?.collapseToEnd();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 값 식 숫자 하나를 그 자리에서 고침 — 바뀐 글이면 `done(새 글)` */
|
||||||
|
export function editNumber(num: HTMLElement, done: (text: string) => void): void {
|
||||||
|
const before = plain(num.textContent ?? "");
|
||||||
|
num.textContent = before;
|
||||||
|
num.contentEditable = "true";
|
||||||
|
num.dataset.editing = "1";
|
||||||
|
let over = false;
|
||||||
|
const finish = (keep: boolean): void => {
|
||||||
|
if (over) return;
|
||||||
|
over = true;
|
||||||
|
num.contentEditable = "false";
|
||||||
|
delete num.dataset.editing;
|
||||||
|
const text = plain(num.textContent ?? "").trim();
|
||||||
|
if (keep && text && text !== before) done(text);
|
||||||
|
else num.textContent = before;
|
||||||
|
};
|
||||||
|
num.addEventListener("keydown", (ev) => {
|
||||||
|
if (ev.key === "Enter" || ev.key === "Escape") {
|
||||||
|
ev.preventDefault();
|
||||||
|
finish(ev.key === "Enter");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
num.addEventListener("blur", () => finish(true), { once: true });
|
||||||
|
num.focus();
|
||||||
|
window.getSelection()?.selectAllChildren(num);
|
||||||
|
}
|
||||||
@@ -5,13 +5,18 @@
|
|||||||
* 값 식이 결과와 같으면 값 식 칸 생략 · 식을 못 읽은 줄 = 원문 + 까닭(붉은 글)
|
* 값 식이 결과와 같으면 값 식 칸 생략 · 식을 못 읽은 줄 = 원문 + 까닭(붉은 글)
|
||||||
* 이음 = 여러 줄 위에서 아래로(`createEqChain`) · 하위 로직 부르기 = 줄 아래 접힘 블록(노 · 재 · 경 · 계 소계 · 펼치면 그 로직 줄 이음)
|
* 이음 = 여러 줄 위에서 아래로(`createEqChain`) · 하위 로직 부르기 = 줄 아래 접힘 블록(노 · 재 · 경 · 계 소계 · 펼치면 그 로직 줄 이음)
|
||||||
* 크기 = 글 `--text-body` · 접힘 단추 22
|
* 크기 = 글 `--text-body` · 접힘 단추 22
|
||||||
* 상태 = 바로 고침 — 이름 식 칸을 누르면 그 자리가 식 칸으로 바뀜(`onEdit` → 부르는 쪽이 `editing` 을 정해 다시 그림 · `editor` 가 칸을 줌) · 펼친 블록 `data-open`
|
* 상태 = 바로 고침(801) — 기호 식을 누르면 그린 식 그대로 고침 자리(`onEdit(line, id, 누른 자리)` → 부르는 쪽이 `editing` 을 정해
|
||||||
* 쓰는 법 = `createEqChain(tree.줄, { path: "", open: new Set(), onVar, editing, onEdit, editor })`
|
* 다시 그림 · `editor(line, id, host)` 가 그 자리에 고침을 붙임 · 호표는 수량 쪽만) · 값 식 숫자 · 중간 줄 결과 값 = 그 자리 숫자 고침(`onValue`)
|
||||||
|
* · 펼친 블록 `data-open`
|
||||||
|
* 801 = 박스 · 테두리 없는 조판 한 줄 · 한 마디 줄(서버 `한마디` — 표 찾기 · 요소 · 로직 부름 하나)은 「q = 0.7」(로직 부름은 아래 펼침 그대로)
|
||||||
|
* 쓰는 법 = `createEqChain(tree.줄, { path: "", open: new Set(), onVar, editing, onEdit, editor, onValue })`
|
||||||
* 대신한 옛 변종 = 시트 줄(이름 | 식 | 값 한 줄 · 분수 · 풀이 없음 · 799-2 에 걷음) · 줄 눌러 여는 아래 편집 칸(800-3 에 걷음)
|
* 대신한 옛 변종 = 시트 줄(이름 | 식 | 값 한 줄 · 분수 · 풀이 없음 · 799-2 에 걷음) · 줄 눌러 여는 아래 편집 칸(800-3 에 걷음)
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { createBadge } from "./ui_template_badge";
|
import { createBadge } from "./ui_template_badge";
|
||||||
import { createButton } from "./ui_template_button";
|
import { createButton } from "./ui_template_button";
|
||||||
|
import { editNumber } from "./ui_template_equation_edit";
|
||||||
|
import { leavesOf } from "./ui_template_equation_text";
|
||||||
import {
|
import {
|
||||||
eqText,
|
eqText,
|
||||||
plainEq,
|
plainEq,
|
||||||
@@ -31,10 +36,12 @@ export interface EqLineOptions {
|
|||||||
onVar?: (node: EqNode) => void;
|
onVar?: (node: EqNode) => void;
|
||||||
/** 바로 고치는 줄 이름표(`lineId`) — 이 줄의 이름 식 칸 자리에 `editor` 칸이 들어감 */
|
/** 바로 고치는 줄 이름표(`lineId`) — 이 줄의 이름 식 칸 자리에 `editor` 칸이 들어감 */
|
||||||
editing?: string | null;
|
editing?: string | null;
|
||||||
/** 이름 식 칸을 누름(없으면 고칠 수 없음) */
|
/** 기호 식을 누름(없으면 고칠 수 없음) — 누른 자리 */
|
||||||
onEdit?: (line: EqLine, id: string) => void;
|
onEdit?: (line: EqLine, id: string, at: { x: number; y: number }) => void;
|
||||||
/** 고치는 줄의 식 칸 */
|
/** 고치는 줄 — 그린 식(`host`)에 고침을 붙임 */
|
||||||
editor?: (line: EqLine, id: string) => HTMLElement;
|
editor?: (line: EqLine, id: string, host: HTMLElement) => void;
|
||||||
|
/** 값 숫자를 고침 — `leaf` = 기호 식의 그 마디(수 · 변수) · 중간 줄 결과 값이면 줄 머리 */
|
||||||
|
onValue?: (line: EqLine, leaf: EqNode, text: string) => void;
|
||||||
/** 줄 바로 아래에 끼울 것(하위 로직 블록 소계 묶음 · 800-4) */
|
/** 줄 바로 아래에 끼울 것(하위 로직 블록 소계 묶음 · 800-4) */
|
||||||
after?: (line: EqLine) => HTMLElement | null;
|
after?: (line: EqLine) => HTMLElement | null;
|
||||||
/** 결과 줄 값 뒤 단위 */
|
/** 결과 줄 값 뒤 단위 */
|
||||||
@@ -86,7 +93,7 @@ function blockPanel(node: EqNode, at: number, o: EqLineOptions): HTMLElement {
|
|||||||
box.dataset.eqBlockPanel = node.열쇠 ?? "";
|
box.dataset.eqBlockPanel = node.열쇠 ?? "";
|
||||||
box.dataset.blockId = id;
|
box.dataset.blockId = id;
|
||||||
const head = createButton({
|
const head = createButton({
|
||||||
label: `${String.fromCodePoint(0x245f + at)} ${node.이름 || node.열쇠 || ""}`,
|
label: `${node.기호 ? "" : `${String.fromCodePoint(0x245f + at)} `}${node.이름 || node.열쇠 || ""}`,
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
size: 22,
|
size: 22,
|
||||||
iconStart: "chevronRight",
|
iconStart: "chevronRight",
|
||||||
@@ -110,7 +117,8 @@ function blockPanel(node: EqNode, at: number, o: EqLineOptions): HTMLElement {
|
|||||||
body.hidden = !on;
|
body.hidden = !on;
|
||||||
};
|
};
|
||||||
body.hidden = true;
|
body.hidden = true;
|
||||||
box.append(div("ui-eq__block-row", head, bundle(node)), body);
|
const sign = node.기호 ? [symbolOf(node), span("ui-eq__op", ":")] : [];
|
||||||
|
box.append(div("ui-eq__block-row", ...sign, head, bundle(node)), body);
|
||||||
if (o.open.has(id)) toggle(true);
|
if (o.open.has(id)) toggle(true);
|
||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
@@ -132,6 +140,36 @@ function markOf(line: EqLine): HTMLElement | null {
|
|||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 값 숫자 자리 — 누르면 그 자리 고침 */
|
||||||
|
function valueSpot(num: HTMLElement, leaf: () => EqNode, line: EqLine, o: EqLineOptions): void {
|
||||||
|
num.classList.add("is-value");
|
||||||
|
num.title = "눌러서 값 고침";
|
||||||
|
num.addEventListener("click", (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (num.dataset.editing) return;
|
||||||
|
const unit = [...num.querySelectorAll(".ui-eq__unit")];
|
||||||
|
unit.forEach((u) => u.remove());
|
||||||
|
// 결과 값 칸은 글 + 단위 — 글만 고침
|
||||||
|
editNumber(num, (text) => o.onValue?.(line, leaf(), text));
|
||||||
|
num.addEventListener("blur", () => num.append(...unit), { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 값 식 숫자 ↔ 기호 식 잎(같은 차례) — 수 · 입력 · 중간 · 표 찾기 · 요소만 고침(호표 단가 쪽 · 블록 · 금액 아님) */
|
||||||
|
function values(box: HTMLElement, line: EqLine, o: EqLineOptions): void {
|
||||||
|
const leaves = leavesOf(line.이름식);
|
||||||
|
const skip = line.갈래 === "호표" ? leavesOf(line.이름식?.좌).length : 0;
|
||||||
|
box.querySelectorAll<HTMLElement>(".ui-eq__num, .ui-eq__str").forEach((num, at) => {
|
||||||
|
const leaf = leaves[at];
|
||||||
|
const ok =
|
||||||
|
at >= skip &&
|
||||||
|
leaf &&
|
||||||
|
(leaf.꼴 === "수" ||
|
||||||
|
(leaf.꼴 === "변수" && ["입력", "중간", "찾기", "요소"].includes(leaf.갈래 ?? "")));
|
||||||
|
if (ok && num.classList.contains("ui-eq__num")) valueSpot(num, () => leaf, line, o);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 줄 하나 */
|
/** 줄 하나 */
|
||||||
export function createEqLine(line: EqLine, o: EqLineOptions): HTMLElement {
|
export function createEqLine(line: EqLine, o: EqLineOptions): HTMLElement {
|
||||||
const id = lineId(line);
|
const id = lineId(line);
|
||||||
@@ -174,24 +212,38 @@ export function createEqLine(line: EqLine, o: EqLineOptions): HTMLElement {
|
|||||||
line.글 || "—",
|
line.글 || "—",
|
||||||
...(resUnit ? [span("ui-eq__unit", resUnit)] : []),
|
...(resUnit ? [span("ui-eq__unit", resUnit)] : []),
|
||||||
);
|
);
|
||||||
if (line.이름식) {
|
if (o.onValue && line.갈래 === "중간" && head && line.글) valueSpot(result, () => head, line, o);
|
||||||
|
const lone = line.한마디 && line.이름식;
|
||||||
|
if (lone && line.이름식?.꼴 === "블록") blocks.push(line.이름식); // 펼침은 그대로
|
||||||
|
if (line.이름식 && !lone) {
|
||||||
const name = span("ui-eq__box ui-eq__name");
|
const name = span("ui-eq__box ui-eq__name");
|
||||||
|
name.append(renderEq(line.이름식, hand));
|
||||||
if (o.editing === id && o.editor) {
|
if (o.editing === id && o.editor) {
|
||||||
name.classList.add("is-open");
|
name.classList.add("is-open");
|
||||||
name.append(o.editor(line, id));
|
const expr = name.firstElementChild as HTMLElement;
|
||||||
} else {
|
// 호표 = 단가 × 수량 — 수량 쪽만 고침
|
||||||
name.append(renderEq(line.이름식, hand));
|
const target = line.갈래 === "호표" ? (expr.lastElementChild as HTMLElement) : expr;
|
||||||
if (o.onEdit) {
|
const host = span("ui-eq__edit");
|
||||||
name.classList.add("is-edit");
|
target.replaceWith(host);
|
||||||
name.title = "눌러서 바로 고침";
|
host.append(target);
|
||||||
name.addEventListener("click", () => o.onEdit?.(line, id));
|
o.editor(line, id, host);
|
||||||
}
|
} else if (o.onEdit) {
|
||||||
|
name.classList.add("is-edit");
|
||||||
|
name.title = "눌러서 바로 고침 · 기호를 누르면 연결";
|
||||||
|
name.addEventListener("click", (ev) =>
|
||||||
|
o.onEdit?.(line, id, { x: ev.clientX, y: ev.clientY }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
body.append(span("ui-eq__op", "="), name);
|
body.append(span("ui-eq__op", "="), name);
|
||||||
const same =
|
const same =
|
||||||
plainEq(line.값식) === (line.글 ?? "") || plainEq(line.값식) === plainEq(line.이름식);
|
plainEq(line.값식) === (line.글 ?? "") || plainEq(line.값식) === plainEq(line.이름식);
|
||||||
if (line.값식 && !same)
|
if (line.값식 && !same) {
|
||||||
body.append(span("ui-eq__op", "="), span("ui-eq__box ui-eq__val", renderEq(line.값식)));
|
const val = span("ui-eq__box ui-eq__val", renderEq(line.값식));
|
||||||
|
if (o.onValue) values(val, line, o);
|
||||||
|
body.append(span("ui-eq__op", "="), val);
|
||||||
|
}
|
||||||
|
body.append(span("ui-eq__op", "="), result);
|
||||||
|
} else if (lone) {
|
||||||
body.append(span("ui-eq__op", "="), result);
|
body.append(span("ui-eq__op", "="), result);
|
||||||
} else {
|
} else {
|
||||||
body.append(
|
body.append(
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* ui_template_equation_text.ts
|
||||||
|
* 부품 「수식」 글 꼴(801-1 · 요구 662) — 수식 나무 ↔ 키보드 한 줄 식의 한 곳(화면 · DOM 없음 · 시험 `test_801_1_equation_inline`).
|
||||||
|
* textOf = 나무 → 키보드 식(변수 = 열쇠 · 표 찾기 · 요소 · 로직 부르기 = 서버 `입력글`(별칭 꼴) › 원문 식 · 만약 = 세 갈래 모두) · `swap` 마디는 그 글로
|
||||||
|
* leavesOf = 보이는 잎(수 · 글 · 변수 · 블록) 차례 — 값 식 그림의 숫자 차례와 같음(탄 갈래만 · 반올림 감싸개는 첫 인자만)
|
||||||
|
* 보이기 규칙 = `taken`(만약 = 탄 갈래만) · `HIDDEN`(반올림 = 자리 감싸개 숨김) — 셈 · 식은 그대로(그림만)
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import type { EqNode } from "./ui_template_equation";
|
||||||
|
|
||||||
|
/** 그림에서 감싸개를 숨기는 함수(첫 인자만 보임) */
|
||||||
|
export const HIDDEN = new Set(["반올림"]);
|
||||||
|
|
||||||
|
/** 만약 — 탄 갈래 마디(모르면 null = 세 갈래 모두 보임) */
|
||||||
|
export const taken = (n: EqNode): EqNode | null =>
|
||||||
|
n.걸림 === 1 ? (n.참 ?? null) : n.걸림 === 2 ? (n.거짓 ?? null) : null;
|
||||||
|
|
||||||
|
const isOp = (n: EqNode | undefined): boolean => n?.꼴 === "연산";
|
||||||
|
const wrap = (text: string, yes: boolean): string => (yes ? `(${text})` : text);
|
||||||
|
|
||||||
|
/** 나무 → 키보드 한 줄 식 — 엔진 묶음 그대로(분수 · 퍼센트가 연산 오른쪽이면 괄호) */
|
||||||
|
export function textOf(n: EqNode | undefined, swap?: Map<EqNode, string>): string {
|
||||||
|
if (!n) return "";
|
||||||
|
const own = swap?.get(n);
|
||||||
|
if (own !== undefined) return own;
|
||||||
|
const t = (x: EqNode | string | undefined): string =>
|
||||||
|
typeof x === "string" ? x : textOf(x, swap);
|
||||||
|
switch (n.꼴) {
|
||||||
|
case "수": {
|
||||||
|
const raw = typeof n.값 === "number" || typeof n.값 === "string" ? String(n.값) : "";
|
||||||
|
return /^-?\d+(\.\d+)?(e-?\d+)?$/i.test(raw) ? raw : (n.글 ?? "").replace(/,/g, "");
|
||||||
|
}
|
||||||
|
case "글":
|
||||||
|
return n.따옴 ? `'${n.글 ?? ""}'` : (n.글 ?? "");
|
||||||
|
case "변수":
|
||||||
|
return n.갈래 === "입력" || n.갈래 === "중간"
|
||||||
|
? (n.열쇠 ?? "")
|
||||||
|
: n.입력글 || n.식 || n.열쇠 || "";
|
||||||
|
case "블록":
|
||||||
|
return n.입력글 || n.식 || n.열쇠 || "";
|
||||||
|
case "연산": {
|
||||||
|
const right = n.우?.꼴 === "분수" || n.우?.꼴 === "퍼센트";
|
||||||
|
return `${t(n.좌)} ${n.기호 ?? ""} ${wrap(t(n.우), right)}`;
|
||||||
|
}
|
||||||
|
case "분수": {
|
||||||
|
const top = n.위 as EqNode;
|
||||||
|
const low = n.아래 as EqNode;
|
||||||
|
const head = wrap(t(top), isOp(top) && !["×", "÷"].includes(top.기호 ?? ""));
|
||||||
|
if (n.사슬 && low?.꼴 === "연산")
|
||||||
|
return `${head} / ${wrap(t(low.좌), isOp(low.좌))} / ${wrap(t(low.우), isOp(low.우))}`;
|
||||||
|
return `${head} / ${wrap(t(low), isOp(low) || low?.꼴 === "분수")}`;
|
||||||
|
}
|
||||||
|
case "괄호":
|
||||||
|
return `(${t(n.안)})`;
|
||||||
|
case "퍼센트":
|
||||||
|
return `${t(n.안)} / 100`;
|
||||||
|
case "음":
|
||||||
|
return `-${t(n.안)}`;
|
||||||
|
case "조건":
|
||||||
|
return `만약(${t(n.조건)}, ${t(n.참)}, ${t(n.거짓)})`;
|
||||||
|
case "함수":
|
||||||
|
return `${n.이름 ?? ""}(${(n.인자 ?? []).map((a) => t(a)).join(", ")})`;
|
||||||
|
default:
|
||||||
|
return n.글 ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 보이는 잎 차례 — 값 식 그림(`renderEq`)의 수 · 글 차례와 같음 */
|
||||||
|
export function leavesOf(n: EqNode | undefined, out: EqNode[] = []): EqNode[] {
|
||||||
|
if (!n) return out;
|
||||||
|
const go = (x: EqNode | string | undefined): void => {
|
||||||
|
if (x && typeof x !== "string") leavesOf(x, out);
|
||||||
|
};
|
||||||
|
switch (n.꼴) {
|
||||||
|
case "수":
|
||||||
|
case "글":
|
||||||
|
case "변수":
|
||||||
|
case "블록":
|
||||||
|
out.push(n);
|
||||||
|
break;
|
||||||
|
case "조건": {
|
||||||
|
const pick = taken(n);
|
||||||
|
if (pick) go(pick);
|
||||||
|
else [n.조건, n.참, n.거짓].forEach(go);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "함수":
|
||||||
|
(HIDDEN.has(n.이름 ?? "") ? (n.인자 ?? []).slice(0, 1) : (n.인자 ?? [])).forEach(go);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
[n.좌, n.우, n.위, n.아래, n.안].forEach(go);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user