diff --git a/LICENSE.txt b/LICENSE.txt index 1054bedc9f8..e5424065cce 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -53,8 +53,10 @@ EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- Contains a portion of d3-color https://github.com/d3/d3-color +Contains a portion of d3-geo https://github.com/d3/d3-geo +Contains a portion of d3-geo-projection https://github.com/d3/d3-geo-projection -Copyright 2010-2016 Mike Bostock +Copyright 2010-2021 Mike Bostock All rights reserved. Redistribution and use in source and binary forms, with or without modification, diff --git a/build/generate-flow-typed-style-spec.js b/build/generate-flow-typed-style-spec.js index 2cc8ccf22ba..f174ba2bd6f 100644 --- a/build/generate-flow-typed-style-spec.js +++ b/build/generate-flow-typed-style-spec.js @@ -38,6 +38,8 @@ function flowType(property) { return 'TerrainSpecification'; case 'fog': return 'FogSpecification'; + case 'projection': + return 'ProjectionSpecification'; case 'sources': return '{[_: string]: SourceSpecification}'; case '*': @@ -185,6 +187,8 @@ ${flowObjectDeclaration('TerrainSpecification', spec.terrain)} ${flowObjectDeclaration('FogSpecification', spec.fog)} +${flowObjectDeclaration('ProjectionSpecification', spec.projection)} + ${spec.source.map(key => flowObjectDeclaration(flowSourceTypeName(key), spec[key])).join('\n\n')} export type SourceSpecification = diff --git a/build/generate-struct-arrays.js b/build/generate-struct-arrays.js index 6a3e8cf86ea..a6e14614fd4 100644 --- a/build/generate-struct-arrays.js +++ b/build/generate-struct-arrays.js @@ -118,10 +118,10 @@ function camelize (str) { global.camelize = camelize; import posAttributes from '../src/data/pos_attributes.js'; -import rasterBoundsAttributes from '../src/data/raster_bounds_attributes.js'; +import boundsAttributes from '../src/data/bounds_attributes.js'; createStructArrayType('pos', posAttributes); -createStructArrayType('raster_bounds', rasterBoundsAttributes); +createStructArrayType('raster_bounds', boundsAttributes); import circleAttributes from '../src/data/bucket/circle_attributes.js'; import fillAttributes from '../src/data/bucket/fill_attributes.js'; @@ -130,6 +130,7 @@ import lineAttributesExt from '../src/data/bucket/line_attributes_ext.js'; import patternAttributes from '../src/data/bucket/pattern_attributes.js'; import dashAttributes from '../src/data/bucket/dash_attributes.js'; import skyboxAttributes from '../src/render/skybox_attributes.js'; +import tileBoundsAttributes from '../src/data/bounds_attributes.js'; import {fillExtrusionAttributes, centroidAttributes} from '../src/data/bucket/fill_extrusion_attributes.js'; // layout vertex arrays @@ -208,6 +209,9 @@ createStructArrayType('line_strip_index', createLayout([ // skybox vertex array createStructArrayType(`skybox_vertex`, skyboxAttributes); +// tile bounds vertex array +createStructArrayType(`tile_bounds`, tileBoundsAttributes); + // paint vertex arrays // used by SourceBinder for float properties @@ -244,7 +248,6 @@ fs.writeFileSync('src/data/array_types.js', import assert from 'assert'; import {Struct, StructArray} from '../util/struct_array.js'; import {register} from '../util/web_worker_transfer.js'; -import Point from '@mapbox/point-geometry'; ${layouts.map(structArrayLayoutJs).join('\n')} ${arraysWithStructAccessors.map(structArrayJs).join('\n')} diff --git a/debug/projections.html b/debug/projections.html new file mode 100644 index 00000000000..94754f0c925 --- /dev/null +++ b/debug/projections.html @@ -0,0 +1,189 @@ + + + + Mapbox GL JS debug page + + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + + diff --git a/src/data/array_types.js b/src/data/array_types.js index b14fc62d934..38d46b82217 100644 --- a/src/data/array_types.js +++ b/src/data/array_types.js @@ -815,41 +815,6 @@ class StructArrayLayout1ui2 extends StructArray { StructArrayLayout1ui2.prototype.bytesPerElement = 2; register('StructArrayLayout1ui2', StructArrayLayout1ui2); -/** - * Implementation of the StructArray layout: - * [0]: Float32[5] - * - * @private - */ -class StructArrayLayout5f20 extends StructArray { - uint8: Uint8Array; - float32: Float32Array; - - _refreshViews() { - this.uint8 = new Uint8Array(this.arrayBuffer); - this.float32 = new Float32Array(this.arrayBuffer); - } - - emplaceBack(v0: number, v1: number, v2: number, v3: number, v4: number) { - const i = this.length; - this.resize(i + 1); - return this.emplace(i, v0, v1, v2, v3, v4); - } - - emplace(i: number, v0: number, v1: number, v2: number, v3: number, v4: number) { - const o4 = i * 5; - this.float32[o4 + 0] = v0; - this.float32[o4 + 1] = v1; - this.float32[o4 + 2] = v2; - this.float32[o4 + 3] = v3; - this.float32[o4 + 4] = v4; - return i; - } -} - -StructArrayLayout5f20.prototype.bytesPerElement = 20; -register('StructArrayLayout5f20', StructArrayLayout5f20); - /** * Implementation of the StructArray layout: * [0]: Float32[2] @@ -1227,7 +1192,6 @@ export { StructArrayLayout1ul3ui12, StructArrayLayout2ui4, StructArrayLayout1ui2, - StructArrayLayout5f20, StructArrayLayout2f8, StructArrayLayout4f16, StructArrayLayout2i4 as PosArray, @@ -1251,6 +1215,6 @@ export { StructArrayLayout3ui6 as TriangleIndexArray, StructArrayLayout2ui4 as LineIndexArray, StructArrayLayout1ui2 as LineStripIndexArray, - StructArrayLayout5f20 as GlobeVertexArray, - StructArrayLayout3f12 as SkyboxVertexArray + StructArrayLayout3f12 as SkyboxVertexArray, + StructArrayLayout4i8 as TileBoundsArray }; diff --git a/src/data/raster_bounds_attributes.js b/src/data/bounds_attributes.js similarity index 100% rename from src/data/raster_bounds_attributes.js rename to src/data/bounds_attributes.js diff --git a/src/data/bucket.js b/src/data/bucket.js index b286d445c07..cc02377b070 100644 --- a/src/data/bucket.js +++ b/src/data/bucket.js @@ -9,6 +9,7 @@ import type {FeatureStates} from '../source/source_state.js'; import type {ImagePosition} from '../render/image_atlas.js'; import type LineAtlas from '../render/line_atlas.js'; import type {CanonicalTileID} from '../source/tile_id.js'; +import type {TileTransform} from '../geo/projection/tile_transform.js'; export type BucketParameters = { index: number, @@ -78,7 +79,7 @@ export interface Bucket { +layers: Array; +stateDependentLayers: Array; +stateDependentLayerIds: Array; - populate(features: Array, options: PopulateParameters, canonical: CanonicalTileID): void; + populate(features: Array, options: PopulateParameters, canonical: CanonicalTileID, tileTransform: TileTransform): void; update(states: FeatureStates, vtLayer: VectorTileLayer, availableImages: Array, imagePositions: {[_: string]: ImagePosition}): void; isEmpty(): boolean; diff --git a/src/data/bucket/circle_bucket.js b/src/data/bucket/circle_bucket.js index 6afe789757a..8193e806376 100644 --- a/src/data/bucket/circle_bucket.js +++ b/src/data/bucket/circle_bucket.js @@ -28,6 +28,7 @@ import type VertexBuffer from '../../gl/vertex_buffer.js'; import type Point from '@mapbox/point-geometry'; import type {FeatureStates} from '../../source/source_state.js'; import type {ImagePosition} from '../../render/image_atlas.js'; +import type {TileTransform} from '../../geo/projection/tile_transform.js'; function addCircleVertex(layoutVertexArray, x, y, extrudeX, extrudeY) { layoutVertexArray.emplaceBack( @@ -77,7 +78,7 @@ class CircleBucket implements Bucke this.stateDependentLayerIds = this.layers.filter((l) => l.isStateDependent()).map((l) => l.id); } - populate(features: Array, options: PopulateParameters, canonical: CanonicalTileID) { + populate(features: Array, options: PopulateParameters, canonical: CanonicalTileID, tileTransform: TileTransform) { const styleLayer = this.layers[0]; const bucketFeatures = []; let circleSortKey = null; @@ -103,7 +104,7 @@ class CircleBucket implements Bucke type: feature.type, sourceLayerIndex, index, - geometry: needGeometry ? evaluationFeature.geometry : loadGeometry(feature), + geometry: needGeometry ? evaluationFeature.geometry : loadGeometry(feature, canonical, tileTransform), patterns: {}, sortKey }; diff --git a/src/data/bucket/fill_bucket.js b/src/data/bucket/fill_bucket.js index 2c2e6da9b62..57c9b84b356 100644 --- a/src/data/bucket/fill_bucket.js +++ b/src/data/bucket/fill_bucket.js @@ -31,6 +31,7 @@ import type VertexBuffer from '../../gl/vertex_buffer.js'; import type Point from '@mapbox/point-geometry'; import type {FeatureStates} from '../../source/source_state.js'; import type {ImagePosition} from '../../render/image_atlas.js'; +import type {TileTransform} from '../../geo/projection/tile_transform.js'; class FillBucket implements Bucket { index: number; @@ -75,7 +76,7 @@ class FillBucket implements Bucket { this.stateDependentLayerIds = this.layers.filter((l) => l.isStateDependent()).map((l) => l.id); } - populate(features: Array, options: PopulateParameters, canonical: CanonicalTileID) { + populate(features: Array, options: PopulateParameters, canonical: CanonicalTileID, tileTransform: TileTransform) { this.hasPattern = hasPattern('fill', this.layers, options); const fillSortKey = this.layers[0].layout.get('fill-sort-key'); const bucketFeatures = []; @@ -96,7 +97,7 @@ class FillBucket implements Bucket { type: feature.type, sourceLayerIndex, index, - geometry: needGeometry ? evaluationFeature.geometry : loadGeometry(feature), + geometry: needGeometry ? evaluationFeature.geometry : loadGeometry(feature, canonical, tileTransform), patterns: {}, sortKey }; diff --git a/src/data/bucket/fill_extrusion_bucket.js b/src/data/bucket/fill_extrusion_bucket.js index 16505a40d6c..3c2d38cd53b 100644 --- a/src/data/bucket/fill_extrusion_bucket.js +++ b/src/data/bucket/fill_extrusion_bucket.js @@ -36,6 +36,7 @@ import type IndexBuffer from '../../gl/index_buffer.js'; import type VertexBuffer from '../../gl/vertex_buffer.js'; import type {FeatureStates} from '../../source/source_state.js'; import type {ImagePosition} from '../../render/image_atlas.js'; +import type {TileTransform} from '../../geo/projection/tile_transform.js'; const FACTOR = Math.pow(2, 13); @@ -216,7 +217,7 @@ class FillExtrusionBucket implements Bucket { this.enableTerrain = options.enableTerrain; } - populate(features: Array, options: PopulateParameters, canonical: CanonicalTileID) { + populate(features: Array, options: PopulateParameters, canonical: CanonicalTileID, tileTransform: TileTransform) { this.features = []; this.hasPattern = hasPattern('fill-extrusion', this.layers, options); this.featuresOnBorder = []; @@ -234,7 +235,7 @@ class FillExtrusionBucket implements Bucket { id, sourceLayerIndex, index, - geometry: needGeometry ? evaluationFeature.geometry : loadGeometry(feature), + geometry: needGeometry ? evaluationFeature.geometry : loadGeometry(feature, canonical, tileTransform), properties: feature.properties, type: feature.type, patterns: {} diff --git a/src/data/bucket/line_bucket.js b/src/data/bucket/line_bucket.js index f63758a9eae..99039cf969d 100644 --- a/src/data/bucket/line_bucket.js +++ b/src/data/bucket/line_bucket.js @@ -35,6 +35,7 @@ import type VertexBuffer from '../../gl/vertex_buffer.js'; import type {FeatureStates} from '../../source/source_state.js'; import type {ImagePosition} from '../../render/image_atlas.js'; import type LineAtlas from '../../render/line_atlas.js'; +import type {TileTransform} from '../../geo/projection/tile_transform.js'; // NOTE ON EXTRUDE SCALE: // scale the extrusion vector so that the normal length is this value. @@ -134,7 +135,7 @@ class LineBucket implements Bucket { this.stateDependentLayerIds = this.layers.filter((l) => l.isStateDependent()).map((l) => l.id); } - populate(features: Array, options: PopulateParameters, canonical: CanonicalTileID) { + populate(features: Array, options: PopulateParameters, canonical: CanonicalTileID, tileTransform: TileTransform) { this.hasPattern = hasPattern('line', this.layers, options); const lineSortKey = this.layers[0].layout.get('line-sort-key'); const bucketFeatures = []; @@ -155,7 +156,7 @@ class LineBucket implements Bucket { type: feature.type, sourceLayerIndex, index, - geometry: needGeometry ? evaluationFeature.geometry : loadGeometry(feature), + geometry: needGeometry ? evaluationFeature.geometry : loadGeometry(feature, canonical, tileTransform), patterns: {}, sortKey }; diff --git a/src/data/bucket/symbol_bucket.js b/src/data/bucket/symbol_bucket.js index 50cc769f276..77977ebcb81 100644 --- a/src/data/bucket/symbol_bucket.js +++ b/src/data/bucket/symbol_bucket.js @@ -61,6 +61,7 @@ import type {SymbolQuad} from '../../symbol/quads.js'; import type {SizeData} from '../../symbol/symbol_size.js'; import type {FeatureStates} from '../../source/source_state.js'; import type {ImagePosition} from '../../render/image_atlas.js'; +import type {TileTransform} from '../../geo/projection/tile_transform.js'; export type SingleCollisionBox = { x1: number; y1: number; @@ -425,7 +426,7 @@ class SymbolBucket implements Bucket { } } - populate(features: Array, options: PopulateParameters, canonical: CanonicalTileID) { + populate(features: Array, options: PopulateParameters, canonical: CanonicalTileID, tileTransform: TileTransform) { const layer = this.layers[0]; const layout = layer.layout; @@ -463,7 +464,7 @@ class SymbolBucket implements Bucket { continue; } - if (!needGeometry) evaluationFeature.geometry = loadGeometry(feature); + if (!needGeometry) evaluationFeature.geometry = loadGeometry(feature, canonical, tileTransform); let text: Formatted | void; if (hasText) { diff --git a/src/data/feature_index.js b/src/data/feature_index.js index 5a291b703b5..8f13d52b33f 100644 --- a/src/data/feature_index.js +++ b/src/data/feature_index.js @@ -27,11 +27,13 @@ import type Transform from '../geo/transform.js'; import type {FilterSpecification, PromoteIdSpecification} from '../style-spec/types.js'; import type {TilespaceQueryGeometry} from '../style/query_geometry.js'; import type {FeatureIndex as FeatureIndexStruct} from './array_types.js'; +import type {TileTransform} from '../geo/projection/tile_transform.js'; type QueryParameters = { pixelPosMatrix: Float32Array, transform: Transform, tileResult: TilespaceQueryGeometry, + tileTransform: TileTransform, params: { filter: FilterSpecification, layers: Array, @@ -153,7 +155,7 @@ class FeatureIndex { sourceFeatureState, (feature: VectorTileFeature, styleLayer: StyleLayer, featureState: Object, layoutVertexArrayOffset: number = 0) => { if (!featureGeometry) { - featureGeometry = loadGeometry(feature); + featureGeometry = loadGeometry(feature, this.tileID.canonical, args.tileTransform); } return styleLayer.queryIntersectsFeature(tilespaceGeometry, feature, featureState, featureGeometry, this.z, args.transform, args.pixelPosMatrix, elevationHelper, layoutVertexArrayOffset); diff --git a/src/data/load_geometry.js b/src/data/load_geometry.js index e85cb7324c3..64c3ed93f60 100644 --- a/src/data/load_geometry.js +++ b/src/data/load_geometry.js @@ -3,8 +3,12 @@ import {warnOnce, clamp} from '../util/util.js'; import EXTENT from './extent.js'; +import {lngFromMercatorX, latFromMercatorY} from '../geo/mercator_coordinate.js'; +import resample from '../geo/projection/resample.js'; +import Point from '@mapbox/point-geometry'; -import type Point from '@mapbox/point-geometry'; +import type {CanonicalTileID} from '../source/tile_id.js'; +import type {TileTransform} from '../geo/projection/tile_transform.js'; // These bounds define the minimum and maximum supported coordinate values. // While visible coordinates are within [0, EXTENT], tiles may theoretically @@ -14,33 +18,61 @@ const BITS = 15; const MAX = Math.pow(2, BITS - 1) - 1; const MIN = -MAX - 1; +function clampPoint(point: Point) { + const {x, y} = point; + point.x = clamp(x, MIN, MAX); + point.y = clamp(y, MIN, MAX); + if (x < point.x || x > point.x + 1 || y < point.y || y > point.y + 1) { + // warn when exceeding allowed extent except for the 1-px-off case + // https://github.com/mapbox/mapbox-gl-js/issues/8992 + warnOnce('Geometry exceeds allowed extent, reduce your vector tile buffer size'); + } + return point; +} + +// a subset of VectorTileGeometry +type FeatureWithGeometry = { + extent: number; + type: 1 | 2 | 3; + loadGeometry(): Array>; +} + /** * Loads a geometry from a VectorTileFeature and scales it to the common extent * used internally. * @param {VectorTileFeature} feature * @private */ -export default function loadGeometry(feature: VectorTileFeature): Array> { - const scale = EXTENT / feature.extent; - const geometry = feature.loadGeometry(); - for (let r = 0; r < geometry.length; r++) { - const ring = geometry[r]; - for (let p = 0; p < ring.length; p++) { - const point = ring[p]; - // round here because mapbox-gl-native uses integers to represent - // points and we need to do the same to avoid rendering differences. - const x = Math.round(point.x * scale); - const y = Math.round(point.y * scale); - - point.x = clamp(x, MIN, MAX); - point.y = clamp(y, MIN, MAX); - - if (x < point.x || x > point.x + 1 || y < point.y || y > point.y + 1) { - // warn when exceeding allowed extent except for the 1-px-off case - // https://github.com/mapbox/mapbox-gl-js/issues/8992 - warnOnce('Geometry exceeds allowed extent, reduce your vector tile buffer size'); - } +export default function loadGeometry(feature: FeatureWithGeometry, canonical?: CanonicalTileID, tileTransform?: TileTransform): Array> { + const featureExtent = feature.extent; + const scale = EXTENT / featureExtent; + const projection = tileTransform ? tileTransform.projection : undefined; + const isMercator = !projection || projection.name === 'mercator'; + + function reproject(p) { + if (isMercator || !canonical || !tileTransform || !projection) { + return new Point(p.x * scale, p.y * scale); + } else { + const z2 = 1 << canonical.z; + const lng = lngFromMercatorX((canonical.x + p.x / featureExtent) / z2); + const lat = latFromMercatorY((canonical.y + p.y / featureExtent) / z2); + const {x, y} = projection.project(lng, lat); + return new Point( + (x * tileTransform.scale - tileTransform.x) * EXTENT, + (y * tileTransform.scale - tileTransform.y) * EXTENT + ); } } + + const geometry = feature.loadGeometry(); + + for (let i = 0; i < geometry.length; i++) { + geometry[i] = !isMercator && feature.type !== 1 ? + resample(geometry[i], reproject, 1) : + geometry[i].map(reproject); + + geometry[i].forEach(p => clampPoint(p._round())); + } + return geometry; } diff --git a/src/geo/mercator_coordinate.js b/src/geo/mercator_coordinate.js index cb952ce89aa..1cc9d4f9aa8 100644 --- a/src/geo/mercator_coordinate.js +++ b/src/geo/mercator_coordinate.js @@ -6,13 +6,13 @@ import type {LngLatLike} from '../geo/lng_lat.js'; /* * The average circumference of the world in meters. */ -const earthCircumfrence = 2 * Math.PI * earthRadius; // meters +const earthCircumference = 2 * Math.PI * earthRadius; // meters /* * The circumference at a line of latitude in meters. */ function circumferenceAtLatitude(latitude: number) { - return earthCircumfrence * Math.cos(latitude * Math.PI / 180); + return earthCircumference * Math.cos(latitude * Math.PI / 180); } export function mercatorXfromLng(lng: number) { @@ -40,6 +40,8 @@ export function altitudeFromMercatorZ(z: number, y: number) { return z * circumferenceAtLatitude(latFromMercatorY(y)); } +export const MAX_MERCATOR_LATITUDE = 85.051129; + /** * Determine the Mercator scale factor for a given latitude, see * https://en.wikipedia.org/wiki/Mercator_projection#Scale_factor @@ -148,7 +150,7 @@ class MercatorCoordinate { */ meterInMercatorCoordinateUnits() { // 1 meter / circumference at equator in meters * Mercator projection scale factor at this latitude - return 1 / earthCircumfrence * mercatorScale(latFromMercatorY(this.y)); + return 1 / earthCircumference * mercatorScale(latFromMercatorY(this.y)); } } diff --git a/src/geo/projection/adjustments.js b/src/geo/projection/adjustments.js new file mode 100644 index 00000000000..2b834012114 --- /dev/null +++ b/src/geo/projection/adjustments.js @@ -0,0 +1,147 @@ +// @flow + +import LngLat from '../lng_lat.js'; +import MercatorCoordinate, {MAX_MERCATOR_LATITUDE} from '../mercator_coordinate.js'; +import {mat4, mat2} from 'gl-matrix'; +import {clamp} from '../../util/util.js'; +import type {Projection} from './index.js'; +import type Transform from '../transform.js'; + +export default function getProjectionAdjustments(transform: Transform, withoutRotation?: boolean) { + const projection = transform.projection; + + const interpT = getInterpolationT(transform); + + const zoomAdjustment = getZoomAdjustment(projection, transform.center); + const zoomAdjustmentOrigin = getZoomAdjustment(projection, LngLat.convert(projection.center)); + const scaleAdjustment = Math.pow(2, zoomAdjustment * interpT + (1 - interpT) * zoomAdjustmentOrigin); + + const matrix = getShearAdjustment(transform.projection, transform.zoom, transform.center, interpT, withoutRotation); + + mat4.scale(matrix, matrix, [scaleAdjustment, scaleAdjustment, 1]); + + return matrix; +} + +export function getProjectionAdjustmentInverted(transform: Transform) { + const m = getProjectionAdjustments(transform, true); + return mat2.invert([], [ + m[0], m[1], + m[4], m[5]]); +} + +function getInterpolationT(transform: Transform) { + const range = transform.projection.range; + if (!range) return 0; + + const size = Math.max(transform.width, transform.height); + // The interpolation ranges are manually defined based on what makes + // sense in a 1024px wide map. Adjust the ranges to the current size + // of the map. The smaller the map, the earlier you can start unskewing. + const rangeAdjustment = Math.log(size / 1024) / Math.LN2; + const zoomA = range[0] + rangeAdjustment; + const zoomB = range[1] + rangeAdjustment; + const t = clamp((transform.zoom - zoomA) / (zoomB - zoomA), 0, 1); + return t; +} + +// approx. kilometers per longitude degree at equator +const offset = 1 / 40000; + +/* + * Calculates the scale difference between Mercator and the given projection at a certain location. + */ +function getZoomAdjustment(projection: Projection, loc: LngLat) { + // make sure we operate within mercator space for adjustments (they can go over for other projections) + const lat = clamp(loc.lat, -MAX_MERCATOR_LATITUDE, MAX_MERCATOR_LATITUDE); + + const loc1 = new LngLat(loc.lng - 180 * offset, lat); + const loc2 = new LngLat(loc.lng + 180 * offset, lat); + + const p1 = projection.project(loc1.lng, lat); + const p2 = projection.project(loc2.lng, lat); + + const m1 = MercatorCoordinate.fromLngLat(loc1); + const m2 = MercatorCoordinate.fromLngLat(loc2); + + const pdx = p2.x - p1.x; + const pdy = p2.y - p1.y; + const mdx = m2.x - m1.x; + const mdy = m2.y - m1.y; + + const scale = Math.sqrt((mdx * mdx + mdy * mdy) / (pdx * pdx + pdy * pdy)); + + return Math.log(scale) / Math.LN2; +} + +function getShearAdjustment(projection, zoom, loc, interpT, withoutRotation?: boolean) { + + // create two locations a tiny amount (~1km) east and west of the given location + const locw = new LngLat(loc.lng - 180 * offset, loc.lat); + const loce = new LngLat(loc.lng + 180 * offset, loc.lat); + + const pw = projection.project(locw.lng, locw.lat); + const pe = projection.project(loce.lng, loce.lat); + + const pdx = pe.x - pw.x; + const pdy = pe.y - pw.y; + + // Calculate how much the map would need to be rotated to make east-west in + // projected coordinates be left-right + const angleAdjust = -Math.atan(pdy / pdx); + + // Pick a location identical to the original one except for poles to make sure we're within mercator bounds + const mc2 = MercatorCoordinate.fromLngLat(loc); + mc2.y = clamp(mc2.y, -1 + offset, 1 - offset); + const loc2 = mc2.toLngLat(); + const p2 = projection.project(loc2.lng, loc2.lat); + + // Find the projected coordinates of two locations, one slightly south and one slightly east. + // Then calculate the transform that would make the projected coordinates of the two locations be: + // - equal distances from the original location + // - perpendicular to one another + // + // Only the position of the coordinate to the north is adjusted. + // The coordinate to the east stays where it is. + const mc3 = MercatorCoordinate.fromLngLat(loc2); + mc3.x += offset; + const loc3 = mc3.toLngLat(); + const p3 = projection.project(loc3.lng, loc3.lat); + const pdx3 = p3.x - p2.x; + const pdy3 = p3.y - p2.y; + const delta3 = rotate(pdx3, pdy3, angleAdjust); + + const mc4 = MercatorCoordinate.fromLngLat(loc2); + mc4.y += offset; + const loc4 = mc4.toLngLat(); + const p4 = projection.project(loc4.lng, loc4.lat); + const pdx4 = p4.x - p2.x; + const pdy4 = p4.y - p2.y; + const delta4 = rotate(pdx4, pdy4, angleAdjust); + + const scale = Math.abs(delta3.x) / Math.abs(delta4.y); + + const unrotate = mat4.identity([]); + mat4.rotateZ(unrotate, unrotate, (-angleAdjust) * (1 - (withoutRotation ? 0 : interpT))); + + // unskew + const shear = mat4.identity([]); + mat4.scale(shear, shear, [1, 1 - (1 - scale) * interpT, 1]); + shear[4] = -delta4.x / delta4.y * interpT; + + // unrotate + mat4.rotateZ(shear, shear, angleAdjust); + + mat4.multiply(shear, unrotate, shear); + + return shear; +} + +function rotate(x, y, angle) { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + return { + x: x * cos - y * sin, + y: x * sin + y * cos + }; +} diff --git a/src/geo/projection/albers.js b/src/geo/projection/albers.js new file mode 100644 index 00000000000..0b14cd609ee --- /dev/null +++ b/src/geo/projection/albers.js @@ -0,0 +1,44 @@ +// @flow +import LngLat from '../lng_lat.js'; +import {clamp} from '../../util/util.js'; + +export default { + name: 'albers', + range: [4, 7], + + center: [-96, 37.5], + parallels: [29.5, 45.5], + + conical: true, + + project(lng: number, lat: number) { + const p1 = this.parallels[0] / 180 * Math.PI; + const p2 = this.parallels[1] / 180 * Math.PI; + const n = 0.5 * (Math.sin(p1) + Math.sin(p2)); + const theta = n * ((lng - this.center[0]) / 180 * Math.PI); + const c = Math.pow(Math.cos(p1), 2) + 2 * n * Math.sin(p1); + const r = 0.5; + const a = r / n * Math.sqrt(c - 2 * n * Math.sin(lat / 180 * Math.PI)); + const b = r / n * Math.sqrt(c - 2 * n * Math.sin(0 / 180 * Math.PI)); + const x = a * Math.sin(theta); + const y = b - a * Math.cos(theta); + return {x: 1 + 0.5 * x, y: 1 - 0.5 * y}; + }, + unproject(x: number, y: number) { + const p1 = this.parallels[0] / 180 * Math.PI; + const p2 = this.parallels[1] / 180 * Math.PI; + const n = 0.5 * (Math.sin(p1) + Math.sin(p2)); + const c = Math.pow(Math.cos(p1), 2) + 2 * n * Math.sin(p1); + const r = 0.5; + const b = r / n * Math.sqrt(c - 2 * n * Math.sin(0 / 180 * Math.PI)); + const x_ = (x - 1) * 2; + const y_ = (y - 1) * -2; + const y2 = -(y_ - b); + const theta = Math.atan2(x_, y2); + const lng = clamp((theta / n * 180 / Math.PI) + this.center[0], -180, 180); + const a = x_ / Math.sin(theta); + const s = clamp((Math.pow(a / r * n, 2) - c) / (-2 * n), -1, 1); + const lat = clamp(Math.asin(s) * 180 / Math.PI, -90, 90); + return new LngLat(lng, lat); + } +}; diff --git a/src/geo/projection/equal_earth.js b/src/geo/projection/equal_earth.js new file mode 100644 index 00000000000..888db26f72b --- /dev/null +++ b/src/geo/projection/equal_earth.js @@ -0,0 +1,56 @@ +// @flow +import LngLat from '../lng_lat.js'; +import {clamp} from '../../util/util.js'; + +const a1 = 1.340264; +const a2 = -0.081106; +const a3 = 0.000893; +const a4 = 0.003796; +const M = Math.sqrt(3) / 2; + +export default { + name: 'equalEarth', + center: [0, 0], + range: [3.5, 7], + + project(lng: number, lat: number) { + // based on https://github.com/d3/d3-geo, MIT-licensed + lat = lat / 180 * Math.PI; + lng = lng / 180 * Math.PI; + const theta = Math.asin(M * Math.sin(lat)); + const theta2 = theta * theta; + const theta6 = theta2 * theta2 * theta2; + const x = lng * Math.cos(theta) / (M * (a1 + 3 * a2 * theta2 + theta6 * (7 * a3 + 9 * a4 * theta2))); + const y = theta * (a1 + a2 * theta2 + theta6 * (a3 + a4 * theta2)); + + return { + x: (x / Math.PI + 0.5) * 0.5, + y: 1 - (y / Math.PI + 1) * 0.5 + }; + }, + + unproject(x: number, y: number) { + // based on https://github.com/d3/d3-geo, MIT-licensed + x = (2 * x - 0.5) * Math.PI; + y = (2 * (1 - y) - 1) * Math.PI; + let theta = y; + let theta2 = theta * theta; + let theta6 = theta2 * theta2 * theta2; + + for (let i = 0, delta, fy, fpy; i < 12; ++i) { + fy = theta * (a1 + a2 * theta2 + theta6 * (a3 + a4 * theta2)) - y; + fpy = a1 + 3 * a2 * theta2 + theta6 * (7 * a3 + 9 * a4 * theta2); + theta -= delta = fy / fpy; + theta2 = theta * theta; + theta6 = theta2 * theta2 * theta2; + if (Math.abs(delta) < 1e-12) break; + } + + const lambda = M * x * (a1 + 3 * a2 * theta2 + theta6 * (7 * a3 + 9 * a4 * theta2)) / Math.cos(theta); + const phi = Math.asin(clamp(Math.sin(theta) / M, -1, 1)); + const lng = clamp(lambda * 180 / Math.PI, -180, 180); + const lat = clamp(phi * 180 / Math.PI, -90, 90); + + return new LngLat(lng, lat); + } +}; diff --git a/src/geo/projection/equirectangular.js b/src/geo/projection/equirectangular.js new file mode 100644 index 00000000000..b6f694f4b6f --- /dev/null +++ b/src/geo/projection/equirectangular.js @@ -0,0 +1,18 @@ +// @flow +import LngLat from '../lng_lat.js'; +import {clamp} from '../../util/util.js'; + +export default { + name: 'equirectangular', + center: [0, 0], + project(lng: number, lat: number) { + const x = 0.5 + lng / 360; + const y = 0.5 - lat / 360; + return {x, y}; + }, + unproject(x: number, y: number) { + const lng = (x - 0.5) * 360; + const lat = clamp((0.5 - y) * 360, -90, 90); + return new LngLat(lng, lat); + } +}; diff --git a/src/geo/projection/index.js b/src/geo/projection/index.js new file mode 100644 index 00000000000..304383d5e46 --- /dev/null +++ b/src/geo/projection/index.js @@ -0,0 +1,36 @@ +// @flow +import albers from './albers.js'; +import equalEarth from './equal_earth.js'; +import equirectangular from './equirectangular.js'; +import lambertConformalConic from './lambert.js'; +import mercator from './mercator.js'; +import naturalEarth from './natural_earth.js'; +import winkelTripel from './winkel_tripel.js'; +import LngLat from '../lng_lat.js'; +import type {ProjectionSpecification} from '../../style-spec/types.js'; + +export type Projection = { + name: string, + center: [number, number], + parallels?: [number, number], + range?: [number, number], + conical?: boolean, + project: (lng: number, lat: number) => {x: number, y: number}, + unproject: (x: number, y: number) => LngLat +}; + +const projections = { + albers, + equalEarth, + equirectangular, + lambertConformalConic, + mercator, + naturalEarth, + winkelTripel +}; + +export function getProjection(config: ProjectionSpecification) { + const projection = projections[config.name]; + if (!projection) throw new Error(`Invalid projection name: ${config.name}`); + return projection.conical ? {...projection, ...config} : projection; +} diff --git a/src/geo/projection/lambert.js b/src/geo/projection/lambert.js new file mode 100644 index 00000000000..eae0cc3ebee --- /dev/null +++ b/src/geo/projection/lambert.js @@ -0,0 +1,70 @@ +// @flow +import LngLat from '../lng_lat.js'; +import {clamp} from '../../util/util.js'; + +const halfPi = Math.PI / 2; + +function tany(y) { + return Math.tan((halfPi + y) / 2); +} + +function getParams([lat0, lat1]) { + const y0 = lat0 * Math.PI / 180; + const y1 = lat1 * Math.PI / 180; + const cy0 = Math.cos(y0); + const n = y0 === y1 ? Math.sin(y0) : Math.log(cy0 / Math.cos(y1)) / Math.log(tany(y1) / tany(y0)); + const f = cy0 * Math.pow(tany(y0), n) / n; + + return {n, f}; +} + +export default { + name: 'lambertConformalConic', + range: [3.5, 7], + + center: [0, 30], + parallels: [30, 30], + + conical: true, + + project(lng: number, lat: number) { + // based on https://github.com/d3/d3-geo, MIT-licensed + lat = lat / 180 * Math.PI; + lng = lng / 180 * Math.PI; + + const epsilon = 1e-6; + const {n, f} = getParams(this.parallels); + + if (f > 0) { + if (lat < -halfPi + epsilon) lat = -halfPi + epsilon; + } else { + if (lat > halfPi - epsilon) lat = halfPi - epsilon; + } + + const r = f / Math.pow(tany(lat), n); + const x = r * Math.sin(n * lng); + const y = f - r * Math.cos(n * lng); + + return { + x: (x / Math.PI + 0.5) * 0.5, + y: 1 - (y / Math.PI + 0.5) * 0.5 + }; + }, + + unproject(x: number, y: number) { + // based on https://github.com/d3/d3-geo, MIT-licensed + x = (2 * x - 0.5) * Math.PI; + y = (2 * (1 - y) - 0.5) * Math.PI; + const {n, f} = getParams(this.parallels); + const fy = f - y; + const r = Math.sign(n) * Math.sqrt(x * x + fy * fy); + let l = Math.atan2(x, Math.abs(fy)) * Math.sign(fy); + + if (fy * n < 0) l -= Math.PI * Math.sign(x) * Math.sign(fy); + + const lng = clamp((l / n) * 180 / Math.PI, -180, 180); + const lat = clamp((2 * Math.atan(Math.pow(f / r, 1 / n)) - halfPi) * 180 / Math.PI, -90, 90); + + return new LngLat(lng, lat); + } +}; diff --git a/src/geo/projection/mercator.js b/src/geo/projection/mercator.js new file mode 100644 index 00000000000..7c76340678e --- /dev/null +++ b/src/geo/projection/mercator.js @@ -0,0 +1,18 @@ +// @flow +import {mercatorXfromLng, mercatorYfromLat, lngFromMercatorX, latFromMercatorY} from '../mercator_coordinate.js'; +import LngLat from '../lng_lat.js'; + +export default { + name: 'mercator', + center: [0, 0], + project(lng: number, lat: number) { + const x = mercatorXfromLng(lng); + const y = mercatorYfromLat(lat); + return {x, y}; + }, + unproject(x: number, y: number) { + const lng = lngFromMercatorX(x); + const lat = latFromMercatorY(y); + return new LngLat(lng, lat); + } +}; diff --git a/src/geo/projection/natural_earth.js b/src/geo/projection/natural_earth.js new file mode 100644 index 00000000000..4223e7a00be --- /dev/null +++ b/src/geo/projection/natural_earth.js @@ -0,0 +1,51 @@ +// @flow +import LngLat from '../lng_lat.js'; +import {clamp} from '../../util/util.js'; + +export default { + name: 'naturalEarth', + center: [0, 0], + range: [3.5, 7], + + project(lng: number, lat: number) { + // based on https://github.com/d3/d3-geo, MIT-licensed + lat = lat / 180 * Math.PI; + lng = lng / 180 * Math.PI; + + const phi2 = lat * lat; + const phi4 = phi2 * phi2; + const x = lng * (0.8707 - 0.131979 * phi2 + phi4 * (-0.013791 + phi4 * (0.003971 * phi2 - 0.001529 * phi4))); + const y = lat * (1.007226 + phi2 * (0.015085 + phi4 * (-0.044475 + 0.028874 * phi2 - 0.005916 * phi4))); + + return { + x: (x / Math.PI + 0.5) * 0.5, + y: 1 - (y / Math.PI + 1) * 0.5 + }; + }, + + unproject(x: number, y: number) { + // based on https://github.com/d3/d3-geo, MIT-licensed + x = (2 * x - 0.5) * Math.PI; + y = (2 * (1 - y) - 1) * Math.PI; + const epsilon = 1e-6; + let phi = y; + let i = 25; + let delta = 0; + let phi2 = phi * phi; + + do { + phi2 = phi * phi; + const phi4 = phi2 * phi2; + phi -= delta = (phi * (1.007226 + phi2 * (0.015085 + phi4 * (-0.044475 + 0.028874 * phi2 - 0.005916 * phi4))) - y) / + (1.007226 + phi2 * (0.015085 * 3 + phi4 * (-0.044475 * 7 + 0.028874 * 9 * phi2 - 0.005916 * 11 * phi4))); + } while (Math.abs(delta) > epsilon && --i > 0); + + phi2 = phi * phi; + const lambda = x / (0.8707 + phi2 * (-0.131979 + phi2 * (-0.013791 + phi2 * phi2 * phi2 * (0.003971 - 0.001529 * phi2)))); + + const lng = clamp(lambda * 180 / Math.PI, -180, 180); + const lat = clamp(phi * 180 / Math.PI, -90, 90); + + return new LngLat(lng, lat); + } +}; diff --git a/src/geo/projection/resample.js b/src/geo/projection/resample.js new file mode 100644 index 00000000000..74389807236 --- /dev/null +++ b/src/geo/projection/resample.js @@ -0,0 +1,49 @@ +// @flow + +import Point from '@mapbox/point-geometry'; + +function pointToLineDist(px, py, ax, ay, bx, by) { + const dx = ax - bx; + const dy = ay - by; + return Math.abs((ay - py) * dx - (ax - px) * dy) / Math.hypot(dx, dy); +} + +function addResampled(resampled, startMerc, endMerc, startProj, endProj, reproject, tolerance) { + const midMerc = new Point( + (startMerc.x + endMerc.x) / 2, + (startMerc.y + endMerc.y) / 2); + + const midProj = reproject(midMerc); + const err = pointToLineDist(midProj.x, midProj.y, startProj.x, startProj.y, endProj.x, endProj.y); + + // if reprojected midPoint is too far from geometric midpoint, recurse into two halves + if (err >= tolerance) { + // we're very unlikely to hit max call stack exceeded here, + // but we might want to safeguard against it in the future + addResampled(resampled, startMerc, midMerc, startProj, midProj, reproject, tolerance); + addResampled(resampled, midMerc, endMerc, midProj, endProj, reproject, tolerance); + + } else { // otherwise, just add the point + resampled.push(endProj); + } +} + +export default function resample(line: Array, reproject: (Point) => Point, tolerance: number): Array { + const resampled = []; + let prevMerc, prevProj; + + for (const pointMerc of line) { + const pointProj = reproject(pointMerc); + + if (prevMerc && prevProj) { + addResampled(resampled, prevMerc, pointMerc, prevProj, pointProj, reproject, tolerance); + } else { + resampled.push(pointProj); + } + + prevMerc = pointMerc; + prevProj = pointProj; + } + + return resampled; +} diff --git a/src/geo/projection/tile_transform.js b/src/geo/projection/tile_transform.js new file mode 100644 index 00000000000..eddce3d9894 --- /dev/null +++ b/src/geo/projection/tile_transform.js @@ -0,0 +1,99 @@ +// @flow +import Point from '@mapbox/point-geometry'; +import MercatorCoordinate, {altitudeFromMercatorZ, lngFromMercatorX, latFromMercatorY} from '../mercator_coordinate.js'; +import EXTENT from '../../data/extent.js'; +import {vec3} from 'gl-matrix'; +import type {Projection} from './index.js'; + +export type TileTransform = { + scale: number, + x: number, + y: number, + x2: number, + y2: number, + projection: Projection +}; + +export default function tileTransform(id: Object, projection: Projection) { + const s = Math.pow(2, -id.z); + + const x1 = (id.x) * s; + const x2 = (id.x + 1) * s; + const y1 = (id.y) * s; + const y2 = (id.y + 1) * s; + + if (projection.name === 'mercator') { + return {scale: 1 << id.z, x: id.x, y: id.y, x2: id.x + 1, y2: id.y + 1, projection}; + } + + const lng1 = lngFromMercatorX(x1); + const lng2 = lngFromMercatorX(x2); + const lat1 = latFromMercatorY(y1); + const lat2 = latFromMercatorY(y2); + + const p0 = projection.project(lng1, lat1); + const p1 = projection.project(lng2, lat1); + const p2 = projection.project(lng2, lat2); + const p3 = projection.project(lng1, lat2); + + let minX = Math.min(p0.x, p1.x, p2.x, p3.x); + let minY = Math.min(p0.y, p1.y, p2.y, p3.y); + let maxX = Math.max(p0.x, p1.x, p2.x, p3.x); + let maxY = Math.max(p0.y, p1.y, p2.y, p3.y); + + // we pick an error threshold for calculating the bbox that balances between performance and precision + const maxErr = s / 16; + + function processSegment(pa, pb, ax, ay, bx, by) { + const mx = (ax + bx) / 2; + const my = (ay + by) / 2; + + const pm = projection.project(lngFromMercatorX(mx), latFromMercatorY(my)); + const err = Math.max(0, minX - pm.x, minY - pm.y, pm.x - maxX, pm.y - maxY); + + minX = Math.min(minX, pm.x); + maxX = Math.max(maxX, pm.x); + minY = Math.min(minY, pm.y); + maxY = Math.max(maxY, pm.y); + + if (err > maxErr) { + processSegment(pa, pm, ax, ay, mx, my); + processSegment(pm, pb, mx, my, bx, by); + } + } + + processSegment(p0, p1, x1, y1, x2, y1); + processSegment(p1, p2, x2, y1, x2, y2); + processSegment(p2, p3, x2, y2, x1, y2); + processSegment(p3, p0, x1, y2, x1, y1); + + // extend the bbox by max error to make sure coords don't go past tile extent + minX -= maxErr; + minY -= maxErr; + maxX += maxErr; + maxY += maxErr; + + const max = Math.max(maxX - minX, maxY - minY); + const scale = 1 / max; + + return { + scale, + x: minX * scale, + y: minY * scale, + x2: maxX * scale, + y2: maxY * scale, + projection + }; +} + +export function getTilePoint(tileTransform: TileTransform, {x, y}: {x: number, y: number}, wrap: number = 0) { + return new Point( + ((x - wrap) * tileTransform.scale - tileTransform.x) * EXTENT, + (y * tileTransform.scale - tileTransform.y) * EXTENT); +} + +export function getTileVec3(tileTransform: TileTransform, coord: MercatorCoordinate, wrap: number = 0): vec3 { + const x = ((coord.x - wrap) * tileTransform.scale - tileTransform.x) * EXTENT; + const y = (coord.y * tileTransform.scale - tileTransform.y) * EXTENT; + return vec3.fromValues(x, y, altitudeFromMercatorZ(coord.z, coord.y)); +} diff --git a/src/geo/projection/winkel_tripel.js b/src/geo/projection/winkel_tripel.js new file mode 100644 index 00000000000..8ce2d8616d7 --- /dev/null +++ b/src/geo/projection/winkel_tripel.js @@ -0,0 +1,64 @@ +// @flow +import LngLat from '../lng_lat.js'; +import {clamp} from '../../util/util.js'; + +export default { + name: 'winkelTripel', + center: [0, 0], + range: [3.5, 7], + + project(lng: number, lat: number) { + lat = lat / 180 * Math.PI; + lng = lng / 180 * Math.PI; + const phi1 = Math.acos(2 / Math.PI); + const alpha = Math.acos(Math.cos(lat) * Math.cos(lng / 2)); + const x = 0.5 * (lng * Math.cos(phi1) + (2 * Math.cos(lat) * Math.sin(lng / 2)) / (Math.sin(alpha) / alpha)) || 0; + const y = 0.5 * (lat + Math.sin(lat) / (Math.sin(alpha) / alpha)) || 0; + return { + x: (x / Math.PI + 0.5) * 0.5, + y: 1 - (y / Math.PI + 1) * 0.5 + }; + }, + + unproject(x: number, y: number) { + // based on https://github.com/d3/d3-geo-projection, MIT-licensed + x = (2 * x - 0.5) * Math.PI; + y = (2 * (1 - y) - 1) * Math.PI; + let lambda = x; + let phi = y; + let i = 25; + const epsilon = 1e-6; + let dlambda = 0, dphi = 0; + do { + const cosphi = Math.cos(phi), + sinphi = Math.sin(phi), + sinphi2 = 2 * sinphi * cosphi, + sin2phi = sinphi * sinphi, + cos2phi = cosphi * cosphi, + coslambda2 = Math.cos(lambda / 2), + sinlambda2 = Math.sin(lambda / 2), + sinlambda = 2 * coslambda2 * sinlambda2, + sin2lambda2 = sinlambda2 * sinlambda2, + C = 1 - cos2phi * coslambda2 * coslambda2, + F = C ? 1 / C : 0, + E = C ? Math.acos(cosphi * coslambda2) * Math.sqrt(1 / C) : 0, + fx = 0.5 * (2 * E * cosphi * sinlambda2 + lambda * 2 / Math.PI) - x, + fy = 0.5 * (E * sinphi + phi) - y, + dxdlambda = 0.5 * F * (cos2phi * sin2lambda2 + E * cosphi * coslambda2 * sin2phi) + 1 / Math.PI, + dxdphi = F * (sinlambda * sinphi2 / 4 - E * sinphi * sinlambda2), + dydlambda = 0.125 * F * (sinphi2 * sinlambda2 - E * sinphi * cos2phi * sinlambda), + dydphi = 0.5 * F * (sin2phi * coslambda2 + E * sin2lambda2 * cosphi) + 0.5, + denominator = dxdphi * dydlambda - dydphi * dxdlambda; + + dlambda = (fy * dxdphi - fx * dydphi) / denominator; + dphi = (fx * dydlambda - fy * dxdlambda) / denominator; + lambda -= dlambda; + phi -= dphi; + } while ((Math.abs(dlambda) > epsilon || Math.abs(dphi) > epsilon) && --i > 0); + + const lng = clamp(lambda * 180 / Math.PI, -180, 180); + const lat = clamp(phi * 180 / Math.PI, -90, 90); + + return new LngLat(lng, lat); + } +}; diff --git a/src/geo/transform.js b/src/geo/transform.js index d8f30068af8..0ce5c87baf6 100644 --- a/src/geo/transform.js +++ b/src/geo/transform.js @@ -2,9 +2,11 @@ import LngLat from './lng_lat.js'; import LngLatBounds from './lng_lat_bounds.js'; -import MercatorCoordinate, {mercatorXfromLng, mercatorYfromLat, mercatorZfromAltitude, latFromMercatorY} from './mercator_coordinate.js'; +import MercatorCoordinate, {mercatorXfromLng, mercatorYfromLat, mercatorZfromAltitude, latFromMercatorY, MAX_MERCATOR_LATITUDE} from './mercator_coordinate.js'; +import {getProjection} from './projection/index.js'; +import tileTransform from '../geo/projection/tile_transform.js'; import Point from '@mapbox/point-geometry'; -import {wrap, clamp, radToDeg, degToRad, getAABBPointSquareDist, furthestTileCorner} from '../util/util.js'; +import {wrap, clamp, pick, radToDeg, degToRad, getAABBPointSquareDist, furthestTileCorner, warnOnce} from '../util/util.js'; import {number as interpolate} from '../style-spec/util/interpolate.js'; import EXTENT from '../data/extent.js'; import {vec4, mat4, mat2, vec3, quat} from 'gl-matrix'; @@ -12,10 +14,15 @@ import {Aabb, Frustum, Ray} from '../util/primitives.js'; import EdgeInsets from './edge_insets.js'; import {FreeCamera, FreeCameraOptions, orientationFromFrame} from '../ui/free_camera.js'; import assert from 'assert'; +import getProjectionAdjustments, {getProjectionAdjustmentInverted} from './projection/adjustments.js'; +import {getPixelsToTileUnitsMatrix} from '../source/pixels_to_tile_units.js'; import {UnwrappedTileID, OverscaledTileID, CanonicalTileID} from '../source/tile_id.js'; import type {Elevation} from '../terrain/elevation.js'; import type {PaddingOptions} from './edge_insets.js'; +import type {Projection} from './projection/index.js'; +import type Tile from '../source/tile.js'; +import type {ProjectionSpecification} from '../style-spec/types.js'; import type {FeatureDistanceData} from '../style-spec/feature_filter/index.js'; const NUM_WORLD_COPIES = 3; @@ -32,9 +39,7 @@ type ElevationReference = "sea" | "ground"; class Transform { tileSize: number; tileZoom: number; - lngRange: ?[number, number]; - latRange: ?[number, number]; - maxValidLatitude: number; + maxBounds: ?LngLatBounds; // 2^zoom (worldSize = tileSize * scale) scale: number; @@ -87,16 +92,26 @@ class Transform { // Inverse of glCoordMatrix, from NDC to screen coordinates, [-1, 1] x [-1, 1] --> [0, w] x [h, 0] labelPlaneMatrix: Float32Array; + inverseAdjustmentMatrix: Array; + + worldMinX: number; + worldMaxX: number; + worldMinY: number; + worldMaxY: number; + freezeTileCoverage: boolean; cameraElevationReference: ElevationReference; fogCullDistSq: ?number; _averageElevation: number; + projectionOptions: ProjectionSpecification; + projection: Projection; _elevation: ?Elevation; _fov: number; _pitch: number; _zoom: number; _cameraZoom: ?number; _unmodified: boolean; + _unmodifiedProjection: boolean; _renderWorldCopies: boolean; _minZoom: number; _maxZoom: number; @@ -107,6 +122,7 @@ class Transform { _constraining: boolean; _projMatrixCache: {[_: number]: Float32Array}; _alignedProjMatrixCache: {[_: number]: Float32Array}; + _pixelsToTileUnitsCache: {[_: number]: Float32Array}; _fogTileMatrixCache: {[_: number]: Float32Array}; _distanceTileDataCache: {[_: number]: FeatureDistanceData}; _camera: FreeCamera; @@ -115,7 +131,6 @@ class Transform { constructor(minZoom: ?number, maxZoom: ?number, minPitch: ?number, maxPitch: ?number, renderWorldCopies: boolean | void) { this.tileSize = 512; // constant - this.maxValidLatitude = 85.051129; // constant this._renderWorldCopies = renderWorldCopies === undefined ? true : renderWorldCopies; this._minZoom = minZoom || DEFAULT_MIN_ZOOM; @@ -124,6 +139,7 @@ class Transform { this._minPitch = (minPitch === undefined || minPitch === null) ? 0 : minPitch; this._maxPitch = (maxPitch === undefined || maxPitch === null) ? 60 : maxPitch; + this.setProjection(); this.setMaxBounds(); this.width = 0; @@ -153,7 +169,7 @@ class Transform { clone._elevation = this._elevation; clone._centerAltitude = this._centerAltitude; clone.tileSize = this.tileSize; - clone.latRange = this.latRange; + clone.setMaxBounds(this.getMaxBounds()); clone.width = this.width; clone.height = this.height; clone.cameraElevationReference = this.cameraElevationReference; @@ -169,6 +185,7 @@ class Transform { clone._camera = this._camera.clone(); clone._calcMatrices(); clone.freezeTileCoverage = this.freezeTileCoverage; + if (!this._unmodifiedProjection) clone.setProjection(this.getProjection()); return clone; } @@ -196,6 +213,18 @@ class Transform { this._calcMatrices(); } + getProjection() { + return pick(this.projection, ['name', 'center', 'parallels']); + } + + setProjection(projection?: ?ProjectionSpecification) { + this._unmodifiedProjection = !projection; + if (projection === undefined || projection === null) projection = {name: 'mercator'}; + this.projectionOptions = projection; + this.projection = getProjection(projection); + this._calcMatrices(); + } + get minZoom(): number { return this._minZoom; } set minZoom(zoom: number) { if (this._minZoom === zoom) return; @@ -224,7 +253,9 @@ class Transform { this.pitch = Math.min(this.pitch, pitch); } - get renderWorldCopies(): boolean { return this._renderWorldCopies; } + get renderWorldCopies(): boolean { + return this.projection.name === 'mercator' && this._renderWorldCopies; + } set renderWorldCopies(renderWorldCopies?: ?boolean) { if (renderWorldCopies === undefined) { renderWorldCopies = true; @@ -261,10 +292,19 @@ class Transform { } get bearing(): number { - return -this.angle / Math.PI * 180; + return wrap(this.rotation, -180, 180); } + set bearing(bearing: number) { - const b = -wrap(bearing, -180, 180) * Math.PI / 180; + this.rotation = bearing; + } + + get rotation(): number { + return -this.angle / Math.PI * 180; + } + + set rotation(rotation: number) { + const b = -rotation * Math.PI / 180; if (this.angle === b) return; this._unmodified = false; this.angle = b; @@ -331,7 +371,7 @@ class Transform { // Camera zoom describes the distance of the camera to the sea level (altitude). It is used only for manipulating the camera location. // The standard zoom (this._zoom) defines the camera distance to the terrain (height). Its behavior and conceptual meaning in determining // which tiles to stream is same with or without the terrain. - const elevationAtCenter = this._elevation.getAtPointOrZero(MercatorCoordinate.fromLngLat(this.center), -1); + const elevationAtCenter = this._elevation.getAtPointOrZero(this.locationCoordinate(this.center), -1); if (elevationAtCenter === -1) { // Elevation data not loaded yet @@ -414,7 +454,7 @@ class Transform { // Compute zoom level from the height of the camera relative to the terrain const cameraZoom: number = this._cameraZoom; - const elevationAtCenter = this._elevation.getAtPointOrZero(MercatorCoordinate.fromLngLat(this.center)); + const elevationAtCenter = this._elevation.getAtPointOrZero(this.locationCoordinate(this.center)); const mercatorElevation = mercatorZfromAltitude(elevationAtCenter, this.center.lat); const altitude = this._mercatorZfromZoom(cameraZoom); const minHeight = this._mercatorZfromZoom(this._maxZoom); @@ -648,11 +688,12 @@ class Transform { const actualZ = z; const useElevationData = this.elevation && !options.isTerrainDEM; + const isMercator = this.projection.name === 'mercator'; if (options.minzoom !== undefined && z < options.minzoom) return []; if (options.maxzoom !== undefined && z > options.maxzoom) z = options.maxzoom; - const centerCoord = MercatorCoordinate.fromLngLat(this.center); + const centerCoord = this.locationCoordinate(this.center); const numTiles = 1 << z; const centerPoint = [numTiles * centerCoord.x, numTiles * centerCoord.y, 0]; const cameraFrustum = Frustum.fromInvProjectionMatrix(this.invProjMatrix, this.worldSize, z); @@ -667,19 +708,67 @@ class Transform { const zoomSplitDistance = this.cameraToCenterDistance / options.tileSize * (options.roundZoom ? 1 : 0.502); // No change of LOD behavior for pitch lower than 60 and when there is no top padding: return only tile ids from the requested zoom level - const minZoom = this.pitch <= 60.0 && this._edgeInsets.top <= this._edgeInsets.bottom && !this._elevation ? z : 0; + const minZoom = this.pitch <= 60.0 && this._edgeInsets.top <= this._edgeInsets.bottom && !this._elevation && isMercator ? z : 0; // When calculating tile cover for terrain, create deep AABB for nodes, to ensure they intersect frustum: for sources, // other than DEM, use minimum of visible DEM tiles and center altitude as upper bound (pitch is always less than 90°). const maxRange = options.isTerrainDEM && this._elevation ? this._elevation.exaggeration() * 10000 : this._centerAltitude; const minRange = options.isTerrainDEM ? -maxRange : this._elevation ? this._elevation.getMinElevationBelowMSL() : 0; + + const sizeAtMercatorCoord = mc => { + // Calculate how scale compares between projected coordinates and mercator coordinates. + // Returns a length. The units don't matter since the result is only + // used in a ratio with other values returned by this function. + + // Construct a small square in Mercator coordinates. + const offset = 1 / 40000; + const mcEast = new MercatorCoordinate(mc.x + offset, mc.y, mc.z); + const mcSouth = new MercatorCoordinate(mc.x, mc.y + offset, mc.z); + + // Convert the square to projected coordinates. + const ll = mc.toLngLat(); + const llEast = mcEast.toLngLat(); + const llSouth = mcSouth.toLngLat(); + const p = this.locationCoordinate(ll); + const pEast = this.locationCoordinate(llEast); + const pSouth = this.locationCoordinate(llSouth); + + // Calculate the size of each edge of the reprojected square + const dx = Math.hypot(pEast.x - p.x, pEast.y - p.y); + const dy = Math.hypot(pSouth.x - p.x, pSouth.y - p.y); + + // Calculate the size of a projected square that would have the + // same area as the reprojected square. + return Math.sqrt(dx * dy) / offset; + }; + + const centerSize = sizeAtMercatorCoord(MercatorCoordinate.fromLngLat(this.center)); + + const aabbForTile = (z, x, y, wrap, min, max) => { + const tt = tileTransform({z, x, y}, this.projection); + const tx = tt.x / tt.scale; + const ty = tt.y / tt.scale; + const tx2 = tt.x2 / tt.scale; + const ty2 = tt.y2 / tt.scale; + if (isNaN(tx) || isNaN(tx2) || isNaN(ty) || isNaN(ty2)) { + assert(false); + } + const ret = new Aabb( + [(wrap + tx) * numTiles, numTiles * ty, min], + [(wrap + tx2) * numTiles, numTiles * ty2, max]); + return ret; + }; + const newRootTile = (wrap: number): any => { const max = maxRange; const min = minRange; + const aabb = this.projection.name === 'mercator' ? + new Aabb([wrap * numTiles, 0, min], [(wrap + 1) * numTiles, numTiles, max]) : + aabbForTile(0, 0, 0, wrap, min, max); return { // With elevation, this._elevation provides z coordinate values. For 2D: // All tiles are on zero elevation plane => z difference is zero - aabb: new Aabb([wrap * numTiles, 0, min], [(wrap + 1) * numTiles, numTiles, max]), + aabb, zoom: 0, x: 0, y: 0, @@ -758,8 +847,21 @@ class Transform { dzSqr = square(it.aabb.distanceZ(cameraPoint) * meterToTile); } + let scaleAdjustment = 1; + if (!isMercator && actualZ <= 5) { + // In other projections, not all tiles are the same size. + // Account for the tile size difference by adjusting the distToSplit. + // Adjust by the ratio of the area at the tile center to the area at the map center. + // Adjustments are only needed at lower zooms where tiles are not similarly sized. + const numTiles = Math.pow(2, it.zoom); + const tileCenterSize = sizeAtMercatorCoord(new MercatorCoordinate((it.x + 0.5) / numTiles, (it.y + 0.5) / numTiles)); + const areaRatio = tileCenterSize / centerSize; + // Fudge the ratio slightly so that all tiles near the center have the same zoom level. + scaleAdjustment = areaRatio > 0.85 ? 1 : areaRatio; + } + const distanceSqr = dx * dx + dy * dy + dzSqr; - const distToSplit = (1 << maxZoom - it.zoom) * zoomSplitDistance; + const distToSplit = (1 << maxZoom - it.zoom) * zoomSplitDistance * scaleAdjustment; const distToSplitSqr = square(distToSplit * distToSplitScale(Math.max(dzSqr, cameraHeightSqr), distanceSqr)); return distanceSqr < distToSplitSqr; @@ -802,7 +904,6 @@ class Transform { const dx = centerPoint[0] - ((0.5 + x + (it.wrap << it.zoom)) * (1 << (z - it.zoom))); const dy = centerPoint[1] - 0.5 - y; const id = it.tileID ? it.tileID : new OverscaledTileID(tileZoom, it.wrap, it.zoom, x, y); - result.push({tileID: id, distanceSq: dx * dx + dy * dy}); continue; } @@ -811,7 +912,7 @@ class Transform { const childX = (x << 1) + (i % 2); const childY = (y << 1) + (i >> 1); - const aabb = it.aabb.quadrant(i); + const aabb = this.projection.name === 'mercator' ? it.aabb.quadrant(i) : aabbForTile(it.zoom + 1, childX, childY, it.wrap, 0, 0); const child = {aabb, zoom: it.zoom + 1, x: childX, y: childY, wrap: it.wrap, fullyVisible, tileID: undefined, shouldSplit: undefined}; if (useElevationData) { child.tileID = new OverscaledTileID(it.zoom + 1 === maxZoom ? overscaledZ : it.zoom + 1, it.wrap, it.zoom + 1, childX, childY); @@ -857,7 +958,8 @@ class Transform { if (!minmax) { minmax = {min: minRange, max: maxRange}; } - const cornerFar = furthestTileCorner(this.bearing); + // ensure that we want `this.rotation` instead of `this.bearing` here + const cornerFar = furthestTileCorner(this.rotation); const farX = cornerFar[0] * EXTENT; const farY = cornerFar[1] * EXTENT; @@ -882,7 +984,7 @@ class Transform { const cover = result.sort((a, b) => a.distanceSq - b.distanceSq).map(a => a.tileID); // Relax the assertion on terrain, on high zoom we use distance to center of tile // while camera might be closer to selected center of map. - assert(!cover.length || this.elevation || cover[0].overscaledZ === overscaledZ); + assert(!cover.length || this.elevation || cover[0].overscaledZ === overscaledZ || !isMercator); return cover; } @@ -902,15 +1004,16 @@ class Transform { // Transform from LngLat to Point in world coordinates [-180, 180] x [90, -90] --> [0, this.worldSize] x [0, this.worldSize] project(lnglat: LngLat) { - const lat = clamp(lnglat.lat, -this.maxValidLatitude, this.maxValidLatitude); + const lat = clamp(lnglat.lat, -MAX_MERCATOR_LATITUDE, MAX_MERCATOR_LATITUDE); + const projectedLngLat = this.projection.project(lnglat.lng, lat); return new Point( - mercatorXfromLng(lnglat.lng) * this.worldSize, - mercatorYfromLat(lat) * this.worldSize); + projectedLngLat.x * this.worldSize, + projectedLngLat.y * this.worldSize); } // Transform from Point in world coordinates to LngLat [0, this.worldSize] x [0, this.worldSize] --> [-180, 180] x [90, -90] unproject(point: Point): LngLat { - return new MercatorCoordinate(point.x / this.worldSize, point.y / this.worldSize).toLngLat(); + return this.projection.unproject(point.x / this.worldSize, point.y / this.worldSize); } // Point at center in world coordinates. @@ -920,18 +1023,14 @@ class Transform { const a = this.pointCoordinate(point); const b = this.pointCoordinate(this.centerPoint); const loc = this.locationCoordinate(lnglat); - const newCenter = new MercatorCoordinate( - loc.x - (a.x - b.x), - loc.y - (a.y - b.y)); - this.center = this.coordinateLocation(newCenter); - if (this._renderWorldCopies) { - this.center = this.center.wrap(); - } + this.setLocation(new MercatorCoordinate( + loc.x - (a.x - b.x), + loc.y - (a.y - b.y))); } setLocation(location: MercatorCoordinate) { this.center = this.coordinateLocation(location); - if (this._renderWorldCopies) { + if (this.renderWorldCopies) { this.center = this.center.wrap(); } } @@ -984,24 +1083,31 @@ class Transform { } /** - * Given a geographical lnglat, return an unrounded + * Given a geographical lngLat, return an unrounded * coordinate that represents it at this transform's zoom level. - * @param {LngLat} lnglat + * @param {LngLat} lngLat * @returns {Coordinate} * @private */ - locationCoordinate(lnglat: LngLat) { - return MercatorCoordinate.fromLngLat(lnglat); + locationCoordinate(lngLat: LngLat, altitude?: number) { + const z = altitude ? + mercatorZfromAltitude(altitude, lngLat.lat) : + undefined; + const projectedLngLat = this.projection.project(lngLat.lng, lngLat.lat); + return new MercatorCoordinate( + projectedLngLat.x, + projectedLngLat.y, + z); } /** * Given a Coordinate, return its geographical position. * @param {Coordinate} coord - * @returns {LngLat} lnglat + * @returns {LngLat} lngLat * @private */ coordinateLocation(coord: MercatorCoordinate) { - return coord.toLngLat(); + return this.projection.unproject(coord.x, coord.y); } /** @@ -1227,11 +1333,8 @@ class Transform { * Returns the maximum geographical bounds the map is constrained to, or `null` if none set. * @returns {LngLatBounds} {@link LngLatBounds}. */ - getMaxBounds(): LngLatBounds | null { - if (!this.latRange || this.latRange.length !== 2 || - !this.lngRange || this.lngRange.length !== 2) return null; - - return new LngLatBounds([this.lngRange[0], this.latRange[0]], [this.lngRange[1], this.latRange[1]]); + getMaxBounds(): ?LngLatBounds { + return this.maxBounds; } /** @@ -1239,27 +1342,49 @@ class Transform { * * @param {LngLatBounds} bounds A {@link LngLatBounds} object describing the new geographic boundaries of the map. */ - setMaxBounds(bounds?: LngLatBounds) { + setMaxBounds(bounds: ?LngLatBounds) { + this.maxBounds = bounds; + + let minLat = -MAX_MERCATOR_LATITUDE; + let maxLat = MAX_MERCATOR_LATITUDE; + let minLng = -180; + let maxLng = 180; + if (bounds) { - const eastBound = bounds.getEast(); - const westBound = bounds.getWest(); - // Unwrap bounds if they cross the 180th meridian - this.lngRange = [westBound, eastBound > westBound ? eastBound : eastBound + 360]; - this.latRange = [bounds.getSouth(), bounds.getNorth()]; - this._constrain(); - } else { - this.lngRange = null; - this.latRange = [-this.maxValidLatitude, this.maxValidLatitude]; + minLat = bounds.getSouth(); + maxLat = bounds.getNorth(); + minLng = bounds.getWest(); + maxLng = bounds.getEast(); + if (maxLng < minLng) maxLng += 360; } + + this.worldMinX = mercatorXfromLng(minLng) * this.tileSize; + this.worldMaxX = mercatorXfromLng(maxLng) * this.tileSize; + this.worldMinY = mercatorYfromLat(maxLat) * this.tileSize; + this.worldMaxY = mercatorYfromLat(minLat) * this.tileSize; + + this._constrain(); } calculatePosMatrix(unwrappedTileID: UnwrappedTileID, worldSize: number): Float32Array { + let scale, scaledX, scaledY; const canonical = unwrappedTileID.canonical; - const scale = worldSize / this.zoomScale(canonical.z); - const unwrappedX = canonical.x + Math.pow(2, canonical.z) * unwrappedTileID.wrap; - const posMatrix = mat4.identity(new Float64Array(16)); - mat4.translate(posMatrix, posMatrix, [unwrappedX * scale, canonical.y * scale, 0]); + + if (this.projection.name === 'mercator') { + scale = worldSize / this.zoomScale(canonical.z); + const unwrappedX = canonical.x + Math.pow(2, canonical.z) * unwrappedTileID.wrap; + scaledX = unwrappedX * scale; + scaledY = canonical.y * scale; + } else { + const cs = tileTransform(canonical, this.projection); + scale = 1; + scaledX = cs.x; + scaledY = cs.y; + mat4.scale(posMatrix, posMatrix, [scale / cs.scale, scale / cs.scale, this.pixelsPerMeter / this.worldSize]); + } + + mat4.translate(posMatrix, posMatrix, [scaledX, scaledY, 0]); mat4.scale(posMatrix, posMatrix, [scale / EXTENT, scale / EXTENT, 1]); return posMatrix; @@ -1334,12 +1459,25 @@ class Transform { } const posMatrix = this.calculatePosMatrix(unwrappedTileID, this.worldSize); - mat4.multiply(posMatrix, aligned ? this.alignedProjMatrix : this.projMatrix, posMatrix); + const projMatrix = this.projection.name === 'mercator' ? aligned ? this.alignedProjMatrix : this.projMatrix : this.mercatorMatrix; + mat4.multiply(posMatrix, projMatrix, posMatrix); cache[projMatrixKey] = new Float32Array(posMatrix); return cache[projMatrixKey]; } + calculatePixelsToTileUnitsMatrix(tile: Tile): Float32Array { + const key = tile.tileID.key; + const cache = this._pixelsToTileUnitsCache; + if (cache[key]) { + return cache[key]; + } + + const matrix = getPixelsToTileUnitsMatrix(tile, this); + cache[key] = matrix; + return cache[key]; + } + customLayerMatrix(): Array { return this.mercatorMatrix.slice(); } @@ -1377,7 +1515,7 @@ class Transform { // Camera zoom has to be updated as the orbit distance might have changed this._cameraZoom = this._zoomFromMercatorZ(maxAltitude); this._centerAltitude = newCenter.toAltitude(); - this._center = newCenter.toLngLat(); + this._center = this.coordinateLocation(newCenter); this._updateZoomFromElevation(); this._constrain(); this._calcMatrices(); @@ -1397,7 +1535,7 @@ class Transform { const cameraHeight = this._camera.position[2] - terrainElevation; if (cameraHeight < minHeight) { - const center = MercatorCoordinate.fromLngLat(this._center, this._centerAltitude); + const center = this.locationCoordinate(this._center, this._centerAltitude); const cameraPos = this._camera.mercatorPosition; const cameraToCenter = [center.x - cameraPos.x, center.y - cameraPos.y, center.z - cameraPos.z]; const prevDistToCamera = vec3.length(cameraToCenter); @@ -1422,106 +1560,80 @@ class Transform { this._constraining = true; - let minY = Infinity; - let maxY = -Infinity; - let minX, maxX, sy, sx, y2; - const size = this.size, - unmodified = this._unmodified; - - if (this.latRange) { - const latRange = this.latRange; - minY = mercatorYfromLat(latRange[1]) * this.worldSize; - maxY = mercatorYfromLat(latRange[0]) * this.worldSize; - sy = maxY - minY < size.y ? size.y / (maxY - minY) : 0; - } - - if (this.lngRange) { - const lngRange = this.lngRange; - minX = mercatorXfromLng(lngRange[0]) * this.worldSize; - maxX = mercatorXfromLng(lngRange[1]) * this.worldSize; - sx = maxX - minX < size.x ? size.x / (maxX - minX) : 0; - } - - const point = this.point; - - // how much the map should scale to fit the screen into given latitude/longitude ranges - const s = Math.max(sx || 0, sy || 0); - - if (s) { - this.center = this.unproject(new Point( - sx ? (maxX + minX) / 2 : point.x, - sy ? (maxY + minY) / 2 : point.y)); - this.zoom += this.scaleZoom(s); - this._unmodified = unmodified; + // alternate constraining for non-Mercator projections + const maxBounds = this.maxBounds; + if (this.projection.name !== 'mercator' && maxBounds) { + const center = this.center; + center.lat = clamp(center.lat, maxBounds.getSouth(), maxBounds.getNorth()); + center.lng = clamp(center.lng, maxBounds.getWest(), maxBounds.getEast()); + this.center = center; this._constraining = false; return; } - if (this.latRange) { - const y = point.y, - h2 = size.y / 2; - - if (y - h2 < minY) y2 = minY + h2; - if (y + h2 > maxY) y2 = maxY - h2; + const unmodified = this._unmodified; + const {x, y} = this.point; + let s = 0; + let x2 = x; + let y2 = y; + const w2 = this.width / 2; + const h2 = this.height / 2; + + const minY = this.worldMinY * this.scale; + const maxY = this.worldMaxY * this.scale; + if (y - h2 < minY) y2 = minY + h2; + if (y + h2 > maxY) y2 = maxY - h2; + if (maxY - minY < this.height) { + s = Math.max(s, this.height / (maxY - minY)); + y2 = (maxY + minY) / 2; } - let x = point.x; + if (this.maxBounds) { + const minX = this.worldMinX * this.scale; + const maxX = this.worldMaxX * this.scale; - if (this.lngRange) { // Translate to positive positions with the map center in the center position. // This ensures that the map snaps to the correct edge. const shift = this.worldSize / 2 - (minX + maxX) / 2; - x = (x + shift + this.worldSize) % this.worldSize; - minX += shift; - maxX += shift; + x2 = (x + shift + this.worldSize) % this.worldSize - shift; - const w2 = size.x / 2; - if (x - w2 < minX) x = minX + w2; - if (x + w2 > maxX) x = maxX - w2; - - x -= shift; + if (x2 - w2 < minX) x2 = minX + w2; + if (x2 + w2 > maxX) x2 = maxX - w2; + if (maxX - minX < this.width) { + s = Math.max(s, this.width / (maxX - minX)); + x2 = (maxX + minX) / 2; + } } - // pan the map if the screen goes off the range - if (x !== point.x || y2 !== undefined) { - this.center = this.unproject(new Point( - x, - y2 !== undefined ? y2 : point.y)); + if (x2 !== x || y2 !== y) { // pan the map to fit the range + this.center = this.unproject(new Point(x2, y2)); + } + if (s) { // scale the map to fit the range + this.zoom += this.scaleZoom(s); } this._constrainCameraAltitude(); - this._unmodified = unmodified; this._constraining = false; } /** - * Returns the minimum zoom at which `this.width` can fit `this.lngRange` - * and `this.height` can fit `this.latRange`. + * Returns the minimum zoom at which `this.width` can fit max longitude range + * and `this.height` can fit max latitude range. * * @returns {number} The zoom value. */ _minZoomForBounds(): number { - const minZoomForDim = (dim: number, range: [number, number]): number => { - return Math.log2(dim / (this.tileSize * Math.abs(range[1] - range[0]))); - }; - let minLatZoom = DEFAULT_MIN_ZOOM; - if (this.latRange) { - const latRange = this.latRange; - minLatZoom = minZoomForDim(this.height, [mercatorYfromLat(latRange[0]), mercatorYfromLat(latRange[1])]); + let minZoom = Math.max(0, this.scaleZoom(this.height / (this.worldMaxY - this.worldMinY))); + if (this.maxBounds) { + minZoom = Math.max(minZoom, this.scaleZoom(this.width / (this.worldMaxX - this.worldMinX))); } - let minLngZoom = DEFAULT_MIN_ZOOM; - if (this.lngRange) { - const lngRange = this.lngRange; - minLngZoom = minZoomForDim(this.width, [mercatorXfromLng(lngRange[0]), mercatorXfromLng(lngRange[1])]); - } - - return Math.max(minLatZoom, minLngZoom); + return minZoom; } /** * Returns the maximum distance of the camera from the center of the bounds, such that - * `this.width` can fit `this.lngRange` and `this.height` can fit `this.latRange`. + * `this.width` can fit max longitude range and `this.height` can fit max latitude range. * In mercator units. * * @returns {number} The mercator z coordinate. @@ -1583,6 +1695,20 @@ class Transform { let m = mat4.mul([], cameraToClip, worldToCamera); + if (this.projection.name !== 'mercator') { + // Projections undistort as you zoom in (shear, scale, rotate). + // Apply the undistortion around the center of the map. + const mc = this.locationCoordinate(this.center); + const adjustments = mat4.identity([]); + mat4.translate(adjustments, adjustments, [mc.x * this.worldSize, mc.y * this.worldSize, 0]); + mat4.multiply(adjustments, adjustments, getProjectionAdjustments(this)); + mat4.translate(adjustments, adjustments, [-mc.x * this.worldSize, -mc.y * this.worldSize, 0]); + mat4.multiply(m, m, adjustments); + this.inverseAdjustmentMatrix = getProjectionAdjustmentInverted(this); + } else { + this.inverseAdjustmentMatrix = [1, 0, 0, 1]; + } + // The mercatorMatrix can be used to transform points from mercator coordinates // ([0, 0] nw, [1, 1] se) to GL coordinates. this.mercatorMatrix = mat4.scale([], m, [this.worldSize, this.worldSize, this.worldSize / pixelsPerMeter]); @@ -1645,6 +1771,7 @@ class Transform { this._projMatrixCache = {}; this._alignedProjMatrixCache = {}; + this._pixelsToTileUnitsCache = {}; } _calcFogMatrices() { @@ -1703,7 +1830,7 @@ class Transform { /** * Apply a 3d translation to the camera position, but clamping it so that - * it respects the bounds set by `this.latRange` and `this.lngRange`. + * it respects the maximum longitude and latitude range set. * * @param {vec3} translation The translation vector. */ @@ -1744,7 +1871,7 @@ class Transform { if (this._terrainEnabled()) this._updateCameraOnTerrain(); - this._center = new MercatorCoordinate(position[0], position[1], position[2]).toLngLat(); + this._center = this.coordinateLocation(new MercatorCoordinate(position[0], position[1], position[2])); this._unmodified = false; this._constrain(); this._calcMatrices(); @@ -1773,7 +1900,12 @@ class Transform { } _terrainEnabled(): boolean { - return !!this._elevation; + if (!this._elevation) return false; + if (this.projection.name !== 'mercator') { + warnOnce('Terrain is not yet supported with alternate projections. Use mercator to enable terrain.'); + return false; + } + return true; } // Check if any of the four corners are off the edge of the rendered map diff --git a/src/render/draw_background.js b/src/render/draw_background.js index d2b15f51e7c..bd786aa8889 100644 --- a/src/render/draw_background.js +++ b/src/render/draw_background.js @@ -3,6 +3,7 @@ import StencilMode from '../gl/stencil_mode.js'; import DepthMode from '../gl/depth_mode.js'; import CullFaceMode from '../gl/cull_face_mode.js'; +import Tile from '../source/tile.js'; import { backgroundUniformValues, backgroundPatternUniformValues @@ -37,7 +38,12 @@ function drawBackground(painter: Painter, sourceCache: SourceCache, layer: Backg const program = painter.useProgram(image ? 'backgroundPattern' : 'background'); - const tileIDs = coords ? coords : transform.coveringTiles({tileSize}); + let tileIDs = coords; + let backgroundTiles; + if (!tileIDs) { + backgroundTiles = painter.getBackgroundTiles(); + tileIDs = Object.values(backgroundTiles).map(tile => (tile: any).tileID); + } if (image) { context.activeTexture.set(gl.TEXTURE0); @@ -50,14 +56,19 @@ function drawBackground(painter: Painter, sourceCache: SourceCache, layer: Backg const matrix = coords ? tileID.projMatrix : painter.transform.calculateProjMatrix(unwrappedTileID); painter.prepareDrawTile(tileID); + const tile = sourceCache ? sourceCache.getTile(tileID) : + backgroundTiles ? backgroundTiles[tileID.key] : new Tile(tileID, tileSize, transform.zoom, painter); + const uniformValues = image ? backgroundPatternUniformValues(matrix, opacity, painter, image, {tileID, tileSize}, crossfade) : backgroundUniformValues(matrix, opacity, color); painter.prepareDrawProgram(context, program, unwrappedTileID); + const {tileBoundsBuffer, tileBoundsIndexBuffer, tileBoundsSegments} = painter.getTileBoundsBuffers(tile); + program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.disabled, - uniformValues, layer.id, painter.tileExtentBuffer, - painter.quadTriangleIndexBuffer, painter.tileExtentSegments); + uniformValues, layer.id, tileBoundsBuffer, + tileBoundsIndexBuffer, tileBoundsSegments); } } diff --git a/src/render/draw_debug.js b/src/render/draw_debug.js index 18101641c1f..63ba163c6e6 100644 --- a/src/render/draw_debug.js +++ b/src/render/draw_debug.js @@ -136,9 +136,15 @@ function drawDebugTile(painter, sourceCache, coord: OverscaledTileID) { // Bind the empty texture for drawing outlines painter.emptyTexture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE); + tile._makeDebugTileBoundsBuffers(painter.context, painter.transform.projection); + + const debugBuffer = tile._tileDebugBuffer || painter.debugBuffer; + const debugIndexBuffer = tile._tileDebugIndexBuffer || painter.debugIndexBuffer; + const debugSegments = tile._tileDebugSegments || painter.debugSegments; + program.draw(context, gl.LINE_STRIP, depthMode, stencilMode, colorMode, CullFaceMode.disabled, debugUniformValues(posMatrix, Color.red), id, - painter.debugBuffer, painter.tileBorderIndexBuffer, painter.debugSegments); + debugBuffer, debugIndexBuffer, debugSegments); const tileRawData = tile.latestRawTileData; const tileByteLength = (tileRawData && tileRawData.byteLength) || 0; diff --git a/src/render/draw_hillshade.js b/src/render/draw_hillshade.js index dc4ea0aaf9f..ef678574693 100644 --- a/src/render/draw_hillshade.js +++ b/src/render/draw_hillshade.js @@ -63,9 +63,11 @@ function renderHillshade(painter, coord, tile, layer, depthMode, stencilMode, co painter.prepareDrawProgram(context, program, coord.toUnwrapped()); + const {tileBoundsBuffer, tileBoundsIndexBuffer, tileBoundsSegments} = painter.getTileBoundsBuffers(tile); + program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.disabled, - uniformValues, layer.id, painter.rasterBoundsBuffer, - painter.quadTriangleIndexBuffer, painter.rasterBoundsSegments); + uniformValues, layer.id, tileBoundsBuffer, + tileBoundsIndexBuffer, tileBoundsSegments); } export function prepareDEMTexture(painter: Painter, tile: Tile, dem: DEMData) { @@ -114,11 +116,13 @@ function prepareHillshade(painter, tile, layer, depthMode, stencilMode, colorMod context.bindFramebuffer.set(fbo.framebuffer); context.viewport.set([0, 0, tileSize, tileSize]); + const {tileBoundsBuffer, tileBoundsIndexBuffer, tileBoundsSegments} = painter.getTileBoundsBuffers(tile); + painter.useProgram('hillshadePrepare').draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.disabled, hillshadeUniformPrepareValues(tile.tileID, dem), - layer.id, painter.rasterBoundsBuffer, - painter.quadTriangleIndexBuffer, painter.rasterBoundsSegments); + layer.id, tileBoundsBuffer, + tileBoundsIndexBuffer, tileBoundsSegments); tile.needsHillshadePrepare = false; } diff --git a/src/render/draw_raster.js b/src/render/draw_raster.js index 19b1caaef50..beaea4fc0a6 100644 --- a/src/render/draw_raster.js +++ b/src/render/draw_raster.js @@ -87,9 +87,11 @@ function drawRaster(painter: Painter, sourceCache: SourceCache, layer: RasterSty uniformValues, layer.id, source.boundsBuffer, painter.quadTriangleIndexBuffer, source.boundsSegments); } else { + const {tileBoundsBuffer, tileBoundsIndexBuffer, tileBoundsSegments} = painter.getTileBoundsBuffers(tile); + program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.disabled, - uniformValues, layer.id, painter.rasterBoundsBuffer, - painter.quadTriangleIndexBuffer, painter.rasterBoundsSegments); + uniformValues, layer.id, tileBoundsBuffer, + tileBoundsIndexBuffer, tileBoundsSegments); } } } diff --git a/src/render/draw_symbol.js b/src/render/draw_symbol.js index dc94dbfeb38..e796564df82 100644 --- a/src/render/draw_symbol.js +++ b/src/render/draw_symbol.js @@ -4,7 +4,6 @@ import Point from '@mapbox/point-geometry'; import drawCollisionDebug from './draw_collision_debug.js'; import SegmentVector from '../data/segment.js'; -import pixelsToTileUnits from '../source/pixels_to_tile_units.js'; import * as symbolProjection from '../symbol/projection.js'; import * as symbolSize from '../symbol/symbol_size.js'; import {mat4} from 'gl-matrix'; @@ -126,8 +125,8 @@ function updateVariableAnchors(coords, painter, layer, sourceCache, rotationAlig const sizeData = bucket.textSizeData; const size = symbolSize.evaluateSizeForZoom(sizeData, tr.zoom); - const pixelToTileScale = pixelsToTileUnits(tile, 1, painter.transform.zoom); - const labelPlaneMatrix = symbolProjection.getLabelPlaneMatrix(coord.projMatrix, pitchWithMap, rotateWithMap, painter.transform, pixelToTileScale); + const pixelsToTileUnits = painter.transform.calculatePixelsToTileUnitsMatrix(tile); + const labelPlaneMatrix = symbolProjection.getLabelPlaneMatrix(coord.projMatrix, pitchWithMap, rotateWithMap, painter.transform, pixelsToTileUnits); const updateTextFitIcon = layer.layout.get('icon-text-fit') !== 'none' && bucket.hasIconData(); if (size) { @@ -298,7 +297,7 @@ function drawLayerSymbols(painter, sourceCache, layer, coords, isText, translate texSize = tile.imageAtlasTexture.size; } - const s = pixelsToTileUnits(tile, 1, painter.transform.zoom); + const s = painter.transform.calculatePixelsToTileUnitsMatrix(tile); const labelPlaneMatrix = symbolProjection.getLabelPlaneMatrix(coord.projMatrix, pitchWithMap, rotateWithMap, painter.transform, s); // labelPlaneMatrixInv is used for converting vertex pos to tile coordinates needed for sampling elevation. const labelPlaneMatrixInv = painter.terrain && pitchWithMap && alongLine ? mat4.invert(new Float32Array(16), labelPlaneMatrix) : identityMat4; diff --git a/src/render/painter.js b/src/render/painter.js index 88de16aec79..7ad554175ef 100644 --- a/src/render/painter.js +++ b/src/render/painter.js @@ -8,11 +8,11 @@ import SourceCache from '../source/source_cache.js'; import EXTENT from '../data/extent.js'; import pixelsToTileUnits from '../source/pixels_to_tile_units.js'; import SegmentVector from '../data/segment.js'; -import {RasterBoundsArray, PosArray, TriangleIndexArray, LineStripIndexArray} from '../data/array_types.js'; +import {PosArray, TileBoundsArray, TriangleIndexArray, LineStripIndexArray} from '../data/array_types.js'; import {values, MAX_SAFE_INTEGER} from '../util/util.js'; import {isMapAuthenticated} from '../util/mapbox.js'; -import rasterBoundsAttributes from '../data/raster_bounds_attributes.js'; import posAttributes from '../data/pos_attributes.js'; +import boundsAttributes from '../data/bounds_attributes.js'; import ProgramConfiguration from '../data/program_configuration.js'; import CrossTileSymbolIndex from '../symbol/cross_tile_symbol_index.js'; import shaders from '../shaders/shaders.js'; @@ -42,6 +42,7 @@ import custom from './draw_custom.js'; import sky from './draw_sky.js'; import {Terrain} from '../terrain/terrain.js'; import {Debug} from '../util/debug.js'; +import Tile from '../source/tile.js'; const draw = { symbol, @@ -59,7 +60,6 @@ const draw = { }; import type Transform from '../geo/transform.js'; -import type Tile from '../source/tile.js'; import type {OverscaledTileID, UnwrappedTileID} from '../source/tile_id.js'; import type Style from '../style/style.js'; import type StyleLayer from '../style/style_layer.js'; @@ -112,13 +112,13 @@ class Painter { tileExtentBuffer: VertexBuffer; tileExtentSegments: SegmentVector; debugBuffer: VertexBuffer; + debugIndexBuffer: IndexBuffer; debugSegments: SegmentVector; - rasterBoundsBuffer: VertexBuffer; - rasterBoundsSegments: SegmentVector; viewportBuffer: VertexBuffer; viewportSegments: SegmentVector; quadTriangleIndexBuffer: IndexBuffer; - tileBorderIndexBuffer: IndexBuffer; + mercatorBoundsBuffer: VertexBuffer; + mercatorBoundsSegments: SegmentVector; _tileClippingMaskIDs: {[_: number]: number }; stencilClearMode: StencilMode; style: Style; @@ -147,6 +147,7 @@ class Painter { tileLoaded: boolean; frameCopies: Array; loadTimeStamps: Array; + _backgroundTiles: {[_: number | string]: Tile}; constructor(gl: WebGLRenderingContext, transform: Transform) { this.context = new Context(gl); @@ -166,10 +167,11 @@ class Painter { this.gpuTimers = {}; this.frameCounter = 0; + this._backgroundTiles = {}; } updateTerrain(style: Style, cameraChanging: boolean) { - const enabled = !!style && !!style.terrain; + const enabled = !!style && !!style.terrain && this.transform.projection.name === 'mercator'; if (!enabled && (!this._terrain || !this._terrain.enabled)) return; if (!this._terrain) { this._terrain = new Terrain(this, style); @@ -202,7 +204,7 @@ class Painter { } get terrain(): ?Terrain { - return this._terrain && this._terrain.enabled ? this._terrain : null; + return this.transform._terrainEnabled() && this._terrain && this._terrain.enabled ? this._terrain : null; } /* @@ -240,14 +242,6 @@ class Painter { this.debugBuffer = context.createVertexBuffer(debugArray, posAttributes.members); this.debugSegments = SegmentVector.simpleSegment(0, 0, 4, 5); - const rasterBoundsArray = new RasterBoundsArray(); - rasterBoundsArray.emplaceBack(0, 0, 0, 0); - rasterBoundsArray.emplaceBack(EXTENT, 0, EXTENT, 0); - rasterBoundsArray.emplaceBack(0, EXTENT, 0, EXTENT); - rasterBoundsArray.emplaceBack(EXTENT, EXTENT, EXTENT, EXTENT); - this.rasterBoundsBuffer = context.createVertexBuffer(rasterBoundsArray, rasterBoundsAttributes.members); - this.rasterBoundsSegments = SegmentVector.simpleSegment(0, 0, 4, 2); - const viewportArray = new PosArray(); viewportArray.emplaceBack(-1, -1); viewportArray.emplaceBack(1, -1); @@ -256,19 +250,23 @@ class Painter { this.viewportBuffer = context.createVertexBuffer(viewportArray, posAttributes.members); this.viewportSegments = SegmentVector.simpleSegment(0, 0, 4, 2); - const tileLineStripIndices = new LineStripIndexArray(); - tileLineStripIndices.emplaceBack(0); - tileLineStripIndices.emplaceBack(1); - tileLineStripIndices.emplaceBack(3); - tileLineStripIndices.emplaceBack(2); - tileLineStripIndices.emplaceBack(0); - this.tileBorderIndexBuffer = context.createIndexBuffer(tileLineStripIndices); + const tileBoundsArray = new TileBoundsArray(); + tileBoundsArray.emplaceBack(0, 0, 0, 0); + tileBoundsArray.emplaceBack(EXTENT, 0, EXTENT, 0); + tileBoundsArray.emplaceBack(0, EXTENT, 0, EXTENT); + tileBoundsArray.emplaceBack(EXTENT, EXTENT, EXTENT, EXTENT); + this.mercatorBoundsBuffer = context.createVertexBuffer(tileBoundsArray, boundsAttributes.members); + this.mercatorBoundsSegments = SegmentVector.simpleSegment(0, 0, 4, 2); const quadTriangleIndices = new TriangleIndexArray(); quadTriangleIndices.emplaceBack(0, 1, 2); quadTriangleIndices.emplaceBack(2, 1, 3); this.quadTriangleIndexBuffer = context.createIndexBuffer(quadTriangleIndices); + const tileLineStripIndices = new LineStripIndexArray(); + for (const i of [0, 1, 3, 2, 0]) tileLineStripIndices.emplaceBack(i); + this.debugIndexBuffer = context.createIndexBuffer(tileLineStripIndices); + this.emptyTexture = new Texture(context, { width: 1, height: 1, @@ -282,6 +280,21 @@ class Painter { this.loadTimeStamps.push(window.performance.now()); } + getTileBoundsBuffers(tile: Tile) { + let tileBoundsBuffer, tileBoundsIndexBuffer, tileBoundsSegments; + if (tile._tileBoundsBuffer) { + tileBoundsBuffer = tile._tileBoundsBuffer; + tileBoundsIndexBuffer = tile._tileBoundsIndexBuffer; + tileBoundsSegments = tile._tileBoundsSegments; + } else { + tileBoundsBuffer = this.mercatorBoundsBuffer; + tileBoundsIndexBuffer = this.quadTriangleIndexBuffer; + tileBoundsSegments = this.mercatorBoundsSegments; + } + + return {tileBoundsBuffer, tileBoundsIndexBuffer, tileBoundsSegments}; + } + /* * Reset the drawing canvas by clearing the stencil buffer so that we can draw * new tiles at the same location, while retaining previously drawn pixels. @@ -325,14 +338,16 @@ class Painter { this._tileClippingMaskIDs = {}; for (const tileID of tileIDs) { + const tile = sourceCache.getTile(tileID); const id = this._tileClippingMaskIDs[tileID.key] = this.nextStencilID++; + const {tileBoundsBuffer, tileBoundsIndexBuffer, tileBoundsSegments} = this.getTileBoundsBuffers(tile); program.draw(context, gl.TRIANGLES, DepthMode.disabled, // Tests will always pass, and ref value will be written to stencil buffer. new StencilMode({func: gl.ALWAYS, mask: 0}, id, 0xFF, gl.KEEP, gl.KEEP, gl.REPLACE), ColorMode.disabled, CullFaceMode.disabled, clippingMaskUniformValues(tileID.projMatrix), - '$clipping', this.tileExtentBuffer, - this.quadTriangleIndexBuffer, this.tileExtentSegments); + '$clipping', tileBoundsBuffer, + tileBoundsIndexBuffer, tileBoundsSegments); } } @@ -890,6 +905,22 @@ class Painter { return true; } + + getBackgroundTiles() { + const oldTiles = this._backgroundTiles; + const newTiles = this._backgroundTiles = {}; + + const tileSize = 512; + const tileIDs = this.transform.coveringTiles({tileSize}); + for (const tileID of tileIDs) { + newTiles[tileID.key] = oldTiles[tileID.key] || new Tile(tileID, tileSize, this.transform.tileZoom, this); + } + return newTiles; + } + + clearBackgroundTiles() { + this._backgroundTiles = {}; + } } export default Painter; diff --git a/src/render/program/circle_program.js b/src/render/program/circle_program.js index 2e07158f3e9..552ac9cc60d 100644 --- a/src/render/program/circle_program.js +++ b/src/render/program/circle_program.js @@ -2,10 +2,9 @@ import { Uniform1f, - Uniform2f, + UniformMatrix2f, UniformMatrix4f } from '../uniform_binding.js'; -import pixelsToTileUnits from '../../source/pixels_to_tile_units.js'; import type Context from '../../gl/context.js'; import type {UniformValues, UniformLocations} from '../uniform_binding.js'; @@ -17,7 +16,7 @@ import browser from '../../util/browser.js'; export type CircleUniformsType = {| 'u_camera_to_center_distance': Uniform1f, - 'u_extrude_scale': Uniform2f, + 'u_extrude_scale': UniformMatrix2f, 'u_device_pixel_ratio': Uniform1f, 'u_matrix': UniformMatrix4f |}; @@ -26,7 +25,7 @@ export type CircleDefinesType = 'PITCH_WITH_MAP' | 'SCALE_WITH_MAP'; const circleUniforms = (context: Context, locations: UniformLocations): CircleUniformsType => ({ 'u_camera_to_center_distance': new Uniform1f(context, locations.u_camera_to_center_distance), - 'u_extrude_scale': new Uniform2f(context, locations.u_extrude_scale), + 'u_extrude_scale': new UniformMatrix2f(context, locations.u_extrude_scale), 'u_device_pixel_ratio': new Uniform1f(context, locations.u_device_pixel_ratio), 'u_matrix': new UniformMatrix4f(context, locations.u_matrix) }); @@ -39,12 +38,15 @@ const circleUniformValues = ( ): UniformValues => { const transform = painter.transform; - let extrudeScale: [number, number]; + let extrudeScale; if (layer.paint.get('circle-pitch-alignment') === 'map') { - const pixelRatio = pixelsToTileUnits(tile, 1, transform.zoom); - extrudeScale = [pixelRatio, pixelRatio]; + extrudeScale = transform.calculatePixelsToTileUnitsMatrix(tile); } else { - extrudeScale = transform.pixelsToGLUnits; + extrudeScale = new Float32Array([ + transform.pixelsToGLUnits[0], + 0, + 0, + transform.pixelsToGLUnits[1]]); } return { diff --git a/src/render/program/line_program.js b/src/render/program/line_program.js index 804e2b1b672..685bbe524c4 100644 --- a/src/render/program/line_program.js +++ b/src/render/program/line_program.js @@ -5,6 +5,7 @@ import { Uniform1f, Uniform2f, Uniform3f, + UniformMatrix2f, UniformMatrix4f } from '../uniform_binding.js'; import pixelsToTileUnits from '../../source/pixels_to_tile_units.js'; @@ -20,7 +21,7 @@ import type {CrossfadeParameters} from '../../style/evaluation_parameters.js'; export type LineUniformsType = {| 'u_matrix': UniformMatrix4f, - 'u_ratio': Uniform1f, + 'u_pixels_to_tile_units': UniformMatrix2f, 'u_device_pixel_ratio': Uniform1f, 'u_units_to_pixels': Uniform2f, 'u_dash_image': Uniform1i, @@ -34,7 +35,7 @@ export type LineUniformsType = {| export type LinePatternUniformsType = {| 'u_matrix': UniformMatrix4f, 'u_texsize': Uniform2f, - 'u_ratio': Uniform1f, + 'u_pixels_to_tile_units': UniformMatrix2f, 'u_device_pixel_ratio': Uniform1f, 'u_units_to_pixels': Uniform2f, 'u_image': Uniform1i, @@ -46,7 +47,7 @@ export type LineDefinesType = 'RENDER_LINE_GRADIENT' | 'RENDER_LINE_DASH'; const lineUniforms = (context: Context, locations: UniformLocations): LineUniformsType => ({ 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), - 'u_ratio': new Uniform1f(context, locations.u_ratio), + 'u_pixels_to_tile_units': new UniformMatrix2f(context, locations.u_pixels_to_tile_units), 'u_device_pixel_ratio': new Uniform1f(context, locations.u_device_pixel_ratio), 'u_units_to_pixels': new Uniform2f(context, locations.u_units_to_pixels), 'u_dash_image': new Uniform1i(context, locations.u_dash_image), @@ -60,7 +61,7 @@ const lineUniforms = (context: Context, locations: UniformLocations): LineUnifor const linePatternUniforms = (context: Context, locations: UniformLocations): LinePatternUniformsType => ({ 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), 'u_texsize': new Uniform2f(context, locations.u_texsize), - 'u_ratio': new Uniform1f(context, locations.u_ratio), + 'u_pixels_to_tile_units': new UniformMatrix2f(context, locations.u_pixels_to_tile_units), 'u_device_pixel_ratio': new Uniform1f(context, locations.u_device_pixel_ratio), 'u_image': new Uniform1i(context, locations.u_image), 'u_units_to_pixels': new Uniform2f(context, locations.u_units_to_pixels), @@ -77,9 +78,11 @@ const lineUniformValues = ( imageHeight: number ): UniformValues => { const transform = painter.transform; + const pixelsToTileUnits = transform.calculatePixelsToTileUnitsMatrix(tile); + const values = { 'u_matrix': calculateMatrix(painter, tile, layer, matrix), - 'u_ratio': 1 / pixelsToTileUnits(tile, 1, transform.zoom), + 'u_pixels_to_tile_units': pixelsToTileUnits, 'u_device_pixel_ratio': browser.devicePixelRatio, 'u_units_to_pixels': [ 1 / transform.pixelsToGLUnits[0], @@ -114,7 +117,7 @@ const linePatternUniformValues = ( 'u_matrix': calculateMatrix(painter, tile, layer, matrix), 'u_texsize': tile.imageAtlasTexture.size, // camera zoom ratio - 'u_ratio': 1 / pixelsToTileUnits(tile, 1, transform.zoom), + 'u_pixels_to_tile_units': transform.calculatePixelsToTileUnitsMatrix(tile), 'u_device_pixel_ratio': browser.devicePixelRatio, 'u_image': 0, 'u_scale': [tileZoomRatio, crossfade.fromScale, crossfade.toScale], diff --git a/src/render/uniform_binding.js b/src/render/uniform_binding.js index 86ea382d792..8fb623ca890 100644 --- a/src/render/uniform_binding.js +++ b/src/render/uniform_binding.js @@ -151,6 +151,24 @@ class UniformMatrix3f extends Uniform { } } +const emptyMat2 = new Float32Array(4); +class UniformMatrix2f extends Uniform { + constructor(context: Context, location: WebGLUniformLocation) { + super(context, location); + this.current = emptyMat2; + } + + set(v: Float32Array): void { + for (let i = 0; i < 4; i++) { + if (v[i] !== this.current[i]) { + this.current = v; + this.gl.uniformMatrix2fv(this.location, false, v); + break; + } + } + } +} + export { Uniform, Uniform1i, @@ -159,6 +177,7 @@ export { Uniform3f, Uniform4f, UniformColor, + UniformMatrix2f, UniformMatrix3f, UniformMatrix4f }; diff --git a/src/shaders/circle.vertex.glsl b/src/shaders/circle.vertex.glsl index f9a1942276a..171b964a54e 100644 --- a/src/shaders/circle.vertex.glsl +++ b/src/shaders/circle.vertex.glsl @@ -5,7 +5,7 @@ #define NUM_SAMPLES_PER_RING 16 uniform mat4 u_matrix; -uniform vec2 u_extrude_scale; +uniform mat2 u_extrude_scale; uniform lowp float u_device_pixel_ratio; uniform highp float u_camera_to_center_distance; diff --git a/src/shaders/line.vertex.glsl b/src/shaders/line.vertex.glsl index 06287c1fd2a..14977f808f6 100644 --- a/src/shaders/line.vertex.glsl +++ b/src/shaders/line.vertex.glsl @@ -18,7 +18,7 @@ attribute float a_linesofar; #endif uniform mat4 u_matrix; -uniform mediump float u_ratio; +uniform mat2 u_pixels_to_tile_units; uniform vec2 u_units_to_pixels; uniform lowp float u_device_pixel_ratio; @@ -95,8 +95,8 @@ void main() { mediump float t = 1.0 - abs(u); mediump vec2 offset2 = offset * a_extrude * EXTRUDE_SCALE * normal.y * mat2(t, -u, u, t); - vec4 projected_extrude = u_matrix * vec4(dist / u_ratio, 0.0, 0.0); - gl_Position = u_matrix * vec4(pos + offset2 / u_ratio, 0.0, 1.0) + projected_extrude; + vec4 projected_extrude = u_matrix * vec4(dist * u_pixels_to_tile_units, 0.0, 0.0); + gl_Position = u_matrix * vec4(pos + offset2 * u_pixels_to_tile_units, 0.0, 1.0) + projected_extrude; #ifndef RENDER_TO_TEXTURE // calculate how much the perspective view squishes or stretches the extrude diff --git a/src/shaders/line_pattern.vertex.glsl b/src/shaders/line_pattern.vertex.glsl index 89994fb972b..dcf94d5afdf 100644 --- a/src/shaders/line_pattern.vertex.glsl +++ b/src/shaders/line_pattern.vertex.glsl @@ -12,7 +12,7 @@ attribute float a_linesofar; uniform mat4 u_matrix; uniform vec2 u_units_to_pixels; -uniform mediump float u_ratio; +uniform mat2 u_pixels_to_tile_units; uniform lowp float u_device_pixel_ratio; varying vec2 v_normal; @@ -82,8 +82,8 @@ void main() { mediump float t = 1.0 - abs(u); mediump vec2 offset2 = offset * a_extrude * scale * normal.y * mat2(t, -u, u, t); - vec4 projected_extrude = u_matrix * vec4(dist / u_ratio, 0.0, 0.0); - gl_Position = u_matrix * vec4(pos + offset2 / u_ratio, 0.0, 1.0) + projected_extrude; + vec4 projected_extrude = u_matrix * vec4(dist * u_pixels_to_tile_units, 0.0, 0.0); + gl_Position = u_matrix * vec4(pos + offset2 * u_pixels_to_tile_units, 0.0, 1.0) + projected_extrude; #ifndef RENDER_TO_TEXTURE // calculate how much the perspective view squishes or stretches the extrude diff --git a/src/source/canvas_source.js b/src/source/canvas_source.js index 49f11c3dae6..7f5dc9ecda2 100644 --- a/src/source/canvas_source.js +++ b/src/source/canvas_source.js @@ -3,7 +3,7 @@ import ImageSource from './image_source.js'; import window from '../util/window.js'; -import rasterBoundsAttributes from '../data/raster_bounds_attributes.js'; +import boundsAttributes from '../data/bounds_attributes.js'; import SegmentVector from '../data/segment.js'; import Texture from '../render/texture.js'; import {ErrorEvent} from '../util/evented.js'; @@ -211,8 +211,12 @@ class CanvasSource extends ImageSource { const context = this.map.painter.context; const gl = context.gl; + if (!this._boundsArray) { + this._makeBoundsArray(); + } + if (!this.boundsBuffer) { - this.boundsBuffer = context.createVertexBuffer(this._boundsArray, rasterBoundsAttributes.members); + this.boundsBuffer = context.createVertexBuffer(this._boundsArray, boundsAttributes.members); } if (!this.boundsSegments) { diff --git a/src/source/image_source.js b/src/source/image_source.js index d76737bcc0c..1ebf0dcd230 100644 --- a/src/source/image_source.js +++ b/src/source/image_source.js @@ -5,11 +5,12 @@ import {Event, ErrorEvent, Evented} from '../util/evented.js'; import {getImage, ResourceType} from '../util/ajax.js'; import EXTENT from '../data/extent.js'; import {RasterBoundsArray} from '../data/array_types.js'; -import rasterBoundsAttributes from '../data/raster_bounds_attributes.js'; +import boundsAttributes from '../data/bounds_attributes.js'; import SegmentVector from '../data/segment.js'; import Texture from '../render/texture.js'; import MercatorCoordinate from '../geo/mercator_coordinate.js'; import browser from '../util/browser.js'; +import tileTransform, {getTilePoint} from '../geo/projection/tile_transform.js'; import type {Source} from './source.js'; import type {CanvasSourceSpecification} from './canvas_source.js'; @@ -219,6 +220,7 @@ class ImageSource extends Evented implements Source { */ setCoordinates(coordinates: Coordinates) { this.coordinates = coordinates; + delete this._boundsArray; // Calculate which mercator tile is suitable for rendering the video in // and create a buffer with the corner coordinates. These coordinates @@ -236,9 +238,19 @@ class ImageSource extends Evented implements Source { // level) this.minzoom = this.maxzoom = this.tileID.z; + this.fire(new Event('data', {dataType:'source', sourceDataType: 'content'})); + return this; + } + + _makeBoundsArray() { + const tileTr = tileTransform(this.tileID, this.map.transform.projection); + // Transform the corner coordinates into the coordinate space of our // tile. - const tileCoords = cornerCoords.map((coord) => this.tileID.getTilePoint(coord)._round()); + const tileCoords = this.coordinates.map((coord) => { + const projectedCoord = tileTr.projection.project(coord[0], coord[1]); + return getTilePoint(tileTr, projectedCoord)._round(); + }); this._boundsArray = new RasterBoundsArray(); this._boundsArray.emplaceBack(tileCoords[0].x, tileCoords[0].y, 0, 0); @@ -251,7 +263,6 @@ class ImageSource extends Evented implements Source { delete this.boundsBuffer; } - this.fire(new Event('data', {dataType:'source', sourceDataType: 'content'})); return this; } @@ -263,8 +274,12 @@ class ImageSource extends Evented implements Source { const context = this.map.painter.context; const gl = context.gl; + if (!this._boundsArray) { + this._makeBoundsArray(); + } + if (!this.boundsBuffer) { - this.boundsBuffer = context.createVertexBuffer(this._boundsArray, rasterBoundsAttributes.members); + this.boundsBuffer = context.createVertexBuffer(this._boundsArray, boundsAttributes.members); } if (!this.boundsSegments) { diff --git a/src/source/pixels_to_tile_units.js b/src/source/pixels_to_tile_units.js index 33fd3bfa8eb..35d863e93e3 100644 --- a/src/source/pixels_to_tile_units.js +++ b/src/source/pixels_to_tile_units.js @@ -1,8 +1,12 @@ // @flow +import {mat2} from 'gl-matrix'; + import EXTENT from '../data/extent.js'; import type {OverscaledTileID} from './tile_id.js'; +import type Transform from '../geo/transform.js'; +import type {TileTransform} from '../geo/projection/tile_transform.js'; /** * Converts a pixel value at a the given zoom level to tile units. @@ -19,3 +23,9 @@ import type {OverscaledTileID} from './tile_id.js'; export default function(tile: {tileID: OverscaledTileID, tileSize: number}, pixelValue: number, z: number): number { return pixelValue * (EXTENT / (tile.tileSize * Math.pow(2, z - tile.tileID.overscaledZ))); } + +export function getPixelsToTileUnitsMatrix(tile: {tileID: OverscaledTileID, tileSize: number, tileTransform: TileTransform}, transform: Transform): Float32Array { + const {scale} = tile.tileTransform; + const s = scale * EXTENT / (tile.tileSize * Math.pow(2, transform.zoom - tile.tileID.overscaledZ + tile.tileID.canonical.z)); + return mat2.scale(new Float32Array(4), transform.inverseAdjustmentMatrix, [s, s]); +} diff --git a/src/source/source_cache.js b/src/source/source_cache.js index 730a5f98beb..a9c47bdf5a7 100644 --- a/src/source/source_cache.js +++ b/src/source/source_cache.js @@ -415,7 +415,7 @@ class SourceCache extends Evented { handleWrapJump(lng: number) { // On top of the regular z/x/y values, TileIDs have a `wrap` value that specify - // which cppy of the world the tile belongs to. For example, at `lng: 10` you + // which copy of the world the tile belongs to. For example, at `lng: 10` you // might render z/x/y/0 while at `lng: 370` you would render z/x/y/1. // // When lng values get wrapped (going from `lng: 370` to `long: 10`) you expect @@ -595,7 +595,7 @@ class SourceCache extends Evented { if (idealTileIDs.length === 0) { return retain; } const checked: {[_: number | string]: boolean } = {}; - const minZoom = idealTileIDs[idealTileIDs.length - 1].overscaledZ; + const minZoom = idealTileIDs.reduce((min, id) => Math.min(min, id.overscaledZ), Infinity); const maxZoom = idealTileIDs[0].overscaledZ; assert(minZoom <= maxZoom); const minCoveringZoom = Math.max(maxZoom - SourceCache.maxOverzooming, this._source.minzoom); @@ -737,7 +737,8 @@ class SourceCache extends Evented { const cached = Boolean(tile); if (!cached) { - tile = new Tile(tileID, this._source.tileSize * tileID.overscaleFactor(), this.transform.tileZoom); + const painter = this.map ? this.map.painter : null; + tile = new Tile(tileID, this._source.tileSize * tileID.overscaleFactor(), this.transform.tileZoom, painter, this._source.type === 'raster'); this._loadTile(tile, this._tileLoaded.bind(this, tile, tileID.key, tile.state)); } diff --git a/src/source/tile.js b/src/source/tile.js index e72b38db13d..00a13ebee35 100644 --- a/src/source/tile.js +++ b/src/source/tile.js @@ -6,7 +6,7 @@ import FeatureIndex from '../data/feature_index.js'; import GeoJSONFeature from '../util/vectortile_to_geojson.js'; import featureFilter from '../style-spec/feature_filter/index.js'; import SymbolBucket from '../data/bucket/symbol_bucket.js'; -import {CollisionBoxArray} from '../data/array_types.js'; +import {CollisionBoxArray, TileBoundsArray, PosArray, TriangleIndexArray, LineStripIndexArray} from '../data/array_types.js'; import Texture from '../render/texture.js'; import browser from '../util/browser.js'; import {Debug} from '../util/debug.js'; @@ -16,6 +16,15 @@ import SourceFeatureState from '../source/source_state.js'; import {lazyLoadRTLTextPlugin} from './rtl_text_plugin.js'; import {TileSpaceDebugBuffer} from '../data/debug_viz.js'; import Color from '../style-spec/util/color.js'; +import loadGeometry from '../data/load_geometry.js'; +import earcut from 'earcut'; +import getTileMesh from './tile_mesh.js'; +import tileTransform from '../geo/projection/tile_transform.js'; + +import boundsAttributes from '../data/bounds_attributes.js'; +import EXTENT from '../data/extent.js'; +import Point from '@mapbox/point-geometry'; +import SegmentVector from '../data/segment.js'; const CLOCK_SKEW_RETRY_TIMEOUT = 30000; @@ -36,6 +45,10 @@ import type {LayerFeatureStates} from './source_state.js'; import type {Cancelable} from '../types/cancelable.js'; import type {FilterSpecification} from '../style-spec/types.js'; import type {TilespaceQueryGeometry} from '../style/query_geometry.js'; +import type VertexBuffer from '../gl/vertex_buffer.js'; +import type IndexBuffer from '../gl/index_buffer.js'; +import type {Projection} from '../geo/projection/index.js'; +import type {TileTransform} from '../geo/projection/tile_transform.js'; import type Painter from '../render/painter.js'; export type TileState = @@ -47,6 +60,20 @@ export type TileState = | 'expired'; /* Tile data was previously loaded, but has expired per its * HTTP headers and is in the process of refreshing. */ +// a tile bounds outline used for getting reprojected tile geometry in non-mercator projections +const BOUNDS_FEATURE = (() => { + const c0 = new Point(0, 0); + const c1 = new Point(EXTENT + 1, 0); + const c2 = new Point(EXTENT + 1, EXTENT + 1); + const c3 = new Point(0, EXTENT + 1); + const coords = [[c0, c1, c2, c3, c0]]; + return { + type: 2, + extent: EXTENT, + loadGeometry() { return coords.slice(); } + }; +})(); + /** * A tile object is the combination of a Coordinate, which defines * its place, as well as a unique ID and data tracking for its content @@ -80,6 +107,8 @@ class Tile { actor: ?Actor; vtLayers: {[_: string]: VectorTileLayer}; isSymbolTile: ?boolean; + isRaster: ?boolean; + tileTransform: TileTransform; neighboringTiles: ?Object; dem: ?DEMData; @@ -102,12 +131,20 @@ class Tile { queryGeometryDebugViz: TileSpaceDebugBuffer; queryBoundsDebugViz: TileSpaceDebugBuffer; + + _tileDebugBuffer: ?VertexBuffer; + _tileBoundsBuffer: ?VertexBuffer; + _tileDebugIndexBuffer: IndexBuffer; + _tileBoundsIndexBuffer: IndexBuffer; + _tileDebugSegments: SegmentVector; + _tileBoundsSegments: SegmentVector; + /** * @param {OverscaledTileID} tileID * @param size * @private */ - constructor(tileID: OverscaledTileID, size: number, tileZoom: number) { + constructor(tileID: OverscaledTileID, size: number, tileZoom: number, painter: any, isRaster?: boolean) { this.tileID = tileID; this.uid = uniqueId(); this.uses = 0; @@ -119,6 +156,7 @@ class Tile { this.hasSymbolBuckets = false; this.hasRTLText = false; this.dependencies = {}; + this.isRaster = isRaster; // Counts the number of times a response was already expired when // received. We're using this to add a delay when making a new request @@ -127,6 +165,14 @@ class Tile { this.expiredRequestCount = 0; this.state = 'loading'; + + if (painter) { + const {projection} = painter.transform; + this.tileTransform = tileTransform(tileID.canonical, projection); + if (painter.context) { + this._makeTileBoundsBuffers(painter.context, projection); + } + } } registerFadeDuration(duration: number) { @@ -255,6 +301,20 @@ class Tile { this.lineAtlasTexture.destroy(); } + if (this._tileBoundsBuffer) { + this._tileBoundsBuffer.destroy(); + this._tileBoundsIndexBuffer.destroy(); + this._tileBoundsSegments.destroy(); + this._tileBoundsBuffer = null; + } + + if (this._tileDebugBuffer) { + this._tileDebugBuffer.destroy(); + this._tileDebugIndexBuffer.destroy(); + this._tileDebugSegments.destroy(); + this._tileDebugBuffer = null; + } + Debug.run(() => { if (this.queryGeometryDebugViz) { this.queryGeometryDebugViz.unload(); @@ -335,7 +395,8 @@ class Tile { tileResult, pixelPosMatrix, transform, - params + params, + tileTransform: this.tileTransform }, layers, serializedLayers, sourceFeatureState); } @@ -514,6 +575,60 @@ class Tile { } }); } + + _makeDebugTileBoundsBuffers(context: Context, projection: Projection) { + if (!projection || projection.name === 'mercator' || this._tileDebugBuffer) return; + + // reproject tile outline with adaptive resampling + const boundsLine = loadGeometry(BOUNDS_FEATURE, this.tileID.canonical, this.tileTransform)[0]; + + // generate vertices for debugging tile boundaries + const debugVertices = new PosArray(); + const debugIndices = new LineStripIndexArray(); + + for (let i = 0; i < boundsLine.length; i++) { + const {x, y} = boundsLine[i]; + debugVertices.emplaceBack(x, y); + debugIndices.emplaceBack(i); + } + debugIndices.emplaceBack(0); + + this._tileDebugIndexBuffer = context.createIndexBuffer(debugIndices); + this._tileDebugBuffer = context.createVertexBuffer(debugVertices, boundsAttributes.members); + this._tileDebugSegments = SegmentVector.simpleSegment(0, 0, debugVertices.length, debugIndices.length); + } + + _makeTileBoundsBuffers(context: Context, projection: Projection) { + if (this._tileBoundsBuffer || !projection || projection.name === 'mercator') return; + + // reproject tile outline with adaptive resampling + const boundsLine = loadGeometry(BOUNDS_FEATURE, this.tileID.canonical, this.tileTransform)[0]; + + let boundsVertices, boundsIndices; + if (this.isRaster) { + // for raster tiles, generate an adaptive MARTINI mesh + const mesh = getTileMesh(this.tileID.canonical, projection); + boundsVertices = mesh.vertices; + boundsIndices = mesh.indices; + + } else { + // for vector tiles, generate an Earcut triangulation of the outline + boundsVertices = new TileBoundsArray(); + boundsIndices = new TriangleIndexArray(); + + for (const {x, y} of boundsLine) { + boundsVertices.emplaceBack(x, y, 0, 0); + } + const indices = earcut(boundsVertices.int16, undefined, 4); + for (let i = 0; i < indices.length; i += 3) { + boundsIndices.emplaceBack(indices[i], indices[i + 1], indices[i + 2]); + } + } + + this._tileBoundsBuffer = context.createVertexBuffer(boundsVertices, boundsAttributes.members); + this._tileBoundsIndexBuffer = context.createIndexBuffer(boundsIndices); + this._tileBoundsSegments = SegmentVector.simpleSegment(0, 0, boundsVertices.length, boundsIndices.length); + } } export default Tile; diff --git a/src/source/tile_id.js b/src/source/tile_id.js index bae57ca5cf0..69e7b690cb0 100644 --- a/src/source/tile_id.js +++ b/src/source/tile_id.js @@ -1,13 +1,9 @@ // @flow import {getTileBBox} from '@mapbox/whoots-js'; -import EXTENT from '../data/extent.js'; -import Point from '@mapbox/point-geometry'; -import MercatorCoordinate, {altitudeFromMercatorZ} from '../geo/mercator_coordinate.js'; import {MAX_SAFE_INTEGER} from '../util/util.js'; import assert from 'assert'; import {register} from '../util/web_worker_transfer.js'; -import {vec3} from 'gl-matrix'; export class CanonicalTileID { z: number; @@ -43,20 +39,6 @@ export class CanonicalTileID { .replace('{bbox-epsg-3857}', bbox); } - getTilePoint(coord: MercatorCoordinate) { - const tilesAtZoom = Math.pow(2, this.z); - return new Point( - (coord.x * tilesAtZoom - this.x) * EXTENT, - (coord.y * tilesAtZoom - this.y) * EXTENT); - } - - getTileVec3(coord: MercatorCoordinate): vec3 { - const tilesAtZoom = Math.pow(2, this.z); - const x = (coord.x * tilesAtZoom - this.x) * EXTENT; - const y = (coord.y * tilesAtZoom - this.y) * EXTENT; - return vec3.fromValues(x, y, altitudeFromMercatorZ(coord.z, coord.y)); - } - toString() { return `${this.z}/${this.x}/${this.y}`; } @@ -181,14 +163,6 @@ export class OverscaledTileID { toString() { return `${this.overscaledZ}/${this.canonical.x}/${this.canonical.y}`; } - - getTilePoint(coord: MercatorCoordinate) { - return this.canonical.getTilePoint(new MercatorCoordinate(coord.x - this.wrap, coord.y)); - } - - getTileVec3(coord: MercatorCoordinate) { - return this.canonical.getTileVec3(new MercatorCoordinate(coord.x - this.wrap, coord.y, coord.z)); - } } function calculateKey(wrap: number, overscaledZ: number, z: number, x: number, y: number): number { diff --git a/src/source/tile_mesh.js b/src/source/tile_mesh.js new file mode 100644 index 00000000000..eefbf5cac9f --- /dev/null +++ b/src/source/tile_mesh.js @@ -0,0 +1,162 @@ +// @flow +// logic for generating non-Mercator adaptive raster tile reprojection meshes with MARTINI + +import tileTransform from '../geo/projection/tile_transform.js'; +import EXTENT from '../data/extent.js'; +import {lngFromMercatorX, latFromMercatorY} from '../geo/mercator_coordinate.js'; +import {TileBoundsArray, TriangleIndexArray} from '../data/array_types.js'; + +import type {CanonicalTileID} from './tile_id.js'; +import type {Projection} from '../geo/projection/index.js'; + +const meshSize = 32; +const gridSize = meshSize + 1; + +const numTriangles = meshSize * meshSize * 2 - 2; +const numParentTriangles = numTriangles - meshSize * meshSize; + +const coords = new Uint16Array(numTriangles * 4); + +// precalculate RTIN triangle coordinates +for (let i = 0; i < numTriangles; i++) { + let id = i + 2; + let ax = 0, ay = 0, bx = 0, by = 0, cx = 0, cy = 0; + + if (id & 1) { + bx = by = cx = meshSize; // bottom-left triangle + + } else { + ax = ay = cy = meshSize; // top-right triangle + } + + while ((id >>= 1) > 1) { + const mx = (ax + bx) >> 1; + const my = (ay + by) >> 1; + + if (id & 1) { // left half + bx = ax; by = ay; + ax = cx; ay = cy; + + } else { // right half + ax = bx; ay = by; + bx = cx; by = cy; + } + + cx = mx; cy = my; + } + + const k = i * 4; + coords[k + 0] = ax; + coords[k + 1] = ay; + coords[k + 2] = bx; + coords[k + 3] = by; +} + +// temporary arrays we'll reuse for MARTINI mesh code +const reprojectedCoords = new Uint16Array(gridSize * gridSize * 2); +const used = new Uint8Array(gridSize * gridSize); +const indexMap = new Uint16Array(gridSize * gridSize); + +type TileMesh = { + vertices: TileBoundsArray, + indices: TriangleIndexArray +}; + +export default function getTileMesh(canonical: CanonicalTileID, projection: Projection): TileMesh { + const cs = tileTransform(canonical, projection); + const z2 = Math.pow(2, canonical.z); + + for (let y = 0; y < gridSize; y++) { + for (let x = 0; x < gridSize; x++) { + const lng = lngFromMercatorX((canonical.x + x / meshSize) / z2); + const lat = latFromMercatorY((canonical.y + y / meshSize) / z2); + const p = projection.project(lng, lat); + const k = y * gridSize + x; + reprojectedCoords[2 * k + 0] = Math.round((p.x * cs.scale - cs.x) * EXTENT); + reprojectedCoords[2 * k + 1] = Math.round((p.y * cs.scale - cs.y) * EXTENT); + } + } + + used.fill(0); + indexMap.fill(0); + + // iterate over all possible triangles, starting from the smallest level + for (let i = numTriangles - 1; i >= 0; i--) { + const k = i * 4; + const ax = coords[k + 0]; + const ay = coords[k + 1]; + const bx = coords[k + 2]; + const by = coords[k + 3]; + const mx = (ax + bx) >> 1; + const my = (ay + by) >> 1; + const cx = mx + my - ay; + const cy = my + ax - mx; + + const aIndex = ay * gridSize + ax; + const bIndex = by * gridSize + bx; + const mIndex = my * gridSize + mx; + + // calculate error in the middle of the long edge of the triangle + const rax = reprojectedCoords[2 * aIndex + 0]; + const ray = reprojectedCoords[2 * aIndex + 1]; + const rbx = reprojectedCoords[2 * bIndex + 0]; + const rby = reprojectedCoords[2 * bIndex + 1]; + const rmx = reprojectedCoords[2 * mIndex + 0]; + const rmy = reprojectedCoords[2 * mIndex + 1]; + + // raster tiles are typically 512px, and we use 1px as an error threshold; 8192 / 512 = 16 + const isUsed = Math.hypot((rax + rbx) / 2 - rmx, (ray + rby) / 2 - rmy) >= 16; + + used[mIndex] = used[mIndex] || (isUsed ? 1 : 0); + + if (i < numParentTriangles) { // bigger triangles; accumulate error with children + const leftChildIndex = ((ay + cy) >> 1) * gridSize + ((ax + cx) >> 1); + const rightChildIndex = ((by + cy) >> 1) * gridSize + ((bx + cx) >> 1); + used[mIndex] = used[mIndex] || used[leftChildIndex] || used[rightChildIndex]; + } + } + + const vertices = new TileBoundsArray(); + const indices = new TriangleIndexArray(); + + let numVertices = 0; + + function addVertex(x, y) { + const k = y * gridSize + x; + + if (indexMap[k] === 0) { + vertices.emplaceBack( + reprojectedCoords[2 * k + 0], + reprojectedCoords[2 * k + 1], + x * EXTENT / meshSize, + y * EXTENT / meshSize); + + // save new vertex index so that we can reuse it + indexMap[k] = ++numVertices; + } + + return indexMap[k] - 1; + } + + function addTriangles(ax, ay, bx, by, cx, cy) { + const mx = (ax + bx) >> 1; + const my = (ay + by) >> 1; + + if (Math.abs(ax - cx) + Math.abs(ay - cy) > 1 && used[my * gridSize + mx]) { + // triangle doesn't approximate the surface well enough; drill down further + addTriangles(cx, cy, ax, ay, mx, my); + addTriangles(bx, by, cx, cy, mx, my); + + } else { + const ai = addVertex(ax, ay); + const bi = addVertex(bx, by); + const ci = addVertex(cx, cy); + indices.emplaceBack(ai, bi, ci); + } + } + + addTriangles(0, 0, meshSize, meshSize, meshSize, 0); + addTriangles(meshSize, meshSize, 0, 0, 0, meshSize); + + return {vertices, indices}; +} diff --git a/src/source/video_source.js b/src/source/video_source.js index 3bf4e04807e..0ed5cc59991 100644 --- a/src/source/video_source.js +++ b/src/source/video_source.js @@ -3,7 +3,7 @@ import {getVideo, ResourceType} from '../util/ajax.js'; import ImageSource from './image_source.js'; -import rasterBoundsAttributes from '../data/raster_bounds_attributes.js'; +import boundsAttributes from '../data/bounds_attributes.js'; import SegmentVector from '../data/segment.js'; import Texture from '../render/texture.js'; import {ErrorEvent} from '../util/evented.js'; @@ -208,8 +208,12 @@ class VideoSource extends ImageSource { const context = this.map.painter.context; const gl = context.gl; + if (!this._boundsArray) { + this._makeBoundsArray(); + } + if (!this.boundsBuffer) { - this.boundsBuffer = context.createVertexBuffer(this._boundsArray, rasterBoundsAttributes.members); + this.boundsBuffer = context.createVertexBuffer(this._boundsArray, boundsAttributes.members); } if (!this.boundsSegments) { diff --git a/src/source/worker.js b/src/source/worker.js index f78f6bd70c4..715fd101117 100644 --- a/src/source/worker.js +++ b/src/source/worker.js @@ -12,6 +12,7 @@ import {enforceCacheSizeLimit} from '../util/tile_request_cache.js'; import {extend} from '../util/util.js'; import {PerformanceUtils} from '../util/performance.js'; import {Event} from '../util/evented.js'; +import {getProjection} from '../geo/projection/index.js'; import type { WorkerSource, @@ -24,8 +25,9 @@ import type { import type {WorkerGlobalScopeInterface} from '../util/web_worker.js'; import type {Callback} from '../types/callback.js'; -import type {LayerSpecification} from '../style-spec/types.js'; +import type {LayerSpecification, ProjectionSpecification} from '../style-spec/types.js'; import type {PluginState} from './rtl_text_plugin.js'; +import type {Projection} from '../geo/projection/index.js'; /** * @private @@ -38,6 +40,8 @@ export default class Worker { workerSourceTypes: {[_: string]: Class }; workerSources: {[_: string]: {[_: string]: {[_: string]: WorkerSource } } }; demWorkerSources: {[_: string]: {[_: string]: RasterDEMTileWorkerSource } }; + projections: {[_: string]: Projection }; + defaultProjection: Projection; isSpriteLoaded: {[_: string]: boolean }; referrer: ?string; terrain: ?boolean; @@ -51,6 +55,9 @@ export default class Worker { this.availableImages = {}; this.isSpriteLoaded = {}; + this.projections = {}; + this.defaultProjection = getProjection({name: 'mercator'}); + this.workerSourceTypes = { vector: VectorTileWorkerSource, geojson: GeoJSONWorkerSource @@ -124,6 +131,10 @@ export default class Worker { callback(); } + setProjection(mapId: string, config: ProjectionSpecification) { + this.projections[mapId] = getProjection(config); + } + setLayers(mapId: string, layers: Array, callback: WorkerTileCallback) { this.getLayerIndex(mapId).replace(layers); callback(); @@ -137,6 +148,7 @@ export default class Worker { loadTile(mapId: string, params: WorkerTileParameters & {type: string}, callback: WorkerTileCallback) { assert(params.type); const p = this.enableTerrain ? extend({enableTerrain: this.terrain}, params) : params; + p.projection = this.projections[mapId] || this.defaultProjection; this.getWorkerSource(mapId, params.type, params.source).loadTile(p, callback); } @@ -148,6 +160,7 @@ export default class Worker { reloadTile(mapId: string, params: WorkerTileParameters & {type: string}, callback: WorkerTileCallback) { assert(params.type); const p = this.enableTerrain ? extend({enableTerrain: this.terrain}, params) : params; + p.projection = this.projections[mapId] || this.defaultProjection; this.getWorkerSource(mapId, params.type, params.source).reloadTile(p, callback); } diff --git a/src/source/worker_source.js b/src/source/worker_source.js index 781a181b627..4f0791be448 100644 --- a/src/source/worker_source.js +++ b/src/source/worker_source.js @@ -13,6 +13,7 @@ import type DEMData from '../data/dem_data.js'; import type {StyleGlyph} from '../style/style_glyph.js'; import type {StyleImage} from '../style/style_image.js'; import type {PromoteIdSpecification} from '../style-spec/types.js'; +import type {Projection} from '../geo/projection/index.js'; import window from '../util/window.js'; const {ImageBitmap} = window; @@ -38,7 +39,8 @@ export type WorkerTileParameters = RequestedTileParameters & { showCollisionBoxes: boolean, collectResourceTiming?: boolean, returnDependencies?: boolean, - enableTerrain?: boolean + enableTerrain?: boolean, + projection?: Projection }; export type WorkerDEMTileParameters = TileParameters & { diff --git a/src/source/worker_tile.js b/src/source/worker_tile.js index 948fa6b68bf..b1ca42a983c 100644 --- a/src/source/worker_tile.js +++ b/src/source/worker_tile.js @@ -15,8 +15,9 @@ import LineAtlas from '../render/line_atlas.js'; import ImageAtlas from '../render/image_atlas.js'; import GlyphAtlas from '../render/glyph_atlas.js'; import EvaluationParameters from '../style/evaluation_parameters.js'; -import {OverscaledTileID} from './tile_id.js'; +import {CanonicalTileID, OverscaledTileID} from './tile_id.js'; import {PerformanceUtils} from '../util/performance.js'; +import tileTransform from '../geo/projection/tile_transform.js'; import type {Bucket} from '../data/bucket.js'; import type Actor from '../util/actor.js'; @@ -29,12 +30,14 @@ import type { WorkerTileCallback, } from '../source/worker_source.js'; import type {PromoteIdSpecification} from '../style-spec/types.js'; +import type {TileTransform} from '../geo/projection/tile_transform.js'; class WorkerTile { tileID: OverscaledTileID; uid: number; zoom: number; tileZoom: number; + canonical: CanonicalTileID; pixelRatio: number; tileSize: number; source: string; @@ -45,6 +48,7 @@ class WorkerTile { returnDependencies: boolean; enableTerrain: boolean; isSymbolTile: ?boolean; + tileTransform: TileTransform; status: 'parsing' | 'done'; data: VectorTile; @@ -59,6 +63,7 @@ class WorkerTile { this.tileZoom = params.tileZoom; this.uid = params.uid; this.zoom = params.zoom; + this.canonical = params.tileID.canonical; this.pixelRatio = params.pixelRatio; this.tileSize = params.tileSize; this.source = params.source; @@ -69,6 +74,12 @@ class WorkerTile { this.promoteId = params.promoteId; this.enableTerrain = !!params.enableTerrain; this.isSymbolTile = params.isSymbolTile; + if (params.projection) { + this.tileTransform = tileTransform(params.tileID.canonical, params.projection); + } else { + // silence flow + assert(params.projection); + } } parse(data: VectorTile, layerIndex: StyleLayerIndex, availableImages: Array, actor: Actor, callback: WorkerTileCallback) { @@ -147,6 +158,7 @@ class WorkerTile { index: featureIndex.bucketLayerIDs.length, layers: family, zoom: this.zoom, + canonical: this.canonical, pixelRatio: this.pixelRatio, overscaling: this.overscaling, collisionBoxArray: this.collisionBoxArray, @@ -156,7 +168,7 @@ class WorkerTile { availableImages }); - bucket.populate(features, options, this.tileID.canonical); + bucket.populate(features, options, this.tileID.canonical, this.tileTransform); featureIndex.bucketLayerIDs.push(family.map((l) => l.id)); } } diff --git a/src/style-spec/diff.js b/src/style-spec/diff.js index 4baa19f7997..44dc24f449a 100644 --- a/src/style-spec/diff.js +++ b/src/style-spec/diff.js @@ -106,8 +106,12 @@ const operations = { /* * { command: 'setFog', args: [fogProperties] } */ - setFog: 'setFog' + setFog: 'setFog', + /* + * { command: 'setProjection', args: [projectionProperties] } + */ + setProjection: 'setProjection' }; function addSource(sourceId, after, commands) { @@ -363,6 +367,9 @@ function diffStyles(before, after) { if (!isEqual(before.fog, after.fog)) { commands.push({command: operations.setFog, args: [after.fog]}); } + if (!isEqual(before.projection, after.projection)) { + commands.push({command: operations.setProjection, args: [after.projection]}); + } // Handle changes to `sources` // If a source is to be removed, we also--before the removeSource diff --git a/src/style-spec/reference/v8.json b/src/style-spec/reference/v8.json index 2b036408c14..e25044a9bd5 100644 --- a/src/style-spec/reference/v8.json +++ b/src/style-spec/reference/v8.json @@ -94,6 +94,15 @@ "delay": 0 } }, + "projection": { + "type": "projection", + "doc": "The projection the map should be rendered in. Suported projections are Albers, Equal Earth, Equirectangular (WGS84), Globe, Lambert conformal conic, Mercator, Natural Earth, and Winkel Tripel. Terrain and fog are not supported for projections other than mercator.", + "example": { + "name": "albers", + "center": [-154, 50], + "parallels": [55, 65] + } + }, "layers": { "required": true, "type": "array", @@ -3958,6 +3967,92 @@ } } }, + "projection": { + "name": { + "type": "enum", + "values": { + "albers": { + "doc": "An Albers equal-area projection centered on the continental United States. You can configure the projection for a different region by setting `center` and `parallels` properties. You may want to set max bounds to constrain the map to the relevant region." + }, + "equalEarth": { + "doc": "An Equal Earth projection." + }, + "equirectangular": { + "doc": "An Equirectangular projection. This projection is very similar to the Plate Carrée projection." + }, + "lambertConformalConic": { + "doc": "A Lambert conformal conic projection. You can configure the projection for a region by setting `center` and `parallels` properties. You may want to set max bounds to constrain the map to the relevant region." + }, + "mercator": { + "doc": "The Mercator projection is the default projection." + }, + "naturalEarth": { + "doc": "A Natural Earth projection." + }, + "winkelTripel": { + "doc": "A Winkel Tripel projection." + } + }, + "default": "mercator", + "doc": "The name of the projection to be used for rendering the map.", + "required": true, + "sdk-support": { + "basic functionality": { + "js": "2.6.0" + } + } + }, + "center": { + "type": "array", + "length": 2, + "value": "number", + "property-type": "data-constant", + "transition": false, + "doc": "The reference longitude and latitude of the projection. `center` takes the form of [lng, lat]. This property is only configurable for conic projections (Albers and Lambert Conformal Conic). All other projections are centered on [0, 0].", + "example": [ + -96, + 37.5 + ], + "requires": [ + { + "name": [ + "albers", + "lambertConformalConic" + ] + } + ], + "sdk-support": { + "basic functionality": { + "js": "2.6.0" + } + } + }, + "parallels": { + "type": "array", + "length": 2, + "value": "number", + "property-type": "data-constant", + "transition": false, + "doc": "The standard parallels of the projection, denoting the desired latitude range with minimal distortion. `parallels` takes the form of [lat0, lat1]. This property is only configurable for conic projections (Albers and Lambert Conformal Conic).", + "example": [ + 29.5, + 45.5 + ], + "requires": [ + { + "name": [ + "albers", + "lambertConformalConic" + ] + } + ], + "sdk-support": { + "basic functionality": { + "js": "2.6.0" + } + } + } + }, "terrain" : { "source": { "type": "string", diff --git a/src/style-spec/types.js b/src/style-spec/types.js index 55118fdc198..892c0abcad8 100644 --- a/src/style-spec/types.js +++ b/src/style-spec/types.js @@ -72,6 +72,7 @@ export type StyleSpecification = {| "sprite"?: string, "glyphs"?: string, "transition"?: TransitionSpecification, + "projection"?: ProjectionSpecification, "layers": Array |} @@ -93,6 +94,12 @@ export type FogSpecification = {| "horizon-blend"?: PropertyValueSpecification |} +export type ProjectionSpecification = {| + "name": "albers" | "equalEarth" | "equirectangular" | "lambertConformalConic" | "mercator" | "naturalEarth" | "winkelTripel", + "center"?: [number, number], + "parallels"?: [number, number] +|} + export type VectorSourceSpecification = { "type": "vector", "url"?: string, diff --git a/src/style-spec/validate/validate.js b/src/style-spec/validate/validate.js index c810a7a6476..6aad0833572 100644 --- a/src/style-spec/validate/validate.js +++ b/src/style-spec/validate/validate.js @@ -22,6 +22,7 @@ import validateFog from './validate_fog.js'; import validateString from './validate_string.js'; import validateFormatted from './validate_formatted.js'; import validateImage from './validate_image.js'; +import validateProjection from './validate_projection.js'; const VALIDATORS = { '*'() { @@ -43,7 +44,8 @@ const VALIDATORS = { 'fog': validateFog, 'string': validateString, 'formatted': validateFormatted, - 'resolvedImage': validateImage + 'resolvedImage': validateImage, + 'projection': validateProjection }; // Main recursive validation function. Tracks: diff --git a/src/style-spec/validate/validate_projection.js b/src/style-spec/validate/validate_projection.js new file mode 100644 index 00000000000..dba6d53b329 --- /dev/null +++ b/src/style-spec/validate/validate_projection.js @@ -0,0 +1,30 @@ +import ValidationError from '../error/validation_error.js'; +import getType from '../util/get_type.js'; +import validate from './validate.js'; + +export default function validateProjection(options) { + const projection = options.value; + const styleSpec = options.styleSpec; + const projectionSpec = styleSpec.projection; + const style = options.style; + + let errors = []; + + const rootType = getType(projection); + + if (rootType === 'object') { + for (const key in projection) { + errors = errors.concat(validate({ + key, + value: projection[key], + valueSpec: projectionSpec[key], + style, + styleSpec + })); + } + } else if (rootType !== 'string') { + errors = errors.concat([new ValidationError('projection', projection, `object or string expected, ${rootType} found`)]); + } + + return errors; +} diff --git a/src/style/fog.js b/src/style/fog.js index 5cafafa1dc9..1fdd1747e63 100644 --- a/src/style/fog.js +++ b/src/style/fog.js @@ -34,11 +34,16 @@ class Fog extends Evented { _transitioning: Transitioning; properties: PossiblyEvaluated; + // Alternate projections do not yet support fog. + // Disable fog rendering until they do. + _disabledForProjections: boolean; + constructor(fogOptions?: FogSpecification) { super(); this._transitionable = new Transitionable(fogProperties); this.set(fogOptions); this._transitioning = this._transitionable.untransitioned(); + this._disabledForProjections = false; } get state(): FogState { @@ -69,6 +74,7 @@ class Fog extends Evented { } getOpacity(pitch: number): number { + if (this._disabledForProjections) return 0; const fogColor = (this.properties && this.properties.get('color')) || 1.0; const pitchFactor = smoothstep(FOG_PITCH_START, FOG_PITCH_END, pitch); return pitchFactor * fogColor.a; diff --git a/src/style/query_geometry.js b/src/style/query_geometry.js index 7cd1547a7c1..646dadf73da 100644 --- a/src/style/query_geometry.js +++ b/src/style/query_geometry.js @@ -12,6 +12,7 @@ import {vec3} from 'gl-matrix'; import {Ray} from '../util/primitives.js'; import MercatorCoordinate from '../geo/mercator_coordinate.js'; import type {OverscaledTileID} from '../source/tile_id.js'; +import {getTilePoint, getTileVec3} from '../geo/projection/tile_transform.js'; /** * A data-class that represents a screenspace query from `Map#queryRenderedFeatures`. @@ -179,15 +180,16 @@ export class QueryGeometry { // outside the query volume even if it looks like it overlaps visually, a 1px bias value overcomes that. const bias = 1; const padding = tile.queryPadding + bias; + const wrap = tile.tileID.wrap; const geometryForTileCheck = use3D ? - this._bufferedCameraMercator(padding, transform).map((p) => tile.tileID.getTilePoint(p)) : - this._bufferedScreenMercator(padding, transform).map((p) => tile.tileID.getTilePoint(p)); - const tilespaceVec3s = this.screenGeometryMercator.map((p) => tile.tileID.getTileVec3(p)); + this._bufferedCameraMercator(padding, transform).map((p) => getTilePoint(tile.tileTransform, p, wrap)) : + this._bufferedScreenMercator(padding, transform).map((p) => getTilePoint(tile.tileTransform, p, wrap)); + const tilespaceVec3s = this.screenGeometryMercator.map((p) => getTileVec3(tile.tileTransform, p, wrap)); const tilespaceGeometry = tilespaceVec3s.map((v) => new Point(v[0], v[1])); const cameraMercator = transform.getFreeCameraOptions().position || new MercatorCoordinate(0, 0, 0); - const tilespaceCameraPosition = tile.tileID.getTileVec3(cameraMercator); + const tilespaceCameraPosition = getTileVec3(tile.tileTransform, cameraMercator, wrap); const tilespaceRays = tilespaceVec3s.map((tileVec) => { const dir = vec3.sub(tileVec, tileVec, tilespaceCameraPosition); vec3.normalize(dir, dir); diff --git a/src/style/style.js b/src/style/style.js index ca5a4cb4e10..f5a23c3e604 100644 --- a/src/style/style.js +++ b/src/style/style.js @@ -66,7 +66,8 @@ import type { LightSpecification, SourceSpecification, TerrainSpecification, - FogSpecification + FogSpecification, + ProjectionSpecification } from '../style-spec/types.js'; import type {CustomLayerInterface} from './style_layer/custom_style_layer.js'; import type {Validator} from './validate_style.js'; @@ -86,7 +87,8 @@ const supportedDiffOperations = pick(diffOperations, [ 'setTransition', 'setGeoJSONSourceData', 'setTerrain', - 'setFog' + 'setFog', + 'setProjection' // 'setGlyphs', // 'setSprite', ]); @@ -95,7 +97,8 @@ const ignoredDiffOperations = pick(diffOperations, [ 'setCenter', 'setZoom', 'setBearing', - 'setPitch' + 'setPitch', + 'setProjection' ]); const empty = emptyStyle(); @@ -322,6 +325,11 @@ class Style extends Evented { this._serializedLayers[layer.id] = layer.serialize(); this._updateLayerCount(layer, true); } + + if (this.stylesheet.projection && this.map.transform._unmodifiedProjection) { + this.setProjection(this.stylesheet.projection); + } + this.dispatcher.broadcast('setLayers', this._serializeLayers(this._order)); this.light = new Light(this.stylesheet.light); @@ -337,6 +345,21 @@ class Style extends Evented { this.fire(new Event('style.load')); } + setProjection(projection?: ?ProjectionSpecification) { + this.map.painter.clearBackgroundTiles(); + for (const id in this._sourceCaches) { + this._sourceCaches[id].clearTiles(); + } + + this.map.transform.setProjection(projection); + this.dispatcher.broadcast('setProjection', this.map.transform.projectionOptions); + + const fog = this.fog; + if (fog) fog._disabledForProjections = Boolean(projection && projection.name !== 'mercator'); + + this.map._update(true); + } + _loadSprite(url: string) { this._spriteRequest = loadSprite(url, this.map._requestManager, (err, images) => { this._spriteRequest = null; @@ -1177,6 +1200,7 @@ class Style extends Evented { sprite: this.stylesheet.sprite, glyphs: this.stylesheet.glyphs, transition: this.stylesheet.transition, + projection: this.stylesheet.projection, sources, layers: this._serializeLayers(this._order) }, (value) => { return value !== undefined; }); diff --git a/src/symbol/placement.js b/src/symbol/placement.js index b6cdc645ee8..b1a4d70b7b8 100644 --- a/src/symbol/placement.js +++ b/src/symbol/placement.js @@ -9,7 +9,6 @@ import {getAnchorJustification, evaluateVariableOffset} from './symbol_layout.js import {getAnchorAlignment, WritingMode} from './shaping.js'; import {mat4} from 'gl-matrix'; import assert from 'assert'; -import pixelsToTileUnits from '../source/pixels_to_tile_units.js'; import Point from '@mapbox/point-geometry'; import type Transform from '../geo/transform.js'; import type StyleLayer from '../style/style_layer.js'; @@ -250,7 +249,7 @@ export class Placement { const dynamicFilter = styleLayer.dynamicFilter(); const dynamicFilterNeedsFeature = styleLayer.dynamicFilterNeedsFeature(); - const pixelsToTiles = pixelsToTileUnits(tile, 1, this.transform.zoom); + const pixelsToTiles = this.transform.calculatePixelsToTileUnitsMatrix(tile); const textLabelPlaneMatrix = projection.getLabelPlaneMatrix(posMatrix, pitchWithMap, diff --git a/src/symbol/projection.js b/src/symbol/projection.js index 7ae83849571..a61dde4422c 100644 --- a/src/symbol/projection.js +++ b/src/symbol/projection.js @@ -2,7 +2,7 @@ import Point from '@mapbox/point-geometry'; -import {mat4, vec3, vec4} from 'gl-matrix'; +import {mat2, mat4, vec3, vec4} from 'gl-matrix'; import * as symbolSize from './symbol_size.js'; import {addDynamicAttributes} from '../data/bucket/symbol_bucket.js'; @@ -78,10 +78,14 @@ function getLabelPlaneMatrix(posMatrix: mat4, pitchWithMap: boolean, rotateWithMap: boolean, transform: Transform, - pixelsToTileUnits: number) { + pixelsToTileUnits: Float32Array) { const m = mat4.create(); if (pitchWithMap) { - mat4.scale(m, m, [1 / pixelsToTileUnits, 1 / pixelsToTileUnits, 1]); + const s = mat2.invert([], pixelsToTileUnits); + m[0] = s[0]; + m[1] = s[1]; + m[4] = s[2]; + m[5] = s[3]; if (!rotateWithMap) { mat4.rotateZ(m, m, transform.angle); } @@ -98,10 +102,15 @@ function getGlCoordMatrix(posMatrix: mat4, pitchWithMap: boolean, rotateWithMap: boolean, transform: Transform, - pixelsToTileUnits: number) { + pixelsToTileUnits: Float32Array) { if (pitchWithMap) { const m = mat4.clone(posMatrix); - mat4.scale(m, m, [pixelsToTileUnits, pixelsToTileUnits, 1]); + const s = mat4.identity([]); + s[0] = pixelsToTileUnits[0]; + s[1] = pixelsToTileUnits[1]; + s[4] = pixelsToTileUnits[2]; + s[5] = pixelsToTileUnits[3]; + mat4.multiply(m, m, s); if (!rotateWithMap) { mat4.rotateZ(m, m, -transform.angle); } diff --git a/src/terrain/terrain.js b/src/terrain/terrain.js index 068eb88d0d0..12a50539a0c 100644 --- a/src/terrain/terrain.js +++ b/src/terrain/terrain.js @@ -4,7 +4,7 @@ import Point from '@mapbox/point-geometry'; import SourceCache from '../source/source_cache.js'; import {OverscaledTileID} from '../source/tile_id.js'; import Tile from '../source/tile.js'; -import rasterBoundsAttributes from '../data/raster_bounds_attributes.js'; +import boundsAttributes from '../data/bounds_attributes.js'; import {RasterBoundsArray, TriangleIndexArray, LineIndexArray} from '../data/array_types.js'; import SegmentVector from '../data/segment.js'; import Texture from '../render/texture.js'; @@ -222,7 +222,7 @@ export class Terrain extends Elevation { // edge vertices from neighboring tiles evaluate to the same 3D point. const [triangleGridArray, triangleGridIndices, skirtIndicesOffset] = createGrid(GRID_DIM + 1); const context = painter.context; - this.gridBuffer = context.createVertexBuffer(triangleGridArray, rasterBoundsAttributes.members); + this.gridBuffer = context.createVertexBuffer(triangleGridArray, boundsAttributes.members); this.gridIndexBuffer = context.createIndexBuffer(triangleGridIndices); this.gridSegments = SegmentVector.simpleSegment(0, 0, triangleGridArray.length, triangleGridIndices.length); this.gridNoSkirtSegments = SegmentVector.simpleSegment(0, 0, triangleGridArray.length, skirtIndicesOffset); diff --git a/src/ui/camera.js b/src/ui/camera.js index f00dda5ef32..363fd7a4262 100644 --- a/src/ui/camera.js +++ b/src/ui/camera.js @@ -353,7 +353,9 @@ class Camera extends Evented { * const bearing = map.getBearing(); * @see [Example: Navigate the map with game-like controls](https://www.mapbox.com/mapbox-gl-js/example/game-controls/) */ - getBearing(): number { return this.transform.bearing; } + getBearing(): number { + return this.transform.bearing; + } /** * Sets the map's bearing (rotation). The bearing is the compass direction that is "up"; for example, a bearing @@ -1564,7 +1566,7 @@ class Camera extends Evented { // interpolating between the two endpoints will cross it. _normalizeCenter(center: LngLat) { const tr = this.transform; - if (!tr.renderWorldCopies || tr.lngRange) return; + if (!tr.renderWorldCopies || tr.maxBounds) return; const delta = center.lng - tr.center.lng; center.lng += diff --git a/src/ui/free_camera.js b/src/ui/free_camera.js index 925f209720c..d4d4c02b4aa 100644 --- a/src/ui/free_camera.js +++ b/src/ui/free_camera.js @@ -306,6 +306,7 @@ class FreeCamera { vec3.scale(invPosition, invPosition, -worldSize); mat4.fromQuat(matrix, invOrientation); + mat4.translate(matrix, matrix, invPosition); // Pre-multiply y (2nd row) diff --git a/src/ui/map.js b/src/ui/map.js index 2f7bf56cbee..7d6eec2e83b 100755 --- a/src/ui/map.js +++ b/src/ui/map.js @@ -62,7 +62,8 @@ import type { LightSpecification, TerrainSpecification, FogSpecification, - SourceSpecification + SourceSpecification, + ProjectionSpecification } from '../style-spec/types.js'; import type {ElevationQueryOptions} from '../terrain/elevation.js'; @@ -118,7 +119,8 @@ type MapOptions = { transformRequest?: RequestTransformFunction, accessToken: string, testMode: ?boolean, - locale?: Object + locale?: Object, + projection?: ProjectionSpecification | string }; const defaultMinZoom = -2; @@ -267,6 +269,8 @@ const defaultOptions = { * @param {Object} [options.locale=null] A patch to apply to the default localization table for UI strings such as control tooltips. The `locale` object maps namespaced UI string IDs to translated strings in the target language; * see `src/ui/default_locale.js` for an example with all supported string IDs. The object may specify all UI strings (thereby adding support for a new translation) or only a subset of strings (thereby patching the default translation table). * @param {boolean} [options.testMode=false] Silences errors and warnings generated due to an invalid accessToken, useful when using the library to write unit tests. + * @param {ProjectionSpecification} [options.projection='mercator'] The projection the map should be rendered in. Available projections are Albers ('albers'), Equal Earth ('equalEarth'), Equirectangular/Plate Carrée/WGS84 ('equirectangular'), Lambert ('lambertConformalConic'), Mercator ('mercator'), Natural Earth ('naturalEarth'), and Winkel Tripel ('winkelTripel'). + * Conical projections such as Albers and Lambert have configurable `center` and `parallels` properties that allow developers to define the region in which the projection has minimal distortion; see the example for how to configure these properties. * @example * const map = new mapboxgl.Map({ * container: 'map', // container ID @@ -497,6 +501,17 @@ class Map extends Camera { this.handlers = new HandlerManager(this, options); + this._localFontFamily = options.localFontFamily; + this._localIdeographFontFamily = options.localIdeographFontFamily; + + if (options.style) { + this.setStyle(options.style, {localFontFamily: this._localFontFamily, localIdeographFontFamily: this._localIdeographFontFamily}); + } + + if (options.projection) { + this.setProjection(options.projection); + } + const hashName = (typeof options.hash === 'string' && options.hash) || undefined; this._hash = options.hash && (new Hash(hashName)).addTo(this); // don't set position from options if set through hash @@ -516,11 +531,6 @@ class Map extends Camera { this.resize(); - this._localFontFamily = options.localFontFamily; - this._localIdeographFontFamily = options.localIdeographFontFamily; - - if (options.style) this.setStyle(options.style, {localFontFamily: this._localFontFamily, localIdeographFontFamily: this._localIdeographFontFamily}); - if (options.attributionControl) this.addControl(new AttributionControl({customAttribution: options.customAttribution})); @@ -741,7 +751,7 @@ class Map extends Camera { * const maxBounds = map.getMaxBounds(); */ getMaxBounds(): LngLatBounds | null { - return this.transform.getMaxBounds(); + return this.transform.getMaxBounds() || null; } /** @@ -982,6 +992,38 @@ class Map extends Camera { /** @section {Point conversion} */ + /** + * Returns a {@link ProjectionSpecification} object that defines the current map projection. + * + * @returns {ProjectionSpecification} The {@link ProjectionSpecification} defining the current map projection. + * @example + * const projection = map.getProjection(); + */ + getProjection() { + return this.transform.getProjection(); + } + + /** + * Sets the map's projection. If called with `null` or `undefined`, the map will reset to Mercator. + * + * @param {ProjectionSpecification | string | null | undefined} projection The projection that the map should be rendered in. + * This can be a {@link ProjectionSpecification} object or a string of the projection's name. + * @example + * map.setProjection('albers'); + * map.setProjection({ + * name: 'albers', + * center: [35, 55], + * parallels: [20, 60] + * }); + */ + setProjection(projection?: ?ProjectionSpecification | string) { + this._lazyInitEmptyStyle(); + if (typeof projection === 'string') { + projection = (({name: projection}: any): ProjectionSpecification); + } + this.style.setProjection(projection); + } + /** * Returns a {@link Point} representing pixel coordinates, relative to the map's `container`, * that correspond to the specified geographical location. @@ -2844,20 +2886,22 @@ class Map extends Camera { this._placementDirty = this.style && this.style._updatePlacement(this.painter.transform, this.showCollisionBoxes, fadeDuration, this._crossSourceCollisions); // Actually draw - this.painter.render(this.style, { - showTileBoundaries: this.showTileBoundaries, - showTerrainWireframe: this.showTerrainWireframe, - showOverdrawInspector: this._showOverdrawInspector, - showQueryGeometry: !!this._showQueryGeometry, - rotating: this.isRotating(), - zooming: this.isZooming(), - moving: this.isMoving(), - fadeDuration, - isInitialLoad: this._isInitialLoad, - showPadding: this.showPadding, - gpuTiming: !!this.listens('gpu-timing-layer'), - speedIndexTiming: this.speedIndexTiming, - }); + if (this.style) { + this.painter.render(this.style, { + showTileBoundaries: this.showTileBoundaries, + showTerrainWireframe: this.showTerrainWireframe, + showOverdrawInspector: this._showOverdrawInspector, + showQueryGeometry: !!this._showQueryGeometry, + rotating: this.isRotating(), + zooming: this.isZooming(), + moving: this.isMoving(), + fadeDuration, + isInitialLoad: this._isInitialLoad, + showPadding: this.showPadding, + gpuTiming: !!this.listens('gpu-timing-layer'), + speedIndexTiming: this.speedIndexTiming, + }); + } this.fire(new Event('render')); diff --git a/test/expression.test.js b/test/expression.test.js index 2c4155d43f6..db741f604e2 100644 --- a/test/expression.test.js +++ b/test/expression.test.js @@ -6,12 +6,16 @@ import {toString} from '../src/style-spec/expression/types.js'; import ignores from './ignores.json'; import {CanonicalTileID} from '../src/source/tile_id.js'; import MercatorCoordinate from '../src/geo/mercator_coordinate.js'; +import tileTransform, {getTilePoint} from '../src/geo/projection/tile_transform.js'; +import {getProjection} from '../src/geo/projection/index.js'; import {fileURLToPath} from 'url'; const __filename = fileURLToPath(import.meta.url); +const projection = getProjection({name: 'mercator'}); function getPoint(coord, canonical) { - const p = canonical.getTilePoint(MercatorCoordinate.fromLngLat({lng: coord[0], lat: coord[1]}, 0)); + const tileTr = tileTransform(canonical, projection); + const p = getTilePoint(tileTr, MercatorCoordinate.fromLngLat({lng: coord[0], lat: coord[1]}, 0)); p.x = Math.round(p.x); p.y = Math.round(p.y); return p; diff --git a/test/integration/lib/render.js b/test/integration/lib/render.js index 623fed8abf7..ed2e590e7e0 100644 --- a/test/integration/lib/render.js +++ b/test/integration/lib/render.js @@ -145,6 +145,7 @@ async function runTest(t) { fadeDuration: options.fadeDuration || 0, optimizeForTerrain: options.optimizeForTerrain || false, localIdeographFontFamily: options.localIdeographFontFamily || false, + projection: options.projection, crossSourceCollisions: typeof options.crossSourceCollisions === "undefined" ? true : options.crossSourceCollisions, transformRequest: (url, resourceType) => { // some tests have the port hardcoded to 2900 diff --git a/test/integration/render-tests/map-projections/albers-configured/expected.png b/test/integration/render-tests/map-projections/albers-configured/expected.png new file mode 100644 index 00000000000..703a596ad69 Binary files /dev/null and b/test/integration/render-tests/map-projections/albers-configured/expected.png differ diff --git a/test/integration/render-tests/map-projections/albers-configured/style.json b/test/integration/render-tests/map-projections/albers-configured/style.json new file mode 100644 index 00000000000..d1b27020c61 --- /dev/null +++ b/test/integration/render-tests/map-projections/albers-configured/style.json @@ -0,0 +1,18 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 512, + "height": 512, + "operations": [ + ["setStyle", "local://mapbox-gl-styles/styles/basic-v9.json"], + ["wait"], + ["setProjection", {"name": "albers", "center": [-154, 63]}], + ["wait"] + ] + } + }, + "center": [-122.414, 37.776], + "sources": {}, + "layers": [] + } \ No newline at end of file diff --git a/test/integration/render-tests/map-projections/albers/expected.png b/test/integration/render-tests/map-projections/albers/expected.png new file mode 100644 index 00000000000..36806ecea25 Binary files /dev/null and b/test/integration/render-tests/map-projections/albers/expected.png differ diff --git a/test/integration/render-tests/map-projections/albers/style.json b/test/integration/render-tests/map-projections/albers/style.json new file mode 100644 index 00000000000..8c1843bec2a --- /dev/null +++ b/test/integration/render-tests/map-projections/albers/style.json @@ -0,0 +1,18 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 512, + "height": 512, + "operations": [ + ["setStyle", "local://mapbox-gl-styles/styles/basic-v9.json"], + ["wait"], + ["setProjection", "albers"], + ["wait"] + ] + } + }, + "center": [-122.414, 37.776], + "sources": {}, + "layers": [] + } \ No newline at end of file diff --git a/test/integration/render-tests/map-projections/equal-earth/expected.png b/test/integration/render-tests/map-projections/equal-earth/expected.png new file mode 100644 index 00000000000..b49409a3b5c Binary files /dev/null and b/test/integration/render-tests/map-projections/equal-earth/expected.png differ diff --git a/test/integration/render-tests/map-projections/equal-earth/style.json b/test/integration/render-tests/map-projections/equal-earth/style.json new file mode 100644 index 00000000000..0111dad26a4 --- /dev/null +++ b/test/integration/render-tests/map-projections/equal-earth/style.json @@ -0,0 +1,17 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 512, + "height": 512, + "operations": [ + ["setStyle", "local://mapbox-gl-styles/styles/basic-v9.json"], + ["wait"], + ["setProjection", "equalEarth"], + ["wait"] + ] + } + }, + "sources": {}, + "layers": [] + } \ No newline at end of file diff --git a/test/integration/render-tests/map-projections/equirectangular/expected.png b/test/integration/render-tests/map-projections/equirectangular/expected.png new file mode 100644 index 00000000000..4f475bc89fa Binary files /dev/null and b/test/integration/render-tests/map-projections/equirectangular/expected.png differ diff --git a/test/integration/render-tests/map-projections/equirectangular/style.json b/test/integration/render-tests/map-projections/equirectangular/style.json new file mode 100644 index 00000000000..06855d57996 --- /dev/null +++ b/test/integration/render-tests/map-projections/equirectangular/style.json @@ -0,0 +1,17 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 512, + "height": 512, + "operations": [ + ["setStyle", "local://mapbox-gl-styles/styles/basic-v9.json"], + ["wait"], + ["setProjection", "equirectangular"], + ["wait"] + ] + } + }, + "sources": {}, + "layers": [] + } \ No newline at end of file diff --git a/test/integration/render-tests/map-projections/lambert/expected.png b/test/integration/render-tests/map-projections/lambert/expected.png new file mode 100644 index 00000000000..1b13608ea4d Binary files /dev/null and b/test/integration/render-tests/map-projections/lambert/expected.png differ diff --git a/test/integration/render-tests/map-projections/lambert/style.json b/test/integration/render-tests/map-projections/lambert/style.json new file mode 100644 index 00000000000..c23857d3618 --- /dev/null +++ b/test/integration/render-tests/map-projections/lambert/style.json @@ -0,0 +1,18 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 512, + "height": 512, + "operations": [ + ["setStyle", "local://mapbox-gl-styles/styles/basic-v9.json"], + ["wait"], + ["setProjection", "lambertConformalConic"], + ["wait"] + ] + } + }, + "center": [-122.414, 37.776], + "sources": {}, + "layers": [] + } \ No newline at end of file diff --git a/test/integration/render-tests/map-projections/natural-earth/expected.png b/test/integration/render-tests/map-projections/natural-earth/expected.png new file mode 100644 index 00000000000..9bd964b3966 Binary files /dev/null and b/test/integration/render-tests/map-projections/natural-earth/expected.png differ diff --git a/test/integration/render-tests/map-projections/natural-earth/style.json b/test/integration/render-tests/map-projections/natural-earth/style.json new file mode 100644 index 00000000000..0e1eb24811f --- /dev/null +++ b/test/integration/render-tests/map-projections/natural-earth/style.json @@ -0,0 +1,17 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 512, + "height": 512, + "operations": [ + ["setStyle", "local://mapbox-gl-styles/styles/basic-v9.json"], + ["wait"], + ["setProjection", "naturalEarth"], + ["wait"] + ] + } + }, + "sources": {}, + "layers": [] + } \ No newline at end of file diff --git a/test/integration/render-tests/map-projections/winkel-tripel/expected.png b/test/integration/render-tests/map-projections/winkel-tripel/expected.png new file mode 100644 index 00000000000..e63c90ea905 Binary files /dev/null and b/test/integration/render-tests/map-projections/winkel-tripel/expected.png differ diff --git a/test/integration/render-tests/map-projections/winkel-tripel/style.json b/test/integration/render-tests/map-projections/winkel-tripel/style.json new file mode 100644 index 00000000000..35ea92485eb --- /dev/null +++ b/test/integration/render-tests/map-projections/winkel-tripel/style.json @@ -0,0 +1,17 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 512, + "height": 512, + "operations": [ + ["setStyle", "local://mapbox-gl-styles/styles/basic-v9.json"], + ["wait"], + ["setProjection", "winkelTripel"], + ["wait"] + ] + } + }, + "sources": {}, + "layers": [] + } \ No newline at end of file diff --git a/test/unit/data/symbol_bucket.test.js b/test/unit/data/symbol_bucket.test.js index a73cfffcf63..aabb639d4bf 100644 --- a/test/unit/data/symbol_bucket.test.js +++ b/test/unit/data/symbol_bucket.test.js @@ -13,6 +13,7 @@ import Tile from '../../../src/source/tile.js'; import CrossTileSymbolIndex from '../../../src/symbol/cross_tile_symbol_index.js'; import FeatureIndex from '../../../src/data/feature_index.js'; import {createSymbolBucket} from '../../util/create_symbol_layer.js'; +import {getProjection} from '../../../src/geo/projection/index.js'; import {fileURLToPath} from 'url'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); @@ -47,11 +48,12 @@ test('SymbolBucket', (t) => { const placement = new Placement(transform, 0, true); const tileID = new OverscaledTileID(0, 0, 0, 0, 0); const crossTileSymbolIndex = new CrossTileSymbolIndex(); + const painter = {transform: {projection: getProjection({name: 'mercator'})}}; // add feature from bucket A bucketA.populate([{feature}], options); performSymbolLayout(bucketA, stacks, glyphPositions); - const tileA = new Tile(tileID, 512); + const tileA = new Tile(tileID, 512, 0, painter); tileA.latestFeatureIndex = new FeatureIndex(tileID); tileA.buckets = {test: bucketA}; tileA.collisionBoxArray = collisionBoxArray; @@ -59,7 +61,7 @@ test('SymbolBucket', (t) => { // add same feature from bucket B bucketB.populate([{feature}], options); performSymbolLayout(bucketB, stacks, glyphPositions); - const tileB = new Tile(tileID, 512); + const tileB = new Tile(tileID, 512, 0, painter); tileB.buckets = {test: bucketB}; tileB.collisionBoxArray = collisionBoxArray; diff --git a/test/unit/geo/transform.test.js b/test/unit/geo/transform.test.js index ba2928e45db..93cd7d8dd09 100644 --- a/test/unit/geo/transform.test.js +++ b/test/unit/geo/transform.test.js @@ -5,7 +5,7 @@ import LngLat from '../../../src/geo/lng_lat.js'; import {OverscaledTileID, CanonicalTileID} from '../../../src/source/tile_id.js'; import {fixedNum, fixedLngLat, fixedCoord, fixedPoint, fixedVec3, fixedVec4} from '../../util/fixed.js'; import {FreeCameraOptions} from '../../../src/ui/free_camera.js'; -import MercatorCoordinate, {mercatorZfromAltitude} from '../../../src/geo/mercator_coordinate.js'; +import MercatorCoordinate, {mercatorZfromAltitude, MAX_MERCATOR_LATITUDE} from '../../../src/geo/mercator_coordinate.js'; import {vec3, quat} from 'gl-matrix'; import LngLatBounds from '../../../src/geo/lng_lat_bounds.js'; import {degToRad} from '../../../src/util/util.js'; @@ -16,7 +16,6 @@ test('transform', (t) => { const transform = new Transform(); transform.resize(500, 500); t.equal(transform.unmodified, true); - t.equal(transform.maxValidLatitude, 85.051129); t.equal(transform.tileSize, 512, 'tileSize'); t.equal(transform.worldSize, 512, 'worldSize'); t.equal(transform.width, 500, 'width'); @@ -95,15 +94,12 @@ test('transform', (t) => { t.end(); }); - t.test('lngRange & latRange constrain zoom and center', (t) => { + t.test('maxBounds constrain zoom and center', (t) => { const transform = new Transform(); transform.center = new LngLat(0, 0); transform.zoom = 10; transform.resize(500, 500); - - transform.lngRange = [-5, 5]; - transform.latRange = [-5, 5]; - + transform.setMaxBounds(LngLatBounds.convert([-5, -5, 5, 5])); transform.zoom = 0; t.equal(transform.zoom, 5.135709286104402); @@ -122,8 +118,7 @@ test('transform', (t) => { const transform = new Transform(); transform.zoom = 6; transform.resize(500, 500); - transform.lngRange = [160, 190]; - transform.latRange = [-55, -23]; + transform.setMaxBounds(LngLatBounds.convert([160, -55, 190, -23])); transform.center = new LngLat(-170, -40); @@ -137,8 +132,7 @@ test('transform', (t) => { const transform = new Transform(); transform.zoom = 6; transform.resize(500, 500); - transform.lngRange = [-190, -160]; - transform.latRange = [-55, -23]; + transform.setMaxBounds(LngLatBounds.convert([-190, -55, -160, -23])); transform.center = new LngLat(170, -40); @@ -152,8 +146,7 @@ test('transform', (t) => { const transform = new Transform(); transform.zoom = 6; transform.resize(500, 500); - transform.lngRange = [0, 360]; - transform.latRange = [-90, 90]; + transform.setMaxBounds(LngLatBounds.convert([0, -90, 360, 90])); transform.center = new LngLat(-155, 0); @@ -166,8 +159,7 @@ test('transform', (t) => { const transform = new Transform(); transform.zoom = 6; transform.resize(500, 500); - transform.lngRange = [-360, 0]; - transform.latRange = [-90, 90]; + transform.setMaxBounds(LngLatBounds.convert([-360, -90, 0, 90])); transform.center = new LngLat(160, 0); t.same(transform.center.lng.toFixed(10), -200); @@ -223,8 +215,8 @@ test('transform', (t) => { t.end(); }); - t.test('_minZoomForBounds respects latRange and lngRange', (t) => { - t.test('it returns 0 when latRange and lngRange are undefined', (t) => { + t.test('_minZoomForBounds respects maxBounds', (t) => { + t.test('it returns 0 when lngRange is undefined', (t) => { const transform = new Transform(); transform.center = new LngLat(0, 0); transform.zoom = 10; @@ -239,8 +231,7 @@ test('transform', (t) => { transform.center = new LngLat(0, 0); transform.zoom = 10; transform.resize(500, 500); - transform.lngRange = [-5, 5]; - transform.latRange = [-5, 5]; + transform.setMaxBounds(LngLatBounds.convert([-5, -5, 5, 5])); const preComputedMinZoom = transform._minZoomForBounds(); transform.zoom = 0; @@ -349,7 +340,7 @@ test('transform', (t) => { const bounds = transform.getBounds(); // Bounds stops at the edge of the map - t.same(bounds.getNorth().toFixed(6), transform.maxValidLatitude); + t.same(bounds.getNorth().toFixed(6), MAX_MERCATOR_LATITUDE); // Top corners of bounds line up with side of view t.same(transform.locationPoint(bounds.getNorthWest()).x.toFixed(10), 0); t.same(transform.locationPoint(bounds.getNorthEast()).x.toFixed(10), transform.width); @@ -369,7 +360,7 @@ test('transform', (t) => { const bounds = transform.getBounds(); // Bounds stops at the edge of the map - t.same(bounds.getSouth().toFixed(6), -transform.maxValidLatitude); + t.same(bounds.getSouth().toFixed(6), -MAX_MERCATOR_LATITUDE); // Top corners of bounds line up with side of view t.same(transform.locationPoint(bounds.getSouthEast()).x.toFixed(10), 0); t.same(transform.locationPoint(bounds.getSouthWest()).x.toFixed(10), transform.width); @@ -1067,8 +1058,8 @@ test('transform', (t) => { t.test('clamps latitude', (t) => { const transform = new Transform(); - t.deepEqual(transform.project(new LngLat(0, -90)), transform.project(new LngLat(0, -transform.maxValidLatitude))); - t.deepEqual(transform.project(new LngLat(0, 90)), transform.project(new LngLat(0, transform.maxValidLatitude))); + t.deepEqual(transform.project(new LngLat(0, -90)), transform.project(new LngLat(0, -MAX_MERCATOR_LATITUDE))); + t.deepEqual(transform.project(new LngLat(0, 90)), transform.project(new LngLat(0, MAX_MERCATOR_LATITUDE))); t.end(); }); @@ -1359,7 +1350,7 @@ test('transform', (t) => { t.test('clamp to bounds', (t) => { const transform = new Transform(); transform.resize(100, 100); - transform.setMaxBounds(new LngLatBounds(new LngLat(-180, -transform.maxValidLatitude), new LngLat(180, transform.maxValidLatitude))); + transform.setMaxBounds(new LngLatBounds(new LngLat(-180, -MAX_MERCATOR_LATITUDE), new LngLat(180, MAX_MERCATOR_LATITUDE))); transform.zoom = 8.56; const options = new FreeCameraOptions(); @@ -1490,7 +1481,7 @@ test('transform', (t) => { }); t.test('_translateCameraConstrained', (t) => { - t.test('it clamps at zoom 0 when lngRange and latRange are not defined', (t) => { + t.test('it clamps at zoom 0 when maxBounds are not defined', (t) => { const transform = new Transform(); transform.center = new LngLat(0, 0); transform.zoom = 10; @@ -1525,8 +1516,7 @@ test('transform', (t) => { transform.center = new LngLat(0, 0); transform.zoom = 20; transform.resize(500, 500); - transform.lngRange = [-5, 5]; - transform.latRange = [-5, 5]; + transform.setMaxBounds(LngLatBounds.convert([-5, -5, 5, 5])); //record constrained zoom transform.zoom = 0; diff --git a/test/unit/source/geojson_worker_source.test.js b/test/unit/source/geojson_worker_source.test.js index e7b4a466a90..6bf3076b691 100644 --- a/test/unit/source/geojson_worker_source.test.js +++ b/test/unit/source/geojson_worker_source.test.js @@ -3,6 +3,7 @@ import GeoJSONWorkerSource from '../../../src/source/geojson_worker_source.js'; import StyleLayerIndex from '../../../src/style/style_layer_index.js'; import {OverscaledTileID} from '../../../src/source/tile_id.js'; import perf from '../../../src/util/performance.js'; +import {getProjection} from '../../../src/geo/projection/index.js'; const actor = {send: () => {}}; @@ -34,7 +35,8 @@ test('reloadTile', (t) => { source: 'sourceId', uid: 0, tileID: new OverscaledTileID(0, 0, 0, 0, 0), - maxZoom: 10 + maxZoom: 10, + projection: getProjection({name: 'mercator'}) }; function addData(callback) { diff --git a/test/unit/source/source_cache.test.js b/test/unit/source/source_cache.test.js index f97a55b40a4..52b2749fbf1 100644 --- a/test/unit/source/source_cache.test.js +++ b/test/unit/source/source_cache.test.js @@ -68,7 +68,8 @@ function createSourceCache(options, used) { type: 'mock-source-type' }, spec), /* dispatcher */ {}, eventedParent)); sc.used = typeof used === 'boolean' ? used : true; - sc.transform = {tileZoom: 0}; + sc.transform = new Transform(); + sc.map = {painter: {transform: sc.transform}}; return {sourceCache: sc, eventedParent}; } @@ -334,7 +335,10 @@ test('SourceCache#removeTile', (t) => { callback(); } }); - sourceCache.map = {painter: {crossTileSymbolIndex: "", tileExtentVAO: {}}}; + sourceCache.map = {painter: {transform: new Transform(), crossTileSymbolIndex: "", tileExtentVAO: {}, context: { + createIndexBuffer: () => {}, + createVertexBuffer: () => {} + }}}; sourceCache._addTile(tileID); diff --git a/test/unit/source/vector_tile_worker_source.test.js b/test/unit/source/vector_tile_worker_source.test.js index 6aa005f14ff..9b90edea628 100644 --- a/test/unit/source/vector_tile_worker_source.test.js +++ b/test/unit/source/vector_tile_worker_source.test.js @@ -6,6 +6,7 @@ import {test} from '../../util/test.js'; import VectorTileWorkerSource from '../../../src/source/vector_tile_worker_source.js'; import StyleLayerIndex from '../../../src/style/style_layer_index.js'; import perf from '../../../src/util/performance.js'; +import {getProjection} from '../../../src/geo/projection/index.js'; import {fileURLToPath} from 'url'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); @@ -19,6 +20,7 @@ test('VectorTileWorkerSource#abortTile aborts pending request', (t) => { source: 'source', uid: 0, tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, + projection: getProjection({name: 'mercator'}), request: {url: 'http://localhost:2900/abort'} }, (err, res) => { t.false(err); @@ -46,7 +48,8 @@ test('VectorTileWorkerSource#abortTile aborts pending async request', (t) => { source.loadTile({ uid: 0, - tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}} + tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, + projection: getProjection({name: 'mercator'}) }, (err, res) => { t.false(err); t.false(res); @@ -246,6 +249,7 @@ test('VectorTileWorkerSource provides resource timing information', (t) => { source: 'source', uid: 0, tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, + projection: getProjection({name: 'mercator'}), request: {url: 'http://localhost:2900/faketile.pbf', collectResourceTiming: true} }, (err, res) => { t.false(err); diff --git a/test/unit/source/worker.test.js b/test/unit/source/worker.test.js index 927e4ae0773..3fcaba05458 100644 --- a/test/unit/source/worker.test.js +++ b/test/unit/source/worker.test.js @@ -10,6 +10,7 @@ test('load tile', (t) => { t.test('calls callback on error', (t) => { window.useFakeXMLHttpRequest(); const worker = new Worker(_self); + worker.setProjection(0, {name: 'mercator'}); worker.loadTile(0, { type: 'vector', source: 'source', diff --git a/test/unit/source/worker_tile.test.js b/test/unit/source/worker_tile.test.js index 04375c7826e..9d3b1751daa 100644 --- a/test/unit/source/worker_tile.test.js +++ b/test/unit/source/worker_tile.test.js @@ -3,6 +3,7 @@ import WorkerTile from '../../../src/source/worker_tile.js'; import Wrapper from '../../../src/source/geojson_wrapper.js'; import {OverscaledTileID} from '../../../src/source/tile_id.js'; import StyleLayerIndex from '../../../src/style/style_layer_index.js'; +import {getProjection} from '../../../src/geo/projection/index.js'; function createWorkerTile() { return new WorkerTile({ @@ -12,7 +13,8 @@ function createWorkerTile() { tileSize: 512, source: 'source', tileID: new OverscaledTileID(1, 0, 1, 1, 1), - overscaling: 1 + overscaling: 1, + projection: getProjection({name: 'mercator'}) }); } diff --git a/test/unit/terrain/terrain.test.js b/test/unit/terrain/terrain.test.js index d246283f05a..40dacb0b274 100644 --- a/test/unit/terrain/terrain.test.js +++ b/test/unit/terrain/terrain.test.js @@ -3,7 +3,7 @@ import {extend} from '../../../src/util/util.js'; import {createMap} from '../../util/index.js'; import DEMData from '../../../src/data/dem_data.js'; import {RGBAImage} from '../../../src/util/image.js'; -import MercatorCoordinate from '../../../src/geo/mercator_coordinate.js'; +import MercatorCoordinate, {MAX_MERCATOR_LATITUDE} from '../../../src/geo/mercator_coordinate.js'; import window from '../../../src/util/window.js'; import {OverscaledTileID} from '../../../src/source/tile_id.js'; import styleSpec from '../../../src/style-spec/reference/latest.js'; @@ -369,6 +369,9 @@ test('Elevation', (t) => { }; const map = createMap(t, { style: extend(createStyle(), { + projection: { + name: 'mercator' + }, sources: { trace: { type: 'geojson', @@ -1511,7 +1514,7 @@ test('terrain getBounds', (t) => { map.once('render', () => { t.ok(map.transform.elevation); const bounds = map.getBounds(); - t.same(bounds.getNorth().toFixed(6), map.transform.maxValidLatitude); + t.same(bounds.getNorth().toFixed(6), MAX_MERCATOR_LATITUDE); t.same( toFixed(bounds.toArray()), toFixed([[ -23.3484820899, 77.6464759596 ], [ 23.3484820899, 85.0511287798 ]]) @@ -1521,7 +1524,7 @@ test('terrain getBounds', (t) => { map.setCenter({lng: 0, lat: -90}); const sBounds = map.getBounds(); - t.same(sBounds.getSouth().toFixed(6), -map.transform.maxValidLatitude); + t.same(sBounds.getSouth().toFixed(6), -MAX_MERCATOR_LATITUDE); t.same( toFixed(sBounds.toArray()), toFixed([[ -23.3484820899, -85.0511287798 ], [ 23.3484820899, -77.6464759596]]) diff --git a/test/unit/ui/map.test.js b/test/unit/ui/map.test.js index deec79c18e9..e38a29b03fb 100755 --- a/test/unit/ui/map.test.js +++ b/test/unit/ui/map.test.js @@ -4,6 +4,7 @@ import window from '../../../src/util/window.js'; import Map from '../../../src/ui/map.js'; import {createMap} from '../../util/index.js'; import LngLat from '../../../src/geo/lng_lat.js'; +import LngLatBounds from '../../../src/geo/lng_lat_bounds.js'; import Tile from '../../../src/source/tile.js'; import {OverscaledTileID} from '../../../src/source/tile_id.js'; import {Event, ErrorEvent} from '../../../src/util/evented.js'; @@ -11,6 +12,7 @@ import simulate from '../../util/simulate_interaction.js'; import {fixedLngLat, fixedNum} from '../../util/fixed.js'; import Fog from '../../../src/style/fog.js'; import Color from '../../../src/style-spec/util/color.js'; +import {MAX_MERCATOR_LATITUDE} from '../../../src/geo/mercator_coordinate.js'; function createStyleSource() { return { @@ -234,10 +236,10 @@ test('Map', (t) => { t.stub(Map.prototype, '_detectMissingCSS'); t.stub(Map.prototype, '_authenticate'); const map = new Map({container: window.document.createElement('div'), testMode: true}); - map.transform.lngRange = [-120, 140]; - map.transform.latRange = [-60, 80]; + + map.transform.setMaxBounds(LngLatBounds.convert([-120, -60, 140, 80])); map.transform.resize(600, 400); - t.equal(map.transform.zoom, 0.6983039737971012, 'map transform is constrained'); + t.ok(map.transform.zoom, 0.698303973797101, 'map transform is constrained'); t.ok(map.transform.unmodified, 'map transform is not modified'); map.setStyle(createStyle()); map.on('style.load', () => { @@ -905,7 +907,7 @@ test('Map', (t) => { const map = createMap(t, {zoom: 2, center: [0, 90], pitch: 80, skipCSSStub: true}); const bounds = map.getBounds(); - t.same(bounds.getNorth().toFixed(6), map.transform.maxValidLatitude); + t.same(bounds.getNorth().toFixed(6), MAX_MERCATOR_LATITUDE); t.same( toFixed(bounds.toArray()), toFixed([[ -23.3484820899, 77.6464759596 ], [ 23.3484820899, 85.0511287798 ]]) @@ -915,7 +917,7 @@ test('Map', (t) => { map.setCenter({lng: 0, lat: -90}); const sBounds = map.getBounds(); - t.same(sBounds.getSouth().toFixed(6), -map.transform.maxValidLatitude); + t.same(sBounds.getSouth().toFixed(6), -MAX_MERCATOR_LATITUDE); t.same( toFixed(sBounds.toArray()), toFixed([[ -23.3484820899, -85.0511287798 ], [ 23.3484820899, -77.6464759596]]) @@ -1310,6 +1312,119 @@ test('Map', (t) => { t.end(); }); + t.test('#getProjection', (t) => { + t.test('map defaults to Mercator', (t) => { + const map = createMap(t); + t.deepEqual(map.getProjection(), {name: 'mercator', center: [0, 0]}); + t.end(); + }); + + t.test('respects projection options object', (t) => { + const options = { + name: 'albers', + center: [12, 34], + parallels: [10, 42] + }; + const map = createMap(t, {projection: options}); + t.deepEqual(map.getProjection(), options); + t.end(); + }); + + t.test('respects projection options string', (t) => { + const map = createMap(t, {projection: 'albers'}); + t.deepEqual(map.getProjection(), { + name: 'albers', + center: [-96, 37.5], + parallels: [29.5, 45.5] + }); + t.end(); + }); + + t.test('composites user and default projection options', (t) => { + const options = { + name: 'albers', + center: [12, 34] + }; + const map = createMap(t, {projection: options}); + t.deepEqual(map.getProjection(), { + name: 'albers', + center: [12, 34], + parallels: [29.5, 45.5] + }); + t.end(); + }); + + t.test('does not composite user and default projection options for non-conical projections', (t) => { + const options = { + name: 'naturalEarth', + center: [12, 34] + }; + const map = createMap(t, {projection: options}); + t.deepEqual(map.getProjection(), { + name: 'naturalEarth', + center: [0, 0] + }); + t.end(); + }); + t.end(); + }); + + t.test('#setProjection', (t) => { + t.test('sets projection by string', (t) => { + const map = createMap(t); + map.setProjection('albers'); + t.deepEqual(map.getProjection(), { + name: 'albers', + center: [-96, 37.5], + parallels: [29.5, 45.5] + }); + t.end(); + }); + + t.test('throws error if invalid projection name is supplied', (t) => { + const map = createMap(t); + map.on('error', ({error}) => { + t.match(error.message, /Invalid projection name: fakeProj/); + t.end(); + }); + t.end(); + }); + + t.test('sets projection by options object', (t) => { + const options = { + name: 'albers', + center: [12, 34], + parallels: [10, 42] + }; + const map = createMap(t); + map.setProjection(options); + t.deepEqual(map.getProjection(), options); + t.end(); + }); + + t.test('sets projection by options object with just name', (t) => { + const map = createMap(t); + map.setProjection({name: 'albers'}); + t.deepEqual(map.getProjection(), { + name: 'albers', + center: [-96, 37.5], + parallels: [29.5, 45.5] + }); + t.end(); + }); + + t.test('setProjection with no argument defaults to Mercator', (t) => { + const map = createMap(t); + map.setProjection({name: 'albers'}); + t.equal(map.transform._unmodifiedProjection, false); + map.setProjection(); + t.deepEqual(map.getProjection(), {name: 'mercator', center: [0, 0]}); + t.equal(map.transform._unmodifiedProjection, true); + t.end(); + }); + t.end(); + }); + t.test('#remove', (t) => { const map = createMap(t); t.equal(map.getContainer().childNodes.length, 3); diff --git a/test/unit/ui/marker.test.js b/test/unit/ui/marker.test.js index aa19db9a91f..72742a8abf0 100644 --- a/test/unit/ui/marker.test.js +++ b/test/unit/ui/marker.test.js @@ -830,7 +830,7 @@ test('Drag above horizon clamps', (t) => { }); test('Drag below / behind camera', (t) => { - const map = createMap(t); + const map = createMap(t, {zoom: 3}); map.setPitch(85); const marker = new Marker({draggable: true}) .setLngLat(map.unproject([map.transform.width / 2, map.transform.height - 20]))