- 시점 창 · 구간 창 · 다이어그램 선 위 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
491 lines
22 KiB
TypeScript
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 };
|
|
}
|