Highchart Legend with Checkbox, events, hover style, symbol order change - javascript

I currently have Highcharts implemented in a Chart component in my application, but I need to make some changes to the Legend, went through most of the documentation, created some functions with Highcharts.wrap().
First, the Legend was simple, each legend item being
[Symbol] [Label] .
But now I need to change it into:
[Checkbox] [Label] [Symbol]
Here is what I got so far:
[Checkbox] [Symbol] [Label]
And with the click on the checkbox replicating the click on the Legend (symbol, label), which shows/hide the series line.
how? with this: (showing only the important parts)
const defaultOptions: Highcharts.Options = {
...,
legend: {
borderColor: "transparent",
verticalAlign: "top",
align: "left",
x: 14,
itemCheckboxStyle: {
cursor: "pointer",
border: "1px solid #62737a",
},
},
...,
plotOptions: {
series: {
...,
showCheckbox: true,
selected: true,
events: {
checkboxClick: function () {
this.setVisible(!this.visible);
},
},
},
...,
},
...,
}
If we only use showCheckbox: true, the checkbox will be far on the right side of each label, not ideal. So this is needed: (If possible I also would like tips on how to avoid the any error on TS in this case, without the comments).
Highcharts.wrap(Highcharts.Legend.prototype, "positionCheckboxes", legendCheckboxPosition);
function legendCheckboxPosition(
// eslint-disable-next-line #typescript-eslint/no-explicit-any
this: any,
// eslint-disable-next-line #typescript-eslint/no-explicit-any
p: any,
scrollOffset: number
) {
const alignAttr = this.group.alignAttr;
const clipHeight = this.clipHeight || this.legendHeight;
let translateY: number;
if (alignAttr) {
translateY = alignAttr.translateY;
Highcharts.each(
this.allItems,
function (item: {
// eslint-disable-next-line #typescript-eslint/no-explicit-any
checkbox: any;
// eslint-disable-next-line #typescript-eslint/no-explicit-any
legendItem: { getBBox: (arg0: boolean) => any };
// eslint-disable-next-line #typescript-eslint/no-explicit-any
checkboxOffset: any;
}) {
const checkbox = item.checkbox;
const bBox = item.legendItem.getBBox(true);
let top;
if (checkbox) {
top = translateY + checkbox.y + (scrollOffset || 0) + 2;
Highcharts.css(checkbox, {
left:
alignAttr.translateX +
item.checkboxOffset +
checkbox.x -
100 -
bBox.width +
17 +
"px",
top: top + "px",
display: top > translateY - 6 && top < translateY + clipHeight - 6 ? "" : "none",
});
}
}
);
}
}
But with this done, I still need to make some changes, which are:
Change the order of Symbol and Label
There is supposed to be a rtl property inside the legends options, which is supposed to change the order of Symbol and Label , but if I do that, it reverses, but it also reverse the order of the legends somehow, I'll show:
-> Without rtl:
-> With rtl: true inside the legends options:
The checkbox distance I understand, because it will need to change my legendCheckboxPosition function, my real problem here is the order of the legends being changed, like if I used legend.reversed: true.. I found out that I can use the reversed property to fix this, but I was wondering if this was a bug with something else..because in the documentation the rtl property only changes the order of Symbol and Label, not the legends order.
This is what I need:
I need to put a style in the :hover of the checkbox, I tried using the legend.itemCheckboxStyle but that doesn't allow me to add hover effects... (I need to place a box-shadow when hovering the checkbox)
ONE ISSUE SOLVED: Another issue is when clicking the legend item (which is separated of the checkbox)
When clicking the legend item, it shows/hide the series, but it doesn't change the checkbox selection.
I know that the checkbox selection is determined by the series.selected property, and that I have the legendItemClick event inside the plotOptions.series.events, but inside that I don't have a this.setSelected function, only this.setVisible function. I tried using that, but it seems to freeze the chart, not doing anything.
How to change the checkbox selection when clicking only in the legend item?
Edit: Managed to solve this by adding this event to options.plotOptions.series.events :
legendItemClick: function () {
const seriesIndex = this.index;
this.chart.series[seriesIndex].select();
},
Well.. that is my problem, with the hope that you guys can help me solve it.

A possible way to arranging the elements of legend would be to edit the legend in legend.labelFormatter and add a Unicode line character. To have the checkbox on the right also you can style it in legend.itemCheckboxStyle.
legend: {
symbolWidth: 0,
useHTML: true,
labelFormatter: function() {
return `<div>${this.name}<span style="color: green;">━</span></div>`;
},
itemDistance: 50,
itemCheckboxStyle: {
"width": "13px",
"height": "13px",
"margin-left": "-130px",
"position": "absolute"
}
},
API References:
https://api.highcharts.com/highcharts/legend.itemCheckboxStyle
Demo: https://jsfiddle.net/BlackLabel/sbcL7rp9/3/

After a lot of research and experimenting, I managed to have it all working.
Options (default options and events, also toggling visibility on load)
const series = GetSeries(opts, null);
...
// Check if Series is supposed to be hidden from load (from the checkbox selected prop), if it is, hide it
series.forEach((serie) => {
if (serie.selected !== undefined && serie.selected === false) serie.visible = false;
});
const defaultOptions: Highcharts.Options = {
...,
legend: {
borderColor: "transparent",
verticalAlign: "top",
align: "left",
x: 14,
},
...,
plotOptions: {
series: {
...
showCheckbox: true,
selected: true,
events: {
checkboxClick: function () {
this.setVisible(!this.visible);
},
legendItemClick: function () {
const seriesIndex = this.index;
this.chart.series[seriesIndex].select();
},
},
},
...
},
...
};
return mergeObjects(defaultOptions, opts);
}
And a lot of resolved extending Highcharts:
// Adjust position of the Highchart Legend Checkbox, and switch label and symbol in the legend
function legendPositionAdjustments(
// eslint-disable-next-line #typescript-eslint/no-explicit-any
this: any,
// eslint-disable-next-line #typescript-eslint/no-explicit-any
p: any,
scrollOffset: number
) {
const pixelsToREM = (value: number) => {
return value / 16;
};
const alignAttr = this.group.alignAttr;
const clipHeight = this.clipHeight || this.legendHeight;
let translateY: number;
const legendMainElement = this.box.parentGroup.element;
// Adjust the main legend element to be closer to the checkbox
if (legendMainElement) {
Highcharts.attr(legendMainElement, "transform", "translate(19, 10)");
}
if (alignAttr) {
translateY = alignAttr.translateY;
this.allItems.forEach(function (item: {
// eslint-disable-next-line #typescript-eslint/no-explicit-any
checkbox: any;
// eslint-disable-next-line #typescript-eslint/no-explicit-any
legendItem: { getBBox: (arg0: boolean) => any };
// eslint-disable-next-line #typescript-eslint/no-explicit-any
checkboxOffset: any;
// eslint-disable-next-line #typescript-eslint/no-explicit-any
legendGroup: any;
}) {
const bBox = item.legendItem.getBBox(true);
// Change position of Label and Symbol in the Highcharts Legend
const legendItemElement = item.legendGroup.element;
const legendItemPath = legendItemElement.querySelector("path.highcharts-graph");
const legendItemPoint = legendItemElement.querySelector("path.highcharts-point");
const legendItemText = legendItemElement.querySelector("text");
if (legendItemPath) {
Highcharts.attr(legendItemPath, "transform", `translate(${bBox.width + 3}, 0)`);
}
if (legendItemPoint) {
Highcharts.attr(legendItemPoint, "transform", `translate(${bBox.width + 3}, 0)`);
}
if (legendItemText) {
Highcharts.attr(legendItemText, "x", 0);
}
// Adjust the position of the checkbox to the left side of the Highcharts Legend
const checkbox = item.checkbox;
let top;
let left;
if (checkbox) {
top = translateY + checkbox.y + (scrollOffset || 0) + 4;
left = alignAttr.translateX + item.checkboxOffset + checkbox.x - 100 - bBox.width + 17;
Highcharts.css(checkbox, {
left: pixelsToREM(left) + "rem",
top: pixelsToREM(top) + "rem",
display: top > translateY - 6 && top < translateY + clipHeight - 6 ? "" : "none",
});
}
});
}
}
// This function is called when triggering show/hide of series, always calling with visibility = true
// eslint-disable-next-line #typescript-eslint/no-explicit-any, #typescript-eslint/no-unused-vars
function colorizeLegendRegardlessOfVisibility(this: any, proceed: any, item: any, visible: any) {
proceed.call(this, item, true);
}
This will:
Avoid changing the color of the legend and symbol when series is hidden or legend is out of focus
Correct position the checkbox to left side of the legend
Switch positions of the symbol and label, which are in the SVG
Hope to help someone who encounters similar problems in the future.

Related

Plotting timestamp on the x-axis based on the start date

My Goal:
I'm trying to make my timeline (with horizontal rectangles) timestamp-dependent and wondering how to move forward. In the future, the user of the application will have an option to select a start date. Let's say if start date is 04/01/2021 12:00 am, then I want the Text Timeline to be divided into 30 days or less based on how it looks on the UI. This functionality will enable me to put a dot if I want to, on the timeline based on the timestamp. For example, the data that I've, as mentioned inside InsightTimeLine.tsx, if I want to put a dot at "Timestamp": "04/06/2021 18:15:00", for Text 4, I might be able to do that on the horizontal line. With the current setup, I'm not able to do this.
Brief Description about Files:
File 1: App.tsx: This is where a Timeline component is called.
File 2: InsightTimeline.tsx:This is where I've my data defined and I'm making the timeline using makeTimelinetrace function
File 3: Plot.tsx :This is where the plotting of timeline is done based on some calculations.
I'm pasting my code below and also a CodeSandBox link for the existing setup:
App.tsx
export default function App() {
return (
<div>
<div className="pin">
<h2 style={{ display: "inline-block" }}>Text Timeline </h2>
<Timeline />
</div>
</div>
);
}
InsightTimeline.tsx:
function InsightTimeline() {
const iCanvasContainer = useRef(null);
const plot = d3.select(iCanvasContainer.current);
const [bins, setBins] = useState(14);
const [timeline, setTimeline] = useState(new Plot(plot, bins));
useEffect(() => {
if (bins) {
setTimeline(new Plot(plot, bins));
}
}, [bins]);
useEffect(() => {
if (iCanvasContainer.current) {
timeline.refreshTimeline();
var i = 0;
var data = [
{
ID: "3",
Object: "Text 1",
Timestamp: "05/12/2020 13:26:00"
},
{
ID: "6",
Object: "Text 2",
Timestamp: "01/07/2020 18:59:00"
},
{
ID: "7",
Object: "Text 3",
Timestamp: "01/07/2020 18:49:00"
},
{
ID: "57",
Object: "Text 4",
Timestamp: "04/06/2021 18:15:00"
}
];
if (data) {
data?.map((datatext: any) => {
i += 1;
timeline.makeTimelineTrace(i, datatext.Object.toUpperCase());
});
}
timeline.doRefresh();
}
}, [timeline]);
return <svg ref={iCanvasContainer} />;
}
export default InsightTimeline;
Plot.tsx
class Plot {
logname: string = "Plotter";
plot: any;
legendWidth: number = 50;
timelineBins: number = 14;
timelineSpace: number;
timelineRow: number = 22;
timelineThickness: number = 10;
timelineMarginTop: number = 25;
timelineDelta: any;
layer_base: any;
layer_text: any;
constructor(public inPlot: any, public inBins?: number) {
if (inBins) this.timelineBins = inBins;
this.timelineSpace = (1000 - this.legendWidth) / (this.timelineBins + 1);
try {
console.log(`${this.logname}: D3 Init: Creating Plot Container.`);
this.plot = inPlot;
this.plot.attr("class", "plot");
this.layer_base = this.plot.append("g").attr("class", "base_layer");
this.layer_text = this.plot.append("g").attr("class", "base_layer");
console.log(`${this.logname}: D3 Init Done.`);
} catch (error) {
console.log(`${this.logname}: ERROR - Could not create plot. (${error})`);
if (!this.plot) console.log(`${this.logname}: Reference Not Defined.`);
if (!this.timelineRow)
console.log(`${this.logname}: Timeline Row Not Defined.`);
}
}
getLogName() {
return this.logname;
}
doRefresh() {
console.log(`${this.logname}: ** REFRESH`);
this.plot.exit().remove();
}
destroy() {
this.plot = undefined;
}
makeTimelineTrace(row: number, label: string) {
this.layer_base
.append("rect")
.attr("class", "timeline_trace")
.attr("x", 0)
.attr("y", this.timelineRow * row + this.timelineThickness / 2);
this.layer_text
.append("text")
.attr("class", "timeline_text")
.attr("x", 15)
.attr("y", this.timelineRow * row + (this.timelineThickness - 5) / 2)
.classed("label", true)
.text(label);
}
refreshTimeline() {
// this.plot.selectAll("text").remove();
// this.plot.selectAll("rect").remove();
}
}
export default Plot;
The code sandbox browser should show the following on your end:
P.S. If for some reason you don't see anything below the Text Timeline text after opening the codesandbox, you can go to any file, for example, Plot,tsx hit space and once it gets saved it will show up in the browser. Not sure why it is happening in sandbox though but this is the hack I found.

In a nuxt application, console.logging beyond a certain level of nesting returns undefined. How to reach deeply nested elements?

In a nuxtjs application I am trying to access a div in a legend provided by a map from ArcGIS. This div is nested quite deeply in my map element. I gave the map element a ref map in order to reach it.
<template>
<div id="viewDiv" ref="map" class="h-full"></div>
</template>
This console log
console.log(
'inTheMap?',
this.$refs.map.children[0].children[2].children[0].children[1]
.children[0]
)
will return a div but the div that I want to reach is nested deeper. Unfortunately if I console.log just one level deeper (if I add .children[0]), the console.log returns undefined. My question: is there a limit to how deep we can console.log nested elements ? If yes, is there another way to reach deeply nested elements ?
This is (part of) the code for the map:
<template>
<div id="viewDiv" ref="map" class="h-full"></div>
</template>
<script>
import Map from '#arcgis/core/Map'
import MapView from '#arcgis/core/views/MapView'
import esriConfig from '#arcgis/core/config'
import FeatureLayer from '#arcgis/core/layers/FeatureLayer'
import Legend from '#arcgis/core/widgets/Legend'
import Search from '#arcgis/core/widgets/Search'
export default {
props: {
selectedTab: {
type: Number,
default: 3,
},
},
data() {
return {
project:
'https://...',
countries:
'https://...',
projectLyr: undefined,
countryLyr: undefined,
searchWidget: undefined,
legend: undefined,
map: undefined,
view: undefined,
fieldName: 'composite_risk',
renderer: {},
filter: "dev_type = 'road and railway'",
impactId: 0,
hasKmField: true,
buttonDescription: 'Total Environmental Risk',
legendSymbol: [],
}
},
mounted() {
esriConfig.apiKey =
'myToken'
this.map = new Map({ basemap: 'osm-light-gray' })
this.view = new MapView({
map: this.map,
center: [15, 50],
zoom: 3,
container: 'viewDiv',
})
this.projectLyr = new FeatureLayer({
url: this.project,
outFields: ['*'],
title: 'Legend',
})
this.countryLyr = new FeatureLayer({
url: this.countries,
outFields: ['*'],
title: 'Legend',
})
this.view.popup = {
dockOptions: {
position: 'bottom-left',
},
}
this.legend = new Legend({
view: this.view
})
this.view.ui.add(this.legend, 'top-right')
this.updateLayer({
value: 'composite_risk',
hasKmField: true,
title: 'Total Environmental Risk',
})
},
methods: {
updateLayer(value) {
if (typeof value === 'number') {
this.filter =
value === 1
? "dev_type = 'road' OR dev_type = 'railway'"
: "dev_type = 'road and railway'"
value = {
value: 'composite_risk',
hasKmField: true,
title: 'Total Environmental Risk',
}
this.view.popup.close()
this.impactId = 0
}
if (!value.isFilter) {
this.fieldName = value.value
this.hasKmField = value.hasKmField
this.buttonDescription = value.title
} else {
this.filter = `${value.value}`
}
this.$nextTick(() => {
if (this.selectedTab === 0) {
this.map.remove(this.projectLyr)
this.map.add(this.countryLyr)
this.filtering(value)
} else {
this.map.remove(this.countryLyr)
this.map.add(this.projectLyr)
this.filtering(value)
}
this.layer.popupTemplate =
this.selectedTab === 0
? this.popupTemplateCountry
: this.popupTemplateProject
})
console.log(
'inTheMap?',
this.$refs.map.children[0].children[2].children[0].children[1]
.children[0]
)
},
}
This is the div I would like to hide:
In order to reach the element you can start on the container node of the legend.
This is a step by step code sample in order to get to the desire element. There is no exist checks in order to simplify.
// first get all service entries of the legend container
const legend_services = legend.container.querySelectorAll(".esri-legend__service");
// this will have the same order as they have in the map
// in this case we want the fist legend service
const desire_legend_service = legend_services[0];
// now you want the legend layer entry in the legend service
const legend_layer = desire_legend_service.querySelector(".esri-legend__layer");
// now in your case you want to hide the first table of the legend layer
const legend_layer_first_table = legend_layer.querySelector(".esri-legend__layer-table");
legend_layer_first_table.classList = "hide";
hide class is just,
.hide {
display: none;
}
Now, one thing in order for this to work is that the legend needs to be display on screen. You need an strategy to secure this before searching for the div element to hide.

Add css class to noUiSlider handle on event

By default on the start event noUiSlider adds an active class to the active handle but removes it when the event has ended. I want a way to show the user that the handle has been dragged already so changing the colour via a css class would be ideal.
I cannot tell which of the handles has been dragged from the data it provides.
Here is my function which initiates noUISlider
setRangeSlider(e) {
const min = 0;
const max = 1000000;
noUiSlider.cssClasses.target += ' c-range-slider';
const options = {
start: [min || 0, max || this.maxAvailablePrice.id],
step: 50000,
tooltips: true,
connect: true,
range: {
min,
max,
},
format: {
to(value) {
return formatCurrency([value]);
},
from(value) {
return stripFormatting(value);
},
},
};
this.slider = noUiSlider.create(e, options);
this.slider.on('start', (values) => {
console.log('SearchFiltersPriceComponent -> setRangeSlider -> this.slider', this.slider);
});
}
When I console out this.slider from the start event it prints out all sorts of useful information but I cannot find which handle has just been dragged and how to target that handle to add a class to it.
this.slider.target will return the slider element and handle parameter in event callback function will return the index of the handle that has been dragged. so these two can be used together to locate a particular handle. see the code for example
setRangeSlider(e) {
const min = 0;
const max = 1000000;
noUiSlider.cssClasses.target += ' c-range-slider';
const options = {
start: [min || 0, max || this.maxAvailablePrice.id],
step: 50000,
tooltips: true,
connect: true,
range: {
min,
max,
},
format: {
to(value) {
return formatCurrency([value]);
},
from(value) {
return stripFormatting(value);
},
},
};
this.slider = noUiSlider.create(e, options);
this.slider.on('start', (values, handle, unencoded, tap, positions, noUiSlider) => {
let sliderElem = this.slider.target;
let handleElem = document.querySelectorAll("div.noUi-handle[data-handle='" + handle + "']")[0];
console.log('SearchFiltersPriceComponent -> setRangeSlider -> this.slider', handleElem);
handleElem.classList.add("mystyle");
});
this.slider.on('end', (values, handle, unencoded, tap, positions, noUiSlider) => {
let sliderElem = this.slider.target;
let handleElem = document.querySelectorAll("div.noUi-handle[data-handle='"+handle+"']")[0];
handleElem.classList.remove("mystyle");
});
}

React highchart: Shared tooltip in same and different chart

I am using react-highchart for a project. And displaying two charts:
1) Line Chart with 2 series data, it will render two lines on same chart.
2) Bar or Column Chart.
Now when I hover over a point it should enable tooltip on both lines in 1st chart and in column chart as well. X-axis is datetime.
It should active point on both lines like this:
In react-highchart, I have used shared: true attribute, but it is not making both lines active.
tooltip: {
enabled: true,
useHTML: true,
shared: true,
backgroundColor: 'rgba(255,255,255,1)',
borderRadius: 3,
shape: 'rectangle'
}
And is there a way to make another chart's tooltip active as well?
EDIT
After a suggestion, I was checking synchronized-charts in highcharts, but the code example was in jQuery, I need it in react-highcharts. Still I tried to convert the code to react and did this:
import ReactHighcharts from 'react-highcharts/ReactHighcharts';
/**
* Override the reset function, we don't need to hide the tooltips and
* crosshairs.
*/
ReactHighcharts.Highcharts.Pointer.prototype.reset = function reset() {
return undefined;
};
ReactHighcharts.Highcharts.Point.prototype.highlight = function highlight(event) {
event = this.series.chart.pointer.normalize(event);
this.onMouseOver(); // Show the hover marker
this.series.chart.tooltip.refresh(this); // Show the tooltip
this.series.chart.xAxis[0].drawCrosshair(event, this); // Show the
crosshair
};
After chart render callback:
['mousemove', 'touchmove', 'touchstart'].forEach(eventType => {
const container = document.getElementById('tab__charts');
container.removeEventListener(eventType, this.handleMouseMove);
container.addEventListener(eventType, this.handleMouseMove);
});
Handle Mouse move and syncExtreme:
handleMouseMove(e) {
for (let i = 0; i < ReactHighcharts.Highcharts.charts.length; i += 1) {
const chart = ReactHighcharts.Highcharts.charts[i];
if (chart) {
// Find coordinates within the chart
const event = chart.pointer.normalize(e);
// Get the hovered point
const point = chart.series[0].searchPoint(event, true);
if (point) {
point.highlight(e);
}
}
}
}
syncExtremes(e) {
const thisChart = this.chart;
if (e.trigger !== 'syncExtremes') { // Prevent feedback loop
ReactHighcharts.Highcharts.each(ReactHighcharts.Highcharts.charts, (chart) => {
if (chart !== thisChart) {
if (chart.xAxis[0].setExtremes) { // It is null while updating
chart.xAxis[0].setExtremes(
e.min,
e.max,
undefined,
false,
{ trigger: 'syncExtremes' },
);
}
}
});
}
}
Now when I hover over the the point it is giving error:
But Somehow It worked for second chart, If I hover over second chart point, it is showing tooltip on both chart. Not working for first chart. Plus first chart has two series. I am getting closer to solution.
EDIT 2: Solution
I figured out that what was causing Tooltips to be synchronised only if hover over second Chart. It was due to that console error, which was breaking the code (For loop inside handleMouseMove()). So after putting that error into try catch, it fixed the problem.
if (point) {
try {
point.highlight(e);
} catch (err) {
// pass;
}
}
Not the best way, but it works. The only problem now is, first chart has two Series line (Check the above image), and only first one getting active circle, not the second one.
EDIT 3: Solution for Highlighting Multiple series.
After reading the code, I found this line, whhich was causing only first series to highlight point:
point = chart.series[0].searchPoint(event, true)
this line is only taking first series. Bad code. It should be:
chart.series.forEach(series => {
const point = series.searchPoint(event, true);
if (point) {
try {
point.highlight(e);
} catch (err) {
// pass;
}
}
});
The only issue is now this try catch, without it getting Can not read property category of undefined.
I recommend you to use highcharts-react-official wrapper. Below you can find an example of synchronized charts:
import React from "react";
import { render } from "react-dom";
// Import Highcharts
import Highcharts from "highcharts/highstock";
//import HighchartsReact from "./HighchartsReact.min.js";
import HighchartsReact from "highcharts-react-official";
(function(H) {
H.Pointer.prototype.reset = function() {
return undefined;
};
/**
* Highlight a point by showing tooltip, setting hover state and draw crosshair
*/
H.Point.prototype.highlight = function(event) {
event = this.series.chart.pointer.normalize(event);
this.onMouseOver(); // Show the hover marker
this.series.chart.tooltip.refresh(this); // Show the tooltip
this.series.chart.xAxis[0].drawCrosshair(event, this); // Show the crosshair
};
H.syncExtremes = function(e) {
var thisChart = this.chart;
if (e.trigger !== "syncExtremes") {
// Prevent feedback loop
Highcharts.each(Highcharts.charts, function(chart) {
if (chart && chart !== thisChart) {
if (chart.xAxis[0].setExtremes) {
// It is null while updating
chart.xAxis[0].setExtremes(e.min, e.max, undefined, false, {
trigger: "syncExtremes"
});
}
}
});
}
};
})(Highcharts);
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
options: {
chart: {
type: "line",
zoomType: "x",
panning: true,
panKey: "shift"
},
xAxis: {
events: {
setExtremes: function(e) {
Highcharts.syncExtremes(e);
}
}
},
series: [
{
data: [
29.9,
71.5,
106.4,
129.2,
144.0,
176.0,
135.6,
148.5,
216.4,
194.1,
95.6,
54.4
]
}
]
},
options2: {
chart: {
zoomType: "x"
},
series: [
{
data: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
}
],
xAxis: {
events: {
setExtremes: function(e) {
Highcharts.syncExtremes(e);
}
}
}
}
};
}
componentDidMount() {
["mousemove", "touchmove", "touchstart"].forEach(function(eventType) {
document
.getElementById("container")
.addEventListener(eventType, function(e) {
var chart, point, i, event;
for (i = 0; i < Highcharts.charts.length; i = i + 1) {
chart = Highcharts.charts[i];
if (chart) {
// Find coordinates within the chart
event = chart.pointer.normalize(e);
// Get the hovered point
point = chart.series[0].searchPoint(event, true);
if (point) {
point.highlight(e);
}
}
}
});
});
}
inputChange(e) {
this.setState({
options: {
series: [{ data: [1, 1, 1] }, { data: [2, 2, 2] }]
}
});
}
render() {
return (
<div id="container">
<HighchartsReact
constructorType={"chart"}
highcharts={Highcharts}
options={this.state.options}
/>
<HighchartsReact
constructorType={"chart"}
highcharts={Highcharts}
options={this.state.options2}
/>
</div>
);
}
}
render(<App />, document.getElementById("root"));
Live demo: https://codesandbox.io/s/jl8mrq2m53

How do I selecting a date range (like onClick but drag/select)

I'd like to rewrite vizwit using Chart.js, and I'm having a hard time figuring out how to get the date/time chart interaction to work. If you try selecting a date range on this demo, you'll see that it filters the other charts. How do I get Chart.js to let me select a range like that on its time scale chart? It seems like by default it only lets me click on a specific date point.
Thanks for your time.
Building on #jordanwillis's and your answers, you can easily achieve anything you want, by placing another canvas on top on your chart.
Just add pointer-events:none to it's style to make sure it doesn't intefere with the chart's events.
No need to use the annotations plugin.
For example (in this example canvas is the original chart canvas and overlay is your new canvas placed on top):
var options = {
type: 'line',
data: {
labels: ["Red", "Blue", "Yellow", "Green", "Purple", "Orange"],
datasets: [{
label: '# of Votes',
data: [12, 19, 3, 5, 2, 3],
borderWidth: 1
},
{
label: '# of Points',
data: [7, 11, 5, 8, 3, 7],
borderWidth: 1
}
]
},
options: {
scales: {
yAxes: [{
ticks: {
reverse: false
}
}]
}
}
}
var canvas = document.getElementById('chartJSContainer');
var ctx = canvas.getContext('2d');
var chart = new Chart(ctx, options);
var overlay = document.getElementById('overlay');
var startIndex = 0;
overlay.width = canvas.width;
overlay.height = canvas.height;
var selectionContext = overlay.getContext('2d');
var selectionRect = {
w: 0,
startX: 0,
startY: 0
};
var drag = false;
canvas.addEventListener('pointerdown', evt => {
const points = chart.getElementsAtEventForMode(evt, 'index', {
intersect: false
});
startIndex = points[0]._index;
const rect = canvas.getBoundingClientRect();
selectionRect.startX = evt.clientX - rect.left;
selectionRect.startY = chart.chartArea.top;
drag = true;
// save points[0]._index for filtering
});
canvas.addEventListener('pointermove', evt => {
const rect = canvas.getBoundingClientRect();
if (drag) {
const rect = canvas.getBoundingClientRect();
selectionRect.w = (evt.clientX - rect.left) - selectionRect.startX;
selectionContext.globalAlpha = 0.5;
selectionContext.clearRect(0, 0, canvas.width, canvas.height);
selectionContext.fillRect(selectionRect.startX,
selectionRect.startY,
selectionRect.w,
chart.chartArea.bottom - chart.chartArea.top);
} else {
selectionContext.clearRect(0, 0, canvas.width, canvas.height);
var x = evt.clientX - rect.left;
if (x > chart.chartArea.left) {
selectionContext.fillRect(x,
chart.chartArea.top,
1,
chart.chartArea.bottom - chart.chartArea.top);
}
}
});
canvas.addEventListener('pointerup', evt => {
const points = chart.getElementsAtEventForMode(evt, 'index', {
intersect: false
});
drag = false;
console.log('implement filter between ' + options.data.labels[startIndex] + ' and ' + options.data.labels[points[0]._index]);
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.0/Chart.js"></script>
<body>
<canvas id="overlay" width="600" height="400" style="position:absolute;pointer-events:none;"></canvas>
<canvas id="chartJSContainer" width="600" height="400"></canvas>
</body>
Notice we're basing our events and coordinates on the original canvas, but we draw on the overlay. This way we don't mess the chart's functionality.
For all of you interested in Jony Adamits solution, I created a ChartJs plugin based on his implementation. Additionaly I fixed some minor issues in regard to resizing the chart and detection of the selected data points.
Feel free to use it or to create a plugin github repo for it.
Installation
import "chart.js";
import {Chart} from 'chart.js';
import {ChartJsPluginRangeSelect} from "./chartjs-plugin-range-select";
Chart.pluginService.register(new ChartJsPluginRangeSelect());
Configuration
let chartOptions = rangeSelect: {
onSelectionChanged: (result: Array<Array<any>>) => {
console.log(result);
}
}
Plugin Code
import {Chart, ChartSize, PluginServiceGlobalRegistration, PluginServiceRegistrationOptions} from "chart.js";
interface ChartJsPluginRangeSelectExtendedOptions {
rangeSelect?: RangeSelectOptions;
}
interface RangeSelectOptions {
onSelectionChanged?: (filteredDataSets: Array<Array<any>>) => void;
fillColor?: string | CanvasGradient | CanvasPattern;
cursorColor?: string | CanvasGradient | CanvasPattern;
cursorWidth?: number;
state?: RangeSelectState;
}
interface RangeSelectState {
canvas: HTMLCanvasElement;
}
interface ActiveSelection {
x: number;
w: number;
}
export class ChartJsPluginRangeSelect implements PluginServiceRegistrationOptions, PluginServiceGlobalRegistration {
public id = 'rangeSelect';
beforeInit(chartInstance: Chart, options?: any) {
const opts = (chartInstance.config.options as ChartJsPluginRangeSelectExtendedOptions);
if (opts.rangeSelect) {
const canvas = this.createOverlayCanvas(chartInstance);
opts.rangeSelect = Object.assign({}, opts.rangeSelect, {state: {canvas: canvas}});
chartInstance.canvas.parentElement.prepend(canvas);
}
}
resize(chartInstance: Chart, newChartSize: ChartSize, options?: any) {
const rangeSelectOptions = (chartInstance.config.options as ChartJsPluginRangeSelectExtendedOptions).rangeSelect;
if (rangeSelectOptions) {
rangeSelectOptions.state.canvas.width = newChartSize.width;
rangeSelectOptions.state.canvas.height = newChartSize.height;
}
}
destroy(chartInstance: Chart) {
const rangeSelectOptions = (chartInstance.config.options as ChartJsPluginRangeSelectExtendedOptions).rangeSelect;
if (rangeSelectOptions) {
rangeSelectOptions.state.canvas.remove();
delete rangeSelectOptions.state;
}
}
private createOverlayCanvas(chart: Chart): HTMLCanvasElement {
const rangeSelectOptions = (chart.config.options as ChartJsPluginRangeSelectExtendedOptions).rangeSelect;
const overlay = this.createOverlayHtmlCanvasElement(chart);
const ctx = overlay.getContext('2d');
let selection: ActiveSelection = {x: 0, w: 0};
let isDragging = false;
chart.canvas.addEventListener('pointerdown', evt => {
const rect = chart.canvas.getBoundingClientRect();
selection.x = this.getXInChartArea(evt.clientX - rect.left, chart);
isDragging = true;
});
chart.canvas.addEventListener('pointerleave', evt => {
if (!isDragging) {
ctx.clearRect(0, 0, overlay.width, overlay.height);
}
});
chart.canvas.addEventListener('pointermove', evt => {
ctx.clearRect(0, 0, chart.canvas.width, chart.canvas.height);
const chartContentRect = chart.canvas.getBoundingClientRect();
const currentX = this.getXInChartArea(evt.clientX - chartContentRect.left, chart);
if (isDragging) {
selection.w = currentX - selection.x;
ctx.fillStyle = rangeSelectOptions.fillColor || '#00000044';
ctx.fillRect(selection.x, chart.chartArea.top, selection.w, chart.chartArea.bottom - chart.chartArea.top);
} else {
const cursorWidth = rangeSelectOptions.cursorWidth || 1;
ctx.fillStyle = rangeSelectOptions.cursorColor || '#00000088';
ctx.fillRect(currentX, chart.chartArea.top, cursorWidth, chart.chartArea.bottom - chart.chartArea.top);
}
});
chart.canvas.addEventListener('pointerup', evt => {
const onSelectionChanged = rangeSelectOptions.onSelectionChanged;
if (onSelectionChanged) {
onSelectionChanged(this.getDataSetDataInSelection(selection, chart));
}
selection = {w: 0, x: 0};
isDragging = false;
ctx.clearRect(0, 0, overlay.width, overlay.height);
});
return overlay;
}
private createOverlayHtmlCanvasElement(chartInstance: Chart): HTMLCanvasElement {
const overlay = document.createElement('canvas');
overlay.style.position = 'absolute';
overlay.style.pointerEvents = 'none';
overlay.width = chartInstance.canvas.width;
overlay.height = chartInstance.canvas.height;
return overlay;
}
private getXInChartArea(val: number, chartInstance: Chart) {
return Math.min(Math.max(val, chartInstance.chartArea.left), chartInstance.chartArea.right);
}
private getDataSetDataInSelection(selection: ActiveSelection, chartInstance: Chart): Array<any> {
const result = [];
const xMin = Math.min(selection.x, selection.x + selection.w);
const xMax = Math.max(selection.x, selection.x + selection.w);
for (let i = 0; i < chartInstance.data.datasets.length; i++) {
result[i] = chartInstance.getDatasetMeta(i)
.data
.filter(data => xMin <= data._model.x && xMax >= data._model.x)
.map(data => chartInstance.data.datasets[i].data[data._index]);
}
return result;
}
}
Unfortunately, nothing like this is built into chart.js. You would have to implement your own event hooks and handlers that would render a highlighted section on a chart and then use the .getElementsAtEvent(e) prototype method to figure out what data has been highlighted. Even these hooks that are built in may not be enough to implement what you are wanting.
Event hook options are:
Add event handlers on the canvas element itself (see example below)
canvas.onclick = function(evt){
var activePoints = myLineChart.getElementsAtEvent(evt);
// => activePoints is an array of points on the canvas that are at the same position as the click event.
};
Add event handler on the chart.js chart object using the onClick config option (explained here).
Extend some of the core charts event hooks and add your own. (see here for some guidance).
Assuming this approach works, then you could then filter your original chart data array accordingly (in the underlying chart.js object) and call the .update() prototype method to paint a new chart.
Update a few months later based on #jordanwillis' answer: I've got the beginnings of range selection.
canvas.onpointerdown = function (evt) {
clearAnnotations()
const points = chart.getElementsAtEventForMode(evt, 'index', { intersect: false })
const label = chart.data.labels[points[0]._index]
addAnnotation(label)
}
canvas.onpointerup = function (evt) {
const points = chart.getElementsAtEventForMode(evt, 'index', { intersect: false })
const label = chart.data.labels[points[0]._index]
addAnnotation(label)
}
function clearAnnotations () {
if (chart.options.annotation) {
chart.options.annotation.annotations = []
}
}
function addAnnotation (label) {
const annotation = {
scaleID: 'x-axis-0',
type: 'line',
mode: 'vertical',
value: label,
borderColor: 'red'
}
chart.options.annotation = chart.options.annotation || {}
chart.options.annotation.annotations = chart.options.annotation.annotations || []
chart.options.annotation.annotations.push(annotation)
chart.update()
}
Still need to figure out how to show a visual hover indicator as in the demo linked in the question, but it's a start.
The people who made ChartJS also made a plugin called chartjs-plugin-zoom. To install the plugin type:
npm install chartjs-plugin-zoom.
Implement:
import { Chart } from 'chart.js';
import zoomPlugin from 'chartjs-plugin-zoom';
Chart.register(zoomPlugin);
To add zooming functionality by dragging, add this to the chart configuration:
options: {
plugins: {
zoom: {
pan: {
enabled: true,
mode: 'x',
modifierKey: 'ctrl',
},
zoom: {
drag: {
enabled: true
},
mode: 'x'
}
}
}
}
A more thorough installation and use tutorial can be found here.
Instructions on how to implement zooming functionality can be found here.

Categories