From ed2bc3c03978db7ec20aaf27a93682838c9a1d00 Mon Sep 17 00:00:00 2001 From: Josh Day Date: Tue, 3 Dec 2024 11:50:38 -0500 Subject: [PATCH] rewrite kinda a lot --- Artifacts.toml | 6 +- Project.toml | 5 +- src/PlotlyLight.jl | 154 ++++++++++++++++++++++++++++++--------------- src/json.jl | 42 +++++++++++++ test/quarto.qmd | 9 +++ test/runtests.jl | 47 +++++++++----- 6 files changed, 192 insertions(+), 71 deletions(-) create mode 100644 src/json.jl create mode 100644 test/quarto.qmd diff --git a/Artifacts.toml b/Artifacts.toml index 6ee8e47..a3f3ae9 100644 --- a/Artifacts.toml +++ b/Artifacts.toml @@ -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" diff --git a/Project.toml b/Project.toml index 7d37d4a..ea25412 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "PlotlyLight" uuid = "ca7969ec-10b3-423e-8d99-40f33abb42bf" authors = ["joshday "] -version = "0.10.0" +version = "0.11.0" [deps] Artifacts = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" @@ -9,9 +9,7 @@ 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" @@ -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] diff --git a/src/PlotlyLight.jl b/src/PlotlyLight.jl index cf43ce3..71a6905 100644 --- a/src/PlotlyLight.jl +++ b/src/PlotlyLight.jl @@ -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" @@ -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)) @@ -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: +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, "") +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_` overwrites `settings.layout.template` @@ -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/mathjax@3.2.2/es5/tex-svg.js"))), ) ) diff --git a/src/json.jl b/src/json.jl new file mode 100644 index 0000000..98a9176 --- /dev/null +++ b/src/json.jl @@ -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), ',', '{', '}') diff --git a/test/quarto.qmd b/test/quarto.qmd new file mode 100644 index 0000000..9676f0f --- /dev/null +++ b/test/quarto.qmd @@ -0,0 +1,9 @@ +--- +engine: julia +--- + +```{julia} +using PlotlyLight + +plot() +``` diff --git a/test/runtests.jl b/test/runtests.jl index c675fa5..92e9d4b 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,5 +1,5 @@ using PlotlyLight -using PlotlyLight: settings +using PlotlyLight: settings, Plot, json using Cobweb using Cobweb: h using JSON3: JSON3 @@ -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 @@ -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 @@ -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