Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3', 'origin/sub_laptop_5' and 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
43 changed files with 4769 additions and 161 deletions
+12 -5
View File
@@ -2,9 +2,10 @@
* design_structure_road.ts
* 노면공 › 콘크리트 포장 명세 — 사용자 구조물 표 08(`docs/raw/구조물정의_표/08_사토장_토취장_노면공_기타.md`).
*
* 기준 측점(패널 위치 칸) + 필수 컨테이너 「상세 내용」 한 벌 = 포장사양 고르기 · 높이(m) 손 입력.
* 구간 길이 · 전 · 후는 정의 옵션 칸 그대로(구간 배치) · 표에 없는 옛 상세 칸은 화면에서 빼고 값은 남김.
* 저장 = structures.json `options` — 새 키 `pave_spec` · `height_m`.
* 시작 · 종료 측점(패널 위치 줄 · 기준 측점 · 전 · 후 · 길이 입력 없음) + 필수 컨테이너 「상세 내용」 한 벌 =
* 포장사양 고르기 → 길이(m · 시작~종료 출력) → 높이(m · 두께 손 입력). 표에 없는 옛 상세 칸은 화면에서 빼고 값은 남김.
* 저장 = structures.json `options` — 새 키 `pave_spec` · `height_m` · `widening_off`(확폭 끈 구간 · 87-3) ·
* 옛 `length_m` · `before_m` · `after_m` 은 저장 때 뺌(`dropped`).
* 기타(52-14) = 기준 측점 + 상세 종류 이름(정의 옵션 칸 `name`) + 선택 컨테이너 「상세 내용」(비고 글) ·
* 저장 키 `etc_detail_{n}_note` · 잇기 `getcdetail{n}`.
* import 없음 — 시험이 이 파일만 옮겨 돌림.
@@ -12,8 +13,11 @@
import type { SpecChoice, StructureSpec } from "./design_structure_spec";
/** 포장사양 선택지 — 표의 나머지 값은 사용자 답 뒤 여기에만 더함. */
export const PAVE_SPEC_CHOICES: SpecChoice[] = [{ value: "콘크리트", label: "콘크리트" }];
/** 포장사양 선택지 — 근거 별표2 · 별표3 · 임도기술교본 3장. */
export const PAVE_SPEC_CHOICES: SpecChoice[] = ["콘크리트", "아스콘", "쇄석"].map((v) => ({
value: v,
label: v,
}));
export const PAVEMENT_SPEC: StructureSpec = {
typeId: "pavement_concrete",
@@ -31,6 +35,7 @@ export const PAVEMENT_SPEC: StructureSpec = {
key: "pave_spec",
choices: PAVE_SPEC_CHOICES,
},
{ id: "length", label: "길이 (m)", kind: "auto" },
{ id: "height", label: "높이 (m)", kind: "number", key: "height_m", step: 0.05 },
],
},
@@ -44,6 +49,8 @@ export const PAVEMENT_SPEC: StructureSpec = {
"separation_sheet",
"wire_mesh",
],
dropped: ["length_m", "before_m", "after_m"],
byRange: true,
};
export const ETC_SPEC: StructureSpec = {
@@ -69,6 +69,10 @@ export interface StructureSpec {
containers: SpecContainer[];
/** 명세가 화면에서 뺀 옛 키 — 값은 그대로 남고 「지난 정의 값」으로 보임. */
retired: string[];
/** 저장 때 지우는 옛 키 — 화면에도 안 그림(옛 항목을 [수정]하면 빠짐). */
dropped?: string[];
/** 범위 직접 입력형 — 시작 · 종료 측점만 받고 기준 측점 칸 없음(알약 = 시점 · 길이 · 전 · 후 입력 없음). */
byRange?: boolean;
}
/** 글 항목 길이 상한(자) — 칸 · 저장이 함께 자름. */
@@ -49,6 +49,7 @@ import {
type WallPath,
} from "./B05_Profile_UI_Corridor_Structures_Path";
import { computeRevetmentLayout } from "../B06_Section/B06_Section_UI_Cross_Revetment";
import { withoutGuardedFill } from "../B06_Section/B06_Section_UI_Cross_Body_Guard";
/** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */
export interface StructureFrame {
@@ -274,7 +275,8 @@ export function buildCorridorStructures(
const solids: CorridorStructure[] = [];
for (const section of crossSections) {
const layout = culvertLayoutOf(section);
const fordLayout = fordLayoutOf(section);
// 흙막이가 선 쪽은 횡단과 같은 자리 함수로 — 측벽 걷고 관 · 바닥판이 흙막이까지(89-1)
const fordLayout = withoutGuardedFill(fordLayoutOf(section), layout);
// 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위
// culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다
// (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙).
@@ -449,12 +451,11 @@ export function buildCorridorStructures(
if (fordLayout) {
// 구체는 월류 폭만큼 도로 방향으로 이어지고 기준 측점 전후로 절반씩 걸친다.
const half = Math.max(fordLayout.ford.span_m, 0.5) / 2;
const invertPoints = fordLayout.pipe.outer;
const fordBore = pipeBore(
fordLayout.ford.diameter_m,
{ offset: invertPoints[3].offset, elevation: invertPoints[3].elevation },
{ offset: invertPoints[2].offset, elevation: invertPoints[2].elevation },
);
// 관 끝 = 윗 꼭짓점 자리(흙막이 사면에서 안 튀어나옴 · 89-1) · 높이 = invert
const [topLeft, topRight, invertRight, invertLeft] = fordLayout.pipe.outer;
const pipeStart = { offset: topLeft.offset, elevation: invertLeft.elevation };
const pipeEnd = { offset: topRight.offset, elevation: invertRight.elevation };
const fordBore = pipeBore(fordLayout.ford.diameter_m, pipeStart, pipeEnd);
// 관은 련수만큼 폭 안에 등간격 — 구멍도 관마다 하나씩 낸다(2026-08-25 사용자 ⑥).
const count = Math.max(fordLayout.ford.pipe_count, 1);
const pipeChainages = Array.from(
@@ -518,7 +519,6 @@ export function buildCorridorStructures(
),
);
// 관은 련수만큼 폭 안에 등간격으로 놓는다(단면엔 1개만 보이지만 실물은 여러 련).
const invert = fordLayout.pipe.outer;
for (const at of pipeChainages) {
const frame = frameAt(at);
solids.push({
@@ -526,8 +526,8 @@ export function buildCorridorStructures(
kind: "pipe",
key: "pipe",
pipe: {
start: [invert[3].offset, invert[3].elevation],
end: [invert[2].offset, invert[2].elevation],
start: [pipeStart.offset, pipeStart.elevation],
end: [pipeEnd.offset, pipeEnd.elevation],
diameterM: fordLayout.ford.diameter_m,
wallThicknessM: pipeWallThicknessM(
fordLayout.ford.pipe_kind,
@@ -161,6 +161,22 @@ export function buildStructuresForm(options: {
optionRow,
{ get: groupBoxes.get, pick: groupBoxes.pick, clear: groupBoxes.clear, set: groupBoxes.setOne },
options.onFacilityChange,
// 노면공 길이 칸(87-1) = 시작~종료 출력 — 못 셈이면 빈 글(「—」)
(_c, item) => {
if (item.id !== "length") return "";
const step = options.getInterval();
const start = startFields.read(step, false);
const end = endFields.read(step, false);
return start !== null && end !== null && end > start
? String(Number((end - start).toFixed(2)))
: "";
},
);
// 시작 · 종료를 고치면 길이 출력을 다시 그림
[startFields, endFields].forEach((fields) =>
[fields.station, fields.remainder].forEach((input) =>
input.addEventListener("change", () => structureSpec.refresh()),
),
);
groupBoxes.watch(structureSpec.paintLinks);
const staleBox = document.createElement("fieldset");
@@ -56,6 +56,7 @@ import {
DEFAULT_INTERVAL_LENGTH_M,
groupOptions,
hasComputedRange,
isRangeOnly,
typeOptions,
readBeforeOption,
readLengthOption,
@@ -238,7 +239,7 @@ export function createStructuresSection(
// [?] 설명서가 먼저 여는 항목 = `구조물_<종류 이름>.md`(54-2 · 종류 안내 글은 그 파일로 옮김)
root.dataset.helpTopic = type ? `구조물_${type.name}` : "";
// 정의가 기준 측점을 끈 종류(48-3) — 기준 측점이 곧 위치인 종류(점형 · 범위 계산)는 늘 보임
anchorFields.wrap.hidden = isInterval && !showsAnchor(type!);
anchorFields.wrap.hidden = isInterval && (!showsAnchor(type!) || isRangeOnly(type));
anchorFields.wrap.querySelector("span")!.textContent = "기준 측점"; // 빈 칸 안내 없음(54-43)
syncRangeDisplay();
}
@@ -314,7 +314,10 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
return;
}
// 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다.
anchor = ctx.anchorFields.read(step, false) ?? start;
// 범위 직접 입력형(노면공 · 87-1) = 기준 칸 없음 → 늘 시점.
anchor = STRUCTURE_SPECS[type.type_id]?.byRange
? start
: (ctx.anchorFields.read(step, false) ?? start);
if (anchor < start || anchor > end) {
ctx.anchorFields.station.classList.add("is-invalid");
ctx.anchorFields.station.focus();
@@ -20,6 +20,7 @@ import {
} from "./B05_Profile_Api_Structures";
import type { DesignSelection, ResolvedSelection } from "../A00_Common/design/design_selection";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다.
* 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */
@@ -209,6 +210,10 @@ export function readBeforeOption(inputs: OptionEntry[]): number {
return length !== null && before > length ? length : before;
}
/** 시작 · 종료 측점만 받는 명세 종류인가(노면공 · 87-1) — 기준 측점 칸 없음 · 길이 출력. */
export const isRangeOnly = (type: { type_id: string } | null): boolean =>
!!type && !!STRUCTURE_SPECS[type.type_id]?.byRange;
/** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이
* 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만
* 준다(2026-08-19 사용자 지시). */
@@ -217,6 +222,7 @@ export function hasComputedRange(type: StructureType | null): boolean {
!!type &&
!type.managed_by &&
type.placement === "interval" &&
!isRangeOnly(type) &&
type.options.some((option) => option.key === "length_m" && isB05Option(option))
);
}
@@ -12,6 +12,7 @@ import type { StructureType } from "./B05_Profile_Api_Structures";
import {
createSpecForm,
type SpecForm,
type SpecFormHooks,
type SpecLinks,
} from "../A00_Common/design/design_structure_form";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
@@ -29,8 +30,9 @@ export function createStructureSpecForm(
optionRow: HTMLElement,
links: SpecLinks,
onChange: () => void,
auto?: SpecFormHooks["auto"],
): SpecForm {
const form = createSpecForm(links, { onChange });
const form = createSpecForm(links, { onChange, auto });
form.root.hidden = true;
forms.set(optionRow, form);
const lock = (): void => {
@@ -49,7 +51,7 @@ export function createStructureSpecForm(
/** 정의 옵션 칸으로 안 그리는 키 — 명세 키 · 명세가 뺀 옛 키. */
export function specOwnedKeys(type: StructureType | null): Set<string> {
const spec = structureSpecOf(type);
return new Set(spec ? [...specKeys(spec).keys, ...spec.retired] : []);
return new Set(spec ? [...specKeys(spec).keys, ...spec.retired, ...(spec.dropped ?? [])] : []);
}
/** 종류 · 값으로 명세 폼을 다시 그림(명세 없는 종류면 가림). */
@@ -85,5 +87,5 @@ export function specRenderedKeys(
if (!spec) return [];
const { keys, prefixes } = specKeys(spec);
const own = Object.keys(previous ?? {}).filter((key) => prefixes.some((p) => key.startsWith(p)));
return [...keys, ...own];
return [...keys, ...own, ...(spec.dropped ?? [])];
}
+3
View File
@@ -60,6 +60,7 @@ export type {
/** 물넘이포장 제원 — 정의처는 렌더 모듈이다(사본 금지, 타입 전용 import라 순환 없음). */
import type { FordPavementSpec } from "./B06_Section_UI_Cross_Ford_Pavement";
import type { RevetmentSpec } from "./B06_Section_UI_Cross_Revetment";
import type { PavementEntry } from "@util/common_util_structure_pavement";
export type { FordPavementSpec, RevetmentSpec };
export interface SectionContextResponse {
@@ -315,6 +316,8 @@ export interface CrossSection extends SectionStation {
ford_pavement?: FordPavementSpec;
/** 독립 기슭막이 제원(있을 때만). 구조물 정본 D군 구간 안의 측점 전부에 붙는다. */
revetment?: RevetmentSpec;
/** 노면공(G군) 포장 — 범위 안 측점마다(88). 두께 · 무늬 · 확폭 켬. */
pavement?: PavementEntry[];
}
export interface SectionDetailResponse {
+3
View File
@@ -77,6 +77,7 @@ from B06_Section.B06_Section_Schema import (
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for, haul_limits_for
from common_util.common_util_auth import verify_session
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_structure_pavement import attach_pavement_structures
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_workflow_state import get_workflow_state
from config.config_db import get_db_pool, run_with_connection
@@ -274,6 +275,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
# 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 —
# 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정).
attach_wall_structures(root, cross_sections)
attach_pavement_structures(root, cross_sections) # 노면공 형상(88)
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
@@ -497,6 +499,7 @@ async def regenerate_sections(
# 재생성 응답도 상세 조회와 같은 배수관 세트·구조물 벽 정보를 실어야 화면이 어긋나지 않는다.
attach_culvert_sets(project_root, result["cross_sections"])
attach_wall_structures(project_root, result["cross_sections"])
attach_pavement_structures(project_root, result["cross_sections"])
return SectionDetailResponse(
longitudinal=result["longitudinal"], cross_sections=result["cross_sections"]
)
+14
View File
@@ -34,6 +34,11 @@ import {
wallSpecsFrom,
type WallStructureInput,
} from "@util/common_util_structure_walls";
import {
attachPavementSpecs,
pavementSpecsFrom,
type PavementStructureInput,
} from "@util/common_util_structure_pavement";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
@@ -65,6 +70,15 @@ async function withDraftWalls(
await withDraftPipeKinds(detail, projectId);
if (!drafts || !drafts.length) return withStructureAreas(detail);
const types = await fetchStructureTypes().catch(() => []);
// 노면공(88) — 초안 범위로 포장 제원을 다시 얹음(짝 `common_util_structure_pavement`)
const paveTypes = new Set(types.filter((type) => type.group === "G").map((type) => type.type_id));
if (paveTypes.size) {
for (const section of detail.cross_sections) delete section.pavement;
attachPavementSpecs(
detail.cross_sections as unknown as Array<Record<string, unknown>>,
pavementSpecsFrom(drafts as unknown as PavementStructureInput[], paveTypes),
);
}
const names = new Map(
types
// 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라
+14 -10
View File
@@ -210,8 +210,12 @@ export function withoutGuardedFill<T extends BoxLayout | FordLayout>(
if (!layout || !culvert?.culvert.body) return layout;
const guarded = (outward: number): boolean => culvert.walls.some((w) => w.outward === outward);
const mid = layout.label.at.offset;
const reach = (points: OffsetPoint[], outward: number, offset: number): OffsetPoint[] =>
points.map((p) => ((p.offset - mid) * outward > 0 ? { ...p, offset } : p));
const reach = (
points: OffsetPoint[],
outward: number,
offset: (p: OffsetPoint) => number,
): OffsetPoint[] =>
points.map((p) => ((p.offset - mid) * outward > 0 ? { ...p, offset: offset(p) } : p));
if ("box" in layout && "topSlab" in layout) {
const sides = layout.sides.map((side) =>
guarded(side.role === "left" ? 1 : -1)
@@ -226,7 +230,7 @@ export function withoutGuardedFill<T extends BoxLayout | FordLayout>(
const to = wall?.bottomBack.offset ?? side.offset;
if ((to - side.offset) * outward < 1e-6) continue;
[topSlab, bottomSlab, barrel] = [topSlab, bottomSlab, barrel].map((p) =>
reach(p, outward, to),
reach(p, outward, () => to),
);
}
const run =
@@ -237,19 +241,19 @@ export function withoutGuardedFill<T extends BoxLayout | FordLayout>(
const sides = ford.sides.map((side) =>
guarded(side.outward) ? { ...side, parts: [], fillSegments: [], cutLine: null } : side,
);
// 관 · 바닥판이 흙막이까지 따라감 — 관 끝 = 흙막이 전면(관 invert 높이) · 바닥판 끝 = 배면(80)
// 관 · 바닥판이 흙막이까지 따라감 — 관 끝 꼭짓점 = 그 높이의 흙막이 전면(사면 이용 · 89-1) ·
// 아래 꼭짓점(invert)은 종전 자리라 1 m 맞춤 그대로 · 바닥판 끝 = 배면(80)
let { pipe, slabBridge } = ford;
for (const side of ford.sides) {
const wall = culvert.walls.find((w) => w.role === side.role && w.outward === side.outward);
if (!wall) continue;
const front =
wall.points[2].offset +
wall.outward * wall.lean * (wall.topJoint.elevation - side.slabTopElevation);
const faceAt = (p: OffsetPoint): number =>
wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - p.elevation);
pipe = {
outer: reach(pipe.outer, side.outward, front),
inner: reach(pipe.inner, side.outward, front),
outer: reach(pipe.outer, side.outward, faceAt),
inner: reach(pipe.inner, side.outward, faceAt),
};
slabBridge = reach(slabBridge, side.outward, wall.bottomBack.offset);
slabBridge = reach(slabBridge, side.outward, () => wall.bottomBack.offset);
}
if (pipe === ford.pipe) return { ...ford, sides } as T;
const [right, left] = pipe.outer.slice(2);
@@ -1,3 +1,4 @@
import type { PavementEntry } from "@util/common_util_structure_pavement";
import type { CrossDesign } from "./B06_Section_Api_Fetch";
const SVG_NS = "http://www.w3.org/2000/svg";
@@ -29,26 +30,80 @@ export function appendRockBoundaryOverlay(
}
}
type Edge = { offset_m: number; elevation_m: number };
/** 포장사양 → 무늬(88) — 콘크리트 빗금 · 아스콘 점 · 쇄석 돌. 그 밖 = 채움만. */
const PAVE_MARKS: Record<string, { size: number; marks: string }> = {
콘크리트: { size: 6, marks: '<path d="M0,6 L6,0" class="b06-chart__pave-mark"/>' },
아스콘: {
size: 5,
marks: '<circle cx="1.5" cy="1.5" r="0.7" class="b06-chart__pave-mark--dot"/>',
},
쇄석: {
size: 9,
marks:
'<circle cx="2.5" cy="2.5" r="1.6" class="b06-chart__pave-mark"/>' +
'<ellipse cx="6.8" cy="6.5" rx="1.8" ry="1.2" class="b06-chart__pave-mark"/>',
},
};
/** 무늬 id 는 문서 안에서 유일 — 카드 · 다시 그리기마다 새 번호(물넘이 빗금과 같은 방식). */
let paveSeq = 0;
/** 확폭 끈 구간 — 규격 폭 끝(좌 = left − 확폭좌 · 우 = right + 확폭우 · 표고는 두 끝 사이 직선). */
function standardEdges(edges: { left: Edge; right: Edge }, design: CrossDesign) {
const { left, right } = edges;
const span = right.offset_m - left.offset_m || 1;
const at = (offset: number): Edge => ({
offset_m: offset,
elevation_m:
left.elevation_m + ((right.elevation_m - left.elevation_m) * (offset - left.offset_m)) / span,
});
return {
left: at(left.offset_m - (design.widening_left_m ?? 0)),
right: at(right.offset_m + (design.widening_right_m ?? 0)),
};
}
/**
* 포장층 — 노면선(= 포장 윗면) 아래로 두께만큼. 노면공 범위(`section.pavement`)면 그 두께 · 무늬 ·
* 확폭 켬/끔(여러 건은 아래로 차례로 쌓음) · 없으면 카드 포장 켬일 때 표준 두께 채움만.
*/
export function appendPavementOverlay(
svg: SVGElement,
design: CrossDesign,
x: (offset: number) => number,
y: (elevation: number) => number,
pavement?: readonly PavementEntry[] | null,
): void {
const edges = design.carriageway_edges ?? design.road_edges;
if (!design.paved || !edges) return;
const thickness = design.pavement_thickness_m ?? 0.2;
const { left, right } = edges;
const polygon = document.createElementNS(SVG_NS, "polygon");
polygon.setAttribute(
"points",
[
`${x(left.offset_m)},${y(left.elevation_m)}`,
`${x(right.offset_m)},${y(right.elevation_m)}`,
`${x(right.offset_m)},${y(right.elevation_m - thickness)}`,
`${x(left.offset_m)},${y(left.elevation_m - thickness)}`,
].join(" "),
);
polygon.setAttribute("class", "b06-chart__pavement");
svg.append(polygon);
if (!edges) return;
const layers = pavement?.length ? pavement : design.paved ? [null] : [];
let depth = 0;
for (const entry of layers) {
const thickness = entry?.thickness_m ?? design.pavement_thickness_m ?? 0.2;
const { left, right } = entry && !entry.widening_on ? standardEdges(edges, design) : edges;
const points = [
`${x(left.offset_m)},${y(left.elevation_m - depth)}`,
`${x(right.offset_m)},${y(right.elevation_m - depth)}`,
`${x(right.offset_m)},${y(right.elevation_m - depth - thickness)}`,
`${x(left.offset_m)},${y(left.elevation_m - depth - thickness)}`,
].join(" ");
depth += thickness;
const polygon = document.createElementNS(SVG_NS, "polygon");
polygon.setAttribute("points", points);
polygon.setAttribute("class", "b06-chart__pavement");
svg.append(polygon);
const spec = entry?.spec;
const mark = spec ? PAVE_MARKS[spec] : undefined;
if (!spec || !mark) continue;
const id = `b06-pave-hatch-${(paveSeq += 1)}`;
const defs = document.createElementNS(SVG_NS, "defs");
defs.innerHTML = `<pattern id="${id}" patternUnits="userSpaceOnUse" width="${mark.size}" height="${mark.size}">${mark.marks}</pattern>`;
const hatch = document.createElementNS(SVG_NS, "polygon");
hatch.setAttribute("points", points);
hatch.setAttribute("class", "b06-chart__pavement-hatch");
hatch.setAttribute("fill", `url(#${id})`);
hatch.dataset.paveSpec = spec;
svg.append(defs, hatch);
}
}
+24 -9
View File
@@ -145,15 +145,30 @@ export function appendFordOverlay(
// 관·채움이 벽 위에 겹쳐 클릭을 가로챈다 — 투명 겹면을 맨 위에 얹어 벽을 고른다
// (배수관 기슭막이 `culvert-revet-hit`와 같은 수법).
if (onSelectWall) {
// 본체(바닥판 · 관) 판정면 — 벽 판정면보다 먼저 깔아 겹친 자리는 벽이 먹는다(66-1).
for (const points of [layout.slabBridge, layout.pipe.outer]) {
const hit = polygon(points, "b06-chart__culvert-revet-hit", "세월교 본체 선택");
hit.addEventListener("click", (event) => {
event.stopPropagation();
onSelectWall("body");
});
layer.append(hit);
}
// 본체 판정면 = 바닥판 밑 ~ 측벽 상단 최고 사각형 하나(관 위 성토 · 벽-관 사이 포함 · 89-2) —
// 측벽 판정면 · 흙막이(뒤에 그리는 배관 오버레이)가 위에 얹혀 겹친 자리는 그쪽이 먹는다(66-1).
const offsets = [
...layout.slabBridge,
...layout.sides.flatMap((side) => side.parts.flatMap((part) => part.points)),
].map((point) => point.offset);
const top = Math.max(...layout.sides.map((side) => side.top.elevation));
const bottom = Math.min(...layout.slabBridge.map((point) => point.elevation));
const [from, to] = [Math.min(...offsets), Math.max(...offsets)];
const bodyHit = polygon(
[
{ offset: from, elevation: top },
{ offset: to, elevation: top },
{ offset: to, elevation: bottom },
{ offset: from, elevation: bottom },
],
"b06-chart__culvert-revet-hit",
"세월교 본체 선택",
);
bodyHit.addEventListener("click", (event) => {
event.stopPropagation();
onSelectWall("body");
});
layer.append(bodyHit);
// 측벽 판정면 = ㄴ(벽 + 바닥판)을 감싸는 가상 사각형(79-6) — 얇은 벽만 잡던 것을 넓힘.
for (const side of layout.sides) {
const points = side.parts.flatMap((part) => part.points);
+2 -1
View File
@@ -429,7 +429,8 @@ export function createCrossSectionCard(
x,
toDisplayY,
);
if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
if (!fordPaved)
appendPavementOverlay(plotLayer, section.design, x, toDisplayY, section.pavement);
// 세월교 측점 — 계획고는 이미 월류 높이만큼 내려와 있다. "월류가 없었다면"
// 노면을 점선으로 남겨 얼마나 내려앉았는지 읽히게 한다(2026-08-30 사용자).
appendFordSurfaceDropPlan(plotLayer, section.design, x, toDisplayY, fordLayout);
@@ -185,6 +185,22 @@
stroke-width: 1;
}
/* 노면공 포장사양 무늬(88): 채움 박스 위에 무늬만 한 겹 — 콘크리트 빗금 · 아스콘 점 · 쇄석 돌 */
.b06-chart__pavement-hatch {
stroke: none;
}
.b06-chart__pave-mark {
fill: none;
stroke: color-mix(in srgb, var(--color-text-body) 70%, transparent);
stroke-width: 1;
}
.b06-chart__pave-mark--dot {
fill: color-mix(in srgb, var(--color-text-body) 70%, transparent);
stroke: none;
}
/* 독립 기슭막이(2026-08-28): 성토면 끝에 서는 벽 — 배관 세트 기슭막이와 같은 색 계열 */
.b06-chart__revetment {
fill: color-mix(in srgb, #9b6bdc 22%, transparent);
@@ -415,7 +415,7 @@ function drawDesign(
): void {
const { design, culvert, box, ford, own } = layouts;
const paved = appendFordPavementOverlay(svg, section.ford_pavement, design, x, y);
if (!paved) appendPavementOverlay(svg, design, x, y);
if (!paved) appendPavementOverlay(svg, design, x, y, section.pavement);
appendCrossDesignOverlay(
svg,
design,
+16 -1
View File
@@ -40,8 +40,20 @@ def _logics(codes: list[str]) -> tuple[list[dict], list[str]]:
return found, missing
def _labels(whole: Any, spec: dict) -> dict[str, str]:
"""고르기 원문번호 → 보이는 글(85-1) — 기계 「이름 규격」 · 직종(입력 이름에 「직종」) 「이름」 · 모르는 번호는 뺌."""
table = "LB" if "직종" in str(spec.get("이름") or "") else "EQ"
rows = whole.numbers.get(table, {})
out = {}
for value in spec.get("고르기") or []:
row = rows.get(str(value)) if isinstance(value, str) else None
if row:
out[value] = " ".join(str(row[k]) for k in ("이름", "규격") if row.get(k))
return out
def _listed(logics: list[dict]) -> list[dict[str, Any]]:
"""로직 줄마다 `{로직, 이름, 결과단위, 출처, 입력: [{이름, 설명, 단위, 고르기?, 범위?, 자동값}]}`."""
"""로직 줄마다 `{로직, 이름, 결과단위, 출처, 입력: [{이름, 설명, 단위, 고르기?, 고르기이름?, 범위?, 자동값}]}`."""
whole = m01.loaded()[1]
out = []
for logic in logics:
@@ -51,6 +63,9 @@ def _listed(logics: list[dict]) -> list[dict[str, Any]]:
one = {k: spec[k] for k in _SPEC if k in spec}
one.setdefault("설명", None)
one.setdefault("단위", None)
labels = _labels(whole, spec)
if labels:
one["고르기이름"] = labels
specs.append({**one, "자동값": auto.get(spec["이름"])})
out.append(
{
+14 -3
View File
@@ -13,6 +13,7 @@ import {
choicesOf,
inputKey,
isOutside,
labelOf,
rangeOf,
storedOf,
type ValueDoc,
@@ -65,7 +66,12 @@ export function createValueControl(
if (control instanceof HTMLSelectElement) {
control.append(
...(auto === null ? [el("option", { attrs: { value: "" }, text: "—" })] : []),
...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })),
...choices.map((c) =>
el("option", {
attrs: { value: c.값, ...(c.이름 !== c.값 ? { title: c.값 } : {}) },
text: c.이름,
}),
),
);
}
const empty = value === null || value === "";
@@ -194,10 +200,15 @@ export function createInputsValueCard(opts: InputsValueOptions): InputsValueHand
};
if (opts.mode === "view") {
const text = isEmpty(stored.값) ? auto : stored.값;
const label = text === null ? "—" : labelOf(input, text);
const shown = el("span", {
className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`,
text: text === null ? "—" : String(text),
attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {},
text: label,
attrs: isEmpty(stored.값)
? { title: VALUE_TEXT.auto }
: label !== String(text)
? { title: String(text) }
: {},
});
paintState();
return el("div", { className: "m02-inputval__cell", children: [shown, state] });
+12 -3
View File
@@ -14,9 +14,14 @@ const text = (value: unknown): string =>
? JSON.stringify(value)
: String(value);
/** 값 → 보이는 글(85-1) — 원문번호면 「이름 규격」 · 그 밖은 값 그대로 */
export const labelOf = (input: ItemInput, value: unknown): string =>
input.고르기이름?.[text(value)] ?? text(value);
/** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */
export function valueHint(input: ItemInput): string {
if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / ");
if (Array.isArray(input.고르기) && input.고르기.length)
return input.고르기.map((one) => labelOf(input, one)).join(" / ");
const range = input.범위;
if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`;
if (range && typeof range === "object") {
@@ -60,7 +65,11 @@ export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw:
const object =
typeof one === "object" && one !== null ? (one as Record<string, unknown>) : null;
const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number;
return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw };
return {
값: String(raw),
이름: object ? String(object.이름 ?? raw) : labelOf(input, one),
raw,
};
});
}
@@ -112,7 +121,7 @@ export function valueLine(
for (const input of inputs) {
const value = values?.[input.이름];
if (value !== undefined && value !== "") {
parts.push(`${input.이름} ${value}${input.단위 ? input.단위 : ""}`);
parts.push(`${input.이름} ${labelOf(input, value)}${input.단위 ? input.단위 : ""}`);
} else if (autoText(input) === "") empty += 1;
}
return { text: parts.join(" · "), empty };
@@ -330,6 +330,8 @@ export interface ItemInput {
설명?: string;
단위?: string;
고르기?: unknown[];
/** 고르기 원문번호 → 보이는 글(85-1 · 기계 「이름 규격」 · 직종 이름) */
고르기이름?: Record<string, string>;
범위?: unknown;
자동값?: unknown;
}
@@ -12,6 +12,7 @@
* ========================================================================== */
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
import { leaveOrStay } from "@ui/ui_template_leave_dialog";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import type {
@@ -68,7 +69,7 @@ const TEXT = {
noFieldNumber: "{value} 없음",
noSheet: "스프레드시트를 못 띄움 — {value}",
readFailed: "{value} 파일을 못 읽음 — {why}",
discard: "카드에 저장 안 한 고침이 있음 — 버리고 다른 항목을 열까요?",
discard: "카드에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
pills: "알약",
pillsTip: "아래 알약 줄 보임 · 숨김",
take: "가져오기",
@@ -179,6 +180,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
const numberOf = (kind: CardKind, id: string): string => opts.numberOf?.(kind, id) ?? "";
const anyDirty = (): boolean => cards.some((c) => c.saver.dirty());
const saveAll = async (): Promise<void> => {
for (const c of cards) if (c.saver.dirty() && !(await c.saver.run())) throw new Error();
};
const reload = (): void => void load(column ? { id: column, label } : null, true);
/** 카드 틀 하나 — 제목 옆 = ? · 제목 줄 오른쪽 = [저장] · [〇〇 수정](빈 단추 한 모양) · 부제 = 번호 · 이름 */
@@ -461,7 +465,12 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
async function load(col: { id: string; label: string } | null, again = false): Promise<void> {
// 카드 고침을 저장 안 했으면 버릴지 물음(한 번) — 취소면 보던 열 그대로
if (!again && anyDirty() && col?.id !== column && !(await showConfirmDialog(TEXT.discard)))
if (
!again &&
anyDirty() &&
col?.id !== column &&
(await leaveOrStay(TEXT.discard, { save: saveAll, stillDirty: anyDirty })) === "stay"
)
return;
const mine = ++seq;
drop();
@@ -530,9 +539,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
},
column: () => column,
dirty: anyDirty,
saveAll: async () => {
for (const c of cards) if (c.saver.dirty() && !(await c.saver.run())) throw new Error();
},
saveAll,
discard: () => {
drop();
opts.onDirty?.(false);
@@ -98,7 +98,7 @@ export interface LaborDiagramSource {
로직: string;
이름: string;
결과단위?: string;
입력: { 이름: string; 단위?: string; 자동값?: unknown }[];
입력: { 이름: string; 단위?: string; 자동값?: unknown; 고르기이름?: Record<string, string> }[];
}[];
/** calc 답 · 아직이면 null */
calc: {
@@ -137,10 +137,11 @@ export function buildLaborDiagram(src: LaborDiagramSource): DiagramData {
inputs: (def?.입력 ?? []).map((x) => {
const mine = r.입력값[x.이름];
const has = mine !== undefined && mine !== "";
const raw = has ? String(mine) : autoOf(x.자동값);
return {
name: x.이름,
unit: x.단위 ?? "",
value: has ? String(mine) : autoOf(x.자동값),
value: x.고르기이름?.[raw] ?? raw, // 85-1 원문번호 → 「이름 규격」
...(has || !autoOf(x.자동값) ? {} : { auto: true }),
};
}),
@@ -14,7 +14,8 @@
* 글자는 이 파일에 둠(도면 부품 `M02_MasterTemplete_Drawing.ts` 와 같은 방식).
* ========================================================================== */
import { showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { showToast } from "@ui/ui_template_elements";
import { leaveOrStay } from "@ui/ui_template_leave_dialog";
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
import {
clearPart,
@@ -252,9 +253,12 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
if ((col?.id ?? null) !== column && cards.dirty()) {
// 카드에 저장 안 한 고침 — 버릴지 물음 · 머물면 표의 고른 열을 되돌림
const from = column;
void showConfirmDialog(TEXT.discardCards).then((ok) => {
if (!ok) return void (from && opts.keepColumn?.(from));
cards.discard(); // 한 번 물었음 — 카드가 다시 묻지 않게
void leaveOrStay(TEXT.discardCards, {
save: cards.saveAll,
stillDirty: cards.dirty,
discard: cards.discard, // 한 번 물었음 — 카드가 다시 묻지 않게
}).then((got) => {
if (got === "stay") return void (from && opts.keepColumn?.(from));
void load(col);
});
return;
@@ -272,7 +276,12 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
const toNo = to && numbers[to.id]?.[PART_FIELD[kind]];
if (!to || !toNo) return void showToast(TEXT.saveFirst, "error");
// 카드에 저장 안 한 고침 — 가져온 뒤 카드를 다시 읽어 버려지니 먼저 물음
if (cards.dirty() && !(await showConfirmDialog(TEXT.discardCards))) return;
if (
cards.dirty() &&
(await leaveOrStay(TEXT.discardCards, { save: cards.saveAll, stillDirty: cards.dirty })) ===
"stay"
)
return;
const target = `${toNo} ${columnHead(to)}`;
const here = { layer: getActiveLayer(), owner: getActiveOwner() };
const same = (from: From): boolean => from.layer === here.layer && from.owner === here.owner;
@@ -1,6 +1,6 @@
/* =============================================================================
* M02_MasterTemplete_TreePanel_Dialogs.ts
* 구조물 라이브러리 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기 · 저장/버림 물음.
* 구조물 라이브러리 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기.
* 모두 약속(Promise)으로 답함 — 닫기 · Esc · 바깥 누르기 = 취소(null / "cancel").
* ========================================================================== */
@@ -183,36 +183,3 @@ export function askFolder(
});
});
}
export type Settle = "save" | "discard" | "cancel";
/** 저장 안 한 표 고침 — 저장하고 계속 · 버리고 계속 · 취소 */
export function askSettle(): Promise<Settle> {
return new Promise((resolve) => {
let done = false;
const finish = (value: Settle): void => {
if (done) return;
done = true;
resolve(value);
};
openModal({
title: L("M02_TreeUnsaved"),
closeLabel: L("M02_TreeCancel"),
dialogClass: "m02-modal m02-treedlg",
beforeClose: () => (finish("cancel"), true),
mount: (body, close) => {
const choose = (value: Settle) => () => {
finish(value);
close();
};
const save = createButton({ label: L("M02_TreeSaveGo"), variant: "filled" });
const discard = createButton({ label: L("M02_TreeDiscardGo"), variant: "danger" });
const cancel = createButton({ label: L("M02_TreeCancel"), variant: "ghost" });
save.addEventListener("click", choose("save"));
discard.addEventListener("click", choose("discard"));
cancel.addEventListener("click", choose("cancel"));
body.append(el("div", { className: "m02-side__row", children: [save, discard, cancel] }));
},
});
});
}
@@ -6,6 +6,7 @@
import { setLeaveGuard } from "../A00_Common/router";
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { leaveOrStay } from "@ui/ui_template_leave_dialog";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { t as L } from "@ui/ui_template_locale";
import {
@@ -26,7 +27,6 @@ import {
type StructureColumn,
type StructurePanelHandle,
} from "./M02_MasterTemplete_Structure";
import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs";
import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel";
import { createAreaToDrawing, sentRanges } from "./M02_MasterTemplete_AreaToDrawing";
import { detailHelp, HELP_TITLE } from "./M02_MasterTemplete_CardHelp";
@@ -202,19 +202,26 @@ export function createMain(
event.returnValue = "";
};
window.addEventListener("beforeunload", guard);
const confirmLeave = async (): Promise<boolean> => {
if (!busy()) return true; // 카드 고침(도면 · 엑셀)도 같은 물음
if (!(await showConfirmDialog(L("M02_DiscardConfirm")))) {
// 취소 — 초점을 시트(고른 칸 입력 자리) · 집계표 · 도면으로 돌려 키보드 그대로
host
.querySelector<HTMLElement>(".ss-input, .ui-sheet, iframe")
?.focus({ preventScroll: true });
return false;
}
dirty = false;
toDrawing.drop();
return true;
/**
* 저장 안 한 고침 물음(떠나기 · 나무 고치기 한 몸) — 단추 셋 [취소] [버리고 넘어감] [저장 후 넘어감].
* "clean" = 고침 없음 · "saved" · "discarded" · false = 머묾(취소 · 저장 실패 · 판 다름 · 카드 저장 실패).
* 열린 양식이 없는 화면(옵션 정의 등)은 저장할 것이 없어 2단추 그대로.
*/
const askUnsaved = async (message: string): Promise<false | "clean" | "saved" | "discarded"> => {
if (!busy()) return "clean"; // 카드 고침(도면 · 엑셀)도 같은 물음
const drop_ = (): void => {
dirty = false;
toDrawing.drop();
};
let got: "saved" | "discarded" | "stay";
if (sel) got = await leaveOrStay(message, { save: doSave, stillDirty: busy, discard: drop_ });
else got = (await showConfirmDialog(message)) ? (drop_(), "discarded") : "stay";
if (got !== "stay") return got;
// 머묾 — 초점을 시트(고른 칸 입력 자리) · 집계표 · 도면으로 돌려 키보드 그대로
host.querySelector<HTMLElement>(".ss-input, .ui-sheet, iframe")?.focus({ preventScroll: true });
return false;
};
const confirmLeave = async (): Promise<boolean> => !!(await askUnsaved(L("M02_DiscardConfirm")));
setLeaveGuard(confirmLeave); // 머리 메뉴 · 뒤로 가기로 떠날 때도 물음
const showNotice = (nodes: (HTMLElement | string)[]): void => {
@@ -573,15 +580,9 @@ export function createMain(
}
async function settle(): Promise<false | "clean" | "changed"> {
if (!busy()) return "clean";
const choice = await askSettle();
if (choice === "cancel") return false;
if (choice === "save") {
await doSave();
return dirty ? false : "changed"; // 저장이 막히면(판 다름 등) 나무도 안 고침
}
dirty = false; // 버림 — 화면을 서버 그대로 다시 읽음
if (sel) await open(sel);
const got = await askUnsaved(L("M02_TreeUnsaved"));
if (got === false || got === "clean") return got;
if (got === "discarded" && sel) await open(sel); // 버림 — 화면을 서버 그대로 다시 읽음
return "changed";
}
+4 -4
View File
@@ -194,11 +194,11 @@ def _label(key: str, value: Any) -> str:
def _length(item: dict[str, Any]) -> float | None:
stated = _number(item["options"].get("length_m"))
if stated:
return stated
# 구간형은 시작~종료가 먼저(노면공 87-2) — 옛 length_m 은 범위를 고쳐도 안 따라옴
start, end = _number(item.get("start_m")), _number(item.get("end_m"))
return abs(end - start) if start is not None and end is not None else None
if start is not None and end is not None:
return abs(end - start)
return _number(item["options"].get("length_m")) or None
def _source_value(source: dict[str, Any], item: dict[str, Any]) -> float | None:
@@ -0,0 +1,116 @@
"""노면공(G군) 구조물 정본 → 횡단 측점 포장 제원(`section.pavement`).
범위(start~end · ±0.02 m) 안 측점마다 `[{type_id, spec, thickness_m, start_m, end_m, widening_on}]`.
두께 = `options.height_m` · 무늬 = `options.pave_spec` · 확폭 끈 구간 = `options.widening_off`("s-e,s-e").
그림 · 정보만 — 토공 면적 · 포장 켜기(`design.paved`)는 건드리지 않음.
**짝**: `common_util/common_util_structure_pavement.ts` — 브라우저는 저장 전 목록으로 같은 제원을 얹음.
거울 시험 `resources/tester/test_b06_structure_pavement_mirror.py`.
"""
import logging
import math
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — C군 벽(`common_util_structure_walls`)과 같은 값.
_EDGE_TOLERANCE_M = 0.02
def _num(value: Any) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return value if math.isfinite(value) else None
def widening_off_ranges(text: Any) -> list[tuple[float, float]]:
"""「시작-끝,시작-끝」(측점 m) → 구간 목록. 못 읽는 조각은 버림(= 켬)."""
ranges: list[tuple[float, float]] = []
for part in str(text or "").split(","):
bounds = part.split("-")
if len(bounds) != 2 or not bounds[0].strip() or not bounds[1].strip():
continue
try:
start, end = float(bounds[0]), float(bounds[1])
except ValueError:
continue
if math.isfinite(start) and math.isfinite(end):
ranges.append((min(start, end), max(start, end)))
return ranges
def pavement_specs_from(
structures: list[dict[str, Any]], type_ids: set[str]
) -> list[dict[str, Any]]:
"""G군 구조물 → 구간 제원. 범위가 없으면(시작 · 끝 · 기준 모두 빔) 건너뜀."""
specs: list[dict[str, Any]] = []
for item in structures:
if item.get("type_id") not in type_ids:
continue
anchor = _num(item.get("chainage_m"))
start = _num(item.get("start_m"))
end = _num(item.get("end_m"))
start = anchor if start is None else start
end = anchor if end is None else end
if start is None or end is None:
continue
options = item.get("options") or {}
spec = options.get("pave_spec")
specs.append(
{
"type_id": item["type_id"],
"spec": spec if isinstance(spec, str) and spec else None,
"thickness_m": _num(options.get("height_m")),
"start_m": min(start, end),
"end_m": max(start, end),
"widening_off": widening_off_ranges(options.get("widening_off")),
}
)
return specs
def _inside(chainage: float, start: float, end: float) -> bool:
return start - _EDGE_TOLERANCE_M <= chainage <= end + _EDGE_TOLERANCE_M
def attach_pavement_specs(sections: list[dict[str, Any]], specs: list[dict[str, Any]]) -> int:
"""범위 안 측점에 `pavement` 목록을 얹음. 얹은 측점 수를 돌려줌."""
attached = 0
for section in sections:
chainage = _num(section.get("chainage_m"))
if chainage is None:
continue
found = [
{
"type_id": spec["type_id"],
"spec": spec["spec"],
"thickness_m": spec["thickness_m"],
"start_m": spec["start_m"],
"end_m": spec["end_m"],
"widening_on": not any(
_inside(chainage, start, end) for start, end in spec["widening_off"]
),
}
for spec in specs
if _inside(chainage, spec["start_m"], spec["end_m"])
]
if found:
section["pavement"] = found
attached += 1
return attached
def attach_pavement_structures(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
"""저장된 구조물 정본으로 `section.pavement` 를 얹음. 정본을 못 읽으면 0(비치명)."""
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
try:
type_ids = {key for key, value in structure_type_map().items() if value.group == "G"}
structures = [item.model_dump() for item in load_structures(str(project_root))[1]]
except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 감
logger.exception("B06 노면공 정본을 읽지 못했습니다 (없는 것으로 본다)")
return 0
return attach_pavement_specs(cross_sections, pavement_specs_from(structures, type_ids))
@@ -0,0 +1,111 @@
/* =============================================================================
* common_util_structure_pavement.ts
* 노면공(G군) 구조물 → 횡단 측점 포장 제원(`section.pavement`).
* 파이썬 `common_util/common_util_structure_pavement.py` 의 **짝** — 거울 시험
* `resources/tester/test_b06_structure_pavement_mirror.py`.
*
* 서버는 저장분으로 · 브라우저는 저장 전 목록으로 같은 제원을 얹음.
* 두께 = options.height_m · 무늬 = options.pave_spec · 확폭 끈 구간 = options.widening_off.
* ========================================================================== */
/** 구조물 정본 한 건(필요한 칸만) — `structures.json` 과 같은 이름. */
export interface PavementStructureInput {
type_id: string;
chainage_m?: number | null;
start_m?: number | null;
end_m?: number | null;
options?: Record<string, unknown> | null;
}
/** 측점에 얹는 포장 한 건 — `section.pavement[]`. */
export interface PavementEntry {
type_id: string;
/** 포장사양 — 콘크리트 · 아스콘 · 쇄석(비면 표준 그림). */
spec: string | null;
/** 두께(m) — 노면선 아래로. 비면 표준 횡단 포장 두께. */
thickness_m: number | null;
start_m: number;
end_m: number;
/** false = 확폭 끈 구간(규격 폭 끝까지만). */
widening_on: boolean;
}
export interface PavementSpec extends Omit<PavementEntry, "widening_on"> {
widening_off: Array<[number, number]>;
}
/** 파이썬 `_EDGE_TOLERANCE_M` — C군 벽과 같은 값. */
const EDGE_TOLERANCE_M = 0.02;
const num = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
/** 「시작-끝,시작-끝」(측점 m) → 구간 목록. 못 읽는 조각은 버림(= 켬). */
export function wideningOffRanges(text: unknown): Array<[number, number]> {
const ranges: Array<[number, number]> = [];
for (const part of String(text ?? "").split(",")) {
const bounds = part.split("-");
if (bounds.length !== 2 || !bounds[0].trim() || !bounds[1].trim()) continue;
const start = Number(bounds[0]);
const end = Number(bounds[1]);
if (Number.isFinite(start) && Number.isFinite(end))
ranges.push([Math.min(start, end), Math.max(start, end)]);
}
return ranges;
}
/** G군 구조물 → 구간 제원(파이썬 `pavement_specs_from`). */
export function pavementSpecsFrom(
structures: readonly PavementStructureInput[],
typeIds: ReadonlySet<string>,
): PavementSpec[] {
const specs: PavementSpec[] = [];
for (const item of structures) {
if (!typeIds.has(item.type_id)) continue;
const anchor = num(item.chainage_m);
const start = num(item.start_m) ?? anchor;
const end = num(item.end_m) ?? anchor;
if (start === null || end === null) continue;
const options = (item.options ?? {}) as Record<string, unknown>;
const spec = options.pave_spec;
specs.push({
type_id: item.type_id,
spec: typeof spec === "string" && spec ? spec : null,
thickness_m: num(options.height_m),
start_m: Math.min(start, end),
end_m: Math.max(start, end),
widening_off: wideningOffRanges(options.widening_off),
});
}
return specs;
}
const inside = (chainage: number, start: number, end: number): boolean =>
start - EDGE_TOLERANCE_M <= chainage && chainage <= end + EDGE_TOLERANCE_M;
/** 범위 안 측점에 `pavement` 목록을 얹음(파이썬 `attach_pavement_specs`). 얹은 측점 수. */
export function attachPavementSpecs(
sections: Array<Record<string, unknown>>,
specs: readonly PavementSpec[],
): number {
let attached = 0;
for (const section of sections) {
const chainage = num(section.chainage_m);
if (chainage === null) continue;
const found: PavementEntry[] = specs
.filter((spec) => inside(chainage, spec.start_m, spec.end_m))
.map((spec) => ({
type_id: spec.type_id,
spec: spec.spec,
thickness_m: spec.thickness_m,
start_m: spec.start_m,
end_m: spec.end_m,
widening_on: !spec.widening_off.some(([start, end]) => inside(chainage, start, end)),
}));
if (found.length) {
section.pavement = found;
attached += 1;
}
}
return attached;
}
+7 -18
View File
@@ -1,5 +1,5 @@
{
"판": 19,
"판": 20,
"schema_version": 1,
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.",
"구조물군": {
@@ -3177,25 +3177,14 @@
],
"options": [
{
"key": "length_m",
"label": "길이",
"key": "height_m",
"label": "높이",
"input": "number",
"unit": "m",
"default": 10
},
{
"key": "before_m",
"label": "기준측점 전",
"input": "number",
"unit": "m",
"default": 5
},
{
"key": "after_m",
"label": "기준측점 후",
"input": "number",
"unit": "m",
"default": 5
"default": 0.2,
"required": false,
"phase": "detail",
"default_basis": "사용자 확정 10-8 · 임도기술교본 3-2 「두께 0.2m」 기준 · 명세 칸(상세 내용)이 받음"
},
{
"key": "thickness_cm",
File diff suppressed because it is too large Load Diff
@@ -46,11 +46,14 @@ if (process.argv[2] === "road") {
same: spec === road.PAVEMENT_SPEC,
containers: spec.containers.map((c) => [c.id, c.title, c.required, c.link]),
choices: road.PAVE_SPEC_CHOICES.map((c) => c.value),
items: spec.containers[0].items.map((i) => [i.id, i.label, i.kind]),
state,
edited: s.writeOptions(spec, edited),
cleared: s.writeOptions(spec, cleared),
keys: s.specKeys(spec),
retired: spec.retired,
dropped: spec.dropped,
byRange: spec.byRange,
}),
);
process.exit(0);
+9
View File
@@ -65,6 +65,8 @@ function pipeFit(edit) {
const { ford, culvert } = computeStoredLayouts(section, list);
const [right, left] = ford.pipe.outer.slice(2);
const ends = { 1: left.offset, [-1]: right.offset };
const [topLeft, topRight] = ford.pipe.outer;
const tops = { 1: topLeft, [-1]: topRight };
const slab = ford.slabBridge.map((p) => p.offset);
const slabEnd = { 1: Math.max(...slab), [-1]: Math.min(...slab) };
return {
@@ -80,6 +82,13 @@ function pipeFit(edit) {
role: side.role,
guard: true,
pipeGap: r3(ends[side.outward] - front),
// 관 윗모서리 ↔ 그 높이의 흙막이 전면(사면 이용 · 89-1)
topGap: r3(
tops[side.outward].offset -
(wall.points[2].offset +
wall.outward * wall.lean * (wall.topJoint.elevation - tops[side.outward].elevation)),
),
lean: wall.lean,
slabGap: r3(slabEnd[side.outward] - wall.bottomBack.offset),
};
}),
@@ -36,7 +36,15 @@ def test_명세_등록_필수_컨테이너_하나(out):
def test_포장사양_선택지는_명세_한_곳(out):
assert out["choices"] == ["콘크리트"]
assert out["choices"] == ["콘크리트", "아스콘", "쇄석"]
def test_상세내용_차례_포장사양_길이_출력_높이(out):
assert out["items"] == [
["spec", "포장사양", "select"],
["length", "길이 (m)", "auto"],
["height", "높이 (m)", "number"],
]
def test_저장_키_왕복_옛_칸은_명세_밖(out):
@@ -44,7 +52,10 @@ def test_저장_키_왕복_옛_칸은_명세_밖(out):
assert out["edited"] == {"pave_spec": "콘크리트", "height_m": 0.35}
assert out["cleared"] == {"pave_spec": "콘크리트"}
assert out["keys"] == {"keys": ["pave_spec", "height_m"], "prefixes": []}
assert "length_m" not in out["retired"] # 구간 길이 · 전 · 후는 정의 칸 그대로
# 길이 · 기준측점 전/후는 입력 없음 — 시작 · 종료만 받고 옛 값은 저장 때 뺌(87-1)
assert out["byRange"] is True
assert out["dropped"] == ["length_m", "before_m", "after_m"]
assert "length_m" not in out["retired"]
assert set(out["retired"]) == {
"thickness_cm",
"width_m",
@@ -62,4 +73,7 @@ def test_기타_상세_종류_이름_글_칸():
name = next(o for o in etc["options"] if o["key"] == "name")
assert name["label"] == "상세 종류 이름" and name["input"] == "text"
pave = next(t for t in doc["types"] if t["type_id"] == "pavement_concrete")
assert [o["key"] for o in pave["options"]][:3] == ["length_m", "before_m", "after_m"]
keys = [o["key"] for o in pave["options"]]
assert not {"length_m", "before_m", "after_m"} & set(keys) # 87-1 시작 · 종료 직접 입력
height = next(o for o in pave["options"] if o["key"] == "height_m")
assert height["default"] == 0.2
+11
View File
@@ -47,6 +47,17 @@ def test_관은_흙막이_전면까지_바닥판은_배면까지(fit: dict) -> N
assert side["slabGap"] == pytest.approx(0, abs=1e-3)
def test_관_윗모서리도_흙막이_전면_사면에(fit: dict) -> None:
"""89-1 — 흙막이 사면 이용: 윗모서리가 그 높이 전면 자리라 벽 밖으로 안 튀어나감."""
leaned = 0
for case in (fit[k] for k in ("both", "inletPlus1", "outletPlus2", "inletOnly")):
for side in case["sides"]:
if side["guard"]:
assert side["topGap"] == pytest.approx(0, abs=1e-3)
leaned += side["lean"] > 0
assert leaned
def test_흙막이를_옮기면_관이_그만큼_따라감(fit: dict) -> None:
base = fit["both"]["lengthM"]
assert fit["inletPlus1"]["lengthM"] == pytest.approx(base + 1, abs=1e-3)
@@ -0,0 +1,131 @@
"""노면공 포장 제원(88) — 파이썬·TS 짝 거울 시험.
`common_util/common_util_structure_pavement.py`(서버 · 저장분) 와 `.ts`(브라우저 · 초안)가
같은 입력에 같은 `section.pavement` 를 내는지 대조. TS 는 프로젝트 tsc 로 옮겨 실제 코드 그대로 돌림.
"""
import json
import subprocess
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
from common_util.common_util_structure_pavement import ( # noqa: E402
attach_pavement_specs,
pavement_specs_from,
)
STRUCTURES = [
{
"type_id": "pavement_concrete",
"placement": "interval",
"chainage_m": None,
"start_m": 100.0,
"end_m": 160.0,
"options": {"pave_spec": "콘크리트", "height_m": 0.25, "widening_off": "120-140, x-1,,"},
},
{
# 뒤집힌 구간 · 두께 빔 · 사양 빔 — 두 벌 다 작은 값을 시작으로 · null 로.
"type_id": "gravel_surfacing",
"placement": "interval",
"start_m": 150.0,
"end_m": 130.0,
"options": {"pave_spec": "", "height_m": "0.3"},
},
{"type_id": "pavement_concrete", "start_m": None, "end_m": None, "options": {}},
{"type_id": "masonry_wet", "start_m": 0.0, "end_m": 500.0, "options": {}},
{
"type_id": "pavement_concrete",
"chainage_m": 300.0,
"options": {"pave_spec": "아스콘", "height_m": 0.05, "widening_off": "290-310"},
},
]
TYPE_IDS = ["pavement_concrete", "gravel_surfacing"]
CHAINAGES = [99.97, 100.0, 119.99, 130.0, 145.0, 160.02, 160.05, 300.0, 400.0]
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { attachPavementSpecs, pavementSpecsFrom } from "./common_util_structure_pavement.js";
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
const sections = input.chainages.map((chainage_m) => ({ chainage_m }));
attachPavementSpecs(sections, pavementSpecsFrom(input.structures, new Set(input.type_ids)));
writeFileSync(process.argv[3], JSON.stringify(sections));
"""
def _python_result() -> list[dict]:
sections = [{"chainage_m": value} for value in CHAINAGES]
attach_pavement_specs(sections, pavement_specs_from(STRUCTURES, set(TYPE_IDS)))
return sections
def _ts_result(tmp_path: Path) -> list[dict]:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(PROJECT_ROOT / "common_util" / "common_util_structure_pavement.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
payload = tmp_path / "input.json"
result = tmp_path / "output.json"
payload.write_text(
json.dumps({"structures": STRUCTURES, "type_ids": TYPE_IDS, "chainages": CHAINAGES}),
encoding="utf-8",
)
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(payload), str(result)],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def test_pavement_specs_mirror():
with tempfile.TemporaryDirectory() as workdir:
ts = _ts_result(Path(workdir))
py = _python_result()
assert ts == py
by = {section["chainage_m"]: section.get("pavement") for section in py}
# 범위 밖 · 경계 오차(0.02 m) 안
assert by[99.97] is None and by[160.05] is None and by[400.0] is None
assert [entry["type_id"] for entry in by[100.0]] == ["pavement_concrete"]
assert by[160.02][0]["thickness_m"] == 0.25
# 확폭 끈 구간 120~140 (±0.02) — 끔 · 못 읽는 조각은 버림
assert by[119.99][0]["widening_on"] is False
assert by[100.0][0]["widening_on"] is True
# 겹친 범위 = 둘 다 · 뒤집힌 구간 정렬 · 문자 두께 · 빈 사양 = null
assert [entry["type_id"] for entry in by[145.0]] == ["pavement_concrete", "gravel_surfacing"]
gravel = by[145.0][1]
assert (gravel["start_m"], gravel["end_m"]) == (130.0, 150.0)
assert gravel["thickness_m"] is None and gravel["spec"] is None
# 기준 측점만 있는 옛 항목 = 그 측점 한 점
assert by[300.0] == [
{
"type_id": "pavement_concrete",
"spec": "아스콘",
"thickness_m": 0.05,
"start_m": 300.0,
"end_m": 300.0,
"widening_on": False,
}
]
+5 -2
View File
@@ -66,11 +66,14 @@ def test_알약은_전체_편집기와_같은_부품():
def test_저장_안_한_카드_고침_물음():
struct = _text(M02 / "M02_MasterTemplete_Structure.ts")
show = struct.split("const show = ")[1].split("const pickPrice")[0]
assert "cards.dirty()" in show and "showConfirmDialog(TEXT.discardCards)" in show
assert "cards.dirty()" in show and "leaveOrStay(TEXT.discardCards," in show
assert "opts.keepColumn?.(from)" in show
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert "const busy = (): boolean => dirty || !!panel?.dirty() || toDrawing.waiting();" in main
assert main.count("busy()") >= 3 # 떠나기 · 새로고침 막기 · 나무 고침(settle)
assert main.count("busy()") >= 2 # 떠나기 · 새로고침 막기
assert (
'askUnsaved(L("M02_TreeUnsaved"))' in main
) # 나무 고침(settle)도 같은 물음 · 카드 저장 실패까지 봄
assert main.index("await panel.saveCards();") < main.index("await panel.reload();")
assert "keepColumn: (colId) => sheet?.selectColumn(colId)" in main
+1 -1
View File
@@ -46,7 +46,7 @@ def test_메뉴는_숨기고_저장은_도면_칸만():
def test_저장_안_한_카드_고침은_물음():
cards = _text("M02_MasterTemplete_ItemCards.ts")
# 물음 한 번(31-7) — 어느 카드든(도면 · 엑셀) 고침이 있으면 · 틀의 저장 표시(saver)로 봄
assert "if (!again && anyDirty() && col?.id !== column && !(await showConfirmDialog(" in cards
assert "(await leaveOrStay(TEXT.discard, { save: saveAll, stillDirty: anyDirty })) === \"stay\"" in cards
assert "const anyDirty = (): boolean => cards.some((c) => c.saver.dirty());" in cards
assert "dirty: anyDirty," in cards
assert "dirty: () => cards.dirty()," in _text("M02_MasterTemplete_Structure.ts")
+29
View File
@@ -70,6 +70,9 @@ const kind = { 이름: "종류", 고르기: ["막돌", "견치돌"] };
const size = { 이름: "규격", 고르기: [25, 40] };
const area = { 이름: "지역" };
const inputs = [height, kind, size, area];
const machine = {
이름: "기계", 고르기: ["0201-0060", "9999"], 고르기이름: { "0201-0060": "굴착기(무한궤도) 0.6㎥" },
};
console.log(JSON.stringify({
stored: [
m.storedOf(height, ""), m.storedOf(height, "2.5"), m.storedOf(kind, "막돌"),
@@ -82,6 +85,11 @@ console.log(JSON.stringify({
known: m.keepKnown(inputs, { 높이: 2, 종류: "", 없앤것: 3 }),
line: m.valueLine(inputs, { 높이: 2, 규격: 40 }),
lineEmpty: m.valueLine(inputs, {}),
named: [
m.choicesOf(machine).map((c) => [c.값, c.이름]), m.valueHint(machine),
m.valueLine([machine], { 기계: "0201-0060" }).text, m.labelOf(machine, "9999"),
m.storedOf(machine, "0201-0060"),
],
}));
"""
@@ -100,6 +108,27 @@ def test_값_셈() -> None:
assert got["known"] == {"kept": {"높이": 2}, "lost": ["없앤것"]}
assert got["line"] == {"text": "높이 2m · 규격 40", "empty": 2} # 종류 · 지역 = 자동값 없음
assert got["lineEmpty"] == {"text": "", "empty": 3} # 높이는 자동값이 있어 빈 값 아님
# 85-1 — 보이는 글 = 이름 규격 · 모르는 번호는 그대로 · 저장 값은 번호
assert got["named"] == [
[["0201-0060", "굴착기(무한궤도) 0.6㎥"], ["9999", "9999"]],
"굴착기(무한궤도) 0.6㎥ / 9999",
"기계 굴착기(무한궤도) 0.6㎥",
"9999",
"0201-0060",
]
def test_고르기_원문번호에_이름표(client: TestClient) -> None:
"""85-1 — 기계 · 집게 = 「이름 규격」 · 직종(입력 이름에 「직종」) = 직종 이름 · 고르기는 번호 그대로."""
got = client.get("/api/m02/logic-lib/inputs", params={"keys": "GC000260"}).json()
specs = {i["이름"]: i for i in got["로직"][0]["입력"]}
assert specs["기계"]["고르기이름"]["0201-0060"] == "굴착기(무한궤도) 0.6㎥"
assert specs["집게"]["고르기이름"]["7206-0020"] == "부착용 집게 0.2㎥"
assert "0201-0060" in specs["기계"]["고르기"]
assert "고르기이름" not in specs["뒷길이"] and "고르기이름" not in specs["지역"]
whole = labor_inputs.m01.loaded()[1]
job = {"이름": "조종원직종", "고르기": ["1048", "없는번호"]}
assert labor_inputs._labels(whole, job) == {"1048": "건설기계운전사"}
ROWS_SCRIPT = r"""
+125
View File
@@ -0,0 +1,125 @@
"""저장 안 한 고침 3단추 물음(PLAN 86-1 · `ui_template_leave_dialog.ts`) — Node 로 TS 를 돌려 판정.
못박는 것
- 단추 순서 [취소] [버리고 넘어감] [저장 후 넘어감] · 기본 초점 = 취소 · Esc = 취소.
- 저장 후 넘어감: 저장 성공 + 고침 없음 = saved / 저장 던짐 · 고침 남음 = stay(머묾).
- 버리고 넘어감: discard 갈고리 호출 · 취소: 아무것도 안 부름.
- M02 자리(UI_Main · Structure · ItemCards)가 이 물음을 씀.
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
M02 = ROOT / "M02_MasterTemplete"
SCRIPT = r"""
const fs = require("fs");
const ts = require(process.argv[1] + "/config/node_modules/typescript");
const src = fs.readFileSync(process.argv[1] + "/ui_template/ui_template_leave_dialog.ts", "utf8");
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
let focused = null;
let overlay = null;
let buttons = [];
const node = (tag, o = {}) => ({
tag, ...o, kids: [], on: {},
append(...x) { this.kids.push(...x); },
addEventListener(k, f) { this.on[k] = f; },
remove() { overlay = null; },
});
const stubs = {
"./ui_template_elements": {
el: node,
createButton: (o) => {
const b = node("button", { label: o.label, variant: o.variant });
b.focus = () => { focused = b.label; };
b.click = o.onClick;
buttons.push(b);
return b;
},
},
"./ui_template_locale": { t: (k) => k },
};
globalThis.document = { activeElement: null, body: { append(x) { overlay = x; } } };
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n));
const { leaveOrStay } = box.exports;
const run = async (pick, hooks) => {
buttons = [];
focused = null;
const calls = [];
const wrap = {
save: async () => { calls.push("save"); if (hooks.throws) throw new Error("x"); },
stillDirty: () => !!hooks.left,
discard: () => calls.push("discard"),
};
const p = leaveOrStay("msg", wrap);
const labels = buttons.map((b) => b.label);
const first = focused;
if (pick === "esc") overlay.on.keydown({ key: "Escape", stopPropagation() {} });
else buttons[pick].click();
return { got: await p, calls, labels, first };
};
(async () => {
const out = {
cancel: await run(0, {}),
esc: await run("esc", {}),
discard: await run(1, {}),
save: await run(2, {}),
saveThrows: await run(2, { throws: true }),
saveLeft: await run(2, { left: true }),
};
process.stdout.write(JSON.stringify(out));
})();
"""
@pytest.fixture(scope="module")
def got() -> dict:
done = subprocess.run(
["node", "-e", SCRIPT, str(ROOT)],
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
def test_단추_순서_기본_초점_취소(got):
row = got["cancel"]
assert row["labels"] == ["M02_TreeCancel", "M02_TreeDiscardGo", "M02_TreeSaveGo"]
assert row["first"] == "M02_TreeCancel"
def test_취소_Esc는_머묾(got):
assert got["cancel"]["got"] == "stay" and got["cancel"]["calls"] == []
assert got["esc"]["got"] == "stay" and got["esc"]["calls"] == []
def test_버리고_넘어감(got):
assert got["discard"]["got"] == "discarded"
assert got["discard"]["calls"] == ["discard"]
def test_저장_후_넘어감과_실패_머묾(got):
assert got["save"] == {**got["save"], "got": "saved", "calls": ["save"]}
assert got["saveThrows"]["got"] == "stay" # 저장 던짐
assert got["saveLeft"]["got"] == "stay" # 저장 뒤에도 고침이 남음(카드 저장 실패 등)
def test_M02_자리가_3단추_물음을_씀():
main = (M02 / "M02_MasterTemplete_UI_Main.ts").read_text(encoding="utf-8")
assert "leaveOrStay(message, { save: doSave, stillDirty: busy" in main
assert "askSettle" not in main
assert "!(await showConfirmDialog(L(" not in main
cards = (M02 / "M02_MasterTemplete_ItemCards.ts").read_text(encoding="utf-8")
assert "leaveOrStay(TEXT.discard" in cards
struct = (M02 / "M02_MasterTemplete_Structure.ts").read_text(encoding="utf-8")
assert struct.count("leaveOrStay(TEXT.discardCards") == 2
+8
View File
@@ -216,3 +216,11 @@ def test_측점이_없어진_손_값은_맨_아래_비고_줄로(project: Path)
assert last["값"] == {"sta": "NO.27+15", "h_intake": 1, "memo": "설계에서 측점 없어짐"}
kept = fill.strip_design(doc)
assert kept["줄"][-1]["값"]["h_intake"] == 1 # 다음 저장에도 남음
def test_집계_길이는_구간형이면_시작_종료가_먼저(project: Path) -> None:
# 노면공 87-2 — 범위를 고쳐도 옛 length_m 이 안 앞섬
moved = {"start_m": 200, "end_m": 245, "options": {"length_m": 30}}
assert fill._length(moved) == 45
assert fill._length({"options": {"length_m": 12}}) == 12 # 점형 · 구간 없음 = 입력 길이
assert fill._length({"options": {}}) is None
+84
View File
@@ -0,0 +1,84 @@
/* =============================================================================
* ui_template_leave_dialog.ts
* 저장 안 한 고침이 있을 때 넘어가기 전 물음 — 단추 셋: [취소] [버리고 넘어감] [저장 후 넘어감](주 단추).
* 기본 초점 = 취소 · Esc = 취소 · 저장이 실패하거나 고침이 남으면 머묾.
* 몸은 공용 확인 창(`showConfirmDialog`)과 같은 틀(.ui-confirm).
* ========================================================================== */
import { createButton, el } from "./ui_template_elements";
import { t as L } from "./ui_template_locale";
export type LeaveChoice = "save" | "discard" | "cancel";
/** 3단추 물음 — 닫기 · Esc = "cancel" */
export function askLeave(message: string): Promise<LeaveChoice> {
return new Promise((resolve) => {
const overlay = el("div", { className: "ui-confirm" });
const panel = el("div", { className: "ui-confirm__panel" });
const actions = el("div", { className: "ui-confirm__actions" });
const back = document.activeElement as HTMLElement | null; // 닫힐 때 초점을 돌려놓을 자리
const done = (choice: LeaveChoice): void => {
overlay.remove();
if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true });
resolve(choice);
};
const cancel = createButton({
label: L("M02_TreeCancel"),
variant: "ghost",
onClick: () => done("cancel"),
});
actions.append(
cancel,
createButton({
label: L("M02_TreeDiscardGo"),
variant: "danger",
onClick: () => done("discard"),
}),
createButton({
label: L("M02_TreeSaveGo"),
variant: "filled",
onClick: () => done("save"),
}),
);
overlay.addEventListener("keydown", (ev) => {
if (ev.key !== "Escape") return;
ev.stopPropagation(); // 뒤 창 · 페이지 Esc 까지 닫지 않음
done("cancel");
});
panel.append(el("p", { className: "ui-confirm__message", text: message }), actions);
overlay.append(panel);
document.body.append(overlay);
cancel.focus(); // 창에 초점 — 뒤 칸에 글이 들어가지 않고 Esc 가 먹음
});
}
export interface LeaveHooks {
/** 저장 한 번 — 실패는 던지거나 `stillDirty` 로 알림(알림 글은 저장 길이 띄움) */
save: () => Promise<unknown>;
/** 저장 뒤에도 고침이 남았나 — 남으면 머묾 */
stillDirty: () => boolean;
/** [버리고 넘어감] 때 */
discard?: () => void | Promise<void>;
}
/**
* 물음 → 고름대로 처리. 넘어가도 되면 "saved" | "discarded", 머물면 "stay".
* 저장 실패(던짐) · 저장 뒤 고침 남음 · 취소 = "stay".
*/
export async function leaveOrStay(
message: string,
hooks: LeaveHooks,
): Promise<"saved" | "discarded" | "stay"> {
const choice = await askLeave(message);
if (choice === "cancel") return "stay";
if (choice === "discard") {
await hooks.discard?.();
return "discarded";
}
try {
await hooks.save();
} catch {
return "stay";
}
return hooks.stillDirty() ? "stay" : "saved";
}
+2 -2
View File
@@ -154,8 +154,8 @@ export const ui_locales_m2 = {
"저장 안 한 표 고침이 있음 — 나무를 고치기 전에 처리",
"Unsaved table edits — settle them before changing the tree",
],
M02_TreeSaveGo: ["저장하고 계속", "Save and continue"],
M02_TreeDiscardGo: ["버리고 계속", "Discard and continue"],
M02_TreeSaveGo: ["저장 후 넘어감", "Save and continue"],
M02_TreeDiscardGo: ["버리고 넘어감", "Discard and continue"],
M02_TreeDone: ["나무를 고침", "Tree updated"],
M02_PracticeTitle: ["실무문서", "Practice documents"],
M02_PracticeRow: ["실무문서 목록", "Practice document list"],