- [등록] · [길찾기] 뒤 길 선 · 표지 → 서버 헤드리스 Edge(없으면 chrome)로 카카오 지도 1200×800 PNG → auto/<경로 id>.png - 페이지 주소 = KAKAO_CAPTURE_ORIGIN(기본 http://localhost:5173) + /__route_capture page.route 채움 - 사용자 그림 우선 · 자동 그림으로 되돌리기 · 실패 = 까닭 한 줄 · 동시 요청 잠금 · 좌표 저장 없음 - [지도 캡처] · [화면 캡처] · getDisplayMedia · 244-10 캡처 안내 없앰(지도 SDK 도메인 안내는 그대로) - 새 시험 test_244_11 · 244_4 · 244_9 · 244_10 기대값 고침 · requirements playwright Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
351 lines
13 KiB
TypeScript
351 lines
13 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」
|
|
* ========================================================================== */
|
|
|
|
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;
|
|
}
|
|
|
|
export interface DiagramLine {
|
|
from: string;
|
|
to: string;
|
|
x1: number;
|
|
y1: number;
|
|
x2: number;
|
|
y2: number;
|
|
labels: string[];
|
|
/** 글마다 호버(사용자 km ≠ 자동 값이면 「자동 값 ○○ km」 · 아니면 "") */
|
|
tips: 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) => {
|
|
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;
|
|
|
|
/** [길찾기] 구간 — 거래처 → 기준점 · 기준점 → 시점(거침) · 기준점 없으면 거래처 → 시점
|
|
* (시점 → 중점 = 노선 측점 거리 그대로 · 길찾기 안 함) */
|
|
export function kmSegments(data: MilestoneData, routeId: string, start: LonLat): KmSegment[] {
|
|
const route = data.routes.find((r) => r.id === routeId);
|
|
if (!route) return [];
|
|
const viaBase = data.base.enabled && route.via_base;
|
|
const baseName = data.base.name || "기준점";
|
|
const from = pointOf(route);
|
|
const first: KmSegment = {
|
|
key: "route",
|
|
text: `${route.name || "거래처"} → ${viaBase ? baseName : "시점"}`,
|
|
from,
|
|
to: viaBase ? pointOf(data.base) : (start ?? null),
|
|
};
|
|
if (!viaBase) return [first];
|
|
return [
|
|
first,
|
|
{ key: "base", text: `${baseName} → 시점`, from: pointOf(data.base), to: start ?? null },
|
|
];
|
|
}
|
|
|
|
/** 카카오 지도 표지(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)];
|
|
};
|
|
|
|
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, legs: Leg[]): void => {
|
|
const [x1, y1] = center.get(from)!;
|
|
const [x2, y2] = center.get(to)!;
|
|
lines.push({ from, to, x1, y1, x2, y2, labels: legText(legs), tips: legs.map(legTip) });
|
|
};
|
|
|
|
// 공사지 = 가운데 컨테이너 · 안에 시점(왼) · 중점(오른)
|
|
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", [{ 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 || "기준점",
|
|
});
|
|
line("base", "start", data.base.legs);
|
|
}
|
|
|
|
// 거래처 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히
|
|
const groups = new Map<
|
|
string,
|
|
Array<{ id: string; label: string; items: 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, 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,
|
|
items: g.items,
|
|
});
|
|
line(g.id, g.anchor === "base" ? "base" : "start", 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("/")}`;
|
|
}
|
|
|
|
/** 선 글 한 줄 → 그 구간 — 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";
|
|
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 });
|