diff --git a/.gitignore b/.gitignore index 29dcf10064d..8c7f4101fdf 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,6 @@ flow-coverage *-generated.js test/integration/**/index*.html test/integration/**/actual.png +test/integration/**/actual.json test/integration/**/diff.png .eslintcache diff --git a/debug/cross_source_points.geojson b/debug/cross_source_points.geojson new file mode 100644 index 00000000000..cd52bb17cd3 --- /dev/null +++ b/debug/cross_source_points.geojson @@ -0,0 +1,57 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -77.01, + 38.90 + ] + }, + "properties": { + "name": "GeoJSON Source 1" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -77.01, + 38.89 + ] + }, + "properties": { + "name": "GeoJSON Source 2" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -77.03, + 38.90 + ] + }, + "properties": { + "name": "GeoJSON Source 3" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -77.03, + 38.89 + ] + }, + "properties": { + "name": "GeoJSON Source 4" + } + } + ] +} diff --git a/debug/debug.html b/debug/debug.html index 1bb56c7c876..2d93cc10bdc 100644 --- a/debug/debug.html +++ b/debug/debug.html @@ -36,7 +36,7 @@ container: 'map', zoom: 12.5, center: [-77.01866, 38.888], - style: 'mapbox://styles/mapbox/streets-v9', + style: 'mapbox://styles/mapbox/streets-v10', hash: true }); @@ -67,6 +67,21 @@ "line-width": {"base": 1.5, "stops": [[5, 0.75], [18, 32]]} } }, 'country-label-lg'); + + map.addSource("points", { + "type": "geojson", + "data": '/debug/cross_source_points.geojson' + }); + + map.addLayer({ + "id": "points", + "type": "symbol", + "source": "points", + "layout": { + "text-field": "{name}", + "text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"] + } + }); }); map.on('click', function(e) { diff --git a/src/data/bucket/symbol_bucket.js b/src/data/bucket/symbol_bucket.js index 8c401bdf958..8f0ff487311 100644 --- a/src/data/bucket/symbol_bucket.js +++ b/src/data/bucket/symbol_bucket.js @@ -1,5 +1,4 @@ // @flow - const Point = require('@mapbox/point-geometry'); const {SegmentVector} = require('../segment'); const VertexBuffer = require('../../gl/vertex_buffer'); @@ -7,44 +6,28 @@ const IndexBuffer = require('../../gl/index_buffer'); const {ProgramConfigurationSet} = require('../program_configuration'); const createVertexArrayType = require('../vertex_array_type'); const {TriangleIndexArray, LineIndexArray} = require('../index_array_type'); -const EXTENT = require('../extent'); -const {packUint8ToFloat} = require('../../shaders/encode_attribute'); -const Anchor = require('../../symbol/anchor'); -const getAnchors = require('../../symbol/get_anchors'); const resolveTokens = require('../../util/token'); -const {getGlyphQuads, getIconQuads} = require('../../symbol/quads'); -const {shapeText, shapeIcon, WritingMode} = require('../../symbol/shaping'); const transformText = require('../../symbol/transform_text'); const mergeLines = require('../../symbol/mergelines'); -const clipLine = require('../../symbol/clip_line'); -const util = require('../../util/util'); const scriptDetection = require('../../util/script_detection'); const loadGeometry = require('../load_geometry'); -const CollisionFeature = require('../../symbol/collision_feature'); -const findPoleOfInaccessibility = require('../../util/find_pole_of_inaccessibility'); -const classifyRings = require('../../util/classify_rings'); const vectorTileFeatureTypes = require('@mapbox/vector-tile').VectorTileFeature.types; const createStructArrayType = require('../../util/struct_array'); const verticalizePunctuation = require('../../util/verticalize_punctuation'); +const Anchor = require('../../symbol/anchor'); +const OpacityState = require('../../symbol/opacity_state'); const {getSizeData} = require('../../symbol/symbol_size'); import type {Feature as ExpressionFeature} from '../../style-spec/expression'; import type {Bucket, BucketParameters, IndexedFeature, PopulateParameters} from '../bucket'; import type {ProgramInterface, SerializedProgramConfiguration} from '../program_configuration'; import type CollisionBoxArray, {CollisionBox} from '../../symbol/collision_box'; -import type CollisionTile from '../../symbol/collision_tile'; import type { StructArray, SerializedStructArray } from '../../util/struct_array'; import type SymbolStyleLayer from '../../style/style_layer/symbol_style_layer'; -import type {Shaping, PositionedIcon} from '../../symbol/shaping'; import type {SymbolQuad} from '../../symbol/quads'; -import type {SizeData} from '../../symbol/symbol_size'; -import type {StyleImage} from '../../style/style_image'; -import type {StyleGlyph} from '../../style/style_glyph'; -import type {ImagePosition} from '../../render/image_atlas'; -import type {GlyphPosition} from '../../render/glyph_atlas'; type SymbolBucketParameters = BucketParameters & { sdfIcons: boolean, @@ -58,22 +41,47 @@ type SymbolBucketParameters = BucketParameters & { lineVertexArray: StructArray, } -type SymbolInstance = { +export type SingleCollisionBox = { + x1: number; + y1: number; + x2: number; + y2: number; + anchorPointX: number; + anchorPointY: number; +}; + +export type CollisionArrays = { + textBox?: SingleCollisionBox; + iconBox?: SingleCollisionBox; + textCircles?: Array; +}; + +export type SymbolInstance = { + key: string, textBoxStartIndex: number, textBoxEndIndex: number, iconBoxStartIndex: number, iconBoxEndIndex: number, - glyphQuads: Array, - iconQuads: Array, textOffset: [number, number], iconOffset: [number, number], anchor: Anchor, line: Array, featureIndex: number, feature: ExpressionFeature, - writingModes: number, textCollisionFeature?: {boxStartIndex: number, boxEndIndex: number}, - iconCollisionFeature?: {boxStartIndex: number, boxEndIndex: number} + iconCollisionFeature?: {boxStartIndex: number, boxEndIndex: number}, + placedTextSymbolIndices: Array; + numGlyphVertices: number; + numVerticalGlyphVertices: number; + numIconVertices: number; + // Populated/modified on foreground during placement + isDuplicate: boolean; + textOpacityState: OpacityState; + iconOpacityState: OpacityState; + collisionArrays?: CollisionArrays; + placedText?: boolean; + placedIcon?: boolean; + hidden?: boolean; }; export type SymbolFeature = {| @@ -87,17 +95,13 @@ export type SymbolFeature = {| id?: any |}; -type ShapedTextOrientations = { - '1'?: Shaping, - '2'?: Shaping -}; - const PlacedSymbolArray = createStructArrayType({ members: [ { type: 'Int16', name: 'anchorX' }, { type: 'Int16', name: 'anchorY' }, { type: 'Uint16', name: 'glyphStartIndex' }, { type: 'Uint16', name: 'numGlyphs' }, + { type: 'Uint32', name: 'vertexStartIndex' }, { type: 'Uint32', name: 'lineStartIndex' }, { type: 'Uint32', name: 'lineLength' }, { type: 'Uint16', name: 'segment' }, @@ -105,8 +109,8 @@ const PlacedSymbolArray = createStructArrayType({ { type: 'Uint16', name: 'upperSize' }, { type: 'Float32', name: 'lineOffsetX' }, { type: 'Float32', name: 'lineOffsetY' }, - { type: 'Float32', name: 'placementZoom' }, - { type: 'Uint8', name: 'vertical' } + { type: 'Uint8', name: 'writingMode' }, + { type: 'Uint8', name: 'hidden' } ] }); @@ -119,7 +123,8 @@ const GlyphOffsetArray = createStructArrayType({ const LineVertexArray = createStructArrayType({ members: [ { type: 'Int16', name: 'x' }, - { type: 'Int16', name: 'y' } + { type: 'Int16', name: 'y' }, + { type: 'Int16', name: 'tileUnitDistanceFromAnchor' } ]}); const layoutAttributes = [ @@ -131,11 +136,26 @@ const dynamicLayoutAttributes = [ { name: 'a_projected_pos', components: 3, type: 'Float32' } ]; +// Opacity arrays are frequently updated but don't contain a lot of information, so we pack them +// tight. Each Uint32 is actually four duplicate Uint8s for the four corners of a glyph +// 7 bits are for the current opacity, and the lowest bit is the target opacity +const placementOpacityAttributes = [ + { name: 'a_fade_opacity', components: 1, type: 'Uint32' } +]; +const shaderOpacityAttributes = [ + { name: 'a_fade_opacity', components: 1, type: 'Uint8', offset: 0 } +]; + +const collisionAttributes = [ + { name: 'a_placed', components: 2, type: 'Uint8' } +]; + const symbolInterfaces = { text: { layoutAttributes: layoutAttributes, dynamicLayoutAttributes: dynamicLayoutAttributes, indexArrayType: TriangleIndexArray, + opacityAttributes: placementOpacityAttributes, paintAttributes: [ {property: 'text-color', name: 'fill_color'}, {property: 'text-halo-color', name: 'halo_color'}, @@ -148,6 +168,7 @@ const symbolInterfaces = { layoutAttributes: layoutAttributes, dynamicLayoutAttributes: dynamicLayoutAttributes, indexArrayType: TriangleIndexArray, + opacityAttributes: placementOpacityAttributes, paintAttributes: [ {property: 'icon-color', name: 'fill_color'}, {property: 'icon-halo-color', name: 'halo_color'}, @@ -160,10 +181,19 @@ const symbolInterfaces = { layoutAttributes: [ {name: 'a_pos', components: 2, type: 'Int16'}, {name: 'a_anchor_pos', components: 2, type: 'Int16'}, - {name: 'a_extrude', components: 2, type: 'Int16'}, - {name: 'a_data', components: 2, type: 'Uint8'} + {name: 'a_extrude', components: 2, type: 'Int16'} ], - indexArrayType: LineIndexArray + indexArrayType: LineIndexArray, + collisionAttributes: collisionAttributes + }, + collisionCircle: { // used to render collision circles for debugging purposes + layoutAttributes: [ + {name: 'a_pos', components: 2, type: 'Int16'}, + {name: 'a_anchor_pos', components: 2, type: 'Int16'}, + {name: 'a_extrude', components: 2, type: 'Int16'} + ], + collisionAttributes: collisionAttributes, + indexArrayType: TriangleIndexArray } }; @@ -183,37 +213,19 @@ function addVertex(array, anchorX, anchorY, ox, oy, tx, ty, sizeVertex) { ); } -function addDynamicAttributes(dynamicLayoutVertexArray, p, angle, placementZoom) { - const twoPi = Math.PI * 2; - const angleAndZoom = packUint8ToFloat( - ((angle + twoPi) % twoPi) / twoPi * 255, - placementZoom * 10); - dynamicLayoutVertexArray.emplaceBack(p.x, p.y, angleAndZoom); - dynamicLayoutVertexArray.emplaceBack(p.x, p.y, angleAndZoom); - dynamicLayoutVertexArray.emplaceBack(p.x, p.y, angleAndZoom); - dynamicLayoutVertexArray.emplaceBack(p.x, p.y, angleAndZoom); -} - -function addCollisionBoxVertex(layoutVertexArray, point, anchor, extrude, maxZoom, placementZoom) { - return layoutVertexArray.emplaceBack( - // pos - point.x, - point.y, - // a_anchor_pos - anchor.x, - anchor.y, - // extrude - Math.round(extrude.x), - Math.round(extrude.y), - // data - maxZoom * 10, - placementZoom * 10); +function addDynamicAttributes(dynamicLayoutVertexArray, p, angle) { + dynamicLayoutVertexArray.emplaceBack(p.x, p.y, angle); + dynamicLayoutVertexArray.emplaceBack(p.x, p.y, angle); + dynamicLayoutVertexArray.emplaceBack(p.x, p.y, angle); + dynamicLayoutVertexArray.emplaceBack(p.x, p.y, angle); } type SerializedSymbolBuffer = { layoutVertexArray: SerializedStructArray, dynamicLayoutVertexArray: SerializedStructArray, indexArray: SerializedStructArray, + opacityVertexArray: SerializedStructArray, + collisionVertexArray: SerializedStructArray, programConfigurations: {[string]: ?SerializedProgramConfiguration}, segments: Array, }; @@ -232,6 +244,12 @@ class SymbolBuffers { dynamicLayoutVertexArray: StructArray; dynamicLayoutVertexBuffer: VertexBuffer; + opacityVertexArray: StructArray; + opacityVertexBuffer: VertexBuffer; + + collisionVertexArray: StructArray; + collisionVertexBuffer: VertexBuffer; + constructor(programInterface: ProgramInterface, layers: Array, zoom: number, arrays?: SerializedSymbolBuffer) { this.programInterface = programInterface; @@ -247,6 +265,17 @@ class SymbolBuffers { const DynamicLayoutVertexArrayType = createVertexArrayType(programInterface.dynamicLayoutAttributes); this.dynamicLayoutVertexArray = new DynamicLayoutVertexArrayType(arrays && arrays.dynamicLayoutVertexArray); } + + if (programInterface.opacityAttributes) { + const OpacityVertexArrayType = createVertexArrayType(programInterface.opacityAttributes); + this.opacityVertexArray = new OpacityVertexArrayType(arrays && arrays.opacityVertexArray); + } + + if (programInterface.collisionAttributes) { + const CollisionVertexArrayType = createVertexArrayType(programInterface.collisionAttributes); + this.collisionVertexArray = new CollisionVertexArrayType(arrays && arrays.collisionVertexArray); + } + } serialize(transferables?: Array): SerializedSymbolBuffer { @@ -256,17 +285,29 @@ class SymbolBuffers { programConfigurations: this.programConfigurations.serialize(transferables), segments: this.segments.get(), dynamicLayoutVertexArray: this.dynamicLayoutVertexArray && this.dynamicLayoutVertexArray.serialize(transferables), + opacityVertexArray: this.opacityVertexArray && this.opacityVertexArray.serialize(transferables), + collisionVertexArray: this.collisionVertexArray && this.collisionVertexArray.serialize(transferables) }; } - upload(gl: WebGLRenderingContext) { + upload(gl: WebGLRenderingContext, dynamicIndexBuffer) { this.layoutVertexBuffer = new VertexBuffer(gl, this.layoutVertexArray); - this.indexBuffer = new IndexBuffer(gl, this.indexArray); + this.indexBuffer = new IndexBuffer(gl, this.indexArray, dynamicIndexBuffer); this.programConfigurations.upload(gl); if (this.programInterface.dynamicLayoutAttributes) { this.dynamicLayoutVertexBuffer = new VertexBuffer(gl, this.dynamicLayoutVertexArray, true); } + if (this.programInterface.opacityAttributes) { + this.opacityVertexBuffer = new VertexBuffer(gl, this.opacityVertexArray, true); + // This is a performance hack so that we can write to opacityVertexArray with uint32s + // even though the shaders read uint8s + this.opacityVertexBuffer.itemSize = 1; + this.opacityVertexBuffer.attributes = shaderOpacityAttributes; + } + if (this.programInterface.collisionAttributes) { + this.collisionVertexBuffer = new VertexBuffer(gl, this.collisionVertexArray, true); + } } destroy() { @@ -278,6 +319,12 @@ class SymbolBuffers { if (this.dynamicLayoutVertexBuffer) { this.dynamicLayoutVertexBuffer.destroy(); } + if (this.opacityVertexBuffer) { + this.opacityVertexBuffer.destroy(); + } + if (this.collisionVertexBuffer) { + this.collisionVertexBuffer.destroy(); + } } } @@ -294,20 +341,22 @@ class SymbolBuffers { * * 2. WorkerTile asynchronously requests from the main thread all of the glyphs * and icons needed (by this bucket and any others). When glyphs and icons - * have been received, the WorkerTile creates a CollisionTile and invokes: + * have been received, the WorkerTile creates a CollisionIndex and invokes: * - * 3. SymbolBucket#prepare(stacks, icons) to perform text shaping and layout, - * populating `this.symbolInstances` and `this.collisionBoxArray`. + * 3. performSymbolLayout(bucket, stacks, icons) perform texts shaping and + * layout on a Symbol Bucket. This step populates: + * `this.symbolInstances`: metadata on generated symbols + * `this.collisionBoxArray`: collision data for use by foreground + * `this.text`: SymbolBuffers for text symbols + * `this.icons`: SymbolBuffers for icons + * `this.collisionBox`: Debug SymbolBuffers for collision boxes + * `this.collisionCircle`: Debug SymbolBuffers for collision circles + * The results are sent to the foreground for rendering * - * 4. SymbolBucket#place(collisionTile): taking collisions into account, decide - * on which labels and icons to actually draw and at which scale, populating - * the vertex arrays (`this.arrays.glyph`, `this.arrays.icon`) and thus - * completing the parsing / buffer population process. - * - * The reason that `prepare` and `place` are separate methods is that - * `prepare`, being independent of pitch and orientation, only needs to happen - * at tile load time, whereas `place` must be invoked on already-loaded tiles - * when the pitch/orientation are changed. (See `redoPlacement`.) + * 4. performSymbolPlacement(bucket, collisionIndex) is run on the foreground, + * and uses the CollisionIndex along with current camera settings to determine + * which symbols can actually show on the map. Collided symbols are hidden + * using a dynamic "OpacityVertexArray". * * @private */ @@ -315,10 +364,11 @@ class SymbolBucket implements Bucket { static programInterfaces: { text: ProgramInterface, icon: ProgramInterface, - collisionBox: ProgramInterface + collisionBox: ProgramInterface, + collisionCircle: ProgramInterface }; - static MAX_INSTANCES: number; + static MAX_GLYPHS: number; static addDynamicAttributes: typeof addDynamicAttributes; collisionBoxArray: CollisionBoxArray; @@ -339,11 +389,15 @@ class SymbolBucket implements Bucket { pixelRatio: number; tilePixelRatio: number; compareText: {[string]: Array}; + fadeStartTime: number; + sortFeaturesByY: boolean; + sortedAngle: number; text: SymbolBuffers; icon: SymbolBuffers; collisionBox: SymbolBuffers; uploaded: boolean; + collisionCircle: SymbolBuffers; constructor(options: SymbolBucketParameters) { this.collisionBoxArray = options.collisionBoxArray; @@ -360,6 +414,7 @@ class SymbolBucket implements Bucket { this.text = new SymbolBuffers(symbolInterfaces.text, options.layers, options.zoom, options.text); this.icon = new SymbolBuffers(symbolInterfaces.icon, options.layers, options.zoom, options.icon); this.collisionBox = new SymbolBuffers(symbolInterfaces.collisionBox, options.layers, options.zoom, options.collisionBox); + this.collisionCircle = new SymbolBuffers(symbolInterfaces.collisionCircle, options.layers, options.zoom, options.collisionCircle); this.textSizeData = options.textSizeData; this.iconSizeData = options.iconSizeData; @@ -369,6 +424,12 @@ class SymbolBucket implements Bucket { this.glyphOffsetArray = new GlyphOffsetArray(options.glyphOffsetArray); this.lineVertexArray = new LineVertexArray(options.lineVertexArray); + this.symbolInstances = options.symbolInstances; + + const layout = options.layers[0].layout; + this.sortFeaturesByY = layout['text-allow-overlap'] || layout['icon-allow-overlap'] || + layout['text-ignore-placement'] || layout['icon-ignore-placement']; + } else { const layer = this.layers[0]; this.textSizeData = getSizeData(this.zoom, layer, 'text-size'); @@ -376,6 +437,18 @@ class SymbolBucket implements Bucket { } } + createArrays() { + this.text = new SymbolBuffers(symbolInterfaces.text, this.layers, this.zoom); + this.icon = new SymbolBuffers(symbolInterfaces.icon, this.layers, this.zoom); + this.collisionBox = new SymbolBuffers(symbolInterfaces.collisionBox, this.layers, this.zoom); + this.collisionCircle = new SymbolBuffers(symbolInterfaces.collisionCircle, this.layers, this.zoom); + + this.placedGlyphArray = new PlacedSymbolArray(); + this.placedIconArray = new PlacedSymbolArray(); + this.glyphOffsetArray = new GlyphOffsetArray(); + this.lineVertexArray = new LineVertexArray(); + } + populate(features: Array, options: PopulateParameters) { const layer: SymbolStyleLayer = this.layers[0]; const layout = layer.layout; @@ -461,10 +534,9 @@ class SymbolBucket implements Bucket { } } + isEmpty() { - return this.icon.layoutVertexArray.length === 0 && - this.text.layoutVertexArray.length === 0 && - this.collisionBox.layoutVertexArray.length === 0; + return this.symbolInstances.length === 0; } serialize(transferables?: Array) { @@ -481,387 +553,62 @@ class SymbolBucket implements Bucket { lineVertexArray: this.lineVertexArray.serialize(transferables), text: this.text.serialize(transferables), icon: this.icon.serialize(transferables), - collisionBox: this.collisionBox.serialize(transferables) + collisionBox: this.collisionBox.serialize(transferables), + collisionCircle: this.collisionCircle.serialize(transferables), + symbolInstances: this.symbolInstances }; } upload(gl: WebGLRenderingContext) { - this.text.upload(gl); - this.icon.upload(gl); + this.text.upload(gl, this.sortFeaturesByY); + this.icon.upload(gl, this.sortFeaturesByY); this.collisionBox.upload(gl); + this.collisionCircle.upload(gl); } destroy() { this.text.destroy(); this.icon.destroy(); this.collisionBox.destroy(); + this.collisionCircle.destroy(); } - prepare(glyphMap: {[string]: {[number]: ?StyleGlyph}}, - glyphPositions: {[string]: {[number]: GlyphPosition}}, - imageMap: {[string]: StyleImage}, - imagePositions: {[string]: ImagePosition}) { - this.symbolInstances = []; - - const tileSize = 512 * this.overscaling; - this.tilePixelRatio = EXTENT / tileSize; - this.compareText = {}; - this.iconsNeedLinear = false; - - const layout = this.layers[0].layout; - - const oneEm = 24; - const lineHeight = layout['text-line-height'] * oneEm; - - const fontstack = layout['text-font'].join(','); - const textAlongLine = layout['text-rotation-alignment'] === 'map' && layout['symbol-placement'] === 'line'; - const glyphs = glyphMap[fontstack] || {}; - const glyphPositionMap = glyphPositions[fontstack] || {}; - - for (const feature of this.features) { - - const shapedTextOrientations = {}; - const text = feature.text; - if (text) { - const textOffset = this.layers[0].getLayoutValue('text-offset', {zoom: this.zoom}, feature).map((t)=> t * oneEm); - const spacing = this.layers[0].getLayoutValue('text-letter-spacing', {zoom: this.zoom}, feature) * oneEm; - const spacingIfAllowed = scriptDetection.allowsLetterSpacing(text) ? spacing : 0; - const textAnchor = this.layers[0].getLayoutValue('text-anchor', {zoom: this.zoom}, feature); - const textJustify = this.layers[0].getLayoutValue('text-justify', {zoom: this.zoom}, feature); - const maxWidth = layout['symbol-placement'] !== 'line' ? - this.layers[0].getLayoutValue('text-max-width', {zoom: this.zoom}, feature) * oneEm : - 0; - - const applyShaping = (text: string, writingMode: 1 | 2) => { - return shapeText( - text, glyphs, maxWidth, lineHeight, textAnchor, textJustify, - spacingIfAllowed, textOffset, oneEm, writingMode); - }; - - shapedTextOrientations[WritingMode.horizontal] = applyShaping(text, WritingMode.horizontal); - - if (scriptDetection.allowsVerticalWritingMode(text) && textAlongLine) { - shapedTextOrientations[WritingMode.vertical] = applyShaping(text, WritingMode.vertical); - } - } - - let shapedIcon; - if (feature.icon) { - const image = imageMap[feature.icon]; - if (image) { - shapedIcon = shapeIcon( - imagePositions[feature.icon], - this.layers[0].getLayoutValue('icon-offset', {zoom: this.zoom}, feature), - this.layers[0].getLayoutValue('icon-anchor', {zoom: this.zoom}, feature)); - if (this.sdfIcons === undefined) { - this.sdfIcons = image.sdf; - } else if (this.sdfIcons !== image.sdf) { - util.warnOnce('Style sheet warning: Cannot mix SDF and non-SDF icons in one buffer'); - } - if (image.pixelRatio !== this.pixelRatio) { - this.iconsNeedLinear = true; - } else if (layout['icon-rotate'] !== 0 || !this.layers[0].isLayoutValueFeatureConstant('icon-rotate')) { - this.iconsNeedLinear = true; - } - } - } - - if (shapedTextOrientations[WritingMode.horizontal] || shapedIcon) { - this.addFeature(feature, shapedTextOrientations, shapedIcon, glyphPositionMap); - } - } - } - - /** - * Given a feature and its shaped text and icon data, add a 'symbol - * instance' for each _possible_ placement of the symbol feature. - * (SymbolBucket#place() selects which of these instances to send to the - * renderer based on collisions with symbols in other layers from the same - * source.) - * @private - */ - addFeature(feature: SymbolFeature, - shapedTextOrientations: ShapedTextOrientations, - shapedIcon: PositionedIcon | void, - glyphPositionMap: {[number]: GlyphPosition}) { - const layoutTextSize = this.layers[0].getLayoutValue('text-size', {zoom: this.zoom + 1}, feature); - const layoutIconSize = this.layers[0].getLayoutValue('icon-size', {zoom: this.zoom + 1}, feature); - - const textOffset = this.layers[0].getLayoutValue('text-offset', {zoom: this.zoom }, feature); - const iconOffset = this.layers[0].getLayoutValue('icon-offset', {zoom: this.zoom }, feature); - - // To reduce the number of labels that jump around when zooming we need - // to use a text-size value that is the same for all zoom levels. - // This calculates text-size at a high zoom level so that all tiles can - // use the same value when calculating anchor positions. - let textMaxSize = this.layers[0].getLayoutValue('text-size', {zoom: 18}, feature); - if (textMaxSize === undefined) { - textMaxSize = layoutTextSize; - } - - const layout = this.layers[0].layout, - glyphSize = 24, - fontScale = layoutTextSize / glyphSize, - textBoxScale = this.tilePixelRatio * fontScale, - textMaxBoxScale = this.tilePixelRatio * textMaxSize / glyphSize, - iconBoxScale = this.tilePixelRatio * layoutIconSize, - symbolMinDistance = this.tilePixelRatio * layout['symbol-spacing'], - avoidEdges = layout['symbol-avoid-edges'], - textPadding = layout['text-padding'] * this.tilePixelRatio, - iconPadding = layout['icon-padding'] * this.tilePixelRatio, - textMaxAngle = layout['text-max-angle'] / 180 * Math.PI, - textAlongLine = layout['text-rotation-alignment'] === 'map' && layout['symbol-placement'] === 'line', - iconAlongLine = layout['icon-rotation-alignment'] === 'map' && layout['symbol-placement'] === 'line', - mayOverlap = layout['text-allow-overlap'] || layout['icon-allow-overlap'] || - layout['text-ignore-placement'] || layout['icon-ignore-placement'], - symbolPlacement = layout['symbol-placement'], - textRepeatDistance = symbolMinDistance / 2; - - const addSymbolInstance = (line, anchor) => { - const inside = !(anchor.x < 0 || anchor.x > EXTENT || anchor.y < 0 || anchor.y > EXTENT); - - if (avoidEdges && !inside) return; - - // Normally symbol layers are drawn across tile boundaries. Only symbols - // with their anchors within the tile boundaries are added to the buffers - // to prevent symbols from being drawn twice. - // - // Symbols in layers with overlap are sorted in the y direction so that - // symbols lower on the canvas are drawn on top of symbols near the top. - // To preserve this order across tile boundaries these symbols can't - // be drawn across tile boundaries. Instead they need to be included in - // the buffers for both tiles and clipped to tile boundaries at draw time. - const addToBuffers = inside || mayOverlap; - this.addSymbolInstance(anchor, line, shapedTextOrientations, shapedIcon, this.layers[0], - addToBuffers, this.collisionBoxArray, feature.index, feature.sourceLayerIndex, this.index, - textBoxScale, textPadding, textAlongLine, textOffset, - iconBoxScale, iconPadding, iconAlongLine, iconOffset, - {zoom: this.zoom}, feature, glyphPositionMap); - }; - - if (symbolPlacement === 'line') { - for (const line of clipLine(feature.geometry, 0, 0, EXTENT, EXTENT)) { - const anchors = getAnchors( - line, - symbolMinDistance, - textMaxAngle, - shapedTextOrientations[WritingMode.vertical] || shapedTextOrientations[WritingMode.horizontal], - shapedIcon, - glyphSize, - textMaxBoxScale, - this.overscaling, - EXTENT - ); - for (const anchor of anchors) { - const shapedText = shapedTextOrientations[WritingMode.horizontal]; - if (!shapedText || !this.anchorIsTooClose(shapedText.text, textRepeatDistance, anchor)) { - addSymbolInstance(line, anchor); - } + addToLineVertexArray(anchor: Anchor, line: any) { + const lineStartIndex = this.lineVertexArray.length; + if (anchor.segment !== undefined) { + let sumForwardLength = anchor.dist(line[anchor.segment + 1]); + let sumBackwardLength = anchor.dist(line[anchor.segment]); + const vertices = {}; + for (let i = anchor.segment + 1; i < line.length; i++) { + vertices[i] = { x: line[i].x, y: line[i].y, tileUnitDistanceFromAnchor: sumForwardLength }; + if (i < line.length - 1) { + sumForwardLength += line[i + 1].dist(line[i]); } } - } else if (feature.type === 'Polygon') { - for (const polygon of classifyRings(feature.geometry, 0)) { - // 16 here represents 2 pixels - const poi = findPoleOfInaccessibility(polygon, 16); - addSymbolInstance(polygon[0], new Anchor(poi.x, poi.y, 0)); - } - } else if (feature.type === 'LineString') { - // https://github.com/mapbox/mapbox-gl-js/issues/3808 - for (const line of feature.geometry) { - addSymbolInstance(line, new Anchor(line[0].x, line[0].y, 0)); - } - } else if (feature.type === 'Point') { - for (const points of feature.geometry) { - for (const point of points) { - addSymbolInstance([point], new Anchor(point.x, point.y, 0)); + for (let i = anchor.segment || 0; i >= 0; i--) { + vertices[i] = { x: line[i].x, y: line[i].y, tileUnitDistanceFromAnchor: sumBackwardLength }; + if (i > 0) { + sumBackwardLength += line[i - 1].dist(line[i]); } } - } - } - - anchorIsTooClose(text: string, repeatDistance: number, anchor: Point) { - const compareText = this.compareText; - if (!(text in compareText)) { - compareText[text] = []; - } else { - const otherAnchors = compareText[text]; - for (let k = otherAnchors.length - 1; k >= 0; k--) { - if (anchor.dist(otherAnchors[k]) < repeatDistance) { - // If it's within repeatDistance of one anchor, stop looking - return true; - } - } - } - // If anchor is not within repeatDistance of any other anchor, add to array - compareText[text].push(anchor); - return false; - } - - place(collisionTile: CollisionTile, showCollisionBoxes: boolean) { - // Calculate which labels can be shown and when they can be shown and - // create the bufers used for rendering. - - this.text = new SymbolBuffers(symbolInterfaces.text, this.layers, this.zoom); - this.icon = new SymbolBuffers(symbolInterfaces.icon, this.layers, this.zoom); - this.collisionBox = new SymbolBuffers(symbolInterfaces.collisionBox, this.layers, this.zoom); - - this.placedGlyphArray = new PlacedSymbolArray(); - this.placedIconArray = new PlacedSymbolArray(); - this.glyphOffsetArray = new GlyphOffsetArray(); - this.lineVertexArray = new LineVertexArray(); - - const layer = this.layers[0]; - const layout = layer.layout; - - // Symbols that don't show until greater than the CollisionTile's maxScale won't even be added - // to the buffers. Even though pan operations on a tilted map might cause the symbol to be - // displayable, we have to stay conservative here because the CollisionTile didn't consider - // this scale range. - const maxScale = collisionTile.maxScale; - - const textAlongLine = layout['text-rotation-alignment'] === 'map' && layout['symbol-placement'] === 'line'; - const iconAlongLine = layout['icon-rotation-alignment'] === 'map' && layout['symbol-placement'] === 'line'; - - const mayOverlap = layout['text-allow-overlap'] || layout['icon-allow-overlap'] || - layout['text-ignore-placement'] || layout['icon-ignore-placement']; - - // Sort symbols by their y position on the canvas so that the lower symbols - // are drawn on top of higher symbols. - // Don't sort symbols that won't overlap because it isn't necessary and - // because it causes more labels to pop in and out when rotating. - if (mayOverlap) { - const angle = collisionTile.angle; - - const sin = Math.sin(angle), - cos = Math.cos(angle); - - this.symbolInstances.sort((a, b) => { - const aRotated = (sin * a.anchor.x + cos * a.anchor.y) | 0; - const bRotated = (sin * b.anchor.x + cos * b.anchor.y) | 0; - return (aRotated - bRotated) || (b.featureIndex - a.featureIndex); - }); - } - - for (const symbolInstance of this.symbolInstances) { - const textCollisionFeature = { - boxStartIndex: symbolInstance.textBoxStartIndex, - boxEndIndex: symbolInstance.textBoxEndIndex - }; - const iconCollisionFeature = { - boxStartIndex: symbolInstance.iconBoxStartIndex, - boxEndIndex: symbolInstance.iconBoxEndIndex - }; - - const hasText = !(symbolInstance.textBoxStartIndex === symbolInstance.textBoxEndIndex); - const hasIcon = !(symbolInstance.iconBoxStartIndex === symbolInstance.iconBoxEndIndex); - - const iconWithoutText = layout['text-optional'] || !hasText, - textWithoutIcon = layout['icon-optional'] || !hasIcon; - - - // Calculate the scales at which the text and icon can be placed without collision. - - let glyphScale = hasText ? - collisionTile.placeCollisionFeature(textCollisionFeature, - layout['text-allow-overlap'], layout['symbol-avoid-edges']) : - collisionTile.minScale; - - let iconScale = hasIcon ? - collisionTile.placeCollisionFeature(iconCollisionFeature, - layout['icon-allow-overlap'], layout['symbol-avoid-edges']) : - collisionTile.minScale; - - - // Combine the scales for icons and text. - - if (!iconWithoutText && !textWithoutIcon) { - iconScale = glyphScale = Math.max(iconScale, glyphScale); - } else if (!textWithoutIcon && glyphScale) { - glyphScale = Math.max(iconScale, glyphScale); - } else if (!iconWithoutText && iconScale) { - iconScale = Math.max(iconScale, glyphScale); - } - - - // Insert final placement into collision tree and add glyphs/icons to buffers - if (!hasText && !hasIcon) continue; - const line = symbolInstance.line; - const lineStartIndex = this.lineVertexArray.length; for (let i = 0; i < line.length; i++) { - this.lineVertexArray.emplaceBack(line[i].x, line[i].y); + const vertex = vertices[i]; + this.lineVertexArray.emplaceBack(vertex.x, vertex.y, vertex.tileUnitDistanceFromAnchor); } - const lineLength = this.lineVertexArray.length - lineStartIndex; - - - if (hasText) { - collisionTile.insertCollisionFeature(textCollisionFeature, glyphScale, layout['text-ignore-placement']); - if (glyphScale <= maxScale) { - const textSizeData = getSizeVertexData(layer, - this.zoom, - this.textSizeData, - 'text-size', - symbolInstance.feature); - this.addSymbols( - this.text, - symbolInstance.glyphQuads, - glyphScale, - textSizeData, - layout['text-keep-upright'], - symbolInstance.textOffset, - textAlongLine, - collisionTile.angle, - symbolInstance.feature, - symbolInstance.writingModes, - symbolInstance.anchor, - lineStartIndex, - lineLength, - this.placedGlyphArray); - } - } - - if (hasIcon) { - collisionTile.insertCollisionFeature(iconCollisionFeature, iconScale, layout['icon-ignore-placement']); - if (iconScale <= maxScale) { - const iconSizeData = getSizeVertexData( - layer, - this.zoom, - this.iconSizeData, - 'icon-size', - symbolInstance.feature); - this.addSymbols( - this.icon, - symbolInstance.iconQuads, - iconScale, - iconSizeData, - layout['icon-keep-upright'], - symbolInstance.iconOffset, - iconAlongLine, - collisionTile.angle, - symbolInstance.feature, - 0, - symbolInstance.anchor, - lineStartIndex, - lineLength, - this.placedIconArray - ); - } - } - } - - if (showCollisionBoxes) this.addToDebugBuffers(collisionTile); + return { + lineStartIndex: lineStartIndex, + lineLength: this.lineVertexArray.length - lineStartIndex + }; } addSymbols(arrays: SymbolBuffers, quads: Array, - scale: number, sizeVertex: any, - keepUpright: boolean, lineOffset: [number, number], alongLine: boolean, - placementAngle: number, feature: ExpressionFeature, - writingModes: number, + writingMode: any, labelAnchor: Anchor, lineStartIndex: number, lineLength: number, @@ -870,31 +617,18 @@ class SymbolBucket implements Bucket { const layoutVertexArray = arrays.layoutVertexArray; const dynamicLayoutVertexArray = arrays.dynamicLayoutVertexArray; - const zoom = this.zoom; - const placementZoom = Math.max(Math.log(scale) / Math.LN2 + zoom, 0); - + const segment = arrays.segments.prepareSegment(4 * quads.length, arrays.layoutVertexArray, arrays.indexArray); const glyphOffsetArrayStart = this.glyphOffsetArray.length; - - const labelAngle = ((labelAnchor.angle + placementAngle) + 2 * Math.PI) % (2 * Math.PI); - const inVerticalRange = ( - (labelAngle > Math.PI * 1 / 4 && labelAngle <= Math.PI * 3 / 4) || - (labelAngle > Math.PI * 5 / 4 && labelAngle <= Math.PI * 7 / 4)); - const useVerticalMode = Boolean(writingModes & WritingMode.vertical) && inVerticalRange; + const vertexStartIndex = segment.vertexLength; for (const symbol of quads) { - if (alongLine && keepUpright) { - // drop incorrectly oriented glyphs - if ((symbol.writingMode === WritingMode.vertical) !== useVerticalMode) continue; - } - const tl = symbol.tl, tr = symbol.tr, bl = symbol.bl, br = symbol.br, tex = symbol.tex; - const segment = arrays.segments.prepareSegment(4, arrays.layoutVertexArray, arrays.indexArray); const index = segment.vertexLength; const y = symbol.glyphOffset[1]; @@ -903,7 +637,7 @@ class SymbolBucket implements Bucket { addVertex(layoutVertexArray, labelAnchor.x, labelAnchor.y, bl.x, y + bl.y, tex.x, tex.y + tex.h, sizeVertex); addVertex(layoutVertexArray, labelAnchor.x, labelAnchor.y, br.x, y + br.y, tex.x + tex.w, tex.y + tex.h, sizeVertex); - addDynamicAttributes(dynamicLayoutVertexArray, labelAnchor, 0, placementZoom); + addDynamicAttributes(dynamicLayoutVertexArray, labelAnchor, 0); indexArray.emplaceBack(index, index + 1, index + 2); indexArray.emplaceBack(index + 1, index + 2, index + 3); @@ -915,23 +649,60 @@ class SymbolBucket implements Bucket { } placedSymbolArray.emplaceBack(labelAnchor.x, labelAnchor.y, - glyphOffsetArrayStart, this.glyphOffsetArray.length - glyphOffsetArrayStart, + glyphOffsetArrayStart, this.glyphOffsetArray.length - glyphOffsetArrayStart, vertexStartIndex, lineStartIndex, lineLength, labelAnchor.segment, sizeVertex ? sizeVertex[0] : 0, sizeVertex ? sizeVertex[1] : 0, lineOffset[0], lineOffset[1], - placementZoom, useVerticalMode); + writingMode, false); arrays.programConfigurations.populatePaintArrays(arrays.layoutVertexArray.length, feature); } - addToDebugBuffers(collisionTile: CollisionTile) { - const arrays = this.collisionBox; + _addCollisionDebugVertex(layoutVertexArray: StructArray, collisionVertexArray: StructArray, point: Point, anchor: Point, extrude: Point) { + collisionVertexArray.emplaceBack(0, 0); + return layoutVertexArray.emplaceBack( + // pos + point.x, + point.y, + // a_anchor_pos + anchor.x, + anchor.y, + // extrude + Math.round(extrude.x), + Math.round(extrude.y)); + } + + + addCollisionDebugVertices(x1: number, y1: number, x2: number, y2: number, arrays: SymbolBuffers, boxAnchorPoint: Point, symbolInstance: SymbolInstance, isCircle: boolean) { + const segment = arrays.segments.prepareSegment(4, arrays.layoutVertexArray, arrays.indexArray); + const index = segment.vertexLength; + const layoutVertexArray = arrays.layoutVertexArray; const indexArray = arrays.indexArray; + const collisionVertexArray = arrays.collisionVertexArray; + + this._addCollisionDebugVertex(layoutVertexArray, collisionVertexArray, boxAnchorPoint, symbolInstance.anchor, new Point(x1, y1)); + this._addCollisionDebugVertex(layoutVertexArray, collisionVertexArray, boxAnchorPoint, symbolInstance.anchor, new Point(x2, y1)); + this._addCollisionDebugVertex(layoutVertexArray, collisionVertexArray, boxAnchorPoint, symbolInstance.anchor, new Point(x2, y2)); + this._addCollisionDebugVertex(layoutVertexArray, collisionVertexArray, boxAnchorPoint, symbolInstance.anchor, new Point(x1, y2)); - const angle = -collisionTile.angle; - const yStretch = collisionTile.yStretch; + segment.vertexLength += 4; + if (isCircle) { + indexArray.emplaceBack(index, index + 1, index + 2); + indexArray.emplaceBack(index, index + 2, index + 3); + segment.primitiveLength += 2; + } else { + indexArray.emplaceBack(index, index + 1); + indexArray.emplaceBack(index + 1, index + 2); + indexArray.emplaceBack(index + 2, index + 3); + indexArray.emplaceBack(index + 3, index); + + segment.primitiveLength += 4; + } + } + + generateCollisionDebugBuffers() { for (const symbolInstance of this.symbolInstances) { symbolInstance.textCollisionFeature = {boxStartIndex: symbolInstance.textBoxStartIndex, boxEndIndex: symbolInstance.textBoxEndIndex}; symbolInstance.iconCollisionFeature = {boxStartIndex: symbolInstance.iconBoxStartIndex, boxEndIndex: symbolInstance.iconBoxEndIndex}; @@ -942,178 +713,118 @@ class SymbolBucket implements Bucket { for (let b = feature.boxStartIndex; b < feature.boxEndIndex; b++) { const box: CollisionBox = (this.collisionBoxArray.get(b): any); - if (collisionTile.perspectiveRatio === 1 && box.maxScale < 1) { - // These boxes aren't used on unpitched maps - // See CollisionTile#insertCollisionFeature - continue; - } - const boxAnchorPoint = box.anchorPoint; - - const tl = new Point(box.x1, box.y1 * yStretch)._rotate(angle); - const tr = new Point(box.x2, box.y1 * yStretch)._rotate(angle); - const bl = new Point(box.x1, box.y2 * yStretch)._rotate(angle); - const br = new Point(box.x2, box.y2 * yStretch)._rotate(angle); - - const maxZoom = Math.max(0, Math.min(25, this.zoom + Math.log(box.maxScale) / Math.LN2)); - const placementZoom = Math.max(0, Math.min(25, this.zoom + Math.log(box.placementScale) / Math.LN2)); - - const segment = arrays.segments.prepareSegment(4, arrays.layoutVertexArray, arrays.indexArray); - const index = segment.vertexLength; - - addCollisionBoxVertex(layoutVertexArray, boxAnchorPoint, symbolInstance.anchor, tl, maxZoom, placementZoom); - addCollisionBoxVertex(layoutVertexArray, boxAnchorPoint, symbolInstance.anchor, tr, maxZoom, placementZoom); - addCollisionBoxVertex(layoutVertexArray, boxAnchorPoint, symbolInstance.anchor, br, maxZoom, placementZoom); - addCollisionBoxVertex(layoutVertexArray, boxAnchorPoint, symbolInstance.anchor, bl, maxZoom, placementZoom); - - indexArray.emplaceBack(index, index + 1); - indexArray.emplaceBack(index + 1, index + 2); - indexArray.emplaceBack(index + 2, index + 3); - indexArray.emplaceBack(index + 3, index); - - segment.vertexLength += 4; - segment.primitiveLength += 4; + const x1 = box.x1; + const y1 = box.y1; + const x2 = box.x2; + const y2 = box.y2; + + // If the radius > 0, this collision box is actually a circle + // The data we add to the buffers is exactly the same, but we'll render with a different shader. + const isCircle = box.radius > 0; + this.addCollisionDebugVertices(x1, y1, x2, y2, isCircle ? this.collisionCircle : this.collisionBox, box.anchorPoint, symbolInstance, isCircle); } } } } - /** - * Add a single label & icon placement. - * - * Note that in the case of `symbol-placement: line`, the symbol instance's - * array of glyph 'quads' may include multiple copies of each glyph, - * corresponding to the different orientations it might take at different - * zoom levels as the text goes around bends in the line. - * - * As such, each glyph quad includes a minzoom and maxzoom at which it - * should be rendered. This zoom range is calculated based on the 'layout' - * {text,icon} size -- i.e. text/icon-size at `z: tile.zoom + 1`. If the - * size is zoom-dependent, then the zoom range is adjusted at render time - * to account for the difference. - * - * @private - */ - addSymbolInstance(anchor: Anchor, - line: Array, - shapedTextOrientations: ShapedTextOrientations, - shapedIcon: PositionedIcon | void, - layer: SymbolStyleLayer, - addToBuffers: boolean, - collisionBoxArray: CollisionBoxArray, - featureIndex: number, - sourceLayerIndex: number, - bucketIndex: number, - textBoxScale: number, - textPadding: number, - textAlongLine: boolean, - textOffset: [number, number], - iconBoxScale: number, - iconPadding: number, - iconAlongLine: boolean, - iconOffset: [number, number], - globalProperties: Object, - feature: SymbolFeature, - glyphPositionMap: {[number]: GlyphPosition}) { - - let textCollisionFeature, iconCollisionFeature; - let iconQuads = []; - let glyphQuads = []; - for (const writingModeString in shapedTextOrientations) { - const writingMode = parseInt(writingModeString, 10); - if (!shapedTextOrientations[writingMode]) continue; - glyphQuads = glyphQuads.concat(addToBuffers ? - getGlyphQuads(anchor, shapedTextOrientations[writingMode], - layer, textAlongLine, globalProperties, feature, glyphPositionMap) : - []); - textCollisionFeature = new CollisionFeature(collisionBoxArray, - line, - anchor, - featureIndex, - sourceLayerIndex, - bucketIndex, - shapedTextOrientations[writingMode], - textBoxScale, - textPadding, - textAlongLine, - false); + // These flat arrays are meant to be quicker to iterate over than the source + // CollisionBoxArray + deserializeCollisionBoxes(collisionBoxArray: CollisionBoxArray, textStartIndex: number, textEndIndex: number, iconStartIndex: number, iconEndIndex: number): CollisionArrays { + const collisionArrays = {}; + for (let k = textStartIndex; k < textEndIndex; k++) { + const box: CollisionBox = (collisionBoxArray.get(k): any); + if (box.radius === 0) { + collisionArrays.textBox = { x1: box.x1, y1: box.y1, x2: box.x2, y2: box.y2, anchorPointX: box.anchorPointX, anchorPointY: box.anchorPointY }; + + break; // Only one box allowed per instance + } else { + if (!collisionArrays.textCircles) { + collisionArrays.textCircles = []; + } + const used = 1; // May be updated at collision detection time + collisionArrays.textCircles.push(box.anchorPointX, box.anchorPointY, box.radius, box.signedDistanceFromAnchor, used); + } } - - const textBoxStartIndex = textCollisionFeature ? textCollisionFeature.boxStartIndex : this.collisionBoxArray.length; - const textBoxEndIndex = textCollisionFeature ? textCollisionFeature.boxEndIndex : this.collisionBoxArray.length; - - if (shapedIcon) { - iconQuads = addToBuffers ? - getIconQuads(anchor, shapedIcon, layer, - iconAlongLine, shapedTextOrientations[WritingMode.horizontal], - globalProperties, feature) : - []; - iconCollisionFeature = new CollisionFeature(collisionBoxArray, - line, - anchor, - featureIndex, - sourceLayerIndex, - bucketIndex, - shapedIcon, - iconBoxScale, - iconPadding, - iconAlongLine, - true); + for (let k = iconStartIndex; k < iconEndIndex; k++) { + // An icon can only have one box now, so this indexing is a bit vestigial... + const box: CollisionBox = (collisionBoxArray.get(k): any); + if (box.radius === 0) { + collisionArrays.iconBox = { x1: box.x1, y1: box.y1, x2: box.x2, y2: box.y2, anchorPointX: box.anchorPointX, anchorPointY: box.anchorPointY }; + break; // Only one box allowed per instance + } } + return collisionArrays; + } - const iconBoxStartIndex = iconCollisionFeature ? iconCollisionFeature.boxStartIndex : this.collisionBoxArray.length; - const iconBoxEndIndex = iconCollisionFeature ? iconCollisionFeature.boxEndIndex : this.collisionBoxArray.length; + sortFeatures(angle: number) { + if (!this.sortFeaturesByY) return; - if (textBoxEndIndex > SymbolBucket.MAX_INSTANCES) { - util.warnOnce("Too many symbols being rendered in a tile. See https://github.com/mapbox/mapbox-gl-js/issues/2907"); - } - if (iconBoxEndIndex > SymbolBucket.MAX_INSTANCES) { - util.warnOnce("Too many glyphs being rendered in a tile. See https://github.com/mapbox/mapbox-gl-js/issues/2907"); + if (this.sortedAngle === angle) return; + this.sortedAngle = angle; + + // The current approach to sorting doesn't sort across segments so don't try. + // Sorting within segments separately seemed not to be worth the complexity. + if (this.text.segments.get().length > 1 || this.icon.segments.get().length > 1) return; + + // If the symbols are allowed to overlap sort them by their vertical screen position. + // The index array buffer is rewritten to reference the (unchanged) vertices in the + // sorted order. + + // To avoid sorting the actual symbolInstance array we sort an array of indexes. + const symbolInstanceIndexes = []; + for (let i = 0; i < this.symbolInstances.length; i++) { + symbolInstanceIndexes.push(i); } - const writingModes = ( - (shapedTextOrientations[WritingMode.vertical] ? WritingMode.vertical : 0) | - (shapedTextOrientations[WritingMode.horizontal] ? WritingMode.horizontal : 0) - ); - - this.symbolInstances.push({ - textBoxStartIndex, - textBoxEndIndex, - iconBoxStartIndex, - iconBoxEndIndex, - glyphQuads, - iconQuads, - textOffset, - iconOffset, - anchor, - line, - featureIndex, - feature, - writingModes + const sin = Math.sin(angle), + cos = Math.cos(angle); + + symbolInstanceIndexes.sort((aIndex, bIndex) => { + const a = this.symbolInstances[aIndex]; + const b = this.symbolInstances[bIndex]; + const aRotated = (sin * a.anchor.x + cos * a.anchor.y) | 0; + const bRotated = (sin * b.anchor.x + cos * b.anchor.y) | 0; + return (aRotated - bRotated) || (b.featureIndex - a.featureIndex); }); - } -} -function getSizeVertexData(layer: SymbolStyleLayer, tileZoom: number, sizeData: SizeData, sizeProperty, feature) { - if (sizeData.functionType === 'source') { - return [ - 10 * layer.getLayoutValue(sizeProperty, ({}: any), feature) - ]; - } else if (sizeData.functionType === 'composite') { - const zoomRange = sizeData.coveringZoomRange; - return [ - 10 * layer.getLayoutValue(sizeProperty, {zoom: zoomRange[0]}, feature), - 10 * layer.getLayoutValue(sizeProperty, {zoom: zoomRange[1]}, feature) - ]; + this.text.indexArray.clear(); + this.icon.indexArray.clear(); + + for (const i of symbolInstanceIndexes) { + const symbolInstance = this.symbolInstances[i]; + + for (const placedTextSymbolIndex of symbolInstance.placedTextSymbolIndices) { + const placedSymbol = (this.placedGlyphArray.get(placedTextSymbolIndex): any); + + const endIndex = placedSymbol.vertexStartIndex + placedSymbol.numGlyphs * 4; + for (let vertexIndex = placedSymbol.vertexStartIndex; vertexIndex < endIndex; vertexIndex += 4) { + this.text.indexArray.emplaceBack(vertexIndex, vertexIndex + 1, vertexIndex + 2); + this.text.indexArray.emplaceBack(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3); + } + } + + const placedIcon = (this.placedIconArray.get(i): any); + if (placedIcon.numGlyphs) { + const vertexIndex = placedIcon.vertexStartIndex; + this.icon.indexArray.emplaceBack(vertexIndex, vertexIndex + 1, vertexIndex + 2); + this.icon.indexArray.emplaceBack(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3); + } + } + + if (this.text.indexBuffer) this.text.indexBuffer.updateData(this.text.indexArray.serialize()); + if (this.icon.indexBuffer) this.icon.indexBuffer.updateData(this.icon.indexArray.serialize()); } - return null; } SymbolBucket.programInterfaces = symbolInterfaces; // this constant is based on the size of StructArray indexes used in a symbol -// bucket--namely, iconBoxEndIndex and textBoxEndIndex +// bucket--namely, glyphOffsetArrayStart // eg the max valid UInt16 is 65,535 -SymbolBucket.MAX_INSTANCES = 65535; +// See https://github.com/mapbox/mapbox-gl-js/issues/2907 for motivation +// lineStartIndex and textBoxStartIndex could potentially be concerns +// but we expect there to be many fewer boxes/lines than glyphs +SymbolBucket.MAX_GLYPHS = 65535; SymbolBucket.addDynamicAttributes = addDynamicAttributes; diff --git a/src/data/feature_index.js b/src/data/feature_index.js index aa10d50652e..49d9b5620b7 100644 --- a/src/data/feature_index.js +++ b/src/data/feature_index.js @@ -11,9 +11,9 @@ const vt = require('@mapbox/vector-tile'); const Protobuf = require('pbf'); const GeoJSONFeature = require('../util/vectortile_to_geojson'); const arraysIntersect = require('../util/util').arraysIntersect; +const TileCoord = require('../source/tile_coord'); -import type CollisionTile from '../symbol/collision_tile'; -import type TileCoord from '../source/tile_coord'; +import type CollisionIndex from '../symbol/collision_index'; import type StyleLayer from '../style/style_layer'; import type {SerializedStructArray} from '../util/struct_array'; import type {FeatureFilter} from '../style-spec/feature_filter'; @@ -38,7 +38,9 @@ type QueryParameters = { params: { filter: FilterSpecification, layers: Array, - } + }, + tileSourceMaxZoom: number, + collisionBoxArray: any } export type SerializedFeatureIndex = { @@ -61,22 +63,22 @@ class FeatureIndex { rawTileData: ArrayBuffer; bucketLayerIDs: Array>; - collisionTile: CollisionTile; vtLayers: {[string]: VectorTileLayer}; sourceLayerCoder: DictionaryCoder; + collisionIndex: CollisionIndex; + static deserialize(serialized: SerializedFeatureIndex, - rawTileData: ArrayBuffer, - collisionTile: CollisionTile) { + rawTileData: ArrayBuffer) { + const coord = serialized.coord; const self = new FeatureIndex( - serialized.coord, + new TileCoord(coord.z, coord.x, coord.y, coord.w), serialized.overscaling, new Grid(serialized.grid), new FeatureIndexArray(serialized.featureIndexArray)); self.rawTileData = rawTileData; self.bucketLayerIDs = serialized.bucketLayerIDs; - self.setCollisionTile(collisionTile); return self; } @@ -114,8 +116,8 @@ class FeatureIndex { } } - setCollisionTile(collisionTile: CollisionTile) { - this.collisionTile = collisionTile; + setCollisionIndex(collisionIndex: CollisionIndex) { + this.collisionIndex = collisionIndex; } serialize(transferables?: Array): SerializedFeatureIndex { @@ -167,9 +169,11 @@ class FeatureIndex { matching.sort(topDownFeatureComparator); this.filterMatching(result, matching, this.featureIndexArray, queryGeometry, filter, params.layers, styleLayers, args.bearing, pixelsToTileUnits); - const matchingSymbols = this.collisionTile.queryRenderedSymbols(queryGeometry, args.scale); + const matchingSymbols = this.collisionIndex ? + this.collisionIndex.queryRenderedSymbols(queryGeometry, this.coord, args.tileSourceMaxZoom, EXTENT / args.tileSize, args.collisionBoxArray) : + []; matchingSymbols.sort(); - this.filterMatching(result, matchingSymbols, this.collisionTile.collisionBoxArray, queryGeometry, filter, params.layers, styleLayers, args.bearing, pixelsToTileUnits); + this.filterMatching(result, matchingSymbols, args.collisionBoxArray, queryGeometry, filter, params.layers, styleLayers, args.bearing, pixelsToTileUnits); return result; } diff --git a/src/data/program_configuration.js b/src/data/program_configuration.js index 5d709ea0d60..f4e5e860ec6 100644 --- a/src/data/program_configuration.js +++ b/src/data/program_configuration.js @@ -29,6 +29,8 @@ export type ProgramInterface = { layoutAttributes: Array, indexArrayType: Class, dynamicLayoutAttributes?: Array, + opacityAttributes?: Array, + collisionAttributes?: Array, paintAttributes?: Array, indexArrayType2?: Class } diff --git a/src/geo/transform.js b/src/geo/transform.js index b7dd78d356a..ce46887ef6c 100644 --- a/src/geo/transform.js +++ b/src/geo/transform.js @@ -43,6 +43,8 @@ class Transform { _maxZoom: number; _center: LngLat; _constraining: boolean; + _posMatrixCache: {[string]: Float32Array}; + constructor(minZoom: ?number, maxZoom: ?number, renderWorldCopies: boolean | void) { this.tileSize = 512; // constant @@ -60,6 +62,23 @@ class Transform { this._fov = 0.6435011087932844; this._pitch = 0; this._unmodified = true; + this._posMatrixCache = {}; + } + + clone(): Transform { + const clone = new Transform(this._minZoom, this._maxZoom, this._renderWorldCopies); + clone.tileSize = this.tileSize; + clone.latRange = this.latRange; + clone.width = this.width; + clone.height = this.height; + clone._center = this._center; + clone.zoom = this.zoom; + clone.angle = this.angle; + clone._fov = this._fov; + clone._pitch = this._pitch; + clone._unmodified = this._unmodified; + clone._calcMatrices(); + return clone; } get minZoom(): number { return this._minZoom; } @@ -379,7 +398,14 @@ class Transform { * @param {TileCoord} tileCoord * @param {number} maxZoom maximum source zoom to account for overscaling */ - calculatePosMatrix(tileCoord: TileCoord, maxZoom?: number) { + calculatePosMatrix(tileCoord: TileCoord, maxZoom?: number): Float32Array { + let posMatrixKey = tileCoord.id.toString(); + if (maxZoom) { + posMatrixKey += maxZoom.toString(); + } + if (this._posMatrixCache[posMatrixKey]) { + return this._posMatrixCache[posMatrixKey]; + } // if z > maxzoom then the tile is actually a overscaled maxzoom tile, // so calculate the matrix the maxzoom tile would use. const coord = tileCoord.toCoordinate(maxZoom); @@ -390,22 +416,8 @@ class Transform { mat4.scale(posMatrix, posMatrix, [scale / EXTENT, scale / EXTENT, 1]); mat4.multiply(posMatrix, this.projMatrix, posMatrix); - return new Float32Array(posMatrix); - } - - /** - * Calculate the distance from the center of a tile to the camera - * These distances are in view-space dimensions derived from the size of the - * viewport, similar to this.cameraToCenterDistance - * If the tile is dead-center in the viewport, then cameraToTileDistance == cameraToCenterDistance - * - * @param {Tile} tile - */ - cameraToTileDistance(tile: Object) { - const posMatrix = this.calculatePosMatrix(tile.coord, tile.sourceMaxZoom); - const tileCenter = [tile.tileSize / 2, tile.tileSize / 2, 0, 1]; - vec4.transformMat4(tileCenter, tileCenter, posMatrix); - return tileCenter[3]; + this._posMatrixCache[posMatrixKey] = new Float32Array(posMatrix); + return this._posMatrixCache[posMatrixKey]; } _constrain() { @@ -521,6 +533,7 @@ class Transform { if (!m) throw new Error("failed to invert matrix"); this.pixelMatrixInverse = m; + this._posMatrixCache = {}; } } diff --git a/src/gl/index_buffer.js b/src/gl/index_buffer.js index 2489df35b94..abc2986f85d 100644 --- a/src/gl/index_buffer.js +++ b/src/gl/index_buffer.js @@ -1,14 +1,19 @@ // @flow +const assert = require('assert'); import type {TriangleIndexArray, LineIndexArray} from '../data/index_array_type'; +import type {SerializedStructArray} from '../util/struct_array'; + class IndexBuffer { gl: WebGLRenderingContext; buffer: WebGLBuffer; + dynamicDraw: boolean; - constructor(gl: WebGLRenderingContext, array: TriangleIndexArray | LineIndexArray) { + constructor(gl: WebGLRenderingContext, array: TriangleIndexArray | LineIndexArray, dynamicDraw?: boolean) { this.gl = gl; this.buffer = gl.createBuffer(); + this.dynamicDraw = Boolean(dynamicDraw); // The bound index buffer is part of vertex array object state. We don't want to // modify whatever VAO happens to be currently bound, so make sure the default @@ -21,14 +26,23 @@ class IndexBuffer { } gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.buffer); - gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, array.arrayBuffer, gl.STATIC_DRAW); - delete array.arrayBuffer; + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, array.arrayBuffer, this.dynamicDraw ? gl.DYNAMIC_DRAW : gl.STATIC_DRAW); + + if (!this.dynamicDraw) { + delete array.arrayBuffer; + } } bind() { this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.buffer); } + updateData(array: SerializedStructArray) { + assert(this.dynamicDraw); + this.bind(); + this.gl.bufferSubData(this.gl.ELEMENT_ARRAY_BUFFER, 0, array.arrayBuffer); + } + destroy() { if (this.buffer) { this.gl.deleteBuffer(this.buffer); diff --git a/src/render/draw_collision_debug.js b/src/render/draw_collision_debug.js index 6a3169a9d52..fdf1903cc97 100644 --- a/src/render/draw_collision_debug.js +++ b/src/render/draw_collision_debug.js @@ -5,44 +5,49 @@ import type SourceCache from '../source/source_cache'; import type StyleLayer from '../style/style_layer'; import type TileCoord from '../source/tile_coord'; import type SymbolBucket from '../data/bucket/symbol_bucket'; +const pixelsToTileUnits = require('../source/pixels_to_tile_units'); module.exports = drawCollisionDebug; -function drawCollisionDebug(painter: Painter, sourceCache: SourceCache, layer: StyleLayer, coords: Array) { +function drawCollisionDebugGeometry(painter: Painter, sourceCache: SourceCache, layer: StyleLayer, coords: Array, drawCircles: boolean) { const gl = painter.gl; - gl.enable(gl.STENCIL_TEST); - const program = painter.useProgram('collisionBox'); - - gl.activeTexture(gl.TEXTURE1); - painter.frameHistory.bind(gl); - gl.uniform1i(program.uniforms.u_fadetexture, 1); - + const program = drawCircles ? painter.useProgram('collisionCircle') : painter.useProgram('collisionBox'); for (let i = 0; i < coords.length; i++) { const coord = coords[i]; const tile = sourceCache.getTile(coord); const bucket: ?SymbolBucket = (tile.getBucket(layer): any); if (!bucket) continue; + const buffers = drawCircles ? bucket.collisionCircle : bucket.collisionBox; + if (!buffers) continue; gl.uniformMatrix4fv(program.uniforms.u_matrix, false, coord.posMatrix); - painter.enableTileClippingMask(coord); - - painter.lineWidth(1); - gl.uniform1f(program.uniforms.u_scale, Math.pow(2, painter.transform.zoom - tile.coord.z)); - gl.uniform1f(program.uniforms.u_zoom, painter.transform.zoom * 10); - const maxZoom = Math.max(0, Math.min(25, tile.coord.z + Math.log((tile.collisionTile: any).maxScale) / Math.LN2)); - gl.uniform1f(program.uniforms.u_maxzoom, maxZoom * 10); + if (!drawCircles) { + painter.lineWidth(1); + } - gl.uniform1f(program.uniforms.u_collision_y_stretch, (tile.collisionTile: any).yStretch); - gl.uniform1f(program.uniforms.u_pitch, painter.transform.pitch / 360 * 2 * Math.PI); gl.uniform1f(program.uniforms.u_camera_to_center_distance, painter.transform.cameraToCenterDistance); + const pixelRatio = pixelsToTileUnits(tile, 1, painter.transform.zoom); + const scale = Math.pow(2, painter.transform.zoom - tile.coord.z); + gl.uniform1f(program.uniforms.u_pixels_to_tile_units, pixelRatio); + gl.uniform2f(program.uniforms.u_extrude_scale, + painter.transform.pixelsToGLUnits[0] / (pixelRatio * scale), + painter.transform.pixelsToGLUnits[1] / (pixelRatio * scale)); program.draw( gl, - gl.LINES, + drawCircles ? gl.TRIANGLES : gl.LINES, layer.id, - bucket.collisionBox.layoutVertexBuffer, - bucket.collisionBox.indexBuffer, - bucket.collisionBox.segments); + buffers.layoutVertexBuffer, + buffers.indexBuffer, + buffers.segments, + null, + buffers.collisionVertexBuffer, + null); } } + +function drawCollisionDebug(painter: Painter, sourceCache: SourceCache, layer: StyleLayer, coords: Array) { + drawCollisionDebugGeometry(painter, sourceCache, layer, coords, false); + drawCollisionDebugGeometry(painter, sourceCache, layer, coords, true); +} diff --git a/src/render/draw_symbol.js b/src/render/draw_symbol.js index 37152bb63a6..d1333041250 100644 --- a/src/render/draw_symbol.js +++ b/src/render/draw_symbol.js @@ -18,24 +18,10 @@ module.exports = drawSymbols; function drawSymbols(painter: Painter, sourceCache: SourceCache, layer: SymbolStyleLayer, coords: Array) { if (painter.renderPass !== 'translucent') return; - const drawAcrossEdges = - !layer.layout['text-allow-overlap'] && - !layer.layout['icon-allow-overlap'] && - !layer.layout['text-ignore-placement'] && - !layer.layout['icon-ignore-placement']; - const gl = painter.gl; // Disable the stencil test so that labels aren't clipped to tile boundaries. - // - // Layers with features that may be drawn overlapping aren't clipped. These - // layers are sorted in the y direction, and to draw the correct ordering near - // tile edges the icons are included in both tiles and clipped when drawing. - if (drawAcrossEdges) { - gl.disable(gl.STENCIL_TEST); - } else { - gl.enable(gl.STENCIL_TEST); - } + gl.disable(gl.STENCIL_TEST); painter.setDepthSublayer(0); painter.depthMask(false); @@ -142,7 +128,7 @@ function drawLayerSymbols(painter, sourceCache, layer, coords, isText, translate gl.uniformMatrix4fv(program.uniforms.u_label_plane_matrix, false, labelPlaneMatrix); } - gl.uniform1f(program.uniforms.u_collision_y_stretch, (tile.collisionTile: any).yStretch); + gl.uniform1f(program.uniforms.u_fade_change, painter.options.collisionFadeDuration ? ((Date.now() - bucket.fadeStartTime) / painter.options.collisionFadeDuration) : 1); drawTileSymbols(program, programConfiguration, painter, layer, tile, buffers, isText, isSDF, pitchWithMap); } @@ -159,10 +145,6 @@ function setSymbolDrawState(program, painter, layer, isText, rotateInShader, pit gl.uniform1f(program.uniforms.u_is_text, isText ? 1 : 0); - gl.activeTexture(gl.TEXTURE1); - painter.frameHistory.bind(gl); - gl.uniform1i(program.uniforms.u_fadetexture, 1); - gl.uniform1f(program.uniforms.u_pitch, tr.pitch / 360 * 2 * Math.PI); const isZoomConstant = sizeData.functionType === 'constant' || sizeData.functionType === 'source'; @@ -211,5 +193,6 @@ function drawSymbolElements(buffers, layer, gl, program) { buffers.indexBuffer, buffers.segments, buffers.programConfigurations.get(layer.id), - buffers.dynamicLayoutVertexBuffer); + buffers.dynamicLayoutVertexBuffer, + buffers.opacityVertexBuffer); } diff --git a/src/render/frame_history.js b/src/render/frame_history.js deleted file mode 100644 index 04bb00c4a2c..00000000000 --- a/src/render/frame_history.js +++ /dev/null @@ -1,82 +0,0 @@ -// @flow - -class FrameHistory { - changeTimes: Float64Array; - changeOpacities: Uint8Array; - opacities: Uint8ClampedArray; - array: Uint8Array; - previousZoom: number; - firstFrame: boolean; - changed: boolean; - texture: WebGLTexture; - - constructor() { - this.changeTimes = new Float64Array(256); - this.changeOpacities = new Uint8Array(256); - this.opacities = new Uint8ClampedArray(256); - this.array = new Uint8Array(this.opacities.buffer); - - this.previousZoom = 0; - this.firstFrame = true; - } - - record(now: number, zoom: number, duration: number) { - if (this.firstFrame) { - now = 0; - this.firstFrame = false; - } - - zoom = Math.floor(zoom * 10); - - let z; - if (zoom < this.previousZoom) { - for (z = zoom + 1; z <= this.previousZoom; z++) { - this.changeTimes[z] = now; - this.changeOpacities[z] = this.opacities[z]; - } - } else { - for (z = zoom; z > this.previousZoom; z--) { - this.changeTimes[z] = now; - this.changeOpacities[z] = this.opacities[z]; - } - } - - for (z = 0; z < 256; z++) { - const timeSince = now - this.changeTimes[z]; - const opacityChange = (duration ? timeSince / duration : 1) * 255; - if (z <= zoom) { - this.opacities[z] = this.changeOpacities[z] + opacityChange; - } else { - this.opacities[z] = this.changeOpacities[z] - opacityChange; - } - } - - this.changed = true; - this.previousZoom = zoom; - } - - isVisible(zoom: number) { - return this.opacities[Math.floor(zoom * 10)] !== 0; - } - - bind(gl: WebGLRenderingContext) { - if (!this.texture) { - this.texture = gl.createTexture(); - gl.bindTexture(gl.TEXTURE_2D, this.texture); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.ALPHA, 256, 1, 0, gl.ALPHA, gl.UNSIGNED_BYTE, this.array); - - } else { - gl.bindTexture(gl.TEXTURE_2D, this.texture); - if (this.changed) { - gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 256, 1, gl.ALPHA, gl.UNSIGNED_BYTE, this.array); - this.changed = false; - } - } - } -} - -module.exports = FrameHistory; diff --git a/src/render/painter.js b/src/render/painter.js index 0d15b1de666..e2b56acdf02 100644 --- a/src/render/painter.js +++ b/src/render/painter.js @@ -2,7 +2,6 @@ const browser = require('../util/browser'); const mat4 = require('@mapbox/gl-matrix').mat4; -const FrameHistory = require('./frame_history'); const SourceCache = require('../source/source_cache'); const EXTENT = require('../data/extent'); const pixelsToTileUnits = require('../source/pixels_to_tile_units'); @@ -12,6 +11,7 @@ const VertexArrayObject = require('./vertex_array_object'); const RasterBoundsArray = require('../data/raster_bounds_array'); const PosArray = require('../data/pos_array'); const {ProgramConfiguration} = require('../data/program_configuration'); +const CrossTileSymbolIndex = require('../symbol/cross_tile_symbol_index'); const shaders = require('../shaders'); const Program = require('./program'); const RenderTexture = require('./render_texture'); @@ -45,7 +45,8 @@ type PainterOptions = { showOverdrawInspector: boolean, showTileBoundaries: boolean, rotating: boolean, - zooming: boolean + zooming: boolean, + collisionFadeDuration: number } /** @@ -58,7 +59,6 @@ class Painter { gl: WebGLRenderingContext; transform: Transform; _tileTextures: { [number]: Array }; - frameHistory: FrameHistory; numSublayers: number; depthEpsilon: number; lineWidthRange: [number, number]; @@ -94,14 +94,13 @@ class Painter { _showOverdrawInspector: boolean; cache: { [string]: Program }; currentProgram: Program; + crossTileSymbolIndex: CrossTileSymbolIndex; constructor(gl: WebGLRenderingContext, transform: Transform) { this.gl = gl; this.transform = transform; this._tileTextures = {}; - this.frameHistory = new FrameHistory(); - this.setup(); // Within each layer there are multiple distinct z-planes that can be drawn to. @@ -113,6 +112,8 @@ class Painter { this.basicFillProgramConfiguration = ProgramConfiguration.createBasicFill(); this.emptyProgramConfiguration = new ProgramConfiguration(); + + this.crossTileSymbolIndex = new CrossTileSymbolIndex(); } /* @@ -276,9 +277,7 @@ class Painter { this.imageManager = style.imageManager; this.glyphManager = style.glyphManager; - this.frameHistory.record(Date.now(), this.transform.zoom, style.getTransition().duration); - - for (const id in this.style.sourceCaches) { + for (const id in style.sourceCaches) { const sourceCache = this.style.sourceCaches[id]; if (sourceCache.used) { sourceCache.prepare(this.gl); diff --git a/src/render/program.js b/src/render/program.js index dff0ac63699..d74f484997f 100644 --- a/src/render/program.js +++ b/src/render/program.js @@ -89,7 +89,8 @@ class Program { indexBuffer: IndexBuffer, segments: SegmentVector, configuration: ?ProgramConfiguration, - dynamicLayoutBuffer: ?VertexBuffer) { + dynamicLayoutBuffer: ?VertexBuffer, + dynamicLayoutBuffer2: ?VertexBuffer) { const primitiveSize = { [gl.LINES]: 2, @@ -107,7 +108,9 @@ class Program { indexBuffer, configuration && configuration.paintVertexBuffer, segment.vertexOffset, - dynamicLayoutBuffer); + dynamicLayoutBuffer, + dynamicLayoutBuffer2 + ); gl.drawElements( drawMode, diff --git a/src/render/tile_mask.js b/src/render/tile_mask.js index ec8a9d769dc..be3c8381e37 100644 --- a/src/render/tile_mask.js +++ b/src/render/tile_mask.js @@ -85,7 +85,7 @@ function computeTileMasks(rootTile: TileCoord, ref: TileCoord, childArray: Array // The current tile is masked out, so we don't need to add them to the mask set. if (ref.id === childTile.coord.id) { return; - } else if (childTile.coord.isChildOf(ref)) { + } else if (childTile.coord.isChildOf(ref, childTile.sourceMaxZoom)) { // There's at least one child tile that is masked out, so recursively descend const children = ref.children(Infinity); for (let j = 0; j < children.length; j++) { @@ -102,4 +102,3 @@ function computeTileMasks(rootTile: TileCoord, ref: TileCoord, childArray: Array const maskTileId = new TileCoord(diffZ, ref.x - (rootTile.x << diffZ), ref.y - (rootTile.y << diffZ)).id; mask[maskTileId] = mask[maskTileId] || true; } - diff --git a/src/render/vertex_array_object.js b/src/render/vertex_array_object.js index c5422aa0257..89ec18eec50 100644 --- a/src/render/vertex_array_object.js +++ b/src/render/vertex_array_object.js @@ -13,6 +13,7 @@ class VertexArrayObject { boundIndexBuffer: ?IndexBuffer; boundVertexOffset: ?number; boundDynamicVertexBuffer: ?VertexBuffer; + boundDynamicVertexBuffer2: ?VertexBuffer; vao: any; gl: WebGLRenderingContext; @@ -32,7 +33,8 @@ class VertexArrayObject { indexBuffer: ?IndexBuffer, vertexBuffer2: ?VertexBuffer, vertexOffset: ?number, - dynamicVertexBuffer: ?VertexBuffer) { + dynamicVertexBuffer: ?VertexBuffer, + dynamicVertexBuffer2: ?VertexBuffer) { if (gl.extVertexArrayObject === undefined) { (gl: any).extVertexArrayObject = gl.getExtension("OES_vertex_array_object"); @@ -45,11 +47,12 @@ class VertexArrayObject { this.boundVertexBuffer2 !== vertexBuffer2 || this.boundIndexBuffer !== indexBuffer || this.boundVertexOffset !== vertexOffset || - this.boundDynamicVertexBuffer !== dynamicVertexBuffer + this.boundDynamicVertexBuffer !== dynamicVertexBuffer || + this.boundDynamicVertexBuffer2 !== dynamicVertexBuffer2 ); if (!gl.extVertexArrayObject || isFreshBindRequired) { - this.freshBind(gl, program, layoutVertexBuffer, indexBuffer, vertexBuffer2, vertexOffset, dynamicVertexBuffer); + this.freshBind(gl, program, layoutVertexBuffer, indexBuffer, vertexBuffer2, vertexOffset, dynamicVertexBuffer, dynamicVertexBuffer2); this.gl = gl; } else { (gl: any).extVertexArrayObject.bindVertexArrayOES(this.vao); @@ -58,6 +61,14 @@ class VertexArrayObject { // The buffer may have been updated. Rebind to upload data. dynamicVertexBuffer.bind(); } + + if (indexBuffer && indexBuffer.dynamicDraw) { + indexBuffer.bind(); + } + + if (dynamicVertexBuffer2) { + dynamicVertexBuffer2.bind(); + } } } @@ -67,7 +78,8 @@ class VertexArrayObject { indexBuffer: ?IndexBuffer, vertexBuffer2: ?VertexBuffer, vertexOffset: ?number, - dynamicVertexBuffer: ?VertexBuffer) { + dynamicVertexBuffer: ?VertexBuffer, + dynamicVertexBuffer2: ?VertexBuffer) { let numPrevAttributes; const numNextAttributes = program.numAttributes; @@ -84,6 +96,7 @@ class VertexArrayObject { this.boundIndexBuffer = indexBuffer; this.boundVertexOffset = vertexOffset; this.boundDynamicVertexBuffer = dynamicVertexBuffer; + this.boundDynamicVertexBuffer2 = dynamicVertexBuffer2; } else { numPrevAttributes = (gl: any).currentNumAttributes || 0; @@ -105,6 +118,9 @@ class VertexArrayObject { if (dynamicVertexBuffer) { dynamicVertexBuffer.enableAttributes(gl, program); } + if (dynamicVertexBuffer2) { + dynamicVertexBuffer2.enableAttributes(gl, program); + } layoutVertexBuffer.bind(); layoutVertexBuffer.setVertexAttribPointers(gl, program, vertexOffset); @@ -119,6 +135,10 @@ class VertexArrayObject { if (indexBuffer) { indexBuffer.bind(); } + if (dynamicVertexBuffer2) { + dynamicVertexBuffer2.bind(); + dynamicVertexBuffer2.setVertexAttribPointers(gl, program, vertexOffset); + } (gl: any).currentNumAttributes = numNextAttributes; } diff --git a/src/shaders/_prelude.vertex.glsl b/src/shaders/_prelude.vertex.glsl index de4d40eb891..db46ddca6aa 100644 --- a/src/shaders/_prelude.vertex.glsl +++ b/src/shaders/_prelude.vertex.glsl @@ -26,6 +26,10 @@ vec2 unpack_float(const float packedValue) { return vec2(v0, packedIntValue - v0 * 256); } +vec2 unpack_opacity(const float packedOpacity) { + int intOpacity = int(packedOpacity) / 2; + return vec2(float(intOpacity) / 127.0, mod(packedOpacity, 2.0)); +} // To minimize the number of attributes needed, we encode a 4-component // color into a pair of floats (i.e. a vec2) as follows: diff --git a/src/shaders/collision_box.fragment.glsl b/src/shaders/collision_box.fragment.glsl index aa0e5df0d5f..751c412ded9 100644 --- a/src/shaders/collision_box.fragment.glsl +++ b/src/shaders/collision_box.fragment.glsl @@ -1,38 +1,21 @@ -uniform float u_zoom; -// u_maxzoom is derived from the maximum scale considered by the CollisionTile -// Labels with placement zoom greater than this value will not be placed, -// regardless of perspective effects. -uniform float u_maxzoom; -uniform sampler2D u_fadetexture; -// v_max_zoom is a collision-box-specific value that controls when line-following -// collision boxes are used. -varying float v_max_zoom; -varying float v_placement_zoom; -varying float v_perspective_zoom_adjust; -varying vec2 v_fade_tex; +varying float v_placed; +varying float v_notUsed; void main() { float alpha = 0.5; - // Green = no collisions, label is showing - gl_FragColor = vec4(0.0, 1.0, 0.0, 1.0) * alpha; + // Red = collision, hide label + gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0) * alpha; - // Red = collision, label hidden - if (texture2D(u_fadetexture, v_fade_tex).a < 1.0) { - gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0) * alpha; + // Blue = no collision, label is showing + if (v_placed > 0.5) { + gl_FragColor = vec4(0.0, 0.0, 1.0, 0.5) * alpha; } - // Faded black = this collision box is not used at this zoom (for curved labels) - if (u_zoom >= v_max_zoom + v_perspective_zoom_adjust) { - gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0) * alpha * 0.25; + if (v_notUsed > 0.5) { + // This box not used, fade it out + gl_FragColor *= .1; } - - // Faded blue = the placement scale for this label is beyond the CollisionTile - // max scale, so it's impossible for this label to show without collision detection - // being run again (the label's glyphs haven't even been added to the symbol bucket) - if (v_placement_zoom >= u_maxzoom) { - gl_FragColor = vec4(0.0, 0.0, 1.0, 1.0) * alpha * 0.2; - } -} +} \ No newline at end of file diff --git a/src/shaders/collision_box.vertex.glsl b/src/shaders/collision_box.vertex.glsl index 3ae55780341..a035ca58f9e 100644 --- a/src/shaders/collision_box.vertex.glsl +++ b/src/shaders/collision_box.vertex.glsl @@ -1,32 +1,23 @@ attribute vec2 a_pos; attribute vec2 a_anchor_pos; attribute vec2 a_extrude; -attribute vec2 a_data; +attribute vec2 a_placed; uniform mat4 u_matrix; -uniform float u_scale; -uniform float u_pitch; -uniform float u_collision_y_stretch; +uniform vec2 u_extrude_scale; uniform float u_camera_to_center_distance; -varying float v_max_zoom; -varying float v_placement_zoom; -varying float v_perspective_zoom_adjust; -varying vec2 v_fade_tex; +varying float v_placed; +varying float v_notUsed; void main() { vec4 projectedPoint = u_matrix * vec4(a_anchor_pos, 0, 1); highp float camera_to_anchor_distance = projectedPoint.w; - highp float collision_perspective_ratio = 1.0 + 0.5 * ((camera_to_anchor_distance / u_camera_to_center_distance) - 1.0); + highp float collision_perspective_ratio = 0.5 + 0.5 * (u_camera_to_center_distance / camera_to_anchor_distance); - highp float incidence_stretch = camera_to_anchor_distance / (u_camera_to_center_distance * cos(u_pitch)); - highp float collision_adjustment = max(1.0, incidence_stretch / u_collision_y_stretch); + gl_Position = u_matrix * vec4(a_pos, 0.0, 1.0); + gl_Position.xy += a_extrude * u_extrude_scale * gl_Position.w * collision_perspective_ratio; - gl_Position = u_matrix * vec4(a_pos + a_extrude * collision_perspective_ratio * collision_adjustment / u_scale, 0.0, 1.0); - - v_max_zoom = a_data.x; - v_placement_zoom = a_data.y; - - v_perspective_zoom_adjust = floor(log2(collision_perspective_ratio * collision_adjustment) * 10.0); - v_fade_tex = vec2((v_placement_zoom + v_perspective_zoom_adjust) / 255.0, 0.0); + v_placed = a_placed.x; + v_notUsed = a_placed.y; } diff --git a/src/shaders/collision_circle.fragment.glsl b/src/shaders/collision_circle.fragment.glsl new file mode 100644 index 00000000000..abafd8fb669 --- /dev/null +++ b/src/shaders/collision_circle.fragment.glsl @@ -0,0 +1,33 @@ + +varying float v_placed; +varying float v_notUsed; +varying float v_radius; +varying vec2 v_extrude; +varying vec2 v_extrude_scale; + +void main() { + float alpha = 0.5; + + // Red = collision, hide label + vec4 color = vec4(1.0, 0.0, 0.0, 1.0) * alpha; + + // Blue = no collision, label is showing + if (v_placed > 0.5) { + color = vec4(0.0, 0.0, 1.0, 0.5) * alpha; + } + + if (v_notUsed > 0.5) { + // This box not used, fade it out + color *= .2; + } + + float extrude_scale_length = length(v_extrude_scale); + float extrude_length = length(v_extrude) * extrude_scale_length; + float stroke_width = 3.0; + float radius = v_radius * extrude_scale_length; + + float distance_to_edge = abs(extrude_length - radius); + float opacity_t = smoothstep(-stroke_width, 0.0, -distance_to_edge); + + gl_FragColor = opacity_t * color; +} diff --git a/src/shaders/collision_circle.vertex.glsl b/src/shaders/collision_circle.vertex.glsl new file mode 100644 index 00000000000..a26f39f0017 --- /dev/null +++ b/src/shaders/collision_circle.vertex.glsl @@ -0,0 +1,33 @@ +attribute vec2 a_pos; +attribute vec2 a_anchor_pos; +attribute vec2 a_extrude; +attribute vec2 a_placed; + +uniform mat4 u_matrix; +uniform vec2 u_extrude_scale; +uniform float u_camera_to_center_distance; + +varying float v_placed; +varying float v_notUsed; +varying float v_radius; + +varying vec2 v_extrude; +varying vec2 v_extrude_scale; + +void main() { + vec4 projectedPoint = u_matrix * vec4(a_anchor_pos, 0, 1); + highp float camera_to_anchor_distance = projectedPoint.w; + highp float collision_perspective_ratio = 0.5 + 0.5 * (camera_to_anchor_distance / u_camera_to_center_distance); + + gl_Position = u_matrix * vec4(a_pos, 0.0, 1.0); + + highp float padding_factor = 1.2; // Pad the vertices slightly to make room for anti-alias blur + gl_Position.xy += a_extrude * u_extrude_scale * padding_factor * gl_Position.w / collision_perspective_ratio; + + v_placed = a_placed.x; + v_notUsed = a_placed.y; + v_radius = abs(a_extrude.y); // We don't pitch the circles, so both units of the extrusion vector are equal in magnitude to the radius + + v_extrude = a_extrude * padding_factor; + v_extrude_scale = u_extrude_scale * u_camera_to_center_distance / collision_perspective_ratio; +} diff --git a/src/shaders/index.js b/src/shaders/index.js index e70487adc98..5132bf57b46 100644 --- a/src/shaders/index.js +++ b/src/shaders/index.js @@ -26,6 +26,10 @@ const shaders: {[string]: {fragmentSource: string, vertexSource: string}} = { fragmentSource: fs.readFileSync(__dirname + '/../shaders/collision_box.fragment.glsl', 'utf8'), vertexSource: fs.readFileSync(__dirname + '/../shaders/collision_box.vertex.glsl', 'utf8') }, + collisionCircle: { + fragmentSource: fs.readFileSync(__dirname + '/../shaders/collision_circle.fragment.glsl', 'utf8'), + vertexSource: fs.readFileSync(__dirname + '/../shaders/collision_circle.vertex.glsl', 'utf8') + }, debug: { fragmentSource: fs.readFileSync(__dirname + '/../shaders/debug.fragment.glsl', 'utf8'), vertexSource: fs.readFileSync(__dirname + '/../shaders/debug.vertex.glsl', 'utf8') diff --git a/src/shaders/symbol_icon.fragment.glsl b/src/shaders/symbol_icon.fragment.glsl index 2e785ab73ba..3d38bbd3499 100644 --- a/src/shaders/symbol_icon.fragment.glsl +++ b/src/shaders/symbol_icon.fragment.glsl @@ -1,15 +1,14 @@ uniform sampler2D u_texture; -uniform sampler2D u_fadetexture; #pragma mapbox: define lowp float opacity varying vec2 v_tex; -varying vec2 v_fade_tex; +varying float v_fade_opacity; void main() { #pragma mapbox: initialize lowp float opacity - lowp float alpha = texture2D(u_fadetexture, v_fade_tex).a * opacity; + lowp float alpha = opacity * v_fade_opacity; gl_FragColor = texture2D(u_texture, v_tex) * alpha; #ifdef OVERDRAW_INSPECTOR diff --git a/src/shaders/symbol_icon.vertex.glsl b/src/shaders/symbol_icon.vertex.glsl index c49c42201bc..31edb089676 100644 --- a/src/shaders/symbol_icon.vertex.glsl +++ b/src/shaders/symbol_icon.vertex.glsl @@ -3,6 +3,7 @@ const float PI = 3.141592653589793; attribute vec4 a_pos_offset; attribute vec4 a_data; attribute vec3 a_projected_pos; +attribute float a_fade_opacity; uniform bool u_is_size_zoom_constant; uniform bool u_is_size_feature_constant; @@ -12,7 +13,7 @@ uniform highp float u_camera_to_center_distance; uniform highp float u_pitch; uniform bool u_rotate_symbol; uniform highp float u_aspect_ratio; -uniform highp float u_collision_y_stretch; +uniform float u_fade_change; #pragma mapbox: define lowp float opacity @@ -26,7 +27,7 @@ uniform bool u_pitch_with_map; uniform vec2 u_texsize; varying vec2 v_tex; -varying vec2 v_fade_tex; +varying float v_fade_opacity; void main() { #pragma mapbox: initialize lowp float opacity @@ -37,9 +38,7 @@ void main() { vec2 a_tex = a_data.xy; vec2 a_size = a_data.zw; - highp vec2 angle_labelminzoom = unpack_float(a_projected_pos[2]); - highp float segment_angle = -angle_labelminzoom[0] / 255.0 * 2.0 * PI; - mediump float a_labelminzoom = angle_labelminzoom[1]; + highp float segment_angle = -a_projected_pos[2]; float size; if (!u_is_size_zoom_constant && !u_is_size_feature_constant) { @@ -83,11 +82,7 @@ void main() { gl_Position = u_gl_coord_matrix * vec4(projected_pos.xy / projected_pos.w + rotation_matrix * (a_offset / 64.0 * fontScale), 0.0, 1.0); v_tex = a_tex / u_texsize; - // See comments in symbol_sdf.vertex - highp float incidence_stretch = camera_to_anchor_distance / (u_camera_to_center_distance * cos(u_pitch)); - highp float collision_adjustment = max(1.0, incidence_stretch / u_collision_y_stretch); - - highp float collision_perspective_ratio = 1.0 + 0.5*((camera_to_anchor_distance / u_camera_to_center_distance) - 1.0); - highp float perspective_zoom_adjust = floor(log2(collision_perspective_ratio * collision_adjustment) * 10.0); - v_fade_tex = vec2((a_labelminzoom + perspective_zoom_adjust) / 255.0, 0.0); + vec2 fade_opacity = unpack_opacity(a_fade_opacity); + float fade_change = fade_opacity[1] > 0.5 ? u_fade_change : -u_fade_change; + v_fade_opacity = max(0.0, min(1.0, fade_opacity[0] + fade_change)); } diff --git a/src/shaders/symbol_sdf.fragment.glsl b/src/shaders/symbol_sdf.fragment.glsl index 521817cd436..e1905fa3381 100644 --- a/src/shaders/symbol_sdf.fragment.glsl +++ b/src/shaders/symbol_sdf.fragment.glsl @@ -9,12 +9,11 @@ uniform bool u_is_halo; #pragma mapbox: define lowp float halo_blur uniform sampler2D u_texture; -uniform sampler2D u_fadetexture; uniform highp float u_gamma_scale; uniform bool u_is_text; -varying vec4 v_data0; -varying vec2 v_data1; +varying vec2 v_data0; +varying vec3 v_data1; void main() { #pragma mapbox: initialize highp vec4 fill_color @@ -24,9 +23,9 @@ void main() { #pragma mapbox: initialize lowp float halo_blur vec2 tex = v_data0.xy; - vec2 fade_tex = v_data0.zw; float gamma_scale = v_data1.x; float size = v_data1.y; + float fade_opacity = v_data1[2]; float fontScale = u_is_text ? size / 24.0 : size; @@ -40,11 +39,10 @@ void main() { } lowp float dist = texture2D(u_texture, tex).a; - lowp float fade_alpha = texture2D(u_fadetexture, fade_tex).a; highp float gamma_scaled = gamma * gamma_scale; - highp float alpha = smoothstep(buff - gamma_scaled, buff + gamma_scaled, dist) * fade_alpha; + highp float alpha = smoothstep(buff - gamma_scaled, buff + gamma_scaled, dist); - gl_FragColor = color * (alpha * opacity); + gl_FragColor = color * (alpha * opacity * fade_opacity); #ifdef OVERDRAW_INSPECTOR gl_FragColor = vec4(1.0); diff --git a/src/shaders/symbol_sdf.vertex.glsl b/src/shaders/symbol_sdf.vertex.glsl index bb2604cf1b9..c562dda1efb 100644 --- a/src/shaders/symbol_sdf.vertex.glsl +++ b/src/shaders/symbol_sdf.vertex.glsl @@ -3,6 +3,7 @@ const float PI = 3.141592653589793; attribute vec4 a_pos_offset; attribute vec4 a_data; attribute vec3 a_projected_pos; +attribute float a_fade_opacity; // contents of a_size vary based on the type of property value // used for {text,icon}-size. @@ -32,12 +33,12 @@ uniform highp float u_pitch; uniform bool u_rotate_symbol; uniform highp float u_aspect_ratio; uniform highp float u_camera_to_center_distance; -uniform highp float u_collision_y_stretch; +uniform float u_fade_change; uniform vec2 u_texsize; -varying vec4 v_data0; -varying vec2 v_data1; +varying vec2 v_data0; +varying vec3 v_data1; void main() { #pragma mapbox: initialize highp vec4 fill_color @@ -52,9 +53,7 @@ void main() { vec2 a_tex = a_data.xy; vec2 a_size = a_data.zw; - highp vec2 angle_labelminzoom = unpack_float(a_projected_pos[2]); - highp float segment_angle = -angle_labelminzoom[0] / 255.0 * 2.0 * PI; - mediump float a_labelminzoom = angle_labelminzoom[1]; + highp float segment_angle = -a_projected_pos[2]; float size; if (!u_is_size_zoom_constant && !u_is_size_feature_constant) { @@ -106,29 +105,10 @@ void main() { float gamma_scale = gl_Position.w; vec2 tex = a_tex / u_texsize; - // incidence_stretch is the ratio of how much y space a label takes up on a tile while drawn perpendicular to the viewport vs - // how much space it would take up if it were drawn flat on the tile - // Using law of sines, camera_to_anchor/sin(ground_angle) = camera_to_center/sin(incidence_angle) - // sin(incidence_angle) = 1/incidence_stretch - // Incidence angle 90 -> head on, sin(incidence_angle) = 1, no incidence stretch - // Incidence angle 1 -> very oblique, sin(incidence_angle) =~ 0, lots of incidence stretch - // ground_angle = u_pitch + PI/2 -> sin(ground_angle) = cos(u_pitch) - // This 2D calculation is only exactly correct when gl_Position.x is in the center of the viewport, - // but it's a close enough approximation for our purposes - highp float incidence_stretch = camera_to_anchor_distance / (u_camera_to_center_distance * cos(u_pitch)); - // incidence_stretch only applies to the y-axis, but without re-calculating the collision tile, we can't - // adjust the size of only one axis. So, we do a crude approximation at placement time to get the aspect ratio - // about right, and then do the rest of the adjustment here: there will be some extra padding on the x-axis, - // but hopefully not too much. - // Never make the adjustment less than 1.0: instead of allowing collisions on the x-axis, be conservative on - // the y-axis. - highp float collision_adjustment = max(1.0, incidence_stretch / u_collision_y_stretch); - - // Floor to 1/10th zoom to dodge precision issues that can cause partially hidden labels - highp float collision_perspective_ratio = 1.0 + 0.5*((camera_to_anchor_distance / u_camera_to_center_distance) - 1.0); - highp float perspective_zoom_adjust = floor(log2(collision_perspective_ratio * collision_adjustment) * 10.0); - vec2 fade_tex = vec2((a_labelminzoom + perspective_zoom_adjust) / 255.0, 0.0); - - v_data0 = vec4(tex.x, tex.y, fade_tex.x, fade_tex.y); - v_data1 = vec2(gamma_scale, size); + vec2 fade_opacity = unpack_opacity(a_fade_opacity); + float fade_change = fade_opacity[1] > 0.5 ? u_fade_change : -u_fade_change; + float interpolated_fade_opacity = max(0.0, min(1.0, fade_opacity[0] + fade_change)); + + v_data0 = vec2(tex.x, tex.y); + v_data1 = vec3(gamma_scale, size, interpolated_fade_opacity); } diff --git a/src/source/geojson_source.js b/src/source/geojson_source.js index 1abbfb4b7e7..9415aabfa73 100644 --- a/src/source/geojson_source.js +++ b/src/source/geojson_source.js @@ -198,10 +198,6 @@ class GeoJSONSource extends Evented implements Source { source: this.id, pixelRatio: browser.devicePixelRatio, overscaling: tile.coord.z > this.maxzoom ? Math.pow(2, tile.coord.z - this.maxzoom) : 1, - angle: this.map.transform.angle, - pitch: this.map.transform.pitch, - cameraToCenterDistance: this.map.transform.cameraToCenterDistance, - cameraToTileDistance: this.map.transform.cameraToTileDistance(tile), showCollisionBoxes: this.map.showCollisionBoxes }; @@ -217,11 +213,6 @@ class GeoJSONSource extends Evented implements Source { tile.loadVectorData(data, this.map.painter); - if (tile.redoWhenDone) { - tile.redoWhenDone = false; - tile.redoPlacement(this); - } - return callback(null); }, this.workerID); } diff --git a/src/source/source_cache.js b/src/source/source_cache.js index 667a255afbd..30df7cebd5e 100644 --- a/src/source/source_cache.js +++ b/src/source/source_cache.js @@ -16,6 +16,7 @@ import type Style from '../style/style'; import type Dispatcher from '../util/dispatcher'; import type Transform from '../geo/transform'; import type {TileState} from './tile'; +import type CollisionIndex from '../symbol/collision_index'; /** * `SourceCache` is responsible for @@ -44,6 +45,7 @@ class SourceCache extends Evented { _maxTileCacheSize: ?number; _paused: boolean; _shouldReloadOnResume: boolean; + _needsFullPlacement: boolean; _coveredTiles: {[any]: boolean}; transform: Transform; _isIdRenderable: (id: number) => boolean; @@ -86,6 +88,8 @@ class SourceCache extends Evented { this._maxTileCacheSize = null; this._isIdRenderable = this._isIdRenderable.bind(this); + + this._coveredTiles = {}; } onAdd(map: Map) { @@ -125,6 +129,10 @@ class SourceCache extends Evented { this._paused = true; } + getNeedsFullPlacement() { + return this._needsFullPlacement; + } + resume() { if (!this._paused) return; const shouldReload = this._shouldReloadOnResume; @@ -232,6 +240,10 @@ class SourceCache extends Evented { // HACK this is necessary to fix https://github.com/mapbox/mapbox-gl-js/issues/2986 if (this.map) this.map.painter.tileExtentVAO.vao = null; + + this._updatePlacement(); + if (this.map) + tile.added(this.map.painter.crossTileSymbolIndex); } /** @@ -513,9 +525,12 @@ class SourceCache extends Evented { if (tile) return tile; + tile = this._cache.get((tileCoord.id: any)); if (tile) { - tile.redoPlacement(this._source); + this._updatePlacement(); + if (this.map) + tile.added(this.map.painter.crossTileSymbolIndex); if (this._cacheTimers[tileCoord.id]) { clearTimeout(this._cacheTimers[tileCoord.id]); delete this._cacheTimers[tileCoord.id]; @@ -580,10 +595,13 @@ class SourceCache extends Evented { if (tile.uses > 0) return; - tile.stopPlacementThrottler(); + this._updatePlacement(); + if (this.map) + tile.removed(this.map.painter.crossTileSymbolIndex); if (tile.hasData()) { - const wrappedId = tile.coord.wrapped().id; + tile.coord = tile.coord.wrapped(); + const wrappedId = tile.coord.id; this._cache.add((wrappedId: any), tile); this._setCacheInvalidationTimer(wrappedId, tile); } else { @@ -593,6 +611,10 @@ class SourceCache extends Evented { } } + _updatePlacement() { + this._needsFullPlacement = true; + } + /** * Remove all tiles from this pyramid */ @@ -659,11 +681,12 @@ class SourceCache extends Evented { return tileResults; } - redoPlacement() { + commitPlacement(collisionIndex: CollisionIndex, collisionFadeTimes: any) { + this._needsFullPlacement = false; const ids = this.getIds(); for (let i = 0; i < ids.length; i++) { const tile = this.getTileByID(ids[i]); - tile.redoPlacement(this._source); + tile.commitPlacement(collisionIndex, collisionFadeTimes, this.transform.angle); } } diff --git a/src/source/tile.js b/src/source/tile.js index f2ac9ab59ae..58bd571a723 100644 --- a/src/source/tile.js +++ b/src/source/tile.js @@ -8,9 +8,8 @@ const vt = require('@mapbox/vector-tile'); const Protobuf = require('pbf'); const GeoJSONFeature = require('../util/vectortile_to_geojson'); const featureFilter = require('../style-spec/feature_filter'); -const CollisionTile = require('../symbol/collision_tile'); +const CollisionIndex = require('../symbol/collision_index'); const CollisionBoxArray = require('../symbol/collision_box'); -const Throttler = require('../util/throttler'); const RasterBoundsArray = require('../data/raster_bounds_array'); const TileCoord = require('./tile_coord'); const EXTENT = require('../data/extent'); @@ -20,6 +19,9 @@ const IndexBuffer = require('../gl/index_buffer'); const Texture = require('../render/texture'); const {SegmentVector} = require('../data/segment'); const {TriangleIndexArray} = require('../data/index_array_type'); +const projection = require('../symbol/projection'); +const {performSymbolPlacement, updateOpacities} = require('../symbol/symbol_placement'); +const pixelsToTileUnits = require('../source/pixels_to_tile_units'); const CLOCK_SKEW_RETRY_TIMEOUT = 30000; @@ -28,6 +30,8 @@ import type StyleLayer from '../style/style_layer'; import type {WorkerTileResult} from './worker_source'; import type {RGBAImage, AlphaImage} from '../util/image'; import type Mask from '../render/tile_mask'; +import type CrossTileSymbolIndex from '../symbol/cross_tile_symbol_index'; + export type TileState = | 'loading' // Tile data is in the process of loading. | 'loaded' // Tile data has been loaded. Tile can be rendered. @@ -62,13 +66,9 @@ class Tile { fadeEndTime: any; rawTileData: ArrayBuffer; collisionBoxArray: ?CollisionBoxArray; - collisionTile: ?CollisionTile; + collisionIndex: ?CollisionIndex; featureIndex: ?FeatureIndex; redoWhenDone: boolean; - angle: number; - pitch: number; - cameraToCenterDistance: number; - cameraToTileDistance: number; showCollisionBoxes: boolean; placementSource: any; workerID: number | void; @@ -104,8 +104,6 @@ class Tile { this.expiredRequestCount = 0; this.state = 'loading'; - - this.placementThrottler = new Throttler(300, this._immediateRedoPlacement.bind(this)); } registerFadeDuration(animationLoop: any, duration: number) { @@ -139,18 +137,17 @@ class Tile { this.state = 'loaded'; // empty GeoJSON tile - if (!data) return; + if (!data) { + this.collisionBoxArray = new CollisionBoxArray(); + return; + } - // If we are redoing placement for the same tile, we will not recieve - // a new "rawTileData" object. If we are loading a new tile, we will - // recieve a new "rawTileData" object. if (data.rawTileData) { + // Only vector tiles have rawTileData this.rawTileData = data.rawTileData; } - this.collisionBoxArray = new CollisionBoxArray(data.collisionBoxArray); - this.collisionTile = CollisionTile.deserialize(data.collisionTile, this.collisionBoxArray); - this.featureIndex = FeatureIndex.deserialize(data.featureIndex, this.rawTileData, this.collisionTile); + this.featureIndex = FeatureIndex.deserialize(data.featureIndex, this.rawTileData); this.buckets = deserializeBucket(data.buckets, painter.style); if (data.iconAtlasImage) { @@ -159,34 +156,6 @@ class Tile { if (data.glyphAtlasImage) { this.glyphAtlasImage = data.glyphAtlasImage; } - } - - /** - * Replace this tile's symbol buckets with fresh data. - * @param {Object} data - * @param {Style} style - * @returns {undefined} - * @private - */ - reloadSymbolData(data: WorkerTileResult, style: any) { - if (this.state === 'unloaded') return; - - this.collisionTile = CollisionTile.deserialize(data.collisionTile, this.collisionBoxArray); - - if (this.featureIndex) { - this.featureIndex.setCollisionTile(this.collisionTile); - } - - for (const id in this.buckets) { - const bucket = this.buckets[id]; - if (bucket instanceof SymbolBucket) { - bucket.destroy(); - delete this.buckets[id]; - } - } - - // Add new symbol buckets - util.extend(this.buckets, deserializeBucket(data.buckets, style)); if (data.iconAtlasImage) { this.iconAtlasImage = data.iconAtlasImage; @@ -215,78 +184,59 @@ class Tile { } this.collisionBoxArray = null; - this.collisionTile = null; this.featureIndex = null; this.state = 'unloaded'; } - redoPlacement(source: any) { - if (source.type !== 'vector' && source.type !== 'geojson') { - return; - } - if (this.state !== 'loaded') { - this.redoWhenDone = true; - return; - } - if (!this.collisionTile) { // empty tile - return; + added(crossTileSymbolIndex: CrossTileSymbolIndex) { + for (const id in this.buckets) { + const bucket = this.buckets[id]; + if (bucket instanceof SymbolBucket) { + crossTileSymbolIndex.addTileLayer(id, this.coord, this.sourceMaxZoom, bucket.symbolInstances); + } } + } - const cameraToTileDistance = source.map.transform.cameraToTileDistance(this); - if (this.angle === source.map.transform.angle && - this.pitch === source.map.transform.pitch && - this.showCollisionBoxes === source.map.showCollisionBoxes) { - if (this.cameraToTileDistance === cameraToTileDistance && - this.cameraToCenterDistance === source.map.transform.cameraToCenterDistance) { - return; - } else if (this.pitch < 25) { - // At low pitch tile distance doesn't affect placement very - // much, so we skip the cost of redoPlacement - // However, we might as well store the latest value of - // cameraToTileDistance and cameraToCenterDistance in case a redoPlacement request - // is already queued. - this.cameraToTileDistance = cameraToTileDistance; - this.cameraToCenterDistance = source.map.transform.cameraToCenterDistance; - return; + removed(crossTileSymbolIndex: CrossTileSymbolIndex) { + for (const id in this.buckets) { + const bucket = this.buckets[id]; + if (bucket instanceof SymbolBucket) { + crossTileSymbolIndex.removeTileLayer(id, this.coord, this.sourceMaxZoom); } } + } + + placeLayer(showCollisionBoxes: boolean, collisionIndex: CollisionIndex, layer: any) { + const bucket = this.getBucket(layer); + const collisionBoxArray = this.collisionBoxArray; - this.angle = source.map.transform.angle; - this.pitch = source.map.transform.pitch; - this.cameraToCenterDistance = source.map.transform.cameraToCenterDistance; - this.cameraToTileDistance = cameraToTileDistance; - this.showCollisionBoxes = source.map.showCollisionBoxes; - this.placementSource = source; + if (bucket && bucket instanceof SymbolBucket && collisionBoxArray) { + const posMatrix = collisionIndex.transform.calculatePosMatrix(this.coord, this.sourceMaxZoom); - this.state = 'reloading'; - this.placementThrottler.invoke(); + const pitchWithMap = bucket.layers[0].layout['text-pitch-alignment'] === 'map'; + const textPixelRatio = EXTENT / this.tileSize; // text size is not meant to be affected by scale + const pixelRatio = pixelsToTileUnits(this, 1, collisionIndex.transform.zoom); + + const labelPlaneMatrix = projection.getLabelPlaneMatrix(posMatrix, pitchWithMap, true, collisionIndex.transform, pixelRatio); + performSymbolPlacement(bucket, collisionIndex, showCollisionBoxes, collisionIndex.transform.zoom, textPixelRatio, posMatrix, labelPlaneMatrix, this.coord.id, collisionBoxArray); + } } - _immediateRedoPlacement() { - this.placementSource.dispatcher.send('redoPlacement', { - type: this.placementSource.type, - uid: this.uid, - source: this.placementSource.id, - angle: this.angle, - pitch: this.pitch, - cameraToCenterDistance: this.cameraToCenterDistance, - cameraToTileDistance: this.cameraToTileDistance, - showCollisionBoxes: this.showCollisionBoxes - }, (_, data) => { - if (this.state !== 'reloading') return; - - this.state = 'loaded'; - this.reloadSymbolData(data, this.placementSource.map.style); - this.placementSource.fire('data', {tile: this, coord: this.coord, dataType: 'source'}); - // HACK this is nescessary to fix https://github.com/mapbox/mapbox-gl-js/issues/2986 - if (this.placementSource.map) this.placementSource.map.painter.tileExtentVAO.vao = null; - - if (this.redoWhenDone) { - this.state = 'reloading'; - this.redoWhenDone = false; - this._immediateRedoPlacement(); + commitPlacement(collisionIndex: CollisionIndex, collisionFadeTimes: any, angle: number) { + // Start all collision animations at the same time + for (const id in this.buckets) { + const bucket = this.buckets[id]; + if (bucket instanceof SymbolBucket) { + updateOpacities(bucket, collisionFadeTimes); + bucket.sortFeatures(angle); } - }, this.workerID); + } + + // Don't update the collision index used for queryRenderedFeatures + // until all layers have been updated to the same state + if (this.featureIndex) { + this.featureIndex.setCollisionIndex(collisionIndex); + } } getBucket(layer: StyleLayer) { @@ -331,12 +281,14 @@ class Tile { } return this.featureIndex.query({ - queryGeometry, - bearing, - params, - scale, - additionalRadius, + queryGeometry: queryGeometry, + scale: scale, tileSize: this.tileSize, + bearing: bearing, + params: params, + additionalRadius: additionalRadius, + tileSourceMaxZoom: this.sourceMaxZoom, + collisionBoxArray: this.collisionBoxArray }, layers); } @@ -492,13 +444,6 @@ class Tile { } } } - - stopPlacementThrottler() { - this.placementThrottler.stop(); - if (this.state === 'reloading') { - this.state = 'loaded'; - } - } } module.exports = Tile; diff --git a/src/source/tile_coord.js b/src/source/tile_coord.js index f320463f144..682d554dd79 100644 --- a/src/source/tile_coord.js +++ b/src/source/tile_coord.js @@ -4,6 +4,11 @@ const assert = require('assert'); const WhooTS = require('@mapbox/whoots-js'); const Coordinate = require('../geo/coordinate'); +/** + * @module TileCoord + * @private + */ + class TileCoord { z: number; x: number; @@ -123,15 +128,15 @@ class TileCoord { } /** - * - * @memberof Map - * @param {TileCoord} child TileCoord to check whether it is a child of the root tile + * @param {TileCoord} parent TileCoord that is potentially a parent of this TileCoord + * @param {number} sourceMaxZoom x and y coordinates only shift with z up to sourceMaxZoom * @returns {boolean} result boolean describing whether or not `child` is a child tile of the root - * @private */ - isChildOf(parent: any) { + isChildOf(parent: TileCoord, sourceMaxZoom: number) { + const parentZ = Math.min(sourceMaxZoom, parent.z); + const childZ = Math.min(sourceMaxZoom, this.z); // We're first testing for z == 0, to avoid a 32 bit shift, which is undefined. - return parent.z === 0 || (parent.z < this.z && parent.x === (this.x >> (this.z - parent.z)) && parent.y === (this.y >> (this.z - parent.z))); + return parent.z === 0 || (parent.z < this.z && parent.x === (this.x >> (childZ - parentZ)) && parent.y === (this.y >> (childZ - parentZ))); } static cover(z: number, bounds: [Coordinate, Coordinate, Coordinate, Coordinate], diff --git a/src/source/vector_tile_source.js b/src/source/vector_tile_source.js index 24c27a08a1d..b1b3a1073cc 100644 --- a/src/source/vector_tile_source.js +++ b/src/source/vector_tile_source.js @@ -100,10 +100,6 @@ class VectorTileSource extends Evented implements Source { source: this.id, pixelRatio: browser.devicePixelRatio, overscaling: overscaling, - angle: this.map.transform.angle, - pitch: this.map.transform.pitch, - cameraToCenterDistance: this.map.transform.cameraToCenterDistance, - cameraToTileDistance: this.map.transform.cameraToTileDistance(tile), showCollisionBoxes: this.map.showCollisionBoxes }; @@ -127,11 +123,6 @@ class VectorTileSource extends Evented implements Source { if (this.map._refreshExpiredTiles) tile.setExpiryData(data); tile.loadVectorData(data, this.map.painter); - if (tile.redoWhenDone) { - tile.redoWhenDone = false; - tile.redoPlacement(this); - } - callback(null); if (tile.reloadCallback) { diff --git a/src/source/vector_tile_worker_source.js b/src/source/vector_tile_worker_source.js index ab4cf846cff..fe9478a2a20 100644 --- a/src/source/vector_tile_worker_source.js +++ b/src/source/vector_tile_worker_source.js @@ -10,9 +10,7 @@ import type { WorkerSource, WorkerTileParameters, WorkerTileCallback, - TileParameters, - RedoPlacementParameters, - RedoPlacementCallback, + TileParameters } from '../source/worker_source'; import type Actor from '../util/actor'; @@ -134,6 +132,7 @@ class VectorTileWorkerSource implements WorkerSource { vtSource = this; if (loaded && loaded[uid]) { const workerTile = loaded[uid]; + workerTile.showCollisionBoxes = params.showCollisionBoxes; if (workerTile.status === 'parsing') { workerTile.reloadCallback = callback; @@ -184,24 +183,6 @@ class VectorTileWorkerSource implements WorkerSource { delete loaded[uid]; } } - - redoPlacement(params: RedoPlacementParameters, callback: RedoPlacementCallback) { - const loaded = this.loaded[params.source], - loading = this.loading[params.source], - uid = params.uid; - - if (loaded && loaded[uid]) { - const workerTile = loaded[uid]; - const result = workerTile.redoPlacement(params.angle, params.pitch, params.cameraToCenterDistance, params.cameraToTileDistance, params.showCollisionBoxes); - - if (result.result) { - callback(null, result.result, result.transferables); - } - - } else if (loading && loading[uid]) { - loading[uid].angle = params.angle; - } - } } module.exports = VectorTileWorkerSource; diff --git a/src/source/worker.js b/src/source/worker.js index 360cdb1a9fb..12de113da05 100644 --- a/src/source/worker.js +++ b/src/source/worker.js @@ -13,9 +13,7 @@ import type { WorkerSource, WorkerTileParameters, WorkerTileCallback, - TileParameters, - RedoPlacementParameters, - RedoPlacementCallback + TileParameters } from '../source/worker_source'; import type {WorkerGlobalScopeInterface} from '../util/web_worker'; @@ -96,11 +94,6 @@ class Worker { } } - redoPlacement(mapId: string, params: RedoPlacementParameters & {type: string}, callback: RedoPlacementCallback) { - assert(params.type); - this.getWorkerSource(mapId, params.type).redoPlacement(params, callback); - } - /** * Load a {@link WorkerSource} script at params.url. The script is run * (using importScripts) with `registerWorkerSource` in scope, which is a diff --git a/src/source/worker_source.js b/src/source/worker_source.js index 35dc0863292..1528e109cf1 100644 --- a/src/source/worker_source.js +++ b/src/source/worker_source.js @@ -5,7 +5,6 @@ import type Actor from '../util/actor'; import type StyleLayerIndex from '../style/style_layer_index'; import type {SerializedBucket} from '../data/bucket'; import type {SerializedFeatureIndex} from '../data/feature_index'; -import type {SerializedCollisionTile} from '../symbol/collision_tile'; import type {SerializedStructArray} from '../util/struct_array'; import type {RequestParameters} from '../util/ajax'; import type {RGBAImage, AlphaImage} from '../util/image'; @@ -15,14 +14,6 @@ export type TileParameters = { uid: string, }; -export type PlacementConfig = { - angle: number, - pitch: number, - cameraToCenterDistance: number, - cameraToTileDistance: number, - showCollisionBoxes: boolean, -}; - export type WorkerTileParameters = TileParameters & { coord: TileCoord, request: RequestParameters, @@ -31,29 +22,20 @@ export type WorkerTileParameters = TileParameters & { tileSize: number, pixelRatio: number, overscaling: number, -} & PlacementConfig; + showCollisionBoxes: boolean +}; export type WorkerTileResult = { buckets: Array, iconAtlasImage: RGBAImage, glyphAtlasImage: AlphaImage, featureIndex: SerializedFeatureIndex, - collisionTile: SerializedCollisionTile, collisionBoxArray: SerializedStructArray, rawTileData?: ArrayBuffer, }; export type WorkerTileCallback = (error: ?Error, result: ?WorkerTileResult, transferables: ?Array) => void; -export type RedoPlacementParameters = TileParameters & PlacementConfig; - -export type RedoPlacementResult = { - buckets: Array, - collisionTile: SerializedCollisionTile -}; - -export type RedoPlacementCallback = (error: ?Error, result: ?RedoPlacementResult, transferables: ?Array) => void; - /** * May be implemented by custom source types to provide code that can be run on * the WebWorkers. In addition to providing a custom @@ -74,7 +56,7 @@ export interface WorkerSource { /** * Loads a tile from the given params and parse it into buckets ready to send * back to the main thread for rendering. Should call the callback with: - * `{ buckets, featureIndex, collisionTile, rawTileData}`. + * `{ buckets, featureIndex, collisionIndex, rawTileData}`. */ loadTile(params: WorkerTileParameters, callback: WorkerTileCallback): void; @@ -94,6 +76,5 @@ export interface WorkerSource { */ removeTile(params: TileParameters): void; - redoPlacement(params: RedoPlacementParameters, callback: RedoPlacementCallback): void; removeSource?: (params: {source: string}) => void; } diff --git a/src/source/worker_tile.js b/src/source/worker_tile.js index 20f29538fa3..294e5c9d27e 100644 --- a/src/source/worker_tile.js +++ b/src/source/worker_tile.js @@ -1,7 +1,7 @@ // @flow const FeatureIndex = require('../data/feature_index'); -const CollisionTile = require('../symbol/collision_tile'); +const {performSymbolLayout} = require('../symbol/symbol_layout'); const CollisionBoxArray = require('../symbol/collision_box'); const DictionaryCoder = require('../util/dictionary_coder'); const SymbolBucket = require('../data/bucket/symbol_bucket'); @@ -29,10 +29,6 @@ class WorkerTile { tileSize: number; source: string; overscaling: number; - angle: number; - pitch: number; - cameraToCenterDistance: number; - cameraToTileDistance: number; showCollisionBoxes: boolean; status: 'parsing' | 'done'; @@ -52,10 +48,6 @@ class WorkerTile { this.tileSize = params.tileSize; this.source = params.source; this.overscaling = params.overscaling; - this.angle = params.angle; - this.pitch = params.pitch; - this.cameraToCenterDistance = params.cameraToCenterDistance; - this.cameraToTileDistance = params.cameraToTileDistance; this.showCollisionBoxes = params.showCollisionBoxes; } @@ -168,23 +160,13 @@ class WorkerTile { if (error) { return callback(error); } else if (glyphMap && imageMap) { - const collisionTile = new CollisionTile( - this.angle, - this.pitch, - this.cameraToCenterDistance, - this.cameraToTileDistance, - this.collisionBoxArray); - const glyphAtlas = makeGlyphAtlas(glyphMap); const imageAtlas = makeImageAtlas(imageMap); for (const bucket of this.symbolBuckets) { recalculateLayers(bucket, this.zoom); - bucket.prepare(glyphMap, glyphAtlas.positions, - imageMap, imageAtlas.positions); - - bucket.place(collisionTile, this.showCollisionBoxes); + performSymbolLayout(bucket, glyphMap, glyphAtlas.positions, imageMap, imageAtlas.positions, this.showCollisionBoxes); } this.status = 'done'; @@ -197,7 +179,6 @@ class WorkerTile { callback(null, { buckets: serializeBuckets(util.values(buckets), transferables), featureIndex: featureIndex.serialize(transferables), - collisionTile: collisionTile.serialize(transferables), collisionBoxArray: this.collisionBoxArray.serialize(), glyphAtlasImage: glyphAtlas.image, iconAtlasImage: imageAtlas.image @@ -205,39 +186,6 @@ class WorkerTile { } } } - - redoPlacement(angle: number, pitch: number, cameraToCenterDistance: number, cameraToTileDistance: number, showCollisionBoxes: boolean) { - this.angle = angle; - this.pitch = pitch; - this.cameraToCenterDistance = cameraToCenterDistance; - this.cameraToTileDistance = cameraToTileDistance; - - if (this.status !== 'done') { - return {}; - } - - const collisionTile = new CollisionTile( - this.angle, - this.pitch, - this.cameraToCenterDistance, - this.cameraToTileDistance, - this.collisionBoxArray); - - for (const bucket of this.symbolBuckets) { - recalculateLayers(bucket, this.zoom); - - bucket.place(collisionTile, showCollisionBoxes); - } - - const transferables = []; - return { - result: { - buckets: serializeBuckets(this.symbolBuckets, transferables), - collisionTile: collisionTile.serialize(transferables) - }, - transferables: transferables - }; - } } function recalculateLayers(bucket: SymbolBucket, zoom: number) { diff --git a/src/style/placement.js b/src/style/placement.js new file mode 100644 index 00000000000..75cfccc283d --- /dev/null +++ b/src/style/placement.js @@ -0,0 +1,138 @@ +// @flow + +const browser = require('../util/browser'); +const CollisionIndex = require('../symbol/collision_index'); +const TileCoord = require('../source/tile_coord'); + +import type Transform from '../geo/transform'; +import type StyleLayer from './style_layer'; +import type SourceCache from '../source/source_cache'; + +function compareTileCoords(a: number, b: number) { + const aCoord = TileCoord.fromID(a); + const bCoord = TileCoord.fromID(b); + if (aCoord.isLessThan(bCoord)) { + return -1; + } else if (bCoord.isLessThan(aCoord)) { + return 1; + } else { + return 0; + } +} + +class LayerPlacement { + _currentTileIndex: number; + _tileIDs: Array; + + constructor(tileIDs: Array) { + this._currentTileIndex = 0; + this._tileIDs = tileIDs; + } + + continuePlacement(sourceCache, collisionIndex, showCollisionBoxes: boolean, layer, shouldPausePlacement) { + while (this._currentTileIndex < this._tileIDs.length) { + const tile = sourceCache.getTileByID(this._tileIDs[this._currentTileIndex]); + tile.placeLayer(showCollisionBoxes, collisionIndex, layer); + + this._currentTileIndex++; + if (shouldPausePlacement()) { + return true; + } + } + } +} + +class Placement { + collisionIndex: CollisionIndex; + _done: boolean; + _currentPlacementIndex: number; + _forceFullPlacement: boolean; + _showCollisionBoxes: boolean; + _delayUntil: number; + _collisionFadeTimes: any; + _inProgressLayer: ?LayerPlacement; + _sourceCacheTileIDs: {[string]: Array}; + + constructor(transform: Transform, order: Array, + forceFullPlacement: boolean, showCollisionBoxes: boolean, fadeDuration: number, + previousPlacement: ?Placement) { + + this.collisionIndex = new CollisionIndex(transform.clone()); + this._currentPlacementIndex = order.length - 1; + this._forceFullPlacement = forceFullPlacement; + this._showCollisionBoxes = showCollisionBoxes; + this._sourceCacheTileIDs = {}; + this._done = false; + + if (forceFullPlacement || !previousPlacement) { + this._delayUntil = browser.now(); + } else { + this._delayUntil = previousPlacement._delayUntil + 300; + } + + if (previousPlacement) { + this._collisionFadeTimes = previousPlacement._collisionFadeTimes; + } else { + this._collisionFadeTimes = { + latestStart: 0, + duration: fadeDuration + }; + } + } + + isDone(): boolean { + return this._done; + } + + continuePlacement(order: Array, layers: {[string]: StyleLayer}, sourceCaches: {[string]: SourceCache}) { + const startTime = browser.now(); + + if (startTime < this._delayUntil) return true; + + const shouldPausePlacement = () => { + const elapsedTime = browser.now() - startTime; + return this._forceFullPlacement ? false : elapsedTime > 2; + }; + + while (this._currentPlacementIndex >= 0) { + const layerId = order[this._currentPlacementIndex]; + const layer = layers[layerId]; + if (layer.type === 'symbol') { + const sourceCache = sourceCaches[layer.source]; + + if (!this._inProgressLayer) { + if (!this._sourceCacheTileIDs[layer.source]) { + this._sourceCacheTileIDs[layer.source] = sourceCache.getRenderableIds().sort(compareTileCoords); + } + this._inProgressLayer = new LayerPlacement(this._sourceCacheTileIDs[layer.source]); + } + + const pausePlacement = this._inProgressLayer.continuePlacement(sourceCache, this.collisionIndex, this._showCollisionBoxes, layer, shouldPausePlacement); + + if (pausePlacement) { + // We didn't finish placing all layers within 2ms, + // but we can keep rendering with a partial placement + // We'll resume here on the next frame + return; + } + + delete this._inProgressLayer; + } + + this._currentPlacementIndex--; + } + + for (const id in sourceCaches) { + sourceCaches[id].commitPlacement(this.collisionIndex, this._collisionFadeTimes); + } + + this._done = true; + } + + stillFading() { + return Date.now() < this._collisionFadeTimes.latestStart + this._collisionFadeTimes.duration; + } + +} + +module.exports = Placement; diff --git a/src/style/style.js b/src/style/style.js index 7748f07af03..4e0791e943a 100644 --- a/src/style/style.js +++ b/src/style/style.js @@ -25,12 +25,14 @@ const getWorkerPool = require('../util/global_worker_pool'); const deref = require('../style-spec/deref'); const diff = require('../style-spec/diff'); const rtlTextPlugin = require('../source/rtl_text_plugin'); +const Placement = require('./placement'); import type Map from '../ui/map'; import type Transform from '../geo/transform'; import type {Source} from '../source/source'; import type {StyleImage} from './style_image'; import type {StyleGlyph} from './style_glyph'; +import type CollisionIndex from '../symbol/collision_index'; const supportedDiffOperations = util.pick(diff.operations, [ 'addLayer', @@ -92,6 +94,10 @@ class Style extends Evented { _updatedPaintProps: {[layer: string]: {[class: string]: true}}; _updatedAllPaintProps: boolean; _updatedSymbolOrder: boolean; + _layerOrderChanged: boolean; + + collisionIndex: CollisionIndex; + placement: Placement; z: number; constructor(map: Map, options: StyleOptions = {}) { @@ -110,8 +116,6 @@ class Style extends Evented { this.zoomHistory = {}; this._loaded = false; - util.bindAll(['_redoPlacement'], this); - this._resetUpdates(); const self = this; @@ -574,6 +578,7 @@ class Style extends Evented { } this._order.splice(index, 0, id); + this._layerOrderChanged = true; this._layers[id] = layer; @@ -630,6 +635,8 @@ class Style extends Evented { const newIndex = before ? this._order.indexOf(before) : this._order.length; this._order.splice(newIndex, 0, id); + this._layerOrderChanged = true; + if (layer.type === 'symbol') { this._updatedSymbolOrder = true; if (layer.source && !this._updatedSources[layer.source]) { @@ -666,6 +673,8 @@ class Style extends Evented { const index = this._order.indexOf(id); this._order.splice(index, 1); + this._layerOrderChanged = true; + if (layer.type === 'symbol') { this._updatedSymbolOrder = true; } @@ -978,10 +987,45 @@ class Style extends Evented { } } - _redoPlacement() { + getNeedsFullPlacement() { + // Anything that changes our "in progress" layer and tile indices requires us + // to start over. When we start over, we do a full placement instead of incremental + // to prevent starvation. + if (this._layerOrderChanged) { + // We need to restart placement to keep layer indices in sync. + return true; + } for (const id in this.sourceCaches) { - this.sourceCaches[id].redoPlacement(); + if (this.sourceCaches[id].getNeedsFullPlacement()) { + // A tile has been added or removed, we need to do a full placement + // New tiles can't be rendered until they've finished their first placement + return true; + } } + return false; + } + + _generateCollisionBoxes() { + for (const id in this.sourceCaches) { + this._reloadSource(id); + } + } + + _updatePlacement(transform: Transform, showCollisionBoxes: boolean, fadeDuration: number) { + const forceFullPlacement = this.getNeedsFullPlacement(); + + if (forceFullPlacement || !this.placement || this.placement.isDone()) { + this.placement = new Placement(transform, this._order, forceFullPlacement, showCollisionBoxes, fadeDuration, this.placement); + this._layerOrderChanged = false; + } + + this.placement.continuePlacement(this._order, this._layers, this.sourceCaches); + + if (this.placement.isDone()) this.collisionIndex = this.placement.collisionIndex; + + // needsRender is false when we have just finished a placement that didn't change the visibility of any symbols + const needsRerender = !this.placement.isDone() || this.placement.stillFading(); + return needsRerender; } // Callbacks from web workers diff --git a/src/symbol/collision_box.js b/src/symbol/collision_box.js index e4cc6d70957..1a2f92ea313 100644 --- a/src/symbol/collision_box.js +++ b/src/symbol/collision_box.js @@ -7,22 +7,15 @@ export type CollisionBox = { anchorPoint: Point, anchorPointX: number, anchorPointY: number, - offsetX: number, - offsetY: number, x1: number, y1: number, x2: number, y2: number, - unadjustedMaxScale: number, - maxScale: number, featureIndex: number, sourceLayerIndex: number, bucketIndex: number, - bbox0: number, - bbox1: number, - bbox2: number, - bbox3: number, - placementScale: number + radius: number, + signedDistanceFromAnchor: number }; /** @@ -31,31 +24,12 @@ export type CollisionBox = { * represent all the area covered by a single label. They are used to * prevent collisions between labels. * - * A collision box actually represents a 3d volume. The first two dimensions, - * x and y, are specified with `anchor` along with `x1`, `y1`, `x2`, `y2`. - * The third dimension, zoom, is limited by `maxScale` which determines - * how far in the z dimensions the box extends. - * - * As you zoom in on a map, all points on the map get further and further apart - * but labels stay roughly the same size. Labels cover less real world area on - * the map at higher zoom levels than they do at lower zoom levels. This is why - * areas are are represented with an anchor point and offsets from that point - * instead of just using four absolute points. - * - * Line labels are represented by a set of these boxes spaced out along a line. - * When you zoom in, line labels cover less real world distance along the line - * than they used to. Collision boxes near the edges that used to cover label - * no longer do. If a box doesn't cover the label anymore it should be ignored - * when doing collision checks. `maxScale` is how much you can scale the map - * before the label isn't within the box anymore. - * For example - * lower zoom: - * https://cloud.githubusercontent.com/assets/1421652/8060094/4d975f76-0e91-11e5-84b1-4edeb30a5875.png - * slightly higher zoom: - * https://cloud.githubusercontent.com/assets/1421652/8060061/26ae1c38-0e91-11e5-8c5a-9f380bf29f0a.png - * In the zoomed in image the two grey boxes on either side don't cover the - * label anymore. Their maxScale is smaller than the current scale. - * + * Line labels are represented by a set of these boxes spaced out along the + * line. When we calculate collision geometries, we use the circle inscribed + * in the box, rather than the box itself. This makes collision detection more + * stable during rotation. The circle geometry is based solely on the line + * geometry and the total length of the label -- individual glyph shapings + * doesn't factor into collision detection. * * @class CollisionBoxArray * @private @@ -67,21 +41,12 @@ const CollisionBoxArray = createStructArrayType({ { type: 'Int16', name: 'anchorPointX' }, { type: 'Int16', name: 'anchorPointY' }, - // the offset of the box from the label's anchor point - { type: 'Int16', name: 'offsetX' }, - { type: 'Int16', name: 'offsetY' }, - // distances to the edges from the anchor { type: 'Int16', name: 'x1' }, { type: 'Int16', name: 'y1' }, { type: 'Int16', name: 'x2' }, { type: 'Int16', name: 'y2' }, - // the box is only valid for scales < maxScale. - // The box does not block other boxes at scales >= maxScale; - { type: 'Float32', name: 'unadjustedMaxScale' }, - { type: 'Float32', name: 'maxScale' }, - // the index of the feature in the original vectortile { type: 'Uint32', name: 'featureIndex' }, // the source layer the feature appears in @@ -89,13 +54,12 @@ const CollisionBoxArray = createStructArrayType({ // the bucket the feature appears in { type: 'Uint16', name: 'bucketIndex' }, - // rotated and scaled bbox used for indexing - { type: 'Int16', name: 'bbox0' }, - { type: 'Int16', name: 'bbox1' }, - { type: 'Int16', name: 'bbox2' }, - { type: 'Int16', name: 'bbox3' }, + // collision circles for lines store their distance to the anchor in tile units + // so that they can be ignored if the projected label doesn't extend into + // the box area + { type: 'Int16', name: 'radius' }, + { type: 'Int16', name: 'signedDistanceFromAnchor' } - { type: 'Float32', name: 'placementScale' } ] }); diff --git a/src/symbol/collision_feature.js b/src/symbol/collision_feature.js index 41d446366e7..f79a7e73410 100644 --- a/src/symbol/collision_feature.js +++ b/src/symbol/collision_feature.js @@ -6,7 +6,7 @@ import type Anchor from './anchor'; /** * A CollisionFeature represents the area of the tile covered by a single label. - * It is used with CollisionTile to check if the label overlaps with any + * It is used with CollisionIndex to check if the label overlaps with any * previous labels. A CollisionFeature is mostly just a set of CollisionBox * objects. * @@ -36,7 +36,7 @@ class CollisionFeature { boxScale: number, padding: number, alignLine: boolean, - straight: boolean) { + overscaling: number) { const y1 = shaped.top * boxScale - padding; const y2 = shaped.bottom * boxScale + padding; const x1 = shaped.left * boxScale - padding; @@ -53,20 +53,12 @@ class CollisionFeature { // set minimum box height to avoid very many small labels height = Math.max(10 * boxScale, height); - if (straight) { - // used for icon labels that are aligned with the line, but don't curve along it - const vector = line[anchor.segment + 1].sub(line[(anchor.segment: any)])._unit()._mult(length); - const straightLine = [anchor.sub(vector), anchor.add(vector)]; - this._addLineCollisionBoxes(collisionBoxArray, straightLine, anchor, 0, length, height, featureIndex, sourceLayerIndex, bucketIndex); - } else { - // used for text labels that curve along a line - this._addLineCollisionBoxes(collisionBoxArray, line, anchor, (anchor.segment: any), length, height, featureIndex, sourceLayerIndex, bucketIndex); - } + this._addLineCollisionCircles(collisionBoxArray, line, anchor, (anchor.segment: any), length, height, featureIndex, sourceLayerIndex, bucketIndex, overscaling); } } else { - collisionBoxArray.emplaceBack(anchor.x, anchor.y, 0, 0, x1, y1, x2, y2, Infinity, Infinity, featureIndex, sourceLayerIndex, bucketIndex, - 0, 0, 0, 0, 0); + collisionBoxArray.emplaceBack(anchor.x, anchor.y, x1, y1, x2, y2, featureIndex, sourceLayerIndex, bucketIndex, + 0, 0); } this.boxEndIndex = collisionBoxArray.length; @@ -80,7 +72,7 @@ class CollisionFeature { * @param boxSize The size of the collision boxes that will be created. * @private */ - _addLineCollisionBoxes(collisionBoxArray: CollisionBoxArray, + _addLineCollisionCircles(collisionBoxArray: CollisionBoxArray, line: Array, anchor: Anchor, segment: number, @@ -88,13 +80,20 @@ class CollisionFeature { boxSize: number, featureIndex: number, sourceLayerIndex: number, - bucketIndex: number) { + bucketIndex: number, + overscaling: number) { const step = boxSize / 2; const nBoxes = Math.floor(labelLength / step); - // We calculate line collision boxes out to 300% of what would normally be our + // We calculate line collision circles out to 300% of what would normally be our // max size, to allow collision detection to work on labels that expand as // they move into the distance - const nPitchPaddingBoxes = Math.floor(nBoxes / 2); + // Vertically oriented labels in the distant field can extend past this padding + // This is a noticeable problem in overscaled tiles where the pitch 0-based + // symbol spacing will put labels very close together in a pitched map. + // To reduce the cost of adding extra collision circles, we slowly increase + // them for overscaled tiles. + const overscalingPaddingFactor = 1 + .4 * Math.log(overscaling) / Math.LN2; + const nPitchPaddingBoxes = Math.floor(nBoxes * overscalingPaddingFactor / 2); // offset the center of the first box by half a box so that the edge of the // box is at the edge of the label. @@ -104,8 +103,7 @@ class CollisionFeature { let index = segment + 1; let anchorDistance = firstBoxOffset; const labelStartDistance = -labelLength / 2; - const paddingStartDistance = labelStartDistance - labelLength / 8; - + const paddingStartDistance = labelStartDistance - labelLength / 4; // move backwards along the line to the first segment the label appears on do { index--; @@ -151,7 +149,9 @@ class CollisionFeature { index++; // There isn't enough room before the end of the line. - if (index + 1 >= line.length) return; + if (index + 1 >= line.length) { + return; + } segmentLength = line[index].dist(line[index + 1]); } @@ -163,35 +163,18 @@ class CollisionFeature { const p1 = line[index + 1]; const boxAnchorPoint = p1.sub(p0)._unit()._mult(segmentBoxDistance)._add(p0)._round(); - // Distance from label anchor point to inner (towards center) edge of this box - // The tricky thing here is that box positioning doesn't change with scale, - // but box size does change with scale. - // Technically, distanceToInnerEdge should be: - // Math.max(Math.abs(boxDistanceToAnchor - firstBoxOffset) - (step / scale), 0); - // But using that formula would make solving for maxScale more difficult, so we - // approximate with scale=2. - // This makes our calculation spot-on at scale=2, and on the conservative side for - // lower scales - const distanceToInnerEdge = Math.max(Math.abs(boxDistanceToAnchor - firstBoxOffset) - step / 2, 0); - let maxScale = labelLength / 2 / distanceToInnerEdge; - - // The box maxScale calculations are designed to be conservative on collisions in the scale range - // [1,2]. At scale=1, each box has 50% overlap, and at scale=2, the boxes are lined up edge - // to edge (beyond scale 2, gaps start to appear, which could potentially allow missed collisions). - // We add "pitch padding" boxes to the left and right to handle effective underzooming - // (scale < 1) when labels are in the distance. The overlap approximation could cause us to use - // these boxes when the scale is greater than 1, but we prevent that because we know - // they're only necessary for scales less than one. - // This preserves the pre-pitch-padding behavior for unpitched maps. - if (i < 0 || i >= nBoxes) { - maxScale = Math.min(maxScale, 0.99); - } + // If the box is within boxSize of the anchor, force the box to be used + // (so even 0-width labels use at least one box) + // Otherwise, the .8 multiplication gives us a little bit of conservative + // padding in choosing which boxes to use (see CollisionIndex#placedCollisionCircles) + const paddedAnchorDistance = Math.abs(boxDistanceToAnchor - firstBoxOffset) < step ? + 0 : + (boxDistanceToAnchor - firstBoxOffset) * 0.8; collisionBoxArray.emplaceBack(boxAnchorPoint.x, boxAnchorPoint.y, - boxAnchorPoint.x - anchor.x, boxAnchorPoint.y - anchor.y, - -boxSize / 2, -boxSize / 2, boxSize / 2, boxSize / 2, maxScale, maxScale, + -boxSize / 2, -boxSize / 2, boxSize / 2, boxSize / 2, featureIndex, sourceLayerIndex, bucketIndex, - 0, 0, 0, 0, 0); + boxSize / 2, paddedAnchorDistance); } } } diff --git a/src/symbol/collision_index.js b/src/symbol/collision_index.js new file mode 100644 index 00000000000..998b82c2bcb --- /dev/null +++ b/src/symbol/collision_index.js @@ -0,0 +1,357 @@ +// @flow + +const Point = require('@mapbox/point-geometry'); +const intersectionTests = require('../util/intersection_tests'); + +const Grid = require('./grid_index'); +const glmatrix = require('@mapbox/gl-matrix'); + +const mat4 = glmatrix.mat4; + +const projection = require('../symbol/projection'); + +import type Transform from '../geo/transform'; +import type TileCoord from '../source/tile_coord'; +import type {SingleCollisionBox} from '../data/bucket/symbol_bucket'; + +// When a symbol crosses the edge that causes it to be included in +// collision detection, it will cause changes in the symbols around +// it. This constant specifies how many pixels to pad the edge of +// the viewport for collision detection so that the bulk of the changes +// occur offscreen. Making this constant greater increases label +// stability, but it's expensive. +const viewportPadding = 100; + +/** + * A collision index used to prevent symbols from overlapping. It keep tracks of + * where previous symbols have been placed and is used to check if a new + * symbol overlaps with any previously added symbols. + * + * There are two steps to insertion: first placeCollisionBox/Circles checks if + * there's room for a symbol, then insertCollisionBox/Circles actually puts the + * symbol in the index. The two step process allows paired symbols to be inserted + * together even if they overlap. + * + * @private + */ +class CollisionIndex { + grid: Grid; + ignoredGrid: Grid; + transform: Transform; + pitchfactor: number; + + constructor( + transform: Transform, + grid: Grid = new Grid(transform.width + 2 * viewportPadding, transform.height + 2 * viewportPadding, 25), + ignoredGrid: Grid = new Grid(transform.width + 2 * viewportPadding, transform.height + 2 * viewportPadding, 25) + ) { + this.transform = transform; + + this.grid = grid; + this.ignoredGrid = ignoredGrid; + this.pitchfactor = Math.cos(transform._pitch) * transform.cameraToCenterDistance; + } + + placeCollisionBox(collisionBox: SingleCollisionBox, allowOverlap: boolean, textPixelRatio: number, posMatrix: mat4): Array { + const projectedPoint = this.projectAndGetPerspectiveRatio(posMatrix, collisionBox.anchorPointX, collisionBox.anchorPointY); + const tileToViewport = textPixelRatio * projectedPoint.perspectiveRatio; + const tlX = collisionBox.x1 / tileToViewport + projectedPoint.point.x; + const tlY = collisionBox.y1 / tileToViewport + projectedPoint.point.y; + const brX = collisionBox.x2 / tileToViewport + projectedPoint.point.x; + const brY = collisionBox.y2 / tileToViewport + projectedPoint.point.y; + + if (!allowOverlap) { + if (this.grid.hitTest(tlX, tlY, brX, brY)) { + return []; + } + } + return [tlX, tlY, brX, brY]; + } + + approximateTileDistance(tileDistance: any, lastSegmentAngle: number, pixelsToTileUnits: number, cameraToAnchorDistance: number, pitchWithMap: boolean): number { + // This is a quick and dirty solution for chosing which collision circles to use (since collision circles are + // laid out in tile units). Ideally, I think we should generate collision circles on the fly in viewport coordinates + // at the time we do collision detection. + // See https://github.com/mapbox/mapbox-gl-js/issues/5474 + + // incidenceStretch is the ratio of how much y space a label takes up on a tile while drawn perpendicular to the viewport vs + // how much space it would take up if it were drawn flat on the tile + // Using law of sines, camera_to_anchor/sin(ground_angle) = camera_to_center/sin(incidence_angle) + // Incidence angle 90 -> head on, sin(incidence_angle) = 1, no stretch + // Incidence angle 1 -> very oblique, sin(incidence_angle) =~ 0, lots of stretch + // ground_angle = u_pitch + PI/2 -> sin(ground_angle) = cos(u_pitch) + // incidenceStretch = 1 / sin(incidenceAngle) + + const incidenceStretch = pitchWithMap ? 1 : cameraToAnchorDistance / this.pitchfactor; + const lastSegmentTile = tileDistance.lastSegmentViewportDistance * pixelsToTileUnits; + return tileDistance.prevTileDistance + + lastSegmentTile + + (incidenceStretch - 1) * lastSegmentTile * Math.abs(Math.sin(lastSegmentAngle)); + } + + placeCollisionCircles(collisionCircles: Array, + allowOverlap: boolean, + scale: number, + textPixelRatio: number, + key: string, + symbol: any, + lineVertexArray: any, + glyphOffsetArray: any, + fontSize: number, + posMatrix: mat4, + labelPlaneMatrix: mat4, + showCollisionCircles: boolean, + pitchWithMap: boolean): Array { + const placedCollisionCircles = []; + + const projectedAnchor = this.projectAnchor(posMatrix, symbol.anchorX, symbol.anchorY); + + const projectionCache = {}; + const fontScale = fontSize / 24; + const lineOffsetX = symbol.lineOffsetX * fontSize; + const lineOffsetY = symbol.lineOffsetY * fontSize; + + const tileUnitAnchorPoint = new Point(symbol.anchorX, symbol.anchorY); + // projection.project generates NDC coordinates, as opposed to the + // pixel-based grid coordinates generated by this.projectPoint + const labelPlaneAnchorPoint = + projection.project(tileUnitAnchorPoint, labelPlaneMatrix).point; + const firstAndLastGlyph = projection.placeFirstAndLastGlyph( + fontScale, + glyphOffsetArray, + lineOffsetX, + lineOffsetY, + /*flip*/ false, + labelPlaneAnchorPoint, + tileUnitAnchorPoint, + symbol, + lineVertexArray, + labelPlaneMatrix, + projectionCache, + /*return tile distance*/ true); + + let collisionDetected = false; + + const tileToViewport = projectedAnchor.perspectiveRatio * textPixelRatio; + // equivalent to pixel_to_tile_units + const pixelsToTileUnits = tileToViewport / scale; + + let firstTileDistance = 0, lastTileDistance = 0; + if (firstAndLastGlyph) { + firstTileDistance = this.approximateTileDistance(firstAndLastGlyph.first.tileDistance, firstAndLastGlyph.first.angle, pixelsToTileUnits, projectedAnchor.cameraDistance, pitchWithMap); + lastTileDistance = this.approximateTileDistance(firstAndLastGlyph.last.tileDistance, firstAndLastGlyph.last.angle, pixelsToTileUnits, projectedAnchor.cameraDistance, pitchWithMap); + } + + for (let k = 0; k < collisionCircles.length; k += 5) { + const anchorPointX = collisionCircles[k]; + const anchorPointY = collisionCircles[k + 1]; + const tileUnitRadius = collisionCircles[k + 2]; + const boxSignedDistanceFromAnchor = collisionCircles[k + 3]; + if (!firstAndLastGlyph || + (boxSignedDistanceFromAnchor < -firstTileDistance) || + (boxSignedDistanceFromAnchor > lastTileDistance)) { + // The label either doesn't fit on its line or we + // don't need to use this circle because the label + // doesn't extend this far. Either way, mark the circle unused. + markCollisionCircleUsed(collisionCircles, k, false); + continue; + } + + const projectedPoint = this.projectPoint(posMatrix, anchorPointX, anchorPointY); + const radius = tileUnitRadius / tileToViewport; + + const atLeastOneCirclePlaced = placedCollisionCircles.length > 0; + if (atLeastOneCirclePlaced) { + const dx = projectedPoint.x - placedCollisionCircles[placedCollisionCircles.length - 4]; + const dy = projectedPoint.y - placedCollisionCircles[placedCollisionCircles.length - 3]; + // The circle edges touch when the distance between their centers is 2x the radius + // When the distance is 1x the radius, they're doubled up, and we could remove + // every other circle while keeping them all in touch. + // We actually start removing circles when the distance is √2x the radius: + // thinning the number of circles as much as possible is a major performance win, + // and the small gaps introduced don't make a very noticeable difference. + const placedTooDensely = radius * radius * 2 > dx * dx + dy * dy; + if (placedTooDensely) { + const atLeastOneMoreCircle = (k + 8) < collisionCircles.length; + if (atLeastOneMoreCircle) { + const nextBoxDistanceToAnchor = collisionCircles[k + 8]; + if ((nextBoxDistanceToAnchor > -firstTileDistance) && + (nextBoxDistanceToAnchor < lastTileDistance)) { + // Hide significantly overlapping circles, unless this is the last one we can + // use, in which case we want to keep it in place even if it's tightly packed + // with the one before it. + markCollisionCircleUsed(collisionCircles, k, false); + continue; + } + } + } + } + const collisionBoxArrayIndex = k / 5; + placedCollisionCircles.push(projectedPoint.x, projectedPoint.y, radius, collisionBoxArrayIndex); + markCollisionCircleUsed(collisionCircles, k, true); + + if (!allowOverlap) { + if (this.grid.hitTestCircle(projectedPoint.x, projectedPoint.y, radius)) { + if (!showCollisionCircles) { + return []; + } else { + // Don't early exit if we're showing the debug circles because we still want to calculate + // which circles are in use + collisionDetected = true; + } + } + } + } + + return collisionDetected ? [] : placedCollisionCircles; + } + + /** + * Because the geometries in the CollisionIndex are an approximation of the shape of + * symbols on the map, we use the CollisionIndex to look up the symbol part of + * `queryRenderedFeatures`. Non-symbol features are looked up tile-by-tile, and + * historically collisions were handled per-tile. + * + * For this reason, `queryRenderedSymbols` still takes tile coordinate inputs and + * converts them back to viewport coordinates. The change to a viewport coordinate + * CollisionIndex means it's now possible to re-design queryRenderedSymbols to + * run entirely in viewport coordinates, saving unnecessary conversions. + * See https://github.com/mapbox/mapbox-gl-js/issues/5475 + * + * @private + */ + queryRenderedSymbols(queryGeometry: any, tileCoord: TileCoord, tileSourceMaxZoom: number, textPixelRatio: number, collisionBoxArray: any) { + const sourceLayerFeatures = {}; + const result = []; + + if (queryGeometry.length === 0 || (this.grid.keysLength() === 0 && this.ignoredGrid.keysLength() === 0)) { + return result; + } + + const posMatrix = this.transform.calculatePosMatrix(tileCoord, tileSourceMaxZoom); + + const query = []; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (let i = 0; i < queryGeometry.length; i++) { + const ring = queryGeometry[i]; + for (let k = 0; k < ring.length; k++) { + const p = this.projectPoint(posMatrix, ring[k].x, ring[k].y); + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + query.push(p); + } + } + + const tileID = tileCoord.id; + + const thisTileFeatures = []; + const features = this.grid.query(minX, minY, maxX, maxY); + for (let i = 0; i < features.length; i++) { + if (features[i].tileID === tileID) { + thisTileFeatures.push(features[i].boxIndex); + } + } + const ignoredFeatures = this.ignoredGrid.query(minX, minY, maxX, maxY); + for (let i = 0; i < ignoredFeatures.length; i++) { + if (ignoredFeatures[i].tileID === tileID) { + thisTileFeatures.push(ignoredFeatures[i].boxIndex); + } + } + + for (let i = 0; i < thisTileFeatures.length; i++) { + const blocking = collisionBoxArray.get(thisTileFeatures[i]); + const sourceLayer = blocking.sourceLayerIndex; + const featureIndex = blocking.featureIndex; + + // Skip already seen features. + if (sourceLayerFeatures[sourceLayer] === undefined) { + sourceLayerFeatures[sourceLayer] = {}; + } + if (sourceLayerFeatures[sourceLayer][featureIndex]) continue; + + + // Check if query intersects with the feature box + // "Collision Circles" for line labels are treated as boxes here + // Since there's no actual collision taking place, the circle vs. square + // distinction doesn't matter as much, and box geometry is easier + // to work with. + const projectedPoint = this.projectAndGetPerspectiveRatio(posMatrix, blocking.anchorPointX, blocking.anchorPointY); + const tileToViewport = textPixelRatio * projectedPoint.perspectiveRatio; + const x1 = blocking.x1 / tileToViewport + projectedPoint.point.x; + const y1 = blocking.y1 / tileToViewport + projectedPoint.point.y; + const x2 = blocking.x2 / tileToViewport + projectedPoint.point.x; + const y2 = blocking.y2 / tileToViewport + projectedPoint.point.y; + const bbox = [ + new Point(x1, y1), + new Point(x2, y1), + new Point(x2, y2), + new Point(x1, y2) + ]; + if (!intersectionTests.polygonIntersectsPolygon(query, bbox)) continue; + + sourceLayerFeatures[sourceLayer][featureIndex] = true; + result.push(thisTileFeatures[i]); + } + + return result; + } + + insertCollisionBox(collisionBox: Array, ignorePlacement: boolean, tileID: number, boxStartIndex: number) { + const grid = ignorePlacement ? this.ignoredGrid : this.grid; + + const key = { tileID: tileID, boxIndex: boxStartIndex }; + grid.insert(key, collisionBox[0], collisionBox[1], collisionBox[2], collisionBox[3]); + } + + insertCollisionCircles(collisionCircles: Array, ignorePlacement: boolean, tileID: number, boxStartIndex: number) { + const grid = ignorePlacement ? this.ignoredGrid : this.grid; + + for (let k = 0; k < collisionCircles.length; k += 4) { + const key = { tileID: tileID, boxIndex: boxStartIndex + collisionCircles[k + 3] }; + grid.insertCircle(key, collisionCircles[k], collisionCircles[k + 1], collisionCircles[k + 2]); + } + } + + projectAnchor(posMatrix: mat4, x: number, y: number) { + const p = [x, y, 0, 1]; + projection.xyTransformMat4(p, p, posMatrix); + return { + perspectiveRatio: 0.5 + 0.5 * (p[3] / this.transform.cameraToCenterDistance), + cameraDistance: p[3] + }; + } + + projectPoint(posMatrix: mat4, x: number, y: number) { + const p = [x, y, 0, 1]; + projection.xyTransformMat4(p, p, posMatrix); + return new Point( + (((p[0] / p[3] + 1) / 2) * this.transform.width) + viewportPadding, + (((-p[1] / p[3] + 1) / 2) * this.transform.height) + viewportPadding + ); + } + + projectAndGetPerspectiveRatio(posMatrix: mat4, x: number, y: number) { + const p = [x, y, 0, 1]; + projection.xyTransformMat4(p, p, posMatrix); + const a = new Point( + (((p[0] / p[3] + 1) / 2) * this.transform.width) + viewportPadding, + (((-p[1] / p[3] + 1) / 2) * this.transform.height) + viewportPadding + ); + return { + point: a, + perspectiveRatio: 0.5 + 0.5 * (p[3] / this.transform.cameraToCenterDistance) + }; + } + +} + +function markCollisionCircleUsed(collisionCircles: Array, index: number, used: boolean) { + collisionCircles[index + 4] = used ? 1 : 0; +} + +module.exports = CollisionIndex; diff --git a/src/symbol/collision_tile.js b/src/symbol/collision_tile.js deleted file mode 100644 index 019dd61d56e..00000000000 --- a/src/symbol/collision_tile.js +++ /dev/null @@ -1,394 +0,0 @@ -// @flow - -const Point = require('@mapbox/point-geometry'); -const EXTENT = require('../data/extent'); -const Grid = require('grid-index'); -const intersectionTests = require('../util/intersection_tests'); - -import type CollisionBoxArray, {CollisionBox} from './collision_box'; - -export type SerializedCollisionTile = {| - angle: number, - pitch: number, - cameraToCenterDistance: number, - cameraToTileDistance: number, - grid: ArrayBuffer, - ignoredGrid: ArrayBuffer -|}; - -/** - * A collision tile used to prevent symbols from overlapping. It keep tracks of - * where previous symbols have been placed and is used to check if a new - * symbol overlaps with any previously added symbols. - * - * @private - */ -class CollisionTile { - grid: Grid; - ignoredGrid: Grid; - perspectiveRatio: number; - minScale: number; - maxScale: number; - angle: number; - pitch: number; - cameraToCenterDistance: number; - cameraToTileDistance: number; - rotationMatrix: [number, number, number, number]; - reverseRotationMatrix: [number, number, number, number]; - yStretch: number; - collisionBoxArray: CollisionBoxArray; - tempCollisionBox: CollisionBox; - edges: Array; - - static deserialize(serialized: SerializedCollisionTile, collisionBoxArray: any) { - return new CollisionTile( - serialized.angle, - serialized.pitch, - serialized.cameraToCenterDistance, - serialized.cameraToTileDistance, - collisionBoxArray, - new Grid(serialized.grid), - new Grid(serialized.ignoredGrid) - ); - } - - constructor( - angle: number, - pitch: number, - cameraToCenterDistance: number, - cameraToTileDistance: number, - collisionBoxArray: any, - grid: Grid = new Grid(EXTENT, 12, 6), - ignoredGrid: Grid = new Grid(EXTENT, 12, 0) - ) { - this.angle = angle; - this.pitch = pitch; - this.cameraToCenterDistance = cameraToCenterDistance; - this.cameraToTileDistance = cameraToTileDistance; - - this.grid = grid; - this.ignoredGrid = ignoredGrid; - - this.perspectiveRatio = 1 + 0.5 * ((cameraToTileDistance / cameraToCenterDistance) - 1); - - // High perspective ratio means we're effectively "underzooming" - // the tile. Adjust the minScale and maxScale range accordingly - // to constrain the number of collision calculations - this.minScale = .5 / this.perspectiveRatio; - this.maxScale = 2 / this.perspectiveRatio; - - const sin = Math.sin(this.angle), - cos = Math.cos(this.angle); - this.rotationMatrix = [cos, -sin, sin, cos]; - this.reverseRotationMatrix = [cos, sin, -sin, cos]; - - // Stretch boxes in y direction to account for the map tilt. - // The amount the map is squished depends on the y position. - // We can only approximate here based on the y position of the tile - // The shaders calculate a more accurate "incidence_stretch" - // at render time to calculate an effective scale for collision - // purposes, but we still want to use the yStretch approximation - // here because we can't adjust the aspect ratio of the collision - // boxes at render time. - this.yStretch = Math.max(1, cameraToTileDistance / (cameraToCenterDistance * Math.cos(pitch / 180 * Math.PI))); - - this.collisionBoxArray = collisionBoxArray; - if (collisionBoxArray.length === 0) { - // the first time collisionBoxArray is passed to a CollisionTile - - // tempCollisionBox - collisionBoxArray.emplaceBack(); - - //left - collisionBoxArray.emplaceBack(0, 0, 0, 0, 0, -EXTENT, 0, EXTENT, Infinity, Infinity, - 0, 0, 0, 0, 0, 0, 0, 0, 0); - // right - collisionBoxArray.emplaceBack(EXTENT, 0, 0, 0, 0, -EXTENT, 0, EXTENT, Infinity, Infinity, - 0, 0, 0, 0, 0, 0, 0, 0, 0); - // top - collisionBoxArray.emplaceBack(0, 0, 0, 0, -EXTENT, 0, EXTENT, 0, Infinity, Infinity, - 0, 0, 0, 0, 0, 0, 0, 0, 0); - // bottom - collisionBoxArray.emplaceBack(0, EXTENT, 0, 0, -EXTENT, 0, EXTENT, 0, Infinity, Infinity, - 0, 0, 0, 0, 0, 0, 0, 0, 0); - } - - this.tempCollisionBox = collisionBoxArray.get(0); - this.edges = [ - collisionBoxArray.get(1), - collisionBoxArray.get(2), - collisionBoxArray.get(3), - collisionBoxArray.get(4) - ]; - } - - serialize(transferables: ?Array): SerializedCollisionTile { - const grid = this.grid.toArrayBuffer(); - const ignoredGrid = this.ignoredGrid.toArrayBuffer(); - if (transferables) { - transferables.push(grid); - transferables.push(ignoredGrid); - } - return { - angle: this.angle, - pitch: this.pitch, - cameraToCenterDistance: this.cameraToCenterDistance, - cameraToTileDistance: this.cameraToTileDistance, - grid: grid, - ignoredGrid: ignoredGrid - }; - } - - /** - * Find the scale at which the collisionFeature can be shown without - * overlapping with other features. - * @private - */ - placeCollisionFeature(collisionFeature: {boxStartIndex: number, boxEndIndex: number}, - allowOverlap: boolean, - avoidEdges: boolean): number { - - const collisionBoxArray = this.collisionBoxArray; - let minPlacementScale = this.minScale; - const rotationMatrix = this.rotationMatrix; - const yStretch = this.yStretch; - - for (let b = collisionFeature.boxStartIndex; b < collisionFeature.boxEndIndex; b++) { - - const box: CollisionBox = (collisionBoxArray.get(b): any); - - const anchorPoint = box.anchorPoint._matMult(rotationMatrix); - const x = anchorPoint.x; - const y = anchorPoint.y; - - // When the 'perspectiveRatio' is high, we're effectively underzooming - // the tile because it's in the distance. - // In order to detect collisions that only happen while underzoomed, - // we have to query a larger portion of the grid. - // This extra work is offset by having a lower 'maxScale' bound - // Note that this adjustment ONLY affects the bounding boxes - // in the grid. It doesn't affect the boxes used for the - // minPlacementScale calculations. - const x1 = x + box.x1 * this.perspectiveRatio; - const y1 = y + box.y1 * yStretch * this.perspectiveRatio; - const x2 = x + box.x2 * this.perspectiveRatio; - const y2 = y + box.y2 * yStretch * this.perspectiveRatio; - - box.bbox0 = x1; - box.bbox1 = y1; - box.bbox2 = x2; - box.bbox3 = y2; - - // When the map is pitched the distance covered by a line changes. - // Adjust the max scale by (approximatePitchedLength / approximateRegularLength) - // to compensate for this. - - const offset = new Point(box.offsetX, box.offsetY)._matMult(rotationMatrix); - const xSqr = offset.x * offset.x; - const ySqr = offset.y * offset.y; - const yStretchSqr = ySqr * yStretch * yStretch; - const adjustmentFactor = Math.sqrt((xSqr + yStretchSqr) / (xSqr + ySqr)) || 1; - box.maxScale = box.unadjustedMaxScale * adjustmentFactor; - - if (!allowOverlap) { - const blockingBoxes = this.grid.query(x1, y1, x2, y2); - - for (let i = 0; i < blockingBoxes.length; i++) { - const blocking: CollisionBox = (collisionBoxArray.get(blockingBoxes[i]): any); - const blockingAnchorPoint = blocking.anchorPoint._matMult(rotationMatrix); - - minPlacementScale = this.getPlacementScale(minPlacementScale, anchorPoint, box, blockingAnchorPoint, blocking); - if (minPlacementScale >= this.maxScale) { - return minPlacementScale; - } - } - } - - if (avoidEdges) { - let rotatedCollisionBox; - - if (this.angle) { - const reverseRotationMatrix = this.reverseRotationMatrix; - const tl = new Point(box.x1, box.y1).matMult(reverseRotationMatrix); - const tr = new Point(box.x2, box.y1).matMult(reverseRotationMatrix); - const bl = new Point(box.x1, box.y2).matMult(reverseRotationMatrix); - const br = new Point(box.x2, box.y2).matMult(reverseRotationMatrix); - - rotatedCollisionBox = this.tempCollisionBox; - rotatedCollisionBox.anchorPointX = box.anchorPoint.x; - rotatedCollisionBox.anchorPointY = box.anchorPoint.y; - rotatedCollisionBox.x1 = Math.min(tl.x, tr.x, bl.x, br.x); - rotatedCollisionBox.y1 = Math.min(tl.y, tr.x, bl.x, br.x); - rotatedCollisionBox.x2 = Math.max(tl.x, tr.x, bl.x, br.x); - rotatedCollisionBox.y2 = Math.max(tl.y, tr.x, bl.x, br.x); - rotatedCollisionBox.maxScale = box.maxScale; - } else { - rotatedCollisionBox = box; - } - - for (let k = 0; k < this.edges.length; k++) { - const edgeBox = this.edges[k]; - minPlacementScale = this.getPlacementScale(minPlacementScale, box.anchorPoint, rotatedCollisionBox, edgeBox.anchorPoint, edgeBox); - if (minPlacementScale >= this.maxScale) { - return minPlacementScale; - } - } - } - } - - return minPlacementScale; - } - - queryRenderedSymbols(queryGeometry: Array>, scale: number): Array<*> { - const sourceLayerFeatures = {}; - const result = []; - - if (queryGeometry.length === 0 || (this.grid.keys.length === 0 && this.ignoredGrid.keys.length === 0)) { - return result; - } - - const collisionBoxArray = this.collisionBoxArray; - const rotationMatrix = this.rotationMatrix; - const yStretch = this.yStretch; - - // Generate a rotated geometry out of the original query geometry. - // Scale has already been handled by the prior conversions. - const rotatedQuery = []; - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (let i = 0; i < queryGeometry.length; i++) { - const ring = queryGeometry[i]; - for (let k = 0; k < ring.length; k++) { - const p = ring[k].matMult(rotationMatrix); - minX = Math.min(minX, p.x); - minY = Math.min(minY, p.y); - maxX = Math.max(maxX, p.x); - maxY = Math.max(maxY, p.y); - rotatedQuery.push(p); - } - } - - const features = this.grid.query(minX, minY, maxX, maxY); - const ignoredFeatures = this.ignoredGrid.query(minX, minY, maxX, maxY); - for (let i = 0; i < ignoredFeatures.length; i++) { - features.push(ignoredFeatures[i]); - } - - // "perspectiveRatio" is a tile-based approximation of how much larger symbols will - // be in the distance. It won't line up exactly with the actually rendered symbols - // Being exact would require running the collision detection logic in symbol_sdf.vertex - // in the CPU - const perspectiveScale = scale / this.perspectiveRatio; - - // Account for the rounding done when updating symbol shader variables. - const roundedScale = Math.pow(2, Math.ceil(Math.log(perspectiveScale) / Math.LN2 * 10) / 10); - - for (let i = 0; i < features.length; i++) { - const blocking: CollisionBox = (collisionBoxArray.get(features[i]): any); - const sourceLayer = blocking.sourceLayerIndex; - const featureIndex = blocking.featureIndex; - - // Skip already seen features. - if (sourceLayerFeatures[sourceLayer] === undefined) { - sourceLayerFeatures[sourceLayer] = {}; - } - if (sourceLayerFeatures[sourceLayer][featureIndex]) continue; - - // Check if feature is rendered (collision free) at current scale. - if (roundedScale < blocking.placementScale || roundedScale > blocking.maxScale) continue; - - // Check if query intersects with the feature box at current scale. - const anchor = blocking.anchorPoint.matMult(rotationMatrix); - const x1 = anchor.x + blocking.x1 / perspectiveScale; - const y1 = anchor.y + blocking.y1 / perspectiveScale * yStretch; - const x2 = anchor.x + blocking.x2 / perspectiveScale; - const y2 = anchor.y + blocking.y2 / perspectiveScale * yStretch; - const bbox = [ - new Point(x1, y1), - new Point(x2, y1), - new Point(x2, y2), - new Point(x1, y2) - ]; - if (!intersectionTests.polygonIntersectsPolygon(rotatedQuery, bbox)) continue; - - sourceLayerFeatures[sourceLayer][featureIndex] = true; - result.push(features[i]); - } - - return result; - } - - getPlacementScale(minPlacementScale: number, - anchorPoint: Point, - box: CollisionBox, - blockingAnchorPoint: Point, - blocking: CollisionBox): number { - - // Find the lowest scale at which the two boxes can fit side by side without overlapping. - // Original algorithm: - const anchorDiffX = anchorPoint.x - blockingAnchorPoint.x; - const anchorDiffY = anchorPoint.y - blockingAnchorPoint.y; - let s1 = (blocking.x1 - box.x2) / anchorDiffX; // scale at which new box is to the left of old box - let s2 = (blocking.x2 - box.x1) / anchorDiffX; // scale at which new box is to the right of old box - let s3 = (blocking.y1 - box.y2) * this.yStretch / anchorDiffY; // scale at which new box is to the top of old box - let s4 = (blocking.y2 - box.y1) * this.yStretch / anchorDiffY; // scale at which new box is to the bottom of old box - - if (isNaN(s1) || isNaN(s2)) s1 = s2 = 1; - if (isNaN(s3) || isNaN(s4)) s3 = s4 = 1; - - let collisionFreeScale = Math.min(Math.max(s1, s2), Math.max(s3, s4)); - const blockingMaxScale = blocking.maxScale; - const boxMaxScale = box.maxScale; - - if (collisionFreeScale > blockingMaxScale) { - // After a box's maxScale the label has shrunk enough that the box is no longer needed to cover it, - // so unblock the new box at the scale that the old box disappears. - collisionFreeScale = blockingMaxScale; - } - - if (collisionFreeScale > boxMaxScale) { - // If the box can only be shown after it is visible, then the box can never be shown. - // But the label can be shown after this box is not visible. - collisionFreeScale = boxMaxScale; - } - - if (collisionFreeScale > minPlacementScale && - collisionFreeScale >= blocking.placementScale) { - // If this collision occurs at a lower scale than previously found collisions - // and the collision occurs while the other label is visible - - // this this is the lowest scale at which the label won't collide with anything - minPlacementScale = collisionFreeScale; - } - - return minPlacementScale; - } - - - /** - * Remember this collisionFeature and what scale it was placed at to block - * later features from overlapping with it. - * @private - */ - insertCollisionFeature(collisionFeature: {boxStartIndex: number, boxEndIndex: number}, - minPlacementScale: number, - ignorePlacement: boolean) { - const grid = ignorePlacement ? this.ignoredGrid : this.grid; - const collisionBoxArray = this.collisionBoxArray; - - for (let k = collisionFeature.boxStartIndex; k < collisionFeature.boxEndIndex; k++) { - const box: CollisionBox = (collisionBoxArray.get(k): any); - box.placementScale = minPlacementScale; - if (minPlacementScale < this.maxScale && - (this.perspectiveRatio === 1 || box.maxScale >= 1)) { - // Boxes with maxScale < 1 are only relevant in pitched maps, - // so filter them out in unpitched maps to keep the grid sparse - grid.insert(k, box.bbox0, box.bbox1, box.bbox2, box.bbox3); - } - } - } -} - -module.exports = CollisionTile; diff --git a/src/symbol/cross_tile_symbol_index.js b/src/symbol/cross_tile_symbol_index.js new file mode 100644 index 00000000000..3b3cca5030d --- /dev/null +++ b/src/symbol/cross_tile_symbol_index.js @@ -0,0 +1,256 @@ +// @flow +const EXTENT = require('../data/extent'); +const OpacityState = require('./opacity_state'); +const assert = require('assert'); + +import type TileCoord from '../source/tile_coord'; +import type {SymbolInstance} from '../data/bucket/symbol_bucket'; + +/* + The CrossTileSymbolIndex generally works on the assumption that + a conceptual "unique symbol" can be identified by the text of + the label combined with the anchor point. The goal is to keep + symbol opacity states (determined by collision detection animations) + consistent as we switch tile resolutions. + + Whenever we add a label, we look for duplicates at lower resolution, + and if we find one, we copy its opacity state. If we find duplicates + at higher resolution, we mark the added label as "blocked". + + When we remove a label that's currently showing, we look for duplicates + at higher resolution, and if we find one we copy our opacity state + to that label. + + The code mostly assumes that at any given time a "unique symbol" will have + one "non-duplicate" entry, and that the rest of the entries in the + index will all be marked as duplicate. This is not necessarily true: + + (1) The code searches child/parent hierarchies for duplicates, but it + is possible for the source to contain symbols with anchors on tile + boundaries, where the symbol does not stay in the same hierarchy + at all zoom levels. + (2) A high resolution tile may contain two symbols with the same label + but different anchor points. At lower resolution, both of those + symbols will appear to be the same. + + In the cases that violate our assumptions, copying opacities between + zoom levels won't work as expected. However, the highest resolution + tile should always "win", so that after some fade flicker the right + label will show. +*/ + +// Round anchor positions to roughly 4 pixel grid +const roundingFactor = 512 / EXTENT / 2; + +class TileLayerIndex { + coord: TileCoord; + sourceMaxZoom: number; + symbolInstances: any; + + constructor(coord: TileCoord, sourceMaxZoom: number, symbolInstances: Array) { + this.coord = coord; + this.sourceMaxZoom = sourceMaxZoom; + this.symbolInstances = {}; + + for (const symbolInstance of symbolInstances) { + const key = symbolInstance.key; + if (!this.symbolInstances[key]) { + this.symbolInstances[key] = []; + } + // This tile may have multiple symbol instances with the same key + // Store each one along with its coordinates + this.symbolInstances[key].push({ + instance: symbolInstance, + coordinates: this.getScaledCoordinates(symbolInstance, coord) + }); + symbolInstance.isDuplicate = false; + // If we don't pick up an opacity from our parent or child tiles + // Reset so that symbols in cached tiles fade in the same + // way as freshly loaded tiles + symbolInstance.textOpacityState = new OpacityState(); + symbolInstance.iconOpacityState = new OpacityState(); + } + } + + // Converts the coordinates of the input symbol instance into coordinates that be can compared + // against other symbols in this index. Coordinates are: + // (1) world-based (so after conversion the source tile is irrelevant) + // (2) converted to the z-scale of this TileLayerIndex + // (3) down-sampled by "roundingFactor" from tile coordinate precision in order to be + // more tolerant of small differences between tiles. + getScaledCoordinates(symbolInstance: SymbolInstance, childTileCoord: TileCoord): any { + const zDifference = Math.min(this.sourceMaxZoom, childTileCoord.z) - Math.min(this.sourceMaxZoom, this.coord.z); + const scale = roundingFactor / (1 << zDifference); + const anchor = symbolInstance.anchor; + return { + x: Math.floor((childTileCoord.x * EXTENT + anchor.x) * scale), + y: Math.floor((childTileCoord.y * EXTENT + anchor.y) * scale) + }; + } + + getMatchingSymbol(childTileSymbol: SymbolInstance, childTileCoord: TileCoord) { + if (!this.symbolInstances[childTileSymbol.key]) { + return; + } + + const childTileSymbolCoordinates = + this.getScaledCoordinates(childTileSymbol, childTileCoord); + + for (const thisTileSymbol of this.symbolInstances[childTileSymbol.key]) { + // Return any symbol with the same keys whose coordinates are within 1 + // grid unit. (with a 4px grid, this covers a 12px by 12px area) + if (Math.abs(thisTileSymbol.coordinates.x - childTileSymbolCoordinates.x) <= 1 && + Math.abs(thisTileSymbol.coordinates.y - childTileSymbolCoordinates.y) <= 1) { + return thisTileSymbol.instance; + } + } + } + + forEachSymbolInstance(fn: any) { + for (const key in this.symbolInstances) { + const keyedSymbolInstances = this.symbolInstances[key]; + for (const symbolInstance of keyedSymbolInstances) { + fn(symbolInstance.instance); + } + } + } +} + +class CrossTileSymbolLayerIndex { + indexes: any; + + constructor() { + this.indexes = {}; + } + + addTile(coord: TileCoord, sourceMaxZoom: number, symbolInstances: Array) { + + let minZoom = 25; + let maxZoom = 0; + for (const zoom in this.indexes) { + minZoom = Math.min((zoom: any), minZoom); + maxZoom = Math.max((zoom: any), maxZoom); + } + + const tileIndex = new TileLayerIndex(coord, sourceMaxZoom, symbolInstances); + + // make all higher-res child tiles block duplicate labels in this tile + for (let z = maxZoom; z > coord.z; z--) { + const zoomIndexes = this.indexes[z]; + for (const id in zoomIndexes) { + const childIndex = zoomIndexes[id]; + if (!childIndex.coord.isChildOf(coord, sourceMaxZoom)) continue; + // Mark labels in this tile blocked, and don't copy opacity state + // into this tile + this.blockLabels(childIndex, tileIndex, false); + } + } + + // make this tile block duplicate labels in lower-res parent tiles + let parentCoord = coord; + for (let z = coord.z - 1; z >= minZoom; z--) { + parentCoord = (parentCoord: any).parent(sourceMaxZoom); + const parentIndex = this.indexes[z] && this.indexes[z][parentCoord.id]; + if (parentIndex) { + // Mark labels in the parent tile blocked, and copy opacity state + // into this tile + this.blockLabels(tileIndex, parentIndex, true); + } + } + + if (this.indexes[coord.z] === undefined) { + this.indexes[coord.z] = {}; + } + this.indexes[coord.z][coord.id] = tileIndex; + } + + removeTile(coord: TileCoord, sourceMaxZoom: number) { + const removedIndex = this.indexes[coord.z][coord.id]; + + delete this.indexes[coord.z][coord.id]; + if (Object.keys(this.indexes[coord.z]).length === 0) { + delete this.indexes[coord.z]; + } + + const minZoom = Object.keys(this.indexes).reduce((minZoom, zoom) => { + return Math.min(minZoom, zoom); + }, 25); + + let parentCoord = coord; + for (let z = coord.z - 1; z >= minZoom; z--) { + parentCoord = parentCoord.parent(sourceMaxZoom); + if (!parentCoord) break; // Flow doesn't know that z >= minZoom would prevent this + const parentIndex = this.indexes[z] && this.indexes[z][parentCoord.id]; + if (parentIndex) this.unblockLabels(removedIndex, parentIndex); + } + } + + blockLabels(childIndex: TileLayerIndex, parentIndex: TileLayerIndex, copyParentOpacity: boolean) { + childIndex.forEachSymbolInstance((symbolInstance) => { + // only non-duplicate labels can block other labels + if (!symbolInstance.isDuplicate) { + + const parentSymbolInstance = parentIndex.getMatchingSymbol(symbolInstance, childIndex.coord); + if (parentSymbolInstance !== undefined) { + // if the parent label was previously non-duplicate, make it duplicate because it's now blocked + if (!parentSymbolInstance.isDuplicate) { + parentSymbolInstance.isDuplicate = true; + + // If the child label is the one being added to the index, + // copy the parent's opacity to the child + if (copyParentOpacity) { + symbolInstance.textOpacityState = parentSymbolInstance.textOpacityState.clone(); + symbolInstance.iconOpacityState = parentSymbolInstance.iconOpacityState.clone(); + } + } + } + } + }); + } + + unblockLabels(childIndex: TileLayerIndex, parentIndex: TileLayerIndex) { + assert(childIndex.coord.z > parentIndex.coord.z); + childIndex.forEachSymbolInstance((symbolInstance) => { + // only non-duplicate labels were blocking other labels + if (!symbolInstance.isDuplicate) { + + const parentSymbolInstance = parentIndex.getMatchingSymbol(symbolInstance, childIndex.coord); + if (parentSymbolInstance !== undefined) { + // this label is now unblocked, copy its opacity state + parentSymbolInstance.isDuplicate = false; + parentSymbolInstance.textOpacityState = symbolInstance.textOpacityState.clone(); + parentSymbolInstance.iconOpacityState = symbolInstance.iconOpacityState.clone(); + + // mark child as duplicate so that it doesn't unblock further tiles at lower res + // in the remaining calls to unblockLabels before it's fully removed + symbolInstance.isDuplicate = true; + } + } + }); + } +} + +class CrossTileSymbolIndex { + layerIndexes: any; + + constructor() { + this.layerIndexes = {}; + } + + addTileLayer(layerId: string, coord: TileCoord, sourceMaxZoom: number, symbolInstances: Array) { + let layerIndex = this.layerIndexes[layerId]; + if (layerIndex === undefined) { + layerIndex = this.layerIndexes[layerId] = new CrossTileSymbolLayerIndex(); + } + layerIndex.addTile(coord, sourceMaxZoom, symbolInstances); + } + + removeTileLayer(layerId: string, coord: TileCoord, sourceMaxZoom: number) { + const layerIndex = this.layerIndexes[layerId]; + if (layerIndex !== undefined) { + layerIndex.removeTile(coord, sourceMaxZoom); + } + } +} + +module.exports = CrossTileSymbolIndex; diff --git a/src/symbol/grid_index.js b/src/symbol/grid_index.js new file mode 100644 index 00000000000..0c9ef6b7d09 --- /dev/null +++ b/src/symbol/grid_index.js @@ -0,0 +1,295 @@ +// @flow + +/** + * GridIndex is a data structure for testing the intersection of + * circles and rectangles in a 2d plane. + * It is optimized for rapid insertion and querying. + * GridIndex splits the plane into a set of "cells" and keeps track + * of which geometries intersect with each cell. At query time, + * full geometry comparisons are only done for items that share + * at least one cell. As long as the geometries are relatively + * uniformly distributed across the plane, this greatly reduces + * the number of comparisons necessary. + * + * @private + */ +class GridIndex { + circleKeys: Array; + boxKeys: Array; + boxCells: Array>; + circleCells: Array>; + bboxes: Array; + circles: Array; + xCellCount: number; + yCellCount: number; + width: number; + height: number; + xScale: number; + yScale: number; + boxUid: number; + circleUid: number; + + constructor (width: number, height: number, cellSize: number) { + const boxCells = this.boxCells = []; + const circleCells = this.circleCells = []; + + // More cells -> fewer geometries to check per cell, but items tend + // to be split across more cells. + // Sweet spot allows most small items to fit in one cell + this.xCellCount = Math.ceil(width / cellSize); + this.yCellCount = Math.ceil(height / cellSize); + + for (let i = 0; i < this.xCellCount * this.yCellCount; i++) { + boxCells.push([]); + circleCells.push([]); + } + this.circleKeys = []; + this.boxKeys = []; + this.bboxes = []; + this.circles = []; + + this.width = width; + this.height = height; + this.xScale = this.xCellCount / width; + this.yScale = this.yCellCount / height; + this.boxUid = 0; + this.circleUid = 0; + } + + keysLength() { + return this.boxKeys.length + this.circleKeys.length; + } + + insert(key: any, x1: number, y1: number, x2: number, y2: number) { + this._forEachCell(x1, y1, x2, y2, this._insertBoxCell, this.boxUid++); + this.boxKeys.push(key); + this.bboxes.push(x1); + this.bboxes.push(y1); + this.bboxes.push(x2); + this.bboxes.push(y2); + } + + insertCircle(key: any, x: number, y: number, radius: number) { + // Insert circle into grid for all cells in the circumscribing square + // It's more than necessary (by a factor of 4/PI), but fast to insert + this._forEachCell(x - radius, y - radius, x + radius, y + radius, this._insertCircleCell, this.circleUid++); + this.circleKeys.push(key); + this.circles.push(x); + this.circles.push(y); + this.circles.push(radius); + } + + _insertBoxCell(x1: number, y1: number, x2: number, y2: number, cellIndex: number, uid: number) { + this.boxCells[cellIndex].push(uid); + } + + _insertCircleCell(x1: number, y1: number, x2: number, y2: number, cellIndex: number, uid: number) { + this.circleCells[cellIndex].push(uid); + } + + _query(x1: number, y1: number, x2: number, y2: number, hitTest: boolean) { + if (x2 < 0 || x1 > this.width || y2 < 0 || y1 > this.height) { + return hitTest ? false : []; + } + let result = []; + if (x1 <= 0 && y1 <= 0 && this.width <= x2 && this.height <= y2) { + // We use `Array#slice` because `this.keys` may be a `Int32Array` and + // some browsers (Safari and IE) do not support `TypedArray#slice` + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/slice#Browser_compatibility + result = Array.prototype.slice.call(this.boxKeys).concat(this.circleKeys); + } else { + const queryArgs = { + hitTest, + seenUids: { box: {}, circle: {} } + }; + this._forEachCell(x1, y1, x2, y2, this._queryCell, result, queryArgs); + } + return hitTest ? result.length > 0 : result; + } + + _queryCircle(x: number, y: number, radius: number, hitTest: boolean) { + // Insert circle into grid for all cells in the circumscribing square + // It's more than necessary (by a factor of 4/PI), but fast to insert + const x1 = x - radius; + const x2 = x + radius; + const y1 = y - radius; + const y2 = y + radius; + if (x2 < 0 || x1 > this.width || y2 < 0 || y1 > this.height) { + return hitTest ? false : []; + } + + // Box query early exits if the bounding box is larger than the grid, but we don't do + // the equivalent calculation for circle queries because early exit is less likely + // and the calculation is more expensive + const result = []; + const queryArgs = { + hitTest, + circle: { x: x, y: y, radius: radius }, + seenUids: { box: {}, circle: {} } + }; + this._forEachCell(x1, y1, x2, y2, this._queryCellCircle, result, queryArgs); + return hitTest ? result.length > 0 : result; + } + + query(x1: number, y1: number, x2: number, y2: number): Array { + return (this._query(x1, y1, x2, y2, false): any); + } + + hitTest(x1: number, y1: number, x2: number, y2: number): boolean { + return (this._query(x1, y1, x2, y2, true): any); + } + + hitTestCircle(x: number, y: number, radius: number): boolean { + return (this._queryCircle(x, y, radius, true): any); + } + + _queryCell(x1: number, y1: number, x2: number, y2: number, cellIndex: number, result: any, queryArgs: any) { + const seenUids = queryArgs.seenUids; + const boxCell = this.boxCells[cellIndex]; + if (boxCell !== null) { + const bboxes = this.bboxes; + for (const boxUid of boxCell) { + if (!seenUids.box[boxUid]) { + seenUids.box[boxUid] = true; + const offset = boxUid * 4; + if ((x1 <= bboxes[offset + 2]) && + (y1 <= bboxes[offset + 3]) && + (x2 >= bboxes[offset + 0]) && + (y2 >= bboxes[offset + 1])) { + if (queryArgs.hitTest) { + result.push(true); + return true; + } else { + result.push(this.boxKeys[boxUid]); + } + } + } + } + } + const circleCell = this.circleCells[cellIndex]; + if (circleCell !== null) { + const circles = this.circles; + for (const circleUid of circleCell) { + if (!seenUids.circle[circleUid]) { + seenUids.circle[circleUid] = true; + const offset = circleUid * 3; + if (this._circleAndRectCollide( + circles[offset], + circles[offset + 1], + circles[offset + 2], + x1, + y1, + x2, + y2)) { + if (queryArgs.hitTest) { + result.push(true); + return true; + } else { + result.push(this.circleKeys[circleUid]); + } + } + } + } + } + } + + _queryCellCircle(x1: number, y1: number, x2: number, y2: number, cellIndex: number, result: any, queryArgs: any) { + const circle = queryArgs.circle; + const seenUids = queryArgs.seenUids; + const boxCell = this.boxCells[cellIndex]; + if (boxCell !== null) { + const bboxes = this.bboxes; + for (const boxUid of boxCell) { + if (!seenUids.box[boxUid]) { + seenUids.box[boxUid] = true; + const offset = boxUid * 4; + if (this._circleAndRectCollide( + circle.x, + circle.y, + circle.radius, + bboxes[offset + 0], + bboxes[offset + 1], + bboxes[offset + 2], + bboxes[offset + 3])) { + result.push(true); + return true; + } + } + } + } + + const circleCell = this.circleCells[cellIndex]; + if (circleCell !== null) { + const circles = this.circles; + for (const circleUid of circleCell) { + if (!seenUids.circle[circleUid]) { + seenUids.circle[circleUid] = true; + const offset = circleUid * 3; + if (this._circlesCollide( + circles[offset], + circles[offset + 1], + circles[offset + 2], + circle.x, + circle.y, + circle.radius)) { + result.push(true); + return true; + } + } + } + } + } + + _forEachCell(x1: number, y1: number, x2: number, y2: number, fn: any, arg1: any, arg2?: any) { + const cx1 = this._convertToXCellCoord(x1); + const cy1 = this._convertToYCellCoord(y1); + const cx2 = this._convertToXCellCoord(x2); + const cy2 = this._convertToYCellCoord(y2); + + for (let x = cx1; x <= cx2; x++) { + for (let y = cy1; y <= cy2; y++) { + const cellIndex = this.xCellCount * y + x; + if (fn.call(this, x1, y1, x2, y2, cellIndex, arg1, arg2)) return; + } + } + } + + _convertToXCellCoord(x: number) { + return Math.max(0, Math.min(this.xCellCount - 1, Math.floor(x * this.xScale))); + } + + _convertToYCellCoord(y: number) { + return Math.max(0, Math.min(this.yCellCount - 1, Math.floor(y * this.yScale))); + } + + _circlesCollide(x1: number, y1: number, r1: number, x2: number, y2: number, r2: number): boolean { + const dx = x2 - x1; + const dy = y2 - y1; + const bothRadii = r1 + r2; + return (bothRadii * bothRadii) > (dx * dx + dy * dy); + } + + _circleAndRectCollide(circleX: number, circleY: number, radius: number, x1: number, y1: number, x2: number, y2: number): boolean { + const halfRectWidth = (x2 - x1) / 2; + const distX = Math.abs(circleX - (x1 + halfRectWidth)); + if (distX > (halfRectWidth + radius)) { + return false; + } + + const halfRectHeight = (y2 - y1) / 2; + const distY = Math.abs(circleY - (y1 + halfRectHeight)); + if (distY > (halfRectHeight + radius)) { + return false; + } + + if (distX <= halfRectWidth || distY <= halfRectHeight) { + return true; + } + + const dx = distX - halfRectWidth; + const dy = distY - halfRectHeight; + return (dx * dx + dy * dy <= (radius * radius)); + } +} + +module.exports = GridIndex; diff --git a/src/symbol/opacity_state.js b/src/symbol/opacity_state.js new file mode 100644 index 00000000000..e2d669cedac --- /dev/null +++ b/src/symbol/opacity_state.js @@ -0,0 +1,23 @@ +// @flow + +class OpacityState { + opacity: number; + targetOpacity: number; + time: number + + constructor() { + this.opacity = 0; + this.targetOpacity = 0; + this.time = 0; + } + + clone() { + const clone = new OpacityState(); + clone.opacity = this.opacity; + clone.targetOpacity = this.targetOpacity; + clone.time = this.time; + return clone; + } +} + +module.exports = OpacityState; diff --git a/src/symbol/projection.js b/src/symbol/projection.js index 82ec505b298..1b46a8a13fb 100644 --- a/src/symbol/projection.js +++ b/src/symbol/projection.js @@ -9,11 +9,15 @@ import type Painter from '../render/painter'; import type SymbolStyleLayer from '../style/style_layer/symbol_style_layer'; import type Transform from '../geo/transform'; import type SymbolBucket from '../data/bucket/symbol_bucket'; +const WritingMode = require('../symbol/shaping').WritingMode; module.exports = { updateLineLabels, getLabelPlaneMatrix, - getGlCoordMatrix + getGlCoordMatrix, + project, + placeFirstAndLastGlyph, + xyTransformMat4 }; /* @@ -109,7 +113,7 @@ function getGlCoordMatrix(posMatrix: mat4, function project(point: Point, matrix: mat4) { const pos = [point.x, point.y, 0, 1]; - vec4.transformMat4(pos, pos, matrix); + xyTransformMat4(pos, pos, matrix); const w = pos[3]; return { point: new Point(pos[0] / w, pos[1] / w), @@ -118,9 +122,7 @@ function project(point: Point, matrix: mat4) { } function isVisible(anchorPos: [number, number, number, number], - placementZoom: number, - clippingBuffer: [number, number], - painter: Painter) { + clippingBuffer: [number, number]) { const x = anchorPos[0] / anchorPos[3]; const y = anchorPos[1] / anchorPos[3]; const inPaddedViewport = ( @@ -128,7 +130,7 @@ function isVisible(anchorPos: [number, number, number, number], x <= clippingBuffer[0] && y >= -clippingBuffer[1] && y <= clippingBuffer[1]); - return inPaddedViewport && painter.frameHistory.isVisible(placementZoom); + return inPaddedViewport; } /* @@ -159,20 +161,31 @@ function updateLineLabels(bucket: SymbolBucket, const lineVertexArray = bucket.lineVertexArray; const placedSymbols = isText ? bucket.placedGlyphArray : bucket.placedIconArray; + let useVertical = false; + for (let s = 0; s < placedSymbols.length; s++) { const symbol: any = placedSymbols.get(s); + // Don't do calculations for vertical glyphs unless the previous symbol was horizontal + // and we determined that vertical glyphs were necessary. + // Also don't do calculations for symbols that are collided and fully faded out + if (symbol.hidden || symbol.writingMode === WritingMode.vertical && !useVertical) { + hideGlyphs(symbol.numGlyphs, dynamicLayoutVertexArray); + continue; + } + // Awkward... but we're counting on the paired "vertical" symbol coming immediately after its horizontal counterpart + useVertical = false; const anchorPos = [symbol.anchorX, symbol.anchorY, 0, 1]; vec4.transformMat4(anchorPos, anchorPos, posMatrix); // Don't bother calculating the correct point for invisible labels. - if (!isVisible(anchorPos, symbol.placementZoom, clippingBuffer, painter)) { + if (!isVisible(anchorPos, clippingBuffer)) { hideGlyphs(symbol.numGlyphs, dynamicLayoutVertexArray); continue; } const cameraToAnchorDistance = anchorPos[3]; - const perspectiveRatio = 1 + 0.5 * ((cameraToAnchorDistance / painter.transform.cameraToCenterDistance) - 1); + const perspectiveRatio = 0.5 + 0.5 * (cameraToAnchorDistance / painter.transform.cameraToCenterDistance); const fontSize = symbolSize.evaluateSizeForFeature(sizeData, partiallyEvaluatedSize, symbol); const pitchScaledFontSize = pitchWithMap ? @@ -183,10 +196,12 @@ function updateLineLabels(bucket: SymbolBucket, const anchorPoint = project(tileAnchorPoint, labelPlaneMatrix).point; const projectionCache = {}; - const placeUnflipped = placeGlyphsAlongLine(symbol, pitchScaledFontSize, false /*unflipped*/, keepUpright, posMatrix, labelPlaneMatrix, glCoordMatrix, + const placeUnflipped: any = placeGlyphsAlongLine(symbol, pitchScaledFontSize, false /*unflipped*/, keepUpright, posMatrix, labelPlaneMatrix, glCoordMatrix, bucket.glyphOffsetArray, lineVertexArray, dynamicLayoutVertexArray, anchorPoint, tileAnchorPoint, projectionCache); - if (placeUnflipped.notEnoughRoom || + useVertical = placeUnflipped.useVertical; + + if (placeUnflipped.notEnoughRoom || useVertical || (placeUnflipped.needsFlipping && placeGlyphsAlongLine(symbol, pitchScaledFontSize, true /*flipped*/, keepUpright, posMatrix, labelPlaneMatrix, glCoordMatrix, bucket.glyphOffsetArray, lineVertexArray, dynamicLayoutVertexArray, anchorPoint, tileAnchorPoint, projectionCache).notEnoughRoom)) { @@ -201,19 +216,28 @@ function updateLineLabels(bucket: SymbolBucket, } } -function placeGlyphsAlongLine(symbol, - fontSize: number, - flip: boolean, - keepUpright: boolean, - posMatrix: mat4, - labelPlaneMatrix: mat4, - glCoordMatrix: mat4, - glyphOffsetArray: any, - lineVertexArray: any, - dynamicLayoutVertexArray, - anchorPoint: Point, - tileAnchorPoint: Point, - projectionCache: {[number]: Point}) { +function placeFirstAndLastGlyph(fontScale: number, glyphOffsetArray: any, lineOffsetX: number, lineOffsetY: number, flip: boolean, anchorPoint: Point, tileAnchorPoint: Point, symbol: any, lineVertexArray: any, labelPlaneMatrix: mat4, projectionCache: any, returnTileDistance: boolean) { + const glyphEndIndex = symbol.glyphStartIndex + symbol.numGlyphs; + const lineStartIndex = symbol.lineStartIndex; + const lineEndIndex = symbol.lineStartIndex + symbol.lineLength; + + const firstGlyphOffset = glyphOffsetArray.getoffsetX(symbol.glyphStartIndex); + const lastGlyphOffset = glyphOffsetArray.getoffsetX(glyphEndIndex - 1); + + const firstPlacedGlyph = placeGlyphAlongLine(fontScale * firstGlyphOffset, lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, symbol.segment, + lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache, returnTileDistance); + if (!firstPlacedGlyph) + return null; + + const lastPlacedGlyph = placeGlyphAlongLine(fontScale * lastGlyphOffset, lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, symbol.segment, + lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache, returnTileDistance); + if (!lastPlacedGlyph) + return null; + + return { first: firstPlacedGlyph, last: lastPlacedGlyph }; +} + +function placeGlyphsAlongLine(symbol, fontSize, flip, keepUpright, posMatrix, labelPlaneMatrix, glCoordMatrix, glyphOffsetArray, lineVertexArray, dynamicLayoutVertexArray, anchorPoint, tileAnchorPoint, projectionCache) { const fontScale = fontSize / 24; const lineOffsetX = symbol.lineOffsetX * fontSize; const lineOffsetY = symbol.lineOffsetY * fontSize; @@ -221,71 +245,75 @@ function placeGlyphsAlongLine(symbol, let placedGlyphs; if (symbol.numGlyphs > 1) { const glyphEndIndex = symbol.glyphStartIndex + symbol.numGlyphs; - - // Place the first and the last glyph in the label first, so we can figure out - // the overall orientation of the label and determine whether it needs to be flipped in keepUpright mode - const firstGlyphOffset = glyphOffsetArray.get(symbol.glyphStartIndex).offsetX; - const lastGlyphOffset = glyphOffsetArray.get(glyphEndIndex - 1).offsetX; const lineStartIndex = symbol.lineStartIndex; const lineEndIndex = symbol.lineStartIndex + symbol.lineLength; - const firstPlacedGlyph = placeGlyphAlongLine(fontScale * firstGlyphOffset, lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, symbol.segment, - lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache); - if (!firstPlacedGlyph) + // Place the first and the last glyph in the label first, so we can figure out + // the overall orientation of the label and determine whether it needs to be flipped in keepUpright mode + const firstAndLastGlyph = placeFirstAndLastGlyph(fontScale, glyphOffsetArray, lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, symbol, lineVertexArray, labelPlaneMatrix, projectionCache, false); + if (!firstAndLastGlyph) { return { notEnoughRoom: true }; + } + const firstPoint = project(firstAndLastGlyph.first.point, glCoordMatrix).point; + const lastPoint = project(firstAndLastGlyph.last.point, glCoordMatrix).point; - const lastPlacedGlyph = placeGlyphAlongLine(fontScale * lastGlyphOffset, lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, symbol.segment, - lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache); - if (!lastPlacedGlyph) - return { notEnoughRoom: true }; + if (keepUpright && !flip) { + if (symbol.writingMode === WritingMode.horizontal) { + // On top of choosing whether to flip, choose whether to render this version of the glyphs or the alternate + // vertical glyphs. We can't just filter out vertical glyphs in the horizontal range because the horizontal + // and vertical versions can have slightly different projections which could lead to angles where both or + // neither showed. + if (Math.abs(lastPoint.y - firstPoint.y) > Math.abs(lastPoint.x - firstPoint.x)) { + return { useVertical: true }; + } + } - const firstPoint = project(firstPlacedGlyph.point, glCoordMatrix).point; - const lastPoint = project(lastPlacedGlyph.point, glCoordMatrix).point; + if (symbol.writingMode === WritingMode.vertical ? firstPoint.y < lastPoint.y : firstPoint.x > lastPoint.x) { + // Includes "horizontalOnly" case for labels without vertical glyphs + return { needsFlipping: true }; + } - if (keepUpright && !flip && - (symbol.vertical ? firstPoint.y < lastPoint.y : firstPoint.x > lastPoint.x)) { - return { needsFlipping: true }; } - placedGlyphs = [firstPlacedGlyph]; + placedGlyphs = [firstAndLastGlyph.first]; for (let glyphIndex = symbol.glyphStartIndex + 1; glyphIndex < glyphEndIndex - 1; glyphIndex++) { - const glyph = glyphOffsetArray.get(glyphIndex); - // Since first and last glyph fit on the line, we're sure that the rest of the glyphs can be placed - placedGlyphs.push(placeGlyphAlongLine(fontScale * glyph.offsetX, lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, symbol.segment, - lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache)); + // $FlowFixMe + placedGlyphs.push(placeGlyphAlongLine(fontScale * glyphOffsetArray.getoffsetX(glyphIndex), lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, symbol.segment, + lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache, false)); } - placedGlyphs.push(lastPlacedGlyph); + placedGlyphs.push(firstAndLastGlyph.last); } else { // Only a single glyph to place // So, determine whether to flip based on projected angle of the line segment it's on if (keepUpright && !flip) { const a = project(tileAnchorPoint, posMatrix).point; - const tileSegmentEnd = lineVertexArray.get(symbol.lineStartIndex + symbol.segment + 1); + const tileVertexIndex = (symbol.lineStartIndex + symbol.segment + 1); + // $FlowFixMe + const tileSegmentEnd = new Point(lineVertexArray.getx(tileVertexIndex), lineVertexArray.gety(tileVertexIndex)); const projectedVertex = project(tileSegmentEnd, posMatrix); // We know the anchor will be in the viewport, but the end of the line segment may be // behind the plane of the camera, in which case we can use a point at any arbitrary (closer) // point on the segment. const b = (projectedVertex.signedDistanceFromCamera > 0) ? projectedVertex.point : - projectTruncatedLineSegment(tileAnchorPoint, new Point(tileSegmentEnd.x, tileSegmentEnd.y), a, 1, posMatrix); + projectTruncatedLineSegment(tileAnchorPoint, tileSegmentEnd, a, 1, posMatrix); if (symbol.vertical ? b.y > a.y : b.x < a.x) { return { needsFlipping: true }; } } - const glyph = glyphOffsetArray.get(symbol.glyphStartIndex); - const singleGlyph = placeGlyphAlongLine(fontScale * glyph.offsetX, lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, symbol.segment, - symbol.lineStartIndex, symbol.lineStartIndex + symbol.lineLength, lineVertexArray, labelPlaneMatrix, projectionCache); + // $FlowFixMe + const singleGlyph = placeGlyphAlongLine(fontScale * glyphOffsetArray.getoffsetX(symbol.glyphStartIndex), lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, symbol.segment, + symbol.lineStartIndex, symbol.lineStartIndex + symbol.lineLength, lineVertexArray, labelPlaneMatrix, projectionCache, false); if (!singleGlyph) return { notEnoughRoom: true }; placedGlyphs = [singleGlyph]; } - const placementZoom = symbol.placementZoom; for (const glyph: any of placedGlyphs) { - addDynamicAttributes(dynamicLayoutVertexArray, glyph.point, glyph.angle, placementZoom); + addDynamicAttributes(dynamicLayoutVertexArray, glyph.point, glyph.angle); } return {}; } @@ -312,7 +340,8 @@ function placeGlyphAlongLine(offsetX: number, lineEndIndex: number, lineVertexArray: any, labelPlaneMatrix: mat4, - projectionCache: {[number]: Point}) { + projectionCache: {[number]: Point}, + returnTileDistance: boolean) { const combinedOffsetX = flip ? offsetX - lineOffsetX : @@ -334,6 +363,7 @@ function placeGlyphAlongLine(offsetX: number, lineStartIndex + anchorSegment : lineStartIndex + anchorSegment + 1; + const initialIndex = currentIndex; let current = anchorPoint; let prev = anchorPoint; let distanceToPrev = 0; @@ -351,18 +381,19 @@ function placeGlyphAlongLine(offsetX: number, current = projectionCache[currentIndex]; if (current === undefined) { - const projection = project(lineVertexArray.get(currentIndex), labelPlaneMatrix); + const currentVertex = new Point(lineVertexArray.getx(currentIndex), lineVertexArray.gety(currentIndex)); + const projection = project(currentVertex, labelPlaneMatrix); if (projection.signedDistanceFromCamera > 0) { current = projectionCache[currentIndex] = projection.point; } else { // The vertex is behind the plane of the camera, so we can't project it // Instead, we'll create a vertex along the line that's far enough to include the glyph + const previousLineVertexIndex = currentIndex - dir; const previousTilePoint = distanceToPrev === 0 ? tileAnchorPoint : - new Point(lineVertexArray.get(currentIndex - dir).x, lineVertexArray.get(currentIndex - dir).y); - const currentTilePoint = new Point(lineVertexArray.get(currentIndex).x, lineVertexArray.get(currentIndex).y); + new Point(lineVertexArray.getx(previousLineVertexIndex), lineVertexArray.gety(previousLineVertexIndex)); // Don't cache because the new vertex might not be far enough out for future glyphs on the same segment - current = projectTruncatedLineSegment(previousTilePoint, currentTilePoint, prev, absOffsetX - distanceToPrev + 1, labelPlaneMatrix); + current = projectTruncatedLineSegment(previousTilePoint, currentVertex, prev, absOffsetX - distanceToPrev + 1, labelPlaneMatrix); } } @@ -382,16 +413,35 @@ function placeGlyphAlongLine(offsetX: number, return { point: p, - angle: segmentAngle + angle: segmentAngle, + tileDistance: returnTileDistance ? + { + prevTileDistance: (currentIndex - dir) === initialIndex ? 0 : lineVertexArray.gettileUnitDistanceFromAnchor(currentIndex - dir), + lastSegmentViewportDistance: absOffsetX - distanceToPrev + } : null }; } -const offscreenPoint = new Point(-Infinity, -Infinity); +const hiddenGlyphAttributes = new Float32Array([-Infinity, -Infinity, 0, -Infinity, -Infinity, 0, -Infinity, -Infinity, 0, -Infinity, -Infinity, 0]); // Hide them by moving them offscreen. We still need to add them to the buffer // because the dynamic buffer is paired with a static buffer that doesn't get updated. -function hideGlyphs(num, dynamicLayoutVertexArray) { +function hideGlyphs(num: number, dynamicLayoutVertexArray: any) { for (let i = 0; i < num; i++) { - addDynamicAttributes(dynamicLayoutVertexArray, offscreenPoint, 0, 25); + const offset = dynamicLayoutVertexArray.length; + dynamicLayoutVertexArray.resize(offset + 4); + // Since all hidden glyphs have the same attributes, we can build up the array faster with a single call to Float32Array.set + // for each set of four vertices, instead of calling addDynamicAttributes for each vertex. + dynamicLayoutVertexArray.float32.set(hiddenGlyphAttributes, offset * 3); } } + +// For line label layout, we're not using z output and our w input is always 1 +// This custom matrix transformation ignores those components to make projection faster +function xyTransformMat4(out: vec4, a: vec4, m: mat4) { + const x = a[0], y = a[1]; + out[0] = m[0] * x + m[4] * y + m[12]; + out[1] = m[1] * x + m[5] * y + m[13]; + out[3] = m[3] * x + m[7] * y + m[15]; + return out; +} diff --git a/src/symbol/shaping.js b/src/symbol/shaping.js index 93938602b6c..131b8586be1 100644 --- a/src/symbol/shaping.js +++ b/src/symbol/shaping.js @@ -9,7 +9,8 @@ import type {ImagePosition} from '../render/image_atlas'; const WritingMode = { horizontal: 1, - vertical: 2 + vertical: 2, + horizontalOnly: 3 }; module.exports = { diff --git a/src/symbol/symbol_layout.js b/src/symbol/symbol_layout.js new file mode 100644 index 00000000000..27d2cd90e6e --- /dev/null +++ b/src/symbol/symbol_layout.js @@ -0,0 +1,402 @@ +// @flow +const Anchor = require('./anchor'); +const getAnchors = require('./get_anchors'); +const clipLine = require('./clip_line'); +const OpacityState = require('./opacity_state'); +const {shapeText, shapeIcon, WritingMode} = require('./shaping'); +const {getGlyphQuads, getIconQuads} = require('./quads'); +const CollisionFeature = require('./collision_feature'); +const util = require('../util/util'); +const scriptDetection = require('../util/script_detection'); +const findPoleOfInaccessibility = require('../util/find_pole_of_inaccessibility'); +const classifyRings = require('../util/classify_rings'); +const EXTENT = require('../data/extent'); +const SymbolBucket = require('../data/bucket/symbol_bucket'); + +import type {Shaping, PositionedIcon} from './shaping'; +import type {SizeData} from './symbol_size'; +import type CollisionBoxArray from './collision_box'; +import type {SymbolFeature} from '../data/bucket/symbol_bucket'; +import type {StyleImage} from '../style/style_image'; +import type {StyleGlyph} from '../style/style_glyph'; +import type SymbolStyleLayer from '../style/style_layer/symbol_style_layer'; +import type {ImagePosition} from '../render/image_atlas'; +import type {GlyphPosition} from '../render/glyph_atlas'; + +const Point = require('@mapbox/point-geometry'); + +module.exports = { + performSymbolLayout +}; + +function performSymbolLayout(bucket: SymbolBucket, + glyphMap: {[string]: {[number]: ?StyleGlyph}}, + glyphPositions: {[string]: {[number]: GlyphPosition}}, + imageMap: {[string]: StyleImage}, + imagePositions: {[string]: ImagePosition}, + showCollisionBoxes: boolean) { + bucket.createArrays(); + bucket.symbolInstances = []; + + const tileSize = 512 * bucket.overscaling; + bucket.tilePixelRatio = EXTENT / tileSize; + bucket.compareText = {}; + bucket.iconsNeedLinear = false; + + const layout = bucket.layers[0].layout; + + const oneEm = 24; + const lineHeight = layout['text-line-height'] * oneEm; + const fontstack = layout['text-font'].join(','); + const textAlongLine = layout['text-rotation-alignment'] === 'map' && layout['symbol-placement'] === 'line'; + const keepUpright = layout['text-keep-upright']; + + const glyphs = glyphMap[fontstack] || {}; + const glyphPositionMap = glyphPositions[fontstack] || {}; + + for (const feature of bucket.features) { + + const shapedTextOrientations = {}; + const text = feature.text; + if (text) { + const allowsVerticalWritingMode = scriptDetection.allowsVerticalWritingMode(text); + const textOffset = bucket.layers[0].getLayoutValue('text-offset', {zoom: bucket.zoom}, feature).map((t)=> t * oneEm); + const spacing = bucket.layers[0].getLayoutValue('text-letter-spacing', {zoom: bucket.zoom}, feature) * oneEm; + const spacingIfAllowed = scriptDetection.allowsLetterSpacing(text) ? spacing : 0; + const textAnchor = bucket.layers[0].getLayoutValue('text-anchor', {zoom: bucket.zoom}, feature); + const textJustify = bucket.layers[0].getLayoutValue('text-justify', {zoom: bucket.zoom}, feature); + const maxWidth = layout['symbol-placement'] !== 'line' ? + bucket.layers[0].getLayoutValue('text-max-width', {zoom: bucket.zoom}, feature) * oneEm : + 0; + + shapedTextOrientations.horizontal = shapeText(text, glyphs, maxWidth, lineHeight, textAnchor, textJustify, spacingIfAllowed, textOffset, oneEm, WritingMode.horizontal); + if (allowsVerticalWritingMode && textAlongLine && keepUpright) { + shapedTextOrientations.vertical = shapeText(text, glyphs, maxWidth, lineHeight, textAnchor, textJustify, spacingIfAllowed, textOffset, oneEm, WritingMode.vertical); + } + } + + let shapedIcon; + if (feature.icon) { + const image = imageMap[feature.icon]; + if (image) { + shapedIcon = shapeIcon( + imagePositions[feature.icon], + bucket.layers[0].getLayoutValue('icon-offset', {zoom: bucket.zoom}, feature), + bucket.layers[0].getLayoutValue('icon-anchor', {zoom: bucket.zoom}, feature)); + if (bucket.sdfIcons === undefined) { + bucket.sdfIcons = image.sdf; + } else if (this.sdfIcons !== image.sdf) { + util.warnOnce('Style sheet warning: Cannot mix SDF and non-SDF icons in one buffer'); + } + if (image.pixelRatio !== bucket.pixelRatio) { + bucket.iconsNeedLinear = true; + } else if (layout['icon-rotate'] !== 0 || !bucket.layers[0].isLayoutValueFeatureConstant('icon-rotate')) { + bucket.iconsNeedLinear = true; + } + } + } + + if (shapedTextOrientations.horizontal || shapedIcon) { + addFeature(bucket, feature, shapedTextOrientations, shapedIcon, glyphPositionMap); + } + } + + if (showCollisionBoxes) { + bucket.generateCollisionDebugBuffers(); + } +} + + +/** + * Given a feature and its shaped text and icon data, add a 'symbol + * instance' for each _possible_ placement of the symbol feature. + * (At render timePlaceSymbols#place() selects which of these instances to + * show or hide based on collisions with symbols in other layers.) + * @private + */ +function addFeature(bucket: SymbolBucket, + feature: SymbolFeature, + shapedTextOrientations: any, + shapedIcon: PositionedIcon | void, + glyphPositionMap: {[number]: GlyphPosition}) { + const layoutTextSize = bucket.layers[0].getLayoutValue('text-size', {zoom: bucket.zoom + 1}, feature); + const layoutIconSize = bucket.layers[0].getLayoutValue('icon-size', {zoom: bucket.zoom + 1}, feature); + + const textOffset = bucket.layers[0].getLayoutValue('text-offset', {zoom: bucket.zoom }, feature); + const iconOffset = bucket.layers[0].getLayoutValue('icon-offset', {zoom: bucket.zoom }, feature); + + // To reduce the number of labels that jump around when zooming we need + // to use a text-size value that is the same for all zoom levels. + // bucket calculates text-size at a high zoom level so that all tiles can + // use the same value when calculating anchor positions. + let textMaxSize = bucket.layers[0].getLayoutValue('text-size', {zoom: 18}, feature); + if (textMaxSize === undefined) { + textMaxSize = layoutTextSize; + } + + const layout = bucket.layers[0].layout, + glyphSize = 24, + fontScale = layoutTextSize / glyphSize, + textBoxScale = bucket.tilePixelRatio * fontScale, + textMaxBoxScale = bucket.tilePixelRatio * textMaxSize / glyphSize, + iconBoxScale = bucket.tilePixelRatio * layoutIconSize, + symbolMinDistance = bucket.tilePixelRatio * layout['symbol-spacing'], + textPadding = layout['text-padding'] * bucket.tilePixelRatio, + iconPadding = layout['icon-padding'] * bucket.tilePixelRatio, + textMaxAngle = layout['text-max-angle'] / 180 * Math.PI, + textAlongLine = layout['text-rotation-alignment'] === 'map' && layout['symbol-placement'] === 'line', + iconAlongLine = layout['icon-rotation-alignment'] === 'map' && layout['symbol-placement'] === 'line', + mayOverlap = layout['text-allow-overlap'] || layout['icon-allow-overlap'] || + layout['text-ignore-placement'] || layout['icon-ignore-placement'], + symbolPlacement = layout['symbol-placement'], + textRepeatDistance = symbolMinDistance / 2; + + const addSymbolAtAnchor = (line, anchor) => { + const inside = !(anchor.x < 0 || anchor.x >= EXTENT || anchor.y < 0 || anchor.y >= EXTENT); + + if (!inside) return; + + // Normally symbol layers are drawn across tile boundaries. Only symbols + // with their anchors within the tile boundaries are added to the buffers + // to prevent symbols from being drawn twice. + // + // Symbols in layers with overlap are sorted in the y direction so that + // symbols lower on the canvas are drawn on top of symbols near the top. + // To preserve bucket order across tile boundaries these symbols can't + // be drawn across tile boundaries. Instead they need to be included in + // the buffers for both tiles and clipped to tile boundaries at draw time. + const addToBuffers = inside || mayOverlap; + bucket.symbolInstances.push(addSymbol(bucket, anchor, line, shapedTextOrientations, shapedIcon, bucket.layers[0], + addToBuffers, bucket.collisionBoxArray, feature.index, feature.sourceLayerIndex, bucket.index, + textBoxScale, textPadding, textAlongLine, textOffset, + iconBoxScale, iconPadding, iconAlongLine, iconOffset, + {zoom: bucket.zoom}, feature, glyphPositionMap)); + }; + + if (symbolPlacement === 'line') { + for (const line of clipLine(feature.geometry, 0, 0, EXTENT, EXTENT)) { + const anchors = getAnchors( + line, + symbolMinDistance, + textMaxAngle, + shapedTextOrientations.vertical || shapedTextOrientations.horizontal, + shapedIcon, + glyphSize, + textMaxBoxScale, + bucket.overscaling, + EXTENT + ); + for (const anchor of anchors) { + const shapedText = shapedTextOrientations.horizontal; + if (!shapedText || !anchorIsTooClose(bucket, shapedText.text, textRepeatDistance, anchor)) { + addSymbolAtAnchor(line, anchor); + } + } + } + } else if (feature.type === 'Polygon') { + for (const polygon of classifyRings(feature.geometry, 0)) { + // 16 here represents 2 pixels + const poi = findPoleOfInaccessibility(polygon, 16); + addSymbolAtAnchor(polygon[0], new Anchor(poi.x, poi.y, 0)); + } + } else if (feature.type === 'LineString') { + // https://github.com/mapbox/mapbox-gl-js/issues/3808 + for (const line of feature.geometry) { + addSymbolAtAnchor(line, new Anchor(line[0].x, line[0].y, 0)); + } + } else if (feature.type === 'Point') { + for (const points of feature.geometry) { + for (const point of points) { + addSymbolAtAnchor([point], new Anchor(point.x, point.y, 0)); + } + } + } +} + +function addTextVertices(bucket: SymbolBucket, + addToBuffers: boolean, + anchor: Point, + shapedText: Shaping, + layer: SymbolStyleLayer, + textAlongLine: boolean, + globalProperties: Object, + feature: SymbolFeature, + textOffset: [number, number], + lineArray: any, + writingMode: number, + placedTextSymbolIndices: Array, + glyphPositionMap: {[number]: GlyphPosition}) { + const glyphQuads = addToBuffers ? + getGlyphQuads(anchor, shapedText, + layer, textAlongLine, globalProperties, feature, glyphPositionMap) : + []; + + const textSizeData = getSizeVertexData(layer, + bucket.zoom, + bucket.textSizeData, + 'text-size', + feature); + + bucket.addSymbols( + bucket.text, + glyphQuads, + textSizeData, + textOffset, + textAlongLine, + feature, + writingMode, + anchor, + lineArray.lineStartIndex, + lineArray.lineLength, + bucket.placedGlyphArray); + + // The placedGlyphArray is used at render time in drawTileSymbols + // These indices allow access to the array at collision detection time + placedTextSymbolIndices.push(bucket.placedGlyphArray.length - 1); + + return glyphQuads.length * 4; +} + + +/** + * Add a single label & icon placement. + * + * @private + */ +function addSymbol(bucket: SymbolBucket, + anchor: Anchor, + line: Array, + shapedTextOrientations: any, + shapedIcon: PositionedIcon | void, + layer: SymbolStyleLayer, + addToBuffers: boolean, + collisionBoxArray: CollisionBoxArray, + featureIndex: number, + sourceLayerIndex: number, + bucketIndex: number, + textBoxScale: number, + textPadding: number, + textAlongLine: boolean, + textOffset: [number, number], + iconBoxScale: number, + iconPadding: number, + iconAlongLine: boolean, + iconOffset: [number, number], + globalProperties: Object, + feature: SymbolFeature, + glyphPositionMap: {[number]: GlyphPosition}) { + const lineArray = bucket.addToLineVertexArray(anchor, line); + + let textCollisionFeature, iconCollisionFeature; + + let numIconVertices = 0; + let numGlyphVertices = 0; + let numVerticalGlyphVertices = 0; + const key = shapedTextOrientations.horizontal ? shapedTextOrientations.horizontal.text : ''; + const placedTextSymbolIndices = []; + if (shapedTextOrientations.horizontal) { + // As a collision approximation, we can use either the vertical or the horizontal version of the feature + // We're counting on the two versions having similar dimensions + textCollisionFeature = new CollisionFeature(collisionBoxArray, line, anchor, featureIndex, sourceLayerIndex, bucketIndex, shapedTextOrientations.horizontal, textBoxScale, textPadding, textAlongLine, bucket.overscaling); + numGlyphVertices += addTextVertices(bucket, addToBuffers, anchor, shapedTextOrientations.horizontal, layer, textAlongLine, globalProperties, feature, textOffset, lineArray, shapedTextOrientations.vertical ? WritingMode.horizontal : WritingMode.horizontalOnly, placedTextSymbolIndices, glyphPositionMap); + + if (shapedTextOrientations.vertical) { + numVerticalGlyphVertices += addTextVertices(bucket, addToBuffers, anchor, shapedTextOrientations.vertical, layer, textAlongLine, globalProperties, feature, textOffset, lineArray, WritingMode.vertical, placedTextSymbolIndices, glyphPositionMap); + } + } + + const textBoxStartIndex = textCollisionFeature ? textCollisionFeature.boxStartIndex : bucket.collisionBoxArray.length; + const textBoxEndIndex = textCollisionFeature ? textCollisionFeature.boxEndIndex : bucket.collisionBoxArray.length; + + if (shapedIcon) { + const iconQuads = addToBuffers ? + getIconQuads(anchor, shapedIcon, layer, + iconAlongLine, shapedTextOrientations.horizontal, + globalProperties, feature) : + []; + iconCollisionFeature = new CollisionFeature(collisionBoxArray, line, anchor, featureIndex, sourceLayerIndex, bucketIndex, shapedIcon, iconBoxScale, iconPadding, /*align boxes to line*/false, bucket.overscaling); + + numIconVertices = iconQuads.length * 4; + + const iconSizeData = getSizeVertexData(layer, + bucket.zoom, + bucket.iconSizeData, + 'icon-size', + feature); + + bucket.addSymbols( + bucket.icon, + iconQuads, + iconSizeData, + iconOffset, + iconAlongLine, + feature, + false, + anchor, + lineArray.lineStartIndex, + lineArray.lineLength, + bucket.placedIconArray); + } + + const iconBoxStartIndex = iconCollisionFeature ? iconCollisionFeature.boxStartIndex : bucket.collisionBoxArray.length; + const iconBoxEndIndex = iconCollisionFeature ? iconCollisionFeature.boxEndIndex : bucket.collisionBoxArray.length; + + if (bucket.glyphOffsetArray.length >= SymbolBucket.MAX_GLYPHS) util.warnOnce("Too many glyphs being rendered in a tile. See https://github.com/mapbox/mapbox-gl-js/issues/2907"); + + const textOpacityState = new OpacityState(); + const iconOpacityState = new OpacityState(); + + return { + key, + textBoxStartIndex, + textBoxEndIndex, + iconBoxStartIndex, + iconBoxEndIndex, + textOffset, + iconOffset, + anchor, + line, + featureIndex, + feature, + numGlyphVertices, + numVerticalGlyphVertices, + numIconVertices, + textOpacityState, + iconOpacityState, + isDuplicate: false, + placedTextSymbolIndices + }; +} + +function anchorIsTooClose(bucket: any, text: string, repeatDistance: number, anchor: Point) { + const compareText = bucket.compareText; + if (!(text in compareText)) { + compareText[text] = []; + } else { + const otherAnchors = compareText[text]; + for (let k = otherAnchors.length - 1; k >= 0; k--) { + if (anchor.dist(otherAnchors[k]) < repeatDistance) { + // If it's within repeatDistance of one anchor, stop looking + return true; + } + } + } + // If anchor is not within repeatDistance of any other anchor, add to array + compareText[text].push(anchor); + return false; +} + +function getSizeVertexData(layer: SymbolStyleLayer, tileZoom: number, sizeData: SizeData, sizeProperty: string, feature: SymbolFeature) { + if (sizeData.functionType === 'source') { + return [ + 10 * layer.getLayoutValue(sizeProperty, ({}: any), feature) + ]; + } else if (sizeData.functionType === 'composite') { + const zoomRange = sizeData.coveringZoomRange; + return [ + 10 * layer.getLayoutValue(sizeProperty, {zoom: zoomRange[0]}, feature), + 10 * layer.getLayoutValue(sizeProperty, {zoom: zoomRange[1]}, feature) + ]; + } + return null; +} diff --git a/src/symbol/symbol_placement.js b/src/symbol/symbol_placement.js new file mode 100644 index 00000000000..0048fd5195a --- /dev/null +++ b/src/symbol/symbol_placement.js @@ -0,0 +1,264 @@ +// @flow + +const symbolSize = require('./symbol_size'); + +import type SymbolBucket, {SymbolInstance} from '../data/bucket/symbol_bucket'; +import type OpacityState from './opacity_state'; +import type CollisionIndex from './collision_index'; +import type CollisionBoxArray from './collision_box'; +import type {StructArray} from '../util/struct_array'; +const mat4 = require('@mapbox/gl-matrix').mat4; + +module.exports = { + updateOpacities: updateOpacities, + performSymbolPlacement: performSymbolPlacement +}; + +function updateOpacity(symbolInstance: SymbolInstance, opacityState: OpacityState, targetOpacity: number, opacityUpdateTime: number, collisionFadeTimes: any) { + if (symbolInstance.isDuplicate) { + opacityState.opacity = 0; + opacityState.targetOpacity = 0; + } else { + if (opacityState.targetOpacity !== targetOpacity) { + collisionFadeTimes.latestStart = opacityUpdateTime; + } + const increment = collisionFadeTimes.duration ? ((opacityUpdateTime - opacityState.time) / collisionFadeTimes.duration) : 1; + opacityState.opacity = Math.max(0, Math.min(1, opacityState.opacity + (opacityState.targetOpacity === 1 ? increment : -increment))); + opacityState.targetOpacity = targetOpacity; + opacityState.time = opacityUpdateTime; + } +} + +const shift25 = Math.pow(2, 25); +const shift24 = Math.pow(2, 24); +const shift17 = Math.pow(2, 17); +const shift16 = Math.pow(2, 16); +const shift9 = Math.pow(2, 9); +const shift8 = Math.pow(2, 8); +const shift1 = Math.pow(2, 1); + +// All four vertices for a glyph will have the same opacity state +// So we pack the opacity into a uint8, and then repeat it four times +// to make a single uint32 that we can upload for each glyph in the +// label. +function packOpacity(opacityState: OpacityState): number { + if (opacityState.opacity === 0 && opacityState.targetOpacity === 0) { + return 0; + } else if (opacityState.opacity === 1 && opacityState.targetOpacity === 1) { + return 4294967295; + } + const targetBit = opacityState.targetOpacity === 1 ? 1 : 0; + const opacityBits = Math.floor(opacityState.opacity * 127); + return opacityBits * shift25 + targetBit * shift24 + + opacityBits * shift17 + targetBit * shift16 + + opacityBits * shift9 + targetBit * shift8 + + opacityBits * shift1 + targetBit; +} + +function updateOpacities(bucket: SymbolBucket, collisionFadeTimes: any) { + const glyphOpacityArray = bucket.text && bucket.text.opacityVertexArray; + const iconOpacityArray = bucket.icon && bucket.icon.opacityVertexArray; + if (glyphOpacityArray) glyphOpacityArray.clear(); + if (iconOpacityArray) iconOpacityArray.clear(); + + bucket.fadeStartTime = Date.now(); + + for (const symbolInstance of bucket.symbolInstances) { + + const hasText = !(symbolInstance.textBoxStartIndex === symbolInstance.textBoxEndIndex); + const hasIcon = !(symbolInstance.iconBoxStartIndex === symbolInstance.iconBoxEndIndex); + + if (!hasText && !hasIcon) continue; + + if (hasText) { + const targetOpacity = symbolInstance.placedText ? 1.0 : 0.0; + const opacityState = symbolInstance.textOpacityState; + const initialHidden = opacityState.opacity === 0 && opacityState.targetOpacity === 0; + updateOpacity(symbolInstance, opacityState, targetOpacity, bucket.fadeStartTime, collisionFadeTimes); + const nowHidden = opacityState.opacity === 0 && opacityState.targetOpacity === 0; + if (initialHidden !== nowHidden) { + for (const placedTextSymbolIndex of symbolInstance.placedTextSymbolIndices) { + const placedSymbol = (bucket.placedGlyphArray.get(placedTextSymbolIndex): any); + // If this label is completely faded, mark it so that we don't have to calculate + // its position at render time + placedSymbol.hidden = nowHidden; + } + } + + // Vertical text fades in/out on collision the same way as corresponding + // horizontal text. Switch between vertical/horizontal should be instantaneous + const opacityEntryCount = (symbolInstance.numGlyphVertices + symbolInstance.numVerticalGlyphVertices) / 4; + const packedOpacity = packOpacity(opacityState); + for (let i = 0; i < opacityEntryCount; i++) { + glyphOpacityArray.emplaceBack(packedOpacity); + } + } + + if (hasIcon) { + const targetOpacity = symbolInstance.placedIcon ? 1.0 : 0.0; + const opacityState = symbolInstance.iconOpacityState; + updateOpacity(symbolInstance, opacityState, targetOpacity, bucket.fadeStartTime, collisionFadeTimes); + const opacityEntryCount = symbolInstance.numIconVertices / 4; + const packedOpacity = packOpacity(opacityState); + for (let i = 0; i < opacityEntryCount; i++) { + iconOpacityArray.emplaceBack(packedOpacity); + } + } + + } + + if (glyphOpacityArray && bucket.text.opacityVertexBuffer) { + bucket.text.opacityVertexBuffer.updateData(glyphOpacityArray.serialize()); + } + if (iconOpacityArray && bucket.icon.opacityVertexBuffer) { + bucket.icon.opacityVertexBuffer.updateData(iconOpacityArray.serialize()); + } +} + + +function updateCollisionBox(collisionVertexArray: StructArray, placed: boolean) { + collisionVertexArray.emplaceBack(placed ? 1 : 0, 0); + collisionVertexArray.emplaceBack(placed ? 1 : 0, 0); + collisionVertexArray.emplaceBack(placed ? 1 : 0, 0); + collisionVertexArray.emplaceBack(placed ? 1 : 0, 0); +} + +function updateCollisionCircles(collisionVertexArray: StructArray, collisionCircles: Array, placed: boolean, isDuplicate: boolean) { + for (let k = 0; k < collisionCircles.length; k += 5) { + const notUsed = isDuplicate || (collisionCircles[k + 4] === 0); + collisionVertexArray.emplaceBack(placed ? 1 : 0, notUsed ? 1 : 0); + collisionVertexArray.emplaceBack(placed ? 1 : 0, notUsed ? 1 : 0); + collisionVertexArray.emplaceBack(placed ? 1 : 0, notUsed ? 1 : 0); + collisionVertexArray.emplaceBack(placed ? 1 : 0, notUsed ? 1 : 0); + } +} + +function performSymbolPlacement(bucket: SymbolBucket, collisionIndex: CollisionIndex, showCollisionBoxes: boolean, zoom: number, textPixelRatio: number, posMatrix: mat4, labelPlaneMatrix: mat4, tileID: number, collisionBoxArray: CollisionBoxArray) { + const layer = bucket.layers[0]; + const layout = layer.layout; + + const scale = Math.pow(2, zoom - bucket.zoom); + + let collisionDebugBoxArray, collisionDebugCircleArray; + if (showCollisionBoxes) { + if (bucket.collisionBox && bucket.collisionBox.collisionVertexArray && bucket.collisionBox.collisionVertexArray.length) { + collisionDebugBoxArray = bucket.collisionBox.collisionVertexArray; + collisionDebugBoxArray.clear(); + } + + if (bucket.collisionCircle && bucket.collisionCircle.collisionVertexArray && bucket.collisionCircle.collisionVertexArray.length) { + collisionDebugCircleArray = bucket.collisionCircle.collisionVertexArray; + collisionDebugCircleArray.clear(); + } + } + + const partiallyEvaluatedTextSize = symbolSize.evaluateSizeForZoom(bucket.textSizeData, collisionIndex.transform, layer, true); + const pitchWithMap = bucket.layers[0].layout['text-pitch-alignment'] === 'map'; + + for (const symbolInstance of bucket.symbolInstances) { + + const hasText = !(symbolInstance.textBoxStartIndex === symbolInstance.textBoxEndIndex); + const hasIcon = !(symbolInstance.iconBoxStartIndex === symbolInstance.iconBoxEndIndex); + if (!symbolInstance.collisionArrays) { + symbolInstance.collisionArrays = bucket.deserializeCollisionBoxes(collisionBoxArray, symbolInstance.textBoxStartIndex, symbolInstance.textBoxEndIndex, symbolInstance.iconBoxStartIndex, symbolInstance.iconBoxEndIndex); + } + + const iconWithoutText = layout['text-optional'] || !hasText, + textWithoutIcon = layout['icon-optional'] || !hasIcon; + + let placedGlyphBox = []; + let placedIconBox = []; + let placedGlyphCircles = []; + let placedCircles = false; + if (!symbolInstance.isDuplicate) { + // isDuplicate -> Although we're rendering this tile, this symbol is also present in + // a child tile that will be rendered on top. Don't place this symbol, so that + // there's room in the CollisionIndex for the child symbol. + + // Symbols that are in the parent but not the child will keep getting rendered + // (and potentially colliding out child symbols) until the parent tile is removed. + // It might be better to filter out all the parent symbols so that the child tile + // starts rendering as close as possible to its final state? + if (symbolInstance.collisionArrays.textBox) { + placedGlyphBox = collisionIndex.placeCollisionBox(symbolInstance.collisionArrays.textBox, + layout['text-allow-overlap'], textPixelRatio, posMatrix); + } + + if (symbolInstance.collisionArrays.iconBox) { + placedIconBox = collisionIndex.placeCollisionBox(symbolInstance.collisionArrays.iconBox, + layout['icon-allow-overlap'], textPixelRatio, posMatrix); + } + + const textCircles = symbolInstance.collisionArrays.textCircles; + if (textCircles) { + const placedSymbol = (bucket.placedGlyphArray.get(symbolInstance.placedTextSymbolIndices[0]): any); + const fontSize = symbolSize.evaluateSizeForFeature(bucket.textSizeData, partiallyEvaluatedTextSize, placedSymbol); + placedGlyphCircles = collisionIndex.placeCollisionCircles(textCircles, + layout['text-allow-overlap'], + scale, + textPixelRatio, + symbolInstance.key, + placedSymbol, + bucket.lineVertexArray, + bucket.glyphOffsetArray, + fontSize, + posMatrix, + labelPlaneMatrix, + showCollisionBoxes, + pitchWithMap); + // If text-allow-overlap is set, force "placedCircles" to true + // In theory there should always be at least one circle placed + // in this case, but for now quirks in text-anchor + // and text-offset may prevent that from being true. + placedCircles = layout['text-allow-overlap'] || placedGlyphCircles.length > 0; + } + } + + let placeGlyph = placedGlyphBox.length > 0 || placedCircles; + let placeIcon = placedIconBox.length > 0; + + // Combine the scales for icons and text. + if (!iconWithoutText && !textWithoutIcon) { + placeIcon = placeGlyph = placeIcon && placeGlyph; + } else if (!textWithoutIcon) { + placeGlyph = placeIcon && placeGlyph; + } else if (!iconWithoutText) { + placeIcon = placeIcon && placeGlyph; + } + + symbolInstance.placedText = placeGlyph; + symbolInstance.placedIcon = placeIcon; + + if (symbolInstance.collisionArrays.textBox) { + if (collisionDebugBoxArray) { + updateCollisionBox(collisionDebugBoxArray, placeGlyph); + } + if (placeGlyph) { + collisionIndex.insertCollisionBox(placedGlyphBox, layout['text-ignore-placement'], tileID, symbolInstance.textBoxStartIndex); + } + } + if (symbolInstance.collisionArrays.iconBox) { + if (collisionDebugBoxArray) { + updateCollisionBox(collisionDebugBoxArray, placeIcon); + } + if (placeIcon) { + collisionIndex.insertCollisionBox(placedIconBox, layout['icon-ignore-placement'], tileID, symbolInstance.iconBoxStartIndex); + } + } + if (symbolInstance.collisionArrays.textCircles) { + if (collisionDebugCircleArray) { + updateCollisionCircles(collisionDebugCircleArray, symbolInstance.collisionArrays.textCircles, placeGlyph, symbolInstance.isDuplicate); + } + if (placeGlyph) { + collisionIndex.insertCollisionCircles(placedGlyphCircles, layout['text-ignore-placement'], tileID, symbolInstance.textBoxStartIndex); + } + } + + } + + // If the buffer hasn't been uploaded for the first time yet, we don't need to call updateData since it will happen at upload time + if (collisionDebugBoxArray && bucket.collisionBox.collisionVertexBuffer) + bucket.collisionBox.collisionVertexBuffer.updateData(collisionDebugBoxArray.serialize()); + if (collisionDebugCircleArray && bucket.collisionCircle.collisionVertexBuffer) + bucket.collisionCircle.collisionVertexBuffer.updateData(collisionDebugCircleArray.serialize()); +} diff --git a/src/ui/map.js b/src/ui/map.js index c6022b9bc70..72d4229c458 100755 --- a/src/ui/map.js +++ b/src/ui/map.js @@ -124,7 +124,8 @@ const defaultOptions = { maxTileCacheSize: null, - transformRequest: null + transformRequest: null, + collisionFadeDuration: 300 }; /** @@ -236,6 +237,7 @@ class Map extends Camera { _frameId: any; _styleDirty: ?boolean; _sourcesDirty: ?boolean; + _placementDirty: ?boolean; _loaded: boolean; _trackResize: boolean; _preserveDrawingBuffer: boolean; @@ -243,6 +245,7 @@ class Map extends Camera { _refreshExpiredTiles: boolean; _hash: Hash; _delegatedListeners: any; + _collisionFadeDuration: number; scrollZoom: ScrollZoomHandler; boxZoom: BoxZoomHandler; @@ -269,6 +272,7 @@ class Map extends Camera { this._trackResize = options.trackResize; this._bearingSnap = options.bearingSnap; this._refreshExpiredTiles = options.refreshExpiredTiles; + this._collisionFadeDuration = options.collisionFadeDuration; const transformRequestFn = options.transformRequest; this._transformRequest = transformRequestFn ? (url, type) => transformRequestFn(url, type) || ({ url }) : (url) => ({ url }); @@ -306,8 +310,7 @@ class Map extends Camera { this.on('move', this._update.bind(this, false)); this.on('zoom', this._update.bind(this, true)); - this.on('moveend', () => { - this.animationLoop.set(300); // text fading + this.on('move', () => { this._rerender(); }); @@ -893,9 +896,6 @@ class Map extends Camera { if (this.style) { this.style.setEventedParent(null); this.style._remove(); - this.off('rotate', this.style._redoPlacement); - this.off('pitch', this.style._redoPlacement); - this.off('move', this.style._redoPlacement); } if (!style) { @@ -913,10 +913,6 @@ class Map extends Camera { this.style.loadJSON(style); } - this.on('rotate', this.style._redoPlacement); - this.on('pitch', this.style._redoPlacement); - this.on('move', this.style._redoPlacement); - return this; } @@ -1403,7 +1399,7 @@ class Map extends Camera { * @returns {boolean} A Boolean indicating whether the map is fully loaded. */ loaded() { - if (this._styleDirty || this._sourcesDirty) + if (this._styleDirty || this._sourcesDirty || this._placementDirty) return false; if (!this.style || !this.style.loaded()) return false; @@ -1458,12 +1454,15 @@ class Map extends Camera { this.style._updateSources(this.transform); } + this._placementDirty = this.style && this.style._updatePlacement(this.painter.transform, this.showCollisionBoxes, this._collisionFadeDuration); + // Actually draw this.painter.render(this.style, { showTileBoundaries: this.showTileBoundaries, showOverdrawInspector: this._showOverdrawInspector, rotating: this.rotating, - zooming: this.zooming + zooming: this.zooming, + collisionFadeDuration: this._collisionFadeDuration }); this.fire('render'); @@ -1473,19 +1472,22 @@ class Map extends Camera { this.fire('load'); } - this._frameId = null; - - // Flag an ongoing transition + // We should set _styleDirty for ongoing animations before firing 'render', + // but the test suite currently assumes that it can read still images while animations are + // still ongoing. See https://github.com/mapbox/mapbox-gl-js/issues/3966 if (!this.animationLoop.stopped()) { this._styleDirty = true; } + this._frameId = null; + + // Schedule another render frame if it's needed. // // Even though `_styleDirty` and `_sourcesDirty` are reset in this // method, synchronous events fired during Style#update or // Style#_updateSources could have caused them to be set again. - if (this._sourcesDirty || this._repaint || this._styleDirty) { + if (this._sourcesDirty || this._repaint || this._styleDirty || this._placementDirty) { this._rerender(); } @@ -1566,7 +1568,11 @@ class Map extends Camera { set showCollisionBoxes(value: boolean) { if (this._showCollisionBoxes === value) return; this._showCollisionBoxes = value; - this.style._redoPlacement(); + if (value) { + // When we turn collision boxes on we have to generate them for existing tiles + // When we turn them off, there's no cost to leaving existing boxes in place + this.style._generateCollisionBoxes(); + } } /* diff --git a/src/util/struct_array.js b/src/util/struct_array.js index 84ec19960bb..1ac1b53d6d7 100644 --- a/src/util/struct_array.js +++ b/src/util/struct_array.js @@ -334,6 +334,20 @@ function createStructArrayType(options: StructArrayTypeParameters): Class 1) { + name += c; + } + if (name in StructArrayType.prototype) { + throw new Error(`${name} is a reserved name and cannot be used as a member name.`); + } + // $FlowFixMe + StructArrayType.prototype[name] = createIndexedMemberComponentGetter(member, c, size); + } + } + return StructArrayType; } @@ -397,6 +411,13 @@ function createMemberComponentString(member, component) { return `this._structArray.${getArrayViewName(member.type)}[${index}]`; } +function createIndexedMemberComponentGetter(member, component, size) { + const componentOffset = (member.offset / sizeOf(member.type) + component).toFixed(0); + const componentStride = size / sizeOf(member.type); + return new Function('index', + `return this.${getArrayViewName(member.type)}[index * ${componentStride} + ${componentOffset}];`); +} + function createAccessors(member, c) { const code = createMemberComponentString(member, c); return { diff --git a/test/ignores.json b/test/ignores.json index 7f79f254e84..8436de1ebed 100644 --- a/test/ignores.json +++ b/test/ignores.json @@ -1,6 +1,5 @@ { "query-tests/regressions/mapbox-gl-js#4494": "https://github.com/mapbox/mapbox-gl-js/issues/2716", - "query-tests/symbol-features-in/tilted-outside": "https://github.com/mapbox/mapbox-gl-js/issues/4945", "query-tests/symbol/panned-after-insert": "https://github.com/mapbox/mapbox-gl-js/issues/3346", "query-tests/symbol/rotated-after-insert": "https://github.com/mapbox/mapbox-gl-js/issues/3346", "render-tests/fill-extrusion-pattern/@2x": "https://github.com/mapbox/mapbox-gl-js/issues/3327", diff --git a/test/integration/glyphs/mapbox/Open Sans Bold,Arial Unicode MS Bold/0-255.pbf b/test/integration/glyphs/mapbox/Open Sans Bold,Arial Unicode MS Bold/0-255.pbf new file mode 100644 index 00000000000..cea1c094d6a Binary files /dev/null and b/test/integration/glyphs/mapbox/Open Sans Bold,Arial Unicode MS Bold/0-255.pbf differ diff --git a/test/integration/glyphs/mapbox/Open Sans Semibold,Arial Unicode MS Bold/0-255.pbf b/test/integration/glyphs/mapbox/Open Sans Semibold,Arial Unicode MS Bold/0-255.pbf new file mode 100644 index 00000000000..cea1c094d6a Binary files /dev/null and b/test/integration/glyphs/mapbox/Open Sans Semibold,Arial Unicode MS Bold/0-255.pbf differ diff --git a/test/integration/lib/query.js b/test/integration/lib/query.js index 16bcb8f9948..adfb76af56a 100644 --- a/test/integration/lib/query.js +++ b/test/integration/lib/query.js @@ -97,7 +97,10 @@ exports.run = function (implementation, options, query) { drawAxisAlignedLine([b[0], a[1]], [a[0], a[1]], data, width, height, color); } - const actual = path.join(dir, 'actual.png'); + const actualJSON = path.join(dir, 'actual.json'); + fs.writeFile(actualJSON, JSON.stringify(results, null, 2), () => {}); + + const actualPNG = path.join(dir, 'actual.png'); const png = new PNG({ width: params.width * params.pixelRatio, @@ -107,9 +110,9 @@ exports.run = function (implementation, options, query) { png.data = data; png.pack() - .pipe(fs.createWriteStream(actual)) + .pipe(fs.createWriteStream(actualPNG)) .on('finish', () => { - params.actual = fs.readFileSync(actual).toString('base64'); + params.actual = fs.readFileSync(actualPNG).toString('base64'); done(); }); }); diff --git a/test/integration/query-tests/symbol-features-in/pitched-screen/expected.json b/test/integration/query-tests/symbol-features-in/pitched-screen/expected.json index e505ece2845..0c35f4db073 100644 --- a/test/integration/query-tests/symbol-features-in/pitched-screen/expected.json +++ b/test/integration/query-tests/symbol-features-in/pitched-screen/expected.json @@ -1,189 +1,68 @@ [ - { - "geometry": { - "type": "Point", - "coordinates": [ - 2.9999542236328125, - 4.000061604283047 - ] - }, - "type": "Feature", - "properties": {} - }, - { - "geometry": { - "type": "Point", - "coordinates": [ - 4.000053405761719, - 4.999974190748432 - ] - }, - "type": "Feature", - "properties": {} - }, - { - "geometry": { - "type": "Point", - "coordinates": [ - 2.9999542236328125, - 4.000061604283047 - ] - }, - "type": "Feature", - "properties": {} - }, - { - "geometry": { - "type": "Point", - "coordinates": [ - 4.000053405761719, - 4.000061604283047 - ] - }, - "type": "Feature", - "properties": {} - }, - { - "geometry": { - "type": "Point", - "coordinates": [ - 4.999980926513672, - 4.999974190748432 - ] - }, - "type": "Feature", - "properties": {} - }, - { - "geometry": { - "type": "Point", - "coordinates": [ - 4.000053405761719, - 4.000061604283047 - ] - }, - "type": "Feature", - "properties": {} - }, - { - "geometry": { - "type": "Point", - "coordinates": [ - 4.000053405761719, - 4.999974190748432 - ] - }, - "type": "Feature", - "properties": {} - }, - { - "geometry": { - "type": "Point", - "coordinates": [ - 4.999980926513672, - 4.000061604283047 - ] - }, - "type": "Feature", - "properties": {} - }, - { - "geometry": { - "type": "Point", - "coordinates": [ - 2.9999542236328125, - 4.000061604283047 - ] - }, - "type": "Feature", - "properties": {} - }, - { - "geometry": { - "type": "Point", - "coordinates": [ - 4.000053405761719, - 4.000061604283047 - ] - }, - "type": "Feature", - "properties": {} - }, - { - "geometry": { - "type": "Point", - "coordinates": [ - 2.9999542236328125, - 4.000061604283047 - ] - }, - "type": "Feature", - "properties": {} - }, - { - "geometry": { - "type": "Point", - "coordinates": [ - 4.000053405761719, - 2.999955856573024 - ] - }, - "type": "Feature", - "properties": {} - }, - { - "geometry": { - "type": "Point", - "coordinates": [ - 4.999980926513672, - 4.000061604283047 - ] - }, - "type": "Feature", - "properties": {} - }, - { - "geometry": { - "type": "Point", - "coordinates": [ - 4.000053405761719, - 2.999955856573024 - ] - }, - "type": "Feature", - "properties": {} - }, - { - "geometry": { - "type": "Point", - "coordinates": [ - 4.000053405761719, - 4.000061604283047 - ] - }, - "type": "Feature", - "properties": {} - }, - { - "geometry": { - "type": "Point", - "coordinates": [ - 4.000053405761719, - 2.999955856573024 - ] - }, - "type": "Feature", - "properties": {} - }, - { - "geometry": { - "type": "Point", - "coordinates": [ - 4.000053405761719, - 2.999955856573024 - ] - }, - "type": "Feature", - "properties": {} - } + { + "geometry": { + "type": "Point", + "coordinates": [ + 4.000053405761719, + 4.999974190748432 + ] + }, + "type": "Feature", + "properties": {} + }, + { + "geometry": { + "type": "Point", + "coordinates": [ + 4.999980926513672, + 4.999974190748432 + ] + }, + "type": "Feature", + "properties": {} + }, + { + "geometry": { + "type": "Point", + "coordinates": [ + 2.9999542236328125, + 4.000061604283047 + ] + }, + "type": "Feature", + "properties": {} + }, + { + "geometry": { + "type": "Point", + "coordinates": [ + 4.000053405761719, + 2.999955856573024 + ] + }, + "type": "Feature", + "properties": {} + }, + { + "geometry": { + "type": "Point", + "coordinates": [ + 4.000053405761719, + 4.000061604283047 + ] + }, + "type": "Feature", + "properties": {} + }, + { + "geometry": { + "type": "Point", + "coordinates": [ + 4.999980926513672, + 4.000061604283047 + ] + }, + "type": "Feature", + "properties": {} + } ] diff --git a/test/integration/query-tests/symbol/filtered/style.json b/test/integration/query-tests/symbol/filtered/style.json index 6ee629e3e8f..80ce49be919 100644 --- a/test/integration/query-tests/symbol/filtered/style.json +++ b/test/integration/query-tests/symbol/filtered/style.json @@ -6,8 +6,8 @@ "width": 500, "height": 500, "queryGeometry": [ - 366, - 311 + 370, + 305 ] } }, diff --git a/test/integration/query-tests/symbol/rotated-inside/expected.json b/test/integration/query-tests/symbol/rotated-inside/expected.json index df01aa9286c..5054d113257 100644 --- a/test/integration/query-tests/symbol/rotated-inside/expected.json +++ b/test/integration/query-tests/symbol/rotated-inside/expected.json @@ -1,318 +1,158 @@ [ { - "properties": { - "id": "34005", - "name": "Burlington County", - "pop_density": 2007.2920458152796 - }, - "id": 33, - "type": "Feature", "geometry": { "type": "Polygon", "coordinates": [ [ [ - -74.52850341796875, - 39.56970506644248 - ], - [ - -74.54498291015625, - 39.58029027440864 - ], - [ - -74.542236328125, - 39.586640623314594 - ], - [ - -74.55322265625, - 39.59933957529532 - ], - [ - -74.57794189453125, - 39.59933957529532 - ], - [ - -74.58892822265625, - 39.61203619933693 - ], - [ - -74.6246337890625, - 39.62684598486689 - ], - [ - -74.63287353515625, - 39.62049932196811 - ], - [ - -74.6356201171875, - 39.62896140981414 - ], - [ - -74.6575927734375, - 39.62896140981414 - ], - [ - -74.66033935546875, - 39.66914219401812 - ], - [ - -74.6685791015625, - 39.68393975392732 - ], - [ - -74.77294921875, - 39.751545363937566 - ], - [ - -74.80865478515625, - 39.785323314592574 - ], - [ - -74.8388671875, - 39.79165483525341 - ], - [ - -74.8773193359375, - 39.78321267821704 - ], - [ - -74.89654541015625, - 39.785323314592574 - ], - [ - -74.90478515625, - 39.79165483525341 - ], - [ - -74.9322509765625, - 39.88866516883712 - ], - [ - -74.95147705078125, - 39.8992015115692 - ], - [ - -74.9652099609375, - 39.92026933763398 - ], - [ - -74.98443603515625, - 39.92869465373238 - ], - [ - -74.9761962890625, - 39.9413306837869 - ], - [ - -75.01739501953125, - 39.947647823922495 - ], - [ - -75.0146484375, - 39.95817509460008 - ], - [ - -75.0201416015625, - 39.96870074491696 - ], - [ - -75.01190185546875, - 39.981329386272165 - ], - [ - -75.0201416015625, - 39.98974718404571 - ], - [ - -75.03387451171875, - 39.99185147142347 - ], - [ - -75.03662109375, - 39.9981639445858 - ], - [ - -75.06134033203125, - 39.99185147142347 - ], - [ - -75.047607421875, - 40.01078714046551 - ], - [ - -75.0146484375, - 40.02130468739708 + -74.26483154296875, + 40.168380093142446 ], [ - -74.96795654296875, - 40.05284760182397 + -74.25933837890625, + 40.16208338164617 ], [ - -74.92950439453125, - 40.071766346626134 + -74.2620849609375, + 40.14948820651523 ], [ - -74.90753173828125, - 40.071766346626134 + -74.25384521484375, + 40.14738878354049 ], [ - -74.86358642578125, - 40.08437592451517 + -74.24285888671875, + 40.1305910638018 ], [ - -74.8553466796875, - 40.09488212232117 + -74.24560546875, + 40.117990048904744 ], [ - -74.8388671875, - 40.10118506258701 + -74.23187255859375, + 40.109588074741396 ], [ - -74.82513427734375, - 40.128491056854074 + -74.20440673828125, + 40.11378919157522 ], [ - -74.78668212890625, - 40.12219064672337 + -74.16046142578125, + 40.107487419012415 ], [ - -74.761962890625, - 40.13479088304851 + -74.1522216796875, + 40.099084147368444 ], [ - -74.74273681640625, - 40.1368906953459 + -74.1082763671875, + 40.13899044275823 ], [ - -74.72076416015625, - 40.14948820651523 + -74.09454345703125, + 40.109588074741396 ], [ - -74.70428466796875, - 40.17047886718112 + -74.07257080078125, + 40.09067983779909 ], [ - -74.7125244140625, - 40.168380093142446 + -74.0478515625, + 40.10538669840983 ], [ - -74.7125244140625, - 40.180971763887186 + -73.9654541015625, + 40.099084147368444 ], [ - -74.70428466796875, - 40.18516846826054 + -74.00115966796875, + 39.97712009843963 ], [ - -74.67681884765625, - 40.174676220563384 + -74.03411865234375, + 39.77054750039528 ], [ - -74.6795654296875, - 40.16628125420655 + -74.04510498046875, + 39.73676229957945 ], [ - -74.6630859375, - 40.16628125420655 + -74.0972900390625, + 39.650112101863726 ], [ - -74.6356201171875, - 40.14948820651523 + -74.234619140625, + 39.47648555419738 ], [ - -74.6246337890625, - 40.14948820651523 + -74.29779052734375, + 39.47648555419738 ], [ - -74.6246337890625, - 40.14318974292439 + -74.31427001953125, + 39.50192146626986 ], [ - -74.6026611328125, - 40.1368906953459 + -74.3280029296875, + 39.508278990341125 ], [ - -74.58892822265625, - 40.13899044275823 + -74.3829345703125, + 39.499802162332884 ], [ - -74.39117431640625, - 39.77476948529548 + -74.4049072265625, + 39.54005788576376 ], [ - -74.39117431640625, - 39.59722324495564 + -74.41864013671875, + 39.55064761909321 ], [ - -74.40216064453125, - 39.57393934359189 + -74.4158935546875, + 39.563353165829284 ], [ -74.42138671875, 39.56970506644248 ], [ - -74.41864013671875, - 39.55911824217185 - ], - [ - -74.44061279296875, - 39.55488305992401 - ], - [ - -74.4378662109375, - 39.54217596171196 - ], - [ - -74.44610595703125, - 39.546411919686705 - ], - [ - -74.443359375, - 39.55700068337126 - ], - [ - -74.454345703125, - 39.55911824217185 - ], - [ - -74.45709228515625, - 39.54852980171145 - ], - [ - -74.46533203125, - 39.55700068337126 - ], - [ - -74.476318359375, - 39.552765371831015 + -74.40216064453125, + 39.57393934359189 ], [ - -74.48455810546875, - 39.55911824217185 + -74.39117431640625, + 39.59722324495564 ], [ - -74.49554443359375, - 39.55700068337126 + -74.39117431640625, + 39.77476948529548 ], [ - -74.5037841796875, - 39.56970506644248 + -74.55322265625, + 40.08017299133499 ], [ - -74.51202392578125, - 39.56970506644248 + -74.52850341796875, + 40.09067983779909 ], [ - -74.51751708984375, - 39.57393934359189 + -74.40765380859375, + 40.172577576321686 ], [ - -74.52850341796875, - 39.56970506644248 + -74.26483154296875, + 40.168380093142446 ] ] ] - } + }, + "type": "Feature", + "properties": { + "id": "34029", + "name": "Ocean County", + "pop_density": 2310.250993696356 + }, + "id": 49 } ] \ No newline at end of file diff --git a/test/integration/query-tests/symbol/rotated-inside/style.json b/test/integration/query-tests/symbol/rotated-inside/style.json index f9af284a367..420555c5a63 100644 --- a/test/integration/query-tests/symbol/rotated-inside/style.json +++ b/test/integration/query-tests/symbol/rotated-inside/style.json @@ -6,8 +6,8 @@ "width": 500, "height": 500, "queryGeometry": [ - 366, - 311 + 370, + 305 ] } }, @@ -49,4 +49,4 @@ "interactive": true } ] -} \ No newline at end of file +} diff --git a/test/integration/render-tests/debug/collision-lines-pitched/expected.png b/test/integration/render-tests/debug/collision-lines-pitched/expected.png new file mode 100644 index 00000000000..c629bd863f8 Binary files /dev/null and b/test/integration/render-tests/debug/collision-lines-pitched/expected.png differ diff --git a/test/integration/render-tests/debug/collision-lines-pitched/style.json b/test/integration/render-tests/debug/collision-lines-pitched/style.json new file mode 100644 index 00000000000..639f2f0a236 --- /dev/null +++ b/test/integration/render-tests/debug/collision-lines-pitched/style.json @@ -0,0 +1,55 @@ +{ + "version": 8, + "metadata": { + "test": { + "collisionDebug": true, + "width": 500, + "height": 500 + } + }, + "center": [ + -74.522, + 39.92 + ], + "pitch": 30, + "zoom": 7.5, + "sources": { + "us-counties": { + "type": "vector", + "maxzoom": 7, + "minzoom": 7, + "tiles": [ + "local://tiles/counties-{z}-{x}-{y}.mvt" + ] + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "sprite": "local://sprites/sprite", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "counties", + "type": "symbol", + "source": "us-counties", + "source-layer": "counties", + "layout": { + "text-field": "{name}", + "text-size": 11, + "symbol-spacing": 60, + "text-max-angle": 1000, + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "symbol-placement": "line" + }, + "interactive": true + } + ] +} diff --git a/test/integration/render-tests/debug/collision-lines/expected.png b/test/integration/render-tests/debug/collision-lines/expected.png new file mode 100644 index 00000000000..b1b6a619861 Binary files /dev/null and b/test/integration/render-tests/debug/collision-lines/expected.png differ diff --git a/test/integration/render-tests/debug/collision-lines/style.json b/test/integration/render-tests/debug/collision-lines/style.json new file mode 100644 index 00000000000..65fd31be232 --- /dev/null +++ b/test/integration/render-tests/debug/collision-lines/style.json @@ -0,0 +1,54 @@ +{ + "version": 8, + "metadata": { + "test": { + "collisionDebug": true, + "width": 500, + "height": 500 + } + }, + "center": [ + -74.522, + 39.92 + ], + "zoom": 7, + "sources": { + "us-counties": { + "type": "vector", + "maxzoom": 7, + "minzoom": 7, + "tiles": [ + "local://tiles/counties-{z}-{x}-{y}.mvt" + ] + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "sprite": "local://sprites/sprite", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "counties", + "type": "symbol", + "source": "us-counties", + "source-layer": "counties", + "layout": { + "text-field": "{name}", + "text-size": 11, + "symbol-spacing": 60, + "text-max-angle": 1000, + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "symbol-placement": "line" + }, + "interactive": true + } + ] +} diff --git a/test/integration/render-tests/debug/collision-overscaled/expected.png b/test/integration/render-tests/debug/collision-overscaled/expected.png index 4c54aa17db6..268943d200f 100644 Binary files a/test/integration/render-tests/debug/collision-overscaled/expected.png and b/test/integration/render-tests/debug/collision-overscaled/expected.png differ diff --git a/test/integration/render-tests/debug/collision-pitched-wrapped/expected.png b/test/integration/render-tests/debug/collision-pitched-wrapped/expected.png index 9d418548278..ae4d449c1c8 100644 Binary files a/test/integration/render-tests/debug/collision-pitched-wrapped/expected.png and b/test/integration/render-tests/debug/collision-pitched-wrapped/expected.png differ diff --git a/test/integration/render-tests/debug/collision-pitched/expected.png b/test/integration/render-tests/debug/collision-pitched/expected.png index f55f51da0f7..a315d0996bc 100644 Binary files a/test/integration/render-tests/debug/collision-pitched/expected.png and b/test/integration/render-tests/debug/collision-pitched/expected.png differ diff --git a/test/integration/render-tests/debug/collision/expected.png b/test/integration/render-tests/debug/collision/expected.png index f61575f1738..301fb9cd586 100644 Binary files a/test/integration/render-tests/debug/collision/expected.png and b/test/integration/render-tests/debug/collision/expected.png differ diff --git a/test/integration/render-tests/extent/1024-symbol/expected.png b/test/integration/render-tests/extent/1024-symbol/expected.png index f3eb10087a5..4129e538844 100644 Binary files a/test/integration/render-tests/extent/1024-symbol/expected.png and b/test/integration/render-tests/extent/1024-symbol/expected.png differ diff --git a/test/integration/render-tests/icon-text-fit/placement-line/expected.png b/test/integration/render-tests/icon-text-fit/placement-line/expected.png index 0bf2f3ccd53..e86de1ddd7a 100644 Binary files a/test/integration/render-tests/icon-text-fit/placement-line/expected.png and b/test/integration/render-tests/icon-text-fit/placement-line/expected.png differ diff --git a/test/integration/render-tests/mixed-zoom/z10-z11/expected.png b/test/integration/render-tests/mixed-zoom/z10-z11/expected.png new file mode 100644 index 00000000000..2037a3ce609 Binary files /dev/null and b/test/integration/render-tests/mixed-zoom/z10-z11/expected.png differ diff --git a/test/integration/render-tests/mixed-zoom/z10-z11/style.json b/test/integration/render-tests/mixed-zoom/z10-z11/style.json new file mode 100644 index 00000000000..526df5fd057 --- /dev/null +++ b/test/integration/render-tests/mixed-zoom/z10-z11/style.json @@ -0,0 +1,24 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 512, + "height": 512, + "debug": true, + "collisionDebug": true, + "operations": [ + ["setStyle", "local://../../node_modules/mapbox-gl-styles/styles/basic-v9.json"], + ["wait"], + ["setZoom", 11.1], + ["sleep", 3000] + ] + } + }, + "center": [ + -118.303, + 33.908 + ], + "zoom": 10.5, + "sources": {}, + "layers": [] +} diff --git a/test/integration/render-tests/regressions/mapbox-gl-js#4647/expected.png b/test/integration/render-tests/regressions/mapbox-gl-js#4647/expected.png index 5bbd8ae7f2d..8960cd8ad2b 100644 Binary files a/test/integration/render-tests/regressions/mapbox-gl-js#4647/expected.png and b/test/integration/render-tests/regressions/mapbox-gl-js#4647/expected.png differ diff --git a/test/integration/render-tests/runtime-styling/set-style-glyphs/expected.png b/test/integration/render-tests/runtime-styling/set-style-glyphs/expected.png index 08a12b4dd74..c836ec518ca 100644 Binary files a/test/integration/render-tests/runtime-styling/set-style-glyphs/expected.png and b/test/integration/render-tests/runtime-styling/set-style-glyphs/expected.png differ diff --git a/test/integration/render-tests/symbol-avoid-edges/default/expected.png b/test/integration/render-tests/symbol-avoid-edges/default/expected.png deleted file mode 100644 index 9a5fb0b2d34..00000000000 Binary files a/test/integration/render-tests/symbol-avoid-edges/default/expected.png and /dev/null differ diff --git a/test/integration/render-tests/symbol-avoid-edges/default/style.json b/test/integration/render-tests/symbol-avoid-edges/default/style.json deleted file mode 100644 index 0a568bfe33c..00000000000 --- a/test/integration/render-tests/symbol-avoid-edges/default/style.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "version": 8, - "metadata": { - "test": { - "height": 256 - } - }, - "center": [ - 13.418056, - 52.499167 - ], - "zoom": 14, - "sources": { - "mapbox": { - "type": "vector", - "maxzoom": 14, - "tiles": [ - "local://tiles/{z}-{x}-{y}.mvt" - ] - } - }, - "sprite": "local://sprites/sprite", - "layers": [ - { - "id": "background", - "type": "background", - "paint": { - "background-color": "white" - } - }, - { - "id": "map-line", - "type": "symbol", - "source": "mapbox", - "source-layer": "road", - "layout": { - "symbol-placement": "line", - "icon-allow-overlap": true, - "icon-ignore-placement": true, - "icon-image": "restaurant-12", - "icon-rotation-alignment": "map" - }, - "paint": { - "icon-opacity": 1 - } - } - ] -} \ No newline at end of file diff --git a/test/integration/render-tests/symbol-avoid-edges/rotated-false/expected.png b/test/integration/render-tests/symbol-avoid-edges/rotated-false/expected.png deleted file mode 100644 index 839d2d949ee..00000000000 Binary files a/test/integration/render-tests/symbol-avoid-edges/rotated-false/expected.png and /dev/null differ diff --git a/test/integration/render-tests/symbol-avoid-edges/rotated-false/style.json b/test/integration/render-tests/symbol-avoid-edges/rotated-false/style.json deleted file mode 100644 index 44e0f05060c..00000000000 --- a/test/integration/render-tests/symbol-avoid-edges/rotated-false/style.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "version": 8, - "metadata": { - "test": { - "diff": 0.0002, - "height": 256 - } - }, - "center": [ - 13.418056, - 52.499167 - ], - "zoom": 14, - "bearing": 45, - "sources": { - "mapbox": { - "type": "vector", - "maxzoom": 14, - "tiles": [ - "local://tiles/{z}-{x}-{y}.mvt" - ] - } - }, - "sprite": "local://sprites/sprite", - "layers": [ - { - "id": "background", - "type": "background", - "paint": { - "background-color": "white" - } - }, - { - "id": "map-line", - "type": "symbol", - "source": "mapbox", - "source-layer": "road", - "layout": { - "symbol-placement": "line", - "icon-allow-overlap": true, - "icon-ignore-placement": true, - "icon-image": "restaurant-12", - "icon-rotation-alignment": "map" - }, - "paint": { - "icon-opacity": 1 - } - } - ] -} \ No newline at end of file diff --git a/test/integration/render-tests/symbol-avoid-edges/rotated/expected.png b/test/integration/render-tests/symbol-avoid-edges/rotated/expected.png deleted file mode 100644 index ce9a5d4eaf8..00000000000 Binary files a/test/integration/render-tests/symbol-avoid-edges/rotated/expected.png and /dev/null differ diff --git a/test/integration/render-tests/symbol-avoid-edges/rotated/style.json b/test/integration/render-tests/symbol-avoid-edges/rotated/style.json deleted file mode 100644 index 17aafd8f693..00000000000 --- a/test/integration/render-tests/symbol-avoid-edges/rotated/style.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "version": 8, - "metadata": { - "test": { - "height": 256 - } - }, - "center": [ - 13.418056, - 52.499167 - ], - "zoom": 14, - "bearing": 45, - "sources": { - "mapbox": { - "type": "vector", - "maxzoom": 14, - "tiles": [ - "local://tiles/{z}-{x}-{y}.mvt" - ] - } - }, - "sprite": "local://sprites/sprite", - "layers": [ - { - "id": "background", - "type": "background", - "paint": { - "background-color": "white" - } - }, - { - "id": "map-line", - "type": "symbol", - "source": "mapbox", - "source-layer": "road", - "layout": { - "symbol-avoid-edges": true, - "symbol-placement": "line", - "icon-allow-overlap": true, - "icon-ignore-placement": true, - "icon-image": "restaurant-12", - "icon-rotation-alignment": "map" - }, - "paint": { - "icon-opacity": 1 - } - } - ] -} \ No newline at end of file diff --git a/test/integration/render-tests/symbol-avoid-edges/true/expected.png b/test/integration/render-tests/symbol-avoid-edges/true/expected.png deleted file mode 100644 index 29fcf50d088..00000000000 Binary files a/test/integration/render-tests/symbol-avoid-edges/true/expected.png and /dev/null differ diff --git a/test/integration/render-tests/symbol-avoid-edges/true/style.json b/test/integration/render-tests/symbol-avoid-edges/true/style.json deleted file mode 100644 index bf0c971d2d5..00000000000 --- a/test/integration/render-tests/symbol-avoid-edges/true/style.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "version": 8, - "metadata": { - "test": { - "height": 256 - } - }, - "center": [ - 13.418056, - 52.499167 - ], - "zoom": 14, - "sources": { - "mapbox": { - "type": "vector", - "maxzoom": 14, - "tiles": [ - "local://tiles/{z}-{x}-{y}.mvt" - ] - } - }, - "sprite": "local://sprites/sprite", - "layers": [ - { - "id": "background", - "type": "background", - "paint": { - "background-color": "white" - } - }, - { - "id": "map-line", - "type": "symbol", - "source": "mapbox", - "source-layer": "road", - "layout": { - "symbol-avoid-edges": true, - "symbol-placement": "line", - "icon-allow-overlap": true, - "icon-ignore-placement": true, - "icon-image": "restaurant-12", - "icon-rotation-alignment": "map" - }, - "paint": { - "icon-opacity": 1 - } - } - ] -} \ No newline at end of file diff --git a/test/integration/render-tests/symbol-placement/line-overscaled/expected.png b/test/integration/render-tests/symbol-placement/line-overscaled/expected.png index de7ef0c77b0..1358f2ba5ab 100644 Binary files a/test/integration/render-tests/symbol-placement/line-overscaled/expected.png and b/test/integration/render-tests/symbol-placement/line-overscaled/expected.png differ diff --git a/test/integration/render-tests/symbol-placement/line/expected.png b/test/integration/render-tests/symbol-placement/line/expected.png index 902671a731a..458a62c9999 100644 Binary files a/test/integration/render-tests/symbol-placement/line/expected.png and b/test/integration/render-tests/symbol-placement/line/expected.png differ diff --git a/test/integration/render-tests/symbol-placement/point/expected.png b/test/integration/render-tests/symbol-placement/point/expected.png index de16456d76f..3decd9d2215 100644 Binary files a/test/integration/render-tests/symbol-placement/point/expected.png and b/test/integration/render-tests/symbol-placement/point/expected.png differ diff --git a/test/integration/render-tests/symbol-spacing/line-close/expected.png b/test/integration/render-tests/symbol-spacing/line-close/expected.png index 24f7a0fea3a..f801591aa25 100644 Binary files a/test/integration/render-tests/symbol-spacing/line-close/expected.png and b/test/integration/render-tests/symbol-spacing/line-close/expected.png differ diff --git a/test/integration/render-tests/symbol-spacing/line-far/expected.png b/test/integration/render-tests/symbol-spacing/line-far/expected.png index 4ad171c587f..01ca25c0d3f 100644 Binary files a/test/integration/render-tests/symbol-spacing/line-far/expected.png and b/test/integration/render-tests/symbol-spacing/line-far/expected.png differ diff --git a/test/integration/render-tests/symbol-visibility/visible/expected.png b/test/integration/render-tests/symbol-visibility/visible/expected.png index 5014fa57ed6..15a436c560e 100644 Binary files a/test/integration/render-tests/symbol-visibility/visible/expected.png and b/test/integration/render-tests/symbol-visibility/visible/expected.png differ diff --git a/test/integration/render-tests/text-font/chinese/expected.png b/test/integration/render-tests/text-font/chinese/expected.png index 95e5ce6608c..3a304d2f7ac 100644 Binary files a/test/integration/render-tests/text-font/chinese/expected.png and b/test/integration/render-tests/text-font/chinese/expected.png differ diff --git a/test/integration/render-tests/text-letter-spacing/zoom-and-property-function/style.json b/test/integration/render-tests/text-letter-spacing/zoom-and-property-function/style.json index 0d082e42af5..ead382d1b74 100644 --- a/test/integration/render-tests/text-letter-spacing/zoom-and-property-function/style.json +++ b/test/integration/render-tests/text-letter-spacing/zoom-and-property-function/style.json @@ -29,7 +29,7 @@ "type": "Point", "coordinates": [ 35, -15 ] } - } + } ] } } diff --git a/test/integration/render-tests/text-pitch-alignment/auto-text-rotation-alignment-map/expected.png b/test/integration/render-tests/text-pitch-alignment/auto-text-rotation-alignment-map/expected.png index 988d9a1002d..9e9635d8734 100644 Binary files a/test/integration/render-tests/text-pitch-alignment/auto-text-rotation-alignment-map/expected.png and b/test/integration/render-tests/text-pitch-alignment/auto-text-rotation-alignment-map/expected.png differ diff --git a/test/integration/render-tests/text-pitch-alignment/auto-text-rotation-alignment-viewport/expected.png b/test/integration/render-tests/text-pitch-alignment/auto-text-rotation-alignment-viewport/expected.png index 948d22f692a..69c308600b6 100644 Binary files a/test/integration/render-tests/text-pitch-alignment/auto-text-rotation-alignment-viewport/expected.png and b/test/integration/render-tests/text-pitch-alignment/auto-text-rotation-alignment-viewport/expected.png differ diff --git a/test/integration/render-tests/text-pitch-alignment/map-text-rotation-alignment-map/expected.png b/test/integration/render-tests/text-pitch-alignment/map-text-rotation-alignment-map/expected.png index 988d9a1002d..9e9635d8734 100644 Binary files a/test/integration/render-tests/text-pitch-alignment/map-text-rotation-alignment-map/expected.png and b/test/integration/render-tests/text-pitch-alignment/map-text-rotation-alignment-map/expected.png differ diff --git a/test/integration/render-tests/text-pitch-alignment/map-text-rotation-alignment-viewport/expected.png b/test/integration/render-tests/text-pitch-alignment/map-text-rotation-alignment-viewport/expected.png index 2d7031ca9a1..f9195acc845 100644 Binary files a/test/integration/render-tests/text-pitch-alignment/map-text-rotation-alignment-viewport/expected.png and b/test/integration/render-tests/text-pitch-alignment/map-text-rotation-alignment-viewport/expected.png differ diff --git a/test/integration/render-tests/text-pitch-alignment/viewport-overzoomed/expected.png b/test/integration/render-tests/text-pitch-alignment/viewport-overzoomed/expected.png index 97ad6702e44..c4f4958a064 100644 Binary files a/test/integration/render-tests/text-pitch-alignment/viewport-overzoomed/expected.png and b/test/integration/render-tests/text-pitch-alignment/viewport-overzoomed/expected.png differ diff --git a/test/integration/render-tests/text-pitch-alignment/viewport-text-rotation-alignment-map/expected.png b/test/integration/render-tests/text-pitch-alignment/viewport-text-rotation-alignment-map/expected.png index 01dff3a944c..634cab3bc86 100644 Binary files a/test/integration/render-tests/text-pitch-alignment/viewport-text-rotation-alignment-map/expected.png and b/test/integration/render-tests/text-pitch-alignment/viewport-text-rotation-alignment-map/expected.png differ diff --git a/test/integration/render-tests/text-pitch-alignment/viewport-text-rotation-alignment-viewport/expected.png b/test/integration/render-tests/text-pitch-alignment/viewport-text-rotation-alignment-viewport/expected.png index 948d22f692a..69c308600b6 100644 Binary files a/test/integration/render-tests/text-pitch-alignment/viewport-text-rotation-alignment-viewport/expected.png and b/test/integration/render-tests/text-pitch-alignment/viewport-text-rotation-alignment-viewport/expected.png differ diff --git a/test/integration/render-tests/text-tile-edge-clipping/default/expected.png b/test/integration/render-tests/text-tile-edge-clipping/default/expected.png index fbc8dde283b..4bc3b453c6c 100644 Binary files a/test/integration/render-tests/text-tile-edge-clipping/default/expected.png and b/test/integration/render-tests/text-tile-edge-clipping/default/expected.png differ diff --git a/test/integration/render-tests/text-visibility/visible/expected.png b/test/integration/render-tests/text-visibility/visible/expected.png index 37474b2bd1a..c52cc02e73b 100644 Binary files a/test/integration/render-tests/text-visibility/visible/expected.png and b/test/integration/render-tests/text-visibility/visible/expected.png differ diff --git a/test/integration/tiles/mapbox.mapbox-streets-v7/10-175-409.mvt b/test/integration/tiles/mapbox.mapbox-streets-v7/10-175-409.mvt new file mode 100644 index 00000000000..90d4416f3cf Binary files /dev/null and b/test/integration/tiles/mapbox.mapbox-streets-v7/10-175-409.mvt differ diff --git a/test/integration/tiles/mapbox.mapbox-streets-v7/11-351-818.mvt b/test/integration/tiles/mapbox.mapbox-streets-v7/11-351-818.mvt new file mode 100644 index 00000000000..ed31ed97636 Binary files /dev/null and b/test/integration/tiles/mapbox.mapbox-streets-v7/11-351-818.mvt differ diff --git a/test/suite_implementation.js b/test/suite_implementation.js index 955bc7c985d..bf0221dd5f2 100644 --- a/test/suite_implementation.js +++ b/test/suite_implementation.js @@ -37,7 +37,8 @@ module.exports = function(style, options, _callback) { attributionControl: false, preserveDrawingBuffer: true, axonometric: options.axonometric || false, - skew: options.skew || [0, 0] + skew: options.skew || [0, 0], + collisionFadeDuration: 0 }); // Configure the map to never stop the render loop @@ -51,7 +52,11 @@ module.exports = function(style, options, _callback) { map.once('load', () => { if (options.collisionDebug) { map.showCollisionBoxes = true; - options.operations = [["wait"]]; + if (options.operations) { + options.operations.push(["wait"]); + } else { + options.operations = [["wait"]]; + } } applyOperations(map, options.operations, () => { const viewport = gl.getParameter(gl.VIEWPORT); @@ -107,6 +112,12 @@ function applyOperations(map, operations, callback) { }; wait(); + } else if (operation[0] === 'sleep') { + // Prefer "wait", which renders until the map is loaded + // Use "sleep" when you need to test something that sidesteps the "loaded" logic + setTimeout(() => { + applyOperations(map, operations.slice(1), callback); + }, operation[1]); } else if (operation[0] === 'addImage') { const {data, width, height} = PNG.sync.read(fs.readFileSync(path.join(__dirname, './integration', operation[2]))); map.addImage(operation[1], {width, height, data: new Uint8Array(data)}, operation[3] || {}); diff --git a/test/unit/data/symbol_bucket.test.js b/test/unit/data/symbol_bucket.test.js index 0da86a6fc60..23fb2af54bc 100644 --- a/test/unit/data/symbol_bucket.test.js +++ b/test/unit/data/symbol_bucket.test.js @@ -6,11 +6,16 @@ const path = require('path'); const Protobuf = require('pbf'); const VectorTile = require('@mapbox/vector-tile').VectorTile; const SymbolBucket = require('../../../src/data/bucket/symbol_bucket'); -const CollisionTile = require('../../../src/symbol/collision_tile'); +const CollisionIndex = require('../../../src/symbol/collision_index'); const CollisionBoxArray = require('../../../src/symbol/collision_box'); const SymbolStyleLayer = require('../../../src/style/style_layer/symbol_style_layer'); const util = require('../../../src/util/util'); const featureFilter = require('../../../src/style-spec/feature_filter'); +const {performSymbolLayout} = require('../../../src/symbol/symbol_layout'); +const {performSymbolPlacement} = require('../../../src/symbol/symbol_placement'); +const Transform = require('../../../src/geo/transform'); + +const mat4 = require('@mapbox/gl-matrix').mat4; // Load a point feature from fixture tile. const vt = new VectorTile(new Protobuf(fs.readFileSync(path.join(__dirname, '/../../fixtures/mbsv5-6-18-23.vector.pbf')))); @@ -19,7 +24,19 @@ const glyphs = JSON.parse(fs.readFileSync(path.join(__dirname, '/../../fixtures/ /*eslint new-cap: 0*/ const collisionBoxArray = new CollisionBoxArray(); -const collision = new CollisionTile(0, 0, 1, 1, collisionBoxArray); +const transform = new Transform(); +transform.width = 100; +transform.height = 100; +transform.cameraToCenterDistance = 100; +const labelPlaneMatrix = mat4.identity(new Float64Array(16)); +// This is a bogus projection matrix: all it does is make tile coordinates +// project to somewhere within the viewport, assuming a tile extent of 8192. +mat4.scale(labelPlaneMatrix, labelPlaneMatrix, [1 / 8192, 1 / 8192, 1]); +const collision = new CollisionIndex(transform); +const showCollisionBoxes = false; +const zoom = 0; +const pixelRatio = 1; +const tileID = 0; const stacks = { 'Test': glyphs }; @@ -45,39 +62,37 @@ test('SymbolBucket', (t) => { const options = {iconDependencies: {}, glyphDependencies: {}}; // add feature from bucket A - const a = collision.grid.keys.length; + const a = collision.grid.keysLength(); bucketA.populate([{feature}], options); - bucketA.prepare(stacks, {}); - bucketA.place(collision); + performSymbolLayout(bucketA, stacks, {}); + performSymbolPlacement(bucketA, collision, showCollisionBoxes, zoom, pixelRatio, labelPlaneMatrix, labelPlaneMatrix, tileID, collisionBoxArray); - const b = collision.grid.keys.length; + const b = collision.grid.keysLength(); t.notEqual(a, b, 'places feature'); // add same feature from bucket B - const a2 = collision.grid.keys.length; + const a2 = collision.grid.keysLength(); bucketB.populate([{feature}], options); - bucketB.prepare(stacks, {}); - bucketB.place(collision); - const b2 = collision.grid.keys.length; - t.equal(a2, b2, 'detects collision and does not place feature'); + performSymbolLayout(bucketB, stacks, {}); + performSymbolPlacement(bucketB, collision, showCollisionBoxes, zoom, pixelRatio, labelPlaneMatrix, labelPlaneMatrix, tileID, collisionBoxArray); + const b2 = collision.grid.keysLength(); + t.equal(b2, a2, 'detects collision and does not place feature'); t.end(); }); - test('SymbolBucket integer overflow', (t) => { t.stub(util, 'warnOnce'); - t.stub(SymbolBucket, 'MAX_INSTANCES').value(5); + t.stub(SymbolBucket, 'MAX_GLYPHS').value(5); const bucket = bucketSetup(); const options = {iconDependencies: {}, glyphDependencies: {}}; bucket.populate([{feature}], options); - bucket.prepare(stacks, {}); - bucket.place(collision); + const fakeGlyph = { rect: { w: 10, h: 10 }, metrics: { left: 10, top: 10, advance: 10 } }; + performSymbolLayout(bucket, stacks, { 'Test': {97: fakeGlyph, 98: fakeGlyph, 99: fakeGlyph, 100: fakeGlyph, 101: fakeGlyph, 102: fakeGlyph} }); - t.ok(util.warnOnce.calledTwice); - t.ok(util.warnOnce.getCall(0).calledWithMatch(/Too many (symbols|glyphs) being rendered in a tile./)); - t.ok(util.warnOnce.getCall(1).calledWithMatch(/Too many (symbols|glyphs) being rendered in a tile./)); + t.ok(util.warnOnce.calledOnce); + t.ok(util.warnOnce.getCall(0).calledWithMatch(/Too many glyphs being rendered in a tile./)); t.end(); }); @@ -86,9 +101,9 @@ test('SymbolBucket redo placement', (t) => { const options = {iconDependencies: {}, glyphDependencies: {}}; bucket.populate([{feature}], options); - bucket.prepare(stacks, {}); - bucket.place(collision); - bucket.place(collision); + performSymbolLayout(bucket, stacks, {}); + performSymbolPlacement(bucket, collision, showCollisionBoxes, zoom, pixelRatio, labelPlaneMatrix, labelPlaneMatrix, tileID, collisionBoxArray); + performSymbolPlacement(bucket, collision, showCollisionBoxes, zoom, pixelRatio, labelPlaneMatrix, labelPlaneMatrix, tileID, collisionBoxArray); t.end(); }); diff --git a/test/unit/source/geojson_source.test.js b/test/unit/source/geojson_source.test.js index 71d6d4baabd..ff324f37167 100644 --- a/test/unit/source/geojson_source.test.js +++ b/test/unit/source/geojson_source.test.js @@ -196,7 +196,7 @@ test('GeoJSONSource#update', (t) => { const source = new GeoJSONSource('id', {data: {}}, mockDispatcher); source.map = { - transform: { cameraToCenterDistance: 1, cameraToTileDistance: () => { return 1; } } + transform: {} }; source.on('data', (e) => { diff --git a/test/unit/source/tile.test.js b/test/unit/source/tile.test.js index 0c7b4fb9298..37afe751db5 100644 --- a/test/unit/source/tile.test.js +++ b/test/unit/source/tile.test.js @@ -8,10 +8,10 @@ const fs = require('fs'); const path = require('path'); const vtpbf = require('vt-pbf'); const FeatureIndex = require('../../../src/data/feature_index'); -const CollisionTile = require('../../../src/symbol/collision_tile'); +const CollisionIndex = require('../../../src/symbol/collision_index'); +const Transform = require('../../../src/geo/transform'); const CollisionBoxArray = require('../../../src/symbol/collision_box'); const util = require('../../../src/util/util'); -const Evented = require('../../../src/util/evented'); test('querySourceFeatures', (t) => { const features = [{ @@ -202,100 +202,26 @@ test('Tile#isLessThan', (t)=>{ t.end(); }); -test('Tile#redoPlacement', (t) => { - - test('redoPlacement on an empty tile', (t) => { +test('Tile#placeLayer', (t) => { + test('placeLayer on an empty tile', (t) => { const tile = new Tile(new TileCoord(1, 1, 1)); tile.loadVectorData(null, createPainter()); - t.doesNotThrow(() => tile.redoPlacement({type: 'vector'})); - t.notOk(tile.redoWhenDone); - t.end(); - }); - - test('redoPlacement on a loading tile', (t) => { - const tile = new Tile(new TileCoord(1, 1, 1)); - t.doesNotThrow(() => tile.redoPlacement({type: 'vector'})); - t.ok(tile.redoWhenDone); + t.doesNotThrow(() => tile.placeLayer(false, new CollisionIndex(new Transform()), {id: 'layer'})); t.end(); }); - test('redoPlacement on a reloading tile', (t) => { + test('placeLayer on a loading tile', (t) => { const tile = new Tile(new TileCoord(1, 1, 1)); - tile.loadVectorData(createVectorData(), createPainter()); - - const options = { - type: 'vector', - dispatcher: { - send: () => {} - }, - map: { - transform: { cameraToCenterDistance: 1, cameraToTileDistance: () => { return 1; } } - } - }; - - tile.redoPlacement(options); - tile.redoPlacement(options); - - t.ok(tile.redoWhenDone); + t.doesNotThrow(() => tile.placeLayer(false, new CollisionIndex(new Transform()), {id: 'layer'})); t.end(); }); - test('reloaded tile fires a data event on completion', (t)=>{ - const tile = new Tile(new TileCoord(1, 1, 1)); - tile.loadVectorData(createVectorData(), createPainter()); - t.stub(tile, 'reloadSymbolData').returns(null); - const source = util.extend(new Evented(), { - type: 'vector', - dispatcher: { - send: (name, data, cb) => { - if (name === 'redoPlacement') setTimeout(cb, 300); - } - }, - map: { - transform: { cameraToCenterDistance: 1, cameraToTileDistance: () => { return 1; } }, - painter: { tileExtentVAO: {vao: 0}} - } - }); - - tile.redoPlacement(source); - tile.placementSource.on('data', ()=>{ - if (tile.state === 'loaded') t.end(); - }); - }); - - test('changing cameraToCenterDistance does not trigger placement for low pitch', (t)=>{ + test('placeLayer on a reloading tile', (t) => { const tile = new Tile(new TileCoord(1, 1, 1)); tile.loadVectorData(createVectorData(), createPainter()); - t.stub(tile, 'reloadSymbolData').returns(null); - const source1 = util.extend(new Evented(), { - type: 'vector', - dispatcher: { - send: (name, data, cb) => { - cb(); - } - }, - map: { - transform: { cameraToCenterDistance: 1, pitch: 10, cameraToTileDistance: () => { return 1; } }, - painter: { tileExtentVAO: {vao: 0}} - } - }); - - const source2 = util.extend(new Evented(), { - type: 'vector', - dispatcher: { - send: () => {} - }, - map: { - transform: { cameraToCenterDistance: 2, pitch: 10, cameraToTileDistance: () => { return 1; } }, - painter: { tileExtentVAO: {vao: 0}} - } - }); - - tile.redoPlacement(source1); - tile.redoPlacement(source2); - t.ok(tile.state === 'loaded'); + tile.placeLayer(false, new CollisionIndex(new Transform()), {id: 'layer'}); t.end(); }); @@ -395,7 +321,6 @@ function createVectorData(options) { const collisionBoxArray = new CollisionBoxArray(); return util.extend({ collisionBoxArray: collisionBoxArray.serialize(), - collisionTile: (new CollisionTile(0, 0, 1, 1, collisionBoxArray)).serialize(), featureIndex: (new FeatureIndex(new TileCoord(1, 1, 1))).serialize(), buckets: [] }, options); diff --git a/test/unit/source/tile_mask.test.js b/test/unit/source/tile_mask.test.js index d15a10225f2..50785a04615 100644 --- a/test/unit/source/tile_mask.test.js +++ b/test/unit/source/tile_mask.test.js @@ -9,6 +9,7 @@ test('computeTileMasks', (t) => { class Tile { constructor(z, x, y, w) { this.coord = new TileCoord(z, x, y, w); + this.sourceMaxZoom = 16; } setMask(mask) { diff --git a/test/unit/source/vector_tile_source.test.js b/test/unit/source/vector_tile_source.test.js index f3534d84e15..58d9b34687c 100644 --- a/test/unit/source/vector_tile_source.test.js +++ b/test/unit/source/vector_tile_source.test.js @@ -9,7 +9,7 @@ const Evented = require('../../../src/util/evented'); function createSource(options, transformCallback) { const source = new VectorTileSource('id', options, { send: function() {} }, options.eventedParent); source.onAdd({ - transform: { angle: 0, pitch: 0, cameraToCenterDistance: 1, cameraToTileDistance: () => { return 1; }, showCollisionBoxes: false }, + transform: { showCollisionBoxes: false }, _transformRequest: transformCallback ? transformCallback : (url) => { return { url }; } }); diff --git a/test/unit/source/vector_tile_worker_source.test.js b/test/unit/source/vector_tile_worker_source.test.js index e08c3dd6307..20c0f3b8099 100644 --- a/test/unit/source/vector_tile_worker_source.test.js +++ b/test/unit/source/vector_tile_worker_source.test.js @@ -47,54 +47,3 @@ test('removeTile', (t) => { t.end(); }); - -test('redoPlacement', (t) => { - - t.test('on loaded tile', (t) => { - const source = new VectorTileWorkerSource(null, new StyleLayerIndex()); - const tile = { - redoPlacement: function(angle, pitch, cameraToCenterDistance, cameraToTileDistance, showCollisionBoxes) { - t.equal(angle, 60); - t.equal(pitch, 30); - t.equal(showCollisionBoxes, false); - return { - result: {isResult: true}, - transferables: {isTransferrables: true} - }; - } - }; - source.loaded = {mapbox: {3: tile}}; - - source.redoPlacement({ - uid: 3, - source: 'mapbox', - angle: 60, - pitch: 30, - cameraToCenterDistance: 1, - cameraToTileDistance: 1, - showCollisionBoxes: false - }, (err, result, transferables) => { - t.error(err); - t.ok(result.isResult); - t.ok(transferables.isTransferrables); - t.end(); - }); - }); - - t.test('on loading tile', (t) => { - const source = new VectorTileWorkerSource(null, new StyleLayerIndex()); - const tile = {}; - source.loading = {mapbox: {3: tile}}; - - source.redoPlacement({ - uid: 3, - source: 'mapbox', - angle: 60 - }); - - t.equal(source.loading.mapbox[3].angle, 60); - t.end(); - }); - - t.end(); -}); diff --git a/test/unit/source/worker.test.js b/test/unit/source/worker.test.js index 4e418c418b9..e669b00f76c 100644 --- a/test/unit/source/worker.test.js +++ b/test/unit/source/worker.test.js @@ -16,7 +16,7 @@ test('load tile', (t) => { type: 'vector', source: 'source', uid: 0, - request: { url: '/error' }// Sinon fake server gives 404 responses by default + request: { url: '/error' }// Sinon fake server gives 404 responses by default }, (err) => { t.ok(err); window.restore(); @@ -28,18 +28,6 @@ test('load tile', (t) => { t.end(); }); -test('redo placement', (t) => { - const worker = new Worker(_self); - _self.registerWorkerSource('test', function() { - this.redoPlacement = function(options) { - t.ok(options.mapbox); - t.end(); - }; - }); - - worker.redoPlacement(0, {type: 'test', mapbox: true}); -}); - test('isolates different instances\' data', (t) => { const worker = new Worker(_self); diff --git a/test/unit/symbol/collision_feature.js b/test/unit/symbol/collision_feature.js index 73dd3455904..f48f5dfe8fb 100644 --- a/test/unit/symbol/collision_feature.js +++ b/test/unit/symbol/collision_feature.js @@ -21,7 +21,7 @@ test('CollisionFeature', (t) => { const point = new Point(500, 0); const anchor = new Anchor(point.x, point.y, 0, undefined); - const cf = new CollisionFeature(collisionBoxArray, [point], anchor, 0, 0, 0, shapedText, 1, 0, false); + const cf = new CollisionFeature(collisionBoxArray, [point], anchor, 0, 0, 0, shapedText, 1, 0, false, 1); t.equal(cf.boxEndIndex - cf.boxStartIndex, 1); const box = collisionBoxArray.get(cf.boxStartIndex); @@ -35,7 +35,7 @@ test('CollisionFeature', (t) => { test('line label', (t) => { const line = [new Point(0, 0), new Point(500, 100), new Point(510, 90), new Point(700, 0)]; const anchor = new Anchor(505, 95, 0, 1); - const cf = new CollisionFeature(collisionBoxArray, line, anchor, 0, 0, 0, shapedText, 1, 0, true); + const cf = new CollisionFeature(collisionBoxArray, line, anchor, 0, 0, 0, shapedText, 1, 0, true, 1); const boxPoints = pluckAnchorPoints(cf); t.deepEqual(boxPoints, [ { x: 370, y: 74}, @@ -61,39 +61,39 @@ test('CollisionFeature', (t) => { t.end(); }); - test('boxes for handling pitch underzooming have scale < 1', (t) => { + test('boxes for handling pitch underzooming', (t) => { const line = [new Point(0, 0), new Point(500, 100), new Point(510, 90), new Point(700, 0)]; const anchor = new Anchor(505, 95, 0, 1); - const cf = new CollisionFeature(collisionBoxArray, line, anchor, 0, 0, 0, shapedText, 1, 0, true); - const maxScales = pluckMaxScales(cf); - t.deepEqual(maxScales, [ - 0.37037035822868347, - 0.43478259444236755, - 0.5263158082962036, - 0.6666666865348816, - 0.9090909361839294, - 1.4285714626312256, - 2, - 3.3333332538604736, - 10, - Infinity, - 10, - 3.3333332538604736, - 2, - 1.4285714626312256, - 1.1111111640930176, - 0.9090909361839294, - 0.6666666865348816, - 0.5263158082962036, - 0.43478259444236755, - 0.37037035822868347]); + const cf = new CollisionFeature(collisionBoxArray, line, anchor, 0, 0, 0, shapedText, 1, 0, true, 1); + const distancesToAnchor = pluckDistancesToAnchor(cf); + t.deepEqual(distancesToAnchor, [ + -112, + -96, + -80, + -64, + -48, + -32, + -24, + -16, + -8, + 0, + 8, + 16, + 24, + 32, + 40, + 48, + 64, + 80, + 96, + 112]); t.end(); }); test('vertical line label', (t) => { const line = [new Point(0, 0), new Point(0, 100), new Point(0, 111), new Point(0, 112), new Point(0, 200)]; const anchor = new Anchor(0, 110, 0, 1); - const cf = new CollisionFeature(collisionBoxArray, line, anchor, 0, 0, 0, shapedText, 1, 0, true); + const cf = new CollisionFeature(collisionBoxArray, line, anchor, 0, 0, 0, shapedText, 1, 0, true, 1); const boxPoints = pluckAnchorPoints(cf); t.deepEqual(boxPoints, [ { x: 0, y: 10 }, @@ -124,7 +124,7 @@ test('CollisionFeature', (t) => { const line = [new Point(0, 0), new Point(500, 100), new Point(510, 90), new Point(700, 0)]; const anchor = new Anchor(505, 95, 0, 1); - const cf = new CollisionFeature(collisionBoxArray, line, anchor, 0, 0, 0, shapedText, 1, 0, true); + const cf = new CollisionFeature(collisionBoxArray, line, anchor, 0, 0, 0, shapedText, 1, 0, true, 1); t.equal(cf.boxEndIndex - cf.boxStartIndex, 0); t.end(); }); @@ -139,7 +139,7 @@ test('CollisionFeature', (t) => { const line = [new Point(0, 0), new Point(500, 100), new Point(510, 90), new Point(700, 0)]; const anchor = new Anchor(505, 95, 0, 1); - const cf = new CollisionFeature(collisionBoxArray, line, anchor, 0, 0, 0, shapedText, 1, 0, true); + const cf = new CollisionFeature(collisionBoxArray, line, anchor, 0, 0, 0, shapedText, 1, 0, true, 1); t.equal(cf.boxEndIndex - cf.boxStartIndex, 0); t.end(); }); @@ -154,7 +154,7 @@ test('CollisionFeature', (t) => { const line = [new Point(0, 0), new Point(500, 100), new Point(510, 90), new Point(700, 0)]; const anchor = new Anchor(505, 95, 0, 1); - const cf = new CollisionFeature(collisionBoxArray, line, anchor, 0, 0, 0, shapedText, 1, 0, true); + const cf = new CollisionFeature(collisionBoxArray, line, anchor, 0, 0, 0, shapedText, 1, 0, true, 1); t.ok(cf.boxEndIndex - cf.boxStartIndex < 45); t.end(); }); @@ -163,7 +163,7 @@ test('CollisionFeature', (t) => { const line = [new Point(3103, 4068), new Point(3225.6206896551726, 4096)]; const anchor = new Anchor(3144.5959947505007, 4077.498298013894, 0.22449735614507618, 0); const shaping = { right: 256, left: 0, bottom: 256, top: 0 }; - const cf = new CollisionFeature(collisionBoxArray, line, anchor, 0, 0, 0, shaping, 1, 0, true); + const cf = new CollisionFeature(collisionBoxArray, line, anchor, 0, 0, 0, shaping, 1, 0, true, 1); t.equal(cf.boxEndIndex - cf.boxStartIndex, 1); t.end(); }); @@ -178,10 +178,10 @@ test('CollisionFeature', (t) => { return result; } - function pluckMaxScales(cf) { + function pluckDistancesToAnchor(cf) { const result = []; for (let i = cf.boxStartIndex; i < cf.boxEndIndex; i++) { - result.push(collisionBoxArray.get(i).maxScale); + result.push(collisionBoxArray.get(i).signedDistanceFromAnchor); } return result; } diff --git a/test/unit/symbol/grid_index.test.js b/test/unit/symbol/grid_index.test.js new file mode 100644 index 00000000000..6e7cb002b9f --- /dev/null +++ b/test/unit/symbol/grid_index.test.js @@ -0,0 +1,61 @@ +'use strict'; + +const test = require('mapbox-gl-js-test').test; + +const GridIndex = require('../../../src/symbol/grid_index'); + +test('GridIndex', (t) => { + + t.test('indexes features', (t) => { + const grid = new GridIndex(100, 100, 10); + grid.insert(0, 4, 10, 6, 30); + grid.insert(1, 4, 10, 30, 12); + grid.insert(2, -10, 30, 5, 35); + + t.deepEqual(grid.query(4, 10, 5, 11).sort(), [0, 1]); + t.deepEqual(grid.query(24, 10, 25, 11).sort(), [1]); + t.deepEqual(grid.query(40, 40, 100, 100), []); + t.deepEqual(grid.query(-6, 0, 3, 100), [2]); + t.deepEqual(grid.query(-Infinity, -Infinity, Infinity, Infinity).sort(), [0, 1, 2]); + t.end(); + }); + + t.test('returns multiple copies of a key if multiple boxes were inserted with the same key', (t) => { + const grid = new GridIndex(100, 100, 10); + const key = 123; + grid.insert(key, 3, 3, 4, 4); + grid.insert(key, 13, 13, 14, 14); + grid.insert(key, 23, 23, 24, 24); + t.deepEqual(grid.query(0, 0, 30, 30), [key, key, key]); + t.end(); + }); + + t.test('circle-circle intersection', (t) => { + const grid = new GridIndex(100, 100, 10); + grid.insertCircle(0, 50, 50, 10); + grid.insertCircle(1, 60, 60, 15); + grid.insertCircle(2, -10, 110, 20); + + t.ok(grid.hitTestCircle(55, 55, 2)); + t.notOk(grid.hitTestCircle(10, 10, 10)); + t.ok(grid.hitTestCircle(0, 100, 10)); + t.ok(grid.hitTestCircle(80, 60, 10)); + + t.end(); + }); + + t.test('circle-rectangle intersection', (t) => { + const grid = new GridIndex(100, 100, 10); + grid.insertCircle(0, 50, 50, 10); + grid.insertCircle(1, 60, 60, 15); + grid.insertCircle(2, -10, 110, 20); + + t.deepEqual(grid.query(45, 45, 55, 55), [0, 1]); + t.deepEqual(grid.query(0, 0, 30, 30), []); + t.deepEqual(grid.query(0, 80, 20, 100), [2]); + + t.end(); + }); + + t.end(); +});