Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts
T
eomsangdonandClaude Opus 5.5 7fdbcc4794 feat(B08): 거리이정표 여러 기준점 — bases[] · 경로 to · 옛 base 옮김 · 사슬 셈(254-1)
- 데이터 = bases[](id · to = 시점 또는 다른 기준점 · linked) · 경로 via_base 대신 to
- 옛 꼴(base · via_base) = 읽을 때 · 받을 때 새 꼴로 옮김(migrate 짝 Py · TS · 저장 = 새 꼴)
- 행 합 = 거래처 구간 + 기준점 사슬 구간들(방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤)
- 도로 구분 = 시점에 닿는 기준점 구간 1차 · 그 밖 기준점 구간 · 거래처 → 기준점 2차
- 화면 = 기준점 여럿 등록 · 삭제(이은 상자 안 이음) · 연결 놓기 = 기준점 id · 길찾기 · 표지 사슬
- 시험 = test_254_1 새로 · 244 · 250 기대값 새 꼴로

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

439 lines
20 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`(휠 = 줌 · 가운데 버튼 · 빈 곳 왼쪽 끌기 = 팬 · 빈 곳 더블클릭 = 원배율)
* 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 = [+] 등록 모달 분류 「기준위치」(244-13 · [기준점 +] 없앰)
* 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 수정 · 삭제) · 선 · 선 글 누름 = 연결선 모달
* (도로 구분 · 거리 · 다음 지도 경로 · `…_UI_Milestone_LegModal`) · 선 글 더블클릭 = 바로 고침(그대로)
* 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소)
* 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7)
* 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점
* 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소
* ========================================================================== */
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
import {
midKm,
roadOf,
segKind,
withRoads,
type MilestoneRoute,
type Road,
} from "./B08_Quantity_Milestone_Calc";
import {
layoutMilestone,
legAt,
ownerOf,
panContent,
panStart,
setLeg,
type DiagramBox,
} from "./B08_Quantity_Milestone_Layout";
import { openLegInfo } from "./B08_Quantity_UI_Milestone_LegModal";
import { openPlaceModal, openSiteInfo, toastKm } from "./B08_Quantity_UI_Milestone_Modal";
import {
findSegmentKm,
liveMilestone,
putMilestone,
roadOptions,
siteAddress,
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 data = liveMilestone(projectId);
const leg = legAt(data, from, i, midKm);
if (!leg) return;
document.querySelector(".b08-milestone__leg-edit")?.remove();
const kind = segKind(data, from);
const road = createSelectField({
label: "도로 구분",
options: roadOptions(kind), // 포장 종류 = 선 자리(152) · 비포장만 고름
compact: true,
});
road.select.value = roadOf(leg.road, kind);
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");
if (leg.auto_km != null) {
km.placeholder = String(leg.auto_km); // 빈칸 = 자동 값
km.title = `자동 값 ${leg.auto_km} 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 || null) as Road | null,
...(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();
}
/** 처음 배율(149) — 상자 범위가 틀에 차도록 당김(1 ~ 1.8배 · 가운데 맞춤) · 좌표 = viewBox(바탕 이동 vx · vy 뺌) */
function fitBoxes(
boxes: DiagramBox[],
vx: number,
vy: number,
vw: number,
vh: number,
): ZoomPanState | undefined {
if (!boxes.length) return undefined;
const pad = 36;
const x0 = Math.min(...boxes.map((b) => b.x)) - pad - vx;
const y0 = Math.min(...boxes.map((b) => b.y)) - pad - vy;
const x1 = Math.max(...boxes.map((b) => b.x + b.w)) + pad - vx;
const y1 = Math.max(...boxes.map((b) => b.y + b.h)) + pad - vy;
const scale = Math.min(1.8, Math.max(1, Math.min(vw / (x1 - x0), vh / (y1 - y0))));
if (scale <= 1.001) return undefined;
const cx = (x0 + x1) / 2;
const cy = (y0 + y1) / 2;
return { scale, tx: vw / 2 - cx * scale, ty: vh / 2 - cy * scale };
}
/** 다이어그램 — 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";
// 틀 위 휠 = 줌만(244-8) — 그림 밖 여백 · 단추 줄 휠도 페이지 스크롤로 새지 않음
host.addEventListener("wheel", (e) => e.preventDefault(), { passive: false });
let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로(사용자가 줌 · 팬 했을 때만)
let userZoomed = false;
// 사용자 줌 · 팬 = 그 배율 지킴 · 그 전엔 그릴 때마다 상자 범위에 맞춰 당김(149 · 글씨 읽힘 · 새 상자도 보임)
host.addEventListener("wheel", () => (userZoomed = true));
host.addEventListener("pointermove", (e) => e.buttons && (userZoomed = true));
host.addEventListener("dblclick", () => (userZoomed = false)); // 공용 더블클릭 = 원배율
const INFO: Record<DiagramBox["kind"], (id: string) => void> = {
route: (id) => openPlaceModal(projectId, { add: false, id }),
site: () => openSiteInfo(projectId),
start: () => openSiteInfo(projectId),
mid: () => openSiteInfo(projectId),
base: (id) => openPlaceModal(projectId, { add: false, id }),
};
/** 153 상자 끌어 옮김 — 4px 넘게 움직이면 끌기(누름 = 모달은 그대로) · 놓으면 그 자리 저장(캐시 · [저장] 때 서버)
* 기준점을 옮기면 거친 거래처도 따라감(자리 안 정한 것만 · 레이아웃이 기준점 둘레로 놓음) */
const dragBox = (g: SVGGElement, box: DiagramBox, shift: SVGGElement): void => {
g.addEventListener("pointerdown", (down) => {
if (down.button !== 0) return;
down.stopPropagation(); // 빈 곳 팬 안 함
const ctm = shift.getScreenCTM();
const k = ctm ? ctm.a : 1; // 화면 px ÷ 그림 좌표
let moved = false;
const move = (e: PointerEvent): void => {
const dx = (e.clientX - down.clientX) / k;
const dy = (e.clientY - down.clientY) / k;
if (!moved && Math.hypot(e.clientX - down.clientX, e.clientY - down.clientY) < 4) return;
moved = true;
g.setAttribute("transform", `translate(${dx} ${dy})`);
};
const up = (e: PointerEvent): void => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
if (!moved) return;
const swallow = (c: Event): void => c.stopPropagation(); // 끌고 난 누름 = 모달 아님
g.addEventListener("click", swallow, { capture: true, once: true });
setTimeout(() => g.removeEventListener("click", swallow, { capture: true }), 0);
const pos: [number, number] = [
Math.round(box.x + (e.clientX - down.clientX) / k),
Math.round(box.y + (e.clientY - down.clientY) / k),
];
const fresh = liveMilestone(projectId);
const target = ownerOf(fresh, box.id);
if (!target) return;
target.pos = pos;
userZoomed = true; // 옮긴 뒤 배율 그대로
putMilestone(projectId, fresh);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
};
/** 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 상자 = 그 기준점 거침(254 · to = 기준점 id) ·
* 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 같은 연결 = 취소 · 바뀌면 그 거래처 구간 새로(km 비움) +
* 길찾기 한 번(km · 경로 그림) */
const connectDot = (box: DiagramBox, shift: SVGGElement): SVGCircleElement => {
const cx = box.x + box.w;
const cy = box.y + box.h / 2;
const dot = svg("circle", { class: "b08-milestone__connect", cx, cy, r: 7 });
dot.append(svg("title", {}, "끌어서 기준점 · 공사지에 놓으면 연결"));
dot.addEventListener("click", (e) => e.stopPropagation()); // 상자 모달 안 뜸
dot.addEventListener("pointerdown", (down) => {
if (down.button !== 0) return;
down.stopPropagation(); // 상자 끌기 · 팬 안 함
down.preventDefault();
const toLocal = (e: PointerEvent): [number, number] => {
const m = shift.getScreenCTM();
if (!m) return [cx, cy];
const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(m.inverse());
return [p.x, p.y];
};
const wire = svg("line", { class: "b08-milestone__wire", x1: cx, y1: cy, x2: cx, y2: cy });
shift.append(wire);
let over: Element | null = null;
/** 놓을 곳 — 기준점 id · "site"(공사지 · 시점 · 중점) · 없음 */
const targetOf = (e: PointerEvent): string | null => {
const hit = document.elementFromPoint(e.clientX, e.clientY);
const g = hit?.closest<SVGGElement>("[data-b08-milestone-box]");
const id = g?.dataset.b08MilestoneBox;
if (id === "site" || id === "start" || id === "mid") return "site";
return id && liveMilestone(projectId).bases.some((b) => b.id === id) ? id : null;
};
const move = (e: PointerEvent): void => {
const [x, y] = toLocal(e);
wire.setAttribute("x2", String(x));
wire.setAttribute("y2", String(y));
const t = targetOf(e);
const g = t ? shift.querySelector(`[data-b08-milestone-box="${t}"]`) : null;
if (g !== over) {
over?.classList.remove("is-drop-target");
g?.classList.add("is-drop-target");
over = g;
}
};
const up = (e: PointerEvent): void => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
wire.remove();
over?.classList.remove("is-drop-target");
const t = targetOf(e);
const fresh = liveMilestone(projectId);
const route = fresh.routes.find((r) => r.id === box.id);
if (!t || !route) return;
if (route.linked !== false && route.to === t) return; // 같은 연결 = 그대로
route.to = t;
route.linked = true; // 155 요소 추가 뒤 첫 이음
route.legs = [{ road: null, km: null }]; // 옛 구간 km = 다른 길 — 새로 길찾기
putMilestone(projectId, fresh);
void findSegmentKm(projectId, route.id).then(toastKm);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
return dot;
};
// 153 · 159 [방위 배치] — 끌어 놓은 자리 모두 지우고 방위(소재지 좌표) 자동 자리로 · 늘 보임
const arrange = createButton({
label: "방위 배치",
variant: "ghost",
onClick: () => {
const fresh = liveMilestone(projectId);
fresh.bases.forEach((b) => (b.pos = null));
fresh.routes.forEach((r) => (r.pos = null));
userZoomed = false;
putMilestone(projectId, fresh);
},
});
arrange.dataset.b08MilestoneArrange = "";
// 155 [요소 추가] — 기준점 · 거래처 등록(공사지 · 기준점 [+] 없앰) · 등록 뒤 연결 점으로 선 이음
const addElem = createButton({
label: "요소 추가",
variant: "ghost",
onClick: () => openPlaceModal(projectId, { add: true }, addRoute),
});
addElem.dataset.b08MilestoneAdd = "open";
const tools = el("div", { className: "b08-milestone__tools", children: [arrange, addElem] });
const refresh = (): void => {
const data = liveMilestone(projectId);
const site = siteInfo();
const mk = midKm(data, site?.mid_default_m ?? null);
// 시점 · 중점 주소 · 좌표(244-12) — 지금 중점 측점으로 서버 VWorld(뒤에서 · 오면 다시 그림)
const addr = siteAddress(projectId, data.site.mid_station_m ?? site?.mid_default_m ?? null);
const layout = layoutMilestone(
withRoads(data), // 선 글 도로 구분 = 선 자리 포장 종류(152)
{
start_lonlat: addr?.start_lonlat ?? site?.start_lonlat,
mid_lonlat: addr?.mid_lonlat ?? site?.mid_lonlat,
start_address: addr?.start_address,
mid_address: addr?.mid_address,
},
mk,
);
// 틀 = 페이지 폭 전체 · 높이 고정(CSS · 149) — 그림 범위를 틀 비율로 넓혀 가운데 둠
// (화면 px ↔ 그림 좌표 비율이 가로세로 같아야 팬 · 줌이 어긋나지 않음)
const fw = Math.max(host.clientWidth, 320);
const fh = Math.max(host.clientHeight, 240);
const [lx, ly, lw, lh] = layout.view;
const scale = Math.max(lw / fw, lh / fh);
const vw = fw * scale;
const vh = fh * scale;
const vx = lx - (vw - lw) / 2;
const vy = ly - (vh - lh) / 2;
// viewBox 원점 0 — 줌 · 팬 계산이 0 기준 · 그림은 안쪽 묶음에서 옮김
const root = svg("svg", {
viewBox: `0 0 ${vw} ${vh}`,
width: fw,
height: fh,
role: "img",
"aria-label": "거리이정표 다이어그램",
});
const layer = svg("g", {});
const shift = svg("g", { transform: `translate(${-vx} ${-vy})` });
layer.append(shift);
root.append(layer);
// 선 = 공사지 바탕 위 · 시점 · 중점 · 거래처 상자 아래(244-12 · 시점 → 중점 선이 바탕에 안 가림)
const lineLayer = svg("g", {});
for (const ln of layout.lines) {
const ends = { x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 };
// 누르는 자리 = 투명한 굵은 선(244-13 · 연결선 모달)
const hit = svg("line", { class: "b08-milestone__line-hit", ...ends });
hit.dataset.b08MilestoneLine = ln.from;
hit.addEventListener("click", (e) => {
e.stopPropagation();
openLegInfo(projectId, ln.from);
});
lineLayer.append(svg("line", { class: "b08-milestone__line", ...ends }), hit);
}
for (const box of layout.boxes) {
const g = svg("g", { class: `b08-milestone__box is-${box.kind}` });
g.dataset.b08MilestoneBox = box.id;
// 글 줄 = 항목(거래처 윗줄) · 이름 · 작은 글씨(주소 · 좌표) — 상자 가운데 맞춤
const rows: Array<[string, string]> = [
...(box.kind === "route" && box.items
? [[box.items, "b08-milestone__items"] as [string, string]]
: []),
[box.label, ""],
...(box.notes ?? []).map((n): [string, string] => [n, "b08-milestone__note"]),
];
const x = box.x + box.w / 2;
const text = svg("text", {
x,
y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2 - ((rows.length - 1) * 15) / 2,
"text-anchor": "middle",
"dominant-baseline": "central",
});
rows.forEach(([row, cls], i) =>
text.append(svg("tspan", { x, dy: i ? 15 : 0, ...(cls ? { class: cls } : {}) }, row)),
);
g.append(svg("rect", { x: box.x, y: box.y, width: box.w, height: box.h, rx: 6 }), text);
if (box.tip) g.append(svg("title", {}, box.tip)); // 줄이지 않은 주소
// 상자 누름 = 모달(탭 이동 아님)
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 === "route" || box.kind === "base") dragBox(g, box, shift);
if (box.kind === "route") g.append(connectDot(box, shift));
shift.append(g);
if (box.kind === "site") shift.append(lineLayer);
}
// 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄
for (const ln of layout.lines) {
// 자리 = 레이아웃이 상자 · 다른 선 · 다른 글을 피해 고른 곳(244-12 ④)
const x = ln.lx;
const y = ln.ly - ((ln.labels.length - 1) * 16) / 2;
const text = svg("text", {
class: "b08-milestone__km",
x,
y,
"text-anchor": "middle",
"dominant-baseline": "central",
});
ln.labels.forEach((label, i) => {
const span = svg("tspan", { x, dy: i ? 16 : 0 }, label);
span.dataset.b08MilestoneLeg = `${ln.from}:${i}`;
if (ln.tips[i]) span.append(svg("title", {}, ln.tips[i]));
// 누름 = 연결선 모달(더블클릭이 오면 취소) · 더블클릭 = 바로 고침
let wait = 0;
span.addEventListener("click", (e) => {
e.stopPropagation();
clearTimeout(wait);
wait = window.setTimeout(() => openLegInfo(projectId, ln.from), 250);
});
span.addEventListener("dblclick", (e) => {
e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음
clearTimeout(wait);
editLegAt(projectId, span, ln.from, i, mk);
});
text.append(span);
});
shift.append(text);
}
host.replaceChildren(root, tools);
attachSvgZoomPan({
svg: root,
layer,
widthPx: vw,
heightPx: vh,
pad: { left: 0, right: 0, top: 0, bottom: 0 },
initial: userZoomed ? zoomState : fitBoxes(layout.boxes, vx, vy, vw, vh),
onChange: (state) => (zoomState = state),
wheelZoom: true,
content: panContent(vw, vh), // 원배율에서도 팬(244-6 · 범위 없으면 1배는 안 움직임)
panStart, // 가운데 버튼 · 빈 곳 왼쪽 끌기
});
};
refresh();
// 틀 크기가 바뀌면(창 · 패널 접기 · 높이 = 58vh) 그 비율로 다시 그림 — 그림이 틀보다 크면 아래가 잘림(251)
let lastW = 0;
let lastH = 0;
new ResizeObserver(() => {
const w = host.clientWidth;
const h = host.clientHeight;
if (w && (Math.abs(w - lastW) > 1 || Math.abs(h - lastH) > 1)) {
lastW = w;
lastH = h;
refresh();
}
}).observe(host);
return { root: host, refresh };
}