UNPKG

billboard.js

Version:

Re-usable easy interface JavaScript chart library, based on D3 v4+

890 lines (782 loc) 23.7 kB
/** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license */ import {window} from "../module/browser"; import {sanitize} from "../module/util"; import CanvasPainter, {CanvasPointType, CanvasRect, CanvasStyle} from "./CanvasPainter"; import {withOpacity} from "./color"; // Fallback coordinate box for custom SVG point patterns without a viewBox. SVG can // render the markup directly; canvas needs an explicit box to normalize coordinates. const DEFAULT_POINT_VIEWBOX = {x: 0, y: 0, w: 8, h: 8}; export type CanvasPointPattern = CanvasPointType | string; type CustomPointBox = CanvasRect; type CustomPointMatrix = [number, number, number, number, number, number]; type CustomPointStyle = { fill?: string | null, stroke?: string | null, lineWidth?: number, alpha?: number }; type CustomPointPaintStop = {offset: number, color: string, opacity?: number}; type CustomPointPaint = | { type: "linearGradient", x1: string | number, y1: string | number, x2: string | number, y2: string | number, stops: CustomPointPaintStop[] } | { type: "radialGradient", cx: string | number, cy: string | number, r: string | number, fx?: string | number, fy?: string | number, fr?: string | number, stops: CustomPointPaintStop[] }; type CustomPointShape = | { type: "polygon" | "polyline", points: number[][], box: CustomPointBox, matrix: CustomPointMatrix, style: CustomPointStyle } | { type: "circle", cx: number, cy: number, r: number, box: CustomPointBox, matrix: CustomPointMatrix, style: CustomPointStyle } | { type: "ellipse", cx: number, cy: number, rx: number, ry: number, box: CustomPointBox, matrix: CustomPointMatrix, style: CustomPointStyle } | { type: "rect", x: number, y: number, w: number, h: number, box: CustomPointBox, matrix: CustomPointMatrix, style: CustomPointStyle } | { type: "line", x1: number, y1: number, x2: number, y2: number, box: CustomPointBox, matrix: CustomPointMatrix, style: CustomPointStyle } | { type: "path", d: string, path2D?: Path2D, box: CustomPointBox, matrix: CustomPointMatrix, style: CustomPointStyle }; type CustomPointPattern = { shapes: CustomPointShape[], box: CustomPointBox, paints: Map<string, CustomPointPaint> }; const customPointPatternCache = new Map<string, CustomPointPattern | null>(); const IDENTITY_POINT_MATRIX: CustomPointMatrix = [1, 0, 0, 1, 0, 0]; /** * Check if point type can be drawn by the built-in point renderer. * @param {string} type Point type * @returns {boolean} Whether type is built-in * @private */ function isBuiltinPointType(type: CanvasPointPattern): type is CanvasPointType { return type === "circle" || type === "rectangle"; } /** * Get numeric SVG attribute. * @param {Element} node SVG element * @param {string} name Attribute name * @param {number} fallback Fallback value * @returns {number} Numeric attribute value * @private */ function getSvgNumber(node: Element, name: string, fallback = 0): number { const value = node.getAttribute(name); const parsed = value === null ? NaN : parseFloat(value); return Number.isFinite(parsed) ? parsed : fallback; } /** * Parse an SVG points attribute. * @param {string} value Points attribute * @returns {Array} Coordinate pairs * @private */ function parseSvgPoints(value: string): number[][] { const nums = (value.match(/[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi) || []) .map(Number) .filter(Number.isFinite); const points: number[][] = []; for (let i = 0; i < nums.length - 1; i += 2) { points.push([nums[i], nums[i + 1]]); } return points; } /** * Parse an SVG viewBox attribute. * @param {string|null} value viewBox value * @returns {object|null} Parsed box * @private */ function parseViewBox(value: string | null): CustomPointBox | null { const values = value?.trim().split(/[\s,]+/).map(Number).filter(Number.isFinite) || []; return values.length === 4 ? { x: values[0], y: values[1], w: values[2], h: values[3] } : null; } /** * Get bounds for coordinate pairs. * @param {Array} points Coordinate pairs * @returns {object} Bounds * @private */ function getPointsBox(points: number[][]): CustomPointBox { const xs = points.map(([x]) => x); const ys = points.map(([, y]) => y); const x = Math.min(...xs); const y = Math.min(...ys); return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y }; } /** * Merge multiple bounds. * @param {Array} boxes Bounds * @returns {object} Merged bounds * @private */ function mergePointBoxes(boxes: CustomPointBox[]): CustomPointBox { const validBoxes = boxes.filter(box => Number.isFinite(box.x) && Number.isFinite(box.y) && Number.isFinite(box.w) && Number.isFinite(box.h) ); if (!validBoxes.length) { return {...DEFAULT_POINT_VIEWBOX}; } const x = Math.min(...validBoxes.map(box => box.x)); const y = Math.min(...validBoxes.map(box => box.y)); const right = Math.max(...validBoxes.map(box => box.x + box.w)); const bottom = Math.max(...validBoxes.map(box => box.y + box.h)); return { x, y, w: right - x, h: bottom - y }; } /** * Multiply SVG affine matrices. * @param {Array} a Base matrix * @param {Array} b Matrix to append * @returns {Array} Combined matrix * @private */ function multiplyPointMatrix(a: CustomPointMatrix, b: CustomPointMatrix): CustomPointMatrix { return [ a[0] * b[0] + a[2] * b[1], a[1] * b[0] + a[3] * b[1], a[0] * b[2] + a[2] * b[3], a[1] * b[2] + a[3] * b[3], a[0] * b[4] + a[2] * b[5] + a[4], a[1] * b[4] + a[3] * b[5] + a[5] ]; } /** * Parse SVG transform attribute into a canvas matrix. * @param {string|null} value Transform attribute * @returns {Array} Matrix * @private */ function parsePointTransform(value: string | null): CustomPointMatrix { let matrix: CustomPointMatrix = [...IDENTITY_POINT_MATRIX]; (value?.match(/[a-z]+\([^)]*\)/gi) || []).forEach(token => { const [, rawName, body] = token.match(/^([a-z]+)\(([^)]*)\)$/i) || []; const name = rawName?.toLowerCase(); const values = (body?.match(/[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi) || []) .map(Number) .filter(Number.isFinite); let next: CustomPointMatrix | null = null; if (name === "matrix" && values.length >= 6) { next = values.slice(0, 6) as CustomPointMatrix; } else if (name === "translate" && values.length) { next = [1, 0, 0, 1, values[0], values[1] || 0]; } else if (name === "scale" && values.length) { next = [values[0], 0, 0, values[1] ?? values[0], 0, 0]; } else if (name === "rotate" && values.length) { const angle = values[0] * Math.PI / 180; const cos = Math.cos(angle); const sin = Math.sin(angle); const rotate: CustomPointMatrix = [cos, sin, -sin, cos, 0, 0]; next = values.length >= 3 ? multiplyPointMatrix( multiplyPointMatrix([1, 0, 0, 1, values[1], values[2]], rotate), [1, 0, 0, 1, -values[1], -values[2]] ) : rotate; } else if (name === "skewx" && values.length) { next = [1, 0, Math.tan(values[0] * Math.PI / 180), 1, 0, 0]; } else if (name === "skewy" && values.length) { next = [1, Math.tan(values[0] * Math.PI / 180), 0, 1, 0, 0]; } next && (matrix = multiplyPointMatrix(matrix, next)); }); return matrix; } /** * Read style or presentation attribute. * @param {Element} node SVG element * @param {string} name Property name * @returns {string|null} Property value * @private */ function getPointPresentationValue(node: Element, name: string): string | null { const style = node.getAttribute("style"); const attr = node.getAttribute(name); const match = style?.match(new RegExp(`(?:^|;)\\s*${name}\\s*:\\s*([^;]+)`, "i")); return (match?.[1] || attr || "").trim() || null; } /** * Parse inherited point style from SVG presentation attributes. * @param {Element} node SVG element * @param {object} inherited Inherited style * @returns {object} Resolved style * @private */ function parsePointStyle(node: Element, inherited: CustomPointStyle): CustomPointStyle { const style: CustomPointStyle = {...inherited}; const fill = getPointPresentationValue(node, "fill"); const stroke = getPointPresentationValue(node, "stroke"); const strokeWidth = getPointPresentationValue(node, "stroke-width"); const opacity = getPointPresentationValue(node, "opacity"); const fillOpacity = getPointPresentationValue(node, "fill-opacity"); const strokeOpacity = getPointPresentationValue(node, "stroke-opacity"); if (fill) { style.fill = fill === "none" ? null : fill; } if (stroke) { style.stroke = stroke === "none" ? null : stroke; } if (strokeWidth) { const lineWidth = parseFloat(strokeWidth); Number.isFinite(lineWidth) && (style.lineWidth = lineWidth); } [opacity, fillOpacity, strokeOpacity].forEach(value => { const alpha = value === null ? NaN : parseFloat(value); Number.isFinite(alpha) && (style.alpha = (style.alpha ?? 1) * alpha); }); return style; } /** * Collect local SVG definitions addressable by id. * @param {Element} root SVG root element * @returns {Map} Local definition map * @private */ function collectPointDefs(root: Element): Map<string, Element> { const defs = new Map<string, Element>(); Array.from(root.querySelectorAll("[id]")).forEach(node => { const id = node.getAttribute("id"); id && defs.set(id, node); }); return defs; } /** * Parse SVG length or percentage for custom point paint coordinates. * @param {string|null} value SVG length value * @param {string|number} fallback Fallback value * @returns {string|number} Parsed value * @private */ function parsePointPaintLength(value: string | null, fallback: string | number): string | number { const text = value?.trim(); if (!text) { return fallback; } return /%$/.test(text) ? text : parseFloat(text); } /** * Convert SVG length/percentage into custom point coordinate space. * @param {string|number|undefined} value Length value * @param {number} origin Box origin * @param {number} size Box size * @param {number} fallback Fallback value * @returns {number} Coordinate * @private */ function pointPaintCoord( value: string | number | undefined, origin: number, size: number, fallback: number ): number { if (typeof value === "number") { return Number.isFinite(value) ? value : fallback; } if (typeof value === "string" && /%$/.test(value)) { const percent = parseFloat(value); return Number.isFinite(percent) ? origin + size * percent / 100 : fallback; } const parsed = parseFloat(value ?? ""); return Number.isFinite(parsed) ? parsed : fallback; } /** * Parse SVG gradient stop offset. * @param {string|null} value Stop offset * @returns {number} Offset * @private */ function parsePointPaintOffset(value: string | null): number { const text = value?.trim() || "0"; const parsed = parseFloat(text); const offset = /%$/.test(text) ? parsed / 100 : parsed; return Math.max(0, Math.min(1, Number.isFinite(offset) ? offset : 0)); } /** * Parse SVG gradient stops. * @param {Element} node Gradient node * @returns {Array} Stops * @private */ function parsePointPaintStops(node: Element): CustomPointPaintStop[] { return Array.from(node.children || []) .filter(child => child.tagName.toLowerCase() === "stop") .map(stop => { const color = getPointPresentationValue(stop, "stop-color") || "#000"; const opacity = getPointPresentationValue(stop, "stop-opacity"); const parsedOpacity = opacity === null ? NaN : parseFloat(opacity); return { offset: parsePointPaintOffset(stop.getAttribute("offset")), color, opacity: Number.isFinite(parsedOpacity) ? parsedOpacity : undefined }; }); } /** * Collect local linear/radial gradients addressable by id. * @param {Map} defs Local definition map * @returns {Map} Paint map * @private */ function collectPointPaints(defs: Map<string, Element>): Map<string, CustomPointPaint> { const paints = new Map<string, CustomPointPaint>(); defs.forEach((node, id) => { const tagName = node.tagName.toLowerCase(); const stops = parsePointPaintStops(node); if (!stops.length) { return; } if (tagName === "lineargradient") { paints.set(id, { type: "linearGradient", x1: parsePointPaintLength(node.getAttribute("x1"), "0%"), y1: parsePointPaintLength(node.getAttribute("y1"), "0%"), x2: parsePointPaintLength(node.getAttribute("x2"), "100%"), y2: parsePointPaintLength(node.getAttribute("y2"), "0%"), stops }); } else if (tagName === "radialgradient") { paints.set(id, { type: "radialGradient", cx: parsePointPaintLength(node.getAttribute("cx"), "50%"), cy: parsePointPaintLength(node.getAttribute("cy"), "50%"), r: parsePointPaintLength(node.getAttribute("r"), "50%"), fx: parsePointPaintLength(node.getAttribute("fx"), "50%"), fy: parsePointPaintLength(node.getAttribute("fy"), "50%"), fr: parsePointPaintLength(node.getAttribute("fr"), 0), stops }); } }); return paints; } /** * Parse custom SVG point element into canvas drawable shapes. * @param {Element} node SVG element * @param {object} fallbackBox Fallback bounds for path-only shapes * @param {Map} defs Local definitions * @param {Array} matrix Current transform matrix * @param {object} inheritedStyle Current inherited style * @param {Set} seen Already resolved definition ids * @returns {Array} Parsed shapes * @private */ function parseCustomPointNode( node: Element, fallbackBox: CustomPointBox, defs: Map<string, Element>, matrix: CustomPointMatrix = IDENTITY_POINT_MATRIX, inheritedStyle: CustomPointStyle = {}, seen = new Set<string>() ): CustomPointShape[] { const tagName = node.tagName.toLowerCase(); const children = Array.from(node.children || []); const style = parsePointStyle(node, inheritedStyle); const transform = multiplyPointMatrix(matrix, parsePointTransform(node.getAttribute("transform"))); const shapeBase = {matrix: transform, style}; if (tagName === "defs") { return []; } if (tagName === "svg" || tagName === "g" || tagName === "symbol") { return children.reduce<CustomPointShape[]>( (shapes, child) => shapes.concat( parseCustomPointNode(child, fallbackBox, defs, transform, style, seen) ), [] ); } if (tagName === "use") { const href = node.getAttribute("href") || node.getAttribute("xlink:href") || ""; const id = href.charAt(0) === "#" ? href.slice(1) : ""; const target = id && defs.get(id); if (!target || seen.has(id)) { return []; } const x = getSvgNumber(node, "x", 0); const y = getSvgNumber(node, "y", 0); const useTransform = multiplyPointMatrix(transform, [1, 0, 0, 1, x, y]); seen.add(id); const shapes = parseCustomPointNode(target, fallbackBox, defs, useTransform, style, seen); seen.delete(id); return shapes; } if (tagName === "polygon" || tagName === "polyline") { const points = parseSvgPoints(node.getAttribute("points") || ""); return points.length ? [{ type: tagName, points, box: getPointsBox(points), ...shapeBase }] : []; } if (tagName === "circle") { const cx = getSvgNumber(node, "cx", 0); const cy = getSvgNumber(node, "cy", 0); const r = getSvgNumber(node, "r", 0); return r > 0 ? [{ type: "circle", cx, cy, r, box: {x: cx - r, y: cy - r, w: r * 2, h: r * 2}, ...shapeBase }] : []; } if (tagName === "ellipse") { const cx = getSvgNumber(node, "cx", 0); const cy = getSvgNumber(node, "cy", 0); const rx = getSvgNumber(node, "rx", 0); const ry = getSvgNumber(node, "ry", 0); return rx > 0 && ry > 0 ? [{ type: "ellipse", cx, cy, rx, ry, box: {x: cx - rx, y: cy - ry, w: rx * 2, h: ry * 2}, ...shapeBase }] : []; } if (tagName === "rect") { const x = getSvgNumber(node, "x", 0); const y = getSvgNumber(node, "y", 0); const w = getSvgNumber(node, "width", 0); const h = getSvgNumber(node, "height", 0); return w > 0 && h > 0 ? [{ type: "rect", x, y, w, h, box: {x, y, w, h}, ...shapeBase }] : []; } if (tagName === "line") { const x1 = getSvgNumber(node, "x1", 0); const y1 = getSvgNumber(node, "y1", 0); const x2 = getSvgNumber(node, "x2", 0); const y2 = getSvgNumber(node, "y2", 0); return [{ type: "line", x1, y1, x2, y2, box: getPointsBox([[x1, y1], [x2, y2]]), ...shapeBase }]; } if (tagName === "path") { const d = node.getAttribute("d") || ""; return d ? [{ type: "path", d, box: fallbackBox, ...shapeBase }] : []; } return []; } /** * Parse a custom SVG point pattern string. * @param {string} pattern SVG pattern * @returns {object|null} Parsed pattern * @private */ function parseCustomPointPattern(pattern: string): CustomPointPattern | null { if (!/^</.test(pattern)) { return null; } if (customPointPatternCache.has(pattern)) { return customPointPatternCache.get(pattern) || null; } let parsed: CustomPointPattern | null = null; try { const doc = new window.DOMParser().parseFromString(sanitize(pattern), "image/svg+xml"); const root = doc.documentElement; const fallbackBox = parseViewBox(root.getAttribute("viewBox")) || DEFAULT_POINT_VIEWBOX; const defs = collectPointDefs(root); const shapes = parseCustomPointNode(root, fallbackBox, defs); if (shapes.length) { parsed = { shapes, box: root.tagName.toLowerCase() === "svg" ? fallbackBox : mergePointBoxes(shapes.map(shape => shape.box)), paints: collectPointPaints(defs) }; } } catch { parsed = null; } customPointPatternCache.set(pattern, parsed); return parsed; } /** * Trace a parsed custom SVG point shape. * @param {CanvasRenderingContext2D} ctx Canvas context * @param {object} shape Parsed shape * @private */ function traceCustomPointShape(ctx: CanvasRenderingContext2D, shape: CustomPointShape): void { if (shape.type === "polygon" || shape.type === "polyline") { const [start, ...rest] = shape.points; ctx.moveTo(start[0], start[1]); rest.forEach(([x, y]) => ctx.lineTo(x, y)); shape.type === "polygon" && ctx.closePath(); } else if (shape.type === "circle") { ctx.moveTo(shape.cx + shape.r, shape.cy); ctx.arc(shape.cx, shape.cy, shape.r, 0, Math.PI * 2); } else if (shape.type === "ellipse") { ctx.moveTo(shape.cx + shape.rx, shape.cy); ctx.ellipse(shape.cx, shape.cy, shape.rx, shape.ry, 0, 0, Math.PI * 2); } else if (shape.type === "rect") { ctx.rect(shape.x, shape.y, shape.w, shape.h); } else if (shape.type === "line") { ctx.moveTo(shape.x1, shape.y1); ctx.lineTo(shape.x2, shape.y2); } } /** * Extract local SVG paint server id. * @param {string|null|undefined} value Paint value * @returns {string|null} Paint id * @private */ function getCustomPointPaintId(value: string | null | undefined): string | null { const match = value?.match(/^url\(#([^)]+)\)$/); return match?.[1] || null; } /** * Create canvas gradient for a local custom point paint server. * @param {CanvasRenderingContext2D} ctx Canvas context * @param {object} paint Paint server * @param {object} box Pattern box * @returns {CanvasGradient} Canvas gradient * @private */ function createCustomPointPaint( ctx: CanvasRenderingContext2D, paint: CustomPointPaint, box: CustomPointBox ): CanvasGradient { const {x, y, w, h} = box; const gradient = paint.type === "linearGradient" ? ctx.createLinearGradient( pointPaintCoord(paint.x1, x, w, x), pointPaintCoord(paint.y1, y, h, y), pointPaintCoord(paint.x2, x, w, x + w), pointPaintCoord(paint.y2, y, h, y) ) : ctx.createRadialGradient( pointPaintCoord(paint.fx, x, w, x + w / 2), pointPaintCoord(paint.fy, y, h, y + h / 2), pointPaintCoord(paint.fr, x, Math.max(w, h), 0), pointPaintCoord(paint.cx, x, w, x + w / 2), pointPaintCoord(paint.cy, y, h, y + h / 2), Math.max(0.01, pointPaintCoord(paint.r, 0, Math.max(w, h), Math.max(w, h) / 2)) ); paint.stops.forEach(({offset, color, opacity}) => { gradient.addColorStop(offset, opacity === undefined ? color : withOpacity(color, opacity)); }); return gradient; } /** * Resolve custom SVG point shape style against the caller-provided canvas style. * @param {CanvasRenderingContext2D} ctx Canvas context * @param {object} base Base canvas style * @param {object} shapeStyle SVG shape style * @param {Map} paints Local paint servers * @param {object} box Pattern box * @returns {object} Draw style and paint flags * @private */ function getCustomPointDrawStyle( ctx: CanvasRenderingContext2D, base: CanvasStyle | undefined, shapeStyle: CustomPointStyle, paints: Map<string, CustomPointPaint>, box: CustomPointBox ): {style: CanvasStyle, shouldFill: boolean, shouldStroke: boolean, alpha?: number} { const style: CanvasStyle = {...(base || {})}; if (shapeStyle.fill !== undefined) { if (shapeStyle.fill === null) { delete style.fill; } else if (getCustomPointPaintId(shapeStyle.fill)) { const paint = paints.get(getCustomPointPaintId(shapeStyle.fill)!); paint && (style.fill = createCustomPointPaint(ctx, paint, box)); } else if (!/^url\(/.test(shapeStyle.fill)) { style.fill = shapeStyle.fill; } } if (shapeStyle.stroke !== undefined) { if (shapeStyle.stroke === null) { delete style.stroke; } else if (getCustomPointPaintId(shapeStyle.stroke)) { const paint = paints.get(getCustomPointPaintId(shapeStyle.stroke)!); paint && (style.stroke = createCustomPointPaint(ctx, paint, box)); } else if (!/^url\(/.test(shapeStyle.stroke)) { style.stroke = shapeStyle.stroke; } } shapeStyle.lineWidth !== undefined && (style.lineWidth = shapeStyle.lineWidth); return { style, shouldFill: shapeStyle.fill !== null && (!style.stroke || style.fill !== undefined), shouldStroke: shapeStyle.stroke !== null && style.stroke !== undefined, alpha: shapeStyle.alpha }; } /** * Draw built-in or custom SVG point pattern. * @param {CanvasPainter} painter Canvas painter * @param {string} pattern Point type or SVG pattern * @param {number} x X coordinate * @param {number} y Y coordinate * @param {number} r Radius * @param {object} style Optional style * @param {number} baseR Unexpanded radius used to resolve custom pattern scale * @private */ export function drawPointPattern( painter: CanvasPainter, pattern: CanvasPointPattern, x: number, y: number, r: number, style?, baseR = r ): void { if (isBuiltinPointType(pattern)) { painter.point(pattern, x, y, r, style); return; } const parsed = parseCustomPointPattern(pattern); if (!parsed || r <= 0) { painter.point("circle", x, y, r, style); return; } painter.withState(ctx => { const {box, paints, shapes} = parsed; const scale = baseR > 0 ? r / baseR : 1; const drawShape = (shape: CustomPointShape) => { const { style: drawStyle, shouldFill, shouldStroke, alpha } = getCustomPointDrawStyle(ctx, style, shape.style, paints, box); ctx.save(); painter.applyStyle(drawStyle); alpha !== undefined && (ctx.globalAlpha *= alpha); ctx.transform(...shape.matrix); ctx.beginPath(); if (shape.type === "path") { if (window.Path2D) { const path = shape.path2D || (shape.path2D = new window.Path2D(shape.d)); shouldFill && ctx.fill(path); shouldStroke && ctx.stroke(path); } } else { traceCustomPointShape(ctx, shape); shouldFill && ctx.fill(); shouldStroke && ctx.stroke(); } ctx.restore(); }; ctx.translate( x - (box.x + box.w / 2) * scale, y - (box.y + box.h / 2) * scale ); ctx.scale(scale, scale); shapes.forEach(drawShape); }); }