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

This commit is contained in:
2026-10-11 14:11:07 +09:00
22 changed files with 1349 additions and 79 deletions
+55 -14
View File
@@ -24,7 +24,12 @@ import type {
SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import { CROSS_PAD, inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common";
import { DETAIL_TARGETS, highlightDetail, presentKeys } from "./B06_Section_UI_Cross_Detail_Pick";
import {
DETAIL_TARGETS,
highlightDetail,
outlinePicked,
presentKeys,
} from "./B06_Section_UI_Cross_Detail_Pick";
import {
attachSvgZoomPan,
type ZoomPanHandle,
@@ -111,18 +116,25 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
if (repick.has(key)) td.dataset.repick = "";
return mark(td, { cell: key, row, col }, isAdded(key));
};
// 이름 칸 — 더한 행이면 글 손질(자리 = 좌 0 · 중 1 · 우 2)
// 이름 칸 — 더한 행이면 글 손질(자리 = 좌 0 · 중 1 · 우 2) · 합친 묶음 = 덮는 줄 키 모두(259-3)
const nameCell = (
label: string | null,
span: { col?: number; row?: number } | undefined,
key: string | null,
row: string,
slot: number,
keys?: Array<string | null>,
): HTMLElement => {
const added = row.startsWith("r");
const data = { row, label: added ? `${row}:${slot}` : undefined };
const data = {
row,
label: added ? `${row}:${slot}` : undefined,
keys: keys && keys.length > 1 ? keys.filter(Boolean).join(" ") : undefined,
};
return mark(cell("th", label ?? "", span, key), data, added);
};
const groupKeys = (slots: typeof layout.left, index: number) =>
slots.slice(index, index + groupSpan(slots, index)).map((slot) => slot[2]);
const table = document.createElement("table");
table.className = "b06-detail__qtable";
const titleRow = table.insertRow();
@@ -137,13 +149,31 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
const row = table.insertRow();
// 이름 칸(「깍기」 등)도 그 줄 첫 값 칸 키로 눌림(164-2) · 묶음 = 하위 줄만큼(179-4)
if (groupL !== null && subL !== null)
row.append(nameCell(groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0));
row.append(
nameCell(
groupL,
{ row: groupSpan(layout.left, index) },
keyL,
id,
0,
groupKeys(layout.left, index),
),
);
else if (groupL !== null) row.append(nameCell(groupL, { col: 2 }, keyL, id, 0));
if (subL !== null) row.append(nameCell(subL, undefined, keyL, id, 0));
row.append(valueCell(keyL, id));
const [groupM, subM, keyM] = layout.middle[index];
if (groupM !== null && subM !== null)
row.append(nameCell(groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1));
row.append(
nameCell(
groupM,
{ row: groupSpan(layout.middle, index) },
keyM,
id,
1,
groupKeys(layout.middle, index),
),
);
else if (groupM !== null) row.append(nameCell(groupM, { col: 2 }, keyM, id, 1));
if (subM !== null) row.append(nameCell(subM, undefined, keyM, id, 1));
row.append(valueCell(keyM, id));
@@ -277,24 +307,35 @@ export function openCrossDetail(section: CrossSection): void {
tableBox.className = "b06-detail__table";
body.append(cad, note, tableBox);
// 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2)
// 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2) ·
// 저장된 조각 · 띠 칸 = 그래프에서 고름 + 값 글(259-2) · 고른 칸 묶음 = 바깥 한 줄(259-3)
let picked: string | null = null;
const showPicked = (): void => {
const svg = cad.querySelector("svg");
const present = svg ? presentKeys(svg, state.section.design) : new Set<string>();
if (picked && !present.has(picked)) picked = null;
tableBox.querySelectorAll<HTMLElement>("[data-key]").forEach((el) => {
el.classList.toggle("is-picked", el.dataset.key === picked);
el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? ""));
const graph = picked ? tablePick.graph(picked) : null;
if (picked && !graph && !present.has(picked)) picked = null;
tableBox.querySelectorAll<HTMLElement>("[data-key], [data-cell]").forEach((el) => {
const keys = (el.dataset.keys ?? el.dataset.key ?? el.dataset.cell ?? "").split(" ");
el.classList.toggle("is-picked", picked !== null && keys.includes(picked));
if (el.dataset.key)
el.toggleAttribute(
"data-empty",
!present.has(el.dataset.key) && !tablePick.graph(el.dataset.key),
);
});
if (svg) highlightDetail(svg, state.section.design, picked);
outlinePicked(tableBox);
const band = graph && "band" in graph ? graph.band : graph ? null : picked;
if (svg) highlightDetail(svg, state.section.design, band);
};
tableBox.addEventListener("click", (event) => {
const el = (event.target as HTMLElement).closest<HTMLElement>("[data-key]");
const key = el?.dataset.key;
if (!key || el.hasAttribute("data-empty")) return;
const el = (event.target as HTMLElement).closest<HTMLElement>("[data-key], [data-cell]");
const key = el?.dataset.key ?? el?.dataset.cell;
if (!el || !key) return;
if (!tablePick.graph(key) && (!el.dataset.key || el.hasAttribute("data-empty"))) return;
picked = picked === key ? null : key;
showPicked();
tablePick.showCell(picked);
});
// 모달 배율 — 다시 그려도 이어 감 · 칸 크기가 바뀌면 원복(173-4)
@@ -5,7 +5,7 @@
* 짝(① 파이썬 · TS) = `B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py`
* (`piece_path` · `sub_path` · `path_length` · `ring_area` · `ground_runs` — [저장] 뒤 정본 셈) ·
* 거울 시험 `resources/tester/test_209_1_detail_select.py`.
* 나눔(`splitAtCrossings`)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산.
* 나눔(`splitAtCrossings`) · 닫힌 영역(`findFaces` · 259-2)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산.
* ========================================================================== */
import type { CrossDesign, CrossPickPiece, SectionSample } from "./B06_Section_Api_Types";
@@ -227,6 +227,89 @@ export function splitAtCrossings(lines: SplitPiece[]): SplitPiece[] {
return out;
}
/* ── 닫힌 영역(259-2 · 화면 몫) ─────────────────────────────────────────── */
/** 나눈 조각으로 둘러싸인 면 하나 — 둘레 조각 번호 · 고리(m) · 넓이(㎡). */
export interface Face {
edges: number[];
ring: Pt[];
area: number;
}
/** 꺾은선이 그 끝에서 떠나는 방향(라디안) — 아주 짧은 첫 토막은 건너뜀. */
function leaving(points: Pt[]): number {
const [x0, y0] = points[0];
const next = points.find((p) => dist(p, points[0]) > 1e-6) ?? points[points.length - 1];
return Math.atan2(next[1] - y0, next[0] - x0);
}
/**
* 조각(변)으로 된 평면 그래프의 면 — 끝이 `TOUCH_M` 안이면 같은 마디 · 열린 끝 조각(마디 차수 1)은
* 거듭 지움(열린 영역 제외) · 각 반변에서 도착 마디의 시계 방향 다음 변으로 돌아 면을 셈 ·
* 넓이가 양(반시계)인 것만 = 바깥 면 뺌.
* ponytail: 안에 떠 있는 섬(다리 없음)은 바깥 면의 구멍으로 안 뺌 — 필요하면 포함 판정 더함.
*/
export function findFaces(pieces: SplitPiece[]): Face[] {
const nodes: Pt[] = [];
const nodeOf = (p: Pt): number => {
const found = nodes.findIndex((q) => dist(p, q) <= TOUCH_M);
return found >= 0 ? found : nodes.push(p) - 1;
};
const ends = pieces.map((piece) => [
nodeOf(piece.points[0]),
nodeOf(piece.points[piece.points.length - 1]),
]);
const alive = ends.map(([a, b]) => a !== b);
for (let changed = true; changed;) {
const degree = new Array<number>(nodes.length).fill(0);
ends.forEach(([a, b], i) => alive[i] && (degree[a]++, degree[b]++));
changed = false;
ends.forEach(([a, b], i) => {
if (alive[i] && (degree[a] < 2 || degree[b] < 2)) [alive[i], changed] = [false, true];
});
}
// 반변 h = 2i(앞 → 뒤) · 2i+1(뒤 → 앞)
const path = (h: number): Pt[] => {
const points = pieces[h >> 1].points;
return h & 1 ? [...points].reverse() : points;
};
const head = (h: number): number => ends[h >> 1][h & 1 ? 0 : 1];
const out = nodes.map(() => [] as Array<[number, number]>);
ends.forEach(([a, b], i) => {
if (!alive[i]) return;
out[a].push([leaving(path(2 * i)), 2 * i]);
out[b].push([leaving(path(2 * i + 1)), 2 * i + 1]);
});
out.forEach((list) => list.sort((p, q) => p[0] - q[0]));
const next = (h: number): number => {
const list = out[head(h)];
const at = list.findIndex(([, e]) => e === (h ^ 1));
return list[(at - 1 + list.length) % list.length][1];
};
const faces: Face[] = [];
const seen = new Set<number>();
ends.forEach((_, i) => {
if (!alive[i]) return;
for (const start of [2 * i, 2 * i + 1]) {
if (seen.has(start)) continue;
const cycle: number[] = [];
for (let h = start; !seen.has(h) && cycle.length <= 2 * pieces.length; h = next(h)) {
seen.add(h);
cycle.push(h);
}
const ring = cycle.flatMap((h) => path(h).slice(1));
let twice = 0;
ring.forEach((p, k) => {
const q = ring[(k + 1) % ring.length];
twice += p[0] * q[1] - q[0] * p[1];
});
if (twice / 2 > 1e-9)
faces.push({ edges: [...new Set(cycle.map((h) => h >> 1))], ring, area: twice / 2 });
}
});
return faces;
}
/** 조각 → 저장 꼴(두 끝 · 0.1 mm 반올림). */
export function pieceSource(piece: SplitPiece): CrossPickPiece {
const round = (p: Pt): Pt => [Math.round(p[0] * 1e4) / 1e4, Math.round(p[1] * 1e4) / 1e4];
@@ -6,6 +6,7 @@
* (사면 · 노면 · 측구). 판정 선은 화면 좌표라 십자(x(0)) · `data-px-per-m` 로 m 로 되돌려
* `crossZoneAt` 으로 가름 — 측구 = 단면유형을 성토 양쪽으로 둬도 절토로 남는 자리.
* 편책 · 제근 · 표토제거는 늘 빈칸 = 누름 없음(짝 표에서 뺌) · 대상이 그 카드에 없는 칸 = `presentKeys` 밖.
* 고른 칸 묶음 = 바깥 한 줄(`outlinePicked` · 259-3).
* ponytail: 판정 선의 구조물 트림 여부는 복제에서 모름 → 자리로만 가름(트림 선은 놓인 구역을 따름).
* ========================================================================== */
@@ -75,3 +76,48 @@ export function presentKeys(svg: Element, design: CrossDesign | undefined): Set<
Object.keys(DETAIL_TARGETS).filter((key) => targetsOf(svg, design, key).length > 0),
);
}
/* ── 표 강조 바깥 한 줄(259-3) ──────────────────────────────────────────── */
/** 직사각형들(좌 · 위 · 우 · 아래)의 합친 모양 바깥 줄 — 격자로 나눠 덮인 칸과 안 덮인 칸 사이 변만(SVG path). */
export function unionOutline(rects: number[][]): string {
const xs = [...new Set(rects.flatMap((r) => [r[0], r[2]]))].sort((a, b) => a - b);
const ys = [...new Set(rects.flatMap((r) => [r[1], r[3]]))].sort((a, b) => a - b);
const covered = (i: number, j: number): boolean =>
i >= 0 &&
j >= 0 &&
i + 1 < xs.length &&
j + 1 < ys.length &&
rects.some((r) => r[0] <= xs[i] && xs[i + 1] <= r[2] && r[1] <= ys[j] && ys[j + 1] <= r[3]);
const parts: string[] = [];
for (let i = 0; i + 1 < xs.length; i++)
for (let j = 0; j + 1 < ys.length; j++) {
if (!covered(i, j)) continue;
const [x0, x1, y0, y1] = [xs[i], xs[i + 1], ys[j], ys[j + 1]];
if (!covered(i, j - 1)) parts.push(`M${x0} ${y0}H${x1}`);
if (!covered(i, j + 1)) parts.push(`M${x0} ${y1}H${x1}`);
if (!covered(i - 1, j)) parts.push(`M${x0} ${y0}V${y1}`);
if (!covered(i + 1, j)) parts.push(`M${x1} ${y0}V${y1}`);
}
return parts.join("");
}
/** 고른 칸(`.is-picked`) 묶음에 바깥 한 줄 — 칸마다 테두리를 그리면 붙은 칸에 겹선이 생김. */
export function outlinePicked(box: HTMLElement): void {
box.querySelector(".b06-detail__pick-outline")?.remove();
const cells = [...box.querySelectorAll(".is-picked")];
if (!cells.length) return;
const base = box.getBoundingClientRect();
const rects = cells.map((cell) => {
const r = cell.getBoundingClientRect();
return [r.left - base.left, r.top - base.top, r.right - base.left, r.bottom - base.top].map(
(v) => Math.round(v),
);
});
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "b06-detail__pick-outline");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", unionOutline(rects));
svg.append(path);
box.append(svg);
}
+109 -11
View File
@@ -3,7 +3,10 @@
* [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선 · 암 경계선(259-1)을 만나는 점에서 조각으로
* 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 ·
* Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴):
* 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」).
* 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이 · 둘레(안 닫히면 「닫히지 않음」).
* 닫힌 영역(259-2) = 조각으로 둘러싸인 면(`findFaces` · 열린 영역 제외) · 안 누름 = 그 영역(둘레 조각)
* 고름 · Shift/Ctrl = 영역 더하기 · 채움 강조 · 값 = 면적 / 둘레 · 선으로 한 영역을 닫아도 채움.
* 표 칸 누름 = 저장된 조각을 다시 고름(`choose`) · 띠 = 값 글만(`note`).
* 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_Cross_Detail_Table` 이 [칸에 넣기] · Enter 로 씀).
* ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때.
* ========================================================================== */
@@ -17,16 +20,21 @@ import {
pieceLabel,
} from "./B06_Section_UI_Cross_Design_Pick";
import {
findFaces,
LINE_TOLERANCE_M,
pathLength,
pieceSource,
ringArea,
splitAtCrossings,
type Face,
type Pt,
type SplitPiece,
} from "./B06_Section_UI_Cross_Detail_Geom";
const SVG_NS = "http://www.w3.org/2000/svg";
export const PIECE_HIT_CLASS = "b06-detail__piece";
/** 닫힌 영역 판정 면(259-2) — 조각 판정 선 밑 · 투명 채움. */
export const FACE_HIT_CLASS = "b06-detail__face";
/** 화면 px → m(카드 `x` · `toDisplayY` 의 역 · 축 옵션). */
export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt {
@@ -124,16 +132,45 @@ export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPie
to: { offset: to[0], elevation: to[1] },
structure: piece.line === "structure",
});
const horizontal = Math.abs(to[0] - from[0]);
const height = Math.abs(to[1] - from[1]);
const grade = horizontal > 0 ? `${((height / horizontal) * 100).toFixed(1)}%` : "수직";
return (
`${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m\n` +
`수평 ${Math.abs(to[0] - from[0]).toFixed(2)}m · 높이 ${Math.abs(to[1] - from[1]).toFixed(2)}m`
`${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m · 경사비율 ${grade}\n` +
`수평 ${horizontal.toFixed(2)}m · 높이 ${height.toFixed(2)}m`
);
}
const total = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0);
const area = ringArea(pieces.map((piece) => piece.points));
return (
`${pieces.length}개 · 길이 합 ${total.toFixed(2)}m\n` +
(area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡`)
(area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡ · 둘레 ${total.toFixed(2)}m`)
);
}
/** 고른 영역 값 두 줄(259-2) — 넓이 합 / 둘레(고른 둘레 조각 길이 합 · 붙은 영역은 맞닿은 변 뺌). */
export function regionLabel(faces: Face[], pieces: SplitPiece[]): string {
const area = faces.reduce((sum, face) => sum + face.area, 0);
const around = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0);
const count = faces.length > 1 ? `${faces.length}개 · ` : "";
return `영역 ${count}면적 ${area.toFixed(2)}㎡\n둘레 ${around.toFixed(2)}m`;
}
/** 영역 여럿 → 둘레 조각 — 둘 이상이 맞닿은 변은 짝수 번 나와 빠짐. */
export function regionEdges(faces: Face[]): number[] {
const count = new Map<number, number>();
for (const face of faces)
for (const edge of face.edges) count.set(edge, (count.get(edge) ?? 0) + 1);
return [...count].filter(([, n]) => n % 2).map(([edge]) => edge);
}
/** 그림 조각이 저장된 조각(두 끝 · 어느 방향이든)과 같은 자리인가(259-2 표 칸 → 그래프). */
export function samePiece(piece: SplitPiece, stored: CrossPickPiece): boolean {
if (piece.line !== stored.line) return false;
const near = (a: Pt, b: Pt) => Math.hypot(a[0] - b[0], a[1] - b[1]) <= LINE_TOLERANCE_M;
const [a, b] = [piece.points[0], piece.points[piece.points.length - 1]];
return (
(near(a, stored.start) && near(b, stored.end)) || (near(a, stored.end) && near(b, stored.start))
);
}
@@ -156,6 +193,10 @@ export interface DetailSelect {
setMode: (kind: "area" | "length" | null) => void;
source: (kind: "area" | "length") => ReturnType<typeof selectionSource>;
clear: () => void;
/** 저장된 조각을 그림에서 고름(259-2 표 칸 누름) · null = 비움. */
choose: (stored: CrossPickPiece[] | null) => void;
/** 고름 없이 값 글만(띠 · 259-2) — 다음 누름에 지워짐. */
note: (text: string | null) => void;
detach: () => void;
}
@@ -171,11 +212,18 @@ export function attachDetailSelect(input: {
picked: (additive: boolean) => void;
/** [칸에 넣기] 단추. */
commit: () => void;
/** 면적 고르기 중 그 자리에 면적 띠가 있나 — 있으면 영역 대신 띠(표가 맡음). */
band?: (event: MouseEvent) => boolean;
}): DetailSelect {
const { cad } = input;
let pieces: SplitPiece[] = [];
let hits: SVGPolylineElement[] = [];
let faces: Face[] = [];
let faceEls: SVGPolygonElement[] = [];
let selected: number[] = [];
/** 영역 누름으로 고른 면 — 비면 고른 조각이 한 면을 꼭 닫을 때 그 면. */
let chosenFaces: number[] = [];
let noteText: string | null = null;
let mode: "area" | "length" | null = null;
const label = document.createElement("span");
@@ -193,8 +241,21 @@ export function attachDetailSelect(input: {
const show = (): void => {
const chosen = selected.map((i) => pieces[i]);
hits.forEach((hit, i) => hit.classList.toggle("is-selected", selected.includes(i)));
label.textContent = selectionLabel(input.design(), chosen);
label.hidden = !chosen.length;
const same = (edges: number[]) =>
edges.length === selected.length && edges.every((edge) => selected.includes(edge));
const filled = chosenFaces.length
? chosenFaces
: faces.flatMap((face, i) => (same(face.edges) ? [i] : []));
faceEls.forEach((el, i) => el.classList.toggle("is-selected", filled.includes(i)));
label.textContent =
noteText ??
(filled.length
? regionLabel(
filled.map((i) => faces[i]),
chosen,
)
: selectionLabel(input.design(), chosen));
label.hidden = !label.textContent;
put.hidden = !mode || !selectionSource(mode, chosen);
// [+ − ⤢] 왼쪽(카드 꼴) — 그림을 다시 그리면 막대가 새로 생김
const bar = cad.querySelector(".b06-cross-card__zoom");
@@ -202,16 +263,29 @@ export function attachDetailSelect(input: {
};
const onClick = (event: MouseEvent): void => {
const hit = (event.target as Element).closest?.(`.${PIECE_HIT_CLASS}`);
const additive = event.shiftKey || event.ctrlKey || event.metaKey || mode === "area";
const target = event.target as Element;
const hit = target.closest?.(`.${PIECE_HIT_CLASS}`);
const shift = event.shiftKey || event.ctrlKey || event.metaKey;
const additive = shift || mode === "area";
noteText = null;
if (!hit) {
if (!additive && !mode && selected.length) {
selected = [];
const face = target.closest?.(`.${FACE_HIT_CLASS}`);
if (face && !(mode === "area" && input.band?.(event))) {
// 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter)
event.stopPropagation();
const index = faceEls.indexOf(face as SVGPolygonElement);
chosenFaces = toggleSelection(chosenFaces, index, shift);
selected = regionEdges(chosenFaces.map((i) => faces[i]));
show();
input.picked(true);
return;
}
if (!additive && !mode) [selected, chosenFaces] = [[], []];
show();
return;
}
event.stopPropagation();
chosenFaces = [];
selected = toggleSelection(selected, hits.indexOf(hit as SVGPolylineElement), additive);
show();
input.picked(additive);
@@ -226,7 +300,19 @@ export function attachDetailSelect(input: {
const layer = cad.querySelector<SVGGElement>("g.b06-chart__plot-layer");
pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : [];
selected = pieces.flatMap((piece, i) => (keep.has(pieceKey(piece)) ? [i] : []));
chosenFaces = [];
faces = findFaces(pieces);
const toPx = axes ? mToPx(axes) : null;
// 영역 판정 면 먼저(밑) → 조각 판정 선(위)
faceEls = toPx
? faces.map((face) => {
const el = document.createElementNS(SVG_NS, "polygon");
el.setAttribute("points", face.ring.map(toPx).join(" "));
el.setAttribute("class", FACE_HIT_CLASS);
layer?.append(el);
return el;
})
: [];
hits = toPx
? pieces.map((piece) => {
const hit = document.createElementNS(SVG_NS, "polyline");
@@ -248,7 +334,19 @@ export function attachDetailSelect(input: {
selected.map((i) => pieces[i]),
),
clear() {
selected = [];
[selected, chosenFaces] = [[], []];
show();
},
choose(stored) {
chosenFaces = [];
noteText = null;
selected = stored
? pieces.flatMap((piece, i) => (stored.some((s) => samePiece(piece, s)) ? [i] : []))
: [];
show();
},
note(text) {
noteText = text;
show();
},
detach() {
@@ -9,6 +9,8 @@
* 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) 또는 닫힌 선 조각들 · 길이 = 선 조각(들) —
* 조각 = 선이 만나는 점에서 나눈 것(209-1 · `_Cross_Detail_Select`) · 저장 = 조각 두 끝 목록.
* 길이는 한 번 누름 = 바로 칸에 · Shift/Ctrl+누름 = 여럿 → Enter · [칸에 넣기].
* 닫힌 영역 안 누름 = 그 영역 둘레 조각(259-2) · 면적 고르기 중 띠 자리는 띠가 앞.
* 표 칸 누름(259-2) = 저장된 조각을 그림에서 다시 고름 · 띠 칸 = 값 글(`cellGraph` · `bandLabel`).
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
@@ -19,7 +21,7 @@ import type {
CrossDetailTable,
CrossSection,
} from "./B06_Section_Api_Fetch";
import type { SectionSample } from "./B06_Section_Api_Types";
import type { CrossPickPiece, SectionSample } from "./B06_Section_Api_Types";
import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
import { pickLines, piecesValue } from "./B06_Section_UI_Cross_Detail_Geom";
import { attachDetailSelect, PIECE_HIT_CLASS } from "./B06_Section_UI_Cross_Detail_Select";
@@ -173,6 +175,32 @@ export function areaSourceOf(target: Element): CrossCellSource | null {
return band ? { src: "area", key: band[1] } : null;
}
/** 그 칸이 그래프에서 무엇을 가리키나(259-2) — 저장된 조각 · 띠 키(저장 띠 출처 → 칸 키) · 없음. */
export function cellGraph(
table: CrossDetailTable,
key: string,
): { pieces: CrossPickPiece[] } | { band: string } | null {
const source = table.cells[key];
if (source && "pieces" in source) return { pieces: source.pieces };
if (source && source.src === "area" && "key" in source) return { band: source.key };
return null;
}
const BAND_NAME: Readonly<Record<string, string>> = {
cut_soil: "깍기(토사)",
cut_rock: "깍기(암석)",
ditch_soil: "측구(토사)",
ditch_rock: "측구(암석)",
embankment: "쌓기",
spoil_fill: "사토장",
};
/** 띠 값 글(259-2) — 「깍기(토사) 면적 X㎡」 · 띠 아님 · 값 없음 = null. */
export function bandLabel(key: string, design: CrossDesign | undefined): string | null {
const value = BAND_NAME[key] ? localCellValue({ src: "area", key }, design) : null;
return value === null ? null : `${BAND_NAME[key]} 면적 ${value.toFixed(2)}㎡`;
}
/* ── 칸 값(짝 ① · 서버 `cell_value` 거울) ─────────────────────────────────── */
const AREA_DESIGN: Readonly<Record<string, string>> = {
@@ -280,7 +308,7 @@ export interface DetailTableHost {
let menu: ReturnType<typeof createMapContextMenu> | null = null;
const PICK_HINT = {
area: "면적 띠를 누름 · 또는 닫힌 영역의 선들을 차례로 누름 → Enter · Esc 취소",
area: "면적 띠를 누름 · 또는 닫힌 영역 안 · 선들을 누름 → Enter · Esc 취소",
length: "선을 누름 · Shift/Ctrl+누름 = 여럿 → Enter · Esc 취소",
} as const;
@@ -289,6 +317,10 @@ const PICK_HINT = {
export function attachDetailTable(host: DetailTableHost): {
detach: () => void;
redraw: () => void;
/** 칸 그래프 짝(259-2) — 저장된 조각 · 띠 키 · 없음. */
graph: (key: string) => ReturnType<typeof cellGraph>;
/** 표 칸 누름(259-2) — 저장된 조각을 그림에서 고름 · 띠면 값 글 · null = 비움. */
showCell: (key: string | null) => void;
} {
menu ??= createMapContextMenu("b06-detail");
const ctxMenu = menu;
@@ -323,6 +355,7 @@ export function attachDetailTable(host: DetailTableHost): {
const shown = localCellValue(source, section.design, section.samples);
const td = tableBox.querySelector<HTMLElement>(`td[data-cell="${CSS.escape(key)}"]`);
if (td) td.textContent = shown === null ? "다시 고름" : shown.toFixed(2);
if ("key" in source) select.note(bandLabel(source.key, section.design)); // 띠도 값 글(259-2)
edit((table) => setCell(table, key, source));
};
const commitSelection = (): void => {
@@ -354,14 +387,17 @@ export function attachDetailTable(host: DetailTableHost): {
if (picking?.kind === "length" && !additive) commitSelection();
},
commit: commitSelection,
band: (event) => !!bandAt(event),
});
// 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로) — 선 조각 누름은 위가 맡음
const bandAt = (event: MouseEvent): Element | undefined =>
document
.elementsFromPoint(event.clientX, event.clientY)
.find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR));
// 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로 · 영역보다 앞) — 선 조각 누름은 위가 맡음
const onPick = (event: MouseEvent): void => {
if (picking?.kind !== "area" || (event.target as Element).closest(`.${PIECE_HIT_CLASS}`))
return;
const hit = document
.elementsFromPoint(event.clientX, event.clientY)
.find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR));
const hit = bandAt(event);
const source = hit ? areaSourceOf(hit) : null;
if (!source) return;
event.stopPropagation();
@@ -426,5 +462,15 @@ export function attachDetailTable(host: DetailTableHost): {
tableBox.removeEventListener("keydown", onEnter);
},
redraw: () => select.redraw(),
graph: (key) => cellGraph(detailTableOf(host.section(), host.projectId, host.routeId()), key),
showCell: (key) => {
const target = key
? cellGraph(detailTableOf(host.section(), host.projectId, host.routeId()), key)
: null;
select.choose(target && "pieces" in target ? target.pieces : null);
// 저장 조각 = 조각 값 글 · 저장 띠 = 그 띠 · 저장 없음 = 칸 키 띠
const band = !target ? key : "band" in target ? target.band : null;
select.note(band ? bandLabel(band, host.section().design) : null);
},
};
}
@@ -28,6 +28,8 @@
.b06-detail__table {
flex: none;
/* 고른 칸 바깥 줄(259-3) 자리 기준 */
position: relative;
}
/* 그림 칸 = 테마 색(173-4 · 카드와 같은 바탕) · 그림은 칸 크기로 새로 그려 가득 */
@@ -81,7 +83,7 @@
color: var(--color-text);
}
/* 표 칸 누름 ↔ 그림 강조(150-4 · 164-2) — 짝 있는 칸만 손가락 · 고른 칸 · 줄 붉게 */
/* 표 칸 누름 ↔ 그림 강조(150-4 · 164-2) — 짝 있는 칸만 손가락 · 고른 칸 붉게 · 테두리는 묶음 바깥 한 줄(259-3) */
.b06-detail__qtable [data-key] {
cursor: pointer;
}
@@ -92,12 +94,23 @@
.b06-detail__qtable .is-picked {
background: color-mix(in srgb, var(--color-danger) 18%, transparent);
box-shadow: inset 0 0 0 1px var(--color-danger);
color: var(--color-danger);
}
.b06-detail__qtable tr:has(.is-picked) {
background: color-mix(in srgb, var(--color-danger) 5%, transparent);
.b06-detail__pick-outline {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.b06-detail__pick-outline path {
fill: none;
stroke: var(--color-danger);
stroke-width: 2;
stroke-linecap: square;
}
/* 그림에 대상이 없는 칸(암석 띠 없음 · 면적 0 · 사토장 없음) — 회색 · 누름 없음 */
@@ -188,6 +201,22 @@
stroke-width: 6;
}
/* 닫힌 영역 판정 면(259-2) — 투명 채움으로 누름 · 고른 영역 = 붉은 채움 */
.b06-detail__face {
fill: transparent;
stroke: none;
pointer-events: fill;
cursor: pointer;
}
.b06-detail__face:hover {
fill: color-mix(in srgb, var(--color-accent) 8%, transparent);
}
.b06-detail__face.is-selected {
fill: color-mix(in srgb, var(--color-danger) 22%, transparent);
}
/* 고른 것 값(카드 179-1 꼴) · [칸에 넣기] — [+ − ⤢] 왼쪽 */
.b06-detail__commit {
width: auto;
@@ -524,10 +524,9 @@ def build_cross_drawing(
# 표 모듈은 이 모듈의 직렬화 헬퍼를 쓰므로 순환을 피해 함수 안에서 들여온다.
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
_cross_note_entities,
_cross_table_entities,
cross_note_height,
cross_note_lines,
cross_note_block_entities,
cross_note_block_height,
cross_table_height,
cross_table_width,
)
@@ -550,7 +549,7 @@ def build_cross_drawing(
if quantity_table is not None:
stack = (
cross_table_height(design, table_scale)
+ cross_note_height(cross_note_lines(design), table_scale)
+ cross_note_block_height(design, table_scale)
+ 8.0
)
oy = table_bottom + stack + own_half_height * CROSS_MM
@@ -580,12 +579,11 @@ def build_cross_drawing(
table_bottom = oy - half_height
if quantity_table is not None:
table_top = oy - half_height - 8.0
# 표 위 글(150-3) — 줄 수만큼 표를 내림
note_lines = cross_note_lines(design)
# 표 위 글(150-3) · CAD 칸(260-3) — 그 높이만큼 표를 내림
entities.extend(
_cross_note_entities(drawing_id, note_lines, table_top, center_x, table_scale)
cross_note_block_entities(drawing_id, design, table_top, center_x, table_scale)
)
table_top -= cross_note_height(note_lines, table_scale)
table_top -= cross_note_block_height(design, table_scale)
entities.extend(
_cross_table_entities(
drawing_id, quantity_table, table_top, title_label, center_x, design, table_scale
@@ -34,8 +34,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
polyline_entity,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
cross_note_height,
cross_note_lines,
cross_note_block_height,
cross_table_height,
cross_table_width,
)
@@ -106,7 +105,7 @@ def section_block_size(
top = half_height + _BORDER_PAD_MM
below = half_height + _BORDER_PAD_MM
if with_table:
note_h = cross_note_height(cross_note_lines(design_spec), table_scale)
note_h = cross_note_block_height(design_spec, table_scale)
below += 8.0 + note_h + cross_table_height(design_spec, table_scale)
return (width, top + below)
@@ -401,6 +401,96 @@ def _cross_note_entities(
]
# 표 위 CAD 칸(260-3) — B06 [상세] 빈 CAD(`design.structure_note_cad`) 엔티티를 표 왼쪽 위에 맞춤.
# 칸이 있으면(엔티티가 비었어도) 옛 글 대신 이것 · 없으면 옛 글 줄 그대로.
def cross_note_cad(design: dict[str, Any] | None) -> list[dict[str, Any]] | None:
"""설계의 표 위 CAD 엔티티 — 칸이 없으면 None(옛 글 줄로)."""
cad = design.get("structure_note_cad") if isinstance(design, dict) else None
if not isinstance(cad, dict):
return None
entities = cad.get("entities")
return [e for e in entities if isinstance(e, dict)] if isinstance(entities, list) else []
def _text_width(label: str, font: float) -> float:
"""글 폭 어림(mm) — 한글 · 한자 = 글자 크기 · 그 밖 = 0.6 배."""
return sum(font if ord(char) >= 0x2E80 else 0.6 * font for char in label)
def note_cad_bbox(entities: list[dict[str, Any]]) -> tuple[float, float, float, float] | None:
"""CAD 칸 범위 (min_x, min_y, max_x, max_y) — 글은 기준점이 세로 가운데(CAD `textBaseline`)라
글 높이 · 폭을 맞춤 쪽으로 더함 · 그 밖은 `entities_bbox`."""
from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox
boxes = []
for entity in entities:
shape = entity.get("shapeData") or {}
base = shape.get("basePoint")
if entity.get("type") == "Text" and isinstance(base, dict):
options = shape.get("options") or {}
font = float(options.get("fontSize") or 0.0)
width = _text_width(str(shape.get("label") or ""), font)
x, y = float(base["x"]), float(base["y"])
left = {"center": x - width / 2.0, "right": x - width}.get(options.get("textAlign"), x)
boxes.append((left, y - font / 2.0, left + width, y + font / 2.0))
elif (box := entities_bbox([entity])) is not None:
boxes.append(box)
if not boxes:
return None
return (
min(b[0] for b in boxes),
min(b[1] for b in boxes),
max(b[2] for b in boxes),
max(b[3] for b in boxes),
)
def _on_layer(entity: dict[str, Any], layer_id: str) -> dict[str, Any]:
entity["layerId"] = layer_id
for child in entity.get("children") or []:
_on_layer(child, layer_id)
return entity
def cross_note_block_height(design: dict[str, Any] | None, scale: float = 1.0) -> float:
"""표 위 칸 높이(mm) — CAD 칸 = 범위 높이 × 배율 · 없으면 옛 글 줄 높이."""
cad = cross_note_cad(design)
if cad is None:
return cross_note_height(cross_note_lines(design), scale)
box = note_cad_bbox(cad)
return (box[3] - box[1]) * scale if box else 0.0
def cross_note_block_entities(
drawing_id: str,
design: dict[str, Any] | None,
top: float,
center_x: float = 0.0,
scale: float = 1.0,
) -> list[dict[str, Any]]:
"""표 위 칸 엔티티 — CAD 칸 = 범위 왼쪽 위를 표 왼쪽 끝 · `top` 에 맞춰 옮김(× 배율 · 표 층 ·
id `{도면}:notecad:{n}`) · 없으면 옛 글 줄(`_cross_note_entities`)."""
cad = cross_note_cad(design)
if cad is None:
return _cross_note_entities(drawing_id, cross_note_lines(design), top, center_x, scale)
box = note_cad_bbox(cad)
if box is None:
return []
from B07_DesignDetail.B07_DesignDetail_Engine_Template import _transform_entity
left = center_x - _CROSS_TABLE_WIDTH * scale / 2.0
dx, dy = left - box[0] * scale, top - box[3] * scale
return [
_on_layer(
_transform_entity(entity, f"{drawing_id}:notecad:{index}", scale, dx, dy),
CROSS_TABLE_LAYER_ID,
)
for index, entity in enumerate(cad)
]
def cross_table_width(scale: float = 1.0) -> float:
"""수량 산출표 가로 폭(mm)."""
return _CROSS_TABLE_WIDTH * scale
+22 -4
View File
@@ -10,7 +10,8 @@
* 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌)
* 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State`
* 단가연결(251-4) = 항목 칸 앞 램프(초록 = 연결 · 노랑 = 없음 · 호버 = 무엇) · 줄 우클릭 [단가연결] ·
* [단가연결 풀기] — 창 = B09 설계내역서와 같은 `openPriceLink` · 경로 `ref` · B09 운반공 줄이 받음
* [단가연결 풀기] — 창 = 「운반 단가」 모달(253-3 · B09 운반공 줄과 같음 · 그 행 거리) · 경로 `ref` ·
* B09 운반공 줄이 받음
* ========================================================================== */
import "./B08_Quantity_UI_Milestone.css";
@@ -18,6 +19,7 @@ import { createDataTable, type DataColumn } from "@ui/ui_template_data_table";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import type { Row } from "../B09_Estimation/B09_Estimation_Calc_Base";
import { openHaulModal } from "../B09_Estimation/B09_Estimation_UI_Haul_Modal";
import { openPriceLink } from "../B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link";
import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram";
@@ -100,7 +102,7 @@ export function addRoute(projectId: string, init: Partial<MilestoneRoute> = {}):
/** [거리이정표] 하위 탭 — 위 다이어그램 + 표 */
function tableView(projectId: string): MilestoneView {
const diagram = createDiagram(projectId, (init) => addRoute(projectId, init));
let keys: Array<{ route: string; index: number }> = [];
let keys: Array<{ route: string; index: number; L1: number; L2: number }> = [];
const routeAt = (i: number) =>
liveMilestone(projectId).routes.find((r) => r.id === keys[i]?.route);
const makeTable = (columns: DataColumn[]) => {
@@ -151,8 +153,19 @@ function tableView(projectId: string): MilestoneView {
// 호버 글 이름(사전조합 · 구조물 조합 = 들여온 대가 이름) — B09 는 tab · key 만 읽음
r.ref = ref && { ...ref, name: ref.name ?? from?.name ?? "" };
});
const at = keys[Number(tr?.dataset.i)];
pickAtCell([
["단가연결", () => void openPriceLink({ apply: link })],
[
"단가연결",
() =>
openHaulModal({
ref: (route.ref as Row | null) ?? null,
dist: at && { L1: at.L1, L2: at.L2 },
name: route.rows[at?.index ?? 0]?.item || route.name || "운반",
apply: link,
other: () => void openPriceLink({ apply: link }),
}),
],
...(route.ref ? [["단가연결 풀기", () => link(null)] as [string, () => void]] : []),
]);
});
@@ -180,7 +193,12 @@ function tableView(projectId: string): MilestoneView {
table.root.replaceWith(fresh.root);
table = fresh;
}
keys = rows.map(({ route, index }) => ({ route, index }));
keys = rows.map(({ route, index, paved, unpaved }) => ({
route,
index,
L1: paved,
L2: unpaved,
}));
none.hidden = rows.length > 0;
table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` })));
diagram.refresh();
+5 -1
View File
@@ -112,7 +112,9 @@ def haul_items(
) -> list[dict[str, Any]]:
"""운반공 줄 — B08 운반 묶음(251-1 · 장비 둘 + 총괄자재 묶음 · 옛 키 haul:1~6 그대로) +
거리이정표 사용자 상자 행(253-1 · `B08_Quantity_Haul.haul_rows` · 수량 빈칸) ·
연결 = 거리이정표 단가연결(251-5 · `refs{줄 키: ref}` · 설계내역서 연결 덮개가 이김)."""
연결 = 거리이정표 단가연결(251-5 · `refs{줄 키: ref}` · 설계내역서 연결 덮개가 이김) ·
`dist{L1, L2}` = 그 행 거리(253-3 「운반 단가」 모달 · 없으면 None)."""
dists = {r["key"]: {"L1": r.get("L1"), "L2": r.get("L2")} for r in routes or []}
out = []
for g in haul_groups(materials):
qty = g["qty"]
@@ -128,6 +130,7 @@ def haul_items(
"qty": qty,
"note": "B08 총괄자재" if qty is not None else "",
"ref": ref,
"dist": dists.get(bill_key(g["key"])),
"why": WHY_LOGIC if logic else WHY_HAUL,
}
)
@@ -146,6 +149,7 @@ def haul_items(
"qty": None,
"note": "B08 거리이정표",
"ref": ref,
"dist": dists.get(r["key"]),
"why": WHY_LOGIC if logic else WHY_HAUL,
}
)
+41 -1
View File
@@ -6,6 +6,9 @@
[저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 → 서버가 정본으로 다시 셈
POST /api/projects/{id}/estimation/prices
[단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘
POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks}
「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 ·
저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로)
POST /api/projects/{id}/estimation/library {kind, layer, owner, key}
설계내역서 연결 고르기(204-2) — 서고 구조물조합 · 사전조합을 프로젝트 사본으로
(있으면 그대로) → {폴더, 만듦, key = 대가 블록 id, payload} ·
@@ -25,7 +28,7 @@ from typing import Any
from uuid import UUID
from fastapi import APIRouter, Body, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from B08_Quantity.B08_Quantity_Haul import haul_rows
from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone
@@ -206,6 +209,43 @@ async def refresh_prices(project_id: UUID) -> dict[str, Any]:
return await payload(project_id, root)
class PreviewBody(BaseModel):
code: str = Field(min_length=1, max_length=40)
given: dict[str, Any] = Field(default_factory=dict)
rate: float | None = None
picks: dict[str, Any] | None = None
def logic_preview(root: Path, body: PreviewBody) -> dict[str, Any]:
"""로직 한 산근 미리보기 — 단가 = 굳힌 책(없는 줄 = 지금 M01) · 사용자 대비표 고름."""
src = {"code": body.code, "given": sangeun._clean_given(body.given), "rate": body.rate,
"picks": body.picks} # fmt: skip
hands = [("preview", "미리보기", src)]
keys = sangeun.hand_resource_keys({"haul_refs": {"preview": {"tab": "logic", **src}}})
user = store.read_user(root)
base = compute_base(prices.build_inputs(store.read_book(root), keys), user)
block = sangeun.Solver(base).add(sangeun.hand_whole(hands), src, None)
text = [
*block["head"],
*(t for ln in block["lines"] for t in ln.get("text") or []),
block["foot"],
]
return {
"name": block["name"],
"lines": [t for t in text if t],
"unit": block["unit"],
"price": block["sum"],
"parts": {p: block[p] for p in sangeun.PARTS},
"why": block["why"],
}
@router.post("/{project_id}/estimation/logic-preview")
async def preview_logic(project_id: UUID, body: PreviewBody) -> dict[str, Any]:
root = await _project_root(project_id)
return await asyncio.to_thread(logic_preview, root, body)
class LibraryBody(BaseModel):
kind: str
layer: str
@@ -0,0 +1,380 @@
/* =============================================================================
* B09_Estimation_UI_Haul_Modal.ts
* 「운반 단가」 모달(PLAN 253-3 · 요구 162-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결.
* 위 = 단가산출 이름 · [변경](`openLogicPicker`) · [다른 종류](옛 단가연결 창) ·
* 왼 = 옵션 값 — 로직 입력(`fetchLogicInputs`) + 사이클 입력(L1 · L2 · V1~V4 · t1 · t3~t6 · t1 셈 값) ·
* 오른 = 계산 절차 — 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle`) + 로직 산출 줄
* (서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만).
* 로직 입력 Cm · 사이클시간 · t2 · 운반거리 = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) ·
* 빈칸으로 지우면 다시 거리에서.
* 연결 없는 줄도 같은 모달(단가산출 빈칸 → [고르기]) · [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본).
* 여는 곳 = B08 거리이정표 표 우클릭 · B09 설계내역서 운반공 줄 단가연결.
* ========================================================================== */
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import {
CYCLE_DEFAULTS,
CYCLE_FILL,
cycleInputs,
cycleTime,
fillGiven,
type Dist,
} from "../B08_Quantity/B08_Quantity_Haul_Cycle";
import { loadTable, type TableRow } from "../M01_MasterData/M01_MasterData_UI_Logic_Note";
import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick";
import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values";
import type { Row } from "./B09_Estimation_Calc_Base";
type Apply = (ref: Row | null, from?: Row) => void;
export interface HaulModalOpts {
/** 지금 연결(없으면 null) */
ref: Row | null;
/** 그 행 거리 — L1 포장 계 · L2 비포장(km) */
dist: Dist;
/** 줄 이름(제목) */
name: string;
apply: Apply;
/** 옛 단가연결 창(일위대가 · 구조물 조합 등 다른 종류) */
other?: () => void;
}
/** 사이클 입력 칸 — [이름, 글, 단위] · 무리 = 화면 묶음 */
const CYCLE_FIELDS: Array<[string, string, string][]> = [
[
["L1", "포장 운반거리 L1", "km"],
["L2", "비포장 운반거리 L2", "km"],
],
[
["V1", "포장 적재 V1", "km/hr"],
["V2", "포장 공차 V2", "km/hr"],
["V3", "비포장 적재 V3", "km/hr"],
["V4", "비포장 공차 V4", "km/hr"],
],
[
["t1", "적재 t1", "분"],
["T", "적재량 T", "ton"],
["rt", "단위중량 rt", "t/㎥"],
["f", "체적환산 f", ""],
["qs", "버킷용량 qs", "㎥"],
["k", "버킷계수 k", ""],
["Es", "작업효율 Es", ""],
["Cms", "적재기계 Cms", "초"],
],
[
["t3", "적하 t3", "분"],
["t4", "대기 t4", "분"],
["t5", "덮개 t5", "분"],
["t6", "세륜 t6", "분"],
],
];
/** 표 고르기(건설품셈 8-2-8 · master_data 읽기만) — [표 키, 채울 칸(값칸 → 입력), 조건 칸들] */
const PICKS: Array<[string, Record<string, string>, string]> = [
["CC000054", { 적재: "V1", 공차: "V2" }, "포장 도로상태"],
["CC000054", { 적재: "V3", 공차: "V4" }, "비포장 도로상태"],
["CC000055", { 시간: "t3" }, "적하(토질 · 작업조건)"],
["CC000056", { 시간: "t4" }, "대기(적재장소)"],
["CC000057", { 시간: "t5" }, "덮개"],
["CC000058", { 시간: "t6" }, "세륜"],
];
const OWNABLE = new Set(["L1", "L2", "t1"]);
const FROM_DIST = "거리에서";
const fmt = (v: number | null | undefined): string =>
v == null || !Number.isFinite(v) ? "—" : String(v);
/** 계산 절차 — 사이클 식 줄(거창 단가산출 꼴) */
export function cycleLines(input: Record<string, unknown>): string[] {
const v: Record<string, number> = { ...CYCLE_DEFAULTS };
for (const k of Object.keys(v)) if (typeof input[k] === "number") v[k] = input[k] as number;
const r = cycleTime(input);
const t1 =
typeof input.t1 === "number"
? `t1 = ${fmt(r.t1)} 분(손 입력)`
: `t1 = Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es}) = ${r.t1} 분`;
return [
"사이클시간 — 건설품셈 8-2-8 덤프트럭",
`q = T / rt × f = ${v.T} / ${v.rt} × ${v.f} = ${r.q} ㎥`,
`n = q / (qs × k) = ${r.q} / (${v.qs} × ${v.k}) = ${r.n} 회`,
t1,
`t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 = (${v.L1}/${v.V1} + ${v.L1}/${v.V2} + ${v.L2}/${v.V3} + ${v.L2}/${v.V4}) × 60 = ${r.t2} 분`,
`Cm = t1 + t2 + t3 + t4 + t5 + t6 = ${r.t1} + ${r.t2} + ${v.t3} + ${v.t4} + ${v.t5} + ${v.t6} = ${r.cm} 분`,
`운반거리 = L1 + L2 = ${r.L} km`,
];
}
interface Preview {
lines: string[];
unit: string;
price: number | null;
why: string | null;
}
async function preview(code: string, given: Row, rate: unknown, picks: unknown): Promise<Preview> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) throw new Error("프로젝트 없음");
const res = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/logic-preview`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, given, rate: rate ?? null, picks: picks ?? null }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as Preview;
}
const label = (text: string, unit: string): string => (unit ? `${text} (${unit})` : text);
/** 「운반 단가」 모달 */
export function openHaulModal(o: HaulModalOpts): void {
const logicRef = o.ref?.tab === "logic" ? o.ref : null;
const st = {
code: String(logicRef?.code ?? ""),
name: String(logicRef?.name ?? ""),
given: { ...((logicRef?.given as Row) || {}) } as Row,
cycle: { ...((logicRef?.cycle as Row) || {}) } as Row,
own: new Set<string>((logicRef?.own as string[]) || []),
rate: logicRef?.rate ?? null,
picks: logicRef?.picks ?? null,
inputs: [] as ItemInput[],
unit: "",
};
let seq = 0;
let timer = 0;
openModal({
title: `운반 단가 — ${o.name}`,
closeLabel: "닫기",
wide: true,
dialogClass: "b09-haul-modal",
mount: (body, close, head) => {
const title = el("strong");
const linkBox = el("div", {
attrs: { style: "display:flex;gap:8px;align-items:center;margin-bottom:8px" },
});
const left = el("div", { attrs: { style: "overflow:auto;min-width:0" } });
const steps = el("pre", {
attrs: { style: "margin:0;white-space:pre-wrap;font-size:12px;line-height:1.6" },
});
const right = el("div", { attrs: { style: "overflow:auto;min-width:0" }, children: [steps] });
const ref = (): Row => ({ own: [...st.own], cycle: st.cycle, given: st.given });
const names = (): string[] => st.inputs.map((i) => i.이름);
const filled = (): Row => fillGiven(ref(), names(), o.dist) as Row;
const redraw = (): void => {
const input = cycleInputs(ref(), o.dist);
const head2 = cycleLines(input);
steps.textContent = [...head2, "", st.code ? "단가산출 — 셈 중…" : "단가산출 — 없음"].join(
"\n",
);
window.clearTimeout(timer);
if (!st.code) return;
const my = ++seq;
timer = window.setTimeout(() => {
preview(st.code, filled(), st.rate, st.picks).then(
(got) => {
if (my !== seq) return; // 최신 응답만
st.unit = got.unit;
const price = got.price == null ? `빈칸 — ${got.why ?? ""}` : `${got.price} 원`;
steps.textContent = [...head2, "", ...got.lines, "", `단가 = ${price}`].join("\n");
},
(error: unknown) => {
if (my === seq)
steps.textContent = [...head2, "", `못 셈 — ${String(error)}`].join("\n");
},
);
}, 250);
};
const numberField = (name: string, text: string, unit: string): HTMLElement => {
const input = el("input", {
className: "ui-input m02-inputval__control",
attrs: { type: "number", step: "any", "data-haul-cycle": name },
}) as HTMLInputElement;
const tag = el("span", { className: "m02-labin__muted" });
const show = (): void => {
const now = cycleInputs(ref(), o.dist);
const auto =
name === "t1" ? cycleTime(now).t1 : ((now[name] as number) ?? CYCLE_DEFAULTS[name]);
const mine = OWNABLE.has(name) ? st.own.has(name) : name in st.cycle;
if (document.activeElement !== input)
input.value = mine ? String(st.cycle[name] ?? "") : "";
input.placeholder = String(auto ?? "");
tag.textContent = (name === "L1" || name === "L2") && !mine && o.dist ? FROM_DIST : "";
};
input.addEventListener("change", () => {
const v = input.value.trim() === "" ? null : Number(input.value);
if (v === null || !Number.isFinite(v)) {
delete st.cycle[name];
st.own.delete(name);
} else {
st.cycle[name] = v;
if (OWNABLE.has(name)) st.own.add(name);
}
update();
});
shows.push(show);
return row(label(text, unit), [input, tag]);
};
const row = (text: string, cells: HTMLElement[]): HTMLElement =>
el("div", {
className: "m02-inputval__row",
children: [
el("span", { className: "m02-inputval__name", text }),
el("div", { className: "m02-inputval__cell", children: cells }),
],
});
const shows: Array<() => void> = [];
const tablePick = (key: string, map: Record<string, string>, text: string): HTMLElement => {
const select = el("select", {
className: "ui-select ui-select--sm m02-inputval__control",
attrs: { "data-haul-pick": Object.values(map).join(",") },
}) as HTMLSelectElement;
select.append(el("option", { text: "표에서 고르기", attrs: { value: "" } }));
void loadTable(key).then((t: TableRow | null) => {
const conds = Object.keys(t?.조건 || {});
(t?.줄 || []).forEach((line, i) => {
const what = conds.map((c) => String(line[c] ?? "")).join(" · ");
const vals = Object.keys(map)
.map((col) => `${map[col]} ${String(line[col] ?? "")}`)
.join(" · ");
select.append(el("option", { text: `${what} — ${vals}`, attrs: { value: String(i) } }));
});
select.onchange = () => {
const line = t?.줄?.[Number(select.value)];
if (!line) return;
for (const [col, name] of Object.entries(map))
if (typeof line[col] === "number") st.cycle[name] = line[col] as number;
select.value = "";
update();
};
});
return row(text, [select]);
};
const logicRows = (): HTMLElement[] =>
st.inputs.map((input) => {
const fill = input.이름 in CYCLE_FILL && !st.own.has(input.이름);
const value = fill ? filled()[input.이름] : st.given[input.이름];
const field = createValueControl(
input,
(value as string | number | null) ?? null,
(next, bad) => {
if (bad) return;
if (next === null || next === "") {
delete st.given[input.이름];
st.own.delete(input.이름);
} else {
st.given[input.이름] =
typeof next === "string" && Number.isFinite(Number(next)) ? Number(next) : next;
if (input.이름 in CYCLE_FILL) st.own.add(input.이름);
}
update();
},
);
field.control.setAttribute("data-labor-input", input.이름);
const tag = el("span", { className: "m02-labin__muted" });
shows.push(() => {
const auto = input.이름 in CYCLE_FILL && !st.own.has(input.이름);
tag.textContent = auto ? FROM_DIST : "";
if (auto && document.activeElement !== field.control)
field.control.value = String(filled()[input.이름] ?? "");
});
return row(label(input.이름, input.단위 || ""), [field.control, tag, field.error]);
});
/** 값 바뀜 — 칸은 그대로 두고 표시 · 계산 절차만 */
const update = (): void => {
shows.forEach((show) => show());
redraw();
};
/** 로직 바뀜 — 왼쪽 칸을 새로 */
const refresh = (): void => {
shows.splice(0);
const sec = (text: string): HTMLElement =>
el("h4", { text, attrs: { style: "margin:12px 0 4px" } });
left.replaceChildren(
sec("단가산출 옵션 값"),
...(st.code
? logicRows()
: [el("p", { className: "m02-labin__muted", text: "단가산출 없음 — [고르기]" })]),
sec("사이클시간 입력"),
...CYCLE_FIELDS.flatMap((group) => group.map(([n, t, u]) => numberField(n, t, u))),
sec("표에서 고르기(건설품셈 8-2-8)"),
...PICKS.map(([key, map, text]) => tablePick(key, map, text)),
);
title.textContent = st.code ? `단가산출 ${st.code} ${st.name}` : "단가산출 — 없음";
change.querySelector(".ui-btn__label")!.textContent = st.code ? "변경" : "고르기";
update();
};
const useLogic = (code: string, name: string): void => {
fetchLogicInputs([code]).then(
(got) => {
st.code = code;
st.name = name;
st.inputs = got.로직[0]?.입력 ?? [];
const keep = new Set(st.inputs.map((i) => i.이름));
for (const k of Object.keys(st.given)) if (!keep.has(k)) delete st.given[k];
refresh();
},
(error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"),
);
};
const change = createButton({
label: "변경",
variant: "ghost",
size: "sm",
onClick: () => openLogicPicker((logic) => useLogic(logic.키, logic.이름)),
});
linkBox.append(title, change);
if (o.other)
linkBox.append(
createButton({
label: "다른 종류",
variant: "ghost",
size: "sm",
onClick: () => {
close();
o.other?.();
},
}),
);
const ok = createButton({
label: "적용",
onClick: () => {
if (!st.code) return showToast("단가산출을 먼저 고르세요", "error");
const next: Row = {
tab: "logic",
code: st.code,
name: st.name,
given: filled(),
rate: st.rate,
picks: st.picks,
cycle: st.cycle,
own: [...st.own],
};
o.apply(next, { name: st.name, unit: st.unit.replace(/^원\//, "") });
showToast("적용함 — [저장]하면 서버가 거리로 다시 셈", "success");
close();
},
});
head.append(ok);
body.append(
linkBox,
el("div", {
attrs: {
style:
"display:grid;grid-template-columns:minmax(320px,1fr) minmax(360px,1.4fr);gap:16px;min-height:60vh",
},
children: [left, right],
}),
);
if (st.code) useLogic(st.code, st.name);
else refresh();
},
});
}
@@ -225,7 +225,13 @@ function linkFor(ctx: TabContext, rows: DataRow[], i: number) {
const row = rows[i];
const e = entryOf(freeze(ctx.user, rows), String(row.key));
const ref: Row | null = e && "ref" in e ? e.ref : (row.ref ?? null);
return linkItems(ctx, ref, applyRef(ctx, rows, i));
const key = String(row.key);
// 253-3 운반공 줄 = 「운반 단가」 모달(거리 = 서버가 준 항목 `dist`)
const item = key.startsWith("haul:")
? ((ctx.payload.inputs?.bill?.items as Row[]) || []).find((it) => it.key === key)
: undefined;
const haul = item && { dist: item.dist ?? null, name: String(row.name ?? item.name ?? "") };
return linkItems(ctx, ref, applyRef(ctx, rows, i), haul);
}
/** 줄 우클릭 메뉴 — 더함 · 연결 · 옮김 · 레벨 · 자동값으로 · 지움(모든 줄 · 지킬 것 안만) */
@@ -19,7 +19,9 @@ import { itemName, type PickerLayer } from "@ui/ui_template_item_picker_data";
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input";
import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick";
import type { Dist } from "../B08_Quantity/B08_Quantity_Haul_Cycle";
import type { Row } from "./B09_Estimation_Calc_Base";
import { openHaulModal } from "./B09_Estimation_UI_Haul_Modal";
import type { TabContext } from "./B09_Estimation_UI_Tabs";
/** 연결 적용 — ref = null 이면 풀기 · from = 고른 호표(명칭 · 규격 · 단위 채움) */
@@ -95,22 +97,27 @@ async function importLib(
}
}
/** 항목 줄 단가연결 메뉴(251-6) — [단가연결 모달, 그 밖(로직 입력값 · 단가연결 풀기)] · ref = 지금 연결 */
export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [Leaf[], Leaf[]] {
/** 항목 줄 단가연결 메뉴(251-6) — [단가연결 모달, 그 밖(로직 입력값 · 단가연결 풀기)] · ref = 지금 연결 ·
* haul = 운반공 줄(253-3 · 그 행 거리 · 이름) → 「운반 단가」 모달(옛 창은 [다른 종류]) */
export function linkItems(
ctx: TabContext,
ref: Row | null,
apply: ApplyRef,
haul?: { dist: Dist; name: string },
): [Leaf[], Leaf[]] {
const more: Leaf[] = [];
if (ref?.tab === "logic")
const priceLink = (): void =>
void openPriceLink({
apply,
hopyo: { dg: ctx.sheets.daega || [], sg: ctx.sheets.sg_list || [] },
onPayload: (p) => Object.assign(ctx.payload, p),
});
const haulModal = (): void =>
openHaulModal({ ref, dist: haul?.dist, name: haul?.name || "", apply, other: priceLink });
if (ref?.tab === "logic" && !haul)
more.push(["로직 입력값 고치기", () => askInputs(apply, ref.code, ref.name || "", ref)]);
if (ref) more.push(["단가연결 풀기", () => apply(null)]);
const open: Leaf = [
"단가연결",
() =>
openPriceLink({
apply,
hopyo: { dg: ctx.sheets.daega || [], sg: ctx.sheets.sg_list || [] },
onPayload: (p) => Object.assign(ctx.payload, p),
}),
];
return [[open], more];
return [[["단가연결", haul ? haulModal : priceLink]], more];
}
/** 단가연결 모달(251 · 159 · 160) — 한 목록에 단가산출 로직 · 일위대가 사전조합 · 구조물 조합(마스터 · 회사 ·
@@ -130,4 +130,93 @@ out.rockSource = S.selectionSource("length", [rockPieces[1]]);
out.rockLocal = T.localCellValue(out.rockSource, design, samples);
out.rockLength = G.pathLength(rockPieces[1].points);
/* ③ 닫힌 영역(259-2) — 면 찾기 · 열린 영역 제외 · 영역 고름 값 · 붙은 두 영역 = 맞닿은 변 뺌 */
const faces = G.findFaces(pieces);
out.faces = faces.map((face) => ({
area: face.area,
lines: face.edges.map((i) => pieces[i].line).sort(),
}));
const one = faces.map((face) =>
S.regionLabel(
[face],
face.edges.map((i) => pieces[i]),
),
);
out.faceLabels = one;
const union = S.regionEdges(faces);
const unionPieces = union.map((i) => pieces[i]);
out.union = {
lines: unionPieces.map((p) => p.line).sort(),
label: S.regionLabel(faces, unionPieces),
selectLabel: S.selectionLabel(design, unionPieces),
source: S.selectionSource("area", unionPieces),
};
out.union.local = T.localCellValue(out.union.source, design, samples);
// 열린 원지반만 = 면 없음
out.openFaces = G.findFaces(pieces.filter((p) => p.line === "ground")).length;
// 정사각형 하나 + 매달린 꼬리 — 꼬리 지우고 면 하나(넓이 1)
out.square = G.findFaces([
{
line: "ground",
points: [
[0, 0],
[1, 0],
],
},
{
line: "design",
points: [
[1, 0],
[1, 1],
[0, 1],
],
},
{
line: "rock",
points: [
[0, 1],
[0, 0],
],
},
{
line: "spoil",
points: [
[1, 1],
[3, 3],
],
},
]).map((face) => ({ area: face.area, edges: face.edges.sort() }));
/* ④ 표 칸 → 그래프(259-2) — 저장된 조각 짝 · 칸 그래프 짝 · 띠 값 글 */
const stored = G.pieceSource(rockPieces[1]);
out.same = {
forward: S.samePiece(rockPieces[1], stored),
reverse: S.samePiece(rockPieces[1], { ...stored, start: stored.end, end: stored.start }),
otherLine: S.samePiece(rockPieces[1], { ...stored, line: "ground" }),
otherPiece: S.samePiece(rockPieces[0], stored),
};
const table = {
cells: {
fence: out.rockSource,
cut_soil: { src: "area", key: "cut_rock" },
"r:r1:0": { src: "text", text: "글" },
},
rows: [],
columns: [],
};
out.cellGraph = ["fence", "cut_soil", "r:r1:0", "embankment"].map((key) => T.cellGraph(table, key));
out.bandLabel = [
T.bandLabel("cut_soil", { cut_soil_area_m2: 1.234 }),
T.bandLabel("cut_soil", {}),
T.bandLabel("ground", { cut_soil_area_m2: 1 }),
];
/* ⑤ 표 강조 바깥 한 줄(259-3) — 합친 묶음 이름 칸(두 줄) + 둘째 줄 이름 · 값 = ㄴ 모양 */
const P = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Pick.ts"));
out.outline = P.unionOutline([
[0, 0, 10, 20],
[10, 10, 20, 20],
[20, 10, 30, 20],
]);
process.stdout.write(JSON.stringify(out));
+6 -3
View File
@@ -80,8 +80,9 @@ def test_상세_표_칸_키_누름_배선():
) as f:
src = f.read()
assert "element.dataset.key = key" in src
assert "highlightDetail(svg, state.section.design, picked)" in src
assert 'closest<HTMLElement>("[data-key]")' in src
# 259-2 — 저장 띠 출처면 그 띠 · 저장 조각이면 강조 없음(그래프 고름) · 값 칸(data-cell)도 누름
assert "highlightDetail(svg, state.section.design, band)" in src
assert 'closest<HTMLElement>("[data-key], [data-cell]")' in src
def test_상세_큰_창_공용_막_그림_글_표_순서():
@@ -93,6 +94,8 @@ def test_상세_큰_창_공용_막_그림_글_표_순서():
css = f.read().replace(chr(13), "")
assert "wide: true" in src and "backdropClass" not in src
assert "body.append(cad, note, tableBox)" in src
assert "pointer-events: none" not in css and "b06-detail__backdrop" not in css
# 막 없음 — 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
assert ".ui-modal.b06-detail > .ui-modal__body {\n overflow: hidden;" in css
assert "flex: 1;\n min-height: 0;" in css and "height: 100%;" in css # 173-4 칸 가득
+10 -6
View File
@@ -105,10 +105,12 @@ def test_강조_붉은_한_벌_고름_hover_다름_빈칸():
".b06-detail__cad .b06-chart__center.is-active line {\n stroke: var(--color-danger);",
".b06-detail__cad .b06-chart__spoil-fill.is-active {\n stroke: var(--color-danger);",
".b06-detail__qtable .is-picked {\n background: color-mix(in srgb, var(--color-danger) 18%",
".b06-detail__qtable tr:has(.is-picked) {",
".b06-detail__qtable [data-key][data-empty] {\n cursor: default;",
):
assert selector in css, selector
# 259-3 — 줄 물들임 · 칸마다 테두리 없앰(묶음 바깥 한 줄)
assert "tr:has(.is-picked)" not in css
assert "inset 0 0 0 1px var(--color-danger)" not in css
assert "royal-amethyst" not in css and "--color-warning);" not in css.split("/* 그림 강조")[1]
assert (
".b06-detail__qtable [data-key]:hover {\n background: color-mix(in srgb, var(--color-danger) 8%"
@@ -118,11 +120,13 @@ def test_강조_붉은_한_벌_고름_hover_다름_빈칸():
def test_빈칸_누름_없음_이름_칸_첫_키():
src = _read("B06_Section_UI_Cross_Detail.ts")
assert 'el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? ""))' in src
assert 'if (!key || el.hasAttribute("data-empty")) return;' in src
# 179-4 — 묶음 이름 칸 = 하위 줄만큼(양식 2줄 · 더한 줄만큼 늘어남) · 그 줄 첫 값 칸 키
assert "nameCell(groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0)" in src
assert "nameCell(groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1)" in src
assert "!present.has(el.dataset.key) && !tablePick.graph(el.dataset.key)" in src
assert '(!el.dataset.key || el.hasAttribute("data-empty"))) return;' in src
# 179-4 — 묶음 이름 칸 = 하위 줄만큼(양식 2줄 · 더한 줄만큼 늘어남) · 그 줄 첫 값 칸 키 ·
# 259-3 덮는 줄 키 모두(data-keys)
flat = " ".join(src.split())
assert "nameCell( groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0," in flat
assert "nameCell( groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1," in flat
def test_표_글자_14px_이름_칸_머리색_빈칸_값칸만():
+2 -2
View File
@@ -117,11 +117,11 @@ def test_원지반_포함_닫힌_영역_넓이(out):
def test_정보창_값(out):
label = out["label"]
length = 2 * hypot(4 / 3, 2) + 4 + 2 * CROSS_X
assert label["ring"] == f"4개 · 길이 합 {length:.2f}m\n면적 {AREA:.2f}㎡"
assert label["ring"] == f"4개 · 길이 합 {length:.2f}m\n면적 {AREA:.2f}㎡ · 둘레 {length:.2f}m"
assert label["open"].endswith("면적 닫히지 않음")
assert label["single"] == "길이 4.00m · 경사비율 0.0%\n수평 4.00m · 높이 0.00m" # 카드 꼴
assert label["ground"] == (
f"원지반선 길이 {2 * CROSS_X:.2f}m\n수평 {2 * CROSS_X:.2f}m · 높이 0.00m"
f"원지반선 길이 {2 * CROSS_X:.2f}m · 경사비율 0.0%\n수평 {2 * CROSS_X:.2f}m · 높이 0.00m"
)
@@ -0,0 +1,62 @@
"""253-3 미리보기 길 `POST …/estimation/logic-preview` + 「운반 단가」 모달 — 거리이정표 우클릭 ·
설계내역서 운반공 줄에서 엶."""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Haul_Cycle import cycle_time # noqa: E402
from B09_Estimation.B09_Estimation_Bill_Items import haul_items # noqa: E402
from B09_Estimation.B09_Estimation_Router import PreviewBody, logic_preview, router # noqa: E402
def _src(name: str) -> str:
return (ROOT / name).read_text(encoding="utf-8")
def test_미리보기_계산_절차(tmp_path):
cm = cycle_time({"L1": 25.1, "L2": 0.8})["cm"]
given = {"qt": 15, "f": 1, "E": 0.9, "Cm": cm, "지역": "전국평균"}
got = logic_preview(tmp_path, PreviewBody(code="GF000134", given=given))
assert got["unit"] == "㎥" and got["price"] and got["price"] > 0
assert "Cm : 109.78 분" in got["lines"]
assert any("60 * 15 * 1 * 0.9 / 109.78" in line for line in got["lines"])
assert got["lines"][-1].startswith("계 :")
# 거리 늘면 단가도 늘음
far = logic_preview(tmp_path, PreviewBody(code="GF000134", given={**given, "Cm": 200}))
assert far["price"] > got["price"]
def test_미리보기_길():
paths = {(r.path, tuple(r.methods)) for r in router.routes}
assert ("/api/projects/{project_id}/estimation/logic-preview", ("POST",)) in paths
def test_내역_줄_거리():
one = {"ref": None}
routes = [
{**one, "key": "haul:3", "name": "모래운반", "user": False, "L1": 25.1, "L2": 0.8},
{**one, "key": "haul:r-u1", "name": "깬돌운반", "user": True, "L1": 3, "L2": 1},
]
items = {i["key"]: i for i in haul_items(None, {}, routes)}
assert items["haul:3"]["dist"] == {"L1": 25.1, "L2": 0.8}
assert items["haul:r-u1"]["dist"] == {"L1": 3, "L2": 1}
assert items["haul:1"]["dist"] is None
def test_모달_여는_곳():
modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts")
for want in ("/estimation/logic-preview", "openLogicPicker", "fetchLogicInputs", "fillGiven",
"cycleInputs", "my !== seq", "CC000054", "거리에서", "[고르기]"): # fmt: skip
assert want in modal, want
milestone = _src("B08_Quantity/B08_Quantity_UI_Milestone.ts")
assert "openHaulModal({" in milestone and "L1: paved" in milestone
link = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts")
assert '[["단가연결", haul ? haulModal : priceLink]]' in link
edit = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts")
assert (
'key.startsWith("haul:")' in edit
and "linkItems(ctx, ref, applyRef(ctx, rows, i), haul)" in edit
)
+89 -1
View File
@@ -6,6 +6,9 @@
거리이정표 꼴(빈 곳 왼쪽 끌기 팬)은 끌기 끝 click 만 막음.
- 259-1 암 경계선 — 설계선과 만나는 점에서 나뉨 · 고름 값 · 저장 꼴 · 서버 `pick_lines` 거울 ·
암 프리셋 아니면 다시 고름.
- 259-2 닫힌 영역 — 면 찾기(열린 영역 · 매달린 꼬리 제외) · 영역 고름 값(면적 · 둘레) · 붙은 영역 =
맞닿은 변 뺌 · 서버 거울 · 표 칸 → 저장된 조각 · 띠 짝 · 띠 값 글.
- 259-3 표 강조 — 고른 칸 묶음 바깥 한 줄(칸 사이 겹선 없음).
./venv/Scripts/python.exe -m pytest resources/tester/test_259_detail_region.py -q
"""
@@ -13,6 +16,7 @@
import json
import os
import subprocess
from math import hypot
from pathlib import Path
import pytest
@@ -45,6 +49,7 @@ AXES = {
"exaggeration": 2,
}
ROCK_X = 2 + 4 / 6 # 경사(4 : 6)가 암 경계선 99 m 와 만나는 offset
GROUND_X = 2 + 4 / 3 # 경사가 원지반 100 m 와 만나는 offset
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
@@ -98,7 +103,9 @@ def test_암_경계선이_설계선과_만나는_점에서_나뉨(out):
def test_암_경계선_고름_값_저장_꼴(out):
length = 2 * ROCK_X
assert out["rockLength"] == pytest.approx(length, abs=1e-3)
assert out["rockLabel"] == f"암 경계선 길이 {length:.2f}m\n수평 {length:.2f}m · 높이 0.00m"
assert out["rockLabel"] == (
f"암 경계선 길이 {length:.2f}m · 경사비율 0.0%\n수평 {length:.2f}m · 높이 0.00m"
)
source = out["rockSource"]
assert source["src"] == "length" and source["pieces"][0]["line"] == "rock"
patch = CrossSectionPatch(chainage_m=40.0, detail_table={"cells": {"fence": source}})
@@ -113,3 +120,84 @@ def test_암_경계선_서버_거울(out):
soil = {**DESIGN, "geometry_preset": "soil"}
assert pick_lines(soil, SAMPLES)["rock"] == []
assert cell_value(out["rockSource"], soil, SAMPLES) == (None, None, True)
# ── 259-2 닫힌 영역 · 영역 고름 · 면적 ────────────────────────────────────
SLOPE_1M = hypot(4 / 6, 1.0) # 경사 조각 높이 1 m 길이
FACE_TOP = (2 * GROUND_X + 2 * ROCK_X) / 2 # 원지반 100 ~ 암 99 사이 사다리꼴
FACE_LOW = (2 * ROCK_X + 4) / 2 # 암 99 ~ 바닥 98 사이 사다리꼴
def test_면_찾기_열린_영역_제외(out):
faces = sorted(out["faces"], key=lambda f: -f["area"])
assert [f["area"] for f in faces] == pytest.approx([FACE_TOP, FACE_LOW], abs=1e-6)
assert faces[0]["lines"] == ["design", "design", "ground", "rock"]
assert faces[1]["lines"] == ["design", "design", "design", "rock"]
assert out["openFaces"] == 0 # 원지반만 = 열림
assert out["square"] == [{"area": pytest.approx(1.0), "edges": [0, 1, 2]}] # 꼬리 지움
def test_영역_고름_면적_둘레(out):
top = 2 * GROUND_X + 2 * ROCK_X + 2 * SLOPE_1M
low = 2 * ROCK_X + 4 + 2 * SLOPE_1M
assert sorted(out["faceLabels"]) == sorted(
[
f"영역 면적 {FACE_TOP:.2f}㎡\n둘레 {top:.2f}m",
f"영역 면적 {FACE_LOW:.2f}㎡\n둘레 {low:.2f}m",
]
)
def test_붙은_두_영역_맞닿은_변_뺌(out):
union = out["union"]
assert union["lines"] == ["design"] * 5 + ["ground"] # 암 경계선 빠짐
area = FACE_TOP + FACE_LOW
around = 2 * GROUND_X + 4 + 4 * SLOPE_1M
assert union["label"] == f"영역 2개 · 면적 {area:.2f}㎡\n둘레 {around:.2f}m"
assert (
union["selectLabel"]
== f"6개 · 길이 합 {around:.2f}m\n면적 {area:.2f}㎡ · 둘레 {around:.2f}m"
)
assert union["local"] == pytest.approx(area, abs=1e-3)
number, _text, again = cell_value(union["source"], DESIGN, SAMPLES)
assert not again and number == pytest.approx(union["local"], abs=1e-6)
# ── 259-2 표 칸 → 그래프 ─────────────────────────────────────────────────
def test_표_칸_저장된_조각을_그래프에서_고름(out):
assert out["same"] == {
"forward": True,
"reverse": True,
"otherLine": False,
"otherPiece": False,
}
pieces, band, text, none = out["cellGraph"]
assert pieces == {"pieces": out["rockSource"]["pieces"]}
assert band == {"band": "cut_rock"}
assert text is None and none is None
def test_띠_값_글(out):
assert out["bandLabel"] == ["깍기(토사) 면적 1.23㎡", None, None]
# ── 259-3 표 강조 바깥 한 줄 ──────────────────────────────────────────────
def test_고른_칸_묶음_바깥_한_줄(out):
parts = {"M" + p for p in out["outline"].split("M") if p}
assert parts == {
"M0 0H10",
"M0 0V10",
"M10 0V10",
"M0 20H10",
"M0 10V20",
"M10 10H20",
"M10 20H20",
"M20 10H30",
"M20 20H30",
"M30 10V20",
} # 칸 사이(10 · 20 세로) 겹선 없음
+139
View File
@@ -155,3 +155,142 @@ def test_옛_글은_줄마다_Text_로_옮김(out):
def test_측점_이름은_B07_꼴(out):
assert out["station"] == ["NO.71+13", "NO.71", "NO.72"]
# ── 260-3 B07 표 위 ─────────────────────────────────────────────────────
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( # noqa: E402
CROSS_TABLE_LAYER_ID,
FRAME_LAYER_ID,
build_cross_drawing,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import section_block_size # noqa: E402
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( # noqa: E402
cross_note_block_height,
cross_note_cad,
note_cad_bbox,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox # noqa: E402
_SOURCE = {
"chainage_m": 20.0,
"samples": [
{"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.3 * x}
for x in range(-10, 11)
],
}
_QTABLE = {"ground": 100.0, "planned": 99.5}
def _text(label: str, x: float, y: float, align: str = "left", font: float = 2.0) -> dict:
return {
"id": label,
"type": "Text",
"lineColor": "#ff7f7f",
"lineWidth": 1,
"layerId": "기본",
"shapeData": {
"label": label,
"basePoint": {"x": x, "y": y},
"options": {"textAlign": align, "fontSize": font, "textColor": "#ff7f7f"},
},
}
# 미리 넣음과 같은 꼴 — 원(중심 6.5,-9 · 반지름 6.5) · 지시선 · 오른쪽 글(도면 좌표 아무 자리여도 됨)
_CIRCLE = {
"id": "c",
"type": "Circle",
"lineColor": "#ff7f7f",
"lineWidth": 1,
"layerId": "기본",
"shapeData": {"center": {"x": 106.5, "y": 41.0}, "radius": 6.5},
}
_CAD = {
"structure_note_cad": {
"entities": [_CIRCLE, _text("NO.1", 115.0, 48.5), _text("관유출부보강", 115.0, 45.5)],
"layers": [{"id": "기본", "name": "기본"}],
}
}
def _draw(design: dict | None) -> dict:
return build_cross_drawing(_SOURCE, "cross_00020m", None, design, _QTABLE, "No.1")
def _table(drawing: dict) -> dict:
return next(e for e in drawing["entities"] if e["type"] == "Table")
def _frame_height(drawing: dict) -> float:
frame = [e for e in drawing["entities"] if e.get("layerId") == FRAME_LAYER_ID]
_x0, y0, _x1, y1 = entities_bbox(frame)
return y1 - y0
def test_CAD_칸_범위는_글_높이_폭까지():
"""글 기준점 = 세로 가운데 · 왼쪽 맞춤 폭 = 한글 글자 크기 · 원 = 중심 ± 반지름."""
box = note_cad_bbox(_CAD["structure_note_cad"]["entities"])
assert box == pytest.approx((100.0, 34.5, 115.0 + 6 * 2.0, 49.5))
assert note_cad_bbox([_text("AB", 10.0, 0.0, "center")]) == pytest.approx((8.8, -1, 11.2, 1))
assert note_cad_bbox([]) is None
assert cross_note_cad(None) is None and cross_note_cad({"structure_note": "x"}) is None
def test_CAD_칸은_표_왼쪽_위_맞춤_표는_범위_높이만큼_내려감():
plain = _draw(None)
drawn = _draw(_CAD)
height = 49.5 - 34.5
assert cross_note_block_height(_CAD) == pytest.approx(height)
plain_top = _table(plain)["shapeData"]["origin"]["y"]
drawn_top = _table(drawn)["shapeData"]["origin"]["y"]
assert plain_top - drawn_top == pytest.approx(height)
placed = [e for e in drawn["entities"] if e.get("id") in _placed_ids()]
left = _table(drawn)["shapeData"]["origin"]["x"]
box = note_cad_bbox(placed)
# 좌상 맞춤 — 범위 왼쪽 = 표 왼쪽 · 범위 위 = 옛 표 윗변(글 없을 때 표 자리)
assert box[0] == pytest.approx(left) and box[3] == pytest.approx(plain_top)
assert box[1] == pytest.approx(drawn_top)
# 표 층으로 · 크기 그대로(배율 1) · 글 색 그대로 · id 는 도면마다 새로
assert {e["layerId"] for e in placed} == {CROSS_TABLE_LAYER_ID}
circle = next(e for e in placed if e["type"] == "Circle")
assert circle["shapeData"]["radius"] == 6.5
assert {e["lineColor"] for e in placed} == {"#ff7f7f"}
assert "c" not in {e["id"] for e in placed}
# 블록(테두리) · 시트 칸 크기도 같은 규칙
assert _frame_height(drawn) - _frame_height(plain) == pytest.approx(height)
_w, plain_h = section_block_size(_SOURCE, None)
_w, cad_h = section_block_size(_SOURCE, None, design=_CAD)
assert cad_h - plain_h == pytest.approx(height)
_w, scaled_h = section_block_size(_SOURCE, None, design=_CAD, table_scale=2.0)
_w, plain2_h = section_block_size(_SOURCE, None, table_scale=2.0)
assert scaled_h - plain2_h == pytest.approx(2 * height)
def _placed_ids() -> set[str]:
"""옮겨 놓은 CAD 칸 엔티티 id — 결정적 seed `{도면}:notecad:{n}`."""
from uuid import uuid5
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _ENTITY_NS
return {str(uuid5(_ENTITY_NS, f"cross_00020m:notecad:{n}")) for n in range(3)}
def test_CAD_칸이_있으면_옛_글_대신():
both = _draw({**_CAD, "structure_note": "옛 글"})
labels = [e["shapeData"]["label"] for e in both["entities"] if e["type"] == "Text"]
assert "옛 글" not in labels and "NO.1" in labels
def test_CAD_칸이_비면_아무것도_안_놓음_옛_글도_안_씀():
plain = _draw(None)
empty = _draw({"structure_note_cad": {"entities": []}, "structure_note": "옛 글"})
assert _table(plain)["shapeData"] == _table(empty)["shapeData"]
assert not [e for e in empty["entities"] if e["type"] == "Text"]
def test_CAD_칸이_없으면_옛_글_줄_그대로():
noted = _draw({"structure_note": "L형 측구\n집수정 보강"})
labels = [e["shapeData"]["label"] for e in noted["entities"] if e["type"] == "Text"]
assert labels == ["L형 측구", "집수정 보강"]