mapbox-gl
Version:
A WebGL interactive maps library
457 lines (397 loc) • 21.4 kB
JavaScript
// @flow
import Point from '@mapbox/point-geometry';
import clipLine from './clip_line.js';
import PathInterpolator from './path_interpolator.js';
import * as intersectionTests from '../util/intersection_tests.js';
import Grid from './grid_index.js';
import {mat4, vec4} from 'gl-matrix';
import ONE_EM from '../symbol/one_em.js';
import {FOG_SYMBOL_CLIPPING_THRESHOLD, getFogOpacityAtTileCoord} from '../style/fog_helpers.js';
import assert from 'assert';
import {OverscaledTileID} from '../source/tile_id.js';
import * as projection from '../symbol/projection.js';
import type Transform from '../geo/transform.js';
import type {SingleCollisionBox} from '../data/bucket/symbol_bucket.js';
import type {
GlyphOffsetArray,
SymbolLineVertexArray
} from '../data/array_types.js';
import type {FogState} from '../style/fog_helpers.js';
import type {Vec3, Mat4} from 'gl-matrix';
type PlacedCollisionBox = {|
box: Array<number>,
offscreen: boolean,
occluded: boolean
|};
type PlacedCollisionCircles = {|
circles: Array<number>,
offscreen: boolean,
collisionDetected: boolean,
occluded: boolean
|};
type ScreenAnchorPoint = {|
occluded: boolean,
perspectiveRatio: number,
point: Point,
signedDistanceFromCamera: number,
|};
// 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;
screenRightBoundary: number;
screenBottomBoundary: number;
gridRightBoundary: number;
gridBottomBoundary: number;
fogState: ?FogState;
constructor(
transform: Transform,
fogState: ?FogState,
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;
this.screenRightBoundary = transform.width + viewportPadding;
this.screenBottomBoundary = transform.height + viewportPadding;
this.gridRightBoundary = transform.width + 2 * viewportPadding;
this.gridBottomBoundary = transform.height + 2 * viewportPadding;
this.fogState = fogState;
}
placeCollisionBox(scale: number, collisionBox: SingleCollisionBox, shift: Point, allowOverlap: boolean, textPixelRatio: number, posMatrix: Mat4, collisionGroupPredicate?: any): PlacedCollisionBox {
assert(!this.transform.elevation || collisionBox.elevation !== undefined);
let anchorX = collisionBox.projectedAnchorX;
let anchorY = collisionBox.projectedAnchorY;
let anchorZ = collisionBox.projectedAnchorZ;
// Apply elevation vector to the anchor point
const elevation = collisionBox.elevation;
const tileID = collisionBox.tileID;
if (elevation && tileID) {
const up = this.transform.projection.upVector(tileID.canonical, collisionBox.tileAnchorX, collisionBox.tileAnchorY);
const upScale = this.transform.projection.upVectorScale(tileID.canonical, this.transform.center.lat, this.transform.worldSize).metersToTile;
anchorX += up[0] * elevation * upScale;
anchorY += up[1] * elevation * upScale;
anchorZ += up[2] * elevation * upScale;
}
const checkOcclusion = this.transform.projection.name === 'globe' || !!elevation || this.transform.pitch > 0;
const projectedPoint = this.projectAndGetPerspectiveRatio(posMatrix, [anchorX, anchorY, anchorZ], collisionBox.tileID, checkOcclusion);
const tileToViewport = textPixelRatio * projectedPoint.perspectiveRatio;
const tlX = (collisionBox.x1 * scale + shift.x - collisionBox.padding) * tileToViewport + projectedPoint.point.x;
const tlY = (collisionBox.y1 * scale + shift.y - collisionBox.padding) * tileToViewport + projectedPoint.point.y;
const brX = (collisionBox.x2 * scale + shift.x + collisionBox.padding) * tileToViewport + projectedPoint.point.x;
const brY = (collisionBox.y2 * scale + shift.y + collisionBox.padding) * tileToViewport + projectedPoint.point.y;
// Clip at 10 times the distance of the map center or, said otherwise, when the label
// would be drawn at 10% the size of the features around it without scaling. Refer:
// https://github.com/mapbox/mapbox-gl-native/wiki/Text-Rendering#perspective-scaling
// 0.55 === projection.getPerspectiveRatio(camera_to_center, camera_to_center * 10)
const minPerspectiveRatio = 0.55;
const isClipped = projectedPoint.perspectiveRatio <= minPerspectiveRatio || projectedPoint.occluded;
if (!this.isInsideGrid(tlX, tlY, brX, brY) ||
(!allowOverlap && this.grid.hitTest(tlX, tlY, brX, brY, collisionGroupPredicate)) ||
isClipped) {
return {
box: [],
offscreen: false,
occluded: projectedPoint.occluded
};
}
return {
box: [tlX, tlY, brX, brY],
offscreen: this.isOffscreen(tlX, tlY, brX, brY),
occluded: false
};
}
placeCollisionCircles(allowOverlap: boolean,
symbol: any,
lineVertexArray: SymbolLineVertexArray,
glyphOffsetArray: GlyphOffsetArray,
fontSize: number,
posMatrix: Float32Array,
labelPlaneMatrix: Float32Array,
labelToScreenMatrix?: Mat4,
showCollisionCircles: boolean,
pitchWithMap: boolean,
collisionGroupPredicate?: any,
circlePixelDiameter: number,
textPixelPadding: number,
tileID: OverscaledTileID): PlacedCollisionCircles {
const placedCollisionCircles = [];
const elevation = this.transform.elevation;
const getElevation = elevation ? elevation.getAtTileOffsetFunc(tileID, this.transform.center.lat, this.transform.worldSize, this.transform.projection) : (_ => [0, 0, 0]);
const tileUnitAnchorPoint = new Point(symbol.tileAnchorX, symbol.tileAnchorY);
const projectedAnchor = this.transform.projection.projectTilePoint(symbol.tileAnchorX, symbol.tileAnchorY, tileID.canonical);
const anchorElevation = getElevation(tileUnitAnchorPoint);
const elevatedAnchor = [projectedAnchor.x + anchorElevation[0], projectedAnchor.y + anchorElevation[1], projectedAnchor.z + anchorElevation[2]];
const checkOcclusion = this.transform.projection.name === 'globe' || !!elevation || this.transform.pitch > 0;
const screenAnchorPoint = this.projectAndGetPerspectiveRatio(posMatrix, [elevatedAnchor[0], elevatedAnchor[1], elevatedAnchor[2]], tileID, checkOcclusion);
const {perspectiveRatio} = screenAnchorPoint;
const labelPlaneFontSize = pitchWithMap ? fontSize / perspectiveRatio : fontSize * perspectiveRatio;
const labelPlaneFontScale = labelPlaneFontSize / ONE_EM;
const labelPlaneAnchorPoint = projection.project(new Point(elevatedAnchor[0], elevatedAnchor[1]), labelPlaneMatrix, elevatedAnchor[2]).point;
const projectionCache = {};
const lineOffsetX = symbol.lineOffsetX * labelPlaneFontScale;
const lineOffsetY = symbol.lineOffsetY * labelPlaneFontScale;
const firstAndLastGlyph = screenAnchorPoint.signedDistanceFromCamera > 0 ? projection.placeFirstAndLastGlyph(
labelPlaneFontScale,
glyphOffsetArray,
lineOffsetX,
lineOffsetY,
/*flip*/ false,
labelPlaneAnchorPoint,
tileUnitAnchorPoint,
symbol,
lineVertexArray,
labelPlaneMatrix,
projectionCache,
elevation && !pitchWithMap ? getElevation : null, // pitchWithMap: no need to sample elevation as it has no effect when projecting using scale/rotate to tile space labelPlaneMatrix.
pitchWithMap && !!elevation,
this.transform.projection,
tileID
) : null;
let collisionDetected = false;
let inGrid = false;
let entirelyOffscreen = true;
if (firstAndLastGlyph && !screenAnchorPoint.occluded) {
const radius = circlePixelDiameter * 0.5 * perspectiveRatio + textPixelPadding;
const screenPlaneMin = new Point(-viewportPadding, -viewportPadding);
const screenPlaneMax = new Point(this.screenRightBoundary, this.screenBottomBoundary);
const interpolator = new PathInterpolator();
// Construct a projected path from projected line vertices. Anchor points are ignored and removed
const first = firstAndLastGlyph.first;
const last = firstAndLastGlyph.last;
let projectedPath = [];
for (let i = first.path.length - 1; i >= 1; i--) {
projectedPath.push(first.path[i]);
}
for (let i = 1; i < last.path.length; i++) {
projectedPath.push(last.path[i]);
}
assert(projectedPath.length >= 2);
// Tolerate a slightly longer distance than one diameter between two adjacent circles
const circleDist = radius * 2.5;
// The path might need to be converted into screen space if a pitched map is used as the label space
if (labelToScreenMatrix) {
assert(pitchWithMap);
const screenSpacePath = elevation ?
projectedPath.map((p, index) => {
const elevation = getElevation(index < first.path.length - 1 ? first.tilePath[first.path.length - 1 - index] : last.tilePath[index - first.path.length + 2]);
return projection.project(p, labelToScreenMatrix, elevation[2]);
}) :
projectedPath.map(p => projection.project(p, labelToScreenMatrix));
// Do not try to place collision circles if even of the points is behind the camera.
// This is a plausible scenario with big camera pitch angles
if (screenSpacePath.some(point => point.signedDistanceFromCamera <= 0)) {
projectedPath = [];
} else {
projectedPath = screenSpacePath.map(p => p.point);
}
}
let segments = [];
if (projectedPath.length > 0) {
// Quickly check if the path is fully inside or outside of the padded collision region.
// For overlapping paths we'll only create collision circles for the visible segments
const minPoint = projectedPath[0].clone();
const maxPoint = projectedPath[0].clone();
for (let i = 1; i < projectedPath.length; i++) {
minPoint.x = Math.min(minPoint.x, projectedPath[i].x);
minPoint.y = Math.min(minPoint.y, projectedPath[i].y);
maxPoint.x = Math.max(maxPoint.x, projectedPath[i].x);
maxPoint.y = Math.max(maxPoint.y, projectedPath[i].y);
}
if (minPoint.x >= screenPlaneMin.x && maxPoint.x <= screenPlaneMax.x &&
minPoint.y >= screenPlaneMin.y && maxPoint.y <= screenPlaneMax.y) {
// Quad fully visible
segments = [projectedPath];
} else if (maxPoint.x < screenPlaneMin.x || minPoint.x > screenPlaneMax.x ||
maxPoint.y < screenPlaneMin.y || minPoint.y > screenPlaneMax.y) {
// Not visible
segments = [];
} else {
segments = clipLine([projectedPath], screenPlaneMin.x, screenPlaneMin.y, screenPlaneMax.x, screenPlaneMax.y);
}
}
for (const seg of segments) {
// interpolate positions for collision circles. Add a small padding to both ends of the segment
assert(seg.length > 0);
interpolator.reset(seg, radius * 0.25);
let numCircles = 0;
if (interpolator.length <= 0.5 * radius) {
numCircles = 1;
} else {
numCircles = Math.ceil(interpolator.paddedLength / circleDist) + 1;
}
for (let i = 0; i < numCircles; i++) {
const t = i / Math.max(numCircles - 1, 1);
const circlePosition = interpolator.lerp(t);
// add viewport padding to the position and perform initial collision check
const centerX = circlePosition.x + viewportPadding;
const centerY = circlePosition.y + viewportPadding;
placedCollisionCircles.push(centerX, centerY, radius, 0);
const x1 = centerX - radius;
const y1 = centerY - radius;
const x2 = centerX + radius;
const y2 = centerY + radius;
entirelyOffscreen = entirelyOffscreen && this.isOffscreen(x1, y1, x2, y2);
inGrid = inGrid || this.isInsideGrid(x1, y1, x2, y2);
if (!allowOverlap) {
if (this.grid.hitTestCircle(centerX, centerY, radius, collisionGroupPredicate)) {
// Don't early exit if we're showing the debug circles because we still want to calculate
// which circles are in use
collisionDetected = true;
if (!showCollisionCircles) {
return {
circles: [],
offscreen: false,
collisionDetected,
occluded: false
};
}
}
}
}
}
}
return {
circles: ((!showCollisionCircles && collisionDetected) || !inGrid) ? [] : placedCollisionCircles,
offscreen: entirelyOffscreen,
collisionDetected,
occluded: screenAnchorPoint.occluded
};
}
/**
* 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`.
*
* @private
*/
queryRenderedSymbols(viewportQueryGeometry: Array<Point>): {[id: number]: Array<number>} {
if (viewportQueryGeometry.length === 0 || (this.grid.keysLength() === 0 && this.ignoredGrid.keysLength() === 0)) {
return {};
}
const query = [];
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const point of viewportQueryGeometry) {
const gridPoint = new Point(point.x + viewportPadding, point.y + viewportPadding);
minX = Math.min(minX, gridPoint.x);
minY = Math.min(minY, gridPoint.y);
maxX = Math.max(maxX, gridPoint.x);
maxY = Math.max(maxY, gridPoint.y);
query.push(gridPoint);
}
const features = this.grid.query(minX, minY, maxX, maxY)
.concat(this.ignoredGrid.query(minX, minY, maxX, maxY));
const seenFeatures = {};
const result = {};
for (const feature of features) {
const featureKey = feature.key;
// Skip already seen features.
if (seenFeatures[featureKey.bucketInstanceId] === undefined) {
seenFeatures[featureKey.bucketInstanceId] = {};
}
if (seenFeatures[featureKey.bucketInstanceId][featureKey.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 bbox = [
new Point(feature.x1, feature.y1),
new Point(feature.x2, feature.y1),
new Point(feature.x2, feature.y2),
new Point(feature.x1, feature.y2)
];
if (!intersectionTests.polygonIntersectsPolygon(query, bbox)) {
continue;
}
seenFeatures[featureKey.bucketInstanceId][featureKey.featureIndex] = true;
if (result[featureKey.bucketInstanceId] === undefined) {
result[featureKey.bucketInstanceId] = [];
}
result[featureKey.bucketInstanceId].push(featureKey.featureIndex);
}
return result;
}
insertCollisionBox(collisionBox: Array<number>, ignorePlacement: boolean, bucketInstanceId: number, featureIndex: number, collisionGroupID: number) {
const grid = ignorePlacement ? this.ignoredGrid : this.grid;
const key = {bucketInstanceId, featureIndex, collisionGroupID};
grid.insert(key, collisionBox[0], collisionBox[1], collisionBox[2], collisionBox[3]);
}
insertCollisionCircles(collisionCircles: Array<number>, ignorePlacement: boolean, bucketInstanceId: number, featureIndex: number, collisionGroupID: number) {
const grid = ignorePlacement ? this.ignoredGrid : this.grid;
const key = {bucketInstanceId, featureIndex, collisionGroupID};
for (let k = 0; k < collisionCircles.length; k += 4) {
grid.insertCircle(key, collisionCircles[k], collisionCircles[k + 1], collisionCircles[k + 2]);
}
}
projectAndGetPerspectiveRatio(posMatrix: Mat4, point: Vec3, tileID: ?OverscaledTileID, checkOcclusion: boolean): ScreenAnchorPoint {
const p = [point[0], point[1], point[2], 1];
let behindFog = false;
if (point[2] || this.transform.pitch > 0) {
vec4.transformMat4(p, p, posMatrix);
if (this.fogState && tileID) {
const fogOpacity = getFogOpacityAtTileCoord(this.fogState, point[0], point[1], point[2], tileID.toUnwrapped(), this.transform);
behindFog = fogOpacity > FOG_SYMBOL_CLIPPING_THRESHOLD;
}
} else {
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,
// See perspective ratio comment in symbol_sdf.vertex
// We're doing collision detection in viewport space so we need
// to scale down boxes in the distance
perspectiveRatio: Math.min(0.5 + 0.5 * (this.transform.cameraToCenterDistance / p[3]), 1.5),
signedDistanceFromCamera: p[3],
occluded: (checkOcclusion && p[2] > p[3]) || behindFog // Occluded by the far plane
};
}
isOffscreen(x1: number, y1: number, x2: number, y2: number): boolean {
return x2 < viewportPadding || x1 >= this.screenRightBoundary || y2 < viewportPadding || y1 > this.screenBottomBoundary;
}
isInsideGrid(x1: number, y1: number, x2: number, y2: number): boolean {
return x2 >= 0 && x1 < this.gridRightBoundary && y2 >= 0 && y1 < this.gridBottomBoundary;
}
/*
* Returns a matrix for transforming collision shapes to viewport coordinate space.
* Use this function to render e.g. collision circles on the screen.
* example transformation: clipPos = glCoordMatrix * viewportMatrix * circle_pos
*/
getViewportMatrix(): Mat4 {
const m = mat4.identity([]);
mat4.translate(m, m, [-viewportPadding, -viewportPadding, 0.0]);
return m;
}
}
export default CollisionIndex;