Radial Ridgeline Chart

A month of a solar array's output, one ring a day: midnight at the bottom, noon at the top, so a sunny day swells out over the rings after it and a passing cloud leaves a dent.

  • Try it:
  • Point at a ring to read its output
  • Compare July with December
Get the code JavaScript JSON
About this chart

When a radial ridgeline works

A ridgeline stacks many curves of the same kind, each lifted off a baseline of its own, so they can overlap without hiding each other's shape. Bent into rings, it suits anything that runs round a clock: each day's hours sit at the same angles on every ring, so a whole month of daily patterns, and the days that break them, show in one view.

Good for

  • Daily patterns: energy, traffic, visits by hour
  • Many similar curves where the shape matters
  • Spotting the days that break a pattern

Think twice when

  • Exact values: a line chart is easier to read
  • A few series: plain lines are clearer
  • Data with no daily or yearly cycle
For developers

Get the code.

Built with amCharts 5, a JavaScript library. The JavaScript includes your theme and renderer (canvas or SVG); the JSON also follows the changes you make with the controls.

// Create root element
// https://www.amcharts.com/docs/v5/getting-started/#Root_element
var root = am5.Root.new("chartdiv");

// Set themes
// https://www.amcharts.com/docs/v5/concepts/themes/
root.setThemes([
  am5themes_Animated.new(root),
  am5themes_Responsive.new(root)
]);


// ── Sample data: a month of output from a 10 kW solar array at 48° north (Munich, Paris), every 15 minutes ──

var CAPACITY = 10; // the array's top output, kW
var STEPS = 96;    // fifteen-minute steps a day

// A seeded random number generator (mulberry32): the same clouds on every visit
function makeRandom(seed) {
  return function() {
    seed = seed + 0x6D2B79F5 | 0;
    var t = Math.imul(seed ^ seed >>> 15, 1 | seed);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}

// One day's output, kW, every 15 minutes: the sun's path on that date, dented by the day's clouds
function makeDay(date, random) {
  // the day's length and how high the sun gets at noon follow the date
  var dayOfYear = (date - new Date(date.getFullYear(), 0, 0)) / 864e5;
  var declination = 23.44 * Math.sin(2 * Math.PI * (284 + dayOfYear) / 365) * Math.PI / 180;
  var latitude = 48 * Math.PI / 180;
  var halfDay = Math.acos(-Math.tan(latitude) * Math.tan(declination)) * 12 / Math.PI; // hours from sunrise to noon
  var noon = 12.4; // the sun is highest a little after 12:00 on the clock
  var rise = noon - halfDay;
  var set = noon + halfDay;
  // the share of the capacity a clear noon gives: all of it in June, about a third in December
  var peak = Math.min(1, Math.sin(Math.PI / 2 - latitude + declination) / Math.sin(62 * Math.PI / 180));

  // the sky: half the days clear, a third broken, the rest grey
  var r = random();
  var sky = r < 0.5 ? 0.92 + 0.08 * random() : r < 0.82 ? 0.7 + 0.2 * random() : 0.2 + 0.3 * random();
  // passing clouds, each a dent in the curve: light ones on a clear day, deeper on the others
  var clouds = [];
  var count = sky > 0.9 ? 1 + Math.floor(random() * 2) : 2 + Math.floor(random() * 4);
  for (var i = 0; i < count; i++) {
    clouds.push({
      hour: rise + 1.2 + random() * (set - rise - 2.4), // when it passes
      width: 0.25 + random() * 0.6,                     // how long it takes, hours
      depth: sky > 0.9 ? 0.15 + random() * 0.3 : 0.3 + random() * 0.5 // the share of light it takes away
    });
  }

  var output = [];
  for (var step = 0; step < STEPS; step++) {
    var hour = step * 24 / STEPS;
    // a clear day's curve: zero at night, rising to the noon peak
    var clear = hour > rise && hour < set ? Math.pow(Math.sin(Math.PI * (hour - rise) / (set - rise)), 1.25) * peak : 0;
    var light = sky;
    clouds.forEach(function(cloud) {
      light *= 1 - cloud.depth * Math.exp(-Math.pow((hour - cloud.hour) / cloud.width, 2));
    });
    output.push(CAPACITY * clear * light);
  }
  return output;
}

// A month of days (month 0 is January): each with its date and its output
function makeMonth(year, month) {
  var random = makeRandom(year * 100 + month); // each month has clouds of its own
  var days = [];
  var count = new Date(year, month + 1, 0).getDate(); // the month's last day
  for (var day = 1; day <= count; day++) {
    var date = new Date(year, month, day);
    days.push({ date: date, output: makeDay(date, random) });
  }
  return days;
}


// ── The chart: one ring a day ──

// Create chart
// https://www.amcharts.com/docs/v5/charts/radar-chart/
var chart = root.container.children.push(am5radar.RadarChart.new(root, {
  panX: false,    // no dragging...
  panY: false,
  wheelX: "none", // ...or zooming with the wheel
  wheelY: "none",
  startAngle: 90, // midnight at the bottom...
  endAngle: 450,  // ...and the full circle round, so noon is at the top
  radius: am5.percent(92),      // most of the space, leaving just room for the hour labels
  innerRadius: am5.percent(25), // the first days get room to swell, clear of the middle
  maxTooltipDistance: 1 // only the tooltip of the line nearest the pointer, not one for every day
}));

// Create axes
// https://www.amcharts.com/docs/v5/charts/radar-chart/radar-axes/
// The hours of the day round the circle: a date axis over one day. Every ring takes its times on that same day,
// so 13:00 is at the same angle on every ring
var DAY = new Date(2026, 0, 1).getTime(); // any day without a clock change
var xRenderer = am5radar.AxisRendererCircular.new(root, {
  minGridDistance: 40 // labels at least 40px apart round the rim
});
xRenderer.labels.template.setAll({
  multiLocation: 0,    // each hour at its spoke, not between two
  textType: "regular", // upright, not turned along the circle
  radius: 16,          // out from the rim, clear of the highest noon
  maxPosition: 0.99    // the day's end is its start: one 00:00, not two on top of each other
});
xRenderer.grid.template.setAll({
  strokeOpacity: 0.08 // faint spokes, so they don't cut through the lines
});
var xAxis = chart.xAxes.push(am5xy.DateAxis.new(root, {
  baseInterval: { timeUnit: "minute", count: 15 }, // the data comes every 15 minutes
  gridIntervals: [{ timeUnit: "hour", count: 3 }], // a spoke every three hours
  min: DAY,
  max: DAY + 864e5, // midnight to midnight
  strictMinMax: true,
  markUnitChange: false, // midnight shows 00:00 like every other hour, not the date of a new day
  renderer: xRenderer
}));

// The rings go out from the middle, one value unit a day. The values place the lines but mean nothing to a reader,
// so the axis shows no labels or grid
var yRenderer = am5radar.AxisRendererRadial.new(root, {});
yRenderer.labels.template.set("forceHidden", true);
yRenderer.grid.template.set("forceHidden", true);
var yAxis = chart.yAxes.push(am5xy.ValueAxis.new(root, {
  min: 0,             // the first day's ring is one unit out from the hole
  strictMinMax: true, // the rim is the highest noon, not rounded up to a round number
  renderer: yRenderer
}));

// Create series
// https://www.amcharts.com/docs/v5/charts/radar-chart/radar-series/
// One line a day, the first day nearest the middle and the last at the rim. A day's output lifts its line off its
// own ring, so a sunny noon swells out over the rings after it: a radial ridgeline. Thirty-one lines, enough for
// any month; a shorter month leaves the last one empty. Each takes the next color, from the middle out
var ridgeHeight = 10;   // a day at full capacity swells out over this many rings
var areaOpacity = 0.08; // the area between each line and its ring
var lines = [];
for (var i = 0; i < 31; i++) {
  var series = chart.series.push(am5radar.SmoothedRadarLineSeries.new(root, {
    xAxis: xAxis,
    yAxis: yAxis,
    valueXField: "time",
    valueYField: "value",
    openValueYField: "ring", // the area runs from the line down to the day's ring, not to the middle
    tooltip: am5.Tooltip.new(root, {
      // the day (the series' name), the time and the output then
      labelText: "{name}, {valueX.formatDate('HH:mm')}: [bold]{power} kW[/]"
    })
  }));
  series.strokes.template.setAll({
    strokeWidth: 1.5, // thin, so thirty noons can cross and still show their shapes (a 1px line is too thin to point at)
    interactive: true // the line takes the pointer, for its hover state
  });
  // the line under the pointer stands out: thicker, which also makes it easier to stay on
  series.strokes.template.states.create("hover", {
    strokeWidth: 3
  });
  series.fills.template.setAll({
    visible: true,
    fillOpacity: areaOpacity
  });
  lines.push(series);
}

// Add cursor
// https://www.amcharts.com/docs/v5/charts/xy-chart/cursor/
// A spoke and a circle follow the pointer, and the tooltip of the day nearest to it shows
var cursor = chart.set("cursor", am5radar.RadarCursor.new(root, {}));


// ── Drawing a month ──

var days = []; // the month shown, from makeMonth()

// Each line's points: the day's ring plus the output scaled to the ridge height. The times are on the axis's day
function lineData(index, day) {
  return day.output.map(function(kW, step) {
    return {
      time: DAY + step * 9e5, // 15 minutes apart
      value: Math.round((index + 1 + ridgeHeight * kW / CAPACITY) * 100) / 100, // two decimals are plenty to draw
      ring: index + 1,
      power: Math.round(kW * 10) / 10 // for the tooltip
    };
  });
}

function drawLines() {
  lines.forEach(function(series, index) {
    var day = days[index];
    series.data.setAll(day ? lineData(index, day) : []); // no day 31 in a 30-day month
    series.set("name", day ? root.dateFormatter.format(day.date, "MMM d") : ""); // the tooltip's date
  });
}

// Shows a month, 0 for January to 11 for December
function showMonth(month) {
  days = makeMonth(2026, month);
  drawLines();
}

showMonth(6); // July

// Make stuff animate on load
// https://www.amcharts.com/docs/v5/concepts/animations/
chart.appear(1000, 100);