Charts/Distribution charts
Horizon
<Horizon /> · 10 examples
A line chart sliced into bands and folded down, so a dozen series fit where one used to.
Overview
Dashboards have a standing problem: forty series, one screen, and a line chart that is about as readable at that density as a plate of noodles. Making each line chart shorter does not help for long, because once a line is 30 pixels tall the wiggles are lost in the pixel grid.
The horizon chart is a way to cheat the height. Cut the vertical range of a series into equal slices, tint each slice a bit darker than the one below it, and then lay all of the slices on top of one another in a single short row. The tallest peaks are now the darkest patches, and the whole row is a quarter, or a third, of its original height. The technique was introduced for time series by Saito and colleagues in the mid-2000s, and tested rather carefully by Jeffrey Heer and his co-authors in a 2009 paper called Sizing the Horizon, which is a title no other chart has earned.
The price is a small amount of learning. Nobody reads a horizon chart correctly on the first glance. But after a minute of explanation (darker means larger) the eye picks it up, and the chart becomes the best tool there is for scanning a lot of series for the one that did something.
Props
API
<Horizon data={ticks} x={|t: &Tick| t.time} x_axis={AxisConfig::new().time()}>
<TrendSeries name="CPU Δ" y={|t: &Tick| t.cpu} />
<TrendSeries name="Latency Δ" y={|t: &Tick| t.latency} />
</Horizon>| Prop | Type | Default |
|---|---|---|
data | as above | required |
x | Fn(&T) -> f64 | required |
| children | <TrendSeries>: one horizon row each; name is the row label | required |
bands | usize: shades per sign | 3 |
positive, negative | colours | good and bad |
x_axis | AxisConfig | AxisConfig::new() |
class |
Each row is cut into bands equal slices of its largest absolute value. The slices are stacked on top of each other, darker for bigger values, and negative values are flipped up in the negative colour. A row takes a fraction of the height a normal line chart would need. The slice size is shown next to each row's name.
Run them
10 examples
Press Run to load an example into the live render at the top of the page. Open one to read its code.
How to read it
- 01Darker means larger. The faint layer is the first slice of the range, the darkest is the last.
- 02Green (by default) is above zero and red is below. A row that flips between them is crossing its baseline.
- 03Look across rows for the same place on the time axis: a column of dark patches means that many series spiked together.
- 04To read a row's size, look at the band value printed beside its name: one full layer is that much, and the shades stack.
- 05There is no hover in a horizon. The rows are for scanning. Use a line chart for the series that catch your eye.
When to use it, and when not to
Reach for it when
- You have many series over the same time axis and not enough vertical room: servers, sensors, stocks, sales regions.
- You are scanning for the one series that behaves differently, and a picture of ten rows in the height of two lines makes it easier to find.
- Your data has both signs and a meaningful zero: deltas, returns, deviations from a target.
- The viewer will see the chart often enough to learn it. A dashboard on a wall is the ideal setting.
Skip it when
- You need to read exact values. Layers tell you the order of magnitude, not the number. Use a time series for that.
- You have three series and plenty of room. A plain line chart is clearer, and the horizon's compactness buys you nothing.
- The audience will see it once and has no time to learn the encoding. A chart that needs a two-line legend to read is a bad one for a single slide.
- You need to compare magnitudes across series. Each series has its own band height, so a dark row in one series is not the same size as a dark row in another.
The math behind it
Slicing a series
Each series is a sequence . For a series, find its largest absolute value and divide it by the number of bands (default 3) to get the band height . Every series gets its own , so a series that moves between and and one that moves between and both use the full depth of their rows. The band height is printed under the series name.
If the series is all zeros or empty, falls back to 1.
Folding
Band (counting from ) holds the part of the value that falls inside the -th slice. Positive values fill the positive bands and negative values, mirrored, fill their own, so a single row shows both signs. Each band is a layer of height at most , and every layer is drawn on the same axis from to , one on top of the next.
A value that is missing is left blank in every band, not drawn as zero.
Shade
Layer is filled with an opacity that grows with , so the deepest slice is the darkest. Positive layers use the positive colour (the theme's good colour by default) and negative layers use negative (the bad colour). With the default the opacities are about 0.53, 0.77 and 1.0.
More bands make each row shorter for the same range but harder to read, because the eye has to tell more shades apart. Three is a sensible start. Two is nearly a plain area chart.
Real life usage
Fourteen servers and a Tuesday
Tomasz was on call for a fleet of fourteen database replicas, which meant that on a bad night he was looking at fourteen CPU graphs and a phone that had not stopped vibrating since two in the morning. The graphs lived in a dashboard with a tall grid of line charts, and the grid needed scrolling.
The thing about scrolling is that you cannot see a pattern across the rows you are not looking at. He kept finding a spike in the fifth graph and then wondering whether the third had spiked at the same moment, scrolling back, and losing the fifth. This is not a skill problem. It is a screen problem.
He wanted all fourteen on one screen, with shared time, and sensitive enough to show that a replica had started drifting a point or two off its own normal. The metric was the deviation of CPU from each replica's 7-day mean, so it had both signs, and zero meant "as usual".
He rebuilt the panel as a <Horizon> from zopra-charts, with one field per replica, three bands, and a 15-minute time axis. The fourteen rows fit in the height that used to hold three line charts, and nobody had to scroll.
The pattern was obvious at once: a thin column of dark red running across rows six to nine at 02:10, two replicas whose CPU had dropped well below normal and two that went up. In a grid of line charts he had read it as four separate incidents. In the horizon it was one: a failover. A leader had gone away and the load had moved.
That changed the postmortem. The team had been chasing four alert rules, and the fix was a single health-check that took 40 seconds to notice the failover. After the check was tightened, pages per failover dropped from 11 to 2, and Tomasz started getting uninterrupted sleep for the first time that quarter.
He is told the horizon chart looks like a barcode that went to art school. He has stopped correcting people.
Pitfalls
- Comparing darkness across rows. Each series is scaled to its own largest value. A dark row means "large for this series", not "large". Read the band value beside the name.
- One huge outlier flattens the rest. The band height comes from the largest absolute value, so a single spike makes every other point fall in the faint layer. Clip or winsorise first if the spike is noise.
- Too many bands. More than four shades are hard to tell apart. Three is usually the right trade between height and legibility.
- Using it for strictly positive data with no baseline. It works, but only half the colour scheme is used and the zero is arbitrary. A time series with a filled area is clearer.
From around the web