/* ============================================================================= * 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( tag: K, attrs: Record, 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) => 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 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("[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 }>(); const picUrl = (id: string, key: string): Promise => { 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 }; }