diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 58be791ee..3143cfa60 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -43,6 +43,7 @@ import { emptyFlow, examplesOf, fetchFlow, + fetchFlowFor, sameShape, withExamples, mountLogicFlow, @@ -78,9 +79,11 @@ interface Opened extends Draft { values: Record; /** 흐름 로직의 왼쪽 「지금 내용」 자리 — 흐름도 왼쪽 보기가 그림 */ flowHost: HTMLElement; + /** 오른쪽 흐름도의 기준(예시값만 다른지 견줄 때) */ + flowBase?: CfLogic; } -/** 줄의 흐름(있으면 흐름도가 정본) */ +/** 줄의 흐름(있으면 흐름도로 셈) */ const flowOf = (row: LogicRow | null): CfLogic | null => (row?.흐름 as CfLogic | undefined) ?? null; const OWN_FILE = "로직_자체.json"; @@ -165,6 +168,7 @@ export async function mountM01LogicLab( if (opened.origKey !== null && now === opened.original) delete drafts[opened.id]; else drafts[opened.id] = pick(opened, clone(opened.row)); persist(); + if (!flowOf(opened.row)) scheduleFlow(opened); // 왼쪽 편집기로 고침 — 오른쪽 흐름도도 따라감 const inputs = JSON.stringify(opened.row.입력 ?? []); if (inputs !== calcInputs) drawCalc(); if (isOwnLogic(opened.row)) { @@ -241,6 +245,22 @@ export async function mountM01LogicLab( flowNow?.redrawLeft(); }; + /** 왼쪽 고침 뒤 잠시 쉬면 고친 줄을 서버가 흐름도로 바꿔 줌 → 오른쪽에 넣음 · 마지막 물음만 받음 */ + let flowTimer: number | undefined; + let flowSeq = 0; + const scheduleFlow = (o: Opened): void => { + window.clearTimeout(flowTimer); + flowTimer = window.setTimeout(() => { + const seq = ++flowSeq; + if (!o.row) return; + void fetchFlowFor(o.row, o.origKey ?? "", o.file).then((got) => { + if (!got || seq !== flowSeq || opened !== o || !flowNow || flowOf(o.row)) return; + o.flowBase = withExamples(got.flow, o.values); + flowNow.set(clone(o.flowBase)); + }); + }, 700); + }; + /** 오른쪽 흐름도 — 줄의 흐름 · 없으면 서버 변환(흐름 받기) · 그것도 없으면 빈 흐름 */ const startFlow = async (o: Opened): Promise => { const row = o.row; @@ -249,7 +269,7 @@ export async function mountM01LogicLab( const own = flowOf(row); const got = own || o.origKey === null ? null : await fetchFlow(o.origKey, o.file); // 시험 입력 한 벌 — 열 때 왼쪽 시험 계산 입력을 흐름 변수 예시값으로 - let base = withExamples( + o.flowBase = withExamples( own ?? got?.flow ?? (await emptyFlow(row.이름, row.결과단위)), o.values, ); @@ -257,7 +277,7 @@ export async function mountM01LogicLab( try { const made = await mountLogicFlow({ split, - flow: clone(base), + flow: clone(o.flowBase), title: row.이름 || row.키, readOnly: !admin, notes: got?.notes ?? [], @@ -266,9 +286,9 @@ export async function mountM01LogicLab( onChange: (lg) => { if (!o.row || opened !== o) return; const was = flowOf(o.row); - if (sameShape(lg, was ?? base)) { + if (sameShape(lg, was ?? o.flowBase ?? lg)) { // 예시값만 바뀜 — 고친 것 아님 · 왼쪽 시험 계산 입력에 같은 이름으로 넣고 다시 셈 - base = lg; + o.flowBase = lg; syncLeft(o, examplesOf(lg)); return; } diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts index 55b7c00be..4642527d9 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts @@ -18,8 +18,8 @@ const TEXT = { New: ["신규 로직", "New logic"], Waiting: ["흐름도 불러오는 중", "Loading the flow diagram"], Converted: [ - "옛 식을 흐름도로 바꿔 보임 — 저장하면 흐름도가 정본", - "Converted from the old formula — saving makes the flow the master", + "이 로직의 계산 방법을 흐름도로 보여 줌", + "Shows how this logic is calculated as a flow diagram", ], Failed: ["흐름도를 못 띄움 — {v}", "Couldn't open the flow diagram — {v}"], } as const; @@ -122,7 +122,7 @@ export interface FlowAnswer { notes: string[]; } -/** 마스터 로직의 흐름 — 줄에 흐름이 있으면 그대로 · 없으면 서버가 옛 식을 바꿔 줌 · 길이 없거나 못 받으면 null */ +/** 마스터 로직의 흐름 — 줄에 흐름이 있으면 그대로 · 없으면 서버가 계산 방법을 흐름도로 바꿔 줌 · 못 받으면 null */ export async function fetchFlow(key: string, file = ""): Promise { try { const q = new URLSearchParams({ key, ...(file ? { file } : {}) }); @@ -135,6 +135,27 @@ export async function fetchFlow(key: string, file = ""): Promise { + try { + const r = await fetch(`${API_BASE_URL}/m01/logic/flow`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ file, key, row }), + }); + if (!r.ok) return null; + const got = (await r.json()) as Partial; + return got.flow ? { flow: got.flow, converted: !!got.converted, notes: got.notes ?? [] } : null; + } catch { + return null; + } +} + const loadFlowMods = () => Promise.all([ import("../M02_MasterTemplete/DataFlow/M02_DF_Panel"), @@ -223,9 +244,9 @@ export async function mountLogicFlow(opts: LogicFlowOpts): Promise { let logicSig = ""; let gone = false; - /** 다른 로직 — 흐름도 저장본(DF-) · 마스터 로직(흐름 받기) */ + /** 다른 로직 — 마스터 로직(흐름 받기) */ const getLogic = async (key: string): Promise => - key.startsWith("DF-") ? M.api.cfGet(key) : ((await fetchFlow(key))?.flow ?? null); + (await fetchFlow(key))?.flow ?? null; const left = (): void => { const at = opts.leftHost(); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.css b/M02_MasterTemplete/DataFlow/M02_DF_Entry.css deleted file mode 100644 index 9058ccdb2..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry.css +++ /dev/null @@ -1,287 +0,0 @@ -/* 단가산출 다이어그램 — 화면 전체를 덮는 시험판 페이지 · 페이지 하나만 세로로 밀림(안쪽 스크롤 없음) */ - -.m02-df { - position: fixed; - inset: 0; - z-index: 1000; /* 공용 모달과 같은 층 — 뒤에 여는 물음 · 고르기 창은 그 위(--z-confirm 1050) */ - overflow-x: hidden; - overflow-y: auto; - background: var(--color-canvas); - color: var(--color-text); - font-size: var(--text-body); -} - -.m02-df:focus { - outline: none; -} - -.m02-df__muted { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} - -/* 위 띠 — 밀어도 위에 붙어 있음 */ -.m02-df__head { - position: sticky; - top: 0; - z-index: 2; - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--spacing-12); - padding: var(--spacing-12) var(--spacing-24); - border-bottom: 1px solid var(--color-border); - background: var(--color-surface-raised); -} - -.m02-df__title { - margin: 0; - font-size: var(--text-subheading); -} - -.m02-df__trial { - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.m02-df__strip { - display: flex; - flex: 1 1 480px; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--spacing-12); -} - -.m02-df__name { - flex: 1 1 260px; -} - -.m02-df__actions { - display: flex; - gap: var(--spacing-8); - margin-left: auto; -} - -.m02-df__body { - padding: var(--spacing-16) var(--spacing-24) var(--spacing-24); -} - -/* 시작 화면 */ -.m02-df__start { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: var(--spacing-24); -} - -.m02-df__start-col { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -.m02-df__start-col h3 { - margin: var(--spacing-8) 0 0; -} - -/* 고치는 화면 — 위 다이어그램(크게) · 아래 고른 상자 판(크게) + 원가 */ -.m02-df__layout { - display: flex; - flex-direction: column; - gap: var(--spacing-16); -} - -.m02-df__part { - display: flex; - flex-direction: column; - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface); -} - -.m02-df__part h3 { - margin: 0; - padding: var(--spacing-8) var(--spacing-12); - border-bottom: 1px solid var(--color-border); -} - -.m02-df__part-body { - padding: var(--spacing-12); -} - -.m02-df__lower { - display: grid; - grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr); - gap: var(--spacing-16); - align-items: start; -} - -.m02-df__box .m02-df__part-body { - min-height: 50vh; -} - -.m02-df__box-slots { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--spacing-12); -} - -.m02-df__slot { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -.m02-df__slot h4 { - margin: 0; -} - -@media (max-width: 1100px) { - .m02-df__lower, - .m02-df__start, - .m02-df__box-slots { - grid-template-columns: minmax(0, 1fr); - } -} - -.m02-df__list { - display: flex; - flex-direction: column; - gap: var(--spacing-4); -} - -.m02-df__item { - display: flex; - align-items: center; - gap: var(--spacing-8); - width: 100%; - padding: var(--spacing-8) var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface); - color: var(--color-text); - font: inherit; - text-align: left; - cursor: pointer; -} - -.m02-df__item:hover { - border-color: var(--color-accent); -} - -.m02-df__item-name { - flex: 1; - min-width: 0; -} - -.m02-df__item-unit { - color: var(--color-text-muted); - font-size: var(--text-body-sm); - white-space: nowrap; -} - -.m02-df__note { - margin: 0 0 var(--spacing-12); - color: var(--color-text-secondary); - font-size: var(--text-body-sm); - white-space: pre-line; -} - -.m02-df__note:empty { - display: none; -} - -.m02-df__color { - display: inline-flex; - align-items: center; - gap: var(--spacing-8); - margin-bottom: var(--spacing-8); - font-size: var(--text-body-sm); -} - -.m02-df__color[hidden] { - display: none; -} - -.m02-df__color-input { - width: 44px; - height: 32px; - padding: 0; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: none; - cursor: pointer; -} - -/* 상세 내용 — 머리(왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약 이름) · 설정 · 본문 · 경고 */ -.m02-df__detail-head { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: var(--spacing-12); - padding: var(--spacing-8) var(--spacing-12); - border-bottom: 1px solid var(--color-border); -} - -.m02-df__detail-head h3 { - margin: 0; - padding: 0; - border: 0; -} - -.m02-df__detail-name { - display: flex; - flex-wrap: wrap; - align-items: baseline; - justify-content: flex-end; - gap: var(--spacing-8); -} - -.m02-df__detail-pill { - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.m02-df__detail-row { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - padding: var(--spacing-8) 0; - border-bottom: 1px dashed var(--color-border); -} - -.m02-df__detail-row:last-child { - border-bottom: 0; -} - -.m02-df__detail-row[hidden] { - display: none; -} - -.m02-df__detail-row h4 { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.m02-df__detail-warn { - margin: 0; - padding-left: var(--spacing-16); - font-size: var(--text-body-sm); -} - -.m02-df__detail-dl { - display: grid; - grid-template-columns: auto 1fr; - gap: var(--spacing-4) var(--spacing-12); - margin: 0; -} - -.m02-df__detail-dl dd { - margin: 0; -} - -.m02-df__detail-trace { - margin: 0; - padding-left: var(--spacing-16); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts deleted file mode 100644 index e2438aad8..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts +++ /dev/null @@ -1,644 +0,0 @@ -/* ============================================================================= - * M02_DF_Entry.ts - * 단가산출 흐름도(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기 - * 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md`(1판 약속 = 원가다이어그램_정함.md) · 이 폴더째 지워도 됨 - * (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). DataFlow 의 다른 파일은 import() 로 늦게. - * 잇기: 열기 → 입력 값(cfValues) → renderDiagram · 「상세 내용」(상자 · 알약 · 선 고름마다 판을 바꿔 끼움 — - * M02_DF_Entry_Detail) · renderSummary → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장(서버 대조) · 지우기 · - * 실행 취소 · Ctrl+Z · Ctrl+S. 단추 · 칸은 공용 작은 크기. 바깥 페이지 하나만 세로로 밀림. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - createSelectField, - createTag, - el, - showConfirmDialog, - showToast, -} from "@ui/ui_template_elements"; -import { openModal } from "@ui/ui_template_modal"; -import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; -import type { CfListItem } from "./M02_DF_Api"; -import type { CfBreak, CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval"; -import type { CfKind, CfLogic, CfPort, CfStep } from "./M02_DF_Model"; -import type { CfSel } from "./M02_DF_Verify"; -import type { DfPillSel } from "./M02_DF_Diagram_Parts"; -import { - detailFrame, - drawHead, - drawWarnings, - fetchValues, - loadLogics, - queriesOf, - unitChoices, - type DfPick, -} from "./M02_DF_Entry_Detail"; -import "./M02_DF_Entry.css"; - -/** DataFlow 의 다른 파일 — 페이지를 열 때 한 번에 불러옴(엔진이 M01 첫 묶음에 안 들어가게) */ -const loadMods = () => - Promise.all([ - import("./M02_DF_Model"), - import("./M02_DF_Eval"), - import("./M02_DF_Api"), - import("./M02_DF_Diagram"), - import("./M02_DF_FuncPanel"), - import("./M02_DF_OutputPanel"), - import("./M02_DF_Summary"), - import("./M02_DF_InputPanel"), - import("./M02_DF_PillPanel"), - import("./M02_DF_LinkPanel"), - import("./M02_DF_Standards"), - ]).then(([model, ev, api, diagram, func, output, summary, input, pill, line, std]) => ({ - model, - ev, - api, - diagram, - func, - output, - summary, - input, - pill, - line, - std, - })); -type Mods = Awaited>; - -const TEXT = { - Open: ["단가산출 흐름도(시험)", "Cost flow (trial)"], - Title: ["단가산출 흐름도", "Cost flow"], - Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], - Close: ["닫기", "Close"], - Loading: ["불러오는 중", "Loading"], - OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"], - Start_New: ["새로 만들기", "New"], - Start_Mine: ["내 로직", "My logics"], - Start_MineNone: ["아직 없음", "None yet"], - Name: ["로직 이름", "Logic name"], - NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"], - Unit: ["결과 단위", "Result unit"], - Undo: ["실행 취소", "Undo"], - Save: ["저장", "Save"], - Delete: ["지우기", "Delete"], - Unsaved: ["저장 안 함", "Not saved"], - Diagram: ["다이어그램", "Diagram"], - Summary: ["원가", "Cost"], - Color: ["상자 색", "Box colour"], - BoxName: ["상자 이름", "Box name"], - NameTaken: ["「{v}」 — 같은 이름의 상자가 있음", "“{v}” — another box has this name"], - Standards: ["표준 표", "Standards"], - Logics: ["다른 로직을 못 읽음 — {v}", "Couldn't read other logics — {v}"], - NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"], - SaveAnyway: [ - "고칠 곳이 있음 — {v} · 그래도 저장할까요?", - "There are issues — {v} · Save anyway?", - ], - Saving: ["저장 중", "Saving"], - Saved: ["저장했음", "Saved"], - SaveFailed: ["저장 못 함 — {v}", "Not saved — {v}"], - Differs: [ - "저장 때 서버가 다시 셈한 원가 {s} 원 — 화면 값 {c} 원과 다름", - "Recalculated on save: {s} — differs from the screen value {c}", - ], - Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"], - Deleted: ["지웠음", "Deleted"], - Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"], - Leave_Ask: [ - "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", - "There are unsaved changes — close without saving?", - ], - Leave_Ok: ["저장 안 하고 닫기", "Close without saving"], -} as const; - -type TextKey = keyof typeof TEXT; - -const te = (key: TextKey, vars: Record = {}): string => - Object.entries(vars).reduce( - (text, [k, v]) => text.replace(`{${k}}`, String(v)), - (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, - ); - -const muted = (text: string): HTMLElement => el("p", { className: "m02-df__muted", text }); - -/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */ -export function createDataFlowButton(): HTMLButtonElement { - return createButton({ label: te("Open"), variant: "filled", size: "sm", onClick: openDataFlow }); -} - -function openDataFlow(): void { - /** 고치는 화면이 열려 있을 때만 */ - let paper: { dirty(): boolean; save(): Promise; undo(): void } | null = null; - let units: string[] | null = null; - const mods = loadMods(); - - const mayLeave = async (): Promise => - !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); - - const body = el("main", { className: "m02-df__body" }); - const strip = el("div", { className: "m02-df__strip" }); - const close = createButton({ - label: te("Close"), - variant: "ghost", - size: "sm", - onClick: () => - void (async () => { - if (await mayLeave()) paper ? void showStart() : shut(); - })(), - }); - const page = el("div", { - className: "m02-df", - attrs: { role: "dialog", "aria-modal": "true", "aria-label": te("Title") }, - children: [ - el("header", { - className: "m02-df__head", - children: [ - el("h2", { className: "m02-df__title", text: te("Title") }), - el("span", { className: "m02-df__trial", text: te("Trial") }), - strip, - close, - ], - }), - body, - ], - }); - const prevOverflow = document.body.style.overflow; - const shut = (): void => { - page.remove(); - document.body.style.overflow = prevOverflow; - }; - page.addEventListener("keydown", (ev) => { - if (ev.defaultPrevented) return; - if (ev.key === "Escape") { - void (async () => { - if (await mayLeave()) shut(); - })(); - return; - } - if (!paper || !(ev.ctrlKey || ev.metaKey)) return; - const key = ev.key.toLowerCase(); - if (key === "z" && !ev.shiftKey) { - ev.preventDefault(); - paper.undo(); - } else if (key === "s") { - ev.preventDefault(); - void paper.save(); - } - }); - - const run = (job: (M: Mods) => Promise): void => { - body.replaceChildren(muted(te("Loading"))); - void mods.then(job).catch((e: unknown) => { - showToast(te("OpenFailed", { v: e instanceof Error ? e.message : String(e) }), "error"); - void showStart(); - }); - }; - - /* ---------- 시작 화면 ---------- */ - - const listRow = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => { - const row = el("button", { - className: "m02-df__item", - attrs: { type: "button", title }, - children: [ - el("span", { className: "m02-df__item-name", text: name }), - el("span", { className: "m02-df__item-unit", text: unit }), - ], - }); - row.addEventListener("click", onClick); - return row; - }; - - const showStart = async (): Promise => { - paper = null; - strip.replaceChildren(); - const mineHost = el("div", { className: "m02-df__list", children: [muted(te("Loading"))] }); - body.replaceChildren( - el("div", { - className: "m02-df__start", - children: [ - el("section", { - className: "m02-df__start-col", - children: [ - bigButton(te("Start_New"), () => - run(async (M) => showPaper(M, M.model.newLogic("", (await units_())[0]), false)), - ), - el("h3", { text: te("Start_Mine") }), - mineHost, - ], - }), - ], - }), - ); - const M = await mods; - try { - const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample); - mineHost.replaceChildren( - ...(mine.length - ? mine.map((x) => - listRow( - x.name, - x.resultUnit, - () => run(async (M2) => showPaper(M2, await M2.api.cfGet(x.key), true)), - x.saved, - ), - ) - : [muted(te("Start_MineNone"))]), - ); - } catch (e) { - mineHost.replaceChildren(muted(M.api.cfPlain(e))); - } - }; - const units_ = async (): Promise => (units ??= await unitChoices()); - - /* ---------- 고치는 화면 ---------- */ - - const showPaper = async (M: Mods, first: CfLogic, saved: boolean): Promise => { - const choices = await units_(); - let lg = first; - let savedLg: CfLogic | null = saved ? first : null; - let stack: CfLogic[] = []; - let pick: DfPick = null; - const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null); - let values: CfValues = {}; - let valueSig = ""; - let logics: NonNullable = {}; - let logicSig = ""; - let logicNotes: string[] = []; - let broken: CfBreak[] = []; - let notes: string[] = []; - let result: CfEvalResult; - let busy = false; - - const unsaved = createTag(te("Unsaved"), "warning"); - const note = el("p", { className: "m02-df__note", attrs: { role: "status" } }); - const diagramHost = el("div"); - // 상자마다 색 바꾸기(정함 점검 9) — 고른 상자 판 머리에 하나 - const colorBox = el("input", { - className: "m02-df__color-input", - attrs: { type: "color", "aria-label": te("Color") }, - }); - colorBox.addEventListener("change", () => { - const id = pickedBox(); - if (id) edit(M.model.setColor(lg, id, colorBox.value)); - }); - const colorRow = el("label", { - className: "m02-df__color", - children: [el("span", { text: te("Color") }), colorBox], - }); - // 상자 이름 — 입력 · 함수 상자(출력 상자는 출력 판의 이름 칸) · 빈 이름 · 같은 이름은 안 받음 - const boxName = createInputField({ label: te("BoxName"), compact: true }); - boxName.input.addEventListener("change", () => { - const box = lg.boxes.find((b) => b.id === pickedBox()); - if (!box) return; - const next = boxName.input.value.trim(); - const taken = lg.boxes.some((b) => b.id !== box.id && b.name === next); - if (taken) note.textContent = te("NameTaken", { v: next }); - if (!next || taken || next === box.name) return void (boxName.input.value = box.name); - edit(M.model.updateBox(lg, box.id, { name: next })); - }); - // 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만(갈아끼우면 편집기가 있다고 여김) - const funcHost = el("div"); - const outHost = el("div"); - const inHost = el("div"); - const summaryHost = el("div"); - const linkHost = el("div"); - const pillHost = el("div"); - const detail = detailFrame(); - detail.settings.append(boxName.root, colorRow); - detail.body.append(inHost, funcHost, outHost, linkHost, pillHost); - - const evalNow = (): CfEvalResult => { - try { - return M.ev.evalLogic(lg, values, { logics }); - } catch (e) { - const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; - return { ports: {}, outputs: {}, cost, errors: [M.api.cfPlain(e)] }; - } - }; - - const edit = (next: CfLogic): void => { - if (next === lg) return; - stack = [...stack, lg]; - lg = next; - redraw(); - }; - /** 다이어그램이 부른 바꿈 — 다이어그램이 제 옛 그림으로 한 번 더 그려도 다음 틱에 새로 */ - const editFromDiagram = (next: CfLogic): void => { - edit(next); - setTimeout(redraw); - }; - - /** 입력 값 다시 받기 — 물음이 바뀐 때만 · 같은 조작의 뒤끝이라 기록을 더 쌓지 않음 */ - const refreshValues = (): void => { - // 조건 찾기 재료는 이어진 알약 값(지금 셈)을 함께 물음 — 값이 바뀌면 다시 받음 - const pill = (p: CfPort): unknown => result?.ports[p.box]?.[p.name] ?? null; - const sig = JSON.stringify(queriesOf(lg, pill, M.model.elementPort).map((a) => a.q)); - if (sig === valueSig) return; - valueSig = sig; - void fetchValues(M.api.cfValues, lg, pill, M.model.elementPort).then( - (got) => { - if (sig !== valueSig) return; - values = got.values; - notes = got.errors.length ? [te("Values", { v: got.errors.join(" · ") })] : []; - redraw(); - }, - (e: unknown) => { - notes = [te("Values", { v: M.api.cfPlain(e) })]; - redraw(); - }, - ); - }; - - /** 다른 로직 입력이 부르는 로직들 다시 읽기 — 부르는 열쇠가 바뀐 때만 */ - const refreshLogics = (): void => { - const sig = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? "")); - if (sig === logicSig) return; - logicSig = sig; - const get = async (key: string): Promise => - key.startsWith("DF-") ? M.api.cfGet(key) : Promise.resolve(null); - void loadLogics(lg, get).then((got) => { - if (sig !== logicSig) return; - logics = got.logics; - logicNotes = got.errors.length ? [te("Logics", { v: got.errors.join(" · ") })] : []; - redraw(); - }); - }; - - /** 고름 — 검증 칸 줄 · 알약 판의 상자 이름 누름 */ - const choose = (sel: CfSel): void => { - const box = lg.boxes.find((b) => b.id === sel.box); - pick = sel.link - ? { kind: "link", id: sel.link } - : box && sel.port - ? { kind: "pill", box: box.id, port: sel.port, dir: M.model.portDir(box, sel.port) } - : box - ? { kind: "box", id: box.id } - : null; - redraw(); - }; - - /** 흐름 길 — 알약 = 그 알약에서 앞뒤 · 선 = 보낸 알약 앞 · 받는 알약 뒤 */ - const traceNow = (): { back: CfStep[]; forward: CfStep[] } | null => { - try { - if (pick?.kind === "pill") { - const p = { box: pick.box, name: pick.port }; - return { back: M.model.traceBack(lg, p), forward: M.model.traceForward(lg, p) }; - } - const id = pick?.kind === "link" ? pick.id : ""; - const link = lg.links.find((l) => l.id === id); - if (link) - return { - back: M.model.traceBack(lg, link.from), - forward: M.model.traceForward(lg, link.to), - }; - } catch { - /* 따라갈 수 없으면 칠하지 않음 */ - } - return null; - }; - - const name = createInputField({ - label: te("Name"), - placeholder: te("NamePlaceholder"), - value: lg.name, - compact: true, - onInput: (v) => edit({ ...lg, name: v }), - }); - name.root.classList.add("m02-df__name"); - const unitList = choices.includes(lg.resultUnit) ? choices : [lg.resultUnit, ...choices]; - const unit = createSelectField({ - label: te("Unit"), - options: unitList.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), - value: lg.resultUnit, - compact: true, - onChange: (v) => edit({ ...lg, resultUnit: v }), - }); - - const diagramOn = { - select: (id: string | null, pill?: DfPillSel): void => { - pick = pill - ? { kind: "pill", ...pill } - : !id - ? null - : lg.links.some((l) => l.id === id) - ? { kind: "link", id } - : { kind: "box", id }; - // 흐름 길 · 판을 새로 — 다이어그램이 제 그림을 그린 뒤 - setTimeout(redraw); - }, - move: (id: string, x: number, y: number): void => edit(M.model.moveBox(lg, id, x, y)), - link: (from: CfPort, to: CfPort): string | null => { - const got = M.model.link(lg, from, to); - if (got.why) return got.why; - editFromDiagram(got.lg); - return null; - }, - unlink: (id: string): void => editFromDiagram(M.model.unlink(lg, id)), - add: (kind: CfKind, x: number, y: number): void => { - const got = M.model.addBox(lg, kind, x, y); - pick = { kind: "box", id: got.box.id }; - editFromDiagram(got.lg); - }, - remove: (id: string): void => { - if (pickedBox() === id) pick = null; - editFromDiagram(M.model.removeBox(lg, id)); - }, - }; - - const drawPanel = (): void => { - const boxId = pickedBox(); - if (boxId && !lg.boxes.some((b) => b.id === boxId)) pick = null; - const box = lg.boxes.find((b) => b.id === pickedBox()); - drawHead(detail, lg, pick); - // 설정 줄(상자 이름 · 색)은 상자를 고른 때만 — 선 · 알약이면 줄째 숨김 - (detail.settings.parentElement as HTMLElement).hidden = !box; - colorRow.hidden = !box; - boxName.root.hidden = !box || box.kind === "output"; - if (box && document.activeElement !== boxName.input) boxName.input.value = box.name; - linkHost.hidden = pick?.kind !== "link"; - pillHost.hidden = pick?.kind !== "pill"; - if (pick?.kind === "link") - M.line.renderLinkPanel(linkHost, lg, pick.id, { change: edit }, { values, logics }); - if (pick?.kind === "pill") - M.pill.renderPillPanel( - pillHost, - lg, - pick.box, - pick.port, - { pick: (id) => choose({ box: id }) }, - { values, logics }, - ); - drawWarnings(detail, pick, broken); - if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color; - funcHost.hidden = box?.kind !== "function"; - outHost.hidden = box?.kind !== "output"; - inHost.hidden = box?.kind !== "input"; - if (box?.kind === "input") M.input.renderInputPanel(inHost, lg, box.id, { change: edit }); - if (box?.kind === "function") - M.func.renderFuncPanel(funcHost, lg, box.id, { change: edit }, { values, logics }); - if (box?.kind === "output") - M.output.renderOutputPanel(outHost, lg, box.id, { change: edit }, { result }); - }; - - const redraw = (): void => { - result = evalNow(); - refreshValues(); - refreshLogics(); - const linkId = pick?.kind === "link" ? pick.id : ""; - if (linkId && !lg.links.some((l) => l.id === linkId)) pick = null; - try { - broken = M.ev.breaks(lg, values, { logics }); - } catch { - broken = result.breaks ?? []; - } - unsaved.hidden = lg === savedLg; - undo.disabled = !stack.length; - deleteButton.hidden = !lg.key; - M.diagram.renderDiagram(diagramHost, lg, diagramOn, { - values: result.ports, - selected: pick?.kind === "box" || pick?.kind === "link" ? pick.id : null, - selectedPill: - pick?.kind === "pill" ? { box: pick.box, port: pick.port, dir: pick.dir } : null, - trace: traceNow(), - breaks: broken, - }); - drawPanel(); - M.summary.renderSummary( - summaryHost, - lg, - result, - { change: edit, select: choose }, - { values }, - ); - note.textContent = [...notes, ...logicNotes, ...result.errors].slice(0, 4).join("\n"); - }; - - const save = async (): Promise => { - if (busy) return; - if (!lg.name.trim()) { - note.textContent = te("NoName"); - return void name.input.focus(); - } - let problems: string[] = []; - try { - problems = M.ev.checkLogic(lg, values, { logics }); - } catch { - problems = []; - } - const ask = te("SaveAnyway", { v: problems.slice(0, 3).join(" · ") }); - if (problems.length && !(await showConfirmDialog(ask, te("Save")))) return; - busy = true; - note.textContent = te("Saving"); - const screen = result.cost.계; - try { - const got = await M.api.cfSave(lg); - lg = got.logic; - savedLg = lg; - stack = []; - redraw(); - const server = got.values.cost.계; - if (Math.abs(server - screen) > 0.5) - notes = [te("Differs", { s: formatMoney(server), c: formatMoney(screen) })]; - note.textContent = [...notes, ...result.errors].join("\n"); - showToast(te("Saved"), "success"); - } catch (e) { - note.textContent = te("SaveFailed", { v: M.api.cfPlain(e) }); - } finally { - busy = false; - } - }; - - const doUndo = (): void => { - const back = stack[stack.length - 1]; - if (!back) return; - stack = stack.slice(0, -1); - lg = back; - name.input.value = lg.name; - unit.select.value = lg.resultUnit; - redraw(); - }; - - const doDelete = async (): Promise => { - if (!lg.key || busy) return; - if (!(await showConfirmDialog(te("Delete_Ask", { v: lg.name }), te("Delete")))) return; - try { - await M.api.cfDelete(lg.key); - savedLg = lg; - showToast(te("Deleted"), "success"); - void showStart(); - } catch (e) { - note.textContent = M.api.cfPlain(e); - } - }; - - const undo = createButton({ label: te("Undo"), variant: "ghost", size: "sm", onClick: doUndo }); - const deleteButton = createButton({ - label: te("Delete"), - variant: "danger", - size: "sm", - onClick: () => void doDelete(), - }); - strip.replaceChildren( - name.root, - unit.root, - unsaved, - el("div", { - className: "m02-df__actions", - children: [ - createButton({ - label: te("Standards"), - variant: "ghost", - size: "sm", - onClick: () => - openModal({ - title: te("Standards"), - closeLabel: te("Close"), - mount: (host: HTMLElement) => - M.std.renderStandards(host, { change: () => redraw() }, { title: false }), - }), - }), - undo, - createButton({ label: te("Save"), size: "sm", onClick: () => void save() }), - deleteButton, - ], - }), - ); - body.replaceChildren( - note, - el("div", { - className: "m02-df__layout", - children: [ - part(te("Diagram"), "m02-df__diagram", [diagramHost]), - el("div", { - className: "m02-df__lower", - children: [detail.root, part(te("Summary"), "m02-df__summary", [summaryHost])], - }), - ], - }), - ); - paper = { dirty: () => lg !== savedLg, save, undo: doUndo }; - redraw(); - if (!lg.name) name.input.focus(); - }; - - void showStart(); - document.body.style.overflow = "hidden"; // 뒤 페이지는 안 밀리고 시험판 페이지만 밀림 - document.body.append(page); - page.tabIndex = -1; - page.focus(); -} - -/** 고치는 화면의 큰 자리 — 제목 + 몸(안쪽 스크롤 없음 · 내용만큼 늘어남) */ -function part(title: string, className: string, children: HTMLElement[]): HTMLElement { - return el("section", { - className: `m02-df__part ${className}`, - children: [el("h3", { text: title }), el("div", { className: "m02-df__part-body", children })], - }); -} - -/** 보통 크기 단추 — 칸 폭으로 늘어나지 않게 감쌈 */ -function bigButton(label: string, onClick: () => void): HTMLElement { - return el("div", { children: [createButton({ label, variant: "filled", size: "sm", onClick })] }); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Panel.css b/M02_MasterTemplete/DataFlow/M02_DF_Panel.css index f9ffd4fc8..37bdae5e5 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Panel.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_Panel.css @@ -57,43 +57,64 @@ padding: var(--spacing-4) 0; } -/* 왼쪽 가장자리 끌기 */ +/* 왼쪽 가장자리 끌기 — 늘 보이는 손잡이(세로 막대 + 가운데 잡이) */ .m02-dfp__grip { position: absolute; top: 0; bottom: 0; - left: -4px; + left: -5px; z-index: 2; - width: 8px; + width: 10px; cursor: col-resize; touch-action: none; + background: color-mix(in srgb, var(--color-border) 70%, transparent); + border-radius: 4px; +} + +.m02-dfp__grip::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 4px; + height: 40px; + transform: translate(-50%, -50%); + border-radius: 2px; + background: var(--color-text-muted); } .m02-dfp__grip:hover, .m02-dfp__grip:focus-visible, .m02-dfp.is-resizing .m02-dfp__grip { - background: color-mix(in srgb, var(--color-accent) 35%, transparent); + background: color-mix(in srgb, var(--color-accent) 40%, transparent); outline: none; } +.m02-dfp__grip:hover::after, +.m02-dfp__grip:focus-visible::after, +.m02-dfp.is-resizing .m02-dfp__grip::after { + background: var(--color-accent); +} + .m02-dfp.is-resizing { user-select: none; } -/* 접기 = 얇은 띠 */ +/* 접기 = 얇은 띠 — 같은 「흐름도」 단추를 세로로 */ .m02-dfp__strip { + display: flex; + justify-content: center; width: 100%; - padding: var(--spacing-12) 0; - border: 0; + padding: var(--spacing-8) 0; background: var(--color-surface); - color: var(--color-text-secondary); - font: inherit; - writing-mode: vertical-rl; - cursor: pointer; } -.m02-dfp__strip:hover, -.m02-dfp__strip:focus-visible { +/* 공용 작은 단추 크기는 그대로 — 글 방향만 세로 */ +.m02-dfp__strip .m02-dfp__toggle { + writing-mode: vertical-rl; +} + +.m02-dfp__toggle[aria-expanded="true"] { color: var(--color-accent); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts index 173a32cd0..18c80030c 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts @@ -16,9 +16,9 @@ import "./M02_DF_Panel.css"; const TEXT = { Big: ["크게 보기", "Full screen"], - Fold: ["접기", "Collapse"], - Strip: ["흐름도 ▸", "Flow ▸"], - StripTitle: ["흐름도 펴기", "Open the flow panel"], + Toggle: ["흐름도", "Flow"], + ToggleOpen: ["흐름도 펴기", "Open the flow panel"], + ToggleClose: ["흐름도 접기", "Collapse the flow panel"], Close: ["닫기", "Close"], Detail: ["상세 내용", "Details"], Cost: ["원가 · 검증", "Cost · check"], @@ -108,6 +108,23 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane const sm = (label: string, onClick: () => void, variant: "ghost" | "filled" = "ghost") => createButton({ label, size: "sm", variant, onClick }); + /* 「흐름도」 단추 하나 — 누르면 접고 · 다시 누르면 펼침(펼치면 머리 · 접으면 얇은 띠에 놓임) */ + const toggle = createButton({ + label: tf("Toggle"), + size: "sm", + variant: "ghost", + onClick: () => setFolded(!folded), + }); + toggle.classList.add("m02-dfp__toggle"); + const head = el("header", { + className: "m02-dfp__head", + children: [ + toggle, + el("strong", { className: "m02-dfp__title", text: opts.title }), + sm(tf("Big"), () => expand()), + ], + }); + /* 펼친 패널 */ const costBox = el("details", { className: "m02-dfp__cost", @@ -117,31 +134,12 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane const tools = el("div", { className: "m02-dfp__tools", children: [undo] }); const open = el("div", { className: "m02-dfp__open", - children: [ - el("header", { - className: "m02-dfp__head", - children: [ - el("strong", { className: "m02-dfp__title", text: opts.title }), - sm(tf("Big"), () => expand()), - sm(tf("Fold"), () => setFolded(true)), - ], - }), - tools, - note, - diagram, - detail, - costBox, - ], + children: [head, tools, note, diagram, detail, costBox], }); costBox.append(summary); - /* 접힌 띠 */ - const strip = el("button", { - className: "m02-dfp__strip", - text: tf("Strip"), - attrs: { type: "button", title: tf("StripTitle"), "aria-label": tf("StripTitle") }, - }); - strip.addEventListener("click", () => setFolded(false)); + /* 접힌 띠 — 같은 「흐름도」 단추만 */ + const strip = el("div", { className: "m02-dfp__strip" }); /* 왼쪽 가장자리 — 끌기 · 화살표 키 */ const grip = el("div", { @@ -195,12 +193,18 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane }); const setFolded = (on: boolean): void => { + // 접기 전 너비를 기억 — 끈 적이 없어도 펴면 그 너비로 + if (on && !folded && width === null && root.isConnected) + width = Math.round(root.getBoundingClientRect().width); folded = on; writeStore(FOLD_KEY, on ? "1" : "0"); root.classList.toggle("is-folded", on); open.hidden = on; strip.hidden = !on; grip.hidden = on; + (on ? strip : head).prepend(toggle); + toggle.setAttribute("aria-expanded", String(!on)); + toggle.title = tf(on ? "ToggleOpen" : "ToggleClose"); tell(); if (!on) paper.refit(); }; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Paper.css b/M02_MasterTemplete/DataFlow/M02_DF_Paper.css new file mode 100644 index 000000000..30d08a0ef --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Paper.css @@ -0,0 +1,113 @@ +/* 흐름도 고치는 핵심(M02_DF_Paper) · 상세 내용 틀(M02_DF_Entry_Detail) 꾸밈 — 옛 시험판 Entry.css 에서 옮김 */ +.m02-df__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m02-df__part { + display: flex; + flex-direction: column; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); +} + +.m02-df__part h3 { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-bottom: 1px solid var(--color-border); +} + +.m02-df__part-body { + padding: var(--spacing-12); +} + +.m02-df__note { + margin: 0 0 var(--spacing-12); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + white-space: pre-line; +} + +.m02-df__note:empty { + display: none; +} + +.m02-df__color { + display: inline-flex; + align-items: center; + gap: var(--spacing-8); + margin-bottom: var(--spacing-8); + font-size: var(--text-body-sm); +} + +.m02-df__color[hidden] { + display: none; +} + +.m02-df__color-input { + width: 44px; + height: 32px; + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: none; + cursor: pointer; +} + +.m02-df__detail-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--spacing-12); + padding: var(--spacing-8) var(--spacing-12); + border-bottom: 1px solid var(--color-border); +} + +.m02-df__detail-head h3 { + margin: 0; + padding: 0; + border: 0; +} + +.m02-df__detail-name { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: flex-end; + gap: var(--spacing-8); +} + +.m02-df__detail-pill { + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-df__detail-row { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-8) 0; + border-bottom: 1px dashed var(--color-border); +} + +.m02-df__detail-row:last-child { + border-bottom: 0; +} + +.m02-df__detail-row[hidden] { + display: none; +} + +.m02-df__detail-row h4 { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-df__detail-warn { + margin: 0; + padding-left: var(--spacing-16); + font-size: var(--text-body-sm); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts index fd1a99b9e..40bd21d79 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_DF_Paper.ts * 흐름도 고치는 핵심 — 다이어그램 · 상세 내용(상자 · 알약 · 선 판) · 원가 · 검증 칸 · 실행 취소 - * Entry 시험판의 고치는 화면(showPaper)을 옮겨 적은 공용 조각 — 오른쪽 패널(M02_DF_Panel) · 「크게 보기」 가 함께 씀. + * (옛 시험판 고치는 화면을 옮겨 적은) 공용 조각 — 오른쪽 패널(M02_DF_Panel) · 「크게 보기」 가 함께 씀. * 저장 · 지우기 · 이름 · 결과 단위는 없음(부르는 쪽 페이지 몫) · 바뀔 때마다 onChange(객체 그대로). * readOnly = 끌기 · 고치기 막고 보기 · 흐름 따라가기 · 검증 칸 예시값만(onChange 안 부름). * 판 DOM 은 한 번 만들고 자리만 옮겨 끼움(패널 ↔ 크게 보기) — 고친 것 · 고른 것 그대로. @@ -31,7 +31,7 @@ import { queriesOf, type DfPick, } from "./M02_DF_Entry_Detail"; -import "./M02_DF_Entry.css"; +import "./M02_DF_Paper.css"; const TEXT = { Undo: ["실행 취소", "Undo"], @@ -51,7 +51,7 @@ export interface PaperOpts { readOnly?: boolean; /** 고칠 때마다 — 객체 그대로(부르는 쪽 캐시에 쌓음) */ onChange(lg: CfLogic): void; - /** 다른 로직 입력이 부르는 로직 — 없으면 흐름도 저장본(DF-) 만 */ + /** 다른 로직 입력이 부르는 로직 — 없으면 못 부름(빈 값) */ logics?(key: string): Promise; /** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */ fitMin?(): number; @@ -186,8 +186,7 @@ export function createPaper(opts: PaperOpts): Paper { const sig = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? "")); if (sig === logicSig) return; logicSig = sig; - const get = - opts.logics ?? (async (key: string) => (key.startsWith("DF-") ? api.cfGet(key) : null)); + const get = opts.logics ?? (async () => null); void loadLogics(lg, get).then((got) => { if (sig !== logicSig) return; logics = got.logics; diff --git a/resources/master_data/로직_자체.json b/resources/master_data/로직_자체.json index 87528c301..71ce77145 100644 --- a/resources/master_data/로직_자체.json +++ b/resources/master_data/로직_자체.json @@ -2,74 +2,5 @@ "그룹": "로직", "원문": "자체", "판": "2026", - "줄": [ - { - "키": "GX000001", - "원문번호": "13-4-1", - "구분": "자체", - "상세구분": "13장 구조물", - "이름": "돌쌓기 메쌓기(인력)", - "결과단위": "원/㎡", - "출처": "산림품셈 13-4-1", - "소유": "현장", - "비고": "고르기 첫 값 = 원문에 있는 조합(뒷길이 25 · 깬잡석 · 골쌓기) · 「원문에 없는 조합: 뒷길이=25 · 돌=견치돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=견치돌 · 쌓기=켜쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=켜쌓기 / 뒷길이=30 · 돌=견치돌 · 쌓기=골쌓기 외 5」 · GF000219 를 본뜸", - "입력": [ - { "이름": "뒷길이", "설명": "쌓을 돌의 뒤쪽 길이", "단위": "㎝", "고르기": [25, 30, 35, 45, 55, 60, 75] }, - { "이름": "돌", "설명": "쌓을 돌 종류", "고르기": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"] }, - { "이름": "쌓기", "설명": "돌을 어긋나게 쌓는지 줄 맞춰 쌓는지", "고르기": ["골쌓기", "켜쌓기"] }, - { "이름": "높이", "설명": "쌓는 벽 높이", "단위": "m" }, - { "이름": "초과증가율", "설명": "기준 높이를 넘어 더 드는 품 비율", "단위": "%", "범위": [80, 100] } - ], - "중간": [{ "이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))", "출처": "산림품셈 13-4-1 주③" }], - "호표": [ - { "종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)", "비목": "노무비" }, - { "종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)", "비목": "노무비" } - ], - "덧줄": [], - "끝수": null, - "시험입력": { "높이": 4.0, "돌": "깬잡석" } - }, - { - "키": "GX000002", - "원문번호": "12-34-4", - "구분": "자체", - "상세구분": "12장 철근콘크리트", - "이름": "암거이음받침 채움재(T=20㎜)", - "결과단위": "원/㎡", - "출처": "산림품셈 12-34-4", - "소유": "현장", - "비고": "해석: 원문 재료비 「JOINT FILLER」 뿐 — ㎡당 1㎡ · 설치 품 원문 없음 · GF000208 를 본뜸", - "입력": [], - "중간": [], - "호표": [{ "종류": "재료", "요소": { "구분": "도료·방수·접착", "상세구분": "방수·실링", "검색어": "JOINT FILLER" }, "이름": "JOINT FILLER(T=20㎜)", "단위": "㎡", "수량": "1", "비목": "재료비" }], - "덧줄": [], - "끝수": null - }, - { - "키": "GX000003", - "원문번호": "13-4-1", - "구분": "자체", - "상세구분": "13장 구조물", - "이름": "돌쌓기 메쌓기(인력)", - "결과단위": "원/㎡", - "출처": "산림품셈 13-4-1", - "소유": "현장", - "비고": "고르기 첫 값 = 원문에 있는 조합(뒷길이 25 · 깬잡석 · 골쌓기) · 「원문에 없는 조합: 뒷길이=25 · 돌=견치돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=견치돌 · 쌓기=켜쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=켜쌓기 / 뒷길이=30 · 돌=견치돌 · 쌓기=골쌓기 외 5」 · GF000219 를 본뜸", - "입력": [ - { "이름": "뒷길이", "설명": "쌓을 돌의 뒤쪽 길이", "단위": "㎝", "고르기": [25, 30, 35, 45, 55, 60, 75] }, - { "이름": "돌", "설명": "쌓을 돌 종류", "고르기": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"] }, - { "이름": "쌓기", "설명": "돌을 어긋나게 쌓는지 줄 맞춰 쌓는지", "고르기": ["골쌓기", "켜쌓기"] }, - { "이름": "높이", "설명": "쌓는 벽 높이", "단위": "m" }, - { "이름": "초과증가율", "설명": "기준 높이를 넘어 더 드는 품 비율", "단위": "%", "범위": [80, 100] } - ], - "중간": [{ "이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))", "출처": "산림품셈 13-4-1 주③" }], - "호표": [ - { "종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)", "비목": "노무비" }, - { "종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)", "비목": "노무비" } - ], - "덧줄": [], - "끝수": null, - "시험입력": { "높이": 4.0, "돌": "깬잡석" } - } - ] + "줄": [] }