veritatisquam
Version:
Carbon charting components, lerna monorepo
753 lines (648 loc) • 19.9 kB
text/typescript
// Internal Imports
import * as Configuration from '../configuration';
import { Service } from './service';
import {
AxisPositions,
CartesianOrientations,
ScaleTypes,
ThresholdOptions,
} from '../interfaces';
import { Tools } from '../tools';
// D3 Imports
import { scaleBand, scaleLinear, scaleTime, scaleLog } from 'd3-scale';
import { extent, sum } from 'd3-array';
import { map, values } from 'd3-collection';
// Misc
import {
differenceInYears,
addYears,
subYears,
differenceInMonths,
addMonths,
subMonths,
differenceInDays,
addDays,
subDays,
differenceInHours,
addHours,
subHours,
differenceInMinutes,
addMinutes,
subMinutes,
differenceInSeconds,
subSeconds,
addSeconds,
} from 'date-fns';
export class CartesianScales extends Service {
protected scaleTypes = {
top: null,
right: null,
bottom: null,
left: null,
};
protected scales = {
top: null,
right: null,
bottom: null,
left: null,
};
protected domainAxisPosition: AxisPositions;
protected rangeAxisPosition: AxisPositions;
protected secondaryDomainAxisPosition: AxisPositions;
protected secondaryRangeAxisPosition: AxisPositions;
protected dualAxes: Boolean;
protected orientation: CartesianOrientations;
getDomainAxisPosition({ datum = null } = {}) {
if (this.dualAxes && datum) {
const options = this.model.getOptions();
const { groupMapsTo } = options.data;
const axesOptions = Tools.getProperty(
options,
'axes',
this.secondaryDomainAxisPosition
);
const dataset = datum[groupMapsTo];
if (
axesOptions?.correspondingDatasets &&
axesOptions.correspondingDatasets.includes(dataset)
) {
return this.secondaryDomainAxisPosition;
}
}
return this.domainAxisPosition;
}
getRangeAxisPosition({ datum = null, groups = null } = {}) {
if (this.dualAxes) {
const options = this.model.getOptions();
const { groupMapsTo } = options.data;
const axisOptions = Tools.getProperty(
options,
'axes',
this.secondaryRangeAxisPosition
);
let dataset;
if (datum !== null) {
dataset = datum[groupMapsTo];
} else if (groups && groups.length > 0) {
dataset = groups[0];
}
if (
axisOptions?.correspondingDatasets &&
axisOptions.correspondingDatasets.includes(dataset)
) {
return this.secondaryRangeAxisPosition;
}
}
return this.rangeAxisPosition;
}
getAxisOptions(position: AxisPositions) {
return Tools.getProperty(this.model.getOptions(), 'axes', position);
}
getDomainAxisOptions() {
const domainAxisPosition = this.getDomainAxisPosition();
return this.getAxisOptions(domainAxisPosition);
}
getRangeAxisOptions() {
const rangeAxisPosition = this.getRangeAxisPosition();
return this.getAxisOptions(rangeAxisPosition);
}
update(animate = true) {
this.determineAxisDuality();
this.findDomainAndRangeAxes();
this.determineOrientation();
const axisPositions = Object.keys(AxisPositions).map(
(axisPositionKey) => AxisPositions[axisPositionKey]
);
axisPositions.forEach((axisPosition) => {
this.scales[axisPosition] = this.createScale(axisPosition);
});
}
findDomainAndRangeAxes() {
// find main axes between (left & right) && (bottom & top)
const verticalAxesPositions = this.findVerticalAxesPositions();
const horizontalAxesPositions = this.findHorizontalAxesPositions();
// Now we have horizontal & vertical main axes to choose domain & range axes from
const domainAndRangeAxesPositions = this.findDomainAndRangeAxesPositions(
verticalAxesPositions,
horizontalAxesPositions
);
this.domainAxisPosition =
domainAndRangeAxesPositions.primaryDomainAxisPosition;
this.rangeAxisPosition =
domainAndRangeAxesPositions.primaryRangeAxisPosition;
if (this.isDualAxes()) {
this.secondaryDomainAxisPosition =
domainAndRangeAxesPositions.secondaryDomainAxisPosition;
this.secondaryRangeAxisPosition =
domainAndRangeAxesPositions.secondaryRangeAxisPosition;
}
}
determineOrientation() {
if (
(this.rangeAxisPosition === AxisPositions.LEFT ||
this.rangeAxisPosition === AxisPositions.RIGHT) &&
(this.domainAxisPosition === AxisPositions.BOTTOM ||
this.domainAxisPosition === AxisPositions.TOP)
) {
this.orientation = CartesianOrientations.VERTICAL;
} else {
this.orientation = CartesianOrientations.HORIZONTAL;
}
}
isDualAxes() {
return this.dualAxes;
}
// if any of the axes objects have correspondingDatasets [] asserted we flag the chart as dual axes
// it does not count as dual axes if it just has another axis turned on but is not actually using it to map a dataset
determineAxisDuality() {
const options = this.model.getOptions();
const axesOptions = Tools.getProperty(options, 'axes');
if (
(axesOptions[AxisPositions.LEFT]?.correspondingDatasets &&
axesOptions[AxisPositions.RIGHT]) ||
(axesOptions[AxisPositions.RIGHT]?.correspondingDatasets &&
axesOptions[AxisPositions.LEFT]) ||
(axesOptions[AxisPositions.TOP]?.correspondingDatasets &&
axesOptions[AxisPositions.BOTTOM]) ||
(axesOptions[AxisPositions.BOTTOM]?.correspondingDatasets &&
axesOptions[AxisPositions.TOP])
) {
this.dualAxes = true;
}
}
getOrientation() {
return this.orientation;
}
getScaleByPosition(axisPosition: AxisPositions) {
return this.scales[axisPosition];
}
getScaleTypeByPosition(axisPosition: AxisPositions) {
return this.scaleTypes[axisPosition];
}
getDomainAxisScaleType() {
const domainAxisPosition = this.getDomainAxisPosition();
return this.getScaleTypeByPosition(domainAxisPosition);
}
getRangeAxisScaleType() {
const rangeAxisPosition = this.getRangeAxisPosition();
return this.getScaleTypeByPosition(rangeAxisPosition);
}
getDomainScale() {
return this.scales[this.domainAxisPosition];
}
getRangeScale() {
return this.scales[this.rangeAxisPosition];
}
// Find the main x-axis out of the 2 x-axis on the chart (when 2D axis is used)
getMainXAxisPosition() {
const possibleXAxisPositions = [
AxisPositions.BOTTOM,
AxisPositions.TOP,
];
return [this.domainAxisPosition, this.rangeAxisPosition].find(
(position) => possibleXAxisPositions.indexOf(position) > -1
);
}
// Find the main y-axis out of the 2 y-axis on the chart (when 2D axis is used)
getMainYAxisPosition() {
const possibleYAxisPositions = [
AxisPositions.LEFT,
AxisPositions.RIGHT,
];
return [this.domainAxisPosition, this.rangeAxisPosition].find(
(position) => possibleYAxisPositions.indexOf(position) > -1
);
}
getMainXScale() {
return this.scales[this.getMainXAxisPosition()];
}
getMainYScale() {
return this.scales[this.getMainYAxisPosition()];
}
getValueFromScale(
scale: any,
scaleType: ScaleTypes,
axisPosition: AxisPositions,
datum: any,
index?: number
) {
const options = this.model.getOptions();
const axesOptions = Tools.getProperty(options, 'axes');
const axisOptions = axesOptions[axisPosition];
const { mapsTo } = axisOptions;
const value = datum[mapsTo] !== undefined ? datum[mapsTo] : datum;
let scaledValue;
switch (scaleType) {
case ScaleTypes.LABELS:
scaledValue = scale(value) + scale.step() / 2;
break;
case ScaleTypes.TIME:
scaledValue = scale(new Date(value));
break;
default:
scaledValue = scale(value);
}
return scaledValue;
}
getBoundedScaledValues(datum: any, index?: number) {
const { bounds } = this.model.getOptions();
const scale = this.scales[this.rangeAxisPosition];
const options = this.model.getOptions();
const axesOptions = Tools.getProperty(options, 'axes');
const axisOptions = axesOptions[this.rangeAxisPosition];
const { mapsTo } = axisOptions;
const value = datum[mapsTo] !== undefined ? datum[mapsTo] : datum;
const boundedValues = [
scale(
datum[bounds.upperBoundMapsTo]
? datum[bounds.upperBoundMapsTo]
: value
),
scale(
datum[bounds.lowerBoundMapsTo]
? datum[bounds.lowerBoundMapsTo]
: value
),
];
return boundedValues;
}
getValueThroughAxisPosition(
axisPosition: AxisPositions,
datum: any,
index?: number
) {
const scaleType = this.scaleTypes[axisPosition];
const scale = this.scales[axisPosition];
return this.getValueFromScale(
scale,
scaleType,
axisPosition,
datum,
index
);
}
getDomainValue(d, i) {
const axisPosition = this.getDomainAxisPosition({ datum: d });
return this.getValueThroughAxisPosition(axisPosition, d, i);
}
getRangeValue(d, i) {
const axisPosition = this.getRangeAxisPosition({ datum: d });
return this.getValueThroughAxisPosition(axisPosition, d, i);
}
getMainXScaleType() {
return this.getScaleTypeByPosition(this.getMainXAxisPosition());
}
getMainYScaleType() {
return this.getScaleTypeByPosition(this.getMainYAxisPosition());
}
getDomainIdentifier(datum?: any) {
const options = this.model.getOptions();
return Tools.getProperty(
options,
'axes',
this.getDomainAxisPosition({ datum: datum }),
'mapsTo'
);
}
getRangeIdentifier(datum?: any) {
const options = this.model.getOptions();
return Tools.getProperty(
options,
'axes',
this.getRangeAxisPosition({ datum: datum }),
'mapsTo'
);
}
extendsDomain(axisPosition: AxisPositions, domain: any) {
const options = this.model.getOptions();
const axisOptions = Tools.getProperty(options, 'axes', axisPosition);
if (axisOptions.scaleType === ScaleTypes.TIME) {
const spaceToAddToEdges = Tools.getProperty(
options,
'timeScale',
'addSpaceOnEdges'
);
return addSpacingToTimeDomain(domain, spaceToAddToEdges);
} else {
return addSpacingToContinuousDomain(
domain,
Configuration.axis.paddingRatio
);
}
}
protected findVerticalAxesPositions() {
const options = this.model.getOptions();
const axesOptions = Tools.getProperty(options, 'axes');
const dualAxes = this.isDualAxes();
// If right axis has been specified as `main`
if (
(Tools.getProperty(axesOptions, AxisPositions.LEFT) === null &&
Tools.getProperty(axesOptions, AxisPositions.RIGHT) !== null) ||
Tools.getProperty(axesOptions, AxisPositions.RIGHT, 'main') ===
true ||
(dualAxes &&
Tools.getProperty(
axesOptions,
AxisPositions.LEFT,
'correspondingDatasets'
))
) {
return {
primary: AxisPositions.RIGHT,
secondary: AxisPositions.LEFT,
};
}
return { primary: AxisPositions.LEFT, secondary: AxisPositions.RIGHT };
}
protected findHorizontalAxesPositions() {
const options = this.model.getOptions();
const axesOptions = Tools.getProperty(options, 'axes');
const dualAxes = this.isDualAxes();
// If top axis has been specified as `main`
if (
(Tools.getProperty(axesOptions, AxisPositions.BOTTOM) === null &&
Tools.getProperty(axesOptions, AxisPositions.TOP) !== null) ||
Tools.getProperty(axesOptions, AxisPositions.TOP, 'main') ===
true ||
(dualAxes &&
Tools.getProperty(
axesOptions,
AxisPositions.BOTTOM,
'correspondingDatasets'
))
) {
return {
primary: AxisPositions.TOP,
secondary: AxisPositions.BOTTOM,
};
}
return { primary: AxisPositions.BOTTOM, secondary: AxisPositions.TOP };
}
protected findDomainAndRangeAxesPositions(
verticalAxesPositions,
horizontalAxesPositions
) {
const options = this.model.getOptions();
const mainVerticalAxisOptions = Tools.getProperty(
options,
'axes',
verticalAxesPositions.primary
);
const mainHorizontalAxisOptions = Tools.getProperty(
options,
'axes',
horizontalAxesPositions.primary
);
const mainVerticalScaleType =
mainVerticalAxisOptions.scaleType || ScaleTypes.LINEAR;
const mainHorizontalScaleType =
mainHorizontalAxisOptions.scaleType || ScaleTypes.LINEAR;
const result = {
primaryDomainAxisPosition: null,
secondaryDomainAxisPosition: null,
primaryRangeAxisPosition: null,
secondaryRangeAxisPosition: null,
};
// assign to to be a vertical chart by default
result.primaryDomainAxisPosition = horizontalAxesPositions.primary;
result.primaryRangeAxisPosition = verticalAxesPositions.primary;
// secondary axes
result.secondaryDomainAxisPosition = horizontalAxesPositions.secondary;
result.secondaryRangeAxisPosition = verticalAxesPositions.secondary;
// if neither the horizontal axes are label or time
// and atleast one of the main vertical ones are labels or time then it should be horizontal
if (
(!(
mainHorizontalScaleType === ScaleTypes.LABELS ||
mainHorizontalScaleType === ScaleTypes.TIME
) &&
mainVerticalScaleType === ScaleTypes.LABELS) ||
mainVerticalScaleType === ScaleTypes.TIME
) {
result.primaryDomainAxisPosition = verticalAxesPositions.primary;
result.primaryRangeAxisPosition = horizontalAxesPositions.primary;
// secondary axes
result.secondaryDomainAxisPosition =
verticalAxesPositions.secondary;
result.secondaryRangeAxisPosition =
horizontalAxesPositions.secondary;
}
return result;
}
protected getScaleDomain(axisPosition: AxisPositions) {
const options = this.model.getOptions();
const axisOptions = Tools.getProperty(options, 'axes', axisPosition);
const bounds = Tools.getProperty(options, 'bounds');
const { includeZero } = axisOptions;
const scaleType =
Tools.getProperty(axisOptions, 'scaleType') || ScaleTypes.LINEAR;
if (this.model.isDataEmpty()) {
return [];
}
const displayData = this.model.getDisplayData();
const { mapsTo, percentage } = axisOptions;
// If domain is specified return that domain
if (axisOptions.domain) {
if (scaleType === ScaleTypes.LABELS) {
return axisOptions.domain;
} else if (scaleType === ScaleTypes.TIME) {
axisOptions.domain = axisOptions.domain.map((d) =>
d.getTime === undefined ? new Date(d) : d
);
}
return this.extendsDomain(axisPosition, axisOptions.domain);
}
// Return [0, 100] for percentage axis scale
if (percentage) {
return [0, 100];
}
// If scale is a LABELS scale, return some labels as the domain
if (axisOptions && scaleType === ScaleTypes.LABELS) {
// Get unique values
return map(displayData, (d) => d[mapsTo]).keys();
}
// Get the extent of the domain
let domain;
let allDataValues;
const dataGroupNames = this.model.getDataGroupNames();
if (scaleType === ScaleTypes.TIME) {
allDataValues = displayData.map(
(datum) => +new Date(datum[mapsTo])
);
} else if (bounds && options.axes) {
allDataValues = [];
displayData.forEach((datum) => {
allDataValues.push(datum[mapsTo]);
if (datum[bounds.upperBoundMapsTo]) {
allDataValues.push(datum[bounds.upperBoundMapsTo]);
}
if (datum[bounds.lowerBoundMapsTo]) {
allDataValues.push(datum[bounds.lowerBoundMapsTo]);
}
});
} else if (
axisOptions.stacked === true &&
dataGroupNames &&
axisPosition === this.getRangeAxisPosition()
) {
const { groupMapsTo } = options.data;
const dataValuesGroupedByKeys = this.model.getDataValuesGroupedByKeys(
dataGroupNames
);
const nonStackedGroupsData = displayData.filter(
(datum) => !dataGroupNames.includes(datum[groupMapsTo])
);
const stackedValues = dataValuesGroupedByKeys.map((dataValues) => {
const { sharedStackKey, ...numericalValues } = dataValues;
return sum(values(numericalValues) as number[]);
});
allDataValues = [
...stackedValues,
...nonStackedGroupsData.map((datum) => datum[mapsTo]),
];
} else {
allDataValues = displayData.map((datum) => datum[mapsTo]);
}
if (scaleType !== ScaleTypes.TIME && includeZero) {
allDataValues.push(0);
}
domain = extent(allDataValues);
domain = this.extendsDomain(axisPosition, domain);
return domain;
}
protected createScale(axisPosition: AxisPositions) {
const options = this.model.getOptions();
const axisOptions = Tools.getProperty(options, 'axes', axisPosition);
if (!axisOptions) {
return null;
}
const scaleType =
Tools.getProperty(axisOptions, 'scaleType') || ScaleTypes.LINEAR;
this.scaleTypes[axisPosition] = scaleType;
let scale;
if (scaleType === ScaleTypes.TIME) {
scale = scaleTime();
} else if (scaleType === ScaleTypes.LOG) {
scale = scaleLog().base(axisOptions.base || 10);
} else if (scaleType === ScaleTypes.LABELS) {
scale = scaleBand();
} else {
scale = scaleLinear();
}
scale.domain(this.getScaleDomain(axisPosition));
return scale;
}
protected getHighestDomainThreshold(): null | {
threshold: ThresholdOptions;
scaleValue: number;
} {
const axesOptions = Tools.getProperty(this.model.getOptions(), 'axes');
const domainAxisPosition = this.getDomainAxisPosition();
const { thresholds } = axesOptions[domainAxisPosition];
if (!thresholds) {
return null;
}
const domainScale = this.getDomainScale();
// Find the highest threshold for the domain
const highestThreshold = thresholds.sort(
(a, b) => b.value - a.value
)[0];
const scaleType = this.getScaleTypeByPosition(domainAxisPosition);
if (
scaleType === ScaleTypes.TIME &&
(typeof highestThreshold.value === 'string' ||
highestThreshold.value.getTime === undefined)
) {
highestThreshold.value = new Date(highestThreshold.value);
}
return {
threshold: highestThreshold,
scaleValue: domainScale(highestThreshold.value),
};
}
protected getHighestRangeThreshold(): null | {
threshold: ThresholdOptions;
scaleValue: number;
} {
const axesOptions = Tools.getProperty(this.model.getOptions(), 'axes');
const rangeAxisPosition = this.getRangeAxisPosition();
const { thresholds } = axesOptions[rangeAxisPosition];
if (!thresholds) {
return null;
}
const rangeScale = this.getRangeScale();
// Find the highest threshold for the range
const highestThreshold = thresholds.sort(
(a, b) => b.value - a.value
)[0];
return {
threshold: highestThreshold,
scaleValue: rangeScale(highestThreshold.value),
};
}
}
function addSpacingToTimeDomain(domain: any, spaceToAddToEdges: number) {
const startDate = new Date(domain[0]);
const endDate = new Date(domain[1]);
if (differenceInYears(endDate, startDate) > 1) {
return [
subYears(startDate, spaceToAddToEdges),
addYears(endDate, spaceToAddToEdges),
];
}
if (differenceInMonths(endDate, startDate) > 1) {
return [
subMonths(startDate, spaceToAddToEdges),
addMonths(endDate, spaceToAddToEdges),
];
}
if (differenceInDays(endDate, startDate) > 1) {
return [
subDays(startDate, spaceToAddToEdges),
addDays(endDate, spaceToAddToEdges),
];
}
if (differenceInHours(endDate, startDate) > 1) {
return [
subHours(startDate, spaceToAddToEdges),
addHours(endDate, spaceToAddToEdges),
];
}
if (differenceInMinutes(endDate, startDate) > 30) {
return [
subMinutes(startDate, spaceToAddToEdges * 30),
addMinutes(endDate, spaceToAddToEdges * 30),
];
}
if (differenceInMinutes(endDate, startDate) > 1) {
return [
subMinutes(startDate, spaceToAddToEdges),
addMinutes(endDate, spaceToAddToEdges),
];
}
if (differenceInSeconds(endDate, startDate) > 15) {
return [
subSeconds(startDate, spaceToAddToEdges * 15),
addSeconds(endDate, spaceToAddToEdges * 15),
];
}
if (differenceInSeconds(endDate, startDate) > 1) {
return [
subSeconds(startDate, spaceToAddToEdges),
addSeconds(endDate, spaceToAddToEdges),
];
}
return [startDate, endDate];
}
function addSpacingToContinuousDomain(
[lower, upper]: number[],
paddingRatio: number
) {
const domainLength = upper - lower;
const padding = domainLength * paddingRatio;
// If padding crosses 0, keep 0 as new upper bound
const newUpper = upper <= 0 && upper + padding > 0 ? 0 : upper + padding;
// If padding crosses 0, keep 0 as new lower bound
const newLower = lower >= 0 && lower - padding < 0 ? 0 : lower - padding;
return [newLower, newUpper];
}