- 공사지 · 기준점 [+] = 모달(항목 · 업체 이름 · 목적지 + B01 주소 검색 · 지도) · 방위 고르기 없앰 · 좌표로 자동 자리 - 상자 누름 = 모달(업체 = 항목 정보 · 구간 km · 도로 구분 · 고침 · 삭제 / 공사지 = 등록 주소 · VWorld 주소 · 지도 / 기준점 = 공통 구간) - 다이어그램 팬 · 줌 = 공용 b_svg_zoom_pan - 경로 하위 탭 = 그림만 · [다음 지도에서 길찾기] 새 창 · [화면 캡처] · 하위 탭 [추가] · [삭제] 없앰 - 서버 site-address(좌표 → 주소 · VWorld getAddress) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
230 lines
7.6 KiB
TypeScript
230 lines
7.6 KiB
TypeScript
/* =============================================================================
|
|
* 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<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: 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<string, [number, number]>();
|
|
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<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, []);
|
|
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("/")}`;
|
|
}
|