Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_Route.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

201 lines
7.4 KiB
TypeScript

/* =============================================================================
* B08_Quantity_UI_Milestone_Route.ts
* 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달).
*
* 그림 = [등록] · [길찾기] 때 서버 자동 캡처(244-11 · 카카오 지도 + 길 선 + 표지) · [그림 올리기] = 사용자
* 그림 우선 · [자동 그림으로 되돌리기] = 사용자 그림만 지움 · [그림 지우기] = 둘 다 · 즉시 서버
* 자동 그림 실패 = 까닭 한 줄(이 탭 안내 줄) · 사진 올리기
* [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 거래처 좌표 · 경유 = 기준점 좌표(거침) ·
* 도착 = 목적지 좌표) — 보기만
* 카카오 지도(244-9) = `…_UI_Milestone_KakaoMap` — 길 선 · 표지(보기만) · JS 키 없음 = 안내 한 줄
* ========================================================================== */
import {
createMapContextMenu,
type MapContextMenu,
type MapContextMenuItem,
} from "@ui/ui_template_context_menu";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
import { kakaoRouteUrl } from "./B08_Quantity_Milestone_Layout";
import {
autoImageNote,
canRevertImage,
deleteImage,
imageBlobUrl,
liveMilestone,
putMilestone,
routeImageKey,
siteInfo,
uploadImage,
} from "./B08_Quantity_UI_Milestone_State";
import { routeMapView } from "./B08_Quantity_UI_Milestone_KakaoMap";
let menu: MapContextMenu | null = null;
let lastCell: Element | null = null;
/** 표 칸 누름 자리 기억 — 「▾」 메뉴를 그 칸 아래에 엶 */
export function trackCells(root: HTMLElement): void {
root.addEventListener("click", (e) => (lastCell = (e.target as Element).closest("td")), true);
}
export function pickAtCell(items: MapContextMenuItem[]): void {
const box = lastCell?.getBoundingClientRect();
if (!box) return;
menu ??= createMapContextMenu("b08-milestone");
if (!menu.element.isConnected) {
menu.element.style.position = "fixed";
document.body.append(menu.element);
}
menu.open(box.left, box.bottom, items);
}
export const DEST_TEXT: Record<Dest, string> = { start: "시점", mid: "중점" };
/** 그 경로를 고쳐 캐시에 */
export function editRoute(
projectId: string,
routeId: string,
fn: (data: MilestoneData, route: MilestoneRoute) => void,
): void {
const data = liveMilestone(projectId);
const route = data.routes.find((r) => r.id === routeId);
if (!route) return;
fn(data, route);
putMilestone(projectId, data);
}
export interface MilestoneView {
root: HTMLElement;
refresh: () => void;
}
/** 새 창 길찾기 지점 — 거래처 → (기준점 거침 · 좌표 있으면) → 목적지(첫 표 줄 · 시점 · 중점) */
export function routePoints(
d: MilestoneData,
r: MilestoneRoute,
): Array<{ name: string; lon: number; lat: number }> | null {
const site = siteInfo();
const dest = r.rows[0]?.dest ?? "start";
const to = dest === "mid" ? site?.mid_lonlat : site?.start_lonlat;
if (r.lon == null || r.lat == null || !to) return null;
const points = [{ name: r.name || "거래처", lon: r.lon, lat: r.lat }];
if (d.base.enabled && r.via_base && d.base.lon != null && d.base.lat != null)
points.push({ name: d.base.name || "기준점", lon: d.base.lon, lat: d.base.lat });
points.push({ name: `임도 ${DEST_TEXT[dest]}`, lon: to[0], lat: to[1] });
return points;
}
export function routeView(projectId: string, routeId: string): MilestoneView {
const title = el("h4");
const HINT =
"경로 그림 = [길찾기] 때 서버가 지도에 길 선을 그려 자동으로 만듦 · [그림 올리기] = 내 그림 우선";
const hint = el("p", { className: "b08-options__hint", text: HINT });
hint.dataset.b08MilestoneRouteHint = "";
// 그림 — 경로 하나 = 그림 하나
const img = el("img", { className: "b08-milestone__image", attrs: { alt: "경로 그림" } });
const noImage = el("p", { className: "ui-general-block__empty", text: "길찾기 그림 없음" });
let shown: string | null | undefined;
const loadImage = (force = false): void => {
const name = routeImageKey(routeId);
if (!force && name === shown) return;
shown = name;
img.hidden = !name;
noImage.hidden = !!name;
if (img.src) URL.revokeObjectURL(img.src);
img.removeAttribute("src");
if (name)
imageBlobUrl(projectId, routeId).then(
(url) => (img.src = url),
(error: unknown) => showToast(`그림을 못 읽었습니다 — ${String(error)}`, "error"),
);
};
const put = (picked: File): Promise<void> =>
uploadImage(projectId, routeId, picked).then(
() => loadImage(true),
(error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"),
);
const file = el("input", { attrs: { type: "file", accept: "image/*" } });
file.hidden = true;
file.addEventListener("change", () => {
const picked = file.files?.[0];
file.value = "";
if (picked) void put(picked);
});
const kakao = createButton({
label: "다음 지도에서 길찾기",
size: "sm",
variant: "filled",
onClick: () => {
const d = liveMilestone(projectId);
const r = d.routes.find((x) => x.id === routeId);
const points = r ? routePoints(d, r) : null;
if (!points)
return showToast(
"거래처 주소(다이어그램 상자 → 항목 정보)와 노선 시점을 먼저 정하세요.",
"info",
);
window.open(kakaoRouteUrl(points), "_blank", "noopener");
},
});
kakao.dataset.b08MilestoneKakao = "";
const upload = createButton({
label: "그림 올리기",
size: "sm",
variant: "ghost",
onClick: () => file.click(),
});
const revert = createButton({
label: "자동 그림으로 되돌리기",
size: "sm",
variant: "ghost",
onClick: () =>
void deleteImage(projectId, routeId, true).catch((error: unknown) =>
showToast(`자동 그림으로 되돌리지 못했습니다 — ${String(error)}`, "error"),
),
});
revert.dataset.b08MilestoneRevert = "";
const removeImage = createButton({
label: "그림 지우기",
size: "sm",
variant: "ghost",
onClick: () =>
void deleteImage(projectId, routeId).catch((error: unknown) =>
showToast(`그림을 지우지 못했습니다 — ${String(error)}`, "error"),
),
});
const mapView = routeMapView(projectId, routeId);
const root = el("section", {
className: "b08-milestone__route ui-card",
attrs: { "data-b08-milestone-route": routeId },
children: [
title,
el("div", {
className: "b08-milestone__bar",
children: [kakao, upload, revert, removeImage, file],
}),
hint,
mapView.root,
noImage,
img,
],
});
const refresh = (): void => {
const d = liveMilestone(projectId);
const r = d.routes.find((x) => x.id === routeId);
if (!r) return;
const dest = DEST_TEXT[r.rows[0]?.dest ?? "start"];
title.textContent = `${r.name || "거래처"} → ${dest}${r.address ? ` · ${r.address}` : ""}`;
loadImage();
revert.hidden = !canRevertImage(routeId);
hint.textContent = autoImageNote(routeId) ?? HINT;
mapView.draw();
};
refresh();
return { root, refresh };
}