UNPKG

protomaps-leaflet

Version:

Vector tile rendering and labeling for [Leaflet](https://github.com/Leaflet/Leaflet).

1,079 lines (966 loc) 31.3 kB
import Point from "@mapbox/point-geometry"; import { ArrayAttr, AttrOption, FontAttr, FontAttrOptions, NumberAttr, StringAttr, TextAttr, TextAttrOptions, } from "./attribute"; import { Label, Layout } from "./labeler"; import { lineCells, simpleLabel } from "./line"; import { Sheet } from "./task"; import { linebreak } from "./text"; import { Bbox, Feature, GeomType } from "./tilecache"; export interface PaintSymbolizer { before?(ctx: CanvasRenderingContext2D, z: number): void; draw( ctx: CanvasRenderingContext2D, geom: Point[][], z: number, feature: Feature, ): void; } export enum Justify { Left = 1, Center = 2, Right = 3, } export enum TextPlacements { N = 1, Ne = 2, E = 3, Se = 4, S = 5, Sw = 6, W = 7, Nw = 8, } export interface DrawExtra { justify: Justify; } export interface LabelSymbolizer { /* the symbolizer can, but does not need to, inspect index to determine the right position * if return undefined, no label is added * return a label, but if the label collides it is not added */ place(layout: Layout, geom: Point[][], feature: Feature): Label[] | undefined; } export const createPattern = ( width: number, height: number, fn: (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => void, ) => { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); canvas.width = width; canvas.height = height; if (ctx !== null) fn(canvas, ctx); return canvas; }; export class PolygonSymbolizer implements PaintSymbolizer { pattern?: CanvasImageSource; fill: StringAttr; opacity: NumberAttr; stroke: StringAttr; width: NumberAttr; perFeature: boolean; doStroke: boolean; constructor(options: { pattern?: CanvasImageSource; fill?: AttrOption<string>; opacity?: AttrOption<number>; stroke?: AttrOption<string>; width?: AttrOption<number>; perFeature?: boolean; }) { this.pattern = options.pattern; this.fill = new StringAttr(options.fill, "black"); this.opacity = new NumberAttr(options.opacity, 1); this.stroke = new StringAttr(options.stroke, "black"); this.width = new NumberAttr(options.width, 0); this.perFeature = (this.fill.perFeature || this.opacity.perFeature || this.stroke.perFeature || this.width.perFeature || options.perFeature) ?? false; this.doStroke = false; } public before(ctx: CanvasRenderingContext2D, z: number) { if (!this.perFeature) { ctx.globalAlpha = this.opacity.get(z); ctx.fillStyle = this.fill.get(z); ctx.strokeStyle = this.stroke.get(z); const width = this.width.get(z); if (width > 0) this.doStroke = true; ctx.lineWidth = width; } if (this.pattern) { const patten = ctx.createPattern(this.pattern, "repeat"); if (patten) ctx.fillStyle = patten; } } public draw( ctx: CanvasRenderingContext2D, geom: Point[][], z: number, f: Feature, ) { let doStroke = false; if (this.perFeature) { ctx.globalAlpha = this.opacity.get(z, f); ctx.fillStyle = this.fill.get(z, f); const width = this.width.get(z, f); if (width) { doStroke = true; ctx.strokeStyle = this.stroke.get(z, f); ctx.lineWidth = width; } } const drawPath = () => { ctx.fill(); if (doStroke || this.doStroke) { ctx.stroke(); } }; ctx.beginPath(); for (const poly of geom) { for (let p = 0; p < poly.length; p++) { const pt = poly[p]; if (p === 0) ctx.moveTo(pt.x, pt.y); else ctx.lineTo(pt.x, pt.y); } } drawPath(); } } export function arr(base: number, a: number[]): (z: number) => number { return (z) => { const b = z - base; if (b >= 0 && b < a.length) { return a[b]; } return 0; }; } function getStopIndex(input: number, stops: number[][]): number { let idx = 0; while (stops[idx + 1][0] < input) idx++; return idx; } function interpolate(factor: number, start: number, end: number): number { return factor * (end - start) + start; } function computeInterpolationFactor( z: number, idx: number, base: number, stops: number[][], ): number { const difference = stops[idx + 1][0] - stops[idx][0]; const progress = z - stops[idx][0]; if (difference === 0) return 0; if (base === 1) return progress / difference; return (base ** progress - 1) / (base ** difference - 1); } export function exp(base: number, stops: number[][]): (z: number) => number { return (z) => { if (stops.length < 1) return 0; if (z <= stops[0][0]) return stops[0][1]; if (z >= stops[stops.length - 1][0]) return stops[stops.length - 1][1]; const idx = getStopIndex(z, stops); const factor = computeInterpolationFactor(z, idx, base, stops); return interpolate(factor, stops[idx][1], stops[idx + 1][1]); }; } export type Stop = [number, number] | [number, string] | [number, boolean]; export function step( output0: number | string | boolean, stops: Stop[], ): (z: number) => number | string | boolean { // Step computes discrete results by evaluating a piecewise-constant // function defined by stops. // Returns the output value of the stop with a stop input value just less than // the input one. If the input value is less than the input of the first stop, // output0 is returned return (z) => { if (stops.length < 1) return 0; let retval = output0; for (let i = 0; i < stops.length; i++) { if (z >= stops[i][0]) retval = stops[i][1]; } return retval; }; } export function linear(stops: number[][]): (z: number) => number { return exp(1, stops); } export class LineSymbolizer implements PaintSymbolizer { color: StringAttr; width: NumberAttr; opacity: NumberAttr; dash: ArrayAttr<number> | null; dashColor: StringAttr; dashWidth: NumberAttr; skip: boolean; perFeature: boolean; lineCap: StringAttr<CanvasLineCap>; lineJoin: StringAttr<CanvasLineJoin>; constructor(options: { color?: AttrOption<string>; width?: AttrOption<number>; opacity?: AttrOption<number>; dash?: number[]; dashColor?: AttrOption<string>; dashWidth?: AttrOption<number>; skip?: boolean; perFeature?: boolean; lineCap?: AttrOption<CanvasLineCap>; lineJoin?: AttrOption<CanvasLineJoin>; }) { this.color = new StringAttr(options.color, "black"); this.width = new NumberAttr(options.width); this.opacity = new NumberAttr(options.opacity); this.dash = options.dash ? new ArrayAttr(options.dash) : null; this.dashColor = new StringAttr(options.dashColor, "black"); this.dashWidth = new NumberAttr(options.dashWidth, 1.0); this.lineCap = new StringAttr(options.lineCap, "butt"); this.lineJoin = new StringAttr(options.lineJoin, "miter"); this.skip = false; this.perFeature = !!( this.dash?.perFeature || this.color.perFeature || this.opacity.perFeature || this.width.perFeature || this.lineCap.perFeature || this.lineJoin.perFeature || options.perFeature ); } public before(ctx: CanvasRenderingContext2D, z: number) { if (!this.perFeature) { ctx.strokeStyle = this.color.get(z); ctx.lineWidth = this.width.get(z); ctx.globalAlpha = this.opacity.get(z); ctx.lineCap = this.lineCap.get(z); ctx.lineJoin = this.lineJoin.get(z); } } public draw( ctx: CanvasRenderingContext2D, geom: Point[][], z: number, f: Feature, ) { if (this.skip) return; const strokePath = () => { if (this.perFeature) { ctx.globalAlpha = this.opacity.get(z, f); ctx.lineCap = this.lineCap.get(z, f); ctx.lineJoin = this.lineJoin.get(z, f); } if (this.dash) { ctx.save(); if (this.perFeature) { ctx.lineWidth = this.dashWidth.get(z, f); ctx.strokeStyle = this.dashColor.get(z, f); ctx.setLineDash(this.dash.get(z, f)); } else { ctx.setLineDash(this.dash.get(z)); } ctx.stroke(); ctx.restore(); } else { ctx.save(); if (this.perFeature) { ctx.lineWidth = this.width.get(z, f); ctx.strokeStyle = this.color.get(z, f); } ctx.stroke(); ctx.restore(); } }; ctx.beginPath(); for (const ls of geom) { for (let p = 0; p < ls.length; p++) { const pt = ls[p]; if (p === 0) ctx.moveTo(pt.x, pt.y); else ctx.lineTo(pt.x, pt.y); } } strokePath(); } } export interface IconSymbolizerOptions { name: string; sheet: Sheet; } export class IconSymbolizer implements LabelSymbolizer { name: string; sheet: Sheet; dpr: number; constructor(options: IconSymbolizerOptions) { this.name = options.name; this.sheet = options.sheet; this.dpr = window.devicePixelRatio; } public place(layout: Layout, geom: Point[][], feature: Feature) { const pt = geom[0]; const a = new Point(geom[0][0].x, geom[0][0].y); const loc = this.sheet.get(this.name); const width = loc.w / this.dpr; const height = loc.h / this.dpr; const bbox = { minX: a.x - width / 2, minY: a.y - height / 2, maxX: a.x + width / 2, maxY: a.y + height / 2, }; const draw = (ctx: CanvasRenderingContext2D) => { ctx.globalAlpha = 1; ctx.drawImage( this.sheet.canvas, loc.x, loc.y, loc.w, loc.h, -loc.w / 2 / this.dpr, -loc.h / 2 / this.dpr, loc.w / 2, loc.h / 2, ); }; return [{ anchor: a, bboxes: [bbox], draw: draw }]; } } export class CircleSymbolizer implements LabelSymbolizer, PaintSymbolizer { radius: NumberAttr; fill: StringAttr; stroke: StringAttr; width: NumberAttr; opacity: NumberAttr; constructor(options: { radius?: AttrOption<number>; fill?: AttrOption<string>; stroke?: AttrOption<string>; width?: AttrOption<number>; opacity?: AttrOption<number>; }) { this.radius = new NumberAttr(options.radius, 3); this.fill = new StringAttr(options.fill, "black"); this.stroke = new StringAttr(options.stroke, "white"); this.width = new NumberAttr(options.width, 0); this.opacity = new NumberAttr(options.opacity); } public draw( ctx: CanvasRenderingContext2D, geom: Point[][], z: number, f: Feature, ) { ctx.globalAlpha = this.opacity.get(z, f); const radius = this.radius.get(z, f); const width = this.width.get(z, f); if (width > 0) { ctx.strokeStyle = this.stroke.get(z, f); ctx.lineWidth = width; ctx.beginPath(); ctx.arc(geom[0][0].x, geom[0][0].y, radius + width / 2, 0, 2 * Math.PI); ctx.stroke(); } ctx.fillStyle = this.fill.get(z, f); ctx.beginPath(); ctx.arc(geom[0][0].x, geom[0][0].y, radius, 0, 2 * Math.PI); ctx.fill(); } public place(layout: Layout, geom: Point[][], feature: Feature) { const pt = geom[0]; const a = new Point(geom[0][0].x, geom[0][0].y); const radius = this.radius.get(layout.zoom, feature); const bbox = { minX: a.x - radius, minY: a.y - radius, maxX: a.x + radius, maxY: a.y + radius, }; const draw = (ctx: CanvasRenderingContext2D) => { this.draw(ctx, [[new Point(0, 0)]], layout.zoom, feature); }; return [{ anchor: a, bboxes: [bbox], draw }]; } } export class ShieldSymbolizer implements LabelSymbolizer { font: FontAttr; text: TextAttr; background: StringAttr; fill: StringAttr; padding: NumberAttr; constructor( options: { fill?: AttrOption<string>; background?: AttrOption<string>; padding?: AttrOption<number>; } & FontAttrOptions & TextAttrOptions, ) { this.font = new FontAttr(options); this.text = new TextAttr(options); this.fill = new StringAttr(options.fill, "black"); this.background = new StringAttr(options.background, "white"); this.padding = new NumberAttr(options.padding, 0); // TODO check falsy } public place(layout: Layout, geom: Point[][], f: Feature) { const property = this.text.get(layout.zoom, f); if (!property) return undefined; const font = this.font.get(layout.zoom, f); layout.scratch.font = font; const metrics = layout.scratch.measureText(property); const width = metrics.width; const ascent = metrics.actualBoundingBoxAscent; const descent = metrics.actualBoundingBoxDescent; const pt = geom[0]; const a = new Point(geom[0][0].x, geom[0][0].y); const p = this.padding.get(layout.zoom, f); const bbox = { minX: a.x - width / 2 - p, minY: a.y - ascent - p, maxX: a.x + width / 2 + p, maxY: a.y + descent + p, }; const draw = (ctx: CanvasRenderingContext2D) => { ctx.globalAlpha = 1; ctx.fillStyle = this.background.get(layout.zoom, f); ctx.fillRect( -width / 2 - p, -ascent - p, width + 2 * p, ascent + descent + 2 * p, ); ctx.fillStyle = this.fill.get(layout.zoom, f); ctx.font = font; ctx.fillText(property, -width / 2, 0); }; return [{ anchor: a, bboxes: [bbox], draw: draw }]; } } // TODO make me work with multiple anchors export class FlexSymbolizer implements LabelSymbolizer { list: LabelSymbolizer[]; constructor(list: LabelSymbolizer[]) { this.list = list; } public place(layout: Layout, geom: Point[][], feature: Feature) { let labels = this.list[0].place(layout, geom, feature); if (!labels) return undefined; let label = labels[0]; const anchor = label.anchor; let bbox = label.bboxes[0]; const height = bbox.maxY - bbox.minY; const draws = [{ draw: label.draw, translate: { x: 0, y: 0 } }]; const newGeom = [[new Point(geom[0][0].x, geom[0][0].y + height)]]; for (let i = 1; i < this.list.length; i++) { labels = this.list[i].place(layout, newGeom, feature); if (labels) { label = labels[0]; bbox = mergeBbox(bbox, label.bboxes[0]); draws.push({ draw: label.draw, translate: { x: 0, y: height } }); } } const draw = (ctx: CanvasRenderingContext2D) => { for (const sub of draws) { ctx.save(); ctx.translate(sub.translate.x, sub.translate.y); sub.draw(ctx); ctx.restore(); } }; return [{ anchor: anchor, bboxes: [bbox], draw: draw }]; } } const mergeBbox = (b1: Bbox, b2: Bbox) => { return { minX: Math.min(b1.minX, b2.minX), minY: Math.min(b1.minY, b2.minY), maxX: Math.max(b1.maxX, b2.maxX), maxY: Math.max(b1.maxY, b2.maxY), }; }; export class GroupSymbolizer implements LabelSymbolizer { list: LabelSymbolizer[]; constructor(list: LabelSymbolizer[]) { this.list = list; } public place(layout: Layout, geom: Point[][], feature: Feature) { const first = this.list[0]; if (!first) return undefined; let labels = first.place(layout, geom, feature); if (!labels) return undefined; let label = labels[0]; const anchor = label.anchor; let bbox = label.bboxes[0]; const draws = [label.draw]; for (let i = 1; i < this.list.length; i++) { labels = this.list[i].place(layout, geom, feature); if (!labels) return undefined; label = labels[0]; bbox = mergeBbox(bbox, label.bboxes[0]); draws.push(label.draw); } const draw = (ctx: CanvasRenderingContext2D) => { for (const d of draws) { d(ctx); } }; return [{ anchor: anchor, bboxes: [bbox], draw: draw }]; } } export class CenteredSymbolizer implements LabelSymbolizer { symbolizer: LabelSymbolizer; constructor(symbolizer: LabelSymbolizer) { this.symbolizer = symbolizer; } public place(layout: Layout, geom: Point[][], feature: Feature) { const a = geom[0][0]; const placed = this.symbolizer.place(layout, [[new Point(0, 0)]], feature); if (!placed || placed.length === 0) return undefined; const firstLabel = placed[0]; const bbox = firstLabel.bboxes[0]; const width = bbox.maxX - bbox.minX; const height = bbox.maxY - bbox.minY; const centered = { minX: a.x - width / 2, maxX: a.x + width / 2, minY: a.y - height / 2, maxY: a.y + height / 2, }; const draw = (ctx: CanvasRenderingContext2D) => { ctx.translate(-width / 2, height / 2 - bbox.maxY); firstLabel.draw(ctx, { justify: Justify.Center }); }; return [{ anchor: a, bboxes: [centered], draw: draw }]; } } export class Padding implements LabelSymbolizer { symbolizer: LabelSymbolizer; padding: NumberAttr; constructor(padding: number, symbolizer: LabelSymbolizer) { this.padding = new NumberAttr(padding, 0); this.symbolizer = symbolizer; } public place(layout: Layout, geom: Point[][], feature: Feature) { const placed = this.symbolizer.place(layout, geom, feature); if (!placed || placed.length === 0) return undefined; const padding = this.padding.get(layout.zoom, feature); for (const label of placed) { for (const bbox of label.bboxes) { bbox.minX -= padding; bbox.minY -= padding; bbox.maxX += padding; bbox.maxY += padding; } } return placed; } } export interface TextSymbolizerOptions extends FontAttrOptions, TextAttrOptions { fill?: AttrOption<string>; stroke?: AttrOption<string>; width?: AttrOption<number>; lineHeight?: AttrOption<number>; letterSpacing?: AttrOption<number>; maxLineChars?: AttrOption<number>; justify?: Justify; } export class TextSymbolizer implements LabelSymbolizer { font: FontAttr; text: TextAttr; fill: StringAttr; stroke: StringAttr; width: NumberAttr; lineHeight: NumberAttr; // in ems letterSpacing: NumberAttr; // in px maxLineCodeUnits: NumberAttr; justify?: Justify; constructor(options: TextSymbolizerOptions) { this.font = new FontAttr(options); this.text = new TextAttr(options); this.fill = new StringAttr(options.fill, "black"); this.stroke = new StringAttr(options.stroke, "black"); this.width = new NumberAttr(options.width, 0); this.lineHeight = new NumberAttr(options.lineHeight, 1); this.letterSpacing = new NumberAttr(options.letterSpacing, 0); this.maxLineCodeUnits = new NumberAttr(options.maxLineChars, 15); this.justify = options.justify; } public place(layout: Layout, geom: Point[][], feature: Feature) { const property = this.text.get(layout.zoom, feature); if (!property) return undefined; const font = this.font.get(layout.zoom, feature); layout.scratch.font = font; const letterSpacing = this.letterSpacing.get(layout.zoom, feature); // line breaking const lines = linebreak( property, this.maxLineCodeUnits.get(layout.zoom, feature), ); let longestLine = ""; let longestLineLen = 0; for (const line of lines) { if (line.length > longestLineLen) { longestLineLen = line.length; longestLine = line; } } const metrics = layout.scratch.measureText(longestLine); const width = metrics.width + letterSpacing * (longestLineLen - 1); const ascent = metrics.actualBoundingBoxAscent; const descent = metrics.actualBoundingBoxDescent; const lineHeight = (ascent + descent) * this.lineHeight.get(layout.zoom, feature); const a = new Point(geom[0][0].x, geom[0][0].y); const bbox = { minX: a.x, minY: a.y - ascent, maxX: a.x + width, maxY: a.y + descent + (lines.length - 1) * lineHeight, }; // inside draw, the origin is the anchor // and the anchor is the typographic baseline of the first line const draw = (ctx: CanvasRenderingContext2D, extra?: DrawExtra) => { ctx.globalAlpha = 1; ctx.font = font; ctx.fillStyle = this.fill.get(layout.zoom, feature); const textStrokeWidth = this.width.get(layout.zoom, feature); let y = 0; for (const line of lines) { let startX = 0; if ( this.justify === Justify.Center || (extra && extra.justify === Justify.Center) ) { startX = (width - ctx.measureText(line).width) / 2; } else if ( this.justify === Justify.Right || (extra && extra.justify === Justify.Right) ) { startX = width - ctx.measureText(line).width; } if (textStrokeWidth) { ctx.lineWidth = textStrokeWidth * 2; // centered stroke ctx.strokeStyle = this.stroke.get(layout.zoom, feature); if (letterSpacing > 0) { let xPos = startX; for (const letter of line) { ctx.strokeText(letter, xPos, y); xPos += ctx.measureText(letter).width + letterSpacing; } } else { ctx.strokeText(line, startX, y); } } if (letterSpacing > 0) { let xPos = startX; for (const letter of line) { ctx.fillText(letter, xPos, y); xPos += ctx.measureText(letter).width + letterSpacing; } } else { ctx.fillText(line, startX, y); } y += lineHeight; } }; return [{ anchor: a, bboxes: [bbox], draw: draw }]; } } export class CenteredTextSymbolizer implements LabelSymbolizer { centered: LabelSymbolizer; constructor(options: TextSymbolizerOptions) { this.centered = new CenteredSymbolizer(new TextSymbolizer(options)); } public place(layout: Layout, geom: Point[][], feature: Feature) { return this.centered.place(layout, geom, feature); } } export interface OffsetSymbolizerValues { offsetX?: number; offsetY?: number; placements?: TextPlacements[]; justify?: Justify; } export type DataDrivenOffsetSymbolizer = ( zoom: number, feature: Feature, ) => OffsetSymbolizerValues; export interface OffsetSymbolizerOptions { offsetX?: AttrOption<number>; offsetY?: AttrOption<number>; justify?: Justify; placements?: TextPlacements[]; ddValues?: DataDrivenOffsetSymbolizer; } export class OffsetSymbolizer implements LabelSymbolizer { symbolizer: LabelSymbolizer; offsetX: NumberAttr; offsetY: NumberAttr; justify?: Justify; placements: TextPlacements[]; ddValues: DataDrivenOffsetSymbolizer; constructor(symbolizer: LabelSymbolizer, options: OffsetSymbolizerOptions) { this.symbolizer = symbolizer; this.offsetX = new NumberAttr(options.offsetX, 0); this.offsetY = new NumberAttr(options.offsetY, 0); this.justify = options.justify ?? undefined; this.placements = options.placements ?? [ TextPlacements.Ne, TextPlacements.Sw, TextPlacements.Nw, TextPlacements.Se, TextPlacements.N, TextPlacements.E, TextPlacements.S, TextPlacements.W, ]; this.ddValues = options.ddValues ?? (() => { return {}; }); } public place(layout: Layout, geom: Point[][], feature: Feature) { if (feature.geomType !== GeomType.Point) return undefined; const anchor = geom[0][0]; const placed = this.symbolizer.place(layout, [[new Point(0, 0)]], feature); if (!placed || placed.length === 0) return undefined; const firstLabel = placed[0]; const fb = firstLabel.bboxes[0]; // Overwrite options values via the data driven function if exists let offsetXvalue = this.offsetX; let offsetYvalue = this.offsetY; let justifyValue = this.justify; let placements = this.placements; const { offsetX: ddOffsetX, offsetY: ddOffsetY, justify: ddJustify, placements: ddPlacements, } = this.ddValues(layout.zoom, feature) || {}; if (ddOffsetX) offsetXvalue = new NumberAttr(ddOffsetX, 0); if (ddOffsetY) offsetYvalue = new NumberAttr(ddOffsetY, 0); if (ddJustify) justifyValue = ddJustify; if (ddPlacements) placements = ddPlacements; const offsetX = offsetXvalue.get(layout.zoom, feature); const offsetY = offsetYvalue.get(layout.zoom, feature); const getBbox = (a: Point, o: Point) => { return { minX: a.x + o.x + fb.minX, minY: a.y + o.y + fb.minY, maxX: a.x + o.x + fb.maxX, maxY: a.y + o.y + fb.maxY, }; }; let origin = new Point(offsetX, offsetY); let justify: Justify; const draw = (ctx: CanvasRenderingContext2D) => { ctx.translate(origin.x, origin.y); firstLabel.draw(ctx, { justify: justify }); }; const placeLabelInPoint = (a: Point, o: Point) => { const bbox = getBbox(a, o); if (!layout.index.bboxCollides(bbox, layout.order)) return [{ anchor: anchor, bboxes: [bbox], draw: draw }]; }; for (const placement of placements) { const xAxisOffset = this.computeXaxisOffset(offsetX, fb, placement); const yAxisOffset = this.computeYaxisOffset(offsetY, fb, placement); justify = this.computeJustify(justifyValue, placement); origin = new Point(xAxisOffset, yAxisOffset); return placeLabelInPoint(anchor, origin); } return undefined; } computeXaxisOffset(offsetX: number, fb: Bbox, placement: TextPlacements) { const labelWidth = fb.maxX; const labelHalfWidth = labelWidth / 2; if ([TextPlacements.N, TextPlacements.S].includes(placement)) return offsetX - labelHalfWidth; if ( [TextPlacements.Nw, TextPlacements.W, TextPlacements.Sw].includes( placement, ) ) return offsetX - labelWidth; return offsetX; } computeYaxisOffset(offsetY: number, fb: Bbox, placement: TextPlacements) { const labelHalfHeight = Math.abs(fb.minY); const labelBottom = fb.maxY; const labelCenterHeight = (fb.minY + fb.maxY) / 2; if ([TextPlacements.E, TextPlacements.W].includes(placement)) return offsetY - labelCenterHeight; if ( [TextPlacements.Nw, TextPlacements.Ne, TextPlacements.N].includes( placement, ) ) return offsetY - labelBottom; if ( [TextPlacements.Sw, TextPlacements.Se, TextPlacements.S].includes( placement, ) ) return offsetY + labelHalfHeight; return offsetY; } computeJustify(fixedJustify: Justify | undefined, placement: TextPlacements) { if (fixedJustify) return fixedJustify; if ([TextPlacements.N, TextPlacements.S].includes(placement)) return Justify.Center; if ( [TextPlacements.Ne, TextPlacements.E, TextPlacements.Se].includes( placement, ) ) return Justify.Left; return Justify.Right; } } export class OffsetTextSymbolizer implements LabelSymbolizer { symbolizer: LabelSymbolizer; constructor(options: OffsetSymbolizerOptions & TextSymbolizerOptions) { this.symbolizer = new OffsetSymbolizer( new TextSymbolizer(options), options, ); } public place(layout: Layout, geom: Point[][], feature: Feature) { return this.symbolizer.place(layout, geom, feature); } } export enum LineLabelPlacement { Above = 1, Center = 2, Below = 3, } export class LineLabelSymbolizer implements LabelSymbolizer { font: FontAttr; text: TextAttr; fill: StringAttr; stroke: StringAttr; width: NumberAttr; offset: NumberAttr; position: LineLabelPlacement; maxLabelCodeUnits: NumberAttr; repeatDistance: NumberAttr; constructor( options: { radius?: AttrOption<number>; fill?: AttrOption<string>; stroke?: AttrOption<string>; width?: AttrOption<number>; offset?: AttrOption<number>; maxLabelChars?: AttrOption<number>; repeatDistance?: AttrOption<number>; position?: LineLabelPlacement; } & TextAttrOptions & FontAttrOptions, ) { this.font = new FontAttr(options); this.text = new TextAttr(options); this.fill = new StringAttr(options.fill, "black"); this.stroke = new StringAttr(options.stroke, "black"); this.width = new NumberAttr(options.width, 0); this.offset = new NumberAttr(options.offset, 0); this.position = options.position ?? LineLabelPlacement.Above; this.maxLabelCodeUnits = new NumberAttr(options.maxLabelChars, 40); this.repeatDistance = new NumberAttr(options.repeatDistance, 250); } public place(layout: Layout, geom: Point[][], feature: Feature) { const name = this.text.get(layout.zoom, feature); if (!name) return undefined; if (name.length > this.maxLabelCodeUnits.get(layout.zoom, feature)) return undefined; const minLabelableDim = 20; const fbbox = feature.bbox; if ( fbbox.maxY - fbbox.minY < minLabelableDim && fbbox.maxX - fbbox.minX < minLabelableDim ) return undefined; const font = this.font.get(layout.zoom, feature); layout.scratch.font = font; const metrics = layout.scratch.measureText(name); const width = metrics.width; const height = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; let repeatDistance = this.repeatDistance.get(layout.zoom, feature); if (layout.overzoom > 4) repeatDistance *= 1 << (layout.overzoom - 4); const cellSize = height * 2; const labelCandidates = simpleLabel(geom, width, repeatDistance, cellSize); if (labelCandidates.length === 0) return undefined; const labels = []; for (const candidate of labelCandidates) { const dx = candidate.end.x - candidate.start.x; const dy = candidate.end.y - candidate.start.y; const cells = lineCells( candidate.start, candidate.end, width, cellSize / 2, ); const bboxes = cells.map((c) => { return { minX: c.x - cellSize / 2, minY: c.y - cellSize / 2, maxX: c.x + cellSize / 2, maxY: c.y + cellSize / 2, }; }); const draw = (ctx: CanvasRenderingContext2D) => { ctx.globalAlpha = 1; // ctx.beginPath(); // ctx.moveTo(0, 0); // ctx.lineTo(dx, dy); // ctx.strokeStyle = "red"; // ctx.stroke(); ctx.rotate(Math.atan2(dy, dx)); if (dx < 0) { ctx.scale(-1, -1); ctx.translate(-width, 0); } let heightPlacement = 0; if (this.position === LineLabelPlacement.Below) heightPlacement += height; else if (this.position === LineLabelPlacement.Center) heightPlacement += height / 2; ctx.translate( 0, heightPlacement - this.offset.get(layout.zoom, feature), ); ctx.font = font; const lineWidth = this.width.get(layout.zoom, feature); if (lineWidth) { ctx.lineWidth = lineWidth; ctx.strokeStyle = this.stroke.get(layout.zoom, feature); ctx.strokeText(name, 0, 0); } ctx.fillStyle = this.fill.get(layout.zoom, feature); ctx.fillText(name, 0, 0); }; labels.push({ anchor: candidate.start, bboxes: bboxes, draw: draw, deduplicationKey: name, deduplicationDistance: repeatDistance, }); } return labels; } }