mapbox-gl
Version:
A WebGL interactive maps library
315 lines (264 loc) • 13.8 kB
JavaScript
const Point = require('point-geometry');
const mat4 = require('@mapbox/gl-matrix').mat4;
const vec4 = require('@mapbox/gl-matrix').vec4;
const symbolSize = require('./symbol_size');
const addDynamicAttributes = require('../data/bucket/symbol_bucket').addDynamicAttributes;
module.exports = {
updateLineLabels: updateLineLabels,
getLabelPlaneMatrix: getLabelPlaneMatrix,
getGlCoordMatrix: getGlCoordMatrix
};
/*
* # Overview of coordinate spaces
*
* ## Tile coordinate spaces
* Each label has an anchor. Some labels have corresponding line geometries.
* The points for both anchors and lines are stored in tile units. Each tile has it's own
* coordinate space going from (0, 0) at the top left to (EXTENT, EXTENT) at the bottom right.
*
* ## GL coordinate space
* At the end of everything, the vertex shader needs to produce a position in GL coordinate space,
* which is (-1, 1) at the top left and (1, -1) in the bottom right.
*
* ## Map pixel coordinate spaces
* Each tile has a pixel coordinate space. It's just the tile units scaled so that one unit is
* whatever counts as 1 pixel at the current zoom.
* This space is used for pitch-alignment=map, rotation-alignment=map
*
* ## Rotated map pixel coordinate spaces
* Like the above, but rotated so axis of the space are aligned with the viewport instead of the tile.
* This space is used for pitch-alignment=map, rotation-alignment=viewport
*
* ## Viewport pixel coordinate space
* (0, 0) is at the top left of the canvas and (pixelWidth, pixelHeight) is at the bottom right corner
* of the canvas. This space is used for pitch-alignment=viewport
*
*
* # Vertex projection
* It goes roughly like this:
* 1. project the anchor and line from tile units into the correct label coordinate space
* - map pixel space pitch-alignment=map rotation-alignment=map
* - rotated map pixel space pitch-alignment=map rotation-alignment=viewport
* - viewport pixel space pitch-alignment=viewport rotation-alignment=*
* 2. if the label follows a line, find the point along the line that is the correct distance from the anchor.
* 3. add the glyph's corner offset to the point from step 3
* 4. convert from the label coordinate space to gl coordinates
*
* For horizontal labels we want to do step 1 in the shader for performance reasons (no cpu work).
* This is what `u_label_plane_matrix` is used for.
* For labels aligned with lines we have to steps 1 and 2 on the cpu since we need access to the line geometry.
* This is what `updateLineLabels(...)` does.
* Since the conversion is handled on the cpu we just set `u_label_plane_matrix` to an identity matrix.
*
* Steps 3 and 4 are done in the shaders for all labels.
*/
/*
* Returns a matrix for converting from tile units to the correct label coordinate space.
*/
function getLabelPlaneMatrix(posMatrix, pitchWithMap, rotateWithMap, transform, pixelsToTileUnits) {
const m = mat4.identity(new Float32Array(16));
if (pitchWithMap) {
mat4.identity(m);
mat4.scale(m, m, [1 / pixelsToTileUnits, 1 / pixelsToTileUnits, 1]);
if (!rotateWithMap) {
mat4.rotateZ(m, m, transform.angle);
}
} else {
mat4.scale(m, m, [transform.width / 2, -transform.height / 2, 1]);
mat4.translate(m, m, [1, -1, 0]);
mat4.multiply(m, m, posMatrix);
}
return m;
}
/*
* Returns a matrix for converting from the correct label coordinate space to gl coords.
*/
function getGlCoordMatrix(posMatrix, pitchWithMap, rotateWithMap, transform, pixelsToTileUnits) {
const m = mat4.identity(new Float32Array(16));
if (pitchWithMap) {
mat4.multiply(m, m, posMatrix);
mat4.scale(m, m, [pixelsToTileUnits, pixelsToTileUnits, 1]);
if (!rotateWithMap) {
mat4.rotateZ(m, m, -transform.angle);
}
} else {
mat4.scale(m, m, [1, -1, 1]);
mat4.translate(m, m, [-1, -1, 0]);
mat4.scale(m, m, [2 / transform.width, 2 / transform.height, 1]);
}
return m;
}
function project(point, matrix) {
const pos = [point.x, point.y, 0, 1];
vec4.transformMat4(pos, pos, matrix);
return new Point(pos[0] / pos[3], pos[1] / pos[3]);
}
function isVisible(anchorPos, placementZoom, clippingBuffer, painter) {
const x = anchorPos[0] / anchorPos[3];
const y = anchorPos[1] / anchorPos[3];
const inPaddedViewport = (
x >= -clippingBuffer[0] &&
x <= clippingBuffer[0] &&
y >= -clippingBuffer[1] &&
y <= clippingBuffer[1]);
return inPaddedViewport && painter.frameHistory.isVisible(placementZoom);
}
/*
* Update the `dynamicLayoutVertexBuffer` for the buffer with the correct glyph positions for the current map view.
* This is only run on labels that are aligned with lines. Horizontal labels are handled entirely in the shader.
*/
function updateLineLabels(bucket, posMatrix, painter, isText, labelPlaneMatrix, glCoordMatrix, pitchWithMap, keepUpright, pixelsToTileUnits, layer) {
const sizeData = isText ? bucket.textSizeData : bucket.iconSizeData;
const partiallyEvaluatedSize = symbolSize.evaluateSizeForZoom(sizeData, painter.transform, layer, isText);
const clippingBuffer = [256 / painter.width * 2 + 1, 256 / painter.height * 2 + 1];
const dynamicLayoutVertexArray = isText ?
bucket.buffers.glyph.dynamicLayoutVertexArray :
bucket.buffers.icon.dynamicLayoutVertexArray;
dynamicLayoutVertexArray.clear();
const lineVertexArray = bucket.lineVertexArray;
const placedSymbols = isText ? bucket.placedGlyphArray : bucket.placedIconArray;
for (let s = 0; s < placedSymbols.length; s++) {
const symbol = placedSymbols.get(s);
const anchorPos = [symbol.anchorX, symbol.anchorY, 0, 1];
vec4.transformMat4(anchorPos, anchorPos, posMatrix);
// Don't bother calculating the correct point for invisible labels.
if (!isVisible(anchorPos, symbol.placementZoom, clippingBuffer, painter)) {
hideGlyphs(symbol.numGlyphs, dynamicLayoutVertexArray);
continue;
}
const cameraToAnchorDistance = anchorPos[3];
const perspectiveRatio = 1 + 0.5 * ((cameraToAnchorDistance / painter.transform.cameraToCenterDistance) - 1);
const fontSize = symbolSize.evaluateSizeForFeature(sizeData, partiallyEvaluatedSize, symbol);
const pitchScaledFontSize = pitchWithMap ?
fontSize * perspectiveRatio :
fontSize / perspectiveRatio;
const anchorPoint = project(new Point(symbol.anchorX, symbol.anchorY), labelPlaneMatrix);
const projectionCache = {};
const placeUnflipped = placeGlyphsAlongLine(symbol, pitchScaledFontSize, false /*unflipped*/, keepUpright, posMatrix, labelPlaneMatrix, glCoordMatrix,
bucket.glyphOffsetArray, lineVertexArray, dynamicLayoutVertexArray, anchorPoint, projectionCache);
if (placeUnflipped.notEnoughRoom ||
(placeUnflipped.needsFlipping &&
placeGlyphsAlongLine(symbol, pitchScaledFontSize, true /*flipped*/, keepUpright, posMatrix, labelPlaneMatrix, glCoordMatrix,
bucket.glyphOffsetArray, lineVertexArray, dynamicLayoutVertexArray, anchorPoint, projectionCache).notEnoughRoom)) {
hideGlyphs(symbol.numGlyphs, dynamicLayoutVertexArray);
}
}
if (isText) {
bucket.buffers.glyph.dynamicLayoutVertexBuffer.updateData(dynamicLayoutVertexArray.serialize());
} else {
bucket.buffers.icon.dynamicLayoutVertexBuffer.updateData(dynamicLayoutVertexArray.serialize());
}
}
function placeGlyphsAlongLine(symbol, fontSize, flip, keepUpright, posMatrix, labelPlaneMatrix, glCoordMatrix, glyphOffsetArray, lineVertexArray, dynamicLayoutVertexArray, anchorPoint, projectionCache) {
const fontScale = fontSize / 24;
const lineOffsetX = symbol.lineOffsetX * fontSize;
const lineOffsetY = symbol.lineOffsetY * fontSize;
let placedGlyphs;
if (symbol.numGlyphs > 1) {
const glyphEndIndex = symbol.glyphStartIndex + symbol.numGlyphs;
// Place the first and the last glyph in the label first, so we can figure out
// the overall orientation of the label and determine whether it needs to be flipped in keepUpright mode
const firstGlyphOffset = glyphOffsetArray.get(symbol.glyphStartIndex).offsetX;
const lastGlyphOffset = glyphOffsetArray.get(glyphEndIndex - 1).offsetX;
const lineStartIndex = symbol.lineStartIndex;
const lineEndIndex = symbol.lineStartIndex + symbol.lineLength;
const firstPlacedGlyph = placeGlyphAlongLine(fontScale * firstGlyphOffset, lineOffsetX, lineOffsetY, flip, anchorPoint, symbol.segment,
lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache);
if (!firstPlacedGlyph)
return { notEnoughRoom: true };
const lastPlacedGlyph = placeGlyphAlongLine(fontScale * lastGlyphOffset, lineOffsetX, lineOffsetY, flip, anchorPoint, symbol.segment,
lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache);
if (!lastPlacedGlyph)
return { notEnoughRoom: true };
const firstPoint = project(firstPlacedGlyph.point, glCoordMatrix);
const lastPoint = project(lastPlacedGlyph.point, glCoordMatrix);
if (keepUpright && !flip &&
(symbol.vertical ? firstPoint.y < lastPoint.y : firstPoint.x > lastPoint.x)) {
return { needsFlipping: true };
}
placedGlyphs = [firstPlacedGlyph];
for (let glyphIndex = symbol.glyphStartIndex + 1; glyphIndex < glyphEndIndex - 1; glyphIndex++) {
const glyph = glyphOffsetArray.get(glyphIndex);
// Since first and last glyph fit on the line, we're sure that the rest of the glyphs can be placed
placedGlyphs.push(placeGlyphAlongLine(fontScale * glyph.offsetX, lineOffsetX, lineOffsetY, flip, anchorPoint, symbol.segment,
lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache));
}
placedGlyphs.push(lastPlacedGlyph);
} else {
// Only a single glyph to place
// So, determine whether to flip based on projected angle of the line segment it's on
if (keepUpright && !flip) {
const a = project(lineVertexArray.get(symbol.lineStartIndex + symbol.segment), posMatrix);
const b = project(lineVertexArray.get(symbol.lineStartIndex + symbol.segment + 1), posMatrix);
if (symbol.vertical ? b.y > a.y : b.x < a.x) {
return { needsFlipping: true };
}
}
const glyph = glyphOffsetArray.get(symbol.glyphStartIndex);
const singleGlyph = placeGlyphAlongLine(fontScale * glyph.offsetX, lineOffsetX, lineOffsetY, flip, anchorPoint, symbol.segment,
symbol.lineStartIndex, symbol.lineStartIndex + symbol.lineLength, lineVertexArray, labelPlaneMatrix, projectionCache);
if (!singleGlyph)
return { notEnoughRoom: true };
placedGlyphs = [singleGlyph];
}
const placementZoom = symbol.placementZoom;
for (const glyph of placedGlyphs) {
addDynamicAttributes(dynamicLayoutVertexArray, glyph.point, glyph.angle, placementZoom);
}
return {};
}
function placeGlyphAlongLine(offsetX, lineOffsetX, lineOffsetY, flip, anchorPoint, anchorSegment,
lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache) {
const combinedOffsetX = flip ?
offsetX - lineOffsetX :
offsetX + lineOffsetX;
let dir = combinedOffsetX > 0 ? 1 : -1;
let angle = 0;
if (flip) {
// The label needs to be flipped to keep text upright.
// Iterate in the reverse direction.
dir *= -1;
angle = Math.PI;
}
if (dir < 0) angle += Math.PI;
let currentIndex = dir > 0 ?
lineStartIndex + anchorSegment :
lineStartIndex + anchorSegment + 1;
let current = anchorPoint;
let prev = anchorPoint;
let distanceToPrev = 0;
let currentSegmentDistance = 0;
const absOffsetX = Math.abs(combinedOffsetX);
while (distanceToPrev + currentSegmentDistance <= absOffsetX) {
currentIndex += dir;
// offset does not fit on the projected line
if (currentIndex < lineStartIndex || currentIndex >= lineEndIndex)
return null;
prev = current;
current = projectionCache[currentIndex];
if (current === undefined) {
current = projectionCache[currentIndex] = project(lineVertexArray.get(currentIndex), labelPlaneMatrix);
}
distanceToPrev += currentSegmentDistance;
currentSegmentDistance = prev.dist(current);
}
// The point is on the current segment. Interpolate to find it.
const segmentInterpolationT = (absOffsetX - distanceToPrev) / currentSegmentDistance;
const prevToCurrent = current.sub(prev);
const p = prevToCurrent.mult(segmentInterpolationT)._add(prev);
// offset the point from the line to text-offset and icon-offset
p._add(prevToCurrent._unit()._perp()._mult(lineOffsetY * dir));
const segmentAngle = angle + Math.atan2(current.y - prev.y, current.x - prev.x);
return {
point: p,
angle: segmentAngle
};
}
const offscreenPoint = new Point(-Infinity, -Infinity);
// Hide them by moving them offscreen. We still need to add them to the buffer
// because the dynamic buffer is paired with a static buffer that doesn't get updated.
function hideGlyphs(num, dynamicLayoutVertexArray) {
for (let i = 0; i < num; i++) {
addDynamicAttributes(dynamicLayoutVertexArray, offscreenPoint, 0, 25);
}
}