Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_3
This commit is contained in:
@@ -43,6 +43,7 @@ import {
|
||||
emptyFlow,
|
||||
examplesOf,
|
||||
fetchFlow,
|
||||
fetchFlowFor,
|
||||
sameShape,
|
||||
withExamples,
|
||||
mountLogicFlow,
|
||||
@@ -78,9 +79,11 @@ interface Opened extends Draft {
|
||||
values: Record<string, string>;
|
||||
/** 흐름 로직의 왼쪽 「지금 내용」 자리 — 흐름도 왼쪽 보기가 그림 */
|
||||
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<void> => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<FlowAnswer | null> {
|
||||
try {
|
||||
const q = new URLSearchParams({ key, ...(file ? { file } : {}) });
|
||||
@@ -135,6 +135,27 @@ export async function fetchFlow(key: string, file = ""): Promise<FlowAnswer | nu
|
||||
}
|
||||
}
|
||||
|
||||
/** 고치는 중인 줄의 흐름 — 왼쪽 편집기로 고친 줄(저장 전)을 서버가 흐름도로 바꿔 줌 · 못 받으면 null */
|
||||
export async function fetchFlowFor(
|
||||
row: unknown,
|
||||
key: string,
|
||||
file: string,
|
||||
): Promise<FlowAnswer | null> {
|
||||
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<FlowAnswer>;
|
||||
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<LogicFlow> {
|
||||
let logicSig = "";
|
||||
let gone = false;
|
||||
|
||||
/** 다른 로직 — 흐름도 저장본(DF-) · 마스터 로직(흐름 받기) */
|
||||
/** 다른 로직 — 마스터 로직(흐름 받기) */
|
||||
const getLogic = async (key: string): Promise<CfLogic | null> =>
|
||||
key.startsWith("DF-") ? M.api.cfGet(key) : ((await fetchFlow(key))?.flow ?? null);
|
||||
(await fetchFlow(key))?.flow ?? null;
|
||||
|
||||
const left = (): void => {
|
||||
const at = opts.leftHost();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<ReturnType<typeof loadMods>>;
|
||||
|
||||
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, string | number> = {}): 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<void>; undo(): void } | null = null;
|
||||
let units: string[] | null = null;
|
||||
const mods = loadMods();
|
||||
|
||||
const mayLeave = async (): Promise<boolean> =>
|
||||
!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>): 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<void> => {
|
||||
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<string[]> => (units ??= await unitChoices());
|
||||
|
||||
/* ---------- 고치는 화면 ---------- */
|
||||
|
||||
const showPaper = async (M: Mods, first: CfLogic, saved: boolean): Promise<void> => {
|
||||
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<CfEvalOptions["logics"]> = {};
|
||||
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<CfLogic | null> =>
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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 })] });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<CfLogic | null>;
|
||||
/** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */
|
||||
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;
|
||||
|
||||
@@ -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, "돌": "깬잡석" }
|
||||
}
|
||||
]
|
||||
"줄": []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user