120 lines
3.4 KiB
TypeScript
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));
|
|
}
|