Skip to content

Basic Makie.jl support #165

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,22 @@ Tricks = "410a4b4d-49e4-4fbc-ab6d-cb71b17b3775"

[weakdeps]
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a"
Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7"
ScientificTypes = "321657f4-b219-11e9-178b-2701a2544e81"
Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d"

[extensions]
DynamicQuantitiesLinearAlgebraExt = "LinearAlgebra"
DynamicQuantitiesMakieExt = "Makie"
DynamicQuantitiesMeasurementsExt = "Measurements"
DynamicQuantitiesScientificTypesExt = "ScientificTypes"
DynamicQuantitiesUnitfulExt = "Unitful"

[compat]
DispatchDoctor = "0.4"
LinearAlgebra = "1"
Makie = ">= 0.21.0"
Measurements = "2"
PrecompileTools = "1"
ScientificTypes = "3"
Expand All @@ -34,6 +37,7 @@ julia = "1.10"

[extras]
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a"
Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7"
ScientificTypes = "321657f4-b219-11e9-178b-2701a2544e81"
Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d"
2 changes: 2 additions & 0 deletions docs/Project.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
[deps]
CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
DynamicQuantities = "06fc5a27-2a28-4c7c-a15d-362465fb6821"
76 changes: 70 additions & 6 deletions docs/src/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,53 +40,58 @@ Also, using `DynamicQuantities.Constants`, we were able to obtain the (dimension
Let's solve a simple projectile motion problem.
First load the `DynamicQuantities` module:

```julia
```@example projectile
using DynamicQuantities
```

Set up initial conditions as quantities:

```julia
```@example projectile
# Can explicitly import units:
using DynamicQuantities: km, m, s, min
y0 = 10km
v0 = 250m/s
θ = deg2rad(60)
g = 9.81m/s^2
nothing # hide
Comment on lines 53 to +57
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
y0 = 10km
v0 = 250m/s
θ = deg2rad(60)
g = 9.81m/s^2
nothing # hide
y0 = 10km
v0 = 250m/s
θ = deg2rad(60)
g = 9.81m/s^2;

A bit simpler. Can you also use ; rather than nothing elsewhere too

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, no dice just yet:

image

I noticed you mention this in your discussion with Abhro in #175, and I've wanted to try it out ever since. Is there something else that I could try? Found some relevant discussion here: JuliaDocs/Documenter.jl#1509

```

Next, we use trig functions to calculate x and y components of initial velocity.
`vx0` is the x component and
`vy0` is the y component:

```julia
```@example projectile
vx0 = v0 * cos(θ)
vy0 = v0 * sin(θ)
nothing # hide
```

Next, let's create a time vector from 0 seconds to 1.3 minutes.
Note that these are the same dimension (time), so it's fine to treat
them as dimensionally equivalent!

```julia
```@example projectile
t = range(0s, 1.3min, length=100)
nothing # hide
```

Next, use kinematic equations to calculate x and y as a function of time.
`x(t)` is the x position at time t, and
`y(t)` is the y position:

```julia
```@example projectile
x(t) = vx0*t
y(t) = vy0*t - 0.5*g*t^2 + y0
nothing # hide
```

These are functions, so let's evaluate them:

```julia
```@example projectile
x_si = x.(t)
y_si = y.(t)
nothing # hide
```

These are regular vectors of quantities
Expand All @@ -106,6 +111,8 @@ Now, we plot:
plot(x_km, y_km, label="Trajectory", xlabel="x [km]", ylabel="y [km]")
```

See [Plotting](@ref) for more plotting support with units.

## 3. Using dimensional angles

Say that we wish to track angles as a unit, rather than assume
Expand Down Expand Up @@ -504,3 +511,60 @@ function my_func(x::UnionAbstractQuantity{T,D}) where {T,D}
return x / ustrip(x)
end
```

### Plotting
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need an explicit example of this? i.e., is it something that users would expect to "just work" anyways?


We can use [`Makie.jl`](https://docs.makie.org/v0.22/) to create plots with units. Below are a few usage examples. See [Makie.jl > Dimension conversion](https://docs.makie.org/stable/explanations/dim-converts#Current-conversions-in-Makie) for more.

!!! warning "Experimental"
Unit support is still a new feature, so please report an issue if you notice any unintended behavior.

Continuing from [2. Projectile motion](@ref), we can also plot `x_si` and `y_si` directly without needing to manually strip their units beforehand:

```@example projectile
using CairoMakie
lines(x_si, y_si; axis=(xlabel="x", ylabel="y"))
```

To convert units, we pass a `DQConversion` object to `axis` with our desired unit:

```@example projectile
# Temporary until this is upstreamed to Makie.jl
const DQConversion = Base.get_extension(DynamicQuantities, :DynamicQuantitiesMakieExt).DQConversion
lines(x_si, y_si;
axis = (
xlabel = "x",
ylabel = "y",
dim1_conversion = DQConversion(us"km"),
dim2_conversion = DQConversion(us"km"),
)
)
```

!!! note
A [Symbolic Dimensions](@ref) object is passed to `DQConversion` for this conversion to work properly. Passing a regular `Quantity`, e.g., (u"km") will throw an error.

Finally, the desired units for a figure can also be set ahead of time. All plot objects within it will automatically convert to the given units:

```@example projectile
fig = Figure()
ax = Axis(fig[1, 1];
xlabel = "time",
ylabel = "displacement",
dim1_conversion=DQConversion(us"s"),
dim2_conversion=DQConversion(us"km"),
)
lines!(ax, t, x_si; label="x")
lines!(ax, t, y_si; label="y")
axislegend()
fig
```

!!! tip
For nice unit support with Makie.jl and tabular data, see [AlgebraOfGraphics.jl > Units](https://aog.makie.org/dev/examples/scales/units#units).
170 changes: 170 additions & 0 deletions ext/DynamicQuantitiesMakieExt.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
module DynamicQuantitiesMakieExt

using DynamicQuantities: UnionAbstractQuantity, SymbolicDimensions, ustrip, dimension
using TestItems: @testitem

import Makie as M
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just out of curiosity, is there a package higher up the plotting tools hierarchy that we should be overloading instead? For example there is RecipesBase.jl.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make MakieCore.jl?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You know, the distinction between MakieCore and Makie proper when it comes to defining recipes and axis conversions is something that I've been trying to clarify for myself too

AFAICT, MakieCore is for recipes, while axis converts (like in this PR) require the whole megillah so things like expand_dimensions and create_dim_conversion will work. At least, that is the sense I get from the docs, which seem to specifically advise using Makie over MakieCore for defining package extensions

Interestingly, it looks like the long term plan may be to phase out MakieCore, but idk what the latest on that is MakieOrg/Makie.jl#3645 (comment)


M.expand_dimensions(::M.PointBased, y::AbstractVector{<:UnionAbstractQuantity}) = (keys(y), y)
M.create_dim_conversion(::Type{<:UnionAbstractQuantity}) = DQConversion()
M.MakieCore.should_dim_convert(::Type{<:UnionAbstractQuantity}) = true

unit_string(quantity::UnionAbstractQuantity) = string(dimension(quantity))

function unit_convert(::M.Automatic, x)
x
end

function unit_convert(quantity::UnionAbstractQuantity, x::AbstractArray)
# Note: unit_convert.(Ref(quantity), x) currently causes broadcasting error for `QuantityArray`s
map(Base.Fix1(unit_convert, quantity), x)
end

function unit_convert(quantity::UnionAbstractQuantity, value)
conv = ustrip(quantity, value)
return Float64(conv)
end

"""
DQConversion(unit=automatic; units_in_label=false)

Allows to plot arrays of DynamicQuantity objects into an axis.

# Arguments
- `unit=automatic`: sets the unit as conversion target. If left at automatic, the best unit will be chosen for all plots + values plotted to the axis (e.g. years for long periods, or km for long distances, or nanoseconds for short times).
- `units_in_label=true`: controls, whether plots are shown in the label_prefix of the axis labels, or in the tick labels

# Examples

```julia
using DynamicQuantities, CairoMakie

# DQConversion will get chosen automatically,
scatter(1:4, [1u"ns", 2u"ns", 3u"ns", 4u"ns"])
```

Fix unit to always use Meter & display unit in the xlabel:

```julia
# Temporary until this is upstreamed to Makie.jl
const DQConversion = Base.get_extension(DynamicQuantities, :DynamicQuantitiesMakieExt).DQConversion

dqc = DQConversion(us"m"; units_in_label=false)

scatter(1:4, [0.01u"km", 0.02u"km", 0.03u"km", 0.04u"km"]; axis=(dim2_conversion=dqc, xlabel="x (km)"))
```
"""
struct DQConversion <: M.AbstractDimConversion
quantity::M.Observable{UnionAbstractQuantity{T1, SymbolicDimensions{T2}} where {T1 <: Real, T2 <: Real}}
automatic_units::Bool
units_in_label::M.Observable{Bool}
end

function DQConversion(quantity=M.automatic; units_in_label=true)
return DQConversion(quantity, quantity isa M.Automatic, units_in_label)
end

M.needs_tick_update_observable(conversion::DQConversion) = conversion.quantity

function M.get_ticks(conversion::DQConversion, ticks, scale, formatter, vmin, vmax)
quantity = conversion.quantity[]
quantity isa M.Automatic && return [], []
unit_str = unit_string(quantity)
tick_vals, labels = M.get_ticks(ticks, scale, formatter, vmin, vmax)
if conversion.units_in_label[]
labels = labels .* unit_str
end
return tick_vals, labels
end

function M.convert_dim_observable(conversion::DQConversion, value_obs::M.Observable, deregister)
# TODO: replace with update_extrema
if conversion.automatic_units
conversion.quantity[] = oneunit(value_obs[][1])
end
result = map(conversion.quantity, value_obs; ignore_equal_values=true) do unit, values
if !isempty(values)
# try if conversion works, to through error if not!
# Is there a function for this to check in DynamicQuantities?
unit_convert(unit, values[1])
end
return M.convert_dim_value(conversion, values)
end
append!(deregister, result.inputs)
return result
end

function M.convert_dim_value(conversion::DQConversion, values)
return unit_convert(conversion.quantity[], values)
end

@testitem "1 arg expansion" begin
using DynamicQuantities, Makie, Dates

f, ax, pl = scatter(u"m" .* (1:10))
@test pl isa Scatter{Tuple{Vector{Point2{Float64}}}}
end

@testitem "recipe" begin
using DynamicQuantities, Makie, Dates
const DQConversion = Base.get_extension(DynamicQuantities, :DynamicQuantitiesMakieExt).DQConversion

@recipe(DQPlot, x) do scene
return Attributes()
end

function Makie.plot!(plot::DQPlot)
return scatter!(plot, plot.x, map(x -> x .* u"s", plot.x))
end

f, ax, pl = dqplot(1:5)

pl_conversion = Makie.get_conversions(pl)
ax_conversion = Makie.get_conversions(ax)

@test pl_conversion[2] isa DQConversion
@test ax_conversion[2] isa DQConversion
@test pl.plots[1][1][] == Point{2,Float32}.(1:5, 1:5)
end

@testitem "unit switching" begin
using DynamicQuantities, Makie
f, ax, pl = scatter((1:10)u"m")
@test_throws DynamicQuantities.DimensionError scatter!(ax, (1:10)u"kg")
@test_throws MethodError scatter!(ax, (1:10))
end

@testitem "observables cleanup" begin
using DynamicQuantities, Makie

function test_cleanup(arg)
obs = Observable(arg)
f, ax, pl = scatter(obs)
@test length(obs.listeners) == 1
delete!(ax, pl)
@test length(obs.listeners) == 0
end

test_cleanup([0.01u"km", 0.02u"km", 0.03u"km", 0.04u"km"])
end

# TODO: Move upstream to Makie.jl
@testitem "reftest" begin
using DynamicQuantities, Makie
const DQConversion = Base.get_extension(DynamicQuantities, :DynamicQuantitiesMakieExt).DQConversion

fig = Figure()

ax1 = Axis(fig[1, 1]; dim2_conversion=DQConversion(us"J/s"))
ax2 = Axis(fig[1, 2]; dim2_conversion=DQConversion(us"mm/m^2"))
ax3 = Axis(fig[2, 1]; dim1_conversion=DQConversion(us"W/m^2"), dim2_conversion=DQConversion(us"μm"))
ax4 = Axis(fig[2, 2]; dim1_conversion=DQConversion(us"W/m^2"))

scatter!(ax1, (1:10) .* u"J/s")
scatter!(ax2, (1:10) .* u"K", exp.(1:10) .* u"mm/m^2")
scatter!(ax3, 10 .^ (1:6) .* u"W/m^2", (1:6) .* 1000 .* u"nm")
scatter!(ax4, (0:10) .* u"W/m^2", (0:10) .* u"g")
scatter!(ax4, (0:10) .* u"kW/m^2", (0:10) .* u"kg")
end

end
1 change: 1 addition & 0 deletions test/Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
DispatchDoctor = "8d63f2c5-f18a-4cf2-ba9d-b3f60fc568c8"
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a"
Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7"
Ratios = "c84ed2f1-dad5-54f0-aa8e-dbefe2724439"
SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f"
Expand Down
Loading