@rfprodz/client-d3-charts
Version:
Angular chart components based on D3JS (https://d3js.org).
1,434 lines (1,426 loc) • 114 kB
JavaScript
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)