Coverage for scanpath_studio/controls.py: 92%
2594 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
1from __future__ import annotations
3import html
4import json
5import math
6import re
7from collections.abc import Callable
8from contextlib import contextmanager
9from contextvars import ContextVar
10from copy import deepcopy
12import numpy as np
13import pandas as pd
14import streamlit as st
15from streamlit.errors import StreamlitAPIException
16from streamlit_sortables import sort_items
18from . import column_names as cn
19from .alignment import ALGORITHMS as ALIGN_ALGORITHMS
20from .annotations import has_screen_annotations, known_tags
21from .constants import (
22 BACKGROUND_PRESETS,
23 COLORSCALES,
24 COMPARE_FIXATION_OPACITY,
25 CUSTOM_PALETTE,
26 DEFAULT_BACKGROUND_COLOR,
27 DEFAULT_FIXATION_COLOR,
28 DEFAULT_FIXATION_COLORSCALE,
29 DEFAULT_FIXATION_SYMBOL,
30 DEFAULT_HEATMAP_COLORSCALE,
31 DEFAULT_HEATMAP_SIGMA_PX,
32 DEFAULT_MARKER_DURATION_RANGE,
33 DEFAULT_MARKER_SIZE_RANGE,
34 DEFAULT_MARKER_SIZE_SCALE,
35 DEFAULT_PALETTE,
36 DEFAULT_SACCADE_WIDTH,
37 DEMO_CHOICE,
38 FIXATION_SYMBOLS,
39 HEATMAP_SIGMA_BOUNDS,
40 HIGHLIGHTED_TEXT_COLOR,
41 ICONS,
42 LEGEND_ARRANGEMENT_LABELS,
43 LEGEND_KIND_LABELS,
44 LEGEND_KINDS,
45 LEGEND_POSITION_LABELS,
46 MARKER_DURATION_BOUNDS,
47 MARKER_SIZE_SCALES,
48 OUT_OF_TEXT_COLOR,
49 PALETTES,
50 RAW_GAZE_LINK_FOR_KEY,
51 RAW_GAZE_SEEDED_FOR_KEY,
52 RAW_GAZE_SNAP_RESTORE_KEY,
53 SACCADE_CLASS_COLORS,
54 SACCADE_CLASS_EDITABLE,
55 SACCADE_CLASS_LABELS,
56 SACCADE_CLASS_ORDER,
57 SACCADE_COLOR,
58 SACCADE_COLOR_MODES,
59 SACCADE_DASH_OPTIONS,
60 SACCADE_DIRECTION_CLASSES,
61 SACCADE_WIDTH_BOUNDS,
62 SELF_SCALED_HEATMAP_STYLES,
63 UNIFORM_COLOR_FIELD,
64 WORD_BOX_COLOR,
65 WORD_BOX_FILL_COLOR,
66 WORD_BOX_FILL_OPACITY,
67 WORD_BOX_LINE_OPACITY,
68 WORD_LABEL_COLOR,
69 compare_palette_color,
70 drift_correction_enabled,
71 icon_html,
72 icons_to_html,
73 palette_label,
74 palette_settings,
75 spoken,
76 upload_limit_mb,
77)
78from .crash_report import guarded
79from .data import (
80 INTERNAL_COLUMNS,
81 READING_MEASURE_FIELDS,
82 READING_MEASURE_KEYS,
83 coerce_bool_or_na,
84 frame_fingerprint,
85 mapping_value_preview,
86 user_columns,
87)
88from .export import (
89 DEFAULT_CAPTION_PATTERN,
90 DEFAULT_TITLE_PATTERN,
91 pattern_error,
92 pattern_fields,
93 render_pattern,
94)
95from .fields import (
96 LABEL_GAP,
97 NARROW_LABEL_W,
98 labeled,
99 plain,
100 row_label,
101 tooltip,
102)
103from .session_keys import (
104 COMPARE_B_FILTER_STATE_KEYS,
105 SHARE_FLOAT_RANGE_PARAMS,
106 SINGLE_COMPARE_FIX_RANGE,
107 SINGLE_COMPARE_LAYOUT,
108 SINGLE_COMPARE_STIMULUS,
109 SINGLE_COMPARE_TOGGLE,
110 SINGLE_PLAYBACK_SPEED,
111 compare_state_keys,
112 keep_legacy_marker_scale,
113 rename_legacy_keys,
114)
115from .session_keys import DESIGN_PRESETS as _DESIGN_PRESETS_WIRE_KEY
117NONE_OPTION = "(none)"
120# --- UX-51: compact `label | field` rows --------------------------------------
121# Every control in the Scanpath rail used to stack its title ABOVE its field, so
122# one ⚙️ Style popover spent well over a screen's height on eight controls. The
123# title now sits in a column to the LEFT of the field: a row is one line instead
124# of two, and a section reads as a compact form rather than a long scroll.
125#
126# Built from per-row `st.columns`, not CSS on Streamlit's own widget-label DOM.
127# The split is then ordinary layout — it cannot leak outside the containers we
128# build it in, and it does not ride on internal test ids a Streamlit re-skin can
129# move. (Container-scoped CSS is the fallback if this reads badly when the rail
130# is tight, not the starting point.)
131#
132# ONE label width for the whole rail (`_LABEL_W`) rather than a width per row:
133# labels lining up down a section — and across sections — is most of what makes
134# the result read as a form. A label too long for the column truncates with an
135# ellipsis and shows in full on hover, instead of widening the column for every
136# other row in the section.
137#
138# The widget keeps its real `label` and `help`, and merely hides them
139# (`label_visibility="collapsed"`), so the accessible name, `AppTest` lookups and
140# the wire format are all untouched. What the user sees is the markdown twin in
141# the left column — and the `?` tooltip icon folds INTO it: the help text becomes
142# the label's own hover tooltip, which buys back the icon's width on every row.
143#
144# Controls whose label already sits beside the field — `st.checkbox`,
145# `st.toggle` — keep their native one-line shape; splitting those would only
146# indent them away from the section's other rows.
148#: The label column's share of a `label | field` row. Tuned for the ~28rem
149#: popover body (`styles.get_app_css` pins `stPopoverBody`), which is where these
150#: rows live: ~160px of label — about 23 characters at the rail's 0.92rem — while
151#: leaving the field wide enough for a multiselect's chips, or for a slider plus
152#: the UX-9 box you type an exact value into.
153#:
154#: UX-69 moved the row itself down into `fields.py`, so the Scanpath subtabs can
155#: share it without importing this module (which imports two of them). The
156#: names below stay as they were — this module's call sites are the row's
157#: heaviest user by far.
158_LABEL_W = NARROW_LABEL_W
160#: UX-158: a popover whose titles are all short can narrow its own label column
161#: (`_rail_label_width`), so its fields sit closer to their titles. A ContextVar,
162#: not a module global, because Streamlit runs each session's script on its own
163#: thread and a temporarily-rebound global would leak into another session.
164_LABEL_W_OVERRIDE: ContextVar[float | None] = ContextVar(
165 "rail_label_width", default=None
166)
169def _label_w() -> float:
170 """The rail's label-column share for the row being drawn now."""
171 override = _LABEL_W_OVERRIDE.get()
172 return _LABEL_W if override is None else override
175#: The rail popovers' label column (UX-158 for 👁️ Fixations, UX-159 for the
176#: rest): with titles kept short, this much of the ~28rem body holds the
177#: longest of them ("Snap above words", "Direction arrows") and brings every
178#: field closer to its title than the rail's default split does.
179_POPOVER_LABEL_W = 0.3
182@contextmanager
183def _rail_label_width(width: float):
184 """Draw the rows inside with a label column of ``width`` (UX-158)."""
185 token = _LABEL_W_OVERRIDE.set(width)
186 try:
187 yield
188 finally:
189 _LABEL_W_OVERRIDE.reset(token)
192@contextmanager
193def _popover_rows(slug: str):
194 """Lay a rail popover's rows out the UX-158 way (UX-159).
196 The popover's own label column (`_POPOVER_LABEL_W`) and a keyed container,
197 ``rail_rows_<slug>``, that `styles.py` spaces the rows of apart — the two
198 things that made 👁️ Fixations read as a form, for every popover alike.
199 """
200 with _rail_label_width(_POPOVER_LABEL_W), st.container(key=f"rail_rows_{slug}"):
201 yield
204#: Tighter than the 1rem default: these rows are dense and the width is scarce.
205_LABEL_GAP = LABEL_GAP
207#: `label | field | note` for one column-mapping row (UX-52 round 3). Its own
208#: triple rather than `_LABEL_W`: the mapping renders full-width on the 🗂️ Data
209#: page and inside the upload wizard, not in the rail's ~28rem popover, so there
210#: is room for the "✨ auto-detected …" note beside the field instead of under it.
211_MAPPING_ROW_W = (0.24, 0.40, 0.36)
213#: `label | ✨` on a grid cell's first line, with the select on its second
214#: (UX-53 r14). Stacking is what makes a packed theme read as two rows — every
215#: field name on one line, every control on the next — instead of as a ragged
216#: run of label-field pairs. The flag is one glyph and takes only what it needs.
217_GRID_LABEL_W = (0.85, 0.15)
220def inline_field_label(
221 host, label: str, help_text: str | None = None, *, emphasis: bool = False
222) -> None:
223 """Render a field title above its control (UX-53 r14).
225 The public door onto `_row_label`, for callers that build their own row:
226 `wizard._render_identity_field` puts one picker per table across a shared
227 row and needs their titles to look like every other mapping title, tooltip
228 included. ``emphasis`` (UX-113) is the bolder/larger `.sps-flabel-emph`
229 variant, for a title that should stand out among its neighbours (the
230 wizard's own upload/attach table titles).
231 """
232 _row_label(host, label, help_text, emphasis=emphasis)
235#: Shown in a select with nothing chosen. UX-53 r10 replaced the `"(none)"`
236#: *option* with a real empty state, so this is placeholder text, not a value
237#: anyone can pick.
238_UNMAPPED_PLACEHOLDER = "Not mapped"
241#: `fields.plain` under this module's own name — the markdown-stripping the
242#: mapping UI's headings and flags do before writing a plain-text tooltip.
243_plain = plain
246#: `fields.row_label` under this module's own name: one row's title, with its
247#: description folded into the title's own hover tooltip rather than a `?` icon.
248_row_label = row_label
251def _labeled(host, kind: str, label: str, **kwargs):
252 """`fields.labeled` at the rail's label width — see that module's docstring.
254 Every call site in this module renders into the rail or its popovers, so the
255 narrow width is the one they all want; passing `label_width` explicitly
256 overrides it.
257 """
258 kwargs.setdefault("label_width", _label_w())
259 # UX-97: a layer's controls grey out while its layer toggle is off.
260 kwargs["disabled"], kwargs["help"] = _layer_gate(
261 bool(kwargs.get("disabled", False)), kwargs.get("help")
262 )
263 return labeled(host, kind, label, **kwargs)
266def _rail_names() -> cn.ColumnNames:
267 """DATA-66: the open dataset's names, for the rail's column pickers.
269 The fixations table's, then the words table's for the word-level fields
270 (surprisal, frequency …) carried onto fixations. The pickers' values stay
271 canonical — only what they show changes."""
272 return cn.active_all(st.session_state)
275def _slider_row(host, n_boxes: int, lead: float = 0.0) -> list:
276 """Columns for a ``label | slider | box…`` row, label column first (UX-51).
278 The slider keeps its pre-UX-51 5 : 1.5 proportion against each typed box; the
279 label takes ``_LABEL_W`` off the top so the row lines up with the plain
280 ``label | field`` rows around it. ``vertical_alignment="center"`` is what puts
281 the label beside the slider's *track*: a slider prints its current value
282 above the track, so a top-aligned label would sit against that number instead
283 of against the control.
285 ``lead`` (UX-157) inserts a column of that relative weight between the label
286 and the slider, for a control that belongs on the slider's line — the colour
287 range's *Auto* checkbox.
288 """
289 label_w = _label_w()
290 rest = 1.0 - label_w
291 total = lead + 5.0 + 1.5 * n_boxes
292 weights = [
293 label_w,
294 *([rest * lead / total] if lead else []),
295 rest * 5.0 / total,
296 *([rest * 1.5 / total] * n_boxes),
297 ]
298 return host.columns(weights, gap=_LABEL_GAP, vertical_alignment="center")
301# --- UX-9: sliders you can also type an exact value into ----------------------
302# A slider is the right control for "sweep until it looks right", but it can't be
303# set to a precise value — awkward when a figure has to match a spec (marker size
304# 12, opacity 0.65, line width 1.5). These wrappers pair each slider with a
305# number box.
306#
307# The SLIDER keeps the canonical session key, so nothing downstream changes: deep
308# links, Share, Save & restore and `_collect_viz_settings` all still read
309# `global_*` / `cmp*_*` / `single_*` exactly as before. The box owns a shadow
310# `{key}__num*` key and writes the canonical one from its `on_change`; the
311# canonical value is mirrored back into the box *before* either widget renders,
312# so a slider drag, a deep link, a restored config and a Quick-view preset all
313# move the box too — one-way sync each direction, no feedback loop.
316def _shadow_key_missing(*keys: str) -> bool:
317 """True when a number box's shadow key is not in session state (BUG-18).
319 An ``on_change`` callback runs *before* the script that would (re)create the
320 widget, and Streamlit drops a widget's key at the end of any run in which it
321 did not render. Several of these boxes are conditional — the heatmap
322 colour-range pair only renders when the current trial/metric has data — so a
323 change queued while a slow rerun was still in flight can reach a callback
324 whose own key no longer exists, and reading it raised ``KeyError`` and took
325 the app down mid-rerun.
327 A missing shadow key means there is no user edit left to apply: the canonical
328 key still holds the last committed value, and the box re-seeds from it the
329 next time it renders. So the callback becomes a no-op rather than a crash.
330 """
331 return any(k not in st.session_state for k in keys)
334_INT_NUMBER_FORMATS = frozenset({"%d", "%u", "%i"})
337def _number_box_format(fmt: str | None, *values) -> str | None:
338 """Adapt a slider's ``format`` for the number box beside it.
340 ``st.number_input`` renders a yellow "value below has type float, but format
341 %d displays as integer" warning above itself when an integer format meets a
342 float value — and the colour-range sliders pass float bounds on purpose (so a
343 restored config clamps into another dataset's range) while wanting whole
344 numbers on screen. ``"%.0f"`` shows the same digits without the warning.
345 """
346 if fmt in _INT_NUMBER_FORMATS and any(isinstance(v, float) for v in values):
347 return "%.0f"
348 return fmt
351def _numeric_slider(
352 host,
353 label: str,
354 *,
355 key: str,
356 min_value,
357 max_value,
358 step=None,
359 slider_format: str | None = None,
360 number_format: str | None = None,
361 help: str | None = None,
362 disabled: bool = False,
363 on_change=None,
364 persist_state: str | None = None,
365 label_left: bool = False,
366 display: str | None = None,
367 field_host=None,
368) -> None:
369 """A single-value slider plus a number box bound to the same setting.
371 ``field_host`` (UX-158) draws the slider and its box into that column, for a
372 `_sub_row` whose title and caption the caller has already drawn.
374 ``number_format`` defaults to ``slider_format``; pass it separately when the
375 slider's format carries a unit suffix (``"%.1f px"``), which ``number_input``
376 does not accept.
378 ``disabled`` greys BOTH halves (VIZ-21) without touching the canonical key —
379 a disabled Streamlit widget still owns and keeps its value, so a mode toggle
380 never rewrites a deep-linked / restored setting.
382 ``label_left`` opts the row into the UX-51 ``label | slider | box`` shape.
383 It is opt-in rather than the default because the sliders in the rail's
384 ⚙️ Playback popover are laid out two-up in half-width columns, where a third
385 column would leave the slider unusable.
386 """
387 disabled, help = _layer_gate(disabled, help) # UX-97
388 num_key = f"{key}__num"
389 if key in st.session_state:
390 st.session_state[num_key] = st.session_state[key]
392 def _apply() -> None:
393 if _shadow_key_missing(num_key): # BUG-18
394 return
395 st.session_state[key] = st.session_state[num_key]
396 if on_change is not None:
397 on_change()
399 # A narrow box on the same line as the slider: the box is for typing an
400 # exact value, so it only needs room for the number itself (the CSS drops its
401 # +/- steppers and caps its width), and the slider keeps most of the row.
402 if field_host is not None:
403 slider_col, num_col = field_host.columns(
404 [5, 1.5], gap=_LABEL_GAP, vertical_alignment="center"
405 )
406 elif label_left:
407 label_col, slider_col, num_col = _slider_row(host, 1)
408 _row_label(label_col, display if display is not None else label, help)
409 else:
410 slider_col, num_col = host.columns([5, 1.5], vertical_alignment="bottom")
411 slider_col.slider(
412 label,
413 min_value=min_value,
414 max_value=max_value,
415 step=step,
416 format=slider_format,
417 key=key,
418 help=help,
419 disabled=disabled,
420 on_change=on_change,
421 persist_state=persist_state,
422 label_visibility=(
423 "collapsed" if label_left or field_host is not None else "visible"
424 ),
425 )
426 num_col.number_input(
427 label,
428 min_value=min_value,
429 max_value=max_value,
430 step=step,
431 format=_number_box_format(
432 number_format if number_format is not None else slider_format,
433 min_value,
434 max_value,
435 step,
436 st.session_state.get(num_key),
437 ),
438 key=num_key,
439 on_change=_apply,
440 label_visibility="collapsed",
441 disabled=disabled,
442 )
445def _range_slider(
446 host,
447 label: str,
448 *,
449 key: str,
450 min_value,
451 max_value,
452 step=None,
453 slider_format: str | None = None,
454 number_format: str | None = None,
455 help: str | None = None,
456 disabled: bool = False,
457 on_change=None,
458 persist_state: str | None = None,
459 label_left: bool = False,
460 display: str | None = None,
461 lead=None,
462 field_host=None,
463 number_bounds: tuple | None = None,
464) -> None:
465 """A two-handle range slider plus min/max number boxes, all on one line.
467 ``number_bounds`` bounds the two number boxes when it differs from the
468 slider's (``None`` on either side = unbounded); a typed value outside the
469 slider's span is then the caller's to make room for on the next run, as
470 `_render_color_range` does by widening the slider to the stored range.
472 ``lead`` (UX-157) is a callable given a column ahead of the slider, to draw
473 a control of its own there. ``field_host`` (UX-158) draws the whole line
474 into that column, for a `_sub_row` whose title and caption the caller has
475 already drawn.
477 The boxes are deliberately small — they hold a number, not a sentence — so
478 the slider still gets most of the row. A min typed above the max is swapped
479 rather than rejected. ``disabled`` greys all three without changing the
480 stored range (VIZ-21).
482 ``label_left`` / ``display`` behave as in :func:`_numeric_slider` (UX-51).
483 """
484 disabled, help = _layer_gate(disabled, help) # UX-97
485 lo_key, hi_key = f"{key}__num_lo", f"{key}__num_hi"
486 current = st.session_state.get(key)
487 if isinstance(current, (tuple, list)) and len(current) == 2:
488 st.session_state[lo_key], st.session_state[hi_key] = current
490 def _apply() -> None:
491 if _shadow_key_missing(lo_key, hi_key): # BUG-18
492 return
493 lo, hi = st.session_state[lo_key], st.session_state[hi_key]
494 st.session_state[key] = (min(lo, hi), max(lo, hi))
495 if on_change is not None:
496 on_change()
498 if field_host is not None:
499 weights = [*([2.2] if lead is not None else []), 5, 1.5, 1.5]
500 cols = field_host.columns(weights, gap=_LABEL_GAP, vertical_alignment="center")
501 if lead is not None:
502 lead(cols[0])
503 slider_col, lo_col, hi_col = cols[-3:]
504 elif label_left:
505 if lead is not None:
506 label_col, lead_col, slider_col, lo_col, hi_col = _slider_row(
507 host, 2, lead=2.2
508 )
509 lead(lead_col)
510 else:
511 label_col, slider_col, lo_col, hi_col = _slider_row(host, 2)
512 _row_label(label_col, display if display is not None else label, help)
513 else:
514 slider_col, lo_col, hi_col = host.columns(
515 [5, 1.5, 1.5], vertical_alignment="bottom"
516 )
517 slider_col.slider(
518 label,
519 min_value=min_value,
520 max_value=max_value,
521 step=step,
522 format=slider_format,
523 key=key,
524 help=help,
525 disabled=disabled,
526 on_change=on_change,
527 persist_state=persist_state,
528 label_visibility=(
529 "collapsed" if label_left or field_host is not None else "visible"
530 ),
531 )
532 fmt = number_format if number_format is not None else slider_format
533 num_min, num_max = (
534 number_bounds if number_bounds is not None else (min_value, max_value)
535 )
536 for col, num_key, side in ((lo_col, lo_key, "min"), (hi_col, hi_key, "max")):
537 col.number_input(
538 f"{label} ({side})",
539 min_value=num_min,
540 max_value=num_max,
541 step=step,
542 format=_number_box_format(
543 fmt, min_value, max_value, step, st.session_state.get(num_key)
544 ),
545 key=num_key,
546 on_change=_apply,
547 label_visibility="collapsed",
548 disabled=disabled,
549 )
552# VIZ-4: MIME by extension for a user-uploaded stimulus image → a `data:` URI the
553# figure builders accept as `background_image` (plots._image_to_data_uri passes a
554# `data:` URI straight through).
555_UPLOAD_IMAGE_MIME = {
556 "png": "image/png",
557 "jpg": "image/jpeg",
558 "jpeg": "image/jpeg",
559 "gif": "image/gif",
560 "webp": "image/webp",
561}
564def _uploaded_image_data_uri(uploaded) -> str | None:
565 """Base64 ``data:`` URI for a Streamlit ``UploadedFile`` image, or ``None``.
567 Cached in session state keyed by the file's id so a multi-MB screenshot is
568 encoded once, not on every rerun (VIZ-4)."""
569 if uploaded is None:
570 return None
571 import base64
573 cache = st.session_state.get("_stimulus_image_upload_cache")
574 if isinstance(cache, dict) and cache.get("id") == uploaded.file_id:
575 return cache.get("uri")
576 ext = (uploaded.name.rsplit(".", 1)[-1] if "." in uploaded.name else "").lower()
577 mime = _UPLOAD_IMAGE_MIME.get(ext, "image/png")
578 uri = f"data:{mime};base64," + base64.b64encode(uploaded.getvalue()).decode("ascii")
579 st.session_state["_stimulus_image_upload_cache"] = {
580 "id": uploaded.file_id,
581 "uri": uri,
582 }
583 return uri
586# PRE-3: drift-correction picker options — "Off" + each algorithm title-cased.
587_ALIGN_OPTIONS = ["Off", *(a.title() for a in ALIGN_ALGORITHMS)]
590# --- VIZ-21/23: which rail controls actually apply in Animate / Compare -------
591# One rail feeds the static, animation, and comparison renderers, but some layers
592# do not exist in every mode. Each affected control therefore declares which
593# render paths consume it. Unsupported controls stay visible but disabled so the
594# reason is discoverable, and their stored values survive mode switches, deep
595# links, and restored configs. Keep the authoritative setting → render-path
596# table in `CLAUDE.md` in sync with these gates.
599def _mode_gate(
600 animating: bool,
601 comparing: bool,
602 *,
603 in_animation: bool = True,
604 in_compare: bool = True,
605) -> tuple[bool, str]:
606 """``(disabled, reason)`` for a control, given which paths honour it.
608 ``in_animation`` / ``in_compare`` state whether the corresponding builder
609 actually consumes the setting. The reason string is prefixed onto the
610 control's ``help`` so the tooltip explains the greying instead of leaving
611 the user guessing."""
612 modes = []
613 if animating and not in_animation:
614 modes.append("**Animate**")
615 if comparing and not in_compare:
616 modes.append("**Compare**")
617 if not modes:
618 return False, ""
619 return True, (
620 f"{ICONS['warning']} Not used in "
621 + " / ".join(modes)
622 + " mode. Your value is kept."
623 )
626def _gated_help(base: str | None, reason: str) -> str | None:
627 """Prefix ``reason`` (from :func:`_mode_gate`) onto a control's help text."""
628 if not reason:
629 return base
630 return f"{reason}\n\n{base}" if base else reason
633# --- UX-97: a layer's settings stay readable while the layer is off ----------
634# Before this, each layer block was gated `if show_<layer>:`, so switching the
635# layer off left its ▾ clickable but empty — the affordance said "there is
636# something here" and the popover said otherwise. The controls now always
637# render; while the layer is off they are greyed, because they change something
638# nothing is drawing. Same rule as `_mode_gate`: disabling never rewrites the
639# stored value, since a disabled Streamlit widget keeps its key.
640_LAYER_OFF_REASON: list[str] = []
643@contextmanager
644def _layer_off(
645 label: str, *, off: bool, reason: str | None = None, caption: bool = True
646):
647 """Grey every rail control rendered inside, while ``off``.
649 ``label`` names the layer's toggle, so the reason reads as an instruction
650 ("Turn **👁️ Fixations** on…") rather than a bare refusal. Nested use pushes
651 onto a stack, so an inner block that greys for its own reason wins.
652 ``reason`` replaces that instruction when switching the layer on would not
653 help — VIZ-45's trial with no fixations for the layer to draw.
654 ``caption=False`` greys without writing the reason again, where the section
655 already said it once.
656 """
657 if not off:
658 yield
659 return
660 _LAYER_OFF_REASON.append(
661 reason
662 or f"{ICONS['warning']} **{label}** is off — turn the layer on to change "
663 "this. Your settings are kept either way."
664 )
665 try:
666 if caption:
667 st.caption(_LAYER_OFF_REASON[-1])
668 yield
669 finally:
670 _LAYER_OFF_REASON.pop()
673def _layer_gate(disabled: bool, help: str | None) -> tuple[bool, str | None]:
674 """Fold the active :func:`_layer_off` reason into one control's args."""
675 if not _LAYER_OFF_REASON:
676 return disabled, help
677 return True, _gated_help(help, _LAYER_OFF_REASON[-1])
680# Static defaults for the keyed visualization widgets that the plot-config
681# restore (app._restore_plot_config) can set. Seeded into session_state so those
682# widgets render WITHOUT a `value=`/`index=` argument — that keeps their key
683# programmatically settable without Streamlit's "default value but also set via
684# Session State API" warning. Data-dependent defaults (color-by / axis fields /
685# sizing / canvas) are seeded locally where they're computed.
686_VIZ_WIDGET_DEFAULTS = {
687 # First-load layers default to the *core scanpath* only — fixations, saccades
688 # and the reading text — so a new user lands on a legible picture instead of
689 # seven stacked encodings. The bounding-box grid and the density heatmap are
690 # analytical overlays, off by default and one click (or one design preset) away.
691 "global_show_words": False,
692 "global_show_labels": True,
693 # UX-128: the 📄 Stimulus section's own master switch — on by default, so
694 # a fresh session's figure is unchanged (Text on, Bounding boxes/Image
695 # off, same as before this toggle existed).
696 "global_show_stimulus": True,
697 "global_show_fix": True,
698 "global_show_order": False,
699 "global_show_saccades": True,
700 "global_show_saccade_arrows": False,
701 "global_saccade_color": SACCADE_COLOR,
702 "global_saccade_style": "Solid",
703 "global_saccade_width": DEFAULT_SACCADE_WIDTH,
704 # VIZ-8: colour saccades uniformly, or by reading type (forward / skip /
705 # refixation / return sweep / regression). "By type" splits the saccade trace
706 # into one colour per class with a small legend; the five class colours are
707 # each restorable, so seed them here.
708 "global_saccade_color_mode": "Uniform",
709 # VIZ-8: show the saccade-type colour key on the plot (default on). Optional,
710 # like the other legends.
711 "global_saccade_type_legend": True,
712 # The fixed duration scale: one mapping of duration to marker size for every
713 # figure (√ by default — area grows with duration). Old configs and links
714 # that predate it are migrated to "relative" so they still draw as saved.
715 "global_marker_size_scale": DEFAULT_MARKER_SIZE_SCALE,
716 "global_marker_duration_range": DEFAULT_MARKER_DURATION_RANGE,
717 "global_duration_size_legend": True,
718 # 📐 Figure & canvas → Legends: where each legend sits (all Auto = as drawn
719 # before the setting existed). Size None = the figure's own text size.
720 **{
721 f"global_legend_{kind}_{part}": default
722 for kind in LEGEND_KINDS
723 for part, default in (
724 ("position", "auto"),
725 ("arrangement", "auto"),
726 ("size", None),
727 )
728 },
729 "global_saccade_class_color_forward": SACCADE_CLASS_COLORS["forward"],
730 "global_saccade_class_color_skip": SACCADE_CLASS_COLORS["skip"],
731 "global_saccade_class_color_refixation": SACCADE_CLASS_COLORS["refixation"],
732 "global_saccade_class_color_return_sweep": SACCADE_CLASS_COLORS["return_sweep"],
733 "global_saccade_class_color_regression": SACCADE_CLASS_COLORS["regression"],
734 # VIZ-31: the saccade *filter* — which reading classes are drawn at all. The
735 # same `measures.classify_saccades` split the colour mode above uses, applied
736 # as visibility instead of hue ("show me only the regressions"). Default is
737 # every class, which the figure builder treats as "no filter" and short-
738 # circuits, so the common case pays nothing.
739 "global_saccade_classes": list(SACCADE_CLASS_ORDER),
740 # VIZ-9 "linear reading" mode: draw saccades as upward arcs (`Arc`) instead of
741 # straight connectors, and/or snap each fixation above the word it lands on.
742 "global_saccade_render_mode": "Straight",
743 "global_fixation_snap_to_word": False,
744 "global_illustration_label": "Auto",
745 "global_illustration_text": "",
746 # VIZ-10: autoplay the animated replay on load (default on). The toggle lives
747 # in the Animate ⚙ Playback popover (tabs.render_single_trial_tab); the replay
748 # player starts it at the configured speed (plots.animation_player_post_script).
749 "global_anim_autoplay": True,
750 # VIZ-11 follow-up: the animation frame grid, exposed instead of decided for
751 # the user. Step = smoothness; max frames = the ceiling that keeps a long
752 # reading's GIF/MP4 bounded (it coarsens the step, and the popover says so).
753 # Defaults match the old constants, so nothing changes until someone moves them.
754 "global_anim_grid_step_ms": 100,
755 "global_anim_max_frames": 360,
756 # VIZ-6: fixation marker alpha. Default 0.7 so overlapping fixations show
757 # through (the classic translucent scanpath look); drag to 1.0 for fully
758 # opaque markers. This replaced the old binary `Hollow circles` toggle in the
759 # UI — the `global_hollow_fixations` key is kept (no widget) so saved configs
760 # / deep links that carry it still render hollow.
761 "global_fixation_opacity": 0.7,
762 "global_hollow_fixations": False,
763 # VIZ-17: the flat colour every fixation wears when "Color fixations by" is
764 # "(uniform)" — the default, since marker size already encodes duration.
765 "global_fixation_color": DEFAULT_FIXATION_COLOR,
766 # VIZ-15: fixation marker shape. A second encoding channel that, unlike hue,
767 # survives greyscale printing.
768 "global_fixation_symbol": DEFAULT_FIXATION_SYMBOL,
769 # VIZ-18: the active colour palette. A preset, not a rendering mode — picking
770 # one writes the individual colour keys below, so every per-element picker
771 # still overrides it and every surface carries the resulting colours.
772 "global_palette": DEFAULT_PALETTE,
773 # PRE-3: in-place vertical drift-correction. "Off" = raw fixations; otherwise
774 # one of alignment.ALGORITHMS (title-cased in the UI) snaps each fixation to
775 # its assigned text line. `align_connectors` draws faint original→corrected
776 # connector lines.
777 "global_align_algorithm": "Off",
778 "global_align_connectors": False,
779 "global_highlight_text_color": HIGHLIGHTED_TEXT_COLOR,
780 "global_show_heatmap": False,
781 "global_heatmap_sigma_auto": True,
782 "global_heatmap_sigma_px": DEFAULT_HEATMAP_SIGMA_PX,
783 "global_show_raw_gaze": False,
784 # UX-86: raw gaze's own style — previously fixed in `plots._add_raw_gaze_layer`
785 # (#888888, size 4, opacity 0.6) with no control at all.
786 "global_raw_gaze_color": "#888888",
787 "global_raw_gaze_marker_size": 4.0,
788 "global_raw_gaze_opacity": 0.6,
789 # ⬚ Word boxes' own style — previously fixed in `plots.build_word_boxes`.
790 "global_word_box_color": WORD_BOX_COLOR,
791 "global_word_box_line_opacity": WORD_BOX_LINE_OPACITY,
792 "global_word_box_fill_color": WORD_BOX_FILL_COLOR,
793 "global_word_box_fill_opacity": WORD_BOX_FILL_OPACITY,
794 "global_show_stimulus_image": False,
795 # VIZ-4: image-based stimuli. Opacity dims a busy stimulus image so the AOIs /
796 # scanpath read over it (round-trips in Share / Save & restore, since it also
797 # applies to dataset images). A user-uploaded image (session-only — an uploaded
798 # image can't ride a deep link) is stretched to fill the monitor; precise
799 # crop placement is available via the CLI / headless API (background_image_*).
800 "global_stimulus_image_opacity": 1.0,
801 # VIZ-4: manual image alignment — nudge the image origin (px) and scale its
802 # size so it lines up with the text boxes / fixations when the data's frame
803 # doesn't match the image. Applies to dataset + uploaded images alike.
804 "global_stimulus_image_offset_x": 0.0,
805 "global_stimulus_image_offset_y": 0.0,
806 "global_stimulus_image_scale": 1.0,
807 "global_heatmap_style": "Word boxes",
808 "global_heatmap_metric": "duration_ms",
809 # VIZ-3: heatmap colour-scaling. "Linear" maps colour straight to the value;
810 # "Log" maps to log1p(value), compressing heavy-tailed dwell times so a few
811 # very-hot words don't wash out the rest.
812 "global_heatmap_norm": "Linear",
813 "global_show_fixation_colorbar": True,
814 "global_show_heatmap_colorbar": True,
815 # Frame the view to the whole presentation monitor (scanpath sits at its true
816 # on-screen position) rather than cropping to the data extent. Default on.
817 "global_fit_to_monitor": True,
818 # VIZ-34: optional monitor-pixel coordinate grid. Auto chooses a stable
819 # 1/2/5×10ⁿ interval from the visible range; the stored manual value remains
820 # available while Auto is on so switching back does not lose it.
821 "global_show_coordinate_grid": False,
822 "global_coordinate_grid_auto": True,
823 "global_coordinate_grid_spacing": 100.0,
824 "global_order_font_color": "#111111",
825 "global_order_font_size": 10,
826 "global_fixation_colorscale": DEFAULT_FIXATION_COLORSCALE,
827 "global_heatmap_colorscale": DEFAULT_HEATMAP_COLORSCALE,
828 # Restorable by the Save & restore config too, so seed here (no inline
829 # value=/index=) to avoid Streamlit's "default value but also set via
830 # Session State API" warning when a restore pre-sets them.
831 "global_critical_span_style": "Mark text",
832 "global_span_border_color": "#000000",
833 # Fixation classification (viz-only — PRE-2). SHORT / LONG / OUT-OF-BOUNDS
834 # each get a mode (Off | Highlight | Discard); Highlight overlays a marker in
835 # the chosen symbol+colour, Discard hides them from the plot only (reading
836 # measures and export tables are untouched). Short/long thresholds in ms
837 # follow eyekit's discard_short (~80) / discard_long (~800).
838 "global_fixclass_short_mode": "Off",
839 "global_fixclass_short_threshold_ms": 80,
840 "global_fixclass_short_symbol": "triangle-up-open",
841 "global_fixclass_short_color": "#ff7f0e",
842 "global_fixclass_long_mode": "Off",
843 "global_fixclass_long_threshold_ms": 800,
844 "global_fixclass_long_symbol": "square-open",
845 "global_fixclass_long_color": "#9467bd",
846 "global_fixclass_oob_mode": "Off",
847 "global_fixclass_oob_symbol": "x",
848 "global_fixclass_oob_color": OUT_OF_TEXT_COLOR,
849 "global_fixclass_blink_mode": "Off",
850 "global_fixclass_blink_symbol": "diamond-open",
851 "global_fixclass_blink_color": "#17becf",
852 # VIZ-7: single-trial fixation-index window (start, end over `order_in_trial`)
853 # for the main scanpath plot. `None` = full trial; the real bounds depend on
854 # the selected trial's fixation count, so `render_plot_controls` resolves/clamps
855 # the concrete (1, max_fix) range at render time (mirroring `multi_fix_range`).
856 "single_fix_range": None,
857 # Whether that window survives a trial change. Off = the window belongs to
858 # the trial it was drawn on (switching trials shows the whole new trial); on
859 # = re-apply it to every trial, clamped to each one's length. Pinned here so
860 # it re-syncs when its popover first mounts on a later run (BUG-15).
861 "single_fix_range_all_trials": False,
862 # Show the A/B legend on the two-trial comparison overlay (CMP-2). Off by
863 # default — the per-scanpath colours already tell the readings apart.
864 "global_show_compare_legend": True, # #374 F26: names A and B
865 # VIZ-13: reading measure shown in the word hover tooltip. "Off" (None) hides
866 # the measure line; any canonical measure column name shows it.
867 "global_word_hover_measure": "total_fixation_duration_ms",
868 # Colour-bar styling (Axes & color bars expander).
869 **{
870 f"global_{bar}_colorbar_{name}": default
871 for bar in ("fixation", "heatmap")
872 for name, default in (
873 ("orientation", "Vertical"),
874 ("tickangle", 0),
875 ("tickfont_size", 12),
876 )
877 },
878 # EXP-5: title/caption on the figure (Figure & canvas group). Off by default;
879 # the two patterns are only meaningful while the toggle is on — see
880 # `_collect_viz_settings`, which reports them empty otherwise.
881 "global_show_title": False,
882 "global_show_caption": False,
883 "global_title_pattern": "",
884 "global_caption_pattern": "",
885}
888# Out-of-text fixation marker options: Plotly symbol → emoji-prefixed label (the
889# emoji makes each choice stand out in the dropdown).
890_OUT_OF_TEXT_MARKERS = {
891 "x": "✕ Cross",
892 "circle-open": "○ Circle",
893 "diamond-open": "◇ Diamond",
894 "square-open": "□ Square",
895 "star": "★ Star",
896 "triangle-up-open": "△ Triangle",
897 "triangle-down-open": "▽ Triangle (down)",
898}
900# Fixation-classification modes (PRE-2): a category can be left alone, marked with
901# an overlay marker, or hidden from the plot (viz-only — never changes measures).
902_FIXCLASS_MODES = ("Off", "Highlight", "Discard")
905#: PRE-2's four fixation classes: ``(key prefix, row title, help, has a ms
906#: threshold)``. UX-162 shortened the titles to fit one table row each.
907_FIXCLASS_CATEGORIES = (
908 ("short", "Short", "Fixations shorter than the ms threshold.", True),
909 ("long", "Long", "Fixations longer than the ms threshold.", True),
910 (
911 "oob",
912 "Out of bounds",
913 "Fixations in no word box (gaps between lines count).",
914 False,
915 ),
916 ("blink", "Blink", "Fixations at or next to a blink; needs a blink column.", False),
917)
920def _render_fixation_cleaning(
921 *, disabled: bool = False, reason: str = "", prefix: str = "global"
922) -> None:
923 """PRE-2 short / long / out-of-bounds / blink visual filtering controls.
925 VIZ-27 gives this its own popover instead of burying data inclusion under
926 marker styling. Viz-only: highlight or discard, with customizable short/long
927 thresholds, all on the spot.
929 UX-162: one table row per class — its mode, then the ms threshold (short and
930 long only), then the marker and colour a *Highlight* draws with — under a
931 captioned header, instead of up to four rows each that came and went with the
932 mode. What a mode leaves idle is greyed, not hidden. Every value rides a
933 ``global_fixclass_{prefix}_*`` key (seeded in ``_VIZ_WIDGET_DEFAULTS``).
935 ``make_scanpath_figure`` and ``make_scanpath_animation`` both consume
936 ``fixation_flags`` (VIZ-23 — *Discard* drops the rows before the replay's
937 frames are built, *Highlight* overlays them as the trail reaches them); the
938 comparison builders take no flags argument, so the whole block renders
939 disabled (with the reason) in Compare only.
941 CMP-24: the comparison builders take the flags now, one set per scanpath.
942 ``prefix`` is the key namespace — ``global`` for A (and every non-compare
943 figure), ``cmp1`` for scanpath B, whose table has only the *Mode* and *ms*
944 columns: B chooses which of its fixations are flagged, and a Highlight draws
945 with A's marker and colour."""
946 own_look = prefix == "global"
947 label_w = _label_w()
948 rest = 1.0 - label_w
949 weights = (
950 [label_w, rest * 0.3, rest * 0.22, rest * 0.32, rest * 0.16]
951 if own_look
952 else [label_w, rest * 0.5, rest * 0.5]
953 )
954 mode_help = _gated_help(
955 "**Highlight** marks these fixations; **Discard** hides their markers "
956 "(saccades and the heatmap still use them; measures and exports are "
957 "unchanged).",
958 reason,
959 )
960 head = st.columns(weights, gap=_LABEL_GAP, vertical_alignment="center")
961 _sub_caption(head[1], "Mode", mode_help)
962 _sub_caption(head[2], "ms", "Short: below this many ms. Long: above it.")
963 if own_look:
964 _sub_caption(head[3], "Marker", "The marker a Highlight draws.")
965 _sub_caption(head[4], "Color")
966 for category, label, row_help, has_threshold in _FIXCLASS_CATEGORIES:
967 row_disabled, help_text = _layer_gate(disabled, _gated_help(row_help, reason))
968 cols = st.columns(weights, gap=_LABEL_GAP, vertical_alignment="center")
969 _row_label(cols[0], label, help_text)
970 mode = cols[1].selectbox(
971 f"{label} fixations",
972 options=_FIXCLASS_MODES,
973 key=f"{prefix}_fixclass_{category}_mode",
974 persist_state="session",
975 disabled=row_disabled,
976 help=mode_help,
977 label_visibility="collapsed",
978 )
979 if has_threshold:
980 cols[2].number_input(
981 f"{label} threshold (ms)",
982 min_value=1,
983 step=10,
984 key=f"{prefix}_fixclass_{category}_threshold_ms",
985 persist_state="session",
986 disabled=row_disabled or mode == "Off",
987 label_visibility="collapsed",
988 )
989 if not own_look:
990 continue
991 highlight_idle = row_disabled or mode != "Highlight"
992 cols[3].selectbox(
993 f"{label} marker",
994 options=list(_OUT_OF_TEXT_MARKERS),
995 format_func=lambda s: _OUT_OF_TEXT_MARKERS[s],
996 key=f"global_fixclass_{category}_symbol",
997 persist_state="session",
998 disabled=highlight_idle,
999 label_visibility="collapsed",
1000 )
1001 # `persist_state` keeps a picker first drawn in a popover from mounting
1002 # at its proto default (black) — BUG-15 / ENG-36.
1003 cols[4].color_picker(
1004 f"{label} color",
1005 key=f"global_fixclass_{category}_color",
1006 persist_state="session",
1007 disabled=highlight_idle,
1008 label_visibility="collapsed",
1009 )
1012def _collect_fixation_flags(prefix: str = "global") -> dict:
1013 """Build the ``fixation_flags`` dict the figure builder consumes from the
1014 ``global_fixclass_*`` session keys (PRE-2). One entry per category; ``oob`` has
1015 no threshold.
1017 CMP-24: ``prefix="cmp1"`` is scanpath B's set — its own modes and thresholds,
1018 A's markers and colours (see ``_render_fixation_cleaning``)."""
1019 ss = st.session_state
1020 return {
1021 "short": {
1022 "mode": ss.get(f"{prefix}_fixclass_short_mode", "Off"),
1023 "threshold_ms": float(
1024 ss.get(f"{prefix}_fixclass_short_threshold_ms") or 80
1025 ),
1026 "symbol": ss.get("global_fixclass_short_symbol") or "triangle-up-open",
1027 "color": ss.get("global_fixclass_short_color") or "#ff7f0e",
1028 },
1029 "long": {
1030 "mode": ss.get(f"{prefix}_fixclass_long_mode", "Off"),
1031 "threshold_ms": float(
1032 ss.get(f"{prefix}_fixclass_long_threshold_ms") or 800
1033 ),
1034 "symbol": ss.get("global_fixclass_long_symbol") or "square-open",
1035 "color": ss.get("global_fixclass_long_color") or "#9467bd",
1036 },
1037 "oob": {
1038 "mode": ss.get(f"{prefix}_fixclass_oob_mode", "Off"),
1039 "symbol": ss.get("global_fixclass_oob_symbol") or "x",
1040 "color": ss.get("global_fixclass_oob_color") or OUT_OF_TEXT_COLOR,
1041 },
1042 "blink": {
1043 "mode": ss.get(f"{prefix}_fixclass_blink_mode", "Off"),
1044 "symbol": ss.get("global_fixclass_blink_symbol") or "diamond-open",
1045 "color": ss.get("global_fixclass_blink_color") or "#17becf",
1046 },
1047 }
1050def _fixation_filter_badge(prefix: str = "global") -> str:
1051 """Compact VIZ-27 badge summarising active visual filters."""
1052 active = [
1053 st.session_state.get(f"{prefix}_fixclass_{name}_mode", "Off")
1054 for name in ("short", "long", "oob", "blink")
1055 ]
1056 n_active = sum(mode != "Off" for mode in active)
1057 n_discard = sum(mode == "Discard" for mode in active)
1058 if not n_active:
1059 return ""
1060 detail = f"{n_active} on"
1061 if n_discard:
1062 detail += f", {n_discard} discarding"
1063 return f" · {detail}"
1066def _plot_filter_badge() -> str:
1067 """UX-72: one badge for the whole Filters & highlights section.
1069 The section folds the fixation and saccade filters together, so its header
1070 has to answer "is anything being hidden?" for both — the reason each of them
1071 badged its own trigger before (VIZ-27): a thinned figure otherwise reads as
1072 missing data. The detail stays on each half's own badge inside.
1073 """
1074 comparing = bool(st.session_state.get("_resolved_comparing"))
1075 b_active = comparing and bool(
1076 _fixation_filter_badge("cmp1")
1077 or _saccade_filter_badge("cmp1_saccade_classes")
1078 or st.session_state.get("single_compare_fix_range_user_set")
1079 )
1080 return (
1081 " •" if _fixation_filter_badge() or _saccade_filter_badge() or b_active else ""
1082 )
1085def _saccade_filter_badge(key: str = "global_saccade_classes") -> str:
1086 """Compact badge summarising the VIZ-31 saccade reading-class filter.
1088 Mirrors :func:`_fixation_filter_badge` — an active filter must be visible
1089 without opening the popover, or a figure missing half its saccades reads as
1090 a data problem. Empty (or a full selection) means no filter, so no badge.
1091 """
1092 selected = st.session_state.get(key)
1093 if not selected:
1094 return ""
1095 hidden = [cls for cls in SACCADE_CLASS_ORDER if cls not in set(selected)]
1096 if not hidden:
1097 return ""
1098 if len(hidden) == len(SACCADE_CLASS_ORDER) - 1:
1099 # One class left standing — name it; "5 hidden" says much less than
1100 # "regression only" when that is the whole point of the figure.
1101 kept = next(c for c in SACCADE_CLASS_ORDER if c not in set(hidden))
1102 return f" · {SACCADE_CLASS_LABELS[kept].lower()} only"
1103 return f" · {len(hidden)} types hidden"
1106# Quick-view presets: one click starts from the app's visualization defaults and
1107# then applies the focused layer/style overrides below. A named view is therefore
1108# deterministic: returning to Scanpath cannot keep a colour, size, filter or
1109# geometry edit made in Custom. The persistent Custom tile owns that hand-tuned
1110# state and restores it exactly when selected again.
1111_ILLUSTRATION_OVERRIDE_KEYS = (
1112 "global_saccade_render_mode",
1113 "global_fixation_snap_to_word",
1114 "global_saccade_color_mode",
1115 "global_fixation_opacity",
1116)
1117_PRE_ILLUSTRATION_STATE = "_quick_view_pre_illustration"
1118_QUICK_VIEW_SELECTION_KEY = "_quick_view_selection"
1119_QUICK_VIEW_CUSTOM_STATE = "_quick_view_custom_state"
1120_QUICK_VIEW_APPLIED_STATE = "_quick_view_applied_state"
1121#: The design a drift to Custom left, and its baseline: ``(selection, state)``.
1122#: While the highlight reads Custom, settings that come back to that baseline
1123#: (Compare switched on and off again) put the design's highlight back.
1124_QUICK_VIEW_DRIFTED_FROM = "_quick_view_drifted_from"
1125_CUSTOM_VIEW = "custom"
1127#: VIZ-39 — the user's own saved designs: ``{name: {global_key: value}}``.
1128#: The three built-ins are *code*; these are data, and they are what the
1129#: "Quick views" row was standing in for — it offered exactly one unnamed
1130#: snapshot ("your most recent custom settings"), which could not be kept,
1131#: compared, or come back to.
1132DESIGN_PRESETS_KEY = _DESIGN_PRESETS_WIRE_KEY
1133#: Which saved design the ✏️ button has open for editing, if any.
1134_DESIGN_EDIT_KEY = "_design_preset_editing"
1135#: Whether the 💾 "Save current design" modal is open.
1136_DESIGN_SAVE_PENDING_KEY = "_design_save_pending"
1137#: Which saved design the 🗑️ confirmation is asking about, if any.
1138_DESIGN_DELETE_PENDING_KEY = "_design_delete_pending"
1139#: The 💾 dialog's two ways of saving, and the radio that picks between them.
1140_SAVE_MODE_NEW = "new"
1141_SAVE_MODE_REPLACE = "replace"
1142_DESIGN_SAVE_MODE_KEY = "design_save_mode"
1143#: `[name | ✏️ | 🗑️]` — the shared column split, so the rename field lands
1144#: exactly where the name it replaces was.
1145_DESIGN_ROW_W = (0.62, 0.19, 0.19)
1146#: A selection of `design:<name>`, kept in the same slot as the built-ins so
1147#: `_sync_quick_view_state`'s drift detection applies to saved designs too —
1148#: touch any control and the highlight drops, exactly as for a built-in.
1149_DESIGN_SELECTION_PREFIX = "design:"
1152def design_presets() -> dict[str, dict]:
1153 """The user's saved designs, newest last. Always a dict (VIZ-39)."""
1154 stored = st.session_state.get(DESIGN_PRESETS_KEY)
1155 return stored if isinstance(stored, dict) else {}
1158def _design_selection(name: str) -> str:
1159 return f"{_DESIGN_SELECTION_PREFIX}{name}"
1162def selected_design_name() -> str | None:
1163 """The saved design currently applied, or ``None``."""
1164 selected = st.session_state.get(_QUICK_VIEW_SELECTION_KEY)
1165 if isinstance(selected, str) and selected.startswith(_DESIGN_SELECTION_PREFIX):
1166 return selected[len(_DESIGN_SELECTION_PREFIX) :]
1167 return None
1170def save_design_preset(name: str) -> str | None:
1171 """Store the live plot settings under ``name``. Returns the name taken.
1173 An existing name is **overwritten**, which is what the ✏️ editor's *Update
1174 to current settings* means; the caller is what distinguishes saving a new
1175 design from updating one, because only it knows which the user asked for.
1176 """
1177 clean = " ".join(str(name).split())[:60]
1178 if not clean:
1179 return None
1180 # A design named after a built-in would shadow it in `_apply_view_preset`,
1181 # which checks saved designs first — so the built-in button would silently
1182 # start applying someone else's settings.
1183 if clean in _VIEW_PRESETS:
1184 clean = f"{clean} (mine)"
1185 presets = dict(design_presets())
1186 presets[clean] = _capture_quick_view_state()
1187 st.session_state[DESIGN_PRESETS_KEY] = presets
1188 st.session_state[_QUICK_VIEW_SELECTION_KEY] = _design_selection(clean)
1189 st.session_state.pop(_QUICK_VIEW_DRIFTED_FROM, None)
1190 # Pop rather than snapshot, exactly as `_apply_view_preset` does. Saving runs
1191 # inside the dialog's fragment frame, where popovers that are open have their
1192 # `*__num` slider twins in session_state; those keys are collected the moment
1193 # the popover closes, so a baseline taken here would read as drift on the
1194 # next full run and drop the highlight off the design just saved. Letting
1195 # `_sync_quick_view_state` take the baseline puts it in the same frame as
1196 # the comparison.
1197 st.session_state.pop(_QUICK_VIEW_APPLIED_STATE, None)
1198 return clean
1201def rename_design_preset(old: str, new: str) -> str | None:
1202 """Rename a saved design in place, keeping its position in the list."""
1203 clean = " ".join(str(new).split())[:60]
1204 presets = design_presets()
1205 if not clean or old not in presets or clean == old:
1206 return None
1207 st.session_state[DESIGN_PRESETS_KEY] = {
1208 (clean if key == old else key): value for key, value in presets.items()
1209 }
1210 if selected_design_name() == old:
1211 st.session_state[_QUICK_VIEW_SELECTION_KEY] = _design_selection(clean)
1212 return clean
1215def delete_design_preset(name: str) -> None:
1216 """Forget a saved design. The live plot settings are left exactly as they are.
1218 Deleting the design you are *looking at* must not change the figure — the
1219 settings are already applied and are the user's own. Only the highlight
1220 goes, which `_sync_quick_view_state` handles by falling through to Custom.
1221 """
1222 presets = dict(design_presets())
1223 presets.pop(name, None)
1224 st.session_state[DESIGN_PRESETS_KEY] = presets
1225 if selected_design_name() == name:
1226 st.session_state[_QUICK_VIEW_SELECTION_KEY] = _CUSTOM_VIEW
1227 if st.session_state.get(_DESIGN_EDIT_KEY) == name:
1228 st.session_state.pop(_DESIGN_EDIT_KEY, None)
1231#: UX-179 — the saved-design file: `{"kind": DESIGNS_FILE_KIND, "designs": {…}}`.
1232#: The retired 💾 Session backup was the only portable copy of the library; this
1233#: is its own file now, written by *Export* and read by *Import* in My designs.
1234DESIGNS_FILE_KIND = "scanpath_studio_designs"
1235#: 2 — the fixed duration scale. A schema-1 design predates it, so it keeps
1236#: the relative marker scale it was drawn with (`keep_legacy_marker_scale`).
1237DESIGNS_FILE_SCHEMA = 2
1238_DESIGN_IMPORT_KEY = "design_import_upload"
1239_DESIGN_IMPORT_NOTE_KEY = "_design_import_note"
1242def designs_to_json(designs: dict[str, dict]) -> str:
1243 """Serialize a design library to the Export file (pure — no Streamlit)."""
1244 from scanpath_studio import __version__
1246 return json.dumps(
1247 {
1248 "kind": DESIGNS_FILE_KIND,
1249 "schema": DESIGNS_FILE_SCHEMA,
1250 "app": {"name": "Scanpath Studio", "version": __version__},
1251 "designs": {name: dict(values) for name, values in designs.items()},
1252 },
1253 indent=2,
1254 )
1257def sanitize_design(values: dict) -> tuple[dict, list[str]]:
1258 """A design's settings as they may be applied, and the keys that were not.
1260 Keeps the keys :func:`_is_design_key` names, each through the typed,
1261 bounded rule a link or the recovery cache is read with
1262 (``url_state.sanitize_session_value``): numbers clamped to their widget's
1263 bounds, colours ``#rrggbb``, switches booleans, choices from their
1264 vocabulary. A value that fails is left out and its key returned, so one bad
1265 setting never reaches a widget or ``_collect_viz_settings``.
1266 """
1267 from .url_state import sanitize_session_value
1269 clean: dict = {}
1270 skipped: list[str] = []
1271 for key, value in values.items():
1272 key = str(key)
1273 if not _is_design_key(key):
1274 continue
1275 try:
1276 clean[key] = sanitize_session_value(key, value)
1277 except (TypeError, ValueError, OverflowError):
1278 skipped.append(key)
1279 return clean, skipped
1282def designs_from_json(text: str, *, report: list[str] | None = None) -> dict[str, dict]:
1283 """Parse an Export file into ``{name: settings}`` (pure — no Streamlit).
1285 Keeps only what a design can hold — the keys :func:`_is_design_key` names,
1286 as `_apply_view_preset` applies them, each validated by
1287 :func:`sanitize_design` — and gives a name that collides with a built-in
1288 the same ``" (mine)"`` suffix :func:`save_design_preset` does. What was
1289 left out is appended to ``report`` as one line per design (and one for a
1290 file written by a newer version). Raises ``ValueError`` for anything that
1291 is not a designs file.
1292 """
1293 data = json.loads(text)
1294 if not isinstance(data, dict) or data.get("kind") != DESIGNS_FILE_KIND:
1295 raise ValueError("not a Scanpath Studio designs file")
1296 raw = data.get("designs")
1297 if not isinstance(raw, dict):
1298 raise ValueError("the file holds no designs")
1299 try:
1300 schema = int(data.get("schema", 1))
1301 except (TypeError, ValueError, OverflowError):
1302 schema = 1
1303 notes = report if report is not None else []
1304 if schema > DESIGNS_FILE_SCHEMA:
1305 notes.append(
1306 "From a newer version; settings this one doesn't know were skipped"
1307 )
1308 designs: dict[str, dict] = {}
1309 for name, values in raw.items():
1310 clean = " ".join(str(name).split())[:60]
1311 if not clean or not isinstance(values, dict):
1312 if clean:
1313 notes.append(f"{clean}: not a design, skipped")
1314 continue
1315 if clean in _VIEW_PRESETS:
1316 clean = f"{clean} (mine)"
1317 design = {
1318 str(key): value for key, value in values.items() if _is_design_key(key)
1319 }
1320 if schema < 2:
1321 design = keep_legacy_marker_scale(design)
1322 # A design saved before a key was renamed holds the old name.
1323 design, skipped = sanitize_design(rename_legacy_keys(design))
1324 designs[clean] = design
1325 if skipped:
1326 notes.append(
1327 f"{clean}: skipped {len(skipped)} invalid setting"
1328 + ("" if len(skipped) == 1 else "s")
1329 )
1330 return designs
1333def _import_designs() -> None:
1334 """``on_change`` of the Import uploader: merge the file into the library.
1336 A design with the same name is **replaced** — the file is the newer copy
1337 of it — and every other design the user has stays.
1338 """
1339 uploaded = st.session_state.get(_DESIGN_IMPORT_KEY)
1340 if uploaded is None:
1341 return
1342 report: list[str] = []
1343 try:
1344 incoming = designs_from_json(uploaded.getvalue().decode("utf-8"), report=report)
1345 except (ValueError, UnicodeDecodeError):
1346 st.session_state[_DESIGN_IMPORT_NOTE_KEY] = (
1347 "error:Couldn't import it: it isn't a designs file."
1348 )
1349 return
1350 st.session_state[DESIGN_PRESETS_KEY] = {**design_presets(), **incoming}
1351 count = len(incoming)
1352 note = f"Imported {count} design{'' if count == 1 else 's'}."
1353 if report:
1354 note = f"warning:{note} " + "; ".join(report) + "."
1355 st.session_state[_DESIGN_IMPORT_NOTE_KEY] = note
1358def _render_design_file_row(host, saved: dict[str, dict]) -> None:
1359 """*Export* / *Import* under the design list (UX-179)."""
1360 note = st.session_state.pop(_DESIGN_IMPORT_NOTE_KEY, None)
1361 if note and note.startswith("error:"):
1362 host.error(note.removeprefix("error:"), icon=ICONS["error"])
1363 elif note and note.startswith("warning:"):
1364 host.warning(note.removeprefix("warning:"), icon=ICONS["warning"])
1365 elif note:
1366 host.success(note, icon=ICONS["confirm"])
1367 row = host.container(horizontal=True, gap="small", key="design_file_row")
1368 row.download_button(
1369 "Export",
1370 icon=ICONS["download"],
1371 data=designs_to_json(saved),
1372 file_name="scanpath_studio_designs.json",
1373 mime="application/json",
1374 key="design_export",
1375 disabled=not saved,
1376 help="Download your saved designs as a JSON file, to use on another "
1377 "computer or share.",
1378 )
1379 with row.popover("Import", icon=ICONS["upload"]):
1380 st.file_uploader(
1381 "Designs file (JSON)",
1382 type=["json"],
1383 key=_DESIGN_IMPORT_KEY,
1384 on_change=_import_designs,
1385 max_upload_size=upload_limit_mb(),
1386 )
1387 st.caption(
1388 "A file exported here. A design with the same name as one of yours "
1389 "replaces it; the rest are added."
1390 )
1393def _toggle_design_editor(name: str) -> None:
1394 """✏️ opens the inline editor for one design, and closes any other."""
1395 current = st.session_state.get(_DESIGN_EDIT_KEY)
1396 st.session_state[_DESIGN_EDIT_KEY] = None if current == name else name
1399_VIEW_PRESETS: dict[str, dict[str, object]] = {
1400 "scanpath": {
1401 "global_show_fix": True,
1402 "global_show_saccades": True,
1403 "global_show_saccade_arrows": False,
1404 "global_show_labels": True,
1405 "global_show_stimulus": True,
1406 "global_show_order": False,
1407 "global_show_heatmap": False,
1408 "global_show_words": False,
1409 "global_show_raw_gaze": False,
1410 },
1411 "heatmap": {
1412 "global_show_heatmap": True,
1413 "global_show_labels": True,
1414 "global_show_stimulus": True,
1415 "global_show_fix": False,
1416 "global_show_saccades": False,
1417 "global_show_order": False,
1418 "global_show_words": False,
1419 "global_show_raw_gaze": False,
1420 # #374 F16: the word colours are the whole figure; a highlighted span
1421 # would read as part of the map.
1422 "global_critical_span_style": "None",
1423 },
1424 "illustration": {
1425 "global_show_fix": True,
1426 "global_show_saccades": True,
1427 "global_show_saccade_arrows": False,
1428 "global_show_labels": True,
1429 "global_show_stimulus": True,
1430 "global_show_order": False,
1431 "global_show_heatmap": False,
1432 "global_show_words": False,
1433 "global_show_raw_gaze": False,
1434 "global_saccade_render_mode": "Arc",
1435 "global_fixation_snap_to_word": True,
1436 "global_saccade_color_mode": "Uniform",
1437 "global_fixation_opacity": 1.0,
1438 },
1439 "reading_order": {
1440 "global_show_fix": True,
1441 "global_show_order": True,
1442 "global_show_saccades": True,
1443 "global_show_saccade_arrows": True,
1444 "global_show_labels": True,
1445 "global_show_stimulus": True,
1446 "global_show_heatmap": False,
1447 "global_show_words": False,
1448 "global_show_raw_gaze": False,
1449 },
1450 "everything": {
1451 "global_show_words": True,
1452 "global_show_labels": True,
1453 "global_show_stimulus": True,
1454 "global_show_fix": True,
1455 "global_show_saccades": True,
1456 "global_show_saccade_arrows": True,
1457 "global_show_heatmap": True,
1458 "global_show_order": False,
1459 "global_show_raw_gaze": False,
1460 },
1461}
1464def _forget_raw_gaze_default(ss) -> None:
1465 """Let the next run decide the raw-gaze layer for the open dataset again.
1467 A named design or *Reset* puts the view back to the defaults, and on a
1468 dataset whose only gaze is samples the default is **on** — a preset whose
1469 name says nothing about raw gaze must not leave that dataset a blank plot.
1470 Clearing the record (and the stashed pre-snap value, which the reset has
1471 just made stale) is what lets `app.seed_raw_gaze_default` apply it."""
1472 ss.pop(RAW_GAZE_SEEDED_FOR_KEY, None)
1473 ss.pop(RAW_GAZE_SNAP_RESTORE_KEY, None)
1474 # …and the link's claim: both callers take the link's view params off the
1475 # URL, so the dataset is decided afresh rather than left on the link's off.
1476 ss.pop(RAW_GAZE_LINK_FOR_KEY, None)
1479def _drop_linked_view_params() -> None:
1480 """Take a deep link's view params off the URL once a design is chosen.
1482 ``url_state._apply_url_preset`` re-applies them at the top of every rerun,
1483 as ``setdefault`` — so any key the chosen design leaves unset is refilled
1484 from the link. Every design leaves some unset; since VIZ-46 an *auto*
1485 colour range is one of them (absent means auto), so a design saved on auto
1486 came back showing the link's range. Selection/source params are not in
1487 ``URL_PRESET_PARAMS`` and stay.
1488 """
1489 from . import session_keys as _sk
1491 for param in (*_sk.URL_PRESET_PARAMS, *_sk.LEGEND_PARAMS):
1492 st.query_params.pop(param, None)
1495def _apply_view_preset(name: str) -> None:
1496 """Apply one deterministic named view, or restore the Custom snapshot.
1498 Runs as a button ``on_click`` callback, i.e. *before* the next rerun
1499 instantiates the layer checkboxes — so writing their ``global_show_*`` keys
1500 here is picked up cleanly (no "set after widget instantiated" warning).
1502 Named views always begin from ``_VIZ_WIDGET_DEFAULTS``. Existing dynamic
1503 ``global_*`` values are cleared so the next app rerun can re-seed dataset-
1504 dependent canvas/field defaults. Custom is the only view that preserves
1505 manual edits; leaving it snapshots the complete live design state (``_is_design_key``).
1506 """
1507 saved = design_presets()
1508 if name not in {*_VIEW_PRESETS, _CUSTOM_VIEW, *saved}:
1509 raise ValueError(f"Unknown design preset: {name}")
1511 ss = st.session_state
1512 before = {
1513 key: deepcopy(ss[key])
1514 for key in list(ss)
1515 if _is_design_key(key) or _is_restorable_global(key)
1516 }
1517 try:
1518 _apply_view_preset_state(ss, name, saved)
1519 finally:
1520 _hold_view_writes(ss, before)
1523def _hold_view_writes(ss, before: dict) -> None:
1524 """#374 F9 for the design presets: hold every value a preset changed.
1526 A popover widget the user has opened keeps echoing the value it last
1527 showed, so a preset's write to it (Saccades ▾ → Arc, Fixations ▾ → Snap,
1528 the defaults a preset resets to) would hold for one run and snap back. So
1529 each changed value goes through `write_through`. The dataset-seeded keys
1530 are left alone: the rerun seeds them from the data, and holding the
1531 static default would overwrite that.
1532 """
1533 for key, old in before.items():
1534 if key in _SEEDED_VIEW_KEYS or key not in ss:
1535 continue
1536 if _write_match_key(ss[key]) != _write_match_key(old):
1537 write_through(key, ss[key])
1540#: Keys `app.seed_canvas_state` / the font and raw-gaze seeding refill after a
1541#: preset clears their guards — see `_hold_view_writes`.
1542_SEEDED_VIEW_KEYS = frozenset(
1543 {
1544 "global_canvas_width",
1545 "global_canvas_height",
1546 "global_base_font_size",
1547 "global_font_family",
1548 "global_scale_text_to_boxes",
1549 "global_show_raw_gaze",
1550 }
1551)
1554def _apply_view_preset_state(ss, name: str, saved: dict) -> None:
1555 """The body of `_apply_view_preset`: write the chosen design's state."""
1556 ss.pop(_QUICK_VIEW_DRIFTED_FROM, None)
1557 current = ss.get(_QUICK_VIEW_SELECTION_KEY)
1558 if current == _CUSTOM_VIEW:
1559 ss[_QUICK_VIEW_CUSTOM_STATE] = _capture_quick_view_state()
1561 # VIZ-39 — one of the user's own saved designs. Same shape as the built-in
1562 # branch below (clear, then write what the design owns), and deliberately
1563 # NOT the Custom branch's shape: a saved design is a *record* of settings,
1564 # so it starts from the widget defaults like a built-in does rather than
1565 # layering onto whatever happened to be on screen.
1566 if name in saved:
1567 for key in list(ss):
1568 if _is_design_key(key):
1569 ss.pop(key, None)
1570 for key, value in _VIZ_WIDGET_DEFAULTS.items():
1571 if _is_design_key(key):
1572 ss[key] = deepcopy(value)
1573 # Validated again here: a design can also come from the recovery
1574 # cache or an older session, and a bad value must cost that setting,
1575 # not the rerun (round 11).
1576 for key, value in sanitize_design(saved[name])[0].items():
1577 ss[key] = deepcopy(value)
1578 ss.pop("_canvas_seeded_for", None)
1579 ss.pop("_font_seeded_for", None)
1580 ss.pop("_palette_picked", None)
1581 ss.pop(_PRE_ILLUSTRATION_STATE, None)
1582 _drop_linked_view_params()
1583 ss[_QUICK_VIEW_SELECTION_KEY] = _design_selection(name)
1584 ss.pop(_QUICK_VIEW_APPLIED_STATE, None)
1585 return
1587 if name == _CUSTOM_VIEW:
1588 custom = ss.get(_QUICK_VIEW_CUSTOM_STATE)
1589 if isinstance(custom, dict):
1590 for key in list(ss):
1591 if _is_design_key(key):
1592 ss.pop(key, None)
1593 for key, value in custom.items():
1594 if _is_design_key(key):
1595 ss[key] = deepcopy(value)
1596 _drop_linked_view_params()
1597 ss[_QUICK_VIEW_SELECTION_KEY] = _CUSTOM_VIEW
1598 ss.pop(_QUICK_VIEW_APPLIED_STATE, None)
1599 return
1601 for key in list(ss):
1602 if _is_restorable_global(key):
1603 ss.pop(key, None)
1604 for key, value in _VIZ_WIDGET_DEFAULTS.items():
1605 if _is_restorable_global(key):
1606 ss[key] = deepcopy(value)
1607 # Canvas/font values are source-dependent rather than static defaults.
1608 # Removing their guards lets app.seed_canvas_state restore them on the rerun
1609 # that follows this button callback.
1610 ss.pop("_canvas_seeded_for", None)
1611 ss.pop("_font_seeded_for", None)
1612 ss.pop("_palette_picked", None)
1613 ss.pop(_PRE_ILLUSTRATION_STATE, None)
1614 # VIZ-45 — the raw-gaze layer is dataset-dependent too. Not for a saved
1615 # design above: that is the user's own record, raw-gaze switch included.
1616 _forget_raw_gaze_default(ss)
1618 # A deep-link preset is applied at the top of every rerun. Once the user has
1619 # explicitly chosen a design preset it must not immediately put the old visual
1620 # settings back; selection/source parameters are not part of this list.
1621 _drop_linked_view_params()
1623 for key, value in _VIEW_PRESETS[name].items():
1624 ss[key] = deepcopy(value)
1625 ss[_QUICK_VIEW_SELECTION_KEY] = name
1626 # The complete baseline is captured by `_sync_quick_view_state` after the
1627 # next rerun has restored data-dependent defaults.
1628 ss.pop(_QUICK_VIEW_APPLIED_STATE, None)
1631#: Widget keys a design preset must not carry, even though they are `global_*`.
1632#: `st.file_uploader` refuses to have its key assigned from session state at
1633#: all, so snapshotting one and writing it back raises
1634#: `StreamlitValueAssignmentNotAllowedError` and takes the whole page down —
1635#: which is what happened the moment a Custom view was captured with a stimulus
1636#: image attached. The `_upload` suffix is the same convention
1637#: `url_state._restore_column_mapping` already excludes by.
1638def _is_restorable_global(key: object) -> bool:
1639 name = str(key)
1640 return name.startswith("global_") and not name.endswith("_upload")
1643#: The fixation-index windows (VIZ-7, CMP-24) — one per scanpath, each a value
1644#: plus the flag that says it was *chosen* rather than the slider's own
1645#: auto-default. They are filters, so a design owns them; see
1646#: `_capture_quick_view_state` for how they are recorded.
1647_FIX_WINDOWS = (
1648 ("single_fix_range", "single_fix_range_user_set"),
1649 (SINGLE_COMPARE_FIX_RANGE, f"{SINGLE_COMPARE_FIX_RANGE}_user_set"),
1650)
1651_FIX_WINDOW_KEYS = frozenset(key for pair in _FIX_WINDOWS for key in pair)
1652_FIX_WINDOW_ALL_TRIALS_KEY = "single_fix_range_all_trials"
1654#: Plot controls that are not `global_*` — the rest of what the rail, the Compare
1655#: view and the replay let you set. A design used to snapshot only `global_*`,
1656#: so it kept scanpath A's filters and dropped everything beside them: each
1657#: scanpath's own styling (`cmp{0,1}_*`), scanpath B's filters
1658#: (`cmp1_fixclass_*`, `cmp1_saccade_classes`), whether Compare is on and how it
1659#: lays out, the fixation windows and the replay speed. Only the stimulus-image
1660#: upload stays out, and that is `_is_restorable_global`'s.
1661_DESIGN_EXTRA_KEYS = (
1662 compare_state_keys(0)
1663 | compare_state_keys(1)
1664 | COMPARE_B_FILTER_STATE_KEYS
1665 | {
1666 SINGLE_COMPARE_TOGGLE,
1667 SINGLE_COMPARE_LAYOUT,
1668 SINGLE_COMPARE_STIMULUS,
1669 SINGLE_PLAYBACK_SPEED,
1670 _FIX_WINDOW_ALL_TRIALS_KEY,
1671 }
1672 | _FIX_WINDOW_KEYS
1673)
1676def _is_design_key(key: object) -> bool:
1677 """Whether a session key is a plot setting a design records and restores."""
1678 return _is_restorable_global(key) or str(key) in _DESIGN_EXTRA_KEYS
1681def _render_saved_designs(host) -> None:
1682 """The user's own designs: save, apply, rename, delete (VIZ-39).
1684 Its foot is *Export* / *Import* (UX-179): the library's portable file,
1685 which used to ride in the 💾 Session backup.
1687 The list is an expander, because it grows and the rail is narrow — and
1688 because it is the *built-ins* above that should stay one click away. The 💾
1689 button is drawn **into the expander's own title bar** (`.st-key-design_shell`
1690 is the positioning context; the CSS lives in `styles.py`) rather than in a
1691 column beside it: a column would take a fifth of the rail's width away from
1692 the list underneath for a single icon, and the header row's right-hand side
1693 is empty space Streamlit is not using. Saving stays on screen whether the
1694 list is open or shut, which is the point.
1695 """
1696 saved = design_presets()
1697 active = selected_design_name()
1698 label = (
1699 f"{ICONS['designs']} My designs ({len(saved)})"
1700 if saved
1701 else f"{ICONS['designs']} My designs"
1702 )
1703 shell = host.container(key="design_shell")
1704 with shell.expander(label, expanded=bool(st.session_state.get(_DESIGN_EDIT_KEY))):
1705 if not saved:
1706 st.caption(
1707 "No saved designs yet. Set the layers, colors and figure up "
1708 f"the way you like them, then hit {ICONS['save']} — it lands here, one click "
1709 "from every trial you look at afterwards."
1710 )
1711 for name in saved:
1712 # One bordered container per design, so a design reads as one object
1713 # rather than as three buttons that happen to be adjacent.
1714 row = st.container(border=True, key=f"design_row_{name}")
1715 if st.session_state.get(_DESIGN_EDIT_KEY) == name:
1716 _render_design_rename(row, name)
1717 continue
1718 cells = row.columns(_DESIGN_ROW_W, vertical_alignment="center")
1719 cells[0].button(
1720 name,
1721 key=f"design_apply_{name}",
1722 type="primary" if active == name else "tertiary",
1723 width="stretch",
1724 help=f"Apply “{name}”.",
1725 on_click=_apply_view_preset,
1726 args=(name,),
1727 )
1728 # Material icons rather than emoji: emoji render at whatever size and
1729 # baseline the platform font decides, which is what made these two
1730 # sit high and unaligned in their buttons.
1731 # UX-200: `spoken` names each icon for screen readers.
1732 cells[1].button(
1733 spoken(f"Rename design {name}"),
1734 icon=ICONS["edit"],
1735 wrap=True,
1736 key=f"design_edit_{name}",
1737 type="tertiary",
1738 width="stretch",
1739 help="Rename this design.",
1740 on_click=_toggle_design_editor,
1741 args=(name,),
1742 )
1743 cells[2].button(
1744 spoken(f"Delete design {name}"),
1745 icon=ICONS["delete"],
1746 wrap=True,
1747 key=f"design_delete_{name}",
1748 type="tertiary",
1749 width="stretch",
1750 help="Delete this design.",
1751 on_click=_ask_delete_design,
1752 args=(name,),
1753 )
1754 _render_design_file_row(st, saved)
1755 if shell.button(
1756 spoken("Save the plot settings as a design"),
1757 icon=ICONS["save"],
1758 wrap=True,
1759 key="design_save",
1760 help="Save the plot settings on screen now as a named design.",
1761 ):
1762 st.session_state[_DESIGN_SAVE_PENDING_KEY] = True
1763 st.session_state[_DESIGN_SAVE_MODE_KEY] = _SAVE_MODE_NEW
1764 if st.session_state.get(_DESIGN_SAVE_PENDING_KEY):
1765 _design_save_dialog()
1766 pending_delete = st.session_state.get(_DESIGN_DELETE_PENDING_KEY)
1767 if pending_delete in saved:
1768 _design_delete_dialog(str(pending_delete))
1771def _ask_delete_design(name: str) -> None:
1772 """🗑️ arms the confirmation instead of deleting (VIZ-39).
1774 A design is a handful of choices the user made and cannot get back by
1775 undoing anything, so it gets the same confirm step ♻️ Reset visualization
1776 has — and for the same reason, an `on_click` that only sets a flag, because
1777 a callback may not open a dialog.
1778 """
1779 st.session_state[_DESIGN_DELETE_PENDING_KEY] = name
1780 st.session_state.pop(_DESIGN_EDIT_KEY, None)
1783def _close_design_delete_dialog() -> None:
1784 """Disarm the 🗑️ confirmation. Also its ``on_dismiss`` hook."""
1785 st.session_state.pop(_DESIGN_DELETE_PENDING_KEY, None)
1788@st.dialog("Delete this design?", on_dismiss=_close_design_delete_dialog)
1789@guarded()
1790def _design_delete_dialog(name: str) -> None:
1791 """Confirm forgetting one saved design — VIZ-39."""
1792 st.caption(
1793 f"**{name}** will be deleted. The plot on screen does not change: these "
1794 "settings stay applied until you pick another design."
1795 )
1796 yes, no = st.columns(2, gap="small")
1797 if yes.button(
1798 f"{ICONS['delete']} Delete it",
1799 key="design_delete_confirm",
1800 type="primary",
1801 width="stretch",
1802 ):
1803 delete_design_preset(name)
1804 _close_design_delete_dialog()
1805 st.rerun(scope="app")
1806 if no.button("Cancel", key="design_delete_cancel", width="stretch"):
1807 _close_design_delete_dialog()
1808 st.rerun(scope="app")
1811def _close_design_save_dialog() -> None:
1812 """Disarm the modal. Also the ``on_dismiss`` hook — see below."""
1813 st.session_state.pop(_DESIGN_SAVE_PENDING_KEY, None)
1816# `on_dismiss` is what keeps a *flag*-driven dialog honest: ✕ and Esc close the
1817# modal in the browser without running a line of the body, so without this the
1818# flag stayed armed and the dialog reopened on the very next rerun — clicking a
1819# preset, toggling a layer, anything.
1820@st.dialog("Save current design", on_dismiss=_close_design_save_dialog)
1821@guarded()
1822def _design_save_dialog() -> None:
1823 """Name the settings on screen and keep them (VIZ-39).
1825 A **form**, so ⏎ is the same as clicking 💾 Save: Streamlit routes Enter to
1826 the form's *first* submit button, which is why Save is written before Cancel
1827 and why it is never `disabled` (a disabled first button turns Enter off for
1828 the whole form — an empty name is caught below instead).
1830 Opened from a pending flag rather than the button's return value, and closed
1831 with an explicit ``scope="app"`` rerun, for the same reason as
1832 `_reset_viz_confirmation_dialog`: a dialog body is a fragment.
1833 """
1834 saved = design_presets()
1835 mode = _SAVE_MODE_NEW
1836 if saved:
1837 # Outside the form on purpose. A form batches its widgets and does not
1838 # rerun until it is submitted, so a radio *inside* one cannot change
1839 # what the form shows — the choice has to be made where it can.
1840 mode = st.radio(
1841 "Save the settings on screen as",
1842 options=(_SAVE_MODE_NEW, _SAVE_MODE_REPLACE),
1843 format_func=lambda choice: (
1844 "A new design"
1845 if choice == _SAVE_MODE_NEW
1846 else "A replacement for one I saved"
1847 ),
1848 key=_DESIGN_SAVE_MODE_KEY,
1849 )
1850 with st.form("design_save_form", border=False):
1851 if mode == _SAVE_MODE_REPLACE:
1852 name = st.selectbox(
1853 "Design to replace",
1854 options=list(saved),
1855 key="design_replace_target",
1856 )
1857 st.warning(
1858 "The chosen design's stored settings are **overwritten** by "
1859 "the ones on screen now. What it held is not recoverable.",
1860 icon=ICONS["warning"],
1861 )
1862 else:
1863 name = st.text_input(
1864 "Design name",
1865 key="design_new_name",
1866 placeholder="e.g. Paper figure",
1867 # Streamlit 1.65 blocks the form's Save until there is a name;
1868 # `save_design_preset` still refuses a blank one server-side.
1869 required=True,
1870 help="Stores every plot setting on screen now — layers, "
1871 "colors, filter, figure and canvas.",
1872 )
1873 row = st.columns(2, gap="small")
1874 save = row[0].form_submit_button(
1875 f"{ICONS['save']} Save", type="primary", width="stretch"
1876 )
1877 cancel = row[1].form_submit_button("Cancel", width="stretch")
1878 if cancel:
1879 _close_design_save_dialog()
1880 st.rerun(scope="app")
1881 if save:
1882 if not save_design_preset(name or ""):
1883 st.error("Give the design a name first.")
1884 else:
1885 st.session_state.pop("design_new_name", None)
1886 _close_design_save_dialog()
1887 st.rerun(scope="app")
1890def _render_design_rename(row, name: str) -> None:
1891 """✏️ turns the card *itself* into the rename field — VIZ-39.
1893 The box sits exactly where the name was and the two icons keep their slots,
1894 so nothing moves under the cursor and the list does not grow a panel: the
1895 card is either a design you can apply or a name you are typing.
1897 A **form**, because ⏎ has to commit — Streamlit routes Enter to the first
1898 submit button, which is ✓. Both buttons are `on_click` callbacks rather than
1899 return values, so the row is already drawn in its new state on the rerun the
1900 submit itself causes; handling the result after the fact would draw the card
1901 once in the state the user just left.
1902 """
1903 with row.form(f"design_rename_form_{name}", border=False):
1904 cells = st.columns(_DESIGN_ROW_W, vertical_alignment="center")
1905 cells[0].text_input(
1906 "Name",
1907 value=name,
1908 key=f"design_rename_{name}",
1909 label_visibility="collapsed",
1910 required=True,
1911 )
1912 # Both need an explicit `key`: a submit button's identity is its label,
1913 # and these two shared the empty one — the icon is not part of it, so
1914 # the second silently collapsed to a 0-height cell without them.
1915 # UX-200 named them for screen readers (`spoken`).
1916 cells[1].form_submit_button(
1917 spoken("Save the new name"),
1918 icon=ICONS["confirm"],
1919 wrap=True,
1920 key=f"design_rename_go_{name}",
1921 type="tertiary",
1922 width="stretch",
1923 help=f"Rename “{name}”.",
1924 on_click=_rename_named_design,
1925 args=(name,),
1926 )
1927 cells[2].form_submit_button(
1928 spoken("Cancel renaming"),
1929 icon=ICONS["close"],
1930 wrap=True,
1931 key=f"design_rename_cancel_{name}",
1932 type="tertiary",
1933 width="stretch",
1934 help="Keep the name it has.",
1935 on_click=_cancel_design_rename,
1936 )
1939def _cancel_design_rename() -> None:
1940 st.session_state.pop(_DESIGN_EDIT_KEY, None)
1943def _rename_named_design(old: str) -> None:
1944 """``on_click`` for ✓: read the card's box, then leave edit mode.
1946 Always leaves it, including when the name is blank or unchanged — ✓ means
1947 *done*, and ✕ is there for backing out. `rename_design_preset` no-ops on
1948 both, so neither can lose a design.
1949 """
1950 rename_design_preset(old, st.session_state.get(f"design_rename_{old}") or "")
1951 st.session_state.pop(_DESIGN_EDIT_KEY, None)
1954def _capture_quick_view_state() -> dict[str, object]:
1955 """Snapshot every live plot setting for the persistent Custom view and for
1956 saved designs — the `global_*` keys plus `_DESIGN_EXTRA_KEYS`.
1958 Uploader keys are left out (see `_is_restorable_global`): an `UploadedFile`
1959 is neither deep-copyable in any useful sense nor assignable back, and the
1960 image itself is not a *setting* — it survives on its own widget key across
1961 the view switch regardless.
1963 A fixation window is recorded only once it has been *chosen*: the slider
1964 rewrites its auto-default on every render, so snapshotting that would make
1965 the drift check below fire on a plain trial change. It is applied to
1966 whichever trial is open, clamped to its length.
1967 """
1968 ss = st.session_state
1969 state = {
1970 str(key): deepcopy(value)
1971 for key, value in ss.items()
1972 if _is_design_key(key) and str(key) not in _FIX_WINDOW_KEYS
1973 }
1974 for window_key, user_set_key in _FIX_WINDOWS:
1975 window = ss.get(window_key)
1976 if (
1977 ss.get(user_set_key)
1978 and isinstance(window, (list, tuple))
1979 and len(window) == 2
1980 ):
1981 state[window_key] = tuple(window)
1982 return state
1985#: VIZ-44 — `global_*` keys that *mirror* a setting rather than hold one, so
1986#: they are left out of the drift check below. The highlight-span pair is
1987#: re-derived from `global_critical_span_style` on every run the Stimulus
1988#: popover draws, and each `__num*` key is the typed box beside a slider,
1989#: registered the first time its popover renders — neither is anything the user
1990#: set apart from the canonical key, which the check does compare.
1991_DRIFT_MIRROR_KEYS = frozenset(
1992 {"global_highlight_span_on", "global_highlight_span_mode"}
1993)
1994_NUMERIC_TWIN_SUFFIXES = ("__num", "__num_lo", "__num_hi")
1995_ABSENT = object()
1998#: #374 F25 — Compare and Animate are ways of *viewing* a design, not part of
1999#: it: switching one on leaves the design's highlight where it is. (A design
2000#: still records whether Compare was on, so applying it restores that.)
2001_VIEW_MODE_KEYS = frozenset({SINGLE_COMPARE_TOGGLE, "single_animate"})
2002#: The dataset the applied design's baseline was taken on; see
2003#: `_sync_quick_view_state`.
2004_QUICK_VIEW_DATASET = "_quick_view_dataset"
2007def _is_drift_mirror(key: str) -> bool:
2008 return key in _DRIFT_MIRROR_KEYS or key.endswith(_NUMERIC_TWIN_SUFFIXES)
2011def _design_drifted(applied: dict, current: dict) -> bool:
2012 """Whether the plot settings moved off the design that was applied (VIZ-44).
2014 Every design key counts — a named view resets *all* of them to the widget
2015 defaults, so changing any plot control is a departure from it — except the
2016 mirrors above. A key present on only one side is compared against its
2017 widget default: a control registered late (its popover opened after the
2018 baseline was taken) at its default value has not been changed, while a key
2019 with no default (an explicit colour range, VIZ-46) appearing *has*.
2020 """
2021 for key in applied.keys() | current.keys():
2022 if _is_drift_mirror(key) or key in _VIEW_MODE_KEYS:
2023 continue
2024 default = _VIZ_WIDGET_DEFAULTS.get(key, _ABSENT)
2025 if applied.get(key, default) != current.get(key, default):
2026 return True
2027 return False
2030def _drift_to_custom(selected: str, applied: dict) -> str:
2031 """Drop the highlight to Custom, remembering the design it left."""
2032 ss = st.session_state
2033 ss[_QUICK_VIEW_SELECTION_KEY] = _CUSTOM_VIEW
2034 ss[_QUICK_VIEW_CUSTOM_STATE] = _capture_quick_view_state()
2035 ss[_QUICK_VIEW_DRIFTED_FROM] = (selected, applied)
2036 ss.pop(_QUICK_VIEW_APPLIED_STATE, None)
2037 return _CUSTOM_VIEW
2040def _returned_to_design() -> str | None:
2041 """The design a drift left, once the settings are back on its baseline.
2043 Changing a setting reads Custom; changing it back restores every setting
2044 the design had, so the highlight goes back to it rather than staying
2045 Custom. Only the design that was left is checked —
2046 an explicit pick (`_apply_view_preset`, a save, Reset) forgets it.
2047 """
2048 ss = st.session_state
2049 drifted = ss.get(_QUICK_VIEW_DRIFTED_FROM)
2050 if not (isinstance(drifted, tuple) and len(drifted) == 2):
2051 return None
2052 name, applied = drifted
2053 saved = name.removeprefix(_DESIGN_SELECTION_PREFIX)
2054 still_exists = name in _VIEW_PRESETS or (
2055 name != saved and saved in design_presets()
2056 )
2057 if not still_exists or not isinstance(applied, dict):
2058 ss.pop(_QUICK_VIEW_DRIFTED_FROM, None)
2059 return None
2060 if _design_drifted(applied, _capture_quick_view_state()):
2061 return None
2062 ss[_QUICK_VIEW_SELECTION_KEY] = name
2063 ss[_QUICK_VIEW_APPLIED_STATE] = applied
2064 ss.pop(_QUICK_VIEW_DRIFTED_FROM, None)
2065 return name
2068def _link_departs_from(name: str) -> bool:
2069 """Whether the open deep link sets a design value design ``name`` would not.
2071 #374 F25: on a link's first run the highlight is inferred from the settings
2072 the link restored. Matching a design's own few keys is not enough — a link
2073 from a view with a hand-changed colour matched Scanpath — so every design
2074 value the link carries is held against what the design would set (its own
2075 value, else the widget default). Values with no default (the canvas size,
2076 seeded per dataset) say nothing either way.
2077 """
2078 from .url_state import linked_state_keys
2080 ss = st.session_state
2081 preset = _VIEW_PRESETS[name]
2082 for key in linked_state_keys():
2083 if not _is_design_key(key) or key in _VIEW_MODE_KEYS or key not in ss:
2084 continue
2085 expected = preset.get(key, _VIZ_WIDGET_DEFAULTS.get(key, _ABSENT))
2086 if expected is _ABSENT:
2087 continue
2088 if _write_match_key(ss.get(key)) != _write_match_key(expected):
2089 return True
2090 return False
2093def _sync_quick_view_state() -> str:
2094 """Keep the design-preset highlight in step with manual plot-control edits.
2096 Only a change to a plot setting drops the highlight to 🛠️ Custom; see
2097 `_design_drifted` for what is not one (VIZ-44 — narrowing the trial pool
2098 used to flip it).
2099 """
2100 ss = st.session_state
2101 selected = ss.get(_QUICK_VIEW_SELECTION_KEY)
2102 # #374 F25: another dataset re-seeds its own canvas size, highlight column
2103 # and hover fields. That is the design meeting new data, not a departure
2104 # from it, so a design that was in force stays highlighted: its baseline is
2105 # retaken on the new dataset (the seeds have run by now).
2106 dataset = (ss.get("data_source_choice"), ss.get("public_dataset_choice"))
2107 if ss.get(_QUICK_VIEW_DATASET, dataset) != dataset and isinstance(
2108 ss.get(_QUICK_VIEW_APPLIED_STATE), dict
2109 ):
2110 ss[_QUICK_VIEW_APPLIED_STATE] = _capture_quick_view_state()
2111 ss[_QUICK_VIEW_DATASET] = dataset
2112 # VIZ-39: a `design:<name>` selection is valid while that design still
2113 # exists, and from here on is treated exactly like a built-in — including
2114 # the drift check below, so editing any control drops the highlight.
2115 if selected_design_name() in design_presets():
2116 applied = ss.get(_QUICK_VIEW_APPLIED_STATE)
2117 if not isinstance(applied, dict):
2118 ss[_QUICK_VIEW_APPLIED_STATE] = _capture_quick_view_state()
2119 return str(selected)
2120 if _design_drifted(applied, _capture_quick_view_state()):
2121 return _drift_to_custom(str(selected), applied)
2122 return str(selected)
2123 if selected not in {*_VIEW_PRESETS, _CUSTOM_VIEW}:
2124 selected = next(
2125 (
2126 name
2127 for name in ("illustration", "scanpath", "heatmap")
2128 if all(
2129 ss.get(key) == value for key, value in _VIEW_PRESETS[name].items()
2130 )
2131 and not _link_departs_from(name)
2132 ),
2133 _CUSTOM_VIEW,
2134 )
2135 ss[_QUICK_VIEW_SELECTION_KEY] = selected
2136 if selected == _CUSTOM_VIEW:
2137 ss[_QUICK_VIEW_CUSTOM_STATE] = _capture_quick_view_state()
2138 else:
2139 ss[_QUICK_VIEW_APPLIED_STATE] = _capture_quick_view_state()
2141 if selected == _CUSTOM_VIEW:
2142 returned = _returned_to_design()
2143 if returned is not None:
2144 return returned
2145 ss[_QUICK_VIEW_CUSTOM_STATE] = _capture_quick_view_state()
2146 return _CUSTOM_VIEW
2148 applied = ss.get(_QUICK_VIEW_APPLIED_STATE)
2149 if not isinstance(applied, dict):
2150 ss[_QUICK_VIEW_APPLIED_STATE] = _capture_quick_view_state()
2151 return str(selected)
2152 if _design_drifted(applied, _capture_quick_view_state()):
2153 return _drift_to_custom(str(selected), applied)
2154 return str(selected)
2157def _active_quick_view() -> str | None:
2158 """Return the quick-view preset whose owned values match current state.
2160 Illustration is deliberately checked before Scanpath: its layer set is a
2161 superset of the Scanpath contract, so the old order mislabeled an active
2162 Illustration as Scanpath even while arc-and-snap geometry remained live.
2163 """
2164 return _sync_quick_view_state()
2167# VIZ-18: palette setting name → the session key it writes. A palette is applied
2168# by writing the *ordinary* colour keys, so nothing downstream has to know
2169# palettes exist — the per-element pickers, the deep link, Save & restore, the
2170# CLI and the API all keep carrying plain colours.
2171_PALETTE_STATE_KEYS = {
2172 "fixation_color": "global_fixation_color",
2173 "fixation_colorscale": "global_fixation_colorscale",
2174 "heatmap_colorscale": "global_heatmap_colorscale",
2175 "saccade_color": "global_saccade_color",
2176 "word_label_color": "global_text_color",
2177 "highlight_text_color": "global_highlight_text_color",
2178}
2181def palette_state(name: str) -> dict:
2182 """The ``session_state`` writes that applying palette ``name`` performs."""
2183 settings = palette_settings(name)
2184 state = {
2185 state_key: settings[setting]
2186 for setting, state_key in _PALETTE_STATE_KEYS.items()
2187 if setting in settings
2188 }
2189 for cls_name, color in settings.get("saccade_class_colors", {}).items():
2190 if cls_name in SACCADE_CLASS_EDITABLE:
2191 state[f"global_saccade_class_color_{cls_name}"] = color
2192 return state
2195def apply_palette(name: str) -> None:
2196 """Apply a VIZ-18 palette by writing its colours into ``session_state``.
2198 Runs as a widget ``on_change`` callback — i.e. before the next rerun
2199 instantiates the colour pickers — so writing their keys here is picked up
2200 cleanly, exactly like ``_apply_view_preset``. Deliberately does *not* touch
2201 the background colour: that's a canvas/Experimental-Setup choice the user
2202 makes for their output medium, not part of the mark palette.
2204 ``CUSTOM_PALETTE`` is a no-op: it names the *absence* of a palette, so
2205 re-selecting it must not overwrite the colours the user just set by hand.
2206 """
2207 if name == CUSTOM_PALETTE:
2208 return
2209 for key, value in palette_state(name).items():
2210 write_through(key, value)
2213#: #374 F9 — programmatic widget writes the browser may not have taken yet:
2214#: ``{key: [value, stale_echo]}``. See `write_through`.
2215_PENDING_WRITES_KEY = "_pending_widget_writes"
2216_WRITE_FRESH = "\x00fresh" # written this run: the browser has not answered yet
2217_WRITE_UNSEEN = "\x00unseen" # one run on: the next echo is the browser's
2220def _write_match_key(value):
2221 """Normalize a widget value for comparison: pickers hand back lowercase hex,
2222 sliders tuples where the stored value is a list."""
2223 if isinstance(value, tuple):
2224 return [_write_match_key(v) for v in value]
2225 if isinstance(value, list):
2226 return [_write_match_key(v) for v in value]
2227 return _palette_match_key(value)
2230def write_through(key: str, value) -> None:
2231 """Write ``value`` to a widget's key so that a closed popover cannot undo it.
2233 #374 F9. A widget inside an ``st.popover`` is mounted in the browser only
2234 while the popover is open. Once it has been open, the browser remembers the
2235 value it showed and sends that value back on every rerun; a programmatic
2236 write made while the popover is closed reaches no mounted widget, so it
2237 holds for one run and the next rerun puts the remembered value back (the
2238 palette that "stopped sticking" after Fixations ▾ had been opened).
2240 So the write is also recorded here, and `reassert_pending_writes` repeats
2241 it at the top of each run while the browser keeps echoing the old value.
2242 It lets go as soon as the browser sends anything else: the written value
2243 (the widget remounted and took it) or a new pick of the user's own.
2244 Call it from a callback, like any write to a widget key.
2245 """
2246 ss = st.session_state
2247 ss[key] = value
2248 pending = dict(ss.get(_PENDING_WRITES_KEY) or {})
2249 pending[key] = [deepcopy(value), _WRITE_FRESH]
2250 ss[_PENDING_WRITES_KEY] = pending
2253def reassert_pending_writes() -> None:
2254 """Re-apply `write_through` writes the browser has not taken yet.
2256 Runs at the top of every script run, before any widget is built. The first
2257 run after a write learns what the browser echoes for the key; while it
2258 keeps echoing that, the write is repeated; any other value ends it.
2259 """
2260 ss = st.session_state
2261 pending = ss.get(_PENDING_WRITES_KEY)
2262 if not pending:
2263 return
2264 kept = {}
2265 for key, (value, stale) in pending.items():
2266 if stale == _WRITE_FRESH:
2267 # The run the callback wrote in: the write itself is what reads
2268 # back, so there is nothing to learn yet.
2269 kept[key] = [value, _WRITE_UNSEEN]
2270 continue
2271 current = _write_match_key(ss.get(key))
2272 if current == _write_match_key(value):
2273 continue # the browser has it
2274 if stale == _WRITE_UNSEEN:
2275 stale = current
2276 elif current != stale:
2277 continue # the user picked something else
2278 ss[key] = deepcopy(value)
2279 kept[key] = [value, stale]
2280 if kept:
2281 ss[_PENDING_WRITES_KEY] = kept
2282 else:
2283 ss.pop(_PENDING_WRITES_KEY, None)
2286def _palette_match_key(value):
2287 """Normalize a colour for comparison — the pickers hand back lowercase hex."""
2288 return value.lower() if isinstance(value, str) and value.startswith("#") else value
2291def _active_palette() -> str | None:
2292 """Which palette the live colour keys match, or ``None`` once customized.
2294 The VIZ-12 rule applied to VIZ-18: a palette is one-way (it writes the
2295 ordinary colour keys and never reads them back), so without this the selector
2296 keeps reading "Colourblind-safe" after the user has hand-edited one of its
2297 colours — naming a property the figure no longer has.
2298 """
2299 ss = st.session_state
2300 for name in PALETTES:
2301 wanted = palette_state(name)
2302 if all(
2303 _palette_match_key(ss.get(key)) == _palette_match_key(value)
2304 for key, value in wanted.items()
2305 ):
2306 return name
2307 return None
2310def _on_palette_change() -> None:
2311 name = st.session_state.get("global_palette") or DEFAULT_PALETTE
2312 if name != CUSTOM_PALETTE:
2313 # What "Custom" is a departure *from*, for the caption below.
2314 st.session_state["_palette_picked"] = name
2315 apply_palette(name)
2318def _popover_selectbox(label: str, options: list, state_key: str, host=None, **kwargs):
2319 """A selectbox inside a popover whose seeded session value actually shows.
2321 A *keyed* selectbox first painted inside a (closed-until-clicked) popover
2322 renders its first option rather than the value seeded into session state, and
2323 commits that wrong value on the next interaction — the same first-open quirk
2324 the VIZ-8 class colour pickers hit. Passing an explicit ``index=`` and writing
2325 the pick back by hand sidesteps it, which is what lets a VIZ-18 palette (or a
2326 deep link, or a restored config) set a non-first colorscale and have the
2327 picker agree with the figure.
2329 Renders as a UX-51 ``label | field`` row like every other rail control.
2330 """
2331 current = st.session_state.get(state_key)
2332 index = options.index(current) if current in options else 0
2333 picked = _labeled(
2334 host if host is not None else st,
2335 "selectbox",
2336 label,
2337 options=options,
2338 index=index,
2339 **kwargs,
2340 )
2341 st.session_state[state_key] = picked
2342 return picked
2345# Help text for the (multi-capable) Trial ID mapping, shared by all tables.
2346_TRIAL_MAPPING_HELP = (
2347 "The column that identifies each trial. If none does alone, pick several "
2348 "(e.g. participant + text + repeated reading); their values are joined "
2349 "into one ID. Pick the same columns in every table so trials line up."
2350)
2352# Word-box geometry is one rectangle in two interchangeable encodings. The
2353# mapping UI shows a format picker plus four fields instead of all eight at once;
2354# both encodings normalize to canonical x/y/width/height in
2355# ``data.normalize_words``, so the returned schema still carries all eight keys.
2356BOX_FORMAT_EDGES = "Edges"
2357BOX_FORMAT_ORIGIN = "Origin + size"
2358_BOX_SUBFIELDS: dict[str, list[tuple]] = {
2359 BOX_FORMAT_EDGES: [
2360 ("left", "Box left"),
2361 ("right", "Box right"),
2362 ("top", "Box top"),
2363 ("bottom", "Box bottom"),
2364 ],
2365 BOX_FORMAT_ORIGIN: [
2366 ("x", "Box x (top-left)"),
2367 ("y", "Box y (top-left)"),
2368 ("width", "Box width"),
2369 ("height", "Box height"),
2370 ],
2371}
2372_ALL_BOX_KEYS = [key for fields in _BOX_SUBFIELDS.values() for key, _ in fields]
2374#: UX-91 — the word-box row: `Word box * + format radio | four coordinate
2375#: selects`. The head is wide enough for the title and both radio options
2376#: without wrapping (a wrapped radio would push the selects beside it out of
2377#: line with every other row on the page); the four selects split the rest
2378#: evenly, matching the width they had when they were a row of their own.
2379_BOX_ROW_W = (0.26, 0.185, 0.185, 0.185, 0.185)
2382def _default_box_format(proposed: dict[str, str | None]) -> str:
2383 """Which box encoding to show first, from what auto-detect found.
2385 Edges if all four edge columns were detected, else origin+size if those four
2386 were, else edges."""
2387 if all(proposed.get(k) for k in ("left", "right", "top", "bottom")):
2388 return BOX_FORMAT_EDGES
2389 if all(proposed.get(k) for k in ("x", "y", "width", "height")):
2390 return BOX_FORMAT_ORIGIN
2391 return BOX_FORMAT_EDGES
2394# UX-52: mapping fields that only a **multipart** dataset needs (one logical
2395# trial spread over several screens, DATA-21/DATA-24) plus the per-screen canvas
2396# size. All optional, all inert for an ordinary single-screen corpus, and
2397# together they were half the rows in the editor — so they fold into an
2398# "Advanced" group instead of padding the list everyone reads.
2399# UX-113: "block" joins it for the same reason — only a table with sub-screen
2400# blocks that each restart their own numbering (e.g. a comprehension
2401# question's answer blocks) ever needs it.
2402_ADVANCED_MAPPING_KEYS = frozenset({"screen_id", "block"})
2403#: AN-32 — the reading measures fold into a group of their own wherever every
2404#: field is listed at once (the ⚙️ Configure panel), so thirteen optional
2405#: fields never stretch the required ones apart.
2406_MEASURE_MAPPING_KEYS = frozenset(READING_MEASURE_KEYS)
2408#: Mapping keys that are **resolved but never rendered** (UX-53 round 3).
2409#:
2410#: `screen_fixation_id` and the two `canvas_*` fields are per-screen bookkeeping
2411#: that only a multipart export carries, and when it does carry them the column
2412#: names are the canonical ones auto-detection already finds. They were three
2413#: more rows on a page whose complaint was length, offering a choice nobody
2414#: makes. `_assemble_mapping` still puts them in the schema straight from the
2415#: proposal, so multipart datasets keep their per-screen canvas and nothing
2416#: downstream sees a narrower schema — what is gone is the widget, not the
2417#: field. Anything genuinely unmappable this way is a column-name problem, and
2418#: `data.py`'s candidate lists are where that gets fixed.
2419#: UX-55 r4 hid `screen_index` ("Screen name") behind this set; UX-88 removed
2420#: the field outright, from the specs themselves — it is not offered, not
2421#: resolved from a proposal, and not written into any schema. The *column* is
2422#: untouched and still load-bearing: `multipart.normalize_screen_identity`
2423#: derives it from first appearance, and the public corpora in `datasets.py`
2424#: stamp it straight onto the frames — neither goes through a mapping. What is
2425#: gone is the idea that anyone should have to think about it.
2426_HIDDEN_MAPPING_KEYS = frozenset(
2427 {"screen_fixation_id", "canvas_width", "canvas_height"}
2428)
2430WORD_FIELD_SPECS: list[dict] = [
2431 {
2432 "key": "participant",
2433 "label": "Participant ID",
2434 "required": False,
2435 "help": "Which participant produced this row. Omit for word boxes "
2436 "shared by all participants.",
2437 },
2438 {
2439 "key": "trial",
2440 "label": "Trial ID",
2441 "required": True,
2442 "multi": True,
2443 "help": _TRIAL_MAPPING_HELP,
2444 },
2445 {
2446 "key": "screen_id",
2447 "label": "Screen ID",
2448 "required": False,
2449 "help": "Which screen of a multi-screen trial this word is on. Leave "
2450 "empty for one-screen trials.",
2451 },
2452 {
2453 "key": "word_id",
2454 "label": "Word/IA ID",
2455 "required": True,
2456 "help": "Identifier of each word (interest area) — the key fixations "
2457 "attach to, and the order of words within a trial.",
2458 },
2459 {
2460 "key": "text",
2461 "label": "Word text/label",
2462 "required": False,
2463 "help": "The word's text; drawn on the stimulus and shown in tooltips.",
2464 },
2465 {
2466 "key": "text_id",
2467 "label": "Text ID",
2468 "required": False,
2469 "help": "Groups words by the text they belong to, for filtering and "
2470 "selection. Empty: the trial ID is used (a repeat shares the first's).",
2471 },
2472 {
2473 "key": "line",
2474 "label": "Line index",
2475 "required": False,
2476 "help": "Line number of the word on screen, kept as source metadata. "
2477 "The plot's line coloring and hover infer lines from the word boxes' Y "
2478 "instead, since many exports carry one constant here.",
2479 },
2480 # UX-113: only meaningful alongside "Aggregate character AOIs into word
2481 # boxes" — a table whose rows are grouped into sub-blocks that each
2482 # restart their own numbering (e.g. a comprehension question's stem /
2483 # target / distractor answer blocks). Without it, two blocks' word 0
2484 # would silently aggregate into one merged box.
2485 {
2486 "key": "block",
2487 "label": "AOI block",
2488 "required": False,
2489 "help": "Groups word boxes that each restart their own numbering "
2490 "within one screen (e.g. a question's stem/target/distractor "
2491 "blocks) — leave empty for ordinary one-block screens. Only used "
2492 "when aggregating character AOIs into word boxes.",
2493 },
2494 {
2495 "key": "canvas_width",
2496 "label": "Screen canvas width",
2497 "required": False,
2498 "help": "Recorded canvas width in pixels; must be constant within a screen.",
2499 },
2500 {
2501 "key": "canvas_height",
2502 "label": "Screen canvas height",
2503 "required": False,
2504 "help": "Recorded canvas height in pixels; must be constant within a screen.",
2505 },
2506 {
2507 "key": "box",
2508 "kind": "box",
2509 "label": "Word box",
2510 "required": True,
2511 "help": "Bounding box per word/AOI. Edges = left/right/top/bottom (EyeLink IA_*); Origin + size = x/y/width/height.",
2512 },
2513 # AN-32: the reading measures a dataset brings, one optional field each —
2514 # the Corpus Analysis page shows these and computes none. Short labels, as
2515 # they share two lines; the full name and the EyeLink column are the hover.
2516 *(
2517 {
2518 "key": key,
2519 "label": label,
2520 "help": f"{name}. Auto-detected from EyeLink's `{candidates[0]}`"
2521 " (or a column named like it). Leave empty if your report has none.",
2522 }
2523 for key, _column, label, name, _kind, candidates in READING_MEASURE_FIELDS
2524 ),
2525]
2527FIX_FIELD_SPECS: list[dict] = [
2528 {
2529 "key": "participant",
2530 "label": "Participant ID",
2531 "required": True,
2532 "help": "Which participant produced this fixation.",
2533 },
2534 {
2535 "key": "trial",
2536 "label": "Trial ID",
2537 "required": True,
2538 "multi": True,
2539 "help": _TRIAL_MAPPING_HELP,
2540 },
2541 {
2542 "key": "screen_id",
2543 "label": "Screen ID",
2544 "required": False,
2545 "help": "Which screen of a multi-screen trial this fixation is on. Map "
2546 "it in the Words table too.",
2547 },
2548 {
2549 "key": "x",
2550 "label": "X coordinate",
2551 "required": False,
2552 "help": "Fixation pixel X. Leave empty for AOI-only data and map "
2553 "Word/IA ID instead — those fixations are placed at word-box centers.",
2554 },
2555 {
2556 "key": "y",
2557 "label": "Y coordinate",
2558 "required": False,
2559 "help": "Fixation pixel Y. Leave empty for AOI-only data (map Word/IA ID instead).",
2560 },
2561 {
2562 "key": "duration",
2563 "label": "Duration (ms)",
2564 "required": True,
2565 "help": "Fixation length in milliseconds; sets marker size and the "
2566 "dwell-time heatmap.",
2567 },
2568 {
2569 "key": "timestamp",
2570 "label": "Timestamp (ms)",
2571 "required": False,
2572 "help": "When each fixation starts (ms), on one clock for the whole "
2573 "trial; orders fixations and times the replay. Defaults to row order.",
2574 },
2575 # UX-53 removed *Screen-local timestamp (ms)* from the mapping: it was a
2576 # second clock for the same fixations, and the parent-trial timestamp above
2577 # already orders every screen. `screen_timestamp_ms` survives as a
2578 # passthrough column for the corpora that ship one (datasets.py stamps it),
2579 # so nothing downstream loses it — it just stops being a question every
2580 # uploader has to answer.
2581 {
2582 "key": "fixation_id",
2583 "label": "Fixation ID",
2584 "required": False,
2585 "help": "Sequential fixation number within a trial. Defaults to row order.",
2586 },
2587 {
2588 "key": "screen_fixation_id",
2589 "label": "Screen-local fixation ID",
2590 "required": False,
2591 "help": "Optional fixation number that resets within each screen; the "
2592 "parent-global fixation ID is retained separately.",
2593 },
2594 {
2595 "key": "text_id",
2596 "label": "Text ID",
2597 "required": False,
2598 "help": "Groups fixations by the text/passage they belong to, for "
2599 "filtering and selection.",
2600 },
2601 {
2602 "key": "word_id",
2603 "label": "Word/IA ID",
2604 "required": False,
2605 "help": "Which word/AOI each fixation landed on. Authoritative when "
2606 "present (overrides geometric assignment), and supplies the location "
2607 "when X/Y are absent — for AOI-only data, leave X/Y empty and map this.",
2608 },
2609 {
2610 "key": "canvas_width",
2611 "label": "Screen canvas width",
2612 "required": False,
2613 "help": "Recorded canvas width in pixels; must be constant within a screen.",
2614 },
2615 {
2616 "key": "canvas_height",
2617 "label": "Screen canvas height",
2618 "required": False,
2619 "help": "Recorded canvas height in pixels; must be constant within a screen.",
2620 },
2621 # pass_index / saccade_type / saccade_amplitude / eye are no longer explicit
2622 # mapping fields — they're auto-detected and offered under "fields to keep"
2623 # (see data.FIX_OPTIONAL_FIELDS), so they don't clutter the wizard and aren't
2624 # hardcoded as schema. noise_flag was removed (it silently dropped fixations
2625 # with no UI to undo); saccade_amplitude is recomputed from X/Y by measures.
2626]
2628RAW_GAZE_FIELD_SPECS: list[dict] = [
2629 {
2630 "key": "participant",
2631 "label": "Participant ID",
2632 "required": True,
2633 "help": "Which participant produced this gaze sample.",
2634 },
2635 {
2636 "key": "trial",
2637 "label": "Trial ID",
2638 "required": True,
2639 "multi": True,
2640 "help": _TRIAL_MAPPING_HELP,
2641 },
2642 {
2643 "key": "screen_id",
2644 "label": "Screen ID",
2645 "required": False,
2646 "help": "Which screen of a multi-screen trial this sample is on.",
2647 },
2648 # UX-113: text_id/word_id round out the row to the same six identity
2649 # fields Fixations/AOI map (Trial · Screen · Participant · Text · Word/IA
2650 # · text-or-id), instead of raw gaze being the one table with no way to
2651 # say either. Both optional — a raw-gaze-only dataset with no such column
2652 # still works exactly as before (see `data.normalize_raw_gaze`).
2653 {
2654 "key": "text_id",
2655 "label": "Text ID",
2656 "required": False,
2657 "help": "Groups gaze samples by the text/passage they belong to. Left "
2658 "unmapped, the text id mirrors the trial id — raw gaze normally has "
2659 "no separate text/passage concept.",
2660 },
2661 {
2662 "key": "word_id",
2663 "label": "Word/IA ID",
2664 "required": False,
2665 "help": "Which word/AOI each gaze sample landed on, if the export "
2666 "already carries one — raw gaze is not assigned to words the way "
2667 "fixations are, so this is carried through as-is, not computed.",
2668 },
2669 {
2670 "key": "text",
2671 "label": "Word text/label",
2672 "required": False,
2673 "help": "Optional word/label associated with the sample.",
2674 },
2675 {
2676 "key": "x",
2677 "label": "X coordinate",
2678 "required": True,
2679 "help": "Gaze pixel X at this timepoint.",
2680 },
2681 {
2682 "key": "y",
2683 "label": "Y coordinate",
2684 "required": True,
2685 "help": "Gaze pixel Y at this timepoint.",
2686 },
2687 {
2688 "key": "timestamp",
2689 "label": "Timestamp (ms)",
2690 "required": False,
2691 "help": "Sample time (ms); orders the continuous gaze path. Left "
2692 "unmapped, the samples keep their row order and are numbered "
2693 "1, 2, … per trial — with no time, since no sampling rate is known.",
2694 },
2695]
2698def _assemble_mapping(
2699 df: pd.DataFrame,
2700 field_specs: list[dict],
2701 proposed: dict[str, str | None],
2702 only_keys: list[str] | None,
2703 *,
2704 pick: Callable[..., str | None],
2705 pick_box_format: Callable[[dict], str],
2706 pick_multi: Callable[..., list[str]],
2707) -> dict[str, str | None]:
2708 """Build the schema dict, deferring every *choice* to the caller.
2710 The **shape** of a mapping — which keys exist, that a ``kind: "box"`` field
2711 expands into all eight box keys with the inactive four set to ``None``, that
2712 a ``multi`` field collapses to a plain string when exactly one column is
2713 picked — is defined once, here; :func:`column_mapping_ui` supplies the
2714 choices by rendering widgets.
2715 """
2716 mapping: dict[str, str | None] = {}
2717 for spec in field_specs:
2718 key = spec["key"]
2719 # When ``only_keys`` is given, handle just that subset (the wizard
2720 # renders fields in grouped, ordered steps).
2721 if only_keys is not None and key not in only_keys:
2722 continue
2723 default = proposed.get(key)
2724 # Resolved from auto-detection, never offered as a row (UX-53).
2725 if key in _HIDDEN_MAPPING_KEYS:
2726 mapping[key] = default
2727 continue
2728 label = spec["label"] + (" *" if spec.get("required") else "")
2729 if spec.get("kind") == "box":
2730 fmt = pick_box_format(spec)
2731 # Always emit all eight box keys; only the active format's four
2732 # get a column, the rest stay None.
2733 mapping.update({box_key: None for box_key in _ALL_BOX_KEYS})
2734 for sub_key, sub_label in _BOX_SUBFIELDS[fmt]:
2735 # UX-91: the four coordinates *are* the required Word box, so
2736 # each carries the `*` its parent does. The star was on the
2737 # group heading alone, which read as one optional-looking row
2738 # of four beneath a required title.
2739 sub_star = " *" if spec.get("required") else ""
2740 mapping[sub_key] = pick(sub_key, sub_label + sub_star, None)
2741 continue
2742 if spec.get("multi"):
2743 chosen_cols = pick_multi(spec, default, label)
2744 if not chosen_cols:
2745 mapping[key] = None
2746 elif len(chosen_cols) == 1:
2747 mapping[key] = chosen_cols[0]
2748 else:
2749 mapping[key] = list(chosen_cols)
2750 continue
2751 mapping[key] = pick(key, label, spec.get("help"))
2752 return mapping
2755#: Set once the user has pressed **✅ Add dataset** on a wizard that still has a
2756#: required field unmapped. Until then a blank required field is simply *not
2757#: filled in yet* — colouring it red on arrival would paint a fresh upload with
2758#: errors before the user has done anything wrong (UX-53).
2759ADD_ATTEMPTED_KEY = "_wizard_add_attempted"
2761#: UX-53 round 4 — the field's state is a **tint on the select itself**, not a
2762#: dot and a sentence beside it. `● ✨ auto-detected \`CURRENT_FIX_INDEX\`` was
2763#: longer than the control it described, on every row. Low-alpha rgba so it
2764#: tints whatever the active theme paints underneath rather than assuming a
2765#: light background.
2766#: UX-67 dropped the green: a mapping the user chose is simply *filled*, and
2767#: tinting it made "reviewed" compete for attention with the two states that
2768#: actually need acting on. `user` keeps its own name in `_field_state` — it is
2769#: still what moves a field **out** of amber once someone picks — it just has no
2770#: colour of its own now.
2771_FIELD_TINT = {
2772 "auto": "rgba(234, 179, 8, 0.16)",
2773 "missing": "rgba(239, 68, 68, 0.20)",
2774}
2777#: Fields the user has actually interacted with this session. Deliberately ONE
2778#: key, and deliberately *outside* the `col_map_` namespace:
2779#: `tabs._collect_column_mapping` sweeps every `col_map_*` key into the saved
2780#: config, so a per-field marker named that way would travel in users' configs
2781#: as if it were part of the mapping.
2782TOUCHED_FIELDS_KEY = "_mapping_touched_fields"
2785def _mark_field_touched(state_key: str) -> None:
2786 """Record that the user moved this field (the select's ``on_change``).
2788 What separates *auto-detected and left alone* from *the user chose this* —
2789 which are the same value, and different claims. Picking the detected column
2790 by hand is an approval, and UX-53 r11 wants it to read as one.
2791 """
2792 st.session_state.setdefault(TOUCHED_FIELDS_KEY, set()).add(state_key)
2795def _field_state(
2796 *,
2797 chosen,
2798 default,
2799 is_required: bool,
2800 attempted: bool,
2801 touched: bool,
2802 detected_label: str,
2803) -> tuple[str, str]:
2804 """``(state, hover)`` for one mapping row.
2806 ``state`` keys `_FIELD_TINT`: **user** they chose it, **auto** detection
2807 found it and nobody has touched it, **missing** it is required and still
2808 empty *after* an add was attempted (before that, empty is simply not filled
2809 in yet). ``""`` is the neutral, untinted row.
2811 Two rules from UX-53 r11, both about who decided:
2813 * **Choosing a value is an approval**, even when it is the value detection
2814 already proposed — so a touched field goes green rather than staying
2815 amber. Amber means "nobody has looked at this yet", which stops being true
2816 the moment they pick.
2817 * **Clearing a detected field goes neutral**, not amber: the ✕ is a decision
2818 that this column is *not* the one, and leaving it amber would keep
2819 flagging a suggestion the user has just rejected. It stays red only when
2820 the field is required and an add has been attempted, because then it is
2821 genuinely blocking.
2823 ``hover`` is the ✨ icon's tooltip, and the only place the detected column
2824 name is written: the name is what made the old inline note run past the
2825 width of the control it annotated, and it is looked at once.
2826 """
2827 unmapped = chosen in (None, NONE_OPTION)
2828 if unmapped:
2829 if is_required and attempted:
2830 return "missing", "required — pick a column"
2831 if default:
2832 return "", f"{detected_label} `{default}` · not used"
2833 return "", ""
2834 if touched:
2835 if default and chosen != default:
2836 return "user", f"{detected_label} `{default}` · overridden"
2837 return "user", f"{detected_label} `{default}` · confirmed" if default else ""
2838 if default and chosen == default:
2839 return "auto", f"{detected_label} `{default}`"
2840 if default:
2841 return "user", f"{detected_label} `{default}` · overridden"
2842 return "user", ""
2845#: Marker key recording which table a stored mapping was made for.
2846#:
2847#: The prefix, not a suffix, and deliberately outside ``col_map_*``:
2848#: `tabs._collect_column_mapping` sweeps **every** `col_map_*` key that does not
2849#: end in `_upload` into the saved config, so a marker named
2850#: ``col_map_fix__mapped_columns`` would travel in one — come back from JSON as a
2851#: *list* rather than the tuple it was written as, never compare equal to the
2852#: signature again, and so clear the mapping on the first run after every
2853#: restore. It describes this session's widget state, not the mapping, and has
2854#: no business in a file that opens on another machine.
2855def _mapped_columns_key(state_key_prefix: str) -> str:
2856 """Session key holding the ``(dataset, columns)`` ``state_key_prefix`` maps."""
2857 return f"_mapped_columns_{state_key_prefix}"
2860def claim_mapping(state_key_prefix: str, dataset: object) -> None:
2861 """Record that ``state_key_prefix``'s keys now describe ``dataset`` (BUG-32).
2863 For a writer that seeds mapping keys for a table nobody has read yet — the
2864 wizard starting a fresh dataset, a setup restored into it. The columns are
2865 left unknown, so that dataset's first sighting counts as the *same* dataset
2866 (DATA-24's stale-only rule keeps every pick its table can honour), while any
2867 other dataset that meets the keys first — the demo, after ✕ Cancel — drops
2868 them. Without it the marker would still name whatever those keys used to
2869 describe, and the new table would clear exactly what was just restored.
2870 """
2871 st.session_state[_mapped_columns_key(state_key_prefix)] = (dataset, None)
2874def _mapping_state_keys(state_key_prefix: str, field_specs: list[dict]) -> list[str]:
2875 """Every session key the mapping widgets for ``field_specs`` write."""
2876 keys: list[str] = []
2877 for spec in field_specs:
2878 if spec.get("kind") == "box":
2879 keys.append(f"{state_key_prefix}_box_format")
2880 keys.extend(f"{state_key_prefix}_{box_key}" for box_key in _ALL_BOX_KEYS)
2881 continue
2882 keys.append(f"{state_key_prefix}_{spec['key']}")
2883 return keys
2886def forget_mapping_for_other_table(
2887 df: pd.DataFrame,
2888 state_key_prefix: str,
2889 field_specs: list[dict],
2890 *,
2891 dataset: object = None,
2892) -> None:
2893 """Drop a stored mapping that was made for a *different* table (DATA-24).
2895 A mapping widget owns its key once it has rendered, and a key that exists
2896 beats the ``index=`` computed from auto-detection — that is what makes a
2897 user's override stick. But the app switches data sources in place, so the
2898 same keys outlive the table they describe: opening the bundled demo (no
2899 screen columns → *Screen order* = ``(none)``) and then switching to
2900 MultiplEYE left *Screen order* on ``(none)`` while the caption beneath it
2901 still read "✨ auto-detected `screen_index`", because the proposal had indeed
2902 found the column and only the widget was stale. The multipart trial then
2903 ordered its screens by name instead of by reading order, and the user had to
2904 set a field the app had already detected.
2906 The discriminator is the **column universe**, not the data: a new file with
2907 the same headers is the case where keeping the mapping is the whole point,
2908 while different headers mean this mapping was never about this table. The
2909 first sighting of a prefix only *records* the signature — it must not clear,
2910 or it would wipe the ``col_map_*`` keys a deep link or a restored config
2911 seeds before any widget renders (``url_state._seed_column_mapping``).
2913 Even then it clears only what has gone stale — a pick naming a column the new
2914 table still has survives. That is not tidiness: the wizard *grows* its own
2915 frame mid-flow (``_wizard_filename_derive`` appends ``file_part_N``), so a
2916 signature change is routine there and dropping the whole mapping would reset
2917 steps the user had already filled in. What gets cleared is a field left at
2918 ``(none)`` or pointing at a column that is gone — in both cases there is no
2919 user choice to lose, and auto-detection deserves another go.
2921 **BUG-32: the column universe alone is not the table.** Two datasets that
2922 share an AOI file have identical headers by construction, so under a
2923 columns-only signature the second silently inherited every pick made for
2924 the first — and nothing was cleared or said, because every pick still named
2925 a real column. ``dataset`` is the caller's identity for the data the
2926 mapping describes (the source key on the 🗂️ Data page, the add-dataset
2927 wizard's own), and a change of dataset drops **every** pick, however valid
2928 it still looks: a choice made for one dataset is not a choice for another.
2929 The same-dataset rules above are unchanged, so the wizard growing its own
2930 frame keeps what was filled in. A caller seeding keys *for* a dataset whose
2931 table has not been read yet stamps it first with :func:`claim_mapping`.
2932 """
2933 columns_seen = tuple(str(column) for column in df.columns)
2934 signature = (dataset, columns_seen)
2935 marker = _mapped_columns_key(state_key_prefix)
2936 previous = st.session_state.get(marker)
2937 st.session_state[marker] = signature
2938 if not (isinstance(previous, tuple) and len(previous) == 2):
2939 return # first sighting: record only
2940 if previous == signature:
2941 return
2942 keys = _mapping_state_keys(state_key_prefix, field_specs)
2943 if previous[0] != dataset:
2944 for key in keys:
2945 st.session_state.pop(key, None)
2946 st.session_state.get(TOUCHED_FIELDS_KEY, set()).discard(key)
2947 return
2948 columns = set(columns_seen)
2949 for key in keys:
2950 stored = st.session_state.get(key)
2951 if isinstance(stored, str) and stored != NONE_OPTION and stored in columns:
2952 continue
2953 # The multi-capable Trial ID. Keep a composite whose every component
2954 # survived; a partial one is not a mapping the user can have meant.
2955 if (
2956 isinstance(stored, (list, tuple))
2957 and stored
2958 and all(column in columns for column in stored)
2959 ):
2960 continue
2961 # The box *format* is a property of the table (which four columns it
2962 # has), not a preference, so it is re-derived from the new proposal.
2963 st.session_state.pop(key, None)
2964 # The approval goes with the answer it approved (UX-53 r11): a field
2965 # re-proposed for a different table has not been confirmed by anyone.
2966 st.session_state.get(TOUCHED_FIELDS_KEY, set()).discard(key)
2969def column_mapping_ui(
2970 df: pd.DataFrame,
2971 table_label: str,
2972 state_key_prefix: str,
2973 field_specs: list[dict],
2974 proposed: dict[str, str | None],
2975 expand_on_problem: bool = True,
2976 problems: list[str] | None = None,
2977 container=None,
2978 use_expander: bool = True,
2979 only_keys: list[str] | None = None,
2980 header: bool = True,
2981 detected_label: str = "auto-detected",
2982 columns_per_row: int = 1,
2983 stack_labels: bool | None = None,
2984 dataset: object = None,
2985 option_labels: dict | None = None,
2986) -> dict[str, str | None]:
2987 """Render a column-mapping expander letting users override the inferred mapping.
2989 Renders into ``container`` — the 🗂️ Data page's mapping slot, or the setup
2990 wizard's own step. With no container it renders inline, wherever the caller
2991 already is.
2993 Returns a mapping {field_key: column_name_or_None}. Fields marked
2994 ``multi: True`` (Trial ID) render as a multiselect: picking several columns
2995 yields a list, meaning "build this ID on the fly by joining the columns'
2996 values" (see ``data.trial_id_series``); a single pick stays a plain string.
2997 A field marked ``kind: "box"`` (the word box) renders a coordinate-format
2998 radio plus the four sub-fields for that format, and expands into all eight
2999 box keys (the four inactive ones set to None) so the returned schema keeps
3000 its fixed shape.
3002 ``columns_per_row`` (UX-53 r7) packs several fields onto one line — four
3003 fixation fields fit where one used to sit, and a mapping that fits on a
3004 screen is one you can check against itself. The default of 1 keeps the
3005 🗂️ Data page's editor exactly as it was.
3007 ``stack_labels`` picks the row shape: label *above* the control (the wizard)
3008 or ``label | field`` beside it (the Data page). It defaults to
3009 ``columns_per_row > 1``, which is right for both of those — but a caller
3010 that renders **one** field into a cell of a row it built itself still wants
3011 the stacked shape, and inferring it from the field count got that wrong
3012 (UX-53 r17: the screen fields landed on the identity rows with their titles
3013 beside them while every neighbour had its title above).
3015 ``dataset`` names the data this mapping is for, so picks made for one
3016 dataset never carry into another with the same headers (BUG-32) — see
3017 :func:`forget_mapping_for_other_table`.
3018 """
3019 forget_mapping_for_other_table(df, state_key_prefix, field_specs, dataset=dataset)
3020 # UX-108 — PERF-6 narrows `df` to only the columns a plan decided to
3021 # actually *parse* (auto-detect + the optional-field registry + whatever a
3022 # `col_map_*` key already names, session-wide); a column nobody has named
3023 # yet is never in it, so offering `df.columns` here hid the rest of the
3024 # file. Worse, "whatever a `col_map_*` key already names" is swept from
3025 # session state with no dataset scoping — re-uploading the same file for a
3026 # *second* dataset inherits the first one's picks as the plan's floor, so
3027 # the read narrows to exactly what was kept last time and the picker looks
3028 # like it is *remembering* which fields to hide. `app._read_uploaded_frame`
3029 # stashes the file's real header the moment it reads it, independent of
3030 # what the plan actually parsed; every field in the file belongs in this
3031 # list regardless. Empty outside an upload context (the 🗂️ Data page's
3032 # remap editor has no raw file to ask, and offers only what survived the
3033 # original import by design) — fall back to the parsed frame there.
3034 full_header = st.session_state.get(f"{state_key_prefix}_header")
3035 options = list(full_header) if full_header else user_columns(df)
3037 # DATA-66: ✏️ Edit dataset offers the stored *canonical* columns; the caller
3038 # passes the dataset's own names for them. The values stay canonical.
3039 def _option_label(column) -> str:
3040 return (option_labels or {}).get(column, column)
3042 expanded = bool(expand_on_problem and problems)
3043 # UX-53 field colour: which rows *must* be filled, and whether the user has
3044 # already tried to add the dataset (before that, empty is not an error).
3045 required_keys = {spec["key"] for spec in field_specs if spec.get("required")}
3046 # UX-90: a `kind="box"` spec is required under its own key ("box"), but what
3047 # the user actually fills are its four coordinate sub-fields — which are not
3048 # in `field_specs` at all, so `_field_state` saw them as optional and they
3049 # stayed neutral after a failed add. Only the active format's four ever
3050 # render, so naming all eight here is safe and needs no format resolution.
3051 if any(spec.get("kind") == "box" and spec.get("required") for spec in field_specs):
3052 required_keys.update(_ALL_BOX_KEYS)
3053 add_attempted = bool(st.session_state.get(ADD_ATTEMPTED_KEY))
3054 stacked = columns_per_row > 1 if stack_labels is None else stack_labels
3055 #: state -> the keyed cells in that state, filled as rows render and emitted
3056 #: as ONE <style> block at the end. Per-row style tags would be one extra
3057 #: element per field on a page whose whole problem is length.
3058 tint_cells: dict[str, list[str]] = {}
3059 # UX-52 round 2 — "the column mapping can be overwhelming". Two changes:
3060 # every row is `label | field` (UX-51's shape, which the user asked for here
3061 # too), and the multipart/canvas fields fold into an **Advanced** group.
3062 # They are all optional, all meaningless for an ordinary single-screen
3063 # dataset, and they were half the rows. The group is skipped when the caller
3064 # asks for a subset (`only_keys`) — that is the wizard, which already groups
3065 # these fields into its own ordered steps (DATA-22).
3066 group_advanced = only_keys is None
3067 hosts: dict[str, object] = {}
3069 def _host_for(field_key: str):
3070 """The container a field's row renders into (main, or Advanced)."""
3071 if group_advanced and field_key in _ADVANCED_MAPPING_KEYS:
3072 return hosts.get("advanced") or hosts["main"]
3073 if group_advanced and field_key in _MEASURE_MAPPING_KEYS:
3074 return hosts.get("measures") or hosts["main"]
3075 return hosts["main"]
3077 #: Grid cursor for `columns_per_row > 1`: the current row's columns and how
3078 #: many of them are used. Reset whenever the host changes, so the Advanced
3079 #: group never continues a row started by the main one.
3080 grid: dict = {"cols": [], "used": 0, "host": None}
3082 #: The word box's own row (UX-57). Reserved by `_render_box_format` and
3083 #: drained by the four sub-field picks that follow it — a separate cursor
3084 #: from `grid`, because the box is *one* top-level spec that expands into
3085 #: four, so it cannot borrow the group's `columns_per_row` without pulling
3086 #: the format radio into a cell meant for a select.
3087 box_grid: dict = {"cells": [], "used": 0}
3089 def _grid_cell(host):
3090 """The next free cell in a `columns_per_row`-wide grid."""
3091 if grid["host"] is not host or grid["used"] >= columns_per_row:
3092 grid["cols"] = host.columns(columns_per_row, gap=_LABEL_GAP)
3093 grid["used"] = 0
3094 grid["host"] = host
3095 cell = grid["cols"][grid["used"]]
3096 grid["used"] += 1
3097 return cell
3099 def _row(field_key: str, field_label: str, help_text):
3100 """Where one field's control and its ✨ flag render.
3102 Two shapes. **One per row** (the default, and what the 🗂️ Data page's
3103 editor uses) is UX-52's `label | field | note` triple: the flag rides
3104 beside the control instead of under it, so a row is one line tall.
3106 **`columns_per_row > 1`** (UX-53 r7) stacks label-over-field inside a
3107 grid cell instead, because four `label | field` pairs side by side would
3108 leave nothing but a sliver for each select. The label row reserves its
3109 own flag column *before* the select renders — the flag depends on the
3110 value the select returns, and a Streamlit container can be filled after
3111 later elements are written, which is what makes the order work.
3112 """
3113 host = _host_for(field_key)
3114 if field_key in _ALL_BOX_KEYS and box_grid["used"] < len(box_grid["cells"]):
3115 # A box sub-field takes the next cell of the row the box reserved,
3116 # so the four sit side by side under one heading (UX-57).
3117 cell = box_grid["cells"][box_grid["used"]]
3118 box_grid["used"] += 1
3119 head = cell.container()
3120 label_col, flag_col = head.columns(
3121 _GRID_LABEL_W, gap=None, vertical_alignment="center"
3122 )
3123 _row_label(label_col, field_label, help_text)
3124 return cell, flag_col
3125 if stacked:
3126 # UX-53 r14: label over field. Each cell writes its title first and
3127 # its select second, so across the row the titles line up on one
3128 # line and the controls on the next — and the select gets the cell's
3129 # full width instead of splitting it with the label, which is what
3130 # keeps a long column name legible.
3131 #
3132 # `host` itself is the cell when the caller supplied one (a single
3133 # field dropped into a row it laid out); only a multi-field group
3134 # cuts its own grid.
3135 cell = _grid_cell(host) if columns_per_row > 1 else host.container()
3136 head = cell.container()
3137 label_col, flag_col = head.columns(
3138 _GRID_LABEL_W, gap=None, vertical_alignment="center"
3139 )
3140 _row_label(label_col, field_label, help_text)
3141 return cell, flag_col
3142 label_col, field_col, note_col = host.columns(
3143 _MAPPING_ROW_W, gap=_LABEL_GAP, vertical_alignment="center"
3144 )
3145 _row_label(label_col, field_label, help_text)
3146 return field_col, note_col
3148 def _selectbox(field_key: str, field_label: str, help_text=None) -> str | None:
3149 default = proposed.get(field_key)
3150 field_col, note_col = _row(field_key, field_label, help_text)
3151 # The select goes in its own keyed container so the state tint has
3152 # something to attach to: Streamlit stamps `.st-key-<key>` on it, and the
3153 # one <style> block emitted at the end of this mapping lists the cells
3154 # per state (see `tint_cells`).
3155 cell_key = f"{state_key_prefix}_{field_key}_cell"
3156 field_col = field_col.container(key=cell_key)
3157 state_key = f"{state_key_prefix}_{field_key}"
3158 # UX-53 r10: the value lives in the KEY and `index` is always None, which
3159 # is precisely what turns Streamlit's own clear (✕) on — its selectbox
3160 # sets `clearable=(index is None)`. So the empty state is a real `None`
3161 # rather than the old `"(none)"` sentinel option, and the ✕ sits *inside*
3162 # the control instead of beside it.
3163 #
3164 # Absent key -> seed from auto-detection. Present but holding something
3165 # this table cannot offer -> blank it: that is a legacy `"(none)"` from a
3166 # saved config, or a column a new upload does not have, and Streamlit
3167 # raises on a stored value outside `options`. Present and already None ->
3168 # left alone, because that is the user having cleared it on purpose.
3169 if state_key in st.session_state:
3170 stored = st.session_state[state_key]
3171 if stored is not None and stored not in options:
3172 st.session_state[state_key] = None
3173 else:
3174 st.session_state[state_key] = default if default in options else None
3175 chosen = field_col.selectbox(
3176 field_label,
3177 options=options,
3178 format_func=_option_label,
3179 index=None,
3180 placeholder=_UNMAPPED_PLACEHOLDER,
3181 key=state_key,
3182 help=help_text,
3183 label_visibility="collapsed",
3184 # DATA-26: the editor lives on the Data page, which executes only
3185 # while it is the active view — but the mapping drives `prepare_data`
3186 # on every view. Without this, Streamlit drops the key at the end of
3187 # any run in which the widget did not render and the mapping reverts
3188 # to auto-detection the moment the user clicks over to Scanpath.
3189 persist_state="session",
3190 # Marks the field as *decided by a person*, which is what separates
3191 # green from amber even when the value is identical (UX-53 r11).
3192 on_change=_mark_field_touched,
3193 args=(state_key,),
3194 )
3195 # Surface what auto-detection found for this field (ENG-9), flag when the
3196 # user has overridden it, and carry UX-53's colour so the row's state is
3197 # readable without parsing the sentence. DATA-24: `(none)` gets its own
3198 # wording — it used to fall into the plain branch, so a field detection
3199 # had found but the widget was not using read exactly like one it was.
3200 state, hover = _field_state(
3201 chosen=chosen,
3202 default=default if default in df.columns else None,
3203 is_required=field_key in required_keys,
3204 attempted=add_attempted,
3205 touched=state_key in st.session_state.get(TOUCHED_FIELDS_KEY, ()),
3206 detected_label=detected_label,
3207 )
3208 if option_labels and default and hover:
3209 # DATA-66: "currently mapped `duration_ms`" names the user's column.
3210 hover = hover.replace(f"`{default}`", f"`{_option_label(default)}`")
3211 if state:
3212 tint_cells.setdefault(state, []).append(cell_key)
3213 # UX-92 — the ✨ is a **button** while the row is amber, and pressing it
3214 # is the approval the select cannot report.
3215 #
3216 # Re-picking the value a select already holds fires no `on_change`:
3217 # Streamlit dedupes it in the frontend and does not even rerun (verified
3218 # in a browser, not inferred). Since UX-53 r10 the value lives in the
3219 # widget key with `index=None` — which is what makes the ✕ clear work —
3220 # so the detected column *is* the widget's value, and confirming it by
3221 # hand is invisible to Python by construction. A one-click confirm in
3222 # the space the flag already occupies is the only honest way to say "I
3223 # chose this" for that case.
3224 _render_field_flag(
3225 note_col,
3226 state=state,
3227 hover=hover,
3228 preview=value_preview_tip(df, field_key, chosen),
3229 confirm_label=f"Confirm the detected {field_label} column",
3230 confirm_help=f"{hover} — click to confirm this column and clear the mark.",
3231 cell_key=cell_key,
3232 state_key=state_key,
3233 )
3234 # `NONE_OPTION` is still tolerated on the way out: a config restored
3235 # before this run could have seeded it.
3236 return None if chosen in (None, NONE_OPTION) else chosen
3238 host = container if container is not None else st.container()
3239 # Render inside an expander by default; ``use_expander=False`` renders inline
3240 # (the collapsed wizard panel already lives in an expander, and the ⚙️ Configure
3241 # menu popover nests no expander either — Streamlit forbids both).
3242 section = (
3243 host.expander(f"Column mapping — {table_label}", expanded=expanded)
3244 if use_expander
3245 else host.container()
3246 )
3247 with section:
3248 if not use_expander and header:
3249 st.markdown(f"**Column mapping — {table_label}**")
3250 if header:
3251 st.caption(
3252 "Detected from your file's column names. Change any row that is wrong."
3253 )
3254 if problems:
3255 st.warning(
3256 "Fix these before the app can use this table: " + "; ".join(problems)
3257 )
3258 # Both hosts are reserved up front, so the Advanced group sits *after*
3259 # every ordinary row no matter where its fields fall in the spec order
3260 # (Streamlit lays containers out in creation order, and
3261 # `_assemble_mapping` interleaves them).
3262 hosts["main"] = st.container()
3263 measures_slot = st.container()
3264 advanced_slot = st.container()
3265 if group_advanced and any(
3266 spec["key"] in _MEASURE_MAPPING_KEYS for spec in field_specs
3267 ):
3268 hosts["measures"] = measures_slot.expander(
3269 f"{ICONS['settings']} Reading measures",
3270 expanded=any(proposed.get(key) for key in _MEASURE_MAPPING_KEYS),
3271 )
3272 hosts["measures"].caption(
3273 "The per-AOI measures your report already has (FFD, TFD, …). "
3274 "The Corpus Analysis page shows these; it computes none."
3275 )
3276 if group_advanced and any(
3277 spec["key"] in _ADVANCED_MAPPING_KEYS for spec in field_specs
3278 ):
3279 # Open when the dataset actually uses one of them, so a multipart
3280 # corpus does not hide its screen mapping behind a fold.
3281 def _mapped(key: str) -> bool:
3282 # `NONE_OPTION` is the literal string the selectbox holds for
3283 # "not mapped" — truthy, so a bare `or` kept the group open
3284 # forever once the widgets had rendered once.
3285 stored = st.session_state.get(f"{state_key_prefix}_{key}")
3286 if stored in (None, NONE_OPTION, ""):
3287 stored = None
3288 return bool(proposed.get(key) or stored)
3290 in_use = any(_mapped(key) for key in _ADVANCED_MAPPING_KEYS)
3291 hosts["advanced"] = advanced_slot.expander(
3292 f"{ICONS['settings']} Screens & AOI blocks — advanced",
3293 expanded=bool(in_use),
3294 )
3295 hosts["advanced"].caption(
3296 "Only for trials that span several screens, or word boxes "
3297 "numbered in blocks. Leave empty otherwise."
3298 )
3300 def _render_box_format(spec: dict) -> str:
3301 """The box's heading, its format radio, and the row its four
3302 sub-fields will render into (UX-57).
3304 The description moves onto the heading's hover, like every other
3305 explanation on this page (UX-53), and the row is reserved *here*
3306 because `_assemble_mapping` calls this immediately before picking
3307 the four sub-fields — so by the time they ask `_row` for a cell,
3308 there is one waiting.
3309 """
3310 fmt_key = f"{state_key_prefix}_box_format"
3311 if fmt_key not in st.session_state:
3312 # Seed via session state (no `index=`) so it survives reruns
3313 # and never fights a default arg — same pattern as the
3314 # multiselect below.
3315 st.session_state[fmt_key] = _default_box_format(proposed)
3316 star = " *" if spec.get("required") else ""
3317 box_host = hosts["main"]
3318 title = html.escape(_plain(spec["label"]) + star)
3319 # UX-91: title, format radio and the four coordinate selects on
3320 # **one** line. They used to stack — heading, radio, then a row of
3321 # four — which cost three lines for one field on a page whose whole
3322 # complaint is length, and left the radio looking like a heading for
3323 # the row beneath rather than the switch that chooses what it holds.
3324 #
3325 # The row can be sized before the radio returns because every format
3326 # in `_BOX_SUBFIELDS` has exactly four sub-fields; only their
3327 # *names* differ. `_BOX_ROW_W` gives the head enough room for
3328 # "Word box *" plus both radio options on one line.
3329 head = box_host
3330 if stacked:
3331 cells = box_host.columns(_BOX_ROW_W, gap=_LABEL_GAP)
3332 head = cells[0]
3333 box_grid["cells"] = list(cells[1:])
3334 box_grid["used"] = 0
3335 if spec.get("help"):
3336 tip = tooltip(spec["label"], spec["help"])
3337 head.markdown(
3338 f'<div class="sps-box-title"><span class="sps-fhelp" '
3339 f'data-tip="{tip}" aria-label="{tip}">{title}</span></div>',
3340 unsafe_allow_html=True,
3341 )
3342 else:
3343 head.markdown(
3344 f'<div class="sps-box-title">{title}</div>',
3345 unsafe_allow_html=True,
3346 )
3347 chosen = head.radio(
3348 "Coordinate format",
3349 options=list(_BOX_SUBFIELDS),
3350 key=fmt_key,
3351 horizontal=True,
3352 label_visibility="collapsed",
3353 persist_state="session",
3354 )
3355 return chosen
3357 def _render_multi(spec: dict, default, label: str) -> list[str]:
3358 state_key = f"{state_key_prefix}_{spec['key']}"
3359 # A source may *declare* a composite id (PoTeC's reader + text).
3360 parts = default if isinstance(default, (list, tuple)) else [default]
3361 proposed_default = [c for c in parts if c is not None and c in df.columns]
3362 stored = st.session_state.get(state_key)
3363 if stored is None:
3364 # Seed via session state instead of `default=` so the
3365 # stale-column reset below never fights a default arg.
3366 st.session_state[state_key] = proposed_default
3367 else:
3368 # A new upload changes the column universe — silently
3369 # keeping stale picks would leave the field empty (the
3370 # selectboxes self-heal via their index fallback; a
3371 # multiselect doesn't). Drop unknown columns and fall
3372 # back to the auto-proposal when nothing survives.
3373 valid = [c for c in stored if c in df.columns]
3374 if len(valid) != len(stored):
3375 st.session_state[state_key] = valid or proposed_default
3376 field_col, note_col = _row(spec["key"], label, spec.get("help"))
3377 # UX-90: a keyed cell, like `_selectbox`'s, so `_emit_field_tints`
3378 # has something to colour when this required field is left empty.
3379 cell_key = f"{state_key_prefix}_{spec['key']}_cell"
3380 field_col = field_col.container(key=cell_key)
3381 chosen_cols = field_col.multiselect(
3382 label,
3383 # UX-108 — the same widened list `_selectbox` uses (`options`,
3384 # closed over from the outer scope), not `df.columns` again:
3385 # Trial ID is the one field every dataset composes, and it is a
3386 # `multi: True` field precisely so several raw columns can be
3387 # joined into one id — the composable columns are exactly the
3388 # ones a narrowed parse is most likely to have left out.
3389 options=options,
3390 format_func=_option_label,
3391 key=state_key,
3392 help=spec.get("help"),
3393 label_visibility="collapsed",
3394 select_all=False, # an id is a few columns, never all (#374)
3395 persist_state="session",
3396 on_change=_mark_field_touched,
3397 args=(state_key,),
3398 )
3399 # UX-176: the same amber / ✨-confirm / green / red rule the
3400 # selects get (UX-90's red-when-required-and-empty included).
3401 state = multi_field_flag(
3402 note_col,
3403 state_key=state_key,
3404 cell_key=cell_key,
3405 chosen=list(chosen_cols),
3406 default=proposed_default,
3407 required=spec["key"] in required_keys,
3408 detected_label=detected_label,
3409 preview=value_preview_tip(df, spec["key"], list(chosen_cols)),
3410 )
3411 if state:
3412 tint_cells.setdefault(state, []).append(cell_key)
3413 return list(chosen_cols)
3415 mapping = _assemble_mapping(
3416 df,
3417 field_specs,
3418 proposed,
3419 only_keys,
3420 pick=_selectbox,
3421 pick_box_format=_render_box_format,
3422 pick_multi=_render_multi,
3423 )
3424 _emit_field_tints(tint_cells)
3425 return mapping
3428def multi_field_flag(
3429 flag_host,
3430 *,
3431 state_key: str,
3432 cell_key: str,
3433 chosen: list,
3434 default: list,
3435 required: bool,
3436 detected_label: str = "auto-detected",
3437 preview: str = "",
3438) -> str:
3439 """The ✨ flag of a *multi-column* picker, and its tint state (UX-176).
3441 The identity pickers (Trial / Participant / Text ID) are multiselects, so
3442 they never reached `_selectbox`'s amber tint and ✨ confirm button — the
3443 auto-detected id read exactly like one somebody had checked. This is the
3444 same rule for them: the columns detection proposed, untouched, are amber
3445 with a ✨ **button** that approves them; picking goes green; clearing goes
3446 neutral (or red once an add is attempted, for a required one). Returns the
3447 `_FIELD_TINT` state for the caller to paint (`mark_cells`). ``preview``
3448 is the picked columns' value preview (`value_preview_tip`)."""
3449 joined = " + ".join(chosen) if chosen else None
3450 proposed = " + ".join(default) if default else None
3451 state, hover = _field_state(
3452 chosen=joined,
3453 default=proposed,
3454 is_required=required,
3455 attempted=bool(st.session_state.get(ADD_ATTEMPTED_KEY)),
3456 touched=state_key in st.session_state.get(TOUCHED_FIELDS_KEY, ()),
3457 detected_label=detected_label,
3458 )
3459 _render_field_flag(
3460 flag_host,
3461 state=state,
3462 hover=hover,
3463 preview=preview,
3464 confirm_label="Confirm the detected columns",
3465 confirm_help=f"{hover} — click to confirm and clear the mark.",
3466 cell_key=cell_key,
3467 state_key=state_key,
3468 )
3469 return state
3472def value_preview_tip(df, field_key: str, column) -> str:
3473 """`data.mapping_value_preview` for a picked column, as a tooltip line."""
3474 if column in (None, NONE_OPTION, "", []):
3475 return ""
3476 preview = mapping_value_preview(df, field_key, column)
3477 return f"Values: {preview}" if preview else ""
3480def _render_field_flag(
3481 host,
3482 *,
3483 state: str,
3484 hover: str,
3485 preview: str,
3486 confirm_label: str,
3487 confirm_help: str,
3488 cell_key: str,
3489 state_key: str,
3490) -> None:
3491 """A mapping row's ✨ flag and its value preview, in the slot beside it.
3493 The ✨ is a **button** while the row is amber (UX-92) and an icon whose
3494 tooltip says what detection found otherwise. The preview — a few of the
3495 mapped column's values and what the app reads them as — is an icon of its
3496 own with a hover tooltip, like every other note on this form; on an amber
3497 row it rides on the confirm button's tooltip instead, which is where the
3498 eye already is, and keeps the slot one line tall.
3499 """
3500 if state == "auto":
3501 host.button(
3502 # UX-200: named for screen readers; only the ✨ shows.
3503 f"{ICONS['auto_detected']} {spoken(confirm_label)}",
3504 wrap=True,
3505 key=f"{cell_key}_confirm",
3506 help=confirm_help + (f"\n\n{preview}" if preview else ""),
3507 on_click=_mark_field_touched,
3508 args=(state_key,),
3509 )
3510 return
3511 # Icons only, on the rail's CSS hover (`.sps-fhelp`, 120 ms) rather than
3512 # the browser's ~1 s native one.
3513 spans = []
3514 if hover:
3515 spans.append(
3516 f'<span class="sps-map-flag sps-fhelp" '
3517 f'data-tip="{tooltip(hover)}">'
3518 f"{icon_html('auto_detected')}</span>"
3519 )
3520 if preview:
3521 tip = tooltip(preview)
3522 spans.append(
3523 f'<span class="sps-map-flag sps-map-preview sps-fhelp" tabindex="0" '
3524 f'data-tip="{tip}" aria-label="{tip}">{icon_html("preview")}</span>'
3525 )
3526 if spans:
3527 host.markdown("".join(spans), unsafe_allow_html=True)
3530def mark_cells(cells_by_state: dict) -> None:
3531 """Paint mapping cells built outside `column_mapping_ui` (UX-176) —
3532 ``{state: [cell_key, …]}``, the same states and `<style>` block."""
3533 _emit_field_tints(
3534 {
3535 state: [str(k) for k in keys]
3536 for state, keys in cells_by_state.items()
3537 if keys
3538 }
3539 )
3542def mark_missing_cells(cell_keys) -> None:
3543 """Paint the *missing* tint onto keyed cells the mapping UI did not render.
3545 UX-91. `wizard._render_identity_field` builds its own multiselects — one per
3546 table, laid out by the caller — so they never pass through
3547 `column_mapping_ui` and never reached `tint_cells`. Trial ID is required and
3548 was the last field that could survive a failed add without going red. This
3549 is the same `<style>` block by the same rules; only the collection point
3550 differs.
3551 """
3552 _emit_field_tints({"missing": [str(key) for key in cell_keys]})
3555def _emit_field_tints(tint_cells: dict[str, list[str]]) -> None:
3556 """One <style> block tinting each mapping cell by its state (UX-53 r4).
3558 Written after the rows because a cell's state is only known once its widget
3559 has returned a value. Targets the BaseWeb select *control* rather than the
3560 Streamlit wrapper, so the colour lands on the box the user is looking at and
3561 not on the whole row.
3562 """
3563 rules = []
3564 for state, keys in tint_cells.items():
3565 tint = _FIELD_TINT.get(state)
3566 if not tint or not keys:
3567 continue
3568 # The painted node, checked against the live DOM (UX-91): a selectbox
3569 # and a multiselect have the *same* shape —
3570 # `[data-testid="stSelectbox"|"stMultiSelect"] > div > div` is the box
3571 # carrying the background, and everything above it is transparent.
3572 # `!important` because the theme paints that node through an emotion
3573 # class that outranks a plain class selector.
3574 #
3575 # The earlier `[data-baseweb="select"]` selectors are gone: they match
3576 # **nothing** in this Streamlit version. Selectboxes tinted anyway,
3577 # through the `stSelectbox` fallback beside them, which is why the rule
3578 # looked fine — while multiselects (Trial ID, the one required one) had
3579 # no matching selector at all and silently never coloured.
3580 selector = ", ".join(
3581 f".st-key-{key} [data-testid='stSelectbox'] > div > div, "
3582 f".st-key-{key} [data-testid='stMultiSelect'] > div > div"
3583 for key in keys
3584 )
3585 rules.append(f"{selector} {{ background-color: {tint} !important; }}")
3586 if rules:
3587 st.markdown(f"<style>{''.join(rules)}</style>", unsafe_allow_html=True)
3590# Field-option helpers — shared by the rail's selectors and the plot-config
3591# restore path (`app._restore_plot_config`) so both agree on what's valid for
3592# the current data.
3593#: Numeric fixation columns 'Color fixations by' never offers on its own:
3594#: identifiers and on-screen geometry, which say *which* fixation or *where* —
3595#: the figure already shows both — not something about it.
3596_COLOR_BY_EXCLUDED = frozenset(
3597 {
3598 "participant_id",
3599 "trial_id",
3600 "text_id",
3601 "screen_id",
3602 "screen_index",
3603 "fixation_id",
3604 "screen_fixation_id",
3605 "x",
3606 "y",
3607 "canvas_width",
3608 "canvas_height",
3609 }
3610)
3613def color_field_options(trial_fixations: pd.DataFrame) -> list[str]:
3614 """Columns offered in the 'Color fixations by' selector — the familiar fields
3615 in a preferred order, then the dataset's other numeric columns, falling back
3616 to ``['duration_ms']``."""
3617 preferred_color_fields = [
3618 "duration_ms",
3619 "pass_index",
3620 "eye",
3621 "saccade_type",
3622 "saccade_amplitude",
3623 # BUG-25: EyeLink's own amplitudes, in degrees, kept distinct from the
3624 # pixel one above (and from each other — outgoing vs incoming saccade).
3625 "next_saccade_amplitude_deg",
3626 "prev_saccade_amplitude_deg",
3627 "word_id",
3628 "timestamp_ms",
3629 "is_regression",
3630 "progression",
3631 "gpt2_surprisal",
3632 "wordfreq_frequency",
3633 "subtlex_frequency",
3634 "universal_pos",
3635 "ptb_pos",
3636 ]
3637 fields = [f for f in preferred_color_fields if f in trial_fixations.columns]
3638 # Then every other numeric column the dataset kept (pupil size, a detection
3639 # confidence, a measure of its own), as the axis and hover pickers offer
3640 # them — but no identifier, no position (the plot already *is* x/y) and no
3641 # bookkeeping column (`user_columns`), and no boolean: a 0–1 colorscale over
3642 # a flag reads worse than the flags' own pickers.
3643 fields += [
3644 col
3645 for col in user_columns(trial_fixations)
3646 if col not in fields
3647 and col not in _COLOR_BY_EXCLUDED
3648 and pd.api.types.is_numeric_dtype(trial_fixations[col])
3649 and not pd.api.types.is_bool_dtype(trial_fixations[col])
3650 ]
3651 fields = fields or ["duration_ms"]
3652 # `(uniform)` leads and is the default (VIZ-17): marker *size* already encodes
3653 # duration, so mapping duration to hue as well spends the colour channel on a
3654 # variable that's already shown. Colour-by is then an opt-in for a *second*
3655 # variable. "line" is likewise synthetic (not a real column): colour each
3656 # fixation by the text line it lands on, inferred from word geometry.
3657 return [UNIFORM_COLOR_FIELD] + fields + ["line"]
3660def hover_field_options(
3661 frame: pd.DataFrame | None, *, words: bool = False
3662) -> list[str]:
3663 """Scalar columns that can be added to a VIZ-26 hover tooltip.
3665 Read off the columns, never the rows: a trial with no fixations still has
3666 the dataset's columns, and answering ``[]`` for it made `_seed_viz_state`
3667 drop the user's hover picks as stale the moment a filter landed on one
3668 (VIZ-44), for good.
3669 """
3670 if frame is None:
3671 return []
3672 preferred = (
3673 [
3674 "text",
3675 "word_id",
3676 "line_idx",
3677 "total_fixation_duration_ms",
3678 "first_fixation_ms",
3679 "first_pass_gaze_duration_ms",
3680 "regression_path_duration_ms",
3681 "n_fixations",
3682 ]
3683 if words
3684 else [
3685 "order_in_trial",
3686 "duration_ms",
3687 "word_id",
3688 "timestamp_ms",
3689 "pass_index",
3690 "eye",
3691 "saccade_type",
3692 "saccade_amplitude",
3693 ]
3694 )
3695 available = user_columns(frame)
3696 if words and {"x", "y", "height"} <= set(frame.columns):
3697 available.append("line_idx") # geometry-derived in plots._add_word_label_trace
3698 result: list[str] = []
3699 for column in [*preferred, *available]:
3700 if column in available and column != "image_path" and column not in result:
3701 result.append(column)
3702 return result
3705def numeric_field_options(trial_fixations: pd.DataFrame) -> list[str]:
3706 """Numeric columns offered as X/Y axis fields."""
3707 return [
3708 col
3709 for col in user_columns(trial_fixations)
3710 if pd.api.types.is_numeric_dtype(trial_fixations[col])
3711 ]
3714# Word columns that look like a per-word boolean flag the user might want to
3715# highlight on the text (the OneStop answer/distractor spans first, then any
3716# other boolean column).
3717_PREFERRED_HIGHLIGHT_FIELDS = ["is_in_aspan", "is_in_dspan"]
3719#: The highlight column the app seeded itself, as opposed to one the user
3720#: picked: a seeded pick is re-derived when the dataset changes.
3721_HIGHLIGHT_SEEDED_KEY = "_global_highlight_column_seeded"
3724def highlight_column_options(words: pd.DataFrame | None) -> list[str]:
3725 """Boolean word columns offered in the 'Highlight words by' selector.
3727 The OneStop answer/distractor spans lead, followed by any other boolean
3728 column in the words frame. Empty when there's nothing to highlight."""
3729 if words is None or words.empty:
3730 return []
3731 cols = [c for c in _PREFERRED_HIGHLIGHT_FIELDS if c in words.columns]
3732 for col in user_columns(words):
3733 if col not in cols and pd.api.types.is_bool_dtype(words[col]):
3734 cols.append(col)
3735 return cols
3738def _drop_stale(state_key: str, options: list) -> None:
3739 """Clear a persisted selectbox value that isn't valid for the current
3740 ``options`` (e.g. after switching datasets, or restoring a config built on
3741 different data) so ``st.selectbox`` falls back to its ``index=`` default
3742 instead of raising."""
3743 if state_key in st.session_state and st.session_state[state_key] not in options:
3744 del st.session_state[state_key]
3747def _drop_stale_multi(state_key: str, options: list) -> None:
3748 """Keep only still-valid values in a persisted multiselect list."""
3749 value = st.session_state.get(state_key)
3750 if isinstance(value, (list, tuple)):
3751 filtered = [item for item in value if item in options]
3752 if list(value) != filtered:
3753 st.session_state[state_key] = filtered
3754 elif value is not None:
3755 st.session_state.pop(state_key, None)
3758def _explicit_pair(val) -> tuple | None:
3759 """A stored ``(min, max)`` as an ordered pair of finite floats, or ``None``
3760 for a malformed/missing value — WITHOUT touching session_state.
3762 Shared by the rail's colour-range slider (``_render_color_range``) and
3763 ``_collect_viz_settings``, so the figure and the slider read one value. It
3764 is deliberately **not** clamped to the loaded data: an explicit range is
3765 the user's endpoints, and narrowing the trial pool must not change the
3766 mapping it pins (round-7 review, finding 10). VIZ-46 clamped it to the
3767 pool's span, which re-scaled a pinned figure whenever a filter removed the
3768 trial holding its extreme value."""
3769 if not (isinstance(val, (list, tuple)) and len(val) == 2):
3770 return None
3771 try:
3772 a, b = float(val[0]), float(val[1])
3773 except (TypeError, ValueError):
3774 return None
3775 if not (math.isfinite(a) and math.isfinite(b)):
3776 return None
3777 return (min(a, b), max(a, b))
3780# --- VIZ-46: a colour range is *auto* until the user sets one -----------------
3781# `api.plot_scanpath` leaves `fixation_color_range` / `heatmap_range` at `None`,
3782# and every builder then scales the figure to its own trial (a comparison, to A
3783# and B together). The rail used to `setdefault` its slider key to the whole
3784# dataset's span the moment the slider rendered, so the app never drew that
3785# default: its heatmap sat on the dataset's longest single fixation while the
3786# headless one used the trial's own per-word values.
3787#
3788# So the canonical `global_*` key now means **explicit**: it is present only
3789# when a range was chosen — dragged or typed here, un-ticking *Auto*, or
3790# arriving on a Share link / saved config / saved design — and `None` reaches
3791# the builders otherwise, i.e. the API's rule, not a copy of it. The slider
3792# draws a private *view* key instead, seeded from the canonical value or (auto)
3793# the dataset span it is bounded by, so rendering it can no longer pin a number
3794# into every link, config and bulk export. Nothing else changes shape: the
3795# link's generic range sweep already emits only a key that is present, and the
3796# config writer already writes the figure's `None`.
3797_COLOR_RANGE_URL_PARAMS = {
3798 state_key: param for param, state_key in SHARE_FLOAT_RANGE_PARAMS.items()
3799}
3802def _color_range_view_key(state_key: str) -> str:
3803 """The private key the rail's slider draws for ``state_key`` (VIZ-46)."""
3804 return f"_{state_key.removeprefix('global_')}_view"
3807def _color_range_auto_key(state_key: str) -> str:
3808 """The private key of ``state_key``'s *Auto* checkbox (VIZ-46)."""
3809 return f"_{state_key.removeprefix('global_')}_auto"
3812def forget_color_range(state_key: str) -> None:
3813 """Put one colour range back to auto — per trial, like the API (VIZ-46).
3815 Also drops the link param that may have set it: `url_state._apply_url_preset`
3816 re-seeds from `st.query_params` at the top of every rerun, so on a page
3817 opened from a Share link the range would otherwise come straight back.
3818 """
3819 st.session_state.pop(state_key, None)
3820 param = _COLOR_RANGE_URL_PARAMS.get(state_key)
3821 if param is not None:
3822 st.query_params.pop(param, None)
3825#: The reading's grain, in the order a per-word dwell groups by: one word of one
3826#: screen of one reading (the screen only on multipart data).
3827_WORD_DWELL_KEYS = ("participant_id", "trial_id", "screen_id", "word_id")
3830def heatmap_value_bounds(
3831 fixations: pd.DataFrame | None,
3832 words: pd.DataFrame | None,
3833 *,
3834 counts: bool = False,
3835) -> tuple[float, float] | None:
3836 """The span of the values a duration-weighted word-box heatmap maps, in ms.
3838 A word box is tinted by the *summed* duration of the fixations in it, so
3839 its range is per-word dwell — which refixations and rereading push well
3840 past the longest single fixation the rail used to bound it by (round-7
3841 review, finding 11). Summed over the pool's ``word_id`` assignment, the
3842 grouping Compare's shared word heatmap uses too; the static heatmap bins by
3843 box containment, which can differ by a stray fixation, so these are the
3844 slider's *suggested* bounds and any endpoint can still be typed.
3846 Without a ``word_id`` the upper bound is a reading's whole dwell (no word
3847 can hold more); words-only data (no fixations) maps its own
3848 ``total_fixation_duration_ms``, as the figure's fallback does. ``None``
3849 when there is nothing to map. One groupby over the pool, cached on its
3850 fingerprint by :func:`_heatmap_value_bounds_cached`.
3852 ``counts`` gives the span of fixations per word instead (per reading
3853 without a ``word_id``); the words-only fallback has no counts to give.
3854 """
3855 if counts:
3856 if fixations is None or fixations.empty:
3857 return None
3858 keys = [k for k in _WORD_DWELL_KEYS if k in fixations.columns]
3859 if "word_id" in keys and fixations["word_id"].notna().any():
3860 per_word = (
3861 fixations[fixations["word_id"].notna()]
3862 .groupby(keys, dropna=False, sort=False)
3863 .size()
3864 )
3865 else:
3866 per_word = fixations.groupby(
3867 [k for k in keys if k != "word_id"] or [np.zeros(len(fixations))],
3868 dropna=False,
3869 sort=False,
3870 ).size()
3871 return (1.0, float(per_word.max())) if len(per_word) else None
3872 if (
3873 fixations is not None
3874 and not fixations.empty
3875 and "duration_ms" in fixations.columns
3876 ):
3877 duration = pd.to_numeric(fixations["duration_ms"], errors="coerce")
3878 keys = [k for k in _WORD_DWELL_KEYS if k in fixations.columns]
3879 if "word_id" in keys and fixations["word_id"].notna().any():
3880 frame = fixations[keys].assign(_d=duration)
3881 frame = frame[frame["word_id"].notna()]
3882 dwell = frame.groupby(keys, dropna=False, sort=False)["_d"].sum()
3883 values = dwell[dwell > 0]
3884 if not values.empty:
3885 return float(values.min()), float(values.max())
3886 positive = duration[duration > 0]
3887 if positive.empty:
3888 return None
3889 reading = [k for k in _WORD_DWELL_KEYS[:3] if k in fixations.columns]
3890 if reading:
3891 per_reading = positive.groupby(
3892 [fixations.loc[positive.index, k] for k in reading], dropna=False
3893 ).sum()
3894 upper = float(per_reading.max())
3895 else:
3896 upper = float(positive.sum())
3897 return float(positive.min()), upper
3898 if words is not None and "total_fixation_duration_ms" in words.columns:
3899 values = pd.to_numeric(words["total_fixation_duration_ms"], errors="coerce")
3900 values = values[values > 0]
3901 if not values.empty:
3902 return float(values.min()), float(values.max())
3903 return None
3906def _heatmap_bounds_for_rail(
3907 fixations: pd.DataFrame | None,
3908 words: pd.DataFrame | None,
3909 *,
3910 counts: bool = False,
3911) -> tuple[float, float] | None:
3912 """:func:`heatmap_value_bounds`, cached on the frames it actually reads.
3914 The words table is read only when the fixations carry no durations (the
3915 words-only fallback), so only then does it — and its fingerprint — enter
3916 the cache key; otherwise a change to the words cannot change the answer.
3917 """
3918 from scanpath_studio.data import frame_fingerprint
3920 words_used = (
3921 fixations is None or fixations.empty or "duration_ms" not in fixations.columns
3922 )
3923 if not words_used:
3924 words = None
3925 return _heatmap_value_bounds_cached(
3926 fixations,
3927 words,
3928 counts,
3929 (
3930 frame_fingerprint(fixations),
3931 None if words is None else frame_fingerprint(words),
3932 ),
3933 )
3936def _cheap_heatmap_bounds(
3937 fixations: pd.DataFrame | None, *, counts: bool = False
3938) -> tuple[float, float] | None:
3939 """Placeholder bounds for the greyed range while the heatmap is off.
3941 The shortest and longest single fixation — one vectorised pass and no
3942 groupby — or ``None`` when there is none (the range is then not drawn,
3943 as before). The heatmap's own bounds (:func:`_heatmap_bounds_for_rail`)
3944 replace them once it is shown. For ``counts``, 1 to the fixation count.
3945 """
3946 if counts:
3947 n = 0 if fixations is None else len(fixations)
3948 return (1.0, float(n)) if n else None
3949 if (
3950 fixations is not None
3951 and not fixations.empty
3952 and "duration_ms" in fixations.columns
3953 ):
3954 duration = pd.to_numeric(fixations["duration_ms"], errors="coerce")
3955 duration = duration[duration > 0]
3956 if not duration.empty:
3957 return float(duration.min()), float(duration.max())
3958 return None
3961@st.cache_data(show_spinner=False, max_entries=8)
3962def _heatmap_value_bounds_cached(
3963 _fixations: pd.DataFrame | None, _words: pd.DataFrame | None, counts, cache_key
3964) -> tuple[float, float] | None:
3965 return heatmap_value_bounds(_fixations, _words, counts=counts)
3968def _render_color_range(
3969 label: str,
3970 state_key: str,
3971 lo: float,
3972 hi: float,
3973 *,
3974 disabled: bool,
3975 reason: str,
3976 help: str | None = None,
3977 field_host=None,
3978 slider_format: str = "%d",
3979) -> None:
3980 """*Auto* checkbox + the ``[lo, hi]``-bounded range slider (VIZ-46).
3982 ``slider_format`` labels the handles, e.g. ``"%d ms"`` for a range in ms;
3983 the number boxes beside it keep a bare number.
3985 ``field_host`` (UX-158) draws *Auto*, the slider and its boxes into that
3986 column, for a `_sub_row` whose title and caption the caller has already
3987 drawn.
3989 While the range is auto the slider sits at its full bounds and *Auto* is
3990 ticked; the figure is scaled to the trial, not to those bounds. Dragging the
3991 slider or typing a bound makes the range explicit (and un-ticks *Auto*), as
3992 does un-ticking *Auto* itself, which pins the bounds on screen — the
3993 dataset-wide scale the app used to default to, now one click away. An
3994 explicit range is sticky across trials until *Auto* is ticked again.
3996 ``[lo, hi]`` is the span the data suggests. An explicit range is drawn —
3997 and reaches the figure — exactly as stored, never clamped to it: the
3998 slider's bounds widen to hold its endpoints when no remaining observation
3999 reaches them (a filter removed the trial with the extreme value, or the
4000 range came on a link built on other data), and the number boxes take any
4001 endpoint, beyond the observed span too (round-7 review, findings 10–11).
4002 """
4003 ss = st.session_state
4004 view_key = _color_range_view_key(state_key)
4005 auto_key = _color_range_auto_key(state_key)
4006 explicit = _explicit_pair(ss.get(state_key))
4007 if explicit is None:
4008 ss.pop(state_key, None) # a malformed value is not a range
4009 else:
4010 lo = min(lo, float(math.floor(explicit[0])))
4011 hi = max(hi, float(math.ceil(explicit[1])))
4012 hi = hi if hi > lo else lo + 1.0
4013 shown = explicit if explicit is not None else (lo, hi)
4014 if ss.get(view_key) != shown:
4015 ss[view_key] = shown
4016 ss[auto_key] = explicit is None
4018 def _commit_view() -> None:
4019 view = ss.get(view_key)
4020 if isinstance(view, (tuple, list)) and len(view) == 2:
4021 ss[state_key] = (float(min(view)), float(max(view)))
4023 def _toggle_auto() -> None:
4024 if ss.get(auto_key):
4025 forget_color_range(state_key)
4026 else:
4027 _commit_view()
4029 auto_text = (
4030 "Auto (default): each trial is scaled to its own values; in Compare, A and "
4031 "B share one range. Off: the range applies to every trial. Dragging the "
4032 "range turns Auto off."
4033 )
4034 auto_disabled, _ = _layer_gate(disabled, None)
4036 # UX-157: *Auto* sits on the range's own line, between its title and the
4037 # slider, instead of a row of its own above it. Its explanation joins the
4038 # row title's tooltip: a `?` icon beside it would squeeze "Auto" to "A…".
4039 def _auto(col) -> None:
4040 col.checkbox(
4041 "Auto",
4042 key=auto_key,
4043 on_change=_toggle_auto,
4044 disabled=auto_disabled,
4045 )
4047 range_help = _gated_help(f"{help} {auto_text}" if help else auto_text, reason)
4048 _range_slider(
4049 st,
4050 label,
4051 label_left=True,
4052 key=view_key,
4053 min_value=lo,
4054 max_value=hi,
4055 step=1.0,
4056 slider_format=slider_format,
4057 number_format="%d",
4058 disabled=disabled,
4059 on_change=_commit_view,
4060 help=range_help,
4061 lead=_auto,
4062 field_host=field_host,
4063 # Any endpoint can be typed: the slider spans the data, but a common
4064 # scale often reaches past this pool's largest value.
4065 number_bounds=(None, None),
4066 )
4069def _sub_row(
4070 caption: str | None,
4071 *,
4072 section: str | None = None,
4073 section_help: str | None = None,
4074 caption_help: str | None = None,
4075 section_share: float = 0.45,
4076):
4077 """One row of a titled group of rows; return the column for its field (UX-158).
4079 The label column is split in two: the group's title (``section``, drawn on
4080 the group's first row only) and this row's short caption, so a run of
4081 related controls reads as one setting without a full title per row. The
4082 two together are exactly the label column's width, so the fields line up
4083 with the ordinary ``label | field`` rows around them. ``section_share`` is
4084 the title's part of that column — wider for a longer title ("Scanpath A").
4085 A ``None`` caption leaves its cell empty, for a group's continuation row.
4086 """
4087 label_w = _label_w()
4088 section_w = label_w * section_share
4089 section_col, caption_col, field_col = st.columns(
4090 [section_w, label_w - section_w, 1.0 - label_w],
4091 gap=_LABEL_GAP,
4092 vertical_alignment="center",
4093 )
4094 if section is not None:
4095 _, section_help = _layer_gate(False, section_help)
4096 _row_label(section_col, section, section_help)
4097 if caption:
4098 _sub_caption(caption_col, caption, caption_help)
4099 return field_col
4102def _check_row(
4103 label: str,
4104 *,
4105 key: str,
4106 help: str | None = None,
4107 check_label: str = "Show",
4108 check_share: float = 0.26,
4109 disabled: bool = False,
4110 on_change=None,
4111 args: tuple = (),
4112 persist_state: str | None = None,
4113):
4114 """A ``label | ☑ Show | …`` row; return ``(value, rest)`` (UX-159).
4116 The shape UX-155 gave *Fixation index*: the row's title on the left, a
4117 checkbox that says what it does, and the rest of the row (``rest``) for the
4118 controls it governs — which the caller greys while it is off rather than
4119 hiding them. The help goes on the title's tooltip, not the checkbox, so the
4120 row carries no ``?`` icon. ``persist_state`` is forwarded to the checkbox,
4121 and every caller on a wire-format key spells out ``"session"`` —
4122 `test_widget_value_sync` checks for it at the call site.
4123 """
4124 disabled, help = _layer_gate(disabled, help)
4125 label_w = _label_w()
4126 rest_w = 1.0 - label_w
4127 label_col, check_col, rest_col = st.columns(
4128 [label_w, rest_w * check_share, rest_w * (1.0 - check_share)],
4129 gap=_LABEL_GAP,
4130 vertical_alignment="center",
4131 )
4132 _row_label(label_col, label, help)
4133 return (
4134 check_col.checkbox(
4135 check_label,
4136 key=key,
4137 disabled=disabled,
4138 on_change=on_change,
4139 args=args,
4140 persist_state=persist_state,
4141 ),
4142 rest_col,
4143 )
4146#: The help on the duration-scale rows — shared with nothing else, but long
4147#: enough that the row code reads better without it inline.
4148_SCALE_HELP = (
4149 "How duration sets marker size. √, linear and log use the duration bounds "
4150 "below, so a duration is the same size in every figure. Relative stretches "
4151 "each figure from its own shortest to longest fixation."
4152)
4153_DURATION_BOUNDS_HELP = (
4154 "The durations (ms) that get the smallest and the largest marker. Unused on "
4155 "the relative scale."
4156)
4157_SIZE_KEY_HELP = (
4158 "Reference circles labeled in ms. Drawn on a fixed scale only, and in Compare "
4159 "only when both scanpaths use the same size range."
4160)
4163def _render_duration_scale_rows() -> None:
4164 """The fixed duration scale: curve, duration bounds and the size key.
4166 One scale for both scanpaths of a comparison too (only the size *range* is
4167 per scanpath there), so none of the three carries Compare's gate. The bounds
4168 and the key are greyed, never hidden, on the relative scale.
4169 """
4170 scale_dis, scale_help = _layer_gate(False, _SCALE_HELP)
4171 # Keyless, like the colorscale picker: a keyed selectbox first painted in a
4172 # closed popover shows its first option rather than the seeded value — and a
4173 # link or settings file that predates the fixed scale seeds "relative".
4174 options = list(MARKER_SIZE_SCALES)
4175 current = st.session_state.get("global_marker_size_scale")
4176 st.session_state["global_marker_size_scale"] = _sub_row(
4177 "Scale", caption_help=scale_help
4178 ).selectbox(
4179 "Duration scale",
4180 options=options,
4181 index=options.index(current) if current in options else 0,
4182 format_func=lambda s: MARKER_SIZE_SCALES[s],
4183 disabled=scale_dis,
4184 help=scale_help,
4185 label_visibility="collapsed",
4186 )
4187 relative = st.session_state["global_marker_size_scale"] == "relative"
4188 _, bounds_help = _layer_gate(relative, _DURATION_BOUNDS_HELP)
4189 _range_slider(
4190 st,
4191 "Durations (ms)",
4192 key="global_marker_duration_range",
4193 persist_state="session",
4194 min_value=MARKER_DURATION_BOUNDS[0],
4195 max_value=MARKER_DURATION_BOUNDS[1],
4196 step=10,
4197 disabled=relative,
4198 slider_format="%d ms",
4199 number_format="%d",
4200 help=_DURATION_BOUNDS_HELP,
4201 field_host=_sub_row("Durations", caption_help=bounds_help),
4202 )
4203 key_dis, key_help = _layer_gate(relative, _SIZE_KEY_HELP)
4204 _sub_row("Size key", caption_help=key_help).checkbox(
4205 "Show",
4206 key="global_duration_size_legend",
4207 persist_state="session",
4208 disabled=key_dis,
4209 )
4212def _sub_caption(host, text: str, help: str | None = None) -> None:
4213 """A muted field caption — `fields.row_label`'s markup plus ``.sps-fsub``."""
4214 text = _plain(text)
4215 if not help:
4216 host.markdown(
4217 f'<span class="sps-flabel sps-fsub">{html.escape(text)}</span>',
4218 unsafe_allow_html=True,
4219 )
4220 return
4221 tip = tooltip(text, help)
4222 host.markdown(
4223 f'<span class="sps-fhelp" data-tip="{tip}" aria-label="{tip}">'
4224 f'<span class="sps-flabel sps-flabel-help sps-fsub">{html.escape(text)}'
4225 "</span></span>",
4226 unsafe_allow_html=True,
4227 )
4230#: UX-159: "A" and "B", as the ⚖️ Compare popover's *Label A* / *Label B* and
4231#: the figure's A/B legend name them — they were "Scanpath 1" / "Scanpath 2".
4232_COMPARE_SCANPATHS = ((0, "Scanpath A"), (1, "Scanpath B"))
4235def render_pattern_help(host, fields: dict) -> None:
4236 """The one place the ``{field}`` vocabulary is spelled out (UX-31).
4238 Three surfaces speak this little language — the figure title/caption, the
4239 Compare A/B legend labels, and the bulk-export file-naming pattern — and two
4240 of them used to describe it only as "same fields as the other one", so a user
4241 who had seen neither had no way to learn that ``{participant_id}`` was even a
4242 thing. Rendered as a collapsed expander: it is reference material, not a
4243 control, and the rail is narrow.
4244 """
4245 if not fields:
4246 return
4247 from .export import TABLE_PATTERN_LABELS
4249 # EXP-22: `{table.field}` names are listed under their table's heading,
4250 # after the plain list — which they are kept out of, so it reads as it did.
4251 grouped: dict[str, list[str]] = {}
4252 plain = []
4253 for name in fields:
4254 table, dot, _rest = str(name).partition(".")
4255 if dot and table in TABLE_PATTERN_LABELS:
4256 grouped.setdefault(table, []).append(name)
4257 else:
4258 plain.append(name)
4259 sections = [
4260 f"**{TABLE_PATTERN_LABELS[table]}**\n\n"
4261 + "\n".join(f"- `{{{name}}}`" for name in sorted(grouped[table]))
4262 for table in TABLE_PATTERN_LABELS
4263 if table in grouped
4264 ]
4265 with host.expander("Available fields", expanded=False):
4266 st.markdown(
4267 "Type any of these in a pattern and the trial's own value is "
4268 "substituted:\n\n"
4269 + "\n".join(f"- `{{{name}}}`" for name in sorted(plain))
4270 + "".join(f"\n\n{section}" for section in sections)
4271 + "\n\nAnything else is left as literal text."
4272 )
4275def _trial_rows(
4276 frame: pd.DataFrame | None, trial_fixations: pd.DataFrame
4277) -> pd.DataFrame:
4278 """``frame``'s rows for the trial ``trial_fixations`` holds (EXP-22).
4280 Cached on the frame's fingerprint and the trial: ``frame`` is the filtered
4281 corpus, and slicing it by string ids is a full scan the rail would otherwise
4282 repeat on every rerun while *Title* or *Caption* is on."""
4283 if frame is None or frame.empty or trial_fixations.empty:
4284 return pd.DataFrame()
4285 ids = tuple(
4286 (column, str(trial_fixations[column].iloc[0]))
4287 for column in ("participant_id", "trial_id")
4288 if column in frame.columns and column in trial_fixations.columns
4289 )
4290 return _c_trial_rows(frame, frame_fingerprint(frame), ids)
4293@st.cache_data(show_spinner=False, max_entries=8)
4294def _c_trial_rows(_frame: pd.DataFrame, fingerprint, ids: tuple) -> pd.DataFrame:
4295 mask = pd.Series(True, index=_frame.index)
4296 for column, value in ids:
4297 mask &= _frame[column].astype(str) == value
4298 return _frame[mask]
4301def _selected_metadata_rows(trial_fixations: pd.DataFrame | None) -> dict:
4302 """The attached metadata tables' rows for the trial ``trial_fixations`` is
4303 (EXP-22) — enough to name every field, whichever trial it is."""
4304 from . import metadata as md
4306 ids = {}
4307 if trial_fixations is not None and not trial_fixations.empty:
4308 for column in ("participant_id", "trial_id", "text_id"):
4309 if column in trial_fixations.columns:
4310 ids[column] = str(trial_fixations[column].iloc[0])
4311 return md.pattern_rows(
4312 ids.get("participant_id"), ids.get("trial_id"), ids.get("text_id")
4313 )
4316def current_dataset_name() -> str:
4317 """The name the dataset picker shows for the source now loaded (VIZ-36).
4319 ``data_source_choice`` is canonical: since DATA-9's flat picker the stored
4320 value *is* the entry's label — the user's own name for an upload, the
4321 registry label for a public corpus — and DATA-23's rename re-keys it, so a
4322 renamed dataset carries the new name without anything else to update.
4324 Returns ``""`` rather than a placeholder when there is no session (headless
4325 render, import time): ``{dataset_name}`` then renders empty, which is the
4326 same thing an unnamed CLI render produces, instead of inventing a label
4327 that would disagree with the app.
4328 """
4329 try:
4330 return str(st.session_state.get("data_source_choice") or "")
4331 except Exception: # no ScriptRunContext — headless import, tests, docs
4332 return ""
4335def render_pattern_input(
4336 host,
4337 label: str,
4338 key: str,
4339 fields: dict,
4340 *,
4341 help: str | None = None,
4342 placeholder: str | None = None,
4343 label_left: bool = False,
4344 disabled: bool = False,
4345 label_visibility: str = "visible",
4346 preview: bool = True,
4347) -> str:
4348 """A pattern text box with live validation and a rendered preview.
4350 Returns the pattern, or ``""`` when it names a field that does not exist —
4351 an invalid pattern must not reach a figure or a filename, and the error says
4352 which placeholder is wrong (see ``export.pattern_error``).
4354 ``placeholder`` is what an *empty* box falls back to, printed greyed inside
4355 the box itself (UX-31). These boxes default to empty — the fallback used to
4356 be stated only in the `help` tooltip, so the one thing you need to know
4357 before typing (what you get if you don't) needed a hover to find.
4359 Pass it **only** where an empty box really does produce that string. The
4360 Compare A/B labels qualify (empty → the auto ``participant · trial``); the
4361 figure title/caption do not (empty → no title at all), and a placeholder
4362 there would promise the opposite of what happens.
4364 ``label_left`` opts into the UX-51 ``label | field`` row (the rail's
4365 Title/Caption boxes). The error and the preview still span the full width
4366 below the row — they are the box's *output*, not a second field.
4368 ``disabled`` greys the box without touching its key (UX-68, the Compare
4369 settings menu while Compare is off) — the same "your value is kept" contract
4370 as :func:`_numeric_slider`. The error and preview still render, because they
4371 describe the pattern that *is* stored, not one being typed.
4372 """
4373 if label_left:
4374 _labeled(
4375 host,
4376 "text_input",
4377 label,
4378 key=key,
4379 persist_state="session",
4380 help=help,
4381 placeholder=placeholder,
4382 disabled=disabled,
4383 )
4384 else:
4385 host.text_input(
4386 label,
4387 key=key,
4388 persist_state="session",
4389 help=help,
4390 placeholder=placeholder,
4391 disabled=disabled,
4392 label_visibility=label_visibility,
4393 )
4394 value = st.session_state.get(key, "")
4395 if not value or not preview:
4396 # `preview=False` (a switched-off title) asks nothing of ``fields``,
4397 # whose values are computed on demand.
4398 return ""
4399 error = pattern_error(value, fields)
4400 if error:
4401 host.error(error)
4402 return ""
4403 host.caption(f"{label} preview — **{render_pattern(value, fields)}**")
4404 return value
4407def _pin(key: str, default) -> None:
4408 """Seed ``key``'s default if it has none yet. Never overwrites.
4410 This is the *first value* half of the widget-state contract. The other half
4411 — keeping that value alive through runs where the widget doesn't render, and
4412 pushing it to the browser when the widget finally mounts — used to be a
4413 matching ``rewrite=True`` re-assertion here (BUG-15: Streamlit only sent a
4414 value down on the run it was written programmatically, so a control whose
4415 popover first opened on a *later* run mounted at its proto default — nothing
4416 pressed on a segmented control, black on a colour picker). Streamlit 1.61
4417 owns that natively: every widget on these keys passes
4418 ``persist_state="session"``, which preserves the value while unmounted and
4419 marks it as changed on remount so the frontend adopts it (ENG-36). Keep the
4420 kwarg when adding a widget on a ``global_*`` / ``single_*`` / ``cmp{idx}_*``
4421 key — it is what makes this a plain ``setdefault`` again.
4423 Safe only because no viz widget passes ``value=``/``index=`` (see
4424 ``_seed_viz_state``); adding one would fight the stored value *and* log
4425 Streamlit's "default value but also set via Session State API" warning.
4427 A **list** default is copied on the way in: ``_VIZ_WIDGET_DEFAULTS`` is a
4428 module-level table, so seeding a multiselect (``global_saccade_classes``)
4429 with the list object itself would hand session state a live alias of the
4430 default, and one in-place edit anywhere would change it for the rest of the
4431 process.
4432 """
4433 if key in st.session_state:
4434 return
4435 try:
4436 st.session_state[key] = list(default) if isinstance(default, list) else default
4437 except StreamlitAPIException:
4438 # The key belongs to a widget already created this run (⚙ Compare
4439 # options / ⚙ Playback render in tabs.py before the rail). It is already
4440 # carrying its value, so there is nothing to seed.
4441 pass
4444def compare_style_defaults() -> dict:
4445 """Every per-scanpath comparison styling key → the value a session seeds.
4447 One table for the seeding below and for EXP-19's share link, which leaves a
4448 style off the link while it still equals this (`url_state._link_defaults`).
4449 ``cmp{idx}_label_pattern`` is not seeded — its absence *is* the auto label —
4450 so it is listed here as the empty string it reads as; ``cmp{idx}_box_color``
4451 likewise, its absence being "the scanpath's own colour", and
4452 ``cmp{idx}_box_fill_color``, its absence being "the figure's fill", and
4453 ``cmp{idx}_raw_gaze_color``, its absence being "the scanpath's own colour",
4454 and ``cmp{idx}_heatmap_colorscale``, "the figure's colour scale".
4455 """
4456 defaults: dict = {}
4457 for idx, _ in _COMPARE_SCANPATHS:
4458 defaults.update(
4459 {
4460 f"cmp{idx}_fix_color": compare_palette_color(idx),
4461 f"cmp{idx}_saccade_color": compare_palette_color(idx),
4462 f"cmp{idx}_saccade_style": "Solid",
4463 f"cmp{idx}_saccade_width": DEFAULT_SACCADE_WIDTH,
4464 f"cmp{idx}_marker_size_range": DEFAULT_MARKER_SIZE_RANGE,
4465 # VIZ-6: per-scanpath marker alpha (replaces the per-scanpath
4466 # hollow checkbox). Default 0.7 matches the single-trial default
4467 # so overlapping fixations show through. `cmp{idx}_hollow` kept
4468 # seeded for saved-config / deep-link backward compatibility (no
4469 # widget renders it anymore).
4470 f"cmp{idx}_opacity": COMPARE_FIXATION_OPACITY,
4471 f"cmp{idx}_hollow": False,
4472 f"cmp{idx}_label_pattern": "",
4473 # The word-box outline; empty follows `cmp{idx}_fix_color`.
4474 f"cmp{idx}_box_color": "",
4475 # The word-box fill; empty follows `global_word_box_fill_color`.
4476 f"cmp{idx}_box_fill_color": "",
4477 # The raw-gaze samples; empty follows `cmp{idx}_fix_color`.
4478 f"cmp{idx}_raw_gaze_color": "",
4479 # The heatmap's colour scale; empty follows the figure's.
4480 f"cmp{idx}_heatmap_colorscale": "",
4481 }
4482 )
4483 # CMP-24 — scanpath B's own filters (A's are the rail's ordinary ones).
4484 defaults.update(
4485 {
4486 "cmp1_saccade_classes": list(SACCADE_CLASS_ORDER),
4487 **{f"cmp1_fixclass_{cat}_mode": "Off" for cat, *_ in _FIXCLASS_CATEGORIES},
4488 "cmp1_fixclass_short_threshold_ms": 80,
4489 "cmp1_fixclass_long_threshold_ms": 800,
4490 }
4491 )
4492 return defaults
4495def _seed_compare_styles() -> None:
4496 """Seed the per-scanpath comparison styling keys (so the collected dicts have
4497 values even when the relevant layer popover isn't open this run).
4499 Seeding is all that is needed: the widgets themselves carry
4500 ``persist_state="session"``, which keeps the value alive through the runs
4501 where the popover isn't open (ENG-36)."""
4502 for key, default in compare_style_defaults().items():
4503 if not key.endswith(
4504 (
4505 "_label_pattern",
4506 "_box_color",
4507 "_box_fill_color",
4508 "_raw_gaze_color",
4509 "_heatmap_colorscale",
4510 )
4511 ):
4512 _pin(key, default)
4515def _render_compare_fix_styles(*, uniform: bool = True) -> None:
4516 """Scanpath B's fixation styling for the two-trial comparison, rendered in
4517 the Fixations popover straight under the *Marker* group — which, in
4518 Compare, is scanpath A's (its colour, size and opacity rows write A's
4519 ``cmp0_*`` keys).
4521 UX-159: the scanpath's name as the group title and a caption per row
4522 (`_sub_row`). The widgets keep the prefixed label as their accessible name;
4523 only the visible text is shortened. With a colour-by column the markers are
4524 filled by it, so the scanpath's colour is captioned as their *Outline*."""
4525 idx, name = _COMPARE_SCANPATHS[1]
4526 caption = "Color" if uniform else "Outline"
4527 _compare_fix_color_picker(
4528 _sub_row(
4529 caption,
4530 section=name,
4531 section_help=_COMPARE_SCANPATH_HELP[idx],
4532 caption_help=None if uniform else _COMPARE_OUTLINE_HELP,
4533 section_share=_COMPARE_SECTION_SHARE,
4534 ),
4535 idx,
4536 )
4537 _compare_size_slider(
4538 idx,
4539 "This scanpath's smallest and largest marker diameter, in px.",
4540 )
4541 _compare_opacity_slider(idx)
4544_COMPARE_OUTLINE_HELP = (
4545 "The color-by column fills the markers, so this color outlines them."
4546)
4549def _compare_fix_color_picker(host, idx: int) -> None:
4550 """One scanpath's flat fixation colour, ``cmp{idx}_fix_color``."""
4551 disabled, _ = _layer_gate(False, None)
4552 host.color_picker(
4553 f"{_COMPARE_SCANPATHS[idx][1]} — fixation color",
4554 key=f"cmp{idx}_fix_color",
4555 persist_state="session",
4556 disabled=disabled,
4557 label_visibility="collapsed",
4558 )
4561def _compare_size_slider(idx: int, help: str) -> None:
4562 """One scanpath's marker size range, ``cmp{idx}_marker_size_range``."""
4563 _, size_help = _layer_gate(False, help)
4564 _range_slider(
4565 st,
4566 f"{_COMPARE_SCANPATHS[idx][1]} — marker size range",
4567 key=f"cmp{idx}_marker_size_range",
4568 persist_state="session",
4569 min_value=4,
4570 max_value=40,
4571 help=help,
4572 field_host=_sub_row("Size", caption_help=size_help),
4573 )
4576def _compare_opacity_slider(idx: int) -> None:
4577 """One scanpath's marker opacity, ``cmp{idx}_opacity``."""
4578 _numeric_slider(
4579 st,
4580 f"{_COMPARE_SCANPATHS[idx][1]} — opacity",
4581 key=f"cmp{idx}_opacity",
4582 persist_state="session",
4583 min_value=0.1,
4584 max_value=1.0,
4585 step=0.05,
4586 slider_format="%.2f",
4587 help="Marker opacity for this scanpath (1.0 = fully opaque).",
4588 field_host=_sub_row("Opacity"),
4589 )
4592#: UX-159: the per-scanpath groups' titles ("Scanpath A") are longer than a
4593#: popover group title usually is, so their title row takes more of the label
4594#: column. Only that row: the group's other captions keep the usual share, so
4595#: "Opacity" is not cut to "Opa…".
4596_COMPARE_SECTION_SHARE = 0.62
4598#: The tooltip already leads with the group's title ("Scanpath A — …"), so
4599#: these start at what follows it.
4600_COMPARE_SCANPATH_HELP = {
4601 0: "The selected trial.",
4602 1: "The trial it is compared with.",
4603}
4606def _compare_saccade_color_picker(host, idx: int) -> None:
4607 """One scanpath's saccade colour, ``cmp{idx}_saccade_color``."""
4608 disabled, _ = _layer_gate(False, None)
4609 host.color_picker(
4610 f"{_COMPARE_SCANPATHS[idx][1]} — saccade color",
4611 key=f"cmp{idx}_saccade_color",
4612 persist_state="session",
4613 disabled=disabled,
4614 label_visibility="collapsed",
4615 )
4618def _compare_saccade_line_rows(idx: int) -> None:
4619 """One scanpath's saccade *Style* and *Width* rows (``cmp{idx}_saccade_*``)."""
4620 name = _COMPARE_SCANPATHS[idx][1]
4621 disabled, _ = _layer_gate(False, None)
4622 _sub_row("Style").selectbox(
4623 f"{name} — line style",
4624 options=list(SACCADE_DASH_OPTIONS.keys()),
4625 key=f"cmp{idx}_saccade_style",
4626 persist_state="session",
4627 disabled=disabled,
4628 label_visibility="collapsed",
4629 )
4630 _numeric_slider(
4631 st,
4632 f"{name} — line width",
4633 key=f"cmp{idx}_saccade_width",
4634 persist_state="session",
4635 min_value=SACCADE_WIDTH_BOUNDS[0],
4636 max_value=SACCADE_WIDTH_BOUNDS[1],
4637 step=0.5,
4638 slider_format="%.1f px",
4639 number_format="%.1f",
4640 field_host=_sub_row("Width"),
4641 )
4644def _render_compare_saccade_styles() -> None:
4645 """Scanpath B's saccade styling for the two-trial comparison, rendered in
4646 the Saccades popover under scanpath A's group — the popover's *Line* group,
4647 retitled in Compare, whose colour, style and width rows write A's
4648 ``cmp0_*`` keys. Laid out like :func:`_render_compare_fix_styles`."""
4649 idx, name = _COMPARE_SCANPATHS[1]
4650 _compare_saccade_color_picker(
4651 _sub_row(
4652 "Color",
4653 section=name,
4654 section_help=_COMPARE_SCANPATH_HELP[idx],
4655 section_share=_COMPARE_SECTION_SHARE,
4656 ),
4657 idx,
4658 )
4659 _compare_saccade_line_rows(idx)
4662def _render_heatmap_blur_row(
4663 fixations: pd.DataFrame | None,
4664 words: pd.DataFrame | None,
4665 *,
4666 disabled: bool,
4667 reason: str | None,
4668) -> None:
4669 """``Blur | ☑ Auto | σ px``: the Interpolated heatmap's Gaussian σ.
4671 The box always shows the σ in use: on Auto, the one the figure computes
4672 from this trial (`plots.interpolated_sigma_px`, greyed); off, the fixed
4673 one, ``global_heatmap_sigma_px``. It is a shadow of that key, so Auto's
4674 value is never written over the user's own."""
4675 from scanpath_studio.plots import _compute_axis_ranges, interpolated_sigma_px
4677 help_text = _gated_help(
4678 "Interpolated only: the Gaussian blur's σ, in px. Auto: 2% of the "
4679 "larger span of fixations and word boxes, ≥ 8 px.",
4680 reason,
4681 )
4682 auto, rest = _check_row(
4683 "Blur",
4684 key="global_heatmap_sigma_auto",
4685 persist_state="session",
4686 check_label="Auto",
4687 disabled=disabled,
4688 help=help_text,
4689 )
4690 view_key = "_heatmap_sigma_view"
4691 if auto:
4692 trial_words = (
4693 _trial_rows(words, fixations)
4694 if words is not None and fixations is not None
4695 else None
4696 )
4697 *_, x_min, x_max, y_min, y_max = _compute_axis_ranges(
4698 1,
4699 1,
4700 (fixations, "x", "y"),
4701 word_frames=[] if trial_words is None else [trial_words],
4702 )
4703 shown = (
4704 interpolated_sigma_px(x_max - x_min, y_max - y_min)
4705 if x_min is not None
4706 else DEFAULT_HEATMAP_SIGMA_PX
4707 )
4708 else:
4709 shown = st.session_state.get(
4710 "global_heatmap_sigma_px", DEFAULT_HEATMAP_SIGMA_PX
4711 )
4712 st.session_state[view_key] = round(float(shown), 1)
4714 def _apply() -> None:
4715 if _shadow_key_missing(view_key): # BUG-18
4716 return
4717 st.session_state["global_heatmap_sigma_px"] = float(st.session_state[view_key])
4719 box_col, unit_col = rest.columns(
4720 [0.75, 0.25], gap=_LABEL_GAP, vertical_alignment="center"
4721 )
4722 box_col.number_input(
4723 "Blur σ (px)",
4724 min_value=HEATMAP_SIGMA_BOUNDS[0],
4725 max_value=HEATMAP_SIGMA_BOUNDS[1],
4726 step=1.0,
4727 format="%.1f",
4728 key=view_key,
4729 on_change=_apply,
4730 disabled=_layer_gate(disabled or auto, None)[0],
4731 label_visibility="collapsed",
4732 )
4733 _sub_caption(unit_col, "px")
4736def _render_colorbar_rows(bar: str, *, disabled: bool, reason: str | None) -> None:
4737 """One colour scale's bar: ``Color bar | ☑ Show | orientation``, then the
4738 tick labels' angle and size — for ``bar`` ``"fixation"`` or ``"heatmap"``.
4740 Each bar has its own keys, so the fixations' and the heatmap's can be styled
4741 apart. ``disabled`` greys all four (the scale itself is idle) without
4742 touching a stored value."""
4743 shown, rest = _check_row(
4744 "Color bar",
4745 key=f"global_show_{bar}_colorbar",
4746 persist_state="session",
4747 disabled=disabled,
4748 help=_gated_help(
4749 "The scale's legend: right of the plot (Vertical) or below it "
4750 "(Horizontal).",
4751 reason,
4752 ),
4753 )
4754 idle = disabled or not shown
4755 rest.radio(
4756 "Color bar orientation",
4757 options=["Vertical", "Horizontal"],
4758 horizontal=True,
4759 key=f"global_{bar}_colorbar_orientation",
4760 persist_state="session",
4761 disabled=_layer_gate(idle, None)[0],
4762 label_visibility="collapsed",
4763 )
4764 angle_help = _gated_help("Tick-label angle, in degrees.", reason)
4765 _numeric_slider(
4766 st,
4767 "Tick label angle",
4768 key=f"global_{bar}_colorbar_tickangle",
4769 persist_state="session",
4770 min_value=-90,
4771 max_value=90,
4772 step=15,
4773 disabled=idle,
4774 help=angle_help,
4775 field_host=_sub_row("Angle", caption_help=_layer_gate(False, angle_help)[1]),
4776 )
4777 size_help = _gated_help("Tick-label size, in px.", reason)
4778 _numeric_slider(
4779 st,
4780 "Tick label size",
4781 key=f"global_{bar}_colorbar_tickfont_size",
4782 persist_state="session",
4783 min_value=6,
4784 max_value=20,
4785 disabled=idle,
4786 help=size_help,
4787 field_host=_sub_row("Size", caption_help=_layer_gate(False, size_help)[1]),
4788 )
4791#: ``colour | opacity slider + box`` inside one ⬚ Word boxes row: the swatch
4792#: takes only what it needs and the opacity fills the rest of the line.
4793_COLOR_OPACITY_W = (1.0, 4.5)
4796def _box_opacity(
4797 host, *, key: str, label: str, help: str, persist_state: str | None = None
4798) -> None:
4799 """One ⬚ Word boxes opacity (outline or fill), drawn beside its colour."""
4800 _numeric_slider(
4801 st,
4802 label,
4803 key=key,
4804 persist_state=persist_state,
4805 min_value=0.0,
4806 max_value=1.0,
4807 step=0.01,
4808 number_format="%.2f",
4809 help=help,
4810 field_host=host,
4811 )
4814_LINE_OPACITY_HELP = "Outline opacity; 0 hides it."
4815_FILL_OPACITY_HELP = "How strongly the fill shows; 0 draws outlines only."
4818def _compare_follow_color_picker(
4819 host,
4820 idx: int,
4821 part: str,
4822 *,
4823 follow: str,
4824 help: str,
4825 what: str,
4826 disabled: bool = False,
4827) -> None:
4828 """One scanpath's ``part`` colour, ``cmp{idx}_{part}_color`` — the word-box
4829 outline (``"box"``), its fill (``"box_fill"``) or the raw-gaze samples
4830 (``"raw_gaze"``); ``what`` names it in the widget's label.
4832 The picker is a shadow of that key: it shows the colour actually drawn —
4833 ``follow`` until one is picked — and only a pick writes the override, so an
4834 untouched colour keeps following ``follow`` when that changes. Picking
4835 ``follow`` itself again goes back to following it."""
4836 key = f"cmp{idx}_{part}_color"
4837 pick_key = f"{key}__pick"
4838 st.session_state[pick_key] = st.session_state.get(key) or follow
4840 def _apply() -> None:
4841 if _shadow_key_missing(pick_key): # BUG-18
4842 return
4843 picked = st.session_state[pick_key]
4844 st.session_state[key] = "" if picked.lower() == follow.lower() else picked
4846 disabled, tip = _layer_gate(disabled, help)
4847 host.color_picker(
4848 f"{_COMPARE_SCANPATHS[idx][1]} — {what} color",
4849 key=pick_key,
4850 on_change=_apply,
4851 disabled=disabled,
4852 help=tip,
4853 label_visibility="collapsed",
4854 )
4857def _compare_fix_color(idx: int) -> str:
4858 """The fixation colour one scanpath wears in Compare."""
4859 return st.session_state.get(f"cmp{idx}_fix_color") or compare_palette_color(idx)
4862def _render_compare_box_groups(fill_help: str) -> None:
4863 """The word boxes per scanpath, for the static comparison — laid out like
4864 the Fixations popover's Compare groups: scanpath A's group (its *Line* and
4865 *Fill* rows) where the *Box* group is, then scanpath B's.
4867 Each reading's boxes are outlined in its own colour — its fixation colour
4868 until one is picked here, so A and B stay apart by default — and filled in
4869 its own, the figure's fill until one is picked. The two opacities are
4870 shared by both readings and sit on A's rows."""
4871 figure_fill = (
4872 st.session_state.get("global_word_box_fill_color") or WORD_BOX_FILL_COLOR
4873 )
4874 for idx, name in _COMPARE_SCANPATHS:
4875 line_help = f"{name}'s word-box outline color. Defaults to its fixation color."
4876 line_col, line_opacity_col = _sub_row(
4877 "Line",
4878 section=name,
4879 section_help=_COMPARE_SCANPATH_HELP[idx]
4880 + (
4881 " Line and fill colors are its own; the opacities are shared."
4882 if idx == 0
4883 else ""
4884 ),
4885 caption_help=_layer_gate(False, line_help)[1],
4886 section_share=_COMPARE_SECTION_SHARE,
4887 ).columns(_COLOR_OPACITY_W, gap=_LABEL_GAP, vertical_alignment="center")
4888 _compare_follow_color_picker(
4889 line_col,
4890 idx,
4891 "box",
4892 follow=_compare_fix_color(idx),
4893 help=line_help,
4894 what="word box line",
4895 )
4896 this_fill_help = f"{name}'s word-box fill color. {fill_help}"
4897 fill_col, fill_opacity_col = _sub_row(
4898 "Fill", caption_help=_layer_gate(False, this_fill_help)[1]
4899 ).columns(_COLOR_OPACITY_W, gap=_LABEL_GAP, vertical_alignment="center")
4900 _compare_follow_color_picker(
4901 fill_col,
4902 idx,
4903 "box_fill",
4904 follow=figure_fill,
4905 help=this_fill_help,
4906 what="word box fill",
4907 )
4908 # One outline and one fill opacity for both readings, on A's rows.
4909 if idx == 0:
4910 _box_opacity(
4911 line_opacity_col,
4912 key="global_word_box_line_opacity",
4913 persist_state="session",
4914 label="Line opacity",
4915 help=f"{_LINE_OPACITY_HELP} Applies to both scanpaths' outlines.",
4916 )
4917 _box_opacity(
4918 fill_opacity_col,
4919 key="global_word_box_fill_opacity",
4920 persist_state="session",
4921 label="Fill opacity",
4922 help=f"{_FILL_OPACITY_HELP} Applies to both scanpaths' fills.",
4923 )
4926def _compare_heatmap_colorscale_row(
4927 idx: int, *, disabled: bool, reason: str | None
4928) -> None:
4929 """One scanpath's group title and heatmap *Colors* row, for the comparison:
4930 ``cmp{idx}_heatmap_colorscale``, the figure's colour scale until one is
4931 picked. The metric, scaling and range above are shared, so A and B stay on
4932 one scale; two different colour scales get a colour bar each.
4934 Keyless, like the figure's own colour-scale picker (`_popover_selectbox`),
4935 and a shadow like `_compare_follow_color_picker`: it shows the scale drawn,
4936 and only a pick writes the override."""
4937 name = _COMPARE_SCANPATHS[idx][1]
4938 key = f"cmp{idx}_heatmap_colorscale"
4939 follow = (
4940 st.session_state.get("global_heatmap_colorscale") or DEFAULT_HEATMAP_COLORSCALE
4941 )
4942 shown = st.session_state.get(key) or follow
4943 disabled, tip = _layer_gate(
4944 disabled,
4945 _gated_help(
4946 f"{name}'s heatmap color scale, on the range both share. Defaults to "
4947 "the figure's.",
4948 reason,
4949 ),
4950 )
4951 picked = _sub_row(
4952 "Colors",
4953 section=name,
4954 section_help=_COMPARE_SCANPATH_HELP[idx]
4955 + (" Its color scale is its own; the rest is shared." if idx == 0 else ""),
4956 caption_help=tip,
4957 section_share=_COMPARE_SECTION_SHARE,
4958 ).selectbox(
4959 f"{name} — heatmap colors",
4960 COLORSCALES,
4961 index=COLORSCALES.index(shown) if shown in COLORSCALES else 0,
4962 disabled=disabled,
4963 help=tip,
4964 label_visibility="collapsed",
4965 )
4966 # A scale the picker cannot show (an API-only name) is left alone.
4967 if not disabled and shown in COLORSCALES and picked != shown:
4968 st.session_state[key] = "" if picked == follow else picked
4971def _compare_raw_gaze_color_row(idx: int, *, disabled: bool) -> None:
4972 """One scanpath's group title and raw-gaze *Color* row, for the comparison:
4973 its samples' colour, ``cmp{idx}_raw_gaze_color`` — its fixation colour
4974 until one is picked. Scanpath A's group goes on with the shared *Size* and
4975 *Opacity* rows."""
4976 name = _COMPARE_SCANPATHS[idx][1]
4977 help_text = f"{name}'s raw-gaze sample color. Defaults to its fixation color."
4978 _compare_follow_color_picker(
4979 _sub_row(
4980 "Color",
4981 section=name,
4982 section_help=_COMPARE_SCANPATH_HELP[idx]
4983 + (" Color is its own; size and opacity are shared." if idx == 0 else ""),
4984 caption_help=_layer_gate(disabled, help_text)[1],
4985 section_share=_COMPARE_SECTION_SHARE,
4986 ),
4987 idx,
4988 "raw_gaze",
4989 follow=_compare_fix_color(idx),
4990 help=help_text,
4991 what="raw gaze",
4992 disabled=disabled,
4993 )
4996def _collect_compare_styles() -> tuple[dict, dict]:
4997 """Build the ``(style_a, style_b)`` dicts the comparison figure consumes from
4998 the ``cmp{idx}_*`` session keys (rendered under each layer's popover)."""
4999 styles: list[dict] = []
5000 for idx, _ in _COMPARE_SCANPATHS:
5001 default_color = compare_palette_color(idx)
5002 styles.append(
5003 dict(
5004 # `or default_color` so a falsy ("" / None) value can never escape
5005 # as a colour (defensive against the color-picker black desync).
5006 fix_color=st.session_state.get(f"cmp{idx}_fix_color") or default_color,
5007 saccade_color=(
5008 st.session_state.get(f"cmp{idx}_saccade_color") or default_color
5009 ),
5010 saccade_style=SACCADE_DASH_OPTIONS.get(
5011 st.session_state.get(f"cmp{idx}_saccade_style", "Solid"), "solid"
5012 ),
5013 saccade_width=float(
5014 st.session_state.get(
5015 f"cmp{idx}_saccade_width", DEFAULT_SACCADE_WIDTH
5016 )
5017 ),
5018 marker_size_range=tuple(
5019 st.session_state.get(
5020 f"cmp{idx}_marker_size_range", DEFAULT_MARKER_SIZE_RANGE
5021 )
5022 ),
5023 hollow=bool(st.session_state.get(f"cmp{idx}_hollow", False)),
5024 opacity=float(st.session_state.get(f"cmp{idx}_opacity", 1.0)),
5025 # None (no override) is dropped by the builder, which then
5026 # outlines the boxes in `fix_color`.
5027 box_color=st.session_state.get(f"cmp{idx}_box_color") or None,
5028 # Likewise: None fills with the figure's `word_box_fill_color`.
5029 box_fill_color=(
5030 st.session_state.get(f"cmp{idx}_box_fill_color") or None
5031 ),
5032 # None colours the samples in `fix_color`.
5033 raw_gaze_color=(
5034 st.session_state.get(f"cmp{idx}_raw_gaze_color") or None
5035 ),
5036 # None draws the heatmap in the figure's colour scale.
5037 heatmap_colorscale=(
5038 st.session_state.get(f"cmp{idx}_heatmap_colorscale") or None
5039 ),
5040 )
5041 )
5042 # CMP-24: B draws under its own filters. A's style names none, so the
5043 # builders give A the figure's — the rail's ordinary filters.
5044 styles[1].update(compare_b_filters())
5045 return styles[0], styles[1]
5048def _ordered_saccade_classes(key: str) -> list[str]:
5049 """A saccade-class multiselect's value in ``SACCADE_CLASS_ORDER`` (VIZ-31) —
5050 a cleared one reads as every class, i.e. no filter."""
5051 chosen = set(st.session_state.get(key) or SACCADE_CLASS_ORDER)
5052 return [cls_name for cls_name in SACCADE_CLASS_ORDER if cls_name in chosen]
5055def compare_b_filters() -> dict:
5056 """Scanpath B's filters, as the comparison builders read them off its style
5057 (CMP-24) — ``plots.COMPARE_FILTER_STYLE_KEYS``."""
5058 return dict(
5059 fixation_flags=_collect_fixation_flags("cmp1"),
5060 saccade_classes=_ordered_saccade_classes("cmp1_saccade_classes"),
5061 )
5064def render_compare_filters(host, compare_fixations: pd.DataFrame | None) -> None:
5065 """Scanpath B's half of the Filters & highlights section (CMP-24).
5067 Rendered into the slot ``render_plot_controls`` reserved under A's filters —
5068 after the rail, because B is picked (and its fixations loaded) below it. The
5069 same three filters A has: the fixation-index window, the short / long /
5070 out-of-bounds / blink flags, and which saccade classes are drawn. Every value
5071 rides B's own keys (``session_keys.COMPARE_B_FILTER_STATE_KEYS`` and
5072 ``single_compare_fix_range``), so the two readings are filtered apart.
5073 """
5074 if host is None:
5075 return
5076 animating = bool(st.session_state.get("_resolved_animating"))
5077 with host:
5078 with (
5079 _rail_subsection(
5080 st,
5081 f"{ICONS['fixations']} Fixations · B{_fixation_filter_badge('cmp1')}",
5082 ),
5083 _popover_rows("filter_fix_b"),
5084 ):
5085 _render_fix_range_slider(
5086 compare_fixations,
5087 key="single_compare_fix_range",
5088 trial_state_key="_compare_fix_range_trial",
5089 all_trials_key=None,
5090 )
5091 _render_fixation_cleaning(prefix="cmp1")
5092 _cls_dis, _cls_reason = _mode_gate(animating, True, in_animation=False)
5093 with (
5094 _rail_subsection(
5095 st,
5096 f"{ICONS['saccades']} Saccades · B"
5097 f"{_saccade_filter_badge('cmp1_saccade_classes')}",
5098 note=_cls_reason,
5099 ),
5100 _popover_rows("filter_sac_b"),
5101 ):
5102 _labeled(
5103 st,
5104 "multiselect",
5105 "Show saccade types · B",
5106 display="Types",
5107 options=SACCADE_CLASS_ORDER,
5108 format_func=lambda cls: SACCADE_CLASS_LABELS[cls],
5109 key="cmp1_saccade_classes",
5110 persist_state="session",
5111 disabled=_cls_dis,
5112 help=_gated_help(
5113 "The saccade classes scanpath B draws. An empty list draws all.",
5114 _cls_reason,
5115 ),
5116 )
5119def _fix_range_bounds(fixations: pd.DataFrame | None) -> tuple[int, int]:
5120 """Lowest and highest fixation index in ``fixations`` (``(0, 0)`` when none).
5122 Both bounds come from the *displayed* frame, which is what makes the slider
5123 correct on a multipart trial: ``order_in_trial`` there is the PARENT-GLOBAL
5124 index, so a later screen runs e.g. 509-578, not 1-70. Assuming a floor of 1
5125 (BUG-47) both put indices the screen does not contain inside the slider's
5126 reach and left the untouched default ``(1, max)`` unequal to the frame's own
5127 range, which stamped every screen after the first with the Illustration
5128 "fixation subset" disclosure.
5129 """
5130 if (
5131 fixations is None
5132 or fixations.empty
5133 or "order_in_trial" not in fixations.columns
5134 ):
5135 return (0, 0)
5136 order = pd.to_numeric(fixations["order_in_trial"], errors="coerce").dropna()
5137 if order.empty:
5138 return (0, 0)
5139 return (int(order.min()), int(order.max()))
5142def _fix_range_trial_key(fixations: pd.DataFrame | None) -> tuple | None:
5143 """Identity of the trial the window slider is sizing, or ``None`` if unclear.
5145 Used only to notice a *trial change*; a frame that isn't a single trial (or
5146 carries no identity columns) returns ``None``, which is treated as "don't
5147 reset" so an ambiguous frame never silently drops the user's window.
5148 """
5149 if fixations is None or fixations.empty:
5150 return None
5151 parts = []
5152 # BUG-47: a multipart trial's screens are separate readings sharing one
5153 # parent-global index axis, so "fixations 5-20" means as little across two
5154 # screens as it does across two trials -- the screen is part of the identity.
5155 for col in ("participant_id", "trial_id", "screen_id"):
5156 if col not in fixations.columns:
5157 continue
5158 values = fixations[col].dropna().unique()
5159 if len(values) != 1:
5160 return None
5161 parts.append(str(values[0]))
5162 return tuple(parts) or None
5165def _render_fix_range_slider(
5166 fixations: pd.DataFrame | None,
5167 *,
5168 key: str = "single_fix_range",
5169 trial_state_key: str = "_fix_range_trial",
5170 all_trials_key: str | None = "single_fix_range_all_trials",
5171) -> None:
5172 """Render the VIZ-7 fixation-index window slider (``single_fix_range``).
5174 The slider value persists across trial changes (which shift the bounds), so
5175 it is seeded/clamped via session_state *only* (no ``value=`` arg) to stay
5176 inside ``[min_fix, max_fix]`` — a stored out-of-range value would otherwise
5177 raise. **Both** bounds come from the displayed frame (BUG-47): on a multipart
5178 trial ``order_in_trial`` is parent-global, so a later screen runs 509-578 and
5179 a hard-coded floor of 1 would offer indices that screen does not hold and
5180 leave the untouched default unequal to the frame's full range — which the
5181 Illustration policy reads as a deliberate "fixation subset".
5182 This is the single fixation-index control for the app; the Comparisons subtab
5183 deliberately has none of its own (ENG-8). A frame with fewer than two
5184 fixations can't host a range slider (a one-value slider throws in the
5185 browser), so the window is cleared to ``None`` (the full, unsliced trial).
5187 **Scope.** A window is per-trial by default: picking another trial shows all
5188 of that trial's fixations again, because a range like "fixations 5–20" rarely
5189 means the same thing on a different reading. A multipart trial's *screens*
5190 count as different readings here too (BUG-47). The *Apply to all trials*
5191 checkbox opts into the sticky behaviour (the window is re-applied to every
5192 trial, clamped to each one's length).
5194 The checkbox is deliberately **UI-only** state (cf. ``share_identity_mode``),
5195 because what it governs — what happens to the window when you select a
5196 *different* trial — has no referent on the other three surfaces: a share link,
5197 a ``render`` invocation and an ``api.plot_scanpath`` call each address one
5198 explicit trial, and ``api``'s ``fix_index_range`` is a per-call argument
5199 rather than sticky state. If it is ever persisted into a saved config it must
5200 be added to ``session_keys.PLOT_CONFIG_STATE_KEYS``.
5202 ``single_fix_range`` itself now reaches all four surfaces (VIZ-40 closed the
5203 last gap with the ``?fix_range=lo,hi`` param; ``render --fix-index-range``
5204 and ``api``'s ``fix_index_range`` were already there). The link carries it
5205 only when the ``single_fix_range_user_set`` flag above says the window was
5206 *chosen* — see the ``FIX_RANGE_PARAM`` block in
5207 ``url_state._build_share_query`` — because the untouched default is the
5208 trial's own full range and would otherwise re-window every recipient.
5209 ``tabs._build_studio_config`` still does not write one: a saved config is
5210 restored onto whatever trial is open, where a fixation window means even
5211 less than it does on a link.
5213 ``single_fix_range_user_set`` itself is deliberately **not** wire format,
5214 for the same reason as ``share_identity_mode`` and the checkbox above: it
5215 records that *this* user moved *this* slider, which has no referent on the
5216 other three surfaces, and a recipient re-derives it here anyway (the
5217 ``setdefault`` in the explicit-value branch below). It is nonetheless
5218 load-bearing for the ``fix_range`` emission, so if it is ever persisted into
5219 a saved config it must be added to ``session_keys.PLOT_CONFIG_STATE_KEYS``.
5220 """
5221 if fixations is None:
5222 return
5223 # CMP-24: the same slider serves scanpath B under its own keys; B's window is
5224 # always per-trial (no *All trials* box — B's trial moves with its picker).
5225 user_key = f"{key}_user_set"
5226 min_fix, max_fix = _fix_range_bounds(fixations)
5227 if max_fix < 1 or min_fix >= max_fix:
5228 # Nothing meaningful to window — clear any stale stored range so the
5229 # plot isn't filtered by a window the slider can no longer show. The
5230 # `min_fix >= max_fix` half is the single-fixation frame: a one-value
5231 # range slider throws in the browser, and on a multipart screen that
5232 # frame's lone index is 509, not 1 (BUG-47).
5233 if st.session_state.get(key) is not None:
5234 st.session_state[key] = None
5235 st.session_state[user_key] = False
5236 return
5237 # Notice a trial change *before* resolving the stored window: in per-trial
5238 # mode, un-freezing the window is what makes the `user_set is False` branch
5239 # below expand it to the new trial's full range.
5240 all_trials = bool(all_trials_key and st.session_state.get(all_trials_key, False))
5241 trial_key = _fix_range_trial_key(fixations)
5242 if trial_key is not None:
5243 previous = st.session_state.get(trial_state_key)
5244 st.session_state[trial_state_key] = trial_key
5245 if previous is not None and previous != trial_key and not all_trials:
5246 st.session_state[user_key] = False
5247 stored = st.session_state.get(key)
5248 user_set = st.session_state.get(user_key)
5250 def _reset_to_full() -> None:
5251 st.session_state[key] = (min_fix, max_fix)
5253 if stored is None:
5254 _reset_to_full()
5255 st.session_state[user_key] = False
5256 elif user_set is False:
5257 # BUG-16: an untouched auto-default follows the selected trial and always
5258 # expands to its full range — which is the frame's OWN range, floor
5259 # included, so that an untouched window equals the full range on a later
5260 # multipart screen too and no Illustration disclosure fires (BUG-47).
5261 _reset_to_full()
5262 elif isinstance(stored, (tuple, list)) and len(stored) == 2:
5263 # A value supplied before this widget first renders (test seam, restored
5264 # session, or future deep link) is explicit and should be preserved.
5265 st.session_state.setdefault(user_key, True)
5266 lo = max(min_fix, min(int(stored[0]), max_fix))
5267 hi = max(lo, min(int(stored[1]), max_fix))
5268 st.session_state[key] = (lo, hi)
5269 else:
5270 _reset_to_full()
5271 st.session_state[user_key] = False
5273 # The slider sits in the 🧹 Filter popover. Once that has been open, the
5274 # browser sends the window it last showed back on every rerun (#374 F9), so
5275 # a window this run changed by itself — a new trial's full range, a clamp, a
5276 # reset, a link — was undone on the next rerun, clamped to the new trial and
5277 # silently hid most of its fixations. So `key` holds the window and the
5278 # widgets draw it under a key of their own, which moves to a new generation
5279 # whenever the window changed without them: a fresh widget has nothing old
5280 # to send back.
5281 gen_key = f"_{key}_widget_gen"
5282 generation = int(st.session_state.get(gen_key) or 0)
5283 widget_key = f"_{key}__w{generation}"
5284 window = tuple(st.session_state[key])
5285 shown = st.session_state.get(widget_key)
5286 if shown is None or tuple(shown) != window:
5287 for stale in (widget_key, f"{widget_key}__num_lo", f"{widget_key}__num_hi"):
5288 st.session_state.pop(stale, None)
5289 generation += 1
5290 st.session_state[gen_key] = generation
5291 widget_key = f"_{key}__w{generation}"
5292 st.session_state[widget_key] = window
5294 def _mark_fix_range_user_set() -> None:
5295 st.session_state[key] = tuple(st.session_state[widget_key])
5296 st.session_state[user_key] = True
5298 all_trials_disabled, _ = _layer_gate(False, None)
5300 # UX-162: *All trials* sits on the range's own line, ahead of its slider,
5301 # the way a colour range's *Auto* does (UX-157); its explanation joins the
5302 # row title's tooltip. Seeded via `_VIZ_WIDGET_DEFAULTS`, so no `value=`.
5303 def _all_trials(col) -> None:
5304 col.checkbox(
5305 "All trials",
5306 key=all_trials_key,
5307 persist_state="session",
5308 disabled=all_trials_disabled,
5309 )
5311 _range_slider(
5312 st,
5313 "Fixation index range"
5314 if key == "single_fix_range"
5315 else "B fixation index range",
5316 display="Index range",
5317 label_left=True,
5318 key=widget_key,
5319 persist_state="session",
5320 min_value=min_fix,
5321 max_value=max_fix,
5322 on_change=_mark_fix_range_user_set,
5323 help="Draw only the fixations whose index is in this range. All trials: "
5324 "keep the window when you move to another trial. In Compare, this is "
5325 "scanpath A's window.",
5326 lead=_all_trials if all_trials_key else None,
5327 )
5330def _seed_viz_state(
5331 trial_fixations: pd.DataFrame,
5332 base_font_size: int,
5333 words: pd.DataFrame | None,
5334) -> tuple[list[str], list[str], list[str]]:
5335 """Seed every viz widget's session_state default (pure — renders nothing).
5337 Both ``render_plot_controls`` (which renders the widgets) and
5338 ``viz_settings_from_state`` (the non-rendering reader used by the Corpus view
5339 and the Save & restore panel) call this first, so the controls and their
5340 consumers can't drift. The widgets render WITHOUT a ``value=``/``index=``
5341 argument and rely on these defaults, which keeps their keys programmatically
5342 settable (deep links / plot-config restore) without Streamlit's "default
5343 value but also set via Session State API" warning.
5345 Seeding only — keeping a stored value alive through a run where its widget
5346 doesn't render is the widgets' own ``persist_state="session"`` (ENG-36), so
5347 this is safe to call both before rendering (``render_plot_controls``) and after
5348 (``app.main`` re-reads the settings once the rail has rendered, where writing
5349 a widget key would raise). Returns ``(color_fields, numeric_fields,
5350 highlight_options)`` for the caller to reuse.
5351 """
5352 for _key, _default in _VIZ_WIDGET_DEFAULTS.items():
5353 _pin(_key, _default)
5354 _pin("global_marker_size_range", (8, 24))
5355 _seed_compare_styles()
5357 color_fields = color_field_options(trial_fixations)
5358 _drop_stale("global_color_by", color_fields)
5359 # VIZ-17: one flat colour by default — see `color_field_options`.
5360 _pin("global_color_by", UNIFORM_COLOR_FIELD)
5362 numeric_fields = numeric_field_options(trial_fixations)
5363 if numeric_fields:
5364 x_default = "x" if "x" in numeric_fields else numeric_fields[0]
5365 y_default = (
5366 "y"
5367 if "y" in numeric_fields
5368 else numeric_fields[min(1, len(numeric_fields) - 1)]
5369 )
5370 _drop_stale("global_x_field", numeric_fields)
5371 _pin("global_x_field", x_default)
5372 _drop_stale("global_y_field", numeric_fields)
5373 _pin("global_y_field", y_default)
5375 # Highlight-column default + stale-clear run every time (even when the Text
5376 # styling popover isn't rendered this run) so a restored config on data with
5377 # no boolean columns can't carry a dangling pick.
5378 highlight_options = highlight_column_options(words)
5379 _drop_stale("global_highlight_column", highlight_options)
5380 # A column the app seeded is re-derived for each dataset; only the user's own
5381 # pick survives a switch. #374 F6: only the bundled demo is seeded (its
5382 # answer span); any other dataset opens with nothing highlighted, rather
5383 # than with whichever yes/no column came first (IA_SKIP on EyeLink data).
5384 ss = st.session_state
5385 seeded = (
5386 "is_in_aspan"
5387 if "is_in_aspan" in highlight_options and current_dataset_name() == DEMO_CHOICE
5388 else None
5389 )
5390 current = ss.get("global_highlight_column")
5391 if current not in (None, seeded) and current == ss.get(_HIGHLIGHT_SEEDED_KEY):
5392 ss.pop("global_highlight_column", None)
5393 if seeded is not None:
5394 if "global_highlight_column" not in ss:
5395 ss[_HIGHLIGHT_SEEDED_KEY] = seeded
5396 _pin("global_highlight_column", seeded)
5398 # VIZ-26: arbitrary multi-field word/fixation hover. The legacy one-measure
5399 # key remains as a fallback for old links/configs, but new surfaces write the
5400 # explicit lists.
5401 word_hover_options = hover_field_options(words, words=True)
5402 fix_hover_options = hover_field_options(trial_fixations)
5403 _drop_stale_multi("global_word_hover_fields", word_hover_options)
5404 _drop_stale_multi("global_fixation_hover_fields", fix_hover_options)
5405 if "global_word_hover_fields" not in st.session_state:
5406 legacy = st.session_state.get(
5407 "global_word_hover_measure", "total_fixation_duration_ms"
5408 )
5409 default_word_hover = ["text", "word_id", "line_idx"]
5410 if legacy:
5411 default_word_hover.append(legacy)
5412 _pin(
5413 "global_word_hover_fields",
5414 [field for field in default_word_hover if field in word_hover_options],
5415 )
5416 if "global_fixation_hover_fields" not in st.session_state:
5417 _pin(
5418 "global_fixation_hover_fields",
5419 [
5420 field
5421 for field in ("order_in_trial", "duration_ms", "word_id")
5422 if field in fix_hover_options
5423 ],
5424 )
5425 return color_fields, numeric_fields, highlight_options
5428#: What each Legends row places (📐 Figure & canvas → Legends).
5429_LEGEND_ROW_HELP = {
5430 "compare": "The A/B legend naming the two scanpaths (Compare's *Legend*).",
5431 "saccades": "The saccade-type legend (↗️ Saccades → Color by type → Legend).",
5432 "colors": "The legend of a categorical Color by, and the Highlight entries.",
5433 "size_key": "The duration size key (👁️ Fixations → Size key). Its circles "
5434 "keep the true marker sizes; Size sets its labels.",
5435}
5438def _collect_legend_layout(ss) -> dict | None:
5439 """The legend placements set under 📐 Figure & canvas → Legends.
5441 Only the legends moved off *Auto* are listed; ``None`` when none is, which
5442 every builder reads as "as it always drew". A stale value a link or an old
5443 config left behind falls back to *Auto* rather than failing the figure.
5444 """
5445 layout = {}
5446 for kind in LEGEND_KINDS:
5447 position = ss.get(f"global_legend_{kind}_position") or "auto"
5448 arrangement = ss.get(f"global_legend_{kind}_arrangement") or "auto"
5449 size = ss.get(f"global_legend_{kind}_size")
5450 if position not in LEGEND_POSITION_LABELS:
5451 position = "auto"
5452 if arrangement not in LEGEND_ARRANGEMENT_LABELS:
5453 arrangement = "auto"
5454 try:
5455 size = int(size) if size else None
5456 except (TypeError, ValueError):
5457 size = None
5458 if position != "auto" or arrangement != "auto" or size:
5459 layout[kind] = {
5460 "position": position,
5461 "arrangement": arrangement,
5462 "size": size,
5463 }
5464 return layout or None
5467def _collect_viz_settings(
5468 trial_fixations: pd.DataFrame,
5469 words: pd.DataFrame | None,
5470 *,
5471 numeric_fields: list[str] | None = None,
5472 highlight_options: list[str] | None = None,
5473) -> dict:
5474 """Build the viz-settings dict from session_state (pure — renders nothing).
5476 The single source of truth for the dict the figure builders consume, so the
5477 rendered controls (``render_plot_controls``) and the non-rendering reader
5478 (``viz_settings_from_state``) return identical shapes. Conditionally-applied
5479 fields (colour ranges, the highlight column, saccade arrows) are gated here
5480 exactly as the widgets gate them, so a stored value for an off layer doesn't
5481 leak into the figure. ``compare_style_a``/``_b`` are ``None``; the rendering
5482 path fills them in when the comparison toggle is on.
5483 """
5484 ss = st.session_state
5485 if highlight_options is None:
5486 highlight_options = highlight_column_options(words)
5488 show_fix = bool(ss.get("global_show_fix"))
5489 show_saccades = bool(ss.get("global_show_saccades"))
5490 show_heatmap = bool(ss.get("global_show_heatmap"))
5491 # UX-128: the 📄 Stimulus section's master switch — ANDed into its two
5492 # layers' *effective* values below, rather than read by the figure
5493 # builders directly, so it stays a pure display gate: `global_show_labels`/
5494 # `global_show_stimulus_image` still hold whatever the user configured, for
5495 # the moment this is turned back on. Word boxes left the section for one of
5496 # their own, so this switch no longer gates them.
5497 show_stimulus = bool(ss.get("global_show_stimulus", True))
5498 show_labels = show_stimulus and bool(ss.get("global_show_labels"))
5499 color_by = ss.get("global_color_by")
5501 # Fixation colour range only applies when fixations are shown AND coloured by
5502 # a numeric column with a valid spread — mirror the widget's gate. A range
5503 # the user never set is ABSENT, not seeded (VIZ-46), so `None` reaches the
5504 # builders and each trial is scaled to its own values — the API's own rule.
5505 fixation_color_range = None
5506 if (
5507 show_fix
5508 and color_by in trial_fixations.columns
5509 and pd.api.types.is_numeric_dtype(trial_fixations[color_by])
5510 ):
5511 cmin, cmax = trial_fixations[color_by].min(), trial_fixations[color_by].max()
5512 if pd.notna(cmin) and pd.notna(cmax):
5513 # Passed through as stored, not clamped to this pool's span: a
5514 # pinned scale must not move when a filter narrows the pool.
5515 fixation_color_range = _explicit_pair(ss.get("global_fixation_color_range"))
5517 # Heatmap colour range only applies for the duration-weighted heatmap —
5518 # over fixations, or a words-only dataset's own dwell column.
5519 heatmap_range = None
5520 if show_heatmap and ss.get("global_heatmap_metric") == "duration_ms":
5521 heatmap_range = _explicit_pair(ss.get("global_heatmap_color_range"))
5523 # Fixation-index window (VIZ-7): a (start, end) tuple over `order_in_trial`,
5524 # or None for the full trial. Read straight from the slider's session key;
5525 # the rendering path clamps it to the trial's fixation count, and the
5526 # non-rendering Corpus reader simply leaves it None (it never windows).
5527 fix_index_range = None
5528 _fr = ss.get("single_fix_range")
5529 if isinstance(_fr, (tuple, list)) and len(_fr) == 2:
5530 fix_index_range = (int(_fr[0]), int(_fr[1]))
5532 # The highlight column applies only while its style has something to draw on:
5533 # **Mark text** recolours the word labels, so it needs Text; **Mark border**
5534 # is its own outline layer (independent of Text and Word boxes, as in the
5535 # builder), so it needs only the 📄 Stimulus master switch.
5536 critical_span_style = ss.get("global_critical_span_style", "Mark text")
5537 span_drawable = (
5538 show_labels if critical_span_style == "Mark text" else show_stimulus
5539 ) and critical_span_style in ("Mark text", "Mark border")
5540 highlight_column = None
5541 if span_drawable and highlight_options:
5542 candidate = ss.get("global_highlight_column")
5543 highlight_column = candidate if candidate in highlight_options else None
5545 # Background colour comes from the Experimental Setup picker (read here so it
5546 # flows into the figure via viz_settings).
5547 bg_options = list(BACKGROUND_PRESETS.keys()) + ["Custom…"]
5548 bg_choice = ss.get("global_bg_choice", bg_options[0])
5549 if bg_choice == "Custom…":
5550 background_color = ss.get("global_bg_custom", DEFAULT_BACKGROUND_COLOR)
5551 else:
5552 background_color = BACKGROUND_PRESETS.get(
5553 bg_choice, BACKGROUND_PRESETS[bg_options[0]]
5554 )
5556 return dict(
5557 show_words=bool(ss.get("global_show_words")),
5558 word_box_color=ss.get("global_word_box_color") or WORD_BOX_COLOR,
5559 word_box_line_opacity=float(
5560 ss.get("global_word_box_line_opacity", WORD_BOX_LINE_OPACITY)
5561 ),
5562 word_box_fill_color=ss.get("global_word_box_fill_color") or WORD_BOX_FILL_COLOR,
5563 word_box_fill_opacity=float(
5564 ss.get("global_word_box_fill_opacity", WORD_BOX_FILL_OPACITY)
5565 ),
5566 show_labels=show_labels,
5567 show_fix=show_fix,
5568 show_order=bool(ss.get("global_show_order")),
5569 show_saccades=show_saccades,
5570 # Arrows are a saccade sub-layer: never report them on when saccades off.
5571 show_saccade_arrows=bool(ss.get("global_show_saccade_arrows"))
5572 and show_saccades,
5573 show_heatmap=show_heatmap,
5574 # `or default` (not get-default) so a segmented_control deselect → None
5575 # falls back instead of propagating None into the figure builders.
5576 heatmap_style=ss.get("global_heatmap_style") or "Word boxes",
5577 heatmap_norm=ss.get("global_heatmap_norm") or "Linear",
5578 heatmap_sigma_px=None
5579 if ss.get("global_heatmap_sigma_auto", True)
5580 else float(ss.get("global_heatmap_sigma_px", DEFAULT_HEATMAP_SIGMA_PX)),
5581 show_raw_gaze=bool(ss.get("global_show_raw_gaze")),
5582 # UX-86: raw gaze's own style.
5583 raw_gaze_color=ss.get("global_raw_gaze_color") or "#888888",
5584 raw_gaze_marker_size=float(ss.get("global_raw_gaze_marker_size", 4.0)),
5585 raw_gaze_opacity=float(ss.get("global_raw_gaze_opacity", 0.6)),
5586 show_stimulus_image=show_stimulus
5587 and bool(ss.get("global_show_stimulus_image")),
5588 # VIZ-4: image-stimulus opacity (applies to dataset + uploaded images) and
5589 # the uploaded image's data URI (session-only; set by render_plot_controls).
5590 stimulus_image_opacity=float(ss.get("global_stimulus_image_opacity", 1.0)),
5591 stimulus_image_upload_uri=ss.get("_stimulus_image_upload_uri"),
5592 # VIZ-4: manual image alignment (origin nudge + size scale).
5593 stimulus_image_offset_x=float(ss.get("global_stimulus_image_offset_x", 0.0)),
5594 stimulus_image_offset_y=float(ss.get("global_stimulus_image_offset_y", 0.0)),
5595 stimulus_image_scale=float(ss.get("global_stimulus_image_scale", 1.0)),
5596 color_by=color_by,
5597 heatmap_metric=ss.get("global_heatmap_metric") or "duration_ms",
5598 x_field=ss.get("global_x_field"),
5599 y_field=ss.get("global_y_field"),
5600 marker_size_range=tuple(ss.get("global_marker_size_range", (8, 24))),
5601 marker_size_scale=(
5602 ss.get("global_marker_size_scale") or DEFAULT_MARKER_SIZE_SCALE
5603 ),
5604 marker_duration_range=tuple(
5605 ss.get("global_marker_duration_range") or DEFAULT_MARKER_DURATION_RANGE
5606 ),
5607 duration_size_legend=bool(ss.get("global_duration_size_legend", True)),
5608 legend_layout=_collect_legend_layout(ss),
5609 order_font_size=ss.get("global_order_font_size"),
5610 order_font_color=ss.get("global_order_font_color"),
5611 **{
5612 f"show_{bar}_colorbar": bool(ss.get(f"global_show_{bar}_colorbar"))
5613 for bar in ("fixation", "heatmap")
5614 },
5615 fit_to_monitor=bool(ss.get("global_fit_to_monitor")),
5616 show_coordinate_grid=bool(ss.get("global_show_coordinate_grid")),
5617 coordinate_grid_auto=bool(ss.get("global_coordinate_grid_auto", True)),
5618 coordinate_grid_spacing=(
5619 None
5620 if bool(ss.get("global_coordinate_grid_auto", True))
5621 else float(ss.get("global_coordinate_grid_spacing", 100.0))
5622 ),
5623 fixation_color_range=fixation_color_range,
5624 heatmap_range=heatmap_range,
5625 fixation_colorscale=ss.get("global_fixation_colorscale")
5626 or DEFAULT_FIXATION_COLORSCALE,
5627 heatmap_colorscale=ss.get("global_heatmap_colorscale")
5628 or DEFAULT_HEATMAP_COLORSCALE,
5629 critical_span_style=critical_span_style,
5630 highlight_column=highlight_column,
5631 saccade_color=ss.get("global_saccade_color", SACCADE_COLOR),
5632 saccade_style=ss.get("global_saccade_style") or "Solid",
5633 saccade_width=float(ss.get("global_saccade_width") or DEFAULT_SACCADE_WIDTH),
5634 # VIZ-8: colour-by-reading-type mode + the per-class palette + optional
5635 # colour-key legend.
5636 saccade_color_mode=ss.get("global_saccade_color_mode") or "Uniform",
5637 saccade_type_legend=bool(ss.get("global_saccade_type_legend", True)),
5638 saccade_class_colors={
5639 cls_name: ss.get(
5640 f"global_saccade_class_color_{cls_name}", SACCADE_CLASS_COLORS[cls_name]
5641 )
5642 for cls_name in SACCADE_CLASS_EDITABLE
5643 },
5644 # VIZ-31: the reading-class filter. Ordered by SACCADE_CLASS_ORDER (not by
5645 # click order) so the same selection always produces the same figure key,
5646 # and unknown names are dropped — a stale link must not smuggle a class
5647 # the build no longer classifies into the builder. An *empty* selection
5648 # reads as "no filter", not "draw nothing": hiding the layer entirely is
5649 # what the Saccades toggle above the filter is for, and a cleared
5650 # multiselect that blanks the figure reads as a bug.
5651 saccade_classes=[
5652 cls_name
5653 for cls_name in SACCADE_CLASS_ORDER
5654 if cls_name in set(ss.get("global_saccade_classes") or SACCADE_CLASS_ORDER)
5655 ],
5656 # VIZ-9: linear-reading mode (arced saccades + snap fixations above words).
5657 saccade_render_mode=ss.get("global_saccade_render_mode") or "Straight",
5658 fixation_snap_to_word=bool(ss.get("global_fixation_snap_to_word")),
5659 illustration_label=ss.get("global_illustration_label") or "Auto",
5660 illustration_text=str(ss.get("global_illustration_text") or ""),
5661 # VIZ-10: autoplay the animated replay on load (default on).
5662 anim_autoplay=bool(ss.get("global_anim_autoplay", True)),
5663 # VIZ-11 follow-up: the animation frame grid (smoothness vs. frame count).
5664 anim_grid_step_ms=float(ss.get("global_anim_grid_step_ms", 100) or 100),
5665 anim_max_frames=int(ss.get("global_anim_max_frames", 360) or 360),
5666 hollow_fixations=bool(ss.get("global_hollow_fixations")),
5667 fixation_opacity=float(ss.get("global_fixation_opacity", 1.0)),
5668 # VIZ-17 uniform fixation colour + VIZ-15 marker shape.
5669 fixation_color=ss.get("global_fixation_color") or DEFAULT_FIXATION_COLOR,
5670 fixation_symbol=ss.get("global_fixation_symbol") or DEFAULT_FIXATION_SYMBOL,
5671 # VIZ-18: the active palette name — *derived* from the colour keys above
5672 # rather than read back from the selector, so a hand-edited figure is
5673 # reported as `Custom` on every surface instead of carrying a palette
5674 # name it no longer matches. The colours themselves ride in the
5675 # individual keys, so `Custom` restores exactly; the name is only there
5676 # for the picker to come back on the right entry and for export captions.
5677 palette=_active_palette() or CUSTOM_PALETTE,
5678 fix_index_range=fix_index_range,
5679 highlight_text_color=ss.get("global_highlight_text_color"),
5680 text_color=ss.get("global_text_color", WORD_LABEL_COLOR),
5681 color_by_line=color_by == "line",
5682 # Fixation classification (PRE-2) + compare-overlay legend (CMP-2).
5683 fixation_flags=_collect_fixation_flags(),
5684 show_compare_legend=bool(ss.get("global_show_compare_legend")),
5685 span_border_color=ss.get("global_span_border_color", "#000000"),
5686 **{
5687 key: value
5688 for bar in ("fixation", "heatmap")
5689 for key, value in (
5690 (
5691 f"{bar}_colorbar_orientation",
5692 ss.get(f"global_{bar}_colorbar_orientation") or "Vertical",
5693 ),
5694 (
5695 f"{bar}_colorbar_tickangle",
5696 int(ss.get(f"global_{bar}_colorbar_tickangle") or 0),
5697 ),
5698 (
5699 f"{bar}_colorbar_tickfont_size",
5700 int(ss.get(f"global_{bar}_colorbar_tickfont_size") or 12),
5701 ),
5702 )
5703 },
5704 background_color=background_color,
5705 compare_style_a=None,
5706 compare_style_b=None,
5707 word_hover_measure=ss.get(
5708 "global_word_hover_measure", "total_fixation_duration_ms"
5709 ),
5710 word_hover_fields=list(ss.get("global_word_hover_fields") or []),
5711 fixation_hover_fields=list(ss.get("global_fixation_hover_fields") or []),
5712 # PRE-3: in-place drift correction. `tabs._drift_corrected` applies it once
5713 # above the render-mode split, so it reaches all three builders (VIZ-23);
5714 # only the connector layer is still static-figure-only.
5715 #
5716 # PRE-21: resolved to "Off" while the feature is gated, rather than each
5717 # consumer gating separately. That is what makes an old share link or
5718 # saved config carrying `align_algorithm=warp` degrade *silently* — the
5719 # setting is read, then ignored, and nothing downstream can disagree.
5720 align_algorithm=(
5721 (ss.get("global_align_algorithm") or "Off")
5722 if drift_correction_enabled()
5723 else "Off"
5724 ),
5725 align_connectors=drift_correction_enabled()
5726 and bool(ss.get("global_align_connectors"))
5727 and (ss.get("global_align_algorithm") or "Off") != "Off",
5728 # EXP-5: empty when the toggle is off, regardless of stored pattern text,
5729 # so turning it off can never leave a stale pattern silently applied.
5730 title_pattern=(
5731 ss.get("global_title_pattern") or "" if ss.get("global_show_title") else ""
5732 ),
5733 caption_pattern=(
5734 ss.get("global_caption_pattern") or ""
5735 if ss.get("global_show_caption")
5736 else ""
5737 ),
5738 )
5741def viz_settings_from_state(
5742 trial_fixations: pd.DataFrame,
5743 base_font_size: int,
5744 words: pd.DataFrame | None = None,
5745) -> dict:
5746 """Resolve the viz-settings dict from session_state WITHOUT rendering widgets.
5748 Used by the views that consume the settings but don't host the controls — the
5749 Corpus Analysis figures and the Save & restore panel — so they stay in sync
5750 with the scanpath rail (which renders the actual widgets via
5751 ``render_plot_controls``) on whatever the user last set.
5752 """
5753 _, numeric_fields, highlight_options = _seed_viz_state(
5754 trial_fixations, base_font_size, words
5755 )
5756 return _collect_viz_settings(
5757 trial_fixations,
5758 words,
5759 numeric_fields=numeric_fields,
5760 highlight_options=highlight_options,
5761 )
5764def corpus_style_controls(
5765 trial_fixations: pd.DataFrame,
5766 base_font_size: int,
5767 *,
5768 words: pd.DataFrame | None = None,
5769 host=None,
5770 canvas_renderer=None,
5771) -> dict:
5772 """Focused, shared-key styling controls for Corpus Analysis (AN-29).
5774 Corpus figures intentionally expose only the palette channels they consume;
5775 the single-scanpath layer controls remain in the Scanpath rail. Because these
5776 widgets write the same ``global_*`` keys, Share/config restore, the CLI
5777 palette, and headless builder parameters stay one contract.
5779 ``canvas_renderer`` renders the canvas / text panel here too (VIZ-31). The
5780 corpus figures are drawn true-to-scale from exactly those values — monitor
5781 size, fonts, line spacing, background — so the view that consumes them needs
5782 a way to change them; before VIZ-31 the panel lived in the always-present
5783 sidebar and was reachable from here for free (there is no sidebar now —
5784 UX-38).
5785 """
5786 _seed_viz_state(trial_fixations, base_font_size, words)
5787 target = host or st
5788 with target.expander(
5789 f"{ICONS['designs']} Corpus figure style", expanded=False
5790 ) as style_panel:
5791 active = _active_palette()
5792 options = list(PALETTES) if active else [CUSTOM_PALETTE, *PALETTES]
5793 st.session_state["global_palette"] = active or CUSTOM_PALETTE
5794 st.selectbox(
5795 "Palette",
5796 options=options,
5797 key="global_palette",
5798 persist_state="session",
5799 on_change=_on_palette_change,
5800 format_func=palette_label,
5801 help="Same palette as the Scanpath view; designs and Share links keep it.",
5802 )
5803 columns = st.columns(2)
5804 columns[0].color_picker(
5805 "Primary series",
5806 key="global_fixation_color",
5807 persist_state="session",
5808 help="First group or profile; also the Scanpath fixation color.",
5809 )
5810 columns[1].color_picker(
5811 "Secondary series",
5812 key="global_saccade_color",
5813 persist_state="session",
5814 help="Second group; also the Scanpath saccade color.",
5815 )
5816 st.selectbox(
5817 "Heatmap color scale",
5818 options=COLORSCALES,
5819 key="global_heatmap_colorscale",
5820 persist_state="session",
5821 help="Used by word matrices and stimulus heatmaps.",
5822 )
5823 # VIZ-31's canvas controls belong to this disclosure too. Rendering
5824 # them after the `with` block left the expander collapsed but every
5825 # monitor/font/background field open across the Corpus page.
5826 if canvas_renderer is not None:
5827 canvas_renderer(style_panel)
5828 return viz_settings_from_state(trial_fixations, base_font_size, words=words)
5831def _rail_section(host, label: str, *, slug: str, name: str | None = None, **toggle):
5832 """One rail section: `[toggle | ▾]` on a single line (UX-80).
5834 The shape #UX-68 gave 🎬 Animate and ⚖️ Compare, applied to every section of
5835 the rail: the layer's switch and the disclosure for its options share a row,
5836 and the options open in a **popover** rather than inside an expander.
5838 That last part is the point, not the tidiness. The rail is ~150–200 px wide
5839 on purpose (the plot is the hero, #UX-51), so an expander lays its controls
5840 out inside that width and crops them; a popover is positioned over the page
5841 and sizes to its content. #UX-74 tried the opposite — inlining the popovers —
5842 and had to be reverted for exactly this.
5844 Passing ``toggle`` kwargs (``key=``, ``disabled=``) draws the switch and
5845 returns its value; the name is the switch's label, so clicking it flips the
5846 switch (UX-153). Omitting them leaves the section's **name** on its own,
5847 for the sections that have no layer to switch: 📐 Figure & canvas holds
5848 none, and Filters & highlights is not a layer at all — there, clicking the name opens
5849 the popover. (📄 Stimulus has a master switch over its three layers since
5850 UX-128.) ``note=`` is a line written into the top of the popover — used for
5851 the ⚠️ that says why a switch is greyed.
5853 Returns ``(value, body)`` — ``value`` is ``None`` for a name-only section.
5854 The ``split_mode_`` key prefix is what `styles.py` styles the row with; it is
5855 shared with the two mode rows above the plot deliberately, because they are
5856 the same control.
5858 **Round 2 (UX-80).** Three things the first cut got wrong, all visible at
5859 once in one screenshot: the row wrapped, the trigger showed two arrows, and
5860 the toggle carried a `?`.
5862 - ``width="content"``, not ``"stretch"`` — a stretched trigger claims the
5863 row's whole width, so the switch and the ▾ could not share a line however
5864 much room the rail had. Animate and Compare had it right.
5865 - **No visible label and no icon on the trigger.** A `▾` label (or a
5866 `:material/arrow_drop_down:` icon) sits *beside* the chevron Streamlit
5867 draws on every popover, which is one arrow too many. Since BUG-108 the
5868 trigger does carry a label, ``name="Fixation"`` → "Fixation settings"
5869 (``label`` without its icon and bold when ``name`` is omitted), for
5870 screen readers; `styles.py` clips it off screen.
5871 - ``help_text`` went to the **popover**, not the toggle, so that a row which
5872 has to fit did not also carry Streamlit's `?` icon. **UX-103 took the
5873 hover text off this row entirely** — see the comments in the body.
5875 **BUG-37 — the popover carries an explicit ``key=``.** ``st.popover`` is a
5876 stateful widget in this Streamlit version (it takes ``key``/``on_change``
5877 like ``st.expander``), and every section here called it with the same empty
5878 label (``""``, until BUG-108 named each) — the only thing distinguishing one from another is
5879 surrounding call order. Without a `key`, Streamlit falls back to a
5880 positional auto-key, and this row sits downstream of several booleans that
5881 change which widgets render (`_mode_gate`'s `disabled=`/`help=`, the
5882 layer toggles themselves) — so the auto-key for a *later* section can shift
5883 between reruns whenever an *earlier* one's rendered shape changes. A
5884 shifted key reads to Streamlit as a brand-new widget, which drops the one
5885 thing this widget tracks: whether it is open. That is what a click "not
5886 working" looked like — the popover opened, a rerun landed before the user
5887 saw it, and the fresh auto-key came back closed. Every OTHER popover in the
5888 app has a distinct, stable label (the filter funnel, ⇅, Summary stats, …), which
5889 is why only
5890 this shared-blank-label family of eight was affected.
5891 """
5892 row = host.container(
5893 horizontal=True,
5894 wrap=False,
5895 vertical_alignment="center",
5896 gap=None,
5897 key=f"split_mode_rail_{slug}",
5898 )
5899 note = toggle.pop("note", None)
5900 # UX-153: the name is the switch's own label again, so clicking the word
5901 # flips the switch -- as it always did on Animate and Compare. UX-103 had
5902 # split them (a collapsed switch + the name as markdown) to be rid of the
5903 # native tooltip: in a one-line row Streamlit puts a checkbox label in
5904 # "truncate" mode, which stamps a `title=` repeating the words already on
5905 # screen, and a `title` cannot be styled or suppressed from CSS. `wrap=True`
5906 # is what turns truncate mode off, and with it the `title`; the one-line
5907 # ellipsis it would have drawn comes from `styles.py` instead (the
5908 # `split_mode_` label rule), which draws it without a tooltip.
5909 if toggle:
5910 # #374 F19: no `**` in the label, which is the switch's accessible
5911 # name verbatim; `styles.py` draws the row names bold instead.
5912 value = row.toggle(label.replace("**", ""), wrap=True, **toggle)
5913 else:
5914 # A name-only section: `styles.py` stretches the ▾ trigger's click
5915 # target over the whole row, so the name opens the popover (UX-153).
5916 value = None
5917 row.markdown(label)
5918 # BUG-108: the trigger is named for screen readers ("Fixation settings");
5919 # `styles.py` clips that label off screen, so the chevron stays the only
5920 # thing drawn. `wrap=True` for the reason the switch has it: a truncated
5921 # one-line label would stamp a native `title=` tooltip.
5922 name = name or re.sub(r":material/\w+:|\*", "", label).strip()
5923 body = row.popover(
5924 f"{name} settings",
5925 width="content",
5926 wrap=True,
5927 key=f"split_mode_rail_{slug}_popover",
5928 )
5929 # ...and the popover trigger carries no `help=` either. What hovered there
5930 # was a restatement of the section's own name; what is worth saying -- why
5931 # a switch is greyed -- is written inside the popover instead, where it is
5932 # read without waiting for a tooltip and without covering the row below.
5933 if note:
5934 body.caption(note)
5935 return value, body
5938def _rail_subsection(host, label: str, *, note: str = ""):
5939 """A named block inside the rail's Filters & highlights section (UX-72).
5941 **Scope, after UX-74 was reverted.** That item flattened *every* section's
5942 `⚙️ …` popovers into blocks like this one; the rail read worse for it — a
5943 section became a long unbroken run — so the popovers are back everywhere
5944 they were. What is left using this is the one section that never had them:
5945 #UX-72's Filters & highlights, whose two halves (👁️ Fixations · ↗️ Saccades) are
5946 genuinely one thing each and would spend a click for nothing.
5948 ``note`` renders under the label — a block has no trigger, so the sentence a
5949 popover carried as a tooltip (what the filter does, and why it is inert in
5950 Animate or Compare) goes here instead.
5951 """
5952 # A line opening with `<div` is a raw HTML block, where the label's
5953 # `ICONS` shortcode would print as text (UX-138).
5954 host.markdown(
5955 f'<div class="sps-rail-subhead">{icons_to_html(label)}</div>',
5956 unsafe_allow_html=True,
5957 )
5958 box = host.container()
5959 if note:
5960 box.caption(note)
5961 return box
5964#: BUG-36 — the ♻️ Reset visualization button's confirmation flag.
5965_RESET_VIZ_PENDING_KEY = "_reset_viz_pending"
5968@st.dialog("Reset visualization?")
5969@guarded()
5970def _reset_viz_confirmation_dialog() -> None:
5971 """The modal body — BUG-36. Opened by ``render_viz_reset``.
5973 Handled by the button's *return value*, not ``on_click`` (see the module
5974 note on ``reset_viz_settings`` for why that function itself still runs as
5975 a bare call rather than a callback here: the confirm click executes inside
5976 the dialog's own fragment rerun, a different script frame from the one
5977 that instantiates the rail's ``global_*`` widgets, so deleting their keys
5978 here hits none of the "set after instantiation" ordering that forces
5979 ``on_click`` on the *un-confirmed* button next door).
5980 """
5981 st.caption(
5982 "Reset every plot setting, Filters & highlights included. Annotations, trial "
5983 "filters, data and the selected trial are kept."
5984 )
5985 yes, no = st.columns(2)
5986 if yes.button(
5987 f"{ICONS['reset']} Reset it",
5988 key="reset_viz_confirm",
5989 type="primary",
5990 width="stretch",
5991 ):
5992 reset_viz_settings()
5993 st.session_state.pop(_RESET_VIZ_PENDING_KEY, None)
5994 st.rerun(scope="app")
5995 if no.button("Cancel", key="reset_viz_cancel", width="stretch"):
5996 st.session_state.pop(_RESET_VIZ_PENDING_KEY, None)
5997 st.rerun(scope="app")
6000def render_viz_reset(host) -> None:
6001 """Render the scoped visualization reset into ``host`` — a plain button.
6003 Full width, like every other control in the rail, and at its foot (BUG-24)
6004 below everything it resets.
6006 **UX-73**: it used to be a popover holding a caption and this button, so the
6007 rail's one escape hatch was itself a click deep — and the thing behind the
6008 click was a single button, which is what a popover is *for* avoiding. The
6009 caption it held is the button's tooltip now; nothing else was in there.
6011 **BUG-36**: the click now arms a confirmation dialog rather than firing
6012 ``reset_viz_settings`` straight away — a Share link's settings ride on this
6013 too, so an accidental click used to be able to lose more than it looked
6014 like.
6015 """
6016 if host.button(
6017 f"{ICONS['reset']} Reset visualization",
6018 key="reset_viz_settings_btn",
6019 width="stretch",
6020 help="Reset every plot setting, Filters & highlights included. Annotations, "
6021 "trial filters, data and the selected trial are kept.",
6022 ):
6023 st.session_state[_RESET_VIZ_PENDING_KEY] = True
6024 if st.session_state.get(_RESET_VIZ_PENDING_KEY):
6025 _reset_viz_confirmation_dialog()
6028def render_plot_controls(
6029 trial_fixations: pd.DataFrame,
6030 base_font_size: int,
6031 *,
6032 host=None,
6033 has_raw_gaze: bool = False,
6034 has_stimulus_image: bool = False,
6035 words: pd.DataFrame | None = None,
6036 fix_range_fixations: pd.DataFrame | None = None,
6037 canvas_renderer=None,
6038 slots: dict | None = None,
6039 has_fixations: bool = True,
6040 has_words: bool = True,
6041) -> dict:
6042 """Render the visualization controls and return the resolved settings dict.
6044 Layout (VIZ-31 / UX-44 — grouped so the rail reads by category):
6045 1. Quick-view presets + Palette at the top: the two controls that get to a
6046 good figure without opening anything.
6047 2. Five collapsible sections — **👁️ Fixations** (expanded), then collapsed
6048 **↗️ Saccades**, **📄 Stimulus**, **🔥 Overlays**, and
6049 **📐 Figure & canvas** (canvas/text plus axes/labels).
6050 3. Inside a section: **layer toggle → ⚙️ style**, the detail popovers
6051 shown only while the layer is on. Streamlit nests neither
6052 expander-in-expander nor popover-in-popover, so an expander holding
6053 popovers is the only two-level shape available — which is also why
6054 Fixations and Saccades are peer sections rather than sub-sections of a
6055 single "Scanpath" group. UX-74 tried replacing those popovers with
6056 inline blocks and was reverted: a section then read as one long
6057 undifferentiated run.
6058 3b. Filtering left the sections entirely (UX-72): one **Filters & highlights**
6059 section after them holds both the fixation and the saccade filters.
6060 4. **📐 Figure & canvas** follows the same shape with no layer to toggle
6061 (UX-48): the framing toggle inline, then four popovers — 🖥️ Screen &
6062 geometry · 🔤 Text & fonts (both from ``canvas_renderer``) · 📊 Axes &
6063 grid · 🏷️ Title & labels.
6065 The sections are created up front (Streamlit lays containers out in creation
6066 order), so each block below renders into its section without moving in this
6067 file — see the "Layer groups" comment.
6069 ``canvas_renderer`` is an optional ``callable(slot)`` rendering the canvas /
6070 text panel (``app.render_canvas_controls``) into a slot reserved
6071 between the Overlays and Figure groups. VIZ-31 moved that panel into the rail
6072 so the figure's fonts, text colour and background sit beside the
6073 other visual controls; when it is ``None`` (the wizard, the non-rendering
6074 readers) nothing is drawn there and the panel keeps its own home.
6076 ``host`` is the container to render into — the app passes the scanpath rail
6077 (``tabs.render_single_trial_tab``); ``None`` renders in place. The returned
6078 dict is built by
6079 ``_collect_viz_settings`` (shared with ``viz_settings_from_state``) so the
6080 rendered controls and the non-rendering readers can't drift.
6082 ``fix_range_fixations`` is the *selected trial's* fixations, used only to size
6083 the VIZ-7 fixation-index window slider (its max is that trial's fixation
6084 count). When omitted, the slider isn't rendered (e.g. the non-rendering
6085 Corpus reader, which never windows).
6086 """
6087 # can re-push the stored values to the browser (BUG-15 — see `_pin`).
6088 color_fields, numeric_fields, highlight_options = _seed_viz_state(
6089 trial_fixations, base_font_size, words
6090 )
6091 if not numeric_fields:
6092 st.error(
6093 "The fixations have no numeric columns to plot. Check the fixation "
6094 "mapping on the Data Management page."
6095 )
6096 st.stop()
6098 # The keyed container is the spotlight-tour target
6099 # (`.st-key-tour_grp_viz_controls`). Values for controls not rendered this run
6100 # are read back from session_state by `_collect_viz_settings`, so the returned
6101 # dict always carries every key the figure builders depend on.
6102 viz = (host if host is not None else st).container(key="tour_grp_viz_controls")
6104 # --- Design presets ---------------------------------------------------
6105 # VIZ-39 renamed this from "Quick views" and gave it a second half: the four
6106 # built-in designs in the 2x2 grid they have always been in, and the user's
6107 # own saved designs in an expander under them. 🛠️ Custom is not a design —
6108 # it is the one unnamed slot holding *your most recent hand-tuning*, so
6109 # switching to a built-in and back does not lose it. Naming settings you
6110 # want to keep is what "My designs" is for. The remaining preset keys
6111 # (`reading_order`, `everything`) stay in `_VIEW_PRESETS` for any deep link.
6112 viz.markdown(
6113 '<div class="sps-control-label">Design presets</div>',
6114 unsafe_allow_html=True,
6115 )
6116 # A 2×2 grid keeps the labels readable in the narrow rail.
6117 _active = _active_quick_view()
6118 _qv_grid = viz.container(key="quick_views_grid")
6119 _qv_top = _qv_grid.columns(2, gap="small")
6120 _qv_top[0].button(
6121 f"{ICONS['preset_scanpath']} Scanpath",
6122 key="viz_view_scanpath",
6123 type="primary" if _active == "scanpath" else "secondary",
6124 width="stretch",
6125 help="Fixations + saccades over the text — the core scanpath.",
6126 on_click=_apply_view_preset,
6127 args=("scanpath",),
6128 )
6129 _qv_top[1].button(
6130 f"{ICONS['heatmap']} Heatmap",
6131 key="viz_view_heatmap",
6132 type="primary" if _active == "heatmap" else "secondary",
6133 width="stretch",
6134 help="Each word box colored by the total time spent on it (ms), with "
6135 "nothing else drawn.",
6136 on_click=_apply_view_preset,
6137 args=("heatmap",),
6138 )
6139 _qv_bottom = _qv_grid.columns(2, gap="small")
6140 _qv_bottom[0].button(
6141 f"{ICONS['illustration']} Illustration",
6142 key="viz_view_illustration",
6143 type="primary" if _active == "illustration" else "secondary",
6144 width="stretch",
6145 help="A clean schematic: fixations snapped above words, arced "
6146 "saccades, one saccade color, opaque markers.",
6147 on_click=_apply_view_preset,
6148 args=("illustration",),
6149 )
6150 _qv_bottom[1].button(
6151 f"{ICONS['preset_custom']} Custom",
6152 key="viz_view_custom",
6153 type="primary" if _active == _CUSTOM_VIEW else "secondary",
6154 width="stretch",
6155 help="Your most recent custom plot settings. Save them under a name in "
6156 f"{ICONS['designs']} My designs to keep them.",
6157 on_click=_apply_view_preset,
6158 args=(_CUSTOM_VIEW,),
6159 )
6160 _render_saved_designs(viz)
6162 # VIZ-31: the Illustration *label* (the publication-disclosure override) now
6163 # lives in the "📐 Figure & canvas" group below, with the other figure-level
6164 # presentation settings, rather than as a third top-level row up here.
6166 # VIZ-18: these figures end up in papers — printed, sometimes in black &
6167 # white — and are read by colourblind viewers, so the colour defaults are a
6168 # choice rather than a constant. Picking one writes the individual colour
6169 # keys, so every per-element picker below still overrides it — and once one
6170 # is overridden the selector says **Custom** rather than keeping a name the
6171 # figure no longer earns (the same rule the Quick-view buttons follow above).
6172 # `Custom` is offered only while it's true, so the list stays the three real
6173 # palettes the moment the colours match one again.
6174 _active = _active_palette()
6175 _palette_options = list(PALETTES) if _active else [CUSTOM_PALETTE, *PALETTES]
6176 st.session_state["global_palette"] = _active or CUSTOM_PALETTE
6177 # UX-80 r2: no `help=`, because Streamlit draws it as a `?` icon and the ask
6178 # was to clear those off the rail's head. This is the one that had nowhere
6179 # else to go — the toggles' text moved to their ▾ tooltips, but a selectbox
6180 # has no second hover target — so what each palette *is* now reads off the
6181 # option names themselves ("Default (colourblind-safe)", "Print / greyscale",
6182 # "High contrast"), and "any change reads Custom" is visible the moment it
6183 # happens. The full explanation lives in the docs.
6184 viz.selectbox(
6185 "Palette",
6186 options=_palette_options,
6187 key="global_palette",
6188 persist_state="session",
6189 on_change=_on_palette_change,
6190 format_func=palette_label,
6191 )
6193 # Keep the palette controls visually separate from the bordered layer cards.
6194 # A keyed wrapper gives the spacing a stable, narrowly scoped CSS hook.
6195 viz.container(key="palette_layers_divider").divider()
6197 # Each main layer is an `st.toggle`; the layer's detailed styling lives in a
6198 # per-layer popover shown only while the layer is on — so the rail shows just
6199 # the toggles (plus, for fixations, the primary "Color by" control), and the
6200 # fiddly knobs open in an overlay instead of growing the rail past the plot.
6201 # Values for off layers are read back from session_state by
6202 # `_collect_viz_settings`, so the returned dict always carries every key.
6204 # VIZ-21: the two view modes (rail → 🎛️ Plot controls, rendered *before* this
6205 # function) route the figure through different builders, and each ignores a
6206 # different slice of these controls. Read both flags and gate every affected
6207 # widget through `_mode_gate` — greyed with a reason, never silently ignored,
6208 # and never with its stored value rewritten. VIZ-23 then made a batch of them
6209 # live in Animate / Compare, so what remains gated below is the genuinely
6210 # builder-less set (see CLAUDE.md's setting → render-path table).
6211 comparing = bool(
6212 st.session_state.get(
6213 "_resolved_comparing", st.session_state.get("single_compare_toggle")
6214 )
6215 )
6216 animating = bool(
6217 st.session_state.get(
6218 "_resolved_animating", st.session_state.get("single_animate")
6219 )
6220 )
6221 # Handy shorthands for the two recurring gates.
6222 _static_only = dict(in_animation=False, in_compare=False)
6223 _no_compare = dict(in_compare=False)
6225 # --- Layer groups (VIZ-31) --------------------------------------------
6226 # The seven layers used to sit as one flat top-to-bottom run of toggles, so
6227 # the rail opened as ~13 peer rows with no hint that Text / Bounding boxes /
6228 # Stimulus image describe the *stimulus* while Fixations / Saccades / Raw
6229 # gaze describe the *recording*. They are now named groups, plus two for how
6230 # the figure is framed.
6231 #
6232 # The groups are created HERE, up front, because Streamlit lays containers
6233 # out in **creation** order — which lets each block below keep its current
6234 # position in this file while rendering into whichever group it belongs to.
6235 # So the visual order is exactly the order of these six lines; the code
6236 # order further down is unchanged (and irrelevant to the layout).
6237 #
6238 # **Section shape.** Each group is `layer toggle → ⚙️ style → 🧹 filter`, the
6239 # nesting the original wireframe asked for. Streamlit nests neither
6240 # expander-in-expander nor popover-in-popover, but popover-in-**expander** is
6241 # allowed — so a section is an expander and its sub-sections are popovers,
6242 # which is the only two-level shape the framework actually renders. That is
6243 # also why Fixations and Saccades are *peer* sections rather than one
6244 # "Scanpath" group holding two sub-sections: the sub-section level is spent
6245 # on style/filter, where it earns more than on the layer split.
6246 #
6247 # Fixations opens because it is the primary layer; Saccades and the less-used
6248 # groups stay collapsed so the independent rail starts compact. The preset row
6249 # above still covers the common combinations without opening anything at all.
6250 # UX-80: each section is a row — its switch (when it has exactly one thing
6251 # to switch) and a ▾ holding everything else. The layer toggles are rendered
6252 # HERE rather than in the blocks below, because a row lays its children out
6253 # in creation order and the switch has to precede the ▾; the blocks below
6254 # still own everything inside the popovers.
6255 fix_off_disabled, _fix_off_reason = _mode_gate(
6256 animating, comparing, in_animation=False, in_compare=True
6257 )
6258 # VIZ-45: a trial with no fixations (raw gaze only, or words only) has
6259 # nothing for the fixation-built controls to act on — Fixations, Saccades,
6260 # the Filter and its index window — and the samples are never turned into
6261 # fixations, so they grey with that reason, keeping their values for the
6262 # next trial that has fixations.
6263 no_fixations_note = (
6264 ""
6265 if has_fixations
6266 else f"{ICONS['warning']} This trial has no fixations."
6267 + (
6268 f" Its gaze samples are under {ICONS['raw_gaze']} **Raw gaze**."
6269 if has_raw_gaze
6270 else ""
6271 )
6272 )
6273 show_fix, fix_grp = _rail_section(
6274 viz,
6275 f"{ICONS['fixations']} **Fixations**",
6276 slug="fix",
6277 name="Fixation",
6278 key="global_show_fix",
6279 persist_state="session",
6280 disabled=fix_off_disabled or not has_fixations,
6281 # No fixations: the popover body's own `_layer_off` caption says it.
6282 note=""
6283 if no_fixations_note
6284 else (
6285 f"{ICONS['warning']} **Animate** always draws fixations; this switch "
6286 "applies to the other figures."
6287 if fix_off_disabled
6288 else ""
6289 ),
6290 )
6291 show_saccades, sac_grp = _rail_section(
6292 viz,
6293 f"{ICONS['saccades']} **Saccades**",
6294 slug="sac",
6295 name="Saccade",
6296 key="global_show_saccades",
6297 persist_state="session",
6298 disabled=not has_fixations,
6299 )
6300 # UX-128: a master switch for the section's layers (text, image),
6301 # matching Fixations/Saccades. Earlier this was name-only — each
6302 # layer carried its own toggle and nothing gated all of them at once — on
6303 # the reasoning that a master switch would have to remember which of the
6304 # layers were on to restore them. It doesn't: this toggle never touches
6305 # `global_show_labels`/`global_show_stimulus_image` themselves, so each
6306 # keeps whatever the user set. It only ANDs into the *effective* values
6307 # `_collect_viz_settings` returns — turning it back on reveals exactly what
6308 # was configured before, with nothing to restore.
6309 show_stimulus, stim_grp = _rail_section(
6310 viz,
6311 f"{ICONS['stimulus']} **Stimulus**",
6312 slug="stim",
6313 name="Stimulus",
6314 key="global_show_stimulus",
6315 persist_state="session",
6316 )
6317 # Word boxes were a third layer inside 📄 Stimulus; they are a section of
6318 # their own now, with a style (outline + fill) that the Stimulus popover had
6319 # no room for. The switch keeps its `global_show_words` key, so links and
6320 # saved configs are unchanged — only the Stimulus master no longer gates it.
6321 show_word_boxes, boxes_grp = _rail_section(
6322 viz,
6323 f"{ICONS['word_boxes']} **Word boxes**",
6324 slug="boxes",
6325 name="Word boxes",
6326 key="global_show_words",
6327 persist_state="session",
6328 # No word boxes: the popover body's own `_layer_off` caption says it.
6329 disabled=not has_words,
6330 )
6331 # UX-86: Overlays dissolved — Heatmap and Raw gaze are now peer sections,
6332 # each with exactly one thing to switch, so each carries its own toggle
6333 # on the row the way Fixations/Saccades do. `_mode_gate` is called again
6334 # (cheaply) where each layer's style popover needs its own `reason` text.
6335 heat_disabled, heat_reason = _mode_gate(animating, comparing, in_animation=False)
6336 # VIZ-45: the heatmap draws from fixations or from the word boxes' own
6337 # measures, so only a trial with neither has nothing for it.
6338 heat_nothing = not has_fixations and not has_words
6339 heat_nothing_note = (
6340 f"{ICONS['warning']} This trial has no fixations and no word boxes."
6341 + (
6342 f" Its gaze samples are under {ICONS['raw_gaze']} **Raw gaze**."
6343 if has_raw_gaze
6344 else ""
6345 )
6346 )
6347 show_heatmap, heatmap_grp = _rail_section(
6348 viz,
6349 f"{ICONS['heatmap']} **Heatmap**",
6350 slug="heatmap",
6351 name="Heatmap",
6352 key="global_show_heatmap",
6353 persist_state="session",
6354 disabled=heat_disabled or heat_nothing,
6355 # Nothing to draw: the popover body's own `_layer_off` caption says it.
6356 note="" if heat_nothing else heat_reason,
6357 )
6358 # VIZ-48: the comparison builder draws each reading's samples; the replay
6359 # still has no raw-gaze layer (VIZ-49).
6360 raw_disabled, raw_reason = _mode_gate(animating, comparing, in_animation=False)
6361 show_raw_gaze, raw_gaze_grp = _rail_section(
6362 viz,
6363 f"{ICONS['raw_gaze']} **Raw gaze**",
6364 slug="rawgaze",
6365 name="Raw gaze",
6366 key="global_show_raw_gaze",
6367 persist_state="session",
6368 disabled=not has_raw_gaze or raw_disabled,
6369 note=_gated_help(
6370 "" if has_raw_gaze else f"{ICONS['warning']} No raw gaze samples to show.",
6371 raw_reason,
6372 ),
6373 )
6374 # UX-72 — ONE filter section for the whole figure, a peer of the layer
6375 # sections rather than a 🧹 popover inside each of them. "Filter the plot"
6376 # was two controls, in two places, each two clicks deep; it is one place
6377 # now, and it sits after the layers because it thins what they draw.
6378 #
6379 # Not to be confused with the filter funnel on the control line (#UX-64),
6380 # which narrows
6381 # the *trial pool* — which readings you can pick. This one thins one
6382 # reading. The badge still says an active filter is on, or a thinned figure
6383 # reads as missing data; with two filters folded together it now reports
6384 # both, so `•` on the section means at least one of them is narrowing.
6385 # UX-80: no toggle — filtering is not a layer — but the same row shape, so
6386 # its controls open over the page instead of being cropped by the rail.
6387 _filter_none, filter_grp = _rail_section(
6388 viz,
6389 f"{ICONS['plot_filter']} **Filters & highlights**{_plot_filter_badge()}",
6390 slug="filter",
6391 name="Filters & highlights",
6392 note=no_fixations_note,
6393 )
6394 # Sub-slots up front so each block below renders into the right half of the
6395 # section from wherever it sits in this file (the same trick the sections
6396 # themselves use).
6397 filter_fix_slot = filter_grp.container()
6398 filter_sac_slot = filter_grp.container()
6399 # CMP-24: scanpath B's own filters, filled by `render_compare_filters` once B
6400 # is loaded (below the rail). Handed back through `slots`, not the settings
6401 # dict, which is hashed into figure keys and must stay plain data.
6402 if comparing and slots is not None:
6403 slots["compare_filter"] = filter_grp.container()
6404 ab = " · A" if comparing else ""
6405 # Canvas/text and the former Figure/axes controls share one disclosure: both
6406 # describe the figure's framing rather than a data layer. The injected canvas
6407 # renderer writes directly into this expander (not a nested expander), as its
6408 # own popover sub-groups — see the "Figure & canvas" block below.
6409 _figure_none, figure_grp = _rail_section(
6410 viz,
6411 f"{ICONS['figure']} **Figure & canvas**",
6412 slug="figure",
6413 name="Figure & canvas",
6414 )
6416 # --- Fixations --------------------------------------------------------
6417 # The Fixations toggle reaches the static figure AND Compare (CMP-7 — the
6418 # comparison heatmap is unreadable under two full sets of markers). Only the
6419 # animated replay ignores it: the replay *is* the fixation trail, so there is
6420 # nothing left to draw with it off, and `make_scanpath_animation` takes no
6421 # `show_fixations` argument.
6422 # The toggle is on the section's row (UX-80); the styling below is still
6423 # (partly) live in Animate / Compare, so the popover stays reachable even
6424 # when the (inert) layer toggle reads off.
6425 # UX-158: every title in this popover is short, so its label column is
6426 # narrower than the rail's, bringing the fields closer to their titles; the
6427 # keyed container is what `styles.py` spaces the rows apart by.
6428 with (
6429 fix_grp,
6430 _layer_off(
6431 f"{ICONS['fixations']} Fixations",
6432 off=not (show_fix or fix_off_disabled) or not has_fixations,
6433 reason=no_fixations_note or None,
6434 ),
6435 _popover_rows("fix"),
6436 ):
6437 # The metric that maps to fixation HUE — applies on every render path.
6438 # In Compare and the co-animation (Animate + Compare) the chosen values
6439 # — numeric, a category, or the text line — fill both scanpaths'
6440 # markers on one shared scale / one shared category→colour mapping,
6441 # and each scanpath's flat colour becomes its marker outline.
6442 metric_disabled, metric_reason = _mode_gate(animating, comparing)
6443 # UX-158: colour, shape, size and opacity are one "Marker" group — a
6444 # title on the first row and a short caption per row, instead of a full
6445 # title each (VIZ-17 → UX-154 put the flat colour / colorscale beside
6446 # the colour-by pick; UX-156 briefly squeezed shape, size and opacity
6447 # into one row of unlabelled boxes). The swatch shows while
6448 # **(uniform)** is picked, the colorscale once a column is; the flat
6449 # colour is inert in Compare, where each scanpath wears its own colour
6450 # (see "Per-scanpath (comparison)" below).
6451 by_help = _gated_help(
6452 "The column that colors the markers. **One color**: the "
6453 "color in the box beside it. A numeric column: the color scale "
6454 "beside it. **Line** or a categorical column: a discrete palette. In "
6455 "Compare, both scanpaths share the mapping.",
6456 metric_reason,
6457 )
6458 by_disabled, by_help = _layer_gate(metric_disabled, by_help)
6459 # In Compare this group *is* scanpath A's: its colour, size and opacity
6460 # rows write A's `cmp0_*` keys (the figure-wide ones are inert there),
6461 # and scanpath B's group follows it. Shape and the duration scale stay
6462 # shared by both.
6463 field = _sub_row(
6464 "Color",
6465 section=_COMPARE_SCANPATHS[0][1] if comparing else "Marker",
6466 section_help=(
6467 _COMPARE_SCANPATH_HELP[0] + " Color, size and opacity are its "
6468 "own; shape and duration scale are "
6469 "shared."
6470 if comparing
6471 else "How fixation markers are drawn."
6472 ),
6473 caption_help=by_help,
6474 section_share=_COMPARE_SECTION_SHARE if comparing else 0.45,
6475 )
6476 by_col, style_col = field.columns(
6477 [0.6, 0.4], gap=_LABEL_GAP, vertical_alignment="center"
6478 )
6479 # DATA-66: the dataset's own names, its columns before the app's.
6480 rail_names = _rail_names()
6481 color_labels = rail_names.option_labels(
6482 color_fields,
6483 {
6484 UNIFORM_COLOR_FIELD: "One color",
6485 "line": "Line" + cn.COMPUTED_SUFFIX,
6486 },
6487 roles=True,
6488 )
6489 color_by = by_col.selectbox(
6490 "Color fixations by",
6491 # "line" is no column of either table, so it is placed by hand,
6492 # after the app's own fields like the computed field it is.
6493 options=rail_names.sort_options(
6494 [f for f in color_fields if f != "line"], first=(UNIFORM_COLOR_FIELD,)
6495 )
6496 + (["line"] if "line" in color_fields else []),
6497 format_func=color_labels.__getitem__,
6498 key="global_color_by",
6499 persist_state="session",
6500 # VIZ-46: a chosen colour range is in the units of the column it was
6501 # chosen for, so picking another column puts it back to auto rather
6502 # than clamping ms into, say, surprisal's (often one-value) span.
6503 on_change=forget_color_range,
6504 args=("global_fixation_color_range",),
6505 disabled=by_disabled,
6506 help=by_help,
6507 label_visibility="collapsed",
6508 )
6509 if color_by == UNIFORM_COLOR_FIELD and comparing:
6510 _compare_fix_color_picker(style_col, 0)
6511 elif color_by == UNIFORM_COLOR_FIELD:
6512 _dis, _reason = _mode_gate(animating, comparing, **_no_compare)
6513 _dis, _tip = _layer_gate(
6514 _dis,
6515 _gated_help("The single color every fixation marker wears.", _reason),
6516 )
6517 style_col.color_picker(
6518 "Fixation color",
6519 key="global_fixation_color",
6520 persist_state="session",
6521 disabled=_dis,
6522 help=_tip,
6523 label_visibility="collapsed",
6524 )
6525 else:
6526 # 'line' and a categorical column are drawn from a discrete
6527 # palette, so the colorscale is idle for them — greyed, not hidden.
6528 discrete = color_by == "line" or (
6529 color_by in trial_fixations.columns
6530 and not pd.api.types.is_numeric_dtype(trial_fixations[color_by])
6531 )
6532 _dis, _tip = _layer_gate(
6533 metric_disabled or discrete,
6534 _gated_help(
6535 "The color scale for a numeric column. Not used for **Line** or "
6536 "a categorical column.",
6537 metric_reason,
6538 ),
6539 )
6540 # Keyless on purpose, like `_popover_selectbox`: a keyed selectbox
6541 # first painted in a closed popover shows its first option instead
6542 # of the seeded value, so the index is passed and the pick written
6543 # back by hand.
6544 current_scale = st.session_state.get("global_fixation_colorscale")
6545 st.session_state["global_fixation_colorscale"] = style_col.selectbox(
6546 "Colorscale",
6547 COLORSCALES,
6548 index=(
6549 COLORSCALES.index(current_scale)
6550 if current_scale in COLORSCALES
6551 else 0
6552 ),
6553 disabled=_dis,
6554 help=_tip,
6555 label_visibility="collapsed",
6556 )
6557 if comparing and color_by != UNIFORM_COLOR_FIELD:
6558 # A column fills the markers, so A's colour is their outline.
6559 _compare_fix_color_picker(
6560 _sub_row("Outline", caption_help=_COMPARE_OUTLINE_HELP), 0
6561 )
6562 raw_cmin = (
6563 trial_fixations[color_by].min()
6564 if color_by in trial_fixations.columns
6565 and pd.api.types.is_numeric_dtype(trial_fixations[color_by])
6566 else None
6567 )
6568 raw_cmax = trial_fixations[color_by].max() if raw_cmin is not None else None
6569 if pd.notna(raw_cmin) and pd.notna(raw_cmax):
6570 # Integer bounds + step so the range reads as whole numbers
6571 # (durations, surprisal, … all read cleaner as ints); values
6572 # stay floats so a restored config on different data clamps in.
6573 # The bounds span the loaded pool; the *default* is auto — each
6574 # trial on its own scale, like the API (VIZ-46).
6575 cmin = float(math.floor(raw_cmin))
6576 cmax = float(math.ceil(raw_cmax))
6577 cmax_eff = cmax if cmax > cmin else cmin + 1.0
6578 _render_color_range(
6579 "Fixation color range",
6580 "global_fixation_color_range",
6581 cmin,
6582 cmax_eff,
6583 field_host=_sub_row(
6584 "Range",
6585 caption_help="The values at the two ends of the color scale.",
6586 ),
6587 disabled=metric_disabled,
6588 reason=metric_reason,
6589 help="The values at the two ends of the color scale.",
6590 )
6591 # VIZ-15: shape survives greyscale printing where hue doesn't, and
6592 # VIZ-23 made it a true global — the one marker property Compare does
6593 # NOT override per scanpath.
6594 shape_help = (
6595 "Marker shape. Applies to every figure, and to both scanpaths in Compare."
6596 )
6597 shape_dis, shape_help = _layer_gate(False, shape_help)
6598 _sub_row("Shape", caption_help=shape_help).selectbox(
6599 "Marker shape",
6600 options=list(FIXATION_SYMBOLS),
6601 format_func=lambda s: FIXATION_SYMBOLS[s],
6602 key="global_fixation_symbol",
6603 persist_state="session",
6604 disabled=shape_dis,
6605 help=shape_help,
6606 label_visibility="collapsed",
6607 )
6608 # Size / opacity are per-scanpath in Compare (`cmp*_marker_size_range`
6609 # / `cmp*_opacity` override these there), so they carry its gate.
6610 _dis, _reason = _mode_gate(animating, comparing, **_no_compare)
6611 size_text = "Smallest and largest marker diameter, in px."
6612 if comparing:
6613 _compare_size_slider(0, size_text)
6614 else:
6615 _, size_help = _layer_gate(_dis, _gated_help(size_text, _reason))
6616 _range_slider(
6617 st,
6618 "Size",
6619 key="global_marker_size_range",
6620 persist_state="session",
6621 min_value=4,
6622 max_value=40,
6623 disabled=_dis,
6624 help=_gated_help(size_text, _reason),
6625 field_host=_sub_row("Size", caption_help=size_help),
6626 )
6627 _render_duration_scale_rows()
6628 if comparing:
6629 _compare_opacity_slider(0)
6630 # Scanpath B's group, straight under A's.
6631 _render_compare_fix_styles(uniform=color_by == UNIFORM_COLOR_FIELD)
6632 else:
6633 _, opac_help = _layer_gate(
6634 _dis,
6635 _gated_help(
6636 "Marker opacity; lower it to see overlapping fixations.",
6637 _reason,
6638 ),
6639 )
6640 _numeric_slider(
6641 st,
6642 "Opacity",
6643 key="global_fixation_opacity",
6644 persist_state="session",
6645 min_value=0.1,
6646 max_value=1.0,
6647 step=0.05,
6648 slider_format="%.2f",
6649 disabled=_dis,
6650 help=_gated_help(
6651 "Marker opacity; lower it to see overlapping fixations.",
6652 _reason,
6653 ),
6654 field_host=_sub_row("Opacity", caption_help=opac_help),
6655 )
6656 # The fixations' own colour bar, after the marker groups — idle unless the colour-by column is
6657 # numeric, since a discrete palette has no scale to show.
6658 _render_colorbar_rows(
6659 "fixation",
6660 disabled=metric_disabled or raw_cmin is None,
6661 reason=metric_reason,
6662 )
6663 # PRE-3: vertical drift correction. Snap each fixation to its assigned
6664 # text line using one of the Carr et al. (2021) algorithms; "Off"
6665 # leaves the raw coordinates. VIZ-23 hoisted the correction above the
6666 # render-mode split in `tabs.py`, so the *algorithm* now applies on all
6667 # three paths. The CONNECTORS don't: only `make_scanpath_figure` has a
6668 # connector layer, and drawing a full-length "original position" layer
6669 # from frame zero would misread as part of the replay's trail.
6670 static_disabled, static_reason = _mode_gate(
6671 animating, comparing, **_static_only
6672 )
6673 # PRE-21: not fully integrated, so hidden unless SCANPATH_EXPERIMENTAL
6674 # is set. The keys keep their defaults ("Off"), so nothing downstream
6675 # needs a second gate to render correctly.
6676 if drift_correction_enabled():
6677 align_algo = _labeled(
6678 st,
6679 "selectbox",
6680 "Drift correction",
6681 options=_ALIGN_OPTIONS,
6682 key="global_align_algorithm",
6683 persist_state="session",
6684 help="Move each fixation vertically onto the text line an "
6685 "algorithm assigns it to (Carr et al., 2021). Off: as recorded. "
6686 f"{ICONS['line_assignment']} Line assignment compares the "
6687 "algorithms.",
6688 )
6689 if align_algo != "Off":
6690 _labeled(
6691 st,
6692 "checkbox",
6693 "Show drift connectors",
6694 key="global_align_connectors",
6695 persist_state="session",
6696 disabled=static_disabled,
6697 help=_gated_help(
6698 "A faint line from each fixation's recorded position to "
6699 "its corrected one.",
6700 static_reason,
6701 ),
6702 )
6703 # UX-155: the switch, the label colour and the label size share one
6704 # row, the last two greyed while the switch is off (never hidden, and
6705 # never rewritten — a disabled widget keeps its key). The size is a
6706 # number box rather than UX-9's slider + box: three controls do not
6707 # leave a slider enough width to be draggable.
6708 order_disabled, order_help = _layer_gate(
6709 False, "Number each fixation by its order in the trial."
6710 )
6711 label_w = _label_w()
6712 rest = 1.0 - label_w
6713 # UX-158: the checkbox says what it does ("Show") and the number box is
6714 # captioned, so the row reads without hovering.
6715 label_col, check_col, color_col, size_cap_col, size_col = st.columns(
6716 [label_w, rest * 0.26, rest * 0.2, rest * 0.18, rest * 0.36],
6717 gap=_LABEL_GAP,
6718 vertical_alignment="center",
6719 )
6720 _row_label(label_col, "Fixation index", order_help)
6721 show_order = check_col.checkbox(
6722 "Show",
6723 key="global_show_order",
6724 persist_state="session",
6725 disabled=order_disabled,
6726 )
6727 # In Compare (and in a dual animation) the index labels are tinted to
6728 # each scanpath's own colour, so the global colour is inert there.
6729 _dis, _reason = _mode_gate(animating, comparing, **_no_compare)
6730 _dis, _tip = _layer_gate(
6731 _dis or not show_order,
6732 _gated_help("Fixation-index label color.", _reason),
6733 )
6734 color_col.color_picker(
6735 "Index label color",
6736 key="global_order_font_color",
6737 persist_state="session",
6738 disabled=_dis,
6739 help=_tip,
6740 label_visibility="collapsed",
6741 )
6742 # A shadow box (`__num`) writing the canonical key, as UX-9's boxes do:
6743 # the rail's CSS drops a `__num` box's steppers, and the canonical key is
6744 # what deep links, Share and restore read. Rounded, since a link or a
6745 # restored config can hand back a float and the box has int bounds.
6746 size_key = "global_order_font_size"
6747 size_num_key = f"{size_key}__num"
6748 if size_key in st.session_state:
6749 st.session_state[size_num_key] = round(st.session_state[size_key])
6751 def _apply_index_size() -> None:
6752 if _shadow_key_missing(size_num_key): # BUG-18
6753 return
6754 st.session_state[size_key] = st.session_state[size_num_key]
6756 _dis, _tip = _layer_gate(
6757 not show_order,
6758 "Index label size, in px.",
6759 )
6760 _sub_caption(size_cap_col, "Size")
6761 size_col.number_input(
6762 "Index label size",
6763 key=size_num_key,
6764 min_value=6,
6765 max_value=72,
6766 step=1,
6767 on_change=_apply_index_size,
6768 disabled=_dis,
6769 help=_tip,
6770 label_visibility="collapsed",
6771 )
6772 # "Snap above words" is the fixation half of VIZ-9 (its
6773 # partner is Saccades → Style → Line shape → Arc). Keep the control
6774 # for saved-view compatibility, but do not give it a separate
6775 # "Linear-reading schematic" heading in this already compact panel.
6776 # Still `make_scanpath_figure`-only (VIZ-9's `fixation_snap_to_word`),
6777 # unlike the drift correction — hence its own gate. UX-156 moved it to the
6778 # bottom of the panel: it is a schematic mode, not marker styling.
6779 _labeled(
6780 st,
6781 "checkbox",
6782 "Snap above words",
6783 key="global_fixation_snap_to_word",
6784 persist_state="session",
6785 disabled=static_disabled,
6786 help=_gated_help(
6787 "Draw each fixation above its word, not at its recorded position.",
6788 static_reason,
6789 ),
6790 )
6792 # VIZ-27: filtering decides which fixations are visible; it is not marker
6793 # appearance. Keep it beside the fixation layer as a first-class popover and
6794 # show a local badge so an active Discard cannot be forgotten. A chip in the
6795 # trial-fact strip was rejected because this is a view setting, not trial data.
6796 # CMP-24: every builder honours the flags now — Compare draws each scanpath
6797 # under its own set — so nothing greys this block any more.
6798 _flag_dis, _flag_reason = False, ""
6799 # UX-162: the subsection says only why it is greyed, when it is; what it does
6800 # is in the rows' tooltips now, beside the controls it describes.
6801 with (
6802 _rail_subsection(
6803 filter_fix_slot,
6804 f"{ICONS['fixations']} Fixations{ab}{_fixation_filter_badge()}",
6805 note=_flag_reason,
6806 ),
6807 _layer_off(
6808 f"{ICONS['fixations']} Fixations",
6809 off=not (show_fix or fix_off_disabled) or not has_fixations,
6810 reason=no_fixations_note or None,
6811 # The Filters & highlights section's own note already said it.
6812 caption=has_fixations,
6813 ),
6814 _popover_rows("filter_fix"),
6815 ):
6816 # VIZ-27 follow-up: the index window removes fixations just like the
6817 # short/long/OOB rules, so it belongs here rather than under marker style.
6818 # The max follows the selected trial (BUG-16).
6819 _render_fix_range_slider(fix_range_fixations)
6820 _render_fixation_cleaning(disabled=_flag_dis, reason=_flag_reason)
6822 # --- Saccades ---------------------------------------------------------
6823 # UX-159: laid out like 👁️ Fixations (UX-158) — one *Line* group (colour,
6824 # style, width, shape) with a caption per row, then *Direction arrows* as a
6825 # `label | ☑ Show` row.
6826 with (
6827 sac_grp,
6828 _layer_off(
6829 f"{ICONS['saccades']} Saccades",
6830 off=not show_saccades or not has_fixations,
6831 reason=no_fixations_note or None,
6832 ),
6833 _popover_rows("sac"),
6834 ):
6835 # VIZ-8 / VIZ-19: uniform colour, the two-way forward-vs-regression
6836 # split, or the full reading-class breakdown. Reading-class colouring
6837 # is a `make_scanpath_figure` feature — the animation draws one
6838 # uniform saccade colour and the comparison overlay one colour per
6839 # scanpath — so the mode picker greys out in both.
6840 class_disabled, class_reason = _mode_gate(animating, comparing, **_static_only)
6841 # The single uniform colour, style and width: honoured by the static
6842 # figure and the animation; Compare paints each scanpath in its own
6843 # colour and style instead (see "Per-scanpath (comparison)" below).
6844 _dis, _reason = _mode_gate(animating, comparing, **_no_compare)
6845 mode_disabled, mode_help = _layer_gate(
6846 class_disabled,
6847 _gated_help(
6848 "Uniform: one color. Forward / regression: two colors. By type: "
6849 "forward, skip, refixation, return sweep and regression.",
6850 class_reason,
6851 ),
6852 )
6853 # In Compare this group *is* scanpath A's, as the Fixations popover's
6854 # *Marker* group is: its colour, style and width rows write A's
6855 # `cmp0_*` keys, and scanpath B's group follows the shared Shape row.
6856 field = _sub_row(
6857 "Color",
6858 section=_COMPARE_SCANPATHS[0][1] if comparing else "Line",
6859 section_help=(
6860 _COMPARE_SCANPATH_HELP[0] + " Color, style and width are its "
6861 "own; shape and direction arrows are shared."
6862 if comparing
6863 else "How saccades are drawn."
6864 ),
6865 caption_help=mode_help,
6866 section_share=_COMPARE_SECTION_SHARE if comparing else 0.45,
6867 )
6868 mode_col, swatch_col = field.columns(
6869 [0.6, 0.4], gap=_LABEL_GAP, vertical_alignment="center"
6870 )
6871 color_mode = mode_col.selectbox(
6872 "Saccade color",
6873 options=SACCADE_COLOR_MODES,
6874 key="global_saccade_color_mode",
6875 persist_state="session",
6876 disabled=mode_disabled,
6877 help=mode_help,
6878 label_visibility="collapsed",
6879 )
6880 # In Animate / Compare the class breakdown never draws, so the slot
6881 # keeps the uniform swatch rather than showing five dead class ones.
6882 if comparing:
6883 _compare_saccade_color_picker(swatch_col, 0)
6884 elif color_mode == "Uniform" or class_disabled:
6885 swatch_disabled, swatch_help = _layer_gate(
6886 _dis,
6887 _gated_help(
6888 "Color of the saccade lines and direction arrows.", _reason
6889 ),
6890 )
6891 swatch_col.color_picker(
6892 "Line color",
6893 key="global_saccade_color",
6894 persist_state="session",
6895 disabled=swatch_disabled,
6896 help=swatch_help,
6897 label_visibility="collapsed",
6898 )
6899 else:
6900 # VIZ-19: the two-way mode reuses the same class colours, so only
6901 # the pickers it actually draws with, three to a row.
6902 classes = (
6903 list(SACCADE_DIRECTION_CLASSES)
6904 if color_mode == "Forward / regression"
6905 else list(SACCADE_CLASS_EDITABLE)
6906 )
6907 classes_help = (
6908 "Saccades classed by where they land relative to the fixation they "
6909 "leave."
6910 if color_mode == "By type"
6911 else "Skips, refixations and return sweeps count as forward; "
6912 "saccades out of bounds are Other."
6913 )
6914 swatch_disabled, _ = _layer_gate(False, None)
6915 for start in range(0, len(classes), 3):
6916 row = _sub_row(
6917 "Types" if start == 0 else None, caption_help=classes_help
6918 )
6919 for col, cls_name in zip(
6920 row.columns(3, gap=_LABEL_GAP), classes[start : start + 3]
6921 ):
6922 # `persist_state` is what keeps a picker first drawn in a
6923 # popover from mounting at its proto default (black) while
6924 # the figure draws the stored colour (BUG-15 / ENG-36).
6925 col.color_picker(
6926 SACCADE_CLASS_LABELS[cls_name],
6927 key=f"global_saccade_class_color_{cls_name}",
6928 persist_state="session",
6929 disabled=swatch_disabled,
6930 )
6931 _, legend_help = _layer_gate(
6932 False,
6933 "The saccade-type color key on the plot.",
6934 )
6935 _sub_row("Legend", caption_help=legend_help).checkbox(
6936 "Show",
6937 key="global_saccade_type_legend",
6938 persist_state="session",
6939 disabled=swatch_disabled,
6940 )
6941 if comparing:
6942 _compare_saccade_line_rows(0)
6943 else:
6944 # A selectbox, not UX-80's segmented control: four segments do not fit
6945 # beside a caption, and a wrapped control reads as two settings.
6946 if st.session_state.get("global_saccade_style") not in SACCADE_DASH_OPTIONS:
6947 st.session_state["global_saccade_style"] = "Solid"
6948 style_disabled, style_help = _layer_gate(
6949 _dis, _gated_help("Line style for the saccade traces.", _reason)
6950 )
6951 _sub_row("Style", caption_help=style_help).selectbox(
6952 "Saccade line style",
6953 options=list(SACCADE_DASH_OPTIONS.keys()),
6954 key="global_saccade_style",
6955 persist_state="session",
6956 disabled=style_disabled,
6957 help=style_help,
6958 label_visibility="collapsed",
6959 )
6960 _, width_help = _layer_gate(
6961 _dis, _gated_help("Thickness of the saccade lines. Default 2.", _reason)
6962 )
6963 _numeric_slider(
6964 st,
6965 "Saccade line width",
6966 key="global_saccade_width",
6967 persist_state="session",
6968 min_value=SACCADE_WIDTH_BOUNDS[0],
6969 max_value=SACCADE_WIDTH_BOUNDS[1],
6970 step=0.5,
6971 slider_format="%.1f px",
6972 number_format="%.1f",
6973 disabled=_dis,
6974 help=_gated_help("Thickness of the saccade lines. Default 2.", _reason),
6975 field_host=_sub_row("Width", caption_help=width_help),
6976 )
6977 # VIZ-9: "linear reading" schematic — arched saccades. Its paired
6978 # control, "Snap above words", remains under Fixations because it moves
6979 # fixations. Arcs are a `make_scanpath_figure` feature.
6980 shape_disabled, shape_help = _layer_gate(
6981 class_disabled,
6982 _gated_help(
6983 "Straight connectors, or upward **arcs** over the text (the "
6984 f"classic linear-reading diagram). Pairs with {ICONS['fixations']} Fixations ▾ → "
6985 "**Snap above words**.",
6986 class_reason,
6987 ),
6988 )
6989 _sub_row("Shape", caption_help=shape_help).segmented_control(
6990 "Line shape",
6991 options=["Straight", "Arc"],
6992 key="global_saccade_render_mode",
6993 persist_state="session",
6994 disabled=shape_disabled,
6995 help=shape_help,
6996 label_visibility="collapsed",
6997 )
6998 if comparing:
6999 # Scanpath B's group, under A's (whose last row is the shared Shape).
7000 _render_compare_saccade_styles()
7001 # VIZ-23 gave `make_scanpath_animation` an arrow layer of its own (each
7002 # arrowhead un-masks with the saccade it belongs to), so direction
7003 # arrows reach all three builders.
7004 _check_row(
7005 "Direction arrows",
7006 key="global_show_saccade_arrows",
7007 persist_state="session",
7008 help="An arrowhead on each saccade, pointing in the gaze direction.",
7009 )
7011 # VIZ-31: the Saccades section's *filter* sub-section, the counterpart to the
7012 # fixation one above — which reading classes are drawn at all, as opposed to
7013 # what colour they are drawn in. "Show only the regressions" is the figure a
7014 # reading paper asks for, and until now the only way to approximate it was to
7015 # colour the other four classes to match the background. Static-only for the
7016 # same reason the class *colouring* is: the classification never reaches the
7017 # animation or comparison builders (see CLAUDE.md's render-path table), so the
7018 # picker greys out there rather than silently dropping the filter.
7019 # CMP-24: the comparison builders honour the class filter now; only the
7020 # animation still has no classification to filter on.
7021 _cls_dis, _cls_reason = _mode_gate(animating, comparing, in_animation=False)
7022 with (
7023 _rail_subsection(
7024 filter_sac_slot,
7025 f"{ICONS['saccades']} Saccades{ab}{_saccade_filter_badge()}",
7026 note=_cls_reason,
7027 ),
7028 _layer_off(
7029 f"{ICONS['saccades']} Saccades",
7030 off=not show_saccades or not has_fixations,
7031 reason=no_fixations_note or None,
7032 caption=has_fixations,
7033 ),
7034 _popover_rows("filter_sac"),
7035 ):
7036 _labeled(
7037 st,
7038 "multiselect",
7039 "Show saccade types",
7040 display="Types",
7041 options=SACCADE_CLASS_ORDER,
7042 format_func=lambda cls: SACCADE_CLASS_LABELS[cls],
7043 key="global_saccade_classes",
7044 persist_state="session",
7045 disabled=_cls_dis,
7046 help=_gated_help(
7047 "Draw only saccades of these types (Other: starts or lands off "
7048 "the text). Empty draws all.",
7049 _cls_reason,
7050 ),
7051 )
7053 # --- Stimulus ---------------------------------------------------------
7054 # UX-163: the Fixations layout (UX-158). Each of the section's layers is a
7055 # `label | ☑ Show` row — *Text*, *Image* — with what it governs as
7056 # captioned rows under it, greyed while it is off (UX-97) rather than
7057 # hidden; the span highlight is a *Highlight* row of its own. Word boxes
7058 # have their own section now, and the hover fields moved to
7059 # 📐 Figure & canvas → Hover.
7060 #
7061 # UX-128: the layer switches and their settings stay live while the
7062 # section's master switch is off, so a user can set up what they want shown
7063 # before turning it back on — nothing here mutates them; `_collect_viz_
7064 # settings` only ANDs the master into what reaches the figure.
7065 with stim_grp, _popover_rows("stim"):
7066 if not show_stimulus:
7067 st.caption(
7068 f"{ICONS['warning']} **{ICONS['stimulus']} Stimulus** is off — "
7069 "nothing below shows in the plot. Your settings are kept either way."
7070 )
7071 show_labels, _ = _check_row(
7072 "Text",
7073 key="global_show_labels",
7074 persist_state="session",
7075 help="Draw the reading text.",
7076 )
7077 # UX-81: the typography that draws this text lives beside the layer
7078 # that draws it. Reserved here and filled by the single
7079 # `canvas_renderer` call in the 📐 Figure & canvas block below — one
7080 # call draws both halves, since a widget drawn twice is a duplicate-key
7081 # error. Keyed so `styles.py` spaces its rows like the popover's own.
7082 stim_text_slot = st.container(key="rail_rows_stim_text")
7084 # "Highlight a span": the canonical value stays in
7085 # `global_critical_span_style` ("Mark text" | "Mark border" | "None"),
7086 # so deep links / Share / restore are unchanged — the on/off and the
7087 # mode widgets are derived from it each run, and their callbacks write
7088 # it back on interaction.
7089 canonical = st.session_state.get("global_critical_span_style", "Mark text")
7091 def _on_span_toggle():
7092 st.session_state["global_critical_span_style"] = (
7093 st.session_state.get("global_highlight_span_mode", "Mark text")
7094 if st.session_state["global_highlight_span_on"]
7095 else "None"
7096 )
7098 def _on_span_mode():
7099 st.session_state["global_critical_span_style"] = st.session_state[
7100 "global_highlight_span_mode"
7101 ]
7103 st.session_state["global_highlight_span_on"] = canonical != "None"
7104 if canonical in ("Mark text", "Mark border"):
7105 st.session_state["global_highlight_span_mode"] = canonical
7106 else:
7107 st.session_state.setdefault("global_highlight_span_mode", "Mark text")
7109 # VIZ-23: the span's *text*-marking channel reaches all three builders —
7110 # the animation and the comparison figure take `highlight_column` +
7111 # `highlight_text_color` and recolour the word labels. Neither has a
7112 # border-overlay layer, so **Mark border** stays a `make_scanpath_figure`
7113 # feature and only its colour picker is gated (`tabs._marked_text_column`
7114 # hands the other two builders `None` under that style, so nothing is
7115 # marked there rather than silently falling back to text marking).
7116 border_disabled, border_reason = _mode_gate(
7117 animating, comparing, **_static_only
7118 )
7119 span_on, span_rest = _check_row(
7120 "Highlight",
7121 key="global_highlight_span_on",
7122 persist_state="session",
7123 on_change=_on_span_toggle,
7124 help="Mark the words where the chosen true/false column is true "
7125 "(OneStop: its answer span).",
7126 )
7127 span_off_disabled, _ = _layer_gate(not span_on, None)
7128 if highlight_options:
7129 highlight_labels = cn.active(st.session_state, "words").option_labels(
7130 highlight_options
7131 )
7132 span_rest.selectbox(
7133 "Highlight words by",
7134 options=highlight_options,
7135 format_func=highlight_labels.__getitem__,
7136 key="global_highlight_column",
7137 persist_state="session",
7138 disabled=span_off_disabled,
7139 label_visibility="collapsed",
7140 placeholder="Choose a column",
7141 # #374 F6: nothing is seeded outside the demo, and an unseeded
7142 # selectbox would otherwise pick its first option itself.
7143 **(
7144 {}
7145 if "global_highlight_column" in st.session_state
7146 else {"index": None}
7147 ),
7148 )
7149 style_help = (
7150 "**Mark text**: color the span's words (needs **Text** on). "
7151 "**Mark border**: outline the span. The box beside it is the color."
7152 + (
7153 f"\n\n{ICONS['warning']} **Mark border** is drawn on the static "
7154 "figure only."
7155 if border_disabled
7156 else ""
7157 )
7158 )
7159 style_field = _sub_row("Style", caption_help=style_help)
7160 mode_col, span_color_col = style_field.columns(
7161 [0.75, 0.25], gap=_LABEL_GAP, vertical_alignment="center"
7162 )
7163 span_mode = mode_col.radio(
7164 "Style",
7165 options=["Mark text", "Mark border"],
7166 horizontal=True,
7167 key="global_highlight_span_mode",
7168 persist_state="session",
7169 on_change=_on_span_mode,
7170 disabled=span_off_disabled,
7171 label_visibility="collapsed",
7172 )
7173 critical_span_style = span_mode if span_on else "None"
7174 st.session_state["global_critical_span_style"] = critical_span_style
7175 if span_mode == "Mark border":
7176 span_color_col.color_picker(
7177 "Border color",
7178 key="global_span_border_color",
7179 persist_state="session",
7180 disabled=span_off_disabled or border_disabled,
7181 help=_gated_help(
7182 "Color of the span outline (used with 'Mark border').",
7183 border_reason,
7184 ),
7185 label_visibility="collapsed",
7186 )
7187 else:
7188 span_color_col.color_picker(
7189 "Highlighted text color",
7190 key="global_highlight_text_color",
7191 persist_state="session",
7192 disabled=span_off_disabled,
7193 label_visibility="collapsed",
7194 )
7196 # VIZ-4: a stimulus image can come from the dataset (MultiplEYE stamps a
7197 # per-trial `image_path`) OR be uploaded here for any dataset (a
7198 # full-monitor screenshot of the reading screen). The upload's `data:`
7199 # URI is stashed in session for the tab to place + the (pure) collector
7200 # to read; the switch is enabled whenever either source exists.
7201 uploaded_img = st.session_state.get("global_stimulus_image_upload")
7202 upload_uri = _uploaded_image_data_uri(uploaded_img)
7203 st.session_state["_stimulus_image_upload_uri"] = upload_uri
7204 can_show_image = has_stimulus_image or upload_uri is not None
7205 # VIZ-23: `background_image*` are parameters of all three builders — the
7206 # comparison figure places one `layout.image` per panel in the split
7207 # layouts — so the whole image group is live in every mode.
7208 show_stim_image, _ = _check_row(
7209 "Image",
7210 key="global_show_stimulus_image",
7211 persist_state="session",
7212 disabled=not can_show_image,
7213 help="The stimulus page behind the scanpath: the dataset's own image, "
7214 "or one you upload."
7215 + ("" if can_show_image else " Upload one below to switch it on."),
7216 )
7217 # The uploader is never greyed: it is the only way to get an image in
7218 # and enable the switch in the first place.
7219 _sub_row(
7220 "File",
7221 caption_help="Upload a screenshot of the reading screen. It replaces "
7222 "the dataset's image and is stretched to the monitor; "
7223 "offset and scale below align it. Not included in Share "
7224 "links.",
7225 ).file_uploader(
7226 "Upload a stimulus image",
7227 type=["png", "jpg", "jpeg", "gif", "webp"],
7228 # No persist_state: st.file_uploader does not take it, and an
7229 # UploadedFile is already stashed by _uploaded_image_data_uri.
7230 key="global_stimulus_image_upload",
7231 max_upload_size=upload_limit_mb(),
7232 label_visibility="collapsed",
7233 )
7234 # The placement rows need an image to place: with none loaded they
7235 # could never come alive, so they wait for one rather than sit greyed.
7236 # Loaded but switched off, they grey (UX-97).
7237 if can_show_image:
7238 image_idle = not show_stim_image
7239 opacity_help = "Image opacity."
7240 _numeric_slider(
7241 st,
7242 "Image opacity",
7243 key="global_stimulus_image_opacity",
7244 persist_state="session",
7245 min_value=0.1,
7246 max_value=1.0,
7247 step=0.05,
7248 number_format="%.2f",
7249 disabled=image_idle,
7250 help=opacity_help,
7251 field_host=_sub_row("Opacity", caption_help=opacity_help),
7252 )
7253 # VIZ-4: manual alignment. When the data's coordinates don't match the
7254 # image exactly, nudge it (X/Y px) and scale it to line it up with the
7255 # word boxes and fixations. Dataset and uploaded images alike.
7256 offset = _sub_row(
7257 "Offset",
7258 caption_help="Shift the image right (X) and down (Y), in px, to "
7259 "align it with the text.",
7260 )
7261 x_cap, x_col, y_cap, y_col = offset.columns(
7262 [0.1, 0.4, 0.1, 0.4], gap=_LABEL_GAP, vertical_alignment="center"
7263 )
7264 _sub_caption(x_cap, "X")
7265 x_col.number_input(
7266 "Image X offset (px)",
7267 step=5.0,
7268 key="global_stimulus_image_offset_x",
7269 persist_state="session",
7270 disabled=image_idle,
7271 label_visibility="collapsed",
7272 )
7273 _sub_caption(y_cap, "Y")
7274 y_col.number_input(
7275 "Image Y offset (px)",
7276 step=5.0,
7277 key="global_stimulus_image_offset_y",
7278 persist_state="session",
7279 disabled=image_idle,
7280 label_visibility="collapsed",
7281 )
7282 scale_help = (
7283 "Scale the image so its text matches the word boxes (1 = as placed)."
7284 )
7285 _numeric_slider(
7286 st,
7287 "Image scale",
7288 key="global_stimulus_image_scale",
7289 persist_state="session",
7290 min_value=0.25,
7291 max_value=3.0,
7292 step=0.05,
7293 number_format="%.2f",
7294 disabled=image_idle,
7295 help=scale_help,
7296 field_host=_sub_row("Scale", caption_help=scale_help),
7297 )
7299 # --- Heatmap ----------------------------------------------------------
7300 # Compare supports a shared word-box scale: overlay splits each box into
7301 # A/B halves; side-by-side and stacked tint their respective full boxes.
7302 # Animation remains disabled because a time-varying density layer would
7303 # need a distinct frame contract. The toggle itself is on the section's
7304 # row now (UX-86); this block only owns the style popover's contents.
7305 # UX-160: the Fixations layout (UX-158) — a *Style* row (the style picker,
7306 # plus Duration mass's spread, greyed for the other two styles), then one
7307 # *Color* group: what is mapped and its colorscale, the scaling, the range.
7308 with (
7309 heatmap_grp,
7310 _layer_off(
7311 f"{ICONS['heatmap']} Heatmap",
7312 off=not show_heatmap or heat_nothing,
7313 reason=heat_nothing_note if heat_nothing else None,
7314 ),
7315 _popover_rows("heatmap"),
7316 ):
7317 # A selectbox now rather than a radio: three long options do not fit
7318 # one line beside a title, and a wrapped radio reads as two settings.
7319 # `persist_state` shows the seeded style on first open — the reason
7320 # this was a radio, not a segmented control, before ENG-36.
7321 style_disabled, style_help = _layer_gate(
7322 heat_disabled or comparing,
7323 _gated_help(
7324 "Word boxes: color each word box by its fixations. Interpolated: "
7325 "the fixations blurred with a Gaussian (Blur, below), scaled to "
7326 "the figure's own peak. Compare always uses word boxes.",
7327 "Comparison heatmaps use split word boxes."
7328 if comparing
7329 else heat_reason,
7330 ),
7331 )
7332 heat_style = _sub_row(
7333 None,
7334 section="Style",
7335 section_help=style_help,
7336 ).selectbox(
7337 "Style",
7338 options=["Word boxes", "Interpolated"],
7339 key="global_heatmap_style",
7340 persist_state="session",
7341 disabled=style_disabled,
7342 help=style_help,
7343 label_visibility="collapsed",
7344 )
7345 _render_heatmap_blur_row(
7346 trial_fixations,
7347 words,
7348 disabled=heat_disabled or comparing or heat_style != "Interpolated",
7349 reason="Comparison heatmaps use split word boxes."
7350 if comparing
7351 else heat_reason,
7352 )
7353 metric_disabled_h, metric_help = _layer_gate(
7354 heat_disabled,
7355 _gated_help(
7356 "What the heatmap shows, and its color scale.",
7357 heat_reason,
7358 ),
7359 )
7360 field = _sub_row(
7361 "By",
7362 section="Color",
7363 section_help="What the heatmap colors by and how.",
7364 caption_help=metric_help,
7365 )
7366 # In Compare each scanpath picks its own colour scale (its group,
7367 # below), so the metric takes the whole row.
7368 metric_col, scale_col = (
7369 (field, None)
7370 if comparing
7371 else field.columns([0.5, 0.5], gap=_LABEL_GAP, vertical_alignment="center")
7372 )
7373 metric_labels = _rail_names().option_labels(
7374 ["duration_ms", "counts"], {"counts": "Fixation count"}, roles=True
7375 )
7376 heatmap_metric = metric_col.selectbox(
7377 "Metric",
7378 options=["duration_ms", "counts"],
7379 format_func=metric_labels.__getitem__,
7380 key="global_heatmap_metric",
7381 persist_state="session",
7382 # A pinned range is in the old metric's units: back to auto.
7383 on_change=forget_color_range,
7384 args=("global_heatmap_color_range",),
7385 disabled=metric_disabled_h,
7386 help=metric_help,
7387 label_visibility="collapsed",
7388 )
7389 if scale_col is not None:
7390 # Keyless on purpose — see `_popover_selectbox`.
7391 current_scale = st.session_state.get("global_heatmap_colorscale")
7392 st.session_state["global_heatmap_colorscale"] = scale_col.selectbox(
7393 "Colors",
7394 COLORSCALES,
7395 index=COLORSCALES.index(current_scale)
7396 if current_scale in COLORSCALES
7397 else 0,
7398 disabled=metric_disabled_h,
7399 help=metric_help,
7400 label_visibility="collapsed",
7401 )
7402 norm_disabled, norm_help = _layer_gate(
7403 heat_disabled,
7404 _gated_help(
7405 "Linear: color follows the value. Log: color follows log(1 + "
7406 "value), so a few high values don't wash out the rest.",
7407 heat_reason,
7408 ),
7409 )
7410 # `required` — a radio before UX-160, so it could never be deselected;
7411 # an empty segmented control would read "nothing" while the figure
7412 # draws Linear (the collector's fallback).
7413 _sub_row("Scale", caption_help=norm_help).segmented_control(
7414 "Scaling",
7415 options=["Linear", "Log"],
7416 required=True,
7417 key="global_heatmap_norm",
7418 persist_state="session",
7419 disabled=norm_disabled,
7420 help=norm_help,
7421 label_visibility="collapsed",
7422 )
7423 # Finding 11: bounded by what a word box maps — its summed dwell —
7424 # not by the longest single fixation, which refixations exceed.
7425 # The dwell groupby runs only while the heatmap is shown; switched off,
7426 # the greyed range is drawn from the single-fixation span instead.
7427 counts = heatmap_metric == "counts"
7428 heat_bounds = (
7429 _heatmap_bounds_for_rail(trial_fixations, words, counts=counts)
7430 if show_heatmap
7431 else _cheap_heatmap_bounds(trial_fixations, counts=counts)
7432 )
7433 if heat_bounds is not None:
7434 # The scale starts at 0 (an empty word), as the figure's auto does.
7435 hmin = 0.0
7436 hmax = float(math.ceil(heat_bounds[1]))
7437 hmax_eff = hmax if hmax > hmin else hmin + 1.0
7438 range_text = (
7439 "Fixations per word" if counts else "Word dwell time (ms)"
7440 ) + (
7441 " at the two ends of the color scale; auto runs from 0 to the "
7442 "trial's highest. You can type values beyond the slider."
7443 )
7444 # Finding 12: the smoothed styles scale their density to their own
7445 # peak, so a range does nothing there — greyed, and kept for Word
7446 # boxes. Compare always draws word boxes, so it applies again.
7447 self_scaled = not comparing and heat_style in SELF_SCALED_HEATMAP_STYLES
7448 # VIZ-46: auto (per trial, like the API) until a range is chosen.
7449 _render_color_range(
7450 "Color range",
7451 "global_heatmap_color_range",
7452 hmin,
7453 hmax_eff,
7454 disabled=heat_disabled or self_scaled,
7455 reason=heat_reason
7456 or (
7457 f"{ICONS['warning']} **{heat_style}** is scaled to each "
7458 "figure's own peak, so the range does not apply to it."
7459 if self_scaled
7460 else ""
7461 ),
7462 help=range_text,
7463 slider_format="%d" if counts else "%d ms",
7464 field_host=_sub_row("Range", caption_help=range_text),
7465 )
7467 if comparing:
7468 # The per-scanpath groups, after the rows both share.
7469 for idx, _ in _COMPARE_SCANPATHS:
7470 _compare_heatmap_colorscale_row(
7471 idx, disabled=heat_disabled, reason=heat_reason
7472 )
7473 _render_colorbar_rows("heatmap", disabled=heat_disabled, reason=heat_reason)
7475 # Raw gaze is drawn by the static and comparison builders. The toggle is on
7476 # the section's row (UX-86); this owns the style popover — previously
7477 # nothing, since raw gaze had no styling of its own before it got a section.
7478 # UX-161: one *Marker* group, as in 👁️ Fixations (UX-158).
7479 with (
7480 raw_gaze_grp,
7481 _layer_off(f"{ICONS['raw_gaze']} Raw gaze", off=not show_raw_gaze),
7482 _popover_rows("rawgaze"),
7483 ):
7484 if comparing:
7485 # VIZ-48: a comparison colours each reading's samples by its own
7486 # scanpath (the A/B cue), so the *Marker* group becomes scanpath
7487 # A's — as in 👁️ Fixations — and B's group follows it.
7488 _compare_raw_gaze_color_row(0, disabled=raw_disabled)
7489 else:
7490 color_disabled, color_help = _layer_gate(
7491 raw_disabled,
7492 _gated_help(
7493 "Not used outside Compare: samples are colored by time (or order).",
7494 raw_reason,
7495 ),
7496 )
7497 _sub_row(
7498 "Color",
7499 section="Marker",
7500 section_help="How each raw-gaze sample is drawn: color, size and "
7501 "opacity.",
7502 caption_help=color_help,
7503 ).color_picker(
7504 "Color",
7505 key="global_raw_gaze_color",
7506 persist_state="session",
7507 disabled=color_disabled,
7508 help=color_help,
7509 label_visibility="collapsed",
7510 )
7511 size_help = "Diameter of each raw-gaze sample dot, in px."
7512 _numeric_slider(
7513 st,
7514 "Marker size",
7515 key="global_raw_gaze_marker_size",
7516 persist_state="session",
7517 min_value=1.0,
7518 max_value=12.0,
7519 step=0.5,
7520 disabled=raw_disabled,
7521 help=size_help,
7522 field_host=_sub_row("Size", caption_help=_layer_gate(False, size_help)[1]),
7523 )
7524 opacity_help = "Sample opacity; lower it to see dense clusters."
7525 _numeric_slider(
7526 st,
7527 "Opacity",
7528 key="global_raw_gaze_opacity",
7529 persist_state="session",
7530 min_value=0.1,
7531 max_value=1.0,
7532 step=0.05,
7533 number_format="%.2f",
7534 disabled=raw_disabled,
7535 help=opacity_help,
7536 field_host=_sub_row(
7537 "Opacity", caption_help=_layer_gate(False, opacity_help)[1]
7538 ),
7539 )
7540 if comparing:
7541 # Scanpath B's group, under A's (whose size and opacity are shared).
7542 _compare_raw_gaze_color_row(1, disabled=raw_disabled)
7543 # --- Word boxes -------------------------------------------------------
7544 # The interest areas' outline and fill. One *Box* group, as raw gaze's
7545 # *Marker* (UX-161). All three render paths draw the boxes; a static
7546 # comparison outlines and fills each reading's in its own colours.
7547 with (
7548 boxes_grp,
7549 _layer_off(
7550 f"{ICONS['word_boxes']} Word boxes",
7551 off=not show_word_boxes or not has_words,
7552 reason=None
7553 if has_words
7554 else f"{ICONS['warning']} This trial has no word boxes to draw.",
7555 ),
7556 _popover_rows("boxes"),
7557 ):
7558 # The co-animation (Compare + Animate) draws one set of boxes in these
7559 # colours; the static comparison outlines and fills each reading's
7560 # boxes on its own, so there the *Box* group becomes scanpath A's, and
7561 # scanpath B's follows it — as the Fixations popover does.
7562 box_section_help = "How each word's box (as given in the data) is drawn."
7563 fill_text = (
7564 "Keep its opacity low so the text, fixations and image under the "
7565 "boxes still read; 0 draws outlines only."
7566 )
7567 if comparing and not animating:
7568 _render_compare_box_groups(
7569 "Defaults to the figure's fill color. " + fill_text
7570 )
7571 else:
7572 line_disabled, line_help = _layer_gate(
7573 False, "Color of each word box's outline."
7574 )
7575 color_col, opacity_col = _sub_row(
7576 "Line",
7577 section="Box",
7578 section_help=box_section_help,
7579 caption_help=line_help,
7580 ).columns(_COLOR_OPACITY_W, gap=_LABEL_GAP, vertical_alignment="center")
7581 color_col.color_picker(
7582 "Line color",
7583 key="global_word_box_color",
7584 persist_state="session",
7585 disabled=line_disabled,
7586 help=line_help,
7587 label_visibility="collapsed",
7588 )
7589 _box_opacity(
7590 opacity_col,
7591 key="global_word_box_line_opacity",
7592 persist_state="session",
7593 label="Line opacity",
7594 help=_LINE_OPACITY_HELP,
7595 )
7596 fill_disabled, fill_help = _layer_gate(
7597 False,
7598 "Color the inside of each box is filled with, at the opacity "
7599 "beside it. " + fill_text,
7600 )
7601 color_col, opacity_col = _sub_row("Fill", caption_help=fill_help).columns(
7602 _COLOR_OPACITY_W, gap=_LABEL_GAP, vertical_alignment="center"
7603 )
7604 color_col.color_picker(
7605 "Fill color",
7606 key="global_word_box_fill_color",
7607 persist_state="session",
7608 disabled=fill_disabled,
7609 help=fill_help,
7610 label_visibility="collapsed",
7611 )
7612 _box_opacity(
7613 opacity_col,
7614 key="global_word_box_fill_opacity",
7615 persist_state="session",
7616 label="Fill opacity",
7617 help=_FILL_OPACITY_HELP,
7618 )
7620 # --- Figure & canvas --------------------------------------------------
7621 # UX-80/81: one popover, four named groups inside it and nothing nested —
7622 #
7623 # 🖥️ Screen & framing Crop to data (the monitor's size is the Data
7624 # page's Recording setup)
7625 # 📊 Axes & grid the coordinate grid and the axis fields (each
7626 # colour bar is under its own layer)
7627 # 🏷️ Title & labels the Illustration disclosure + the EXP-5 title
7628 # 💬 Hover the word and fixation tooltip fields (moved here
7629 # from 📄 Stimulus and 👁️ Fixations)
7630 #
7631 # 🔤 Text & fonts is **not** here any more: it describes the stimulus text,
7632 # so it moved to 📄 Stimulus → Text (UX-81), beside the layer it draws. The
7633 # physical-geometry fields (monitor width in mm, viewing distance, DPI) went
7634 # with the same pass — they are experiment facts the 🗂️ Data page's Recording
7635 # setup already owns, and a second set here could disagree with it.
7636 #
7637 # The three containers are created up front so each block keeps its place in
7638 # this file while landing in the right group.
7639 screen_group = _rail_subsection(figure_grp, f"{ICONS['screen']} Screen & framing")
7640 axes = _rail_subsection(figure_grp, f"{ICONS['axes']} Axes & grid")
7641 labels = _rail_subsection(figure_grp, f"{ICONS['labels']} Title & labels")
7642 hover = _rail_subsection(figure_grp, f"{ICONS['hover']} Hover")
7643 legends = _rail_subsection(figure_grp, f"{ICONS['legend']} Legends")
7644 # UX-163: each block's rows take the popover layout (`_popover_rows`) — the
7645 # framing switch, the grid and the colour bar become `label | ☑ Show | …`
7646 # rows carrying what they govern (greyed while off), the monitor size and
7647 # the two axis fields one row each.
7648 with screen_group, _popover_rows("fig_screen"):
7649 # The box reads "crop", the wire key "fit to monitor" — its inverse. The
7650 # box is a shadow re-seeded from the key every run, so links, configs and
7651 # presets that set the key move it, and only a click writes the key.
7652 crop_key = "_rail_crop_to_data"
7653 st.session_state[crop_key] = not st.session_state.get(
7654 "global_fit_to_monitor", True
7655 )
7657 def _apply_crop() -> None:
7658 if _shadow_key_missing(crop_key): # BUG-18
7659 return
7660 st.session_state["global_fit_to_monitor"] = not st.session_state[crop_key]
7662 _check_row(
7663 "Frame",
7664 key=crop_key,
7665 on_change=_apply_crop,
7666 check_label="Crop to data",
7667 check_share=0.6,
7668 help="Off: show the whole monitor. On: zoom to the fixations and word "
7669 "boxes, plus a 5% margin.",
7670 )
7671 screen_rows = st.container(key="rail_rows_fig_screen_canvas")
7672 if canvas_renderer is not None:
7673 # UX-163: the typography rows always draw, greyed while *Text* is off
7674 # (`text_disabled`), at the popovers' label width. BUG-38 still holds:
7675 # they belong to 📄 Stimulus → Text and never fall back into this one.
7676 with _rail_label_width(_POPOVER_LABEL_W):
7677 canvas_renderer(
7678 screen_rows,
7679 text_host=stim_text_slot,
7680 text_disabled=not show_labels,
7681 )
7683 with axes, _popover_rows("fig_axes"):
7684 show_coordinate_grid, grid_rest = _check_row(
7685 "Grid",
7686 key="global_show_coordinate_grid",
7687 persist_state="session",
7688 help="A grid of screen coordinates, in monitor pixels. Auto picks the "
7689 "interval; untick it to set the major interval (px).",
7690 )
7691 grid_off_disabled, _ = _layer_gate(not show_coordinate_grid, None)
7692 auto_col, spacing_col, px_col = grid_rest.columns(
7693 [0.4, 0.42, 0.18], gap=_LABEL_GAP, vertical_alignment="center"
7694 )
7695 automatic_grid = auto_col.checkbox(
7696 "Auto",
7697 key="global_coordinate_grid_auto",
7698 persist_state="session",
7699 disabled=grid_off_disabled,
7700 )
7701 spacing_col.number_input(
7702 "Major grid interval (px)",
7703 min_value=10.0,
7704 max_value=5000.0,
7705 step=10.0,
7706 key="global_coordinate_grid_spacing",
7707 persist_state="session",
7708 disabled=grid_off_disabled or automatic_grid,
7709 label_visibility="collapsed",
7710 )
7711 _sub_caption(px_col, "px")
7713 # The animation and the comparison figures always plot spatial x/y —
7714 # only `make_scanpath_figure` takes `x_field`/`y_field`.
7715 axis_disabled, axis_reason = _mode_gate(animating, comparing, **_static_only)
7716 axis_disabled, axis_help = _layer_gate(
7717 axis_disabled,
7718 _gated_help(
7719 "The fixation columns on the X and Y axes. Only x / y (screen "
7720 "position) is fully supported; with any other field the plot "
7721 "shows fixation markers only — no word boxes, text, saccades, "
7722 "heatmap or coordinate grid.",
7723 axis_reason,
7724 ),
7725 )
7726 label_w = _label_w()
7727 rest = 1.0 - label_w
7728 axes_cols = st.columns(
7729 [label_w, rest * 0.1, rest * 0.4, rest * 0.1, rest * 0.4],
7730 gap=_LABEL_GAP,
7731 vertical_alignment="center",
7732 )
7733 _row_label(axes_cols[0], "Axes", axis_help)
7734 _sub_caption(axes_cols[1], "X")
7735 axis_labels = _rail_names().option_labels(numeric_fields, roles=True)
7736 axes_cols[2].selectbox(
7737 "X axis field",
7738 options=numeric_fields,
7739 format_func=axis_labels.__getitem__,
7740 key="global_x_field",
7741 persist_state="session",
7742 disabled=axis_disabled,
7743 label_visibility="collapsed",
7744 )
7745 _sub_caption(axes_cols[3], "Y")
7746 axes_cols[4].selectbox(
7747 "Y axis field",
7748 options=numeric_fields,
7749 format_func=axis_labels.__getitem__,
7750 key="global_y_field",
7751 persist_state="session",
7752 disabled=axis_disabled,
7753 label_visibility="collapsed",
7754 )
7755 if (
7756 st.session_state.get("global_x_field", "x"),
7757 st.session_state.get("global_y_field", "y"),
7758 ) != ("x", "y"):
7759 st.caption(
7760 f"{ICONS['warning']} Limited support: the plot shows fixation "
7761 "markers only — no word boxes, text, saccades, heatmap or "
7762 "coordinate grid."
7763 )
7765 # EXP-5: title/caption on the figure — moved here from being Export-only
7766 # (EXP-2), so it's visible live rather than a setting a user has to remember
7767 # to go find under Export. This is now the single source of truth: the
7768 # Export panel's bulk section reads these two patterns back instead of
7769 # keeping its own copy, and the live figure on screen (all three render
7770 # paths) carries the same title/caption a bulk export would produce.
7771 def _prefill(show_key: str, pattern_key: str, default: str) -> None:
7772 # Switching one on fills an empty box with a starting pattern, rather
7773 # than leaving one the user has to know the field syntax to fill. It
7774 # has to be the switch's callback: `_seed_viz_state` already seeded the
7775 # pattern to "" this run, so a `setdefault` here would be a no-op.
7776 if st.session_state.get(show_key) and not st.session_state.get(pattern_key):
7777 st.session_state[pattern_key] = default
7779 with labels, _popover_rows("fig_labels"):
7780 label_help = (
7781 "Auto: label the figure when a setting changes its geometry or data. "
7782 "Show: always label it. Hide: never."
7783 )
7784 label_mode = _sub_row(
7785 "Label",
7786 section="Illustration",
7787 section_help="A note in the figure's corner saying it is not drawn "
7788 "exactly as recorded.",
7789 caption_help=label_help,
7790 ).selectbox(
7791 "Illustration label",
7792 options=["Auto", "Show", "Hide"],
7793 key="global_illustration_label",
7794 persist_state="session",
7795 help=label_help,
7796 label_visibility="collapsed",
7797 )
7798 text_help = "The label's text. Empty: “Illustration ·” and the reasons."
7799 _sub_row("Text", caption_help=text_help).text_input(
7800 "Illustration text",
7801 key="global_illustration_text",
7802 persist_state="session",
7803 placeholder="Illustration · <reasons>",
7804 disabled=label_mode == "Hide",
7805 help=text_help,
7806 label_visibility="collapsed",
7807 )
7808 # EXP-22: the *selected* trial's frames, not the corpus the rail was
7809 # handed — its tables name the `{table.field}` fields the boxes validate
7810 # against and the list shows, and "one value per trial" has to be read
7811 # off one trial.
7812 _sel_fix = (
7813 fix_range_fixations if fix_range_fixations is not None else pd.DataFrame()
7814 )
7815 # Read only while one is shown: with both off, the greyed boxes ask
7816 # nothing of the fields (`preview=False`), so the default rerun does no
7817 # title/caption work at all (PERF-7's rule).
7818 _title_caption_fields = (
7819 pattern_fields(
7820 "p01",
7821 "t01",
7822 _trial_rows(words, _sel_fix),
7823 _sel_fix,
7824 {},
7825 dataset_name=current_dataset_name(),
7826 metadata_rows=_selected_metadata_rows(_sel_fix),
7827 # DATA-66: the field list offers the dataset's own names too.
7828 column_names=_rail_names(),
7829 )
7830 if st.session_state.get("global_show_title")
7831 or st.session_state.get("global_show_caption")
7832 else {}
7833 )
7834 any_shown = False
7835 for name, show_key, pattern_key, default, help_text in (
7836 (
7837 "Title",
7838 "global_show_title",
7839 "global_title_pattern",
7840 DEFAULT_TITLE_PATTERN,
7841 "A line of text above the plot. {field} inserts a value of this "
7842 "trial; the figure grows to make room.",
7843 ),
7844 (
7845 "Caption",
7846 "global_show_caption",
7847 "global_caption_pattern",
7848 DEFAULT_CAPTION_PATTERN,
7849 "A line of text below the plot. {field} inserts a value of this "
7850 "trial; the figure grows to make room.",
7851 ),
7852 ):
7853 shown, rest = _check_row(
7854 name,
7855 key=show_key,
7856 persist_state="session",
7857 on_change=_prefill,
7858 args=(show_key, pattern_key, default),
7859 help=help_text,
7860 )
7861 any_shown = any_shown or shown
7862 render_pattern_input(
7863 rest,
7864 name,
7865 pattern_key,
7866 _title_caption_fields,
7867 help=help_text,
7868 disabled=not shown,
7869 label_visibility="collapsed",
7870 preview=shown,
7871 )
7872 if any_shown:
7873 render_pattern_help(st.container(), _title_caption_fields)
7875 # The tooltips' fields, for words and for fixations — figure-wide rather
7876 # than one layer's, so they sit together here instead of closing the
7877 # 📄 Stimulus and 👁️ Fixations popovers. Both are honoured by all three
7878 # render paths (the comparison builders take them too), so neither carries
7879 # a `_mode_gate`; and neither greys with its layer, since a hidden layer's
7880 # tooltip choice is still the one it shows when switched back on.
7881 with hover, _popover_rows("fig_hover"):
7882 word_names = cn.active(st.session_state, "words")
7883 word_hover = word_names.sort_options(hover_field_options(words, words=True))
7884 word_hover_labels = word_names.option_labels(word_hover, roles=True)
7885 _labeled(
7886 st,
7887 "multiselect",
7888 "Word hover fields",
7889 display="Words",
7890 options=word_hover,
7891 format_func=word_hover_labels.__getitem__,
7892 key="global_word_hover_fields",
7893 persist_state="session",
7894 help="Fields shown when hovering a word.",
7895 )
7896 fix_names = _rail_names()
7897 fix_hover = fix_names.sort_options(hover_field_options(trial_fixations))
7898 fix_hover_labels = fix_names.option_labels(fix_hover, roles=True)
7899 _labeled(
7900 st,
7901 "multiselect",
7902 "Fixation hover fields",
7903 display="Fixations",
7904 options=fix_hover,
7905 format_func=fix_hover_labels.__getitem__,
7906 key="global_fixation_hover_fields",
7907 persist_state="session",
7908 help="Fields shown when hovering a fixation, in this order.",
7909 )
7911 # Where each legend sits. In addition to each layer's own *Show legend*
7912 # switch, never instead of it: a legend that is off stays off wherever it
7913 # is placed. Auto everywhere draws the figure as it always was.
7914 with legends, _popover_rows("fig_legends"):
7915 for kind in LEGEND_KINDS:
7916 # A row whose legend the current figure cannot draw greys out, its
7917 # values kept (no `index=`/`value=`), like every gated rail control.
7918 gated_off = {
7919 "compare": None
7920 if comparing
7921 else "Only in Compare: the A/B legend names the two scanpaths.",
7922 "saccades": "Only on the static figure: the replay and Compare "
7923 "draw no saccade-type legend."
7924 if animating or comparing
7925 else None,
7926 }.get(kind)
7927 field = _sub_row(
7928 LEGEND_KIND_LABELS[kind],
7929 caption_help=gated_off or _LEGEND_ROW_HELP[kind],
7930 )
7931 pos_col, arr_col, size_col = field.columns(
7932 [0.44, 0.34, 0.22], gap=_LABEL_GAP, vertical_alignment="center"
7933 )
7934 pos_col.selectbox(
7935 f"{LEGEND_KIND_LABELS[kind]} legend position",
7936 disabled=bool(gated_off),
7937 options=list(LEGEND_POSITION_LABELS),
7938 format_func=LEGEND_POSITION_LABELS.__getitem__,
7939 key=f"global_legend_{kind}_position",
7940 persist_state="session",
7941 label_visibility="collapsed",
7942 help="Where this legend sits. Above, Below, Left and Right are "
7943 "outside the plot (the figure grows to make room); the Inside "
7944 "spots sit over it. Auto: where it is drawn by default.",
7945 )
7946 arr_col.selectbox(
7947 f"{LEGEND_KIND_LABELS[kind]} legend arrangement",
7948 disabled=bool(gated_off),
7949 options=list(LEGEND_ARRANGEMENT_LABELS),
7950 format_func=LEGEND_ARRANGEMENT_LABELS.__getitem__,
7951 key=f"global_legend_{kind}_arrangement",
7952 persist_state="session",
7953 label_visibility="collapsed",
7954 help="Stacked: one item under the other. Side by side: in a "
7955 "row. Auto: a row above or below the plot, a stack elsewhere.",
7956 )
7957 size_col.number_input(
7958 f"{LEGEND_KIND_LABELS[kind]} legend text size",
7959 disabled=bool(gated_off),
7960 min_value=6,
7961 max_value=72,
7962 step=1,
7963 key=f"global_legend_{kind}_size",
7964 persist_state="session",
7965 placeholder="Auto",
7966 label_visibility="collapsed",
7967 help="Text size in px. Empty: the figure's own.",
7968 )
7970 # Build the dict from session_state so it matches viz_settings_from_state
7971 # exactly; then fill in the per-scanpath comparison styling, shown only when
7972 # the Compare toggle (rail plot-controls section) is on, so all styling sits here.
7973 settings = _collect_viz_settings(
7974 trial_fixations,
7975 words,
7976 numeric_fields=numeric_fields,
7977 highlight_options=highlight_options,
7978 )
7979 # The per-scanpath comparison styling is rendered inline under each layer's
7980 # popover (Fixation / Saccade) above; here we just collect it from the keys.
7981 if st.session_state.get("single_compare_toggle"):
7982 settings["compare_style_a"], settings["compare_style_b"] = (
7983 _collect_compare_styles()
7984 )
7985 return settings
7988# Cached option-list scans for the trial-filter panel. These run on every
7989# rerun to populate the multiselects; caching them on a cheap frame fingerprint
7990# keeps them off the hot path on large corpora (full-column unique() scans).
7991@st.cache_data(show_spinner=False)
7992def _participant_options(
7993 _words: pd.DataFrame, _fixations: pd.DataFrame, cache_key
7994) -> list[str]:
7995 return sorted(
7996 set(_words["participant_id"].dropna().astype(str))
7997 | set(_fixations["participant_id"].dropna().astype(str))
7998 )
8001@st.cache_data(show_spinner=False)
8002def _column_unique_strs(_df: pd.DataFrame, column: str, cache_key) -> list[str]:
8003 if column not in _df.columns:
8004 return []
8005 # Drop missing values, including the literal "nan" a string-coerced optional
8006 # field leaves for NaN (e.g. ET2 readers with no recorded gender) — a "nan"
8007 # filter option would be meaningless.
8008 values = _df[column].dropna().astype(str).unique()
8009 return sorted(v for v in values if v.strip().lower() not in ("nan", "none", "<na>"))
8012@st.cache_data(show_spinner=False)
8013def _numeric_column_bounds(_df: pd.DataFrame, column: str, cache_key):
8014 """``(lo, hi, distinct)`` over a column's **finite** values, or ``None``.
8016 UX-49's slider needs finite bounds and at least two distinct values — a
8017 one-option range control is the same family as the single-option
8018 ``st.select_slider`` that throws ``RangeError`` in the browser. ``None`` here
8019 means "render no slider", not "render a degenerate one". Infinities are
8020 dropped along with NaN: one ``inf`` would otherwise pin the whole slider.
8022 A full-column scan, hence the cache — keyed on the frame fingerprint by the
8023 caller, exactly like ``_column_unique_strs``.
8024 """
8025 if column not in _df.columns:
8026 return None
8027 values = pd.to_numeric(_df[column], errors="coerce")
8028 finite = values[np.isfinite(values)] if len(values) else values
8029 if finite.empty:
8030 return None
8031 distinct = int(finite.nunique())
8032 if distinct < 2:
8033 return None
8034 lo, hi = finite.min(), finite.max()
8035 # A whole-numbered column gets whole-numbered bounds, so the slider steps in
8036 # 1s and reads "3 – 17" rather than "3.00 – 17.00". Streamlit picks int vs
8037 # float behaviour from the *type* of min_value/max_value, so the decision has
8038 # to be made here, on the data. `%` on a float that happens to be whole (an
8039 # int column with NaNs is float64) counts as whole — the point is the values,
8040 # not the dtype pandas landed on.
8041 if bool((finite % 1 == 0).all()):
8042 return int(lo), int(hi), distinct
8043 return float(lo), float(hi), distinct
8046@st.cache_data(show_spinner=False)
8047def _trials_missing_column(_df: pd.DataFrame, column: str, cache_key) -> int:
8048 """How many trials carry no numeric value for ``column``.
8050 Counted in *trials*, not rows, because that is the unit the filter keeps or
8051 drops — and it is what makes the "kept anyway" caption honest.
8052 """
8053 if column not in _df.columns or "trial_id" not in _df.columns:
8054 return 0
8055 keys = (
8056 ["participant_id", "trial_id"]
8057 if "participant_id" in _df.columns
8058 else ["trial_id"]
8059 )
8060 values = pd.to_numeric(_df[column], errors="coerce")
8061 usable = pd.DataFrame({"_v": np.isfinite(values)})
8062 for k in keys:
8063 usable[k] = _df[k].astype(str).to_numpy()
8064 per_trial = usable.groupby(keys, dropna=False)["_v"].any()
8065 return int((~per_trial).sum())
8068@st.cache_data(show_spinner=False)
8069def _column_present_bools(_df: pd.DataFrame, column: str, cache_key) -> frozenset:
8070 if column not in _df.columns:
8071 return frozenset()
8072 # Callers pass only bool-dtype columns today (a string "True"/"False"
8073 # column takes the categorical path); read by meaning anyway, never by
8074 # truthiness, so a future caller can't hide a class (round 11).
8075 flags = coerce_bool_or_na(pd.Series(_df[column])).dropna()
8076 return frozenset(bool(v) for v in flags.unique())
8079def _bool_metadata_filter(
8080 label: str,
8081 col: str,
8082 df: pd.DataFrame,
8083 true_label: str,
8084 false_label: str,
8085 key: str,
8086 host,
8087 on_change=None,
8088 help: str | None = None,
8089) -> None:
8090 """Render a friendly multiselect for a boolean metadata column.
8092 Rendering only — the narrowing value is derived from the widget key by
8093 ``_compute_trial_filters``. Renders nothing when the column is absent or has
8094 fewer than two classes."""
8095 if col not in df.columns:
8096 return
8097 present = _column_present_bools(df, col, cache_key=(frame_fingerprint(df), col))
8098 label_to_val = {true_label: True, false_label: False}
8099 options = [lbl for lbl, val in label_to_val.items() if val in present]
8100 if len(options) < 2:
8101 return
8102 _seed_filter_widget(key, options, options)
8103 _labeled(
8104 host,
8105 "multiselect",
8106 label,
8107 options=options,
8108 key=key,
8109 on_change=on_change,
8110 help=help or None,
8111 )
8114def _bool_filter_narrowing(
8115 col: str, df: pd.DataFrame, true_label: str, false_label: str, key: str
8116) -> set | None:
8117 """The set of raw bool values to keep for a boolean metadata column, read
8118 from its widget key — or None when absent / fewer than two classes / the user
8119 kept everything (no narrowing). The read-side twin of ``_bool_metadata_filter``."""
8120 if col not in df.columns:
8121 return None
8122 present = _column_present_bools(df, col, cache_key=(frame_fingerprint(df), col))
8123 label_to_val = {true_label: True, false_label: False}
8124 options = [lbl for lbl, val in label_to_val.items() if val in present]
8125 if len(options) < 2:
8126 return None
8127 chosen = st.session_state.get(key)
8128 if not chosen or set(chosen) == set(options):
8129 return None
8130 vals = {label_to_val[c] for c in chosen if c in label_to_val}
8131 return vals or None
8134# What the two values of a well-known boolean condition column mean. A filter's
8135# *title* is the dataset's own name for its column (DATA-66, `trial_filter_labels`);
8136# these name its values, and any other boolean column reads Yes / No.
8137_FILTER_FIELD_LABELS = {
8138 "question_preview": {"true": "Hunting", "false": "Gathering"},
8139 "repeated_reading_trial": {"true": "Repeated", "false": "First"},
8140 "is_correct": {"true": "Correct", "false": "Incorrect"},
8141}
8143# Built-in sources (no wizard) auto-offer these known trial-level conditions when
8144# present; the Upload source uses the fields the user chose in the wizard.
8145_DEFAULT_FILTER_FIELDS = [
8146 "question_preview",
8147 "difficulty_level",
8148 "repeated_reading_trial",
8149 "is_correct",
8150 # UX-49: the offered set is otherwise all-categorical, which left the range
8151 # slider invisible on every bundled and public corpus. Presentation order is
8152 # the one numeric trial-level field that is both universal (EyeLink writes it
8153 # on every export) and worth filtering on — it is how you exclude the start
8154 # or the tail of a session when you suspect practice or fatigue effects.
8155 "TRIAL_INDEX",
8156 # MultiplEYE facets (present only when that corpus is loaded).
8157 "genre",
8158 "session",
8159 "is_practice",
8160]
8163_EMPTY_TRIAL_FILTERS: dict = {
8164 "participants": None,
8165 "metadata": {},
8166 # UX-49: column → (lo, hi) for the numeric trial-level range filters. Kept
8167 # apart from `metadata` because that one is membership (`.isin`) and
8168 # enumerating a float column's values is exactly what doesn't work.
8169 "ranges": {},
8170 # The ranged columns whose *Keep unknown values* is off: a trial with no
8171 # value there is left out instead of kept (`data.filter_trials`).
8172 "ranges_drop_unknown": (),
8173 # DATA-20: widget keys behind a participant-grain metadata narrowing (which
8174 # lands in `participants`, not `metadata`), so UX-7's per-filter clear can
8175 # reset the control that actually caused it.
8176 "participant_filter_keys": (),
8177 "favorites_only": False,
8178 "required_tags": [],
8179 "excluded_tags": [],
8180}
8183#: Every filter-layer namespace in the app. ``""`` is the main trial pool;
8184#: ``"cmp"`` is compare mode's scanpath B (CMP-8 §5.2). The empty prefix's
8185#: clear-sweep uses this to know which keys are *not* its own — without it,
8186#: "Clear all filters" on A would wipe B too, since ``"filter_"`` is a prefix of
8187#: ``"cmpfilter_"``. Register a new instance here, not just at its call site.
8188FILTER_PREFIXES: tuple = ("", "cmp")
8191def read_trial_filters(prefix: str = "") -> dict:
8192 """The trial-filter selections to apply this run.
8194 ``prefix`` scopes the whole filter layer to one instance (CMP-8 §5.2). The
8195 default ``""`` is the main pool and leaves every existing call site
8196 byte-identical; compare mode's scanpath **B** renders with ``prefix="cmp"``
8197 so it can be narrowed on *its own* dataset's columns, which A's filters may
8198 not even have.
8200 Computed last run by ``render_trial_filters`` and stashed in a *plain*
8201 session_state value (not a widget key), so it survives runs where the filter
8202 panel itself isn't rendered — e.g. when a non-Scanpath view is active on the
8203 top nav. ``main()`` reads this *before* the tab renders, so filtering
8204 stays global even though the controls now live in the Trial Selection panel.
8205 """
8206 return dict(st.session_state.get(f"{prefix}_trial_filters", _EMPTY_TRIAL_FILTERS))
8209def clear_trial_filters(prefix: str = "") -> None:
8210 """Reset every trial filter to "no constraint" (UX-7's one-click escape).
8212 All of them — the Narrow-by multiselects, the More-popover condition filters,
8213 and the annotation filters — live under the ``filter_`` key prefix, so
8214 dropping those keys is the whole reset: each widget re-seeds to its own empty
8215 default (an empty multiselect means *no* narrowing) on the next render. The
8216 derived results and the cross-view mirror are cleared with them so the same
8217 run already sees an unfiltered pool.
8219 Safe to call as a button ``on_click``: callbacks run before the rerun
8220 instantiates the widgets, so removing their keys doesn't trip Streamlit's
8221 "set after instantiation" guard.
8222 """
8223 for key in _own_filter_keys(prefix):
8224 del st.session_state[key]
8225 st.session_state.pop(f"{prefix}_trial_filters", None)
8226 st.session_state.pop(f"{prefix}_trial_filters_raw", None)
8229def _own_filter_keys(prefix: str) -> list:
8230 """Filter widget keys belonging to ``prefix`` and to no *longer* prefix.
8232 The sweep used to be prefix-blind, which is fine while there is one filter
8233 set and wrong the moment there are two: ``"filter_"`` is a prefix of
8234 ``"cmpfilter_"``, so clearing A's filters would silently wipe B's as well.
8235 Matching forwards is not enough — the empty prefix has to explicitly skip
8236 keys carrying a known namespace.
8237 """
8238 own = f"{prefix}filter_"
8239 foreign = tuple(f"{p}filter_" for p in FILTER_PREFIXES if p and p != prefix)
8240 return [
8241 k
8242 for k in list(st.session_state)
8243 if str(k).startswith(own) and not (prefix == "" and str(k).startswith(foreign))
8244 ]
8247def reset_viz_settings() -> None:
8248 """Put every visualization setting back to the app's defaults (UX-26).
8250 The mechanism is ``clear_trial_filters``' — delete the widget keys and let
8251 each control re-seed from ``_VIZ_WIDGET_DEFAULTS`` (plus the data-dependent
8252 defaults `_seed_viz_state` computes) on the next render — so it must run as a
8253 button ``on_click``: callbacks run before the rerun instantiates the widgets,
8254 which is what keeps deleting their keys clear of Streamlit's "set after
8255 instantiation" guard.
8257 The key set is the honest inventory of what *visualization settings* means:
8258 every ``global_*`` key, the per-scanpath compare styles
8259 (``session_keys.compare_state_keys``), and the fixation-window pair the rail
8260 owns. ``session_keys.PLOT_CONFIG_STATE_KEYS`` is folded in so a setting that
8261 is restorable-but-not-currently-rendered is reset too. Deliberately NOT
8262 touched: the trial selection, the annotations (user-authored content, not a
8263 setting), the column mapping, and the data source.
8265 Deep links re-apply: ``url_state._apply_url_preset`` seeds from
8266 ``st.query_params`` at the top of every rerun, so on a page opened from a
8267 Share link, deleting the keys alone would let the link reinstate itself on
8268 the very next run. The viz params are stripped from the query string here;
8269 the selection params (source / participant / trial) are left, so a reset
8270 keeps you on the trial you were looking at.
8271 """
8272 from . import session_keys as _sk
8274 keys = set(_sk.PLOT_CONFIG_STATE_KEYS)
8275 keys |= set(_sk.compare_state_keys(0)) | set(_sk.compare_state_keys(1))
8276 # CMP-24 — B's own filters and window.
8277 keys |= set(_sk.COMPARE_B_FILTER_STATE_KEYS) | {
8278 _sk.SINGLE_COMPARE_FIX_RANGE,
8279 f"{_sk.SINGLE_COMPARE_FIX_RANGE}_user_set",
8280 }
8281 keys |= {k for k in st.session_state if str(k).startswith("global_")}
8282 keys |= {
8283 "single_fix_range",
8284 "single_fix_range_all_trials",
8285 _PRE_ILLUSTRATION_STATE,
8286 _QUICK_VIEW_SELECTION_KEY,
8287 _QUICK_VIEW_CUSTOM_STATE,
8288 _QUICK_VIEW_APPLIED_STATE,
8289 _QUICK_VIEW_DRIFTED_FROM,
8290 }
8291 # VIZ-39: `DESIGN_PRESETS_KEY` is deliberately NOT in that set. Reset puts
8292 # the *view* back to defaults; the user's saved designs are a library, not
8293 # a view, and losing them to a reset button would be the kind of undoless
8294 # deletion nothing here offers.
8295 for key in keys:
8296 st.session_state.pop(key, None)
8297 # Re-seeding is source-driven for these two (see app.seed_canvas_state);
8298 # dropping the guard makes the canvas / font snap back to the source's
8299 # authoritative monitor on the next run rather than sticking at the old size.
8300 st.session_state.pop("_canvas_seeded_for", None)
8301 st.session_state.pop("_font_seeded_for", None)
8302 st.session_state.pop("_palette_picked", None)
8303 st.session_state.pop(_PRE_ILLUSTRATION_STATE, None)
8304 # VIZ-45 — and the raw-gaze layer's dataset default, the same way.
8305 _forget_raw_gaze_default(st.session_state)
8306 for param in (*_sk.URL_PRESET_PARAMS, *_sk.LEGEND_PARAMS):
8307 st.query_params.pop(param, None)
8310def clear_trial_filter(
8311 *keys: str, prefix: str = "", frames: tuple | None = None
8312) -> None:
8313 """Reset *one* trial filter (UX-7) — the same mechanism as the reset-all.
8315 Deleting the widget's key is the correct reset for every filter shape here,
8316 because each re-seeds to its own "no constraint" default on the next render:
8317 an empty multiselect for Narrow-by, *all* values selected for a condition,
8318 unchecked for Favorites. Safe as a button ``on_click`` for the same reason
8319 :func:`clear_trial_filters` is.
8321 BUG-115: only ``keys`` leave the ``_trial_filters_raw`` mirror. The
8322 empty-pool panel, the one caller, replaces the view and its filter funnel,
8323 so on that run Streamlit has dropped every filter widget's key and the
8324 mirror is all that remembers the *other* filters; dropping it whole cleared
8325 them all. With ``frames`` (the ``(words, fixations)`` the funnel filters)
8326 the survivors are seeded back into their widget keys and the result is
8327 re-derived, so the next run applies them instead of one run later.
8328 """
8329 raw_key = f"{prefix}_trial_filters_raw"
8330 mirror = dict(st.session_state.get(raw_key) or {})
8331 # A range's *Keep unknown values* choice is part of that filter, so it
8332 # goes with it.
8333 for key in (*keys, *(keep_unknown_key(k) for k in keys)):
8334 st.session_state.pop(key, None)
8335 mirror.pop(key, None)
8336 st.session_state[raw_key] = mirror
8337 if frames is None:
8338 st.session_state.pop(f"{prefix}_trial_filters", None)
8339 return
8340 for key, value in mirror.items():
8341 st.session_state.setdefault(key, value)
8342 st.session_state[f"{prefix}_trial_filters"] = _compute_trial_filters(
8343 *frames, prefix=prefix
8344 )
8347def has_active_trial_filters(prefix: str = "") -> bool:
8348 """Whether any trial filter is currently narrowing the pool."""
8349 f = read_trial_filters(prefix)
8350 return bool(
8351 # `[]` is a narrowing that matched nobody — the *most* active a filter
8352 # can be. `None` is the no-constraint default.
8353 f.get("participants") is not None
8354 or f.get("metadata")
8355 or f.get("ranges")
8356 or f.get("favorites_only")
8357 or f.get("required_tags")
8358 or f.get("excluded_tags")
8359 )
8362#: UX-198 — the titles of the filters whose session key is not
8363#: ``filter_<column>``; every other one is titled by its column.
8364_FILTER_KEY_LABELS = {
8365 "filter_participants": "Participant",
8366 "filter_text_id": "Text",
8367 "filter_favorites": "Favorites only",
8368 "filter_req_tags": "With any of these tags",
8369 "filter_exc_tags": "Excluding tags",
8370}
8372#: The metadata filters' key stems (DATA-20 / DATA-29 / text grain), longest
8373#: first so ``filter_meta_`` cannot claim a ``filter_metadata_…`` column.
8374_METADATA_FILTER_STEMS = ("filter_trialmeta_", "filter_textmeta_", "filter_meta_")
8377def active_filter_keys(trial_filters: dict, prefix: str = "") -> list[str]:
8378 """The widget keys behind every narrowing in ``trial_filters`` (UX-198).
8380 The filter result already carries them for UX-7's per-filter clear; this
8381 lists them once, in the panel's order, so a summary of the pool names the
8382 same controls the panel shows.
8383 """
8384 keys: list[str] = []
8385 if trial_filters.get("participants") is not None:
8386 keys.append(f"{prefix}filter_participants")
8387 keys.extend(trial_filters.get("participant_filter_keys") or ())
8388 keys.extend((trial_filters.get("metadata_keys") or {}).values())
8389 keys.extend(trial_filters.get("text_filter_keys") or ())
8390 if trial_filters.get("trial_keys") is not None:
8391 keys.extend(trial_filters.get("trial_filter_keys") or ())
8392 if trial_filters.get("favorites_only"):
8393 keys.append(f"{prefix}filter_favorites")
8394 if trial_filters.get("required_tags"):
8395 keys.append(f"{prefix}filter_req_tags")
8396 if trial_filters.get("excluded_tags"):
8397 keys.append(f"{prefix}filter_exc_tags")
8398 return list(dict.fromkeys(keys))
8401def _is_number(value) -> bool:
8402 return isinstance(value, (int, float, np.integer, np.floating)) and not isinstance(
8403 value, (bool, np.bool_)
8404 )
8407def describe_filter_keys(
8408 keys, values, label_for: Callable[[str], str], prefix: str = ""
8409) -> list[dict]:
8410 """One ``{"field", "values" | "range"}`` entry per filter that narrows.
8412 Pure: ``values`` maps a key to its widget value and ``label_for`` titles it.
8413 A key whose value no longer narrows (an emptied multiselect) is skipped, so
8414 the list says exactly what is constraining the pool. A range is a
8415 two-number tuple, or a two-number list under a range or metadata key —
8416 a categorical multiselect also holds a list. ``Favorites only`` has
8417 neither values nor range: it is on or absent.
8419 A range also says what happens to the records with no value —
8420 ``"unknown": "kept"`` or ``"excluded"``, from its *Keep unknown values*
8421 choice (``values`` holds it under :func:`keep_unknown_key`). A constant
8422 field has no range to slide, so its entry is only ``"unknown":
8423 "excluded"``: that choice is the whole filter.
8424 """
8425 items: list[dict] = []
8426 for key in keys:
8427 value = values.get(key)
8428 bare = key[len(prefix) :] if prefix and key.startswith(prefix) else key
8429 if bare == "filter_favorites":
8430 if value:
8431 items.append({"field": label_for(key)})
8432 continue
8433 unknown = "excluded" if values.get(keep_unknown_key(key)) is False else "kept"
8434 ranged = isinstance(value, tuple) or (
8435 isinstance(value, list)
8436 and (bare.endswith("_range") or bare.startswith(_METADATA_FILTER_STEMS))
8437 )
8438 if ranged and len(value) == 2 and all(_is_number(v) for v in value):
8439 items.append(
8440 {
8441 "field": label_for(key),
8442 "range": [float(value[0]), float(value[1])],
8443 "unknown": unknown,
8444 }
8445 )
8446 continue
8447 if value is None and unknown == "excluded":
8448 items.append({"field": label_for(key), "unknown": unknown})
8449 continue
8450 if isinstance(value, (list, tuple, set)) and value:
8451 items.append({"field": label_for(key), "values": [str(v) for v in value]})
8452 return items
8455def active_filter_items(
8456 words: pd.DataFrame, fixations: pd.DataFrame, *, prefix: str = ""
8457) -> list[dict]:
8458 """What is narrowing the pool this run, one entry per filter (UX-198).
8460 Read from the result ``app.main`` filtered with (``read_trial_filters``),
8461 so the list always matches the counts beside it. The values are the
8462 widgets', falling back to the ``_trial_filters_raw`` mirror on a run where
8463 the panel has not drawn them yet — the labels the user picked, not the
8464 raw booleans a condition filter resolves to.
8465 """
8466 keys = active_filter_keys(read_trial_filters(prefix), prefix)
8467 if not keys:
8468 return []
8469 values = dict(st.session_state.get(f"{prefix}_trial_filters_raw") or {})
8470 live = [*keys, *(keep_unknown_key(k) for k in keys)]
8471 values.update({k: st.session_state[k] for k in live if k in st.session_state})
8472 names = _rail_names()
8473 labels = trial_filter_labels(words, fixations, names=names)
8475 def label_for(key: str) -> str:
8476 from scanpath_studio import metadata as md
8478 bare = key[len(prefix) :] if prefix and key.startswith(prefix) else key
8479 if bare in _FILTER_KEY_LABELS:
8480 return _FILTER_KEY_LABELS[bare]
8481 for stem in _METADATA_FILTER_STEMS:
8482 if bare.startswith(stem):
8483 return md.field_label(bare[len(stem) :])
8484 col = bare.removeprefix("filter_")
8485 if col.endswith("_range") and col.removesuffix("_range") in labels:
8486 col = col.removesuffix("_range")
8487 return labels.get(col) or names.field_label(col)
8489 return describe_filter_keys(keys, values, label_for, prefix)
8492def format_filter_item(item: dict, *, max_values: int = 3) -> str:
8493 """``Participant: p1, p2`` / ``Trial index: 3–10`` / ``Favorites only``.
8495 A filter that leaves out the records with no value says so:
8496 ``Score: 80–100 (unknown values excluded)``.
8497 """
8498 excluded = item.get("unknown") == "excluded"
8499 if "range" in item:
8500 lo, hi = item["range"]
8501 text = f"{item['field']}: {lo:,.10g}–{hi:,.10g}"
8502 return f"{text} (unknown values excluded)" if excluded else text
8503 if excluded and not item.get("values"):
8504 return f"{item['field']}: unknown values excluded"
8505 values = list(item.get("values") or ())
8506 if not values:
8507 return str(item["field"])
8508 shown = ", ".join(values[:max_values])
8509 if len(values) > max_values:
8510 shown += f" +{len(values) - max_values} more"
8511 return f"{item['field']}: {shown}"
8514# --- Trial summary chips (the "Field = Value" strip above the plot) ----------
8515_CHIP_TEXT_ID_COLS = (
8516 "unique_text_id",
8517 "text_id",
8518 "unique_paragraph_id",
8519 "paragraph_id",
8520)
8521# Sensible default chips: trial identity + the common OneStop conditions + the
8522# computed trial-level summary stats (which the chips replaced the Trial Info tab
8523# with). The "@"-prefixed keys are virtual fields computed per trial in
8524# `tabs._render_trial_condition_chips` (see SUMMARY_CHIP_FIELDS).
8525_CHIP_DEFAULT_CONDITIONS = [
8526 "difficulty_level",
8527 "question_preview",
8528 "repeated_reading_trial",
8529 "is_correct",
8530 # MultiplEYE facets + reader metadata (present only for that corpus).
8531 "genre",
8532 "session",
8533 "pp_age",
8534 "pp_gender",
8535]
8536# Virtual chip fields → label. These are computed per trial (not data columns),
8537# always trial-level, and folded in from the former Trial Info tab's summary.
8538SUMMARY_CHIP_FIELDS = {
8539 # #374 F8: the sum of the fixation durations (or the recorded dwell time),
8540 # not the time spent on the trial — that is the next chip. The key keeps
8541 # its old name: saved chip lists carry it.
8542 "@reading_time_s": "Total fixation time (s)",
8543 "@trial_duration_s": "Trial duration (s)",
8544 "@word_count": "Number of words",
8545 "@fixation_count": "Number of fixations",
8546 "@in_text_fixations": "Fixations in word boxes",
8547 # VIZ-45: a raw-gaze trial's own headline number. Written only for a trial
8548 # that has samples (`tabs._summary_rows`), so a dataset without raw gaze
8549 # never shows it.
8550 "@gaze_sample_count": "Number of gaze samples",
8551}
8552#: What each summary chip counts, as its tooltip (#374 F8).
8553SUMMARY_CHIP_HELP = {
8554 "@reading_time_s": "The sum of the trial's fixation durations "
8555 "(the recorded trial dwell time when the data has one).",
8556 "@trial_duration_s": "From the first fixation's onset to the last fixation's "
8557 "end, saccades included.",
8558}
8559#: …and the ones shown by default. The other two are offered in *Available*
8560#: like any other field. All four used to be default chips behind a **Summary
8561#: stats** popover; now that they are chips on the strip itself, four of them
8562#: crowd out the conditions beside them — and reading time and the fixation
8563#: count are the ones worth a permanent chip ("how long was this reading, and
8564#: how many fixations"). Word count is a property of the text rather than of
8565#: the reading, and the in-text count only means something when you are
8566#: already chasing a geometry problem.
8567#:
8568#: VIZ-45 added the gaze-sample count. A summary chip is drawn only for a number
8569#: the trial has, so a trial with fixations and no samples still shows the same
8570#: two chips, and a raw-gaze-only trial — whose reading time and fixation count
8571#: were never measured and are left out — shows the one count it has.
8572_CHIP_DEFAULT_SUMMARY = (
8573 "@reading_time_s",
8574 "@trial_duration_s",
8575 "@fixation_count",
8576 "@gaze_sample_count",
8577)
8580def _trial_level_columns(words: pd.DataFrame, fixations: pd.DataFrame) -> set:
8581 """Columns that are constant within a trial (so a single chip value is
8582 meaningful), sampled from the first trial only — cheap, and trial-level-ness
8583 is essentially a property of the dataset, not the specific trial. A column
8584 counts as trial-level when it has ≤1 distinct value within that sample trial.
8585 """
8586 level: set = set()
8587 src = fixations if (fixations is not None and not fixations.empty) else words
8588 if (
8589 src is None
8590 or src.empty
8591 or "participant_id" not in src.columns
8592 or "trial_id" not in src.columns
8593 ):
8594 return level
8595 pid, tid = str(src["participant_id"].iloc[0]), str(src["trial_id"].iloc[0])
8596 for frame in (words, fixations):
8597 if (
8598 frame is None
8599 or frame.empty
8600 or "participant_id" not in frame.columns
8601 or "trial_id" not in frame.columns
8602 ):
8603 continue
8604 sub = frame[
8605 (frame["participant_id"].astype(str) == pid)
8606 & (frame["trial_id"].astype(str) == tid)
8607 ]
8608 if sub.empty:
8609 continue
8610 for col in sub.columns:
8611 if sub[col].nunique(dropna=True) <= 1:
8612 level.add(col)
8613 return level
8616def _chip_field_options(words, fixations, trial_level: set) -> list[str]:
8617 """Pickable chip fields: participant + a text id + the data's *trial-level*
8618 columns + the computed summary fields. Non-trial-level columns (per-word /
8619 per-fixation) are intentionally excluded — a single chip value for them would
8620 be misleading."""
8621 cols: list[str] = []
8623 def add(c: str) -> None:
8624 # #374 F5: one chip per role — `unique_trial_id` beside `trial_id` would
8625 # be a second "Trial", told apart only by an internal name.
8626 role = cn.ROLE_LABELS.get(c)
8627 if role is not None and any(cn.ROLE_LABELS.get(x) == role for x in cols):
8628 return
8629 if c and c not in cols:
8630 cols.append(c)
8632 if "participant_id" in words.columns or "participant_id" in fixations.columns:
8633 add("participant_id")
8634 add(next((c for c in _CHIP_TEXT_ID_COLS if c in words.columns), ""))
8635 for c in list(words.columns) + list(fixations.columns):
8636 if c in trial_level and c not in INTERNAL_COLUMNS:
8637 add(c)
8638 # DATA-20: participant-grain metadata is constant within a trial by
8639 # construction, so it belongs in this list on exactly the same terms as a
8640 # trial-level recorded column — no allowlist of its own.
8641 for field in participant_metadata_fields():
8642 add(field.name)
8643 # DATA-29 — and a trial-grain field is constant within a trial by
8644 # definition, which is the property this list is for.
8645 for field in trial_metadata_fields():
8646 add(field.name)
8647 # Text-grain metadata is constant within a trial by construction too — a
8648 # text attribute (genre, difficulty) doesn't vary across the readers of it.
8649 for field in text_metadata_fields():
8650 add(field.name)
8651 cols.extend(SUMMARY_CHIP_FIELDS)
8652 return cols
8655def unique_field_labels(columns, label_of) -> dict[str, str]:
8656 """``{column: label}`` with every label distinct, in ``columns``' order.
8658 Two columns can share a label — `text_id` and `unique_text_id` read from
8659 one column of the user's file — so the first keeps its label and each later
8660 one adds its column name: "PARAGRAPH (unique_text_id)". The ✏️ chip editor needs this to stay
8661 invertible; the trial filters use it (UX-149) so two sliders over different
8662 columns never carry the same title.
8663 """
8664 labels: dict[str, str] = {}
8665 taken: set[str] = set()
8666 for column in columns:
8667 if column in labels:
8668 continue
8669 base = label_of(column)
8670 label = base if base not in taken else f"{base} ({column})"
8671 taken.add(label)
8672 labels[column] = label
8673 return labels
8676def chip_field_label(col: str, names: cn.ColumnNames | None = None) -> str:
8677 """What a chip field is called, on the chip and in ✏️ Edit chips alike.
8679 A summary statistic keeps the app's name (`SUMMARY_CHIP_FIELDS`); a data
8680 column is shown under the dataset's own name (DATA-66). ``names`` is the
8681 map to read, the open dataset's by default (Compare's B passes its own).
8682 """
8683 if col in SUMMARY_CHIP_FIELDS:
8684 return SUMMARY_CHIP_FIELDS[col]
8685 return (names if names is not None else _rail_names()).field_label(col)
8688def field_help(col: str, names: cn.ColumnNames | None = None) -> str:
8689 """The tooltip beside a field's name (#374 F5): the bundled demo's
8690 description of its own column, and a role's source column
8691 ("from RECORDING_SESSION_LABEL"). ``""`` when there is neither."""
8692 if col in SUMMARY_CHIP_FIELDS:
8693 return SUMMARY_CHIP_HELP.get(col, "")
8694 names = names if names is not None else _rail_names()
8695 note = (
8696 cn.DEMO_COLUMN_NOTES.get(col, "")
8697 if current_dataset_name() == DEMO_CHOICE
8698 else ""
8699 )
8700 source = names.source_tooltip(col)
8701 if source:
8702 source = source[:1].upper() + source[1:] + "."
8703 return " ".join(part for part in (note, source) if part)
8706def _default_chip_fields(available: list[str]) -> list[str]:
8707 text_col = next((c for c in _CHIP_TEXT_ID_COLS if c in available), None)
8708 wanted = (
8709 ["participant_id"]
8710 + ([text_col] if text_col else [])
8711 + _CHIP_DEFAULT_CONDITIONS
8712 + list(_CHIP_DEFAULT_SUMMARY)
8713 )
8714 return [f for f in wanted if f in available]
8717def render_trial_chip_picker(
8718 words: pd.DataFrame, fixations: pd.DataFrame, host
8719) -> None:
8720 """Render the inline **Edit chips** control: choose which ``Field = Value``
8721 chips appear above the scanpath and **drag to reorder** them (UX-1 / UX-1a).
8723 Two drag buckets — *Shown* (in display order) and *Available* — via
8724 ``streamlit_sortables.sort_items``: drag a field between buckets to show / hide
8725 it, and within *Shown* to reorder. The resulting order is written to the plain
8726 session key ``trial_chip_fields`` (read by ``tabs._render_trial_condition_chips``).
8728 Only *trial-level* fields are offered (constant within a trial) plus the
8729 computed summary stats, so a chip never shows a per-word column whose single
8730 value would mislead. The trial-level set is computed once (sampling the first
8731 trial) and cached per column-signature; a **Refresh** button recomputes it.
8732 Default seeded once (participant + text + common conditions + summary)."""
8733 # Cached per column-signature (stable across trials / filters within a
8734 # dataset), recomputed on a dataset/column change or Refresh. Shared with
8735 # UX-49's range filters, which gate on the same answer.
8736 # DATA-20: the field universe now also depends on the attached participant
8737 # registry, so it has to be part of the signature — attaching or detaching a
8738 # table changes what the picker offers, and without this the component kept
8739 # a drag order built from the old set.
8740 signature = (
8741 tuple(words.columns),
8742 tuple(fixations.columns),
8743 tuple(field.name for field in participant_metadata_fields()),
8744 # DATA-29 — attaching or detaching the trial table changes the offered
8745 # set the same way, so it belongs in the signature for the same reason.
8746 tuple(field.name for field in trial_metadata_fields()),
8747 # And the text table, the third grain — same reasoning again.
8748 tuple(field.name for field in text_metadata_fields()),
8749 )
8750 available = _chip_field_options(
8751 words, fixations, cached_trial_level_columns(words, fixations)
8752 )
8753 if not available:
8754 return
8756 # Display labels must be unique to stay invertible: two fields can share a
8757 # name (`text_id` and `unique_text_id` read from one column), so disambiguate.
8758 names = _rail_names()
8759 key_to_label = unique_field_labels(
8760 available, lambda col: chip_field_label(col, names)
8761 )
8762 label_to_key = {label: key for key, label in key_to_label.items()}
8764 # Current selection/order, pruned to what's available + seeded once.
8765 if "trial_chip_fields" in st.session_state:
8766 st.session_state["trial_chip_fields"] = [
8767 f for f in st.session_state["trial_chip_fields"] if f in available
8768 ]
8769 st.session_state.setdefault("trial_chip_fields", _default_chip_fields(available))
8770 selected = list(st.session_state["trial_chip_fields"])
8771 hidden = [k for k in available if k not in selected]
8773 host.caption(
8774 "Drag fields between **Shown** and **Available**, and reorder within "
8775 "**Shown** — these chips appear above the scanpath. Their colors are "
8776 "set below the list."
8777 )
8778 buckets = [
8779 {
8780 "header": "Shown · drag to reorder",
8781 "items": [key_to_label[k] for k in selected],
8782 },
8783 {"header": "Available", "items": [key_to_label[k] for k in hidden]},
8784 ]
8785 with host:
8786 # Key varies with the field universe so the component re-mounts (rather than
8787 # keeping a stale drag order) when the dataset / columns change. DATA-66:
8788 # and with the labels — the component hands back the labels it holds, so
8789 # one renamed by ✏️ Edit dataset → Save would otherwise drop its chip.
8790 result = sort_items(
8791 buckets,
8792 multi_containers=True,
8793 direction="vertical",
8794 key="trial_chip_sort_"
8795 f"{abs(hash((signature, tuple(key_to_label.items()))))}",
8796 )
8797 shown_labels = result[0]["items"] if result else []
8798 st.session_state["trial_chip_fields"] = [
8799 label_to_key[lbl] for lbl in shown_labels if lbl in label_to_key
8800 ]
8801 host.button(
8802 f"{ICONS['refresh']} Refresh fields",
8803 key="trial_chip_refresh",
8804 help="Re-scan which fields are trial-level (if the offered list looks off "
8805 "for the current data).",
8806 on_click=lambda: st.session_state.pop("_trial_level_cache", None),
8807 )
8809 # UX-28: per-chip colour, generalized from what used to be a handful of
8810 # hardcoded OneStop column/value special-cases (tabs._chip_color) — any
8811 # dataset's condition chips can now be highlighted, not just OneStop's.
8812 # `#EEF2F7` must match `tabs._CHIP_NEUTRAL_BG`: picking it back is how a
8813 # chip returns to "no override" rather than staying pinned to a colour.
8814 _neutral = "#EEF2F7"
8815 shown_now = st.session_state["trial_chip_fields"]
8816 if shown_now:
8817 host.caption("Chip colors — optional highlight per shown field.")
8818 colors = dict(st.session_state.get("trial_chip_colors") or {})
8819 for key in shown_now:
8820 label_col, swatch_col = host.columns([3, 1], vertical_alignment="center")
8821 label_col.caption(key_to_label[key])
8822 picked = swatch_col.color_picker(
8823 key_to_label[key],
8824 value=colors.get(key) or _neutral,
8825 key=f"trial_chip_color_{key}",
8826 label_visibility="collapsed",
8827 help="Pick back to the default gray to remove the highlight.",
8828 )
8829 if picked.lower() == _neutral.lower():
8830 colors.pop(key, None)
8831 else:
8832 colors[key] = picked
8833 st.session_state["trial_chip_colors"] = colors
8836def _seed_filter_widget(
8837 key: str, options: list, default: list, *, prefix: str = ""
8838) -> None:
8839 """Pre-seed a filter widget's state from the persistent mirror.
8841 Filter controls live in the Scanpath tab body, which doesn't render on every
8842 run (other views on the top nav). Streamlit clears a not-rendered
8843 widget's key, so on return we re-seed it from ``_trial_filters_raw`` (the last
8844 selections), dropping any value no longer in ``options`` (e.g. after a dataset
8845 switch). Setting the key *before* the widget renders avoids the
8846 default-plus-session-state warning."""
8847 if key in st.session_state:
8848 return
8849 mirror = st.session_state.get(f"{prefix}_trial_filters_raw", {})
8850 if key in mirror:
8851 kept = [v for v in mirror[key] if v in options]
8852 st.session_state[key] = kept if kept else list(default)
8853 else:
8854 st.session_state[key] = list(default)
8857def _seed_range_widget(col: str, lo, hi, *, prefix: str = "") -> None:
8858 """Pre-seed a range slider from the persistent mirror, clamped to the column.
8860 The range twin of :func:`_seed_filter_widget`, and it needs its own because
8861 the stored value is a *pair*, not a list of options to intersect. A stored
8862 range outside the current column's extent (a dataset switch, or a filter that
8863 shrank the pool) is clamped rather than dropped, so the slider never renders
8864 a value Streamlit would reject.
8866 The seeded pair keeps ``lo``/``hi``'s own type: Streamlit reads int-vs-float
8867 slider behaviour off the values, so seeding ``(0.0, 10.0)`` for an integer
8868 column would put it back on decimal steps.
8869 """
8870 cast = int if isinstance(lo, int) and isinstance(hi, int) else float
8871 key = _range_filter_key(col, prefix)
8873 def _clamped(pair) -> tuple:
8874 return (
8875 cast(min(max(pair[0], lo), hi)),
8876 cast(min(max(pair[1], lo), hi)),
8877 )
8879 if key in st.session_state:
8880 stored = st.session_state[key]
8881 if isinstance(stored, (tuple, list)) and len(stored) == 2:
8882 st.session_state[key] = _clamped(stored)
8883 return
8884 mirror = st.session_state.get(f"{prefix}_trial_filters_raw", {})
8885 stored = mirror.get(key)
8886 if isinstance(stored, (tuple, list)) and len(stored) == 2:
8887 st.session_state[key] = _clamped(stored)
8888 else:
8889 st.session_state[key] = (lo, hi)
8892def _filter_fields_for(words: pd.DataFrame, fixations: pd.DataFrame) -> list:
8893 """Trial-level condition columns to offer as filters (wizard-chosen for an
8894 upload, else the built-in defaults present in the data)."""
8895 filter_fields = st.session_state.get("wizard_filter_fields")
8896 if filter_fields is None:
8897 filter_fields = [
8898 c
8899 for c in _DEFAULT_FILTER_FIELDS
8900 if c in words.columns or c in fixations.columns
8901 ]
8902 return filter_fields
8905def cached_trial_level_columns(words: pd.DataFrame, fixations: pd.DataFrame) -> set:
8906 """``_trial_level_columns`` memoized per column-signature for this session.
8908 Trial-level-ness is a property of the dataset's shape, so the signature is
8909 the two frames' column tuples — stable across trials and filters. Shared by
8910 the ✏️ Edit chips picker and UX-49's range filters, which need the *same*
8911 answer: a column that varies inside a trial would filter rows rather than
8912 trials, silently cutting a scanpath in half.
8913 """
8914 signature = (tuple(words.columns), tuple(fixations.columns))
8915 cache = st.session_state.get("_trial_level_cache")
8916 if not cache or cache.get("signature") != signature:
8917 cache = {
8918 "signature": signature,
8919 "fields": _trial_level_columns(words, fixations),
8920 }
8921 st.session_state["_trial_level_cache"] = cache
8922 return cache["fields"]
8925def _range_filter_key(col: str, prefix: str = "") -> str:
8926 """Session key of ``col``'s range slider (prefix-scoped, CMP-8 §5.2).
8928 Under the ``filter_`` prefix on purpose: that is what gets it swept by
8929 *✕ Clear all filters* and kept out of compare-mode B's namespace for free.
8930 """
8931 return f"{prefix}filter_{col}_range"
8934#: The stem a range filter's *Keep unknown values* key carries after
8935#: ``filter_`` — see :func:`keep_unknown_key`.
8936_KEEP_UNKNOWN_STEM = "keepunknown_"
8939def keep_unknown_key(range_key: str) -> str:
8940 """Session key of the *Keep unknown values* choice beside a range filter.
8942 ``filter_score_range`` → ``filter_keepunknown_score_range``,
8943 ``cmpfilter_meta_age`` → ``cmpfilter_keepunknown_meta_age``. Under the
8944 filter layer's own ``…filter_`` prefix, so *✕ Clear all filters*, the
8945 ``_trial_filters_raw`` mirror and compare-mode B's namespace treat it as
8946 one more filter key without being taught about it. A plain session key:
8947 trial filters travel in no share link or settings file, so it is not a
8948 wire key either. ``True`` (or absent) keeps the records with no value —
8949 the filter's long-standing rule; ``False`` leaves them out.
8950 """
8951 head, sep, rest = range_key.partition("filter_")
8952 if not sep or rest.startswith(_KEEP_UNKNOWN_STEM):
8953 return range_key
8954 return f"{head}filter_{_KEEP_UNKNOWN_STEM}{rest}"
8957def _keeps_unknown(range_key: str, prefix: str = "") -> bool:
8958 """The *Keep unknown values* choice for ``range_key``, mirror as fallback."""
8959 key = keep_unknown_key(range_key)
8960 if key in st.session_state:
8961 return st.session_state[key] is not False
8962 mirror = st.session_state.get(f"{prefix}_trial_filters_raw") or {}
8963 return mirror.get(key, True) is not False
8966def _render_keep_unknown(
8967 host, range_key: str, *, unknown: int, noun: str, prefix: str, on_change
8968) -> None:
8969 """The *Keep unknown values* checkbox under a range, and what it does.
8971 Drawn only when some record has no value — with none, there is nothing to
8972 keep or leave out, and the stale choice is dropped so it cannot narrow a
8973 pool where it means nothing. The caption names how many records it
8974 concerns, in the unit the filter keeps or drops.
8975 """
8976 key = keep_unknown_key(range_key)
8977 if unknown <= 0:
8978 st.session_state.pop(key, None)
8979 return
8980 if key not in st.session_state:
8981 mirror = st.session_state.get(f"{prefix}_trial_filters_raw") or {}
8982 st.session_state[key] = mirror.get(key, True) is not False
8983 _labeled(
8984 host,
8985 "checkbox",
8986 "Keep unknown values",
8987 key=key,
8988 on_change=on_change,
8989 help=f"On: {noun}s with no value are kept whatever the range. "
8990 f"Off: only {noun}s with a value in the range are kept.",
8991 )
8992 plural = f"{unknown:,} {noun}{'s' if unknown != 1 else ''}"
8993 one = unknown == 1
8994 verb = "has" if one else "have"
8995 if st.session_state[key]:
8996 fate = "and is kept" if one else "and are kept"
8997 else:
8998 fate = "and is left out" if one else "and are left out"
8999 host.caption(f"{plural} {verb} no value {fate}.")
9002def _render_fixed_value(host, label: str, value: float, noun: str) -> None:
9003 """A numeric field with one value: shown as that value, not a slider.
9005 Streamlit refuses a slider whose ends are equal, and there is no range to
9006 pick anyway.
9007 """
9008 host.caption(f"**{label}**: {value:,.10g} for every {noun} that has a value.")
9011def _numeric_filter_fields(
9012 words: pd.DataFrame, fixations: pd.DataFrame
9013) -> dict[str, tuple]:
9014 """UX-49: which of the offered filter fields render as a *range*, and over what.
9016 Maps column → ``(frame, lo, hi)``. The set of columns the panel offers does
9017 **not** grow — this only auto-detects the *dtype* of what
9018 ``_filter_fields_for`` already returns, so a numeric one renders as a
9019 two-ended slider instead of a multiselect with one option per distinct float.
9020 That also bounds the panel's size: it can show no more rows than it does now.
9022 Two gates, both load-bearing. **Trial-level**: a column that varies inside a
9023 trial (fixation duration, word surprisal) would filter *rows*, not trials —
9024 that is PRE-2's render-layer territory, not this one. **Two distinct finite
9025 values**: fewer, and there is no range to pick.
9026 """
9027 trial_level = cached_trial_level_columns(words, fixations)
9028 fields: dict[str, tuple] = {}
9029 for col in _filter_fields_for(words, fixations):
9030 if col not in trial_level:
9031 continue
9032 frame = words if col in words.columns else fixations
9033 if col not in frame.columns or pd.api.types.is_bool_dtype(frame[col]):
9034 continue
9035 if not pd.api.types.is_numeric_dtype(frame[col]):
9036 continue
9037 bounds = _numeric_column_bounds(
9038 frame, col, cache_key=(frame_fingerprint(frame), col)
9039 )
9040 if bounds is None:
9041 continue
9042 lo, hi, _distinct = bounds
9043 fields[col] = (frame, lo, hi)
9044 return fields
9047def metadata_filter_key(name: str, prefix: str = "") -> str:
9048 """Session key of a participant-metadata filter (DATA-20).
9050 Under the ``filter_`` prefix like every other trial filter, which is what
9051 gets it swept by *✕ Clear all filters*, mirrored into
9052 ``_trial_filters_raw``, and kept out of compare-mode B's namespace — all
9053 for free, rather than by teaching each of those about a new field kind.
9054 """
9055 return f"{prefix}filter_meta_{name}"
9058def trial_metadata_filter_key(name: str, prefix: str = "") -> str:
9059 """Session key of a trial-metadata filter (DATA-29).
9061 Its own ``filter_trialmeta_`` prefix rather than DATA-20's ``filter_meta_``:
9062 the two tables may legitimately register the *same* column name at different
9063 grains (a ``session`` column describing readers and one describing trials),
9064 and a shared prefix would collide them onto one widget. Still under
9065 ``filter_``, so *✕ Clear all filters*, the ``_trial_filters_raw`` mirror and
9066 compare-mode B's namespace keep working without being taught about it.
9067 """
9068 return f"{prefix}filter_trialmeta_{name}"
9071def text_metadata_filter_key(name: str, prefix: str = "") -> str:
9072 """Session key of a text-metadata filter — the third grain.
9074 Its own ``filter_textmeta_`` prefix, on the same reasoning as
9075 ``trial_metadata_filter_key``: a table's column name may legitimately
9076 collide with one at another grain, and a shared prefix would collide the
9077 widgets. Still under ``filter_``, so the clear-all sweep, the
9078 ``_trial_filters_raw`` mirror and compare-mode B's namespace keep working.
9079 """
9080 return f"{prefix}filter_textmeta_{name}"
9083def participant_metadata_fields():
9084 """The attached participant table's fields, or ``()`` when none (DATA-20)."""
9085 from scanpath_studio.tabs import active_participant_metadata
9087 attached = active_participant_metadata()
9088 return attached.fields if attached is not None else ()
9091def trial_metadata_fields():
9092 """The attached trial table's fields, or ``()`` when none (DATA-29)."""
9093 from scanpath_studio import metadata as md
9095 attached = md.active_trials()
9096 return attached.fields if attached is not None else ()
9099def text_metadata_fields():
9100 """The attached text table's fields, or ``()`` when none — third grain."""
9101 from scanpath_studio import metadata as md
9103 attached = md.active_texts()
9104 return attached.fields if attached is not None else ()
9107def _render_metadata_range(
9108 host,
9109 attached,
9110 field,
9111 key: str,
9112 *,
9113 noun: str,
9114 table: str,
9115 unknown: int,
9116 prefix: str,
9117 on_change,
9118) -> None:
9119 """One numeric metadata field's filter, at any of the three grains.
9121 A slider over the loaded records' values, then *Keep unknown values* when
9122 some record has none. A field with one value has no range to slide —
9123 Streamlit refuses a slider whose ends are equal, which stopped the whole
9124 Scanpath view for a one-row trial table — so it is shown as that value,
9125 and its unknowns choice is the only narrowing it offers.
9126 """
9127 extent = _metadata_numeric_summary(attached)[field.name][0]
9128 if extent is None:
9129 st.session_state.pop(keep_unknown_key(key), None)
9130 return
9131 low, high = extent
9132 if low < high:
9133 _seed_range_bounds(key, low, high, prefix=prefix)
9134 host.slider(
9135 field.label,
9136 min_value=low,
9137 max_value=high,
9138 key=key,
9139 on_change=on_change,
9140 help=f"From your {table} table ({field.source}). Keep only {noun}s "
9141 "whose value falls in this range.",
9142 )
9143 else:
9144 _render_fixed_value(host, field.label, low, noun)
9145 _render_keep_unknown(
9146 host, key, unknown=unknown, noun=noun, prefix=prefix, on_change=on_change
9147 )
9150def _metadata_range_narrowing(attached, field, key: str, prefix: str):
9151 """``(range, keep_unknown)`` one numeric metadata field narrows by, or
9152 ``None`` when it does not narrow.
9154 It narrows when its slider is off full extent, or when *Keep unknown
9155 values* is off — then even the full extent (or a constant field's one
9156 value) leaves out the records with no value.
9157 """
9158 extent = _metadata_numeric_summary(attached)[field.name][0]
9159 if extent is None:
9160 return None
9161 keep = _keeps_unknown(key, prefix)
9162 chosen = st.session_state.get(key)
9163 if (
9164 extent[0] < extent[1]
9165 and isinstance(chosen, (tuple, list))
9166 and len(chosen) == 2
9167 and tuple(chosen) != extent
9168 ):
9169 return (float(chosen[0]), float(chosen[1])), keep
9170 if not keep:
9171 return extent, keep
9172 return None
9175def _render_participant_metadata_filters(host, *, prefix: str, on_change) -> None:
9176 """One control per registered participant-grain field.
9178 A reader attribute narrows by **reader**, so these do not need the field to
9179 exist on the words/fixations frames — `_compute_trial_filters` resolves the
9180 selection to a set of participant ids and intersects it with the participant
9181 filter. That is the whole reason the table never has to be broadcast.
9182 """
9183 from scanpath_studio import metadata as md
9185 attached = md.attached_for("participant", prefix)
9186 if attached is None or not attached.fields:
9187 return
9188 host.markdown("**By participant**")
9189 for field in attached.fields:
9190 key = metadata_filter_key(field.name, prefix)
9191 if field.is_numeric:
9192 _render_metadata_range(
9193 host,
9194 attached,
9195 field,
9196 key,
9197 noun="participant",
9198 table="participant",
9199 unknown=_metadata_numeric_summary(attached)[field.name][1],
9200 prefix=prefix,
9201 on_change=on_change,
9202 )
9203 continue
9204 options = md.options_for(attached, field.name)
9205 if len(options) <= 1:
9206 continue
9207 _seed_filter_widget(key, options, options, prefix=prefix)
9208 _labeled(
9209 host,
9210 "multiselect",
9211 field.label,
9212 options=options,
9213 key=key,
9214 on_change=on_change,
9215 help=f"From your participant table ({field.source}).",
9216 )
9219def _render_trial_metadata_filters(
9220 host,
9221 *,
9222 prefix: str,
9223 on_change,
9224 keys: Callable[[], set] | None = None,
9225 pool_key: tuple | None = None,
9226) -> None:
9227 """One control per registered trial-grain field (DATA-29).
9229 The mirror image of :func:`_render_participant_metadata_filters`, and simpler
9230 for the reason DATA-29 opened with: a trial attribute already *is* the grain
9231 the pool is keyed on, so the selection resolves straight to
9232 ``(participant_id, trial_id)`` keys with no reader indirection in between.
9234 ``keys`` returns the loaded pool's ``(participant_id, trial_id)`` pairs, so
9235 the *Keep unknown values* caption counts readings; without it the count is
9236 of the table's own keys. ``pool_key`` identifies that pool (the frames'
9237 fingerprints) for the cached count, so ``keys`` is called only on a miss.
9238 """
9239 from scanpath_studio import metadata as md
9241 attached = md.attached_for("trial", prefix)
9242 if attached is None or not attached.fields:
9243 return
9244 host.markdown("**By trial**")
9245 for field in attached.fields:
9246 key = trial_metadata_filter_key(field.name, prefix)
9247 if field.is_numeric:
9248 _render_metadata_range(
9249 host,
9250 attached,
9251 field,
9252 key,
9253 noun="trial",
9254 table="trial",
9255 unknown=_metadata_numeric_summary(attached, keys, pool_key)[field.name][
9256 1
9257 ],
9258 prefix=prefix,
9259 on_change=on_change,
9260 )
9261 continue
9262 options = md.trial_options_for(attached, field.name)
9263 if len(options) <= 1:
9264 continue
9265 _seed_filter_widget(key, options, options, prefix=prefix)
9266 _labeled(
9267 host,
9268 "multiselect",
9269 field.label,
9270 options=options,
9271 key=key,
9272 on_change=on_change,
9273 help=f"From your trial table ({field.source}).",
9274 )
9277def _render_text_metadata_filters(host, *, prefix: str, on_change) -> None:
9278 """One control per registered text-grain field — the third grain.
9280 Flat, like :func:`_render_participant_metadata_filters` — a text attribute
9281 narrows by **text**, so `_compute_trial_filters` resolves the selection to
9282 a set of text ids and folds it into the existing "Narrow by → Text" slot
9283 rather than a new one.
9284 """
9285 from scanpath_studio import metadata as md
9287 attached = md.attached_for("text", prefix)
9288 if attached is None or not attached.fields:
9289 return
9290 host.markdown("**By text**")
9291 for field in attached.fields:
9292 key = text_metadata_filter_key(field.name, prefix)
9293 if field.is_numeric:
9294 _render_metadata_range(
9295 host,
9296 attached,
9297 field,
9298 key,
9299 noun="text",
9300 table="text",
9301 unknown=_metadata_numeric_summary(attached)[field.name][1],
9302 prefix=prefix,
9303 on_change=on_change,
9304 )
9305 continue
9306 options = md.text_options_for(attached, field.name)
9307 if len(options) <= 1:
9308 continue
9309 _seed_filter_widget(key, options, options, prefix=prefix)
9310 _labeled(
9311 host,
9312 "multiselect",
9313 field.label,
9314 options=options,
9315 key=key,
9316 on_change=on_change,
9317 help=f"From your text table ({field.source}).",
9318 )
9321def _text_metadata_narrowing(prefix: str) -> tuple:
9322 """``(text ids | None, widget keys)`` for the active text-metadata filters.
9324 Flat-grain sibling of :func:`_participant_metadata_narrowing` — ``None``
9325 means no constraint; an **empty set** means a constraint nothing satisfies.
9326 """
9327 from scanpath_studio import metadata as md
9329 attached = md.attached_for("text", prefix)
9330 if attached is None or not attached.fields:
9331 return None, ()
9332 selections: dict[str, list] = {}
9333 ranges: dict[str, tuple] = {}
9334 keep_unknown: dict[str, bool] = {}
9335 keys: list = []
9336 for field in attached.fields:
9337 key = text_metadata_filter_key(field.name, prefix)
9338 chosen = st.session_state.get(key)
9339 if field.is_numeric:
9340 narrowing = _metadata_range_narrowing(attached, field, key, prefix)
9341 if narrowing is not None:
9342 ranges[field.name], keep_unknown[field.name] = narrowing
9343 keys.append(key)
9344 continue
9345 options = md.text_options_for(attached, field.name)
9346 if chosen and len(chosen) < len(options):
9347 selections[field.name] = list(chosen)
9348 keys.append(key)
9349 return (
9350 md.texts_matching(attached, selections, ranges, keep_unknown=keep_unknown),
9351 tuple(keys),
9352 )
9355def _trial_metadata_narrowing(prefix: str, keys) -> tuple:
9356 """``((participant_id, trial_id) keys | None, widget keys)`` (DATA-29).
9358 ``None`` means no constraint; an empty set means a constraint nothing
9359 satisfies — the same three-way contract as
9360 :func:`_participant_metadata_narrowing`, one grain down. ``keys`` is a
9361 callable returning the loaded pool's keys, called only when a trial table
9362 is attached: it scans every frame, and this runs on every rerun.
9363 """
9364 from scanpath_studio import metadata as md
9366 attached = md.attached_for("trial", prefix)
9367 if attached is None or not attached.fields:
9368 return None, ()
9369 selections: dict[str, list] = {}
9370 ranges: dict[str, tuple] = {}
9371 keep_unknown: dict[str, bool] = {}
9372 widget_keys: list = []
9373 for field in attached.fields:
9374 key = trial_metadata_filter_key(field.name, prefix)
9375 chosen = st.session_state.get(key)
9376 if field.is_numeric:
9377 narrowing = _metadata_range_narrowing(attached, field, key, prefix)
9378 if narrowing is not None:
9379 ranges[field.name], keep_unknown[field.name] = narrowing
9380 widget_keys.append(key)
9381 continue
9382 options = md.trial_options_for(attached, field.name)
9383 if chosen and len(chosen) < len(options):
9384 selections[field.name] = list(chosen)
9385 widget_keys.append(key)
9386 return (
9387 md.trials_matching(
9388 attached, selections, ranges, keys=keys(), keep_unknown=keep_unknown
9389 ),
9390 tuple(widget_keys),
9391 )
9394def _seed_range_bounds(key: str, low: float, high: float, *, prefix: str) -> None:
9395 """``_seed_range_widget`` for a key that is not derived from a column name."""
9397 def _clamped(pair) -> tuple:
9398 return (min(max(pair[0], low), high), min(max(pair[1], low), high))
9400 if key in st.session_state:
9401 stored = st.session_state[key]
9402 if isinstance(stored, (tuple, list)) and len(stored) == 2:
9403 st.session_state[key] = _clamped(stored)
9404 return
9405 mirror = st.session_state.get(f"{prefix}_trial_filters_raw", {})
9406 stored = mirror.get(key)
9407 if isinstance(stored, (tuple, list)) and len(stored) == 2:
9408 st.session_state[key] = _clamped(stored)
9409 else:
9410 st.session_state[key] = (low, high)
9413def _participant_metadata_narrowing(prefix: str) -> tuple:
9414 """``(reader ids | None, widget keys)`` for the active metadata filters.
9416 ``None`` means no constraint; an **empty set** means a constraint nothing
9417 satisfies. The keys are the widgets that produced it, so UX-7's per-filter
9418 clear can reset the right controls.
9419 """
9420 from scanpath_studio import metadata as md
9422 attached = md.attached_for("participant", prefix)
9423 if attached is None or not attached.fields:
9424 return None, ()
9425 selections: dict[str, list] = {}
9426 ranges: dict[str, tuple] = {}
9427 keep_unknown: dict[str, bool] = {}
9428 keys: list = []
9429 for field in attached.fields:
9430 key = metadata_filter_key(field.name, prefix)
9431 chosen = st.session_state.get(key)
9432 if field.is_numeric:
9433 narrowing = _metadata_range_narrowing(attached, field, key, prefix)
9434 if narrowing is not None:
9435 ranges[field.name], keep_unknown[field.name] = narrowing
9436 keys.append(key)
9437 continue
9438 options = md.options_for(attached, field.name)
9439 if chosen and len(chosen) < len(options):
9440 selections[field.name] = list(chosen)
9441 keys.append(key)
9442 return (
9443 md.participants_matching(
9444 attached, selections, ranges, keep_unknown=keep_unknown
9445 ),
9446 tuple(keys),
9447 )
9450def _loaded_trial_keys(words: pd.DataFrame, fixations: pd.DataFrame) -> set:
9451 """``(participant_id, trial_id)`` pairs present in either frame (DATA-29)."""
9452 from scanpath_studio.data import frame_fingerprint
9454 return set(
9455 _c_loaded_trial_keys(
9456 words, fixations, frame_fingerprint(words), frame_fingerprint(fixations)
9457 )
9458 )
9461@st.cache_data(show_spinner=False, max_entries=8)
9462def _c_loaded_trial_keys(_words, _fixations, wkey, fkey) -> frozenset:
9463 from scanpath_studio.data import trial_keys as _keys
9465 return frozenset(_keys(_words)) | frozenset(_keys(_fixations))
9468def _pool_fingerprint(words: pd.DataFrame, fixations: pd.DataFrame) -> tuple:
9469 """The loaded pool's identity, for a cache keyed on it."""
9470 from scanpath_studio.data import frame_fingerprint
9472 return (frame_fingerprint(words), frame_fingerprint(fixations))
9475def _metadata_numeric_summary(
9476 attached, keys: Callable[[], set] | None = None, pool_key: tuple | None = None
9477) -> dict:
9478 """``{field: (extent, unknown count)}`` for an attached metadata table's
9479 numeric fields — what each range filter is drawn and narrowed from.
9481 Cached on the table's content (its frame's fingerprint and its join report)
9482 and, when ``keys`` counts unknowns over the loaded pool, on ``pool_key``:
9483 the filter panel draws on every rerun, and these were several Python passes
9484 over the table and the pool per numeric field each time. ``keys`` without a
9485 ``pool_key`` is called every time, and the pool it returns is the key.
9486 """
9487 from scanpath_studio.data import frame_fingerprint
9489 names = tuple(f.name for f in attached.fields if f.is_numeric)
9490 table_key = (
9491 type(attached).__name__,
9492 frame_fingerprint(attached.frame),
9493 hash(attached.report),
9494 names,
9495 )
9496 if keys is not None and pool_key is None:
9497 loaded = frozenset(keys())
9498 keys, pool_key = (lambda: loaded), loaded
9499 return _c_metadata_numeric_summary(attached, table_key, keys, pool_key)
9502@st.cache_data(show_spinner=False, max_entries=16)
9503def _c_metadata_numeric_summary(_attached, table_key, _keys, pool_key) -> dict:
9504 from scanpath_studio import metadata as md
9506 loaded = _keys() if _keys is not None else None
9507 return {
9508 name: (
9509 md.numeric_extent(_attached, name),
9510 md.unknown_count(_attached, name, loaded),
9511 )
9512 for name in table_key[-1]
9513 }
9516def _compute_trial_filters(
9517 words: pd.DataFrame, fixations: pd.DataFrame, *, prefix: str = ""
9518) -> dict:
9519 """Derive the narrowing filter result from the live filter-widget values.
9521 Reads the widget keys (filter_participants / filter_<col> / filter_favorites /
9522 filter_req_tags / filter_exc_tags) — which Streamlit has already updated on the
9523 rerun the user changed a filter — so the filter applies on the SAME run. The
9524 on_change callbacks in ``render_trial_filters`` call this *before* the rerun;
9525 it also runs at the end of that function for no-change runs. Only narrowing
9526 selections feed the result.
9527 """
9528 result: dict = {
9529 "participants": None,
9530 "metadata": {},
9531 "ranges": {},
9532 # Ranged columns whose *Keep unknown values* is off.
9533 "ranges_drop_unknown": (),
9534 # column -> the session key holding it, so "clear just this filter"
9535 # (UX-7) can reset one widget. Not derivable from the column name: the
9536 # Narrow-by Text multiselect lands in `metadata` under the *text column*
9537 # but lives under `filter_text_id`.
9538 "metadata_keys": {},
9539 # DATA-20: widget keys behind a participant-grain metadata narrowing,
9540 # which lands in `participants` above rather than in `metadata`.
9541 "participant_filter_keys": (),
9542 # DATA-29: a trial-grain narrowing is already `(participant_id,
9543 # trial_id)` keys, so it gets its own slot rather than being squeezed
9544 # into `participants` (a reader may be kept for one trial and dropped
9545 # for another) or `metadata` (which is column → values on the frames).
9546 # `None` = no constraint; an empty set = nothing satisfies it.
9547 "trial_keys": None,
9548 "trial_filter_keys": (),
9549 # Text-grain metadata's own widget keys, folded into the existing
9550 # "Narrow by → Text" slot above (`metadata`/`metadata_keys`) rather
9551 # than a fourth top-level slot — this is only for UX-7's per-filter
9552 # clear, since the *value* already lives in `metadata[text_field]`.
9553 "text_filter_keys": (),
9554 "favorites_only": False,
9555 "required_tags": [],
9556 "excluded_tags": [],
9557 }
9558 parts = _participant_options(
9559 words,
9560 fixations,
9561 cache_key=(frame_fingerprint(words), frame_fingerprint(fixations)),
9562 )
9563 if len(parts) > 1:
9564 sel = st.session_state.get(f"{prefix}filter_participants")
9565 if sel and len(sel) < len(parts):
9566 result["participants"] = list(sel)
9567 # DATA-20: a participant-grain metadata constraint *is* a participant
9568 # constraint, so it folds into the same slot rather than becoming a fourth
9569 # kind of filter every consumer would have to learn. Intersection, not
9570 # replacement: an explicit reader pick still wins over the table.
9571 by_metadata, meta_keys = _participant_metadata_narrowing(prefix)
9572 if by_metadata is not None:
9573 chosen = result["participants"]
9574 keep = by_metadata if chosen is None else by_metadata & {str(p) for p in chosen}
9575 # Ordered by the dataset's own participant order, so the resulting
9576 # selector list doesn't reshuffle when a metadata filter changes. May
9577 # legitimately be **empty** — an impossible combination narrows to no
9578 # reader, which `data.filter_trials` distinguishes from "no constraint".
9579 result["participants"] = [p for p in parts if str(p) in keep]
9580 # UX-7's per-filter report clears by widget key, and this narrowing did
9581 # not come from `filter_participants`. Its own top-level slot, not an
9582 # entry in `metadata_keys`: that dict is column-name → *one* key string,
9583 # and a dataset is free to have a column called "participants".
9584 result["participant_filter_keys"] = meta_keys
9585 # DATA-29: the trial table narrows to keys directly. Computed against the
9586 # loaded pool so a trial-id-keyed table expands to every reading of that
9587 # trial, and so a numeric range keeps the trials the table never mentions
9588 # (UX-49's rule, one grain down).
9589 by_trial, trial_keys_used = _trial_metadata_narrowing(
9590 prefix, lambda: _loaded_trial_keys(words, fixations)
9591 )
9592 if by_trial is not None:
9593 result["trial_keys"] = by_trial
9594 result["trial_filter_keys"] = trial_keys_used
9595 # Text narrowing (the "Narrow by → Text" multiselect). Like a categorical
9596 # condition, but the text id isn't in the condition list, so handle it here.
9597 text_field, text_frame = _text_field_and_frame(words, fixations)
9598 if text_field is not None:
9599 text_vals = _column_unique_strs(
9600 text_frame,
9601 text_field,
9602 cache_key=(frame_fingerprint(text_frame), text_field),
9603 )
9604 sel = st.session_state.get(f"{prefix}filter_text_id")
9605 by_picker = (
9606 set(sel)
9607 if sel and len(text_vals) > 1 and len(sel) < len(text_vals)
9608 else None
9609 )
9610 # Text-grain metadata folds into the same slot, the same way
9611 # participant-grain metadata folds into `result["participants"]` — an
9612 # explicit pick still wins over (intersects with) the table.
9613 by_text_meta, text_meta_keys = _text_metadata_narrowing(prefix)
9614 combined = None
9615 if by_picker is not None and by_text_meta is not None:
9616 combined = by_picker & by_text_meta
9617 elif by_picker is not None:
9618 combined = by_picker
9619 elif by_text_meta is not None:
9620 combined = by_text_meta & {str(v) for v in text_vals}
9621 if combined is not None:
9622 result["metadata"][text_field] = combined
9623 # UX-7's per-filter clear pops exactly this key, so it must be
9624 # emitted already-prefixed or clearing one of B's filters no-ops.
9625 result["metadata_keys"][text_field] = f"{prefix}filter_text_id"
9626 result["text_filter_keys"] = text_meta_keys
9627 # UX-49: numeric trial-level columns narrow by range, not by membership. A
9628 # slider still at full extent is "no filter" and contributes nothing.
9629 # With *Keep unknown values* off, even the full extent narrows: it leaves
9630 # out the trials with no value.
9631 numeric_fields = _numeric_filter_fields(words, fixations)
9632 drop_unknown: list = []
9633 for col, (_frame, lo, hi) in numeric_fields.items():
9634 key = _range_filter_key(col, prefix)
9635 chosen = st.session_state.get(key)
9636 keep = _keeps_unknown(key, prefix)
9637 if isinstance(chosen, (tuple, list)) and len(chosen) == 2:
9638 sel_lo, sel_hi = float(chosen[0]), float(chosen[1])
9639 else:
9640 sel_lo, sel_hi = float(lo), float(hi)
9641 if sel_lo <= lo and sel_hi >= hi and keep:
9642 continue
9643 result["ranges"][col] = (sel_lo, sel_hi)
9644 result["metadata_keys"][col] = key
9645 if not keep:
9646 drop_unknown.append(col)
9647 result["ranges_drop_unknown"] = tuple(drop_unknown)
9648 for col in _filter_fields_for(words, fixations):
9649 if col in numeric_fields:
9650 continue
9651 frame = words if col in words.columns else fixations
9652 if col not in frame.columns:
9653 continue
9654 spec = _FILTER_FIELD_LABELS.get(col, {})
9655 if pd.api.types.is_bool_dtype(frame[col]):
9656 vals = _bool_filter_narrowing(
9657 col,
9658 frame,
9659 spec.get("true", "Yes"),
9660 spec.get("false", "No"),
9661 f"{prefix}filter_{col}",
9662 )
9663 if vals is not None:
9664 result["metadata"][col] = vals
9665 result["metadata_keys"][col] = f"{prefix}filter_{col}"
9666 else:
9667 values = _column_unique_strs(
9668 frame, col, cache_key=(frame_fingerprint(frame), col)
9669 )
9670 sel = st.session_state.get(f"{prefix}filter_{col}")
9671 if sel and len(values) > 1 and len(sel) < len(values):
9672 result["metadata"][col] = set(sel)
9673 result["metadata_keys"][col] = f"{prefix}filter_{col}"
9674 result["favorites_only"] = bool(
9675 st.session_state.get(f"{prefix}filter_favorites", False)
9676 )
9677 result["required_tags"] = list(
9678 st.session_state.get(f"{prefix}filter_req_tags") or []
9679 )
9680 result["excluded_tags"] = list(
9681 st.session_state.get(f"{prefix}filter_exc_tags") or []
9682 )
9683 return result
9686def _text_field_and_frame(words: pd.DataFrame, fixations: pd.DataFrame):
9687 """The text/passage id column to narrow by + the frame it lives on (prefer
9688 fixations, where trials live). ``(None, fixations)`` when no text column."""
9689 for field in ("unique_text_id", "text_id"):
9690 if field in fixations.columns:
9691 return field, fixations
9692 if field in words.columns:
9693 return field, words
9694 return None, fixations
9697def render_narrow_by(
9698 words: pd.DataFrame,
9699 fixations: pd.DataFrame,
9700 *,
9701 prefix: str = "",
9702 text_host=None,
9703 part_host=None,
9704) -> None:
9705 """Inline **Narrow by** multiselects — Text + Participant — that narrow the
9706 trial pool feeding the picker (the former Browse-by Text/Participant modes, now
9707 filters). They write the same ``filter_*`` keys the "More" popover uses and
9708 recompute via ``_compute_trial_filters``, so narrowing applies the same run.
9709 Start empty = no narrowing; pick values to narrow."""
9711 def _apply() -> None:
9712 st.session_state[f"{prefix}_trial_filters"] = _compute_trial_filters(
9713 words, fixations, prefix=prefix
9714 )
9716 th = text_host if text_host is not None else st
9717 ph = part_host if part_host is not None else st
9719 text_field, text_frame = _text_field_and_frame(words, fixations)
9720 if text_field is not None:
9721 text_vals = _column_unique_strs(
9722 text_frame,
9723 text_field,
9724 cache_key=(frame_fingerprint(text_frame), text_field),
9725 )
9726 if len(text_vals) > 1:
9727 _seed_filter_widget(f"{prefix}filter_text_id", text_vals, [], prefix=prefix)
9728 th.multiselect(
9729 "Text",
9730 options=text_vals,
9731 key=f"{prefix}filter_text_id",
9732 on_change=_apply,
9733 placeholder="All texts",
9734 label_visibility="collapsed",
9735 )
9737 parts = _participant_options(
9738 words,
9739 fixations,
9740 cache_key=(frame_fingerprint(words), frame_fingerprint(fixations)),
9741 )
9742 if len(parts) > 1:
9743 _seed_filter_widget(f"{prefix}filter_participants", parts, [], prefix=prefix)
9744 ph.multiselect(
9745 "Participant",
9746 options=parts,
9747 key=f"{prefix}filter_participants",
9748 on_change=_apply,
9749 placeholder="All participants",
9750 label_visibility="collapsed",
9751 )
9754def trial_filter_labels(
9755 words: pd.DataFrame,
9756 fixations: pd.DataFrame,
9757 numeric_fields: dict | None = None,
9758 *,
9759 names: cn.ColumnNames | None = None,
9760) -> dict[str, str]:
9761 """``{column: title}`` for the trial-filter panel's data-column filters.
9763 DATA-66: a filter is titled by the dataset's own name for its column
9764 (``names``, the open dataset's map by default; Compare's B passes its own).
9765 UX-149: one label namespace across the range sliders and the multiselects,
9766 so two filters over different columns never share a title — the ✏️ chip
9767 editor's rule (`unique_field_labels`).
9768 """
9769 if numeric_fields is None:
9770 numeric_fields = _numeric_filter_fields(words, fixations)
9771 if names is None:
9772 names = _rail_names()
9773 columns = [
9774 *numeric_fields,
9775 *(c for c in _filter_fields_for(words, fixations) if c not in numeric_fields),
9776 ]
9777 return unique_field_labels(columns, names.field_label)
9780def render_trial_filters(
9781 words: pd.DataFrame,
9782 fixations: pd.DataFrame,
9783 *,
9784 host,
9785 prefix: str = "",
9786 names: cn.ColumnNames | None = None,
9787) -> dict:
9788 """Render the trial-filter controls into ``host`` and persist the selections.
9790 Lets the user narrow the trial pool by participant and by categorical
9791 condition (Hunting/Gathering, difficulty, first/repeated reading,
9792 correctness) plus annotation state (favorites / tags). Renders into ``host``
9793 — since UX-64 the trial picker's 🔎 funnel popover. The narrowing result is
9794 derived by ``_compute_trial_filters`` and stashed in session_state
9795 (`_trial_filters`); each widget's ``on_change`` recomputes it *before* the
9796 rerun so ``main()``'s ``read_trial_filters`` applies the change on the same
9797 run. The persisted value also survives runs where this panel isn't rendered
9798 (a non-Scanpath view on the top nav).
9799 ``host`` is required: it used to default to ``st.sidebar``, a container the
9800 app has not drawn since UX-38 — a caller that forgot it rendered the whole
9801 panel into chrome nobody sees. Now it cannot compile.
9802 """
9804 def _apply() -> None:
9805 st.session_state[f"{prefix}_trial_filters"] = _compute_trial_filters(
9806 words, fixations, prefix=prefix
9807 )
9809 # Text + Participant narrowing now lives in the inline "Narrow by" row
9810 # (``render_narrow_by``); this popover keeps the condition + annotation filters.
9811 #
9812 # UX-49: a numeric trial-level column gets a two-ended range slider instead
9813 # of a multiselect over its distinct floats. Rendered first, as extra rows
9814 # among the categorical ones rather than in a section of their own.
9815 numeric_fields = _numeric_filter_fields(words, fixations)
9816 labels = trial_filter_labels(words, fixations, numeric_fields, names=names)
9817 for col, (frame, lo, hi) in numeric_fields.items():
9818 label = labels[col]
9819 _seed_range_widget(col, lo, hi, prefix=prefix)
9820 host.slider(
9821 label,
9822 min_value=lo,
9823 max_value=hi,
9824 key=_range_filter_key(col, prefix),
9825 on_change=_apply,
9826 help=" ".join(
9827 filter(
9828 None,
9829 (
9830 field_help(col, names),
9831 "Keep only trials whose value falls in this range.",
9832 ),
9833 )
9834 ),
9835 )
9836 # Say how many trials have no value and what happens to them, or the
9837 # kept-anyway trials look like the range isn't working.
9838 _render_keep_unknown(
9839 host,
9840 _range_filter_key(col, prefix),
9841 unknown=_trials_missing_column(
9842 frame, col, cache_key=(frame_fingerprint(frame), col)
9843 ),
9844 noun="trial",
9845 prefix=prefix,
9846 on_change=_apply,
9847 )
9848 for col in _filter_fields_for(words, fixations):
9849 if col in numeric_fields:
9850 continue
9851 frame = words if col in words.columns else fixations
9852 if col not in frame.columns:
9853 continue
9854 spec = _FILTER_FIELD_LABELS.get(col, {})
9855 label = labels[col]
9856 if pd.api.types.is_bool_dtype(frame[col]):
9857 _bool_metadata_filter(
9858 label,
9859 col,
9860 frame,
9861 spec.get("true", "Yes"),
9862 spec.get("false", "No"),
9863 f"{prefix}filter_{col}",
9864 host,
9865 on_change=_apply,
9866 help=field_help(col, names),
9867 )
9868 else:
9869 values = _column_unique_strs(
9870 frame, col, cache_key=(frame_fingerprint(frame), col)
9871 )
9872 if len(values) > 1:
9873 _seed_filter_widget(
9874 f"{prefix}filter_{col}", values, values, prefix=prefix
9875 )
9876 _labeled(
9877 host,
9878 "multiselect",
9879 label,
9880 options=values,
9881 key=f"{prefix}filter_{col}",
9882 on_change=_apply,
9883 help=field_help(col, names) or None,
9884 )
9886 _render_participant_metadata_filters(host, prefix=prefix, on_change=_apply)
9887 _render_trial_metadata_filters(
9888 host,
9889 prefix=prefix,
9890 on_change=_apply,
9891 keys=lambda: _loaded_trial_keys(words, fixations),
9892 pool_key=_pool_fingerprint(words, fixations),
9893 )
9894 _render_text_metadata_filters(host, prefix=prefix, on_change=_apply)
9896 # The annotation filters are trial level: they read the trial's own star
9897 # and tags, never a screen's (`annotations.select_keys`), so the picker
9898 # offers trial-level tags only and the panel says where screen ones are.
9899 host.markdown("**By trial annotation**")
9900 if f"{prefix}filter_favorites" not in st.session_state:
9901 st.session_state[f"{prefix}filter_favorites"] = bool(
9902 st.session_state.get(f"{prefix}_trial_filters_raw", {}).get(
9903 f"{prefix}filter_favorites", False
9904 )
9905 )
9906 _labeled(
9907 host,
9908 "checkbox",
9909 f"{ICONS['favorite']} Favorites only",
9910 key=f"{prefix}filter_favorites",
9911 on_change=_apply,
9912 help="Keep trials starred as a whole. A star on one screen does not count.",
9913 )
9914 # DATA-48: the tags of the dataset this pool comes from — compare mode's B
9915 # (the `cmp` prefix) may be another dataset, with tags of its own.
9916 tags = known_tags(prefix, trial_level=True)
9917 if tags:
9918 _seed_filter_widget(f"{prefix}filter_req_tags", tags, [], prefix=prefix)
9919 _labeled(
9920 host,
9921 "multiselect",
9922 "With any of these tags",
9923 options=tags,
9924 key=f"{prefix}filter_req_tags",
9925 on_change=_apply,
9926 help="Keep trials tagged as a whole with any of these.",
9927 )
9928 _seed_filter_widget(f"{prefix}filter_exc_tags", tags, [], prefix=prefix)
9929 _labeled(
9930 host,
9931 "multiselect",
9932 "Excluding tags",
9933 options=tags,
9934 key=f"{prefix}filter_exc_tags",
9935 on_change=_apply,
9936 help="Hide trials tagged as a whole with any of these, e.g. 'To exclude'.",
9937 )
9938 if has_screen_annotations(prefix):
9939 host.caption(
9940 "Screen annotations are not used by these filters. They are listed "
9941 f"on {ICONS['view_data']} **Data Management → Annotations**."
9942 )
9944 # UX-26: the filter reset used to appear only in the empty-result diagnostic
9945 # panel — you had to filter yourself into nothing before the escape hatch
9946 # showed up. It now has a permanent home at the foot of the panel that set
9947 # the filters (and a second one in the rail's Reset settings popover).
9948 host.divider()
9949 host.button(
9950 "✕ Clear all filters",
9951 key=f"{prefix}clear_all_filters_panel",
9952 on_click=clear_trial_filters,
9953 args=(prefix,),
9954 width="stretch",
9955 help="Reset every filter in this panel.",
9956 )
9958 # Mirror the rendered widget values so _seed_filter_widget can restore them on
9959 # a run where this panel isn't shown (the keys get cleared); then publish the
9960 # derived result for read_trial_filters (covers no-change runs).
9961 # UX-49: the range keys have to be listed explicitly. Without them a range
9962 # silently resets on any run where this popover isn't rendered — a trip
9963 # through Corpus Analysis is enough, since Streamlit drops the key and
9964 # `_seed_range_widget` would then find nothing to restore.
9965 keys = (
9966 [
9967 f"{prefix}filter_participants",
9968 f"{prefix}filter_text_id",
9969 f"{prefix}filter_req_tags",
9970 f"{prefix}filter_exc_tags",
9971 ]
9972 + [f"{prefix}filter_{c}" for c in _filter_fields_for(words, fixations)]
9973 + [_range_filter_key(c, prefix) for c in numeric_fields]
9974 # DATA-20 — mirrored like any other filter, so a metadata narrowing
9975 # survives a run where the popover didn't render and round-trips
9976 # through Share / save & restore with the rest of the filter layer.
9977 + [metadata_filter_key(f.name, prefix) for f in participant_metadata_fields()]
9978 # DATA-29 — same reason, one grain down.
9979 + [trial_metadata_filter_key(f.name, prefix) for f in trial_metadata_fields()]
9980 # And the text table, the third grain — same reasoning again.
9981 + [text_metadata_filter_key(f.name, prefix) for f in text_metadata_fields()]
9982 )
9983 # Each range's *Keep unknown values* choice, for the same reason as the
9984 # range itself.
9985 keys += [keep_unknown_key(k) for k in keys]
9986 st.session_state[f"{prefix}_trial_filters_raw"] = {
9987 k: st.session_state[k] for k in keys if k in st.session_state
9988 }
9989 result = _compute_trial_filters(words, fixations, prefix=prefix)
9990 st.session_state[f"{prefix}_trial_filters"] = result
9991 return result