mapbox-gl
Version:
A WebGL interactive maps library
557 lines (456 loc) • 21.3 kB
JavaScript
'use strict';
var Point = require('point-geometry');
var Bucket = require('./bucket');
var ElementGroups = require('./element_groups');
var Anchor = require('../symbol/anchor');
var getAnchors = require('../symbol/get_anchors');
var resolveTokens = require('../util/token');
var Quads = require('../symbol/quads');
var Shaping = require('../symbol/shaping');
var resolveText = require('../symbol/resolve_text');
var mergeLines = require('../symbol/mergelines');
var shapeText = Shaping.shapeText;
var shapeIcon = Shaping.shapeIcon;
var getGlyphQuads = Quads.getGlyphQuads;
var getIconQuads = Quads.getIconQuads;
var clipLine = require('../symbol/clip_line');
var util = require('../util/util');
var loadGeometry = require('./load_geometry');
var EXTENT = require('./buffer').EXTENT;
var CollisionFeature = require('../symbol/collision_feature');
module.exports = SymbolBucket;
function SymbolBucket(options) {
Bucket.apply(this, arguments);
this.collisionDebug = options.collisionDebug;
this.overscaling = options.overscaling;
// To reduce the number of labels that jump around when zooming we need
// to use a text-size value that is the same for all zoom levels.
// This calculates text-size at a high zoom level so that all tiles can
// use the same value when calculating anchor positions.
var zoomHistory = { lastIntegerZoom: Infinity, lastIntegerZoomTime: 0, lastZoom: 0 };
this.adjustedTextMaxSize = this.layer.getLayoutValue('text-size', 18, zoomHistory);
this.adjustedTextSize = this.layer.getLayoutValue('text-size', this.zoom + 1, zoomHistory);
this.adjustedIconMaxSize = this.layer.getLayoutValue('icon-size', 18, zoomHistory);
this.adjustedIconSize = this.layer.getLayoutValue('icon-size', this.zoom + 1, zoomHistory);
}
SymbolBucket.prototype = util.inherit(Bucket, {});
var shaderAttributeArgs = ['x', 'y', 'ox', 'oy', 'tx', 'ty', 'minzoom', 'maxzoom', 'labelminzoom'];
var shaderAttributes = [{
name: 'pos',
components: 2,
type: Bucket.AttributeType.SHORT,
value: ['x', 'y']
}, {
name: 'offset',
components: 2,
type: Bucket.AttributeType.SHORT,
value: [
'Math.round(ox * 64)', // use 1/64 pixels for placement
'Math.round(oy * 64)'
]
}, {
name: 'data1',
components: 4,
type: Bucket.AttributeType.UNSIGNED_BYTE,
value: [
'tx / 4', // tex
'ty / 4', // tex
'(labelminzoom || 0) * 10', // labelminzoom
'0'
]
}, {
name: 'data2',
components: 2,
type: Bucket.AttributeType.UNSIGNED_BYTE,
value: [
'(minzoom || 0) * 10', // minzoom
'Math.min(maxzoom || 25, 25) * 10' // minzoom
]
}];
SymbolBucket.prototype.shaders = {
glyph: {
vertexBuffer: true,
elementBuffer: true,
attributeArgs: shaderAttributeArgs,
attributes: shaderAttributes
},
icon: {
vertexBuffer: true,
elementBuffer: true,
attributeArgs: shaderAttributeArgs,
attributes: shaderAttributes
},
collisionBox: {
vertexBuffer: true,
attributeArgs: ['point', 'extrude', 'maxZoom', 'placementZoom'],
attributes: [{
name: 'pos',
components: 2,
type: Bucket.AttributeType.SHORT,
value: [ 'point.x', 'point.y' ]
}, {
name: 'extrude',
components: 2,
type: Bucket.AttributeType.SHORT,
value: [
'Math.round(extrude.x)',
'Math.round(extrude.y)'
]
}, {
name: 'data',
components: 2,
type: Bucket.AttributeType.UNSIGNED_BYTE,
value: [
'maxZoom * 10',
'placementZoom * 10'
]
}]
}
};
SymbolBucket.prototype.addFeatures = function(collisionTile, stacks, icons) {
var tileSize = 512 * this.overscaling;
this.tilePixelRatio = EXTENT / tileSize;
this.compareText = {};
this.symbolInstances = [];
this.iconsNeedLinear = false;
var layout = this.layer.layout;
var features = this.features;
var textFeatures = this.textFeatures;
var horizontalAlign = 0.5,
verticalAlign = 0.5;
switch (layout['text-anchor']) {
case 'right':
case 'top-right':
case 'bottom-right':
horizontalAlign = 1;
break;
case 'left':
case 'top-left':
case 'bottom-left':
horizontalAlign = 0;
break;
}
switch (layout['text-anchor']) {
case 'bottom':
case 'bottom-right':
case 'bottom-left':
verticalAlign = 1;
break;
case 'top':
case 'top-right':
case 'top-left':
verticalAlign = 0;
break;
}
var justify = layout['text-justify'] === 'right' ? 1 :
layout['text-justify'] === 'left' ? 0 :
0.5;
var oneEm = 24;
var lineHeight = layout['text-line-height'] * oneEm;
var maxWidth = layout['symbol-placement'] !== 'line' ? layout['text-max-width'] * oneEm : 0;
var spacing = layout['text-letter-spacing'] * oneEm;
var textOffset = [layout['text-offset'][0] * oneEm, layout['text-offset'][1] * oneEm];
var fontstack = layout['text-font'].join(',');
var geometries = [];
for (var g = 0; g < features.length; g++) {
geometries.push(loadGeometry(features[g]));
}
if (layout['symbol-placement'] === 'line') {
// Merge adjacent lines with the same text to improve labelling.
// It's better to place labels on one long line than on many short segments.
var merged = mergeLines(features, textFeatures, geometries);
geometries = merged.geometries;
features = merged.features;
textFeatures = merged.textFeatures;
}
var shapedText, shapedIcon;
for (var k = 0; k < features.length; k++) {
if (!geometries[k]) continue;
if (textFeatures[k]) {
shapedText = shapeText(textFeatures[k], stacks[fontstack], maxWidth,
lineHeight, horizontalAlign, verticalAlign, justify, spacing, textOffset);
} else {
shapedText = null;
}
if (layout['icon-image']) {
var iconName = resolveTokens(features[k].properties, layout['icon-image']);
var image = icons[iconName];
shapedIcon = shapeIcon(image, layout);
if (image) {
if (this.sdfIcons === undefined) {
this.sdfIcons = image.sdf;
} else if (this.sdfIcons !== image.sdf) {
console.warn('Style sheet warning: Cannot mix SDF and non-SDF icons in one buffer');
}
if (image.pixelRatio !== 1) {
this.iconsNeedLinear = true;
}
}
} else {
shapedIcon = null;
}
if (shapedText || shapedIcon) {
this.addFeature(geometries[k], shapedText, shapedIcon);
}
}
this.placeFeatures(collisionTile, this.buffers, this.collisionDebug);
};
SymbolBucket.prototype.addFeature = function(lines, shapedText, shapedIcon) {
var layout = this.layer.layout;
var glyphSize = 24;
var fontScale = this.adjustedTextSize / glyphSize,
textMaxSize = this.adjustedTextMaxSize !== undefined ? this.adjustedTextMaxSize : this.adjustedTextSize,
textBoxScale = this.tilePixelRatio * fontScale,
textMaxBoxScale = this.tilePixelRatio * textMaxSize / glyphSize,
iconBoxScale = this.tilePixelRatio * this.adjustedIconSize,
symbolMinDistance = this.tilePixelRatio * layout['symbol-spacing'],
avoidEdges = layout['symbol-avoid-edges'],
textPadding = layout['text-padding'] * this.tilePixelRatio,
iconPadding = layout['icon-padding'] * this.tilePixelRatio,
textMaxAngle = layout['text-max-angle'] / 180 * Math.PI,
textAlongLine = layout['text-rotation-alignment'] === 'map' && layout['symbol-placement'] === 'line',
iconAlongLine = layout['icon-rotation-alignment'] === 'map' && layout['symbol-placement'] === 'line',
mayOverlap = layout['text-allow-overlap'] || layout['icon-allow-overlap'] ||
layout['text-ignore-placement'] || layout['icon-ignore-placement'],
isLine = layout['symbol-placement'] === 'line',
textRepeatDistance = symbolMinDistance / 2;
if (isLine) {
lines = clipLine(lines, 0, 0, EXTENT, EXTENT);
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
// Calculate the anchor points around which you want to place labels
var anchors;
if (isLine) {
anchors = getAnchors(
line,
symbolMinDistance,
textMaxAngle,
shapedText,
shapedIcon,
glyphSize,
textMaxBoxScale,
this.overscaling,
EXTENT
);
} else {
anchors = [ new Anchor(line[0].x, line[0].y, 0) ];
}
// For each potential label, create the placement features used to check for collisions, and the quads use for rendering.
for (var j = 0, len = anchors.length; j < len; j++) {
var anchor = anchors[j];
if (shapedText && isLine) {
if (this.anchorIsTooClose(shapedText.text, textRepeatDistance, anchor)) {
continue;
}
}
var inside = !(anchor.x < 0 || anchor.x > EXTENT || anchor.y < 0 || anchor.y > EXTENT);
if (avoidEdges && !inside) continue;
// Normally symbol layers are drawn across tile boundaries. Only symbols
// with their anchors within the tile boundaries are added to the buffers
// to prevent symbols from being drawn twice.
//
// Symbols in layers with overlap are sorted in the y direction so that
// symbols lower on the canvas are drawn on top of symbols near the top.
// To preserve this order across tile boundaries these symbols can't
// be drawn across tile boundaries. Instead they need to be included in
// the buffers for both tiles and clipped to tile boundaries at draw time.
var addToBuffers = inside || mayOverlap;
this.symbolInstances.push(new SymbolInstance(anchor, line, shapedText, shapedIcon, layout, addToBuffers, this.symbolInstances.length,
textBoxScale, textPadding, textAlongLine,
iconBoxScale, iconPadding, iconAlongLine));
}
}
};
SymbolBucket.prototype.anchorIsTooClose = function(text, repeatDistance, anchor) {
var compareText = this.compareText;
if (!(text in compareText)) {
compareText[text] = [];
} else {
var otherAnchors = compareText[text];
for (var k = otherAnchors.length - 1; k >= 0; k--) {
if (anchor.dist(otherAnchors[k]) < repeatDistance) {
// If it's within repeatDistance of one anchor, stop looking
return true;
}
}
}
// If anchor is not within repeatDistance of any other anchor, add to array
compareText[text].push(anchor);
return false;
};
SymbolBucket.prototype.placeFeatures = function(collisionTile, buffers, collisionDebug) {
// Calculate which labels can be shown and when they can be shown and
// create the bufers used for rendering.
this.resetBuffers(buffers);
var elementGroups = this.elementGroups = {
glyph: new ElementGroups(buffers.glyphVertex, buffers.glyphElement),
icon: new ElementGroups(buffers.iconVertex, buffers.iconElement),
sdfIcons: this.sdfIcons,
iconsNeedLinear: this.iconsNeedLinear
};
var layout = this.layer.layout;
var maxScale = collisionTile.maxScale;
elementGroups.glyph.adjustedSize = this.adjustedTextSize;
elementGroups.icon.adjustedSize = this.adjustedIconSize;
// Transfer the name of the fonstack back to the main thread along with the buffers.
// The draw function needs to know which fonstack's glyph atlas to bind when rendering.
elementGroups.glyph.fontstack = layout['text-font'].join(',');
var textAlongLine = layout['text-rotation-alignment'] === 'map' && layout['symbol-placement'] === 'line';
var iconAlongLine = layout['icon-rotation-alignment'] === 'map' && layout['symbol-placement'] === 'line';
var mayOverlap = layout['text-allow-overlap'] || layout['icon-allow-overlap'] ||
layout['text-ignore-placement'] || layout['icon-ignore-placement'];
// Sort symbols by their y position on the canvas so that they lower symbols
// are drawn on top of higher symbols.
// Don't sort symbols that won't overlap because it isn't necessary and
// because it causes more labels to pop in and out when rotating.
if (mayOverlap) {
var angle = collisionTile.angle;
var sin = Math.sin(angle),
cos = Math.cos(angle);
this.symbolInstances.sort(function(a, b) {
var aRotated = (sin * a.x + cos * a.y) | 0;
var bRotated = (sin * b.x + cos * b.y) | 0;
return (aRotated - bRotated) || (b.index - a.index);
});
}
for (var p = 0; p < this.symbolInstances.length; p++) {
var symbolInstance = this.symbolInstances[p];
var hasText = symbolInstance.hasText;
var hasIcon = symbolInstance.hasIcon;
var iconWithoutText = layout['text-optional'] || !hasText,
textWithoutIcon = layout['icon-optional'] || !hasIcon;
// Calculate the scales at which the text and icon can be placed without collision.
var glyphScale = hasText ?
collisionTile.placeCollisionFeature(symbolInstance.textCollisionFeature,
layout['text-allow-overlap'], layout['symbol-avoid-edges']) :
collisionTile.minScale;
var iconScale = hasIcon ?
collisionTile.placeCollisionFeature(symbolInstance.iconCollisionFeature,
layout['icon-allow-overlap'], layout['symbol-avoid-edges']) :
collisionTile.minScale;
// Combine the scales for icons and text.
if (!iconWithoutText && !textWithoutIcon) {
iconScale = glyphScale = Math.max(iconScale, glyphScale);
} else if (!textWithoutIcon && glyphScale) {
glyphScale = Math.max(iconScale, glyphScale);
} else if (!iconWithoutText && iconScale) {
iconScale = Math.max(iconScale, glyphScale);
}
// Insert final placement into collision tree and add glyphs/icons to buffers
if (hasText) {
if (!layout['text-ignore-placement']) {
collisionTile.insertCollisionFeature(symbolInstance.textCollisionFeature, glyphScale);
}
if (glyphScale <= maxScale) {
this.addSymbols('glyph', symbolInstance.glyphQuads, glyphScale, layout['text-keep-upright'], textAlongLine, collisionTile.angle);
}
}
if (hasIcon) {
if (!layout['icon-ignore-placement']) {
collisionTile.insertCollisionFeature(symbolInstance.iconCollisionFeature, iconScale);
}
if (iconScale <= maxScale) {
this.addSymbols('icon', symbolInstance.iconQuads, iconScale, layout['icon-keep-upright'], iconAlongLine, collisionTile.angle);
}
}
}
if (collisionDebug) this.addToDebugBuffers(collisionTile);
};
SymbolBucket.prototype.addSymbols = function(shaderName, quads, scale, keepUpright, alongLine, placementAngle) {
var group = this.makeRoomFor(shaderName, 4 * quads.length);
// TODO manual curry
var addElement = this[this.getAddMethodName(shaderName, 'element')].bind(this);
var addVertex = this[this.getAddMethodName(shaderName, 'vertex')].bind(this);
var zoom = this.zoom;
var placementZoom = Math.max(Math.log(scale) / Math.LN2 + zoom, 0);
for (var k = 0; k < quads.length; k++) {
var symbol = quads[k],
angle = symbol.angle;
// drop upside down versions of glyphs
var a = (angle + placementAngle + Math.PI) % (Math.PI * 2);
if (keepUpright && alongLine && (a <= Math.PI / 2 || a > Math.PI * 3 / 2)) continue;
var tl = symbol.tl,
tr = symbol.tr,
bl = symbol.bl,
br = symbol.br,
tex = symbol.tex,
anchorPoint = symbol.anchorPoint,
minZoom = Math.max(zoom + Math.log(symbol.minScale) / Math.LN2, placementZoom),
maxZoom = Math.min(zoom + Math.log(symbol.maxScale) / Math.LN2, 25);
if (maxZoom <= minZoom) continue;
// Lower min zoom so that while fading out the label it can be shown outside of collision-free zoom levels
if (minZoom === placementZoom) minZoom = 0;
var index = addVertex(anchorPoint.x, anchorPoint.y, tl.x, tl.y, tex.x, tex.y, minZoom, maxZoom, placementZoom) - group.vertexStartIndex;
addVertex(anchorPoint.x, anchorPoint.y, tr.x, tr.y, tex.x + tex.w, tex.y, minZoom, maxZoom, placementZoom);
addVertex(anchorPoint.x, anchorPoint.y, bl.x, bl.y, tex.x, tex.y + tex.h, minZoom, maxZoom, placementZoom);
addVertex(anchorPoint.x, anchorPoint.y, br.x, br.y, tex.x + tex.w, tex.y + tex.h, minZoom, maxZoom, placementZoom);
group.vertexLength += 4;
addElement(index, index + 1, index + 2);
addElement(index + 1, index + 2, index + 3);
group.elementLength += 2;
}
};
SymbolBucket.prototype.updateIcons = function(icons) {
var iconValue = this.layer.layout['icon-image'];
if (!iconValue) return;
for (var i = 0; i < this.features.length; i++) {
var iconName = resolveTokens(this.features[i].properties, iconValue);
if (iconName)
icons[iconName] = true;
}
};
SymbolBucket.prototype.updateFont = function(stacks) {
var fontName = this.layer.layout['text-font'],
stack = stacks[fontName] = stacks[fontName] || {};
this.textFeatures = resolveText(this.features, this.layer.layout, stack);
};
SymbolBucket.prototype.addToDebugBuffers = function(collisionTile) {
this.elementGroups.collisionBox = new ElementGroups(this.buffers.collisionBoxVertex);
var group = this.makeRoomFor('collisionBox', 0);
var angle = -collisionTile.angle;
var yStretch = collisionTile.yStretch;
for (var j = 0; j < this.symbolInstances.length; j++) {
for (var i = 0; i < 2; i++) {
var feature = this.symbolInstances[j][i === 0 ? 'textCollisionFeature' : 'iconCollisionFeature'];
if (!feature) continue;
var boxes = feature.boxes;
for (var b = 0; b < boxes.length; b++) {
var box = boxes[b];
var anchorPoint = box.anchorPoint;
var tl = new Point(box.x1, box.y1 * yStretch)._rotate(angle);
var tr = new Point(box.x2, box.y1 * yStretch)._rotate(angle);
var bl = new Point(box.x1, box.y2 * yStretch)._rotate(angle);
var br = new Point(box.x2, box.y2 * yStretch)._rotate(angle);
var maxZoom = Math.max(0, Math.min(25, this.zoom + Math.log(box.maxScale) / Math.LN2));
var placementZoom = Math.max(0, Math.min(25, this.zoom + Math.log(box.placementScale) / Math.LN2));
this.addCollisionBoxVertex(anchorPoint, tl, maxZoom, placementZoom);
this.addCollisionBoxVertex(anchorPoint, tr, maxZoom, placementZoom);
this.addCollisionBoxVertex(anchorPoint, tr, maxZoom, placementZoom);
this.addCollisionBoxVertex(anchorPoint, br, maxZoom, placementZoom);
this.addCollisionBoxVertex(anchorPoint, br, maxZoom, placementZoom);
this.addCollisionBoxVertex(anchorPoint, bl, maxZoom, placementZoom);
this.addCollisionBoxVertex(anchorPoint, bl, maxZoom, placementZoom);
this.addCollisionBoxVertex(anchorPoint, tl, maxZoom, placementZoom);
group.vertexLength += 8;
}
}
}
};
function SymbolInstance(anchor, line, shapedText, shapedIcon, layout, addToBuffers, index,
textBoxScale, textPadding, textAlongLine,
iconBoxScale, iconPadding, iconAlongLine) {
this.x = anchor.x;
this.y = anchor.y;
this.index = index;
this.hasText = !!shapedText;
this.hasIcon = !!shapedIcon;
if (this.hasText) {
this.glyphQuads = addToBuffers ? getGlyphQuads(anchor, shapedText, textBoxScale, line, layout, textAlongLine) : [];
this.textCollisionFeature = new CollisionFeature(line, anchor, shapedText, textBoxScale, textPadding, textAlongLine, false);
}
if (this.hasIcon) {
this.iconQuads = addToBuffers ? getIconQuads(anchor, shapedIcon, iconBoxScale, line, layout, iconAlongLine) : [];
this.iconCollisionFeature = new CollisionFeature(line, anchor, shapedIcon, iconBoxScale, iconPadding, iconAlongLine, true);
}
}