billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
477 lines (431 loc) • 12.3 kB
text/typescript
/**
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
*/
import {getFontSize} from "./util";
export type CanvasRect = {x: number, y: number, w: number, h: number};
export type CanvasRectRadius = number | {tl?: number, tr?: number, br?: number, bl?: number};
export type CanvasPointType = "circle" | "rectangle";
export type CanvasStyle = {
fill?: string | CanvasGradient | CanvasPattern,
stroke?: string | CanvasGradient | CanvasPattern,
lineWidth?: number,
alpha?: number,
font?: string,
textAlign?: CanvasTextAlign,
textBaseline?: CanvasTextBaseline,
lineDash?: number[]
};
type DrawCallback = (ctx: CanvasRenderingContext2D) => void;
/**
* Centralized canvas drawing gateway.
* @private
*/
export default class CanvasPainter {
/**
* Constructor.
* @param {CanvasRenderingContext2D} ctx Canvas drawing context
* @private
*/
constructor(private ctx: CanvasRenderingContext2D) {}
/**
* Get current drawing context.
* @returns {CanvasRenderingContext2D} Canvas drawing context
* @private
*/
get context(): CanvasRenderingContext2D {
return this.ctx;
}
/**
* Run a draw operation on another canvas context.
* @param {CanvasRenderingContext2D} ctx Canvas drawing context
* @param {function} draw Draw callback
* @private
*/
withContext(ctx: CanvasRenderingContext2D, draw: () => void): void {
const prev = this.ctx;
this.ctx = ctx;
try {
draw();
} finally {
this.ctx = prev;
}
}
/**
* Run a draw operation in an isolated canvas state.
* @param {DrawCallback} draw Draw callback
* @private
*/
withState(draw: DrawCallback): void {
const {ctx} = this;
ctx.save();
try {
draw(ctx);
} finally {
ctx.restore();
}
}
/**
* Run a draw operation with a translated origin.
* @param {number} x Translation x
* @param {number} y Translation y
* @param {DrawCallback} draw Draw callback
* @private
*/
withTranslation(x: number, y: number, draw: DrawCallback): void {
this.withState(ctx => {
ctx.translate(x, y);
draw(ctx);
});
}
/**
* Run a draw operation clipped to a rectangle.
* @param {object} rect Clip rectangle
* @param {DrawCallback} draw Draw callback
* @private
*/
clipRect(rect: CanvasRect, draw: DrawCallback): void {
this.withState(ctx => {
ctx.beginPath();
ctx.rect(rect.x, rect.y, rect.w, rect.h);
ctx.clip();
draw(ctx);
});
}
/**
* Measure text using the current canvas state.
* @param {string} text Text value
* @returns {TextMetrics} Text metrics
* @private
*/
measureText(text: string): TextMetrics {
return this.ctx.measureText(text);
}
/**
* Add a line segment to the current path.
* @param {number} x1 Start x
* @param {number} y1 Start y
* @param {number} x2 End x
* @param {number} y2 End y
* @private
*/
traceLine(x1: number, y1: number, x2: number, y2: number): void {
const {ctx} = this;
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
}
/**
* Add a crisp line segment to the current path.
* @param {number} x1 Start x
* @param {number} y1 Start y
* @param {number} x2 End x
* @param {number} y2 End y
* @param {number} lineWidth Stroke width
* @private
*/
traceCrispLine(x1: number, y1: number, x2: number, y2: number, lineWidth: number): void {
this.traceLine(
this.crisp(x1, lineWidth),
this.crisp(y1, lineWidth),
this.crisp(x2, lineWidth),
this.crisp(y2, lineWidth)
);
}
/**
* Add a circle to the current path.
* @param {number} x Center x
* @param {number} y Center y
* @param {number} r Radius
* @private
*/
traceCircle(x: number, y: number, r: number): void {
this.ctx.moveTo(x + r, y);
this.ctx.arc(x, y, r, 0, Math.PI * 2);
}
/**
* Stroke a path.
* @param {DrawCallback} draw Path callback
* @param {object} style Optional style
* @private
*/
strokePath(draw: DrawCallback, style?: CanvasStyle): void {
this.withStyle(style, ctx => {
ctx.beginPath();
draw(ctx);
ctx.stroke();
});
}
/**
* Fill a path.
* @param {DrawCallback} draw Path callback
* @param {object} style Optional style
* @private
*/
fillPath(draw: DrawCallback, style?: CanvasStyle): void {
this.withStyle(style, ctx => {
ctx.beginPath();
draw(ctx);
ctx.fill();
});
}
/**
* Fill a rectangle.
* @param {object} rect Rectangle
* @param {object} style Optional style
* @private
*/
fillRect(rect: CanvasRect, style?: CanvasStyle): void {
this.withStyle(style, ctx => {
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
});
}
/**
* Fill a rectangle with optional corner radii.
* @param {object} rect Rectangle
* @param {number|object} radius Corner radius
* @param {object} style Optional style
* @private
*/
fillRoundRect(rect: CanvasRect, radius: CanvasRectRadius = 0, style?: CanvasStyle): void {
const normalized = this.normalizeRect(rect);
const corners = this.getRectRadii(normalized, radius);
if (!corners.tl && !corners.tr && !corners.br && !corners.bl) {
this.fillRect(normalized, style);
return;
}
this.fillPath(ctx => {
this.traceRoundRect(ctx, normalized, corners);
}, style);
}
/**
* Stroke a rectangle with optional corner radii.
* @param {object} rect Rectangle
* @param {number|object} radius Corner radius
* @param {object} style Optional style
* @private
*/
strokeRoundRect(rect: CanvasRect, radius: CanvasRectRadius = 0, style?: CanvasStyle): void {
const normalized = this.normalizeRect(rect);
const corners = this.getRectRadii(normalized, radius);
if (!corners.tl && !corners.tr && !corners.br && !corners.bl) {
this.strokeRect(normalized, style);
return;
}
this.strokePath(ctx => {
this.traceRoundRect(ctx, normalized, corners);
}, style);
}
/**
* Stroke a rectangle.
* @param {object} rect Rectangle
* @param {object} style Optional style
* @private
*/
strokeRect(rect: CanvasRect, style?: CanvasStyle): void {
this.withStyle(style, ctx => {
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
});
}
/**
* Draw text.
* @param {string} text Text value
* @param {number} x X coordinate
* @param {number} y Y coordinate
* @param {object} style Optional style
* @private
*/
text(text: string, x: number, y: number,
style?: CanvasStyle & {angle?: number, maxWidth?: number}): void {
const fillText = (ctx: CanvasRenderingContext2D, textX: number, textY: number) => {
style?.maxWidth === undefined ?
ctx.fillText(text, textX, textY) :
ctx.fillText(text, textX, textY, style.maxWidth);
};
const draw = (ctx: CanvasRenderingContext2D) => {
if (style?.angle) {
ctx.translate(x, y);
ctx.rotate(style.angle * Math.PI / 180);
fillText(ctx, 0, 0);
} else {
fillText(ctx, x, y);
}
};
if (style?.angle) {
this.withState(ctx => {
this.applyStyle(style);
draw(ctx);
});
} else {
this.withStyle(style, draw);
}
}
/**
* Draw a possibly rotated multiline text block.
* @param {string} text Text value
* @param {number} x X coordinate
* @param {number} y Y coordinate
* @param {object} style Optional style
* @private
*/
textLines(text: string, x: number, y: number,
style?: CanvasStyle & {angle?: number, maxWidth?: number}): void {
this.withState(ctx => {
const lines = text.split("\n");
const lineHeight = getFontSize(style?.font || ctx.font);
const firstLineY = lines.length > 1 ? -((lines.length - 1) * lineHeight) : 0;
this.applyStyle(style);
ctx.translate(x, y);
style?.angle && ctx.rotate(style.angle * Math.PI / 180);
lines.forEach((line, i) => {
const lineY = firstLineY + (i * lineHeight);
style?.maxWidth === undefined ?
ctx.fillText(line, 0, lineY) :
ctx.fillText(line, 0, lineY, style.maxWidth);
});
});
}
/**
* Draw a point shape.
* @param {string} type Point shape
* @param {number} x X coordinate
* @param {number} y Y coordinate
* @param {number} r Radius
* @param {object} style Optional style
* @private
*/
point(type: CanvasPointType, x: number, y: number, r: number, style?: CanvasStyle): void {
this.withStyle(style, ctx => {
const shouldFill = !style?.stroke || style.fill !== undefined;
const shouldStroke = style?.stroke !== undefined;
if (type === "rectangle") {
const size = r * 2;
const rect = {x: x - r, y: y - r, w: size, h: size};
shouldFill && ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
shouldStroke && ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
} else {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
shouldFill && ctx.fill();
shouldStroke && ctx.stroke();
}
});
}
/**
* Normalize rounded rectangle corner radii.
* @param {object} rect Rectangle
* @param {number|object} radius Corner radius
* @returns {object} Corner radii
* @private
*/
private getRectRadii(rect: CanvasRect, radius: CanvasRectRadius): Required<
Exclude<
CanvasRectRadius,
number
>
> {
const base = typeof radius === "number" ?
{tl: radius, tr: radius, br: radius, bl: radius} :
{
tl: radius.tl || 0,
tr: radius.tr || 0,
br: radius.br || 0,
bl: radius.bl || 0
};
const max = Math.max(0, Math.min(Math.abs(rect.w), Math.abs(rect.h)) / 2);
return {
tl: Math.max(0, Math.min(base.tl, max)),
tr: Math.max(0, Math.min(base.tr, max)),
br: Math.max(0, Math.min(base.br, max)),
bl: Math.max(0, Math.min(base.bl, max))
};
}
/**
* Add a rounded rectangle to the current path.
* @param {CanvasRenderingContext2D} ctx Canvas context
* @param {object} rect Rectangle
* @param {object} radius Corner radii
* @private
*/
private traceRoundRect(
ctx: CanvasRenderingContext2D,
rect: CanvasRect,
radius: Required<Exclude<CanvasRectRadius, number>>
): void {
const {x, y, w, h} = rect;
const right = x + w;
const bottom = y + h;
ctx.moveTo(x + radius.tl, y);
ctx.lineTo(right - radius.tr, y);
radius.tr ? ctx.quadraticCurveTo(right, y, right, y + radius.tr) : ctx.lineTo(right, y);
ctx.lineTo(right, bottom - radius.br);
radius.br ?
ctx.quadraticCurveTo(right, bottom, right - radius.br, bottom) :
ctx.lineTo(right, bottom);
ctx.lineTo(x + radius.bl, bottom);
radius.bl ? ctx.quadraticCurveTo(x, bottom, x, bottom - radius.bl) : ctx.lineTo(x, bottom);
ctx.lineTo(x, y + radius.tl);
radius.tl ? ctx.quadraticCurveTo(x, y, x + radius.tl, y) : ctx.lineTo(x, y);
ctx.closePath();
}
/**
* Normalize rectangle coordinates for path drawing.
* @param {object} rect Rectangle
* @returns {object} Normalized rectangle
* @private
*/
private normalizeRect(rect: CanvasRect): CanvasRect {
const x = rect.w < 0 ? rect.x + rect.w : rect.x;
const y = rect.h < 0 ? rect.y + rect.h : rect.y;
return {
x,
y,
w: Math.abs(rect.w),
h: Math.abs(rect.h)
};
}
/**
* Apply drawing style.
* @param {object} style Drawing style
* @private
*/
applyStyle(style?: CanvasStyle): void {
if (!style) {
return;
}
const {ctx} = this;
style.fill !== undefined && (ctx.fillStyle = style.fill);
style.stroke !== undefined && (ctx.strokeStyle = style.stroke);
style.lineWidth !== undefined && (ctx.lineWidth = style.lineWidth);
style.alpha !== undefined && (ctx.globalAlpha = style.alpha);
style.font !== undefined && (ctx.font = style.font);
style.textAlign !== undefined && (ctx.textAlign = style.textAlign);
style.textBaseline !== undefined && (ctx.textBaseline = style.textBaseline);
style.lineDash !== undefined && ctx.setLineDash(style.lineDash);
}
/**
* Run with optional style state.
* @param {object} style Optional drawing style
* @param {DrawCallback} draw Draw callback
* @private
*/
private withStyle(style: CanvasStyle | undefined, draw: DrawCallback): void {
if (style) {
this.withState(ctx => {
this.applyStyle(style);
draw(ctx);
});
} else {
draw(this.ctx);
}
}
/**
* Get crisp canvas coordinate for axis/grid strokes.
* @param {number} value Coordinate value
* @param {number} lineWidth Stroke width
* @returns {number} Crisp coordinate
* @private
*/
crisp(value: number, lineWidth: number): number {
return lineWidth % 2 ? Math.round(value) + 0.5 : Math.round(value);
}
}