Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts
T
eomsangdonandClaude Opus 5.5 d70b66f57e feat(b08): 거리이정표 — 수정 글 · 상자 하나 = 항목 하나 · 폭 전체 · 처음 배율 맞춤 · 지도 높이 · 도로 구분 = 선 자리 (250)
- 등록 모달 [+ 줄] · 같은 이름 합침 없앰 · 기준위치 = 항목 · 목적지 없음 · 「고침」 → 「수정」
- 다이어그램 · 표 폭 전체 · 틀 높이 고정 · 틀 비율 그림 범위 · 처음 배율 = 상자 범위 맞춤
- 카카오 지도 높이 어디서나 480px
- 도로 구분 = 선 자리(거래처 → 기준점 2차포장 · 기준점 → 시점 1차포장 · 그 밖 포장 · 비포장만 고름) · 파이썬 · TS 짝

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

260 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)
* 도로 구분(152) = 포장 종류는 선 자리로 정함 — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 = 1차포장 ·
* 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · ▾ 는 그 포장 · 비포장 둘(`roadOptions` · `roadOf`)
* 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음)
* 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정)
* 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음)
* 자동 그림(244-11) = 거래처 쪽 구간 [길찾기] · 옮김 때만(`findSegmentKm`)
* ========================================================================== */
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import {
ROADS,
roadOf,
segKind,
type Leg,
type Road,
type SegKind,
} 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[],
kind: SegKind,
): { 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(kind), // 포장 종류 = 선 자리(152) · 비포장만 고름
compact: true,
});
const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true });
road.select.value = roadOf(leg.road, kind);
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("mid"),
compact: true,
});
road.select.value = roadOf(data.site.mid_road, "mid");
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);
const kind = segKind(liveMilestone(projectId), from);
let legs = legsEditor(legsOf(), kind);
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(), kind);
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);
},
});
}