tangram
Version:
WebGL Maps for Vector Tiles
385 lines (320 loc) • 15.1 kB
JavaScript
// Text label rendering methods, can be mixed into a rendering style
import StyleParser from '../style_parser';
import Geo from '../../utils/geo';
import log from '../../utils/log';
import Thread from '../../utils/thread';
import WorkerBroker from '../../utils/worker_broker';
import Collision from '../../labels/collision';
import TextSettings from '../text/text_settings';
import TextCanvas from './text_canvas';
// namespaces label textures (ensures new texture name when a tile is built multiple times)
let text_texture_id = 0;
export const TextLabels = {
resetText () {
if (Thread.is_main) {
this.canvas = new TextCanvas();
}
else if (Thread.is_worker) {
this.texts = {}; // unique texts, grouped by tile, by style
}
},
freeText (tile) {
delete this.texts[tile.id];
},
parseTextFeature (feature, draw, context, tile) {
// Compute label text
let text = this.parseTextSource(feature, draw, context);
if (text == null || text === '') {
return; // no text for this feature
}
// Compute text style and layout settings for this feature label
let text_settings = TextSettings.compute(draw, context);
let text_settings_key = TextSettings.key(text_settings);
// first label in tile, or with this style?
this.texts[tile.id] = this.texts[tile.id] || {};
let sizes = this.texts[tile.id][text_settings_key] = this.texts[tile.id][text_settings_key] || {};
if (text instanceof Object){
let results = [];
// add both left/right text elements to repeat group to improve repeat culling
// avoids one component of a boundary label (e.g. Colorado) being culled too aggressively when it also
// appears in nearby boundary labels (e.g. Colorado/Utah & Colorado/New Mexico repeat as separate groups)
let repeat_group_prefix = text.left + '-' + text.right; // NB: should be all text keys, not just left/right
for (let key in text){
let current_text = text[key];
if (!current_text) {
continue;
}
let layout = this.computeTextLayout({}, feature, draw, context, tile, current_text, text_settings, repeat_group_prefix, key);
if (!sizes[current_text]) {
// first label with this text/style/tile combination, make a new label entry
sizes[current_text] = {
text_settings,
ref: 0 // # of times this text/style combo appears in tile
};
}
results.push({
draw, text : current_text, text_settings_key, layout
});
}
return (results.length > 0 && results); // return null if no boundary labels found
}
else {
// unique text strings, grouped by text drawing style
let layout = this.computeTextLayout({}, feature, draw, context, tile, text, text_settings);
if (!sizes[text]) {
// first label with this text/style/tile combination, make a new label entry
sizes[text] = {
text_settings,
ref: 0 // # of times this text/style combo appears in tile
};
}
return {
draw, text, text_settings_key, layout
};
}
},
// Compute the label text, default is value of feature.properties.name
// - String value indicates a feature property look-up, e.g. `short_name` means use feature.properties.short_name
// - Function will use the return value as the label text (for custom labels)
// - Array (of strings and/or functions) defines a list of fallbacks, evaluated according to the above rules,
// with the first non-null value used as the label text
// e.g. `[name:es, name:en, name]` prefers Spanish names, followed by English, and last the default local name
parseTextSource (feature, draw, context) {
let text;
let source = draw.text_source || 'name';
if (source != null && !Array.isArray(source) && typeof source === 'object') {
// left/right boundary labels
text = {};
for (let key in source) {
text[key] = this.parseTextSourceValue(source[key], feature, context);
}
}
else {
// single label
text = this.parseTextSourceValue(source, feature, context);
}
return text;
},
parseTextSourceValue (source, feature, context) {
let text;
if (Array.isArray(source)) {
for (let s=0; s < source.length; s++) {
if (typeof source[s] === 'string') {
text = feature.properties[source[s]];
} else if (typeof source[s] === 'function') {
text = source[s](context);
}
if (text) {
return text; // stop if we found a text property
}
}
}
else if (typeof source === 'string') {
text = feature.properties[source];
}
else if (source instanceof Function) {
text = source(context);
}
return text;
},
async prepareTextLabels (tile, queue) {
if (Object.keys(this.texts[tile.id]||{}).length === 0) {
return [];
}
// first call to main thread, ask for text pixel sizes
try {
const texts = await WorkerBroker.postMessage(this.main_thread_target+'.calcTextSizes', tile.id, this.texts[tile.id]);
if (tile.canceled) {
log('trace', `Style ${this.name}: stop tile build because tile was canceled: ${tile.key}, post-calcTextSizes()`);
return [];
}
this.texts[tile.id] = texts || [];
if (!texts) {
Collision.abortTile(tile.id);
return [];
}
return this.buildTextLabels(tile, queue);
}
catch (e) { // error thrown if style has been removed from main thread
Collision.abortTile(tile.id);
return [];
}
},
async collideAndRenderTextLabels (tile, collision_group, queue) {
let labels = await this.prepareTextLabels(tile, queue);
if (labels.length === 0) {
Collision.collide([], collision_group, tile.id);
return {};
}
labels = await Collision.collide(labels, collision_group, tile.id);
if (tile.canceled) {
log('trace', `stop tile build because tile was canceled: ${tile.key}, post-collide()`);
return {};
}
let texts = this.texts[tile.id];
if (texts == null || labels.length === 0) {
return {};
}
this.cullTextStyles(texts, labels);
// set alignments
labels.forEach(q => {
let text_settings_key = q.text_settings_key;
let text_info = texts[text_settings_key] && texts[text_settings_key][q.text];
if (!text_info.text_settings.can_articulate){
text_info.align = text_info.align || {};
text_info.align[q.label.align] = {};
}
else {
// consider making it a set
if (!text_info.type) {
text_info.type = [];
}
if (text_info.type.indexOf(q.label.type) === -1){
text_info.type.push(q.label.type);
}
}
});
// second call to main thread, for rasterizing the set of texts
try {
const rasterized = await WorkerBroker.postMessage(this.main_thread_target+'.rasterizeTexts', tile.id, tile.key, texts);
if (tile.canceled) {
log('trace', `stop tile build because tile was canceled: ${tile.key}, post-rasterizeTexts()`);
return {};
}
return { labels, ...rasterized };
}
catch (e) { // error thrown if style has been removed from main thread
return {};
}
},
// Remove unused text/style combinations to avoid unnecessary rasterization
cullTextStyles(texts, labels) {
// Count how many times each text/style combination is used
for (let i=0; i < labels.length; i++) {
let label = labels[i];
texts[label.text_settings_key][label.text].ref++;
}
// Remove text/style combinations that have no visible labels
for (let style in texts) {
for (let text in texts[style]) {
// no labels for this text
if (texts[style][text].ref < 1) {
delete texts[style][text];
}
}
}
for (let style in texts) {
// no labels for this style
if (Object.keys(texts[style]).length === 0) {
delete texts[style];
}
}
},
// Called on main thread from worker, to compute the size of each text string,
// were it to be rendered. This info is then used to perform initial label culling, *before*
// labels are actually rendered.
calcTextSizes (tile_id, texts) {
return this.canvas.textSizes(tile_id, texts);
},
// Called on main thread from worker, to create atlas of labels for a tile
async rasterizeTexts (tile_id, tile_key, texts) {
let canvas = new TextCanvas(); // one per style per tile (style may be rendering multiple tiles at once)
let max_texture_size = Math.min(this.max_texture_size, 2048); // cap each label texture at 2048x2048
let textures = canvas.setTextureTextPositions(texts, max_texture_size);
let texture_prefix = ['labels', this.name, tile_key, tile_id, text_texture_id, ''].join('-');
text_texture_id++;
textures = await canvas.rasterize(texts, textures, tile_id, texture_prefix, this.gl);
if (!textures) {
return {};
}
return { texts, textures };
},
preprocessText (draw) {
// Font settings are required
if (!draw || !draw.font || typeof draw.font !== 'object') {
return;
}
// Font weight
draw.font.weight = StyleParser.createPropertyCache(draw.font.weight);
// Colors
draw.font.fill = StyleParser.createPropertyCache(draw.font.fill || TextSettings.defaults.fill);
draw.font.alpha = StyleParser.createPropertyCache(draw.font.alpha);
if (draw.font.stroke) {
draw.font.stroke.color = StyleParser.createPropertyCache(draw.font.stroke.color);
draw.font.stroke.alpha = StyleParser.createPropertyCache(draw.font.stroke.alpha);
}
if (draw.font.background) {
draw.font.background.color = StyleParser.createPropertyCache(draw.font.background.color);
draw.font.background.alpha = StyleParser.createPropertyCache(draw.font.background.alpha);
draw.font.background.width = StyleParser.createPropertyCache(draw.font.background.width, StyleParser.parsePositiveNumber);
if (draw.font.background.stroke) {
draw.font.background.stroke.color = StyleParser.createPropertyCache(draw.font.background.stroke.color);
draw.font.background.stroke.alpha = StyleParser.createPropertyCache(draw.font.background.stroke.alpha);
}
}
// Convert font and text stroke sizes
draw.font.px_size = StyleParser.createPropertyCache(draw.font.size || TextSettings.defaults.size, TextCanvas.fontPixelSize, TextCanvas.fontPixelSize);
if (draw.font.stroke && draw.font.stroke.width != null) {
draw.font.stroke.width = StyleParser.createPropertyCache(draw.font.stroke.width, StyleParser.parsePositiveNumber);
}
if (draw.font.background && draw.font.background.stroke && draw.font.background.stroke.width != null) {
draw.font.background.stroke.width = StyleParser.createPropertyCache(draw.font.background.stroke.width, StyleParser.parsePositiveNumber);
}
// Offset (2d array)
draw.offset = StyleParser.createPropertyCache(draw.offset,
v => Array.isArray(v) && v.map(StyleParser.parseNumber)
);
// Buffer (1d value or or 2d array) - must be >= 0
draw.buffer = StyleParser.createPropertyCache(draw.buffer,
v => (Array.isArray(v) ? v : [v, v]).map(StyleParser.parsePositiveNumber)
);
// Repeat rules - for text labels, defaults to tile size
draw.repeat_distance = StyleParser.createPropertyCache(
draw.repeat_distance,
StyleParser.parsePositiveNumber
);
return draw;
},
// Additional text-specific layout settings
computeTextLayout (target, feature, draw, context, tile, text, text_settings, repeat_group_prefix, orientation) {
let layout = target || {};
// common settings w/points
layout = this.computeLayout(layout, feature, draw, context, tile);
// if draw group didn't specify repeat distance, override with text label-specific logic
if (draw.repeat_distance == null) {
// defaults: no limit on labels for point geometries, tile size (256px) limit for other geometries
layout.repeat_distance = (context.geometry === 'point' ? 0 : Geo.tile_size);
if (layout.repeat_distance) {
layout.repeat_distance *= layout.units_per_pixel;
layout.repeat_scale = 1; // initial repeat pass in tile with full scale
if (typeof draw.repeat_group === 'function') {
layout.repeat_group = draw.repeat_group(context); // dynamic repeat group
}
else {
layout.repeat_group = draw.repeat_group; // pre-computed repeat group
}
}
}
// repeat rules include the text
if (layout.repeat_distance) {
if (repeat_group_prefix) {
layout.repeat_group += '/' + repeat_group_prefix;
}
layout.repeat_group += '/' + text;
}
// Max number of subdivisions to try
layout.subdiv = tile.overzoom2;
layout.align = draw.align;
// used to fudge width value as text may overflow bounding box if it has italic, bold, etc style
// TODO rename to more generic, not italic-specific (bold)
layout.italic = (text_settings.style !== 'normal');
// used to determine orientation of text if the text_source has a `left` or `right` key
if (orientation === 'right') {
layout.orientation = 1;
}
else if (orientation === 'left'){
layout.orientation = -1;
}
return layout;
}
};