/* ============================================================================= * E07_StabArea_UI_Map.ts * E07 가운데 평면 지도(PLAN 536-2) — 등급(SI) · 안전율 색 격자 · 노선 띠 · 노선 · 측점 · 고른 측점 · * 방위 · 축척 · 셀 툴팁. 휠 = 확대 · 끌기 = 옮김 · 두 번 누름 = 맞춤. 색 · 풀기 = logic/E07_StabArea_Raster. * ========================================================================== */ import { el } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { CLASS_KEYS, cellIndex, rasterRgba, type Decoded } from "./logic/E07_StabArea_Raster"; export type MapLayer = "cls" | "fs"; export interface StationPoint { chainage_m: number; label: string | null; x: number; y: number; } export interface AreaMap { root: HTMLElement; setRoute: (line: number[][], stations: StationPoint[]) => void; setBand: (halfWidthM: number) => void; setResult: (data: Decoded | null) => void; setLayer: (layer: MapLayer) => void; focus: (chainage: number) => void; dispose: () => void; } interface View { cx: number; cy: number; scale: number; // px / m } const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, ""); const css = (name: string, fallback: string): string => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; export function createAreaMap(): AreaMap { const root = el("div", { className: "e07-map" }); const canvas = el("canvas", { className: "e07-map__canvas" }); const tip = el("div", { className: "e07-map__tip" }); const empty = el("p", { className: "e07-map__empty", text: t("E07_Map_Empty") }); tip.hidden = true; root.append(canvas, tip, empty); const ctx = canvas.getContext("2d")!; let line: number[][] = []; let stations: StationPoint[] = []; let band = 100; let data: Decoded | null = null; let layer: MapLayer = "cls"; let image: HTMLCanvasElement | null = null; let picked: number | null = null; let view: View = { cx: 0, cy: 0, scale: 1 }; let width = 0; let height = 0; const toScreen = (x: number, y: number): [number, number] => [ width / 2 + (x - view.cx) * view.scale, height / 2 - (y - view.cy) * view.scale, ]; const toModel = (px: number, py: number): [number, number] => [ view.cx + (px - width / 2) / view.scale, view.cy - (py - height / 2) / view.scale, ]; function buildImage(): void { image = null; if (!data) return; const { nx, ny } = data.grid; const off = document.createElement("canvas"); off.width = nx; off.height = ny; off.getContext("2d")!.putImageData(new ImageData(rasterRgba(data, layer), nx, ny), 0, 0); image = off; } function fit(): void { const xs = line.map((p) => p[0]); const ys = line.map((p) => p[1]); if (!xs.length || !width || !height) return; const pad = band; const [x0, x1] = [Math.min(...xs) - pad, Math.max(...xs) + pad]; const [y0, y1] = [Math.min(...ys) - pad, Math.max(...ys) + pad]; const scale = Math.min(width / Math.max(x1 - x0, 1), height / Math.max(y1 - y0, 1)) * 0.92; view = { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, scale }; } function path(): void { ctx.beginPath(); line.forEach((p, i) => i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])), ); } function drawRoute(text: string, accent: string): void { if (line.length < 2) return; ctx.lineJoin = ctx.lineCap = "round"; // 노선 띠 — 중심선 ± band(옅은 띠) ctx.globalAlpha = 0.12; ctx.strokeStyle = accent; ctx.lineWidth = Math.max(2 * band * view.scale, 1); path(); ctx.stroke(); ctx.globalAlpha = 1; ctx.strokeStyle = text; ctx.lineWidth = 2; path(); ctx.stroke(); const gap = stations.length > 1 ? 20 * view.scale : 999; const every = [1, 2, 5, 10, 25, 50, 100].find((n) => n * gap >= 70) ?? 100; ctx.font = "11px sans-serif"; ctx.textBaseline = "middle"; stations.forEach((s, i) => { const [px, py] = toScreen(s.x, s.y); if (px < -50 || py < -50 || px > width + 50 || py > height + 50) return; const on = picked !== null && Math.abs(s.chainage_m - picked) < 1e-6; ctx.fillStyle = on ? accent : text; ctx.beginPath(); ctx.arc(px, py, on ? 6 : i % every === 0 ? 3 : 1.5, 0, Math.PI * 2); ctx.fill(); if ((on || i % every === 0) && s.label) ctx.fillText(shortLabel(s.label), px + 7, py - 9); }); } function drawFurniture(text: string): void { ctx.fillStyle = text; ctx.strokeStyle = text; ctx.beginPath(); ctx.moveTo(width - 24, 14); ctx.lineTo(width - 30, 34); ctx.lineTo(width - 18, 34); ctx.closePath(); ctx.fill(); ctx.font = "bold 12px sans-serif"; ctx.textAlign = "center"; ctx.fillText("N", width - 24, 46); const target = 100 / view.scale; const pow = 10 ** Math.floor(Math.log10(target)); const meters = [1, 2, 5, 10].map((k) => k * pow).find((m) => m >= target) ?? target; const px = meters * view.scale; ctx.fillStyle = css("--color-surface", "#ffffff"); ctx.globalAlpha = 0.85; ctx.fillRect(width - 24 - px, height - 42, px + 16, 34); ctx.globalAlpha = 1; ctx.fillStyle = text; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(width - 16 - px, height - 16); ctx.lineTo(width - 16, height - 16); ctx.stroke(); ctx.fillText(`${meters} m`, width - 16 - px / 2, height - 26); ctx.textAlign = "start"; } function draw(): void { const dpr = window.devicePixelRatio || 1; width = root.clientWidth; height = root.clientHeight; if (!width || !height) return; if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height); const text = css("--color-text", "#1f2937"); const accent = css("--color-primary", "#6d28d9"); if (data && image) { const g = data.grid; const [left, top] = toScreen(g.x0 - g.dx / 2, g.y0 + (g.ny - 0.5) * g.dx); ctx.imageSmoothingEnabled = false; ctx.drawImage(image, left, top, g.nx * g.dx * view.scale, g.ny * g.dx * view.scale); } drawRoute(text, accent); drawFurniture(text); empty.hidden = Boolean(data); } let drag: { x: number; y: number; view: View } | null = null; canvas.addEventListener("wheel", (event) => { event.preventDefault(); const rect = canvas.getBoundingClientRect(); const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top); const k = Math.exp(-event.deltaY * 0.0015); view = { scale: view.scale * k, cx: mx - (mx - view.cx) / k, cy: my - (my - view.cy) / k }; draw(); }); canvas.addEventListener("pointerdown", (event) => { drag = { x: event.clientX, y: event.clientY, view: { ...view } }; canvas.setPointerCapture(event.pointerId); }); canvas.addEventListener("pointerup", () => (drag = null)); canvas.addEventListener("dblclick", () => { fit(); draw(); }); canvas.addEventListener("pointerleave", () => (tip.hidden = true)); canvas.addEventListener("pointermove", (event) => { if (drag) { view = { ...drag.view, cx: drag.view.cx - (event.clientX - drag.x) / view.scale, cy: drag.view.cy + (event.clientY - drag.y) / view.scale, }; draw(); return; } const rect = canvas.getBoundingClientRect(); const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top); const i = data ? cellIndex(data.grid, mx, my) : -1; if (i < 0 || !data!.cls[i]) return void (tip.hidden = true); const near = stations.reduce( (best, s) => !best || (s.x - mx) ** 2 + (s.y - my) ** 2 < (best.x - mx) ** 2 + (best.y - my) ** 2 ? s : best, null, ); const m = data!.m[i]; tip.textContent = [ t(`E07_Class_${CLASS_KEYS[data!.cls[i] - 1]}` as LocaleKey), `SI ${data!.si[i].toFixed(3)} · Fs ${data!.fs[i].toFixed(3)}`, `${t("E07_Tip_Slope")} ${data!.slope[i].toFixed(1)}°${Number.isNaN(m) ? "" : ` · m ${m.toFixed(2)}`}`, near ? `${t("E07_Tip_Station")} ${near.label ?? near.chainage_m.toFixed(1)}` : "", ] .filter(Boolean) .join("\n"); tip.style.left = `${event.clientX - rect.left + 14}px`; tip.style.top = `${event.clientY - rect.top + 14}px`; tip.hidden = false; }); const resize = new ResizeObserver(() => { const first = !width; draw(); if (first) { fit(); draw(); } }); resize.observe(root); const theme = new MutationObserver(draw); theme.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); // 검증 창구 — 모델 좌표 → 화면(client) 좌표 · 지금 격자 · 층 const probe = { project: (x: number, y: number) => { const rect = canvas.getBoundingClientRect(); const [px, py] = toScreen(x, y); return { x: rect.left + px, y: rect.top + py }; }, data: () => data, layer: () => layer, }; (window as unknown as { __e07Map?: typeof probe }).__e07Map = probe; return { root, setRoute(nextLine, nextStations) { line = nextLine; stations = nextStations; fit(); draw(); }, setBand(halfWidthM) { band = halfWidthM; draw(); }, setResult(next) { data = next; buildImage(); draw(); }, setLayer(next) { layer = next; buildImage(); draw(); }, focus(chainage) { picked = chainage; const s = stations.find((x) => Math.abs(x.chainage_m - chainage) < 1e-6); if (s) view = { ...view, cx: s.x, cy: s.y }; draw(); }, dispose() { resize.disconnect(); theme.disconnect(); if ((window as unknown as { __e07Map?: unknown }).__e07Map === probe) delete (window as unknown as { __e07Map?: unknown }).__e07Map; }, }; }