map-zoomtospan
Version:
A cross-platform utility designed to calculate the optimal map zoom level and/or center point for a given map viewport, ensuring a balanced view of map features.
257 lines • 9.7 kB
JavaScript
// Classes
export class WebMercatorProjection {
constructor(worldSize = WebMercatorProjection.defaultWorldSize) {
Object.defineProperty(this, "worldSize", {
enumerable: true,
configurable: true,
writable: true,
value: worldSize
});
}
project(latLng, zoom) {
const x = latLng.lng / 360 + 0.5;
const y = 0.5 - (Math.log(Math.tan(Math.PI * (0.25 + latLng.lat / 360))) / Math.PI) / 2;
if (zoom === undefined) {
return { x, y };
}
const scale = Math.pow(2, zoom) * this.worldSize;
return { x: x * scale, y: y * scale };
}
unproject(point, zoom) {
if (zoom === undefined) {
return {
lat: (point.x - 0.5) * 360,
lng: (2 * Math.atan(Math.exp(Math.PI * (1 - 2 * point.y))) - Math.PI / 2) * 180 / Math.PI
};
}
const scale = Math.pow(2, zoom) * this.worldSize;
const lng = (point.x / scale - 0.5) * 360;
const lat = (2 * Math.atan(Math.exp(Math.PI * (1 - 2 * point.y / scale))) - Math.PI / 2) * 180 / Math.PI;
return { lat, lng };
}
}
Object.defineProperty(WebMercatorProjection, "defaultWorldSize", {
enumerable: true,
configurable: true,
writable: true,
value: 512
});
// Helper functions
export function wrapLng(lng) {
if (lng > 180) {
return lng - 360;
}
else if (lng < -180) {
return lng + 360;
}
return lng;
}
export function wrapLat(lat) {
if (lat > 90) {
return 90;
}
else if (lat < -90) {
return -90;
}
return lat;
}
export function wrapLatLng(latLng) {
return {
lat: wrapLat(latLng.lat),
lng: wrapLng(latLng.lng),
};
}
export function extendLatLngBounds(bounds, otherBounds) {
if ('lat' in otherBounds && 'lng' in otherBounds) {
return {
ne: {
lat: Math.max(bounds.ne.lat, otherBounds.lat),
lng: Math.max(bounds.ne.lng, otherBounds.lng),
},
sw: {
lat: Math.min(bounds.sw.lat, otherBounds.lat),
lng: Math.min(bounds.sw.lng, otherBounds.lng),
},
};
}
else if ('ne' in otherBounds && 'sw' in otherBounds) {
return {
ne: {
lat: Math.max(bounds.ne.lat, otherBounds.ne.lat),
lng: Math.max(bounds.ne.lng, otherBounds.ne.lng),
},
sw: {
lat: Math.min(bounds.sw.lat, otherBounds.sw.lat),
lng: Math.min(bounds.sw.lng, otherBounds.sw.lng),
},
};
}
throw new Error('Invalid bounds');
}
export function extendPointBounds(bounds, otherBounds) {
return {
topLeft: {
x: Math.min(bounds.topLeft.x, otherBounds.topLeft.x),
y: Math.min(bounds.topLeft.y, otherBounds.topLeft.y),
},
bottomRight: {
x: Math.max(bounds.bottomRight.x, otherBounds.bottomRight.x),
y: Math.max(bounds.bottomRight.y, otherBounds.bottomRight.y),
},
};
}
export function normalizeOverlay(overlay) {
if ('position' in overlay) {
return [overlay];
}
if ('center' in overlay) {
return [
{
position: overlay.center,
boundingRect: {
width: overlay.radius * 2,
height: overlay.radius * 2,
},
anchor: { x: 0.5, y: 0.5 },
}
];
}
if ('points' in overlay) {
let bounds = {
ne: { lng: overlay.points[0].lng, lat: overlay.points[0].lat },
sw: { lng: overlay.points[0].lng, lat: overlay.points[0].lat },
};
for (const coord of overlay.points) {
bounds = extendLatLngBounds(bounds, coord);
}
return [
{
position: { lng: bounds.ne.lng, lat: bounds.ne.lat },
boundingRect: { width: overlay.width, height: overlay.width },
anchor: { x: 0.5, y: 0.5 },
},
{
position: { lng: bounds.sw.lng, lat: bounds.sw.lat },
boundingRect: { width: overlay.width, height: overlay.width },
anchor: { x: 0.5, y: 0.5 },
}
];
}
throw new Error('Invalid overlay');
}
export function getOverlaysContainingPointBounds(overlays, zoom, projection) {
let bounds = {
topLeft: { x: Infinity, y: Infinity },
bottomRight: { x: -Infinity, y: -Infinity },
};
for (const overlay of overlays) {
const point = projection.project(overlay.position, zoom);
const { boundingRect = { width: 0, height: 0 } } = overlay;
const { anchor = { x: 0.5, y: 0.5 } } = overlay;
const overlayTopLeft = {
x: point.x - anchor.x * boundingRect.width,
y: point.y - anchor.y * boundingRect.height,
};
const overlayBottomRight = {
x: point.x + (1 - anchor.x) * boundingRect.width,
y: point.y + (1 - anchor.y) * boundingRect.height,
};
bounds = extendPointBounds(bounds, { topLeft: overlayTopLeft, bottomRight: overlayBottomRight });
}
return bounds;
}
export function extendOverlaysContainingPointBoundsWithCenter(overlays, zoom, projection, center) {
const centerPoint = projection.project(center, zoom);
const overlayBounds = getOverlaysContainingPointBounds(overlays, zoom, projection);
const leftToCenter = Math.abs(centerPoint.x - overlayBounds.topLeft.x);
const rightToCenter = Math.abs(overlayBounds.bottomRight.x - centerPoint.x);
const maxHorizontalDistance = Math.max(leftToCenter, rightToCenter);
const topToCenter = Math.abs(centerPoint.y - overlayBounds.topLeft.y);
const bottomToCenter = Math.abs(overlayBounds.bottomRight.y - centerPoint.y);
const maxVerticalDistance = Math.max(topToCenter, bottomToCenter);
const extendedBounds = {
topLeft: {
x: centerPoint.x - maxHorizontalDistance,
y: centerPoint.y - maxVerticalDistance
},
bottomRight: {
x: centerPoint.x + maxHorizontalDistance,
y: centerPoint.y + maxVerticalDistance
}
};
return extendedBounds;
}
export function isAllOverlaysCanBePutInsideContentArea(overlayBounds, contentBounds) {
const overlayWidth = Math.abs(overlayBounds.bottomRight.x - overlayBounds.topLeft.x);
const overlayHeight = Math.abs(overlayBounds.bottomRight.y - overlayBounds.topLeft.y);
const contentWidth = Math.abs(contentBounds.bottomRight.x - contentBounds.topLeft.x);
const contentHeight = Math.abs(contentBounds.bottomRight.y - contentBounds.topLeft.y);
return overlayWidth <= contentWidth && overlayHeight <= contentHeight;
}
// Main function
export function mapZoomToSpan(options) {
if (!options.overlays.length) {
return { ok: false, error: 'No overlays provided' };
}
const overlays = options.overlays.flatMap(normalizeOverlay);
const projection = options.projection || new WebMercatorProjection(options.worldSize || WebMercatorProjection.defaultWorldSize);
const contentBounds = {
topLeft: {
x: options.viewport.insets.left,
y: options.viewport.insets.top,
},
bottomRight: {
x: options.viewport.size.width - options.viewport.insets.right,
y: options.viewport.size.height - options.viewport.insets.bottom,
},
};
const centerOffsetInPixels = {
x: (options.viewport.insets.left - options.viewport.insets.right) / 2,
y: (options.viewport.insets.top - options.viewport.insets.bottom) / 2,
};
const zoomRange = options.zoomRange || [0, 20];
const precision = options.precision || 0.01;
let resultZoom = zoomRange[1];
let resultOverlayBounds = null;
let foundValidZoom = false;
const totalSteps = Math.floor((zoomRange[1] - zoomRange[0]) / precision);
let leftZoomStep = 0;
let rightZoomStep = totalSteps;
do {
const currentZoomStep = Math.floor((leftZoomStep + rightZoomStep) / 2);
let currentZoom = zoomRange[0] + currentZoomStep * precision;
let overlayBounds = getOverlaysContainingPointBounds(overlays, currentZoom, projection);
if (options.center) {
overlayBounds = extendOverlaysContainingPointBoundsWithCenter(overlays, currentZoom, projection, options.center);
}
if (isAllOverlaysCanBePutInsideContentArea(overlayBounds, contentBounds)) {
leftZoomStep = currentZoomStep + 1;
resultZoom = currentZoom;
resultOverlayBounds = overlayBounds;
foundValidZoom = true;
}
else {
rightZoomStep = currentZoomStep - 1;
}
} while (rightZoomStep >= leftZoomStep);
if (!foundValidZoom || !resultOverlayBounds) {
return { ok: false, error: 'No valid zoom was found' };
}
const overlayCenterPoint = {
x: (resultOverlayBounds.topLeft.x + resultOverlayBounds.bottomRight.x) / 2,
y: (resultOverlayBounds.topLeft.y + resultOverlayBounds.bottomRight.y) / 2,
};
const viewportCenterPoint = {
x: overlayCenterPoint.x - centerOffsetInPixels.x,
y: overlayCenterPoint.y - centerOffsetInPixels.y,
};
const viewportCenterLatLng = projection.unproject(viewportCenterPoint, resultZoom);
return {
ok: true,
result: {
center: options.center || viewportCenterLatLng,
zoom: resultZoom,
}
};
}
//# sourceMappingURL=zoomtospan.js.map