Skip to content

Commit 8e97c9e

Browse files
authored
compress json data arrays (#62)
* compress json data arrays * make json compression opt-in * forgot left-over src_inject
1 parent 0fb1351 commit 8e97c9e

5 files changed

Lines changed: 111 additions & 5 deletions

File tree

Project.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ version = "0.13.2"
55

66
[deps]
77
Artifacts = "56f22d72-fd6d-98f1-02f0-08ddc0907c33"
8+
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
89
Cobweb = "ec354790-cf28-43e8-bb59-b484409b7bad"
10+
CodecZlib = "944b1d66-785c-5afd-91f1-9de20f533193"
911
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
1012
Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6"
1113
EasyConfig = "acab07b0-f158-46d4-8913-50acef6d41fe"
@@ -15,7 +17,9 @@ REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb"
1517
[compat]
1618
Aqua = "0.8"
1719
Artifacts = "1"
20+
Base64 = "1.11.0"
1821
Cobweb = "0.6, 0.7"
22+
CodecZlib = "0.7.6"
1923
Dates = "1"
2024
Downloads = "1.6"
2125
EasyConfig = "0.1"

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515
## ✨ Features
1616

1717
- 🚀 Fastest time-to-first-plot in Julia!
18-
- 🌐 Use the [Plotly.js Javascript documentation](https://plotly.com/javascript/) directly. No magic syntax: Just [`JSON3.write`](https://github.com/quinnj/JSON3.jl).
18+
- 🌐 Use the [Plotly.js Javascript documentation](https://plotly.com/javascript/) directly. No magic syntax.
1919
- 📂 Set deeply-nested keys easily, e.g. `myplot.layout.xaxis.title.font.family = "Arial"`.
2020
- 📊 The Same [built-in themes](https://plotly.com/python/templates/) as Plotly's python package.
21+
- 🗜️ Use `PlotlyLight.preset.display.compress!()` to automatically compress large arrays and produce plots that download and display faster.
2122

2223
<br><br>
2324

src/PlotlyLight.jl

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
module PlotlyLight
22

33
using Artifacts: @artifact_str
4+
using Base64
45
using Downloads: download
56
using Dates
67
using REPL
78

89
using JSON3: JSON3
910
using EasyConfig: Config
1011
using Cobweb: Cobweb, h, IFrame, Node
12+
using CodecZlib
1113

1214
#-----------------------------------------------------------------------------# exports
1315
export Config, preset, Plot, plot
@@ -42,6 +44,7 @@ Base.@kwdef mutable struct Settings
4244
use_iframe::Bool = false
4345
iframe_style = "display:block; border:none; min-height:350px; min-width:350px; width:100%; height:100%"
4446
src_inject::Vector = []
47+
compress::Bool = false
4548
end
4649
settings::Settings = Settings()
4750

@@ -63,6 +66,14 @@ function with_settings(f; kw...)
6366
end
6467
end
6568

69+
function get_src_inject(s::Settings)
70+
src_inject = s.src_inject
71+
if s.compress
72+
src_inject = union(src_inject, json_compression_src_inject)
73+
end
74+
return src_inject
75+
end
76+
6677
#-----------------------------------------------------------------------------# utils/other
6778
attributes(t::Symbol) = plotly.schema.traces[t].attributes
6879
check_attribute(trace, attr::Symbol) = haskey(attributes(Symbol(trace)), attr) || @warn("`$trace` does not have attribute `$attr`.")
@@ -122,7 +133,7 @@ end
122133
rand_id() = "plotlyx-" * join(rand('a':'z', 10))
123134

124135
function html_div(o::Plot, id=rand_id())
125-
h.div(class="plotlylight-parent", settings.src_inject..., settings.src, settings.div(; id), NewPlotScript(o, settings, id))
136+
h.div(class="plotlylight-parent", get_src_inject(settings)..., settings.src, settings.div(; id), NewPlotScript(o, settings, id))
126137
end
127138

128139
function html_page(o::Plot, id=rand_id())
@@ -133,7 +144,7 @@ function html_page(o::Plot, id=rand_id())
133144
h.meta(name="description", content="PlotlyLight.jl Plot"),
134145
h.title("PlotlyLight.jl"),
135146
settings.page_css,
136-
settings.src_inject...,
147+
get_src_inject(settings)...,
137148
settings.src
138149
),
139150
h.body(h.div(class="plotlylight-parent", settings.div(; id), NewPlotScript(o, settings, id)))
@@ -188,6 +199,7 @@ preset = (
188199
display = (
189200
fullscreen! = () -> (settings.div.style = "height:100vh; width:100vw"),
190201
mathjax! = () -> (push!(settings.src_inject, h.script(src="https://cdn.jsdelivr.net/npm/[email protected]/es5/tex-svg.js"))),
202+
compress! = (enabled=true) -> (settings.compress = enabled)
191203
)
192204
)
193205

src/json.jl

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,92 @@ json(io::IO, ::Union{Missing, Nothing}) = print(io, "null")
3636
json(io::IO, x::Bool) = print(io, x ? "true" : "false")
3737

3838
# Arrays
39-
json(io::IO, x::AbstractVector) = json_join(io, x, ',', '[', ']')
40-
json(io::IO, x::AbstractArray) = json(io, eachslice(x; dims=1))
39+
_json_generic_arr(io::IO, x::AbstractVector) = json_join(io, x, ',', '[', ']')
40+
_json_generic_arr(io::IO, x::AbstractArray) = json(io, eachslice(x; dims=1))
41+
json(io::IO, x::AbstractVector) = _json_generic_arr(io, x)
42+
json(io::IO, x::AbstractArray) = _json_generic_arr(io, x)
4143

4244
# Objects
4345
json(io::IO, x::Pair) = json(io, x.first, JSON(':'), x.second)
4446
json(io::IO, x::Union{NamedTuple, AbstractDict}) = json_join(io, pairs(x), ',', '{', '}')
47+
48+
49+
50+
# Compress certain array types for some (huge) space savings for large arrays
51+
52+
json_compression_src_inject = [
53+
h.script(src="https://cdn.jsdelivr.net/npm/[email protected]/umd/index.js"),
54+
h.script(raw"""
55+
function numArrFromBase64(T, base64_dat, ...dims) {
56+
arr = new T(fflate.unzlibSync(Uint8Array.from(atob(base64_dat), c => c.charCodeAt(0))).buffer)
57+
if (dims.length == 1) {
58+
return arr;
59+
} else if (dims.length == 2) {
60+
arr2d = [];
61+
for (let i = 0; i < arr.length; i += dims[1]) {
62+
arr2d.push(arr.subarray(i, i + dims[1]));
63+
}
64+
return arr2d;
65+
} else {
66+
throw new Error(`>2 dims not implemented.`);
67+
}
68+
}
69+
function strVecFromBase64(base64_dat, lens) {
70+
strs = fflate.strFromU8(fflate.unzlibSync(Uint8Array.from(atob(base64_dat), c => c.charCodeAt(0))));
71+
arr = [];
72+
cur = 0;
73+
for (var i = 0; i < lens.length; i++) {
74+
arr.push(strs.slice(cur, cur + lens[i]));
75+
cur += lens[i];
76+
}
77+
return arr;
78+
}
79+
""")
80+
]
81+
82+
json(io::IO, arr::AbstractVector{<:AbstractFloat}) = _json_num_arr(io, arr)
83+
json(io::IO, arr::AbstractMatrix{<:AbstractFloat}) = _json_num_arr(io, arr)
84+
json(io::IO, arr::AbstractVector{<:Integer}) = _json_num_arr(io, arr)
85+
json(io::IO, arr::AbstractMatrix{<:Integer}) = _json_num_arr(io, arr)
86+
87+
function _to_js_eltype(arr::AbstractArray{<:AbstractFloat})
88+
# be opinionated and cap at Float32, which should be enough for
89+
# plotting, halving filesize vs Float64
90+
T = (eltype(arr) == Float16) ? Float16 : Float32
91+
return convert(AbstractArray{T}, arr)
92+
end
93+
94+
function _to_js_eltype(arr::AbstractArray{<:Integer})
95+
# find the smallest integer type that can represent the data
96+
mn, mx = extrema(arr)
97+
types = (UInt8, Int8, UInt16, Int16, UInt32, Int32)
98+
i = findfirst(t -> mn >= typemin(t) && mx <= typemax(t), types)
99+
isnothing(i) && error("Integer values in plot data are too large to fit in UInt32 or Int32.")
100+
T = types[i]
101+
return convert(AbstractArray{T}, arr)
102+
end
103+
104+
function _json_num_arr(io::IO, arr)
105+
if settings.compress
106+
js_arr = _to_js_eltype(arr)
107+
T = eltype(js_arr)
108+
base64_dat = base64encode(transcode(ZlibCompressor, Vector(reinterpret(UInt8, view(transpose(js_arr), :)))))
109+
dims = join(size(js_arr), ',')
110+
T_js = string(T)[1] * lowercase(string(T)[2:end])
111+
print(io, "numArrFromBase64($(T_js)Array,'", base64_dat, "',", dims, ")")
112+
else
113+
_json_generic_arr(io, arr)
114+
end
115+
end
116+
117+
function json(io::IO, arr::AbstractVector{<:AbstractString})
118+
if settings.compress
119+
# store a (compressed) contatenation of the strings and indices where each element starts
120+
base64_dat = base64encode(transcode(ZlibCompressor, join(arr)))
121+
print(io, "strVecFromBase64('", base64_dat, "',")
122+
json(io, length.(arr))
123+
print(io, ")")
124+
else
125+
_json_generic_arr(io, arr)
126+
end
127+
end

test/runtests.jl

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ html(x) = repr("text/html", x)
2121
@test json(Inf) == "null"
2222
@test json(-Inf) == "null"
2323
@test json(DateTime(2021,1,1)) == "\"2021-01-01 00:00:00\""
24+
preset.display.compress!(true)
25+
@test json(Int[1, 2]) == "numArrFromBase64(Uint8Array,'eJxjZAIAAAYABA==',2)"
26+
@test json(Int[1 2; 3 4]) == "numArrFromBase64(Uint8Array,'eJxjZGJmAQAAGAAL',2,2)"
27+
@test json(Float64[1, 2]) == "numArrFromBase64(Float32Array,'eJxjYGiwZ2BgcAAABIMBAA==',2)"
28+
@test json(Float64[1 2; 3 4]) == "numArrFromBase64(Float32Array,'eJxjYGiwZ2BgcAAiIG5wAAAQgwJA',2,2)"
29+
@test json(["a", "b"]) == "strVecFromBase64('eJxLTAIAASYAxA==',numArrFromBase64(Uint8Array,'eJxjZAQAAAUAAw==',2))"
2430
end
2531

2632
#-----------------------------------------------------------------------------# Plot methods

0 commit comments

Comments
 (0)