tangram
Version:
WebGL Maps for Vector Tiles
749 lines (614 loc) • 28.5 kB
JavaScript
import Label, {textLayoutToJSON} from './label';
import Vector from '../utils/vector';
import OBB from '../utils/obb';
const STOPS = [0, 0.33, 0.66, 0.99]; // zoom levels for curved label snapshot data (offsets and angles)
const LINE_EXCEED_STRAIGHT = 1.5; // minimal ratio for straight labels (label length) / (line length)
const LINE_EXCEED_STRAIGHT_NO_CURVE = 1.8; // minimal ratio for straight labels that have no curved option (like Arabic)
const LINE_EXCEED_STAIGHT_LOOSE = 2.3; // 2nd pass minimal ratio for straight labels
const STRAIGHT_ANGLE_TOLERANCE = 0.1; // multiple "almost straight" segments within this angle tolerance can be considered one straight segment (in radians)
const CURVE_MIN_TOTAL_COST = 1.3; // curved line total curvature tolerance (sum in radians)
const CURVE_MIN_AVG_COST = 0.4; // curved line average curvature tolerance (mean)
const CURVE_MAX_ANGLE = 1; // curved line singular curvature tolerance (value in radians)
const ORIENTED_LABEL_OFFSET_FACTOR = 1.2; // multiply offset by this amount to avoid linked label collision
const VERTICAL_ANGLE_TOLERANCE = 0.01; // nearly vertical lines considered vertical within this angle tolerance
let LabelLine = {
// Given a label's bounding box size and size of broken up individual segments
// return a label that fits along the line geometry that is either straight (preferred) or curved (if straight tolerances aren't met)
create : function(segment_sizes, total_size, line, layout){
// The passes done for fitting a label, and provided tolerances for each pass
// First straight is chosen with a low tolerance. Then curved. Then straight with a higher tolerance.
const passes = [
{ type: 'straight', tolerance : (layout.no_curving) ? LINE_EXCEED_STRAIGHT_NO_CURVE : LINE_EXCEED_STRAIGHT },
{ type: 'curved' },
{ type: 'straight', tolerance : LINE_EXCEED_STAIGHT_LOOSE }
];
// loop through passes. first label found wins.
for (let i = 0; i < passes.length; i++){
let check = passes[i];
let label;
if (check.type === 'straight'){
label = new LabelLineStraight(total_size, line, layout, check.tolerance);
}
else if (check.type === 'curved' && !layout.no_curving && line.length > 2){
label = new LabelLineCurved(segment_sizes, line, layout);
}
if (label && !label.throw_away) {
return label;
}
}
return false;
}
};
export default LabelLine;
// Base class for a labels.
export class LabelLineBase {
constructor (layout) {
this.id = Label.nextLabelId();
this.layout = layout;
this.position = [];
this.angle = 0;
this.offset = layout.offset.slice();
this.unit_scale = this.layout.units_per_pixel;
this.obbs = [];
this.aabbs = [];
this.type = ''; // "curved" or "straight" to be set by child class
this.throw_away = false; // boolean that determines if label should be discarded
}
// Minimal representation of label
toJSON () {
return {
id: this.id,
type: this.type,
position: this.position,
size: this.size,
offset: this.offset,
angle: this.angle,
breach: this.breach,
may_repeat_across_tiles: this.may_repeat_across_tiles,
layout: textLayoutToJSON(this.layout)
};
}
// Given a line, find the longest series of segments that maintains a constant orientation in the x-direction.
// This assures us that the line has no orientation flip, so text would not appear upside-down.
// If the line's orientation is reversed, the flip return value will be true, otherwise false
static splitLineByOrientation(line){
let current_line = [line[0]];
let current_length = 0;
let max_length = 0;
let orientation = 0;
let longest_line = current_line;
let flip = false;
for (let i = 1; i < line.length; i++) {
let pt = line[i];
let prev_pt = line[i - 1];
let length = Vector.length(Vector.sub(pt, prev_pt));
if (pt[0] > prev_pt[0]){
// positive orientation
if (orientation === 1){
current_line.push(pt);
current_length += length;
if (current_length > max_length){
longest_line = current_line;
max_length = current_length;
flip = false;
}
}
else {
current_line = [prev_pt, pt];
current_length = length;
if (current_length > max_length){
longest_line = current_line;
max_length = current_length;
flip = false;
}
orientation = 1;
}
}
else if (pt[0] < prev_pt[0]) {
// negative orientation
if (orientation === -1){
current_line.unshift(pt);
current_length += length;
if (current_length > max_length){
longest_line = current_line;
max_length = current_length;
flip = true;
}
}
else {
// prepend points (reverse order)
current_line = [pt, prev_pt];
current_length = length;
if (current_length > max_length){
longest_line = current_line;
max_length = current_length;
flip = true;
}
orientation = -1;
}
}
else {
// vertical line (doesn't change previous orientation)
if (orientation === -1){
current_line.unshift(pt);
}
else {
current_line.push(pt);
orientation = 1;
}
current_length += length;
if (current_length > max_length){
longest_line = current_line;
max_length = current_length;
flip = (orientation === -1);
}
}
}
return [longest_line, flip];
}
// Checks each segment to see if it should be discarded (via collision). If any segment fails this test, they all fail.
discard(bboxes, exclude = null) {
if (this.throw_away) {
return true;
}
for (let i = 0; i < this.obbs.length; i++){
let aabb = this.aabbs[i];
let obb = this.obbs[i];
let obj = { aabb, obb };
let shouldDiscard = Label.prototype.occluded.call(obj, bboxes, exclude);
if (shouldDiscard) {
return true;
}
}
return false;
}
// Checks each segment to see if it is within the tile. If any segment fails this test, they all fail.
inTileBounds() {
for (let i = 0; i < this.aabbs.length; i++) {
let aabb = this.aabbs[i];
let obj = { aabb };
let in_bounds = Label.prototype.inTileBounds.call(obj);
if (!in_bounds) {
return false;
}
}
return true;
}
// Method to calculate oriented bounding box
// "angle" is the angle of the text segment, "angle_offset" is the angle applied to the offset.
// Offset angle is constant for the entire label, while segment angles are not.
static createOBB (position, width, height, angle, angle_offset, offset, upp) {
let p0 = position[0];
let p1 = position[1];
// apply offset, x positive, y pointing down
if (offset && (offset[0] !== 0 || offset[1] !== 0)) {
offset = Vector.rot(offset, angle_offset);
p0 += offset[0] * upp;
p1 -= offset[1] * upp;
}
// the angle of the obb is negative since it's the tile system y axis is pointing down
return new OBB(p0, p1, -angle, width, height);
}
}
// Class for straight labels.
// Extends base LabelLine class.
export class LabelLineStraight extends LabelLineBase {
constructor (size, line, layout, tolerance){
super(layout);
this.type = 'straight';
this.size = size;
this.throw_away = !this.fit(size, line, layout, tolerance);
}
// Determine if the label can fit the geometry within provided tolerance
// A straight label is generally placed at segment midpoints, but can "look ahead" to further segments
// if they are within an angle bound given by STRAIGHT_ANGLE_TOLERANCE and place at the midpoint between non-consecutive segments
fit (size, line, layout, tolerance){
let upp = this.unit_scale;
let flipped; // boolean indicating if orientation of line is changed
// Make new copy of line, with consistent orientation
[line, flipped] = LabelLineBase.splitLineByOrientation(line);
// matches for "left" or "right" labels where the offset angle is dependent on the geometry
if (typeof layout.orientation === 'number'){
this.offset[1] += ORIENTED_LABEL_OFFSET_FACTOR * (size[1] - layout.vertical_buffer);
// if line is flipped, or the orientation is "left" (-1), flip the offset's y-axis
if (flipped){
this.offset[1] *= -1;
}
if (layout.orientation === -1){
this.offset[1] *= -1;
}
}
let line_lengths = getLineLengths(line);
let label_length = size[0] * upp;
// loop through line looking for a placement for the label
for (let i = 0; i < line.length - 1; i++){
let curr = line[i];
let curve_tolerance = 0;
let length = 0;
let ahead_index = i + 1;
let prev_angle;
// look ahead to further line segments within an angle tolerance
while (ahead_index < line.length){
let ahead_curr = line[ahead_index - 1];
let ahead_next = line[ahead_index];
let next_angle = getAngleForSegment(ahead_curr, ahead_next);
if (ahead_index !== i + 1){
curve_tolerance += getAbsAngleDiff(next_angle, prev_angle);
}
// if curve tolerance is exceeded, break out of loop
if (Math.abs(curve_tolerance) > STRAIGHT_ANGLE_TOLERANCE){
break;
}
length += line_lengths[ahead_index - 1];
// check if label fits geometry
if (calcFitness(length, label_length) < tolerance){
let curr_midpt = Vector.mult(Vector.add(curr, ahead_next), 0.5);
// TODO: modify angle if line chosen within curve_angle_tolerance
// Currently line angle is the same as the starting angle, perhaps it should average across segments?
this.angle = -next_angle;
// ensure that all vertical labels point up (not down) by snapping angles close to pi/2 to -pi/2
if (Math.abs(this.angle - Math.PI/2) < VERTICAL_ANGLE_TOLERANCE) {
// flip angle and offset
this.angle = -Math.PI/2;
if (typeof layout.orientation === 'number'){
this.offset[1] *= -1;
}
}
this.position = curr_midpt;
this.updateBBoxes(this.position, size, this.angle, this.angle, this.offset);
return true; // use this placement
}
prev_angle = next_angle;
ahead_index++;
}
}
return false;
}
// Calculate bounding boxes
updateBBoxes(position, size, angle, angle_offset, offset) {
let upp = this.unit_scale;
// reset bounding boxes
this.obbs = [];
this.aabbs = [];
let width = (size[0] + 2 * this.layout.buffer[0]) * upp * Label.epsilon;
let height = (size[1] + 2 * this.layout.buffer[1]) * upp * Label.epsilon;
let obb = LabelLineBase.createOBB(position, width, height, angle, angle_offset, offset, upp);
let aabb = obb.getExtent();
this.obbs.push(obb);
this.aabbs.push(aabb);
if (this.inTileBounds) {
this.breach = !this.inTileBounds();
}
if (this.mayRepeatAcrossTiles) {
this.may_repeat_across_tiles = this.mayRepeatAcrossTiles();
}
}
}
// Class for curved labels
// Extends base LabelLine class to support angles, pre_angles, offsets as arrays for each segment
class LabelLineCurved extends LabelLineBase {
constructor (segment_sizes, line, layout) {
super(layout);
this.type = 'curved';
// extra data for curved labels
this.angles = [];
this.pre_angles = [];
this.offsets = [];
this.num_segments = segment_sizes.length;
this.sizes = segment_sizes;
this.throw_away = !this.fit(this.sizes, line, layout);
}
// Minimal representation of label
toJSON () {
return {
id: this.id,
type: this.type,
obbs: this.obbs.map(o => o.toJSON()),
position: this.position,
breach: this.breach,
may_repeat_across_tiles: this.may_repeat_across_tiles,
layout: textLayoutToJSON(this.layout)
};
}
// Determine if the curved label can fit the geometry.
// No tolerance is provided because the label must fit entirely within the line geometry.
fit (size, line, layout){
let upp = this.unit_scale;
let flipped; // boolean determining if the line orientation is reversed
let height_px = Math.max(...size.map(s => s[1])); // use max segment height
let height = height_px * upp;
// Make new copy of line, with consistent orientation
[line, flipped] = LabelLineBase.splitLineByOrientation(line);
// matches for "left" or "right" labels where the offset angle is dependent on the geometry
if (typeof layout.orientation === 'number'){
this.offset[1] += ORIENTED_LABEL_OFFSET_FACTOR * (height_px - layout.vertical_buffer);
// if line is flipped, or the orientation is "left" (-1), flip the offset's y-axis
if (flipped){
this.offset[1] *= -1;
}
if (layout.orientation === -1){
this.offset[1] *= -1;
}
}
let line_lengths = getLineLengths(line);
let label_lengths = size.map(size => size[0] * upp);
let total_line_length = line_lengths.reduce((prev, next) => prev + next, 0);
let total_label_length = label_lengths.reduce((prev, next) => prev + next, 0);
// if label displacement is longer than the line, no fit can be possible
if (total_label_length > total_line_length){
return false;
}
// need two line segments for a curved label
// NB: single segment lines should still be labeled if possible during straight label placement pass
let start_index = 0, end_index = line.length-1;
if (end_index - start_index < 2){
return false;
}
// all positional offsets of the label are relative to the anchor
let anchor_index = LabelLineCurved.curvaturePlacement(line, total_line_length, line_lengths, total_label_length, start_index, end_index);
let anchor = line[anchor_index];
// if anchor not found, or greater than the end_index, no fit possible
if (anchor_index === -1 || end_index - anchor_index < 2){
return false;
}
// set start position at anchor position
this.position = anchor;
// Loop through labels at each zoom level stop
// TODO: Can be made faster since we are computing every segment for every zoom stop
// We can skip a segment's calculation once a segment's angle equals its fully zoomed angle
for (var i = 0; i < label_lengths.length; i++){
this.offsets[i] = [];
this.angles[i] = [];
this.pre_angles[i] = [];
// loop through stops (z = [0, .33, .66, .99] + base zoom)
for (var j = 0; j < STOPS.length; j++){
let stop = STOPS[j];
// scale the line geometry by the zoom magnification
let [new_line, line_lengths] = LabelLineCurved.scaleLine(stop, line);
anchor = new_line[anchor_index];
// calculate label data relative to anchor position
let {positions, offsets, angles, pre_angles} = LabelLineCurved.placeAtIndex(anchor_index, new_line, line_lengths, label_lengths);
// translate 2D offsets into "polar coordinates"" (1D distances with angles)
let offsets1d = offsets.map(offset => {
return Math.sqrt(offset[0] * offset[0] + offset[1] * offset[1]) / upp;
});
// Calculate everything that is independent of zoom level (angle for offset, bounding boxes, etc)
if (stop === 0){
// use average angle for a global label offset (if offset is specified)
this.angle = 1 / angles.length * angles.reduce((prev, next) => prev + next);
// calculate bounding boxes for collision at zoom level 0
for (let i = 0; i < positions.length; i++){
let position = positions[i];
let pre_angle = pre_angles[i];
let width = label_lengths[i];
let angle_segment = pre_angle + angles[i];
let angle_offset = this.angle;
let obb = LabelLineBase.createOBB(position, width, height, angle_segment, angle_offset, this.offset, upp);
let aabb = obb.getExtent();
this.obbs.push(obb);
this.aabbs.push(aabb);
}
}
// push offsets/angles/pre_angles for each zoom and for each label segment
this.offsets[i].push(offsets1d[i]);
this.angles[i].push(angles[i]);
this.pre_angles[i].push(pre_angles[i]);
}
}
return true;
}
// Find optimal starting segment for placing a curved label along a line within provided tolerances
// This is determined by calculating the curvature at each interior vertex of a line
// then construct a "window" whose breadth is the length of the label. Place this label at each vertex
// and add the curvatures of each vertex within the window. The vertex mimimizing this value is the "best" placement.
// Return -1 is no placement found.
static curvaturePlacement(line, total_line_length, line_lengths, label_length, start_index, end_index){
start_index = start_index || 0;
end_index = end_index || line.length - 1;
var curvatures = []; // array of curvature values per line vertex
// calculate curvature values
for (let i = start_index + 1; i < end_index; i++){
var prev = line[i - 1];
var curr = line[i];
var next = line[i + 1];
var norm_1 = Vector.perp(curr, prev);
var norm_2 = Vector.perp(next, curr);
var curvature = Vector.angleBetween(norm_1, norm_2);
// If curvature at a vertex is greater than the tolerance, remove it from consideration
// by giving it an infinite penalty
if (curvature > CURVE_MAX_ANGLE) {
curvature = Infinity;
}
curvatures.push(curvature);
}
curvatures.push(Infinity); // Infinite penalty for going off end of line
// calculate curvature costs
var total_costs = [];
var avg_costs = [];
var line_index = start_index;
var position = 0;
for (let i = 0; i < start_index; i++){
position += line_lengths[i];
}
// move window along line, starting at first vertex
while (position + label_length < total_line_length){
// define window breadth
var window_start = position;
var window_end = window_start + label_length;
var line_position = window_start;
var ahead_index = line_index;
var cost = 0;
// iterate through points on line intersecting window
while (ahead_index < end_index && line_position + line_lengths[ahead_index] < window_end){
cost += curvatures[ahead_index];
if (cost === Infinity) {
break; // no further progress can be made
}
line_position += line_lengths[ahead_index];
ahead_index++;
}
// if optimal cost, break out
if (cost === 0) {
return line_index;
}
var avg_cost = cost / (ahead_index - line_index);
total_costs.push(cost);
avg_costs.push(avg_cost);
position += line_lengths[line_index];
line_index++;
}
if (total_costs.length === 0) {
return -1;
}
// calculate min cost and avg cost to determine if label can fit within curvatures tolerances
var min_total_cost = Math.min.apply(null, total_costs);
var min_index = total_costs.indexOf(min_total_cost);
var min_avg_cost = avg_costs[min_index];
if (min_total_cost < CURVE_MIN_TOTAL_COST && min_avg_cost < CURVE_MIN_AVG_COST){
// return index with best placement (least curvature)
return total_costs.indexOf(min_total_cost);
}
else {
// if tolerances aren't satisfied, throw away tile
return -1;
}
}
// Scale the line by a scale factor (used for computing the angles and offsets at fractional zoom levels)
// Return the new line positions and their lengths
static scaleLine(scale, line){
var new_line = [line[0]];
var line_lengths = [];
line.forEach((pt, i) => {
if (i === line.length - 1) {
return;
}
var v = Vector.sub(line[i+1], line[i]);
var delta = Vector.mult(v, 1 + scale);
new_line.push(Vector.add(new_line[i], delta));
line_lengths.push(Vector.length(delta));
});
return [new_line, line_lengths];
}
// Place a label at a given line index
static placeAtIndex(anchor_index, line, line_lengths, label_lengths){
let anchor = line[anchor_index];
// Use flat coordinates. Get nearest line vertex index, and offset from the vertex for all labels.
let [indices, relative_offsets] = LabelLineCurved.getIndicesAndOffsets(anchor_index, line_lengths, label_lengths);
// get 2D positions based on "flat" indices and offsets
let positions = LabelLineCurved.getPositionsFromIndicesAndOffsets(line, indices, relative_offsets);
// get 2d offsets, angles and pre_angles relative to anchor
let [offsets, angles, pre_angles] = LabelLineCurved.getAnglesFromIndicesAndOffsets(anchor, indices, line, positions);
return {positions, offsets, angles, pre_angles};
}
// Given label lengths to place along a line broken into several lengths, computer what indices and at which offsets
// the labels will appear on the line. Assume the line is straight, as it is not necessary to consider angles.
//
// Label lengths:
// |-----|----|-----|-----------------|-------------|
//
// Line Lengths;
// |---------|---------|-------------|------------|----------|-------|
//
// Result: indices: [0,0,1,1,3,4]
static getIndicesAndOffsets(line_index, line_lengths, label_lengths){
let num_labels = label_lengths.length;
let indices = [];
let offsets = [];
let label_index = 0;
let label_offset = 0;
let line_offset = 0;
// iterate along line
while (label_index < num_labels){
let label_length = label_lengths[label_index];
// iterate along labels within the line segment
while (label_index < num_labels && label_offset + 0.5 * label_length <= line_offset + line_lengths[line_index]){
let offset = label_offset - line_offset + 0.5 * label_length;
offsets.push(offset);
indices.push(line_index);
label_offset += label_length;
label_index++;
label_length = label_lengths[label_index];
}
line_offset += line_lengths[line_index];
line_index++;
}
return [indices, offsets];
}
// Given indices and 1D offsets on a line, compute their 2D positions
static getPositionsFromIndicesAndOffsets(line, indices, offsets){
let positions = [];
for (let i = 0; i < indices.length; i++){
let index = indices[i];
let offset = offsets[i];
let angle = getAngleForSegment(line[index], line[index + 1]);
let offset2d = Vector.rot([offset, 0], angle);
let position = Vector.add(line[index], offset2d);
positions.push(position);
}
return positions;
}
// Given indices and 1D offsets on a line, compute their angles and pre-angles from a reference anchor point
static getAnglesFromIndicesAndOffsets(anchor, indices, line, positions){
let angles = [];
let pre_angles = [];
let offsets = [];
for (let i = 0; i < positions.length; i++){
let position = positions[i];
let index = indices[i];
let offset = Vector.sub(position, anchor);
let offset_angle = -Vector.angle(offset);
let angle = getTextAngleForSegment(line[index], line[index + 1]);
let pre_angle = angle - offset_angle;
if (i > 0){
let prev_angle = angles[i - 1];
let prev_pre_angle = pre_angles[i - 1];
if (Math.abs(offset_angle - prev_angle) > Math.PI) {
offset_angle += (offset_angle > prev_angle) ? -2 * Math.PI : 2 * Math.PI;
}
if (Math.abs(prev_pre_angle - pre_angle) > Math.PI) {
pre_angle += (pre_angle > prev_pre_angle) ? -2 * Math.PI : 2 * Math.PI;
}
}
angles.push(offset_angle);
pre_angles.push(pre_angle);
offsets.push(offset);
}
return [offsets, angles, pre_angles];
}
}
// Fitness function (label length / line length)
function calcFitness(line_length, label_length) {
return label_length / line_length;
}
function getAngleForSegment(p, q){
let pq = Vector.sub(q,p);
return Vector.angle(pq);
}
function getTextAngleForSegment(pt1, pt2) {
return -getAngleForSegment(pt1, pt2);
}
function getLineLengths(line){
let lengths = [];
for (let i = 0; i < line.length - 1; i++){
let p = line[i];
let q = line[i+1];
let length = Math.hypot(p[0] - q[0], p[1] - q[1]);
lengths.push(length);
}
return lengths;
}
function getAbsAngleDiff(angle1, angle2){
let small, big;
if (angle1 > angle2){
small = angle2;
big = angle1;
}
else {
small = angle1;
big = angle2;
}
while (big - small > Math.PI){
small += 2 * Math.PI;
}
return Math.abs(big - small);
}