Skip to content
This repository has been archived by the owner on Dec 10, 2021. It is now read-only.

feat: optimize functions for getting text dimension #199

Merged
merged 5 commits into from
Aug 6, 2019
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions packages/superset-ui-dimension/src/getMultipleTextDimensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { TextStyle, Dimension } from './types';
import getBBoxCeil from './svg/getBBoxCeil';
import { hiddenSvgFactory, textFactory } from './svg/factories';
import updateTextNode from './svg/updateTextNode';

/**
* get dimensions of multiple texts with same style
* @param input
* @param defaultDimension
*/
export default function getMultipleTextDimensions(
input: {
className?: string;
container?: HTMLElement;
style?: TextStyle;
texts: string[];
},
defaultDimension?: Dimension,
): Dimension[] {
const { texts, className, style, container } = input;

const cache = new Map<string, Dimension>();
let textNode: SVGTextElement | undefined;
let svgNode: SVGSVGElement | undefined;

const dimensions = texts.map(text => {
// Empty string
if (text.length === 0) {
return { height: 0, width: 0 };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of this special case, why not initialize the cache with:

{ "": { height: 0, width: 0 } }

then we don't need to check both cases every time (and i'd imagine the O(1) cache lookup would be about the same speed as the string length compare)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I generally agree with this, but the W3C SVGGraphicsElement spec has a method named getBBox (that we use on line 9), so this instance may be an acceptable exception

}
// Check if this string has been computed already
if (cache.has(text)) {
return cache.get(text) as Dimension;
}

// Lazy creation of text and svg nodes
if (!textNode) {
svgNode = hiddenSvgFactory.createInContainer(container);
textNode = textFactory.createInContainer(svgNode);
}

// Update text and get dimension
updateTextNode(textNode, { className, style, text });
const dimension = getBBoxCeil(textNode, defaultDimension);
// Store result to cache
cache.set(text, dimension);

return dimension;
});

// Remove svg node, if any
if (svgNode && textNode) {
setTimeout(() => {
kristw marked this conversation as resolved.
Show resolved Hide resolved
textFactory.removeFromContainer(svgNode);
hiddenSvgFactory.removeFromContainer(container);
// eslint-disable-next-line no-magic-numbers
}, 500);
}

return dimensions;
}
55 changes: 18 additions & 37 deletions packages/superset-ui-dimension/src/getTextDimension.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import { TextStyle, Dimension } from './types';

const SVG_NS = 'http://www.w3.org/2000/svg';
const STYLE_FIELDS: (keyof TextStyle)[] = [
'font',
'fontWeight',
'fontStyle',
'fontSize',
'fontFamily',
'letterSpacing',
];
import updateTextNode from './svg/updateTextNode';
import getBBoxCeil from './svg/getBBoxCeil';
import { hiddenSvgFactory, textFactory } from './svg/factories';

export interface GetTextDimensionInput {
className?: string;
Expand All @@ -17,39 +10,27 @@ export interface GetTextDimensionInput {
text: string;
}

const DEFAULT_DIMENSION = { height: 20, width: 100 };

export default function getTextDimension(
input: GetTextDimensionInput,
defaultDimension: Dimension = DEFAULT_DIMENSION,
defaultDimension?: Dimension,
): Dimension {
const { text, className, style = {}, container = document.body } = input;
const { text, className, style, container } = input;

const textNode = document.createElementNS(SVG_NS, 'text');
textNode.textContent = text;

if (className !== undefined && className !== null) {
textNode.setAttribute('class', className);
// Empty string
if (text.length === 0) {
return { height: 0, width: 0 };
}

STYLE_FIELDS.filter(
(field: keyof TextStyle) => style[field] !== undefined && style[field] !== null,
).forEach((field: keyof TextStyle) => {
textNode.style[field] = `${style[field]}`;
});

const svg = document.createElementNS(SVG_NS, 'svg');
svg.style.position = 'absolute'; // so it won't disrupt page layout
svg.style.opacity = '0'; // and not visible
svg.style.pointerEvents = 'none'; // and not capturing mouse events
svg.appendChild(textNode);
container.appendChild(svg);
const svgNode = hiddenSvgFactory.createInContainer(container);
const textNode = textFactory.createInContainer(svgNode);
updateTextNode(textNode, { className, style, text });
const dimension = getBBoxCeil(textNode, defaultDimension);

const bbox = textNode.getBBox ? textNode.getBBox() : defaultDimension;
container.removeChild(svg);
setTimeout(() => {
kristw marked this conversation as resolved.
Show resolved Hide resolved
textFactory.removeFromContainer(svgNode);
hiddenSvgFactory.removeFromContainer(container);
// eslint-disable-next-line no-magic-numbers
}, 500);

return {
height: Math.ceil(bbox.height),
width: Math.ceil(bbox.width),
};
return dimension;
}
1 change: 1 addition & 0 deletions packages/superset-ui-dimension/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { default as getTextDimension } from './getTextDimension';
export { default as getMultipleTextDimensions } from './getMultipleTextDimensions';
export { default as computeMaxFontSize } from './computeMaxFontSize';
export { default as mergeMargin } from './mergeMargin';
export { default as parseLength } from './parseLength';
Expand Down
41 changes: 41 additions & 0 deletions packages/superset-ui-dimension/src/svg/LazyFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export default class LazyFactory<T extends HTMLElement | SVGElement> {
private cache = new Map<
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is cache the right name for this? Seems like it's storing each node for its entire lifecycle, and not really being used as a cache

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have strong opinion here. What would you suggest it be called?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe containers? I don't feel too strongly either.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is hashed by containers but not storing the containers, so probably should not be called containers.
I can rename to activeNodes.

HTMLElement | SVGElement,
{
counter: number;
node: T;
}
>();

private factoryFn: () => T;

constructor(factoryFn: () => T) {
this.factoryFn = factoryFn;
}

createInContainer(container: HTMLElement | SVGElement = document.body) {
if (this.cache.has(container)) {
const entry = this.cache.get(container)!;
entry.counter += 1;

return entry.node;
}

const node = this.factoryFn();
container.appendChild(node);
this.cache.set(container, { counter: 1, node });

return node;
}

removeFromContainer(container: HTMLElement | SVGElement = document.body) {
if (this.cache.has(container)) {
const entry = this.cache.get(container)!;
entry.counter -= 1;
if (entry.counter === 0) {
container.removeChild(entry.node);
this.cache.delete(container);
}
}
}
}
2 changes: 2 additions & 0 deletions packages/superset-ui-dimension/src/svg/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const SVG_NS = 'http://www.w3.org/2000/svg';
10 changes: 10 additions & 0 deletions packages/superset-ui-dimension/src/svg/createHiddenSvgNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { SVG_NS } from './constants';

export default function createHiddenSvgNode() {
const svgNode = document.createElementNS(SVG_NS, 'svg');
svgNode.style.position = 'absolute'; // so it won't disrupt page layout
svgNode.style.opacity = '0'; // and not visible
svgNode.style.pointerEvents = 'none'; // and not capturing mouse events

return svgNode;
}
5 changes: 5 additions & 0 deletions packages/superset-ui-dimension/src/svg/createTextNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { SVG_NS } from './constants';

export default function createTextNode() {
return document.createElementNS(SVG_NS, 'text');
}
6 changes: 6 additions & 0 deletions packages/superset-ui-dimension/src/svg/factories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import LazyFactory from './LazyFactory';
import createHiddenSvgNode from './createHiddenSvgNode';
import createTextNode from './createTextNode';

export const hiddenSvgFactory = new LazyFactory(createHiddenSvgNode);
export const textFactory = new LazyFactory(createTextNode);
15 changes: 15 additions & 0 deletions packages/superset-ui-dimension/src/svg/getBBoxCeil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Dimension } from '../types';

const DEFAULT_DIMENSION = { height: 20, width: 100 };

export default function getBBoxCeil(
kristw marked this conversation as resolved.
Show resolved Hide resolved
node: SVGGraphicsElement,
defaultDimension: Dimension = DEFAULT_DIMENSION,
) {
const { width, height } = node.getBBox ? node.getBBox() : defaultDimension;

return {
height: Math.ceil(height),
width: Math.ceil(width),
};
}
47 changes: 47 additions & 0 deletions packages/superset-ui-dimension/src/svg/updateTextNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { TextStyle } from '../types';

const STYLE_FIELDS: (keyof TextStyle)[] = [
'font',
'fontWeight',
'fontStyle',
'fontSize',
'fontFamily',
'letterSpacing',
];

export default function updateTextNode(
node: SVGTextElement,
{
className,
style = {},
text,
}: {
className?: string;
style?: TextStyle;
text?: string;
} = {},
) {
const textNode = node;

if (textNode.textContent !== text) {
textNode.textContent = typeof text === 'undefined' ? null : text;
}
if (textNode.getAttribute('class') !== className) {
textNode.setAttribute('class', className || '');
}

// clear style
STYLE_FIELDS.forEach((field: keyof TextStyle) => {
textNode.style[field] = null;
});

// apply new style
// Note that the font field will auto-populate other font fields when applicable.
STYLE_FIELDS.filter(
(field: keyof TextStyle) => typeof style[field] !== 'undefined' && style[field] !== null,
).forEach((field: keyof TextStyle) => {
textNode.style[field] = `${style[field]}`;
});

return textNode;
}
16 changes: 3 additions & 13 deletions packages/superset-ui-dimension/test/computeMaxFontSize.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,10 @@
import { computeMaxFontSize } from '../src/index';
import addDummyFill from './addDummyFill';
import { addDummyFill, removeDummyFill } from './getBBoxDummyFill';

describe('computeMaxFontSize(input)', () => {
describe('returns dimension of the given text', () => {
let originalFn: () => DOMRect;

beforeEach(() => {
// @ts-ignore - fix jsdom
originalFn = SVGElement.prototype.getBBox;
addDummyFill();
});

afterEach(() => {
// @ts-ignore - fix jsdom
SVGElement.prototype.getBBox = originalFn;
});
beforeEach(addDummyFill);
afterEach(removeDummyFill);

it('requires either idealFontSize or maxHeight', () => {
expect(() =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
export const SAMPLE_TEXT = 'dummy text. does not really matter';
let originalFn: () => DOMRect;

const textToWidth = {
paris: 200,
tokyo: 300,
beijing: 400,
};

export const SAMPLE_TEXT = Object.keys(textToWidth);

export function addDummyFill() {
// @ts-ignore - fix jsdom
originalFn = SVGElement.prototype.getBBox;

export default function addDummyFill() {
// @ts-ignore - fix jsdom
SVGElement.prototype.getBBox = function getBBox() {
let width = 200;
let width = textToWidth[this.textContent] || 200;
let height = 20;

if (this.getAttribute('class') === 'test-class') {
width = 100;
width /= 2;
}

if (this.style.fontFamily === 'Lobster') {
width = 250;
width *= 1.25;
}

if (this.style.fontSize) {
Expand Down Expand Up @@ -45,3 +56,8 @@ export default function addDummyFill() {
};
};
}

export function removeDummyFill() {
// @ts-ignore - fix jsdom
SVGElement.prototype.getBBox = originalFn;
}
Loading