Files
Aislo/B08_Quantity/B08_Quantity_Milestone_Layout.ts
T
eomsangdonandClaude Opus 5.5 5f2e6e18b6 feat(b08): 거리이정표 [+] 모달 · 주소 검색 재활용 · 방위 자동 · 시점 모달 · 팬 줌 · 경로 탭 = 길찾기 그림 (244-4)
- 공사지 · 기준점 [+] = 모달(항목 · 업체 이름 · 목적지 + 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
2026-10-10 21:33:53 +09:00

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("/")}`;
}