/* ============================================================================= * 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 = { 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(); 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(); 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 = 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(); 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(); const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`; /** 상자 id → 잇는 곳에서 본 방위(기준점 둘레 빈 방위 = 그 방위에서 돌림 · 되돌아가는 쪽 뺌) */ const degOf = new Map(); 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 });