Skip to content

Commit

Permalink
Use TextMetrics to measure label text width before rendering
Browse files Browse the repository at this point in the history
Currently, the timeline-chart creates a BitMapText object to measure the
width of the label text for every state, before actually rendering it.
If a label text does not fit within its state, the BitMapText object is not used,
and will remain there until the chart deletes the state. This hogs the
performance of the timeline chart, especially when a large number of
states need to be removed, because the chart needs to delete the
BitMapText objects as well.

This commit uses PIXI.TextMetrics.measureText() as a faster alternative
to measure the text label width. The advantage of this method is that it
is a static method, which requires no object construction. Thus the
timeline-chart will only create and remove BitMapText objects that are
actually used to display label texts. The disadvatage is that the
measurement between the 2 methods are not the same, since TextMetrics
works with PIXI.Text objects, and not PIXI.BitMapText.

Through trials, it is found that the ratio between the text width
returned by the two methods is consistent. Hence, this commit uses this
ratio as a temporary solution to properly calculate the text width,
until PIXI supports TextMetrics for BitMapText.

Signed-off-by: Hoang Thuan Pham <hoang.pham@calian.ca>
  • Loading branch information
hoangphamEclipse committed Apr 4, 2023
1 parent 4694a71 commit 9b23c78
Show file tree
Hide file tree
Showing 2 changed files with 106 additions and 48 deletions.
96 changes: 61 additions & 35 deletions timeline-chart/src/components/time-graph-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,33 @@ export interface TimeGraphStateStyle {
borderColor?: number
}

/**
* Using BitMapText.getLocalBounds() to measure the full label width is expensive, because
* we need to create a BitMapText object to use it. This means that we need to create a BitMapText
* for every state, even if we don't use it. When we remove the states, these objects need to
* be removed as well. In the case where we have a lot states, this cause multiple garbage collection
* and hogs the performance of the timeline chart.
*
* An alternative to measure the width of the label is to use PIXI.TextMetrics.measureText().
* However, this method applies only for Text objects, and not BitMapText; therefore there is a
* slight difference between the values returned by the two methods. PIXI.TextMetrics returns a
* slightly smaller value. Currently, PIXI does not support measureText() for BitMapText.
*
* Through some trials, it looks like the ratio between the text width returned by the two methods is
* consistent. Hence, we can use PIXI.TextMetrics.measureText() to get a good estimation of the text width,
* then multiply with the SCALING_FACTOR to determine the actual width of the label when rendered
* with BitMapText.
*
* SCALING_FACTOR = BitMapText.getLocalBounds() / PIXI.TextMetrics.measureText()
*/
const SCALING_FACTOR = 1.04;

export class TimeGraphStateComponent extends TimeGraphComponent<TimelineChart.TimeGraphState> {

static fontController: FontController = new FontController();

protected _options: TimeGraphStyledRect;
private textLabelObject: PIXI.BitmapText | undefined;
private textWidth: number;

constructor(
id: string,
Expand Down Expand Up @@ -55,8 +75,8 @@ export class TimeGraphStateComponent extends TimeGraphComponent<TimelineChart.Ti
if (!this.model.label) {
return;
}
const fontName = TimeGraphStateComponent.fontController.getFontName(this._options.color ? this._options.color : 0, this._options.height - 2) ||
TimeGraphStateComponent.fontController.getDefaultFontName();
const { fontName, fontStyle } = TimeGraphStateComponent.fontController.getFont(this._options.color ? this._options.color : 0, this._options.height - 2) ||
TimeGraphStateComponent.fontController.getDefaultFont();
const position = {
x: this._options.position.x + this._options.width < 0 ? this._options.position.x : Math.max(0, this._options.position.x),
y: this._options.position.y
Expand All @@ -65,48 +85,54 @@ export class TimeGraphStateComponent extends TimeGraphComponent<TimelineChart.Ti
const labelText = this.model.label;
const textPadding = 0.5;
if (displayWidth < 3) {
if (this.textLabelObject) {
this.textLabelObject.text = "";
}
this.removeLabel();
return;
}

let addLabel = false;
if (!this.textLabelObject) {
this.textLabelObject = new PIXI.BitmapText(this.model.label, { fontName: fontName });
this.textWidth = this.textLabelObject.getLocalBounds().width;
addLabel = true;
}
if (fontStyle) {
const metrics = PIXI.TextMetrics.measureText(this.model.label, fontStyle);
// Round the text width up just to be sure that it will fit in the state
const textWidth = Math.ceil(metrics.width * SCALING_FACTOR);

let textObjX = position.x + textPadding;
const textObjY = position.y + textPadding;
let displayLabel = "";
let textObjX = position.x + textPadding;
const textObjY = position.y + textPadding;
let displayLabel = "";

if (displayWidth > this.textWidth) {
textObjX = position.x + (displayWidth - this.textWidth) / 2;
displayLabel = labelText;
}
else {
const textScaler = displayWidth / this.textWidth;
const index = Math.min(Math.floor(textScaler * labelText.length), labelText.length - 1)
const partialLabel = labelText.substr(0, Math.max(index - 1, 0));
if (partialLabel.length > 0) {
displayLabel = partialLabel.concat("…");
if (displayWidth > textWidth) {
textObjX = position.x + (displayWidth - textWidth) / 2;
displayLabel = labelText;
}
else {
const textScaler = displayWidth / textWidth;
const index = Math.min(Math.floor(textScaler * labelText.length), labelText.length - 1);
const partialLabel = labelText.substr(0, Math.max(index - 1, 0));
if (partialLabel.length > 0) {
displayLabel = partialLabel.concat("…");
}
}
}

this.textLabelObject.text = displayLabel;
if (displayLabel === "") {
this.removeLabel();
return;
}

if (displayLabel === "") {
return;
}
if (!this.textLabelObject) {
this.textLabelObject = new PIXI.BitmapText(displayLabel, { fontName: fontName });
this.displayObject.addChild(this.textLabelObject);
} else {
this.textLabelObject.text = displayLabel;
}

this.textLabelObject.alpha = this._options.opacity ?? 1;
this.textLabelObject.x = textObjX;
this.textLabelObject.y = textObjY;
this.textLabelObject.alpha = this._options.opacity ?? 1;
this.textLabelObject.x = textObjX;
this.textLabelObject.y = textObjY;
}
}

if (addLabel) {
this.displayObject.addChild(this.textLabelObject);
private removeLabel() {
if (this.textLabelObject) {
this.textLabelObject.destroy();
this.textLabelObject = undefined;
}
}

Expand Down
58 changes: 45 additions & 13 deletions timeline-chart/src/time-graph-font-controller.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,37 @@
import * as PIXI from "pixi.js-legacy";

const DEFAULT_FONT_SIZE = 8;
const DEFAULT_FONT_NAME = "LabelFont8White";
const DEFAULT_FONT_STYLE = {
fontFamily: "monospace",
fontSize: 8,
fill: "white",
fontWeight: "bold"
};

export class FontController {
private fontFamily: string;
private fontStyleMap: Map<number, Map<string, PIXI.TextStyle>>;
private fontNameMap: Map<number, Map<string, string>>;
private fontColorMap: Map<number, string>;
private defaultFontName: string = "LabelFont8White";

constructor(fontFamily: string = "monospace") {
this.fontFamily = fontFamily;
this.fontStyleMap = new Map<number, Map<string, PIXI.TextStyle>>();
this.fontNameMap = new Map<number, Map<string, string>>();
this.fontColorMap = new Map<number, string>();

const defaultFontSize = 8;
this.updateFontNameMap(defaultFontSize);
this.updateFontNameMapAndFontStyleMap(DEFAULT_FONT_SIZE);
}

getDefaultFontName(): string {
return this.defaultFontName;
getDefaultFont(): { fontName: string, fontStyle: PIXI.TextStyle} {
return {
fontName: DEFAULT_FONT_NAME,
fontStyle: new PIXI.TextStyle(DEFAULT_FONT_STYLE)
}
}

createFontName(fontColor: string, fontSize: number): string {
createFont(fontColor: string, fontSize: number): { fontName: string, fontStyle: PIXI.TextStyle } {
const fontName = "LabelFont" + fontSize.toString() + fontColor;
const fontStyle = {
fontFamily: this.fontFamily,
Expand All @@ -28,17 +40,30 @@ export class FontController {
fontWeight: "bold"
};
PIXI.BitmapFont.from(fontName, fontStyle, { chars: this.getCharacterSet() });
return fontName;

return {
fontName: fontName,
fontStyle: new PIXI.TextStyle(fontStyle)
}
}

updateFontNameMap(size: number) {
updateFontNameMapAndFontStyleMap(size: number) {
let color2FontMap = new Map<string, string>();
color2FontMap.set("black", this.createFontName("Black", size));
color2FontMap.set("white", this.createFontName("White", size));
let style2FontMap = new Map<string, PIXI.TextStyle>();

const blackFont = this.createFont("Black", size);
color2FontMap.set("black", blackFont.fontName);
style2FontMap.set("black", blackFont.fontStyle);

const whiteFont = this.createFont("White", size);
color2FontMap.set("white", whiteFont.fontName);
style2FontMap.set("white", blackFont.fontStyle);

this.fontNameMap.set(size, color2FontMap);
this.fontStyleMap.set(size, style2FontMap);
}

getFontName(color: number, size: number): string {
getFont(color: number, size: number): { fontName: string, fontStyle: PIXI.TextStyle | undefined} {
let fontColor = this.fontColorMap.get(color);
if (!fontColor) {
let colorInHex = color.toString(16);
Expand All @@ -61,16 +86,23 @@ export class FontController {
}

let fontName: string | undefined;
let fontStyle: PIXI.TextStyle | undefined;
if (size) {
const MIN_FONT_SIZE = 6;
size = Math.max(size, MIN_FONT_SIZE);
if (!this.fontNameMap.has(size)) {
this.updateFontNameMap(size);
this.updateFontNameMapAndFontStyleMap(size);
}
const size2FontMap = this.fontNameMap.get(size);
fontName = size2FontMap ? size2FontMap.get(fontColor) : undefined;

const size2StyleMap = this.fontStyleMap.get(size);
fontStyle = size2StyleMap ? size2StyleMap.get(fontColor) : undefined;
}
return {
fontName: fontName ? fontName : "",
fontStyle: fontStyle
}
return fontName ? fontName : "";
}

private getCharacterSet(): string[][] {
Expand Down

0 comments on commit 9b23c78

Please sign in to comment.