From 924b1f61ebe6e0e76c2c77b4a7e642d556cb97e0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:57:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=20=E2=80=94=20=EA=B3=B5=EC=82=AC=EC=A7=80=20=C2=B7=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=A0=90=20=C2=B7=20=EC=97=85=EC=B2=B4=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=EB=B0=A9=EC=9C=84=20=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=C2=B7=20=EC=84=A0=20km=20=EA=B8=80=20=C2=B7=20=EC=83=81?= =?UTF-8?q?=EC=9E=90=20=EB=88=84=EB=A6=84=20=C2=B7=20[+]=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EC=B6=94=EA=B0=80=20(244-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체 · 선마다 「도로 구분 km」 - 업체 자리 = 기준점(없으면 공사지) 기준 지도 방위 — 경위도 방위각 › 8방 › 빈 방위 차례 · 같은 방위 나란히 - 업체 상자 누름 = 그 경로 하위 탭 · 공사지 · 기준점 [+] = 작은 창(항목 · 업체 · 목적지 · 방위) → 경로 탭 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 221 ++++++++++++++++++ B08_Quantity/B08_Quantity_UI_Milestone.css | 71 ++++++ B08_Quantity/B08_Quantity_UI_Milestone.ts | 17 +- .../B08_Quantity_UI_Milestone_Diagram.ts | 174 ++++++++++++++ .../test_244_2_b08_milestone_diagram.py | 154 ++++++++++++ 5 files changed, 632 insertions(+), 5 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Milestone_Layout.ts create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts create mode 100644 resources/tester/test_244_2_b08_milestone_diagram.py 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 + )