/** * Canvas viewport helpers — shared across all rApp mini-canvases. * * Provides: * • `fitViewToNodes()` — identical fit-to-content algorithm * • `persistViewport()` / `restoreViewport()` — localStorage I/O * * Matches the behavior of the main rSpace canvas in `website/canvas.html`. */ import type { Viewport } from "./canvas-interaction"; export interface Rect { x: number; y: number; width: number; height: number; } export interface FitViewOptions { /** Padding in screen pixels around the content. Default 40. */ padding?: number; /** Maximum zoom after fit. Default 1.5. */ maxZoom?: number; /** Minimum zoom after fit. Default 0.1. */ minZoom?: number; } /** * Compute the viewport that fits the given content rects inside `target`. * Returns `null` if there are no rects or the target is zero-sized. * * `rects` are in canvas (content) coordinates. */ export function fitViewToRects( rects: Rect[], target: Element, options: FitViewOptions = {}, ): Viewport | null { if (rects.length === 0) return null; const bounding = target.getBoundingClientRect(); if (bounding.width === 0 || bounding.height === 0) return null; const pad = options.padding ?? 40; const maxZoom = options.maxZoom ?? 1.5; const minZoom = options.minZoom ?? 0.1; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const r of rects) { if (r.x < minX) minX = r.x; if (r.y < minY) minY = r.y; if (r.x + r.width > maxX) maxX = r.x + r.width; if (r.y + r.height > maxY) maxY = r.y + r.height; } const contentW = (maxX - minX) + pad * 2; const contentH = (maxY - minY) + pad * 2; const scaleX = bounding.width / contentW; const scaleY = bounding.height / contentH; const zoom = clamp(Math.min(scaleX, scaleY), minZoom, maxZoom); const x = (bounding.width - contentW * zoom) / 2 - (minX - pad) * zoom; const y = (bounding.height - contentH * zoom) / 2 - (minY - pad) * zoom; return { x, y, zoom }; } /** Convenience wrapper: extract rects from typed nodes with `position` + size. */ export function fitViewToNodes( nodes: N[], getSize: (n: N) => { w: number; h: number }, target: Element, options?: FitViewOptions, ): Viewport | null { return fitViewToRects( nodes.map(n => { const s = getSize(n); return { x: n.position.x, y: n.position.y, width: s.w, height: s.h }; }), target, options, ); } const VIEWPORT_KEY_PREFIX = "rspace_viewport:"; /** Persist viewport to localStorage under a scoped key. */ export function persistViewport(key: string, v: Viewport): void { try { localStorage.setItem(VIEWPORT_KEY_PREFIX + key, JSON.stringify({ x: v.x, y: v.y, zoom: v.zoom, })); } catch { /* quota exceeded or storage disabled — ignore */ } } /** Read a previously persisted viewport. Returns null if none stored or invalid. */ export function restoreViewport(key: string): Viewport | null { try { const raw = localStorage.getItem(VIEWPORT_KEY_PREFIX + key); if (!raw) return null; const parsed = JSON.parse(raw) as Partial; if (typeof parsed.x !== "number" || typeof parsed.y !== "number" || typeof parsed.zoom !== "number") return null; if (!isFinite(parsed.x) || !isFinite(parsed.y) || !isFinite(parsed.zoom)) return null; return { x: parsed.x, y: parsed.y, zoom: parsed.zoom }; } catch { return null; } } function clamp(v: number, lo: number, hi: number): number { return Math.min(hi, Math.max(lo, v)); }