What's a line chart?
Let's define precisely what is a line chart when it's best used and the dos and don'ts of this type of chart.
If you want to dive directly into practice, have a look at the How-to do a line chart? guide.
What's a line chart?
A line chart (or line plot or line graph or curve chart) displays data values as points (markers or symbols) that are connected using line segments, curves, or even steps.
The most standard usage of a line chart is to show the trend of a value over a continuous time span which is represented along the X-axis.
The measurement points are ordered, typically by their X-axis value.
import io.data2viz.charts.*
import io.data2viz.charts.core.Padding
import io.data2viz.charts.core.*
import io.data2viz.charts.dimension.*
import io.data2viz.charts.chart.*
import io.data2viz.charts.chart.mark.*
import io.data2viz.charts.viz.*
import io.data2viz.charts.layout.*
import io.data2viz.charts.config.configs.*
import io.data2viz.math.*
import io.data2viz.color.*
import io.data2viz.geom.*
import io.data2viz.shape.Symbols
import io.data2viz.dsv.Dsv
import org.w3c.fetch.Response
import kotlinx.browser.window
import kotlin.js.Promise
val width = 600.0
val height = 400.0
// The dataset holds 2016 & 2017
private val year = 2017
// The "Weather" class
data class Weather(
val city: String,
val year: Int,
val month: Int,
val highTemp: Double,
val avgTemp: Double,
val lowTemp: Double,
val precip: Double)
// This function transform a CSV line to a "Weather" instance
private fun parseWeather(row: List<String>) = Weather(
row[0],
row[1].toInt(),
row[2].toInt(),
row[3].toDouble(),
row[4].toDouble(),
row[5].toDouble(),
row[6].toDouble()
)
// Just use a simple list of months label for the X axis
private val months = listOf("Jan.", "Feb.", "Mar.", "Apr.", "May", "Jun.",
"Jul.", "Aug.", "Sep.", "Oct.", "Nov.", "Dec.")
fun main() {
// Creating and sizing the VizContainer
val vc = newVizContainer().apply {
size = Size(width, height)
}
// original taken from https://vincentarelbundock.github.io/Rdatasets/
val request: Promise<Response> =
window.fetch("https://docs.google.com/spreadsheets/d/e/2PACX-1vTX4QuCNyDvUoA"
+"wk6Jl6UJ4r336A87VIKQ5BVyEgowXG_raXdFBMvmUhmz1LLc07GavyC9J6pZ4"
+"YHqJ/pub?gid=650761999&single=true&output=csv")
request.then {
it.text().then {
val results = Dsv()
.parseRows(it)
.drop(1)
.map { parseWeather(it) }
.filter { it.year == year }
.toMutableList()
vc.chart(results) {
title = "Monthly average temperature in $year"
config {
cursor {
show = true
type = CursorType.Vertical
}
legend {
show = LegendVisibility.Show
}
}
series = discrete( { domain.city } )
val monthDim = discrete( { domain.month } ) {
formatter = { "${months[this - 1]} "}
}
val tempDim = quantitative( { domain.avgTemp } ) {
name = "Average temperature for the month"
formatter = { "$thisĀ°F" }
}
line(monthDim, tempDim) {
// data is loaded from an external CSV, it may take a few seconds
curve = MarkCurves.Curved
size = constant(30.0)
symbol = constant(Symbols.Circle)
showSymbols = true
strokeWidth = constant(2.0)
y {
start = .0
end = 100.0
}
}
}
}
}
}
When to use a line chart?
A line chart is particularly great for these use cases:
Catch a glimpse of the changes over time
We generally associate the X-axis of a line chart with time, so using a line chart to display the variation of a value over time allows the user to see it very quickly.
It's also working very well for any other dimension (distance, temperature...) that is expected to be continuous.
import io.data2viz.charts.core.*
import io.data2viz.charts.chart.*
import io.data2viz.charts.chart.mark.*
import io.data2viz.charts.viz.*
import io.data2viz.geom.*
val width = 400.0
val height = 150.0
fun main() {
val vc = newVizContainer().apply { size = Size(width, height) }
with(vc) {
val values = listOf(1.0, 1.2, 1.3, 1.2, 1.5, 1.9, 2.6, 4.2, 6.8, 7.7, 8.2, 8.4, 8.6, 8.6, 8.5, 8.7)
chart(values) {
val indexDimension = quantitative( { indexInData.toDouble() } )
val valueDimension = quantitative( { domain } )
line(indexDimension, valueDimension)
}
}
}
Identify trends
Line charts are good to detect patterns and identify trends, but also very good for seeing when something's wrong (pikes or holes...).
import io.data2viz.charts.core.*
import io.data2viz.charts.chart.*
import io.data2viz.charts.chart.mark.*
import io.data2viz.charts.viz.*
import io.data2viz.geom.*
import kotlin.random.*
import kotlin.math.*
val width = 400.0
val height = 150.0
fun main() {
val vc = newVizContainer().apply { size = Size(width, height) }
with(vc) {
val values = (0 .. 100).map{ Random.nextDouble() + if (it == 80) 16.0 else .0 }
chart(values) {
val indexDimension = quantitative( { indexInData.toDouble() } )
val valueDimension = quantitative( { domain } )
line(indexDimension, valueDimension)
}
}
}
Compare patterns
Line charts offer a high data to ink ratio, making them a good option to identify patterns when you have multiple series.
import io.data2viz.charts.core.*
import io.data2viz.charts.chart.*
import io.data2viz.charts.chart.mark.*
import io.data2viz.charts.viz.*
import io.data2viz.geom.*
import kotlin.random.*
import kotlin.math.*
val width = 400.0
val height = 150.0
data class Record(val batch:Int, val value:Double)
val values = listOf(
Record(1, 1.0),
Record(1, 1.2),
Record(1, 1.3),
Record(1, 1.2),
Record(1, 1.5),
Record(1, 1.9),
Record(1, 2.6),
Record(1, 4.2),
Record(1, 6.8),
Record(1, 7.7),
Record(1, 8.2),
Record(1, 8.4),
Record(1, 8.6),
Record(1, 8.6),
Record(1, 8.5),
Record(1, 8.7),
Record(2, 8.0),
Record(2, 7.8),
Record(2, 7.3),
Record(2, 7.2),
Record(2, 7.5),
Record(2, 6.9),
Record(2, 6.6),
Record(2, 6.2),
Record(2, 5.8),
Record(2, 5.7),
Record(2, 5.2),
Record(2, 5.4),
Record(2, 5.6),
Record(2, 5.6),
Record(2, 5.5),
Record(2, 5.7)
)
fun main() {
val vc = newVizContainer().apply { size = Size(width, height) }
with(vc) {
chart(values) {
val indexDimension = quantitative( { indexInSeries.toDouble() } )
val valueDimension = quantitative( { domain.value } )
series = discrete( { domain.batch } )
line(indexDimension, valueDimension) {
strokeWidth = constant(2.0)
}
}
}
}
When not to use a line chart?
A line chart is not a good option when:
Your intervals are not of equals size
Remember, we said that users generally associate the X-axis to a continuous dimension.
This does not mean that you have to use a continuous dimension, but the intervals are expected to be of equals size.
import io.data2viz.charts.core.*
import io.data2viz.charts.chart.*
import io.data2viz.charts.chart.mark.*
import io.data2viz.charts.viz.*
import io.data2viz.geom.*
import kotlin.random.*
import kotlin.math.*
val width = 400.0
val height = 150.0
data class Record(val month:String, val value:Double)
val values = listOf(
Record("Jan.", 1.0),
Record("Feb.", 2.2),
Record("Mar.", 2.3),
Record("Apr.", 3.2),
Record("May", 3.5),
Record("Oct.", 3.9),
Record("Nov.", 4.6),
Record("Dec.", 5.2)
)
fun main() {
val vc = newVizContainer().apply { size = Size(width, height) }
with(vc) {
chart(values) {
val monthDimension = discrete( { domain.month } )
val valueDimension = quantitative( { domain.value } )
line(monthDimension, valueDimension)
}
}
}
If your intervals are not equals, you can use a continuous dimension (ie. temporal for time data).
Look at the previous example when using Instant
instead of String
, and using a temporal dimension for the X-axis:
import io.data2viz.charts.core.*
import io.data2viz.charts.chart.*
import io.data2viz.charts.chart.mark.*
import io.data2viz.charts.viz.*
import io.data2viz.geom.*
import kotlin.random.*
import kotlin.math.*
import kotlinx.datetime.*
val width = 400.0
val height = 150.0
data class Record(val date:String, val value:Double)
val values = listOf(
Record("2020-01-01T00:00:00.000Z", 1.0),
Record("2020-02-01T00:00:00.000Z", 2.2),
Record("2020-03-01T00:00:00.000Z", 2.3),
Record("2020-04-01T00:00:00.000Z", 3.2),
Record("2020-05-01T00:00:00.000Z", 3.5),
Record("2020-10-01T00:00:00.000Z", 6.9),
Record("2020-11-01T00:00:00.000Z", 7.6),
Record("2020-12-01T00:00:00.000Z", 7.2)
)
fun main() {
val vc = newVizContainer().apply { size = Size(width, height) }
with(vc) {
chart(values) {
val timeDimension = temporal( { domain.date.toInstant() } )
val valueDimension = quantitative( { domain.value } )
line(timeDimension, valueDimension) {
showSymbols = true
}
}
}
}
You have some missing values
You can use null values to emphasize on the fact that some of your values are missing.
Line charts can handle null values, and you can switch between 2 behaviors using the joinMissingValues
property.
However, when you have missing records and you want to identify them, you may prefer to use other visualizations like bar charts where missing values are easily identified.
import io.data2viz.charts.core.*
import io.data2viz.charts.chart.*
import io.data2viz.charts.chart.mark.*
import io.data2viz.charts.viz.*
import io.data2viz.geom.*
import kotlin.random.*
import kotlin.math.*
val width = 400.0
val height = 150.0
data class Record(val month:String, val value:Double?)
val values = listOf(
Record("Jan.", 1.0),
Record("Feb.", 2.2),
Record("Mar.", 2.3),
Record("Apr.", null),
Record("May", 3.5),
Record("Oct.", 3.9),
Record("Nov.", 4.6),
Record("Dec.", 5.2)
)
fun main() {
val vc = newVizContainer().apply { size = Size(width, height) }
with(vc) {
chart(values) {
val monthDimension = discrete( { domain.month } )
val valueDimension = quantitative( { domain.value } )
line(monthDimension, valueDimension) {
joinMissingValues = false // false by default, change it
}
}
}
}
You have too many series
Defining a limit to the number of series depends on your chart's size and the trends, but when you have over 5 series, start thinking of another chart type.
import io.data2viz.charts.core.*
import io.data2viz.charts.chart.*
import io.data2viz.charts.chart.mark.*
import io.data2viz.charts.viz.*
import io.data2viz.geom.*
import kotlin.random.*
import kotlin.math.*
val width = 400.0
val height = 150.0
data class Record(val batch:Int, val value:Double)
val values: List<Record> = (2 .. 6).map { seriesIndex ->
(0..10).map {
Record(seriesIndex, seriesIndex * Random.nextDouble())
}
}.flatten()
fun main() {
val vc = newVizContainer().apply { size = Size(width, height) }
with(vc) {
chart(values) {
val indexDimension = discrete( { indexInSeries } )
val valueDimension = quantitative( { domain.value } )
series = discrete( { domain.batch } )
line(indexDimension, valueDimension)
}
}
}
Stacked areas (or maybe stream chart) may be an alternative:
import io.data2viz.charts.core.*
import io.data2viz.charts.chart.*
import io.data2viz.charts.chart.mark.*
import io.data2viz.charts.viz.*
import io.data2viz.geom.*
import kotlin.random.*
import kotlin.math.*
val width = 400.0
val height = 150.0
data class Record(val batch:Int, val value:Double)
val values: List<Record> = (2 .. 6).map { seriesIndex ->
(0..10).map {
Record(seriesIndex, seriesIndex * Random.nextDouble())
}
}.flatten()
fun main() {
val vc = newVizContainer().apply { size = Size(width, height) }
with(vc) {
chart(values) {
val indexDimension = discrete( { indexInSeries } )
val valueDimension = quantitative( { domain.value } )
series = discrete( { domain.batch } )
area(indexDimension, valueDimension) {
stacking = Stacking.Standard
}
}
}
}