();
const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`;
@@ -267,41 +453,60 @@ export function layoutMilestone(
const key = keyOf(anchor, d);
if (!groups.has(key)) groups.set(key, []);
const items = [...new Set(r.rows.map((row) => row.item.trim()).filter(Boolean))].join(" · ");
- groups
- .get(key)!
- .push({ id: r.id, label: r.name || "거래처", items, anchor, deg: d, legs: r.legs });
+ groups.get(key)!.push({
+ id: r.id,
+ label: r.name || "거래처",
+ items,
+ address: r.address,
+ 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 [ax, ay] = center(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 spread = Math.abs(ux) * BOX.h + Math.abs(uy) * BOX.w + GAP;
+ const off = (k - (group.length - 1) / 2) * spread;
+ const reach = g.anchor === "site" ? R_BASE : 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,
- items: g.items,
- });
+ 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,
+ items: g.items,
+ notes: notesOf(BOX.w, g.address),
+ tip: g.address,
+ },
+ [ux, uy],
+ );
line(g.id, g.anchor === "base" ? "base" : "start", 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;
+ const lines = pending.map(([from, to, legs]) =>
+ edgeLine(byId.get(from)!, byId.get(to)!, legs, byId.get(to === "start" ? "site" : to)!),
+ );
+ placeLabels(lines, boxes);
+
+ // 바탕 = 고정 범위 · 넘는 상자가 있을 때만 그쪽으로 넓힘(새 상자가 와도 다시 줌아웃 없음)
+ const [bx, by, bw, bh] = BASE_VIEW;
+ const xs = [bx + PAD, bx + bw - PAD, ...boxes.flatMap((b) => [b.x, b.x + b.w])];
+ const ys = [by + PAD, by + bh - PAD, ...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],
+ view: [minX, minY, Math.max(...xs) + PAD - minX, Math.max(...ys) + PAD - minY],
};
}
diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py
index 43465fa21..6f962871b 100644
--- a/B08_Quantity/B08_Quantity_Router_Milestone.py
+++ b/B08_Quantity/B08_Quantity_Router_Milestone.py
@@ -10,7 +10,8 @@
표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` ·
좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄
GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 ·
- 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함)
+ 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) · ?mid_m= 다이어그램
+ 지금 중점 측점(244-12 · 상자 작은 글씨)
POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오
길찾기 → km(소수 1자리) · 실패 · 키 없음 = 까닭 한 줄(서버 단독 · 키 서버만 · 저장 안 함)
+ 경로 좌표 [위도, 경도] 목록(244-9 · 카카오 지도 길 선 · 그때그때 · 저장 안 함)
@@ -245,11 +246,15 @@ async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]:
@router.get(PATH + "/site-address")
-async def milestone_site_address(project_id: UUID) -> dict[str, Any]:
- """시점 모달 — 프로젝트 등록 때 사업지역 · 시점 · 중점 경위도와 VWorld 주소."""
+async def milestone_site_address(
+ project_id: UUID, mid_m: Annotated[float | None, Query(ge=0)] = None
+) -> dict[str, Any]:
+ """시점 모달 — 프로젝트 등록 때 사업지역 · 시점 · 중점 경위도와 VWorld 주소.
+ mid_m = 다이어그램 지금 중점 측점(캐시 · 244-12) · 없으면 저장본."""
root = await _project_root(project_id)
data = await asyncio.to_thread(read_data, root)
- site = await asyncio.to_thread(site_info, root, data["site"]["mid_station_m"])
+ mid = data["site"]["mid_station_m"] if mid_m is None else mid_m
+ site = await asyncio.to_thread(site_info, root, mid)
project = await get_project_by_id(str(project_id)) or {}
async def where(lonlat: list[float] | None) -> str | None:
diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css
index 1cac2d074..1581b8005 100644
--- a/B08_Quantity/B08_Quantity_UI_Milestone.css
+++ b/B08_Quantity/B08_Quantity_UI_Milestone.css
@@ -160,6 +160,12 @@
font-size: 12px;
}
+/* 상자 작은 글씨 = 주소 · 좌표(244-12) */
+.b08-milestone__note {
+ fill: var(--color-text-secondary);
+ font-size: 10px;
+}
+
/* 도로 구분 안 고른 줄 = 합계 칸 「도로 구분 고르기」(244-7) */
.b08-milestone .is-pick-road {
color: var(--color-danger);
diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts
index c4e7cd91b..55bf4e8a9 100644
--- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts
+++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts
@@ -9,6 +9,8 @@
* 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 없으면 위에 [기준점 +](244-6)
* 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소)
* 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7)
+ * 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점
+ * 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소
* ========================================================================== */
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
@@ -33,6 +35,7 @@ import {
liveMilestone,
putMilestone,
roadOptions,
+ siteAddress,
siteInfo,
} from "./B08_Quantity_UI_Milestone_State";
@@ -148,7 +151,18 @@ export function createDiagram(
const data = liveMilestone(projectId);
const site = siteInfo();
const mk = midKm(data, site?.mid_default_m ?? null);
- const layout = layoutMilestone(data, site, mk);
+ // 시점 · 중점 주소 · 좌표(244-12) — 지금 중점 측점으로 서버 VWorld(뒤에서 · 오면 다시 그림)
+ const addr = siteAddress(projectId, data.site.mid_station_m ?? site?.mid_default_m ?? null);
+ const layout = layoutMilestone(
+ data,
+ {
+ start_lonlat: addr?.start_lonlat ?? site?.start_lonlat,
+ mid_lonlat: addr?.mid_lonlat ?? site?.mid_lonlat,
+ start_address: addr?.start_address,
+ mid_address: addr?.mid_address,
+ },
+ mk,
+ );
const [vx, vy, vw, vh] = layout.view;
// viewBox 원점 0 — 줌 · 팬 계산이 0 기준 · 그림은 안쪽 묶음에서 옮김
const root = svg("svg", {
@@ -166,37 +180,36 @@ export function createDiagram(
const shift = svg("g", { transform: `translate(${-vx} ${-vy})` });
layer.append(shift);
root.append(layer);
+ // 선 = 공사지 바탕 위 · 시점 · 중점 · 거래처 상자 아래(244-12 · 시점 → 중점 선이 바탕에 안 가림)
+ const lineLayer = svg("g", {});
for (const ln of layout.lines) {
- shift.append(
+ lineLayer.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.kind === "route" && box.items ? "" : box.label,
- ),
+ // 글 줄 = 항목(거래처 윗줄) · 이름 · 작은 글씨(주소 · 좌표) — 상자 가운데 맞춤
+ const rows: Array<[string, string]> = [
+ ...(box.kind === "route" && box.items
+ ? [[box.items, "b08-milestone__items"] as [string, string]]
+ : []),
+ [box.label, ""],
+ ...(box.notes ?? []).map((n): [string, string] => [n, "b08-milestone__note"]),
+ ];
+ const x = box.x + box.w / 2;
+ const text = svg("text", {
+ x,
+ y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2 - ((rows.length - 1) * 15) / 2,
+ "text-anchor": "middle",
+ "dominant-baseline": "central",
+ });
+ rows.forEach(([row, cls], i) =>
+ text.append(svg("tspan", { x, dy: i ? 15 : 0, ...(cls ? { class: cls } : {}) }, row)),
);
- if (box.kind === "route" && box.items) {
- // 거래처 상자 = 항목(윗줄) + 거래처 이름(아랫줄)
- const x = box.x + box.w / 2;
- const two = g.lastChild as SVGTextElement;
- two.setAttribute("y", String(box.y + box.h / 2 - 8));
- two.append(
- svg("tspan", { x, class: "b08-milestone__items" }, box.items),
- svg("tspan", { x, dy: 16 }, box.label),
- );
- }
+ g.append(svg("rect", { x: box.x, y: box.y, width: box.w, height: box.h, rx: 6 }), text);
+ if (box.tip) g.append(svg("title", {}, box.tip)); // 줄이지 않은 주소
// 상자 누름 = 모달(탭 이동 아님)
const info = (): void => INFO[box.kind](box.id);
g.setAttribute("role", "button");
@@ -209,12 +222,20 @@ export function createDiagram(
if (box.kind === "site") g.append(plus(box, false));
if (box.kind === "base") g.append(plus(box, true));
shift.append(g);
+ if (box.kind === "site") shift.append(lineLayer);
}
// 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄
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" });
+ // 자리 = 레이아웃이 상자 · 다른 선 · 다른 글을 피해 고른 곳(244-12 ④)
+ const x = ln.lx;
+ const y = ln.ly - ((ln.labels.length - 1) * 16) / 2;
+ const text = svg("text", {
+ class: "b08-milestone__km",
+ x,
+ y,
+ "text-anchor": "middle",
+ "dominant-baseline": "central",
+ });
ln.labels.forEach((label, i) => {
const span = svg("tspan", { x, dy: i ? 16 : 0 }, label);
span.dataset.b08MilestoneLeg = `${ln.from}:${i}`;
diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts
index 411f9c173..fc0ed923c 100644
--- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts
+++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts
@@ -378,7 +378,7 @@ export function openBaseInfo(projectId: string, create = false): void {
});
}
-interface SiteAddress {
+export interface SiteAddress {
region: string;
start_lonlat: [number, number] | null;
mid_lonlat: [number, number] | null;
diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts
index a92539d1b..bdb3d9188 100644
--- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts
+++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts
@@ -19,6 +19,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { request } from "./B08_Quantity_UI_Hydro";
import { ROADS, type MilestoneData } from "./B08_Quantity_Milestone_Calc";
import { kmSegments, mapMarks, putAutoKm, type KmSegment } from "./B08_Quantity_Milestone_Layout";
+import type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal";
export interface SiteInfo {
length_m: number | null;
@@ -57,6 +58,25 @@ export async function loadMilestone(projectId: string, force = false): Promise saved?.site ?? null;
+
+/** 시점 · 중점 주소(244-12 · 다이어그램 상자 작은 글씨) — 중점 측점마다 한 번 서버 VWorld(뒤에서 ·
+ * 오면 다시 그림) · 아직 · 실패 = null(좌표만) */
+const siteAddresses = new Map();
+export function siteAddress(projectId: string, midM: number | null): SiteAddress | null {
+ const key = `${projectId}:${midM ?? ""}`;
+ if (!siteAddresses.has(key)) {
+ siteAddresses.set(key, null);
+ const query = midM == null ? "" : `?mid_m=${midM}`;
+ request(`/projects/${projectId}/quantity/milestone/site-address${query}`).then(
+ (s) => {
+ siteAddresses.set(key, s);
+ changes.dispatchEvent(new Event("change"));
+ },
+ () => undefined,
+ );
+ }
+ return siteAddresses.get(key) ?? null;
+}
export const routeImages = (): Record => saved?.images ?? {};
/** 보이는 그림이 사용자 그림이고 자동 그림도 있음 = 「자동 그림으로 되돌리기」 */
diff --git a/resources/tester/test_244_12_b08_milestone_base_view.py b/resources/tester/test_244_12_b08_milestone_base_view.py
new file mode 100644
index 000000000..2c83b6a93
--- /dev/null
+++ b/resources/tester/test_244_12_b08_milestone_base_view.py
@@ -0,0 +1,221 @@
+"""244-12 B08 거리이정표 다이어그램(사용자 142~144번) — ① 넉넉한 고정 바탕(공사지만 있어도
+거래처 · 기준점 자리까지 · 넘는 상자가 생길 때만 넓힘) ② 시점 → 중점 선(공사지 바탕 위) · 시점 ·
+중점 상자 작은 글씨 주소 · 좌표(위도, 경도 소수 5자리) · 중점 주소 = 지금 중점 측점(?mid_m=)
+③ 거래처 상자 = 항목 · 이름 · 주소(줄임표 + 호버).
+"""
+
+import json
+import re
+import subprocess
+import sys
+from pathlib import Path
+from uuid import uuid4
+
+ROOT = Path(__file__).resolve().parents[2]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402
+
+IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
+LONG = "경상남도 거창군 북상면 병곡리 968-31"
+
+
+def _read(rel):
+ return (ROOT / rel).read_text(encoding="utf-8")
+
+
+def _node(expr):
+ src = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")
+ result = subprocess.run(
+ ["node", "--experimental-strip-types", "--no-warnings",
+ "--input-type=module-typescript", "-e",
+ IMPORTS.sub("", src) + f"\nconsole.log(JSON.stringify({expr}));"],
+ 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, dir=None, via=True, address="", item=""):
+ return {"id": rid, "name": rid, "address": address, "via_base": via, "dir": dir,
+ "lon": None, "lat": None, "legs": [],
+ "rows": [{"item": item, "dest": "start", "note": ""}]} # fmt: skip
+
+
+def _data(routes, base=False):
+ return {
+ "site": {"mid_station_m": None, "mid_road": "비포장"},
+ "base": {"enabled": base, "name": "", "address": "", "dir": None, "legs": []},
+ "routes": routes,
+ }
+
+
+def _clipped(note, box_w):
+ # 줄임표 · 안쪽 여백(양쪽 8) 안 · 앞부분 그대로
+ assert note.endswith("…") and LONG.startswith(note[:-1])
+ assert _node(f'textW("{note}", 10)') <= box_w - 16
+
+
+def _lay(data, site=None):
+ return _node(f"layoutMilestone({json.dumps(data, ensure_ascii=False)}, {json.dumps(site)}, 1)")
+
+
+def test_바탕_고정_범위_넘을_때만_넓힘():
+ base_view = _node("BASE_VIEW")
+ assert _lay(_data([]))["view"] == base_view # 공사지만 있어도 넉넉한 바탕
+ vx, vy, vw, vh = base_view
+ assert vw >= 900 and vh >= 800
+ # 공사지 둘레 8방 거래처 · 기준점(서) — 바탕 그대로(다시 줌아웃 없음)
+ dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
+ for lay in (_lay(_data([_route(d, d, via=False) for d in dirs])), _lay(_data([], base=True))):
+ assert lay["view"] == base_view
+ for b in lay["boxes"]:
+ assert vx <= b["x"] and b["x"] + b["w"] <= vx + vw
+ assert vy <= b["y"] and b["y"] + b["h"] <= vy + vh
+ # 기준점 너머 거래처 = 바탕 넘음 → 그쪽만 넓힘
+ lay = _lay(_data([_route("far", "W")], base=True))
+ x, y, w, h = lay["view"]
+ far = next(b for b in lay["boxes"] if b["id"] == "far")
+ assert x < vx and x <= far["x"] - 48 and x + w == vx + vw and (y, h) == (vy, vh)
+
+
+def test_시점_중점_선_상자_주소_좌표():
+ site = {"start_lonlat": [127.812345678, 35.7], "mid_lonlat": [127.9, 35.712341],
+ "start_address": LONG, "mid_address": None} # fmt: skip
+ lay = _lay(_data([]), site)
+ boxes = {b["id"]: b for b in lay["boxes"]}
+ note, coord = boxes["start"]["notes"]
+ _clipped(note, boxes["start"]["w"])
+ assert coord == "35.70000, 127.81235"
+ assert boxes["start"]["tip"] == LONG # 호버 = 줄이지 않은 주소
+ assert boxes["mid"]["notes"] == ["35.71234, 127.90000"] # 주소 없음 = 좌표만
+ assert _lay(_data([]))["boxes"][1]["notes"] == [] # 좌표도 없음 = 빈 줄
+ line = next(ln for ln in lay["lines"] if ln["from"] == "start")
+ assert line["to"] == "mid" and line["labels"] == ["비포장 1km"]
+ s, m = boxes["start"], boxes["mid"]
+ assert m["x"] - (s["x"] + s["w"]) >= 100 # 선 글 자리
+ for b in (s, m): # 상자 = 공사지 안
+ site_box = boxes["site"]
+ assert site_box["x"] <= b["x"] and b["x"] + b["w"] <= site_box["x"] + site_box["w"]
+ assert site_box["y"] + 20 < b["y"] and b["y"] + b["h"] <= site_box["y"] + site_box["h"]
+
+
+def test_거래처_상자_항목_이름_주소():
+ lay = _lay(_data([_route("g", "N", via=False, address=LONG, item="장비"),
+ _route("k", "E", via=False, address="거창읍")])) # fmt: skip
+ boxes = {b["id"]: b for b in lay["boxes"]}
+ assert (boxes["g"]["items"], boxes["g"]["label"]) == ("장비", "g")
+ _clipped(boxes["g"]["notes"][0], boxes["g"]["w"])
+ assert boxes["g"]["tip"] == LONG and boxes["k"]["notes"] == ["거창읍"]
+ assert _node('clip("가나다", 100, 10)') == "가나다"
+ assert _node(f'clip("{LONG}", 108, 10)') == "경상남도 거창군 북상…" # 한글 10 · 빈칸 6 · … 6
+
+
+def test_화면_선_순서_글_줄_중점_주소():
+ ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts")
+ # 선 = 공사지 바탕 뒤 · 다른 상자 앞(시점 → 중점 선 보임)
+ assert 'if (box.kind === "site") shift.append(lineLayer);' in ui
+ assert '...(box.notes ?? []).map((n): [string, string] => [n, "b08-milestone__note"])' in ui
+ assert 'if (box.tip) g.append(svg("title", {}, box.tip));' in ui
+ assert "siteAddress(projectId, data.site.mid_station_m ?? site?.mid_default_m ?? null)" in ui
+ state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
+ assert "site-address${query}" in state and "?mid_m=${midM}" in state
+ assert ".b08-milestone__note {" in _read("B08_Quantity/B08_Quantity_UI_Milestone.css")
+ for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts", "UI_Milestone_State.ts"):
+ assert len(_read(f"B08_Quantity/B08_Quantity_{name}").splitlines()) <= 700
+
+
+def test_중점_주소_지금_측점(tmp_path, monkeypatch):
+ from fastapi import FastAPI
+ from fastapi.testclient import TestClient
+
+ async def root(_pid):
+ return tmp_path
+
+ async def project(_pid):
+ return {"region": ""}
+
+ async def where(lon, lat):
+ return f"주소 {lon:.5f}"
+
+ monkeypatch.setattr(ms, "_project_root", root)
+ monkeypatch.setattr(ms, "get_project_by_id", project)
+ monkeypatch.setattr(ms, "reverse_geocode", where)
+ app = FastAPI()
+ app.include_router(ms.router)
+ client = TestClient(app)
+ path = tmp_path / "B05_Profile" / "route" / "planned_route.csv"
+ path.parent.mkdir(parents=True)
+ path.write_text("sequence,x,y\n0,208000,367000\n1,209000,367000", encoding="utf-8")
+ url = f"/api/projects/{uuid4()}/quantity/milestone/site-address"
+ half = client.get(url).json() # 저장본 없음 = 기본(연장 ÷ 2)
+ near = client.get(url, params={"mid_m": 100}).json()
+ assert near["start_lonlat"] == half["start_lonlat"]
+ assert near["start_lonlat"][0] < near["mid_lonlat"][0] < half["mid_lonlat"][0]
+ assert near["mid_address"] == f"주소 {near['mid_lonlat'][0]:.5f}"
+ assert client.get(url, params={"mid_m": -1}).status_code == 422
+
+
+def _hits(a, b, gap=0):
+ return (a["x"] < b["x"] + b["w"] + gap and b["x"] < a["x"] + a["w"] + gap
+ and a["y"] < b["y"] + b["h"] + gap and b["y"] < a["y"] + a["h"] + gap) # fmt: skip
+
+
+def _seg_hits(x1, y1, x2, y2, r):
+ t0, t1, dx, dy = 0.0, 1.0, x2 - x1, y2 - y1
+ for p, q in ((-dx, x1 - r["x"]), (dx, r["x"] + r["w"] - x1),
+ (-dy, y1 - r["y"]), (dy, r["y"] + r["h"] - y1)): # fmt: skip
+ if p == 0:
+ if q < 0:
+ return False
+ continue
+ t = q / p
+ t0, t1 = (max(t0, t), t1) if p < 0 else (t0, min(t1, t))
+ if t0 > t1:
+ return False
+ return True
+
+
+def test_여유_상자_선_글_겹침_0():
+ """④(145번) — 몰린 꼴(기준점 거침 셋 같은 방위 · 방위 없음 둘 · 공사지 동쪽 셋 ·
+ 서쪽 = 기준점 자리)."""
+ legs = [{"road": "2차포장", "km": 23.8}]
+ routes = [{**_route(f"n{i}", "N", address=LONG, item="장비"), "legs": legs} for i in range(3)]
+ routes += [{**_route(f"f{i}"), "legs": legs} for i in range(2)]
+ routes += [{**_route(f"e{i}", "E", via=False), "legs": legs} for i in range(3)]
+ routes += [{**_route("w", "W", via=False), "legs": legs}] # 기준점과 같은 방위 = 밀려남
+ data = _data(routes, base=True)
+ data["base"]["legs"] = [{"road": "1차포장", "km": 0.6}, {"road": "비포장", "km": 0.8}]
+ lay = _lay(data)
+ gap = _node("GAP")
+ boxes = lay["boxes"]
+ inside = {("site", "start"), ("site", "mid")}
+ for i, a in enumerate(boxes): # 상자 사이 여유(공사지 안 시점 · 중점만 품음)
+ for b in boxes[i + 1 :]:
+ if (a["id"], b["id"]) not in inside:
+ assert not _hits(a, b, gap - 0.01), (a["id"], b["id"])
+ for ln in lay["lines"]: # 선 끝 = 어느 상자 안도 아님(바깥 → 시점 = 공사지 테두리에서 끊김)
+ for x, y in ((ln["x1"], ln["y1"]), (ln["x2"], ln["y2"])):
+ for b in boxes:
+ if not (b["id"] == "site" and ln["from"] == "start"):
+ assert not (b["x"] < x < b["x"] + b["w"] and b["y"] < y < b["y"] + b["h"]), (
+ ln["from"],
+ ln["to"],
+ b["id"],
+ x,
+ y,
+ )
+ rects = {ln["from"]: _node(f"labelRect({json.dumps(ln, ensure_ascii=False)})")
+ for ln in lay["lines"] if ln["labels"]} # fmt: skip
+ for k, r in rects.items(): # 선 글 ↔ 다른 선
+ for o in lay["lines"]:
+ if o["from"] != k:
+ assert not _seg_hits(o["x1"], o["y1"], o["x2"], o["y2"], r), (k, o["from"])
+ keys = list(rects)
+ for i, k in enumerate(keys): # 선 글 ↔ 상자 · 다른 선 글
+ for b in boxes:
+ if not (b["kind"] == "site" and k == "start"):
+ assert not _hits(rects[k], b), (k, b["id"])
+ for j in keys[i + 1 :]:
+ assert not _hits(rects[k], rects[j]), (k, j)
diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py
index 4d459a64f..341e66d4b 100644
--- a/resources/tester/test_244_6_b08_milestone_pan_client.py
+++ b/resources/tester/test_244_6_b08_milestone_pan_client.py
@@ -97,8 +97,8 @@ def test_거래처_상자_항목_글():
assert boxes["g"]["items"] == "장비 · 골재" and boxes["g"]["label"] == "거창군청"
assert boxes["k"]["items"] == "" and boxes["k"]["label"] == "거래처"
ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts")
- assert 'svg("tspan", { x, class: "b08-milestone__items" }, box.items)' in ui
- assert 'svg("tspan", { x, dy: 16 }, box.label)' in ui
+ # 244-12 — 글 줄 = 항목(윗줄) · 이름 · 작은 글씨
+ assert '[box.items, "b08-milestone__items"]' in ui and '[box.label, ""]' in ui
def test_표_폭_다이어그램_폭_좌측_카드_없음():