From 9e0746058e5ee5d90aee37deebfaed2f1ed43573 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 22:54:02 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(751-1):=20=EC=9B=90=ED=99=94=20?= =?UTF-8?q?=EA=BC=B4=20=ED=9D=A0=20=E2=80=94=20M01=20=C2=B7=20M02=20=C2=B7?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=EA=B8=80=20=E2=80=94=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=EA=BC=B4=20=EA=B0=88=EC=9D=8C=20=C2=B7=20=EC=9C=A8?= =?UTF-8?q?=20=E3=80=8C4.0=E3=80=8D=20=C2=B7=20=EC=A4=91=EA=B0=84=20?= =?UTF-8?q?=EA=B0=92=20=C2=B7=20=EB=A7=88=EB=B2=95=EC=82=AC=20=EC=BD=A4?= =?UTF-8?q?=EB=A7=88=20=C2=B7=20=EC=84=9C=EA=B3=A0=20=EA=B8=88=EC=95=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 파이썬 짝을 common_util/common_util_money.py 로 옮김(TS 와 같은 이름 · 서버 어디서나 불러옴) - 시험 계산: 율 글자 「4.0 %」 → 숫자 4(서버 _pct) · 중간 값 경비 · 공구손료 등 비목 이름은 1,000 미만도 돈 꼴(870.2455 → 870) - 기초 편집 단가 = 공용 단가 꼴 · 금액 = 소수 1 · 서고 기준 금액 · 구조물 조합 일위대가 금액 = 공용 정수 꼴(124,419.85 → 124,420) - 마법사(확인 · 고르기 · 별칭 장부 · 엑셀 · 우리 금액): 콤마 없음 · 소수 셋 기본 · 부동소수 꼬리 걷음 · 품 비교 꼬리표 지수 꼴(1.235e+05) 걷음 - 다시 잇기 · 붙여넣기 대조 · 찾아 넣기 금액 열 끝 0 없음(22.9 원이 23 으로 숨지 않음) · 흐름 로직 텍스트 수식 비목 소계 정수 - 낡은 시험 고침: test_m02_hopyo_screen 품 비교 보통인부 0.0364 → 0.03636(수량 꼴 유효숫자 넷 규칙으로 파이썬 · TS 통일) Co-Authored-By: Claude Sonnet 5.5 --- M01_MasterData/M01_MasterData_Router.py | 2 +- M01_MasterData/M01_MasterData_Store_Flow.py | 4 +++- M01_MasterData/M01_MasterData_UI_Logic_Calc.ts | 5 ++++- M01_MasterData/M01_MasterData_UI_Logic_Edit.ts | 11 +++++++---- M02_MasterTemplete/M02_Hopyo_SkPatch.py | 13 +++++++++++-- M02_MasterTemplete/M02_Hopyo_Verdict.py | 4 ++-- M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts | 2 +- .../M02_MasterTemplete_Hopyo_Check.ts | 8 ++++---- .../M02_MasterTemplete_Hopyo_CheckRows.ts | 3 ++- .../M02_MasterTemplete_Hopyo_Choose.ts | 8 ++++++-- .../M02_MasterTemplete_Hopyo_Ledger.ts | 3 ++- .../M02_MasterTemplete_Hopyo_Relink.ts | 10 +++++----- .../M02_MasterTemplete_Hopyo_Sheet.ts | 3 ++- M02_MasterTemplete/M02_MasterTemplete_ItemList.ts | 3 ++- .../M02_MasterTemplete_Labor_Paste.ts | 6 +++--- M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts | 2 +- .../M02_MasterTemplete_Library_List.ts | 4 ++-- M02_MasterTemplete/M02_MasterTemplete_Structure.ts | 3 ++- .../common_util_money.py | 0 common_util/common_util_money.ts | 2 +- resources/tester/test_588_2_money_fmt.py | 6 +++--- resources/tester/test_m02_hopyo_screen.py | 4 ++-- ui_template/ui_template_group_cards.ts | 4 ++-- ui_template/ui_template_item_picker.ts | 4 ++-- ui_template/ui_template_item_picker_detail.ts | 3 ++- 25 files changed, 72 insertions(+), 45 deletions(-) rename resources/master_data/scripts/master_fmt.py => common_util/common_util_money.py (100%) diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 724023a68..eafc850b1 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -266,7 +266,7 @@ def _pct(got: dict, body: CalcBody) -> dict: except store.cm.mf.FormulaError: return got lines = labor_doc.pct_extras(whole, logic, got["lines"], got.get("middle") or {}) - return {**got, "lines": lines} + return {**got, "lines": _num(lines)} # 율 Decimal(4.0) 이 글자 「4.0」 으로 나가지 않게 @router.post("/text") diff --git a/M01_MasterData/M01_MasterData_Store_Flow.py b/M01_MasterData/M01_MasterData_Store_Flow.py index 34e111133..fecdbbcc5 100644 --- a/M01_MasterData/M01_MasterData_Store_Flow.py +++ b/M01_MasterData/M01_MasterData_Store_Flow.py @@ -19,6 +19,7 @@ from contextvars import ContextVar from decimal import Decimal, InvalidOperation from typing import Any +from common_util import common_util_money as mfm from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Make as make from M01_MasterData import M01_MasterData_Store_Revise as revise @@ -265,7 +266,8 @@ def text(key: str, inputs: dict, row: dict | None = None) -> dict | None: groups: dict[str, list] = {} for o in got["outputs"]: value = o["value"] - shown = f"{value:,}" if isinstance(value, (int, Decimal)) else str(value) + # 비목 소계 = 정수 · 그 밖 = 수량 꼴 + shown = (mfm.fmt_money if o["cost"] else mfm.fmt)(value) line = {"이름": o["name"], "글": shown, "이름글": o["name"], "금액": value, "조각": []} groups.setdefault(o["cost"] or "값", []).append(line) out = [{"비목": k, "줄": v} for k, v in groups.items()] diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index 6bfa49452..289a819d4 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -213,6 +213,9 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void { ); } +/** 중간 값 이름이 돈 꼴(경비 · 노무비 · 공구손료 · 금액 · 단가 · 노임) — 1,000 미만이어도 돈 꼴 */ +const MONEY_NAME = /(비|료|액|가|임)$/; + /** `money` = 결과가 돈(원) — 그때만 결과 값도 정수 · 수량·비율 결과는 소수 그대로 */ export function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLElement[] { const note = draft @@ -303,7 +306,7 @@ export function answerView(answer: CalcAnswer, draft: boolean, money: boolean): el("dt", { text: k }), el("dd", { text: - money && typeof v === "number" && Math.abs(v) >= 1000 + money && typeof v === "number" && (Math.abs(v) >= 1000 || MONEY_NAME.test(k)) ? formatMoney(v) : formatNumber(v), }), diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts b/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts index d706e0d10..3e4a14832 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts @@ -20,7 +20,7 @@ import { tx } from "./M01_MasterData_UI_Logic_Text"; import { createBadge } from "@ui/ui_template_badge"; import { createText } from "@ui/ui_template_text"; import { createIconButton } from "@ui/ui_template_button"; -import { formatNumber } from "../common_util/common_util_money"; +import { formatLineMoney, formatNumber, formatPrice } from "../common_util/common_util_money"; export type PickMode = "element" | "table"; export type PickDone = (item: ElementBrief, column?: string) => void; @@ -272,14 +272,14 @@ function inputsSection( function priceCell(ctx: EditContext, item: HoLine, i: number): HTMLElement { const line = ctx.lines?.[i]; - if (line) return el("span", { className: "m01-logic__money", text: formatNumber(line.단가) }); + if (line) return el("span", { className: "m01-logic__money", text: formatPrice(line.단가) }); if (item.종류 === "로직" || item.요소.includes("{")) { return el("span", { className: "ui-text ui-text--muted", text: tx("Ho_PriceLater") }); } const brief = ctx.prices[item.요소]; if (brief === undefined) return el("span", { className: "ui-text ui-text--muted", text: "" }); if (brief === null) return createText(tx("Ho_Missing"), "error"); - return el("span", { className: "m01-logic__money", text: formatNumber(brief.값) }); + return el("span", { className: "m01-logic__money", text: formatPrice(brief.값) }); } function hoSection( @@ -343,7 +343,10 @@ function hoSection( textBox(item.단위 ?? "", (v) => set(() => (item.단위 = v)), "m01-logic__unit"), el("div", { className: "m01-logic__pair", children: [tag, qty, table] }), priceCell(ctx, item, i), - el("span", { className: "m01-logic__money", text: formatNumber(ctx.lines?.[i]?.금액 ?? "") }), + el("span", { + className: "m01-logic__money", + text: formatLineMoney(ctx.lines?.[i]?.금액 ?? ""), + }), cost, dropButton(() => { list.splice(i, 1); diff --git a/M02_MasterTemplete/M02_Hopyo_SkPatch.py b/M02_MasterTemplete/M02_Hopyo_SkPatch.py index ef390969d..55f6a2fe5 100644 --- a/M02_MasterTemplete/M02_Hopyo_SkPatch.py +++ b/M02_MasterTemplete/M02_Hopyo_SkPatch.py @@ -29,6 +29,7 @@ import re from decimal import Decimal from typing import Any +from common_util import common_util_money as money_fmt from M02_MasterTemplete import M02_Hopyo_Combine as combine from M02_MasterTemplete import M02_Hopyo_Elem as elem from M02_MasterTemplete import M02_Hopyo_Finger as finger @@ -227,14 +228,22 @@ class _Plan: for x in rows: self.drop(x["cid"]) if want < -TINY: - self.miss("노임", t, a, f"엑셀 {a:.6g} · 더한 기초만 {f:.6g}", 단위="인") + self.miss( + "노임", + t, + a, + f"엑셀 {money_fmt.fmt(a)} · 더한 기초만 {money_fmt.fmt(f)}", + 단위="인", + ) elif not rows: if t in self.lb: unit = "인" self.add({"종류": "인력", "요소": self.lb[t], "이름": t, "단위": unit, "수량": _num(want * self.tb)}) # fmt: skip else: - self.miss("노임", t, want, f"엑셀 {a:.6g} — 우리 인력에 없음", 단위="인") + self.miss( + "노임", t, want, f"엑셀 {money_fmt.fmt(a)} — 우리 인력에 없음", 단위="인" + ) elif len(rows) == 1 or not b: self.cover(rows[0]["cid"], qty=want * self.tb) for x in rows[1:]: diff --git a/M02_MasterTemplete/M02_Hopyo_Verdict.py b/M02_MasterTemplete/M02_Hopyo_Verdict.py index 000f5b706..54cca597f 100644 --- a/M02_MasterTemplete/M02_Hopyo_Verdict.py +++ b/M02_MasterTemplete/M02_Hopyo_Verdict.py @@ -12,6 +12,7 @@ from __future__ import annotations from typing import Any +from common_util import common_util_money as money_fmt from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis from M02_MasterTemplete import M02_Hopyo_Combine as combine from M02_MasterTemplete import M02_Hopyo_Finger as finger @@ -20,8 +21,7 @@ from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Option as option -def _num(x: float) -> str: - return f"{x:.4g}" +_num = money_fmt.fmt # 꼬리표 수 — 공용 수량 꼴(1만 넘어도 지수 꼴 아님) def excel_qty(efp: dict[str, Any]) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts index 21852349c..5130636fc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts @@ -175,7 +175,7 @@ export function openFindPicker(opt: FindPickOptions): void { { key: "품명", label: tc("Std_Name"), width: 200 }, { key: "규격", label: tc("Std_Spec"), width: 110 }, { key: "단위", label: tc("Std_Unit"), width: 44 }, - { key: "금액", label: tc("Std_Money"), kind: "number", width: 84 }, + { key: "금액", label: tc("Std_Money"), kind: "number", decimals: 1, trim: true, width: 84 }, { key: "구분", label: L("M02_FindClass"), width: 180 }, { key: "출처", label: tc("Std_Source"), width: 120 }, ], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index c756a5617..e9a35acef 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -53,6 +53,7 @@ import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; import { draftDoc } from "./logic/M02_MasterTemplete_Hopyo_Draft"; import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem"; import { openLeftOf } from "./M02_MasterTemplete_Hopyo_Left"; +import { formatNumber, formatPrice } from "../common_util/common_util_money"; import "./M02_MasterTemplete_Hopyo.css"; export const POLL_MS = 3000; @@ -161,8 +162,7 @@ export function leftOut(lines: HopyoLine[], d: CheckDraft): Record - v === undefined ? "–" : String(Math.round(v * 10000) / 10000); +const n4 = (v: number | undefined): string => (v === undefined ? "–" : formatNumber(v)); /** 품 비교 글 — 「콘크리트공 0.1091 → 0.1091 ✓ · 굴착기 0.29 → 0.31 ≠」 */ export function compareText(q: HopyoMatch["품"] | undefined): string { @@ -201,7 +201,7 @@ export const sureLines = (lines: HopyoLine[], d: CheckDraft): HopyoLine[] => const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const won = (v: number | null | undefined): string => - v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); + v === null || v === undefined ? "" : formatPrice(v); const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error"); const note = (text: string): HTMLElement => el("span", { className: "ui-text ui-text--muted", text, attrs: { "data-text": "14" } }); @@ -467,7 +467,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { children: [ td(x.이름), td(x.규격), - td(x.수량 === null ? "" : String(x.수량), "m02-hopyo__num"), + td(x.수량 === null ? "" : formatNumber(x.수량), "m02-hopyo__num"), td(x.단위), td(won(x.금액), "m02-hopyo__num"), td(x.까닭), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts index 6f93225ef..8b90df4f8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts @@ -26,6 +26,7 @@ import type { LaborCalc } from "./M02_MasterTemplete_Labor_Api"; import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks"; import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { formatPrice } from "../common_util/common_util_money"; import { tableOf } from "./logic/M02_MasterTemplete_Hopyo_Draft"; /** 드롭다운 — 저장 값(서버 뺌 까닭) → 화면 글 */ @@ -248,7 +249,7 @@ function matRow(line: HopyoLine, ctx: RowsContext): HTMLElement { const first = mats[0]; const ours = mats.length ? mats.reduce((s, m) => s + (m.반영 ?? 0), 0) : null; const show = (v: number | null | undefined): string => - wait ? "…" : v === null || v === undefined ? "—" : v.toLocaleString("ko-KR"); + wait ? "…" : v === null || v === undefined ? "—" : formatPrice(v); const p = parts[0]; return rowOf(line, ctx, [ el("td", { className: "m02-labrow__no", text: String(line.차례 + 1) }), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts index a9db164a4..7ee3b84dc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts @@ -24,6 +24,7 @@ import type { } from "./M02_MasterTemplete_Hopyo_Api"; import { getActiveLayer, getActiveOwner } from "./M02_MasterTemplete_Api_Fetch"; import { type FindKind, openFindPicker } from "./M02_MasterTemplete_Find_Pick"; +import { formatNumber } from "../common_util/common_util_money"; import "./M02_MasterTemplete_Hopyo.css"; const MATERIAL = new Set(["자재", "별도계상"]); @@ -34,7 +35,7 @@ const FOLD_TEXT: Record = { const PRE_TIP = "미리 고름 — 엑셀 인용 조항 = 1위 기초 조항 · 누르기는 사용자"; const note = (text: string): HTMLElement => el("span", { className: "ui-text ui-text--muted", text, attrs: { "data-text": "14" } }); -const num = (v: number): string => String(Math.round(v * 10000) / 10000); +const num = formatNumber; /** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */ export const targetText = (t: HopyoTarget | null | undefined): string => { @@ -52,7 +53,10 @@ export const inputsText = ( ): string => [ Object.entries(values) - .map(([k, v]) => `${k}=${String(v)}${from[k] && from[k] !== "자동값" ? `(${from[k]})` : ""}`) + .map( + ([k, v]) => + `${k}=${formatNumber(v)}${from[k] && from[k] !== "자동값" ? `(${from[k]})` : ""}`, + ) .join(", "), unread.length ? `⚠ 못 가름: ${unread.join(", ")}` : "", ] diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts index 523be4af8..33cde0fb8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts @@ -27,6 +27,7 @@ import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; import { dateTimeText } from "../common_util/common_util_time"; import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; +import { formatNumber } from "../common_util/common_util_money"; import "./M02_MasterTemplete_Hopyo.css"; export const LEDGER_TITLE = "별칭 장부"; @@ -108,7 +109,7 @@ export const pairText = (p: LedgerPair): string => `${p.로직 ?? p.자재 ?? ""} × ${p.배}`, p.몫 != null ? `몫 ${p.몫}` : "", Object.entries(p.입력값 ?? {}) - .map(([k, v]) => `${k}=${String(v)}`) + .map(([k, v]) => `${k}=${formatNumber(v)}`) .join(", "), ] .filter(Boolean) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts index 8f038fb87..013177e1f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts @@ -22,6 +22,7 @@ import { } from "./M02_MasterTemplete_Hopyo_Api"; import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api"; import { lineId } from "./M02_MasterTemplete_Labor_Calc"; +import { formatMoney } from "../common_util/common_util_money"; type Kind = "노임" | "자재"; const COST: Record = { 노임: "노무비", 자재: "재료비" }; @@ -37,8 +38,7 @@ export interface RelinkSource { price?: (kind: Kind, key: string) => number | null | undefined; } -const won = (v: number | null | undefined): string => - v == null ? "-" : Math.round(v).toLocaleString("ko-KR"); +const won = (v: number | null | undefined): string => (v == null ? "-" : formatMoney(v)); /** 사전조합 여럿(붙여넣기 · 실무자료 들여오기 뒤) — 서버가 쓰고 전후 1 단위 값 */ export function relinkKeys(keys: string[]): RelinkSource { @@ -152,11 +152,11 @@ export async function openRelink(src: RelinkSource): Promise { { key: "pick", label: "고름", width: 44 }, { key: "unit", label: "단위", width: 44 }, { key: "qty", label: "수량", kind: "number", decimals: 4, trim: true, width: 60 }, - { key: "pasted", label: "붙인 단가", kind: "number", decimals: 1, width: 84 }, + { key: "pasted", label: "붙인 단가", kind: "number", decimals: 3, trim: true, width: 84 }, { key: "now", label: src.priceLabel, width: 84 }, { key: "cand", label: "이음 후보 (누름 = 다음)", width: 210 }, - { key: "price", label: "후보 단가", kind: "number", decimals: 1, width: 84 }, - { key: "gap", label: "금액 차이", kind: "number", decimals: 1, width: 84 }, + { key: "price", label: "후보 단가", kind: "number", decimals: 3, trim: true, width: 84 }, + { key: "gap", label: "금액 차이", kind: "number", decimals: 1, trim: true, width: 84 }, ], rows: data, frozen: 1, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index e585953e5..b9316c596 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -58,6 +58,7 @@ import { import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose"; import { openRelink, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink"; import { leftButton } from "./M02_MasterTemplete_Hopyo_Left"; +import { formatPrice } from "../common_util/common_util_money"; import "./M02_MasterTemplete_Hopyo.css"; export interface HopyoSheetOptions { @@ -86,7 +87,7 @@ const DANSAN = "단가산출"; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const won = (v: number | null | undefined): string => - v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); + v === null || v === undefined ? "" : formatPrice(v); const note = (text: string): HTMLElement => el("span", { className: "ui-text ui-text--muted", text, attrs: { "data-text": "14" } }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts index a9d139738..5dc909f03 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -16,6 +16,7 @@ import "../M01_MasterData/M01_MasterData_UI_Style.css"; import { fetchTree, type Layer, type TreeItem } from "./M02_MasterTemplete_Api_Fetch"; import { folderOf, leafName, matchText } from "./M02_MasterTemplete_TreePanel_Model"; import { createEmpty } from "@ui/ui_template_empty"; +import { formatMoney } from "../common_util/common_util_money"; /** 목록 종류 — 하위 컨테이너 셋 + 항목 통째(복사 생성) */ export type ListKind = "structure" | "area" | "labor" | "item"; @@ -54,7 +55,7 @@ const SIZE = 30; const DASH = "—"; const day = (iso?: string): string => dateText(iso, ""); const money = (value: number | null | undefined): string => - typeof value === "number" ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) : DASH; + typeof value === "number" ? formatMoney(value) : DASH; /** 가장 늦게 고친 날 — 항목 통째 */ const latest = (row: ItemRow): string => day( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Paste.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Paste.ts index cf00010b3..9b1f8242e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Paste.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Paste.ts @@ -23,6 +23,7 @@ import { import { openRelink, relinkDoc, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink"; import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api"; import { lineId } from "./M02_MasterTemplete_Labor_Calc"; +import { formatMoney } from "../common_util/common_util_money"; const COLS_KEY = "aislo:hopyo-paste-cols"; const FIELDS = ["품명", "규격", "수량", "단위", "금액", "노", "재", "경"] as const; @@ -40,8 +41,7 @@ const NEED = new Set(["품명", "수량", "단위"]); const letter = (n: number): string => n < 26 ? String.fromCharCode(65 + n) : `${letter(Math.floor(n / 26) - 1)}${letter(n % 26)}`; -const won = (v: number | null | undefined): string => - v == null ? "-" : Math.round(v).toLocaleString("ko-KR"); +const won = (v: number | null | undefined): string => (v == null ? "-" : formatMoney(v)); export function rememberedCols(): PasteCols | null { try { @@ -169,7 +169,7 @@ export function builtNotice(got: HopyoBuiltAll, keys: string[]): void { { key: "name", label: "합계 다른 호표", width: 260 }, { key: "excel", label: "엑셀", kind: "number" }, { key: "ours", label: "우리", kind: "number" }, - { key: "gap", label: "차이", kind: "number", decimals: 1 }, + { key: "gap", label: "차이", kind: "number", decimals: 1, trim: true }, ], rows, }).root, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 98962e502..85fc55196 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -154,7 +154,7 @@ export function openLogicPicker( { key: "품명", label: tc("Std_Name"), width: 220 }, { key: "규격", label: tc("Std_Spec"), width: 220 }, { key: "단위", label: tc("Std_Unit"), width: 56 }, - { key: "금액", label: tc("Std_Money"), kind: "number", width: 96 }, + { key: "금액", label: tc("Std_Money"), kind: "number", decimals: 1, trim: true, width: 96 }, { key: "출처", label: tc("Std_Source"), width: 150 }, ], rows: [], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts index d0f324a4a..e1a92ede2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts @@ -34,6 +34,7 @@ import { type TemplateInfo, } from "./M02_MasterTemplete_Api_Fetch"; import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { formatMoney } from "../common_util/common_util_money"; /** 목록 줄 — 서버가 문서 `이름`(제목) · 단위 · 1 단위 기준 계를 실어 보냄 */ type Row = TemplateInfo & { @@ -63,8 +64,7 @@ export interface LibraryListOptions { const SIZE = 30; const why = (error: unknown): string => (error instanceof Error ? error.message : ""); -const money = (v: number | null | undefined): string => - v == null ? "" : v.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); +const money = (v: number | null | undefined): string => (v == null ? "" : formatMoney(v)); export const libraryLabel = (row: Row): string => (row.제목 ? `${row.이름} ${row.제목}` : row.이름); /** 새 단가산출 몸(717-2a) — 바탕 없음 · 빈 더한줄 · 단위 「식」(서버 필수 · 상세에서 고침 · 717-2b) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 50396c26a..7e61ac2ac 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -40,6 +40,7 @@ import { } from "./M02_MasterTemplete_ItemCards"; import { layerSource } from "./M02_MasterTemplete_ItemSource"; import { pickHere, pickedName, type From } from "./M02_MasterTemplete_ItemPick"; +import { formatMoney } from "../common_util/common_util_money"; import "./M02_MasterTemplete_Structure.css"; export type { CardKind, EditKind, SheetKind } from "./M02_MasterTemplete_ItemCards"; @@ -76,7 +77,7 @@ export type ColumnNumbers = Record; /** 집계표 「일위대가 합계」 줄 글(86-3) — 반영합 · 없으면 기준합 · 계산값 없음 = 빈 글(「—」) · 색은 `laborSumState` */ export function laborSumText(numbers: StructureNumbers | undefined): string { if (typeof numbers?.일위대가합계 !== "number") return ""; - return numbers.일위대가합계.toLocaleString("ko-KR", { maximumFractionDigits: 0 }); + return formatMoney(numbers.일위대가합계); } /** 합계 칸 색 상태 · 툴팁 까닭(101-1) — 계산값 없음 · 담은 것 없음 = 색 없음 */ diff --git a/resources/master_data/scripts/master_fmt.py b/common_util/common_util_money.py similarity index 100% rename from resources/master_data/scripts/master_fmt.py rename to common_util/common_util_money.py diff --git a/common_util/common_util_money.ts b/common_util/common_util_money.ts index abbf4fa2c..7a1d3759a 100644 --- a/common_util/common_util_money.ts +++ b/common_util/common_util_money.ts @@ -6,7 +6,7 @@ * formatPrice 단가 — 200 원 미만 = 유효숫자 넷 · 그 밖 = 최대 소수 3 · 끝 0 뗌 * formatNumber 수량 · 율 · 품 — 소수 넷째 자리 · 유효숫자 넷 중 많은 쪽 · 끝 0 뗌 * 값이 숫자가 아니면 빈 값 = 「」 · 객체 = JSON 글 · 그 밖 = String. - * 짝: `resources/master_data/scripts/master_fmt.py` (거울 시험 `resources/tester/test_588_2_money_fmt.py`). + * 짝: `common_util/common_util_money.py` (거울 시험 `resources/tester/test_588_2_money_fmt.py`). * ========================================================================== */ const LOCALE = "ko-KR"; diff --git a/resources/tester/test_588_2_money_fmt.py b/resources/tester/test_588_2_money_fmt.py index 2a26b5749..0598a951b 100644 --- a/resources/tester/test_588_2_money_fmt.py +++ b/resources/tester/test_588_2_money_fmt.py @@ -1,4 +1,4 @@ -"""원화 꼴 한 벌 거울 시험 — `master_fmt.py`(파이썬) ↔ `common_util_money.ts`(TS · Node) 가 같은 글. +"""원화 꼴 한 벌 거울 시험 — `common_util_money.py`(파이썬) ↔ `common_util_money.ts`(TS · Node) 가 같은 글. CLAUDE.md 5장 ① 짝. 값은 숫자 글(`num`) · 숫자 아닌 글(`text`) · 빈 값(None). """ @@ -10,9 +10,9 @@ import sys from decimal import Decimal HERE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, os.path.join(HERE, "..", "master_data", "scripts")) +sys.path.insert(0, os.path.join(HERE, "..", "..")) -import master_fmt as mf # noqa: E402 +from common_util import common_util_money as mf # noqa: E402 NUMS = [ "0", "1", "2.5", "-1234.5", "1.00005", "0.12345", "0.00167", "22.9", "1846.39", diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index a6710daec..8d49cad8a 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -413,13 +413,13 @@ def test_실물_응답_대역_확인_창(): assert remicon["상태"] == "맞음" and remicon["checked"] and remicon["checkable"] assert ( remicon["compare"] - == "콘크리트공 0.1091 → 0.1091 ✓ · 보통인부 0.0364 → 0.0364 ✓ · 굴착기 0.29 → 0.2909 ✓" + == "콘크리트공 0.1091 → 0.1091 ✓ · 보통인부 0.03636 → 0.03636 ✓ · 굴착기 0.29 → 0.2909 ✓" ) assert ( remicon["inputs"] == "타설방법=장비사용타설(규격), 구조물=철근구조물(규격), 소량감률=50(블록)" ) - assert remicon["qty"] == "콘크리트공 0.1091 · 보통인부 0.0364 · 굴착기 0.29hr" + assert remicon["qty"] == "콘크리트공 0.1091 · 보통인부 0.03636 · 굴착기 0.29hr" assert remicon["cands"][0][1] == "맞음" and remicon["cands"][0][2].startswith("조항 건설품셈") rebar = by["철근가공조립(현장)"] assert rebar["상태"] == "다른 점" and not rebar["checked"] and rebar["checkable"] diff --git a/ui_template/ui_template_group_cards.ts b/ui_template/ui_template_group_cards.ts index 70327f2ba..54ed26f97 100644 --- a/ui_template/ui_template_group_cards.ts +++ b/ui_template/ui_template_group_cards.ts @@ -13,6 +13,7 @@ import "./ui_template_group_cards.css"; import { createButton } from "./ui_template_elements"; +import { formatLineMoney } from "../common_util/common_util_money"; /** 비목 셋 — 노무 · 재료 · 경비 */ export type GroupParts = [number | null, number | null, number | null]; @@ -81,9 +82,8 @@ export const PART_LABELS = ["노무", "재료", "경비"] as const; export const clampColumns = (value: unknown): number => GROUP_COLUMNS.includes(value as 1) ? (value as number) : 2; -const won = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 1 }); const plain = (value: number | null | undefined): string => - value === null || value === undefined || !Number.isFinite(value) ? "" : won.format(value); + value === null || value === undefined || !Number.isFinite(value) ? "" : formatLineMoney(value); /** 줄 비목 풍선 — 「노무 1,234 · 재료 0 · 경비 56」 · 셋 다 빈칸 = 없음 */ export function partsTip( diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts index d80b39ab3..16d599191 100644 --- a/ui_template/ui_template_item_picker.ts +++ b/ui_template/ui_template_item_picker.ts @@ -53,6 +53,7 @@ import { createEditView, type EditView } from "./ui_template_item_picker_edit"; import { createComboTable } from "./ui_template_item_picker_table"; import { createToolbar } from "./ui_template_toolbar"; import { createText } from "./ui_template_text"; +import { formatMoney } from "../common_util/common_util_money"; export type { SummaryItem } from "./ui_template_item_picker_data"; @@ -212,8 +213,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise - n === null ? "" : ` · ${n.toLocaleString("ko-KR", { maximumFractionDigits: 0 })}`; + const sum = (n: number | null): string => (n === null ? "" : ` · ${formatMoney(n)}`); const buttons = spec.열.map((col) => { const button = createButton({ label: `${spec.이름}=${col.이름}${sum(col.합계)}`, diff --git a/ui_template/ui_template_item_picker_detail.ts b/ui_template/ui_template_item_picker_detail.ts index ada74e3d8..9553767a2 100644 --- a/ui_template/ui_template_item_picker_detail.ts +++ b/ui_template/ui_template_item_picker_detail.ts @@ -15,6 +15,7 @@ import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements"; import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import { formatMoney } from "../common_util/common_util_money"; import { dateText } from "../common_util/common_util_time"; import { calcLabor, type LaborDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards"; @@ -239,7 +240,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { async function paintCalc(from: ProjectItemInfo, mine: number): Promise { const got = await calcCopy(from.폴더).then(summarize, () => null); if (!got || mine !== seq) return; - const total = Math.round(got.total).toLocaleString(); + const total = formatMoney(got.total); status.append( el("span", { className: "ui-item-picker__calc", From 1288e21e55e0ab6b93406d3197261e4d8ba8b2a7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 22:58:28 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(756-1):=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=84=A0=20=EC=A7=84=ED=95=98=EA=B2=8C=20=C2=B7=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=20=ED=95=9C=20=EC=95=84=EC=9D=B4=EC=BD=98=20=C2=B7=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=EB=85=B8=EC=84=A0=20=EC=97=AC=EB=9F=BF=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=C2=B7=20=EC=BC=A0=20=EB=8F=99?= =?UTF-8?q?=EC=95=88=20=EB=93=B1=EA=B3=A0=EC=84=A0=20=EB=9D=A0=20=EB=84=93?= =?UTF-8?q?=ED=9E=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 예정노선 수정 켬에도 추천 선 불투명 그대로(Render) - 스냅 + 스냅 간격 = 고르기 메뉴 하나(측점 No. · 10 m · 끔) · 도구줄 표 snap-step 줄 걷음 - 추천노선 = 여럿 고르기(공용 부품 multi · 줄 색 점 PickMenuItem.color · 끔 줄 없음) · 선 색 --draw-map-rec-1~4 · 예정노선 가이드 선은 켬끔 한 줄 - 켠 동안 등고선 띠 = LAS 는 자르지 않음 · 도엽은 계획선 + 예정노선으로 넓힘(캔버스 · GL · 도엽 다시 받기) - 시험 624_2 · 634_2 · 626_1 · 636_2 · 390_4 · b05_routeedit_initial 을 새 꼴로 고침 · 새 시험 756_1 Co-Authored-By: Claude Sonnet 5.5 --- A00_Common/map/map_route_preview.ts | 11 ++-- A00_Common/map/map_toolbar.ts | 9 ++- B05_Profile/B05_Profile_UI_RouteEdit.ts | 22 ++++--- .../B05_Profile_UI_RouteEdit_Chrome.ts | 28 ++++---- B05_Profile/B05_Profile_UI_RouteEdit_Gl.ts | 4 +- B05_Profile/B05_Profile_UI_RouteEdit_Input.ts | 32 ++++++++++ .../B05_Profile_UI_RouteEdit_RecPreview.ts | 64 +++++++++++++------ .../B05_Profile_UI_RouteEdit_Render.ts | 10 ++- .../B05_Profile_UI_RouteEdit_SnapStep.ts | 40 ++++-------- .../B05_Profile_UI_Style_RouteEdit.css | 6 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_Guide.ts | 7 +- resources/tester/test_390_4_snap_step_text.py | 3 +- resources/tester/test_624_2_map_parts.py | 2 - .../tester/test_626_1_recommend_preview.py | 2 +- resources/tester/test_634_2_hover_table.py | 4 +- resources/tester/test_636_2_guide_screen.py | 4 +- resources/tester/test_756_1_contour_band.py | 63 ++++++++++++++++++ .../tester/test_b05_routeedit_initial.py | 8 +-- ui_template/ui_template_context_menu.css | 9 +++ ui_template/ui_template_context_menu.ts | 8 +++ ui_template/ui_template_theme.css | 4 ++ ui_template/ui_template_ui_kit_parts.ts | 11 ++-- 22 files changed, 244 insertions(+), 107 deletions(-) create mode 100644 resources/tester/test_756_1_contour_band.py diff --git a/A00_Common/map/map_route_preview.ts b/A00_Common/map/map_route_preview.ts index a2676335c..50d3796f9 100644 --- a/A00_Common/map/map_route_preview.ts +++ b/A00_Common/map/map_route_preview.ts @@ -1,7 +1,8 @@ /* ============================================================================= * map_route_preview.ts * 공용 「추천노선」 미리보기(PLAN 626-1 → 636-2 공용 · 요구 530 · 546) — B05 평면 · 예정노선(D03)이 같이 씀. - * 메뉴 = 도구줄 아이콘 하나(compass · 호버 「추천노선」) + 아래 판(항목 하나만 · 맨 끝 「끔」 · 부품 `createPickMenu`). + * 메뉴 = 도구줄 아이콘 하나(compass · 호버 「추천노선」) + 아래 판(여럿 고르기 · 줄마다 색 점 · 「끔」 줄 없음 · + * 다 끄면 꺼짐 · 부품 `createPickMenu({ multi })` · 589-12). * 그리기 = 지도 위 선(노랑 `--draw-map-sketch` · 검은 테 · 기본 점선) — 위성 · 어두운 바탕 모두 보임. * 항목 · 선 셈은 페이지 몫: B05 = 노선 추천 저장본 넷(`B05_Profile_UI_RouteEdit_RecPreview`) · * 예정노선 = 가이드 선(`D03_PlanAnalysis_UI_Guide`). @@ -12,17 +13,17 @@ import { themeColor } from "@ui/ui_template_palette"; type Vertex = [number, number] | [number, number, number]; -/** 「추천노선」 메뉴 — 고름 = `onPick(값)` · 「끔」 = `onPick(null)`. */ +/** 「추천노선」 메뉴 — 줄 누름 = `onPick(값)`(켬끔 바뀜 · 지금 켠 줄은 `menu.values()`). */ export function createRoutePreviewMenu(opts: { items: PickMenuItem[]; - onPick: (value: string | null) => void; + onPick: (value: string) => void; }): PickMenu { const menu = createPickMenu({ + multi: true, icon: "compass", title: "추천노선", // 마우스 글 = 공용 표 호버제목(634-2 · `mapToolGroups` 가 맞춤) items: opts.items, - offLabel: "끔", - onPick: opts.onPick, + onPick: (value) => value && opts.onPick(value), }); menu.root.dataset.act = "recommend"; return menu; diff --git a/A00_Common/map/map_toolbar.ts b/A00_Common/map/map_toolbar.ts index 031d60e95..60286a277 100644 --- a/A00_Common/map/map_toolbar.ts +++ b/A00_Common/map/map_toolbar.ts @@ -12,7 +12,7 @@ import { mapToolButton, mapToolTip, type MapToolAct } from "@ui/ui_template_map_tools"; import type { MapPage } from "./map_layer_list"; -export type MapToolKey = MapToolAct | "hint-toggle" | "snap-step" | "recommend" | "terrain"; +export type MapToolKey = MapToolAct | "hint-toggle" | "recommend" | "terrain"; export interface MapToolRow { key: MapToolKey; @@ -52,16 +52,15 @@ export const MAP_TOOLBAR: readonly (readonly MapToolRow[])[] = [ { key: "station-snap", title: "스냅", - does: "곡선 가운데(MC)를 간격 배수에 맞춤", + does: "곡선 가운데(MC)를 간격 배수에 맞춤 · 고르기 메뉴 하나(측점 No. · 10 m · 끔 · 589-4)", pages: B05, }, - { key: "snap-step", title: "스냅 간격", does: "스냅 간격 고르기(고르기 메뉴)", pages: B05 }, ], [ { key: "recommend", title: "추천노선", - does: "B05 = 노선 추천 저장본 선 하나만 · 예정노선 = 가이드 선(지점 잇기 · 636-2) · 끔", + does: "B05 = 노선 추천 저장본 선 여럿 고르기(색 구분) · 예정노선 = 가이드 선(지점 잇기 · 636-2)", pages: BOTH, }, ], @@ -75,7 +74,7 @@ export const mapToolTitle = (key: MapToolKey): string => ROWS.find((row) => row.key === key)?.title ?? key; const isAct = (key: MapToolKey): key is MapToolAct => - key !== "hint-toggle" && key !== "snap-step" && key !== "recommend" && key !== "terrain"; + key !== "hint-toggle" && key !== "station-snap" && key !== "recommend" && key !== "terrain"; /** 그 페이지 도구줄 묶음 — 넘긴 요소 · 기본 단추(단추 열쇠만) · 마우스 글 = 표 · 빈 묶음 뺌. */ export function mapToolGroups( diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 829023d60..20c5f1a06 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -31,6 +31,7 @@ import { type RouteEditContours, type SheetLayers, } from "./B05_Profile_UI_RouteEdit_Contour"; +import { contourBandLine, editingExpected } from "./B05_Profile_UI_RouteEdit_Input"; import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock"; import { fetchRoutePlan, @@ -75,7 +76,7 @@ import { } from "./B05_Profile_UI_RouteEdit_History"; import type { DesignPageHost } from "../A00_Common/design/design_page"; import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft"; -import { bindStationSnap, paintStationSnap } from "./B05_Profile_UI_RouteEdit_SnapStep"; +import { bindStationSnap } from "./B05_Profile_UI_RouteEdit_SnapStep"; import * as fit from "./logic/B05_Profile_RouteEdit_StationFit"; import { calcChecked } from "../A00_Common/design/design_calc_server"; import "./B05_Profile_UI_Style_RouteEdit.css"; @@ -116,7 +117,7 @@ export async function mountRouteEditor( const stationIntervalM = options.stationIntervalM ?? 20; const chrome = createRouteEditChrome(parent, projectId, options.tools, host); const { overlay, canvas, status, measureBox, measureText, measureButton } = chrome; - const { minorContourButton, stationSnapButton } = chrome; + const { minorContourButton } = chrome; chrome.onRedraw(() => draw()); // 겹 켬끔 · 추천 보기 = 장면 새로(624-3) const context = canvas.getContext("2d")!; const expectedDraw = createExpectedDraw({ @@ -129,7 +130,7 @@ export async function mountRouteEditor( pxPerMeter: () => pxPerMeter(), view: () => view, frame: () => ({ meta, setView: (next: ViewState) => (view = next) }), - redraw: () => draw(), + redraw: () => (draw(), sheetFollow?.poke()), // 켠 동안 예정노선이 도엽 밖으로 가면 다시 받음(589-10) onOpen: () => ((picked = -1), syncCurveBar()), onFollow: (flat, line) => restoreSnapshot( @@ -168,14 +169,13 @@ export async function mountRouteEditor( /** [측점 스냅](224-2 · 366-3) — 켬 = 곡선 MC 를 고른 간격 배수에 · 켬끔 자체는 되돌리기 밖 · [저장] 때 저장. */ let stationSnap = false; /** 간격 · 저장 비교 · 편집 창 측점(끔이면 null · 그리기 · 누름 · 기울기가 봄)은 `_SnapStep` 몫(390-2 · 5). */ - const snap = bindStationSnap(chrome, projectId, stationIntervalM, (toggle) => { - if (toggle) setStationSnap(!stationSnap); + const snap = bindStationSnap(chrome, stationIntervalM, () => { + stationSnap = snap.on(); if (stationSnap && history) applyEdit(snap.text()); // 전체 · 한 걸음 tellDraft(); // 끔 · 간격 바꿈도 계산 줄에 바로(430) draw(); }); const stations = () => snap.stations(plannedLine.length ? plannedLine : planned, curveInfo); - const setStationSnap = (on: boolean) => paintStationSnap(stationSnapButton, (stationSnap = on)); /** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */ let history: RouteEditHistory | null = null; let meta: VWorldMeta | null = null; @@ -599,7 +599,13 @@ export async function mountRouteEditor( try { const [plan, drainage, sources] = await Promise.all([ fetchRoutePlan(projectId), - fetchRouteSheets(projectId, () => (planned.length ? planned : plannedLine)), // 띠 = 편집 노드 + fetchRouteSheets(projectId, () => + contourBandLine( + planned.length ? planned : plannedLine, + expectedDraw.line(), + editingExpected({ overAll: expectedDraw }), + ), + ), // 띠 = 편집 노드(+ 켠 동안 고치는 예정노선 · 589-10) fetchTerrainSources(projectId).catch(() => null), ]); if (closed) return; @@ -612,7 +618,7 @@ export async function mountRouteEditor( minRadiusM = plan.min_radius_m ?? 0; limitRadiusM = plan.limit_radius_m ?? 0; limitArcM = plan.limit_curve_length_m ?? 0; - setStationSnap(snap.load(plan)); + stationSnap = snap.load(plan); // 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다). const flat = edits.flattenServerPlan(nodes, plan.curves ?? [], plan.node_edits); ({ planned, curveOn, curveRadius, curveLock, curveArc } = flat); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index ec60d45b6..7ca580e44 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -65,10 +65,8 @@ export interface RouteEditChrome { measureText: HTMLElement; measureButton: HTMLButtonElement; minorContourButton: HTMLButtonElement; - /** [측점 스냅] 켬끔(요구 224-2) — `aria-pressed` · `is-active` 는 본체가 맞춤. */ - stationSnapButton: HTMLButtonElement; - /** 스냅 간격 고르기 메뉴(하나 · 634-3) — 누름 배선 = `onPick`(`_SnapStep`). */ - snapStep: PickMenu & { onPick: (fn: (value: string | null) => void) => void }; + /** 스냅 고르기 메뉴 하나(요구 224-2 · 589-4) — 측점 No. · 10 m · 끔(끔 = 스냅 꺼짐) · 배선 = `onPick`(`_SnapStep`). */ + snap: PickMenu & { onPick: (fn: (value: string | null) => void) => void }; /** 설계 지형 칩(626-1) — 지형 읽은 뒤 `load`. */ terrain: RouteTerrain; /** 추천 노선 미리보기(626-1) — 그리기 안에서 `draw`. */ @@ -130,19 +128,19 @@ export function createRouteEditChrome( `; parent.append(overlay); - // 스냅 간격 = 고르기 메뉴 하나(634-3 · 요구 546) — 누름 배선은 `_SnapStep`. - let pickSnapStep: (value: string | null) => void = () => {}; - const snapStep = createPickMenu({ - icon: "spacing", - title: "스냅 간격", + // 스냅 = 고르기 메뉴 하나(634-3 · 589-4 · 켬끔 + 간격) — 누름 배선은 `_SnapStep`. + let pickSnap: (value: string | null) => void = () => {}; + const snap = createPickMenu({ + icon: "magnet", + title: "스냅", items: [ { value: "10", label: "10 m" }, { value: "station", label: "측점 No." }, ], - onPick: (value) => pickSnapStep(value), + offLabel: "끔", + onPick: (value) => pickSnap(value), }); - snapStep.root.dataset.act = "snap-step"; - snapStep.root.classList.add("b05-routeedit__snap-step"); + snap.root.dataset.act = "station-snap"; const terrain = createRouteTerrain(projectId); let gl: RouteEditGl | null = null; let redrawScene: (() => void) | null = null; @@ -161,7 +159,7 @@ export function createRouteEditChrome( redo: mapToolButton("redo", { disabled: true }), ...(tools[0] ? { view3d: tools[0] } : {}), // 부른 쪽 단추([3D]) "contour-minor": mapToolButton("contour-minor", { disabled: true }), - "snap-step": snapStep.root, + "station-snap": snap.root, recommend: preview.root, terrain: terrain.root, }), @@ -261,7 +259,6 @@ export function createRouteEditChrome( const minorContourButton = mapTools.button("contour-minor")!; gl = createRouteEditGl(canvas, layers, host); // GL 바탕(589-2 · 그리기가 `routeGlOf` 로 찾음) Object.assign(canvas, { mapProbe: { layers: layers.state, preview: preview.state } }); // 검증 창구 - const stationSnapButton = mapTools.button("station-snap")!; bindFootprintToggle(mapTools.button("footprint")!); bindMarkToggle(mapTools.button("stations")!, "stations"); bindMarkToggle(mapTools.button("slopes")!, "slopes"); @@ -275,8 +272,7 @@ export function createRouteEditChrome( measureText, measureButton, minorContourButton, - stationSnapButton, - snapStep: Object.assign(snapStep, { onPick: (fn: typeof pickSnapStep) => (pickSnapStep = fn) }), + snap: Object.assign(snap, { onPick: (fn: typeof pickSnap) => (pickSnap = fn) }), terrain, preview, layers, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Gl.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Gl.ts index 19c94f3d4..b27be11f5 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Gl.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Gl.ts @@ -25,7 +25,7 @@ import { } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapGl_Frame"; import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour"; import type { RouteEditLayers } from "./B05_Profile_UI_RouteEdit_Layers"; -import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input"; +import { sceneContourBand } from "./B05_Profile_UI_RouteEdit_Input"; import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render"; import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve"; @@ -179,7 +179,7 @@ export function createRouteEditGl( layers.follow(meta, normalizer, toScreen, view.width, view.height); // 노선 둘레 띠만(캔버스 자르기와 같은 네모 · 돌림 · 칸 안 px) const line = (scene.plannedLine.length ? scene.plannedLine : scene.planned) as Vertex[]; - const band = scene.hasMeta ? contourBandRect(line, scene.toScreen, bandM) : null; + const band = sceneContourBand(scene, line, scene.toScreen, bandM); const corners: [number, number][] = band ? [ [band.x, band.y], diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index be18299bc..74c67a03e 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -504,6 +504,38 @@ function stationTickAtScreen( return best; } +type XY = ReadonlyArray; + +/** 예정노선 수정 켬인가 — 아래 노선 그림이 흐려진 동안(`overAll.dim` < 1 · 589-10). */ +export const editingExpected = (scene: { overAll?: { dim: () => number } }): boolean => + (scene.overAll?.dim() ?? 1) < 1; + +/** 등고선 띠를 잴 선 — 켠 동안은 계획선 + 고치는 예정노선(계획선만 보면 앞 토막 지움에 띠가 줄어듦 · 589-10). */ +export const contourBandLine = (plan: XY, expected: XY, editing: boolean): XY => + editing ? [...plan, ...expected] : plan; + +/** 화면에서 등고선을 자를 사각형 — 켠 동안 LAS 는 자르지 않음(전체 · null) · 도엽은 띠를 예정노선까지 넓힘. */ +export function sceneContourBand( + scene: { + hasMeta: boolean; + contours: { source: string } | null; + overAll?: { dim: () => number }; + expected: XY; + }, + line: XY, + toScreen: ScreenOf, + bandM: number, +): { x: number; y: number; width: number; height: number } | null { + if (!scene.hasMeta) return null; + const editing = editingExpected(scene); + if (editing && scene.contours?.source === "las") return null; + return contourBandRect( + contourBandLine(line, scene.expected, editing) as Array<[number, number]>, + toScreen, + bandM, + ); +} + /** 등고선을 보일 화면 사각형 — 노선 경계에 `bandM` 를 두른 것. 노선이 없으면 null. * * **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는 diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts b/B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts index a21b72860..def720ed0 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts @@ -1,7 +1,8 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_RecPreview.ts * 평면 노선 추천 미리보기(PLAN 626-1 · 요구 530 · 결정 D1 · D7 · D8) — 평면 도구줄 아이콘 하나 + 아래 판 - * (영선 · 토공 최소 · 기울기 최소 · 비중 계산 · 끔) · 하나만 보임 · 선만(위 캔버스 · 노선 아래 그림 · 집기 없음). + * (영선 · 토공 최소 · 기울기 최소 · 비중 계산) · 여럿 고름(589-12 · 줄마다 색 · 하나도 안 고르면 꺼짐) · + * 선만(위 캔버스 · 노선 아래 그림 · 집기 없음). * 선 = 노선 추천 저장본(`B05_Profile/route_recommend/` · 지금 설계 지형 · 체인이 미리 셈) 그대로 — 다시 셈 · 쓰기 없음 · * 저장본 없으면(409) 알림 · 끔. 옛 추천 모달 · 좌측 「노선 추천」 칸 · [적용](`as_initial`) 은 걷음(서버 길 · 시험은 남김). * 부르는 곳 = `_Chrome`(도구줄 `recommend` 칸) · `_RouteEdit`(그리기 `overStations` 앞). @@ -9,6 +10,7 @@ * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; +import { themeColor } from "@ui/ui_template_palette"; import { createRoutePreviewMenu, paintRoutePreview } from "../A00_Common/map/map_route_preview"; import type { AnalysisTerrain } from "./B05_Profile_Api_Replan"; import { fetchRouteInitial, type RouteInitialLogic } from "./B05_Profile_Api_RouteInitial"; @@ -24,12 +26,23 @@ const MODES = [ "weighted", ] as const satisfies readonly RouteInitialLogic[]; +/** 갈래별 선 색(`--draw-map-rec-1~4`) · 점선 꼴(색만으로 못 가르는 눈도 가름). */ +const REC_VARS = ["--draw-map-rec-1", "--draw-map-rec-2", "--draw-map-rec-3", "--draw-map-rec-4"]; +const REC_FALLBACK = ["#ffe94d", "#d58bff", "#ff7ab8", "#a3e635"]; +const REC_COLORS = REC_VARS.map((name, index) => `var(${name}, ${REC_FALLBACK[index]})`); +const REC_DASHES = [ + [10, 6], + [3, 6], + [14, 4, 3, 4], + [6, 3], +]; + export interface RecPreview { root: HTMLElement; - /** 노선 그리기 안(돌림 안) — 고른 선 하나. */ + /** 노선 그리기 안(돌림 안) — 고른 선들(갈래마다 색). */ draw: (context: CanvasRenderingContext2D, toScreen: (vertex: Vertex) => [number, number]) => void; - /** 검증 창구 — 고른 갈래 · 선 점 수. */ - state: () => { mode: string | null; points: number }; + /** 검증 창구 — 고른 갈래들 · 보이는 선 점 수(갈래별). */ + state: () => { modes: string[]; points: Record }; } export function createRecPreview(opts: { @@ -39,18 +52,23 @@ export function createRecPreview(opts: { redraw: () => void; }): RecPreview { const lines = new Map(); - let shown: Vertex[] | null = null; - let turn = 0; + const shown = new Map(); + const turns = new Map(); const menu = createRoutePreviewMenu({ - items: MODES.map((mode) => ({ value: mode, label: LOGIC_LABEL[mode] })), - onPick: (mode) => void pick(mode as RouteInitialLogic | null), + items: MODES.map((mode, index) => ({ + value: mode, + label: LOGIC_LABEL[mode], + color: REC_COLORS[index], + })), + onPick: (mode) => void pick(mode as RouteInitialLogic), }); - async function pick(mode: RouteInitialLogic | null): Promise { - const mine = ++turn; - shown = null; + async function pick(mode: RouteInitialLogic): Promise { + const mine = (turns.get(mode) ?? 0) + 1; + turns.set(mode, mine); + shown.delete(mode); opts.redraw(); - if (!mode) return; + if (!menu.values().includes(mode)) return; const key = `${mode}|${opts.terrain()}`; try { if (!lines.has(key)) { @@ -63,12 +81,12 @@ export function createRecPreview(opts: { if (planned.length < 2) throw new Error("추천 선이 비었음"); lines.set(key, planned); } - if (mine !== turn) return; - shown = lines.get(key)!; + if (mine !== turns.get(mode)) return; + shown.set(mode, lines.get(key)!); opts.redraw(); } catch (error) { - if (mine !== turn) return; - menu.set(null); + if (mine !== turns.get(mode)) return; + menu.setItem(mode, { checked: false }); const text = error instanceof Error ? error.message : String(error); showToast(`추천 노선 「${LOGIC_LABEL[mode]}」 못 봄 — ${text}`, "info"); } @@ -77,8 +95,18 @@ export function createRecPreview(opts: { return { root: menu.root, draw(context, toScreen) { - if (shown) paintRoutePreview(context, shown, toScreen); + MODES.forEach((mode, index) => { + const line = shown.get(mode); + if (line) + paintRoutePreview(context, line, toScreen, { + color: themeColor(REC_VARS[index], REC_FALLBACK[index]), + dash: REC_DASHES[index], + }); + }); }, - state: () => ({ mode: menu.value(), points: shown?.length ?? 0 }), + state: () => ({ + modes: menu.values(), + points: Object.fromEntries([...shown].map(([mode, line]) => [mode, line.length])), + }), }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 0264527ed..e7037b266 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -25,7 +25,7 @@ import { type StationLabelBox, } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import type { EditedCurve, EditedNode, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve"; -import { contourBandRect, setStationLabelBoxes } from "./B05_Profile_UI_RouteEdit_Input"; +import { sceneContourBand, setStationLabelBoxes } from "./B05_Profile_UI_RouteEdit_Input"; import { drawPickHover, drawStationNormal } from "./B05_Profile_UI_RouteEdit_Hover"; import { drawPickedCurve, traceNodeTriangle } from "./B05_Profile_UI_RouteEdit_Curve_Pick"; import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross"; @@ -198,7 +198,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou context.save(); // 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면 // 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥). - const band = scene.hasMeta ? contourBandRect(line as Vertex[], toScreen, CONTOUR_BAND_M) : null; + const band = sceneContourBand(scene, line, toScreen, CONTOUR_BAND_M); if (band) { context.beginPath(); context.rect(band.x, band.y, band.width, band.height); @@ -264,7 +264,11 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou style.getPropertyValue("--draw-text-secondary") || "#9ca3af", 1.6, ); - scene.underRoute?.(context); // 추천 노선 보기(626-1) + // 추천 노선 보기(626-1) — 예정노선 수정 켬(dim)에도 진한 색 그대로(589-2) + context.save(); + context.globalAlpha = 1; + scene.underRoute?.(context); + context.restore(); // 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시). // **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다. // 예상노선 뒤 · 노선 앞(414-2) — 노선이 네모 위를 지나감 · 고른 곡선은 `drawPickedCurve` 가 위에. diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_SnapStep.ts b/B05_Profile/B05_Profile_UI_RouteEdit_SnapStep.ts index a21ab3fa9..b27ae27c9 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_SnapStep.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_SnapStep.ts @@ -1,17 +1,15 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_SnapStep.ts - * 노선 편집 [측점 스냅] 단추 · 스냅 간격 고르기(390-2 · 요구 242-11). + * 노선 편집 스냅 고르기 하나(390-2 · 요구 242-11 · 589-4) — 측점 No. · 10 m · 끔(끔 = 스냅 꺼짐). * * 간격 = 10 m · 측점 No.(편집 창 측점 간격 · 기본 · 280-4) — 곡선 MC 를 그 간격 배수에 맞춤. - * 고른 값은 프로젝트별 화면 기억(pref `routeedit-snap-step`) · 스냅 켬이면 저장 노선에도 같이 - * (`station_snap_step` · 390-5 · 서버 측점 셈이 MC 측점 값 자릿수로 씀 · 옛 노선 = 측점 No.). + * 켬이면 저장 노선에도 같이(`station_snap_step` · 390-5 · 서버 측점 셈이 MC 측점 값 자릿수로 씀 · 옛 노선 = 측점 No.). * 저장 노선이 스냅 켬이면 열 때 그 간격으로 고르기를 맞춤. 추천 · 서버 초기 노선은 측점 No. 그대로. - * 고르기 = 도구줄 고르기 메뉴 하나(634-3 · `chrome.snapStep`) · 스냅 끔이면 흐림(CSS) · 고를 수는 있음. + * 고르기 = 도구줄 고르기 메뉴 하나(634-3 · `chrome.snap`) · 처음 = 끔. * 상태줄 글은 고른 간격 따라(390-4) — 측점 No. = 「일반측점에」 · 10 m = 「10 m 배수에」. * 평면 눈금 = 일반측점(스냅이 옮기지 않음 · 280-4) + 곡선 측점(MC 에 측점 없을 때 · 252-2 · `ticks`). * ========================================================================== */ -import { readState, writeState } from "../A00_Common/b_page_state"; import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types"; import type { RouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome"; import type { EditedCurve, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve"; @@ -25,12 +23,6 @@ export type SnapSetting = Pick; type SavedSnap = { station_snap?: boolean; station_snap_step?: number | null }; -/** [측점 스냅] 단추 켬끔 모양(`is-active` · `aria-pressed`). */ -export function paintStationSnap(button: HTMLButtonElement, on: boolean): void { - button.classList.toggle("is-active", on); - button.setAttribute("aria-pressed", String(on)); -} - /** 스냅 뒤 상태줄 글 — `step` = 고르기 값("10" · "station"). */ export function snapFitText(step: string): string { const where = step === "station" ? "일반측점" : `${step} m 배수`; @@ -40,6 +32,8 @@ export function snapFitText(step: string): string { export type SnapStep = { /** 지금 스냅 간격(m) — 측점 No. 면 측점 간격. */ (): number; + /** 스냅 켬인가(고르기가 끔이 아님). */ + on: () => boolean; /** 스냅 뒤 상태줄 글. */ text: () => string; /** 저장 · 초안 두 칸 — 끔이면 간격 null. */ @@ -57,24 +51,17 @@ export type SnapStep = { ) => { stations: number[] | null; curveStations: number[] }; }; -/** 단추 · 고르기 배선 — `onChange(true)` = 단추 누름(켬끔 바꿈) · `onChange(false)` = 간격 바꿈. */ +/** 고르기 배선 — 고름(켬 · 간격 · 끔) 때 `onChange()`. */ export function bindStationSnap( - chrome: Pick, - projectId: string, + chrome: Pick, stationIntervalM: number, - onChange: (toggle: boolean) => void, + onChange: () => void, ): SnapStep { - // 고르기 메뉴 하나(634-3 · 옛 날