- 공사지 · 기준점 [+] = 모달(항목 · 업체 이름 · 목적지 + B01 주소 검색 · 지도) · 방위 고르기 없앰 · 좌표로 자동 자리 - 상자 누름 = 모달(업체 = 항목 정보 · 구간 km · 도로 구분 · 고침 · 삭제 / 공사지 = 등록 주소 · VWorld 주소 · 지도 / 기준점 = 공통 구간) - 다이어그램 팬 · 줌 = 공용 b_svg_zoom_pan - 경로 하위 탭 = 그림만 · [다음 지도에서 길찾기] 새 창 · [화면 캡처] · 하위 탭 [추가] · [삭제] 없앰 - 서버 site-address(좌표 → 주소 · VWorld getAddress) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
147 lines
5.8 KiB
TypeScript
147 lines
5.8 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`(휠 = 줌 · 가운데 버튼 끌기 = 팬 · 더블클릭 = 원배율)
|
|
* ========================================================================== */
|
|
|
|
import { 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, type DiagramBox } from "./B08_Quantity_Milestone_Layout";
|
|
import {
|
|
openAddItem,
|
|
openBaseInfo,
|
|
openRouteInfo,
|
|
openSiteInfo,
|
|
} from "./B08_Quantity_UI_Milestone_Modal";
|
|
import { liveMilestone, 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;
|
|
}
|
|
|
|
/** 다이어그램 — 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 layout = layoutMilestone(data, site, midKm(data, site?.mid_default_m ?? null));
|
|
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) => text.append(svg("tspan", { x, dy: i ? 16 : 0 }, label)));
|
|
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 };
|
|
}
|