Skip to content

Commit

Permalink
rewrite kinda a lot
Browse files Browse the repository at this point in the history
  • Loading branch information
joshday committed Dec 3, 2024
1 parent 4cdaf06 commit ed2bc3c
Show file tree
Hide file tree
Showing 6 changed files with 192 additions and 71 deletions.
6 changes: 3 additions & 3 deletions Artifacts.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[plotly_artifacts]
git-tree-sha1 = "d95fb1315e47ef8ed0e43ddb04ab5d6328473637"
git-tree-sha1 = "aa67a2d0c294419c5568a236d0fdf8de18af2bd6"

[[plotly_artifacts.download]]
sha256 = "4f8fcc209707eb3c5c05de1b427d681e5d9220d467e7481c0011f51c14b493af"
url = "https://gist.github.com/joshday/2e3eab14b37ae13c0ed5b9bc9cd1b29c/raw/d95fb1315e47ef8ed0e43ddb04ab5d6328473637.tar.gz"
sha256 = "9e8f6feca5d99705364e99f931e7b050965b23aed7599c3011a1408c2be75bda"
url = "https://gist.github.com/joshday/9c5faed42fa19016ce0e5c5677c9ac4e/raw/aa67a2d0c294419c5568a236d0fdf8de18af2bd6.tar.gz"
5 changes: 1 addition & 4 deletions Project.toml
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
name = "PlotlyLight"
uuid = "ca7969ec-10b3-423e-8d99-40f33abb42bf"
authors = ["joshday <[email protected]>"]
version = "0.10.0"
version = "0.11.0"

[deps]
Artifacts = "56f22d72-fd6d-98f1-02f0-08ddc0907c33"
Cobweb = "ec354790-cf28-43e8-bb59-b484409b7bad"
Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6"
EasyConfig = "acab07b0-f158-46d4-8913-50acef6d41fe"
JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb"
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
StructTypes = "856f2bd8-1eba-4b0a-8007-ebc267875bd4"

[compat]
Aqua = "0.8"
Expand All @@ -20,7 +18,6 @@ Cobweb = "0.6, 0.7"
Downloads = "1.6"
EasyConfig = "0.1"
JSON3 = "1.14"
StructTypes = "1.10"
julia = "1.7"

[extras]
Expand Down
154 changes: 105 additions & 49 deletions src/PlotlyLight.jl
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,29 @@ module PlotlyLight
using Artifacts: @artifact_str
using Downloads: download
using Random: randstring
using REPL: REPL

using JSON3: JSON3
using EasyConfig: Config
using StructTypes: StructTypes
using Cobweb: Cobweb, h, IFrame, Node

#-----------------------------------------------------------------------------# exports
export Plot, Config, preset, plot
export Config, preset, Plot, plot

#-----------------------------------------------------------------------------# __init__
function __init__()
# Hack since extensions with REPL are wonky
for M in Base.loaded_modules_order
if Symbol(M) == :REPL
@eval Base.display(::$M.REPLDisplay, o::Plot) = Cobweb.preview(html_page(o))
end
end
end

include("json.jl")

#-----------------------------------------------------------------------------# PlotlyArtifacts
artifact(x...) = joinpath(artifact"plotly_artifacts", x...)

#-----------------------------------------------------------------------------# plotly::PlotlyArtifacts
Base.@kwdef struct PlotlyArtifacts
version::VersionNumber = VersionNumber(read(artifact("version.txt"), String))
url::String = "https://cdn.plot.ly/plotly-$version.min.js"
Expand All @@ -28,22 +38,36 @@ plotly::PlotlyArtifacts = PlotlyArtifacts()

#-----------------------------------------------------------------------------# Settings
Base.@kwdef mutable struct Settings
src::Node = h.script(src="https://cdn.plot.ly/plotly-$(plotly.version).min.js", charset="utf-8")
div::Node = h.div(; style="height:100vh;width:100vw;")
src::Node = h.script(src=plotly.url, charset="utf-8")
div::Node = h.div(; class="plotlylight-plot-div")
layout::Config = Config()
config::Config = Config(responsive=true)
config::Config = Config(responsive=true, displaylogo=false)
reuse_preview::Bool = true
style::Dict{String,String} = Dict("display" => "block", "border" => "none", "min-height" => "350px", "min-width" => "350px", "width" => "100%", "height" => "100%")
inject_head::Union{Nothing, Node} = nothing
page_css::Cobweb.Node = h.style("html, body { padding: 0px; margin: 0px; }")
iframe_style = "display:block; border:none; min-height:350px; min-width:350px; width:100%; height:100%"
src_inject::Vector = []
end
settings::Settings = Settings()

#-----------------------------------------------------------------------------# utils/other
# Hack to change behavior of `JSON3.write` for `AbstractMatrix`
_fix(x::Config) = Config(k => _fix(v) for (k,v) in pairs(x))
_fix(x) = x
_fix(x::AbstractMatrix) = eachrow(x)
function Settings(s::Settings; kw...)
s2 = deepcopy(s)
for (k, v) in kw
setfield!(s2, k, v)
end
return s2
end

function with_settings(f; kw...)
old = settings
try
global settings = Settings(settings; kw...)
f(settings)
finally
global settings = old
end
end

#-----------------------------------------------------------------------------# utils/other
attributes(t::Symbol) = plotly.schema.traces[t].attributes
check_attribute(trace, attr::Symbol) = haskey(attributes(Symbol(trace)), attr) || @warn("`$trace` does not have attribute `$attr`.")
check_attributes(trace; kw...) = foreach(k -> check_attribute(Symbol(trace), k), keys(kw))
Expand All @@ -53,63 +77,91 @@ mutable struct Plot
data::Vector{Config}
layout::Config
config::Config
Plot(data::Vector{Config}, layout::Config = Config(), config::Config = Config()) = new(data, Config(layout), Config(config))
Plot(data::AbstractVector, layout = Config(), config = Config()) = new(Config.(data), Config(layout), Config(config))
Plot(data, layout = Config(), config = Config()) = new([Config(data)], Config(layout), Config(config))
end

Plot(data::Config, layout::Config = Config(), config::Config = Config()) = Plot([data], layout, config)
Plot(; layout=Config(), config=Config(), kw...) = Plot(Config(kw), Config(layout), Config(config))
Base.:(==)(a::Plot, b::Plot) = all(getfield(a,f) == getfield(b,f) for f in fieldnames(Plot))

save(p::Plot, file::AbstractString) = open(io -> print(io, html_page(p)), file, "w")
save(file::AbstractString, p::Plot) = save(p, file)

(p::Plot)(; kw...) = p(Config(kw))
(p::Plot)(data::Config) = (push!(p.data, data); return p)
(p::Plot)(p2::Plot) = (append!(p.data, p2.data); merge!(p.layout, p2.layout); merge!(p.config, p2.config); p)
(p::Plot)(p2::Plot) = merge!(p, p2)

StructTypes.StructType(::Plot) = StructTypes.Struct()
Base.:(==)(a::Plot, b::Plot) = all(getfield(a,f) == getfield(b,f) for f in fieldnames(Plot))
function Plot(; kw...)
Base.depwarn("`Plot(; kw...)` is deprecated. Use `plot(; kw...)` instead.", :Plot, force=true)
plot(; kw...)
end

Base.getproperty(p::Plot, x::Symbol) = x in fieldnames(Plot) ? getfield(p, x) : (; kw...) -> p(plot(; type=x, kw...))
Base.propertynames(p::Plot) = vcat(fieldnames(Plot)..., keys(plotly.schema.traces)...)

save(p::Plot, file::AbstractString) = open(io -> print(io, html_page(p)), file, "w")
save(file::AbstractString, p::Plot) = save(p, file)
Base.merge!(a::Plot, b::Plot) = (append!(a.data, b.data); merge!(a.layout, b.layout); merge!(a.config, b.config); a)

#-----------------------------------------------------------------------------# plot
plot(; kw...) = plot(get(kw, :type, :scatter); kw...)
plot(trace; kw...) = (check_attributes(trace; kw...); Plot(; type=trace, kw...))
Base.propertynames(::typeof(plot)) = sort!(collect(keys(plotly.schema.traces)))
Base.getproperty(::typeof(plot), x::Symbol) = (; kw...) -> plot(x; kw...)

#-----------------------------------------------------------------------------# display/show
function html_div(o::Plot; id=randstring(10))
data = JSON3.write(_fix.(o.data); allow_inf=true)
layout = JSON3.write(merge(settings.layout, o.layout); allow_inf=true)
config = JSON3.write(merge(settings.config, o.config); allow_inf=true)
h.div(class="plotlylight-parent-div",
settings.src,
settings.div(; id, class="plotlylight-plot-div"),
h.script("Plotly.newPlot(\"$id\", $data, $layout, $config)")
)
function plot(; layout = Config(), config=Config(), type=:scatter, kw...)
check_attributes(type; kw...)
data = isempty(kw) ? Config[] : [Config(; type, kw...)]
Plot(data, layout, config)
end
Base.propertynames(::typeof(plot)) = keys(plotly.schema.traces)
Base.getproperty(::typeof(plot), type::Symbol) = (; kw...) -> plot(; type=type, kw...)


#-----------------------------------------------------------------------------# NewPlotScript
# PlotlyX representation of: <script>Plotly.newPlot("$id", $data, $layout, $config)</script>
struct NewPlotScript
plot::Plot
settings::Settings
id::String
end
function Base.show(io::IO, ::MIME"text/html", o::NewPlotScript)
layout = merge(o.settings.layout, o.plot.layout)
config = merge(o.settings.config, o.plot.config)
print(io, "<script>Plotly.newPlot(\"", o.id, "\",")
json(io, o.plot.data); print(io, ',')
json(io, layout); print(io, ',')
json(io, config)
print(io, ")</script>")
end

#-----------------------------------------------------------------------------# display
rand_id() = "plotlyx-" * join(rand('a':'z', 10))

function html_div(o::Plot, id=rand_id())
h.div(class="plotlylight-parent", settings.src, settings.src_inject..., settings.div(; id), NewPlotScript(o, settings, id))
end
function html_page(o::Plot)

function html_page(o::Plot, id=rand_id())
h.html(
h.head(
h.meta(charset="utf-8"),
h.meta(name="viewport", content="width=device-width, initial-scale=1"),
h.meta(name="description", content="PlotlyLight.jl"),
h.meta(name="description", content="PlotlyLight.jl Plot"),
h.title("PlotlyLight.jl"),
h.style("html, body { padding: 0px; margin: 0px; } /* remove scrollbar in iframe */"),
isnothing(settings.inject_head) ? "" : settings.inject_head
settings.page_css,
settings.src_inject...,
settings.src
),
h.body(html_div(o))
h.body(h.div(class="plotlylight-parent", settings.div(; id), NewPlotScript(o, settings, id)))
)
end
function html_iframe(o::Plot; style=settings.style)
IFrame(html_page(o); style=join(["$k:$v" for (k,v) in style], ';'))
end
Base.show(io::IO, ::MIME"text/html", o::Plot) = show(io, MIME"text/html"(), html_iframe(o))
Base.show(io::IO, ::MIME"juliavscode/html", o::Plot) = show(io, MIME"text/html"(), o)

Base.display(::REPL.REPLDisplay, o::Plot) = Cobweb.preview(h.html(h.body(o, style="margin: 0px;")), reuse=settings.reuse_preview)
function html_iframe(o::Plot, id=rand_id(), kw...)
with_settings() do s
s.div.style = "height:100vh; width:100vw"
Cobweb.IFrame(html_page(o, id); style=s.iframe_style, kw...)
end
end

mathjax_script = h.script(type="text/javascript", async=true, src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js")
function Base.show(io::IO, ::MIME"text/html", o::Plot)
get(io, :jupyter, false) ?
show(io, MIME("text/html"), html_iframe(o)) :
show(io, MIME("text/html"), html_div(o))
end
Base.show(io::IO, ::MIME"juliavscode/html", o) = show(io, MIME("text/html"), o)

#-----------------------------------------------------------------------------# preset
# `preset_template_<X>` overwrites `settings.layout.template`
Expand Down Expand Up @@ -137,6 +189,10 @@ preset = (
cdn! = () -> (settings.src = h.script(src=plotly.url, charset="utf-8"); nothing),
local! = () -> (settings.src = h.script(src=plotly.path, charset="utf-8"); nothing),
standalone! = () -> (settings.src = h.script(read(plotly.path, String), charset="utf-8"); nothing)
),
display = (
fullscreen! = () -> (settings.div.style = "height:100vh; width:100vw"),
mathjax! = () -> (push!(settings.src_inject, h.script(src="https://cdn.jsdelivr.net/npm/[email protected]/es5/tex-svg.js"))),
)
)

Expand Down
42 changes: 42 additions & 0 deletions src/json.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@

#-----------------------------------------------------------------------------# json
function json_join(io::IO, itr, sep, left, right)
print(io, left)
for (i, item) in enumerate(itr)
i == 1 || print(io, sep)
json(io, item)
end
print(io, right)
end

json(io::IO, x) = json_join(io, x, ',', '[', ']') # ***FALLBACK METHOD***

struct JSON{T}
x::T
end
json(io::IO, x::JSON) = print(io, x.x)


json(x) = sprint(json, x)
json(io::IO, args...) = foreach(x -> json(io, x), args)

# Strings
json(io::IO, x::Union{AbstractChar, AbstractString, Symbol}) = print(io, '"', x, '"')

# Numbers
json(io::IO, x::Real) = isfinite(x) ? print(io, x) : print(io, "null")
json(io::IO, x::Rational) = json(io, float(x))

# Nulls
json(io::IO, ::Union{Missing, Nothing}) = print(io, "null")

# Bools
json(io::IO, x::Bool) = print(io, x ? "true" : "false")

# Arrays
json(io::IO, x::AbstractVector) = json_join(io, x, ',', '[', ']')
json(io::IO, x::AbstractArray) = json(io, eachslice(x; dims=1))

# Objects
json(io::IO, x::Pair) = json(io, x.first, JSON(':'), x.second)
json(io::IO, x::Union{NamedTuple, AbstractDict}) = json_join(io, pairs(x), ',', '{', '}')
9 changes: 9 additions & 0 deletions test/quarto.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
engine: julia
---

```{julia}
using PlotlyLight
plot()
```
47 changes: 32 additions & 15 deletions test/runtests.jl
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using PlotlyLight
using PlotlyLight: settings
using PlotlyLight: settings, Plot, json
using Cobweb
using Cobweb: h
using JSON3: JSON3
Expand All @@ -8,32 +8,50 @@ using Aqua

html(x) = repr("text/html", x)

#-----------------------------------------------------------------------------# json
@testset "json" begin
@test json(1) == "1"
@test json(1.0) == "1.0"
@test json(1//2) == "0.5"
@test json([1,2,3]) == "[1,2,3]"
@test json([1.0,2.0,3.0]) == "[1.0,2.0,3.0]"
@test json([1 2; 3 4]) == "[[1,2],[3,4]]"
@test json((x=1,y=2)) == "{\"x\":1,\"y\":2}"
@test json(nothing) == "null"
@test json(true) == "true"
@test json(false) == "false"
@test json("test") == "\"test\""
@test json(missing) == "null"
@test json(NaN) == "null"
@test json(Inf) == "null"
@test json(-Inf) == "null"
end

#-----------------------------------------------------------------------------# Plot methods
@testset "Plot methods" begin
p = Plot(Config(x = 1:10))
p = Plot(Config(x = 1:10, type=:scatter))
@test p isa Plot
@test Plot(; x=1:10) == p
@test Plot(; x=1:10, type=:scatter) == p
@test !occursin("Title", html(p))
@test !occursin("displaylogo", html(p))
@test occursin("\"displaylogo\":false", html(p))

p2 = Plot(Config(x = 1:10), Config(title="Title"))
@test occursin("Title", html(p2))
@test !occursin("displaylogo", html(p2))

p3 = Plot(Config(x = 1:10), Config(title="Title"), Config(displaylogo=true))
@test occursin("Title", html(p3))
@test occursin("displaylogo", html(p3))
@test occursin("\"displaylogo\":true", html(p3))

p4 = Plot()
@test isempty(only(p4.data))
p4(Config(x=1:10,y=1:10))
@test length(p4.data) == 2
p4 = Plot();
@test isempty(p4.data)
@test p4(Config(x=1:10,y=1:10)) isa Plot
@test length(p4.data) == 1
p4(;x=1:10, y=1:10)
@test length(p4.data) == 3
@test p4.data[2] == p4.data[3]
@test length(p4.data) == 2
@test p4.data[1] == p4.data[2]

p5 = p(p2(p3(p4)))
@test length(p5.data) == 6
@test length(p5.data) == 5
end

@testset "plot" begin
Expand All @@ -44,7 +62,7 @@ end

@testset "settings" begin
@test PlotlyLight.settings.layout == Config()
@test PlotlyLight.settings.config == Config(; responsive=true)
@test PlotlyLight.settings.config == Config(; responsive=true, displaylogo=false)
end

@testset "saving" begin
Expand All @@ -61,7 +79,6 @@ end
@testset "other" begin
@test propertynames(Plot()) isa Vector{Symbol}
@test all(x in propertynames(Plot()) for x in propertynames(plot))
@test PlotlyLight._fix([1 2; 3 4]) == [[1, 2], [3, 4]]
@test propertynames(JSON3.read(JSON3.write(Plot()))) == [:data, :layout, :config]
end

Expand Down

0 comments on commit ed2bc3c

Please sign in to comment.