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.
547 lines (542 loc) • 22.3 kB
JavaScript
/**
* Multicolor Series v3.1.1 (2025-06-02)
*
* (c) 2012-2025 Black Label
*
* License: MIT
*/
(function (factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory;
} else {
factory(Highcharts);
}
}(function (Highcharts) {
/**
*
* Helpers
*
*/
const { isArray, pick, SeriesRegistry, Series } = Highcharts;
const containsStringNumberNumberSequence = (sequenceValue) => {
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) => containsStringNumberNumberSequence(value);
/**
*
* @private
* @class
* @name Highcharts.seriesTypes.coloredline
*
* @augments Highcharts.seriesTypes.LineSeries
*
*/
class ColoredlineSeries extends SeriesRegistry.seriesTypes.line {
/**
*
* Constructor
*
*/
constructor() {
super();
this.getSegment = (segment, colorType) => {
const series = this, options = series.options, lineWidth = options.lineWidth, dashStyle = options.dashStyle, roundCap = options.linecap !== 'square', attribs = {
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 === null || item === void 0 ? void 0 : item.shadow) {
item.shadow(!!options.shadow);
}
return item;
};
this.segments = [];
this.singlePoints = [];
this.graphPaths = [];
this.areaPaths = [];
this.graphs = [];
}
/**
*
* Functions
*
*/
getPath(graphPaths) {
let segmentPath = [];
if (graphPaths) {
graphPaths.forEach((graphPath) => {
if (isArray(graphPath[0])) {
segmentPath = segmentPath.concat(graphPath[0]);
}
});
}
return segmentPath;
}
getSegmentPath(segment) {
const series = this, segmentPath = [], step = series.options.step;
// Build the segment line
segment.forEach((point, index) => {
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;
}
formatTrackerPath(trackerPath) {
var _a, _b, _c, _d;
const series = this, options = series.options, trackerPathLength = trackerPath.length, singlePoints = series.singlePoints, snap = (_b = (_a = series.chart.options.tooltip) === null || _a === void 0 ? void 0 : _a.snap) !== null && _b !== void 0 ? _b : 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 (((_c = trackerPath[index]) === null || _c === void 0 ? void 0 : _c.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 &&
((_d = trackerPath[index]) === null || _d === void 0 ? void 0 : _d.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;
}
drawTracker() {
var _a, _b, _c;
const series = this, options = series.options, trackByArea = options.trackByArea, trackerPath = this.getPath(series.graphPaths), chart = series.chart, pointer = chart.pointer, renderer = chart.renderer, snap = (_b = (_a = chart.options.tooltip) === null || _a === void 0 ? void 0 : _a.snap) !== null && _b !== void 0 ? _b : 0, tracker = series.tracker, cursor = options.cursor, css = cursor && { cursor }, trackerFill = 'rgba(192,192,192,0.002)';
const onMouseOver = () => {
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',
visibility: series.visible ? 'visible' : 'hidden',
stroke: trackerFill,
fill: trackByArea ? trackerFill : 'none',
'stroke-width': (_c = options.lineWidth) !== null && _c !== void 0 ? _c : 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) => {
if (track) {
track.addClass('highcharts-tracker')
.on('mouseover', onMouseOver)
.on('mouseout', (event) => {
if (pointer) {
pointer.onTrackerMouseOut(event);
}
});
if (css) {
track.css(css);
}
if (typeof document.documentElement.ontouchstart !==
'undefined') {
track.on('touchstart', onMouseOver);
}
}
});
}
}
}
setState(state,
// Unused inherit argument added to keep the same type as in the Series.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_inherit) {
var _a, _b, _c, _d;
const series = this, options = series.options, graphs = series.graphs, stateOptions = options.states;
let lineWidth = (_a = options.lineWidth) !== null && _a !== void 0 ? _a : 0;
state = state || '';
if (series.state !== state) {
series.state = state;
if (stateOptions &&
state &&
(((_b = stateOptions.hover) === null || _b === void 0 ? void 0 : _b.enabled) === false ||
((_c = stateOptions.inactive) === null || _c === void 0 ? void 0 : _c.enabled) === false)) {
return;
}
if (stateOptions && state) {
lineWidth = ((_d = stateOptions[state]) === null || _d === void 0 ? void 0 : _d.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) => {
if (!graph.dashstyle) {
graph.attr({ 'stroke-width': lineWidth });
}
});
}
}
}
getSegments() {
var _a, _b;
const series = this, points = series.points;
let segments = [], 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) => {
var _a;
if (pointIndex > 0 &&
points[pointIndex].segmentColor !==
points[pointIndex - 1].segmentColor) {
segments.push({
points: points.slice(lastColor, pointIndex + 1),
color: (_a = points[pointIndex - 1].segmentColor) !== null && _a !== void 0 ? _a : ''
});
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: (_a = points[pointsLength - 1].segmentColor) !== null && _a !== void 0 ? _a : ''
});
}
}
if ((points.length > 0) && segments.length === 0) {
segments = [
{
color: (_b = points[0].segmentColor) !== null && _b !== void 0 ? _b : '',
points
}
];
}
// Else, split on null points or different colors
}
else {
let previousColor = null;
points.forEach((point, pointIndex) => {
var _a, _b, _c, _d;
const colorChanged = pointIndex > 0 && (point.y === null ||
points[pointIndex - 1].y === null ||
(point.segmentColor !==
points[pointIndex - 1].segmentColor &&
points[pointIndex].segmentColor !==
previousColor)), colorExists = !!(((_a = points[pointIndex - 1]) === null || _a === void 0 ? void 0 : _a.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) => {
if (pointObject.y === null) {
// Remove null points (might be on edges)
formattedPoints.splice(k, 1);
}
});
segments.push({
points: formattedPoints,
color: (_b = (colorExists ?
points[pointIndex - 1].segmentColor :
previousColor)) !== null && _b !== void 0 ? _b : ''
});
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) => {
if (formattedPoint.y === null) {
// Remove null points (might be on edges)
formattedPoints.splice(formattedPointIndex, 1);
}
});
segments.push({
points: formattedPoints,
color: (_c = (colorExists ?
points[pointIndex - 1].segmentColor :
previousColor)) !== null && _c !== void 0 ? _c : ''
});
lastColor = pointIndex;
}
}
// Store previous color
if (point) {
previousColor = (_d = point.segmentColor) !== null && _d !== void 0 ? _d : '';
}
});
}
}
// Register it
series.segments = segments;
}
setSeriesGraphPathsAndSinglePoints() {
const series = this, graphPaths = [];
let singlePoints = [], // Used in drawTracker
segmentPath;
// Divide into segments and build graph and area paths
series.segments.forEach((segment) => {
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;
}
drawGraph() {
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) => {
// 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) => {
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
*
*/
translate() {
super.translate.apply(this, arguments);
if (this.getSegments) {
this.getSegments();
}
}
}
/**
*
* Registry
*
*/
SeriesRegistry.registerSeriesType('coloredline', ColoredlineSeries);
/**
*
* @private
* @class
* @name Highcharts.seriesTypes.coloredarea
*
*/
class ColoredareaSeries extends ColoredlineSeries {
/**
*
* Constructor
*
*/
constructor() {
super();
this.segments = [];
this.singlePoints = [];
}
/**
*
* Functions
*
*/
init(chart, options) {
// Removed to prevent overwriting `threshold: 0` with `null` (#51)
// options.threshold = options.threshold || null;
Series.prototype.init.call(this, chart, options);
}
closeSegment(path, segment, translatedThreshold) {
path.push('L', segment[segment.length - 1].plotX, translatedThreshold, 'L', segment[0].plotX, translatedThreshold);
}
drawGraph() {
var _a;
super.drawGraph.call(this);
const series = this, graphs = series.graphs;
if (graphs) { // Cancel running animations, #459
// do we have animation
(_a = series === null || series === void 0 ? void 0 : series.graphPaths) === null || _a === void 0 ? void 0 : _a.forEach((segment, index) => {
// Update color and path
if (series.graphs[index]) {
series.graphs[index].attr({ fill: segment[1] });
}
});
}
}
getSegmentPath(segment) {
var _a, _b;
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 = (_a = this.yAxis.min) !== null && _a !== void 0 ? _a : 0;
}
const translatedThreshold = this.yAxis.getThreshold((_b = options.threshold) !== null && _b !== void 0 ? _b : 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;
}
setSeriesGraphPathsAndSinglePoints() {
const series = this, graphPaths = [];
let singlePoints = [], // Used in drawTracker
segmentPaths;
// Divide into segments and build graph and area paths
this.areaPaths = [];
series.segments.forEach((segment) => {
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);
}));