16  Change Over Time

Line charts and their variants – showing change over a sequence.

(ns plotje-book.change-over-time
  (:require
   ;; Tablecloth -- dataset manipulation
   [tablecloth.api :as tc]
   ;; Kindly -- notebook rendering protocol
   [scicloj.kindly.v4.kind :as kind]
   ;; Plotje -- composable plotting
   [scicloj.plotje.api :as pj]))

Line

Connected line through data points.

(def wave {:x (range 30)
           :y (map #(Math/sin (* % 0.3)) (range 30))})
(-> wave
    (pj/lay-line :x :y))
yx051015202530-1.0-0.8-0.6-0.4-0.20.00.20.40.60.81.0

Grouped Lines

Color separates multiple series. Real datasets often start in wide form – each series in its own column. Plotje plots long form – one row per observation, with the series label in a column. Use tc/pivot->longer to reshape, then map the label column to :color. See Datasets for more on the wide-to-long reshape.

(def waves-wide
  (tc/dataset
   {:x   (range 30)
    :sin (map #(Math/sin (* % 0.3)) (range 30))
    :cos (map #(Math/cos (* % 0.3)) (range 30))}))
(def waves
  (tc/pivot->longer waves-wide [:sin :cos]
                    {:target-columns :function
                     :value-column-name :y}))
(-> waves
    (pj/lay-line :x :y {:color :function}))
yxfunctionsincos051015202530-1.0-0.8-0.6-0.4-0.20.00.20.40.60.81.0

Thick Line

Constant stroke width via :size.

(-> wave
    (pj/lay-line :x :y {:size 4}))
yx051015202530-1.0-0.8-0.6-0.4-0.20.00.20.40.60.81.0

Line with Points

Overlay points on a grouped line plot.

(def growth
  {:day [1 2 3 4 5 1 2 3 4 5]
   :value [10 15 13 18 22 8 12 11 16 19]
   :group [:a :a :a :a :a :b :b :b :b :b]})
(-> growth
    (pj/pose :day :value {:color :group})
    pj/lay-line
    pj/lay-point)
valuedaygroupab12345810121416182022

Step

Horizontal-then-vertical connected points.

(-> {:x [1 2 3 4 5]
     :y [2 4 1 5 3]}
    (pj/lay-step :x :y)
    pj/lay-point)
yx1234512345

Step by Group

Grouped step lines.

(-> growth
    (pj/pose :day :value {:color :group})
    pj/lay-step
    pj/lay-point)
valuedaygroupab12345810121416182022

Stacked Step

The groups pile up in the order the legend lists them, the first on top, with horizontal-then-vertical segments.

(-> {:x (concat (range 5) (range 5) (range 5))
     :y (concat [1 2 3 4 5] [2 2 2 2 2] [3 1 2 1 2])
     :group (concat (repeat 5 "A") (repeat 5 "B") (repeat 5 "C"))}
    (pj/lay-step :x :y {:position :stack :color :group}))
yxgroupABC012340123456789

Area

Filled area under a line.

(-> {:x (range 30)
     :y (map #(Math/sin (* % 0.3)) (range 30))}
    (pj/lay-area :x :y))
yx051015202530-1.0-0.8-0.6-0.4-0.20.00.20.40.60.81.0

Stacked Area

The bands pile up in the order the legend lists them, the first on top.

(-> {:x (concat (range 10) (range 10) (range 10))
     :y (concat [1 2 3 4 5 4 3 2 1 0]
                [2 2 2 3 3 3 2 2 2 2]
                [1 1 1 1 2 2 2 1 1 1])
     :group (concat (repeat 10 "A") (repeat 10 "B") (repeat 10 "C"))}
    (pj/lay-area :x :y {:position :stack :color :group}))
yxgroupABC02468012345678910

Dates on the x-axis

Real time-series usually have an actual date column, not an integer step. Plotje detects temporal columns (java.util.Date via #inst, java.time.LocalDate, LocalDateTime, Instant) and picks calendar-aware tick labels automatically.

(def temp-pose
  (-> {:date [#inst "2024-01-01" #inst "2024-02-01" #inst "2024-03-01"
              #inst "2024-04-01" #inst "2024-05-01" #inst "2024-06-01"]
       :temperature [3 5 9 14 19 23]}
      (pj/lay-line :date :temperature)
      pj/lay-point))
temp-pose
temperaturedateJan-01Jan-15Jan-29Feb-12Feb-26Mar-11Mar-25Apr-08Apr-22May-06May-2024681012141618202224

Multiple Series Over Time

Pass {:color :group} to get one line per category. Rows are drawn in their given order, so pre-sort by date if your data is not already sorted.

(def months
  [#inst "2024-01-01" #inst "2024-02-01" #inst "2024-03-01"
   #inst "2024-04-01" #inst "2024-05-01" #inst "2024-06-01"])
(-> {:date        (concat months months)
     :temperature [3  5  9 14 19 23
                   15 17 19 22 25 28]
     :city        (concat (repeat 6 "Zurich")
                          (repeat 6 "Athens"))}
    (pj/lay-line :date :temperature {:color :city})
    pj/lay-point)
temperaturedatecityZurichAthensJanFebMarAprMayJun510152025

Area Over Dates

Filled area also works on a date axis – useful for cumulative metrics where the volume below the curve carries meaning.

(-> {:date [#inst "2024-01-01" #inst "2024-02-01" #inst "2024-03-01"
            #inst "2024-04-01" #inst "2024-05-01" #inst "2024-06-01"]
     :sales [10 25 30 22 35 40]}
    (pj/lay-area :date :sales))
salesdateJan-01Jan-15Jan-29Feb-12Feb-26Mar-11Mar-25Apr-08Apr-22May-06May-200510152025303540

See Inference Rules for details on how dates are detected and formatted.

Two Series on One Date Axis

Two series measured over different stretches of time read against each other only if their axes agree. Stacked without saying so, each panel spans its own dates across the same width, so a reading above another stands for a different month.

(def zurich
  {:date [#inst "2024-01-01" #inst "2024-02-01" #inst "2024-03-01"
          #inst "2024-04-01" #inst "2024-05-01" #inst "2024-06-01"]
   :temperature [3 5 9 14 19 23]})
(def athens
  {:date [#inst "2024-05-01" #inst "2024-06-01" #inst "2024-07-01"
          #inst "2024-08-01" #inst "2024-09-01" #inst "2024-10-01"]
   :temperature [25 28 31 32 27 22]})
(def cities
  [(-> zurich (pj/lay-line :date :temperature) pj/lay-point)
   (-> athens (pj/lay-line :date :temperature) pj/lay-point)])
(pj/arrange cities {:cols 1})
temperaturedateJan-01Jan-15Jan-29Feb-12Feb-26Mar-11Mar-25Apr-08Apr-22May-06May-201020temperaturedateMay-06May-20Jun-03Jun-17Jul-01Jul-15Jul-29Aug-12Aug-26Sep-09Sep-232530

:share-scales #{:x} pools the date column across the cells. The pooled range is the union of both, so each series occupies the part of the axis it covers, and both panels carry the same tick labels. One above the other, that is something to look at rather than to check: a month is at the same place in both panels.

(pj/arrange cities {:cols 1 :share-scales #{:x}})
temperaturedateJanFebMarAprMayJunJulAugSepOct1020temperaturedateJanFebMarAprMayJunJulAugSepOct2530

An axis holds a date as a number of milliseconds, which is what lets the two ranges be pooled at all.

The two panels line up here because their y axes label at the same width. Where they do not – one series in single digits and one in millions – each panel reserves the room its own labels need, and the shared axis comes out spanning different widths. :align-panels, which pj/arrange takes among its options, reserves the same room on every cell; the Composition chapter covers that and :share-scales on any column.

A Distribution of the Dates

A time series says what the readings were, and less about when they were taken. pj/marginal puts a distribution of the pose’s :x column in a thin panel above the plot, sharing its axis – and on a date column that distribution is how the observations are spread through time.

(def sightings
  {:date [#inst "2021-03-14" #inst "2021-07-02" #inst "2021-11-28"
          #inst "2022-02-09" #inst "2022-05-30" #inst "2022-06-11"
          #inst "2022-06-25" #inst "2022-07-08" #inst "2022-09-17"
          #inst "2023-01-22" #inst "2023-08-05" #inst "2024-02-19"]
   :count [2 5 3 8 6 11 9 14 12 7 4 2]})
(-> sightings
    (pj/lay-point :date :count)
    (pj/marginal :top :histogram))
024countdate2021-072022-012022-072023-012023-072024-012468101214

Smoothed Time Series

A LOESS smoother overlaid on a noisy time series makes the underlying trend easier to see. pj/lay-smooth works on any numerical y axis, including dates on x.

(-> {:date  [#inst "2024-01-01" #inst "2024-02-01" #inst "2024-03-01"
             #inst "2024-04-01" #inst "2024-05-01" #inst "2024-06-01"
             #inst "2024-07-01" #inst "2024-08-01" #inst "2024-09-01"
             #inst "2024-10-01" #inst "2024-11-01" #inst "2024-12-01"]
     :sales [10 14 12 18 22 19 25 28 24 30 27 33]}
    (pj/pose :date :sales)
    pj/lay-line
    pj/lay-smooth)
salesdateJan 2024Mar 2024May 2024Jul 2024Sep 2024Nov 20241015202530

Zero-Line Baseline

Time series with positive and negative values often benefit from a horizontal reference at zero, drawn with pj/lay-rule-h. The rule is drawn at a value written on the layer, :y-intercept, rather than from a data column, and it is a layer like any other: it paints in the order it was added, and the y axis reaches its value even where the series does not.

(-> {:t (range 12)
     :delta [-3 -1 -2 0 2 4 -1 3 5 -2 1 4]}
    (pj/lay-line :t :delta)
    pj/lay-point
    (pj/lay-rule-h {:y-intercept 0 :color "#888"}))
deltat01234567891011-3-2-1012345

What’s Next

  • Timelines – events, intervals, and schedules on a time axis (Gantt charts, Marey diagrams, annotated time series)
  • Relationships – heatmaps, contours, and 2D density
  • Polar Coordinates – radial charts for cyclical data
  • Gallery – more chart variations with side-by-side code
source: notebooks/plotje_book/change_over_time.clj