Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-10 20:57:44 +09:00
5 changed files with 632 additions and 5 deletions
@@ -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<Dir, number> = {
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<string, [number, number]>();
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<string>();
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],
};
}
@@ -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;
}
+12 -5
View File
@@ -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<MilestoneRoute> = {}):
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);
}
@@ -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<K extends keyof SVGElementTagNameMap>(
tag: K,
attrs: Record<string, string | number>,
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<MilestoneRoute>) => 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<MilestoneRoute>) => 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 };
}
@@ -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
)