Merge remote-tracking branches 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into main_laptop_1

This commit is contained in:
2026-11-02 23:01:25 +09:00
47 changed files with 316 additions and 152 deletions
+6 -5
View File
@@ -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;
+4 -5
View File
@@ -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(
+14 -8
View File
@@ -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);
+12 -16
View File
@@ -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(
<div class="b05-routeedit__busy" hidden><span></span></div>
<aside class="b05-routeedit__side-wrap"></aside>`;
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,
+2 -2
View File
@@ -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],
@@ -504,6 +504,38 @@ function stationTickAtScreen(
return best;
}
type XY = ReadonlyArray<readonly [number, number]>;
/** 예정노선 수정 켬인가 — 아래 노선 그림이 흐려진 동안(`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.
*
* **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는
@@ -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<string, number> };
}
export function createRecPreview(opts: {
@@ -39,18 +52,23 @@ export function createRecPreview(opts: {
redraw: () => void;
}): RecPreview {
const lines = new Map<string, Vertex[]>();
let shown: Vertex[] | null = null;
let turn = 0;
const shown = new Map<RouteInitialLogic, Vertex[]>();
const turns = new Map<RouteInitialLogic, number>();
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<void> {
const mine = ++turn;
shown = null;
async function pick(mode: RouteInitialLogic): Promise<void> {
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])),
}),
};
}
@@ -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` 가 위에.
@@ -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<RouteDraft, "stationSnap" | "stationSnapStep">;
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<RouteEditChrome, "stationSnapButton" | "snapStep">,
projectId: string,
chrome: Pick<RouteEditChrome, "snap">,
stationIntervalM: number,
onChange: (toggle: boolean) => void,
onChange: () => void,
): SnapStep {
// 고르기 메뉴 하나(634-3 · 옛 날 <select> 대신) — 값 = "10" · "station"
const menu = chrome.snapStep;
// 고르기 메뉴 하나(634-3) — 값 = "10" · "station" · 끔 null
const menu = chrome.snap;
const value = (): string => menu.value() ?? "station";
const saved = readState<string>("routeedit-snap-step", projectId);
menu.set(saved && STEPS.includes(saved) ? saved : "station");
menu.onPick(() => {
writeState("routeedit-snap-step", value(), projectId);
onChange(false);
});
chrome.stationSnapButton.addEventListener("click", () => onChange(true));
const on = () => chrome.stationSnapButton.getAttribute("aria-pressed") === "true";
menu.onPick(() => onChange());
const on = () => menu.value() !== null;
const step = (): number | null => (value() === "station" ? null : Number(value()));
const draft = (): SnapSetting => ({ stationSnap: on(), stationSnapStep: on() ? step() : null });
let loaded: SnapSetting = { stationSnap: false, stationSnapStep: null };
@@ -82,12 +69,13 @@ export function bindStationSnap(
on() ? stationsAlong(line, stationIntervalM, false) : null;
const stepM = () => step() ?? stationIntervalM;
return Object.assign(stepM, {
on,
text: () => snapFitText(value()),
draft,
load: (plan: SavedSnap) => {
const snapOn = plan.station_snap === true;
const kept = String(plan.station_snap_step ?? "station");
if (snapOn) menu.set(STEPS.includes(kept) ? kept : "station");
menu.set(snapOn ? (STEPS.includes(kept) ? kept : "station") : null);
loaded = { stationSnap: snapOn, stationSnapStep: snapOn ? step() : null };
return snapOn;
},
@@ -315,15 +315,11 @@ html.is-bm-route .b05-routeedit__tools {
line-height: 1.5;
}
/* 설계 지형 칩이 숨으면 앞 구분선도(390-1 · 626-1) · 스냅 끔이면 간격 고르기 흐림(390-2). */
/* 설계 지형 칩이 숨으면 앞 구분선도(390-1 · 626-1). */
.ui-toolbar__divider:has(+ [data-act="terrain"][hidden]) {
display: none;
}
[data-act="station-snap"][aria-pressed="false"] + .b05-routeedit__snap-step {
opacity: 0.5;
}
/* 637-3 도구줄 지형 칸 — 지형 칩 · LAS 올리기 · 진행 서클 한 줄(예정노선 `.d03-plan__terrain` 과 같음) */
.b05-routeedit__terrain {
display: flex;
@@ -4,6 +4,7 @@
* 지점 = 지점 목록 차례 · 지점 둘 이상일 때만 열림 · 높이 = 지형 칩 원천 격자 · 걸음 20 m · 기울기 ±14 % 고정(사용자 원문 ·
* 그리기 칸 값과 따로) · 급경사 문턱 = 급경사 범위 둘째 경계(기본 70 %).
* 셈 = 공용 `map_guide_route`(브라우저만 · 저장 안 함) · 켠 동안 지점 · 지형 · 급경사 범위가 바뀌면 다시 셈(끌기 중엔 멈춘 뒤 한 번).
* 메뉴 = 켬끔 한 줄(여럿 고르기 부품 · 「끔」 줄 없음 · 589-12).
* 그림 = 닿은 선 노랑 실선 · 못 닿은 구간 = 간 데 → 다음 지점 빨강 점선 + 알림 · 끝 지점 옆 짧은 글(길이 · 최대 기울기 · 급경사 %).
* 메뉴 · 선 그리기 = 공용 `map_route_preview`(B05 추천 미리보기와 같음).
* ========================================================================== */
@@ -64,13 +65,13 @@ export function createPlanGuide(opts: {
let open: boolean | null = null;
const menu = createRoutePreviewMenu({
items: [{ value: "guide", label: "가이드 선(지점 잇기)" }],
onPick: (value) => {
onPick: () => {
[result, doneKey, doneGrid] = [null, "", null];
if (value) compute(true);
if (on()) compute(true);
opts.redraw();
},
});
const on = (): boolean => menu.value() === "guide";
const on = (): boolean => menu.values().includes("guide");
function compute(picked = false): void {
window.clearTimeout(timer);
+1 -1
View File
@@ -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")
+3 -1
View File
@@ -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()]
@@ -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),
}),
@@ -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);
+11 -2
View File
@@ -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:]:
+2 -2
View File
@@ -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]:
@@ -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 },
],
@@ -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<string, numbe
return out;
}
const n4 = (v: number | undefined): string =>
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.까닭),
@@ -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) }),
@@ -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<string, string> = {
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(", ")}` : "",
]
@@ -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)
@@ -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<Kind, "노무비" | "재료비"> = { 노임: "노무비", 자재: "재료비" };
@@ -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<void> {
{ 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,
@@ -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" } });
@@ -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(
@@ -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,
@@ -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: [],
@@ -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) */
@@ -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<string, StructureNumbers>;
/** 집계표 「일위대가 합계」 줄 글(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) — 계산값 없음 · 담은 것 없음 = 색 없음 */
+1 -1
View File
@@ -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";
@@ -67,8 +67,9 @@ def test_고르기는_10_m_측점_No_둘() -> None:
import re
chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8")
block = chrome[chrome.index('icon: "spacing"') :]
block = chrome[chrome.index('icon: "magnet"') :]
block = block[: block.index("onPick")]
assert 'offLabel: "끔"' in block # 589-4 스냅 켬끔 + 간격 한 아이콘
assert re.findall(r'value: "([^"]+)"', block) == ["10", "station"]
assert "<select" not in chrome
assert 'const STEPS = ["10", "station"];' in SNAP.read_text(encoding="utf-8")
+3 -3
View File
@@ -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",
-2
View File
@@ -44,14 +44,12 @@ def test_toolbar_table_d03_same_as_before_plus_terrain() -> None:
"contour-minor",
"footprint",
"station-snap",
"snap-step",
"recommend",
"terrain",
} <= b05
tools = _read(ROOT / "ui_template" / "ui_template_map_tools.ts")
for key in {k for g in _toolbar_rows() for k, _ in g} - {
"hint-toggle",
"snap-step",
"recommend",
"terrain",
}:
@@ -44,7 +44,7 @@ def test_미리보기_넷_하나만_저장본만() -> None:
assert re.findall(r'"(\w+)"', modes) == ["gradeline", "earthwork", "gentle", "weighted"]
assert "recompute" not in src and "createRoutePreviewMenu({" in src
shared = read(ROOT / "A00_Common" / "map" / "map_route_preview.ts") # 636-2 공용 메뉴 · 그리기
assert "createPickMenu({" in shared and 'offLabel: "끔"' in shared
assert "createPickMenu({" in shared and "multi: true" in shared and "offLabel" not in shared
assert "terrain: opts.terrain()" in src # 지금 설계 지형 저장본(D7)
chrome = read(B05 / "B05_Profile_UI_RouteEdit_Chrome.ts")
assert "recommend: preview.root" in chrome and "terrain: terrain.root" in chrome
+1 -3
View File
@@ -50,7 +50,6 @@ GROUP2 = [
("footprint", "절성토선"),
("measure", "길이측정"),
("station-snap", "스냅"),
("snap-step", "스냅 간격"),
("recommend", "추천노선"),
("terrain", "수치지형도/라이다"),
]
@@ -72,8 +71,7 @@ def test_그룹2_표_호버():
assert rows == GROUP2
tools = _read(ROOT / "ui_template" / "ui_template_map_tools.ts")
for key, title in GROUP2[1:-2]: # 추천 · 지형 = 페이지 요소
if key != "snap-step":
assert re.search(rf'"?{key}"?: \{{ icon: "\w+", title: "{title}"', tools), key
assert re.search(rf'"?{key}"?: \{{ icon: "\w+", title: "{title}"', tools), key
def test_페이지_호버_덮어쓰기_없음():
+3 -1
View File
@@ -16,7 +16,9 @@ read = lambda *parts: ROOT.joinpath(*parts).read_text(encoding="utf-8") # noqa:
def test_공용_메뉴_그리기() -> None:
src = read("A00_Common", "map", "map_route_preview.ts")
assert 'icon: "compass"' in src and 'offLabel: "끔"' in src
assert (
'icon: "compass"' in src and "multi: true" in src and "offLabel" not in src
) # 589-12 여럿 고르기 · 끔 줄 없음
assert 'dataset.act = "recommend"' in src and "--draw-map-sketch" in src
for name in (
"B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts",
@@ -0,0 +1,63 @@
"""756-1 (요구 589-10) — 예정노선 수정 켬 때 등고선 띠: LAS = 자르지 않음 · 도엽 = 계획선 + 예정노선으로 넓힘.
./venv/Scripts/python.exe -m pytest resources/tester/test_756_1_contour_band.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
INPUT = B05 / "B05_Profile_UI_RouteEdit_Input.ts"
TS = ROOT / "config" / "node_modules" / "typescript"
NODE = """
const fs = require("fs");
const ts = require(process.argv[1]);
const js = ts.transpileModule(fs.readFileSync(process.argv[2], "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => new Proxy({}, { get: () => () => {} }));
const { sceneContourBand } = box.exports;
const toScreen = ([x, y]) => [x, -y]; // 1 px = 1 m
const plan = [[0, 0], [100, 0]];
const expected = [[0, 0], [100, 0], [100, 1000]];
const run = (source, dim) =>
sceneContourBand({ hasMeta: true, contours: { source }, overAll: { dim: () => dim }, expected }, plan, toScreen, 300);
console.log(JSON.stringify({
offSheet: run("sheet", 1),
onSheet: run("sheet", 0.35),
onLas: run("las", 0.35),
offLas: run("las", 1),
}));
"""
@pytest.fixture(scope="module")
def bands() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", "-e", NODE, str(TS), str(INPUT)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_끈_동안은_계획선_둘레_띠_그대로(bands) -> None:
assert bands["offSheet"]["height"] == 600 # 계획선 y 0 ± 300 m
assert bands["offLas"] is not None
def test_켠_동안_도엽은_예정노선까지_넓힘(bands) -> None:
assert bands["onSheet"]["height"] == 1600 # 예정노선 y 0~1000 ± 300 m
def test_켠_동안_LAS는_자르지_않음(bands) -> None:
assert bands["onLas"] is None
@@ -196,7 +196,7 @@ def test_되돌리기_다시하기가_현재_추천을_함께_되살림(results)
def test_노선_추천_다듬기_23_3():
"""도구줄 차례 = 공용 표 「b05」(624-3) · 스냅 간격 = [측점 스냅] 오른쪽 · 지형 칩 맨 오른쪽."""
"""도구줄 차례 = 공용 표 「b05」(624-3) · 스냅 = 고르기 하나(589-4) · 지형 칩 맨 오른쪽."""
page = os.path.join(HERE, "..", "..", "B05_Profile")
def read(name):
@@ -211,7 +211,7 @@ def test_노선_추천_다듬기_23_3():
'mapToolButton("redo"',
"view3d: tools[0]", # 부른 쪽 단추([3D])
'mapToolButton("contour-minor"',
'"snap-step": snapStep.root,', # 634-3 고르기 메뉴 · 측점 · 기울기 · 절성토선 · 재기 · 스냅 = 표 기본 단추
'"station-snap": snap.root,', # 634-3 고르기 메뉴 · 측점 · 기울기 · 절성토선 · 재기 · 스냅 = 표 기본 단추
"recommend: preview.root,",
"terrain: terrain.root,",
]
@@ -222,12 +222,12 @@ def test_노선_추천_다듬기_23_3():
os.path.join(HERE, "..", "..", "A00_Common", "map", "map_toolbar.ts"), encoding="utf-8"
).read()
keys = re.findall(r'key: "([\w-]+)"', table[table.index("MAP_TOOLBAR") :])
assert keys.index("station-snap") + 1 == keys.index("snap-step")
assert "snap-step" not in keys # 스냅 + 간격 = 한 아이콘
assert keys[-2:] == ["recommend", "terrain"]
assert "b05-route__view-separator" not in chrome # 나눔선 = 공용 도구줄
style = read("B05_Profile_UI_Style_RouteEdit.css")
assert '.ui-toolbar__divider:has(+ [data-act="terrain"][hidden])' in style
assert '[data-act="station-snap"][aria-pressed="false"] + .b05-routeedit__snap-step' in style
assert "b05-routeedit__snap-step" not in style
assert "b05-routeedit__spin" not in chrome # 회전 = 도구줄
+2 -2
View File
@@ -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"]
+9
View File
@@ -138,6 +138,15 @@
opacity: 0.55;
}
/* 줄 색 점(589-12 · 추천노선 선 색 구분) */
.ui-pick-menu__dot {
flex: none;
width: 10px;
height: 10px;
border-radius: var(--radius-pills);
box-shadow: 0 0 0 1px var(--color-border-strong);
}
/* 여럿 고르기 단추(634-1 · 요구 551) — 아이콘 + ▾ */
.ui-icon-btn.ui-pick-menu--multi {
width: auto;
+8
View File
@@ -316,6 +316,8 @@ export interface PickMenuItem {
label: string;
title?: string;
icon?: IconName;
/** 줄 앞 색 점(그림 색 구분 · 589-12) — CSS 색 값. */
color?: string;
disabled?: boolean;
}
@@ -369,6 +371,12 @@ export function createPickMenu(opts: {
b.type = "button";
b.className = "ui-row-menu__item";
if (item.icon) b.append(icon(item.icon, 18));
if (item.color) {
const dot = document.createElement("i");
dot.className = "ui-pick-menu__dot";
dot.style.background = item.color;
b.append(dot);
}
b.append(Object.assign(document.createElement("span"), { textContent: item.label }));
if (item.title) b.title = item.title;
b.disabled = !!item.disabled;
+2 -2
View File
@@ -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(
+2 -2
View File
@@ -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<ItemPickerResul
const spec = chosen ? specOf(chosen) : null;
specHost.hidden = !spec || spec.열.length < 2;
if (!spec || specHost.hidden) return void specHost.replaceChildren();
const sum = (n: number | null): string =>
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.합계)}`,
@@ -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<void> {
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",
+4
View File
@@ -599,6 +599,10 @@ html.is-bm-route .m01lab-src__body * {
--draw-map-ecology-2: #92d050; /* 2등급 */
--draw-map-ecology-x: #ff8000; /* 별도관리지역 */
--draw-map-sketch: #ffe94d; /* 지도 위 그림(테마 안 따름 · 검은 테와 함께) — 긋기 미리보기 · 그리는 선 · 고름 강조 · 마커 */
--draw-map-rec-1: #ffe94d; /* 추천노선 보기 선 넷(여럿 보기 색 구분 · 검은 테와 함께) */
--draw-map-rec-2: #d58bff;
--draw-map-rec-3: #ff7ab8;
--draw-map-rec-4: #a3e635;
--draw-map-measure: #ffffff; /* 거리재기 */
--draw-map-warn: #ff5a4d; /* 긋기 막힘(경고) */
--draw-map-slope-up: #ff9e2c; /* 긋기 기울기 부호 — + 오르막(따뜻한 색 · 노드 · 꺾임점) */
+7 -4
View File
@@ -136,17 +136,20 @@ const gridRows = (): Cell[][] => [
[{ content: "암", head: true }, "3.10", "m³"],
[{ content: "쌓기", colSpan: 2, head: true }, "8.05", "m³"],
];
/** 634-1 견본 — 고르기 메뉴 하나(추천 노선 꼴) · 여럿(지도 겹 묶음 · 켜면 눌림 · 막힌 줄) */
/** 634-1 견본 — 고르기 메뉴 하나(스냅 꼴) · 여럿(지도 겹 묶음 · 켜면 눌림 · 막힌 줄 · 추천노선 꼴 색 점 줄 · 589-12) */
const pickDemo = (s: KitState): HTMLElement | null => {
if (s !== "default" && s !== "disabled") return null;
const one = createPickMenu({ icon: "compass", title: "추천노선", items: [{ value: "a", label: "노선 가" }, { value: "b", label: "노선 나" }], offLabel: "끔", onPick: noop });
const one = createPickMenu({ icon: "magnet", title: "스냅", items: [{ value: "a", label: "10 m" }, { value: "b", label: "측점 No." }], offLabel: "끔", onPick: noop });
const colored = createPickMenu({ multi: true, icon: "compass", title: "추천노선", onPick: noop,
items: [{ value: "a", label: "노선 가", color: "var(--draw-map-rec-1)" }, { value: "b", label: "노선 나", color: "var(--draw-map-rec-2)" }] });
colored.setItem("a", { checked: true });
const many = createPickMenu({ multi: true, icon: "streams", title: "수계망도", onPick: noop,
items: [{ value: "stream", label: "세류", icon: "droplet" }, { value: "water", label: "하천", icon: "ripple" }, { value: "bands", label: "계류100m", icon: "borderOuter", disabled: true, title: "계류100m — 지형 받기 뒤" }] });
one.set("a");
many.setItem("stream", { checked: true });
many.setItem("water", { checked: true });
if (s === "disabled") [one.root, many.root].forEach((b) => (b.disabled = true));
return row(one.root, many.root);
if (s === "disabled") [one.root, many.root, colored.root].forEach((b) => (b.disabled = true));
return row(one.root, many.root, colored.root);
};
/** 709-1 견본 — 쪽 단추(가운데 쪽 · 한 장뿐이라 이전 · 다음 다 막힘) · 수평 구분선(소제목 사이) */
const pagerDemo = (s: KitState): HTMLElement | null =>