331 lines
15 KiB
TypeScript
331 lines
15 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 → 서버 헤드리스 캡처 그림
|
|
* (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김
|
|
* 길찾기 = 연결선 한 구간씩(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<string, string>;
|
|
auto_images: Record<string, string>;
|
|
place_images: Record<string, string>;
|
|
place_times: Record<string, number>;
|
|
}
|
|
|
|
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 = <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;
|
|
|
|
/** 길찾기 시점 = 경로 탭에서 끌어 놓은 덮개(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<string, SiteAddress | null>();
|
|
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<SiteAddress>(`/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<string, string> => 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<string, number>();
|
|
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<MilestoneData>("b08-milestone", projectId) !== null;
|
|
|
|
/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본 · 옛 꼴 캐시 = 새 꼴 · 254) */
|
|
export function liveMilestone(projectId: string): MilestoneData {
|
|
return migrateMilestone(
|
|
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> {
|
|
// 표지 = 거래처 쪽 구간 두 끝(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<void> {
|
|
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<string> {
|
|
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<string> {
|
|
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<string, Array<[number, number]>>();
|
|
export const segmentPath = (from: string): Array<[number, number]> => paths.get(from) ?? [];
|
|
/** 경로 탭 · 자동 그림 길 선 = 거래처 쪽 구간 */
|
|
export const routePaths = (routeId: string): Array<Array<[number, number]>> =>
|
|
paths.has(routeId) ? [paths.get(routeId)!] : [];
|
|
|
|
/** [길찾기] 한 구간(연결선 · 244-13) — 서버가 카카오로 km · 자동 값을 캐시에(sameKm = 같으면 안 씀) ·
|
|
* 거래처 쪽 구간 = 자동 그림(뒤에서) · 돌려줌 = 결과 한 줄(까닭 줄엔 「 — 」) */
|
|
export async function findSegmentKm(
|
|
projectId: string,
|
|
from: string,
|
|
image = true,
|
|
): Promise<string> {
|
|
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 를 손으로 넣으세요.`;
|
|
}
|
|
}
|