Skip to content

Instantly share code, notes, and snippets.

@aanastasiou
Created November 15, 2025 09:39
Show Gist options
  • Select an option

  • Save aanastasiou/850fb6eef158e60a37611b07ebb53093 to your computer and use it in GitHub Desktop.

Select an option

Save aanastasiou/850fb6eef158e60a37611b07ebb53093 to your computer and use it in GitHub Desktop.
A stacked plot procedure for Racket
#lang racket/base
(require
racket/vector
racket/match
racket/class
plot
plot/utils
data-frame
; These imports are structured this way here because of the way colors exposes
; (or not) its internals. To expose specific structs for example, we need to import from
; deeper in the module, which then clashes with other module declarations and this leads to
; this kind of cherry picking. Ideally, colors should expose its internal data structs.
(only-in math/statistics statistics statistics-max statistics-min)
(only-in math exact-round pi)
(only-in colors color->hsl hsl->color hsl)
(only-in colors/private/shared hsl-color)
(only-in racket/draw color%))
; ---FUNCTIONS THAT GENERATE RANDOM TIME SERIES AND DATA FRAMES (skip)-----
; None of this is optimised in any way, just stuff I needed to build random data-frames practically
; Generates a random tag of length len using the characters in alphabet
(define (get-random-string alphabet slen)
(define alphabet-len (sub1 (string-length alphabet)))
(build-string slen (lambda (n) (string-ref alphabet (exact-round (* (random) alphabet-len))))))
; Generates a ramp vector from 0 to max in N steps
(define (ramp N y-start y-end)
(build-vector N (lambda (n) (+ y-start (* (/ n N) (- y-end y-start))))))
; Generates a vector of length N with random values in the range y-m \pm y-s
(define (random-data N y-m y-s)
(build-vector N (lambda (d) (+ y-m (* 2.0 y-s (- (random) 0.5))))))
; Generates a vector of length N with a sinusoid of frequency freq and amplitude amp and then
; mixes it with a random noise vector of equal amplitude.
(define (random-data-with-sin N freq amp mix)
(define lin-phase (ramp N 0.0 (* 2.0 pi)))
(define sin-component (vector-map (lambda (n) (* amp (sin (* freq n)))) lin-phase))
(define noise-component (random-data N 0 amp))
(vector-map (lambda (n1 n2) (+ (* (- 1.0 mix) n1) (* mix n2))) sin-component noise-component))
(define (build-random-data-frame-with-common-reference M N)
; Add a time series for common reference
(define cmn (make-series "RefX"
#:data (ramp N 0 N)))
; Add M series with random names
(define data (build-list M (lambda (d)
(make-series (get-random-string "0123456789ABCDEF" 8)
#:data (if (> 0.5 (random))
(random-data N 0 (+ 0.1 (* (random) 2)))
(random-data-with-sin N
(+ 2.0
(exact-round (* (random) 2)))
(+ 0.1 (* (random) 2))
(+ 0.1 (* (random) 0.8))))))))
(make-data-frame #:series (cons cmn data)))
; --------
; --- FUNCTIONS THAT ARE USED IN THE GENERATION OF A SENSIBLE COLOR PALETTE (skip) -----
; Takes an x \in R[0..1] --> Z[0..255]. Used to generate random colours
(define (->u8 x) (exact-round (* x 255)))
; Constructs a colour object given RGB or RGBA triplets
(define (color r g b (a 1.0)) (make-object color% r g b a))
; Builds a palette of N colours by inscribing a regular N-gon in the colour circle.
; In this way, colours have enough separation between them to be distinct enough.
; Also adds an initial offset (an initial colour) which gives some control over
; the range of colours generated. If the initial offset is not provided, a random
; colour is generated.
(define (build-palette n-colors
#:starting-color (starting-color
;Need to refine the way the random colour is generated to
; avoid the grey "line" and very saturated options
; (i.e generate HSL with specific ranges for H S L instead of
; an RGB triplet)
(color (->u8 (random)) (->u8 (random))(->u8 (random)))))
; This destructuring is impossible by simply importing colors.
(match-define (hsl-color init-h init-s init-l init-a) (color->hsl starting-color))
; x that represents Hue here can take values \in R[-inf..inf]. In reality, Hue represents an
; angle around the HSL color space that is theta \n Z[0..360].
; This function takes an R value and ensures that it wraps around the 0..360 range.
(define (modulo-hue x) (/ (modulo (round (* x 360)) 360) 360.0))
(build-list n-colors (lambda (n) (hsl->color (hsl (modulo-hue (+ init-h (* n (/ 1.0 n-colors))))
init-s
init-l
init-a)))))
; ------------------------
; ---- CODE ESSENTIAL TO MULTIPLOT STARTS HERE---------------
; Generates a line renderer given specific range parameters for one of the time series in data-frame
; df. This is an internal function and handles ONLY the rendering.
(define (plot-param->line df x-series-name y-series-name
y-series-min y-series-max ; Specify the time series range (in time-series units)
y-plot-min y-plot-max ; Specify the plot Y-axis range to place it in (in plot units)
#:label (label #f) ; Any non-#f string will be added as a label
#:colour (colour 'random))
; Resolve the requested colour value (either random or a user-defined one)
(define colour-resolved (if (eq? colour 'random)
(color (->u8 (random)) (->u8 (random))(->u8 (random)))
colour))
; Isolate the X-axis data from the data-frame (no further transformation)
(define x-plot-data (df-select df
x-series-name))
; Pre-compute the time series range (it is used very frequently later on)
(define y-series-range (- y-series-max y-series-min))
; Pre-compute the plot range
(define y-plot-range (- y-plot-max y-plot-min))
; Re-scale the specified time series so that its y-series-range fits in the y-plot-range
(define y-plot-data (vector-map (lambda (u) (+ y-plot-min
; TODO: If it is not a number it should become nan
; TODO: Clip outside the specified range to y-plot-range
(* (/ (- (or (and (number? u) u) 0.0) y-series-min)
y-series-range)
y-plot-range)))
(df-select df y-series-name)))
; Create the renderer. The lines renderer is complemented by two hrules at the min/max limits.
; If a label is provided, the top hrule (corresponding to the local max value) is not added.
(let [(basic-plot-data
; TODO: The combination to a 2-vector should be hapenning during the first pass,
; during pre-processing
(list (lines (vector-map (lambda (u v) (vector u v)) x-plot-data y-plot-data)
#:color colour-resolved)
(hrule y-plot-min #:color '(0.0 0.0 0.0))))]
; If the plot-range includes zero, then add a horizontal rule (makes it easier to interpret the plot)
(if (and (> 0 y-series-min)
(< 0 y-series-max))
(cons (hrule (+ (* (/ (abs y-series-min)
y-series-range)
y-plot-range)
y-plot-min)
#:color '(0.0 0.0 0.0))
basic-plot-data)
basic-plot-data)
; If a label is provided add it, otherwise append an hrule at the y-plot-max level.
(if label
(cons (point-label (vector (vector-ref (df-select df x-series-name) 0)
y-plot-max)
label
#:point-sym 'none
#:color '(0.0 0.0 0.0))
basic-plot-data)
(cons
(hrule y-plot-max #:color '(0.0 0.0 0.0))
basic-plot-data))))
; A multiplot is configured via a source data-frame and one or more time-series plot configurations.
(struct data-source-conf (df subplot-confs))
; A subplot configuration includes the X/Y time series names and the time series Y ranges to plot
(struct subplot-conf (x-series-name y-series-name y-series-min y-series-max))
; A subplot trace configuration is basically a subplot-conf, plus the y-plot range and the function
; that accepts plot-units and returns time-series units (more on this later). This is an
; internal data structure.
(struct subplot-trace-conf subplot-conf (y-plot-min y-plot-max plot->series))
; The actual function that generates the plot. It accepts a data-source-conf and returns a plot (snip)
(define (multiplot data
#:width (width 1.0) ;The width of each time series in plot units
#:separation (separation 0.25) ;The separation between the max of one waveform and the min of another (in plot units)
#:start-y (start-y 0.0)) ; The y-min of the first waveform (in plot-units)
; Create the subplot trace data for each time series
; TODO: These should be done with destructuring, it will be more understandable
; Isolate the data frame
(define df (data-source-conf-df data))
; Isolate the list of configurations
(define confs (data-source-conf-subplot-confs data))
; Given a plot configuration, generate a trace configuration which basically arranges each
; plot in a stacked array of plots
(define traces
(for/list [(a-subplot-conf (in-list confs))
(k (in-range 0 (length confs)))]
(let* ([y-series-name (subplot-conf-y-series-name a-subplot-conf)]
[y-series-max (subplot-conf-y-series-max a-subplot-conf)]
[y-series-min (subplot-conf-y-series-min a-subplot-conf)]
[stats (and (or (eq? y-series-min 'series-min)
(eq? y-series-max 'series-max))
(df-statistics df y-series-name))]
[y-series-max-resolved (or (and (eq? y-series-max 'series-max) (statistics-max stats)) y-series-max)]
[y-series-min-resolved (or (and (eq? y-series-min 'series-min) (statistics-min stats)) y-series-min)]
[y-plot-starts-at (+ start-y (* k (+ width separation)))]
[y-plot-ends-at (+ y-plot-starts-at width)])
(subplot-trace-conf (subplot-conf-x-series-name a-subplot-conf)
y-series-name
y-series-min-resolved
y-series-max-resolved
y-plot-starts-at
y-plot-ends-at
; Given a d value WITHIN the plot-range, it produces a linear mapping of d to
; the time-series range. If d is OUTSIDE of the plot-range, it produces #f
(lambda (d)
(and
(>= d y-plot-starts-at)
(<= d y-plot-ends-at)
(+ y-series-min-resolved
(* (/ (- d y-plot-starts-at)
(- y-plot-ends-at y-plot-starts-at))
(- y-series-max-resolved y-series-min-resolved)))))))))
; This is basically a linear-ticks-layout with a custom label function that chooses the label
; value depending on the y-plot-range
(define (segmented-ticks #:number [number 10]
#:base [base 10]
#:divisors [divisors '(1 2 4 5)])
(ticks
(linear-ticks-layout #:number number
#:base base
#:divisors divisors)
(lambda (u v w)
(for/list [(k (in-list w))]
(let* [(current-value (pre-tick-value k))
; TODO: Could not (apply or ...), but this ormap is simplistic, needs improvement
(current-label-value (ormap (lambda (m) m) (for/list ([l (in-list traces)]) ((subplot-trace-conf-plot->series l) current-value))))]
; If a number is produced it is converted to a label, otherwise to an empty string.
(if (number? current-label-value)
; TODO: This 2 here, should really be a user define parameter.
(real->plot-label current-label-value 2)
""))))))
; Build a palette for the N waveforms being plotted
; TODO: The colour of each waveform should become a subplot-conf parameter
(define palette (build-palette (length traces)))
; Create the plot
; TODO: It should be noted that any plot-y-ticks parameterization will be overriden by multiplot
(parameterize [(plot-y-ticks (segmented-ticks))]
(plot-snip
(for/list ([a-trace (in-list traces)]
[a-color (in-list palette)])
(plot-param->line df
(subplot-conf-x-series-name a-trace)
(subplot-conf-y-series-name a-trace)
(subplot-conf-y-series-min a-trace)
(subplot-conf-y-series-max a-trace)
(subplot-trace-conf-y-plot-min a-trace)
(subplot-trace-conf-y-plot-max a-trace)
#:label (subplot-conf-y-series-name a-trace)
#:colour a-color))
; Confine the range of the plot
#:y-min start-y
#:y-max (+ start-y (* (length traces) (+ width separation)))
; TODO: These should become user defined parameters
#:height 1280
#:width 720)))
; ------------------------------
; -- USAGE EXAMPLE
; Data file to use in this example (if available)
; The CSV should only contain numeric data
; (define data-file-name "some/csv/data/file")
; Construct the data-frame from CSV
; (define df (df-read/csv data-file-name))
; Here, I am building a dummy data-frame for demo purposes
; The dummy data-frame contains 16 time-series under a common reference channel called RefX
; Each time seres is of length 100
(define df (build-random-data-frame-with-common-reference 16 100))
; Get all the time-series name minus the RefX
(define sn (remove* '("RefX") (df-series-names df)))
; Define the data for the multiplot
(define my-plot (data-source-conf df
(for/list ([a-series-name (in-list sn)])
(subplot-conf "RefX"
a-series-name
'series-min
'series-max))))
; Generate the plot (notice here, still able to parameterize if required)
(define p-snip (parameterize [(plot-x-label "Time (Samples)")
(plot-y-label "")]
(multiplot my-plot)))
p-snip
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment