feat(485-2 B07 축척): ⚙ 모달 X · Y 축척 칸 — 종단 · 횡단 · 토적 · 전개도 · 분모 정수 · 범위 밖 = 칸 붉음 + 저장 꺼짐 · 토적 X · 전개도 「자동」 · 저장본 n 장 물음 · 저장 뒤 화면 다시 세움 · [기본값] · 새 _UI_Settings_Scale.ts · 판정 logic/_Scale_Check.ts · 시험 test_280_5 축척 셋

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 22:53:40 +09:00
co-authored by Claude Opus 5.5
parent 1e71ef8085
commit 682be199fd
6 changed files with 406 additions and 7 deletions
@@ -4,7 +4,7 @@
*
* 평면도 = 평면도 관리 모달 그대로(`openPlanManager`)
* 횡단도 = 장 배치(열 · 행 · [이 장] · [모든 장]) · 표 배율 · [안내선 숨기기] — 좌측 카드 밑에서 옮김
* 구조물 도면 = 「M02 그림 — 축척 없음」 한 줄 · 종단 · 토적 · 전개도 = 몸 비움(축척 칸은 485-2)
* 구조물 도면 = 「M02 그림 — 축척 없음」 한 줄 · 종단 · 횡단 · 토적 · 전개도 = X · Y 축척 칸(`_UI_Settings_Scale`)
* [원본에서 다시 그리기] = 지금 연 도면이 그 묶음일 때만 켬
* ========================================================================== */
@@ -17,6 +17,7 @@ import {
isCrossSheet,
} from "./B07_DesignDetail_UI_Panels";
import { openPlanManager, type PlanManageControls } from "./B07_DesignDetail_UI_PlanManage";
import { mountDrawingScale } from "./B07_DesignDetail_UI_Settings_Scale";
type Kind = DesignDrawingItem["kind"];
@@ -81,6 +82,7 @@ export function openDrawingSettings(kind: Kind, deps: DrawingSettingsDeps): void
closeModal = close;
if (mine) body.append(note(`지금 도면: ${current?.label}`));
if (kind === "structure") body.append(note("M02 그림 — 축척 없음"));
mountDrawingScale(body, kind, deps, close);
if (kind === "cross") mountCross(body, deps, close);
},
});
@@ -0,0 +1,157 @@
/* =============================================================================
* B07_DesignDetail_UI_Settings_Scale.ts
* ⚙ 모달 X · Y 축척 칸(485-2 · 요구 280-5) — 종단 · 횡단 · 토적 · 전개도만.
*
* 칸 = 분모 정수 · 범위 밖 = 칸 붉게 + [축척 저장] 꺼짐 · 기본이 자동인 칸(토적 X · 전개도) = 「자동」
* [축척 저장] = 그 종류 저장본이 있으면 n 장 물음(취소 = 그대로) → PUT → 화면 다시 세움(도면 다시 그림)
* [기본값] = 칸만 기본으로 채움 — 저장은 [축척 저장]
* ========================================================================== */
import { createCheckbox } from "@ui/ui_template_checkbox";
import {
createButton,
createInputField,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import {
type DrawingScaleAxes,
type DrawingScaleKind,
type DrawingScales,
fetchDrawingScales,
saveDrawingScales,
} from "./B07_DesignDetail_Api_Fetch";
import type { DrawingSettingsDeps } from "./B07_DesignDetail_UI_Settings";
import { readScaleAxis, scaleToSend } from "./logic/B07_DesignDetail_Scale_Check";
const LABEL: Record<DrawingScaleKind, string> = {
longitudinal: "종단도",
cross: "횡단도",
mass_haul: "토적도",
development: "전개도",
};
const AXES = [
["x", "X(가로) 1/"],
["y", "Y(세로) 1/"],
] as const;
const isScaleKind = (kind: string): kind is DrawingScaleKind => kind in LABEL;
/** ⚙ 모달 몸에 축척 칸을 붙임 — 축척 없는 종류는 아무것도 안 함. */
export function mountDrawingScale(
body: HTMLElement,
kind: string,
deps: DrawingSettingsDeps,
close: () => void,
): void {
if (!isScaleKind(kind) || !deps.plan) return;
const { projectId, onClosed } = deps.plan;
const section = document.createElement("div");
section.className = "b07-scale";
section.textContent = "축척 받는 중…";
body.append(section);
void fetchDrawingScales(projectId)
.then((data) => {
section.textContent = "";
buildScaleFields(section, kind, data, async (next) => {
const saved = data.saved[kind] ?? 0;
const ask = `${LABEL[kind]} 축척을 바꾸면 저장본 ${saved}장이 풀립니다(원본에서 다시 그림). 바꿀까요?`;
if (saved > 0 && !(await showConfirmDialog(ask, "바꿈"))) return;
showLoadingOverlay();
try {
await saveDrawingScales(projectId, { [kind]: next });
close();
showToast(`${LABEL[kind]} 축척을 바꿨습니다.`, "success");
onClosed(true, deps.current()?.id); // 목록 · 도면을 서버에서 다시 받아 다시 그림
} catch (error) {
showToast(
error instanceof Error ? error.message : "축척을 저장하지 못했습니다.",
"error",
);
} finally {
hideLoadingOverlay();
}
});
})
.catch(() => {
section.textContent = "축척을 받지 못했습니다.";
});
}
function buildScaleFields(
section: HTMLElement,
kind: DrawingScaleKind,
data: DrawingScales,
save: (next: DrawingScaleAxes) => Promise<void>,
): void {
const fields = AXES.map(([axis, label]) => {
const fallback = data.defaults[kind][axis];
const range = data.ranges[kind][axis];
const [min, max] = range;
const field = createInputField({ label, type: "number", compact: true, min, max });
field.root.classList.add("b07-scale__field");
const auto = fallback === null ? createCheckbox("자동") : undefined;
if (auto) field.root.append(auto.root);
const set = (value: number | null): void => {
field.input.value = value === null ? "" : String(value);
if (auto) auto.input.checked = value === null;
field.input.disabled = value === null && !!auto;
};
set(data.scales[kind][axis]);
const read = () => readScaleAxis(field.input.value, !!auto?.input.checked, range);
return { axis, field, auto, fallback, set, read };
});
const saveButton = createButton({
label: "축척 저장",
onClick: () => {
const [x, y] = fields.map((f) => f.read()) as [number | null, number | null];
const now = data.scales[kind];
if (x === now.x && y === now.y) {
showToast("바뀐 축척이 없습니다.", "info");
return;
}
void save({ x: scaleToSend(x, fields[0].fallback), y: scaleToSend(y, fields[1].fallback) });
},
});
const check = (): void => {
let bad = false;
for (const f of fields) {
const result = f.read();
f.field.setError(typeof result === "string" ? result : undefined);
bad ||= typeof result === "string";
}
saveButton.disabled = bad;
};
for (const f of fields) {
f.field.input.addEventListener("input", check);
f.auto?.input.addEventListener("change", () => {
if (f.auto!.input.checked) f.set(null);
else f.field.input.disabled = false; // 빈 칸 — 숫자를 적어야 저장
check();
});
}
const reset = createButton({
label: "기본값",
variant: "ghost",
onClick: () => {
fields.forEach((f) => f.set(f.fallback));
check();
},
});
reset.title = "칸을 기본 축척으로 채움 — [축척 저장]으로 씀";
const title = document.createElement("p");
title.className = "b07-settings__note";
title.textContent =
kind === "cross" ? "축척 — 횡단도 장(표준 횡단면도 뺌)" : `축척 — ${LABEL[kind]}`;
const row = document.createElement("div");
row.className = "b07-scale__row";
row.append(...fields.map((f) => f.field.root));
const buttons = document.createElement("div");
buttons.className = "b07-scale__actions";
buttons.append(reset, saveButton);
section.append(title, row, buttons);
}
@@ -503,3 +503,27 @@ html.is-bm-route .b07-cad-stack > .cad-tabs {
gap: var(--spacing-8);
min-width: 280px;
}
/* ⚙ 축척 칸(485-2) — X · Y 두 칸 한 줄 · 단추 오른쪽 */
.b07-scale {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 280px;
margin-bottom: var(--spacing-8);
}
.b07-scale__row {
display: flex;
gap: var(--spacing-8);
}
.b07-scale__field {
flex: 1;
}
.b07-scale__actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
}
@@ -0,0 +1,24 @@
/* =============================================================================
* B07_DesignDetail_Scale_Check.ts
* ⚙ 축척 칸(485-2) 판정 — 칸 글 → 분모(정수 · 범위 안) · 자동 = null · 틀리면 까닭 글.
* 서버 `checked_scales`(`_Router_Support_Scale.py`)와 같은 규칙 — 범위는 서버가 내려 줌.
* ========================================================================== */
/** 칸 하나 읽기 — 숫자 · null(자동) · 까닭 글(틀림). */
export function readScaleAxis(
raw: string,
auto: boolean,
[low, high]: [number, number],
): number | null | string {
if (auto) return null;
const text = raw.trim().replace(/,/g, "");
const value = Number(text);
if (text === "" || !Number.isInteger(value) || value < low || value > high) {
return `1/${low.toLocaleString()}~1/${high.toLocaleString()} 사이 정수`;
}
return value;
}
/** 보낼 값 — 기본값과 같으면 null(파일엔 기본 그대로 남김). */
export const scaleToSend = (value: number | null, fallback: number | null): number | null =>
value === fallback ? null : value;
+151 -5
View File
@@ -96,6 +96,27 @@ const modals = [];
const plans = [];
const calls = [];
let failConfirm = false;
const SCALES = {
scales: {
longitudinal: { x: 1000, y: 200 },
cross: { x: 100, y: 100 },
mass_haul: { x: null, y: 50000 },
development: { x: null, y: null },
},
defaults: {
longitudinal: { x: 1000, y: 200 },
cross: { x: 100, y: 100 },
mass_haul: { x: null, y: 50000 },
development: { x: null, y: null },
},
ranges: {
longitudinal: { x: [100, 10000], y: [10, 2000] },
cross: { x: [10, 1000], y: [10, 1000] },
mass_haul: { x: [100, 20000], y: [1000, 1000000] },
development: { x: [10, 10000], y: [10, 10000] },
},
saved: { longitudinal: 2, cross: 0, mass_haul: 0, development: 0 },
};
let answer = true;
const button = (opts) => {
@@ -118,13 +139,24 @@ const STUBS = {
label.className = "ui-field__label";
label.textContent = opts.label;
root.append(label, input);
return { root, input };
const setError = (msg) => (input.error = msg ?? null);
return { root, input, setError };
},
showToast: (message, kind) => toasts.push([kind, message]),
showLoadingOverlay: () => calls.push(["overlay+"]),
hideLoadingOverlay: () => calls.push(["overlay-"]),
showConfirmDialog: async (message) => (calls.push(["ask", message]), answer),
},
"@ui/ui_template_checkbox": {
createCheckbox: (text) => {
const root = new FakeNode("label");
root.className = "ui-checkbox";
const input = new FakeNode("input");
input.checked = false;
root.append(input);
return { root, input };
},
},
"@ui/ui_template_modal": {
openModal: (opts) => {
const body = new FakeNode("div");
@@ -152,6 +184,9 @@ const STUBS = {
return { all_confirmed: true };
},
invalidateDesignDrawing: async (projectId, id) => calls.push(["invalidate", id]),
// 485-2 축척 — 범위 · 기본은 서버(`_Router_Support_Scale.py`)와 같은 값
fetchDrawingScales: async () => JSON.parse(JSON.stringify(SCALES)),
saveDrawingScales: async (projectId, scales) => calls.push(["scales", scales]),
},
};
@@ -274,12 +309,14 @@ const drawings = [
openDrawingSettings("cross", deps);
await new Promise((r) => setTimeout(r, 0));
const cross = modals.at(-1);
const crossInputs = all(cross.body).filter((n) => n.tagName === "INPUT");
const layoutNode = byClass(cross.body, "b07-cross-layout")[0];
const crossInputs = all(layoutNode).filter((n) => n.tagName === "INPUT");
out.crossModal = {
title: cross.opts.title,
labels: byClass(cross.body, "ui-field__label").map((n) => n.textContent),
labels: byClass(layoutNode, "ui-field__label").map((n) => n.textContent),
values: crossInputs.map((n) => n.value),
buttons: all(cross.body)
scaleTitle: texts(byClass(cross.body, "b07-scale")[0])[0],
buttons: all(layoutNode)
.filter((n) => n.tagName === "BUTTON")
.map((n) => [n.textContent, n.disabled]),
guideLast: byClass(cross.body, "b07-cross-layout")[0].children.at(-1) === guideToggle,
@@ -287,7 +324,7 @@ const drawings = [
notes: texts(cross.body).filter((t) => t.startsWith("지금 도면")),
};
crossInputs[0].value = "2";
all(cross.body)
all(layoutNode)
.find((n) => n.textContent === "이 장")
.click();
out.crossSave = { saved, closed: !cross.open };
@@ -318,6 +355,115 @@ const drawings = [
openDrawingSettings("cross", deps);
out.standardInCross = !modals.at(-1).opts.actions[0].disabled;
// ── 축척 칸(485-2) ─────────────────────────────────────────────────────
const closedCalls = [];
const scaleDeps = { ...deps, plan: { projectId: "p1", onClosed: (...a) => closedCalls.push(a) } };
const tick = () => new Promise((r) => setTimeout(r, 0));
const scaleOf = async (kind) => {
openDrawingSettings(kind, scaleDeps);
await tick();
const modal = modals.at(-1);
const section = byClass(modal.body, "b07-scale")[0];
const inputs = all(section).filter(
(n) => n.tagName === "INPUT" && !n.parent.classList.contains("ui-checkbox"),
);
const autos = byClass(section, "ui-checkbox").map((n) => n.children[0]);
const btn = (label) =>
all(section).find((n) => n.tagName === "BUTTON" && n.textContent === label);
const type = (input, value) => {
input.value = value;
input.listeners.input.forEach((h) => h());
};
const toggle = (box) => {
box.checked = !box.checked;
box.listeners.change.forEach((h) => h());
};
const state = () => ({
values: inputs.map((n) => n.value),
errors: inputs.map((n) => n.error ?? null),
disabled: inputs.map((n) => !!n.disabled),
autos: autos.map((n) => n.checked),
save: !btn("축척 저장").disabled,
});
return { modal, section, inputs, autos, btn, type, toggle, state };
};
const scale = {};
current = drawings[2]; // longitudinal
let s = await scaleOf("longitudinal");
scale.longLabels = byClass(s.section, "ui-field__label").map((n) => n.textContent);
scale.longStart = s.state();
s.type(s.inputs[0], "50");
scale.longLow = s.state();
s.type(s.inputs[0], "500.5");
scale.longFrac = s.state();
s.type(s.inputs[0], "500");
scale.longOk = s.state();
calls.length = 0;
answer = false;
s.btn("축척 저장").click();
await tick();
scale.cancel = { calls: calls.map((c) => c[0]), open: s.modal.open, closed: closedCalls.length };
answer = true;
calls.length = 0;
s.btn("축척 저장").click();
await tick();
await tick();
scale.save = {
ask: calls.find((c) => c[0] === "ask")?.[1],
sent: calls.find((c) => c[0] === "scales")?.[1],
open: s.modal.open,
closed: [...closedCalls],
};
s = await scaleOf("longitudinal");
s.type(s.inputs[1], "300");
s.btn("기본값").click();
scale.reset = s.state();
calls.length = 0;
const toastAt = toasts.length;
s.btn("축척 저장").click();
await tick();
scale.same = { calls: calls.length, toasts: toasts.slice(toastAt).map((t) => t[1]) };
s = await scaleOf("mass_haul");
scale.massStart = s.state();
s.toggle(s.autos[0]);
scale.massManual = s.state();
s.type(s.inputs[0], "2000");
scale.massTyped = s.state();
calls.length = 0;
s.btn("축척 저장").click();
await tick();
await tick();
scale.massSent = {
ask: calls.filter((c) => c[0] === "ask").length,
sent: calls.find((c) => c[0] === "scales")?.[1],
};
s = await scaleOf("mass_haul");
s.toggle(s.autos[0]);
s.toggle(s.autos[0]);
scale.massBack = s.state();
s = await scaleOf("development");
scale.dev = s.state();
openDrawingSettings("structure", scaleDeps);
scale.structure = byClass(modals.at(-1).body, "b07-scale").length;
out.scale = scale;
const Check = loadTs(path.join(B07, "logic", "B07_DesignDetail_Scale_Check.ts"));
out.readAxis = [
["1,000", false],
[" 99 ", false],
["10001", false],
["12.5", false],
["", false],
["abc", false],
["", true],
].map(([raw, auto]) => Check.readScaleAxis(raw, auto, [100, 10000]));
out.toSend = [
Check.scaleToSend(200, 200),
Check.scaleToSend(500, 200),
Check.scaleToSend(null, null),
];
// ── 저장 묶음 ────────────────────────────────────────────────────────────
const tabsOf = (list, activeId) => ({
tabs: () => list,
+47 -1
View File
@@ -83,6 +83,7 @@ def test_횡단_모달은_배치_칸_표_배율_안내선(out):
assert cross["guideLast"] is True
assert cross["head"] == [["원본에서 다시 그리기", False]]
assert cross["notes"] == ["지금 도면: cross_s1"]
assert cross["scaleTitle"] == "축척 — 횡단도 장(표준 횡단면도 뺌)"
saved = out["crossSave"]
assert saved["closed"] is True
assert saved["saved"] == [[{"default": [4, 4], "sheets": {"1": [2, 2]}, "table_scale": 1.2}, 1]]
@@ -93,7 +94,7 @@ def test_구조물_종단_평면도_모달(out):
assert structure["title"] == "구조물 도면 설정"
assert structure["notes"] == ["M02 그림 — 축척 없음"]
assert structure["head"][0][1] is True # 다른 묶음 도면을 연 중 = 꺼짐
assert out["longModal"] == {"title": "종단도 설정", "bodyKids": 0} # 축척 칸은 485-2
assert out["longModal"] == {"title": "종단도 설정", "bodyKids": 1} # 축척 칸(485-2)
assert out["planModal"] == {"viaManager": True, "head": [["원본에서 다시 그리기", False]]}
assert out["planRedraw"] == {"redraws": 1, "closed": True}
assert out["standardInCross"] is True
@@ -216,3 +217,48 @@ def test_서버_단계_완료(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(router, "_confirmed_source", blocked)
assert asyncio.run(router.complete_design_stage(project)).status_code == 409
RANGE = "1/100~1/10,000 사이 정수"
def test_485_2_축척_칸_판정(out):
assert out["readAxis"] == [1000, RANGE, RANGE, RANGE, RANGE, RANGE, None] # 자동 = null
assert out["toSend"] == [None, 500, None] # 기본과 같으면 null
def test_485_2_종단_축척_저장(out):
scale = out["scale"]
assert scale["longLabels"] == ["X(가로) 1/", "Y(세로) 1/"]
assert scale["longStart"]["values"] == ["1000", "200"]
assert scale["longStart"]["autos"] == [] # 종단 = 자동 없음
for bad in ("longLow", "longFrac"): # 범위 밖 · 소수 = 붉음 + 저장 꺼짐
assert scale[bad]["errors"] == [RANGE, None]
assert scale[bad]["save"] is False
assert scale["longOk"]["errors"] == [None, None] and scale["longOk"]["save"] is True
# 저장본 2장 — 물음 [취소] = 요청 없음 · 모달 그대로
assert scale["cancel"] == {"calls": ["ask"], "open": True, "closed": 0}
save = scale["save"]
assert "저장본 2장" in save["ask"]
assert save["sent"] == {"longitudinal": {"x": 500, "y": None}} # Y = 기본 → null
assert save["open"] is False
assert save["closed"] == [[True, "longitudinal"]] # 화면 다시 세움 · 같은 도면
assert scale["reset"]["values"] == ["1000", "200"] # [기본값] = 칸만
assert scale["same"] == {"calls": 0, "toasts": ["바뀐 축척이 없습니다."]}
def test_485_2_토적_전개도_자동(out):
scale = out["scale"]
assert scale["massStart"] == {
"values": ["", "50000"],
"errors": [None, None],
"disabled": [True, False],
"autos": [True],
"save": True,
}
assert scale["massManual"]["errors"][0] and scale["massManual"]["save"] is False # 빈 칸
assert scale["massTyped"]["save"] is True
assert scale["massSent"] == {"ask": 0, "sent": {"mass_haul": {"x": 2000, "y": None}}}
assert scale["massBack"]["autos"] == [True] and scale["massBack"]["save"] is True
assert scale["dev"]["autos"] == [True, True] and scale["dev"]["disabled"] == [True, True]
assert scale["structure"] == 0 # 구조물 = 축척 칸 없음