Skip to content

Improve Discrete Axis Specification - #71

Merged
slemus9 merged 4 commits into
mainfrom
issue-70-discrete-axis
Apr 11, 2026
Merged

Improve Discrete Axis Specification#71
slemus9 merged 4 commits into
mainfrom
issue-70-discrete-axis

Conversation

@slemus9

@slemus9 slemus9 commented Mar 28, 2026

Copy link
Copy Markdown
Contributor

Purpose

Fixes #70

Currently, a user can specify Categorical/Discrete axis for a LineChart. However, in order to so, the user has to take care of the mapping between the categories and their actual numeric values on the axis. For example:

lineChart(
  "My Discrete Chart",
  pointsSeries("Values by days", 1.0 -> 4.5, 2.0 -> 1.0, 3.0 -> 6.3, 4.0 -> 3.3, 5.0 -> 2.1)
).withXAxis(Axis.CategoryScale("Days", NonEmptyVector("Mon", "Tue", "Wed", "Thu", "Fri")))

Here the user needs to take into account the following mapping when building the chart: "Mon" -> 1.0, "Tue" -> 2.0, "Wed" -> 3.0, "Thu" -> 4.0, "Fri" -> 5.0. This is error-prone and allows for values that shouldn't be valid; for instance, the user is allowed to map a discrete category into a decimal position on the axis.

A more user-friendly specification would be a direct map from the String categories to the co-domain values like this:

lineChart(
  "My Discrete Chart",
  pointsSeries("Values by days", "Mon" -> 4.5, "Tue" -> 1.0, "Wed" -> 6.3, "Thu" -> 3.3, "Fri" -> 2.1)
).withXAxisLabel("Days")

Here the user doesn't have to worry about the specific numeric position of each category, as we can infer those and compute them behind the scenes

The goal of this PR is to constrain the specification of the LineChart so that it prevents us from building some invalid permutations between axis and line series, as well as to simplify the DSL and expose more user-friendly functions to build Discrete charts

Design

In order to allow Discrete String categories in the LineChart specification, we have to open up the LineSeries type so that it can receive different type of values for the Domain and Codomain of the plot. The main change proposed by this PR is to index the LineChart type by the Domain and Codomain types:

  case LineChart[Dom, Range](
      title: Option[String],
      series: Vector[LineSeries[Dom, Range]],
      xAxis: Axis[Dom],
      yAxis: Axis[Range]
  )

However, we can not allow any type to act as the Domain/Codomain, we also need to constrain it to the types that we can effectively process; in this case, we will only allow Strings and Doubles (for now). To implement this restriction we define the following ADT

enum Domain[Type]:
  /** Evidence that [[Type]] is equal to one of the valid Domain Types.
    * This is useful to convert from a generic [[Type]] to a more specific [[Domain.Types]]
    * (and vice versa)
    */
  val eq: Type EqualToEither Domain.Types // eq : Type =:= Double | Type =:= String

  case Reals[Type](override val eq: Type =:= Double)    extends Domain[Type]
  case Discrete[Type](override val eq: Type =:= String) extends Domain[Type]

Domain represents all the valid types that can be given as the Domain or Codomain to a Line Chart. Each case of the ADT has an object eq object that will help us pattern-match on the Domain when we are in the scope of a generic function. For example:

def processAxis[Type](axis: Axis[Type], values: Vector[Type])(using domain: Domain[Type]) = 
  domain match
    case Axis.Discrete(eq) =>
      val stringAxis = eq.substituteCo(axis) // Axis[String]
      val stringValues = eq.substituteCo(values) // Vector[String]
    case Axis.Reals(eq) => ...

We can now constraint the DSL by requiring an implicit evidence showing that the given Type is a valid Domain type:

final case class LineSeries[Dom: Domain as domain, Range: Domain as range](
    label: String,
    data: Vector[(Dom, Range)]
)

Finally, we can write some generic functions that will map the line series data points to the actual numeric values in accordance to the Axis specification:

  private def getAxisPosition[Type: Domain as domain](
      axis: Axis[Type],
      value: Type
  ): Option[Double] =

Note that the Axis is also indexed by the type of the Domain. This will make sure that we can not construct Axis that are not coherent with the type of the Line Series

enum Axis[Type]:
  case LinearScale(label: String, min: Option[Double], max: Option[Double]) extends Axis[Double]
  case CategoryScale(label: String, categories: Vector[String]) extends Axis[String]

For example, the following expression won't compile:

lineChart(
  "My Chart",
  pointsSeries("series", "Mon" -> 10.0, "Wed" -> 20.0, "Sat" -> 30.0)
).withXAxis(Axis.LinearScale("Range", min = Some(100), max = Some(200))
Found:    florence.core.model.Axis.LinearScale
Required: florence.core.model.Axis[String]

DSL changes

We modify the DSL so that the user doesn't need to specify the Axis.CategoryScale. We will infer the positions of the Discrete categories based on the order of the line series sequences that the user created.

We assume that the categories that we should display in the axis are the unique labels from the sequence of categories, and we show them in the order that they first appear. For example, If we receive the following labels:

List("Mon", "Tue", "Wed", "Tue", "Mon", "Thu", "Sat")

The order in which we show them in the axis is:

List("Mon", "Tue", "Wed", "Thu", "Sat")

Since the user can specify multiple line series, we traverse all the series in the same order that the user wrote, and we yield a single axis specification. For example, if the user created the following sequence of series:

series = List(
  List("Mon" -> 1.0, "Tue" -> 2.0, "Mon" -> 3.0, "Fri" -> 4.0), 
  List("Sat" -> 5.0, "Sun" -> 10.0), 
  List("Thu" -> 6.0, "Mon" -> 12.0, "Sat" -> 5.0)
)

The order in which we display the categories in the x-axis is:

List("Mon", "Tue", "Fri", "Sat", "Sun", "Thu")

The user can still manually build a Axis.CategoryScale to specify the order in which the labels should appear on the axis

Result

Fully inferred categorical axis

lineChart(
  "Discrete Chart",
  pointsSeries("series 1", "c1" -> 2.0, "c2" -> 5.0, "c3" -> 1.0),
  pointsSeries("series 2", "c4" -> 6.0, "c5" -> 10.0),
  pointsSeries("series 3", "c1" -> 1.0, "c3" -> 2.0, "c5" -> 3.0, "c6" -> 4.0)
)
image

Constraining the values of the categorical axis

In this example, the plot will only show the categories that belong to the Axis.CategoryScale sequence

lineChart(
  "Discrete Chart",
  pointsSeries("series 1", "c1" -> 2.0, "c2" -> 5.0, "c3" -> 1.0),
  pointsSeries("series 2", "c4" -> 6.0, "c5" -> 10.0),
  pointsSeries("series 3", "c1" -> 1.0, "c3" -> 2.0, "c5" -> 3.0, "c6" -> 4.0)
).withXAxis(Axis.CategoryScale("Cats", Vector("c2", "c3", "c4", "c5")))
image

Specifying the order of the categories in the axis

in this example, we swap the order between categories "c3" and "c4"

lineChart(
  "Discrete Chart",
  pointsSeries("series 1", "c1" -> 2.0, "c2" -> 5.0, "c3" -> 1.0),
  pointsSeries("series 2", "c4" -> 6.0, "c5" -> 10.0),
  pointsSeries("series 3", "c1" -> 1.0, "c3" -> 2.0, "c5" -> 3.0, "c6" -> 4.0)
).withXAxis(Axis.CategoryScale("Cats", Vector("c2", "c4", "c3", "c5")))
image

@slemus9 slemus9 self-assigned this Mar 28, 2026
@slemus9
slemus9 force-pushed the issue-70-discrete-axis branch from 6ff9b51 to 1ebba03 Compare March 28, 2026 00:54
@slemus9
slemus9 force-pushed the issue-70-discrete-axis branch from 1ebba03 to 5233fd0 Compare March 28, 2026 00:57
@slemus9
slemus9 marked this pull request as ready for review March 28, 2026 18:36
@slemus9 slemus9 changed the title Improve Discrete Axis Specification [WIP] Improve Discrete Axis Specification Mar 28, 2026
Comment thread florence-core/src/florence/core/model/Chart.scala
Comment thread florence-core/src/florence/core/model/Chart.scala
Comment thread florence-core/src/florence/core/model/Chart.scala
Comment thread florence-core/src/florence/core/rendering/LineChartInterpreter.scala Outdated
@slemus9
slemus9 merged commit 179a352 into main Apr 11, 2026
1 check passed
@slemus9
slemus9 deleted the issue-70-discrete-axis branch April 11, 2026 14:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve Developer Experience when creating a Plot with Discrete Axis

2 participants