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

This commit is contained in:
2026-10-25 14:45:33 +09:00
18 changed files with 627 additions and 519 deletions
@@ -243,8 +243,10 @@ export function saveCrossLayout(projectId: string, layout: CrossLayout): Promise
/** 도면 축척 종류(485) — 표준 횡단 · 평면도 · 구조물은 뺌. */
export type DrawingScaleKind = "longitudinal" | "cross" | "mass_haul" | "development";
/** 축척 분모 — null = 자동(토적 가로 · 전개도 · 종단 평면 위치도) · 범위 = [최소, 최대]. */
export type DrawingScaleAxes = { x: number | null; y: number | null; plan?: number | null };
/** 축척 분모 — 늘 지정값(자동 없음 · 523-1) · 범위 = [최소, 최대]. */
export type DrawingScaleAxes = { x: number; y: number; plan?: number };
/** 보낼 축척 — null = 기본. */
export type DrawingScaleSend = { [K in keyof DrawingScaleAxes]: number | null };
export interface DrawingScales {
scales: Record<DrawingScaleKind, DrawingScaleAxes>;
defaults: Record<DrawingScaleKind, DrawingScaleAxes>;
@@ -265,7 +267,7 @@ export function fetchDrawingScales(projectId: string): Promise<DrawingScales> {
/** 축척 저장 — 보낸 종류만 바꿈 · 값이 바뀐 종류의 저장본은 서버가 풂. */
export function saveDrawingScales(
projectId: string,
scales: Partial<Record<DrawingScaleKind, DrawingScaleAxes>>,
scales: Partial<Record<DrawingScaleKind, DrawingScaleSend>>,
): Promise<DrawingScales> {
return requestJson(`/projects/${projectId}/drawing-scales`, {
method: "PUT",
@@ -32,6 +32,7 @@ from config.config_system import (
DRAWING_SCALE_CROSS,
DRAWING_SCALE_LONG_H,
DRAWING_SCALE_LONG_V,
DRAWING_SCALE_MASSHAUL_H,
DRAWING_SCALE_MASSHAUL_V_M3_MM,
)
from M02_MasterTemplete.M02_Template_Layers import (
@@ -93,16 +94,17 @@ def add_title_fields(extra: dict[str, str]) -> None:
# 도면 축척(485) — 프로젝트마다 `{B07 폴더}/drawing_scales.json` `{종류: {x, y}}`(분모).
# 칸이 없거나 어긋나면 기본(`config_system`) · None = 자동(토적 가로 · 전개도 가로 · 세로).
# 칸이 없거나 어긋나면(옛 null 포함) 기본(`config_system`) — 기본은 모두 지정값(자동 없음 · 523-1).
# 읽는 프로젝트 = 위 문맥 변수(라우터가 세움) — 엔진 서명을 고치지 않는다.
DRAWING_SCALES_FILE = "drawing_scales.json"
DRAWING_SCALE_DEFAULTS: dict[str, dict[str, int | None]] = {
# plan = 종단 장 우측 상단 평면 위치도(521-2) · None = 자동(노선을 칸에 맞춤)
"longitudinal": {"x": DRAWING_SCALE_LONG_H, "y": DRAWING_SCALE_LONG_V, "plan": None},
DRAWING_SCALE_DEFAULTS: dict[str, dict[str, int]] = {
# plan = 종단 장 우측 상단 평면 위치도(521-2)
"longitudinal": {"x": DRAWING_SCALE_LONG_H, "y": DRAWING_SCALE_LONG_V, "plan": 5000},
"cross": {"x": DRAWING_SCALE_CROSS, "y": DRAWING_SCALE_CROSS},
# 토적 세로 = 1 mm 당 50 ㎥ → 표기 1:50,000 (분모 / 1000 = 종이 1 mm 의 ㎥)
"mass_haul": {"x": None, "y": int(DRAWING_SCALE_MASSHAUL_V_M3_MM * 1000)},
"development": {"x": None, "y": None},
"mass_haul": {"x": DRAWING_SCALE_MASSHAUL_H, "y": int(DRAWING_SCALE_MASSHAUL_V_M3_MM * 1000)},
# 전개도 = 옛 자동이 흔히 고르던 1/50(가로 · 세로 같음)
"development": {"x": 50, "y": 50},
}
@@ -110,7 +112,7 @@ def drawing_scales_path(project_root: Path) -> Path:
return _design_root(project_root) / DRAWING_SCALES_FILE
def read_drawing_scales(project_root: Path | None) -> dict[str, dict[str, int | None]]:
def read_drawing_scales(project_root: Path | None) -> dict[str, dict[str, int]]:
"""종류마다 {x, y} — 파일에 양의 정수가 있으면 그것, 아니면 기본."""
# ponytail: 부를 때마다 작은 파일을 읽음(수정 시각 캐시는 같은 시각 두 번 쓰기에 낡음)
stored: Any = {}
@@ -121,7 +123,7 @@ def read_drawing_scales(project_root: Path | None) -> dict[str, dict[str, int |
stored = {}
if not isinstance(stored, dict):
stored = {}
scales: dict[str, dict[str, int | None]] = {}
scales: dict[str, dict[str, int]] = {}
for kind, defaults in DRAWING_SCALE_DEFAULTS.items():
row = stored.get(kind) if isinstance(stored.get(kind), dict) else {}
scales[kind] = {
@@ -133,8 +135,8 @@ def read_drawing_scales(project_root: Path | None) -> dict[str, dict[str, int |
return scales
def drawing_scale(kind: str) -> dict[str, int | None]:
"""이 요청 프로젝트의 그 종류 축척 {x, y}(분모 · None = 자동)."""
def drawing_scale(kind: str) -> dict[str, int]:
"""이 요청 프로젝트의 그 종류 축척 {x, y}(분모)."""
return read_drawing_scales(_project_root.get())[kind]
@@ -34,7 +34,7 @@ from config.config_db import get_db_pool
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
# 분모 범위(정수) — 기본 · 자동 후보(토적 500~6,000 · 전개도 50~5,000)를 모두 담음.
# 분모 범위(정수) — 기본(`DRAWING_SCALE_DEFAULTS`) · 도면 관행 축척(토적 500~6,000 · 전개도 50~5,000)을 모두 담음.
SCALE_RANGES: dict[str, dict[str, tuple[int, int]]] = {
"longitudinal": {"x": (100, 10000), "y": (10, 2000), "plan": (500, 50000)},
"cross": {"x": (10, 1000), "y": (10, 1000)},
@@ -50,7 +50,7 @@ _KIND_LABEL = {
def checked_scales(scales: Any) -> dict[str, dict[str, int | None]]:
"""{종류: {x, y}} — 분모 정수 · 범위 안 · null = 기본(자동 칸은 자동)."""
"""{종류: {x, y}} — 분모 정수 · 범위 안 · null = 기본."""
if not isinstance(scales, dict):
raise ValueError("축척은 {종류: {x, y}} 꼴로 보내 주세요.")
checked: dict[str, dict[str, int | None]] = {}
@@ -138,7 +138,7 @@ def write_drawing_scales(
class DrawingScalesRequest(BaseModel):
"""바꿀 종류만 — {종류: {x, y}} · 분모 정수 · null = 기본(자동)."""
"""바꿀 종류만 — {종류: {x, y}} · 분모 정수 · null = 기본."""
scales: dict[str, Any] = Field(default_factory=dict)
@@ -7,7 +7,6 @@
* ========================================================================== */
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
import { buildDevelopmentSection } from "./B07_DesignDetail_UI_Development";
@@ -236,99 +235,6 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean {
return drawing.kind === "cross" && drawing.id.startsWith("cross_s");
}
/** 표 · 글 배율 범위(180-2) — 서버 `CROSS_SCALE_RANGE` 와 같음. */
const CROSS_SCALE_MIN = 0.5;
const CROSS_SCALE_MAX = 2;
/** 배치 칸 줄 뒤 덧칸 — 표 배율(180-2). */
export interface CrossLayoutExtras {
scale?: { value: number; onApply: (scale: number) => void };
}
/**
* 횡단 장 배치 칸(140-3 · 153-1) — 줄마다 한 일(212-3):
* 「배치 열 [4] 행 [4]」 · 「[이 장] [모든 장]」 · 「표 배율 [1] [배율 적용]」 · (부르는 쪽이 붙이는 [안내선 숨기기]).
* 단추 · 숫자 칸 = 줄 위 작은 단추 한 규격(`--btn-bar-*`) · `onApply` 가 없으면(장이 아닌 도면) [이 장]을 끔.
*/
export function buildCrossLayoutControls(
grid: [number, number],
onApply: ((grid: [number, number]) => void) | null,
onAllDefault: (grid: [number, number]) => void,
extras: CrossLayoutExtras = {},
): HTMLElement {
const box = document.createElement("div");
box.className = "b07-cross-layout";
const row = (...items: HTMLElement[]): HTMLElement => {
const line = document.createElement("div");
line.className = "b07-cross-layout__row";
line.append(...items);
return line;
};
const number = (value: number, label: string) =>
createInputField({
label,
type: "number",
value: String(value),
min: 1,
max: 10,
compact: true,
});
const cols = number(grid[0], "배치 열");
const rows = number(grid[1], "행");
const read = (): [number, number] | null => {
const value: [number, number] = [Number(cols.input.value), Number(rows.input.value)];
return value.every((n) => Number.isInteger(n) && n >= 1 && n <= 10) ? value : null;
};
const action = (label: string, title: string, run: ((grid: [number, number]) => void) | null) => {
const button = createButton({
label,
variant: "ghost",
size: "sm",
disabled: !run,
onClick: () => {
const value = read();
if (value) run?.(value);
else showToast("열 · 행은 1~10 사이 정수로 적어 주세요.", "error");
},
});
button.title = title;
return button;
};
box.append(
row(cols.root, rows.root),
row(
action("이 장", "이 장만 이 배치로", onApply),
action("모든 장", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault),
),
);
if (extras.scale) {
const { onApply: applyScale } = extras.scale;
const scale = createInputField({
label: "표 배율",
type: "number",
value: String(extras.scale.value),
min: CROSS_SCALE_MIN,
max: CROSS_SCALE_MAX,
compact: true,
});
scale.input.setAttribute("step", "0.1");
const button = createButton({
label: "배율 적용",
variant: "ghost",
size: "sm",
onClick: () => {
const value = Number(scale.input.value);
if (value >= CROSS_SCALE_MIN && value <= CROSS_SCALE_MAX) applyScale(value);
else
showToast(`표 배율은 ${CROSS_SCALE_MIN}~${CROSS_SCALE_MAX} 사이로 적어 주세요.`, "error");
},
});
button.title = "모든 장의 표 · 표 위 글 크기(기본 1)";
box.append(row(scale.root, button));
}
return box;
}
/** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */
function ditchLabel(design: CrossDesignInfo): string {
const ditch = design.ditch;
+121 -39
View File
@@ -3,21 +3,19 @@
* 좌측 도면 묶음 ⚙ 설정 모달(요구 280-5) — 공용 `openModal` · 머리 [원본에서 다시 그리기](281).
*
* 평면도 = 평면도 관리 모달 그대로(`openPlanManager`)
* 횡단도 = 장 배치(열 · 행 · [이 장] · [모든 장]) · 표 배율 · [안내선 숨기기] — 좌측 카드 밑에서 옮김
* 그 밖 = 그룹 상자(523-1) + 맨 아래 단추 줄 하나 — [안내선 숨기기](횡단) · [기본값](축척) · [취소] · [확인]
* 횡단도 = 「장 배치」(열 · 행 · 적용 이 장 / 모든 장) · 「표」(표 배율) · 축척
* 구조물 도면 = 「M02 그림 — 축척 없음」 한 줄 · 종단 · 횡단 · 토적 · 전개도 = X · Y 축척 칸(`_UI_Settings_Scale`)
* [확인] = 바뀐 것만 저장(축척 → 배치) 뒤 닫고 다시 그림 · 바뀐 것 없으면 닫기만 · 붉은 칸 있으면 꺼짐
* [원본에서 다시 그리기] = 지금 연 도면이 그 묶음일 때만 켬
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { CrossLayout, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
import {
buildCrossLayoutControls,
DRAWING_GROUPS,
isCrossSheet,
} from "./B07_DesignDetail_UI_Panels";
import { DRAWING_GROUPS, isCrossSheet } from "./B07_DesignDetail_UI_Panels";
import { openPlanManager, type PlanManageControls } from "./B07_DesignDetail_UI_PlanManage";
import { mountDrawingScale } from "./B07_DesignDetail_UI_Settings_Scale";
import { mountDrawingScale, settingsGroup } from "./B07_DesignDetail_UI_Settings_Scale";
type Kind = DesignDrawingItem["kind"];
@@ -40,6 +38,10 @@ export interface DrawingSettingsDeps {
/** ⚙ 묶음에 드는 도면 종류 — 횡단도 카드엔 표준 횡단면도 줄이 같이 있음. */
const memberOf = (kind: Kind): Kind[] => (kind === "cross" ? ["cross", "cross_standard"] : [kind]);
/** 표 · 글 배율 범위(180-2) — 서버 `CROSS_SCALE_RANGE` 와 같음. */
const CROSS_SCALE_MIN = 0.5;
const CROSS_SCALE_MAX = 2;
const note = (text: string): HTMLElement => {
const line = document.createElement("p");
line.className = "b07-settings__note";
@@ -81,47 +83,127 @@ export function openDrawingSettings(kind: Kind, deps: DrawingSettingsDeps): void
mount: (body, close) => {
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);
if (kind === "structure") body.append(settingsGroup("축척", note("M02 그림 — 축척 없음")));
const confirm = createButton({ label: "확인", onClick: () => void apply() });
const refresh = (): void => {
confirm.disabled = scale?.read() === "invalid" || cross?.read() === "invalid";
};
const cross = kind === "cross" ? mountCross(body, deps, refresh) : undefined;
const scale = mountDrawingScale(body, kind, deps, refresh);
const apply = async (): Promise<void> => {
const nextScale = scale?.read() ?? null;
const nextLayout = cross?.read() ?? null;
if (nextScale === "invalid" || nextLayout === "invalid") return;
if (nextScale && !(await scale!.save(nextScale))) return;
close();
if (nextLayout) deps.cross.save(nextLayout, cross!.sheet);
else if (nextScale) deps.plan?.onClosed(true, deps.current()?.id); // 목록 · 도면 다시 받음
};
const cancel = createButton({ label: "취소", variant: "ghost", onClick: close });
cancel.classList.add("b07-settings__cancel");
const footer = document.createElement("div");
footer.className = "b07-settings__actions";
if (cross) footer.append(deps.cross.guideToggle);
if (scale) {
const reset = createButton({ label: "기본값", variant: "ghost", onClick: scale.reset });
reset.title = "축척 칸을 기본으로 채움 — [확인]으로 씀";
footer.append(reset);
}
footer.append(cancel, confirm);
body.append(footer);
},
});
}
/** 횡단 모달 몸 — 배치 칸은 받은 뒤 · 안내선 단추는 먼저(배치를 못 받아도 남음). */
function mountCross(body: HTMLElement, deps: DrawingSettingsDeps, close: () => void): void {
interface CrossFields {
sheet: number;
/** null = 그대로(못 받음 포함) · "invalid" = 붉은 칸 · 그 밖 = 저장할 배치 */
read: () => CrossLayout | null | "invalid";
}
/** 횡단 「장 배치」 · 「표」 그룹 — 칸은 배치를 받은 뒤 채움. */
function mountCross(
body: HTMLElement,
deps: DrawingSettingsDeps,
onCheck: () => void,
): CrossFields {
const current = deps.current();
const sheet =
current && isCrossSheet(current)
? deps.drawings.filter(isCrossSheet).findIndex((item) => item.id === current.id) + 1
: 0;
body.classList.add("ui-panel-form"); // 칸 · 단추 꼴 = 좌측 패널 때와 같음(269)
const holder = document.createElement("div");
holder.className = "b07-cross-layout";
holder.append(deps.cross.guideToggle);
body.append(holder);
const number = (label: string, min: number, max: number) => {
const field = createInputField({ label, type: "number", min, max, compact: true });
field.input.disabled = true; // 받기 전
field.input.addEventListener("input", onCheck);
return field;
};
const cols = number("배치 열", 1, 10);
const rows = number("행", 1, 10);
const scope = createSelectField({
label: "적용",
compact: true,
options: [
...(sheet ? [{ value: "sheet", text: "이 장만" }] : []),
{ value: "all", text: "모든 장" },
],
value: sheet ? "sheet" : "all",
});
scope.select.title = "이 장만 = 지금 장 · 모든 장 = 모든 장을 이 배치로(장마다 값 지움)";
const table = number("표 배율", CROSS_SCALE_MIN, CROSS_SCALE_MAX);
table.input.setAttribute("step", "0.1");
table.root.title = "모든 장의 표 · 표 위 글 크기(기본 1)";
const line = (...items: HTMLElement[]): HTMLElement => {
const row = document.createElement("div");
row.className = "b07-cross-layout__row";
row.append(...items);
return row;
};
const layoutBox = settingsGroup("장 배치", line(cols.root, rows.root), line(scope.root));
const tableBox = settingsGroup("표", line(table.root));
layoutBox.classList.add("b07-cross-layout");
tableBox.classList.add("b07-cross-layout");
body.append(layoutBox, tableBox);
let layout: CrossLayout | undefined;
let shown: [number, number] = [0, 0];
void deps.cross
.layout()
.then((layout) => {
const apply = (next: CrossLayout): void => {
close();
deps.cross.save(next, sheet);
};
const controls = buildCrossLayoutControls(
(sheet && layout.sheets[String(sheet)]) || layout.default,
sheet
? (grid) => apply({ ...layout, sheets: { ...layout.sheets, [String(sheet)]: grid } })
: null,
(grid) => apply({ ...layout, default: grid, sheets: {} }),
{
scale: {
value: layout.table_scale ?? 1,
onApply: (scale) => apply({ ...layout, table_scale: scale }),
},
},
);
controls.append(deps.cross.guideToggle);
holder.replaceWith(controls);
.then((got) => {
layout = got;
shown = (sheet && got.sheets[String(sheet)]) || got.default;
cols.input.value = String(shown[0]);
rows.input.value = String(shown[1]);
table.input.value = String(got.table_scale ?? 1);
for (const field of [cols, rows, table]) field.input.disabled = false;
})
.catch(() => holder.prepend(note("배치를 받지 못했습니다.")));
.catch(() => layoutBox.append(note("배치를 받지 못했습니다.")));
return {
sheet,
read: () => {
if (!layout) return null;
const grid: [number, number] = [Number(cols.input.value), Number(rows.input.value)];
const tableScale = Number(table.input.value);
const badGrid = !grid.every((n) => Number.isInteger(n) && n >= 1 && n <= 10);
const badTable = !(tableScale >= CROSS_SCALE_MIN && tableScale <= CROSS_SCALE_MAX);
cols.setError(badGrid ? "1~10 정수" : undefined);
rows.setError(badGrid ? "1~10 정수" : undefined);
table.setError(badTable ? `${CROSS_SCALE_MIN}~${CROSS_SCALE_MAX}` : undefined);
if (badGrid || badTable) return "invalid";
let next = layout;
const touched =
grid[0] !== shown[0] ||
grid[1] !== shown[1] ||
scope.select.value !== (sheet ? "sheet" : "all");
if (touched && scope.select.value === "sheet") {
next = { ...next, sheets: { ...next.sheets, [String(sheet)]: grid } };
} else if (touched) {
next = { ...next, default: grid, sheets: {} };
}
if (tableScale !== (layout.table_scale ?? 1)) next = { ...next, table_scale: tableScale };
return next === layout ? null : next;
},
};
}
@@ -3,14 +3,13 @@
* ⚙ 모달 X · Y 축척 칸(485-2 · 요구 280-5) — 종단 · 횡단 · 토적 · 전개도만.
* 종단 = 셋째 칸 「평면도」(521-2 · 우측 상단 평면 위치도) — 칸은 서버 기본값 열쇠 그대로.
*
* 칸 = 분모 정수 · 범위 밖 = 칸 붉게 + [축척 저장] 꺼짐 · 기본이 자동인 칸(토적 X · 전개도) = 「자동」
* [축척 저장] = 그 종류 저장본이 있으면 n 장 물음(취소 = 그대로) → PUT → 화면 다시 세움(도면 다시 그림)
* [기본값] = 칸만 기본으로 채움 — 저장은 [축척 저장]
* 칸 = 분모 정수(늘 지정값 · 자동 없음 523-1) · 범위 밖 = 칸 붉게 + 모달 [확인] 꺼짐
* 저장 = 모달 [확인](`_UI_Settings`)이 `save` 를 부름 — 그 종류 저장본이 있으면 n 장 물음(취소 = 그대로) → PUT
* [기본값] = 칸만 기본으로 채움 — 저장은 [확인]
* `settingsGroup` = 모달 안 그룹 상자(523-1) — 횡단 배치 · 표도 같은 상자
* ========================================================================== */
import { createCheckbox } from "@ui/ui_template_checkbox";
import {
createButton,
createInputField,
hideLoadingOverlay,
showConfirmDialog,
@@ -21,6 +20,7 @@ import {
type DrawingScaleAxes,
type DrawingScaleKind,
type DrawingScales,
type DrawingScaleSend,
fetchDrawingScales,
saveDrawingScales,
} from "./B07_DesignDetail_Api_Fetch";
@@ -38,86 +38,104 @@ const AXIS_LABEL: Record<Axis, string> = { x: "X(가로) 1/", y: "Y(세로) 1/",
const isScaleKind = (kind: string): kind is DrawingScaleKind => kind in LABEL;
/** ⚙ 모달 몸에 축척 칸을 붙임 — 축척 없는 종류는 아무것도 안 함. */
/** 모달 안 그룹 상자 — 제목(legend) + 칸. */
export function settingsGroup(legend: string, ...children: HTMLElement[]): HTMLFieldSetElement {
const box = document.createElement("fieldset");
box.className = "b07-settings__group";
const title = document.createElement("legend");
title.textContent = legend;
box.append(title, ...children);
return box;
}
/** 축척 칸 한 벌 — 모달 [확인] · [기본값] 이 씀. */
export interface ScaleFields {
/** null = 그대로(못 받음 포함) · "invalid" = 붉은 칸 있음 · 그 밖 = 보낼 값 */
read: () => DrawingScaleSend | null | "invalid";
reset: () => void;
/** 저장본 물음 → PUT — 취소 · 실패면 거짓(모달 머묾) */
save: (next: DrawingScaleSend) => Promise<boolean>;
}
/** ⚙ 모달 몸에 축척 그룹을 붙임 — 축척 없는 종류는 undefined · `onCheck` = 칸 바뀜(모달 [확인] 켜고 끔). */
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 = "축척 받는 중…";
onCheck: () => void,
): ScaleFields | undefined {
if (!isScaleKind(kind) || !deps.plan) return undefined;
const { projectId } = deps.plan;
const status = document.createElement("p");
status.className = "b07-settings__note";
status.textContent = "축척 받는 중…";
const section = settingsGroup(
kind === "cross" ? "축척 — 횡단도 장(표준 횡단면도 뺌)" : `축척 — ${LABEL[kind]}`,
status,
);
section.classList.add("b07-scale");
body.append(section);
let built: Omit<ScaleFields, "save"> | undefined;
let data: DrawingScales | undefined;
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();
}
});
.then((got) => {
data = got;
status.remove();
built = buildScaleFields(section, kind, got, onCheck);
onCheck();
})
.catch(() => {
section.textContent = "축척을 받지 못했습니다.";
status.textContent = "축척을 받지 못했습니다.";
});
return {
read: () => built?.read() ?? null,
reset: () => built?.reset(),
save: async (next) => {
const saved = data?.saved[kind] ?? 0;
const ask = `${LABEL[kind]} 축척을 바꾸면 저장본 ${saved}장이 풀립니다(원본에서 다시 그림). 바꿀까요?`;
if (saved > 0 && !(await showConfirmDialog(ask, "바꿈"))) return false;
showLoadingOverlay();
try {
await saveDrawingScales(projectId, { [kind]: next });
showToast(`${LABEL[kind]} 축척을 바꿨습니다.`, "success");
return true;
} catch (error) {
showToast(error instanceof Error ? error.message : "축척을 저장하지 못했습니다.", "error");
return false;
} finally {
hideLoadingOverlay();
}
},
};
}
function buildScaleFields(
section: HTMLElement,
kind: DrawingScaleKind,
data: DrawingScales,
save: (next: DrawingScaleAxes) => Promise<void>,
): void {
onCheck: () => void,
): Omit<ScaleFields, "save"> {
const axes = Object.keys(data.defaults[kind]) as Axis[];
const fields = axes.map((axis) => {
const label = AXIS_LABEL[axis];
const fallback = data.defaults[kind][axis] ?? null;
const fallback = data.defaults[kind][axis] as number;
const range = data.ranges[kind][axis] ?? [1, 1];
const [min, max] = range;
const field = createInputField({ label, type: "number", compact: true, min, max });
const field = createInputField({
label: AXIS_LABEL[axis],
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;
const set = (value: number): void => {
field.input.value = String(value);
};
set(data.scales[kind][axis] ?? null);
const read = () => readScaleAxis(field.input.value, !!auto?.input.checked, range);
return { axis, field, auto, fallback, set, read };
set(data.scales[kind][axis] ?? fallback);
const read = () => readScaleAxis(field.input.value, range);
return { axis, field, fallback, set, read };
});
const saveButton = createButton({
label: "축척 저장",
onClick: () => {
const read = fields.map((f) => [f, f.read() as number | null] as const);
const now = data.scales[kind];
if (read.every(([f, value]) => value === (now[f.axis] ?? null))) {
showToast("바뀐 축척이 없습니다.", "info");
return;
}
const next = Object.fromEntries(read.map(([f, v]) => [f.axis, scaleToSend(v, f.fallback)]));
void save(next as DrawingScaleAxes);
},
});
// 칸 밑 오류 글은 숨기고(틀린 칸만 길어짐) 줄 아래 한 줄로 알림
const alertLine = document.createElement("p");
alertLine.className = "b07-scale__error";
@@ -134,35 +152,27 @@ function buildScaleFields(
}
alertLine.textContent = [...errors].map(([m, axes]) => `${axes.join(" · ")}: ${m}`).join(" / ");
alertLine.hidden = errors.size === 0;
saveButton.disabled = errors.size > 0;
onCheck();
};
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 = "칸을 기본 축척으로 채움 — [축척 저장]으로 씀";
for (const f of fields) f.field.input.addEventListener("input", check);
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, alertLine, buttons);
section.append(row, alertLine);
return {
read: () => {
const read = fields.map((f) => [f, f.read()] as const);
if (read.some(([, value]) => typeof value === "string")) return "invalid";
const now = data.scales[kind];
if (read.every(([f, value]) => value === (now[f.axis] ?? f.fallback))) return null;
return Object.fromEntries(
read.map(([f, v]) => [f.axis, scaleToSend(v as number, f.fallback)]),
) as DrawingScaleSend;
},
reset: () => {
fields.forEach((f) => f.set(f.fallback));
check();
},
};
}
+53 -15
View File
@@ -214,8 +214,8 @@
color: var(--color-text-muted);
}
/* 횡단 장 배치(153-1 · 212-3 · 280-5 ⚙ 횡단 모달) — 한 줄에 한 일:
배치 열 · 행 / [이 장] [모든 장] / 표 배율 [배율 적용] / [안내선 숨기기] · 한 줄 안 같은 너비. */
/* 횡단 장 배치(153-1 · 280-5 ⚙ 횡단 모달 · 523-1 그룹 상자) — 한 줄에 한 일:
「장 배치」 배치 열 · 행 / 적용 · 「표」 표 배율 · 한 줄 안 같은 너비. */
.b07-cross-layout {
display: flex;
flex-direction: column;
@@ -453,27 +453,71 @@ html.is-bm-route .b07-cad-stack > .cad-tabs {
padding-block: 2px;
}
/* ⚙ 설정 모달(280-5) — 안내 한 줄 · 횡단 배치 칸 */
/* ⚙ 설정 모달(280-5 · 523-1) — 공용 모달 폭 절반 · 그룹 상자 · 맨 아래 단추 줄 하나 */
.ui-modal.b07-settings {
width: min(360px, 92vw);
}
/* 좁은 폭 — 제목 · 머리 단추 한 줄(줄바꿈 없음) */
.ui-modal.b07-settings .ui-modal__top > h3 {
font-size: 16px;
white-space: nowrap;
}
.ui-modal.b07-settings .ui-modal__top .ui-btn {
padding-inline: var(--spacing-8);
white-space: nowrap;
}
.b07-settings__note {
margin: 0 0 var(--spacing-8);
margin: 0;
color: var(--color-text-secondary);
font-size: 13px;
}
.b07-settings .b07-cross-layout {
.b07-settings__group {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 280px;
min-width: 0;
margin: 0;
padding: var(--spacing-8) var(--spacing-12) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
/* ⚙ 축척 칸(485-2) — X · Y 두 칸 한 줄 · 단추 오른쪽 */
.b07-settings__group > legend {
padding: 0 var(--spacing-4);
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 600;
}
.b07-settings__actions {
display: flex;
align-items: center;
gap: var(--spacing-8);
padding-top: var(--spacing-8);
border-top: 1px solid var(--color-border);
}
.b07-settings__cancel {
margin-left: auto;
}
/* 단추 줄 한 규격 — 안내선 단추(좌측 패널 작은 꼴)도 같은 키 · 좁은 폭에 맞춘 옆 여백 */
.b07-settings__actions .ui-btn {
padding: var(--spacing-8) var(--spacing-12);
font-size: var(--text-body-sm);
border-radius: var(--radius-buttons);
white-space: nowrap;
}
/* ⚙ 축척 칸(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 {
@@ -495,12 +539,6 @@ html.is-bm-route .b07-cad-stack > .cad-tabs {
font-size: 12px;
}
.b07-scale__actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
}
/* 받지 못한 도면(508-3b) — 미리받기 · 열기가 두 번 다 실패 · 누르면 다시 받음 */
.b07-drawing-button[data-fetch="error"] {
border-left-color: var(--color-danger);
@@ -1,16 +1,11 @@
/* =============================================================================
* B07_DesignDetail_Scale_Check.ts
* ⚙ 축척 칸(485-2) 판정 — 칸 글 → 분모(정수 · 범위 안) · 자동 = null · 틀리면 까닭 글.
* ⚙ 축척 칸(485-2) 판정 — 칸 글 → 분모(정수 · 범위 안) · 틀리면 까닭 글.
* 서버 `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;
/** 칸 하나 읽기 — 숫자 · 까닭 글(틀림). */
export function readScaleAxis(raw: string, [low, high]: [number, number]): number | string {
const text = raw.trim().replace(/,/g, "");
const value = Number(text);
if (text === "" || !Number.isInteger(value) || value < low || value > high) {
@@ -20,5 +15,5 @@ export function readScaleAxis(
}
/** 보낼 값 — 기본값과 같으면 null(파일엔 기본 그대로 남김). */
export const scaleToSend = (value: number | null, fallback: number | null): number | null =>
export const scaleToSend = (value: number, fallback: number): number | null =>
value === fallback ? null : value;