Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.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

491 lines
22 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) ·
* 구간 줄마다 두 줄(254 · 1줄 도로 구분 · 2줄 km)
* 연결 점 ●(254) = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소)
* 장소 그림(254-3) = 서버 `place/<상자 id>.png`(256-1) 을 상자 글 아래 작게 · 그림 없으면 자리 없음
* 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점
* 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소
* ========================================================================== */
import { createButton, createSelectField, el, showToast } 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,
makesLoop,
ownerOf,
panContent,
panStart,
setLeg,
type DiagramBox,
} from "./B08_Quantity_Milestone_Layout";
import { openLegInfo } from "./B08_Quantity_UI_Milestone_LegModal";
import { fmtKm, openPlaceModal, openSiteInfo, toastKm } from "./B08_Quantity_UI_Milestone_Modal";
import {
findSegmentKm,
liveMilestone,
placeImageBlobUrl,
placeImageKey,
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 ? "" : fmtKm(leg.km);
const shown = km.value; // 안 고치면 저장 값 그대로 · 반올림 값으로 안 덮음
km.setAttribute("aria-label", "km");
if (leg.auto_km != null) {
km.placeholder = fmtKm(leg.auto_km); // 빈칸 = 자동 값
km.title = `자동 값 ${fmtKm(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 === shown || (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) · 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 자기 · 같은 연결 = 취소 · 기준점 돌기
* (A → B → A) = 취소 + 알림 · 바뀌면 그 구간 새로(km 비움) + 길찾기 한 번(km · 거래처 = 경로 그림) */
const connectDot = (box: DiagramBox, shift: SVGGElement): SVGCircleElement => {
const cx = box.x + 10;
const cy = box.y + 10;
const dot = svg("circle", { class: "b08-milestone__connect", cx, cy, r: 6 });
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";
if (id === box.id) return null; // 자기 = 취소
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 = ownerOf(fresh, box.id);
if (!t || !route) return;
if (route.linked !== false && route.to === t) return; // 같은 연결 = 그대로
if (box.kind === "base" && makesLoop(fresh, box.id, t)) {
showToast(
"연결이 돌아 제자리로 옵니다(기준점 A → B → A) — 연결하지 않았습니다.",
"warning",
);
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] });
/** 장소 그림(254-3 · 256-1) blob 주소 — 상자마다 키(이름#시각)가 바뀔 때만 다시 받음 */
const picUrls = new Map<string, { key: string; url: Promise<string> }>();
const picUrl = (id: string, key: string): Promise<string> => {
let got = picUrls.get(id);
if (!got || got.key !== key) {
got?.url.then(URL.revokeObjectURL, () => undefined);
got = { key, url: placeImageBlobUrl(projectId, id) };
picUrls.set(id, got);
}
return got.url;
};
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,
new Set([...data.bases, ...data.routes].map((o) => o.id).filter((id) => placeImageKey(id))),
);
// 틀 = 페이지 폭 전체 · 높이 고정(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 th = box.pic ? box.pic.y - box.y : box.h; // 글 자리 = 그림 위
const text = svg("text", {
x,
y: box.kind === "site" ? box.y + 18 : box.y + th / 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);
// 254-3 장소 그림 — 글 아래 작게(그림 없으면 자리 없음 · 못 읽으면 지움)
const key = box.pic && placeImageKey(box.id);
if (box.pic && key) {
const { x: px, y: py, w: pw, h: ph } = box.pic;
const pic = svg("image", {
class: "b08-milestone__pic",
x: px,
y: py,
width: pw,
height: ph,
});
pic.setAttribute("preserveAspectRatio", "xMidYMid slice");
g.append(pic);
picUrl(box.id, key).then(
(url) => pic.setAttribute("href", url),
() => pic.remove(),
);
}
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" || box.kind === "base") g.append(connectDot(box, shift));
shift.append(g);
if (box.kind === "site") shift.append(lineLayer);
}
// 선 글은 상자 위에(가리지 않게 맨 끝) — 구간 줄마다 두 줄(254 · 도로 구분 / km)
for (const ln of layout.lines) {
// 자리 = 레이아웃이 상자 · 다른 선 · 다른 글을 피해 고른 곳(244-12 ④)
const x = ln.lx;
const count = ln.labels.reduce((n, l) => n + l.split("\n").length, 0);
const y = ln.ly - ((count - 1) * 16) / 2;
const text = svg("text", {
class: "b08-milestone__km",
x,
y,
"text-anchor": "middle",
"dominant-baseline": "central",
});
let first = true;
ln.labels.forEach((label, i) => {
let wait = 0;
for (const row of label.split("\n")) {
const span = svg("tspan", { x, dy: first ? 0 : 16 }, row);
first = false;
span.dataset.b08MilestoneLeg = `${ln.from}:${i}`;
if (ln.tips[i]) span.append(svg("title", {}, ln.tips[i]));
// 누름 = 연결선 모달(더블클릭이 오면 취소) · 더블클릭 = 바로 고침
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 };
}