import CollisionIndex from './collision_index'; import type {FeatureKey} from './collision_index'; import EXTENT from '../data/extent'; import * as symbolSize from './symbol_size'; import * as projection from './projection'; import {getAnchorJustification, evaluateVariableOffset} from './symbol_layout'; import {getAnchorAlignment, WritingMode} from './shaping'; import {mat4} from 'gl-matrix'; import assert from 'assert'; import pixelsToTileUnits from '../source/pixels_to_tile_units'; import Point from '@mapbox/point-geometry'; import type Transform from '../geo/transform'; import type StyleLayer from '../style/style_layer'; import {PossiblyEvaluated} from '../style/properties'; import type {SymbolLayoutProps, SymbolLayoutPropsPossiblyEvaluated} from '../style/style_layer/symbol_style_layer_properties.g'; import {getOverlapMode, OverlapMode} from '../style/style_layer/symbol_style_layer'; import type Tile from '../source/tile'; import SymbolBucket, {CollisionArrays, SingleCollisionBox} from '../data/bucket/symbol_bucket'; import type {CollisionBoxArray, CollisionVertexArray, SymbolInstance} from '../data/array_types.g'; import type FeatureIndex from '../data/feature_index'; import type {OverscaledTileID} from '../source/tile_id'; import type {TextAnchor} from './symbol_layout'; class OpacityState { opacity: number; placed: boolean; constructor(prevState: OpacityState, increment: number, placed: boolean, skipFade?: boolean | null) { if (prevState) { this.opacity = Math.max(0, Math.min(1, prevState.opacity + (prevState.placed ? increment : -increment))); } else { this.opacity = (skipFade && placed) ? 1 : 0; } this.placed = placed; } isHidden() { return this.opacity === 0 && !this.placed; } } class JointOpacityState { text: OpacityState; icon: OpacityState; constructor(prevState: JointOpacityState, increment: number, placedText: boolean, placedIcon: boolean, skipFade?: boolean | null) { this.text = new OpacityState(prevState ? prevState.text : null, increment, placedText, skipFade); this.icon = new OpacityState(prevState ? prevState.icon : null, increment, placedIcon, skipFade); } isHidden() { return this.text.isHidden() && this.icon.isHidden(); } } class JointPlacement { text: boolean; icon: boolean; // skipFade = outside viewport, but within CollisionIndex::viewportPadding px of the edge // Because these symbols aren't onscreen yet, we can skip the "fade in" animation, // and if a subsequent viewport change brings them into view, they'll be fully // visible right away. skipFade: boolean; constructor(text: boolean, icon: boolean, skipFade: boolean) { this.text = text; this.icon = icon; this.skipFade = skipFade; } } class CollisionCircleArray { // Stores collision circles and placement matrices of a bucket for debug rendering. invProjMatrix: mat4; viewportMatrix: mat4; circles: Array; constructor() { this.invProjMatrix = mat4.create(); this.viewportMatrix = mat4.create(); this.circles = []; } } export class RetainedQueryData { bucketInstanceId: number; featureIndex: FeatureIndex; sourceLayerIndex: number; bucketIndex: number; tileID: OverscaledTileID; featureSortOrder: Array; constructor(bucketInstanceId: number, featureIndex: FeatureIndex, sourceLayerIndex: number, bucketIndex: number, tileID: OverscaledTileID) { this.bucketInstanceId = bucketInstanceId; this.featureIndex = featureIndex; this.sourceLayerIndex = sourceLayerIndex; this.bucketIndex = bucketIndex; this.tileID = tileID; } } type CollisionGroup = { ID: number; predicate?: (key: FeatureKey) => boolean; }; class CollisionGroups { collisionGroups: {[groupName: string]: CollisionGroup}; maxGroupID: number; crossSourceCollisions: boolean; constructor(crossSourceCollisions: boolean) { this.crossSourceCollisions = crossSourceCollisions; this.maxGroupID = 0; this.collisionGroups = {}; } get(sourceID: string) { // The predicate/groupID mechanism allows for arbitrary grouping, // but the current interface defines one source == one group when // crossSourceCollisions == true. if (!this.crossSourceCollisions) { if (!this.collisionGroups[sourceID]) { const nextGroupID = ++this.maxGroupID; this.collisionGroups[sourceID] = { ID: nextGroupID, predicate: (key) => { return key.collisionGroupID === nextGroupID; } }; } return this.collisionGroups[sourceID]; } else { return {ID: 0, predicate: null}; } } } function calculateVariableLayoutShift( anchor: TextAnchor, width: number, height: number, textOffset: [number, number], textBoxScale: number ): Point { const {horizontalAlign, verticalAlign} = getAnchorAlignment(anchor); const shiftX = -(horizontalAlign - 0.5) * width; const shiftY = -(verticalAlign - 0.5) * height; const offset = evaluateVariableOffset(anchor, textOffset); return new Point( shiftX + offset[0] * textBoxScale, shiftY + offset[1] * textBoxScale ); } function shiftVariableCollisionBox(collisionBox: SingleCollisionBox, shiftX: number, shiftY: number, rotateWithMap: boolean, pitchWithMap: boolean, angle: number) { const {x1, x2, y1, y2, anchorPointX, anchorPointY} = collisionBox; const rotatedOffset = new Point(shiftX, shiftY); if (rotateWithMap) { rotatedOffset._rotate(pitchWithMap ? angle : -angle); } return { x1: x1 + rotatedOffset.x, y1: y1 + rotatedOffset.y, x2: x2 + rotatedOffset.x, y2: y2 + rotatedOffset.y, // symbol anchor point stays the same regardless of text-anchor anchorPointX, anchorPointY }; } export type VariableOffset = { textOffset: [number, number]; width: number; height: number; anchor: TextAnchor; textBoxScale: number; prevAnchor?: TextAnchor; }; type TileLayerParameters = { bucket: SymbolBucket; layout: PossiblyEvaluated; posMatrix: mat4; textLabelPlaneMatrix: mat4; labelToScreenMatrix: mat4; scale: number; textPixelRatio: number; holdingForFade: boolean; collisionBoxArray: CollisionBoxArray; partiallyEvaluatedTextSize: { uSize: number; uSizeT: number; }; collisionGroup: CollisionGroup; }; export type BucketPart = { sortKey?: number | void; symbolInstanceStart: number; symbolInstanceEnd: number; parameters: TileLayerParameters; }; export type CrossTileID = string | number; export class Placement { transform: Transform; collisionIndex: CollisionIndex; placements: { [_ in CrossTileID]: JointPlacement; }; opacities: { [_ in CrossTileID]: JointOpacityState; }; variableOffsets: { [_ in CrossTileID]: VariableOffset; }; placedOrientations: { [_ in CrossTileID]: number; }; commitTime: number; prevZoomAdjustment: number; lastPlacementChangeTime: number; stale: boolean; fadeDuration: number; retainedQueryData: { [_: number]: RetainedQueryData; }; collisionGroups: CollisionGroups; prevPlacement: Placement; zoomAtLastRecencyCheck: number; collisionCircleArrays: { [k in any]: CollisionCircleArray; }; constructor(transform: Transform, fadeDuration: number, crossSourceCollisions: boolean, prevPlacement?: Placement) { this.transform = transform.clone(); this.collisionIndex = new CollisionIndex(this.transform); this.placements = {}; this.opacities = {}; this.variableOffsets = {}; this.stale = false; this.commitTime = 0; this.fadeDuration = fadeDuration; this.retainedQueryData = {}; this.collisionGroups = new CollisionGroups(crossSourceCollisions); this.collisionCircleArrays = {}; this.prevPlacement = prevPlacement; if (prevPlacement) { prevPlacement.prevPlacement = undefined; // Only hold on to one placement back } this.placedOrientations = {}; } getBucketParts(results: Array, styleLayer: StyleLayer, tile: Tile, sortAcrossTiles: boolean) { const symbolBucket = (tile.getBucket(styleLayer) as SymbolBucket); const bucketFeatureIndex = tile.latestFeatureIndex; if (!symbolBucket || !bucketFeatureIndex || styleLayer.id !== symbolBucket.layerIds[0]) return; const collisionBoxArray = tile.collisionBoxArray; const layout = symbolBucket.layers[0].layout; const scale = Math.pow(2, this.transform.zoom - tile.tileID.overscaledZ); const textPixelRatio = tile.tileSize / EXTENT; const posMatrix = this.transform.calculatePosMatrix(tile.tileID.toUnwrapped()); const pitchWithMap = layout.get('text-pitch-alignment') === 'map'; const rotateWithMap = layout.get('text-rotation-alignment') === 'map'; const pixelsToTiles = pixelsToTileUnits(tile, 1, this.transform.zoom); const textLabelPlaneMatrix = projection.getLabelPlaneMatrix(posMatrix, pitchWithMap, rotateWithMap, this.transform, pixelsToTiles); let labelToScreenMatrix = null; if (pitchWithMap) { const glMatrix = projection.getGlCoordMatrix( posMatrix, pitchWithMap, rotateWithMap, this.transform, pixelsToTiles); labelToScreenMatrix = mat4.multiply([] as any, this.transform.labelPlaneMatrix, glMatrix); } // As long as this placement lives, we have to hold onto this bucket's // matching FeatureIndex/data for querying purposes this.retainedQueryData[symbolBucket.bucketInstanceId] = new RetainedQueryData( symbolBucket.bucketInstanceId, bucketFeatureIndex, symbolBucket.sourceLayerIndex, symbolBucket.index, tile.tileID ); const parameters = { bucket: symbolBucket, layout, posMatrix, textLabelPlaneMatrix, labelToScreenMatrix, scale, textPixelRatio, holdingForFade: tile.holdingForFade(), collisionBoxArray, partiallyEvaluatedTextSize: symbolSize.evaluateSizeForZoom(symbolBucket.textSizeData, this.transform.zoom), collisionGroup: this.collisionGroups.get(symbolBucket.sourceID) }; if (sortAcrossTiles) { for (const range of symbolBucket.sortKeyRanges) { const {sortKey, symbolInstanceStart, symbolInstanceEnd} = range; results.push({sortKey, symbolInstanceStart, symbolInstanceEnd, parameters}); } } else { results.push({ symbolInstanceStart: 0, symbolInstanceEnd: symbolBucket.symbolInstances.length, parameters }); } } attemptAnchorPlacement( anchor: TextAnchor, textBox: SingleCollisionBox, width: number, height: number, textBoxScale: number, rotateWithMap: boolean, pitchWithMap: boolean, textPixelRatio: number, posMatrix: mat4, collisionGroup: CollisionGroup, textOverlapMode: OverlapMode, symbolInstance: SymbolInstance, bucket: SymbolBucket, orientation: number, iconBox?: SingleCollisionBox | null ): { shift: Point; placedGlyphBoxes: { box: Array; offscreen: boolean; }; } { const textOffset = [symbolInstance.textOffset0, symbolInstance.textOffset1] as [number, number]; const shift = calculateVariableLayoutShift(anchor, width, height, textOffset, textBoxScale); const placedGlyphBoxes = this.collisionIndex.placeCollisionBox( shiftVariableCollisionBox( textBox, shift.x, shift.y, rotateWithMap, pitchWithMap, this.transform.angle), textOverlapMode, textPixelRatio, posMatrix, collisionGroup.predicate); if (iconBox) { const placedIconBoxes = this.collisionIndex.placeCollisionBox( shiftVariableCollisionBox( iconBox, shift.x, shift.y, rotateWithMap, pitchWithMap, this.transform.angle), textOverlapMode, textPixelRatio, posMatrix, collisionGroup.predicate); if (placedIconBoxes.box.length === 0) return; } if (placedGlyphBoxes.box.length > 0) { let prevAnchor; // If this label was placed in the previous placement, record the anchor position // to allow us to animate the transition if (this.prevPlacement && this.prevPlacement.variableOffsets[symbolInstance.crossTileID] && this.prevPlacement.placements[symbolInstance.crossTileID] && this.prevPlacement.placements[symbolInstance.crossTileID].text) { prevAnchor = this.prevPlacement.variableOffsets[symbolInstance.crossTileID].anchor; } assert(symbolInstance.crossTileID !== 0); this.variableOffsets[symbolInstance.crossTileID] = { textOffset, width, height, anchor, textBoxScale, prevAnchor }; this.markUsedJustification(bucket, anchor, symbolInstance, orientation); if (bucket.allowVerticalPlacement) { this.markUsedOrientation(bucket, orientation, symbolInstance); this.placedOrientations[symbolInstance.crossTileID] = orientation; } return {shift, placedGlyphBoxes}; } } placeLayerBucketPart(bucketPart: BucketPart, seenCrossTileIDs: { [k in string | number]: boolean; }, showCollisionBoxes: boolean) { const { bucket, layout, posMatrix, textLabelPlaneMatrix, labelToScreenMatrix, textPixelRatio, holdingForFade, collisionBoxArray, partiallyEvaluatedTextSize, collisionGroup } = bucketPart.parameters; const textOptional = layout.get('text-optional'); const iconOptional = layout.get('icon-optional'); const textOverlapMode = getOverlapMode(layout, 'text-overlap', 'text-allow-overlap'); const textAlwaysOverlap = textOverlapMode === 'always'; const iconOverlapMode = getOverlapMode(layout, 'icon-overlap', 'icon-allow-overlap'); const iconAlwaysOverlap = iconOverlapMode === 'always'; const rotateWithMap = layout.get('text-rotation-alignment') === 'map'; const pitchWithMap = layout.get('text-pitch-alignment') === 'map'; const hasIconTextFit = layout.get('icon-text-fit') !== 'none'; const zOrderByViewportY = layout.get('symbol-z-order') === 'viewport-y'; // This logic is similar to the "defaultOpacityState" logic below in updateBucketOpacities // If we know a symbol is always supposed to show, force it to be marked visible even if // it wasn't placed into the collision index (because some or all of it was outside the range // of the collision grid). // There is a subtle edge case here we're accepting: // Symbol A has text-allow-overlap: true, icon-allow-overlap: true, icon-optional: false // A's icon is outside the grid, so doesn't get placed // A's text would be inside grid, but doesn't get placed because of icon-optional: false // We still show A because of the allow-overlap settings. // Symbol B has allow-overlap: false, and gets placed where A's text would be // On panning in, there is a short period when Symbol B and Symbol A will overlap // This is the reverse of our normal policy of "fade in on pan", but should look like any other // collision and hopefully not be too noticeable. // See https://github.com/mapbox/mapbox-gl-js/issues/7172 const alwaysShowText = textAlwaysOverlap && (iconAlwaysOverlap || !bucket.hasIconData() || iconOptional); const alwaysShowIcon = iconAlwaysOverlap && (textAlwaysOverlap || !bucket.hasTextData() || textOptional); if (!bucket.collisionArrays && collisionBoxArray) { bucket.deserializeCollisionBoxes(collisionBoxArray); } const placeSymbol = (symbolInstance: SymbolInstance, collisionArrays: CollisionArrays) => { if (seenCrossTileIDs[symbolInstance.crossTileID]) return; if (holdingForFade) { // Mark all symbols from this tile as "not placed", but don't add to seenCrossTileIDs, because we don't // know yet if we have a duplicate in a parent tile that _should_ be placed. this.placements[symbolInstance.crossTileID] = new JointPlacement(false, false, false); return; } let placeText = false; let placeIcon = false; let offscreen = true; let shift = null; let placed = {box: null, offscreen: null}; let placedVerticalText = {box: null, offscreen: null}; let placedGlyphBoxes = null; let placedGlyphCircles = null; let placedIconBoxes = null; let textFeatureIndex = 0; let verticalTextFeatureIndex = 0; let iconFeatureIndex = 0; if (collisionArrays.textFeatureIndex) { textFeatureIndex = collisionArrays.textFeatureIndex; } else if (symbolInstance.useRuntimeCollisionCircles) { textFeatureIndex = symbolInstance.featureIndex; } if (collisionArrays.verticalTextFeatureIndex) { verticalTextFeatureIndex = collisionArrays.verticalTextFeatureIndex; } const textBox = collisionArrays.textBox; if (textBox) { const updatePreviousOrientationIfNotPlaced = (isPlaced) => { let previousOrientation = WritingMode.horizontal; if (bucket.allowVerticalPlacement && !isPlaced && this.prevPlacement) { const prevPlacedOrientation = this.prevPlacement.placedOrientations[symbolInstance.crossTileID]; if (prevPlacedOrientation) { this.placedOrientations[symbolInstance.crossTileID] = prevPlacedOrientation; previousOrientation = prevPlacedOrientation; this.markUsedOrientation(bucket, previousOrientation, symbolInstance); } } return previousOrientation; }; const placeTextForPlacementModes = (placeHorizontalFn, placeVerticalFn) => { if (bucket.allowVerticalPlacement && symbolInstance.numVerticalGlyphVertices > 0 && collisionArrays.verticalTextBox) { for (const placementMode of bucket.writingModes) { if (placementMode === WritingMode.vertical) { placed = placeVerticalFn(); placedVerticalText = placed; } else { placed = placeHorizontalFn(); } if (placed && placed.box && placed.box.length) break; } } else { placed = placeHorizontalFn(); } }; if (!layout.get('text-variable-anchor')) { const placeBox = (collisionTextBox, orientation) => { const placedFeature = this.collisionIndex.placeCollisionBox( collisionTextBox, textOverlapMode, textPixelRatio, posMatrix, collisionGroup.predicate); if (placedFeature && placedFeature.box && placedFeature.box.length) { this.markUsedOrientation(bucket, orientation, symbolInstance); this.placedOrientations[symbolInstance.crossTileID] = orientation; } return placedFeature; }; const placeHorizontal = () => { return placeBox(textBox, WritingMode.horizontal); }; const placeVertical = () => { const verticalTextBox = collisionArrays.verticalTextBox; if (bucket.allowVerticalPlacement && symbolInstance.numVerticalGlyphVertices > 0 && verticalTextBox) { return placeBox(verticalTextBox, WritingMode.vertical); } return {box: null, offscreen: null}; }; placeTextForPlacementModes(placeHorizontal, placeVertical); updatePreviousOrientationIfNotPlaced(placed && placed.box && placed.box.length); } else { let anchors = layout.get('text-variable-anchor'); // If this symbol was in the last placement, shift the previously used // anchor to the front of the anchor list, only if the previous anchor // is still in the anchor list if (this.prevPlacement && this.prevPlacement.variableOffsets[symbolInstance.crossTileID]) { const prevOffsets = this.prevPlacement.variableOffsets[symbolInstance.crossTileID]; if (anchors.indexOf(prevOffsets.anchor) > 0) { anchors = anchors.filter(anchor => anchor !== prevOffsets.anchor); anchors.unshift(prevOffsets.anchor); } } const placeBoxForVariableAnchors = (collisionTextBox, collisionIconBox, orientation) => { const width = collisionTextBox.x2 - collisionTextBox.x1; const height = collisionTextBox.y2 - collisionTextBox.y1; const textBoxScale = symbolInstance.textBoxScale; const variableIconBox = hasIconTextFit && (iconOverlapMode === 'never') ? collisionIconBox : null; let placedBox: { box: Array; offscreen: boolean; } = {box: [], offscreen: false}; const placementAttempts = (textOverlapMode !== 'never') ? anchors.length * 2 : anchors.length; for (let i = 0; i < placementAttempts; ++i) { const anchor = anchors[i % anchors.length]; const overlapMode = (i >= anchors.length) ? textOverlapMode : 'never'; const result = this.attemptAnchorPlacement( anchor, collisionTextBox, width, height, textBoxScale, rotateWithMap, pitchWithMap, textPixelRatio, posMatrix, collisionGroup, overlapMode, symbolInstance, bucket, orientation, variableIconBox); if (result) { placedBox = result.placedGlyphBoxes; if (placedBox && placedBox.box && placedBox.box.length) { placeText = true; shift = result.shift; break; } } } return placedBox; }; const placeHorizontal = () => { return placeBoxForVariableAnchors(textBox, collisionArrays.iconBox, WritingMode.horizontal); }; const placeVertical = () => { const verticalTextBox = collisionArrays.verticalTextBox; const wasPlaced = placed && placed.box && placed.box.length; if (bucket.allowVerticalPlacement && !wasPlaced && symbolInstance.numVerticalGlyphVertices > 0 && verticalTextBox) { return placeBoxForVariableAnchors(verticalTextBox, collisionArrays.verticalIconBox, WritingMode.vertical); } return {box: null, offscreen: null}; }; placeTextForPlacementModes(placeHorizontal, placeVertical); if (placed) { placeText = placed.box; offscreen = placed.offscreen; } const prevOrientation = updatePreviousOrientationIfNotPlaced(placed && placed.box); // If we didn't get placed, we still need to copy our position from the last placement for // fade animations if (!placeText && this.prevPlacement) { const prevOffset = this.prevPlacement.variableOffsets[symbolInstance.crossTileID]; if (prevOffset) { this.variableOffsets[symbolInstance.crossTileID] = prevOffset; this.markUsedJustification(bucket, prevOffset.anchor, symbolInstance, prevOrientation); } } } } placedGlyphBoxes = placed; placeText = placedGlyphBoxes && placedGlyphBoxes.box && placedGlyphBoxes.box.length > 0; offscreen = placedGlyphBoxes && placedGlyphBoxes.offscreen; if (symbolInstance.useRuntimeCollisionCircles) { const placedSymbol = bucket.text.placedSymbolArray.get(symbolInstance.centerJustifiedTextSymbolIndex); const fontSize = symbolSize.evaluateSizeForFeature(bucket.textSizeData, partiallyEvaluatedTextSize, placedSymbol); const textPixelPadding = layout.get('text-padding'); const circlePixelDiameter = symbolInstance.collisionCircleDiameter; placedGlyphCircles = this.collisionIndex.placeCollisionCircles( textOverlapMode, placedSymbol, bucket.lineVertexArray, bucket.glyphOffsetArray, fontSize, posMatrix, textLabelPlaneMatrix, labelToScreenMatrix, showCollisionBoxes, pitchWithMap, collisionGroup.predicate, circlePixelDiameter, textPixelPadding); assert(!placedGlyphCircles.circles.length || (!placedGlyphCircles.collisionDetected || showCollisionBoxes)); // If text-overlap is set to 'always', 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. placeText = textAlwaysOverlap || (placedGlyphCircles.circles.length > 0 && !placedGlyphCircles.collisionDetected); offscreen = offscreen && placedGlyphCircles.offscreen; } if (collisionArrays.iconFeatureIndex) { iconFeatureIndex = collisionArrays.iconFeatureIndex; } if (collisionArrays.iconBox) { const placeIconFeature = iconBox => { const shiftedIconBox = hasIconTextFit && shift ? shiftVariableCollisionBox( iconBox, shift.x, shift.y, rotateWithMap, pitchWithMap, this.transform.angle) : iconBox; return this.collisionIndex.placeCollisionBox(shiftedIconBox, iconOverlapMode, textPixelRatio, posMatrix, collisionGroup.predicate); }; if (placedVerticalText && placedVerticalText.box && placedVerticalText.box.length && collisionArrays.verticalIconBox) { placedIconBoxes = placeIconFeature(collisionArrays.verticalIconBox); placeIcon = placedIconBoxes.box.length > 0; } else { placedIconBoxes = placeIconFeature(collisionArrays.iconBox); placeIcon = placedIconBoxes.box.length > 0; } offscreen = offscreen && placedIconBoxes.offscreen; } const iconWithoutText = textOptional || (symbolInstance.numHorizontalGlyphVertices === 0 && symbolInstance.numVerticalGlyphVertices === 0); const textWithoutIcon = iconOptional || symbolInstance.numIconVertices === 0; // Combine the scales for icons and text. if (!iconWithoutText && !textWithoutIcon) { placeIcon = placeText = placeIcon && placeText; } else if (!textWithoutIcon) { placeText = placeIcon && placeText; } else if (!iconWithoutText) { placeIcon = placeIcon && placeText; } if (placeText && placedGlyphBoxes && placedGlyphBoxes.box) { if (placedVerticalText && placedVerticalText.box && verticalTextFeatureIndex) { this.collisionIndex.insertCollisionBox( placedGlyphBoxes.box, textOverlapMode, layout.get('text-ignore-placement'), bucket.bucketInstanceId, verticalTextFeatureIndex, collisionGroup.ID); } else { this.collisionIndex.insertCollisionBox( placedGlyphBoxes.box, textOverlapMode, layout.get('text-ignore-placement'), bucket.bucketInstanceId, textFeatureIndex, collisionGroup.ID); } } if (placeIcon && placedIconBoxes) { this.collisionIndex.insertCollisionBox( placedIconBoxes.box, iconOverlapMode, layout.get('icon-ignore-placement'), bucket.bucketInstanceId, iconFeatureIndex, collisionGroup.ID); } if (placedGlyphCircles) { if (placeText) { this.collisionIndex.insertCollisionCircles( placedGlyphCircles.circles, textOverlapMode, layout.get('text-ignore-placement'), bucket.bucketInstanceId, textFeatureIndex, collisionGroup.ID); } if (showCollisionBoxes) { const id = bucket.bucketInstanceId; let circleArray = this.collisionCircleArrays[id]; // Group collision circles together by bucket. Circles can't be pushed forward for rendering yet as the symbol placement // for a bucket is not guaranteed to be complete before the commit-function has been called if (circleArray === undefined) circleArray = this.collisionCircleArrays[id] = new CollisionCircleArray(); for (let i = 0; i < placedGlyphCircles.circles.length; i += 4) { circleArray.circles.push(placedGlyphCircles.circles[i + 0]); // x circleArray.circles.push(placedGlyphCircles.circles[i + 1]); // y circleArray.circles.push(placedGlyphCircles.circles[i + 2]); // radius circleArray.circles.push(placedGlyphCircles.collisionDetected ? 1 : 0); // collisionDetected-flag } } } assert(symbolInstance.crossTileID !== 0); assert(bucket.bucketInstanceId !== 0); this.placements[symbolInstance.crossTileID] = new JointPlacement(placeText || alwaysShowText, placeIcon || alwaysShowIcon, offscreen || bucket.justReloaded); seenCrossTileIDs[symbolInstance.crossTileID] = true; }; if (zOrderByViewportY) { assert(bucketPart.symbolInstanceStart === 0); const symbolIndexes = bucket.getSortedSymbolIndexes(this.transform.angle); for (let i = symbolIndexes.length - 1; i >= 0; --i) { const symbolIndex = symbolIndexes[i]; placeSymbol(bucket.symbolInstances.get(symbolIndex), bucket.collisionArrays[symbolIndex]); } } else { for (let i = bucketPart.symbolInstanceStart; i < bucketPart.symbolInstanceEnd; i++) { placeSymbol(bucket.symbolInstances.get(i), bucket.collisionArrays[i]); } } if (showCollisionBoxes && bucket.bucketInstanceId in this.collisionCircleArrays) { const circleArray = this.collisionCircleArrays[bucket.bucketInstanceId]; // Store viewport and inverse projection matrices per bucket mat4.invert(circleArray.invProjMatrix, posMatrix); circleArray.viewportMatrix = this.collisionIndex.getViewportMatrix(); } bucket.justReloaded = false; } markUsedJustification(bucket: SymbolBucket, placedAnchor: TextAnchor, symbolInstance: SymbolInstance, orientation: number) { const justifications = { 'left': symbolInstance.leftJustifiedTextSymbolIndex, 'center': symbolInstance.centerJustifiedTextSymbolIndex, 'right': symbolInstance.rightJustifiedTextSymbolIndex }; let autoIndex; if (orientation === WritingMode.vertical) { autoIndex = symbolInstance.verticalPlacedTextSymbolIndex; } else { autoIndex = justifications[getAnchorJustification(placedAnchor)]; } const indexes = [ symbolInstance.leftJustifiedTextSymbolIndex, symbolInstance.centerJustifiedTextSymbolIndex, symbolInstance.rightJustifiedTextSymbolIndex, symbolInstance.verticalPlacedTextSymbolIndex ]; for (const index of indexes) { if (index >= 0) { if (autoIndex >= 0 && index !== autoIndex) { // There are multiple justifications and this one isn't it: shift offscreen bucket.text.placedSymbolArray.get(index).crossTileID = 0; } else { // Either this is the chosen justification or the justification is hardwired: use this one bucket.text.placedSymbolArray.get(index).crossTileID = symbolInstance.crossTileID; } } } } markUsedOrientation(bucket: SymbolBucket, orientation: number, symbolInstance: SymbolInstance) { const horizontal = (orientation === WritingMode.horizontal || orientation === WritingMode.horizontalOnly) ? orientation : 0; const vertical = orientation === WritingMode.vertical ? orientation : 0; const horizontalIndexes = [ symbolInstance.leftJustifiedTextSymbolIndex, symbolInstance.centerJustifiedTextSymbolIndex, symbolInstance.rightJustifiedTextSymbolIndex ]; for (const index of horizontalIndexes) { bucket.text.placedSymbolArray.get(index).placedOrientation = horizontal; } if (symbolInstance.verticalPlacedTextSymbolIndex) { bucket.text.placedSymbolArray.get(symbolInstance.verticalPlacedTextSymbolIndex).placedOrientation = vertical; } } commit(now: number): void { this.commitTime = now; this.zoomAtLastRecencyCheck = this.transform.zoom; const prevPlacement = this.prevPlacement; let placementChanged = false; this.prevZoomAdjustment = prevPlacement ? prevPlacement.zoomAdjustment(this.transform.zoom) : 0; const increment = prevPlacement ? prevPlacement.symbolFadeChange(now) : 1; const prevOpacities = prevPlacement ? prevPlacement.opacities : {}; const prevOffsets = prevPlacement ? prevPlacement.variableOffsets : {}; const prevOrientations = prevPlacement ? prevPlacement.placedOrientations : {}; // add the opacities from the current placement, and copy their current values from the previous placement for (const crossTileID in this.placements) { const jointPlacement = this.placements[crossTileID]; const prevOpacity = prevOpacities[crossTileID]; if (prevOpacity) { this.opacities[crossTileID] = new JointOpacityState(prevOpacity, increment, jointPlacement.text, jointPlacement.icon); placementChanged = placementChanged || jointPlacement.text !== prevOpacity.text.placed || jointPlacement.icon !== prevOpacity.icon.placed; } else { this.opacities[crossTileID] = new JointOpacityState(null, increment, jointPlacement.text, jointPlacement.icon, jointPlacement.skipFade); placementChanged = placementChanged || jointPlacement.text || jointPlacement.icon; } } // copy and update values from the previous placement that aren't in the current placement but haven't finished fading for (const crossTileID in prevOpacities) { const prevOpacity = prevOpacities[crossTileID]; if (!this.opacities[crossTileID]) { const jointOpacity = new JointOpacityState(prevOpacity, increment, false, false); if (!jointOpacity.isHidden()) { this.opacities[crossTileID] = jointOpacity; placementChanged = placementChanged || prevOpacity.text.placed || prevOpacity.icon.placed; } } } for (const crossTileID in prevOffsets) { if (!this.variableOffsets[crossTileID] && this.opacities[crossTileID] && !this.opacities[crossTileID].isHidden()) { this.variableOffsets[crossTileID] = prevOffsets[crossTileID]; } } for (const crossTileID in prevOrientations) { if (!this.placedOrientations[crossTileID] && this.opacities[crossTileID] && !this.opacities[crossTileID].isHidden()) { this.placedOrientations[crossTileID] = prevOrientations[crossTileID]; } } // this.lastPlacementChangeTime is the time of the last commit() that // resulted in a placement change -- in other words, the start time of // the last symbol fade animation assert(!prevPlacement || prevPlacement.lastPlacementChangeTime !== undefined); if (placementChanged) { this.lastPlacementChangeTime = now; } else if (typeof this.lastPlacementChangeTime !== 'number') { this.lastPlacementChangeTime = prevPlacement ? prevPlacement.lastPlacementChangeTime : now; } } updateLayerOpacities(styleLayer: StyleLayer, tiles: Array) { const seenCrossTileIDs = {}; for (const tile of tiles) { const symbolBucket = tile.getBucket(styleLayer) as SymbolBucket; if (symbolBucket && tile.latestFeatureIndex && styleLayer.id === symbolBucket.layerIds[0]) { this.updateBucketOpacities(symbolBucket, seenCrossTileIDs, tile.collisionBoxArray); } } } updateBucketOpacities(bucket: SymbolBucket, seenCrossTileIDs: { [k in string | number]: boolean; }, collisionBoxArray?: CollisionBoxArray | null) { if (bucket.hasTextData()) bucket.text.opacityVertexArray.clear(); if (bucket.hasIconData()) bucket.icon.opacityVertexArray.clear(); if (bucket.hasIconCollisionBoxData()) bucket.iconCollisionBox.collisionVertexArray.clear(); if (bucket.hasTextCollisionBoxData()) bucket.textCollisionBox.collisionVertexArray.clear(); const layout = bucket.layers[0].layout; const duplicateOpacityState = new JointOpacityState(null, 0, false, false, true); const textAllowOverlap = layout.get('text-allow-overlap'); const iconAllowOverlap = layout.get('icon-allow-overlap'); const variablePlacement = layout.get('text-variable-anchor'); const rotateWithMap = layout.get('text-rotation-alignment') === 'map'; const pitchWithMap = layout.get('text-pitch-alignment') === 'map'; const hasIconTextFit = layout.get('icon-text-fit') !== 'none'; // If allow-overlap is true, we can show symbols before placement runs on them // But we have to wait for placement if we potentially depend on a paired icon/text // with allow-overlap: false. // See https://github.com/mapbox/mapbox-gl-js/issues/7032 const defaultOpacityState = new JointOpacityState(null, 0, textAllowOverlap && (iconAllowOverlap || !bucket.hasIconData() || layout.get('icon-optional')), iconAllowOverlap && (textAllowOverlap || !bucket.hasTextData() || layout.get('text-optional')), true); if (!bucket.collisionArrays && collisionBoxArray && ((bucket.hasIconCollisionBoxData() || bucket.hasTextCollisionBoxData()))) { bucket.deserializeCollisionBoxes(collisionBoxArray); } const addOpacities = (iconOrText, numVertices: number, opacity: number) => { for (let i = 0; i < numVertices / 4; i++) { iconOrText.opacityVertexArray.emplaceBack(opacity); } }; for (let s = 0; s < bucket.symbolInstances.length; s++) { const symbolInstance = bucket.symbolInstances.get(s); const { numHorizontalGlyphVertices, numVerticalGlyphVertices, crossTileID } = symbolInstance; const isDuplicate = seenCrossTileIDs[crossTileID]; let opacityState = this.opacities[crossTileID]; if (isDuplicate) { opacityState = duplicateOpacityState; } else if (!opacityState) { opacityState = defaultOpacityState; // store the state so that future placements use it as a starting point this.opacities[crossTileID] = opacityState; } seenCrossTileIDs[crossTileID] = true; const hasText = numHorizontalGlyphVertices > 0 || numVerticalGlyphVertices > 0; const hasIcon = symbolInstance.numIconVertices > 0; const placedOrientation = this.placedOrientations[symbolInstance.crossTileID]; const horizontalHidden = placedOrientation === WritingMode.vertical; const verticalHidden = placedOrientation === WritingMode.horizontal || placedOrientation === WritingMode.horizontalOnly; if (hasText) { const packedOpacity = packOpacity(opacityState.text); // Vertical text fades in/out on collision the same way as corresponding // horizontal text. Switch between vertical/horizontal should be instantaneous const horizontalOpacity = horizontalHidden ? PACKED_HIDDEN_OPACITY : packedOpacity; addOpacities(bucket.text, numHorizontalGlyphVertices, horizontalOpacity); const verticalOpacity = verticalHidden ? PACKED_HIDDEN_OPACITY : packedOpacity; addOpacities(bucket.text, numVerticalGlyphVertices, verticalOpacity); // If this label is completely faded, mark it so that we don't have to calculate // its position at render time. If this layer has variable placement, shift the various // symbol instances appropriately so that symbols from buckets that have yet to be placed // offset appropriately. const symbolHidden = opacityState.text.isHidden(); [ symbolInstance.rightJustifiedTextSymbolIndex, symbolInstance.centerJustifiedTextSymbolIndex, symbolInstance.leftJustifiedTextSymbolIndex ].forEach(index => { if (index >= 0) { bucket.text.placedSymbolArray.get(index).hidden = symbolHidden || horizontalHidden ? 1 : 0; } }); if (symbolInstance.verticalPlacedTextSymbolIndex >= 0) { bucket.text.placedSymbolArray.get(symbolInstance.verticalPlacedTextSymbolIndex).hidden = symbolHidden || verticalHidden ? 1 : 0; } const prevOffset = this.variableOffsets[symbolInstance.crossTileID]; if (prevOffset) { this.markUsedJustification(bucket, prevOffset.anchor, symbolInstance, placedOrientation); } const prevOrientation = this.placedOrientations[symbolInstance.crossTileID]; if (prevOrientation) { this.markUsedJustification(bucket, 'left', symbolInstance, prevOrientation); this.markUsedOrientation(bucket, prevOrientation, symbolInstance); } } if (hasIcon) { const packedOpacity = packOpacity(opacityState.icon); const useHorizontal = !(hasIconTextFit && symbolInstance.verticalPlacedIconSymbolIndex && horizontalHidden); if (symbolInstance.placedIconSymbolIndex >= 0) { const horizontalOpacity = useHorizontal ? packedOpacity : PACKED_HIDDEN_OPACITY; addOpacities(bucket.icon, symbolInstance.numIconVertices, horizontalOpacity); bucket.icon.placedSymbolArray.get(symbolInstance.placedIconSymbolIndex).hidden = (opacityState.icon.isHidden() as any); } if (symbolInstance.verticalPlacedIconSymbolIndex >= 0) { const verticalOpacity = !useHorizontal ? packedOpacity : PACKED_HIDDEN_OPACITY; addOpacities(bucket.icon, symbolInstance.numVerticalIconVertices, verticalOpacity); bucket.icon.placedSymbolArray.get(symbolInstance.verticalPlacedIconSymbolIndex).hidden = (opacityState.icon.isHidden() as any); } } if (bucket.hasIconCollisionBoxData() || bucket.hasTextCollisionBoxData()) { const collisionArrays = bucket.collisionArrays[s]; if (collisionArrays) { let shift = new Point(0, 0); if (collisionArrays.textBox || collisionArrays.verticalTextBox) { let used = true; if (variablePlacement) { const variableOffset = this.variableOffsets[crossTileID]; if (variableOffset) { // This will show either the currently placed position or the last // successfully placed position (so you can visualize what collision // just made the symbol disappear, and the most likely place for the // symbol to come back) shift = calculateVariableLayoutShift(variableOffset.anchor, variableOffset.width, variableOffset.height, variableOffset.textOffset, variableOffset.textBoxScale); if (rotateWithMap) { shift._rotate(pitchWithMap ? this.transform.angle : -this.transform.angle); } } else { // No offset -> this symbol hasn't been placed since coming on-screen // No single box is particularly meaningful and all of them would be too noisy // Use the center box just to show something's there, but mark it "not used" used = false; } } if (collisionArrays.textBox) { updateCollisionVertices(bucket.textCollisionBox.collisionVertexArray, opacityState.text.placed, !used || horizontalHidden, shift.x, shift.y); } if (collisionArrays.verticalTextBox) { updateCollisionVertices(bucket.textCollisionBox.collisionVertexArray, opacityState.text.placed, !used || verticalHidden, shift.x, shift.y); } } const verticalIconUsed = Boolean(!verticalHidden && collisionArrays.verticalIconBox); if (collisionArrays.iconBox) { updateCollisionVertices(bucket.iconCollisionBox.collisionVertexArray, opacityState.icon.placed, verticalIconUsed, hasIconTextFit ? shift.x : 0, hasIconTextFit ? shift.y : 0); } if (collisionArrays.verticalIconBox) { updateCollisionVertices(bucket.iconCollisionBox.collisionVertexArray, opacityState.icon.placed, !verticalIconUsed, hasIconTextFit ? shift.x : 0, hasIconTextFit ? shift.y : 0); } } } } bucket.sortFeatures(this.transform.angle); if (this.retainedQueryData[bucket.bucketInstanceId]) { this.retainedQueryData[bucket.bucketInstanceId].featureSortOrder = bucket.featureSortOrder; } if (bucket.hasTextData() && bucket.text.opacityVertexBuffer) { bucket.text.opacityVertexBuffer.updateData(bucket.text.opacityVertexArray); } if (bucket.hasIconData() && bucket.icon.opacityVertexBuffer) { bucket.icon.opacityVertexBuffer.updateData(bucket.icon.opacityVertexArray); } if (bucket.hasIconCollisionBoxData() && bucket.iconCollisionBox.collisionVertexBuffer) { bucket.iconCollisionBox.collisionVertexBuffer.updateData(bucket.iconCollisionBox.collisionVertexArray); } if (bucket.hasTextCollisionBoxData() && bucket.textCollisionBox.collisionVertexBuffer) { bucket.textCollisionBox.collisionVertexBuffer.updateData(bucket.textCollisionBox.collisionVertexArray); } assert(bucket.text.opacityVertexArray.length === bucket.text.layoutVertexArray.length / 4); assert(bucket.icon.opacityVertexArray.length === bucket.icon.layoutVertexArray.length / 4); // Push generated collision circles to the bucket for debug rendering if (bucket.bucketInstanceId in this.collisionCircleArrays) { const instance = this.collisionCircleArrays[bucket.bucketInstanceId]; bucket.placementInvProjMatrix = instance.invProjMatrix; bucket.placementViewportMatrix = instance.viewportMatrix; bucket.collisionCircleArray = instance.circles; delete this.collisionCircleArrays[bucket.bucketInstanceId]; } } symbolFadeChange(now: number) { return this.fadeDuration === 0 ? 1 : ((now - this.commitTime) / this.fadeDuration + this.prevZoomAdjustment); } zoomAdjustment(zoom: number) { // When zooming out quickly, labels can overlap each other. This // adjustment is used to reduce the interval between placement calculations // and to reduce the fade duration when zooming out quickly. Discovering the // collisions more quickly and fading them more quickly reduces the unwanted effect. return Math.max(0, (this.transform.zoom - zoom) / 1.5); } hasTransitions(now: number) { return this.stale || now - this.lastPlacementChangeTime < this.fadeDuration; } stillRecent(now: number, zoom: number) { // The adjustment makes placement more frequent when zooming. // This condition applies the adjustment only after the map has // stopped zooming. This avoids adding extra jank while zooming. const durationAdjustment = this.zoomAtLastRecencyCheck === zoom ? (1 - this.zoomAdjustment(zoom)) : 1; this.zoomAtLastRecencyCheck = zoom; return this.commitTime + this.fadeDuration * durationAdjustment > now; } setStale() { this.stale = true; } } function updateCollisionVertices(collisionVertexArray: CollisionVertexArray, placed: boolean, notUsed: boolean | number, shiftX?: number, shiftY?: number) { collisionVertexArray.emplaceBack(placed ? 1 : 0, notUsed ? 1 : 0, shiftX || 0, shiftY || 0); collisionVertexArray.emplaceBack(placed ? 1 : 0, notUsed ? 1 : 0, shiftX || 0, shiftY || 0); collisionVertexArray.emplaceBack(placed ? 1 : 0, notUsed ? 1 : 0, shiftX || 0, shiftY || 0); collisionVertexArray.emplaceBack(placed ? 1 : 0, notUsed ? 1 : 0, shiftX || 0, shiftY || 0); } // 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. 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); function packOpacity(opacityState: OpacityState): number { if (opacityState.opacity === 0 && !opacityState.placed) { return 0; } else if (opacityState.opacity === 1 && opacityState.placed) { return 4294967295; } const targetBit = opacityState.placed ? 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; } const PACKED_HIDDEN_OPACITY = 0;