diff --git a/Project.toml b/Project.toml index 2f7adbe..e0a9b6b 100644 --- a/Project.toml +++ b/Project.toml @@ -5,7 +5,9 @@ version = "0.13.2" [deps] Artifacts = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" +Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" Cobweb = "ec354790-cf28-43e8-bb59-b484409b7bad" +CodecZlib = "944b1d66-785c-5afd-91f1-9de20f533193" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" EasyConfig = "acab07b0-f158-46d4-8913-50acef6d41fe" @@ -15,7 +17,9 @@ REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" [compat] Aqua = "0.8" Artifacts = "1" +Base64 = "1.11.0" Cobweb = "0.6, 0.7" +CodecZlib = "0.7.6" Dates = "1" Downloads = "1.6" EasyConfig = "0.1" diff --git a/README.md b/README.md index 2fbc490..ee507be 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ ## ✨ Features - 🚀 Fastest time-to-first-plot in Julia! -- 🌐 Use the [Plotly.js Javascript documentation](https://plotly.com/javascript/) directly. No magic syntax: Just [`JSON3.write`](https://github.com/quinnj/JSON3.jl). +- 🌐 Use the [Plotly.js Javascript documentation](https://plotly.com/javascript/) directly. No magic syntax. - 📂 Set deeply-nested keys easily, e.g. `myplot.layout.xaxis.title.font.family = "Arial"`. - 📊 The Same [built-in themes](https://plotly.com/python/templates/) as Plotly's python package. +- 🗜️ Use `PlotlyLight.preset.display.compress!()` to automatically compress large arrays and produce plots that download and display faster.

diff --git a/src/PlotlyLight.jl b/src/PlotlyLight.jl index dd2c706..22e2c32 100644 --- a/src/PlotlyLight.jl +++ b/src/PlotlyLight.jl @@ -1,6 +1,7 @@ module PlotlyLight using Artifacts: @artifact_str +using Base64 using Downloads: download using Dates using REPL @@ -8,6 +9,7 @@ using REPL using JSON3: JSON3 using EasyConfig: Config using Cobweb: Cobweb, h, IFrame, Node +using CodecZlib #-----------------------------------------------------------------------------# exports export Config, preset, Plot, plot @@ -42,6 +44,7 @@ Base.@kwdef mutable struct Settings use_iframe::Bool = false iframe_style = "display:block; border:none; min-height:350px; min-width:350px; width:100%; height:100%" src_inject::Vector = [] + compress::Bool = false end settings::Settings = Settings() @@ -63,6 +66,14 @@ function with_settings(f; kw...) end end +function get_src_inject(s::Settings) + src_inject = s.src_inject + if s.compress + src_inject = union(src_inject, json_compression_src_inject) + end + return src_inject +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`.") @@ -122,7 +133,7 @@ end rand_id() = "plotlyx-" * join(rand('a':'z', 10)) function html_div(o::Plot, id=rand_id()) - h.div(class="plotlylight-parent", settings.src_inject..., settings.src, settings.div(; id), NewPlotScript(o, settings, id)) + h.div(class="plotlylight-parent", get_src_inject(settings)..., settings.src, settings.div(; id), NewPlotScript(o, settings, id)) end function html_page(o::Plot, id=rand_id()) @@ -133,7 +144,7 @@ function html_page(o::Plot, id=rand_id()) h.meta(name="description", content="PlotlyLight.jl Plot"), h.title("PlotlyLight.jl"), settings.page_css, - settings.src_inject..., + get_src_inject(settings)..., settings.src ), h.body(h.div(class="plotlylight-parent", settings.div(; id), NewPlotScript(o, settings, id))) @@ -188,6 +199,7 @@ preset = ( 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"))), + compress! = (enabled=true) -> (settings.compress = enabled) ) ) diff --git a/src/json.jl b/src/json.jl index 7d95472..5402640 100644 --- a/src/json.jl +++ b/src/json.jl @@ -36,9 +36,92 @@ json(io::IO, ::Union{Missing, Nothing}) = print(io, "null") 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)) +_json_generic_arr(io::IO, x::AbstractVector) = json_join(io, x, ',', '[', ']') +_json_generic_arr(io::IO, x::AbstractArray) = json(io, eachslice(x; dims=1)) +json(io::IO, x::AbstractVector) = _json_generic_arr(io, x) +json(io::IO, x::AbstractArray) = _json_generic_arr(io, x) # 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), ',', '{', '}') + + + +# Compress certain array types for some (huge) space savings for large arrays + +json_compression_src_inject = [ + h.script(src="https://cdn.jsdelivr.net/npm/fflate@0.8.2/umd/index.js"), + h.script(raw""" + function numArrFromBase64(T, base64_dat, ...dims) { + arr = new T(fflate.unzlibSync(Uint8Array.from(atob(base64_dat), c => c.charCodeAt(0))).buffer) + if (dims.length == 1) { + return arr; + } else if (dims.length == 2) { + arr2d = []; + for (let i = 0; i < arr.length; i += dims[1]) { + arr2d.push(arr.subarray(i, i + dims[1])); + } + return arr2d; + } else { + throw new Error(`>2 dims not implemented.`); + } + } + function strVecFromBase64(base64_dat, lens) { + strs = fflate.strFromU8(fflate.unzlibSync(Uint8Array.from(atob(base64_dat), c => c.charCodeAt(0)))); + arr = []; + cur = 0; + for (var i = 0; i < lens.length; i++) { + arr.push(strs.slice(cur, cur + lens[i])); + cur += lens[i]; + } + return arr; + } + """) +] + +json(io::IO, arr::AbstractVector{<:AbstractFloat}) = _json_num_arr(io, arr) +json(io::IO, arr::AbstractMatrix{<:AbstractFloat}) = _json_num_arr(io, arr) +json(io::IO, arr::AbstractVector{<:Integer}) = _json_num_arr(io, arr) +json(io::IO, arr::AbstractMatrix{<:Integer}) = _json_num_arr(io, arr) + +function _to_js_eltype(arr::AbstractArray{<:AbstractFloat}) + # be opinionated and cap at Float32, which should be enough for + # plotting, halving filesize vs Float64 + T = (eltype(arr) == Float16) ? Float16 : Float32 + return convert(AbstractArray{T}, arr) +end + +function _to_js_eltype(arr::AbstractArray{<:Integer}) + # find the smallest integer type that can represent the data + mn, mx = extrema(arr) + types = (UInt8, Int8, UInt16, Int16, UInt32, Int32) + i = findfirst(t -> mn >= typemin(t) && mx <= typemax(t), types) + isnothing(i) && error("Integer values in plot data are too large to fit in UInt32 or Int32.") + T = types[i] + return convert(AbstractArray{T}, arr) +end + +function _json_num_arr(io::IO, arr) + if settings.compress + js_arr = _to_js_eltype(arr) + T = eltype(js_arr) + base64_dat = base64encode(transcode(ZlibCompressor, Vector(reinterpret(UInt8, view(transpose(js_arr), :))))) + dims = join(size(js_arr), ',') + T_js = string(T)[1] * lowercase(string(T)[2:end]) + print(io, "numArrFromBase64($(T_js)Array,'", base64_dat, "',", dims, ")") + else + _json_generic_arr(io, arr) + end +end + +function json(io::IO, arr::AbstractVector{<:AbstractString}) + if settings.compress + # store a (compressed) contatenation of the strings and indices where each element starts + base64_dat = base64encode(transcode(ZlibCompressor, join(arr))) + print(io, "strVecFromBase64('", base64_dat, "',") + json(io, length.(arr)) + print(io, ")") + else + _json_generic_arr(io, arr) + end +end \ No newline at end of file diff --git a/test/runtests.jl b/test/runtests.jl index 266e0d9..84888e4 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -21,6 +21,12 @@ html(x) = repr("text/html", x) @test json(Inf) == "null" @test json(-Inf) == "null" @test json(DateTime(2021,1,1)) == "\"2021-01-01 00:00:00\"" + preset.display.compress!(true) + @test json(Int[1, 2]) == "numArrFromBase64(Uint8Array,'eJxjZAIAAAYABA==',2)" + @test json(Int[1 2; 3 4]) == "numArrFromBase64(Uint8Array,'eJxjZGJmAQAAGAAL',2,2)" + @test json(Float64[1, 2]) == "numArrFromBase64(Float32Array,'eJxjYGiwZ2BgcAAABIMBAA==',2)" + @test json(Float64[1 2; 3 4]) == "numArrFromBase64(Float32Array,'eJxjYGiwZ2BgcAAiIG5wAAAQgwJA',2,2)" + @test json(["a", "b"]) == "strVecFromBase64('eJxLTAIAASYAxA==',numArrFromBase64(Uint8Array,'eJxjZAQAAAUAAw==',2))" end #-----------------------------------------------------------------------------# Plot methods