Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/style-spec/reference/v8.json
Original file line number Diff line number Diff line change
Expand Up @@ -2882,6 +2882,12 @@
},
"line-center": {
"doc": "The label is placed at the center of the line of the geometry. Can only be used on `LineString` and `Polygon` geometries. Note that a single feature in a vector tile may contain multiple line geometries."
},
"line-start": {
"doc": "The label is placed at the start of the line of the geometry. Can only be used on `LineString` and `Polygon` geometries. Works with line geometry that may cross tile boundaries."
},
"line-end": {
"doc": "The label is placed at the end of the line of the geometry. Can only be used on `LineString` and `Polygon` geometries. Works with line geometry that may cross tile boundaries."
}
},
"default": "point",
Expand Down
2 changes: 1 addition & 1 deletion src/style-spec/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,7 +784,7 @@ export type SymbolLayerSpecification = {
"maxzoom"?: number,
"filter"?: FilterSpecification,
"layout"?: {
"symbol-placement"?: PropertyValueSpecification<"point" | "line" | "line-center">,
"symbol-placement"?: PropertyValueSpecification<"point" | "line" | "line-center" | "line-start" | "line-end">,
"symbol-spacing"?: PropertyValueSpecification<number>,
"symbol-avoid-edges"?: PropertyValueSpecification<boolean>,
"symbol-sort-key"?: DataDrivenPropertyValueSpecification<number>,
Expand Down
2 changes: 1 addition & 1 deletion src/style/style_layer/symbol_style_layer_properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type ResolvedImage from '../../style-spec/expression/types/resolved_image
import type {StylePropertySpecification} from '../../style-spec/style-spec';

export type LayoutProps = {
"symbol-placement": DataConstantProperty<"point" | "line" | "line-center">;
"symbol-placement": DataConstantProperty<"point" | "line" | "line-center" | "line-start" | "line-end">;
"symbol-spacing": DataConstantProperty<number>;
"symbol-avoid-edges": DataConstantProperty<boolean>;
"symbol-sort-key": DataDrivenProperty<number>;
Expand Down
37 changes: 36 additions & 1 deletion src/symbol/get_anchors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import checkMaxAngle from './check_max_angle';
import type Point from '@mapbox/point-geometry';
import type {Shaping, PositionedIcon} from './shaping';

export {getAnchors, getCenterAnchor};
export {getAnchors, getCenterAnchor, getStartAnchor, getEndAnchor};

function getLineLength(line: Array<Point>): number {
let lineLength = 0;
Expand Down Expand Up @@ -166,3 +166,38 @@ function resample(line: Array<Point>, offset: number, spacing: number, angleWind

return anchors;
}

function getStartAnchor(
line: Array<Point>,
maxAngle: number,
shapedText: Shaping | null | undefined,
shapedIcon: PositionedIcon | null | undefined,
glyphSize: number,
boxScale: number,
): Anchor | null | undefined {
if (line.length < 2) return;

// Use the first point as the anchor
const a = line[0];
const b = line[1];
const angle = a.angleTo(b);
return new Anchor(a.x, a.y, 0, angle, 0);
}

function getEndAnchor(
line: Array<Point>,
maxAngle: number,
shapedText: Shaping | null | undefined,
shapedIcon: PositionedIcon | null | undefined,
glyphSize: number,
boxScale: number,
): Anchor | null | undefined {
if (line.length < 2) return;

// Use the last point as the anchor
const lastIndex = line.length - 1;
const a = line[lastIndex];
const b = line[lastIndex - 1];
const angle = a.angleTo(b);
return new Anchor(a.x, a.y, 0, angle, lastIndex - 1);
}
153 changes: 147 additions & 6 deletions src/symbol/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,115 @@ function projectTruncatedLineSegment(
return vec3.scaleAndAdd(projectedUnit, previousProjectedPoint, projectedUnit, minimumLength) as [number, number, number];
}

function projectSegment(
startIdx: number,
endIdx: number,
lineEndIndex: number,
lineVertexArray: SymbolLineVertexArray,
tileID: CanonicalTileID,
labelPlaneMatrix: mat4,
projection: Projection,
elevationParams: ElevationParams | null,
): {projStart: vec4; projEnd: vec4; tileStart: Point; tileEnd: Point} | null {
if (endIdx >= lineEndIndex) return null;
const tileStart = new Point(lineVertexArray.getx(startIdx), lineVertexArray.gety(startIdx));
const tileEnd = new Point(lineVertexArray.getx(endIdx), lineVertexArray.gety(endIdx));
const projStart = elevatePointAndProject(tileStart, tileID, labelPlaneMatrix, projection, elevationParams);
const projEnd = elevatePointAndProject(tileEnd, tileID, labelPlaneMatrix, projection, elevationParams);
if (projStart[3] <= 0 || projEnd[3] <= 0) return null;
return {projStart, projEnd, tileStart, tileEnd};
}

function computeTangentAngle(
tangent: vec3,
baseAngle: number,
pitchWithMap: boolean,
projection: Projection,
tileID: CanonicalTileID,
tilePoint: Point,
): number {
let diffX = tangent[0];
let diffY = tangent[1];
if (pitchWithMap) {
const axisZ = projection.upVector(tileID, tilePoint.x, tilePoint.y);
if (axisZ[0] !== 0 || axisZ[1] !== 0 || axisZ[2] !== 1) {
const axisX: [number, number, number] = [axisZ[2], 0, -axisZ[0]];
const axisY = vec3.cross([], axisZ, axisX);
vec3.normalize(axisX, axisX);
vec3.normalize(axisY, axisY);
diffX = vec3.dot(tangent, axisX);
diffY = vec3.dot(tangent, axisY);
}
}
return baseAngle + Math.atan2(diffY, diffX);
}

function extrapolateAlongTangent(
anchorPoint: [number, number, number],
tileAnchorPoint: Point,
anchorSegment: number,
lineStartIndex: number,
lineEndIndex: number,
lineVertexArray: SymbolLineVertexArray,
tileID: CanonicalTileID,
labelPlaneMatrix: mat4,
reprojection: Projection,
elevationParams: ElevationParams | null,
dir: number,
distance: number,
lineOffsetY: number,
baseAngle: number,
pitchWithMap: boolean,
returnPathInTileCoords: boolean | null | undefined,
pathVertices: vec3[],
tilePath: Point[],
): PlacedGlyph | null {
const seg = projectSegment(
lineStartIndex + anchorSegment,
lineStartIndex + anchorSegment + 1,
lineEndIndex, lineVertexArray, tileID,
labelPlaneMatrix, reprojection, elevationParams);
if (!seg) return null;

const tangent = vec3.sub([], seg.projEnd as unknown as vec3, seg.projStart as unknown as vec3);
const len = vec3.length(tangent);
if (len === 0) return null;
vec3.normalize(tangent, tangent);

const point = vec3.scaleAndAdd([], anchorPoint, tangent, distance);

const tileTangent = seg.tileEnd.sub(seg.tileStart);
const tileLen = tileTangent.mag();
const tilePoint = tileLen > 0 ?
tileAnchorPoint.add(tileTangent._unit()._mult(distance * tileLen / len)) :
tileAnchorPoint;

const dirTangent = vec3.scale([], tangent, dir);
let axisZ: [number, number, number] = [0, 0, 1];
if (pitchWithMap) {
axisZ = reprojection.upVector(tileID, tilePoint.x, tilePoint.y);
}

if (lineOffsetY) {
const offsetDir = vec3.cross([], axisZ, dirTangent);
vec3.normalize(offsetDir, offsetDir);
vec3.scaleAndAdd(point, point, offsetDir, lineOffsetY * dir);
}

pathVertices.push(point);
if (returnPathInTileCoords) tilePath.push(tilePoint);

const angle = computeTangentAngle(dirTangent, baseAngle, pitchWithMap, reprojection, tileID, tilePoint);

return {
point,
angle,
path: pathVertices,
tilePath,
up: axisZ
};
}

function placeGlyphAlongLine(
offsetX: number,
lineOffsetX: number,
Expand Down Expand Up @@ -635,14 +744,38 @@ function placeGlyphAlongLine(

if (dir < 0) angle += Math.PI;

const absOffsetX = Math.abs(combinedOffsetX);

// Place zero-offset glyphs directly at the anchor with the segment angle
if (absOffsetX === 0) {
const flipAngle = flip ? Math.PI : 0;
const up: [number, number, number] = pitchWithMap ?
reprojection.upVector(tileID.canonical, tileAnchorPoint.x, tileAnchorPoint.y) : [0, 0, 1];
const seg = projectSegment(
lineStartIndex + anchorSegment, lineStartIndex + anchorSegment + 1,
lineEndIndex, lineVertexArray, tileID.canonical, labelPlaneMatrix, reprojection, elevationParams);
const segTangent = seg ?
vec3.sub([], seg.projEnd as unknown as vec3, seg.projStart as unknown as vec3) :
null;

return {
point: anchorPoint as unknown as vec3,
angle: segTangent ?
computeTangentAngle(segTangent, flipAngle, pitchWithMap, reprojection, tileID.canonical, tileAnchorPoint) :
flipAngle,
path: [anchorPoint],
tilePath: returnPathInTileCoords ? [tileAnchorPoint] : [],
up
};
}

let currentIndex = lineStartIndex + anchorSegment + (dir > 0 ? 0 : 1) | 0;
let current = anchorPoint;
let prev = anchorPoint;
let distanceToPrev = 0;
let currentSegmentDistance = 0;
const absOffsetX = Math.abs(combinedOffsetX);
const pathVertices = [];
const tilePath = [];
const pathVertices: vec3[] = [];
const tilePath: Point[] = [];
let currentVertex = tileAnchorPoint;
let prevVertex = currentVertex;
let prevToCurrent = vec3.zero([]);
Expand All @@ -655,8 +788,18 @@ function placeGlyphAlongLine(
currentIndex += dir;

// offset does not fit on the projected line
if (currentIndex < lineStartIndex || currentIndex >= lineEndIndex)
if (currentIndex < lineStartIndex || currentIndex >= lineEndIndex) {
// Haven't traversed any segments yet — extrapolate past the endpoint
if (distanceToPrev === 0 && currentSegmentDistance === 0) {
return extrapolateAlongTangent(
anchorPoint, tileAnchorPoint, anchorSegment,
lineStartIndex, lineEndIndex, lineVertexArray,
tileID.canonical, labelPlaneMatrix, reprojection, elevationParams,
dir, dir * absOffsetX, lineOffsetY, angle, pitchWithMap,
returnPathInTileCoords, pathVertices, tilePath);
}
return null;
}

prev = current;
prevVertex = currentVertex;
Expand Down Expand Up @@ -749,9 +892,7 @@ function placeGlyphAlongLine(
return {
point: labelPlanePoint,
angle: segmentAngle,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
path: pathVertices,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
tilePath,
up: axisZ
};
Expand Down
49 changes: 46 additions & 3 deletions src/symbol/symbol_layout.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Anchor from './anchor';
import {getAnchors, getCenterAnchor} from './get_anchors';
import {getAnchors, getCenterAnchor, getStartAnchor, getEndAnchor} from './get_anchors';
import {shapeText, shapeIcon, WritingMode, fitIconToText, isPositionedIcon, getPositionedIconSize, isFullyStretchableX, isFullyStretchableY} from './shaping';
import {getGlyphQuads, getIconQuads, getIconQuadsNumber, type SymbolQuad} from './quads';
import {warnOnce, degToRad, clamp} from '../util/util';
Expand Down Expand Up @@ -296,7 +296,7 @@ export type SymbolBucketData = {
sizes: Sizes,
hasAnySecondaryIcon: boolean,
textAlongLine: boolean,
symbolPlacement: "point" | "line" | "line-center"
symbolPlacement: "point" | "line" | "line-center" | "line-start" | "line-end"
};

export function performSymbolLayout(bucket: SymbolBucket,
Expand Down Expand Up @@ -958,7 +958,7 @@ function addFeature(bucket: SymbolBucket,
iconTextFit: "none" | "width" | "height" | "both",
iconOffset: [number, number],
textAlongLine: boolean,
symbolPlacement: "point" | "line" | "line-center",
symbolPlacement: "point" | "line" | "line-center" | "line-start" | "line-end",
iconCollisionBounds?: SymbolBoundingBox | null,
iconVerticalCollisionBounds?: SymbolBoundingBox | null,
textCollisionBounds?: SymbolBoundingBox | null,
Expand All @@ -980,6 +980,15 @@ function addFeature(bucket: SymbolBucket,

const defaultShaping = getDefaultHorizontalShaping(shapedTextOrientations.horizontal) || shapedTextOrientations.vertical;

// For line-start/line-end placement, shift textOffset along the line so that
// all glyph offsets end up with the same sign (all positive for line-start,
// all negative for line-end). Centered text has glyph offsets ranging from
// negative to positive, but endpoint anchors can only traverse inward.
if (defaultShaping && (symbolPlacement === 'line-start' || symbolPlacement === 'line-end')) {
const shift = symbolPlacement === 'line-start' ? -defaultShaping.left : -defaultShaping.right;
textOffset = [textOffset[0] + shift, textOffset[1]];
}

// Store text shaping data for icon-text-fit and text appearance updates
const hasTextAppearances = bucket.hasAnyAppearanceProperty(['text-size', 'text-offset', 'text-rotate']);
const featureData = bucket.getAppearanceFeatureData(feature.index);
Expand Down Expand Up @@ -1076,6 +1085,40 @@ function addFeature(bucket: SymbolBucket,
}
}
}
} else if (symbolPlacement === 'line-start') {
// No clipping, multiple lines per feature are allowed
// "lines" with only one point are ignored as in clipLines
for (const line of feature.geometry) {
if (line.length > 1) {
const anchor = getStartAnchor(
line,
textMaxAngle,
shapedTextOrientations.vertical || defaultShaping,
shapedIcon,
glyphSize,
textMaxBoxScale);
if (anchor) {
addSymbolAtAnchor(line, anchor, canonical);
}
}
}
} else if (symbolPlacement === 'line-end') {
// No clipping, multiple lines per feature are allowed
// "lines" with only one point are ignored as in clipLines
for (const line of feature.geometry) {
if (line.length > 1) {
const anchor = getEndAnchor(
line,
textMaxAngle,
shapedTextOrientations.vertical || defaultShaping,
shapedIcon,
glyphSize,
textMaxBoxScale);
if (anchor) {
addSymbolAtAnchor(line, anchor, canonical);
}
}
}
} else if (feature.type === 'Polygon') {
for (const polygon of classifyRings(feature.geometry, 0)) {
// 16 here represents 2 pixels
Expand Down
2 changes: 1 addition & 1 deletion test/build/typings/types__mapbox-gl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1730,7 +1730,7 @@ const linePaint: NonNullable<mapboxgl.LinePaint> = {
};

const symbolLayout: mapboxgl.SymbolLayout = {
"symbol-placement": eitherType("point", "line", "line-center"),
"symbol-placement": eitherType("point", "line", "line-center", "line-start", "line-end"),
"symbol-spacing": eitherType(0, expression),
"symbol-avoid-edges": false,
"symbol-z-order": eitherType("viewport-y", "source"),
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading