Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
226 lines
9.1 KiB
TypeScript
226 lines
9.1 KiB
TypeScript
/* =============================================================================
|
|
* B08_Quantity_UI_Milestone_Route.ts
|
|
* 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4 · 157) — 머리 줄(거래처 이름 · 주소 + [그림 변경] · [초기화]) +
|
|
* 길찾기 지도(연결선 모달과 같은 카카오 지도 · 표지 끌어 소재지 옮김) · 사용자 그림을 올리면 그 그림 · [초기화] = 지도로
|
|
*
|
|
* [그림 변경] = 사용자 그림 올림(이 탭 = 그 그림) · [초기화] = 사용자 그림만 지움(길찾기 지도로 · 즉시 서버)
|
|
* 서버 자동 그림(244-11 · 선 이을 때 · 서류용)은 그대로 만듦 · 이 탭에는 안 보임(157)
|
|
* 255-2 시점도 끌기 = 길찾기용 덮개(`site.start_pin`) · 표지 옮기면 그 자리에 닿는 구간 모두 다시 길찾기 ·
|
|
* 열 때 선 없는 사슬 구간 모두 길찾기 · 실패 = 안내 한 줄 · 끌기는 그림 캡처 안 함
|
|
* ========================================================================== */
|
|
|
|
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 { chainOf, ownerOf } from "./B08_Quantity_Milestone_Layout";
|
|
import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap";
|
|
import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place";
|
|
import {
|
|
capturePlace,
|
|
deleteImage,
|
|
findSegmentKm,
|
|
hasUserImage,
|
|
imageBlobUrl,
|
|
liveMilestone,
|
|
putMilestone,
|
|
routeImageKey,
|
|
segmentPath,
|
|
startPoint,
|
|
touchingSegments,
|
|
uploadImage,
|
|
} from "./B08_Quantity_UI_Milestone_State";
|
|
|
|
let menu: MapContextMenu | null = null;
|
|
let lastCell: Element | null = null;
|
|
|
|
/** 표 칸 누름 자리 기억 — 「▾」 메뉴를 그 칸 아래에 엶 */
|
|
export function trackCells(root: HTMLElement): void {
|
|
for (const type of ["click", "contextmenu"])
|
|
root.addEventListener(type, (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 routeView(projectId: string, routeId: string): MilestoneView {
|
|
// 157 머리 줄 = 거래처 이름 · 주소(왼) + [그림 변경] · [초기화](오른) · 글자 = 메인 탭 크기
|
|
const name = el("strong", { className: "b08-milestone__route-name" });
|
|
const address = el("span", { className: "b08-milestone__route-addr" });
|
|
|
|
// 몸 = 사용자 그림 있으면 그 그림 · 없으면 길찾기 지도(연결선 모달과 같은 지도 · 표지 끌어 소재지 옮김)
|
|
const img = el("img", { className: "b08-milestone__image", attrs: { alt: "경로 그림" } });
|
|
let shown: string | null | undefined;
|
|
const loadImage = (): void => {
|
|
const key = hasUserImage(routeId) ? routeImageKey(routeId) : null;
|
|
img.hidden = !key;
|
|
if (key === shown) return;
|
|
shown = key;
|
|
if (img.src) URL.revokeObjectURL(img.src);
|
|
img.removeAttribute("src");
|
|
if (key)
|
|
imageBlobUrl(projectId, routeId).then(
|
|
(url) => (img.src = url),
|
|
(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 uploadImage(projectId, routeId, picked).then(
|
|
() => refresh(),
|
|
(error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"),
|
|
);
|
|
});
|
|
const change = createButton({
|
|
label: "그림 변경",
|
|
variant: "ghost",
|
|
onClick: () => file.click(),
|
|
});
|
|
change.dataset.b08MilestoneImageChange = "";
|
|
// 초기화 = 사용자 그림만 지우고 길찾기 지도로 되돌림
|
|
const reset = createButton({
|
|
label: "초기화",
|
|
variant: "ghost",
|
|
onClick: () =>
|
|
void deleteImage(projectId, routeId, true).then(
|
|
() => refresh(),
|
|
(error: unknown) => showToast(`초기화하지 못했습니다 — ${String(error)}`, "error"),
|
|
),
|
|
});
|
|
reset.dataset.b08MilestoneRevert = "";
|
|
|
|
const pins = (): MapPin[] => {
|
|
const d = liveMilestone(projectId);
|
|
const start = startPoint(d);
|
|
const r = d.routes.find((x) => x.id === routeId);
|
|
const out: MapPin[] = [];
|
|
const add = (key: string, label: string, lon?: number | null, lat?: number | null): void => {
|
|
if (lon != null && lat != null) out.push({ key, name: label, lon, lat, movable: true }); // 255-2 시점도 끌기(길찾기 덮개)
|
|
};
|
|
if (!r) return out;
|
|
add(r.id, r.name || "거래처", r.lon, r.lat);
|
|
if (r.linked === false) return out;
|
|
for (const b of chainOf(d, r.to)) add(b.id, b.name || "기준점", b.lon, b.lat); // 254 기준점 사슬
|
|
add("start", "시점", start?.[0], start?.[1]);
|
|
return out;
|
|
};
|
|
const lines = (): Array<Array<[number, number]>> => {
|
|
const d = liveMilestone(projectId);
|
|
const r = d.routes.find((x) => x.id === routeId);
|
|
const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : [];
|
|
return [routeId, ...chain].map(segmentPath).filter((p) => p.length >= 2);
|
|
};
|
|
// 255-2 길찾기 실패 = 안내 한 줄(까닭 줄 = 「 — 」)
|
|
const status = el("p", { className: "b08-options__hint" });
|
|
status.dataset.b08MilestoneRouteStatus = "";
|
|
status.hidden = true;
|
|
/** 길 선 여럿 다시 길찾기(그림 캡처 없음 · 캡처는 [저장] 때) → 선 다시 · 실패 안내 */
|
|
const refind = async (ids: string[]): Promise<void> => {
|
|
const texts = await Promise.all(ids.map((id) => findSegmentKm(projectId, id, false)));
|
|
const bad = texts.filter((t) => t.includes(" — "));
|
|
status.textContent = bad.join(" · ");
|
|
status.hidden = !bad.length;
|
|
drawMap();
|
|
};
|
|
/** 표지 끌기 — 시점 = 덮개(site.start_pin · 노선 그대로) · 거래처 · 기준점 = 소재지 · 닿는 선 모두 다시 */
|
|
const moveTo = async (key: string, lon: number, lat: number): Promise<void> => {
|
|
const address = key === "start" ? "" : await reverseAddress(projectId, lon, lat);
|
|
const fresh = liveMilestone(projectId);
|
|
if (key === "start") fresh.site.start_pin = { lon, lat };
|
|
else {
|
|
const target = ownerOf(fresh, key);
|
|
if (!target) return;
|
|
Object.assign(target, { lon, lat, address });
|
|
const label = fresh.bases.some((b) => b.id === key) ? "기준점" : "거래처";
|
|
void capturePlace(projectId, key, { name: target.name || label, lon, lat }); // 256-1 장소 그림
|
|
}
|
|
putMilestone(projectId, fresh);
|
|
await refind(touchingSegments(fresh, key));
|
|
};
|
|
const map = editMap(projectId, {
|
|
hint: "",
|
|
onMove: (key, lon, lat) => void moveTo(key, lon, lat),
|
|
});
|
|
const drawMap = (): void => map.set(pins(), lines());
|
|
let asked = false; // 길 선이 없으면 열 때 한 번 길찾기(사슬 선 모두 · 그림 · 알림 없음)
|
|
const ensurePath = (): void => {
|
|
if (asked) return;
|
|
asked = true;
|
|
const d = liveMilestone(projectId);
|
|
const r = d.routes.find((x) => x.id === routeId);
|
|
const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : [];
|
|
const missing = [routeId, ...chain].filter((id) => !segmentPath(id).length);
|
|
if (missing.length) void refind(missing);
|
|
};
|
|
|
|
const root = el("section", {
|
|
className: "b08-milestone__route ui-card",
|
|
attrs: { "data-b08-milestone-route": routeId },
|
|
children: [
|
|
el("div", {
|
|
className: "b08-milestone__route-head",
|
|
children: [
|
|
el("div", { className: "b08-milestone__route-title", children: [name, address] }),
|
|
el("div", { className: "b08-milestone__bar", children: [change, reset, file] }),
|
|
],
|
|
}),
|
|
status,
|
|
img,
|
|
map.root,
|
|
],
|
|
});
|
|
|
|
function refresh(): void {
|
|
const d = liveMilestone(projectId);
|
|
const r = d.routes.find((x) => x.id === routeId);
|
|
if (!r) return;
|
|
name.textContent = `${r.name || "거래처"} → ${DEST_TEXT[r.rows[0]?.dest ?? "start"]}`;
|
|
address.textContent = r.address || "";
|
|
loadImage();
|
|
const user = hasUserImage(routeId);
|
|
reset.hidden = !user;
|
|
map.root.hidden = user;
|
|
if (!user) {
|
|
drawMap();
|
|
ensurePath();
|
|
}
|
|
}
|
|
refresh();
|
|
return { root, refresh };
|
|
}
|