Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts
T
eomsangdonandClaude Sonnet 5.5 4737756502 fix(b08): 거리이정표 km 칸 끝수 정리 — 3자리 반올림 · 끝 0 지움 · 안 고치면 저장 m 그대로 (261-1)
- 시점 창 · 구간 창 · 다이어그램 선 위 km 칸 · 자동 값 글 = 같은 도우미(fmtKm)
- 안 고친 칸은 반올림 값으로 덮지 않음
- 시험 test_261_1 추가 · test_255_1 기대값만 고침

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-11 15:17:40 +09:00

286 lines
12 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 · 254) = 포장 종류는 선 자리로 정함 — → 기준점 = 2차포장 · 기준점 → 시점 = 1차포장 ·
* 거래처 → 시점 · 시점 → 중점 = 포장 · ▾ 는 그 포장 · 비포장 둘(`roadOptions` · `roadOf`)
* 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음)
* 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정)
* 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음)
* 자동 그림(244-11) = 거래처 쪽 구간 [길찾기] · 옮김 때만(`findSegmentKm`)
* ========================================================================== */
import { createCheckbox } from "@ui/ui_template_checkbox";
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 { ownerOf, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout";
import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap";
import { fmtKm, toastKm } from "./B08_Quantity_UI_Milestone_Modal";
import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place";
import {
findSegmentKm,
liveMilestone,
putMilestone,
roadOptions,
segmentPath,
siteInfo,
startPoint,
} 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;
orig: number | null; // 안 고친 칸 = 원래 값 그대로(반올림 값으로 안 덮음)
shown: string;
}> = [];
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 ? "" : fmtKm(leg.km);
const auto = leg.auto_km ?? null;
if (auto != null) {
dist.input.placeholder = fmtKm(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선
dist.input.title = `자동 값 ${fmtKm(auto)} km`;
}
const ref = { road: road.select, km: dist.input, auto, orig: leg.km, shown: dist.input.value };
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, orig, shown }) => ({
road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null,
km: dist.value === shown ? orig : 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 ? "" : fmtKm(data.site.mid_station_m / 1000);
dist.input.placeholder = site?.mid_default_m == null ? "" : fmtKm(site.mid_default_m / 1000); // 빈칸 = 노선 ÷ 2
const shown = dist.input.value; // 안 고치면 저장 값(m) 그대로
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,
...(dist.input.value === shown ? {} : { 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(→ 기준점 · 시점 · 254) · "start"(시점 → 중점) */
export function openLegInfo(projectId: string, from: string): void {
if (from === "start") return openMidLeg(projectId);
const start = (): [number, number] | null => startPoint(liveMilestone(projectId)); // 255-2 덮개
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[] => ownerOf(liveMilestone(projectId), from)?.legs ?? [];
const isBase = liveMilestone(projectId).bases.some((b) => b.id === 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 = ownerOf(fresh, 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;
};
/** 표지 = 그 구간 두 끝(주인 · to = 기준점 또는 시점) */
const pins = (): MapPin[] => {
const d = liveMilestone(projectId);
const owner = ownerOf(d, 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 (!owner) return out;
add(owner.id, owner.name || (isBase ? "기준점" : "거래처"), owner.lon, owner.lat);
const target = d.bases.find((b) => b.id === owner.to);
if (target) add(target.id, target.name || "기준점", target.lon, target.lat);
else 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 = ownerOf(fresh, 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);
// 155 [수기 입력] — 카카오 길찾기 연결 끊음(자동 값 지움 · 길찾기 · 지도 숨김 · km 손 입력) · 풀면 다시 길찾기
const owner = (d = liveMilestone(projectId)) => ownerOf(d, from);
const manual = createCheckbox("수기 입력 — 카카오 길찾기 안 함(거리 손 입력)");
manual.input.checked = !!owner()?.manual;
manual.input.dataset.b08MilestoneManual = "";
const auto = hint(
isBase
? "기준점 구간 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)"
: "빈칸 = 길찾기 자동 값 · 넣은 값이 우선",
);
const mapHead = el("h4", { text: "경로 — 다음(카카오) 지도" });
const showMode = (): void => {
const off = manual.input.checked;
find.hidden = auto.hidden = mapHead.hidden = map.root.hidden = off;
status.textContent = off ? "수기 입력 — 거리를 손으로 넣으세요" : status.textContent;
};
manual.input.addEventListener("change", () => {
const fresh = liveMilestone(projectId);
const target = owner(fresh);
if (!target) return;
target.legs = legs.value().map((l) => (manual.input.checked ? { ...l, auto_km: null } : l));
target.manual = manual.input.checked;
putMilestone(projectId, fresh);
redrawLegs();
showMode();
if (!manual.input.checked) void run(); // 다시 카카오 연결 = 길찾기 한 번
});
body.append(manual.root, legs.root, auto, status, mapHead, map.root);
showMode();
drawMap();
// 길 선이 아직 없으면 열 때 한 번(km 같으면 안 씀 · 자동 그림 없음 · 수기 입력이면 안 함)
if (!manual.input.checked && !segmentPath(from).length && seg.from && seg.to) void run(false);
},
});
}