highcharts-multicolor-series
Version:
Highcharts plugin that adds new series: multicolor-series, where you can define color for each part of a path, between two points.
867 lines (718 loc) • 26.7 kB
text/typescript
import SeriesRegistry from 'highcharts-github/ts/Core/Series/SeriesRegistry';
import Utilities from 'highcharts-github/ts/Core/Utilities';
import Series from 'highcharts-github/ts/Core/Series/Series';
import type LinePoint from 'highcharts-github/ts/Series/Line/LinePoint';
import type AreaPoint from 'highcharts-github/ts/Series/Area/AreaPoint';
import type PointerEvent from 'highcharts-github/ts/Core/PointerEvent';
import type SVGElement from 'highcharts-github/ts/Core/Renderer/SVG/SVGElement';
import type SVGAttributes from
'highcharts-github/ts/Core/Renderer/SVG/SVGAttributes';
import type ColorType from 'highcharts-github/ts/Core/Color/ColorType';
import type SVGPath from 'highcharts-github/ts/Core/Renderer/SVG/SVGPath';
import { type StatesOptionsKey } from
'highcharts-github/ts/Core/Series/StatesOptions';
import type { SeriesTypeOptions } from
'highcharts-github/ts/Core/Series/SeriesType';
import type Chart from 'highcharts-github/ts/Core/Chart/Chart';
import {
type SeriesColoredPoint,
type SeriesColoredSegment,
type SeriesColoredSegmentPath,
type SeriesColoredGraphPath
} from 'types';
/**
*
* Helpers
*
*/
const { isArray, pick } = Utilities;
const containsStringNumberNumberSequence = (
sequenceValue: SeriesColoredSegmentPath[]
): boolean => {
let isSequenceFound = false;
for (let index = 0; index < sequenceValue.length; index++) {
if (
typeof sequenceValue[index] === 'string' &&
typeof sequenceValue[index + 1] === 'number' &&
typeof sequenceValue[index + 2] === 'number'
) {
isSequenceFound = true;
} else {
isSequenceFound = false;
break;
}
index += 2;
}
return isSequenceFound;
};
/**
*
* Type guards
*
*/
const isSVGPathSegment = (
value: SeriesColoredSegmentPath[]
): value is SVGPath.Segment[] => containsStringNumberNumberSequence(value);
/**
*
* Coloredline series
*
*/
/**
*
* Declarations
*
*/
type SeriesColoredlinePoint = SeriesColoredPoint<LinePoint>;
type SeriesColoredlineSegment = SeriesColoredSegment<SeriesColoredlinePoint>;
/**
*
* @private
* @class
* @name Highcharts.seriesTypes.coloredline
*
* @augments Highcharts.seriesTypes.LineSeries
*
*/
class ColoredlineSeries extends SeriesRegistry.seriesTypes.line {
/**
*
* Constructor
*
*/
constructor() {
super();
this.segments = [];
this.singlePoints = [];
this.graphPaths = [];
this.areaPaths = [];
this.graphs = [];
}
/**
*
* Properties
*
*/
public pointRange: number | undefined;
public singlePoints: SeriesColoredlinePoint[];
public points!: SeriesColoredlinePoint[];
public segments: SeriesColoredlineSegment[];
// Overrides graphPath property from the Series type.
public graphPaths: SeriesColoredGraphPath[] | undefined;
// Overrides areaPath property from the Series type.
public areaPaths: SeriesColoredSegmentPath[];
// Overrides graph property from Series type.
public graphs: SVGElement[] | [];
/**
*
* Functions
*
*/
public getPath (
graphPaths: SeriesColoredGraphPath[] | undefined
): SeriesColoredSegmentPath[] {
let segmentPath: SeriesColoredSegmentPath[] = [];
if (graphPaths) {
graphPaths.forEach((graphPath): void => {
if (isArray(graphPath[0])) {
segmentPath = segmentPath.concat(graphPath[0]);
}
});
}
return segmentPath;
}
public getSegmentPath (
segment: SeriesColoredlinePoint[]
): SeriesColoredSegmentPath[] {
const series = this,
segmentPath: SeriesColoredSegmentPath[] = [],
step = series.options.step;
// Build the segment line
segment.forEach((point, index: number): void => {
const plotX = Number(point.plotX),
plotY = Number(point.plotY);
let lastPoint;
// Declarations: moveTo or lineTo
segmentPath.push(index ? 'L' : 'M');
// Step line?
if (step && index) {
lastPoint = segment[index - 1];
const lastPointPlotX = Number(lastPoint.plotX);
if (step === 'right') {
segmentPath.push(
lastPoint.plotX,
plotY,
'L'
);
} else if (step === 'center') {
segmentPath.push(
(lastPointPlotX + plotX) / 2,
lastPoint.plotY,
'L',
(lastPointPlotX + plotX) / 2,
plotY,
'L'
);
} else {
segmentPath.push(
plotX,
lastPoint.plotY,
'L'
);
}
}
// Normal line to next point
segmentPath.push(
plotX,
plotY
);
});
return segmentPath;
}
public formatTrackerPath (
trackerPath: SeriesColoredSegmentPath[]
): SeriesColoredSegmentPath[] {
const series = this,
options = series.options,
trackerPathLength = trackerPath.length,
singlePoints = series.singlePoints,
snap = series.chart.options.tooltip?.snap ?? 0;
let singlePoint,
index;
// Extend end points. A better way would be to use round linecaps,
// but those are not clickable in VML.
if (trackerPathLength && !options.trackByArea) {
index = trackerPathLength + 1;
while (index--) {
if (trackerPath[index]?.toString() === 'M') { // Extend left side
const nextTrackerPath = trackerPath[index + 1];
if (typeof nextTrackerPath === 'number') {
trackerPath.splice(
index + 1,
0,
nextTrackerPath - snap,
trackerPath[index + 2],
'L'
);
}
}
if (
(
index &&
trackerPath[index]?.toString() === 'M'
) ||
index === trackerPathLength
) { // Extend right side
const subPreviousTrackerPath = trackerPath[index - 2];
if (typeof subPreviousTrackerPath === 'number') {
trackerPath.splice(
index,
0,
'L',
subPreviousTrackerPath + snap,
trackerPath[index - 1]
);
}
}
}
}
// Handle single points
for (index = 0; index < singlePoints.length; index++) {
singlePoint = singlePoints[index];
const singlePointPlotX = Number(singlePoint.plotX);
trackerPath.push(
'M',
singlePointPlotX - snap,
singlePoint.plotY,
'L',
singlePointPlotX + snap,
singlePoint.plotY
);
}
return trackerPath;
}
public drawTracker(): void {
const series = this,
options = series.options,
trackByArea = options.trackByArea,
trackerPath = this.getPath(series.graphPaths),
chart = series.chart,
pointer = chart.pointer,
renderer = chart.renderer,
snap = chart.options.tooltip?.snap ?? 0,
tracker = series.tracker,
cursor = options.cursor,
css = cursor && { cursor },
trackerFill = 'rgba(192,192,192,0.002)';
const onMouseOver = (): void => {
if (chart.hoverSeries !== series) {
series.onMouseOver();
}
};
const formattedTrackerPath = this.formatTrackerPath(trackerPath);
// Draw the tracker
if (isSVGPathSegment(formattedTrackerPath)) {
if (tracker) {
tracker.attr({ d: formattedTrackerPath });
} else { // Create a tracker
series.tracker = renderer.path(formattedTrackerPath)
.attr({
'stroke-linejoin': 'round', // #1225
visibility: series.visible ? 'visible' : 'hidden',
stroke: trackerFill,
fill: trackByArea ? trackerFill : 'none',
'stroke-width': options.lineWidth ?? 0 +
(trackByArea ? 0 : 2 * snap),
zIndex: 2
})
.add(series.group);
// The tracker is added to the series group, which is clipped, but is covered
// by the marker group. So the marker group also needs to capture events.
[series.tracker, series.markerGroup]
.forEach((track): void => {
if (track) {
track.addClass('highcharts-tracker')
.on('mouseover', onMouseOver)
.on('mouseout', (event: PointerEvent): void => {
if (pointer) {
pointer.onTrackerMouseOut(event);
}
});
if (css) {
track.css(css);
}
if (
typeof document.documentElement.ontouchstart !==
'undefined'
) {
track.on('touchstart', onMouseOver);
}
}
});
}
}
}
public setState (
state?: (StatesOptionsKey | ''),
// Unused inherit argument added to keep the same type as in the Series.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_inherit?: boolean
): void {
const series = this,
options = series.options,
graphs = series.graphs,
stateOptions = options.states;
let lineWidth = options.lineWidth ?? 0;
state = state || '';
if (series.state !== state) {
series.state = state;
if (
stateOptions &&
state &&
(
stateOptions.hover?.enabled === false ||
stateOptions.inactive?.enabled === false
)
) {
return;
}
if (stateOptions && state) {
lineWidth = stateOptions[state]?.lineWidth || lineWidth + 1;
}
if (graphs) { // Hover is turned off for dashed lines in VML
// use attr because animate will cause any other animation on the graph to stop
graphs.forEach((graph: SVGElement): void => {
if (!graph.dashstyle) {
graph.attr({ 'stroke-width': lineWidth });
}
});
}
}
}
public getSegments(): void {
const series = this,
points = series.points;
let segments: SeriesColoredlineSegment[] = [],
lastColor = 0,
pointsLength = points.length;
if (pointsLength) { // No action required for []
// if connect nulls, just remove null points
if (series.options.connectNulls) {
// Iterate backwars for secure point removal
for (let index = pointsLength - 1; index >= 0; --index) {
if (points[index].y === null) {
points.splice(index, 1);
}
}
pointsLength = points.length;
points.forEach((_point, pointIndex): void => {
if (
pointIndex > 0 &&
points[pointIndex].segmentColor !==
points[pointIndex - 1].segmentColor
) {
segments.push({
points: points.slice(lastColor, pointIndex + 1),
color: points[pointIndex - 1].segmentColor ?? ''
});
lastColor = pointIndex;
}
});
if (pointsLength) {
// Add the last segment (only single-point last segement is added)
if (lastColor !== pointsLength - 1) {
segments.push({
points: points.slice(lastColor, pointsLength),
color: points[pointsLength - 1].segmentColor ?? ''
});
}
}
if ((points.length > 0) && segments.length === 0) {
segments = [
{
color: points[0].segmentColor ?? '',
points
}
];
}
// Else, split on null points or different colors
} else {
let previousColor: string | null = null;
points.forEach((point, pointIndex): void => {
const colorChanged = pointIndex > 0 && (
point.y === null ||
points[pointIndex - 1].y === null ||
(
point.segmentColor !==
points[pointIndex - 1].segmentColor &&
points[pointIndex].segmentColor !==
previousColor
)
),
colorExists = !!(points[pointIndex - 1]?.segmentColor &&
points[pointIndex - 1].y !== null);
let formattedPoints =
points.slice(lastColor, pointIndex + 1);
if (colorChanged) {
if (formattedPoints.length > 0) {
// Do not create segments with null ponits
formattedPoints.forEach((pointObject, k): void => {
if (pointObject.y === null) {
// Remove null points (might be on edges)
formattedPoints.splice(k, 1);
}
});
segments.push({
points: formattedPoints,
color: (
colorExists ?
points[pointIndex - 1].segmentColor :
previousColor
) ?? ''
});
lastColor = pointIndex;
}
} else if (pointIndex === pointsLength - 1) {
let next = pointIndex + 1;
if (point.y === null) {
next--;
}
formattedPoints = points.slice(lastColor, next);
if (formattedPoints.length > 0) {
// Do not create segments with null ponits
formattedPoints.forEach((
formattedPoint, formattedPointIndex
): void => {
if (formattedPoint.y === null) {
// Remove null points (might be on edges)
formattedPoints.splice(
formattedPointIndex, 1
);
}
});
segments.push({
points: formattedPoints,
color: (
colorExists ?
points[pointIndex - 1].segmentColor :
previousColor
) ?? ''
});
lastColor = pointIndex;
}
}
// Store previous color
if (point) {
previousColor = point.segmentColor ?? '';
}
});
}
}
// Register it
series.segments = segments;
}
public setSeriesGraphPathsAndSinglePoints(): SeriesColoredGraphPath[] {
const series = this,
graphPaths: SeriesColoredGraphPath[] = [];
let singlePoints: SeriesColoredlinePoint[] = [], // Used in drawTracker
segmentPath;
// Divide into segments and build graph and area paths
series.segments.forEach((segment): void => {
segmentPath = series.getSegmentPath(segment.points);
// Add the segment to the graph, or a single point for tracking
if (segment.points.length > 1) {
graphPaths.push([segmentPath, segment.color]);
} else {
singlePoints = [...singlePoints, ...segment.points];
}
});
// Record it for use in drawGraph and drawTracker, and return graphPaths
series.singlePoints = singlePoints;
series.graphPaths = graphPaths;
return graphPaths;
}
public getSegment = (
segment: SeriesColoredGraphPath,
colorType: ColorType
): SVGElement | undefined => {
const series = this,
options = series.options,
lineWidth = options.lineWidth,
dashStyle = options.dashStyle,
roundCap = options.linecap !== 'square',
attribs: SVGAttributes = {
stroke: colorType,
'stroke-width': lineWidth,
fill: 'none',
zIndex: 1 // #1069
};
let item;
if (dashStyle) {
attribs.dashstyle = dashStyle;
} else if (roundCap) {
attribs['stroke-linecap'] =
attribs['stroke-linejoin'] = 'round';
}
if (segment[1]) {
attribs.stroke = segment[1];
}
if (isSVGPathSegment(segment[0])) {
item = series.chart.renderer.path(segment[0])
.attr(attribs)
.add(series.group);
}
if (item?.shadow) {
item.shadow(!!options.shadow);
}
return item;
};
public drawGraph(): void {
const series = this,
options = series.options,
colorType = options.lineColor || series.color || '',
graphPaths = series.setSeriesGraphPathsAndSinglePoints(),
graphPathLength = graphPaths.length;
let graphSegmentsLength = 0;
// Draw the graphs
let graphs = series.graphs;
if (graphs) { // Cancel running animations, #459
// do we have animation
graphPaths.forEach((segment, segmentIndex): void => {
// Update color and path
if (
series.graphs[segmentIndex] && isSVGPathSegment(segment[0])
) {
series.graphs[segmentIndex].attr({
d: segment[0],
stroke: segment[1]
});
} else {
const formattedSegment =
this.getSegment(segment, colorType);
if (formattedSegment) {
series.graphs[segmentIndex] = formattedSegment;
}
}
});
} else if (graphPaths.length > 0) { // #1487
graphs = [];
graphPaths.forEach((segment, segmentIndex): void => {
const formattedSegment = this.getSegment(segment, colorType);
if (formattedSegment) {
graphs[segmentIndex] = formattedSegment;
}
});
series.graphs = graphs;
}
// Checks if series.graphs exists. #3
graphSegmentsLength = (series.graphs && series.graphs.length) || -1;
for (
let index = graphSegmentsLength;
index >= graphPathLength;
index--
) {
if (series.graphs && series.graphs[index]) {
series.graphs[index].destroy();
series.graphs.splice(index, 1);
}
}
}
/**
*
* Events
*
*/
public translate(): void {
super.translate.apply(this, arguments);
if (this.getSegments) {
this.getSegments();
}
}
}
/**
*
* Registry
*
*/
SeriesRegistry.registerSeriesType('coloredline', ColoredlineSeries);
/**
*
* Coloredarea series
*
*/
/**
*
* Declarations
*
*/
type SeriesColoredareaPoint = SeriesColoredPoint<AreaPoint>;
type SeriesColoredareaSegment = SeriesColoredSegment<SeriesColoredareaPoint>;
/**
*
* @private
* @class
* @name Highcharts.seriesTypes.coloredarea
*
*/
class ColoredareaSeries extends ColoredlineSeries {
/**
*
* Constructor
*
*/
constructor() {
super();
this.segments = [];
this.singlePoints = [];
}
/**
*
* Properties
*
*/
public singlePoints: SeriesColoredareaPoint[];
public points!: SeriesColoredareaPoint[];
public segments: SeriesColoredareaSegment[];
/**
*
* Functions
*
*/
public init(chart: Chart, options: DeepPartial<SeriesTypeOptions>): void {
// Removed to prevent overwriting `threshold: 0` with `null` (#51)
// options.threshold = options.threshold || null;
Series.prototype.init.call(this, chart, options);
}
public closeSegment(
path: SeriesColoredSegmentPath[],
segment: SeriesColoredareaPoint[],
translatedThreshold: number
): void {
path.push(
'L',
segment[segment.length - 1].plotX,
translatedThreshold,
'L',
segment[0].plotX,
translatedThreshold
);
}
public drawGraph(): void {
super.drawGraph.call(this);
const series = this,
graphs = series.graphs;
if (graphs) { // Cancel running animations, #459
// do we have animation
series?.graphPaths?.forEach((segment, index): void => {
// Update color and path
if (series.graphs[index]) {
series.graphs[index].attr({ fill: segment[1] });
}
});
}
}
public getSegmentPath(
segment: SeriesColoredareaPoint[]
): SeriesColoredSegmentPath[] {
const series = this,
areaSegmentPath = [],
options = this.options,
originalThreshold = this.options.threshold;
// Set threshold to yAxis.min if null, with fallback to 0 (#51)
if (options.threshold === null) {
series.options.threshold = this.yAxis.min ?? 0;
}
const translatedThreshold =
this.yAxis.getThreshold(options.threshold ?? 0); // #2181
const segmentPath = super.getSegmentPath.call(this, segment); // Call base method
areaSegmentPath.push(...segmentPath);
const segLength = segmentPath.length;
let yBottom;
if (segLength === 3) { // For animation from 1 to two points
areaSegmentPath.push('L', segmentPath[1], segmentPath[2]);
}
if (options.stacking) {
for (let index = segment.length - 1; index >= 0; index--) {
yBottom = pick(segment[index].yBottom, translatedThreshold);
// Step line?
if (index < segment.length - 1 && options.step) {
areaSegmentPath.push(segment[index + 1].plotX, yBottom);
}
areaSegmentPath.push(segment[index].plotX, yBottom);
}
} else { // Follow zero line back
this.closeSegment(areaSegmentPath, segment, translatedThreshold);
}
// Restore original threshold to prevent side effects (#51)
series.options.threshold = originalThreshold;
return areaSegmentPath;
}
public setSeriesGraphPathsAndSinglePoints(): SeriesColoredGraphPath[] {
const series = this,
graphPaths: SeriesColoredGraphPath[] = [];
let singlePoints: SeriesColoredareaPoint[] = [], // Used in drawTracker
segmentPaths;
// Divide into segments and build graph and area paths
this.areaPaths = [];
series.segments.forEach((segment): void => {
segmentPaths = series.getSegmentPath(segment.points);
// Add the segment to the graph, or a single point for tracking
if (segment.points.length > 1) {
graphPaths.push([segmentPaths, segment.color]);
} else {
singlePoints = [...singlePoints, ...segment.points];
}
});
// Record it for use in drawGraph and drawTracker, and return graphPaths
series.singlePoints = singlePoints;
series.graphPaths = graphPaths;
return graphPaths;
}
}
/**
*
* Registry
*
*/
SeriesRegistry.registerSeriesType('coloredarea', ColoredareaSeries);
export { ColoredlineSeries, ColoredareaSeries };