All checks were successful
continuous-integration/drone/push Build is passing
522 lines
13 KiB
JavaScript
522 lines
13 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
function clamp(value, min, max) {
|
|
if (value < min) {
|
|
return min;
|
|
}
|
|
if (value > max) {
|
|
return max;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function toNumber(value) {
|
|
var num = Number(value);
|
|
return Number.isFinite(num) ? num : null;
|
|
}
|
|
|
|
function escapeHTML(value) {
|
|
return String(value)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function formatValue(value, format) {
|
|
if (value === null || value === undefined || Number.isNaN(value)) {
|
|
return "-";
|
|
}
|
|
switch (format) {
|
|
case "int":
|
|
return String(Math.round(value));
|
|
case "float1":
|
|
return Number(value).toFixed(1);
|
|
case "float2":
|
|
return Number(value).toFixed(2);
|
|
default:
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
function pickTickIndices(total, desired) {
|
|
if (total <= 0) {
|
|
return [];
|
|
}
|
|
if (total === 1) {
|
|
return [0];
|
|
}
|
|
var target = Math.max(2, Math.min(total, desired || 6));
|
|
if (target >= total) {
|
|
var all = [];
|
|
for (var i = 0; i < total; i++) {
|
|
all.push(i);
|
|
}
|
|
return all;
|
|
}
|
|
var indices = [0];
|
|
var step = (total - 1) / (target - 1);
|
|
for (var j = 1; j < target - 1; j++) {
|
|
indices.push(Math.round(j * step));
|
|
}
|
|
indices.push(total - 1);
|
|
var seen = {};
|
|
var deduped = [];
|
|
for (var k = 0; k < indices.length; k++) {
|
|
var idx = indices[k];
|
|
if (!seen[idx]) {
|
|
seen[idx] = true;
|
|
deduped.push(idx);
|
|
}
|
|
}
|
|
deduped.sort(function (a, b) {
|
|
return a - b;
|
|
});
|
|
return deduped;
|
|
}
|
|
|
|
function getPlotBounds(width, height) {
|
|
return {
|
|
left: 52,
|
|
top: 16,
|
|
right: width - 20,
|
|
bottom: height - 78,
|
|
};
|
|
}
|
|
|
|
function buildScales(config, plot) {
|
|
var maxY = 0;
|
|
for (var i = 0; i < config.series.length; i++) {
|
|
var values = config.series[i].values || [];
|
|
for (var j = 0; j < values.length; j++) {
|
|
var value = toNumber(values[j]);
|
|
if (value !== null && value > maxY) {
|
|
maxY = value;
|
|
}
|
|
}
|
|
}
|
|
if (maxY <= 0) {
|
|
maxY = 1;
|
|
}
|
|
var count = config.labels.length;
|
|
var xSpan = plot.right - plot.left;
|
|
var ySpan = plot.bottom - plot.top;
|
|
|
|
return {
|
|
maxY: maxY,
|
|
xForIndex: function (index) {
|
|
if (count <= 1) {
|
|
return plot.left;
|
|
}
|
|
return plot.left + (index / (count - 1)) * xSpan;
|
|
},
|
|
yForValue: function (value) {
|
|
var numeric = toNumber(value);
|
|
if (numeric === null) {
|
|
return null;
|
|
}
|
|
return plot.bottom - (numeric / maxY) * ySpan;
|
|
},
|
|
};
|
|
}
|
|
|
|
function drawGrid(ctx, plot, config, scales) {
|
|
ctx.save();
|
|
ctx.strokeStyle = "#e2e8f0";
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([2, 4]);
|
|
|
|
var yTickCount = Math.max(2, config.yTicks || 5);
|
|
for (var i = 0; i < yTickCount; i++) {
|
|
var yRatio = i / (yTickCount - 1);
|
|
var y = plot.top + yRatio * (plot.bottom - plot.top);
|
|
ctx.beginPath();
|
|
ctx.moveTo(plot.left, y);
|
|
ctx.lineTo(plot.right, y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
var xIndices = pickTickIndices(config.labels.length, config.xTicks || 6);
|
|
for (var j = 0; j < xIndices.length; j++) {
|
|
var x = scales.xForIndex(xIndices[j]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, plot.top);
|
|
ctx.lineTo(x, plot.bottom);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawAxes(ctx, plot) {
|
|
ctx.save();
|
|
ctx.strokeStyle = "#94a3b8";
|
|
ctx.lineWidth = 1.5;
|
|
ctx.setLineDash([]);
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(plot.left, plot.bottom);
|
|
ctx.lineTo(plot.right, plot.bottom);
|
|
ctx.stroke();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(plot.left, plot.top);
|
|
ctx.lineTo(plot.left, plot.bottom);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawLabels(ctx, plot, config, scales) {
|
|
ctx.save();
|
|
ctx.fillStyle = "#475569";
|
|
ctx.font = "10px sans-serif";
|
|
|
|
var yTickCount = Math.max(2, config.yTicks || 5);
|
|
for (var i = 0; i < yTickCount; i++) {
|
|
var ratio = i / (yTickCount - 1);
|
|
var y = plot.top + ratio * (plot.bottom - plot.top);
|
|
var value = scales.maxY * (1 - ratio);
|
|
ctx.textAlign = "right";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(formatValue(value, "int"), plot.left - 8, y);
|
|
}
|
|
|
|
var xIndices = pickTickIndices(config.labels.length, config.xTicks || 6);
|
|
for (var j = 0; j < xIndices.length; j++) {
|
|
var idx = xIndices[j];
|
|
var tick = (config.tickLabels && config.tickLabels[idx]) || config.labels[idx] || "";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "top";
|
|
ctx.fillText(tick, scales.xForIndex(idx), plot.bottom + 12);
|
|
}
|
|
|
|
if (config.yLabel) {
|
|
ctx.save();
|
|
ctx.translate(16, plot.top + (plot.bottom-plot.top)/2);
|
|
ctx.rotate(-Math.PI / 2);
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "top";
|
|
ctx.font = "12px sans-serif";
|
|
ctx.fillText(config.yLabel, 0, 0);
|
|
ctx.restore();
|
|
}
|
|
|
|
if (config.xLabel) {
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "top";
|
|
ctx.font = "12px sans-serif";
|
|
ctx.fillText(config.xLabel, plot.left + (plot.right-plot.left)/2, plot.bottom + 48);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawSeries(ctx, plot, config, scales) {
|
|
for (var i = 0; i < config.series.length; i++) {
|
|
var series = config.series[i];
|
|
var values = series.values || [];
|
|
if (!values.length) {
|
|
continue;
|
|
}
|
|
ctx.save();
|
|
ctx.strokeStyle = series.color || "#2563eb";
|
|
ctx.lineWidth = series.lineWidth || 2.5;
|
|
ctx.setLineDash(Array.isArray(series.dash) ? series.dash : []);
|
|
ctx.beginPath();
|
|
var moved = false;
|
|
for (var j = 0; j < values.length; j++) {
|
|
var y = scales.yForValue(values[j]);
|
|
if (y === null) {
|
|
continue;
|
|
}
|
|
var x = scales.xForIndex(j);
|
|
if (!moved) {
|
|
ctx.moveTo(x, y);
|
|
moved = true;
|
|
} else {
|
|
ctx.lineTo(x, y);
|
|
}
|
|
}
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
function drawLegend(ctx, config, width, height) {
|
|
var x = 52;
|
|
var y = height - 32;
|
|
|
|
ctx.save();
|
|
ctx.font = "12px sans-serif";
|
|
ctx.textBaseline = "middle";
|
|
for (var i = 0; i < config.series.length; i++) {
|
|
var series = config.series[i];
|
|
var label = series.name || "Series";
|
|
ctx.strokeStyle = series.color || "#2563eb";
|
|
ctx.fillStyle = "#475569";
|
|
ctx.lineWidth = series.lineWidth || 2.5;
|
|
ctx.setLineDash(Array.isArray(series.dash) ? series.dash : []);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, y);
|
|
ctx.lineTo(x + 16, y);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
ctx.fillText(label, x + 22, y);
|
|
x += 22 + ctx.measureText(label).width + 18;
|
|
if (x > width - 160) {
|
|
x = 52;
|
|
y += 18;
|
|
}
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function updateTooltip(state, config) {
|
|
if (!state.tooltip) {
|
|
return;
|
|
}
|
|
if (state.hoverIndex === null) {
|
|
state.tooltip.classList.remove("visible");
|
|
state.tooltip.setAttribute("aria-hidden", "true");
|
|
return;
|
|
}
|
|
|
|
var idx = state.hoverIndex;
|
|
var rows = [];
|
|
rows.push('<div class="web3-chart-tooltip-title">' + escapeHTML(config.labels[idx] || "") + "</div>");
|
|
for (var i = 0; i < config.series.length; i++) {
|
|
var series = config.series[i];
|
|
if (series.tooltipHidden) {
|
|
continue;
|
|
}
|
|
var values = series.values || [];
|
|
var value = toNumber(values[idx]);
|
|
var valueLabel = formatValue(value, series.tooltipFormat || "int");
|
|
rows.push(
|
|
'<div class="web3-chart-tooltip-row">' +
|
|
'<span class="web3-chart-tooltip-label"><span class="web3-chart-tooltip-swatch" style="background:' + escapeHTML(series.color || "#2563eb") + '"></span>' +
|
|
escapeHTML(series.name || "Series") +
|
|
"</span>" +
|
|
'<span class="web3-chart-tooltip-value">' + escapeHTML(valueLabel) + "</span>" +
|
|
"</div>"
|
|
);
|
|
}
|
|
|
|
var hoverRows = config.hoverRows || [];
|
|
for (var j = 0; j < hoverRows.length; j++) {
|
|
var hover = hoverRows[j];
|
|
var values = hover.values || [];
|
|
var label = values[idx] || "-";
|
|
rows.push(
|
|
'<div class="web3-chart-tooltip-row">' +
|
|
'<span class="web3-chart-tooltip-label">' + escapeHTML(hover.name || "Value") + "</span>" +
|
|
'<span class="web3-chart-tooltip-value">' + escapeHTML(label) + "</span>" +
|
|
"</div>"
|
|
);
|
|
}
|
|
|
|
state.tooltip.innerHTML = rows.join("");
|
|
state.tooltip.classList.add("visible");
|
|
state.tooltip.setAttribute("aria-hidden", "false");
|
|
|
|
var box = state.wrapper.getBoundingClientRect();
|
|
var tooltipBox = state.tooltip.getBoundingClientRect();
|
|
var left = clamp(state.mouseX + 14, 4, box.width - tooltipBox.width - 4);
|
|
var top = clamp(state.mouseY + 14, 4, box.height - tooltipBox.height - 4);
|
|
state.tooltip.style.left = left + "px";
|
|
state.tooltip.style.top = top + "px";
|
|
}
|
|
|
|
function drawHover(ctx, plot, config, scales, hoverIndex) {
|
|
if (hoverIndex === null) {
|
|
return;
|
|
}
|
|
var x = scales.xForIndex(hoverIndex);
|
|
ctx.save();
|
|
ctx.strokeStyle = "#94a3b8";
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([3, 4]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, plot.top);
|
|
ctx.lineTo(x, plot.bottom);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
for (var i = 0; i < config.series.length; i++) {
|
|
var series = config.series[i];
|
|
var values = series.values || [];
|
|
var y = scales.yForValue(values[hoverIndex]);
|
|
if (y === null) {
|
|
continue;
|
|
}
|
|
ctx.fillStyle = "#ffffff";
|
|
ctx.strokeStyle = series.color || "#2563eb";
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 3.5, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function renderLineChart(options) {
|
|
if (!options || !options.canvasId || !options.config) {
|
|
return;
|
|
}
|
|
var canvas = document.getElementById(options.canvasId);
|
|
if (!canvas) {
|
|
return;
|
|
}
|
|
var config = options.config;
|
|
if (!Array.isArray(config.labels) || config.labels.length === 0 || !Array.isArray(config.series) || config.series.length === 0) {
|
|
return;
|
|
}
|
|
var wrapper = canvas.parentElement;
|
|
var tooltip = options.tooltipId ? document.getElementById(options.tooltipId) : null;
|
|
var ctx = canvas.getContext("2d");
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
|
|
var state = {
|
|
canvas: canvas,
|
|
wrapper: wrapper,
|
|
tooltip: tooltip,
|
|
hoverIndex: null,
|
|
mouseX: 0,
|
|
mouseY: 0,
|
|
scales: null,
|
|
plot: null,
|
|
cssWidth: 0,
|
|
cssHeight: config.height || 360,
|
|
};
|
|
|
|
function redraw() {
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
var dpr = window.devicePixelRatio || 1;
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
ctx.fillStyle = "#ffffff";
|
|
ctx.fillRect(0, 0, state.cssWidth, state.cssHeight);
|
|
|
|
drawGrid(ctx, state.plot, config, state.scales);
|
|
drawAxes(ctx, state.plot);
|
|
drawSeries(ctx, state.plot, config, state.scales);
|
|
drawLabels(ctx, state.plot, config, state.scales);
|
|
drawLegend(ctx, config, state.cssWidth, state.cssHeight);
|
|
drawHover(ctx, state.plot, config, state.scales, state.hoverIndex);
|
|
updateTooltip(state, config);
|
|
}
|
|
|
|
function resize() {
|
|
var rect = canvas.getBoundingClientRect();
|
|
var width = Math.max(320, Math.floor(rect.width));
|
|
var height = config.height || 360;
|
|
var dpr = window.devicePixelRatio || 1;
|
|
|
|
canvas.width = Math.round(width * dpr);
|
|
canvas.height = Math.round(height * dpr);
|
|
canvas.style.height = height + "px";
|
|
|
|
state.cssWidth = width;
|
|
state.cssHeight = height;
|
|
state.plot = getPlotBounds(width, height);
|
|
state.scales = buildScales(config, state.plot);
|
|
redraw();
|
|
}
|
|
|
|
canvas.addEventListener("mousemove", function (event) {
|
|
var rect = canvas.getBoundingClientRect();
|
|
var x = event.clientX - rect.left;
|
|
var y = event.clientY - rect.top;
|
|
state.mouseX = x;
|
|
state.mouseY = y;
|
|
|
|
if (!state.plot || config.labels.length === 0) {
|
|
return;
|
|
}
|
|
if (x < state.plot.left || x > state.plot.right || y < state.plot.top || y > state.plot.bottom) {
|
|
state.hoverIndex = null;
|
|
redraw();
|
|
return;
|
|
}
|
|
|
|
var ratio = (x - state.plot.left) / (state.plot.right - state.plot.left);
|
|
var idx = Math.round(ratio * (config.labels.length - 1));
|
|
state.hoverIndex = clamp(idx, 0, config.labels.length - 1);
|
|
redraw();
|
|
});
|
|
|
|
canvas.addEventListener("mouseleave", function () {
|
|
state.hoverIndex = null;
|
|
redraw();
|
|
});
|
|
|
|
window.addEventListener("resize", resize);
|
|
if (window.ResizeObserver) {
|
|
var observer = new ResizeObserver(function () {
|
|
resize();
|
|
});
|
|
observer.observe(wrapper);
|
|
}
|
|
|
|
resize();
|
|
}
|
|
|
|
function renderFromScript(options) {
|
|
if (!options || !options.configId) {
|
|
return;
|
|
}
|
|
var configNode = document.getElementById(options.configId);
|
|
if (!configNode) {
|
|
return;
|
|
}
|
|
var payload = configNode.textContent || "";
|
|
if (!payload.trim()) {
|
|
return;
|
|
}
|
|
try {
|
|
var config = JSON.parse(payload);
|
|
renderLineChart({
|
|
canvasId: options.canvasId,
|
|
tooltipId: options.tooltipId,
|
|
config: config,
|
|
});
|
|
} catch (error) {
|
|
// Leave page functional even when chart config is malformed.
|
|
}
|
|
}
|
|
|
|
function renderFromDataset(options) {
|
|
if (!options || !options.canvasId) {
|
|
return;
|
|
}
|
|
var canvas = document.getElementById(options.canvasId);
|
|
if (!canvas) {
|
|
return;
|
|
}
|
|
var payload = canvas.dataset.chartConfig || "";
|
|
if (!payload.trim()) {
|
|
return;
|
|
}
|
|
try {
|
|
var config = JSON.parse(payload);
|
|
renderLineChart({
|
|
canvasId: options.canvasId,
|
|
tooltipId: options.tooltipId,
|
|
config: config,
|
|
});
|
|
} catch (error) {
|
|
// Leave page functional even when chart config is malformed.
|
|
}
|
|
}
|
|
|
|
window.Web3Charts = {
|
|
renderLineChart: renderLineChart,
|
|
renderFromScript: renderFromScript,
|
|
renderFromDataset: renderFromDataset,
|
|
};
|
|
})();
|