/* ============================================================================= * B08_Quantity_UI_Milestone_State.ts * 거리이정표(PLAN 244-1) 데이터 층 — 저장본 · 캐시 · [저장] · 경로 그림 길(좌측 패널 카드는 244-6 에서 없앰 · * 공사지 · 기준점은 다이어그램 모달). * * 저장본 = GET …/quantity/milestone(서버 정본 · 표는 서버가 셈) · 조작 = 캐시 `b08-milestone` 즉시 + * 표는 TS 짝(`…_Milestone_Calc`)이 바로 셈 · [저장](좌측 패널 한 줄) = PUT → 캐시 비움 · 자동 저장 없음. * 그림 = 올리는 즉시 서버(경로 하나 = 그림 하나 · B09 견적서 꼴). * [길찾기](244-7) = 구간마다 서버 POST …/directions(카카오 키는 서버만) → 자동 km 를 캐시에 · * 단추 누를 때만(조작 중 자동 호출 없음) · km 만 남김(경로 좌표 · 응답 원문 없음) * 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐) * 경로 그림 자동(244-11) = [길찾기] 뒤 길 선 · 표지를 서버 POST …/image/auto → 서버 헤드리스 캡처 그림 * (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김 * 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 · 기준점 → to(기준점 · 시점) · 254) · * 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점) * 장소 그림(256-1) = 상자 좌표가 정해지면 `capturePlace` → 서버 캡처 `place/<상자 id>.png` · * [저장] 뒤엔 서버가 바뀐 상자만 다시(다음 읽기에 보임) · `placeImageKey` · `placeImageBlobUrl` * 기준점 여럿(254) = `bases[]` · 길 선 표 키 = 거래처 · 기준점 id · 옛 꼴 캐시 = 읽을 때 새 꼴(`migrateMilestone`) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { PAVED, SITE_ID, migrateMilestone, type MilestoneData, type SegKind, } from "./B08_Quantity_Milestone_Calc"; import { mapMarks, ownerOf, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout"; import type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal"; export interface SiteInfo { length_m: number | null; mid_default_m: number | null; start_lonlat: [number, number] | null; mid_lonlat: [number, number] | null; } interface Payload { data: MilestoneData; site: SiteInfo; images: Record; auto_images: Record; place_images: Record; place_times: Record; } export const EMPTY: MilestoneData = { site: { mid_station_m: null, mid_road: "비포장" }, bases: [], routes: [], }; const changes = new EventTarget(); let saved: Payload | null = null; let savedFor: string | null = null; const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; /** 저장본 읽기(프로젝트마다 한 번 · force = 다시) */ export async function loadMilestone(projectId: string, force = false): Promise { if (!saved || savedFor !== projectId || force) { saved = await request(`/projects/${projectId}/quantity/milestone`); savedFor = projectId; changes.dispatchEvent(new Event("change")); } return saved; } export const siteInfo = (): SiteInfo | null => saved?.site ?? null; /** 길찾기 시점 = 경로 탭에서 끌어 놓은 덮개(255-2 · site.start_pin) · 없으면 노선 첫 점 */ export const startPoint = (data: MilestoneData): [number, number] | null => { const pin = data.site.start_pin; return pin ? [pin.lon, pin.lat] : (siteInfo()?.start_lonlat ?? null); }; /** 그 자리(시점 "start" · 기준점 · 거래처 id)에 닿는 구간(255-2) — 나가는 구간(자기) + 들어오는 구간(to = 그 자리) */ export function touchingSegments(data: MilestoneData, key: string): string[] { const to = key === "start" ? SITE_ID : key; const incoming = [...data.bases, ...data.routes].filter((o) => o.to === to && o.linked !== false); return [...(key === "start" ? [] : [key]), ...incoming.map((o) => o.id)]; } /** 시점 · 중점 주소(244-12 · 다이어그램 상자 작은 글씨) — 중점 측점마다 한 번 서버 VWorld(뒤에서 · * 오면 다시 그림) · 아직 · 실패 = null(좌표만) */ const siteAddresses = new Map(); export function siteAddress(projectId: string, midM: number | null): SiteAddress | null { const key = `${projectId}:${midM ?? ""}`; if (!siteAddresses.has(key)) { siteAddresses.set(key, null); const query = midM == null ? "" : `?mid_m=${midM}`; request(`/projects/${projectId}/quantity/milestone/site-address${query}`).then( (s) => { siteAddresses.set(key, s); changes.dispatchEvent(new Event("change")); }, () => undefined, ); } return siteAddresses.get(key) ?? null; } export const routeImages = (): Record => saved?.images ?? {}; /** 사용자가 올린 그림이 있음(157 · 자동 그림과 다른 이름) — 경로 탭 = 그 그림 · 없으면 길찾기 지도 */ export const hasUserImage = (routeId: string): boolean => { const name = saved?.images[routeId]; return !!name && name !== saved?.auto_images?.[routeId]; }; /** 경로 그림 다시 읽을 표(이름 + 자동 그림 새로 만든 횟수 — 이름이 같아도 새 그림) */ const imageRev = new Map(); export const routeImageKey = (routeId: string): string | null => { const name = saved?.images[routeId]; return name ? `${name}#${imageRev.get(routeId) ?? 0}` : null; }; export const hasMilestoneDraft = (projectId: string): boolean => readState("b08-milestone", projectId) !== null; /** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본 · 옛 꼴 캐시 = 새 꼴 · 254) */ export function liveMilestone(projectId: string): MilestoneData { return migrateMilestone( clone(readState("b08-milestone", projectId) ?? saved?.data ?? EMPTY), ); } /** 고친 한 벌을 캐시에 · 화면에 알림 */ export function putMilestone(projectId: string, data: MilestoneData): void { writeState("b08-milestone", data, projectId); changes.dispatchEvent(new Event("change")); } /** host 가 붙어 있는 동안 바뀔 때마다 fn — 떨어진 뒤 첫 알림에서 스스로 뗌 */ export function onMilestoneChange(host: HTMLElement, fn: () => void): void { const listener = (): void => { if (host.isConnected) fn(); else changes.removeEventListener("change", listener); }; changes.addEventListener("change", listener); } /** [저장] — 캐시가 있을 때만 PUT · 서버가 표를 다시 셈 */ export async function saveMilestone(projectId: string): Promise { const draft = readState("b08-milestone", projectId); if (!draft) return; saved = await request(`/projects/${projectId}/quantity/milestone`, { method: "PUT", body: JSON.stringify(draft), }); savedFor = projectId; clearState("b08-milestone", projectId); changes.dispatchEvent(new Event("change")); } const imageUrl = (projectId: string, routeId: string): string => `${API_BASE_URL}/projects/${projectId}/quantity/milestone/image?route=${encodeURIComponent(routeId)}`; async function call(input: string, init: RequestInit = {}): Promise { const response = await fetch(input, { credentials: "include", ...init }); if (!response.ok) { const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; throw new Error(typeof body?.detail === "string" ? body.detail : `HTTP ${response.status}`); } return response; } /** 경로 그림 — 올리기(교체) · 지우기는 즉시 서버 · 목록 고쳐 알림 */ export async function uploadImage(projectId: string, routeId: string, file: File): Promise { const form = new FormData(); form.append("file", file); const body = (await ( await call(imageUrl(projectId, routeId), { method: "POST", body: form }) ).json()) as { name: string }; if (saved) saved.images = { ...saved.images, [routeId]: body.name }; changes.dispatchEvent(new Event("change")); } /** 그림 지우기(자동 그림까지) · userOnly = 사용자 그림만 = 자동 그림으로 되돌리기 */ export async function deleteImage( projectId: string, routeId: string, userOnly = false, ): Promise { const url = imageUrl(projectId, routeId) + (userOnly ? "&user_only=true" : ""); const body = (await (await call(url, { method: "DELETE" })).json()) as { name: string | null }; if (saved) { const rest = { ...saved.images }; const auto = { ...saved.auto_images }; delete rest[routeId]; if (body.name) rest[routeId] = body.name; else delete auto[routeId]; saved.images = rest; saved.auto_images = auto; } changes.dispatchEvent(new Event("change")); } /** 경로 그림 자동(244-11) — 길 선 · 표지만 보냄 → 서버 헤드리스 캡처 → 자동 그림(사용자 그림 우선) */ export async function autoRouteImage( projectId: string, routeId: string, lines: Array>, ): Promise { // 표지 = 거래처 쪽 구간 두 끝(mapMarks 차례 = 거래처 · 기준점(거침) · 시점 → 앞 둘) const marks = mapMarks( liveMilestone(projectId), routeId, startPoint(liveMilestone(projectId)), ).slice(0, 2); let reason: string | null; try { const res = await request<{ name: string | null; reason: string | null }>( `/projects/${projectId}/quantity/milestone/image/auto?route=${encodeURIComponent(routeId)}`, { method: "POST", body: JSON.stringify({ lines, marks }) }, ); reason = res.reason; if (res.name && saved) { const user = saved.images[routeId] && saved.images[routeId] !== saved.auto_images?.[routeId]; saved.auto_images = { ...saved.auto_images, [routeId]: res.name }; if (!user) saved.images = { ...saved.images, [routeId]: res.name }; imageRev.set(routeId, (imageRev.get(routeId) ?? 0) + 1); } } catch (error) { reason = `경로 그림 자동 캡처 요청 실패(${String(error)}) — [그림 변경]으로 넣으세요.`; } if (reason) showToast(reason, "warning"); changes.dispatchEvent(new Event("change")); } /** 장소 그림(256-1) 다시 읽을 표 — 이름 + 바뀐 시각 · 없으면 null */ export const placeImageKey = (placeId: string): string | null => { const name = saved?.place_images?.[placeId]; return name ? `${name}#${saved?.place_times?.[placeId] ?? 0}` : null; }; /** 장소 그림(256-1) — 상자 좌표 · 이름 → 서버 캡처 → 목록 고쳐 알림(뒤에서 · 실패 = 알림 한 줄) */ export async function capturePlace( projectId: string, placeId: string, mark: { name: string; lon: number; lat: number }, ): Promise { let reason: string | null; try { const res = await request<{ name: string | null; time: number | null; reason: string | null }>( `/projects/${projectId}/quantity/milestone/place-image?id=${encodeURIComponent(placeId)}`, { method: "POST", body: JSON.stringify(mark) }, ); reason = res.reason; if (res.name && saved) { saved.place_images = { ...saved.place_images, [placeId]: res.name }; saved.place_times = { ...saved.place_times, [placeId]: res.time ?? Date.now() }; } } catch (error) { reason = `장소 그림 캡처 요청 실패(${String(error)})`; } if (reason) showToast(reason, "warning"); changes.dispatchEvent(new Event("change")); } /** 장소 그림 → 화면 주소(blob · 쿠키 포함) */ export async function placeImageBlobUrl(projectId: string, placeId: string): Promise { const name = saved?.place_images?.[placeId] ?? `place/${placeId}.png`; const url = `${API_BASE_URL}/projects/${projectId}/quantity/milestone/file?name=${encodeURIComponent(name)}`; return URL.createObjectURL(await (await call(url)).blob()); } /** 그림 → 화면 주소(blob · 쿠키 포함) */ export async function imageBlobUrl(projectId: string, routeId: string): Promise { return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob()); } /** 도로 구분 ▾(152) — 그 자리 포장 종류 · 비포장 둘(포장 종류는 선 자리로 정함 · `roadOf`) */ export function roadOptions(kind: SegKind): Array<{ value: string; text: string }> { return [PAVED[kind], "비포장"].map((r) => ({ value: r, text: r })); } /** 마지막 [길찾기] 길 선 — 구간(from = 거래처 · 기준점 id) → [위도, 경도] 목록(메모리만 · 저장 안 함) */ const paths = new Map>(); export const segmentPath = (from: string): Array<[number, number]> => paths.get(from) ?? []; /** 경로 탭 · 자동 그림 길 선 = 거래처 쪽 구간 */ export const routePaths = (routeId: string): Array> => paths.has(routeId) ? [paths.get(routeId)!] : []; /** [길찾기] 한 구간(연결선 · 244-13) — 서버가 카카오로 km · 자동 값을 캐시에(sameKm = 같으면 안 씀) · * 거래처 쪽 구간 = 자동 그림(뒤에서) · 돌려줌 = 결과 한 줄(까닭 줄엔 「 — 」) */ export async function findSegmentKm( projectId: string, from: string, image = true, ): Promise { const data = liveMilestone(projectId); const owner = ownerOf(data, from); if (owner?.manual) return ""; // 155 수기 입력 = 카카오 길찾기 안 함 const seg = segmentEnds(data, from, startPoint(data)); if (!seg) return ""; if (!seg.from || !seg.to) { const missing = seg.text.split(" → ")[seg.from ? 1 : 0]; return `${seg.text} — ${missing} 좌표가 없습니다(소재지 · 노선) · km 를 손으로 넣으세요.`; } try { const res = await request<{ km: number | null; reason: string | null; path?: Array<[number, number]>; }>(`/projects/${projectId}/quantity/milestone/directions`, { method: "POST", body: JSON.stringify({ origin: { lon: seg.from[0], lat: seg.from[1] }, destination: { lon: seg.to[0], lat: seg.to[1] }, }), }); if (res.km == null) return `${seg.text} — ${res.reason ?? "길찾기 실패"}`; if ((res.path?.length ?? 0) >= 2) paths.set(from, res.path!); else paths.delete(from); const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에 if (ownerOf(fresh, from)?.legs[0]?.auto_km !== res.km) { putAutoKm(fresh, from, res.km); putMilestone(projectId, fresh); } else changes.dispatchEvent(new Event("change")); if (image && seg.key === "route" && paths.has(from)) void autoRouteImage(projectId, from, routePaths(from)); // 뒤에서 · km 알림 안 기다림 return `${seg.text} ${res.km} km`; } catch (error) { return `${seg.text} — 길찾기 요청 실패(${String(error)}) · km 를 손으로 넣으세요.`; } }