Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4
충돌 풂 — Culvert_Tiers cutBerm = sub7 (lengthM) 꼴 + 91-4 복합경사 제원 · test_79 유출 끝 = 수평공간 없이 바로 절토(89-4) · Culvert_Geom 700줄 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
@@ -32,6 +32,7 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = {
|
||||
prefix: "ford_chute",
|
||||
link: "gfordpavechute",
|
||||
max: 1,
|
||||
// 구조 · 두께(cm · 새 상자 30) — 배관 · 박스 · 세월교 `CHUTE_ITEMS` 와 같은 칸(90-2)
|
||||
items: [
|
||||
{
|
||||
id: "structure",
|
||||
@@ -42,6 +43,7 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = {
|
||||
{ value: "돌붙임", label: "돌붙임" },
|
||||
],
|
||||
},
|
||||
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "30" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -83,11 +83,12 @@ export const revetKeys = (side: "inlet" | "outlet"): Record<string, string> => (
|
||||
});
|
||||
|
||||
/** 세월교 · 박스 유입 · 유출 흙막이 칸(79-7) — 높이 빈 칸 = 자동(`heightHint` · 횡단도가 셈) ·
|
||||
* 절토부 앞 수평공간 칸(`{side}_revet_berm_m`)을 더함. */
|
||||
* 유입만 절토부 앞 수평공간 칸(`inlet_revet_berm_m` · 유출은 없음 · 89-4)을 더함. */
|
||||
export function bodyGuardItems(side: "inlet" | "outlet", heightHint: string): SpecItem[] {
|
||||
const items = guardItems({ 흙막이: revetKeys(side) }, undefined, true).map((item) =>
|
||||
item.id === "height" ? { ...item, default: undefined, hint: heightHint } : item,
|
||||
);
|
||||
if (side === "outlet") return items;
|
||||
const berm: SpecItem = {
|
||||
id: "berm",
|
||||
label: "수평공간 (m)",
|
||||
@@ -109,6 +110,20 @@ export const SOIL_ADD: SpecContainer = {
|
||||
items: guardItems(),
|
||||
};
|
||||
|
||||
/** 도수로/돌붙임 칸 — 구조 · 두께(cm · 새 상자 30 · 90-2 사용자 확정) · 물넘이 포장(`ford_chute`)도 같은 칸. */
|
||||
export const CHUTE_ITEMS: SpecItem[] = [
|
||||
{
|
||||
id: "structure",
|
||||
label: "구조",
|
||||
kind: "select",
|
||||
choices: [
|
||||
{ value: "도수로", label: "도수로" },
|
||||
{ value: "돌붙임", label: "돌붙임" },
|
||||
],
|
||||
},
|
||||
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "30" },
|
||||
];
|
||||
|
||||
/** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */
|
||||
export const CHUTE_ADD: SpecContainer = {
|
||||
id: "chute_add",
|
||||
@@ -117,15 +132,5 @@ export const CHUTE_ADD: SpecContainer = {
|
||||
prefix: "chute_add",
|
||||
link: "gchuteadd",
|
||||
max: 1,
|
||||
items: [
|
||||
{
|
||||
id: "structure",
|
||||
label: "구조",
|
||||
kind: "select",
|
||||
choices: [
|
||||
{ value: "도수로", label: "도수로" },
|
||||
{ value: "돌붙임", label: "돌붙임" },
|
||||
],
|
||||
},
|
||||
],
|
||||
items: CHUTE_ITEMS,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,9 @@ export function setPipeSectionsSource(source: () => readonly CrossSection[] | nu
|
||||
sectionsSource = source;
|
||||
}
|
||||
|
||||
/** 횡단 측점 목록 읽기 — 노면공 확폭 구간 상자(87-3)가 확폭을 읽음 · 자료 없으면 null. */
|
||||
export const pipeSections = (): readonly CrossSection[] | null => sectionsSource?.() ?? null;
|
||||
|
||||
/** 유출 단 수를 대는 곳 — 횡단 보기의 세션 값 우선(`countFor`) · 없으면 정본 patch. */
|
||||
let extraCountSource: ((section: CrossSection) => number) | null = null;
|
||||
export function setPipeExtraCountSource(source: (section: CrossSection) => number): void {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/** 글 항목 길이 상한(자) — 칸 · 저장이 함께 자름. */
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/* =============================================================================
|
||||
* design_structure_widening.ts
|
||||
* 노면공 › 확폭 구간(87-3) — 범위 안에서 확폭(좌 + 우) > 0 인 횡단 측점을 이어진 구간으로 묶고,
|
||||
* 끈 구간을 `options.widening_off = "시작-끝,시작-끝"`(측점 m · 빈 글 = 모두 켬)으로 읽고 씀.
|
||||
* 켬 = 확폭 포함 · 끔 = 규격 폭만. 노선이 바뀌어 구간 끝이 안 맞는 끈 값은 무시(= 켬).
|
||||
* 횡단 형상 쪽(88장)도 이 키를 같은 글로 읽음 — 측점이 범위 ±0.02 m 안.
|
||||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 구조물 범위 · 구간 끝 맞춤 허용치(m). */
|
||||
export const WIDENING_TOL_M = 0.02;
|
||||
|
||||
/** 횡단 측점에서 읽는 몫만 — 횡단 설계 확폭(좌 · 우) · 설계 없으면 저장된 곡선 확폭(바깥쪽 한 방향). */
|
||||
export interface WideningStation {
|
||||
chainage_m: number;
|
||||
curve_widening_m?: number | null;
|
||||
curve_outer_side?: "left" | "right" | null;
|
||||
design?: { widening_left_m?: number; widening_right_m?: number } | null;
|
||||
}
|
||||
|
||||
export interface WideningRun {
|
||||
start_m: number;
|
||||
end_m: number;
|
||||
/** 구간 안 확폭(좌 + 우) 최댓값(m). */
|
||||
widening_m: number;
|
||||
}
|
||||
|
||||
/** 한 측점의 확폭(좌 + 우, m) — 없으면 0. */
|
||||
export function wideningOf(station: WideningStation): number {
|
||||
const sum = station.design
|
||||
? (station.design.widening_left_m ?? 0) + (station.design.widening_right_m ?? 0)
|
||||
: station.curve_outer_side
|
||||
? (station.curve_widening_m ?? 0)
|
||||
: 0;
|
||||
return Number.isFinite(sum) && sum > 0 ? sum : 0;
|
||||
}
|
||||
|
||||
/** 범위 `[start, end]` 안 확폭 > 0 측점을 이어진 구간(범위 안 측점 목록에서 이웃)으로 묶음. */
|
||||
export function wideningRuns(
|
||||
stations: readonly WideningStation[],
|
||||
start: number,
|
||||
end: number,
|
||||
): WideningRun[] {
|
||||
const inside = stations
|
||||
.filter((s) => s.chainage_m >= start - WIDENING_TOL_M && s.chainage_m <= end + WIDENING_TOL_M)
|
||||
.sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
const runs: WideningRun[] = [];
|
||||
let open: WideningRun | null = null;
|
||||
for (const station of inside) {
|
||||
const width = wideningOf(station);
|
||||
if (width <= 0) {
|
||||
open = null;
|
||||
continue;
|
||||
}
|
||||
if (open) {
|
||||
open.end_m = station.chainage_m;
|
||||
open.widening_m = Math.max(open.widening_m, width);
|
||||
} else {
|
||||
open = { start_m: station.chainage_m, end_m: station.chainage_m, widening_m: width };
|
||||
runs.push(open);
|
||||
}
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
const round = (value: number): number => Number(value.toFixed(2));
|
||||
|
||||
/** 저장 글 → 끈 구간 쌍. 틀린 조각은 버림. */
|
||||
export function parseOff(text: unknown): Array<[number, number]> {
|
||||
if (typeof text !== "string") return [];
|
||||
const pairs: Array<[number, number]> = [];
|
||||
for (const part of text.split(",")) {
|
||||
const [a, b] = part.split("-").map((token) => Number(token.trim()));
|
||||
if (part.includes("-") && Number.isFinite(a) && Number.isFinite(b) && b >= a)
|
||||
pairs.push([a, b]);
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
export const formatOff = (pairs: ReadonlyArray<readonly [number, number]>): string =>
|
||||
pairs.map(([a, b]) => `${round(a)}-${round(b)}`).join(",");
|
||||
|
||||
/** 끈 쌍이 이 구간과 맞는가(시작 · 끝 각각 허용치 안). */
|
||||
export const pairMatches = (pair: readonly [number, number], run: WideningRun): boolean =>
|
||||
Math.abs(pair[0] - run.start_m) <= WIDENING_TOL_M &&
|
||||
Math.abs(pair[1] - run.end_m) <= WIDENING_TOL_M;
|
||||
|
||||
/** 지금 구간들 중 끈 것만 남긴 글(안 맞는 옛 값은 빠짐). */
|
||||
export function offForRuns(runs: readonly WideningRun[], text: unknown): string {
|
||||
const pairs = parseOff(text);
|
||||
return formatOff(
|
||||
runs
|
||||
.filter((run) => pairs.some((p) => pairMatches(p, run)))
|
||||
.map((run) => [run.start_m, run.end_m] as const),
|
||||
);
|
||||
}
|
||||
|
||||
/** 구간 하나의 켬/끔을 뒤집은 새 글(맞는 구간 쌍만 남김). */
|
||||
export function toggleRun(
|
||||
runs: readonly WideningRun[],
|
||||
text: unknown,
|
||||
target: WideningRun,
|
||||
): string {
|
||||
const pairs = parseOff(text);
|
||||
const isOff = (run: WideningRun): boolean => pairs.some((p) => pairMatches(p, run));
|
||||
return formatOff(
|
||||
runs
|
||||
.filter((run) => (run === target ? !isOff(run) : isOff(run)))
|
||||
.map((run) => [run.start_m, run.end_m] as const),
|
||||
);
|
||||
}
|
||||
|
||||
export const isRunOff = (run: WideningRun, text: unknown): boolean =>
|
||||
parseOff(text).some((p) => pairMatches(p, run));
|
||||
@@ -59,6 +59,10 @@ function bootstrap(): void {
|
||||
let shown = "";
|
||||
let shownHash = "";
|
||||
const renderAll = (): void => {
|
||||
document.documentElement.classList.toggle(
|
||||
"is-bm-route",
|
||||
NATIVE_MENU_ROUTE.test(currentRoute()),
|
||||
); // 전역 글 선택 막음(81-1)
|
||||
const within = showWithinPage(currentRoute()); // B05 ↔ B06 — 보기만 바꿈(24-3a)
|
||||
shown = currentRoute();
|
||||
shownHash = window.location.hash;
|
||||
|
||||
@@ -110,7 +110,7 @@ export function createSpreadsheet(
|
||||
): SpreadsheetHandle {
|
||||
const readOnly = !!opts.readOnly;
|
||||
const noTabs = opts.tabs === false;
|
||||
const bare = readOnly || !!opts.preview; // 도구 모음 · 메뉴 없음
|
||||
const bare = readOnly || !!opts.preview; // 도구 모음 없음 · 우클릭은 「복사」 한 줄
|
||||
const root = el("div", {
|
||||
className: `ss${readOnly ? " ss--readonly" : ""}${noTabs ? " ss--notabs" : ""}${opts.preview ? " ss--preview" : ""}`,
|
||||
});
|
||||
@@ -142,7 +142,7 @@ export function createSpreadsheet(
|
||||
ctx.editor = ed;
|
||||
const toolbar: PartHandle | null = bare ? null : mountToolbar(ctx);
|
||||
const tabs: PartHandle | null = noTabs ? null : mountTabs(ctx);
|
||||
const menu: PartHandle | null = bare ? null : attachMenu(ctx);
|
||||
const menu: PartHandle = attachMenu(ctx); // 읽기 전용 · 미리보기는 「복사」 한 줄(81-3)
|
||||
const clip = attachClipboard(ctx);
|
||||
const status = createStatusBar(ctx);
|
||||
const others = [toolbar, tabs, menu, clip, status].filter((p): p is PartHandle => !!p);
|
||||
@@ -171,7 +171,7 @@ export function createSpreadsheet(
|
||||
}
|
||||
|
||||
grid.render();
|
||||
select(ctx.selection);
|
||||
select(ctx.selection, false, true);
|
||||
loadLinks();
|
||||
if (opts.conditions && !readOnly)
|
||||
void import("./spreadsheet_condition").then((m) => {
|
||||
@@ -221,7 +221,7 @@ export function createSpreadsheet(
|
||||
if (command.종류 === "칸") grid.invalidate([...again, ...touched(command.시트, command.칸)]);
|
||||
else grid.render();
|
||||
}
|
||||
select(ctx.selection);
|
||||
select(ctx.selection, false, true); // 명령이 바꾼 뒤 — 알약 점 등 문서 종속 부품도 다시(81-2)
|
||||
opts.onChange?.(structuredClone(ctx.book));
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ export function createSpreadsheet(
|
||||
return out;
|
||||
}
|
||||
|
||||
function select(selection: Selection, reveal = false): void {
|
||||
function select(selection: Selection, reveal = false, dataChanged = false): void {
|
||||
ctx.selection = selection;
|
||||
memory.set(selection.시트, selection);
|
||||
grid.renderSelection();
|
||||
@@ -244,7 +244,7 @@ export function createSpreadsheet(
|
||||
grid.reveal(far.r, far.c);
|
||||
}
|
||||
ed.sync();
|
||||
for (const p of others) p.refresh();
|
||||
for (const p of others) p.refresh(dataChanged);
|
||||
const g = selection.범위[0];
|
||||
opts.onSelect?.({
|
||||
시트: selection.시트,
|
||||
@@ -261,7 +261,7 @@ export function createSpreadsheet(
|
||||
ctx.book.활성 = sheetId;
|
||||
ctx.selection = memory.get(sheetId) ?? selectCell(sheet, 0, 0);
|
||||
grid.render();
|
||||
select(ctx.selection, true);
|
||||
select(ctx.selection, true, true);
|
||||
ed.focus();
|
||||
}
|
||||
|
||||
@@ -275,13 +275,13 @@ export function createSpreadsheet(
|
||||
memory.clear();
|
||||
ctx.selection = selectCell(firstSheet(next), 0, 0);
|
||||
grid.render();
|
||||
select(ctx.selection);
|
||||
select(ctx.selection, false, true);
|
||||
loadLinks();
|
||||
},
|
||||
recalc() {
|
||||
ctx.engine.rebuild(ctx.book);
|
||||
grid.render();
|
||||
for (const p of others) p.refresh();
|
||||
for (const p of others) p.refresh(true);
|
||||
return ctx.engine.snapshot();
|
||||
},
|
||||
getLinkedValues() {
|
||||
|
||||
@@ -97,6 +97,16 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe
|
||||
back();
|
||||
};
|
||||
const act = mountActions(ctx, back);
|
||||
ctx.dataMenu = (kind) => {
|
||||
if (kind === "filter") return (toggleFilter(), back());
|
||||
if (kind === "condfmt") return void openPanel(TEXT.condfmt, async () => createCondFmtPanel);
|
||||
if (kind === "validation")
|
||||
return void openPanel(TEXT.validation, async () => createValidationPanel);
|
||||
void openPanel(
|
||||
TEXT.names,
|
||||
async () => (await import("./spreadsheet_names")).createNameManagerPanel,
|
||||
);
|
||||
};
|
||||
const menu = createMapContextMenu("ss");
|
||||
ctx.root.append(menu.element);
|
||||
let closeSort: (() => void) | null = null;
|
||||
|
||||
@@ -358,7 +358,9 @@ export function mountLinks(
|
||||
bar.querySelector(".is-here")?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
||||
}
|
||||
|
||||
/** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 */
|
||||
/** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감
|
||||
* 읽기(slot — getBoundingClientRect) 를 칸마다 먼저 다 끝내고 쓰기(append)는 나중에 — 안 그러면
|
||||
* 읽기·쓰기가 번갈아 와서 매 칸마다 강제 레이아웃이 남(81-2②). */
|
||||
function placeDots(): void {
|
||||
for (const d of dots) d.remove();
|
||||
dots = [];
|
||||
@@ -368,11 +370,37 @@ export function mountLinks(
|
||||
// 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수
|
||||
const byCell = new Map<string, CellLink[]>();
|
||||
for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]);
|
||||
|
||||
// 1) 읽기 — 칸마다 자리만 먼저 모음(DOM 변경 없음)
|
||||
const cellSlots: Array<{
|
||||
list: CellLink[];
|
||||
layer: HTMLElement;
|
||||
box: ReturnType<typeof slot>["box"];
|
||||
}> = [];
|
||||
for (const [a1, list] of byCell) {
|
||||
const p = parseA1(a1);
|
||||
if (!p) continue;
|
||||
const { layer, box } = slot(ctx, p.r, p.c);
|
||||
if (!box.w || !box.h) continue; // 숨긴 행열
|
||||
cellSlots.push({ list, layer, box });
|
||||
}
|
||||
const rangeSlots: Array<{
|
||||
mark: { 시트: string; 범위: string; 이름: string; 설명?: string };
|
||||
layer: HTMLElement;
|
||||
box: ReturnType<typeof slot>["box"];
|
||||
end: ReturnType<typeof slot>["box"];
|
||||
}> = [];
|
||||
for (const mark of opts?.표시범위 ?? []) {
|
||||
const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null;
|
||||
if (!g) continue;
|
||||
const { layer, box } = slot(ctx, g.r0, g.c0);
|
||||
const end = slot(ctx, g.r1, g.c1).box;
|
||||
if (!box.w || !box.h) continue;
|
||||
rangeSlots.push({ mark, layer, box, end });
|
||||
}
|
||||
|
||||
// 2) 쓰기 — 모은 자리에 한 번에 그림
|
||||
for (const { list, layer, box } of cellSlots) {
|
||||
const first = list[0];
|
||||
const tint = el("div", { className: "ss-link-cell", attrs: { "data-key": first.키 } });
|
||||
tint.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
|
||||
@@ -392,12 +420,7 @@ export function mountLinks(
|
||||
dots.push(...marks);
|
||||
}
|
||||
// 보낸 범위(도면 표 등) — 채움 없는 점선 테두리 + 왼위 이름표(알약 색 채움과 안 겹침)
|
||||
for (const mark of opts?.표시범위 ?? []) {
|
||||
const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null;
|
||||
if (!g) continue;
|
||||
const { layer, box } = slot(ctx, g.r0, g.c0);
|
||||
const end = slot(ctx, g.r1, g.c1).box;
|
||||
if (!box.w || !box.h) continue;
|
||||
for (const { mark, layer, box, end } of rangeSlots) {
|
||||
const frame = el("div", {
|
||||
className: "ss-link-range",
|
||||
attrs: { "data-range": mark.범위 },
|
||||
@@ -542,12 +565,17 @@ export function mountLinks(
|
||||
ctx.grid.root.addEventListener("drop", onDropEvent);
|
||||
ctx.grid.root.addEventListener("mousemove", onHover);
|
||||
|
||||
let dotsReady = false;
|
||||
return {
|
||||
root: opts ? bar : null,
|
||||
apply: applyLinked,
|
||||
refresh() {
|
||||
// dataChanged 거짓(고름만 바뀜) 이면 점은 건너뜀 — 점은 선택에 안 매임(81-2②)
|
||||
refresh(dataChanged = false) {
|
||||
renderBar();
|
||||
placeDots();
|
||||
if (dataChanged || !dotsReady) {
|
||||
placeDots();
|
||||
dotsReady = true;
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom);
|
||||
|
||||
@@ -12,10 +12,21 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { rangeToA1 } from "./spreadsheet_address";
|
||||
import { confirmMerge, dialogKeys } from "./spreadsheet_actions";
|
||||
import { deleteComment, getComment, setComment } from "./spreadsheet_comments";
|
||||
import {
|
||||
cellInsertItems,
|
||||
cellMoreItems,
|
||||
colInsertItems,
|
||||
colMoreItems,
|
||||
copyCut,
|
||||
copyOnlyItems,
|
||||
pasteItems,
|
||||
rowInsertItems,
|
||||
rowMoreItems,
|
||||
} from "./spreadsheet_menu_items";
|
||||
import { st } from "./spreadsheet_text";
|
||||
import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
@@ -28,7 +39,8 @@ function within(range: CellRange, r: number, c: number): boolean {
|
||||
|
||||
/** `ctx.root` 의 contextmenu 를 받음(root null) */
|
||||
export function attachMenu(ctx: SpreadsheetContext): PartHandle {
|
||||
if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} };
|
||||
// 읽기 전용 · 미리보기 = 「복사」 한 줄(81-3)
|
||||
const bare = ctx.readOnly || ctx.root.classList.contains("ss--preview");
|
||||
|
||||
const menu = createMapContextMenu("ss");
|
||||
ctx.root.append(menu.element);
|
||||
@@ -39,7 +51,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
|
||||
}
|
||||
|
||||
function cutCopy(kind: "cut" | "copy"): void {
|
||||
document.execCommand(kind);
|
||||
copyCut(ctx, kind);
|
||||
}
|
||||
|
||||
/** 메모 글 입력 — 작은 오버레이(native prompt 안 씀, 자동화 창이 native 창을 자동 취소하는
|
||||
@@ -79,7 +91,10 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
|
||||
return [
|
||||
[st("MenuCut"), () => cutCopy("cut")],
|
||||
[st("MenuCopy"), () => cutCopy("copy")],
|
||||
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
|
||||
...pasteItems(ctx),
|
||||
"-",
|
||||
...cellInsertItems(ctx, range),
|
||||
"-",
|
||||
[
|
||||
st("MenuClearContent"),
|
||||
() => ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [range] }),
|
||||
@@ -107,21 +122,20 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
|
||||
...(note
|
||||
? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[])
|
||||
: []),
|
||||
...cellMoreItems(ctx, range),
|
||||
...(ctx.cellMenuExtra?.(r, c) ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
function rowMenuItems(rows: number[]): MapContextMenuItem[] {
|
||||
const at = Math.min(...rows);
|
||||
const at = rows[0]; // rows 는 늘 오름차순 연속(81-2③) — 전체 고름이면 104만 개라 Math.min(...rows) 는 스택 넘침
|
||||
const 수 = rows.length;
|
||||
return [
|
||||
[st("MenuCut"), () => cutCopy("cut")],
|
||||
[st("MenuCopy"), () => cutCopy("copy")],
|
||||
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
|
||||
[
|
||||
st("MenuInsertRow"),
|
||||
() => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at, 수 }),
|
||||
],
|
||||
...pasteItems(ctx),
|
||||
"-",
|
||||
...rowInsertItems(ctx, at, 수),
|
||||
[
|
||||
st("MenuDeleteRow"),
|
||||
() => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at, 수 }),
|
||||
@@ -148,20 +162,19 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
|
||||
숨김: false,
|
||||
}),
|
||||
],
|
||||
...rowMoreItems(ctx, rows),
|
||||
];
|
||||
}
|
||||
|
||||
function colMenuItems(cols: number[]): MapContextMenuItem[] {
|
||||
const at = Math.min(...cols);
|
||||
const at = cols[0]; // cols 도 늘 오름차순 연속 — 같은 까닭(81-2③)
|
||||
const 수 = cols.length;
|
||||
return [
|
||||
[st("MenuCut"), () => cutCopy("cut")],
|
||||
[st("MenuCopy"), () => cutCopy("copy")],
|
||||
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
|
||||
[
|
||||
st("MenuInsertCol"),
|
||||
() => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at, 수 }),
|
||||
],
|
||||
...pasteItems(ctx),
|
||||
"-",
|
||||
...colInsertItems(ctx, at, 수),
|
||||
[
|
||||
st("MenuDeleteCol"),
|
||||
() => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at, 수 }),
|
||||
@@ -188,6 +201,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
|
||||
숨김: false,
|
||||
}),
|
||||
],
|
||||
...colMoreItems(ctx, cols),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -217,7 +231,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
|
||||
활성: { r: hit.r, c: 0 },
|
||||
기준: { r: hit.r, c: 0 },
|
||||
});
|
||||
menu.open(x, y, rowMenuItems(rows));
|
||||
menu.open(x, y, bare ? copyOnlyItems(ctx) : rowMenuItems(rows));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -235,7 +249,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
|
||||
활성: { r: 0, c: hit.c },
|
||||
기준: { r: 0, c: hit.c },
|
||||
});
|
||||
menu.open(x, y, colMenuItems(cols));
|
||||
menu.open(x, y, bare ? copyOnlyItems(ctx) : colMenuItems(cols));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -250,7 +264,13 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
|
||||
});
|
||||
}
|
||||
const active = ctx.selection.범위[0] ?? range(hit.r, hit.c, hit.r, hit.c);
|
||||
menu.open(x, y, cellMenuItems(active, hit.r, hit.c));
|
||||
menu.open(
|
||||
x,
|
||||
y,
|
||||
bare
|
||||
? [...copyOnlyItems(ctx), ...(ctx.cellMenuExtra?.(hit.r, hit.c) ?? [])]
|
||||
: cellMenuItems(active, hit.r, hit.c),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_menu_items.ts (81-3)
|
||||
* 우클릭 메뉴에 덧붙이는 항목 — 구글 시트 우클릭 가운데 기능이 이미 있는 것만(조사_A 3 표 (a)).
|
||||
* 붙여넣기 · 선택하여 붙여넣기 ▸ · 행 · 열 · 셀 삽입/삭제 ▸ · 행 · 열 크기 · 데이터에 맞추기 ·
|
||||
* 시트 정렬 · 필터 · 고정 ▸ · 그룹 ▸ · 셀 작업 더보기 ▸(드롭다운 · 조건부 서식 · 범위 이름).
|
||||
* 안 만듦 = 하이퍼링크 · 댓글 · 범위/시트 보호 · 다른 파일로 복사.
|
||||
* `spreadsheet_menu.ts` 가 이 목록을 펼쳐 붙임. 몸통 창 · 명령 모듈은 누를 때 `import()`(크기 예산).
|
||||
* 붙여넣기는 보안 문맥(localhost · https)에서만 클립보드를 읽어 실제로 붙임 — http 는 Ctrl+V 안내.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { dialogKeys, type Act } from "./spreadsheet_actions";
|
||||
import { parseA1 } from "./spreadsheet_address";
|
||||
import { parseClipboard } from "./spreadsheet_clipboard";
|
||||
import type { Editor } from "./spreadsheet_editor";
|
||||
import {
|
||||
buildColGeometry,
|
||||
buildRowGeometry,
|
||||
MAX_COL_CHARS,
|
||||
pxToColChars,
|
||||
pxToPt,
|
||||
} from "./spreadsheet_headers";
|
||||
import type { PasteSpecialMode } from "./spreadsheet_paste_special";
|
||||
import { st } from "./spreadsheet_text";
|
||||
import type { CellRange, Command, Sheet } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const TEXT = {
|
||||
paste: "붙여넣기",
|
||||
pasteSpecial: "선택하여 붙여넣기",
|
||||
pasteValues: "값만 붙여넣기",
|
||||
pasteFormat: "서식만 붙여넣기",
|
||||
pasteSpecialHint: "선택하여 붙여넣기는 Ctrl+Shift+V 를 쓸 것",
|
||||
pasteFailed: "붙여넣을 내용이 없음",
|
||||
insertLines: "행 · 열 삽입",
|
||||
rowsAbove: (n: number) => `위에 ${n}행 삽입`,
|
||||
rowsBelow: (n: number) => `아래에 ${n}행 삽입`,
|
||||
colsLeft: (n: number) => `왼쪽에 ${n}열 삽입`,
|
||||
colsRight: (n: number) => `오른쪽에 ${n}열 삽입`,
|
||||
insertCells: "셀 삽입",
|
||||
shiftRight: "셀 삽입 후 오른쪽으로 밀기",
|
||||
shiftDown: "셀 삽입 후 아래로 밀기",
|
||||
deleteLines: "행 · 열 삭제",
|
||||
deleteRows: (n: number) => `${n}행 삭제`,
|
||||
deleteCols: (n: number) => `${n}열 삭제`,
|
||||
deleteCells: "셀 삭제",
|
||||
shiftLeft: "셀 삭제 후 왼쪽으로 당기기",
|
||||
shiftUp: "셀 삭제 후 위로 당기기",
|
||||
rowSize: "행 크기 조절…",
|
||||
colSize: "열 크기 조절…",
|
||||
rowSizeLabel: "행 높이(px)",
|
||||
colSizeLabel: "열 너비(px)",
|
||||
autofit: "데이터에 맞추기",
|
||||
sortAsc: "시트 정렬 A → Z",
|
||||
sortDesc: "시트 정렬 Z → A",
|
||||
filterOn: "필터 만들기",
|
||||
filterOff: "필터 삭제",
|
||||
freeze: "고정",
|
||||
freezeRow: "첫 행 고정",
|
||||
freezeCol: "첫 열 고정",
|
||||
freezeAt: "현재 칸까지 고정",
|
||||
unfreeze: "고정 풀기",
|
||||
group: "그룹",
|
||||
groupOn: "그룹으로 묶기",
|
||||
groupOff: "그룹 풀기",
|
||||
more: "셀 작업 더보기",
|
||||
dropdown: "드롭다운…",
|
||||
condfmt: "조건부 서식…",
|
||||
nameRange: "범위 이름 지정…",
|
||||
nameLabel: "이름",
|
||||
nameEmpty: "이름을 넣을 것",
|
||||
ok: "확인",
|
||||
cancel: "취소",
|
||||
badNumber: "1 이상 숫자를 넣을 것",
|
||||
};
|
||||
|
||||
type Item = [string, () => void];
|
||||
const SEP = "-" as const;
|
||||
|
||||
/** 메뉴를 닫으면 누른 단추가 지워져 초점이 문서 밖으로 감 — 격자 입력칸으로 되돌림 */
|
||||
function back(ctx: SpreadsheetContext): void {
|
||||
(ctx.editor as Editor).focus?.();
|
||||
}
|
||||
|
||||
/** 잘라내기 · 복사 — 초점을 격자로 되돌린 뒤 불러야 부품의 copy · cut 듣기가 받음(빈 복사 막음) */
|
||||
export function copyCut(ctx: SpreadsheetContext, kind: "cut" | "copy"): void {
|
||||
back(ctx);
|
||||
document.execCommand(kind);
|
||||
}
|
||||
|
||||
/** 읽기 전용 · 미리보기 시트 — 「복사」 한 줄 */
|
||||
export function copyOnlyItems(ctx: SpreadsheetContext): MapContextMenuItem[] {
|
||||
return [[st("MenuCopy"), () => copyCut(ctx, "copy")]];
|
||||
}
|
||||
|
||||
const id = (ctx: SpreadsheetContext): string => ctx.selection.시트;
|
||||
|
||||
/** 메뉴로 클립보드를 읽을 수 있는가 — 보안 문맥(localhost · https)만 */
|
||||
export function canMenuPaste(): boolean {
|
||||
return globalThis.isSecureContext === true && typeof navigator?.clipboard?.read === "function";
|
||||
}
|
||||
|
||||
async function readClipboard(): Promise<{ html: string; text: string } | null> {
|
||||
if (!canMenuPaste()) return null;
|
||||
try {
|
||||
let html = "";
|
||||
let text = "";
|
||||
for (const item of await navigator.clipboard.read()) {
|
||||
if (!html && item.types.includes("text/html"))
|
||||
html = await (await item.getType("text/html")).text();
|
||||
if (!text && item.types.includes("text/plain"))
|
||||
text = await (await item.getType("text/plain")).text();
|
||||
}
|
||||
return { html, text };
|
||||
} catch {
|
||||
return null; // 권한 거절 — 안내로
|
||||
}
|
||||
}
|
||||
|
||||
/** 메뉴 붙여넣기 — 보통은 부품의 paste 듣기로(잘라내기 옮김 · 엑셀 경고 그대로) · 골라 붙이기는 바로 */
|
||||
export async function menuPaste(ctx: SpreadsheetContext, mode?: PasteSpecialMode): Promise<void> {
|
||||
const clip = await readClipboard();
|
||||
if (!clip) return showToast(mode ? TEXT.pasteSpecialHint : st("MenuPasteHint"), "info");
|
||||
back(ctx);
|
||||
if (!mode) {
|
||||
const data = new DataTransfer();
|
||||
data.setData("text/html", clip.html);
|
||||
data.setData("text/plain", clip.text);
|
||||
ctx.root.dispatchEvent(
|
||||
new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const at = { ...ctx.selection.활성 };
|
||||
const block = parseClipboard(clip.html, clip.text, at);
|
||||
if (!block) return showToast(TEXT.pasteFailed, "error");
|
||||
const source =
|
||||
block.출처 === "aislo" && block.원점 ? { 시트: id(ctx), ...block.원점 } : undefined;
|
||||
(await import("./spreadsheet_paste_special")).pasteSpecial(ctx, block, at, mode, source);
|
||||
}
|
||||
|
||||
export function pasteItems(ctx: SpreadsheetContext): MapContextMenuItem[] {
|
||||
return [
|
||||
[TEXT.paste, () => void menuPaste(ctx)],
|
||||
{
|
||||
label: TEXT.pasteSpecial,
|
||||
children: [
|
||||
[TEXT.pasteValues, () => void menuPaste(ctx, "값")],
|
||||
[TEXT.pasteFormat, () => void menuPaste(ctx, "서식")],
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 행 · 열 넣기 — 위 · 왼쪽은 `at` · 아래 · 오른쪽은 `at + 수`(고른 줄 수만큼) */
|
||||
export function insertLines(
|
||||
ctx: SpreadsheetContext,
|
||||
rows: boolean,
|
||||
at: number,
|
||||
수: number,
|
||||
after: boolean,
|
||||
): void {
|
||||
ctx.dispatch({ 종류: rows ? "행넣기" : "열넣기", 시트: id(ctx), at: after ? at + 수 : at, 수 });
|
||||
}
|
||||
|
||||
function deleteLines(ctx: SpreadsheetContext, rows: boolean, at: number, 수: number): void {
|
||||
ctx.dispatch({ 종류: rows ? "행지우기" : "열지우기", 시트: id(ctx), at, 수 });
|
||||
}
|
||||
|
||||
export function rowInsertItems(ctx: SpreadsheetContext, at: number, 수: number): Item[] {
|
||||
return [
|
||||
[TEXT.rowsAbove(수), () => insertLines(ctx, true, at, 수, false)],
|
||||
[TEXT.rowsBelow(수), () => insertLines(ctx, true, at, 수, true)],
|
||||
];
|
||||
}
|
||||
|
||||
export function colInsertItems(ctx: SpreadsheetContext, at: number, 수: number): Item[] {
|
||||
return [
|
||||
[TEXT.colsLeft(수), () => insertLines(ctx, false, at, 수, false)],
|
||||
[TEXT.colsRight(수), () => insertLines(ctx, false, at, 수, true)],
|
||||
];
|
||||
}
|
||||
|
||||
const shifts = () => import("./spreadsheet_insert_dialog");
|
||||
|
||||
/** 칸 메뉴 — 행 · 열 · 셀 삽입/삭제 ▸ */
|
||||
export function cellInsertItems(ctx: SpreadsheetContext, g: CellRange): MapContextMenuItem[] {
|
||||
const rows = g.r1 - g.r0 + 1;
|
||||
const cols = g.c1 - g.c0 + 1;
|
||||
return [
|
||||
{
|
||||
label: TEXT.insertLines,
|
||||
children: [...rowInsertItems(ctx, g.r0, rows), ...colInsertItems(ctx, g.c0, cols)],
|
||||
},
|
||||
{
|
||||
label: TEXT.insertCells,
|
||||
children: [
|
||||
[TEXT.shiftRight, () => void shifts().then((m) => m.insertShiftRight(ctx, g))],
|
||||
[TEXT.shiftDown, () => void shifts().then((m) => m.insertShiftDown(ctx, g))],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: TEXT.deleteLines,
|
||||
children: [
|
||||
[TEXT.deleteRows(rows), () => deleteLines(ctx, true, g.r0, rows)],
|
||||
[TEXT.deleteCols(cols), () => deleteLines(ctx, false, g.c0, cols)],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: TEXT.deleteCells,
|
||||
children: [
|
||||
[TEXT.shiftLeft, () => void shifts().then((m) => m.deleteShiftLeft(ctx, g))],
|
||||
[TEXT.shiftUp, () => void shifts().then((m) => m.deleteShiftUp(ctx, g))],
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 몸통은 [데이터 ▾] 와 같은 늦은 길(`spreadsheet_actions_run.ts`) */
|
||||
function run(ctx: SpreadsheetContext, act: Act): void {
|
||||
void import("./spreadsheet_actions_run").then((m) => m.run(ctx, () => back(ctx), act));
|
||||
}
|
||||
|
||||
function freezeGroupItems(ctx: SpreadsheetContext): MapContextMenuItem[] {
|
||||
return [
|
||||
{
|
||||
label: TEXT.freeze,
|
||||
children: [
|
||||
[TEXT.freezeRow, () => run(ctx, "freezeRow")],
|
||||
[TEXT.freezeCol, () => run(ctx, "freezeCol")],
|
||||
[TEXT.freezeAt, () => run(ctx, "freezeAt")],
|
||||
[TEXT.unfreeze, () => run(ctx, "unfreeze")],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: TEXT.group,
|
||||
children: [
|
||||
[TEXT.groupOn, () => run(ctx, "group")],
|
||||
[TEXT.groupOff, () => run(ctx, "ungroup")],
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 쓴 칸을 모두 덮는 범위(없으면 null) */
|
||||
export function usedRange(sheet: Sheet): CellRange | null {
|
||||
let g: CellRange | null = null;
|
||||
for (const key of Object.keys(sheet.칸)) {
|
||||
const p = parseA1(key);
|
||||
if (!p) continue;
|
||||
g = g
|
||||
? {
|
||||
r0: Math.min(g.r0, p.r),
|
||||
c0: Math.min(g.c0, p.c),
|
||||
r1: Math.max(g.r1, p.r),
|
||||
c1: Math.max(g.c1, p.c),
|
||||
}
|
||||
: { r0: p.r, c0: p.c, r1: p.r, c1: p.c };
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/** 열 머리에서 — 쓴 범위를 잠깐 골라 `job`(정렬 · 필터) 뒤 고름을 되돌림. 빈 열이면 안 함 */
|
||||
async function onUsed(
|
||||
ctx: SpreadsheetContext,
|
||||
c: number,
|
||||
job: () => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const used = usedRange(ctx.sheet());
|
||||
if (!used || c < used.c0 || c > used.c1) return;
|
||||
const keep = ctx.selection;
|
||||
const top = { r: used.r0, c };
|
||||
ctx.select({ 시트: id(ctx), 범위: [used], 활성: top, 기준: top });
|
||||
try {
|
||||
await job();
|
||||
} finally {
|
||||
ctx.select(keep);
|
||||
}
|
||||
}
|
||||
|
||||
/** 시트 정렬 — 쓴 범위 전체를 이 열 기준으로(열 하나만 섞이지 않게) */
|
||||
export function sortByColumn(ctx: SpreadsheetContext, c: number, desc: boolean): Promise<void> {
|
||||
return onUsed(ctx, c, async () =>
|
||||
(await import("./spreadsheet_sort_dialog")).sortSelection(ctx, desc),
|
||||
);
|
||||
}
|
||||
|
||||
function filterItem(ctx: SpreadsheetContext, c?: number): Item {
|
||||
const on = !!ctx.sheet().필터;
|
||||
const go = (): void => ctx.dataMenu?.("filter");
|
||||
return [
|
||||
on ? TEXT.filterOff : TEXT.filterOn,
|
||||
() => (on || c === undefined ? go() : void onUsed(ctx, c, go)),
|
||||
];
|
||||
}
|
||||
|
||||
/** 숫자 · 글 하나 받는 작은 창 — 확인이 거르면(오류 글) 창이 남음 */
|
||||
function askValue(
|
||||
ctx: SpreadsheetContext,
|
||||
title: string,
|
||||
label: string,
|
||||
value: string,
|
||||
submit: (text: string) => string | null | Promise<string | null>,
|
||||
): void {
|
||||
const overlay = el("div", { className: "ui-confirm" });
|
||||
const close = (): void => {
|
||||
overlay.remove();
|
||||
back(ctx);
|
||||
};
|
||||
const field = createInputField({ label, value });
|
||||
const error = el("p", { text: "" });
|
||||
error.style.cssText = "color:var(--color-danger, red);font-size:0.8rem;min-height:1em;";
|
||||
const ok = async (): Promise<void> => {
|
||||
const fail = await submit(field.input.value.trim());
|
||||
if (fail) error.textContent = fail;
|
||||
else close();
|
||||
};
|
||||
field.input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") void ok();
|
||||
});
|
||||
overlay.append(
|
||||
el("div", {
|
||||
className: "ui-confirm__panel",
|
||||
children: [
|
||||
el("h3", { text: title }),
|
||||
field.root,
|
||||
error,
|
||||
el("div", {
|
||||
className: "ui-confirm__actions",
|
||||
children: [
|
||||
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
|
||||
createButton({ label: TEXT.ok, variant: "filled", onClick: () => void ok() }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
document.body.append(overlay);
|
||||
dialogKeys(overlay, close);
|
||||
field.input.focus();
|
||||
field.input.select();
|
||||
}
|
||||
|
||||
/** 행 높이 · 열 너비 바꾸기(px) — 고른 줄 모두 한 명령(한 번에 되돌림) · 잘못된 값이면 오류 글 */
|
||||
export function resizeLines(
|
||||
ctx: SpreadsheetContext,
|
||||
rows: boolean,
|
||||
lines: number[],
|
||||
text: string,
|
||||
): string | null {
|
||||
const px = Number(text);
|
||||
if (!Number.isFinite(px) || px < 1) return TEXT.badNumber;
|
||||
ctx.dispatch(
|
||||
rows
|
||||
? { 종류: "행높이", 시트: id(ctx), 행: lines, 높이: Math.min(pxToPt(px), 409) }
|
||||
: { 종류: "열폭", 시트: id(ctx), 열: lines, 폭: Math.min(pxToColChars(px), MAX_COL_CHARS) },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function sizeItem(ctx: SpreadsheetContext, rows: boolean, lines: number[]): Item {
|
||||
return [
|
||||
rows ? TEXT.rowSize : TEXT.colSize,
|
||||
() => {
|
||||
const geo = (rows ? buildRowGeometry : buildColGeometry)(ctx.book, ctx.sheet());
|
||||
askValue(
|
||||
ctx,
|
||||
rows ? TEXT.rowSize : TEXT.colSize,
|
||||
rows ? TEXT.rowSizeLabel : TEXT.colSizeLabel,
|
||||
String(Math.round(geo.size(lines[0]))),
|
||||
(text) => resizeLines(ctx, rows, lines, text),
|
||||
);
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 열 너비를 데이터에 맞춤 — 고른 열 모두 한 명령 */
|
||||
export async function autofitColumns(ctx: SpreadsheetContext, cols: number[]): Promise<void> {
|
||||
const { estimateColumnWidth } = await import("./spreadsheet_autofit");
|
||||
const 명령: Command[] = cols.map((c) => ({
|
||||
종류: "열폭",
|
||||
시트: id(ctx),
|
||||
열: [c],
|
||||
폭: estimateColumnWidth(ctx.book, id(ctx), c),
|
||||
}));
|
||||
ctx.dispatch({ 종류: "묶음", 명령 });
|
||||
}
|
||||
|
||||
/** 행 머리 끝 — 크기 · 고정 ▸ · 그룹 ▸ */
|
||||
export function rowMoreItems(ctx: SpreadsheetContext, rows: number[]): MapContextMenuItem[] {
|
||||
return [SEP, sizeItem(ctx, true, rows), SEP, ...freezeGroupItems(ctx)];
|
||||
}
|
||||
|
||||
/** 열 머리 끝 — 크기 · 데이터에 맞추기 · 시트 정렬 · 필터 · 고정 ▸ · 그룹 ▸ */
|
||||
export function colMoreItems(ctx: SpreadsheetContext, cols: number[]): MapContextMenuItem[] {
|
||||
const c = cols[0];
|
||||
return [
|
||||
SEP,
|
||||
sizeItem(ctx, false, cols),
|
||||
[TEXT.autofit, () => void autofitColumns(ctx, cols)],
|
||||
SEP,
|
||||
[TEXT.sortAsc, () => void sortByColumn(ctx, c, false)],
|
||||
[TEXT.sortDesc, () => void sortByColumn(ctx, c, true)],
|
||||
filterItem(ctx, c),
|
||||
SEP,
|
||||
...freezeGroupItems(ctx),
|
||||
];
|
||||
}
|
||||
|
||||
/** 칸 메뉴 끝 — 고정 ▸ · 그룹 ▸ · 셀 작업 더보기 ▸ */
|
||||
export function cellMoreItems(ctx: SpreadsheetContext, g: CellRange): MapContextMenuItem[] {
|
||||
return [
|
||||
SEP,
|
||||
...freezeGroupItems(ctx),
|
||||
{
|
||||
label: TEXT.more,
|
||||
children: [
|
||||
[TEXT.dropdown, () => ctx.dataMenu?.("validation")],
|
||||
[TEXT.condfmt, () => ctx.dataMenu?.("condfmt")],
|
||||
[
|
||||
TEXT.nameRange,
|
||||
() =>
|
||||
askValue(ctx, TEXT.nameRange, TEXT.nameLabel, "", (name) => nameRange(ctx, name, g)),
|
||||
],
|
||||
filterItem(ctx),
|
||||
],
|
||||
},
|
||||
SEP,
|
||||
];
|
||||
}
|
||||
|
||||
/** 범위 이름 — `spreadsheet_names.defineName`(오류 글 · 성공 null) */
|
||||
export async function nameRange(
|
||||
ctx: SpreadsheetContext,
|
||||
name: string,
|
||||
g: CellRange,
|
||||
): Promise<string | null> {
|
||||
if (!name) return TEXT.nameEmpty;
|
||||
return (await import("./spreadsheet_names")).defineName(ctx, name, g);
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import { isFrac, fracToDouble } from "./spreadsheet_functions_math";
|
||||
import { computeExtent } from "./spreadsheet_grid_cells";
|
||||
import type { CellRange, Scalar } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
@@ -31,13 +32,16 @@ export interface SelectionStats {
|
||||
/** 고른 범위(여럿) 안 칸 값을 모아 통계를 냄 — 엔진 값 기준(식 칸도 풀린 값). */
|
||||
export function computeSelectionStats(ctx: SpreadsheetContext): SelectionStats {
|
||||
const sheet = ctx.selection.시트;
|
||||
const extent = computeExtent(ctx.sheet()); // 쓴 범위까지만 셈(81-2 — 열 머리 전체 고름이면 104만 칸을 다 돎)
|
||||
const nums: number[] = [];
|
||||
let 개수 = 0;
|
||||
const seen = new Set<number>();
|
||||
|
||||
const visit = (range: CellRange): void => {
|
||||
for (let r = range.r0; r <= range.r1; r++) {
|
||||
for (let c = range.c0; c <= range.c1; c++) {
|
||||
const r1 = Math.min(range.r1, extent.rows - 1);
|
||||
const c1 = Math.min(range.c1, extent.cols - 1);
|
||||
for (let r = range.r0; r <= r1; r++) {
|
||||
for (let c = range.c0; c <= c1; c++) {
|
||||
const key = r * 1_000_000 + c;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
@@ -112,11 +112,14 @@ export interface SpreadsheetContext {
|
||||
/** 칸 메뉴 끝에 붙일 줄 — 칸 메뉴(`spreadsheet_menu.ts`)가 빈 것으로 세움(= 칸 메뉴 있음) ·
|
||||
* 조건 만들기(`spreadsheet_condition.ts`)가 채움 · 없으면 칸 메뉴 없는 편집기 */
|
||||
cellMenuExtra?: (r: number, c: number) => Array<[label: string, action: () => void]>;
|
||||
/** [데이터 ▾] 와 같은 창 · 필터를 우클릭 메뉴에서 — 데이터 메뉴(`spreadsheet_data_menu.ts`)가 채움 */
|
||||
dataMenu?: (kind: "filter" | "condfmt" | "validation" | "names") => void;
|
||||
}
|
||||
|
||||
/** E 부품 — D 가 `mountToolbar(ctx)` 등으로 붙이고 고름 · 문서가 바뀌면 `refresh()`. */
|
||||
export interface PartHandle {
|
||||
root: HTMLElement | null;
|
||||
refresh(): void;
|
||||
/** `dataChanged` — 문서가 실제로 바뀐 다시 그림(참) vs 고름만 바뀐 다시 그림(거짓 · 81-2 알약 점 건너뜀용) */
|
||||
refresh(dataChanged?: boolean): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -40,13 +40,16 @@ import {
|
||||
INLET_REVET_KEYS,
|
||||
type InletStructureKind,
|
||||
labeled,
|
||||
numberInput,
|
||||
OUTLET_REVET_KEYS,
|
||||
stepper,
|
||||
WING_IN_KEYS,
|
||||
WING_OUT_KEYS,
|
||||
} from "./B05_Profile_UI_Drainage_Facility_Fields";
|
||||
import { guardChoiceFields, revetChoiceRow } from "./B05_Profile_UI_Drainage_Revet_Choice";
|
||||
import {
|
||||
guardChoiceFields,
|
||||
guardSoilCount,
|
||||
outletTiersRow,
|
||||
revetChoiceRow,
|
||||
} from "./B05_Profile_UI_Drainage_Revet_Choice";
|
||||
import { optionDefaults } from "./B05_Profile_UI_Structures_Fields";
|
||||
import {
|
||||
createSpecForm,
|
||||
@@ -67,7 +70,6 @@ import {
|
||||
pipeDrawn,
|
||||
pipeExtraCount,
|
||||
pipeLengthM,
|
||||
setPipeExtraCount,
|
||||
} from "../A00_Common/design/design_structure_pipe_calc";
|
||||
import {
|
||||
hasLinks,
|
||||
@@ -296,25 +298,11 @@ export function createFacilityOptionsForm(
|
||||
return drawnMemo;
|
||||
};
|
||||
// 유출 「추가 흙막이(단)」(54-27) — 값 = 횡단 조정 단 수(같은 저장 자리) · 흙막이 상자 수가 따라감
|
||||
const tiersInput = numberInput("1", "0");
|
||||
tiersInput.max = "9";
|
||||
const tiersRow = labeled("추가 흙막이(단)", stepper(tiersInput, 1, 0));
|
||||
tiersRow.dataset.specItem = "outlet.tiers";
|
||||
tiersRow
|
||||
.querySelectorAll("button")
|
||||
.forEach((b, i) => (b.title = `1단 ${i ? "늘리기" : "줄이기"}`));
|
||||
const paintTiers = (values: Record<string, string>): HTMLElement => {
|
||||
const count = pipeExtraCount(chainage);
|
||||
tiersInput.value = count === null ? "" : String(count);
|
||||
tiersInput.disabled = count === null;
|
||||
tiersRow.hidden = !!values.structure && values.structure !== "기슭막이";
|
||||
return tiersRow;
|
||||
};
|
||||
tiersInput.addEventListener("change", () => {
|
||||
const count = Math.max(0, Math.min(9, Math.round(Number(tiersInput.value) || 0)));
|
||||
// 지형이 못 세운 단은 횡단 기하가 자름 — 끝난 뒤 세운 만큼으로 다시 그림
|
||||
void setPipeExtraCount(chainage, count).then(() => pipeForm.recount());
|
||||
});
|
||||
const tiersRow = outletTiersRow(
|
||||
() => chainage,
|
||||
() => pipeForm.recount(),
|
||||
(structure) => !structure || structure === "기슭막이",
|
||||
);
|
||||
const inletChoice = revetChoiceRow(
|
||||
() => chainage,
|
||||
() => pipeForm.refresh(),
|
||||
@@ -327,7 +315,7 @@ export function createFacilityOptionsForm(
|
||||
// 관 보호공 [연동] · [경사](54-30) — 유입 · 유출 상자 각각 한 줄(같은 값) · 길이 있는 흙막이만(54-40 · 집수정 · 안 고름은 숨김)
|
||||
fields: (c, values) =>
|
||||
c.id === "outlet"
|
||||
? [paintTiers(values), outletChoice.paint(values.structure !== "기슭막이")]
|
||||
? [tiersRow.paint(values), outletChoice.paint(values.structure !== "기슭막이")]
|
||||
: c.id === "inlet"
|
||||
? [inletChoice.paint(values.structure !== "기슭막이")]
|
||||
: c.id === "soil_add" // 추가 흙막이 상자도 같은 줄(54-36 · 상자마다 한 줄 · 같은 값)
|
||||
@@ -407,11 +395,17 @@ export function createFacilityOptionsForm(
|
||||
};
|
||||
};
|
||||
const fordFlowBox = overflow(() => fordForm, "ford");
|
||||
// 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1)
|
||||
const fordForm: SpecForm = createSpecForm(specLinks, {
|
||||
onChange: fordFlowBox.onChange,
|
||||
fields: guardChoiceFields(
|
||||
() => chainage,
|
||||
() => fordForm.refresh(),
|
||||
() => fordForm.recount(),
|
||||
),
|
||||
count: guardSoilCount(
|
||||
() => chainage,
|
||||
() => fordForm.state().outlet?.[0]?.structure,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -473,6 +467,11 @@ export function createFacilityOptionsForm(
|
||||
fields: guardChoiceFields(
|
||||
() => chainage,
|
||||
() => boxForm.refresh(),
|
||||
() => boxForm.recount(),
|
||||
),
|
||||
count: guardSoilCount(
|
||||
() => chainage,
|
||||
() => boxForm.state().outlet?.[0]?.structure,
|
||||
),
|
||||
});
|
||||
// 날개벽 칸 — 세월교 · BOX 가 같은 조각을 쓰던 자리 · 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음
|
||||
|
||||
@@ -43,7 +43,7 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
...revetKeys("inlet"),
|
||||
...revetKeys("outlet"),
|
||||
"inlet_revet_berm_m", // 흙막이 절토부 앞 수평공간(79-7)
|
||||
"outlet_revet_berm_m",
|
||||
"outlet_revet_berm_m", // 칸 없앰(89-4) — 목록에 남겨 다음 [저장] 때 옛 값을 지움
|
||||
],
|
||||
// 물넘이 포장 = 표 03 명세 키(PLAN 52-3 · `design_structure_ford_pavement`) + 월류 폭 · 높이(65-5) — 옛 경사 ·
|
||||
// 두께 · 길이는 폼에서 빠져 이어 붙임 · 도수로/돌붙임 키는 `FORM_OPTION_PREFIXES`
|
||||
@@ -61,7 +61,7 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
...revetKeys("inlet"),
|
||||
...revetKeys("outlet"),
|
||||
"inlet_revet_berm_m",
|
||||
"outlet_revet_berm_m",
|
||||
"outlet_revet_berm_m", // 칸 없앰(89-4) — 목록에 남겨 다음 [저장] 때 옛 값을 지움
|
||||
],
|
||||
revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")],
|
||||
};
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { toggleButton } from "./B05_Profile_UI_Panel_Fields";
|
||||
import { labeled, numberInput, stepper } from "./B05_Profile_UI_Drainage_Facility_Fields";
|
||||
import {
|
||||
pipeExtraCount,
|
||||
pipeRevetChoice,
|
||||
setPipeExtraCount,
|
||||
setPipeRevetChoice,
|
||||
} from "../A00_Common/design/design_structure_pipe_calc";
|
||||
|
||||
@@ -54,20 +57,67 @@ export function revetChoiceRow(
|
||||
return { paint };
|
||||
}
|
||||
|
||||
/** 세월교 · 박스 흙막이 상자 줄(65-2 · 배관과 같은 줄) — 흙막이를 고른 유입 · 유출 · 추가 흙막이 상자마다 한 줄.
|
||||
* 명세 폼 `fields` 자리에 그대로 씀. */
|
||||
/** 유출 「추가 흙막이(단)」(54-27 · 90-1 배관 · 세월교 · 박스 공용) — 값 = 횡단 조정 단 수(같은 저장 자리) ·
|
||||
* 흙막이 상자 수가 따라감(폼 `count` 훅 = `pipeExtraCount`). `shown` = 유출 구조 값이 벽인지. */
|
||||
export function outletTiersRow(
|
||||
chainageOf: () => number | null,
|
||||
/** 쓴 뒤 — 지형이 못 세운 단은 횡단 기하가 자름 · 세운 만큼으로 상자를 다시 셈. */
|
||||
recount: () => void,
|
||||
shown: (structure: string | undefined) => boolean,
|
||||
): { paint: (values: Record<string, string>) => HTMLElement } {
|
||||
const input = numberInput("1", "0");
|
||||
input.max = "9";
|
||||
const row = labeled("추가 흙막이(단)", stepper(input, 1, 0));
|
||||
row.dataset.specItem = "outlet.tiers";
|
||||
row.querySelectorAll("button").forEach((b, i) => (b.title = `1단 ${i ? "늘리기" : "줄이기"}`));
|
||||
input.addEventListener("change", () => {
|
||||
const count = Math.max(0, Math.min(9, Math.round(Number(input.value) || 0)));
|
||||
void setPipeExtraCount(chainageOf(), count).then(recount);
|
||||
});
|
||||
return {
|
||||
paint: (values) => {
|
||||
const count = pipeExtraCount(chainageOf());
|
||||
input.value = count === null ? "" : String(count);
|
||||
input.disabled = count === null;
|
||||
row.hidden = !shown(values.structure);
|
||||
return row;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 세월교 · 박스 흙막이 상자 줄(65-2 · 배관과 같은 줄) — 흙막이를 고른 유입 · 유출 · 추가 흙막이 상자마다 한 줄 ·
|
||||
* 유출 상자엔 「추가 흙막이(단)」(90-1). 명세 폼 `fields` 자리에 그대로 씀. */
|
||||
export function guardChoiceFields(
|
||||
chainageOf: () => number | null,
|
||||
written: () => void,
|
||||
recount: () => void,
|
||||
): (c: { id: string }, values: Record<string, string>) => HTMLElement[] {
|
||||
const sides = {
|
||||
inlet: revetChoiceRow(chainageOf, written),
|
||||
outlet: revetChoiceRow(chainageOf, written),
|
||||
};
|
||||
const tiers = outletTiersRow(chainageOf, recount, (structure) => structure === "흙막이");
|
||||
return (c, values) =>
|
||||
c.id === "soil_add"
|
||||
? [revetChoiceRow(chainageOf, written).paint(false)]
|
||||
: c.id === "inlet" || c.id === "outlet"
|
||||
? [sides[c.id].paint(values.structure !== "흙막이")]
|
||||
? [
|
||||
...(c.id === "outlet" ? [tiers.paint(values)] : []),
|
||||
sides[c.id].paint(values.structure !== "흙막이"),
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
/** 세월교 · 박스 흙막이 상자 수(90-1) — 유출이 흙막이면 지형이 세운 유출 단 수 · 아니면 0(옛 상자는
|
||||
* 숨기고 값은 파일에 남김). 명세 폼 `count` 훅 자리. */
|
||||
export function guardSoilCount(
|
||||
chainageOf: () => number | null,
|
||||
outletStructure: () => string | undefined,
|
||||
): (c: { id: string }) => number | null | undefined {
|
||||
return (c) =>
|
||||
c.id !== "soil_add"
|
||||
? undefined
|
||||
: outletStructure() === "흙막이"
|
||||
? pipeExtraCount(chainageOf())
|
||||
: 0;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type FacilityDefinition,
|
||||
} from "./B05_Profile_UI_Structures_Define_Form";
|
||||
import { createStructureSpecForm } from "./B05_Profile_UI_Structures_Spec";
|
||||
import { createWideningBox } from "./B05_Profile_UI_Structures_Widening";
|
||||
|
||||
export interface StructuresFormElements {
|
||||
root: HTMLElement;
|
||||
@@ -156,11 +157,38 @@ export function buildStructuresForm(options: {
|
||||
set: groupBoxes.setOne,
|
||||
});
|
||||
groupBoxes.watch(facilityOptions.paintLinks);
|
||||
// 노면공 시작~종료 읽기(87-1 길이 출력 · 87-3 확폭 구간 상자가 같이 씀) — 못 읽으면 null
|
||||
const rangeNow = (): { start: number | null; end: number | null } => {
|
||||
const step = options.getInterval();
|
||||
return { start: startFields.read(step, false), end: endFields.read(step, false) };
|
||||
};
|
||||
const widening = createWideningBox({
|
||||
range: rangeNow,
|
||||
interval: options.getInterval,
|
||||
onChange: options.onFacilityChange,
|
||||
});
|
||||
// 일반 구조물 명세 컨테이너(PLAN 52-10 · 노면공 등) — 잇기 · 반영 길은 배관 명세와 같음
|
||||
const structureSpec = createStructureSpecForm(
|
||||
optionRow,
|
||||
{ get: groupBoxes.get, pick: groupBoxes.pick, clear: groupBoxes.clear, set: groupBoxes.setOne },
|
||||
options.onFacilityChange,
|
||||
// 노면공 길이 칸(87-1) = 시작~종료 출력 — 못 셈이면 빈 글(「—」)
|
||||
(_c, item) => {
|
||||
const { start, end } = rangeNow();
|
||||
return item.id === "length" && start !== null && end !== null && end > start
|
||||
? String(Number((end - start).toFixed(2)))
|
||||
: "";
|
||||
},
|
||||
widening,
|
||||
);
|
||||
// 시작 · 종료를 고치면 길이 출력 · 확폭 구간을 다시 그림
|
||||
[startFields, endFields].forEach((fields) =>
|
||||
[fields.station, fields.remainder].forEach((input) =>
|
||||
input.addEventListener("change", () => {
|
||||
structureSpec.refresh();
|
||||
widening.refresh();
|
||||
}),
|
||||
),
|
||||
);
|
||||
groupBoxes.watch(structureSpec.paintLinks);
|
||||
const staleBox = document.createElement("fieldset");
|
||||
@@ -174,6 +202,7 @@ export function buildStructuresForm(options: {
|
||||
positionDivider,
|
||||
optionRow,
|
||||
structureSpec.root,
|
||||
widening.root, // 노면공 「확폭 구간」 — 상세 내용 아래(87-3)
|
||||
facilityOptions.root,
|
||||
staleBox,
|
||||
groupBoxes.root,
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function splitPavementRange(
|
||||
await showConfirmDialog(
|
||||
`지정한 포장 구간(${format(start)}~${format(end)})이 물넘이포장` +
|
||||
`(${format(partial.start)}~${format(partial.end)})의 끝에 걸칩니다.\n` +
|
||||
"물넘이는 그 자체가 포장이라 겹칠 수 없습니다 — 기준 측점·길이·전후 값을 고쳐 주세요.",
|
||||
"물넘이는 그 자체가 포장이라 겹칠 수 없습니다 — 시작·종료 측점을 고쳐 주세요.",
|
||||
"확인",
|
||||
);
|
||||
return null;
|
||||
|
||||
@@ -12,13 +12,17 @@ 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";
|
||||
import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec";
|
||||
import { optionDefaults } from "./B05_Profile_UI_Structures_Fields";
|
||||
import type { WideningBox } from "./B05_Profile_UI_Structures_Widening";
|
||||
|
||||
const forms = new WeakMap<HTMLElement, SpecForm>();
|
||||
/** 범위형 명세(노면공 · 87-3)의 「확폭 구간」 상자 — 옵션 칸 짝. */
|
||||
const widenings = new WeakMap<HTMLElement, WideningBox>();
|
||||
|
||||
/** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌. */
|
||||
export const structureSpecOf = (type: StructureType | null): StructureSpec | null =>
|
||||
@@ -29,14 +33,20 @@ export function createStructureSpecForm(
|
||||
optionRow: HTMLElement,
|
||||
links: SpecLinks,
|
||||
onChange: () => void,
|
||||
auto?: SpecFormHooks["auto"],
|
||||
widening?: WideningBox,
|
||||
): SpecForm {
|
||||
const form = createSpecForm(links, { onChange });
|
||||
const form = createSpecForm(links, { onChange, auto });
|
||||
form.root.hidden = true;
|
||||
forms.set(optionRow, form);
|
||||
if (widening) widenings.set(optionRow, widening);
|
||||
const lock = (): void => {
|
||||
const locked = optionRow.classList.contains("is-locked");
|
||||
form.root.inert = locked;
|
||||
form.root.classList.toggle("is-locked", locked);
|
||||
for (const root of [form.root, widening?.root]) {
|
||||
if (!root) continue;
|
||||
root.inert = locked;
|
||||
root.classList.toggle("is-locked", locked);
|
||||
}
|
||||
};
|
||||
if (typeof MutationObserver !== "undefined")
|
||||
new MutationObserver(lock).observe(optionRow, {
|
||||
@@ -49,7 +59,16 @@ 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 ?? []),
|
||||
...(spec.byRange ? ["widening_off"] : []),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
/** 종류 · 값으로 명세 폼을 다시 그림(명세 없는 종류면 가림). */
|
||||
@@ -60,6 +79,7 @@ export function paintStructureSpec(
|
||||
): void {
|
||||
const form = forms.get(optionRow);
|
||||
const spec = structureSpecOf(type);
|
||||
widenings.get(optionRow)?.paint(!!spec?.byRange, values.widening_off);
|
||||
if (!form) return;
|
||||
form.root.hidden = !spec;
|
||||
if (!spec) return;
|
||||
@@ -73,7 +93,9 @@ export function readStructureSpec(
|
||||
type: StructureType | null,
|
||||
): Record<string, string | number> {
|
||||
const form = forms.get(optionRow);
|
||||
return form && structureSpecOf(type) ? form.read() : {};
|
||||
const spec = structureSpecOf(type);
|
||||
if (!form || !spec) return {};
|
||||
return { ...form.read(), ...(spec.byRange ? widenings.get(optionRow)?.read() : {}) };
|
||||
}
|
||||
|
||||
/** [수정] 병합에서 폼이 다시 쓰는 키 — 명세 키 + 옛 값 중 선택 컨테이너 머리 키(빼면 지워져야 함). */
|
||||
@@ -85,5 +107,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 ?? []), ...(spec.byRange ? ["widening_off"] : [])];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Structures_Widening.ts
|
||||
* 노면공 「확폭 구간」 상자(PLAN 87-3) — 상세 내용 아래. 범위 안 확폭 > 0 측점을 이어진 구간으로 묶어
|
||||
* 구간마다 단추(켬 = 확폭 포함 · 끔 = 규격 폭만 · 기본 켬). 끈 구간 = `options.widening_off`.
|
||||
* 규칙(구간 묶기 · 글 읽고 쓰기)은 `design_structure_widening` · 횡단 측점 읽기는 `pipeSections`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pipeSections } from "../A00_Common/design/design_structure_pipe_calc";
|
||||
import {
|
||||
isRunOff,
|
||||
offForRuns,
|
||||
toggleRun,
|
||||
wideningRuns,
|
||||
type WideningRun,
|
||||
} from "../A00_Common/design/design_structure_widening";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import { group } from "./B05_Profile_UI_Drainage_Facility_Fields";
|
||||
|
||||
export interface WideningBox {
|
||||
root: HTMLElement;
|
||||
/** 종류 · 저장 값으로 다시 그림(범위형이 아니면 가림). */
|
||||
paint: (visible: boolean, stored: string | number | undefined) => void;
|
||||
/** 저장할 값 — 끈 구간이 없으면 빈 묶음. 횡단 자료가 없으면 저장 값을 그대로 지킴. */
|
||||
read: () => Record<string, string>;
|
||||
/** 범위 · 횡단 자료가 바뀌었을 때 다시 그림. */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function createWideningBox(hooks: {
|
||||
range: () => { start: number | null; end: number | null };
|
||||
interval: () => number;
|
||||
onChange: () => void;
|
||||
}): WideningBox {
|
||||
const box = group("확폭 구간");
|
||||
box.root.classList.add("b05-structure__widening");
|
||||
box.root.hidden = true;
|
||||
let off = "";
|
||||
|
||||
/** 지금 범위의 구간들 — 횡단 자료 · 범위가 없으면 null(+ 안내 글). */
|
||||
const runs = (): { list: WideningRun[] | null; note: string } => {
|
||||
const sections = pipeSections();
|
||||
if (!sections?.length) return { list: null, note: "횡단 자료 없음" };
|
||||
const { start, end } = hooks.range();
|
||||
if (start === null || end === null || end <= start)
|
||||
return { list: null, note: "시작 · 종료 측점을 넣으면 보임" };
|
||||
const list = wideningRuns(sections, start, end);
|
||||
return { list, note: list.length ? "" : "범위 안 확폭 없음" };
|
||||
};
|
||||
|
||||
const paintBody = (): void => {
|
||||
const { list, note } = runs();
|
||||
if (!list?.length) {
|
||||
const text = document.createElement("span");
|
||||
text.className = "b05-structure__widening-note";
|
||||
text.textContent = note;
|
||||
box.body.replaceChildren(text);
|
||||
return;
|
||||
}
|
||||
const step = hooks.interval();
|
||||
box.body.replaceChildren(
|
||||
...list.map((run) => {
|
||||
const on = !isRunOff(run, off);
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `b05-route__irregular-btn${on ? " is-primary" : ""}`;
|
||||
button.setAttribute("aria-pressed", String(on));
|
||||
button.dataset.wideningRun = `${run.start_m}-${run.end_m}`;
|
||||
button.textContent = `${stationLabel(run.start_m, step)} ~ ${stationLabel(run.end_m, step)} · 확폭 ${Number(run.widening_m.toFixed(2))} m`;
|
||||
button.title = on ? "확폭 포함 — 누르면 규격 폭만" : "규격 폭만 — 누르면 확폭 포함";
|
||||
button.addEventListener("click", () => {
|
||||
off = toggleRun(list, off, run);
|
||||
paintBody();
|
||||
hooks.onChange();
|
||||
});
|
||||
return button;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// 횡단 자료가 늦게 오면 상자 위에 올 때 다시 그림
|
||||
box.root.addEventListener("pointerenter", () => {
|
||||
if (!box.root.hidden) paintBody();
|
||||
});
|
||||
|
||||
return {
|
||||
root: box.root,
|
||||
paint(visible, stored) {
|
||||
box.root.hidden = !visible;
|
||||
off = typeof stored === "string" ? stored : "";
|
||||
if (visible) paintBody();
|
||||
},
|
||||
read() {
|
||||
const { list } = runs();
|
||||
const text = list ? offForRuns(list, off) : off;
|
||||
const out: Record<string, string> = text ? { widening_off: text } : {};
|
||||
return out;
|
||||
},
|
||||
refresh() {
|
||||
if (!box.root.hidden) paintBody();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -570,3 +570,25 @@
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 노면공 「확폭 구간」 상자(PLAN 87-3) — 구간마다 단추 한 줄 */
|
||||
.b05-structure__widening[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-structure__widening .b05-structure__group-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.b05-structure__widening .b05-structure__group-body button {
|
||||
align-self: stretch;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.b05-structure__widening-note {
|
||||
color: var(--text-muted, inherit);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -219,10 +220,12 @@ export interface CulvertSideSpec {
|
||||
face_side?: string | null;
|
||||
face_role?: string | null;
|
||||
face_slope_ratio?: number | string | null;
|
||||
/** 보호공(물받이) 길이 = 낙차고 × 2 (사방교본 교차 참조, 2026-08-19 사용자 확정). */
|
||||
apron_length_m?: number;
|
||||
/** 보호공 두께 1.0m 내외 (사방교본 교차 참조). */
|
||||
apron_thickness_m?: number;
|
||||
}
|
||||
|
||||
/** 도수로 · 돌붙임(90-2) — 유출 성토사면을 따라 사면 안쪽으로 두께만큼 띠 · 안에 돌(그림만 · 면적 무관). */
|
||||
export interface ChuteSpec {
|
||||
structure: string;
|
||||
thickness_m: number;
|
||||
}
|
||||
|
||||
/** 배수관 측점의 세트(배관·기슭막이·보호공) 제원 — 횡단 카드 오버레이 입력. */
|
||||
@@ -248,6 +251,7 @@ export interface CulvertSet {
|
||||
foundation?: string | null;
|
||||
/** 세월교 · 박스 흙막이(73-4) — 자리 기준 본체. 있으면 벽 자동 자리 = 관(세월교) · 본체(박스) 끝. */
|
||||
body?: "box" | "ford";
|
||||
chute?: ChuteSpec;
|
||||
}
|
||||
|
||||
/** 세월교 날개벽 한쪽 — 횡단면엔 안 보이고 바닥판 연장량만 넘긴다. */
|
||||
@@ -279,6 +283,7 @@ export interface FordSet {
|
||||
wing_out: FordWingSpec;
|
||||
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
|
||||
defaulted?: string[];
|
||||
chute?: ChuteSpec;
|
||||
}
|
||||
|
||||
/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */
|
||||
@@ -299,6 +304,7 @@ export interface BoxSet {
|
||||
wing_out: FordWingSpec;
|
||||
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
|
||||
defaulted?: string[];
|
||||
chute?: ChuteSpec;
|
||||
}
|
||||
|
||||
export interface CrossSection extends SectionStation {
|
||||
@@ -315,6 +321,8 @@ export interface CrossSection extends SectionStation {
|
||||
ford_pavement?: FordPavementSpec;
|
||||
/** 독립 기슭막이 제원(있을 때만). 구조물 정본 D군 구간 안의 측점 전부에 붙는다. */
|
||||
revetment?: RevetmentSpec;
|
||||
/** 노면공(G군) 포장 — 범위 안 측점마다(88). 두께 · 무늬 · 확폭 켬. */
|
||||
pavement?: PavementEntry[];
|
||||
}
|
||||
|
||||
export interface SectionDetailResponse {
|
||||
|
||||
@@ -24,6 +24,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -63,12 +64,8 @@ _SPAN_LINKED_TYPES: frozenset[str] = frozenset({"ford_pavement"})
|
||||
# ⚠ 교차 참조 ① 관 위 최소 토피(m). 임도 배수관 토피 규정이 지식DB에 없어 별표2
|
||||
# 교량·암거 "복토 시 흙 두께 50㎝ 이상"을 끌어왔다. B05 계획고 하향 차단 기준이다.
|
||||
MIN_PIPE_COVER_M = 0.5
|
||||
# ⚠ 교차 참조 ② 보호공(물받이) 길이 = 낙차고(기슭막이 높이) × 배수.
|
||||
# 사방기술교본 바닥막이 물받이 "길이 = 낙차고의 2~3배" 중 2배 채택.
|
||||
APRON_LENGTH_FACTOR = 2.0
|
||||
# 보호공 두께(m) — 사용자 확정 0.45 (2026-08-20, 실무 돌붙임 L3=45와 정합).
|
||||
# 사방교본 물받이 "두께 1m 내외"는 바닥막이용 교차 참조라 채택하지 않았다.
|
||||
APRON_THICKNESS_M = 0.45
|
||||
# 도수로 · 돌붙임 두께 빈 값(cm) — 사용자 확정 30 cm(10-8 · 90-2).
|
||||
CHUTE_THICKNESS_CM = 30.0
|
||||
|
||||
# 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3) = 사방교본 계획비탈
|
||||
# 1:0.3~0.5의 급한 쪽. 지식DB 근거가 있는 값이라 교차 참조 표시가 붙지 않는다.
|
||||
@@ -226,15 +223,36 @@ def _side_spec(
|
||||
"face_slope": REVET_FACE_SLOPE,
|
||||
}
|
||||
)
|
||||
# 보호공은 기슭막이 바닥의 세굴 방지 구조 — 낙차고(기슭막이 높이)에 종속한다.
|
||||
if height is not None and height > 0:
|
||||
spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3)
|
||||
spec["apron_thickness_m"] = APRON_THICKNESS_M
|
||||
return spec
|
||||
|
||||
|
||||
def _chute(values: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""도수로 · 돌붙임(90-2) — 첫 상자(`chute_add_{n}_*`) 구조 · 두께(cm → m · 빈 값 30 cm) ·
|
||||
구조를 안 고르면 None. 횡단이 유출 성토사면을 따라 띠로 그림. TS 짝 `chute`."""
|
||||
head = re.compile(r"^chute_add_(\d+)_structure$")
|
||||
picked = sorted(
|
||||
(int(hit.group(1)), str(value).strip())
|
||||
for key, value in values.items()
|
||||
if (hit := head.match(key)) and str(value or "").strip()
|
||||
)
|
||||
if not picked:
|
||||
return None
|
||||
n, structure = picked[0]
|
||||
cm = _number(values.get(f"chute_add_{n}_thickness"), None)
|
||||
cm = cm if cm and cm > 0 else CHUTE_THICKNESS_CM
|
||||
return {"structure": structure, "thickness_m": round(cm / 100.0, 3)}
|
||||
|
||||
|
||||
def _with_chute(spec: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""도수로 · 돌붙임이 있으면 세트에 `chute` 로 실음(90-2)."""
|
||||
chute = _chute(values)
|
||||
if chute:
|
||||
spec["chute"] = chute
|
||||
return spec
|
||||
|
||||
|
||||
def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 보호공)."""
|
||||
"""관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 도수로 · 돌붙임)."""
|
||||
defaults = _registry_pipe_defaults()
|
||||
values = dict(options or {})
|
||||
diameter_mm = _number(
|
||||
@@ -242,7 +260,7 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
)
|
||||
kind = values.get("pipe_kind")
|
||||
diameter_m = round((diameter_mm or 1000.0) / 1000.0, 3)
|
||||
return {
|
||||
spec = {
|
||||
"type": "pipe",
|
||||
"pipe_kind": str(kind) if kind else None,
|
||||
"diameter_m": diameter_m,
|
||||
@@ -253,6 +271,7 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"inlet": _side_spec(values, defaults, "inlet", diameter_m),
|
||||
"outlet": _side_spec(values, defaults, "outlet", diameter_m),
|
||||
}
|
||||
return _with_chute(spec, values)
|
||||
|
||||
|
||||
def _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]:
|
||||
@@ -286,9 +305,6 @@ def _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]:
|
||||
"face_role": values.get("face_role"),
|
||||
"face_slope_ratio": values.get("face_slope_ratio"),
|
||||
}
|
||||
if height is not None and height > 0:
|
||||
spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3)
|
||||
spec["apron_thickness_m"] = APRON_THICKNESS_M
|
||||
return spec
|
||||
|
||||
|
||||
@@ -329,12 +345,12 @@ def _guard_set(values: dict[str, Any], body: str, diameter_m: float) -> dict[str
|
||||
for role, key in zip(("inlet", "outlet"), _GUARD_KEYS[body]):
|
||||
guarded = str(values.get(key) or "").strip() == "흙막이"
|
||||
sides[role] = _revet_side(values, role) if guarded else {"role": role, "structure": ""}
|
||||
if guarded:
|
||||
# 절토부 앞 수평공간(79-7) — 흙막이 컨테이너 칸 · 빈 값 = 화면 기본값
|
||||
if guarded and role == "inlet":
|
||||
# 절토부 앞 수평공간(79-7) — 유입만(유출은 없음 · 89-4) · 빈 값 = 화면 기본값
|
||||
sides[role]["revet_berm_m"] = _number(values.get(f"{role}_revet_berm_m"), None)
|
||||
if not any(side["structure"] for side in sides.values()):
|
||||
return None
|
||||
return {
|
||||
guard = {
|
||||
"type": "pipe",
|
||||
"hidden_pipe": True,
|
||||
"body": body,
|
||||
@@ -346,6 +362,7 @@ def _guard_set(values: dict[str, Any], body: str, diameter_m: float) -> dict[str
|
||||
"min_cover_m": 0.0,
|
||||
**sides,
|
||||
}
|
||||
return _with_chute(guard, values) # 흙막이 쪽 유출 띠는 흙막이 성토선을 따름(90-2)
|
||||
|
||||
|
||||
def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]:
|
||||
@@ -459,7 +476,7 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
guard = _guard_set(values, "ford", spec["diameter_m"])
|
||||
if guard:
|
||||
spec["guard"] = guard
|
||||
return spec
|
||||
return _with_chute(spec, values)
|
||||
|
||||
|
||||
def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
@@ -536,7 +553,7 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
guard = _guard_set(values, "box", box_body_height(spec))
|
||||
if guard:
|
||||
spec["guard"] = guard
|
||||
return spec
|
||||
return _with_chute(spec, values)
|
||||
|
||||
|
||||
def box_body_height(box: dict[str, Any]) -> float:
|
||||
|
||||
@@ -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군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라
|
||||
|
||||
@@ -176,10 +176,11 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void {
|
||||
const group = target.closest<HTMLElement>("fieldset");
|
||||
if (!group) return;
|
||||
const ownerSlot = slotOf(panelRoot.dataset.extraSide === "inlet" ? "inlet" : "outlet");
|
||||
// 배관 유출 다단 = 그 단의 「흙막이」 컨테이너(54-10) — 옛 추가 기슭막이 칸 대신 그 상자를 켬 ·
|
||||
// 단 수 · 옵션은 부모(유출) 칸에 그대로
|
||||
// 배관 · 세월교 · 박스 유출 다단 = 그 단의 「흙막이」 컨테이너(54-10 · 90-1) — 옛 추가 기슭막이 칸
|
||||
// 대신 그 상자를 켬 · 단 수 · 옵션은 부모(유출) 칸에 그대로
|
||||
const soil =
|
||||
ownerSlot === sideSlots.outlet && slotUsable(ownerSlot)
|
||||
(ownerSlot === sideSlots.outlet || ownerSlot === sideSlots.wingOutlet) &&
|
||||
slotUsable(ownerSlot)
|
||||
? ownerSlot
|
||||
.closest(".b05-structure__spec")
|
||||
?.querySelectorAll<HTMLElement>('fieldset[data-spec-container="soil_add"]')[
|
||||
@@ -187,7 +188,7 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void {
|
||||
]
|
||||
: undefined;
|
||||
if (soil) {
|
||||
moveRows(panelRoot, PIPE_OUTLET_ROWS, ownerSlot);
|
||||
moveRows(panelRoot, rowsFor(ownerSlot), ownerSlot);
|
||||
setGroupLabel(soil, "");
|
||||
soil.classList.add("is-active");
|
||||
if (scroll) soil.scrollIntoView({ block: "nearest" });
|
||||
|
||||
@@ -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);
|
||||
@@ -267,3 +271,20 @@ export function withoutGuardedFill<T extends BoxLayout | FordLayout>(
|
||||
label: { ...ford.label, text: `${ford.label.text} L${Math.ceil(lengthM - 1e-6)}m` },
|
||||
} as T;
|
||||
}
|
||||
|
||||
/** 세월교 · 박스 유입 흙막이 수평공간 시작 = 본체 바닥(세월교 바닥판 · 박스 저판 상면) 높이의
|
||||
* 흙막이 전면 자리(89-3). 본체가 이 측점에 없으면 null(배관 규칙). */
|
||||
export function guardFloorStart(
|
||||
section: CrossSection,
|
||||
base: BodyGuardBase | null,
|
||||
wall: WallLayout,
|
||||
): OffsetPoint | null {
|
||||
const end = base?.ends.inlet;
|
||||
if (!end) return null;
|
||||
const slab = section.culvert?.body === "box" ? (section.box?.slab_thickness_m ?? 0) : 0;
|
||||
const floor = end.invert + slab;
|
||||
return {
|
||||
offset: wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - floor),
|
||||
elevation: floor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import type { BoxLayout, BoxSideRole } from "./B06_Section_UI_Cross_Box_Geom";
|
||||
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
|
||||
export {
|
||||
computeBoxLayout,
|
||||
@@ -127,6 +128,17 @@ export function appendBoxOverlay(
|
||||
polyline.append(title);
|
||||
layer.append(polyline);
|
||||
}
|
||||
// 도수로 · 돌붙임 띠(90-2) — 유출 끝 ~ 원지반 성토부선 따라(흙막이 쪽은 배관 오버레이가 그림)
|
||||
if (box.chute)
|
||||
appendChuteBand(
|
||||
layer,
|
||||
layout.sides
|
||||
.filter((side) => side.wingRole === "outlet")
|
||||
.flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut").map((s) => s.points)),
|
||||
box.chute,
|
||||
x,
|
||||
toDisplayY,
|
||||
);
|
||||
// ⑥ 끝 바깥 절 · 성토선 — 세월교 측벽과 같은 체계(79-7). 흙막이 쪽은 기하가 걷어 둠.
|
||||
for (const side of layout.sides) {
|
||||
const mark = side.role === "left" ? "좌" : "우";
|
||||
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
WallLayout,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Geom";
|
||||
import { appendWallTrench, foundationChoice } from "./B06_Section_UI_Cross_Excavation";
|
||||
import { appendWallHatch } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
import { appendChuteBand, appendWallHatch } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
|
||||
// 기하 계산 진입점과 공개 상수·타입은 여기서 재수출한다 — B05(최소 토피)와 횡단 뷰가
|
||||
// 이 모듈 경로로 이미 참조하고 있어 분리 후에도 import 경로를 바꾸지 않는다.
|
||||
@@ -101,6 +101,16 @@ export function appendCulvertOverlay(
|
||||
layer.append(line);
|
||||
};
|
||||
const drawOutletFill = (): void => {
|
||||
// 도수로 · 돌붙임 띠(90-2) — 유출 끝 ~ 원지반 성토 조각(단 사이 포함) · 연동 카드엔 안 그림
|
||||
// (B07 은 `linked` 없이 관 숨김으로만 알림 — 관 숨긴 배관 = 연동 카드)
|
||||
if (culvert.chute && !linked && (!hidePipe || culvert.body))
|
||||
appendChuteBand(
|
||||
layer,
|
||||
layout.outletFill.segments.filter((s) => s.kind !== "cut").map((s) => s.points),
|
||||
culvert.chute,
|
||||
x,
|
||||
toDisplayY,
|
||||
);
|
||||
for (const segment of layout.outletFill.segments) {
|
||||
// 유출부가 지면에 닿은 뒤의 절토선은 그리지 않는다(2026-08-24 사용자) — 벽이
|
||||
// 이미 원지반에 묻힌 구간이라 도면에 더 보탤 정보가 없다. 면적·수량 계산은
|
||||
|
||||
@@ -20,9 +20,6 @@ export const REVET_THICKNESS_M = 0.45;
|
||||
* 못 가른 자리만 이 값. 돌쌓기 벽은 `B06_Section_UI_Cross_Lean.leanOf`(품셈 13-4-4 [주]⑪ 표준경사). */
|
||||
export const REVET_LEAN_RATIO = 0.3;
|
||||
|
||||
/** 돌붙임 두께(m) — 실무 돌붙임 L3=45(울진 1공구 수량집계표). */
|
||||
export const PITCHING_THICKNESS_M = 0.45;
|
||||
|
||||
/** 사다리꼴(배면부) 상단 폭(m) — 띠 두께의 절반(2026-08-20 사용자 확정). */
|
||||
export const REVET_TRAP_TOP_M = REVET_THICKNESS_M / 2;
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall";
|
||||
import {
|
||||
bodyGuardAxes,
|
||||
bodyGuardBase,
|
||||
guardFloorStart,
|
||||
bodyGuardHeight,
|
||||
fordPipeFit,
|
||||
mergeGuardTrim,
|
||||
@@ -597,7 +598,7 @@ export function computeCulvertLayout(
|
||||
outletBottom: outletWall?.bottomBack.elevation ?? 0,
|
||||
basinStart: culvert.hidden_pipe
|
||||
? inletWall
|
||||
? pipeCorners.inlet.bottom
|
||||
? (guardFloorStart(section, body, inletWall) ?? pipeCorners.inlet.bottom)
|
||||
: null
|
||||
: basinFillStart,
|
||||
basinBottom: culvert.hidden_pipe ? (inletWall?.bottomBack.elevation ?? 0) : basinFillBottom,
|
||||
@@ -617,8 +618,7 @@ export function computeCulvertLayout(
|
||||
fillSlopeOf(wall, wall.role === "inlet" ? inletInfo.edge : outletInfo.edge);
|
||||
for (const wall of walls) {
|
||||
const slope = withBackCut(slopeOf(wall), wall, section.design, groundAt, !culvert.hidden_pipe);
|
||||
// 사면선은 노견부터 우리가 그린다 — 트림 경계를 노견까지 당겨 백엔드 설계선의
|
||||
// 성토 구간을 통째로 덮는다(설계선은 1:1.2 고정이라 그대로 두면 다시 어긋난다).
|
||||
// 사면선은 노견부터 우리가 그림 — 트림을 노견까지 당겨 백엔드 설계선 성토 구간을 통째로 덮음
|
||||
if (wall.outward > 0) {
|
||||
trimMax = slope.points[0].offset;
|
||||
trimMaxElevation = null;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 집수정 계류측 두 채널의 입력을 짜고 면적용(미확정 단 제외) 결과를 함께 돌려준다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection, CulvertSideSpec } from "./B06_Section_Api_Fetch";
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import {
|
||||
GUARD_CUT_BERM_M,
|
||||
REVET_EMBED_DEPTH_M,
|
||||
@@ -86,17 +86,14 @@ export function buildCulvertTiers(input: {
|
||||
side: culvert?.hidden_pipe ? culvert.inlet.face_side : null,
|
||||
});
|
||||
// 세월교 · 박스 흙막이 끝 절토 = 컨테이너 수평공간 뒤 절토 경사(79-7) · 배관은 종전(3도 선).
|
||||
const cutBerm = (spec: CulvertSideSpec | undefined) =>
|
||||
const cutBerm = (lengthM: number) =>
|
||||
culvert?.body
|
||||
? {
|
||||
lengthM: Math.max(spec?.revet_berm_m ?? GUARD_CUT_BERM_M, 0),
|
||||
cut: structureCutSpec(section.design),
|
||||
}
|
||||
? { lengthM: Math.max(lengthM, 0), cut: structureCutSpec(section.design) }
|
||||
: undefined;
|
||||
// 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로).
|
||||
// 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다.
|
||||
const outletInput = {
|
||||
cutBerm: cutBerm(culvert?.outlet),
|
||||
cutBerm: cutBerm(0), // 유출 수평공간 없음 — 끝이 원지반에 묻히면 바로 절토(89-4)
|
||||
startBottomElevation: input.outletBottom,
|
||||
outward: input.outletInfo.outward,
|
||||
groundAt,
|
||||
@@ -110,7 +107,7 @@ export function buildCulvertTiers(input: {
|
||||
// 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 —
|
||||
// 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자).
|
||||
const basinInput = {
|
||||
cutBerm: cutBerm(culvert?.inlet),
|
||||
cutBerm: cutBerm(culvert?.inlet.revet_berm_m ?? GUARD_CUT_BERM_M),
|
||||
startBottomElevation: input.basinBottom,
|
||||
outward: input.inletInfo.outward,
|
||||
groundAt,
|
||||
|
||||
@@ -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,79 @@ 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");
|
||||
const spec = entry?.spec;
|
||||
const mark = spec ? PAVE_MARKS[spec] : undefined;
|
||||
if (spec && mark) {
|
||||
// 무늬는 바탕 채움째 한 패턴 — 다각형 한 장(B07 도면 수확이 겹선을 안 담음)
|
||||
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}"><rect width="${mark.size}" height="${mark.size}" class="b06-chart__pave-bg"/>${mark.marks}</pattern>`;
|
||||
svg.append(defs);
|
||||
polygon.style.fill = `url(#${id})`;
|
||||
polygon.dataset.paveSpec = spec;
|
||||
}
|
||||
svg.append(polygon);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom";
|
||||
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
|
||||
export {
|
||||
computeFordLayout,
|
||||
@@ -127,6 +128,17 @@ export function appendFordOverlay(
|
||||
layer.append(pipeOuter, pipeInner);
|
||||
const bodyShapes = [slab, pipeOuter, pipeInner];
|
||||
|
||||
// 도수로 · 돌붙임 띠(90-2) — 유출 측벽 끝 ~ 원지반 성토부선 따라(흙막이 쪽은 배관 오버레이가 그림)
|
||||
if (ford.chute)
|
||||
appendChuteBand(
|
||||
layer,
|
||||
layout.sides
|
||||
.filter((side) => side.role === "outlet")
|
||||
.flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut").map((s) => s.points)),
|
||||
ford.chute,
|
||||
x,
|
||||
toDisplayY,
|
||||
);
|
||||
// ④ 계류측 마감선 — 구조물이 원지반 위면 성토부선, 안이면 절토선(집수정과 같은 체계).
|
||||
for (const side of layout.sides) {
|
||||
const role = side.role === "inlet" ? "유입" : "유출";
|
||||
@@ -145,15 +157,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);
|
||||
|
||||
@@ -44,7 +44,7 @@ export function hatchOfForm(form: string | null | undefined): WallHatch {
|
||||
}
|
||||
|
||||
/** 띠 안의 국소 좌표 → 화면 px. u = 바닥(0)~상단(1), v = 배면(-0.5)~전면(+0.5). */
|
||||
interface BandFrame {
|
||||
export interface BandFrame {
|
||||
point: (u: number, v: number) => [number, number];
|
||||
/** 띠 축 기울기(도) — 사각형·원의 세로축을 벽 경사에 맞춘다. */
|
||||
leanDegrees: number;
|
||||
@@ -64,7 +64,7 @@ function line(a: [number, number], b: [number, number]): SVGLineElement {
|
||||
}
|
||||
|
||||
/** 띠 중심선 위 한 칸 — 회전 사각형(돌·격자 칸). */
|
||||
function cell(
|
||||
export function cell(
|
||||
frame: BandFrame,
|
||||
u: number,
|
||||
v: number,
|
||||
@@ -87,14 +87,20 @@ function cell(
|
||||
}
|
||||
|
||||
/** 켜 수 — 켜 높이가 목표치에 가장 가깝게, 최소 2켜. */
|
||||
function courses(spanM: number, targetM: number): number {
|
||||
export function courses(spanM: number, targetM: number): number {
|
||||
return Math.max(2, Math.round(spanM / targetM));
|
||||
}
|
||||
|
||||
function drawStones(group: SVGElement, frame: BandFrame, wet: boolean): void {
|
||||
/** 돌 한 줄 — `thicknessM` = 띠 두께(벽 = 흙막이 띠 · 도수로/돌붙임 = 그 두께 · 90-2). */
|
||||
export function drawStones(
|
||||
group: SVGElement,
|
||||
frame: BandFrame,
|
||||
wet: boolean,
|
||||
thicknessM = REVET_THICKNESS_M,
|
||||
): void {
|
||||
const count = courses(frame.spanM, 0.45);
|
||||
const courseM = frame.spanM / count;
|
||||
const widthM = REVET_THICKNESS_M * (wet ? 0.76 : 0.9);
|
||||
const widthM = thicknessM * (wet ? 0.76 : 0.9);
|
||||
const heightM = courseM * (wet ? 0.7 : 0.86);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
group.append(cell(frame, (i + 0.5) / count, 0, widthM, heightM, 0.3));
|
||||
@@ -308,3 +314,89 @@ export function appendWallHatch(
|
||||
// 옹벽만 — 흙막이(찰쌓기 · 콘크리트 등)에는 없음(73-2).
|
||||
if (wall.retaining) drawDrains(group, wall, x, toDisplayY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 도수로 · 돌붙임 띠(90-2) — 유출 성토사면 선(윗면 = 사면선)을 사면 안쪽(아래)으로 두께만큼 오프셋한
|
||||
* 띠 · 안에 돌(흙막이 돌과 같은 그리기 · 두께 인자). 선마다 한 띠 · 그림만(면적 · 터파기 무관).
|
||||
* 도수로 · 돌붙임 형상 같음 · 배관 · 박스 · 세월교 오버레이가 부름(B07 은 오버레이를 따라옴).
|
||||
*/
|
||||
export function appendChuteBand(
|
||||
layer: SVGElement,
|
||||
lines: OffsetPoint[][],
|
||||
chute: { structure: string; thickness_m: number },
|
||||
x: (offset: number) => number,
|
||||
toDisplayY: (elevation: number) => number,
|
||||
): void {
|
||||
const thickness = chute.thickness_m;
|
||||
if (!(thickness > 0)) return;
|
||||
const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1;
|
||||
const px = (p: OffsetPoint): [number, number] => [x(p.offset), toDisplayY(p.elevation)];
|
||||
for (const raw of lines) {
|
||||
const top = raw.filter(
|
||||
(p, i) =>
|
||||
i === 0 ||
|
||||
Math.hypot(p.offset - raw[i - 1].offset, p.elevation - raw[i - 1].elevation) > 1e-6,
|
||||
);
|
||||
if (top.length < 2) continue;
|
||||
// 변마다 아래쪽(사면 안쪽) 단위 법선
|
||||
const normals = top.slice(1).map((p, i) => {
|
||||
const dx = p.offset - top[i].offset;
|
||||
const dz = p.elevation - top[i].elevation;
|
||||
const length = Math.hypot(dx, dz);
|
||||
const sign = dx / length > 0 ? -1 : 1;
|
||||
return { offset: (sign * -dz) / length, elevation: (sign * dx) / length };
|
||||
});
|
||||
// 꺾임은 두 오프셋 선의 교점(마이터 · 너무 뾰족하면 두 배까지)
|
||||
const bottom = top.map((p, i) => {
|
||||
const a = normals[Math.max(i - 1, 0)];
|
||||
const b = normals[Math.min(i, normals.length - 1)];
|
||||
const sum = { offset: a.offset + b.offset, elevation: a.elevation + b.elevation };
|
||||
const k = thickness / Math.max(sum.offset * a.offset + sum.elevation * a.elevation, 0.5);
|
||||
return { offset: p.offset + sum.offset * k, elevation: p.elevation + sum.elevation * k };
|
||||
});
|
||||
const outline = [...top, ...bottom.reverse()];
|
||||
const band = document.createElementNS(SVG_NS, "polygon");
|
||||
band.setAttribute("points", outline.map((p) => px(p).join(",")).join(" "));
|
||||
band.setAttribute("class", "b06-chart__culvert-pitching");
|
||||
const title = document.createElementNS(SVG_NS, "title");
|
||||
title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · 성토사면 따라 원지반까지`;
|
||||
band.append(title);
|
||||
clipSeq += 1;
|
||||
const clipId = `b06-chute-clip-${clipSeq}`;
|
||||
const clip = document.createElementNS(SVG_NS, "clipPath");
|
||||
clip.setAttribute("id", clipId);
|
||||
const clipShape = document.createElementNS(SVG_NS, "polygon");
|
||||
clipShape.setAttribute("points", band.getAttribute("points") ?? "");
|
||||
clip.append(clipShape);
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
group.setAttribute("clip-path", `url(#${clipId})`);
|
||||
top.slice(1).forEach((end, i) => {
|
||||
const start = top[i];
|
||||
const normal = normals[i];
|
||||
const [ax, ay] = px(start);
|
||||
const [bx, by] = px(end);
|
||||
drawStones(
|
||||
group,
|
||||
{
|
||||
spanM: Math.hypot(end.offset - start.offset, end.elevation - start.elevation),
|
||||
pixelsPerMeter,
|
||||
point: (u, v) =>
|
||||
px({
|
||||
offset:
|
||||
start.offset +
|
||||
(end.offset - start.offset) * u +
|
||||
normal.offset * thickness * (v + 0.5),
|
||||
elevation:
|
||||
start.elevation +
|
||||
(end.elevation - start.elevation) * u +
|
||||
normal.elevation * thickness * (v + 0.5),
|
||||
}),
|
||||
leanDegrees: (Math.atan2(bx - ax, -(by - ay)) * 180) / Math.PI,
|
||||
},
|
||||
false,
|
||||
thickness,
|
||||
);
|
||||
});
|
||||
layer.append(band, clip, group);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,13 +142,6 @@ export function applyPipeOptionsToCache(
|
||||
});
|
||||
}
|
||||
}
|
||||
// 세월교 · 박스 추가 흙막이 = 폼 상자 수가 유출 다단 수(배관은 횡단이 정함 · 73-4)
|
||||
const soil = body ? soilCount(patch) : null;
|
||||
if (soil !== null && soil !== (owner.design?.extra_wall_counts?.outlet ?? 0))
|
||||
// 늦게 불러옴 — 흙막이 세트 때만 쓰는 횡단 기하 묶음(배관 폼 길은 안 끌어옴)
|
||||
void import("../A00_Common/design/design_structure_pipe_calc").then((calc) =>
|
||||
calc.setPipeExtraCount(owner.chainage_m, soil),
|
||||
);
|
||||
if (!culvert.hidden_pipe || body) applySoilTiers(ctx, owner, patch, whole);
|
||||
own(culvert.inlet, "basin_length_m", num("inlet_basin_length_m"));
|
||||
own(culvert.inlet, "basin_before_m", num("inlet_basin_before_m"));
|
||||
@@ -190,14 +183,6 @@ function syncBodyGuard(
|
||||
return !!guard;
|
||||
}
|
||||
|
||||
/** 추가 흙막이 상자 수 — 가장 큰 `soil_add_{n}_*` 의 n. */
|
||||
function soilCount(patch: Record<string, number | string>): number {
|
||||
return Math.max(
|
||||
0,
|
||||
...Object.keys(patch).map((key) => Number(/^soil_add_(\d+)_/.exec(key)?.[1] ?? 0)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 흙막이 컨테이너가 마지막으로 보낸 높이 · 형태(`측점:extra{n}`) — 기하가 다시 그릴 때마다 키를 지형에
|
||||
* 맞춰 고쳐 되받으므로(syncApplied) 되받은 값과 견주면 안 바꾼 단도 다시 보냄(54-10). */
|
||||
const soilSent = new Map<string, { h: number | null; m: string | null }>();
|
||||
|
||||
@@ -185,6 +185,22 @@
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/* 노면공 포장사양 무늬(88): 포장 박스 채움 = 바탕 + 무늬 — 콘크리트 빗금 · 아스콘 점 · 쇄석 돌 */
|
||||
.b06-chart__pave-bg {
|
||||
fill: color-mix(in srgb, var(--color-text-secondary) 30%, transparent);
|
||||
}
|
||||
|
||||
.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);
|
||||
@@ -344,7 +360,7 @@
|
||||
stroke-width: 2.2;
|
||||
}
|
||||
|
||||
/* 유출부 돌붙임 — 성토사면을 따라 원지반까지 덮는 돌붙임 띠(실무 도면의 돌 연쇄) */
|
||||
/* 유출부 도수로 · 돌붙임 띠(90-2) — 성토사면 안쪽으로 두께만큼 · 안에 돌(`appendChuteBand`) */
|
||||
.b06-chart__culvert-pitching {
|
||||
fill: color-mix(in srgb, #3fa34d 18%, transparent);
|
||||
stroke: #3fa34d;
|
||||
|
||||
@@ -317,6 +317,108 @@ function rectPoints(element: SVGElement): XY[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** pattern 한 칸의 도형(SVG 좌표) — 선(path M/L) · 원/타원. 바탕 rect 는 그림이 아니라 뺌. */
|
||||
export interface PatternTile {
|
||||
width: number;
|
||||
height: number;
|
||||
paths: XY[][];
|
||||
rings: Array<{ cx: number; cy: number; rx: number; ry: number }>;
|
||||
}
|
||||
|
||||
/** 무늬 칸 수 상한 — 넘으면 무늬를 버리고 외곽선만(ponytail: 큰 다각형이면 칸 크기를 키울 것). */
|
||||
const PATTERN_TILE_LIMIT = 20000;
|
||||
|
||||
/**
|
||||
* pattern 채움을 다각형(도면 좌표) 안으로 펴서 자른다(88-4) — CAD 에는 무늬 채움이 없다.
|
||||
* 칸은 SVG 사용자 좌표 원점 기준(`patternUnits="userSpaceOnUse"`). 선은 이어 붙여 한 줄로,
|
||||
* 다각형 안에 온전히 든 원은 원 그대로 · 걸친 원/타원은 잘린 폴리선으로.
|
||||
*/
|
||||
export function patternMarks(
|
||||
polygon: XY[],
|
||||
tile: PatternTile,
|
||||
): { polylines: XY[][]; circles: Array<{ center: XY; radius: number }> } {
|
||||
const polylines: XY[][] = [];
|
||||
const circles: Array<{ center: XY; radius: number }> = [];
|
||||
const { width, height } = tile;
|
||||
if (polygon.length < 3 || !(width > 0) || !(height > 0)) return { polylines, circles };
|
||||
const xs = polygon.map((point) => point[0]);
|
||||
const ys = polygon.map((point) => -point[1]);
|
||||
const [i0, i1] = [Math.floor(Math.min(...xs) / width), Math.floor(Math.max(...xs) / width)];
|
||||
const [j0, j1] = [Math.floor(Math.min(...ys) / height), Math.floor(Math.max(...ys) / height)];
|
||||
if ((i1 - i0 + 1) * (j1 - j0 + 1) > PATTERN_TILE_LIMIT) return { polylines, circles };
|
||||
const lines: XY[][] = [];
|
||||
const arcs: XY[][] = [];
|
||||
for (let i = i0; i <= i1; i += 1) {
|
||||
for (let j = j0; j <= j1; j += 1) {
|
||||
const [dx, dy] = [i * width, j * height];
|
||||
for (const path of tile.paths) {
|
||||
lines.push(
|
||||
...clipPointsToPolygon(
|
||||
path.map(([x, y]) => flip(x + dx, y + dy)),
|
||||
polygon,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const { cx, cy, rx, ry } of tile.rings) {
|
||||
const center = flip(cx + dx, cy + dy);
|
||||
const ring: XY[] = circlePoints([0, 0], 1).map(([x, y]) => [
|
||||
center[0] + x * rx,
|
||||
center[1] + y * ry,
|
||||
]);
|
||||
if (rx === ry && ring.every((point) => pointInPolygon(point, polygon))) {
|
||||
circles.push({ center, radius: rx });
|
||||
} else {
|
||||
arcs.push(...clipPointsToPolygon(ring, polygon));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
polylines.push(...chain(lines).map((points) => simplify(points)), ...chain(arcs));
|
||||
return { polylines, circles };
|
||||
}
|
||||
|
||||
/** 다각형 채움이 가리키는 pattern 을 칸 도형으로 읽는다(채움 = fill 속성 또는 style). 없으면 null. */
|
||||
function patternTileOf(element: SVGElement, root: SVGElement): PatternTile | null {
|
||||
const fill = element.getAttribute("fill") ?? String(element.style?.fill ?? "");
|
||||
const id = /url\(["']?#([^"')]+)/.exec(fill)?.[1];
|
||||
if (!id) return null;
|
||||
const pattern = Array.from(root.querySelectorAll<SVGElement>("pattern")).find(
|
||||
(node) => node.getAttribute("id") === id,
|
||||
);
|
||||
if (!pattern) return null;
|
||||
const tile: PatternTile = {
|
||||
width: attr(pattern, "width"),
|
||||
height: attr(pattern, "height"),
|
||||
paths: [],
|
||||
rings: [],
|
||||
};
|
||||
for (const child of Array.from(pattern.children) as SVGElement[]) {
|
||||
const tag = child.tagName.toLowerCase();
|
||||
if (tag === "path") {
|
||||
// M/L 절대 좌표만(무늬 빗금 꼴) — M 마다 새 줄
|
||||
for (const run of (child.getAttribute("d") ?? "").split(/(?=M)/i)) {
|
||||
const numbers = run.match(/-?[\d.]+/g)?.map(Number) ?? [];
|
||||
const points: XY[] = [];
|
||||
for (let index = 0; index + 1 < numbers.length; index += 2) {
|
||||
points.push([numbers[index], numbers[index + 1]]);
|
||||
}
|
||||
if (points.length > 1) tile.paths.push(points);
|
||||
}
|
||||
} else if (tag === "circle") {
|
||||
const r = attr(child, "r");
|
||||
tile.rings.push({ cx: attr(child, "cx"), cy: attr(child, "cy"), rx: r, ry: r });
|
||||
} else if (tag === "ellipse") {
|
||||
tile.rings.push({
|
||||
cx: attr(child, "cx"),
|
||||
cy: attr(child, "cy"),
|
||||
rx: attr(child, "rx"),
|
||||
ry: attr(child, "ry"),
|
||||
});
|
||||
}
|
||||
}
|
||||
return tile;
|
||||
}
|
||||
|
||||
/** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다 (블록 테두리 안으로 자른다). */
|
||||
function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] {
|
||||
const [fx0, , fx1] = frame;
|
||||
@@ -337,6 +439,21 @@ function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] {
|
||||
const poly = polyEntity(style, simplify(clipX(run, fx0, fx1)));
|
||||
if (poly) entities.push(poly);
|
||||
}
|
||||
// 무늬 채움(노면공 · 물넘이포장) — 무늬 선 · 원을 다각형 안으로 펴서 같은 층 · 색으로(88-4)
|
||||
const tile = tag === "polygon" && !clip ? patternTileOf(element, root) : null;
|
||||
if (tile) {
|
||||
const marks = patternMarks(points, tile);
|
||||
for (const run of marks.polylines) {
|
||||
const poly = polyEntity(style, clipX(run, fx0, fx1));
|
||||
if (poly) entities.push(poly);
|
||||
}
|
||||
for (const { center, radius } of marks.circles) {
|
||||
if (!inside(center[0])) continue;
|
||||
entities.push(
|
||||
baseEntity(style, "Circle", { center: { x: center[0], y: center[1] }, radius }),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (tag === "line") {
|
||||
const start = flip(attr(element, "x1"), attr(element, "y1"));
|
||||
const end = flip(attr(element, "x2"), attr(element, "y2"));
|
||||
@@ -415,7 +532,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,
|
||||
|
||||
@@ -195,6 +195,9 @@ def _add_entity(
|
||||
start_angle = shape.get("startAngle")
|
||||
end_angle = shape.get("endAngle")
|
||||
if isinstance(start_angle, (int, float)) and isinstance(end_angle, (int, float)):
|
||||
# DXF 호는 늘 반시계 — 시계방향 호는 시작 · 끝을 바꿔야 같은 작은 호.
|
||||
if shape.get("counterClockwise") is False:
|
||||
start_angle, end_angle = end_angle, start_angle
|
||||
space.add_arc(
|
||||
center,
|
||||
radius,
|
||||
|
||||
@@ -29,6 +29,8 @@ from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
import ezdxf
|
||||
from ezdxf import colors as ezdxf_colors
|
||||
from ezdxf import path as ezdxf_path
|
||||
from ezdxf.enums import TextEntityAlignment
|
||||
from ezdxf.lldxf.encoding import decode_dxf_unicode, has_dxf_unicode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,6 +40,8 @@ _DEFAULT_COLOR = "#f5f7fa"
|
||||
# 곡선을 선분으로 풀 때 허용 오차(mm) — 도각 크기(840x594mm)에서 눈에 띄지 않는다.
|
||||
_FLATTEN_MM = 0.2
|
||||
_MAX_ENTITIES = 20000
|
||||
# 블록 속 블록을 풀 깊이 — 자기를 품는 깨진 블록에서 끝없이 돌지 않게.
|
||||
_MAX_BLOCK_DEPTH = 16
|
||||
|
||||
|
||||
# 프로그램에 함께 담은 LibreDWG 자리 — `B07_DesignDetail/openwebcad/tools/libredwg/`.
|
||||
@@ -117,16 +121,26 @@ def _plain(label: str) -> str:
|
||||
return decode_dxf_unicode(label) if has_dxf_unicode(label) else label
|
||||
|
||||
|
||||
def _text(index: int, entity: Any, label: str, insert: Any, height: float) -> dict[str, Any]:
|
||||
rotation = float(getattr(entity.dxf, "rotation", 0.0) or 0.0)
|
||||
def _text(
|
||||
index: int,
|
||||
entity: Any,
|
||||
label: str,
|
||||
insert: Any,
|
||||
height: float,
|
||||
direction: Any = None,
|
||||
align: str = "left",
|
||||
) -> dict[str, Any]:
|
||||
if direction is None:
|
||||
rotation = float(getattr(entity.dxf, "rotation", 0.0) or 0.0)
|
||||
direction = (cos(radians(rotation)), sin(radians(rotation)))
|
||||
return {
|
||||
**_base(index, entity, "Text"),
|
||||
"shapeData": {
|
||||
"label": _plain(label),
|
||||
"basePoint": _point(insert[0], insert[1]),
|
||||
"options": {
|
||||
"textDirection": _point(cos(radians(rotation)), sin(radians(rotation))),
|
||||
"textAlign": "left",
|
||||
"textDirection": _point(direction[0], direction[1]),
|
||||
"textAlign": align,
|
||||
"textColor": _color(entity),
|
||||
"fontSize": float(height) or 3.0,
|
||||
"fontFamily": "sans-serif",
|
||||
@@ -143,16 +157,50 @@ def _flatten(entity: Any) -> list[Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _polyline_points(entity: Any) -> tuple[list[Any], bool]:
|
||||
"""폴리선 → WCS 점열. bulge(호 마디)는 펴고 OCS(압출 −Z 거울 블록)는 WCS 로.
|
||||
|
||||
`make_path` 가 닫힌 폴리선의 닫는 마디까지 넣으므로 닫힘 표시는 False.
|
||||
면 · 메시 POLYLINE 처럼 경로로 못 바꾸면 꼭짓점을 그대로 잇는다.
|
||||
"""
|
||||
try:
|
||||
return list(ezdxf_path.make_path(entity).flattening(_FLATTEN_MM)), False
|
||||
except Exception:
|
||||
if entity.dxftype() == "LWPOLYLINE":
|
||||
return list(entity.get_points("xy")), bool(entity.closed)
|
||||
return [vertex.dxf.location for vertex in entity.vertices], bool(entity.is_closed)
|
||||
|
||||
|
||||
# DXF 가로 정렬 → CAD 글 정렬. 맞춤(ALIGNED · FIT)은 첫 점에서 왼쪽.
|
||||
_TEXT_ALIGN = {
|
||||
TextEntityAlignment.CENTER: "center",
|
||||
TextEntityAlignment.MIDDLE: "center",
|
||||
TextEntityAlignment.TOP_CENTER: "center",
|
||||
TextEntityAlignment.MIDDLE_CENTER: "center",
|
||||
TextEntityAlignment.BOTTOM_CENTER: "center",
|
||||
TextEntityAlignment.RIGHT: "right",
|
||||
TextEntityAlignment.TOP_RIGHT: "right",
|
||||
TextEntityAlignment.MIDDLE_RIGHT: "right",
|
||||
TextEntityAlignment.BOTTOM_RIGHT: "right",
|
||||
}
|
||||
|
||||
|
||||
def _text_placement(entity: Any) -> tuple[Any, Any, str]:
|
||||
"""TEXT 의 (WCS 기준점, WCS 방향, 가로 정렬). 정렬점 · OCS(압출 −Z)를 따른다."""
|
||||
align, first, _second = entity.get_placement()
|
||||
ocs = entity.ocs()
|
||||
rotation = radians(float(entity.dxf.rotation or 0.0))
|
||||
direction = ocs.to_wcs((cos(rotation), sin(rotation), 0.0))
|
||||
return ocs.to_wcs(first), direction, _TEXT_ALIGN.get(align, "left")
|
||||
|
||||
|
||||
def _convert_entity(index: int, entity: Any) -> list[dict[str, Any]]:
|
||||
kind = entity.dxftype()
|
||||
if kind == "LINE":
|
||||
return [_line(index, entity, entity.dxf.start, entity.dxf.end)]
|
||||
if kind == "LWPOLYLINE":
|
||||
shape = _polyline(index, entity, list(entity.get_points("xy")), bool(entity.closed))
|
||||
return [shape] if shape else []
|
||||
if kind == "POLYLINE":
|
||||
points = [vertex.dxf.location for vertex in entity.vertices]
|
||||
shape = _polyline(index, entity, points, bool(entity.is_closed))
|
||||
if kind in ("LWPOLYLINE", "POLYLINE"):
|
||||
points, closed = _polyline_points(entity)
|
||||
shape = _polyline(index, entity, points, closed)
|
||||
return [shape] if shape else []
|
||||
if kind in ("CIRCLE", "ARC", "ELLIPSE", "SPLINE"):
|
||||
points = _flatten(entity)
|
||||
@@ -172,24 +220,32 @@ def _convert_entity(index: int, entity: Any) -> list[dict[str, Any]]:
|
||||
label = str(entity.dxf.text or "").strip()
|
||||
if not label:
|
||||
return []
|
||||
return [_text(index, entity, label, entity.dxf.insert, float(entity.dxf.height or 3.0))]
|
||||
insert, direction, align = _text_placement(entity)
|
||||
height = float(entity.dxf.height or 3.0)
|
||||
return [_text(index, entity, label, insert, height, direction, align)]
|
||||
if kind == "MTEXT":
|
||||
label = str(entity.plain_text() or "").strip()
|
||||
if not label:
|
||||
return []
|
||||
height = float(entity.dxf.char_height or 3.0)
|
||||
return [_text(index, entity, label, entity.dxf.insert, height)]
|
||||
# 붙임점 1~9 = 위 · 가운데 · 아래 줄의 왼 · 가운데 · 오른.
|
||||
align = ("left", "center", "right")[(int(entity.dxf.attachment_point or 1) - 1) % 3]
|
||||
return [_text(index, entity, label, entity.dxf.insert, height, align=align)]
|
||||
return []
|
||||
|
||||
|
||||
def _expand(entity: Any) -> list[Any]:
|
||||
"""블록·치수처럼 속에 도형을 품은 것은 풀어서 낱개로 만든다. 못 풀면 버린다."""
|
||||
def _expand(entity: Any, depth: int = 0) -> list[Any]:
|
||||
"""블록·치수처럼 속에 도형을 품은 것은 풀어서 낱개로 — 블록 속 블록까지. 못 풀면 버린다."""
|
||||
if entity.dxftype() in ("INSERT", "DIMENSION", "LEADER", "MULTILEADER"):
|
||||
if depth >= _MAX_BLOCK_DEPTH:
|
||||
logger.info("B07 도각 불러오기 — 블록이 %d 겹 넘게 겹쳐 건너뜀", _MAX_BLOCK_DEPTH)
|
||||
return []
|
||||
try:
|
||||
return list(entity.virtual_entities())
|
||||
inner = list(entity.virtual_entities())
|
||||
except Exception:
|
||||
logger.info("B07 도각 불러오기 — %s 는 풀지 못해 건너뜀", entity.dxftype())
|
||||
return []
|
||||
return [item for child in inner for item in _expand(child, depth + 1)]
|
||||
return [entity]
|
||||
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
|
||||
// CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3).
|
||||
import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host";
|
||||
import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit";
|
||||
import { createCadTabs, type CadTab } from "@ui/cad_host/cad_host_tabs";
|
||||
|
||||
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
|
||||
interface DesignMeta {
|
||||
@@ -109,7 +110,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
// (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색
|
||||
// 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
|
||||
onChanged: (dirty) => {
|
||||
if (!frameEditor.isEditing()) cadDirty = dirty;
|
||||
if (frameEditor.isEditing()) return;
|
||||
cadDirty = dirty;
|
||||
if (dirty) tabs.markDirty();
|
||||
},
|
||||
onNavigate: (direction) => navigateDrawing(direction),
|
||||
onExport: (fileFormat) => void exportDrawingFile(fileFormat),
|
||||
@@ -248,7 +251,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
|
||||
/** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */
|
||||
const mayDiscardEdits = (): boolean => {
|
||||
if (!cadDirty) return true;
|
||||
if (!cadDirty && !tabs.anyDirty()) return true;
|
||||
return window.confirm(
|
||||
"저장하지 않은 편집이 있습니다.\n" +
|
||||
"지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" +
|
||||
@@ -259,7 +262,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
/** 도면 전환 중인가 — 겹쳐 누르면 늦게 온 응답이 화면을 덮는다. */
|
||||
let loadInFlight = false;
|
||||
|
||||
const loadDrawing = async (drawing: DesignDrawingItem, index: number) => {
|
||||
/** `stash` = 탭에 받아 둔 저장 안 한 편집본(82-4) — 있으면 그것을 싣고 미저장 그대로 */
|
||||
const loadDrawing = async (drawing: DesignDrawingItem, index: number, stash?: CadDrawing) => {
|
||||
if (!projectId || loadInFlight) return;
|
||||
loadInFlight = true;
|
||||
highlightActive(drawing.id);
|
||||
@@ -271,10 +275,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
currentDrawing = drawing;
|
||||
currentIndex = index;
|
||||
currentConfirmed = response.confirmed;
|
||||
cadDirty = false; // 새 도면을 실었다 — 미저장 편집은 이 도면 것이 아니다
|
||||
cadDirty = !!stash; // 새 도면을 실었다 — 미저장은 받아 둔 편집본일 때만
|
||||
applyConfirmButtonState();
|
||||
updateInfoPanel(drawing, response);
|
||||
cad.load(response.drawing, buildMeta(drawing, response, index));
|
||||
cad.load(stash ?? response.drawing, buildMeta(drawing, response, index));
|
||||
if (response.drawing.stale_layout) {
|
||||
showToast(
|
||||
"옛 배치로 확정된 장입니다. [수정]을 누르면 박스를 그림에 1 mm로 붙인 새 배치로 다시 만듭니다.",
|
||||
@@ -290,17 +294,37 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 목록에서 연 도면 = 탭(82-4) — 탭을 바꿀 때 지금 도면을 받아 두고(저장 안 한 편집 그대로) 다음 장을 싣는다.
|
||||
* 새 장 · 지우기는 없음(서버 길 없음) · 닫기는 미저장 탭만 물음 · 마지막 한 탭은 못 닫음.
|
||||
*/
|
||||
const tabs = createCadTabs<CadDrawing>({
|
||||
features: { close: true },
|
||||
capture: async () => ({ drawing: (await requestCadDrawing()).drawing, dirty: cadDirty }),
|
||||
canLeave: () => {
|
||||
if (!frameEditor.isEditing()) return !loadInFlight;
|
||||
showToast("도각 편집을 마친 뒤 다른 도면으로 옮기세요.", "warning");
|
||||
return false;
|
||||
},
|
||||
show: async (tab: CadTab<CadDrawing>) => {
|
||||
const index = drawings.findIndex((item) => item.id === tab.id);
|
||||
if (index >= 0)
|
||||
await loadDrawing(drawings[index], index, tab.dirty ? tab.drawing : undefined);
|
||||
},
|
||||
});
|
||||
const openTab = (drawing: DesignDrawingItem): Promise<boolean> =>
|
||||
tabs.open({ id: drawing.id, name: drawing.label, title: drawing.label });
|
||||
|
||||
const selectDrawing = (drawing: DesignDrawingItem) => {
|
||||
if (drawing === currentDrawing || !mayDiscardEdits()) return;
|
||||
void loadDrawing(drawing, drawings.indexOf(drawing));
|
||||
if (drawing === currentDrawing) return;
|
||||
void openTab(drawing);
|
||||
};
|
||||
|
||||
const navigateDrawing = (direction: "prev" | "next") => {
|
||||
if (currentIndex < 0) return;
|
||||
const target = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
|
||||
if (target < 0 || target >= drawings.length) return;
|
||||
if (!mayDiscardEdits()) return;
|
||||
void loadDrawing(drawings[target], target);
|
||||
void openTab(drawings[target]);
|
||||
};
|
||||
|
||||
const requestCadDrawing = (): Promise<SaveResult> => cad.requestSave();
|
||||
@@ -319,6 +343,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
currentConfirmed = true;
|
||||
currentDrawing.confirmed = true;
|
||||
cadDirty = false; // 저장했다
|
||||
const savedTab = tabs.active();
|
||||
if (savedTab) tabs.markClean(savedTab); // 받아 둔 편집본은 저장됨
|
||||
applyConfirmButtonState();
|
||||
drawingCache.delete(currentDrawing.id); // 확정본은 서버 저장분이 정본이다.
|
||||
|
||||
@@ -462,12 +488,15 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
|
||||
drawingPanel.append(infoPanelHost, confirmActions);
|
||||
|
||||
const cadStack = document.createElement("div");
|
||||
cadStack.className = "b07-cad-stack";
|
||||
cadStack.append(tabs.element, cad.element);
|
||||
const layout = createWorkflowLayout({
|
||||
title: L("B07_Design_Title"),
|
||||
steps: workflowSteps(),
|
||||
activeStep: 4,
|
||||
leftPanel: drawingPanel,
|
||||
mainContent: cad.element,
|
||||
mainContent: cadStack,
|
||||
stages: workflowState?.stages,
|
||||
currentStage: workflowState?.current_stage,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
@@ -492,7 +521,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const reopen = drawings.findIndex((item) => item.id === reopenDrawingId);
|
||||
reopenDrawingId = undefined;
|
||||
const first = Math.max(reopen, 0);
|
||||
void loadDrawing(drawings[first], first).then(() => {
|
||||
void openTab(drawings[first]).then(() => {
|
||||
// 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다.
|
||||
void prefetchAllDrawings();
|
||||
});
|
||||
|
||||
@@ -328,3 +328,16 @@
|
||||
.b07-plan-tools__remove {
|
||||
padding: 0 var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 목록에서 연 도면 탭 줄 + CAD(82-4) */
|
||||
.b07-cad-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.b07-cad-stack > .cad-host {
|
||||
flex: 1 1 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { clearRecovery, restoreRecovery } from '../helpers/autosave';
|
||||
import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json';
|
||||
import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage';
|
||||
import { requestDrawingExport, requestHostSave } from '../integration/aislo-drawing-bridge';
|
||||
import { runSendSelectionTable } from '../integration/aislo-table-export';
|
||||
import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png';
|
||||
import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg';
|
||||
import { isHostSave, redo, undo } from '../state';
|
||||
@@ -87,6 +88,14 @@ export const FILE_COMMANDS: CadCommand[] = [
|
||||
return 'PNG 내보내기';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'TOAREA',
|
||||
label: '면적산출로 보내기',
|
||||
glyph: '▦',
|
||||
hint: '고른 범위(표 · 선 표 · 글)를 면적산출 새 시트로 한 번 옮긴다',
|
||||
needsSelection: true,
|
||||
run: runSendSelectionTable,
|
||||
},
|
||||
{
|
||||
id: 'UNDO',
|
||||
label: '실행 취소',
|
||||
|
||||
@@ -89,6 +89,7 @@ const VIEW_ONLY_COMMAND_IDS: ReadonlySet<string> = new Set([
|
||||
'EXPORTPNG',
|
||||
'COPYCLIP',
|
||||
'COPYBASE',
|
||||
'TOAREA', // 고른 범위 → 면적산출 표(M02 83장)
|
||||
]);
|
||||
|
||||
/** 확정한 도면에서 이 명령을 실행해도 되는가. */
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { type Arc, Point } from '@flatten-js/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EPSILON } from '../App.consts.ts';
|
||||
import type { DrawController } from '../drawControllers/DrawController.ts';
|
||||
import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts';
|
||||
import { ArcEntity } from './ArcEntity.ts';
|
||||
import { LineEntity } from './LineEntity.ts';
|
||||
|
||||
describe('ArcEntity.distanceTo', () => {
|
||||
/**
|
||||
@@ -57,3 +60,62 @@ describe('ArcEntity.distanceTo', () => {
|
||||
expect(distanceInfo[0]).toBeLessThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ArcEntity 대칭 · 그리기 · 복제(84-3)', () => {
|
||||
const middle = (entity: ArcEntity) => {
|
||||
const arc = entity.getShape() as Arc;
|
||||
return arc.pointAtLength(arc.length / 2) as Point;
|
||||
};
|
||||
const mirrorOver = (axisEnd: Point) => {
|
||||
const arc = new ArcEntity('layer1', new Point(2, 1), 1, 0, Math.PI / 2, true);
|
||||
const before = arc.getShape() as Arc;
|
||||
const axis = new LineEntity('layer1', new Point(0, 0), axisEnd);
|
||||
const expected = [before.start, middle(arc), before.end].map((point) =>
|
||||
mirrorPointOverAxis(point, axis)
|
||||
);
|
||||
arc.mirror(axis);
|
||||
const after = arc.getShape() as Arc;
|
||||
return { expected, actual: [after.start, middle(arc), after.end], length: after.length };
|
||||
};
|
||||
|
||||
for (const [name, axisEnd] of [
|
||||
['세로축', new Point(0, 1)],
|
||||
['가로축', new Point(1, 0)],
|
||||
['45° 축', new Point(1, 1)],
|
||||
['30° 축', new Point(Math.cos(Math.PI / 6), Math.sin(Math.PI / 6))],
|
||||
] as const) {
|
||||
it(`${name} 대칭 — 끝점 · 가운데 점이 대칭 자리 · 작은 호 그대로`, () => {
|
||||
const { expected, actual, length } = mirrorOver(axisEnd);
|
||||
for (let index = 0; index < 3; index++) {
|
||||
expect(actual[index].x).toBeCloseTo(expected[index].x, 9);
|
||||
expect(actual[index].y).toBeCloseTo(expected[index].y, 9);
|
||||
}
|
||||
expect(length).toBeCloseTo(Math.PI / 2, 9);
|
||||
});
|
||||
}
|
||||
|
||||
it('시작각이 음수 · 끝각 0 인 호 — 끝각을 2π 로 바꾸지 않음(온 원으로 안 그림)', () => {
|
||||
const arc = new ArcEntity('layer1', new Point(0, 0), 1, -Math.PI / 2, 0, true);
|
||||
const calls: number[][] = [];
|
||||
const controller = {
|
||||
setLineStyles: () => {},
|
||||
drawArc: (_c: Point, _r: number, start: number, end: number) => calls.push([start, end]),
|
||||
} as unknown as DrawController;
|
||||
arc.draw(controller);
|
||||
expect(calls[0][1] - calls[0][0]).toBeCloseTo(Math.PI / 2, 9);
|
||||
});
|
||||
|
||||
it('복제 — 색 · 층 · 선 꼴 그대로', () => {
|
||||
const arc = new ArcEntity('구조물', new Point(0, 0), 1, 0, 1, false);
|
||||
arc.lineColor = '#ff0000';
|
||||
arc.lineWidth = 3;
|
||||
arc.lineDash = [2, 1];
|
||||
const copy = arc.clone();
|
||||
expect([copy.layerId, copy.lineColor, copy.lineWidth, copy.lineDash]).toEqual([
|
||||
'구조물',
|
||||
'#ff0000',
|
||||
3,
|
||||
[2, 1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,8 +60,9 @@ export class ArcEntity implements Entity, StartAndEndpointEntity {
|
||||
drawController.drawArc(
|
||||
this.arc.center,
|
||||
this.arc.r.valueOf(),
|
||||
this.arc?.startAngle || 0,
|
||||
this.arc?.endAngle || 2 * Math.PI,
|
||||
this.arc.startAngle,
|
||||
// 끝각 = 시작각 ± 쓸어간 각 — 끝각 0 을 2π 로 바꾸면 시작각이 음수인 호(대칭 뒤)가 온 원으로 그려짐.
|
||||
this.arc.startAngle + (this.arc.counterClockwise ? this.arc.sweep : -this.arc.sweep),
|
||||
this.arc.counterClockwise
|
||||
);
|
||||
}
|
||||
@@ -87,12 +88,13 @@ export class ArcEntity implements Entity, StartAndEndpointEntity {
|
||||
|
||||
public mirror(mirrorAxis: LineEntity) {
|
||||
const mirroredCenter = mirrorPointOverAxis(this.arc.center, mirrorAxis);
|
||||
mirrorAxis.getAngle();
|
||||
// 축 각 θ 에 대한 대칭 — 각 α → 2θ − α · 도는 방향 반대.
|
||||
const doubleAxisAngle = 2 * mirrorAxis.getAngle();
|
||||
this.arc = new Arc(
|
||||
mirroredCenter,
|
||||
this.arc.r.valueOf(),
|
||||
-this.arc.startAngle,
|
||||
-this.arc.endAngle,
|
||||
doubleAxisAngle - this.arc.startAngle,
|
||||
doubleAxisAngle - this.arc.endAngle,
|
||||
!this.arc.counterClockwise
|
||||
);
|
||||
}
|
||||
@@ -100,14 +102,13 @@ export class ArcEntity implements Entity, StartAndEndpointEntity {
|
||||
public clone(): Entity {
|
||||
if (this.arc) {
|
||||
const { center, r, startAngle, endAngle, counterClockwise } = this.arc;
|
||||
return new ArcEntity(
|
||||
getActiveLayerId(),
|
||||
center,
|
||||
r.valueOf(),
|
||||
startAngle,
|
||||
endAngle,
|
||||
counterClockwise
|
||||
);
|
||||
// 색 · 층 · 선 꼴은 그대로 — 폴리선 자식도 이 길로 복제됨.
|
||||
const copy = new ArcEntity(this.layerId, center, r.valueOf(), startAngle, endAngle, counterClockwise);
|
||||
copy.lineColor = this.lineColor;
|
||||
copy.lineWidth = this.lineWidth;
|
||||
copy.lineDash = this.lineDash;
|
||||
copy.opacity = this.opacity;
|
||||
return copy;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,13 @@ export class LineEntity implements Entity, StartAndEndpointEntity {
|
||||
}
|
||||
|
||||
public clone(): LineEntity {
|
||||
return new LineEntity(getActiveLayerId(), this.segment.clone());
|
||||
// 색 · 층 · 선 꼴은 그대로 — 폴리선 자식도 이 길로 복제됨.
|
||||
const copy = new LineEntity(this.layerId, this.segment.clone());
|
||||
copy.lineColor = this.lineColor;
|
||||
copy.lineWidth = this.lineWidth;
|
||||
copy.lineDash = this.lineDash;
|
||||
copy.opacity = this.opacity;
|
||||
return copy;
|
||||
}
|
||||
|
||||
public intersectsWithBox(box: Box): boolean {
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* 고른 범위 → 표 자료(M02 83장 · 도면 → 면적산출 한 번 옮기기). 상태 없는 순수 함수 — 엔티티 JSON(`toJson` 꼴)만 읽는다.
|
||||
* ① 표 엔티티(Table) 칸 그대로(병합 = colSpan · rowSpan · 폭 · 높이)
|
||||
* ② 선 + 글 격자 인식 — 가로 · 세로 선 경계를 허용오차로 묶고(PolyLine 자식 · 사각형 변 풀어 씀 ·
|
||||
* 끊긴 · 겹친 선 합침) 안쪽 선 빠진 곳 = 병합 · 글 자리가 든 칸에 글
|
||||
* ③ 둘 다 아님 글 자리로 줄(y 묶음) · 칸(x 차례) 폴백
|
||||
* 「1,500」 같은 글은 숫자로. 선이 축에서 기운 것(회전된 표)은 ③ 으로 떨어진다.
|
||||
* 칸 기하는 `table-geometry.ts`(cellOwners) 와 같은 규칙 — 서버 짝 판정은 `Engine_Frame_Export.table_parts`.
|
||||
* 시험: `resources/tester/cad/table-from-drawing.test.ts`(node --experimental-strip-types).
|
||||
*/
|
||||
import { cellOwners, type TableCells } from './table-geometry.ts';
|
||||
|
||||
export type DrawingCellValue = string | number;
|
||||
|
||||
export interface DrawingTableMerge {
|
||||
row: number;
|
||||
col: number;
|
||||
rowSpan: number;
|
||||
colSpan: number;
|
||||
}
|
||||
|
||||
export interface DrawingTable {
|
||||
/** table = 표 엔티티 · grid = 선 격자 · text = 글 자리 폴백 */
|
||||
kind: 'table' | 'grid' | 'text';
|
||||
rows: DrawingCellValue[][];
|
||||
merges: DrawingTableMerge[];
|
||||
/** 도면 단위(mm) — 폴백(text)은 빈 배열(기본 폭) */
|
||||
colWidths: number[];
|
||||
rowHeights: number[];
|
||||
}
|
||||
|
||||
/** 엔티티 JSON 중 읽는 칸만 */
|
||||
export interface DrawingEntityJson {
|
||||
type: string;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 엔티티 종류마다 꼴이 달라 읽는 자리에서 가림
|
||||
shapeData?: any;
|
||||
children?: DrawingEntityJson[] | null;
|
||||
}
|
||||
|
||||
interface Seg {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
interface Label {
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
}
|
||||
interface Interval {
|
||||
at: number;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
const NUMBER = /^[+\-−]?(\d{1,3}(,\d{3})+|\d+)(\.\d+)?$|^[+\-−]?\.\d+$/;
|
||||
|
||||
/** 「1,500」 · 「-2.5」 → 숫자 · 그 밖은 글 그대로(앞뒤 공백만 뗌) */
|
||||
export function cellValue(text: string): DrawingCellValue {
|
||||
const trimmed = text.trim();
|
||||
if (!NUMBER.test(trimmed)) return trimmed;
|
||||
const value = Number(trimmed.replace(/,/g, '').replace('−', '-'));
|
||||
return Number.isFinite(value) ? value : trimmed;
|
||||
}
|
||||
|
||||
const span = (value: number | undefined): number => Math.max(1, Math.round(value ?? 1));
|
||||
|
||||
/** ① 표 엔티티 → 표 자료 */
|
||||
function fromTableEntity(data: {
|
||||
columnWidths: number[];
|
||||
rowHeights: number[];
|
||||
cells: TableCells;
|
||||
}): DrawingTable {
|
||||
const rowCount = data.rowHeights.length;
|
||||
const colCount = data.columnWidths.length;
|
||||
const rows: DrawingCellValue[][] = [];
|
||||
const merges: DrawingTableMerge[] = [];
|
||||
for (let row = 0; row < rowCount; row += 1) {
|
||||
const line: DrawingCellValue[] = [];
|
||||
for (let col = 0; col < colCount; col += 1) {
|
||||
const cell = data.cells[row]?.[col];
|
||||
line.push(cell ? cellValue(cell.text ?? '') : '');
|
||||
if (!cell) continue;
|
||||
const rowSpan = Math.min(span(cell.rowSpan), rowCount - row);
|
||||
const colSpan = Math.min(span(cell.colSpan), colCount - col);
|
||||
if (rowSpan > 1 || colSpan > 1) merges.push({ row, col, rowSpan, colSpan });
|
||||
}
|
||||
rows.push(line);
|
||||
}
|
||||
return {
|
||||
kind: 'table',
|
||||
rows,
|
||||
merges,
|
||||
colWidths: [...data.columnWidths],
|
||||
rowHeights: [...data.rowHeights],
|
||||
};
|
||||
}
|
||||
|
||||
/** 선 · 글 모으기 — PolyLine 자식 · 사각형 변을 풀어 씀 */
|
||||
function collect(entities: DrawingEntityJson[], segs: Seg[], labels: Label[]): void {
|
||||
for (const entity of entities) {
|
||||
const data = entity.shapeData;
|
||||
if (entity.type === 'Line' && data?.startPoint && data?.endPoint) {
|
||||
segs.push({
|
||||
x1: data.startPoint.x,
|
||||
y1: data.startPoint.y,
|
||||
x2: data.endPoint.x,
|
||||
y2: data.endPoint.y,
|
||||
});
|
||||
} else if (entity.type === 'Rectangle' && Array.isArray(data?.points)) {
|
||||
const points = data.points as { x: number; y: number }[];
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const a = points[index];
|
||||
const b = points[(index + 1) % points.length];
|
||||
segs.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y });
|
||||
}
|
||||
} else if (entity.type === 'PolyLine' && entity.children) {
|
||||
collect(entity.children, segs, labels);
|
||||
} else if (entity.type === 'Text' && data?.basePoint && String(data.label ?? '').trim()) {
|
||||
labels.push(labelOf(data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 글이 놓인 자리 — 자리표 칸이면 basePoint 가 가운데 · 아니면 기준선에서 글 높이 반쯤 위 */
|
||||
function labelOf(data: {
|
||||
label: string;
|
||||
basePoint: { x: number; y: number };
|
||||
options?: { fontSize?: number; boxWidth?: number; boxHeight?: number };
|
||||
}): Label {
|
||||
const size = data.options?.fontSize ?? 2.2;
|
||||
const boxed = Boolean(data.options?.boxWidth && data.options?.boxHeight);
|
||||
return {
|
||||
text: data.label,
|
||||
x: data.basePoint.x,
|
||||
y: boxed ? data.basePoint.y : data.basePoint.y + size * 0.35,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
/** 값들을 허용오차 안에서 묶어 대표값(평균) 목록 — 오름차순 */
|
||||
function cluster(values: number[], tol: number): number[] {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const groups: number[][] = [];
|
||||
for (const value of sorted) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && value - last[last.length - 1] <= tol) last.push(value);
|
||||
else groups.push([value]);
|
||||
}
|
||||
return groups.map((group) => group.reduce((sum, value) => sum + value, 0) / group.length);
|
||||
}
|
||||
|
||||
const nearest = (edges: number[], value: number, tol: number): number => {
|
||||
let best = -1;
|
||||
let bestGap = tol;
|
||||
for (let index = 0; index < edges.length; index += 1) {
|
||||
const gap = Math.abs(edges[index] - value);
|
||||
if (gap <= bestGap) {
|
||||
best = index;
|
||||
bestGap = gap;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
/** 한 경계 위 선 구간들이 [from, to] 를 거의(90%) 덮는가 — 끊긴 · 겹친 선은 합쳐 셈 */
|
||||
function covers(intervals: Interval[], from: number, to: number, tol: number): boolean {
|
||||
const parts = intervals
|
||||
.map((part) => [Math.max(part.from, from), Math.min(part.to, to)] as const)
|
||||
.filter(([a, b]) => b > a)
|
||||
.sort((p, q) => p[0] - q[0]);
|
||||
let covered = 0;
|
||||
let reach = from;
|
||||
for (const [a, b] of parts) {
|
||||
const start = Math.max(a, reach);
|
||||
if (b > start) covered += b - start;
|
||||
reach = Math.max(reach, b);
|
||||
}
|
||||
return covered >= (to - from) * 0.9 - tol;
|
||||
}
|
||||
|
||||
/** ② 선 + 글 격자 — 경계가 2×2 안 되거나 글이 반 넘게 격자 밖이면 null */
|
||||
function fromGrid(segs: Seg[], labels: Label[]): DrawingTable | null {
|
||||
const box = { x0: Infinity, x1: -Infinity, y0: Infinity, y1: -Infinity };
|
||||
for (const s of segs) {
|
||||
box.x0 = Math.min(box.x0, s.x1, s.x2);
|
||||
box.x1 = Math.max(box.x1, s.x1, s.x2);
|
||||
box.y0 = Math.min(box.y0, s.y1, s.y2);
|
||||
box.y1 = Math.max(box.y1, s.y1, s.y2);
|
||||
}
|
||||
const extent = Math.max(box.x1 - box.x0, box.y1 - box.y0, 1e-9);
|
||||
const tol = extent * 0.003;
|
||||
const horizontal: Seg[] = [];
|
||||
const vertical: Seg[] = [];
|
||||
for (const s of segs) {
|
||||
const dx = Math.abs(s.x2 - s.x1);
|
||||
const dy = Math.abs(s.y2 - s.y1);
|
||||
if (Math.max(dx, dy) <= tol) continue; // 점 같은 선
|
||||
if (dy <= dx * 0.01) horizontal.push(s);
|
||||
else if (dx <= dy * 0.01) vertical.push(s);
|
||||
}
|
||||
let colEdges = cluster(
|
||||
vertical.map((s) => (s.x1 + s.x2) / 2),
|
||||
tol
|
||||
);
|
||||
let rowEdges = cluster(
|
||||
horizontal.map((s) => (s.y1 + s.y2) / 2),
|
||||
tol
|
||||
).reverse(); // 위 → 아래
|
||||
const lineOf = (list: Seg[], edges: number[], vertical: boolean): Interval[][] => {
|
||||
const byEdge: Interval[][] = edges.map(() => []);
|
||||
for (const s of list) {
|
||||
const at = vertical ? (s.x1 + s.x2) / 2 : (s.y1 + s.y2) / 2;
|
||||
const index = nearest(edges, at, tol);
|
||||
if (index < 0) continue;
|
||||
const [a, b] = vertical ? [s.y1, s.y2] : [s.x1, s.x2];
|
||||
byEdge[index].push({ at, from: Math.min(a, b), to: Math.max(a, b) });
|
||||
}
|
||||
return byEdge;
|
||||
};
|
||||
// 칸 경계를 하나도 덮지 못하는 짧은 선(눈금 · 지시선)의 경계는 뺀다 — 두 번이면 자리 잡음
|
||||
for (let pass = 0; pass < 2; pass += 1) {
|
||||
if (colEdges.length < 2 || rowEdges.length < 2) return null;
|
||||
const vLines = lineOf(vertical, colEdges, true);
|
||||
const hLines = lineOf(horizontal, rowEdges, false);
|
||||
const keepCol = colEdges.map((_, i) =>
|
||||
rowEdges.slice(1).some((y, j) => covers(vLines[i], y + tol, rowEdges[j] - tol, tol))
|
||||
);
|
||||
const keepRow = rowEdges.map((_, j) =>
|
||||
colEdges.slice(1).some((x, i) => covers(hLines[j], colEdges[i] + tol, x - tol, tol))
|
||||
);
|
||||
colEdges = colEdges.filter((_, i) => keepCol[i]);
|
||||
rowEdges = rowEdges.filter((_, j) => keepRow[j]);
|
||||
}
|
||||
if (colEdges.length < 2 || rowEdges.length < 2) return null;
|
||||
const colCount = colEdges.length - 1;
|
||||
const rowCount = rowEdges.length - 1;
|
||||
const vLines = lineOf(vertical, colEdges, true);
|
||||
const hLines = lineOf(horizontal, rowEdges, false);
|
||||
|
||||
// 안쪽 선 빠진 곳 = 같은 묶음(union-find)
|
||||
const parent = Array.from({ length: rowCount * colCount }, (_, i) => i);
|
||||
const find = (i: number): number => {
|
||||
while (parent[i] !== i) {
|
||||
parent[i] = parent[parent[i]];
|
||||
i = parent[i];
|
||||
}
|
||||
return i;
|
||||
};
|
||||
const join = (a: number, b: number): void => {
|
||||
parent[find(a)] = find(b);
|
||||
};
|
||||
for (let r = 0; r < rowCount; r += 1) {
|
||||
for (let c = 1; c < colCount; c += 1) {
|
||||
if (!covers(vLines[c], rowEdges[r + 1] + tol, rowEdges[r] - tol, tol))
|
||||
join(r * colCount + c - 1, r * colCount + c);
|
||||
}
|
||||
}
|
||||
for (let r = 1; r < rowCount; r += 1) {
|
||||
for (let c = 0; c < colCount; c += 1) {
|
||||
if (!covers(hLines[r], colEdges[c] + tol, colEdges[c + 1] - tol, tol))
|
||||
join((r - 1) * colCount + c, r * colCount + c);
|
||||
}
|
||||
}
|
||||
// 묶음이 꽉 찬 사각형이면 병합 — 아니면(ㄱ 자 등) 칸 따로
|
||||
const groups = new Map<number, number[]>();
|
||||
for (let i = 0; i < rowCount * colCount; i += 1) {
|
||||
const root = find(i);
|
||||
const members = groups.get(root);
|
||||
if (members) members.push(i);
|
||||
else groups.set(root, [i]);
|
||||
}
|
||||
const cells: TableCells = Array.from({ length: rowCount }, () =>
|
||||
Array.from({ length: colCount }, () => ({ text: '' }))
|
||||
);
|
||||
for (const members of groups.values()) {
|
||||
if (members.length < 2) continue;
|
||||
const rs = members.map((i) => Math.floor(i / colCount));
|
||||
const cs = members.map((i) => i % colCount);
|
||||
const [r0, r1, c0, c1] = [Math.min(...rs), Math.max(...rs), Math.min(...cs), Math.max(...cs)];
|
||||
if ((r1 - r0 + 1) * (c1 - c0 + 1) !== members.length) continue;
|
||||
cells[r0][c0] = { text: '', rowSpan: r1 - r0 + 1, colSpan: c1 - c0 + 1 };
|
||||
for (let r = r0; r <= r1; r += 1)
|
||||
for (let c = c0; c <= c1; c += 1) if (r !== r0 || c !== c0) cells[r][c] = null;
|
||||
}
|
||||
|
||||
// 글 → 칸(병합이면 앵커) · 한 칸 여러 글은 위 → 아래 · 왼 → 오른 차례로 띄어 이음
|
||||
const owners = cellOwners(cells, rowCount, colCount);
|
||||
const texts = new Map<string, Label[]>();
|
||||
let outside = 0;
|
||||
for (const label of labels) {
|
||||
const c = colEdges.findIndex(
|
||||
(x, i) => i < colCount && label.x >= x && label.x <= colEdges[i + 1]
|
||||
);
|
||||
const r = rowEdges.findIndex(
|
||||
(y, j) => j < rowCount && label.y <= y && label.y >= rowEdges[j + 1]
|
||||
);
|
||||
if (c < 0 || r < 0) {
|
||||
outside += 1;
|
||||
continue;
|
||||
}
|
||||
const key = owners[r][c] ?? `${r}:${c}`;
|
||||
const inCell = texts.get(key);
|
||||
if (inCell) inCell.push(label);
|
||||
else texts.set(key, [label]);
|
||||
}
|
||||
if (!labels.length || outside * 2 > labels.length) return null;
|
||||
const rows: DrawingCellValue[][] = cells.map((line) => line.map(() => ''));
|
||||
const merges: DrawingTableMerge[] = [];
|
||||
cells.forEach((line, r) =>
|
||||
line.forEach((cell, c) => {
|
||||
if (!cell) return;
|
||||
if ((cell.rowSpan ?? 1) > 1 || (cell.colSpan ?? 1) > 1)
|
||||
merges.push({ row: r, col: c, rowSpan: cell.rowSpan ?? 1, colSpan: cell.colSpan ?? 1 });
|
||||
const inCell = texts.get(`${r}:${c}`) ?? [];
|
||||
const sorted = sortReading(inCell);
|
||||
if (sorted.length) rows[r][c] = cellValue(sorted.map((label) => label.text.trim()).join(' '));
|
||||
})
|
||||
);
|
||||
return {
|
||||
kind: 'grid',
|
||||
rows,
|
||||
merges,
|
||||
colWidths: colEdges.slice(1).map((x, i) => x - colEdges[i]),
|
||||
rowHeights: rowEdges.slice(1).map((y, j) => rowEdges[j] - y),
|
||||
};
|
||||
}
|
||||
|
||||
/** 읽는 차례 — 줄(y, 글 높이 반 안이면 같은 줄) 위 → 아래 · 줄 안 왼 → 오른 */
|
||||
function textLines(labels: Label[]): Label[][] {
|
||||
const sorted = [...labels].sort((a, b) => b.y - a.y);
|
||||
const lines: { y: number; size: number; items: Label[] }[] = [];
|
||||
for (const label of sorted) {
|
||||
const last = lines[lines.length - 1];
|
||||
if (last && last.y - label.y <= Math.max(last.size, label.size) * 0.5) last.items.push(label);
|
||||
else lines.push({ y: label.y, size: label.size, items: [label] });
|
||||
}
|
||||
return lines.map((line) => line.items.sort((a, b) => a.x - b.x));
|
||||
}
|
||||
|
||||
const sortReading = (labels: Label[]): Label[] => textLines(labels).flat();
|
||||
|
||||
/** ③ 글 자리 폴백 — 줄마다 x 차례로 칸 */
|
||||
function fromText(labels: Label[]): DrawingTable {
|
||||
const lines = textLines(labels);
|
||||
const width = Math.max(...lines.map((line) => line.length));
|
||||
return {
|
||||
kind: 'text',
|
||||
rows: lines.map((line) => {
|
||||
const values: DrawingCellValue[] = line.map((label) => cellValue(label.text));
|
||||
while (values.length < width) values.push('');
|
||||
return values;
|
||||
}),
|
||||
merges: [],
|
||||
colWidths: [],
|
||||
rowHeights: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 고른 엔티티 JSON → 표 자료. 표 엔티티가 있으면 칸이 가장 많은 표 하나 · 없으면 선 격자 · 그래도 아니면 글 자리.
|
||||
* 표로 옮길 글이 하나도 없으면(빈 범위 · 글 없는 선) null.
|
||||
*/
|
||||
export function tableFromDrawing(entities: DrawingEntityJson[]): DrawingTable | null {
|
||||
const tables = entities.filter(
|
||||
(entity) => entity.type === 'Table' && entity.shapeData?.columnWidths?.length
|
||||
);
|
||||
if (tables.length) {
|
||||
const size = (entity: DrawingEntityJson) =>
|
||||
entity.shapeData.columnWidths.length * entity.shapeData.rowHeights.length;
|
||||
const biggest = tables.reduce((best, entity) => (size(entity) > size(best) ? entity : best));
|
||||
return fromTableEntity(biggest.shapeData);
|
||||
}
|
||||
const segs: Seg[] = [];
|
||||
const labels: Label[] = [];
|
||||
collect(entities, segs, labels);
|
||||
if (!labels.length) return null;
|
||||
return (segs.length ? fromGrid(segs, labels) : null) ?? fromText(labels);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* CAD → 부모 화면 표 보내기(M02 83장 · 도면 → 면적산출 한 번 옮기기 · 연동 아님). 짝 = `M02_MasterTemplete_AreaDrawing.ts`.
|
||||
* export-table-request 부모 [면적산출로 보내기] 단추 → 고른 범위를 표 자료로
|
||||
* export-table{table} 표 자료(`helpers/table-from-drawing.ts` 꼴) → 부모가 새 시트로
|
||||
* CAD 명령 TOAREA(「면적산출로 보내기」)도 같은 길. 보기 전용 도면에서도 됨(도면을 안 바꿈).
|
||||
* 고른 것이 없거나 옮길 글이 없으면 부모에 안 보내고 안내만.
|
||||
*/
|
||||
import { toast } from 'react-toastify';
|
||||
import type { DrawingEntityJson } from '../helpers/table-from-drawing.ts';
|
||||
import { type DrawingTable, tableFromDrawing } from '../helpers/table-from-drawing.ts';
|
||||
import { getSelectedEntities } from '../state.ts';
|
||||
|
||||
export const AISLO_EXPORT_TABLE_REQUEST_MESSAGE = 'aislo:b08:export-table-request';
|
||||
export const AISLO_EXPORT_TABLE_MESSAGE = 'aislo:b08:export-table';
|
||||
|
||||
const TEXT = {
|
||||
empty: '면적산출로 보낼 범위를 먼저 고르세요(누름 · 끌기).',
|
||||
noText: '고른 범위에 글이 없어 표로 옮길 것이 없습니다.',
|
||||
standalone: '면적산출 화면에서 연 도면에서만 보낼 수 있습니다.',
|
||||
};
|
||||
|
||||
type Notify = (type: string, payload: Record<string, unknown>) => void;
|
||||
|
||||
function notifyParent(type: string, payload: Record<string, unknown>): void {
|
||||
if (window.parent === window) return;
|
||||
window.parent.postMessage({ type, ...payload }, window.location.origin);
|
||||
}
|
||||
|
||||
/** 고른 범위 → 표 자료 · 고른 것이 없으면 'empty' · 글이 없으면 'no-text' */
|
||||
export async function selectionTable(): Promise<DrawingTable | 'empty' | 'no-text'> {
|
||||
const selected = getSelectedEntities();
|
||||
if (!selected.length) return 'empty';
|
||||
const json = (await Promise.all(selected.map((entity) => entity.toJson()))).filter(
|
||||
(entity): entity is NonNullable<typeof entity> => Boolean(entity)
|
||||
);
|
||||
return tableFromDrawing(json as DrawingEntityJson[]) ?? 'no-text';
|
||||
}
|
||||
|
||||
/** 고른 범위를 표로 만들어 부모에 보냄 — 보냈으면 true */
|
||||
export async function sendSelectionTable(notify: Notify = notifyParent): Promise<boolean> {
|
||||
const result = await selectionTable();
|
||||
if (result === 'empty') toast.info(TEXT.empty);
|
||||
else if (result === 'no-text') toast.info(TEXT.noText);
|
||||
else {
|
||||
notify(AISLO_EXPORT_TABLE_MESSAGE, { table: result });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** CAD 명령 TOAREA — 부모 없이 띄운 CAD(단독)면 안내만 */
|
||||
export function runSendSelectionTable(): string {
|
||||
if (window.parent === window) {
|
||||
toast.info(TEXT.standalone);
|
||||
return '부모 화면 없음';
|
||||
}
|
||||
void sendSelectionTable();
|
||||
return '면적산출로 보내기';
|
||||
}
|
||||
|
||||
/** 부모 요청 메시지면 처리하고 true */
|
||||
export async function handleTableExportMessage(
|
||||
data: unknown,
|
||||
notify: Notify = notifyParent
|
||||
): Promise<boolean> {
|
||||
const message = data as { type?: string } | null;
|
||||
if (
|
||||
!message ||
|
||||
typeof message !== 'object' ||
|
||||
message.type !== AISLO_EXPORT_TABLE_REQUEST_MESSAGE
|
||||
)
|
||||
return false;
|
||||
await sendSelectionTable(notify);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 부모 [면적산출로 보내기] 요청을 받음 — 같은 출처 · 부모 창만 */
|
||||
export function registerAisloTableExport(): void {
|
||||
window.addEventListener('message', (event: MessageEvent<unknown>) => {
|
||||
if (event.origin !== window.location.origin || event.source !== window.parent) return;
|
||||
void handleTableExportMessage(event.data);
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { queryEntitiesNearPoint } from './helpers/spatial-index';
|
||||
import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points';
|
||||
import { InputController } from './inputController/input-controller.ts';
|
||||
import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.ts';
|
||||
import { registerAisloTableExport } from './integration/aislo-table-export.ts';
|
||||
import { registerDxfClipPaste } from './integration/dxf-clip-paste.ts';
|
||||
import {
|
||||
getActiveToolActor,
|
||||
@@ -155,6 +156,7 @@ function initApplication() {
|
||||
setLayers(layers);
|
||||
setActiveLayerId(layers[0].id);
|
||||
registerAisloDrawingBridge();
|
||||
registerAisloTableExport(); // 고른 범위 → 면적산출 표(M02 83장)
|
||||
registerDxfClipPaste();
|
||||
registerCadDebugHook();
|
||||
registerAutoSave();
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import { CopyAction } from './copy-tool.ts';
|
||||
import { cloneKeepingLook } from './move-tool.helpers.ts';
|
||||
import { selectToolStateMachine } from './select-tool.ts';
|
||||
import type {
|
||||
AbsolutePointInputEvent,
|
||||
@@ -337,7 +338,7 @@ export const arrayToolStateMachine = createMachine(
|
||||
const y = distanceVector.y * i;
|
||||
resultEntities.push(
|
||||
...getSelectedEntities().map((entity) => {
|
||||
const clone = entity.clone();
|
||||
const clone = cloneKeepingLook(entity);
|
||||
clone.move(x, y);
|
||||
return clone;
|
||||
})
|
||||
@@ -346,7 +347,7 @@ export const arrayToolStateMachine = createMachine(
|
||||
const angleRad = context.angleStep * i * TO_RADIANS;
|
||||
resultEntities.push(
|
||||
...getSelectedEntities().map((entity) => {
|
||||
const clone = entity.clone();
|
||||
const clone = cloneKeepingLook(entity);
|
||||
clone.rotate(context.pivotPoint as Point, angleRad);
|
||||
return clone;
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { Entity } from '../entities/Entity';
|
||||
import { compact } from 'es-toolkit';
|
||||
import { LineEntity } from '../entities/LineEntity';
|
||||
import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts';
|
||||
import { moveEntities } from './move-tool.helpers';
|
||||
import { cloneKeepingLook, moveEntities } from './move-tool.helpers';
|
||||
|
||||
export interface CopyContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
@@ -208,7 +208,7 @@ export const copyToolStateMachine = createMachine(
|
||||
return {
|
||||
startPoint: context.startPoint,
|
||||
// Make a copy of the selected entities before copying them, so we can restore them when the user cancels the copy action
|
||||
originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())),
|
||||
originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity))),
|
||||
copiedEntities: selectedEntities,
|
||||
};
|
||||
}),
|
||||
@@ -221,7 +221,7 @@ export const copyToolStateMachine = createMachine(
|
||||
|
||||
// Copy the entities to the new location
|
||||
// Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be copied;
|
||||
const movedEntities = context.originalSelectedEntities.map((entity) => entity.clone());
|
||||
const movedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity));
|
||||
moveEntities(
|
||||
movedEntities,
|
||||
endPointTemp.x - context.startPoint.x,
|
||||
@@ -246,15 +246,15 @@ export const copyToolStateMachine = createMachine(
|
||||
|
||||
// Copy the entities one final time
|
||||
const currentEndPoint = (event as MouseClickEvent).worldMouseLocation;
|
||||
const copiedEntities = context.originalSelectedEntities.map((entity) => entity.clone());
|
||||
const copiedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity));
|
||||
moveEntities(
|
||||
copiedEntities,
|
||||
currentEndPoint.x - context.startPoint.x,
|
||||
currentEndPoint.y - context.startPoint.y
|
||||
);
|
||||
|
||||
// Switch the copied entities back from the ghost helper entities to the real entities
|
||||
addEntities([...context.originalSelectedEntities, ...copiedEntities], true);
|
||||
// 원본은 목록에 그대로 있음 — 복사본만 더함(원본까지 더하면 누를 때마다 원본이 겹겹이 쌓임).
|
||||
addEntities(copiedEntities, true);
|
||||
},
|
||||
[CopyAction.DESELECT_ENTITIES]: assign(() => {
|
||||
setGhostHelperEntities([]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { copyStyle } from './modify/modify.helpers';
|
||||
|
||||
/**
|
||||
* Move entities by the difference between the start and end points
|
||||
@@ -11,3 +12,17 @@ export function moveEntities(entities: Entity[], deltaX: number, deltaY: number)
|
||||
entity.move(deltaX, deltaY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 복제해도 색 · 층 · 선 꼴 · 투명도를 그대로 둔다(`clone()` 은 활성 층 · 흰색으로 새로 만듦).
|
||||
* `inPlace` = 옮기기 · 크기 · 회전처럼 같은 도형을 갈아 끼울 때 — id · 묶음까지 그대로.
|
||||
*/
|
||||
export function cloneKeepingLook(entity: Entity, inPlace = false): Entity {
|
||||
const copy = copyStyle(entity, entity.clone());
|
||||
copy.opacity = entity.opacity;
|
||||
if (inPlace) {
|
||||
copy.id = entity.id;
|
||||
copy.groupId = entity.groupId;
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { assign, createMachine, sendTo } from 'xstate';
|
||||
import { selectToolStateMachine } from './select-tool';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { compact } from 'es-toolkit';
|
||||
import { moveEntities } from './move-tool.helpers';
|
||||
import { cloneKeepingLook, moveEntities } from './move-tool.helpers';
|
||||
import { LineEntity } from '../entities/LineEntity';
|
||||
import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts';
|
||||
|
||||
@@ -211,7 +211,7 @@ export const moveToolStateMachine = createMachine(
|
||||
return {
|
||||
startPoint: context.startPoint,
|
||||
// Make a copy of the selected entities before moving them, so we can restore them when the user cancels the move action
|
||||
originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())),
|
||||
originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))),
|
||||
movedEntities: selectedEntities,
|
||||
};
|
||||
}),
|
||||
@@ -224,7 +224,7 @@ export const moveToolStateMachine = createMachine(
|
||||
|
||||
// Move the entities to the new location
|
||||
// Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be moved;
|
||||
const movedEntities = context.originalSelectedEntities.map((entity) => entity.clone());
|
||||
const movedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true));
|
||||
moveEntities(
|
||||
movedEntities,
|
||||
endPointTemp.x - context.startPoint.x,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { selectToolStateMachine } from './select-tool';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { compact } from 'es-toolkit';
|
||||
import { rotateEntities } from './rotate-tool.helpers';
|
||||
import { cloneKeepingLook } from './move-tool.helpers';
|
||||
|
||||
export interface RotateContext extends ToolContext {
|
||||
rotationOrigin: Point | null;
|
||||
@@ -231,7 +232,7 @@ export const rotateToolStateMachine = createMachine(
|
||||
return {
|
||||
...context,
|
||||
// Make a copy of the selected entities before rotating them, so we can restore them when the user cancels the rotate action
|
||||
originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())),
|
||||
originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))),
|
||||
};
|
||||
}),
|
||||
[RotateAction.DRAW_TEMP_ROTATE_ENTITIES]: ({ context, event }) => {
|
||||
@@ -245,7 +246,7 @@ export const rotateToolStateMachine = createMachine(
|
||||
|
||||
// Draw all selected entities according to rotate vector, so the user gets visual feedback of where the entities will be end up after rotating
|
||||
const rotatedEntities = compact(
|
||||
context.originalSelectedEntities.map((entity) => entity.clone())
|
||||
context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true))
|
||||
);
|
||||
rotateEntities(
|
||||
rotatedEntities,
|
||||
@@ -264,7 +265,7 @@ export const rotateToolStateMachine = createMachine(
|
||||
|
||||
// Rotate the entities one final time
|
||||
const rotatedEntities = compact(
|
||||
context.originalSelectedEntities.map((entity) => entity.clone())
|
||||
context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true))
|
||||
);
|
||||
rotateEntities(
|
||||
rotatedEntities,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { selectToolStateMachine } from './select-tool';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { compact } from 'es-toolkit';
|
||||
import { scaleEntities } from './scale-tool.helpers';
|
||||
import { cloneKeepingLook } from './move-tool.helpers';
|
||||
|
||||
export interface ScaleContext extends ToolContext {
|
||||
baseVectorStartPoint: Point | null;
|
||||
@@ -234,7 +235,7 @@ export const scaleToolStateMachine = createMachine(
|
||||
return {
|
||||
...context,
|
||||
// Make a copy of the selected entities before scaling them, so we can restore them when the user cancels the scale action
|
||||
originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())),
|
||||
originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))),
|
||||
};
|
||||
}),
|
||||
[ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => {
|
||||
@@ -248,7 +249,7 @@ export const scaleToolStateMachine = createMachine(
|
||||
|
||||
// Draw all selected entities according to scale vector, so the user gets visual feedback of where the entities will be end up after scaling
|
||||
const scaledEntities = compact(
|
||||
context.originalSelectedEntities.map((entity) => entity.clone())
|
||||
context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true))
|
||||
);
|
||||
scaleEntities(
|
||||
scaledEntities,
|
||||
@@ -267,7 +268,7 @@ export const scaleToolStateMachine = createMachine(
|
||||
|
||||
// Scale the entities one final time
|
||||
const scaledEntities = compact(
|
||||
context.originalSelectedEntities.map((entity) => entity.clone())
|
||||
context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true))
|
||||
);
|
||||
scaleEntities(
|
||||
scaledEntities,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Source.ts
|
||||
* 원문 보기(품셈 본문 절) — 출처 글로 서버가 찾음(`GET /m01/logic/source`) · 늦게 온 답은 버림
|
||||
* M01 로직 상세 「원문」 패널 · M02 일위대가 상세 「원문」 패널(47-4)이 같이 씀
|
||||
* M01 로직 상세 「원문」 패널 · M02 일위대가 상세 「원문」 패널(47-4)이 같이 씀 ·
|
||||
* 길은 부르는 쪽이 줌(85-4 · M02 = `/m02/logic-lib/source` + 제목 행 [원문 다운로드] pdf 통째)
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
@@ -16,6 +17,7 @@ const TEXT = {
|
||||
From: ["출처", "Source"],
|
||||
NoSource: ["출처 없음", "No source"],
|
||||
Near: ["{v} 절이 없어 위 절", "No {v} section — showing the parent"],
|
||||
Download: ["원문 다운로드", "Download source"],
|
||||
} as const;
|
||||
|
||||
const ts = (key: keyof typeof TEXT, v = ""): string =>
|
||||
@@ -70,15 +72,38 @@ export interface SourceView {
|
||||
show(src: string): void;
|
||||
}
|
||||
|
||||
export function createSourceView(): SourceView {
|
||||
/** 출처 글 → 원문 pdf 책(85-4) — 모르면 null */
|
||||
const bookOf = (src: string): string | null =>
|
||||
src.startsWith("건설품셈") ? "건설" : src.startsWith("산림품셈") ? "산림" : null;
|
||||
|
||||
export interface SourceViewOptions {
|
||||
/** 원문 절 길(기본 M01 `/m01/logic/source` — 시스템 관리자) */
|
||||
path?: string;
|
||||
/** 원문 pdf 길(`?book=`) — 주면 제목 행 오른쪽 [원문 다운로드] */
|
||||
pdf?: string;
|
||||
}
|
||||
|
||||
export function createSourceView(opts: SourceViewOptions = {}): SourceView {
|
||||
const body = el("div", { className: "m01lab-src__body" });
|
||||
const title = el("strong", { className: "m02-dfp__title" });
|
||||
const download = opts.pdf
|
||||
? (el("a", {
|
||||
className: "ui-btn ui-btn--ghost ui-btn--sm",
|
||||
text: ts("Download"),
|
||||
attrs: { download: "", "data-source-pdf": "" },
|
||||
}) as HTMLAnchorElement)
|
||||
: null;
|
||||
let shown: string | null = null;
|
||||
|
||||
const show = (src: string): void => {
|
||||
if (shown === src) return;
|
||||
shown = src;
|
||||
title.textContent = src || ts("NoSource");
|
||||
if (download) {
|
||||
const book = bookOf(src);
|
||||
download.hidden = !book;
|
||||
download.href = book ? `${API_BASE_URL}${opts.pdf}?${new URLSearchParams({ book })}` : "";
|
||||
}
|
||||
body.replaceChildren(el("p", { className: "m01-logic__muted", text: ts("Loading") }));
|
||||
const none = (): void =>
|
||||
body.replaceChildren(
|
||||
@@ -86,9 +111,12 @@ export function createSourceView(): SourceView {
|
||||
el("p", { className: "m01-logic__muted", text: `${ts("From")} · ${src || "—"}` }),
|
||||
);
|
||||
if (!src.trim()) return none();
|
||||
void fetch(`${API_BASE_URL}/m01/logic/source?${new URLSearchParams({ src })}`, {
|
||||
credentials: "include",
|
||||
})
|
||||
void fetch(
|
||||
`${API_BASE_URL}${opts.path ?? "/m01/logic/source"}?${new URLSearchParams({ src })}`,
|
||||
{
|
||||
credentials: "include",
|
||||
},
|
||||
)
|
||||
.then((r) => (r.ok ? (r.json() as Promise<{ found: Found[] }>) : { found: [] }))
|
||||
.catch(() => ({ found: [] as Found[] }))
|
||||
.then(({ found }) => {
|
||||
@@ -109,11 +137,19 @@ export function createSourceView(): SourceView {
|
||||
...f.blocks.map(blockNode),
|
||||
]),
|
||||
);
|
||||
requestAnimationFrame(() =>
|
||||
body.querySelector(".m01lab-src__hit")?.scrollIntoView({ block: "center" }),
|
||||
);
|
||||
// 강조 항을 패널 몸 가운데로 — 몸만 굴림(85-5 · scrollIntoView 는 메인 창까지 굴림)
|
||||
requestAnimationFrame(() => {
|
||||
const hit = body.querySelector<HTMLElement>(".m01lab-src__hit");
|
||||
const box = hit?.closest<HTMLElement>(".ui-side-panels__body");
|
||||
if (!hit || !box) return hit?.scrollIntoView({ block: "nearest" });
|
||||
const off = hit.getBoundingClientRect().top - box.getBoundingClientRect().top;
|
||||
box.scrollTop += off - (box.clientHeight - hit.offsetHeight) / 2;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return { root: el("div", { className: "m01lab-src", children: [title, body] }), show };
|
||||
const head = download
|
||||
? el("div", { className: "m02-dfp__head", children: [title, download] })
|
||||
: title;
|
||||
return { root: el("div", { className: "m01lab-src", children: [head, body] }), show };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
`shapeData.style.연결` 이 `연결` 과 같은 표를 면적 [저장] 때 칸 글로 다시 적음(자리 · 크기 그대로 ·
|
||||
칸 수가 바뀌면 열 너비 · 행 높이도 새로) · `값` = 마지막 칸 글(`{시트id!A1: 글}` · 다름 알림용).
|
||||
도면에 그 표가 없으면 기록은 그대로 둠. 층 = `store.at(층 폴더)` · 권한은 부르는 쪽.
|
||||
여러 장(82-3) — 연결표 줄 `장` = 표가 놓인 장id(1장 = 빈 값) · 맞춤은 모든 장(안 열린 장도 서버가).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,6 +17,7 @@ from typing import Any
|
||||
from common_util import common_util_spreadsheet as sheet_node
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.M02_Template_Layers import drawing_sheets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
LINKS = "연결표"
|
||||
@@ -40,7 +42,7 @@ def _style_link(entity: Any) -> Any:
|
||||
|
||||
|
||||
def refresh_drawing(column: str) -> list[str]:
|
||||
"""지금 층 그 열 도면의 연결표마다 칸 글 다시 · 마지막 값 갱신 → 칸을 간 표의 `연결` 목록."""
|
||||
"""지금 층 그 열 도면(모든 장)의 연결표마다 칸 글 다시 · 마지막 값 갱신 → 칸을 간 표의 `연결` 목록."""
|
||||
try:
|
||||
drawing, area = store._path("structure", column), store._path("area", column)
|
||||
except store.StoreError:
|
||||
@@ -61,7 +63,7 @@ def refresh_drawing(column: str) -> list[str]:
|
||||
except sheet_node.RecalcError as e:
|
||||
logger.warning("도면 표 맞춤 건너뜀 %s — %s", column, e)
|
||||
return []
|
||||
entities = (doc.get("도면") or {}).get("entities") or []
|
||||
entities = [e for _, sheet in drawing_sheets(doc) for e in sheet.get("entities") or []]
|
||||
done: list[str] = []
|
||||
for link, got in zip(links, results):
|
||||
if not got.get("ok"):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 구조물 라이브러리 나무의 [복사] → [붙여넣기].
|
||||
|
||||
붙일 폴더(`dest`) 아래 새 항목 — 대상 층 새 키(SC · SP) · 열 `본뜬` = 원본(층 · 주인 · 키) ·
|
||||
같은 이름은 「이름 (2)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더 (2)」).
|
||||
같은 이름은 「이름(1)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더(1)」).
|
||||
파일(도면 · 면적 · 입력 연결)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫.
|
||||
"""
|
||||
|
||||
@@ -40,13 +40,6 @@ def _items(doc: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [c for c in doc.get("열") or [] if tree.is_item(c)]
|
||||
|
||||
|
||||
def _unique(path: list[str], taken: list[list[str]]) -> list[str]:
|
||||
n, out = 2, path
|
||||
while out in taken:
|
||||
out, n = [*path[:-1], f"{path[-1]} ({n})"], n + 1
|
||||
return out
|
||||
|
||||
|
||||
def paste(
|
||||
source: Path,
|
||||
target: Path,
|
||||
@@ -67,6 +60,7 @@ def paste(
|
||||
taken_folders = tree.folders_of(dst)
|
||||
plan: list[tuple[str, list[str]]] = [] # (원본 키, 새 경로)
|
||||
new_folders: list[list[str]] = []
|
||||
renamed: list[str] = [] # 겹쳐서 번호 붙은 새 이름
|
||||
|
||||
def busy(path: list[str]) -> bool:
|
||||
paths = [*taken, *(p for _, p in plan)]
|
||||
@@ -78,16 +72,18 @@ def paste(
|
||||
found = next((p for p, k in src_items if k == key), None)
|
||||
if found is None:
|
||||
raise store.StoreError(404, f"없는 항목 「{key}」")
|
||||
plan.append((key, _unique([*dest, found[-1]], [*taken, *(p for _, p in plan)])))
|
||||
used = [*taken, *(p for _, p in plan)]
|
||||
path = tree.unique_path([*dest, found[-1]], lambda p: p in used)
|
||||
renamed += [path[-1]] if path[-1] != found[-1] else []
|
||||
plan.append((key, path))
|
||||
continue
|
||||
folder = list(one.get("경로") or [])
|
||||
if not folder or (
|
||||
folder not in src_folders and not any(_under(p, folder) for p, _ in src_items)
|
||||
):
|
||||
raise store.StoreError(404, f"없는 폴더 「{' '.join(folder)}」")
|
||||
top, n = [*dest, folder[-1]], 2
|
||||
while busy(top):
|
||||
top, n = [*dest, f"{folder[-1]} ({n})"], n + 1
|
||||
top = tree.unique_path([*dest, folder[-1]], busy)
|
||||
renamed += [top[-1]] if top[-1] != folder[-1] else []
|
||||
new_folders.append(top)
|
||||
new_folders += [[*top, *f[len(folder) :]] for f in src_folders if _under(f, folder)]
|
||||
plan += [(k, [*top, *p[len(folder) :]]) for p, k in src_items if _under(p, folder) and k]
|
||||
@@ -102,4 +98,7 @@ def paste(
|
||||
doc = got["문서"]
|
||||
doc["폴더"] = [*(doc.get("폴더") or []), *new_folders]
|
||||
store.write("table", store.STRUCTURE_TABLE, got["판"], doc)
|
||||
return {"op": "paste", "경로": dest, "열": made}
|
||||
note: dict[str, Any] = {"op": "paste", "경로": dest, "열": made}
|
||||
if renamed:
|
||||
note["바뀐이름"] = renamed
|
||||
return note
|
||||
|
||||
@@ -34,13 +34,10 @@ def heads_of(path: list[str], name: str | None) -> list[str]:
|
||||
|
||||
|
||||
def _free_path(doc: dict[str, Any], full: list[str]) -> list[str]:
|
||||
"""같은 경로 항목이 이미 있으면 마지막 이름을 「이름 (2)」 로
|
||||
"""같은 경로 항목이 이미 있으면 마지막 이름을 「이름(1)」 로
|
||||
(나무는 같은 경로 두 항목을 안 둠)."""
|
||||
taken = [tree.path_of(c) for c in doc.get("열", []) if tree.is_item(c)]
|
||||
out, n = list(full), 2
|
||||
while out in taken:
|
||||
out, n = [*full[:-1], f"{full[-1]} ({n})"], n + 1
|
||||
return out
|
||||
return tree.unique_path(list(full), lambda p: p in taken)
|
||||
|
||||
|
||||
def create_item(
|
||||
|
||||
@@ -280,8 +280,15 @@ def calc(
|
||||
snapshot = load_prices(prices)
|
||||
gaps = missing_inputs(snapshot, labor, values)
|
||||
blocked = {gap["로직"] for gap in gaps}
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
|
||||
|
||||
sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0))
|
||||
lines, stuck = [], []
|
||||
rates = { # 켜쌓기율(85-2) — 문서 계산과 같은 `layered`
|
||||
one.get("로직"): one.get("켜쌓기율")
|
||||
for one in (labor.get("줄") or {}).get("담은로직") or []
|
||||
if isinstance(one, dict)
|
||||
}
|
||||
for at, key in enumerate(_logic_keys(labor), start=1):
|
||||
head: dict[str, Any] = {"차례": at, "로직": key}
|
||||
if key in blocked:
|
||||
@@ -292,7 +299,7 @@ def calc(
|
||||
given = {name: one.get("값") for name, one in (values.get(key) or {}).items()}
|
||||
try:
|
||||
with mf.mp.choosing((picks or {}).get(key)):
|
||||
got = mf.run(snapshot, key, given)
|
||||
got = labor_doc.layered(mf.run(snapshot, key, given), rates.get(key))
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as error:
|
||||
lines.append({**head, "ok": False, "까닭": str(error) or type(error).__name__})
|
||||
stuck.append(key)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
|
||||
|
||||
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량}
|
||||
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?}
|
||||
| {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
|
||||
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
|
||||
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
|
||||
@@ -8,6 +8,8 @@
|
||||
금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은
|
||||
반영 없음 · 자재 기준 단가 = `master_material.element` 기본값 · 자재 줄에 값 열 다섯(`가격`) ·
|
||||
기준단가가 된 칸 이름(`기준칸`)도 실음(43-3).
|
||||
켜쌓기율(85-2) = 표 주문 「골쌓기 기준」 로직만 · % · 없음 = 100 · 그 로직 세부항목 줄 전부 수량 × 율
|
||||
(`layered` · `run_line` 한 곳 → 문서 · 프로젝트 사본 · 서고 같은 함수).
|
||||
참조 줄(단가승격 · 사전조합 · 50-3) = 기준이 가리킨 문서 1 단위 값(펼침 `M02_Labor_Ref.Expand`) ·
|
||||
분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만.
|
||||
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
|
||||
@@ -22,6 +24,7 @@ from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
@@ -105,6 +108,7 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
if not isinstance(given, dict):
|
||||
raise _bad(f"{where} · 입력값이 묶음 아님")
|
||||
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
|
||||
rate = _rate(one.get("켜쌓기율"), code, where)
|
||||
lines.append(
|
||||
{
|
||||
"로직": code,
|
||||
@@ -112,6 +116,7 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
"메모": str(memo) if memo else None,
|
||||
"입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")},
|
||||
"수량": _qty(one.get("수량"), where),
|
||||
**({"켜쌓기율": rate} if rate is not None else {}),
|
||||
}
|
||||
)
|
||||
mats, codes = [], set()
|
||||
@@ -157,6 +162,42 @@ def _alias(alias: Any, where: str) -> str | None:
|
||||
return alias or None
|
||||
|
||||
|
||||
def _rate(value: Any, code: str, where: str) -> int | float | None:
|
||||
"""켜쌓기율(85-2) — 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖 로직은 None(안 둠)."""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
rate = _number(value)
|
||||
if rate is None or not Decimal(0) < rate <= Decimal(200):
|
||||
raise _bad(f"{where} · 켜쌓기율은 0 초과 200 이하 %")
|
||||
if rate != rate.quantize(Decimal("0.1")):
|
||||
raise _bad(f"{where} · 켜쌓기율은 소수 한 자리까지")
|
||||
if rate == 100 or code not in labor_inputs.layered():
|
||||
return None
|
||||
return int(rate) if rate == rate.to_integral_value() else float(rate)
|
||||
|
||||
|
||||
def layered(got: dict[str, Any], rate: Any) -> dict[str, Any]:
|
||||
"""엔진 답 × 켜쌓기율(85-2) — 세부항목 줄 전부 수량 · 금액 · 비목 · 비목 합 · 계 · 단가는 그대로."""
|
||||
if rate in (None, 100) or "결과" in got:
|
||||
return got
|
||||
times = Decimal(str(rate)) / 100
|
||||
|
||||
def mul(value: Any) -> Any:
|
||||
return value * times if isinstance(value, Decimal) else value
|
||||
|
||||
lines = [
|
||||
{
|
||||
**x,
|
||||
"수량": mul(x.get("수량")),
|
||||
"금액": mul(x.get("금액")),
|
||||
"비목": {k: mul(v) for k, v in (x.get("비목") or {}).items()},
|
||||
}
|
||||
for x in got.get("줄") or []
|
||||
]
|
||||
sums = {k: mul(got[k]) for k in COSTS}
|
||||
return {**got, "줄": lines, **sums, "계": sum(sums.values())}
|
||||
|
||||
|
||||
def from_combo(key: str) -> dict[str, Any]:
|
||||
"""옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 —
|
||||
담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키).
|
||||
@@ -331,7 +372,7 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"분모": pill.get("단위"),
|
||||
"배": pill.get("배") or 1,
|
||||
}
|
||||
lines.append({**line, **run_line(whole, code, one.get("입력값"))})
|
||||
lines.append({**line, **run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"))})
|
||||
mats = [
|
||||
{
|
||||
"코드": one["코드"],
|
||||
@@ -344,13 +385,13 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return settle(_json({"줄": lines, "자재": mats}), doc, have)
|
||||
|
||||
|
||||
def run_line(whole: Any, code: str, given: Any) -> dict[str, Any]:
|
||||
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값) — `{기준, 까닭}` ·
|
||||
def run_line(whole: Any, code: str, given: Any, rate: Any = None) -> dict[str, Any]:
|
||||
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율) — `{기준, 까닭}` ·
|
||||
돈 아닌 로직은 `{결과, 기준: None}`(반영 없음)."""
|
||||
try:
|
||||
logic = whole.logic(code)
|
||||
auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None}
|
||||
got = mf.run(whole, code, {**auto, **m01._dec(given or {})})
|
||||
got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
|
||||
return {"기준": None, "까닭": str(e) or type(e).__name__}
|
||||
if "결과" in got:
|
||||
|
||||
@@ -15,6 +15,7 @@ from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
|
||||
_SPEC = ("이름", "설명", "단위", "고르기", "범위")
|
||||
_LAYERED = "골쌓기 기준" # 85-2 — 표 주문에 이 글이 있으면 켜쌓기율 대상 로직
|
||||
|
||||
|
||||
def _num(value: Any) -> Any:
|
||||
@@ -40,9 +41,32 @@ def _logics(codes: list[str]) -> tuple[list[dict], list[str]]:
|
||||
return found, missing
|
||||
|
||||
|
||||
def layered() -> set[str]:
|
||||
"""켜쌓기율(85-2) 대상 로직 키 — 표 `주` 에 「골쌓기 기준」 · 그 표 `용도.로직키`(자동 판정)."""
|
||||
out: set[str] = set()
|
||||
for data in m01.loaded()[0].values():
|
||||
for row in data.get("표") or []:
|
||||
if any(_LAYERED in str(note) for note in row.get("주") or []):
|
||||
out.update((row.get("용도") or {}).get("로직키") or [])
|
||||
return out
|
||||
|
||||
|
||||
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]
|
||||
layers = layered()
|
||||
out = []
|
||||
for logic in logics:
|
||||
auto = m01.mau.values(whole, logic)
|
||||
@@ -51,6 +75,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(
|
||||
{
|
||||
@@ -58,6 +85,7 @@ def _listed(logics: list[dict]) -> list[dict[str, Any]]:
|
||||
"이름": logic.get("이름"),
|
||||
"결과단위": logic.get("결과단위"),
|
||||
"출처": logic.get("출처"),
|
||||
"켜쌓기": logic.get("키") in layers,
|
||||
"입력": specs,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -152,7 +152,7 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]:
|
||||
이름=name or pill.get("이름") or line["로직"],
|
||||
단위=unit or pill.get("단위") or None,
|
||||
배=pill.get("배") or 1, # ponytail: 저장 때 적음 · 마스터 결과단위가 바뀌면 다시 저장
|
||||
담은로직=[{"로직": line["로직"], "입력값": line["입력값"]}],
|
||||
담은로직=[{k: line[k] for k in ("로직", "입력값", "켜쌓기율") if k in line}],
|
||||
**origin(line["로직"]),
|
||||
)
|
||||
else:
|
||||
@@ -200,6 +200,7 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]:
|
||||
head = {"참조": one["참조"]} if "참조" in one else {"로직": one["로직"]}
|
||||
if "로직" in one:
|
||||
head["입력값"] = one["입력값"]
|
||||
head.update({k: one[k] for k in ("켜쌓기율",) if k in one})
|
||||
lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]})
|
||||
at_ = here()
|
||||
me = {"층": at_[0], "주인": at_[1], "키": key}
|
||||
@@ -368,7 +369,8 @@ class Expand:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
line = (doc.get("담은로직") or [{}])[0]
|
||||
got = labor.run_line(self.whole, str(line.get("로직") or ""), line.get("입력값"))
|
||||
code = str(line.get("로직") or "")
|
||||
got = labor.run_line(self.whole, code, line.get("입력값"), line.get("켜쌓기율"))
|
||||
times = labor._number(doc.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
why = got.get("까닭") or (None if base else "돈 아닌 로직")
|
||||
@@ -395,7 +397,7 @@ class Expand:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
pill = pill or {}
|
||||
got = labor.run_line(self.whole, one["로직"], one.get("입력값"))
|
||||
got = labor.run_line(self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"))
|
||||
times = labor._number(pill.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
return {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -150,9 +150,13 @@ export interface StructureDoc {
|
||||
열: string;
|
||||
도번: string;
|
||||
도면: { entities: unknown[] } & Record<string, unknown>;
|
||||
/** 여러 장(82-2) — `도면` = 1장 · 더한 장 차례대로 · 장 도번 = 도번 + `-n`(둘째 = -2) */
|
||||
더한장?: StructureSheet[];
|
||||
/** 면적산출 → 도면 표 연결 기록(41 · `AreaToDrawing` · `M02_Area_Table`) — 표 style.연결 = 연결 · 값 = 마지막 칸 글 */
|
||||
연결표?: {
|
||||
연결: string;
|
||||
/** 표가 놓인 장id(82-3) — 1장 = 빈 값(옛 기록은 칸 없음 = 1장) */
|
||||
장?: string;
|
||||
시트: string;
|
||||
범위: string;
|
||||
글자크기: number;
|
||||
@@ -160,6 +164,13 @@ export interface StructureDoc {
|
||||
}[];
|
||||
}
|
||||
|
||||
/** 구조물 도면 더한 장 하나(82-2) */
|
||||
export interface StructureSheet {
|
||||
장id: string;
|
||||
이름: string;
|
||||
도면: { entities: unknown[] } & Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 시스템 층 구조물 도면 목록 */
|
||||
export const listStructures = async (): Promise<TemplateInfo[]> =>
|
||||
(await listTemplates("system", null)).filter((t) => t.종류 === "structure");
|
||||
@@ -175,6 +186,9 @@ export interface StructureNumbers {
|
||||
면적번: string;
|
||||
/** 일위대가 `SU-######`(39장) */
|
||||
일위대가번: string;
|
||||
/** 일위대가 합계(86-3) — 반영합 · 없으면 기준합(`합계기준` 참) · 계산값 없으면 칸 없음 */
|
||||
일위대가합계?: number;
|
||||
합계기준?: boolean;
|
||||
}
|
||||
|
||||
/** `{열 id: {도번, 면적번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */
|
||||
@@ -319,6 +333,8 @@ export interface ItemInput {
|
||||
설명?: string;
|
||||
단위?: string;
|
||||
고르기?: unknown[];
|
||||
/** 고르기 원문번호 → 보이는 글(85-1 · 기계 「이름 규격」 · 직종 이름) */
|
||||
고르기이름?: Record<string, string>;
|
||||
범위?: unknown;
|
||||
자동값?: unknown;
|
||||
}
|
||||
@@ -329,6 +345,8 @@ export interface ItemLogic {
|
||||
결과단위?: string;
|
||||
/** 품셈 출처 글(47-4 · 원문 패널) */
|
||||
출처?: string | null;
|
||||
/** 켜쌓기율 대상(85-2 · 표 주문 「골쌓기 기준」) */
|
||||
켜쌓기?: boolean;
|
||||
입력: ItemInput[];
|
||||
}
|
||||
|
||||
@@ -354,6 +372,8 @@ export interface TreeNote {
|
||||
/** 새 · 고친 항목 열 — 붙여넣기는 새 열 목록(PLAN 30-2) */
|
||||
열?: string | string[];
|
||||
지운항목?: string[];
|
||||
/** 같은 이름이 있어 「이름(1)」 꼴로 바뀐 새 이름 — 옮기기 · 붙여넣기(PLAN 86-2) */
|
||||
바뀐이름?: string[];
|
||||
}
|
||||
export interface TreeView {
|
||||
판: string;
|
||||
|
||||
@@ -1,19 +1,51 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_AreaDrawing.ts
|
||||
* 면적산출 상세 오른쪽 「도면」 패널 몸(PLAN 47-6) — 그 열 구조물 도면을 보기만(확대 · 이동).
|
||||
* 그리기는 구조물 조합 도면 카드 미리보기와 같은 길(`DrawingCard.mountDrawingView`).
|
||||
* 면적산출 상세 오른쪽 「도면」 패널 몸(PLAN 47-6) — 그 열 구조물 도면을 보기 · 고르기(고치기 없음).
|
||||
* 처음 펼 때 읽고 그림 — 접힌 채 iframe 을 띄우지 않음.
|
||||
* 83-3 — 머리 [면적산출로 보내기] = CAD 에 고른 범위 요청(`aislo:b08:export-table-request`) → 받은 표 자료
|
||||
* (`aislo:b08:export-table` · CAD `integration/aislo-table-export.ts`)를 `onTable` 로(새 시트 만들기는 부른 쪽).
|
||||
* CAD 명령 TOAREA 로 보낸 것도 같은 길. 고칠 수 없는 면적(읽기 층)이면 단추 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { readTemplate, type Layer, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { mountDrawingView } from "./M02_MasterTemplete_DrawingCard";
|
||||
import { mountDrawingTemplate, type DrawingTemplateDoc } from "./M02_MasterTemplete_Drawing";
|
||||
import type { DrawingTable } from "./M02_MasterTemplete_AreaFromDrawing";
|
||||
|
||||
const TEXT = {
|
||||
loading: "도면 불러오는 중…",
|
||||
none: "이 열은 구조물 도면이 없음",
|
||||
send: "면적산출로 보내기",
|
||||
sendTip:
|
||||
"도면에서 고른 범위(표 · 선으로 그린 표 · 글)를 면적산출 새 시트로 한 번 옮김 — 연동 아님 · 누름 · 끌기로 고름",
|
||||
};
|
||||
|
||||
export const EXPORT_TABLE_REQUEST = "aislo:b08:export-table-request";
|
||||
export const EXPORT_TABLE = "aislo:b08:export-table";
|
||||
|
||||
/** 고르기만 — 제목줄 · 리본 · 패널 · 상태줄 숨김(카드 고치기 갈래와 같은 숨김 · 명령 안내 줄은 남김) */
|
||||
const HIDE =
|
||||
".cad-titlebar, .cad-ribbon, .cad-inspector, .cad-statusbar, .cad-quick-access" +
|
||||
" { display: none !important; }";
|
||||
|
||||
function hideChrome(frame: HTMLIFrameElement): void {
|
||||
const apply = (): void => {
|
||||
const inner = frame.contentDocument;
|
||||
if (!inner?.head || inner.getElementById("m02-area-hide")) return;
|
||||
const style = inner.createElement("style");
|
||||
style.id = "m02-area-hide";
|
||||
style.textContent = HIDE;
|
||||
inner.head.append(style);
|
||||
};
|
||||
frame.addEventListener("load", apply);
|
||||
apply();
|
||||
}
|
||||
|
||||
export interface AreaDrawingOptions {
|
||||
/** 받은 표 자료 — 없으면(읽기 층) 보내기 단추 없음 */
|
||||
onTable?: (table: DrawingTable) => void;
|
||||
}
|
||||
|
||||
export interface AreaDrawing {
|
||||
root: HTMLElement;
|
||||
/** 패널이 펼쳐짐 — 처음 한 번만 읽고 그림 */
|
||||
@@ -21,13 +53,37 @@ export interface AreaDrawing {
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function createAreaDrawing(layer: Layer, column: string): AreaDrawing {
|
||||
export function createAreaDrawing(
|
||||
layer: Layer,
|
||||
column: string,
|
||||
opts: AreaDrawingOptions = {},
|
||||
): AreaDrawing {
|
||||
const root = el("div", { className: "m02-apanel__drawing" });
|
||||
const stage = el("div", { className: "m02-apanel__drawstage" });
|
||||
let view: { destroy: () => void } | null = null;
|
||||
let frame: HTMLIFrameElement | null = null;
|
||||
let started = false;
|
||||
let dead = false;
|
||||
const note = (text: string): void =>
|
||||
root.replaceChildren(el("p", { className: "m02-apanel__note", text }));
|
||||
stage.replaceChildren(el("p", { className: "m02-apanel__note", text }));
|
||||
|
||||
const send = createButton({ label: TEXT.send, size: "sm", variant: "ghost" });
|
||||
send.title = TEXT.sendTip;
|
||||
send.dataset.areaFromDrawing = "";
|
||||
send.disabled = true; // 도면이 서면 켬
|
||||
send.addEventListener("click", () =>
|
||||
frame?.contentWindow?.postMessage({ type: EXPORT_TABLE_REQUEST }, window.location.origin),
|
||||
);
|
||||
if (opts.onTable) root.append(el("div", { className: "m02-apanel__drawbar", children: [send] }));
|
||||
root.append(stage);
|
||||
|
||||
const onMessage = (event: MessageEvent): void => {
|
||||
if (!frame || event.source !== frame.contentWindow) return;
|
||||
if (event.origin !== window.location.origin) return;
|
||||
const data = event.data as { type?: string; table?: DrawingTable } | null;
|
||||
if (data?.type === EXPORT_TABLE && data.table && opts.onTable) opts.onTable(data.table);
|
||||
};
|
||||
window.addEventListener("message", onMessage);
|
||||
|
||||
const show = (): void => {
|
||||
if (started) return;
|
||||
@@ -38,8 +94,21 @@ export function createAreaDrawing(layer: Layer, column: string): AreaDrawing {
|
||||
if (dead) return;
|
||||
const doc = got.문서 as StructureDoc | null;
|
||||
if (!doc?.도면) return note(TEXT.none);
|
||||
root.replaceChildren();
|
||||
view = mountDrawingView(root, doc, { layer, projectId: null }, column);
|
||||
stage.replaceChildren();
|
||||
// 보기 전용 + 고르기(누름 · 끌기) — 미리보기(끌기 = 화면 옮기기)는 고르기가 꺼져 있어 안 씀
|
||||
view = mountDrawingTemplate(stage, doc.도면 as unknown as DrawingTemplateDoc, {
|
||||
readOnly: true,
|
||||
name: `preview:${column}`,
|
||||
layer,
|
||||
panelsCollapsed: true,
|
||||
originMarker: true,
|
||||
noArea: true,
|
||||
frame: true,
|
||||
noImport: true,
|
||||
});
|
||||
frame = stage.querySelector("iframe");
|
||||
if (frame) hideChrome(frame);
|
||||
send.disabled = !frame;
|
||||
})
|
||||
.catch(() => !dead && note(TEXT.none));
|
||||
};
|
||||
@@ -49,6 +118,7 @@ export function createAreaDrawing(layer: Layer, column: string): AreaDrawing {
|
||||
show,
|
||||
destroy: () => {
|
||||
dead = true;
|
||||
window.removeEventListener("message", onMessage);
|
||||
view?.destroy();
|
||||
root.remove();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_AreaFromDrawing.ts
|
||||
* 도면 → 면적산출 한 번 옮기기(PLAN 83-3 · 연동 아님) — 오른쪽 도면 패널(`AreaDrawing`)이 받은 표 자료를
|
||||
* 면적 통합문서의 새 시트(「도면에서 n」)로 만듦. 기존 시트는 안 덮음 · 병합 · 열 폭 · 행 높이 반영.
|
||||
* 표 자료 꼴 = CAD `helpers/table-from-drawing.ts` 의 `DrawingTable`(rows · merges · colWidths · rowHeights · 도면 mm).
|
||||
* 스프레드시트 부품은 안 고침 — 시트 자료만 만들고 싣기는 공개 손잡이 `addSheet`(되돌리기 한 번 · onChange → 화면 고침 표시).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import type { Cell, Sheet, Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
const TEXT = {
|
||||
done: (name: string) =>
|
||||
`도면 표를 「${name}」 시트로 옮김 — [저장]을 눌러야 저장 · 되돌리기 Ctrl+Z`,
|
||||
loose: "표 선을 못 찾아 글 자리로 줄 · 칸을 나눔 — 칸이 맞는지 확인",
|
||||
noHandle: "스프레드시트가 시트 더하기를 아직 못 함 — 옮기지 않음",
|
||||
noSheet: "면적산출 시트가 열려 있지 않음",
|
||||
};
|
||||
|
||||
/** CAD 가 보낸 표 자료(`DrawingTable`) */
|
||||
export interface DrawingTable {
|
||||
kind: "table" | "grid" | "text";
|
||||
rows: (string | number)[][];
|
||||
merges: { row: number; col: number; rowSpan: number; colSpan: number }[];
|
||||
colWidths: number[];
|
||||
rowHeights: number[];
|
||||
}
|
||||
|
||||
export const SHEET_PREFIX = "도면에서";
|
||||
|
||||
/** 도면 mm → 엑셀 열 폭(글자 단위 · 1글자 ≈ 7 px ≈ 1.85 mm) · 행 높이(pt = mm × 72 / 25.4) */
|
||||
const CHAR_PER_MM = 0.54;
|
||||
const PT_PER_MM = 72 / 25.4;
|
||||
const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));
|
||||
const round1 = (v: number): number => Math.round(v * 10) / 10;
|
||||
|
||||
/** 0 → `A` · 27 → `AB`(`spreadsheet_address.colName` 과 같은 규칙) */
|
||||
function colName(c: number): string {
|
||||
let s = "";
|
||||
for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26))
|
||||
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
|
||||
return s;
|
||||
}
|
||||
const a1 = (r: number, c: number): string => `${colName(c)}${r + 1}`;
|
||||
|
||||
/** 「도면에서 n」 — 안 쓴 가장 작은 n */
|
||||
export function nextSheetName(book: Pick<Workbook, "시트">): string {
|
||||
const used = new Set(book.시트.map((s) => s.이름));
|
||||
let n = 1;
|
||||
while (used.has(`${SHEET_PREFIX} ${n}`)) n += 1;
|
||||
return `${SHEET_PREFIX} ${n}`;
|
||||
}
|
||||
|
||||
/** 안 쓴 시트 id(`s1` …) */
|
||||
function nextSheetId(book: Pick<Workbook, "시트">): string {
|
||||
const used = new Set(book.시트.map((s) => s.id));
|
||||
let n = book.시트.length + 1;
|
||||
while (used.has(`s${n}`)) n += 1;
|
||||
return `s${n}`;
|
||||
}
|
||||
|
||||
/** 표 자료 → 새 시트(이름 · id 는 문서에서 안 쓴 것) */
|
||||
export function tableToSheet(table: DrawingTable, book: Pick<Workbook, "시트">): Sheet {
|
||||
const 칸: Record<string, Cell> = {};
|
||||
table.rows.forEach((line, r) =>
|
||||
line.forEach((value, c) => {
|
||||
if (value === "" || value === null || value === undefined) return;
|
||||
칸[a1(r, c)] = { 값: value };
|
||||
}),
|
||||
);
|
||||
const 병합 = table.merges
|
||||
.filter((m) => m.rowSpan > 1 || m.colSpan > 1)
|
||||
.map((m) => `${a1(m.row, m.col)}:${a1(m.row + m.rowSpan - 1, m.col + m.colSpan - 1)}`);
|
||||
const sheet: Sheet = { id: nextSheetId(book), 이름: nextSheetName(book), 칸 };
|
||||
if (병합.length) sheet.병합 = 병합;
|
||||
if (table.colWidths.length) {
|
||||
sheet.열 = {};
|
||||
table.colWidths.forEach((w, c) => {
|
||||
if (w > 0) sheet.열![colName(c)] = { 폭: round1(clamp(w * CHAR_PER_MM, 2, 60)) };
|
||||
});
|
||||
}
|
||||
if (table.rowHeights.length) {
|
||||
sheet.행 = {};
|
||||
table.rowHeights.forEach((h, r) => {
|
||||
if (h > 0) sheet.행![String(r + 1)] = { 높이: round1(clamp(h * PT_PER_MM, 12, 100)) };
|
||||
});
|
||||
}
|
||||
return sheet;
|
||||
}
|
||||
|
||||
/** 스프레드시트 공개 손잡이 중 쓰는 것 — 시트 더하기(되돌리기 한 번 · onChange · 탭 줄은 부품 몫) */
|
||||
export interface AddSheetHandle {
|
||||
getDoc(): Workbook;
|
||||
addSheet?: (sheet: Sheet, opts?: { activate?: boolean }) => unknown;
|
||||
}
|
||||
|
||||
/** 받은 표 → 면적 새 시트(활성) · 알림 — 옮긴 시트 이름 · 못 하면 null */
|
||||
export function importDrawingTable(
|
||||
handle: AddSheetHandle | null,
|
||||
table: DrawingTable,
|
||||
): string | null {
|
||||
if (!handle?.addSheet) {
|
||||
showToast(handle ? TEXT.noHandle : TEXT.noSheet, "error");
|
||||
return null;
|
||||
}
|
||||
const sheet = tableToSheet(table, handle.getDoc());
|
||||
handle.addSheet(sheet, { activate: true });
|
||||
showToast(TEXT.done(sheet.이름), "success");
|
||||
if (table.kind === "text") showToast(TEXT.loose, "info");
|
||||
return sheet.이름;
|
||||
}
|
||||
@@ -62,3 +62,27 @@
|
||||
height: min(60vh, 520px);
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
/* 도면 패널 머리 [면적산출로 보내기](83-3) — 아래 도면 칸이 남은 높이를 다 씀 */
|
||||
.m02-apanel__drawing:has(> .m02-apanel__drawstage) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.m02-apanel__drawbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-apanel__drawstage {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m02-apanel__drawstage .cad-host {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* M02_MasterTemplete_AreaPanel.ts
|
||||
* 면적산출 상세 오른쪽 패널(PLAN 39-4 D2 · D6 · 47-6) — 틀 = 공용 오른쪽 접는 패널(하나만 열림 · 폭 공유)
|
||||
* · 「일위대가」 = 그 열 일위대가의 담은 로직 + 자재목록(기준 · 반영) · 몸 = 일위대가 보기 갈래(`Labor_View`)
|
||||
* · 「도면」 = 그 열 구조물 도면 보기만(`AreaDrawing` · 처음 펼 때 그림)
|
||||
* · 「도면」 = 그 열 구조물 도면 보기 · 고르기(`AreaDrawing` · 처음 펼 때 그림) · [면적산출로 보내기] = 새 시트(83-3)
|
||||
* · 줄 누름 = 칸 지정 모달(`AreaLink` · 그 줄 알약 키를 면적 칸에 잇기 · 풀기)
|
||||
* · 시트 칸 값 · 연결이 바뀌면 `refresh` — 받은 기준에 바로 곱함 · 칸 주소도 넘김(서버 안 부름 · `Labor_View.setQty`)
|
||||
* ========================================================================== */
|
||||
@@ -12,6 +12,7 @@ import { createSidePanels } from "@ui/ui_template_side_panels";
|
||||
import type { LinkItem, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { createAreaDrawing } from "./M02_MasterTemplete_AreaDrawing";
|
||||
import { importDrawingTable } from "./M02_MasterTemplete_AreaFromDrawing";
|
||||
import { openAreaLink } from "./M02_MasterTemplete_AreaLink";
|
||||
import { createLaborView, type LaborViewRow } from "./M02_MasterTemplete_Labor_View";
|
||||
import "./M02_MasterTemplete_AreaPanel.css";
|
||||
@@ -95,10 +96,14 @@ export function buildAreaPanel(opts: AreaPanelOptions): AreaPanel {
|
||||
});
|
||||
};
|
||||
const view = createLaborView(openLink);
|
||||
const drawing = createAreaDrawing(opts.layer, opts.column);
|
||||
// 도면 → 면적 새 시트(83-3 · 한 번 · 연동 아님) — 고칠 수 없는 면적이면 보내기 단추 없음
|
||||
const drawing = createAreaDrawing(opts.layer, opts.column, {
|
||||
onTable: opts.readOnly ? undefined : (table) => void importDrawingTable(opts.sheet(), table),
|
||||
});
|
||||
const panels = createSidePanels({
|
||||
prefKey: "m02-area-panels",
|
||||
defaultWidth: DEFAULT_WIDTH,
|
||||
follow: false, // 85-5 — 면적 상세 고정 높이 칸을 채움 · 메인 스크롤과 따로
|
||||
panels: [
|
||||
{ id: "labor", title: TEXT.labor, body: view.root },
|
||||
{ id: "drawing", title: TEXT.drawing, body: drawing.root },
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* 표 style.연결 = 연결 · 도면에서 지운 표는 기록에서 뺌(면적 [저장] 때 서버가 칸 글 · 값을 다시 적음 · 41-1)
|
||||
* ③ 도면을 열 때 — 연결표마다 지금 면적 값과 마지막 값이 다르면 알림 한 줄 + [업데이트](`updateTable`)
|
||||
* ④ 표 여러 개 — id 마다 한 줄
|
||||
* ⑤ 여러 장(82-3) — 놓을 장 = 지금 탭 · 새 T번호 = 모든 장에서 안 쓴 것 · 연결표 줄 `장` = 표가 놓인 장id
|
||||
* (1장 = 빈 값) · 안 실린 장 표 갱신 = 도면 손잡이(받아 둔 장) · 면적 [저장] 때는 서버가 모든 장
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -108,6 +110,12 @@ async function fetchTable(layer: Layer, column: string, spec: LinkSpec): Promise
|
||||
return data as AreaTable;
|
||||
}
|
||||
|
||||
/** 장마다 `[장id, entities]` — 1장 = 빈 값(서버 `drawing_sheets` 와 같음) */
|
||||
const sheetsOf = (doc: StructureDoc): [string, unknown[]][] => [
|
||||
["", doc.도면?.entities ?? []],
|
||||
...(doc.더한장 ?? []).map((s): [string, unknown[]] => [s.장id, s.도면?.entities ?? []]),
|
||||
];
|
||||
|
||||
const isTable = (e: unknown): e is TableEntityJson =>
|
||||
!!e && typeof e === "object" && (e as { type?: unknown }).type === "Table";
|
||||
const linkOf = (e: TableEntityJson): string | null => {
|
||||
@@ -241,7 +249,8 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
|
||||
/** 다음 연결 id — 기록 · 도면 표지에 없는 가장 작은 T번호 */
|
||||
const nextId = (doc: StructureDoc, taken: Set<string>): string => {
|
||||
for (const row of doc.연결표 ?? []) taken.add(row.연결);
|
||||
for (const e of doc.도면?.entities ?? []) if (isTable(e)) taken.add(linkOf(e) ?? "");
|
||||
for (const [, entities] of sheetsOf(doc))
|
||||
for (const e of entities) if (isTable(e)) taken.add(linkOf(e) ?? "");
|
||||
let n = 1;
|
||||
while (taken.has(`T${n}`)) n += 1;
|
||||
return `T${n}`;
|
||||
@@ -323,12 +332,14 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
|
||||
waiting = false;
|
||||
const old = new Map((doc.연결표 ?? []).map((row) => [row.연결, row]));
|
||||
const rows: TableLink[] = [];
|
||||
for (const e of doc.도면?.entities ?? []) {
|
||||
if (!isTable(e)) continue;
|
||||
const id = linkOf(e);
|
||||
const row = id ? (old.get(id) ?? fresh.get(id)) : undefined;
|
||||
if (!id || !row || rows.some((r) => r.연결 === id)) continue;
|
||||
rows.push({ ...row, 값: latest.get(id) ?? row.값 });
|
||||
for (const [sheet, entities] of sheetsOf(doc)) {
|
||||
for (const e of entities) {
|
||||
if (!isTable(e)) continue;
|
||||
const id = linkOf(e);
|
||||
const row = id ? (old.get(id) ?? fresh.get(id)) : undefined;
|
||||
if (!id || !row || rows.some((r) => r.연결 === id)) continue;
|
||||
rows.push({ ...row, 장: sheet, 값: latest.get(id) ?? row.값 });
|
||||
}
|
||||
}
|
||||
// 기록이 없던 도면은 칸을 안 만듦(옛 문서 그대로)
|
||||
return rows.length || doc.연결표 ? { ...doc, 연결표: rows } : doc;
|
||||
|
||||
@@ -86,3 +86,27 @@
|
||||
max-width: calc(100vw - 16px);
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
/* 카드 미리보기 「장 n」(82-2) — 1장만 보이는 여러 장 도면 */
|
||||
.m02-drawing:has(> .m02-drawing__sheets) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.m02-drawing__sheets {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
padding: 1px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 탭 줄(82-1) — 그림 칸에 붙임 */
|
||||
.m02-drawing > .cad-tabs {
|
||||
margin-bottom: calc(-1 * var(--spacing-8));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import "./M02_MasterTemplete_Drawing.css";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host";
|
||||
import { createCadTabs, type CadTab, type CadTabs } from "@ui/cad_host/cad_host_tabs";
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
|
||||
@@ -51,13 +52,19 @@ export interface DrawingTemplateOptions {
|
||||
frame?: boolean;
|
||||
/** 파일 불러오기(📂) 단추를 안 둠 — 집계표 카드 미리보기(불러오기는 도면 상세에서만). */
|
||||
noImport?: boolean;
|
||||
/** 장id(82-1) — 자동백업 칸을 장마다 나눔(1장 = 빈 값 · 옛 칸 그대로) */
|
||||
sheet?: string;
|
||||
/** 장 도번(82-2) — 얹은 도각 `{{도면번호}}` 자리 */
|
||||
frameNumber?: string;
|
||||
/** 탭 줄(82-1) — 그림 칸 바로 위에 놓음 */
|
||||
tabs?: HTMLElement;
|
||||
}
|
||||
|
||||
const RECOVERY_PREFIX = "OPEN_WEB_CAD__RECOVERY__";
|
||||
|
||||
/** 자동백업 칸 이름 — 층(과 프로젝트)을 붙여 같은 이름의 양식끼리 안 겹치게 한다. */
|
||||
function recoveryScopeOf(options: DrawingTemplateOptions): string {
|
||||
const name = options.name ?? "drawing";
|
||||
function recoveryScopeOf(options: DrawingTemplateOptions, sheet = ""): string {
|
||||
const name = `${options.name ?? "drawing"}${sheet ? `#${sheet}` : ""}`;
|
||||
if (!options.layer) return `m02:${name}`;
|
||||
const project = options.layer === "project" ? `:${options.projectId ?? ""}` : "";
|
||||
return `m02:${options.layer}${project}:${name}`;
|
||||
@@ -84,6 +91,8 @@ export interface DrawingTemplateHandle {
|
||||
/** 표 놓기 · 칸 갈기(41장) — `cad_host` 그대로 */
|
||||
placeTable: (entity: Record<string, unknown>, link: string) => void;
|
||||
updateTable: (link: string, cells: unknown[][]) => Promise<number>;
|
||||
/** 다른 장을 같은 CAD 에 실음(82-1) — 백업 칸 · 도각 도번도 그 장 것으로 */
|
||||
loadSheet: (drawing: DrawingTemplateDoc, sheet: string, frameNumber?: string) => void;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
@@ -98,18 +107,22 @@ interface StructureFrame {
|
||||
layer: Record<string, unknown> | null;
|
||||
}
|
||||
const BLANK_LAYER = { id: "기본", name: "기본", isVisible: true, isLocked: false };
|
||||
let framePromise: Promise<StructureFrame | null> | null = null;
|
||||
const framePromises = new Map<string, Promise<StructureFrame | null>>();
|
||||
|
||||
/** 구조물 도각 — 한 번만 받아 돌려씀 · 못 받으면 도각 없이 */
|
||||
function fetchFrame(): Promise<StructureFrame | null> {
|
||||
framePromise ??= fetch(`${API_BASE_URL}/m02/structure-frame`, { credentials: "include" })
|
||||
.then((response) => (response.ok ? (response.json() as Promise<StructureFrame>) : null))
|
||||
.catch(() => null)
|
||||
.then((frame) => {
|
||||
if (!frame?.layer) framePromise = null; // 다음에 다시 받음
|
||||
return frame?.layer ? frame : null;
|
||||
});
|
||||
return framePromise;
|
||||
/** 구조물 도각 — 장 도번마다 한 번만 받아 돌려씀(`{{도면번호}}` · 82-2) · 못 받으면 도각 없이 */
|
||||
function fetchFrame(number = ""): Promise<StructureFrame | null> {
|
||||
const query = number ? `?number=${encodeURIComponent(number)}` : "";
|
||||
const got =
|
||||
framePromises.get(number) ??
|
||||
fetch(`${API_BASE_URL}/m02/structure-frame${query}`, { credentials: "include" })
|
||||
.then((response) => (response.ok ? (response.json() as Promise<StructureFrame>) : null))
|
||||
.catch(() => null)
|
||||
.then((frame) => {
|
||||
if (!frame?.layer) framePromises.delete(number); // 다음에 다시 받음
|
||||
return frame?.layer ? frame : null;
|
||||
});
|
||||
framePromises.set(number, got);
|
||||
return got;
|
||||
}
|
||||
|
||||
/** 도각 층을 뺀 도면 — 구조물 엔티티 · 층만 남음 */
|
||||
@@ -147,8 +160,9 @@ export function mountDrawingTemplate(
|
||||
): DrawingTemplateHandle {
|
||||
const readOnly = options.readOnly === true;
|
||||
const noArea = options.noArea === true;
|
||||
const recoveryScope = recoveryScopeOf(options);
|
||||
migrateOldRecovery(options, recoveryScope);
|
||||
let recoveryScope = recoveryScopeOf(options, options.sheet);
|
||||
let frameNumber = options.frameNumber ?? "";
|
||||
migrateOldRecovery(options, recoveryScopeOf(options));
|
||||
let base: DrawingTemplateDoc = doc;
|
||||
|
||||
const root = document.createElement("div");
|
||||
@@ -238,7 +252,7 @@ export function mountDrawingTemplate(
|
||||
const load = async (drawing: DrawingTemplateDoc): Promise<void> => {
|
||||
const mine = ++loadSeq;
|
||||
cad.beginLoading();
|
||||
const frame = options.frame ? await fetchFrame() : null;
|
||||
const frame = options.frame ? await fetchFrame(frameNumber) : null;
|
||||
if (mine !== loadSeq) return; // 그새 다른 도면을 실음
|
||||
const own = withoutFrame(drawing);
|
||||
const shown = frame?.layer
|
||||
@@ -319,11 +333,12 @@ export function mountDrawingTemplate(
|
||||
|
||||
// 한 줄 — 페이지가 머리 칸(headerSlot)을 주면 거기, 아니면 그림 칸 위에 작게.
|
||||
toolbar.append(...(noArea ? [] : [areaButton]), actions);
|
||||
const tabs = options.tabs ? [options.tabs] : [];
|
||||
if (options.headerSlot) {
|
||||
toolbar.classList.add("m02-drawing__toolbar--slot");
|
||||
options.headerSlot.append(toolbar);
|
||||
root.append(cad.element);
|
||||
} else root.append(...(noArea && !canImport ? [] : [toolbar]), cad.element);
|
||||
root.append(...tabs, cad.element);
|
||||
} else root.append(...(noArea && !canImport ? [] : [toolbar]), ...tabs, cad.element);
|
||||
container.append(root);
|
||||
void load(doc);
|
||||
|
||||
@@ -331,6 +346,12 @@ export function mountDrawingTemplate(
|
||||
getDoc,
|
||||
placeTable: cad.placeTable,
|
||||
updateTable: cad.updateTable,
|
||||
loadSheet: (drawing, sheet, number) => {
|
||||
recoveryScope = recoveryScopeOf(options, sheet);
|
||||
frameNumber = number ?? "";
|
||||
base = drawing;
|
||||
void load(drawing);
|
||||
},
|
||||
destroy: () => {
|
||||
areaModal?.close();
|
||||
cad.destroy();
|
||||
@@ -339,3 +360,161 @@ export function mountDrawingTemplate(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* --- 구조물 도면 여러 장(82-1 · 82-2) — 한 파일 `도면`(1장) + `더한장[]` · 탭 줄 = 공용 `cad_host_tabs` --- */
|
||||
|
||||
/** 구조물 도면 더한 장 — `M02_MasterTemplete_Api_Fetch` `StructureSheet` 와 같은 꼴 */
|
||||
interface SheetDoc {
|
||||
장id: string;
|
||||
이름: string;
|
||||
도면: DrawingTemplateDoc;
|
||||
}
|
||||
/** 구조물 도면 문서에서 쓰는 칸 */
|
||||
interface SheetsDoc {
|
||||
도번?: string;
|
||||
도면: DrawingTemplateDoc;
|
||||
더한장?: SheetDoc[];
|
||||
}
|
||||
|
||||
const FIRST_TAB = "#1"; // 1장 탭 id — 장id 는 「#」 를 안 씀(저장은 빈 값)
|
||||
const FIRST_NAME = "1장";
|
||||
|
||||
/** 장 도번 — 1장 = 도번 · n 째 장 = 도번-n(SD-000010-2) */
|
||||
export const sheetNumber = (number: string | undefined, index: number): string =>
|
||||
!number ? "" : index === 0 ? number : `${number}-${index + 1}`;
|
||||
|
||||
/** 표 칸 갈기 — 안 실린 장(받아 둔 편집본)에서 · 크기는 칸 수가 바뀔 때만(서버 `refresh_drawing` 과 같음) */
|
||||
function updateStashedTables(
|
||||
drawing: DrawingTemplateDoc,
|
||||
link: string,
|
||||
cells: unknown[][],
|
||||
): number {
|
||||
let count = 0;
|
||||
for (const entity of drawing.entities) {
|
||||
const shape = (entity as { type?: string; shapeData?: Record<string, unknown> }).shapeData;
|
||||
const style = shape?.style as Record<string, unknown> | undefined;
|
||||
if (entity.type !== "Table" || !shape || style?.연결 !== link) continue;
|
||||
shape.cells = cells;
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export interface StructureSheetsHandle extends Omit<DrawingTemplateHandle, "getDoc" | "loadSheet"> {
|
||||
/** `{도면, 더한장?}` — 부르는 쪽이 읽은 문서에 얹음 */
|
||||
getDoc: () => Promise<{ 도면: DrawingTemplateDoc; 더한장?: SheetDoc[] }>;
|
||||
/** 지금 탭 장id(1장 = 빈 값) */
|
||||
activeSheet: () => string;
|
||||
}
|
||||
|
||||
/** 구조물 도면 상세 — 탭 줄 + 한 CAD · 탭 바꾸면 지금 장을 받아 두고 다음 장을 실음 */
|
||||
export function mountStructureSheets(
|
||||
container: HTMLElement,
|
||||
doc: SheetsDoc,
|
||||
options: DrawingTemplateOptions = {},
|
||||
): StructureSheetsHandle {
|
||||
const readOnly = options.readOnly === true;
|
||||
const more = doc.더한장 ?? [];
|
||||
let cadDirty = false;
|
||||
let listDirty = false;
|
||||
const report = (): void => options.onChanged?.(cadDirty || listDirty || tabs.anyDirty());
|
||||
const numberOf = (id: string): string =>
|
||||
sheetNumber(
|
||||
doc.도번,
|
||||
tabs.tabs().findIndex((tab) => tab.id === id),
|
||||
);
|
||||
|
||||
const tabs: CadTabs<DrawingTemplateDoc> = createCadTabs<DrawingTemplateDoc>({
|
||||
features: readOnly ? {} : { add: true, rename: true, remove: true, reorder: true },
|
||||
capture: async () => ({ drawing: await drawing.getDoc(), dirty: cadDirty }),
|
||||
show: (tab) => {
|
||||
cadDirty = false;
|
||||
drawing.loadSheet(
|
||||
tab.drawing ?? { entities: [], layers: [BLANK_LAYER] },
|
||||
tab.id === FIRST_TAB ? "" : tab.id,
|
||||
numberOf(tab.id),
|
||||
);
|
||||
report();
|
||||
},
|
||||
create: (): CadTab<DrawingTemplateDoc> => {
|
||||
const names = new Set(tabs.tabs().map((tab) => tab.name));
|
||||
let n = tabs.tabs().length + 1;
|
||||
while (names.has(`${n}장`)) n += 1;
|
||||
const id = `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
return {
|
||||
id,
|
||||
name: `${n}장`,
|
||||
drawing: { format: 6, entities: [], layers: [{ ...BLANK_LAYER }] },
|
||||
};
|
||||
},
|
||||
onChange: () => {
|
||||
listDirty = true;
|
||||
retitle();
|
||||
report();
|
||||
},
|
||||
});
|
||||
const retitle = (): void => {
|
||||
for (const tab of tabs.tabs()) tab.title = numberOf(tab.id);
|
||||
};
|
||||
tabs.set(
|
||||
[
|
||||
{ id: FIRST_TAB, name: FIRST_NAME, drawing: doc.도면, fixed: true },
|
||||
...more.map((sheet) => ({ id: sheet.장id, name: sheet.이름, drawing: sheet.도면 })),
|
||||
],
|
||||
FIRST_TAB,
|
||||
);
|
||||
retitle();
|
||||
tabs.set(tabs.tabs(), FIRST_TAB); // 풍선 글을 그림
|
||||
if (readOnly && !more.length) tabs.element.hidden = true; // 볼 장이 하나 — 줄 없음
|
||||
|
||||
const drawing = mountDrawingTemplate(container, doc.도면, {
|
||||
...options,
|
||||
sheet: "",
|
||||
frameNumber: doc.도번,
|
||||
tabs: tabs.element,
|
||||
onChanged: (dirty) => {
|
||||
cadDirty = dirty;
|
||||
if (dirty && !readOnly) tabs.markDirty();
|
||||
report();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
getDoc: async () => {
|
||||
const all = await tabs.collect();
|
||||
const first = all.find((tab) => tab.id === FIRST_TAB);
|
||||
const rest = all
|
||||
.filter((tab) => tab.id !== FIRST_TAB)
|
||||
.map((tab) => ({
|
||||
장id: tab.id,
|
||||
이름: tab.name,
|
||||
도면: tab.drawing ?? { format: 6, entities: [], layers: [{ ...BLANK_LAYER }] },
|
||||
}));
|
||||
listDirty = false;
|
||||
tabs.markClean(undefined, true);
|
||||
return {
|
||||
도면: first?.drawing ?? doc.도면,
|
||||
...(rest.length || doc.더한장 ? { 더한장: rest } : {}),
|
||||
};
|
||||
},
|
||||
activeSheet: () => {
|
||||
const id = tabs.active()?.id ?? FIRST_TAB;
|
||||
return id === FIRST_TAB ? "" : id;
|
||||
},
|
||||
placeTable: drawing.placeTable,
|
||||
// 실린 장은 CAD · 안 실린 장은 받아 둔 편집본에서(82-3)
|
||||
updateTable: async (link, cells) => {
|
||||
const now = tabs.active();
|
||||
let count = 0;
|
||||
for (const tab of tabs.tabs()) {
|
||||
if (tab === now || !tab.drawing) continue;
|
||||
const changed = updateStashedTables(tab.drawing, link, cells);
|
||||
if (changed) tab.dirty = true;
|
||||
count += changed;
|
||||
}
|
||||
if (count) report();
|
||||
return count + (await drawing.updateTable(link, cells).catch(() => 0));
|
||||
},
|
||||
destroy: drawing.destroy,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,11 +82,21 @@ function mountStructure(
|
||||
originMarker: true, // 좌표 0,0 기준점 십자
|
||||
noArea: true, // 작도 영역 칸 없음
|
||||
frame: true, // 도각 = 보여주기만 · 외곽 좌하단 (0,0)(56장)
|
||||
frameNumber: doc.도번, // 도각 `{{도면번호}}` = 1장 도번(82-2)
|
||||
noImport: true, // 파일 불러오기는 도면 상세에서만(47-2)
|
||||
...hooks,
|
||||
});
|
||||
const frame = host.querySelector("iframe");
|
||||
if (frame) hideChrome(frame, edit ? HIDE_EDIT : HIDE_VIEW);
|
||||
// 여러 장(82-2) — 미리보기는 1장 · 장 수만 알림(다른 장은 도면 상세 탭에서)
|
||||
const sheets = 1 + (doc.더한장?.length ?? 0);
|
||||
if (sheets > 1) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "m02-drawing__sheets";
|
||||
badge.textContent = `장 ${sheets}`;
|
||||
badge.title = `1장을 보는 중 · 모두 ${sheets}장 — 다른 장은 도면 상세에서`;
|
||||
host.querySelector(".m02-drawing")?.append(badge);
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -23,7 +24,6 @@ import {
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
type StructureDoc,
|
||||
type StructureNumbers,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
cardShell,
|
||||
@@ -47,7 +47,7 @@ export type EditKind = ItemPart;
|
||||
/** 카드 종류 — 열 문서 셋 + 일위대가(39장 · 보기 갈래 카드 · 고침은 일위대가 상세) */
|
||||
export type CardKind = EditKind | "labor";
|
||||
export type SheetKind = Exclude<EditKind, "structure">;
|
||||
export const PART_FIELD: Record<CardKind, keyof StructureNumbers> = {
|
||||
export const PART_FIELD: Record<CardKind, "도번" | "면적번" | "일위대가번"> = {
|
||||
structure: "도번",
|
||||
labor: "일위대가번",
|
||||
area: "면적번",
|
||||
@@ -68,7 +68,7 @@ const TEXT = {
|
||||
noFieldNumber: "{value} 없음",
|
||||
noSheet: "스프레드시트를 못 띄움 — {value}",
|
||||
readFailed: "{value} 파일을 못 읽음 — {why}",
|
||||
discard: "카드에 저장 안 한 고침이 있음 — 버리고 다른 항목을 열까요?",
|
||||
discard: "카드에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
|
||||
pills: "알약",
|
||||
pillsTip: "아래 알약 줄 보임 · 숨김",
|
||||
take: "가져오기",
|
||||
@@ -99,6 +99,8 @@ export interface ItemCardsOptions {
|
||||
extraCards?: HTMLElement[];
|
||||
/** 카드 가운데 하나라도 저장 안 한 고침이 생김 · 없어짐(PLAN 31-4 · 31-7) */
|
||||
onDirty?: (dirty: boolean) => void;
|
||||
/** 카드 [저장] 이 끝남 — 번호 · 일위대가 합계를 다시 읽는 자리(86-3) */
|
||||
onSaved?: () => void;
|
||||
/** 면적산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */
|
||||
sheetEdit?: SheetEditOptions;
|
||||
/** 「가져오기」(39-5 · 43-9b) — 다른 항목의 이 칸을 골라 덮어씀 · 빈 카드도 같음 · 없으면 단추 없음 */
|
||||
@@ -179,6 +181,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);
|
||||
|
||||
/** 카드 틀 하나 — 제목 옆 = ? · 제목 줄 오른쪽 = [저장] · [〇〇 수정](빈 단추 한 모양) · 부제 = 번호 · 이름 */
|
||||
@@ -191,7 +196,14 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
const no = el("span", { className: "m02-struct__no" });
|
||||
const name = el("span", { className: "m02-struct__title" });
|
||||
const shell = cardShell({ title, body, sub: [no, name] });
|
||||
const saver = saveControl(shell, save, () => opts.onDirty?.(anyDirty()));
|
||||
const saver = saveControl(
|
||||
shell,
|
||||
async () => {
|
||||
await save();
|
||||
opts.onSaved?.();
|
||||
},
|
||||
() => opts.onDirty?.(anyDirty()),
|
||||
);
|
||||
const help = helpButton(shell, title, CARD_HELP[kind]); // 제목 바로 옆(47-1)
|
||||
const edit = createButton({
|
||||
label: TEXT.edit,
|
||||
@@ -461,7 +473,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 +547,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);
|
||||
|
||||
@@ -90,7 +90,33 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 순서 칸 2행(51-14) — 위 [위로] [아래로] / 아래 [옵션]·[수정] [삭제] · 줄 사이도 단추 묶음 간격 */
|
||||
/* 순서(85-3) — 손잡이 + ▲▼ 작은 단추 · 끌어 놓을 줄 위 선 */
|
||||
.m02-labrow__order {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.m02-labrow__arrow.ui-btn {
|
||||
min-width: 0;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.m02-labrow__grip {
|
||||
padding: 0 4px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.m02-labrow__row.is-dragover td {
|
||||
box-shadow: inset 0 2px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
/* 순서 칸 2행(51-14) — 위 손잡이 ▲▼ / 아래 [옵션]·[수정] [삭제] · 줄 사이도 단추 묶음 간격 */
|
||||
.m02-labrow__tools {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -129,6 +129,8 @@ export interface LaborRow {
|
||||
메모: string | null;
|
||||
입력값: Record<string, string | number>;
|
||||
수량: LaborQty;
|
||||
/** 켜쌓기 적용율 %(85-2 · 대상 로직만 · 없음 = 100) */
|
||||
켜쌓기율?: number | null;
|
||||
}
|
||||
export interface LaborMat {
|
||||
코드: string;
|
||||
@@ -164,6 +166,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
|
||||
입력값: r.입력값 ?? {},
|
||||
수량: r.수량 ?? null,
|
||||
...(r.참조 ? { 참조: r.참조 } : {}),
|
||||
...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}),
|
||||
})),
|
||||
자재: (d.자재 ?? []).map((m) => ({
|
||||
코드: m.코드,
|
||||
|
||||
@@ -131,12 +131,17 @@ export function mountLaborDetail(
|
||||
});
|
||||
|
||||
/** 원문 패널(47-4 · 카드 갈래엔 없음) — 열려 있을 때만 찾음 · 고른 줄 없으면 안내 한 줄 */
|
||||
const source = createSourceView();
|
||||
// 85-4 — M02 길(로그인 사용자 모두) · 제목 행 [원문 다운로드]
|
||||
const source = createSourceView({
|
||||
path: "/m02/logic-lib/source",
|
||||
pdf: "/m02/logic-lib/source-pdf",
|
||||
});
|
||||
const sourceHost = el("div", { attrs: { "data-labor-source": "" } });
|
||||
const side = opt.card
|
||||
? null
|
||||
: createSidePanels({
|
||||
prefKey: "m02-labor-side",
|
||||
follow: false, // 85-5 — 상세 고정 높이 칸을 채움 · 메인 스크롤과 따로
|
||||
panels: [{ id: "source", title: tc("Labor_Source"), body: sourceHost }],
|
||||
onChange: () => showSource(),
|
||||
});
|
||||
@@ -186,7 +191,7 @@ export function mountLaborDetail(
|
||||
option: openRow,
|
||||
ref: openRef,
|
||||
qty: openQty,
|
||||
...(opt.card ? {} : { move }),
|
||||
move: reorder,
|
||||
remove: removeRow,
|
||||
alias: setAlias,
|
||||
}
|
||||
@@ -310,10 +315,10 @@ export function mountLaborDetail(
|
||||
}
|
||||
};
|
||||
|
||||
function move(i: number, d: number): void {
|
||||
const j = i + d;
|
||||
if (j < 0 || j >= cur.담은로직.length) return;
|
||||
[cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]];
|
||||
/** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 끌기 = 놓은 줄 자리) */
|
||||
function reorder(from: number, to: number): void {
|
||||
if (from === to || to < 0 || to >= cur.담은로직.length) return;
|
||||
cur.담은로직.splice(to, 0, ...cur.담은로직.splice(from, 1));
|
||||
changed();
|
||||
}
|
||||
/** 줄 고름(47-5) — 하이라이트 + 하위 행(51-2 · 표만 다시 그림 · 다이어그램 안 다시 그림) + 원문 패널(47-4) ·
|
||||
@@ -347,13 +352,18 @@ export function mountLaborDetail(
|
||||
logic,
|
||||
values: row.입력값,
|
||||
memo: row.메모,
|
||||
rate: row.켜쌓기율 ?? null,
|
||||
readOnly: !edit,
|
||||
done: (next) => {
|
||||
const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
|
||||
const same = JSON.stringify(values) === JSON.stringify(row.입력값);
|
||||
const same =
|
||||
JSON.stringify(values) === JSON.stringify(row.입력값) &&
|
||||
next.켜쌓기율 === (row.켜쌓기율 ?? null);
|
||||
row.입력값 = values;
|
||||
row.메모 = next.메모;
|
||||
changed(same); // 입력값이 그대로면(메모만) 서버 안 부름
|
||||
if (next.켜쌓기율 === null) delete row.켜쌓기율;
|
||||
else row.켜쌓기율 = next.켜쌓기율;
|
||||
changed(same); // 입력값 · 켜쌓기율이 그대로면(메모만) 서버 안 부름
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -39,6 +39,17 @@ function footer(
|
||||
export interface LaborInputResult {
|
||||
입력값: LaborValues;
|
||||
메모: string | null;
|
||||
/** 켜쌓기 적용율 %(85-2 · 대상 로직만 · 빈 칸 · 100 = null) */
|
||||
켜쌓기율: number | null;
|
||||
}
|
||||
|
||||
/** 켜쌓기율 칸 글 → 값 — 빈 글 · 100 = null · 0 초과 200 이하 · 소수 한 자리 밖 = NaN */
|
||||
export function layerRate(raw: string): number | null {
|
||||
if (raw.trim() === "") return null;
|
||||
const v = Number(raw);
|
||||
const ok =
|
||||
Number.isFinite(v) && v > 0 && v <= 200 && Math.abs(v * 10 - Math.round(v * 10)) < 1e-9;
|
||||
return !ok ? NaN : v === 100 ? null : v;
|
||||
}
|
||||
|
||||
export interface LaborInputOptions {
|
||||
@@ -48,6 +59,8 @@ export interface LaborInputOptions {
|
||||
logic: ItemLogic | null;
|
||||
values: LaborValues;
|
||||
memo: string | null;
|
||||
/** 켜쌓기율(85-2) — 로직이 대상일 때만 칸 */
|
||||
rate?: number | null;
|
||||
readOnly: boolean;
|
||||
done: (next: LaborInputResult) => void;
|
||||
/** 머리 한 줄 알림(51-14 · 서고 원본 고침) */
|
||||
@@ -100,9 +113,27 @@ export function openLaborInput(opt: LaborInputOptions): void {
|
||||
}) as HTMLInputElement;
|
||||
memo.value = opt.memo ?? "";
|
||||
memo.readOnly = opt.readOnly;
|
||||
const rate = opt.logic?.켜쌓기
|
||||
? (el("input", {
|
||||
className: "ui-input m02-inputval__control",
|
||||
attrs: {
|
||||
type: "number",
|
||||
min: "0.1",
|
||||
max: "200",
|
||||
step: "0.1",
|
||||
placeholder: "100",
|
||||
"data-labor-layer": "",
|
||||
},
|
||||
}) as HTMLInputElement)
|
||||
: null;
|
||||
if (rate) {
|
||||
rate.value = opt.rate == null ? "" : String(opt.rate);
|
||||
rate.readOnly = opt.readOnly;
|
||||
}
|
||||
const ok = (): void => {
|
||||
if (bad.size) return showToast(tc("In_Bad"), "error");
|
||||
opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null });
|
||||
const layer = rate ? layerRate(rate.value) : null;
|
||||
if (bad.size || Number.isNaN(layer)) return showToast(tc("In_Bad"), "error");
|
||||
opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null, 켜쌓기율: layer });
|
||||
close();
|
||||
};
|
||||
body.append(
|
||||
@@ -129,6 +160,21 @@ export function openLaborInput(opt: LaborInputOptions): void {
|
||||
...(rows.length
|
||||
? rows
|
||||
: [el("p", { className: "m02-labin__muted", text: tc("In_None") })]),
|
||||
...(rate
|
||||
? [
|
||||
el("div", {
|
||||
className: "m02-inputval__row",
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-inputval__name",
|
||||
text: tc("In_Layer"),
|
||||
attrs: { title: tc("In_LayerHint") },
|
||||
}),
|
||||
rate,
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
el("div", {
|
||||
className: "m02-inputval__row",
|
||||
children: [
|
||||
|
||||
@@ -56,12 +56,15 @@ export async function openRefEdit(opt: RefEditOptions): Promise<void> {
|
||||
logic,
|
||||
values: row.입력값,
|
||||
memo: row.메모,
|
||||
rate: row.켜쌓기율 ?? null,
|
||||
readOnly: opt.readOnly,
|
||||
notice,
|
||||
okLabel: tc("Save"),
|
||||
done: (next) => {
|
||||
row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
|
||||
row.메모 = next.메모;
|
||||
if (next.켜쌓기율 === null) delete row.켜쌓기율;
|
||||
else row.켜쌓기율 = next.켜쌓기율;
|
||||
void save(doc)
|
||||
.catch((error) => fail(error, "Lib_SaveFailed"))
|
||||
.finally(opt.closed);
|
||||
|
||||
@@ -148,6 +148,9 @@ function moneyCell(
|
||||
}
|
||||
|
||||
/** 이름 칸 글마다 title = 전체 글(한 줄 말줄임 · 45-7) */
|
||||
/** 끌고 있는 줄 차례(85-3) — 손잡이 dragstart ~ drop */
|
||||
let dragFrom: number | null = null;
|
||||
|
||||
const titled = (td: HTMLElement): HTMLElement => {
|
||||
for (const c of td.children) c.setAttribute("title", c.textContent ?? "");
|
||||
return td;
|
||||
@@ -179,7 +182,8 @@ export interface RowHandlers {
|
||||
ref?: (i: number) => void;
|
||||
/** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */
|
||||
qty?: (i: number) => void;
|
||||
move?: (i: number, d: number) => void;
|
||||
/** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 손잡이 끌기 = 놓은 줄 자리) */
|
||||
move?: (from: number, to: number) => void;
|
||||
remove?: (i: number) => void;
|
||||
/** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */
|
||||
alias?: (i: number, value: string | null) => void;
|
||||
@@ -307,7 +311,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
// 참조 줄 — 키 · 이름 · 단위 = 가리킨 문서 것(calc 답) · 넣을 값 없음(승격 때 고정)
|
||||
const unit = row.참조 ? (line?.분모 ?? "") : (logic?.결과단위 ?? "");
|
||||
const values = valueLine(logic?.입력 ?? [], row.입력값);
|
||||
const note = values.text;
|
||||
const layer =
|
||||
row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : "";
|
||||
const note = [values.text, layer].filter(Boolean).join(" · ");
|
||||
const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null);
|
||||
const small = (
|
||||
label: string,
|
||||
@@ -322,6 +328,32 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
run();
|
||||
},
|
||||
});
|
||||
// 순서(85-3) — ▲▼ 작은 단추(글은 title · aria) + 손잡이 끌기(줄 전체는 안 끎 · 별칭 칸 글 고르기)
|
||||
const arrow = (mark: string, label: string, to: number): HTMLElement => {
|
||||
const b = small(mark, () => move?.(i, to));
|
||||
b.classList.add("m02-labrow__arrow");
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", label);
|
||||
if (to < 0 || to >= data.rows.length) (b as HTMLButtonElement).disabled = true;
|
||||
return b;
|
||||
};
|
||||
const grip = (): HTMLElement => {
|
||||
const g = el("span", {
|
||||
className: "m02-labrow__grip",
|
||||
text: "⠿",
|
||||
attrs: { draggable: "true", title: tc("Labor_Drag"), "data-labor-grip": String(i) },
|
||||
});
|
||||
g.addEventListener("click", (e) => e.stopPropagation());
|
||||
g.addEventListener("dragstart", (ev) => {
|
||||
dragFrom = i;
|
||||
ev.dataTransfer?.setData("text/plain", String(i));
|
||||
if (ev.dataTransfer) ev.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
g.addEventListener("dragend", () => {
|
||||
dragFrom = null;
|
||||
});
|
||||
return g;
|
||||
};
|
||||
const sel = data.selected === id ? " is-selected" : "";
|
||||
const tr = el("tr", {
|
||||
className: (on ? "m02-labrow__row is-open" : "m02-labrow__row") + sel,
|
||||
@@ -371,10 +403,14 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
children: [
|
||||
...(move
|
||||
? [
|
||||
group([
|
||||
small(tc("Up"), () => move(i, -1)),
|
||||
small(tc("Down"), () => move(i, 1)),
|
||||
]),
|
||||
el("div", {
|
||||
className: "m02-labrow__order",
|
||||
children: [
|
||||
grip(),
|
||||
arrow("▲", tc("Up"), i - 1),
|
||||
arrow("▼", tc("Down"), i + 1),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
group([
|
||||
@@ -401,6 +437,21 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
],
|
||||
});
|
||||
if (on) tr.addEventListener("click", () => on.open(i));
|
||||
if (move) {
|
||||
tr.addEventListener("dragover", (ev) => {
|
||||
if (dragFrom === null || dragFrom === i) return;
|
||||
ev.preventDefault();
|
||||
tr.classList.add("is-dragover");
|
||||
});
|
||||
tr.addEventListener("dragleave", () => tr.classList.remove("is-dragover"));
|
||||
tr.addEventListener("drop", (ev) => {
|
||||
ev.preventDefault();
|
||||
tr.classList.remove("is-dragover");
|
||||
const from = dragFrom;
|
||||
dragFrom = null;
|
||||
if (from !== null && from !== i) move(from, i);
|
||||
});
|
||||
}
|
||||
const subs = sel ? subRows(line, wait, tools) : [];
|
||||
if (!note) return [tr, ...subs];
|
||||
// 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐
|
||||
|
||||
@@ -33,6 +33,7 @@ const TEXT = {
|
||||
Col_Move: ["순서", "Move"],
|
||||
Up: ["위로", "Up"],
|
||||
Down: ["아래로", "Down"],
|
||||
Labor_Drag: ["끌어 순서 바꿈", "Drag to reorder"],
|
||||
Remove: ["삭제", "Delete"],
|
||||
Labor_Option: ["옵션", "Options"],
|
||||
Labor_Edit: ["수정", "Edit"],
|
||||
@@ -112,6 +113,12 @@ const TEXT = {
|
||||
In_Title: ["넣을 값 — {value}", "Inputs — {value}"],
|
||||
In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"],
|
||||
In_Memo: ["메모", "Memo"],
|
||||
In_Layer: ["켜쌓기 적용율 (%)", "Layered-course rate (%)"],
|
||||
In_LayerHint: [
|
||||
"0 초과 200 이하 · 소수 한 자리 · 빈 칸 = 100",
|
||||
"0 < rate ≤ 200 · 1 decimal · empty = 100",
|
||||
],
|
||||
Labor_Layer: ["켜쌓기 {n}%", "Layered {n}%"],
|
||||
In_QtyCell: ["면적산출 칸", "Area sheet cell"],
|
||||
In_QtyEmpty: ["빈 칸", "empty cell"],
|
||||
In_QtyHand: ["손 입력", "Manual"],
|
||||
|
||||
@@ -7,14 +7,19 @@
|
||||
담을 로직 고르기 · 일위대가 입력 모달이 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은
|
||||
`/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음.
|
||||
39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌).
|
||||
85-4 — 원문 패널 읽기(`/logic-lib/source` · M01 길과 같은 몸) · 원문 pdf 통째
|
||||
(`/logic-lib/source-pdf` · 책 이름만 받음 · 경로 안 받음)도 로그인만.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_http_cache import cached_file_response
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
|
||||
@@ -100,3 +105,27 @@ def get_logic_auto(key: str) -> dict:
|
||||
def get_logic_inputs(keys: str = "") -> dict:
|
||||
"""일위대가 입력 모달 — 로직 키들(쉼표)의 입력 목록 + 자동값(`M02_Labor_Inputs`)."""
|
||||
return _read(labor_inputs.logic_inputs, keys.split(","))
|
||||
|
||||
|
||||
#: 원문 pdf(85-4) — 책 이름 → 파일(쪽 자르기 없음)
|
||||
SOURCE_PDF = {
|
||||
"건설": make.BODY / "건설공사_표준품셈" / "2026년_건설공사_표준품셈.pdf",
|
||||
"산림": make.BODY / "산림_표준품셈" / "첨부" / "(산림청고시 제2025-82호) 산림사업 표준품셈.pdf",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/logic-lib/source", dependencies=[Depends(verify_session)])
|
||||
def get_logic_source(src: str) -> dict:
|
||||
"""원문 패널 — 출처 글로 찾은 품셈 본문 절(M01 `/logic/source` 와 같은 몸 · 로그인만)."""
|
||||
return _read(make.source, src)
|
||||
|
||||
|
||||
@router.get("/logic-lib/source-pdf", dependencies=[Depends(verify_session)])
|
||||
def get_source_pdf(request: Request, book: str) -> Response:
|
||||
"""원문 pdf 통째 내려받기 — `book` = 건설 | 산림(그 밖 400)."""
|
||||
path = SOURCE_PDF.get(book)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=400, detail="책은 건설 · 산림 중 하나")
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="원문 pdf 없음")
|
||||
return cached_file_response(request, path, "application/pdf", filename=path.name)
|
||||
|
||||
@@ -104,15 +104,19 @@ def _on_frame_layer(entity: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def structure_frame() -> dict:
|
||||
"""구조물 도면에 얹는 도각 — B07 시스템 양식 1:1 · 외곽 좌하단 (0,0) · 보여주기 층."""
|
||||
def structure_frame(number: str | None = None) -> dict:
|
||||
"""구조물 도면에 얹는 도각 — B07 시스템 양식 1:1 · 외곽 좌하단 (0,0) · 보여주기 층 ·
|
||||
`number` = 장 도번(82-2 · 도각 `{{도면번호}}`)."""
|
||||
fields = {"도면번호": number} if number else None
|
||||
placed = frame_entities("m02-structure", usable_bbox(), fit=False)
|
||||
box = entities_bbox(placed)
|
||||
if not box:
|
||||
return {"entities": [], "layer": None}
|
||||
ox, oy = box[0], box[1]
|
||||
x0, y0, x1, y1 = usable_bbox()
|
||||
entities = frame_entities("m02-structure", (x0 - ox, y0 - oy, x1 - ox, y1 - oy), fit=False)
|
||||
entities = frame_entities(
|
||||
"m02-structure", (x0 - ox, y0 - oy, x1 - ox, y1 - oy), fit=False, fields=fields
|
||||
)
|
||||
return {
|
||||
"entities": [_on_frame_layer(entity) for entity in entities],
|
||||
"layer": {
|
||||
@@ -125,9 +129,9 @@ def structure_frame() -> dict:
|
||||
|
||||
|
||||
@router.get("/structure-frame")
|
||||
async def get_structure_frame() -> JSONResponse:
|
||||
"""구조물 도면 상세 · 카드 미리보기가 얹는 도각(56장)."""
|
||||
return JSONResponse({"status": "success", **structure_frame()})
|
||||
async def get_structure_frame(number: str | None = None) -> JSONResponse:
|
||||
"""구조물 도면 상세 · 카드 미리보기가 얹는 도각(56장) · `number` = 장 도번(82-2)."""
|
||||
return JSONResponse({"status": "success", **structure_frame(number)})
|
||||
|
||||
|
||||
@router.get("/drawing-fields")
|
||||
|
||||
@@ -235,11 +235,12 @@ def _edit_tree(folder: Path, body: dict[str, Any]) -> dict[str, Any]:
|
||||
else: # 여러 개 — 차례로 고쳐 저장은 한 번(하나라도 막히면 아무것도 안 씀)
|
||||
if body.get("op") not in ("move", "delete") or not targets:
|
||||
raise tree.TreeError(422, "여러 개는 옮기기 · 지우기만 — 「대상」 이 필요함")
|
||||
doc, note = got["문서"], {"op": body["op"], "지운항목": []}
|
||||
doc, note = got["문서"], {"op": body["op"], "지운항목": [], "바뀐이름": []}
|
||||
for one in paste_store.outer(targets):
|
||||
step = {**body, "경로": one.get("경로"), "키": one.get("키")}
|
||||
doc, done = tree.apply(doc, step, ids)
|
||||
note["지운항목"] += done.get("지운항목", [])
|
||||
note["바뀐이름"] += done.get("바뀐이름", [])
|
||||
except tree.TreeError as e:
|
||||
raise store.StoreError(e.status, e.detail) from e
|
||||
store.write("table", store.STRUCTURE_TABLE, got["판"], doc)
|
||||
@@ -267,7 +268,7 @@ def post_paste(
|
||||
session: Session = Depends(verify_session),
|
||||
):
|
||||
"""여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 복사한 층은 읽기 권한 · 이 층은 쓰기 권한 ·
|
||||
새 키(SC · SP) · 본뜬 = 원본 · 같은 이름 「이름 (2)」 · 폴더는 안의 것까지 → 새 나무 + 알림."""
|
||||
새 키(SC · SP) · 본뜬 = 원본 · 같은 이름 「이름(1)」 · 폴더는 안의 것까지 → 새 나무 + 알림."""
|
||||
target = _root(session, layer, owner, write=True)
|
||||
source = _root(session, body.from_layer, body.from_owner)
|
||||
who = access.grant(session, body.from_layer, body.from_owner).owner
|
||||
|
||||
@@ -226,13 +226,39 @@ def _codes() -> dict[str, str]:
|
||||
return codes
|
||||
|
||||
|
||||
def structure_numbers() -> dict[str, dict[str, str]]:
|
||||
"""`{열 id: {도번, 면적번}}` — 집계표 줄 · 좌측 줄이 한 번에 받음(없는 쪽은 빈 글)."""
|
||||
out: dict[str, dict[str, str]] = {}
|
||||
def _total(sums: Any) -> float | None:
|
||||
"""`계산값` 합 칸의 `계` — 수 아닌 값 · 없음 = None."""
|
||||
total = sums.get("계") if isinstance(sums, dict) else None
|
||||
if total is None or isinstance(total, bool):
|
||||
return None
|
||||
try:
|
||||
return float(total)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _labor_sum(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""일위대가 합계 칸(86-3) — 반영합 `계`(0 아님) · 없으면 기준합 `계` + `합계기준` 참 ·
|
||||
담은 로직 0 · 계산값 없음 = 빈 칸 (목록 `labor` 합계와 같은 규칙)."""
|
||||
if not doc.get("담은로직"):
|
||||
return {}
|
||||
calc = doc.get("계산값") or {}
|
||||
if total := _total(calc.get("반영합")):
|
||||
return {"일위대가합계": total, "합계기준": False}
|
||||
base = _total(calc.get("기준합"))
|
||||
return {} if base is None else {"일위대가합계": base, "합계기준": True}
|
||||
|
||||
|
||||
def structure_numbers() -> dict[str, dict[str, Any]]:
|
||||
"""`{열 id: {도번, 일위대가번, 면적번, (일위대가합계, 합계기준)}}` — 집계표 줄 · 좌측 줄이 한 번에
|
||||
받음(번호 없는 쪽은 빈 글 · 합계는 일위대가 계산값이 있을 때만)."""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for kind, key, _, _ in PARTS:
|
||||
for name, doc in _docs(kind):
|
||||
row = out.setdefault(name, {k: "" for _, k, _, _ in PARTS})
|
||||
row[key] = str(doc.get(key) or "")
|
||||
if kind == "labor":
|
||||
row.update(_labor_sum(doc))
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -57,6 +58,7 @@ const TEXT = {
|
||||
noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음",
|
||||
pillsFailed: "일위대가 알약을 못 받음 — {value}",
|
||||
discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
|
||||
baseNote: "(기준)",
|
||||
};
|
||||
|
||||
/** 집계표 줄 열쇠 → 문서 */
|
||||
@@ -72,6 +74,13 @@ const fill = (text: string, value: string): string => text.replace("{value}", va
|
||||
/** `{열 id: {도번, 면적번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */
|
||||
export type ColumnNumbers = Record<string, StructureNumbers>;
|
||||
|
||||
/** 집계표 「일위대가 합계」 줄 글(86-3) — 반영합 · 수량이 없으면 기준합 + 「(기준)」 · 계산값 없음 = 빈 글(「—」) */
|
||||
export function laborSumText(numbers: StructureNumbers | undefined): string {
|
||||
if (typeof numbers?.일위대가합계 !== "number") return "";
|
||||
const text = numbers.일위대가합계.toLocaleString("ko-KR", { maximumFractionDigits: 0 });
|
||||
return numbers.합계기준 ? `${text} ${TEXT.baseNote}` : text;
|
||||
}
|
||||
|
||||
/** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2 · 분류3) · 꼴만 씀 */
|
||||
export interface StructureColumn {
|
||||
id: string;
|
||||
@@ -182,6 +191,8 @@ export interface StructurePanelOptions {
|
||||
/** [도면 수정] · [면적산출 수정] — 그 열을 CAD · 스프레드시트 전체 편집으로 엶
|
||||
* (페이지의 `open`) · label 은 화면 제목에 씀 */
|
||||
onEdit: (column: string, label: string, kind: CardKind) => void;
|
||||
/** 번호 · 일위대가 합계를 다시 읽음(카드 [저장] 뒤 · 86-3) — 집계표 줄 글을 새로 그림 */
|
||||
onNumbers?: () => void;
|
||||
/** 집계표에 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 카드 고침을 버리지 않고 머묾 — 표의 고른 열을 되돌림(31-3) */
|
||||
@@ -220,6 +231,15 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
editable: () => opts.isAdmin,
|
||||
onEdit: (kind) => void openEdit(kind),
|
||||
numberOf: (kind, id) => numbers[id]?.[PART_FIELD[kind]],
|
||||
// 카드 [저장] 뒤 — 서버가 일위대가 계산값을 다시 적었을 수 있어 번호 · 합계를 다시 읽음(86-3)
|
||||
onSaved: () =>
|
||||
void fetchStructureNumbers().then(
|
||||
(got) => {
|
||||
numbers = got;
|
||||
opts.onNumbers?.();
|
||||
},
|
||||
() => undefined,
|
||||
),
|
||||
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
|
||||
warnOnFail: (id) => !!numbers[id],
|
||||
// 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 집계표 줄 「조회 후 선택」 과 같은 길
|
||||
@@ -252,9 +272,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 +295,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;
|
||||
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
MIN_LEVELS = 4 # 옛 약속 — 종류 · 분류1 · 분류2 · 분류3
|
||||
@@ -173,6 +174,14 @@ def _taken_item(doc: dict[str, Any], path: list[str], skip=None) -> None:
|
||||
raise TreeError(409, f"이미 있는 항목 「{name_of(path)}」")
|
||||
|
||||
|
||||
def unique_path(path: list[str], busy: Callable[[list[str]], bool]) -> list[str]:
|
||||
"""겹치는 경로면 마지막 이름을 「이름(1)」 「이름(2)」… 로 — 옮기기 · 붙여넣기 · 복사 생성이 한 벌을 씀."""
|
||||
out, n = path, 1
|
||||
while busy(out):
|
||||
out, n = [*path[:-1], f"{path[-1]}({n})"], n + 1
|
||||
return out
|
||||
|
||||
|
||||
def _set_path(col: dict[str, Any], path: list[str]) -> None:
|
||||
col["경로"] = path
|
||||
col["머리"] = list(path) # 머리 줄 수는 저장 맞춤(`normalize_table`)이 채움
|
||||
@@ -272,21 +281,28 @@ def apply(
|
||||
to = _path(body.get("새경로"), "새경로")
|
||||
if key:
|
||||
col = _find(out, key, ids)
|
||||
moved = [*to, path_of(col)[-1]]
|
||||
_taken_item(out, moved, [col])
|
||||
want = [*to, path_of(col)[-1]]
|
||||
taken = _item_paths(out, [col])
|
||||
moved = unique_path(want, lambda p: p in taken) # 같은 폴더 같은 이름 = 「이름(1)」
|
||||
out["열"] = [c for c in out["열"] if c is not col]
|
||||
_set_path(col, moved)
|
||||
_place(out, col)
|
||||
note["경로"] = moved
|
||||
if moved != want:
|
||||
note["바뀐이름"] = [moved[-1]]
|
||||
else:
|
||||
_folder(out, where, "옮길")
|
||||
if _under(to, where):
|
||||
raise TreeError(422, "폴더를 제 안으로 옮길 수 없음")
|
||||
moved = [*to, where[-1]]
|
||||
if moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out)):
|
||||
raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」")
|
||||
folders_now, paths_now = folders_of(out), _item_paths(out)
|
||||
moved = unique_path(
|
||||
[*to, where[-1]],
|
||||
lambda p: p != where and (p in folders_now or any(_under(x, p) for x in paths_now)),
|
||||
)
|
||||
_move_folder(out, where, moved, replace=True)
|
||||
note["경로"] = moved
|
||||
if moved[-1] != where[-1]:
|
||||
note["바뀐이름"] = [moved[-1]]
|
||||
else: # delete
|
||||
if key:
|
||||
gone = [_find(out, key, ids)]
|
||||
|
||||
@@ -425,7 +425,13 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
}
|
||||
picked = new Set();
|
||||
paint();
|
||||
showToast(L("M02_TreeDone"), "success");
|
||||
const renamed = note?.바뀐이름 ?? [];
|
||||
showToast(
|
||||
renamed.length
|
||||
? L("M02_TreeRenamed").replace("{value}", renamed.join("」 · 「"))
|
||||
: L("M02_TreeDone"),
|
||||
"success",
|
||||
);
|
||||
await opt.onChanged(col);
|
||||
} catch (error) {
|
||||
// 409 = 판 다름(서버 detail 이 객체 → 「HTTP 409」) 또는 같은 이름(글 까닭 — 그대로 보임)
|
||||
|
||||
@@ -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 {
|
||||
@@ -21,12 +22,12 @@ import {
|
||||
comboMerge,
|
||||
comboView,
|
||||
createStructurePanel,
|
||||
laborSumText,
|
||||
pillsFor,
|
||||
STRUCTURE_TABLE,
|
||||
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 +203,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 => {
|
||||
@@ -255,6 +263,7 @@ export function createMain(
|
||||
isAdmin,
|
||||
confirmLeave,
|
||||
keepColumn: (colId) => sheet?.selectColumn(colId), // 카드 고침을 안 버림 — 고른 열 되돌림
|
||||
onNumbers: () => sheet?.recalc(), // 카드 [저장] 뒤 번호 · 일위대가 합계 줄을 새로(86-3)
|
||||
// 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶
|
||||
onEdit: (column, label, kind) => {
|
||||
void open({ layer, projectId: null, kind, name: column, label });
|
||||
@@ -287,7 +296,8 @@ export function createMain(
|
||||
doc as Parameters<typeof createSpreadsheet>[1],
|
||||
{
|
||||
readOnly,
|
||||
tabs: false, // 시트 하나 · 탭 줄 없음
|
||||
// 시트 하나 = 탭 줄 없음 · 도면에서 옮긴 시트가 있으면(2장 이상 · 83-3) 탭 줄
|
||||
tabs: ((doc as { 시트?: unknown[] }).시트?.length ?? 0) > 1,
|
||||
onChange: () => {
|
||||
dirty = !readOnly;
|
||||
apanel?.refresh(); // 칸 값 · 연결이 바뀜 — 반영 금액을 바로 다시 곱함
|
||||
@@ -350,6 +360,7 @@ export function createMain(
|
||||
laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? "",
|
||||
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
|
||||
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
|
||||
laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId]), // 86-3
|
||||
combo: {
|
||||
...(readOnly || !extras.onHeadRename
|
||||
? {}
|
||||
@@ -416,14 +427,13 @@ export function createMain(
|
||||
// 열 때만 `import()` 로 받음(계약 `spreadsheet_types.ts` 머리)
|
||||
return mountSheet(doc, at.kind, at, readOnly);
|
||||
}
|
||||
// 구조물 도면 = 여러 장 탭(82-1 · `도면` 1장 + `더한장`) — 도번은 안 건드림
|
||||
const mountDrawing = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>(
|
||||
DRAWING_MODS,
|
||||
"mountDrawingTemplate",
|
||||
at.kind === "structure" ? "mountStructureSheets" : "mountDrawingTemplate",
|
||||
);
|
||||
if (mountDrawing) {
|
||||
// 구조물 도면은 CAD 문서가 `도면` 칸에 들어 있음 — 도번은 안 건드림
|
||||
const cad = at.kind === "structure" ? (doc as StructureDoc).도면 : doc;
|
||||
editor = mountDrawing(host, cad, {
|
||||
editor = mountDrawing(host, doc, {
|
||||
onSave: () => void doSave(),
|
||||
readOnly,
|
||||
name: at.name,
|
||||
@@ -504,13 +514,13 @@ export function createMain(
|
||||
const at = sel;
|
||||
try {
|
||||
const edited = editor ? await editor.getDoc() : loaded;
|
||||
// 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번은 읽은 그대로.
|
||||
// 구조물 도면 저장은 `도면` · `더한장` 칸만 갈아 끼움 — 도번은 읽은 그대로.
|
||||
// 면적산출은 스프레드시트 문서로 통째 갈아 끼우되 번호는 계약 밖(엔진이 모름)이라 옮겨 붙임.
|
||||
const doc =
|
||||
at.kind === "structure" && loaded
|
||||
? toDrawing.withLinks({
|
||||
...(loaded as StructureDoc),
|
||||
도면: edited as StructureDoc["도면"],
|
||||
...(edited as Pick<StructureDoc, "도면" | "더한장">),
|
||||
})
|
||||
: at.kind === "area" && loaded
|
||||
? { ...(loaded as Record<string, unknown>), ...(edited as Record<string, unknown>) }
|
||||
@@ -573,15 +583,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";
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,30 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 일위대가 상세(85-5) — 면적 상세와 같은 고정 높이 · 본문 칸만 굴림 · 오른쪽 패널은 칸 높이(따로 스크롤) */
|
||||
.m02-main:has(> .m02-main__host > .ui-side-panels-row) {
|
||||
box-sizing: border-box;
|
||||
height: calc(100vh - var(--spacing-64));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m02-main__host:has(> .ui-side-panels-row) {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m02-main__host > .ui-side-panels-row {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m02-main__host > .ui-side-panels-row > :first-child {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m02-main__empty {
|
||||
margin: 0;
|
||||
padding: var(--spacing-24);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user