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

252 lines
11 KiB
TypeScript

/* =============================================================================
* 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 → 서버 헤드리스 캡처 그림
* (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김
* ========================================================================== */
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 { ROADS, type MilestoneData } from "./B08_Quantity_Milestone_Calc";
import { kmSegments, mapMarks, putAutoKm, type KmSegment } from "./B08_Quantity_Milestone_Layout";
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<string, string>;
auto_images: Record<string, string>;
}
export const EMPTY: MilestoneData = {
site: { mid_station_m: null, mid_road: "비포장" },
base: { enabled: false, name: "", address: "", dir: null, legs: [] },
routes: [],
};
const changes = new EventTarget();
let saved: Payload | null = null;
let savedFor: string | null = null;
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
/** 저장본 읽기(프로젝트마다 한 번 · force = 다시) */
export async function loadMilestone(projectId: string, force = false): Promise<Payload> {
if (!saved || savedFor !== projectId || force) {
saved = await request<Payload>(`/projects/${projectId}/quantity/milestone`);
savedFor = projectId;
changes.dispatchEvent(new Event("change"));
}
return saved;
}
export const siteInfo = (): SiteInfo | null => saved?.site ?? null;
export const routeImages = (): Record<string, string> => saved?.images ?? {};
/** 보이는 그림이 사용자 그림이고 자동 그림도 있음 = 「자동 그림으로 되돌리기」 */
export const canRevertImage = (routeId: string): boolean => {
const name = saved?.images[routeId];
const auto = saved?.auto_images?.[routeId];
return !!name && !!auto && name !== auto;
};
/** 경로 그림 다시 읽을 표(이름 + 자동 그림 새로 만든 횟수 — 이름이 같아도 새 그림) */
const imageRev = new Map<string, number>();
export const routeImageKey = (routeId: string): string | null => {
const name = saved?.images[routeId];
return name ? `${name}#${imageRev.get(routeId) ?? 0}` : null;
};
/** 경로 그림 자동 상태 한 줄(만드는 중 · 실패 까닭) — 없으면 null */
const autoNotes = new Map<string, string>();
export const autoImageNote = (routeId: string): string | null => autoNotes.get(routeId) ?? null;
export const hasMilestoneDraft = (projectId: string): boolean =>
readState<MilestoneData>("b08-milestone", projectId) !== null;
/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본) */
export function liveMilestone(projectId: string): MilestoneData {
return clone(readState<MilestoneData>("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<void> {
const draft = readState<MilestoneData>("b08-milestone", projectId);
if (!draft) return;
saved = await request<Payload>(`/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<Response> {
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<void> {
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<void> {
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<Array<[number, number]>>,
): Promise<void> {
const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null);
autoNotes.set(routeId, "경로 그림 만드는 중…(서버 지도 캡처)");
changes.dispatchEvent(new Event("change"));
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) {
autoNotes.set(routeId, reason);
showToast(reason, "warning");
} else autoNotes.delete(routeId);
changes.dispatchEvent(new Event("change"));
}
/** 그림 → 화면 주소(blob · 쿠키 포함) */
export async function imageBlobUrl(projectId: string, routeId: string): Promise<string> {
return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob());
}
export const roadOptions = ROADS.map((r) => ({ value: r, text: r }));
/** 구간 도로 구분 ▾ — 빈 값 = 안 고름(자동 판정 없음 · 244-7) */
export const legRoadOptions = [{ value: "", text: "도로 구분 고르기" }, ...roadOptions];
/** 마지막 [길찾기] 길 선 — 경로 id → 구간마다 [위도, 경도] 목록(메모리만 · 저장 안 함) */
const paths = new Map<string, Array<Array<[number, number]>>>();
export const routePaths = (routeId: string): Array<Array<[number, number]>> =>
paths.get(routeId) ?? [];
/** [길찾기] — 그 거래처의 구간마다(`kmSegments`) 서버가 카카오로 km · 자동 값을 캐시에 ·
* 돌려줌 = 구간마다 결과 한 줄(km 또는 까닭 · 까닭 줄엔 「 — 」) */
export async function findRouteKm(projectId: string, routeId: string): Promise<string[]> {
const segments = kmSegments(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null);
const out: string[] = [];
const got: Array<[KmSegment["key"], number]> = [];
const lines: Array<Array<[number, number]>> = [];
for (const seg of segments) {
if (!seg.from || !seg.to) {
const missing = seg.text.split(" → ")[seg.from ? 1 : 0];
out.push(
`${seg.text} — ${missing} 좌표가 없습니다(주소 검색 · 노선) · km 를 손으로 넣으세요.`,
);
continue;
}
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) out.push(`${seg.text} — ${res.reason ?? "길찾기 실패"}`);
else {
out.push(`${seg.text} ${res.km} km`);
got.push([seg.key, res.km]);
if ((res.path?.length ?? 0) >= 2) lines.push(res.path!);
}
} catch (error) {
out.push(`${seg.text} — 길찾기 요청 실패(${String(error)}) · km 를 손으로 넣으세요.`);
}
}
paths.set(routeId, lines);
if (lines.length) void autoRouteImage(projectId, routeId, lines); // 뒤에서 · km 알림 안 기다림
if (got.length) {
const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에
for (const [key, km] of got) putAutoKm(fresh, routeId, key, km);
putMilestone(projectId, fresh);
}
return out;
}