From bcc5c4a2273e95291fa10b153558efc9ccea10c4 Mon Sep 17 00:00:00 2001 From: Isaac Besora Date: Mon, 25 Oct 2021 17:21:24 +0200 Subject: [PATCH 1/2] LineLabelSymbolizer refactor --- src/symbolizer.ts | 105 +++++++++++++++++++++++++++++++++------------- 1 file changed, 77 insertions(+), 28 deletions(-) diff --git a/src/symbolizer.ts b/src/symbolizer.ts index 44bd1d1f..5508abc6 100644 --- a/src/symbolizer.ts +++ b/src/symbolizer.ts @@ -6,7 +6,7 @@ import { GeomType, Feature, Bbox } from "./tilecache"; import polylabel from "polylabel"; import { NumberAttr, StringAttr, TextAttr, FontAttr } from "./attribute"; import { linebreak, isCjk } from "./text"; -import { lineCells, simpleLabel } from "./line"; +import { LabelCandidate, lineCells, simpleLabel } from "./line"; import { Index, Label, Layout } from "./labeler"; // https://bugs.webkit.org/show_bug.cgi?id=230751 @@ -810,35 +810,82 @@ export class LineLabelSymbolizer implements LabelSymbolizer { public place(layout: Layout, geom: Point[][], feature: Feature) { let name = this.text.get(layout.zoom, feature); - if (!name) return undefined; - if (name.length > this.maxLabelCodeUnits.get(layout.zoom, feature)) - return undefined; + if (this.canBeRendered(name, layout, feature)) { + const { width, height, cellSize, font } = this.getTextMetrics( + layout, + feature + ); + const repeatDistance = this.getRepeatDistance(layout, feature); + const labelCandidates = simpleLabel( + geom, + width, + repeatDistance, + cellSize + ); + if (labelCandidates.length !== 0) { + return this.renderLabelCandidates( + labelCandidates, + () => name, + layout, + feature, + font, + width, + height, + cellSize, + repeatDistance + ); + } + } + return undefined; + } - let MIN_LABELABLE_DIM = 20; - let fbbox = feature.bbox; - if ( - fbbox.maxY - fbbox.minY < MIN_LABELABLE_DIM && - fbbox.maxX - fbbox.minX < MIN_LABELABLE_DIM - ) - return undefined; + protected canBeRendered(name: string, layout: Layout, feature: Feature) { + const MIN_LABELABLE_DIM = 20; + const fbbox = feature.bbox; + const validName = + name && name.length <= this.maxLabelCodeUnits.get(layout.zoom, feature); + const validDimensions = + fbbox.maxY - fbbox.minY >= MIN_LABELABLE_DIM && + fbbox.maxX - fbbox.minX >= MIN_LABELABLE_DIM; + return validName && validDimensions; + } - let font = this.font.get(layout.zoom, feature); + protected getTextMetrics(layout: Layout, feature: Feature) { + const font = this.font.get(layout.zoom, feature); + const name = feature.props.name; layout.scratch.font = font; - let metrics = layout.scratch.measureText(name); - let width = metrics.width; - let height = + const metrics = layout.scratch.measureText(name); + const width = metrics.width; + const height = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; - var repeatDistance = this.repeatDistance.get(layout.zoom, feature); - if (layout.overzoom > 4) repeatDistance *= 1 << (layout.overzoom - 4); - - let cell_size = height * 2; + const cellSize = height * 2; - let label_candidates = simpleLabel(geom, width, repeatDistance, cell_size); - if (label_candidates.length == 0) return undefined; + return { width, height, cellSize, font }; + } - let labels = []; - for (let candidate of label_candidates) { + protected getRepeatDistance(layout: Layout, feature: Feature) { + let repeatDistance = this.repeatDistance.get(layout.zoom, feature); + if (layout.overzoom > 4) repeatDistance *= 1 << (layout.overzoom - 4); + return repeatDistance; + } + + protected renderLabelCandidates( + labelCandidates: LabelCandidate[], + getName: (candidate: LabelCandidate | LabelCandidateWithText) => string, + layout: Layout, + feature: Feature, + font: string, + width: number, + height: number, + cellSize: number, + repeatDistance: number, + allowCollisions = false + ) { + const labels = []; + for (let candidate of labelCandidates) { + const name = getName(candidate); + const completeText = feature.props.name; let dx = candidate.end.x - candidate.start.x; let dy = candidate.end.y - candidate.start.y; @@ -846,14 +893,14 @@ export class LineLabelSymbolizer implements LabelSymbolizer { candidate.start, candidate.end, width, - cell_size / 2 + cellSize / 2 ); let bboxes = cells.map((c) => { return { - minX: c.x - cell_size / 2, - minY: c.y - cell_size / 2, - maxX: c.x + cell_size / 2, - maxY: c.y + cell_size / 2, + minX: c.x - cellSize / 2, + minY: c.y - cellSize / 2, + maxX: c.x + cellSize / 2, + maxY: c.y + cellSize / 2, }; }); @@ -904,6 +951,8 @@ export class LineLabelSymbolizer implements LabelSymbolizer { draw: draw, deduplicationKey: name, deduplicationDistance: repeatDistance, + allowCollisions: allowCollisions, + collisionKey: completeText, }); } From bbe06056cca39ecc4a926596d38797050a849fe5 Mon Sep 17 00:00:00 2001 From: Isaac Besora Date: Mon, 25 Oct 2021 17:22:47 +0200 Subject: [PATCH 2/2] feat(symbolizer): Adds symbolizer for curve labels --- src/labeler.ts | 12 ++- src/line.ts | 9 ++- src/maths.ts | 8 ++ src/symbolizer.ts | 183 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 src/maths.ts diff --git a/src/labeler.ts b/src/labeler.ts index 38dd56bf..7ceb0c30 100644 --- a/src/labeler.ts +++ b/src/labeler.ts @@ -18,6 +18,8 @@ export interface Label { draw: (ctx: any, drawExtra?: DrawExtra) => void; deduplicationKey?: string; deduplicationDistance?: number; + allowCollisions?: boolean; + collisionKey?: string; } export interface IndexedLabel { @@ -135,7 +137,14 @@ export class Index { public labelCollides(label: Label, order: number): boolean { for (let bbox of label.bboxes) { for (let match of this.tree.search(bbox)) { - if (match.indexed_label.order <= order) return true; + if (match.indexed_label.order <= order) { + if ( + !!label.allowCollisions && + match.collisionKey === label.collisionKey + ) + return false; + return true; + } } } return false; @@ -187,6 +196,7 @@ export class Index { for (let bbox of label.bboxes) { var b: any = bbox; b.indexed_label = indexed_label; + if (label.collisionKey) b.collisionKey = label.collisionKey; this.tree.insert(b); if (bbox.minX < 0) wrapsLeft = true; diff --git a/src/line.ts b/src/line.ts index 94ffdcda..359a155d 100644 --- a/src/line.ts +++ b/src/line.ts @@ -98,7 +98,8 @@ export function simpleLabel( mls: any, minimum: number, repeatDistance: number, - cellSize: number + cellSize: number, + overflowAllowed = false ): LabelCandidate[] { let longestStart; let longestEnd; @@ -111,7 +112,7 @@ export function simpleLabel( for (let ls of mls) { let segments = linelabel(ls, Math.PI / 90); // 2 degrees, close to a straight line for (let segment of segments) { - if (segment.length >= minimum + cellSize) { + if (segment.length >= minimum + cellSize || overflowAllowed) { let start = new Point( ls[segment.beginIndex].x, ls[segment.beginIndex].y @@ -125,8 +126,8 @@ export function simpleLabel( // offset from the start by cellSize to allow streets that meet at right angles // to both be labeled. for ( - var i = cellSize; - i < segment.length - minimum; + var i = overflowAllowed ? 0 : cellSize; + i < segment.length - (overflowAllowed ? 0 : minimum); i += repeatDistance ) { candidates.push({ diff --git a/src/maths.ts b/src/maths.ts new file mode 100644 index 00000000..8bd7bdc2 --- /dev/null +++ b/src/maths.ts @@ -0,0 +1,8 @@ +// @ts-ignore +import Point from "@mapbox/point-geometry"; +export type Vector = Point; + +export const normalize = (v: Vector): Vector => { + const vectorMagnitude = Math.hypot(v.x, v.y); + return { x: v.x / vectorMagnitude, y: v.y / vectorMagnitude }; +}; diff --git a/src/symbolizer.ts b/src/symbolizer.ts index 5508abc6..54d03d91 100644 --- a/src/symbolizer.ts +++ b/src/symbolizer.ts @@ -8,6 +8,7 @@ import { NumberAttr, StringAttr, TextAttr, FontAttr } from "./attribute"; import { linebreak, isCjk } from "./text"; import { LabelCandidate, lineCells, simpleLabel } from "./line"; import { Index, Label, Layout } from "./labeler"; +import { normalize, Vector } from "./maths"; // https://bugs.webkit.org/show_bug.cgi?id=230751 const MAX_VERTICES_PER_DRAW_CALL = 5400; @@ -960,6 +961,188 @@ export class LineLabelSymbolizer implements LabelSymbolizer { } } +export interface LabelCandidateWithText extends LabelCandidate { + text: string; +} + +export class CurvedLabelSymbolizer extends LineLabelSymbolizer { + constructor(options: any) { + super(options); + } + + public place(layout: Layout, geom: Point[][], feature: Feature) { + let name = this.text.get(layout.zoom, feature); + if (this.canBeRendered(name, layout, feature)) { + const { width, height, cellSize, font } = this.getTextMetrics( + layout, + feature + ); + const repeatDistance = this.getRepeatDistance(layout, feature); + const labelCandidates = this.curvedLabel( + feature, + name, + layout, + geom, + repeatDistance, + cellSize + ); + if (labelCandidates.length !== 0) { + return this.renderLabelCandidates( + labelCandidates, + (c) => (c as LabelCandidateWithText).text, + layout, + feature, + font, + width, + height, + cellSize, + repeatDistance, + true + ); + } + } + return undefined; + } + + private curvedLabel( + feature: Feature, + name: string, + layout: Layout, + geom: Point[][], + repeatDistance: number, + cellSize: number + ): LabelCandidateWithText[] { + const candidates = []; + const chars = [...name]; + const vertices = geom[0]; + const vectors: Vector[] = []; + let totalLineLength = 0; + + // Compute each line segment length and vector + const segmentLengths = vertices.slice(0, -1).map((v, i) => { + const vector = { + x: vertices[i + 1].x - vertices[i].x, + y: vertices[i + 1].y - vertices[i].y, + }; + vectors.push(vector); + const length = Math.hypot(vector.x, vector.y); + totalLineLength += length; + return length; + }); + const font = this.font.get(layout.zoom, feature); + layout.scratch.font = font; + const totalTextLength = layout.scratch.measureText(name).width; + + //Early return if text doesn't fit in line + if (totalLineLength < totalTextLength) return []; + + // Compute an array that contains the length between the text start and + // char i on the i-th position. We compute the metrics of all the text + // instead of doing it char by char to account for text-spacing + const accumTextMetrics: number[] = []; + for (let charIdx = 0; charIdx < chars.length - 1; ++charIdx) { + const currentTextMetrics = layout.scratch.measureText( + name.slice(0, charIdx + 1) + ); + accumTextMetrics.push(currentTextMetrics.width); + } + accumTextMetrics.push(totalTextLength); + + let currentSegmentIdx = 0; + let distanceBetweenRenders = repeatDistance; + let remainingTextToRenderCharIdx = 0; + let lastRenderedLength = 0; + let remainingLineLength = totalLineLength; + for (; currentSegmentIdx < vertices.length - 1; ++currentSegmentIdx) { + remainingLineLength -= segmentLengths[currentSegmentIdx]; + if (distanceBetweenRenders >= repeatDistance) { + if (totalTextLength < segmentLengths[currentSegmentIdx]) { + // The whole text fits into this segment, create + // candidates on it + const segmentCandidates: LabelCandidate[] = simpleLabel( + [[vertices[currentSegmentIdx], vertices[currentSegmentIdx + 1]]], + totalTextLength, + repeatDistance, + cellSize + ); + const segmentCandidatesWithText = segmentCandidates.map((c) => { + return { + ...c, + text: name, + }; + }); + candidates.push(...segmentCandidatesWithText); + } else if ( + remainingTextToRenderCharIdx < chars.length && + remainingLineLength >= totalTextLength + ) { + // There are chars pending to render into this segment and + // there's enough place for another text instance + let breakCharIdx = remainingTextToRenderCharIdx; + const startMetrics = + accumTextMetrics[remainingTextToRenderCharIdx - 1] || 0; + // Find the character where the text needs to be split. In order to + // minimize gaps between segments we always render an extra character. + // This means that on the next segment we'll need to take that into account + while ( + accumTextMetrics[breakCharIdx] - startMetrics < + segmentLengths[currentSegmentIdx] && + breakCharIdx < chars.length - 1 + ) { + breakCharIdx++; + } + // Add the extra space on the old segment into the new one + // as the initial offset on the starting point so letters don't overlap + const extraSpaceOnSegment = Math.max( + 0, + lastRenderedLength - (segmentLengths[currentSegmentIdx - 1] || 0) + ); + lastRenderedLength = + accumTextMetrics[breakCharIdx] - + (accumTextMetrics[remainingTextToRenderCharIdx - 1] || 0); + const offsetX = extraSpaceOnSegment; + const offsetY = extraSpaceOnSegment; + const normalizedVector = normalize(vectors[currentSegmentIdx]); + const start = { + x: vertices[currentSegmentIdx].x + offsetX * normalizedVector.x, + y: vertices[currentSegmentIdx].y + offsetY * normalizedVector.y, + }; + // Create candidates on this line using the initial point + // computed before as the initial vertex + const textToRender = name.slice( + remainingTextToRenderCharIdx, + breakCharIdx + 1 + ); + const segmentCandidates: LabelCandidate[] = simpleLabel( + [[start, vertices[currentSegmentIdx + 1]]], + accumTextMetrics[breakCharIdx] - + (accumTextMetrics[remainingTextToRenderCharIdx - 1] || 0), + repeatDistance, + cellSize, + true + ); + const segmentCandidatesWithText = segmentCandidates.map((c) => { + return { + ...c, + text: textToRender, + }; + }); + candidates.push(...segmentCandidatesWithText); + remainingTextToRenderCharIdx = breakCharIdx + 1; + if (remainingTextToRenderCharIdx >= chars.length) { + distanceBetweenRenders = 0; + lastRenderedLength = 0; + remainingTextToRenderCharIdx = 0; + } + } else { + distanceBetweenRenders += segmentLengths[currentSegmentIdx]; + } + } + } + return candidates; + } +} + export class PolygonLabelSymbolizer implements LabelSymbolizer { symbolizer: LabelSymbolizer;