Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts
T
eomsangdonandClaude Opus 5.5 7be4feb1cf feat(b08): 거리이정표 다이어그램 선 글 더블클릭 = km · 도로 구분 바로 고침 (244-4 ⑥)
- 선 글 한 줄 = 그 구간(업체 · 공통 · 시점 → 중점) · 글 화면 자리에 도로 구분 ▾ · km 칸 · Enter · 칸 밖 = 반영 · Esc = 취소
- 값은 모달과 한 벌(legAt · setLeg) · 시점 → 중점 km = 중점 측점 · 표 셈 그대로

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

212 lines
8.1 KiB
TypeScript

/* =============================================================================
* B08_Quantity_UI_Milestone_Diagram.ts
* [거리이정표] 하위 탭 위쪽 다이어그램(PLAN 244-2) — SVG · 자리 = `…_Milestone_Layout`.
*
* 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체들 · 선마다 「도로 구분 km」 글
* 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 업체 = 항목 정보 · 공사지 = 시점 주소 ·
* 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`)
* 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 끌기 = 팬 · 빈 곳 더블클릭 = 원배율)
* 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소)
* ========================================================================== */
import { createSelectField, el } from "@ui/ui_template_elements";
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
import { midKm, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
import { layoutMilestone, legAt, setLeg, type DiagramBox } from "./B08_Quantity_Milestone_Layout";
import {
openAddItem,
openBaseInfo,
openRouteInfo,
openSiteInfo,
} from "./B08_Quantity_UI_Milestone_Modal";
import {
liveMilestone,
putMilestone,
roadOptions,
siteInfo,
} from "./B08_Quantity_UI_Milestone_State";
const NS = "http://www.w3.org/2000/svg";
function svg<K extends keyof SVGElementTagNameMap>(
tag: K,
attrs: Record<string, string | number>,
text?: string,
): SVGElementTagNameMap[K] {
const node = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v));
if (text !== undefined) node.textContent = text;
return node;
}
/** 선 글 바로 고침 — 글 화면 자리에 도로 구분 ▾ · km 칸 · Enter · 칸 밖 = 반영 · Esc = 취소 */
function editLegAt(
projectId: string,
anchor: Element,
from: string,
i: number,
midKm: number,
): void {
const leg = legAt(liveMilestone(projectId), from, i, midKm);
if (!leg) return;
document.querySelector(".b08-milestone__leg-edit")?.remove();
const road = createSelectField({ label: "도로 구분", options: roadOptions, compact: true });
road.select.value = leg.road;
const km = el("input", {
className: "ui-input",
attrs: { type: "number", min: "0", step: "any" },
});
km.value = leg.km == null ? "" : String(leg.km);
km.setAttribute("aria-label", "km");
const box = el("div", { className: "b08-milestone__leg-edit", children: [road.root, km] });
box.dataset.b08MilestoneLegEdit = `${from}:${i}`;
const at = anchor.getBoundingClientRect(); // 팬 · 줌 뒤 글 자리 그대로
box.style.left = `${at.left}px`;
box.style.top = `${at.bottom + 4}px`;
let done = false;
const finish = (apply: boolean): void => {
if (done) return;
done = true;
box.remove();
if (!apply) return;
const n = Number(km.value);
const value = km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null;
const patch = {
road: road.select.value as typeof leg.road,
...(km.value.trim() && value === null ? {} : { km: value }),
};
putMilestone(projectId, setLeg(liveMilestone(projectId), from, i, patch));
};
box.addEventListener("keydown", (e) => {
if (e.key === "Enter") finish(true);
if (e.key === "Escape") finish(false);
});
box.addEventListener("focusout", (e) => {
if (!box.contains(e.relatedTarget as Node | null)) finish(true);
});
document.body.append(box);
km.focus();
km.select();
}
/** 다이어그램 — refresh() 로 캐시 값 다시 그림 */
export function createDiagram(
projectId: string,
addRoute: (init: Partial<MilestoneRoute>) => string,
): { root: HTMLElement; refresh: () => void } {
const host = el("div", { className: "b08-milestone__diagram" });
host.dataset.b08Milestone = "diagram";
let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로
const INFO: Record<DiagramBox["kind"], (id: string) => void> = {
route: (id) => openRouteInfo(projectId, id),
site: () => openSiteInfo(projectId),
start: () => openSiteInfo(projectId),
mid: () => openSiteInfo(projectId),
base: () => openBaseInfo(projectId),
};
const plus = (box: DiagramBox, viaBase: boolean): SVGGElement => {
const g = svg("g", { class: "b08-milestone__plus", role: "button", tabindex: 0 });
const cx = box.x + box.w - 2;
const cy = box.y + 2;
g.append(
svg("circle", { cx, cy, r: 11 }),
svg("text", { x: cx, y: cy, "text-anchor": "middle", "dominant-baseline": "central" }, "+"),
svg("title", {}, "항목 추가"),
);
g.dataset.b08MilestonePlus = viaBase ? "base" : "site";
const go = (e: Event): void => {
e.stopPropagation();
openAddItem(projectId, viaBase, addRoute);
};
g.addEventListener("click", go);
g.addEventListener("keydown", (e) => e.key === "Enter" && go(e));
return g;
};
const refresh = (): void => {
const data = liveMilestone(projectId);
const site = siteInfo();
const mk = midKm(data, site?.mid_default_m ?? null);
const layout = layoutMilestone(data, site, mk);
const [vx, vy, vw, vh] = layout.view;
// viewBox 원점 0 — 줌 · 팬 계산이 0 기준 · 그림은 안쪽 묶음에서 옮김
const root = svg("svg", {
viewBox: `0 0 ${vw} ${vh}`,
width: vw,
height: vh,
role: "img",
"aria-label": "거리이정표 다이어그램",
});
root.style.maxWidth = "100%";
root.style.height = "auto";
const layer = svg("g", {});
const shift = svg("g", { transform: `translate(${-vx} ${-vy})` });
layer.append(shift);
root.append(layer);
for (const ln of layout.lines) {
shift.append(
svg("line", { class: "b08-milestone__line", x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }),
);
}
for (const box of layout.boxes) {
const g = svg("g", { class: `b08-milestone__box is-${box.kind}` });
g.dataset.b08MilestoneBox = box.id;
g.append(
svg("rect", { x: box.x, y: box.y, width: box.w, height: box.h, rx: 6 }),
svg(
"text",
{
x: box.x + box.w / 2,
y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2,
"text-anchor": "middle",
"dominant-baseline": "central",
},
box.label,
),
);
// 상자 누름 = 모달(탭 이동 아님)
const info = (): void => INFO[box.kind](box.id);
g.setAttribute("role", "button");
g.setAttribute("tabindex", "0");
g.addEventListener("click", (e) => {
e.stopPropagation(); // 공사지 안 시점 · 중점 상자 = 한 번만
info();
});
g.addEventListener("keydown", (e) => e.key === "Enter" && info());
if (box.kind === "site") g.append(plus(box, false));
if (box.kind === "base") g.append(plus(box, true));
shift.append(g);
}
// 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄
for (const ln of layout.lines) {
const x = (ln.x1 + ln.x2) / 2;
const y = (ln.y1 + ln.y2) / 2 - ((ln.labels.length - 1) * 16) / 2;
const text = svg("text", { class: "b08-milestone__km", x, y, "text-anchor": "middle" });
ln.labels.forEach((label, i) => {
const span = svg("tspan", { x, dy: i ? 16 : 0 }, label);
span.dataset.b08MilestoneLeg = `${ln.from}:${i}`;
span.addEventListener("dblclick", (e) => {
e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음
editLegAt(projectId, span, ln.from, i, mk);
});
text.append(span);
});
shift.append(text);
}
host.replaceChildren(root);
attachSvgZoomPan({
svg: root,
layer,
widthPx: vw,
heightPx: vh,
pad: { left: 0, right: 0, top: 0, bottom: 0 },
initial: zoomState,
onChange: (state) => (zoomState = state),
wheelZoom: true,
});
};
refresh();
return { root: host, refresh };
}