Files
Aislo/B08_Quantity/B08_Quantity_Milestone_Layout.ts
T
eomsangdonandClaude Opus 5.5 8f96b16380 feat(b08): 거리이정표 경로 그림 = 서버 자동 캡처 · 브라우저 캡처 없앰 (244-11)
- [등록] · [길찾기] 뒤 길 선 · 표지 → 서버 헤드리스 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
2026-10-10 22:59:25 +09:00

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 });