Rich text labels

This commit is contained in:
Andreas Hocevar
2022-02-21 23:25:33 +01:00
parent 1f8338d3b8
commit 18f06b8b9a
10 changed files with 313 additions and 48 deletions

View File

@@ -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"
---
<div id="map" class="map"></div>

View File

@@ -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);

View File

@@ -2,8 +2,6 @@
layout: example.html layout: example.html
title: Vector Label Decluttering title: Vector Label Decluttering
shortdesc: Label decluttering on polygons. shortdesc: Label decluttering on polygons.
resources:
- https://cdn.polyfill.io/v2/polyfill.min.js?features=Set"
docs: > 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. 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" tags: "vector, decluttering, labels"

View File

@@ -367,21 +367,36 @@ export function measureAndCacheTextWidth(font, text, cache) {
} }
/** /**
* @param {string} font Font to use for measuring. * @param {TextState} baseStyle Base style.
* @param {Array<string>} lines Lines to measure. * @param {Array<string>} chunks Text chunks to measure.
* @param {Array<number>} widths Array will be populated with the widths of * @return {{width: number, height: number, widths: Array<number>, heights: Array<number>, lineWidths: Array<number>}}} Text metrics.
* each line.
* @return {number} Width of the whole text.
*/ */
export function measureTextWidths(font, lines, widths) { export function getTextDimensions(baseStyle, chunks) {
const numLines = lines.length; const widths = [];
const heights = [];
const lineWidths = [];
let width = 0; let width = 0;
for (let i = 0; i < numLines; ++i) { let lineWidth = 0;
const currentWidth = measureTextWidth(font, lines[i]); let height = 0;
width = Math.max(width, currentWidth); let lineHeight = 0;
widths.push(currentWidth); 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;
} }
return width; 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, height, widths, heights, lineWidths};
} }
/** /**

View File

@@ -16,9 +16,8 @@ import {
defaultTextAlign, defaultTextAlign,
defaultTextBaseline, defaultTextBaseline,
drawImageOrLabel, drawImageOrLabel,
getTextDimensions,
measureAndCacheTextWidth, measureAndCacheTextWidth,
measureTextHeight,
measureTextWidths,
} from '../canvas.js'; } from '../canvas.js';
import {drawTextOnPath} from '../../geom/flat/textpath.js'; import {drawTextOnPath} from '../../geom/flat/textpath.js';
import {equals} from '../../array.js'; import {equals} from '../../array.js';
@@ -102,6 +101,20 @@ function horizontalTextAlign(text, align) {
return TEXT_ALIGN[align]; return TEXT_ALIGN[align];
} }
/**
* @param {Array<string>} acc Accumulator.
* @param {string} line Line of text.
* @param {number} i Index
* @return {Array<string>} Accumulator.
*/
function createTextChunks(acc, line, i) {
if (i > 0) {
acc.push('\n', '');
}
acc.push(line, '');
return acc;
}
class Executor { class Executor {
/** /**
* @param {number} resolution Resolution. * @param {number} resolution Resolution.
@@ -206,7 +219,7 @@ class Executor {
} }
/** /**
* @param {string} text Text. * @param {string|Array<string>} text Text.
* @param {string} textKey Text style key. * @param {string} textKey Text style key.
* @param {string} fillKey Fill style key. * @param {string} fillKey Fill style key.
* @param {string} strokeKey Stroke style key. * @param {string} strokeKey Stroke style key.
@@ -225,19 +238,22 @@ class Executor {
textState.scale[0] * pixelRatio, textState.scale[0] * pixelRatio,
textState.scale[1] * pixelRatio, textState.scale[1] * pixelRatio,
]; ];
const textIsArray = Array.isArray(text);
const align = horizontalTextAlign( const align = horizontalTextAlign(
text, textIsArray ? text[0] : text,
textState.textAlign || defaultTextAlign textState.textAlign || defaultTextAlign
); );
const strokeWidth = const strokeWidth =
strokeKey && strokeState.lineWidth ? strokeState.lineWidth : 0; strokeKey && strokeState.lineWidth ? strokeState.lineWidth : 0;
const lines = text.split('\n'); const chunks = textIsArray
const numLines = lines.length; ? text
const widths = []; : text.split('\n').reduce(createTextChunks, []);
const width = measureTextWidths(textState.font, lines, widths);
const lineHeight = measureTextHeight(textState.font); const {width, height, widths, heights, lineWidths} = getTextDimensions(
const height = lineHeight * numLines; textState,
chunks
);
const renderWidth = width + strokeWidth; const renderWidth = width + strokeWidth;
const contextInstructions = []; const contextInstructions = [];
// make canvas 2 pixels wider to account for italic text width measurement errors // 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) { if (scale[0] != 1 || scale[1] != 1) {
contextInstructions.push('scale', scale); contextInstructions.push('scale', scale);
} }
contextInstructions.push('font', textState.font);
if (strokeKey) { if (strokeKey) {
contextInstructions.push('strokeStyle', strokeState.strokeStyle); contextInstructions.push('strokeStyle', strokeState.strokeStyle);
contextInstructions.push('lineWidth', strokeWidth); contextInstructions.push('lineWidth', strokeWidth);
@@ -272,26 +287,52 @@ class Executor {
contextInstructions.push('textBaseline', 'middle'); contextInstructions.push('textBaseline', 'middle');
contextInstructions.push('textAlign', 'center'); contextInstructions.push('textAlign', 'center');
const leftRight = 0.5 - align; const leftRight = 0.5 - align;
const x = align * renderWidth + leftRight * strokeWidth; let x = align * renderWidth + leftRight * strokeWidth;
let i; const strokeInstructions = [];
if (strokeKey) { const fillInstructions = [];
for (i = 0; i < numLines; ++i) { let lineHeight = 0;
contextInstructions.push('strokeText', [ let lineOffset = 0;
lines[i], let widthHeightIndex = 0;
x + leftRight * widths[i], let lineWidthIndex = 0;
0.5 * (strokeWidth + lineHeight) + i * lineHeight, 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;
} }
const font = chunks[i + 1] || textState.font;
if (font !== previousFont) {
if (strokeKey) {
strokeInstructions.push('font', font);
} }
if (fillKey) { if (fillKey) {
for (i = 0; i < numLines; ++i) { fillInstructions.push('font', font);
contextInstructions.push('fillText', [
lines[i],
x + leftRight * widths[i],
0.5 * (strokeWidth + lineHeight) + i * lineHeight,
]);
} }
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; this.labels_[key] = label;
return label; return label;
} }
@@ -550,7 +591,7 @@ class Executor {
/** /**
* @private * @private
* @param {string} text The text to draw. * @param {string|Array<string>} text The text to draw.
* @param {string} textKey The key of the text state. * @param {string} textKey The key of the text state.
* @param {string} strokeKey The key for the stroke state. * @param {string} strokeKey The key for the stroke state.
* @param {string} fillKey The key for the fill state. * @param {string} fillKey The key for the fill state.
@@ -564,7 +605,7 @@ class Executor {
const strokeState = this.strokeStates[strokeKey]; const strokeState = this.strokeStates[strokeKey];
const pixelRatio = this.pixelRatio; const pixelRatio = this.pixelRatio;
const align = horizontalTextAlign( const align = horizontalTextAlign(
text, Array.isArray(text) ? text[0] : text,
textState.textAlign || defaultTextAlign textState.textAlign || defaultTextAlign
); );
const baseline = TEXT_ALIGN[textState.textBaseline || defaultTextBaseline]; const baseline = TEXT_ALIGN[textState.textBaseline || defaultTextBaseline];

View File

@@ -1146,7 +1146,12 @@ class CanvasImmediateRenderer extends VectorContext {
? textTextBaseline ? textTextBaseline
: defaultTextBaseline, : 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_ = this.textOffsetX_ =
textOffsetX !== undefined ? this.pixelRatio_ * textOffsetX : 0; textOffsetX !== undefined ? this.pixelRatio_ * textOffsetX : 0;
this.textOffsetY_ = this.textOffsetY_ =

View File

@@ -60,7 +60,7 @@ class CanvasTextBuilder extends CanvasBuilder {
/** /**
* @private * @private
* @type {string} * @type {string|Array<string>}
*/ */
this.text_ = ''; this.text_ = '';

View File

@@ -27,7 +27,11 @@ const DEFAULT_FILL_COLOR = '#333';
* @property {number|import("../size.js").Size} [scale] Scale. * @property {number|import("../size.js").Size} [scale] Scale.
* @property {boolean} [rotateWithView=false] Whether to rotate the text with the view. * @property {boolean} [rotateWithView=false] Whether to rotate the text with the view.
* @property {number} [rotation=0] Rotation in radians (positive rotation clockwise). * @property {number} [rotation=0] Rotation in radians (positive rotation clockwise).
* @property {string} [text] Text content. * @property {string|Array<string>} [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'. * @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 * Default is 'center' for `placement: 'point'`. For `placement: 'line'`, the default is to let the renderer choose a
* placement where `maxAngle` is not exceeded. * placement where `maxAngle` is not exceeded.
@@ -87,7 +91,7 @@ class Text {
/** /**
* @private * @private
* @type {string|undefined} * @type {string|Array<string>|undefined}
*/ */
this.text_ = options.text; this.text_ = options.text;
@@ -314,7 +318,7 @@ class Text {
/** /**
* Get the text to be rendered. * Get the text to be rendered.
* @return {string|undefined} Text. * @return {string|Array<string>|undefined} Text.
* @api * @api
*/ */
getText() { getText() {

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -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});