Convert observable d3.js brush and focus example to jsfiddle

Viewed 511

I'm having trouble converting this observable example to a jsfiddle.

https://observablehq.com/@d3/focus-context?collection=@d3/d3-brush

Here's my jsfiddle https://jsfiddle.net/u5g1ychz/1/

Edit: latest fiddle attempt https://jsfiddle.net/03eagvxk/

HTML

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8"/>
</head>
<body>
  <h1>My Chart</h1>
  <div id="my_chart"></div>
</body>
</html>

CSS

.line {
    fill: none;
    stroke: #ffab00;
    stroke-width: 1.5;
}
  
.overlay {
  fill: none;
  pointer-events: all;
}

/* Style the dots by assigning a fill and stroke */
.dot {
    fill: #ffab00;
    stroke: #fff;
}
  
  .focus circle {
  fill: none;
  stroke: steelblue;
}

Javascript

/* new data */
var x = d3.timeDays(new Date(2010, 06, 01), new Date(2020, 10, 30));
var y = Array.from({length: x.length}, Math.random).map(n => Math.floor(n * 10) + 5);
var data = x.map((v, i) => {
  return {
    "date": v,
    "close": y[i]
  }
});

/* begin observable code */
viewof focus = {
  const svg = d3.create("svg")
      .attr("viewBox", [0, 0, width, focusHeight])
      .style("display", "block");

  const brush = d3.brushX()
      .extent([[margin.left, 0.5], [width - margin.right, focusHeight - margin.bottom + 0.5]])
      .on("brush", brushed)
      .on("end", brushended);

  const defaultSelection = [x(d3.utcYear.offset(x.domain()[1], -1)), x.range()[1]];

  svg.append("g")
      .call(xAxis, x, focusHeight);

  svg.append("path")
      .datum(data)
      .attr("fill", "steelblue")
      .attr("d", area(x, y.copy().range([focusHeight - margin.bottom, 4])));

  const gb = svg.append("g")
      .call(brush)
      .call(brush.move, defaultSelection);

  function brushed({selection}) {
    if (selection) {
      svg.property("value", selection.map(x.invert, x).map(d3.utcDay.round));
      svg.dispatch("input");
    }
  }

  function brushended({selection}) {
    if (!selection) {
      gb.call(brush.move, defaultSelection);
    }
  }

  return svg.node();
}

update = {
  const [minX, maxX] = focus;
  const maxY = d3.max(data, d => minX <= d.date && d.date <= maxX ? d.value : NaN);
  chart.update(x.copy().domain(focus), y.copy().domain([0, maxY]));
}

area = (x, y) => d3.area()
    .defined(d => !isNaN(d.value))
    .x(d => x(d.date))
    .y0(y(0))
    .y1(d => y(d.value))
    
x = d3.scaleUtc()
    .domain(d3.extent(data, d => d.date))
    .range([margin.left, width - margin.right])
    
y = d3.scaleLinear()
    .domain([0, d3.max(data, d => d.value)])
    .range([height - margin.bottom, margin.top])
    
xAxis = (g, x, height) => g
    .attr("transform", `translate(0,${height - margin.bottom})`)
    .call(d3.axisBottom(x).ticks(width / 80).tickSizeOuter(0))
    
yAxis = (g, y, title) => g
    .attr("transform", `translate(${margin.left},0)`)
    .call(d3.axisLeft(y))
    .call(g => g.select(".domain").remove())
    .call(g => g.selectAll(".title").data([title]).join("text")
        .attr("class", "title")
        .attr("x", -margin.left)
        .attr("y", 10)
        .attr("fill", "currentColor")
        .attr("text-anchor", "start")
        .text(title))
        
margin = ({top: 20, right: 20, bottom: 30, left: 40})
height = 440
focusHeight = 100

Downloaded observable javascript file

// https://observablehq.com/@d3/focus-context@326
export default function define(runtime, observer) {
  const main = runtime.module();
  const fileAttachments = new Map([["aapl.csv",new URL("./files/de259092d525c13bd10926eaf7add45b15f2771a8b39bc541a5bba1e0206add4880eb1d876be8df469328a85243b7d813a91feb8cc4966de582dc02e5f8609b7",import.meta.url)]]);
  main.builtin("FileAttachment", runtime.fileAttachments(name => fileAttachments.get(name)));
  main.variable(observer()).define(["md"], function(md){return(
md`# Focus + Context

This [area chart](/@d3/area-chart) uses brushing to specify a focused area. Drag the gray region to pan, or brush to zoom. Compare to a [zoomable chart](/@d3/zoomable-area-chart). Data: [Yahoo Finance](https://finance.yahoo.com/lookup)`
)});
  main.variable(observer("chart")).define("chart", ["d3","width","height","DOM","margin","data","xAxis","yAxis","area"], function(d3,width,height,DOM,margin,data,xAxis,yAxis,area)
{
  const svg = d3.create("svg")
      .attr("viewBox", [0, 0, width, height])
      .style("display", "block");

  const clipId = DOM.uid("clip");

  svg.append("clipPath")
      .attr("id", clipId.id)
    .append("rect")
      .attr("x", margin.left)
      .attr("y", 0)
      .attr("height", height)
      .attr("width", width - margin.left - margin.right);

  const gx = svg.append("g");

  const gy = svg.append("g");

  const path = svg.append("path")
      .datum(data)
      .attr("clip-path", clipId)
      .attr("fill", "steelblue");

  return Object.assign(svg.node(), {
    update(focusX, focusY) {
      gx.call(xAxis, focusX, height);
      gy.call(yAxis, focusY, data.y);
      path.attr("d", area(focusX, focusY));
    }
  });
}
);
  main.variable(observer("viewof focus")).define("viewof focus", ["d3","width","focusHeight","margin","x","xAxis","data","area","y"], function(d3,width,focusHeight,margin,x,xAxis,data,area,y)
{
  const svg = d3.create("svg")
      .attr("viewBox", [0, 0, width, focusHeight])
      .style("display", "block");

  const brush = d3.brushX()
      .extent([[margin.left, 0.5], [width - margin.right, focusHeight - margin.bottom + 0.5]])
      .on("brush", brushed)
      .on("end", brushended);

  const defaultSelection = [x(d3.utcYear.offset(x.domain()[1], -1)), x.range()[1]];

  svg.append("g")
      .call(xAxis, x, focusHeight);

  svg.append("path")
      .datum(data)
      .attr("fill", "steelblue")
      .attr("d", area(x, y.copy().range([focusHeight - margin.bottom, 4])));

  const gb = svg.append("g")
      .call(brush)
      .call(brush.move, defaultSelection);

  function brushed({selection}) {
    if (selection) {
      svg.property("value", selection.map(x.invert, x).map(d3.utcDay.round));
      svg.dispatch("input");
    }
  }

  function brushended({selection}) {
    if (!selection) {
      gb.call(brush.move, defaultSelection);
    }
  }

  return svg.node();
}
);
  main.variable(observer("focus")).define("focus", ["Generators", "viewof focus"], (G, _) => G.input(_));
  main.variable(observer("update")).define("update", ["focus","d3","data","chart","x","y"], function(focus,d3,data,chart,x,y)
{
  const [minX, maxX] = focus;
  const maxY = d3.max(data, d => minX <= d.date && d.date <= maxX ? d.value : NaN);
  chart.update(x.copy().domain(focus), y.copy().domain([0, maxY]));
}
);
  main.variable(observer("data")).define("data", ["d3","FileAttachment"], async function(d3,FileAttachment){return(
Object.assign(d3.csvParse(await FileAttachment("aapl.csv").text(), d3.autoType).map(({date, close}) => ({date, value: close})), {y: "↑ Close $"})
)});
  main.variable(observer("area")).define("area", ["d3"], function(d3){return(
(x, y) => d3.area()
    .defined(d => !isNaN(d.value))
    .x(d => x(d.date))
    .y0(y(0))
    .y1(d => y(d.value))
)});
  main.variable(observer("x")).define("x", ["d3","data","margin","width"], function(d3,data,margin,width){return(
d3.scaleUtc()
    .domain(d3.extent(data, d => d.date))
    .range([margin.left, width - margin.right])
)});
  main.variable(observer("y")).define("y", ["d3","data","height","margin"], function(d3,data,height,margin){return(
d3.scaleLinear()
    .domain([0, d3.max(data, d => d.value)])
    .range([height - margin.bottom, margin.top])
)});
  main.variable(observer("xAxis")).define("xAxis", ["margin","d3","width"], function(margin,d3,width){return(
(g, x, height) => g
    .attr("transform", `translate(0,${height - margin.bottom})`)
    .call(d3.axisBottom(x).ticks(width / 80).tickSizeOuter(0))
)});
  main.variable(observer("yAxis")).define("yAxis", ["margin","d3"], function(margin,d3){return(
(g, y, title) => g
    .attr("transform", `translate(${margin.left},0)`)
    .call(d3.axisLeft(y))
    .call(g => g.select(".domain").remove())
    .call(g => g.selectAll(".title").data([title]).join("text")
        .attr("class", "title")
        .attr("x", -margin.left)
        .attr("y", 10)
        .attr("fill", "currentColor")
        .attr("text-anchor", "start")
        .text(title))
)});
  main.variable(observer("margin")).define("margin", function(){return(
{top: 20, right: 20, bottom: 30, left: 40}
)});
  main.variable(observer("height")).define("height", function(){return(
440
)});
  main.variable(observer("focusHeight")).define("focusHeight", function(){return(
100
)});
  main.variable(observer("d3")).define("d3", ["require"], function(require){return(
require("d3@6")
)});
  return main;
}

2 Answers

It is tricky, as the functions are observables and the viewof is returning a view of the brush (the min and max)

Here is a working fiddle



/* new data */
var x = d3.timeDays(new Date(2015, 06, 01), new Date(2020, 10, 30));
var y = Array.from({length: x.length}, Math.random).map(n => Math.floor(n * 10) + 5);
var data = x.map((v, i) => {
  return {
    "date": v,
    "value": y[i]
  }
});

var margin = {top: 20, right: 20, bottom: 30, left: 40}
var height = 440;
var width = 600;
var focusHeight = 100;

var focusedArea = d3.extent(x);

  const svg = d3.select('#my_chart').append("svg")
      .attr("viewBox", [0, 0, width, height])
      .style("display", "block");

  const clipId = {id: "clip"};

  const clip = svg.append("clipPath")
      .attr("id", clipId.id)
    .append("rect")
      .attr("x", margin.left)
      .attr("y", 0)
      .attr("height", height)
      .attr("width", width - margin.left - margin.right);

  const gx = svg.append("g");

  const gy = svg.append("g");

  const path = svg.append("path")
      .datum(data)
      .attr("clip-path", `url(#${clipId.id})`)
      .attr("fill", "steelblue");

  const updateChart = (focusX, focusY) => {
      gx.call(xAxis, focusX, height);
      gy.call(yAxis, focusY, data.y);
      path.attr("d", area(focusX, focusY));
    };


/* begin observable code */
var focus = () => {
  const svg = d3.select("#focus").append("svg")
      .attr("viewBox", [0, 0, width, focusHeight])
      .style("display", "block");
  const brush = d3.brushX()
      .extent([[margin.left, 0.5], [width - margin.right, focusHeight - margin.bottom + 0.5]])
      .on("brush", brushed)
      .on("end", brushended);

  const defaultSelection = [x(d3.utcYear.offset(x.domain()[1], -1)), x.range()[1]];

  svg.append("g")
      .call(xAxis, x, focusHeight);

  svg.append("path")
      .datum(data)
      .attr("fill", "steelblue")
      .attr("d", area(x, y.copy().range([focusHeight - margin.bottom, 4])));

  const gb = svg.append("g")
      .call(brush)
      .call(brush.move, defaultSelection);

  function brushed({selection}) {
    if (selection) {
      svg.property("value", selection.map(x.invert, x).map(d3.utcDay.round));
      svg.dispatch("input");
        focusedArea = svg.property('value');
        update();
    }
  }

  function brushended({selection}) {
    if (!selection) {
      gb.call(brush.move, defaultSelection);
    }
  }
  return svg.node();
}

var update = function() {
  const [minX, maxX] = focusedArea;
  const maxY = d3.max(data, d => minX <= d.date && d.date <= maxX ? d.value : NaN);
  updateChart(x.copy().domain(focusedArea), y.copy().domain([0, maxY]));
}

var area = (x, y) => d3.area()
    .defined(d => !isNaN(d.value))
    .x(d => x(d.date))
    .y0(y(0))
    .y1(d => y(d.value))
    
var x = d3.scaleUtc()
    .domain(d3.extent(data, d => d.date))
    .range([margin.left, width - margin.right])
    
var y = d3.scaleLinear()
    .domain([0, d3.max(data, d => d.value)])
    .range([height - margin.bottom, margin.top])
    
var xAxis = (g, x, height) => g
    .attr("transform", `translate(0,${height - margin.bottom})`)
    .call(d3.axisBottom(x).ticks(width / 80).tickSizeOuter(0))
    
var yAxis = (g, y, title) => g
    .attr("transform", `translate(${margin.left},0)`)
    .call(d3.axisLeft(y))
    .call(g => g.select(".domain").remove())
    .call(g => g.selectAll(".title").data([title]).join("text")
        .attr("class", "title")
        .attr("x", -margin.left)
        .attr("y", 10)
        .attr("fill", "currentColor")
        .attr("text-anchor", "start")
        .text(title))
        



focus();

One option is per steps below - the output is in the snippet below and works equally as well in jsfiddle.

  1. Small updates to data - see the comments, mainly use value not close
  2. Bring in the Observable cell values outside of focus and chart first and include width - I've used the code in the update block later on in a function
  3. Main change to chart is to remove the return section and handle the chart rendering in chartUpdate function which is called from the brush handler.
  4. focus does not need to be a function returning svg.node() - this is perfectly valid but leaves the need to infer what it means. The brush start and end dates can be passed straight to the chartUpdate without needing to store the value in the svg.property and broadcast it with dispatch etc (although this is part of what the Observable is trying to demo)
  5. chartUdpate is a simple lift of the update logic out of the return statement in chart where it accepts the scaling per the date range in the brush.

// data
// Changes
// 1. use utcDays because scale is scaleUtc
// 2. rename x and y to xData and yData as x and y are scales
// 3. return 'value' not 'close'
// 4. add the y property for the axis label
const xData = d3.utcDays(new Date(2010, 06, 01), new Date(2020, 10, 30));
var yData = Array.from({length: xData.length}, Math.random).map(n => Math.floor(n * 10) + 5);
var data = xData.map((v, i) => {
  return {
    "date": v,
    "value": yData[i]
  }
});
Object.assign(data, {y: "↑ Close $"});

// lift from Observable - neither the focus or chart cells
// Changes
// 1. add width as Observable has it as a built-in
// 2. remove the update block
const height = 440;
const focusHeight = 100;
const margin = {top: 20, right: 20, bottom: 30, left: 40}
// add width 
const width = 600;

const xAxis = (g, x, height) => g
  .attr("transform", `translate(0,${height - margin.bottom})`)
  .call(d3.axisBottom(x).ticks(width / 80).tickSizeOuter(0));

const yAxis = (g, y, title) => g
  .attr("transform", `translate(${margin.left},0)`)
  .call(d3.axisLeft(y))
  .call(g => g.select(".domain").remove())
  .call(g => g.selectAll(".title").data([title]).join("text")
    .attr("class", "title")
    .attr("x", -margin.left)
    .attr("y", 10)
    .attr("fill", "currentColor")
    .attr("text-anchor", "start")
    .text(title));

const x = d3.scaleUtc()
  .domain(d3.extent(data, d => d.date))
  .range([margin.left, width - margin.right]);

const y = d3.scaleLinear()
  .domain([0, d3.max(data, d => d.value)])
  .range([height - margin.bottom, margin.top]);
  
const area = (x, y) => d3.area()
  .defined(d => !isNaN(d.value))
  .x(d => x(d.date))
  .y0(y(0))
  .y1(d => y(d.value));
  
// Lift from chart block (first)
// Changes
// 1. need select, append not create
// 2. define a clipPath, discard the use of clipId
// 3. reference the clipPath in chartPth
// 4. rename some vars to specify they are for the chart
// 5. the logic in the return statement deferred to later
const svgChart = d3.select("#viz")
  .append("svg")
  .attr("viewBox", [0, 0, width, height])
  .style("display", "block");
  
// replace clipId and use clippath 
svgChart.append("defs")
  .append("clipPath")
  .attr("id", "clip")
  .append("rect")
  .attr("x", margin.left)
  .attr("width", width - margin.left - margin.right)
  .attr("height", height);
    
const chartGx = svgChart.append("g");
const chartGy = svgChart.append("g");

// refer to the clippath
const chartPath = svgChart.append("path")
  .datum(data)
  .attr("clip-path", "url(#clip)")
  .attr("fill", "steelblue");
      
// lift from focus block (second)
// Changes
// 1. need select, append not create
// 2. change brushed function; don't use dispatch
// 3. svg.node() becomes svg.node().value (as in data 'value')
// 4. chartUpdate is new function with logic from chart cell
const svgFocus = d3.select("#viz")
  .append("svg")
  .attr("viewBox", [0, 0, width, focusHeight])
  .style("display", "block");

const brush = d3.brushX()
  .extent([[margin.left, 0.5], [width - margin.right, focusHeight - margin.bottom + 0.5]])
  .on("brush", brushed)
  .on("end", brushended);
  
const defaultSelection = [x(d3.utcYear.offset(x.domain()[1], -1)), x.range()[1]];

svgFocus.append("g")
  .call(xAxis, x, focusHeight);

svgFocus.append("path")
  .datum(data)
  .attr("fill", "steelblue")
  .attr("d", area(x, y.copy().range([focusHeight - margin.bottom, 4])));

const gb = svgFocus.append("g")
  .call(brush)
  .call(brush.move, defaultSelection);

// update brushed to sync chart with focus
function brushed({selection}) {
  if (selection) {
    // update logic goes here - to get args for chartUpdate
    const [minX, maxX] = selection.map(x.invert, x).map(d3.utcDay.round);
    const maxY = d3.max(data, d => minX <= d.date && d.date <= maxX ? d.value : NaN);
    const focusX = x.copy().domain([minX, maxX]);
    const focusY = y.copy().domain([0, maxY]);
    
    // call chart update
    chartUpdate(focusX, focusY);
  } 
}

function brushended({selection}) {
  if (!selection) {
    gb.call(brush.move, defaultSelection);
  }
}

// chart  update
function chartUpdate(focusX, focusY) {
  chartGx.call(xAxis, focusX, height);
  chartGy.call(yAxis, focusY, data.y);
  chartPath.attr("d", area(focusX, focusY));
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.3.1/d3.min.js"></script>
<div id="viz"></div>

Related