Creating your first JavaFX chart app, part 2
In this second tutorial, you'll see some of the basic features of Charts-kt, and build a bar chart based on your data model.
This tutorial will help you understand how to:
- Parse and map a CSV file to a Kotlin data class
- Use your own model and data in your charts
- Describe your values through "dimensions"
- Create a bar chart
- Understand the "Datum" concept
What do you need?
- 15 minutes
- A working JavaFX environment (see part 1)
- Intellij Community or Ultimate (tested with IntelliJ 2020.3.*)
Parsing a CSV file
This CSV file contains statistical weather data for each month of 2016 and 2017, for 5 different cities. Save it in the resources
repository of your project:
Parsing this file is very easy using the multiplatform Dsv module of data2viz.
- create a Kotlin data class
Weather
to hold the information, - load the file which is located in
"./src/main/resources/"
, - parse each row and, if needed, drop the header (the first line of the file),
- finally, map each row of the file in a
Weather
instance.
import io.data2viz.dsv.Dsv
import java.io.File
fun main() {
val csvContent = File("./src/main/resources/Weather - Stats.csv").readText()
val weatherData = parseWeatherCSV(csvContent)
println("Loaded ${weatherData.size} records")
}
data class Weather(
val city: String,
val year: Int,
val month: Int,
val highTemp: Double,
val avgTemp: Double,
val lowTemp: Double,
val precipitation: Double
)
fun parseWeatherCSV(csvContent: String): List<Weather> = Dsv()
.parseRows(csvContent)
.drop(1)
.map { row ->
Weather(
row[0],
row[1].toInt(),
row[2].toInt(),
row[3].toDouble(),
row[4].toDouble(),
row[5].toDouble(),
row[6].toDouble()
)
}
The Kotlin data class offers a very convenient way to hold your information loaded from your CSV file.
Copy this code in a LoadCSV.kt
file, and run the main()
, you should see this result:
Using a strongly typed data model
Kotlin is a strongly typed language that helps your writing code via auto-completion and compilation time checking.
This feature helps you when managing your data and building your chart.
Preparing the dataset with Kotlin built-in functions
Let's have a look at the weather in Chicago for 2017.
To filter the dataset, use the built-in filter
function of Kotlin:
val csvContent = File("./src/main/resources/Weather - Stats.csv").readText()
val weatherData = parseWeatherCSV(csvContent)
val chicago2017 = weatherData.filter { weather ->
weather.city == "Chicago" && weather.year == 2017
}
Here, the filter
lambda takes a Weather
parameter, so your IDE can provide auto-completion, as you can see:
Also benefit from strong-typing in your chart
The same applies to the Chart
you create with Charts-kt: by giving it your dataset on instantiation, your chart will be typed with your data, here the Weather
class.
This Weather
class will be referred as the DOMAIN.
Think of it as a generic class <DOMAIN>
, so using it in your chart will create a typed instance: Chart<Weather>
.
Copy and paste this whole code in a new Kotlin file named WeatherChart.kt
in your kotlin
directory:
import io.data2viz.charts.chart.Chart
import io.data2viz.charts.chart.chart
import io.data2viz.charts.chart.discrete
import io.data2viz.charts.chart.mark.bar
import io.data2viz.charts.chart.quantitative
import io.data2viz.charts.viz.VizContainer
import io.data2viz.charts.viz.newVizContainer
import io.data2viz.geom.Size
import javafx.application.Application
import javafx.scene.Scene
import javafx.scene.layout.Pane
import javafx.stage.Stage
fun main() {
Application.launch(WeatherChart::class.java)
}
private val width = 500.0
private val height = 400.0
class WeatherChart : Application() {
override fun start(stage: Stage) {
val root = Pane()
root.newVizContainer().run {
size = Size(width, height)
createChart()
}
// Launch our Scene
stage.apply {
title = "Chicago average temperature in 2017"
scene = (Scene(root, width, height))
show()
}
}
private fun VizContainer.createChart(): Chart<Weather> {
// Load and parse the CSV file
val csvContent = File("./src/main/resources/Weather - Stats.csv").readText()
val weatherData = parseWeatherCSV(csvContent)
val chicago2017 = weatherData.filter {
it.city == "Chicago" && it.year == 2017
}
// Create a bar chart with the month as X-axis and the temperature as Y-axis
return chart(chicago2017) {
val monthDimension = discrete( { domain.month } )
val temperatureDimension = quantitative( { domain.avgTemp } )
bar(monthDimension, temperatureDimension)
}
}
}
Note that the
createChart()
function returns a typedChart<Weather>
instance.
Running the main()
function produces this result:
The dimensions: how to describe our dataset
When you look at the code, you can see that you create 2 dimensions: monthDimension
and temperatureDimension
and use them in your chart:
import io.data2viz.charts.chart.Chart
import io.data2viz.charts.chart.chart
import io.data2viz.charts.chart.discrete
import io.data2viz.charts.chart.mark.bar
import io.data2viz.charts.chart.quantitative
import io.data2viz.charts.viz.VizContainer
import io.data2viz.charts.viz.newVizContainer
import io.data2viz.geom.Size
import javafx.application.Application
import javafx.scene.Scene
import javafx.scene.layout.Pane
import javafx.stage.Stage
fun main() {
Application.launch(WeatherChart::class.java)
}
private val width = 500.0
private val height = 400.0
class WeatherChart : Application() {
override fun start(stage: Stage) {
val root = Pane()
root.newVizContainer().run {
size = Size(width, height)
createChart()
}
// Launch our Scene
stage.apply {
title = "Chicago average temperature in 2017"
scene = (Scene(root, width, height))
show()
}
}
private fun VizContainer.createChart(): Chart<Weather> {
// Load and parse the CSV file
val csvContent = File("./src/main/resources/Weather - Stats.csv").readText()
val weatherData = parseWeatherCSV(csvContent)
val chicago2017 = weatherData.filter {
it.city == "Chicago" && it.year == 2017
}
// Create a bar chart with the month as X-axis and the temperature as Y-axis
return chart(chicago2017) {
val monthDimension = discrete( { domain.month } )
val temperatureDimension = quantitative( { domain.avgTemp } )
bar(monthDimension, temperatureDimension)
}
}
}
A dimension is a way to describe how to acccess the data and how to process the data.
The dimension type (how to process the data)
In the Weather
class, the month is stored as an Int
and the average temperature as a Double
, and, as you want to draw a bar chart, you want to process them differently:
- Temperature is a continuous numeric value, use the
quantitative
dimension to express that - Month is a discrete value, use the
discrete
dimension to process it as a "category"
The dimension accessor (how to access the data)
Each Dimension
applies to a DOMAIN
instance. Here it's your Weather
object and you need to indicates which property you want to translate as your dimension.
This is what is done in the lambda inside the parenthesis, the domain
value refers to your Weather object, so the monthDimension
is based on the domain.month
property, and the temperatureDimension
on the domain.avgTemp
one.
We call this lambda the "accessor", it describes how to access the data.
Here again, the strong-typing offers some help with the editor.
Note how a Quantitative
dimension only accepts some Double
values when using smart-completion with IDEA:
Adding some information on dimensions
You can add some properties on dimensions like name
and formatter
.
Let's add a title
to your chart, and add these properties on your dimension, replace the code of WeatherChart.kt
with the following:
import io.data2viz.charts.chart.Chart
import io.data2viz.charts.chart.chart
import io.data2viz.charts.chart.discrete
import io.data2viz.charts.chart.mark.bar
import io.data2viz.charts.chart.quantitative
import io.data2viz.charts.core.formatToInteger
import io.data2viz.charts.viz.VizContainer
import io.data2viz.charts.viz.newVizContainer
import io.data2viz.format.Locales
import io.data2viz.geom.Size
import javafx.application.Application
import javafx.scene.Scene
import javafx.scene.layout.Pane
import javafx.stage.Stage
fun main() {
Application.launch(WeatherChart::class.java)
}
private val width = 500.0
private val height = 400.0
private val months = listOf("Jan.", "Feb.", "Mar.", "Apr.", "May", "Jun.", "Jul.", "Aug.", "Sep.", "Oct.", "Nov.", "Dec.")
private val city = "Chicago"
private val year = 2017
class WeatherChart : Application() {
override fun start(stage: Stage) {
val root = Pane()
root.newVizContainer().run {
size = Size(width, height)
createChart()
}
// Launch our Scene
stage.apply {
title = "Chicago average temperature in 2017"
scene = (Scene(root, width, height))
show()
}
}
private fun VizContainer.createChart(): Chart<Weather> {
// Load and parse the CSV file
val csvContent = File("./src/main/resources/Weather - Stats.csv").readText()
val weatherData = parseWeatherCSV(csvContent)
val chicago2017 = weatherData.filter {
it.city == city && it.year == year
}
// Create a bar chart with the month as X-axis and the temperature as Y-axis
return chart(chicago2017) {
title = "Monthly average temperature in $city, $year"
val monthDimension = discrete( { domain.month } ) {
formatter = { months[this-1] }
}
val temperatureDimension = quantitative( { domain.avgTemp } ) {
name = "Average temperature"
formatter = { "${this.formatToInteger(Locales.en_US)} °F" }
}
bar(monthDimension, temperatureDimension)
}
}
}
Run the code and you can see that, giving your dimension a name automatically add a title to your corresponding axis (the Y-axis).
Also the data that is computed through your dimension will be formatted with the given formatter (check the Y-axis and the tooltip):
The "Datum", a convenient way to access data
Maybe you already see that the Dimension
did not directly use a DOMAIN
parameter, but instead a Datum<DOMAIN>
, here a Datum<Weather>
:
A Datum
is an object that holds your domain object (as its domain
property), but also some other useful information like the index of your current object in the dataset...
For now, just keep in mind that you don't manipulate the DOMAIN object directly, the Datum
will prove itself very useful on many occasions.