Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -23,19 +24,20 @@ 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"
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"
107 changes: 107 additions & 0 deletions docs/new_vs_old.md
Original file line number Diff line number Diff line change
@@ -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=<Int>` default: `5`
- `--bench-warmup=<Int>` default: `1`
- `--bench-channel=<Int>` default: `1`
- `--bench-out=<Path>` default: `""` (no bench CSV written unless set)
- `--bench-live-start-delay=<Float64>` default: `20` (seconds)
- `--bench-live-delay=<Float64>` 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=<Path>` default:
- `test/livebench_actions_default.txt`
- `--bench-live-report=<Path>` default: `""`
- when empty, report path is auto-generated in the same folder as action file:
- `<actions_stem>_report_<yyyymmdd_HHMMSS>.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).

2 changes: 1 addition & 1 deletion src/ERPExplorer.jl
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ using Unfold
import Makie.SpecApi as S

using BSplineKit
using AlgebraOfGraphics
using Unfold
using WGLMakie
using Bonito
Expand All @@ -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")

Expand Down
148 changes: 128 additions & 20 deletions src/explore.jl
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand All @@ -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

Expand All @@ -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 = """
Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions src/functions_formular.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading