mapbox-gl
Version:
A WebGL interactive maps library
482 lines (386 loc) • 18.7 kB
JavaScript
'use strict';
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 resolveIcons = require('../symbol/resolve_icons');
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 Point = require('point-geometry');
var CollisionFeature = require('../symbol/collision_feature');
module.exports = SymbolBucket;
function SymbolBucket(buffers, layoutProperties, overscaling, zoom, collisionDebug) {
this.buffers = buffers;
this.layoutProperties = layoutProperties;
this.overscaling = overscaling;
this.zoom = zoom;
this.collisionDebug = collisionDebug;
var tileSize = 512 * overscaling;
var tileExtent = 4096;
this.tilePixelRatio = tileExtent / tileSize;
this.compareText = {};
this.symbolInstances = [];
}
SymbolBucket.prototype.needsPlacement = true;
SymbolBucket.prototype.addFeatures = function(collisionTile) {
var layout = this.layoutProperties;
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'];
var geometries = [];
for (var g = 0; g < features.length; g++) {
geometries.push(features[g].loadGeometry());
}
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], this.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 = this.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 bucket');
}
}
} 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.layoutProperties;
var glyphSize = 24;
var fontScale = layout['text-max-size'] / glyphSize,
textBoxScale = this.tilePixelRatio * fontScale,
iconBoxScale = this.tilePixelRatio * layout['icon-max-size'],
symbolMinDistance = this.tilePixelRatio * layout['symbol-min-distance'],
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, 4096, 4096);
}
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 = isLine ?
getAnchors(line, symbolMinDistance, textMaxAngle, shapedText, shapedIcon, glyphSize, textBoxScale, this.overscaling) :
[ 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 > 4096 || anchor.y < 0 || anchor.y > 4096);
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,
textBoxScale, textPadding, textAlongLine,
iconBoxScale, iconPadding, iconAlongLine));
}
}
};
// Check if any other anchors with the same text are closer than repeatDistance
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.buffers = buffers;
var elementGroups = this.elementGroups = {
text: new ElementGroups(buffers.glyphVertex, buffers.glyphElement),
icon: new ElementGroups(buffers.iconVertex, buffers.iconElement),
sdfIcons: this.sdfIcons
};
var layout = this.layoutProperties;
var maxScale = collisionTile.maxScale;
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;
var bRotated = sin * b.x + cos * b.y;
return bRotated - aRotated;
});
}
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 && !layout['text-allow-overlap'] ?
collisionTile.placeCollisionFeature(symbolInstance.textCollisionFeature) :
collisionTile.minScale;
var iconScale = hasIcon && !layout['icon-allow-overlap'] ?
collisionTile.placeCollisionFeature(symbolInstance.iconCollisionFeature) :
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(buffers.glyphVertex, buffers.glyphElement, elementGroups.text,
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(buffers.iconVertex, buffers.iconElement, elementGroups.icon,
symbolInstance.iconQuads, iconScale, layout['icon-keep-upright'], iconAlongLine,
collisionTile.angle);
}
}
}
if (collisionDebug) this.addToDebugBuffers(collisionTile);
};
SymbolBucket.prototype.addSymbols = function(vertex, element, elementGroups, quads, scale, keepUpright, alongLine, placementAngle) {
elementGroups.makeRoomFor(4 * quads.length);
var elementGroup = elementGroups.current;
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 triangleIndex = vertex.index - elementGroup.vertexStartIndex;
vertex.add(anchorPoint.x, anchorPoint.y, tl.x, tl.y, tex.x, tex.y, minZoom, maxZoom, placementZoom);
vertex.add(anchorPoint.x, anchorPoint.y, tr.x, tr.y, tex.x + tex.w, tex.y, minZoom, maxZoom, placementZoom);
vertex.add(anchorPoint.x, anchorPoint.y, bl.x, bl.y, tex.x, tex.y + tex.h, minZoom, maxZoom, placementZoom);
vertex.add(anchorPoint.x, anchorPoint.y, br.x, br.y, tex.x + tex.w, tex.y + tex.h, minZoom, maxZoom, placementZoom);
elementGroup.vertexLength += 4;
element.add(triangleIndex, triangleIndex + 1, triangleIndex + 2);
element.add(triangleIndex + 1, triangleIndex + 2, triangleIndex + 3);
elementGroup.elementLength += 2;
}
};
SymbolBucket.prototype.getDependencies = function(tile, actor, callback) {
var firstdone = false;
this.getTextDependencies(tile, actor, done);
this.getIconDependencies(tile, actor, done);
function done(err) {
if (err || firstdone) return callback(err);
firstdone = true;
}
};
SymbolBucket.prototype.getIconDependencies = function(tile, actor, callback) {
if (this.layoutProperties['icon-image']) {
var features = this.features;
var icons = resolveIcons(features, this.layoutProperties);
if (icons.length) {
actor.send('get icons', { icons: icons }, setIcons.bind(this));
} else {
callback();
}
} else {
callback();
}
function setIcons(err, newicons) {
if (err) return callback(err);
this.icons = newicons;
callback();
}
};
SymbolBucket.prototype.getTextDependencies = function(tile, actor, callback) {
var features = this.features;
var fontstack = this.layoutProperties['text-font'];
var stacks = this.stacks = tile.stacks;
if (stacks[fontstack] === undefined) {
stacks[fontstack] = {};
}
var stack = stacks[fontstack];
var data = resolveText(features, this.layoutProperties, stack);
this.textFeatures = data.textFeatures;
actor.send('get glyphs', {
uid: tile.uid,
fontstack: fontstack,
codepoints: data.codepoints
}, function(err, newstack) {
if (err) return callback(err);
for (var codepoint in newstack) {
stack[codepoint] = newstack[codepoint];
}
callback();
});
};
SymbolBucket.prototype.addToDebugBuffers = function(collisionTile) {
this.elementGroups.collisionBox = new ElementGroups(this.buffers.collisionBoxVertex);
this.elementGroups.collisionBox.makeRoomFor(0);
var buffer = this.buffers.collisionBoxVertex;
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));
buffer.add(anchorPoint, tl, maxZoom, placementZoom);
buffer.add(anchorPoint, tr, maxZoom, placementZoom);
buffer.add(anchorPoint, tr, maxZoom, placementZoom);
buffer.add(anchorPoint, br, maxZoom, placementZoom);
buffer.add(anchorPoint, br, maxZoom, placementZoom);
buffer.add(anchorPoint, bl, maxZoom, placementZoom);
buffer.add(anchorPoint, bl, maxZoom, placementZoom);
buffer.add(anchorPoint, tl, maxZoom, placementZoom);
this.elementGroups.collisionBox.current.vertexLength += 8;
}
}
}
};
function SymbolInstance(anchor, line, shapedText, shapedIcon, layout, addToBuffers,
textBoxScale, textPadding, textAlongLine,
iconBoxScale, iconPadding, iconAlongLine) {
this.x = anchor.x;
this.y = anchor.y;
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);
}
if (this.hasIcon) {
this.iconQuads = addToBuffers ? getIconQuads(anchor, shapedIcon, iconBoxScale, line, layout, iconAlongLine) : [];
this.iconCollisionFeature = new CollisionFeature(line, anchor, shapedIcon, iconBoxScale, iconPadding, iconAlongLine);
}
}