feat(804-6): 옛 수식 탭 걷음 · 줄 쌓기 = 첫 「수식」 탭(요구 686-1)
- M01 단가산출 기초 · M02 단가산출 상세 보기 = 수식(줄 쌓기 · 기본) · 단가산출 상세 둘 - 줄 쌓기에 M02 사본 [단가 갱신] · [재생성](미리 셈 → 알림 → 저장 · 고침 있으면 막음) · 판 바뀜(409) 문서 그대로면 새 판으로 다시 저장 옮김 - 옛 수식 문서 화면 걷음(formula_doc_screen · defs · edit · lines · math · math_model · rows · sub · formula_doc.css) · 이미 안 부르던 796-2 수식 나무 화면(LogicLab_Equation · Defs · Var · css · M01_Logic_Equation_Model · sheet_formula_cell · ui_template_equation_defs · edit) 걷음 — 18 파일 4,860 줄 - formula_doc_render = 줄 쌓기가 쓰는 reach 만 · 옵션 가로 CSS 는 line_stack.css 로 - 남김 = 계산기 common_util_formula_doc* · 서버 길 · 옵션 · 사본 표지 · 합계 상자 · KaTeX · 시험 페이지 tools/line-stack-test - 시험: test_796_2 지움(걷은 화면 원본 검사뿐) · 799_1 · 800_2 걷은 화면 줄만 뺌 · 799_2 · 802_4b2 · 804_2 보기 · 화면 파일 대상 바꿈 · test_804_6 새로(걷은 파일 · 부르는 곳 없음 · 첫 탭 · 사본 할일) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab.ts
|
||||
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 수식(802-3 수식 문서 `A00_Common/formula_doc_screen.ts`) · 단가산출 상세 / 오른쪽 원문
|
||||
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 수식(줄 쌓기 `A00_Common/line_stack_screen.ts` · 804-6) · 단가산출 상세 / 오른쪽 원문
|
||||
* 셈 = 화면 칸 없이 구동(`_Logic_Drive.ts` · 799-2) — 열 때 첫 셈 · 입력 · 시험 고침 · 재료 고름이 `drive.run`
|
||||
* 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안
|
||||
*
|
||||
@@ -43,7 +43,6 @@ import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic";
|
||||
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
|
||||
import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side";
|
||||
import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View";
|
||||
import { createDocScreen } from "../A00_Common/formula_doc_screen";
|
||||
import { createStackScreen } from "../A00_Common/line_stack_screen";
|
||||
import { buildHeadBar } from "./M01_MasterData_UI_Head";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
@@ -108,22 +107,15 @@ export async function mountM01LogicLab(
|
||||
.catch(() => false);
|
||||
let recalc: number | undefined;
|
||||
const drive = createDrive();
|
||||
// 802-3 수식 = 수식 문서 화면(공용 · 옛 수식 나무 화면은 M01 에서 걷음)
|
||||
const equation = createDocScreen({ base: "/m01/logic/doc", admin, cache: "m01_doc_drafts" });
|
||||
// 804-2 줄 쌓기 = 같은 수식 문서 · 같은 캐시 — 탭을 바꾸면 고른 쪽이 다시 읽음
|
||||
// 수식 = 줄 쌓기(804-2 · 804-6 옛 수식 문서 탭 걷음) — 수식 문서 · 캐시 m01_doc_drafts
|
||||
const stack = createStackScreen({ base: "/m01/logic/doc", admin, cache: "m01_doc_drafts" });
|
||||
const leave = (): Promise<boolean> => equation.canLeave().then((ok) => ok && stack.canLeave());
|
||||
const leave = (): Promise<boolean> => stack.canLeave();
|
||||
const editor = el("div", { className: "m01-logic__editor" });
|
||||
// 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」
|
||||
const panes = buildLogicSide(editor);
|
||||
// 보기 고르기 — 머리줄 [신규 로직] 왼쪽
|
||||
const views = createViewPicker(editor, (v) => {
|
||||
const k = opened?.origKey;
|
||||
if (v === "stack") stack.draw(k ?? "", true);
|
||||
else if (v === "equation" && k) {
|
||||
equation.draw("");
|
||||
equation.draw(k);
|
||||
}
|
||||
if (v === "stack") stack.draw(opened?.origKey ?? "", true);
|
||||
});
|
||||
const errors = el("div", {
|
||||
className: "ui-banner ui-banner--danger m01-logic__reasons",
|
||||
@@ -254,10 +246,8 @@ export async function mountM01LogicLab(
|
||||
onTrial: drawCalc,
|
||||
rerun: () => void drive.run(),
|
||||
admin,
|
||||
equation: equation.root,
|
||||
stack: stack.root,
|
||||
});
|
||||
equation.draw(current.origKey ?? "");
|
||||
stack.draw(current.origKey ?? "");
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Detail.ts
|
||||
* 「단가산출 로직」 상세 화면 — 컨테이너를 위에서 아래로(수식 탭 796-2 포함)
|
||||
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 수식(796-2) ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침)
|
||||
* 「단가산출 로직」 상세 화면 — 컨테이너를 위에서 아래로
|
||||
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 수식(줄 쌓기 804-2 · 804-6) ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침)
|
||||
* 시트 · 인력/재료/경비 · 시험 계산 · 텍스트 수식 보기는 799-2 에 걷음 — 셈은 화면 칸 없이 `_Logic_Drive.ts` 구동
|
||||
* 그 자리 고침(722-2) = 시험 값만(`_LogicLab_Cover.ts` trial — 줄 사본으로 셈 · 정본 · 요소 · 캐시 안 바뀜)
|
||||
* ========================================================================== */
|
||||
@@ -36,9 +36,7 @@ export interface DetailContext extends HoContext {
|
||||
onTrial?: () => void;
|
||||
/** 717-2b M02 단가산출 — 단가산출 상세 덩이를 이 칸으로(SK 셈 답 · 고침 표) */
|
||||
cover?: { ho: HTMLElement; open?: ReturnType<typeof lineOpener> };
|
||||
/** 796-2 수식 탭(`_LogicLab_Equation`) 그릇 — 있으면 「수식」 덩이(M02 는 795-1 사본 꼴 뒤에 이음) */
|
||||
equation?: HTMLElement;
|
||||
/** 804-2 줄 쌓기 탭 그릇(`A00_Common/line_stack_screen.ts`) */
|
||||
/** 수식 탭 = 줄 쌓기 그릇(`A00_Common/line_stack_screen.ts` · 804-2 · 804-6) */
|
||||
stack?: HTMLElement;
|
||||
}
|
||||
|
||||
@@ -230,7 +228,6 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
|
||||
// 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임
|
||||
host.replaceChildren(
|
||||
...infoBox(ctx),
|
||||
...(ctx.equation ? [block(ctx.equation, "equation")] : []),
|
||||
...(ctx.stack ? [block(ctx.stack, "stack")] : []),
|
||||
ctx.cover ? block(section(tl("Ho_Title"), [ctx.cover.ho]), "ho") : hoBox(ctx),
|
||||
);
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/* 수식 탭(796-2) — 배치만 · 식 그림은 공통 수식 부품 · 색은 공통 값 */
|
||||
.m01-eq {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.m01-eq__options .ui-toolbar {
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-8) var(--spacing-12);
|
||||
}
|
||||
|
||||
.m01-eq__opt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
max-width: 14rem;
|
||||
}
|
||||
|
||||
.m01-eq__opt > .ui-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-eq__ctl {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-eq__ctl :is(select, input) {
|
||||
width: 100%;
|
||||
max-width: 14rem;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.m01-eq__notice:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m01-eq__edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m01-eq__fx {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-eq__form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
gap: var(--spacing-8) var(--spacing-12);
|
||||
}
|
||||
|
||||
.m01-eq__why:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m01-eq__why {
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 800-2 — 왼쪽 수식 영역 · 오른쪽 변수 정의 영역 */
|
||||
.m01-eq__split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(14rem, 20rem);
|
||||
gap: var(--spacing-12);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.m01-eq__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@container (max-width: 700px) {
|
||||
.m01-eq__split {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.m01-eq__split > .ui-eq__defs {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
.m01-eq__def-edit,
|
||||
.m01-eq__draft {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m01-eq__def-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
@@ -1,616 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Equation.ts
|
||||
* M01 단가산출 로직 상세 「수식」 탭(PLAN 796-2 · 요구 655 · 656 · 657) — PPT 수식 꼴(결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이).
|
||||
* 그림 = 공통 수식 부품(`ui_template_equation*.ts`) · 서버 나무 = `POST /api/m01/logic/equation`(796-1)
|
||||
* 위 = 옵션 가로(도구줄 + 값 칸 · 바꾸면 다시 셈) · 합계 · 고침 도구(시스템 관리자만)
|
||||
* 배치(800-2) = 왼쪽 수식 영역(줄 구분 없이 식이 이어짐 + 소계 표) · 오른쪽 변수 정의 영역(고른 옵션 갈래의 변수만 · 값 · 단위 · DB 열쇠 없음)
|
||||
* 고침(800-3) = 수식 영역의 식 칸을 눌러 그 자리에서(공용 `sheet_formula_cell` · 칠 때마다 `/equation/preview` 검사 · 틀리면 그 자리 빨강 + 까닭)
|
||||
* · 새 기호를 쓰면 정의 영역에 그 기호 줄이 생겨 연결 지정(`_EquationDefs` · 고침 `연결`) · 이름 · 기호 · 첨자 고침 · 지움도 정의 줄에서
|
||||
* · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인)
|
||||
* 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음)
|
||||
* 시트 · 시험 쪽에 저장 안 한 고침이 있으면 수식 고침은 잠김(두 갈래가 한 줄을 따로 고치지 않게)
|
||||
* 페이지 갈래(`page` · 796-2b M02 단가산출 사본) = 같은 부품 · 길 · 고침 목록(문서 `수식고침`) · 옵션 값이 페이지 것 · 저장 = 페이지 [저장]
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createBanner } from "@ui/ui_template_banner";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { createEqChain } from "@ui/ui_template_equation_line";
|
||||
import { createEqDefs, defSign, flashEqDef } from "@ui/ui_template_equation_defs";
|
||||
import { createEqSum } from "@ui/ui_template_equation_sum";
|
||||
import {
|
||||
eqText,
|
||||
type EqLine,
|
||||
type EqSumHooks,
|
||||
type EqNode,
|
||||
type EqTree,
|
||||
} from "@ui/ui_template_equation";
|
||||
import { lineId } from "@ui/ui_template_equation_line";
|
||||
import {
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { createToolbar } from "@ui/ui_template_toolbar";
|
||||
import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_formula_cell";
|
||||
import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values";
|
||||
import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { ApiError, type LogicRow } from "./M01_MasterData_UI_Logic_Api";
|
||||
import {
|
||||
draftRow,
|
||||
renameEditor,
|
||||
type Draft,
|
||||
type DefsHost,
|
||||
} from "./M01_MasterData_UI_LogicLab_EquationDefs";
|
||||
import { openAddDialog } from "./M01_MasterData_UI_LogicLab_EquationVar";
|
||||
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
|
||||
import { formulaEdit, inputsOf, unknownOf, type EqEdit } from "./logic/M01_Logic_Equation_Model";
|
||||
import "./M01_MasterData_UI_LogicLab_Equation.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["수식", "Equation"],
|
||||
Save: ["수식 저장", "Save equation"],
|
||||
Discard: ["수식 고침 버림", "Discard edits"],
|
||||
Add: ["변수 더함", "Add variable"],
|
||||
Edits: ["수식 고침 {n} 건 · 저장 안 함", "{n} equation edits · unsaved"],
|
||||
EditsPage: ["수식 고침 {n} 건 · 사본 위", "{n} equation edits · on copy"],
|
||||
DiscardPage: ["수식 원래로", "Back to copy"],
|
||||
Now: ["지금", "Now"],
|
||||
Before: ["고치기 전", "Before"],
|
||||
Blocked: [
|
||||
"시트 · 시험 쪽에 저장 안 한 고침이 있음 — 먼저 [저장] 또는 [버림] 한 뒤 수식을 고침(수식은 저장본 기준)",
|
||||
"Unsaved edits elsewhere — save or discard first (showing saved version)",
|
||||
],
|
||||
Options: ["옵션", "Options"],
|
||||
Wait: ["수식을 불러오는 중", "Loading equation"],
|
||||
Hint: [
|
||||
"식을 눌러 바로 고침 · 키보드 + − × ÷ / ( )",
|
||||
"Click a formula to edit · keyboard + − × ÷ / ( )",
|
||||
],
|
||||
Apply: ["연결 적용 · 식 넣기", "Apply links and formula"],
|
||||
Leave: ["저장 안 한 수식 고침 {n} 건 — 버리고 나감?", "Discard {n} unsaved equation edits?"],
|
||||
Leave_Ok: ["버림", "Discard"],
|
||||
Saved: ["수식을 저장함", "Equation saved"],
|
||||
Total: ["계", "Total"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
const t = (k: keyof typeof TEXT, n = 0): string =>
|
||||
(TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]).replace("{n}", String(n));
|
||||
|
||||
interface Read {
|
||||
ok?: boolean;
|
||||
reason?: string;
|
||||
수식?: EqTree;
|
||||
되나?: boolean;
|
||||
까닭?: string[];
|
||||
줄?: LogicRow;
|
||||
전?: Record<string, unknown>;
|
||||
후?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
|
||||
const r = await fetch(`${API_BASE_URL}/m01${path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown };
|
||||
if (!r.ok) throw new ApiError(r.status, data.detail ?? r.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
const CACHE = "m01_equation_edits";
|
||||
const BIG = 12; // 고르기가 이보다 많으면 찾기형(입력 + 목록)
|
||||
const loadCache = (): Record<string, EqEdit[]> => {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem(CACHE) ?? "{}") as Record<string, EqEdit[]>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/** 페이지 캐시 갈래(796-2b M02 단가산출 사본) — 길 · 고침 목록 · 옵션 값이 페이지 것 · 저장 = 페이지 [저장] */
|
||||
export interface EqPage {
|
||||
ask: <T>(
|
||||
suffix: "" | "/preview" | "/links",
|
||||
body: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<T>;
|
||||
edits: () => EqEdit[];
|
||||
keep: (edits: EqEdit[]) => void;
|
||||
values: () => Record<string, string>;
|
||||
setValue: (name: string, value: string | null) => void;
|
||||
/** 소계 표 덧단추 자리(796-2e M02 더한 기초 [연결 끊기]) */
|
||||
sum?: EqSumHooks;
|
||||
}
|
||||
|
||||
export interface LabEquationOptions {
|
||||
/** 시스템 관리자 — 고침 도구 */
|
||||
admin: boolean;
|
||||
/** 시트 · 시험 쪽에 저장 안 한 고침이 있나 */
|
||||
blocked: () => boolean;
|
||||
/** 저장 뒤 — 상세를 저장본으로 다시 엶 */
|
||||
onSaved: () => void;
|
||||
/** 없으면 M01(길 `/m01/logic/equation` · 캐시 sessionStorage · [수식 저장]) */
|
||||
page?: EqPage;
|
||||
}
|
||||
|
||||
/** 식에 처음 쓴 새 기호 — 연결을 고르면 고침 `연결` + 그 식 고침을 한 벌로 보냄 */
|
||||
interface PendingLink {
|
||||
line: EqLine;
|
||||
text: string;
|
||||
symbols: string[];
|
||||
rows: Map<string, Draft>;
|
||||
apply: HTMLElement;
|
||||
}
|
||||
|
||||
export function createLabEquation(opt: LabEquationOptions) {
|
||||
const bar = el("div", { className: "m01-eq__bar" });
|
||||
const optionsBar = el("div", { className: "m01-eq__options", attrs: { "data-eq-options": "" } });
|
||||
const notice = el("div", { className: "m01-eq__notice" });
|
||||
const body = el("div", { className: "m01-eq__body" });
|
||||
const root = el("div", {
|
||||
className: "m01-eq",
|
||||
attrs: { "data-lab-equation": "" },
|
||||
children: [bar, optionsBar, notice, body],
|
||||
});
|
||||
let key = "";
|
||||
let version = "";
|
||||
let row: LogicRow | null = null;
|
||||
let rowNow: LogicRow | null = null;
|
||||
let eq: EqTree | null = null;
|
||||
let edits: EqEdit[] = [];
|
||||
let inputs: Record<string, string> = {};
|
||||
let before: Record<string, unknown> | null = null;
|
||||
let why = "";
|
||||
let editingLine: string | null = null;
|
||||
let defEditing: string | null = null;
|
||||
let pending: PendingLink | null = null;
|
||||
let defsBox: HTMLElement = el("div");
|
||||
let ctl: AbortController | null = null;
|
||||
let timer = 0;
|
||||
let paintedBlocked = false;
|
||||
const open = new Set<string>();
|
||||
const drafts = new Map<string, string>();
|
||||
const canEdit = (): boolean => opt.admin && !opt.blocked();
|
||||
const page = opt.page;
|
||||
const vals = (): Record<string, string> => page?.values() ?? inputs;
|
||||
const ask = <T>(
|
||||
suffix: "" | "/preview" | "/links",
|
||||
body: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
) =>
|
||||
page ? page.ask<T>(suffix, body, signal) : post<T>(`/logic/equation${suffix}`, body, signal);
|
||||
|
||||
const body0 = (): Record<string, unknown> => ({ key, inputs: inputsOf(vals()), 고침: edits });
|
||||
const persist = (): void => {
|
||||
if (page) return page.keep(edits);
|
||||
const all = loadCache();
|
||||
if (edits.length) all[key] = edits;
|
||||
else delete all[key];
|
||||
try {
|
||||
sessionStorage.setItem(CACHE, JSON.stringify(all));
|
||||
} catch {
|
||||
/* 캐시가 막혀도 화면 안 고침은 남음 */
|
||||
}
|
||||
};
|
||||
|
||||
/** 서버 셈 — 고침 목록이 있으면 미리 셈 · 없으면 읽기 · 옛 요청 끊음 */
|
||||
async function refresh(): Promise<void> {
|
||||
ctl?.abort();
|
||||
const mine = (ctl = new AbortController());
|
||||
try {
|
||||
const got = await ask<Read>(edits.length ? "/preview" : "", body0(), mine.signal);
|
||||
if (mine !== ctl) return;
|
||||
if (got.되나 === false || got.ok === false) {
|
||||
why = (got.까닭 ?? [got.reason ?? ""]).join(" · ");
|
||||
} else {
|
||||
why = "";
|
||||
eq = got.수식 ?? null;
|
||||
rowNow = got.줄 ?? null;
|
||||
before = edits.length ? (got.전 ?? null) : null;
|
||||
}
|
||||
} catch (error) {
|
||||
if (mine.signal.aborted) return;
|
||||
why = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
paint();
|
||||
}
|
||||
|
||||
/** 고침 하나를 더해 보고 되면 굳힘 — 안 되면 까닭 목록 */
|
||||
async function commit(list: EqEdit[]): Promise<string[]> {
|
||||
const got = await ask<Read>("/preview", {
|
||||
...body0(),
|
||||
고침: [...edits, ...list],
|
||||
}).catch((error: unknown): Read => ({
|
||||
되나: false,
|
||||
까닭: [error instanceof Error ? error.message : String(error)],
|
||||
}));
|
||||
if (!got.되나) return got.까닭 ?? [];
|
||||
edits = [...edits, ...list];
|
||||
persist();
|
||||
ctl?.abort();
|
||||
why = "";
|
||||
eq = got.수식 ?? eq;
|
||||
rowNow = got.줄 ?? rowNow;
|
||||
before = got.전 ?? before;
|
||||
paint();
|
||||
drawOptions();
|
||||
return [];
|
||||
}
|
||||
|
||||
const askAgain = (): void => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void refresh(), 150);
|
||||
};
|
||||
|
||||
// ── 옵션 가로 ──────────────────────────────────────────────────────
|
||||
function optionNode(spec: NonNullable<LogicRow["입력"]>[number], auto: unknown): HTMLElement {
|
||||
const item: ItemInput = {
|
||||
이름: spec.이름,
|
||||
단위: spec.단위,
|
||||
고르기: spec.고르기,
|
||||
범위: spec.범위,
|
||||
자동값: auto,
|
||||
};
|
||||
const label = createText(spec.이름 + (spec.단위 ? ` (${spec.단위})` : ""), "muted", 12);
|
||||
const set = (v: string | number | null): void => {
|
||||
if (page) return page.setValue(spec.이름, v === null || v === "" ? null : String(v));
|
||||
if (v === null || v === "") delete inputs[spec.이름];
|
||||
else inputs[spec.이름] = String(v);
|
||||
askAgain();
|
||||
};
|
||||
let control: HTMLElement;
|
||||
if ((spec.고르기?.length ?? 0) > BIG) {
|
||||
// 찾기형 — 입력 + 목록(칠수록 좁혀짐)
|
||||
const box = createInputField({
|
||||
value: vals()[spec.이름] ?? "",
|
||||
compact: true,
|
||||
size: 24,
|
||||
placeholder: auto === null || auto === undefined ? "" : String(auto),
|
||||
});
|
||||
const list = el("datalist", {
|
||||
attrs: { id: `m01-eq-list-${spec.이름}` },
|
||||
children: (spec.고르기 ?? []).map((o) => el("option", { attrs: { value: String(o) } })),
|
||||
});
|
||||
box.input.setAttribute("list", list.id);
|
||||
box.input.title = box.input.value || box.input.placeholder;
|
||||
box.input.addEventListener("change", () => {
|
||||
box.input.title = box.input.value;
|
||||
set(box.input.value.trim());
|
||||
});
|
||||
control = el("div", { children: [box.root, list] });
|
||||
} else {
|
||||
const made = createValueControl(item, vals()[spec.이름] ?? null, (v) => set(v));
|
||||
const tip = (): void => {
|
||||
const c = made.control;
|
||||
c.title = c instanceof HTMLSelectElement ? (c.selectedOptions[0]?.text ?? "") : c.value;
|
||||
};
|
||||
made.control.addEventListener("change", tip);
|
||||
made.control.addEventListener("input", tip);
|
||||
tip();
|
||||
control = el("div", { children: [made.control, made.error] });
|
||||
}
|
||||
control.classList.add("m01-eq__ctl");
|
||||
const cell = el("div", { className: "m01-eq__opt", children: [label, control] });
|
||||
cell.dataset.eqOption = spec.이름;
|
||||
return cell;
|
||||
}
|
||||
|
||||
function drawOptions(): void {
|
||||
const specs = (rowNow ?? row)?.입력 ?? [];
|
||||
const table = new Map((eq?.변수표 ?? []).map((v) => [v.열쇠, v]));
|
||||
optionsBar.replaceChildren(
|
||||
...(specs.length
|
||||
? [
|
||||
createToolbar({
|
||||
items: specs.map((s) => optionNode(s, table.get(s.이름)?.기본값 ?? null)),
|
||||
size: 24,
|
||||
label: t("Options"),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 바로 고침 · 연결 지정 ──────────────────────────────────────────
|
||||
const total = (sums: Record<string, unknown>): string => ("계" in sums ? "계" : "결과");
|
||||
|
||||
const defsHost: DefsHost = {
|
||||
commit,
|
||||
links: (body) => ask("/links", body),
|
||||
onDropped: (k) => {
|
||||
if (page) page.setValue(k, null);
|
||||
else {
|
||||
delete inputs[k];
|
||||
drawOptions();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function buildDefs(): HTMLElement {
|
||||
const edit = canEdit();
|
||||
const mids = (eq?.변수표 ?? []).filter((v) => v.갈래 === "중간").map((v) => v.열쇠);
|
||||
return createEqDefs(eq?.정의 ?? [], {
|
||||
editing: defEditing,
|
||||
onEdit: edit
|
||||
? (d) => {
|
||||
defEditing = defEditing === defSign(d) ? null : defSign(d);
|
||||
drawDefs();
|
||||
}
|
||||
: undefined,
|
||||
editor: (d) =>
|
||||
renameEditor(d, defsHost, () => {
|
||||
defEditing = null;
|
||||
drawDefs();
|
||||
}),
|
||||
drafts: pending ? [...[...pending.rows.values()].map((d) => d.root), pending.apply] : [],
|
||||
head: edit
|
||||
? [
|
||||
createButton({
|
||||
label: t("Add"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
iconStart: "plus",
|
||||
onClick: () => openAddDialog({ mids, commit }),
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
function drawDefs(): void {
|
||||
const next = buildDefs();
|
||||
defsBox.replaceWith(next);
|
||||
defsBox = next;
|
||||
}
|
||||
|
||||
/** 식 검사가 「모르는 기호」 를 알려 오면 정의 영역에 그 기호 줄을 세움(같은 기호들이면 입력한 것을 지키며 글만 갱신) */
|
||||
function setPending(line: EqLine, text: string, miss: string[]): void {
|
||||
if (!miss.length) {
|
||||
if (!pending) return;
|
||||
pending = null;
|
||||
return drawDefs();
|
||||
}
|
||||
if (pending && pending.symbols.join("\u0000") === miss.join("\u0000")) {
|
||||
[pending.line, pending.text] = [line, text];
|
||||
return;
|
||||
}
|
||||
const rows = new Map(miss.map((m) => [m, draftRow(m, defsHost)] as const));
|
||||
const apply = el("div", {
|
||||
className: "m01-eq__def-actions",
|
||||
children: [createButton({ label: t("Apply"), size: 24, onClick: () => void applyPending() })],
|
||||
});
|
||||
pending = { line, text, symbols: miss, rows, apply };
|
||||
drawDefs();
|
||||
defsBox.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
async function applyPending(): Promise<void> {
|
||||
const p = pending;
|
||||
if (!p) return;
|
||||
const list: EqEdit[] = [];
|
||||
let missing = false;
|
||||
for (const d of p.rows.values()) {
|
||||
const made = d.read();
|
||||
d.say(typeof made === "string" ? made : "");
|
||||
if (typeof made === "string") missing = true;
|
||||
else list.push(made);
|
||||
}
|
||||
if (missing) return;
|
||||
const bad = await commit([...list, formulaEdit(p.line, p.text)]);
|
||||
if (bad.length) return [...p.rows.values()].forEach((d) => d.say(bad.join(" · ")));
|
||||
drafts.delete(lineId(p.line));
|
||||
[pending, editingLine] = [null, null];
|
||||
paint();
|
||||
}
|
||||
|
||||
const check =
|
||||
(line: EqLine) =>
|
||||
async (text: string, signal: AbortSignal): Promise<FormulaVerdict> => {
|
||||
const got = await ask<Read>(
|
||||
"/preview",
|
||||
{ ...body0(), 고침: [...edits, formulaEdit(line, text)] },
|
||||
signal,
|
||||
);
|
||||
const miss = got.되나 ? [] : unknownOf(got.까닭 ?? []);
|
||||
setPending(line, text, miss);
|
||||
const now = got.후 ? eqText(got.후[total(got.후)]) : "";
|
||||
return {
|
||||
되나: !!got.되나,
|
||||
까닭: miss.length
|
||||
? [`모르는 기호 ${miss.join(" · ")} — 오른쪽 변수 정의에서 연결을 지정`]
|
||||
: (got.까닭 ?? []),
|
||||
알림: got.되나 && now ? [`${t("Total")} ${now}`] : [],
|
||||
식: got.되나 ? text : null,
|
||||
};
|
||||
};
|
||||
|
||||
/** 수식 영역의 식 칸 자리에 들어가는 식 칸 — 바꾸면 미리 셈 · 되면 고침 목록에 굳힘 */
|
||||
function editor(line: EqLine, id: string): HTMLElement {
|
||||
const shown = line.입력글 ?? line.식;
|
||||
const close = (): void => {
|
||||
[editingLine, pending] = [null, null];
|
||||
paint();
|
||||
};
|
||||
const cell = createFormulaCell({
|
||||
key: id,
|
||||
spot: id,
|
||||
shown,
|
||||
origin: shown,
|
||||
drafts,
|
||||
check: check(line),
|
||||
put: (text) => {
|
||||
if (text === null) return pending ? undefined : close();
|
||||
void commit([formulaEdit(line, text)]).then((bad) => {
|
||||
if (bad.length) showToast(bad.join(" · "), "error");
|
||||
else close();
|
||||
});
|
||||
},
|
||||
className: "m01-eq__fx",
|
||||
});
|
||||
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 drawBar(): void {
|
||||
const sums = eq?.합계 ?? {};
|
||||
const mark = total(sums);
|
||||
const now = sums[mark];
|
||||
const old = before?.[mark];
|
||||
const diff = Number(now) - Number(old);
|
||||
const items: (HTMLElement | "divider" | "spacer")[] = [
|
||||
createText(`${t("Now")} ${eqText(now) || "—"}`, "muted", 14),
|
||||
];
|
||||
if (before) {
|
||||
items.push(createText(`${t("Before")} ${eqText(old) || "—"}`, "muted", 14));
|
||||
if (Number.isFinite(diff) && diff)
|
||||
items.push(createBadge(`${diff > 0 ? "+" : "−"}${eqText(Math.abs(diff))}`, "warning"));
|
||||
}
|
||||
if (canEdit()) {
|
||||
items.push("spacer");
|
||||
if (edits.length) {
|
||||
items.push(
|
||||
createBadge(t(page ? "EditsPage" : "Edits", edits.length), "warning"),
|
||||
createButton({
|
||||
label: t(page ? "DiscardPage" : "Discard"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () => void discard(),
|
||||
}),
|
||||
...(page
|
||||
? []
|
||||
: [createButton({ label: t("Save"), size: 24, onClick: () => void save() })]),
|
||||
);
|
||||
} else items.push(createText(t("Hint"), "muted", 12));
|
||||
}
|
||||
bar.replaceChildren(createToolbar({ items, size: 28, label: t("Title") }));
|
||||
}
|
||||
|
||||
function paint(): void {
|
||||
paintedBlocked = opt.blocked();
|
||||
drawBar();
|
||||
notice.replaceChildren(
|
||||
...(paintedBlocked && opt.admin
|
||||
? [createBanner({ tone: "warning", text: t("Blocked") })]
|
||||
: []),
|
||||
...(why ? [createBanner({ tone: "danger", text: why })] : []),
|
||||
);
|
||||
if (!eq) {
|
||||
body.replaceChildren(createText(t("Wait"), "muted"));
|
||||
return;
|
||||
}
|
||||
const edit = canEdit();
|
||||
const keys = eq.줄.filter((l) => l.갈래 === "호표").map((l) => l.열쇠);
|
||||
const sum = createEqSum(eq, open, page?.sum, keys);
|
||||
const left = el("div", {
|
||||
className: "m01-eq__left",
|
||||
children: [
|
||||
createEqChain(eq.줄, {
|
||||
path: "",
|
||||
open,
|
||||
resultUnit: eq.결과단위,
|
||||
onVar: edit ? undefined : showDef, // 고칠 수 있으면 식 어디를 눌러도 그 자리 고침(변수 칩 포함)
|
||||
editing: editingLine,
|
||||
onEdit: edit
|
||||
? (_line, id) => {
|
||||
editingLine = id;
|
||||
paint();
|
||||
}
|
||||
: undefined,
|
||||
editor: edit ? editor : undefined,
|
||||
after: (line) => (line.갈래 === "호표" ? (sum?.inline.get(line.열쇠) ?? null) : null),
|
||||
}),
|
||||
...(sum ? [sum.tail] : []), // 799-1 · 800-4 묶음 소계 · 끝수 · 합계
|
||||
],
|
||||
});
|
||||
defsBox = buildDefs();
|
||||
body.replaceChildren(el("div", { className: "m01-eq__split", children: [left, defsBox] }));
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!canEdit() || !edits.length) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const done = await saveRevised((ok) =>
|
||||
post("/logic/equation/save", { ...body0(), version, 개정확인: ok }),
|
||||
);
|
||||
if (!done) return; // 개정 확인 취소 = 고침 그대로
|
||||
[edits, editingLine] = [[], null];
|
||||
persist();
|
||||
showToast(t("Saved"), "success");
|
||||
await refresh();
|
||||
drawOptions();
|
||||
opt.onSaved();
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof ApiError ? (error.detail as { 까닭?: string[] } | null) : null;
|
||||
showToast(
|
||||
detail?.까닭?.join(" · ") ?? (error instanceof Error ? error.message : String(error)),
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function discard(): Promise<void> {
|
||||
edits = [];
|
||||
[editingLine, defEditing, pending] = [null, null, null];
|
||||
drafts.clear();
|
||||
persist();
|
||||
await refresh();
|
||||
drawOptions();
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
/** 상세가 그릴 때마다 — 로직이 바뀔 때만 서버를 부름 */
|
||||
draw: (nextKey: string, nextVersion: string, nextRow: LogicRow): void => {
|
||||
const moved = nextVersion !== version; // 판 · 페이지 문서 · 옵션 바뀜 = 다시 셈
|
||||
version = nextVersion;
|
||||
row = nextRow;
|
||||
if (nextKey !== key) {
|
||||
[key, eq, rowNow, before, why, editingLine] = [nextKey, null, null, null, "", null];
|
||||
[defEditing, pending] = [null, null];
|
||||
inputs = {};
|
||||
edits = page ? page.edits() : (loadCache()[nextKey] ?? []);
|
||||
open.clear();
|
||||
drafts.clear();
|
||||
paint();
|
||||
drawOptions();
|
||||
void refresh().then(drawOptions);
|
||||
} else if (moved) {
|
||||
if (page) edits = page.edits();
|
||||
void refresh().then(drawOptions);
|
||||
} else if (opt.blocked() !== paintedBlocked) paint();
|
||||
},
|
||||
dirty: (): boolean => edits.length > 0,
|
||||
/** 나가기 전 — 저장 안 한 고침이 있으면 버림 물음 · 버리면 true */
|
||||
canLeave: async (): Promise<boolean> => {
|
||||
if (!edits.length) return true;
|
||||
if (!(await showConfirmDialog(t("Leave", edits.length), t("Leave_Ok")))) return false;
|
||||
await discard();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_EquationDefs.ts
|
||||
* 수식 탭 변수 정의 영역 속 고침(PLAN 800-3 · 요구 660-5) — 796-2a 변수 창을 정의 영역 안으로 옮겨 다시 씀.
|
||||
* ① 이름 · 기호 · 첨자 고침 · 지움 = 정의 줄 아래 칸(`renameEditor`) — 쓰는 변수 지움은 서버가 막고 쓰는 곳을 알림
|
||||
* ② 새 기호 연결 지정 = 식에 처음 쓴 기호마다 줄(`draftRow`) — 국문 이름 + 연결(입력 · 표 찾기 · 요소 · 로직 · 상수) → 고침 `연결` 한 건
|
||||
* 표 · 요소 · 로직 후보 = 서버 `…/equation/links`(읽기만) · 연결 글(엔진 식)은 서버가 지음 — 화면은 고른 것만 보냄
|
||||
* 기호 글 쪼개기(`symbolParts`) = 서버 키보드 식 변수 글(`master_equation_edit._TOKEN` 기호_아래^위)과 같은 꼴 — 시험 `test_800_2_equation_layout`
|
||||
* 값 · 단위는 고치지 않음(요구 660-6) · 계산 · 검사는 서버 한 벌
|
||||
* ========================================================================== */
|
||||
|
||||
import type { EqDef } from "@ui/ui_template_equation";
|
||||
import { createBanner } from "@ui/ui_template_banner";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { el } from "@ui/ui_template_elements_base";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { varEdits, type EqEdit } from "./logic/M01_Logic_Equation_Model";
|
||||
|
||||
export interface Candidate {
|
||||
키: string;
|
||||
이름: string;
|
||||
규격?: string;
|
||||
조건?: string[];
|
||||
칸?: string[];
|
||||
입력?: string[];
|
||||
}
|
||||
|
||||
export interface DefsHost {
|
||||
commit: (edits: EqEdit[]) => Promise<string[]>;
|
||||
links: (ask: Record<string, unknown>) => Promise<{ 후보: Candidate[]; 더있음: boolean }>;
|
||||
onDropped: (key: string) => void;
|
||||
}
|
||||
|
||||
/** 새 기호 글 → 열쇠 · 기호 · 아래 · 위(`Q_d^2` → 열쇠 `Q_d2` · 기호 Q · 아래 d · 위 2) */
|
||||
export function symbolParts(token: string): Pick<EqDef, "열쇠" | "기호" | "아래" | "위"> {
|
||||
const [main = "", up = ""] = token.split("^");
|
||||
const [sign = "", ...low] = main.split("_");
|
||||
return { 열쇠: token.replace(/[^\p{L}\p{N}_]/gu, ""), 기호: sign, 아래: low.join("_"), 위: up };
|
||||
}
|
||||
|
||||
const grid = (...kids: HTMLElement[]): HTMLElement =>
|
||||
el("div", { className: "m01-eq__form", children: kids });
|
||||
|
||||
const field = (label: string, value = ""): ReturnType<typeof createInputField> =>
|
||||
createInputField({ label, value, size: 24 });
|
||||
|
||||
const reasonBox = (): { root: HTMLElement; say: (list: string[]) => void } => {
|
||||
const root = el("div", { className: "m01-eq__why" });
|
||||
return {
|
||||
root,
|
||||
say: (list) =>
|
||||
root.replaceChildren(
|
||||
...(list.length
|
||||
? [createBanner({ tone: "danger", title: "안 됨", text: list.join(" · ") })]
|
||||
: []),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/** 정의 줄 고침 칸 — 이름 · 기호 · 위 · 아래 · [적용] · [변수 지움] */
|
||||
export function renameEditor(d: EqDef, host: DefsHost, close: () => void): HTMLElement {
|
||||
const name = field("이름", d.이름);
|
||||
const sign = field("기호", d.기호);
|
||||
const up = field("위첨자", d.위);
|
||||
const down = field("아래첨자", d.아래);
|
||||
const why = reasonBox();
|
||||
const form = (v: Pick<EqDef, "이름" | "기호" | "위" | "아래">) => ({
|
||||
열쇠: d.열쇠,
|
||||
...v,
|
||||
단위: "",
|
||||
});
|
||||
const apply = createButton({
|
||||
label: "적용",
|
||||
size: 24,
|
||||
onClick: async () => {
|
||||
const edits = varEdits(
|
||||
form(d),
|
||||
form({
|
||||
이름: name.input.value.trim(),
|
||||
기호: sign.input.value.trim(),
|
||||
위: up.input.value.trim(),
|
||||
아래: down.input.value.trim(),
|
||||
}),
|
||||
);
|
||||
const bad = edits.length ? await host.commit(edits) : [];
|
||||
why.say(bad);
|
||||
if (!bad.length) close();
|
||||
},
|
||||
});
|
||||
const drop = createButton({
|
||||
label: "변수 지움",
|
||||
variant: "danger-ghost",
|
||||
size: 24,
|
||||
onClick: async () => {
|
||||
const bad = await host.commit([{ 할일: "지움", 열쇠: d.열쇠 }]);
|
||||
why.say(bad);
|
||||
if (!bad.length) host.onDropped(d.열쇠);
|
||||
},
|
||||
});
|
||||
const shut = createButton({ label: "닫기", variant: "ghost", size: 24, onClick: close });
|
||||
const box = el("div", {
|
||||
className: "m01-eq__def-edit",
|
||||
children: [
|
||||
grid(name.root, sign.root, up.root, down.root),
|
||||
el("div", { className: "m01-eq__def-actions", children: [drop, shut, apply] }),
|
||||
why.root,
|
||||
],
|
||||
});
|
||||
box.dataset.eqDefEditor = d.기호;
|
||||
return box;
|
||||
}
|
||||
|
||||
export interface Draft {
|
||||
root: HTMLElement;
|
||||
/** 연결 지정 고침 — 아직 덜 골랐으면 까닭 글 */
|
||||
read: () => EqEdit | string;
|
||||
say: (why: string) => void;
|
||||
}
|
||||
|
||||
const KINDS = [
|
||||
{ value: "입력", text: "입력 (옵션 칸에 생김)" },
|
||||
{ value: "표", text: "표 찾기 (표 · 조건 · 칸)" },
|
||||
{ value: "요소", text: "요소 (인력 · 재료 · 기계)" },
|
||||
{ value: "로직", text: "로직 부르기" },
|
||||
{ value: "상수", text: "상수 (고정 수)" },
|
||||
];
|
||||
const CATS = [
|
||||
{ value: "인력", text: "인력" },
|
||||
{ value: "재료", text: "재료" },
|
||||
{ value: "기계", text: "기계" },
|
||||
];
|
||||
|
||||
/** 후보 찾기 — 검색 글을 칠수록 서버 후보 · 고르기 */
|
||||
function pick(
|
||||
host: DefsHost,
|
||||
ask: () => Record<string, unknown>,
|
||||
onPick: (c: Candidate | null) => void,
|
||||
label: string,
|
||||
): { root: HTMLElement; get: () => Candidate | null; refresh: () => void } {
|
||||
const box = field(label);
|
||||
const list = createSelectField({
|
||||
options: [{ value: "", text: "— 검색해서 고름 —" }],
|
||||
size: 24,
|
||||
ariaLabel: label,
|
||||
});
|
||||
let found: Candidate[] = [];
|
||||
let timer = 0;
|
||||
const get = (): Candidate | null => found[Number(list.select.value)] ?? null;
|
||||
const refresh = (): void => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(async () => {
|
||||
const got = await host.links({ ...ask(), 검색: box.input.value.trim() }).catch(() => null);
|
||||
found = got?.후보 ?? [];
|
||||
const more = got?.더있음 ? [{ value: "", text: "… 더 있음 — 검색을 더 칠 것" }] : [];
|
||||
list.setOptions(
|
||||
[
|
||||
{ value: "", text: found.length ? "— 고름 —" : "— 없음 —" },
|
||||
...found.map((c, i) => ({
|
||||
value: String(i),
|
||||
text: `${c.이름}${c.규격 ? ` · ${c.규격}` : ""}`,
|
||||
})),
|
||||
...more,
|
||||
],
|
||||
"",
|
||||
);
|
||||
onPick(null);
|
||||
}, 250);
|
||||
};
|
||||
box.input.addEventListener("input", refresh);
|
||||
list.select.addEventListener("change", () => onPick(list.select.value === "" ? null : get()));
|
||||
return {
|
||||
root: grid(box.root, list.root),
|
||||
get: () => (list.select.value === "" ? null : get()),
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
type Reader = () => EqEdit | string;
|
||||
|
||||
const inputKind = (): { nodes: HTMLElement[]; read: Reader } => {
|
||||
const base = field("기본값 (빈 칸 = 옵션에서 고름)");
|
||||
const text = (): string => base.input.value.trim();
|
||||
return {
|
||||
nodes: [base.root],
|
||||
read: () => ({ 할일: "연결", 갈래: "입력", ...(text() ? { 기본값: text() } : {}) }),
|
||||
};
|
||||
};
|
||||
|
||||
const constKind = (): { nodes: HTMLElement[]; read: Reader } => {
|
||||
const val = field("값");
|
||||
return {
|
||||
nodes: [val.root],
|
||||
read: () =>
|
||||
val.input.value.trim()
|
||||
? { 할일: "연결", 갈래: "상수", 값: val.input.value.trim() }
|
||||
: "상수 값을 적을 것",
|
||||
};
|
||||
};
|
||||
|
||||
const elementKind = (host: DefsHost): { nodes: HTMLElement[]; read: Reader } => {
|
||||
const cat = createSelectField({ label: "분류", options: CATS, value: "인력", size: 24 });
|
||||
const find = pick(
|
||||
host,
|
||||
() => ({ 갈래: "요소", 분류: cat.select.value }),
|
||||
() => undefined,
|
||||
"요소 이름",
|
||||
);
|
||||
cat.select.addEventListener("change", find.refresh);
|
||||
find.refresh();
|
||||
return {
|
||||
nodes: [cat.root, find.root],
|
||||
read: () => {
|
||||
const one = find.get();
|
||||
return one ? { 할일: "연결", 갈래: "요소", 요소: one.키 } : "요소를 고를 것";
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const logicKind = (host: DefsHost): { nodes: HTMLElement[]; read: Reader } => {
|
||||
const sub = grid();
|
||||
const args = new Map<string, ReturnType<typeof createInputField>>();
|
||||
const find = pick(
|
||||
host,
|
||||
() => ({ 갈래: "로직" }),
|
||||
(c) => {
|
||||
args.clear();
|
||||
sub.replaceChildren(
|
||||
...(c?.입력 ?? []).map((n) => {
|
||||
const f = field(`${n} = (식 · 빈 칸 = 기본)`);
|
||||
args.set(n, f);
|
||||
return f.root;
|
||||
}),
|
||||
);
|
||||
},
|
||||
"로직 이름",
|
||||
);
|
||||
return {
|
||||
nodes: [find.root, sub],
|
||||
read: () => {
|
||||
const one = find.get();
|
||||
if (!one) return "부를 로직을 고를 것";
|
||||
const 인자 = Object.fromEntries([...args].map(([n, f]) => [n, f.input.value.trim()]));
|
||||
return { 할일: "연결", 갈래: "로직", 로직: one.키, 인자 };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const tableKind = (host: DefsHost): { nodes: HTMLElement[]; read: Reader } => {
|
||||
const sub = grid();
|
||||
const conds = new Map<string, ReturnType<typeof createInputField>>();
|
||||
const cols = createSelectField({ label: "칸", options: [{ value: "", text: "—" }], size: 24 });
|
||||
const find = pick(
|
||||
host,
|
||||
() => ({ 갈래: "표" }),
|
||||
(c) => {
|
||||
conds.clear();
|
||||
cols.setOptions(
|
||||
[{ value: "", text: "— 칸 —" }, ...(c?.칸 ?? []).map((v) => ({ value: v, text: v }))],
|
||||
"",
|
||||
);
|
||||
sub.replaceChildren(
|
||||
...(c?.조건 ?? []).map((n) => {
|
||||
const f = field(`조건 ${n} = (변수 기호 · 식)`);
|
||||
conds.set(n, f);
|
||||
return f.root;
|
||||
}),
|
||||
);
|
||||
},
|
||||
"표 이름",
|
||||
);
|
||||
find.refresh();
|
||||
return {
|
||||
nodes: [find.root, cols.root, sub],
|
||||
read: () => {
|
||||
const one = find.get();
|
||||
if (!one || !cols.select.value) return "표와 칸을 고를 것";
|
||||
const 조건 = Object.fromEntries([...conds].map(([n, f]) => [n, f.input.value.trim()]));
|
||||
return { 할일: "연결", 갈래: "표", 표: one.키, 칸: cols.select.value, 조건 };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** 새 기호 줄 — 국문 이름 + 연결 고르기 · `read` 가 고침 `연결` 한 건을 줌 */
|
||||
export function draftRow(token: string, host: DefsHost): Draft {
|
||||
const parts = symbolParts(token);
|
||||
const name = field("국문 이름");
|
||||
const kind = createSelectField({ label: "연결", options: KINDS, value: "입력", size: 24 });
|
||||
const body = grid();
|
||||
const why = reasonBox();
|
||||
let read: Reader = () => "연결을 고를 것";
|
||||
const mount = (): void => {
|
||||
const made = { 입력: inputKind, 상수: constKind }[kind.select.value as "입력" | "상수"]?.();
|
||||
const picked =
|
||||
made ??
|
||||
{ 요소: elementKind, 로직: logicKind, 표: tableKind }[
|
||||
kind.select.value as "요소" | "로직" | "표"
|
||||
](host);
|
||||
body.replaceChildren(...picked.nodes);
|
||||
read = picked.read;
|
||||
};
|
||||
kind.select.addEventListener("change", mount);
|
||||
mount();
|
||||
const head = el("div", {
|
||||
className: "m01-eq__def-actions",
|
||||
children: [
|
||||
el("span", { className: "ui-text ui-text--muted", text: `새 기호 ${token} — 연결을 지정` }),
|
||||
],
|
||||
});
|
||||
const root = el("div", {
|
||||
className: "ui-eq__def m01-eq__draft",
|
||||
children: [head, grid(name.root, kind.root), body, why.root],
|
||||
});
|
||||
root.dataset.eqDraft = token;
|
||||
return {
|
||||
root,
|
||||
say: (text) => why.say(text ? [text] : []),
|
||||
read: () => {
|
||||
const made = read();
|
||||
if (typeof made === "string") return made;
|
||||
const nameText = name.input.value.trim();
|
||||
return { ...made, ...parts, ...(nameText ? { 이름: nameText } : {}) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_EquationVar.ts
|
||||
* 수식 탭 변수 창(PLAN 796-2 · 요구 657) — 변수 고침 · 변수 더함 · 변수 지움(쓰는 곳 있으면 막고 목록 보임).
|
||||
* 고침 창 = 이름 · 기호 · 위첨자 · 아래첨자 · 단위 · 값(입력 변수만 · 시험 값 · 저장 안 함) · [적용] · [변수 지움]
|
||||
* 더함 창 = 갈래(입력 · 중간) · 열쇠 · 이름 · 기호 · 첨자 · 단위 · 기본값(입력) | 식 · 자리(중간)
|
||||
* 적용 = 부르는 쪽 `commit` 이 서버 미리 셈(`/logic/equation/preview`)을 거쳐 되면 [] · 안 되면 까닭 — 까닭은 창 안 띠에 그대로
|
||||
* 부품 = 모달 · 입력 · 고르기 · 단추 · 띠 알림 · 열쇠 · 식 검사 · 이름 겹침은 서버 몫
|
||||
* ========================================================================== */
|
||||
|
||||
import type { EqVarRow } from "@ui/ui_template_equation";
|
||||
import { createBanner } from "@ui/ui_template_banner";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { el } from "@ui/ui_template_elements_base";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { addEdit, varEdits, type EqEdit, type VarForm } from "./logic/M01_Logic_Equation_Model";
|
||||
|
||||
const TEXT = {
|
||||
VarTitle: ["변수 고침", "Edit variable"],
|
||||
AddTitle: ["변수 더함", "Add variable"],
|
||||
Close: ["닫기", "Close"],
|
||||
Apply: ["적용", "Apply"],
|
||||
Drop: ["변수 지움", "Delete variable"],
|
||||
Key: ["열쇠 (식이 부르는 이름 · 안 바뀜)", "Key (used by formulas · fixed)"],
|
||||
Name: ["이름", "Name"],
|
||||
Sign: ["기호", "Symbol"],
|
||||
Up: ["위첨자", "Superscript"],
|
||||
Down: ["아래첨자", "Subscript"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Value: ["값 (시험 값 · 저장 안 함)", "Value (trial · not saved)"],
|
||||
Digits: ["보이는 자리 (0~6 · 빈 칸 = 전체 자리)", "Digits (0-6 · blank = full)"],
|
||||
Kind: ["갈래", "Kind"],
|
||||
Input: ["입력", "Input"],
|
||||
Mid: ["중간", "Intermediate"],
|
||||
Default: ["기본값", "Default"],
|
||||
Formula: ["식", "Formula"],
|
||||
After: ["자리", "Place"],
|
||||
End: ["맨 끝", "End"],
|
||||
Top: ["맨 위", "Top"],
|
||||
Why: ["안 됨", "Not applied"],
|
||||
Hint: ["이름 · 기호 · 첨자를 바꾸면 쓰는 모든 식에 함께 보임", "Renames show in every formula"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
|
||||
|
||||
export type Commit = (edits: EqEdit[]) => Promise<string[]>;
|
||||
|
||||
const grid = (...kids: HTMLElement[]): HTMLElement =>
|
||||
el("div", { className: "m01-eq__form", children: kids });
|
||||
|
||||
/** 까닭 띠 — 있으면 보임 · 없으면 비움 */
|
||||
function reasonBox(): { root: HTMLElement; set: (why: string[]) => void } {
|
||||
const root = el("div", { className: "m01-eq__why" });
|
||||
return {
|
||||
root,
|
||||
set: (why) =>
|
||||
root.replaceChildren(
|
||||
...(why.length
|
||||
? [createBanner({ tone: "danger", title: t("Why"), text: why.join(" · ") })]
|
||||
: []),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export interface VarDialog {
|
||||
row: EqVarRow;
|
||||
/** 입력 변수 시험 값(옵션 칸 글) */
|
||||
value: string;
|
||||
commit: Commit;
|
||||
/** 시험 값을 바꿈(저장 안 함) */
|
||||
onValue: (value: string) => void;
|
||||
/** 변수를 지운 뒤 — 시험 값 비움 */
|
||||
onDropped: (key: string) => void;
|
||||
}
|
||||
|
||||
export function openVarDialog(o: VarDialog): void {
|
||||
const v = o.row;
|
||||
const field = (label: string, value: string): ReturnType<typeof createInputField> =>
|
||||
createInputField({ label, value, size: 28 });
|
||||
const name = field(t("Name"), v.이름);
|
||||
const sign = field(t("Sign"), v.기호);
|
||||
const up = field(t("Up"), v.위);
|
||||
const down = field(t("Down"), v.아래);
|
||||
const unit = field(t("Unit"), v.단위);
|
||||
const val = field(t("Value"), o.value);
|
||||
const shown = v.표시 === undefined ? "" : String(v.표시);
|
||||
const digits = field(t("Digits"), shown);
|
||||
const why = reasonBox();
|
||||
openModal({
|
||||
title: `${t("VarTitle")} · ${v.열쇠}`,
|
||||
closeLabel: t("Close"),
|
||||
width: "40rem",
|
||||
mount: (body, close, actions) => {
|
||||
body.append(
|
||||
el("p", { className: "ui-text ui-text--muted", text: t("Hint") }),
|
||||
grid(
|
||||
name.root,
|
||||
sign.root,
|
||||
up.root,
|
||||
down.root,
|
||||
unit.root,
|
||||
...(v.갈래 === "입력" ? [val.root] : [digits.root]),
|
||||
),
|
||||
why.root,
|
||||
);
|
||||
actions.append(
|
||||
createButton({
|
||||
label: t("Drop"),
|
||||
variant: "danger-ghost",
|
||||
onClick: async () => {
|
||||
const bad = await o.commit([{ 할일: "지움", 열쇠: v.열쇠 }]);
|
||||
why.set(bad);
|
||||
if (bad.length) return;
|
||||
o.onDropped(v.열쇠);
|
||||
close();
|
||||
},
|
||||
}),
|
||||
createButton({
|
||||
label: t("Apply"),
|
||||
onClick: async () => {
|
||||
const now: VarForm = {
|
||||
열쇠: v.열쇠,
|
||||
이름: name.input.value.trim(),
|
||||
기호: sign.input.value.trim(),
|
||||
위: up.input.value.trim(),
|
||||
아래: down.input.value.trim(),
|
||||
단위: unit.input.value.trim(),
|
||||
표시: digits.input.value.trim(),
|
||||
};
|
||||
const edits = varEdits(
|
||||
{
|
||||
열쇠: v.열쇠,
|
||||
이름: v.이름,
|
||||
기호: v.기호,
|
||||
위: v.위,
|
||||
아래: v.아래,
|
||||
단위: v.단위,
|
||||
표시: shown,
|
||||
},
|
||||
now,
|
||||
);
|
||||
const bad = edits.length ? await o.commit(edits) : [];
|
||||
why.set(bad);
|
||||
if (bad.length) return;
|
||||
if (v.갈래 === "입력" && val.input.value.trim() !== o.value)
|
||||
o.onValue(val.input.value.trim());
|
||||
close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface AddDialog {
|
||||
/** 중간 줄 열쇠 차례(자리 고르기) */
|
||||
mids: string[];
|
||||
commit: Commit;
|
||||
}
|
||||
|
||||
export function openAddDialog(o: AddDialog): void {
|
||||
const field = (label: string, value = ""): ReturnType<typeof createInputField> =>
|
||||
createInputField({ label, value, size: 28 });
|
||||
const kind = createSelectField({
|
||||
label: t("Kind"),
|
||||
options: [
|
||||
{ value: "입력", text: t("Input") },
|
||||
{ value: "중간", text: t("Mid") },
|
||||
],
|
||||
value: "입력",
|
||||
size: 28,
|
||||
});
|
||||
const key = field(t("Key"));
|
||||
const name = field(t("Name"));
|
||||
const sign = field(t("Sign"));
|
||||
const up = field(t("Up"));
|
||||
const down = field(t("Down"));
|
||||
const unit = field(t("Unit"));
|
||||
const dflt = field(t("Default"));
|
||||
const expr = field(t("Formula"));
|
||||
const place = createSelectField({
|
||||
label: t("After"),
|
||||
options: [
|
||||
{ value: "", text: t("End") },
|
||||
{ value: "\u0000top", text: t("Top") },
|
||||
...o.mids.map((m) => ({ value: m, text: m })),
|
||||
],
|
||||
value: "",
|
||||
size: 28,
|
||||
});
|
||||
const why = reasonBox();
|
||||
const sync = (): void => {
|
||||
const mid = kind.select.value === "중간";
|
||||
dflt.root.hidden = mid;
|
||||
expr.root.hidden = place.root.hidden = !mid;
|
||||
};
|
||||
kind.select.addEventListener("change", sync);
|
||||
sync();
|
||||
openModal({
|
||||
title: t("AddTitle"),
|
||||
closeLabel: t("Close"),
|
||||
width: "40rem",
|
||||
mount: (body, close, actions) => {
|
||||
body.append(
|
||||
grid(
|
||||
kind.root,
|
||||
key.root,
|
||||
name.root,
|
||||
sign.root,
|
||||
up.root,
|
||||
down.root,
|
||||
unit.root,
|
||||
dflt.root,
|
||||
expr.root,
|
||||
place.root,
|
||||
),
|
||||
why.root,
|
||||
);
|
||||
actions.append(
|
||||
createButton({
|
||||
label: t("Apply"),
|
||||
onClick: async () => {
|
||||
const mid = kind.select.value === "중간";
|
||||
const at = place.select.value;
|
||||
const edit = addEdit(
|
||||
{
|
||||
열쇠: key.input.value,
|
||||
이름: name.input.value,
|
||||
기호: sign.input.value,
|
||||
위: up.input.value,
|
||||
아래: down.input.value,
|
||||
단위: unit.input.value,
|
||||
갈래: mid ? "중간" : "입력",
|
||||
기본값: dflt.input.value.trim(),
|
||||
식: expr.input.value.trim(),
|
||||
},
|
||||
at === "\u0000top" ? "" : at || undefined,
|
||||
);
|
||||
const bad = await o.commit([edit]);
|
||||
why.set(bad);
|
||||
if (!bad.length) close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -178,7 +178,6 @@
|
||||
}
|
||||
|
||||
/* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김 */
|
||||
.m01-logic__editor[data-view="equation"] [data-block]:not([data-block="equation"]),
|
||||
.m01-logic__editor[data-view="stack"] [data-block]:not([data-block="stack"]),
|
||||
.m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]) {
|
||||
display: none;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_View.ts
|
||||
* 「단가산출 로직」 상세 보기 고르기 — 머리줄 [신규 로직] 왼쪽 「분할 고르기 | 세로 나눔선」
|
||||
* 보기 셋 = 수식(기본 · 796-2) · 줄 쌓기(804-2 · `A00_Common/line_stack_screen.ts`) · 단가산출 상세 — 고른 덩이만 보임(시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식은 799-2 에 걷음)
|
||||
* 보기 둘 = 수식(기본 · 줄 쌓기 화면 `A00_Common/line_stack_screen.ts` · 804-6 옛 수식 문서 탭 걷음) · 단가산출 상세 — 고른 덩이만 보임
|
||||
* 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지
|
||||
* 셈 구동은 화면 칸 없이 `_Logic_Drive.ts` · 기본정보는 이름표 없음(늘 맨 위)
|
||||
* 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 수식)
|
||||
@@ -9,10 +9,9 @@
|
||||
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { createToolbar } from "@ui/ui_template_toolbar";
|
||||
import { lt } from "../A00_Common/line_stack_text";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
|
||||
export type LabView = "equation" | "stack" | "ho";
|
||||
export type LabView = "stack" | "ho";
|
||||
|
||||
/** 덩이에 보기 이름표를 붙여 돌려줌 */
|
||||
export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
|
||||
@@ -25,14 +24,13 @@ export function createViewPicker(
|
||||
host: HTMLElement,
|
||||
onChange?: (v: LabView) => void,
|
||||
): { root: HTMLElement } {
|
||||
host.dataset.view = "equation";
|
||||
host.dataset.view = "stack";
|
||||
const segment = createSegment({
|
||||
options: [
|
||||
{ value: "equation", label: tl("View_Equation") },
|
||||
{ value: "stack", label: lt("Tab") },
|
||||
{ value: "stack", label: tl("View_Equation") },
|
||||
{ value: "ho", label: tl("Ho_Title") },
|
||||
],
|
||||
value: "equation",
|
||||
value: "stack",
|
||||
size: 32,
|
||||
ariaLabel: tl("View_Label"),
|
||||
onChange: (v) => {
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData/logic/M01_Logic_Equation_Model.ts
|
||||
* 수식 탭 계산(796-2 · 화면 · fetch 없음) — 줄 → 고침 자리 · 식 칸 아이콘 끼움 · 옵션 값 꼴 · 변수 고침 요청.
|
||||
* 서버 고침 꼴 = `master_equation_edit.py` 머리(할일 식 · 더함 · 지움 · 변수 · 표시) — 여기서는 요청 목록만 만듦
|
||||
* 셈 · 검사는 서버 한 벌(③ 서버 단독) — 브라우저는 모양만
|
||||
* ========================================================================== */
|
||||
|
||||
import type { EqLine } from "@ui/ui_template_equation";
|
||||
|
||||
export interface EqEdit {
|
||||
할일: "식" | "더함" | "지움" | "변수" | "표시" | "연결";
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 식 줄의 고침 자리(`중간:<열쇠>` · `결과` · `호표:<n>` · `덧줄:<n>`) */
|
||||
export const spotOf = (line: EqLine): string =>
|
||||
line.갈래 === "호표" || line.갈래 === "덧줄"
|
||||
? `${line.갈래}:${line.차례 ?? 0}`
|
||||
: line.갈래 === "결과"
|
||||
? "결과"
|
||||
: `중간:${line.열쇠}`;
|
||||
|
||||
export const formulaEdit = (line: EqLine, text: string): EqEdit => ({
|
||||
할일: "식",
|
||||
자리: spotOf(line),
|
||||
식: text,
|
||||
});
|
||||
|
||||
/** 아이콘 줄 — 식 칸 글에 끼우는 꼴 */
|
||||
export type InsertKind = "frac" | "paren" | "sub" | "sup" | "percent";
|
||||
|
||||
/** 선택 영역 `[from, to)` 를 아이콘 꼴로 바꾼 글 · 커서 자리 */
|
||||
export function insertInto(
|
||||
kind: InsertKind,
|
||||
text: string,
|
||||
from: number,
|
||||
to: number,
|
||||
): { text: string; caret: number } {
|
||||
const head = text.slice(0, from);
|
||||
const pick = text.slice(from, to);
|
||||
const tail = text.slice(to);
|
||||
const put = (mid: string, caret: number): { text: string; caret: number } => ({
|
||||
text: head + mid + tail,
|
||||
caret: head.length + caret,
|
||||
});
|
||||
switch (kind) {
|
||||
case "frac": // 분수 = (위) / (아래) — 고른 글이 위 · 커서는 아래 괄호 안
|
||||
return pick ? put(`(${pick}) / ()`, pick.length + 6) : put("() / ()", 1);
|
||||
case "paren":
|
||||
return pick ? put(`(${pick})`, pick.length + 2) : put("()", 1);
|
||||
case "sub":
|
||||
return put(`${pick}_`, pick.length + 1);
|
||||
case "sup":
|
||||
return put(`${pick}^`, pick.length + 1);
|
||||
case "percent":
|
||||
return put(`${pick} / 100`, pick.length + 6);
|
||||
}
|
||||
}
|
||||
|
||||
/** 옵션 값 — 수 꼴 글은 수로(엔진 입력 꼴) · 빈 값은 안 보냄(자동값) */
|
||||
export function inputsOf(values: Record<string, string>): Record<string, number | string> {
|
||||
const out: Record<string, number | string> = {};
|
||||
for (const [name, raw] of Object.entries(values)) {
|
||||
if (raw === "") continue;
|
||||
out[name] = /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface VarForm {
|
||||
열쇠: string;
|
||||
이름: string;
|
||||
기호: string;
|
||||
위: string;
|
||||
아래: string;
|
||||
단위: string;
|
||||
갈래?: "입력" | "중간";
|
||||
설명?: string;
|
||||
기본값?: string;
|
||||
식?: string;
|
||||
/** 보이는 자리 글(중간 줄 · 빈 글 = 전체) */
|
||||
표시?: string;
|
||||
}
|
||||
|
||||
/** 변수 고침 창 → 요청 목록(바꾼 칸만) — `보이는 자리`(중간 줄) = 0~6 · 빈 칸 = 전체 자리(`표시` 지움) */
|
||||
export function varEdits(before: VarForm, now: VarForm): EqEdit[] {
|
||||
const out: EqEdit = { 할일: "변수", 열쇠: before.열쇠 };
|
||||
let changed = false;
|
||||
for (const k of ["이름", "기호", "위", "아래", "단위"] as const)
|
||||
if (now[k] !== before[k]) {
|
||||
out[k] = now[k];
|
||||
changed = true;
|
||||
}
|
||||
const list: EqEdit[] = changed ? [out] : [];
|
||||
if (now.표시 !== before.표시) {
|
||||
const digits = now.표시 === "" || now.표시 === undefined ? null : Number(now.표시);
|
||||
list.push({ 할일: "표시", 열쇠: before.열쇠, 표시: digits });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 변수 더함 창 → 요청 — 빈 칸은 안 보냄 */
|
||||
export function addEdit(form: VarForm, after?: string): EqEdit {
|
||||
const out: EqEdit = { 할일: "더함", 갈래: form.갈래 ?? "입력", 열쇠: form.열쇠.trim() };
|
||||
for (const k of ["이름", "기호", "위", "아래", "단위", "설명"] as const) {
|
||||
const v = (form[k] ?? "").trim();
|
||||
if (v) out[k] = v;
|
||||
}
|
||||
if (form.갈래 === "중간") {
|
||||
out.식 = form.식 || "0";
|
||||
if (after !== undefined) out.뒤 = after;
|
||||
} else if (form.기본값) out.기본값 = form.기본값;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 서버 까닭 「… · 모르는 기호 「A」 「B」 — …」 → 새 기호 글 목록(`master_equation_edit._set_formula` 가 같은 글을 냄) */
|
||||
export function unknownOf(reasons: string[]): string[] {
|
||||
for (const why of reasons) {
|
||||
const hit = /모르는 기호((?:\s*「[^」]+」)+)/.exec(why);
|
||||
if (hit?.[1]) return [...hit[1].matchAll(/「([^」]+)」/g)].map((m) => m[1] ?? "");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
Reference in New Issue
Block a user