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;
+130 -81
View File
@@ -1,6 +1,7 @@
/* 153-1 B07 횡단 장 배치 칸 헬퍼 — 좌측 「횡단도」 묶음 맨 아래 자리 · 공용 입력칸 · 작은 단추를
* Node 로 돌려 표준출력 JSON 한 벌로. TS 는 그 자리에서 트랜스파일 · 화면 그릇(DOM)은 쓰는 만큼만 흉내 ·
* 공용 단추 · 입력칸은 진짜(`ui_template_elements`) · 알림만 기록용 가짜.
/* 153-1 · 523-1 B07 횡단 장 배치 헬퍼 — 좌측 「횡단도」 카드 ⚙ 하나 · ⚙ 횡단 모달(그룹 상자
* 「장 배치」 · 「표」 · 맨 아래 단추 줄 하나 · [확인] = 바뀐 것만 저장)을 Node 로 돌려 표준출력 JSON 한 벌로.
* TS 는 그 자리에서 트랜스파일 · 화면 그릇(DOM)은 쓰는 만큼만 흉내 · 공용 단추 · 입력칸 · 고르기칸은 진짜
* (`ui_template_elements`) · 모달 겉틀 · 알림만 기록용 가짜.
*/
const fs = require("fs");
const path = require("path");
@@ -65,6 +66,14 @@ const byClass = (node, name) => all(node).filter((n) => n.classList.contains(nam
globalThis.document = { createElement: (tag) => new FakeNode(tag) };
const toasts = [];
const modals = [];
const openModal = (opts) => {
const body = new FakeNode("div");
const modal = { opts, body, open: true };
modals.push(modal);
opts.mount(body, () => (modal.open = false), new FakeNode("div"));
return { close: () => (modal.open = false) };
};
const { loadTs } = makeLoader({
root: ROOT,
requireFor: (full) => (request) => {
@@ -74,6 +83,9 @@ const { loadTs } = makeLoader({
...loadTs(path.join(UI, "ui_template_elements.ts")),
showToast: (message, kind) => toasts.push([kind, message]),
};
if (request === "@ui/ui_template_modal") return { openModal };
if (request === "./B07_DesignDetail_UI_PlanManage") return {};
if (request === "./B07_DesignDetail_Api_Fetch") return {};
// 공용 카드(269) = 진짜 모듈
if (request === "@ui/ui_template_collapsible")
return loadTs(path.join(UI, "ui_template_collapsible.ts"));
@@ -90,20 +102,18 @@ const { loadTs } = makeLoader({
},
});
const { buildDrawingSidePanel, buildCrossLayoutControls } = loadTs(
path.join(B07, "B07_DesignDetail_UI_Panels.ts"),
);
const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Panels.ts"));
const { openDrawingSettings } = loadTs(path.join(B07, "B07_DesignDetail_UI_Settings.ts"));
const out = {};
const settings = [];
const slot = new FakeNode("div");
slot.className = "b07-cross-layout-slot";
const drawings = [
{ id: "cross_standard", kind: "cross_standard", label: "표준 횡단면도", confirmed: false },
{ id: "cross_s1", kind: "cross", label: "1장", confirmed: false },
{ id: "cross_s2", kind: "cross", label: "2장", confirmed: true },
];
const panel = buildDrawingSidePanel(
[
{ id: "cross_standard", kind: "cross_standard", label: "표준 횡단면도", confirmed: false },
{ id: "cross_s1", kind: "cross", label: "1장", confirmed: false },
{ id: "cross_s2", kind: "cross", label: "2장", confirmed: true },
],
drawings,
() => {},
undefined,
false,
@@ -119,74 +129,113 @@ out.card = {
gears[0].click();
out.card.settings = settings;
// 장 — [이 장] 켜짐 · 값 바꿔 누름
const applied = [];
const defaults = [];
slot.replaceChildren(
buildCrossLayoutControls(
[3, 4],
(grid) => applied.push(grid),
(grid) => defaults.push(grid),
),
);
const box = slot.children[0];
const buttons = () => all(slot).filter((n) => n.tagName === "BUTTON");
const inputs = () => all(slot).filter((n) => n.tagName === "INPUT");
out.sheet = {
boxClass: box.className,
labels: byClass(slot, "ui-field__label").map((n) => n.textContent),
inputs: inputs().map((n) => ({
value: n.value,
type: n.attrs.type,
min: n.attrs.min,
max: n.attrs.max,
cls: n.className,
})),
buttons: buttons().map((n) => ({ text: n.textContent, cls: n.className, disabled: n.disabled })),
};
inputs()[0].value = "2";
inputs()[1].value = "5";
buttons()[0].click();
buttons()[1].click();
inputs()[0].value = "11";
buttons()[0].click();
out.sheetClicks = { applied, defaults, toasts: [...toasts] };
// 장 아님 — [이 장] 꺼짐 · [모든 장]은 됨
const defaults2 = [];
slot.replaceChildren(buildCrossLayoutControls([4, 4], null, (grid) => defaults2.push(grid)));
buttons()[0].click();
buttons()[1].click();
out.notSheet = {
disabled: buttons().map((n) => n.disabled),
defaults: defaults2,
values: inputs().map((n) => n.value),
};
// 180-2 표 배율 칸 — 배치 칸 줄 뒤 · 0.5~2.0 · step 0.1 · [배율 적용]
const scales = [];
const toastStart = toasts.length;
slot.replaceChildren(
buildCrossLayoutControls([4, 4], null, () => {}, {
scale: { value: 1, onApply: (value) => scales.push(value) },
}),
);
const scaleInput = inputs()[2];
out.scale = {
labels: byClass(slot, "ui-field__label").map((n) => n.textContent),
input: {
value: scaleInput.value,
min: scaleInput.attrs.min,
max: scaleInput.attrs.max,
step: scaleInput.attrs.step,
// ── ⚙ 횡단 모달(523-1) ─────────────────────────────────────────────────────
const tick = () => new Promise((r) => setTimeout(r, 0));
const saved = [];
let current = drawings[1]; // cross_s1 = 1장
const guideToggle = new FakeNode("button");
guideToggle.textContent = "안내선 숨기기";
const deps = {
drawings,
current: () => current,
canRedraw: () => false,
redraw: async () => false,
cross: {
layout: async () => ({ default: [4, 4], sheets: { 1: [3, 4] }, table_scale: 1 }),
save: (next, sheet) => saved.push([next, sheet]),
guideToggle,
},
buttons: buttons().map((n) => n.textContent),
};
scaleInput.value = "1.5";
buttons()[2].click();
scaleInput.value = "2.5";
buttons()[2].click();
out.scale.applied = scales;
out.scale.toasts = toasts.slice(toastStart).map((t) => t[0]);
const open = async () => {
openDrawingSettings("cross", deps);
await tick();
const modal = modals.at(-1);
const inputs = all(modal.body).filter((n) => n.tagName === "INPUT");
const select = all(modal.body).find((n) => n.tagName === "SELECT");
const footer = byClass(modal.body, "b07-settings__actions")[0];
const btn = (label) => all(footer).find((n) => n.textContent === label);
const type = (input, value) => {
input.value = value;
(input.listeners.input || []).forEach((h) => h());
};
return { modal, inputs, select, footer, btn, type };
};
process.stdout.write(JSON.stringify(out));
(async () => {
// 장 — 그룹 상자 둘 · 공용 칸 · 단추 줄 하나
let m = await open();
const legends = byClass(m.modal.body, "b07-settings__group").map(
(g) => g.children[0].textContent,
);
const bodyButtons = all(m.modal.body).filter((n) => n.tagName === "BUTTON");
out.sheet = {
legends,
labels: byClass(m.modal.body, "ui-field__label").map((n) => n.textContent),
inputs: m.inputs.map((n) => ({
value: n.value,
type: n.attrs.type,
min: n.attrs.min,
max: n.attrs.max,
step: n.attrs.step ?? null,
cls: n.className,
})),
scope: { options: m.select.children.map((o) => o.textContent), value: m.select.value },
footers: byClass(m.modal.body, "b07-settings__actions").length,
footerButtons: m.footer.children.map((n) => n.textContent),
buttonsOutsideFooter: bodyButtons.filter((n) => !all(m.footer).includes(n)).length,
};
// 이 장만 — 2 × 5
m.type(m.inputs[0], "2");
m.type(m.inputs[1], "5");
m.btn("확인").click();
await tick();
out.applySheet = { saved: [...saved], closed: !m.modal.open };
// 모든 장 — 고르기만 바꿔도 씀(장마다 값 지움)
saved.length = 0;
m = await open();
m.select.value = "all";
m.btn("확인").click();
await tick();
out.applyAll = [...saved];
// 범위 밖 = 칸 붉음 · [확인] 꺼짐 · 저장 없음
saved.length = 0;
m = await open();
m.type(m.inputs[0], "11");
const confirm = m.btn("확인");
out.badGrid = {
disabled: confirm.disabled,
error: m.inputs[0].className.includes("ui-input--error"),
};
confirm.click();
await tick();
out.badGrid.saved = saved.length;
out.badGrid.open = m.modal.open;
// 표 배율 — 1.5 = 배율만 · 2.5 = 꺼짐
m = await open();
m.type(m.inputs[2], "2.5");
out.badTable = m.btn("확인").disabled;
m.type(m.inputs[2], "1.5");
m.btn("확인").click();
await tick();
out.applyTable = [...saved];
// 장 아님(표준 횡단면도) — 고르기 = 모든 장 하나 · 손대지 않고 [확인] = 저장 없이 닫음
saved.length = 0;
current = drawings[0];
m = await open();
out.notSheet = {
options: m.select.children.map((o) => o.textContent),
values: m.inputs.map((n) => n.value),
};
m.btn("확인").click();
await tick();
out.notSheet.saved = saved.length;
out.notSheet.closed = !m.modal.open;
// [취소] = 저장 없이 닫음
current = drawings[1];
m = await open();
m.type(m.inputs[0], "2");
m.btn("취소").click();
out.cancel = { saved: saved.length, closed: !m.modal.open };
process.stdout.write(JSON.stringify(out));
})();
+109 -94
View File
@@ -60,6 +60,10 @@ class FakeNode {
kids.forEach((k) => (k.parent = this));
this.children.unshift(...kids);
}
remove() {
if (this.parent) this.parent.children = this.parent.children.filter((c) => c !== this);
this.parent = null;
}
replaceWith(node) {
const at = this.parent.children.indexOf(this);
node.parent = this.parent;
@@ -97,16 +101,16 @@ const calls = [];
let failConfirm = false;
const SCALES = {
scales: {
longitudinal: { x: 1000, y: 200, plan: null },
longitudinal: { x: 1000, y: 200, plan: 5000 },
cross: { x: 100, y: 100 },
mass_haul: { x: null, y: 50000 },
development: { x: null, y: null },
mass_haul: { x: 2000, y: 50000 },
development: { x: 50, y: 50 },
},
defaults: {
longitudinal: { x: 1000, y: 200, plan: null },
longitudinal: { x: 1000, y: 200, plan: 5000 },
cross: { x: 100, y: 100 },
mass_haul: { x: null, y: 50000 },
development: { x: null, y: null },
mass_haul: { x: 2000, y: 50000 },
development: { x: 50, y: 50 },
},
ranges: {
longitudinal: { x: [100, 10000], y: [10, 2000], plan: [500, 50000] },
@@ -141,21 +145,27 @@ const STUBS = {
const setError = (msg) => (input.error = msg ?? null);
return { root, input, setError };
},
createSelectField: (opts) => {
const root = new FakeNode("div");
const label = new FakeNode("span");
label.className = "ui-field__label";
label.textContent = opts.label;
const select = new FakeNode("select");
opts.options.forEach((o) => {
const option = new FakeNode("option");
option.textContent = o.text;
option.value = o.value;
select.append(option);
});
select.value = opts.value ?? opts.options[0]?.value;
root.append(label, select);
return { root, select, setOptions() {} };
},
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");
@@ -280,55 +290,85 @@ const drawings = [
let redraws = 0;
const saved = [];
const guideToggle = button({ label: "안내선 숨기기" });
const closedCalls = [];
const deps = {
drawings,
current: () => current,
canRedraw: () => true,
redraw: async () => (redraws++, true),
plan: { projectId: "p1", onClosed: () => {} },
plan: { projectId: "p1", onClosed: (...a) => closedCalls.push(a) },
cross: {
layout: async () => ({ default: [4, 4], sheets: { 1: [3, 2] }, table_scale: 1.2 }),
save: (next, sheet) => saved.push([next, sheet]),
guideToggle,
},
};
const tick = () => new Promise((r) => setTimeout(r, 0));
const texts = (node) =>
all(node)
.map((n) => n._text)
.filter(Boolean);
const legends = (node) =>
byClass(node, "b07-settings__group").map((g) => g.children[0].textContent);
const footerOf = (modal) => byClass(modal.body, "b07-settings__actions");
const footerBtn = (modal, label) =>
all(footerOf(modal)[0]).find((n) => n.tagName === "BUTTON" && n.textContent === label);
const type = (input, value) => {
input.value = value;
(input.listeners.input || []).forEach((h) => h());
};
let autoBoxes = 0; // 「자동」 체크(523-1 걷음) — 모든 모달에서 셈
const countAuto = (modal) =>
(autoBoxes += all(modal.body).filter((n) => n.attrs.type === "checkbox").length);
openDrawingSettings("cross", deps);
await new Promise((r) => setTimeout(r, 0));
await tick();
await tick();
const cross = modals.at(-1);
const layoutNode = byClass(cross.body, "b07-cross-layout")[0];
const crossInputs = all(layoutNode).filter((n) => n.tagName === "INPUT");
countAuto(cross);
const layoutBoxes = byClass(cross.body, "b07-cross-layout");
const crossInputs = layoutBoxes.flatMap((n) => all(n).filter((k) => k.tagName === "INPUT"));
out.crossModal = {
title: cross.opts.title,
labels: byClass(layoutNode, "ui-field__label").map((n) => n.textContent),
legends: legends(cross.body),
labels: layoutBoxes.flatMap((n) => byClass(n, "ui-field__label")).map((n) => n.textContent),
values: crossInputs.map((n) => n.value),
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,
footers: footerOf(cross).length,
footerButtons: footerOf(cross)[0].children.map((n) => n.textContent),
buttonsInGroups: byClass(cross.body, "b07-settings__group").flatMap((g) =>
all(g).filter((n) => n.tagName === "BUTTON"),
).length,
head: cross.opts.actions.map((n) => [n.textContent, n.disabled]),
notes: texts(cross.body).filter((t) => t.startsWith("지금 도면")),
};
crossInputs[0].value = "2";
all(layoutNode)
.find((n) => n.textContent === "이 장")
.click();
out.crossSave = { saved, closed: !cross.open };
type(crossInputs[0], "2");
footerBtn(cross, "확인").click();
await tick();
out.crossSave = {
saved,
closed: !cross.open,
scaleCalls: calls.filter((c) => c[0] === "scales").length,
};
openDrawingSettings("structure", deps);
const structure = modals.at(-1);
countAuto(structure);
out.structureModal = {
title: structure.opts.title,
notes: texts(structure.body),
legends: legends(structure.body),
footerButtons: footerOf(structure)[0].children.map((n) => n.textContent),
head: structure.opts.actions.map((n) => [n.textContent, n.disabled, n.title]),
};
footerBtn(structure, "확인").click();
out.structureModal.closed = !structure.open;
openDrawingSettings("longitudinal", deps);
out.longModal = { title: modals.at(-1).opts.title, bodyKids: modals.at(-1).body.children.length };
out.longModal = {
title: modals.at(-1).opts.title,
legends: legends(modals.at(-1).body),
footers: footerOf(modals.at(-1)).length,
};
current = drawings[1]; // plan_k
openDrawingSettings("plan", deps);
@@ -338,64 +378,52 @@ const drawings = [
head: plan.controls.actions.map((n) => [n.textContent, n.disabled]),
};
plan.controls.actions[0].click();
await new Promise((r) => setTimeout(r, 0));
await tick();
out.planRedraw = { redraws, closed: !plan.open };
current = drawings[3]; // 표준 횡단면도도 횡단 묶음
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));
// ── 축척 칸(485-2 · 523-1 자동 없음 · 저장 = 모달 [확인]) ─────────────────
closedCalls.length = 0;
const scaleOf = async (kind) => {
openDrawingSettings(kind, scaleDeps);
openDrawingSettings(kind, deps);
await tick();
const modal = modals.at(-1);
countAuto(modal);
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 inputs = all(section).filter((n) => n.tagName === "INPUT");
const btn = (label) => footerBtn(modal, label);
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,
save: !btn("확인").disabled,
});
return { modal, section, inputs, autos, btn, type, toggle, state };
return { modal, section, inputs, btn, 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.longLegend = legends(s.modal.body);
scale.longFooter = footerOf(s.modal)[0].children.map((n) => n.textContent);
scale.longStart = s.state();
s.type(s.inputs[0], "50");
type(s.inputs[0], "50");
scale.longLow = s.state();
s.type(s.inputs[0], "500.5");
type(s.inputs[0], "500.5");
scale.longFrac = s.state();
s.type(s.inputs[0], "500");
type(s.inputs[0], "500");
scale.longOk = s.state();
calls.length = 0;
answer = false;
s.btn("축척 저장").click();
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();
s.btn("확인").click();
await tick();
await tick();
scale.save = {
@@ -405,66 +433,53 @@ const drawings = [
closed: [...closedCalls],
};
s = await scaleOf("longitudinal");
s.type(s.inputs[1], "300");
type(s.inputs[1], "300");
s.btn("기본값").click();
scale.reset = s.state();
calls.length = 0;
const toastAt = toasts.length;
s.btn("축척 저장").click();
s.btn("확인").click();
await tick();
scale.same = { calls: calls.length, toasts: toasts.slice(toastAt).map((t) => t[1]) };
// 평면 위치도 칸(521-2) — 셋째 칸 · 자동 끔 → 범위 밖 붉음 → 1/3,000 저장
scale.same = { calls: calls.length, open: s.modal.open };
// 평면 위치도 칸(521-2) — 처음 1/5,000 · 1/500 밑 = 붉음 · 1/3,000 저장
s = await scaleOf("longitudinal");
scale.planStart = s.state();
s.toggle(s.autos[0]);
s.type(s.inputs[2], "400");
type(s.inputs[2], "400");
scale.planLow = s.state();
s.type(s.inputs[2], "3000");
type(s.inputs[2], "3000");
calls.length = 0;
s.btn("축척 저장").click();
s.btn("확인").click();
await tick();
await tick();
scale.planSent = calls.find((c) => c[0] === "scales")?.[1];
// [취소] = 저장 없이 닫음
s = await scaleOf("longitudinal");
type(s.inputs[0], "500");
calls.length = 0;
s.btn("취소").click();
scale.cancelButton = { calls: calls.length, open: s.modal.open };
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();
type(s.inputs[0], "3000");
calls.length = 0;
s.btn("축척 저장").click();
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);
openDrawingSettings("structure", deps);
scale.structure = byClass(modals.at(-1).body, "b07-scale").length;
scale.autoBoxes = autoBoxes;
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),
];
out.readAxis = ["1,000", " 99 ", "10001", "12.5", "", "abc"].map((raw) =>
Check.readScaleAxis(raw, [100, 10000]),
);
out.toSend = [Check.scaleToSend(200, 200), Check.scaleToSend(500, 200)];
// ── 저장 묶음 ────────────────────────────────────────────────────────────
const tabsOf = (list, activeId) => ({
+36 -32
View File
@@ -1,12 +1,14 @@
# -*- coding: utf-8 -*-
"""153-1 B07 횡단 장 배치 칸.
"""153-1 · 523-1 B07 횡단 장 배치 — ⚙ 횡단 모달.
못박는 것
- 배치 칸 자리 = 「횡단도」 카드 ⚙ 모달(280-5) — 카드엔 ⚙ 하나 · 배치 칸 없음.
- 공용 입력칸(ui-field__label · ui-input--sm · 1~10) 둘 + 작은 ghost 단추 [이 장] [모든 장].
- 장이 아니면 [이 장] 꺼짐 · [모든 장]은 됨 · 범위 밖 값 = 알림만.
- 그룹 상자 「장 배치」(배치 열 · 행 · 적용) · 「표」(표 배율) · 공용 작은 칸(ui-input--sm).
- 단추 줄 = 맨 아래 하나([안내선 숨기기] [취소] [확인]) · 그 밖 단추 없음.
- [확인] = 바뀐 것만 저장 — 이 장만 · 모든 장(장마다 값 지움) · 표 배율만 · 손 안 대면 저장 없이 닫음.
- 범위 밖 = 칸 붉음 + [확인] 꺼짐 · 장이 아니면 적용 = 모든 장 하나 · [취소] = 그대로 닫음.
Node 헬퍼 `helper_153_b07_cross_layout.cjs` 가 화면 그릇 흉내로 돌림. 화면은 사용자 확인.
Node 헬퍼 `helper_153_b07_cross_layout.cjs` 가 화면 그릇 흉내로 돌림.
"""
import json
@@ -38,37 +40,39 @@ def test_횡단도_카드는_설정_단추만(out):
assert card["settings"] == ["cross"]
def test_공용_입력칸과_작은_단추(out):
def test_그룹_상자와_단추_줄_하나(out):
sheet = out["sheet"]
assert sheet["boxClass"] == "b07-cross-layout"
assert sheet["labels"] == ["배치 열", "행"]
assert [i["value"] for i in sheet["inputs"]] == ["3", "4"]
for field in sheet["inputs"]:
assert sheet["legends"] == ["장 배치", "표"]
assert sheet["labels"] == ["배치 열", "행", "적용", "표 배율"]
assert [i["value"] for i in sheet["inputs"]] == ["3", "4", "1"] # 지금 장(1장) 값
for field in sheet["inputs"][:2]:
assert field["type"] == "number" and field["min"] == "1" and field["max"] == "10"
assert "ui-input--sm" in field["cls"]
assert [b["text"] for b in sheet["buttons"]] == ["이 장", "모든 장"]
for button in sheet["buttons"]:
assert button["cls"] == "ui-btn ui-btn--ghost ui-btn--sm" and not button["disabled"]
table = sheet["inputs"][2]
assert (table["min"], table["max"], table["step"]) == ("0.5", "2", "0.1")
assert all("ui-input--sm" in field["cls"] for field in sheet["inputs"])
assert sheet["scope"] == {"options": ["이 장만", "모든 장"], "value": "sheet"}
assert sheet["footers"] == 1 and sheet["buttonsOutsideFooter"] == 0
assert sheet["footerButtons"] == ["안내선 숨기기", "취소", "확인"]
def test_이_장_모든_장_누름(out):
clicks = out["sheetClicks"]
assert clicks["applied"] == [[2, 5]]
assert clicks["defaults"] == [[2, 5]]
assert [t[0] for t in clicks["toasts"]] == ["error"] # 11 = 범위 밖
def test_확인은_바뀐_것만_저장(out):
assert out["applySheet"] == {
"saved": [[{"default": [4, 4], "sheets": {"1": [2, 5]}, "table_scale": 1}, 1]],
"closed": True,
}
assert out["applyAll"] == [[{"default": [3, 4], "sheets": {}, "table_scale": 1}, 1]]
assert out["applyTable"] == [
[{"default": [4, 4], "sheets": {"1": [3, 4]}, "table_scale": 1.5}, 1]
]
def test_장_아니면_이_장_꺼짐(out):
not_sheet = out["notSheet"]
assert not_sheet["disabled"] == [True, False]
assert not_sheet["defaults"] == [[4, 4]]
assert not_sheet["values"] == ["4", "4"]
def test_표_배율_칸(out):
scale = out["scale"]
assert scale["labels"] == ["배치 열", "행", "표 배율"]
assert scale["input"] == {"value": "1", "min": "0.5", "max": "2", "step": "0.1"}
assert scale["buttons"] == ["이 장", "모든 장", "배율 적용"]
assert scale["applied"] == [1.5]
assert scale["toasts"] == ["error"] # 2.5 = 범위 밖
def test_범위_밖_장_아님_취소(out):
assert out["badGrid"] == {"disabled": True, "error": True, "saved": 0, "open": True}
assert out["badTable"] is True
assert out["notSheet"] == {
"options": ["모든 장"],
"values": ["4", "4", "1"],
"saved": 0,
"closed": True,
}
assert out["cancel"] == {"saved": 0, "closed": True}
@@ -68,8 +68,8 @@ def test_페이지_잇기_토스트_거름_장만():
assert "if (guideShown() && guide?.marks?.length) {" in page # 숨김 = 넘침 토스트 없음
assert "cad.setGuideVisible(visible)" in page
assert "if (!guideShown()) cad.setGuideVisible(false);" in page # 다시 세운 화면에 이어 줌
# 배치 칸 줄 끝 = ⚙ 횡단 모달(280-5)
assert "controls.append(deps.cross.guideToggle);" in _read(
# ⚙ 횡단 모달(280-5) 맨 아래 단추 줄(523-1)
assert "if (cross) footer.append(deps.cross.guideToggle);" in _read(
B07 / "B07_DesignDetail_UI_Settings.ts"
)
assert "guideToggle.disabled = !isCrossSheet(drawing);" in page # 장만
@@ -2,8 +2,8 @@
"""212-3 B07 좌측 「횡단도」 배치 칸 — 단추 · 숫자 칸 한 규격 · 한 줄에 한 일 · 남는 검은 선 판정.
못박는 것
- 줄 = 「배치 열 · 행」 · 「[이 장] [모든 장]」 · 「표 배율 [배율 적용]」
· [안내선 숨기기](부르는 쪽).
- 줄 = 「배치 열 · 행」 · 「적용」 · 「표 배율」(⚙ 횡단 모달 그룹 상자 · 523-1)
· 단추([안내선 숨기기] [취소] [확인]) = 맨 아래 단추 줄 하나.
- 단추 · 숫자 칸 · 이름 = 좌측 패널 공용 양식(269 `.ui-panel-form`) · 한 줄 안 같은 너비.
- 옛 한 줄 감기(flex-wrap) · 숫자 칸 48px 고정 없음.
- 인쇄 칸선 없음(526-1 · 옛 「남는 검은 선」) — 칸은 안내선 겹판(단면마다 점선)이라
@@ -53,12 +53,11 @@ def test_한_줄에_한_일_같은_너비():
assert "flex-wrap" not in box
assert _decl(_block(".b07-cross-layout__row > *"), "flex") == "1 1 0"
assert "width: 48px" not in CSS
assert 'line.className = "b07-cross-layout__row"' in PANELS
assert "row(cols.root, rows.root)" in PANELS
assert re.search(r"row\(\s*action\(\"이 장\"[^;]*action\(\"모든 장\"", PANELS, re.S)
assert "box.append(row(scale.root, button));" in PANELS
# 안내선 단추 = 배치 칸 맨 아래 한 줄(전체 폭) · ⚙ 횡단 모달(280-5)
assert "controls.append(deps.cross.guideToggle);" in SETTINGS
assert 'row.className = "b07-cross-layout__row"' in SETTINGS
assert "line(cols.root, rows.root), line(scope.root)" in SETTINGS
assert "line(table.root)" in SETTINGS
# 안내선 단추 = 맨 아래 단추 줄(523-1) · ⚙ 횡단 모달(280-5)
assert "if (cross) footer.append(deps.cross.guideToggle);" in SETTINGS
def test_인쇄_칸선_없음_칸은_안내선_겹판():
@@ -1,6 +1,6 @@
"""485-1 B07 도면 X · Y 축척(요구 280-5) — 프로젝트마다 `drawing_scales.json`.
값 없음 = 지금 결과 그대로 · 종단 X 바꿈 = 장 수 · 도각 축척 글 · 횡단 X ≠ Y 덧그림 자리 ·
값 없음 = 기본 지정값(자동 없음 · 523-1) · 종단 X 바꿈 = 장 수 · 도각 축척 글 · 횡단 X ≠ Y 덧그림 자리 ·
토적 · 전개도 · 범위 밖 거절 · 바꾼 종류만 저장본 풂.
"""
@@ -218,8 +218,8 @@ MASS_HAUL = {
def test_토적_가로_세로(project: Path) -> None:
auto = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
assert "SCALE H=1:500 V=1:50,000" in _texts(auto) # 40 m = 자동 1/500
base = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
assert "SCALE H=1:2,000 V=1:50,000" in _texts(base) # 기본 = 지정값(자동 없음 · 523-1)
_set(project, {"mass_haul": {"x": 1000, "y": 100000}})
fixed = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
assert "SCALE H=1:1,000 V=1:100,000" in _texts(fixed)
@@ -231,8 +231,8 @@ def test_토적_가로_세로(project: Path) -> None:
def test_전개도_가로_세로(project: Path) -> None:
rows = [dev._row("soil", "돌망태", i * 20.0, i * 20.0 + 6, 2.0, str(i)) for i in range(3)]
sheet = dev.development_sheets(rows)[0]
auto = build_development_drawing(sheet["id"], sheet, 20.0)
assert any(text.startswith("S=1:") for text in _texts(auto))
base = build_development_drawing(sheet["id"], sheet, 20.0)
assert "S=1:50" in _texts(base) # 기본 = 가로 · 세로 1/50 지정값(523-1)
_set(project, {"development": {"x": 200, "y": 100}})
fixed = build_development_drawing(sheet["id"], sheet, 20.0)
assert "H=1:200 V=1:100" in _texts(fixed)
@@ -244,7 +244,7 @@ def test_전개도_가로_세로(project: Path) -> None:
def test_범위_검사() -> None:
assert checked_scales({"cross": {"x": 100, "y": None}}) == {"cross": {"x": 100, "y": None}}
# 종단 평면 위치도(521-2) = 1/500~1/50,000 · null = 자동
# 종단 평면 위치도(521-2) = 1/500~1/50,000 · null = 기본
assert checked_scales({"longitudinal": {"plan": 3000}}) == {
"longitudinal": {"x": None, "y": None, "plan": 3000}
}
@@ -294,7 +294,7 @@ def test_바꾼_종류만_저장본_풂(project: Path) -> None:
drawings = _read_manifest(project)["drawings"]
assert not drawings["longitudinal_1"]["confirmed"]
assert drawings["cross_00020m"]["confirmed"] and drawings["cover"]["confirmed"]
assert read_drawing_scales(project)["longitudinal"] == {"x": 500, "y": 200, "plan": None}
assert read_drawing_scales(project)["longitudinal"] == {"x": 500, "y": 200, "plan": 5000}
# 보낸 종류만 바뀜 — 앞서 쓴 종단은 남음 · 횡단 = 측점 설계 잠정 몫
invalidated, stale = write_drawing_scales(
project, project / "none.json", {"cross": {"x": 100, "y": 50}}
@@ -321,5 +321,5 @@ def test_GET_PUT_범위_밖_400(project: Path, monkeypatch: pytest.MonkeyPatch)
assert not drawing_scales_path(project).exists() # 거절 = 안 씀
put = client.put(url, json={"scales": {"longitudinal": {"x": 500, "y": 200}}})
assert put.status_code == 200
assert put.json()["scales"]["longitudinal"] == {"x": 500, "y": 200, "plan": None}
assert put.json()["scales"]["longitudinal"] == {"x": 500, "y": 200, "plan": 5000}
assert put.json()["invalidated"] == {}
+26 -25
View File
@@ -6,6 +6,8 @@
- ⚙ 누름 = 카드 접힘 그대로 · 도면 안 엶(Enter 도) · 제목 누름 · 단추 누름은 그대로.
- 모달 — 횡단 = 배치 칸(지금 장 값) + 표 배율 + 안내선 · 평면도 = 평면도 관리 모달 · 구조물 = 축척 없음 줄 ·
머리 [원본에서 다시 그리기] = 그 묶음 도면을 연 때만 켬(표준 횡단면도 = 횡단 묶음).
- 523-1 — 그룹 상자(legend) · 맨 아래 단추 줄 하나([기본값] [취소] [확인]) · 「자동」 체크 없음 ·
[확인] = 바뀐 것만 저장 · 축척 기본 = 지정값(종단 평면도 1/5,000 · 토적 X 1/2,000 · 전개도 1/50).
- 저장 묶음 — 고친 탭(●) + 지금 탭만 confirm · 고친 것 없으면 요청 0 · 실패 / 도각 편집 = 거짓(머묾) ·
원본 되돌림 = 물음 → invalidate → 다시 실음 · [취소] = 그대로.
- 페이지 — [현재 도면 확정] · [수정] · 자동 B08 이동 · 「모든 횡단 확정」 잠금 없음 · 읽기 전용 안 묶음 ·
@@ -77,24 +79,25 @@ def test_설정_누름은_접힘_도면_열기_안_일으킴(out):
def test_횡단_모달은_배치_칸_표_배율_안내선(out):
cross = out["crossModal"]
assert cross["title"] == "횡단도 설정"
assert cross["labels"] == ["배치 열", "행", "표 배율"]
assert cross["legends"] == ["장 배치", "표", "축척 — 횡단도 장(표준 횡단면도 뺌)"]
assert cross["labels"] == ["배치 열", "행", "적용", "표 배율"]
assert cross["values"] == ["3", "2", "1.2"] # 지금 장(1장) 값 · 표 배율
assert [b[0] for b in cross["buttons"]] == ["이 장", "모든 장", "배율 적용", "안내선 숨기기"]
assert cross["guideLast"] is True
assert cross["footers"] == 1 and cross["buttonsInGroups"] == 0 # 단추 줄 하나(523-1)
assert cross["footerButtons"] == ["안내선 숨기기", "기본값", "취소", "확인"]
assert cross["head"] == [["원본에서 다시 그리기", False]]
assert cross["notes"] == ["지금 도면: cross_s1"]
assert cross["scaleTitle"] == "축척 — 횡단도 장(표준 횡단면도 뺌)"
saved = out["crossSave"]
assert saved["closed"] is True
assert saved["closed"] is True and saved["scaleCalls"] == 0 # 축척 그대로 = 안 보냄
assert saved["saved"] == [[{"default": [4, 4], "sheets": {"1": [2, 2]}, "table_scale": 1.2}, 1]]
def test_구조물_종단_평면도_모달(out):
structure = out["structureModal"]
assert structure["title"] == "구조물 도면 설정"
assert structure["notes"] == ["M02 그림 — 축척 없음"]
assert structure["legends"] == ["축척"] and "M02 그림 — 축척 없음" in structure["notes"]
assert structure["footerButtons"] == ["취소", "확인"] and structure["closed"] is True
assert structure["head"][0][1] is True # 다른 묶음 도면을 연 중 = 꺼짐
assert out["longModal"] == {"title": "종단도 설정", "bodyKids": 1} # 축척 칸(485-2)
assert out["longModal"] == {"title": "종단도 설정", "legends": ["축척 — 종단도"], "footers": 1}
assert out["planModal"] == {"viaManager": True, "head": [["원본에서 다시 그리기", False]]}
assert out["planRedraw"] == {"redraws": 1, "closed": True}
assert out["standardInCross"] is True
@@ -223,16 +226,18 @@ 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
assert out["readAxis"] == [1000, RANGE, RANGE, RANGE, RANGE, RANGE]
assert out["toSend"] == [None, 500] # 기본과 같으면 null
def test_485_2_종단_축척_저장(out):
scale = out["scale"]
assert scale["longLabels"] == ["X(가로) 1/", "Y(세로) 1/", "평면도 1/"]
assert scale["longStart"]["values"] == ["1000", "200", ""]
assert scale["longStart"]["autos"] == [True] # 종단 = 평면도 칸만 자동(521-2)
for bad in ("longLow", "longFrac"): # 범위 밖 · 소수 = 붉음 + 저장 꺼짐
assert scale["longLegend"] == ["축척 — 종단도"]
assert scale["longFooter"] == ["기본값", "취소", "확인"]
assert scale["longStart"]["values"] == ["1000", "200", "5000"] # 평면도 = 지정값(523-1)
assert scale["longStart"]["disabled"] == [False, False, False]
for bad in ("longLow", "longFrac"): # 범위 밖 · 소수 = 붉음 + [확인] 꺼짐
assert scale[bad]["errors"] == [RANGE, None, None]
assert scale[bad]["save"] is False
assert scale["longOk"]["errors"] == [None, None, None] and scale["longOk"]["save"] is True
@@ -243,27 +248,23 @@ def test_485_2_종단_축척_저장(out):
assert save["sent"] == {"longitudinal": {"x": 500, "y": None, "plan": None}} # 기본 → null
assert save["open"] is False
assert save["closed"] == [[True, "longitudinal"]] # 화면 다시 세움 · 같은 도면
assert scale["reset"]["values"] == ["1000", "200", ""] # [기본값] = 칸만
assert scale["same"] == {"calls": 0, "toasts": ["바뀐 축척이 없습니다."]}
# 평면 위치도 칸(521-2) — 처음 자동(칸 꺼짐) · 1/500 밑 = 붉음 · 1/3,000 저장
assert scale["planStart"]["disabled"] == [False, False, True]
assert scale["reset"]["values"] == ["1000", "200", "5000"] # [기본값] = 칸만
assert scale["same"] == {"calls": 0, "open": False} # 바뀐 것 없음 = 닫기만
assert scale["planLow"]["errors"] == [None, None, "1/500~1/50,000 사이 정수"]
assert scale["planLow"]["save"] is False
assert scale["planSent"] == {"longitudinal": {"x": None, "y": None, "plan": 3000}}
assert scale["cancelButton"] == {"calls": 0, "open": False}
def test_485_2_토적_전개도_자동(out):
def test_523_1_토적_전개도_지정값_자동_없음(out):
scale = out["scale"]
assert scale["massStart"] == {
"values": ["", "50000"],
"values": ["2000", "50000"],
"errors": [None, None],
"disabled": [True, False],
"autos": [True],
"disabled": [False, False],
"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["massSent"] == {"ask": 0, "sent": {"mass_haul": {"x": 3000, "y": None}}}
assert scale["dev"]["values"] == ["50", "50"] and scale["dev"]["disabled"] == [False, False]
assert scale["structure"] == 0 # 구조물 = 축척 칸 없음
assert scale["autoBoxes"] == 0 # 「자동」 체크 = 모든 모달에서 0
+3 -3
View File
@@ -135,11 +135,11 @@ def test_평면_칸_작도영역_우상단_안_겹침_장마다_강조() -> None
)
assert not plan_box_overlaps(longitudinal, sheet["graph"], (x0, y0, *sheet["bbox"][2:]))
assert f"{number}/{len(sheets)} 장" in sheet["texts"]
assert any(text.startswith("평면도 S=1/") for text in sheet["texts"])
assert "평면도 S=1/5,000" in sheet["texts"] # 기본 지정값(자동 없음 · 523-1)
assert sheet["bold"] # 이 장 구간 굵게
lows.append(min(x for x, _y in sheet["bold"]) - box[0])
# 자동 = 모든 장이 같은 축척 · 같은 자리 — 굵은 구간만 장마다 오른쪽으로 옮겨 감
assert lows == sorted(lows) and len(set(lows)) == len(lows)
# 지정 축척 = 장마다 이 장 구간을 칸 가운데로 — 굵은 구간 왼끝이 칸 안
assert all(0.0 <= low <= 150.0 for low in lows)
def test_칸을_덮으면_측점_줄여_장_더_나눔() -> None:
+4 -4
View File
@@ -123,8 +123,8 @@ def test_mass_haul_scale_follows_route_length():
def test_mass_haul_curve_uses_paper_scale():
"""유토곡선 점은 x=거리xmm_h, y=토량/50mm로 옮겨진다. 40m 노선이면 H 1/500 -> 2.0mm/m."""
mm_h = 1000.0 / auto_scale_h(40.0)
"""유토곡선 점은 x=거리xmm_h, y=토량/50mm로 옮겨진다. 가로 = 기본 지정값 1/2,000(523-1) -> 0.5mm/m."""
mm_h = 1000.0 / 2000
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
curve = _lines(drawing, "b08-masshaul-curve")
assert curve[0][0] == (0.0, 0.0)
@@ -143,7 +143,7 @@ def test_mass_haul_balloon_texts():
assert "사토 1" in labels
assert "M.N= 1+10.00" in labels # 측점 간격 20m 기준 30m 지점
assert "유 토 곡 선" in labels
assert "SCALE H=1:500 V=1:50,000" in labels # 40m 노선의 자동 축척
assert "SCALE H=1:2,000 V=1:50,000" in labels # 기본 지정값(자동 없음 · 523-1)
def test_mass_haul_table_marks_match_delivery_form():
@@ -189,7 +189,7 @@ def test_mass_haul_band_chords_sit_at_stored_levels():
"""경계현은 level_base, 운반현은 띠 중간 높이에 저장값 그대로 놓인다."""
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
chords = _lines(drawing, "b08-masshaul-band")
mm_h = 1000.0 / auto_scale_h(40.0)
mm_h = 1000.0 / 2000 # 기본 지정값(523-1)
assert ((0.0, 0.0), (40.0 * mm_h, 0.0)) in chords # 경계현 level_base=0
assert ((6.0 * mm_h, 300.0 * 0.02), (34.0 * mm_h, 300.0 * 0.02)) in chords # 중간 300㎥
+6 -1
View File
@@ -1,6 +1,7 @@
/* =============================================================================
* ui_template_modal.ts
* 공용 모달 겉틀 — 배경 · 제목줄 · 닫기 · Esc · 포커스. 몸은 `mount` 가 채움.
* 바깥 누르기 = 누름 · 뗌 둘 다 배경일 때만 닫음(칸 끌기로 안 닫힘).
* M01 · M02 가 함께 씀 — `dialogClass` · `backdropClass` 로 화면별 겉모양을 덧붙임.
* 곁값 — `actions`(머리 [닫기] 바로 왼쪽 단추 · mount 셋째 인자 자리에 넣어도 됨) · `wide`(큰 창 96vw × 94vh).
* 안 넘긴 창은 지금 꼴 그대로.
@@ -49,7 +50,11 @@ export function openModal(opts: ModalOptions): { close: () => void } {
className: `ui-modal__backdrop ${opts.backdropClass ?? ""}`.trim(),
children: [dialog],
});
backdrop.addEventListener("click", (ev) => ev.target === backdrop && void ask());
// 바깥 누르기 = 누름 · 뗌 둘 다 바깥일 때만 — 칸 안에서 끌어 바깥에서 떼면(그 반대도) 안 닫음
let outside = false;
backdrop.addEventListener("pointerdown", (ev) => (outside = ev.target === backdrop));
backdrop.addEventListener("pointerup", (ev) => (outside &&= ev.target === backdrop));
backdrop.addEventListener("click", (ev) => ev.target === backdrop && outside && void ask());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && void ask());
opts.mount(body, close, actions);
// 단추가 있을 때만 머리에 — [닫기] 는 머리 직속 그대로(B05 추천의 `.ui-modal__top > button` 갈아 끼우기)