Six values as a race: each runner has a lane on an oval track, and their bar runs from the start line around the oval as far as their value takes it. Photos in the middle name the runners.
Drawing a ranking as a race makes it feel like one: who is ahead, and by how much, reads at a glance. It suits a handful of contestants with one number each, and a maximum to race towards, here 1,000 for a full lap.
Built with amCharts 5, a JavaScript library. The code includes your theme and renderer (canvas or SVG), but not 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/
root.setThemes([
am5themes_Animated.new(root),
am5themes_Responsive.new(root)
]);
// Create chart
var chart = root.container.children.push(am5timeline.CurveChart.new(root, {
wheelY: "zoomX" // the mouse wheel zooms along the track
}));
var yRenderer = am5timeline.AxisRendererCurveY.new(root, {
axisLength: 250, // the lanes take 250px across the track
minGridDistance: 10, // lanes close together: let every name show
axisLocation: 0 // the lane names sit where the track starts
})
yRenderer.labels.template.setAll({
fontSize: 11, // small text...
layer: 30, // ...drawn on layer 30, over the track...
fill: am5.color(0xffffff), // ...in white
fontWeight: "bold",
centerX: am5.p100
});
yRenderer.grid.template.setAll({
stroke: am5.color(0xffffff), // white lines between the lanes...
strokeOpacity: 1, // ...fully opaque...
layer: 30 // ...over the track
})
yRenderer.axisFills.template.setAll({
fill: am5.color(0xb84f49), // track red for every lane...
forceHidden: false, // ...shown even if a theme hides axis fills...
fillOpacity: 1, // ...solid
visible: true
});
// Create axes and their renderers
var xRenderer = am5timeline.AxisRendererCurveX.new(root, {
yRenderer: yRenderer,
rotateLabels: true, // the labels turn with the track
points: getPoints(), // the track's shape, from getPoints at the bottom
minGridDistance: 200, // at least 200px between the distance marks
stroke: am5.color(0x000000), // a dark band along the track...
strokeOpacity:0.1, // ...faint...
strokeWidth:20 // ...and 20px wide
});
xRenderer.grid.template.setAll({
stroke: am5.color(0xffffff), // white distance lines across the lanes...
strokeOpacity: 1, // ...fully opaque...
layer: 30 // ...over the track
})
xRenderer.labels.template.setAll({
maxPosition: 0.98, // no label at the very end, where the loop meets its start
fontSize: 13,
layer: 30, // over the track
fill: am5.color(0xffffff), // white
fontWeight: "bold",
centerY: am5.p100
});
// centers each label across the lanes: half the 250px axis length, scaled with the chart
xRenderer.labels.template.adapters.add("centerX", function (centerX, target) {
return 125 * xRenderer.getPrivate("scale");
})
// a quarter turn more, so the labels read across the track
xRenderer.labels.template.adapters.add("rotation", function (rotation, target) {
return rotation + 90;
})
var yAxis = chart.yAxes.push(am5xy.CategoryAxis.new(root, {
categoryField: "name",
renderer: yRenderer,
// every lane gets a fill, not only every other one
fillRule: function (dataItem) {
const axisFill = dataItem.get("axisFill");
if (axisFill) {
axisFill.set("visible", true);
}
}
}));
var xAxis = chart.xAxes.push(am5xy.ValueAxis.new(root, {
renderer: xRenderer,
min: 0, // the track runs from 0...
max: 1000, // ...to 1,000...
strictMinMax: true // ...exactly, with no rounding to nicer numbers
}));
var series = chart.series.push(am5timeline.CurveColumnSeries.new(root, {
maskBullets: false, // the dots aren't cut off at the plot's edge
xAxis: xAxis,
yAxis: yAxis,
valueXField: "value",
categoryYField: "name",
tooltip: am5.Tooltip.new(root, {})
}))
series.columns.template.setAll({
fill: am5.color(0xffffff), // white bars...
strokeOpacity: 0, // ...with no outline...
fillOpacity: 0.5, // ...half see-through...
layer: 30, // ...over the track
tooltipText: "{categoryY}: {valueX}" // hover a bar for the runner and the distance
});
series.columns.template.states.create("hover", {
fillOpacity: 1 // solid white under the pointer
});
// a white dot at the end of each bar
series.bullets.push(function (root, series, dataItem) {
return am5.Bullet.new(root, {
sprite: am5.Circle.new(root, {
radius: 5, // a 5px dot
fillOpacity: 1,
fill: am5.color(0xffffff)
})
})
})
var data = [{ name: "Chandler", file: "https://www.amcharts.com/wp-content/uploads/assets/timeline/chandler.jpg", track: 1, value: 450 }, { name: "Ross", file: "https://www.amcharts.com/wp-content/uploads/assets/timeline/ross.jpg", track: 2, value: 650 }, { name: "Joey", file: "https://www.amcharts.com/wp-content/uploads/assets/timeline/joey.jpg", track: 3, value: 578 }, { name: "Monica", file: "https://www.amcharts.com/wp-content/uploads/assets/timeline/monica.jpg", track: 4, value: 730 }, { name: "Phoebe", file: "https://www.amcharts.com/wp-content/uploads/assets/timeline/phoebe.jpg", track: 5, value: 490 }, { name: "Rachel", file: "https://www.amcharts.com/wp-content/uploads/assets/timeline/rachel.jpg", track: 6, value: 532 }];
yAxis.data.setAll(data);
series.data.setAll(data);
series.appear(3000, 100); // the bars grow over 3 seconds
// Add the legend: each runner's photo and name, in the middle of the track
// https://www.amcharts.com/docs/v5/charts/xy-chart/legend-xy-series/
var legend = chart.curveContainer.children.push(am5.Legend.new(root, {
centerX: am5.p50, // in the middle of the track...
centerY: am5.p50, // ...both ways
useDefaultMarker: true, // plain markers, swapped for the photos below
nameField: "categoryY", // the names come from each bar's category
maxWidth: 350 // at most 350px wide; more runners wrap to a new row
}));
legend.valueLabels.template.setAll({
forceHidden: true
});
// each legend marker shows the runner's photo instead of a color square
legend.markers.template.setup = function (marker) {
marker.events.on("dataitemchanged", function () {
var dataItem = marker.dataItem
var seriesDataItem = dataItem.dataContext;
var picture = marker.children.push(am5.Picture.new(root, {
width: 30, // a 30px photo...
height: 30, // ...square
src: seriesDataItem.dataContext.file
}));
marker.set("width", 30); // the marker as big as the photo
marker.set("height", 30);
// Round photos
marker.set("mask", am5.Circle.new(root, {
radius: 15, // a 15px radius...
x: 15,
y: 15 // ...centered on the photo
}));
dataItem.on("markerRectangle", function (rectangle) {
rectangle.set("forceHidden", true); // hide the default color square
})
});
}
// pointing at a name in the legend highlights that runner's bar
legend.itemContainers.template.events.on("pointerover", function (e) {
var seriesDataItem = e.target.dataItem.dataContext;
if (seriesDataItem) {
var graphics = seriesDataItem.get("graphics"); // the runner's bar
if (graphics) {
graphics.hover(); // show the bar's hover state
}
}
});
legend.itemContainers.template.events.on("pointerout", function (e) {
var seriesDataItem = e.target.dataItem.dataContext;
if (seriesDataItem) {
var graphics = seriesDataItem.get("graphics");
if (graphics) {
graphics.unhover();
}
}
});
legend.data.setAll(series.dataItems);
// the track's shape for the X axis to follow: two straights joined by half circles
function getPoints() {
var points = [];
var radius = 300; // the curves' radius, in the same units as the points
points.push({ x: 0, y: -300 }); // the top straight...
points.push({ x: 300, y: -300 }); // ...from the middle to the right
// the right half circle, in 50 steps
for (var k = 1; k < 50; k++) {
var angle = -180 - k / 50 * 180;
points.push({ y: radius * am5.math.cos(angle), x: 300 + radius * am5.math.sin(angle) });
}
points.push({ x: -300, y: 300 }); // the bottom straight, to the left
// the left half circle, back up to the top
for (var k = 1; k < 50; k++) {
var angle = -k / 50 * 180;
points.push({ y: radius * am5.math.cos(angle), x: -300 + radius * am5.math.sin(angle) });
}
points.push({ x: 0, y: -300 }); // close the loop
return points;
}