Merge remote-tracking branch 'origin/dev' into sub_laptop_2
This commit is contained in:
@@ -61,6 +61,8 @@ export interface SvgZoomPanOptions {
|
||||
maxScale?: number;
|
||||
/** 이전 상태 — 다시 그려도 배율이 살아남게. */
|
||||
initial?: ZoomPanState;
|
||||
/** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */
|
||||
homeScale?: number;
|
||||
/** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */
|
||||
onChange?: (state: ZoomPanState) => void;
|
||||
/** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */
|
||||
@@ -85,9 +87,15 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
height: Math.max(heightPx - pad.top - pad.bottom, 1),
|
||||
};
|
||||
const maxScale = options.maxScale ?? 8;
|
||||
let scale = initial?.scale ?? 1;
|
||||
let tx = initial?.tx ?? 0;
|
||||
let ty = initial?.ty ?? 0;
|
||||
const homeScale = Math.min(Math.max(options.homeScale ?? 1, 1), maxScale);
|
||||
const home: ZoomPanState = {
|
||||
scale: homeScale,
|
||||
tx: (plot.x + plot.width / 2) * (1 - homeScale),
|
||||
ty: (plot.y + plot.height / 2) * (1 - homeScale),
|
||||
};
|
||||
let scale = initial?.scale ?? home.scale;
|
||||
let tx = initial?.tx ?? home.tx;
|
||||
let ty = initial?.ty ?? home.ty;
|
||||
const applyTransform = (): void => {
|
||||
layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
|
||||
onChange?.({ scale, tx, ty });
|
||||
@@ -182,7 +190,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
/* 이미 해제됨 */
|
||||
}
|
||||
};
|
||||
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
|
||||
if (scale !== 1 || tx !== 0 || ty !== 0) {
|
||||
clampPan();
|
||||
applyTransform();
|
||||
}
|
||||
@@ -209,9 +217,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
}
|
||||
const currentScale = (): number => scale;
|
||||
const reset = (): void => {
|
||||
scale = 1;
|
||||
tx = 0;
|
||||
ty = 0;
|
||||
({ scale, tx, ty } = home);
|
||||
applyTransform();
|
||||
};
|
||||
// 더블클릭 원복.
|
||||
|
||||
@@ -90,7 +90,8 @@ export function createSectionFrame(
|
||||
root.append(heading, body);
|
||||
return { root, body };
|
||||
}
|
||||
root.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
|
||||
// ui-panel-form: 좌측 패널 컨테이너 공용 양식(269 · 「구조물 배치」 기준 칸 · 라벨 · 단추).
|
||||
root.className = `b06-profile__group ui-collapsible ui-sidebar-section ui-panel-form${collapsed ? " is-collapsed" : ""}`;
|
||||
heading.className = "b06-profile__group-legend ui-collapsible__title";
|
||||
root.append(heading);
|
||||
return { root, body: root };
|
||||
|
||||
@@ -357,11 +357,11 @@
|
||||
|
||||
/* ── 조작 컨트롤 높이 템플릿(2026-08-29 사용자 지시) ──────────────────────
|
||||
* 「구조물 배치」 섹션 안의 입력·셀렉트·스텝 묶음·이식 행이 **한 높이**를 쓴다.
|
||||
* 값을 바꾸려면 이 변수 하나만 고치면 된다. 기준은 조정창에서 옮겨 온 행의
|
||||
* 값은 theme `--panel-control-h` 한 곳(좌측 패널 공용 양식이 함께 씀 · 269). 기준은 조정창에서 옮겨 온 행의
|
||||
* 컨트롤 높이(26px)다 — 사용자가 그 크기를 기준으로 정했다. */
|
||||
.b05-structure-section,
|
||||
.b05-drainage__facility {
|
||||
--b05-control-h: 26px;
|
||||
--b05-control-h: var(--panel-control-h);
|
||||
}
|
||||
|
||||
.b05-structure-section input:not([type="checkbox"]),
|
||||
@@ -409,7 +409,7 @@
|
||||
/* 제목(라벨)과 항목 사이 간격도 한 변수로 묶는다(2026-08-29 사용자 지시) — 기준은
|
||||
조정창에서 옮겨 온 행(2px·작은 글자). 측점 행은 가로 배치라 열 간격을 그대로 둔다. */
|
||||
.b05-structure-section {
|
||||
--b05-label-gap: 2px;
|
||||
--b05-label-gap: var(--panel-label-gap);
|
||||
}
|
||||
|
||||
.b05-structure-section .b05-route__field {
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
*
|
||||
* 위 = 카드 그리기 함수로 칸 크기에 맞춰 새로 그린 그림의 복제(173-4 · 테마 색 · 칸 가득 ·
|
||||
* 칸 크기가 바뀌면 다시 그림) ·
|
||||
* 가운데 = 도면 표 위 빈 CAD(260 · `_Cross_Detail_Cad` · `design.structure_note_cad` · 세션 초안 →
|
||||
* [저장]이 patch 로 내보냄 · B07 횡단도 수량표 위에 좌상 맞춤 · 옛 글은 처음 열 때 옮김) ·
|
||||
* 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) ·
|
||||
* 가운데 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) ·
|
||||
* 칸 우클릭 = 그래프에서 고르기 · 자동값으로 · 행 · 열 더함 / 지움(179-4 · `_Cross_Detail_Table`).
|
||||
* 아래 = 도면 표 위 빈 CAD(260 · `_Cross_Detail_Cad` · `design.structure_note_cad` · 세션 초안 →
|
||||
* [저장]이 patch 로 내보냄 · B07 횡단도 수량표 위에 좌상 맞춤 · 옛 글은 처음 열 때 옮김) ·
|
||||
* 머리 한 줄 접기(265-1 · 구조물 측점 폄 · 일반 측점 접힘).
|
||||
* 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2).
|
||||
* 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) —
|
||||
* 카드처럼 도형 층만 움직이고 축 · 눈금은 제자리에서 보이는 범위로 다시 그림(179-3).
|
||||
@@ -38,19 +39,24 @@ import {
|
||||
import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
import { attachDetailTable, designForTable, groupSpan } from "./B06_Section_UI_Cross_Detail_Table";
|
||||
import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
|
||||
import { mountNoteCad, stationNoLabel } from "./B06_Section_UI_Cross_Detail_Cad";
|
||||
import {
|
||||
loadNoteThings,
|
||||
mountNoteCad,
|
||||
stationNoLabel,
|
||||
thingsAt,
|
||||
} from "./B06_Section_UI_Cross_Detail_Cad";
|
||||
|
||||
/** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */
|
||||
export interface CrossDetailHost {
|
||||
projectId: string;
|
||||
routeId: () => number | null;
|
||||
detail: () => SectionDetailResponse | null;
|
||||
/** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션(173-4 · 179-3 · 뷰 `detailChart`). */
|
||||
/** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션 + 처음 배율(173-4 · 179-3 · 266-1 · 뷰 `detailChart`). */
|
||||
chart: (
|
||||
stationId: string,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;
|
||||
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null;
|
||||
}
|
||||
|
||||
let host: CrossDetailHost | null = null;
|
||||
@@ -187,12 +193,14 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
|
||||
/* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */
|
||||
|
||||
/** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 ·
|
||||
* 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴). */
|
||||
* 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴) ·
|
||||
* 처음 · 원복 = `homeScale`(266-1 · 가로 = 카드 반폭 · 줄이면 칸 맞춤 전체). */
|
||||
export function attachDetailZoom(
|
||||
clone: SVGSVGElement,
|
||||
axes: CrossAxesOptions | null,
|
||||
initial: ZoomPanState | undefined,
|
||||
onChange: (state: ZoomPanState) => void,
|
||||
homeScale = 1,
|
||||
): ZoomPanHandle | null {
|
||||
const layer = clone.querySelector<SVGGElement>("g.b06-chart__plot-layer");
|
||||
if (!layer) return null;
|
||||
@@ -223,6 +231,7 @@ export function attachDetailZoom(
|
||||
content: { x: -width, y: -height, width: 3 * width, height: 3 * height },
|
||||
wheelZoom: true,
|
||||
initial,
|
||||
homeScale,
|
||||
onChange: (state) => {
|
||||
follow(state);
|
||||
onChange(state);
|
||||
@@ -251,7 +260,7 @@ function drawChart(
|
||||
section: CrossSection,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
): { clone: SVGSVGElement; axes: CrossAxesOptions | null } | null {
|
||||
): { clone: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null {
|
||||
drawing = true;
|
||||
try {
|
||||
const drawn = host?.chart(section.station_id, widthPx, heightPx);
|
||||
@@ -264,7 +273,7 @@ function drawChart(
|
||||
.querySelectorAll(`[clip-path="url(#${id})"]`)
|
||||
.forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`));
|
||||
});
|
||||
return { clone, axes: drawn.axes };
|
||||
return { clone, axes: drawn.axes, homeScale: drawn.homeScale };
|
||||
} finally {
|
||||
drawing = false;
|
||||
}
|
||||
@@ -286,26 +295,50 @@ export function openCrossDetail(section: CrossSection): void {
|
||||
dialogClass: "b06-detail",
|
||||
wide: true,
|
||||
mount(body, close) {
|
||||
// 표 위 빈 CAD(260-2) — 높이 고정 · 한 번만 만듦 · 닫으면 destroy
|
||||
// 표 위 빈 CAD(260-2) — 높이 고정 · 닫으면 destroy · 머리 한 줄 접기(265-1 · 공용 접기 꼴) ·
|
||||
// 열 때 = 구조물 측점 폄 · 일반 측점 접힘(그 창에서만) · 처음 펼 때 한 번 만듦(접힌 동안 칸 ·
|
||||
// 초안 · [저장] 그대로 — 안 연 칸은 B07 이 같은 짝으로 그림)
|
||||
const noteBox = document.createElement("section");
|
||||
noteBox.className = "b06-detail__notebox ui-collapsible";
|
||||
const noteHead = document.createElement("div");
|
||||
noteHead.className = "b06-detail__notehead ui-collapsible__title";
|
||||
noteHead.textContent = "표 위 CAD";
|
||||
const note = document.createElement("div");
|
||||
note.className = "b06-detail__note";
|
||||
note.style.height = "180px";
|
||||
noteBox.append(noteHead, note);
|
||||
const routeId = host?.routeId();
|
||||
const stations = host?.detail()?.longitudinal.stations ?? [];
|
||||
const noteCad =
|
||||
host && routeId !== null && routeId !== undefined
|
||||
? mountNoteCad(note, section, () => state.section, {
|
||||
projectId: host.projectId,
|
||||
routeId,
|
||||
station: stationNoLabel(section.chainage_m, inferStationInterval(stations)),
|
||||
})
|
||||
: null;
|
||||
note.querySelector<HTMLElement>(".cad-host")?.style.setProperty("min-height", "0");
|
||||
let noteCad: { destroy: () => void } | null = null;
|
||||
const openNote = (): void => {
|
||||
if (noteCad || !host || routeId === null || routeId === undefined) return;
|
||||
noteCad = mountNoteCad(note, section, () => state.section, {
|
||||
projectId: host.projectId,
|
||||
routeId,
|
||||
station: stationNoLabel(section.chainage_m, inferStationInterval(stations)),
|
||||
things,
|
||||
});
|
||||
note.querySelector<HTMLElement>(".cad-host")?.style.setProperty("min-height", "0");
|
||||
};
|
||||
let touched = false;
|
||||
noteHead.addEventListener("click", () => {
|
||||
touched = true;
|
||||
if (!noteBox.classList.toggle("is-collapsed")) openNote();
|
||||
});
|
||||
// 접힘으로 시작 → 그 측점에 놓인 구조물이 있으면 폄(사용자가 먼저 누르면 그대로)
|
||||
noteBox.classList.add("is-collapsed");
|
||||
const things = host ? loadNoteThings(host.projectId) : Promise.resolve([]);
|
||||
void things.then((list) => {
|
||||
if (touched || current !== state || !thingsAt(list, section.chainage_m).length) return;
|
||||
noteBox.classList.remove("is-collapsed");
|
||||
openNote();
|
||||
});
|
||||
const cad = document.createElement("div");
|
||||
cad.className = "b06-detail__cad";
|
||||
const tableBox = document.createElement("div");
|
||||
tableBox.className = "b06-detail__table";
|
||||
body.append(cad, note, tableBox);
|
||||
// 차례 = 그림 → 표 → CAD 칸(265-1)
|
||||
body.append(cad, tableBox, noteBox);
|
||||
|
||||
// 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2) ·
|
||||
// 저장된 조각 · 띠 칸 = 그래프에서 고름 + 값 글(259-2) · 고른 칸 묶음 = 바깥 한 줄(259-3)
|
||||
@@ -359,7 +392,13 @@ export function openCrossDetail(section: CrossSection): void {
|
||||
else {
|
||||
const { clone } = drawn;
|
||||
axes = drawn.axes;
|
||||
const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next));
|
||||
const handle = attachDetailZoom(
|
||||
clone,
|
||||
axes,
|
||||
zoom,
|
||||
(next) => (zoom = next),
|
||||
drawn.homeScale,
|
||||
);
|
||||
cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []));
|
||||
tablePick.redraw(); // 선 조각 고르기(209-1)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Detail_Cad.ts
|
||||
* 횡단 [상세] 모달 가운데 = 표 위 CAD 칸(260 · 요구 162-21).
|
||||
* 횡단 [상세] 모달 아래 = 표 위 CAD 칸(260 · 요구 162-21 · 표 아래 접기 265-1).
|
||||
*
|
||||
* 칸 = `design.structure_note_cad`(CAD 저장 꼴 · 엔티티 · 층) · 세션 초안(`structure-note-cads` ·
|
||||
* 측점키) → [저장]이 patch 로 내보냄 → B07 횡단도가 수량표 위에 좌상 맞춤으로 놓음
|
||||
* (`_Engine_Cad_Table.cross_note_cad_entities`).
|
||||
* 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없고 횡단배수
|
||||
* 주인 측점이면 관 · 관보호공 · 관유출부보강 글을 미리 넣음 · 그 밖 = 빈 도면.
|
||||
* ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `culvertNoteEntities` ·
|
||||
* `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연 측점은 B07 이 그것으로 그림 ·
|
||||
* 거울 시험 `test_260_note_cad.py`.
|
||||
* 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없으면 미리 넣음
|
||||
* (265-2 · 관 주인 측점 = 원형 마크 · 그 측점 구조물마다 지정된 구조물 조합 이름 줄 · 미지정 =
|
||||
* 「이름 조합 미지정」) · 그 밖 = 빈 도면.
|
||||
* ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `noteEntities` ·
|
||||
* `structureNoteLines` · `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연
|
||||
* 측점은 B07 이 그것으로 그림 · 거울 시험 `test_260_note_cad.py` · `test_265_note_cad.py`.
|
||||
* 좌표 = 도면 mm(B07 표와 같은 자 · 글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -18,7 +19,19 @@ import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import type { CrossNoteCad, CrossSection, CulvertSideSpec } from "./B06_Section_Api_Fetch";
|
||||
import {
|
||||
fetchStructures,
|
||||
fetchStructureTypes,
|
||||
readPendingStructures,
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import {
|
||||
readGroupItems,
|
||||
readSummaryItem,
|
||||
summaryLabel,
|
||||
type SummaryItem,
|
||||
} from "../common_util/common_util_summary_item";
|
||||
import type { CrossNoteCad, CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import { pipeLengthWhole } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
|
||||
const noteKey = (chainageM: number): string => chainageM.toFixed(2);
|
||||
|
||||
@@ -104,42 +117,11 @@ export function legacyNoteEntities(note: string | undefined): Record<string, unk
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 횡단배수 미리 넣음(주인 측점 · 관만) ──────────────────────────────────── */
|
||||
/* ── 미리 넣음(265-2) = 원형 마크(관 주인 측점) + 측점 · 구조물 조합 줄 ─────────────── */
|
||||
|
||||
const one = (value: number | null | undefined): string =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value.toFixed(1) : "";
|
||||
const metres = (value: number | null | undefined): string => (one(value) ? `${one(value)}m` : "");
|
||||
const trim = (value: number): string => String(Math.round(value * 100) / 100);
|
||||
/** 돌쌓기(찰) · 찰붙임 → 찰 · (메) · 메붙임 → 메 · 그 밖 = 그대로. */
|
||||
const bondOf = (form: string | null | undefined): string =>
|
||||
/찰/.test(form ?? "") ? "찰" : /메/.test(form ?? "") ? "메" : (form ?? "");
|
||||
const BASIN_FORMS: Record<string, string> = { I: "I형", L: "L형", U: "ㄷ형" };
|
||||
|
||||
/** 관보호공 한 줄 — 구조를 안 고른 쪽 = 없음. */
|
||||
function sideLine(side: CulvertSideSpec | undefined, title: string): string | null {
|
||||
if (!side?.structure) return null;
|
||||
const basin = side.structure === "집수정";
|
||||
const form = basin ? (BASIN_FORMS[side.basin_form ?? ""] ?? "") : bondOf(side.revet_form);
|
||||
const height = basin ? side.basin_height_m : side.revet_height_m;
|
||||
const length = basin ? side.basin_length_m : side.revet_length_m;
|
||||
const spec = [form, `H=${one(height)}`].filter(Boolean).join(",");
|
||||
return `관보호공(${title}) ${side.structure}(${spec}) L=${metres(length)}`;
|
||||
}
|
||||
|
||||
/** 도수로/돌붙임 첫 상자(`chute_add_{n}_*`) — 쪽 칸이 없어 유출부로(사용자가 고침). */
|
||||
function chuteLine(options: Record<string, unknown>, fallback?: string): string | null {
|
||||
const picked = Object.keys(options)
|
||||
.map((key) => /^chute_add_(\d+)_structure$/.exec(key))
|
||||
.filter((hit): hit is RegExpExecArray => !!hit && !!String(options[hit[0]] ?? "").trim())
|
||||
.sort((a, b) => Number(a[1]) - Number(b[1]))[0];
|
||||
const head = picked ? `chute_add_${picked[1]}_` : null;
|
||||
const structure = head ? String(options[`${head}structure`]).trim() : fallback;
|
||||
if (!structure) return null;
|
||||
const bond = head ? bondOf(String(options[`${head}bond`] ?? "")) : "";
|
||||
const area = head ? Number(options[`${head}area`]) : NaN;
|
||||
const spec = [structure, bond].filter(Boolean).join(",");
|
||||
return `관유출부보강(${spec}) ${area > 0 ? trim(area) : ""}m2`;
|
||||
}
|
||||
/** 관 길이 = 사사오입 정수(265-3 · B08 「관매설」과 같은 값) · 없으면 빈칸. */
|
||||
const wholeMetres = (value: number | null | undefined): string =>
|
||||
typeof value === "number" && Number.isFinite(value) ? `${pipeLengthWhole(value)}m` : "";
|
||||
|
||||
/** 측점 이름(B07 `station_no_label` 꼴 · 대문자 NO · 거리는 정수 = 화면 표기). */
|
||||
export function stationNoLabel(chainageM: number, intervalM: number): string {
|
||||
@@ -153,6 +135,42 @@ export function stationNoLabel(chainageM: number, intervalM: number): string {
|
||||
return Math.abs(rest) < 0.05 ? `NO.${number}` : `NO.${number}+${Math.round(rest)}`;
|
||||
}
|
||||
|
||||
/** 측점 글 줄 재료 한 건(관 지점 · 구조물 배치) — 이름 = 구조물정의 종류 이름. */
|
||||
export interface NoteThing {
|
||||
name: string;
|
||||
/** 배관(`facility` pipe) — 원형 마크가 관을 그려 관 본체 조합 줄은 안 씀. */
|
||||
pipe: boolean;
|
||||
/** 놓인 누가거리(관 = 관 자리 · 구조물 = 기준 측점 · 없으면 시작). */
|
||||
at: number;
|
||||
summary_item?: unknown;
|
||||
group_items?: unknown;
|
||||
}
|
||||
|
||||
/** 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리. */
|
||||
const PIPE_BODY_LINK = "grv6lny";
|
||||
|
||||
/** 그 측점에 놓인 구조물(관 · 구조물 배치 · 기준 측점이 찾는 거리 안) — 구조물 측점 판정 · 글 줄 재료. */
|
||||
export const thingsAt = (things: readonly NoteThing[], chainageM: number): NoteThing[] =>
|
||||
things.filter((thing) => Math.abs(thing.at - chainageM) < PIPE_MATCH_M);
|
||||
|
||||
/**
|
||||
* 구조물 조합 줄 — 구조물마다 지정된 조합(본체 → 그룹 차례) 이름 한 줄씩(이름 = 일위대가 이름 +
|
||||
* 조합 경로 옵션 값) · 같은 구조물의 같은 이름은 한 줄 · 미지정 = 「{구조물 이름} 조합 미지정」 ·
|
||||
* 배관은 본체 줄 없음(원형 마크) — 보호공 · 흙막이 · 도수로 조합만.
|
||||
*/
|
||||
export function structureNoteLines(things: readonly NoteThing[], chainageM: number): string[] {
|
||||
return thingsAt(things, chainageM).flatMap((thing) => {
|
||||
const own = readSummaryItem(thing.summary_item);
|
||||
const slots: Array<[string, SummaryItem]> = Object.entries(readGroupItems(thing.group_items));
|
||||
if (own) slots.unshift(["", own]);
|
||||
if (!slots.length) return thing.pipe ? [] : [`${thing.name} 조합 미지정`];
|
||||
const names = slots
|
||||
.filter(([gid]) => !(thing.pipe && (gid === "" || gid === PIPE_BODY_LINK)))
|
||||
.map(([, item]) => summaryLabel(item));
|
||||
return [...new Set(names)];
|
||||
});
|
||||
}
|
||||
|
||||
function shape(type: string, shapeData: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
id: newId(),
|
||||
@@ -164,17 +182,9 @@ function shape(type: string, shapeData: Record<string, unknown>): Record<string,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 횡단배수 미리 넣음 — 관이 이 측점에 **놓인**(주인) 배수관만 · 상자형(세월교 · 박스) · 숨은 관
|
||||
* (독립 기슭막이) · 이웃 측점 = 빈 목록. `options` = 그 관의 `pipe_points` 옵션(돌붙임 형태 · 면적).
|
||||
* 왼쪽 원 안 세 줄(배수관 / Φ관경 / L=관 길이 · 없으면 빈칸) · 오른쪽 = 측점 · 관보호공(유입 · 유출) ·
|
||||
* 관유출부보강.
|
||||
*/
|
||||
export function culvertNoteEntities(
|
||||
section: CrossSection,
|
||||
station: string,
|
||||
options: Record<string, unknown> = {},
|
||||
): Record<string, unknown>[] {
|
||||
/** 원형 마크 — 관이 이 측점에 **놓인**(주인) 배수관만 · 상자형(세월교 · 박스) · 숨은 관(독립 기슭막이) ·
|
||||
* 이웃 측점 = 없음 · 원 안 세 줄(배수관 / Φ관경 / L=관 길이 정수 · 없으면 빈칸) + 지시선. */
|
||||
function pipeMark(section: CrossSection): Record<string, unknown>[] {
|
||||
const culvert = section.culvert;
|
||||
if (!culvert || culvert.type !== "pipe" || culvert.body || culvert.hidden_pipe) return [];
|
||||
if (Math.abs((culvert.chainage_m ?? section.chainage_m) - section.chainage_m) >= PIPE_MATCH_M)
|
||||
@@ -183,14 +193,8 @@ export function culvertNoteEntities(
|
||||
const inside = [
|
||||
"배수관",
|
||||
`Φ${Math.round(culvert.diameter_m * 1000)}m/m`,
|
||||
`L=${metres(section.design?.pipe_length_m)}`,
|
||||
`L=${wholeMetres(section.design?.pipe_length_m)}`,
|
||||
];
|
||||
const right = [
|
||||
station,
|
||||
sideLine(culvert.inlet, "유입"),
|
||||
sideLine(culvert.outlet, "유출"),
|
||||
chuteLine(options, culvert.chute?.structure),
|
||||
].filter((line): line is string => !!line);
|
||||
return [
|
||||
shape("Circle", { center: { x: cx, y: cy }, radius }),
|
||||
shape("Line", {
|
||||
@@ -200,29 +204,73 @@ export function culvertNoteEntities(
|
||||
...inside.map((line, index) =>
|
||||
textEntity(line, cx, cy + LINE * (1 - index), CULVERT_COLOR, "center"),
|
||||
),
|
||||
...right.map((line, index) => textEntity(line, 15, -LINE * (index + 0.5), CULVERT_COLOR)),
|
||||
];
|
||||
}
|
||||
|
||||
/** 관 옵션 — 세션 초안(관 목록 주인 = 설계 가게) → 없으면 정본 `pipe_points.json`. */
|
||||
async function pipeOptionsAt(
|
||||
projectId: string,
|
||||
chainageM: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const pipes = readPendingPipes(projectId) ?? (await fetchDetailPipePoints(projectId)).pipe_points;
|
||||
const pipe = pipes.find((point) => Math.abs(point.chainage_m - chainageM) < PIPE_MATCH_M);
|
||||
return pipe?.options ?? {};
|
||||
/**
|
||||
* 미리 넣음 — 원형 마크(있으면) + 오른쪽 측점 · 구조물 조합 줄(`lines` = `structureNoteLines`) ·
|
||||
* 마크 없으면 글이 왼쪽 끝부터 · 마크도 줄도 없으면 빈 목록.
|
||||
*/
|
||||
export function noteEntities(
|
||||
section: CrossSection,
|
||||
station: string,
|
||||
lines: readonly string[],
|
||||
): Record<string, unknown>[] {
|
||||
const mark = pipeMark(section);
|
||||
if (!mark.length && !lines.length) return [];
|
||||
const x = mark.length ? 15 : 0;
|
||||
return [
|
||||
...mark,
|
||||
...[station, ...lines].map((line, index) =>
|
||||
textEntity(line, x, -LINE * (index + 0.5), CULVERT_COLOR),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** 처음 열 때 칸 — 초안 · 저장분 → 옛 글 → 횡단배수 미리 넣음 → 빈 도면. 옮기거나 넣었으면 초안에 씀. */
|
||||
/** 글 줄 재료 — 관(세션 초안 → 정본 `pipe_points.json`) · 구조물(세션 초안 → 정본 `structures.json`) ·
|
||||
* 이름 = 구조물정의 종류 이름(꺼진 종류 포함) · 못 받으면 빈 목록. */
|
||||
export async function loadNoteThings(projectId: string): Promise<NoteThing[]> {
|
||||
try {
|
||||
const [pipes, structures, types] = await Promise.all([
|
||||
readPendingPipes(projectId) ??
|
||||
fetchDetailPipePoints(projectId).then((res) => res.pipe_points),
|
||||
readPendingStructures(projectId) ?? fetchStructures(projectId).then((res) => res.structures),
|
||||
fetchStructureTypes(true),
|
||||
]);
|
||||
const names = new Map(types.map((type) => [type.type_id, type.name]));
|
||||
const nameOf = (kind: string): string => names.get(kind) ?? kind;
|
||||
const piped = pipes.map((point): NoteThing => {
|
||||
const kind = point.facility ?? "pipe";
|
||||
const { summary_item, group_items } = point;
|
||||
return {
|
||||
name: nameOf(kind),
|
||||
pipe: kind === "pipe",
|
||||
at: point.chainage_m,
|
||||
summary_item,
|
||||
group_items,
|
||||
};
|
||||
});
|
||||
const placed = structures.flatMap((item): NoteThing[] => {
|
||||
const at = item.chainage_m ?? item.start_m;
|
||||
if (typeof at !== "number" || !Number.isFinite(at)) return [];
|
||||
const { summary_item, group_items } = item;
|
||||
return [{ name: nameOf(item.type_id), pipe: false, at, summary_item, group_items }];
|
||||
});
|
||||
return [...piped, ...placed];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 처음 열 때 칸 — 초안 · 저장분 → 옛 글 → 미리 넣음(원형 마크 · 구조물 조합 줄) → 빈 도면.
|
||||
* 옮기거나 넣었으면 초안에 씀. */
|
||||
async function firstNoteCad(section: CrossSection, at: NoteCadPlace): Promise<CrossNoteCad> {
|
||||
const stored = noteCadOf(section, at);
|
||||
if (stored) return stored;
|
||||
let entities = legacyNoteEntities(section.design?.structure_note);
|
||||
if (!entities.length && section.culvert) {
|
||||
const owner = section.culvert.chainage_m ?? section.chainage_m;
|
||||
const options = await pipeOptionsAt(at.projectId, owner).catch(() => ({}));
|
||||
entities = culvertNoteEntities(section, at.station, options);
|
||||
if (!entities.length) {
|
||||
const lines = structureNoteLines(await at.things, section.chainage_m);
|
||||
entities = noteEntities(section, at.station, lines);
|
||||
}
|
||||
const cad: CrossNoteCad = { entities, layers: [BLANK_LAYER] };
|
||||
if (entities.length) writeNoteCad(section, cad, at);
|
||||
@@ -236,10 +284,12 @@ export interface NoteCadPlace {
|
||||
routeId: number;
|
||||
/** 미리 넣음 첫 줄(`stationNoLabel`). */
|
||||
station: string;
|
||||
/** 글 줄 재료(`loadNoteThings` · 모달이 한 번 받음). */
|
||||
things: Promise<NoteThing[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* [상세] 모달 가운데 칸(`first` = 연 측점 · `section` = 카드를 다시 그려 바뀐 지금 측점) — 캔버스만(`bare`) · 그리기 됨 · 고칠 때마다 편집본을 초안에 씀 ·
|
||||
* [상세] 모달 아래 칸(`first` = 연 측점 · `section` = 카드를 다시 그려 바뀐 지금 측점) — 캔버스만(`bare`) · 그리기 됨 · 고칠 때마다 편집본을 초안에 씀 ·
|
||||
* 모달을 닫으면 `destroy`. 칸 크기가 바뀌어도 다시 만들지 않음(iframe 이 스스로 맞춤).
|
||||
*/
|
||||
export function mountNoteCad(
|
||||
|
||||
@@ -579,7 +579,9 @@ export function createSectionView(
|
||||
elevationMid: plot.elevationMid,
|
||||
exaggeration: Math.max(currentExaggeration, 0.1),
|
||||
};
|
||||
return { svg, axes };
|
||||
// 칸 맞춤이 넓힌 가로 범위를 처음 배율로 되좁힘 = 카드 반폭(266-1)
|
||||
const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1;
|
||||
return { svg, axes, homeScale };
|
||||
},
|
||||
focusStation(stationId) {
|
||||
if (stationId === null) clearSelection();
|
||||
|
||||
@@ -39,12 +39,12 @@ export interface SectionViewController {
|
||||
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
|
||||
refreshCards: (chainages: ReadonlyArray<number>) => void;
|
||||
/** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null)
|
||||
* · 축 다시 그리기 옵션(179-3 · 기하 없으면 null). */
|
||||
* · 축 다시 그리기 옵션(179-3 · 기하 없으면 null) · 처음 배율(266-1 · 가로 = 카드 반폭). */
|
||||
detailChart: (
|
||||
stationId: string,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;
|
||||
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null;
|
||||
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
|
||||
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */
|
||||
focusStation: (stationId: string | null) => void;
|
||||
|
||||
@@ -51,8 +51,6 @@
|
||||
문제 제거(2026-08-06 사용자 보고). */
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface-raised);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b06-profile__group-legend {
|
||||
@@ -64,7 +62,7 @@
|
||||
|
||||
/* 라벨·입력·버튼을 **한 줄**로 세운다(횡단 보기 설정, 2026-08-23 사용자 지시).
|
||||
ui-field는 기본이 세로(라벨 위·입력 아래·그 아래 오류 슬롯)라 그대로 두면 버튼이
|
||||
오류 슬롯 높이만큼 밀려 수평이 안 맞는다 — 이 줄에서만 가로로 눕히고 오류 슬롯을 접는다. */
|
||||
오류 슬롯 높이만큼 밀려 수평이 안 맞는다 — 이 줄에서만 가로로 눕힘(오류 슬롯 접기 = 공용 양식 `.ui-panel-form`). */
|
||||
.b06-profile__field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -91,10 +89,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b06-profile__field-row .ui-field__error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 창이 좁아 좌측 패널이 237px 까지 물러난 자리 — 라벨이 안 줄어 2px 이 새어 나왔다.
|
||||
그 폭에서만 라벨을 윗줄로 접는다. 넓은 폭 배치는 손대지 않는다(2026-09-08 실측).
|
||||
⚠ 폭 제한 없이 `flex-wrap` 을 주면 넓은 폭에서도 라벨이 윗줄로 올라간다 — 재 보고 되돌렸다. */
|
||||
|
||||
@@ -360,6 +360,11 @@
|
||||
fill: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 관 정보 글(관매설 · 세월교 · 267-1) — 노폭 라벨(`.b06-chart__carriageway-label`)과 같은 글 크기 */
|
||||
.b06-chart__culvert-label.b06-chart__pipe-info {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 유입 보호공 공간 확보 절토선 — 말단에서 표준단면 절토경사로 지반까지 */
|
||||
.b06-chart__culvert-cut {
|
||||
stroke: var(--color-warning);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Style_Cross_Detail.css
|
||||
* 횡단 카드 [상세] 단추 · 모달(138-4 · 150-2) — 공용 큰 창 · 그림(남은 높이) → 글 칸 → B07 수량표.
|
||||
* 횡단 카드 [상세] 단추 · 모달(138-4 · 150-2) — 공용 큰 창 · 그림(남은 높이) → B07 수량표 → CAD 칸(265-1).
|
||||
* ========================================================================== */
|
||||
|
||||
/* 측구 줄 맨 오른쪽 — ⋯ 가 보이면 그 옆, 숨으면 혼자 오른쪽 끝 */
|
||||
@@ -18,6 +18,20 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 표 위 CAD 칸(265-1) — 표 아래 · 머리 한 줄 접기(공용 `ui-collapsible` · 접히면 그림이 남은 높이) */
|
||||
.b06-detail__notebox {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4, 4px);
|
||||
}
|
||||
|
||||
.b06-detail__notehead {
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.b06-detail__note {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
|
||||
@@ -29,6 +29,8 @@ const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** 구조물 엔티티가 들어갈 레이어·색 — 서버 도면이 이미 선언해 둔 그 레이어다. */
|
||||
const STRUCTURE_LAYER_ID = "b08-structure";
|
||||
const STRUCTURE_COLOR = "#f6d55c";
|
||||
/** 배수관 · 박스암거 색 — 구조물(노랑) · 설계선(보라) · 지반선(흰/검정)과 또렷이 다른 청록(268-2). */
|
||||
const PIPE_COLOR = "#22d3ee";
|
||||
/** 설계선 도면층·색 — 서버 도면(`B07_DesignDetail_Engine_Cad`)이 쓰는 그 값이다. */
|
||||
export const DESIGN_LAYER_ID = "b08-design";
|
||||
const DESIGN_COLOR = "#b794f6";
|
||||
@@ -49,6 +51,7 @@ interface Style {
|
||||
}
|
||||
|
||||
const STRUCTURE_STYLE: Style = { layerId: STRUCTURE_LAYER_ID, color: STRUCTURE_COLOR };
|
||||
const PIPE_STYLE: Style = { layerId: STRUCTURE_LAYER_ID, color: PIPE_COLOR };
|
||||
const DESIGN_STYLE: Style = { layerId: DESIGN_LAYER_ID, color: DESIGN_COLOR };
|
||||
|
||||
function baseEntity(style: Style, type: string, shapeData: unknown): Entity {
|
||||
@@ -494,9 +497,10 @@ function drawDesign(
|
||||
);
|
||||
}
|
||||
|
||||
/** 구조물을 그린다. 그리는 순서는 B06 카드와 같다. */
|
||||
/** 구조물을 그린다. 그리는 순서는 B06 카드와 같다 — 박스암거 · 배수관은 pipeSvg 로 따로(268-2). */
|
||||
function drawStructures(
|
||||
svg: SVGElement,
|
||||
pipeSvg: SVGElement,
|
||||
section: CrossSection,
|
||||
layouts: Layouts,
|
||||
x: (offset: number) => number,
|
||||
@@ -504,12 +508,12 @@ function drawStructures(
|
||||
): void {
|
||||
const { link, culvert, box, ford, own } = layouts;
|
||||
if (own) appendRevetmentOverlay(svg, own, x, y);
|
||||
if (box) appendBoxOverlay(svg, box, x, y);
|
||||
if (box) appendBoxOverlay(pipeSvg, box, x, y);
|
||||
if (ford) appendFordOverlay(svg, ford, x, y);
|
||||
if (culvert) {
|
||||
const linked = !section.culvert && !!link;
|
||||
appendCulvertOverlay(
|
||||
svg,
|
||||
pipeSvg,
|
||||
culvert,
|
||||
x,
|
||||
y,
|
||||
@@ -536,9 +540,11 @@ export function overlayEntities(
|
||||
const designSvg = document.createElementNS(SVG_NS, "svg");
|
||||
drawDesign(designSvg, section, layouts, x, y);
|
||||
const structureSvg = document.createElementNS(SVG_NS, "svg");
|
||||
drawStructures(structureSvg, section, layouts, x, y);
|
||||
const pipeSvg = document.createElementNS(SVG_NS, "svg");
|
||||
drawStructures(structureSvg, pipeSvg, section, layouts, x, y);
|
||||
return [
|
||||
...harvest(designSvg, DESIGN_STYLE, frame),
|
||||
...harvest(structureSvg, STRUCTURE_STYLE, frame),
|
||||
...harvest(pipeSvg, PIPE_STYLE, frame),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -628,6 +628,12 @@ def build_cross_drawing(
|
||||
# 블록 테두리(종이 mm). 프론트가 자기 그림을 이 안으로 자르고, 갈아 끼울
|
||||
# 서버 설계선을 이 안에서만 골라내는 데 쓴다.
|
||||
"frame": [frame_left, frame_bottom, frame_right, frame_top],
|
||||
# 화면이 다시 그려 갈아 끼울 서버 선 — 설계선 + 측구 구간 구조물 선(268-1)
|
||||
"design_ids": [
|
||||
e["id"]
|
||||
for e in entities
|
||||
if e["layerId"] in (DESIGN_LAYER_ID, STRUCTURE_LAYER_ID)
|
||||
],
|
||||
}
|
||||
],
|
||||
"layers": [
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""B07 횡단도 표 위 CAD 칸 — 횡단배수 미리 넣음(260-4 · 요구 162-21).
|
||||
"""B07 횡단도 표 위 CAD 칸 — 미리 넣음(260-4 · 265-2 · 요구 162-21 · 164-5).
|
||||
|
||||
⚠ 짝(①) = `B06_Section/B06_Section_UI_Cross_Detail_Cad.ts` `culvertNoteEntities` ·
|
||||
`stationNoLabel` — 같은 입력 → 같은 엔티티(id 만 다름) · 거울 시험 `test_260_note_cad.py`.
|
||||
⚠ 짝(①) = `B06_Section/B06_Section_UI_Cross_Detail_Cad.ts` `noteEntities` · `structureNoteLines` ·
|
||||
`stationNoLabel` — 같은 입력 → 같은 엔티티(id 만 다름) · 거울 시험 `test_260_note_cad.py` ·
|
||||
`test_265_note_cad.py`.
|
||||
B06 [상세] 모달을 한 번도 안 연 측점(칸 = 없음)에 B07 이 같은 그림을 놓음 · 사용자가 지운 칸
|
||||
(빈 엔티티)은 덮지 않음 · 옛 글(`structure_note`)이 있으면 옛 글이 먼저
|
||||
(모달이 처음 열 때 옮기는 차례와 같음).
|
||||
그림 = 관 주인 측점 원형 마크(배수관 · Φ · L 정수) + 측점 · 그 측점 구조물마다 지정된
|
||||
구조물 조합 이름 줄(미지정 = 「이름 조합 미지정」).
|
||||
좌표 = 도면 mm(글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수.
|
||||
"""
|
||||
|
||||
@@ -14,16 +17,26 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
|
||||
from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file
|
||||
from common_util.common_util_drainage_pipes import PIPE_FACILITY_PIPE, parse_pipe_points
|
||||
|
||||
# TS `PIPE_MATCH_M`(`design_selection`) — 관 자리와 측점이 같은 자리로 보는 폭
|
||||
# TS `PIPE_MATCH_M`(`design_selection`) — 관 · 구조물 자리와 측점이 같은 자리로 보는 폭
|
||||
PIPE_MATCH_M = 0.51
|
||||
_LAYER_ID = "기본"
|
||||
_FONT = 2.0
|
||||
_LINE = 3.0
|
||||
_COLOR = "#ff7f7f"
|
||||
_BASIN_FORMS = {"I": "I형", "L": "L형", "U": "ㄷ형"}
|
||||
# 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리
|
||||
_PIPE_BODY_LINK = "grv6lny"
|
||||
_LAYERS = ("system", "company", "personal")
|
||||
_GROUP_ID = re.compile(r"^g[0-9a-z]{1,16}$")
|
||||
|
||||
#: (관 누가거리, 세트, 옵션) — 원형 마크 재료
|
||||
Pipe = tuple[float, dict[str, Any], dict[str, Any]]
|
||||
#: 글 줄 재료 한 건 `{name, pipe, at, summary_item, group_items}` — TS `NoteThing`
|
||||
Thing = dict[str, Any]
|
||||
|
||||
|
||||
def _js_round(value: float) -> int:
|
||||
@@ -31,69 +44,13 @@ def _js_round(value: float) -> int:
|
||||
return math.floor(value + 0.5)
|
||||
|
||||
|
||||
def _one(value: Any) -> str:
|
||||
ok = isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
||||
return f"{value:.1f}" if ok else ""
|
||||
def _number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
||||
|
||||
|
||||
def _metres(value: Any) -> str:
|
||||
return f"{_one(value)}m" if _one(value) else ""
|
||||
|
||||
|
||||
def _trim(value: float) -> str:
|
||||
rounded = _js_round(value * 100) / 100
|
||||
return str(int(rounded)) if rounded == int(rounded) else str(rounded)
|
||||
|
||||
|
||||
def _bond(form: Any) -> str:
|
||||
"""돌쌓기(찰) · 찰붙임 → 찰 · (메) · 메붙임 → 메 · 그 밖 = 그대로."""
|
||||
text = str(form) if form is not None else ""
|
||||
return "찰" if "찰" in text else "메" if "메" in text else text
|
||||
|
||||
|
||||
def _side_line(side: Any, title: str) -> str | None:
|
||||
if not isinstance(side, dict) or not side.get("structure"):
|
||||
return None
|
||||
basin = side["structure"] == "집수정"
|
||||
form = (
|
||||
_BASIN_FORMS.get(side.get("basin_form") or "", "")
|
||||
if basin
|
||||
else _bond(side.get("revet_form"))
|
||||
)
|
||||
height = side.get("basin_height_m" if basin else "revet_height_m")
|
||||
length = side.get("basin_length_m" if basin else "revet_length_m")
|
||||
spec = ",".join(part for part in (form, f"H={_one(height)}") if part)
|
||||
return f"관보호공({title}) {side['structure']}({spec}) L={_metres(length)}"
|
||||
|
||||
|
||||
def _area(value: Any) -> float:
|
||||
"""JS `Number()` 꼴 — 빈 글 = 0 · 못 읽음 = nan."""
|
||||
if value is None:
|
||||
return math.nan
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return 0.0
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return math.nan
|
||||
|
||||
|
||||
def _chute_line(options: dict[str, Any], fallback: str | None) -> str | None:
|
||||
picked = sorted(
|
||||
int(hit.group(1))
|
||||
for key, value in options.items()
|
||||
if (hit := re.match(r"^chute_add_(\d+)_structure$", key))
|
||||
and str(value if value is not None else "").strip()
|
||||
)
|
||||
head = f"chute_add_{picked[0]}_" if picked else None
|
||||
structure = str(options[f"{head}structure"]).strip() if head else fallback
|
||||
if not structure:
|
||||
return None
|
||||
bond_raw = options.get(f"{head}bond") if head else None
|
||||
bond = _bond(bond_raw if bond_raw is not None else "") if head else ""
|
||||
area = _area(options.get(f"{head}area")) if head else math.nan
|
||||
spec = ",".join(part for part in (structure, bond) if part)
|
||||
return f"관유출부보강({spec}) {_trim(area) if area > 0 else ''}m2"
|
||||
def _whole_metres(value: Any) -> str:
|
||||
"""관 길이 = 사사오입 정수(265-3 · TS `pipeLengthWhole` · B08 「관매설」과 같은 값)."""
|
||||
return f"{_js_round(value)}m" if _number(value) else ""
|
||||
|
||||
|
||||
def station_no_label(chainage_m: float, interval_m: float) -> str:
|
||||
@@ -107,6 +64,59 @@ def station_no_label(chainage_m: float, interval_m: float) -> str:
|
||||
return f"NO.{number}" if abs(rest) < 0.05 else f"NO.{number}+{_js_round(rest)}"
|
||||
|
||||
|
||||
def _summary_item(value: Any) -> dict[str, Any] | None:
|
||||
"""TS `readSummaryItem` — 모양이 맞으면 그대로 · 틀리면 None."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
owner, key = value.get("owner"), value.get("key")
|
||||
ok = (
|
||||
value.get("layer") in _LAYERS
|
||||
and (owner is None or isinstance(owner, str))
|
||||
and isinstance(key, str)
|
||||
and key != ""
|
||||
and isinstance(value.get("name"), str)
|
||||
)
|
||||
return value if ok else None
|
||||
|
||||
|
||||
def _group_items(value: Any) -> list[tuple[str, dict[str, Any]]]:
|
||||
"""TS `readGroupItems` — 그룹 id · 항목 모양이 맞는 것만(차례 그대로)."""
|
||||
if not isinstance(value, dict):
|
||||
return []
|
||||
return [
|
||||
(gid, item)
|
||||
for gid, raw in value.items()
|
||||
if _GROUP_ID.match(str(gid)) and (item := _summary_item(raw))
|
||||
]
|
||||
|
||||
|
||||
def things_at(things: list[Thing], chainage_m: float) -> list[Thing]:
|
||||
"""그 측점에 놓인 구조물 — TS `thingsAt`."""
|
||||
return [thing for thing in things if abs(thing["at"] - chainage_m) < PIPE_MATCH_M]
|
||||
|
||||
|
||||
def structure_note_lines(things: list[Thing], chainage_m: float) -> list[str]:
|
||||
"""구조물 조합 줄 — TS `structureNoteLines` 짝.
|
||||
|
||||
본체 → 그룹 차례 · 같은 이름 한 줄 · 미지정 = 「이름 조합 미지정」 · 배관 본체 뺌.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for thing in things_at(things, chainage_m):
|
||||
own = _summary_item(thing.get("summary_item"))
|
||||
slots = ([("", own)] if own else []) + _group_items(thing.get("group_items"))
|
||||
if not slots:
|
||||
if not thing["pipe"]:
|
||||
lines.append(f"{thing['name']} 조합 미지정")
|
||||
continue
|
||||
names = [
|
||||
item["name"] or item["key"]
|
||||
for gid, item in slots
|
||||
if not (thing["pipe"] and gid in ("", _PIPE_BODY_LINK))
|
||||
]
|
||||
lines += list(dict.fromkeys(names))
|
||||
return lines
|
||||
|
||||
|
||||
def _shape(index: int, kind: str, shape_data: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": f"culvert-note:{index}",
|
||||
@@ -136,17 +146,10 @@ def _text(index: int, label: str, x: float, y: float, align: str = "left") -> di
|
||||
)
|
||||
|
||||
|
||||
def culvert_note_entities(
|
||||
chainage_m: float,
|
||||
culvert: dict[str, Any] | None,
|
||||
design: dict[str, Any] | None,
|
||||
station: str,
|
||||
options: dict[str, Any] | None = None,
|
||||
def _pipe_mark(
|
||||
chainage_m: float, culvert: dict[str, Any] | None, design: dict[str, Any] | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""횡단배수 미리 넣음 — 이 측점에 놓인(주인) 배수관만 · 상자형 · 숨은 관 · 이웃 = 빈 목록.
|
||||
|
||||
`culvert` = 측점에 얹힌 세트(`attach_culvert_sets` 꼴) · `options` = 그 관의 `pipe_points` 옵션.
|
||||
"""
|
||||
"""원형 마크 — TS `pipeMark`(주인 배수관만 · 상자형 · 숨은 관 · 이웃 = 없음)."""
|
||||
if not isinstance(culvert, dict) or culvert.get("type") != "pipe":
|
||||
return []
|
||||
if culvert.get("body") or culvert.get("hidden_pipe"):
|
||||
@@ -159,20 +162,9 @@ def culvert_note_entities(
|
||||
inside = [
|
||||
"배수관",
|
||||
f"Φ{_js_round(float(culvert.get('diameter_m') or 0.0) * 1000)}m/m",
|
||||
f"L={_metres(length)}",
|
||||
f"L={_whole_metres(length)}",
|
||||
]
|
||||
chute = culvert.get("chute") if isinstance(culvert.get("chute"), dict) else {}
|
||||
right = [
|
||||
line
|
||||
for line in (
|
||||
station,
|
||||
_side_line(culvert.get("inlet"), "유입"),
|
||||
_side_line(culvert.get("outlet"), "유출"),
|
||||
_chute_line(options or {}, chute.get("structure")),
|
||||
)
|
||||
if line
|
||||
]
|
||||
entities = [
|
||||
return [
|
||||
_shape(0, "Circle", {"center": {"x": cx, "y": cy}, "radius": radius}),
|
||||
_shape(
|
||||
1,
|
||||
@@ -182,38 +174,84 @@ def culvert_note_entities(
|
||||
"endPoint": {"x": cx, "y": cy - radius - _LINE},
|
||||
},
|
||||
),
|
||||
]
|
||||
entities += [
|
||||
_text(2 + i, line, cx, cy + _LINE * (1 - i), "center") for i, line in enumerate(inside)
|
||||
]
|
||||
entities += [_text(5 + i, line, 15, -_LINE * (i + 0.5)) for i, line in enumerate(right)]
|
||||
return entities
|
||||
] + [_text(2 + i, line, cx, cy + _LINE * (1 - i), "center") for i, line in enumerate(inside)]
|
||||
|
||||
|
||||
def load_pipe_notes(project_root: Path) -> list[tuple[float, dict[str, Any], dict[str, Any]]]:
|
||||
"""정본 `pipe_points.json` 의 배수관 — (관 누가거리, 세트, 옵션) · 관경 안 고름 = 관 없음."""
|
||||
path = pipe_points_file(project_root)
|
||||
try:
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
def note_entities(
|
||||
chainage_m: float,
|
||||
culvert: dict[str, Any] | None,
|
||||
design: dict[str, Any] | None,
|
||||
station: str,
|
||||
lines: list[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""미리 넣음 — TS `noteEntities` 짝(원형 마크 + 측점 · 조합 줄 · 마크 없으면 왼쪽 끝부터).
|
||||
|
||||
`culvert` = 측점에 얹힌 세트(`attach_culvert_sets` 꼴) · `lines` = `structure_note_lines`.
|
||||
"""
|
||||
mark = _pipe_mark(chainage_m, culvert, design)
|
||||
if not mark and not lines:
|
||||
return []
|
||||
pipes = []
|
||||
for point in parse_pipe_points(document.get("points")):
|
||||
x = 15 if mark else 0
|
||||
start = len(mark)
|
||||
return mark + [
|
||||
_text(start + i, line, x, -_LINE * (i + 0.5)) for i, line in enumerate([station, *lines])
|
||||
]
|
||||
|
||||
|
||||
def _things(points: list[Any], structures: list[Any], names: dict[str, str]) -> list[Thing]:
|
||||
"""글 줄 재료 — 관 지점 → 구조물 배치 차례(TS `loadNoteThings` 와 같은 차례)."""
|
||||
things: list[Thing] = [
|
||||
{
|
||||
"name": names.get(point.facility, point.facility),
|
||||
"pipe": point.facility == PIPE_FACILITY_PIPE,
|
||||
"at": float(point.chainage_m),
|
||||
"summary_item": point.summary_item,
|
||||
"group_items": point.group_items,
|
||||
}
|
||||
for point in points
|
||||
]
|
||||
for item in structures:
|
||||
at = item.chainage_m if item.chainage_m is not None else item.start_m
|
||||
if not _number(at):
|
||||
continue
|
||||
things.append(
|
||||
{
|
||||
"name": names.get(item.type_id, item.type_id),
|
||||
"pipe": False,
|
||||
"at": float(at),
|
||||
"summary_item": item.summary_item,
|
||||
"group_items": item.group_items,
|
||||
}
|
||||
)
|
||||
return things
|
||||
|
||||
|
||||
def load_note_sources(project_root: Path) -> tuple[list[Pipe], list[Thing]]:
|
||||
"""정본 재료 — (원형 마크 배수관 · 관경 안 고름 = 없음, 글 줄 재료 = 관 지점 + 구조물 배치)."""
|
||||
try:
|
||||
document = json.loads(pipe_points_file(project_root).read_text(encoding="utf-8"))
|
||||
points = parse_pipe_points(document.get("points"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
points = []
|
||||
pipes: list[Pipe] = []
|
||||
for point in points:
|
||||
options = dict(point.options or {})
|
||||
if point.facility != PIPE_FACILITY_PIPE or options.get("pipe_diameter_mm") in (None, ""):
|
||||
continue
|
||||
spec = {**_culvert_set(options), "chainage_m": float(point.chainage_m)}
|
||||
pipes.append((float(point.chainage_m), spec, options))
|
||||
return pipes
|
||||
_revision, structures = load_structures(str(project_root))
|
||||
names = {kind.type_id: kind.name for kind in load_structure_types()}
|
||||
return pipes, _things(points, structures, names)
|
||||
|
||||
|
||||
def with_culvert_note(
|
||||
def with_structure_note(
|
||||
design: dict[str, Any] | None,
|
||||
chainage_m: float,
|
||||
pipes: list[tuple[float, dict[str, Any], dict[str, Any]]],
|
||||
sources: tuple[list[Pipe], list[Thing]],
|
||||
station: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""CAD 칸이 없고 옛 글도 없는 주인 측점 — 미리 넣음을 칸으로 얹은 사본 · 그 밖 = 그대로.
|
||||
"""CAD 칸이 없고 옛 글도 없는 측점 — 미리 넣음을 칸으로 얹은 사본 · 그 밖 = 그대로.
|
||||
|
||||
설계가 없는 측점(dict 아님)은 그대로 — 표 · 계획선 셈이 설계 없음으로 가는 길을 안 바꿈.
|
||||
"""
|
||||
@@ -221,11 +259,11 @@ def with_culvert_note(
|
||||
return design
|
||||
if str(design.get("structure_note") or "").strip():
|
||||
return design
|
||||
pipes, things = sources
|
||||
near = [pipe for pipe in pipes if abs(pipe[0] - chainage_m) < PIPE_MATCH_M]
|
||||
if not near:
|
||||
return design
|
||||
_at, spec, options = min(near, key=lambda pipe: abs(pipe[0] - chainage_m))
|
||||
entities = culvert_note_entities(chainage_m, spec, design, station, options)
|
||||
spec = min(near, key=lambda pipe: abs(pipe[0] - chainage_m))[1] if near else None
|
||||
lines = structure_note_lines(things, chainage_m)
|
||||
entities = note_entities(chainage_m, spec, design, station, lines)
|
||||
if not entities:
|
||||
return design
|
||||
return {**design, "structure_note_cad": {"entities": entities}}
|
||||
|
||||
@@ -240,7 +240,9 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
|
||||
layer_id = f"{STATION_LAYER_PREFIX}{section['chainage']}"
|
||||
title = section.get("title") or f"{section['chainage']}"
|
||||
station_layers.append(_layer(layer_id, f"횡단 {title}"))
|
||||
design_ids = [e["id"] for e in placed["entities"] if e["layerId"] == DESIGN_LAYER_ID]
|
||||
# 측구 구간 구조물 선(b08-structure)도 갈아 끼울 대상 — 화면 구조물 덧그림이 같은 자리를 그림(268-1)
|
||||
replaceable = (DESIGN_LAYER_ID, STRUCTURE_LAYER_ID)
|
||||
design_ids = [e["id"] for e in placed["entities"] if e["layerId"] in replaceable]
|
||||
for entity in placed["entities"]:
|
||||
_move_to_layer(entity, layer_id)
|
||||
for placement in placed.get("cross_placements") or []:
|
||||
|
||||
@@ -26,8 +26,10 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import (
|
||||
load_pipe_notes,
|
||||
with_culvert_note,
|
||||
Pipe,
|
||||
Thing,
|
||||
load_note_sources,
|
||||
with_structure_note,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import (
|
||||
station_no_label as station_no_label_upper,
|
||||
@@ -390,11 +392,12 @@ def _cross_section_input(
|
||||
chainage: int | float,
|
||||
design: dict[str, Any] | None,
|
||||
overlay: tuple[float, float, float, float] | None = None,
|
||||
pipes: list[tuple[float, dict[str, Any], dict[str, Any]]] | None = None,
|
||||
pipes: tuple[list[Pipe], list[Thing]] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다.
|
||||
|
||||
`pipes` = 정본 배수관(`load_pipe_notes`) — CAD 칸 없는 주인 측점에 미리 넣음(260-4)."""
|
||||
`pipes` = 정본 재료(`load_note_sources` · 배수관 · 구조물) — CAD 칸 없는 측점에
|
||||
미리 넣음(260-4 · 265-2)."""
|
||||
path = find_cross_file(longitudinal_path.parent.parent / "cross_sections", chainage)
|
||||
if not path.is_file():
|
||||
return None
|
||||
@@ -405,7 +408,9 @@ def _cross_section_input(
|
||||
"chainage": chainage,
|
||||
"source": source,
|
||||
# 표 · 계획선은 저장 설계 그대로 · 그림만 미리 넣음 칸을 얹은 사본(260-4)
|
||||
"design": with_culvert_note(design, at, pipes or [], station_no_label_upper(at, interval)),
|
||||
"design": with_structure_note(
|
||||
design, at, pipes or ([], []), station_no_label_upper(at, interval)
|
||||
),
|
||||
"design_line": _cross_design_line(longitudinal_path, source, design),
|
||||
"quantity_table": _quantity_table(source, design),
|
||||
"title": station_no_label(float(source.get("chainage_m", chainage)), interval),
|
||||
@@ -428,7 +433,7 @@ def _read_cross_sheet(
|
||||
if sheet is None:
|
||||
raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.")
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
pipes = load_pipe_notes(project_root)
|
||||
pipes = load_note_sources(project_root)
|
||||
sections = [
|
||||
section
|
||||
for section in (
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* DOM 만 만들고 상태는 갖지 않는다 — 고르기 동작은 인자로 받은 콜백에 넘긴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
|
||||
@@ -79,7 +79,7 @@ export function buildDrawingSidePanel(
|
||||
const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b07-drawing-button";
|
||||
button.className = "b07-drawing-button ui-panel-btn";
|
||||
button.dataset.drawingId = drawing.id;
|
||||
button.dataset.confirmed = String(drawing.confirmed);
|
||||
const name = document.createElement("span");
|
||||
@@ -114,7 +114,7 @@ export function buildDrawingSidePanel(
|
||||
if (!drawing) {
|
||||
const placeholder = document.createElement("button");
|
||||
placeholder.type = "button";
|
||||
placeholder.className = "b07-drawing-button";
|
||||
placeholder.className = "b07-drawing-button ui-panel-btn";
|
||||
placeholder.disabled = true;
|
||||
placeholder.title = "준비 중";
|
||||
placeholder.dataset.pending = "true";
|
||||
@@ -132,14 +132,10 @@ export function buildDrawingSidePanel(
|
||||
panel.append(button);
|
||||
continue;
|
||||
}
|
||||
const section = document.createElement("section");
|
||||
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(B04~B06과 통일, 2026-08-06 사용자 지시).
|
||||
section.className = "b07-drawing-group ui-collapsible ui-sidebar-section";
|
||||
// 컨테이너 = 좌측 패널 공용 카드(269 · B05/B06 「구조물 배치」 양식).
|
||||
const section = createPanelCard(`${group.label} ${items.length}`);
|
||||
section.classList.add("b07-drawing-group");
|
||||
if (group.kind) section.dataset.kind = group.kind;
|
||||
const sectionTitle = document.createElement("h3");
|
||||
sectionTitle.className = "ui-collapsible__title";
|
||||
sectionTitle.textContent = `${group.label} ${items.length}`;
|
||||
section.append(sectionTitle);
|
||||
if (cross) {
|
||||
// 표준 횡단면도 = 맨 위 한 줄(전체 폭) · 개수는 횡단 장만.
|
||||
for (const standard of drawings.filter((item) => item.kind === "cross_standard"))
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||
import { createPanelCard } from "@ui/ui_template_collapsible";
|
||||
import { createSelectField, showToast } from "@ui/ui_template_elements";
|
||||
import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields";
|
||||
import {
|
||||
@@ -257,9 +258,10 @@ export function buildPlanSection(
|
||||
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
|
||||
onManage?: () => void,
|
||||
): HTMLElement {
|
||||
const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section");
|
||||
// 컨테이너 = 좌측 패널 공용 카드(269)
|
||||
const section = createPanelCard(`평면도 ${items.length}`);
|
||||
section.classList.add("b07-drawing-group", "b07-plan");
|
||||
section.dataset.kind = "plan";
|
||||
section.append(el("h3", "ui-collapsible__title", `평면도 ${items.length}`));
|
||||
|
||||
for (const { kindId, name, sheets } of groupPlanSheets(items)) {
|
||||
const block = el("div", "b07-plan-kind");
|
||||
@@ -273,7 +275,7 @@ export function buildPlanSection(
|
||||
|
||||
if (onManage) {
|
||||
// 종류 · 대피소 자리 · 장 미리보기는 모달 한 곳(좌측 공간 · 접근 혼돈 — 130-1).
|
||||
const manage = el("button", "b07-plan-tools__button b07-plan-manage-open", "평면도 관리");
|
||||
const manage = el("button", "ui-panel-btn b07-plan-manage-open", "평면도 관리");
|
||||
manage.type = "button";
|
||||
manage.addEventListener("click", onManage);
|
||||
section.append(manage);
|
||||
|
||||
@@ -40,15 +40,6 @@
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b07-drawing-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
/* 테두리 색·두께는 공통 ui-sidebar-section이 준다 — 여기선 형태(라운드·안쪽 여백)만. */
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 횡단도는 2열 배치 — 단, **칸이 좁아지면 1열로 접는다**(2026-09-08).
|
||||
좌측 패널이 320 고정이던 동안은 2열이 늘 128.8px 라 문제가 없었으나, 공용
|
||||
오버레이가 좁은 폭에서 패널을 280·227·182 로 물리면서 칸이 108.8 → 82.4 → 60px
|
||||
@@ -62,13 +53,6 @@
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b07-drawing-group h3 {
|
||||
margin: var(--spacing-8) 0 var(--spacing-4);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 아직 내용이 없는 도면 — 눌리기는 하고 도각만 나온다(2026-09-01). 옅게 두되
|
||||
못 누르는 것처럼 보이지 않게 커서는 그대로 둔다. */
|
||||
.b07-drawing-button[data-pending="true"] {
|
||||
@@ -94,22 +78,13 @@
|
||||
/* 격자 칸의 기본 최소폭은 글자 길이라, 이름이 길면 칸이 패널을 밀어내 단추가
|
||||
오른쪽으로 튀어나갔다(2026-09-04 실측 14px). 0으로 풀면 이름이 `…`로 잘린다. */
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
/* 경계가 뚜렷하도록 배경색과 유사하지만 조금 짙은 톤 + 은은한 테두리 */
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 55%, transparent);
|
||||
/* 확정 여부를 나타내는 좌측 색 띠 (미확정: 투명) */
|
||||
/* 크기 · 테두리 · 바탕 · 글자 = 공용 `.ui-panel-btn`(269) · 여기선 확정 여부 좌측 색 띠(미확정: 투명)만 */
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: var(--radius-buttons);
|
||||
background: color-mix(in srgb, var(--color-surface) 84%, #000);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.b07-drawing-button__name {
|
||||
overflow: hidden;
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -227,7 +202,7 @@
|
||||
|
||||
/* 횡단 장 배치(153-1 · 212-3) — 횡단도 묶음 맨 아래 전체 폭 · 한 줄에 한 일:
|
||||
배치 열 · 행 / [이 장] [모든 장] / 표 배율 [배율 적용] / [안내선 숨기기].
|
||||
단추 · 숫자 칸 = 줄 위 작은 단추 한 규격(`--btn-bar-*` · 높이 16) · 한 줄 안 같은 너비 · 오류 자리는 접음. */
|
||||
단추 · 숫자 칸 크기 = 좌측 패널 공용 양식(`.ui-panel-form` · 269) · 한 줄 안 같은 너비. */
|
||||
.b07-cross-layout-slot {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -260,17 +235,8 @@
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b07-cross-layout .ui-btn,
|
||||
.b07-cross-layout .ui-field .ui-input {
|
||||
height: auto;
|
||||
padding: var(--btn-bar-padding);
|
||||
font-size: var(--btn-bar-font-size);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.b07-cross-layout .ui-field__label {
|
||||
margin: 0;
|
||||
font-size: var(--btn-bar-font-size);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -279,10 +245,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b07-cross-layout .ui-field__error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */
|
||||
.b07-drawing-list__bypass {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
@@ -315,10 +277,6 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b07-plan-manage-open {
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* [평면도 관리] 모달(130-1) — 왼쪽 종류 · 자리 · 장 목록 / 오른쪽 미리보기 · 공용 모달에 자기 class 로 큰 창 */
|
||||
.ui-modal.b07-plan-manage {
|
||||
width: min(1320px, 96vw);
|
||||
|
||||
@@ -204,6 +204,19 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 262-2 고정 시점 표지 — 둥근 붉은 알약(고르는 표지 = 기본 핀 + 흰 이름표와 구별) */
|
||||
.b08-milestone__map-start {
|
||||
padding: 3px 10px;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 999px;
|
||||
background: #e60046;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 등록 · 연결선 모달(244-13) — 더 큰 창 · 2열 칸 */
|
||||
.ui-modal.b08-milestone__modal {
|
||||
width: min(960px, 94vw);
|
||||
|
||||
@@ -98,6 +98,8 @@ export interface MapPin {
|
||||
lon: number;
|
||||
lat: number;
|
||||
movable: boolean;
|
||||
/** 262-2 고정 시점 표지 — 끌기 · 맞춤 · 보기 이동에 안 쓰임 · 고르는 표지와 모양이 다름 */
|
||||
fixed?: boolean;
|
||||
}
|
||||
|
||||
export interface EditMap {
|
||||
@@ -127,8 +129,9 @@ export function editMap(
|
||||
loadKakaoMaps(projectId).then(
|
||||
(maps) =>
|
||||
requestAnimationFrame(() => {
|
||||
const moving = pins.filter((p) => !p.fixed);
|
||||
const points = [
|
||||
...pins.map((p) => new maps.LatLng(p.lat, p.lon)),
|
||||
...moving.map((p) => new maps.LatLng(p.lat, p.lon)),
|
||||
...lines.flat().map(([lat, lon]) => new maps.LatLng(lat, lon)),
|
||||
];
|
||||
const c = opts.center?.();
|
||||
@@ -156,6 +159,17 @@ export function editMap(
|
||||
),
|
||||
...pins.flatMap((p) => {
|
||||
const at = new maps.LatLng(p.lat, p.lon);
|
||||
if (p.fixed)
|
||||
return [
|
||||
new maps.CustomOverlay({
|
||||
position: at,
|
||||
content: el("span", {
|
||||
className: "b08-milestone__map-start",
|
||||
text: p.name,
|
||||
}),
|
||||
yAnchor: 0.5,
|
||||
}),
|
||||
];
|
||||
const marker = new maps.Marker({ position: at, title: p.name, draggable: p.movable });
|
||||
if (p.movable)
|
||||
maps.event.addListener(marker, "dragend", () => {
|
||||
@@ -176,7 +190,7 @@ export function editMap(
|
||||
if (fitted) {
|
||||
// 보기 밖 표지만 그쪽으로 옮김(줌 그대로)
|
||||
const view = map.getBounds();
|
||||
const out = pins.find((p) => !view.contain(new maps.LatLng(p.lat, p.lon)));
|
||||
const out = moving.find((p) => !view.contain(new maps.LatLng(p.lat, p.lon)));
|
||||
if (out) map.panTo(new maps.LatLng(out.lat, out.lon));
|
||||
} else if (points.length > 1) {
|
||||
const bounds = new maps.LatLngBounds();
|
||||
|
||||
@@ -106,8 +106,8 @@ export function openPlaceModal(
|
||||
const title = target.add
|
||||
? "요소 추가 — 기준점 · 거래처"
|
||||
: base
|
||||
? `기준점 — ${base.name || "기준점"}`
|
||||
: `거래처 — ${route!.name || "거래처"}`; // 254-3 표 머리 글(항목 정보) 없음
|
||||
? base.name || "기준점"
|
||||
: route!.name || "거래처"; // 262-1 「—」 앞 글자 없음
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: "닫기",
|
||||
@@ -259,7 +259,7 @@ export interface SiteAddress {
|
||||
/** 시점(공사지) 상자 — 중점 측점(km · 저장은 m) · 시점 → 중점 도로 · 아래 카카오 지도(시점 · 중점 표지) */
|
||||
export function openSiteInfo(projectId: string): void {
|
||||
openModal({
|
||||
title: "공사지 — 시점 · 중점",
|
||||
title: "시점 · 중점",
|
||||
closeLabel: "닫기",
|
||||
dialogClass: "b08-milestone__modal",
|
||||
mount: (body, close, actions) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e
|
||||
import { searchAddress, type AddressCandidate } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
||||
import { request } from "./B08_Quantity_UI_Hydro";
|
||||
import { editMap } from "./B08_Quantity_UI_Milestone_KakaoMap";
|
||||
import { siteInfo } from "./B08_Quantity_UI_Milestone_State";
|
||||
import { liveMilestone, startPoint } from "./B08_Quantity_UI_Milestone_State";
|
||||
|
||||
export interface Place {
|
||||
address: string;
|
||||
@@ -52,27 +52,35 @@ export function placePicker(projectId: string, initial: Place, name: () => strin
|
||||
list.hidden = true;
|
||||
const where = el("p", { className: "b08-milestone__place" });
|
||||
where.dataset.b08MilestonePlace = "value";
|
||||
// 262-2 시점 = 거리이정표가 쓰는 시점(끌어 옮긴 덮개 · 없으면 노선 시점)
|
||||
const start = (): [number, number] | null => startPoint(liveMilestone(projectId));
|
||||
const map = editMap(projectId, {
|
||||
hint: "지도 누름 = 그 자리를 소재지로 · 표지 끌어 옮김",
|
||||
onClick: (lon, lat) => void moveTo(lon, lat),
|
||||
onMove: (_key, lon, lat) => void moveTo(lon, lat),
|
||||
center: () => siteInfo()?.start_lonlat ?? null,
|
||||
center: start,
|
||||
});
|
||||
|
||||
const draw = (): void => {
|
||||
where.textContent = `소재지 — ${place.address || "주소 찾기 · 지도에서 고르세요"}`;
|
||||
const s = start();
|
||||
map.set(
|
||||
place.lon != null && place.lat != null
|
||||
? [
|
||||
{
|
||||
key: "place",
|
||||
name: name() || "소재지",
|
||||
lon: place.lon,
|
||||
lat: place.lat,
|
||||
movable: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
[
|
||||
...(s
|
||||
? [{ key: "start", name: "시점", lon: s[0], lat: s[1], movable: false, fixed: true }]
|
||||
: []),
|
||||
...(place.lon != null && place.lat != null
|
||||
? [
|
||||
{
|
||||
key: "place",
|
||||
name: name() || "소재지",
|
||||
lon: place.lon,
|
||||
lat: place.lat,
|
||||
movable: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -370,7 +370,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
actions.append(save, reset);
|
||||
const left = document.createElement("div");
|
||||
left.className = "ui-sidebar-panel b09-options";
|
||||
left.append(section, actions);
|
||||
left.append(section, ...DRAFT.panels.map((make) => make(() => user, setUser)), actions);
|
||||
attachCollapsible(left);
|
||||
|
||||
const layout = createWorkflowLayout({
|
||||
|
||||
@@ -122,11 +122,13 @@ export interface TabTable {
|
||||
export type TabBuild = (ctx: TabContext) => TabTable | string;
|
||||
|
||||
/** 초안 탭(252 · `draft/*_UI.ts` 가 TABS · TAB_GROUPS 와 함께 넣음 · 폴더째 지우면 빈 채로) —
|
||||
* builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) */
|
||||
* builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) ·
|
||||
* panels = 왼쪽 패널 카드(264 · 「데이터 점검」 아래 · 지금 사용자 값 · 고침 → 캐시 · 다시 셈) */
|
||||
export const DRAFT: {
|
||||
builders: Record<string, TabBuild>;
|
||||
extend: Array<(payload: Row, user: Row, sheets: Row) => void>;
|
||||
} = { builders: {}, extend: [] };
|
||||
panels: Array<(user: () => Row, setUser: (next: Row) => void) => HTMLElement>;
|
||||
} = { builders: {}, extend: [], panels: [] };
|
||||
|
||||
/** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */
|
||||
export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""B09 공기 셈 짝(PLAN 252-1 · 사용자 161번 · 초안 — draft 폴더째 지우면 사라짐).
|
||||
|
||||
짝 = `B09_Draft_Schedule_Calc.ts`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py`).
|
||||
짝 = `B09_Draft_Schedule_Calc.ts`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py` · `test_264_2_b09_schedule_options.py`).
|
||||
근거 = 실무 설계서 공기 탭 식(거창 25 · 울진 24 · 울진소광 25) — 값 베끼기가 아니라 식만:
|
||||
|
||||
공기산출 설계내역서 항목 줄마다 작업일수 = ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1)
|
||||
@@ -11,13 +11,19 @@
|
||||
단위당 자원 = 서버 역전개(`…_Source.unit_resources` · 호표 구성 1 단위)
|
||||
반영률 = 다른 공종과 겹침(100 = 혼자 · 50 = 반 겹침 · 0 = 다 겹침) · 운반공 기본 0
|
||||
소계 = ROUND(Σ 작업일수 + 기타, 0) · 총작업일수 = ROUND(준비 + 소계 + 정리, 0)
|
||||
공기집계 착공 연월부터 달마다 — 달력일수 · 기후여건 A = ROUNDUP(강수일 + 혹서일, 1) ·
|
||||
공기집계 착공일부터 달마다 — 달력일수 · 기후여건 A = ROUNDUP(강수일 + 혹서일, 1) ·
|
||||
법정공휴일 B = 일요일 + 공휴일(대체 · 선거일 포함 · 사용자 덮개) ·
|
||||
중복 C = A × B ÷ 달력 ·
|
||||
비작업일수 = ROUNDDOWN(A + B − C, 0) · 작업가능일수 = 달력 − 비작업
|
||||
준비 → 작업 → 정리 차례로 작업가능일수를 채움 · 다 쓴 달 = 그 달 비작업 전부 ·
|
||||
마지막 달(덜 쓴 달) = ROUNDUP(비작업 × 쓴 날 ÷ 작업가능일, 0)
|
||||
공사기간 = Σ(쓴 날 + 비작업) · 준공 예정일 = 착공(그 달 1일) + 공사기간 − 1
|
||||
공사기간 = Σ(쓴 날 + 비작업) · 준공 예정일 = 착공일 + 공사기간 − 1
|
||||
옵션(264 · user.schedule · 기본값 = 위 식 그대로) — 착공일(1일 아니면 첫 달 = 그날부터 ·
|
||||
A = 날수 비례) · 기후 자료(원천 climates 고르기 · 달마다 덮개가 이김) ·
|
||||
쉬는 날(일 · 토일) · 공휴일 빼기(대체 · 선거일 · 노동절 · 제헌절 — 이름으로 가름) ·
|
||||
투입 기본(장비 대 · 인력 인) · 반영률 기본(운반공 · 그 밖) ·
|
||||
주 장비 기준(가장 긴 장비 · 장비 시간 합) · 중복 C 끝수(그대로 · 소수 1자리 반올림) ·
|
||||
마지막 달 비작업(쓴 비율 올림 · 전부)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,12 +33,26 @@ from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import num, round_half, rounddown, roundup
|
||||
|
||||
HOURS = 8
|
||||
CREW = 10
|
||||
PREP_DOCS = 2
|
||||
PREP_SETUP = 2
|
||||
TIDY = 4
|
||||
#: 공사 아닌 공종(자재대 · 관급자재대) · 기본 반영률 0(운반 = 다른 공종과 겹침)
|
||||
#: 사용자 값 기본(264 패널 · 표 칸 같은 값)
|
||||
DEFAULTS = {
|
||||
"hours": 8,
|
||||
"units": 1,
|
||||
"crew": 10,
|
||||
"prep_docs": 2,
|
||||
"prep_setup": 2,
|
||||
"tidy": 4,
|
||||
"etc": 0,
|
||||
"rate_haul": 0,
|
||||
"rate_base": 100,
|
||||
}
|
||||
#: 공휴일 빼기 갈래 — 이름에 든 글
|
||||
HOLIDAY_KINDS = {
|
||||
"substitute": ("대체",),
|
||||
"election": ("선거",),
|
||||
"labor": ("노동절", "근로자"),
|
||||
"constitution": ("제헌절",),
|
||||
}
|
||||
#: 공사 아닌 공종(자재대 · 관급자재대) · 기본 반영률 운반공(= 다른 공종과 겹침)
|
||||
SKIP_TOP = ("4", "G")
|
||||
OVERLAP_TOP = ("3",)
|
||||
MAX_MONTHS = 120
|
||||
@@ -42,18 +62,23 @@ def _top(section: Any) -> str:
|
||||
return str(section or "").split(".")[0]
|
||||
|
||||
|
||||
def _cfg(user: dict[str, Any], key: str, default: float) -> float:
|
||||
def _cfg(user: dict[str, Any], key: str) -> float:
|
||||
value = num(user.get(key))
|
||||
return default if value is None or value < 0 else value
|
||||
return DEFAULTS[key] if value is None or value < 0 else value
|
||||
|
||||
|
||||
def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, hours: float) -> dict:
|
||||
def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, base: dict) -> dict:
|
||||
hours = base["hours"]
|
||||
qty = num(item.get("qty")) or 0.0
|
||||
rate = num(cfg.get("rate"))
|
||||
if rate is None:
|
||||
rate = 0.0 if _top(item.get("section")) in OVERLAP_TOP else 100.0
|
||||
rate = base["rate_haul"] if _top(item.get("section")) in OVERLAP_TOP else base["rate_base"]
|
||||
daily_in = num(cfg.get("daily"))
|
||||
machine = (unit or {}).get("machine")
|
||||
total = (unit or {}).get("machine_sum")
|
||||
if machine and base["sum"] and total and (num(total.get("hr")) or 0) > 0:
|
||||
many = f" 외 {total['n'] - 1}종" if (total.get("n") or 1) > 1 else ""
|
||||
machine = {**machine, "name": f"{machine.get('name') or ''}{many}", "hr": total["hr"]}
|
||||
labor = num((unit or {}).get("labor"))
|
||||
out = {
|
||||
"kind": "item",
|
||||
@@ -78,12 +103,12 @@ def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, hour
|
||||
need = qty * rate / 100 / daily_in
|
||||
elif machine and (num(machine.get("hr")) or 0) > 0:
|
||||
hr = num(machine["hr"])
|
||||
units = num(cfg.get("units")) or 1.0
|
||||
units = num(cfg.get("units")) or base["units"]
|
||||
out.update(basis="장비", who=machine.get("name") or "", per=hr, put=units)
|
||||
out.update(q=1 / hr, daily=hours * units / hr)
|
||||
need = qty * rate / 100 * hr / (hours * units)
|
||||
elif labor is not None and labor > 0:
|
||||
crew = num(cfg.get("crew")) or float(CREW)
|
||||
crew = num(cfg.get("crew")) or base["crew"]
|
||||
out.update(basis="인력", who="인력", per=labor, put=crew, daily=crew / labor)
|
||||
need = qty * rate / 100 * labor / crew
|
||||
else:
|
||||
@@ -96,7 +121,15 @@ def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, hour
|
||||
|
||||
def work_table(src: dict[str, Any], bill_rows: list[dict[str, Any]], user: dict) -> dict:
|
||||
"""공기산출 — 설계내역서 차례(묶음 줄 = 그 아래 셀 줄이 있을 때만) · 준비 · 기타 · 정리."""
|
||||
hours = _cfg(user, "hours", HOURS) or HOURS
|
||||
hours = _cfg(user, "hours") or DEFAULTS["hours"]
|
||||
base = {
|
||||
"hours": hours,
|
||||
"units": _cfg(user, "units") or DEFAULTS["units"],
|
||||
"crew": _cfg(user, "crew") or DEFAULTS["crew"],
|
||||
"rate_haul": min(_cfg(user, "rate_haul"), 100),
|
||||
"rate_base": min(_cfg(user, "rate_base"), 100),
|
||||
"sum": user.get("machine_basis") == "sum",
|
||||
}
|
||||
units = src.get("units") or {}
|
||||
cfgs = user.get("rows") or {}
|
||||
rows: list[dict[str, Any]] = []
|
||||
@@ -129,11 +162,11 @@ def work_table(src: dict[str, Any], bill_rows: list[dict[str, Any]], user: dict)
|
||||
"lv": head.get("level") or 1,
|
||||
} # fmt: skip
|
||||
)
|
||||
rows.append(_work_row(row, units.get(key), cfgs.get(key) or {}, hours))
|
||||
docs = _cfg(user, "prep_docs", PREP_DOCS)
|
||||
setup = _cfg(user, "prep_setup", PREP_SETUP)
|
||||
etc = _cfg(user, "etc", 0)
|
||||
tidy = _cfg(user, "tidy", TIDY)
|
||||
rows.append(_work_row(row, units.get(key), cfgs.get(key) or {}, base))
|
||||
docs = _cfg(user, "prep_docs")
|
||||
setup = _cfg(user, "prep_setup")
|
||||
etc = _cfg(user, "etc")
|
||||
tidy = _cfg(user, "tidy")
|
||||
days = sum(r["days"] or 0 for r in rows if r["kind"] == "item")
|
||||
subtotal = round_half(days + etc, 0)
|
||||
missing = sum(1 for r in rows if r["kind"] == "item" and r["days"] is None)
|
||||
@@ -156,45 +189,70 @@ def _month_days(year: int, month: int) -> int:
|
||||
return (nxt - datetime.date(year, month, 1)).days
|
||||
|
||||
|
||||
def _holidays(src: dict[str, Any], year: int, month: int) -> tuple[int, list[str]]:
|
||||
"""그 달 법정공휴일 수(일요일 + 공휴일 겹침 한 번) · 공휴일 이름(「일 이름」)."""
|
||||
def _counted(name: str, off: list[str]) -> bool:
|
||||
"""공휴일 넣음 — 「;」 로 묶인 이름 중 하나라도 뺀 갈래가 아니면 넣음."""
|
||||
for part in name.split(";"):
|
||||
kinds = [k for k, words in HOLIDAY_KINDS.items() if any(w in part for w in words)]
|
||||
if not any(k in off for k in kinds):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _holidays(
|
||||
src: dict[str, Any], year: int, month: int, first: int, user: dict[str, Any]
|
||||
) -> tuple[int, list[str]]:
|
||||
"""그 달(first 일부터) 법정공휴일 수(쉬는 요일 + 공휴일 겹침 한 번) · 공휴일 이름(「일 이름」)."""
|
||||
head = f"{year:04d}-{month:02d}-"
|
||||
off = user.get("holiday_off") or []
|
||||
named = {
|
||||
d: n for d, n in (src.get("holidays") or {}).get(str(year)) or [] if d.startswith(head)
|
||||
d: n
|
||||
for d, n in (src.get("holidays") or {}).get(str(year)) or []
|
||||
if d.startswith(head) and int(d[-2:]) >= first and _counted(n, off)
|
||||
}
|
||||
rest = (5, 6) if user.get("rest") == "satsun" else (6,)
|
||||
count = 0
|
||||
for day in range(1, _month_days(year, month) + 1):
|
||||
for day in range(first, _month_days(year, month) + 1):
|
||||
iso = f"{head}{day:02d}"
|
||||
if datetime.date(year, month, day).weekday() == 6 or iso in named:
|
||||
if datetime.date(year, month, day).weekday() in rest or iso in named:
|
||||
count += 1
|
||||
return count, [f"{int(d[-2:])}일 {n}" for d, n in sorted(named.items())]
|
||||
|
||||
|
||||
def climate_base(src: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]:
|
||||
"""고른 기후 자료(원천 climates) · 없으면 원천 기본값."""
|
||||
return (src.get("climates") or {}).get(user.get("climate_set")) or src.get("climate") or {}
|
||||
|
||||
|
||||
def _climate(src: dict[str, Any], user: dict[str, Any], kind: str, month: int) -> float:
|
||||
mine = ((user.get("climate") or {}).get(kind) or [None] * 12)[month - 1]
|
||||
if num(mine) is not None:
|
||||
return num(mine)
|
||||
return num(((src.get("climate") or {}).get(kind) or [0] * 12)[month - 1]) or 0.0
|
||||
return num((climate_base(src, user).get(kind) or [0] * 12)[month - 1]) or 0.0
|
||||
|
||||
|
||||
def month_table(src: dict[str, Any], user: dict[str, Any], parts: list[float]) -> dict[str, Any]:
|
||||
"""공기집계 — parts = [준비, 작업, 정리] 일수 · 착공 연월부터 채움."""
|
||||
"""공기집계 — parts = [준비, 작업, 정리] 일수 · 착공일부터 채움."""
|
||||
start = {**(src.get("start") or {}), **(user.get("start") or {})}
|
||||
year, month = int(start.get("year") or 2000), int(start.get("month") or 1)
|
||||
first = min(max(int(start.get("day") or 1), 1), _month_days(year, month))
|
||||
begin = datetime.date(year, month, first)
|
||||
left = [max(p, 0) for p in parts]
|
||||
months: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
while sum(left) > 0 or not months:
|
||||
if len(months) >= MAX_MONTHS:
|
||||
break
|
||||
days = _month_days(year, month)
|
||||
full = _month_days(year, month)
|
||||
days = full - first + 1
|
||||
rain = _climate(src, user, "rain", month)
|
||||
heat = _climate(src, user, "heat", month)
|
||||
a = roundup(rain + heat, 1)
|
||||
auto, names = _holidays(src, year, month)
|
||||
a = roundup(rain + heat if first == 1 else (rain + heat) * days / full, 1)
|
||||
auto, names = _holidays(src, year, month, first, user)
|
||||
mine = num((user.get("holiday") or {}).get(f"{year:04d}-{month:02d}"))
|
||||
b = mine if mine is not None else auto
|
||||
c = a * b / days
|
||||
if user.get("c_round") == "round1":
|
||||
c = round_half(c, 1)
|
||||
off = rounddown(a + b - c, 0)
|
||||
avail = days - off
|
||||
cap = max(avail, 0)
|
||||
@@ -208,7 +266,7 @@ def month_table(src: dict[str, Any], user: dict[str, Any], parts: list[float]) -
|
||||
used = sum(use)
|
||||
if cap <= 0:
|
||||
off_used = days
|
||||
elif used >= cap:
|
||||
elif used >= cap or user.get("last_off") == "all":
|
||||
off_used = off
|
||||
else:
|
||||
off_used = roundup(off * used / cap, 0)
|
||||
@@ -237,11 +295,10 @@ def month_table(src: dict[str, Any], user: dict[str, Any], parts: list[float]) -
|
||||
} # fmt: skip
|
||||
)
|
||||
year, month = (year + 1, 1) if month == 12 else (year, month + 1)
|
||||
first = months[0]
|
||||
begin = datetime.date(first["year"], first["month"], 1)
|
||||
first = 1
|
||||
end = begin + datetime.timedelta(days=max(total, 1) - 1)
|
||||
return {
|
||||
"start": {"year": first["year"], "month": first["month"]},
|
||||
"start": {"year": begin.year, "month": begin.month, "day": begin.day},
|
||||
"months": months,
|
||||
"days": total,
|
||||
"months_n": round_half(total / 30, 1),
|
||||
|
||||
@@ -1,35 +1,59 @@
|
||||
/* =============================================================================
|
||||
* B09_Draft_Schedule_Calc.ts
|
||||
* B09 공기 셈 짝(PLAN 252-1 · 사용자 161번 · 초안 — draft 폴더째 지우면 사라짐).
|
||||
* 짝 = `B09_Draft_Schedule_Calc.py`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py`) — 식 설명은 그쪽 머리.
|
||||
* 짝 = `B09_Draft_Schedule_Calc.py`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py` ·
|
||||
* `test_264_2_b09_schedule_options.py`) — 식 설명은 그쪽 머리.
|
||||
* 공기산출 = 설계내역서 항목 줄마다 ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1) · 일작업량 = 장비 · 인력 · 입력
|
||||
* 공기집계 = 착공 연월부터 달마다 작업가능일수(달력 − (A + B − A×B÷달력))로 준비 → 작업 → 정리를 채움
|
||||
* 공기집계 = 착공일부터 달마다 작업가능일수(달력 − (A + B − A×B÷달력))로 준비 → 작업 → 정리를 채움
|
||||
* 옵션(264) = 착공일 · 기후 자료 · 쉬는 날 · 공휴일 빼기 · 투입 기본 · 반영률 기본 · 주 장비 기준 ·
|
||||
* 중복 C 끝수 · 마지막 달 비작업(기본값 = 위 식 그대로)
|
||||
* ========================================================================== */
|
||||
|
||||
import { num, roundHalf, rounddown, roundup, type Row } from "../B09_Estimation_Calc_Base";
|
||||
|
||||
const HOURS = 8;
|
||||
const CREW = 10;
|
||||
const PREP_DOCS = 2;
|
||||
const PREP_SETUP = 2;
|
||||
const TIDY = 4;
|
||||
/** 사용자 값 기본(264 패널 · 표 칸 같은 값) */
|
||||
export const DEFAULTS: Record<string, number> = {
|
||||
hours: 8,
|
||||
units: 1,
|
||||
crew: 10,
|
||||
prep_docs: 2,
|
||||
prep_setup: 2,
|
||||
tidy: 4,
|
||||
etc: 0,
|
||||
rate_haul: 0,
|
||||
rate_base: 100,
|
||||
};
|
||||
/** 공휴일 빼기 갈래 — 이름에 든 글 */
|
||||
export const HOLIDAY_KINDS: Record<string, string[]> = {
|
||||
substitute: ["대체"],
|
||||
election: ["선거"],
|
||||
labor: ["노동절", "근로자"],
|
||||
constitution: ["제헌절"],
|
||||
};
|
||||
const SKIP_TOP = ["4", "G"];
|
||||
const OVERLAP_TOP = ["3"];
|
||||
const MAX_MONTHS = 120;
|
||||
|
||||
const top = (section: unknown): string => String(section ?? "").split(".")[0];
|
||||
|
||||
function cfg(user: Row, key: string, fallback: number): number {
|
||||
function cfg(user: Row, key: string): number {
|
||||
const value = num(user[key]);
|
||||
return value === null || value < 0 ? fallback : value;
|
||||
return value === null || value < 0 ? DEFAULTS[key] : value;
|
||||
}
|
||||
|
||||
function workRow(item: Row, unit: Row | undefined, c: Row, hours: number): Row {
|
||||
function workRow(item: Row, unit: Row | undefined, c: Row, base: Row): Row {
|
||||
const hours: number = base.hours;
|
||||
const qty = num(item.qty) ?? 0;
|
||||
let rate = num(c.rate);
|
||||
if (rate === null) rate = OVERLAP_TOP.includes(top(item.section)) ? 0 : 100;
|
||||
if (rate === null)
|
||||
rate = OVERLAP_TOP.includes(top(item.section)) ? base.rate_haul : base.rate_base;
|
||||
const dailyIn = num(c.daily);
|
||||
const machine = unit?.machine as Row | null | undefined;
|
||||
let machine = unit?.machine as Row | null | undefined;
|
||||
const total = unit?.machine_sum as Row | null | undefined;
|
||||
if (machine && base.sum && total && (num(total.hr) ?? 0) > 0) {
|
||||
const many = (total.n || 1) > 1 ? ` 외 ${total.n - 1}종` : "";
|
||||
machine = { ...machine, name: `${machine.name || ""}${many}`, hr: total.hr };
|
||||
}
|
||||
const labor = num(unit?.labor);
|
||||
const out: Row = {
|
||||
kind: "item",
|
||||
@@ -55,12 +79,12 @@ function workRow(item: Row, unit: Row | undefined, c: Row, hours: number): Row {
|
||||
need = (qty * rate) / 100 / dailyIn;
|
||||
} else if (machine && (num(machine.hr) ?? 0) > 0) {
|
||||
const hr = num(machine.hr) as number;
|
||||
const units = num(c.units) || 1;
|
||||
const units = num(c.units) || base.units;
|
||||
Object.assign(out, { basis: "장비", who: machine.name || "", per: hr, put: units });
|
||||
Object.assign(out, { q: 1 / hr, daily: (hours * units) / hr });
|
||||
need = (((qty * rate) / 100) * hr) / (hours * units);
|
||||
} else if (labor !== null && labor > 0) {
|
||||
const crew = num(c.crew) || CREW;
|
||||
const crew = num(c.crew) || base.crew;
|
||||
Object.assign(out, { basis: "인력", who: "인력", per: labor, put: crew, daily: crew / labor });
|
||||
need = (((qty * rate) / 100) * labor) / crew;
|
||||
} else {
|
||||
@@ -74,7 +98,15 @@ function workRow(item: Row, unit: Row | undefined, c: Row, hours: number): Row {
|
||||
|
||||
/** 공기산출 — 설계내역서 차례(묶음 줄 = 그 아래 셀 줄이 있을 때만) · 준비 · 기타 · 정리 */
|
||||
export function workTable(src: Row, billRows: Row[], user: Row): Row {
|
||||
const hours = cfg(user, "hours", HOURS) || HOURS;
|
||||
const hours = cfg(user, "hours") || DEFAULTS.hours;
|
||||
const base: Row = {
|
||||
hours,
|
||||
units: cfg(user, "units") || DEFAULTS.units,
|
||||
crew: cfg(user, "crew") || DEFAULTS.crew,
|
||||
rate_haul: Math.min(cfg(user, "rate_haul"), 100),
|
||||
rate_base: Math.min(cfg(user, "rate_base"), 100),
|
||||
sum: user.machine_basis === "sum",
|
||||
};
|
||||
const units: Row = src.units || {};
|
||||
const cfgs: Row = user.rows || {};
|
||||
const rows: Row[] = [];
|
||||
@@ -101,12 +133,12 @@ export function workTable(src: Row, billRows: Row[], user: Row): Row {
|
||||
p[1] = true;
|
||||
rows.push({ kind: "head", key: p[0].key, name: p[0].name || "", lv: p[0].level || 1 });
|
||||
}
|
||||
rows.push(workRow(row, units[key], cfgs[key] || {}, hours));
|
||||
rows.push(workRow(row, units[key], cfgs[key] || {}, base));
|
||||
}
|
||||
const docs = cfg(user, "prep_docs", PREP_DOCS);
|
||||
const setup = cfg(user, "prep_setup", PREP_SETUP);
|
||||
const etc = cfg(user, "etc", 0);
|
||||
const tidy = cfg(user, "tidy", TIDY);
|
||||
const docs = cfg(user, "prep_docs");
|
||||
const setup = cfg(user, "prep_setup");
|
||||
const etc = cfg(user, "etc");
|
||||
const tidy = cfg(user, "tidy");
|
||||
const items = rows.filter((r) => r.kind === "item");
|
||||
const days = items.reduce((s: number, r) => s + (r.days || 0), 0);
|
||||
const subtotal = roundHalf(days + etc, 0);
|
||||
@@ -129,16 +161,38 @@ const monthDays = (year: number, month: number): number =>
|
||||
|
||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||
|
||||
/** 그 달 법정공휴일 수(일요일 + 공휴일 겹침 한 번) · 공휴일 이름 */
|
||||
function holidays(src: Row, year: number, month: number): [number, string[]] {
|
||||
/** 공휴일 넣음 — 「;」 로 묶인 이름 중 하나라도 뺀 갈래가 아니면 넣음 */
|
||||
function counted(name: string, off: string[]): boolean {
|
||||
for (const part of name.split(";")) {
|
||||
const kinds = Object.keys(HOLIDAY_KINDS).filter((k) =>
|
||||
HOLIDAY_KINDS[k].some((w) => part.includes(w)),
|
||||
);
|
||||
if (!kinds.some((k) => off.includes(k))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 그 달(first 일부터) 법정공휴일 수(쉬는 요일 + 공휴일 겹침 한 번) · 공휴일 이름 */
|
||||
function holidays(
|
||||
src: Row,
|
||||
year: number,
|
||||
month: number,
|
||||
first: number,
|
||||
user: Row,
|
||||
): [number, string[]] {
|
||||
const head = `${String(year).padStart(4, "0")}-${pad(month)}-`;
|
||||
const off: string[] = user.holiday_off || [];
|
||||
const named = new Map<string, string>(
|
||||
((src.holidays || {})[String(year)] || []).filter(([d]: [string]) => d.startsWith(head)),
|
||||
((src.holidays || {})[String(year)] || []).filter(
|
||||
([d, n]: [string, string]) =>
|
||||
d.startsWith(head) && Number(d.slice(-2)) >= first && counted(n, off),
|
||||
),
|
||||
);
|
||||
const rest = user.rest === "satsun" ? [6, 0] : [0];
|
||||
let count = 0;
|
||||
for (let day = 1; day <= monthDays(year, month); day += 1) {
|
||||
const sunday = new Date(Date.UTC(year, month - 1, day)).getUTCDay() === 0;
|
||||
if (sunday || named.has(`${head}${pad(day)}`)) count += 1;
|
||||
for (let day = first; day <= monthDays(year, month); day += 1) {
|
||||
const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
||||
if (rest.includes(weekday) || named.has(`${head}${pad(day)}`)) count += 1;
|
||||
}
|
||||
const names = [...named.entries()]
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
@@ -146,30 +200,38 @@ function holidays(src: Row, year: number, month: number): [number, string[]] {
|
||||
return [count, names];
|
||||
}
|
||||
|
||||
/** 고른 기후 자료(원천 climates) · 없으면 원천 기본값 */
|
||||
export const climateBase = (src: Row, user: Row): Row =>
|
||||
(src.climates || {})[user.climate_set] || src.climate || {};
|
||||
|
||||
function climate(src: Row, user: Row, kind: string, month: number): number {
|
||||
const mine = num(((user.climate || {})[kind] || [])[month - 1]);
|
||||
if (mine !== null) return mine;
|
||||
return num(((src.climate || {})[kind] || [])[month - 1]) ?? 0;
|
||||
return num((climateBase(src, user)[kind] || [])[month - 1]) ?? 0;
|
||||
}
|
||||
|
||||
/** 공기집계 — parts = [준비, 작업, 정리] 일수 · 착공 연월부터 채움 */
|
||||
/** 공기집계 — parts = [준비, 작업, 정리] 일수 · 착공일부터 채움 */
|
||||
export function monthTable(src: Row, user: Row, parts: number[]): Row {
|
||||
const start: Row = { ...(src.start || {}), ...(user.start || {}) };
|
||||
let year = Math.trunc(Number(start.year) || 2000);
|
||||
let month = Math.trunc(Number(start.month) || 1);
|
||||
let first = Math.min(Math.max(Math.trunc(Number(start.day) || 1), 1), monthDays(year, month));
|
||||
const begin = Date.UTC(year, month - 1, first);
|
||||
const left = parts.map((p) => Math.max(p, 0));
|
||||
const months: Row[] = [];
|
||||
let total = 0;
|
||||
while (left.reduce((s, v) => s + v, 0) > 0 || !months.length) {
|
||||
if (months.length >= MAX_MONTHS) break;
|
||||
const days = monthDays(year, month);
|
||||
const full = monthDays(year, month);
|
||||
const days = full - first + 1;
|
||||
const rain = climate(src, user, "rain", month);
|
||||
const heat = climate(src, user, "heat", month);
|
||||
const a = roundup(rain + heat, 1);
|
||||
const [auto, names] = holidays(src, year, month);
|
||||
const a = roundup(first === 1 ? rain + heat : ((rain + heat) * days) / full, 1);
|
||||
const [auto, names] = holidays(src, year, month, first, user);
|
||||
const mine = num((user.holiday || {})[`${String(year).padStart(4, "0")}-${pad(month)}`]);
|
||||
const b = mine !== null ? mine : auto;
|
||||
const c = (a * b) / days;
|
||||
let c = (a * b) / days;
|
||||
if (user.c_round === "round1") c = roundHalf(c, 1);
|
||||
const off = rounddown(a + b - c, 0);
|
||||
const avail = days - off;
|
||||
const cap = Math.max(avail, 0);
|
||||
@@ -182,7 +244,12 @@ export function monthTable(src: Row, user: Row, parts: number[]): Row {
|
||||
room -= take;
|
||||
}
|
||||
const used = use[0] + use[1] + use[2];
|
||||
const offUsed = cap <= 0 ? days : used >= cap ? off : roundup((off * used) / cap, 0);
|
||||
const offUsed =
|
||||
cap <= 0
|
||||
? days
|
||||
: used >= cap || user.last_off === "all"
|
||||
? off
|
||||
: roundup((off * used) / cap, 0);
|
||||
const span = used + offUsed;
|
||||
total += span;
|
||||
months.push({
|
||||
@@ -209,11 +276,12 @@ export function monthTable(src: Row, user: Row, parts: number[]): Row {
|
||||
year += 1;
|
||||
month = 1;
|
||||
} else month += 1;
|
||||
first = 1;
|
||||
}
|
||||
const first = months[0];
|
||||
const end = new Date(Date.UTC(first.year, first.month - 1, 1) + (Math.max(total, 1) - 1) * 864e5);
|
||||
const from = new Date(begin);
|
||||
const end = new Date(begin + (Math.max(total, 1) - 1) * 864e5);
|
||||
return {
|
||||
start: { year: first.year, month: first.month },
|
||||
start: { year: from.getUTCFullYear(), month: from.getUTCMonth() + 1, day: from.getUTCDate() },
|
||||
months,
|
||||
days: total,
|
||||
months_n: roundHalf(total / 30, 1),
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/* =============================================================================
|
||||
* B09_Draft_Schedule_Panel.ts
|
||||
* B09 왼쪽 패널 「공기 옵션」 카드(PLAN 264-1 · 사용자 164-2 · 초안 — draft 폴더째 지우면 사라짐).
|
||||
* 등록 = `B09_Draft_Schedule_UI` 가 DRAFT.panels(카드) · DRAFT.extend(값 맞춤)에 넣음 —
|
||||
* 본 코드 훅 = `…_UI_Shell` 이 「데이터 점검」 아래 · [저장] 묶음 위에 붙이는 한 줄
|
||||
* 값 = user.schedule(캐시 → [저장] 한 번에) · 공기산출 · 공기집계 표 칸과 같은 값(어디서 고쳐도 한 벌)
|
||||
* 꼴 = 좌측 패널 공용(B05/B06 「구조물 배치」 칸 — 라벨 위 · 입력 아래 · 두 칸 줄 · 체크)
|
||||
* ========================================================================== */
|
||||
|
||||
import "../../B05_Profile/B05_Profile_UI_Style.css";
|
||||
import { num, type Row } from "../B09_Estimation_Calc_Base";
|
||||
import { parseNumber } from "../B09_Estimation_UI_Tabs";
|
||||
import { DEFAULTS } from "./B09_Draft_Schedule_Calc";
|
||||
|
||||
type Sync = (mine: Row, src: Row) => void;
|
||||
|
||||
const HOLIDAY_LABELS: Array<[string, string]> = [
|
||||
["substitute", "대체공휴일"],
|
||||
["election", "선거일"],
|
||||
["labor", "근로자의 날(노동절)"],
|
||||
["constitution", "제헌절"],
|
||||
];
|
||||
const CHOICES: Record<string, Array<[string, string]>> = {
|
||||
rest: [
|
||||
["sun", "일요일만"],
|
||||
["satsun", "토 · 일"],
|
||||
],
|
||||
machine_basis: [
|
||||
["max", "단위당 시간이 가장 긴 장비"],
|
||||
["sum", "장비 시간 합(차례로 일하는 장비)"],
|
||||
],
|
||||
c_round: [
|
||||
["none", "그대로"],
|
||||
["round1", "소수 1자리 반올림"],
|
||||
],
|
||||
last_off: [
|
||||
["ratio", "쓴 비율만큼 올림"],
|
||||
["all", "그 달 비작업 전부"],
|
||||
],
|
||||
};
|
||||
|
||||
/** 지금 화면의 카드 맞춤 — 셈마다 DRAFT.extend 가 부름(카드를 다시 만들면 새 카드로) */
|
||||
let syncNow: Sync | null = null;
|
||||
|
||||
export function syncSchedulePanel(payload: Row, user: Row): void {
|
||||
syncNow?.(user.schedule || {}, payload.inputs?.schedule || {});
|
||||
}
|
||||
|
||||
function field(label: string, input: HTMLElement): HTMLLabelElement {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "b05-route__field";
|
||||
const caption = document.createElement("span");
|
||||
caption.textContent = label;
|
||||
wrapper.append(caption, input);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function pair(...items: HTMLElement[]): HTMLDivElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "b05-route__field-row";
|
||||
row.append(...items);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function createSchedulePanel(user: () => Row, setUser: (next: Row) => void): HTMLElement {
|
||||
const syncs: Sync[] = [];
|
||||
const change = (fn: (s: Row) => void): void => {
|
||||
const next: Row = structuredClone(user().schedule || {});
|
||||
fn(next);
|
||||
setUser({ ...user(), schedule: next });
|
||||
};
|
||||
|
||||
/** 수 칸 — 빈칸 · 기본값 = 지움(기본) · 범위 밖 = 되돌림 */
|
||||
const number = (key: string, label: string, min: number, max: number): HTMLLabelElement => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.min = String(min);
|
||||
input.max = String(max);
|
||||
input.step = "any";
|
||||
input.dataset.b09Schedule = key;
|
||||
let last: Row = {};
|
||||
syncs.push((mine) => {
|
||||
last = mine;
|
||||
input.value = String(num(mine[key]) ?? DEFAULTS[key]);
|
||||
});
|
||||
input.addEventListener("change", () => {
|
||||
const v = parseNumber(input.value);
|
||||
if (v === undefined || (v !== null && (v < min || v > max))) {
|
||||
input.value = String(num(last[key]) ?? DEFAULTS[key]);
|
||||
return;
|
||||
}
|
||||
change((s) => {
|
||||
if (v === null || v === DEFAULTS[key]) delete s[key];
|
||||
else s[key] = v;
|
||||
});
|
||||
});
|
||||
return field(label, input);
|
||||
};
|
||||
|
||||
/** 고르기 칸 — 첫째 = 기본(고르면 지움) · options 없으면 원천에서(기후 자료) */
|
||||
const choose = (
|
||||
key: string,
|
||||
label: string,
|
||||
options?: Array<[string, string]>,
|
||||
): HTMLLabelElement => {
|
||||
const select = document.createElement("select");
|
||||
select.dataset.b09Schedule = key;
|
||||
let values: string[] = [];
|
||||
syncs.push((mine, src) => {
|
||||
const list =
|
||||
options ??
|
||||
Object.entries((src.climates || {}) as Row).map(([id, c]): [string, string] => [
|
||||
id,
|
||||
c.name || id,
|
||||
]);
|
||||
if (list.map(([v]) => v).join() !== values.join()) {
|
||||
values = list.map(([v]) => v);
|
||||
select.replaceChildren(...list.map(([v, text]) => new Option(text, v)));
|
||||
}
|
||||
select.value = values.includes(mine[key]) ? mine[key] : (values[0] ?? "");
|
||||
});
|
||||
select.addEventListener("change", () =>
|
||||
change((s) => {
|
||||
if (select.value === values[0]) delete s[key];
|
||||
else s[key] = select.value;
|
||||
}),
|
||||
);
|
||||
return field(label, select);
|
||||
};
|
||||
|
||||
const start = document.createElement("input");
|
||||
start.type = "date";
|
||||
start.dataset.b09Schedule = "start";
|
||||
syncs.push((mine, src) => {
|
||||
const at: Row = { ...(src.start || {}), ...(mine.start || {}) };
|
||||
const pad = (n: unknown): string => String(Number(n) || 1).padStart(2, "0");
|
||||
start.value = at.year ? `${at.year}-${pad(at.month)}-${pad(at.day)}` : "";
|
||||
});
|
||||
start.addEventListener("change", () => {
|
||||
const hit = /^(\d{4})-(\d{2})-(\d{2})$/.exec(start.value);
|
||||
change((s) => {
|
||||
if (!hit) return void delete s.start;
|
||||
const day = Number(hit[3]);
|
||||
s.start = { year: Number(hit[1]), month: Number(hit[2]), ...(day > 1 ? { day } : {}) };
|
||||
});
|
||||
});
|
||||
|
||||
const checks = HOLIDAY_LABELS.map(([kind, label]) => {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "b05-route__check";
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.dataset.b09Holiday = kind;
|
||||
syncs.push((mine) => {
|
||||
input.checked = !(mine.holiday_off || []).includes(kind);
|
||||
});
|
||||
input.addEventListener("change", () =>
|
||||
change((s) => {
|
||||
const off = HOLIDAY_LABELS.map(([k]) => k).filter((k) =>
|
||||
k === kind ? !input.checked : (s.holiday_off || []).includes(k),
|
||||
);
|
||||
if (off.length) s.holiday_off = off;
|
||||
else delete s.holiday_off;
|
||||
}),
|
||||
);
|
||||
wrapper.append(input, document.createTextNode(label));
|
||||
return wrapper;
|
||||
});
|
||||
const holidayBox = document.createElement("div");
|
||||
holidayBox.className = "b05-route__field";
|
||||
const holidayCaption = document.createElement("span");
|
||||
holidayCaption.textContent = "공휴일 넣기(그 밖 법정공휴일은 늘 넣음)";
|
||||
holidayBox.append(holidayCaption, ...checks);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "b05-route__panel-body";
|
||||
body.append(
|
||||
field("착공일", start),
|
||||
choose("climate_set", "기후 자료(달마다 덮개는 공기집계 표)"),
|
||||
choose("rest", "쉬는 날", CHOICES.rest),
|
||||
holidayBox,
|
||||
number("hours", "일 작업시간(시간)", 0.5, 24),
|
||||
pair(
|
||||
number("units", "장비 기본 대수", 0.001, 1e4),
|
||||
number("crew", "인력 기본 인원", 0.001, 1e4),
|
||||
),
|
||||
pair(
|
||||
number("prep_docs", "서류 · 착수 준비(일)", 0, 1000),
|
||||
number("prep_setup", "공종별 준비(일)", 0, 1000),
|
||||
),
|
||||
pair(number("tidy", "정리(일)", 0, 1000), number("etc", "기타 · 현장여건(일)", 0, 1000)),
|
||||
pair(
|
||||
number("rate_haul", "반영률 운반공(%)", 0, 100),
|
||||
number("rate_base", "반영률 그 밖(%)", 0, 100),
|
||||
),
|
||||
choose("machine_basis", "주 장비 기준", CHOICES.machine_basis),
|
||||
choose("c_round", "중복일수 C 끝수", CHOICES.c_round),
|
||||
choose("last_off", "마지막 달 비작업일", CHOICES.last_off),
|
||||
);
|
||||
const section = document.createElement("section");
|
||||
section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card";
|
||||
section.dataset.b09SchedulePanel = "";
|
||||
section.hidden = true; // 원천(서버 공기 자료)이 오면 보임
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "ui-collapsible__title";
|
||||
heading.textContent = "공기 옵션";
|
||||
section.append(heading, body);
|
||||
|
||||
syncNow = (mine, src) => {
|
||||
section.hidden = !src.climates;
|
||||
for (const sync of syncs) sync(mine, src);
|
||||
};
|
||||
syncNow(user().schedule || {}, {});
|
||||
return section;
|
||||
}
|
||||
@@ -9,12 +9,14 @@
|
||||
공휴일 = `B09_Draft_Schedule_Holidays.json`(2024~2040 · 법정 · 대체 · 확정 선거일 — 공개 `holidays`
|
||||
목록 0.106 에서 뽑음 · 2027 이후 선거일은 날짜가 안 정해져 뺌 · 달마다 사용자 덮개)
|
||||
기후 기본값 = 참고 설계서(25 거창) 월별 강수일(일강수량 5mm 이상) · 혹서일(33℃ 이상) —
|
||||
지역마다 사용자 고침
|
||||
사용자 값 = user["schedule"] — 다듬기 `clean_schedule`(모르는 칸 버림 · 수 칸은 수)
|
||||
고르기(264) = 참고 설계서 셋(거창 25 · 울진 24 · 울진 소광 25 「공사기간산정표」) · 0에서 직접 ·
|
||||
달마다 사용자 덮개가 이김
|
||||
사용자 값 = user["schedule"] — 다듬기 `clean_schedule`(모르는 칸 버림 · 수 칸은 수 · 고르기 칸은 목록 안)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -22,7 +24,7 @@ from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import num
|
||||
from B09_Estimation.B09_Estimation_Summary import expand, hopyo_lines
|
||||
from B09_Estimation.draft.B09_Draft_Schedule_Calc import compute_schedule
|
||||
from B09_Estimation.draft.B09_Draft_Schedule_Calc import HOLIDAY_KINDS, compute_schedule
|
||||
|
||||
HOLIDAYS = Path(__file__).with_name("B09_Draft_Schedule_Holidays.json")
|
||||
CLIMATE = {
|
||||
@@ -30,8 +32,50 @@ CLIMATE = {
|
||||
"rain": [1.2, 1.4, 3.6, 4.5, 3.6, 4.4, 7.7, 6.9, 5.5, 3.1, 2.2, 1.2],
|
||||
"heat": [0, 0, 0, 0, 0.5, 1.2, 5.9, 8.4, 0, 0, 0, 0],
|
||||
}
|
||||
#: 기후 자료 고르기(264) — 첫 줄 = 기본 · rain_note · heat_note = 공기집계 비고
|
||||
CLIMATES = {
|
||||
"geochang25": {
|
||||
**CLIMATE,
|
||||
"name": "거창 25 참고",
|
||||
"rain_note": "일강수량 5mm 이상",
|
||||
"heat_note": "33℃ 이상",
|
||||
},
|
||||
"uljin24": {
|
||||
"name": "울진 24 참고",
|
||||
"label": "경북 울진(참고 설계서 24 · 강수 = 일강수량 5mm 이상 · 혹서 없음)",
|
||||
"rain": [0.6, 1.3, 2.9, 4.3, 3.2, 3.9, 7.4, 8.3, 4, 2.8, 2.6, 1.4],
|
||||
"heat": [0] * 12,
|
||||
"rain_note": "일강수량 5mm 이상",
|
||||
"heat_note": "혹서 없음(참고 설계서)",
|
||||
},
|
||||
"sogwang25": {
|
||||
"name": "울진 소광 25 참고",
|
||||
"label": "경북 울진 소광(참고 설계서 25 · 강수 = 일강수량 10mm 이상 · 혹서 없음)",
|
||||
"rain": [2.1, 2, 3.3, 4, 2.1, 3.7, 6.3, 7.9, 5, 4.2, 3.6, 1.6],
|
||||
"heat": [0] * 12,
|
||||
"rain_note": "일강수량 10mm 이상",
|
||||
"heat_note": "혹서 없음(참고 설계서)",
|
||||
},
|
||||
"zero": {
|
||||
"name": "0에서 직접",
|
||||
"label": "직접 입력(달마다 강수 · 혹서 칸)",
|
||||
"rain": [0] * 12,
|
||||
"heat": [0] * 12,
|
||||
"rain_note": "직접 입력",
|
||||
"heat_note": "직접 입력",
|
||||
},
|
||||
}
|
||||
START_MONTH = 3
|
||||
NUMBERS = ("hours", "prep_docs", "prep_setup", "etc", "tidy")
|
||||
NUMBERS = ("hours", "prep_docs", "prep_setup", "etc", "tidy", "rate_haul", "rate_base")
|
||||
POSITIVE = ("units", "crew")
|
||||
#: 고르기 칸 — 기본값이 첫째
|
||||
CHOICES = {
|
||||
"climate_set": tuple(CLIMATES),
|
||||
"rest": ("sun", "satsun"),
|
||||
"machine_basis": ("max", "sum"),
|
||||
"c_round": ("none", "round1"),
|
||||
"last_off": ("ratio", "all"),
|
||||
}
|
||||
|
||||
|
||||
def unit_resources(sheets: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
@@ -54,7 +98,13 @@ def unit_resources(sheets: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
name = " ".join(t for t in (src.get("name"), src.get("spec")) if t) or mkey
|
||||
machine = {"key": mkey, "name": name, "hr": hr}
|
||||
labor = sum((used.get("labor") or {}).values())
|
||||
out[row["key"]] = {"machine": machine, "labor": labor if labor > 0 else None}
|
||||
# 264 주 장비 기준 「장비 시간 합」 = 차례로 일하는 장비 · n = 장비 수
|
||||
spent = [hr for hr in hours.values() if hr > 0]
|
||||
out[row["key"]] = {
|
||||
"machine": machine,
|
||||
"labor": labor if labor > 0 else None,
|
||||
"machine_sum": {"hr": sum(spent), "n": len(spent)} if machine else None,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
@@ -71,6 +121,7 @@ def schedule_inputs(sheets: dict[str, Any], today: datetime.date | None = None)
|
||||
"units": unit_resources(sheets),
|
||||
"holidays": holiday_table(),
|
||||
"climate": CLIMATE,
|
||||
"climates": CLIMATES,
|
||||
"start": {"year": today.year + 1, "month": START_MONTH},
|
||||
}
|
||||
|
||||
@@ -100,12 +151,25 @@ def clean_schedule(raw: Any) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
for field in NUMBERS:
|
||||
v = num(raw.get(field))
|
||||
if v is not None and 0 <= v <= 1000:
|
||||
if v is not None and 0 <= v <= (100 if field.startswith("rate") else 1000):
|
||||
out[field] = v
|
||||
for field in POSITIVE:
|
||||
if (v := _positive(raw.get(field))) is not None and v <= 1e4:
|
||||
out[field] = v
|
||||
for field, allowed in CHOICES.items():
|
||||
if raw.get(field) in allowed:
|
||||
out[field] = raw[field]
|
||||
off = raw.get("holiday_off") if isinstance(raw.get("holiday_off"), list) else []
|
||||
kinds = [k for k in HOLIDAY_KINDS if k in off]
|
||||
if kinds:
|
||||
out["holiday_off"] = kinds
|
||||
start = raw.get("start") if isinstance(raw.get("start"), dict) else {}
|
||||
year, month = num(start.get("year")), num(start.get("month"))
|
||||
if year and month and 2000 <= year <= 2100 and 1 <= month <= 12:
|
||||
out["start"] = {"year": int(year), "month": int(month)}
|
||||
day = num(start.get("day"))
|
||||
if day and 1 < day <= calendar.monthrange(int(year), int(month))[1]:
|
||||
out["start"]["day"] = int(day)
|
||||
climate = raw.get("climate") if isinstance(raw.get("climate"), dict) else {}
|
||||
kept = {k: v for k in ("rain", "heat") if (v := _twelve(climate.get(k)))}
|
||||
if kept:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* 등록 = 이 파일을 `…_UI_Shell` 이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때 TABS · TAB_GROUPS ·
|
||||
* DRAFT(탭 짜기 · 셈 곁)에 넣음 — draft 폴더째 지우면 탭 · 셈이 함께 사라짐
|
||||
* 셈 = `B09_Draft_Schedule_Calc`(TS 짝 · 조작 즉시) · 원천 = 서버 `inputs.schedule` · 값 = `user.schedule`
|
||||
* 왼쪽 「공기 옵션」 카드 = `B09_Draft_Schedule_Panel`(264-1 · 같은 값 · 비고는 고른 옵션을 따름)
|
||||
* 고칠 칸(더블클릭) — 공기산출: 준비 · 일 작업시간 · 투입 · 일작업량 · 반영률 · 기타 · 정리 /
|
||||
* 공기집계: 착공 연월 · 강수 · 혹서(달마다 · 해 무관) · 법정공휴일(그 해 그 달 덮개 · 빈칸 = 자동)
|
||||
* ========================================================================== */
|
||||
@@ -21,7 +22,8 @@ import {
|
||||
type TabId,
|
||||
type TabTable,
|
||||
} from "../B09_Estimation_UI_Tabs";
|
||||
import { computeSchedule } from "./B09_Draft_Schedule_Calc";
|
||||
import { climateBase, computeSchedule } from "./B09_Draft_Schedule_Calc";
|
||||
import { createSchedulePanel, syncSchedulePanel } from "./B09_Draft_Schedule_Panel";
|
||||
|
||||
const SUM = "schedule_sum" as TabId;
|
||||
const CALC = "schedule_calc" as TabId;
|
||||
@@ -185,6 +187,35 @@ const LINES: Array<[string, string, string, string]> = [
|
||||
["span", "공사기간", "공사일수", "작업일수 계 + 비작업일수"],
|
||||
];
|
||||
|
||||
/** 264 고른 옵션을 따르는 비고 — 기본과 같으면 LINES 글 그대로(rain 은 늘 기후 자료 이름) */
|
||||
function optionNotes(src: Row, mine: Row, midMonth: boolean): Record<string, string> {
|
||||
const base = climateBase(src, mine);
|
||||
const out: Record<string, string> = {};
|
||||
if (base.label)
|
||||
out.rain = `${base.rain_note ?? "일강수량 5mm 이상"}(달마다 · 해 무관) · 기본값 = ${base.label}`;
|
||||
if (base.heat_note) out.heat = base.heat_note;
|
||||
if (midMonth) {
|
||||
out.days = "첫 달 = 착공일부터";
|
||||
out.a = "ROUNDUP(강수 + 혹서, 1) · 첫 달 = 날수 비례";
|
||||
}
|
||||
const off: string[] = mine.holiday_off || [];
|
||||
if (mine.rest === "satsun" || off.length) {
|
||||
const rest = mine.rest === "satsun" ? "토 · 일" : "일요일";
|
||||
const cut = HOLIDAY_NAMES.filter(([k]) => off.includes(k)).map(([, n]) => n);
|
||||
out.b = `${rest} + 공휴일${cut.length ? `(뺌 = ${cut.join(" · ")})` : ""} · 풍선 = 이름 · 고치면 그 달 덮개`;
|
||||
}
|
||||
if (mine.c_round === "round1") out.c = "A × B ÷ 달력일수 · 소수 1자리 반올림";
|
||||
if (mine.last_off === "all") out.off_used = "다 쓴 달 · 마지막 달 = 그 달 비작업 전부";
|
||||
return out;
|
||||
}
|
||||
|
||||
const HOLIDAY_NAMES: Array<[string, string]> = [
|
||||
["substitute", "대체"],
|
||||
["election", "선거일"],
|
||||
["labor", "노동절"],
|
||||
["constitution", "제헌절"],
|
||||
];
|
||||
|
||||
export function buildScheduleSum(ctx: TabContext): TabTable | string {
|
||||
const sch: Row | undefined = ctx.sheets.schedule;
|
||||
if (!sch) return "공기 자료 없음 — 서버가 공기 원천을 아직 주지 않음([업데이트] 뒤 다시)";
|
||||
@@ -192,7 +223,9 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string {
|
||||
const months: Row[] = cal.months;
|
||||
const src: Row = ctx.payload.inputs?.schedule || {};
|
||||
const mine: Row = ctx.user.schedule || {};
|
||||
const start = `${cal.start.year}.${String(cal.start.month).padStart(2, "0")}`;
|
||||
const day = cal.start.day > 1 ? `.${String(cal.start.day).padStart(2, "0")}` : "";
|
||||
const start = `${cal.start.year}.${String(cal.start.month).padStart(2, "0")}${day}`;
|
||||
const notes = optionNotes(src, mine, Boolean(day));
|
||||
const columns: DataColumn[] = [
|
||||
text("label", "구분", 96),
|
||||
text("sub", "세부", 96),
|
||||
@@ -212,15 +245,13 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string {
|
||||
note: `준공 예정 ${cal.end} · 공사기간 ${cal.days}일(≒ ${pct(cal.months_n)}개월)`,
|
||||
},
|
||||
...LINES.map(([key, label, sub, note]) => {
|
||||
const row: DataRow = { key, label, sub, note };
|
||||
const row: DataRow = { key, label, sub, note: notes[key] ?? note };
|
||||
let sum = 0;
|
||||
months.forEach((m, i) => {
|
||||
row[`m${i}`] = m[key];
|
||||
sum += Number(m[key]) || 0;
|
||||
});
|
||||
row.sum = sum;
|
||||
if (key === "rain" && src.climate?.label)
|
||||
row.note = `${note} · 기본값 = ${src.climate.label}`;
|
||||
return row;
|
||||
}),
|
||||
];
|
||||
@@ -232,7 +263,7 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string {
|
||||
frozen: 2,
|
||||
mergeSame: ["label"],
|
||||
rowClass: (i) => (rows[i].key === "span" || rows[i].key === "start" ? "is-title" : null),
|
||||
hint: "공사기간 = 준비기간 + 작업일수 + 비작업일수 + 정리기간 · 착공 = 그 달 1일 · 강수 · 혹서 · 공휴일 칸은 더블클릭으로 고침(빈칸 = 기본값)",
|
||||
hint: "공사기간 = 준비기간 + 작업일수 + 비작업일수 + 정리기간 · 착공 = 「연.월」 또는 「연.월.일」(왼쪽 「공기 옵션」과 같은 값) · 강수 · 혹서 · 공휴일 칸은 더블클릭으로 고침(빈칸 = 기본값)",
|
||||
tip: (i, key) => {
|
||||
const m = months[monthOf(key)];
|
||||
if (!m || rows[i].key !== "b") return null;
|
||||
@@ -266,10 +297,13 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string {
|
||||
const k = String(rows[i].key);
|
||||
change(ctx, (s) => {
|
||||
if (k === "start") {
|
||||
const hit = /(\d{4})\D+(\d{1,2})/.exec(value);
|
||||
const hit = /(\d{4})\D+(\d{1,2})(?:\D+(\d{1,2}))?/.exec(value);
|
||||
if (!value.trim()) delete s.start;
|
||||
else if (hit && Number(hit[2]) >= 1 && Number(hit[2]) <= 12)
|
||||
s.start = { year: Number(hit[1]), month: Number(hit[2]) };
|
||||
else if (hit && Number(hit[2]) >= 1 && Number(hit[2]) <= 12) {
|
||||
const [year, month, day] = [Number(hit[1]), Number(hit[2]), Number(hit[3] ?? 1)];
|
||||
const last = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
s.start = { year, month, ...(day > 1 && day <= last ? { day } : {}) };
|
||||
}
|
||||
return;
|
||||
}
|
||||
const m = months[monthOf(key)];
|
||||
@@ -303,4 +337,7 @@ if (!TABS.some(([id]) => id === SUM)) {
|
||||
const src = payload.inputs?.schedule;
|
||||
if (src) sheets.schedule = computeSchedule(src, sheets.bill?.rows || [], user.schedule || {});
|
||||
});
|
||||
// 264-1 왼쪽 「공기 옵션」 카드 — 셈마다 지금 값으로 맞춤
|
||||
DRAFT.panels.push(createSchedulePanel);
|
||||
DRAFT.extend.push(syncSchedulePanel);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,6 @@ const STUBS = {
|
||||
return cad;
|
||||
},
|
||||
},
|
||||
"@ui/ui_template_collapsible": { attachCollapsible: () => {} },
|
||||
"@ui/ui_template_locale": {
|
||||
currentLanguageIndex: 0,
|
||||
ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }),
|
||||
@@ -233,6 +232,9 @@ function loadTs(file) {
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
// 공용 카드(269) = 진짜 모듈(가짜 그릇 위에서 돎)
|
||||
if (request === "@ui/ui_template_collapsible")
|
||||
return loadTs(path.join(ROOT, "ui_template", "ui_template_collapsible.ts"));
|
||||
if (request === "./B07_DesignDetail_Api_Fetch") return API;
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
throw new Error(`stub 없음: ${request}`);
|
||||
|
||||
@@ -78,7 +78,9 @@ function loadTs(file) {
|
||||
...loadTs(path.join(UI, "ui_template_elements.ts")),
|
||||
showToast: (message, kind) => toasts.push([kind, message]),
|
||||
};
|
||||
if (request === "@ui/ui_template_collapsible") return { attachCollapsible: () => {} };
|
||||
// 공용 카드(269) = 진짜 모듈
|
||||
if (request === "@ui/ui_template_collapsible")
|
||||
return loadTs(path.join(UI, "ui_template_collapsible.ts"));
|
||||
if (request === "@ui/ui_template_locale")
|
||||
return {
|
||||
currentLanguageIndex: 0,
|
||||
|
||||
@@ -7,12 +7,21 @@ const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
// 인자 `load` = 표준입력 {pipes, structures, types} 를 세션 · 서버 흉내로 `loadNoteThings`(265-2)
|
||||
const DATA = { pipes: [], structures: [], types: [] };
|
||||
const STUBS = {
|
||||
"@ui/cad_host/cad_host": { createCadHost: () => ({}) },
|
||||
"../A00_Common/b_page_state": { readState: () => null, writeState: () => undefined },
|
||||
"../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 },
|
||||
"../B04_PreProcess/B04_PreProcess_Api_Fetch": { fetchDetailPipePoints: async () => ({}) },
|
||||
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {
|
||||
fetchDetailPipePoints: async () => ({ pipe_points: DATA.pipes }),
|
||||
},
|
||||
"../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => null },
|
||||
"../B05_Profile/B05_Profile_Api_Structures": {
|
||||
fetchStructures: async () => ({ structures: DATA.structures }),
|
||||
fetchStructureTypes: async () => DATA.types,
|
||||
readPendingStructures: () => null,
|
||||
},
|
||||
};
|
||||
|
||||
function loadTs(file) {
|
||||
@@ -28,6 +37,13 @@ function loadTs(file) {
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
// 관 길이 정수(265-3) = 실제 `pipeLengthWhole` · 조합 참조 읽기(265-2) = 실제(둘 다 import 없음)
|
||||
STUBS["./B06_Section_UI_Cross_Culvert_Const"] = loadTs(
|
||||
path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Culvert_Const.ts"),
|
||||
);
|
||||
STUBS["../common_util/common_util_summary_item"] = loadTs(
|
||||
path.join(ROOT, "common_util", "common_util_summary_item.ts"),
|
||||
);
|
||||
const cad = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail_Cad.ts"));
|
||||
|
||||
const wall = (role, form, height, length) => ({
|
||||
@@ -54,7 +70,6 @@ const section = (spec, design = { pipe_length_m: 22 }, chainage = 1433) => ({
|
||||
culvert: spec,
|
||||
design,
|
||||
});
|
||||
const CHUTE = { chute_add_1_structure: "돌붙임", chute_add_1_bond: "찰붙임", chute_add_1_area: 10 };
|
||||
|
||||
/** 엔티티 → 비교하기 쉬운 꼴(종류 · 글 · 자리 · 색). */
|
||||
const brief = (entities) =>
|
||||
@@ -67,11 +82,12 @@ const brief = (entities) =>
|
||||
color: entity.lineColor,
|
||||
layer: entity.layerId,
|
||||
}));
|
||||
const run = (sec, options) => brief(cad.culvertNoteEntities(sec, "NO.71+13", options));
|
||||
const run = (sec, lines = []) => brief(cad.noteEntities(sec, "NO.71+13", lines));
|
||||
const LINES = ["기슭막이 찰쌓기 2.0m", "돌붙임 찰붙임"];
|
||||
|
||||
const out = {
|
||||
full: run(section(culvert()), CHUTE),
|
||||
noLength: run(section(culvert(), {}), {}),
|
||||
full: run(section(culvert()), LINES),
|
||||
noLength: run(section(culvert(), {})),
|
||||
basin: run(
|
||||
section(
|
||||
culvert({
|
||||
@@ -84,9 +100,9 @@ const out = {
|
||||
},
|
||||
}),
|
||||
),
|
||||
{},
|
||||
[],
|
||||
),
|
||||
chuteFallback: run(section(culvert({ chute: { structure: "도수로", thickness_m: 0.45 } })), {}),
|
||||
chuteFallback: run(section(culvert({ chute: { structure: "도수로", thickness_m: 0.45 } })), []),
|
||||
noSides: run(
|
||||
section(
|
||||
culvert({
|
||||
@@ -94,12 +110,14 @@ const out = {
|
||||
outlet: { role: "outlet", structure: "" },
|
||||
}),
|
||||
),
|
||||
{},
|
||||
[],
|
||||
),
|
||||
none: run(section(undefined), CHUTE),
|
||||
ford: run(section(culvert({ body: "ford" })), CHUTE),
|
||||
hidden: run(section(culvert({ hidden_pipe: true })), CHUTE),
|
||||
neighbour: run(section(culvert(), { pipe_length_m: 22 }, 1440), CHUTE),
|
||||
none: run(section(undefined)),
|
||||
ford: run(section(culvert({ body: "ford" }))),
|
||||
hidden: run(section(culvert({ hidden_pipe: true }))),
|
||||
neighbour: run(section(culvert(), { pipe_length_m: 22 }, 1440)),
|
||||
// 마크 없이 조합 줄만(구조물 측점 · 265-2) = 측점부터 왼쪽 끝
|
||||
linesOnly: run(section(undefined), LINES),
|
||||
legacy: brief(cad.legacyNoteEntities("\nL형 측구 \n\n집수정 보강\n")),
|
||||
legacyEmpty: cad.legacyNoteEntities(" ").length,
|
||||
station: [
|
||||
@@ -108,13 +126,21 @@ const out = {
|
||||
cad.stationNoLabel(1439.97, 20),
|
||||
],
|
||||
};
|
||||
// 거울(260-4) — 시험이 표준입력으로 준 같은 입력 [{section, station, options, chainage, interval}]
|
||||
// → TS 엔티티 · 측점 이름(파이썬 짝 `_Engine_Cad_CulvertNote` 와 대조)
|
||||
// 거울(260-4 · 265-2) — 시험이 표준입력으로 준 같은 입력 [{section, station, things, chainage, interval}]
|
||||
// → TS 조합 줄 · 엔티티 · 측점 이름(파이썬 짝 `_Engine_Cad_CulvertNote` 와 대조)
|
||||
if (process.argv[2] === "mirror") {
|
||||
const cases = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const mirrored = cases.map((item) => ({
|
||||
entities: cad.culvertNoteEntities(item.section, item.station, item.options),
|
||||
station: cad.stationNoLabel(item.chainage, item.interval),
|
||||
}));
|
||||
const mirrored = cases.map((item) => {
|
||||
const lines = cad.structureNoteLines(item.things ?? [], item.section.chainage_m);
|
||||
return {
|
||||
lines,
|
||||
entities: cad.noteEntities(item.section, item.station, lines),
|
||||
station: cad.stationNoLabel(item.chainage, item.interval),
|
||||
at: cad.thingsAt(item.things ?? [], item.section.chainage_m).length,
|
||||
};
|
||||
});
|
||||
process.stdout.write(JSON.stringify(mirrored));
|
||||
} else if (process.argv[2] === "load") {
|
||||
Object.assign(DATA, JSON.parse(fs.readFileSync(0, "utf8")));
|
||||
cad.loadNoteThings("p").then((things) => process.stdout.write(JSON.stringify(things)));
|
||||
} else process.stdout.write(JSON.stringify(out));
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/* PLAN 266-1 단위검증 헬퍼 — [상세] 처음 가로 범위 = 카드 반폭.
|
||||
* 실제 TS(`_Cross_View_Metrics` 칸 맞춤 · `b_svg_zoom_pan` homeScale)를 Node 로 돌림.
|
||||
* 처음 배율 = 뷰 `detailChart` 와 같은 식(base.ppm / fitted.ppm · 짝 시험이 원본 줄을 확인).
|
||||
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_266_detail_home.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const B06 = path.join(ROOT, "B06_Section");
|
||||
const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8");
|
||||
const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)();
|
||||
const CROSS_HEIGHT = Number(/export const CROSS_HEIGHT = (\d+)/.exec(common)[1]);
|
||||
const STUBS = {
|
||||
"./B06_Section_UI_Section_Common": {
|
||||
CROSS_PAD,
|
||||
CROSS_HEIGHT,
|
||||
validElevation: (s) => s.valid !== false && Number.isFinite(s.elevation_m),
|
||||
},
|
||||
};
|
||||
function loadTs(full) {
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) => STUBS[request] ?? {};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
const metrics = loadTs(path.join(B06, "B06_Section_UI_Cross_View_Metrics.ts"));
|
||||
const { attachSvgZoomPan } = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts"));
|
||||
|
||||
function fakeSvg(width, height) {
|
||||
const listeners = {};
|
||||
const attrs = {};
|
||||
return {
|
||||
attrs,
|
||||
setAttribute: (name, value) => (attrs[name] = String(value)),
|
||||
getAttribute: (name) => attrs[name] ?? null,
|
||||
classList: { add() {}, remove() {} },
|
||||
addEventListener: (type, fn) => (listeners[type] ??= []).push(fn),
|
||||
fire: (type, event) =>
|
||||
(listeners[type] ?? []).forEach((fn) =>
|
||||
fn({ preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event }),
|
||||
),
|
||||
setPointerCapture() {},
|
||||
releasePointerCapture() {},
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width, height }),
|
||||
};
|
||||
}
|
||||
|
||||
// 가파른 비탈 — ±30 m 샘플 · 1 m 마다 · 1 m 에 0.6 m 오름(칸 맞춤이 가로를 넓히게)
|
||||
const section = {
|
||||
samples: Array.from({ length: 61 }, (_, i) => ({
|
||||
offset_m: i - 30,
|
||||
elevation_m: 100 + 0.6 * (i - 30),
|
||||
})),
|
||||
};
|
||||
|
||||
/** 반폭 · 칸 크기로 상세 창을 연 셈 → 처음 · 줌 · 팬 · 원복 뒤 보이는 가로 범위(m). */
|
||||
function open(halfWidth, W, H) {
|
||||
const base = metrics.crossPlotBase(section, 1, W, halfWidth, 100);
|
||||
const fitted = metrics.fitPlotBase(base, H);
|
||||
const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1;
|
||||
const svg = fakeSvg(W, H);
|
||||
const layer = fakeSvg(W, H);
|
||||
let state = null;
|
||||
const handle = attachSvgZoomPan({
|
||||
svg,
|
||||
layer,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
pad: CROSS_PAD,
|
||||
maxScale: 16,
|
||||
content: { x: -W, y: -H, width: 3 * W, height: 3 * H },
|
||||
wheelZoom: true,
|
||||
homeScale,
|
||||
onChange: (next) => (state = next),
|
||||
});
|
||||
// 보이는 플롯 가로 끝(svg x) → 도형 층 x → 측점 거리(m)
|
||||
const range = () => {
|
||||
const s = state ?? { scale: 1, tx: 0, ty: 0 };
|
||||
const toM = (x) =>
|
||||
fitted.minOffset + ((x - s.tx) / s.scale - CROSS_PAD.left) / fitted.pixelsPerMeter;
|
||||
return [toM(CROSS_PAD.left), toM(W - CROSS_PAD.right)].map((v) => Math.round(v * 1000) / 1000);
|
||||
};
|
||||
const out = { homeScale, widened: fitted !== base, first: range() };
|
||||
svg.fire("wheel", { clientX: W / 2, clientY: H / 2, deltaY: 100 });
|
||||
out.wheelScale = state.scale / homeScale;
|
||||
svg.fire("pointerdown", { button: 1, clientX: 100, clientY: 100 });
|
||||
svg.fire("pointermove", { clientX: 160, clientY: 100 });
|
||||
svg.fire("pointerup", {});
|
||||
out.panned = range()[0] !== out.first[0];
|
||||
handle.reset(); // [⤢]
|
||||
out.reset = range();
|
||||
for (let i = 0; i < 40; i++) svg.fire("wheel", { clientX: W / 2, clientY: H / 2, deltaY: -100 });
|
||||
out.minScale = state.scale; // 줄이면 칸 맞춤 전체(1배)까지
|
||||
svg.fire("dblclick", {});
|
||||
out.dblclick = range();
|
||||
return out;
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
global15: open(15, 1200, 300),
|
||||
station10: open(10, 1200, 300),
|
||||
tall15: open(15, 1200, 900),
|
||||
}),
|
||||
);
|
||||
@@ -93,7 +93,8 @@ def test_상세_큰_창_공용_막_그림_글_표_순서():
|
||||
with open(os.path.join(base, "B06_Section_UI_Style_Cross_Detail.css"), encoding="utf-8") as f:
|
||||
css = f.read().replace(chr(13), "")
|
||||
assert "wide: true" in src and "backdropClass" not in src
|
||||
assert "body.append(cad, note, tableBox)" in src
|
||||
# 265-1 — 그림 → 표 → CAD 칸(글 칸 자리)
|
||||
assert "body.append(cad, tableBox, noteBox)" in src
|
||||
# 막 없음 — 259-3 표 강조 바깥 줄(누름 통과)만 pointer-events: none
|
||||
backdrop = css.split(".b06-detail__pick-outline {")[0]
|
||||
assert "pointer-events: none" not in backdrop and "b06-detail__backdrop" not in css
|
||||
|
||||
@@ -84,13 +84,16 @@ def test_모달_배선_단추_칸_비율():
|
||||
css = _read("B06_Section_UI_Style_Cross_Detail.css")
|
||||
assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src
|
||||
assert "cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []))" in src
|
||||
assert "attachDetailZoom(clone, axes, zoom, (next) => (zoom = next))" in src
|
||||
flat = " ".join(src.split())
|
||||
assert (
|
||||
"attachDetailZoom( clone, axes, zoom, (next) => (zoom = next), drawn.homeScale, )" in flat
|
||||
)
|
||||
assert "layer.append(...clone.childNodes)" not in src # 감싸기 지움(179-3)
|
||||
assert 'clone.querySelector<SVGGElement>("g.b06-chart__plot-layer")' in src
|
||||
assert "createCrossAxes(axisLayer, axes)" in src
|
||||
view = _read("B06_Section_UI_Section_View.ts")
|
||||
assert "const plot = fitted && crossPlotFromBase(fitted, heightPx);" in view
|
||||
assert "return { svg, axes };" in view
|
||||
assert "return { svg, axes, homeScale };" in view
|
||||
assert "position: relative;" in css
|
||||
# 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득
|
||||
assert ".b06-detail__cad > svg {\n display: block;\n width: 100%;\n height: 100%;" in css
|
||||
|
||||
@@ -100,7 +100,10 @@ def test_상세_칸_크기로_새로_그림_배선():
|
||||
assert "cardZoomStates.delete(stationId);" in view
|
||||
types = _read("B06_Section_UI_Section_View_Types.ts")
|
||||
# 179-3 — svg + 축 옵션
|
||||
assert " ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;" in types
|
||||
assert (
|
||||
" ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null;"
|
||||
in types
|
||||
)
|
||||
assert (
|
||||
" detailChart: (\n stationId: string,\n widthPx: number,\n heightPx: number,"
|
||||
in types
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
못박는 것
|
||||
- 줄 = 「배치 열 · 행」 · 「[이 장] [모든 장]」 · 「표 배율 [배율 적용]」
|
||||
· [안내선 숨기기](부르는 쪽).
|
||||
- 단추 · 숫자 칸 · 이름 = 줄 위 작은 단추 토큰(187 `--btn-bar-*` · 높이 16) · 한 줄 안 같은 너비.
|
||||
- 단추 · 숫자 칸 · 이름 = 좌측 패널 공용 양식(269 `.ui-panel-form`) · 한 줄 안 같은 너비.
|
||||
- 옛 한 줄 감기(flex-wrap) · 숫자 칸 48px 고정 없음.
|
||||
- [안내선 숨기기] 뒤 남는 검은 선 = 인쇄 칸선(도각 층 도면 개체 · 회청 `#8ea0b5`
|
||||
· 밝은 바탕에서 짙게 그려짐) — 안내선(겹판)과 다른 것이라 지우지 않음 · 단추는 겹판만 끔.
|
||||
@@ -40,16 +40,13 @@ def _decl(block: str, prop: str) -> str:
|
||||
|
||||
|
||||
def test_단추_숫자_칸_한_규격_토큰():
|
||||
shared = _block(".b07-cross-layout .ui-btn,\n.b07-cross-layout .ui-field .ui-input")
|
||||
assert _decl(shared, "padding") == "var(--btn-bar-padding)"
|
||||
assert _decl(shared, "font-size") == "var(--btn-bar-font-size)"
|
||||
assert _decl(shared, "line-height") == "1"
|
||||
assert _decl(shared, "height") == "auto" # 공용 작은 칸 30px 를 풀어 단추 높이에 맞춤
|
||||
# 269 — 크기는 좌측 패널 공용 양식(`.ui-panel-form`)이 줌 · B07 덧칠(줄 위 작은 단추 토큰) 없음
|
||||
assert ".b07-cross-layout .ui-btn" not in CSS
|
||||
assert ".b07-cross-layout .ui-field .ui-input {" in CSS # 폭만
|
||||
assert "height" not in _block(".b07-cross-layout .ui-field .ui-input")
|
||||
label = _block(".b07-cross-layout .ui-field__label")
|
||||
assert _decl(label, "font-size") == "var(--btn-bar-font-size)"
|
||||
# 토큰은 theme 한 곳(187)
|
||||
assert "--btn-bar-padding: 1px var(--spacing-8);" in THEME
|
||||
assert "--btn-bar-font-size: var(--text-caption);" in THEME
|
||||
assert "font-size" not in label
|
||||
assert "createPanelCard(" in PANELS # 배치 칸이 사는 횡단도 묶음 = 공용 카드
|
||||
|
||||
|
||||
def test_한_줄에_한_일_같은_너비():
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""254-3 B08 거리이정표 상자 모달 제목 · 장소 그림(사용자 162-6 · 8).
|
||||
|
||||
① 상자 누름 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」(표 머리 글 「항목 정보」 없음)
|
||||
① 상자 누름 모달 제목 = 이름만(262-1 · 「—」 앞 글자 없음 · 표 머리 글 「항목 정보」 없음)
|
||||
② 장소 그림(256-1 `place_images`) = 상자 안 글 아래 작게 · 그림 없으면 자리 없음(상자 높이 그대로)
|
||||
"""
|
||||
|
||||
@@ -67,7 +67,7 @@ def test_장소_그림_상자_안_글_아래():
|
||||
|
||||
def test_모달_제목_화면_그림():
|
||||
modal = _read(B08 + "UI_Milestone_Modal.ts")
|
||||
assert "`거래처 — ${route!.name" in modal and "`기준점 — ${base.name" in modal
|
||||
assert 'route!.name || "거래처"' in modal and 'base.name || "기준점"' in modal # 262-1
|
||||
assert "항목 정보 —" not in modal
|
||||
ui = _read(B08 + "UI_Milestone_Diagram.ts")
|
||||
assert "placeImageBlobUrl(projectId, id)" in ui and "placeImageKey(id)" in ui
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
"""PLAN 260 — B06 [상세] 표 위 CAD 칸(`structure_note_cad`) → B07 횡단도 표 위.
|
||||
|
||||
- 260-1 칸: 스키마 · 재계산 남김 · 세션 초안 → [저장] patch.
|
||||
- 260-2 미리 넣음: 횡단배수 주인 측점만 · 관 · 유입 · 유출 · 도수로 · 빈 구조물 · 옛 글 옮김
|
||||
(`helper_260_note_cad.cjs` 가 실제 TS 를 Node 로 돌림).
|
||||
- 260-2 미리 넣음: 횡단배수 주인 측점 원형 마크 · 빈 구조물 · 옛 글 옮김
|
||||
(`helper_260_note_cad.cjs` 가 실제 TS 를 Node 로 돌림) · 오른쪽 글 = 구조물 조합 줄(265-2 ·
|
||||
`test_265_note_cad.py`) — 옛 관보호공 · 관유출부보강 짜 맞춘 글은 버림.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_260_note_cad.py -q
|
||||
"""
|
||||
@@ -101,50 +102,58 @@ def _labels(entities: list[dict]) -> list[str]:
|
||||
|
||||
|
||||
def test_관_유입_유출_도수로_한_벌(out):
|
||||
"""그림(162-21) 꼴 — 원 안 세 줄 · 오른쪽 측점 · 관보호공 둘 · 관유출부보강."""
|
||||
"""그림(162-21) 꼴 — 원 안 세 줄 · 오른쪽 측점 · 구조물 조합 줄(265-2)."""
|
||||
full = out["full"]
|
||||
assert [entity["type"] for entity in full[:2]] == ["Circle", "Line"]
|
||||
assert _labels(full) == [
|
||||
"배수관",
|
||||
"Φ1200m/m",
|
||||
"L=22.0m",
|
||||
"L=22m",
|
||||
"NO.71+13",
|
||||
"관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m",
|
||||
"관보호공(유출) 기슭막이(메,H=2.0) L=10.0m",
|
||||
"관유출부보강(돌붙임,찰) 10m2",
|
||||
"기슭막이 찰쌓기 2.0m",
|
||||
"돌붙임 찰붙임",
|
||||
]
|
||||
# 원 안 = 가운데 맞춤 · 원 중심 x · 오른쪽 = 왼쪽 맞춤 · 줄 3.0 간격 · 붉은 계열 · 기본 층
|
||||
inside, right = full[2:5], full[5:]
|
||||
assert {(e["align"], e["x"]) for e in inside} == {("center", full[0]["x"])}
|
||||
assert [e["y"] for e in right] == [-1.5, -4.5, -7.5, -10.5]
|
||||
assert [e["y"] for e in right] == [-1.5, -4.5, -7.5]
|
||||
assert {e["x"] for e in right} == {15}
|
||||
assert {e["align"] for e in right} == {"left"}
|
||||
assert {e["color"] for e in full} == {"#ff7f7f"} and {e["layer"] for e in full} == {"기본"}
|
||||
|
||||
|
||||
def test_관_길이_없으면_L_뒤_빈칸(out):
|
||||
assert _labels(out["noLength"])[2] == "L="
|
||||
# 도수로 상자가 없으면 관유출부보강 줄 없음
|
||||
assert not any(label.startswith("관유출부보강") for label in _labels(out["noLength"]))
|
||||
# 조합 줄이 없으면 측점 줄까지
|
||||
assert _labels(out["noLength"])[3:] == ["NO.71+13"]
|
||||
|
||||
|
||||
def test_유입_집수정_은_형태_높이_길이(out):
|
||||
assert "관보호공(유입) 집수정(L형,H=1.7) L=2.0m" in _labels(out["basin"])
|
||||
# 265-2 — 보호공 짜 맞춘 글 버림(조합 줄로) · 원형 마크 · 측점만
|
||||
assert _labels(out["basin"]) == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"]
|
||||
|
||||
|
||||
def test_도수로_옵션이_없으면_세트_구조만(out):
|
||||
assert _labels(out["chuteFallback"])[-1] == "관유출부보강(도수로) m2"
|
||||
# 265-2 — 관유출부보강 짜 맞춘 글 버림
|
||||
assert not any(label.startswith("관유출부보강") for label in _labels(out["chuteFallback"]))
|
||||
|
||||
|
||||
def test_보호공을_안_고르면_관과_측점만(out):
|
||||
assert _labels(out["noSides"]) == ["배수관", "Φ1200m/m", "L=22.0m", "NO.71+13"]
|
||||
assert _labels(out["noSides"]) == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["none", "ford", "hidden", "neighbour"])
|
||||
def test_횡단배수_주인_측점이_아니면_빈값(out, case):
|
||||
"""구조물 없음 · 상자형(세월교 · 박스) · 숨은 관(독립 기슭막이) · 이웃 측점 = 빈 도면."""
|
||||
"""구조물 없음 · 상자형(세월교 · 박스) · 숨은 관 · 이웃 측점 = 빈 도면(줄 없음)."""
|
||||
assert out[case] == []
|
||||
|
||||
|
||||
def test_마크_없이_조합_줄만이면_측점부터_왼쪽_끝(out):
|
||||
lines = out["linesOnly"]
|
||||
assert _labels(lines) == ["NO.71+13", "기슭막이 찰쌓기 2.0m", "돌붙임 찰붙임"]
|
||||
assert {e["x"] for e in lines} == {0} and [e["y"] for e in lines] == [-1.5, -4.5, -7.5]
|
||||
|
||||
|
||||
def test_옛_글은_줄마다_Text_로_옮김(out):
|
||||
legacy = out["legacy"]
|
||||
assert _labels(legacy) == ["L형 측구", "집수정 보강"]
|
||||
@@ -300,10 +309,11 @@ def test_CAD_칸이_없으면_옛_글_줄_그대로():
|
||||
|
||||
from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file # noqa: E402
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402
|
||||
culvert_note_entities,
|
||||
load_pipe_notes,
|
||||
load_note_sources,
|
||||
note_entities,
|
||||
station_no_label,
|
||||
with_culvert_note,
|
||||
structure_note_lines,
|
||||
with_structure_note,
|
||||
)
|
||||
|
||||
_OPTIONS = {
|
||||
@@ -323,36 +333,44 @@ _OPTIONS = {
|
||||
_NO_CHUTE = {k: v for k, v in _OPTIONS.items() if not k.startswith("chute")}
|
||||
|
||||
|
||||
def _case(options: dict, pipe_at: float, chainage: float, design: dict | None, **spec) -> dict:
|
||||
def _item(key: str, name: str) -> dict:
|
||||
return {"layer": "system", "owner": None, "key": key, "name": name}
|
||||
|
||||
|
||||
# 관 조합 — 본체(「관」) · 유입 · 유출 · 도수로(265-2 · 본체 줄은 원형 마크가 대신)
|
||||
_PIPE_THING = {
|
||||
"name": "배관",
|
||||
"pipe": True,
|
||||
"at": 1433.0,
|
||||
"group_items": {
|
||||
"grv6lny": _item("SS000001", "흄관 1200"),
|
||||
"gnbf9m9": _item("SS000055", "기슭막이 메쌓기 1.2m 메쌓기 기초무"),
|
||||
"gchuteadd1": _item("SS000070", "돌붙임 찰붙임"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _case(
|
||||
options: dict, pipe_at: float, chainage: float, design: dict | None, things=(), **spec
|
||||
) -> dict:
|
||||
culvert = {**_culvert_set(options), "chainage_m": pipe_at, **spec}
|
||||
return {
|
||||
"section": {"chainage_m": chainage, "culvert": culvert, "design": design},
|
||||
"station": station_no_label(chainage, 20.0),
|
||||
"options": options,
|
||||
"things": list(things),
|
||||
"chainage": chainage,
|
||||
"interval": 20.0,
|
||||
}
|
||||
|
||||
|
||||
_MIRROR_CASES = [
|
||||
_case(_OPTIONS, 1433.0, 1433.0, {"pipe_length_m": 22}),
|
||||
_case(_OPTIONS, 1433.0, 1433.0, {"pipe_length_m": 22}, [_PIPE_THING]),
|
||||
_case(_NO_CHUTE, 1433.0, 1433.0, {}),
|
||||
_case(
|
||||
{
|
||||
**_NO_CHUTE,
|
||||
"inlet_type": "집수정",
|
||||
"inlet_structure": "L",
|
||||
"inlet_basin_height_m": 1.7,
|
||||
"inlet_basin_length_m": 2,
|
||||
},
|
||||
1433.0,
|
||||
1433.0,
|
||||
{"pipe_length_m": 9.5},
|
||||
),
|
||||
_case({**_NO_CHUTE, "chute_add_2_structure": "도수로", "chute_add_2_area": ""}, 80, 80, None),
|
||||
_case({**_OPTIONS, "pipe_diameter_mm": 800, "chute_add_1_area": 12.345}, 12.5, 12.5, {}),
|
||||
_case({**_OPTIONS, "inlet_revet_form": "콘크리트"}, 440.241, 440.0, {"pipe_length_m": 7}),
|
||||
_case(_OPTIONS, 1433.0, 1440.0, {"pipe_length_m": 22}),
|
||||
_case(_NO_CHUTE, 1433.0, 1433.0, {"pipe_length_m": 9.5}),
|
||||
_case(_NO_CHUTE, 80, 80, None, [{**_PIPE_THING, "at": 80.0}]),
|
||||
_case({**_OPTIONS, "pipe_diameter_mm": 800}, 12.5, 12.5, {}, [{**_PIPE_THING, "at": 12.5}]),
|
||||
_case(_OPTIONS, 440.241, 440.0, {"pipe_length_m": 7}, [{**_PIPE_THING, "at": 440.241}]),
|
||||
_case(_OPTIONS, 1433.0, 1440.0, {"pipe_length_m": 22}, [_PIPE_THING]),
|
||||
_case(_OPTIONS, 1433.0, 1433.0, {}, body="ford"),
|
||||
_case(_OPTIONS, 1433.0, 1433.0, {}, hidden_pipe=True),
|
||||
]
|
||||
@@ -362,33 +380,35 @@ def _without_id(entities: list[dict]) -> list[dict]:
|
||||
return [{k: v for k, v in entity.items() if k != "id"} for entity in entities]
|
||||
|
||||
|
||||
def test_거울_TS_와_파이썬_짝은_같은_입력에_같은_엔티티():
|
||||
def _mirror(cases: list[dict]) -> list[dict]:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_260_note_cad.cjs"), "mirror"],
|
||||
input=json.dumps(_MIRROR_CASES, ensure_ascii=False),
|
||||
input=json.dumps(cases, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
mirrored = json.loads(proc.stdout)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_거울_TS_와_파이썬_짝은_같은_입력에_같은_엔티티():
|
||||
mirrored = _mirror(_MIRROR_CASES)
|
||||
assert len(mirrored) == len(_MIRROR_CASES)
|
||||
for case, ts in zip(_MIRROR_CASES, mirrored, strict=True):
|
||||
section = case["section"]
|
||||
py = culvert_note_entities(
|
||||
section["chainage_m"],
|
||||
section["culvert"],
|
||||
section["design"],
|
||||
case["station"],
|
||||
case["options"],
|
||||
lines = structure_note_lines(case["things"], section["chainage_m"])
|
||||
assert lines == ts["lines"], case["station"]
|
||||
py = note_entities(
|
||||
section["chainage_m"], section["culvert"], section["design"], case["station"], lines
|
||||
)
|
||||
assert _without_id(py) == _without_id(ts["entities"]), case["station"]
|
||||
assert station_no_label(case["chainage"], case["interval"]) == ts["station"]
|
||||
# 대조가 빈 목록끼리만이 아님 — 주인 측점 여섯은 그림이 있음 · 이웃 · 세월교 · 숨은 관 = 빈값
|
||||
assert [bool(item["entities"]) for item in mirrored] == [True] * 6 + [False] * 3
|
||||
labels = [e["shapeData"].get("label") for e in mirrored[4]["entities"]]
|
||||
assert "Φ800m/m" in labels and "관유출부보강(돌붙임,찰) 12.35m2" in labels
|
||||
assert "Φ800m/m" in labels and "돌붙임 찰붙임" in labels and "흄관 1200" not in labels
|
||||
|
||||
|
||||
def _write_pipes(root: Path, points: list[dict]) -> None:
|
||||
@@ -406,10 +426,16 @@ def test_정본_배수관만_읽음(tmp_path):
|
||||
{"chainage_m": 90.0, "lonlat": [0, 0], "facility": "ford_bridge", "options": {}},
|
||||
],
|
||||
)
|
||||
pipes = load_pipe_notes(tmp_path)
|
||||
pipes, things = load_note_sources(tmp_path)
|
||||
assert [pipe[0] for pipe in pipes] == [1433.0] # 관경 안 고름 · 세월교 = 빼
|
||||
assert pipes[0][1]["chainage_m"] == 1433.0 and pipes[0][2] == _OPTIONS
|
||||
assert load_pipe_notes(tmp_path / "없음") == []
|
||||
# 글 줄 재료 = 관 지점 모두(측점 차례 · 시설 이름 = 구조물정의 종류 이름)
|
||||
assert [(t["name"], t["pipe"], t["at"]) for t in things] == [
|
||||
("배관", True, 80.0),
|
||||
("세월교", False, 90.0),
|
||||
("배관", True, 1433.0),
|
||||
]
|
||||
assert load_note_sources(tmp_path / "없음") == ([], [])
|
||||
|
||||
|
||||
def test_칸_없음_만_미리_넣음_지운_칸_옛_글_설계없음_이웃은_그대로(tmp_path):
|
||||
@@ -417,37 +443,38 @@ def test_칸_없음_만_미리_넣음_지운_칸_옛_글_설계없음_이웃은_
|
||||
tmp_path,
|
||||
[{"chainage_m": 1433.0, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS}],
|
||||
)
|
||||
pipes = load_pipe_notes(tmp_path)
|
||||
sources = load_note_sources(tmp_path)
|
||||
design = {"pipe_length_m": 22, "paved": False}
|
||||
seeded = with_culvert_note(design, 1433.0, pipes, "NO.71+13")
|
||||
seeded = with_structure_note(design, 1433.0, sources, "NO.71+13")
|
||||
assert seeded is not design and "structure_note_cad" not in design # 저장 설계는 안 바뀜
|
||||
labels = [e["shapeData"].get("label") for e in seeded["structure_note_cad"]["entities"]]
|
||||
assert labels[2:] == [
|
||||
"배수관",
|
||||
"Φ1200m/m",
|
||||
"L=22.0m",
|
||||
"NO.71+13",
|
||||
"관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m",
|
||||
"관보호공(유출) 기슭막이(메,H=2.0) L=10.0m",
|
||||
"관유출부보강(돌붙임,찰) 10m2",
|
||||
]
|
||||
# 관 조합 미지정 = 원형 마크 · 측점만(배관은 글 줄 없음)
|
||||
assert labels[2:] == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"]
|
||||
cleared = {**design, "structure_note_cad": {"entities": []}}
|
||||
assert with_culvert_note(cleared, 1433.0, pipes, "x") is cleared
|
||||
assert with_structure_note(cleared, 1433.0, sources, "x") is cleared
|
||||
old = {**design, "structure_note": "옛 글"}
|
||||
assert with_culvert_note(old, 1433.0, pipes, "x") is old
|
||||
assert with_culvert_note(None, 1433.0, pipes, "x") is None
|
||||
assert with_culvert_note(design, 1440.0, pipes, "x") is design
|
||||
assert with_structure_note(old, 1433.0, sources, "x") is old
|
||||
assert with_structure_note(None, 1433.0, sources, "x") is None
|
||||
assert with_structure_note(design, 1440.0, sources, "x") is design
|
||||
|
||||
|
||||
def test_B07_은_안_연_측점에_미리_넣음을_표_위에_그림(tmp_path):
|
||||
_write_pipes(
|
||||
tmp_path,
|
||||
[{"chainage_m": 20.2, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS}],
|
||||
[
|
||||
{
|
||||
"chainage_m": 20.2,
|
||||
"lonlat": [0, 0],
|
||||
"facility": "pipe",
|
||||
"options": _OPTIONS,
|
||||
"group_items": {"g9lq9uk": _item("SS000055", "기슭막이 메쌓기 1.2m")},
|
||||
}
|
||||
],
|
||||
)
|
||||
design = with_culvert_note({"pipe_length_m": 22}, 20.0, load_pipe_notes(tmp_path), "NO.1")
|
||||
design = with_structure_note({"pipe_length_m": 22}, 20.0, load_note_sources(tmp_path), "NO.1")
|
||||
drawn = _draw(design)
|
||||
labels = [e["shapeData"]["label"] for e in drawn["entities"] if e["type"] == "Text"]
|
||||
assert "NO.1" in labels and "관유출부보강(돌붙임,찰) 10m2" in labels
|
||||
assert "NO.1" in labels and "기슭막이 메쌓기 1.2m" in labels
|
||||
plain_top = _table(_draw({"pipe_length_m": 22}))["shapeData"]["origin"]["y"]
|
||||
drop = plain_top - _table(drawn)["shapeData"]["origin"]["y"]
|
||||
assert drop == pytest.approx(cross_note_block_height(design)) and drop > 15
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""262 B08 거리이정표 상자 창 제목 · 소재지 지도 시점 표지(사용자 164-1).
|
||||
|
||||
262-1 거래처 · 기준점 창 제목 = 이름만(빈 이름 = 「거래처」 · 「기준점」) · 시점 창 = 「시점 · 중점」 · 요소 추가 창 그대로
|
||||
262-2 소재지 지도 = 시점 표지 고정(끌기 없음 · 맞춤에서 빠짐) · 시점 없으면 표지 없음 · 소재지 없으면 시점 중심
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B08 = "B08_Quantity/B08_Quantity_UI_Milestone"
|
||||
|
||||
|
||||
def _read(name):
|
||||
return (ROOT / f"{B08}{name}").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_제목_이름만():
|
||||
modal = _read("_Modal.ts")
|
||||
title = modal[modal.index("const title = target.add") : modal.index("openModal({")]
|
||||
assert '? base.name || "기준점"' in title and ': route!.name || "거래처"' in title
|
||||
assert '"요소 추가 — 기준점 · 거래처"' in title
|
||||
assert 'title: "시점 · 중점",' in modal and "공사지 —" not in modal
|
||||
assert re.search(r"title: `?[^\n]*(거래처|기준점|공사지) —", modal) is None
|
||||
|
||||
|
||||
def test_시점_표지_고정():
|
||||
place = _read("_Place.ts")
|
||||
assert "startPoint(liveMilestone(projectId))" in place # 거리이정표가 쓰는 시점
|
||||
assert "center: start" in place # 소재지 없음 = 시점 중심
|
||||
assert "movable: false, fixed: true" in place and 'name: "시점"' in place
|
||||
assert "...(s" in place and " : [])," in place # 시점 없음 = 표지 없음
|
||||
kakao = _read("_KakaoMap.ts")
|
||||
assert "fixed?: boolean;" in kakao
|
||||
assert "const moving = pins.filter((p) => !p.fixed);" in kakao
|
||||
assert "...moving.map(" in kakao # 처음 보기 = 소재지 중심(시점은 맞춤에서 빠짐)
|
||||
assert "moving.find(" in kakao # 보기 이동에서도 빠짐
|
||||
assert "if (p.fixed)" in kakao and "b08-milestone__map-start" in kakao
|
||||
css = _read(".css")
|
||||
assert ".b08-milestone__map-start {" in css
|
||||
@@ -0,0 +1,210 @@
|
||||
"""PLAN 264 — B09 공기 옵션(착공일 · 기후 자료 · 쉬는 날 · 공휴일 빼기 · 투입 · 준비 정리 · 반영률 ·
|
||||
주 장비 기준 · 중복 C 끝수 · 마지막 달) — 옵션마다 결과가 바뀜 · 다듬기 · 거울 TS = Py · 패널 훅."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from B09_Estimation.draft.B09_Draft_Schedule_Calc import compute_schedule # noqa: E402
|
||||
from B09_Estimation.draft.B09_Draft_Schedule_Source import ( # noqa: E402
|
||||
CLIMATES,
|
||||
clean_schedule,
|
||||
schedule_inputs,
|
||||
unit_resources,
|
||||
)
|
||||
from test_252_1_b09_schedule import GEOCHANG, SRC, _bill, _ref_user, _ts_schedule # noqa: E402
|
||||
|
||||
SRC2 = {**SRC, "climates": CLIMATES}
|
||||
UNITS = {
|
||||
"a": {
|
||||
"machine": {"key": "M1", "name": "굴착기 0.7㎥", "hr": 0.0066},
|
||||
"labor": 0.01,
|
||||
"machine_sum": {"hr": 0.0086, "n": 2},
|
||||
},
|
||||
"b": {"machine": None, "labor": 1.5},
|
||||
}
|
||||
BILL = [
|
||||
{"kind": "item", "key": "a", "qty": 1000, "section": "1.2", "level": 2},
|
||||
{"kind": "item", "key": "b", "qty": 80, "section": "2.1", "level": 2},
|
||||
{"kind": "item", "key": "h", "qty": 9, "section": "3", "level": 2},
|
||||
]
|
||||
SRC3 = {**SRC2, "units": UNITS}
|
||||
YEAR_2026 = {"start": {"year": 2026, "month": 1}, "tidy": 400}
|
||||
|
||||
|
||||
def _plain():
|
||||
"""거창 재현 사용자 값에서 법정공휴일 덮개만 뺌(자동 B 를 봄)."""
|
||||
user = _ref_user()
|
||||
del user["holiday"]
|
||||
return user
|
||||
|
||||
|
||||
def _cal(user, src=SRC2, bill=None):
|
||||
return compute_schedule(src, _bill(GEOCHANG) if bill is None else bill, user)["calendar"]
|
||||
|
||||
|
||||
def _rows(user):
|
||||
return {r["key"]: r for r in compute_schedule(SRC3, BILL, user)["work"]["rows"]}
|
||||
|
||||
|
||||
def test_기본값_그대로():
|
||||
base = compute_schedule(SRC, _bill(GEOCHANG), _ref_user())
|
||||
assert compute_schedule(SRC2, _bill(GEOCHANG), _ref_user()) == base
|
||||
picked = {**_ref_user(), "climate_set": "geochang25", "rest": "sun", "machine_basis": "max",
|
||||
"c_round": "none", "last_off": "ratio", "holiday_off": [], "units": 1, "crew": 10,
|
||||
"rate_haul": 0, "rate_base": 100, "hours": 8, "prep_docs": 2, "prep_setup": 2,
|
||||
"tidy": 4} # fmt: skip
|
||||
assert compute_schedule(SRC2, _bill(GEOCHANG), picked) == base
|
||||
assert (base["work"]["total"], base["calendar"]["days"]) == (95, 136)
|
||||
|
||||
|
||||
def test_1_착공일_그날부터():
|
||||
user = {**_plain(), "start": {"year": 2026, "month": 3, "day": 15}}
|
||||
cal = _cal(user)
|
||||
first = cal["months"][0]
|
||||
# 3.15 ~ 3.31 = 17일 · A = ROUNDUP(3.6 × 17 ÷ 31, 1) = 2.0 · 일요일 15 · 22 · 29
|
||||
assert (first["days"], first["a"], first["b_auto"]) == (17, 2.0, 3)
|
||||
assert cal["start"] == {"year": 2026, "month": 3, "day": 15}
|
||||
assert cal["end"] > _cal(_plain())["end"]
|
||||
assert cal["months"][1]["days"] == 30 # 둘째 달부터 달 전체
|
||||
|
||||
|
||||
def test_2_기후_자료_고르기():
|
||||
m = _cal({**_plain(), "climate_set": "uljin24"})["months"][0]
|
||||
assert (m["rain"], m["heat"]) == (2.9, 0)
|
||||
m = _cal({**_plain(), "climate_set": "sogwang25"})["months"][5 - 3]
|
||||
assert (m["rain"], m["heat"]) == (2.1, 0) # 5월
|
||||
assert all(m["a"] == 0 for m in _cal({**_plain(), "climate_set": "zero"})["months"])
|
||||
user = {**_plain(), "climate_set": "uljin24", "climate": {"rain": [9] * 12}}
|
||||
assert _cal(user)["months"][0]["rain"] == 9 # 달마다 덮개가 이김
|
||||
|
||||
|
||||
def test_3_쉬는_날_토일():
|
||||
base = _cal(_plain())["months"][0]["b_auto"]
|
||||
assert _cal({**_plain(), "rest": "satsun"})["months"][0]["b_auto"] == base + 4 # 2026.3 토 4번
|
||||
|
||||
|
||||
def test_4_공휴일_빼기():
|
||||
def auto(off):
|
||||
cal = _cal({**YEAR_2026, "holiday_off": off}, bill=[])
|
||||
return [m["b_auto"] for m in cal["months"][:12]]
|
||||
|
||||
full = auto([])
|
||||
assert full == [5, 7, 6, 4, 8, 6, 5, 7, 7, 7, 5, 5]
|
||||
assert auto(["labor"])[4] == 7 # 5.1 노동절
|
||||
assert auto(["substitute"])[4] == 7 # 5.25 부처님오신날 대체
|
||||
assert auto(["election"])[5] == 5 # 6.3 지방선거
|
||||
assert auto(["constitution"])[6] == 4 # 7.17 제헌절
|
||||
may = _cal({**YEAR_2026, "holiday_off": ["labor"]}, bill=[])["months"][4]["names"]
|
||||
assert not any("노동절" in n for n in may)
|
||||
|
||||
|
||||
def test_5_일_작업시간():
|
||||
assert _rows({})["a"]["days"] == 0.9
|
||||
assert _rows({"hours": 4})["a"]["days"] == 1.7 # 1000 × 0.0066 ÷ 4 = 1.65
|
||||
|
||||
|
||||
def test_6_투입_기본():
|
||||
rows = _rows({"units": 2, "crew": 5})
|
||||
assert (rows["a"]["put"], rows["a"]["days"]) == (2, 0.5) # 0.4125 → 0.5
|
||||
assert (rows["b"]["put"], rows["b"]["days"]) == (5, 24)
|
||||
rows = _rows({"crew": 5, "rows": {"b": {"crew": 20}}})
|
||||
assert rows["b"]["days"] == 6 # 줄마다 덮개가 이김
|
||||
|
||||
|
||||
def test_7_준비_정리_기타():
|
||||
work = compute_schedule(SRC3, BILL, {"prep_docs": 3, "prep_setup": 1, "tidy": 6, "etc": 2})
|
||||
w = work["work"]
|
||||
assert (w["prep"], w["tidy"], w["etc"]) == (4, 6, 2)
|
||||
assert w["total"] == 4 + round(0.9 + 12 + 0 + 2) + 6
|
||||
|
||||
|
||||
def test_8_반영률_기본():
|
||||
rows = _rows({"rate_haul": 100, "rate_base": 50})
|
||||
assert (rows["h"]["rate"], rows["a"]["rate"], rows["b"]["rate"]) == (100, 50, 50)
|
||||
assert rows["b"]["days"] == 6
|
||||
assert _rows({"rate_base": 50, "rows": {"b": {"rate": 100}}})["b"]["days"] == 12
|
||||
|
||||
|
||||
def test_9_주_장비_장비_시간_합():
|
||||
a = _rows({"machine_basis": "sum"})["a"]
|
||||
assert (a["per"], a["who"], a["days"]) == (0.0086, "굴착기 0.7㎥ 외 1종", 1.1) # 8.6 ÷ 8
|
||||
assert _rows({})["a"]["per"] == 0.0066
|
||||
|
||||
|
||||
def test_10_중복일수_끝수():
|
||||
base = _cal(_plain())["months"]
|
||||
got = _cal({**_plain(), "c_round": "round1"})["months"]
|
||||
assert all(m["c"] == round(m["c"], 1) for m in got)
|
||||
assert [m["c"] for m in got] != [m["c"] for m in base]
|
||||
|
||||
|
||||
def test_11_마지막_달_비작업_전부():
|
||||
base = _cal(_ref_user())["months"][-1]
|
||||
last = _cal({**_ref_user(), "last_off": "all"})
|
||||
assert (base["off_used"], base["off"]) == (7, 15)
|
||||
assert last["months"][-1]["off_used"] == 15 and last["days"] == 136 + 8
|
||||
|
||||
|
||||
def test_다듬기_새_칸():
|
||||
raw = {
|
||||
"units": 2, "crew": 0, "rate_haul": 50, "rate_base": 120,
|
||||
"climate_set": "uljin24", "rest": "sat", "machine_basis": "sum",
|
||||
"c_round": "round1", "last_off": "x", "holiday_off": ["labor", "junk", "election"],
|
||||
"start": {"year": 2027, "month": 2, "day": 30},
|
||||
} # fmt: skip
|
||||
assert clean_schedule(raw) == {
|
||||
"rate_haul": 50,
|
||||
"units": 2,
|
||||
"climate_set": "uljin24",
|
||||
"machine_basis": "sum",
|
||||
"c_round": "round1",
|
||||
"holiday_off": ["election", "labor"],
|
||||
"start": {"year": 2027, "month": 2},
|
||||
}
|
||||
got = clean_schedule({"start": {"year": 2027, "month": 3, "day": 15}, "holiday_off": "x"})
|
||||
assert got == {"start": {"year": 2027, "month": 3, "day": 15}}
|
||||
|
||||
|
||||
def test_원천_기후_셋_장비_합():
|
||||
sheets = {
|
||||
"bill": {"rows": [{"kind": "item", "key": "earth:4", "ref": {"tab": "sg", "key": "S1"}}]},
|
||||
"sg": [{"key": "S1", "lines": [
|
||||
{"kind": "machine", "ref": {"key": "M1"}, "qty": 0.0066},
|
||||
{"kind": "machine", "ref": {"key": "M2"}, "qty": 0.002},
|
||||
]}],
|
||||
"machine": [],
|
||||
} # fmt: skip
|
||||
unit = unit_resources(sheets)["earth:4"]
|
||||
assert unit["machine_sum"]["n"] == 2 and round(unit["machine_sum"]["hr"], 6) == 0.0086
|
||||
got = schedule_inputs(sheets)
|
||||
assert list(got["climates"]) == ["geochang25", "uljin24", "sogwang25", "zero"]
|
||||
assert got["climates"]["geochang25"]["rain"] == got["climate"]["rain"]
|
||||
|
||||
|
||||
def test_거울_TS_옵션():
|
||||
cases = [
|
||||
(SRC2, _bill(GEOCHANG), {**_plain(), "start": {"year": 2026, "month": 3, "day": 15},
|
||||
"climate_set": "sogwang25", "rest": "satsun",
|
||||
"holiday_off": ["substitute", "constitution"],
|
||||
"c_round": "round1", "last_off": "all"}),
|
||||
(SRC3, BILL, {"machine_basis": "sum", "units": 3, "crew": 7, "rate_haul": 30,
|
||||
"rate_base": 80, "hours": 9, "prep_docs": 1, "tidy": 2, "etc": 1,
|
||||
"climate_set": "zero", "start": {"year": 2027, "month": 12, "day": 31}}),
|
||||
] # fmt: skip
|
||||
for src, bill, user in cases:
|
||||
py = json.loads(json.dumps(compute_schedule(src, bill, user), ensure_ascii=False))
|
||||
assert _ts_schedule(src, bill, user) == py
|
||||
|
||||
|
||||
def test_패널_훅_draft_없으면_빈칸():
|
||||
tabs = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs.ts").read_text("utf-8")
|
||||
shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
ui = (ROOT / "B09_Estimation/draft/B09_Draft_Schedule_UI.ts").read_text("utf-8")
|
||||
assert "{ builders: {}, extend: [], panels: [] }" in tabs # draft 없으면 카드 없음
|
||||
assert "...DRAFT.panels.map((make) => make(() => user, setUser))" in shell
|
||||
assert "DRAFT.panels.push(" in ui
|
||||
@@ -0,0 +1,228 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 265 — B06 [상세] 표 위 CAD 칸 · 접기 · 차례 · 구조물 글 · 관 길이 정수.
|
||||
|
||||
- 265-1 상세 창 차례 = 그림 → 표 → CAD 칸 · CAD 칸 접기(일반 측점 접힘 · 구조물 측점 폄).
|
||||
- 265-2 미리 넣는 글 = 그 측점 구조물마다 지정된 구조물 조합 이름 줄 · 미지정 · 배관 = 원형 마크
|
||||
(TS = 파이썬 짝 · `helper_260_note_cad.cjs` 가 실제 TS 를 Node 로 돌림).
|
||||
- 265-3 원형 마크 L = 사사오입 정수(TS `pipeLengthWhole` = 파이썬 짝 같은 값).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_265_note_cad.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Repository import structures_file_path # noqa: E402
|
||||
from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file # noqa: E402
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402
|
||||
load_note_sources,
|
||||
note_entities,
|
||||
structure_note_lines,
|
||||
with_structure_note,
|
||||
)
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
_PIPE = {"pipe_diameter_mm": 800}
|
||||
|
||||
|
||||
def _helper(mode: str, payload) -> list:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_260_note_cad.cjs"), mode],
|
||||
input=json.dumps(payload, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _labels(entities: list[dict]) -> list[str]:
|
||||
return [e["shapeData"]["label"] for e in entities if e["type"] == "Text"]
|
||||
|
||||
|
||||
def _strip(entities: list[dict]) -> list[dict]:
|
||||
return [{k: v for k, v in e.items() if k != "id"} for e in entities]
|
||||
|
||||
|
||||
def _case(things: list, chainage: float, culvert: dict | None, design: dict) -> dict:
|
||||
return {
|
||||
"section": {"chainage_m": chainage, "culvert": culvert, "design": design},
|
||||
"station": "NO.2",
|
||||
"things": things,
|
||||
"chainage": chainage,
|
||||
"interval": 20.0,
|
||||
}
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (PROJECT_ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ── 265-1 차례 · 접기 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_상세_창_차례는_그림_표_CAD_칸():
|
||||
detail = _read("B06_Section/B06_Section_UI_Cross_Detail.ts")
|
||||
assert "body.append(cad, tableBox, noteBox);" in detail
|
||||
# CAD 칸 = 공용 접기 꼴(머리 한 줄 · 캐럿) · 펼 때 한 번 만듦
|
||||
assert 'noteBox.className = "b06-detail__notebox ui-collapsible";' in detail
|
||||
assert 'noteHead.className = "b06-detail__notehead ui-collapsible__title";' in detail
|
||||
assert 'if (!noteBox.classList.toggle("is-collapsed")) openNote();' in detail
|
||||
|
||||
|
||||
def test_열_때_기본_일반_측점_접힘_구조물_측점_폄():
|
||||
detail = _read("B06_Section/B06_Section_UI_Cross_Detail.ts")
|
||||
start = detail.index('noteBox.classList.add("is-collapsed");')
|
||||
rule = detail[start : detail.index("const cad = ", start)]
|
||||
# 접힘으로 시작 → 그 측점에 놓인 구조물(`thingsAt`)이 있으면 폄 · 사용자가 먼저 누르면 그대로
|
||||
assert "thingsAt(list, section.chainage_m).length" in rule
|
||||
assert 'noteBox.classList.remove("is-collapsed");' in rule and "touched" in rule
|
||||
# 판정 = 놓인 구조물 수(구조물 측점 1 이상 · 일반 측점 0) — 아래 거울 시험 `at`
|
||||
[structure, plain] = _helper(
|
||||
"mirror",
|
||||
[_case(_THINGS, 40.0, None, {}), _case(_THINGS, 20.0, None, {})],
|
||||
)
|
||||
assert structure["at"] == 5 and plain["at"] == 0
|
||||
|
||||
|
||||
# ── 265-2 구조물 조합 줄 ───────────────────────────────────────────────
|
||||
|
||||
_A = {"layer": "system", "owner": None, "key": "SS000055", "name": "기슭막이 메쌓기 1.2m 기초무"}
|
||||
_B = {"layer": "system", "owner": None, "key": "SS000070", "name": "돌붙임 찰붙임"}
|
||||
_C = {"layer": "company", "owner": "3", "key": "SC3-000001", "name": "옹벽 콘크리트 H=2.0"}
|
||||
_BODY = {"layer": "system", "owner": None, "key": "SS000001", "name": "흄관 1200"}
|
||||
_THINGS = [
|
||||
# 배관 = 본체(옛 summary_item · 「관」 grv6lny) 줄 없음 · 보호공 · 도수로 조합만
|
||||
{
|
||||
"name": "배관",
|
||||
"pipe": True,
|
||||
"at": 40.2,
|
||||
"summary_item": _BODY,
|
||||
"group_items": {"grv6lny": _BODY, "gnbf9m9": _A, "gchuteadd1": _B},
|
||||
},
|
||||
# 옹벽 = 본체 + 그룹(같은 조합 = 한 줄)
|
||||
{"name": "옹벽", "pipe": False, "at": 40.0, "summary_item": _C, "group_items": {"gx": _C}},
|
||||
# 조합 미지정 구조물
|
||||
{"name": "돌쌓기(찰)", "pipe": False, "at": 39.6},
|
||||
# 미지정 배관(원형 마크가 대신 · 줄 없음) · 모양 틀린 참조 = 미지정
|
||||
{"name": "배관", "pipe": True, "at": 40.0, "group_items": {"bad id": _A}},
|
||||
{"name": "가드레일", "pipe": False, "at": 40.0, "summary_item": {"key": ""}},
|
||||
# 이웃 측점 구조물 = 뺌
|
||||
{"name": "옹벽", "pipe": False, "at": 40.6, "summary_item": _C},
|
||||
]
|
||||
_LINES = [
|
||||
"기슭막이 메쌓기 1.2m 기초무",
|
||||
"돌붙임 찰붙임",
|
||||
"옹벽 콘크리트 H=2.0",
|
||||
"돌쌓기(찰) 조합 미지정",
|
||||
"가드레일 조합 미지정",
|
||||
]
|
||||
|
||||
|
||||
def test_구조물_여럿_글은_조합_이름_미지정_배관_본체_뺌_TS_파이썬_같음():
|
||||
culvert = {**_culvert_set(_PIPE), "chainage_m": 40.2}
|
||||
cases = [
|
||||
_case(_THINGS, 40.0, culvert, {"pipe_length_m": 8.5}),
|
||||
_case(_THINGS, 40.0, None, {}),
|
||||
_case(_THINGS[5:], 40.0, None, {}),
|
||||
]
|
||||
mirrored = _helper("mirror", cases)
|
||||
for case, ts in zip(cases, mirrored, strict=True):
|
||||
section = case["section"]
|
||||
lines = structure_note_lines(case["things"], 40.0)
|
||||
py = note_entities(40.0, section["culvert"], section["design"], "NO.2", lines)
|
||||
assert ts["lines"] == lines
|
||||
assert _strip(ts["entities"]) == _strip(py)
|
||||
full, bare, none = mirrored
|
||||
assert full["lines"] == _LINES
|
||||
# 배관 = 원형 마크(원 안 배수관 · Φ · L 정수) · 오른쪽 측점 + 조합 줄
|
||||
assert [e["type"] for e in full["entities"][:2]] == ["Circle", "Line"]
|
||||
assert _labels(full["entities"]) == ["배수관", "Φ800m/m", "L=9m", "NO.2", *_LINES]
|
||||
assert "흄관 1200" not in _labels(full["entities"])
|
||||
# 원형 마크 없음(관 없는 구조물 측점) = 측점 + 줄 · 구조물 없는 측점 = 빈 도면
|
||||
assert _labels(bare["entities"]) == ["NO.2", *_LINES]
|
||||
assert none["entities"] == [] and none["at"] == 0
|
||||
|
||||
|
||||
def test_글_줄_재료는_관_지점_구조물_배치_종류_이름():
|
||||
payload = {
|
||||
"pipes": [
|
||||
{"chainage_m": 40.2, "facility": "pipe", "group_items": {"gnbf9m9": _A}},
|
||||
{"chainage_m": 60.0, "facility": "box_culvert"},
|
||||
],
|
||||
"structures": [
|
||||
{"type_id": "retaining_wall", "chainage_m": 40.0, "summary_item": _C},
|
||||
{"type_id": "guardrail", "start_m": 30.0, "end_m": 80.0},
|
||||
{"type_id": "mystery", "chainage_m": None, "start_m": None},
|
||||
],
|
||||
"types": [
|
||||
{"type_id": "pipe", "name": "배관"},
|
||||
{"type_id": "box_culvert", "name": "BOX암거"},
|
||||
{"type_id": "retaining_wall", "name": "옹벽"},
|
||||
{"type_id": "guardrail", "name": "가드레일"},
|
||||
],
|
||||
}
|
||||
things = _helper("load", payload)
|
||||
assert [(t["name"], t["pipe"], t["at"]) for t in things] == [
|
||||
("배관", True, 40.2),
|
||||
("BOX암거", False, 60.0),
|
||||
("옹벽", False, 40.0),
|
||||
("가드레일", False, 30.0), # 구간형 = 시작 측점(기준 측점 없을 때)
|
||||
]
|
||||
assert things[0]["group_items"] == {"gnbf9m9": _A} and things[2]["summary_item"] == _C
|
||||
|
||||
|
||||
def test_파이썬_짝_재료는_정본_관_지점_구조물_배치(tmp_path):
|
||||
path = pipe_points_file(tmp_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
point = {"chainage_m": 40.2, "lonlat": [0, 0], "facility": "pipe", "options": _PIPE}
|
||||
point["group_items"] = {"gnbf9m9": _A}
|
||||
path.write_text(json.dumps({"points": [point]}, ensure_ascii=False), encoding="utf-8")
|
||||
spath = Path(structures_file_path(str(tmp_path)))
|
||||
spath.parent.mkdir(parents=True, exist_ok=True)
|
||||
wall = {
|
||||
"structure_id": "w1",
|
||||
"type_id": "retaining_wall",
|
||||
"placement": "point",
|
||||
"chainage_m": 40.0,
|
||||
"options": {},
|
||||
"memo": "",
|
||||
"placement_source": "manual",
|
||||
"status": "draft",
|
||||
"revision": 1,
|
||||
"geometry": None,
|
||||
"summary_item": _C,
|
||||
}
|
||||
doc = {"revision": 1, "structures": [wall]}
|
||||
spath.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
|
||||
sources = load_note_sources(tmp_path)
|
||||
assert [(t["name"], t["at"]) for t in sources[1]] == [("배관", 40.2), ("옹벽", 40.0)]
|
||||
seeded = with_structure_note({"pipe_length_m": 8.4}, 40.0, sources, "NO.2")
|
||||
labels = _labels(seeded["structure_note_cad"]["entities"])
|
||||
assert labels == ["배수관", "Φ800m/m", "L=8m", "NO.2", _A["name"], _C["name"]]
|
||||
|
||||
|
||||
# ── 265-3 관 길이 정수 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("length", "label"),
|
||||
[(8.5, "L=9m"), (8.4, "L=8m"), (8.45, "L=8m"), (7.0, "L=7m"), (None, "L=")],
|
||||
)
|
||||
def test_원형_마크_L_은_사사오입_정수_TS_파이썬_같음(length, label):
|
||||
design = {} if length is None else {"pipe_length_m": length}
|
||||
culvert = {**_culvert_set(_PIPE), "chainage_m": 40.0}
|
||||
[ts] = _helper("mirror", [_case([], 40.0, culvert, design)])
|
||||
py = note_entities(40.0, culvert, design, "NO.2", [])
|
||||
assert _labels(ts["entities"])[2] == label
|
||||
assert _labels(py)[2] == label
|
||||
@@ -0,0 +1,85 @@
|
||||
"""PLAN 266-1 — B06 [상세] 처음 가로 범위 = 좌측 「횡단 보기 설정」 반폭(개별 표시 반폭이면 그것).
|
||||
|
||||
짝: `B06_Section/B06_Section_UI_Section_View.ts`(`detailChart` 처음 배율) ·
|
||||
`B06_Section_UI_Cross_Detail.ts`(`attachDetailZoom` homeScale) · `A00_Common/b_svg_zoom_pan.ts`.
|
||||
실제 TS 를 `helper_266_detail_home.cjs` 가 Node 로 돌린다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
ROOT = os.path.join(HERE, "..", "..")
|
||||
B06 = os.path.join(ROOT, "B06_Section")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
|
||||
def _read(name: str) -> str:
|
||||
with open(os.path.join(B06, name), encoding="utf-8") as f:
|
||||
return f.read().replace(chr(13), "")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_266_detail_home.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_처음_가로_반폭_15(out):
|
||||
# 칸 맞춤이 가로를 넓혀도 처음 배율로 되좁힘 = −15 ~ +15
|
||||
case = out["global15"]
|
||||
assert case["widened"] is True and case["homeScale"] > 1
|
||||
assert case["first"] == [-15, 15]
|
||||
|
||||
|
||||
def test_개별_반폭_측점(out):
|
||||
assert out["station10"]["first"] == [-10, 10]
|
||||
|
||||
|
||||
def test_안_넓힌_칸_그대로(out):
|
||||
case = out["tall15"]
|
||||
assert case["widened"] is False and case["homeScale"] == 1
|
||||
assert case["first"] == [-15, 15]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["global15", "station10", "tall15"])
|
||||
def test_처음으로_같은_범위_줌_이동_그대로(out, name):
|
||||
case = out[name]
|
||||
assert case["reset"] == case["first"] # [⤢]
|
||||
assert case["dblclick"] == case["first"] # 더블클릭
|
||||
assert case["wheelScale"] == pytest.approx(1.15) # 휠 줌
|
||||
assert case["panned"] is True # 가운데 단추 끌기
|
||||
assert case["minScale"] == 1 # 줄이면 칸 맞춤 전체까지
|
||||
|
||||
|
||||
def test_배선():
|
||||
view = _read("B06_Section_UI_Section_View.ts")
|
||||
assert (
|
||||
"const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1;"
|
||||
in view
|
||||
)
|
||||
assert "return { svg, axes, homeScale };" in view
|
||||
# 반폭 = 개별 표시 반폭 › 좌측 「횡단 보기 설정」(카드와 같은 길)
|
||||
assert (
|
||||
"effectiveCardHalfWidth(section, stationWidth?.widthFor(section) ?? currentCrossHalfWidth)"
|
||||
in view
|
||||
)
|
||||
src = " ".join(_read("B06_Section_UI_Cross_Detail.ts").split())
|
||||
assert "drawn.homeScale" in src and "homeScale, onChange" in src
|
||||
assert "homeScale: drawn.homeScale" in src
|
||||
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 267-1 — B06 횡단 카드 「관매설 …」 글 크기 = 「노폭 …」 글 크기 · 줄 겹침 없음.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_267_1_pipe_info_font.py -q -p no:cacheprovider --basetemp=tmp/pt
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CSS = (ROOT / "B06_Section" / "B06_Section_UI_Style_Cross_Areas.css").read_text(encoding="utf-8")
|
||||
TYPES = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Types.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _font_px(selector: str) -> float:
|
||||
"""셀렉터 블록의 마지막 font-size(px) — 뒤에 오는 규칙이 이긴다."""
|
||||
blocks = re.findall(re.escape(selector) + r"\s*\{([^}]*)\}", CSS)
|
||||
sizes = [float(m) for b in blocks for m in re.findall(r"font-size:\s*([\d.]+)px", b)]
|
||||
assert sizes, selector
|
||||
return sizes[-1]
|
||||
|
||||
|
||||
def test_관_정보_글_크기가_노폭_라벨과_같다() -> None:
|
||||
road = _font_px(".b06-chart__carriageway-label")
|
||||
info = _font_px(".b06-chart__culvert-label.b06-chart__pipe-info")
|
||||
assert road == info == 11.0
|
||||
# 다른 culvert-label(집수정 · 박스 이름)은 그대로 9px
|
||||
assert _font_px(".b06-chart__culvert-label") == 9.0
|
||||
|
||||
|
||||
def test_관_정보_글_줄이_노폭_라벨과_겹치지_않는다() -> None:
|
||||
step = float(re.search(r"- (\d+) \* \(stacked \+ 1\)", TYPES).group(1))
|
||||
font = _font_px(".b06-chart__culvert-label.b06-chart__pipe-info")
|
||||
road_font = _font_px(".b06-chart__carriageway-label")
|
||||
asc, desc = 0.8, 0.2 # 글자 상자 근사 — 기준선 위 80% · 아래 20%
|
||||
road_top = 0 - asc * road_font # 노폭 라벨 기준선 y=0
|
||||
for n in (1, 2, 3): # 관 정보 글이 쌓인 줄 수
|
||||
info_y = -step * n
|
||||
info_bottom = info_y + desc * font
|
||||
assert info_bottom < road_top, (n, info_bottom, road_top)
|
||||
if n > 1: # 윗줄 바닥 < 아랫줄 윗면
|
||||
upper_bottom = -step * n + desc * font
|
||||
lower_top = -step * (n - 1) - asc * font
|
||||
assert upper_bottom < lower_top, n
|
||||
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""268 B07 횡단도 — 노란 측구 덧그림 · 배수관 · 박스암거 색.
|
||||
|
||||
못박는 것
|
||||
- 268-1 측구 구간 구조물 선(b08-structure)도 design_ids 에 실려 화면 덧그림이 갈아 끼울 때 걷힘
|
||||
(장 · 단일 도면 모두) · 설계선 · 측구 본 그림(구조물 선 자체)은 그대로 남음.
|
||||
- 268-2 박스암거 · 배수관 덧그림은 구조물(노랑) · 설계선(보라) · 지반선과 다른 색 한 벌.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
DESIGN_COLOR,
|
||||
DESIGN_LAYER_ID,
|
||||
GROUND_COLOR,
|
||||
ROCK_COLOR,
|
||||
STRUCTURE_COLOR,
|
||||
STRUCTURE_LAYER_ID,
|
||||
build_cross_drawing,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import build_cross_sheet, plan_cross_sheets
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import use_project_templates
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
_DESIGN = {
|
||||
"ditch_enabled": True,
|
||||
"ditch_side": "left",
|
||||
"ditch": {"type": "standard", "top_width_m": 1.0},
|
||||
"road_edges": {"left": {"offset_m": -1.0}},
|
||||
}
|
||||
_LINE = [
|
||||
{"offset_m": x, "elevation_m": 100.0 + (0.0 if x >= -1.0 else -0.3)}
|
||||
for x in (-3.0, -2.0, -1.0, 0.0, 3.0)
|
||||
]
|
||||
|
||||
|
||||
def _source(chainage: float) -> dict:
|
||||
return {
|
||||
"chainage_m": chainage,
|
||||
"samples": [
|
||||
{"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.1 * x}
|
||||
for x in range(-5, 6)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _ditch_ids(entities: list[dict]) -> list[str]:
|
||||
return [e["id"] for e in entities if e["lineColor"] == STRUCTURE_COLOR]
|
||||
|
||||
|
||||
def test_측구_구조물_선_design_ids_장() -> None:
|
||||
use_project_templates(None)
|
||||
sheet = plan_cross_sheets([0.0, 20.0], {"default": [2, 1]})[0]
|
||||
sections = [
|
||||
{
|
||||
"chainage": c,
|
||||
"source": _source(c),
|
||||
"design": _DESIGN,
|
||||
"design_line": _LINE,
|
||||
"quantity_table": {"ground": 1.0, "planned": 0.5},
|
||||
"title": f"No.{int(c // 20)}",
|
||||
}
|
||||
for c in (0.0, 20.0)
|
||||
]
|
||||
drawing = build_cross_sheet(sheet, sections)
|
||||
by_id = {e["id"]: e for e in drawing["entities"]}
|
||||
ditch = _ditch_ids(drawing["entities"])
|
||||
assert len(ditch) == 2 # 측점마다 측구 구간 선 하나
|
||||
for placement in drawing["cross_placements"]:
|
||||
ids = placement["design_ids"]
|
||||
assert len(set(ids) & set(ditch)) == 1 # 자기 측점 측구 선만
|
||||
assert all(by_id[i]["lineColor"] in (DESIGN_COLOR, STRUCTURE_COLOR) for i in ids)
|
||||
# 걷기는 화면이 하고, 서버 도면은 측구 구조물 선을 그대로 가짐
|
||||
assert all(by_id[i]["lineColor"] == STRUCTURE_COLOR for i in ditch)
|
||||
|
||||
|
||||
def test_측구_구조물_선_design_ids_단일_도면() -> None:
|
||||
use_project_templates(None)
|
||||
drawing = build_cross_drawing(
|
||||
_source(0.0), "d", _LINE, _DESIGN, {"ground": 1.0, "planned": 0.5}, "No.0"
|
||||
)
|
||||
ditch = [e for e in drawing["entities"] if e["layerId"] == STRUCTURE_LAYER_ID]
|
||||
assert len(ditch) == 1
|
||||
design_ids = drawing["cross_placements"][0]["design_ids"]
|
||||
assert ditch[0]["id"] in design_ids
|
||||
assert all(
|
||||
e["layerId"] in (DESIGN_LAYER_ID, STRUCTURE_LAYER_ID)
|
||||
for e in drawing["entities"]
|
||||
if e["id"] in design_ids
|
||||
)
|
||||
|
||||
|
||||
def test_측구_없으면_design_ids_설계선만() -> None:
|
||||
use_project_templates(None)
|
||||
drawing = build_cross_drawing(
|
||||
_source(0.0), "d", _LINE, None, {"ground": 1.0, "planned": 0.5}, "No.0"
|
||||
)
|
||||
ids = drawing["cross_placements"][0]["design_ids"]
|
||||
layers = {e["layerId"] for e in drawing["entities"] if e["id"] in ids}
|
||||
assert layers == {DESIGN_LAYER_ID}
|
||||
|
||||
|
||||
def test_배관_박스암거_색_따로() -> None:
|
||||
src = (ROOT / "B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts").read_text(encoding="utf-8")
|
||||
pipe = re.search(r'const PIPE_COLOR = "(#[0-9a-fA-F]{6})"', src)
|
||||
assert pipe, "PIPE_COLOR 없음"
|
||||
color = pipe.group(1).lower()
|
||||
assert color not in {STRUCTURE_COLOR, DESIGN_COLOR, GROUND_COLOR, ROCK_COLOR}
|
||||
assert color not in {
|
||||
"#8ea0b5",
|
||||
"#e8edf4",
|
||||
"#ffe066",
|
||||
"#ff4d4d",
|
||||
} # 표 선 · 글 · 값 · 구조물 평면 색
|
||||
# 박스암거 · 배수관만 pipeSvg 로 — 구조물(돌붙임 · 여울)은 그대로 노랑
|
||||
assert "appendBoxOverlay(pipeSvg," in src and "appendCulvertOverlay(\n pipeSvg," in src
|
||||
assert "appendRevetmentOverlay(svg," in src and "appendFordOverlay(svg," in src
|
||||
assert "harvest(pipeSvg, PIPE_STYLE, frame)" in src
|
||||
@@ -0,0 +1,124 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""269 좌측 패널 컨테이너 공용 양식 — 기준 = B05/B06 「구조물 배치」.
|
||||
|
||||
못박는 것
|
||||
- 공용 양식 = ui_template 한 곳(`.ui-panel-form` · `.ui-panel-btn` · 카드 `createPanelCard`) ·
|
||||
칸 높이 · 라벨 틈 토큰 = theme 한 곳 · 「구조물 배치」도 같은 토큰.
|
||||
- B07 컨테이너 = 공용 카드 · 도면 단추 = `.ui-panel-btn` · 페이지 덧칠(크기 · 글자 · 바탕) 없음.
|
||||
- B06 컨테이너(횡단 보기 설정 · 표준 횡단면 설정) = 칸 틀 `createSectionFrame` 이 공용 양식을 붙임 ·
|
||||
단추 · 입력칸 = 공용 함수(createButton · createInputField · createSelectField) · B06 덧칠 없음.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
THEME = _read("ui_template/ui_template_theme.css")
|
||||
OVERLAY = _read("ui_template/ui_template_overlay.css")
|
||||
COLLAPSIBLE = _read("ui_template/ui_template_collapsible.ts")
|
||||
B05_STRUCT_CSS = _read("B05_Profile/B05_Profile_UI_Style_Structures.css")
|
||||
B07_CSS = _read("B07_DesignDetail/B07_DesignDetail_UI_Style.css")
|
||||
B07_PANELS = _read("B07_DesignDetail/B07_DesignDetail_UI_Panels.ts")
|
||||
B07_PLAN = _read("B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts")
|
||||
DESIGN_PANEL = _read("A00_Common/design/design_panel.ts")
|
||||
B06_CSS = _read("B06_Section/B06_Section_UI_Style.css")
|
||||
B06_PANEL = _read("B06_Section/B06_Section_UI_Page_Panel.ts")
|
||||
B06_STD = _read("B06_Section/B06_Section_UI_Standard_Panel.ts")
|
||||
|
||||
|
||||
def _block(css: str, selector: str) -> str:
|
||||
m = re.search(r"(?:^|\n)" + re.escape(selector) + r"\s*\{([^}]*)\}", css)
|
||||
assert m, selector
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_공용_양식_토큰은_theme_한_곳_구조물_배치도_같은_토큰():
|
||||
assert "--panel-control-h: 26px;" in THEME
|
||||
assert "--panel-label-gap: 2px;" in THEME
|
||||
assert "--b05-control-h: var(--panel-control-h);" in B05_STRUCT_CSS
|
||||
assert "--b05-label-gap: var(--panel-label-gap);" in B05_STRUCT_CSS
|
||||
|
||||
|
||||
def test_공용_양식_칸_라벨_단추_규칙():
|
||||
field = _block(
|
||||
OVERLAY,
|
||||
'.ui-panel-form input:not([type="checkbox"], [type="radio"], [type="range"]),\n'
|
||||
".ui-panel-form select",
|
||||
)
|
||||
assert "height: var(--panel-control-h);" in field
|
||||
assert "padding: 0 6px;" in field
|
||||
assert "font-size: var(--text-caption);" in field
|
||||
label = _block(OVERLAY, ".ui-panel-form .ui-field,\n.ui-panel-form .b05-route__field")
|
||||
assert "gap: var(--panel-label-gap);" in label
|
||||
assert "display: none;" in _block(OVERLAY, ".ui-panel-form .ui-field__error:not(.is-visible)")
|
||||
button = _block(OVERLAY, ".ui-panel-form .ui-btn:not(.ui-help-btn)")
|
||||
# 「구조물 배치」 단추(b05-route__irregular-btn)와 같은 크기
|
||||
assert "padding: var(--spacing-8);" in button
|
||||
assert "font-size: var(--text-caption);" in button
|
||||
assert "border-radius: var(--radius-inputs);" in button
|
||||
raw = _block(OVERLAY, ":where(.ui-panel-btn)")
|
||||
for decl in (
|
||||
"padding: var(--spacing-8);",
|
||||
"border-radius: var(--radius-inputs);",
|
||||
"background: var(--color-surface);",
|
||||
"font-size: var(--text-caption);",
|
||||
):
|
||||
assert decl in raw, decl
|
||||
# 아래 단추 묶음은 컨테이너 밖 — 공용 양식이 `.ui-sidebar-actions` 를 겨누지 않음
|
||||
form_rules = OVERLAY[OVERLAY.index("좌측 패널 컨테이너 공용 양식(269)") :]
|
||||
form_rules = form_rules[: form_rules.index("/* 카드 안에 다시 카드")]
|
||||
assert "ui-sidebar-actions {" not in form_rules and "ui-sidebar-actions ." not in form_rules
|
||||
|
||||
|
||||
def test_공용_카드_함수():
|
||||
assert "export function createPanelCard(" in COLLAPSIBLE
|
||||
assert "ui-collapsible ui-sidebar-section ui-sidebar-card ui-panel-form" in COLLAPSIBLE
|
||||
|
||||
|
||||
def test_B07_컨테이너_공용_카드_도면_단추_공용_꼴():
|
||||
assert "createPanelCard(`${group.label} ${items.length}`)" in B07_PANELS
|
||||
assert "createPanelCard(`평면도 ${items.length}`)" in B07_PLAN
|
||||
for src in (B07_PANELS, B07_PLAN):
|
||||
assert "ui-collapsible ui-sidebar-section" not in src # 손으로 짠 카드 없음
|
||||
assert B07_PANELS.count('"b07-drawing-button ui-panel-btn"') == 2
|
||||
assert '"ui-panel-btn b07-plan-manage-open"' in B07_PLAN
|
||||
|
||||
|
||||
def test_B07_페이지_덧칠_없음():
|
||||
assert not re.search(r"(?:^|\n)\.b07-drawing-group\s*\{", B07_CSS)
|
||||
assert ".b07-drawing-group h3 {" not in B07_CSS
|
||||
assert ".b07-plan-manage-open {" not in B07_CSS
|
||||
button = _block(B07_CSS, ".b07-drawing-button")
|
||||
for prop in ("min-height", "padding", "border-radius", "background", "font-size"):
|
||||
assert not re.search(r"(?:^|\s)" + prop + r"\s*:", button), prop
|
||||
assert "font-size" not in _block(B07_CSS, ".b07-drawing-button__name")
|
||||
assert ".b07-cross-layout .ui-btn" not in B07_CSS
|
||||
assert ".b07-cross-layout .ui-field__error" not in B07_CSS
|
||||
|
||||
|
||||
def test_B06_컨테이너_공용_양식():
|
||||
frame = DESIGN_PANEL[DESIGN_PANEL.index("export function createSectionFrame(") :]
|
||||
frame = frame[: frame.index("\n}\n")]
|
||||
cross = frame[frame.index("b06-profile__group ui-collapsible") :]
|
||||
assert "ui-panel-form" in cross.splitlines()[0]
|
||||
for src in (B06_PANEL, B06_STD):
|
||||
assert "createSectionFrame(" in src or "createButton(" in src
|
||||
assert "<button" not in src and 'createElement("button")' not in src
|
||||
assert 'createElement("input")' not in src and 'createElement("select")' not in src
|
||||
assert "createInputField(" in B06_PANEL and "createButton(" in B06_PANEL
|
||||
# 덧칠 없음 — 칸 글자 · 오류 칸 접기는 공용 양식 몫
|
||||
group = _block(B06_CSS, ".b06-profile__group")
|
||||
assert "font-size" not in group and not re.search(r"(?:^|\s)color\s*:", group)
|
||||
assert ".b06-profile__field-row .ui-field__error" not in B06_CSS
|
||||
for sel in (".ui-btn {", ".ui-input {", ".ui-select {"):
|
||||
for line in B06_CSS.splitlines():
|
||||
if line.endswith(sel) and line.startswith(".b06-profile"):
|
||||
rule = _block(B06_CSS, line[:-2])
|
||||
for prop in ("height", "padding", "font-size"):
|
||||
assert not re.search(r"(?:^|\s)" + prop + r"\s*:", rule), (line, prop)
|
||||
@@ -11,6 +11,17 @@
|
||||
* 스타일은 전역 `ui_template_theme.css`에 있어 어떤 페이지에서도 클래스만 붙이면 동작한다.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 좌측 패널 컨테이너 공용 카드(269) — 접는 제목 + 공용 양식(`ui-panel-form`) · 내용은 카드에 바로 붙임. */
|
||||
export function createPanelCard(title: string, collapsed = false): HTMLElement {
|
||||
const section = document.createElement("section");
|
||||
section.className = `ui-collapsible ui-sidebar-section ui-sidebar-card ui-panel-form${collapsed ? " is-collapsed" : ""}`;
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "ui-collapsible__title";
|
||||
heading.textContent = title;
|
||||
section.append(heading);
|
||||
return section;
|
||||
}
|
||||
|
||||
/** root 아래 `.ui-collapsible__title` 클릭을 위임 처리해 해당 컨테이너를 접거나 편다. */
|
||||
export function attachCollapsible(root: HTMLElement): void {
|
||||
root.addEventListener("click", (event) => {
|
||||
|
||||
@@ -415,6 +415,77 @@
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 좌측 패널 컨테이너 공용 양식(269) — 기준 = B05/B06 「구조물 배치」(칸 높이 · 라벨 · 글자 · 단추).
|
||||
B05~B09 좌측 컨테이너가 `.ui-panel-form`(카드는 `createPanelCard`)을 붙임 · 아래 단추 묶음
|
||||
(`.ui-sidebar-actions`)은 컨테이너 밖이라 안 먹음 · 날 단추는 `.ui-panel-btn`. */
|
||||
:where(.ui-sidebar-card.ui-panel-form) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-panel-form .ui-field,
|
||||
.ui-panel-form .b05-route__field {
|
||||
gap: var(--panel-label-gap);
|
||||
}
|
||||
|
||||
.ui-panel-form .ui-field__label,
|
||||
.ui-panel-form .b05-route__field > span {
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ui-panel-form .ui-field__error:not(.is-visible) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-panel-form input:not([type="checkbox"], [type="radio"], [type="range"]),
|
||||
.ui-panel-form select {
|
||||
box-sizing: border-box;
|
||||
height: var(--panel-control-h);
|
||||
padding: 0 6px;
|
||||
font-size: var(--text-caption);
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.ui-panel-form select.ui-select {
|
||||
padding-right: 24px;
|
||||
background-position: right 6px center;
|
||||
background-size: 12px;
|
||||
}
|
||||
|
||||
/* 숫자 칸 증감 화살표 없음 — 「구조물 배치」와 같게 */
|
||||
.ui-panel-form input[type="number"] {
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.ui-panel-form input[type="number"]::-webkit-outer-spin-button,
|
||||
.ui-panel-form input[type="number"]::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/* 단추 크기 = 「구조물 배치」 단추(`b05-route__irregular-btn`) · 색은 단추 갈래(filled · ghost) 그대로 */
|
||||
.ui-panel-form .ui-btn:not(.ui-help-btn) {
|
||||
padding: var(--spacing-8);
|
||||
border-radius: var(--radius-inputs);
|
||||
font-size: var(--text-caption);
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
/* 날 <button>(도면 단추 등) — 「구조물 배치」 단추 꼴 · 낮은 우선순위라 페이지 상태 표시(띠 · 고름)가 덮음 */
|
||||
:where(.ui-panel-btn) {
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 카드 안에 다시 카드(M02 도면 안 구조물 도면 묶음) — 앞 목록과 간격 */
|
||||
.ui-sidebar-card .ui-sidebar-card {
|
||||
margin-top: var(--spacing-8);
|
||||
|
||||
@@ -161,6 +161,9 @@
|
||||
/* 입력칸 · 고르기칸 높이 — 크기마다 한 값(같은 줄이면 위아래가 맞음) */
|
||||
--control-height: 42px;
|
||||
--control-height-sm: 30px;
|
||||
/* 좌측 패널 컨테이너 칸 높이 · 라벨 틈 — 기준 = B05/B06 「구조물 배치」 · 공용 양식 `.ui-panel-form` 이 씀 */
|
||||
--panel-control-h: 26px;
|
||||
--panel-label-gap: 2px;
|
||||
|
||||
/* 줄 위 작은 단추 한 규격 — 종단 머리 줄 [초기화]~▼ · [배수유역도] · [새 창] 이 함께 씀(높이 = 글자 12 + 여백 2 + 테두리 2) */
|
||||
--btn-bar-padding: 1px var(--spacing-8);
|
||||
|
||||
Reference in New Issue
Block a user