feat(B08): 거리이정표 다이어그램 넉넉한 고정 바탕 · 시점 → 중점 선 · 상자 주소 · 좌표 · 여유 자리 피함(244-12)

- 바탕 = 기준점 · 공사지 둘레 거래처 자리까지 담는 고정 범위 · 넘는 상자가 있을 때만 넓힘
- 시점 → 중점 선 = 공사지 바탕 위(가림 없음) · 시점 · 중점 상자 작은 글씨 주소 · 좌표(소수 5자리)
- 중점 주소 = 지금 중점 측점(site-address ?mid_m=) · 없으면 좌표만
- 거래처 상자 = 항목 · 이름 · 주소 세 줄 · 길면 줄임표 + 호버
- 상자 사이 여유(겹치면 바깥으로 밀어 냄) · 선 = 상자 테두리에서 끊김 · 선 글 = 상자 · 다른 선 · 다른 글 피한 자리

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-10 23:13:07 +09:00
co-authored by Claude Opus 5.5
parent 916d610ec8
commit 0b698cb1b6
8 changed files with 577 additions and 99 deletions
+270 -65
View File
@@ -11,6 +11,10 @@
* 화면 = 북 위(SVG y 아래로 = 남)
* 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌)
* [길찾기] 자동 km(244-7) = `setAutoKm` — 사용자 km 가 있으면 그 값 · 다르면 호버 「자동 값 ○○ km」
* 바탕(244-12) = 공사지 둘레 거래처 · 기준점 자리까지 담는 고정 범위 `BASE_VIEW` · 넘는 상자가 생길 때만 넓힘
* 작은 글씨(244-12) = 시점 · 중점 주소 · 좌표(위도, 경도 소수 5자리) · 거래처 주소 — 길면 줄임표(`clip`) + 호버
* 여유(244-12 ④) = 상자 사이 GAP(겹치면 바깥 방향으로 밀어 냄) · 선 = 상자 테두리에서 끊김 · 선 글 = 상자 ·
* 다른 선 · 다른 글을 피한 자리(`placeLabels`) · 상자 안 글 = 안쪽 여백 넘으면 줄임표
* ========================================================================== */
import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc";
@@ -25,6 +29,10 @@ export interface DiagramBox {
label: string;
/** 거래처 상자 윗줄 — 표 줄 항목(244-6 · 예 「장비」) */
items?: string;
/** 이름 아래 작은 글씨 줄(244-12 · 주소 · 좌표) */
notes?: string[];
/** 호버 = 줄이지 않은 주소 */
tip?: string;
}
export interface DiagramLine {
@@ -37,6 +45,11 @@ export interface DiagramLine {
labels: string[];
/** 글마다 호버(사용자 km ≠ 자동 값이면 「자동 값 ○○ km」 · 아니면 "") */
tips: string[];
/** 선 글 자리(가운데 · 244-12 ④ 상자 · 다른 선 · 다른 글 피함) · 폭 · 높이 */
lx: number;
ly: number;
lw: number;
lh: number;
}
export interface DiagramLayout {
@@ -60,12 +73,96 @@ export const DIR_DEG: Record<Dir, number> = {
};
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;
const SITE = { w: 380, h: 106 };
const NODE = { w: 124, h: 58 };
const BOX = { w: 148, h: 58 };
const R_BASE = 390;
const R_ROUTE = 280;
const PAD = 48;
/** 바탕 반폭 · 반높이 — 기준점 · 공사지 둘레 거래처(같은 거리)가 어느 방위로 와도 담김 */
const REACH = R_BASE;
export const BASE_VIEW: [number, number, number, number] = [
-(REACH + BOX.w / 2 + PAD),
-(REACH + BOX.h / 2 + PAD),
2 * (REACH + BOX.w / 2 + PAD),
2 * (REACH + BOX.h / 2 + PAD),
];
/** 여유(244-12 ④) — 상자 사이 · 선 끝 · 상자 안 글 */
export const GAP = 12;
const LINE_GAP = 4;
const INSET = 8;
/** 글 크기(px) — 화면 CSS 와 같음(이름 13 · 항목 · 선 글 12 · 작은 글씨 10) */
const FONT = { label: 13, items: 12, km: 12, note: 10 };
const KM_LINE = 16;
export interface Rect {
x: number;
y: number;
w: number;
h: number;
}
/** 글 폭 어림 — 한글 · 한자 = 글 크기 · 나머지 = 0.6 배 */
export const textW = (text: string, size: number): number =>
[...text].reduce(
(w, ch) =>
w + (/[\u1100-\u11ff\u3130-\u318f\u3400-\u9fff\uac00-\ud7a3]/.test(ch) ? 1 : 0.6) * size,
0,
);
/** 한 줄 — 폭 넘으면 줄임표 */
export function clip(text: string, maxW: number, size: number): string {
if (textW(text, size) <= maxW) return text;
let out = "";
for (const ch of text) {
if (textW(`${out}${ch}…`, size) > maxW) break;
out += ch;
}
return `${out}…`;
}
/** 상자 작은 글씨 — 주소(줄임) · 좌표(위도, 경도 소수 5자리) */
const notesOf = (w: number, address: string | null | undefined, lonlat?: LonLat): string[] => [
...(address ? [clip(address, w - 2 * INSET, FONT.note)] : []),
...(lonlat ? [`${lonlat[1].toFixed(5)}, ${lonlat[0].toFixed(5)}`] : []),
];
/** 두 네모가 gap 안으로 붙거나 겹침 */
export const hits = (a: Rect, b: Rect, gap = 0): boolean =>
a.x < b.x + b.w + gap && b.x < a.x + a.w + gap && a.y < b.y + b.h + gap && b.y < a.y + a.h + gap;
/** 선분이 네모에 들어가는 자리(0~1 · Liang–Barsky) · 안 지나면 null */
function segEnter(x1: number, y1: number, x2: number, y2: number, r: Rect): number | null {
let t0 = 0;
let t1 = 1;
const dx = x2 - x1;
const dy = y2 - y1;
for (const [p, q] of [
[-dx, x1 - r.x],
[dx, r.x + r.w - x1],
[-dy, y1 - r.y],
[dy, r.y + r.h - y1],
]) {
if (p === 0) {
if (q < 0) return null;
continue;
}
const t = q / p;
if (p < 0) t0 = Math.max(t0, t);
else t1 = Math.min(t1, t);
if (t0 > t1) return null;
}
return t0;
}
/** 선분이 네모를 지남 */
export const segHits = (x1: number, y1: number, x2: number, y2: number, r: Rect): boolean =>
segEnter(x1, y1, x2, y2, r) !== null;
/** 상자 가운데 → 테두리까지 = 선 방향(dx, dy)의 몇 배 */
const toEdge = (b: Rect, dx: number, dy: number): number =>
Math.min(dx ? b.w / 2 / Math.abs(dx) : Infinity, dy ? b.h / 2 / Math.abs(dy) : Infinity);
/** 경위도 두 점 → 방위각(도 · 북 0 · 시계) — 짧은 거리 평면 근사 */
export function bearingDeg(from: [number, number], to: [number, number]): number {
@@ -169,25 +266,105 @@ const unit = (deg: number): [number, number] => {
return [Math.sin(rad), -Math.cos(rad)];
};
/** 선 = 두 상자 테두리에서 끊김(④ · 상자 안으로 안 들어감) · end = 끝 테두리(바깥 → 시점 = 공사지 바탕 테두리 ·
* 바탕 안 중점 · 시점 상자를 가로지르지 않음) · 글 자리는 `placeLabels` */
function edgeLine(a: DiagramBox, b: DiagramBox, legs: Leg[], end: Rect = b): DiagramLine {
const [ax, ay] = [a.x + a.w / 2, a.y + a.h / 2];
const [bx, by] = [b.x + b.w / 2, b.y + b.h / 2];
const [dx, dy] = [bx - ax, by - ay];
const len = Math.hypot(dx, dy) || 1;
const t1 = Math.min(toEdge(a, dx, dy) + LINE_GAP / len, 0.5);
const t2 = Math.max((segEnter(ax, ay, bx, by, end) ?? 1) - LINE_GAP / len, t1);
const labels = legText(legs);
return {
from: a.id,
to: b.id,
x1: ax + dx * t1,
y1: ay + dy * t1,
x2: ax + dx * t2,
y2: ay + dy * t2,
labels,
tips: legs.map(legTip),
lx: (ax + bx) / 2,
ly: (ay + by) / 2,
lw: labels.length ? Math.max(...labels.map((l) => textW(l, FONT.km))) + 8 : 0,
lh: labels.length * KM_LINE + 2,
};
}
export const labelRect = (ln: DiagramLine): Rect => ({
x: ln.lx - ln.lw / 2,
y: ln.ly - ln.lh / 2,
w: ln.lw,
h: ln.lh,
});
/** 선 글 자리(④) — 선 위 가운데부터 앞뒤 · 위아래 차례로 보아 상자 · 다른 선 · 다른 글을 안 덮는 첫 자리
* (시점 → 중점 글은 공사지 바탕 안이 제자리) · 다 덮이면 가장 적게 덮는 자리 */
function placeLabels(lines: DiagramLine[], boxes: DiagramBox[]): void {
const placed: Rect[] = [];
for (const ln of lines) {
if (!ln.labels.length) continue;
const [dx, dy] = [ln.x2 - ln.x1, ln.y2 - ln.y1];
const len = Math.hypot(dx, dy) || 1;
const [px, py] = [-dy / len, dx / len];
const side = (Math.abs(px) * ln.lw + Math.abs(py) * ln.lh) / 2 + 4;
const score = (r: Rect): number =>
boxes.filter((b) => !(b.kind === "site" && ln.from === "start") && hits(r, b, 2)).length +
lines.filter((o) => o !== ln && segHits(o.x1, o.y1, o.x2, o.y2, r)).length +
placed.filter((p) => hits(r, p, 2)).length;
let best: { r: Rect; s: number; x: number; y: number } | null = null;
for (const t of [0.5, 0.4, 0.6, 0.3, 0.7, 0.2, 0.8]) {
for (const o of [0, side, -side]) {
const x = ln.x1 + dx * t + px * o;
const y = ln.y1 + dy * t + py * o;
const r = labelRect({ ...ln, lx: x, ly: y });
const s = score(r);
if (!best || s < best.s) best = { r, s, x, y };
if (s === 0) break;
}
if (best?.s === 0) break;
}
ln.lx = best!.x;
ln.ly = best!.y;
placed.push(best!.r);
}
}
export function layoutMilestone(
data: MilestoneData,
site: { start_lonlat?: LonLat; mid_lonlat?: LonLat } | null,
site: {
start_lonlat?: LonLat;
mid_lonlat?: LonLat;
start_address?: string | null;
mid_address?: string | null;
} | null,
midKm: number,
): DiagramLayout {
const boxes: DiagramBox[] = [];
const lines: DiagramLine[] = [];
const center = new Map<string, [number, number]>();
const put = (box: DiagramBox): void => {
const pending: Array<[string, string, Leg[]]> = [];
const byId = new Map<string, DiagramBox>();
const center = (id: string): [number, number] => {
const b = byId.get(id)!;
return [b.x + b.w / 2, b.y + b.h / 2];
};
/** 상자 놓기 — 상자 안 글 줄임(안쪽 여백) · out = 바깥 방향(겹치면 GAP 날 때까지 그쪽으로 밀어 냄) */
const put = (box: DiagramBox, out?: [number, number]): void => {
const inner = box.w - 2 * INSET;
box.label = clip(box.label, inner, FONT.label);
if (box.items) box.items = clip(box.items, inner, FONT.items);
for (let i = 0; out && i < 400 && boxes.some((b) => hits(box, b, GAP)); i++) {
box.x += out[0] * 4;
box.y += out[1] * 4;
}
boxes.push(box);
center.set(box.id, [box.x + box.w / 2, box.y + box.h / 2]);
byId.set(box.id, box);
};
const line = (from: string, to: string, legs: Leg[]): void => {
const [x1, y1] = center.get(from)!;
const [x2, y2] = center.get(to)!;
lines.push({ from, to, x1, y1, x2, y2, labels: legText(legs), tips: legs.map(legTip) });
pending.push([from, to, legs]);
};
// 공사지 = 가운데 컨테이너 · 안에 시점(왼) · 중점(오른)
// 공사지 = 가운데 컨테이너 · 안에 시점(왼) · 중점(오른) · 사이 = 시점 → 중점 선 · 글
put({
id: "site",
kind: "site",
@@ -197,24 +374,22 @@ export function layoutMilestone(
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: "중점",
});
for (const [id, label, x, address, lonlat] of [
["start", "시점", -SITE.w / 2 + 12, site?.start_address, site?.start_lonlat],
["mid", "중점", SITE.w / 2 - 12 - NODE.w, site?.mid_address, site?.mid_lonlat],
] as const) {
put({
id,
kind: id,
x,
y: -SITE.h / 2 + 34,
w: NODE.w,
h: NODE.h,
label,
notes: notesOf(NODE.w, address, lonlat),
tip: address ?? undefined,
});
}
line("start", "mid", [{ road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }]);
const startLL = site?.start_lonlat ?? null;
@@ -227,22 +402,33 @@ export function layoutMilestone(
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 || "기준점",
});
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 || "기준점",
},
[ux, uy],
);
line("base", "start", data.base.legs);
}
// 거래처 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히
const groups = new Map<
string,
Array<{ id: string; label: string; items: string; anchor: string; deg: number; legs: Leg[] }>
Array<{
id: string;
label: string;
items: string;
address: string;
anchor: string;
deg: number;
legs: Leg[];
}>
>();
const used = new Set<string>();
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],
};
}
@@ -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:
@@ -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);
@@ -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}`;
@@ -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;
@@ -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<P
}
export const siteInfo = (): SiteInfo | null => saved?.site ?? null;
/** 시점 · 중점 주소(244-12 · 다이어그램 상자 작은 글씨) — 중점 측점마다 한 번 서버 VWorld(뒤에서 ·
* 오면 다시 그림) · 아직 · 실패 = null(좌표만) */
const siteAddresses = new Map<string, SiteAddress | null>();
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<SiteAddress>(`/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<string, string> => saved?.images ?? {};
/** 보이는 그림이 사용자 그림이고 자동 그림도 있음 = 「자동 그림으로 되돌리기」 */
@@ -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)
@@ -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_표_폭_다이어그램_폭_좌측_카드_없음():