rspace-online/shared/canvas-viewport.ts

120 lines
3.4 KiB
TypeScript

/**
* 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<N extends { position: { x: number; y: number } }>(
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<Viewport>;
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));
}