Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts
T
eomsangdonandClaude Opus 5.5 7fdbcc4794 feat(B08): 거리이정표 여러 기준점 — bases[] · 경로 to · 옛 base 옮김 · 사슬 셈(254-1)
- 데이터 = bases[](id · to = 시점 또는 다른 기준점 · linked) · 경로 via_base 대신 to
- 옛 꼴(base · via_base) = 읽을 때 · 받을 때 새 꼴로 옮김(migrate 짝 Py · TS · 저장 = 새 꼴)
- 행 합 = 거래처 구간 + 기준점 사슬 구간들(방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤)
- 도로 구분 = 시점에 닿는 기준점 구간 1차 · 그 밖 기준점 구간 · 거래처 → 기준점 2차
- 화면 = 기준점 여럿 등록 · 삭제(이은 상자 안 이음) · 연결 놓기 = 기준점 id · 길찾기 · 표지 사슬
- 시험 = test_254_1 새로 · 244 · 250 기대값 새 꼴로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-11 13:58:00 +09:00

199 lines
7.5 KiB
TypeScript

/* =============================================================================
* B08_Quantity_UI_Milestone_Route.ts
* 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4 · 157) — 머리 줄(거래처 이름 · 주소 + [그림 변경] · [초기화]) +
* 길찾기 지도(연결선 모달과 같은 카카오 지도 · 표지 끌어 소재지 옮김) · 사용자 그림을 올리면 그 그림 · [초기화] = 지도로
*
* [그림 변경] = 사용자 그림 올림(이 탭 = 그 그림) · [초기화] = 사용자 그림만 지움(길찾기 지도로 · 즉시 서버)
* 서버 자동 그림(244-11 · 선 이을 때 · 서류용)은 그대로 만듦 · 이 탭에는 안 보임(157)
* ========================================================================== */
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 {
deleteImage,
findSegmentKm,
hasUserImage,
imageBlobUrl,
liveMilestone,
putMilestone,
routeImageKey,
segmentPath,
siteInfo,
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 start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null;
const pins = (): MapPin[] => {
const d = liveMilestone(projectId);
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: key !== "start" });
};
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);
};
const map = editMap(projectId, {
hint: "",
onMove: (key, lon, lat) =>
void reverseAddress(projectId, lon, lat).then((addr) => {
const fresh = liveMilestone(projectId);
const target = ownerOf(fresh, key);
if (!target) return;
Object.assign(target, { lon, lat, address: addr });
putMilestone(projectId, fresh);
void findSegmentKm(projectId, key).then(() => drawMap());
}),
});
const drawMap = (): void => map.set(pins(), lines());
let asked = false; // 길 선이 아직 없으면 열 때 한 번 길찾기(그림 · 알림 없음)
const ensurePath = (): void => {
if (asked || segmentPath(routeId).length) return;
asked = true;
void findSegmentKm(projectId, routeId, false).then(() => drawMap());
};
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] }),
],
}),
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 };
}