Files
Aislo/B08_Quantity/B08_Quantity_Milestone_Layout.ts
T
eomsangdonandClaude Opus 5.5 41f68b8178 feat(B08): 거리이정표 상자 모달 제목 · 상자 안 장소 그림(254-3)
- 상자 누름 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」(표 머리 글 항목 정보 없음)
- 장소 그림(256-1 place_images) = 상자 글 아래 132 × 88 · 그림 없으면 자리 없음 · 키 바뀔 때만 다시 받음
- 시험 = test_254_3 새로 · 244_6 · 244_10 node 입력 = stdin(명령 줄 길이)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-11 14:12:24 +09:00

681 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B08_Quantity_Milestone_Layout.ts
* 거리이정표 다이어그램 자리 계산(PLAN 244-2) — 화면 없음 · 형 import 만(시험이 node 로 돌림).
*
* 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 거래처들
* 거래처 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 거래처 경위도(모달 주소 검색)가 있으면
* 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 ·
* 같은 방위 여럿 = 옆으로 나란히
* 기준점 자리 = 기준점 주소 좌표 → 잇는 곳(시점 · 다른 기준점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) —
* 옛 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」
* 연결선 구간(244-13) = `segmentEnds` · 도로 구분 갈래 = `chainVia`(기준점 거침 = 2차 · 1차 · 비포장)
* 바탕(244-12) = 공사지 둘레 거래처 · 기준점 자리까지 담는 고정 범위 `BASE_VIEW` · 넘는 상자가 생길 때만 넓힘
* 작은 글씨(244-12) = 시점 · 중점 주소 · 좌표(위도, 경도 소수 5자리) · 거래처 주소 — 길면 줄임표(`clip`) + 호버
* 여유(244-12 ④) = 상자 사이 GAP(겹치면 바깥 방향으로 밀어 냄) · 선 = 상자 테두리에서 끊김 · 선 글 = 상자 ·
* 다른 선 · 다른 글을 피한 자리(`placeLabels`) · 상자 안 글 = 안쪽 여백 넘으면 줄임표
* ========================================================================== */
import type { Dir, Leg, MilestoneBase, MilestoneData } from "./B08_Quantity_Milestone_Calc";
export interface DiagramBox {
id: string;
kind: "site" | "start" | "mid" | "base" | "route";
x: number;
y: number;
w: number;
h: number;
label: string;
/** 거래처 상자 윗줄 — 표 줄 항목(244-6 · 예 「장비」) */
items?: string;
/** 이름 아래 작은 글씨 줄(244-12 · 주소 · 좌표) */
notes?: string[];
/** 호버 = 줄이지 않은 주소 */
tip?: string;
/** 장소 그림 자리(254-3 · 글 아래 · 그림 없으면 없음) */
pic?: Rect;
}
export interface DiagramLine {
from: string;
to: string;
x1: number;
y1: number;
x2: number;
y2: number;
labels: string[];
/** 글마다 호버(사용자 km ≠ 자동 값이면 「자동 값 ○○ km」 · 아니면 "") */
tips: string[];
/** 선 글 자리(가운데 · 244-12 ④ 상자 · 다른 선 · 다른 글 피함) · 폭 · 높이 */
lx: number;
ly: number;
lw: number;
lh: number;
}
export interface DiagramLayout {
boxes: DiagramBox[];
lines: DiagramLine[];
view: [number, number, number, number];
}
type LonLat = [number, number] | null | undefined;
/** 8방 → 방위각(북 0 · 시계 방향) */
export const DIR_DEG: Record<Dir, number> = {
N: 0,
NE: 45,
E: 90,
SE: 135,
S: 180,
SW: 225,
W: 270,
NW: 315,
};
const AUTO: Dir[] = ["N", "NW", "W", "SW", "S", "SE", "E", "NE"];
const SITE = { w: 380, h: 106 };
const NODE = { w: 124, h: 58 };
const BOX = { w: 148, h: 58 };
const PAD = 48;
/** 바탕 반폭 · 반높이 — 공사지 둘레 상자(짧은 구간 · 선 길이 최소쯤)가 어느 방위로 와도 담김 */
const REACH = 390;
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(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;
/** 장소 그림(254-3) — 상자 안쪽 폭 · 서버 그림 480 × 320 과 같은 3:2 */
export const PIC = { w: 132, h: 88 };
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;
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}…`;
}
/** 여러 줄(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)] : []),
...(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: { 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 · 시계) — 짧은 거리 평면 근사 */
export function bearingDeg(from: [number, number], to: [number, number]): number {
const dx = (to[0] - from[0]) * Math.cos((((from[1] + to[1]) / 2) * Math.PI) / 180);
const dy = to[1] - from[1];
return ((Math.atan2(dx, dy) * 180) / Math.PI + 360) % 360;
}
/** 자리 방위 — 경위도 둘 다 → 방위각 · 아니면 8방 · 없으면 null */
export function placeDeg(
own: { dir: Dir | null; lon?: number | null; lat?: number | null },
anchor: LonLat,
): number | null {
if (own.lon != null && own.lat != null && anchor) return bearingDeg(anchor, [own.lon, own.lat]);
return own.dir ? DIR_DEG[own.dir] : null;
}
/** 구간 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 = 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
? `자동 값 ${leg.auto_km} km`
: "";
/** [길찾기] 자동 km → 그 구간 첫 줄(없으면 도로 구분 안 고른 줄 하나) · 사용자 km 는 그대로 */
export function setAutoKm(legs: Leg[], km: number): Leg[] {
if (!legs.length) legs.push({ road: null, km: null, auto_km: km });
else legs[0].auto_km = km;
return legs;
}
export interface KmSegment {
/** route = 거래처 구간 · base = 기준점 구간(→ 시점 · 다른 기준점) */
key: "route" | "base";
text: string;
from: [number, number] | null;
to: [number, number] | null;
}
const pointOf = (p: { lon?: number | null; lat?: number | null }): [number, number] | null =>
p.lon != null && p.lat != null ? [p.lon, p.lat] : null;
/** 이음 대상 = 시점(Calc `SITE_ID` 와 같음 · 형 import 만) */
const TO_SITE = "site";
const baseOf = (data: MilestoneData, id: string): MilestoneBase | undefined =>
data.bases.find((b) => b.id === id);
/** 구간 주인 — 기준점 · 거래처(from id) */
export const ownerOf = (data: MilestoneData, from: string) =>
baseOf(data, from) ?? data.routes.find((r) => r.id === from);
/** 기준점 사슬(254 · Calc `baseChain` 과 같음) — to 부터 시점까지 · 돌기 · 안 이음 · 없음에서 멈춤 */
export function chainOf(data: MilestoneData, to: string): MilestoneBase[] {
const out: MilestoneBase[] = [];
const seen = new Set<string>();
for (let id = to; id !== TO_SITE && !seen.has(id);) {
seen.add(id);
const base = baseOf(data, id);
if (!base || base.linked === false) break;
out.push(base);
id = base.to;
}
return out;
}
/** 돌기(254) — 기준점 from 을 to 에 이으면 to 사슬이 from 으로 돌아옴(A → B → A · 이음 여부 무관) */
export function makesLoop(data: MilestoneData, from: string, to: string): boolean {
const seen = new Set<string>();
for (let id = to; id !== TO_SITE && !seen.has(id); id = baseOf(data, id)?.to ?? TO_SITE) {
if (id === from) return true;
seen.add(id);
}
return false;
}
/** 구간 한 줄(244-13 연결선) — from = 거래처 id · 기준점 id(→ to = 시점 · 기준점) ·
* 없는 구간 = null(시점 → 중점 = 손 입력 · 길찾기 안 함 · 안 이음) */
export function segmentEnds(data: MilestoneData, from: string, start: LonLat): KmSegment | null {
const base = baseOf(data, from);
const owner = base ?? data.routes.find((r) => r.id === from);
if (!owner || owner.linked === false) return null; // 155 안 이은 상자 = 구간 없음
const target = owner.to === TO_SITE ? undefined : baseOf(data, owner.to);
if (owner.to !== TO_SITE && !target) return null;
return {
key: base ? "base" : "route",
text: `${owner.name || (base ? "기준점" : "거래처")} → ${target ? target.name || "기준점" : "시점"}`,
from: pointOf(owner),
to: target ? pointOf(target) : (start ?? null),
};
}
/** [길찾기] 구간 — 거래처 → to · 그 뒤 기준점 사슬 구간들(254)
* (시점 → 중점 = 노선 측점 거리 그대로 · 길찾기 안 함) */
export function kmSegments(data: MilestoneData, routeId: string, start: LonLat): KmSegment[] {
const first = segmentEnds(data, routeId, start);
const route = data.routes.find((r) => r.id === routeId);
if (!first || !route) return [];
return [first, ...chainOf(data, route.to).map((b) => segmentEnds(data, b.id, start)!)];
}
/** 카카오 지도 표지(244-9) — 거래처 · 기준점 사슬(좌표 있을 때) · 시점 */
export function mapMarks(
data: MilestoneData,
routeId: string,
start: LonLat,
): Array<{ name: string; lon: number; lat: number }> {
const route = data.routes.find((r) => r.id === routeId);
if (!route) return [];
const out: Array<{ name: string; lon: number; lat: number }> = [];
const add = (name: string, p: [number, number] | null): void => {
if (p) out.push({ name, lon: p[0], lat: p[1] });
};
add(route.name || "거래처", pointOf(route));
if (route.linked !== false)
for (const b of chainOf(data, route.to)) add(b.name || "기준점", pointOf(b));
add("시점", start ?? null);
return out;
}
/** 자동 km 를 그 구간에(from = 거래처 · 기준점 id · 사용자 km 는 그대로) */
export function putAutoKm(data: MilestoneData, from: string, km: number): MilestoneData {
const legs = ownerOf(data, from)?.legs;
if (legs) setAutoKm(legs, km);
return data;
}
const unit = (deg: number): [number, number] => {
const rad = (deg * Math.PI) / 180;
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);
const rows = labels.flatMap((l) => l.split("\n"));
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: rows.length ? Math.max(...rows.map((l) => textW(l, FONT.km))) + 8 : 0,
lh: rows.length ? rows.length * KM_LINE + 2 : 0,
};
}
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;
start_address?: string | null;
mid_address?: string | null;
} | null,
midKm: number,
pics: ReadonlySet<string> = new Set(), // 254-3 장소 그림 있는 상자 id
): DiagramLayout {
const boxes: DiagramBox[] = [];
const picH = (id: string): number => (pics.has(id) ? PIC.h + INSET : 0);
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 날 때까지 그쪽으로 밀어 냄) */
// 153 사용자가 끌어 놓은 자리(왼 위 · 그림 좌표) — 있으면 방위 자리 · 밀어 냄 대신 그 자리
const posOf = (id: string): [number, number] | null | undefined => ownerOf(data, id)?.pos;
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);
const pos = posOf(box.id);
if (pos) [box.x, box.y] = pos;
// 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;
}
boxes.push(box);
byId.set(box.id, box);
};
const line = (from: string, to: string, legs: Leg[]): void => {
pending.push([from, to, legs]);
};
// 공사지 = 가운데 컨테이너 · 안에 시점(왼) · 중점(오른) · 사이 = 시점 → 중점 선 · 글
put({
id: "site",
kind: "site",
x: -SITE.w / 2,
y: -SITE.h / 2,
w: SITE.w,
h: SITE.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;
const llOf = (p?: { lon?: number | null; lat?: number | null }): LonLat =>
p && p.lon != null && p.lat != null ? [p.lon, p.lat] : null;
const used = new Set<string>();
const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`;
/** 상자 id → 잇는 곳에서 본 방위(기준점 둘레 빈 방위 = 그 방위에서 돌림 · 되돌아가는 쪽 뺌) */
const degOf = new Map<string, number>();
let auto = 0;
/** 방위 안 정한 상자 — 빈 8방 차례(기준점 둘레 = 잇는 곳 반대편부터 · 잇는 곳으로 향하는 쪽 뺌) */
const free = (anchor: string): number => {
const turn = anchor === "site" ? 0 : (degOf.get(anchor) ?? 0);
const blocked = anchor === "site" ? -1 : turn + 180;
const cands = AUTO.map((d) => (DIR_DEG[d] + turn) % 360).filter(
(c) => keyOf(anchor, c) !== keyOf(anchor, blocked),
);
return cands.find((c) => !used.has(keyOf(anchor, c))) ?? cands[auto++ % cands.length];
};
/** 잇는 곳 가운데 → 상자 가운데(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");
// 기준점(254 · 여럿) — 잇는 곳(시점 · 다른 기준점) 기준 방위(좌표 → 방위각 · 없으면 서부터 빈 방위) ·
// 잇는 곳이 먼저 놓이게 사슬 차례 · 돌기 = 공사지 둘레
const waiting = [...data.bases];
while (waiting.length) {
let i = waiting.findIndex(
(b) => b.linked === false || b.to === "site" || byId.has(b.to) || !ownerOf(data, b.to),
);
if (i < 0) i = 0;
const [b] = waiting.splice(i, 1);
const anchor = b.linked === false ? "site" : anchorOf(b.to);
const anchorLL = anchor === "site" ? startLL : (llOf(ownerOf(data, anchor)) ?? startLL);
const deg =
placeDeg({ dir: null, lon: b.lon, lat: b.lat }, anchorLL) ??
(anchor === "site" && !used.has(keyOf("site", 270)) ? 270 : free(anchor));
used.add(keyOf(anchor, deg));
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) + picH(b.id);
const reach = reachOf(anchor, h, ux, uy, b.legs);
put(
{
id: b.id,
kind: "base",
x: ax + ux * reach - BOX.w / 2,
y: ay + uy * reach - h / 2,
w: BOX.w,
h,
label: b.name || "기준점",
notes,
tip: b.address || undefined,
},
[ux, uy],
);
if (b.linked !== false && (b.to === "site" || byId.has(b.to)))
line(b.id, b.to === "site" ? "start" : b.to, b.legs);
}
// 거래처 — 잇는 곳(기준점 · 공사지) 기준 방위 · 같은 방위끼리 옆으로 나란히
const groups = new Map<
string,
Array<{
id: string;
label: string;
items: string;
address: string;
anchor: string;
deg: number;
legs: Leg[];
linked: boolean;
notes: string[];
h: number;
}>
>();
const items = data.routes.map((r) => {
const anchor = anchorOf(r.to);
const anchorLL = anchor === "site" ? startLL : (llOf(ownerOf(data, anchor)) ?? startLL);
return { r, anchor, deg: placeDeg(r, anchorLL) };
});
for (const it of items) if (it.deg !== null) used.add(keyOf(it.anchor, it.deg));
for (const { r, anchor, deg } of items) {
const d = deg ?? free(anchor);
used.add(keyOf(anchor, d));
const key = keyOf(anchor, d);
if (!groups.has(key)) groups.set(key, []);
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 || "거래처",
items,
address: r.address,
anchor,
deg: d,
legs: r.legs,
// 155 안 이은 거래처 = 선 없음(연결 점으로 이음) · 없는 기준점에 이음 = 선 없음
linked: r.linked !== false && (r.to === "site" || anchor === r.to),
notes,
h: boxH((items ? 1 : 0) + 1 + notes.length) + picH(r.id),
});
}
for (const group of groups.values()) {
group.forEach((g, k) => {
const [ax, ay] = center(g.anchor);
const [ux, uy] = unit(g.deg);
// 나란히 = 방위에 직각 · 상자 직각 폭(무리에서 가장 높은 상자) + 여유만큼(④)
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 = reachOf(g.anchor, g.h, ux, uy, g.legs);
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 - g.h / 2,
w: BOX.w,
h: g.h,
label: g.label,
items: g.items,
notes: g.notes,
tip: g.address,
},
[ux, uy],
);
if (g.linked) line(g.id, g.anchor === "site" ? "start" : g.anchor, g.legs);
});
}
// 254-3 장소 그림 = 글 아래 · 안쪽 여백 안(그림 없으면 자리 없음)
for (const b of boxes)
if (pics.has(b.id)) b.pic = { x: b.x + INSET, y: b.y + b.h - INSET - PIC.h, ...PIC };
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],
};
}
/** 선 글 한 줄 → 그 구간 — from = 거래처 id · 기준점 id · "start"(시점 → 중점) */
export function legAt(data: MilestoneData, from: string, i: number, midKm: number): Leg | null {
if (from === "start") return { road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 };
return ownerOf(data, from)?.legs[i] ?? null;
}
/** 선 글에서 고친 값 → 데이터(시점 → 중점 km = 중점 측점 m) · 고친 것만 */
export function setLeg(
data: MilestoneData,
from: string,
i: number,
patch: { road?: Leg["road"]; km?: number | null },
): MilestoneData {
if (from === "start") {
if (patch.road) data.site.mid_road = patch.road;
if (patch.km !== undefined)
data.site.mid_station_m = patch.km == null ? null : Math.round(patch.km * 1000 * 1000) / 1000;
return data;
}
const leg = ownerOf(data, from)?.legs[i];
if (leg) Object.assign(leg, patch);
return data;
}
/** 다이어그램 팬 시작 — 가운데 버튼 · 빈 곳 왼쪽 끌기(상자 · 선 글 · [+] 위는 그 화면 고르기 몫 · 244-6) */
export const PAN_SKIP =
".b08-milestone__box, .b08-milestone__km, .b08-milestone__plus, .b08-milestone__line-hit";
export function panStart(e: { button: number; target: EventTarget | null }): boolean {
if (e.button === 1) return true;
const target = e.target as { closest?: (selector: string) => unknown } | null;
return e.button === 0 && !target?.closest?.(PAN_SKIP);
}
/** 팬 한계 — 원배율에서도 그림 둘레 한 폭씩 끌어 옮길 수 있게(공용 팬은 이 범위까지 엶) */
export const panContent = (w: number, h: number) => ({ x: -w, y: -h, width: 3 * w, height: 3 * h });