390 lines
8.3 KiB
JavaScript
390 lines
8.3 KiB
JavaScript
import {
|
|
isObject,
|
|
assign,
|
|
forEach,
|
|
reduce
|
|
} from 'min-dash';
|
|
|
|
import {
|
|
append as svgAppend,
|
|
attr as svgAttr,
|
|
create as svgCreate,
|
|
remove as svgRemove
|
|
} from 'tiny-svg';
|
|
|
|
import {
|
|
assignStyle
|
|
} from 'min-dom';
|
|
|
|
/**
|
|
* @typedef {import('../util/Types').Dimensions} Dimensions
|
|
*/
|
|
|
|
var DEFAULT_BOX_PADDING = 0;
|
|
|
|
var DEFAULT_LABEL_SIZE = {
|
|
width: 150,
|
|
height: 50
|
|
};
|
|
|
|
|
|
function parseAlign(align) {
|
|
|
|
var parts = align.split('-');
|
|
|
|
return {
|
|
horizontal: parts[0] || 'center',
|
|
vertical: parts[1] || 'top'
|
|
};
|
|
}
|
|
|
|
function parsePadding(padding) {
|
|
|
|
if (isObject(padding)) {
|
|
return assign({ top: 0, left: 0, right: 0, bottom: 0 }, padding);
|
|
} else {
|
|
return {
|
|
top: padding,
|
|
left: padding,
|
|
right: padding,
|
|
bottom: padding
|
|
};
|
|
}
|
|
}
|
|
|
|
function getTextBBox(text, fakeText) {
|
|
|
|
fakeText.textContent = text;
|
|
|
|
var textBBox;
|
|
|
|
try {
|
|
var bbox,
|
|
emptyLine = text === '';
|
|
|
|
// add dummy text, when line is empty to
|
|
// determine correct height
|
|
fakeText.textContent = emptyLine ? 'dummy' : text;
|
|
|
|
textBBox = fakeText.getBBox();
|
|
|
|
// take text rendering related horizontal
|
|
// padding into account
|
|
bbox = {
|
|
width: textBBox.width + textBBox.x * 2,
|
|
height: textBBox.height
|
|
};
|
|
|
|
if (emptyLine) {
|
|
|
|
// correct width
|
|
bbox.width = 0;
|
|
}
|
|
|
|
return bbox;
|
|
} catch (e) {
|
|
return { width: 0, height: 0 };
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Layout the next line and return the layouted element.
|
|
*
|
|
* Alters the lines passed.
|
|
*
|
|
* @param {string[]} lines
|
|
*
|
|
* @return {Object} the line descriptor, an object { width, height, text }
|
|
*/
|
|
function layoutNext(lines, maxWidth, fakeText) {
|
|
|
|
var originalLine = lines.shift(),
|
|
fitLine = originalLine;
|
|
|
|
var textBBox;
|
|
|
|
for (;;) {
|
|
textBBox = getTextBBox(fitLine, fakeText);
|
|
|
|
textBBox.width = fitLine ? textBBox.width : 0;
|
|
|
|
// try to fit
|
|
if (fitLine === ' ' || fitLine === '' || textBBox.width < Math.round(maxWidth) || fitLine.length < 2) {
|
|
return fit(lines, fitLine, originalLine, textBBox);
|
|
}
|
|
|
|
fitLine = shortenLine(fitLine, textBBox.width, maxWidth);
|
|
}
|
|
}
|
|
|
|
function fit(lines, fitLine, originalLine, textBBox) {
|
|
if (fitLine.length < originalLine.length) {
|
|
var remainder = originalLine.slice(fitLine.length).trim();
|
|
|
|
lines.unshift(remainder);
|
|
}
|
|
|
|
return {
|
|
width: textBBox.width,
|
|
height: textBBox.height,
|
|
text: fitLine
|
|
};
|
|
}
|
|
|
|
var SOFT_BREAK = '\u00AD';
|
|
|
|
|
|
/**
|
|
* Shortens a line based on spacing and hyphens.
|
|
* Returns the shortened result on success.
|
|
*
|
|
* @param {string} line
|
|
* @param {number} maxLength the maximum characters of the string
|
|
*
|
|
* @return {string} the shortened string
|
|
*/
|
|
function semanticShorten(line, maxLength) {
|
|
|
|
var parts = line.split(/(\s|-|\u00AD)/g),
|
|
part,
|
|
shortenedParts = [],
|
|
length = 0;
|
|
|
|
// try to shorten via break chars
|
|
if (parts.length > 1) {
|
|
|
|
while ((part = parts.shift())) {
|
|
if (part.length + length < maxLength) {
|
|
shortenedParts.push(part);
|
|
length += part.length;
|
|
} else {
|
|
|
|
// remove previous part, too if hyphen does not fit anymore
|
|
if (part === '-' || part === SOFT_BREAK) {
|
|
shortenedParts.pop();
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
var last = shortenedParts[shortenedParts.length - 1];
|
|
|
|
// translate trailing soft break to actual hyphen
|
|
if (last && last === SOFT_BREAK) {
|
|
shortenedParts[shortenedParts.length - 1] = '-';
|
|
}
|
|
|
|
return shortenedParts.join('');
|
|
}
|
|
|
|
|
|
function shortenLine(line, width, maxWidth) {
|
|
var length = Math.max(line.length * (maxWidth / width), 1);
|
|
|
|
// try to shorten semantically (i.e. based on spaces and hyphens)
|
|
var shortenedLine = semanticShorten(line, length);
|
|
|
|
if (!shortenedLine) {
|
|
|
|
// force shorten by cutting the long word
|
|
shortenedLine = line.slice(0, Math.max(Math.round(length - 1), 1));
|
|
}
|
|
|
|
return shortenedLine;
|
|
}
|
|
|
|
|
|
function getHelperSvg() {
|
|
var helperSvg = document.getElementById('helper-svg');
|
|
|
|
if (!helperSvg) {
|
|
helperSvg = svgCreate('svg');
|
|
|
|
svgAttr(helperSvg, {
|
|
id: 'helper-svg'
|
|
});
|
|
|
|
assignStyle(helperSvg, {
|
|
visibility: 'hidden',
|
|
position: 'fixed',
|
|
width: 0,
|
|
height: 0
|
|
});
|
|
|
|
document.body.appendChild(helperSvg);
|
|
}
|
|
|
|
return helperSvg;
|
|
}
|
|
|
|
|
|
/**
|
|
* Creates a new label utility
|
|
*
|
|
* @param {Object} config
|
|
* @param {Dimensions} config.size
|
|
* @param {number} config.padding
|
|
* @param {Object} config.style
|
|
* @param {string} config.align
|
|
*/
|
|
export default function Text(config) {
|
|
|
|
this._config = assign({}, {
|
|
size: DEFAULT_LABEL_SIZE,
|
|
padding: DEFAULT_BOX_PADDING,
|
|
style: {},
|
|
align: 'center-top'
|
|
}, config || {});
|
|
}
|
|
|
|
/**
|
|
* Returns the layouted text as an SVG element.
|
|
*
|
|
* @param {string} text
|
|
* @param {Object} options
|
|
*
|
|
* @return {SVGElement}
|
|
*/
|
|
Text.prototype.createText = function(text, options) {
|
|
return this.layoutText(text, options).element;
|
|
};
|
|
|
|
/**
|
|
* Returns a labels layouted dimensions.
|
|
*
|
|
* @param {string} text to layout
|
|
* @param {Object} options
|
|
*
|
|
* @return {Dimensions}
|
|
*/
|
|
Text.prototype.getDimensions = function(text, options) {
|
|
return this.layoutText(text, options).dimensions;
|
|
};
|
|
|
|
/**
|
|
* Creates and returns a label and its bounding box.
|
|
*
|
|
* @method Text#createText
|
|
*
|
|
* @param {string} text the text to render on the label
|
|
* @param {Object} options
|
|
* @param {string} options.align how to align in the bounding box.
|
|
* Any of { 'center-middle', 'center-top' },
|
|
* defaults to 'center-top'.
|
|
* @param {string} options.style style to be applied to the text
|
|
* @param {boolean} options.fitBox indicates if box will be recalculated to
|
|
* fit text
|
|
*
|
|
* @return {Object} { element, dimensions }
|
|
*/
|
|
Text.prototype.layoutText = function(text, options) {
|
|
var box = assign({}, this._config.size, options.box),
|
|
style = assign({}, this._config.style, options.style),
|
|
align = parseAlign(options.align || this._config.align),
|
|
padding = parsePadding(options.padding !== undefined ? options.padding : this._config.padding),
|
|
fitBox = options.fitBox || false;
|
|
|
|
var lineHeight = getLineHeight(style);
|
|
|
|
// we split text by lines and normalize
|
|
// {soft break} + {line break} => { line break }
|
|
var lines = text.split(/\u00AD?\r?\n/),
|
|
layouted = [];
|
|
|
|
var maxWidth = box.width - padding.left - padding.right;
|
|
|
|
// ensure correct rendering by attaching helper text node to invisible SVG
|
|
var helperText = svgCreate('text');
|
|
svgAttr(helperText, { x: 0, y: 0 });
|
|
svgAttr(helperText, style);
|
|
|
|
var helperSvg = getHelperSvg();
|
|
|
|
svgAppend(helperSvg, helperText);
|
|
|
|
while (lines.length) {
|
|
layouted.push(layoutNext(lines, maxWidth, helperText));
|
|
}
|
|
|
|
if (align.vertical === 'middle') {
|
|
padding.top = padding.bottom = 0;
|
|
}
|
|
|
|
var totalHeight = reduce(layouted, function(sum, line, idx) {
|
|
return sum + (lineHeight || line.height);
|
|
}, 0) + padding.top + padding.bottom;
|
|
|
|
var maxLineWidth = reduce(layouted, function(sum, line, idx) {
|
|
return line.width > sum ? line.width : sum;
|
|
}, 0);
|
|
|
|
// the y position of the next line
|
|
var y = padding.top;
|
|
|
|
if (align.vertical === 'middle') {
|
|
y += (box.height - totalHeight) / 2;
|
|
}
|
|
|
|
// magic number initial offset
|
|
y -= (lineHeight || layouted[0].height) / 4;
|
|
|
|
|
|
var textElement = svgCreate('text');
|
|
|
|
svgAttr(textElement, style);
|
|
|
|
// layout each line taking into account that parent
|
|
// shape might resize to fit text size
|
|
forEach(layouted, function(line) {
|
|
|
|
var x;
|
|
|
|
y += (lineHeight || line.height);
|
|
|
|
switch (align.horizontal) {
|
|
case 'left':
|
|
x = padding.left;
|
|
break;
|
|
|
|
case 'right':
|
|
x = ((fitBox ? maxLineWidth : maxWidth)
|
|
- padding.right - line.width);
|
|
break;
|
|
|
|
default:
|
|
|
|
// aka center
|
|
x = Math.max((((fitBox ? maxLineWidth : maxWidth)
|
|
- line.width) / 2 + padding.left), 0);
|
|
}
|
|
|
|
var tspan = svgCreate('tspan');
|
|
svgAttr(tspan, { x: x, y: y });
|
|
|
|
tspan.textContent = line.text;
|
|
|
|
svgAppend(textElement, tspan);
|
|
});
|
|
|
|
svgRemove(helperText);
|
|
|
|
var dimensions = {
|
|
width: maxLineWidth,
|
|
height: totalHeight
|
|
};
|
|
|
|
return {
|
|
dimensions: dimensions,
|
|
element: textElement
|
|
};
|
|
};
|
|
|
|
|
|
function getLineHeight(style) {
|
|
if ('fontSize' in style && 'lineHeight' in style) {
|
|
return style.lineHeight * parseInt(style.fontSize, 10);
|
|
}
|
|
} |