UNPKG

isomorphic-svg-charts

Version:

A lightweight, environment-agnostic SVG charting library that works both in the browser and server-side, with no dependencies and no DOM reliance.

604 lines (598 loc) 19.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.CartesianChart = void 0; const catmull_rom_1 = require("./catmull-rom"); function roundByDigitsWithZeroInSegments({ min, max, segments = 10, roundingFactor = 10, // Configurable rounding factor, default to 10 }) { // Ensure min <= 0 and max >= 0 to include zero if (min > 0) min = 0; if (max < 0) max = 0; // Ensure roundingFactor is at least 10 to match resolution requirements const effectiveRoundingFactor = Math.max(roundingFactor, 10); // Round min and max using the configurable rounding factor const roundedMin = Math.floor(min / effectiveRoundingFactor) * effectiveRoundingFactor; let roundedMax = Math.ceil(max / effectiveRoundingFactor) * effectiveRoundingFactor; // Recalculate the range const range = roundedMax - roundedMin; // Calculate the step size to divide the range into equal segments const segmentStep = Math.ceil(range / (segments - 1)); // Re-adjust roundedMin and roundedMax to ensure they align with the segment step const adjustedMin = Math.floor(roundedMin / segmentStep) * segmentStep; let adjustedMax = Math.ceil(roundedMax / segmentStep) * segmentStep; // Prevent adjustedMax from overshooting the original max value if (adjustedMax > max) { adjustedMax = max; } return { roundedMin: adjustedMin, roundedMax: adjustedMax, segmentStep }; } function normalize(min, max, value) { if (min === max) return 0; // Avoid division by zero return (value - min) / (max - min); } function remap(min, max, value) { return min + (max - min) * value; } // function tooltip({ text, x, y }: { text: string; x: number; y: number }) { // const numChars = text.length; // const height = 8; // const width = numChars * 3; // return ` // <g class="tooltip"> // <rect // x="${x - width}" // y="${y}" // width="${width}" // height="${height}" // fill="white" // stroke="gray" // stroke-width="1" // vector-effect="non-scaling-stroke" // /> // <text // x="${x - width / 2}" // y="${y + height / 2}" // font-size="0.25em" // text-anchor="middle" // dominant-baseline="middle" // > // ${text} // </text> // </g> // `; // } class XAxis { height = 20; numTicks = 5; stroke = "#666"; labels = []; centerLabels = false; constructor(config) { this.height = config.height; this.numTicks = config.numTicks ?? this.numTicks; this.stroke = config.stroke ?? this.stroke; this.labels = config.labels ?? this.labels; this.centerLabels = config.centerLabels ?? this.centerLabels; } setCenterLabels(centerLabels) { this.centerLabels = centerLabels; } getHeight() { return this.height; } toString({ width, x, y }) { const height = this.getHeight(); const numTicks = this.numTicks; const arr = Array.from({ length: numTicks }, (_, i) => i); const tickWidth = width / (numTicks - 1); const tickOffset = tickWidth * (this.centerLabels ? 0.5 : 0); const line = ` <line x1="${x}" y1="${y}" x2="${x + width}" y2="${y}" stroke="${this.stroke}" stroke-width="1" vector-effect="non-scaling-stroke" /> `; x = x + tickOffset; return `<g> ${line} ${arr .map((i) => ` <line x1="${remap(x, x + width, (1 / (numTicks - 1)) * i)}" y1="${y}" x2="${remap(x, x + width, (1 / (numTicks - 1)) * i)}" y2="${y + height * 0.25}" stroke="${this.stroke}" stroke-width="1" vector-effect="non-scaling-stroke" /> `) .join("")} ${this.labels .map((label, i) => ` <text x="${remap(x, x + width, (1 / (numTicks - 1)) * i)}" y="${y + height * 0.75}" font-size="1em" text-anchor="middle" dominant-baseline="middle" > ${label} </text> `) .join("")} </g>`; } getNumTicks() { return this.numTicks; } setNumTicks(numTicks) { this.numTicks = numTicks; } } class YAxis { width = 20; numTicks = 5; stroke = "#666"; labels = []; tickFormatter = (value, fixed) => value.toFixed(fixed); constructor(config) { this.width = config.width; this.numTicks = config.numTicks ?? this.numTicks; this.stroke = config.stroke ?? this.stroke; this.labels = config.labels ?? this.labels; this.tickFormatter = config.tickFormatter ?? this.tickFormatter; } getWidth() { return this.width; } setLabels(labels) { this.labels = labels.map((label) => this.tickFormatter(label, 0)); } toString({ height, x, y, }) { const width = this.getWidth(); const numTicks = this.numTicks; const arr = Array.from({ length: numTicks }, (_, i) => i); const tickWidth = 1; const left = x + width; return `<g> <rect x="${x}" y="${y}" width="${this.width}" height="${height}" fill="none" /> <line x1="${left}" y1="${y}" x2="${left}" y2="${y + height}" stroke="${this.stroke}" stroke-width="1" vector-effect="non-scaling-stroke" /> ${arr .map((i) => ` <line x1="${left}" y1="${remap(y + height, y, (1 / (numTicks - 1)) * i)}" x2="${left - tickWidth}" y2="${remap(y + height, y, (1 / (numTicks - 1)) * i)}" stroke="${this.stroke}" stroke-width="1" vector-effect="non-scaling-stroke" /> `) .join("")} ${this.labels .map((label, i) => ` <text x="${left - tickWidth - 1}" y="${remap(y + height, y, (1 / (numTicks - 1)) * i)}" font-size="1em" text-anchor="end" dominant-baseline="middle" > ${label} </text> `) .join("")} </g>`; } getNumTicks() { return this.numTicks; } } class CartesianGrid { stroke = "rgba(50, 50, 50, 0.15)"; constructor(config) { this.stroke = config?.stroke ?? this.stroke; } render({ height, width, x, y, numVerticalTicks, numHorizontalTicks, }) { const verticalTicks = Array.from({ length: numVerticalTicks }, (_, i) => i); const horizontalTicks = Array.from({ length: numHorizontalTicks }, (_, i) => i); return ` <g> <rect x="${x}" y="${y}" width="${width}" height="${height}" fill="none" /> ${verticalTicks .map((i) => ` <line x1="${remap(x, x + width, (1 / (numVerticalTicks - 1)) * i)}" y1="${y}" x2="${remap(x, x + width, (1 / (numVerticalTicks - 1)) * i)}" y2="${y + height}" stroke="${this.stroke}" stroke-width="1" vector-effect="non-scaling-stroke" class="grid-line" /> `) .join("")} ${horizontalTicks .map((i) => ` <line x1="${x}" y1="${remap(y + height, y, (1 / (numHorizontalTicks - 1)) * i)}" x2="${x + width}" y2="${remap(y + height, y, (1 / (numHorizontalTicks - 1)) * i)}" stroke="${this.stroke}" stroke-width="1" vector-effect="non-scaling-stroke" class="grid-line" /> `) .join("")} </g> `; } } class CartesianChart { data; height; width; padding; _xAxis; _yAxis; grid; centerDataPoints = false; backgroundColor; textColor = "currentColor"; stackOffset = "none"; maxYValue = 0; minYValue = 0; components = []; constructor({ data, apsectRatio = 1, padding = 3, backgroundColor, textColor, stackOffset, }) { this.data = data; this.height = 100; this.width = 100 * apsectRatio; this.padding = padding; this.backgroundColor = backgroundColor; this.textColor = textColor ?? this.textColor; this.stackOffset = stackOffset ?? this.stackOffset; return this; } bar(config) { this.centerDataPoints = true; this.components.push({ ...config, type: "bar", }); return this; } area(config) { this.components.push({ ...config, type: "area", }); return this; } line(config) { this.components.push({ ...config, type: "line", }); return this; } renderHighlights() { const plotArea = this.getPlotArea(); const numSections = this.data.length - (this.centerDataPoints ? 0 : 1); const sectionWidth = plotArea.width / numSections; return Array.from({ length: numSections }, (_, i) => i) .map((d, i) => ` <g> <rect x="${plotArea.x + i * sectionWidth}" y="${plotArea.y}" width="${sectionWidth}" height="${plotArea.height}" fill="transparent" class="bar-group" /> </g> `) .join(""); } renderBars(bars) { const plotArea = this.getPlotArea(); const sectionWidth = plotArea.width / this.data.length; let barWidth = sectionWidth / bars.length; const barSpacing = barWidth * 0.15; barWidth -= barSpacing * 1.5; const maxYValue = this.maxYValue; return Array.from({ length: this.data.length }, (_, i) => i) .map((i) => ` <g> ${bars .map((bar, j) => { const value = this.data[i]?.[bar.dataKey]; if (typeof value !== "number") return ""; const barHeight = remap(0, plotArea.height, value / maxYValue); return ` <rect x="${plotArea.x + i * sectionWidth + barWidth * j + barSpacing * (j + 1)}" y="${plotArea.y + plotArea.height - barHeight}" width="${barWidth}" height="${barHeight}" fill="${bar.fill}" rx="${bar.borderRadius ?? 0}" ry="${bar.borderRadius ?? 0}" class="bar" /> `; }) .join("")} </g> `) .join(""); } renderLines(lines) { const plotArea = this.getPlotArea(); const sectionWidth = plotArea.width / (this.data.length - (this.centerDataPoints ? 0 : 1)); const maxYValue = this.maxYValue; return lines .map((line) => { const points = this.data.map((d, i) => { const value = d[line.dataKey]; let x = i * sectionWidth; if (this.centerDataPoints) { x += sectionWidth / 2; } const normalizedValue = normalize(this.minYValue, this.maxYValue, value); const y = remap(plotArea.height, 0, normalizedValue); return { x: plotArea.x + x, y: plotArea.y + y }; }); const path = (0, catmull_rom_1.toCatmullRom)(points, 0.5); return ` <g> <path d="${path}" fill="none" stroke="${line.stroke}" stroke-width="1" stroke-dasharray="${line.style === "dashed" ? "5, 5" : "none"}" vector-effect="non-scaling-stroke" /> ${points .map(({ x, y }) => ` <circle cx="${x}" cy="${y}" r="0.5" fill="${line.stroke}" /> `) .join("")} </g> `; }) .join(""); } renderAreas(areas) { const plotArea = this.getPlotArea(); const sectionWidth = plotArea.width / (this.data.length - (this.centerDataPoints ? 0 : 1)); const stacksMaxHeight = new Map(); const stacks = new Map(); return areas .map((area) => { this.data.map((d, i) => { let value = d[area.dataKey]; if (area.stackId) { value += stacksMaxHeight.get(`${area.stackId}-${i}`) ?? 0; stacksMaxHeight.set(`${area.stackId}-${i}`, value); } }); return area; }) .map((area) => { const points = this.data.map((d, i) => { let value = d[area.dataKey]; if (area.stackId) { value += stacks.get(`${area.stackId}-${i}`) ?? 0; stacks.set(`${area.stackId}-${i}`, value); } let x = i * sectionWidth; if (this.centerDataPoints) { x += sectionWidth / 2; } const maxYValue = this.stackOffset === "expand" ? (stacksMaxHeight.get(`${area.stackId}-${i}`) ?? this.maxYValue) : this.maxYValue; const normalizedValue = normalize(this.minYValue, maxYValue, value); const y = remap(plotArea.height, 0, normalizedValue); return { x: plotArea.x + x, y: plotArea.y + y }; }); const path = (0, catmull_rom_1.toCatmullRom)(points, 0.5); const bottomLeft = points[0]; bottomLeft.y = plotArea.y + plotArea.height; const bottomRight = points[points.length - 1]; bottomRight.y = plotArea.y + plotArea.height; const areaPath = path + ` L ${bottomRight.x} ${bottomRight.y} L ${bottomLeft.x} ${bottomLeft.y} Z`; return ` <g> <path d="${areaPath}" fill="${area.fill}" stroke="none" vector-effect="non-scaling-stroke" /> <path d="${path}" stroke="${area.stroke}" fill="none" vector-effect="non-scaling-stroke" /> </g> `; }) .toReversed() .join(""); } renderComponents() { const bars = this.components.filter((c) => c.type === "bar"); const lines = this.components.filter((c) => c.type === "line"); const areas = this.components.filter((c) => c.type === "area"); return { areas: this.renderAreas(areas), bars: this.renderBars(bars), lines: this.renderLines(lines), highlighs: this.renderHighlights(), }; } calculateHeightScale() { const stacks = new Map(); let maxYValue = -Infinity; let minYValue = Infinity; for (const component of this.components) { let i = 0; for (const data of this.data) { const value = data[component.dataKey]; if (typeof value !== "number") continue; let valueAsNumber = value; if (component.stackId) { valueAsNumber += stacks.get(`${component.stackId}-${i}`) ?? 0; stacks.set(`${component.stackId}-${i}`, valueAsNumber); } maxYValue = Math.max(maxYValue, valueAsNumber); minYValue = Math.min(minYValue, valueAsNumber); i++; } } const { roundedMin, roundedMax, } = roundByDigitsWithZeroInSegments({ min: minYValue, max: maxYValue, segments: 4, }); this.minYValue = roundedMin; this.maxYValue = roundedMax; } xAxis({ dataKey, ...config }) { const labels = dataKey ? this.data.map((d) => { if (dataKey in d) { return String(d[dataKey]); } return ""; }) : []; this._xAxis = new XAxis({ ...config, labels, }); return this; } yAxis(config) { this._yAxis = new YAxis(config); return this; } cartesianGrid() { this.grid = new CartesianGrid(); return this; } getPlotArea() { const xAxisHeight = this._xAxis?.getHeight() ?? 0; const yAxisWidth = this._yAxis?.getWidth() ?? 0; return { x: yAxisWidth + this.padding, y: this.padding, width: this.width - yAxisWidth - this.padding * 2, height: this.height - xAxisHeight - this.padding * 2, }; } toString() { this.calculateHeightScale(); const plotArea = this.getPlotArea(); const numXTicks = this.data.length + (this.centerDataPoints ? 1 : 0); this._xAxis?.setCenterLabels(this.centerDataPoints); this._xAxis?.setNumTicks(numXTicks); const xAxisStr = this._xAxis?.toString({ width: plotArea.width, x: plotArea.x, y: plotArea.y + plotArea.height, }); if (this._yAxis) { const numYTicks = this._yAxis?.getNumTicks(); const maxYValue = this.stackOffset === "expand" ? 1 : this.maxYValue; const minYValue = this.stackOffset === "expand" ? 0 : this.minYValue; const labels = Array.from({ length: numYTicks }, (_, i) => remap(minYValue, maxYValue, (1 / (numYTicks - 1)) * i)); this._yAxis.setLabels(labels); } const yAxisStr = this._yAxis?.toString({ height: plotArea.height, x: this.padding, y: plotArea.y, }); const gridStr = this.grid?.render({ height: plotArea.height, width: plotArea.width, x: plotArea.x, y: plotArea.y, numVerticalTicks: numXTicks, numHorizontalTicks: this._yAxis?.getNumTicks() ?? 5, }); const components = this.renderComponents(); return ` <svg style="font-size: 2.5px; font-family: sans-serif;" viewBox="0 0 ${this.width} ${this.height}" width="100%" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"> <style> .grid-line { stroke-dasharray: 3, 3; } .tooltip { display: none; } g:hover .tooltip { display: block; } .bar-group { fill: transparent; } g:hover > .bar-group { fill: rgba(0, 0, 0, 0.03); } text { fill: ${this.textColor}; } svg { background-color: ${this.backgroundColor ?? "transparent"}; } </style> ${components.areas} ${components.bars} ${yAxisStr} ${xAxisStr} ${components.lines} ${components.highlighs} ${gridStr} </svg> `; } } exports.CartesianChart = CartesianChart;