Sometimes when mouse-overing container, i got `vendors.app.js:8427 Uncaught TypeError: Cannot read property 'xPos' of undefined` that is caused by `-1` index
591 lines
14 KiB
JavaScript
591 lines
14 KiB
JavaScript
import BaseChart from './BaseChart';
|
|
import { dataPrep, zeroDataPrep, getShortenedLabels } from '../utils/axis-chart-utils';
|
|
import { AXIS_LEGEND_BAR_SIZE } from '../utils/constants';
|
|
import { getComponent } from '../objects/ChartComponents';
|
|
import { getOffset, fire } from '../utils/dom';
|
|
import { calcChartIntervals, getIntervalSize, getValueRange, getZeroIndex, scale, getClosestInArray } from '../utils/intervals';
|
|
import { floatTwo } from '../utils/helpers';
|
|
import { makeOverlay, updateOverlay, legendBar } from '../utils/draw';
|
|
import { getTopOffset, getLeftOffset, MIN_BAR_PERCENT_HEIGHT, BAR_CHART_SPACE_RATIO,
|
|
LINE_CHART_DOT_SIZE } from '../utils/constants';
|
|
|
|
export default class AxisChart extends BaseChart {
|
|
constructor(parent, args) {
|
|
super(parent, args);
|
|
|
|
this.barOptions = args.barOptions || {};
|
|
this.lineOptions = args.lineOptions || {};
|
|
|
|
this.type = args.type || 'line';
|
|
this.init = 1;
|
|
|
|
this.setup();
|
|
}
|
|
|
|
setMeasures() {
|
|
if(this.data.datasets.length <= 1) {
|
|
this.config.showLegend = 0;
|
|
this.measures.paddings.bottom = 30;
|
|
}
|
|
}
|
|
|
|
configure(options) {
|
|
super.configure(options);
|
|
|
|
options.axisOptions = options.axisOptions || {};
|
|
options.tooltipOptions = options.tooltipOptions || {};
|
|
|
|
this.config.xAxisMode = options.axisOptions.xAxisMode || 'span';
|
|
this.config.yAxisMode = options.axisOptions.yAxisMode || 'span';
|
|
this.config.xIsSeries = options.axisOptions.xIsSeries || 0;
|
|
this.config.shortenYAxisNumbers = options.axisOptions.shortenYAxisNumbers || 0;
|
|
|
|
this.config.formatTooltipX = options.tooltipOptions.formatTooltipX;
|
|
this.config.formatTooltipY = options.tooltipOptions.formatTooltipY;
|
|
|
|
this.config.valuesOverPoints = options.valuesOverPoints;
|
|
}
|
|
|
|
prepareData(data=this.data) {
|
|
return dataPrep(data, this.type);
|
|
}
|
|
|
|
prepareFirstData(data=this.data) {
|
|
return zeroDataPrep(data);
|
|
}
|
|
|
|
calc(onlyWidthChange = false) {
|
|
this.calcXPositions();
|
|
if(!onlyWidthChange) {
|
|
this.calcYAxisParameters(this.getAllYValues(), this.type === 'line');
|
|
}
|
|
this.makeDataByIndex();
|
|
}
|
|
|
|
calcXPositions() {
|
|
let s = this.state;
|
|
let labels = this.data.labels;
|
|
s.datasetLength = labels.length;
|
|
|
|
s.unitWidth = this.width/(s.datasetLength);
|
|
// Default, as per bar, and mixed. Only line will be a special case
|
|
s.xOffset = s.unitWidth/2;
|
|
|
|
// // For a pure Line Chart
|
|
// s.unitWidth = this.width/(s.datasetLength - 1);
|
|
// s.xOffset = 0;
|
|
|
|
s.xAxis = {
|
|
labels: labels,
|
|
positions: labels.map((d, i) =>
|
|
floatTwo(s.xOffset + i * s.unitWidth)
|
|
)
|
|
};
|
|
}
|
|
|
|
calcYAxisParameters(dataValues, withMinimum = 'false') {
|
|
const yPts = calcChartIntervals(dataValues, withMinimum);
|
|
const scaleMultiplier = this.height / getValueRange(yPts);
|
|
const intervalHeight = getIntervalSize(yPts) * scaleMultiplier;
|
|
const zeroLine = this.height - (getZeroIndex(yPts) * intervalHeight);
|
|
|
|
this.state.yAxis = {
|
|
labels: yPts,
|
|
positions: yPts.map(d => zeroLine - d * scaleMultiplier),
|
|
scaleMultiplier: scaleMultiplier,
|
|
zeroLine: zeroLine,
|
|
};
|
|
|
|
// Dependent if above changes
|
|
this.calcDatasetPoints();
|
|
this.calcYExtremes();
|
|
this.calcYRegions();
|
|
}
|
|
|
|
calcDatasetPoints() {
|
|
let s = this.state;
|
|
let scaleAll = values => values.map(val => scale(val, s.yAxis));
|
|
|
|
s.datasets = this.data.datasets.map((d, i) => {
|
|
let values = d.values;
|
|
let cumulativeYs = d.cumulativeYs || [];
|
|
return {
|
|
name: d.name,
|
|
index: i,
|
|
chartType: d.chartType,
|
|
|
|
values: values,
|
|
yPositions: scaleAll(values),
|
|
|
|
cumulativeYs: cumulativeYs,
|
|
cumulativeYPos: scaleAll(cumulativeYs),
|
|
};
|
|
});
|
|
}
|
|
|
|
calcYExtremes() {
|
|
let s = this.state;
|
|
if(this.barOptions.stacked) {
|
|
s.yExtremes = s.datasets[s.datasets.length - 1].cumulativeYPos;
|
|
return;
|
|
}
|
|
s.yExtremes = new Array(s.datasetLength).fill(9999);
|
|
s.datasets.map(d => {
|
|
d.yPositions.map((pos, j) => {
|
|
if(pos < s.yExtremes[j]) {
|
|
s.yExtremes[j] = pos;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
calcYRegions() {
|
|
let s = this.state;
|
|
if(this.data.yMarkers) {
|
|
this.state.yMarkers = this.data.yMarkers.map(d => {
|
|
d.position = scale(d.value, s.yAxis);
|
|
if(!d.options) d.options = {};
|
|
// if(!d.label.includes(':')) {
|
|
// d.label += ': ' + d.value;
|
|
// }
|
|
return d;
|
|
});
|
|
}
|
|
if(this.data.yRegions) {
|
|
this.state.yRegions = this.data.yRegions.map(d => {
|
|
d.startPos = scale(d.start, s.yAxis);
|
|
d.endPos = scale(d.end, s.yAxis);
|
|
if(!d.options) d.options = {};
|
|
return d;
|
|
});
|
|
}
|
|
}
|
|
|
|
getAllYValues() {
|
|
let key = 'values';
|
|
|
|
if(this.barOptions.stacked) {
|
|
key = 'cumulativeYs';
|
|
let cumulative = new Array(this.state.datasetLength).fill(0);
|
|
this.data.datasets.map((d, i) => {
|
|
let values = this.data.datasets[i].values;
|
|
d[key] = cumulative = cumulative.map((c, i) => c + values[i]);
|
|
});
|
|
}
|
|
|
|
let allValueLists = this.data.datasets.map(d => d[key]);
|
|
if(this.data.yMarkers) {
|
|
allValueLists.push(this.data.yMarkers.map(d => d.value));
|
|
}
|
|
if(this.data.yRegions) {
|
|
this.data.yRegions.map(d => {
|
|
allValueLists.push([d.end, d.start]);
|
|
});
|
|
}
|
|
|
|
return [].concat(...allValueLists);
|
|
}
|
|
|
|
setupComponents() {
|
|
let componentConfigs = [
|
|
[
|
|
'yAxis',
|
|
{
|
|
mode: this.config.yAxisMode,
|
|
width: this.width,
|
|
shortenNumbers: this.config.shortenYAxisNumbers
|
|
// pos: 'right'
|
|
},
|
|
function() {
|
|
return this.state.yAxis;
|
|
}.bind(this)
|
|
],
|
|
|
|
[
|
|
'xAxis',
|
|
{
|
|
mode: this.config.xAxisMode,
|
|
height: this.height,
|
|
// pos: 'right'
|
|
},
|
|
function() {
|
|
let s = this.state;
|
|
s.xAxis.calcLabels = getShortenedLabels(this.width,
|
|
s.xAxis.labels, this.config.xIsSeries);
|
|
|
|
return s.xAxis;
|
|
}.bind(this)
|
|
],
|
|
|
|
[
|
|
'yRegions',
|
|
{
|
|
width: this.width,
|
|
pos: 'right'
|
|
},
|
|
function() {
|
|
return this.state.yRegions;
|
|
}.bind(this)
|
|
],
|
|
];
|
|
|
|
let barDatasets = this.state.datasets.filter(d => d.chartType === 'bar');
|
|
let lineDatasets = this.state.datasets.filter(d => d.chartType === 'line');
|
|
|
|
let barsConfigs = barDatasets.map(d => {
|
|
let index = d.index;
|
|
return [
|
|
'barGraph' + '-' + d.index,
|
|
{
|
|
index: index,
|
|
color: this.colors[index],
|
|
stacked: this.barOptions.stacked,
|
|
|
|
// same for all datasets
|
|
valuesOverPoints: this.config.valuesOverPoints,
|
|
minHeight: this.height * MIN_BAR_PERCENT_HEIGHT,
|
|
},
|
|
function() {
|
|
let s = this.state;
|
|
let d = s.datasets[index];
|
|
let stacked = this.barOptions.stacked;
|
|
|
|
let spaceRatio = this.barOptions.spaceRatio || BAR_CHART_SPACE_RATIO;
|
|
let barsWidth = s.unitWidth * (1 - spaceRatio);
|
|
let barWidth = barsWidth/(stacked ? 1 : barDatasets.length);
|
|
|
|
let xPositions = s.xAxis.positions.map(x => x - barsWidth/2);
|
|
if(!stacked) {
|
|
xPositions = xPositions.map(p => p + barWidth * index);
|
|
}
|
|
|
|
let labels = new Array(s.datasetLength).fill('');
|
|
if(this.config.valuesOverPoints) {
|
|
if(stacked && d.index === s.datasets.length - 1) {
|
|
labels = d.cumulativeYs;
|
|
} else {
|
|
labels = d.values;
|
|
}
|
|
}
|
|
|
|
let offsets = new Array(s.datasetLength).fill(0);
|
|
if(stacked) {
|
|
offsets = d.yPositions.map((y, j) => y - d.cumulativeYPos[j]);
|
|
}
|
|
|
|
return {
|
|
xPositions: xPositions,
|
|
yPositions: d.yPositions,
|
|
offsets: offsets,
|
|
// values: d.values,
|
|
labels: labels,
|
|
|
|
zeroLine: s.yAxis.zeroLine,
|
|
barsWidth: barsWidth,
|
|
barWidth: barWidth,
|
|
};
|
|
}.bind(this)
|
|
];
|
|
});
|
|
|
|
let lineConfigs = lineDatasets.map(d => {
|
|
let index = d.index;
|
|
return [
|
|
'lineGraph' + '-' + d.index,
|
|
{
|
|
index: index,
|
|
color: this.colors[index],
|
|
svgDefs: this.svgDefs,
|
|
heatline: this.lineOptions.heatline,
|
|
regionFill: this.lineOptions.regionFill,
|
|
spline: this.lineOptions.spline,
|
|
hideDots: this.lineOptions.hideDots,
|
|
hideLine: this.lineOptions.hideLine,
|
|
|
|
// same for all datasets
|
|
valuesOverPoints: this.config.valuesOverPoints,
|
|
},
|
|
function() {
|
|
let s = this.state;
|
|
let d = s.datasets[index];
|
|
let minLine = s.yAxis.positions[0] < s.yAxis.zeroLine
|
|
? s.yAxis.positions[0] : s.yAxis.zeroLine;
|
|
|
|
return {
|
|
xPositions: s.xAxis.positions,
|
|
yPositions: d.yPositions,
|
|
|
|
values: d.values,
|
|
|
|
zeroLine: minLine,
|
|
radius: this.lineOptions.dotSize || LINE_CHART_DOT_SIZE,
|
|
};
|
|
}.bind(this)
|
|
];
|
|
});
|
|
|
|
let markerConfigs = [
|
|
[
|
|
'yMarkers',
|
|
{
|
|
width: this.width,
|
|
pos: 'right'
|
|
},
|
|
function() {
|
|
return this.state.yMarkers;
|
|
}.bind(this)
|
|
]
|
|
];
|
|
|
|
componentConfigs = componentConfigs.concat(barsConfigs, lineConfigs, markerConfigs);
|
|
|
|
let optionals = ['yMarkers', 'yRegions'];
|
|
this.dataUnitComponents = [];
|
|
|
|
this.components = new Map(componentConfigs
|
|
.filter(args => !optionals.includes(args[0]) || this.state[args[0]])
|
|
.map(args => {
|
|
let component = getComponent(...args);
|
|
if(args[0].includes('lineGraph') || args[0].includes('barGraph')) {
|
|
this.dataUnitComponents.push(component);
|
|
}
|
|
return [args[0], component];
|
|
}));
|
|
}
|
|
|
|
makeDataByIndex() {
|
|
this.dataByIndex = {};
|
|
|
|
let s = this.state;
|
|
let formatX = this.config.formatTooltipX;
|
|
let formatY = this.config.formatTooltipY;
|
|
let titles = s.xAxis.labels;
|
|
|
|
titles.map((label, index) => {
|
|
let values = this.state.datasets.map((set, i) => {
|
|
let value = set.values[index];
|
|
return {
|
|
title: set.name,
|
|
value: value,
|
|
yPos: set.yPositions[index],
|
|
color: this.colors[i],
|
|
formatted: formatY ? formatY(value) : value,
|
|
};
|
|
});
|
|
|
|
this.dataByIndex[index] = {
|
|
label: label,
|
|
formattedLabel: formatX ? formatX(label) : label,
|
|
xPos: s.xAxis.positions[index],
|
|
values: values,
|
|
yExtreme: s.yExtremes[index],
|
|
};
|
|
});
|
|
}
|
|
|
|
bindTooltip() {
|
|
// NOTE: could be in tooltip itself, as it is a given functionality for its parent
|
|
this.container.addEventListener('mousemove', (e) => {
|
|
let m = this.measures;
|
|
let o = getOffset(this.container);
|
|
let relX = e.pageX - o.left - getLeftOffset(m);
|
|
let relY = e.pageY - o.top;
|
|
|
|
if(relY < this.height + getTopOffset(m)
|
|
&& relY > getTopOffset(m)) {
|
|
this.mapTooltipXPosition(relX);
|
|
} else {
|
|
this.tip.hideTip();
|
|
}
|
|
});
|
|
}
|
|
|
|
mapTooltipXPosition(relX) {
|
|
let s = this.state;
|
|
if(!s.yExtremes) return;
|
|
|
|
let index = getClosestInArray(relX, s.xAxis.positions, true);
|
|
if (index >= 0) {
|
|
let dbi = this.dataByIndex[index];
|
|
|
|
this.tip.setValues(
|
|
dbi.xPos + this.tip.offset.x,
|
|
dbi.yExtreme + this.tip.offset.y,
|
|
{name: dbi.formattedLabel, value: ''},
|
|
dbi.values,
|
|
index
|
|
);
|
|
|
|
this.tip.showTip();
|
|
}
|
|
}
|
|
|
|
renderLegend() {
|
|
let s = this.data;
|
|
if(s.datasets.length > 1) {
|
|
this.legendArea.textContent = '';
|
|
s.datasets.map((d, i) => {
|
|
let barWidth = AXIS_LEGEND_BAR_SIZE;
|
|
// let rightEndPoint = this.baseWidth - this.measures.margins.left - this.measures.margins.right;
|
|
// let multiplier = s.datasets.length - i;
|
|
let rect = legendBar(
|
|
// rightEndPoint - multiplier * barWidth, // To right align
|
|
barWidth * i,
|
|
'0',
|
|
barWidth,
|
|
this.colors[i],
|
|
d.name,
|
|
this.config.truncateLegends);
|
|
this.legendArea.appendChild(rect);
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Overlay
|
|
makeOverlay() {
|
|
if(this.init) {
|
|
this.init = 0;
|
|
return;
|
|
}
|
|
if(this.overlayGuides) {
|
|
this.overlayGuides.forEach(g => {
|
|
let o = g.overlay;
|
|
o.parentNode.removeChild(o);
|
|
});
|
|
}
|
|
|
|
this.overlayGuides = this.dataUnitComponents.map(c => {
|
|
return {
|
|
type: c.unitType,
|
|
overlay: undefined,
|
|
units: c.units,
|
|
};
|
|
});
|
|
|
|
if(this.state.currentIndex === undefined) {
|
|
this.state.currentIndex = this.state.datasetLength - 1;
|
|
}
|
|
|
|
// Render overlays
|
|
this.overlayGuides.map(d => {
|
|
let currentUnit = d.units[this.state.currentIndex];
|
|
|
|
d.overlay = makeOverlay[d.type](currentUnit);
|
|
this.drawArea.appendChild(d.overlay);
|
|
});
|
|
}
|
|
|
|
updateOverlayGuides() {
|
|
if(this.overlayGuides) {
|
|
this.overlayGuides.forEach(g => {
|
|
let o = g.overlay;
|
|
o.parentNode.removeChild(o);
|
|
});
|
|
}
|
|
}
|
|
|
|
bindOverlay() {
|
|
this.parent.addEventListener('data-select', () => {
|
|
this.updateOverlay();
|
|
});
|
|
}
|
|
|
|
bindUnits() {
|
|
this.dataUnitComponents.map(c => {
|
|
c.units.map(unit => {
|
|
unit.addEventListener('click', () => {
|
|
let index = unit.getAttribute('data-point-index');
|
|
this.setCurrentDataPoint(index);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Note: Doesn't work as tooltip is absolutely positioned
|
|
this.tip.container.addEventListener('click', () => {
|
|
let index = this.tip.container.getAttribute('data-point-index');
|
|
this.setCurrentDataPoint(index);
|
|
});
|
|
}
|
|
|
|
updateOverlay() {
|
|
this.overlayGuides.map(d => {
|
|
let currentUnit = d.units[this.state.currentIndex];
|
|
updateOverlay[d.type](currentUnit, d.overlay);
|
|
});
|
|
}
|
|
|
|
onLeftArrow() {
|
|
this.setCurrentDataPoint(this.state.currentIndex - 1);
|
|
}
|
|
|
|
onRightArrow() {
|
|
this.setCurrentDataPoint(this.state.currentIndex + 1);
|
|
}
|
|
|
|
getDataPoint(index=this.state.currentIndex) {
|
|
let s = this.state;
|
|
let data_point = {
|
|
index: index,
|
|
label: s.xAxis.labels[index],
|
|
values: s.datasets.map(d => d.values[index])
|
|
};
|
|
return data_point;
|
|
}
|
|
|
|
setCurrentDataPoint(index) {
|
|
let s = this.state;
|
|
index = parseInt(index);
|
|
if(index < 0) index = 0;
|
|
if(index >= s.xAxis.labels.length) index = s.xAxis.labels.length - 1;
|
|
if(index === s.currentIndex) return;
|
|
s.currentIndex = index;
|
|
fire(this.parent, "data-select", this.getDataPoint());
|
|
}
|
|
|
|
|
|
|
|
// API
|
|
addDataPoint(label, datasetValues, index=this.state.datasetLength) {
|
|
super.addDataPoint(label, datasetValues, index);
|
|
this.data.labels.splice(index, 0, label);
|
|
this.data.datasets.map((d, i) => {
|
|
d.values.splice(index, 0, datasetValues[i]);
|
|
});
|
|
this.update(this.data);
|
|
}
|
|
|
|
removeDataPoint(index = this.state.datasetLength-1) {
|
|
if (this.data.labels.length <= 1) {
|
|
return;
|
|
}
|
|
super.removeDataPoint(index);
|
|
this.data.labels.splice(index, 1);
|
|
this.data.datasets.map(d => {
|
|
d.values.splice(index, 1);
|
|
});
|
|
this.update(this.data);
|
|
}
|
|
|
|
updateDataset(datasetValues, index=0) {
|
|
this.data.datasets[index].values = datasetValues;
|
|
this.update(this.data);
|
|
}
|
|
// addDataset(dataset, index) {}
|
|
// removeDataset(index = 0) {}
|
|
|
|
updateDatasets(datasets) {
|
|
this.data.datasets.map((d, i) => {
|
|
if(datasets[i]) {
|
|
d.values = datasets[i];
|
|
}
|
|
});
|
|
this.update(this.data);
|
|
}
|
|
|
|
// updateDataPoint(dataPoint, index = 0) {}
|
|
// addDataPoint(dataPoint, index = 0) {}
|
|
// removeDataPoint(index = 0) {}
|
|
}
|