diff --git a/src/style-spec/reference/v8.json b/src/style-spec/reference/v8.json index 2ae3173da52..055609726da 100644 --- a/src/style-spec/reference/v8.json +++ b/src/style-spec/reference/v8.json @@ -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", diff --git a/src/style-spec/types.ts b/src/style-spec/types.ts index 6484d59c937..674d727d320 100644 --- a/src/style-spec/types.ts +++ b/src/style-spec/types.ts @@ -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, "symbol-avoid-edges"?: PropertyValueSpecification, "symbol-sort-key"?: DataDrivenPropertyValueSpecification, diff --git a/src/style/style_layer/symbol_style_layer_properties.ts b/src/style/style_layer/symbol_style_layer_properties.ts index 5ac42b41d86..d5c66d4e2c0 100644 --- a/src/style/style_layer/symbol_style_layer_properties.ts +++ b/src/style/style_layer/symbol_style_layer_properties.ts @@ -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; "symbol-avoid-edges": DataConstantProperty; "symbol-sort-key": DataDrivenProperty; diff --git a/src/symbol/get_anchors.ts b/src/symbol/get_anchors.ts index ea83ea237f1..0727ead6bf6 100644 --- a/src/symbol/get_anchors.ts +++ b/src/symbol/get_anchors.ts @@ -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): number { let lineLength = 0; @@ -166,3 +166,38 @@ function resample(line: Array, offset: number, spacing: number, angleWind return anchors; } + +function getStartAnchor( + line: Array, + 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, + 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); +} diff --git a/src/symbol/projection.ts b/src/symbol/projection.ts index f497733f801..15dea6df539 100644 --- a/src/symbol/projection.ts +++ b/src/symbol/projection.ts @@ -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, @@ -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([]); @@ -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; @@ -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 }; diff --git a/src/symbol/symbol_layout.ts b/src/symbol/symbol_layout.ts index d721721cb34..b4aafd1d095 100644 --- a/src/symbol/symbol_layout.ts +++ b/src/symbol/symbol_layout.ts @@ -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'; @@ -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, @@ -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, @@ -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); @@ -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 diff --git a/test/build/typings/types__mapbox-gl.test.ts b/test/build/typings/types__mapbox-gl.test.ts index f703e91ebdd..eb30dbd1a9a 100644 --- a/test/build/typings/types__mapbox-gl.test.ts +++ b/test/build/typings/types__mapbox-gl.test.ts @@ -1730,7 +1730,7 @@ const linePaint: NonNullable = { }; 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"), diff --git a/test/integration/render-tests/symbol-placement/line-end/basic/expected.png b/test/integration/render-tests/symbol-placement/line-end/basic/expected.png new file mode 100644 index 00000000000..21a95097bbe Binary files /dev/null and b/test/integration/render-tests/symbol-placement/line-end/basic/expected.png differ diff --git a/test/integration/render-tests/symbol-placement/line-end/basic/style.json b/test/integration/render-tests/symbol-placement/line-end/basic/style.json new file mode 100644 index 00000000000..cd9521d7cf1 --- /dev/null +++ b/test/integration/render-tests/symbol-placement/line-end/basic/style.json @@ -0,0 +1,123 @@ +{ + "version": 8, + "metadata": { + "test": { + "collisionDebug": true, + "height": 512 + } + }, + "center": [ + 13.418056, + 52.499167 + ], + "zoom": 15, + "sources": { + "mapbox": { + "type": "vector", + "maxzoom": 14, + "tiles": [ + "local://tiles/{z}-{x}-{y}.mvt" + ] + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "line-end", + "type": "symbol", + "source": "mapbox", + "source-layer": "road_label", + "layout": { + "text-field": ".", + "symbol-placement": "line-end", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + }, + { + "id": "road", + "type": "line", + "source": "mapbox", + "source-layer": "road_label", + "paint": { + "line-width": 1, + "line-color": [ + "match", + [ + "%", + [ + "to-number", + [ + "id" + ] + ], + 10 + ], + 0, + [ + "to-color", + "red" + ], + 1, + [ + "to-color", + "blue" + ], + 2, + [ + "to-color", + "yellow" + ], + 3, + [ + "to-color", + "black" + ], + 4, + [ + "to-color", + "grey" + ], + 5, + [ + "to-color", + "purple" + ], + 6, + [ + "to-color", + "green" + ], + 7, + [ + "to-color", + "orange" + ], + 8, + [ + "to-color", + "brown" + ], + 9, + [ + "to-color", + "lime" + ], + [ + "to-color", + "magenta" + ] + ] + } + } + ] +} diff --git a/test/integration/render-tests/symbol-placement/line-end/buffer-tile-map-mode/expected.png b/test/integration/render-tests/symbol-placement/line-end/buffer-tile-map-mode/expected.png new file mode 100644 index 00000000000..a9397e07013 Binary files /dev/null and b/test/integration/render-tests/symbol-placement/line-end/buffer-tile-map-mode/expected.png differ diff --git a/test/integration/render-tests/symbol-placement/line-end/buffer-tile-map-mode/style.json b/test/integration/render-tests/symbol-placement/line-end/buffer-tile-map-mode/style.json new file mode 100644 index 00000000000..b162f35896d --- /dev/null +++ b/test/integration/render-tests/symbol-placement/line-end/buffer-tile-map-mode/style.json @@ -0,0 +1,60 @@ +{ + "version": 8, + "metadata": { + "test": { + "debug": true, + "mapMode": "tile" + } + }, + "center": [ + -73, + 15 + ], + "zoom": 4, + "sources": { + "mapbox": { + "type": "vector", + "maxzoom": 14, + "tiles": [ + "local://tiles/mapbox.mapbox-streets-v7/{z}-{x}-{y}.mvt" + ] + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "line-end", + "type": "symbol", + "source": "mapbox", + "source-layer": "marine_label", + "layout": { + "text-field": "{name_en}", + "symbol-placement": "line-end", + "text-allow-overlap": true, + "text-size": 35, + "text-letter-spacing": 0.4, + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-max-width": 0 + } + }, + { + "id": "line", + "type": "line", + "source": "mapbox", + "source-layer": "marine_label", + "paint": { + "line-width": 1 + } + } + ] +} diff --git a/test/integration/render-tests/symbol-placement/line-end/buffer/expected.png b/test/integration/render-tests/symbol-placement/line-end/buffer/expected.png new file mode 100644 index 00000000000..af63db2560a Binary files /dev/null and b/test/integration/render-tests/symbol-placement/line-end/buffer/expected.png differ diff --git a/test/integration/render-tests/symbol-placement/line-end/buffer/style.json b/test/integration/render-tests/symbol-placement/line-end/buffer/style.json new file mode 100644 index 00000000000..238bcf265f3 --- /dev/null +++ b/test/integration/render-tests/symbol-placement/line-end/buffer/style.json @@ -0,0 +1,65 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "line-end label should remain stable when feature crosses tile boundaries", + "height": 256, + "width": 1024 + } + }, + "center": [ + -73, + 15 + ], + "zoom": 4.5, + "sources": { + "mapbox": { + "type": "vector", + "maxzoom": 14, + "tiles": [ + "local://tiles/mapbox.mapbox-streets-v7/{z}-{x}-{y}.mvt" + ] + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "line-end", + "type": "symbol", + "source": "mapbox", + "source-layer": "marine_label", + "layout": { + "text-field": "{name_en}", + "symbol-placement": "line-end", + "text-allow-overlap": false, + "text-size": 35, + "text-letter-spacing": 0.4, + "text-offset": [ + 3, + 0 + ], + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-max-width": 0 + } + }, + { + "id": "line", + "type": "line", + "source": "mapbox", + "source-layer": "marine_label", + "paint": { + "line-width": 1 + } + } + ] +} diff --git a/test/integration/render-tests/symbol-placement/line-end/icon-offset/expected.png b/test/integration/render-tests/symbol-placement/line-end/icon-offset/expected.png new file mode 100644 index 00000000000..4912ec0b50c Binary files /dev/null and b/test/integration/render-tests/symbol-placement/line-end/icon-offset/expected.png differ diff --git a/test/integration/render-tests/symbol-placement/line-end/icon-offset/style.json b/test/integration/render-tests/symbol-placement/line-end/icon-offset/style.json new file mode 100644 index 00000000000..5f202b431e6 --- /dev/null +++ b/test/integration/render-tests/symbol-placement/line-end/icon-offset/style.json @@ -0,0 +1,75 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "icon-offset should apply correctly with line-end symbol placement", + "height": 256, + "width": 256 + } + }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-30, -30], + [0, 0], + [30, 30] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-30, 10], + [0, -10], + [-30, -50] + ] + } + } + ] + } + } + }, + "sprite": "local://sprites/standard", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "line", + "type": "line", + "source": "geojson", + "paint": { + "line-width": 2, + "line-color": "#ccc" + } + }, + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "symbol-placement": "line-end", + "icon-image": "information", + "icon-allow-overlap": true, + "icon-ignore-placement": true, + "icon-offset": [10, -10] + } + } + ] +} diff --git a/test/integration/render-tests/symbol-placement/line-end/text-offset/expected.png b/test/integration/render-tests/symbol-placement/line-end/text-offset/expected.png new file mode 100644 index 00000000000..efebc5c83a4 Binary files /dev/null and b/test/integration/render-tests/symbol-placement/line-end/text-offset/expected.png differ diff --git a/test/integration/render-tests/symbol-placement/line-end/text-offset/style.json b/test/integration/render-tests/symbol-placement/line-end/text-offset/style.json new file mode 100644 index 00000000000..a620f656b04 --- /dev/null +++ b/test/integration/render-tests/symbol-placement/line-end/text-offset/style.json @@ -0,0 +1,78 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 256, + "width": 256 + } + }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-30, -30], + [0, 0], + [30, 30] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-30, 10], + [0, -10], + [-30, -50] + ] + } + } + ] + } + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "line", + "type": "line", + "source": "geojson", + "paint": { + "line-width": 2, + "line-color": "#ccc" + } + }, + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "symbol-placement": "line-end", + "text-field": "A", + "text-allow-overlap": true, + "text-ignore-placement": true, + "text-offset": [2, 1], + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + } + ] +} diff --git a/test/integration/render-tests/symbol-placement/line-end/tile-map-mode/expected.png b/test/integration/render-tests/symbol-placement/line-end/tile-map-mode/expected.png new file mode 100644 index 00000000000..fa24840312a Binary files /dev/null and b/test/integration/render-tests/symbol-placement/line-end/tile-map-mode/expected.png differ diff --git a/test/integration/render-tests/symbol-placement/line-end/tile-map-mode/style.json b/test/integration/render-tests/symbol-placement/line-end/tile-map-mode/style.json new file mode 100644 index 00000000000..1be393aa681 --- /dev/null +++ b/test/integration/render-tests/symbol-placement/line-end/tile-map-mode/style.json @@ -0,0 +1,125 @@ +{ + "version": 8, + "metadata": { + "test": { + "collisionDebug": true, + "debug": true, + "mapMode": "tile", + "allowed": 0.0002 + } + }, + "center": [ + 13.418056, + 52.499167 + ], + "zoom": 15, + "sources": { + "mapbox": { + "type": "vector", + "maxzoom": 14, + "tiles": [ + "local://tiles/{z}-{x}-{y}.mvt" + ] + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "line-end", + "type": "symbol", + "source": "mapbox", + "source-layer": "road_label", + "layout": { + "text-field": ".", + "symbol-placement": "line-end", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + }, + { + "id": "road", + "type": "line", + "source": "mapbox", + "source-layer": "road_label", + "paint": { + "line-width": 1, + "line-color": [ + "match", + [ + "%", + [ + "to-number", + [ + "id" + ] + ], + 10 + ], + 0, + [ + "to-color", + "red" + ], + 1, + [ + "to-color", + "blue" + ], + 2, + [ + "to-color", + "yellow" + ], + 3, + [ + "to-color", + "black" + ], + 4, + [ + "to-color", + "grey" + ], + 5, + [ + "to-color", + "purple" + ], + 6, + [ + "to-color", + "green" + ], + 7, + [ + "to-color", + "orange" + ], + 8, + [ + "to-color", + "brown" + ], + 9, + [ + "to-color", + "lime" + ], + [ + "to-color", + "magenta" + ] + ] + } + } + ] +} diff --git a/test/integration/render-tests/symbol-placement/line-start/basic/expected.png b/test/integration/render-tests/symbol-placement/line-start/basic/expected.png new file mode 100644 index 00000000000..08b115765a3 Binary files /dev/null and b/test/integration/render-tests/symbol-placement/line-start/basic/expected.png differ diff --git a/test/integration/render-tests/symbol-placement/line-start/basic/style.json b/test/integration/render-tests/symbol-placement/line-start/basic/style.json new file mode 100644 index 00000000000..1fa9330c75e --- /dev/null +++ b/test/integration/render-tests/symbol-placement/line-start/basic/style.json @@ -0,0 +1,123 @@ +{ + "version": 8, + "metadata": { + "test": { + "collisionDebug": true, + "height": 512 + } + }, + "center": [ + 13.418056, + 52.499167 + ], + "zoom": 15, + "sources": { + "mapbox": { + "type": "vector", + "maxzoom": 14, + "tiles": [ + "local://tiles/{z}-{x}-{y}.mvt" + ] + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "line-start", + "type": "symbol", + "source": "mapbox", + "source-layer": "road_label", + "layout": { + "text-field": ".", + "symbol-placement": "line-start", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + }, + { + "id": "road", + "type": "line", + "source": "mapbox", + "source-layer": "road_label", + "paint": { + "line-width": 1, + "line-color": [ + "match", + [ + "%", + [ + "to-number", + [ + "id" + ] + ], + 10 + ], + 0, + [ + "to-color", + "red" + ], + 1, + [ + "to-color", + "blue" + ], + 2, + [ + "to-color", + "yellow" + ], + 3, + [ + "to-color", + "black" + ], + 4, + [ + "to-color", + "grey" + ], + 5, + [ + "to-color", + "purple" + ], + 6, + [ + "to-color", + "green" + ], + 7, + [ + "to-color", + "orange" + ], + 8, + [ + "to-color", + "brown" + ], + 9, + [ + "to-color", + "lime" + ], + [ + "to-color", + "magenta" + ] + ] + } + } + ] +} diff --git a/test/integration/render-tests/symbol-placement/line-start/buffer-tile-map-mode/expected.png b/test/integration/render-tests/symbol-placement/line-start/buffer-tile-map-mode/expected.png new file mode 100644 index 00000000000..32a70e276ef Binary files /dev/null and b/test/integration/render-tests/symbol-placement/line-start/buffer-tile-map-mode/expected.png differ diff --git a/test/integration/render-tests/symbol-placement/line-start/buffer-tile-map-mode/style.json b/test/integration/render-tests/symbol-placement/line-start/buffer-tile-map-mode/style.json new file mode 100644 index 00000000000..a4588b19363 --- /dev/null +++ b/test/integration/render-tests/symbol-placement/line-start/buffer-tile-map-mode/style.json @@ -0,0 +1,60 @@ +{ + "version": 8, + "metadata": { + "test": { + "debug": true, + "mapMode": "tile" + } + }, + "center": [ + -73, + 15 + ], + "zoom": 4, + "sources": { + "mapbox": { + "type": "vector", + "maxzoom": 14, + "tiles": [ + "local://tiles/mapbox.mapbox-streets-v7/{z}-{x}-{y}.mvt" + ] + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "line-start", + "type": "symbol", + "source": "mapbox", + "source-layer": "marine_label", + "layout": { + "text-field": "{name_en}", + "symbol-placement": "line-start", + "text-allow-overlap": true, + "text-size": 35, + "text-letter-spacing": 0.4, + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-max-width": 0 + } + }, + { + "id": "line", + "type": "line", + "source": "mapbox", + "source-layer": "marine_label", + "paint": { + "line-width": 1 + } + } + ] +} diff --git a/test/integration/render-tests/symbol-placement/line-start/buffer/expected.png b/test/integration/render-tests/symbol-placement/line-start/buffer/expected.png new file mode 100644 index 00000000000..e4b4aeddbd2 Binary files /dev/null and b/test/integration/render-tests/symbol-placement/line-start/buffer/expected.png differ diff --git a/test/integration/render-tests/symbol-placement/line-start/buffer/style.json b/test/integration/render-tests/symbol-placement/line-start/buffer/style.json new file mode 100644 index 00000000000..feabb808205 --- /dev/null +++ b/test/integration/render-tests/symbol-placement/line-start/buffer/style.json @@ -0,0 +1,65 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "line-start label should remain stable when feature crosses tile boundaries", + "height": 256, + "width": 1024 + } + }, + "center": [ + -73, + 15 + ], + "zoom": 4.5, + "sources": { + "mapbox": { + "type": "vector", + "maxzoom": 14, + "tiles": [ + "local://tiles/mapbox.mapbox-streets-v7/{z}-{x}-{y}.mvt" + ] + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "line-start", + "type": "symbol", + "source": "mapbox", + "source-layer": "marine_label", + "layout": { + "text-field": "{name_en}", + "symbol-placement": "line-start", + "text-allow-overlap": false, + "text-size": 35, + "text-letter-spacing": 0.4, + "text-offset": [ + 3, + 0 + ], + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-max-width": 0 + } + }, + { + "id": "line", + "type": "line", + "source": "mapbox", + "source-layer": "marine_label", + "paint": { + "line-width": 1 + } + } + ] +} diff --git a/test/integration/render-tests/symbol-placement/line-start/icon-offset/expected.png b/test/integration/render-tests/symbol-placement/line-start/icon-offset/expected.png new file mode 100644 index 00000000000..a5c763bf793 Binary files /dev/null and b/test/integration/render-tests/symbol-placement/line-start/icon-offset/expected.png differ diff --git a/test/integration/render-tests/symbol-placement/line-start/icon-offset/style.json b/test/integration/render-tests/symbol-placement/line-start/icon-offset/style.json new file mode 100644 index 00000000000..026c042dc41 --- /dev/null +++ b/test/integration/render-tests/symbol-placement/line-start/icon-offset/style.json @@ -0,0 +1,75 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "icon-offset should apply correctly with line-start symbol placement", + "height": 256, + "width": 256 + } + }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-30, -30], + [0, 0], + [30, 30] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-30, 10], + [0, -10], + [-30, -50] + ] + } + } + ] + } + } + }, + "sprite": "local://sprites/standard", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "line", + "type": "line", + "source": "geojson", + "paint": { + "line-width": 2, + "line-color": "#ccc" + } + }, + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "symbol-placement": "line-start", + "icon-image": "information", + "icon-allow-overlap": true, + "icon-ignore-placement": true, + "icon-offset": [10, -10] + } + } + ] +} diff --git a/test/integration/render-tests/symbol-placement/line-start/text-offset/expected.png b/test/integration/render-tests/symbol-placement/line-start/text-offset/expected.png new file mode 100644 index 00000000000..6ff73ae0aa0 Binary files /dev/null and b/test/integration/render-tests/symbol-placement/line-start/text-offset/expected.png differ diff --git a/test/integration/render-tests/symbol-placement/line-start/text-offset/style.json b/test/integration/render-tests/symbol-placement/line-start/text-offset/style.json new file mode 100644 index 00000000000..cff5ab5c379 --- /dev/null +++ b/test/integration/render-tests/symbol-placement/line-start/text-offset/style.json @@ -0,0 +1,78 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 256, + "width": 256 + } + }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-30, -30], + [0, 0], + [30, 30] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-30, 10], + [0, -10], + [-30, -50] + ] + } + } + ] + } + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "line", + "type": "line", + "source": "geojson", + "paint": { + "line-width": 2, + "line-color": "#ccc" + } + }, + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "symbol-placement": "line-start", + "text-field": "A", + "text-allow-overlap": true, + "text-ignore-placement": true, + "text-offset": [2, 1], + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + } + ] +} diff --git a/test/integration/render-tests/symbol-placement/line-start/tile-map-mode/expected.png b/test/integration/render-tests/symbol-placement/line-start/tile-map-mode/expected.png new file mode 100644 index 00000000000..60da9a4df79 Binary files /dev/null and b/test/integration/render-tests/symbol-placement/line-start/tile-map-mode/expected.png differ diff --git a/test/integration/render-tests/symbol-placement/line-start/tile-map-mode/style.json b/test/integration/render-tests/symbol-placement/line-start/tile-map-mode/style.json new file mode 100644 index 00000000000..9886f240b12 --- /dev/null +++ b/test/integration/render-tests/symbol-placement/line-start/tile-map-mode/style.json @@ -0,0 +1,125 @@ +{ + "version": 8, + "metadata": { + "test": { + "collisionDebug": true, + "debug": true, + "mapMode": "tile", + "allowed": 0.0002 + } + }, + "center": [ + 13.418056, + 52.499167 + ], + "zoom": 15, + "sources": { + "mapbox": { + "type": "vector", + "maxzoom": 14, + "tiles": [ + "local://tiles/{z}-{x}-{y}.mvt" + ] + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "line-start", + "type": "symbol", + "source": "mapbox", + "source-layer": "road_label", + "layout": { + "text-field": ".", + "symbol-placement": "line-start", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + }, + { + "id": "road", + "type": "line", + "source": "mapbox", + "source-layer": "road_label", + "paint": { + "line-width": 1, + "line-color": [ + "match", + [ + "%", + [ + "to-number", + [ + "id" + ] + ], + 10 + ], + 0, + [ + "to-color", + "red" + ], + 1, + [ + "to-color", + "blue" + ], + 2, + [ + "to-color", + "yellow" + ], + 3, + [ + "to-color", + "black" + ], + 4, + [ + "to-color", + "grey" + ], + 5, + [ + "to-color", + "purple" + ], + 6, + [ + "to-color", + "green" + ], + 7, + [ + "to-color", + "orange" + ], + 8, + [ + "to-color", + "brown" + ], + 9, + [ + "to-color", + "lime" + ], + [ + "to-color", + "magenta" + ] + ] + } + } + ] +} diff --git a/test/unit/symbol/get_anchors.test.ts b/test/unit/symbol/get_anchors.test.ts index 1cc1cfdd0b7..180f3641571 100644 --- a/test/unit/symbol/get_anchors.test.ts +++ b/test/unit/symbol/get_anchors.test.ts @@ -2,7 +2,7 @@ // @ts-nocheck import {describe, test, expect} from '../../util/vitest'; import Point from '@mapbox/point-geometry'; -import {getAnchors, getCenterAnchor} from '../../../src/symbol/get_anchors'; +import {getAnchors, getCenterAnchor, getStartAnchor, getEndAnchor} from '../../../src/symbol/get_anchors'; const TILE_EXTENT = 4096; @@ -116,4 +116,20 @@ describe('getAnchors', () => { const anchor = getCenterAnchor(line, Math.PI / 4, shapedText, shapedIcon, glyphSize, 1); expect(anchor).toBeFalsy(); }); + + test('getStartAnchor', () => { + const line = [new Point(1, 1), new Point(1, 3), new Point(3, 6), new Point(4, 7)]; + const anchor = getStartAnchor(line, Math.PI, shapedText, shapedIcon, glyphSize, 1); + expect(anchor).toEqual( + {x: 1, y: 1, z: 0.0, angle: -1.5707963267948966, segment: 0} + ); + }); + + test('getEndAnchor', () => { + const line = [new Point(1, 1), new Point(1, 3), new Point(3, 6), new Point(4, 7)]; + const anchor = getEndAnchor(line, Math.PI, shapedText, shapedIcon, glyphSize, 1); + expect(anchor).toEqual( + {x: 4, y: 7, z: 0.0, angle: 0.7853981633974483, segment: 2} + ); + }); });