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:
@@ -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],
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user