81 lines
2.4 KiB
TypeScript
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,
|
|
};
|
|
}
|