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:
@@ -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 ?? [])];
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
@@ -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군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user