Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts
T
eomsangdonandClaude Opus 5.5 2afb1af1ca feat(B08): 거리이정표 등록 모달 2열 · 소재지 다음 지도 · 연결선 모달 길찾기 · 도로 구분 갈래 열(244-13)
- 등록 모달 한 벌(추가 · 상자 고침 · 삭제) — 분류(기준위치 · 거래처) · 항목 고르기 · 거래처 이름 · 목적지 2열 · 큰 창
- 소재지 = 주소 찾기 · 다음 지도 누름 · 표지 끌기(직접 입력 없음) · 좌표 → VWorld 주소 서버 길
- 다이어그램 [기준점 +] 없앰 · 연결선 누름 = 도로 구분 · 거리 · 다음 지도 경로 · 끝점 끌기 = 다시 길찾기
- 시점 → 중점 = 손 입력만 · 자동 그림 = 거래처 쪽 구간
- 도로 구분 = 기준점 거침 2차 · 1차 · 비포장 · 그 밖 포장 · 비포장(파이썬 · TS 짝) · 표 열 기준점 따라

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

252 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B08_Quantity_UI_Milestone_LegModal.ts
* 거리이정표 연결선 모달(PLAN 244-13 · 사용자 147번 8) — 다이어그램 선 · 선 글 누름.
*
* 값 = 도로 구분 ▾ · 거리(km) 구간 줄(빈칸 = [길찾기] 자동 값 · 사용자 값 우선 · 244-7)
* 도로 구분 = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 · 비포장 ·
* 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장(`roadOptions` · `chainVia`)
* 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음)
* 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정)
* 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음)
* 자동 그림(244-11) = 거래처 쪽 구간 [길찾기] · 옮김 때만(`findSegmentKm`)
* ========================================================================== */
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { ROADS, type Leg, type Road } from "./B08_Quantity_Milestone_Calc";
import { chainVia, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout";
import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap";
import { toastKm } from "./B08_Quantity_UI_Milestone_Modal";
import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place";
import {
findSegmentKm,
liveMilestone,
putMilestone,
roadOptions,
segmentPath,
siteInfo,
} from "./B08_Quantity_UI_Milestone_State";
const km = (text: string): number | null => {
const n = Number(text);
return text.trim() && Number.isFinite(n) && n >= 0 ? n : null;
};
/** 구간 줄 — 도로 구분 ▾(빈 값 = 안 고름) · 거리 km(빈칸 = 자동 값) · [×] · [+ 구간] */
export function legsEditor(
initial: Leg[],
via: boolean,
): { root: HTMLElement; value: () => Leg[] } {
const list = el("div");
const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement; auto: number | null }> = [];
const add = (leg: Leg): void => {
const road = createSelectField({
label: "도로 구분",
options: roadOptions(via, true, leg.road),
compact: true,
});
const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true });
road.select.value = leg.road ?? "";
dist.input.value = leg.km == null ? "" : String(leg.km);
const auto = leg.auto_km ?? null;
if (auto != null) {
dist.input.placeholder = String(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선
dist.input.title = `자동 값 ${auto} km`;
}
const ref = { road: road.select, km: dist.input, auto };
rows.push(ref);
const drop = createButton({
label: "×",
size: "sm",
variant: "ghost",
onClick: () => {
rows.splice(rows.indexOf(ref), 1);
row.remove();
},
});
const row = el("div", {
className: "b08-milestone__line-edit",
children: [road.root, dist.root, drop],
});
row.dataset.b08MilestoneLeg = "";
list.append(row);
};
(initial.length ? initial : [{ road: null, km: null }]).forEach(add);
const more = createButton({
label: "+ 구간",
size: "sm",
variant: "ghost",
onClick: () => add({ road: null, km: null }),
});
return {
root: el("div", { children: [list, more] }),
value: () =>
rows.map(({ road, km: dist, auto }) => ({
road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null,
km: km(dist.value),
auto_km: auto,
})),
};
}
const hint = (text: string): HTMLElement => el("p", { className: "b08-options__hint", text });
/** 시점 → 중점 — 도로 구분 · 거리 늘 손 입력(빈칸 = 노선 ÷ 2) */
function openMidLeg(projectId: string): void {
const data = liveMilestone(projectId);
const site = siteInfo();
openModal({
title: "구간 — 시점 → 중점",
closeLabel: "닫기",
dialogClass: "b08-milestone__modal",
mount: (body, close, actions) => {
const road = createSelectField({
label: "도로 구분",
options: roadOptions(false, false, data.site.mid_road),
compact: true,
});
road.select.value = data.site.mid_road;
const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true });
dist.input.value =
data.site.mid_station_m == null ? "" : String(data.site.mid_station_m / 1000);
dist.input.placeholder = site?.mid_default_m == null ? "" : String(site.mid_default_m / 1000); // 빈칸 = 노선 ÷ 2
dist.input.dataset.b08MilestoneLegKm = "start";
const save = createButton({
label: "고침",
variant: "filled",
onClick: () => {
const fresh = liveMilestone(projectId);
putMilestone(
projectId,
setLeg(fresh, "start", 0, {
road: road.select.value as Road,
km: km(dist.input.value),
}),
);
close();
},
});
save.dataset.b08MilestoneLegSave = "";
actions.append(save);
body.append(
el("div", { className: "b08-milestone__grid", children: [road.root, dist.root] }),
hint("시점 → 중점 = 늘 손 입력(길찾기 없음) · 빈칸 = 노선 ÷ 2"),
);
},
});
}
/** 연결선 누름 — from = 거래처 id(거래처 → 기준점 · 시점) · "base"(기준점 → 시점) · "start"(시점 → 중점) */
export function openLegInfo(projectId: string, from: string): void {
if (from === "start") return openMidLeg(projectId);
const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null;
const seg = segmentEnds(liveMilestone(projectId), from, start());
if (!seg) return;
openModal({
title: `구간 — ${seg.text}`,
closeLabel: "닫기",
dialogClass: "b08-milestone__modal",
mount: (body, close, actions) => {
const legsOf = (): Leg[] => {
const d = liveMilestone(projectId);
return (from === "base" ? d.base.legs : d.routes.find((r) => r.id === from)?.legs) ?? [];
};
const via = chainVia(liveMilestone(projectId), from);
let legs = legsEditor(legsOf(), via);
const status = hint("");
status.dataset.b08MilestoneLegStatus = "";
/** 모달 구간 값 → 캐시(길찾기 · 옮김 앞) */
const apply = (): void => {
const fresh = liveMilestone(projectId);
const target = from === "base" ? fresh.base : fresh.routes.find((r) => r.id === from);
if (!target) return;
target.legs = legs.value();
putMilestone(projectId, fresh);
};
const redrawLegs = (): void => {
const next = legsEditor(legsOf(), via);
legs.root.replaceWith(next.root);
legs = next;
};
const pins = (): MapPin[] => {
const d = liveMilestone(projectId);
const route = d.routes.find((r) => r.id === from);
const out: MapPin[] = [];
const add = (key: string, name: string, lon?: number | null, lat?: number | null): void => {
if (lon != null && lat != null)
out.push({ key, name, lon, lat, movable: key !== "start" });
};
if (route) add(route.id, route.name || "거래처", route.lon, route.lat);
if (from === "base" || via) add("base", d.base.name || "기준점", d.base.lon, d.base.lat);
else add("start", "시점", start()?.[0], start()?.[1]);
if (from === "base") add("start", "시점", start()?.[0], start()?.[1]);
return out;
};
const drawMap = (): void => {
const path = segmentPath(from);
map.set(pins(), path.length >= 2 ? [path] : []);
};
const run = async (image = true): Promise<void> => {
find.disabled = true;
status.textContent = "길찾기 중…";
const typed = JSON.stringify(legs.value()); // 기다리는 동안 손댄 칸은 그대로
const text = await findSegmentKm(projectId, from, image).finally(
() => (find.disabled = false),
);
status.textContent = text;
if (image) toastKm(text);
if (JSON.stringify(legs.value()) === typed) redrawLegs();
drawMap();
};
// 끝점 끌어 옮김 = 그 소재지 고침(주소 다시) + 다시 길찾기
const map = editMap(projectId, {
hint: "경로 = 카카오 길찾기 · 거래처 · 기준점 표지를 끌어 옮기면 다시 길찾기",
onMove: (key, lon, lat) => {
apply();
void reverseAddress(projectId, lon, lat).then((address) => {
const fresh = liveMilestone(projectId);
const target = key === "base" ? fresh.base : fresh.routes.find((r) => r.id === key);
if (!target) return;
Object.assign(target, { lon, lat, address });
putMilestone(projectId, fresh);
void run();
});
},
});
const find = createButton({
label: "길찾기",
variant: "ghost",
onClick: () => {
apply();
void run();
},
});
find.dataset.b08MilestoneLegFind = "";
const save = createButton({
label: "고침",
variant: "filled",
onClick: () => {
apply();
close();
},
});
save.dataset.b08MilestoneLegSave = "";
actions.append(find, save);
body.append(
legs.root,
hint(
from === "base"
? "기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)"
: "빈칸 = 길찾기 자동 값 · 넣은 값이 우선",
),
status,
el("h4", { text: "경로 — 다음(카카오) 지도" }),
map.root,
);
drawMap();
// 길 선이 아직 없으면 열 때 한 번(km 같으면 안 씀 · 자동 그림 없음)
if (!segmentPath(from).length && seg.from && seg.to) void run(false);
},
});
}