deadhead-space-freight-tycoon/apps/web/src/map/camera.ts

81 lines
2.4 KiB
TypeScript

import { create } from "zustand";
import { usePreferences } from "../state/preferences";
import type { MapPoint } from "./projection";
/** Pan (pixels) and zoom (1 = whole system fits) of the map view. */
export interface Camera {
zoom: number;
panX: number;
panY: number;
}
export interface Viewport {
width: number;
height: number;
}
export const MIN_ZOOM = 0.5;
export const MAX_ZOOM = 40;
/** Leave a little room around the map edge at zoom 1. */
const FIT = 0.92;
export const DEFAULT_CAMERA: Camera = { zoom: 1, panX: 0, panY: 0 };
/** The map view, starting where the player left it on this device (CR-011). */
export const useCamera = create<Camera>(
() => usePreferences.getState().mapCamera ?? DEFAULT_CAMERA,
);
// Save the view as it changes, at most a few times a second.
let saveTimer: ReturnType<typeof setTimeout> | null = null;
useCamera.subscribe((camera) => {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
usePreferences.getState().set({ mapCamera: camera });
}, 300);
});
/** Back to the default view, and forget the saved one ("Reset map view"). */
export function resetMapView(): void {
useCamera.setState(DEFAULT_CAMERA);
if (saveTimer) clearTimeout(saveTimer);
saveTimer = null;
usePreferences.getState().set({ mapCamera: null });
}
/** Pixels per map unit at this zoom. */
function pixelsPerUnit(viewport: Viewport, camera: Camera): number {
return (Math.min(viewport.width, viewport.height) / 2) * FIT * camera.zoom;
}
export function toScreen(point: MapPoint, viewport: Viewport, camera: Camera): MapPoint {
const k = pixelsPerUnit(viewport, camera);
return {
x: viewport.width / 2 + camera.panX + point.x * k,
y: viewport.height / 2 + camera.panY + point.y * k,
};
}
export function pan(camera: Camera, dx: number, dy: number): Camera {
return { ...camera, panX: camera.panX + dx, panY: camera.panY + dy };
}
/** Zooms by `factor`, keeping the map point under `anchor` (screen pixels) in place. */
export function zoomAt(
camera: Camera,
factor: number,
anchor: MapPoint,
viewport: Viewport,
): Camera {
const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, camera.zoom * factor));
const ratio = zoom / camera.zoom;
const cx = viewport.width / 2;
const cy = viewport.height / 2;
return {
zoom,
panX: anchor.x - cx - (anchor.x - cx - camera.panX) * ratio,
panY: anchor.y - cy - (anchor.y - cy - camera.panY) * ratio,
};
}