314 lines
8.9 KiB
TypeScript
314 lines
8.9 KiB
TypeScript
/**
|
|
* Geo-Canvas Coordinate Transformation Utilities
|
|
*
|
|
* Provides bidirectional transformation between geographic coordinates (lat/lng)
|
|
* and canvas coordinates (x/y pixels). Supports multiple projection methods.
|
|
*
|
|
* Key concepts:
|
|
* - Geographic coords: lat/lng (WGS84)
|
|
* - Canvas coords: x/y pixels in tldraw infinite canvas space
|
|
* - Tile coords: z/x/y for OSM-style tile addressing
|
|
* - Web Mercator: The projection used by web maps (EPSG:3857)
|
|
*/
|
|
|
|
import type { Coordinate, BoundingBox, MapViewport } from '../types';
|
|
|
|
// Earth radius in meters (WGS84)
|
|
const EARTH_RADIUS = 6378137;
|
|
|
|
// Maximum latitude for Web Mercator projection (approximately)
|
|
const MAX_LATITUDE = 85.05112878;
|
|
|
|
/**
|
|
* Geographic coordinate anchor point for canvas-geo mapping.
|
|
* Defines where on the canvas a specific lat/lng maps to.
|
|
*/
|
|
export interface GeoAnchor {
|
|
geo: Coordinate;
|
|
canvas: { x: number; y: number };
|
|
zoom: number; // Map zoom level (affects scale)
|
|
}
|
|
|
|
/**
|
|
* Configuration for geo-canvas transformation
|
|
*/
|
|
export interface GeoTransformConfig {
|
|
anchor: GeoAnchor;
|
|
tileSize?: number; // Default 256
|
|
}
|
|
|
|
/**
|
|
* Convert degrees to radians
|
|
*/
|
|
export function toRadians(degrees: number): number {
|
|
return (degrees * Math.PI) / 180;
|
|
}
|
|
|
|
/**
|
|
* Convert radians to degrees
|
|
*/
|
|
export function toDegrees(radians: number): number {
|
|
return (radians * 180) / Math.PI;
|
|
}
|
|
|
|
/**
|
|
* Clamp latitude to valid Web Mercator range
|
|
*/
|
|
export function clampLatitude(lat: number): number {
|
|
return Math.max(-MAX_LATITUDE, Math.min(MAX_LATITUDE, lat));
|
|
}
|
|
|
|
/**
|
|
* Convert lat/lng to Web Mercator projected coordinates (meters)
|
|
*/
|
|
export function geoToMercator(coord: Coordinate): { x: number; y: number } {
|
|
const lat = clampLatitude(coord.lat);
|
|
const x = EARTH_RADIUS * toRadians(coord.lng);
|
|
const y = EARTH_RADIUS * Math.log(Math.tan(Math.PI / 4 + toRadians(lat) / 2));
|
|
return { x, y };
|
|
}
|
|
|
|
/**
|
|
* Convert Web Mercator coordinates (meters) to lat/lng
|
|
*/
|
|
export function mercatorToGeo(point: { x: number; y: number }): Coordinate {
|
|
const lng = toDegrees(point.x / EARTH_RADIUS);
|
|
const lat = toDegrees(2 * Math.atan(Math.exp(point.y / EARTH_RADIUS)) - Math.PI / 2);
|
|
return { lat, lng };
|
|
}
|
|
|
|
/**
|
|
* Get the scale factor at a given zoom level
|
|
* At zoom 0, the world is 256px wide (1 tile)
|
|
* At zoom n, the world is 256 * 2^n px wide
|
|
*/
|
|
export function getScaleAtZoom(zoom: number, tileSize: number = 256): number {
|
|
return tileSize * Math.pow(2, zoom);
|
|
}
|
|
|
|
/**
|
|
* Convert lat/lng to pixel coordinates at a given zoom level
|
|
* Origin (0,0) is at lat=85.05, lng=-180 (top-left of the world)
|
|
*/
|
|
export function geoToPixel(
|
|
coord: Coordinate,
|
|
zoom: number,
|
|
tileSize: number = 256
|
|
): { x: number; y: number } {
|
|
const scale = getScaleAtZoom(zoom, tileSize);
|
|
const lat = clampLatitude(coord.lat);
|
|
|
|
// Longitude: linear mapping from -180..180 to 0..scale
|
|
const x = ((coord.lng + 180) / 360) * scale;
|
|
|
|
// Latitude: Mercator projection
|
|
const latRad = toRadians(lat);
|
|
const y = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * scale;
|
|
|
|
return { x, y };
|
|
}
|
|
|
|
/**
|
|
* Convert pixel coordinates back to lat/lng
|
|
*/
|
|
export function pixelToGeo(
|
|
point: { x: number; y: number },
|
|
zoom: number,
|
|
tileSize: number = 256
|
|
): Coordinate {
|
|
const scale = getScaleAtZoom(zoom, tileSize);
|
|
|
|
// Longitude: linear mapping
|
|
const lng = (point.x / scale) * 360 - 180;
|
|
|
|
// Latitude: inverse Mercator
|
|
const n = Math.PI - (2 * Math.PI * point.y) / scale;
|
|
const lat = toDegrees(Math.atan(Math.sinh(n)));
|
|
|
|
return { lat, lng };
|
|
}
|
|
|
|
/**
|
|
* GeoCanvasTransform - Main class for transforming between geo and canvas coordinates
|
|
*/
|
|
export class GeoCanvasTransform {
|
|
private anchor: GeoAnchor;
|
|
private tileSize: number;
|
|
|
|
constructor(config: GeoTransformConfig) {
|
|
this.anchor = config.anchor;
|
|
this.tileSize = config.tileSize ?? 256;
|
|
}
|
|
|
|
/**
|
|
* Get the current anchor point
|
|
*/
|
|
getAnchor(): GeoAnchor {
|
|
return { ...this.anchor };
|
|
}
|
|
|
|
/**
|
|
* Update the anchor point (e.g., when user pans/zooms)
|
|
*/
|
|
setAnchor(anchor: GeoAnchor): void {
|
|
this.anchor = anchor;
|
|
}
|
|
|
|
/**
|
|
* Update zoom level while keeping the anchor geo-point at the same canvas position
|
|
*/
|
|
setZoom(zoom: number): void {
|
|
this.anchor = { ...this.anchor, zoom };
|
|
}
|
|
|
|
/**
|
|
* Convert geographic coordinates to canvas coordinates
|
|
*/
|
|
geoToCanvas(coord: Coordinate): { x: number; y: number } {
|
|
// Get pixel coords for both the target and anchor at current zoom
|
|
const targetPixel = geoToPixel(coord, this.anchor.zoom, this.tileSize);
|
|
const anchorPixel = geoToPixel(this.anchor.geo, this.anchor.zoom, this.tileSize);
|
|
|
|
// Calculate offset from anchor
|
|
const dx = targetPixel.x - anchorPixel.x;
|
|
const dy = targetPixel.y - anchorPixel.y;
|
|
|
|
// Apply to canvas anchor position
|
|
return {
|
|
x: this.anchor.canvas.x + dx,
|
|
y: this.anchor.canvas.y + dy,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert canvas coordinates to geographic coordinates
|
|
*/
|
|
canvasToGeo(point: { x: number; y: number }): Coordinate {
|
|
// Get anchor pixel position
|
|
const anchorPixel = geoToPixel(this.anchor.geo, this.anchor.zoom, this.tileSize);
|
|
|
|
// Calculate offset from canvas anchor
|
|
const dx = point.x - this.anchor.canvas.x;
|
|
const dy = point.y - this.anchor.canvas.y;
|
|
|
|
// Apply to pixel coords
|
|
const targetPixel = {
|
|
x: anchorPixel.x + dx,
|
|
y: anchorPixel.y + dy,
|
|
};
|
|
|
|
return pixelToGeo(targetPixel, this.anchor.zoom, this.tileSize);
|
|
}
|
|
|
|
/**
|
|
* Get the geographic bounds visible in a canvas viewport
|
|
*/
|
|
canvasBoundsToGeo(bounds: { x: number; y: number; w: number; h: number }): BoundingBox {
|
|
const topLeft = this.canvasToGeo({ x: bounds.x, y: bounds.y });
|
|
const bottomRight = this.canvasToGeo({ x: bounds.x + bounds.w, y: bounds.y + bounds.h });
|
|
|
|
return {
|
|
north: topLeft.lat,
|
|
south: bottomRight.lat,
|
|
east: bottomRight.lng,
|
|
west: topLeft.lng,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get canvas bounds for a geographic bounding box
|
|
*/
|
|
geoBoundsToCanvas(bounds: BoundingBox): { x: number; y: number; w: number; h: number } {
|
|
const topLeft = this.geoToCanvas({ lat: bounds.north, lng: bounds.west });
|
|
const bottomRight = this.geoToCanvas({ lat: bounds.south, lng: bounds.east });
|
|
|
|
return {
|
|
x: topLeft.x,
|
|
y: topLeft.y,
|
|
w: bottomRight.x - topLeft.x,
|
|
h: bottomRight.y - topLeft.y,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get meters per pixel at the current zoom level at a given latitude
|
|
*/
|
|
getMetersPerPixel(lat: number = 0): number {
|
|
const circumference = 2 * Math.PI * EARTH_RADIUS * Math.cos(toRadians(lat));
|
|
const scale = getScaleAtZoom(this.anchor.zoom, this.tileSize);
|
|
return circumference / scale;
|
|
}
|
|
|
|
/**
|
|
* Calculate distance between two canvas points in meters
|
|
*/
|
|
canvasDistanceToMeters(p1: { x: number; y: number }, p2: { x: number; y: number }): number {
|
|
const geo1 = this.canvasToGeo(p1);
|
|
const geo2 = this.canvasToGeo(p2);
|
|
return haversineDistance(geo1, geo2);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Haversine distance between two geographic coordinates (meters)
|
|
*/
|
|
export function haversineDistance(a: Coordinate, b: Coordinate): number {
|
|
const dLat = toRadians(b.lat - a.lat);
|
|
const dLng = toRadians(b.lng - a.lng);
|
|
const lat1 = toRadians(a.lat);
|
|
const lat2 = toRadians(b.lat);
|
|
|
|
const x = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
|
return EARTH_RADIUS * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x));
|
|
}
|
|
|
|
/**
|
|
* Get tile coordinates for a given lat/lng and zoom
|
|
*/
|
|
export function geoToTile(coord: Coordinate, zoom: number): { x: number; y: number; z: number } {
|
|
const n = Math.pow(2, zoom);
|
|
const x = Math.floor(((coord.lng + 180) / 360) * n);
|
|
const latRad = toRadians(clampLatitude(coord.lat));
|
|
const y = Math.floor(((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n);
|
|
return { x, y, z: zoom };
|
|
}
|
|
|
|
/**
|
|
* Get the center lat/lng of a tile
|
|
*/
|
|
export function tileCenterToGeo(x: number, y: number, z: number): Coordinate {
|
|
const n = Math.pow(2, z);
|
|
const lng = ((x + 0.5) / n) * 360 - 180;
|
|
const latRad = Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 0.5)) / n)));
|
|
return { lat: toDegrees(latRad), lng };
|
|
}
|
|
|
|
/**
|
|
* Get the bounding box of a tile
|
|
*/
|
|
export function tileBounds(x: number, y: number, z: number): BoundingBox {
|
|
const n = Math.pow(2, z);
|
|
const west = (x / n) * 360 - 180;
|
|
const east = ((x + 1) / n) * 360 - 180;
|
|
const north = toDegrees(Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n))));
|
|
const south = toDegrees(Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 1)) / n))));
|
|
return { north, south, east, west };
|
|
}
|
|
|
|
/**
|
|
* Create a default GeoCanvasTransform centered at a location
|
|
*/
|
|
export function createDefaultTransform(
|
|
center: Coordinate = { lat: 0, lng: 0 },
|
|
canvasCenter: { x: number; y: number } = { x: 0, y: 0 },
|
|
zoom: number = 10
|
|
): GeoCanvasTransform {
|
|
return new GeoCanvasTransform({
|
|
anchor: {
|
|
geo: center,
|
|
canvas: canvasCenter,
|
|
zoom,
|
|
},
|
|
});
|
|
}
|