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>
Related
I am trying to generate a line chart where the X axis values are a string. In the example that D3 has the X axis is a date. How can I change the format of the X axis based on the example that D3 shows.
D3 example of a Line Chart
https://observablehq.com/#d3/line-chart
LineChart = // 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();
}
My Configuration of the Line Chart
<script>
let chartTitles = document.getElementById('chart-title').innerHTML = 'Line Chart';
let chartColor = '#d13c4b';
let chartHeight = 500;
let chartWidth;
let yAxisLabel = "↑ Daily close ($)";
let statusValues = [{status: 'PHA', value: phaCat},
{status: 'Local', value: localCat},];
console.log(statusValues);
chart = LineChart(statusValues, {
x: d => d.status,
y: d => d.value,
width: chartWidth,
height: chartHeight,
color: chartColor,
yLabel: yAxisLabel
})
console.log(chart);
document.getElementById("d3_chart").append(chart);
</script>
If you're referring to Observable to try to build a line graph, then you might try their Plot library, rather than one-off code like LineGraph. Here's a line graph built on a categorical x-axis:
let div = d3.select('#plot')
let alphabet = d3.csvParse(`letter,frequency
E,0.12702
T,0.09056
A,0.08167
O,0.07507
I,0.06966
N,0.06749
S,0.06327
H,0.06094
R,0.05987
D,0.04253
L,0.04025
C,0.02782
U,0.02758
M,0.02406
W,0.0236
F,0.02288
G,0.02015
Y,0.01974
P,0.01929
B,0.01492
V,0.00978
K,0.00772
J,0.00153
X,0.0015
Q,0.00095
Z,0.00074`, d3.autoType)
div.append(() => Plot.plot({
height: 300,
marks: [
Plot.line(d3.sort(alphabet, o => o.letter), {x: 'letter', y: 'frequency'}),
Plot.dot(d3.sort(alphabet, o => o.letter), {x: 'letter', y: 'frequency', fill: 'black'}),
]
}))
<script src="https://cdn.jsdelivr.net/npm/d3#7"></script>
<script src="https://cdn.jsdelivr.net/npm/#observablehq/plot#0.6"></script>
<div id="plot"></div>
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 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>
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 the following bar chart.
https://jsfiddle.net/zyjp1abo/
As you can see the values are between 1000 and 1005. Showing all the data from 0 to 1005 does not sense since the differences aren't visible.
I'd like to show the bars from 1000 and 1005 and change the y axis accordingly. Simply using extent and changing the domain does not work since the bars are drawn through the bottom margin. I want them to stop at the lowest value, i.e 1000.
https://jsfiddle.net/zyjp1abo/1/
Any ideas? Thank you!
If you want the domain to go from 1000 to 1005, you should use d3.extent. That's not the problem.
The problem is that you are using d3.extent but you keep using y(0) both for translating your x axis and for calculating the bars heights, which is wrong. You have to use your height and your margins.
Here is your code with those changes:
var defaults = {
target: '#chart',
width: 500,
height: 170,
margin: {
top: 20,
right: 20,
bottom: 20,
left: 50
},
yTicks: 5
}
class Barchart {
constructor(config) {
Object.assign(this, defaults, config)
const {
target,
width,
height,
margin
} = this
const w = width - margin.left - margin.right
const h = height - margin.top - margin.bottom
const {
yTicks
} = this
this.chart = d3.select(target)
.attr('width', width)
.attr('height', height)
.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`)
this.x = d3.scaleBand()
.rangeRound([0, w])
.padding(0.1)
this.y = d3.scaleLinear()
.rangeRound([h, 0])
this.xAxis = d3.axisBottom(this.x)
this.chart.append('g')
.attr('class', 'x axis')
this.yAxis = d3.axisLeft(this.y)
.ticks(yTicks)
this.chart.append('g')
.attr('class', 'y axis')
}
render(data) {
const {
x,
y,
xAxis,
yAxis,
chart
} = this
// y.domain(d3.extent(data, v => v.value))
y.domain(d3.extent(data, v => v.value))
const domain = data.map(d => d.timestamp)
x.domain(domain)
chart.select('.x.axis')
.attr('transform', `translate(0, ${(defaults.height - defaults.margin.bottom - defaults.margin.top)})`)
.call(xAxis)
chart.select('.y.axis')
.call(yAxis)
const bars = chart.selectAll('.bar')
.data(data)
bars
.enter()
.append('rect')
.attr('class', 'bar')
.merge(bars)
.attr('x', d => x(d.timestamp))
.attr('y', d => y(d.value))
.attr('width', x.bandwidth())
.attr('height', d => defaults.height - defaults.margin.bottom - defaults.margin.top - y(d.value))
}
}
const random = (min = -10, max = 10) => (
window.Math.floor(window.Math.random() * (max - min + 1)) + min
)
let bar = []
for (let i = 0; i < 20; i++) {
bar.push({
timestamp: Date.now() - (19 - i) * 500,
value: random(1000, 1005)
})
}
const barchart = new Barchart()
barchart.render(bar)
<script src="https://d3js.org/d3.v4.min.js"></script>
<svg id="chart">
</svg>
PS: You could simplify the math for the SVG size and margins.
PPS: There is a lot of debate regarding if a dataviz can have a non-zero baseline. I believe that some kinds of charts, in some situations, can and should have a non-zero baseline, specially line charts, when the change rate is more important than the absolute value, for instance. However, bar charts should always have a zero baseline.
After you change your domain, this line:
.attr('height', d => Math.abs(y(d.value) - y(0)))
Is still calculating the height of the bar based of a 0 value. The conventional way to calculate the height as in this example, is to base it off the inner height of your chart (your variable h).
Here's an updated fiddle.
y.domain([0, d3.max(data, v => v.value)])
change 0 here to 1000, and other places respectively