I have some time-series data whose domain changes: I can take the last 6-months, last year, the last 2 years, and so on. I've created a D3 chart that just displays data.
However, you can also zoom this chart, but when you zoom then change the domain, the zoom "resets" but works again when you click.
When the domain changes, I'd like to keep the current zoom: since it's timeseries data, I'd like it to be in the same place. How can I accomplish this?
<head>
<script src="https://d3js.org/d3.v6.min.js"></script>
<div class="buttons">
<button id="sixmo">Last 6 months</button>
<button id="oneyear">Last year</button>
<button id="twoyears">Last 2 years</button>
</div>
</head>
<body>
<script>
// Random data
function randomData() {
function randn_bm() {
var u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
}
let days = []
let endDate = new Date(2020, 1, 0)
for (var d = new Date(2018, 0, 0); d <= endDate; d.setDate(d.getDate() + 1)) {
days.push(new Date(d));
}
return days.map(d => ({
date: d,
value: randn_bm()
}))
}
// Chart
const height = 600
const width = 800
const margin = { top: 20, right: 0, bottom: 30, left: 40 }
let x;
let y;
const zoomed = (event) => {
let xz = event.transform.rescaleX(x);
gX.call(xAxis, xz);
gLine.selectAll("path")
.data([data])
.join("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", d3.line()
.x(d => xz(d.date))
.y(d => y(d.value)))
}
const zoom = d3.zoom()
.scaleExtent([1, 32])
.extent([[margin.left, 0], [width - margin.right, height]])
.translateExtent([[margin.left, -Infinity], [width - margin.right, Infinity]])
.on("zoom", zoomed);
const svg = d3.select("body").append("svg")
.attr("viewBox", [0, 0, width, height]);
svg.call(zoom)
const gLine = svg.append("g").attr("class", "series").attr("clip-path", "url(#clip)")
const gX = svg.append("g").attr("class", "x-axis")
const gY = svg.append("g").attr("class", "y-axis")
const xAxis = (g, x) => g
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).tickSizeOuter(0))
const yAxis = (g, y) => g
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y))
.call(g => g.select(".domain").remove())
svg.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.top)
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom);
function renderChart(data) {
x = d3.scaleTime()
.domain(d3.extent(data, d => d.date))
.range([margin.left, width - margin.right])
y = d3.scaleLinear()
.domain(d3.extent(data, d => d.value)).nice()
.range([height - margin.bottom, margin.top])
gX.call(xAxis, x);
gY.call(yAxis, y);
gLine.selectAll("path")
.data([data])
.join("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", d3.line()
.x(d => x(d.date))
.y(d => y(d.value)))
}
// Buttons
const data = randomData()
const lastDataDate = new Date(2020, 1, 0)
const buttons = d3.select(".buttons")
.selectAll("button")
.data([6, 12, 24])
.join("button")
.on("click", (_, months) => {
const startDate = new Date(lastDataDate)
startDate.setMonth(startDate.getMonth() - months)
const filteredData = data.filter(d => d.date > startDate)
renderChart(filteredData)
})
renderChart(data)
</script>
</body>
Problem
If you use d3.zoom to zoom/pan, you need to let d3.zoom know when you've manually gone ahead and altered the pan/zoom. It doesn't "know" what sort of tampering you do outside of it. Further, if you are going to update the zoom status of an element so that d3.zoom "knows" of the change, why not use d3.zoom to actually do the zooming and panning too?
In your example, you use the zoom to set the scale of the data, but when you click on the buttons, you set the zoom by merely filtering the data. d3.zoom is none the wiser. That's why the jump occurs when you use a button and then the zoom - the zoom behavior picks up where it was last left.
Lastly, you have written two methods to zoom and pan, when you could just run it all through d3.zoom.
This is not an uncommon problem - here's an example of a the same principle at play.
Solution
Only use one method to zoom/pan. This way there is no need to sync the behavior and state of two separate mechanisms for zoom/pan. You can use d3.zoom for both programmatic zooms and standard zooming quite easily.
You'll find it easiest with a reference scale when dealing with axes and scales - this way zooming is relative to the original zoom state and not the last zoom state (which can cause problems). We use the reference scale each zoom event to rescale our working scale. The working scale is passed to the axis generator and used to position the data.
So in your case, our zoom function simply looks like:
const zoomed = (event) => {
xScale.domain(event.transform.rescaleX(xReference).domain());
draw(data);
}
We rescale the xScale each time to reflect the new domain shown by the zoom transform provided by the zoom event.
This works for mouse interaction with no further modification. We can invoke the programmatic zoom with svg.call(zoom.transform, someZoomTransform), all we have to do is calculate the proper transform, using your code as an example this looked something like:
const endDate = lastDataDate;
const startDate = d3.timeMonth.offset(endDate,-months);
// k = width of range needed for data set / width of range needed for area of interest
const k = (xReference.range()[1] - xReference.range()[0]) / (xReference(endDate) - xReference(startDate))\
// translate to account for starting point of area of interest.
const tx = xReference(startDate);
// let the zoom handle it.
svg.call(zoom.transform, d3.zoomIdentity
.scale(k)
.translate(-tx+margin.left/k, 0) // margin.left/k : account for scale range not starting at 0.
);
Putting that together we get:
const height = 500;
const width = 500;
const margin = { top: 20, right: 0, bottom: 30, left: 40 }
const svg = d3.select("body").append("svg")
.attr("width",width)
.attr("height",height);
var data = randomData();
// Set up Scales:
let xScale = d3.scaleTime()
.domain(d3.extent(data, d => d.date))
.range([margin.left, width - margin.right])
// Reference to hold starting version of scale:
const xReference = xScale.copy();
let yScale = d3.scaleLinear()
.domain(d3.extent(data, d => d.value)).nice()
.range([height - margin.bottom, margin.top])
// Set up Zoom:
const zoomed = (event) => {
xScale.domain(event.transform.rescaleX(xReference).domain());
draw(data);
}
const zoom = d3.zoom()
.scaleExtent([1, 32])
.extent([[margin.left, 0], [width - margin.right, height]])
.translateExtent([[margin.left, -Infinity], [width - margin.right, Infinity]])
.on("zoom", zoomed);
svg.call(zoom);
// Set up axes and miscellania
const gLine = svg.append("g").attr("class", "series").attr("clip-path", "url(#clip)")
const gX = svg.append("g").attr("class", "x-axis")
const gY = svg.append("g").attr("class", "y-axis")
const xAxis = (g, x) => g
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(xScale).tickSizeOuter(0))
const yAxis = (g, y) => g
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(yScale))
.call(g => g.select(".domain").remove())
svg.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.top)
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom);
// Draw:
function draw(data) {
gX.call(xAxis, xScale);
gY.call(yAxis, yScale);
gLine.selectAll("path")
.data([data])
.join("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", d3.line()
.x(d => xScale(d.date))
.y(d => yScale(d.value)))
}
// Button Behavior
const lastDataDate = new Date(2020, 1, 0)
const buttons = d3.select(".buttons")
.selectAll("button")
.data([6, 12, 24])
.join("button")
.on("click", (_, months) => {
const endDate = lastDataDate;
const startDate = d3.timeMonth.offset(endDate,-months);
// k = width of range needed for data set / width of range needed for area of interest
const k = (xReference.range()[1] - xReference.range()[0]) / (xReference(endDate) - xReference(startDate))
// translate to account for starting point of area of interest.
const tx = xReference(startDate);
// let the zoom handle it.
svg.call(zoom.transform, d3.zoomIdentity
.scale(k)
.translate(-tx+margin.left/k, 0) // account for scale range not starting at 0.
);
})
draw(data);
// Random data
function randomData() {
function randn_bm() {
var u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
}
let days = []
let endDate = new Date(2020, 1, 0)
for (var d = new Date(2018, 0, 0); d <= endDate; d.setDate(d.getDate() + 1)) {
days.push(new Date(d));
}
return days.map(d => ({
date: d,
value: randn_bm()
}))
}
<script src="https://d3js.org/d3.v6.min.js"></script>
<div class="buttons">
<button id="sixmo">Last 6 months</button>
<button id="oneyear">Last year</button>
<button id="twoyears">Last 2 years</button>
</div>
One way to do this would be by storing the last zoom event in a variable, and, if one exists, then instead of redrawing the axis from scratch, calling zoomed() with that last event.
EDIT: I now understand your question better. What I did below was as follows:
Whenever a button is clicked, get first the zoomed domain xz;
Then see if we need to clamp it so the domain is a subset of the new data. xz.domain() must always fall within x.domain();
If that was the case, calculate the scaling factor and the point at the centre of the viewport;
Redraw the chart completely;
Ask d3 to scale to the correct ratio, using the previously calculated scale, then ask it to pan to the correct position, using the centre point previously calculated.
Furthermore, I've changed the y-domain to always be calculated with the entire data set. This makes sure the line doesn't jump vertically when any of the buttons are pushed.
There is no jumping around the x-axis, unless your viewport covers data that is no longer available after you click a button.
Testcases
The view should remain the same with all of these:
Click 'last year', then '2 years';
Click '2 years', then zoom into range Nov-Jan 2020. Click '6 months';
Click 'last year', zoom and pan until it covers Feb-Apr 2019. Click '2 years';
Click '6 months', then 'last year', then '2 years'.
The view should change with all of these:
Click '2 years', zoom out fully, then '6 months';
Click '2 years', then zoom into range Feb-Jan 2020. Click '6 months';
Click 'last year', zoom and pan until it covers Feb-Apr 2019. Click '6 months'.
// Random data
function randomData() {
function randn_bm() {
var u = 0,
v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
}
let days = []
let endDate = new Date(2020, 1, 0)
for (var d = new Date(2018, 0, 0); d <= endDate; d.setDate(d.getDate() + 1)) {
days.push(new Date(d));
}
return days.map(d => ({
date: d,
value: randn_bm()
}))
}
// Chart
const height = 400
const width = 800
const margin = {
top: 20,
right: 0,
bottom: 30,
left: 40
}
let x;
let y;
let xz;
const zoomed = (event) => {
xz = event.transform.rescaleX(x);
gX.call(xAxis, xz);
gLine.selectAll("path")
.data([data])
.join("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", d3.line()
.x(d => xz(d.date))
.y(d => y(d.value)))
}
const zoom = d3.zoom()
.scaleExtent([1, 32])
.extent([
[margin.left, 0],
[width - margin.right, height]
])
.translateExtent([
[margin.left, -Infinity],
[width - margin.right, Infinity]
])
.on("zoom", zoomed);
const svg = d3.select("body").append("svg")
.attr('width', width)
.attr('height', height);
svg.call(zoom)
const gLine = svg.append("g").attr("class", "series").attr("clip-path", "url(#clip)")
const gX = svg.append("g").attr("class", "x-axis")
const gY = svg.append("g").attr("class", "y-axis")
const xAxis = (g, x) => g
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).tickSizeOuter(0))
const yAxis = (g, y) => g
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y))
.call(g => g.select(".domain").remove())
svg.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.top)
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom);
function renderChart(data) {
x = d3.scaleTime()
.domain(d3.extent(data, d => d.date))
.range([margin.left, width - margin.right])
let reScale = false,
domain,
centerPoint;
if(xz !== undefined) {
domain = xz.domain();
centerPoint = xz.invert((width - margin.left - margin.right) / 2);
// If the previous center completely falls out of the current bounds, draw the chart anew.
if(domain[1] < data[0].date || domain[0] > data[data.length - 1].date) {
// Nothing
} else {
// Else, clip the domain to fit the data.
if(domain[0] < data[0].date) {
domain[0] = data[0].date;
}
if(domain[1] > data[data.length - 1].date) {
domain[1] = data[data.length - 1].date;
}
reScale = true;
}
}
gY.call(yAxis, y);
gX.call(xAxis, x);
gLine.selectAll("path")
.data([data])
.join("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", d3.line()
.x(d => x(d.date))
.y(d => y(d.value)))
if(reScale) {
const scale = (x.domain()[1] - x.domain()[0])/(domain[1] - domain[0]);
svg.call(zoom.scaleTo, scale)
.call(zoom.translateTo, centerPoint, 0);
}
}
// Buttons
const data = randomData()
// To avoid jumpy behaviour, make sure the y-domain is steady
y = d3.scaleLinear()
.domain(d3.extent(data, d => d.value)).nice()
.range([height - margin.bottom, margin.top])
const lastDataDate = new Date(2020, 1, 0)
const buttons = d3.select(".buttons")
.selectAll("button")
.data([6, 12, 24])
.join("button")
.on("click", (_, months) => {
const startDate = new Date(lastDataDate)
startDate.setMonth(startDate.getMonth() - months)
const filteredData = data.filter(d => d.date > startDate)
renderChart(filteredData)
})
renderChart(data)
<script src="https://d3js.org/d3.v6.js"></script>
<div class="buttons">
<button id="sixmo">Last 6 months</button>
<button id="oneyear">Last year</button>
<button id="twoyears">Last 2 years</button>
</div>
Related
I am following some examples of d3.js to plot graphs. For reference here is the link.
Following is the code where I've used the LineChart function to build the plot. With Django as backend.
<!DOCTYPE html>
{% load static %}
<html>
<script src="https://d3js.org/d3.v6.js"></script>
<body>
<h1> Hello! </h1>
<div id="chart"></div>
</body>
<script>
// set the dimensions and margins of the graph
const margin = {
top: 10,
right: 30,
bottom: 30,
left: 60
},
width = 460 - margin.left - margin.right,
height = 400 - margin.top - margin.bottom;
// Copyright 2021 Observable, Inc.
// Released under the ISC license.
// https://observablehq.com/#d3/line-chart
function LineChart(data, {
x = ([x]) => x, // given d in data, returns the (temporal) x-value
y = ([, y]) => y, // given d in data, returns the (quantitative) y-value
defined, // for gaps in data
curve = d3.curveLinear, // method of interpolation between points
marginTop = 20, // top margin, in pixels
marginRight = 30, // right margin, in pixels
marginBottom = 30, // bottom margin, in pixels
marginLeft = 40, // left margin, in pixels
width = 640, // outer width, in pixels
height = 400, // outer height, in pixels
xType = d3.scaleUtc, // the x-scale type
xDomain, // [xmin, xmax]
xRange = [marginLeft, width - marginRight], // [left, right]
yType = d3.scaleLinear, // the y-scale type
yDomain, // [ymin, ymax]
yRange = [height - marginBottom, marginTop], // [bottom, top]
yFormat, // a format specifier string for the y-axis
yLabel, // a label for the y-axis
color = "currentColor", // stroke color of line
strokeLinecap = "round", // stroke line cap of the line
strokeLinejoin = "round", // stroke line join of the line
strokeWidth = 1.5, // stroke width of line, in pixels
strokeOpacity = 1, // stroke opacity of line
} = {}) {
// Compute values.
const X = d3.map(data, x);
const Y = d3.map(data, y);
const I = d3.range(X.length);
if (defined === undefined) defined = (d, i) => !isNaN(X[i]) && !isNaN(Y[i]);
const D = d3.map(data, defined);
// Compute default domains.
if (xDomain === undefined) xDomain = d3.extent(X);
if (yDomain === undefined) yDomain = [0, d3.max(Y)];
// Construct scales and axes.
const xScale = xType(xDomain, xRange);
const yScale = yType(yDomain, yRange);
const xAxis = d3.axisBottom(xScale).ticks(width / 80).tickSizeOuter(0);
const yAxis = d3.axisLeft(yScale).ticks(height / 40, yFormat);
// Construct a line generator.
const line = d3.line()
.defined(i => D[i])
.curve(curve)
.x(i => xScale(X[i]))
.y(i => yScale(Y[i]));
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
svg.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(xAxis);
svg.append("g")
.attr("transform", `translate(${marginLeft},0)`)
.call(yAxis)
.call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line").clone()
.attr("x2", width - marginLeft - marginRight)
.attr("stroke-opacity", 0.1))
.call(g => g.append("text")
.attr("x", -marginLeft)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.text(yLabel));
svg.append("path")
.attr("fill", "none")
.attr("stroke", color)
.attr("stroke-width", strokeWidth)
.attr("stroke-linecap", strokeLinecap)
.attr("stroke-linejoin", strokeLinejoin)
.attr("stroke-opacity", strokeOpacity)
.attr("d", line(I));
return svg.node();
}
var aapl = [{
"Date": 2021-01-01,
"Close": 182.01
},
{
"Date": 2021-01-02,
"Close": 179.7
},
{
"Date": 2021-01-03,
"Close": 174.92
},
];
var chart = LineChart(aapl, {
x: d => d.Date,
y: d => d.Close,
yLabel: "↑ Daily close ($)",
width: 1000,
height: 1000,
color: "steelblue"
})
// append the svg object to the body of the page
const svg = d3.select("#chart").node().appendChild(chart);
</script>
</html>
Dates are not getting reflected correctly as seen in the image below.
What is the issue here and how can I solve it?
I am following some examples of d3.js to plot graphs. For reference here is the link.
Following is the code where I've used the LineChart function to build the plot. With Django as backend.
{% load static %}
<html>
<script src="https://d3js.org/d3.v6.js"></script>
<body>
<h1> Hello! </h1>
<div id="chart"></div>
</body>
<script>
// set the dimensions and margins of the graph
const margin = {top: 10, right: 30, bottom: 30, left: 60},
width = 460 - margin.left - margin.right,
height = 400 - margin.top - margin.bottom;
// append the svg object to the body of the page
const svg = d3.select("#chart")
.append("chart")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
// Copyright 2021 Observable, Inc.
// Released under the ISC license.
// https://observablehq.com/#d3/line-chart
function LineChart(data, {
x = ([x]) => x, // given d in data, returns the (temporal) x-value
y = ([, y]) => y, // given d in data, returns the (quantitative) y-value
defined, // for gaps in data
curve = d3.curveLinear, // method of interpolation between points
marginTop = 20, // top margin, in pixels
marginRight = 30, // right margin, in pixels
marginBottom = 30, // bottom margin, in pixels
marginLeft = 40, // left margin, in pixels
width = 640, // outer width, in pixels
height = 400, // outer height, in pixels
xType = d3.scaleUtc, // the x-scale type
xDomain, // [xmin, xmax]
xRange = [marginLeft, width - marginRight], // [left, right]
yType = d3.scaleLinear, // the y-scale type
yDomain, // [ymin, ymax]
yRange = [height - marginBottom, marginTop], // [bottom, top]
yFormat, // a format specifier string for the y-axis
yLabel, // a label for the y-axis
color = "currentColor", // stroke color of line
strokeLinecap = "round", // stroke line cap of the line
strokeLinejoin = "round", // stroke line join of the line
strokeWidth = 1.5, // stroke width of line, in pixels
strokeOpacity = 1, // stroke opacity of line
} = {}) {
// Compute values.
const X = d3.map(data, x);
const Y = d3.map(data, y);
const I = d3.range(X.length);
if (defined === undefined) defined = (d, i) => !isNaN(X[i]) && !isNaN(Y[i]);
const D = d3.map(data, defined);
// Compute default domains.
if (xDomain === undefined) xDomain = d3.extent(X);
if (yDomain === undefined) yDomain = [0, d3.max(Y)];
// Construct scales and axes.
const xScale = xType(xDomain, xRange);
const yScale = yType(yDomain, yRange);
const xAxis = d3.axisBottom(xScale).ticks(width / 80).tickSizeOuter(0);
const yAxis = d3.axisLeft(yScale).ticks(height / 40, yFormat);
// Construct a line generator.
const line = d3.line()
.defined(i => D[i])
.curve(curve)
.x(i => xScale(X[i]))
.y(i => yScale(Y[i]));
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
svg.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(xAxis);
svg.append("g")
.attr("transform", `translate(${marginLeft},0)`)
.call(yAxis)
.call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line").clone()
.attr("x2", width - marginLeft - marginRight)
.attr("stroke-opacity", 0.1))
.call(g => g.append("text")
.attr("x", -marginLeft)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.text(yLabel));
svg.append("path")
.attr("fill", "none")
.attr("stroke", color)
.attr("stroke-width", strokeWidth)
.attr("stroke-linecap", strokeLinecap)
.attr("stroke-linejoin", strokeLinejoin)
.attr("stroke-opacity", strokeOpacity)
.attr("d", line(I));
return svg.node();
};
var aapl = [
{
"Date": 1641168000000,
"Close": 182.01
},
{
"Date": 1641254400000,
"Close": 179.7
},
{
"Date": 1641168000000,
"Close": 174.92
},
];
var chart = LineChart(aapl, {
x: d => d.Date,
y: d => d.Close,
yLabel: "↑ Daily close ($)",
width: 400,
height: 500,
color: "steelblue"
})
</script>
</html>
I still learning javascript and specifically the d3.js library. I am unable to display the plot. What is wrong in the shared code and how can I fix it?
There are 2 problems with your code. You are appending chart before it is created and also you need to use appendChild Method as you are returning node of the svg element.
d3 allows to append nodes created by d3.create by using appendChild. I have changed your code below to use appendChild to attach svg node to the chart div node.
// set the dimensions and margins of the graph
const margin = {
top: 10,
right: 30,
bottom: 30,
left: 60
},
width = 460 - margin.left - margin.right,
height = 400 - margin.top - margin.bottom;
// Copyright 2021 Observable, Inc.
// Released under the ISC license.
// https://observablehq.com/#d3/line-chart
function LineChart(data, {
x = ([x]) => x, // given d in data, returns the (temporal) x-value
y = ([, y]) => y, // given d in data, returns the (quantitative) y-value
defined, // for gaps in data
curve = d3.curveLinear, // method of interpolation between points
marginTop = 20, // top margin, in pixels
marginRight = 30, // right margin, in pixels
marginBottom = 30, // bottom margin, in pixels
marginLeft = 40, // left margin, in pixels
width = 640, // outer width, in pixels
height = 400, // outer height, in pixels
xType = d3.scaleUtc, // the x-scale type
xDomain, // [xmin, xmax]
xRange = [marginLeft, width - marginRight], // [left, right]
yType = d3.scaleLinear, // the y-scale type
yDomain, // [ymin, ymax]
yRange = [height - marginBottom, marginTop], // [bottom, top]
yFormat, // a format specifier string for the y-axis
yLabel, // a label for the y-axis
color = "currentColor", // stroke color of line
strokeLinecap = "round", // stroke line cap of the line
strokeLinejoin = "round", // stroke line join of the line
strokeWidth = 1.5, // stroke width of line, in pixels
strokeOpacity = 1, // stroke opacity of line
} = {}) {
// Compute values.
const X = d3.map(data, x);
const Y = d3.map(data, y);
const I = d3.range(X.length);
if (defined === undefined) defined = (d, i) => !isNaN(X[i]) && !isNaN(Y[i]);
const D = d3.map(data, defined);
// Compute default domains.
if (xDomain === undefined) xDomain = d3.extent(X);
if (yDomain === undefined) yDomain = [0, d3.max(Y)];
// Construct scales and axes.
const xScale = xType(xDomain, xRange);
const yScale = yType(yDomain, yRange);
const xAxis = d3.axisBottom(xScale).ticks(width / 80).tickSizeOuter(0);
const yAxis = d3.axisLeft(yScale).ticks(height / 40, yFormat);
// Construct a line generator.
const line = d3.line()
.defined(i => D[i])
.curve(curve)
.x(i => xScale(X[i]))
.y(i => yScale(Y[i]));
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
svg.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(xAxis);
svg.append("g")
.attr("transform", `translate(${marginLeft},0)`)
.call(yAxis)
.call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line").clone()
.attr("x2", width - marginLeft - marginRight)
.attr("stroke-opacity", 0.1))
.call(g => g.append("text")
.attr("x", -marginLeft)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.text(yLabel));
svg.append("path")
.attr("fill", "none")
.attr("stroke", color)
.attr("stroke-width", strokeWidth)
.attr("stroke-linecap", strokeLinecap)
.attr("stroke-linejoin", strokeLinejoin)
.attr("stroke-opacity", strokeOpacity)
.attr("d", line(I));
return svg.node();
}
var aapl = [{
"Date": 1641168000000,
"Close": 182.01
},
{
"Date": 1641254400000,
"Close": 179.7
},
{
"Date": 1641168000000,
"Close": 174.92
},
];
var chart = LineChart(aapl, {
x: d => d.Date,
y: d => d.Close,
yLabel: "↑ Daily close ($)",
width: 400,
height: 500,
color: "steelblue"
})
// append the svg object to the body of the page
const svg = d3.select("#chart").node().appendChild(chart);
<script src="https://d3js.org/d3.v6.js"></script>
<body>
<h1> Hello! </h1>
<div id="chart"></div>
</body>
I'm currently working on trying to create a "facet plot" within d3.js, which I realize in D3 language isn't really a thing. However, I found this thread which outlined how to accomplish the task. From this post, I've converted the line chart from vertical to horizontal, and added a few extra padding elements where needed.
Now I would like to add a tooltip to each plot. From working with other pieces of code, this seems quite challenging. For some reason I can't figure out how to attach the tooltip to each individual plot. Any thoughts on how I might be able to accomplish this?
What it currently looks like:
// Data and manipluation:
const data = d3.range(25).map(i => ({
bib: Math.floor(i / 5) + 1,
ratio: -1 + Math.random() * 5,
run: [1, 2, 3, 4, 5][i % 5],
run: [1, 2, 3, 4, 5][i % 5],
name: ['metric1', 'metric2', 'metric3', 'metric4', 'metric5'][Math.floor(i / 5)]
}));
const grouped = d3.group(data,d=>d.bib);
// Dimensions:
const height = 800;
const width = 700;
const margin = {
top: 10,
left: 50,
right: 50,
bottom: 50
}
const padding = 30;
const doublePadding = padding * 2;
const plotHeight = (height-doublePadding)/grouped.size - padding;
const plotWidth = width-padding*2;
// const plotWidth = (width-padding)/grouped.size - padding;
// const plotHeight = height-padding*2;
const svg = d3.select("#chart1")
.append("svg")
.attr("width", margin.left+width+margin.right)
.attr("height", margin.top+height+margin.bottom+(padding*grouped.size));
const g = svg.append("g")
.attr("transform","translate("+[margin.left,margin.top]+")");
//Scales:
const xScale = d3.scaleLinear()
.domain(d3.extent(data, d => d.run))
.range([0, plotWidth]);
const yScale = d3.scaleLinear()
.domain(d3.extent(data, d => d.ratio))
.range([plotHeight,0]);
// Place plots:
const plots = g.selectAll(null)
.data(grouped)
.enter()
.append("g")
.attr("transform", function(d,i) {
return "translate("+[padding,i*(doublePadding+plotHeight)+padding]+")";
})
//Optional plot background:
plots.append("rect")
.attr("width",plotWidth)
.attr("height",plotHeight)
.attr("fill","#ddd");
// Plot actual data
plots.selectAll(null)
.data(d=>d[1])
.enter()
.append("circle")
.attr("r", 4)
.attr("cy", d=>yScale(d.ratio))
.attr("cx", d=>xScale(d.run))
// Plot line if needed:
plots.append("path")
.attr("d", function(d) {
return d3.line()
.x(d=>xScale(d.run))
.y(d=>yScale(d.ratio))
(d[1])
})
.attr("stroke", "#333")
.attr("stroke-width", 1)
.attr("fill","none")
// Plot names if needed:
plots.append("text")
.attr("x", plotWidth/2)
.attr("y", -10)
.text(function(d) {
return d[1][0].name;
})
.attr("text-anchor","middle");
// Plot axes
plots.append("g")
.attr("transform","translate("+[0,plotHeight]+")")
.call(d3.axisBottom(xScale).ticks(4));
plots.append("g")
.attr("transform","translate("+[-padding,0]+")")
.call(d3.axisLeft(yScale))
<head>
<!-- Load d3.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.6.1/d3.min.js"></script>
</head>
<body>
<div class="graph-container">
<div id="chart1"></div>
</div>
</body>
See a solution in a fiddle:
Add a g element with background and text(s):
const tooltip = d3.select('svg')
.append('g')
.style('visibility', 'hidden');
tooltip.append('rect')
.attr('width', 100)
.attr('height', 50)
.style('fill', '#fff')
.style('stroke', '#000')
tooltip.append('text')
.attr('x', 20)
.attr('y', 20)
Update it on mouseenter, mousevove, mouseleave:
plots.append("rect")
.attr("width",plotWidth)
.attr("height",plotHeight)
.attr("fill","#ddd")
.on("mouseenter", (e, d) => {
tooltip.style('visibility', 'visible');
tooltip.select('text').text(d[0]);
})
.on("mouseleave", () => tooltip.style('visibility', 'hidden'))
.on("mousemove", e => tooltip.attr('transform', `translate(${e.clientX},${e.clientY})`))
I'm really having trouble with D3 and need some help changing my existing barchart to be a grouped barchart The barchart is being used within a tooltip and currently looks like:
Each colour represents a sector of industry (pink = retail, teal = groceries...etc).
I need to change the bar chart so that it compares the percentage change in each industry with the world average percentage change in this industry.
At the moment the bar chart is being created from an array of data. I also have an array with the world percentage values.
So imagine:
countryData = [10,-20,-30,-63,-23,20],
worldData = [23,-40,-23,-42,-23,40]
Where index 0 = retail sector, index 1 = grocery sector, etc.
I need to plot a grouped barchart comparing each sector to the world average (show the world average in red). This is a bit tricky to explain so I drew it for you (...excuse the shoddy drawing).
Please can someone help me change my existing tooltip?
Here's the current code. If you want to simulate the data values changing.
If you want to scrap my existing code that's fine.
.on('mouseover', ({ properties }) => {
// get county data
const mobilityData = covid.data[properties[key]] || {};
const {
retailAverage,
groceryAverage,
parksAverage,
transitAverage,
workplaceAverage,
residentialAverage,
} = getAverage(covid1);
let avgArray = [retailAverage, groceryAverage, parksAverage, transitAverage, workplaceAverage, retailAverage];
let categoriesNames = ["Retail", "Grocery", "Parks", "Transit", "Workplaces", "Residential"];
// create tooltip
div = d3.select('body')
.append('div')
.attr('class', 'tooltip')
.style('opacity', 0);
div.html(properties[key]);
div.transition()
.duration(200)
.style('opacity', 0.9);
// calculate bar graph data for tooltip
const barData = [];
Object.keys(mobilityData).forEach((industry) => {
const stringMinusPercentage = mobilityData[industry].slice(0, -1);
barData.push(+stringMinusPercentage); // changing it to an integer value, from string
});
//combine the two lists for the combined bar graph
var combinedList = [];
for(var i = 0; i < barData.length; i++) {
const stringMinusPercentage2 = +(avgArray[i].slice(0, -1));
const object = {category: categoriesNames[i], country: barData[i], world: stringMinusPercentage2}
combinedList.push(object); //Push object into list
}
console.log(combinedList);
// barData = barData.sort(function (a, b) { return a - b; });
// sort into ascending ^ keeping this in case we need it later
const height2 = 220;
const width2 = 250;
const margin = {
left: 50, right: 10, top: 20, bottom: 15,
};
// create bar chart svg
const svgA = div.append('svg')
.attr('height', height2)
.attr('width', width2)
.style('border', '1px solid')
.append('g')
// apply the margins:
.attr('transform', `translate(${[`${margin.left},${margin.top}`]})`);
const barWidth = 30; // Width of the bars
// plot area is height - vertical margins.
const chartHeight = height2 - margin.top - margin.left;
// set the scale:
const yScale = d3.scaleLinear()
.domain([-100, 100])
.range([chartHeight, 0]);
// draw some rectangles:
svgA
.selectAll('rect')
.data(barData)
.enter()
.append('rect')
.attr('x', (d, i) => i * barWidth)
.attr('y', (d) => {
if (d < 0) {
return yScale(0); // if the value is under zero, the top of the bar is at yScale(0);
}
return yScale(d); // otherwise the rectangle top is above yScale(0) at yScale(d);
})
.attr('height', (d) => Math.abs(yScale(0) - yScale(d))) // the height of the rectangle is the difference between the scale value and yScale(0);
.attr('width', barWidth)
.style('fill', (d, i) => colours[i % 6]) // colour the bars depending on index
.style('stroke', 'black')
.style('stroke-width', '1px');
// Labelling the Y axis
const yAxis = d3.axisLeft(yScale);
svgA.append('text')
.attr('class', 'y label')
.attr('text-anchor', 'end')
.attr('x', -15)
.attr('y', -25)
.attr('dy', '-.75em')
.attr('transform', 'rotate(-90)')
.text('Percentage Change (%)');
svgA.append('g')
.call(yAxis);
})
.on('mouseout', () => {
div.style('opacity', 0);
div.remove();
})
.on('mousemove', () => div
.style('top', `${d3.event.pageY - 140}px`)
.style('left', `${d3.event.pageX + 15}px`));
svg.append('g')
.attr('transform', 'translate(25,25)')
.call(colorLegend, {
colorScale,
circleRadius: 10,
spacing: 30,
textOffset: 20,
});
};
drawMap(svg1, geoJson1, geoPath1, covid1, key1, 'impact1');
drawMap(svg2, geoJson2, geoPath2, covid2, key2, 'impact2');
};
In short I would suggest you to use two Band Scales for x axis. I've attached a code snippet showing the solution.
Enjoy ;)
//Assuming the following data final format
var finalData = [
{
"groupKey": "Retail",
"sectorValue": 70,
"worldValue": 60
},
{
"groupKey": "Grocery",
"sectorValue": 90,
"worldValue": 90
},
{
"groupKey": "other",
"sectorValue": -20,
"worldValue": 30
}
];
var colorRange = d3.scaleOrdinal().range(["#00BCD4", "#FFC400", "#ECEFF1"]);
var subGroupKeys = ["sectorValue", "worldValue"];
var svg = d3.select("svg");
var margin = {top: 20, right: 20, bottom: 30, left: 40};
var width = +svg.attr("width") - margin.left - margin.right;
var height = +svg.attr("height") - margin.top - margin.bottom;
var container = svg.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")");
// The scale spacing the groups, your "sectors":
var x0 = d3.scaleBand()
.domain(finalData.map(d => d.groupKey))
.rangeRound([0, width])
.paddingInner(0.1);
// The scale for spacing each group's bar, your "sector bar":
var x1 = d3.scaleBand()
.domain(subGroupKeys)
.rangeRound([0, x0.bandwidth()])
.padding(0.05);
var yScale = d3.scaleLinear()
.domain([-100, 100])
.rangeRound([height, 0]);
//and then you will need to append both, groups and bars
var groups = container.append('g')
.selectAll('g')
.data(finalData, d => d.groupKey)
.join("g")
.attr("transform", (d) => "translate(" + x0(d.groupKey) + ",0)");
//define groups bars, one per sub group
var bars = groups
.selectAll("rect")
.data(d => subGroupKeys.map(key => ({ key, value: d[key], groupKey: d.groupKey })), (d) => "" + d.groupKey + "_" + d.key)
.join("rect")
.attr("fill", d => colorRange(d.key))
.attr("x", d => x1(d.key))
.attr("width", (d) => x1.bandwidth())
.attr('y', (d) => Math.min(yScale(0), yScale(d.value)))
.attr('height', (d) => Math.abs(yScale(0) - yScale(d.value)));
//append x axis
container.append("g")
.attr("class", "axis")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(x0));
//append y axis
container.append("g")
.attr("class", "y axis")
.call(d3.axisLeft(yScale))
.append("text")
.attr("x", 2)
.attr("y", yScale(yScale.ticks().pop()) + 0.5)
.attr("dy", "0.32em")
.attr("fill", "#000")
.attr("font-weight", "bold")
.attr("text-anchor", "start")
.text("Values");
<script src="https://d3js.org/d3.v7.min.js"></script>
<svg width="600" height="400"></svg>
I have a graph I need to make but having a hard time figuring out the best approach. Essentially what I need is two different data sets on the y-axis that are separate values but still related. At zero on the y-axis the data set changes to a different value that goes in positive increments.This is an example of the type of graph I am talking about
What would be the best way to go about creating this? While I can certainly find examples of multiple y-axis graphs, they don't seem to account for this use case.
You can indeed create two different scales, which is probably the standard solution, or... you can create only one scale! So, just for the sake of curiosity, here is how to do it:
Create a scale going from -10 to 10...
var yScale = d3.scaleLinear()
.domain([-10, 10])
... changing the negative values to positive ones in the axis...
var yAxis = d3.axisLeft(yScale)
.tickFormat(d => d < 0 ? Math.abs(d) : d);
... and, of course, changing the y values to negative ones in the data for the lines below the x axis (here named dataInspiration):
dataInspiration.forEach(d => d.y = -d.y)
Here is a demo using random numbers:
var width = 600,
height = 200,
padding = 20;
svg = d3.select('body').append('svg')
.attr('width', width)
.attr('height', height);
var dataExpiration = d3.range(10).map(d => ({
x: d,
y: Math.random() * 10
}));
var dataInspiration = d3.range(10).map(d => ({
x: d,
y: Math.random() * 10
}));
dataInspiration.forEach(d => d.y = -d.y)
var xScale = d3.scalePoint()
.domain(d3.range(10))
.range([padding, width - padding]);
var yScale = d3.scaleLinear()
.domain([-10, 10])
.range([height - padding, padding])
var line = d3.line()
.x(d => xScale(d.x))
.y(d => yScale(d.y))
.curve(d3.curveMonotoneX);
var lineExpiration = svg.append("path")
.attr("fill", "none")
.attr("stroke", "blue")
.attr("d", line(dataExpiration));
var lineInspiration = svg.append("path")
.attr("fill", "none")
.attr("stroke", "red")
.attr("d", line(dataInspiration));
var xAxis = d3.axisBottom(xScale)
.tickFormat(d => d != 0 ? d : null);
var yAxis = d3.axisLeft(yScale)
.tickFormat(d => d < 0 ? Math.abs(d) : d);
var gX = svg.append("g")
.attr("transform", "translate(0," + yScale(0) + ")")
.call(xAxis);
var gY = svg.append("g")
.attr("transform", "translate(" + padding + ",0)")
.call(yAxis);
<script src="https://d3js.org/d3.v4.min.js"></script>