PyMieSimX 0.2.0__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.0 → pymiesimx-0.2.2}/PKG-INFO +16 -1
  2. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/dashboard.css +114 -38
  3. pymiesimx-0.2.2/PyMieSimX/gui/assets/simulation-sharing.js +36 -0
  4. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/callbacks.py +102 -25
  5. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/components/cards.py +8 -0
  6. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/interface.py +5 -2
  7. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/layout.py +1 -0
  8. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/experiment/page.py +12 -3
  9. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/home.py +13 -5
  10. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/population.py +23 -13
  11. {pymiesimx-0.2.0 → 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.0 → pymiesimx-0.2.2}/PyMieSimX.egg-info/PKG-INFO +16 -1
  15. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX.egg-info/SOURCES.txt +5 -0
  16. {pymiesimx-0.2.0 → 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.0 → pymiesimx-0.2.2}/README.rst +15 -0
  19. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/tests/browser/test_dashboard.py +287 -2
  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.0/PyMieSimX.egg-info/scm_version.json +0 -8
  23. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/.coveragerc +0 -0
  24. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/.flake8 +0 -0
  25. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/.github/dependabot.yml +0 -0
  26. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/.github/workflows/deploy_PyPi.yml +0 -0
  27. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/.github/workflows/deploy_anaconda.yml +0 -0
  28. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/.github/workflows/deploy_coverage.yml +0 -0
  29. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/.github/workflows/quality.yml +0 -0
  30. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/.github/workflows/tests.yml +0 -0
  31. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/.gitignore +0 -0
  32. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/.pre-commit-config.yaml +0 -0
  33. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/AGENTS.md +0 -0
  34. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/CONTRIBUTING.md +0 -0
  35. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/Dockerfile +0 -0
  36. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/LICENSE +0 -0
  37. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/__init__.py +0 -0
  38. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/__main__.py +0 -0
  39. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/api.py +0 -0
  40. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/application/__init__.py +0 -0
  41. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/application/wsgi.py +0 -0
  42. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/__init__.py +0 -0
  43. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/admin.py +0 -0
  44. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/card-state.js +0 -0
  45. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/default-settings.json +0 -0
  46. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/home-spf-radial.gif +0 -0
  47. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/home-spf-radial.png +0 -0
  48. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/logo.png +0 -0
  49. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/materials-stock.json +0 -0
  50. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/pymiesim-favicon.svg +0 -0
  51. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/pymiesim-logo-dark.svg +0 -0
  52. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/assets/pymiesim-logo.svg +0 -0
  53. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/callback_helpers.py +0 -0
  54. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/components/__init__.py +0 -0
  55. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/computation.py +0 -0
  56. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/defaults.py +0 -0
  57. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/experiment_service.py +0 -0
  58. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/jobs.py +0 -0
  59. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/material_catalog.py +0 -0
  60. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/models.py +0 -0
  61. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/__init__.py +0 -0
  62. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/citation.py +0 -0
  63. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/documentation.py +0 -0
  64. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/experiment/__init__.py +0 -0
  65. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/experiment/sections/__init__.py +0 -0
  66. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/experiment/sections/configuration.py +0 -0
  67. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/field_syntax.py +0 -0
  68. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/install_local.py +0 -0
  69. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/sellmeier.py +0 -0
  70. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/settings.py +0 -0
  71. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/single/__init__.py +0 -0
  72. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/single/sections/__init__.py +0 -0
  73. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/single/sections/representations.py +0 -0
  74. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/pages/single/sections/setup.py +0 -0
  75. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/parsing.py +0 -0
  76. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/plotting.py +0 -0
  77. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/population_service.py +0 -0
  78. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/schemas.py +0 -0
  79. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/services.py +0 -0
  80. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/single_service.py +0 -0
  81. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/usage_metrics.py +0 -0
  82. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX/gui/validation.py +0 -0
  83. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX.egg-info/dependency_links.txt +0 -0
  84. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX.egg-info/entry_points.txt +0 -0
  85. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX.egg-info/requires.txt +0 -0
  86. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/PyMieSimX.egg-info/top_level.txt +0 -0
  87. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/conda.recipe/meta.yaml +0 -0
  88. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/makefile +0 -0
  89. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/pyproject.toml +0 -0
  90. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/pytest.ini +0 -0
  91. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/setup.cfg +0 -0
  92. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/tests/gui/cli.py +0 -0
  93. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/tests/gui/test_admin.py +0 -0
  94. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/tests/gui/test_api.py +0 -0
  95. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/tests/gui/test_dash_callbacks.py +0 -0
  96. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/tests/gui/test_home.py +0 -0
  97. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/tests/gui/test_population.py +0 -0
  98. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/tests/gui/test_services.py +0 -0
  99. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/tests/gui/test_usage_metrics.py +0 -0
  100. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/tools/generate_home_spf.py +0 -0
  101. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/tools/next_release_version.py +0 -0
  102. {pymiesimx-0.2.0 → pymiesimx-0.2.2}/tools/release_tag.py +0 -0
  103. {pymiesimx-0.2.0 → 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.0
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
 
@@ -499,9 +499,9 @@ html .home-metric-tile { font-size: .92rem !important; }
499
499
  .version-widget-label { font-size: .72rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; opacity: .7; }
500
500
  .version-widget-value { font-size: .92rem; font-weight: 700; }
501
501
 
502
- html .home-page-hero { padding: 26px !important; border-radius: 15px; border-left: 5px solid rgba(13, 110, 253, .75); }
503
- html .home-page-hero h1 { margin: 0 0 8px; font-family: inherit !important; font-size: 2.55rem !important; font-weight: 800; line-height: 1.05; }
504
- html .home-page-hero .hero-text { max-width: 980px; margin: 0; font-size: 1.08rem !important; line-height: normal; opacity: .86; }
502
+ html .home-page-hero { padding: 30px !important; border-radius: 15px; border-left: 5px solid rgba(13, 110, 253, .75); }
503
+ html .home-page-hero h1 { margin: 0 0 12px; letter-spacing: -.045em; font-family: inherit !important; font-size: 2.55rem !important; font-weight: 800; line-height: 1.05; }
504
+ html .home-page-hero .hero-text { max-width: 460px; margin: 0; font-size: 1rem !important; line-height: 1.6; opacity: .7; }
505
505
 
506
506
  html .home-metrics-card > .card-body {
507
507
  display: grid;
@@ -3869,76 +3869,85 @@ html .single-workspace .representation-card {
3869
3869
  display: flex;
3870
3870
  align-items: center;
3871
3871
  justify-content: space-between;
3872
- gap: 24px;
3872
+ gap: 28px;
3873
3873
  flex-wrap: wrap;
3874
3874
  }
3875
3875
 
3876
3876
  .home-hero-copy {
3877
- flex: 1 1 360px;
3877
+ flex: 1 1 300px;
3878
3878
  min-width: 0;
3879
3879
  }
3880
3880
 
3881
3881
  .home-hero-actions {
3882
3882
  display: flex;
3883
3883
  align-items: center;
3884
- justify-content: flex-end;
3885
3884
  flex-wrap: wrap;
3886
- gap: 14px;
3885
+ gap: 26px;
3887
3886
  margin-left: auto;
3888
3887
  }
3889
3888
 
3890
3889
  .home-visit-metric {
3891
3890
  display: flex;
3892
3891
  flex-direction: column;
3893
- align-items: center;
3894
- gap: 3px;
3895
- padding: 9px 16px;
3896
- border: 1px solid #cddcec;
3897
- border-radius: 9px;
3898
- background: #f3f7fc;
3899
- color: #2f4358;
3900
- }
3901
-
3902
- .home-visit-label {
3903
- font-size: .72rem;
3904
- font-weight: 600;
3892
+ gap: 4px;
3893
+ padding-right: 26px;
3894
+ border-right: 1px solid #e5e7eb;
3905
3895
  }
3906
3896
 
3907
3897
  .home-visit-value {
3908
- font-size: 1.2rem;
3898
+ font-size: 1.9rem;
3899
+ font-weight: 750;
3909
3900
  line-height: 1.2;
3901
+ letter-spacing: -.04em;
3910
3902
  font-variant-numeric: tabular-nums;
3911
3903
  }
3912
3904
 
3905
+ .home-visit-label {
3906
+ font-size: .75rem;
3907
+ opacity: .6;
3908
+ }
3909
+
3913
3910
  .home-support-actions {
3914
3911
  display: flex;
3915
3912
  flex-direction: column;
3916
- gap: 9px;
3913
+ gap: 8px;
3917
3914
  }
3918
3915
 
3919
3916
  .home-support-button {
3920
3917
  display: inline-flex;
3921
3918
  align-items: center;
3922
- gap: 9px;
3923
- flex: 0 0 auto;
3924
- padding: 10px 17px;
3925
- border: 1px solid #e8c56b;
3926
- border-radius: 9px;
3927
- background: linear-gradient(135deg, #fff8e3, #ffe8a9);
3928
- color: #704912;
3929
- font-size: .92rem;
3930
- font-weight: 700;
3919
+ gap: 10px;
3920
+ min-height: 42px;
3921
+ padding: 8px 13px;
3922
+ border: 1px solid #e8d6ad;
3923
+ border-radius: 10px;
3924
+ background: #fcf5e7;
3925
+ color: #795520;
3926
+ font-size: .82rem;
3927
+ font-weight: 650;
3928
+ line-height: 1.4;
3931
3929
  text-decoration: none;
3932
- white-space: nowrap;
3933
- box-shadow: 0 3px 9px rgba(151, 104, 23, .10);
3934
- transition: background 150ms ease, box-shadow 150ms ease;
3930
+ transition: background 150ms ease, border-color 150ms ease, box-shadow 150ms ease;
3935
3931
  }
3936
3932
 
3937
3933
  .home-support-button:hover {
3938
- background: #ffe49a;
3939
- color: #58350b;
3934
+ background: #f8ecd2;
3935
+ border-color: #d7bd85;
3936
+ color: #674516;
3940
3937
  text-decoration: none;
3941
- box-shadow: 0 4px 12px rgba(151, 104, 23, .18);
3938
+ box-shadow: 0 2px 6px rgba(112, 79, 25, .08);
3939
+ }
3940
+
3941
+ .home-star-button {
3942
+ background: #fff;
3943
+ border-color: #e5e7eb;
3944
+ color: #343b46;
3945
+ }
3946
+
3947
+ .home-star-button:hover {
3948
+ background: #f5f6f8;
3949
+ border-color: #cbd0d8;
3950
+ color: #343b46;
3942
3951
  }
3943
3952
 
3944
3953
  .home-support-button:focus-visible {
@@ -3947,8 +3956,47 @@ html .single-workspace .representation-card {
3947
3956
  }
3948
3957
 
3949
3958
  .home-support-icon {
3950
- color: #ad6515;
3951
- font-size: 1.1rem;
3959
+ display: inline-flex;
3960
+ align-items: center;
3961
+ justify-content: center;
3962
+ width: 22px;
3963
+ font-size: 1.15rem;
3964
+ line-height: 1;
3965
+ }
3966
+
3967
+ .home-support-label {
3968
+ flex: 1;
3969
+ }
3970
+
3971
+ .home-support-arrow {
3972
+ font-size: 1rem;
3973
+ opacity: .45;
3974
+ }
3975
+
3976
+ @media (max-width: 575px) {
3977
+ html .home-page-hero {
3978
+ padding: 22px !important;
3979
+ gap: 22px;
3980
+ }
3981
+
3982
+ .home-hero-actions {
3983
+ justify-content: space-between;
3984
+ width: 100%;
3985
+ margin-left: 0;
3986
+ gap: 18px;
3987
+ }
3988
+
3989
+ .home-visit-metric {
3990
+ border-right: 0;
3991
+ padding-right: 0;
3992
+ flex-direction: row;
3993
+ align-items: baseline;
3994
+ gap: 10px;
3995
+ }
3996
+
3997
+ .home-support-actions {
3998
+ width: 100%;
3999
+ }
3952
4000
  }
3953
4001
 
3954
4002
  .admin-page {
@@ -4007,3 +4055,31 @@ html .single-workspace .representation-card {
4007
4055
  .population-page .documentation-table td { overflow-wrap: anywhere; }
4008
4056
  #population-status, #population-preview-error { margin-bottom: 12px; }
4009
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
  ],