diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts
new file mode 100644
index 000000000..d85c07088
--- /dev/null
+++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts
@@ -0,0 +1,221 @@
+/* =============================================================================
+ * B08_Quantity_Milestone_Layout.ts
+ * 거리이정표 다이어그램 자리 계산(PLAN 244-2) — 화면 없음 · 형 import 만(시험이 node 로 돌림).
+ *
+ * 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 업체들
+ * 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 둘 다 경위도가 있으면 방위각 ·
+ * 없으면 사용자가 고른 8방 · 고르지 않았으면 빈 방위를 차례로 · 같은 방위 여럿 = 옆으로 나란히
+ * 기준점 자리 = 공사지 기준 같은 규칙(경위도 · 8방 · 기본 서)
+ * 선 = 업체 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다
+ * 화면 = 북 위(SVG y 아래로 = 남)
+ * ========================================================================== */
+
+import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc";
+
+export interface DiagramBox {
+ id: string;
+ kind: "site" | "start" | "mid" | "base" | "route";
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ label: string;
+}
+
+export interface DiagramLine {
+ from: string;
+ to: string;
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+ labels: string[];
+}
+
+export interface DiagramLayout {
+ boxes: DiagramBox[];
+ lines: DiagramLine[];
+ view: [number, number, number, number];
+}
+
+type LonLat = [number, number] | null | undefined;
+
+/** 8방 → 방위각(북 0 · 시계 방향) */
+export const DIR_DEG: Record
= {
+ N: 0,
+ NE: 45,
+ E: 90,
+ SE: 135,
+ S: 180,
+ SW: 225,
+ W: 270,
+ NW: 315,
+};
+const AUTO: Dir[] = ["N", "NW", "W", "SW", "S", "SE", "E", "NE"];
+
+const SITE = { w: 240, h: 96 };
+const NODE = { w: 72, h: 32 };
+const BOX = { w: 148, h: 44 };
+const R_BASE = 300;
+const R_ROUTE = 260;
+const SPREAD = 64;
+
+/** 경위도 두 점 → 방위각(도 · 북 0 · 시계) — 짧은 거리 평면 근사 */
+export function bearingDeg(from: [number, number], to: [number, number]): number {
+ const dx = (to[0] - from[0]) * Math.cos((((from[1] + to[1]) / 2) * Math.PI) / 180);
+ const dy = to[1] - from[1];
+ return ((Math.atan2(dx, dy) * 180) / Math.PI + 360) % 360;
+}
+
+/** 자리 방위 — 경위도 둘 다 → 방위각 · 아니면 8방 · 없으면 null */
+export function placeDeg(
+ own: { dir: Dir | null; lon?: number | null; lat?: number | null },
+ anchor: LonLat,
+): number | null {
+ if (own.lon != null && own.lat != null && anchor) return bearingDeg(anchor, [own.lon, own.lat]);
+ return own.dir ? DIR_DEG[own.dir] : null;
+}
+
+export const legText = (legs: Leg[]): string[] =>
+ legs.map((leg) => `${leg.road} ${leg.km == null ? "-" : String(leg.km)}km`);
+
+const unit = (deg: number): [number, number] => {
+ const rad = (deg * Math.PI) / 180;
+ return [Math.sin(rad), -Math.cos(rad)];
+};
+
+export function layoutMilestone(
+ data: MilestoneData,
+ site: { start_lonlat?: LonLat; mid_lonlat?: LonLat } | null,
+ midKm: number,
+): DiagramLayout {
+ const boxes: DiagramBox[] = [];
+ const lines: DiagramLine[] = [];
+ const center = new Map();
+ const put = (box: DiagramBox): void => {
+ boxes.push(box);
+ center.set(box.id, [box.x + box.w / 2, box.y + box.h / 2]);
+ };
+ const line = (from: string, to: string, labels: string[]): void => {
+ const [x1, y1] = center.get(from)!;
+ const [x2, y2] = center.get(to)!;
+ lines.push({ from, to, x1, y1, x2, y2, labels });
+ };
+
+ // 공사지 = 가운데 컨테이너 · 안에 시점(왼) · 중점(오른)
+ put({
+ id: "site",
+ kind: "site",
+ x: -SITE.w / 2,
+ y: -SITE.h / 2,
+ w: SITE.w,
+ h: SITE.h,
+ label: "공사지",
+ });
+ put({
+ id: "start",
+ kind: "start",
+ x: -SITE.w / 2 + 16,
+ y: -6,
+ w: NODE.w,
+ h: NODE.h,
+ label: "시점",
+ });
+ put({
+ id: "mid",
+ kind: "mid",
+ x: SITE.w / 2 - 16 - NODE.w,
+ y: -6,
+ w: NODE.w,
+ h: NODE.h,
+ label: "중점",
+ });
+ line(
+ "start",
+ "mid",
+ legText([{ road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }]),
+ );
+
+ const startLL = site?.start_lonlat ?? null;
+ const useBase = data.base.enabled;
+ const baseLL: LonLat =
+ data.base.lon != null && data.base.lat != null ? [data.base.lon, data.base.lat] : null;
+ let baseDeg = 270;
+ if (useBase) {
+ baseDeg = placeDeg(data.base, startLL) ?? 270;
+ const [ux, uy] = unit(baseDeg);
+ const cx = ux * R_BASE;
+ const cy = uy * R_BASE;
+ put({
+ id: "base",
+ kind: "base",
+ x: cx - BOX.w / 2,
+ y: cy - BOX.h / 2,
+ w: BOX.w,
+ h: BOX.h,
+ label: data.base.name || "기준점",
+ });
+ line("base", "start", legText(data.base.legs));
+ }
+
+ // 업체 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히
+ const groups = new Map<
+ string,
+ Array<{ id: string; label: string; anchor: string; deg: number; legs: Leg[] }>
+ >();
+ const used = new Set();
+ const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`;
+ const items = data.routes.map((r) => {
+ const anchor = useBase && r.via_base ? "base" : "site";
+ return { r, anchor, deg: placeDeg(r, anchor === "base" ? baseLL : startLL) };
+ });
+ for (const it of items) if (it.deg !== null) used.add(keyOf(it.anchor, it.deg));
+ let auto = 0;
+ /** 방위 안 고른 업체 — 빈 8방 차례(기준점 쪽 = 공사지 반대편부터 · 기준점 · 공사지로 향하는 쪽 뺌) */
+ const free = (anchor: string): number => {
+ const turn = anchor === "base" ? baseDeg : 0;
+ const blocked = anchor === "base" ? baseDeg + 180 : useBase ? baseDeg : -1;
+ const cands = AUTO.map((d) => (DIR_DEG[d] + turn) % 360).filter(
+ (c) => keyOf(anchor, c) !== keyOf(anchor, blocked),
+ );
+ return cands.find((c) => !used.has(keyOf(anchor, c))) ?? cands[auto++ % cands.length];
+ };
+ for (const { r, anchor, deg } of items) {
+ const d = deg ?? free(anchor);
+ used.add(keyOf(anchor, d));
+ const key = keyOf(anchor, d);
+ if (!groups.has(key)) groups.set(key, []);
+ groups.get(key)!.push({ id: r.id, label: r.name || "업체", anchor, deg: d, legs: r.legs });
+ }
+ for (const group of groups.values()) {
+ group.forEach((g, k) => {
+ const [ax, ay] = center.get(g.anchor === "base" ? "base" : "site")!;
+ const [ux, uy] = unit(g.deg);
+ const off = (k - (group.length - 1) / 2) * SPREAD;
+ const reach = g.anchor === "site" ? R_ROUTE + SITE.w / 4 : R_ROUTE;
+ const cx = ax + ux * reach - uy * off;
+ const cy = ay + uy * reach + ux * off;
+ put({
+ id: g.id,
+ kind: "route",
+ x: cx - BOX.w / 2,
+ y: cy - BOX.h / 2,
+ w: BOX.w,
+ h: BOX.h,
+ label: g.label,
+ });
+ line(g.id, g.anchor === "base" ? "base" : "start", legText(g.legs));
+ });
+ }
+
+ const pad = 48;
+ const xs = boxes.flatMap((b) => [b.x, b.x + b.w]);
+ const ys = boxes.flatMap((b) => [b.y, b.y + b.h]);
+ const minX = Math.min(...xs) - pad;
+ const minY = Math.min(...ys) - pad;
+ return {
+ boxes,
+ lines,
+ view: [minX, minY, Math.max(...xs) + pad - minX, Math.max(...ys) + pad - minY],
+ };
+}
diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css
index be1adfa9a..01057d221 100644
--- a/B08_Quantity/B08_Quantity_UI_Milestone.css
+++ b/B08_Quantity/B08_Quantity_UI_Milestone.css
@@ -43,3 +43,74 @@
align-self: flex-start;
border: 1px solid var(--color-border);
}
+
+/* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) */
+.b08-milestone__diagram {
+ overflow: auto;
+ max-height: 48vh;
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ background: var(--color-surface);
+}
+
+.b08-milestone__diagram svg {
+ display: block;
+ margin: 0 auto;
+ font-size: 13px;
+}
+
+.b08-milestone__line {
+ stroke: var(--color-text-secondary);
+ stroke-width: 1.5;
+}
+
+.b08-milestone__box rect {
+ fill: var(--color-surface);
+ stroke: var(--color-text-secondary);
+ stroke-width: 1.2;
+}
+
+.b08-milestone__box text,
+.b08-milestone__plus text {
+ fill: var(--color-text);
+}
+
+.b08-milestone__box.is-site rect {
+ fill: var(--color-surface-raised);
+ stroke-dasharray: 4 3;
+}
+
+.b08-milestone__box.is-base rect,
+.b08-milestone__box.is-start rect,
+.b08-milestone__box.is-mid rect {
+ stroke: var(--color-accent);
+}
+
+.b08-milestone__box.is-route {
+ cursor: pointer;
+}
+
+.b08-milestone__box.is-route:hover rect,
+.b08-milestone__box.is-route:focus-visible rect {
+ stroke: var(--color-accent);
+ stroke-width: 2;
+}
+
+.b08-milestone__plus {
+ cursor: pointer;
+}
+
+.b08-milestone__plus circle {
+ fill: var(--color-surface);
+ stroke: var(--color-accent);
+ stroke-width: 1.5;
+}
+
+.b08-milestone__km {
+ fill: var(--color-text);
+ font-size: 12px;
+ paint-order: stroke;
+ stroke: var(--color-surface);
+ stroke-width: 4px;
+ stroke-linejoin: round;
+}
diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts
index d215bb7df..faa790110 100644
--- a/B08_Quantity/B08_Quantity_UI_Milestone.ts
+++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts
@@ -6,6 +6,7 @@
* [거리이정표] = 표(항목 · 업체 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고)
* 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈
* 입력 = 더블클릭(243) · 업체 이름을 고치면 그 경로 이름(하위 탭)도
+ * 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(244-2 · 상자 누름 = 경로 탭 · [+] = 항목 추가)
* 경로 탭 = `…_UI_Milestone_Route` · 데이터 · [저장] = `…_UI_Milestone_State`(좌측 패널 [저장] 한 줄)
* ========================================================================== */
@@ -13,6 +14,7 @@ import "./B08_Quantity_UI_Milestone.css";
import { createDataTable, type DataColumn } from "@ui/ui_template_data_table";
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
+import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram";
import {
liveMilestone,
loadMilestone,
@@ -61,8 +63,9 @@ export function addRoute(projectId: string, init: Partial = {}):
return id;
}
-/** [거리이정표] 하위 탭 — 표(+ 244-2 다이어그램 자리) */
-function tableView(projectId: string): MilestoneView {
+/** [거리이정표] 하위 탭 — 위 다이어그램 + 표 */
+function tableView(projectId: string, open: (id: string) => void): MilestoneView {
+ const diagram = createDiagram(projectId, open, (init) => addRoute(projectId, init));
let keys: Array<{ route: string; index: number }> = [];
const table = createDataTable({
columns: COLUMNS,
@@ -97,16 +100,20 @@ function tableView(projectId: string): MilestoneView {
table.root.dataset.b08Milestone = "table";
const none = el("p", {
className: "ui-general-block__empty",
- text: "경로가 없습니다 — 하위 탭 줄 [+ 추가]로 업체를 넣으세요.",
+ text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+] 또는 하위 탭 줄 [+ 추가]로 업체를 넣으세요.",
});
const refresh = (): void => {
const rows = computeMilestone(liveMilestone(projectId), siteInfo()?.mid_default_m ?? null);
keys = rows.map(({ route, index }) => ({ route, index }));
none.hidden = rows.length > 0;
table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` })));
+ diagram.refresh();
};
refresh();
- return { root: el("div", { className: "b08-milestone", children: [none, table.root] }), refresh };
+ return {
+ root: el("div", { className: "b08-milestone", children: [diagram.root, none, table.root] }),
+ refresh,
+ };
}
export function renderMilestone(host: HTMLElement, projectId: string): void {
@@ -173,7 +180,7 @@ export function renderMilestone(host: HTMLElement, projectId: string): void {
function open(id: string): void {
current = id;
drawTabs();
- body = id === "table" ? tableView(projectId) : routeView(projectId, id);
+ body = id === "table" ? tableView(projectId, open) : routeView(projectId, id);
view.replaceChildren(body.root);
}
diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts
new file mode 100644
index 000000000..3a54a3760
--- /dev/null
+++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts
@@ -0,0 +1,174 @@
+/* =============================================================================
+ * B08_Quantity_UI_Milestone_Diagram.ts
+ * [거리이정표] 하위 탭 위쪽 다이어그램(PLAN 244-2) — SVG · 자리 = `…_Milestone_Layout`.
+ *
+ * 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체들 · 선마다 「도로 구분 km」 글
+ * 업체 상자 누름 = 그 경로 하위 탭으로 · 공사지 · 기준점 상자 [+] = 항목 추가(작은 창 → 경로 하위 탭 생김 ·
+ * 같은 업체 이름이면 그 경로에 표 줄만 더함) · 조작 = 캐시(`…_UI_Milestone_State`)
+ * ========================================================================== */
+
+import { openModal } from "@ui/ui_template_modal";
+import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
+import { midKm, type Dest, type Dir, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
+import { layoutMilestone, type DiagramBox } from "./B08_Quantity_Milestone_Layout";
+import {
+ dirOptions,
+ liveMilestone,
+ putMilestone,
+ siteInfo,
+} from "./B08_Quantity_UI_Milestone_State";
+
+const NS = "http://www.w3.org/2000/svg";
+
+function svg(
+ tag: K,
+ attrs: Record,
+ text?: string,
+): SVGElementTagNameMap[K] {
+ const node = document.createElementNS(NS, tag);
+ for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v));
+ if (text !== undefined) node.textContent = text;
+ return node;
+}
+
+/** [+] 작은 창 — 항목 · 업체 이름 · 목적지 · 방위 → 새 경로(같은 이름 = 그 경로에 줄) */
+export function openAddItem(
+ projectId: string,
+ viaBase: boolean,
+ addRoute: (init: Partial) => string,
+): void {
+ openModal({
+ title: viaBase ? "항목 추가 — 기준점 거침" : "항목 추가 — 공사지로 바로",
+ closeLabel: "닫기",
+ mount: (body, close, actions) => {
+ const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" });
+ const company = createInputField({ label: "업체 이름", compact: true });
+ const dest = createSelectField({
+ label: "목적지",
+ compact: true,
+ options: [
+ { value: "start", text: "시점" },
+ { value: "mid", text: "중점" },
+ ],
+ });
+ const dir = createSelectField({
+ label: viaBase ? "방위(기준점에서 본)" : "방위(공사지에서 본)",
+ compact: true,
+ options: dirOptions,
+ });
+ item.input.dataset.b08MilestoneAdd = "item";
+ company.input.dataset.b08MilestoneAdd = "company";
+ const ok = createButton({
+ label: "추가",
+ variant: "filled",
+ onClick: () => {
+ const name = company.input.value.trim();
+ if (!name) return company.setError("업체 이름을 넣으세요.");
+ const row = { item: item.input.value.trim(), dest: dest.select.value as Dest, note: "" };
+ const data = liveMilestone(projectId);
+ const same = data.routes.find((r) => r.name.trim() === name);
+ if (same) {
+ same.rows.push(row);
+ putMilestone(projectId, data);
+ } else
+ addRoute({
+ name,
+ via_base: viaBase,
+ dir: (dir.select.value || null) as Dir | null,
+ rows: [row],
+ });
+ close();
+ },
+ });
+ actions.append(ok);
+ body.append(item.root, company.root, dest.root, dir.root);
+ },
+ });
+}
+
+/** 다이어그램 — refresh() 로 캐시 값 다시 그림 */
+export function createDiagram(
+ projectId: string,
+ openTab: (routeId: string) => void,
+ addRoute: (init: Partial) => string,
+): { root: HTMLElement; refresh: () => void } {
+ const host = el("div", { className: "b08-milestone__diagram" });
+ host.dataset.b08Milestone = "diagram";
+
+ const plus = (box: DiagramBox, viaBase: boolean): SVGGElement => {
+ const g = svg("g", { class: "b08-milestone__plus", role: "button", tabindex: 0 });
+ const cx = box.x + box.w - 2;
+ const cy = box.y + 2;
+ g.append(
+ svg("circle", { cx, cy, r: 11 }),
+ svg("text", { x: cx, y: cy, "text-anchor": "middle", "dominant-baseline": "central" }, "+"),
+ svg("title", {}, "항목 추가"),
+ );
+ g.dataset.b08MilestonePlus = viaBase ? "base" : "site";
+ const go = (e: Event): void => {
+ e.stopPropagation();
+ openAddItem(projectId, viaBase, addRoute);
+ };
+ g.addEventListener("click", go);
+ g.addEventListener("keydown", (e) => e.key === "Enter" && go(e));
+ return g;
+ };
+
+ const refresh = (): void => {
+ const data = liveMilestone(projectId);
+ const site = siteInfo();
+ const layout = layoutMilestone(data, site, midKm(data, site?.mid_default_m ?? null));
+ const [vx, vy, vw, vh] = layout.view;
+ const root = svg("svg", {
+ viewBox: `${vx} ${vy} ${vw} ${vh}`,
+ width: vw,
+ height: vh,
+ role: "img",
+ "aria-label": "거리이정표 다이어그램",
+ });
+ root.style.maxWidth = "100%";
+ root.style.height = "auto";
+ for (const ln of layout.lines) {
+ root.append(
+ svg("line", { class: "b08-milestone__line", x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }),
+ );
+ }
+ for (const box of layout.boxes) {
+ const g = svg("g", { class: `b08-milestone__box is-${box.kind}` });
+ g.dataset.b08MilestoneBox = box.id;
+ g.append(
+ svg("rect", { x: box.x, y: box.y, width: box.w, height: box.h, rx: 6 }),
+ svg(
+ "text",
+ {
+ x: box.x + box.w / 2,
+ y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2,
+ "text-anchor": "middle",
+ "dominant-baseline": "central",
+ },
+ box.label,
+ ),
+ );
+ if (box.kind === "route") {
+ g.setAttribute("role", "button");
+ g.setAttribute("tabindex", "0");
+ g.addEventListener("click", () => openTab(box.id));
+ g.addEventListener("keydown", (e) => e.key === "Enter" && openTab(box.id));
+ }
+ if (box.kind === "site") g.append(plus(box, false));
+ if (box.kind === "base") g.append(plus(box, true));
+ root.append(g);
+ }
+ // 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄
+ for (const ln of layout.lines) {
+ const x = (ln.x1 + ln.x2) / 2;
+ const y = (ln.y1 + ln.y2) / 2 - ((ln.labels.length - 1) * 16) / 2;
+ const text = svg("text", { class: "b08-milestone__km", x, y, "text-anchor": "middle" });
+ ln.labels.forEach((label, i) => text.append(svg("tspan", { x, dy: i ? 16 : 0 }, label)));
+ root.append(text);
+ }
+ host.replaceChildren(root);
+ };
+ refresh();
+ return { root: host, refresh };
+}
diff --git a/resources/tester/test_244_2_b08_milestone_diagram.py b/resources/tester/test_244_2_b08_milestone_diagram.py
new file mode 100644
index 000000000..aa4944a42
--- /dev/null
+++ b/resources/tester/test_244_2_b08_milestone_diagram.py
@@ -0,0 +1,154 @@
+"""244-2 B08 거리이정표 다이어그램 — 방위 자리(8방 · 경위도 방위각 · 빈 방위 차례 ·
+같은 방위 나란히) · 선 글(도로 구분 km) · 상자 누름 · [+] 항목 추가 길.
+
+자리 계산 = `B08_Quantity_Milestone_Layout.ts` 를 node 로 돌림(화면 = 북 위 · SVG y 아래 = 남).
+"""
+
+import json
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
+
+
+def _layout(data, site=None, mid_km=0.75):
+ src = (ROOT / "B08_Quantity" / "B08_Quantity_Milestone_Layout.ts").read_text(encoding="utf-8")
+ body = (
+ f"\nconst d = {json.dumps(data, ensure_ascii=False)};"
+ f"\nconsole.log(JSON.stringify(layoutMilestone(d, {json.dumps(site)}, {mid_km})));"
+ )
+ result = subprocess.run(
+ ["node", "--experimental-strip-types", "--no-warnings",
+ "--input-type=module-typescript", "-e", IMPORTS.sub("", src) + body],
+ capture_output=True, text=True, encoding="utf-8", timeout=60,
+ ) # fmt: skip
+ assert result.returncode == 0, result.stderr
+ return json.loads(result.stdout)
+
+
+def _route(rid, name, dir=None, via=True, legs=None, lon=None, lat=None):
+ return {"id": rid, "name": name, "address": "", "via_base": via, "dir": dir, "lon": lon,
+ "lat": lat, "legs": legs or [],
+ "rows": [{"item": "", "dest": "start", "note": ""}]} # fmt: skip
+
+
+def _data(routes, base=None):
+ return {
+ "site": {"mid_station_m": None, "mid_road": "비포장"},
+ "base": base or {"enabled": False, "name": "", "address": "", "dir": None, "legs": []},
+ "routes": routes,
+ }
+
+
+def _center(layout, bid):
+ box = next(b for b in layout["boxes"] if b["id"] == bid)
+ return box["x"] + box["w"] / 2, box["y"] + box["h"] / 2
+
+
+def test_공사지_컨테이너_시점_중점_선():
+ lay = _layout(_data([]))
+ kinds = {b["id"]: b["kind"] for b in lay["boxes"]}
+ assert kinds == {"site": "site", "start": "start", "mid": "mid"}
+ sx, _ = _center(lay, "start")
+ mx, _ = _center(lay, "mid")
+ assert sx < mx # 시점 왼 · 중점 오른 · 한 컨테이너 안
+ site = next(b for b in lay["boxes"] if b["id"] == "site")
+ for bid in ("start", "mid"):
+ b = next(x for x in lay["boxes"] if x["id"] == bid)
+ assert site["x"] <= b["x"] and b["x"] + b["w"] <= site["x"] + site["w"]
+ assert lay["lines"] == [
+ {**lay["lines"][0], "from": "start", "to": "mid", "labels": ["비포장 0.75km"]}
+ ]
+
+
+def test_8방_자리_기준점_없음_공사지_기준():
+ lay = _layout(
+ _data([_route("e", "동쪽", "E"), _route("n", "북쪽", "N"), _route("sw", "남서", "SW")])
+ )
+ cx, cy = _center(lay, "site")
+ ex, ey = _center(lay, "e")
+ nx, ny = _center(lay, "n")
+ wx, wy = _center(lay, "sw")
+ assert ex > cx + 200 and abs(ey - cy) < 1
+ assert ny < cy - 200 and abs(nx - cx) < 1
+ assert wx < cx and wy > cy
+ line = next(ln for ln in lay["lines"] if ln["from"] == "e")
+ assert line["to"] == "start" # 기준점 없으면 시점으로
+
+
+def test_기준점_기준_방위_선_글():
+ base = {"enabled": True, "name": "병곡리 968-31", "address": "", "dir": "W",
+ "legs": [{"road": "1차포장", "km": 0.6}, {"road": "비포장", "km": 0.8}]} # fmt: skip
+ lay = _layout(
+ _data(
+ [
+ _route("g", "거창군청", "N", legs=[{"road": "2차포장", "km": 23.8}]),
+ _route("k", "김천역", "E", via=False),
+ ],
+ base,
+ ), # fmt: skip
+ )
+ cx, cy = _center(lay, "site")
+ bx, by = _center(lay, "base")
+ assert bx < cx - 200 and abs(by - cy) < 1 # 기준점 = 공사지 서쪽
+ gx, gy = _center(lay, "g")
+ assert abs(gx - bx) < 1 and gy < by - 200 # 거창군청 = 기준점 북쪽
+ kx, _ = _center(lay, "k")
+ assert kx > cx + 200 # 기준점 안 거침 = 공사지 동쪽
+ labels = {(ln["from"], ln["to"]): ln["labels"] for ln in lay["lines"]}
+ assert labels[("g", "base")] == ["2차포장 23.8km"]
+ assert labels[("base", "start")] == ["1차포장 0.6km", "비포장 0.8km"]
+ assert labels[("k", "start")] == []
+ assert next(b for b in lay["boxes"] if b["id"] == "base")["label"] == "병곡리 968-31"
+
+
+def test_경위도_있으면_방위각이_8방보다_앞섬():
+ site = {"start_lonlat": [127.8, 35.7], "mid_lonlat": None}
+ lay = _layout(_data([_route("e", "동쪽 좌표", "N", lon=127.9, lat=35.7)]), site)
+ cx, cy = _center(lay, "site")
+ ex, ey = _center(lay, "e")
+ assert ex > cx + 200 and abs(ey - cy) < 1 # 고른 방위 N 보다 좌표(동) 먼저
+
+
+def test_빈_방위_차례_같은_방위_나란히():
+ lay = _layout(
+ _data(
+ [_route("a", "가", "E"), _route("b", "나", "E"), _route("c", "다"), _route("d", "라")]
+ )
+ )
+ (ax, ay), (bx, by) = _center(lay, "a"), _center(lay, "b")
+ assert abs(ax - bx) < 1 and abs(ay - by) >= 60 # 같은 방위 = 옆으로 나란히
+ cx, cy = _center(lay, "c")
+ dx, dy = _center(lay, "d")
+ sx, sy = _center(lay, "site")
+ assert cy < sy - 200 and abs(cx - sx) < 1 # 고르지 않음 = 빈 방위 첫째(북)
+ assert (dx, dy) != (cx, cy) and dx < sx # 다음 빈 방위(북서)
+ boxes = [b for b in lay["boxes"] if b["kind"] == "route"]
+ for i, p in enumerate(boxes): # 업체 상자끼리 안 겹침
+ for q in boxes[i + 1 :]:
+ assert (
+ p["x"] + p["w"] <= q["x"] or q["x"] + q["w"] <= p["x"]
+ or p["y"] + p["h"] <= q["y"] or q["y"] + q["h"] <= p["y"]
+ ) # fmt: skip
+
+
+def test_화면_상자_누름_더하기_창():
+ ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts").read_text(encoding="utf-8")
+ assert 'g.addEventListener("click", () => openTab(box.id));' in ui
+ assert 'if (box.kind === "site") g.append(plus(box, false));' in ui
+ assert 'if (box.kind === "base") g.append(plus(box, true));' in ui
+ assert "openAddItem(projectId, viaBase, addRoute)" in ui and "openModal({" in ui
+ assert "same.rows.push(row)" in ui # 같은 업체 이름 = 그 경로에 표 줄
+ page = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone.ts").read_text(encoding="utf-8")
+ assert "createDiagram(projectId, open, (init) => addRoute(projectId, init))" in page
+ assert "children: [diagram.root, none, table.root]" in page and "diagram.refresh();" in page
+ for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts"):
+ assert (
+ len((ROOT / f"B08_Quantity/B08_Quantity_{name}").read_text("utf-8").splitlines()) <= 700
+ )