UNPKG

@rfprodz/client-d3-charts

Version:

Angular chart components based on D3JS (https://d3js.org).

1,434 lines (1,426 loc) 114 kB
import * as i3 from '@angular/common'; import { DOCUMENT, CommonModule } from '@angular/common'; import * as i0 from '@angular/core'; import { InjectionToken, Component, ChangeDetectionStrategy, Inject, Input, ViewChild, NgModule } from '@angular/core'; import * as d3 from 'd3'; import * as i1 from '@angular/cdk/layout'; import { Breakpoints } from '@angular/cdk/layout'; import { map, switchMap, timer, first } from 'rxjs'; /** * Generates a configuration object based on a defaut configuration and an options object. * @param config the default object with all properties * @param options the input object * @param result the output object */ const generateConfiguration = (config, options, result) => { const defaultConfiguration = config; if (typeof options === 'undefined') { return config; } const keys = Object.keys(defaultConfiguration); for (const key of keys) { const defaultValue = defaultConfiguration[key]; const value = options[key]; const typedKey = key; if (typeof defaultValue === 'string' || typeof defaultValue === 'number' || typeof defaultValue === 'boolean') { result[typedKey] = typeof value !== 'undefined' ? value : defaultValue; } else if (defaultValue instanceof Function) { result[typedKey] = defaultValue; } else if (typeof defaultValue === 'object' && defaultValue !== null) { const nestedDefaultObject = defaultValue; const nestedObject = value; result[typedKey] = generateConfiguration(nestedDefaultObject, nestedObject, {}); } } return result; }; /** * The bar chart default configuration. */ const defaultBarChartConfig = Object.freeze({ chartTitle: '', width: 350, height: 350, margin: { top: 70, right: 50, bottom: 50, left: 50, }, transitionDuration: 400, xAxisPadding: 0.4, xAxisTitle: '', yAxisTitle: '', yAxisTicks: 10, displayAxisLabels: true, labelTextWrapWidth: 60, // the number of pixels after which a label needs to be given a new line color: d3.scaleOrdinal(d3.schemeCategory10), }); /** * Creates a container for the bar chart. * @param container the chart container * @param config the chart configuration * @returns the object with the svg element and the g element */ const createContainer$5 = (container, config) => { const id = container.nativeElement.id ?? 'bar-0'; d3.select(`#${id}`).select('svg').remove(); const svg = d3 .select(`#${id}`) .append('svg') .attr('width', config.width + config.margin.left + config.margin.right) .attr('height', config.height + config.margin.top + config.margin.bottom) .attr('class', id); const g = svg.append('g').attr('transform', `translate(${config.margin.left},${config.margin.top / 2})`); return { svg, g }; }; /** * Wraps the bar chart axis labels text. * @param svgText the svg text elements * @param width the chart axis label width */ const wrapSvgText$2 = (svgText, width) => { svgText.each(function () { const text = d3.select(this); const words = text.text().split(/\s+/).reverse(); if (words.length > 1) { let line = []; let lineNumber = 0; const lineHeight = 1.4; const y = text.attr('y'); const x = text.attr('x'); const dy = parseFloat(text.attr('dy') ?? 0); let tspan = text.text(null).append('tspan').attr('x', x).attr('y', y).attr('dy', `${dy}em`); // axis label let word = words.pop(); while (typeof word !== 'undefined') { line.push(word ?? ''); tspan.text(line.join(' ')); if ((tspan.node()?.getComputedTextLength() ?? 0) > width) { line.pop(); tspan.text(line.join(' ')); line = [word ?? '']; tspan = text .append('tspan') .attr('x', 0) .attr('y', y) .attr('dy', `${lineNumber * lineHeight + dy}em`) .text(word ?? ''); lineNumber += 1; } word = words.pop(); } } }); }; /** * Creates the legend. * @param g the svg g element * @param config the chart configuration */ const createLegend$2 = (g, config) => { if (config.displayAxisLabels && config.xAxisTitle !== '') { g.append('g') .attr('transform', `translate(0, ${config.height + config.margin.bottom})`) .append('text') .style('font-size', '12px') .attr('class', 'legend') .attr('dx', '1.5em') .attr('dy', '1em') .text(`x - ${config.xAxisTitle}`); } if (config.displayAxisLabels && config.yAxisTitle !== '') { g.append('g') .attr('transform', `translate(0, ${config.height + config.margin.bottom})`) .append('text') .style('font-size', '12px') .attr('class', 'legend') .attr('dx', '1.5em') .attr('dy', '2.5em') .text(`y - ${config.yAxisTitle}`); } if (config.chartTitle !== '') { g.append('g') .attr('transform', `translate(0, 0)`) .append('text') .style('font-size', '12px') .attr('class', 'legend') .attr('dx', '1.5em') .attr('dy', '-2em') .text(config.chartTitle); } }; /** * Creates the x axis. * @param g the svg g element * @param x the x axis scale * @param config the chart configuration */ const createAxisX$1 = (g, x, config) => { const xLabels = g.append('g').attr('transform', `translate(0, ${config.height})`).call(d3.axisBottom(x)).append('text'); g.selectAll('text').call(wrapSvgText$2, config.labelTextWrapWidth); if (config.displayAxisLabels) { xLabels.attr('transform', `translate(${config.width}, 0)`).attr('class', 'legend').attr('dx', '1.5em').attr('dy', '0.7em').text('x'); } }; /** * Creates the y axis. * @param g the svg g element * @param y the y axis scale * @param config the chart configuration */ const createAxisY$1 = (g, y, config) => { const yLabels = g .append('g') .call(d3 .axisLeft(y) .tickFormat(function (d) { return `${d}`; }) .ticks(config.yAxisTicks)) .append('text'); if (config.displayAxisLabels) { yLabels.attr('dy', '-1.5em').attr('class', 'legend').text('y'); } }; /** * The mouse over event handler. * @param self an svg rect element * @param d the chart data node * @param g the svg g element * @param x the x axis scale * @param y the y axis scale * @param config the chart configuration */ const onMouseOver$1 = (self, d, g, x, y, config) => { const widthModifier = 5; d3.select(self) .transition() .duration(config.transitionDuration) .attr('width', x.bandwidth() + widthModifier) .attr('y', function () { const modifier = 10; return y(d.value) - modifier; }) .attr('height', function () { const modifier = 10; return config.height - y(d.value) + modifier; }); g.append('text') .attr('class', 'chart-tooltip') .style('font-size', '11px') .attr('x', () => x(d.title) ?? '') .attr('y', function () { const modifier = 15; return y(d.value) - modifier; }) .text(() => d.value); }; /** * The mouse out event handler. * @param self an svg rect element * @param d the chart data node * @param x the x axis scale * @param y the y axis scale * @param config the chart configuration */ const onMouseOut$1 = (self, d, x, y, config) => { d3.select(self).attr('class', 'bar'); d3.select(self) .transition() .duration(config.transitionDuration) .attr('width', x.bandwidth()) .attr('y', () => y(d.value) ?? 0) .attr('height', () => config.height - (y(d.value) ?? 0)); d3.selectAll('.chart-tooltip').remove(); }; /** * Draws the chart bars, and sets the mouse pointer events. * @param g the svg g element * @param x the x axis scale * @param y the y axis scale * @param config the chart configuration * @param data the chart data */ const drawBarsAndSetPointerEvents = (g, x, y, config, data) => { const duration = 400; g.selectAll('.bar') .data(data) .enter() .append('rect') .attr('class', 'bar') .style('fill', (d, i) => config.color(i.toString())) .on('mouseover', function (event, d) { return onMouseOver$1(this, d, g, x, y, config); }) .on('mouseout', function (event, d) { return onMouseOut$1(this, d, x, y, config); }) .attr('x', d => x(d.title) ?? '') .attr('y', d => y(d.value)) .attr('width', x.bandwidth()) .transition() .ease(d3.easeLinear) .duration(duration) .delay(function (d, i) { const multiplier = 50; return i * multiplier; }) .attr('height', d => config.height - y(d.value)); }; /** * Draws the bar chart. * @param container the chart container * @param data the chart data * @param options the chart options * @returns the chart configuration */ const drawBarChart = (container, data, options) => { const config = generateConfiguration(defaultBarChartConfig, options, {}); const { g } = createContainer$5(container, config); const x = d3 .scaleBand([0, config.width]) .padding(config.xAxisPadding) .domain(data.map(d => d.title)); const y = d3.scaleLinear([config.height, 0]).domain([0, d3.max(data, d => d.value) ?? 1]); createAxisX$1(g, x, config); createAxisY$1(g, y, config); createLegend$2(g, config); drawBarsAndSetPointerEvents(g, x, y, config, data); return config; }; /** * The force directed chart default configuration. */ const defaultForceDirectedChartConfig = Object.freeze({ chartTitle: '', width: 600, height: 600, centerCalcMod: 1.6, charge: { strength: -10, theta: 0.6, distanceMax: 2000, }, distance: 75, fontSize: 10, collisionRadius: 30, margin: { top: 20, right: 20, bottom: 20, left: 20, }, linkStrokeColor: 'lightgray', linkStrokeWidth: 1.5, labelTextWrapWidth: 60, color: d3.scaleOrdinal(d3.schemeCategory10), nodeColor: '#f00000', nodeStrokeColor: 'lightgray', nodeStrokeWidth: 1, }); /** * The force durected chart tick handler. * @param link chart links * @param node chart nodes * @param text chart text * @returns rotation angle */ const ticked = (link, node, text) => { if (typeof link !== 'undefined') { link .attr('x1', d => d.source.x ?? 0) .attr('y1', d => d.source.y ?? 0) .attr('x2', d => d.target.x ?? 0) .attr('y2', d => d.target.y ?? 0); } if (typeof node !== 'undefined') { node.attr('cx', d => d.x ?? 0).attr('cy', d => d.y ?? 0); } if (typeof text !== 'undefined') { const dx = 10; const dy = 5; text.attr('x', d => (d.x ?? 0) + dx).attr('y', d => (d.y ?? 0) - dy); } return 'rotate(0)'; }; /** * Creates a container for the force directed chart. * @param container the chart container * @param config the chart configuration * @returns the object with the svg element and the g element */ const createContainer$4 = (container, config) => { const id = container.nativeElement.id ?? 'force-directed-0'; d3.select(`#${id}`).select('svg').remove(); const svg = d3 .select(`#${id}`) .append('svg') .attr('width', config.width + config.margin.left + config.margin.right) .attr('height', config.height + config.margin.top + config.margin.bottom) .attr('class', id); const g = svg .append('g') .attr('transform', `translate(${config.width / 2 + config.margin.left},${config.height / 2 + config.margin.top})`); return { svg, g }; }; /** * Applies the force directed chart data. * @param g the svg g element * @param data the chart data */ const applyChartData = (g, data) => { const imageXY = 10; g.append('defs') .selectAll('pattern') .data(data.entities) .enter() .append('pattern') .attr('id', (val, i) => `img-${val.index}`) .attr('x', 0) .attr('y', 0) .attr('height', val => { const baseValue = 30; return baseValue + val.linksCount * 2; }) .attr('width', val => { const baseValue = 30; return baseValue + val.linksCount * 2; }) .append('image') .attr('x', imageXY) .attr('y', imageXY) .attr('height', val => { const baseValue = 30; return baseValue + val.linksCount * 2; }) .attr('width', val => { const baseValue = 30; return baseValue + val.linksCount * 2; }) .attr('xlink:href', val => val.img); }; /** * Creates the force directed chart links. * @param svg the svg element * @param config the chart configuration * @param data the chart data * @returns the chart links */ const createLinks = (svg, config, data) => { return svg .selectAll('.link') .data(data.links) .enter() .append('line') .attr('class', 'link') .style('stroke', config.linkStrokeColor) .style('stroke-width', config.linkStrokeWidth); }; /** * Creates the force directed chart forces. * @param config the chart configuration * @param data the chart data * @returns the chart forces */ const createForces = (config, data) => { return d3 .forceSimulation(data.nodes) .force('link', d3.forceLink().id(d => d.index ?? 0)) .force('charge', d3.forceManyBody().strength(config.charge.strength).theta(config.charge.theta).distanceMax(config.charge.distanceMax)) .force('center', d3.forceCenter(config.width / config.centerCalcMod, config.height / config.centerCalcMod)) .force('collision', d3.forceCollide().radius(d => config.collisionRadius)) .force('link', d3 .forceLink(data.links) .id(d => d.index ?? 0) .distance(config.distance) .links(data.links)); }; /** * The force directed chart node drag start handler. * @param event a drag event * @param datum the chart data * @param force the chart forces */ const nodeDragStartHandler = (event, datum, force) => { if (!event.active && typeof force !== 'undefined') { const alphaTarget = 0.3; force.alphaTarget(alphaTarget).restart(); } datum.fx = event.x; datum.fy = event.y; }; /** * The force directed chart node drag handler. * @param event a drag event * @param datum the chart data * @param config the chart configuration */ const nodeDragHandler = (event, datum, config) => { datum.fx = event.x > config.margin.left && event.x < config.width + config.margin.right ? event.x : datum.fx; datum.fy = event.y > config.margin.top && event.y < config.width + config.margin.bottom ? event.y : datum.fy; }; /** * The force directed chart node drag end handler. * @param event a drag event * @param datum the chart data * @param force the chart forces */ const nodeDragEndHandler = (event, datum, force) => { if (!event.active && typeof force !== 'undefined') { force.alphaTarget(0); } datum.fx = null; datum.fy = null; }; /** * Creates the force directed chart nodes. * @param svg the svg element * @param data the chart data * @param force the chart forces * @param config the chart configuration * @returns the chart nodes */ const createNodes = (svg, data, force, config) => { const base = 5; return svg .selectAll('.node') .data(data.nodes) .enter() .append('circle') .attr('class', 'node') .attr('r', node => base + ((node.value ?? 1) + (node.linksCount ?? 1))) .style('stroke-width', config.nodeStrokeWidth) .style('stroke', config.nodeStrokeColor) .style('fill', node => (typeof node.img === 'undefined' || node.img === '' ? config.nodeColor : `url(${node.img})`)) .call(d3 .drag() .on('start', function (event, datum) { nodeDragStartHandler(event, datum, force); }) .on('drag', function (event, datum) { nodeDragHandler(event, datum, config); }) .on('end', function (event, datum) { nodeDragEndHandler(event, datum, force); })); }; /** * Creates the force directed chart text labels. * @param svg the svg element * @param data the chart data * @returns the chart text labels */ const createText = (svg, data) => { return svg .append('g') .selectAll('text') .data(data.nodes) .enter() .append('text') .attr('class', 'legend') .text(node => node.name ?? `N/A (id. ${node.index})`); }; /** * Draws the force directed chart. * @param container the chart container * @param data the chart data * @param options the chart options * @returns the chart configuration */ const drawForceDirectedChart = (container, data, options) => { const config = generateConfiguration(defaultForceDirectedChartConfig, options, {}); const { svg, g } = createContainer$4(container, config); applyChartData(g, data); const link = createLinks(svg, config, data); const force = createForces(config, data); const node = createNodes(svg, data, force, config); const text = createText(svg, data); force.on('tick', () => { ticked(link, node, text); }); return config; }; /** * The gauge chart default configuration. */ const defaultGaugeChartConfig = Object.freeze({ chartTitle: '', width: 600, height: 600, margin: { top: 20, right: 20, bottom: 20, left: 20, }, innerRadius: 100, // increase inner radius to reduce thickness of the chart showLabels: true, showTooltips: true, labelRadiusModifier: 50, labelTextWrapWidth: 60, transitionDuration: 1000, color: 'green', defaultColor: 'lightgray', value: 10, padRad: 0.01, labelFontSize: 12, valueFontSize: 18, }); /** * Creates a container for the gauge chart. * @param container the chart container * @param config the chart configuration * @returns the object with the svg element and the g element */ const createContainer$3 = (container, config) => { const id = container.nativeElement.id ?? 'gauge-0'; d3.select(`#${id}`).select('svg').remove(); const svg = d3 .select(`#${id}`) .append('svg') .attr('width', config.width + config.margin.left + config.margin.right) .attr('height', config.height / 2 + config.margin.top + config.margin.bottom) .attr('class', id); const g = svg .append('g') .attr('transform', `translate(${config.width / 2 + config.margin.left},${config.height / 2 + config.margin.top})`); return { svg, g }; }; /** * Percentage to degrees converter. * @param perc percentage */ const percToDeg = (perc) => { const mod = 360; return perc * mod; }; /** * Degrees to percentage converter. * @param deg degrees */ const degToRad = (deg) => { const mod = 180; return (deg * Math.PI) / mod; }; /** * Repcentage to radians converter. * @param perc percentage */ const percToRad = (perc) => degToRad(percToDeg(perc)); /** * Draws the gauge chart sections. * @param config the chart config * @param arc the charts's arc * @param arcs the chart's arc sections * @param data the chart's data */ const drawSections = (config, arc, arcs, data) => { let startAt = 0.75; // Start at 270deg const sectionPercentage = 1 / data.length / 2; arcs .append('path') .attr('fill', d => (d.data.y <= config.value ? config.color : config.defaultColor)) .attr('opacity', (d, i) => { const total = 100; const sections = data.length - 1; return ((i + 1) * sections) / total; }) .attr('d', datum => { const arcStartRad = percToRad(startAt); const arcEndRad = arcStartRad + percToRad(sectionPercentage); startAt += sectionPercentage; const startPadRad = datum.index === 0 ? 0 : config.padRad / 2; const endPadRad = datum.index === data.length ? 0 : config.padRad / 2; return arc.startAngle(arcStartRad + startPadRad).endAngle(arcEndRad - endPadRad)(datum); }); }; /** * Draws the guage chart labels * @param config the chart config * @param arc the charts's arc * @param arcs the chart's arc sections * @param data the chart's data */ const drawLabels = (config, arc, arcs, data) => { let startAt = 0.75; // Start at 270deg const sectionPercentage = 1 / data.length / 2; const textDy = 5; arcs .append('text') .attr('class', 'legend') .attr('text-anchor', 'middle') .attr('dy', textDy) .attr('transform', datum => { const arcStartRad = percToRad(startAt); const arcEndRad = arcStartRad + percToRad(sectionPercentage); startAt += sectionPercentage; const startPadRad = datum.index === 0 ? 0 : config.padRad / 2; const endPadRad = datum.index === data.length ? 0 : config.padRad / 2; const a = arc.startAngle(arcStartRad + startPadRad).endAngle(arcEndRad - endPadRad); return `translate(${a.centroid(datum)})`; }) .style('font-size', `${config.labelFontSize}px`) .text(d => d.data.y); }; /** * Draws the gauge chart value. * @param config the chart config * @param g the gauge chart container * @param radius the chart radius */ const drawValue = (config, g, radius) => { const mod = 20; g.append('text') .attr('class', 'legend') .attr('text-anchor', 'middle') .attr('dx', radius / mod) .attr('dy', -(radius / mod)) .style('font-size', `${config.valueFontSize}px`) .text(() => `${config.value}%`); }; /** * Draws the gauge chart. * @param container the chart container * @param data the chart data * @param options the chart options * @returns the chart configuration */ const drawGaugeChart = (container, data, options) => { const config = generateConfiguration(defaultGaugeChartConfig, options, {}); const { g } = createContainer$3(container, config); const gauge = d3.pie().value(datum => datum.y); const radius = Math.min(config.width, config.height) / 2; const arc = d3.arc().innerRadius(config.innerRadius).outerRadius(radius); const arcs = g .selectAll('arc') .data(gauge(data)) .enter() .append('g') .attr('class', 'arc') .on('mouseover', function (event, d) { this.style.opacity = '0.8'; const displayTooltip = d.data.y <= config.value && config.showTooltips; if (displayTooltip) { const tooltipText = `${d.data.key}: ${d.data.y}`; g.append('text') .attr('class', 'chart-tooltip') .style('opacity', 0) .attr('dx', -config.width / (2 * 2 * 2)) .attr('dy', radius / 2 - config.margin.top - config.margin.bottom) .text(tooltipText) .transition() .duration(config.transitionDuration) .style('opacity', 1); } }) .on('mouseout', function (event, d) { this.style.opacity = 'unset'; d3.selectAll('.chart-tooltip') .transition() .duration(config.transitionDuration / 2) .style('opacity', 0) .remove(); }); drawSections(config, arc, arcs, data); if (config.showLabels) { drawLabels(config, arc, arcs, data); } drawValue(config, g, radius); return config; }; /** The line chart default configuration. */ const defaultLineChartConfig = Object.freeze({ chartTitle: '', width: 350, height: 350, margin: { top: 70, right: 50, bottom: 50, left: 50, }, transitionDuration: 400, dotRadius: 3.5, xAxisTitle: '', yAxisTitle: '', ticks: { x: 5, y: 10, }, displayAxisLabels: true, dateFormat: 'default', labelTextWrapWidth: 20, // the number of pixels after which a label needs to be given a new line color: d3.scaleOrdinal(d3.schemeCategory10), }); /** * Creates a container for the line chart. * @param container the chart container * @param config the chart configuration * @returns the object with the svg element and the g element */ const createContainer$2 = (container, config) => { const id = container.nativeElement.id ?? 'line-0'; d3.select(`#${id}`).select('svg').remove(); const svg = d3 .select(`#${id}`) .append('svg') .attr('width', config.width + config.margin.left + config.margin.right) .attr('height', config.height + config.margin.top + config.margin.bottom) .attr('class', id); const g = svg.append('g').attr('transform', `translate(${config.margin.left},${config.margin.top / 2})`); return { svg, g }; }; /** * Wraps the line chart axis labels text. * @param svgText the svg text elements * @param width the chart axis label width */ const wrapSvgText$1 = (svgText, width) => { svgText.each(function () { const text = d3.select(this); const words = text.text().split(/\s+/).reverse(); if (words.length > 1) { let line = []; let lineNumber = 0; const lineHeight = 1.4; const y = text.attr('y'); const x = text.attr('x'); const dy = parseFloat(text.attr('dy') ?? 0); let tspan = text.text(null).append('tspan').attr('x', x).attr('y', y).attr('dy', `${dy}em`); // axis label let word = words.pop(); while (typeof word !== 'undefined') { line.push(word ?? ''); tspan.text(line.join(' ')); if ((tspan.node()?.getComputedTextLength() ?? 0) > width) { line.pop(); tspan.text(line.join(' ')); line = [word ?? '']; tspan = text .append('tspan') .attr('x', 0) .attr('y', y) .attr('dy', `${lineNumber * lineHeight + dy}em`) .text(word ?? ''); lineNumber += 1; } word = words.pop(); } } }); }; /** * Creates the legend. * @param g the svg g element * @param config the chart configuration */ const createLegend$1 = (g, config) => { if (config.displayAxisLabels && config.xAxisTitle !== '') { g.append('g') .attr('transform', `translate(0, ${config.height + config.margin.bottom})`) .append('text') .style('font-size', '12px') .attr('class', 'legend') .attr('dx', '0.5em') .attr('dy', '0em') .text(`x - ${config.xAxisTitle}`); } if (config.displayAxisLabels && config.yAxisTitle !== '') { g.append('g') .attr('transform', `translate(0, ${config.height + config.margin.bottom})`) .append('text') .style('font-size', '12px') .attr('class', 'legend') .attr('dx', '0.5em') .attr('dy', '1.5em') .text(`y - ${config.yAxisTitle}`); } if (config.chartTitle !== '') { g.append('g') .attr('transform', `translate(0, 0)`) .append('text') .style('font-size', '12px') .attr('class', 'legend') .attr('dx', '1.5em') .attr('dy', '-2em') .text(config.chartTitle); } }; /** * Creates the x axis. * @param g the svg g element * @param x the x axis scale * @param config the chart configuration */ const createAxisX = (g, x, config) => { const xLabels = g .append('g') .attr('transform', `translate(0, ${config.height})`) .call(d3 .axisBottom(x) .ticks(config.ticks.x) .tickFormat(d => { const date = new Date(d.valueOf()); const formattingOffset = 10; const day = date.getDate(); const dd = day < formattingOffset ? `0${day}` : day; const month = date.getMonth() + 1; const mm = month < formattingOffset ? `0${month}` : month; const year = date.getFullYear().toString(); const yy = year.slice(2); const hours = date.getHours(); const hour = hours < formattingOffset ? `0${hours}` : hours; const minutes = date.getMinutes(); const minute = minutes < formattingOffset ? `0${minutes}` : minutes; let formattedDate = `${dd}/${mm}/${yy} ${hour}:${minute}`; switch (config.dateFormat) { case 'dd/mm/yyyy': formattedDate = `${dd}/${mm}/${year}`; break; case 'dd/mm/yy': formattedDate = `${dd}/${mm}/${yy}`; break; case 'mm/yyyy': formattedDate = `${mm}/${year}`; break; case 'yyyy': formattedDate = `${year}`; break; default: break; } return formattedDate; })) .append('text'); g.selectAll('text').call(wrapSvgText$1, config.labelTextWrapWidth); if (config.displayAxisLabels) { xLabels.attr('transform', `translate(${config.width}, 0)`).attr('class', 'legend').attr('dx', '1.5em').attr('dy', '0.7em').text('x'); } }; /** * Creates the y axis. * @param g the svg g element * @param y the y axis scale * @param config the chart configuration */ const createAxisY = (g, y, config) => { const yLabels = g .append('g') .call(d3 .axisLeft(y) .ticks(config.ticks.y) .tickFormat(d => `${d}`)) .append('text'); if (config.displayAxisLabels) { yLabels.attr('class', 'legend').attr('dy', '-1.5em').attr('class', 'legend').text('y'); } }; /** * The mouse over event handler. * @param self an svg circle element * @param d the chart data node * @param g the svg g element * @param config the chart configuration */ const onMouseOver = (self, d, g, config) => { const duration = 400; d3.select(self) .transition() .duration(duration) .attr('r', config.dotRadius * 2); const tooltipShift = 4; const tooltipDy = -10; g.append('text') .attr('class', 'chart-tooltip') .style('font-size', '11px') .attr('dx', () => (config.width - config.margin.left - config.margin.right) / tooltipShift) .attr('dy', () => tooltipDy) .text(() => `${d.value} (${new Date(d.timestamp).toUTCString()})`) .transition() .duration(config.transitionDuration) .style('opacity', 1); }; /** * The mouse out event handler. * @param self an svg circle element * @param config the chart configuration */ const onMouseOut = (self, config) => { const duration = 400; d3.select(self).attr('class', 'dot'); d3.select(self).transition().duration(duration).attr('r', config.dotRadius); d3.selectAll('.chart-tooltip') .transition() .duration(config.transitionDuration / 2) .style('opacity', 0) .remove(); }; /** * Draws the chart lines, dots, and sets the mouse pointer events. * @param g the svg g element * @param x the x axis scale * @param y the y axis scale * @param config the chart configuration * @param data the chart data * @param datasetLabels the set of labels for the chart's dataset */ const drawLinesDotsAndSetPointerEvents = (g, x, y, config, data, datasetLabels) => { const line = d3 .line() .x(d => x(d.timestamp)) .y(d => y(d.value)) .curve(d3.curveMonotoneX); const flatData = data.flat(); for (let c = 0, maxC = data.length; c < maxC; c += 1) { const chunk = data[c]; g.append('path') .attr('id', `line-${c}`) .style('fill', 'none') .style('stroke', config.color(c.toString())) .style('stroke-width', '2px') .attr('d', line(chunk)); const datasetLabelText = datasetLabels[c]; const datasetLabel = g.append('g').attr('transform', `translate(0, ${config.height + config.margin.bottom})`); const markerShiftY = { multiplier: 12, modifier: 4 }; datasetLabel .append('line') .attr('id', `legend-line-${c}`) .style('fill', 'none') .style('stroke', config.color(c.toString())) .style('stroke-width', '2px') .attr('x1', '6.75em') .attr('y1', c * markerShiftY.multiplier - markerShiftY.modifier) .attr('x2', '7.25em') .attr('y2', c * markerShiftY.multiplier - markerShiftY.modifier); datasetLabel .append('text') .style('font-size', '12px') .attr('class', 'legend') .attr('dx', '10em') .attr('dy', `${c}em`) .text(datasetLabelText); } g.selectAll('.dot') .data(flatData) .enter() .append('circle') .attr('class', 'dot') .style('pointer-events', 'all') .style('fill', (d, i) => config.color(i.toString())) .on('mouseover', function (event, d) { return onMouseOver(this, d, g, config); }) .on('mouseout', function () { return onMouseOut(this, config); }) .attr('cx', function (d) { return x(d.timestamp); }) .attr('cy', function (d) { return y(d.value); }) .attr('r', 0) .transition() .ease(d3.easeLinear) .duration(config.transitionDuration) .delay((d, i) => { const multiplier = 50; return i * multiplier; }) .attr('r', config.dotRadius); }; /** * Draws the line chart. * @param container the chart container * @param data the chart data * @param options the chart options * @returns the chart configuration */ const drawLineChart = (container, data, datasetLabels, options) => { const config = generateConfiguration(defaultLineChartConfig, options, {}); const { g } = createContainer$2(container, config); const range = data.reduce((accumulator, arr) => { const timestamps = arr.map(item => item.timestamp); const minItem = Math.min(...timestamps); const maxItem = Math.max(...timestamps); const minTime = Math.min(minItem, accumulator.minTime); const maxTime = Math.max(maxItem, accumulator.maxTime); const values = arr.map(item => item.value); const maxItemValue = Math.max(...values); const maxValue = Math.max(maxItemValue, accumulator.maxValue); return { minTime, maxTime, maxValue }; }, { minTime: Number(Infinity), maxTime: -Infinity, maxValue: -Infinity }); const x = d3.scaleTime([0, config.width]).domain([range.minTime, range.maxTime]); const y = d3.scaleLinear([config.height, 0]).domain([0, range.maxValue ?? 1]); createAxisX(g, x, config); createAxisY(g, y, config); createLegend$1(g, config); drawLinesDotsAndSetPointerEvents(g, x, y, config, data, datasetLabels); return config; }; /** * The pie chart default configuration. */ const defaultPieChartConfig = Object.freeze({ chartTitle: '', width: 600, height: 600, margin: { top: 20, right: 20, bottom: 20, left: 20, }, innerRadius: 0, // increase inner radius to render a donut chart showLabels: true, labelRadiusModifier: 50, labelTextWrapWidth: 60, transitionDuration: 1000, color: d3.scaleOrdinal(d3.schemeCategory10), }); /** * Creates a container for the pie chart. * @param container the chart container * @param config the chart configuration * @returns the object with the svg element and the g element */ const createContainer$1 = (container, config) => { const id = container.nativeElement.id ?? 'pie-0'; d3.select(`#${id}`).select('svg').remove(); const svg = d3 .select(`#${id}`) .append('svg') .attr('width', config.width + config.margin.left + config.margin.right) .attr('height', config.height + config.margin.top + config.margin.bottom) .attr('class', id); const g = svg .append('g') .attr('transform', `translate(${config.width / 2 + config.margin.left},${config.height / 2 + config.margin.top})`); return { svg, g }; }; /** * Draws the pie chart. * @param container the chart container * @param data the chart data * @param options the chart options * @returns the chart configuration */ const drawPieChart = (container, data, options) => { const config = generateConfiguration(defaultPieChartConfig, options, {}); const { g } = createContainer$1(container, config); const pie = d3.pie().value(datum => datum.y); const radius = Math.min(config.width, config.height) / 2; const arc = d3.arc().innerRadius(config.innerRadius).outerRadius(radius); const arcs = g .selectAll('arc') .data(pie(data)) .enter() .append('g') .attr('class', 'arc') .on('mouseover', function (event, d) { this.style.opacity = '0.8'; const tooltipText = `${d.data.key}: ${d.data.y}`; g.append('text') .attr('class', 'chart-tooltip') .style('opacity', 0) .attr('dx', -config.width / (2 * 2 * 2)) .attr('dy', config.height / 2 + config.margin.top) .text(tooltipText) .transition() .duration(config.transitionDuration) .style('opacity', 1); }) .on('mouseout', function (event, d) { this.style.opacity = 'unset'; d3.selectAll('.chart-tooltip') .transition() .duration(config.transitionDuration / 2) .style('opacity', 0) .remove(); }); arcs .append('path') .attr('fill', (d, i) => config.color(i.toString())) .attr('d', arc); if (config.showLabels) { const label = d3 .arc() .innerRadius(radius) .outerRadius(radius + config.labelRadiusModifier); const textDy = 5; arcs .append('text') .attr('class', 'legend') .attr('text-anchor', 'middle') .attr('dy', textDy) .attr('transform', d => `translate(${label.centroid(d)})`) .style('font-size', '12px') .text(d => d.data.y); } return config; }; /** * The radar chart default configuration. */ const defaultRadarChartConfig = Object.freeze({ chartTitle: '', width: 350, height: 350, margin: { top: 50, right: 50, bottom: 50, left: 50, }, levels: 3, // how many levels or inner circles should there be drawn maxValue: 0, // what is the value that the biggest circle will represent lineFactor: 1.1, // how much farther than the radius of the outer circle should the lines be stretched labelFactor: 1.15, // how much farther than the radius of the outer circle should the labels be placed labelTextWrapWidth: 60, // the number of pixels after which a label needs to be given a new line opacityArea: 0.35, // the opacity of the area of the blob dotRadius: 4, // the size of the colored circles of each blog opacityCircles: 0.1, // the opacity of the circles of each blob strokeWidth: 2, // the width of the stroke around each blob roundStrokes: false, // if true the area and stroke will follow a round path (cardinal-closed) transitionDuration: 200, color: d3.scaleOrdinal(d3.schemeCategory10), }); /** * Creates a container for the radar chart. * @param container the chart container * @param config the chart configuration * @returns the object with the svg element and the g element */ const createContainer = (container, config) => { const id = container.nativeElement.id ?? 'radar-0'; d3.select(`#${id}`).select('svg').remove(); const svg = d3 .select(`#${id}`) .append('svg') .attr('width', config.width + config.margin.left + config.margin.right) .attr('height', config.height + config.margin.top + config.margin.bottom) .attr('class', id); const g = svg .append('g') .attr('transform', `translate(${config.width / 2 + config.margin.left},${config.height / 2 + config.margin.top})`); return { svg, g }; }; /** * Draws the radar chart circular grid. * @param axisGrid the chart axis grid * @param radius the chart radius value * @param maxValue the maximum value of the chart axis * @param config the chart configuration */ const drawCircularGrid = (axisGrid, radius, maxValue, config) => { // background circles axisGrid .selectAll('.levels') .data(d3.range(1, config.levels + 1).reverse()) .enter() .append('circle') .attr('class', 'grid-circle') .attr('r', (d, i) => (radius / config.levels) * d) .style('fill', '#CDCDCD') .style('stroke', '#CDCDCD') .style('fill-opacity', config.opacityCircles) .style('filter', 'url(#glow)'); // text indicating at what % each level is const axisGridX = 4; axisGrid .selectAll('.axis-label') .data(d3.range(1, config.levels + 1).reverse()) .enter() .append('text') .attr('class', 'axis-label') .attr('x', axisGridX) .attr('y', d => (-d * radius) / config.levels) .attr('dy', '0.4em') .style('font-size', '10px') .attr('fill', '#737373') .text((d, i) => (maxValue * d) / config.levels); }; /** * Wraps the chart axis labels text. * @param svgText the svg text elements * @param width the chart axis label width */ const wrapSvgText = (svgText, width) => { svgText.each(function () { const text = d3.select(this); const words = text.text().split(/\s+/).reverse(); if (words.length > 1) { let line = []; let lineNumber = 0; const lineHeight = 1.4; const y = text.attr('y'); const x = text.attr('x'); const dy = parseFloat(text.attr('dy') ?? 0); let tspan = text.text(null).append('tspan').attr('x', x).attr('y', y).attr('dy', `${dy}em`); let word = words.pop(); while (typeof word !== 'undefined') { line.push(word ?? ''); tspan.text(line.join(' ')); if ((tspan.node()?.getComputedTextLength() ?? 0) > width) { line.pop(); tspan.text(line.join(' ')); line = [word ?? '']; lineNumber += 1; tspan = text .append('tspan') .attr('x', x) .attr('y', y) .attr('dy', `${lineNumber * lineHeight + dy}em`) .text(word ?? ''); } word = words.pop(); } } }); }; /** * Creates the legend. * @param g the svg g element * @param config the chart configuration */ const createLegend = (g, config) => { if (config.chartTitle !== '') { g.append('g') .attr('transform', `translate(-${config.width / 2 + config.margin.left / 2}, -${config.height / 2 + config.margin.top / 2})`) .append('text') .style('font-size', '12px') .attr('class', 'legend') .text(config.chartTitle); } }; /** * Draws the radar chart axis. * @param axisGrid the chart axis grid * @param axisNames the chart axis names * @param radiusScale the chart radius scale * @param maxValue the maximum value of the chart axis * @param angleSlice the chart angle slice value * @param config the chart configuration */ const drawAxis = (axisGrid, axisNames, radiusScale, maxValue, angleSlice, config) => { // create the straight lines radiating outward from the center const axis = axisGrid.selectAll('.axis').data(axisNames).enter().append('g').attr('class', 'axis'); // append the lines axis .append('line') .attr('x1', 0) .attr('y1', 0) .attr('x2', (d, i) => radiusScale(maxValue * config.lineFactor) * Math.cos(angleSlice * i - Math.PI / 2)) .attr('y2', (d, i) => radiusScale(maxValue * config.lineFactor) * Math.sin(angleSlice * i - Math.PI / 2)) .attr('class', 'line') .style('stroke', 'white') .style('stroke-width', '2px'); // append the labels at each axis axis .append('text') .attr('class', 'legend') .style('font-size', '11px') .attr('text-anchor', 'middle') .attr('dy', '0.35em') .attr('x', (d, i) => radiusScale(maxValue * config.labelFactor) * Math.cos(angleSlice * i - Math.PI / 2)) .attr('y', (d, i) => radiusScale(maxValue * config.labelFactor) * Math.sin(angleSlice * i - Math.PI / 2)) .text(d => d) .call(wrapSvgText, config.labelTextWrapWidth); }; /** * Draws the radar chart blobs. * @param radiusScale the chart radius scale * @param angleSlice the chart angle slice value * @param g the svg g element * @param data the chart data * @param config the chart configuration */ const drawRadarChartBlobs = (radiusScale, angleSlice, g, data, config) => { // the radial line function const radarLine = d3 .lineRadial() .radius(d => radiusScale(d.value)) .angle((d, i) => i * angleSlice); // create a wrapper for the blobs const blobWrapper = g.selectAll('.radar-wrapper').data(data).enter().append('g').attr('class', 'radar-wrapper'); // append the backgrounds blobWrapper .append('path') .attr('class', 'radar-area') .attr('d', (d, i) => radarLine(d)) .style('fill', (d, i) => config.color(i.toString())) .style('fill-opacity', config.opacityArea) .on('mouseover', function (d, i) { // dim all blobs const radarAreaFillOpacity = 0.1; d3.selectAll('.radar-area').transition().duration(config.transitionDuration).style('fill-opacity', radarAreaFillOpacity); // bring back the hovered over blob const fillOpacity = 0.7; d3.select(this).transition().duration(config.transitionDuration).style('fill-opacity', fillOpacity); }) .on('mouseout', () => { // bring back all blobs d3.selectAll('.radar-area').transition().duration(config.transitionDuration).style('fill-opacity', config.opacityArea); }); // create the outlines blobWrapper .append('path') .attr('class', 'radar-stroke') .attr('d', (d, i) => radarLine(d)) .style('stroke-width', `${config.strokeWidth}px`) .style('stroke', (d, i) => config.color(i.toString())) .style('fill', 'none') .style('filter', 'url(#glow)'); // append the circles const blobWrapperFillOpacity = 0.8; blobWrapper .selectAll('.radar-circle') .data((d, i) => d) .enter() .append('circle') .attr('class', 'radar-circle') .attr('r', config.dotRadius) .attr('cx', (d, i) => radiusScale(d.value) * Math.cos(angleSlice * i - Math.PI / 2)) .attr('cy', (d, i) => radiusScale(d.value) * Math.sin(angleSlice * i - Math.PI / 2)) .style('fill', (d, i, j) => config.color(j.toString())) .style('fill-opacity', blobWrapperFillOpacity); }; /** * Appends the invisible tooltip circles. * @param g the svg g element * @param data the chart data * @param radiusScale the chart radius scale * @param angleSlice the chart angle slice value * @param config the chart configuration */ const appendInvisibleTooltipCircles = (g, data, radiusScale, angleSlice, config) => { // wrapper for the invisible circles on top const blobCircleWrapper = g.selectAll('.radar-circle-wrapper').data(data).enter().append('g').attr('class', 'radar-circle-wrapper'); // append a set of invisible circles on top for the mouseover pop-up const blobCircleWrapperRadiusMultiplier = 1.5; blobCircleWrapper .selectAll('.radar-invisible-circle') .data((d, i) => d) .enter() .append('circle') .attr('class', 'radar-invisible-circle') .attr('r', config.dotRadius * blobCircleWrapperRadiusMultiplier) .attr('cx', (d, i) => radiusScale(d.value) * Math.cos(angleSlice * i - Math.PI / 2)) .attr('cy', (d, i) => radiusScale(d.value) * Math.sin(angleSlice * i - Math.PI / 2)) .style('fill', 'none') .style('pointer-events', 'all') .on('mouseover', function (event, i) { const modifier = 10; const newX = parseFloat(d3.select(this).attr('cx')) - modifier; const newY = parseFloat(d3.select(this).attr('cy')) - modifier; const nodeData = event.target['__data__']; const tooltipText = `${nodeData.value} ${nodeData.unit}`; g.append('text') .attr('class', 'chart-tooltip') .style('opacity', 0) .attr('x', newX) .attr('y', newY) .text(tooltipText) .transition() .duration(config.transitionDuration)