package.src.symbol.placement.js Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of mapbox-gl Show documentation
Show all versions of mapbox-gl Show documentation
A WebGL interactive maps library
The newest version!
// @flow
import CollisionIndex 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 type Tile from '../source/tile';
import type SymbolBucket, {CollisionArrays, SingleCollisionBox} from '../data/bucket/symbol_bucket';
import type {CollisionBoxArray, CollisionVertexArray, SymbolInstance} from '../data/array_types';
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) {
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) {
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?: any };
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: any,
posMatrix: mat4,
textLabelPlaneMatrix: mat4,
labelToScreenMatrix: mat4,
scale: number,
textPixelRatio: number,
holdingForFade: boolean,
collisionBoxArray: ?CollisionBoxArray,
partiallyEvaluatedTextSize: any,
collisionGroup: any
};
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: { [_: CrossTileID]: JointPlacement };
opacities: { [_: CrossTileID]: JointOpacityState };
variableOffsets: {[_: CrossTileID]: VariableOffset };
placedOrientations: {[_: CrossTileID]: number };
commitTime: number;
prevZoomAdjustment: number;
lastPlacementChangeTime: number;
stale: boolean;
fadeDuration: number;
retainedQueryData: {[_: number]: RetainedQueryData};
collisionGroups: CollisionGroups;
prevPlacement: ?Placement;
zoomAtLastRecencyCheck: number;
collisionCircleArrays: {[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): any): 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([], 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,
textAllowOverlap: boolean, symbolInstance: SymbolInstance, bucket: SymbolBucket, orientation: number, iconBox: ?SingleCollisionBox): ?{ shift: Point, placedGlyphBoxes: { box: Array, offscreen: boolean } } {
const textOffset = [symbolInstance.textOffset0, symbolInstance.textOffset1];
const shift = calculateVariableLayoutShift(anchor, width, height, textOffset, textBoxScale);
const placedGlyphBoxes = this.collisionIndex.placeCollisionBox(
shiftVariableCollisionBox(
textBox, shift.x, shift.y,
rotateWithMap, pitchWithMap, this.transform.angle),
textAllowOverlap, textPixelRatio, posMatrix, collisionGroup.predicate);
if (iconBox) {
const placedIconBoxes = this.collisionIndex.placeCollisionBox(
shiftVariableCollisionBox(
iconBox, shift.x, shift.y,
rotateWithMap, pitchWithMap, this.transform.angle),
textAllowOverlap, 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: Object, seenCrossTileIDs: { [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 textAllowOverlap = layout.get('text-allow-overlap');
const iconAllowOverlap = layout.get('icon-allow-overlap');
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 = textAllowOverlap && (iconAllowOverlap || !bucket.hasIconData() || iconOptional);
const alwaysShowIcon = iconAllowOverlap && (textAllowOverlap || !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, textAllowOverlap,
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 && !iconAllowOverlap ? collisionIconBox : null;
let placedBox: ?{ box: Array, offscreen: boolean } = {box: [], offscreen: false};
const placementAttempts = textAllowOverlap ? anchors.length * 2 : anchors.length;
for (let i = 0; i < placementAttempts; ++i) {
const anchor = anchors[i % anchors.length];
const allowOverlap = (i >= anchors.length);
const result = this.attemptAnchorPlacement(
anchor, collisionTextBox, width, height,
textBoxScale, rotateWithMap, pitchWithMap, textPixelRatio, posMatrix,
collisionGroup, allowOverlap, 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(textAllowOverlap,
placedSymbol,
bucket.lineVertexArray,
bucket.glyphOffsetArray,
fontSize,
posMatrix,
textLabelPlaneMatrix,
labelToScreenMatrix,
showCollisionBoxes,
pitchWithMap,
collisionGroup.predicate,
circlePixelDiameter,
textPixelPadding);
assert(!placedGlyphCircles.circles.length || (!placedGlyphCircles.collisionDetected || showCollisionBoxes));
// 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.
placeText = textAllowOverlap || (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,
iconAllowOverlap, 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, layout.get('text-ignore-placement'),
bucket.bucketInstanceId, verticalTextFeatureIndex, collisionGroup.ID);
} else {
this.collisionIndex.insertCollisionBox(placedGlyphBoxes.box, layout.get('text-ignore-placement'),
bucket.bucketInstanceId, textFeatureIndex, collisionGroup.ID);
}
}
if (placeIcon && placedIconBoxes) {
this.collisionIndex.insertCollisionBox(placedIconBoxes.box, layout.get('icon-ignore-placement'),
bucket.bucketInstanceId, iconFeatureIndex, collisionGroup.ID);
}
if (placedGlyphCircles) {
if (placeText) {
this.collisionIndex.insertCollisionCircles(placedGlyphCircles.circles, 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): any): SymbolBucket);
if (symbolBucket && tile.latestFeatureIndex && styleLayer.id === symbolBucket.layerIds[0]) {
this.updateBucketOpacities(symbolBucket, seenCrossTileIDs, tile.collisionBoxArray);
}
}
}
updateBucketOpacities(bucket: SymbolBucket, seenCrossTileIDs: { [string | number]: boolean }, collisionBoxArray: ?CollisionBoxArray) {
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(): 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(): 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;