69 lines
1.6 KiB
TypeScript
69 lines
1.6 KiB
TypeScript
/**
|
|
* Camera math for constellation fly-to — pure, easing-based interpolation.
|
|
*/
|
|
export interface CameraState {
|
|
/** Camera center in canvas space. */
|
|
x: number;
|
|
y: number;
|
|
/** Zoom factor (1 = fit). */
|
|
z: number;
|
|
}
|
|
|
|
export type Easing = (t: number) => number;
|
|
|
|
/** Smooth ease-in-out cubic. Monotonic on [0,1]. */
|
|
export const easeInOutCubic: Easing = (t) =>
|
|
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
|
|
export function flyTo(
|
|
from: CameraState,
|
|
to: CameraState,
|
|
t: number,
|
|
ease: Easing = easeInOutCubic,
|
|
): CameraState {
|
|
const clamped = Math.max(0, Math.min(1, t));
|
|
const k = ease(clamped);
|
|
return {
|
|
x: from.x + (to.x - from.x) * k,
|
|
y: from.y + (to.y - from.y) * k,
|
|
z: from.z + (to.z - from.z) * k,
|
|
};
|
|
}
|
|
|
|
/** Fit-to-view camera so a scene always starts framed. */
|
|
export function fitCamera(
|
|
nodes: { x: number; y: number; r?: number }[],
|
|
width: number,
|
|
height: number,
|
|
padding = 80,
|
|
): CameraState {
|
|
if (nodes.length === 0 || width <= 0 || height <= 0) {
|
|
return { x: width / 2, y: height / 2, z: 1 };
|
|
}
|
|
let minX = Infinity;
|
|
let minY = Infinity;
|
|
let maxX = -Infinity;
|
|
let maxY = -Infinity;
|
|
for (const n of nodes) {
|
|
const r = n.r ?? 8;
|
|
minX = Math.min(minX, n.x - r);
|
|
maxX = Math.max(maxX, n.x + r);
|
|
minY = Math.min(minY, n.y - r);
|
|
maxY = Math.max(maxY, n.y + r);
|
|
}
|
|
const w = Math.max(1, maxX - minX);
|
|
const h = Math.max(1, maxY - minY);
|
|
const z = Math.min(
|
|
3,
|
|
Math.max(
|
|
0.35,
|
|
Math.min((width - padding * 2) / w, (height - padding * 2) / h),
|
|
),
|
|
);
|
|
return {
|
|
x: (minX + maxX) / 2,
|
|
y: (minY + maxY) / 2,
|
|
z,
|
|
};
|
|
}
|