The same project plan wound into a spiral: time starts in the middle and turns outward, two laps from January to early May. Each module keeps its lane and color all the way round.
A spiral fits a long timeline into a square. Time runs at an even pace along the path, so a week is as long on the outer laps as on the inner ones, and the outer laps simply hold more of it. It catches the eye, but dates take longer to find than on a straight line.
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.
npm install @amcharts/amcharts5. See Getting started.
The same chart as a JSON config. Chart Maker reads and writes this format, the library's JSON parser turns it into a chart, and the <am5-chart> element puts it on a page with no code."type" is the class to create and "settings" what you would pass to its new() in code; "properties" fill in its parts, lists such as series and axes or templates such as columns.template; "refs" holds objects used in more than one place ("#series-0" stands for the one named series-0); a string starting with "@" takes a value from another object, such as "@series.get('fill')".
A complete page. Save it as an .html file and open it in a browser.
Paste this into an AI coding assistant to build the chart from scratch. More in amCharts & AI.
var root = am5.Root.new("chartdiv");
// Set themes
// https://www.amcharts.com/docs/v5/concepts/themes/
var responsive = am5themes_Responsive.new(root); // kept in a variable: a rule of its own is added below
root.setThemes([
am5themes_Animated.new(root),
responsive
]);
// Create chart
var chart = root.container.children.push(am5timeline.SpiralChart.new(root, {
// the timeline winds round twice
levelCount: 2,
// How much of each turn the lanes take
yAxisRadius: am5.percent(90),
wheelY: "zoomX", // the mouse wheel zooms in on the dates
// The legend goes to the right of the spiral
layout: root.horizontalLayout
}));
// The scrollbar starts hidden
chart.set("scrollbarX", am5.Scrollbar.new(root, {
orientation: "horizontal", // zooms the dates when shown
forceHidden: true
}));
var yRenderer = am5timeline.AxisRendererCurveY.new(root, {
// The dashed line with the dates runs along the middle of the third lane of six (the empty one)
axisLocation: 2.5 / 6
});
// The middle of the spiral has no room for lane names: the legend names the modules
yRenderer.labels.template.set("forceHidden", true);
yRenderer.grid.template.set("forceHidden", true); // no lines between the lanes
// Create axes and their renderers
var xRenderer = am5timeline.AxisRendererCurveX.new(root, {
yRenderer: yRenderer, // required: the date axis follows the lanes' curve
strokeDasharray: [2, 3], // a dashed line: 2px dashes, 3px gaps...
strokeOpacity: 0.5, // ...half see-through...
stroke: root.interfaceColors.get("alternativeBackground") // ...in the theme's text color
});
xRenderer.labels.template.setAll({
centerY: am5.p50, // date labels centered on the line
fontSize: 11, // small text
minPosition: 0.01 // hide a label at the very start, where it would be cut
});
// Date labels sit on the dashed line, with the chart's background color behind them
xRenderer.labels.template.setup = function(target) {
target.set("layer", 30); // drawn on layer 30, above everything without a layer
target.set("background", am5.Rectangle.new(root, {
fill: root.interfaceColors.get("background"),
fillOpacity: 1 // solid, hiding the dashed line under the text
}));
}
var yAxis = chart.yAxes.push(am5xy.CategoryAxis.new(root, {
maxDeviation: 0, // no panning past the first and last lane
categoryField: "category",
renderer: yRenderer
}));
var xAxis = chart.xAxes.push(am5xy.DateAxis.new(root, {
baseInterval: { timeUnit: "day", count: 1 }, // dates by the day
renderer: xRenderer,
tooltip: am5.Tooltip.new(root, {}) // shows the cursor's date on the axis
}));
// Data: each module has its own color, used by its bars and their end dots
var colorSet = chart.get("colors");
var module1 = { fill: colorSet.getIndex(0) };
var module2 = { fill: colorSet.getIndex(3) };
var module3 = { fill: colorSet.getIndex(5) };
var module4 = { fill: colorSet.getIndex(8) };
var data = [{
"category": "Module #1",
"start": new Date("2026-01-15").getTime(),
"end": new Date("2026-01-18").getTime(),
"settings": module1,
"task": "Gathering requirements"
}, {
"category": "Module #1",
"start": new Date("2026-02-10").getTime(),
"end": new Date("2026-04-23").getTime(),
"settings": module1,
"task": "Development"
}, {
"category": "Module #2",
"start": new Date("2026-01-13").getTime(),
"end": new Date("2026-01-15").getTime(),
"settings": module2,
"task": "Gathering requirements"
}, {
"category": "Module #2",
"start": new Date("2026-01-17").getTime(),
"end": new Date("2026-01-20").getTime(),
"settings": module2,
"task": "Producing specifications"
}, {
"category": "Module #2",
"start": new Date("2026-01-21").getTime(),
"end": new Date("2026-02-10").getTime(),
"settings": module2,
"task": "Development"
}, {
"category": "Module #2",
"start": new Date("2026-02-15").getTime(),
"end": new Date("2026-02-23").getTime(),
"settings": module2,
"task": "Testing and QA"
}, {
"category": "Module #3",
"start": new Date("2026-01-06").getTime(),
"end": new Date("2026-01-24").getTime(),
"settings": module3,
"task": "Gathering requirements"
}, {
"category": "Module #3",
"start": new Date("2026-02-06").getTime(),
"end": new Date("2026-02-15").getTime(),
"settings": module3,
"task": "Producing specifications"
}, {
"category": "Module #3",
"start": new Date("2026-03-15").getTime(),
"end": new Date("2026-04-20").getTime(),
"settings": module3,
"task": "Development"
}, {
"category": "Module #3",
"start": new Date("2026-04-25").getTime(),
"end": new Date("2026-05-05").getTime(),
"settings": module3,
"task": "Testing and QA"
}, {
"category": "Module #4",
"start": new Date("2026-01-20").getTime(),
"end": new Date("2026-02-17").getTime(),
"settings": module4,
"task": "Gathering requirements"
}, {
"category": "Module #4",
"start": new Date("2026-03-02").getTime(),
"end": new Date("2026-03-15").getTime(),
"settings": module4,
"task": "Development"
}, {
"category": "Module #4",
"start": new Date("2026-03-28").getTime(),
"end": new Date("2026-05-04").getTime(),
"settings": module4,
"task": "Testing and QA"
}];
// Add series
// https://www.amcharts.com/docs/v5/charts/xy-chart/series/
var series = chart.series.push(am5timeline.CurveColumnSeries.new(root, {
xAxis: xAxis,
yAxis: yAxis,
baseAxis: yAxis, // the lanes are the base: bars stretch along the dates
valueXField: "end", // each bar ends on its end date...
openValueXField: "start", // ...and starts on its start date
categoryYField: "category",
layer: 30 // drawn on layer 30, above the axes
}));
// Hovering a bar or one of its end dots shows the task and its dates
var tooltipText = "{category}: {task}\n{openValueX.formatDate('MMM d')} to {valueX.formatDate('MMM d')}";
series.columns.template.setAll({
height: am5.percent(25), // a bar takes a quarter of its lane's height
strokeOpacity: 0, // no outline
// The module's color comes from the data
templateField: "settings",
tooltipText: tooltipText
});
// A dot at the start and at the end of each bar, in the bar's color
series.bullets.push(function(root, series, dataItem) {
return am5.Bullet.new(root, {
locationX: 0, // at the bar's start
sprite: am5.Circle.new(root, {
radius: 4, // a 4px dot
templateField: "settings",
tooltipText: tooltipText,
layer: 30
})
});
});
series.bullets.push(function(root, series, dataItem) {
return am5.Bullet.new(root, {
locationX: 1, // at the bar's end
sprite: am5.Circle.new(root, {
radius: 4,
templateField: "settings",
tooltipText: tooltipText,
layer: 30
})
});
});
// Milestones: a lettered badge on its date, in a lane of their own
var lineSeries = chart.series.push(am5timeline.CurveLineSeries.new(root, {
xAxis: xAxis, yAxis: yAxis, categoryYField: "category", valueXField: "date"
}));
// only the badges show, no line joining them
lineSeries.strokes.template.set("forceHidden", true);
lineSeries.bullets.push(function(root, series, dataItem) {
return am5.Bullet.new(root, {
sprite: am5.Label.new(root, {
text: "{letter}",
populateText: true, // fill in {letter} from the data
fontSize: 9, // small text
fontWeight: "bold",
centerX: am5.p50, // centered on its date...
centerY: am5.p50, // ...and in the middle of its lane
paddingTop: 1, paddingBottom: 1, paddingLeft: 4, paddingRight: 4, // space around the letter
layer: 30, // on layer 30 with the bars, above the axes
background: am5.Rectangle.new(root, {
fill: root.interfaceColors.get("background"), // a badge in the background color...
stroke: root.interfaceColors.get("alternativeBackground") // ...with an outline in the text color
})
})
});
});
lineSeries.data.setAll([
{ category: "Milestones", date: new Date("2026-01-20").getTime(), letter: "A" },
{ category: "Milestones", date: new Date("2026-01-28").getTime(), letter: "B" },
{ category: "Milestones", date: new Date("2026-02-15").getTime(), letter: "C" },
{ category: "Milestones", date: new Date("2026-03-05").getTime(), letter: "D" },
{ category: "Milestones", date: new Date("2026-03-11").getTime(), letter: "E" },
{ category: "Milestones", date: new Date("2026-03-17").getTime(), letter: "F" },
{ category: "Milestones", date: new Date("2026-03-25").getTime(), letter: "G" }
]);
var cursor = chart.set("cursor", am5timeline.CurveCursor.new(root, {
behavior: "zoomX", // a drag along the spiral zooms in on those dates
xAxis: xAxis,
yAxis: yAxis
}));
series.data.setAll(data);
// Six lanes: the empty one keeps room for the date labels
yAxis.data.setAll([
{ category: "Module #1" },
{ category: "Module #2" },
{ category: "" },
{ category: "Milestones" },
{ category: "Module #3" },
{ category: "Module #4" }
]);
// Legend: one item per module, in its color
// https://www.amcharts.com/docs/v5/concepts/legend/
var legend = chart.children.push(am5.Legend.new(root, {
nameField: "name", // legend items from plain data: the name...
fillField: "color", // ...and the color, for the marker's fill...
strokeField: "color", // ...and its outline
// the legend only names the colors: clicking it does nothing
clickTarget: "none",
layout: root.verticalLayout, // one module per row
y: am5.p50, // halfway down...
centerY: am5.p50 // ...centered on its own middle
}));
legend.data.setAll([
{ name: "Module #1", color: module1.fill },
{ name: "Module #2", color: module2.fill },
{ name: "Module #3", color: module3.fill },
{ name: "Module #4", color: module4.fill }
]);
// On a narrow chart (a phone) a legend beside the spiral would take half its width: it goes below it,
// two modules a row. The rule is undone on a wider chart
var twoColumns = am5.GridLayout.new(root, { maxColumns: 2 });
responsive.addRule({
relevant: function (width) { return width < 600; }, // the chart is under 600px wide
applying: function () {
chart.set("layout", root.verticalLayout); // the legend under the spiral
legend.setAll({
layout: twoColumns, // two items a row
x: am5.p50, centerX: am5.p50, // centered across the chart
y: undefined, centerY: undefined, // placed by the chart's layout
marginTop: 10 // 10px below the spiral
});
// the empty value labels keep 50px each, which two items a row don't have to spare
legend.valueLabels.template.set("forceHidden", true);
},
removing: function () {
chart.set("layout", root.horizontalLayout); // back to the right of the spiral
legend.setAll({
layout: root.verticalLayout,
x: undefined, centerX: undefined,
y: am5.p50, centerY: am5.p50,
marginTop: 0
});
legend.valueLabels.template.set("forceHidden", false);
}
});
// Animate chart and series in
// https://www.amcharts.com/docs/v5/concepts/animations/#Initial_animation
series.appear(1000);
chart.appear(1000, 100);
{
"type": "SpiralChart",
"settings": {
"levelCount": 2,
"yAxisRadius": {
"type": "Percent",
"value": 90
},
"wheelY": "zoomX",
"layout": {
"type": "HorizontalLayout"
},
"scrollbarX": {
"type": "Scrollbar",
"settings": {
"orientation": "horizontal",
"forceHidden": true
}
},
"cursor": {
"type": "CurveCursor",
"settings": {
"behavior": "zoomX",
"xAxis": "#axis-0",
"yAxis": "#axis-1"
}
}
},
"properties": {
"series": [
"#series-0",
"#series-1"
],
"xAxes": [
"#axis-0"
],
"yAxes": [
"#axis-1"
]
},
"children": [
{
"type": "Legend",
"settings": {
"nameField": "name",
"fillField": "color",
"strokeField": "color",
"clickTarget": "none",
"layout": {
"type": "VerticalLayout"
},
"y": {
"type": "Percent",
"value": 50
},
"centerY": {
"type": "Percent",
"value": 50
}
},
"properties": {
"data": [
{
"name": "Module #1",
"color": {
"type": "Color",
"value": "#67b7dc"
},
"__parse": true
},
{
"name": "Module #2",
"color": {
"type": "Color",
"value": "#8067dc"
},
"__parse": true
},
{
"name": "Module #3",
"color": {
"type": "Color",
"value": "#c767dc"
},
"__parse": true
},
{
"name": "Module #4",
"color": {
"type": "Color",
"value": "#dc6788"
},
"__parse": true
}
]
}
}
],
"refs": [
{
"data-0": [
{
"category": "Module #1",
"start": 1768435200000,
"end": 1768694400000,
"settings": {
"fill": {
"type": "Color",
"value": "#67b7dc"
},
"__parse": true
},
"task": "Gathering requirements",
"__parse": true
},
{
"category": "Module #1",
"start": 1770681600000,
"end": 1776902400000,
"settings": {
"fill": {
"type": "Color",
"value": "#67b7dc"
},
"__parse": true
},
"task": "Development",
"__parse": true
},
{
"category": "Module #2",
"start": 1768262400000,
"end": 1768435200000,
"settings": {
"fill": {
"type": "Color",
"value": "#8067dc"
},
"__parse": true
},
"task": "Gathering requirements",
"__parse": true
},
{
"category": "Module #2",
"start": 1768608000000,
"end": 1768867200000,
"settings": {
"fill": {
"type": "Color",
"value": "#8067dc"
},
"__parse": true
},
"task": "Producing specifications",
"__parse": true
},
{
"category": "Module #2",
"start": 1768953600000,
"end": 1770681600000,
"settings": {
"fill": {
"type": "Color",
"value": "#8067dc"
},
"__parse": true
},
"task": "Development",
"__parse": true
},
{
"category": "Module #2",
"start": 1771113600000,
"end": 1771804800000,
"settings": {
"fill": {
"type": "Color",
"value": "#8067dc"
},
"__parse": true
},
"task": "Testing and QA",
"__parse": true
},
{
"category": "Module #3",
"start": 1767657600000,
"end": 1769212800000,
"settings": {
"fill": {
"type": "Color",
"value": "#c767dc"
},
"__parse": true
},
"task": "Gathering requirements",
"__parse": true
},
{
"category": "Module #3",
"start": 1770336000000,
"end": 1771113600000,
"settings": {
"fill": {
"type": "Color",
"value": "#c767dc"
},
"__parse": true
},
"task": "Producing specifications",
"__parse": true
},
{
"category": "Module #3",
"start": 1773532800000,
"end": 1776643200000,
"settings": {
"fill": {
"type": "Color",
"value": "#c767dc"
},
"__parse": true
},
"task": "Development",
"__parse": true
},
{
"category": "Module #3",
"start": 1777075200000,
"end": 1777939200000,
"settings": {
"fill": {
"type": "Color",
"value": "#c767dc"
},
"__parse": true
},
"task": "Testing and QA",
"__parse": true
},
{
"category": "Module #4",
"start": 1768867200000,
"end": 1771286400000,
"settings": {
"fill": {
"type": "Color",
"value": "#dc6788"
},
"__parse": true
},
"task": "Gathering requirements",
"__parse": true
},
{
"category": "Module #4",
"start": 1772409600000,
"end": 1773532800000,
"settings": {
"fill": {
"type": "Color",
"value": "#dc6788"
},
"__parse": true
},
"task": "Development",
"__parse": true
},
{
"category": "Module #4",
"start": 1774656000000,
"end": 1777852800000,
"settings": {
"fill": {
"type": "Color",
"value": "#dc6788"
},
"__parse": true
},
"task": "Testing and QA",
"__parse": true
}
]
},
{
"data-1": [
{
"category": "Milestones",
"date": 1768867200000,
"letter": "A"
},
{
"category": "Milestones",
"date": 1769558400000,
"letter": "B"
},
{
"category": "Milestones",
"date": 1771113600000,
"letter": "C"
},
{
"category": "Milestones",
"date": 1772668800000,
"letter": "D"
},
{
"category": "Milestones",
"date": 1773187200000,
"letter": "E"
},
{
"category": "Milestones",
"date": 1773705600000,
"letter": "F"
},
{
"category": "Milestones",
"date": 1774396800000,
"letter": "G"
}
]
},
{
"data-2": [
{
"category": "Module #1"
},
{
"category": "Module #2"
},
{
"category": ""
},
{
"category": "Milestones"
},
{
"category": "Module #3"
},
{
"category": "Module #4"
}
]
},
{
"axis-1": {
"type": "CategoryAxis",
"settings": {
"maxDeviation": 0,
"categoryField": "category",
"renderer": {
"type": "AxisRendererCurveY",
"settings": {
"axisLocation": 0.4166666666666667
},
"properties": {
"labels": {
"properties": {
"template": {
"settings": {
"forceHidden": true
}
}
}
},
"grid": {
"properties": {
"template": {
"settings": {
"forceHidden": true
}
}
}
}
}
}
},
"properties": {
"data": "#data-2"
}
}
},
{
"axis-0": {
"type": "DateAxis",
"settings": {
"baseInterval": {
"timeUnit": "day",
"count": 1
},
"renderer": {
"type": "AxisRendererCurveX",
"settings": {
"yRenderer": "#axis-1.get('renderer')",
"strokeDasharray": [
2,
3
],
"strokeOpacity": 0.5,
"stroke": "@root.interfaceColors.get('alternativeBackground')"
},
"properties": {
"labels": {
"properties": {
"template": {
"settings": {
"centerY": {
"type": "Percent",
"value": 50
},
"fontSize": 11,
"minPosition": 0.01
}
}
}
}
}
},
"tooltip": {
"type": "Tooltip"
}
}
}
},
{
"series-0": {
"type": "CurveColumnSeries",
"settings": {
"xAxis": "#axis-0",
"yAxis": "#axis-1",
"baseAxis": "#axis-1",
"valueXField": "end",
"openValueXField": "start",
"categoryYField": "category",
"layer": 30
},
"properties": {
"data": "#data-0",
"columns": {
"properties": {
"template": {
"settings": {
"height": {
"type": "Percent",
"value": 25
},
"strokeOpacity": 0,
"templateField": "settings",
"tooltipText": "{category}: {task}\n{openValueX.formatDate('MMM d')} to {valueX.formatDate('MMM d')}"
}
}
}
},
"bullets": [
{
"type": "Bullet",
"settings": {
"locationX": 0,
"sprite": {
"type": "Circle",
"settings": {
"radius": 4,
"templateField": "settings",
"tooltipText": "{category}: {task}\n{openValueX.formatDate('MMM d')} to {valueX.formatDate('MMM d')}",
"layer": 30
}
}
}
},
{
"type": "Bullet",
"settings": {
"locationX": 1,
"sprite": {
"type": "Circle",
"settings": {
"radius": 4,
"templateField": "settings",
"tooltipText": "{category}: {task}\n{openValueX.formatDate('MMM d')} to {valueX.formatDate('MMM d')}",
"layer": 30
}
}
}
}
]
}
}
},
{
"series-1": {
"type": "CurveLineSeries",
"settings": {
"xAxis": "#axis-0",
"yAxis": "#axis-1",
"categoryYField": "category",
"valueXField": "date"
},
"properties": {
"data": "#data-1",
"strokes": {
"properties": {
"template": {
"settings": {
"forceHidden": true
}
}
}
},
"bullets": [
{
"type": "Bullet",
"settings": {
"sprite": {
"type": "Label",
"settings": {
"text": "{letter}",
"populateText": true,
"fontSize": 9,
"fontWeight": "bold",
"centerX": {
"type": "Percent",
"value": 50
},
"centerY": {
"type": "Percent",
"value": 50
},
"paddingTop": 1,
"paddingBottom": 1,
"paddingLeft": 4,
"paddingRight": 4,
"layer": 30,
"background": {
"type": "Rectangle",
"settings": {
"fill": "@root.interfaceColors.get('background')",
"stroke": "@root.interfaceColors.get('alternativeBackground')"
}
}
}
}
}
}
]
}
}
}
]
}