UNPKG

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
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 };