diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1eab128961b26274a16980a06e0958b0fbb70ac4..2d549ff8530cc217b74cf891886ff2552d117a38 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -119,3 +119,4 @@ deploy to dev.pasteur.cloud: - if: ($CI_COMMIT_BRANCH == "dev" && $CI_PROJECT_ID == $GITLAB_PASTEUR_PROJECT_ID) # gitlab.pasteur.fr only when: manual + diff --git a/README.md b/README.md index 0b5cf10fc87d22e3e55571c56418cf4b6e52267c..bfddaa8a59120b2c8c1835ac81a578b8cd9506fc 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,25 @@ Web interface meant to be served at [nyx.pasteur.cloud](https://nyx.pasteur.clou It features an app catalog, with currently a single available app: * an editor for muscular activity programs. + + +## Local installation + +For local execution, the installation instructions are as follows: + +You will need [JuliaUp](https://github.com/JuliaLang/juliaup?tab=readme-ov-file#juliaup---julia-version-manager) and [git](https://git-scm.com/downloads). + +Once these tools are installed, in a terminal (on Windows, preferably PowerShell), type: +``` +git clone --branch dev https://gitlab.pasteur.fr/nyx/NyxUI.jl NyxUI +cd NyxUI +cp front/Manifest.toml . +juliaup add lts +juliaup default lts +julia --project=. -e 'using Pkg; Pkg.instantiate()' +julia --project=. routes.jl +``` + +You may be asked whether to authorize ports 9284 and 9285; please give the app permission. + +From there, in a web browser, open http://localhost:9284/ to access the app. diff --git a/src/MuscleActivities.jl b/src/MuscleActivities.jl index 86b74a322943c47270c03d17afa896e522d4ce12..57911ca511edf4c89124e4a2d6fbc4b034c51358 100644 --- a/src/MuscleActivities.jl +++ b/src/MuscleActivities.jl @@ -3,6 +3,7 @@ module MuscleActivities using StructTypes using NyxWidgets.Muscles using Random +using Dates using JSON3 using StructTypes using OrderedCollections: OrderedDict @@ -217,8 +218,18 @@ from_json_file(::Type{T}, filepath) where {T} = JSON3.read(read(filepath, String StructTypes.StructType(::Type{MuscleActivity}) = StructTypes.CustomStruct() function StructTypes.lower(seq::MuscleActivity) + version = string(pkgversion(@__MODULE__)) + if endswith(version, ".0") + version = version[1:end-2] + end + datetime = Dates.format(Dates.now(), "yyyymmdd_HHMMSS") Dict = OrderedDict Dict("name" => seq.program_name, + "metadata" => + Dict("software" => + Dict("name" => "NyxUI", + "version" => version), + "date_time" => datetime), "time" => Dict("start" => seq.times[1], "step" => step(seq.times), diff --git a/src/apps/muscles/Backbone.jl b/src/apps/muscles/Backbone.jl index 21868e65dd545970b26baa197231faf9a1808589..eb4758a59f0e5864392c222e985a7d631015d9b1 100644 --- a/src/apps/muscles/Backbone.jl +++ b/src/apps/muscles/Backbone.jl @@ -9,7 +9,7 @@ include("MuscleWidgets.jl") using .MuscleWidgets export hasmodel, getmodel, setmodel, newsequence, getsequence, withsequences, - exportsequence, loadsequence + exportsequence, loadsequence, deletesequence # mutable struct Backbone{W} # widget::Union{Nothing, W} @@ -150,4 +150,20 @@ function loadsequence(session_id, filepath) return sequence end +function deletesequence(session_id) + model = getmodel(session_id) + current = model.sequence[].program_name + sequences = __sequences__[session_id] + lock(sequences) do + for (ix, seq) in pairs(sequences) + if seq.program_name == current + current = ix + break + end + end + @assert current isa Int + pop!(sequences.cache, current) + end +end + end diff --git a/src/apps/muscles/MuscleWidgets.jl b/src/apps/muscles/MuscleWidgets.jl index 21a81d04e8e0d699a4f4ddb14dd5d40e5b10abcf..e5577ed86345d7c58f1d200b59fb2ccd05e3ea1c 100644 --- a/src/apps/muscles/MuscleWidgets.jl +++ b/src/apps/muscles/MuscleWidgets.jl @@ -1,6 +1,5 @@ module MuscleWidgets -using Base: has_nondefault_cmd_flags using NyxWidgets.Players, NyxWidgets.Muscles, NyxWidgets.AnimatedLayers import NyxWidgets.Base: lowerdom, dom_id, dom_id!, Div using NyxPlots diff --git a/src/apps/muscles/app.jl b/src/apps/muscles/app.jl index b24ccc970093d408c5bd7e24ecb17515264c34e1..082584919dcbe8e831b50d195d406f2e2ff249b1 100644 --- a/src/apps/muscles/app.jl +++ b/src/apps/muscles/app.jl @@ -34,9 +34,9 @@ const bonito_app = NamedApp(:inherit, Backbone.app) @out export_sequence_link = "" @in export_sequence_click = false - #@in delete_sequence_click = false @in colormap = default_colormap + @out first_time_in_editmode = true # and in the heatmap view @in editmode = false @out editmode_disable = true @in setvalue = 1.0 @@ -44,9 +44,14 @@ const bonito_app = NamedApp(:inherit, Backbone.app) @in time_interval = 0.05 @in start_time = 0.0 + @out heatmap_view = false + @out area_selected = false @out new_clipboard_item = false + @in delete_sequence_click = false + @out no_sequences_yet = true + @onchange isready begin _, url = init_model(__model__) run(__model__, """ @@ -71,6 +76,7 @@ const bonito_app = NamedApp(:inherit, Backbone.app) series_length = length(seq.times) time_interval = step(seq.times) start_time = first(seq.times) + no_sequences_yet = isempty(sequence_names) end on(MuscleWidgets.clipboard(model)) do _ new_clipboard_item[!] = false @@ -80,6 +86,12 @@ const bonito_app = NamedApp(:inherit, Backbone.app) area_selected[!] = false area_selected = true end + on(model.animation.switchlayer) do layers + if !isnothing(layers) + current = layers[1] + heatmap_view = 1 < current + end + end end @onchange start_time, time_interval, series_length begin @@ -96,24 +108,29 @@ const bonito_app = NamedApp(:inherit, Backbone.app) end valid = false end + previous_length = length(seq.times) + new_length = round(Int, series_length) if series_length < 1 @notify "Time series length must be strictly positive" :error - series_length = length(seq.times) + series_length = previous_length valid = false - elseif round(series_length) != series_length + elseif series_length != new_length @notify "Time series length must be an integer" :error - series_length = round(series_length) + series_length = new_length valid = false - elseif !isnothing(maxlength) && maxlength < series_length + elseif !isnothing(maxlength) && maxlength < new_length @notify "Time series is too long" :error - series_length = length(seq.times) + series_length = previous_length + valid = false + elseif new_length < previous_length && + startswith(string(previous_length), string(new_length)) + @debug "Ignoring new size at the moment" valid = false end # if valid && !(start_time == seq.times[1] && time_interval == step(seq.times) && - series_length == length(seq.times)) - n = round(Int, series_length) - stop_time = start_time + (n-1) * time_interval + new_length == previous_length) + stop_time = start_time + (new_length - 1) * time_interval ts = start_time:time_interval:stop_time @info "Resampling" start_time time_interval series_length MuscleActivities.resample!(seq, ts) @@ -140,6 +157,8 @@ const bonito_app = NamedApp(:inherit, Backbone.app) notify(start_time) notify(time_interval) notify(series_length) + # enable the download and delete buttons + no_sequences_yet = false # toggle edit mode on editmode = true end @@ -149,6 +168,7 @@ const bonito_app = NamedApp(:inherit, Backbone.app) end @onchange selected_sequence_name begin + isempty(selected_sequence_name) && return seq = getsequence(back_session_id, selected_sequence_name) if !isnothing(seq) ix = findfirst(==(selected_sequence_name), sequence_names) @@ -204,6 +224,20 @@ const bonito_app = NamedApp(:inherit, Backbone.app) model = getmodel(back_session_id) model.editmode[] = editmode editmode_disable = !editmode + if editmode && first_time_in_editmode && heatmap_view + first_time_in_editmode = false + end + end + + @onchange heatmap_view begin + if heatmap_view && editmode && first_time_in_editmode + first_time_in_editmode = false + end + end + + @onchange first_time_in_editmode begin + @assert !first_time_in_editmode + @notify "Toggle the \"edit mode\" off to browse the sequence with the heatmap" :info end @onchange setvalue begin @@ -247,7 +281,10 @@ const bonito_app = NamedApp(:inherit, Backbone.app) if !(sequence.program_name in sequence_names) push!(sequence_names, sequence.program_name) end + notify(sequence_names) selected_sequence_name = sequence.program_name + # enable the download and delete buttons + no_sequences_yet = false catch exception @error "Failed to load file" exception @notify "Failed to load file" :error @@ -263,6 +300,37 @@ const bonito_app = NamedApp(:inherit, Backbone.app) @onchange area_selected begin @notify "Click inside the selected area to assign the setvalue, or press Ctrl while clicking to copy the selection" :info end + + @onbutton delete_sequence_click begin + isempty(selected_sequence_name) && return + # update the model + deletesequence(back_session_id) + popat!(sequence_names, selected_sequence_index) + if length(sequence_names) < selected_sequence_index + selected_sequence_index[!] -= 1 + end + if selected_sequence_index == 0 + n = round(Int, series_length) + stop_time = start_time + (n-1) * time_interval + time_support = start_time:time_interval:stop_time + model = getmodel(back_session_id) + muscles = model.overview + sequence = MuscleActivity("", time_support, muscles) + # update the view + selected_sequence_name = "" + # update the view + sequence_name = "" + # disable the download and delete buttons (view) + no_sequences_yet = true + # update the model + model.sequence[] = sequence + else + # update both the view and model + selected_sequence_name = sequence_names[selected_sequence_index] + end + # update the view + notify(sequence_names) + end end function dropdown(key, options, label; margin=true, class=nothing) @@ -283,7 +351,7 @@ function muscle_view(bonito_url=""; channel=":channel") label="Upload motor program(s)", autoupload=true, hideuploadbtn=true, class="no-file-listing", id="uploader", url="/____/upload/$channel")), item(btn("Download motor program", @click(:export_sequence_click), color="primary", - loading=:export_sequence_click, + loading=:export_sequence_click, disable=:no_sequences_yet, [tooltip("Download the current motor program as a JSON file")])), dropdown(:selected_sequence_name, :sequence_names, "Motor program", class="sequence-list"), @@ -307,7 +375,8 @@ function muscle_view(bonito_url=""; channel=":channel") textfield("Start time", :start_time, type="number", step="any", disable=:editmode_disable), ]), - #item(btn("Delete motor program", @click(:delete_sequence_click), color="negative")), + item(btn("Delete motor program", @click(:delete_sequence_click), color="negative", + disable=:no_sequences_yet)), ]) Html.div(style=col, [ diff --git a/src/apps/muscles/muscleapp.css b/src/apps/muscles/muscleapp.css index 436d5d89a569da0f7a841b4d2c84ecc3919ff238..5239c123f715bc275a08e199408dc65b5eedb250 100644 --- a/src/apps/muscles/muscleapp.css +++ b/src/apps/muscles/muscleapp.css @@ -12,3 +12,6 @@ margin-left: 1rem; } +.q-notification__message { + font-size: 120%; +}