diff --git a/Project.toml b/Project.toml index fe6da4c..d09a904 100644 --- a/Project.toml +++ b/Project.toml @@ -4,6 +4,7 @@ authors = ["Simon Danisch, Benedikt Ehinger, Vladimir Mikheev"] version = "0.1.0" [deps] +AlgebraOfGraphics = "cbdf2221-f076-402e-a563-3d30da359d67" BSplineKit = "093aae92-e908-43d7-9660-e50ee39d5a0a" Bonito = "824d6782-a2ef-11e9-3a09-e5662e0c26f8" ColorTypes = "3da002f7-5984-5a60-b8a6-cbb66c0b333f" @@ -23,6 +24,7 @@ UnfoldSim = "ed8ae6d2-84d3-44c6-ab46-0baf21700804" WGLMakie = "276b4fcb-3e11-5398-bf8b-a0c2d153d008" [compat] +AlgebraOfGraphics = "0.10, 0.11, 0.12" BSplineKit = "0.17, 0.18" Bonito = "4" ColorTypes = "0.12.0" @@ -30,12 +32,12 @@ Colors = "0.12, 0.13" DataFrames = "1" DataFramesMeta = "0.15" GeometryBasics = "0.4, 0.5" -Makie = "0.21, 0.22" -MakieCore = "0.9" +Makie = "0.24" +MakieCore = "0.10" StatsBase = "0.34" StatsModels = "0.7" Test = "1.11" -TopoPlots = "0.2" +TopoPlots = "0.3" Unfold = "0.7, 0.8" UnfoldSim = "0.4" -WGLMakie = "0.10, 0.11" +WGLMakie = "0.13" diff --git a/docs/new_vs_old.md b/docs/new_vs_old.md new file mode 100644 index 0000000..e2c131c --- /dev/null +++ b/docs/new_vs_old.md @@ -0,0 +1,107 @@ +# ERPExplorer Changes +## 1) Package-level changes + +### Biggest architectural change + +- The plotting backend was switched from a custom Makie `PlotSpec` pipeline to **AlgebraOfGraphics (AoG)**. +- Old version: manual style building in `src/functions_plotting.jl` + `src/functions_style_scatter_lines.jl`. +- New version: AoG-based `draw_to_spec` in `src/functions_plotting.jl`. + +### Dependency/compat changes + +- New version adds `AlgebraOfGraphics` as a direct dependency in `Project.toml`. +- Rendering stack was updated to newer versions (`Makie 0.24`, `WGLMakie 0.13`, `TopoPlots 0.3`). +- Old version targets earlier stack (`Makie 0.21/0.22`, `WGLMakie 0.10/0.11`, `TopoPlots 0.2`). + +### New user-facing functionality in the package + +- `explore(...)` gained new keyword options: + - `axis_options = nothing` + - `auto_reset_view = true` + - `fit_window = true` +- `positions` now supports multiple topoplot position sets via `Dict`/`NamedTuple` (selection dropdown in UI). +- Reset view logic is explicit and reusable (button + auto reset after updates). +- Layout can now scale with viewport by default (`fit_window=true`). +- Categorical value controls (e.g., fruit/animal) are now selectable in the formula header dropdown content. + +### Axis configuration in new backend + +`update_grid(...; axis_options = ...)` accepts: + +- `:x_unit` (default `:ms`, also accepts `:s`) +- `:xlabel` (default `nothing`, auto label from `x_unit`) +- `:ylabel` (default `"Amplitude (uV)"`) +- `:xlimits` (default `nothing`) +- `:ylimits` (default `nothing`) +- `:xticks` (default `nothing`) +- `:yticks` (default `nothing`) +- `:xtickformat` (default `nothing`) +- `:ytickformat` (default `nothing`) +- `:xscale` (default `nothing`) +- `:yscale` (default `nothing`) + +If a non-supported key is passed, the new code raises an error with the allowed key list. + +### Stability behavior currently enforced + +- In the new AoG path, if the same term is used for `linestyle` and `row/col` facet, linestyle is intentionally disabled for that render. +- Reason: this combination was unstable in AoG for this app (missing facets/legend inconsistencies). +- Color/marker with same-term faceting is still allowed. + +## 2) `test/serve_widgets.jl` comparison + +Both new and old scripts support: + +- Running app server mode. +- Batch benchmark mode (`bench`). +- Live timing mode (`bench-live`). +- Auto-live mode (`bench-live-auto`). +- CLI parsing via `--key=value` and flags. + +The new script extends this with file-driven auto actions and report output. + +### Core runtime defaults + +- New script default URL: `http://127.0.0.1:8082` +- Old script default URL: `http://127.0.0.1:8081` +- Default mode: `serve` + +### CLI options (new script, exact defaults) + +- `--mode` default: `"serve"` + - accepted values used in code: `serve`, `bench`, `bench-live`, `bench-live-auto` +- `--bench` flag: equivalent to `--mode=bench` +- `--bench-live` flag: equivalent to `--mode=bench-live` +- `--bench-live-auto` flag: equivalent to `--mode=bench-live-auto` +- `--bench-repeats=` default: `5` +- `--bench-warmup=` default: `1` +- `--bench-channel=` default: `1` +- `--bench-out=` default: `""` (no bench CSV written unless set) +- `--bench-live-start-delay=` default: `20` (seconds) +- `--bench-live-delay=` default input: `5` + - effective default: `5.0` seconds + - effective minimum: **clamped to at least `5.0`** even if smaller value is passed +- `--bench-live-actions=` default: + - `test/livebench_actions_default.txt` +- `--bench-live-report=` default: `""` + - when empty, report path is auto-generated in the same folder as action file: + - `_report_.csv` + +### Action files in new script + +- Default scenario file: + - `test/livebench_actions_default.txt` +- Full list of currently supported actions: + - `test/livebench_actions_all.txt` +- New script validates action names before running and fails fast for unknown actions. + +### Auto-live report behavior (new script) + +- In `bench-live-auto`, each rendered action can be logged with: + - `effects_ms` + - `layout_ms` + - `total_ms` +- Report is written to CSV: + - explicit path from `--bench-live-report`, or + - auto path beside the action file (default behavior). + diff --git a/src/ERPExplorer.jl b/src/ERPExplorer.jl index d3fb065..8e176db 100644 --- a/src/ERPExplorer.jl +++ b/src/ERPExplorer.jl @@ -5,6 +5,7 @@ using Unfold import Makie.SpecApi as S using BSplineKit +using AlgebraOfGraphics using Unfold using WGLMakie using Bonito @@ -20,7 +21,6 @@ include("explore.jl") include("functions_preprocessing.jl") include("functions_formular.jl") include("functions_plotting.jl") -include("functions_style_scatter_lines.jl") include("widgets_short.jl") include("widgets_long.jl") diff --git a/src/explore.jl b/src/explore.jl index 7430bc6..8550680 100644 --- a/src/explore.jl +++ b/src/explore.jl @@ -1,15 +1,25 @@ """ - explore(model::UnfoldModel; positions = nothing, size = (700, 600)) + explore(model::UnfoldModel; positions = nothing, size = (700, 600), axis_options = nothing, auto_reset_view = true, fit_window = true) Run the dashboard for explorative ERP analysis. Arguments:\\ - `model::UnfoldLinearModel{Float64}` - Unfold linear model with categorical and continuous terms.\\ - `positions::Vector{Point{2, Float32}}` - x an y coordinates of the channels on topoplot.\\ - `size::Tuple{Float64, Float64}` - size of the topoplot panel.\\ +- `axis_options` - optional axis configuration passed to `update_grid` (e.g. `:x_unit`, labels, limits, ticks).\\ +- `auto_reset_view::Bool` - if `true`, recenter axes after each data/mapping update (default `true`).\\ +- `fit_window::Bool` - if `true`, fit dashboard width/height to browser viewport (default `true`).\\ **Return Value:** `Hyperscript.Node{Hyperscript.HTMLSVG}` - final HTML code of the dashboard. """ -function explore(model::UnfoldModel; positions = nothing, size = (700, 600)) +function explore( + model::UnfoldModel; + positions = nothing, + size = (700, 600), + axis_options = nothing, + auto_reset_view = true, + fit_window = true, +) Bonito.set_cleanup_time!(1) # wait one hour before closing session # Initialize the App from Bonito. App allows to wrap all interactive elements and to deploy them myapp = App() do @@ -18,6 +28,13 @@ function explore(model::UnfoldModel; positions = nothing, size = (700, 600)) # Create formula widgets for each term. formula_defaults, formula_toggle, formula_DOM, formula_values = formular_widgets(variables) + reset_button = Bonito.Button( + "Reset view"; + style = Styles( + "padding" => "4px 6px", + "min-height" => "24px", + ), + ) # Extract variable names and types from the model. var_types = map(x -> x[2][3], variables) @@ -28,21 +45,42 @@ function explore(model::UnfoldModel; positions = nothing, size = (700, 600)) # Create interactive topoplot widget on the lower left panel of the dashboard. channel_chosen = Observable(1) - if isnothing(positions) + topo_widget = nothing + topo_size = size .* 0.5 + if positions isa AbstractDict || positions isa NamedTuple + pos_sets = Dict{String,Any}() + for (k, v) in pairs(positions) + pos_sets[string(k)] = v + end + pos_keys = collect(keys(pos_sets)) + topo_select = Dropdown(pos_keys; index = 1) + topo_widget_obs = + Observable{Any}(topoplot_widget(pos_sets[pos_keys[1]], channel_chosen; size = topo_size)) + on(topo_select.value) do key + channel_chosen[] = 1 + topo_widget_obs[] = + topoplot_widget(pos_sets[key], channel_chosen; size = topo_size) + end + topo_widget = Col( + Row(DOM.div("Topoplot:"), topo_select, align_items = "center"), + topo_widget_obs, + ) + elseif isnothing(positions) topo_widget = nothing else - topo_widget = topoplot_widget(positions, channel_chosen; size = size .* 0.5) + topo_widget = topoplot_widget(positions, channel_chosen; size = topo_size) end # Create Observable DataFrame with predicted values (yhats) of the model. ERP_data = get_ERP_data(model, formula_toggle, channel_chosen) # when m changes update formula_defaults on(mapping) do m - ft = formula_toggle.val - ks_m = values(m) - ks_ft = [t.first for t in ft] - for k in ks_ft - formula_defaults[k][] = k ∈ ks_m + selected_terms = Set(v for v in values(m) if v != :none) + for (term, toggle_obs) in formula_defaults + if term in selected_terms && !toggle_obs[] + # Mapping a variable should auto-enable it, but never disable other active terms. + toggle_obs[] = true + end end end @@ -56,34 +94,95 @@ function explore(model::UnfoldModel; positions = nothing, size = (700, 600)) # When multiple events occur nearly simultaneously, the lock ensures that: # Only one plot update happens at a time and Plot data calculations complete fully before starting new ones lk = Base.ReentrantLock() + fig_ref = Ref{Union{Nothing,Makie.FigureAxisPlot}}(nothing) + + function reset_all_axes!() + fig_obj = fig_ref[] + isnothing(fig_obj) && return + lock(lk) do + function collect_axes!(acc, item) + if item isa Makie.Axis + push!(acc, item) + elseif item isa Makie.GridLayoutBase.GridLayout + for child in Makie.GridLayoutBase.contents(item) + collect_axes!(acc, child) + end + end + end + axes = Makie.Axis[] + collect_axes!(axes, fig_obj.figure.layout) + for ax in axes + Makie.reset_limits!(ax) + Makie.autolimits!(ax) + end + end + end # Update the the grid layout + render_count = Ref(0) Makie.onany_latest(ERP_data, mapping; update = true) do ERP_data, mapping # `update = true` means that it will run once immediately lock(lk) do + t0 = time_ns() _tmp = update_grid( ERP_data, formula_values, var_names[var_types.==:CategoricalTerm], var_names[var_types.==:ContinuousTerm], mapping, + axis_options = axis_options, ) - plot_layout[] = _tmp + try + plot_layout[] = _tmp + catch err + err_msg = sprint(showerror, err) + if occursin("Screen Session uninitialized", err_msg) || + occursin("Session status: SOFT_CLOSED", err_msg) + return + end + rethrow(err) + end + render_count[] += 1 + elapsed_ms = (time_ns() - t0) / 1e6 + println("render #", render_count[], " update_grid -> layout in ", round(elapsed_ms; digits = 2), " ms") + if auto_reset_view + reset_all_axes!() + end end return end css = Asset(joinpath(@__DIR__, "..", "style.css")) fig = plot(plot_layout; figure = (size = size,)) + fig_view = fit_window ? WGLMakie.WithConfig(fig; resize_to = :parent) : fig + fig_ref[] = fig + + on(reset_button.value) do _ + reset_all_axes!() + end - # terrible hack to remove the legend protrution at the beginning - ERPExplorer.Makie.colsize!(fig.figure.layout,2,(1)) # Create header, sidebar, topo and content (figure) panels + header_dom = Grid( + formula_DOM, + reset_button; + rows = "1fr", + columns = "1fr auto", + gap = "8px", + align_items = "center", + ) cards = Grid( - Card(formula_DOM, style = Styles("grid-area" => "header")), + Card(header_dom, style = Styles("grid-area" => "header")), Card(mapping_dom, style = Styles("grid-area" => "sidebar")), Card(topo_widget, style = Styles("grid-area" => "topo")), - Card(fig, style = Styles("grid-area" => "content")); + Card( + fig_view, + style = Styles( + "grid-area" => "content", + "min-width" => "0", + "min-height" => "0", + "overflow" => "hidden", + ), + ); columns = "5fr 1fr", rows = "1fr 6fr 4fr", areas = """ @@ -92,17 +191,26 @@ function explore(model::UnfoldModel; positions = nothing, size = (700, 600)) 'content topo' """, ) + container_style = + fit_window ? + Styles( + "height" => "calc(100vh - 24px)", + "width" => "calc(100vw - 24px)", + "margin" => "12px", + "position" => :relative, + ) : + Styles( + "height" => "$(1.2*size[2])px", + "width" => "$(size[1])px", + "margin" => "20px", + "position" => :relative, + ) # Translate the cards and css into HTML code using DOMs res = DOM.div( css, Bonito.TailwindCSS, cards; - style = Styles( - "height" => "$(1.2*size[2])px", - "width" => "$(size[1])px", - "margin" => "20px", - "position" => :relative, - ), + style = container_style, ) return res end diff --git a/src/functions_formular.jl b/src/functions_formular.jl index 7f05ac3..bcfe3db 100644 --- a/src/functions_formular.jl +++ b/src/functions_formular.jl @@ -42,10 +42,11 @@ function formular_widgets(variables) formula_toggle = lift(widget_values..., checkbox_values...; ignore_equal_values = true) do args... result = [] - for i = 1:length(args[1:end/2]) - c = args[i+length(args)/2] + n_widgets = Int(length(args) ÷ 2) + for i = 1:n_widgets + c = args[i+n_widgets] w = args[i] - push!(result, widgets[i][1] => (map(identity, c), map(identity, w))) + push!(result, widgets[i][1] => (c, w)) end return result end diff --git a/src/functions_plotting.jl b/src/functions_plotting.jl index 02fe95b..f2c2bf4 100644 --- a/src/functions_plotting.jl +++ b/src/functions_plotting.jl @@ -1,173 +1,219 @@ """ update_grid(data, formula_values, categorical_vars, continuous_terms, mapping_obs) -Plotting and updating an interactive dashboard. +Plot and update the interactive dashboard using AlgebraOfGraphics. - Arguments:\\ -- `data::DataFrame` - the result of `effects(Dict(...), model) ` with columns: yhat, channel, dummy, time, eventname and unique columns for each formula term.\\ +Arguments:\\ +- `data::DataFrame` - the result of `effects(Dict(...), model)` with columns: yhat, channel, dummy, time, eventname and unique columns for each formula term.\\ - `formula_values::Vector{Pair{Symbol}}` - value range for continuous variables, levels for categorical.\\ - `categorical_vars::Vector{Symbol}` - categorical terms.\\ - `continuous_terms::Vector{Symbol}` - continuous terms.\\ - `mapping::Dict{Symbol, Symbol}` - dictionary with dropdown menus and their default values.\\ +- `axis_options` - optional axis configuration. Supported keys are `:x_unit` (`:ms`/`:s`), + `:xlabel`, `:ylabel`, `:xlimits`, `:ylimits`, `:xticks`, `:yticks`, + `:xtickformat`, `:ytickformat`, `:xscale`, `:yscale`.\\ Action:\\ -- Create default palettes for colors, markers, line styles, and color styles for continuous values.\\ -- Check that the terms are not empty.\\ -- Plot the dashboard.\\ -- Define line and scatter styles for the line plot.\\ -- Add line and scatter styles to the legend. +- Build AoG layers for lines and scatter, with optional faceting and scales.\\ **Return Value:** `Makie.GridLayoutSpec`. """ -function update_grid(data, formula_values, cat_terms, continuous_terms, mapping_obs) - # Convert observable mapping to values - mapping = to_value(mapping_obs) +function update_grid(data, formula_values, cat_terms, continuous_terms, mapping_obs; axis_options = nothing) + mapping_state = to_value(mapping_obs) - # Identify is categorical term activated - cat_active = Dict(cat => data[1, cat] != "typical_value" for cat in cat_terms) - # Identify is continuous term activated + if isnothing(data) || nrow(data) == 0 + empty_axis = S.Axis(; title = "No data for current selection") + return S.GridLayout([(1, 1) => S.GridLayout([(1, 1) => empty_axis])]) + end + + # Use full-column checks to avoid transient first-row artifacts during rapid UI updates. + cat_active = Dict(cat => any(data[!, cat] .!= "typical_value") for cat in cat_terms) cont_active = - Dict(cont => data[1, cont] != "typical_value" for cont in continuous_terms) - # Retrieve levels for selected and unselected categorical terms - cat_levels = [unique(data[!, cat]) for cat in cat_terms] # empty unless selected - - # Prepare styles for categorical and continuous variables - scatter_styles, line_styles = prepare_styles( - data, - cat_terms, - continuous_terms, - mapping, - cat_active, - cont_active, - cat_levels, + Dict(cont => any(data[!, cont] .!= "typical_value") for cont in continuous_terms) + + plot_data = copy(data) + axis_config = Dict{Symbol,Any}( + :x_unit => :ms, + :xlabel => nothing, + :ylabel => "Amplitude (uV)", + :xlimits => nothing, + :ylimits => nothing, + :xticks => nothing, + :yticks => nothing, + :xtickformat => nothing, + :ytickformat => nothing, + :xscale => nothing, + :yscale => nothing, ) - - col_term = mapping[:col] # not used yet - row_term = mapping[:row] # not used yet - - legend_entries = - [n => v for (n, v) in formula_values if merge(cat_active, cont_active)[n]] - - row_levels = - row_term == :none ? [""] : - [v for (v, n) in zip(cat_levels, cat_terms) if n == row_term][1] - col_levels = - col_term == :none ? [""] : - [v for (v, n) in zip(cat_levels, cat_terms) if n == col_term][1] - - - # Initialize matrix of plot axes - axes = Matrix{Makie.BlockSpec}(undef, length(row_levels), length(col_levels)) - - for (r_ix, row_level) in enumerate(row_levels) - for (c_ix, col_level) in enumerate(col_levels) - plots = PlotSpec[] - - # Filter data based on row and column levels - subdata = filter_facet_data(data, row_term, col_term, row_level, col_level) - - active_cat_vars = Dict( - term => level for - (term, level) in zip(cat_terms, cat_levels) if cat_active[term] - ) - if row_term != :none - active_cat_vars[row_term] = [row_level] - end - if col_term != :none - active_cat_vars[col_term] = [col_level] - end - - # Iterate over categorical levels to define styles - for level_grid in Iterators.product(collect(values(active_cat_vars))...) - if !isempty(level_grid) && level_grid[1] .== "typical_value" - continue - end - dict_grid = Dict(collect(keys(active_cat_vars)) .=> level_grid) - define_style_scatter_lines!( - plots, - subdata, - dict_grid, - scatter_styles, - line_styles, - continuous_terms, + allowed_axis_keys = Set(keys(axis_config)) + if !isnothing(axis_options) + for (k, v) in pairs(axis_options) + if !(k in allowed_axis_keys) + error( + "Unsupported axis option $(repr(k)). Supported keys: " * + join(sort!(collect(allowed_axis_keys)), ", "), ) end - axes[r_ix, c_ix] = S.Axis(; plots = plots) + axis_config[k] = v end end - palettes = merge(line_styles, scatter_styles) - legends = Union{Nothing, Makie.BlockSpec}[] - for (term, levels) in legend_entries - if haskey(palettes, term) - push!(legends, variable_legend(term, levels, Dict(palettes[term]))) + x_unit_raw = axis_config[:x_unit] + x_unit = x_unit_raw isa AbstractString ? Symbol(lowercase(x_unit_raw)) : x_unit_raw + if x_unit in (:ms, :millisecond, :milliseconds) + plot_data[!, :time_axis] = 1000 .* plot_data.time + default_xlabel = "Time (ms)" + elseif x_unit in (:s, :sec, :second, :seconds) + plot_data[!, :time_axis] = plot_data.time + default_xlabel = "Time (s)" + else + error("Unsupported x_unit $(repr(x_unit_raw)). Supported values: :ms or :s.") + end + max_time = maximum(plot_data.time) + plot_data[plot_data.time .≈ max_time, :yhat] .= NaN + + cat_color = get(cat_active, mapping_state[:color], false) ? mapping_state[:color] : nothing + cat_marker = get(cat_active, mapping_state[:marker], false) ? mapping_state[:marker] : nothing + cat_linestyle = + get(cat_active, mapping_state[:linestyle], false) ? mapping_state[:linestyle] : nothing + + row_term = + mapping_state[:row] != :none && get(cat_active, mapping_state[:row], false) ? + mapping_state[:row] : :none + col_term = + mapping_state[:col] != :none && get(cat_active, mapping_state[:col], false) ? + mapping_state[:col] : :none + + if cat_linestyle !== nothing && (cat_linestyle == row_term || cat_linestyle == col_term) + # AoG currently behaves inconsistently when the same term drives both facetting and linestyle. + # Prefer stable facets over redundant linestyle encoding in that case. + cat_linestyle = nothing + end + + formula_lookup = Dict(formula_values) + function categorical_levels(term::Symbol) + observed_levels = collect(unique(plot_data[!, term])) + if !haskey(formula_lookup, term) || !(formula_lookup[term] isa AbstractSet) + return observed_levels + end + if !get(cat_active, term, false) + return observed_levels end + configured_levels = sort!(collect(formula_lookup[term])) + observed_set = Set(observed_levels) + configured_observed = [lvl for lvl in configured_levels if lvl in observed_set] + extra_levels = [lvl for lvl in observed_levels if !(lvl in configured_levels)] + return vcat(configured_observed, extra_levels) end - res = S.GridLayout([(1, 1) => S.GridLayout(axes), (:, 2) => S.GridLayout(legends)]) - return res -end + facet_aes = Dict{Symbol,Any}() + if row_term != :none + facet_aes[:row] = row_term + end + if col_term != :none + facet_aes[:col] = col_term + end + scatter_aes = Dict{Symbol,Any}() + if cat_color !== nothing + scatter_aes[:color] = cat_color => string(cat_color) + end + if cat_marker !== nothing + scatter_aes[:marker] = cat_marker => string(cat_marker) + end -function prepare_styles( - data, - cat_terms, - continuous_terms, - mapping, - cat_active, - cont_active, - cat_levels, -) - # Define palettes for markers, colors, line and ?? styles - mpalette = [:circle, :xcross, :star4, :diamond] - cpalette = Makie.wong_colors() - lpalette = [:solid, :dot, :dash] - continuous_styles = [:viridis, :heat, :RdBu] - - # Assign styles to categorical variables - scatter_styles = Dict() - for (vals, cat) in zip(cat_levels, cat_terms) - if !cat_active[cat] - continue - end - for (target, pal) in - zip([:color, :marker, :linestyle], (cpalette, mpalette, lpalette)) - if mapping[target] == cat - p = cat => (target => Dict(zip(vals, pal))) - push!(scatter_styles, p) - end - end + line_aes = Dict{Symbol,Any}() + if cat_linestyle !== nothing + line_aes[:linestyle] = cat_linestyle => string(cat_linestyle) end - # Assign styles to continuous variables - continuous_values = [extrema(data[!, con]) for con in continuous_terms] - # Check for equal min/max values - if isempty(continuous_terms) || - isempty(continuous_values) || - continuous_values[1] == Float64 && continuous_values[1] == continuous_values[2] - # if no continuous variable, use the scatter-color for plotting - line_styles = Dict() + active_cont = filter(cont -> get(cont_active, cont, false), continuous_terms) + has_cont = !isempty(active_cont) + if has_cont + cont_term = first(active_cont) + if cat_color !== nothing + line_aes[:color] = cont_term => AlgebraOfGraphics.scale(:color2) + else + line_aes[:color] = cont_term + end + elseif cat_color !== nothing + line_aes[:color] = cat_color => string(cat_color) + end - else - active_terms = filter(cont -> cont_active[cont], continuous_terms) - line_styles = Dict( - cont => (:colormap => (val, style)) for (style, val, cont) in - zip(continuous_styles, continuous_values, active_terms) - ) + base = AlgebraOfGraphics.data(plot_data) * + AlgebraOfGraphics.mapping(:time_axis, :yhat; pairs(facet_aes)...) + default_color = RGBA(0.0f0, 0.0f0, 0.0f0, 1.0f0) + scatter_visual_kwargs = Pair{Symbol,Any}[] + line_visual_kwargs = Pair{Symbol,Any}[] + if cat_color === nothing && !has_cont + push!(scatter_visual_kwargs, :color => default_color) + push!(line_visual_kwargs, :color => default_color) + end + if cat_linestyle === nothing + # Force a stable default so stale linestyle mappings are not carried across rerenders. + push!(line_visual_kwargs, :linestyle => :solid) end - return scatter_styles, line_styles -end + scatter_layer = AlgebraOfGraphics.mapping(; pairs(scatter_aes)...) * + AlgebraOfGraphics.visual(Scatter; markersize = 10, scatter_visual_kwargs...) + line_layer = AlgebraOfGraphics.mapping(; pairs(line_aes)...) * + AlgebraOfGraphics.visual(Lines; line_visual_kwargs...) -function filter_facet_data(data, row_term, col_term, row_level, col_level) - # Subset data based on row and column levels - subdata = data - if col_term != :none - subdata = subset(subdata, col_term => ByRow(==(col_level))) + spec = base * (line_layer + scatter_layer) + + scales_kwargs = Dict{Symbol,Any}() + if cat_color !== nothing + scales_kwargs[:Color] = + (; palette = Makie.wong_colors(), categories = categorical_levels(cat_color)) + end + if cat_marker !== nothing + scales_kwargs[:Marker] = + (; + palette = [:circle, :xcross, :star4, :diamond], + categories = categorical_levels(cat_marker), + ) + end + if cat_linestyle !== nothing + scales_kwargs[:LineStyle] = + (; + palette = [:solid, :dot, :dash], + categories = categorical_levels(cat_linestyle), + ) end if row_term != :none - subdata = subset(subdata, row_term => ByRow(==(row_level))) + scales_kwargs[:Row] = (; categories = categorical_levels(row_term)) + end + if col_term != :none + scales_kwargs[:Col] = (; categories = categorical_levels(col_term)) + end + if has_cont + cont_term = first(active_cont) + scale_key = cat_color !== nothing ? :color2 : :Color + scales_kwargs[scale_key] = + (; colormap = :viridis, colorrange = extrema(data[!, cont_term])) end - return subdata + + axis_kwargs = Dict{Symbol,Any}() + axis_kwargs[:xlabel] = isnothing(axis_config[:xlabel]) ? default_xlabel : axis_config[:xlabel] + axis_kwargs[:ylabel] = axis_config[:ylabel] + for key in (:xlimits, :ylimits, :xticks, :yticks, :xtickformat, :ytickformat, :xscale, :yscale) + if !isnothing(axis_config[key]) + axis_kwargs[key] = axis_config[key] + end + end + + spec_layout = AlgebraOfGraphics.draw_to_spec( + spec, + AlgebraOfGraphics.scales(; pairs(scales_kwargs)...); + facet = (; + linkxaxes = :none, + linkyaxes = :none, + hidexdecorations = false, + hideydecorations = false, + ), + axis = (; pairs(axis_kwargs)...), + ) + + return S.GridLayout([(1, 1) => spec_layout]) end diff --git a/src/functions_style_scatter_lines.jl b/src/functions_style_scatter_lines.jl deleted file mode 100644 index 2ade969..0000000 --- a/src/functions_style_scatter_lines.jl +++ /dev/null @@ -1,64 +0,0 @@ -""" - define_style_scatter_lines!(plots, data, dict_grid, scatter_styles, line_styles, continuous_vars) -Define styling of lines and points (scatter). - -Actions:\\ -- subset the data.\\ -- select points and plot scatter. Define scatter style: markersize and color.\\ -- plot lines and define line style: colormap, color range, color.\\ - -Arguments:\\ -- `plots::Vector{Makie.PlotSpec}` - an empty SpecApi list to push into parts of the layout.\\ -- `data::DataFrame` - a DataFrame with predicted values to be subsetted.\\ -- `dict_grid::Dict{Any, Any}` - dictionary with one of the possible combination of selected categorical terms.\\ -- `scatter_styles::Dict{Any, Any}` - define colors of scatter.\\ -- `line_styles:: Dict{Symbol, Pair{Symbol, Tuple{Tuple{String, String}, Symbol}}}` - define line styles: colormap, color range, color.\\ -- `continuous_vars::Vector{Symbol}` - continuous terms. - -**Return Value:** `Makie.GridLayoutSpec`. -""" -function define_style_scatter_lines!( - plots, - data, - dict_grid, - scatter_styles, - line_styles, - continuous_vars, -) - selector = [(name => x -> x .== var) for (name, var) in dict_grid] - - sub = subset(data, selector...) # but len(data) and len(sub) are equal... - - @assert !isempty(sub) "This shouldn't be empty..." - points = Point2f.(sub.time, sub.yhat) - points[sub.time.≈maximum(sub.time)] .= Ref(Point2f(NaN)) - # terrible hack, it will remove the last point from ploitting. - #better would be to loop the lines! with views of the dataframe... - - # define style for scatter - args = [ - scatter_styles[term][1] => scatter_styles[term][2][val] for - (term, val) in dict_grid if term ∈ keys(scatter_styles) - ] - - # define style for lines and scatter - if isempty(line_styles) - line_cmap = [] - line_crange = [] - - args = convert(Vector{Pair{Symbol,Any}}, args) - if isempty(args) || !any(x -> x[1] == :color, args) - push!(args, :color => RGBA(0.0f0, 0.0f0, 0.0f0, 1.0f0)) # set scatter color to default black - end - line_color = [:color => Dict(args)[:color]] - - else # if contionus terms are present - line_cmap = [kw => cmap for (_, (kw, (_, cmap))) in line_styles] - line_crange = [:colorrange => lims for (_, (_, (lims, _))) in line_styles] - line_color = [:color => sub[!, name] for name in continuous_vars] - end - - push!(plots, S.Scatter(points; markersize = 10, args...)) - push!(plots, S.Lines(points; line_cmap..., line_crange..., line_color...)) - return -end diff --git a/src/widgets_long.jl b/src/widgets_long.jl index cbf39f2..814c625 100644 --- a/src/widgets_long.jl +++ b/src/widgets_long.jl @@ -90,8 +90,7 @@ function topoplot_widget(positions, channel_chosen; size = ()) marker_list.val[1] = 1 topo_widget = eeg_topoplot( - marker_list, - nothing; + marker_list; positions = positions, colorrange = colorrange, colormap = colormap, diff --git a/src/widgets_short.jl b/src/widgets_short.jl index eef69f8..7da5972 100644 --- a/src/widgets_short.jl +++ b/src/widgets_short.jl @@ -10,6 +10,25 @@ function SelectSet(items) ) end +function Bonito.jsrender(s::Bonito.Session, selector::SelectSet) + rows = map(selector.items[]) do value + is_selected = value in selector.value[] + checkbox = Bonito.Checkbox(is_selected; class = "p-1 m-1") + on(s, checkbox.value) do checked + values = copy(selector.value[]) + has_item = value in values + if checked + !has_item && push!(values, value) + else + has_item && filter!(x -> x != value, values) + end + selector.value[] = values + end + return Row(string(value), checkbox; align_items = "center") + end + return Bonito.jsrender(s, Card(Col(rows...))) +end + function value_range(args) type = args[end-1] default_values = args[end] @@ -33,7 +52,7 @@ function dropdown(name, content) end function widget(values::Set) - return SelectSet(collect(values)) + return SelectSet(sort!(collect(values))) end function widget(range::AbstractRange{<:Number}) @@ -45,6 +64,16 @@ function widget(range::AbstractRange{<:Number}) end widget_value(w::Vector{<:String}; resolution = 1) = w +function widget_value(x::Vector{Any}; resolution = 1) + if isempty(x) + return x + end + if all(v -> v isa AbstractString, x) + return String.(x) + end + return x[1] ≈ x[end] ? (x[1], x[end] - 1e-10) : + range(Float64(x[1]), Float64(x[end]), length = 5) +end widget_value(x::Vector; resolution = 1) = x[1] ≈ x[end] ? (x[1], x[end] - 1e-10) : range(Float64(x[1]), Float64(x[end]), length = 5) @@ -53,30 +82,3 @@ widget_value(x::Vector; resolution = 1) = function formular_text(content; class = "") return DOM.div(content; class = "px-1 text-lg m-1 font-semibold $(class)") end - -function variable_legend(name, values::AbstractRange{<:Number}, palette::Dict) - range, cmap = palette[:colormap] - return S.Colorbar(limits = range, colormap = cmap, label = string(name)) -end - -function variable_legend(name, values::Set, palette::Dict) - marker_color_lookup = (x) -> begin - if haskey(palette, :color) - return get(palette[:color], x, :black) - else - return :black - end - end - marker_lookup = (x) -> begin - if haskey(palette, :marker) - return palette[:marker][x] - else - return :rect - end - end - conditions = collect(values) - elements = map(conditions) do c - return MarkerElement(marker = marker_lookup(c), color = marker_color_lookup(c)) - end - return S.Legend(elements, conditions) -end diff --git a/test/.erp_env/Project.toml b/test/.erp_env/Project.toml new file mode 100644 index 0000000..e861684 --- /dev/null +++ b/test/.erp_env/Project.toml @@ -0,0 +1,12 @@ +[deps] +AlgebraOfGraphics = "cbdf2221-f076-402e-a563-3d30da359d67" +Bonito = "824d6782-a2ef-11e9-3a09-e5662e0c26f8" +DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" +ERPExplorer = "a0936657-5da6-42c1-a2f2-44cfa89f194c" +GeometryBasics = "5c1252a2-5f33-56bf-86c9-59e7332b4326" +Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +TopoPlots = "2bdbdf9c-dbd8-403f-947b-1a4e0dd41a7a" +Unfold = "181c99d8-e21b-4ff3-b70b-c233eddec679" +UnfoldSim = "ed8ae6d2-84d3-44c6-ab46-0baf21700804" +WGLMakie = "276b4fcb-3e11-5398-bf8b-a0c2d153d008" diff --git a/test/livebench_actions_all.txt b/test/livebench_actions_all.txt new file mode 100644 index 0000000..905c9e5 --- /dev/null +++ b/test/livebench_actions_all.txt @@ -0,0 +1,34 @@ +# All currently supported auto-live actions. +# Use these names in --bench-live-actions= + +toggle_luminance_on +toggle_luminance_off +toggle_fruit_on +toggle_fruit_off +toggle_animal_on +toggle_animal_off + +map_color_fruit +map_color_animal +color_none + +map_marker_fruit +map_marker_animal +marker_none + +map_linestyle_fruit +map_linestyle_animal +linestyle_none + +facet_col_fruit +facet_col_animal +facet_col_none + +facet_row_fruit +facet_row_animal +facet_row_none + +channel_1 +channel_2 + +reset_view diff --git a/test/livebench_actions_default.txt b/test/livebench_actions_default.txt new file mode 100644 index 0000000..4faec76 --- /dev/null +++ b/test/livebench_actions_default.txt @@ -0,0 +1,21 @@ +# One action per line. Lines starting with # are ignored. +# This is the default auto-live benchmark sequence. + +toggle_luminance_on +toggle_fruit_on +toggle_animal_on +map_color_fruit +map_marker_animal +map_linestyle_animal +facet_col_animal +facet_row_animal +channel_2 +channel_1 +color_none +marker_none +linestyle_none +facet_col_none +facet_row_none +toggle_luminance_off +toggle_fruit_off +toggle_animal_off diff --git a/test/livebench_actions_default_report_20260222_021913.csv b/test/livebench_actions_default_report_20260222_021913.csv new file mode 100644 index 0000000..1e91cbb --- /dev/null +++ b/test/livebench_actions_default_report_20260222_021913.csv @@ -0,0 +1,18 @@ +seq,source,action,effects_ms,layout_ms,total_ms +1,auto:toggle_luminance_on,toggle_luminance_on,1226.108777,4744.242718,6153.609109 +2,auto:toggle_fruit_on,toggle_fruit_on,510.734376,699.344393,1333.865725 +3,auto:toggle_animal_on,toggle_animal_on,611.61672,1.569058,625.09393 +5,auto:map_marker_animal,map_marker_animal,NaN,5447.064857,127.115258 +6,auto:map_linestyle_animal,map_linestyle_animal,NaN,2679.665987,2689.655804 +7,auto:facet_col_animal,facet_col_animal,NaN,2852.000941,2864.104615 +8,auto:facet_row_animal,facet_row_animal,NaN,1636.918855,1648.167459 +9,auto:channel_2,channel_2,5.082546,854.764466,902.413307 +10,auto:channel_1,channel_1,5.233339,809.998752,831.754559 +11,auto:color_none,color_none,NaN,718.875276,727.505947 +12,auto:marker_none,marker_none,NaN,659.901267,670.272542 +13,auto:linestyle_none,linestyle_none,NaN,610.906467,620.653684 +14,auto:facet_col_none,facet_col_none,NaN,138.844584,148.991932 +15,auto:facet_row_none,facet_row_none,NaN,49.020082,58.276762 +16,auto:toggle_luminance_off,toggle_luminance_off,441.78463,29.87398,485.002657 +17,auto:toggle_fruit_off,toggle_fruit_off,370.480985,1.518254,385.932618 +18,auto:toggle_animal_off,toggle_animal_off,0.918688,1.468798,13.743767 diff --git a/test/livebench_actions_default_report_20260222_023229.csv b/test/livebench_actions_default_report_20260222_023229.csv new file mode 100644 index 0000000..80decf2 --- /dev/null +++ b/test/livebench_actions_default_report_20260222_023229.csv @@ -0,0 +1,18 @@ +seq,source,action,effects_ms,layout_ms,total_ms +1,auto:toggle_luminance_on,toggle_luminance_on,516.079989,5152.273274,5861.273694 +2,auto:toggle_fruit_on,toggle_fruit_on,557.16153,720.565076,1407.264356 +3,auto:toggle_animal_on,toggle_animal_on,548.785327,1.734624,562.952096 +5,auto:map_marker_animal,map_marker_animal,NaN,5755.975335,700.559554 +6,auto:map_linestyle_animal,map_linestyle_animal,NaN,3578.369212,3586.839642 +7,auto:facet_col_animal,facet_col_animal,NaN,2954.919099,2964.844334 +8,auto:facet_row_animal,facet_row_animal,NaN,1645.943011,1654.952478 +9,auto:channel_2,channel_2,5.012698,886.566744,936.393036 +10,auto:channel_1,channel_1,5.139389,825.69271,845.69517 +11,auto:color_none,color_none,NaN,745.144576,753.136845 +12,auto:marker_none,marker_none,NaN,682.051942,690.762638 +13,auto:linestyle_none,linestyle_none,NaN,597.455738,608.405939 +14,auto:facet_col_none,facet_col_none,NaN,113.797353,122.774266 +15,auto:facet_row_none,facet_row_none,NaN,44.053658,53.331615 +16,auto:toggle_luminance_off,toggle_luminance_off,448.201313,29.991743,487.286369 +17,auto:toggle_fruit_off,toggle_fruit_off,373.040614,1.48651,386.697148 +18,auto:toggle_animal_off,toggle_animal_off,1.115058,1.723581,16.51331 diff --git a/test/livebench_actions_default_report_20260222_024745.csv b/test/livebench_actions_default_report_20260222_024745.csv new file mode 100644 index 0000000..850d6a8 --- /dev/null +++ b/test/livebench_actions_default_report_20260222_024745.csv @@ -0,0 +1,18 @@ +seq,source,action,effects_ms,layout_ms,total_ms +1,auto:toggle_luminance_on,toggle_luminance_on,527.911441,5942.991836,6666.943271 +2,auto:toggle_fruit_on,toggle_fruit_on,553.885342,729.257059,1415.898439 +3,auto:toggle_animal_on,toggle_animal_on,576.151685,1.698471,589.116808 +5,auto:map_marker_animal,map_marker_animal,NaN,5884.132787,722.996913 +6,auto:map_linestyle_animal,map_linestyle_animal,NaN,2850.996253,2858.358434 +7,auto:facet_col_animal,facet_col_animal,NaN,2957.964588,2966.899706 +8,auto:facet_row_animal,facet_row_animal,NaN,1681.359777,1692.259694 +9,auto:channel_2,channel_2,5.817038,910.750677,962.183217 +10,auto:channel_1,channel_1,4.841383,803.125426,826.587816 +11,auto:color_none,color_none,NaN,745.170097,751.583668 +12,auto:marker_none,marker_none,NaN,684.123717,695.381265 +13,auto:linestyle_none,linestyle_none,NaN,617.605195,626.051376 +14,auto:facet_col_none,facet_col_none,NaN,120.710409,132.192583 +15,auto:facet_row_none,facet_row_none,NaN,48.728282,52.529817 +16,auto:toggle_luminance_off,toggle_luminance_off,492.94403,43.176388,542.838552 +17,auto:toggle_fruit_off,toggle_fruit_off,387.786501,1.512822,401.724073 +18,auto:toggle_animal_off,toggle_animal_off,1.121492,2.546901,16.784502 diff --git a/test/livebench_actions_default_report_20260222_030302.csv b/test/livebench_actions_default_report_20260222_030302.csv new file mode 100644 index 0000000..d34a8e2 --- /dev/null +++ b/test/livebench_actions_default_report_20260222_030302.csv @@ -0,0 +1,18 @@ +seq,source,action,effects_ms,layout_ms,total_ms +1,auto:toggle_luminance_on,toggle_luminance_on,519.057968,5137.564487,5992.343553 +2,auto:toggle_fruit_on,toggle_fruit_on,555.312988,781.845846,1537.534418 +3,auto:toggle_animal_on,toggle_animal_on,554.819525,1.759512,563.179515 +5,auto:map_marker_animal,map_marker_animal,NaN,5621.005505,763.877295 +6,auto:map_linestyle_animal,map_linestyle_animal,NaN,2789.075069,2798.487938 +7,auto:facet_col_animal,facet_col_animal,NaN,3097.286797,3117.794897 +8,auto:facet_row_animal,facet_row_animal,NaN,1541.721602,1552.602152 +9,auto:channel_2,channel_2,5.397242,829.441591,884.682355 +10,auto:channel_1,channel_1,5.445722,746.853689,771.595217 +11,auto:color_none,color_none,NaN,644.867138,656.522863 +12,auto:marker_none,marker_none,NaN,253.675409,309.816233 +13,auto:linestyle_none,linestyle_none,NaN,4.273752,14.941362 +14,auto:facet_col_none,facet_col_none,NaN,113.916709,123.500211 +15,auto:facet_row_none,facet_row_none,NaN,43.224935,53.758396 +16,auto:toggle_luminance_off,toggle_luminance_off,449.888229,30.118914,497.472644 +17,auto:toggle_fruit_off,toggle_fruit_off,375.451729,1.585592,388.471091 +18,auto:toggle_animal_off,toggle_animal_off,0.921965,1.500547,16.858533 diff --git a/test/livebench_actions_default_report_20260222_034420.csv b/test/livebench_actions_default_report_20260222_034420.csv new file mode 100644 index 0000000..f81865c --- /dev/null +++ b/test/livebench_actions_default_report_20260222_034420.csv @@ -0,0 +1,18 @@ +seq,source,action,effects_ms,layout_ms,total_ms +1,auto:toggle_luminance_on,toggle_luminance_on,473.98681,4541.873214,5303.722197 +2,auto:toggle_fruit_on,toggle_fruit_on,583.472315,714.951958,1473.464594 +3,auto:toggle_animal_on,toggle_animal_on,508.541483,1.595346,516.285019 +5,auto:map_marker_animal,map_marker_animal,NaN,5642.206984,683.760961 +6,auto:map_linestyle_animal,map_linestyle_animal,NaN,2779.791506,2789.465605 +7,auto:facet_col_animal,facet_col_animal,NaN,2868.213053,2885.363738 +8,auto:facet_row_animal,facet_row_animal,NaN,1450.202062,1458.195907 +9,auto:channel_2,channel_2,5.084564,773.66412,820.880175 +10,auto:channel_1,channel_1,5.019374,702.116042,719.290988 +11,auto:color_none,color_none,NaN,621.589813,631.603662 +12,auto:marker_none,marker_none,NaN,225.372886,275.419525 +13,auto:linestyle_none,linestyle_none,NaN,3.641594,10.289461 +14,auto:facet_col_none,facet_col_none,NaN,105.19547,114.876595 +15,auto:facet_row_none,facet_row_none,NaN,43.513367,54.615787 +16,auto:toggle_luminance_off,toggle_luminance_off,414.181508,37.514029,469.726655 +17,auto:toggle_fruit_off,toggle_fruit_off,350.049659,1.455268,364.023783 +18,auto:toggle_animal_off,toggle_animal_off,0.800814,1.434915,13.465956 diff --git a/test/serve_widgets.jl b/test/serve_widgets.jl new file mode 100644 index 0000000..e8288e8 --- /dev/null +++ b/test/serve_widgets.jl @@ -0,0 +1,843 @@ +#!/usr/bin/env julia +import Pkg +using Printf +using Dates + +const ERP_PATH = "/home/svennaber/Documents/SSCS_vis/ERPExplorer.jl" +const HOST = "127.0.0.1" +const PORT = 8082 +const SFREQ = 100 + +function parse_cli(args) + opts = Dict{String,String}() + flags = Set{String}() + for arg in args + if startswith(arg, "--") + kv = split(arg[3:end], "="; limit = 2) + if length(kv) == 2 + opts[kv[1]] = kv[2] + else + push!(flags, kv[1]) + end + end + end + return opts, flags +end + +const CLI_OPTS, CLI_FLAGS = parse_cli(ARGS) +const BENCH_MODE = ("bench" in CLI_FLAGS) || (get(CLI_OPTS, "mode", "serve") == "bench") +const BENCH_LIVE_AUTO = + ("bench-live-auto" in CLI_FLAGS) || (get(CLI_OPTS, "mode", "serve") == "bench-live-auto") +const BENCH_LIVE = + ("bench-live" in CLI_FLAGS) || (get(CLI_OPTS, "mode", "serve") == "bench-live") || BENCH_LIVE_AUTO +const BENCH_REPEATS = parse(Int, get(CLI_OPTS, "bench-repeats", "5")) +const BENCH_WARMUP = parse(Int, get(CLI_OPTS, "bench-warmup", "1")) +const BENCH_CHANNEL = parse(Int, get(CLI_OPTS, "bench-channel", "1")) +const BENCH_OUT = get(CLI_OPTS, "bench-out", "") +const BENCH_LIVE_START_DELAY = parse(Float64, get(CLI_OPTS, "bench-live-start-delay", "20")) +const BENCH_LIVE_DELAY = max(5.0, parse(Float64, get(CLI_OPTS, "bench-live-delay", "5"))) +const DEFAULT_LIVE_ACTIONS_FILE = joinpath(@__DIR__, "livebench_actions_default.txt") +const ALL_LIVE_ACTIONS_FILE = joinpath(@__DIR__, "livebench_actions_all.txt") +const BENCH_LIVE_ACTIONS_FILE = get(CLI_OPTS, "bench-live-actions", DEFAULT_LIVE_ACTIONS_FILE) +const BENCH_LIVE_REPORT = get(CLI_OPTS, "bench-live-report", "") + +# Use a persistent environment next to this script +const ENV_DIR = joinpath(@__DIR__, ".erp_env") +Pkg.activate(ENV_DIR) +Pkg.develop(url="https://github.com/MakieOrg/AlgebraOfGraphics.jl") +Pkg.develop(path=ERP_PATH) + +Pkg.add([ + "Unfold", + "UnfoldSim", + "DataFrames", + "Random", + "GeometryBasics", + "TopoPlots", + "Bonito", + "WGLMakie", + "Makie", +]) +Pkg.instantiate() + +using Random, DataFrames +using GeometryBasics +using Unfold, UnfoldSim +using Makie, WGLMakie +using Bonito +using TopoPlots +using ERPExplorer +using Statistics + +# --- your generator (unchanged) --- +function gen_data(n_channels = 64) + d1, evts = UnfoldSim.predef_eeg(n_repeats = 120, noiselevel = 25; return_epoched = true) + n_timepoints = size(d1, 1) + + dataS = [ + d1 .+ + 3 * sin.(0.1 * pi * i .+ rand() * 2π) .+ + 2 * sin.(0.3 * pi * i .* (1:n_timepoints)) .+ + randn(size(d1)) .* 5 .+ + circshift(d1, rand(-10:10)) .* 0.2 + for i = 1:n_channels + ] + dataS = permutedims(cat(dataS..., dims = 3), (3, 1, 2)) + dataS = dataS .+ rand(dataS) + + evts = insertcols( + evts, + :saccade_amplitude => rand(nrow(evts)) .* 15, + :luminance => rand(nrow(evts)) .* 100, + :contrast => rand(nrow(evts)), + :string => shuffle( + repeat( + ["stringsuperlong", "stringshort", "stringUPPERCASE", "stringEXCITED!!!!"], + outer = div(nrow(evts), 4), + ), + ), + :animal => shuffle(repeat(["cat", "dog"], outer = div(nrow(evts), 2))), + :fruit => shuffle(repeat(["orange", "banana"], outer = div(nrow(evts), 2))), + :color => shuffle(repeat(["black", "white"], outer = div(nrow(evts), 2))), + ) + + positions = rand(Point2f, size(dataS, 1)) + return dataS, evts, positions +end + +# --- simulate + fit --- +dataS, evts, _positions = gen_data() +formulaS = @formula(0 ~ 1 + luminance + fruit + animal) + +# Times length must match the 2nd last dimension of dataS +times = range(0, length = size(dataS, 2), step = 1 / SFREQ) + +model = Unfold.fit(UnfoldModel, formulaS, evts, dataS, times) +_, topo_example_positions = TopoPlots.example_data() +half_positions = topo_example_positions[1:2:end] +positions_sets = Dict( + "TopoPlots example" => topo_example_positions, + "TopoPlots example (half)" => half_positions, +) + +# --- serve it (serve the explorer_app directly; don't wrap it) --- +function start_server(app; host = HOST, port = PORT) + if isdefined(Bonito, :Server) + server = Bonito.Server(app, host, port) + println("Open: http://$(host):$(port)") + return server + elseif isdefined(Bonito, :serve) + url = Bonito.serve(app; host = host, port = port) + println("Open: ", url) + return nothing + else + error("No Bonito.Server or Bonito.serve found in this Bonito version.") + end +end + +function write_bench_csv(path, rows) + open(path, "w") do io + println(io, "step,ok,successful_runs,repeats,effects_ms,update_grid_ms,total_ms,error") + for r in rows + err = replace(r.error, '\n' => ' ') + err = replace(err, '"' => '\'') + println( + io, + string( + r.step, ",", + r.ok, ",", + r.successful_runs, ",", + r.repeats, ",", + r.effects_ms, ",", + r.grid_ms, ",", + r.total_ms, ",\"", + err, "\"", + ), + ) + end + end +end + +function default_livebench_report_path(actions_path::AbstractString) + timestamp = Dates.format(Dates.now(), "yyyymmdd_HHMMSS") + actions_abs = abspath(actions_path) + actions_dir = dirname(actions_abs) + actions_stem = splitext(basename(actions_abs))[1] + return joinpath(actions_dir, "$(actions_stem)_report_$(timestamp).csv") +end + +function read_livebench_action_names(path::AbstractString) + if !isfile(path) + error("Live benchmark action file not found: $(path)") + end + names = String[] + for (line_no, raw_line) in enumerate(eachline(path)) + line = strip(split(raw_line, '#'; limit = 2)[1]) + isempty(line) && continue + push!(names, line) + end + isempty(names) && error("No actions found in live benchmark action file: $(path)") + return names +end + +function write_livebench_csv(path::AbstractString, rows) + open(path, "w") do io + println(io, "seq,source,action,effects_ms,layout_ms,total_ms") + for r in rows + println( + io, + string( + r.seq, ",", + r.source, ",", + r.action, ",", + r.effects_ms, ",", + r.layout_ms, ",", + r.total_ms, + ), + ) + end + end +end + +function run_action_bench(model; repeats = 5, warmup = 1, channel = 1, out_csv = "") + variables = ERPExplorer.extract_variables(model) + formula_values = [k => ERPExplorer.value_range(v) for (k, v) in variables] + var_types = map(x -> x[2][3], variables) + var_names = first.(variables) + cat_terms = var_names[var_types .== :CategoricalTerm] + cont_terms = var_names[var_types .== :ContinuousTerm] + + selected = Dict{Symbol,Any}() + active = Dict{Symbol,Bool}() + for (k, info) in variables + typ = info[3] + vals = info[4] + active[k] = false + if typ == :CategoricalTerm + selected[k] = collect(vals) + elseif typ == :ContinuousTerm || typ == :BSplineTerm + selected[k] = [Float64(vals.min), Float64(vals.max)] + end + end + + mapping = Dict( + :color => :none, + :marker => :none, + :linestyle => :none, + :col => :none, + :row => :none, + ) + + function make_erp_data() + yhat_dict = Dict{Symbol,Any}() + for (k, on) in active + if on + yhat_dict[k] = ERPExplorer.widget_value(selected[k]) + end + end + if isempty(yhat_dict) + yhat_dict = Dict(:dummy => ["dummy"]) + end + + yhats = effects(yhat_dict, model) + for (k, on) in active + if !on + yhats[!, k] .= "typical_value" + end + end + filter!(x -> x.channel == channel, yhats) + return yhats + end + + function run_once() + t0 = time_ns() + t1 = time_ns() + yhats = make_erp_data() + effects_ms = (time_ns() - t1) / 1e6 + t2 = time_ns() + ERPExplorer.update_grid(yhats, formula_values, cat_terms, cont_terms, mapping) + grid_ms = (time_ns() - t2) / 1e6 + total_ms = (time_ns() - t0) / 1e6 + return effects_ms, grid_ms, total_ms + end + + actions = [ + ("baseline", () -> nothing), + ("toggle_luminance_on", () -> (active[:luminance] = true)), + ("toggle_luminance_off", () -> (active[:luminance] = false)), + ("toggle_fruit_on", () -> (active[:fruit] = true)), + ("toggle_animal_on", () -> (active[:animal] = true)), + ("map_color_fruit", () -> (mapping[:color] = :fruit)), + ("map_marker_animal", () -> (mapping[:marker] = :animal)), + ("map_linestyle_animal", () -> (mapping[:linestyle] = :animal)), + ("facet_col_animal", () -> (mapping[:col] = :animal)), + ("facet_row_animal", () -> (mapping[:row] = :animal)), + ("color_none", () -> (mapping[:color] = :none)), + ("marker_none", () -> (mapping[:marker] = :none)), + ("linestyle_none", () -> (mapping[:linestyle] = :none)), + ("facet_col_none", () -> (mapping[:col] = :none)), + ("facet_row_none", () -> (mapping[:row] = :none)), + ("luminance_full_range", () -> begin + active[:luminance] = true + selected[:luminance] = [0.0, 100.0] + end), + ] + + for _ = 1:warmup + run_once() + end + + rows = NamedTuple[] + println("Running GUI-action benchmark") + println("repeats=$(repeats), warmup=$(warmup), channel=$(channel)") + println(rpad("step", 28), rpad("ok", 6), rpad("n", 6), rpad("effects", 12), rpad("grid", 12), "total") + println(repeat("-", 74)) + for (step_name, apply_step!) in actions + apply_step!() + ok = true + err = "" + effects_samples = Float64[] + grid_samples = Float64[] + total_samples = Float64[] + successful_runs = 0 + for _ = 1:repeats + try + effects_ms, grid_ms, total_ms = run_once() + push!(effects_samples, effects_ms) + push!(grid_samples, grid_ms) + push!(total_samples, total_ms) + successful_runs += 1 + catch e + ok = false + err = sprint(showerror, e, catch_backtrace()) + break + end + end + if ok + eff = median(effects_samples) + grd = median(grid_samples) + tot = median(total_samples) + push!( + rows, + ( + step = step_name, + ok = true, + successful_runs = successful_runs, + repeats = repeats, + effects_ms = eff, + grid_ms = grd, + total_ms = tot, + error = "", + ), + ) + @printf("%-28s %-6s %-6d %9.2fms %9.2fms %9.2fms\n", step_name, "true", successful_runs, eff, grd, tot) + else + push!( + rows, + ( + step = step_name, + ok = false, + successful_runs = successful_runs, + repeats = repeats, + effects_ms = NaN, + grid_ms = NaN, + total_ms = NaN, + error = err, + ), + ) + @printf("%-28s %-6s %-6d %9s %9s %9s\n", step_name, "false", successful_runs, "ERR", "ERR", "ERR") + println(" error: ", split(err, '\n')[1]) + end + end + + if !isempty(out_csv) + mkpath(dirname(out_csv)) + write_bench_csv(out_csv, rows) + println("Wrote benchmark CSV: ", out_csv) + end +end + +function mapping_dropdowns_with_handles(var_names, var_types) + cats = [v for (ix, v) in enumerate(var_names) if var_types[ix] == :CategoricalTerm] + push!(cats, :none) + + c_dropdown = Dropdown(cats; index = length(cats)) + m_dropdown = Dropdown(cats; index = length(cats)) + l_dropdown = Dropdown(cats; index = length(cats)) + col_dropdown = Dropdown(cats; index = length(cats)) + row_dropdown = Dropdown(cats; index = length(cats)) + + mapping = @lift Dict( + :color => $(c_dropdown.value), + :marker => $(m_dropdown.value), + :linestyle => $(l_dropdown.value), + :col => $(col_dropdown.value), + :row => $(row_dropdown.value), + ) + mapping_dom = Col( + Row(DOM.div("color:"), c_dropdown, align_items = "center", justify_items = "end"), + Row(DOM.div("marker:"), m_dropdown, align_items = "center", justify_items = "end"), + Row(DOM.div("linestyle:"), l_dropdown, align_items = "center", justify_items = "end"), + Row(DOM.div("column facet"), col_dropdown, align_items = "center", justify_items = "end"), + Row(DOM.div("row facet"), row_dropdown, align_items = "center", justify_items = "end"), + ) + controls = Dict( + :color => c_dropdown, + :marker => m_dropdown, + :linestyle => l_dropdown, + :col => col_dropdown, + :row => row_dropdown, + ) + return mapping, mapping_dom, controls +end + +function build_live_bench_app(model; positions = nothing, size = (700, 600), fit_window = true) + Bonito.set_cleanup_time!(1) + return App() do + variables = ERPExplorer.extract_variables(model) + formula_defaults, formula_toggle, formula_DOM, formula_values = + ERPExplorer.formular_widgets(variables) + reset_button = Bonito.Button( + "Reset view"; + style = Styles( + "padding" => "4px 6px", + "min-height" => "24px", + ), + ) + + var_types = map(x -> x[2][3], variables) + var_names = first.(variables) + cat_terms = var_names[var_types .== :CategoricalTerm] + cont_terms = var_names[var_types .== :ContinuousTerm] + + mapping, mapping_dom, mapping_controls = mapping_dropdowns_with_handles(var_names, var_types) + + channel_chosen = Observable(1) + topo_widget = nothing + topo_size = size .* 0.5 + if positions isa AbstractDict || positions isa NamedTuple + pos_sets = Dict{String,Any}() + for (k, v) in pairs(positions) + pos_sets[string(k)] = v + end + pos_keys = collect(keys(pos_sets)) + topo_select = Dropdown(pos_keys; index = 1) + topo_widget_obs = + Observable{Any}(ERPExplorer.topoplot_widget(pos_sets[pos_keys[1]], channel_chosen; size = topo_size)) + on(topo_select.value) do key + channel_chosen[] = 1 + topo_widget_obs[] = + ERPExplorer.topoplot_widget(pos_sets[key], channel_chosen; size = topo_size) + end + topo_widget = Col( + Row(DOM.div("Topoplot:"), topo_select, align_items = "center"), + topo_widget_obs, + ) + elseif isnothing(positions) + topo_widget = nothing + else + topo_widget = ERPExplorer.topoplot_widget(positions, channel_chosen; size = topo_size) + end + + ERP_data = Observable{Any}(nothing; ignore_equal_values = true) + last_effects_ms = Ref{Union{Nothing,Float64}}(nothing) + onany(formula_toggle, channel_chosen; update = true) do formula_toggle_on, chan + t0 = time_ns() + yhat_dict = Dict{Symbol,Any}() + for (k, v) in formula_toggle_on + if !isempty(v) && v[1] + yhat_dict[k] = ERPExplorer.widget_value(v[2]) + end + end + if isempty(yhat_dict) + yhat_dict = Dict(:dummy => ["dummy"]) + end + yhats = effects(yhat_dict, model) + for (k, wv) in formula_toggle_on + if isempty(wv[2]) || !wv[1] + yhats[!, k] .= "typical_value" + end + end + filter!(x -> x.channel == chan, yhats) + last_effects_ms[] = (time_ns() - t0) / 1e6 + ERP_data[] = yhats + end + + pending_seq = Ref(0) + pending_source = Ref("init") + pending_start_ns = Ref(Int64(0)) + pending_effects_ms = Ref{Union{Nothing,Float64}}(nothing) + auto_action_inflight = Ref(false) + function mark_action!(source::AbstractString) + if auto_action_inflight[] && + startswith(pending_source[], "auto:") && + !startswith(source, "auto:") + return + end + pending_seq[] += 1 + pending_source[] = source + pending_start_ns[] = time_ns() + pending_effects_ms[] = nothing + end + + function set_checkbox!(name::Symbol, enabled::Bool) + if haskey(formula_defaults, name) + formula_defaults[name][] = enabled + end + end + function set_dropdown!(target::Symbol, value::Symbol) + if haskey(mapping_controls, target) + mapping_controls[target].value[] = value + end + end + function click_reset!() + reset_button.value[] = !to_value(reset_button.value) + end + + auto_started = Ref(false) + available_auto_actions = Dict( + "toggle_luminance_on" => () -> set_checkbox!(:luminance, true), + "toggle_luminance_off" => () -> set_checkbox!(:luminance, false), + "toggle_fruit_on" => () -> set_checkbox!(:fruit, true), + "toggle_fruit_off" => () -> set_checkbox!(:fruit, false), + "toggle_animal_on" => () -> set_checkbox!(:animal, true), + "toggle_animal_off" => () -> set_checkbox!(:animal, false), + "map_color_fruit" => () -> set_dropdown!(:color, :fruit), + "map_color_animal" => () -> set_dropdown!(:color, :animal), + "color_none" => () -> set_dropdown!(:color, :none), + "map_marker_fruit" => () -> set_dropdown!(:marker, :fruit), + "map_marker_animal" => () -> set_dropdown!(:marker, :animal), + "marker_none" => () -> set_dropdown!(:marker, :none), + "map_linestyle_fruit" => () -> set_dropdown!(:linestyle, :fruit), + "map_linestyle_animal" => () -> set_dropdown!(:linestyle, :animal), + "linestyle_none" => () -> set_dropdown!(:linestyle, :none), + "facet_col_fruit" => () -> set_dropdown!(:col, :fruit), + "facet_col_animal" => () -> set_dropdown!(:col, :animal), + "facet_col_none" => () -> set_dropdown!(:col, :none), + "facet_row_fruit" => () -> set_dropdown!(:row, :fruit), + "facet_row_animal" => () -> set_dropdown!(:row, :animal), + "facet_row_none" => () -> set_dropdown!(:row, :none), + "channel_2" => () -> (channel_chosen[] = 2), + "channel_1" => () -> (channel_chosen[] = 1), + "reset_view" => click_reset!, + ) + auto_action_names = BENCH_LIVE_AUTO ? read_livebench_action_names(BENCH_LIVE_ACTIONS_FILE) : String[] + invalid_auto_actions = [n for n in auto_action_names if !haskey(available_auto_actions, n)] + if !isempty(invalid_auto_actions) + error( + "Unknown auto actions in $(BENCH_LIVE_ACTIONS_FILE): $(join(invalid_auto_actions, ", ")). " * + "See $(ALL_LIVE_ACTIONS_FILE) for supported names.", + ) + end + auto_actions = [ + (name, available_auto_actions[name]) for name in auto_action_names + ] + auto_rows = NamedTuple[] + auto_completed = Ref(0) + auto_report_written = Ref(false) + auto_report_path = + isempty(BENCH_LIVE_REPORT) ? default_livebench_report_path(BENCH_LIVE_ACTIONS_FILE) : + BENCH_LIVE_REPORT + function maybe_start_auto!() + if !BENCH_LIVE_AUTO || auto_started[] + return + end + auto_started[] = true + @async begin + sleep(BENCH_LIVE_START_DELAY) + println("auto-livebench: starting ", length(auto_actions), " actions") + println("auto-livebench: action file = ", BENCH_LIVE_ACTIONS_FILE) + println("auto-livebench: report file = ", auto_report_path) + for (name, act!) in auto_actions + println("auto-livebench action: ", name) + auto_action_inflight[] = true + mark_action!("auto:" * name) + try + act!() + catch err + auto_action_inflight[] = false + println("auto-livebench action failed: ", name, " :: ", sprint(showerror, err)) + end + sleep(BENCH_LIVE_DELAY) + end + timeout_seconds = max(10.0, BENCH_LIVE_DELAY * (length(auto_actions) + 2)) + deadline = time() + timeout_seconds + while auto_completed[] < length(auto_actions) && time() < deadline + sleep(0.1) + end + if !auto_report_written[] + mkpath(dirname(auto_report_path)) + write_livebench_csv(auto_report_path, auto_rows) + auto_report_written[] = true + println( + "auto-livebench: done (partial) ", + auto_completed[], + "/", + length(auto_actions), + " actions rendered", + ) + println("auto-livebench report: ", auto_report_path) + else + println("auto-livebench: done") + end + end + end + + formula_seen = Ref(false) + syncing_formula_defaults = Ref(false) + on(formula_toggle) do _ + if !formula_seen[] + formula_seen[] = true + return + end + if syncing_formula_defaults[] + return + end + mark_action!("formula") + end + + channel_seen = Ref(false) + on(channel_chosen) do _ + if !channel_seen[] + channel_seen[] = true + return + end + mark_action!("topoplot") + end + + mapping_seen = Ref(false) + on(mapping) do m + if mapping_seen[] + mark_action!("mapping") + else + mapping_seen[] = true + end + syncing_formula_defaults[] = true + try + selected_terms = Set(v for v in values(m) if v != :none) + for (term, toggle_obs) in formula_defaults + if term in selected_terms && !toggle_obs[] + toggle_obs[] = true + end + end + finally + syncing_formula_defaults[] = false + end + end + + on(ERP_data) do yhats + if pending_start_ns[] > 0 && yhats !== nothing + pending_effects_ms[] = last_effects_ms[] + end + maybe_start_auto!() + end + + plot_layout = Observable(ERPExplorer.S.GridLayout()) + lk = Base.ReentrantLock() + auto_reset_view = true + fig_ref = Ref{Union{Nothing,Makie.FigureAxisPlot}}(nothing) + + function reset_all_axes!() + fig_obj = fig_ref[] + isnothing(fig_obj) && return + lock(lk) do + function collect_axes!(acc, item) + if item isa Makie.Axis + push!(acc, item) + elseif item isa Makie.GridLayoutBase.GridLayout + for child in Makie.GridLayoutBase.contents(item) + collect_axes!(acc, child) + end + end + end + + axes = Makie.Axis[] + collect_axes!(axes, fig_obj.figure.layout) + for ax in axes + Makie.reset_limits!(ax) + Makie.autolimits!(ax) + end + end + end + + render_count = Ref(0) + Makie.onany_latest(ERP_data, mapping; update = true) do ERP_data_val, mapping_val + lock(lk) do + t0 = time_ns() + _tmp = ERPExplorer.update_grid( + ERP_data_val, + formula_values, + cat_terms, + cont_terms, + mapping_val, + ) + try + plot_layout[] = _tmp + catch err + err_msg = sprint(showerror, err) + if occursin("Screen Session uninitialized", err_msg) || + occursin("Session status: SOFT_CLOSED", err_msg) + return + end + rethrow(err) + end + render_count[] += 1 + layout_ms = (time_ns() - t0) / 1e6 + println( + "render #", + render_count[], + " update_grid -> layout in ", + round(layout_ms; digits = 2), + " ms", + ) + maybe_start_auto!() + if auto_reset_view + reset_all_axes!() + end + if pending_start_ns[] > 0 + total_ms = (time_ns() - pending_start_ns[]) / 1e6 + effects_value = isnothing(pending_effects_ms[]) ? NaN : pending_effects_ms[] + effects_text = isnan(effects_value) ? "n/a" : @sprintf("%.2f", effects_value) + println( + "livebench #", + pending_seq[], + " source=", + pending_source[], + " effects_ms=", + effects_text, + " layout_ms=", + round(layout_ms; digits = 2), + " total_ms=", + round(total_ms; digits = 2), + ) + if startswith(pending_source[], "auto:") + action_name = pending_source[][6:end] + push!( + auto_rows, + ( + seq = pending_seq[], + source = pending_source[], + action = action_name, + effects_ms = effects_value, + layout_ms = layout_ms, + total_ms = total_ms, + ), + ) + auto_completed[] += 1 + auto_action_inflight[] = false + if BENCH_LIVE_AUTO && + auto_completed[] >= length(auto_actions) && + !auto_report_written[] + mkpath(dirname(auto_report_path)) + write_livebench_csv(auto_report_path, auto_rows) + auto_report_written[] = true + total_vals = [r.total_ms for r in auto_rows] + median_total = isempty(total_vals) ? NaN : median(total_vals) + println( + "auto-livebench summary: actions=", + length(auto_rows), + " median_total_ms=", + round(median_total; digits = 2), + ) + println("auto-livebench report: ", auto_report_path) + end + end + pending_start_ns[] = 0 + end + end + return + end + + css = Asset(joinpath(@__DIR__, "..", "style.css")) + fig = plot(plot_layout; figure = (size = size,)) + fig_view = fit_window ? WGLMakie.WithConfig(fig; resize_to = :parent) : fig + fig_ref[] = fig + + on(reset_button.value) do _ + reset_all_axes!() + end + + header_dom = Grid( + formula_DOM, + reset_button; + rows = "1fr", + columns = "1fr auto", + gap = "8px", + align_items = "center", + ) + cards = Grid( + Card(header_dom, style = Styles("grid-area" => "header")), + Card(mapping_dom, style = Styles("grid-area" => "sidebar")), + Card(topo_widget, style = Styles("grid-area" => "topo")), + Card( + fig_view, + style = Styles( + "grid-area" => "content", + "min-width" => "0", + "min-height" => "0", + "overflow" => "hidden", + ), + ); + columns = "5fr 1fr", + rows = "1fr 6fr 4fr", + areas = """ + 'header header' + 'content sidebar' + 'content topo' + """, + ) + container_style = + fit_window ? + Styles( + "height" => "calc(100vh - 24px)", + "width" => "calc(100vw - 24px)", + "margin" => "12px", + "position" => :relative, + ) : + Styles( + "height" => "$(1.2*size[2])px", + "width" => "$(size[1])px", + "margin" => "20px", + "position" => :relative, + ) + return DOM.div( + css, + Bonito.TailwindCSS, + cards; + style = container_style, + ) + end +end + +if BENCH_MODE + run_action_bench( + model; + repeats = BENCH_REPEATS, + warmup = BENCH_WARMUP, + channel = BENCH_CHANNEL, + out_csv = BENCH_OUT, + ) +else + # --- build the full explorer (ERPExplorer already returns a Bonito.App) --- + WGLMakie.activate!() + explorer_app = + BENCH_LIVE ? + build_live_bench_app(model; positions = positions_sets) : + ERPExplorer.explore(model; positions = positions_sets) + if BENCH_LIVE + println("Live bench enabled. Interact with the UI; each update prints `livebench #... total_ms=...`") + end + if BENCH_LIVE_AUTO + println( + "Auto live bench enabled. Starts after $(BENCH_LIVE_START_DELAY)s; action delay $(BENCH_LIVE_DELAY)s (minimum 5s).", + ) + println("Auto actions file: ", BENCH_LIVE_ACTIONS_FILE) + println("All supported actions listed in: ", ALL_LIVE_ACTIONS_FILE) + if !isempty(BENCH_LIVE_REPORT) + println("Requested auto report path: ", BENCH_LIVE_REPORT) + end + end + server = start_server(explorer_app) + println("Press Ctrl+C to stop.") + wait(Condition()) +end