Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
570 lines
22 KiB
TypeScript
570 lines
22 KiB
TypeScript
/* =============================================================================
|
||
* B08_Quantity_Milestone_Layout.ts
|
||
* 거리이정표 다이어그램 자리 계산(PLAN 244-2) — 화면 없음 · 형 import 만(시험이 node 로 돌림).
|
||
*
|
||
* 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 거래처들
|
||
* 거래처 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 거래처 경위도(모달 주소 검색)가 있으면
|
||
* 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 ·
|
||
* 같은 방위 여럿 = 옆으로 나란히
|
||
* 기준점 자리 = 기준점 주소 좌표 → 공사지(시점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — 옛 8방 무시(244-5)
|
||
* 선 = 거래처 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다
|
||
* 화면 = 북 위(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, 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;
|
||
}
|
||
|
||
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 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 {
|
||
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;
|
||
}
|
||
|
||
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`;
|
||
});
|
||
|
||
/** 자동 값 호버 — 사용자 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;
|
||
|
||
/** 구간 한 줄(244-13 연결선) — from = 거래처 id(거래처 → 기준점(거침) · 시점) · "base"(기준점 → 시점) ·
|
||
* 없는 구간 = null(시점 → 중점 = 손 입력 · 길찾기 안 함) */
|
||
export function segmentEnds(data: MilestoneData, from: string, start: LonLat): KmSegment | null {
|
||
const baseName = data.base.name || "기준점";
|
||
if (from === "base")
|
||
return data.base.enabled
|
||
? { key: "base", text: `${baseName} → 시점`, from: pointOf(data.base), to: start ?? null }
|
||
: null;
|
||
const route = data.routes.find((r) => r.id === from);
|
||
if (!route || route.linked === false) return null; // 155 안 이은 거래처 = 구간 없음
|
||
const viaBase = data.base.enabled && route.via_base;
|
||
return {
|
||
key: "route",
|
||
text: `${route.name || "거래처"} → ${viaBase ? baseName : "시점"}`,
|
||
from: pointOf(route),
|
||
to: viaBase ? pointOf(data.base) : (start ?? null),
|
||
};
|
||
}
|
||
|
||
/** 기준점 거쳐 시점 들어가는 구간(244-13 · 도로 구분 2차 · 1차 · 비포장) — 기준점 → 시점 · 거침 거래처 */
|
||
export function chainVia(data: MilestoneData, from: string): boolean {
|
||
if (!data.base.enabled || from === "start") return false;
|
||
return from === "base" || !!data.routes.find((r) => r.id === from)?.via_base;
|
||
}
|
||
|
||
/** [길찾기] 구간 — 거래처 → 기준점 · 기준점 → 시점(거침) · 기준점 없으면 거래처 → 시점
|
||
* (시점 → 중점 = 노선 측점 거리 그대로 · 길찾기 안 함) */
|
||
export function kmSegments(data: MilestoneData, routeId: string, start: LonLat): KmSegment[] {
|
||
const first = segmentEnds(data, routeId, start);
|
||
if (!first) return [];
|
||
return chainVia(data, routeId) ? [first, segmentEnds(data, "base", start)!] : [first];
|
||
}
|
||
|
||
/** 카카오 지도 표지(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 (data.base.enabled && route.via_base) add(data.base.name || "기준점", pointOf(data.base));
|
||
add("시점", start ?? null);
|
||
return out;
|
||
}
|
||
|
||
/** 자동 km 를 그 구간에(사용자 km 는 그대로) */
|
||
export function putAutoKm(
|
||
data: MilestoneData,
|
||
routeId: string,
|
||
key: KmSegment["key"],
|
||
km: number,
|
||
): MilestoneData {
|
||
const legs = key === "base" ? data.base.legs : data.routes.find((r) => r.id === routeId)?.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);
|
||
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;
|
||
start_address?: string | null;
|
||
mid_address?: string | null;
|
||
} | null,
|
||
midKm: number,
|
||
): DiagramLayout {
|
||
const boxes: DiagramBox[] = [];
|
||
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 =>
|
||
id === "base" ? data.base.pos : data.routes.find((r) => r.id === 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;
|
||
for (let i = 0; !pos && out && i < 400 && boxes.some((b) => hits(box, b, GAP)); 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 useBase = data.base.enabled;
|
||
const baseLL: LonLat =
|
||
data.base.lon != null && data.base.lat != null ? [data.base.lon, data.base.lat] : null;
|
||
let baseDeg = 270;
|
||
if (useBase) {
|
||
baseDeg = placeDeg({ dir: null, lon: data.base.lon, lat: data.base.lat }, startLL) ?? 270;
|
||
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 || "기준점",
|
||
},
|
||
[ux, uy],
|
||
);
|
||
line("base", "start", data.base.legs);
|
||
}
|
||
|
||
// 거래처 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히
|
||
const groups = new Map<
|
||
string,
|
||
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}`;
|
||
const items = data.routes.map((r) => {
|
||
const anchor = useBase && r.via_base ? "base" : "site";
|
||
return { r, anchor, deg: placeDeg(r, anchor === "base" ? (baseLL ?? startLL) : startLL) };
|
||
});
|
||
for (const it of items) if (it.deg !== null) used.add(keyOf(it.anchor, it.deg));
|
||
let auto = 0;
|
||
/** 방위 안 고른 거래처 — 빈 8방 차례(기준점 쪽 = 공사지 반대편부터 · 기준점 · 공사지로 향하는 쪽 뺌) */
|
||
const free = (anchor: string): number => {
|
||
const turn = anchor === "base" ? baseDeg : 0;
|
||
const blocked = anchor === "base" ? baseDeg + 180 : useBase ? baseDeg : -1;
|
||
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];
|
||
};
|
||
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(" · ");
|
||
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(g.anchor === "base" ? "base" : "site");
|
||
const [ux, uy] = unit(g.deg);
|
||
// 나란히 = 방위에 직각 · 상자 직각 폭 + 여유만큼(④)
|
||
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,
|
||
notes: notesOf(BOX.w, g.address),
|
||
tip: g.address,
|
||
},
|
||
[ux, uy],
|
||
);
|
||
// 155 안 이은 거래처 = 선 없음(연결 점으로 이음)
|
||
if (data.routes.find((r) => r.id === g.id)?.linked !== false)
|
||
line(g.id, g.anchor === "base" ? "base" : "start", g.legs);
|
||
});
|
||
}
|
||
|
||
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(거래처 구간) · "base"(공통 구간) · "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 };
|
||
const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs;
|
||
return 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 legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs;
|
||
const leg = 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 });
|