Filename | Stmts | Miss | Cover | Missing |
---|---|---|---|---|
src\PlutoPlotly.jl | 13 | 5 | 61.54% | 64-73 |
src\local_plotly_library.jl | 67 | 12 | 82.09% | 2-5, 35-37, 52-55, 65, 136-137 |
src\basics.jl | 64 | 6 | 90.62% | 36-39, 45, 52, 140, 157 |
src\main_struct.jl | 27 | 0 | 100.00% | |
src\paste_receiver.jl | 1 | 0 | 100.00% | |
src\mathjax.jl | 2 | 0 | 100.00% | |
src\preprocess.jl | 65 | 1 | 98.46% | 78 |
src\js_helpers.jl | 27 | 11 | 59.26% | 33, 60-78 |
src\show.jl | 4 | 0 | 100.00% | |
src\plotlybase_forward.jl | 11 | 0 | 100.00% | |
src\utilities.jl | 22 | 0 | 100.00% | |
src\script_contents\clipboard.jl | 0 | 0 | 0.00% | |
src\script_contents\resizer.jl | 0 | 0 | 0.00% | |
ext\PlotlyKaleidoExt.jl | 19 | 19 | 0.00% | 6-32 |
ext\UnitfulExt.jl | 1 | 0 | 100.00% | |
TOTAL | 323 | 54 | 83.28% |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
module PlutoPlotly using PlotlyBase using HypertextLiteral using AbstractPlutoDingetjes using Dates using Scratch using TOML using Colors using ColorSchemes using LaTeXStrings using Markdown using Downloads: download using Artifacts using ScopedValues using PrecompileTools # This is similar to `@reexport` but does not exports undefined names and can # also avoid exporting the module name function re_export(m::Module; skip_modname = false) mod_name = nameof(m) nms = names(m) exprts = filter(nms) do n isdefined(m, n) && (!skip_modname || n != mod_name) end eval(:(using .$mod_name)) eval(:(export $(exprts...))) end re_export(PlotlyBase; skip_modname = false) export PlutoPlot, get_plotly_version, change_plotly_version, force_pluto_mathjax_local, htl_js, add_plotly_listener!, add_class!, remove_class!, add_js_listener!, default_plotly_template, get_image_options, change_image_options!, plutoplotly_paste_receiver export plot, push_script!, prepend_cell_selector export make_subplots export enable_plutoplotly_offline # From utilities.jl export sample_colorscheme, discrete_colorscale include("local_plotly_library.jl") include("basics.jl") include("script_contents/clipboard.jl") include("script_contents/resizer.jl") include("main_struct.jl") include("paste_receiver.jl") include("mathjax.jl") include("preprocess.jl") include("js_helpers.jl") include("show.jl") # Forward methods of PlotlyBase to support PlutoPlot objects include("plotlybase_forward.jl") include("utilities.jl") # function __init__() # if !is_inside_pluto() # @warn "You loaded this package outside of Pluto, this is not the intended behavior and you should use either PlotlyBase or PlotlyJS directly.\nNOTE: If you receive this warning during pre-compilation or sysimage creation, you can ignore this warning." # end # end @compile_workload begin data = [ scatter(;y = rand(10)), scattergeo(;lat = rand(10), lon = rand(10)), heatmap(;z = rand(10,10)), surface(;z = rand(10,10)) ] layout = Layout(;title = "lol") p = plot(data, layout) _show(p) plot(rand(10,4)) |> _show end end |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 |
get_plotly_esm_url(v) = "https://esm.sh/plotly.js-dist-min@$(VersionNumber(v))" get_plotly_cdn_url(v) = "https://cdn.plot.ly/plotly-$(VersionNumber(v)).min.js" # We use our custom bundler to download an esm version of the plotly library get_plotly_download_url(v) = "https://github.com/disberd/PlotlyArtifactsESM/releases/download/v$(VersionNumber(v))/plotly-esm-min.mjs" """ mapping a path like "/home/user/.julia/blabla/plotly-1.2.3.min.js" to its contents, read as String """ const PLOTLY_DEP_CONTENTS = Dict{String, String}() function get_local_plotly_contents(v) maybe_add_plotly_local(v) path = get_local_path(v) get!(PLOTLY_DEP_CONTENTS, path) do read(path, String) end end get_local_path(v) = if VersionNumber(v) === ARTIFACT_VERSION joinpath(artifact"plotly-esm-min", "plotly-esm-min.mjs") else # We use the UUID explicitly to make this work with PlutoDevMacros even without rootmodule scratchspace = get_scratch!(Base.UUID("8e989ff0-3d88-8e9f-f020-2b208a939ff0"), "plotly-library-esm") joinpath(scratchspace, "$(get_local_name(v)).mjs") end get_local_name(v) = "plotly-esm-min-$(VersionNumber(v))" function maybe_add_plotly_local(v) ver = VersionNumber(v) # Check if the artifact already exists path = get_local_path(ver) if !isfile(path) # We download bundle and save locally @info "Downloading a local version of plotly@$v" bundle_url = get_plotly_download_url(ver) download(bundle_url, path) end nothing end function src_type(type) @assert type in ("hybrid", "esm", "local") type end function get_plotly_import(v, force = "hybrid") force = src_type(force) if force == "hybrid" _ImportedHybridJS(v) elseif force == "esm" _ImportedRemoteJS(get_plotly_esm_url(v)) elseif force == "local" import_local_js(get_local_plotly_contents(v)) end end # Identify a remote JS ESM module to be imported when shown in a script. The `extract` argument, if non-empty, will be the name of the property of the remote module to extract struct _ImportedRemoteJS src::String extract::String end _ImportedRemoteJS(src) = _ImportedRemoteJS(src, "") function Base.show(io, m::MIME"text/javascript", i::_ImportedRemoteJS) write(io, "(await import($(repr(i.src))))" ) if !isempty(i.extract) # Extract specific field from the module write(io, ".$(i.extract)") end end # Identify a local (on filesystem) JS ESM module to be imported when shown in a script. The `extract` argument, if non-empty, will be the name of the property of the local module to extract struct _ImportedLocalJS published extract::String function _ImportedLocalJS(published, extract::AbstractString = "") @nospecialize new(published, extract) end end function Base.show(io, m::MIME"text/javascript", i::_ImportedLocalJS) write(io, """ (await (() => { window.created_imports = window.created_imports ?? new Map(); let code = """ ) Base.show(io, m, i.published) write(io, """; if(created_imports.has(code)){ return created_imports.get(code); } else { let blob_promise = new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = async () => { try { resolve(await import(reader.result)); } catch(e) { reject(); } } reader.onerror = () => reject(); reader.onabort = () => reject(); reader.readAsDataURL( new Blob([code], {type : "text/javascript"})) }); created_imports.set(code, blob_promise); return blob_promise; } })()) """ ) if !isempty(i.extract) # Extract specific field from the module write(io, ".$(i.extract)") end return nothing end function import_local_js(code::AbstractString, extract::AbstractString = "") code_js = try AbstractPlutoDingetjes.Display.published_to_js(code) catch e @warn "published_to_js did not work" exception=(e,catch_backtrace()) maxlog=1 repr(code) end _ImportedLocalJS(code_js, extract) end """ enable_plutoplotly_offline(;version = get_plotly_version()) Creates a script that loads the plotly library on the current browser session so that it is available even when not connected to internet. Put this in a separate cell so that the plotly JS library is stored in the browser and available for all plots. """ function enable_plutoplotly_offline(;version = get_plotly_version()) _import = import_local_js(get_local_plotly_contents(version), "default") v_str = string(VersionNumber(version)) @htl(""" <script> const imports = { $(v_str): $(_import) } window.plutoplotly_imports = imports </script> """) end struct _ImportedHybridJS object::String key::String fallback::_ImportedRemoteJS end function _ImportedHybridJS(v) object = "plutoplotly_imports" key = string(VersionNumber(v)) fallback = _ImportedRemoteJS(get_plotly_esm_url(v), "default") return _ImportedHybridJS(object, key, fallback) end function Base.show(io::IO, m::MIME"text/javascript", i::_ImportedHybridJS) write(io, "window.$(i.object)?.['$(i.key)'] ??") show(io, m, i.fallback) end # function Base.show(io::IO, m::MIME"text/javascript", i::_ImportedHybridJS) # write(io, """await (async function() { # let Plotly = window.$(i.object)?.['$(i.key)'] # if (Plotly == undefined) { # console.log("Could not find loaded library among offline imports, trying to load from esm.sh") # Plotly = """) # show(io, m, i.fallback) # write(io, """ # } else { # console.log("Loaded plotly from window.plutoplotly_imports") # } # return Plotly # })()""") # end |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 |
const ARTIFACT_VERSION = VersionNumber(read(joinpath(artifact"plotly-esm-min", "VERSION"), String)) const DEFAULT_PLOTLY_VERSION = Ref(ARTIFACT_VERSION) const PLOTLY_VERSION = ScopedValue{Union{Nothing, String, VersionNumber}}(nothing) const DEFAULT_TEMPLATE = Ref(PlotlyBase.templates[PlotlyBase.templates.default]) const JS = HypertextLiteral.JavaScript """ ScriptContents Wrapper around a vector of `HypertextLiteral.JavaScript` elements. It has a custom print implementation of `HypertextLiteral.print_script` in order to allow serialization of its various elements inside a script tag. It is used inside the PlutoPlot to allow modularity and ease customization of the script contents that is used to generate the plotlyjs plot in Javascript. """ struct ScriptContents vec::Vector{JS} end function HypertextLiteral.print_script(io::IO, value::ScriptContents) for el ∈ value.vec print(io, el.content, '\n') end end """ htl_js(x) Simple convenience constructor for `HypertextLiteral.JavaScript` objects, renamed and re-exported from HypertextLiteral for convenience in case HypertextLiteral is not explicitly loaded alongisde PlutoPlotly. See also: [`add_plotly_listeners!`](@ref) """ htl_js(x) = HypertextLiteral.JavaScript(x) htl_js(x::HypertextLiteral.JavaScript) = x maybe_publish_to_js(x) = if is_inside_pluto() if isdefined(Main.PlutoRunner, :core_published_to_js) Main.PlutoRunner.PublishedToJavascript(x) else Main.PlutoRunner.publish_to_js(x) end else x end current_cell_id()::Base.UUID = if is_inside_pluto() Main.PlutoRunner.currently_running_cell_id[] else Base.UUID(zero(UInt128)) end function Base.show(io::IO, mime::MIME"text/html", s::JS) if is_inside_pluto() show(io, mime, Markdown.MD(Markdown.Code("js",s.content))) else show(io, MIME"text/plain",s) end end ## Plotly Version ## function change_plotly_version(v) ver = VersionNumber(v) maybe_add_plotly_local(ver) DEFAULT_PLOTLY_VERSION[] = ver end function get_plotly_version() v = @something PLOTLY_VERSION[] DEFAULT_PLOTLY_VERSION[] return VersionNumber(v) end ## Prepend Cell Selector ## """ prepend_cell_selector(selector="") prepend_cell_selector(selectors) Prepends a CSS selector (represented by the argument `selector`) with a selector of the current pluto-cell (of the form `pluto-cell[id='cell_id']`, where `cell_id` is the currently running cell). It can be used to ease creating style sheets (using `@htl` from HypertextLiteral.jl) with selector that only apply to the cell where they are executed. When called with a vector of selectors as input, prepends each selector and joins them together using `,` as separator. `prepend_cell_selector("div") = pluto-cell[id='\$cell_id'] div` `prepend_cell_selector(["div", "span"]) = pluto-cell[id='\$cell_id'] div, pluto-cell[id='\$cell_id'] span` As example, one can create a plot and force its width to 400px in CSS by using the following snippet: ```julia @htl \"\"\" \$(plot(rand(10))) <style> \$(prepend_cell_selector("div.js-plotly-plot")) { width: 400px !important; } </style> \"\"\" ``` """ prepend_cell_selector(str::AbstractString="")::String = "pluto-cell[id='$(current_cell_id())'] $str" |> strip prepend_cell_selector(selectors) = join(map(prepend_cell_selector, selectors), ",\n") const IO_DICT = Ref{Tuple{<:IO, Dict{UInt, Int}}}((IOBuffer(), Dict{UInt, Int}())) function get_IO_DICT(io::IO) old_io = first(IO_DICT[]) dict = if old_io === io last(IO_DICT[]) else d = Dict{UInt, Int}() IO_DICT[] = (io, d) d end return dict end ## Unique Counter ## function unique_io_counter(io::IO, identifier = "script_id") !get(io, :is_pluto, false) && return -1 # We simply return -1 if not inside pluto # We extract (or create if not existing) a dictionary that will keep track of instances of the same script name dict = get_IO_DICT(io) # We use the objectid as the key key = objectid(identifier) counter = get(dict, key, 0) + 1 # Update the counter on the dict that is shared within this IOContext dict[key] = counter end # Using the unique_io_counter inside the show3 method allows to have unique counters for plots within a same cell. # This does not ensure that the same plot object is always given the same unique script id if the plots are added to the cells with `if...end` blocks. function plotly_script_id(io::IO) counter = unique_io_counter(io, "plotly-plot") return "plot_$counter" end function find_matching_template(t::Template) for name in templates.available t == templates[name] && return name end return missing end """ default_plotly_template(;find_matching = false)::Template Returns the current default plotly template (following the synthax to set Templates from PlotlyBase). If `find_matching` is set to true, the function will also send a message (using `@info`) to specify whether the default template is one of the templates available by default in PlotlyBase (and which one it is) or not. """ function default_plotly_template(; find_matching = false) template = DEFAULT_TEMPLATE[] if find_matching matching = find_matching_template(template) if matching isa Missing @info "The default template is not one of the predefined ones" else @info "The default plotly template is $matching" end end template end """ default_plotly_template(template::Template)::Template default_plotly_template(name::Union{Symbol, String})::Template Set `template` as the current default plotly template (**globally**) to be used by all plots from PlutoPlotly (unless specifically overridden with Layout). If called with a `Symbol` or `String`, uses `name` to extract the corresponding template the default ones available in PlotlyBase and sets it as default. """ default_plotly_template(t::Template) = DEFAULT_TEMPLATE[] = t default_plotly_template(s::String) = default_plotly_template(Symbol(s)) function default_plotly_template(s::Symbol) s in templates.available || s === :none || error("The provided template $s is not available") template = s === :none ? Template() : templates[s] DEFAULT_TEMPLATE[] = template end |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 |
const _default_script_contents = htl_js.([ """ // Flag to check if this cell was manually ran or reactively ran const firstRun = this ? false : true const CONTAINER = this ?? html`<div class='plutoplotly-container'>` const PLOT = CONTAINER.querySelector('.js-plotly-plot') ?? CONTAINER.appendChild(html`<div>`) const parent = CONTAINER.parentElement // We use a controller to remove event listeners upon invalidation const controller = new AbortController() // We have to add this to keep supporting @bind with the old API using PLOT PLOT.addEventListener('input', (e) => { CONTAINER.value = PLOT.value if (e.bubbles) { return } CONTAINER.dispatchEvent(new CustomEvent('input')) }, { signal: controller.signal }) """, """ // This create the style subdiv on first run firstRun && CONTAINER.appendChild(html` <style> .plutoplotly-container { width: 100%; height: 100%; min-height: 0; min-width: 0; } .plutoplotly-container .js-plotly-plot .plotly div { margin: 0 auto; // This centers the plot } .plutoplotly-container.popped-out { overflow: auto; z-index: 1000; position: fixed; resize: both; background: var(--main-bg-color); border: 3px solid var(--kbd-border-color); border-radius: 12px; border-top-left-radius: 0px; border-top-right-radius: 0px; } .plutoplotly-clipboard-header { display: flex; flex-flow: row wrap; background: var(--main-bg-color); border: 3px solid var(--kbd-border-color); border-top-left-radius: 12px; border-top-right-radius: 12px; position: fixed; z-index: 1001; cursor: move; transform: translate(0px, -100%); padding: 5px; } .plutoplotly-clipboard-header span { display: inline-block; flex: 1 } .plutoplotly-clipboard-header.hidden { display: none; } .clipboard-span { position: relative; } .clipboard-value { padding-right: 5px; padding-left: 2px; cursor: text; } .clipboard-span.format { display: none; } .clipboard-span.filename { flex: 0 0 100%; text-align: center; border-top: 3px solid var(--kbd-border-color); margin-top: 5px; display: none; } .plutoplotly-container.filesave .clipboard-span.filename { display: inline-block; } .clipboard-value.filename { margin-left: 3px; text-align: left; min-width: min(60%, min-content); } .plutoplotly-container.filesave .clipboard-span.format { display: inline-flex; } .clipboard-span.format .label { flex: 0 0 0; } .clipboard-value.format { position: relative; flex: 1 0 auto; min-width: 30px; margin-right: 10px; } div.format-options { display: inline-flex; flex-flow: column; position: absolute; background: var(--main-bg-color); border-radius: 12px; padding-left: 3px; z-index: 2000; } div.format-options:hover { cursor: pointer; border: 3px solid var(--kbd-border-color); padding: 3px; transform: translate(-3px, -6px); } div.format-options .format-option { display: none; } div.format-options:hover .format-option { display: inline-block; } .format-option:not(.selected) { margin-top: 3px; } div.format-options .format-option.selected { order: -1; display: inline-block; } .format-option:hover { background-color: var(--kbd-border-color); } span.config-value { font-weight: normal; color: var(--pluto-output-color); display: none; position: absolute; background: var(--main-bg-color); border: 3px solid var(--kbd-border-color); border-radius: 12px; transform: translate(0px, calc(-100% - 10px)); padding: 5px; } .label { user-select: none; } .label:hover span.config-value { display: inline-block; min-width: 150px; } .clipboard-span.matching-config .label { color: var(--cm-macro-color); font-weight: bold; } .clipboard-span.different-config .label { color: var(--cm-tag-color); font-weight: bold; } </style> `) """, """ let original_height = plot_obj.layout.height let original_width = plot_obj.layout.width // For the height we have to also put a fixed value in case the plot is put on a non-fixed-size container (like the default wrapper) // We define a variable to check whether we still have to remove the fixed height let remove_container_size = firstRun let container_height = original_height ?? PLOT.container_height ?? 400 CONTAINER.style.height = container_height + 'px' """, clipboard_script, resizer_script, """ Plotly.react(PLOT, plot_obj).then(() => { // Assign the Plotly event listeners for (const [key, listener_vec] of Object.entries(plotly_listeners)) { for (const listener of listener_vec) { PLOT.on(key, listener) } } // Assign the JS event listeners for (const [key, listener_vec] of Object.entries(js_listeners)) { for (const listener of listener_vec) { PLOT.addEventListener(key, listener, { signal: controller.signal }) } } } ) """, """ invalidation.then(() => { // Remove all plotly listeners PLOT.removeAllListeners() // Remove all JS listeners controller.abort() // Remove the resizeObserver resizeObserver.disconnect() }) """, ]) """ PlutoPlot(p::Plot; kwargs...) A wrapper around `PlotlyBase.Plot` to provide optimized visualization within Pluto notebooks exploiting `@htl` from HypertextLiteral. # Fields - `Plot::PlotlyBase.Plot` - `plotly_listeners::Dict{String, Vector{HypertextLitera.JavaScript}}` - `js_listeners::Dict{String, Vector{HypertextLitera.JavaScript}}` - `classList::Vector{String}` - `script_contents::ScriptContents` Once the wrapper has been created, the underlying `Plot` object can be accessed from the `Plot` field of the `PlutoPlot` object. Custom listeners to [plotly events](https://plotly.com/javascript/plotlyjs-events/) are saved inside the `plotly_listeners` field and can be added to the `PlutoPlot` as *javascript* functions using the [`add_plotly_listener!`](@ref) function. Custom listeners to normal javascript events can instead be added to the `PlutoPlot` as *javascript* functions using the [`add_js_listener!`](@ref) function. Multiple listeners can be associated to each event, and they are executed in the order they are added. A list of custom CSS classes can be added to the PlutoPlot by using the [`add_class!`](@ref) and [`remove_class!`](@ref) functions. Finally, the contents of the script tag generating the plot are stored in the field `script_contents` which is of type [`ScriptContents`](@ref). The elements of `script_contents` are written serially inside the javascript script tag. The displayed plot can be customized by modifying the elements of this field. # Examples ```julia p = PlutoPlot(Plot(rand(10))) add_plotly_listener!(p, "plotly_click", "e => console.log(e)") add_class!(p, "custom_class") ``` See also: [`ScriptContents`](@ref), [`add_js_listener!`](@ref), [`add_plotly_listener!`](@ref) """ Base.@kwdef struct PlutoPlot Plot::PlotlyBase.Plot plotly_listeners::Dict{String, Vector{JS}} = Dict{String, Vector{JS}}() js_listeners::Dict{String, Vector{JS}} = Dict{String, Vector{JS}}() classList::Vector{String} = String[] script_contents::ScriptContents = ScriptContents(deepcopy(_default_script_contents)) end PlutoPlot(p::PlotlyBase.Plot; kwargs...) = PlutoPlot(;kwargs..., Plot = p) # Getter that extract the underlying Plot object data function Base.getproperty(p::PlutoPlot, s::Symbol) if hasfield(Plot, s) getfield(getfield(p, :Plot), s) else getfield(p, s) end end function plot(args...;kwargs...) @nospecialize PlutoPlot(Plot(args...;kwargs...)) end # This function extracts the toImageButtonOptions as a Dict """ get_image_options(p::Union{Plot, PlutoPlot})::Dict{Symbol, Any} Extract the dictionary of image options that are stored in the `toImageButtonOptions` of the `PlotConfig` object embedded in the `Plot`. If not explicitly set, the image options are empty by default when creating a Plot object. See also: [`change_image_options!`](@ref) """ function get_image_options(p::Union{Plot, PlutoPlot}) dict = something(p.config.toImageButtonOptions, Dict()) return Dict{Symbol, Any}((Symbol(k) => v) for (k, v) in dict) end """ change_image_options!(p::Union{Plot, PlutoPlot}; kwargs...) Returns the input plot `p` after having modified the `toImageButtonOptions` of the `PlotConfig` object embedded in the plot. These options are passed to the plotly.js library and are used to provide defaults when downloading the plot as an image (See the relevant [docs](https://plotly.com/julia/configuration-options/#customizing-modebar-download-plot-button) for more details.) If explicitly set, each option will also be used as the default value when popping out plot container for customizing size/scale/name before copying to clipboard or downloading the image. See [PR #22](https://github.com/JuliaPluto/PlutoPlotly.jl/pull/32) of PlutoPlotly for more details. ## Keyword Arguments - `format`: The format of the exported plot to download. Can be one of "png", "jpeg", "webp", "svg" or "full-json", - `width`: An integer specifying the width (in pixels) of the exported plot. - `height`: An integer specifying the height (in pixels) of the exported plot. - `scale`: Set the scaling for the generated image, keeping the aspect ratio intact (increases the resolution). - `filename`: Sets the name of the exported file, the extension will be added automatically based on the chosen `format`. """ function change_image_options!(p::Union{Plot, PlutoPlot}; kwargs...) # valid_args = (:format, :width, :height, :scale, :setBackground, :imageDataOnly, :filename) # At the moment setBackground and imageDataOnly are not supported. valid_args = (:format, :width, :height, :scale, :filename) invalid_kwargs = setdiff(keys(kwargs), valid_args) |> Tuple isempty(invalid_kwargs) || error("You provided the some invalid keyword arguments. Invalid kwargs: $invalid_kwargs Possible kwargs: $valid_args") existing_dict = get_image_options(p) new_dict = Dict{Symbol, Any}() for k in valid_args val = get(kwargs, k, get(existing_dict, k, missing)) val isa Missing && continue new_dict[k] = val end isempty(new_dict) && return p.config.toImageButtonOptions = new_dict p end |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 |
""" plutoplotly_paste_receiver(;popped = true) Create a widget that when shown inside a Pluto output generates a container specifically made for extracting images of exported plots obtained with the clipboard button on the plotly modebar. With `popped` equals to true (default), the widget will be collapsed and represented by a clipboard icon on the top-right of the screen. When clicked upon, the container div is expanded and it will contain the last image that has been sent to the clipboard from a PlutoPlotly plot. """ plutoplotly_paste_receiver(;popped = true) = @htl(""" <script src="https://kit.fontawesome.com/087fc9ff41.js" crossorigin="anonymous"></script> <paste-receiver class="plutoplotly noimage minimized $(popped ? "popped" : "")"> <div class="header"> <i class="empty"></i> <i class="clipboard fa-regular fa-clipboard"></i> <i class="minimize fa-solid fa-down-left-and-up-right-to-center"></i> <i class="popout fa-solid fa-arrow-up-right-from-square"></i> <i class="close fa-solid fa-xmark"></i> </div> <img /> <div class="message"> The plot image will be pasted here as soon as the clipboard button is pressed </div> </paste-receiver> <script> const paste_receiver = currentScript.parentElement.querySelector("paste-receiver"); const img = paste_receiver.querySelector("img"); const clipboard_icon = paste_receiver.querySelector(".clipboard"); paste_receiver.attachImage = function (data, caller) { img.src = data; paste_receiver.last_caller = caller; paste_receiver.classList.toggle("noimage", false); paste_receiver.classList.toggle("hasimage", true); // We make the clipboard wobble for half a second clipboard_icon.classList.toggle('animate', true) setTimeout(() => clipboard_icon.classList.toggle('animate', false), 1000) }; const { default: interact } = await import( "https://esm.sh/interactjs@1.10.19" ); function initialize_interact() { paste_receiver.offset = paste_receiver.offset ?? { x: 0, y: 0 }; const startPosition = { x: 0, y: 0 }; interact("paste-receiver.popped > .header") .draggable({ listeners: { start(event) { paste_receiver.offset.y = startPosition.y = paste_receiver.offsetTop; paste_receiver.offset.x = startPosition.x = paste_receiver.offsetLeft; }, move(event) { paste_receiver.offset.x += event.dx; paste_receiver.offset.y += event.dy; paste_receiver.style.top = `min(95vh, \${paste_receiver.offset.y}px`; paste_receiver.style.left = `min(95vw, \${paste_receiver.offset.x}px`; }, }, }) .on("doubletap", (e) => { minimize(e, true) }); function modify_size_position(remove) { const keys = ['top','left','width','height'] const ps = paste_receiver.position_size ?? _.pick(paste_receiver.getBoundingClientRect(), keys) const cs = getComputedStyle(paste_receiver) for (const key of keys) { if (remove) { ps[key] = parseFloat(cs[key]) paste_receiver.style[key] = "" } else { // We put the style from the saved one paste_receiver.style[key] = ps[key] + "px" } } paste_receiver.position_size = ps } function find_center(el) { r = el.getBoundingClientRect() return { top: r.top + r.height/2, left: r.left + r.width/2 } } // This function minimizes or expands the plot and handles the offset function minimize(evt, force) { const current = paste_receiver.classList.contains('minimized') if (force == current) { // Noting happens, we just return return } const minimized_after = !current const r_before = paste_receiver.getBoundingClientRect() paste_receiver.classList.toggle('minimized',force) const r_after = paste_receiver.getBoundingClientRect() // Expanded (e) and Contracted (c) sizes const e = minimized_after ? r_before : r_after const c = minimized_after ? r_after : r_before // We compute the viewport size const vw = window.innerWidth const vh = window.innerHeight // This is the distance from the top of the top-left icon to the top-left of the container const dist = { top: 17.4, left: 15.9, } const to_right = e.left + e.width/2 > vw/2 const minimize_icon = paste_receiver.querySelector('.minimize') minimize_icon.style.order = to_right ? 1 : -1 let left let top if (minimized_after) { /* We are contracting */ left = to_right ? // We have to contracts towards the right, putting the left at the right corner e.right - dist.left : e.left + dist.left // Top for top we don't care about left and right targets, just top value top = e.top + e.height/2 > vh/2 ? e.bottom - dist.top : e.top + dist.top } else { /* We are expanding */ left = to_right ? // We have to contracts towards the right, putting the left at the right corner c.right + dist.left - e.width : c.left - dist.left // Top for top we don't care about left and right targets, just top value top = e.top + e.height/2 > vh/2 ? c.bottom + dist.top - e.height : c.top - dist.top } paste_receiver.style.left = left + 'px' paste_receiver.style.top = top + 'px' } interact('i.minimize').on('tap', function(e) { // We skip on right click if (e.originalEvent.button == 2) {return} minimize(e, true) }) interact('paste-receiver.minimized i.clipboard').on('tap', function(e) { // We skip on right click if (e.originalEvent.button == 2) {return} minimize(e, false) }) interact('paste-receiver.popped:not(.minimized)') .resizable({ edges: { top: true, left: false, bottom: true, right: true }, listeners: { move: function (event) { console.log(event) Object.assign(paste_receiver.style, { width: `\${event.rect.width}px`, height: `\${event.rect.height}px`, }); }, }, }) interact('i.popout').on('tap', function(e) { // We skip on right click if (e.originalEvent.button == 2) {return} paste_receiver.classList.toggle('popped', true) modify_size_position(false) }) interact('i.close').on('tap', function(e) { // We skip on right click if (e.originalEvent.button == 2) {return} modify_size_position(true) paste_receiver.classList.toggle('popped', false) }) } initialize_interact() invalidation.then(() => { interact('paste-receiver').unset() interact('i.close').unset() }) </script> <style> paste-receiver > .header { height: 30px; position: absolute; left: 0px; top: 0px; width: 100%; display: flex; justify-content: space-between; align-items: center; } paste-receiver.plutoplotly { display: flex; background: var(--main-bg-color); border: 3px solid var(--kbd-border-color); border-radius: 12px; min-height: 200px; max-height: 800px; align-items: center; justify-content: center; flex-flow: column; position: relative; overflow: auto; } paste-receiver.plutoplotly.popped { z-index: 1000; position: fixed; width: 600px; height: 400px; right: 165px; top: 62px; } paste-receiver.plutoplotly.popped.minimized { overflow: visible; min-height: 0px; height: 0px !important; width: 0px !important; border: none; background-color: transparent; } paste-receiver.plutoplotly.popped.minimized .header { height: 100%; } paste-receiver.plutoplotly.popped.minimized *:not(.header) { display: none; } paste-receiver.plutoplotly .header:not(:hover) i { visibility: hidden } paste-receiver.plutoplotly.popped.minimized i.clipboard { display: block; scale: 1.5; transform: translate(-50%, 0); visibility: visible !important; } i.clipboard.animate { animation: tilt-shaking 0.2s 0s 6; } @keyframes tilt-shaking { 0% { transform: rotate(0deg) translate(-50%, 0); } 25% { transform: rotate(5deg) translate(-50%, 0); } 50% { transform: rotate(0eg) translate(-50%, 0); } 75% { transform: rotate(-5deg) translate(-50%, 0); } 100% { transform: rotate(0deg) translate(-50%, 0); } } paste-receiver.noimage > div.noimage, paste-receiver.noimage > img { margin: 0 auto; } paste-receiver.noimage > img { display: none; } paste-receiver.hasimage > .message { display: none; } paste-receiver.hasimage > img { display: block; } paste-receiver i { margin: 0 5px; cursor: pointer; color: var(--pluto-output-color); } paste-receiver.popped i.empty, paste-receiver.popped i.popout { display: none; } paste-receiver.popped:not(.minimized) i.clipboard { display: none; } paste-receiver.popped.minimized i.minimize { display: none; } paste-receiver:not(.popped) i.clipboard, paste-receiver:not(.popped) i.minimize, paste-receiver:not(.popped) i.close { display: none; } .header:hover > i.popout { visibility: visible; } </style> """) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
# This hack is necessary to force loading mathjax const FORCE_MATHJAX_LOCAL = Ref(false) """ force_pluto_mathjax_local::Bool force_pluto_mathjax_local(flag::Bool)::Bool Returns `true` if the `PlutoPlot` `show` method forces svgs produced by MathJax to be locally cached and `false` otherwise. The flag can be set at package level by providing the intended boolean value as argument to the function Local svg caching is used to make mathjax in recent plolty versions (>2.10) work as expected. The default `global` caching in Pluto creates problems with the math display. """ force_pluto_mathjax_local() = FORCE_MATHJAX_LOCAL[] force_pluto_mathjax_local(flag::Bool) = FORCE_MATHJAX_LOCAL[] = flag |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 |
const FORCE_FLOAT32 = ScopedValue(true) # Get a val to specify whether floatval() = Val{FORCE_FLOAT32[]}() # Helper struct just to have the name as symbol parameter for dispatch struct AttrName{S} name::Symbol AttrName(s::Symbol) = new{s}(s) end # This is used to handle args... which in case only one element is passed is not iterable Base.iterate(n::AttrName, state=1) = state > 1 ? nothing : (n, state + 1) #= This function is basically `_json_lower` from PlotlyBase, but we do it directly on the PlutoPlot to avoid the modifying the behavior of `_json_lower` for `Plot` objects (which is required to modify how matrices are passed to `publish_to_js`). We now have a complex dispatch to be able to do custom processing for specific attributes. The standard signature for a _process_with_names method is: _process_with_names(x, fl::Val, @nospecialize(args::Vararg{AttrName})) where - the first argument should be the actual input to process and should be typed accordingly for dispatch. - The second argument is either `Val{true}` or `Val{false}` and represents the flag to force number to be converted in Float32. # We added this to significantly improve performance as the runtime check for converting or not was creating type instability. - All the remaining arguments are of type `AttrName` and represent the path of attributes names leading to this specific input. For example, if we are processing the input that is inside the xaxis_range in the layout, the function call will have this form: _process_with_names(x, fl, AttrName(:xaxis), AttrName(:range), AttrName(:layout)) This again is to allow dispatch to work on the path so that one can customize behavior of _process_with_names with great control. At the moment this is only used for modifying the behavior when `title` is passed as a String, changing it to the more recent plotly syntax (see https://github.com/JuliaPluto/PlutoPlotly.jl/issues/51) The various `@nospecialize` below are to avoid exploding compilation given our exponential number of dispatch options, so we only specialize where we need. =# # Main _process_with_names for the PlutoPlot object function _process_with_names(pp::PlutoPlot) p = pp.Plot fl = floatval() out = Dict( :data => _process_with_names(p.data, fl, AttrName(:data)), :layout => _process_with_names(p.layout, fl, AttrName(:layout)), :frames => _process_with_names(p.frames, fl, AttrName(:frames)), :config => _process_with_names(p.config, fl, AttrName(:config)) ) templates = PlotlyBase.templates layout_template = p.layout.template template = if layout_template isa String layout_template === "none" ? Template() : templates[layout_template] elseif layout_template === templates[templates.default] # If we enter here we did not specify any template in the layout, so se use our default DEFAULT_TEMPLATE[] else layout_template end out[:layout][:template] = _process_with_names(template, fl, AttrName(:template), AttrName(:layout)) out end # Generic fallbacks _process_with_names(x, ::Val, @nospecialize(args::Vararg{AttrName})) = _preprocess(x) _process_with_names(x) = _process_with_names(x, floatval()) # Handle strings _process_with_names(s::AbstractString, ::Val, @nospecialize(args::Vararg{AttrName})) = _preprocess(s) _process_with_names(s::AbstractString, ::Val, ::AttrName{:title}, @nospecialize(args::Vararg{AttrName})) = Dict(:text => _preprocess(s)) # Handle Reals _process_with_names(x::Real, ::Val{false}, @nospecialize(args::Vararg{AttrName})) = x _process_with_names(x::Real, ::Val{true}, @nospecialize(args::Vararg{AttrName})) = x isa Bool ? x : Float32(x) # Tuple, Arrays _process_with_names(x::Union{Tuple,AbstractArray}, fl::Val, @nospecialize(args::Vararg{AttrName})) = [_process_with_names(el, fl, args...) for el in x] # Multidimensional array of numbers must be nested 1D arrays _process_with_names(A::AbstractArray{<:Union{Number,AbstractVector{<:Number}},N}, fl::Val, @nospecialize(args::Vararg{AttrName})) where {N} = if N == 1 [_process_with_names(el, fl, args...) for el in A] else [_process_with_names(collect(s), fl, args...) for s ∈ eachslice(A; dims=ndims(A))] end # Dict ans HasFields function _process_with_names(d::Dict, fl::Val, @nospecialize(args::Vararg{AttrName})) Dict{Any,Any}(k => if k isa Symbol # We have this branch as we might have plotly properties here and we assume # they are if the dict key is a symbol. _process_with_names(v, fl, AttrName(k), args...) else _process_with_names(v, fl, args...) end for (k, v) in pairs(d)) end function _process_with_names(d::Dict{Symbol}, fl::Val, @nospecialize(args::Vararg{AttrName})) Dict{Symbol,Any}(k => _process_with_names(v, fl, AttrName(k), args...) for (k, v) in pairs(d)) end # We have a separate one because it seems to reduce allocations _process_with_names(a::PlotlyBase.HasFields, fl::Val, @nospecialize(args::AttrName)) = _process_with_names(a.fields, fl, args...) # Templates _process_with_names(t::PlotlyBase.Template, fl::Val, @nospecialize(args::Vararg{AttrName})) = Dict( :data => _process_with_names(t.data, fl, AttrName(:data), args...), :layout => _process_with_names(t.layout, fl, AttrName(:layout), args...) ) # Config function _process_with_names(pc::PlotlyBase.PlotConfig, fl::Val, @nospecialize(args::Vararg{AttrName})) out = Dict{Symbol,Any}() for fn in fieldnames(PlotlyBase.PlotConfig) field = getfield(pc, fn) if !isnothing(field) out[fn] = _process_with_names(field, fl, AttrName(fn), args...) end end out end ## The functions below are the internal processing only taking the value, so not depending on names path or float32 flag # Defaults to JSON.lower for generic non-overloaded types _preprocess(x) = PlotlyBase.JSON.lower(x) # Default _preprocess(x::TimeType) = sprint(print, x) # For handling datetimes _preprocess(s::Union{AbstractString,Symbol}) = String(s) _preprocess(x::Union{Nothing,Missing}) = x _preprocess(x::Symbol) = string(x) _preprocess(c::PlotlyBase.Cycler) = c.vals function _preprocess(c::PlotlyBase.ColorScheme)::Vector{Tuple{Float64,String}} N = length(c.colors) map(ic -> ((ic[1] - 1) / (N - 1), _preprocess(ic[2])), enumerate(c.colors)) end # Files that will be later moved to an extension. At the moment it's pointless because PlotlyBase uses those internally anyway. _preprocess(s::LaTeXString) = s.s # Colors, they can be put inside an extension _preprocess(c::Color) = @views begin s = hex(c, :rrggbb) r = parse(Int, s[1:2]; base=16) g = parse(Int, s[3:4]; base=16) b = parse(Int, s[5:6]; base=16) return "rgb($r,$g,$b)" end _preprocess(c::TransparentColor) = @views begin s = hex(c, :rrggbbaa) r = parse(Int, s[1:2]; base=16) g = parse(Int, s[3:4]; base=16) b = parse(Int, s[5:6]; base=16) a = parse(Int, s[7:8]; base=16) return "rgba($r,$g,$b,$(a/255))" end |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
## add listeners ## """ add_js_listener!(p::PlutoPlot, event_name::String, listener::HypertextLiteral.JavaScript) add_js_listener!(p::PlutoPlot, event_name::String, listener::String) Add a custom *javascript* `listener` (to be provided as `String` or directly as `HypertextLiteral.JavaScript`) to the `PlutoPlot` object `p`, and associated to the javascript event specified by `event_name`. The listeners are added to the HTML plot div after rendering. The div where the plot is inserted can be accessed using the variable named `PLOT` inside the listener code. # Differences with `add_plotly_listener!` This function adds standard javascript events via the `addEventListener` function. These events differ from the plotly specific events. See also: [`add_plotly_listener!`](@ref), [`htl_js`](@ref) # Examples: ```julia p = PlutoPlot(Plot(rand(10), Layout(uirevision = 1))) add_js_listener!(p, "mousedown", htl_js(\"\"\" function(e) { console.log(PLOT) // logs the plot div inside the developer console when pressing down the mouse } \"\"\" ``` """ function add_js_listener!(p::PlutoPlot, event_name::String, listener::JS) ldict = p.js_listeners listeners_array = get!(ldict, event_name, JS[]) push!(listeners_array, listener) return p end add_js_listener!(p::PlutoPlot, event_name, listener::String) = add_js_listener!(p, event_name, htl_js(listener)) ## add class ## """ add_class!(p::PlutoPlot, className::String) Add a CSS class with name `className` to the list of custom classes that are added to the PLOT div when displayed inside Pluto. This can be used to give custom CSS styles to certain plots. See also: [`remove_class!`](@ref) """ function add_class!(p::PlutoPlot, className::String) cl = p.classList if className ∉ cl push!(cl, className) end return p end ## remove class ## """ remove_class!(p::PlutoPlot, className::String) Remove a CSS class with name `className` (if present) from the list of custom classes that are added to the PLOT div when displayed inside Pluto. This can be used to give custom CSS styles to certain plots. See also: [`add_class!`](@ref) """ function remove_class!(p::PlutoPlot, className::String) cl = p.classList idx = findfirst(x -> x === className, cl) if idx !== nothing deleteat!(cl, idx) end return p end ## Push Script ## """ push_script!(p::PlutoPlot, items...) Add script contents contained in collection `items` at the end of the plot show method script. The `item` must either be a collection of `String` or `HypertextLiteral.JavaScript` elements """ function push_script!(p::PlutoPlot, items::Vararg{JS,N}) where N @nospecialize push!(p.script_contents.vec, items...) return p end ## plotly listener ## """ add_plotly_listener!(p::PlutoPlot, event_name::String, listener::HypertextLiteral.JavaScript) add_plotly_listener!(p::PlutoPlot, event_name::String, listener::String) Add a custom *javascript* `listener` (to be provided as `String` or directly as `HypertextLiteral.JavaScript`) to the `PlutoPlot` object `p`, and associated to the [plotly event](https://plotly.com/javascript/plotlyjs-events/) specified by `event_name`. The listeners are added to the HTML plot div after rendering. The div where the plot is inserted can be accessed using the variable named `PLOT` inside the listener code. # Differences with `add_js_listener!` This function adds a listener using the plotly internal events via the `on` function. These events differ from the standard javascript ones and provide data specific to the plot. See also: [`add_js_listener!`](@ref), [`htl_js`](@ref) # Examples: ```julia p = PlutoPlot(Plot(rand(10), Layout(uirevision = 1))) add_plotly_listener!(p, "plotly_relayout", htl_js(\"\"\" function(e) { console.log(PLOT) // logs the plot div inside the developer console } \"\"\" ``` """ function add_plotly_listener!(p::PlutoPlot, event_name::String, listener::JS) ldict = p.plotly_listeners listeners_array = get!(ldict, event_name, JS[]) push!(listeners_array, listener) return p end add_plotly_listener!(p::PlutoPlot, event_name, listener::String) = add_plotly_listener!(p, event_name, htl_js(listener)) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
function _show(pp::PlutoPlot; script_id = "pluto-plotly-div", ver = get_plotly_version()) @htl """ <script id=$(script_id)> // We start by putting all the variable interpolation here at the beginning // We have to convert all typedarrays in the layout to normal arrays. See Issue #25 // We use lodash for this for compactness function removeTypedArray(o) { return _.isTypedArray(o) ? Array.from(o) : _.isPlainObject(o) ? _.mapValues(o, removeTypedArray) : o } // Publish the plot object to JS let plot_obj = _.update($(maybe_publish_to_js(_process_with_names(pp))), "layout", removeTypedArray) // Get the plotly listeners const plotly_listeners = $(pp.plotly_listeners) // Get the JS listeners const js_listeners = $(pp.js_listeners) // Deal with eventual custom classes let custom_classlist = $(pp.classList) // Load the plotly library const Plotly = $(get_plotly_import(ver, "hybrid")) // Check if we have to force local mathjax font cache if ($(force_pluto_mathjax_local()) && window?.MathJax?.config?.svg?.fontCache === 'global') { window.MathJax.config.svg.fontCache = 'local' } $(pp.script_contents) return CONTAINER </script> """ end # ╔═╡ d42d4694-e05d-4e0e-a198-79a3a5cb688a function Base.show(io::IO, mime::MIME"text/html", plt::PlutoPlot) show(io, mime, _show(plt; script_id = plotly_script_id(io))) # show(io, mime, _show(plt)) end |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
# Methods that do not return the plot object for fname in ( :relayout!, :restyle!, :addtraces!, :deletetraces!, :movetraces!, :purge!, :react!, :extendtraces!, :prependtraces!, # Layout shapes :add_hrect!, :add_hline!, :add_vrect!, :add_vline!, :add_shape!, :add_layout_image!, # generic methods from API first.(PlotlyBase._layout_obj_updaters)..., first.(PlotlyBase._layout_vector_updaters)..., ) @eval PlotlyBase.$fname(p::PlutoPlot, args...; kwargs...) = PlotlyBase.$fname(p.Plot, args...; kwargs...) end # Methods that do return the plot object, (we return the PlutoPlot object in this case) for fname in ( :update!, :add_trace!, ) @eval function PlotlyBase.$fname(p::PlutoPlot, args...; kwargs...) PlotlyBase.$fname(p.Plot, args...; kwargs...) p end end # Methods that return a copy of the plot # Methods that do return the plot object, (we return the PlutoPlot object in this case) for fname in (:fork, :restyle, :relayout, :update, :addtraces, :deletetraces, :movetraces, :redraw, :extendtraces, :prependtraces, :purge, :react) @eval function PlotlyBase.$fname(p::PlutoPlot, args...; kwargs...) p = PlotlyBase.$fname(p.Plot, args...; kwargs...) PlutoPlot(p) end end # Here we put methods from PlotlyJS.jl make_subplots(;kwargs...) = plot(Layout(Subplots(;kwargs...))) @doc (@doc Subplots) make_subplots # Overload of hcat,vcat,hvcat Base.hcat(ps::PlutoPlot...) = PlutoPlot(hcat(map(x -> x.Plot, ps)...)) Base.vcat(ps::PlutoPlot...) = PlutoPlot(vcat(map(x -> x.Plot, ps)...)) Base.hvcat(rows::Tuple{Vararg{Int}}, ps::PlutoPlot...) = PlutoPlot(hvcat(rows, map(x -> x.Plot, ps)...)) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
""" sample_colorscheme(cs::ColorScheme, sample_points::AbstractVector; alpha = nothing) sample_colorscheme(cs::ColorScheme, npoints::Int = length(cs); alpha = nothing) sample_colorscheme(cs_name::Symbol, args...; alpha = nothing) Function to create a reduced sampled version of the provided colorscheme `cs`, optionally specifying the opacity of the colors using the `alpha` keyword argument. The `sample_points` argument can be provided as a vector of points in the range [0, 1] to sample the colorscheme at specific points, or as an integer `npoints` to sample the colorscheme at `npoints` evenly spaced points in the range [0, 1]. Instead of being called with an explicit `ColorScheme` object, the function can also be called with a `Symbol` which is used to extract the corresponding `ColorScheme` object from the `colorschemes` Dict in the `ColorSchemes.jl` package. """ function sample_colorscheme(cs::ColorScheme, sample_points::AbstractVector; alpha = nothing) @assert minimum(sample_points) >= 0 && maximum(sample_points) <= 1 "sample_points must be in the range [0, 1]" colors = map(sample_points) do i c = get(cs, i) ca = convert(RGBA, c) if alpha === nothing ca else RGBA(ca.r, ca.g, ca.b, alpha) end end return ColorScheme(colors, "custom", "autogenerated") end sample_colorscheme(cs::ColorScheme, npoints::Int = length(cs); kwargs...) = sample_colorscheme(cs, range(0, 1, length = npoints); kwargs...) function sample_colorscheme(cs_name::Symbol, args...; kwargs...) cs = get(colorschemes, cs_name) do error("colorscheme name must be a valid key for the `ColorSchemes.colorschemes` Dict.\n$(cs_name) not found within the valid keys") end sample_colorscheme(cs, args...; kwargs...) end """ discrete_colorscale(cs::Union{ColorScheme, Symbol}, points; alpha = nothing) discrete_colorscale(colors::AbstractVector{<:Colorant}) Function to create a discrete colorscale from a colorscheme or a vector of colors, specifically intended for use as the `colorscale` attribute of a plotlyjs plot. The function will assume that the resulting colorscale will have evenly spaced mapping between the colors in the vector and the corresponding plotly colorscale. As example, when providing a vector of 3 colors as input, the resulting colorscale will assign each of the 3 colors to 33% of the values of the colorbar/coloraxis. This is useful for creating heatmaps where values are mapped to a few number of colors rather than to a continuous gradient. When called with a `ColorScheme` or `Symbol` as first argument, the colors are first sampled using the `sample_colorscheme` function, and then converted to a discrete colorscale. ```jldoctest julia> using PlutoPlotly; julia> colorscale = discrete_colorscale(:viridis, 5) 10-element Vector{Tuple{Float64, String}}: (0.0, "rgba(68,1,84,1.0)") (0.2, "rgba(68,1,84,1.0)") (0.2, "rgba(59,82,139,1.0)") (0.4, "rgba(59,82,139,1.0)") (0.4, "rgba(33,144,140,1.0)") (0.6, "rgba(33,144,140,1.0)") (0.6, "rgba(93,201,99,1.0)") (0.8, "rgba(93,201,99,1.0)") (0.8, "rgba(253,231,37,1.0)") (1.0, "rgba(253,231,37,1.0)") julia> plot(heatmap(; z = rand(10,10), colorscale)) ``` """ function discrete_colorscale(cs::Union{ColorScheme, Symbol}, points; alpha = nothing) colors = sample_colorscheme(cs, points; alpha).colors return discrete_colorscale(colors) end function discrete_colorscale(colors::AbstractVector{<:Colorant}) N = length(colors) cols = map(enumerate(colors)) do (i,c) [ ((i-1)/N, _preprocess(c)) ((i)/N, _preprocess(c)) ] end vcat(cols...) end |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 |
const clipboard_script = htl_js(""" // We create a Promise version of setTimeout function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } // We import interact for dragging/resizing const { default: interact } = await import('https://esm.sh/interactjs@1.10.19') function getImageOptions() { const o = plot_obj.config.toImageButtonOptions ?? {}; return { format: o.format ?? "png", width: o.width ?? original_width, height: o.height ?? original_height, scale: o.scale ?? 1, filename: o.filename ?? "newplot", }; } const CLIPBOARD_HEADER = CONTAINER.querySelector(".plutoplotly-clipboard-header") ?? CONTAINER.insertAdjacentElement( "afterbegin", html`<div class="plutoplotly-clipboard-header hidden"> <span class="clipboard-span format" ><span class="label">Format:</span ><span class="clipboard-value format"></span ></span> <span class="clipboard-span width" ><span class="label">Width:</span ><span class="clipboard-value width"></span>px</span > <span class="clipboard-span height" ><span class="label">Height:</span ><span class="clipboard-value height"></span>px</span > <span class="clipboard-span scale" ><span class="label">Scale:</span ><span class="clipboard-value scale"></span ></span> <button class="clipboard-span set">Set</button> <button class="clipboard-span unset">Unset</button> <span class="clipboard-span filename" ><span class="label">Filename:</span ><span class="clipboard-value filename"></span ></span> </div>` ); function checkConfigSync(container) { const valid_classes = [ "missing-config", "matching-config", "different-config", ]; function setClass(cl) { for (const name of valid_classes) { container.classList.toggle(name, name == cl); } } // We use the custom getters we'll set up in the container const { ui_value, config_value, config_span, key } = container; if (config_value === undefined) { setClass("missing-config"); config_span.innerHTML = `The key <b><em>\${key}</em></b> is not present in the config.`; } else if (ui_value == config_value) { setClass("matching-config"); config_span.innerHTML = `The key <b><em>\${key}</em></b> has the same value in the config and in the header.`; } else { setClass("different-config"); config_span.innerHTML = `The key <b><em>\${key}</em></b> has a different value (<em>\${config_value}</em>) in the config.`; } // Add info about setting and unsetting config_span.insertAdjacentHTML( "beforeend", `<br>Click on the label <em><b>once</b></em> to set the current UI value in the config.` ); config_span.insertAdjacentHTML( "beforeend", `<br>Click <em><b>twice</b></em> to remove this key from the config.` ); } const valid_formats = ["png", "svg", "webp", "jpeg", "full-json"]; function initializeUIValueSpan(span, key, value) { const container = span.closest(".clipboard-span"); span.contentEditable = key === "format" ? "false" : "true"; let parse = (x) => x; let update = (x) => (span.textContent = x); if (key === "width" || key === "height") { parse = (x) => Math.round(parseFloat(x)); } else if (key === "scale") { parse = parseFloat; } else if (key === "format") { // We remove contentEditable span.contentEditable = "false"; // Here we first add the subspans for each option const opts_div = span.appendChild(html`<div class="format-options"></div>`); for (const fmt of valid_formats) { const opt = opts_div.appendChild( html`<span class="format-option \${fmt}">\${fmt}</span>` ); opt.onclick = (e) => { span.value = opt.textContent; }; } parse = (x) => { return valid_formats.includes(x) ? x : localValue; }; update = (x) => { for (const opt of opts_div.children) { opt.classList.toggle("selected", opt.textContent === x); } }; } else { // We only have filename here } let localValue; Object.defineProperty(span, "value", { get: () => { return localValue; }, set: (val) => { if (val !== "") { localValue = parse(val); } update(localValue); checkConfigSync(container); }, }); // We also assign a listener so that the editable is blurred when enter is pressed span.onkeydown = (e) => { if (e.keyCode === 13) { e.preventDefault(); span.blur(); } }; span.value = value; } function initializeConfigValueSpan(span, key) { // Here we mostly want to define the setter and getter const container = span.closest(".clipboard-span"); Object.defineProperty(span, "value", { get: () => { return plot_obj.config.toImageButtonOptions[key]; }, set: (val) => { // if undefined is passed, we remove the entry from the options if (val === undefined) { delete plot_obj.config.toImageButtonOptions[key]; } else { plot_obj.config.toImageButtonOptions[key] = val; } checkConfigSync(container); }, }); } const config_spans = {}; for (const [key, value] of Object.entries(getImageOptions())) { const container = CLIPBOARD_HEADER.querySelector(`.clipboard-span.\${key}`); const label = container.querySelector(".label"); // We give the label a function that on single click will set the current value and with double click will unset it label.onclick = DualClick( () => { container.config_value = container.ui_value; }, (e) => { console.log("e", e); e.preventDefault(); container.config_value = undefined; } ); const ui_value_span = container.querySelector(".clipboard-value"); const config_value_span = container.querySelector(".config-value") ?? label.insertAdjacentElement( "afterbegin", html`<span class="config-value"></span>` ); // Assing the two spans as properties of the containing span container.ui_span = ui_value_span; container.config_span = config_value_span; container.key = key; config_spans[key] = container; if (firstRun) { plot_obj.config.toImageButtonOptions = plot_obj.config.toImageButtonOptions ?? {}; // We do the initialization of the value span initializeUIValueSpan(ui_value_span, key, value); // Then we initialize the config value initializeConfigValueSpan(config_value_span, key); // We put some convenience getters/setters // ui_value forward Object.defineProperty(container, "ui_value", { get: () => ui_value_span.value, set: (val) => { ui_value_span.value = val; }, }); // config_value forward Object.defineProperty(container, "config_value", { get: () => config_value_span.value, set: (val) => { config_value_span.value = val; }, }); } } // These objects will contain the default value // This code updates the image options in the PLOT config with the provided ones function setImageOptions(o) { for (const [key, container] of Object.entries(config_spans)) { container.config_value = o[key]; } } function unsetImageOptions() { setImageOptions({}); } const set_button = CLIPBOARD_HEADER.querySelector(".clipboard-span.set"); const unset_button = CLIPBOARD_HEADER.querySelector(".clipboard-span.unset"); if (firstRun) { set_button.onclick = (e) => { for (const container of Object.values(config_spans)) { container.config_value = container.ui_value; } }; unset_button.onclick = unsetImageOptions; } // We add a function to check if the clipboard is popped out CONTAINER.isPoppedOut = () => { return CONTAINER.classList.contains("popped-out"); }; CLIPBOARD_HEADER.onmousedown = function (event) { if (event.target.matches("span.clipboard-value")) { console.log("We don't move!"); return; } const start = { left: parseFloat(CONTAINER.style.left), top: parseFloat(CONTAINER.style.top), X: event.pageX, Y: event.pageY, }; function moveAt(event, start) { const top = event.pageY - start.Y + start.top + "px"; const left = event.pageX - start.X + start.left + "px"; CLIPBOARD_HEADER.style.left = left; CONTAINER.style.left = left; CONTAINER.style.top = top; } // move our absolutely positioned ball under the pointer moveAt(event, start); function onMouseMove(event) { moveAt(event, start); } // We use this to remove the mousemove when clicking outside of the container const controller = new AbortController(); // move the container on mousemove document.addEventListener("mousemove", onMouseMove, { signal: controller.signal, }); document.addEventListener( "mousedown", (e) => { if (e.target.closest(".plutoplotly-container") !== CONTAINER) { cleanUp(); controller.abort(); return; } }, { signal: controller.signal } ); function cleanUp() { console.log("cleaning up the plot move listener"); controller.abort(); CLIPBOARD_HEADER.onmouseup = null; } // (3) drop the ball, remove unneeded handlers CLIPBOARD_HEADER.onmouseup = cleanUp; }; function sendToClipboard(blob) { if (!navigator.clipboard) { alert( "The Clipboard API does not seem to be available, make sure the Pluto notebook is being used from either localhost or an https source." ); } navigator.clipboard .write([ new ClipboardItem({ // The key is determined dynamically based on the blob's type. [blob.type]: blob, }), ]) .then( function () { console.log("Async: Copying to clipboard was successful!"); }, function (err) { console.error("Async: Could not copy text: ", err); } ); } function copyImageToClipboard() { // We extract the image options from the provided parameters (if they exist) const config = {}; for (const [key, container] of Object.entries(config_spans)) { let val = container.config_value ?? (CONTAINER.isPoppedOut() ? container.ui_value : undefined); // If we have undefined we don't create the key. We also ignore format because the clipboard only supports png. if (val === undefined || key === "format") { continue; } config[key] = val; } Plotly.toImage(PLOT, config).then(function (dataUrl) { fetch(dataUrl) .then((res) => res.blob()) .then((blob) => { const paste_receiver = document.querySelector('paste-receiver.plutoplotly') if (paste_receiver) { paste_receiver.attachImage(dataUrl, CONTAINER) } sendToClipboard(blob) }); }); } function saveImageToFile() { const config = {}; for (const [key, container] of Object.entries(config_spans)) { let val = container.config_value ?? (CONTAINER.isPoppedOut() ? container.ui_value : undefined); // If we have undefined we don't create the key. if (val === undefined) { continue; } config[key] = val; } Plotly.downloadImage(PLOT, config); } let container_rect = { width: 0, height: 0, top: 0, left: 0 }; function unpop_container(cl) { CONTAINER.classList.toggle("popped-out", false); CONTAINER.classList.toggle(cl, false); // We fix the height back to the value it had before popout, also setting the flag to signal that upon first resize we remove the fixed inline-style CONTAINER.style.height = container_rect.height + "px"; remove_container_size = true; // We set the other fixed inline-styles to null CONTAINER.style.width = ""; CONTAINER.style.top = ""; CONTAINER.style.left = ""; // We also remove the CLIPBOARD_HEADER CLIPBOARD_HEADER.style.width = ""; CLIPBOARD_HEADER.style.left = ""; // Finally we remove the hidden class to the header CLIPBOARD_HEADER.classList.toggle("hidden", true); return; } function popout_container(opts) { const cl = opts?.cl; const target_container_size = opts?.target_container_size ?? {}; const target_plot_size = opts?.target_plot_size ?? {}; if (CONTAINER.isPoppedOut()) { return unpop_container(cl); } CONTAINER.classList.toggle(cl, cl === undefined ? false : true); // We extract the current size of the container, save them and fix them const { width, height, top, left } = CONTAINER.getBoundingClientRect(); container_rect = { width, height, top, left }; // We save the current plot size before we pop as it will fill the screen const current_plot_size = { width: PLOT._fullLayout.width, height: PLOT._fullLayout.height, }; // We have to save the pad data before popping so we can resize precisely const pad = {}; pad.unpopped = getSizeData().container_pad; CONTAINER.classList.toggle("popped-out", true); pad.popped = getSizeData().container_pad; // We do top and left based on the current rect for (const key of ["top", "left"]) { const start_val = target_container_size[key] ?? container_rect[key]; let offset = 0; for (const kind of ["padding", "border"]) { offset += pad.popped[kind][key] - pad.unpopped[kind][key]; } CONTAINER.style[key] = start_val - offset + "px"; if (key === "left") { CLIPBOARD_HEADER.style[key] = CONTAINER.style[key]; } } // We compute the width and height depending on eventual config data const csz = computeContainerSize({ width: target_plot_size.width ?? config_spans.width.config_value ?? current_plot_size.width, height: target_plot_size.height ?? config_spans.height.config_value ?? current_plot_size.height, }); for (const key of ["width", "height"]) { const val = target_container_size[key] ?? csz[key]; CONTAINER.style[key] = val + "px"; if (key === "width") { CLIPBOARD_HEADER.style[key] = CONTAINER.style[key]; } } CLIPBOARD_HEADER.classList.toggle("hidden", false); const controller = new AbortController(); document.addEventListener( "mousedown", (e) => { if (e.target.closest(".plutoplotly-container") !== CONTAINER) { unpop_container(); controller.abort(); return; } }, { signal: controller.signal } ); } CONTAINER.popOut = popout_container; function DualClick(single_func, dbl_func) { let nclicks = 0; return function (...args) { nclicks += 1; if (nclicks > 1) { dbl_func(...args); nclicks = 0; } else { delay(300).then(() => { if (nclicks == 1) { single_func(...args); } nclicks = 0; }); } }; } // We remove the default download image button plot_obj.config.modeBarButtonsToRemove = _.union( plot_obj.config.modeBarButtonsToRemove, ["toImage"] ); // We add the custom button to the modebar plot_obj.config.modeBarButtonsToAdd = _.union( plot_obj.config.modeBarButtonsToAdd, [ { name: "Copy PNG to Clipboard", icon: { height: 520, width: 520, path: "M280 64h40c35.3 0 64 28.7 64 64V448c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V128C0 92.7 28.7 64 64 64h40 9.6C121 27.5 153.3 0 192 0s71 27.5 78.4 64H280zM64 112c-8.8 0-16 7.2-16 16V448c0 8.8 7.2 16 16 16H320c8.8 0 16-7.2 16-16V128c0-8.8-7.2-16-16-16H304v24c0 13.3-10.7 24-24 24H192 104c-13.3 0-24-10.7-24-24V112H64zm128-8a24 24 0 1 0 0-48 24 24 0 1 0 0 48z", }, direction: "up", click: DualClick(copyImageToClipboard, () => { popout_container(); }), }, { name: "Download Image", icon: Plotly.Icons.camera, direction: "up", click: DualClick(saveImageToFile, () => { popout_container({ cl: "filesave" }); }), }, ] ); """) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 |
const resizer_script = htl_js(""" function getOffsetData(el) { let cs = window.getComputedStyle(el, null); const odata = { padding: { left: parseFloat(cs.paddingLeft), right: parseFloat(cs.paddingRight), top: parseFloat(cs.paddingTop), bottom: parseFloat(cs.paddingBottom), width: parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight), height: parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom), }, border: { left: parseFloat(cs.borderLeftWidth), right: parseFloat(cs.borderRightWidth), top: parseFloat(cs.borderTopWidth), bottom: parseFloat(cs.borderBottomWidth), width: parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth), height: parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth), } }; if (el === PLOT) { // For the PLOT we also want to take into account the offset odata.offset = { top: PLOT.offsetParent == CONTAINER ? PLOT.offsetTop : 0, left: PLOT.offsetParent == CONTAINER ? PLOT.offsetLeft : 0, } } return odata; } function getSizeData() { const data = { plot_pad: getOffsetData(PLOT), plot_rect: PLOT.getBoundingClientRect(), container_pad: getOffsetData(CONTAINER), container_rect: CONTAINER.getBoundingClientRect(), }; return data; } function computeContainerSize({ width, height }, sizeData = getSizeData()) { const computed_size = computePlotSize(sizeData); const offsets = computed_size.offsets; const plot_data = { width: width ?? computed_size.width, height: height ?? computed_size.height, }; return { width: (width ?? computed_size.width) + offsets.width, height: (height ?? computed_size.height) + offsets.height, noChange: width == computed_size.width && height == computed_size.height, } } // This function will change the container size so that the resulting plot will be matching the provided specs function changeContainerSize({ width, height }, sizeData = getSizeData()) { if (!CONTAINER.isPoppedOut()) { console.log("Tried to change container size when not popped, ignoring"); return; } const csz = computeContainerSize({ width, height }, sizeData); if (csz.noChange) { console.log("Size is the same as current, ignoring"); return } // We are now going to set he width and height of the container for (const key of ["width", "height"]) { CONTAINER.style[key] = csz[key] + "px"; } } // We now create the function that will update the plot based on the values specified function updateFromHeader() { const header_data = { height: config_spans.height.ui_value, width: config_spans.width.ui_value, }; changeContainerSize(header_data); } // We assign this function to the onblur event of width and height if (firstRun) { for (const container of Object.values(config_spans)) { container.ui_span.onblur = (e) => { container.ui_value = container.ui_span.textContent; updateFromHeader(); }; } } // This function computes the plot size to use for relayout as a function of the container size function computePlotSize(data = getSizeData()) { // Remove Padding const { container_pad, plot_pad, container_rect } = data; const offsets = { width: plot_pad.padding.width + plot_pad.border.width + plot_pad.offset.left + container_pad.padding.width + container_pad.border.width, height: plot_pad.padding.height + plot_pad.border.height + plot_pad.offset.top + container_pad.padding.height + container_pad.border.height, }; const sz = { width: Math.round(container_rect.width - offsets.width), height: Math.round(container_rect.height - offsets.height), offsets, }; return sz; } // Create the resizeObserver to make the plot even more responsive! :magic: const resizeObserver = new ResizeObserver((entries) => { const sizeData = getSizeData(); const {container_rect, container_pad} = sizeData; let plot_size = computePlotSize(sizeData); // We save the height in the PLOT object PLOT.container_height = container_rect.height; // We deal with some stuff if the container is poppped CLIPBOARD_HEADER.style.width = container_rect.width + "px"; CLIPBOARD_HEADER.style.left = container_rect.left + "px"; config_spans.height.ui_value = plot_size.height; config_spans.width.ui_value = plot_size.width; /* The addition of the invalid argument `plutoresize` seems to fix the problem with calling `relayout` simply with `{autosize: true}` as update breaking mouse relayout events tracking. See https://github.com/plotly/plotly.js/issues/6156 for details */ let config = { // If this is popped out, we ignore the original width/height width: (CONTAINER.isPoppedOut() ? undefined : original_width) ?? plot_size.width, height: (CONTAINER.isPoppedOut() ? undefined : original_height) ?? plot_size.height, plutoresize: true, }; Plotly.relayout(PLOT, config).then(() => { if (remove_container_size && !CONTAINER.isPoppedOut()) { // This is needed to avoid the first resize upon plot creation to already be without a fixed height CONTAINER.style.height = ""; CONTAINER.style.width = ""; remove_container_size = false; } }); }); resizeObserver.observe(CONTAINER); """) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
module PlotlyKaleidoExt using PlutoPlotly: PlutoPlot, get_plotly_version using PlotlyKaleido: savefig, PlotlyKaleido, restart, P, is_running function get_version_in_kaleido() is_running() || return nothing exec = P.proc.cmd.exec flag_idx = findfirst(startswith("https://cdn.plot.ly"), exec) isnothing(flag_idx) && return nothing url = exec[flag_idx] m = match(r"https://cdn.plot.ly/plotly-(\d+\.\d+\.\d+).min.js", url) return first(m.captures) |> VersionNumber end function ensure_correct_version() pkgversion(PlotlyKaleido) >= v"2.2.1" || return # If we can't change version, we just assume it's correct current_version = get_plotly_version() # We find the flags in the cmd used to start kaleido, if we have a specified # version, that appears as a url of the corresponding version on the plotly # CDN, see https://github.com/JuliaPlots/PlotlyKaleido.jl/pull/9 for more # details kaleido_plotly_version = get_version_in_kaleido() if isnothing(kaleido_plotly_version) || kaleido_plotly_version != current_version @info "(Re)Starting the kaleido process with plotly version $current_version" restart(; plotly_version = current_version) # Process it not active, we simply start it with the correct version return end end function PlotlyKaleido.savefig(io::IO, p::PlutoPlot, args...; kwargs...) ensure_correct_version() savefig(io, p.Plot, args...; kwargs...) end end |
1 2 3 4 5 6 7 8 |
module UnitfulExt using PlutoPlotly: _process_with_names, PlutoPlotly, AttrName using Unitful: Quantity, ustrip PlutoPlotly._process_with_names(q::Quantity, fl::Val, @nospecialize(args::Vararg{AttrName})) = _process_with_names(ustrip(q), fl, args...) end |