diff --git a/examples/rich-text-labels.html b/examples/rich-text-labels.html new file mode 100644 index 0000000000..546ec3aff0 --- /dev/null +++ b/examples/rich-text-labels.html @@ -0,0 +1,9 @@ +--- +layout: example.html +title: Rich Text Labels +shortdesc: Rich text labels. +docs: > + The labels in this map use different fonts to create clear context - an alphabetic sort key prefixing the state name in bold, and the population density in an extra line with a smaller font and italic. +tags: "vector, rich-text, labels" +--- +
diff --git a/examples/rich-text-labels.js b/examples/rich-text-labels.js new file mode 100644 index 0000000000..f74c883a2f --- /dev/null +++ b/examples/rich-text-labels.js @@ -0,0 +1,64 @@ +import GeoJSON from '../src/ol/format/GeoJSON.js'; +import Map from '../src/ol/Map.js'; +import VectorLayer from '../src/ol/layer/Vector.js'; +import VectorSource from '../src/ol/source/Vector.js'; +import View from '../src/ol/View.js'; +import {Fill, Stroke, Style, Text} from '../src/ol/style.js'; + +const map = new Map({ + target: 'map', + view: new View({ + center: [0, 0], + zoom: 2, + extent: [-13882269, 2890586, -7456136, 6340207], + showFullExtent: true, + }), +}); + +const labelStyle = new Style({ + text: new Text({ + font: '13px Calibri,sans-serif', + fill: new Fill({ + color: '#000', + }), + stroke: new Stroke({ + color: '#fff', + width: 4, + }), + }), +}); +const countryStyle = new Style({ + fill: new Fill({ + color: 'rgba(255, 255, 255, 0.6)', + }), + stroke: new Stroke({ + color: '#319FD3', + width: 1, + }), +}); +const style = [countryStyle, labelStyle]; + +const vectorLayer = new VectorLayer({ + background: 'white', + source: new VectorSource({ + url: 'https://openlayers.org/data/vector/us-states.json', + format: new GeoJSON(), + }), + style: function (feature) { + labelStyle + .getText() + .setText([ + feature.getId(), + 'bold 13px Calibri,sans-serif', + ` ${feature.get('name')}`, + '', + '\n', + '', + `${feature.get('density')} people/mi²`, + 'italic 11px Calibri,sans-serif', + ]); + return style; + }, +}); + +map.addLayer(vectorLayer); diff --git a/examples/vector-label-decluttering.html b/examples/vector-label-decluttering.html index c453b3a2d2..e756ee797f 100644 --- a/examples/vector-label-decluttering.html +++ b/examples/vector-label-decluttering.html @@ -2,8 +2,6 @@ layout: example.html title: Vector Label Decluttering shortdesc: Label decluttering on polygons. -resources: - - https://cdn.polyfill.io/v2/polyfill.min.js?features=Set" docs: > Decluttering is used to avoid overlapping labels. The `overflow: true` setting on the text style makes it so labels that do not fit within the bounds of a polygon are also considered for decluttering. tags: "vector, decluttering, labels" diff --git a/src/ol/render/canvas.js b/src/ol/render/canvas.js index 6202e5f92d..6ec5947a58 100644 --- a/src/ol/render/canvas.js +++ b/src/ol/render/canvas.js @@ -367,21 +367,36 @@ export function measureAndCacheTextWidth(font, text, cache) { } /** - * @param {string} font Font to use for measuring. - * @param {Array} lines Lines to measure. - * @param {Array} widths Array will be populated with the widths of - * each line. - * @return {number} Width of the whole text. + * @param {TextState} baseStyle Base style. + * @param {Array} chunks Text chunks to measure. + * @return {{width: number, height: number, widths: Array, heights: Array, lineWidths: Array}}} Text metrics. */ -export function measureTextWidths(font, lines, widths) { - const numLines = lines.length; +export function getTextDimensions(baseStyle, chunks) { + const widths = []; + const heights = []; + const lineWidths = []; let width = 0; - for (let i = 0; i < numLines; ++i) { - const currentWidth = measureTextWidth(font, lines[i]); - width = Math.max(width, currentWidth); + let lineWidth = 0; + let height = 0; + let lineHeight = 0; + for (let i = 0, ii = chunks.length; i <= ii; i += 2) { + const text = chunks[i]; + if (text === '\n' || i === ii) { + width = Math.max(width, lineWidth); + lineWidths.push(lineWidth); + lineWidth = 0; + height += lineHeight; + continue; + } + const font = chunks[i + 1] || baseStyle.font; + const currentWidth = measureTextWidth(font, text); widths.push(currentWidth); + lineWidth += currentWidth; + const currentHeight = measureTextHeight(font); + heights.push(currentHeight); + lineHeight = Math.max(lineHeight, currentHeight); } - return width; + return {width, height, widths, heights, lineWidths}; } /** diff --git a/src/ol/render/canvas/Executor.js b/src/ol/render/canvas/Executor.js index 144eeeeb69..1a888b0d15 100644 --- a/src/ol/render/canvas/Executor.js +++ b/src/ol/render/canvas/Executor.js @@ -16,9 +16,8 @@ import { defaultTextAlign, defaultTextBaseline, drawImageOrLabel, + getTextDimensions, measureAndCacheTextWidth, - measureTextHeight, - measureTextWidths, } from '../canvas.js'; import {drawTextOnPath} from '../../geom/flat/textpath.js'; import {equals} from '../../array.js'; @@ -102,6 +101,20 @@ function horizontalTextAlign(text, align) { return TEXT_ALIGN[align]; } +/** + * @param {Array} acc Accumulator. + * @param {string} line Line of text. + * @param {number} i Index + * @return {Array} Accumulator. + */ +function createTextChunks(acc, line, i) { + if (i > 0) { + acc.push('\n', ''); + } + acc.push(line, ''); + return acc; +} + class Executor { /** * @param {number} resolution Resolution. @@ -206,7 +219,7 @@ class Executor { } /** - * @param {string} text Text. + * @param {string|Array} text Text. * @param {string} textKey Text style key. * @param {string} fillKey Fill style key. * @param {string} strokeKey Stroke style key. @@ -225,19 +238,22 @@ class Executor { textState.scale[0] * pixelRatio, textState.scale[1] * pixelRatio, ]; + const textIsArray = Array.isArray(text); const align = horizontalTextAlign( - text, + textIsArray ? text[0] : text, textState.textAlign || defaultTextAlign ); const strokeWidth = strokeKey && strokeState.lineWidth ? strokeState.lineWidth : 0; - const lines = text.split('\n'); - const numLines = lines.length; - const widths = []; - const width = measureTextWidths(textState.font, lines, widths); - const lineHeight = measureTextHeight(textState.font); - const height = lineHeight * numLines; + const chunks = textIsArray + ? text + : text.split('\n').reduce(createTextChunks, []); + + const {width, height, widths, heights, lineWidths} = getTextDimensions( + textState, + chunks + ); const renderWidth = width + strokeWidth; const contextInstructions = []; // make canvas 2 pixels wider to account for italic text width measurement errors @@ -252,7 +268,6 @@ class Executor { if (scale[0] != 1 || scale[1] != 1) { contextInstructions.push('scale', scale); } - contextInstructions.push('font', textState.font); if (strokeKey) { contextInstructions.push('strokeStyle', strokeState.strokeStyle); contextInstructions.push('lineWidth', strokeWidth); @@ -272,26 +287,52 @@ class Executor { contextInstructions.push('textBaseline', 'middle'); contextInstructions.push('textAlign', 'center'); const leftRight = 0.5 - align; - const x = align * renderWidth + leftRight * strokeWidth; - let i; - if (strokeKey) { - for (i = 0; i < numLines; ++i) { - contextInstructions.push('strokeText', [ - lines[i], - x + leftRight * widths[i], - 0.5 * (strokeWidth + lineHeight) + i * lineHeight, - ]); + let x = align * renderWidth + leftRight * strokeWidth; + const strokeInstructions = []; + const fillInstructions = []; + let lineHeight = 0; + let lineOffset = 0; + let widthHeightIndex = 0; + let lineWidthIndex = 0; + let previousFont; + for (let i = 0, ii = chunks.length; i < ii; i += 2) { + const text = chunks[i]; + if (text === '\n') { + lineOffset += lineHeight; + lineHeight = 0; + x = align * renderWidth + leftRight * strokeWidth; + ++lineWidthIndex; + continue; } - } - if (fillKey) { - for (i = 0; i < numLines; ++i) { - contextInstructions.push('fillText', [ - lines[i], - x + leftRight * widths[i], - 0.5 * (strokeWidth + lineHeight) + i * lineHeight, - ]); + const font = chunks[i + 1] || textState.font; + if (font !== previousFont) { + if (strokeKey) { + strokeInstructions.push('font', font); + } + if (fillKey) { + fillInstructions.push('font', font); + } + previousFont = font; } + lineHeight = Math.max(lineHeight, heights[widthHeightIndex]); + const fillStrokeArgs = [ + text, + x + + leftRight * widths[widthHeightIndex] + + align * (widths[widthHeightIndex] - lineWidths[lineWidthIndex]), + 0.5 * (strokeWidth + lineHeight) + lineOffset, + ]; + x += widths[widthHeightIndex]; + if (strokeKey) { + strokeInstructions.push('strokeText', fillStrokeArgs); + } + if (fillKey) { + fillInstructions.push('fillText', fillStrokeArgs); + } + ++widthHeightIndex; } + Array.prototype.push.apply(contextInstructions, strokeInstructions); + Array.prototype.push.apply(contextInstructions, fillInstructions); this.labels_[key] = label; return label; } @@ -550,7 +591,7 @@ class Executor { /** * @private - * @param {string} text The text to draw. + * @param {string|Array} text The text to draw. * @param {string} textKey The key of the text state. * @param {string} strokeKey The key for the stroke state. * @param {string} fillKey The key for the fill state. @@ -564,7 +605,7 @@ class Executor { const strokeState = this.strokeStates[strokeKey]; const pixelRatio = this.pixelRatio; const align = horizontalTextAlign( - text, + Array.isArray(text) ? text[0] : text, textState.textAlign || defaultTextAlign ); const baseline = TEXT_ALIGN[textState.textBaseline || defaultTextBaseline]; diff --git a/src/ol/render/canvas/Immediate.js b/src/ol/render/canvas/Immediate.js index de0aba4e5f..ab7a55397a 100644 --- a/src/ol/render/canvas/Immediate.js +++ b/src/ol/render/canvas/Immediate.js @@ -1146,7 +1146,12 @@ class CanvasImmediateRenderer extends VectorContext { ? textTextBaseline : defaultTextBaseline, }; - this.text_ = textText !== undefined ? textText : ''; + this.text_ = + textText !== undefined + ? Array.isArray(textText) + ? textText.reduce((acc, t, i) => (acc += i % 2 ? ' ' : t), '') + : textText + : ''; this.textOffsetX_ = textOffsetX !== undefined ? this.pixelRatio_ * textOffsetX : 0; this.textOffsetY_ = diff --git a/src/ol/render/canvas/TextBuilder.js b/src/ol/render/canvas/TextBuilder.js index 849cd872d5..b7bb31708e 100644 --- a/src/ol/render/canvas/TextBuilder.js +++ b/src/ol/render/canvas/TextBuilder.js @@ -60,7 +60,7 @@ class CanvasTextBuilder extends CanvasBuilder { /** * @private - * @type {string} + * @type {string|Array} */ this.text_ = ''; diff --git a/src/ol/style/Text.js b/src/ol/style/Text.js index 2c044ed8c0..c8fa321331 100644 --- a/src/ol/style/Text.js +++ b/src/ol/style/Text.js @@ -27,7 +27,11 @@ const DEFAULT_FILL_COLOR = '#333'; * @property {number|import("../size.js").Size} [scale] Scale. * @property {boolean} [rotateWithView=false] Whether to rotate the text with the view. * @property {number} [rotation=0] Rotation in radians (positive rotation clockwise). - * @property {string} [text] Text content. + * @property {string|Array} [text] Text content or rich text content. For plain text provide a string, which can + * contain line breaks (`\n`). For rich text provide an array of text/font tuples. A tuple consists of the text to + * render and the font to use (or `''` to use the text style's font). A line break has to be a separate tuple (i.e. `'\n', ''`). + * **Example:** `['foo', 'bold 10px sans-serif', ' bar', 'italic 10px sans-serif', ' baz', '']` will yield "**foo** *bar* baz". + * **Note:** Rich text is not supported for the immediate rendering API. * @property {string} [textAlign] Text alignment. Possible values: 'left', 'right', 'center', 'end' or 'start'. * Default is 'center' for `placement: 'point'`. For `placement: 'line'`, the default is to let the renderer choose a * placement where `maxAngle` is not exceeded. @@ -87,7 +91,7 @@ class Text { /** * @private - * @type {string|undefined} + * @type {string|Array|undefined} */ this.text_ = options.text; @@ -314,7 +318,7 @@ class Text { /** * Get the text to be rendered. - * @return {string|undefined} Text. + * @return {string|Array|undefined} Text. * @api */ getText() { diff --git a/test/rendering/cases/rich-text-style/expected.png b/test/rendering/cases/rich-text-style/expected.png new file mode 100644 index 0000000000..c75627d500 Binary files /dev/null and b/test/rendering/cases/rich-text-style/expected.png differ diff --git a/test/rendering/cases/rich-text-style/main.js b/test/rendering/cases/rich-text-style/main.js new file mode 100644 index 0000000000..68e387d865 --- /dev/null +++ b/test/rendering/cases/rich-text-style/main.js @@ -0,0 +1,129 @@ +import CircleStyle from '../../../../src/ol/style/Circle.js'; +import Feature from '../../../../src/ol/Feature.js'; +import Fill from '../../../../src/ol/style/Fill.js'; +import Map from '../../../../src/ol/Map.js'; +import Point from '../../../../src/ol/geom/Point.js'; +import Stroke from '../../../../src/ol/style/Stroke.js'; +import Style from '../../../../src/ol/style/Style.js'; +import Text from '../../../../src/ol/style/Text.js'; +import VectorLayer from '../../../../src/ol/layer/Vector.js'; +import VectorSource from '../../../../src/ol/source/Vector.js'; +import View from '../../../../src/ol/View.js'; + +const vectorSource = new VectorSource({ + features: [ + // inline right-bottom + new Feature({ + geometry: new Point([-10, 50]), + text: [ + 'in', + '', + 'line', + 'italic 20px/1.5 Ubuntu', + '\n', + '', + 'right-bottom', + '20px/1.2 Ubuntu', + ], + textAlign: 'right', + textBaseline: 'bottom', + }), + // multi-line - center-middle + new Feature({ + geometry: new Point([0, 0]), + text: ['multi-line', '', '\n', '', 'center-middle', 'italic 20px Ubuntu'], + textAlign: 'center', + textBaseline: 'middle', + }), + + // inline right-top + new Feature({ + geometry: new Point([-10, -50]), + text: [ + 'in', + '', + 'line', + 'italic 20px/1.5 Ubuntu', + '\n', + '', + 'right-top', + '28px/1 Ubuntu', + ], + textAlign: 'right', + textBaseline: 'top', + }), + + // inline left-bottom + new Feature({ + geometry: new Point([10, 50]), + text: [ + 'in', + '', + 'line', + 'italic 20px/1.5 Ubuntu', + '\n', + '', + 'left-bottom', + '20px/1.2 Ubuntu', + ], + textAlign: 'left', + textBaseline: 'bottom', + }), + + // inline left-top + new Feature({ + geometry: new Point([10, -50]), + text: [ + 'in', + '', + 'line', + 'italic 20px/1.5 Ubuntu', + '\n', + '', + 'left-top', + '28px/1 Ubuntu', + ], + textAlign: 'left', + textBaseline: 'top', + }), + ], +}); + +new Map({ + pixelRatio: 1, + layers: [ + new VectorLayer({ + source: vectorSource, + style: function (feature) { + return new Style({ + text: new Text({ + text: feature.get('text'), + font: '24px Ubuntu', + textAlign: feature.get('textAlign'), + textBaseline: feature.get('textBaseline'), + fill: new Fill({ + color: 'black', + }), + stroke: new Stroke({ + color: 'white', + }), + backgroundStroke: new Stroke({width: 1}), + }), + image: new CircleStyle({ + radius: 10, + fill: new Fill({ + color: 'cyan', + }), + }), + }); + }, + }), + ], + target: 'map', + view: new View({ + center: [0, 0], + resolution: 1, + }), +}); + +render({tolerance: 0.01});