feat(B08): 거리이정표 다이어그램 — 기준점 연결 점 · 주소 줄바꿈 · 선 글 두 줄 · 거리 비례 선 길이(254-2)

- 연결 점 = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소 + 알림)
- 기준점 상자 주소 · 주소 줄바꿈(최대 3줄) + 상자 높이 = 내용 · 항목 글 13px
- 선 글 = 구간마다 두 줄(도로 구분 / km) · 처음 자리 선 길이 = 100 + 90·log10(1 + km)(100 ~ 320)
- 밀어 냄 = 놓인 상자 · 놓인 선 둘 다 피함
- 시험 = test_254_2 새로 · 244 · 250 기대값(두 줄 · 짧아진 선) · 244_9 node 입력 = stdin

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-11 14:07:14 +09:00
co-authored by Claude Opus 5.5
parent 7fdbcc4794
commit 2584325b16
11 changed files with 289 additions and 69 deletions
+89 -23
View File
@@ -6,8 +6,12 @@
* 거래처 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 거래처 경위도(모달 주소 검색)가 있으면
* 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 ·
* 같은 방위 여럿 = 옆으로 나란히
* 기준점 자리 = 기준점 주소 좌표 → 공사지(시점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — 옛 8방 무시(244-5)
* 선 = 거래처 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다
* 기준점 자리 = 기준점 주소 좌표 → 잇는 곳(시점 · 다른 기준점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) —
* 옛 8방 무시(244-5) · 여럿(254) = 잇는 곳이 먼저 놓이게 사슬 차례
* 선 = 거래처 → to(기준점 · 시점) · 기준점 → to · 시점 → 중점 — 글 = 구간 줄마다 두 줄(1줄 도로 구분 ·
* 2줄 km · 254 · `legText` 가 줄바꿈 글자로 나눔)
* 처음 자리 선 길이(254) = 그 구간 km 비례(`lineLen` · 로그 · 최소 · 최대) — 잇는 곳 테두리 → 상자 테두리
* 상자 높이 = 내용(254 · 항목 · 이름 · 주소 줄바꿈 `wrap` 최대 3줄 · 좌표) · 기준점 상자도 주소
* 화면 = 북 위(SVG y 아래로 = 남)
* 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌)
* [길찾기] 자동 km(244-7) = `setAutoKm` — 사용자 km 가 있으면 그 값 · 다르면 호버 「자동 값 ○○ km」
@@ -77,11 +81,9 @@ const AUTO: Dir[] = ["N", "NW", "W", "SW", "S", "SE", "E", "NE"];
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;
/** 바탕 반폭 · 반높이 — 공사지 둘레 상자(짧은 구간 · 선 길이 최소쯤)가 어느 방위로 와도 담김 */
const REACH = 390;
export const BASE_VIEW: [number, number, number, number] = [
-(REACH + BOX.w / 2 + PAD),
-(REACH + BOX.h / 2 + PAD),
@@ -93,9 +95,17 @@ export const BASE_VIEW: [number, number, number, number] = [
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 };
/** 글 크기(px) — 화면 CSS 와 같음(이름 · 항목 13(254 · 거래처 글과 같음) · 선 글 12 · 작은 글씨 10) */
const FONT = { label: 13, items: 13, km: 12, note: 10 };
const KM_LINE = 16;
/** 상자 안 글 줄 간격 · 위아래 여백(254 상자 높이 = 줄 × ROW + ROW_PAD · 최소 BOX.h) */
export const ROW = 15;
const ROW_PAD = 13;
const NOTE_LINES = 3;
/** 선 길이(254) — 구간 km 비례(로그) · 최소 · 최대(너무 멀어지지 않게) · 잇는 곳 테두리 → 상자 테두리 */
export const lineLen = (km: number): number =>
Math.min(320, Math.max(100, 100 + 90 * Math.log10(1 + Math.max(0, km))));
export interface Rect {
x: number;
@@ -123,6 +133,36 @@ export function clip(text: string, maxW: number, size: number): string {
return `${out}…`;
}
/** 여러 줄(254) — 폭 넘으면 낱말(빈칸) 사이에서 · 긴 낱말은 글자에서 줄바꿈 · 최대 줄 넘으면 끝 줄 줄임표 */
export function wrap(text: string, maxW: number, size: number, maxLines = NOTE_LINES): string[] {
const out: string[] = [];
let line = "";
for (const word of text.trim().split(/\s+/).filter(Boolean)) {
const next = line ? `${line} ${word}` : word;
if (textW(next, size) <= maxW) {
line = next;
continue;
}
if (line) out.push(line);
line = word;
while (textW(line, size) > maxW) {
let cut = "";
for (const ch of line) {
if (textW(cut + ch, size) > maxW) break;
cut += ch;
}
out.push(cut);
line = line.slice(cut.length);
}
}
if (line) out.push(line);
if (out.length <= maxLines) return out;
return [...out.slice(0, maxLines - 1), clip(out.slice(maxLines - 1).join(" "), maxW, size)];
}
/** 상자 높이(254) = 글 줄(항목 · 이름 · 작은 글씨) · 최소 BOX.h */
const boxH = (rows: number): number => Math.max(BOX.h, rows * ROW + ROW_PAD);
/** 상자 작은 글씨 — 주소(줄임) · 좌표(위도, 경도 소수 5자리) */
const notesOf = (w: number, address: string | null | undefined, lonlat?: LonLat): string[] => [
...(address ? [clip(address, w - 2 * INSET, FONT.note)] : []),
@@ -162,7 +202,7 @@ export const segHits = (x1: number, y1: number, x2: number, y2: number, r: Rect)
segEnter(x1, y1, x2, y2, r) !== null;
/** 상자 가운데 → 테두리까지 = 선 방향(dx, dy)의 몇 배 */
const toEdge = (b: Rect, dx: number, dy: number): number =>
const toEdge = (b: { w: number; h: number }, dx: number, dy: number): number =>
Math.min(dx ? b.w / 2 / Math.abs(dx) : Infinity, dy ? b.h / 2 / Math.abs(dy) : Infinity);
/** 경위도 두 점 → 방위각(도 · 북 0 · 시계) — 짧은 거리 평면 근사 */
@@ -181,12 +221,19 @@ export function placeDeg(
return own.dir ? DIR_DEG[own.dir] : null;
}
/** 구간 km(Calc `legKm` 과 같음 · 형 import 만) */
const kmOf = (leg: Leg): number | null => leg.km ?? leg.auto_km ?? null;
/** 선 글 — 구간 줄마다 두 줄(254 · 1줄 도로 구분 · 2줄 km · 줄바꿈 글자로 나눔) */
export const legText = (legs: Leg[]): string[] =>
legs.map((leg) => {
const km = leg.km ?? leg.auto_km ?? null; // Calc `legKm` 과 같음(형 import 만)
return `${leg.road ?? "도로 구분 고르기"} ${km == null ? "-" : String(km)}km`;
const km = kmOf(leg);
return `${leg.road ?? "도로 구분 고르기"}\n${km == null ? "-" : String(km)}km`;
});
/** 구간 km 합(선 길이 · 254) */
const legsKm = (legs: Leg[]): number => legs.reduce((sum, leg) => sum + Number(kmOf(leg) ?? 0), 0);
/** 자동 값 호버 — 사용자 km 가 자동 값과 다를 때만 */
export const legTip = (leg: Leg): string =>
leg.auto_km != null && leg.km != null && leg.km !== leg.auto_km
@@ -309,6 +356,7 @@ function edgeLine(a: DiagramBox, b: DiagramBox, legs: Leg[], end: Rect = b): Dia
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);
const rows = labels.flatMap((l) => l.split("\n"));
return {
from: a.id,
to: b.id,
@@ -320,8 +368,8 @@ function edgeLine(a: DiagramBox, b: DiagramBox, legs: Leg[], end: Rect = b): Dia
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,
lw: rows.length ? Math.max(...rows.map((l) => textW(l, FONT.km))) + 8 : 0,
lh: rows.length ? rows.length * KM_LINE + 2 : 0,
};
}
@@ -390,7 +438,11 @@ export function layoutMilestone(
if (box.items) box.items = clip(box.items, inner, FONT.items);
const pos = posOf(box.id);
if (pos) [box.x, box.y] = pos;
for (let i = 0; !pos && out && i < 400 && boxes.some((b) => hits(box, b, GAP)); i++) {
// 254 선 길이가 짧아져 앞서 놓인 선 위에 앉지 않게 — 놓인 상자 · 놓인 선(가운데 ↔ 가운데) 둘 다 피함
const blocked = (): boolean =>
boxes.some((b) => hits(box, b, GAP)) ||
pending.some(([f, t]) => segHits(...center(f), ...center(t), box));
for (let i = 0; !pos && out && i < 400 && blocked(); i++) {
box.x += out[0] * 4;
box.y += out[1] * 4;
}
@@ -446,6 +498,9 @@ export function layoutMilestone(
);
return cands.find((c) => !used.has(keyOf(anchor, c))) ?? cands[auto++ % cands.length];
};
/** 잇는 곳 가운데 → 상자 가운데(254) = 잇는 곳 테두리 + 상자 테두리 + 선 길이(구간 km 비례) */
const reachOf = (anchor: string, h: number, ux: number, uy: number, legs: Leg[]): number =>
toEdge(byId.get(anchor)!, ux, uy) + toEdge({ w: BOX.w, h }, ux, uy) + lineLen(legsKm(legs));
/** 잇는 곳 상자 — 놓인 기준점이면 그 id · 아니면 공사지 */
const anchorOf = (to: string): string => (to !== "site" && byId.has(to) ? to : "site");
@@ -467,15 +522,20 @@ export function layoutMilestone(
degOf.set(b.id, deg);
const [ax, ay] = center(anchor);
const [ux, uy] = unit(deg);
const notes = b.address ? wrap(b.address, BOX.w - 2 * INSET, FONT.note) : [];
const h = boxH(1 + notes.length);
const reach = reachOf(anchor, h, ux, uy, b.legs);
put(
{
id: b.id,
kind: "base",
x: ax + ux * R_BASE - BOX.w / 2,
y: ay + uy * R_BASE - BOX.h / 2,
x: ax + ux * reach - BOX.w / 2,
y: ay + uy * reach - h / 2,
w: BOX.w,
h: BOX.h,
h,
label: b.name || "기준점",
notes,
tip: b.address || undefined,
},
[ux, uy],
);
@@ -495,6 +555,8 @@ export function layoutMilestone(
deg: number;
legs: Leg[];
linked: boolean;
notes: string[];
h: number;
}>
>();
const items = data.routes.map((r) => {
@@ -509,6 +571,7 @@ 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(" · ");
const notes = r.address ? wrap(r.address, BOX.w - 2 * INSET, FONT.note) : [];
groups.get(key)!.push({
id: r.id,
label: r.name || "거래처",
@@ -519,16 +582,19 @@ export function layoutMilestone(
legs: r.legs,
// 155 안 이은 거래처 = 선 없음(연결 점으로 이음) · 없는 기준점에 이음 = 선 없음
linked: r.linked !== false && (r.to === "site" || anchor === r.to),
notes,
h: boxH((items ? 1 : 0) + 1 + notes.length),
});
}
for (const group of groups.values()) {
group.forEach((g, k) => {
const [ax, ay] = center(g.anchor);
const [ux, uy] = unit(g.deg);
// 나란히 = 방위에 직각 · 상자 직각 폭 + 여유만큼(④)
const spread = Math.abs(ux) * BOX.h + Math.abs(uy) * BOX.w + GAP;
// 나란히 = 방위에 직각 · 상자 직각 폭(무리에서 가장 높은 상자) + 여유만큼(④)
const tall = Math.max(...group.map((x) => x.h));
const spread = Math.abs(ux) * tall + Math.abs(uy) * BOX.w + GAP;
const off = (k - (group.length - 1) / 2) * spread;
const reach = g.anchor === "site" ? R_BASE : R_ROUTE; // 공사지 둘레 = 기준점과 같은 거리(선 글 자리)
const reach = reachOf(g.anchor, g.h, ux, uy, g.legs);
const cx = ax + ux * reach - uy * off;
const cy = ay + uy * reach + ux * off;
put(
@@ -536,12 +602,12 @@ export function layoutMilestone(
id: g.id,
kind: "route",
x: cx - BOX.w / 2,
y: cy - BOX.h / 2,
y: cy - g.h / 2,
w: BOX.w,
h: BOX.h,
h: g.h,
label: g.label,
items: g.items,
notes: notesOf(BOX.w, g.address),
notes: g.notes,
tip: g.address,
},
[ux, uy],
+5 -4
View File
@@ -118,7 +118,8 @@
stroke: var(--color-success);
}
.b08-milestone__box.is-route {
.b08-milestone__box.is-route,
.b08-milestone__box.is-base {
cursor: pointer;
}
@@ -170,10 +171,10 @@
width: 80px;
}
/* 거래처 상자 윗줄 = 항목(244-6) */
/* 거래처 상자 윗줄 = 항목(244-6) · 글 크기 = 거래처 이름(254) */
.b08-milestone__items {
fill: var(--color-text-secondary);
font-size: 12px;
font-size: 13px;
}
/* 상자 작은 글씨 = 주소 · 좌표(244-12) */
@@ -224,7 +225,7 @@
cursor: pointer;
}
/* 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 · 공사지에 놓으면 연결 */
/* 154 연결 점 ● — 거래처 · 기준점 상자 왼쪽 위 안쪽(254) · 끌어 기준점 · 공사지에 놓으면 연결 */
.b08-milestone__connect {
fill: var(--color-success);
stroke: var(--color-surface);
@@ -10,12 +10,14 @@
* 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 수정 · 삭제) · 선 · 선 글 누름 = 연결선 모달
* (도로 구분 · 거리 · 다음 지도 경로 · `…_UI_Milestone_LegModal`) · 선 글 더블클릭 = 바로 고침(그대로)
* 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소)
* 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7)
* 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) ·
* 구간 줄마다 두 줄(254 · 1줄 도로 구분 · 2줄 km)
* 연결 점 ●(254) = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소)
* 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점
* 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소
* ========================================================================== */
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
import {
midKm,
@@ -28,6 +30,7 @@ import {
import {
layoutMilestone,
legAt,
makesLoop,
ownerOf,
panContent,
panStart,
@@ -201,13 +204,13 @@ export function createDiagram(
});
};
/** 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 상자 = 그 기준점 거침(254 · to = 기준점 id) ·
* 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 같은 연결 = 취소 · 바뀌면 그 거래처 구간 새로(km 비움) +
* 길찾기 한 번(km · 경로 그림) */
/** 154 연결 점 ● — 거래처 · 기준점 상자 왼쪽 위 안쪽(254 · 테두리 안) · 끌어 기준점 상자 = 그 기준점으로
* (to = 기준점 id) · 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 자기 · 같은 연결 = 취소 · 기준점 돌기
* (A → B → A) = 취소 + 알림 · 바뀌면 그 구간 새로(km 비움) + 길찾기 한 번(km · 거래처 = 경로 그림) */
const connectDot = (box: DiagramBox, shift: SVGGElement): SVGCircleElement => {
const cx = box.x + box.w;
const cy = box.y + box.h / 2;
const dot = svg("circle", { class: "b08-milestone__connect", cx, cy, r: 7 });
const cx = box.x + 10;
const cy = box.y + 10;
const dot = svg("circle", { class: "b08-milestone__connect", cx, cy, r: 6 });
dot.append(svg("title", {}, "끌어서 기준점 · 공사지에 놓으면 연결"));
dot.addEventListener("click", (e) => e.stopPropagation()); // 상자 모달 안 뜸
dot.addEventListener("pointerdown", (down) => {
@@ -229,6 +232,7 @@ export function createDiagram(
const g = hit?.closest<SVGGElement>("[data-b08-milestone-box]");
const id = g?.dataset.b08MilestoneBox;
if (id === "site" || id === "start" || id === "mid") return "site";
if (id === box.id) return null; // 자기 = 취소
return id && liveMilestone(projectId).bases.some((b) => b.id === id) ? id : null;
};
const move = (e: PointerEvent): void => {
@@ -250,9 +254,16 @@ export function createDiagram(
over?.classList.remove("is-drop-target");
const t = targetOf(e);
const fresh = liveMilestone(projectId);
const route = fresh.routes.find((r) => r.id === box.id);
const route = ownerOf(fresh, box.id);
if (!t || !route) return;
if (route.linked !== false && route.to === t) return; // 같은 연결 = 그대로
if (box.kind === "base" && makesLoop(fresh, box.id, t)) {
showToast(
"연결이 돌아 제자리로 옵니다(기준점 A → B → A) — 연결하지 않았습니다.",
"warning",
);
return;
}
route.to = t;
route.linked = true; // 155 요소 추가 뒤 첫 이음
route.legs = [{ road: null, km: null }]; // 옛 구간 km = 다른 길 — 새로 길찾기
@@ -371,15 +382,16 @@ export function createDiagram(
});
g.addEventListener("keydown", (e) => e.key === "Enter" && info());
if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift);
if (box.kind === "route") g.append(connectDot(box, shift));
if (box.kind === "route" || box.kind === "base") g.append(connectDot(box, shift));
shift.append(g);
if (box.kind === "site") shift.append(lineLayer);
}
// 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄
// 선 글은 상자 위에(가리지 않게 맨 끝) — 구간 줄마다 두 줄(254 · 도로 구분 / km)
for (const ln of layout.lines) {
// 자리 = 레이아웃이 상자 · 다른 선 · 다른 글을 피해 고른 곳(244-12 ④)
const x = ln.lx;
const y = ln.ly - ((ln.labels.length - 1) * 16) / 2;
const count = ln.labels.reduce((n, l) => n + l.split("\n").length, 0);
const y = ln.ly - ((count - 1) * 16) / 2;
const text = svg("text", {
class: "b08-milestone__km",
x,
@@ -387,23 +399,27 @@ export function createDiagram(
"text-anchor": "middle",
"dominant-baseline": "central",
});
let first = true;
ln.labels.forEach((label, i) => {
const span = svg("tspan", { x, dy: i ? 16 : 0 }, label);
span.dataset.b08MilestoneLeg = `${ln.from}:${i}`;
if (ln.tips[i]) span.append(svg("title", {}, ln.tips[i]));
// 누름 = 연결선 모달(더블클릭이 오면 취소) · 더블클릭 = 바로 고침
let wait = 0;
span.addEventListener("click", (e) => {
e.stopPropagation();
clearTimeout(wait);
wait = window.setTimeout(() => openLegInfo(projectId, ln.from), 250);
});
span.addEventListener("dblclick", (e) => {
e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음
clearTimeout(wait);
editLegAt(projectId, span, ln.from, i, mk);
});
text.append(span);
for (const row of label.split("\n")) {
const span = svg("tspan", { x, dy: first ? 0 : 16 }, row);
first = false;
span.dataset.b08MilestoneLeg = `${ln.from}:${i}`;
if (ln.tips[i]) span.append(svg("title", {}, ln.tips[i]));
// 누름 = 연결선 모달(더블클릭이 오면 취소) · 더블클릭 = 바로 고침
span.addEventListener("click", (e) => {
e.stopPropagation();
clearTimeout(wait);
wait = window.setTimeout(() => openLegInfo(projectId, ln.from), 250);
});
span.addEventListener("dblclick", (e) => {
e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음
clearTimeout(wait);
editLegAt(projectId, span, ln.from, i, mk);
});
text.append(span);
}
});
shift.append(text);
}
@@ -94,7 +94,7 @@ def test_시점_중점_선_상자_주소_좌표():
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"]
assert line["to"] == "mid" and line["labels"] == ["비포장\n1km"] # 254 두 줄
s, m = boxes["start"], boxes["mid"]
assert m["x"] - (s["x"] + s["w"]) >= 100 # 선 글 자리
for b in (s, m): # 상자 = 공사지 안
@@ -108,7 +108,9 @@ def test_거래처_상자_항목_이름_주소():
_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"])
# 254 긴 주소 = 줄바꿈(낱말 사이) + 상자 세로 늘림(줄 × 15 + 13 · 최소 58)
assert boxes["g"]["notes"] == ["경상남도 거창군 북상면", "병곡리 968-31"]
assert boxes["g"]["h"] == 4 * 15 + 13 and boxes["k"]["h"] == 58
assert boxes["g"]["tip"] == LONG and boxes["k"]["notes"] == ["거창읍"]
assert _node('clip("가나다", 100, 10)') == "가나다"
assert _node(f'clip("{LONG}", 108, 10)') == "경상남도 거창군 북상…" # 한글 10 · 빈칸 6 · … 6
@@ -66,7 +66,7 @@ def test_공사지_컨테이너_시점_중점_선():
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"]}
{**lay["lines"][0], "from": "start", "to": "mid", "labels": ["비포장\n0.75km"]} # 254 두 줄
]
@@ -78,8 +78,9 @@ def test_8방_자리_기준점_없음_공사지_기준():
ex, ey = _center(lay, "e")
nx, ny = _center(lay, "n")
wx, wy = _center(lay, "sw")
# 254 선 길이 = km 비례(빈 km = 최소 100) — 공사지 테두리(가로 190 · 세로 53)에서 잼
assert ex > cx + 200 and abs(ey - cy) < 1
assert ny < cy - 200 and abs(nx - cx) < 1
assert ny < cy - 150 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" # 기준점 없으면 시점으로
@@ -105,8 +106,8 @@ def test_기준점_기준_방위_선_글():
kx, _ = _center(lay, "k")
assert kx > cx + 200 # 기준점 안 거침 = 공사지 동쪽
labels = {(ln["from"], ln["to"]): ln["labels"] for ln in lay["lines"]}
assert labels[("g", "b1")] == ["2차포장 23.8km"]
assert labels[("b1", "start")] == ["1차포장 0.6km", "비포장 0.8km"]
assert labels[("g", "b1")] == ["2차포장\n23.8km"] # 254 두 줄(도로 구분 / km)
assert labels[("b1", "start")] == ["1차포장\n0.6km", "비포장\n0.8km"]
assert labels[("k", "start")] == []
assert next(b for b in lay["boxes"] if b["id"] == "b1")["label"] == "병곡리 968-31"
@@ -130,7 +131,7 @@ def test_빈_방위_차례_같은_방위_나란히():
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 cy < sy - 150 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): # 업체 상자끼리 안 겹침
@@ -63,7 +63,7 @@ def test_방위_좌표_자동():
lay = _node(f"layoutMilestone({json.dumps(migrate_milestone(data))}, {json.dumps(site)}, 0)")
bx, by = _center(lay, "b1")
sx, sy = _center(lay, "s")
assert abs(sx - bx) < 1 and sy > by + 200 # 남 = 아래
assert abs(sx - bx) < 1 and sy > by + 150 # 남 = 아래(254 빈 km = 최소 선 길이)
cx, cy = _center(lay, "site")
ex, ey = _center(lay, "e")
assert ex > cx + 200 and abs(ey - cy) < 1
@@ -75,7 +75,7 @@ def test_방위_좌표_자동():
bx, by = _center(lay, "b1")
nx, ny = _center(lay, "n")
assert bx < cx - 200 # 기준점 = 시점 서쪽(좌표)
assert abs(nx - bx) < 1 and ny < by - 200
assert abs(nx - bx) < 1 and ny < by - 150
def test_다음_지도_길찾기_주소():
@@ -49,7 +49,7 @@ def test_좌표_없음_서쪽_옛_방위_무시():
def test_좌표_있음_공사지_기준_방위각():
(cx, cy), (bx, by) = _layout({**BASE, "dir": "W", "lon": 127.8, "lat": 35.8}, SITE)
assert abs(bx - cx) < 1 and by < cy - 200 # 북 = 위(옛 「서」 무시)
assert abs(bx - cx) < 1 and by < cy - 150 # 북 = 위(옛 「서」 무시 · 254 최소 선 길이)
(cx, cy), (bx, by) = _layout({**BASE, "dir": None, "lon": 127.9, "lat": 35.7}, SITE)
assert bx > cx + 200 and abs(by - cy) < 1 # 동 = 오른
# 시점 좌표 모름 = 서쪽 기본
@@ -189,9 +189,10 @@ def test_구간_채움():
# 선 글 = 사용자 값 우선 · 다르면 호버 · 안 고름 = 「도로 구분 고르기」 + 자동 값
lay = _node(LAYOUT, f"layoutMilestone({json.dumps(put)}, {{start_lonlat: {START}}}, 0.75)")
line = {ln["from"]: ln for ln in lay["lines"]}
assert line["r1"]["labels"] == ["2차포장 23.8km"] and line["r1"]["tips"] == ["자동 값 10.5 km"]
assert line["b1"]["labels"] == ["도로 구분 고르기 1.4km"] and line["b1"]["tips"] == [""]
assert line["start"]["labels"] == ["비포장 0.75km"] # 시점 → 중점 = 노선 측점 거리 그대로
# 254 선 글 두 줄(도로 구분 / km)
assert line["r1"]["labels"] == ["2차포장\n23.8km"] and line["r1"]["tips"] == ["자동 값 10.5 km"]
assert line["b1"]["labels"] == ["도로 구분 고르기\n1.4km"] and line["b1"]["tips"] == [""]
assert line["start"]["labels"] == ["비포장\n0.75km"] # 시점 → 중점 = 노선 측점 거리 그대로
def test_사용자_값_우선_고르기_표시():
@@ -47,9 +47,10 @@ def _read(rel):
def _node(rels, script):
src = "".join(IMPORTS.sub("", _read(rel)) for rel in rels)
# 254 — 글이 길어 명령 줄(Windows 32767자)을 넘지 않게 stdin 으로 넘김
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", src + "\n" + script],
"--input-type=module-typescript", "-"], input=src + "\n" + script,
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
@@ -115,7 +115,10 @@ def test_153_끌어_놓은_자리_레이아웃():
def test_154_연결_점_끌어_잇기():
ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts")
assert 'if (box.kind === "route") g.append(connectDot(box, shift));' in ui
# 254 기준점 상자에도 연결 점
assert (
'if (box.kind === "route" || box.kind === "base") g.append(connectDot(box, shift));' in ui
)
assert "route.to = t;" in ui # 254 놓은 기준점 id · 공사지 = "site"
assert "route.legs = [{ road: null, km: null }];" in ui # 옛 구간 km 비움
assert "void findSegmentKm(projectId, route.id).then(toastKm);" in ui # 새 길찾기 한 번
@@ -0,0 +1,129 @@
"""254-2 B08 거리이정표 다이어그램(사용자 162-4 · 5 · 6 · 10) — 기준점 상자 연결 점 · 주소 ·
연결 점 왼쪽 위 안쪽 · 항목 글 = 거래처 글 · 주소 줄바꿈 + 상자 높이 · 선 글 두 줄 ·
선 길이 = 거리 비례(로그 · 최소 · 최대).
"""
import json
import math
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
B08 = "B08_Quantity/B08_Quantity_"
LONG = "경상남도 거창군 북상면 병곡리 968-31"
def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
def _node(expr):
src = IMPORTS.sub("", _read(B08 + "Milestone_Layout.ts"))
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-"],
input=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 _leg(km):
return {"road": None, "km": km}
def _route(rid, dir, km, to="site", address=""):
return {"id": rid, "name": rid, "address": address, "dir": dir, "to": to,
"legs": [_leg(km)] if km is not None else [],
"rows": [{"item": "장비", "dest": "start", "note": ""}]} # fmt: skip
def _base(bid, to, km, address="", lon=None, lat=None):
return {"id": bid, "name": bid, "address": address, "dir": None, "to": to, "lon": lon,
"lat": lat, "legs": [_leg(km)], "linked": True} # fmt: skip
def _data(bases, routes):
return {"site": {"mid_station_m": None, "mid_road": "비포장"}, "bases": bases, "routes": routes}
def _lay(data):
return _node(f"layoutMilestone({json.dumps(data, ensure_ascii=False)}, null, 0.5)")
def _boxes(lay):
return {b["id"]: b for b in lay["boxes"]}
def test_선_길이_거리_비례_최소_최대():
kms = [0, 1, 10, 23.8, 100, 1e6, -3]
got = _node(f"{json.dumps(kms)}.map(lineLen)")
want = [min(320, max(100, 100 + 90 * math.log10(1 + max(0, k)))) for k in kms]
assert all(abs(a - b) < 1e-9 for a, b in zip(got, want))
assert got[0] == 100 and got[5] == 320 and got[6] == 100 # 최소 · 최대(너무 멀지 않게)
assert got[0] < got[1] < got[2] < got[3] < got[4]
# 동쪽 거래처 셋 — 공사지 테두리 → 상자 테두리 = 그 구간 선 길이
lay = _lay(_data([], [_route("a", "E", 1), _route("b", "S", 60), _route("c", "W", None)]))
boxes = _boxes(lay)
site = boxes["site"]
a, b, c = boxes["a"], boxes["b"], boxes["c"]
assert abs((a["x"]) - (site["x"] + site["w"]) - got[1]) < 1e-6
assert abs(b["y"] - (site["y"] + site["h"]) - _node("lineLen(60)")) < 1e-6
assert abs(site["x"] - (c["x"] + c["w"]) - 100) < 1e-6 # 빈 km = 최소
def test_기준점_사슬_자리_주소_높이():
bases = [_base("A", "B", 4.5, address=LONG), _base("B", "site", 0.6, address="거창읍")]
lay = _lay(_data(bases, [_route("g", "N", 23.8, to="A")]))
boxes = _boxes(lay)
a, b, g = boxes["A"], boxes["B"], boxes["g"]
assert (a["kind"], b["kind"]) == ("base", "base")
# 기준점 상자 = 주소(줄바꿈) · 높이 = 이름 + 주소 줄
assert a["notes"] == ["경상남도 거창군 북상면", "병곡리 968-31"] and a["tip"] == LONG
assert a["h"] == 3 * 15 + 13 and b["notes"] == ["거창읍"] and b["h"] == 58
# B = 공사지 서쪽(좌표 없음) · A = B 둘레(B 에 이음) · g = A 북쪽
site = boxes["site"]
assert b["x"] + b["w"] < site["x"] and a["x"] + a["w"] < b["x"] + b["w"]
assert g["y"] + g["h"] < a["y"]
lines = {(ln["from"], ln["to"]) for ln in lay["lines"]}
assert {("g", "A"), ("A", "B"), ("B", "start"), ("start", "mid")} == lines
# 아주 긴 주소 = 최대 3줄 · 끝 줄 줄임표
many = " ".join([LONG] * 4)
rows = _node(f'wrap("{many}", 132, 10)')
assert len(rows) == 3 and rows[-1].endswith("…")
assert all(_node(f'textW("{r}", 10)') <= 132 for r in rows)
def test_선_글_두_줄():
lay = _lay(_data([_base("B", "site", 0.6)], [_route("g", "N", 23.8, to="B")]))
line = next(ln for ln in lay["lines"] if ln["from"] == "B")
assert line["labels"] == ["1차포장\n0.6km"] or line["labels"] == ["도로 구분 고르기\n0.6km"]
assert line["lh"] == 2 * 16 + 2 # 두 줄 높이
two = _node("legText([{road: '1차포장', km: 0.6}, {road: '비포장', km: null}])")
assert two == ["1차포장\n0.6km", "비포장\n-km"]
ui = _read(B08 + "UI_Milestone_Diagram.ts")
assert 'for (const row of label.split("\\n"))' in ui # 줄마다 tspan
assert "span.dataset.b08MilestoneLeg = `${ln.from}:${i}`;" in ui # 두 줄 다 그 구간
def test_연결_점_왼쪽_위_안쪽_기준점도_돌기_취소():
ui = _read(B08 + "UI_Milestone_Diagram.ts")
dot = ui[ui.index("const connectDot") :]
dot = dot[: dot.index("return dot;")]
# 왼쪽 위 안쪽 — 가운데 (10, 10) · 반지름 6 + 테두리 2 = 상자 안
assert "const cx = box.x + 10;" in dot and "const cy = box.y + 10;" in dot and "r: 6" in dot
assert (
'if (box.kind === "route" || box.kind === "base") g.append(connectDot(box, shift));' in ui
)
assert 'if (box.kind === "base" && makesLoop(fresh, box.id, t))' in dot
assert "if (id === box.id) return null;" in dot # 자기 = 취소
assert "route.to = t;" in dot and "route.linked = true;" in dot
css = _read(B08 + "UI_Milestone.css")
items = css[css.index(".b08-milestone__items {") :]
assert "font-size: 13px;" in items[: items.index("}")] # 항목 = 거래처 글
assert "items: 13" in _read(B08 + "Milestone_Layout.ts")
for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts"):
assert len(_read(B08 + name).splitlines()) <= 700, name