Coverage for scanpath_studio/url_state.py: 95%

1388 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-07 21:10 +0000

1"""Deep links, plot-config save/restore, share links, and view-nav state. 

2 

3Split out of ``app.py`` so the URL/deep-link contract, the plot-config 

4save/restore round-trip, the Share-link builder/widget, and the 

5Corpus⇄Scanpath view toggle live in one focused module. ``app.py`` imports 

6these; nothing here imports back from ``app`` (no cycle). 

7""" 

8 

9from __future__ import annotations 

10 

11import copy 

12import json 

13import math 

14import re 

15from collections.abc import Callable, Iterable 

16from dataclasses import dataclass, field 

17from urllib.parse import urlencode 

18 

19import pandas as pd 

20import streamlit as st 

21 

22from scanpath_studio.html_embed import embed_html_iframe 

23 

24from .authoring import event_records_frame 

25from .code_snippet import ( 

26 INSTALL_COMMAND, 

27 SNIPPET_STATE_KEY, 

28 SOURCE_AUTHOR, 

29 SOURCE_BENCHMARK, 

30 SOURCE_DEMO, 

31 SOURCE_MULTIPLEYE, 

32 SOURCE_ONESTOP, 

33 SOURCE_POTEC, 

34 SOURCE_RAW_GAZE, 

35 SOURCE_SYNTHETIC, 

36 SOURCE_UNKNOWN, 

37 UNKNOWN_SOURCE_NOTE, 

38 FigureState, 

39 SnippetSource, 

40 reproduction_code, 

41 upload_source, 

42) 

43from .constants import ( 

44 _VIEW_DATA, 

45 _VIEW_SCANPATH, 

46 AUTHOR_CHOICE, 

47 BACKGROUND_PRESETS, 

48 COLORSCALES, 

49 CUSTOM_PALETTE, 

50 DEFAULT_HEATMAP_SIGMA_PX, 

51 DEMO_CHOICE, 

52 FIXATION_SYMBOLS, 

53 HEATMAP_SIGMA_BOUNDS, 

54 ICONS, 

55 LEGACY_MARKER_SIZE_SCALE, 

56 LEGEND_ARRANGEMENTS, 

57 LEGEND_POSITIONS, 

58 MANUAL_SAMPLE_CHOICE, 

59 MARKER_DURATION_BOUNDS, 

60 MARKER_SIZE_SCALES, 

61 MULTIPLEYE_BUNDLE_CHOICE, 

62 ONESTOP_CHOICE, 

63 ONESTOP_PART_LABELS, 

64 ONESTOP_REGIME_CHOICES, 

65 ONESTOP_REGIME_LABELS, 

66 ONESTOP_REGIME_SOURCE_TOKENS, 

67 ONESTOP_VARIANT_LABELS, 

68 PALETTES, 

69 PUBLIC_DATASETS_CHOICE, 

70 SACCADE_CLASS_EDITABLE, 

71 SACCADE_CLASS_ORDER, 

72 SACCADE_COLOR_MODES, 

73 SACCADE_DASH_OPTIONS, 

74 SACCADE_WIDTH_BOUNDS, 

75 SETUP_OVERRIDE_SESSION_KEYS, 

76 SYNTHETIC_CHOICE, 

77 UNIFORM_COLOR_FIELD, 

78 drift_correction_enabled, 

79 onestop_regime_for_choice, 

80 plural, 

81 preprocessing_enabled, 

82 upload_identity, 

83) 

84from .controls import ( 

85 _ALIGN_OPTIONS, 

86 _FIXCLASS_MODES, 

87 _OUT_OF_TEXT_MARKERS, 

88 claim_mapping, 

89 color_field_options, 

90 forget_color_range, 

91 numeric_field_options, 

92 palette_state, 

93) 

94from .data import composite_respelling_map, respell_reading 

95from .experimental_setup import format_provenance_param, parse_provenance_param 

96from .export import PRINT_DPI_BOUNDS, PRINT_WIDTH_BOUNDS 

97from .session_keys import ( 

98 COMPARE_FIX_RANGE_PARAM, 

99 COMPARE_LAYOUT_PARAM, 

100 COMPARE_PARAM, 

101 COMPARE_SCREEN_PARAM, 

102 COMPARE_SOURCE_PARAM, 

103 COMPARE_SOURCE_STATE_KEY, 

104 COMPARE_STIMULUS_PARAM, 

105 COMPARE_STYLE_PARAMS, 

106 EXPORT_PARAMS, 

107 FIX_RANGE_PARAM, 

108 LEGEND_PARAMS, 

109 LINK_SETUP_STATE_KEY, 

110 PARAM_CORPUS, 

111 PARAM_DATASET, 

112 PARAM_SHOW_TITLE_CAPTION, 

113 PENDING_COMPARE_STATE_KEY, 

114 PUBLIC_DATASET_CHOICE, 

115 SETUP_PARAMS, 

116 SETUP_PROVENANCE_PARAM, 

117 SETUP_PROVENANCE_STATE_KEY, 

118 SINGLE_ANIMATE, 

119 SINGLE_COMPARE_SCREEN_ID, 

120 SINGLE_COMPARE_TOGGLE, 

121) 

122 

123# URL query-param → session_state key map for the deep-link API. Used by 

124# `_apply_url_preset()` to preset widgets when the page is opened from an 

125# external tool with a deep link. 

126# 

127# Selection prefixes — the trial pickers a URL deep link seeds so the link lands 

128# on the requested trial. There's only the Scanpath view's `single` picker now: 

129# the Comparisons subtab (ENG-8) reuses that same selection instead of rendering 

130# its own `select_trial`, so there's no second `multi` picker to seed. Keep this list 

131# in sync with the `key_prefix=` values passed to `select_trial` in tabs.py. 

132_SELECTION_PREFIXES = ("single",) 

133 

134 

135def _coerce_bool(v) -> bool: 

136 return str(v).lower() not in {"0", "false", "no"} 

137 

138 

139def _parse_int_range(v) -> tuple: 

140 a, b = (int(float(x)) for x in str(v).split(",")[:2]) 

141 return (min(a, b), max(a, b)) 

142 

143 

144def _parse_float_range(v) -> tuple: 

145 a, b = (float(x) for x in str(v).split(",")[:2]) 

146 return (min(a, b), max(a, b)) 

147 

148 

149def _parse_field_list(v) -> list[str]: 

150 """Comma-separated hover fields carried by a Share link (VIZ-26).""" 

151 return [part.strip() for part in str(v).split(",") if part.strip()] 

152 

153 

154def _parse_saccade_classes(v) -> list[str]: 

155 """VIZ-31 saccade reading-class filter carried by a Share link. 

156 

157 Comma-separated class names (``regression,return_sweep``). An unknown name 

158 raises, so a link written against a build with different classes surfaces the 

159 reader's "Ignored the link's invalid …" warning instead of quietly showing a figure 

160 with the wrong saccades in it. The result is ordered by 

161 ``SACCADE_CLASS_ORDER`` to match what the multiselect writes. 

162 """ 

163 names = [part.strip() for part in str(v).split(",") if part.strip()] 

164 unknown = [n for n in names if n not in SACCADE_CLASS_ORDER] 

165 if unknown: 

166 raise ValueError(f"unknown saccade class: {', '.join(unknown)}") 

167 return [cls for cls in SACCADE_CLASS_ORDER if cls in set(names)] 

168 

169 

170#: The exact option spellings the two compare `st.segmented_control`s hold. 

171#: A segmented control raises when session state carries a value outside its 

172#: options, so a link's spelling has to be checked before it is seeded. 

173_COMPARE_LAYOUT_OPTIONS = ("Overlay", "Side by side", "Stacked") 

174_COMPARE_STIMULUS_OPTIONS = ("Both", "A", "B") 

175 

176 

177def _parse_choice(value, options: tuple[str, ...], what: str) -> str: 

178 """Match ``value`` case-insensitively against a closed vocabulary. 

179 

180 Raising (rather than falling back to the default) is what turns a mangled 

181 link into the reader's "Ignored the link's invalid …" warning instead of a wedged 

182 widget — the same contract `_parse_align_algorithm` follows. Hyphens are 

183 accepted for the layout so the CLI's `--compare-layout side-by-side` and the 

184 link agree on one spelling. 

185 """ 

186 name = str(value).strip().replace("-", " ") 

187 for option in options: 

188 if option.lower() == name.lower(): 

189 return option 

190 raise ValueError(f"unknown {what} {str(value)!r}") 

191 

192 

193def _parse_compare_layout(v) -> str: 

194 return _parse_choice(v, _COMPARE_LAYOUT_OPTIONS, "compare layout") 

195 

196 

197def _parse_compare_stimulus(v) -> str: 

198 return _parse_choice(v, _COMPARE_STIMULUS_OPTIONS, "compare stimulus source") 

199 

200 

201def _parse_marker_size_scale(v) -> str: 

202 return _parse_choice(v, tuple(MARKER_SIZE_SCALES), "marker size scale") 

203 

204 

205#: The one colour spelling every `st.color_picker` holds and every figure 

206#: builder accepts. The saved-config reader has always checked colours against 

207#: it; the deep link now does too (BUG-69). 

208_HEX_COLOR = re.compile(r"#[0-9A-Fa-f]{6}") 

209 

210 

211def _parse_hex_color(v) -> str: 

212 """A colour param → ``#rrggbb``, raising on anything else (BUG-69). 

213 

214 A colour reaches Plotly straight from session state on the render path, so 

215 ``?order_font_color=zzz`` used to raise inside the figure builder — before 

216 the picker that would have coerced it ever rendered. Raising here instead 

217 turns a mangled link into the reader's "Ignored the link's invalid …" warning. 

218 """ 

219 text = str(v).strip() 

220 if not _HEX_COLOR.fullmatch(text): 

221 raise ValueError(f"not a #rrggbb color: {text!r}") 

222 return text 

223 

224 

225#: A markup tag: `<` + a letter (or `/` + a letter) up to the next `>`. Plotly 

226#: draws a small HTML subset in a figure's title and caption, and `<a href>` in 

227#: it is a working link. Requiring a letter after `<` keeps a literal `<->` or 

228#: `a < b` in a title intact. 

229_MARKUP_TAG = re.compile(r"</?[A-Za-z][^<>]*>") 

230 

231 

232def _strip_markup(v) -> str: 

233 """``v`` with every markup tag removed (SEC6 / BUG-75). 

234 

235 For figure text that arrives from someone else — a share link, a saved 

236 config — which must not be able to put a clickable link to anywhere in the 

237 recipient's figure (``?title_pattern=<a href="…">Session expired</a>``). 

238 What a user types into the box themselves is theirs and is left alone. It 

239 repeats until nothing changes, so a tag split around another 

240 (``<<b>a href=…>``) cannot reassemble itself. 

241 """ 

242 text = str(v) 

243 while True: 

244 stripped = _MARKUP_TAG.sub("", text) 

245 if stripped == text: 

246 return text 

247 text = stripped 

248 

249 

250_COMPARE_ESCAPE = re.compile(r"\\([\\:])") 

251 

252 

253def compare_value(participant, trial) -> str: 

254 """``?compare=``'s value for scanpath B: ``<participant>:<trial>``. 

255 

256 A colon or backslash inside the participant is written ``\\:`` / ``\\\\``, 

257 so the first unescaped colon is always the separator, whatever the ids 

258 hold (round 10); everything after it is the trial, colons and all. An id 

259 without either reads exactly as before, so older links still restore.""" 

260 escaped = str(participant).replace("\\", "\\\\").replace(":", "\\:") 

261 return f"{escaped}:{trial}" 

262 

263 

264def parse_compare_value(raw) -> tuple[str, str] | None: 

265 """:func:`compare_value` read back: ``(participant, trial)``, or ``None`` 

266 when either is empty or there is no separator.""" 

267 text = str(raw or "") 

268 index = 0 

269 while index < len(text): 

270 if text[index] == "\\": 

271 index += 2 

272 continue 

273 if text[index] == ":": 

274 participant = _COMPARE_ESCAPE.sub(r"\1", text[:index]) 

275 trial = text[index + 1 :] 

276 return (participant, trial) if participant and trial else None 

277 index += 1 

278 return None 

279 

280 

281def _parse_playback_speed(v) -> float: 

282 """A replay speed → the ⚙ Playback slider's own option (EXP-18). 

283 

284 It is an `st.select_slider`, which raises on a value outside its options, 

285 so ``?playback_speed=3.3`` is rejected here (the "Ignored the link's invalid …" 

286 warning) rather than wedging the popover. The options belong to `tabs`, 

287 which imports this module — hence the import at call time. 

288 """ 

289 from scanpath_studio.tabs import _ANIM_SPEED_OPTIONS 

290 

291 speed = float(v) 

292 for option in _ANIM_SPEED_OPTIONS: 

293 if math.isclose(speed, option): 

294 return option 

295 raise ValueError(f"not a playback speed the slider offers: {v!r}") 

296 

297 

298# #374 F28: Export → Current figure's Width and DPI boxes take 

299# `PRINT_WIDTH_BOUNDS` / `PRINT_DPI_BOUNDS` (from export, shared by every surface). 

300 

301 

302def _parse_print_unit(v) -> str: 

303 """``mm`` or ``in``, the Width box's two units.""" 

304 return _parse_choice(str(v).strip().lower(), ("mm", "in"), "width unit") 

305 

306 

307def _parse_fixclass_mode(v) -> str: 

308 return _parse_choice(v, tuple(_FIXCLASS_MODES), "fixation-flag mode") 

309 

310 

311def _parse_fixclass_symbol(v) -> str: 

312 name = str(v).strip() 

313 if name not in _OUT_OF_TEXT_MARKERS: 

314 raise ValueError(f"unknown fixation-flag marker {name!r}") 

315 return name 

316 

317 

318def _parse_saccade_style_label(v) -> str: 

319 """A per-scanpath line style → the selectbox's own label (EXP-19). 

320 

321 Not `_parse_choice`, which reads a hyphen as a space for the compare layout's 

322 sake and so could never match *Dash-dot*.""" 

323 name = str(v).strip() 

324 for option in SACCADE_DASH_OPTIONS: 

325 if option.lower() == name.lower(): 

326 return option 

327 raise ValueError(f"unknown line style {name!r}") 

328 

329 

330def _parse_heatmap_style(value) -> str: 

331 """A heatmap style; the retired *Duration mass* opens as *Interpolated*, 

332 the smoothed style it was a variant of.""" 

333 if value == "Duration mass": 

334 return "Interpolated" 

335 if value not in ("Word boxes", "Interpolated"): 

336 raise ValueError(f"not one of the widget's options: {value!r}") 

337 return value 

338 

339 

340def _parse_colorbar_orientation(v) -> str: 

341 return _parse_choice(v, ("Vertical", "Horizontal"), "color bar orientation") 

342 

343 

344def _parse_align_algorithm(v) -> str: 

345 """PRE-3 drift-correction algorithm name → the picker's exact spelling. 

346 

347 The widget stores ``"Off"`` or a title-cased algorithm (``"Warp"``), and the 

348 selectbox raises if session state holds anything else — so an unknown name 

349 must be rejected here (the caller turns a ``ValueError`` into the "Ignored 

350 bad URL param" warning) rather than wedging the rail. Matching is 

351 case-insensitive, as ``cli.render --drift-correction`` is (ENG-22). 

352 """ 

353 name = str(v).strip() 

354 for option in _ALIGN_OPTIONS: 

355 if option.lower() == name.lower(): 

356 return option 

357 raise ValueError(f"unknown drift-correction algorithm {name!r}") 

358 

359 

360# --- Share-link parameter groups ------------------------------------------- 

361# The Share link round-trips the rail's figure settings — the layers, colours, 

362# sizes, fixation flags, colour bars and labels, plus the font family, line 

363# spacing and background — and the replay speed. Since EXP-19 it also carries 

364# what used to travel in the 💾 saved config alone: the recording setup (canvas, 

365# base font size, monitor mm, viewing distance, DPI, the point-size font) and 

366# Compare's per-scanpath `cmp{idx}_*` styles, as `cmp_a_*` / `cmp_b_*`. Those two 

367# groups (`session_keys.SETUP_PARAMS` / `COMPARE_STYLE_PARAMS`) are written only 

368# when they differ from what the recipient would resolve anyway — see 

369# `_link_defaults`. Each group maps a short URL key → the session_state key it 

370# reads/writes. 

371# `_build_share_query` (write) and `_apply_url_preset` (read) both iterate these, 

372# so the two sides can't drift. Data-dependent fields (color ranges, highlight 

373# column, axis/color-by fields) self-heal on load via the rail's _drop_stale, 

374# so a link opened on a different trial degrades gracefully; an explicit colour 

375# range is the sender's endpoints and is kept as given (`_explicit_pair`). 

376# 

377# EXP-19's per-scanpath styles are spelled per side: `cmp_a_<field>` is the first 

378# scanpath's `cmp0_<field>`, `cmp_b_<field>` the second's `cmp1_<field>`. 

379_CMP_STYLE_SIDES = (("a", 0), ("b", 1)) 

380 

381 

382def _cmp_style_params(*fields: str) -> dict[str, str]: 

383 return { 

384 f"cmp_{side}_{name}": f"cmp{idx}_{name}" 

385 for side, idx in _CMP_STYLE_SIDES 

386 for name in fields 

387 } 

388 

389 

390_SHARE_TOGGLE_PARAMS = { # bool → "1"/"0" 

391 "preproc_enabled": "global_preproc_enabled", 

392 "preproc_blink_adjacent": "global_preproc_blink_adjacent", 

393 "show_words": "global_show_words", 

394 "show_labels": "global_show_labels", 

395 # UX-128: the 📄 Stimulus section's master switch (default on, so always 

396 # emitted — a link/config that predates this toggle restores as "on"). 

397 "show_stimulus": "global_show_stimulus", 

398 "show_fixations": "global_show_fix", 

399 "show_order": "global_show_order", 

400 "show_saccades": "global_show_saccades", 

401 "show_saccade_arrows": "global_show_saccade_arrows", 

402 # VIZ-8: saccade-type colour key (default on, so always emitted). 

403 "saccade_type_legend": "global_saccade_type_legend", 

404 # The fixed duration scale's size key (default on, so always emitted). 

405 "duration_size_legend": "global_duration_size_legend", 

406 "snap_fixations": "global_fixation_snap_to_word", 

407 # PRE-3 / ENG-23: the drift-correction connector layer. Its algorithm rides 

408 # in `_SHARE_VALUE_PARAMS` below — both, or a shared corrected view reopens 

409 # uncorrected. 

410 "align_connectors": "global_align_connectors", 

411 # VIZ-10: autoplay the animated replay on load (default on, so always emitted). 

412 "anim_autoplay": "global_anim_autoplay", 

413 "show_heatmap": "global_show_heatmap", 

414 "show_raw_gaze": "global_show_raw_gaze", 

415 # Each colour scale's bar has its own switch; the one they shared before, 

416 # `show_colorbars`, is read below as both (with the old style params). 

417 "show_fixation_colorbar": "global_show_fixation_colorbar", 

418 "show_heatmap_colorbar": "global_show_heatmap_colorbar", 

419 "heatmap_sigma_auto": "global_heatmap_sigma_auto", 

420 "coordinate_grid": "global_show_coordinate_grid", 

421 "coordinate_grid_auto": "global_coordinate_grid_auto", 

422 "hollow_fixations": "global_hollow_fixations", 

423 "scale_text_to_boxes": "global_scale_text_to_boxes", 

424 # EXP-5: title and caption on the figure — each off by default. The one 

425 # switch they shared before, `show_title_caption`, is read below. 

426 "show_title": "global_show_title", 

427 "show_caption": "global_show_caption", 

428 # EXP-18: three switches that change the figure and never rode the link — 

429 # the stimulus-image layer, Show full monitor, and Compare's A/B legend. 

430 "show_stimulus_image": "global_show_stimulus_image", 

431 "fit_to_monitor": "global_fit_to_monitor", 

432 "show_compare_legend": "global_show_compare_legend", 

433 # EXP-19: the point-size font switch, and the per-scanpath hollow markers 

434 # (no widget since VIZ-6, but a saved config still sets them). 

435 "use_stimulus_font_pt": "global_use_stimulus_font_pt", 

436 **_cmp_style_params("hollow"), 

437 # #373: the chips above the plot, shown or hidden. Not part of the figure, 

438 # so the settings file leaves it out (UX-179) — only the link carries it. 

439 "show_chips": "single_show_chips", 

440} 

441_SHARE_VALUE_PARAMS = { # string / choice / color → str (emitted only when set) 

442 # #374 F28: Export → Current figure's print size (only while a width is set). 

443 "export_width_unit": "export_figure_width_unit", 

444 "preproc_short_policy": "global_preproc_short_policy", 

445 "color_by": "global_color_by", 

446 "heatmap_style": "global_heatmap_style", 

447 "heatmap_norm": "global_heatmap_norm", 

448 "heatmap_metric": "global_heatmap_metric", 

449 "critical_span_style": "global_critical_span_style", 

450 "highlight_column": "global_highlight_column", 

451 "x_field": "global_x_field", 

452 "y_field": "global_y_field", 

453 "saccade_style": "global_saccade_style", 

454 "saccade_render_mode": "global_saccade_render_mode", 

455 # The duration scale: always emitted (it is seeded). A link carrying layer 

456 # toggles but no scale opens on the relative one — see `_apply_url_preset`. 

457 "marker_size_scale": "global_marker_size_scale", 

458 "illustration_label": "global_illustration_label", 

459 "illustration_text": "global_illustration_text", 

460 # PRE-3 / ENG-23: vertical drift correction ("Off" or a Carr et al. (2021) 

461 # algorithm). Since VIZ-23 it applies on all three render paths, so a link 

462 # that dropped it reopened a visibly different figure. 

463 "align_algorithm": "global_align_algorithm", 

464 # VIZ-15 marker shape · VIZ-17 uniform fixation colour · VIZ-18 palette. The 

465 # palette is a *preset* — the colours it implies ride in the individual params 

466 # below — so it's expanded first and any explicit colour in the same link 

467 # wins (see `_apply_url_palette`). 

468 "fixation_symbol": "global_fixation_symbol", 

469 "fixation_color": "global_fixation_color", 

470 "palette": "global_palette", 

471 "fixation_colorscale": "global_fixation_colorscale", 

472 "heatmap_colorscale": "global_heatmap_colorscale", 

473 "saccade_color": "global_saccade_color", 

474 # UX-86: raw gaze's own style. 

475 "raw_gaze_color": "global_raw_gaze_color", 

476 # ⬚ Word boxes' outline and fill colours (the fill's opacity is a float). 

477 "word_box_color": "global_word_box_color", 

478 "word_box_fill_color": "global_word_box_fill_color", 

479 # VIZ-8: colour-by-reading-type mode + the five class colours. 

480 "saccade_color_mode": "global_saccade_color_mode", 

481 "saccade_color_forward": "global_saccade_class_color_forward", 

482 "saccade_color_skip": "global_saccade_class_color_skip", 

483 "saccade_color_refixation": "global_saccade_class_color_refixation", 

484 "saccade_color_return_sweep": "global_saccade_class_color_return_sweep", 

485 "saccade_color_regression": "global_saccade_class_color_regression", 

486 # VIZ-31: the reading-class *filter* (which classes are drawn at all), as a 

487 # comma-separated list — the generic writer below already joins a list value, 

488 # and `_URL_PRESETS` overrides the read side with a validating parser. 

489 "saccade_classes": "global_saccade_classes", 

490 "order_font_color": "global_order_font_color", 

491 "text_color": "global_text_color", 

492 "highlight_text_color": "global_highlight_text_color", 

493 "bg_choice": "global_bg_choice", 

494 "bg_custom": "global_bg_custom", 

495 "font_family": "global_font_family", 

496 "word_hover_measure": "global_word_hover_measure", 

497 "word_hover_fields": "global_word_hover_fields", 

498 "fixation_hover_fields": "global_fixation_hover_fields", 

499 # EXP-5: the pattern strings themselves — meaningless while 

500 # `show_title_caption` is off, but carried unconditionally like every other 

501 # value param (the reader only applies them once the toggle is on). 

502 "title_pattern": "global_title_pattern", 

503 "caption_pattern": "global_caption_pattern", 

504 # CMP-11: compare mode's own two settings. CMP-8 put `compare=<pid>:<trial>` 

505 # and `cmp_source` on the link but neither of these, so a shared comparison 

506 # always reopened as Overlay — and, cross-dataset, immediately resolved away 

507 # from it. Both read sides are overridden in `_URL_PRESETS` with validating 

508 # parsers, since each is a closed vocabulary. 

509 "cmp_layout": "single_compare_layout", 

510 "cmp_stimulus": "single_compare_stimulus", 

511 # EXP-18: colour-bar orientation, the span's border colour, and the PRE-2 

512 # fixation flags. *Discard* changes which fixations are drawn at all, so a 

513 # link without it showed the recipient a different scanpath — and without 

514 # the Illustration label the sender's figure carried. 

515 "fixation_colorbar_orientation": "global_fixation_colorbar_orientation", 

516 "heatmap_colorbar_orientation": "global_heatmap_colorbar_orientation", 

517 "span_border_color": "global_span_border_color", 

518 **{ 

519 f"fixclass_{cat}_{part}": f"global_fixclass_{cat}_{part}" 

520 for cat in ("short", "long", "oob", "blink") 

521 for part in ("mode", "symbol", "color") 

522 }, 

523 # EXP-19: Compare's per-scanpath colours, line style and legend label. 

524 **_cmp_style_params( 

525 "fix_color", 

526 "saccade_color", 

527 "saccade_style", 

528 "label_pattern", 

529 "box_color", 

530 "box_fill_color", 

531 "raw_gaze_color", 

532 "heatmap_colorscale", 

533 ), 

534 # CMP-24: scanpath B's own filters — which classes it draws, and each fixation 

535 # flag's mode. A's are the ordinary `saccade_classes` / `fixclass_*` above. 

536 "cmp_b_saccade_classes": "cmp1_saccade_classes", 

537 **{ 

538 f"cmp_b_fixclass_{cat}_mode": f"cmp1_fixclass_{cat}_mode" 

539 for cat in ("short", "long", "oob", "blink") 

540 }, 

541} 

542#: The `_SHARE_VALUE_PARAMS` that carry a colour — read through 

543#: `_parse_hex_color` rather than `str` (BUG-69). 

544_SHARE_COLOR_PARAMS = ( 

545 "fixation_color", 

546 "saccade_color", 

547 "raw_gaze_color", 

548 "word_box_color", 

549 "word_box_fill_color", 

550 "saccade_color_forward", 

551 "saccade_color_skip", 

552 "saccade_color_refixation", 

553 "saccade_color_return_sweep", 

554 "saccade_color_regression", 

555 "order_font_color", 

556 "text_color", 

557 "highlight_text_color", 

558 "bg_custom", 

559 "span_border_color", 

560 "fixclass_short_color", 

561 "fixclass_long_color", 

562 "fixclass_oob_color", 

563 "fixclass_blink_color", 

564 *_cmp_style_params( 

565 "fix_color", "saccade_color", "box_color", "box_fill_color", "raw_gaze_color" 

566 ), 

567) 

568_SHARE_INT_PARAMS = { 

569 "export_dpi": "export_figure_dpi", 

570 "order_font_size": "global_order_font_size", 

571 # VIZ-11 follow-up: the animation frame grid. Worth sharing — a link that 

572 # says "look at this replay" should reproduce the same smoothness. 

573 "anim_grid_step_ms": "global_anim_grid_step_ms", 

574 "anim_max_frames": "global_anim_max_frames", 

575 # EXP-18: colour-bar tick styling and the two fixation-flag thresholds. 

576 "fixation_colorbar_tickangle": "global_fixation_colorbar_tickangle", 

577 "fixation_colorbar_tickfont_size": "global_fixation_colorbar_tickfont_size", 

578 "heatmap_colorbar_tickangle": "global_heatmap_colorbar_tickangle", 

579 "heatmap_colorbar_tickfont_size": "global_heatmap_colorbar_tickfont_size", 

580 "fixclass_short_threshold_ms": "global_fixclass_short_threshold_ms", 

581 "fixclass_long_threshold_ms": "global_fixclass_long_threshold_ms", 

582 # EXP-19: the pixel canvas and the base font — the recording setup's half 

583 # that every figure is drawn at. 

584 "canvas_width": "global_canvas_width", 

585 "canvas_height": "global_canvas_height", 

586 "base_font_size": "global_base_font_size", 

587 # CMP-24: B's two fixation-flag thresholds. 

588 "cmp_b_fixclass_short_threshold_ms": "cmp1_fixclass_short_threshold_ms", 

589 "cmp_b_fixclass_long_threshold_ms": "cmp1_fixclass_long_threshold_ms", 

590} 

591_SHARE_FLOAT_PARAMS = { 

592 "export_width": "export_figure_width", 

593 "preproc_short_threshold_ms": "global_preproc_short_threshold_ms", 

594 "preproc_merge_distance_chars": "global_preproc_merge_distance_chars", 

595 "line_spacing": "global_line_spacing", 

596 "saccade_width": "global_saccade_width", 

597 "fixation_opacity": "global_fixation_opacity", 

598 # The Interpolated heatmap's fixed blur σ (px); its Auto switch is a toggle. 

599 "heatmap_sigma_px": "global_heatmap_sigma_px", 

600 # VIZ-4: image-stimulus opacity (applies to dataset images too, so worth 

601 # sharing; the uploaded image itself can't ride a link). 

602 "stimulus_image_opacity": "global_stimulus_image_opacity", 

603 # VIZ-4: manual image alignment — origin nudge + size scale (apply to dataset 

604 # images, so they round-trip; an uploaded image is re-uploaded on the far end). 

605 "stimulus_image_offset_x": "global_stimulus_image_offset_x", 

606 "stimulus_image_offset_y": "global_stimulus_image_offset_y", 

607 "stimulus_image_scale": "global_stimulus_image_scale", 

608 "coordinate_grid_spacing": "global_coordinate_grid_spacing", 

609 # UX-86: raw gaze's own style. 

610 "raw_gaze_marker_size": "global_raw_gaze_marker_size", 

611 "raw_gaze_opacity": "global_raw_gaze_opacity", 

612 "word_box_line_opacity": "global_word_box_line_opacity", 

613 "word_box_fill_opacity": "global_word_box_fill_opacity", 

614 # EXP-18: the replay speed. A non-1× speed stamps an Illustration label, so 

615 # a link without it reopened a figure that disclosed something else. 

616 "playback_speed": "single_playback_speed", 

617 # EXP-19: the physical half of the recording setup (px/degree, and the 

618 # point-to-pixel conversion of a font given in points), plus Compare's 

619 # per-scanpath line width and marker opacity. 

620 "monitor_width_mm": "global_monitor_width_mm", 

621 "viewing_distance_mm": "global_viewing_distance_mm", 

622 "display_dpi": "global_display_dpi", 

623 "stimulus_font_pt": "global_stimulus_font_pt", 

624 **_cmp_style_params("saccade_width", "opacity"), 

625} 

626_SHARE_INT_RANGE_PARAMS = { 

627 "marker_size_range": "global_marker_size_range", 

628 "marker_duration_range": "global_marker_duration_range", 

629 # VIZ-40 (UX-135) closed VIZ-7's last surface gap: the window is now 

630 # linkable. Read like any other "lo,hi" range — `controls`' slider already 

631 # treats a value present before it first renders as explicit and clamps it 

632 # to the recipient's own trial. The **write** side is not generic, though: 

633 # see the `FIX_RANGE_PARAM` block in `_build_share_query`. 

634 FIX_RANGE_PARAM: "single_fix_range", 

635 # EXP-19. 

636 **_cmp_style_params("marker_size_range"), 

637 # CMP-24 — B's own window. Written on A's terms: see the 

638 # `COMPARE_FIX_RANGE_PARAM` block in `_build_share_query`. 

639 COMPARE_FIX_RANGE_PARAM: "single_compare_fix_range", 

640} 

641_SHARE_FLOAT_RANGE_PARAMS = { 

642 "fixation_color_range": "global_fixation_color_range", 

643 "heatmap_color_range": "global_heatmap_color_range", 

644} 

645 

646#: PRE-21: URL params that belong to a gated feature. Each maps to the predicate 

647#: that says whether it is exposed; while it isn't, the param is neither read nor 

648#: emitted. Kept *in* the contract (`session_keys.py` still pins it, the parser 

649#: still knows how to read it) — this is a visibility gate, not a wire-format 

650#: change, so turning the flag on makes existing links work again. 

651_GATED_URL_PARAMS = { 

652 "align_algorithm": drift_correction_enabled, 

653 "align_connectors": drift_correction_enabled, 

654} 

655 

656 

657def _parse_colorscale(value: str) -> str: 

658 """One of the app's colour scales, else ``ValueError`` (the reader's "Ignored 

659 bad URL param" warning) rather than a name the rail's picker cannot show.""" 

660 if value not in COLORSCALES: 

661 raise ValueError(f"not one of the app's color scales: {value!r}") 

662 return value 

663 

664 

665_URL_PRESETS = { 

666 # Booleans (read side of _SHARE_TOGGLE_PARAMS) + the legacy aliases. 

667 "hide_fixation_numbers": ("global_show_order", lambda v: not _coerce_bool(v)), 

668 **{k: (s, _coerce_bool) for k, s in _SHARE_TOGGLE_PARAMS.items()}, 

669 # Strings / choices / colors. 

670 **{k: (s, str) for k, s in _SHARE_VALUE_PARAMS.items()}, 

671 # Numbers + ranges. 

672 **{k: (s, int) for k, s in _SHARE_INT_PARAMS.items()}, 

673 **{k: (s, float) for k, s in _SHARE_FLOAT_PARAMS.items()}, 

674 **{k: (s, _parse_int_range) for k, s in _SHARE_INT_RANGE_PARAMS.items()}, 

675 **{k: (s, _parse_float_range) for k, s in _SHARE_FLOAT_RANGE_PARAMS.items()}, 

676 # Validated choice (must come after the generic `str` sweep above, which 

677 # covers the same param): the drift-correction picker rejects any value 

678 # outside its options, so the link's spelling is checked here (ENG-23). 

679 "align_algorithm": ("global_align_algorithm", _parse_align_algorithm), 

680 "word_hover_fields": ("global_word_hover_fields", _parse_field_list), 

681 "fixation_hover_fields": ("global_fixation_hover_fields", _parse_field_list), 

682 # VIZ-31 saccade reading-class filter — validated like `align_algorithm` 

683 # above, and for the same reason: the multiselect raises on a value outside 

684 # its options, so an unknown class name has to be rejected here rather than 

685 # wedging the rail. 

686 "saccade_classes": ("global_saccade_classes", _parse_saccade_classes), 

687 "cmp_b_saccade_classes": ("cmp1_saccade_classes", _parse_saccade_classes), 

688 # CMP-11 — same rule again: both are `st.segmented_control` options. 

689 "cmp_layout": ("single_compare_layout", _parse_compare_layout), 

690 "cmp_stimulus": ("single_compare_stimulus", _parse_compare_stimulus), 

691 "marker_size_scale": ("global_marker_size_scale", _parse_marker_size_scale), 

692 # BUG-69 — and for every colour, which Plotly rejects outright. 

693 **{k: (_SHARE_VALUE_PARAMS[k], _parse_hex_color) for k in _SHARE_COLOR_PARAMS}, 

694 # BUG-75 — figure text from a link is text, never markup. 

695 "title_pattern": ("global_title_pattern", _strip_markup), 

696 "caption_pattern": ("global_caption_pattern", _strip_markup), 

697 "illustration_text": ("global_illustration_text", _strip_markup), 

698 "heatmap_style": ("global_heatmap_style", _parse_heatmap_style), 

699 # #374 F28 — a closed vocabulary, like the rest. 

700 "export_width_unit": ("export_figure_width_unit", _parse_print_unit), 

701 # Compare's per-scanpath heatmap colour scale: an app colour scale only. 

702 **{ 

703 param: (key, _parse_colorscale) 

704 for param, key in _cmp_style_params("heatmap_colorscale").items() 

705 }, 

706 # EXP-18 — the settings that joined the link, each a closed vocabulary. 

707 "playback_speed": ("single_playback_speed", _parse_playback_speed), 

708 **{ 

709 f"{bar}_colorbar_orientation": ( 

710 f"global_{bar}_colorbar_orientation", 

711 _parse_colorbar_orientation, 

712 ) 

713 for bar in ("fixation", "heatmap") 

714 }, 

715 **{ 

716 f"fixclass_{cat}_{part}": (f"global_fixclass_{cat}_{part}", parse) 

717 for cat in ("short", "long", "oob", "blink") 

718 for part, parse in ( 

719 ("mode", _parse_fixclass_mode), 

720 ("symbol", _parse_fixclass_symbol), 

721 ) 

722 }, 

723 # CMP-24 — B's flag modes, the same closed vocabulary as A's. 

724 **{ 

725 f"cmp_b_fixclass_{cat}_mode": ( 

726 f"cmp1_fixclass_{cat}_mode", 

727 _parse_fixclass_mode, 

728 ) 

729 for cat in ("short", "long", "oob", "blink") 

730 }, 

731 # EXP-19 — the per-scanpath line style is a selectbox (raises on anything 

732 # else), and the legend label is figure text from someone else (BUG-75). 

733 **{ 

734 param: (state_key, _parse_saccade_style_label) 

735 for param, state_key in _cmp_style_params("saccade_style").items() 

736 }, 

737 **{ 

738 param: (state_key, _strip_markup) 

739 for param, state_key in _cmp_style_params("label_pattern").items() 

740 }, 

741} 

742 

743# Static widget bounds, mirrored from controls.render_plot_controls / 

744# render_canvas_controls, so a restored value is clamped to a range the 

745# widget will accept. 

746_CANVAS_BOUNDS = (100, 10000) 

747_FONT_BOUNDS = (6, 72) 

748_MARKER_BOUNDS = (4, 40) 

749 

750# Widget bounds for the URL-restorable params that feed a min/max-bounded widget 

751# (slider / number_input). A hand-crafted link with an out-of-range value would 

752# otherwise crash the widget on render — Streamlit raises when a Session-State 

753# value falls outside the widget's range. Clamp on the way in. (Data-dependent 

754# colour ranges aren't here — the rail's slider widens to hold them, and its 

755# number boxes are unbounded.) 

756_URL_BOUNDED = { 

757 "global_preproc_short_threshold_ms": (1.0, 500.0), 

758 "global_preproc_merge_distance_chars": (0.25, 10.0), 

759 "global_heatmap_sigma_px": HEATMAP_SIGMA_BOUNDS, 

760 "global_line_spacing": (1.0, 10.0), 

761 "global_saccade_width": SACCADE_WIDTH_BOUNDS, 

762 "global_order_font_size": (6, 72), 

763 "global_anim_grid_step_ms": (20, 500), 

764 "global_anim_max_frames": (30, 2000), 

765 "global_marker_size_range": (4, 40), 

766 "global_marker_duration_range": MARKER_DURATION_BOUNDS, 

767 "global_fixation_opacity": (0.1, 1.0), 

768 "global_stimulus_image_opacity": (0.1, 1.0), 

769 # VIZ-4: image-alignment nudge — clamp a hand-crafted link to sane ranges. 

770 "global_stimulus_image_offset_x": (-5000.0, 5000.0), 

771 "global_stimulus_image_offset_y": (-5000.0, 5000.0), 

772 "global_stimulus_image_scale": (0.25, 3.0), 

773 "global_coordinate_grid_spacing": (10.0, 5000.0), 

774 # UX-86 put raw gaze's style on the link without its bounds (BUG-69), so 

775 # `?raw_gaze_opacity=5` crashed the slider. Mirrors controls.py's widgets. 

776 "global_raw_gaze_marker_size": (1.0, 12.0), 

777 "global_raw_gaze_opacity": (0.1, 1.0), 

778 # 0 is a real choice for both — outlines only / fill only. 

779 "global_word_box_line_opacity": (0.0, 1.0), 

780 "global_word_box_fill_opacity": (0.0, 1.0), 

781 # EXP-18: the colour-bar tick sliders, and the fixation-flag thresholds — 

782 # a `number_input` with only a minimum, capped at a minute here so a link 

783 # cannot carry a number no fixation reaches. 

784 **{ 

785 key: bounds 

786 for bar in ("fixation", "heatmap") 

787 for key, bounds in ( 

788 (f"global_{bar}_colorbar_tickangle", (-90, 90)), 

789 (f"global_{bar}_colorbar_tickfont_size", (6, 20)), 

790 ) 

791 }, 

792 "global_fixclass_short_threshold_ms": (1, 60_000), 

793 "global_fixclass_long_threshold_ms": (1, 60_000), 

794 "cmp1_fixclass_short_threshold_ms": (1, 60_000), 

795 "cmp1_fixclass_long_threshold_ms": (1, 60_000), 

796 # EXP-19: the recording setup and the per-scanpath styles, which used to be 

797 # saved-config only and clamped by `_CONFIG_BOUNDED` alone. Mirrors the 

798 # widgets (`app.render_canvas_controls`, `controls._render_compare_*`). 

799 "global_canvas_width": _CANVAS_BOUNDS, 

800 "global_canvas_height": _CANVAS_BOUNDS, 

801 "global_base_font_size": _FONT_BOUNDS, 

802 "global_monitor_width_mm": (100.0, 3000.0), 

803 "global_viewing_distance_mm": (100.0, 3000.0), 

804 "global_display_dpi": (20.0, 1000.0), 

805 # #374 F28 — mirrors the Export subtab's number boxes. 

806 "export_figure_width": PRINT_WIDTH_BOUNDS, 

807 "export_figure_dpi": PRINT_DPI_BOUNDS, 

808 "global_stimulus_font_pt": (4.0, 144.0), 

809 **{f"cmp{i}_opacity": (0.1, 1.0) for i in (0, 1)}, 

810 **{f"cmp{i}_saccade_width": SACCADE_WIDTH_BOUNDS for i in (0, 1)}, 

811 **{f"cmp{i}_marker_size_range": _MARKER_BOUNDS for i in (0, 1)}, 

812} 

813 

814 

815#: EXP-19 — the settings a source's own declared monitor or typeface overwrites 

816#: the first time that source is seeded (`app.seed_canvas_state`: the canvas 

817#: pair, and `app._FONT_SNAP_KEYS`). A link that carries one names it under 

818#: `LINK_SETUP_STATE_KEY`, so the snap keeps the sender's value. A recording 

819#: setup the recipient saved for that dataset (`app._apply_setup_override`) is 

820#: applied on the same first seeding, and keeps a linked value the same way. 

821_SOURCE_SNAPPED_KEYS = frozenset( 

822 { 

823 "global_canvas_width", 

824 "global_canvas_height", 

825 "global_base_font_size", 

826 "global_font_family", 

827 "global_scale_text_to_boxes", 

828 *SETUP_OVERRIDE_SESSION_KEYS, 

829 } 

830) 

831 

832 

833def _clamp_url_value(state_key: str, value): 

834 """Clamp a deep-linked value to its widget bounds (scalars and 2-tuples).""" 

835 bounds = _URL_BOUNDED.get(state_key) 

836 if bounds is None: 

837 return value 

838 lo, hi = bounds 

839 if isinstance(value, (tuple, list)) and len(value) == 2: 

840 a, b = max(lo, min(value[0], hi)), max(lo, min(value[1], hi)) 

841 return (min(a, b), max(a, b)) 

842 return max(lo, min(value, hi)) 

843 

844 

845# data_choice → ?source= value, for the built-in sources a URL can fully rebuild. 

846# Sources absent here (uploaded tables, stored datasets) can't be reconstructed 

847# from a link — the Share panel warns and shares the view settings only. Public 

848# corpora are covered by the generic `corpus` token below instead of one entry 

849# each. Mirrors the `source` handling in `main()`. 

850_SHAREABLE_SOURCES = { 

851 AUTHOR_CHOICE: "author", 

852 MANUAL_SAMPLE_CHOICE: "author", 

853 DEMO_CHOICE: "demo", 

854 ONESTOP_CHOICE: "onestop", 

855 MULTIPLEYE_BUNDLE_CHOICE: "multipleye", 

856 SYNTHETIC_CHOICE: "synthetic", 

857 # DATA-3: the public OneStop corpus (OSF download-on-demand) is shareable too. 

858 # DATA-63: one dataset per regime, each its own token (`onestop_<regime>`). 

859 # A DATA-3 link (`onestop_public` + `onestop_regime`) is read by `app.main`. 

860 **{ 

861 ONESTOP_REGIME_CHOICES[regime]: token 

862 for regime, token in ONESTOP_REGIME_SOURCE_TOKENS.items() 

863 }, 

864} 

865 

866 

867def _source_choice_for_param(value) -> str | None: 

868 """Invert `_SHAREABLE_SOURCES`: a ``?source=``-style token → the data choice. 

869 

870 Used by CMP-8's `cmp_source`, which names scanpath **B's** corpus in the 

871 same vocabulary. An unknown token returns ``None`` — the link then simply 

872 doesn't move the comparison dataset, which is a safe degrade rather than a 

873 wedged picker. 

874 """ 

875 if not value: 

876 return None 

877 token = str(value).lower() 

878 for choice, param in _SHAREABLE_SOURCES.items(): 

879 if param == token: 

880 return choice 

881 return None 

882 

883 

884# --------------------------------------------------------------------------- 

885# DATA-27 (Task 12): every public corpus on the link — `?source=corpus&corpus=…` 

886# 

887# `_SHAREABLE_SOURCES` above works for sources whose *identity is the token*. 

888# The public corpora can't: `app.public_dataset_registry()` is the built-in 

889# corpora **∪ one entry per harmonised benchmark corpus the user added**, a 

890# catalogue that varies per machine, so there is no fixed token per corpus to 

891# freeze. One generic token names the kind and a second param names the corpus. 

892# 

893# Deliberately generic (R42): a benchmark-only branch would have to re-derive 

894# which registry entry produced the picker's collapsed choice, duplicating 

895# `app.resolve_data_source`'s healing logic — and "these corpora are 

896# special" is the assumption this plan has been bitten by repeatedly. A built-in 

897# corpus and a prepared one are the same kind of thing here. 

898CORPUS_SOURCE_TOKEN = "corpus" 

899 

900# What gets slugged is the entry's **stable identifier** — a prepared corpus' 

901# manifest `name`, a built-in's registry `short` — never its display label, which 

902# carries em-dashes and "(harmonised benchmark)" and is the thing most likely to 

903# be reworded. A link has to survive a rewording. 

904# 

905# Prepared corpora are namespaced with this prefix because the two identifier 

906# spaces overlap: PoTeC and OneStop each ship *both* natively and harmonised, and 

907# both entries are kept on purpose, so bare slugs would collide and a link would 

908# silently open the wrong corpus — the worst failure this feature can have. The 

909# prefix is a constant in code, so it is unaffected by any relabelling, and it 

910# names the property that actually differs (a re-derived harmonisation of the 

911# publisher's release) rather than the pipeline that produced it. 

912_PREPARED_CORPUS_SLUG_PREFIX = "harmonised-" 

913 

914 

915def _slugify_corpus(value: str) -> str: 

916 """Lowercase, ASCII-safe, hyphen-joined form of a corpus identifier.""" 

917 return re.sub(r"[^a-z0-9]+", "-", str(value).strip().lower()).strip("-") 

918 

919 

920def corpus_slug(label: str, spec) -> str: 

921 """The ``?corpus=`` slug for one `public_dataset_registry()` entry, or ``""``. 

922 

923 Empty for an identifier with nothing sluggable in it. A manifest ``name`` 

924 written in a non-Latin script slugifies to ``""``, and returning the bare 

925 namespace prefix for it would give *every* such corpus the same slug **and** 

926 one the reader can never match (it re-slugifies its input, which strips the 

927 trailing hyphen). Not shareable is honest, and is already a supported state; 

928 a slug naming several corpora is the failure this scheme exists to prevent. 

929 """ 

930 # `benchmark_dataset` is the manifest `name`, put on the spec by Task 11R 

931 # precisely as the stable identifier for this wire format. 

932 if dataset := str(spec.get("benchmark_dataset") or "").strip(): 

933 slug = _slugify_corpus(dataset) 

934 return f"{_PREPARED_CORPUS_SLUG_PREFIX}{slug}" if slug else "" 

935 return _slugify_corpus(str(spec.get("short") or label)) 

936 

937 

938def registry_corpus_slugs() -> dict[str, str]: 

939 """``registry label -> slug`` for every corpus a link can name right now. 

940 

941 A slug **two** entries would claim is dropped from both — so it is neither 

942 emitted nor resolvable, and the link degrades onto the existing "doesn't move 

943 the picker" path. The namespace prefix stops the collision this catalogue is 

944 known to have (PoTeC and OneStop each ship natively *and* harmonised) from 

945 arising at all, but avoidance is not detection, and three ways in remain: 

946 `_slugify_corpus` is not injective (``ZuCo-1`` and ``ZuCo 1`` slug alike, and 

947 near-identical names are the norm here — ``MECOL1W1``/``MECOL1W2``/ 

948 ``MECOL2W1``/``MECOL2W2``, ``ZuCo1``/``ZuCo2``); nothing reserves the prefix 

949 against a future built-in whose ``short`` is "Harmonised Foo"; and a bundle 

950 can hold two corpora whose names differ only in punctuation. Refusing to 

951 answer costs the recipient one link. Picking a winner opens the wrong 

952 corpus, silently, which is the worst failure this feature can have. 

953 

954 Reads `app.public_dataset_registry()` — the *function*, never the static 

955 `PUBLIC_DATASET_REGISTRY` dict, which answers for the three built-ins only. 

956 The import is inside the function because `app` imports this module. 

957 """ 

958 from scanpath_studio.app import public_dataset_registry 

959 

960 claimed: dict[str, list] = {} 

961 for label, spec in public_dataset_registry().items(): 

962 if slug := corpus_slug(label, spec): 

963 claimed.setdefault(slug, []).append(str(label)) 

964 return {labels[0]: slug for slug, labels in claimed.items() if len(labels) == 1} 

965 

966 

967def corpus_choice_for_slug(value) -> str | None: 

968 """A ``?corpus=`` slug → its registry label, or ``None``. 

969 

970 ``None`` covers "this reader's bundle doesn't hold that corpus" (the common 

971 case — the recipient has no bundle, or a different subset of one), an unknown 

972 slug, and a slug two entries would answer to (`registry_corpus_slugs` has 

973 already dropped that one). All degrade the way `_source_choice_for_param` 

974 does: the link simply doesn't move the picker. Never guess a near match — a 

975 slug resolving to the wrong corpus opens the wrong data silently. 

976 """ 

977 if not value: 

978 return None 

979 slug = _slugify_corpus(value) 

980 for label, known in registry_corpus_slugs().items(): 

981 if known == slug: 

982 return label 

983 return None 

984 

985 

986def _selected_corpus(data_choice: str) -> tuple[str, dict]: 

987 """Which registry corpus a share is describing: ``(label, spec)``. 

988 

989 ``("", {})`` when the active source is not a public corpus. 

990 

991 `app.resolve_data_source` collapses **any** registry label to 

992 `PUBLIC_DATASETS_CHOICE` and stashes the label on `public_dataset_choice`, so 

993 that is where the answer lives for a link built from the running app. A 

994 caller holding the label itself (the tests, and anything predating the 

995 collapse) is honoured as-is. 

996 """ 

997 from scanpath_studio.app import public_dataset_registry 

998 

999 registry = public_dataset_registry() 

1000 if data_choice in registry: 

1001 return str(data_choice), registry[data_choice] 

1002 if data_choice == PUBLIC_DATASETS_CHOICE: 

1003 chosen = st.session_state.get(PUBLIC_DATASET_CHOICE) 

1004 if chosen in registry: 

1005 return str(chosen), registry[chosen] 

1006 return "", {} 

1007 

1008 

1009def _legend_state(kind: str, spec: dict) -> dict: 

1010 """One legend's three session keys, from a parsed spec.""" 

1011 return { 

1012 f"global_legend_{kind}_position": spec.get("position", "auto"), 

1013 f"global_legend_{kind}_arrangement": spec.get("arrangement", "auto"), 

1014 f"global_legend_{kind}_size": spec.get("size"), 

1015 } 

1016 

1017 

1018def _apply_url_legends(qp) -> None: 

1019 """Seed each ``legend_<kind>=SPEC`` param's three Legends keys. 

1020 

1021 One param per legend rather than three generic ones, written only for a 

1022 legend moved off Auto (`_build_share_query`), so an ordinary link carries 

1023 none. A malformed one is reported and ignored, like every other param. 

1024 """ 

1025 from .plots import parse_legend_spec 

1026 

1027 for param, kind in LEGEND_PARAMS.items(): 

1028 if param not in qp: 

1029 continue 

1030 try: 

1031 spec = parse_legend_spec(qp[param]) 

1032 except ValueError: 

1033 st.warning(f"Ignored the link's invalid {param}={qp[param]}.") 

1034 continue 

1035 if spec.get("size") is not None: 

1036 spec["size"] = _legend_size(spec["size"]) 

1037 for key, value in _legend_state(kind, spec).items(): 

1038 st.session_state.setdefault(key, value) 

1039 

1040 

1041def _legend_query(params: dict) -> None: 

1042 """Write ``legend_<kind>`` for each legend moved off Auto (the inverse).""" 

1043 from .plots import _legend_is_moved, legend_spec_text, normalize_legend_layout 

1044 

1045 layout = { 

1046 kind: { 

1047 "position": st.session_state.get(f"global_legend_{kind}_position") 

1048 or "auto", 

1049 "arrangement": st.session_state.get(f"global_legend_{kind}_arrangement") 

1050 or "auto", 

1051 "size": st.session_state.get(f"global_legend_{kind}_size"), 

1052 } 

1053 for kind in LEGEND_PARAMS.values() 

1054 } 

1055 try: 

1056 layout = normalize_legend_layout(layout) 

1057 except (ValueError, TypeError): 

1058 return 

1059 for param, kind in LEGEND_PARAMS.items(): 

1060 if _legend_is_moved(layout[kind]): 

1061 params[param] = legend_spec_text(layout[kind]) 

1062 

1063 

1064def _apply_url_palette(qp) -> None: 

1065 """Expand a ``?palette=<name>`` deep link into its colour session keys (VIZ-18). 

1066 

1067 A palette is a preset over the ordinary colour keys, so it must be applied 

1068 *before* the generic ``_URL_PRESETS`` loop — and any colour the same link 

1069 states explicitly has to win over it. Both fall out of skipping the keys the 

1070 URL already carries and using ``setdefault`` for the rest. 

1071 """ 

1072 name = qp.get("palette") 

1073 if name not in PALETTES: 

1074 return 

1075 explicit = {_URL_PRESETS[k][0] for k in qp if k in _URL_PRESETS} 

1076 for state_key, value in palette_state(name).items(): 

1077 if state_key not in explicit: 

1078 st.session_state.setdefault(state_key, value) 

1079 

1080 

1081def _apply_url_preset() -> str | None: 

1082 """Read `st.query_params` and preset Streamlit session state for deep links. 

1083 

1084 Returns the URL-requested `source` ("onestop"/"demo"/"upload") or `None`. 

1085 Call this at the very top of `main()` — before any widgets render — so 

1086 session_state values are picked up as the widgets' initial values. 

1087 

1088 URL schema (all params optional): 

1089 ?source=onestop → force "OneStop server bundle" data source 

1090 (also demo / synthetic / upload — see main()) 

1091 ?source=corpus&corpus=potec 

1092 → a public corpus: one entry of 

1093 `app.public_dataset_registry()`, built-in or 

1094 locally prepared (`corpus=harmonised-potec`). 

1095 Resolved in main(); an unresolvable slug 

1096 leaves the picker alone and says so. 

1097 &participant=p001 → preselect participant (Participant mode) 

1098 &trial=37 → preselect trial_index slider 

1099 &trial_id=p001_3_Adv → land on this exact trial id, any picker mode 

1100 (applied after combos build — see 

1101 _apply_url_trial_selection; emitted by Share) 

1102 &screen=intro → open this child screen of a multipart trial 

1103 &tab=animation → pre-tick the Animate toggle (legacy; there's 

1104 no separate Animated Scanpath tab anymore) 

1105 &heatmap_colorscale=Greens 

1106 &hide_fixation_numbers=1 

1107 &show_saccades=1 

1108 &show_heatmap=1 

1109 ...etc — see _URL_PRESETS above 

1110 

1111 Bonus side-effect: when any colorscale is set via URL, also forces the 

1112 "Advanced styling" expander open so the value is visible/editable. 

1113 

1114 External tools can deep-link into this app via the URL schema above to 

1115 land on a specific trial with the reviewer's preferred viz settings. 

1116 """ 

1117 qp = st.query_params 

1118 if not qp: 

1119 return None 

1120 

1121 # Seed selection state for every `select_trial` host (the prefixes in 

1122 # `_SELECTION_PREFIXES`). `?participant=` + `?trial=` map onto Participant mode 

1123 # with the matching participant / slider value. Seeding every prefix keeps a 

1124 # non-first picker from defaulting to "Trial" mode and landing on the 

1125 # alphabetically-first trial instead of the deep-linked one. 

1126 if "participant" in qp or "trial" in qp: 

1127 if "participant" in qp: 

1128 # Capture the deep-link participant ONCE, in a dedicated key the live 

1129 # selector never overwrites. The OneStop loader keys its per-pid shard 

1130 # fast-path off this — so it loads one pid for an embedded review deep 

1131 # link, while ordinary in-app participant switching just *filters* 

1132 # already-loaded data instead of re-invoking the loader. 

1133 st.session_state.setdefault("_deeplink_participant", str(qp["participant"])) 

1134 for prefix in _SELECTION_PREFIXES: 

1135 st.session_state.setdefault(f"{prefix}_select_trial_mode", "Participant") 

1136 if "participant" in qp: 

1137 st.session_state.setdefault( 

1138 f"{prefix}_participant", str(qp["participant"]) 

1139 ) 

1140 if "trial" in qp: 

1141 try: 

1142 st.session_state.setdefault(f"{prefix}_slider", int(qp["trial"])) 

1143 except (ValueError, TypeError): 

1144 st.warning(f"Ignored the link's invalid trial={qp['trial']}.") 

1145 

1146 _apply_url_palette(qp) 

1147 _apply_url_legends(qp) 

1148 

1149 snapped_from_link: set[str] = set() 

1150 for url_key, (state_key, coerce) in _URL_PRESETS.items(): 

1151 if url_key not in qp: 

1152 continue 

1153 # PRE-21: a link naming a gated-off feature is ignored *silently* — no 

1154 # "unavailable in this build" warning. The app hasn't been released, so 

1155 # no such link exists in the world yet; this only has to not crash, and 

1156 # not leave a value the rail can't show but the Share writer would emit. 

1157 if url_key in _GATED_URL_PARAMS and not _GATED_URL_PARAMS[url_key](): 

1158 continue 

1159 raw = qp[url_key] 

1160 try: 

1161 value = coerce(raw) 

1162 except (ValueError, TypeError): 

1163 st.warning(f"Ignored the link's invalid {url_key}={raw}.") 

1164 continue 

1165 # Clamp bounded widgets so a hand-crafted out-of-range link can't crash 

1166 # the slider / number_input on render. 

1167 value = _clamp_url_value(state_key, value) 

1168 if state_key in _SOURCE_SNAPPED_KEYS and state_key not in st.session_state: 

1169 snapped_from_link.add(state_key) 

1170 st.session_state.setdefault(state_key, value) 

1171 

1172 # A link carrying layer toggles but no `marker_size_scale` opens on the 

1173 # relative scale. Share has emitted the scale since the fixed scale became 

1174 # the default, and the toggles always, so that is a link copied before it, 

1175 # drawn relative. `duration_size_legend` is left out of the check: it came 

1176 # in with the scale. A hand-written `?trial_id=` link carries no toggle and 

1177 # gets the new default. 

1178 if "marker_size_scale" not in qp and any( 

1179 k in qp 

1180 for k in _SHARE_TOGGLE_PARAMS 

1181 if k not in ("duration_size_legend", "show_chips") 

1182 ): 

1183 st.session_state.setdefault( 

1184 "global_marker_size_scale", LEGACY_MARKER_SIZE_SCALE 

1185 ) 

1186 

1187 # EXP-19: a source that declares its own monitor or typeface snaps the canvas 

1188 # and font controls to it the first time it is seeded — on a recipient's 

1189 # first run, that is, *after* this link has seeded them — so without a word 

1190 # from here the sender's canvas would be replaced by the corpus default the 

1191 # link had just been careful not to repeat. `app.main` scopes this to the 

1192 # source the link resolves to (`scope_link_setup`) and `app.seed_canvas_state` 

1193 # consumes it (`link_setup_keys_for`), leaving the named keys alone. 

1194 if snapped_from_link: 

1195 st.session_state.setdefault( 

1196 LINK_SETUP_STATE_KEY, {"keys": sorted(snapped_from_link), "choice": None} 

1197 ) 

1198 

1199 # DATA-22 §7 surface 2 (read side): badge the values this link is carrying 

1200 # with how the *sender* knew them, so an assumed monitor arrives labelled as 

1201 # assumed instead of looking measured. Parsing is deliberately forgiving — 

1202 # unknown groups and unknown provenance words are dropped, never raised on — 

1203 # because a mangled param should cost the recipient badges, not the link. 

1204 if SETUP_PROVENANCE_PARAM in qp: 

1205 arrived = parse_provenance_param(str(qp[SETUP_PROVENANCE_PARAM])) 

1206 if arrived: 

1207 st.session_state.setdefault( 

1208 SETUP_PROVENANCE_STATE_KEY, {g: str(p) for g, p in arrived.items()} 

1209 ) 

1210 

1211 # The colour-bar settings the two bars shared before each had its own: 

1212 # each sets both. 

1213 for legacy, (suffix, coerce) in { 

1214 "show_colorbars": ("show_{bar}_colorbar", _coerce_bool), 

1215 "colorbar_orientation": ( 

1216 "{bar}_colorbar_orientation", 

1217 _parse_colorbar_orientation, 

1218 ), 

1219 "colorbar_tickangle": ("{bar}_colorbar_tickangle", int), 

1220 "colorbar_tickfont_size": ("{bar}_colorbar_tickfont_size", int), 

1221 }.items(): 

1222 if legacy not in qp: 

1223 continue 

1224 try: 

1225 value = coerce(qp[legacy]) 

1226 except (ValueError, TypeError): 

1227 st.warning(f"Ignored the link's invalid {legacy}={qp[legacy]}.") 

1228 continue 

1229 for bar in ("fixation", "heatmap"): 

1230 state_key = "global_" + suffix.format(bar=bar) 

1231 st.session_state.setdefault(state_key, _clamp_url_value(state_key, value)) 

1232 

1233 # The switch title and caption shared before each had its own: both. 

1234 if PARAM_SHOW_TITLE_CAPTION in qp: 

1235 try: 

1236 both = _coerce_bool(qp[PARAM_SHOW_TITLE_CAPTION]) 

1237 except (ValueError, TypeError): 

1238 st.warning( 

1239 f"Ignored the link's invalid {PARAM_SHOW_TITLE_CAPTION}=" 

1240 f"{qp[PARAM_SHOW_TITLE_CAPTION]}." 

1241 ) 

1242 else: 

1243 st.session_state.setdefault("global_show_title", both) 

1244 st.session_state.setdefault("global_show_caption", both) 

1245 

1246 # Heatmap / fixation colorscale only render under the Advanced expander — 

1247 # auto-open it so the URL value is exposed in the rail. 

1248 if "heatmap_colorscale" in qp or "fixation_colorscale" in qp: 

1249 st.session_state.setdefault("global_advanced", True) 

1250 

1251 # Animation is now a checkbox in the Scanpath Visualization tab (no separate 

1252 # tab), so a legacy `?tab=animation` deep link just pre-ticks it. 

1253 if (qp.get("tab") or "").lower() == "animation": 

1254 st.session_state.setdefault("single_animate", True) 

1255 if qp.get("screen") not in (None, ""): 

1256 st.session_state.setdefault("single_screen_id", str(qp["screen"])) 

1257 

1258 # CMP-8 §7: `?compare=<participant>:<trial>` turns Compare on and parks B's 

1259 # ids for the picker to consume once its candidate list exists (the picker's 

1260 # own key holds a render-time *label*, so a link can't seed it directly — 

1261 # the same reason ENG-36's trial jump parks a request). `cmp_source` names 

1262 # B's corpus; an unknown name is dropped rather than honoured, which falls 

1263 # back to "B is in this dataset" instead of wedging the picker. 

1264 compare_ids = parse_compare_value(qp.get(COMPARE_PARAM)) 

1265 if compare_ids is not None: 

1266 participant_b, trial_b = compare_ids 

1267 st.session_state.setdefault(SINGLE_COMPARE_TOGGLE, True) 

1268 st.session_state.setdefault( 

1269 PENDING_COMPARE_STATE_KEY, 

1270 {"participant_id": participant_b, "trial_id": trial_b}, 

1271 ) 

1272 source_b = _source_choice_for_param(qp.get(COMPARE_SOURCE_PARAM)) 

1273 if source_b is not None: 

1274 st.session_state.setdefault(COMPARE_SOURCE_STATE_KEY, source_b) 

1275 # B's own screen, for B's navigator — which keeps it only when B's 

1276 # trial has that screen, as A's does with `screen=`. 

1277 if qp.get(COMPARE_SCREEN_PARAM) not in (None, ""): 

1278 st.session_state.setdefault( 

1279 SINGLE_COMPARE_SCREEN_ID, str(qp[COMPARE_SCREEN_PARAM]) 

1280 ) 

1281 

1282 # DATA-3: the public OneStop source options (variant / regime / parts) ride 

1283 # the deep link too, seeded before the loader's widgets render. Validate each 

1284 # against its known domain so a hand-edited link can't wedge the widget. 

1285 if qp.get("onestop_variant") in ONESTOP_VARIANT_LABELS: 

1286 st.session_state.setdefault("onestop_variant", qp["onestop_variant"]) 

1287 if qp.get("onestop_regime") in ONESTOP_REGIME_LABELS: 

1288 st.session_state.setdefault("onestop_regime", qp["onestop_regime"]) 

1289 if "onestop_parts" in qp: 

1290 parts = [ 

1291 p for p in str(qp["onestop_parts"]).split(",") if p in ONESTOP_PART_LABELS 

1292 ] 

1293 if parts: 

1294 st.session_state.setdefault("onestop_parts", parts) 

1295 

1296 if (qp.get("source") or "").lower() == "author": 

1297 if "author_text" in qp: 

1298 st.session_state.setdefault("author_text", str(qp["author_text"])) 

1299 if "author_events" in qp: 

1300 # The whole build — parse, shape check, table, normalization — sits 

1301 # inside the boundary: a hand-edited `[{"x":100},1]` used to pass 

1302 # the list check and raise from `pd.DataFrame` before the app drew 

1303 # anything. The text is kept either way. 

1304 try: 

1305 events = event_records_frame(json.loads(str(qp["author_events"]))) 

1306 except (ValueError, TypeError, RecursionError): 

1307 st.warning("Ignored the link's unreadable hand-made scanpath.") 

1308 else: 

1309 st.session_state.setdefault("_authored_events_frame", events) 

1310 # Prevent the authoring widget's text-change initializer from 

1311 # replacing the just-restored events on its first render. 

1312 st.session_state.setdefault( 

1313 "_author_text_for_events", 

1314 str(qp.get("author_text", st.session_state.get("author_text", ""))), 

1315 ) 

1316 

1317 source = qp.get("source") 

1318 return source.lower() if source else None 

1319 

1320 

1321def link_sets(state_key: str) -> bool: 

1322 """Whether the open deep link carries a value for ``state_key`` (VIZ-45). 

1323 

1324 For a setting whose default depends on the dataset — the raw-gaze layer — 

1325 rather than on a source's declared screen, so it is not scoped the way 

1326 `link_setup_keys_for` is: a link to an uploaded dataset names no source 

1327 this app can open, yet its `show_raw_gaze=0` is still the sender's explicit 

1328 choice. It holds while the link's view params are on the URL, which is also 

1329 exactly while `_apply_url_preset` keeps re-seeding them; choosing a design 

1330 takes them off (`controls._drop_linked_view_params`).""" 

1331 try: 

1332 params = st.query_params 

1333 except Exception: 

1334 return False 

1335 return any( 

1336 url_key in params and target == state_key 

1337 for url_key, (target, _coerce) in _URL_PRESETS.items() 

1338 ) 

1339 

1340 

1341def linked_state_keys() -> frozenset[str]: 

1342 """The session keys the open deep link carries a value for (#374 F25). 

1343 

1344 What the rail's design highlight is recomputed from on a link's first run: 

1345 a link built from a customized view opens on Custom, not on the design 

1346 whose own few settings it happens to match.""" 

1347 try: 

1348 params = st.query_params 

1349 except Exception: 

1350 return frozenset() 

1351 return frozenset( 

1352 target 

1353 for url_key, (target, _coerce) in _URL_PRESETS.items() 

1354 if url_key in params 

1355 ) | frozenset( 

1356 key 

1357 for param, kind in LEGEND_PARAMS.items() 

1358 if param in params 

1359 for key in _legend_state(kind, {}) 

1360 ) 

1361 

1362 

1363#: #374 F14 — a link to an added dataset this session doesn't hold, kept so the 

1364#: notice stays up (the link's params are dropped once it is read) until 

1365#: another dataset is opened: ``{"message": str, "choice": str | None}``. 

1366LINK_DATASET_MISSING_KEY = "_link_dataset_missing" 

1367 

1368 

1369def missing_dataset_message( 

1370 name: str, participant: str | None = None, trial: str | None = None 

1371) -> str: 

1372 """What a recipient reads when a link names a dataset they don't have.""" 

1373 shown = str(name).replace("*", r"\*") 

1374 if trial and participant: 

1375 what = f"trial {trial} of participant {participant} in **{shown}**" 

1376 elif trial: 

1377 what = f"trial {trial} in **{shown}**" 

1378 else: 

1379 what = f"**{shown}**" 

1380 return ( 

1381 f"This link shows {what}, which isn't here. Ask the sender for the data " 

1382 f"files and its setup file ({ICONS['edit']} Edit dataset → Download setup file), " 

1383 f"then add it with {ICONS['add']} Add dataset → Import files. Nothing " 

1384 "from the link was applied." 

1385 ) 

1386 

1387 

1388def resolve_link_dataset(seeded: Iterable[str], current: str | None) -> str | None: 

1389 """Open the added dataset a link names (`?dataset=`, #374 F14). 

1390 

1391 Returns the dataset to open when this session holds one of that name. When 

1392 it doesn't, the link's view is **not** applied to whatever else is open: 

1393 every key ``_apply_url_preset`` seeded this run (``seeded``) is dropped, the 

1394 link's params are cleared so the next run does not seed them again, and a 

1395 notice saying which dataset is missing — and how to get it — is parked 

1396 under :data:`LINK_DATASET_MISSING_KEY`. ``current`` is the dataset open now, 

1397 which the notice is tied to. 

1398 """ 

1399 try: 

1400 params = st.query_params 

1401 name = params.get(PARAM_DATASET) 

1402 except Exception: 

1403 return None 

1404 if not name or params.get("source"): 

1405 return None 

1406 if name in (st.session_state.get("_datasets") or {}): 

1407 return str(name) 

1408 message = missing_dataset_message( 

1409 str(name), params.get("participant"), params.get("trial_id") 

1410 ) 

1411 for key in seeded: 

1412 st.session_state.pop(key, None) 

1413 params.clear() 

1414 st.session_state[LINK_DATASET_MISSING_KEY] = { 

1415 "message": message, 

1416 "choice": current, 

1417 } 

1418 return None 

1419 

1420 

1421def link_dataset_notice(current: str | None) -> str | None: 

1422 """The missing-dataset notice while it holds — until another dataset is 

1423 opened than the one that was open when the link was read.""" 

1424 held = st.session_state.get(LINK_DATASET_MISSING_KEY) 

1425 if not isinstance(held, dict): 

1426 return None 

1427 if held.get("choice") is None: 

1428 # A fresh session has no dataset open until the picker resolves one. 

1429 held["choice"] = current 

1430 elif held.get("choice") != current: 

1431 st.session_state.pop(LINK_DATASET_MISSING_KEY, None) 

1432 return None 

1433 return str(held.get("message") or "") or None 

1434 

1435 

1436def scope_link_setup(choice: str | None) -> None: 

1437 """Tie the keys a link seeded (EXP-19) to the data source it resolved to. 

1438 

1439 Called by `app.main` once its `?source=` dispatch has run, with the choice 

1440 the link landed on — or ``None`` when it named no source this app can open 

1441 (an uploaded dataset, a corpus the recipient has no bundle for, a server 

1442 bundle with no data directory). Then there is nothing to protect: the 

1443 recipient is on whatever source they already had, and it snaps to its own 

1444 monitor exactly as if no link had been opened.""" 

1445 marker = st.session_state.get(LINK_SETUP_STATE_KEY) 

1446 if not isinstance(marker, dict) or marker.get("choice") is not None: 

1447 return # nothing seeded this run, or already scoped on the first one 

1448 if choice is None: 

1449 st.session_state.pop(LINK_SETUP_STATE_KEY, None) 

1450 else: 

1451 st.session_state[LINK_SETUP_STATE_KEY] = {**marker, "choice": str(choice)} 

1452 

1453 

1454def link_setup_keys_for(source_key: tuple) -> frozenset: 

1455 """The linked keys the source snap must leave alone for ``source_key``. 

1456 

1457 ``source_key`` is `seed_canvas_state`'s ``(data_choice, 

1458 public_dataset_choice)``: a public corpus the link named by its registry 

1459 label is seeded under the collapsed picker choice, with the label second. 

1460 Consumes the marker either way — it describes the first seeding only, so a 

1461 link that is not honoured now never will be.""" 

1462 marker = st.session_state.pop(LINK_SETUP_STATE_KEY, None) 

1463 if not isinstance(marker, dict) or marker.get("choice") is None: 

1464 return frozenset() 

1465 if marker["choice"] not in {str(part) for part in source_key if part}: 

1466 return frozenset() 

1467 return frozenset(marker.get("keys") or ()) 

1468 

1469 

1470# plot-config layer key → viz-control session_state key. The inverse of the 

1471# `layers` block written by `tabs._render_plot_config_expander`. 

1472_PLOT_CONFIG_LAYER_KEYS = { 

1473 "words": "global_show_words", 

1474 "word_labels": "global_show_labels", 

1475 # UX-128: the 📄 Stimulus section's master switch. 

1476 "stimulus": "global_show_stimulus", 

1477 "fixations": "global_show_fix", 

1478 "order_labels": "global_show_order", 

1479 "saccades": "global_show_saccades", 

1480 "saccade_arrows": "global_show_saccade_arrows", 

1481 "heatmap": "global_show_heatmap", 

1482 "raw_gaze": "global_show_raw_gaze", 

1483 "stimulus_image": "global_show_stimulus_image", 

1484 "full_monitor": "global_fit_to_monitor", 

1485 "autoplay": "global_anim_autoplay", 

1486} 

1487 

1488 

1489# --- Seeding a stored session value (BUG-71) -------------------------------- 

1490# 

1491# The recovery cache (`persistence.restore_state`) seeds session state straight 

1492# from a JSON file on disk, one key at a time, before any widget renders — the 

1493# same position a deep link is in, without the link's parsers. A value a widget 

1494# refuses (an opacity of 7, a size range of "abc") or one Plotly refuses (a 

1495# colour of "zzz") stopped the app on every launch. `sanitize_session_value` 

1496# holds a stored value to the rules the two readers above already apply: 

1497# `_URL_BOUNDED` (which since EXP-19 also holds the widget bounds a saved config 

1498# alone used to need), the `#rrggbb` colour check, and the closed vocabularies 

1499# whose widgets raise on anything else. 

1500_FIXCLASS_CATEGORIES = ("short", "long", "oob", "blink") 

1501#: Every session key that holds a colour — all of them on the link since EXP-19. 

1502_COLOR_STATE_KEYS = frozenset(_SHARE_VALUE_PARAMS[p] for p in _SHARE_COLOR_PARAMS) 

1503#: Keys whose widget is a toggle or checkbox: a stored non-bool is not a setting. 

1504_BOOL_STATE_KEYS = frozenset( 

1505 { 

1506 *_SHARE_TOGGLE_PARAMS.values(), 

1507 *_PLOT_CONFIG_LAYER_KEYS.values(), 

1508 "global_use_stimulus_font_pt", 

1509 "global_show_compare_legend", 

1510 "single_animate", 

1511 "single_compare_toggle", 

1512 "cmp0_hollow", 

1513 "cmp1_hollow", 

1514 "single_fix_range_all_trials", 

1515 "single_fix_range_user_set", 

1516 "single_compare_fix_range_user_set", 

1517 } 

1518) 

1519#: Free-text settings (the label and title/caption patterns): a string. 

1520_TEXT_STATE_KEYS = frozenset( 

1521 { 

1522 "global_illustration_text", 

1523 "global_title_pattern", 

1524 "global_caption_pattern", 

1525 "cmp0_label_pattern", 

1526 "cmp1_label_pattern", 

1527 } 

1528) 

1529#: A data field the rail heals against the loaded data: a string, or unset. 

1530_FIELD_STATE_KEYS = frozenset({"global_word_hover_measure"}) 

1531#: Two-number ranges with no widget bound of their own: the colour ranges are 

1532#: drawn as given by the rail (its slider widens to hold them), so they only 

1533#: have to be numbers. 

1534_FREE_RANGE_STATE_KEYS = frozenset( 

1535 {"global_fixation_color_range", "global_heatmap_color_range"} 

1536) 

1537 

1538 

1539def _closed_choice(options) -> Callable[[object], object]: 

1540 def parse(value): 

1541 if value not in options: 

1542 raise ValueError(f"not one of the widget's options: {value!r}") 

1543 return value 

1544 

1545 return parse 

1546 

1547 

1548def _legend_size(value) -> int: 

1549 """A legend's text size, clamped to its box's 6–72 px (``None`` = Auto 

1550 passes before this is called).""" 

1551 if isinstance(value, bool): 

1552 raise TypeError(f"not a text size: {value!r}") 

1553 return max(6, min(72, int(value))) 

1554 

1555 

1556#: Each legend's three keys (Figure & canvas → Legends), checked the same way 

1557#: whether they come from a link, a settings file, a design or the cache. 

1558_LEGEND_STATE_PARSERS = { 

1559 key: parse 

1560 for kind in LEGEND_PARAMS.values() 

1561 for key, parse in ( 

1562 (f"global_legend_{kind}_position", _closed_choice(LEGEND_POSITIONS)), 

1563 (f"global_legend_{kind}_arrangement", _closed_choice(LEGEND_ARRANGEMENTS)), 

1564 (f"global_legend_{kind}_size", _legend_size), 

1565 ) 

1566} 

1567 

1568 

1569#: Closed vocabularies — the same sets `_restore_plot_config` checks with 

1570#: `put_valid`, and the links' own validating parsers where there is one. `None` 

1571#: passes (a deselected segmented control stores it, and the rail coerces it). 

1572_CHOICE_STATE_PARSERS = { 

1573 **_LEGEND_STATE_PARSERS, 

1574 "global_align_algorithm": _parse_align_algorithm, 

1575 **{ 

1576 key: lambda v: _parse_saccade_classes( 

1577 ",".join(str(item) for item in v) if isinstance(v, (list, tuple)) else v 

1578 ) 

1579 for key in ("global_saccade_classes", "cmp1_saccade_classes") 

1580 }, 

1581 "single_compare_layout": _parse_compare_layout, 

1582 "single_compare_stimulus": _parse_compare_stimulus, 

1583 "single_playback_speed": _parse_playback_speed, 

1584 **{ 

1585 f"cmp{i}_saccade_style": _closed_choice(tuple(SACCADE_DASH_OPTIONS)) 

1586 for i in (0, 1) 

1587 }, 

1588 "global_illustration_label": _closed_choice(("Auto", "Show", "Hide")), 

1589 "global_marker_size_scale": _closed_choice(tuple(MARKER_SIZE_SCALES)), 

1590 "global_preproc_short_policy": _closed_choice( 

1591 ("Off", "Merge", "Merge then discard", "Discard") 

1592 ), 

1593 "global_heatmap_style": _parse_heatmap_style, 

1594 "global_heatmap_norm": _closed_choice(("Linear", "Log")), 

1595 "global_heatmap_metric": _closed_choice(("duration_ms", "counts")), 

1596 "global_fixation_colorscale": _closed_choice(tuple(COLORSCALES)), 

1597 "global_heatmap_colorscale": _closed_choice(tuple(COLORSCALES)), 

1598 # "" follows the figure's colour scale. 

1599 **{ 

1600 f"cmp{i}_heatmap_colorscale": _closed_choice(("", *COLORSCALES)) for i in (0, 1) 

1601 }, 

1602 "global_saccade_style": _closed_choice(tuple(SACCADE_DASH_OPTIONS)), 

1603 "global_saccade_render_mode": _closed_choice(("Straight", "Arc")), 

1604 "global_saccade_color_mode": _closed_choice(tuple(SACCADE_COLOR_MODES)), 

1605 "global_fixation_symbol": _closed_choice(tuple(FIXATION_SYMBOLS)), 

1606 "global_fixation_colorbar_orientation": _closed_choice(("Vertical", "Horizontal")), 

1607 "global_heatmap_colorbar_orientation": _closed_choice(("Vertical", "Horizontal")), 

1608 "global_critical_span_style": _closed_choice(("Mark text", "Mark border", "None")), 

1609 "global_palette": _closed_choice((*PALETTES, CUSTOM_PALETTE)), 

1610 **{ 

1611 f"{side}_fixclass_{c}_mode": _closed_choice(tuple(_FIXCLASS_MODES)) 

1612 for side in ("global", "cmp1") 

1613 for c in _FIXCLASS_CATEGORIES 

1614 }, 

1615 **{ 

1616 f"global_fixclass_{c}_symbol": _closed_choice(tuple(_OUT_OF_TEXT_MARKERS)) 

1617 for c in _FIXCLASS_CATEGORIES 

1618 }, 

1619} 

1620 

1621 

1622def _bounded_number(value, lo, hi): 

1623 """``value`` as a finite number of the bounds' type, clamped to them.""" 

1624 if isinstance(value, bool) or not isinstance(value, (int, float, str)): 

1625 raise TypeError(f"not a number: {value!r}") 

1626 number = float(value) 

1627 if not math.isfinite(number): 

1628 raise ValueError(f"not a finite number: {value!r}") 

1629 if lo is not None: 

1630 number = max(lo, number) 

1631 if hi is not None: 

1632 number = min(hi, number) 

1633 integral = all(isinstance(b, int) for b in (lo, hi) if b is not None) 

1634 return int(number) if integral else number 

1635 

1636 

1637def sanitize_session_value(key: str, value): 

1638 """A stored session value as it may be seeded, or ``ValueError``/``TypeError``. 

1639 

1640 For the recovery cache (BUG-71), which has no parser of its own: the caller 

1641 drops a value this rejects rather than seeding it, so one bad entry costs the 

1642 user that one setting, never the launch. Numbers and ranges are clamped to 

1643 their widget's bounds (a range comes back as a sorted tuple, the shape the 

1644 widgets write); colours must be ``#rrggbb``; toggles must be booleans; closed 

1645 vocabularies must name an option. A key with no rule — a data-dependent 

1646 field the rail heals against the loaded data, a trial id, a mapping — passes 

1647 through unchanged. 

1648 """ 

1649 if key in _COLOR_STATE_KEYS: 

1650 if not isinstance(value, str): 

1651 raise TypeError(f"not a color: {value!r}") 

1652 if ( 

1653 value == "" 

1654 and key.endswith(("_box_color", "_box_fill_color", "_raw_gaze_color")) 

1655 and key.startswith("cmp") 

1656 ): 

1657 return value # follows the scanpath's colour / the figure's fill 

1658 return _parse_hex_color(value) 

1659 bounds = _URL_BOUNDED.get(key) 

1660 if bounds is not None: 

1661 lo, hi = bounds 

1662 if key.endswith("_range"): 

1663 if not isinstance(value, (list, tuple)) or len(value) != 2: 

1664 raise TypeError(f"not a two-number range: {value!r}") 

1665 a, b = (_bounded_number(v, lo, hi) for v in value) 

1666 return (min(a, b), max(a, b)) 

1667 return _bounded_number(value, lo, hi) 

1668 if key in _FREE_RANGE_STATE_KEYS: 

1669 if not isinstance(value, (list, tuple)) or len(value) != 2: 

1670 raise TypeError(f"not a two-number range: {value!r}") 

1671 a, b = (_bounded_number(v, None, None) for v in value) 

1672 return (float(min(a, b)), float(max(a, b))) 

1673 if key in _BOOL_STATE_KEYS: 

1674 if not isinstance(value, bool): 

1675 raise TypeError(f"not a switch value: {value!r}") 

1676 return value 

1677 if key in _TEXT_STATE_KEYS: 

1678 if not isinstance(value, str): 

1679 raise TypeError(f"not text: {value!r}") 

1680 return value 

1681 if key in _FIELD_STATE_KEYS: 

1682 if value is not None and not isinstance(value, str): 

1683 raise TypeError(f"not a field name: {value!r}") 

1684 return value 

1685 if key in ("single_fix_range", "single_compare_fix_range") and value is not None: 

1686 # The fixation window: re-expanded to each trial's own range, so it 

1687 # only has to be two whole numbers. 

1688 if not isinstance(value, (list, tuple)) or len(value) != 2: 

1689 raise TypeError(f"not a two-number range: {value!r}") 

1690 a, b = (int(_bounded_number(v, None, None)) for v in value) 

1691 return (min(a, b), max(a, b)) 

1692 parser = _CHOICE_STATE_PARSERS.get(key) 

1693 if parser is not None and value is not None: 

1694 return parser(value) 

1695 return value 

1696 

1697 

1698# --- Settings-file schema versioning (ENG-11) ------------------------------- 

1699# 

1700# Single source of truth for the 🔗 Share → File settings-file schema version. The 

1701# writer (`tabs._build_studio_config`) stamps this onto every saved config; the 

1702# reader (`_restore_plot_config`) upgrades an older upload to it before applying, 

1703# so a config saved by an earlier build keeps loading as the layout evolves. 

1704# 

1705# schema 1 — the original plot-config-only format (no `schema` key at all, 

1706# no annotations / provenance / text / highlighting sections). 

1707# schema 2 — config + annotations + text/highlighting + provenance. 

1708# schema 3 — the VIZ-34 coordinate-grid axes fields. 

1709# schema 4 — UX-179: the figure only. Annotations, the column mapping, the 

1710# metadata tables and the saved designs left the file (each has 

1711# its own export now); the reader no longer applies them. 

1712# schema 5 — the fixed duration scale (`sizing.marker_size_scale`, 

1713# `marker_duration_range`, `duration_size_legend`). Older files 

1714# were drawn on the relative scale and are migrated to it. 

1715# schema 6 — the figure *mode* (`mode.animate` / `mode.compare`) and 

1716# scanpath B (`selection.compare`: its reader, trial, dataset and 

1717# screen). Older files carry neither, so they restore no 

1718# comparison and leave the current mode alone. 

1719# v6 -> v7 : the fixations' and the heatmap's colour bars got their own 

1720# settings, and title and caption their own switch; each shared 

1721# value moves to both (`_migrate_config_6_to_7`). 

1722# 

1723# **Bump `PLOT_CONFIG_SCHEMA` and register a migration in `_PLOT_CONFIG_MIGRATIONS` 

1724# whenever the config layout changes** (a renamed key, a moved section, a changed 

1725# value encoding). Each migration is a pure `dict -> dict` upgrading version N to 

1726# N+1; they run in sequence so a very old config is walked forward one step at a 

1727# time. The field-by-field reader already tolerates *missing* sections, so a 

1728# migration is only needed when an old key must be *translated*, not merely when 

1729# new keys are added. 

1730PLOT_CONFIG_SCHEMA = 7 

1731 

1732 

1733def _detect_config_schema(config: dict) -> int: 

1734 """Best-effort schema version of an uploaded config. 

1735 

1736 Schema 1 (the original plot-config-only format) predates the ``schema`` key, 

1737 so a missing or non-numeric value means version 1 rather than an error. 

1738 ``OverflowError`` is caught too: Python's ``json.loads`` accepts the 

1739 non-standard ``Infinity`` / ``NaN`` literals, and ``int(float("inf"))`` raises 

1740 it — a hand-edited config with such a ``schema`` should still degrade to v1 

1741 (and keep its valid plot settings) rather than abort the whole restore.""" 

1742 raw = config.get("schema") 

1743 try: 

1744 return max(1, int(raw)) 

1745 except (TypeError, ValueError, OverflowError): 

1746 return 1 

1747 

1748 

1749def _migrate_config_1_to_2(config: dict) -> dict: 

1750 """Upgrade a schema-1 config to schema 2. 

1751 

1752 Schema 1 held only plot settings — no annotations / provenance / text / 

1753 highlighting sections. Those were *added* in schema 2, and `_restore_plot_config` 

1754 already treats an absent section as "keep the default", so a schema-1 config 

1755 needs no key translation: this migration is intentionally an identity beyond 

1756 the version stamp applied by `_migrate_plot_config`. It stays registered so the 

1757 migration chain is exercised (and so a future schema-3 has a worked example to 

1758 copy) rather than special-casing "no migration needed".""" 

1759 return config 

1760 

1761 

1762#: The sections that make a saved config a *plot* config, as opposed to a file 

1763#: that carries only annotations (or only a design library). Their presence is 

1764#: what licenses the reader — and the 2→3 migration — to fill in defaults for 

1765#: sections an older build did not write. 

1766_PLOT_SECTIONS = ( 

1767 "layers", 

1768 "coloring", 

1769 "sizing", 

1770 "canvas_px", 

1771 "axes", 

1772 "text", 

1773 "highlighting", 

1774) 

1775 

1776 

1777def _has_plot_section(config: dict) -> bool: 

1778 return any(isinstance(config.get(name), dict) for name in _PLOT_SECTIONS) 

1779 

1780 

1781def _migrate_config_2_to_3(config: dict) -> dict: 

1782 """Upgrade to the optional VIZ-34 coordinate-grid axes fields. 

1783 

1784 Stamp explicit defaults so a v1/v2 file restores the complete current 

1785 settings contract without changing its rendered result — but only a file 

1786 that *has* plot settings. BUG-73: an annotations-only backup 

1787 (``{"schema": 2, "annotations": [...]}``) came out of this with an ``axes`` 

1788 section, which made the reader take it for a full plot config and pin the 

1789 defaults of every section it lacked: restoring your notes reset your grid, 

1790 illustration label, preprocessing and title to factory settings. 

1791 """ 

1792 migrated = dict(config) 

1793 if "axes" in config and not isinstance(config.get("axes"), dict): 

1794 return migrated 

1795 if not _has_plot_section(config): 

1796 return migrated 

1797 axes = dict(config.get("axes") or {}) 

1798 axes.setdefault("coordinate_grid", False) 

1799 axes.setdefault("coordinate_grid_auto", True) 

1800 axes.setdefault("coordinate_grid_spacing", 100.0) 

1801 migrated["axes"] = axes 

1802 return migrated 

1803 

1804 

1805def _migrate_config_3_to_4(config: dict) -> dict: 

1806 """UX-179: nothing to translate — schema 4 only *dropped* sections. 

1807 

1808 A v3 file's annotations, column mapping, metadata tables and designs are 

1809 simply not read any more (the reader ignores keys it does not know), so an 

1810 old session backup restores as the figure it described. 

1811 """ 

1812 return config 

1813 

1814 

1815def _migrate_config_4_to_5(config: dict) -> dict: 

1816 """Keep an older figure on the scale it was drawn with. 

1817 

1818 Before schema 5 every figure sized its markers relative to its own 

1819 shortest and longest fixation; the default is now a fixed duration scale. 

1820 Stamping ``marker_size_scale: "relative"`` makes an old file restore the 

1821 figure it saved rather than silently resizing it. Only a file with plot 

1822 settings is touched — an annotations-only backup gains no ``sizing`` 

1823 section (BUG-73's rule, as in the 2→3 step). 

1824 """ 

1825 migrated = dict(config) 

1826 if not _has_plot_section(config): 

1827 return migrated 

1828 if "sizing" in config and not isinstance(config.get("sizing"), dict): 

1829 return migrated 

1830 sizing = dict(config.get("sizing") or {}) 

1831 sizing.setdefault("marker_size_scale", LEGACY_MARKER_SIZE_SCALE) 

1832 migrated["sizing"] = sizing 

1833 return migrated 

1834 

1835 

1836def _migrate_config_5_to_6(config: dict) -> dict: 

1837 """Schema 6 *added* the figure mode and scanpath B; nothing to translate. 

1838 

1839 A schema-5 file never recorded whether it was saved in Animate or Compare, 

1840 nor which reading B was — so it gets no ``mode`` section here, and the 

1841 reader leaves the current mode alone rather than guessing a comparison the 

1842 file cannot name. Only a schema-6 file's explicit ``mode`` (including an 

1843 explicit static one) moves the switches. 

1844 """ 

1845 migrated = dict(config) 

1846 migrated.pop("mode", None) 

1847 selection = migrated.get("selection") 

1848 if isinstance(selection, dict) and "compare" in selection: 

1849 migrated["selection"] = {k: v for k, v in selection.items() if k != "compare"} 

1850 return migrated 

1851 

1852 

1853def _migrate_config_6_to_7(config: dict) -> dict: 

1854 """Schema 7 split two shared settings: the fixations and the heatmap each 

1855 got their own colour bar (`coloring.show_colorbars` / `colorbar_*` → 

1856 `show_{bar}_colorbar` / `{bar}_colorbar_*`), and title and caption their 

1857 own switch (`labels.show_title_caption` → `show_title` + `show_caption`). 

1858 Each old value now sets both.""" 

1859 migrated = dict(config) 

1860 coloring = migrated.get("coloring") 

1861 if isinstance(coloring, dict): 

1862 coloring = dict(coloring) 

1863 if "show_colorbars" in coloring: 

1864 value = coloring.pop("show_colorbars") 

1865 for bar in ("fixation", "heatmap"): 

1866 coloring.setdefault(f"show_{bar}_colorbar", value) 

1867 for name in ("orientation", "tickangle", "tickfont_size"): 

1868 if f"colorbar_{name}" in coloring: 

1869 value = coloring.pop(f"colorbar_{name}") 

1870 for bar in ("fixation", "heatmap"): 

1871 coloring.setdefault(f"{bar}_colorbar_{name}", value) 

1872 migrated["coloring"] = coloring 

1873 labels = migrated.get("labels") 

1874 if isinstance(labels, dict) and "show_title_caption" in labels: 

1875 labels = dict(labels) 

1876 value = labels.pop("show_title_caption") 

1877 labels.setdefault("show_title", value) 

1878 labels.setdefault("show_caption", value) 

1879 migrated["labels"] = labels 

1880 return migrated 

1881 

1882 

1883# version N -> callable that upgrades an N config to N+1. Keyed by the *source* 

1884# version so `_migrate_plot_config` can walk an old config forward step by step. 

1885_PLOT_CONFIG_MIGRATIONS = { 

1886 1: _migrate_config_1_to_2, 

1887 2: _migrate_config_2_to_3, 

1888 3: _migrate_config_3_to_4, 

1889 4: _migrate_config_4_to_5, 

1890 5: _migrate_config_5_to_6, 

1891 6: _migrate_config_6_to_7, 

1892} 

1893 

1894 

1895def _migrate_plot_config(config: dict) -> tuple[dict, str | None]: 

1896 """Upgrade an uploaded plot-config dict to the current schema. 

1897 

1898 Returns ``(config, note)``. ``config`` is a **deep** copy stamped with the 

1899 resolved ``schema`` and walked through every registered migration between its 

1900 detected version and :data:`PLOT_CONFIG_SCHEMA`. The copy is deep (configs are 

1901 small) so a migration is genuinely ``dict -> dict`` pure: a future step that 

1902 translates a renamed key by editing a nested section in place can't leak back 

1903 into the caller's dict. ``note`` is a human-readable warning or ``None`` — set 

1904 when the config was saved by a *newer* build than this one understands (we 

1905 still restore best-effort: the reader simply ignores keys it doesn't 

1906 recognise), or when the chain is missing a step and can't reach the current 

1907 version.""" 

1908 version = _detect_config_schema(config) 

1909 working = copy.deepcopy(config) 

1910 if version > PLOT_CONFIG_SCHEMA: 

1911 return working, ( 

1912 "This settings file was saved by a newer version of Scanpath Studio; " 

1913 "settings this one doesn't recognize were ignored." 

1914 ) 

1915 while version < PLOT_CONFIG_SCHEMA: 

1916 migrate = _PLOT_CONFIG_MIGRATIONS.get(version) 

1917 if migrate is None: 

1918 note = ( 

1919 "This settings file is from an older version that can't be fully " 

1920 "read; applied what still fit." 

1921 ) 

1922 working["schema"] = version 

1923 return working, note 

1924 working = migrate(working) 

1925 version += 1 

1926 working["schema"] = version 

1927 return working, None 

1928 

1929 

1930def _match_selection( 

1931 selection: dict, combos: pd.DataFrame 

1932) -> tuple[pd.Series | None, str]: 

1933 """Find the one reading ``selection`` names in ``combos``, or say why not. 

1934 

1935 Returns ``(row, "")`` on a match and ``(None, reason)`` otherwise, the 

1936 reason a short phrase a notice can quote. A **supplied participant is 

1937 binding**: only that exact ``(participant, trial)`` pair matches, so a 

1938 reader filtered out of the pool is reported as missing rather than replaced 

1939 by another reader's trial of the same name. Only a request that *omitted* 

1940 the participant (a trial-only link, `_build_share_query(include_participant= 

1941 False)`) is looked up by trial id alone — and then only a unique match is 

1942 taken; a trial id several readers share is reported as ambiguous. 

1943 """ 

1944 pid = selection.get("participant_id") 

1945 tid = selection.get("trial_id") 

1946 if tid in (None, ""): 

1947 return None, "it names no trial" 

1948 if combos is None or combos.empty: 

1949 return None, "no trials pass the current filters" 

1950 tid = str(tid) 

1951 participant_given = pid not in (None, "") 

1952 readings = list(zip(combos["participant_id"], combos["trial_id"], strict=True)) 

1953 if participant_given: 

1954 # A link or config saved before composite ids escaped a `_` inside a 

1955 # part names the trial by its old spelling (`composite_respelling_map`). 

1956 pid, tid = respell_reading(str(pid), tid, readings) 

1957 match = combos[ 

1958 (combos["participant_id"].astype(str) == pid) 

1959 & (combos["trial_id"].astype(str) == tid) 

1960 ] 

1961 if match.empty: 

1962 return None, ( 

1963 f"participant {pid}'s trial {tid} isn't in the filtered trials" 

1964 ) 

1965 return match.iloc[0], "" 

1966 trial_ids = {str(t) for _, t in readings} 

1967 if tid not in trial_ids: 

1968 tid = composite_respelling_map([tid], trial_ids).get(tid, tid) 

1969 match = combos[combos["trial_id"].astype(str) == tid] 

1970 if match.empty: 

1971 return None, f"trial {tid} isn't in the filtered trials" 

1972 readers = match["participant_id"].astype(str).unique() 

1973 if len(readers) > 1: 

1974 return None, ( 

1975 f"trial {tid} belongs to {len(readers)} participants and the link " 

1976 "names none" 

1977 ) 

1978 return match.iloc[0], "" 

1979 

1980 

1981def _restore_selection( 

1982 selection: dict, combos: pd.DataFrame, key_prefix: str = "single" 

1983) -> bool: 

1984 """Best-effort: point a tab's trial picker at the saved ``(participant, 

1985 trial)``. Returns True when that reading is found in the current 

1986 (filtered) data — see :func:`_match_selection` for what counts as found, 

1987 and for the reason when it is not. Mirrors the key scheme of 

1988 ``utils.select_trial`` for the given ``key_prefix``, which is now one 

1989 scheme for every dataset (BUG-23 — a composite trial id no longer gets a 

1990 picker, or keys, of its own).""" 

1991 row, _reason = _match_selection(selection, combos) 

1992 if row is None: 

1993 return False 

1994 st.session_state[f"{key_prefix}_select_trial_mode"] = "Trial" 

1995 # The picker renders a single dropdown keyed `<prefix>_trial_id` whose 

1996 # *options* are the trial_field values (`unique_trial_id` when present), so 

1997 # seed that one key with this row's option value — not a 

1998 # `<prefix>_<trial_field>` key, which no widget reads. The slider 

1999 # (`<prefix>_trial_pos`) needs no seeding: the picker mirrors it onto the 

2000 # selectbox's value before it renders. 

2001 trial_field = ( 

2002 "unique_trial_id" if "unique_trial_id" in combos.columns else "trial_id" 

2003 ) 

2004 st.session_state[f"{key_prefix}_trial_id"] = str(row[trial_field]) 

2005 # Read once by `utils.select_trial`: a trial chosen here is never mistaken 

2006 # for one carried over from another dataset that happens to share its id. 

2007 st.session_state[f"_{key_prefix}_trial_chosen"] = str(row[trial_field]) 

2008 if selection.get("screen_id") not in (None, ""): 

2009 st.session_state[f"{key_prefix}_screen_id"] = str(selection["screen_id"]) 

2010 return True 

2011 

2012 

2013def _apply_url_trial_selection(combos: pd.DataFrame) -> str | None: 

2014 """Apply a ``?trial_id=`` deep link to the trial picker — exactly once. 

2015 

2016 Unlike ``?trial=`` (a slider *index*, seeded before any widget renders in 

2017 ``_apply_url_preset``), ``?trial_id=`` carries the canonical trial id, so it 

2018 lands on the exact trial regardless of which picker mode produced the share 

2019 link — but it needs the built ``combos``, so it runs from ``main()`` after 

2020 they exist. Reuses ``_restore_selection`` (the same seeding the plot-config 

2021 restore uses), seeding *every* selection prefix so non-first tabs land on the 

2022 trial too (mirrors the ``_SELECTION_PREFIXES`` loop in ``_apply_url_preset``). 

2023 The Share button emits this param; see ``_build_share_query``. 

2024 

2025 Resolved like its in-app twin, :func:`_apply_pending_trial_selection`: held 

2026 over only while ``combos`` is *empty* (still loading — the OneStop shard, a 

2027 big upload), and consumed once the pool can answer, hit or miss. A miss must 

2028 not retry on every rerun and then jump the picker the moment a filter change 

2029 brings the named reader back into the pool. 

2030 

2031 Returns ``None`` when nothing was waiting or the reading opened, and 

2032 otherwise a sentence saying why the link could not land (its reader filtered 

2033 out, a trial id several readers share with no reader named, …) for the 

2034 caller to show in the page notices. 

2035 """ 

2036 if st.session_state.get("_url_trial_applied"): 

2037 return None 

2038 trial_id = st.query_params.get("trial_id") 

2039 if not trial_id: 

2040 return None 

2041 if combos is None or combos.empty: 

2042 return None 

2043 st.session_state["_url_trial_applied"] = True 

2044 selection = { 

2045 "participant_id": st.query_params.get("participant"), 

2046 "trial_id": trial_id, 

2047 "screen_id": st.query_params.get("screen"), 

2048 } 

2049 _row, reason = _match_selection(selection, combos) 

2050 if reason: 

2051 return f"The link's trial couldn't be opened: {reason}." 

2052 for prefix in _SELECTION_PREFIXES: 

2053 _restore_selection(selection, combos, key_prefix=prefix) 

2054 return None 

2055 

2056 

2057#: Where an in-app "open this trial" request waits for `combos` to exist. 

2058PENDING_TRIAL_KEY = "_pending_trial_selection" 

2059 

2060#: Preprocessing keys a settings file restored this run, applied by 

2061#: :func:`apply_pending_preprocessing` before those widgets render next run. 

2062PENDING_PREPROC_RESTORE_KEY = "_pending_preproc_restore" 

2063PREPROC_KEY_PREFIX = "global_preproc_" 

2064 

2065 

2066def apply_pending_preprocessing() -> None: 

2067 """Write the preprocessing values a settings file restored on the last run. 

2068 

2069 Call before the 🧹 Preprocessing widgets render.""" 

2070 pending = st.session_state.pop(PENDING_PREPROC_RESTORE_KEY, None) 

2071 if isinstance(pending, dict): 

2072 st.session_state.update(pending) 

2073 

2074 

2075def request_trial( 

2076 participant: str | None, trial_id: str | None, *, screen_id: str | None = None 

2077) -> None: 

2078 """Ask the app to open ``trial_id`` in the Scanpath view (ENG-36). 

2079 

2080 ``screen_id`` also opens that screen of a multipart trial — Data 

2081 Management → Annotations' **Open** on a screen annotation. 

2082 

2083 Called from a *callback* — the reader/trial tables in Corpus Analysis have a 

2084 "go to this trial" button — which runs before the script, so the trial pool 

2085 it needs (``combos``) does not exist yet. The request is therefore parked and 

2086 applied by :func:`_apply_pending_trial_selection` once ``main`` has built the 

2087 pool, which is the same shape as the ``?trial_id=`` deep link and reuses the 

2088 same seeding. 

2089 """ 

2090 if not trial_id: 

2091 return 

2092 st.session_state[PENDING_TRIAL_KEY] = { 

2093 "participant_id": str(participant) if participant else None, 

2094 "trial_id": str(trial_id), 

2095 "screen_id": str(screen_id) if screen_id not in (None, "") else None, 

2096 } 

2097 _go_scanpath() 

2098 

2099 

2100def _apply_pending_trial_selection(combos: pd.DataFrame) -> str | None: 

2101 """Consume a :func:`request_trial` hop, if one is waiting. 

2102 

2103 Held over only while the pool cannot answer — an *empty* ``combos`` means 

2104 still loading (the OneStop shard, a big upload), and dropping the request 

2105 there would lose the click. Once the pool exists the request is resolved one 

2106 way or the other and cleared either way: a request the pool has genuinely 

2107 answered "not here" must not sit in session state and then fire later, 

2108 silently re-pointing the picker the moment a filter change happens to bring 

2109 that trial back into scope. Unlike the deep-link twin there is no once-flag — 

2110 each click is its own request, and the key *is* the flag. 

2111 

2112 Returns ``None`` when nothing was waiting or the reading opened, and 

2113 otherwise a sentence saying why it could not — a reader filtered out of the 

2114 pool is never swapped for another reader's same-named trial — for the 

2115 caller to show where the Open click lands. 

2116 """ 

2117 selection = st.session_state.get(PENDING_TRIAL_KEY) 

2118 if not selection or combos is None or combos.empty: 

2119 return None 

2120 st.session_state.pop(PENDING_TRIAL_KEY, None) 

2121 _row, reason = _match_selection(selection, combos) 

2122 if reason: 

2123 return f"Couldn't open that trial: {reason}." 

2124 for prefix in _SELECTION_PREFIXES: 

2125 _restore_selection(selection, combos, key_prefix=prefix) 

2126 return None 

2127 

2128 

2129def _seed_column_mapping( 

2130 mapping, *, overwrite: bool = False, dataset: object = None 

2131) -> None: 

2132 """Seed the ``col_map_*`` session keys from a saved config's ``column_mapping`` 

2133 so a restored config pre-fills the wizard mapping + kept-field choices (and 

2134 the user skips re-mapping). Stale values that don't match the current data are 

2135 tolerated by the mapping widgets (selectbox index fallback / multiselect 

2136 cleanup). Old configs used ``*_paragraph`` keys (now ``*_text_id``) — these 

2137 are translated for backward compatibility. 

2138 

2139 ``overwrite`` controls the write semantics. The plot-config restore runs 

2140 *before* any widget renders, so ``setdefault`` (overwrite=False) is correct — 

2141 it never clobbers a value a later widget will set. The wizard's "Restore a 

2142 saved setup" step, however, runs *after* the mapping widgets were created on a 

2143 previous render, so those keys already exist; ``setdefault`` would be a no-op 

2144 and the restore would silently do nothing. There, pass ``overwrite=True`` so 

2145 an explicit restore wins (the step reruns afterwards, and it runs before the 

2146 mapping widgets re-instantiate, so writing the keys is safe). 

2147 

2148 BUG-32: the mapping is scoped to a dataset, so a caller restoring keys *for* 

2149 a dataset whose table has not been read yet names it as ``dataset`` — the 

2150 wizard's *Restore a saved setup* — and the keys are claimed for it 

2151 (``controls.claim_mapping``): its first table keeps them, another dataset 

2152 meeting them first drops them. Without ``dataset`` (the 💾 plot-config 

2153 restore) the keys describe whatever those prefixes already map, and the 

2154 marker is left alone.""" 

2155 if not isinstance(mapping, dict): 

2156 return 

2157 written: set[str] = set() 

2158 for raw_key, value in mapping.items(): 

2159 if ( 

2160 not isinstance(raw_key, str) 

2161 or not raw_key.startswith("col_map_") 

2162 or raw_key.endswith("_upload") 

2163 ): 

2164 continue 

2165 key = raw_key 

2166 if key.endswith("_paragraph"): 

2167 key = key[: -len("_paragraph")] + "_text_id" 

2168 if overwrite or key not in st.session_state: 

2169 st.session_state[key] = value 

2170 written.add(key) 

2171 if dataset is None: 

2172 return 

2173 for prefix in ("col_map_words", "col_map_fix", "col_map_raw_gaze"): 

2174 if any(key.startswith(f"{prefix}_") for key in written): 

2175 claim_mapping(prefix, dataset) 

2176 

2177 

2178@dataclass 

2179class _RestoreContext: 

2180 """Validated writes and diagnostics for one plot-config restoration.""" 

2181 

2182 config: dict 

2183 applied: int = 0 

2184 skipped: list = field(default_factory=list) 

2185 

2186 def section(self, name: str) -> dict: 

2187 value = self.config.get(name) 

2188 return value if isinstance(value, dict) else {} 

2189 

2190 @staticmethod 

2191 def number(value) -> float | None: 

2192 try: 

2193 return float(value) 

2194 except (TypeError, ValueError): 

2195 return None 

2196 

2197 def put(self, key: str, value) -> None: 

2198 if key.startswith(PREPROC_KEY_PREFIX) and preprocessing_enabled(): 

2199 # The 🧹 Preprocessing widgets render before the restore runs, so 

2200 # their keys can't be written now; they're held for the next run, where `app._preprocessing_settings` applies them 

2201 # ahead of the widgets. 

2202 st.session_state.setdefault(PENDING_PREPROC_RESTORE_KEY, {})[key] = value 

2203 else: 

2204 st.session_state[key] = value 

2205 self.applied += 1 

2206 

2207 def put_valid(self, valid: bool, key: str, value, skip_label: str) -> None: 

2208 if valid: 

2209 self.put(key, value) 

2210 else: 

2211 self.skipped.append(skip_label) 

2212 

2213 def put_int(self, value, key: str, lo: int, hi: int, skip_label: str) -> None: 

2214 number = self.number(value) 

2215 if number is None: 

2216 self.skipped.append(skip_label) 

2217 else: 

2218 self.put(key, max(lo, min(int(number), hi))) 

2219 

2220 def put_float(self, value, key: str, lo: float, hi: float, skip_label: str) -> None: 

2221 number = self.number(value) 

2222 if number is None: 

2223 self.skipped.append(skip_label) 

2224 else: 

2225 self.put(key, max(lo, min(float(number), hi))) 

2226 

2227 

2228def _restore_plot_config( 

2229 config: dict, combos: pd.DataFrame, fixations: pd.DataFrame 

2230) -> tuple[int, list]: 

2231 """Seed session_state from an uploaded plot-config dict so the rail 

2232 widgets render with the saved settings. Returns ``(applied, skipped)`` where 

2233 ``skipped`` lists human-readable labels that didn't fit the current data. 

2234 

2235 Inverse of the config built in ``tabs._render_plot_config_expander``. Runs 

2236 before any widget renders (see ``_apply_uploaded_plot_config``); data- 

2237 dependent fields are validated against the loaded data and skipped when they 

2238 don't apply, so a config shared with a different dataset degrades gracefully.""" 

2239 # ENG-11: upgrade an older (or flag a newer) saved config to the current 

2240 # schema before reading its fields, so configs keep loading across versions. 

2241 config, migration_note = _migrate_plot_config(config) 

2242 if migration_note: 

2243 st.toast(migration_note, icon=ICONS["warning"]) 

2244 

2245 restore = _RestoreContext(config) 

2246 section = restore.section 

2247 number = restore.number 

2248 put = restore.put 

2249 put_valid = restore.put_valid 

2250 put_int = restore.put_int 

2251 put_float = restore.put_float 

2252 skipped = restore.skipped 

2253 

2254 # Older valid configs predate the illustration/preprocessing sections. They 

2255 # still need deterministic defaults for the newly frozen state keys, while 

2256 # a document made entirely of wrong-typed sections must remain a true no-op. 

2257 has_valid_plot_section = _has_plot_section(config) 

2258 

2259 layers = section("layers") 

2260 for cfg_key, state_key in _PLOT_CONFIG_LAYER_KEYS.items(): 

2261 if cfg_key in layers: 

2262 put(state_key, bool(layers[cfg_key])) 

2263 

2264 illustration = section("illustration") 

2265 if "label_mode" in illustration: 

2266 put_valid( 

2267 illustration["label_mode"] in ("Auto", "Show", "Hide"), 

2268 "global_illustration_label", 

2269 illustration["label_mode"], 

2270 "illustration label", 

2271 ) 

2272 elif "illustration" not in config and has_valid_plot_section: 

2273 put("global_illustration_label", "Auto") 

2274 # BUG-75: figure text from a config is text, never markup. Absent in a 

2275 # config saved before it existed, which leaves the automatic wording. 

2276 if isinstance(illustration.get("text"), str): 

2277 put("global_illustration_text", _strip_markup(illustration["text"])) 

2278 elif has_valid_plot_section: 

2279 put("global_illustration_text", "") 

2280 

2281 preprocessing = section("preprocessing") 

2282 if "enabled" in preprocessing: 

2283 put("global_preproc_enabled", bool(preprocessing["enabled"])) 

2284 if "discard_blink_adjacent" in preprocessing: 

2285 put( 

2286 "global_preproc_blink_adjacent", 

2287 bool(preprocessing["discard_blink_adjacent"]), 

2288 ) 

2289 if "short_policy" in preprocessing: 

2290 put_valid( 

2291 preprocessing["short_policy"] 

2292 in ("Off", "Merge", "Merge then discard", "Discard"), 

2293 "global_preproc_short_policy", 

2294 preprocessing["short_policy"], 

2295 "short-fixation policy", 

2296 ) 

2297 if "short_threshold_ms" in preprocessing: 

2298 put_float( 

2299 preprocessing["short_threshold_ms"], 

2300 "global_preproc_short_threshold_ms", 

2301 1.0, 

2302 500.0, 

2303 "short-fixation threshold", 

2304 ) 

2305 if "merge_distance_chars" in preprocessing: 

2306 put_float( 

2307 preprocessing["merge_distance_chars"], 

2308 "global_preproc_merge_distance_chars", 

2309 0.25, 

2310 10.0, 

2311 "short-fixation merge distance", 

2312 ) 

2313 elif "preprocessing" not in config and has_valid_plot_section: 

2314 # Schema-1/2 configs have no preprocessing block; pin the same defaults 

2315 # used by the controls without treating a malformed explicit block as 

2316 # permission to overwrite live state. 

2317 put("global_preproc_enabled", False) 

2318 put("global_preproc_blink_adjacent", True) 

2319 put("global_preproc_short_policy", "Off") 

2320 put("global_preproc_short_threshold_ms", 80.0) 

2321 put("global_preproc_merge_distance_chars", 1.0) 

2322 

2323 coloring = section("coloring") 

2324 # VIZ-18: the palette goes FIRST — it presets the individual colour keys, and 

2325 # every explicit colour saved alongside it (below) must overwrite that preset, 

2326 # not the other way round. Same ordering rule as the `?palette=` deep link. 

2327 palette = coloring.get("palette") 

2328 if palette is not None: 

2329 if palette in PALETTES: 

2330 for state_key, value in palette_state(palette).items(): 

2331 put(state_key, value) 

2332 put("global_palette", palette) 

2333 elif palette != CUSTOM_PALETTE: 

2334 skipped.append("palette") 

2335 # `Custom` is a legitimate saved value, not a bad one — it means the 

2336 # config was written from hand-edited colours, which ride in the explicit 

2337 # colour keys below. Nothing to preset, and nothing to warn about. 

2338 if "heatmap_style" in coloring: 

2339 style = coloring["heatmap_style"] 

2340 put_valid( 

2341 style in ("Word boxes", "Interpolated", "Duration mass"), 

2342 "global_heatmap_style", 

2343 "Interpolated" if style == "Duration mass" else style, 

2344 "heatmap style", 

2345 ) 

2346 # The Interpolated blur: Auto, and the fixed σ (px) used when it is off. 

2347 # Absent from a file written before it existed: automatic, as then. 

2348 if isinstance(config.get("coloring"), dict): 

2349 put( 

2350 "global_heatmap_sigma_auto", 

2351 bool(coloring.get("heatmap_sigma_auto", True)), 

2352 ) 

2353 put_float( 

2354 coloring.get("heatmap_sigma_px", DEFAULT_HEATMAP_SIGMA_PX), 

2355 "global_heatmap_sigma_px", 

2356 *HEATMAP_SIGMA_BOUNDS, 

2357 "heatmap blur", 

2358 ) 

2359 if "heatmap_norm" in coloring: 

2360 put_valid( 

2361 coloring["heatmap_norm"] in ("Linear", "Log"), 

2362 "global_heatmap_norm", 

2363 coloring["heatmap_norm"], 

2364 "heatmap color scaling", 

2365 ) 

2366 if "color_by" in coloring: 

2367 put_valid( 

2368 coloring["color_by"] in color_field_options(fixations), 

2369 "global_color_by", 

2370 coloring["color_by"], 

2371 "color-by field", 

2372 ) 

2373 if "heatmap_metric" in coloring: 

2374 put_valid( 

2375 coloring["heatmap_metric"] in ("duration_ms", "counts"), 

2376 "global_heatmap_metric", 

2377 coloring["heatmap_metric"], 

2378 "heatmap metric", 

2379 ) 

2380 for bar in ("fixation", "heatmap"): 

2381 if f"show_{bar}_colorbar" in coloring: 

2382 put(f"global_show_{bar}_colorbar", bool(coloring[f"show_{bar}_colorbar"])) 

2383 for cfg_key, state_key in ( 

2384 ("fixation_colorscale", "global_fixation_colorscale"), 

2385 ("heatmap_colorscale", "global_heatmap_colorscale"), 

2386 ): 

2387 val = coloring.get(cfg_key) 

2388 if val is not None: 

2389 put_valid( 

2390 val in COLORSCALES, 

2391 state_key, 

2392 val, 

2393 cfg_key.replace("_", " ").replace("colorscale", "color scale"), 

2394 ) 

2395 sac = coloring.get("saccade_color") 

2396 if isinstance(sac, str) and re.fullmatch(r"#[0-9A-Fa-f]{6}", sac): 

2397 put("global_saccade_color", sac) 

2398 if "saccade_style" in coloring: 

2399 put_valid( 

2400 coloring["saccade_style"] in SACCADE_DASH_OPTIONS, 

2401 "global_saccade_style", 

2402 coloring["saccade_style"], 

2403 "saccade line style", 

2404 ) 

2405 if "saccade_width" in coloring: 

2406 put_float( 

2407 coloring["saccade_width"], 

2408 "global_saccade_width", 

2409 SACCADE_WIDTH_BOUNDS[0], 

2410 SACCADE_WIDTH_BOUNDS[1], 

2411 "saccade line width", 

2412 ) 

2413 # VIZ-9: linear-reading mode (arced saccades + snap fixations above words). 

2414 if "saccade_render_mode" in coloring: 

2415 put_valid( 

2416 coloring["saccade_render_mode"] in ("Straight", "Arc"), 

2417 "global_saccade_render_mode", 

2418 coloring["saccade_render_mode"], 

2419 "saccade line shape", 

2420 ) 

2421 if "fixation_snap_to_word" in coloring: 

2422 put("global_fixation_snap_to_word", bool(coloring["fixation_snap_to_word"])) 

2423 # PRE-3 / ENG-23: vertical drift correction. Validated like the deep link — 

2424 # an algorithm the build no longer ships must not reach the selectbox. 

2425 # PRE-21: and skipped entirely while the feature is gated off, silently, for 

2426 # the same reason the deep link is (there is no such config in the world yet 

2427 # — this only has to not crash). 

2428 if drift_correction_enabled(): 

2429 if "drift_correction" in coloring: 

2430 put_valid( 

2431 coloring["drift_correction"] in _ALIGN_OPTIONS, 

2432 "global_align_algorithm", 

2433 coloring["drift_correction"], 

2434 "drift correction", 

2435 ) 

2436 if "drift_connectors" in coloring: 

2437 put("global_align_connectors", bool(coloring["drift_connectors"])) 

2438 # VIZ-8: colour-by-reading-type mode + per-class palette + optional legend. 

2439 mode = coloring.get("saccade_color_mode") 

2440 if mode is not None: 

2441 put_valid( 

2442 mode in SACCADE_COLOR_MODES, # VIZ-19 added "Forward / regression" 

2443 "global_saccade_color_mode", 

2444 mode, 

2445 "saccade color mode", 

2446 ) 

2447 if "saccade_type_legend" in coloring: 

2448 put("global_saccade_type_legend", bool(coloring["saccade_type_legend"])) 

2449 class_colors = coloring.get("saccade_class_colors") 

2450 if isinstance(class_colors, dict): 

2451 for cls_name in SACCADE_CLASS_EDITABLE: 

2452 col = class_colors.get(cls_name) 

2453 if isinstance(col, str) and re.fullmatch(r"#[0-9A-Fa-f]{6}", col): 

2454 put(f"global_saccade_class_color_{cls_name}", col) 

2455 # VIZ-31: the reading-class filter. Unknown names are dropped rather than 

2456 # rejecting the whole config — a class this build no longer classifies would 

2457 # crash the multiselect, and silently widening the filter is the safe way to 

2458 # be wrong (it shows more saccades, never fewer than the file asked for). 

2459 saccade_classes = coloring.get("saccade_classes") 

2460 if isinstance(saccade_classes, list): 

2461 kept = [cls for cls in SACCADE_CLASS_ORDER if cls in set(saccade_classes)] 

2462 if kept: 

2463 put("global_saccade_classes", kept) 

2464 # VIZ-15 marker shape · VIZ-17 uniform fixation colour. 

2465 symbol = coloring.get("fixation_symbol") 

2466 if symbol is not None: 

2467 put_valid( 

2468 symbol in FIXATION_SYMBOLS, 

2469 "global_fixation_symbol", 

2470 symbol, 

2471 "fixation marker shape", 

2472 ) 

2473 fix_color = coloring.get("fixation_color") 

2474 if isinstance(fix_color, str) and re.fullmatch(r"#[0-9A-Fa-f]{6}", fix_color): 

2475 put("global_fixation_color", fix_color) 

2476 if "hollow_fixations" in coloring: 

2477 put("global_hollow_fixations", bool(coloring["hollow_fixations"])) 

2478 if "fixation_opacity" in coloring: 

2479 put_float( 

2480 coloring["fixation_opacity"], 

2481 "global_fixation_opacity", 

2482 0.1, 

2483 1.0, 

2484 "fixation opacity", 

2485 ) 

2486 if "stimulus_image_opacity" in coloring: # VIZ-4 

2487 put_float( 

2488 coloring["stimulus_image_opacity"], 

2489 "global_stimulus_image_opacity", 

2490 0.1, 

2491 1.0, 

2492 "stimulus image opacity", 

2493 ) 

2494 # VIZ-4: manual image alignment (origin nudge + size scale). 

2495 if "stimulus_image_offset_x" in coloring: 

2496 put_float( 

2497 coloring["stimulus_image_offset_x"], 

2498 "global_stimulus_image_offset_x", 

2499 -5000.0, 

2500 5000.0, 

2501 "stimulus image X offset", 

2502 ) 

2503 if "stimulus_image_offset_y" in coloring: 

2504 put_float( 

2505 coloring["stimulus_image_offset_y"], 

2506 "global_stimulus_image_offset_y", 

2507 -5000.0, 

2508 5000.0, 

2509 "stimulus image Y offset", 

2510 ) 

2511 if "stimulus_image_scale" in coloring: 

2512 put_float( 

2513 coloring["stimulus_image_scale"], 

2514 "global_stimulus_image_scale", 

2515 0.25, 

2516 3.0, 

2517 "stimulus image scale", 

2518 ) 

2519 for bar in ("fixation", "heatmap"): 

2520 

2521 def _bar_value(name: str, bar: str = bar): 

2522 return coloring.get(f"{bar}_{name}") 

2523 

2524 co = _bar_value("colorbar_orientation") 

2525 if co is not None: 

2526 put_valid( 

2527 co in ("Vertical", "Horizontal"), 

2528 f"global_{bar}_colorbar_orientation", 

2529 co, 

2530 f"{bar} color bar orientation", 

2531 ) 

2532 for name, lo, hi, label in ( 

2533 ("colorbar_tickangle", -90, 90, "tick angle"), 

2534 ("colorbar_tickfont_size", 6, 20, "tick size"), 

2535 ): 

2536 value = _bar_value(name) 

2537 if value is not None: 

2538 put_int(value, f"global_{bar}_{name}", lo, hi, f"{bar} {label}") 

2539 # Store them even when their layer is off — the rail draws them as given 

2540 # (`controls._explicit_pair`). VIZ-46: a stored range means 

2541 # *explicit*, so a config saved while the range was auto (`null`) restores 

2542 # as auto rather than keeping whatever range this session happened to hold. 

2543 # The writer records the figure's *gated* range, though, so `null` says 

2544 # "auto" only where the saved figure drew that range at all — a config saved 

2545 # with the heatmap off says nothing about the heatmap's range. 

2546 in_effect = { 

2547 "fixation_range": bool(layers.get("fixations")) 

2548 and coloring.get("color_by") not in (None, UNIFORM_COLOR_FIELD, "line"), 

2549 "heatmap_range": bool(layers.get("heatmap")) 

2550 and coloring.get("heatmap_metric") == "duration_ms", 

2551 } 

2552 for cfg_key, state_key, label in ( 

2553 ("fixation_range", "global_fixation_color_range", "fixation color range"), 

2554 ("heatmap_range", "global_heatmap_color_range", "heatmap color range"), 

2555 ): 

2556 rng = coloring.get(cfg_key) 

2557 if isinstance(rng, (list, tuple)) and len(rng) == 2: 

2558 lo, hi = number(rng[0]), number(rng[1]) 

2559 put_valid(lo is not None and hi is not None, state_key, (lo, hi), label) 

2560 elif cfg_key in coloring and rng is None and in_effect[cfg_key]: 

2561 forget_color_range(state_key) 

2562 

2563 sizing = section("sizing") 

2564 marker = sizing.get("marker_size_range") 

2565 if isinstance(marker, (list, tuple)) and len(marker) == 2: 

2566 lo, hi = number(marker[0]), number(marker[1]) 

2567 if lo is None or hi is None: 

2568 skipped.append("marker size range") 

2569 else: 

2570 lo = max(_MARKER_BOUNDS[0], min(int(lo), _MARKER_BOUNDS[1])) 

2571 hi = max(_MARKER_BOUNDS[0], min(int(hi), _MARKER_BOUNDS[1])) 

2572 put("global_marker_size_range", (min(lo, hi), max(lo, hi))) 

2573 if "marker_size_scale" in sizing: 

2574 put_valid( 

2575 sizing["marker_size_scale"] in MARKER_SIZE_SCALES, 

2576 "global_marker_size_scale", 

2577 sizing["marker_size_scale"], 

2578 "marker size scale", 

2579 ) 

2580 durations = sizing.get("marker_duration_range") 

2581 if isinstance(durations, (list, tuple)) and len(durations) == 2: 

2582 lo, hi = number(durations[0]), number(durations[1]) 

2583 if lo is None or hi is None or not math.isfinite(lo + hi): 

2584 skipped.append("marker duration range") 

2585 else: 

2586 put( 

2587 "global_marker_duration_range", 

2588 _clamp_url_value( 

2589 "global_marker_duration_range", (round(lo), round(hi)) 

2590 ), 

2591 ) 

2592 if "duration_size_legend" in sizing: 

2593 put("global_duration_size_legend", bool(sizing["duration_size_legend"])) 

2594 legends = config.get("legends") 

2595 if isinstance(legends, dict): 

2596 from .plots import normalize_legend_layout 

2597 

2598 for kind in LEGEND_PARAMS.values(): 

2599 if kind not in legends: 

2600 continue 

2601 try: 

2602 spec = normalize_legend_layout({kind: legends[kind]})[kind] 

2603 except (ValueError, TypeError, AttributeError): 

2604 skipped.append(f"{kind.replace('_', ' ')} legend") 

2605 continue 

2606 if spec["size"] is not None: 

2607 spec["size"] = _legend_size(spec["size"]) 

2608 for key, value in _legend_state(kind, spec).items(): 

2609 put(key, value) 

2610 if "order_font_size" in sizing: 

2611 put_int( 

2612 sizing["order_font_size"], 

2613 "global_order_font_size", 

2614 *_FONT_BOUNDS, 

2615 "order label size", 

2616 ) 

2617 color = sizing.get("order_font_color") 

2618 if isinstance(color, str) and re.fullmatch(r"#[0-9A-Fa-f]{6}", color): 

2619 put("global_order_font_color", color) 

2620 if "base_font_size" in sizing: 

2621 put_int( 

2622 sizing["base_font_size"], 

2623 "global_base_font_size", 

2624 *_FONT_BOUNDS, 

2625 "figure font size", 

2626 ) 

2627 

2628 # VIZ-11 follow-up: the animation frame grid. 

2629 animation = section("animation") 

2630 if "grid_step_ms" in animation: 

2631 put_int( 

2632 animation["grid_step_ms"], 

2633 "global_anim_grid_step_ms", 

2634 20, 

2635 500, 

2636 "animation frame step", 

2637 ) 

2638 if "max_frames" in animation: 

2639 put_int( 

2640 animation["max_frames"], 

2641 "global_anim_max_frames", 

2642 30, 

2643 2000, 

2644 "animation frame cap", 

2645 ) 

2646 # BUG-72: the replay speed, against the ⚙ Playback slider's own options. 

2647 if "playback_speed" in animation: 

2648 try: 

2649 put( 

2650 "single_playback_speed", 

2651 _parse_playback_speed(animation["playback_speed"]), 

2652 ) 

2653 except (TypeError, ValueError): 

2654 skipped.append("playback speed") 

2655 

2656 # #374 F28 — Export → Current figure's print size; a blank width is the 

2657 # screen-size PNG. 

2658 export = section("export") 

2659 if "width" in export: 

2660 if export["width"] in (None, ""): 

2661 put("export_figure_width", None) 

2662 else: 

2663 put_float( 

2664 export["width"], "export_figure_width", *PRINT_WIDTH_BOUNDS, "width" 

2665 ) 

2666 if "unit" in export: 

2667 try: 

2668 put("export_figure_width_unit", _parse_print_unit(export["unit"])) 

2669 except (TypeError, ValueError): 

2670 skipped.append("width unit") 

2671 if "dpi" in export: 

2672 put_int(export["dpi"], "export_figure_dpi", *PRINT_DPI_BOUNDS, "DPI") 

2673 

2674 canvas = section("canvas_px") 

2675 if "width" in canvas: 

2676 put_int(canvas["width"], "global_canvas_width", *_CANVAS_BOUNDS, "canvas width") 

2677 if "height" in canvas: 

2678 put_int( 

2679 canvas["height"], "global_canvas_height", *_CANVAS_BOUNDS, "canvas height" 

2680 ) 

2681 

2682 setup = section("experimental_setup") 

2683 # DATA-2: write all five keys even for a pre-experimental-setup config. This 

2684 # keeps schema-1/2 restores deterministic and makes the saved-state contract 

2685 # explicit (rather than hiding the key names behind a dynamic loop). 

2686 # 

2687 # DATA-22 review: "all five" means all five of *this* section's keys, and 

2688 # only for a full plot config. The wizard's setup file now also carries an 

2689 # `experimental_setup` — a `SetupSnapshot`, which has no `display_dpi` / 

2690 # `stimulus_font_pt` / `use_stimulus_font_pt` — and loading one through the 

2691 # settings-file uploader used to flip this branch on and overwrite 

2692 # those three with the reader's own fallbacks. A section that never mentions 

2693 # a setting must not restate it. 

2694 full_config = isinstance(config.get("canvas_px"), dict) 

2695 setup_context = isinstance(config.get("experimental_setup"), dict) or full_config 

2696 

2697 def _stated(key: str) -> bool: 

2698 """Whether this config is entitled to write ``key``'s session state.""" 

2699 return full_config or key in setup 

2700 

2701 if setup_context: 

2702 monitor_width = number(setup.get("monitor_width_mm", 597.0)) 

2703 put_valid( 

2704 monitor_width is not None, 

2705 "global_monitor_width_mm", 

2706 max(100.0, min(float(monitor_width), 3000.0)) 

2707 if monitor_width is not None 

2708 else 597.0, 

2709 "monitor width", 

2710 ) 

2711 viewing_distance = number(setup.get("viewing_distance_mm", 800.0)) 

2712 put_valid( 

2713 viewing_distance is not None, 

2714 "global_viewing_distance_mm", 

2715 max(100.0, min(float(viewing_distance), 3000.0)) 

2716 if viewing_distance is not None 

2717 else 800.0, 

2718 "viewing distance", 

2719 ) 

2720 if _stated("display_dpi"): 

2721 display_dpi = number(setup.get("display_dpi", 96.0)) 

2722 put_valid( 

2723 display_dpi is not None, 

2724 "global_display_dpi", 

2725 max(20.0, min(float(display_dpi), 1000.0)) 

2726 if display_dpi is not None 

2727 else 96.0, 

2728 "display DPI", 

2729 ) 

2730 if _stated("stimulus_font_pt"): 

2731 stimulus_font = number(setup.get("stimulus_font_pt", 12.0)) 

2732 put_valid( 

2733 stimulus_font is not None, 

2734 "global_stimulus_font_pt", 

2735 max(4.0, min(float(stimulus_font), 144.0)) 

2736 if stimulus_font is not None 

2737 else 12.0, 

2738 "stimulus font", 

2739 ) 

2740 if _stated("use_stimulus_font_pt"): 

2741 put( 

2742 "global_use_stimulus_font_pt", 

2743 bool(setup.get("use_stimulus_font_pt", False)), 

2744 ) 

2745 

2746 axes = section("axes") 

2747 numeric = numeric_field_options(fixations) 

2748 for cfg_key, state_key, label in ( 

2749 ("x_field", "global_x_field", "X axis field"), 

2750 ("y_field", "global_y_field", "Y axis field"), 

2751 ): 

2752 val = axes.get(cfg_key) 

2753 if val is not None: 

2754 put_valid(val in numeric, state_key, val, label) 

2755 if "coordinate_grid" in axes: 

2756 put("global_show_coordinate_grid", bool(axes["coordinate_grid"])) 

2757 if "coordinate_grid_auto" in axes: 

2758 put("global_coordinate_grid_auto", bool(axes["coordinate_grid_auto"])) 

2759 if axes.get("coordinate_grid_spacing") is not None: 

2760 put_float( 

2761 axes["coordinate_grid_spacing"], 

2762 "global_coordinate_grid_spacing", 

2763 10.0, 

2764 5000.0, 

2765 "coordinate grid spacing", 

2766 ) 

2767 

2768 text = section("text") 

2769 if "scale_text_to_boxes" in text: 

2770 put("global_scale_text_to_boxes", bool(text["scale_text_to_boxes"])) 

2771 if "line_spacing" in text: 

2772 n = number(text["line_spacing"]) 

2773 if n is None: 

2774 skipped.append("line spacing") 

2775 else: 

2776 put("global_line_spacing", max(1.0, min(float(n), 10.0))) 

2777 if isinstance(text.get("font_family"), str) and text["font_family"].strip(): 

2778 put("global_font_family", text["font_family"]) 

2779 tc = text.get("text_color") 

2780 if isinstance(tc, str) and re.fullmatch(r"#[0-9A-Fa-f]{6}", tc): 

2781 put("global_text_color", tc) 

2782 word_hover_fields = text.get( 

2783 "word_hover_fields", 

2784 ["text", "word_id", "line_idx", "total_fixation_duration_ms"] 

2785 if isinstance(config.get("text"), dict) 

2786 else None, 

2787 ) 

2788 if isinstance(word_hover_fields, list) and all( 

2789 isinstance(field, str) for field in word_hover_fields 

2790 ): 

2791 put("global_word_hover_fields", word_hover_fields) 

2792 fixation_hover_fields = text.get( 

2793 "fixation_hover_fields", 

2794 ["order_in_trial", "duration_ms", "word_id"] 

2795 if isinstance(config.get("text"), dict) 

2796 else None, 

2797 ) 

2798 if isinstance(fixation_hover_fields, list) and all( 

2799 isinstance(field, str) for field in fixation_hover_fields 

2800 ): 

2801 put("global_fixation_hover_fields", fixation_hover_fields) 

2802 

2803 # EXP-5: title/caption on the figure, moved here from being Export-only. 

2804 labels = section("labels") 

2805 for cfg_key in ("show_title", "show_caption"): 

2806 if cfg_key in labels: 

2807 put(f"global_{cfg_key}", bool(labels[cfg_key])) 

2808 # BUG-75: a config can come from someone else, like a link — no markup. 

2809 if isinstance(labels.get("title_pattern"), str): 

2810 put("global_title_pattern", _strip_markup(labels["title_pattern"])) 

2811 if isinstance(labels.get("caption_pattern"), str): 

2812 put("global_caption_pattern", _strip_markup(labels["caption_pattern"])) 

2813 elif "labels" not in config and has_valid_plot_section: 

2814 # Pre-EXP-5 configs have no labels block; pin the off defaults so the 

2815 # frozen state-key set is still fully written. 

2816 put("global_show_title", False) 

2817 put("global_show_caption", False) 

2818 put("global_title_pattern", "") 

2819 put("global_caption_pattern", "") 

2820 

2821 highlighting = section("highlighting") 

2822 if "critical_span_style" in highlighting: 

2823 css = highlighting["critical_span_style"] 

2824 put_valid( 

2825 css in ("Mark text", "Mark border", "None"), 

2826 "global_critical_span_style", 

2827 css, 

2828 "text highlighting", 

2829 ) 

2830 if ( 

2831 isinstance(highlighting.get("highlight_column"), str) 

2832 and highlighting["highlight_column"] 

2833 ): 

2834 # The rail's `_drop_stale` clears this if it isn't a column in the 

2835 # restored-onto data, so it needs no validation against words here. 

2836 put("global_highlight_column", highlighting["highlight_column"]) 

2837 # Fixation classification (PRE-2): short/long/out-of-bounds highlight or discard. 

2838 flags = highlighting.get("fixation_flags") 

2839 if isinstance(flags, dict): 

2840 # BUG-72: `blink` too — the writer has always saved all four categories, 

2841 # and the reader used to drop the fourth. 

2842 for cat in _FIXCLASS_CATEGORIES: 

2843 spec = flags.get(cat) 

2844 if not isinstance(spec, dict): 

2845 continue 

2846 mode = spec.get("mode") 

2847 if mode in _FIXCLASS_MODES: 

2848 put(f"global_fixclass_{cat}_mode", mode) 

2849 if cat in ("short", "long") and spec.get("threshold_ms") is not None: 

2850 try: 

2851 put( 

2852 f"global_fixclass_{cat}_threshold_ms", 

2853 int(float(spec["threshold_ms"])), 

2854 ) 

2855 except (TypeError, ValueError): 

2856 pass 

2857 sym = spec.get("symbol") 

2858 if sym in _OUT_OF_TEXT_MARKERS: 

2859 put(f"global_fixclass_{cat}_symbol", sym) 

2860 col = spec.get("color") 

2861 if isinstance(col, str) and re.fullmatch(r"#[0-9A-Fa-f]{6}", col): 

2862 put(f"global_fixclass_{cat}_color", col) 

2863 htc = highlighting.get("highlight_text_color") 

2864 if isinstance(htc, str) and re.fullmatch(r"#[0-9A-Fa-f]{6}", htc): 

2865 put("global_highlight_text_color", htc) 

2866 bg = highlighting.get("background_color") 

2867 if isinstance(bg, str) and bg: 

2868 # Map a saved colour back to a preset name, else fall to the custom slot. 

2869 preset = next( 

2870 (n for n, v in BACKGROUND_PRESETS.items() if str(v).lower() == bg.lower()), 

2871 None, 

2872 ) 

2873 if preset is not None: 

2874 put("global_bg_choice", preset) 

2875 else: 

2876 put("global_bg_choice", "Custom…") 

2877 put("global_bg_custom", bg) 

2878 sbc = highlighting.get("span_border_color") 

2879 if isinstance(sbc, str) and re.fullmatch(r"#[0-9A-Fa-f]{6}", sbc): 

2880 put("global_span_border_color", sbc) 

2881 

2882 # VIZ-43 — raw gaze's own style. The section's `available` / `points` 

2883 # describe the trial the config was saved on, not a setting. Absent in a 

2884 # config saved before this existed, which keeps the seeded defaults. 

2885 raw_gaze = section("raw_gaze") 

2886 rg_color = raw_gaze.get("color") 

2887 if isinstance(rg_color, str) and _HEX_COLOR.fullmatch(rg_color): 

2888 put("global_raw_gaze_color", rg_color) 

2889 for cfg_key, state_key, label in ( 

2890 ("marker_size", "global_raw_gaze_marker_size", "raw gaze marker size"), 

2891 ("opacity", "global_raw_gaze_opacity", "raw gaze opacity"), 

2892 ): 

2893 if cfg_key in raw_gaze: 

2894 put_float(raw_gaze[cfg_key], state_key, *_URL_BOUNDED[state_key], label) 

2895 

2896 # ⬚ Word boxes' style. Absent in a config saved before the section had one, 

2897 # which keeps the seeded defaults — additive, so no schema bump. 

2898 word_boxes = section("word_boxes") 

2899 for cfg_key, state_key in ( 

2900 ("color", "global_word_box_color"), 

2901 ("fill_color", "global_word_box_fill_color"), 

2902 ): 

2903 col = word_boxes.get(cfg_key) 

2904 if isinstance(col, str) and _HEX_COLOR.fullmatch(col): 

2905 put(state_key, col) 

2906 for cfg_key, state_key, label in ( 

2907 ("line_opacity", "global_word_box_line_opacity", "word box line opacity"), 

2908 ("fill_opacity", "global_word_box_fill_opacity", "word box fill opacity"), 

2909 ): 

2910 if cfg_key in word_boxes: 

2911 put_float(word_boxes[cfg_key], state_key, *_URL_BOUNDED[state_key], label) 

2912 

2913 # CMP-11 — the compare *view* (layout + whose stimulus an overlay draws). 

2914 # Validated against the segmented controls' exact options for the same 

2915 # reason the URL params are: seeding a value outside them makes the widget 

2916 # raise. An absent section keeps the seeded defaults, so a pre-CMP-11 config 

2917 # restores unchanged and no schema bump is needed. 

2918 compare_view = config.get("compare_view") 

2919 if isinstance(compare_view, dict): 

2920 # BUG-72: the A/B legend switch rides in the same section. 

2921 if "legend" in compare_view: 

2922 put("global_show_compare_legend", bool(compare_view["legend"])) 

2923 for field, options, label in ( 

2924 ("layout", _COMPARE_LAYOUT_OPTIONS, "compare layout"), 

2925 ("stimulus", _COMPARE_STIMULUS_OPTIONS, "compare stimulus source"), 

2926 ): 

2927 if field not in compare_view: 

2928 continue 

2929 try: 

2930 value = _parse_choice(compare_view[field], options, label) 

2931 except ValueError: 

2932 skipped.append(label) 

2933 continue 

2934 put(f"single_compare_{field}", value) 

2935 

2936 # Per-scanpath comparison styling (cmp{idx}_*). A short or hand-edited list 

2937 # degrades gracefully — a missing field just keeps the seeded default. 

2938 compare = config.get("compare") 

2939 if isinstance(compare, list): 

2940 for idx, entry in enumerate(compare[:2]): 

2941 if not isinstance(entry, dict): 

2942 continue 

2943 fc = entry.get("fix_color") 

2944 if isinstance(fc, str) and re.fullmatch(r"#[0-9A-Fa-f]{6}", fc): 

2945 put(f"cmp{idx}_fix_color", fc) 

2946 sc = entry.get("saccade_color") 

2947 if isinstance(sc, str) and re.fullmatch(r"#[0-9A-Fa-f]{6}", sc): 

2948 put(f"cmp{idx}_saccade_color", sc) 

2949 # "" is a real value — "follow the fixation colour" — and must 

2950 # clear an override the session already holds. 

2951 bc = entry.get("box_color") 

2952 if isinstance(bc, str) and ( 

2953 bc == "" or re.fullmatch(r"#[0-9A-Fa-f]{6}", bc) 

2954 ): 

2955 put(f"cmp{idx}_box_color", bc) 

2956 # Likewise "" for the fill: follow the figure's. 

2957 bf = entry.get("box_fill_color") 

2958 if isinstance(bf, str) and ( 

2959 bf == "" or re.fullmatch(r"#[0-9A-Fa-f]{6}", bf) 

2960 ): 

2961 put(f"cmp{idx}_box_fill_color", bf) 

2962 # And for the raw-gaze samples: follow the fixation colour. 

2963 rg = entry.get("raw_gaze_color") 

2964 if isinstance(rg, str) and ( 

2965 rg == "" or re.fullmatch(r"#[0-9A-Fa-f]{6}", rg) 

2966 ): 

2967 put(f"cmp{idx}_raw_gaze_color", rg) 

2968 # "" again follows: the figure's heatmap colour scale. 

2969 if "heatmap_colorscale" in entry: 

2970 put_valid( 

2971 entry["heatmap_colorscale"] in ("", *COLORSCALES), 

2972 f"cmp{idx}_heatmap_colorscale", 

2973 entry["heatmap_colorscale"], 

2974 f"scanpath {idx + 1} heatmap color scale", 

2975 ) 

2976 if "saccade_style" in entry: 

2977 put_valid( 

2978 entry["saccade_style"] in SACCADE_DASH_OPTIONS, 

2979 f"cmp{idx}_saccade_style", 

2980 entry["saccade_style"], 

2981 f"scanpath {idx + 1} line style", 

2982 ) 

2983 if "saccade_width" in entry: 

2984 put_float( 

2985 entry["saccade_width"], 

2986 f"cmp{idx}_saccade_width", 

2987 SACCADE_WIDTH_BOUNDS[0], 

2988 SACCADE_WIDTH_BOUNDS[1], 

2989 f"scanpath {idx + 1} line width", 

2990 ) 

2991 rng = entry.get("marker_size_range") 

2992 if isinstance(rng, (list, tuple)) and len(rng) == 2: 

2993 lo, hi = number(rng[0]), number(rng[1]) 

2994 if lo is None or hi is None: 

2995 skipped.append(f"scanpath {idx + 1} marker size") 

2996 else: 

2997 lo = max(_MARKER_BOUNDS[0], min(int(lo), _MARKER_BOUNDS[1])) 

2998 hi = max(_MARKER_BOUNDS[0], min(int(hi), _MARKER_BOUNDS[1])) 

2999 put(f"cmp{idx}_marker_size_range", (min(lo, hi), max(lo, hi))) 

3000 if "hollow" in entry: 

3001 put(f"cmp{idx}_hollow", bool(entry["hollow"])) 

3002 if "opacity" in entry: 

3003 put_float( 

3004 entry["opacity"], 

3005 f"cmp{idx}_opacity", 

3006 0.1, 

3007 1.0, 

3008 f"scanpath {idx + 1} opacity", 

3009 ) 

3010 # UX-31: the A/B legend label override. 

3011 if isinstance(entry.get("label_pattern"), str): 

3012 # BUG-75: legend text is figure text too. 

3013 put(f"cmp{idx}_label_pattern", _strip_markup(entry["label_pattern"])) 

3014 # CMP-24: scanpath B's own filters ride its entry. A's are the 

3015 # config's ordinary `fixation_flags` / `saccade_classes`, so an 

3016 # entry-level copy on the first scanpath is not read. 

3017 if idx == 1: 

3018 _restore_compare_b_filters(entry, put) 

3019 

3020 selection = section("selection") 

3021 if selection: 

3022 if _restore_selection(selection, combos): 

3023 restore.applied += 1 

3024 else: 

3025 _row, reason = _match_selection(selection, combos) 

3026 skipped.append(f"trial selection ({reason})") 

3027 

3028 _restore_figure_mode(config, selection, combos, put, skipped) 

3029 

3030 return restore.applied, skipped 

3031 

3032 

3033def _compare_b_problem(compare: object, combos: pd.DataFrame) -> tuple[str | None, str]: 

3034 """``(B's dataset or None, "")`` when a saved scanpath B can be requested, 

3035 else ``(None, reason)``. 

3036 

3037 B in the open dataset is checked against the pool now, by the same exact 

3038 reader-and-trial rule as A. B in another dataset can only be checked once 

3039 that dataset is loaded — Compare's picker reports it then (see 

3040 `tabs` → the pending-compare consumer) — so here it is enough that the 

3041 dataset is one this app can draw B from. 

3042 """ 

3043 from .compare_source import secondary_dataset_options 

3044 

3045 if not isinstance(compare, dict) or compare.get("trial_id") in (None, ""): 

3046 return None, "the file names no second trial" 

3047 if compare.get("participant_id") in (None, ""): 

3048 return None, "the file's second trial names no participant" 

3049 source = compare.get("source") 

3050 if source in (None, "") or source == st.session_state.get("data_source_choice"): 

3051 _row, reason = _match_selection(compare, combos) 

3052 return None, reason 

3053 offered = { 

3054 name: (ready, why) 

3055 for name, ready, why in secondary_dataset_options( 

3056 exclude=st.session_state.get("data_source_choice") 

3057 ) 

3058 } 

3059 if source not in offered: 

3060 return None, f"its dataset {source} is not available here" 

3061 ready, why = offered[source] 

3062 if not ready: 

3063 return None, f"its dataset {source} is not ready — {why or 'not found'}" 

3064 return str(source), "" 

3065 

3066 

3067def _restore_figure_mode( 

3068 config: dict, selection: dict, combos: pd.DataFrame, put, skipped: list 

3069) -> None: 

3070 """Schema 6: put the figure back in the mode it was saved in. 

3071 

3072 ``mode`` is explicit both ways — a static file switches a running Animate 

3073 or Compare *off*, so restoring a figure gives that figure. A comparison 

3074 restores only with the B it names (requested through the same pending 

3075 selection a ``?compare=`` link uses, so B's picker resolves it); when B 

3076 cannot be named here, Compare stays off and the file's restore notice says 

3077 why, rather than pairing A with whichever reading B's picker defaults to. 

3078 """ 

3079 mode = config.get("mode") 

3080 if not isinstance(mode, dict): 

3081 return # an older file: it never said, so nothing is guessed 

3082 if isinstance(mode.get("animate"), bool): 

3083 put(SINGLE_ANIMATE, mode["animate"]) 

3084 if not isinstance(mode.get("compare"), bool): 

3085 return 

3086 if not mode["compare"]: 

3087 put(SINGLE_COMPARE_TOGGLE, False) 

3088 st.session_state.pop(PENDING_COMPARE_STATE_KEY, None) 

3089 return 

3090 compare = selection.get("compare") if isinstance(selection, dict) else None 

3091 source, reason = _compare_b_problem(compare, combos) 

3092 if reason: 

3093 put(SINGLE_COMPARE_TOGGLE, False) 

3094 st.session_state.pop(PENDING_COMPARE_STATE_KEY, None) 

3095 skipped.append(f"comparison ({reason})") 

3096 return 

3097 put(SINGLE_COMPARE_TOGGLE, True) 

3098 from .compare_source import THIS_DATASET 

3099 

3100 put(COMPARE_SOURCE_STATE_KEY, source or THIS_DATASET) 

3101 st.session_state[PENDING_COMPARE_STATE_KEY] = { 

3102 "participant_id": str(compare["participant_id"]), 

3103 "trial_id": str(compare["trial_id"]), 

3104 } 

3105 if compare.get("screen_id") not in (None, ""): 

3106 put(SINGLE_COMPARE_SCREEN_ID, str(compare["screen_id"])) 

3107 else: 

3108 st.session_state.pop(SINGLE_COMPARE_SCREEN_ID, None) 

3109 

3110 

3111def _apply_uploaded_plot_config(combos: pd.DataFrame, fixations: pd.DataFrame) -> None: 

3112 """Restore settings from a freshly uploaded plot-config JSON, once per file. 

3113 

3114 Reads the file captured by 🔗 Share → File's ``plot_config_upload`` 

3115 uploader (persisted in session_state across reruns) and writes the saved 

3116 settings into session_state *before* the widgets render — the same mechanism 

3117 as ``_apply_url_preset``. Deduped by upload identity (``upload_identity``: 

3118 the upload's ``file_id`` + a content hash) so manual tweaks made after a 

3119 restore aren't clobbered on every rerun, while a fresh upload — another 

3120 file, or the same one again — applies. Clearing the uploader forgets the 

3121 marker. Call right after the trial combos are built, before the 

3122 canvas/visualization controls.""" 

3123 replay = st.session_state.pop(_PLOT_CONFIG_TOAST_KEY, None) 

3124 if replay is not None: 

3125 _toast_restored(*replay) 

3126 uploaded = st.session_state.get("plot_config_upload") 

3127 if uploaded is None: 

3128 st.session_state.pop("_plot_config_last_import", None) 

3129 return 

3130 signature = upload_identity(uploaded) 

3131 if st.session_state.get("_plot_config_last_import") == signature: 

3132 return 

3133 # Stamp the signature up front so a malformed file isn't retried every rerun. 

3134 st.session_state["_plot_config_last_import"] = signature 

3135 st.session_state.pop("_plot_config_skipped", None) 

3136 try: 

3137 config = json.loads(uploaded.getvalue().decode("utf-8")) 

3138 if not isinstance(config, dict): 

3139 raise ValueError("expected a JSON object") 

3140 except (ValueError, UnicodeDecodeError): 

3141 st.toast("That file isn't a settings file.", icon=ICONS["warning"]) 

3142 return 

3143 try: 

3144 applied, skipped = _restore_plot_config(config, combos, fixations) 

3145 except Exception as exc: # backstop for an unexpectedly shaped config 

3146 st.toast(f"Couldn't apply that settings file: {exc}", icon=ICONS["warning"]) 

3147 return 

3148 st.session_state["_plot_config_skipped"] = skipped 

3149 staged = st.session_state.get(PENDING_PREPROC_RESTORE_KEY) or {} 

3150 if any(st.session_state.get(k) != v for k, v in staged.items()): 

3151 # Preprocessing reshapes the frames this run already filtered and drew, 

3152 # so run again with the restored values in place (the toast is replayed 

3153 # by the next run's `_apply_uploaded_plot_config`). 

3154 st.session_state[_PLOT_CONFIG_TOAST_KEY] = (applied, bool(skipped)) 

3155 st.rerun() 

3156 st.session_state.pop(PENDING_PREPROC_RESTORE_KEY, None) 

3157 _toast_restored(applied, bool(skipped)) 

3158 

3159 

3160_PLOT_CONFIG_TOAST_KEY = "_plot_config_restored_toast" 

3161 

3162 

3163def _toast_restored(applied: int, any_skipped: bool) -> None: 

3164 if applied: 

3165 st.toast( 

3166 f"Restored {plural(applied, 'setting')} from the settings file.", 

3167 icon=ICONS["success"], 

3168 ) 

3169 elif not any_skipped: 

3170 st.toast("The settings file had no recognized settings.", icon=ICONS["warning"]) 

3171 

3172 

3173def _build_share_query( 

3174 data_choice: str, 

3175 *, 

3176 include_participant: bool = True, 

3177 include_trial: bool = True, 

3178) -> tuple[str, list]: 

3179 """Build the deep-link query string that reproduces the current view. 

3180 

3181 Reads the resolved trial selection and visualization settings back out of 

3182 ``st.session_state`` and encodes them with the same URL schema 

3183 ``_apply_url_preset`` / ``_apply_url_trial_selection`` parse, so opening the 

3184 link reopens the app on this trial with these settings. 

3185 

3186 ``include_participant`` / ``include_trial`` (S3) let the caller leave the 

3187 identifying half out of the link — a URL lands in browser history, proxy 

3188 logs, ``Referer`` headers and chat previews, so naming a participant there 

3189 is opt-out-able. Dropping only the participant still lands on the 

3190 trial when its id is unique in the recipient's pool: ``_restore_selection`` 

3191 looks a participant-free request up by trial id alone. The 

3192 view settings are unaffected either way. 

3193 

3194 Returns ``(query_string, caveats)`` — ``caveats`` holds human-readable notes 

3195 when the link can't fully reproduce the view (e.g. an uploaded data source 

3196 a URL can't rebuild). The query string is URL-encoded and has no leading 

3197 ``?``; the copy widget composes it onto the live origin client-side. 

3198 """ 

3199 

3200 params: dict[str, str] = {} 

3201 caveats: list = [] 

3202 

3203 source = _SHAREABLE_SOURCES.get(data_choice) 

3204 # DATA-27 (Task 12): which public corpus this is, if any. Resolved only when 

3205 # the choice isn't already a token of its own, so the ordinary sources never 

3206 # pay for a registry lookup. `corpus_label` is the *registry label*, which is 

3207 # what the picker collapsed away — see `_selected_corpus`. 

3208 corpus_label, corpus_spec = ("", {}) if source else _selected_corpus(data_choice) 

3209 if corpus_label and not source: 

3210 # A corpus reachable by both tokens keeps emitting its own: OneStop's 

3211 # regimes (`onestop_<regime>`, DATA-63; `onestop_public` before it) 

3212 # have had one since DATA-3. The generic token is additive. 

3213 source = _SHAREABLE_SOURCES.get(corpus_label) 

3214 # Read through `registry_corpus_slugs`, not `corpus_slug` directly, so a slug 

3215 # another entry would also answer to is never emitted: the reader refuses it, 

3216 # and a link the recipient cannot resolve is worse than no link at all. 

3217 corpus_slug_out = ( 

3218 registry_corpus_slugs().get(corpus_label, "") 

3219 if corpus_label and not source 

3220 else "" 

3221 ) 

3222 if source: 

3223 params["source"] = source 

3224 elif corpus_slug_out: 

3225 params["source"] = CORPUS_SOURCE_TOKEN 

3226 params[PARAM_CORPUS] = corpus_slug_out 

3227 # Sharing "you'd need this corpus" beats sharing nothing, which is what a 

3228 # public corpus got before this. The recipient's bundle is theirs — and 

3229 # is the one thing a link can't carry — so the caveat names the corpus 

3230 # and says what to do about it rather than the link quietly not working. 

3231 if prepared := str(corpus_spec.get("benchmark_dataset") or ""): 

3232 caveats.append( 

3233 f"**{prepared}** is a locally prepared corpus. The link names it, " 

3234 "but the data can't travel in a URL: the recipient needs their " 

3235 "own harmonised bundle holding that corpus, with the app's " 

3236 "benchmark data directory pointed at it. Everything else in the " 

3237 "link still applies." 

3238 ) 

3239 else: 

3240 # A link carries settings, never files — so for a dataset the user added 

3241 # there is nothing a URL could do that would save the recipient the 

3242 # upload. What it *can* do is name the route that saves them the 

3243 # re-mapping, which is a real second half of "load the same data": the 

3244 # column mapping and recording setup are exportable as JSON from the 

3245 # add-dataset screen's ⬇️ Download setup file, and re-applied from that screen's 

3246 # *Restore a saved setup*. The caveat used to stop at "load the same 

3247 # data" and leave the mapping to be redone by hand. 

3248 # 

3249 # #374 F14: the link names the dataset, so the recipient's app can open 

3250 # one of that name and say which is missing when it has none. 

3251 if data_choice in (st.session_state.get("_datasets") or {}): 

3252 params[PARAM_DATASET] = str(data_choice) 

3253 caveats.append( 

3254 "This dataset's files can't travel in a link — the recipient needs " 

3255 "them too. Send them with its setup file " 

3256 f"({ICONS['edit']} **Edit dataset → Download setup file**): they add the " 

3257 f"dataset with {ICONS['add']} **Add dataset → Import files** and " 

3258 "restore the setup there. The link names the dataset and carries " 

3259 "the view settings." 

3260 ) 

3261 

3262 if data_choice in (AUTHOR_CHOICE, MANUAL_SAMPLE_CHOICE): 

3263 params["author_text"] = str(st.session_state.get("author_text", "")) 

3264 events = st.session_state.get("_authored_events_frame") 

3265 draft = st.session_state.get("_manual_scanpath_drafts", {}).get(data_choice) 

3266 if draft is not None: 

3267 events = draft[2] 

3268 if isinstance(events, pd.DataFrame): 

3269 params["author_events"] = json.dumps( 

3270 events.to_dict("records"), separators=(",", ":") 

3271 ) 

3272 

3273 selection = st.session_state.get("_share_selection") or {} 

3274 participant = selection.get("participant_id") 

3275 trial_id = selection.get("trial_id") 

3276 screen_id = selection.get("screen_id") 

3277 if include_participant and participant not in (None, ""): 

3278 params["participant"] = str(participant) 

3279 if include_trial and trial_id not in (None, ""): 

3280 params["trial_id"] = str(trial_id) 

3281 if include_trial and screen_id not in (None, ""): 

3282 params["screen"] = str(screen_id) 

3283 

3284 # CMP-8 §7: the second scanpath. Gated on `include_trial` for the same reason 

3285 # A's trial is — lower-level callers may omit identity from a URL even though 

3286 # the app's Share panel always includes it. B's source rides along only when it is one a URL 

3287 # can rebuild; an uploaded dataset lives in session state, so the link says so 

3288 # rather than silently dropping half the comparison. 

3289 compare = selection.get("compare") if include_trial else None 

3290 if isinstance(compare, dict) and compare.get("trial_id") not in (None, ""): 

3291 participant_b = compare.get("participant_id") 

3292 if include_participant and participant_b not in (None, ""): 

3293 params[COMPARE_PARAM] = compare_value(participant_b, compare["trial_id"]) 

3294 else: 

3295 # `compare=` has no trial-only spelling — it is `<pid>:<trial>`, and 

3296 # a trial id alone is ambiguous across readers in B's corpus. Under 

3297 # a programmatic link that drops participants, the comparison therefore 

3298 # cannot travel, and the link must say so rather than arrive as a 

3299 # single scanpath the recipient has no way to know was a pair. 

3300 caveats.append( 

3301 "The compared scanpath names a second participant, so it isn't " 

3302 "included at this privacy setting — the link opens the first " 

3303 "scanpath only." 

3304 ) 

3305 source_b = compare.get("source") 

3306 if source_b: 

3307 token = _SHAREABLE_SOURCES.get(source_b) 

3308 if token and COMPARE_PARAM in params: 

3309 params[COMPARE_SOURCE_PARAM] = token 

3310 elif COMPARE_PARAM in params: 

3311 params.pop(COMPARE_PARAM) 

3312 caveats.append( 

3313 f"The compared scanpath comes from **{source_b}**, which " 

3314 "can't be rebuilt from a link — it isn't included." 

3315 ) 

3316 if COMPARE_PARAM in params and compare.get("screen_id") not in (None, ""): 

3317 params[COMPARE_SCREEN_PARAM] = str(compare["screen_id"]) 

3318 

3319 # Visualization toggles — emit an explicit 0/1 so a layer the user turned 

3320 # *off* is shared as off (the URL coercion reads "0" as False). 

3321 for url_key, state_key in _SHARE_TOGGLE_PARAMS.items(): 

3322 if url_key in _GATED_URL_PARAMS and not _GATED_URL_PARAMS[url_key](): 

3323 continue # PRE-21 

3324 if state_key in st.session_state: 

3325 params[url_key] = "1" if st.session_state[state_key] else "0" 

3326 # Strings / choices / colours / numbers — emit only when set. 

3327 for url_key, state_key in {**_SHARE_VALUE_PARAMS, **_SHARE_INT_PARAMS}.items(): 

3328 if url_key in _GATED_URL_PARAMS and not _GATED_URL_PARAMS[url_key](): 

3329 continue # PRE-21: don't put a gated setting on a link. 

3330 value = st.session_state.get(state_key) 

3331 if value not in (None, ""): 

3332 params[url_key] = ( 

3333 ",".join(str(item) for item in value) 

3334 if isinstance(value, (list, tuple)) 

3335 else str(value) 

3336 ) 

3337 for url_key, state_key in _SHARE_FLOAT_PARAMS.items(): 

3338 value = st.session_state.get(state_key) 

3339 if value is not None: 

3340 params[url_key] = str(value) 

3341 # Two-element ranges → "lo,hi". 

3342 for url_key, state_key in { 

3343 **_SHARE_INT_RANGE_PARAMS, 

3344 **_SHARE_FLOAT_RANGE_PARAMS, 

3345 }.items(): 

3346 value = st.session_state.get(state_key) 

3347 if isinstance(value, (list, tuple)) and len(value) == 2: 

3348 params[url_key] = f"{value[0]},{value[1]}" 

3349 # CMP-11: the two compare-view params describe a comparison, so they only 

3350 # travel when one does. Both widgets carry `persist_state="session"`, so the 

3351 # generic value sweep above would otherwise stamp `cmp_layout`/`cmp_stimulus` 

3352 # onto every later link — including ones where `compare=` was deliberately 

3353 # withheld by the identity picker or dropped because B's corpus can't be 

3354 # rebuilt. They restore nothing on their own. 

3355 if COMPARE_PARAM not in params: 

3356 params.pop(COMPARE_LAYOUT_PARAM, None) 

3357 params.pop(COMPARE_STIMULUS_PARAM, None) 

3358 # VIZ-40 — VIZ-7's fixation window travels only when it *is* a window, for 

3359 # the same shape of reason as the two compare params above: `single_fix_range` 

3360 # is set to the trial's own full range the moment the slider renders (and 

3361 # re-expanded on every trial change), so the generic range sweep would stamp 

3362 # `fix_range` on every link ever copied. Two things have to hold. 

3363 # 

3364 # `single_fix_range_user_set` is the slider's own record of a deliberate 

3365 # drag — the same flag that stops an untouched window following the user from 

3366 # trial to trial — so an auto-default never ships. 

3367 # 

3368 # And the window must differ from the trial's full range, which 

3369 # `tabs.render_single_trial_tab` publishes as `full_fix_range`: dragging the 

3370 # handles back out to both ends restores nothing, and a recipient whose copy 

3371 # of the trial is longer would have it silently truncated to the sender's 

3372 # length. When the trial has no `order_in_trial` there is no full range to 

3373 # compare against, and the `user_set` flag alone decides. 

3374 # 

3375 # It is also gated on `include_trial`, exactly as `trial_id`, `screen` and 

3376 # `compare` above are: an index window means nothing without the trial it 

3377 # indexes into. On a link that withholds trial identity the recipient lands 

3378 # on an arbitrary trial, and the slider clamps the window to *that* trial's 

3379 # length — so a 509–540 window arriving at a 30-fixation trial silently 

3380 # collapses to a single fixation. Only headless callers can reach this (the 

3381 # UI has no identity-mode picker), which is what makes it worth stating. 

3382 window = st.session_state.get("single_fix_range") 

3383 if not include_trial or not st.session_state.get("single_fix_range_user_set"): 

3384 params.pop(FIX_RANGE_PARAM, None) 

3385 elif isinstance(window, (list, tuple)) and len(window) == 2: 

3386 full = (st.session_state.get("_share_selection") or {}).get("full_fix_range") 

3387 if full is not None and tuple(int(v) for v in window) == tuple( 

3388 int(v) for v in full 

3389 ): 

3390 params.pop(FIX_RANGE_PARAM, None) 

3391 # CMP-24 — B's window on exactly the terms of A's, against B's own flag and 

3392 # B's own full range (`compare_full_fix_range`), and only beside the 

3393 # `compare=` that names the trial it indexes into. 

3394 window_b = st.session_state.get("single_compare_fix_range") 

3395 if COMPARE_PARAM not in params or not st.session_state.get( 

3396 "single_compare_fix_range_user_set" 

3397 ): 

3398 params.pop(COMPARE_FIX_RANGE_PARAM, None) 

3399 elif isinstance(window_b, (list, tuple)) and len(window_b) == 2: 

3400 full_b = (st.session_state.get("_share_selection") or {}).get( 

3401 "compare_full_fix_range" 

3402 ) 

3403 if full_b is not None and tuple(int(v) for v in window_b) == tuple( 

3404 int(v) for v in full_b 

3405 ): 

3406 params.pop(COMPARE_FIX_RANGE_PARAM, None) 

3407 # EXP-19 — the recording setup and Compare's per-scanpath styles travel only 

3408 # when they say something the recipient's own session would not: the 

3409 # generic sweeps above stamp every seeded key, and a demo link that restated 

3410 # the demo's 2560x1440 would pin that canvas even where the source is later 

3411 # re-declared. The styles additionally need a comparison to describe — the 

3412 # same rule as `cmp_layout` / `cmp_stimulus`. 

3413 defaults = _link_defaults(data_choice) 

3414 for url_key, state_key in {**SETUP_PARAMS, **COMPARE_STYLE_PARAMS}.items(): 

3415 if url_key not in params: 

3416 continue 

3417 # The recording setup is the *source's*: a link that cannot name the 

3418 # source (an uploaded dataset) would pin its canvas on whatever the 

3419 # recipient happens to have open. It travels in the dataset's own ⬇️ Save 

3420 # setup JSON instead, which the source caveat above already points at. 

3421 orphaned = ( 

3422 url_key in COMPARE_STYLE_PARAMS and COMPARE_PARAM not in params 

3423 ) or (url_key in SETUP_PARAMS and "source" not in params) 

3424 restated = state_key in defaults and _same_setting( 

3425 st.session_state.get(state_key), defaults[state_key] 

3426 ) 

3427 if orphaned or restated: 

3428 params.pop(url_key) 

3429 _legend_query(params) 

3430 # #374 F28 — the print size travels only while a width is set; without 

3431 # one the PNG is drawn at the screen size, which needs nothing said. 

3432 if not st.session_state.get(EXPORT_PARAMS["export_width"]): 

3433 for url_key in EXPORT_PARAMS: 

3434 params.pop(url_key, None) 

3435 if st.session_state.get("single_animate"): 

3436 params["tab"] = "animation" 

3437 

3438 # DATA-22 §7 surface 2: a compact provenance badge for the recording setup. 

3439 # Since EXP-19 the link carries the setup's *values* too, wherever they 

3440 # differ from the corpus' own — but it also carries how the sender's setup 

3441 # was known: without this the recipient cannot tell a monitor the sender 

3442 # measured from one the app assumed on their behalf. Metadata about 

3443 # settings, not a setting — it takes no input and changes no figure, which 

3444 # is why it stops here and never becomes a `render` flag or a builder 

3445 # argument. 

3446 from scanpath_studio.app import active_setup_snapshot 

3447 

3448 snapshot = active_setup_snapshot(data_choice) 

3449 if snapshot is not None: 

3450 params[SETUP_PROVENANCE_PARAM] = format_provenance_param(snapshot) 

3451 

3452 # EXP-22: a `{trials.font_size}`-style field reads a metadata table, and 

3453 # the tables belong to the sender's dataset — they never ride a link. The 

3454 # pattern travels; its value only resolves where the same table is attached. 

3455 if any( 

3456 st.session_state.get(show) 

3457 and f"{{{table}." in str(st.session_state.get(key) or "") 

3458 for show, key in ( 

3459 ("global_show_title", "global_title_pattern"), 

3460 ("global_show_caption", "global_caption_pattern"), 

3461 ) 

3462 for table in ("participants", "trials", "texts") 

3463 ): 

3464 caveats.append( 

3465 "The title or caption names a metadata table's field (like " 

3466 "`{trials.font_size}`). Metadata tables don't travel in a link, so " 

3467 "it shows empty unless the recipient attaches the same table." 

3468 ) 

3469 return urlencode(params), caveats 

3470 

3471 

3472def _restore_compare_b_filters(entry: dict, put) -> None: 

3473 """Seed scanpath B's filter keys from its saved-config ``compare`` entry 

3474 (CMP-24) — the same validation A's ``fixation_flags`` / ``saccade_classes`` 

3475 get, onto B's ``cmp1_*`` keys. B saves no marker or colour (it draws with 

3476 A's), so only each category's mode and threshold are read.""" 

3477 flags = entry.get("fixation_flags") 

3478 if isinstance(flags, dict): 

3479 for cat in _FIXCLASS_CATEGORIES: 

3480 spec = flags.get(cat) 

3481 if not isinstance(spec, dict): 

3482 continue 

3483 if spec.get("mode") in _FIXCLASS_MODES: 

3484 put(f"cmp1_fixclass_{cat}_mode", spec["mode"]) 

3485 if cat in ("short", "long") and spec.get("threshold_ms") is not None: 

3486 try: 

3487 put( 

3488 f"cmp1_fixclass_{cat}_threshold_ms", 

3489 int(float(spec["threshold_ms"])), 

3490 ) 

3491 except (TypeError, ValueError): 

3492 pass 

3493 classes = entry.get("saccade_classes") 

3494 if isinstance(classes, list): 

3495 kept = [cls for cls in SACCADE_CLASS_ORDER if cls in set(classes)] 

3496 if kept: 

3497 put("cmp1_saccade_classes", kept) 

3498 

3499 

3500def _link_defaults(data_choice: str) -> dict: 

3501 """EXP-19 — what a recipient's own session resolves for the settings a link 

3502 carries only when they differ, as ``{session key: value}``. 

3503 

3504 A key missing from the result has no default the sender can know, so it 

3505 always travels: the canvas of a source that declares no monitor is estimated 

3506 from the data extents, which this function does not have (and, on a session 

3507 that has switched sources, the live canvas may not be that estimate at all). 

3508 

3509 * **Canvas** — the source's declared monitor (`app.resolve_source_monitor`, 

3510 without frames: an authoritative source answers from its registry, and the 

3511 recipient's first run snaps to exactly that). 

3512 * **DPI** — derived, as `app.seed_canvas_state` pins it, from the canvas and 

3513 physical width the recipient will have. Those are the sender's own (each 

3514 either on the link or re-resolved to the same value), so a DPI that still 

3515 follows from them is re-derived identically and need not be sent. 

3516 * **Base font** — the factory 16, except on a source that declares its own 

3517 typeface: that one snaps the font on first seeding, so it has no default 

3518 the sender can leave off and always travels. 

3519 * **Everything else** — the factory values a fresh session pins 

3520 (`app.SETUP_DEFAULTS`, `controls.compare_style_defaults`). 

3521 

3522 The elision assumes a *fresh* recipient session. On a machine with the 

3523 recovery cache, the cache restores after the link's presets, so a setting 

3524 left off takes the recipient's cached value rather than the default. 

3525 """ 

3526 from scanpath_studio.app import ( 

3527 _FONT_SNAP_RESTORE_KEY, 

3528 SETUP_DEFAULTS, 

3529 resolve_source_monitor, 

3530 ) 

3531 from scanpath_studio.controls import compare_style_defaults 

3532 

3533 defaults = dict(SETUP_DEFAULTS) 

3534 if _FONT_SNAP_RESTORE_KEY in st.session_state: 

3535 # The source declares its typeface (MultiplEYE), so its first seeding 

3536 # snaps the base font to *that* — a sender who chose the factory 16 

3537 # there has to say so, or the recipient gets the corpus' size. 

3538 defaults.pop("global_base_font_size") 

3539 # What the source itself declares: the recipient has none of this 

3540 # session's own saved setup for it, so a canvas the sender saved travels. 

3541 width, height, authoritative = resolve_source_monitor( 

3542 data_choice, None, None, own_setup=False 

3543 ) 

3544 if authoritative: 

3545 lo, hi = _CANVAS_BOUNDS 

3546 defaults["global_canvas_width"] = min(max(int(width), lo), hi) 

3547 defaults["global_canvas_height"] = min(max(int(height), lo), hi) 

3548 canvas = st.session_state.get("global_canvas_width") 

3549 monitor_mm = st.session_state.get( 

3550 "global_monitor_width_mm", SETUP_DEFAULTS["global_monitor_width_mm"] 

3551 ) 

3552 try: 

3553 defaults["global_display_dpi"] = round( 

3554 float(canvas) / (float(monitor_mm) / 25.4), 2 

3555 ) 

3556 except (TypeError, ValueError, ZeroDivisionError): 

3557 pass # no canvas yet — the DPI, if set at all, travels as it is 

3558 defaults.update(compare_style_defaults()) 

3559 return defaults 

3560 

3561 

3562def _same_setting(value, default) -> bool: 

3563 """Whether a session value restates ``default`` (EXP-19's elision test). 

3564 

3565 Colours compare case-blind (the pickers hand back lowercase hex, the 

3566 constants are upper-case), numbers numerically (an int canvas against a 

3567 float, a tuple range against a list), everything else by equality.""" 

3568 if isinstance(value, bool) or isinstance(default, bool): 

3569 return value is default 

3570 if isinstance(value, str) and isinstance(default, str): 

3571 return value.strip().lower() == default.strip().lower() 

3572 if isinstance(value, (list, tuple)) and isinstance(default, (list, tuple)): 

3573 return len(value) == len(default) and all( 

3574 _same_setting(a, b) for a, b in zip(value, default, strict=True) 

3575 ) 

3576 try: 

3577 return math.isclose(float(value), float(default), rel_tol=0.0, abs_tol=1e-9) 

3578 except (TypeError, ValueError): 

3579 return value == default 

3580 

3581 

3582def _render_share_link_widget(query: str) -> None: 

3583 """Render the current share link and its single Refresh & Copy action. 

3584 

3585 A same-origin ``st.iframe`` embed (same trick as the tour — see 

3586 ``tour.render_spotlight_tour``) composes the full URL from the *live* address: 

3587 ``window.parent.location.origin + pathname`` + the query string built 

3588 server-side. Doing the origin/path join client-side means the link is correct 

3589 wherever the app is served (localhost, Streamlit Cloud, a reverse proxy) 

3590 without the server having to know its own public URL. Copy uses the async 

3591 Clipboard API with a ``document.execCommand`` fallback for insecure contexts. 

3592 """ 

3593 payload = json.dumps(query) 

3594 embed_html_iframe( 

3595 f""" 

3596 <div class="sps-share"> 

3597 <div class="sps-share-row"> 

3598 <input id="sps-share-url" type="text" readonly 

3599 aria-label="Shareable link" /> 

3600 <button id="sps-share-action" type="button">Copy link</button> 

3601 </div> 

3602 <div id="sps-share-status" class="sps-share-status"></div> 

3603 </div> 

3604 <style> 

3605 .sps-share {{ 

3606 font-family: "Source Sans Pro", system-ui, sans-serif; 

3607 color-scheme: light dark; color: inherit; 

3608 }} 

3609 .sps-share-row {{ display: flex; gap: 0.4rem; align-items: stretch; }} 

3610 #sps-share-url {{ 

3611 flex: 1 1 auto; min-width: 0; padding: 0.45rem 0.6rem; 

3612 border: 1px solid rgba(128, 128, 128, 0.5); border-radius: 8px; 

3613 background: rgba(128, 128, 128, 0.08); color: inherit; 

3614 font-size: 0.85rem; font-family: ui-monospace, monospace; 

3615 }} 

3616 #sps-share-action {{ 

3617 flex: 0 0 auto; padding: 0.45rem 0.9rem; cursor: pointer; 

3618 border: 1px solid #1f77b4; border-radius: 8px; white-space: nowrap; 

3619 background: #1f77b4; color: #fff; font-weight: 600; font-size: 0.85rem; 

3620 }} 

3621 #sps-share-action:hover {{ background: #185fa5; }} 

3622 .sps-share-status {{ 

3623 min-height: 1.1rem; margin-top: 0.35rem; font-size: 0.8rem; 

3624 color: #2e7d32; font-weight: 600; 

3625 }} 

3626 </style> 

3627 <script> 

3628 (function () {{ 

3629 const query = {payload}; 

3630 // The iframe is its own document, so `inherit` yields the browser's 

3631 // default (dark) text on a dark Streamlit theme. Same-origin: copy the 

3632 // host app's text colour and scheme. Streamlit applies its theme after 

3633 // first paint (and on a live theme switch), so keep it in sync. 

3634 function syncTheme() {{ 

3635 try {{ 

3636 const p = window.parent; 

3637 const host = p.document.querySelector(".stApp") || p.document.body; 

3638 const cs = p.getComputedStyle(host); 

3639 const root = document.documentElement.style; 

3640 if (root.color !== cs.color) root.color = cs.color; 

3641 if (root.colorScheme !== cs.colorScheme) root.colorScheme = cs.colorScheme; 

3642 }} catch (e) {{ /* cross-origin: keep the media-query fallback */ }} 

3643 }} 

3644 syncTheme(); 

3645 setInterval(syncTheme, 400); 

3646 const loc = window.parent.location; 

3647 const base = loc.origin + loc.pathname; 

3648 const url = query ? base + "?" + query : base; 

3649 const input = document.getElementById("sps-share-url"); 

3650 const status = document.getElementById("sps-share-status"); 

3651 const btn = document.getElementById("sps-share-action"); 

3652 input.value = url; 

3653 input.addEventListener("focus", function () {{ input.select(); }}); 

3654 function flash(msg) {{ 

3655 status.textContent = msg; 

3656 setTimeout(function () {{ status.textContent = ""; }}, 2500); 

3657 }} 

3658 async function copy() {{ 

3659 try {{ 

3660 await navigator.clipboard.writeText(url); 

3661 flash("✓ Link copied to clipboard"); 

3662 }} catch (err) {{ 

3663 input.focus(); 

3664 input.select(); 

3665 try {{ 

3666 document.execCommand("copy"); 

3667 flash("✓ Link copied to clipboard"); 

3668 }} catch (err2) {{ 

3669 flash("Press ⌘/Ctrl-C to copy the selected link"); 

3670 }} 

3671 }} 

3672 }} 

3673 btn.addEventListener("click", copy); 

3674 }})(); 

3675 </script> 

3676 """, 

3677 # One control row plus the transient copy-status line. The previous 

3678 # 110 px frame reserved a visibly empty block before the note below. 

3679 height=76, 

3680 alt="Shareable link with a copy button", 

3681 # #374 F19: its Copy button must be reachable from the keyboard. 

3682 focusable=True, 

3683 ) 

3684 

3685 

3686# ----------------------------------------------------------------------------- 

3687# EXP-7 — the API / CLI code that reproduces the figure on screen 

3688# ----------------------------------------------------------------------------- 

3689#: The Share subtab's two snippet controls. UI-only, exactly like 

3690#: `share_identity_mode`: they govern how the *recipe* is written, not what the 

3691#: figure is, so neither belongs on the wire — a deep link carrying "show me the 

3692#: CLI form" would be describing the reader's pane, not the view. If either is 

3693#: ever persisted into a saved config it has to join 

3694#: `session_keys.PLOT_CONFIG_STATE_KEYS` first. 

3695SNIPPET_FLAVOR_KEY = "snippet_flavor" 

3696SNIPPET_EXPLICIT_KEY = "snippet_explicit" 

3697 

3698_SNIPPET_FLAVORS = (f"{ICONS['python']} Python", f"{ICONS['cli']} CLI") 

3699 

3700#: Output filename the snippet saves to, per figure kind. An animation is 

3701#: interactive HTML; the static and comparison figures raster. 

3702_SNIPPET_OUTPUT = { 

3703 "static": "scanpath.png", 

3704 "comparison": "comparison.png", 

3705 "animation": "scanpath.html", 

3706} 

3707 

3708 

3709def _snippet_source(data_choice: str) -> SnippetSource: 

3710 """Describe the loaded data the way a script would have to load it. 

3711 

3712 Dispatches on the registry entry's **stable identifier** (`short` for a 

3713 built-in, `benchmark_dataset` for a prepared corpus), not the display label 

3714 — the same rule `corpus_slug` follows for the share link, and for the same 

3715 reason: the label is copy and can be reworded. 

3716 

3717 A corpus root is a *local path*, which is why it is emitted only when the 

3718 path box is the user's own (S2 `local_filesystem_enabled`). On a shared 

3719 deployment the location comes from the server's configuration and the user 

3720 never sees it, so quoting it back in a copyable snippet would hand every 

3721 visitor the server's layout. There the snippet carries a placeholder. 

3722 """ 

3723 from scanpath_studio.app import _download_target, local_filesystem_enabled 

3724 

3725 def root(key: str, placeholder: str, *, downloadable: bool = False) -> str: 

3726 if not local_filesystem_enabled(): 

3727 return placeholder 

3728 # UX-184: before its box renders, a downloadable corpus is where the 

3729 # Download folder puts it — the folder the snippet's reader has it in. 

3730 fallback = _download_target(placeholder) if downloadable else placeholder 

3731 return str(st.session_state.get(key) or fallback) 

3732 

3733 if data_choice == DEMO_CHOICE: 

3734 return SnippetSource(kind=SOURCE_DEMO, label=DEMO_CHOICE) 

3735 if data_choice == SYNTHETIC_CHOICE: 

3736 return SnippetSource(kind=SOURCE_SYNTHETIC, label=SYNTHETIC_CHOICE) 

3737 if data_choice in (AUTHOR_CHOICE, MANUAL_SAMPLE_CHOICE): 

3738 return SnippetSource( 

3739 kind=SOURCE_AUTHOR, 

3740 label=AUTHOR_CHOICE, 

3741 options={"path": "scanpath.json"}, 

3742 note=( 

3743 "An authored scanpath exists only in the app — save it with " 

3744 "**Download authoring file** on *Author a scanpath* first (it " 

3745 "saves as `scanpath.json`, which the snippet reads), then run " 

3746 "the snippet beside it." 

3747 ), 

3748 ) 

3749 

3750 corpus_label, spec = _selected_corpus(data_choice) 

3751 if spec.get("benchmark_dataset"): 

3752 return SnippetSource( 

3753 kind=SOURCE_BENCHMARK, 

3754 label=corpus_label, 

3755 options={ 

3756 "root": root("eyegenbench_dir", "data/eyegenbench"), 

3757 "dataset": str(spec["benchmark_dataset"]), 

3758 }, 

3759 ) 

3760 short = str(spec.get("short") or "") 

3761 if short == "PoTeC": 

3762 return SnippetSource( 

3763 kind=SOURCE_POTEC, 

3764 label=corpus_label, 

3765 options={"root": root("potec_dir", "data/PoTeC", downloadable=True)}, 

3766 ) 

3767 if short == "MultiplEYE": 

3768 fixation_source = str( 

3769 st.session_state.get("multipleye_fixation_source") or "scanpaths" 

3770 ) 

3771 return SnippetSource( 

3772 kind=SOURCE_MULTIPLEYE, 

3773 label=corpus_label, 

3774 options={ 

3775 "root": root("multipleye_dir", "data/MultiplEYE"), 

3776 "fixation_source": fixation_source, 

3777 }, 

3778 # `render --source multipleye` has no fixation-source flag, so the 

3779 # non-default reading of the corpus can only be said in Python. 

3780 cli_unsupported=( 

3781 () if fixation_source == "scanpaths" else ("fixation_source",) 

3782 ), 

3783 ) 

3784 if regime := onestop_regime_for_choice(corpus_label or data_choice): 

3785 # DATA-63: one regime's dataset — every part, from the public release. 

3786 from scanpath_studio import datasets 

3787 

3788 return SnippetSource( 

3789 kind=SOURCE_ONESTOP, 

3790 label=corpus_label or data_choice, 

3791 options={ 

3792 "root": root("onestop_public_dir", "data/OneStop", downloadable=True), 

3793 "regime": regime, 

3794 "variant": "public", 

3795 "parts": datasets.onestop_regime_parts(regime), 

3796 }, 

3797 ) 

3798 if data_choice == ONESTOP_CHOICE: 

3799 # The 🗄️ server bundle is the lab export by definition; it has no 

3800 # variant picker of its own. It is also read through a *different* 

3801 # loader from the public one — `data.load_onestop_server_bundle`, 

3802 # which takes the per-pid shards or the CSV.zip exports under 

3803 # `$ONESTOP_DATA_DIR`. `load_onestop` is the public API's nearest 

3804 # twin but reads the regime/part report layout, so the difference is 

3805 # stated rather than papered over: a snippet that silently pointed 

3806 # the wrong loader at the right folder would fail on the user's 

3807 # machine with nothing to explain it. 

3808 return SnippetSource( 

3809 kind=SOURCE_ONESTOP, 

3810 label=data_choice, 

3811 options={ 

3812 "root": root("onestop_lacclab_dir", "data/OneStop"), 

3813 "regime": "ordinary", 

3814 "variant": "lacclab", 

3815 "parts": ["Paragraph"], 

3816 }, 

3817 note=( 

3818 "The app read this through its **server-bundle** path " 

3819 "(`$ONESTOP_DATA_DIR`, per-participant shards or the CSV.zip " 

3820 "exports). The snippet uses the public `load_onestop` loader, " 

3821 "which expects the regime/part report layout in that same " 

3822 "folder — point it at your reports if the two differ." 

3823 ), 

3824 ) 

3825 if data_choice == MULTIPLEYE_BUNDLE_CHOICE: 

3826 # Unlike OneStop's, this bundle loader *is* `multipleye_raw_frames` over 

3827 # the configured root — the same call `load_multipleye` makes — so the 

3828 # snippet reproduces it exactly and needs no caveat. 

3829 return SnippetSource( 

3830 kind=SOURCE_MULTIPLEYE, 

3831 label=MULTIPLEYE_BUNDLE_CHOICE, 

3832 options={"root": root("multipleye_dir", "data/MultiplEYE")}, 

3833 ) 

3834 stored = (st.session_state.get("_datasets") or {}).get(data_choice) 

3835 if isinstance(stored, dict) and _samples_only(stored): 

3836 # VIZ-45: an uploaded dataset recorded as raw gaze alone. Its snippet 

3837 # loads the samples (under a placeholder path — an upload has none the 

3838 # server could quote) and hands the builder no words or fixations, 

3839 # rather than the generic two-table loader it could never have run. 

3840 return SnippetSource( 

3841 kind=SOURCE_RAW_GAZE, 

3842 label=data_choice, 

3843 note=UNKNOWN_SOURCE_NOTE, 

3844 ) 

3845 if isinstance(stored, dict): 

3846 from scanpath_studio.column_names import stored_source_recipe 

3847 

3848 return upload_source( 

3849 data_choice, 

3850 stored_source_recipe(stored), 

3851 words=_has_rows(stored.get("words")), 

3852 fixations=_has_rows(stored.get("fixations")), 

3853 ) 

3854 return SnippetSource( 

3855 kind=SOURCE_UNKNOWN, 

3856 label=data_choice, 

3857 note=UNKNOWN_SOURCE_NOTE, 

3858 ) 

3859 

3860 

3861def _has_rows(frame) -> bool: 

3862 return frame is not None and not getattr(frame, "empty", True) 

3863 

3864 

3865def _samples_only(stored: dict) -> bool: 

3866 """A stored dataset whose only table is raw gaze (VIZ-45).""" 

3867 

3868 def empty(frame) -> bool: 

3869 return frame is None or getattr(frame, "empty", True) 

3870 

3871 return ( 

3872 empty(stored.get("words")) 

3873 and empty(stored.get("fixations")) 

3874 and not empty(stored.get("raw_gaze")) 

3875 ) 

3876 

3877 

3878def _snippet_save_kwargs() -> dict: 

3879 """`save_figure`'s size keywords for the PNG the Export subtab writes.""" 

3880 from scanpath_studio.export import png_save_kwargs 

3881 

3882 ss = st.session_state 

3883 return png_save_kwargs( 

3884 ss.get(EXPORT_PARAMS["export_width"]), 

3885 ss.get(EXPORT_PARAMS["export_width_unit"]) or "mm", 

3886 ss.get(EXPORT_PARAMS["export_dpi"]), 

3887 ) 

3888 

3889 

3890def _render_code_snippet_body(data_choice: str) -> None: 

3891 """Render the **reproduce this figure in code** block of the Share subtab. 

3892 

3893 The figure state comes from `tabs._publish_snippet_state`, written on the 

3894 run that drew the figure — so what is quoted here is the plot's own input, 

3895 not a second reading of the widgets. Nothing is rendered when no scanpath 

3896 has been drawn this session (the Corpus view can reach this panel). 

3897 """ 

3898 state = st.session_state.get(SNIPPET_STATE_KEY) 

3899 if not isinstance(state, FigureState): 

3900 st.caption( 

3901 f"Open a trial on the {ICONS['view_scanpath']} Scanpath view and the code that rebuilds " 

3902 "its figure appears here." 

3903 ) 

3904 return 

3905 

3906 st.markdown( 

3907 "**Reproduce this figure in code** — paste it into a notebook or a " 

3908 "terminal to rebuild exactly this plot, headlessly." 

3909 ) 

3910 flavor_col, explicit_col = st.columns([2, 3], vertical_alignment="center") 

3911 flavor = flavor_col.segmented_control( 

3912 "Flavour", 

3913 options=_SNIPPET_FLAVORS, 

3914 default=_SNIPPET_FLAVORS[0], 

3915 key=SNIPPET_FLAVOR_KEY, 

3916 label_visibility="collapsed", 

3917 ) 

3918 explicit = explicit_col.checkbox( 

3919 "Show every option", 

3920 key=SNIPPET_EXPLICIT_KEY, 

3921 help="By default only the options you changed are written, so the " 

3922 "snippet stays readable. Tick this for the full explicit form — every " 

3923 "figure option at its current value.", 

3924 ) 

3925 output = _SNIPPET_OUTPUT.get(state.kind, "scanpath.png") 

3926 code = reproduction_code( 

3927 _snippet_source(data_choice), 

3928 state, 

3929 explicit=bool(explicit), 

3930 output=output, 

3931 # #374 F28: the PNG Export → Current figure writes, at its pixel size. 

3932 save_kwargs=_snippet_save_kwargs() if output.endswith(".png") else None, 

3933 ) 

3934 # Inspectable from AppTest without re-deriving it (same trick as 

3935 # `_share_query_current` above). 

3936 st.session_state["_snippet_code_current"] = code 

3937 for note in code.caveats: 

3938 st.caption(f"{ICONS['warning']} " + note) 

3939 if flavor == _SNIPPET_FLAVORS[1]: 

3940 if code.cli_unsupported: 

3941 st.caption( 

3942 f"{ICONS['warning']} `render` has no flag for " 

3943 + ", ".join(f"`{name}`" for name in code.cli_unsupported) 

3944 + f" — the {ICONS['python']} Python form carries " 

3945 + ("them." if len(code.cli_unsupported) > 1 else "it.") 

3946 ) 

3947 # The install line rides *in* the copied block (one 📋 copies both), so 

3948 # pasting into a fresh shell works without hunting for the package name. 

3949 st.code(f"{INSTALL_COMMAND}\n\n{code.cli}", language="bash") 

3950 else: 

3951 st.code(f"# {INSTALL_COMMAND}\n{code.python}", language="python") 

3952 

3953 

3954#: UX-179 — the Share subtab's three ways to pass a figure on, as the options of 

3955#: one switch. A segmented control rather than a nested ``st.tabs``: the app 

3956#: keeps one tab bar per page, and only the chosen part is drawn. 

3957SHARE_LINK = "Link" 

3958SHARE_CODE = "Code" 

3959SHARE_FILE = "File" 

3960SHARE_SECTIONS = (SHARE_LINK, SHARE_CODE, SHARE_FILE) 

3961SHARE_SECTION_KEY = "share_section" 

3962 

3963 

3964def _render_share_body( 

3965 data_choice: str, settings_file=None, *, visible: bool = True 

3966) -> None: 

3967 """Render the **Share** subtab: **Link · Code · File**, one at a time. 

3968 

3969 - **Link** — a deep link to the current view (data source + trial + 

3970 visualization settings). Streamlit reruns after every relevant control 

3971 change, so the query handed to the embedded **Refresh & Copy** button 

3972 already reflects the current view when the user clicks it. 

3973 - **Code** — EXP-7's API / CLI snippet that reproduces the figure. 

3974 - **File** — a settings file to download or restore (UX-179), drawn by 

3975 ``settings_file``: a zero-argument callable from ``app.main``, which holds 

3976 the resolved figure settings it writes. ``None`` where there is no figure 

3977 to describe, and the option says so rather than vanishing. It runs only 

3978 while the subtab is ``visible``: building the file re-reads every figure 

3979 setting and slices the trial's raw gaze, which a subtab nobody is looking 

3980 at should not pay for on every rerun. 

3981 """ 

3982 choice = ( 

3983 st.segmented_control( 

3984 "Share as", 

3985 SHARE_SECTIONS, 

3986 default=SHARE_LINK, 

3987 key=SHARE_SECTION_KEY, 

3988 label_visibility="collapsed", 

3989 ) 

3990 or SHARE_LINK 

3991 ) 

3992 if choice == SHARE_CODE: 

3993 _render_code_snippet_body(data_choice) 

3994 return 

3995 if choice == SHARE_FILE: 

3996 if not visible: 

3997 return 

3998 if settings_file is None: 

3999 st.caption("Open a trial first; the settings file describes its figure.") 

4000 else: 

4001 settings_file() 

4002 return 

4003 st.markdown( 

4004 "**Share this view** — a link that reopens Scanpath Studio on the " 

4005 "current trial with your visualization settings." 

4006 ) 

4007 query, caveats = _build_share_query(data_choice) 

4008 # Keep the rendered value inspectable in AppTest without duplicating the 

4009 # browser-only URL composition logic. 

4010 st.session_state["_share_query_current"] = (query, caveats) 

4011 for note in caveats: 

4012 st.caption(f"{ICONS['warning']} " + note) 

4013 _render_share_link_widget(query) 

4014 st.caption( 

4015 "If the recipient runs Scanpath Studio at a different address or port, " 

4016 "replace the start of the URL before opening it." 

4017 ) 

4018 

4019 

4020# ----------------------------------------------------------------------------- 

4021# View navigation 

4022# ----------------------------------------------------------------------------- 

4023# These *request* a view by writing `main_nav`; `menu.render_nav` reconciles the 

4024# router to it on the next run. They are used as `on_click` callbacks, where 

4025# Streamlit forbids `st.switch_page` — hence the request-then-reconcile split 

4026# rather than navigating directly (`menu.switch_to_view` is the direct form, for 

4027# top-level script code). 

4028def _go_scanpath() -> None: 

4029 st.session_state["main_nav"] = _VIEW_SCANPATH 

4030 

4031 

4032def _go_data() -> None: 

4033 st.session_state["main_nav"] = _VIEW_DATA