/* ============================================================================= * B08_Quantity_Milestone_Layout.ts * 거리이정표 다이어그램 자리 계산(PLAN 244-2) — 화면 없음 · 형 import 만(시험이 node 로 돌림). * * 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 업체들 * 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 업체 경위도(모달 주소 검색)가 있으면 * 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 · * 같은 방위 여럿 = 옆으로 나란히 * 기준점 자리 = 공사지 기준 같은 규칙(경위도 · 8방 · 기본 서) * 선 = 업체 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 * 화면 = 북 위(SVG y 아래로 = 남) * ========================================================================== */ 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; } export interface DiagramLine { from: string; to: string; x1: number; y1: number; x2: number; y2: number; labels: string[]; } 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: 240, h: 96 }; const NODE = { w: 72, h: 32 }; const BOX = { w: 148, h: 44 }; const R_BASE = 300; const R_ROUTE = 260; const SPREAD = 64; /** 경위도 두 점 → 방위각(도 · 북 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) => `${leg.road} ${leg.km == null ? "-" : String(leg.km)}km`); const unit = (deg: number): [number, number] => { const rad = (deg * Math.PI) / 180; return [Math.sin(rad), -Math.cos(rad)]; }; export function layoutMilestone( data: MilestoneData, site: { start_lonlat?: LonLat; mid_lonlat?: LonLat } | null, midKm: number, ): DiagramLayout { const boxes: DiagramBox[] = []; const lines: DiagramLine[] = []; const center = new Map(); const put = (box: DiagramBox): void => { boxes.push(box); center.set(box.id, [box.x + box.w / 2, box.y + box.h / 2]); }; const line = (from: string, to: string, labels: string[]): void => { const [x1, y1] = center.get(from)!; const [x2, y2] = center.get(to)!; lines.push({ from, to, x1, y1, x2, y2, labels }); }; // 공사지 = 가운데 컨테이너 · 안에 시점(왼) · 중점(오른) put({ id: "site", kind: "site", x: -SITE.w / 2, y: -SITE.h / 2, w: SITE.w, h: SITE.h, label: "공사지", }); put({ id: "start", kind: "start", x: -SITE.w / 2 + 16, y: -6, w: NODE.w, h: NODE.h, label: "시점", }); put({ id: "mid", kind: "mid", x: SITE.w / 2 - 16 - NODE.w, y: -6, w: NODE.w, h: NODE.h, label: "중점", }); line( "start", "mid", legText([{ 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(data.base, 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 || "기준점", }); line("base", "start", legText(data.base.legs)); } // 업체 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히 const groups = new Map< string, Array<{ id: string; label: string; anchor: string; deg: number; legs: Leg[] }> >(); const used = new Set(); 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, []); groups.get(key)!.push({ id: r.id, label: r.name || "업체", anchor, deg: d, legs: r.legs }); } for (const group of groups.values()) { group.forEach((g, k) => { const [ax, ay] = center.get(g.anchor === "base" ? "base" : "site")!; const [ux, uy] = unit(g.deg); const off = (k - (group.length - 1) / 2) * SPREAD; const reach = g.anchor === "site" ? R_ROUTE + SITE.w / 4 : 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, }); line(g.id, g.anchor === "base" ? "base" : "start", legText(g.legs)); }); } const pad = 48; const xs = boxes.flatMap((b) => [b.x, b.x + b.w]); const ys = 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], }; } /** 다음(카카오) 지도 자동차 길찾기 새 창 주소(공식 `/link/by/car/이름,위도,경도/…` · 경유 ≤ 5) */ export function kakaoRouteUrl(points: Array<{ name: string; lon: number; lat: number }>): string { const part = (p: { name: string; lon: number; lat: number }): string => `${encodeURIComponent(p.name.replace(/[,/]/g, " ").trim() || "지점")},${p.lat},${p.lon}`; return `https://map.kakao.com/link/by/car/${points.map(part).join("/")}`; }