How to display linear regression line on Chart component

I have a resource query that returns a time series of data (put simply a week in YYYY-MM-DD, and a metric). I need to display this data on a chart, which I've done, but I cannot see a way to display a trendline or calculate linear regression for the data series.

I searched the Plotly documentation, and couldn't find any reference to either "trend" or "regression".

I worked out how to do this using a Custom Component and d3.js, example below (my data being a series of weekEnding with metrics numberOfVisits, searches and uniqueContentNames):

<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://d3js.org/d3.v6.js"></script>
<div id="react"></div>

<script type="text/babel">
  const MyCustomComponent = ({ model }) => {
  const ref = React.useRef();

  React.useEffect(() => {
    
    const colours = d3.scaleOrdinal(d3.schemeCategory10);
    const colourNumVisits = colours(0)
    const colourSearches = colours(1)
    const colourContentNames = colours(2)
    const colourTrend = colours(3)
    
    const data = model.weekEnding.map((week, i) => {
      return {
        weekEnding: new Date(week),
        numberOfVisits: model.numberOfVisits[i]
      };
    });
    
    const dataVisits = model.weekEnding.map((week, i) => {
      return {
        weekEnding: new Date(week),
        value: model.numberOfVisits[i]
      };
    });

    const dataSearches = model.weekEnding.map((week, i) => {
      return {
        weekEnding: new Date(week),
        value: model.searches[i]
      };
    });

    const dataUniqueContentNames = model.weekEnding.map((week, i) => {
      return {
        weekEnding: new Date(week),
        value: model.uniqueContentNames[i]
      };
    });
    
    const allData = [...dataVisits, ...dataSearches, ...dataUniqueContentNames];

    
    const svg = d3.select(ref.current).attr("viewBox", [0, 0, width, height]);
    
    const margin = {top: 20, right: 50, bottom: 50, left: 40}, // Increase right margin
    width = 960 - margin.left - margin.right,
    height = 500 - margin.top - margin.bottom;
    
    svg.attr("viewBox", [0, 0, width, height]);
    
    const x = d3.scaleTime()
      .domain(d3.extent(allData, d => d.weekEnding))
      .range([margin.left, width - margin.right])

    const y = d3.scaleLinear()
      .domain([0, d3.max(allData, d => d.value)]).nice()
      .range([height - margin.bottom - 10, margin.top]) // Adjust y-scale range

    // Update x-axis
    const xAxis = g => g
      .attr("transform", `translate(0,${height - margin.bottom -10})`) // Move x-axis up
      .call(d3.axisBottom(x).tickFormat(d3.timeFormat('%Y-%m-%d')).tickValues(allData.map(d => d.weekEnding)))
      .call(g => g.selectAll(".tick text")
        .attr("font-family", "sans-serif")
        .attr("font-size", 10)
        .style("text-anchor", "end")
        .attr("dx", "-.8em")
        .attr("dy", ".15em")
        .attr("transform", "rotate(-65)")
      )
      .call(g => g.select(".domain").style("stroke", "currentColor")); // Add the x-axis line

    const yAxis = g => g
      .attr("transform", `translate(${margin.left},0)`)
      .call(d3.axisLeft(y))
      .call(g => g.select(".domain").remove())
      .call(g => g.select(".tick:last-of-type text").clone()
        .attr("x", 3)
        .attr("text-anchor", "start")
        .attr("font-weight", "bold")
        .attr("font-size", 10)
        .text(allData.y))

    // Adjust line generation function    
    const lineVisits = d3.line()
      .defined(d => !isNaN(d.value))
      .x(d => x(d.weekEnding))
      .y(d => y(d.value))

    const lineSearches = d3.line()
      .defined(d => !isNaN(d.value))
      .x(d => x(d.weekEnding))
      .y(d => y(d.value))

    const lineUniqueContentNames = d3.line()
      .defined(d => !isNaN(d.value))
      .x(d => x(d.weekEnding))
      .y(d => y(d.value))
    

    // Draw axis
    svg.append("g").call(xAxis);
    svg.append("g").call(yAxis);

    
    // Draw data series
    svg.append("path")
      .datum(dataVisits)
      .attr("fill", "none")
      .attr("stroke", colourNumVisits)
      .attr("stroke-width", 1.5)
      .attr("stroke-linejoin", "round")
      .attr("stroke-linecap", "round")
      .attr("d", lineVisits);

    svg.append("path")
      .datum(dataSearches)
      .attr("fill", "none")
      .attr("stroke", colourSearches)
      .attr("stroke-width", 1.5)
      .attr("stroke-linejoin", "round")
      .attr("stroke-linecap", "round")
      .attr("d", lineSearches);

    svg.append("path")
      .datum(dataUniqueContentNames)
      .attr("fill", "none")
      .attr("stroke", colourContentNames)
      .attr("stroke-width", 1.5)
      .attr("stroke-linejoin", "round")
      .attr("stroke-linecap", "round")
      .attr("d", lineUniqueContentNames);
    
    const legend = svg.append("g")
      .attr("class", "legend")
      .attr("x", width - 65)
      .attr("y", 25)
      .attr("height", 100)
      .attr("width", 100);

    legend.append("rect")
      .attr("x", width - 150)
      .attr("y", 50)
      .attr("width", 10)
      .attr("height", 10)
      .style("fill", colourSearches);
    
    legend.append("rect")
      .attr("x", width - 150)
      .attr("y", 25)
      .attr("width", 10)
      .attr("height", 10)
      .style("fill", colourNumVisits);
    
     legend.append("rect")
      .attr("x", width - 150)
      .attr("y", 75)
      .attr("width", 10)
      .attr("height", 10)
      .style("fill", colourContentNames);
    
    legend.append("rect")
      .attr("x", width - 150)
      .attr("y", 105)
      .attr("width", 10)
      .attr("height",1.5)
      .style("fill", colourTrend);


    legend.append("text")
      .attr("x", width - 130)
      .attr("y", 35)
      .style("font-family", "sans-serif") 
      .style("font-size", "11px") 
      .text("Number of Visits");
    
    legend.append("text")
      .attr("x", width - 130)
      .attr("y", 59)
      .style("font-family", "sans-serif") 
      .style("font-size", "11px") 
      .text("Number of Searches");
    
    legend.append("text")
      .attr("x", width - 130)
      .attr("y", 85)
      .style("font-family", "sans-serif") 
      .style("font-size", "11px") 
      .text("# Unique Content Names");
    
    legend.append("text")
      .attr("x", width - 130)
      .attr("y", 110)
      .style("font-family", "sans-serif") 
      .style("font-size", "11px") 
      .text("Number of Visits trend");
    
    const sums = dataVisits.reduce((acc, val) => {
        acc.x += val.weekEnding.getTime();
        acc.y += val.value;
        acc.xy += val.weekEnding.getTime() * val.value;
        acc.xx += val.weekEnding.getTime() * val.weekEnding.getTime();
        return acc;
    }, {x: 0, y: 0, xy: 0, xx: 0});

    const count = dataVisits.length;
    const slope = (count * sums.xy - sums.x * sums.y) / (count * sums.xx - sums.x * sums.x);
    const intercept = (sums.y - slope * sums.x) / count;

    // Create the line generator for the linear regression line
    const regLine = d3.line()
        .x(d => x(d.weekEnding))
        .y(d => y(slope * d.weekEnding.getTime() + intercept));

    // Append the path for the linear regression line
    svg.append("path")
      .datum(allData)
      .attr("fill", "none")
      .attr("stroke", colourTrend)
      .attr("stroke-width", 1.5)
      .attr("stroke-linejoin", "round")
      .attr("stroke-linecap", "round")
      .attr("d", regLine);
    
  }, []);

  return <svg ref={ref} />;
}


  const ConnectedComponent = Retool.connectReactComponent(MyCustomComponent)
  const container = document.getElementById('react')
  const root = ReactDOM.createRoot(container)
  root.render(<ConnectedComponent />)
</script>

Which results in something like the attached.

3 Likes

Hi @Nick_Myers Thanks so much for sharing your solution! Looks great :sunglasses: