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
« 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.
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"""
9from __future__ import annotations
11import copy
12import json
13import math
14import re
15from collections.abc import Callable, Iterable
16from dataclasses import dataclass, field
17from urllib.parse import urlencode
19import pandas as pd
20import streamlit as st
22from scanpath_studio.html_embed import embed_html_iframe
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)
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",)
135def _coerce_bool(v) -> bool:
136 return str(v).lower() not in {"0", "false", "no"}
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))
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))
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()]
154def _parse_saccade_classes(v) -> list[str]:
155 """VIZ-31 saccade reading-class filter carried by a Share link.
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)]
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")
177def _parse_choice(value, options: tuple[str, ...], what: str) -> str:
178 """Match ``value`` case-insensitively against a closed vocabulary.
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}")
193def _parse_compare_layout(v) -> str:
194 return _parse_choice(v, _COMPARE_LAYOUT_OPTIONS, "compare layout")
197def _parse_compare_stimulus(v) -> str:
198 return _parse_choice(v, _COMPARE_STIMULUS_OPTIONS, "compare stimulus source")
201def _parse_marker_size_scale(v) -> str:
202 return _parse_choice(v, tuple(MARKER_SIZE_SCALES), "marker size scale")
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}")
211def _parse_hex_color(v) -> str:
212 """A colour param → ``#rrggbb``, raising on anything else (BUG-69).
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
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][^<>]*>")
232def _strip_markup(v) -> str:
233 """``v`` with every markup tag removed (SEC6 / BUG-75).
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
250_COMPARE_ESCAPE = re.compile(r"\\([\\:])")
253def compare_value(participant, trial) -> str:
254 """``?compare=``'s value for scanpath B: ``<participant>:<trial>``.
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}"
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
281def _parse_playback_speed(v) -> float:
282 """A replay speed → the ⚙ Playback slider's own option (EXP-18).
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
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}")
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).
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")
307def _parse_fixclass_mode(v) -> str:
308 return _parse_choice(v, tuple(_FIXCLASS_MODES), "fixation-flag mode")
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
318def _parse_saccade_style_label(v) -> str:
319 """A per-scanpath line style → the selectbox's own label (EXP-19).
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}")
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
340def _parse_colorbar_orientation(v) -> str:
341 return _parse_choice(v, ("Vertical", "Horizontal"), "color bar orientation")
344def _parse_align_algorithm(v) -> str:
345 """PRE-3 drift-correction algorithm name → the picker's exact spelling.
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}")
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))
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 }
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}
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}
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
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}
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)
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}
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)
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))
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}
867def _source_choice_for_param(value) -> str | None:
868 """Invert `_SHAREABLE_SOURCES`: a ``?source=``-style token → the data choice.
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
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"
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-"
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("-")
920def corpus_slug(label: str, spec) -> str:
921 """The ``?corpus=`` slug for one `public_dataset_registry()` entry, or ``""``.
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))
938def registry_corpus_slugs() -> dict[str, str]:
939 """``registry label -> slug`` for every corpus a link can name right now.
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.
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
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}
967def corpus_choice_for_slug(value) -> str | None:
968 """A ``?corpus=`` slug → its registry label, or ``None``.
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
986def _selected_corpus(data_choice: str) -> tuple[str, dict]:
987 """Which registry corpus a share is describing: ``(label, spec)``.
989 ``("", {})`` when the active source is not a public corpus.
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
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 "", {}
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 }
1018def _apply_url_legends(qp) -> None:
1019 """Seed each ``legend_<kind>=SPEC`` param's three Legends keys.
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
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)
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
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])
1064def _apply_url_palette(qp) -> None:
1065 """Expand a ``?palette=<name>`` deep link into its colour session keys (VIZ-18).
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)
1081def _apply_url_preset() -> str | None:
1082 """Read `st.query_params` and preset Streamlit session state for deep links.
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.
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
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.
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
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']}.")
1146 _apply_url_palette(qp)
1147 _apply_url_legends(qp)
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)
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 )
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 )
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 )
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))
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)
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)
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"]))
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 )
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)
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 )
1317 source = qp.get("source")
1318 return source.lower() if source else None
1321def link_sets(state_key: str) -> bool:
1322 """Whether the open deep link carries a value for ``state_key`` (VIZ-45).
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 )
1341def linked_state_keys() -> frozenset[str]:
1342 """The session keys the open deep link carries a value for (#374 F25).
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 )
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"
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 )
1388def resolve_link_dataset(seeded: Iterable[str], current: str | None) -> str | None:
1389 """Open the added dataset a link names (`?dataset=`, #374 F14).
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
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
1436def scope_link_setup(choice: str | None) -> None:
1437 """Tie the keys a link seeded (EXP-19) to the data source it resolved to.
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)}
1454def link_setup_keys_for(source_key: tuple) -> frozenset:
1455 """The linked keys the source snap must leave alone for ``source_key``.
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 ())
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}
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)
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
1545 return parse
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)))
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}
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}
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
1637def sanitize_session_value(key: str, value):
1638 """A stored session value as it may be seeded, or ``ValueError``/``TypeError``.
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
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
1733def _detect_config_schema(config: dict) -> int:
1734 """Best-effort schema version of an uploaded config.
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
1749def _migrate_config_1_to_2(config: dict) -> dict:
1750 """Upgrade a schema-1 config to schema 2.
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
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)
1777def _has_plot_section(config: dict) -> bool:
1778 return any(isinstance(config.get(name), dict) for name in _PLOT_SECTIONS)
1781def _migrate_config_2_to_3(config: dict) -> dict:
1782 """Upgrade to the optional VIZ-34 coordinate-grid axes fields.
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
1805def _migrate_config_3_to_4(config: dict) -> dict:
1806 """UX-179: nothing to translate — schema 4 only *dropped* sections.
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
1815def _migrate_config_4_to_5(config: dict) -> dict:
1816 """Keep an older figure on the scale it was drawn with.
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
1836def _migrate_config_5_to_6(config: dict) -> dict:
1837 """Schema 6 *added* the figure mode and scanpath B; nothing to translate.
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
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
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}
1895def _migrate_plot_config(config: dict) -> tuple[dict, str | None]:
1896 """Upgrade an uploaded plot-config dict to the current schema.
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
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.
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], ""
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
2013def _apply_url_trial_selection(combos: pd.DataFrame) -> str | None:
2014 """Apply a ``?trial_id=`` deep link to the trial picker — exactly once.
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``.
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.
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
2057#: Where an in-app "open this trial" request waits for `combos` to exist.
2058PENDING_TRIAL_KEY = "_pending_trial_selection"
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_"
2066def apply_pending_preprocessing() -> None:
2067 """Write the preprocessing values a settings file restored on the last run.
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)
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).
2080 ``screen_id`` also opens that screen of a multipart trial — Data
2081 Management → Annotations' **Open** on a screen annotation.
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()
2100def _apply_pending_trial_selection(combos: pd.DataFrame) -> str | None:
2101 """Consume a :func:`request_trial` hop, if one is waiting.
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.
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
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.
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).
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)
2178@dataclass
2179class _RestoreContext:
2180 """Validated writes and diagnostics for one plot-config restoration."""
2182 config: dict
2183 applied: int = 0
2184 skipped: list = field(default_factory=list)
2186 def section(self, name: str) -> dict:
2187 value = self.config.get(name)
2188 return value if isinstance(value, dict) else {}
2190 @staticmethod
2191 def number(value) -> float | None:
2192 try:
2193 return float(value)
2194 except (TypeError, ValueError):
2195 return None
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
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)
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)))
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)))
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.
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"])
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
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)
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]))
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", "")
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)
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"):
2521 def _bar_value(name: str, bar: str = bar):
2522 return coloring.get(f"{bar}_{name}")
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)
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
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 )
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")
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")
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 )
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
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
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 )
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 )
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)
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", "")
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)
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)
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)
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)
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)
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})")
3028 _restore_figure_mode(config, selection, combos, put, skipped)
3030 return restore.applied, skipped
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)``.
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
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), ""
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.
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
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)
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.
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))
3160_PLOT_CONFIG_TOAST_KEY = "_plot_config_restored_toast"
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"])
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.
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.
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.
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 """
3200 params: dict[str, str] = {}
3201 caveats: list = []
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 )
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 )
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)
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"])
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"
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
3448 snapshot = active_setup_snapshot(data_choice)
3449 if snapshot is not None:
3450 params[SETUP_PROVENANCE_PARAM] = format_provenance_param(snapshot)
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
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)
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}``.
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).
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`).
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
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
3562def _same_setting(value, default) -> bool:
3563 """Whether a session value restates ``default`` (EXP-19's elision test).
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
3582def _render_share_link_widget(query: str) -> None:
3583 """Render the current share link and its single Refresh & Copy action.
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 )
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"
3698_SNIPPET_FLAVORS = (f"{ICONS['python']} Python", f"{ICONS['cli']} CLI")
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}
3709def _snippet_source(data_choice: str) -> SnippetSource:
3710 """Describe the loaded data the way a script would have to load it.
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.
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
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)
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 )
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
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
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 )
3861def _has_rows(frame) -> bool:
3862 return frame is not None and not getattr(frame, "empty", True)
3865def _samples_only(stored: dict) -> bool:
3866 """A stored dataset whose only table is raw gaze (VIZ-45)."""
3868 def empty(frame) -> bool:
3869 return frame is None or getattr(frame, "empty", True)
3871 return (
3872 empty(stored.get("words"))
3873 and empty(stored.get("fixations"))
3874 and not empty(stored.get("raw_gaze"))
3875 )
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
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 )
3890def _render_code_snippet_body(data_choice: str) -> None:
3891 """Render the **reproduce this figure in code** block of the Share subtab.
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
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")
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"
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.
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 )
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
4032def _go_data() -> None:
4033 st.session_state["main_nav"] = _VIEW_DATA