PyMieSimX 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of PyMieSimX might be problematic. Click here for more details.

Files changed (103) hide show
  1. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PKG-INFO +16 -1
  2. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/dashboard.css +28 -0
  3. pymiesimx-0.2.2/PyMieSimX/gui/assets/simulation-sharing.js +36 -0
  4. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/callbacks.py +102 -25
  5. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/components/cards.py +8 -0
  6. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/interface.py +5 -2
  7. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/layout.py +1 -0
  8. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/experiment/page.py +12 -3
  9. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/home.py +1 -1
  10. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/population.py +23 -13
  11. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/single/page.py +13 -3
  12. pymiesimx-0.2.2/PyMieSimX/gui/seo.py +91 -0
  13. pymiesimx-0.2.2/PyMieSimX/gui/sharing.py +332 -0
  14. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX.egg-info/PKG-INFO +16 -1
  15. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX.egg-info/SOURCES.txt +5 -0
  16. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX.egg-info/scm_file_list.json +5 -0
  17. pymiesimx-0.2.2/PyMieSimX.egg-info/scm_version.json +8 -0
  18. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/README.rst +15 -0
  19. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/tests/browser/test_dashboard.py +286 -1
  20. pymiesimx-0.2.2/tests/gui/test_seo.py +67 -0
  21. pymiesimx-0.2.2/tests/gui/test_sharing.py +171 -0
  22. pymiesimx-0.2.1/PyMieSimX.egg-info/scm_version.json +0 -8
  23. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/.coveragerc +0 -0
  24. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/.flake8 +0 -0
  25. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/.github/dependabot.yml +0 -0
  26. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/.github/workflows/deploy_PyPi.yml +0 -0
  27. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/.github/workflows/deploy_anaconda.yml +0 -0
  28. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/.github/workflows/deploy_coverage.yml +0 -0
  29. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/.github/workflows/quality.yml +0 -0
  30. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/.github/workflows/tests.yml +0 -0
  31. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/.gitignore +0 -0
  32. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/.pre-commit-config.yaml +0 -0
  33. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/AGENTS.md +0 -0
  34. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/CONTRIBUTING.md +0 -0
  35. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/Dockerfile +0 -0
  36. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/LICENSE +0 -0
  37. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/__init__.py +0 -0
  38. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/__main__.py +0 -0
  39. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/api.py +0 -0
  40. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/application/__init__.py +0 -0
  41. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/application/wsgi.py +0 -0
  42. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/__init__.py +0 -0
  43. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/admin.py +0 -0
  44. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/card-state.js +0 -0
  45. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/default-settings.json +0 -0
  46. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/home-spf-radial.gif +0 -0
  47. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/home-spf-radial.png +0 -0
  48. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/logo.png +0 -0
  49. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/materials-stock.json +0 -0
  50. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/pymiesim-favicon.svg +0 -0
  51. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/pymiesim-logo-dark.svg +0 -0
  52. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/pymiesim-logo.svg +0 -0
  53. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/callback_helpers.py +0 -0
  54. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/components/__init__.py +0 -0
  55. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/computation.py +0 -0
  56. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/defaults.py +0 -0
  57. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/experiment_service.py +0 -0
  58. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/jobs.py +0 -0
  59. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/material_catalog.py +0 -0
  60. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/models.py +0 -0
  61. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/__init__.py +0 -0
  62. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/citation.py +0 -0
  63. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/documentation.py +0 -0
  64. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/experiment/__init__.py +0 -0
  65. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/experiment/sections/__init__.py +0 -0
  66. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/experiment/sections/configuration.py +0 -0
  67. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/field_syntax.py +0 -0
  68. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/install_local.py +0 -0
  69. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/sellmeier.py +0 -0
  70. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/settings.py +0 -0
  71. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/single/__init__.py +0 -0
  72. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/single/sections/__init__.py +0 -0
  73. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/single/sections/representations.py +0 -0
  74. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/single/sections/setup.py +0 -0
  75. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/parsing.py +0 -0
  76. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/plotting.py +0 -0
  77. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/population_service.py +0 -0
  78. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/schemas.py +0 -0
  79. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/services.py +0 -0
  80. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/single_service.py +0 -0
  81. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/usage_metrics.py +0 -0
  82. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX/gui/validation.py +0 -0
  83. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX.egg-info/dependency_links.txt +0 -0
  84. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX.egg-info/entry_points.txt +0 -0
  85. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX.egg-info/requires.txt +0 -0
  86. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/PyMieSimX.egg-info/top_level.txt +0 -0
  87. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/conda.recipe/meta.yaml +0 -0
  88. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/makefile +0 -0
  89. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/pyproject.toml +0 -0
  90. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/pytest.ini +0 -0
  91. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/setup.cfg +0 -0
  92. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/tests/gui/cli.py +0 -0
  93. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/tests/gui/test_admin.py +0 -0
  94. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/tests/gui/test_api.py +0 -0
  95. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/tests/gui/test_dash_callbacks.py +0 -0
  96. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/tests/gui/test_home.py +0 -0
  97. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/tests/gui/test_population.py +0 -0
  98. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/tests/gui/test_services.py +0 -0
  99. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/tests/gui/test_usage_metrics.py +0 -0
  100. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/tools/generate_home_spf.py +0 -0
  101. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/tools/next_release_version.py +0 -0
  102. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/tools/release_tag.py +0 -0
  103. {pymiesimx-0.2.1 → pymiesimx-0.2.2}/tools/verify_conda_artifact.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyMieSimX
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Graphical Dash interface for PyMieSim.
5
5
  Author-email: Martin Poinsinet de Sivry-Houle <martin.poinsinet.de.sivry@gmail.com>
6
6
  License-Expression: MIT
@@ -50,6 +50,21 @@ optical setups, run parameter sweeps, explore individual particles, and download
50
50
  your results as CSV files. The hosted service provides direct access to the
51
51
  graphical interface; local installation is also available below.
52
52
 
53
+ Sharing simulations
54
+ -------------------
55
+
56
+ Use **Copy simulation link** in Single Scatterer, Parameter Scan, or Ensemble
57
+ Optics to share the current setup. Links preserve the input values, sweep
58
+ ranges, selected measures, and plot settings without requiring an account.
59
+ Opening a link restores the setup and automatically runs the simulation once
60
+ the inputs and plot settings are ready. Later edits can be calculated with
61
+ **Run**. If clipboard access is unavailable, use **Show simulation
62
+ link** and copy the displayed URL.
63
+
64
+ Links store the configuration rather than a result snapshot. Recalculation
65
+ uses the installed solver and optical material data, so results can change
66
+ after those dependencies are updated.
67
+
53
68
  Ensemble Optics
54
69
  ---------------
55
70
 
@@ -4055,3 +4055,31 @@ html .single-workspace .representation-card {
4055
4055
  .population-page .documentation-table td { overflow-wrap: anywhere; }
4056
4056
  #population-status, #population-preview-error { margin-bottom: 12px; }
4057
4057
  #population-results { overflow-x: auto; }
4058
+ .workspace-intro {
4059
+ margin: 0;
4060
+ color: var(--muted);
4061
+ font-size: .86rem;
4062
+ line-height: 1.6;
4063
+ }
4064
+
4065
+ .single-tab-content, .experiment-tab-content, .population-page { gap: 12px; }
4066
+ .page-hero.workspace-header {
4067
+ box-sizing: border-box;
4068
+ min-height: 76px;
4069
+ display: flex;
4070
+ flex-direction: column;
4071
+ justify-content: center;
4072
+ gap: 4px;
4073
+ }
4074
+ .workspace-header .hero-text { max-width: none; line-height: 1.6; min-height: 2lh; }
4075
+ @media (max-width: 700px) {
4076
+ .page-hero.workspace-header { min-height: 148px; }
4077
+ .workspace-header .eyebrow { min-height: 2lh; }
4078
+ .workspace-header .hero-text { min-height: 4lh; }
4079
+ }
4080
+
4081
+ .simulation-share-feedback { display: grid; gap: 8px; font-size: .86rem; color: var(--muted); }
4082
+ .simulation-share-actions { display: contents; }
4083
+ .simulation-share-feedback:empty { display: none; }
4084
+ .simulation-share-feedback .field-input { width: 100%; min-width: 0; }
4085
+ .simulation-link-notice { padding: 10px 14px; border: 1px solid var(--line); border-radius: 8px; color: var(--ink); }
@@ -0,0 +1,36 @@
1
+ window.dash_clientside = Object.assign({}, window.dash_clientside, {
2
+ simulation_sharing: {
3
+ auto_run: function(config, fieldIds, fieldValues, fieldClasses, settings) {
4
+ const unchanged = window.dash_clientside.no_update;
5
+ if (!config || config.started) return [unchanged, unchanged];
6
+ const values = Array.prototype.slice.call(arguments, 5);
7
+ const clicks = values.pop();
8
+ if (clicks) return [unchanged, Object.assign({}, config, {started: true})];
9
+
10
+ const actual = {};
11
+ config.control_names.forEach((name, index) => { actual[name] = values[index]; });
12
+ (fieldIds || []).forEach((id, index) => {
13
+ const ordered = {};
14
+ Object.keys(id).sort().forEach(key => { ordered[key] = id[key]; });
15
+ actual[JSON.stringify(ordered)] = fieldValues[index];
16
+ });
17
+ // Field classes depend on the existing validation callbacks; this
18
+ // also waits for dynamic fields and material-mode restoration.
19
+ if (!fieldClasses || fieldClasses.length < config.field_keys.length ||
20
+ !config.field_keys.every(key => Object.prototype.hasOwnProperty.call(actual, key))) {
21
+ return [unchanged, unchanged];
22
+ }
23
+ const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
24
+ if (!Object.entries(config.expected_controls).every(([key, value]) => same(actual[key], value))) {
25
+ return [unchanged, unchanged];
26
+ }
27
+ const plot = (settings || {})[config.workspace] || {};
28
+ if (!Object.entries(config.plot_settings).every(([key, value]) => same(plot[key], value))) {
29
+ return [unchanged, unchanged];
30
+ }
31
+ // Both outputs update together. Later edits and callback updates
32
+ // see the started flag and cannot launch a second automatic run.
33
+ return [1, Object.assign({}, config, {started: true})];
34
+ }
35
+ }
36
+ });
@@ -31,6 +31,7 @@ from PyMieSimX.gui.callback_helpers import (
31
31
  from PyMieSimX.gui.jobs import experiment_jobs
32
32
  from PyMieSimX.gui.validation import normalize_measures
33
33
  from PyMieSimX.gui import usage_metrics
34
+ from PyMieSimX.gui.sharing import register_sharing_callbacks, read_simulation, shared_setup, shared_plot_settings, restore_controls
34
35
  from PyMieSimX.gui.admin import build_admin_page, collect_admin_dashboard_data, dashboard_values, is_admin_access_granted
35
36
  from PyMieSimX.gui.services import (
36
37
  available_measures,
@@ -194,6 +195,21 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
194
195
  def _route_pages(pathname: str | None, search: str | None, home_visits: int, experiment_runs: int, single_runs: int, theme_store: dict | None, plot_settings: dict | None):
195
196
  """Render only the selected route page inside the persistent shell."""
196
197
  route = pathname or "/"
198
+ try:
199
+ setup = read_simulation(search, route)
200
+ share_message = "Shared setup loaded. This simulation runs automatically." if setup else None
201
+ except ValueError as error:
202
+ setup = None
203
+ share_message = str(error)
204
+ plot_settings = shared_plot_settings(plot_settings, setup)
205
+
206
+ def simulation_page(page):
207
+ from dash import html
208
+ restore_controls(page, setup)
209
+ if share_message:
210
+ page.children.insert(0, html.Div(share_message, role="status", className="simulation-link-notice"))
211
+ return build_page_with_footer(page)
212
+
197
213
  active = {
198
214
  "home": "sidebar-link",
199
215
  "experiment": "sidebar-link",
@@ -233,13 +249,13 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
233
249
  return build_page_with_footer(build_settings_page((theme_store or {}).get("theme", "light"), plot_settings or {})), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
234
250
  if route == "/single":
235
251
  active["single"] += " active"
236
- return build_page_with_footer(build_single_page((plot_settings or {}).get("particle_explorer", {}))), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
252
+ return simulation_page(build_single_page((plot_settings or {}).get("particle_explorer", {}), setup)), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
237
253
  if route == "/population":
238
254
  active["population"] += " active"
239
- return build_page_with_footer(build_population_page()), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
255
+ return simulation_page(build_population_page()), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
240
256
  if route == "/experiment":
241
257
  active["experiment"] += " active"
242
- return build_page_with_footer(build_experiment_page(default_measure_options, plot_settings or {})), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
258
+ return simulation_page(build_experiment_page(default_measure_options, plot_settings or {}, setup)), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
243
259
  active["home"] += " active"
244
260
  return build_page_with_footer(build_home_page(home_visits=home_visits)), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
245
261
 
@@ -292,9 +308,12 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
292
308
  Input("plot-experiment-legend", "value", allow_optional=True),
293
309
  Input("plot-experiment-grid", "value", allow_optional=True),
294
310
  State("plot-settings-store", "data"),
311
+ State("url", "search"),
312
+ State("url", "pathname"),
295
313
  )
296
314
  def _sync_plot_settings(*values):
297
- stored_settings = values[-1] or {}
315
+ search, path = values[-2:]
316
+ stored_settings = shared_plot_settings(values[-3], shared_setup(search, path))
298
317
  particle_values = values[:9]
299
318
  sweep_values = values[9:18]
300
319
  single_local_values = values[18:24]
@@ -387,28 +406,43 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
387
406
 
388
407
  return classes, errors
389
408
 
390
- @app.callback(Output("source-fields", "children"), Input("source-type", "value"))
391
- def _render_source_fields(source_type: str):
409
+ @app.callback(Output("source-fields", "children"), Input("source-type", "value"), State("url", "search"))
410
+ def _render_source_fields(source_type: str, search: str = ""):
392
411
  LOGGER.debug("Rendering source fields for %s", source_type)
393
- return render_fields("source", source_type)
412
+ setup = shared_setup(search, "/experiment")
413
+ if setup and setup["controls"].get("source-type") != source_type:
414
+ setup = None
415
+ return restore_controls(render_fields("source", source_type), setup)
394
416
 
395
- @app.callback(Output("scatterer-fields", "children"), Input("scatterer-type", "value"))
396
- def _render_scatterer_fields(scatterer_type: str):
417
+ @app.callback(Output("scatterer-fields", "children"), Input("scatterer-type", "value"), State("url", "search"))
418
+ def _render_scatterer_fields(scatterer_type: str, search: str = ""):
397
419
  LOGGER.debug("Rendering scatterer fields for %s", scatterer_type)
398
- return render_fields("scatterer", scatterer_type)
420
+ setup = shared_setup(search, "/experiment")
421
+ if setup and setup["controls"].get("scatterer-type") != scatterer_type:
422
+ setup = None
423
+ return restore_controls(render_fields("scatterer", scatterer_type), setup)
399
424
 
400
- @app.callback(Output("detector-fields", "children"), Input("detector-type", "value"))
401
- def _render_detector_fields(detector_type: str):
425
+ @app.callback(Output("detector-fields", "children"), Input("detector-type", "value"), State("url", "search"))
426
+ def _render_detector_fields(detector_type: str, search: str = ""):
402
427
  LOGGER.debug("Rendering detector fields for %s", detector_type)
403
- return render_fields("detector", detector_type)
404
-
405
- @app.callback(Output("single-source-fields", "children"), Input("single-source-type", "value"))
406
- def _render_single_source_fields(source_type: str):
407
- return render_fields("single-source", source_type)
408
-
409
- @app.callback(Output("single-scatterer-fields", "children"), Input("single-scatterer-type", "value"))
410
- def _render_single_scatterer_fields(scatterer_type: str):
411
- return render_fields("single-scatterer", scatterer_type)
428
+ setup = shared_setup(search, "/experiment")
429
+ if setup and setup["controls"].get("detector-type") != detector_type:
430
+ setup = None
431
+ return restore_controls(render_fields("detector", detector_type), setup)
432
+
433
+ @app.callback(Output("single-source-fields", "children"), Input("single-source-type", "value"), State("url", "search"))
434
+ def _render_single_source_fields(source_type: str, search: str = ""):
435
+ setup = shared_setup(search, "/single")
436
+ if setup and setup["controls"].get("single-source-type") != source_type:
437
+ setup = None
438
+ return restore_controls(render_fields("single-source", source_type), setup)
439
+
440
+ @app.callback(Output("single-scatterer-fields", "children"), Input("single-scatterer-type", "value"), State("url", "search"))
441
+ def _render_single_scatterer_fields(scatterer_type: str, search: str = ""):
442
+ setup = shared_setup(search, "/single")
443
+ if setup and setup["controls"].get("single-scatterer-type") != scatterer_type:
444
+ setup = None
445
+ return restore_controls(render_fields("single-scatterer", scatterer_type), setup)
412
446
 
413
447
  @app.callback(
414
448
  Output({"kind": "material-toggle", "section": MATCH, "name": MATCH}, "className"),
@@ -478,6 +512,7 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
478
512
  Input({"kind": "field", "section": "detector", "name": ALL}, "value"),
479
513
  Input({"kind": "field", "section": "detector", "name": ALL}, "id"),
480
514
  State("x-axis-select", "value"),
515
+ State("url", "search"),
481
516
  )
482
517
  def _update_x_axis_options(
483
518
  source_type: str,
@@ -490,6 +525,7 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
490
525
  detector_values: list[str],
491
526
  detector_ids: list[dict[str, str]],
492
527
  current_x_axis: str | None,
528
+ search: str = "",
493
529
  ):
494
530
  LOGGER.debug(
495
531
  "Updating x-axis options from form state source=%s scatterer=%s detector=%s current=%s",
@@ -509,6 +545,16 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
509
545
  )
510
546
 
511
547
  options = [{"label": field_name, "value": field_name} for field_name in variable_fields]
548
+ setup = shared_setup(search, "/experiment")
549
+ if setup:
550
+ desired_axis = setup["controls"].get("x-axis-select")
551
+ complete = (len(source_ids) == len(SOURCE_FIELDS[source_type])
552
+ and len(scatterer_ids) == len(SCATTERER_FIELDS[scatterer_type])
553
+ and len(detector_ids) == len(DETECTOR_FIELDS[detector_type]))
554
+ if current_x_axis is None or current_x_axis == desired_axis:
555
+ current_x_axis = desired_axis
556
+ if not complete:
557
+ return options, current_x_axis
512
558
  value = current_x_axis if current_x_axis in variable_fields else (variable_fields[0] if variable_fields else None)
513
559
  return options, value
514
560
 
@@ -835,12 +881,25 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
835
881
  Input("single-representation", "value"),
836
882
  Input("single-projection", "value"),
837
883
  State("plot-settings-store", "data"),
884
+ State("url", "search"),
885
+ State("plot-single-x-scale", "value", allow_optional=True),
886
+ State("plot-single-y-scale", "value", allow_optional=True),
887
+ State("plot-single-font-size", "value", allow_optional=True),
888
+ State("plot-single-line-width", "value", allow_optional=True),
889
+ State("plot-single-legend", "value", allow_optional=True),
890
+ State("plot-single-grid", "value", allow_optional=True),
838
891
  )
839
- def _update_single_plot_options(representation: str, projection: str, plot_settings: dict | None):
892
+ def _update_single_plot_options(representation: str, projection: str, plot_settings: dict | None, search: str = "", *local_values):
893
+ setup = shared_setup(search, "/single")
894
+ plot_settings = shared_plot_settings(plot_settings, setup)
840
895
  particle_settings = (plot_settings or {}).get("particle_explorer", plot_settings or {})
896
+ particle_settings = _merge_local_plot_values(particle_settings, local_values)
841
897
  from PyMieSimX.gui.layout import _plot_options_card
842
898
 
843
- return _plot_options_card("single", particle_settings, representation, projection)
899
+ card = _plot_options_card("single", particle_settings, representation, projection)
900
+ if setup:
901
+ restore_controls(card, {**setup, "controls": {}})
902
+ return card
844
903
 
845
904
  @app.callback(
846
905
  Output("experiment-plot-options-container", "children"),
@@ -848,22 +907,38 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
848
907
  Input("experiment-result", "data"),
849
908
  State("plot-settings-store", "data"),
850
909
  State("plot-experiment-projection", "value", allow_optional=True),
910
+ State("url", "search"),
911
+ State("plot-experiment-x-scale", "value", allow_optional=True),
912
+ State("plot-experiment-y-scale", "value", allow_optional=True),
913
+ State("plot-experiment-font-size", "value", allow_optional=True),
914
+ State("plot-experiment-line-width", "value", allow_optional=True),
915
+ State("plot-experiment-legend", "value", allow_optional=True),
916
+ State("plot-experiment-grid", "value", allow_optional=True),
851
917
  )
852
- def _update_experiment_plot_options(x_axis: str | None, result: dict | None, plot_settings: dict | None, current_projection: str | None):
918
+ def _update_experiment_plot_options(x_axis: str | None, result: dict | None, plot_settings: dict | None, current_projection: str | None, search: str = "", *local_values):
919
+ setup = shared_setup(search, "/experiment")
920
+ plot_settings = shared_plot_settings(plot_settings, setup)
853
921
  sweep_settings = (plot_settings or {}).get("parameter_sweep", plot_settings or {})
854
922
  units = (result or {}).get("units", {})
855
923
  xaxis_unit = units.get(x_axis)
856
924
  if xaxis_unit is None and x_axis:
857
925
  matching_units = [value for key, value in units.items() if key.rsplit(":", 1)[-1] == x_axis]
858
926
  xaxis_unit = matching_units[0] if matching_units else None
927
+ if xaxis_unit is None and x_axis:
928
+ xaxis_unit = next((str(spec.unit) for group in (SOURCE_FIELDS, SCATTERER_FIELDS, DETECTOR_FIELDS)
929
+ for specs in group.values() for spec in specs if spec.name == x_axis and spec.unit is not None), None)
859
930
  is_angle = _is_angular_unit(xaxis_unit)
860
931
  options = [{"label": "Cartesian", "value": "cartesian"}]
861
932
  if is_angle:
862
933
  options.append({"label": "Polar", "value": "polar"})
863
934
  projection = current_projection if is_angle and current_projection == "polar" else "cartesian"
935
+ sweep_settings = _merge_local_plot_values(sweep_settings, local_values)
864
936
  from PyMieSimX.gui.layout import _plot_options_card
865
937
 
866
- return _plot_options_card("experiment", sweep_settings, projection=projection, projection_options=options)
938
+ card = _plot_options_card("experiment", sweep_settings, projection=projection, projection_options=options)
939
+ if setup:
940
+ restore_controls(card, {**setup, "controls": {}})
941
+ return card
867
942
 
868
943
  @app.callback(
869
944
  Output("single-graph", "figure"),
@@ -894,6 +969,8 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
894
969
  """Keep Export CSV greyed out until Run has produced a result."""
895
970
  return not result
896
971
 
972
+ register_sharing_callbacks(app)
973
+
897
974
 
898
975
  def build_single_empty_figure(plot_settings: dict | None = None, theme: str = "light"):
899
976
  """Return the initial representation canvas."""
@@ -6,6 +6,14 @@ from collections.abc import Sequence
6
6
  from dash import html
7
7
 
8
8
 
9
+ def workspace_header(title: str, description: str):
10
+ """Build the same compact introduction card for each simulation workspace."""
11
+ return html.Section(className="page-hero workspace-header", children=[
12
+ html.P(title, className="eyebrow"),
13
+ html.P(description, className="hero-text workspace-intro"),
14
+ ])
15
+
16
+
9
17
  class Card:
10
18
  """Build a RosettaX-style card with an optional colored left accent."""
11
19
 
@@ -14,6 +14,7 @@ from PyMieSimX.gui.callbacks import register_callbacks
14
14
  from PyMieSimX.gui.layout import create_layout
15
15
  from PyMieSimX.gui.material_catalog import ensure_material_catalog
16
16
  from PyMieSimX.gui.services import available_measures
17
+ from PyMieSimX.gui.seo import PAGE_METADATA, SearchDash, register_page_metadata
17
18
 
18
19
  LOGGER = logging.getLogger(__name__)
19
20
  GITHUB_RELEASES_URL = "https://github.com/MartinPdeS/PyMieSimX/releases"
@@ -36,9 +37,10 @@ def _initialize_material_catalog() -> None:
36
37
 
37
38
  def create_dash_app() -> Dash:
38
39
  """Create and configure the experiment dashboard Dash application."""
39
- application = Dash(
40
+ application = SearchDash(
40
41
  __name__,
41
- title="PyMieSim",
42
+ title=PAGE_METADATA["/"]["title"],
43
+ update_title=None,
42
44
  assets_folder=str(Path(__file__).with_name("assets")),
43
45
  suppress_callback_exceptions=True,
44
46
  )
@@ -51,6 +53,7 @@ def create_dash_app() -> Dash:
51
53
  initial_measures = available_measures("SphereSet", "PhotodiodeSet")
52
54
  application.layout = create_layout(initial_measures)
53
55
  register_callbacks(application, initial_measures)
56
+ register_page_metadata(application)
54
57
  LOGGER.debug("Dash application initialized with %d callbacks", len(application.callback_map))
55
58
  return application
56
59
 
@@ -217,6 +217,7 @@ def create_layout(default_measure_options: list[str]):
217
217
  className="app-shell",
218
218
  children=[
219
219
  dcc.Location(id="url", refresh=False),
220
+ dcc.Store(id="page-metadata"),
220
221
  dcc.Store(id="experiment-result"),
221
222
  dcc.Store(id="single-result"),
222
223
  dcc.Download(id="csv-download"),
@@ -3,18 +3,25 @@
3
3
 
4
4
  from dash import dcc, html
5
5
 
6
+ from PyMieSimX.gui.sharing import sharing_controls, sharing_feedback
7
+ from PyMieSimX.gui.components.cards import workspace_header
8
+
6
9
  from PyMieSimX.gui.callback_helpers import status_banner
7
10
  from PyMieSimX.gui.layout import PLOT_CONFIG, _plot_options_card, _x_axis_card, build_plot_options_sidebar, build_tabbed_sidebar
8
11
 
9
12
  from .sections import build_detector_section, build_scatterer_section, build_source_section
10
13
 
11
14
 
12
- def build_experiment_page(default_measure_options: list[str], plot_settings: dict | None = None):
15
+ def build_experiment_page(default_measure_options: list[str], plot_settings: dict | None = None, simulation: dict | None = None):
13
16
  """Build the isolated Parameter Scan workspace."""
14
17
  settings = (plot_settings or {}).get("parameter_sweep", {})
18
+ projection = (simulation or {}).get("controls", {}).get("plot-experiment-projection", "cartesian")
19
+ projection_options = [{"label": "Cartesian", "value": "cartesian"}, {"label": "Polar", "value": "polar"}] if projection == "polar" else None
20
+ initial_plot_options = _plot_options_card("experiment", settings, projection=projection, projection_options=projection_options)
15
21
  return html.Div(
16
22
  className="tab-content-stack experiment-tab-content",
17
23
  children=[
24
+ workspace_header("Compare scattering across a parameter sweep", "Run a Mie parameter sweep across source, scatterer, and detector settings. Compare scattering measures and export the results as CSV."),
18
25
  html.Section(
19
26
  id="configure",
20
27
  className="workspace",
@@ -24,7 +31,6 @@ def build_experiment_page(default_measure_options: list[str], plot_settings: dic
24
31
  html.Section(
25
32
  className="result-column",
26
33
  children=[
27
- _x_axis_card(default_measure_options),
28
34
  html.Div(
29
35
  className="graph-toolbar",
30
36
  children=[
@@ -42,6 +48,7 @@ def build_experiment_page(default_measure_options: list[str], plot_settings: dic
42
48
  disabled=True,
43
49
  className="toolbar-button toolbar-button-secondary",
44
50
  ),
51
+ sharing_controls("/experiment"),
45
52
  ],
46
53
  ),
47
54
  html.Div(
@@ -49,6 +56,8 @@ def build_experiment_page(default_measure_options: list[str], plot_settings: dic
49
56
  className="experiment-run-feedback",
50
57
  children=status_banner("idle", "Ready. Click Run to start a sweep."),
51
58
  ),
59
+ sharing_feedback(),
60
+ _x_axis_card(default_measure_options),
52
61
  html.Section(className="panel graph-panel", children=[dcc.Loading(id="result-graph-loading", type="circle", color="#4f8df7", custom_spinner=html.Div("Computing…", className="plot-computing-indicator"), delay_show=150, delay_hide=150, children=dcc.Graph(id="result-graph", config=PLOT_CONFIG))]),
53
62
  ],
54
63
  ),
@@ -61,7 +70,7 @@ def build_experiment_page(default_measure_options: list[str], plot_settings: dic
61
70
  {"tab_id": "experiment-tab-source", "panel_id": "experiment-panel-source", "label": "Source", "color": "yellow", "content": build_source_section(), "active": False},
62
71
  {"tab_id": "experiment-tab-scatterer", "panel_id": "experiment-panel-scatterer", "label": "Scatterer", "color": "blue", "content": build_scatterer_section(), "active": False},
63
72
  {"tab_id": "experiment-tab-detector", "panel_id": "experiment-panel-detector", "label": "Detector", "color": "cyan", "content": build_detector_section(), "active": False},
64
- {"tab_id": "experiment-tab-plot-options", "panel_id": "experiment-panel-plot-options", "label": "Plot options", "color": "purple", "content": build_plot_options_sidebar("experiment-plot-options-container", _plot_options_card("experiment", settings)), "active": False},
73
+ {"tab_id": "experiment-tab-plot-options", "panel_id": "experiment-panel-plot-options", "label": "Plot options", "color": "purple", "content": build_plot_options_sidebar("experiment-plot-options-container", initial_plot_options), "active": False},
65
74
  ],
66
75
  ),
67
76
  ],
@@ -29,7 +29,7 @@ def build_home_page(*, home_visits: int | float | None = None):
29
29
  [
30
30
  html.H1("PyMieSim"),
31
31
  html.P(
32
- "An open-source library for fast and flexible far-field Mie scattering simulations.",
32
+ "An open-source Mie theory simulator and scattering calculator. Explore individual particles, run parameter sweeps, and export results with PyMieSim.",
33
33
  className="hero-text",
34
34
  ),
35
35
  ],
@@ -4,10 +4,13 @@ import logging
4
4
 
5
5
  from dash import Input, Output, State, dcc, html, no_update
6
6
  from dash.exceptions import PreventUpdate
7
+
8
+ from PyMieSimX.gui.sharing import sharing_controls, sharing_feedback, shared_setup
7
9
  import plotly.graph_objects as go
8
10
  import numpy as np
9
11
 
10
12
  from PyMieSimX.gui.components import Card
13
+ from PyMieSimX.gui.components.cards import workspace_header
11
14
  from PyMieSimX.gui.layout import build_tabbed_sidebar, render_field
12
15
  from PyMieSimX.gui.material_catalog import material_dropdown_options
13
16
  from PyMieSimX.gui.schemas import POPULATION_OPTICAL_FIELDS
@@ -91,19 +94,18 @@ def build_population_page():
91
94
  return html.Div(className="page-content-stack population-page", children=[
92
95
  dcc.Store(id="population-result"), dcc.Download(id="population-download"),
93
96
  dcc.Store(id="population-right-sidebar-active", data=None),
94
- html.Section(className="page-hero", children=[
95
- html.P("From particles to population properties", className="eyebrow"),
96
- html.P("Define particle properties, size distribution, and concentration in the side panels, then compute all optical properties at one wavelength.", className="hero-text"),
97
- ]),
97
+ workspace_header("From particles to population properties", "Define particle properties, size distribution, and concentration in the side panels, then compute all optical properties at one wavelength."),
98
98
  html.Section(className="population-workspace", children=[
99
99
  html.Div(className="result-column population-output", children=[
100
100
  html.Div(className="graph-toolbar", children=[
101
- html.Button(_compute_label("Compute"), id="population-compute", n_clicks=0,
101
+ html.Button(_compute_label("Run"), id="population-compute", n_clicks=0,
102
102
  className="toolbar-button toolbar-button-primary"),
103
103
  html.Button([html.Span("⤓", className="toolbar-button-icon", **{"aria-hidden": "true"}), "Export CSV"],
104
104
  id="population-export", disabled=True, className="toolbar-button toolbar-button-secondary"),
105
+ sharing_controls("/population"),
105
106
  ]),
106
- html.Div(id="population-status", role="status", children="Ready. Click Compute to calculate all properties."),
107
+ sharing_feedback(),
108
+ html.Div(id="population-status", role="status", children="Ready. Click Run to calculate all properties."),
107
109
  _panel("Distribution preview", [
108
110
  dcc.Graph(id="population-preview", config={"displaylogo": False}),
109
111
  html.Div(id="population-preview-error", role="status"),
@@ -138,20 +140,28 @@ def register_population_callbacks(app):
138
140
  Output("population-minimum-field", "style"), Output("population-maximum-field", "style"),
139
141
  Output("population-sampling-field", "style"), Output("population-width", "value"),
140
142
  Input("population-distribution", "value"),
143
+ State("url", "search"),
141
144
  )
142
- def _population_distribution_controls(distribution):
145
+ def _population_distribution_controls(distribution, search=""):
146
+ from dash import ctx
147
+ setup = shared_setup(search, "/population")
148
+ width = (setup or {}).get("controls", {}).get("population-width") if setup and not ctx.triggered else None
143
149
  hidden = {"display": "none"}
144
150
  diameter_label = {"monodisperse": "Diameter [nm]", "gaussian": "Gaussian mean diameter [nm]"}.get(distribution, "Geometric median diameter [nm]")
145
151
  return (diameter_label, "Standard deviation [nm]" if distribution == "gaussian" else "Geometric standard deviation",
146
152
  hidden if distribution == "uniform" else {}, {} if distribution in {"gaussian", "lognormal"} else hidden,
147
153
  {} if distribution in {"gaussian", "uniform"} else hidden,
148
154
  {} if distribution in {"gaussian", "uniform"} else hidden,
149
- hidden if distribution == "monodisperse" else {}, "50" if distribution == "gaussian" else "1.2")
155
+ hidden if distribution == "monodisperse" else {}, width if width is not None else "50" if distribution == "gaussian" else "1.2")
150
156
 
151
157
  @app.callback(Output("population-concentration-label", "children"), Output("population-concentration", "value"),
152
- Input("population-concentration-basis", "value"))
153
- def _population_concentration_controls(basis):
154
- return ("Particle volume fraction [0–1]", "0.001") if basis == "volume" else ("Number concentration [particles/mL]", "1e9")
158
+ Input("population-concentration-basis", "value"), State("url", "search"))
159
+ def _population_concentration_controls(basis, search=""):
160
+ from dash import ctx
161
+ setup = shared_setup(search, "/population")
162
+ concentration = (setup or {}).get("controls", {}).get("population-concentration") if setup and not ctx.triggered else None
163
+ label, default = ("Particle volume fraction [0–1]", "0.001") if basis == "volume" else ("Number concentration [particles/mL]", "1e9")
164
+ return label, concentration if concentration is not None else default
155
165
 
156
166
  @app.callback(Output("population-preview", "figure"), Output("population-preview-error", "children"),
157
167
  *[Input(f"population-{name}", "value") for name in ("distribution", "diameter", "width", "minimum", "maximum", "sampling")],
@@ -183,7 +193,7 @@ def register_population_callbacks(app):
183
193
  Input("population-compute", "n_clicks"), *[State(_control_id(name), "value") for name in FIELDS],
184
194
  prevent_initial_call=True, running=[
185
195
  (Output("population-compute", "disabled"), True, False),
186
- (Output("population-compute", "children"), _compute_label("Computing…"), _compute_label("Compute")),
196
+ (Output("population-compute", "children"), _compute_label("Running…"), _compute_label("Run")),
187
197
  *[(Output(_control_id(name), "disabled"), True, False) for name in FIELDS],
188
198
  *[(Output({"kind": kind, "section": "population", "name": name}, "disabled"),
189
199
  True, not material_dropdown_options(medium=name == "medium"))
@@ -211,7 +221,7 @@ def register_population_callbacks(app):
211
221
  Output("population-export", "disabled", allow_duplicate=True),
212
222
  *[Input(_control_id(name), "value") for name in FIELDS], prevent_initial_call=True)
213
223
  def _invalidate_population(*values):
214
- return None, [], "Ready. Click Compute to calculate all properties.", True
224
+ return None, [], "Ready. Click Run to calculate all properties.", True
215
225
 
216
226
  @app.callback(Output("population-download", "data"), Input("population-export", "n_clicks"),
217
227
  State("population-result", "data"), prevent_initial_call=True)
@@ -2,24 +2,31 @@
2
2
 
3
3
  from dash import dcc, html
4
4
 
5
+ from PyMieSimX.gui.sharing import sharing_controls, sharing_feedback
6
+ from PyMieSimX.gui.components.cards import workspace_header
7
+
5
8
  from PyMieSimX.gui.defaults import DEFAULT_WORKSPACE_SETTINGS
6
9
  from PyMieSimX.gui.layout import PLOT_CONFIG, _plot_options_card, build_plot_options_sidebar, build_tabbed_sidebar
7
10
 
8
11
  from .sections import build_representation_section, build_scatterer_section, build_source_section
9
12
 
10
13
 
11
- def build_single_page(plot_settings: dict | None = None):
14
+ def build_single_page(plot_settings: dict | None = None, simulation: dict | None = None):
12
15
  """Build the single page from independent setup and plot sections."""
16
+ controls = (simulation or {}).get("controls", {})
17
+ representation = controls.get("single-representation", DEFAULT_WORKSPACE_SETTINGS["particle_explorer"]["representation"])
18
+ projection = controls.get("single-projection", DEFAULT_WORKSPACE_SETTINGS["particle_explorer"]["projection"])
19
+ initial_plot_options = _plot_options_card("single", plot_settings or {}, representation=representation, projection=projection)
13
20
  return html.Div(
14
21
  className="tab-content-stack single-tab-content",
15
22
  children=[
23
+ workspace_header("Explore scattering from a single particle", "Configure the source and scatterer to explore nanoparticle Mie scattering, angular patterns, polarization, and fields."),
16
24
  html.Section(
17
25
  className="single-workspace",
18
26
  children=[
19
27
  html.Section(
20
28
  className="result-column",
21
29
  children=[
22
- build_representation_section(),
23
30
  html.Div(
24
31
  className="graph-toolbar",
25
32
  children=[
@@ -37,8 +44,11 @@ def build_single_page(plot_settings: dict | None = None):
37
44
  disabled=True,
38
45
  className="toolbar-button toolbar-button-secondary",
39
46
  ),
47
+ sharing_controls("/single"),
40
48
  ],
41
49
  ),
50
+ sharing_feedback(),
51
+ build_representation_section(),
42
52
  html.Section(className="panel graph-panel single-graph-panel", children=[dcc.Loading(id="single-graph-loading", type="circle", color="#4f8df7", custom_spinner=html.Div("Computing…", className="plot-computing-indicator"), delay_show=150, delay_hide=150, children=dcc.Graph(id="single-graph", config=PLOT_CONFIG))]),
43
53
  ],
44
54
  ),
@@ -50,7 +60,7 @@ def build_single_page(plot_settings: dict | None = None):
50
60
  [
51
61
  {"tab_id": "single-tab-source", "panel_id": "single-panel-source", "label": "Source", "color": "yellow", "content": build_source_section(), "active": False},
52
62
  {"tab_id": "single-tab-scatterer", "panel_id": "single-panel-scatterer", "label": "Scatterer", "color": "blue", "content": build_scatterer_section(), "active": False},
53
- {"tab_id": "single-tab-plot-options", "panel_id": "single-panel-plot-options", "label": "Plot options", "color": "purple", "content": build_plot_options_sidebar("single-plot-options-container", _plot_options_card("single", plot_settings or {}, representation=DEFAULT_WORKSPACE_SETTINGS["particle_explorer"]["representation"], projection=DEFAULT_WORKSPACE_SETTINGS["particle_explorer"]["projection"])), "active": False},
63
+ {"tab_id": "single-tab-plot-options", "panel_id": "single-panel-plot-options", "label": "Plot options", "color": "purple", "content": build_plot_options_sidebar("single-plot-options-container", initial_plot_options), "active": False},
54
64
  ],
55
65
  ),
56
66
  ],