/* ============================================================================= * B08_Quantity_UI_Milestone_Route.ts * 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달). * * 길찾기 API 키 없음 → [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 업체 좌표 · * 경유 = 기준점 좌표(거침) · 도착 = 목적지 좌표) → 사용자가 길 확인 → [화면 캡처] = getDisplayMedia 로 * 고른 창 한 장 → 이 탭 그림(그림 올리기 길) · [그림 올리기] · [그림 지우기] 곁에 * 그림 = 올리는 즉시 서버(교체 · 지우기) * ========================================================================== */ 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 { deleteImage, imageBlobUrl, liveMilestone, putMilestone, routeImages, siteInfo, uploadImage, } from "./B08_Quantity_UI_Milestone_State"; 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 = { 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; } /** 화면 캡처 — 사용자가 고른 창 한 장(png) · 스트림은 바로 끔 */ async function captureWindow(): Promise { const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); try { const video = el("video"); video.muted = true; video.srcObject = stream; await video.play(); await new Promise((done) => requestAnimationFrame(done)); const canvas = el("canvas"); canvas.width = video.videoWidth; canvas.height = video.videoHeight; canvas.getContext("2d")?.drawImage(video, 0, 0); const blob = await new Promise((done, fail) => canvas.toBlob((b) => (b ? done(b) : fail(new Error("캡처 그림을 못 만듦"))), "image/png"), ); return new File([blob], "capture.png", { type: "image/png" }); } finally { stream.getTracks().forEach((t) => t.stop()); } } export function routeView(projectId: string, routeId: string): MilestoneView { const title = el("h4"); const hint = el("p", { className: "b08-options__hint", text: "[다음 지도에서 길찾기] → 새 창에서 길 확인 → [화면 캡처] → 그 창을 고름 = 이 탭 그림", }); // 그림 — 경로 하나 = 그림 하나 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 = routeImages()[routeId] ?? null; 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 => 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 capture = createButton({ label: "화면 캡처", size: "sm", variant: "ghost", onClick: () => void captureWindow().then(put, (error: unknown) => { if ((error as Error)?.name !== "NotAllowedError") showToast(`화면을 캡처하지 못했습니다 — ${String(error)}`, "error"); }), }); capture.dataset.b08MilestoneCapture = ""; const upload = createButton({ label: "그림 올리기", size: "sm", variant: "ghost", onClick: () => file.click(), }); const removeImage = createButton({ label: "그림 지우기", size: "sm", variant: "ghost", onClick: () => void deleteImage(projectId, routeId).catch((error: unknown) => showToast(`그림을 지우지 못했습니다 — ${String(error)}`, "error"), ), }); 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, capture, upload, removeImage, file], }), hint, 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(); }; refresh(); return { root, refresh }; }