UNPKG

maplibre-gl

Version:

BSD licensed community fork of mapbox-gl, a WebGL interactive maps library

973 lines (871 loc) 42.7 kB
import Point from '@mapbox/point-geometry'; import {mat2, mat4, vec2, vec4} from 'gl-matrix'; import * as symbolSize from './symbol_size'; import {addDynamicAttributes} from '../data/bucket/symbol_bucket'; import type {Painter} from '../render/painter'; import type {IReadonlyTransform} from '../geo/transform_interface'; import type {SymbolBucket} from '../data/bucket/symbol_bucket'; import type { GlyphOffsetArray, SymbolLineVertexArray, SymbolDynamicLayoutArray } from '../data/array_types.g'; import {WritingMode} from '../symbol/shaping'; import {findLineIntersection} from '../util/util'; import {type UnwrappedTileID} from '../source/tile_id'; import {type StructArray} from '../util/struct_array'; /** * The result of projecting a point to the screen, with some additional information about the projection. */ export type PointProjection = { /** * The projected point. */ point: Point; /** * The original W component of the projection. */ signedDistanceFromCamera: number; /** * For complex projections (such as globe), true if the point is occluded by the projection, such as by being on the backfacing side of the globe. * If the point is simply beyond the edge of the screen, this should NOT be set to false. */ isOccluded: boolean; }; /* * # Overview of coordinate spaces * * ## Tile coordinate spaces * Each label has an anchor. Some labels have corresponding line geometries. * The points for both anchors and lines are stored in tile units. Each tile has it's own * coordinate space going from (0, 0) at the top left to (EXTENT, EXTENT) at the bottom right. * * ## Clip space (GL coordinate space) * At the end of everything, the vertex shader needs to produce a position in clip space, * which is (-1, 1) at the top left and (1, -1) in the bottom right. * In the depth buffer, values are between 0 (near plane) to 1 (far plane). * * ## Map pixel coordinate spaces * Each tile has a pixel coordinate space. It's just the tile units scaled so that one unit is * whatever counts as 1 pixel at the current zoom. * This space is used for pitch-alignment=map, rotation-alignment=map * * ## Rotated map pixel coordinate spaces * Like the above, but rotated so axis of the space are aligned with the viewport instead of the tile. * This space is used for pitch-alignment=map, rotation-alignment=viewport * * ## Viewport pixel coordinate space * (0, 0) is at the top left of the canvas and (pixelWidth, pixelHeight) is at the bottom right corner * of the canvas. This space is used for pitch-alignment=viewport * * * # Vertex projection * It goes roughly like this: * 1. project the anchor and line from tile units into the correct label coordinate space * - map pixel space pitch-alignment=map rotation-alignment=map * - rotated map pixel space pitch-alignment=map rotation-alignment=viewport * - viewport pixel space pitch-alignment=viewport rotation-alignment=* * 2. if the label follows a line, find the point along the line that is the correct distance from the anchor. * 3. add the glyph's corner offset to the point from step 3 * 4. convert from the label coordinate space to clip space * * For horizontal labels we want to do step 1 in the shader for performance reasons (no cpu work). * This is what `u_label_plane_matrix` is used for. * For labels aligned with lines we have to steps 1 and 2 on the cpu since we need access to the line geometry. * This is what `updateLineLabels(...)` does. * Since the conversion is handled on the cpu we just set `u_label_plane_matrix` to an identity matrix. * * Steps 3 and 4 are done in the shaders for all labels. * * * # Custom projection handling * Note that since MapLibre now supports more than one projection, the transformation * to viewport pixel space and GL clip space now *must* go through the projection's (`transform`'s) * `projectTileCoordinates` function, since it might do nontrivial transformations. * * Hence projecting anything to a symbol's label plane can no longer be handled by a simple matrix, * since, if the symbol's label plane is viewport pixel space, `projectTileCoordinates` must be used. * This is applies both here and in the symbol vertex shaders. */ export function getPitchedLabelPlaneMatrix( rotateWithMap: boolean, transform: IReadonlyTransform, pixelsToTileUnits: number) { const m = mat4.create(); if (!rotateWithMap) { const {vecSouth, vecEast} = getTileSkewVectors(transform); const skew = mat2.create(); skew[0] = vecEast[0]; skew[1] = vecEast[1]; skew[2] = vecSouth[0]; skew[3] = vecSouth[1]; mat2.invert(skew, skew); m[0] = skew[0]; m[1] = skew[1]; m[4] = skew[2]; m[5] = skew[3]; } mat4.scale(m, m, [1 / pixelsToTileUnits, 1 / pixelsToTileUnits, 1]); return m; } /* * Returns a matrix for either converting from pitched label space to tile space, * or for converting from screenspace pixels to clip space. */ export function getGlCoordMatrix( pitchWithMap: boolean, rotateWithMap: boolean, transform: IReadonlyTransform, pixelsToTileUnits: number) { if (pitchWithMap) { const m = mat4.create(); if (!rotateWithMap) { const {vecSouth, vecEast} = getTileSkewVectors(transform); m[0] = vecEast[0]; m[1] = vecEast[1]; m[4] = vecSouth[0]; m[5] = vecSouth[1]; } mat4.scale(m, m, [pixelsToTileUnits, pixelsToTileUnits, 1]); return m; } else { return transform.pixelsToClipSpaceMatrix; } } export function getTileSkewVectors(transform: IReadonlyTransform): {vecEast: vec2; vecSouth: vec2} { const cosRoll = Math.cos(transform.rollInRadians); const sinRoll = Math.sin(transform.rollInRadians); const cosPitch = Math.cos(transform.pitchInRadians); const cosBearing = Math.cos(transform.bearingInRadians); const sinBearing = Math.sin(transform.bearingInRadians); const vecSouth = vec2.create(); vecSouth[0] = -cosBearing * cosPitch * sinRoll - sinBearing * cosRoll; vecSouth[1] = -sinBearing * cosPitch * sinRoll + cosBearing * cosRoll; const vecSouthLen = vec2.length(vecSouth); if (vecSouthLen < 1.0e-9) { vec2.zero(vecSouth); } else { vec2.scale(vecSouth, vecSouth, 1 / vecSouthLen); } const vecEast = vec2.create(); vecEast[0] = cosBearing * cosPitch * cosRoll - sinBearing * sinRoll; vecEast[1] = sinBearing * cosPitch * cosRoll + cosBearing * sinRoll; const vecEastLen = vec2.length(vecEast); if (vecEastLen < 1.0e-9) { vec2.zero(vecEast); } else { vec2.scale(vecEast, vecEast, 1 / vecEastLen); } return {vecEast, vecSouth}; } /** * Projects a point using a specified matrix, including the perspective divide. * Uses a fast path if `getElevation` is undefined. */ export function projectWithMatrix(x: number, y: number, matrix: mat4, getElevation?: (x: number, y: number) => number): PointProjection { let pos; if (getElevation) { // slow because of handle z-index pos = [x, y, getElevation(x, y), 1] as vec4; vec4.transformMat4(pos, pos, matrix); } else { // fast because of ignore z-index pos = [x, y, 0, 1] as vec4; xyTransformMat4(pos, pos, matrix); } const w = pos[3]; return { point: new Point(pos[0] / w, pos[1] / w), signedDistanceFromCamera: w, isOccluded: false }; } export function getPerspectiveRatio(cameraToCenterDistance: number, signedDistanceFromCamera: number): number { return 0.5 + 0.5 * (cameraToCenterDistance / signedDistanceFromCamera); } function isVisible(p: Point, clippingBuffer: [number, number]) { const inPaddedViewport = ( p.x >= -clippingBuffer[0] && p.x <= clippingBuffer[0] && p.y >= -clippingBuffer[1] && p.y <= clippingBuffer[1]); return inPaddedViewport; } /* * Update the `dynamicLayoutVertexBuffer` for the buffer with the correct glyph positions for the current map view. * This is only run on labels that are aligned with lines. Horizontal labels are handled entirely in the shader. */ export function updateLineLabels(bucket: SymbolBucket, painter: Painter, isText: boolean, pitchedLabelPlaneMatrix: mat4, pitchedLabelPlaneMatrixInverse: mat4, pitchWithMap: boolean, keepUpright: boolean, rotateToLine: boolean, unwrappedTileID: UnwrappedTileID, viewportWidth: number, viewportHeight: number, translation: [number, number], getElevation: (x: number, y: number) => number) { const sizeData = isText ? bucket.textSizeData : bucket.iconSizeData; const partiallyEvaluatedSize = symbolSize.evaluateSizeForZoom(sizeData, painter.transform.zoom); const clippingBuffer: [number, number] = [256 / painter.width * 2 + 1, 256 / painter.height * 2 + 1]; const dynamicLayoutVertexArray = isText ? bucket.text.dynamicLayoutVertexArray : bucket.icon.dynamicLayoutVertexArray; dynamicLayoutVertexArray.clear(); const lineVertexArray = bucket.lineVertexArray; const placedSymbols = isText ? bucket.text.placedSymbolArray : bucket.icon.placedSymbolArray; const aspectRatio = painter.transform.width / painter.transform.height; let useVertical = false; for (let s = 0; s < placedSymbols.length; s++) { const symbol = placedSymbols.get(s); // Don't do calculations for vertical glyphs unless the previous symbol was horizontal // and we determined that vertical glyphs were necessary. // Also don't do calculations for symbols that are collided and fully faded out if (symbol.hidden || symbol.writingMode === WritingMode.vertical && !useVertical) { hideGlyphs(symbol.numGlyphs, dynamicLayoutVertexArray); continue; } // Awkward... but we're counting on the paired "vertical" symbol coming immediately after its horizontal counterpart useVertical = false; const tileAnchorPoint = new Point(symbol.anchorX, symbol.anchorY); const projectionCache: ProjectionCache = {projections: {}, offsets: {}, cachedAnchorPoint: undefined, anyProjectionOccluded: false}; const projectionContext: SymbolProjectionContext = { getElevation, pitchedLabelPlaneMatrix, lineVertexArray, pitchWithMap, projectionCache, transform: painter.transform, tileAnchorPoint, unwrappedTileID, width: viewportWidth, height: viewportHeight, translation }; const anchorPos = projectTileCoordinatesToClipSpace(symbol.anchorX, symbol.anchorY, projectionContext); // Don't bother calculating the correct point for invisible labels. if (!isVisible(anchorPos.point, clippingBuffer)) { hideGlyphs(symbol.numGlyphs, dynamicLayoutVertexArray); continue; } const cameraToAnchorDistance = anchorPos.signedDistanceFromCamera; const perspectiveRatio = getPerspectiveRatio(painter.transform.cameraToCenterDistance, cameraToAnchorDistance); const fontSize = symbolSize.evaluateSizeForFeature(sizeData, partiallyEvaluatedSize, symbol); const pitchScaledFontSize = pitchWithMap ? (fontSize * painter.transform.getPitchedTextCorrection(symbol.anchorX, symbol.anchorY, unwrappedTileID) / perspectiveRatio) : fontSize * perspectiveRatio; const placeUnflipped = placeGlyphsAlongLine({ projectionContext, pitchedLabelPlaneMatrixInverse, symbol, fontSize: pitchScaledFontSize, flip: false, keepUpright, glyphOffsetArray: bucket.glyphOffsetArray, dynamicLayoutVertexArray, aspectRatio, rotateToLine, }); useVertical = placeUnflipped.useVertical; if (placeUnflipped.notEnoughRoom || useVertical || (placeUnflipped.needsFlipping && placeGlyphsAlongLine({ projectionContext, pitchedLabelPlaneMatrixInverse, symbol, fontSize: pitchScaledFontSize, flip: true, // flipped keepUpright, glyphOffsetArray: bucket.glyphOffsetArray, dynamicLayoutVertexArray, aspectRatio, rotateToLine, }).notEnoughRoom)) { hideGlyphs(symbol.numGlyphs, dynamicLayoutVertexArray); } } if (isText) { bucket.text.dynamicLayoutVertexBuffer.updateData(dynamicLayoutVertexArray); } else { bucket.icon.dynamicLayoutVertexBuffer.updateData(dynamicLayoutVertexArray); } } type FirstAndLastGlyphPlacement = { first: PlacedGlyph; last: PlacedGlyph; } | null; /* * Place the first and last glyph of a line label, projected to the label plane. * This function is called both during collision detection (to determine the label's size) * and during line label rendering (to make sure the label fits on the line geometry with * the current camera position, which may differ from the position used during collision detection). * * Calling this function has the effect of populating the "projectionCache" with all projected * vertex locations the label will need, making future calls to placeGlyphAlongLine (for all the * intermediate glyphs) much cheaper. * * Returns null if the label can't fit on the geometry */ export function placeFirstAndLastGlyph( fontScale: number, glyphOffsetArray: GlyphOffsetArray, lineOffsetX: number, lineOffsetY: number, flip: boolean, symbol: any, rotateToLine: boolean, projectionContext: SymbolProjectionContext): FirstAndLastGlyphPlacement { const glyphEndIndex = symbol.glyphStartIndex + symbol.numGlyphs; const lineStartIndex = symbol.lineStartIndex; const lineEndIndex = symbol.lineStartIndex + symbol.lineLength; const firstGlyphOffset = glyphOffsetArray.getoffsetX(symbol.glyphStartIndex); const lastGlyphOffset = glyphOffsetArray.getoffsetX(glyphEndIndex - 1); const firstPlacedGlyph = placeGlyphAlongLine(fontScale * firstGlyphOffset, lineOffsetX, lineOffsetY, flip, symbol.segment, lineStartIndex, lineEndIndex, projectionContext, rotateToLine); if (!firstPlacedGlyph) return null; const lastPlacedGlyph = placeGlyphAlongLine(fontScale * lastGlyphOffset, lineOffsetX, lineOffsetY, flip, symbol.segment, lineStartIndex, lineEndIndex, projectionContext, rotateToLine); if (!lastPlacedGlyph) return null; if (projectionContext.projectionCache.anyProjectionOccluded) { return null; } return {first: firstPlacedGlyph, last: lastPlacedGlyph}; } type OrientationChangeType = { useVertical?: boolean; needsFlipping?: boolean; }; function requiresOrientationChange(writingMode, firstPoint, lastPoint, aspectRatio): OrientationChangeType { if (writingMode === WritingMode.horizontal) { // On top of choosing whether to flip, choose whether to render this version of the glyphs or the alternate // vertical glyphs. We can't just filter out vertical glyphs in the horizontal range because the horizontal // and vertical versions can have slightly different projections which could lead to angles where both or // neither showed. const rise = Math.abs(lastPoint.y - firstPoint.y); const run = Math.abs(lastPoint.x - firstPoint.x) * aspectRatio; if (rise > run) { return {useVertical: true}; } } if (writingMode === WritingMode.vertical ? firstPoint.y < lastPoint.y : firstPoint.x > lastPoint.x) { // Includes "horizontalOnly" case for labels without vertical glyphs return {needsFlipping: true}; } return null; } type GlyphLinePlacementResult = OrientationChangeType & { notEnoughRoom?: boolean; }; type GlyphLinePlacementArgs = { projectionContext: SymbolProjectionContext; pitchedLabelPlaneMatrixInverse: mat4; symbol: any; // PlacedSymbolStruct fontSize: number; flip: boolean; keepUpright: boolean; glyphOffsetArray: GlyphOffsetArray; dynamicLayoutVertexArray: StructArray; aspectRatio: number; rotateToLine: boolean; }; /* * Place first and last glyph along the line projected to label plane, and if they fit * iterate through all the intermediate glyphs, calculating their label plane positions * from the projected line. * * Finally, add resulting glyph position calculations to dynamicLayoutVertexArray for * upload to the GPU */ function placeGlyphsAlongLine(args: GlyphLinePlacementArgs): GlyphLinePlacementResult { const { projectionContext, pitchedLabelPlaneMatrixInverse, symbol, fontSize, flip, keepUpright, glyphOffsetArray, dynamicLayoutVertexArray, aspectRatio, rotateToLine } = args; const fontScale = fontSize / 24; const lineOffsetX = symbol.lineOffsetX * fontScale; const lineOffsetY = symbol.lineOffsetY * fontScale; let placedGlyphs; if (symbol.numGlyphs > 1) { const glyphEndIndex = symbol.glyphStartIndex + symbol.numGlyphs; const lineStartIndex = symbol.lineStartIndex; const lineEndIndex = symbol.lineStartIndex + symbol.lineLength; // Place the first and the last glyph in the label first, so we can figure out // the overall orientation of the label and determine whether it needs to be flipped in keepUpright mode // Note: these glyphs are placed onto the label plane const firstAndLastGlyph = placeFirstAndLastGlyph(fontScale, glyphOffsetArray, lineOffsetX, lineOffsetY, flip, symbol, rotateToLine, projectionContext); if (!firstAndLastGlyph) { return {notEnoughRoom: true}; } const firstPoint = projectFromLabelPlaneToClipSpace(firstAndLastGlyph.first.point.x, firstAndLastGlyph.first.point.y, projectionContext, pitchedLabelPlaneMatrixInverse); const lastPoint = projectFromLabelPlaneToClipSpace(firstAndLastGlyph.last.point.x, firstAndLastGlyph.last.point.y, projectionContext, pitchedLabelPlaneMatrixInverse); if (keepUpright && !flip) { const orientationChange = requiresOrientationChange(symbol.writingMode, firstPoint, lastPoint, aspectRatio); if (orientationChange) { return orientationChange; } } placedGlyphs = [firstAndLastGlyph.first]; for (let glyphIndex = symbol.glyphStartIndex + 1; glyphIndex < glyphEndIndex - 1; glyphIndex++) { // Since first and last glyph fit on the line, try placing the rest of the glyphs. const placedGlyph = placeGlyphAlongLine(fontScale * glyphOffsetArray.getoffsetX(glyphIndex), lineOffsetX, lineOffsetY, flip, symbol.segment, lineStartIndex, lineEndIndex, projectionContext, rotateToLine); if (!placedGlyph) { return {notEnoughRoom: true}; } placedGlyphs.push(placedGlyph); } placedGlyphs.push(firstAndLastGlyph.last); } else { // Only a single glyph to place // So, determine whether to flip based on projected angle of the line segment it's on if (keepUpright && !flip) { const a = projectTileCoordinatesToLabelPlane(projectionContext.tileAnchorPoint.x, projectionContext.tileAnchorPoint.y, projectionContext).point; const tileVertexIndex = (symbol.lineStartIndex + symbol.segment + 1); const tileSegmentEnd = new Point(projectionContext.lineVertexArray.getx(tileVertexIndex), projectionContext.lineVertexArray.gety(tileVertexIndex)); const projectedVertex = projectTileCoordinatesToLabelPlane(tileSegmentEnd.x, tileSegmentEnd.y, projectionContext); // We know the anchor will be in the viewport, but the end of the line segment may be // behind the plane of the camera, in which case we can use a point at any arbitrary (closer) // point on the segment. const b = (projectedVertex.signedDistanceFromCamera > 0) ? projectedVertex.point : projectTruncatedLineSegmentToLabelPlane(projectionContext.tileAnchorPoint, tileSegmentEnd, a, 1, projectionContext); const clipSpaceA = projectFromLabelPlaneToClipSpace(a.x, a.y, projectionContext, pitchedLabelPlaneMatrixInverse); const clipSpaceB = projectFromLabelPlaneToClipSpace(b.x, b.y, projectionContext, pitchedLabelPlaneMatrixInverse); const orientationChange = requiresOrientationChange(symbol.writingMode, clipSpaceA, clipSpaceB, aspectRatio); if (orientationChange) { return orientationChange; } } const singleGlyph = placeGlyphAlongLine(fontScale * glyphOffsetArray.getoffsetX(symbol.glyphStartIndex), lineOffsetX, lineOffsetY, flip, symbol.segment, symbol.lineStartIndex, symbol.lineStartIndex + symbol.lineLength, projectionContext, rotateToLine); if (!singleGlyph || projectionContext.projectionCache.anyProjectionOccluded) return {notEnoughRoom: true}; placedGlyphs = [singleGlyph]; } for (const glyph of placedGlyphs) { addDynamicAttributes(dynamicLayoutVertexArray, glyph.point, glyph.angle); } return {}; } /** * Takes a line and direction from `previousTilePoint` to `currentTilePoint`, projects it to the correct label plane, * and returns a projected point along this projected line that is `minimumLength` distance away from `previousProjectedPoint`. * Projects a "virtual" vertex along a line segment. * @param previousTilePoint - Line start point, in tile coordinates. * @param currentTilePoint - Line end point, in tile coordinates. * @param previousProjectedPoint - Projection of `previousTilePoint` into label plane * @param minimumLength - Distance in the projected space along the line for the returned point. * @param projectionContext - Projection context, used to get terrain's `getElevation`, and to project the points to screen pixels. */ function projectTruncatedLineSegmentToLabelPlane(previousTilePoint: Point, currentTilePoint: Point, previousProjectedPoint: Point, minimumLength: number, projectionContext: SymbolProjectionContext) { // We are assuming "previousTilePoint" won't project to a point within one unit of the camera plane // If it did, that would mean our label extended all the way out from within the viewport to a (very distant) // point near the plane of the camera. We wouldn't be able to render the label anyway once it crossed the // plane of the camera. const unitVertexToBeProjected = previousTilePoint.add(previousTilePoint.sub(currentTilePoint)._unit()); const projectedUnitVertex = projectTileCoordinatesToLabelPlane(unitVertexToBeProjected.x, unitVertexToBeProjected.y, projectionContext).point; const projectedUnitSegment = previousProjectedPoint.sub(projectedUnitVertex); return previousProjectedPoint.add(projectedUnitSegment._mult(minimumLength / projectedUnitSegment.mag())); } type IndexToPointCache = { [lineIndex: number]: Point }; /** * @internal * We calculate label-plane projected points for line vertices as we place glyphs along the line * Since we will use the same vertices for potentially many glyphs, cache the results for this bucket * over the course of the render. Each vertex location also potentially has one offset equivalent * for us to hold onto. The vertex indices are per-symbol-bucket. */ type ProjectionCache = { /** * tile-unit vertices projected into label-plane units */ projections: IndexToPointCache; /** * label-plane vertices which have been shifted to follow an offset line */ offsets: IndexToPointCache; /** * Cached projected anchor point. */ cachedAnchorPoint: Point | undefined; /** * Was any projected point occluded by the map itself (eg. occluded by the planet when using globe projection). * * Viewport-pitched line-following texts where *any* of the line points is hidden behind the planet curve becomes entirely hidden. * This is perhaps not the most ideal behavior, but it works, it is simple and planetary-scale texts such as this seem to be a rare edge case. */ anyProjectionOccluded: boolean; }; /** * @internal * Arguments necessary to project a vertex to the label plane */ export type SymbolProjectionContext = { /** * Used to cache results, save cost if projecting the same vertex multiple times */ projectionCache: ProjectionCache; /** * The array of tile-unit vertices transferred from worker */ lineVertexArray: SymbolLineVertexArray; /** * Matrix for transforming from pixels (symbol shaping) to potentially rotated tile units (pitched map label plane). */ pitchedLabelPlaneMatrix: mat4; /** * Function to get elevation at a point * @param x - the x coordinate * @param y - the y coordinate */ getElevation: (x: number, y: number) => number; /** * Only for creating synthetic vertices if vertex would otherwise project behind plane of camera, * but still convenient to pass it inside this type. */ tileAnchorPoint: Point; /** * True when line glyphs are projected onto the map, instead of onto the viewport. */ pitchWithMap: boolean; transform: IReadonlyTransform; unwrappedTileID: UnwrappedTileID; /** * Viewport width. */ width: number; /** * Viewport height. */ height: number; /** * Translation in tile units, computed using text-translate and text-translate-anchor paint style properties. */ translation: [number, number]; }; /** * Only for creating synthetic vertices if vertex would otherwise project behind plane of camera */ export type ProjectionSyntheticVertexArgs = { distanceFromAnchor: number; previousVertex: Point; direction: number; absOffsetX: number; }; /** * Transform a vertex from tile coordinates to label plane coordinates * @param index - index of vertex to project * @param projectionContext - necessary data to project a vertex * @returns the vertex projected to the label plane */ export function projectLineVertexToLabelPlane(index: number, projectionContext: SymbolProjectionContext, syntheticVertexArgs: ProjectionSyntheticVertexArgs): Point { const cache = projectionContext.projectionCache; if (cache.projections[index]) { return cache.projections[index]; } const currentVertex = new Point( projectionContext.lineVertexArray.getx(index), projectionContext.lineVertexArray.gety(index)); const projection = projectTileCoordinatesToLabelPlane(currentVertex.x, currentVertex.y, projectionContext); if (projection.signedDistanceFromCamera > 0) { cache.projections[index] = projection.point; cache.anyProjectionOccluded = cache.anyProjectionOccluded || projection.isOccluded; return projection.point; } // The vertex is behind the plane of the camera, so we can't project it // Instead, we'll create a vertex along the line that's far enough to include the glyph const previousLineVertexIndex = index - syntheticVertexArgs.direction; const previousTilePoint = syntheticVertexArgs.distanceFromAnchor === 0 ? projectionContext.tileAnchorPoint : new Point(projectionContext.lineVertexArray.getx(previousLineVertexIndex), projectionContext.lineVertexArray.gety(previousLineVertexIndex)); // Don't cache because the new vertex might not be far enough out for future glyphs on the same segment const minimumLength = syntheticVertexArgs.absOffsetX - syntheticVertexArgs.distanceFromAnchor + 1; return projectTruncatedLineSegmentToLabelPlane(previousTilePoint, currentVertex, syntheticVertexArgs.previousVertex, minimumLength, projectionContext); } /** * Projects the given point in tile coordinates to the correct label plane. * If pitchWithMap is true, the (rotated) map plane in pixels is used, * otherwise screen pixels are used. */ export function projectTileCoordinatesToLabelPlane(x: number, y: number, projectionContext: SymbolProjectionContext): PointProjection { const translatedX = x + projectionContext.translation[0]; const translatedY = y + projectionContext.translation[1]; let projection; if (projectionContext.pitchWithMap) { projection = projectWithMatrix(translatedX, translatedY, projectionContext.pitchedLabelPlaneMatrix, projectionContext.getElevation); projection.isOccluded = false; } else { projection = projectionContext.transform.projectTileCoordinates(translatedX, translatedY, projectionContext.unwrappedTileID, projectionContext.getElevation); projection.point.x = (projection.point.x * 0.5 + 0.5) * projectionContext.width; projection.point.y = (-projection.point.y * 0.5 + 0.5) * projectionContext.height; } return projection; } function projectFromLabelPlaneToClipSpace(x: number, y: number, projectionContext: SymbolProjectionContext, pitchedLabelPlaneMatrixInverse: mat4): {x: number; y: number} { if (projectionContext.pitchWithMap) { const pos = [x, y, 0, 1] as vec4; vec4.transformMat4(pos, pos, pitchedLabelPlaneMatrixInverse); return projectionContext.transform.projectTileCoordinates(pos[0] / pos[3], pos[1] / pos[3], projectionContext.unwrappedTileID, projectionContext.getElevation).point; } else { return { x: (x / projectionContext.width) * 2.0 - 1.0, y: 1.0 - (y / projectionContext.height) * 2.0 }; } } /** * Projects the given point in tile coordinates to the GL clip space (-1..1). */ export function projectTileCoordinatesToClipSpace(x: number, y: number, projectionContext: SymbolProjectionContext): PointProjection { const projection = projectionContext.transform.projectTileCoordinates(x, y, projectionContext.unwrappedTileID, projectionContext.getElevation); return projection; } /** * Calculate the normal vector for a line segment * @param segmentVector - will be mutated as a tiny optimization * @param offset - magnitude of resulting vector * @param direction - direction of line traversal * @returns a normal vector from the segment, with magnitude equal to offset amount */ export function transformToOffsetNormal(segmentVector: Point, offset: number, direction: number): Point { return segmentVector._unit()._perp()._mult(offset * direction); } /** * Construct offset line segments for the current segment and the next segment, then extend/shrink * the segments until they intersect. If the segments are parallel, then they will touch with no modification. * * @param index - Index of the current vertex * @param prevToCurrentOffsetNormal - Normal vector of the line segment from the previous vertex to the current vertex * @param currentVertex - Current (non-offset) vertex projected to the label plane * @param lineStartIndex - Beginning index for the line this label is on * @param lineEndIndex - End index for the line this label is on * @param offsetPreviousVertex - The previous vertex projected to the label plane, and then offset along the previous segments normal * @param lineOffsetY - Magnitude of the offset * @param projectionContext - Necessary data for tile-to-label-plane projection * @returns The point at which the current and next line segments intersect, once offset and extended/shrunk to their meeting point */ export function findOffsetIntersectionPoint( index: number, prevToCurrentOffsetNormal: Point, currentVertex: Point, lineStartIndex: number, lineEndIndex: number, offsetPreviousVertex: Point, lineOffsetY: number, projectionContext: SymbolProjectionContext, syntheticVertexArgs: ProjectionSyntheticVertexArgs) { if (projectionContext.projectionCache.offsets[index]) { return projectionContext.projectionCache.offsets[index]; } const offsetCurrentVertex = currentVertex.add(prevToCurrentOffsetNormal); if (index + syntheticVertexArgs.direction < lineStartIndex || index + syntheticVertexArgs.direction >= lineEndIndex) { // This is the end of the line, no intersection to calculate projectionContext.projectionCache.offsets[index] = offsetCurrentVertex; return offsetCurrentVertex; } // Offset the vertices for the next segment const nextVertex = projectLineVertexToLabelPlane(index + syntheticVertexArgs.direction, projectionContext, syntheticVertexArgs); const currentToNextOffsetNormal = transformToOffsetNormal(nextVertex.sub(currentVertex), lineOffsetY, syntheticVertexArgs.direction); const offsetNextSegmentBegin = currentVertex.add(currentToNextOffsetNormal); const offsetNextSegmentEnd = nextVertex.add(currentToNextOffsetNormal); // find the intersection of these two lines // if the lines are parallel, offsetCurrent/offsetNextBegin will touch projectionContext.projectionCache.offsets[index] = findLineIntersection(offsetPreviousVertex, offsetCurrentVertex, offsetNextSegmentBegin, offsetNextSegmentEnd) || offsetCurrentVertex; return projectionContext.projectionCache.offsets[index]; } /** * Placed Glyph type */ type PlacedGlyph = { /** * The point at which the glyph should be placed, in label plane coordinates */ point: Point; /** * The angle at which the glyph should be placed */ angle: number; /** * The label-plane path used to reach this glyph: used only for collision detection */ path: Array<Point>; }; /* * Place a single glyph along its line, projected into the label plane, by iterating outward * from the anchor point until the distance traversed in the label plane equals the glyph's * offsetX. Returns null if the glyph can't fit on the line geometry. */ export function placeGlyphAlongLine( offsetX: number, lineOffsetX: number, lineOffsetY: number, flip: boolean, anchorSegment: number, lineStartIndex: number, lineEndIndex: number, projectionContext: SymbolProjectionContext, rotateToLine: boolean): PlacedGlyph | null { const combinedOffsetX = flip ? offsetX - lineOffsetX : offsetX + lineOffsetX; let direction = combinedOffsetX > 0 ? 1 : -1; let angle = 0; if (flip) { // The label needs to be flipped to keep text upright. // Iterate in the reverse direction. direction *= -1; angle = Math.PI; } if (direction < 0) angle += Math.PI; let currentIndex = direction > 0 ? lineStartIndex + anchorSegment : lineStartIndex + anchorSegment + 1; // Project anchor point to viewport and cache it let anchorPoint: Point; if (projectionContext.projectionCache.cachedAnchorPoint) { anchorPoint = projectionContext.projectionCache.cachedAnchorPoint; } else { anchorPoint = projectTileCoordinatesToLabelPlane(projectionContext.tileAnchorPoint.x, projectionContext.tileAnchorPoint.y, projectionContext).point; projectionContext.projectionCache.cachedAnchorPoint = anchorPoint; } let currentVertex = anchorPoint; let previousVertex = anchorPoint; // offsetPrev and intersectionPoint are analogous to previousVertex and currentVertex // but if there's a line offset they are calculated in parallel as projection happens let offsetIntersectionPoint: Point; let offsetPreviousVertex: Point; let distanceFromAnchor = 0; let currentSegmentDistance = 0; const absOffsetX = Math.abs(combinedOffsetX); const pathVertices: Array<Point> = []; let currentLineSegment: Point; while (distanceFromAnchor + currentSegmentDistance <= absOffsetX) { currentIndex += direction; // offset does not fit on the projected line if (currentIndex < lineStartIndex || currentIndex >= lineEndIndex) return null; // accumulate values from last iteration distanceFromAnchor += currentSegmentDistance; previousVertex = currentVertex; offsetPreviousVertex = offsetIntersectionPoint; const syntheticVertexArgs: ProjectionSyntheticVertexArgs = { absOffsetX, direction, distanceFromAnchor, previousVertex }; // find next vertex in viewport space currentVertex = projectLineVertexToLabelPlane(currentIndex, projectionContext, syntheticVertexArgs); if (lineOffsetY === 0) { // Store vertices for collision detection and update current segment geometry pathVertices.push(previousVertex); currentLineSegment = currentVertex.sub(previousVertex); } else { // Calculate the offset for this section let prevToCurrentOffsetNormal; const prevToCurrent = currentVertex.sub(previousVertex); if (prevToCurrent.mag() === 0) { // We are starting with our anchor point directly on the vertex, so look one vertex ahead // to calculate a normal const nextVertex = projectLineVertexToLabelPlane(currentIndex + direction, projectionContext, syntheticVertexArgs); prevToCurrentOffsetNormal = transformToOffsetNormal(nextVertex.sub(currentVertex), lineOffsetY, direction); } else { prevToCurrentOffsetNormal = transformToOffsetNormal(prevToCurrent, lineOffsetY, direction); } // Initialize offsetPrev on our first iteration, after that it will be pre-calculated if (!offsetPreviousVertex) offsetPreviousVertex = previousVertex.add(prevToCurrentOffsetNormal); offsetIntersectionPoint = findOffsetIntersectionPoint(currentIndex, prevToCurrentOffsetNormal, currentVertex, lineStartIndex, lineEndIndex, offsetPreviousVertex, lineOffsetY, projectionContext, syntheticVertexArgs); pathVertices.push(offsetPreviousVertex); currentLineSegment = offsetIntersectionPoint.sub(offsetPreviousVertex); } currentSegmentDistance = currentLineSegment.mag(); } // The point is on the current segment. Interpolate to find it. const segmentInterpolationT = (absOffsetX - distanceFromAnchor) / currentSegmentDistance; const p = currentLineSegment._mult(segmentInterpolationT)._add(offsetPreviousVertex || previousVertex); const segmentAngle = angle + Math.atan2(currentVertex.y - previousVertex.y, currentVertex.x - previousVertex.x); pathVertices.push(p); return { point: p, angle: rotateToLine ? segmentAngle : 0.0, path: pathVertices }; } const hiddenGlyphAttributes = new Float32Array([-Infinity, -Infinity, 0, -Infinity, -Infinity, 0, -Infinity, -Infinity, 0, -Infinity, -Infinity, 0]); // Hide them by moving them offscreen. We still need to add them to the buffer // because the dynamic buffer is paired with a static buffer that doesn't get updated. export function hideGlyphs(num: number, dynamicLayoutVertexArray: SymbolDynamicLayoutArray) { for (let i = 0; i < num; i++) { const offset = dynamicLayoutVertexArray.length; dynamicLayoutVertexArray.resize(offset + 4); // Since all hidden glyphs have the same attributes, we can build up the array faster with a single call to Float32Array.set // for each set of four vertices, instead of calling addDynamicAttributes for each vertex. dynamicLayoutVertexArray.float32.set(hiddenGlyphAttributes, offset * 3); } } // For line label layout, we're not using z output and our w input is always 1 // This custom matrix transformation ignores those components to make projection faster export function xyTransformMat4(out: vec4, a: vec4, m: mat4) { const x = a[0], y = a[1]; out[0] = m[0] * x + m[4] * y + m[12]; out[1] = m[1] * x + m[5] * y + m[13]; out[3] = m[3] * x + m[7] * y + m[15]; return out; } /** * Takes a path of points that was previously projected using the `pitchedLabelPlaneMatrix` * and projects it using the map projection's (mercator/globe...) `projectTileCoordinates` function. * Returns a new array of the projected points. * Does not modify the input array. */ export function projectPathSpecialProjection(projectedPath: Array<Point>, projectionContext: SymbolProjectionContext): Array<PointProjection> { const inverseLabelPlaneMatrix = mat4.create(); mat4.invert(inverseLabelPlaneMatrix, projectionContext.pitchedLabelPlaneMatrix); return projectedPath.map(p => { const backProjected = projectWithMatrix(p.x, p.y, inverseLabelPlaneMatrix, projectionContext.getElevation); const projected = projectionContext.transform.projectTileCoordinates( backProjected.point.x, backProjected.point.y, projectionContext.unwrappedTileID, projectionContext.getElevation ); projected.point.x = (projected.point.x * 0.5 + 0.5) * projectionContext.width; projected.point.y = (-projected.point.y * 0.5 + 0.5) * projectionContext.height; return projected; }); } /** * Takes a path of points projected to screenspace, finds the longest continuous unoccluded segment of that path * and returns it. * Does not modify the input array. */ export function pathSlicedToLongestUnoccluded(path: Array<PointProjection>): Array<PointProjection> { let longestUnoccludedStart = 0; let longestUnoccludedLength = 0; let currentUnoccludedStart = 0; let currentUnoccludedLength = 0; for (let i = 0; i < path.length; i++) { if (path[i].isOccluded) { currentUnoccludedStart = i + 1; currentUnoccludedLength = 0; } else { currentUnoccludedLength++; if (currentUnoccludedLength > longestUnoccludedLength) { longestUnoccludedLength = currentUnoccludedLength; longestUnoccludedStart = currentUnoccludedStart; } } } return path.slice(longestUnoccludedStart, longestUnoccludedStart + longestUnoccludedLength); }