Coverage for scanpath_studio/code_snippet.py: 97%
860 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
1"""EXP-7 — the API / CLI code that reproduces the figure currently on screen.
3The hand-off from *exploring* to *scripting*: someone tunes a figure in the app
4for a paper, then wants that exact figure rebuilt headlessly for a batch of
5trials without reverse-engineering which of the ~dozens of options they actually
6changed.
8This is the **third** rendering of one state, after the deep link
9(``url_state._build_share_query``) and the 💾 saved plot config — so it is
10deliberately *not* a fourth reading of ``session_state``. All three take the same
11input: the settings dict the figure was built from
12(``controls._collect_viz_settings`` → ``tabs._build_figure_settings``). The app
13publishes that dict, plus the trial identity and the render context, as a
14:class:`FigureState` at the point the figure is built (``_snippet_state``), and
15this module is a pure serializer over it with two back ends. Nothing here knows
16about Streamlit, and nothing here decides plot semantics.
18"Only the non-defaults" is answered by :func:`api.figure_options`, which already
19returns every figure keyword → its *effective* default. :func:`figure_kwargs`
20diffs the live settings against it; ``explicit=True`` emits the full form
21instead.
23The two back ends are not symmetric: the Python API takes every figure keyword
24as itself, while the CLI spells each one as a flag in its own vocabulary.
25:data:`_CLI_EMITTERS` is that mapping spelled out, and anything a snippet needs
26but the CLI cannot say comes back in :attr:`ReproductionCode.cli_unsupported` —
27a live audit of the four-surface rule rather than a silent drop. Since EXP-20
28every figure option has a flag, so the list is empty unless an option is added
29without one, which `tests/test_render_every_option.py` refuses.
30"""
32from __future__ import annotations
34import json
35import shlex
36from dataclasses import dataclass, field, replace
37from typing import Any
39#: Where ``tabs._publish_snippet_state`` parks the :class:`FigureState` the
40#: Share subtab writes its snippet from. Session-local and never serialized —
41#: it is derived from state that *is* on the wire, so it carries no contract of
42#: its own and is deliberately absent from ``session_keys``.
43SNIPPET_STATE_KEY = "_snippet_state"
45#: How to get the package the snippets import. Shown *above* both flavours in
46#: the app (`url_state._render_code_snippet_body`) rather than baked into
47#: :func:`python_snippet` / :func:`cli_snippet`: a snippet pulled through
48#: ``api.figure_code`` is being composed by something that already has the
49#: package installed, so the line is presentation for the reader who is about to
50#: paste this into a fresh notebook or shell, not part of the recipe.
51INSTALL_COMMAND = "pip install scanpath-studio"
53#: The figure kinds a snippet can reproduce, matching ``api.figure_options``.
54KINDS = ("static", "animation", "comparison")
56#: The API entry point each kind is reproduced with.
57_API_FUNCTION = {
58 "static": "plot_scanpath",
59 "animation": "animate_scanpath",
60 "comparison": "compare_scanpaths",
61}
63# ---------------------------------------------------------------------------
64# The data half
65# ---------------------------------------------------------------------------
66#: Source kinds. Each one knows how to write *both* halves of its loader — the
67#: Python call and the CLI flags — so a new data source is one entry in
68#: `_SOURCE_WRITERS` rather than a branch in each emitter.
69SOURCE_DEMO = "demo"
70SOURCE_SYNTHETIC = "synthetic"
71SOURCE_FILES = "files"
72SOURCE_AUTHOR = "author"
73SOURCE_POTEC = "potec"
74SOURCE_ONESTOP = "onestop"
75SOURCE_MULTIPLEYE = "multipleye"
76SOURCE_BENCHMARK = "benchmark"
77#: VIZ-45: a dataset recorded as raw gaze alone — no words or fixations table,
78#: so its data half is the samples (``options["raw_gaze"]``, the paths) and the
79#: builder is handed ``None`` for the other two.
80SOURCE_RAW_GAZE = "raw_gaze"
81#: A dataset added through the app: its files are placeholders, but the column
82#: mapping it was added with is written out, table by table (see
83#: :func:`upload_source`).
84SOURCE_UPLOAD = "upload"
85SOURCE_UNKNOWN = "unknown"
87#: What a snippet says when it cannot name the data. An uploaded table lives in
88#: the browser session, not at a path the server can quote back — writing a
89#: guessed path would produce a snippet that runs and loads the wrong thing.
90UNKNOWN_SOURCE_NOTE = (
91 "This dataset was uploaded into the app, so the snippet can't name the "
92 "files it came from — fill in the paths to your own tables."
93)
96@dataclass(frozen=True)
97class SnippetSource:
98 """How the snippet's data half is written.
100 ``kind`` is one of the ``SOURCE_*`` constants; ``options`` carries whatever
101 that kind's writer needs (a corpus root, a regime, a list of paths).
102 ``note`` is a human-readable caveat shown beside the snippet when the code
103 can't fully name the data — the same honesty the Share link's caveats give.
104 """
106 kind: str = SOURCE_UNKNOWN
107 label: str = ""
108 options: dict = field(default_factory=dict)
109 note: str = ""
110 #: Loader options this source needs that ``render`` has no flag for, folded
111 #: into :attr:`ReproductionCode.cli_unsupported` beside the figure ones.
112 cli_unsupported: tuple[str, ...] = ()
115#: The file a snippet saves to when the caller names none, per figure kind. An
116#: animation is interactive HTML — `render --animate` refuses anything else —
117#: while the static and comparison figures raster. EXP-14: `api.figure_code`
118#: used `scanpath.png` for every kind, so its animation command exited on its
119#: first line. The Share subtab keeps its own copy (`url_state._SNIPPET_OUTPUT`).
120DEFAULT_OUTPUT = {
121 "static": "scanpath.png",
122 "comparison": "comparison.png",
123 "animation": "scanpath.html",
124}
127def source_canvas(kind: str) -> tuple[int, int] | None:
128 """The screen ``render`` assumes for a source when ``--canvas`` is omitted.
130 EXP-14: one table for both surfaces. `render --sample` drew the demo at its
131 real 2560×1440 monitor while the Python snippet ``api.figure_code`` wrote
132 for the same request estimated 960×480 from the data extents, so the two
133 flavors of one recipe produced different figures. ``None`` means the
134 screen is read off the data (or, for a benchmark corpus, its manifest)."""
135 if kind in (SOURCE_DEMO, SOURCE_ONESTOP):
136 # OneStop's Dell U2715H — cited in eyegenbench_geometry.DISPLAY_SPECS
137 # ["onestop"] (Berzak et al. 2025); the demo is a subset of OneStop.
138 from .constants import DEFAULT_FIGURE_SIZE
140 return tuple(DEFAULT_FIGURE_SIZE)
141 if kind == SOURCE_POTEC:
142 return (1680, 1050) # PoTeC monitor (DELL P2210)
143 if kind == SOURCE_AUTHOR:
144 return (1200, 800)
145 if kind == SOURCE_MULTIPLEYE:
146 # Coordinates are offset onto the centred stimulus on the real screen.
147 from .datasets import MULTIPLEYE_MONITOR
149 return tuple(MULTIPLEYE_MONITOR)
150 return None
153def _root(source: SnippetSource, fallback: str) -> str:
154 return str(source.options.get("root") or fallback)
157#: The sources whose tables go through ``load_scanpath_data`` — the loader that
158#: keeps only the mapped and recognised columns unless told otherwise.
159_KEEPING_SOURCES = frozenset({SOURCE_FILES, SOURCE_UPLOAD, SOURCE_UNKNOWN})
161#: The figure options that name a fixation column.
162_FIXATION_COLUMN_OPTIONS = ("color_by", "x_field", "y_field", "fixation_hover_fields")
165def _loader_columns() -> frozenset:
166 """Every column ``load_scanpath_data`` hands back without being asked — the
167 canonical ones, the recognized optional ones, and the per-fixation fields
168 the builders compute — plus the two non-column ``color_by`` values."""
169 from .constants import UNIFORM_COLOR_FIELD
170 from .data import FIX_OPTIONAL_FIELDS, WORD_OPTIONAL_FIELDS, empty_fixations_frame
172 return frozenset(
173 {
174 *empty_fixations_frame().columns,
175 *(entry[1] for entry in (*FIX_OPTIONAL_FIELDS, *WORD_OPTIONAL_FIELDS)),
176 "order_in_trial",
177 "pass_index",
178 "progression",
179 "is_regression",
180 "saccade_amplitude",
181 UNIFORM_COLOR_FIELD,
182 "line",
183 }
184 )
187def _kept_columns(settings: dict) -> list[str]:
188 """The dataset's own columns a figure names (a retained pupil size as
189 ``color_by``, say), which the loader has to be told to keep."""
190 named: list[str] = []
191 for option in _FIXATION_COLUMN_OPTIONS:
192 value = settings.get(option)
193 values = value if isinstance(value, (list, tuple)) else [value]
194 named += [str(v) for v in values if isinstance(v, str) and v]
195 known = _loader_columns()
196 return list(dict.fromkeys(c for c in named if c not in known))
199def _with_kept_columns(source: SnippetSource, state: FigureState) -> SnippetSource:
200 """``source`` told to keep the columns ``state``'s figure names, when its
201 loader would otherwise drop them."""
202 if source.kind not in _KEEPING_SOURCES:
203 return source
204 keep = _kept_columns(state.settings)
205 if not keep:
206 return source
207 return replace(source, options={**source.options, "keep_columns": keep})
210def _keep_python(source: SnippetSource) -> list[str]:
211 keep = source.options.get("keep_columns")
212 return [f" keep_columns={_py(list(keep))},"] if keep else []
215def _keep_cli(source: SnippetSource) -> list[str]:
216 keep = source.options.get("keep_columns")
217 return ["--keep-columns", *[str(c) for c in keep]] if keep else []
220def _demo_python(source: SnippetSource) -> list[str]:
221 return ["words, fixations = sps.load_sample_data()"]
224def _synthetic_python(source: SnippetSource) -> list[str]:
225 return [
226 "from scanpath_studio.synthetic import load_synthetic_data",
227 "",
228 "words, fixations = load_synthetic_data()",
229 ]
232def _files_python(source: SnippetSource) -> list[str]:
233 words = source.options.get("words") or ["words.csv"]
234 fixations = source.options.get("fixations") or ["fixations.csv"]
235 return [
236 "words, fixations = sps.load_scanpath_data(",
237 f" {_py(_one_or_list(words))},",
238 f" {_py(_one_or_list(fixations))},",
239 *_schema_python(source),
240 *_keep_python(source),
241 ")",
242 ]
245def _schema_python(source: SnippetSource) -> list[str]:
246 """The ``word_schema=`` / ``fix_schema=`` arguments a file or upload source
247 was loaded with, one field per line."""
248 lines: list[str] = []
249 for option in ("word_schema", "fix_schema"):
250 schema = source.options.get(option)
251 if schema:
252 lines.append(f" {option}={{")
253 lines += [f" {_py(k)}: {_py(v)}," for k, v in schema.items()]
254 lines.append(" },")
255 return lines
258def _author_python(source: SnippetSource) -> list[str]:
259 path = source.options.get("path") or "scanpath.json"
260 return [f"words, fixations = sps.load_authored_scanpath({_py(str(path))})"]
263def _potec_python(source: SnippetSource) -> list[str]:
264 return [
265 "words, fixations = sps.load_potec(",
266 f" {_py(_root(source, 'data/PoTeC'))}, download=True",
267 ")",
268 ]
271def _onestop_python(source: SnippetSource) -> list[str]:
272 lines = [
273 "words, fixations = sps.load_onestop(",
274 f" {_py(_root(source, 'data/OneStop'))},",
275 ]
276 for name, default in (("regime", "ordinary"), ("variant", "public")):
277 value = source.options.get(name)
278 if value:
279 lines.append(f" {name}={_py(str(value))},")
280 else:
281 lines.append(f" {name}={_py(default)},")
282 parts = source.options.get("parts") or ["Paragraph"]
283 lines.append(f" parts={_py([str(p) for p in parts])},")
284 # The public reports are an OSF download; the lacclab variant reads a local
285 # export and rejects `download=True`, so it is written per variant.
286 if str(source.options.get("variant") or "public") == "public":
287 lines.append(" download=True,")
288 lines.append(")")
289 return lines
292def _multipleye_python(source: SnippetSource) -> list[str]:
293 lines = [
294 "words, fixations = sps.load_multipleye(",
295 f" {_py(_root(source, 'data/MultiplEYE'))},",
296 ]
297 fixation_source = source.options.get("fixation_source")
298 if fixation_source and fixation_source != "scanpaths":
299 lines.append(f" fixation_source={_py(str(fixation_source))},")
300 lines.append(")")
301 return lines
304def _benchmark_python(source: SnippetSource) -> list[str]:
305 dataset = str(source.options.get("dataset") or "PoTeC")
306 return [
307 "from scanpath_studio.eyegenbench import load_eyegenbench",
308 "",
309 "words, fixations = load_eyegenbench(",
310 f" {_py(_root(source, 'data/eyegenbench'))}, dataset={_py(dataset)}",
311 ")",
312 ]
315def _unknown_python(source: SnippetSource) -> list[str]:
316 if source.options.get("keep_columns"):
317 return [
318 "# Point these at your own tables — the app can't name an uploaded file.",
319 "words, fixations = sps.load_scanpath_data(",
320 ' "words.csv",',
321 ' "fixations.csv",',
322 *_keep_python(source),
323 ")",
324 ]
325 return [
326 "# Point these at your own tables — the app can't name an uploaded file.",
327 'words, fixations = sps.load_scanpath_data("words.csv", "fixations.csv")',
328 ]
331def _demo_cli(source: SnippetSource) -> list[str]:
332 return ["--sample"]
335def _files_cli(source: SnippetSource) -> list[str]:
336 argv = []
337 words = source.options.get("words") or ["words.csv"]
338 fixations = source.options.get("fixations") or ["fixations.csv"]
339 argv += ["--words", *[str(p) for p in words]]
340 if schema := source.options.get("word_schema"):
341 argv += ["--word-schema", json.dumps(schema, separators=(",", ":"))]
342 argv += ["--fixations", *[str(p) for p in fixations]]
343 if schema := source.options.get("fix_schema"):
344 argv += ["--fix-schema", json.dumps(schema, separators=(",", ":"))]
345 return argv + _keep_cli(source)
348def _author_cli(source: SnippetSource) -> list[str]:
349 return ["--authoring", str(source.options.get("path") or "scanpath.json")]
352def _potec_cli(source: SnippetSource) -> list[str]:
353 return ["--potec", _root(source, "data/PoTeC")]
356def _onestop_cli(source: SnippetSource) -> list[str]:
357 argv = ["--onestop", _root(source, "data/OneStop")]
358 argv += ["--onestop-regime", str(source.options.get("regime") or "ordinary")]
359 argv += ["--onestop-variant", str(source.options.get("variant") or "public")]
360 for part in source.options.get("parts") or ["Paragraph"]:
361 argv += ["--onestop-part", str(part)]
362 return argv
365def _multipleye_cli(source: SnippetSource) -> list[str]:
366 return ["--source", "multipleye", "--export", _root(source, "data/MultiplEYE")]
369def _benchmark_cli(source: SnippetSource) -> list[str]:
370 return [
371 "--eyegenbench",
372 _root(source, "data/eyegenbench"),
373 "--eyegenbench-dataset",
374 str(source.options.get("dataset") or "PoTeC"),
375 ]
378def _unknown_cli(source: SnippetSource) -> list[str]:
379 return ["--words", "words.csv", "--fixations", "fixations.csv", *_keep_cli(source)]
382#: The placeholder paths an uploaded dataset's snippet loads its tables from.
383UPLOAD_WORDS_PLACEHOLDER = "words.csv"
384UPLOAD_FIXATIONS_PLACEHOLDER = "fixations.csv"
386#: What an added dataset did on its way in that ``load_scanpath_data`` cannot
387#: replay — step code (``source_recipe["steps"]``) → the caveat naming it.
388UPLOAD_STEP_NOTES = {
389 "aggregate_char_boxes": (
390 "Its AOIs were character boxes, which the app joined into word boxes; "
391 "the loader in the snippet does not, so join them in your words table "
392 "first."
393 ),
394 "multipleye_preset": (
395 "It was added with the MultiplEYE files preset, which builds its tables "
396 "from the corpus's own files; the loader in the snippet cannot replay "
397 "that, so its mapping is not written out."
398 ),
399}
402def upload_source(
403 label: str,
404 recipe: dict | None,
405 *,
406 words: bool,
407 fixations: bool,
408) -> SnippetSource:
409 """The data half for a dataset added through the app (Share → Code).
411 ``recipe`` is the dataset's ``source_recipe`` — ``schemas`` (each table's
412 mapping in its own files' column names), ``steps`` (what the add did that
413 the loader cannot, :data:`UPLOAD_STEP_NOTES`), ``derived`` (columns made
414 from the file names) and ``unresolved`` (fields an edit left untraceable).
415 ``words`` / ``fixations`` say which tables the dataset has, so an AOI-only
416 or fixation-only dataset loads only that one. The files themselves stay
417 placeholders: an upload has no path the server could quote.
418 """
419 recipe = recipe if isinstance(recipe, dict) else {}
420 schemas = recipe.get("schemas") if isinstance(recipe.get("schemas"), dict) else {}
421 steps = [str(s) for s in recipe.get("steps") or ()]
422 notes = [
423 "This dataset was added in the app, so the snippet can't name its files: "
424 "replace "
425 + " and ".join(
426 f"`{p}`"
427 for p, present in (
428 (UPLOAD_WORDS_PLACEHOLDER, words),
429 (UPLOAD_FIXATIONS_PLACEHOLDER, fixations),
430 )
431 if present
432 )
433 + " with them (a list of files works for one added from several). "
434 + (
435 "The column mapping it was added with is written out."
436 if "multipleye_preset" not in steps
437 else ""
438 )
439 ]
440 notes += [UPLOAD_STEP_NOTES[s] for s in steps if s in UPLOAD_STEP_NOTES]
441 if derived := [str(c) for c in recipe.get("derived") or ()]:
442 notes.append(
443 "It maps "
444 + ", ".join(f"`{c}`" for c in derived)
445 + ", made from the file names when it was added; the loader has no "
446 "such step, so add "
447 + ("those columns" if len(derived) > 1 else "that column")
448 + " to your tables first."
449 )
450 tables = {"words": "AOI", "fixations": "fixations", "raw_gaze": "raw gaze"}
451 unresolved = [
452 f"`{field}` ({tables.get(table, table)} table)"
453 for table, fields in dict(recipe.get("unresolved") or {}).items()
454 for field in fields or ()
455 ]
456 if unresolved:
457 notes.append(
458 "Its mapping was edited after it was added, and "
459 + ", ".join(unresolved)
460 + " could not be traced back to your files' columns — check "
461 + ("those fields" if len(unresolved) > 1 else "that field")
462 + " before running it."
463 )
464 if "multipleye_preset" in steps:
465 schemas = {}
466 options: dict = {
467 "words": UPLOAD_WORDS_PLACEHOLDER if words else None,
468 "fixations": UPLOAD_FIXATIONS_PLACEHOLDER if fixations else None,
469 }
470 for table, option in (
471 ("words", "word_schema"),
472 ("fixations", "fix_schema"),
473 ("raw_gaze", "raw_gaze_schema"),
474 ):
475 schema = schemas.get(table)
476 if isinstance(schema, dict) and schema:
477 options[option] = _compact_schema(schema)
478 return SnippetSource(
479 kind=SOURCE_UPLOAD,
480 label=label,
481 options=options,
482 note=" ".join(n.strip() for n in notes if n.strip()),
483 )
486def _compact_schema(schema: dict) -> dict:
487 """A mapping without its unmapped fields, so the snippet stays readable.
489 An absent field and an unmapped one load the same — except a reading
490 measure, where naming the key with nothing in it means *absent* while
491 leaving it out lets a column under its usual name through (AN-32), so a
492 cleared measure is kept."""
493 return {
494 str(key): value
495 for key, value in schema.items()
496 if value or str(key).startswith("measure_")
497 }
500def _upload_python(source: SnippetSource) -> list[str]:
501 lines = ["words, fixations = sps.load_scanpath_data("]
502 for name in ("words", "fixations"):
503 if source.options.get(name):
504 lines.append(f" {name}={_py(source.options[name])},")
505 lines += _schema_python(source)
506 lines += _keep_python(source)
507 lines.append(")")
508 return lines
511def _upload_cli(source: SnippetSource) -> list[str]:
512 argv: list[str] = []
513 for name, flag, option, schema_flag in (
514 ("words", "--words", "word_schema", "--word-schema"),
515 ("fixations", "--fixations", "fix_schema", "--fix-schema"),
516 ):
517 if not source.options.get(name):
518 continue
519 argv += [flag, str(source.options[name])]
520 if schema := source.options.get(option):
521 argv += [schema_flag, json.dumps(schema, separators=(",", ":"))]
522 return argv + _keep_cli(source)
525def _raw_gaze_only_python(source: SnippetSource) -> list[str]:
526 return [_raw_gaze_python(source), "words, fixations = None, None"]
529def _raw_gaze_only_cli(source: SnippetSource) -> list[str]:
530 return _raw_gaze_cli(source)
533#: kind → (Python loader lines, CLI input flags). A source whose CLI writer is
534#: ``None`` has no ``render`` flags at all, and the CLI snippet says so rather
535#: than inventing one.
536_SOURCE_WRITERS: dict[str, tuple[Any, Any]] = {
537 SOURCE_DEMO: (_demo_python, _demo_cli),
538 SOURCE_SYNTHETIC: (_synthetic_python, None),
539 SOURCE_FILES: (_files_python, _files_cli),
540 SOURCE_AUTHOR: (_author_python, _author_cli),
541 SOURCE_POTEC: (_potec_python, _potec_cli),
542 SOURCE_ONESTOP: (_onestop_python, _onestop_cli),
543 SOURCE_MULTIPLEYE: (_multipleye_python, _multipleye_cli),
544 SOURCE_BENCHMARK: (_benchmark_python, _benchmark_cli),
545 SOURCE_RAW_GAZE: (_raw_gaze_only_python, _raw_gaze_only_cli),
546 SOURCE_UPLOAD: (_upload_python, _upload_cli),
547 SOURCE_UNKNOWN: (_unknown_python, _unknown_cli),
548}
551# ---------------------------------------------------------------------------
552# The figure half
553# ---------------------------------------------------------------------------
554@dataclass(frozen=True)
555class CompareTarget:
556 """Scanpath B, when the figure on screen is a comparison (CMP-9)."""
558 participant: str = ""
559 trial: str = ""
560 layout: str = "overlay"
561 compare_stimulus: str = "both"
562 dataset: str = ""
563 #: EXP-8 §1. The two trace labels the figure was actually built with —
564 #: `friendly_trial_label`'s output, or UX-31's `cmp{idx}_label_pattern`
565 #: override. They ride here rather than in ``FigureState.settings`` because
566 #: `compare_scanpaths` takes them as a named `labels=` parameter, so
567 #: `api._COMPARISON_FIGURE_PARAMS` subtracts them and the
568 #: `_amend_snippet_settings` merge structurally cannot see them — the same
569 #: reason `layout` and `compare_stimulus` are fields here.
570 labels: tuple[str, str] | None = None
571 #: EXP-21 — only beside a ``dataset``: B's own screen, when it is known
572 #: (``setup_b=`` / ``--compare-canvas``), and the table paths B was read
573 #: from, when there are any to name (``render --print-code`` has them; the
574 #: app's uploads and corpora do not, so the snippet writes placeholders).
575 canvas: tuple[int, int] | None = None
576 words: tuple[str, ...] = ()
577 fixations: tuple[str, ...] = ()
578 #: B's own screen of a multipart trial (``screen_b=`` / ``--compare-screen``),
579 #: picked in B's trial independently of A's. ``None``: B is single-screen.
580 screen: str | None = None
581 #: VIZ-48 — only beside a ``dataset``: B's own raw gaze. ``None`` when B has
582 #: none; the paths it was read from (``render --print-code``), or ``()``
583 #: when it has samples but no path to name (an upload — a placeholder).
584 raw_gaze: tuple[str, ...] | None = None
585 #: VIZ-48: whether A's own dataset has raw gaze to load. ``False`` only
586 #: when B's dataset alone brings samples, so the recipe loads B's and not a
587 #: placeholder for A's.
588 primary_raw_gaze: bool = True
591@dataclass(frozen=True)
592class FigureState:
593 """Everything a snippet needs about the figure that is on screen.
595 ``settings`` is the ``tabs._build_figure_settings`` dict — the same mapping
596 the builders consume — so the snippet is a serializer over the figure's own
597 input rather than a second reading of the widgets.
598 """
600 kind: str = "static"
601 settings: dict = field(default_factory=dict)
602 participant: str = ""
603 trial: str = ""
604 screen: str | None = None
605 canvas: tuple[int, int] | None = None
606 base_font_size: int = 16
607 font_family: str = ""
608 title: str = ""
609 caption: str = ""
610 fix_index_range: tuple[int, int] | None = None
611 #: CMP-24 — scanpath B's own window (``compare_scanpaths`` /
612 #: ``animate_scanpath``'s ``fix_index_range_b``); only beside a ``compare``.
613 fix_index_range_b: tuple[int, int] | None = None
614 illustration_label: str = "auto"
615 drift_correction: str | None = None
616 drift_connectors: bool = False
617 playback_speed: float = 1.0
618 autoplay: bool = True
619 compare: CompareTarget | None = None
621 def __post_init__(self) -> None:
622 if self.kind not in KINDS:
623 raise ValueError(f"kind must be one of {KINDS}, got {self.kind!r}.")
626def _comparable(value):
627 """Normalize a setting for equality against its default.
629 A tuple and a list of the same numbers are the same figure — the rail
630 produces one and the builder's signature the other — so a naive ``!=``
631 would report half the marker-size ranges in the app as non-default."""
632 if isinstance(value, (list, tuple)):
633 return tuple(_comparable(item) for item in value)
634 if isinstance(value, dict):
635 return tuple(sorted((str(k), _comparable(v)) for k, v in value.items()))
636 if isinstance(value, bool):
637 return value
638 if isinstance(value, (int, float)):
639 return float(value)
640 return value
643#: Figure keywords that are *derived*, not chosen — the app fills them in from
644#: something else it already decided, so re-emitting them would be noise at best
645#: and wrong at worst. ``connector_y`` is the extreme case: a tuple with one
646#: float per fixation, which would bury a snippet in numbers that
647#: ``drift_connectors=True`` recomputes anyway.
648_DERIVED_SETTINGS = frozenset(
649 {
650 "connector_y",
651 "show_connectors",
652 "illustration_reasons",
653 # Needs a third frame (`raw_gaze=`), not a keyword: the layer is drawn
654 # for the frame it is handed, so both snippets load that table instead
655 # (`draws_raw_gaze`) — `show_raw_gaze=True` alone would draw nothing.
656 "show_raw_gaze",
657 }
658)
660#: EXP-8 §4. Of the derived settings above, the ones whose *value* scales with
661#: the trial rather than being a scalar choice. ``_DERIVED_SETTINGS`` keeps them
662#: out of the snippet's text; this keeps them out of the published
663#: :class:`FigureState` as well, because `tabs._amend_snippet_settings` merges
664#: every builder keyword it recognizes and would otherwise park one float per
665#: fixation in session state on every rerun — for a key that is guaranteed
666#: never to be emitted. The rest of ``_DERIVED_SETTINGS`` stays in the state:
667#: `show_raw_gaze` is read by :func:`state_caveats`, and the others are scalars.
668UNPUBLISHED_SETTINGS = frozenset({"connector_y"})
670#: A stimulus image the user uploaded lives in the figure as a ``data:`` URI —
671#: a megabyte of base64 that would swamp the snippet and mean nothing on
672#: another machine. It becomes a placeholder path plus a caveat.
673_IMAGE_PLACEHOLDER = "stimulus.png"
676def _is_data_uri(value) -> bool:
677 return isinstance(value, str) and value.startswith("data:")
680def figure_kwargs(
681 settings: dict, kind: str = "static", *, explicit: bool = False
682) -> dict:
683 """The figure keywords a snippet has to pass, in ``api`` vocabulary.
685 Keys the chosen builder doesn't accept are dropped (the rail's dict carries
686 a few, e.g. ``title_pattern``, that are not figure keywords at all), and so
687 are keys already at their effective default — unless ``explicit``, which
688 emits the full form. Ordered as ``figure_options`` lists them, so two
689 snippets from neighbouring states read as neighbours."""
690 from . import api
692 defaults = api.figure_options(kind)
693 out = {}
694 for key, default in defaults.items():
695 if key not in settings:
696 continue
697 if key in _DERIVED_SETTINGS:
698 continue
699 if not explicit and _inert(key, settings):
700 continue # #374 F29: styles nothing that is drawn
701 if key == "heatmap_range" and _heatmap_self_scaled(settings, kind):
702 continue # kept for Word boxes, but it pins nothing here
703 value = settings[key]
704 if key == "fixation_flags_b" and _same_flags(
705 value, settings.get("fixation_flags")
706 ):
707 continue # CMP-24: B inheriting A's flags is the builder's default
708 if key in _COMPARE_STYLE_SIDES:
709 value = _drop_inherited_filters(value, settings)
710 # The rail always builds a complete style dict, so the one an
711 # untouched Compare carries is not a choice anyone made — only
712 # what it changes is (EXP-20).
713 value = compare_style_delta(
714 value,
715 _COMPARE_STYLE_SIDES[key],
716 settings.get("marker_size_range", defaults.get("marker_size_range")),
717 )
718 # A frame-valued option (`words_b`) is data, not a setting: its repr is
719 # meaningless in another process. `state_caveats` names it instead.
720 if hasattr(value, "to_dict") and hasattr(value, "columns"):
721 continue
722 if _is_data_uri(value):
723 value = _IMAGE_PLACEHOLDER
724 if explicit or _comparable(value) != _comparable(default):
725 out[key] = value
726 return out
729#: #374 F29: options that style one layer → the switch that draws it. With
730#: the layer off they change nothing, so a snippet leaves them out.
731_LAYER_OPTIONS = {
732 "show_words": (
733 "word_box_color",
734 "word_box_line_opacity",
735 "word_box_fill_color",
736 "word_box_fill_opacity",
737 ),
738 "show_order": ("order_font_size", "order_font_color"),
739 "show_heatmap": (
740 "heatmap_metric",
741 "heatmap_style",
742 "heatmap_norm",
743 "heatmap_sigma_px",
744 "heatmap_range",
745 "heatmap_colorscale",
746 "show_heatmap_colorbar",
747 "heatmap_colorbar_orientation",
748 "heatmap_colorbar_tickangle",
749 "heatmap_colorbar_tickfont_size",
750 ),
751 "show_saccades": (
752 "saccade_color",
753 "saccade_style",
754 "saccade_width",
755 "saccade_color_mode",
756 "saccade_class_colors",
757 "saccade_type_legend",
758 "show_saccade_arrows",
759 ),
760}
761_STYLED_BY = {
762 option: layer for layer, opts in _LAYER_OPTIONS.items() for option in opts
763}
764_FIXATION_SCALE_OPTIONS = frozenset(
765 {
766 "fixation_colorscale",
767 "fixation_color_range",
768 "show_fixation_colorbar",
769 "fixation_colorbar_orientation",
770 "fixation_colorbar_tickangle",
771 "fixation_colorbar_tickfont_size",
772 }
773)
776def _inert(key: str, settings: dict) -> bool:
777 """Whether ``key`` changes nothing on this figure: it styles a layer that
778 is off, a highlight with no highlight column, class colors in *Uniform*,
779 or a color scale on uniformly colored fixations."""
780 from .constants import UNIFORM_COLOR_FIELD
782 layer = _STYLED_BY.get(key)
783 if layer is not None and settings.get(layer, True) is False:
784 return True
785 if key == "saccade_class_colors":
786 return not _classes_coloured(settings)
787 if key in ("critical_span_style", "highlight_text_color", "span_border_color"):
788 if not settings.get("highlight_column", "x"):
789 return True
790 style = settings.get("critical_span_style")
791 if key == "highlight_text_color":
792 return style not in (None, "Mark text")
793 if key == "span_border_color":
794 return style not in (None, "Mark border")
795 if key in _FIXATION_SCALE_OPTIONS:
796 return settings.get("color_by", "x") in (
797 None,
798 "",
799 UNIFORM_COLOR_FIELD,
800 ) and not settings.get("color_by_line")
801 return False
804def _heatmap_self_scaled(settings: dict, kind: str) -> bool:
805 """Whether the figure's heatmap ignores ``heatmap_range``: a smoothed style,
806 which scales to its own peak, outside a comparison (always word boxes)."""
807 from .constants import SELF_SCALED_HEATMAP_STYLES
809 return (
810 kind != "comparison"
811 and settings.get("heatmap_style") in SELF_SCALED_HEATMAP_STYLES
812 )
815#: The per-scanpath style options → which scanpath each styles.
816_COMPARE_STYLE_SIDES = {"style_a": 0, "style_b": 1}
819def _active_flags(flags) -> dict:
820 """The part of a fixation-flags dict that draws anything: each category not
821 *Off*, with the threshold short/long use. Two dicts with the same active
822 part filter identically (CMP-24)."""
823 out = {}
824 for category, spec in (flags or {}).items():
825 if not isinstance(spec, dict) or str(spec.get("mode") or "Off") == "Off":
826 continue
827 entry = {"mode": spec.get("mode")}
828 if category in ("short", "long") and spec.get("threshold_ms") is not None:
829 entry["threshold_ms"] = float(spec["threshold_ms"])
830 out[category] = entry
831 return out
834def _same_flags(a, b) -> bool:
835 return _active_flags(a) == _active_flags(b)
838def _visible_classes(classes) -> frozenset | None:
839 from .constants import SACCADE_CLASS_ORDER
841 if not classes or set(classes) >= set(SACCADE_CLASS_ORDER):
842 return None
843 return frozenset(classes)
846def _drop_inherited_filters(style, settings: dict):
847 """A per-scanpath style without the filters it merely repeats (CMP-24).
849 The rail hands B its whole filter set every run; where it equals the
850 figure's own ``fixation_flags`` / ``saccade_classes`` the builder draws B the
851 same without it, so a snippet should not restate it."""
852 if not isinstance(style, dict):
853 return style
854 style = dict(style)
855 if "fixation_flags" in style and _same_flags(
856 style["fixation_flags"], settings.get("fixation_flags")
857 ):
858 style.pop("fixation_flags")
859 if "saccade_classes" in style and _visible_classes(
860 style["saccade_classes"]
861 ) == _visible_classes(settings.get("saccade_classes")):
862 style.pop("saccade_classes")
863 return style
866def compare_style_delta(style, idx: int, marker_size_range) -> dict | None:
867 """What a per-scanpath style changes, or ``None`` when it changes nothing.
869 Measured against the style the comparison builder would draw *without* it
870 (`plots._comparison_scanpath_style`), whose marker range is the figure's own
871 ``marker_size_range`` — so a scanpath at the stock range under a changed
872 global one still says so. Values the builder drops (``None`` / ``""`` /
873 ``False``) are dropped here too, which is what keeps the reduced dict
874 drawing exactly the same figure as the full one."""
875 if not isinstance(style, dict) or not style:
876 return None
877 from .plots import _comparison_scanpath_style
879 msr = tuple(marker_size_range) if marker_size_range else None
880 kwargs = {} if msr is None else {"default_marker_size_range": msr}
881 base = _comparison_scanpath_style(idx, None, **kwargs)
882 drawn = _comparison_scanpath_style(idx, style, **kwargs)
883 delta = {
884 key: drawn[key]
885 for key in drawn
886 if _comparable(drawn[key]) != _comparable(base.get(key))
887 }
888 return delta or None
891# ---------------------------------------------------------------------------
892# CLI flag emitters — the `render` subset of the figure keywords
893# ---------------------------------------------------------------------------
894def _flag_when(flag: str, wanted) -> Any:
895 """A bare flag emitted only when the setting equals ``wanted``."""
897 def emit(value):
898 return [flag] if _comparable(value) == _comparable(wanted) else []
900 return emit
903def _switch(on: str, off: str) -> Any:
904 """A layer with a flag each way: ``on`` when drawn, ``off`` when not."""
906 def emit(value):
907 return [on] if value else [off]
909 return emit
912def _valued(flag: str) -> Any:
913 def emit(value):
914 return [] if value is None else [flag, str(value)]
916 return emit
919def _int_valued(flag: str) -> Any:
920 """A ``type=int`` flag: ``12.0`` from a settings dict would be refused."""
922 def emit(value):
923 return [] if value is None else [flag, str(int(value))]
925 return emit
928def _optional_valued(flag: str) -> Any:
929 """A flag whose ``None`` is a real choice, spelled as an empty value — the
930 ``--highlight-column ''`` rule, not the absence of the flag."""
932 def emit(value):
933 return [flag, "" if value is None else str(value)]
935 return emit
938def _mapped(flag: str, table: dict) -> Any:
939 """A flag whose CLI vocabulary differs from the settings vocabulary."""
941 def emit(value):
942 token = table.get(value)
943 return [] if token is None else [flag, token]
945 return emit
948def _comma_list(flag: str) -> Any:
949 def emit(value):
950 if not value:
951 return []
952 return [flag, ",".join(str(item) for item in value)]
954 return emit
957def _highlight_column(value):
958 """``--highlight-column``. ``None`` is the real request "highlight nothing",
959 which the flag spells as an empty string — not the absence of the flag,
960 which would leave the default (`is_in_aspan`) in place."""
961 return ["--highlight-column", "" if value is None else str(value)]
964def _fixation_flags(value, flag: str = "--fixation-flag"):
965 """The PRE-2 classification dict → one ``--fixation-flag`` per category.
967 Only categories that are actually doing something are written: an *Off*
968 category is the default, and the builder reads a missing one the same way.
969 ``flag`` is ``--compare-fixation-flag`` for scanpath B's own (CMP-24).
970 """
971 argv: list[str] = []
972 for category, spec in (value or {}).items():
973 if not isinstance(spec, dict):
974 continue
975 mode = str(spec.get("mode") or "Off")
976 if mode == "Off":
977 continue
978 parts = [f"{category}={mode.lower()}"]
979 # Only for the two categories that have one — `oob` / `blink` carry a
980 # stale default threshold in the app's dict that the CLI would reject.
981 if category in ("short", "long") and spec.get("threshold_ms") is not None:
982 parts.append(f"threshold_ms={_num(spec['threshold_ms'])}")
983 if spec.get("symbol"):
984 parts.append(f"symbol={spec['symbol']}")
985 if spec.get("color"):
986 parts.append(f"color={spec['color']}")
987 argv += [flag, ",".join(parts)]
988 return argv
991def _legend_layout(value):
992 """The legend placements → one ``--legend`` per legend that was moved.
994 A legend left on *auto* throughout is the default and is not written.
995 """
996 from .plots import _legend_is_moved, legend_spec_text, normalize_legend_layout
998 argv: list[str] = []
999 for kind, spec in normalize_legend_layout(value).items():
1000 if _legend_is_moved(spec):
1001 argv += ["--legend", f"{kind.replace('_', '-')}={legend_spec_text(spec)}"]
1002 return argv
1005def _compare_fixation_flags(value):
1006 """B's flags (CMP-24). An all-*Off* set still has to be said when A's are
1007 on, or B would inherit them — so it is spelled as one explicit *off*."""
1008 argv = _fixation_flags(value, "--compare-fixation-flag")
1009 return argv or ["--compare-fixation-flag", "short=off"]
1012def _heatmap_metric(value):
1013 # The figure level spells "counts" as None (`_build_figure_settings` and
1014 # `api._figure_kwargs` both translate), so the CLI word has to be put back.
1015 return ["--heatmap-metric", "counts" if value is None else str(value)]
1018def _saccade_color_mode(value):
1019 if value == "By type":
1020 return ["--saccade-color-by-type"]
1021 if value == "Forward / regression":
1022 return ["--saccade-color-by-direction"]
1023 return []
1026def _saccade_class_colors(value, baseline: dict | None = None):
1027 """One ``--saccade-type-color`` per class color that differs from
1028 ``baseline`` — the stock classes, or the colors a named ``--palette`` has
1029 just written (so a stock color it moved is moved back)."""
1030 if not isinstance(value, dict):
1031 return []
1032 from .constants import SACCADE_CLASS_COLORS, SACCADE_CLASS_EDITABLE
1034 reference = baseline if isinstance(baseline, dict) else SACCADE_CLASS_COLORS
1035 argv = []
1036 for name in SACCADE_CLASS_EDITABLE:
1037 color = value.get(name)
1038 if color and str(color).lower() != str(reference.get(name, "")).lower():
1039 argv += ["--saccade-type-color", f"{name}={color}"]
1040 return argv
1043def _effective_color(key: str, value):
1044 """``saccade_class_colors=None`` means the stock class colors, so compare
1045 it as those — otherwise the default palette would read as a change."""
1046 if key == "saccade_class_colors" and value is None:
1047 from .constants import SACCADE_CLASS_COLORS
1049 return dict(SACCADE_CLASS_COLORS)
1050 return value
1053def _palette_colors(name: str) -> dict:
1054 """The figure keywords palette ``name`` writes, as `api` expands them."""
1055 from . import api
1057 return api._expand_palette({"palette": name})
1060def _classes_coloured(settings: dict) -> bool:
1061 """Whether the reading-class colors are drawn at all. In *Uniform* they are
1062 not, so they can be neither a reason to name a palette nor a flag to emit."""
1063 return settings.get("saccade_color_mode") in ("By type", "Forward / regression")
1066def _matching_palette(settings: dict, kind: str) -> tuple[str | None, dict]:
1067 """The ``--palette`` a CLI snippet can name instead of spelling it out.
1069 EXP-12. A palette writes eight colors at once. Spelled key by key, three of
1070 them (`text_color`, `highlight_text_color`, `background_color`) have no
1071 `render` flag and were named unsupported, and the class colors became five
1072 ``--saccade-type-color`` flags — which switch saccades to *By type*, so any
1073 palette choice produced a command drawing a different figure. A palette
1074 matches when every color it writes that the figure draws equals the
1075 figure's (#374 F29: a palette whose colors later flags take back read as
1076 if the screen used it, while its Palette box said *Custom*); one that
1077 explains none (the default palette on a stock figure) is not named at all.
1078 Of those, the one that saves the most flags is named.
1080 Returns ``(name, colors)`` — the colors the named palette supplies — or
1081 ``(None, {})``."""
1082 from . import api
1083 from .constants import PALETTES
1085 defaults = api.figure_options(kind)
1086 best: tuple[str | None, dict] = (None, {})
1087 best_score = 0
1088 for name in PALETTES:
1089 colors = {k: v for k, v in _palette_colors(name).items() if k in defaults}
1090 score = 0
1091 for key, value in colors.items():
1092 if _inert(key, settings):
1093 continue
1094 current = _effective_color(key, settings.get(key, defaults[key]))
1095 default = _effective_color(key, defaults[key])
1096 if _comparable(current) == _comparable(value):
1097 score += int(_comparable(current) != _comparable(default))
1098 else:
1099 # #374 F29: a palette is named only when it is the figure's —
1100 # never one whose colours a later flag has to take back.
1101 break
1102 else:
1103 if score > best_score:
1104 best, best_score = (name, colors), score
1105 return best
1108def _restate_against_palette(
1109 kwargs: dict, settings: dict, kind: str, palette_colors: dict
1110) -> dict:
1111 """The figure keywords to write after ``--palette``: the colors it got right
1112 dropped, and every one it got wrong restated — a color still at its default
1113 included, which `figure_kwargs` would not otherwise write (EXP-20)."""
1114 from . import api
1116 defaults = api.figure_options(kind)
1117 out = dict(kwargs)
1118 for key, value in palette_colors.items():
1119 if _inert(key, settings):
1120 continue
1121 current = _effective_color(key, settings.get(key, defaults.get(key)))
1122 if _comparable(current) == _comparable(value):
1123 out.pop(key, None)
1124 else:
1125 out[key] = current
1126 return out
1129def _marker_size_range(value):
1130 if not value:
1131 return []
1132 lo, hi = value
1133 return ["--marker-size-range", str(int(lo)), str(int(hi))]
1136def _pair(flag: str, sep: str) -> Any:
1137 def emit(value):
1138 if not value:
1139 return []
1140 first, second = value
1141 text = f"{_num(first)}{sep}{_num(second)}"
1142 # A leading minus reads to argparse as another flag (`-5,3` is not a
1143 # negative *number*), so a negative origin is glued to its flag.
1144 return [f"{flag}={text}"] if text.startswith("-") else [flag, text]
1146 return emit
1149def _two_numbers(flag: str) -> Any:
1150 """A ``nargs=2`` flag (`--fixation-color-range LO HI`)."""
1152 def emit(value):
1153 if not value:
1154 return []
1155 lo, hi = value
1156 return [flag, _num(lo), _num(hi)]
1158 return emit
1161def _lowercase(flag: str) -> Any:
1162 """A choice the CLI spells in lower case (`--compare-stimulus b`)."""
1164 def emit(value):
1165 return [] if value is None else [flag, str(value).lower()]
1167 return emit
1170#: The order `--style-a` / `--style-b` write their keys in — `cli._STYLE_KEYS`.
1171_STYLE_SPEC_KEYS = (
1172 "fix_color",
1173 "saccade_color",
1174 "box_color",
1175 "box_fill_color",
1176 "raw_gaze_color",
1177 "heatmap_colorscale",
1178 "saccade_style",
1179 "saccade_width",
1180 "marker_size_range",
1181 "opacity",
1182 "hollow",
1183)
1186def _style_spec(flag: str) -> Any:
1187 """A per-scanpath style (already reduced to what it changes) → one
1188 ``--style-a KEY=VALUE,…`` — the spec `cli._parse_style_spec` reads back."""
1190 def emit(value):
1191 if not isinstance(value, dict) or not value:
1192 return []
1193 filters: list[str] = []
1194 if flag == "--style-b":
1195 # CMP-24: B's filters have flags of their own, not style keys.
1196 if "fixation_flags" in value:
1197 filters += _compare_fixation_flags(value["fixation_flags"])
1198 if "saccade_classes" in value:
1199 filters += _comma_list("--compare-saccade-classes")(
1200 value["saccade_classes"]
1201 )
1202 parts = []
1203 for key in _STYLE_SPEC_KEYS:
1204 if key not in value:
1205 continue
1206 item = value[key]
1207 if key == "marker_size_range":
1208 lo, hi = item
1209 text = f"{int(lo)}:{int(hi)}"
1210 elif key == "hollow":
1211 text = "true" if item else "false"
1212 elif isinstance(item, (int, float)):
1213 text = _num(item)
1214 else:
1215 text = str(item)
1216 parts.append(f"{key}={text}")
1217 return ([flag, ",".join(parts)] if parts else []) + filters
1219 return emit
1222def _num(value) -> str:
1223 """Render a number without a pointless trailing ``.0`` (``1310`` not ``1310.0``)."""
1224 number = float(value)
1225 return str(int(number)) if number.is_integer() else str(number)
1228#: figure-setting key → the ``render`` argv it becomes. A key **absent** from
1229#: this table has no CLI flag; :func:`cli_snippet` reports those rather than
1230#: dropping them, which is what keeps the four-surface rule honest here.
1231_CLI_EMITTERS: dict[str, Any] = {
1232 # EXP-8 §1. CMP-11's co-animation names its two trace labels as loose
1233 # keywords, so the animation half rides the table; the comparison half is
1234 # written by hand in `cli_snippet`, because `compare_scanpaths` takes them
1235 # as one `labels=` pair rather than as figure options. Same two flags.
1236 "label_a": _valued("--label-a"),
1237 "label_b": _valued("--label-b"),
1238 "show_words": _switch("--word-boxes", "--no-word-boxes"),
1239 "show_word_labels": _flag_when("--no-text", False),
1240 "show_fixations": _flag_when("--no-fixations", False),
1241 "show_order": _switch("--fixation-index", "--no-fixation-index"),
1242 "show_saccades": _flag_when("--no-saccades", False),
1243 "show_heatmap": _switch("--heatmap", "--no-heatmap"),
1244 "show_saccade_arrows": _flag_when("--saccade-arrows", True),
1245 "show_coordinate_grid": _flag_when("--coordinate-grid", True),
1246 "coordinate_grid_spacing": _valued("--coordinate-grid-spacing"),
1247 "word_hover_fields": _comma_list("--word-hover-fields"),
1248 "fixation_hover_fields": _comma_list("--fixation-hover-fields"),
1249 "color_by": _valued("--color-by"),
1250 "fixation_color": _valued("--fixation-color"),
1251 "fixation_symbol": _valued("--fixation-symbol"),
1252 "fixation_colorscale": _valued("--fixation-colorscale"),
1253 "heatmap_metric": _heatmap_metric,
1254 "heatmap_colorscale": _valued("--heatmap-colorscale"),
1255 "heatmap_style": _mapped(
1256 "--heatmap-style",
1257 {
1258 "Word boxes": "word-boxes",
1259 "Interpolated": "interpolated",
1260 },
1261 ),
1262 "heatmap_norm": _mapped("--heatmap-norm", {"Linear": "linear", "Log": "log"}),
1263 "highlight_column": _highlight_column,
1264 "critical_span_style": _mapped(
1265 "--critical-span-style",
1266 {"Mark text": "mark-text", "Mark border": "mark-border", "None": "none"},
1267 ),
1268 "fixation_flags": _fixation_flags,
1269 "legend_layout": _legend_layout,
1270 "heatmap_sigma_px": _valued("--heatmap-sigma"),
1271 "marker_size_range": _marker_size_range,
1272 "marker_size_scale": _valued("--marker-size-scale"),
1273 "marker_duration_range": _two_numbers("--marker-duration-range"),
1274 "duration_size_legend": _flag_when("--no-duration-size-legend", False),
1275 "saccade_color": _valued("--saccade-color"),
1276 "saccade_style": _valued("--saccade-style"),
1277 "saccade_width": _valued("--saccade-width"),
1278 "saccade_color_mode": _saccade_color_mode,
1279 "saccade_class_colors": _saccade_class_colors,
1280 "saccade_type_legend": _flag_when("--no-saccade-type-legend", False),
1281 "saccade_classes": _comma_list("--saccade-classes"),
1282 "saccade_render_mode": _flag_when("--saccade-arcs", "Arc"),
1283 "fixation_snap_to_word": _flag_when("--snap-fixations", True),
1284 "background_image": _valued("--stimulus-image"),
1285 "background_image_size": _pair("--stimulus-image-size", "x"),
1286 "background_image_origin": _pair("--stimulus-image-origin", ","),
1287 "background_image_opacity": _valued("--stimulus-image-opacity"),
1288 "anim_grid_step_ms": _valued("--anim-grid-step-ms"),
1289 "anim_max_frames": _int_valued("--anim-max-frames"),
1290 # EXP-20 — every figure option `render` could not say before. Each flag is
1291 # spelled after its option; `tests/test_code_snippet.py` fails on an option
1292 # with no row here, so a new one cannot quietly fall back to being "named".
1293 "fixation_opacity": _valued("--fixation-opacity"),
1294 "hollow_fixations": _flag_when("--hollow-fixations", True),
1295 "color_by_line": _flag_when("--color-by-line", True),
1296 "fixation_color_range": _two_numbers("--fixation-color-range"),
1297 "heatmap_range": _two_numbers("--heatmap-range"),
1298 "order_font_size": _int_valued("--order-font-size"),
1299 "order_font_color": _valued("--order-font-color"),
1300 "text_color": _valued("--text-color"),
1301 "highlight_text_color": _valued("--highlight-text-color"),
1302 "span_border_color": _valued("--span-border-color"),
1303 "background_color": _valued("--background-color"),
1304 "line_spacing": _valued("--line-spacing"),
1305 "scale_text_to_boxes": _flag_when("--no-scale-text-to-boxes", False),
1306 "word_hover_measure": _optional_valued("--word-hover-measure"),
1307 "x_field": _valued("--x-field"),
1308 "y_field": _valued("--y-field"),
1309 # Was `_CLI_IMPLICIT` ("fitting to the canvas is what `--canvas` means") —
1310 # true only while the figure *was* fitted; one that wasn't reproduced
1311 # framed on the monitor anyway.
1312 "fit_to_monitor": _flag_when("--no-full-monitor", False),
1313 **{
1314 key: emitter
1315 for bar in ("fixation", "heatmap")
1316 for key, emitter in (
1317 (f"show_{bar}_colorbar", _flag_when(f"--no-{bar}-colorbar", False)),
1318 (
1319 f"{bar}_colorbar_orientation",
1320 _mapped(
1321 f"--{bar}-colorbar-orientation",
1322 {"Vertical": "vertical", "Horizontal": "horizontal"},
1323 ),
1324 ),
1325 (
1326 f"{bar}_colorbar_tickangle",
1327 _int_valued(f"--{bar}-colorbar-tickangle"),
1328 ),
1329 (
1330 f"{bar}_colorbar_tickfont_size",
1331 _int_valued(f"--{bar}-colorbar-tickfont-size"),
1332 ),
1333 )
1334 },
1335 "illustration_text": _valued("--illustration-text"),
1336 "word_box_color": _valued("--word-box-color"),
1337 "word_box_line_opacity": _valued("--word-box-line-opacity"),
1338 "word_box_fill_color": _valued("--word-box-fill-color"),
1339 "word_box_fill_opacity": _valued("--word-box-fill-opacity"),
1340 "raw_gaze_color": _valued("--raw-gaze-color"),
1341 "raw_gaze_marker_size": _valued("--raw-gaze-marker-size"),
1342 "raw_gaze_opacity": _valued("--raw-gaze-opacity"),
1343 "word_heatmap_col": _valued("--word-heatmap-col"),
1344 "word_heatmap_title": _valued("--word-heatmap-title"),
1345 # The comparison's (and the co-animation's) own options.
1346 "show_legend": _flag_when("--no-compare-legend", False),
1347 "compare_stimulus": _lowercase("--compare-stimulus"),
1348 "style_a": _style_spec("--style-a"),
1349 "style_b": _style_spec("--style-b"),
1350 # CMP-24 — the co-animation's B flags.
1351 "fixation_flags_b": _compare_fixation_flags,
1352 "background_image_b": _valued("--stimulus-image-b"),
1353 "background_image_size_b": _pair("--stimulus-image-size-b", "x"),
1354 "background_image_origin_b": _pair("--stimulus-image-origin-b", ","),
1355}
1357#: Options that only mean something beside a second scanpath, and whose `render`
1358#: flag is refused without `--compare-with`. A *single* replay carries them too —
1359#: the animation builder takes them and ignores them — so there they are left
1360#: off the command rather than written into one `render` would reject.
1361_COMPARE_ONLY_SETTINGS = frozenset(
1362 {
1363 "show_legend",
1364 "label_a",
1365 "label_b",
1366 "compare_stimulus",
1367 "fixation_flags_b",
1368 "style_a",
1369 "style_b",
1370 }
1371)
1374# ---------------------------------------------------------------------------
1375# Emitters
1376# ---------------------------------------------------------------------------
1377def _py(value) -> str:
1378 """A Python literal for a settings value.
1380 ``repr`` is right for everything the settings dict holds (strings, numbers,
1381 bools, tuples, lists, dicts of those) — the one thing worth normalizing is a
1382 tuple, which reads better than a list for a fixed-arity pair."""
1383 if isinstance(value, tuple):
1384 inner = ", ".join(_py(item) for item in value)
1385 return f"({inner})" if len(value) != 1 else f"({inner},)"
1386 if isinstance(value, list):
1387 return "[" + ", ".join(_py(item) for item in value) + "]"
1388 if isinstance(value, dict):
1389 return "{" + ", ".join(f"{_py(k)}: {_py(v)}" for k, v in value.items()) + "}"
1390 return repr(value)
1393def _one_or_list(paths) -> Any:
1394 """One path stays a string; several stay a list — both are accepted."""
1395 items = [str(p) for p in paths]
1396 return items[0] if len(items) == 1 else items
1399def _names_screen_b(state: FigureState) -> bool:
1400 """Whether the recipe names B's screen: only beside a B it draws."""
1401 return (
1402 state.kind in ("comparison", "animation")
1403 and state.compare is not None
1404 and bool(state.compare.screen)
1405 and bool(state.compare.trial)
1406 )
1409def _call_kwargs(state: FigureState, *, explicit: bool) -> list[tuple[str, Any]]:
1410 """The named (non-figure-keyword) arguments the API call carries.
1412 These are parameters of ``plot_scanpath`` / ``animate_scanpath`` /
1413 ``compare_scanpaths`` rather than loose figure keywords, so they are written
1414 by hand rather than diffed — each one is emitted only when it is doing
1415 something, which is the same "only the non-defaults" rule by another route.
1416 """
1417 out: list[tuple[str, Any]] = []
1418 # Each scanpath of a comparison or co-animation names its own screen, as the
1419 # app's two screen navigators pick them.
1420 if state.screen:
1421 out.append(("screen", str(state.screen)))
1422 if _names_screen_b(state):
1423 out.append(("screen_b", str(state.compare.screen)))
1424 if state.canvas:
1425 out.append(("canvas_size", (int(state.canvas[0]), int(state.canvas[1]))))
1426 if explicit or state.base_font_size != 16:
1427 out.append(("base_font_size", int(state.base_font_size)))
1428 if state.font_family and (explicit or _non_default_font(state.font_family)):
1429 out.append(("font_family", str(state.font_family)))
1430 if state.kind == "animation":
1431 if explicit or state.playback_speed != 1.0:
1432 out.append(("playback_speed", float(state.playback_speed)))
1433 if explicit or not state.autoplay:
1434 out.append(("autoplay", bool(state.autoplay)))
1435 else:
1436 if state.fix_index_range:
1437 lo, hi = state.fix_index_range
1438 out.append(("fix_index_range", (int(lo), int(hi))))
1439 if state.drift_correction:
1440 # The rail's own spelling is Title-case ("Warp"). `plot_scanpath`
1441 # lowercases internally but `compare_scanpaths` hands the string
1442 # straight to `alignment.correct`, so an un-lowered snippet *raises*
1443 # there. The CLI validator lowercases too — match it.
1444 out.append(("drift_correction", str(state.drift_correction).lower()))
1445 # Connectors are the static builder's alone (the original→corrected
1446 # layer has no comparison equivalent), so `compare_scanpaths` takes
1447 # no such keyword — see CLAUDE.md's render-path table.
1448 if state.drift_connectors and state.kind == "static":
1449 out.append(("drift_connectors", True))
1450 # CMP-24 — B's own window, on both builders that draw a B.
1451 if state.fix_index_range_b and state.compare is not None:
1452 lo, hi = state.fix_index_range_b
1453 out.append(("fix_index_range_b", (int(lo), int(hi))))
1454 # The disclosure is a rail choice, not a derived value: `illustration_reasons`
1455 # (what the app resolved it to) is in `_DERIVED_SETTINGS`, so without the
1456 # *label mode* the snippet would silently re-derive at "auto" and disagree
1457 # with the figure on screen. `compare_scanpaths` has no such parameter —
1458 # `state_caveats` says so rather than passing a keyword it would reject.
1459 if state.kind != "comparison" and (
1460 explicit or str(state.illustration_label).lower() != "auto"
1461 ):
1462 out.append(("illustration_label", str(state.illustration_label).lower()))
1463 if state.title:
1464 out.append(("title", str(state.title)))
1465 if state.caption:
1466 out.append(("caption", str(state.caption)))
1467 return out
1470def _non_default_font(name: str) -> bool:
1471 from .constants import FONT_FAMILY
1473 return str(name) != FONT_FAMILY
1476#: What a snippet names for a raw-gaze table it can't name — one that was
1477#: uploaded into the app, like `_IMAGE_PLACEHOLDER` for an uploaded image.
1478_RAW_GAZE_PLACEHOLDER = "raw_gaze.csv"
1481def draws_raw_gaze(state: FigureState) -> bool:
1482 """Whether the figure on screen draws a raw-gaze layer (EXP-20).
1484 The single-trial and comparison builders have one (`raw_gaze=` is a frame
1485 of `plot_scanpath` and, since VIZ-48, `compare_scanpaths`), so a replay that
1486 carries the switch draws none, and its snippet has nothing to load."""
1487 return state.kind in {"static", "comparison"} and bool(
1488 state.settings.get("show_raw_gaze")
1489 )
1492def _draws_primary_raw_gaze(state: FigureState) -> bool:
1493 """Whether A's dataset's samples are loaded — every drawn layer except a
1494 comparison whose samples all come from B's dataset (VIZ-48)."""
1495 return draws_raw_gaze(state) and (
1496 state.kind != "comparison"
1497 or state.compare is None
1498 or state.compare.primary_raw_gaze
1499 )
1502#: What a snippet names for B's raw gaze when it can't name the file (VIZ-48).
1503B_RAW_GAZE_PLACEHOLDER = "B_RAW_GAZE"
1506def _second_raw_gaze(state: FigureState) -> list[str] | None:
1507 """B's raw-gaze paths when the comparison draws a second dataset's samples
1508 (VIZ-48), its placeholder when they can't be named, else ``None``."""
1509 other = second_dataset(state)
1510 if other is None or other.raw_gaze is None or not draws_raw_gaze(state):
1511 return None
1512 return list(other.raw_gaze) or [B_RAW_GAZE_PLACEHOLDER]
1515def _passes_raw_gaze(source: SnippetSource, state: FigureState) -> bool:
1516 """Whether the builder call names ``raw_gaze=raw_gaze``.
1518 Wherever the layer is drawn — and always for a raw-gaze-only source
1519 (VIZ-45), whose samples are the data the trial is looked up in."""
1520 return _draws_primary_raw_gaze(state) or (
1521 source.kind == SOURCE_RAW_GAZE and state.kind == "static"
1522 )
1525def _raw_gaze_layer_off(source: SnippetSource, state: FigureState) -> bool:
1526 """A raw-gaze-only figure with the layer switched off (VIZ-45).
1528 `plot_scanpath` turns the layer on for the frame it is handed, and on a
1529 samples-only source the frame is always handed (it is the data), so *off*
1530 has to be written out — ``show_raw_gaze=False`` / ``--no-raw-gaze`` — or
1531 the recipe would draw the samples the figure on screen does not."""
1532 return (
1533 source.kind == SOURCE_RAW_GAZE
1534 and state.kind == "static"
1535 and not state.settings.get("show_raw_gaze", True)
1536 )
1539def _raw_gaze_paths(source: SnippetSource) -> list[str] | None:
1540 paths = source.options.get("raw_gaze")
1541 if not paths:
1542 return None
1543 return [str(paths)] if isinstance(paths, str) else [str(p) for p in paths]
1546def _raw_gaze_named(source: SnippetSource) -> bool:
1547 """A table the snippet can name: given as a path, or the demo's own."""
1548 return _raw_gaze_paths(source) is not None or source.kind == SOURCE_DEMO
1551def _raw_gaze_python(source: SnippetSource) -> str:
1552 paths = _raw_gaze_paths(source)
1553 if paths is None and source.kind == SOURCE_DEMO:
1554 return "raw_gaze = sps.load_sample_raw_gaze()"
1555 target = _py(_one_or_list(paths or [_RAW_GAZE_PLACEHOLDER]))
1556 schema = source.options.get("raw_gaze_schema")
1557 extra = f", raw_gaze_schema={_py(schema)}" if schema else ""
1558 return f"raw_gaze = sps.load_raw_gaze({target}{extra})"
1561def _raw_gaze_cli(source: SnippetSource) -> list[str]:
1562 paths = _raw_gaze_paths(source)
1563 if paths is None and source.kind == SOURCE_DEMO:
1564 return ["--sample-raw-gaze"]
1565 argv = ["--raw-gaze", *(paths or [_RAW_GAZE_PLACEHOLDER])]
1566 schema = source.options.get("raw_gaze_schema")
1567 if schema:
1568 argv += ["--raw-gaze-schema", json.dumps(schema, separators=(",", ":"))]
1569 return argv
1572#: EXP-21 — what a snippet loads scanpath B from when it comes from a second
1573#: dataset whose files it cannot name (an upload, a corpus the app opened).
1574B_WORDS_PLACEHOLDER = "B_WORDS"
1575B_FIXATIONS_PLACEHOLDER = "B_FIXATIONS"
1576#: `render`'s own default for `--compare-dataset-name` (and `compare_scanpaths`'
1577#: for `dataset_b=`), so the CLI half writes the flag only when it differs.
1578_DEFAULT_DATASET_B = "Dataset B"
1581def second_dataset(state: FigureState) -> CompareTarget | None:
1582 """Scanpath B's target when it comes from a second dataset, else ``None``.
1584 EXP-21: B's ids then belong to *that* corpus, so both halves load its
1585 tables and name B in them — ``words_b=`` / ``fixations_b=`` in Python,
1586 ``--compare-words`` / ``--compare-fixations`` beside ``--compare-with`` on
1587 the CLI — rather than looking B's reader up in A's corpus.
1588 """
1589 compare = state.compare
1590 if state.kind == "static" or compare is None:
1591 return None
1592 if not (compare.dataset and compare.trial):
1593 return None
1594 return compare
1597def _second_dataset_tables(compare: CompareTarget) -> tuple[list, list]:
1598 """B's words / fixations paths: the ones it was read from, when there are
1599 any (either may be absent, as `render` allows), else both placeholders."""
1600 if compare.words or compare.fixations:
1601 return list(compare.words), list(compare.fixations)
1602 return [B_WORDS_PLACEHOLDER], [B_FIXATIONS_PLACEHOLDER]
1605def _second_dataset_python(compare: CompareTarget) -> list[str]:
1606 words, fixations = _second_dataset_tables(compare)
1607 lines = [
1608 f"# Scanpath B is from a second dataset, {compare.dataset}.",
1609 "words_b, fixations_b = sps.load_scanpath_data(",
1610 f" {_py(_one_or_list(words) if words else None)},",
1611 f" {_py(_one_or_list(fixations) if fixations else None)},",
1612 ")",
1613 ]
1614 if compare.canvas:
1615 # `--compare-canvas`'s own snapshot (`cli._compare_setup_snapshot`): a
1616 # stated screen is a measured one, which is what the overlay gate reads.
1617 width, height = (int(v) for v in compare.canvas)
1618 lines += [
1619 "setup_b = SetupSnapshot(",
1620 f" canvas_width={width},",
1621 f" canvas_height={height},",
1622 " screen_provenance=Provenance.MEASURED,",
1623 ")",
1624 ]
1625 return lines
1628def _second_dataset_kwargs(compare: CompareTarget) -> list[str]:
1629 kwargs = [
1630 "words_b=words_b",
1631 "fixations_b=fixations_b",
1632 f"dataset_b={_py(compare.dataset)}",
1633 ]
1634 if compare.canvas:
1635 kwargs.append("setup_b=setup_b")
1636 return kwargs
1639def _second_dataset_cli(compare: CompareTarget, *, explicit: bool) -> list[str]:
1640 words, fixations = _second_dataset_tables(compare)
1641 argv = ["--compare-words", *words] if words else []
1642 if fixations:
1643 argv += ["--compare-fixations", *fixations]
1644 if explicit or compare.dataset != _DEFAULT_DATASET_B:
1645 argv += ["--compare-dataset-name", str(compare.dataset)]
1646 if compare.canvas:
1647 width, height = (int(v) for v in compare.canvas)
1648 argv += ["--compare-canvas", f"{width}x{height}"]
1649 return argv
1652def python_snippet(
1653 source: SnippetSource,
1654 state: FigureState,
1655 *,
1656 explicit: bool = False,
1657 output: str = "",
1658 save_kwargs: dict | None = None,
1659) -> str:
1660 """The ``scanpath_studio.api`` code that rebuilds ``state``'s figure.
1662 ``output``, when given, appends the save line for that path — the same
1663 ``api.save_figure`` the CLI would call, with ``save_kwargs`` carrying any
1664 non-default raster geometry (``--width`` / ``--height`` / ``--scale``) so a
1665 translated invocation writes the same-sized file, not just the same
1666 picture."""
1667 loader, _ = _SOURCE_WRITERS.get(source.kind, _SOURCE_WRITERS[SOURCE_UNKNOWN])
1668 source = _with_kept_columns(source, state)
1669 other = second_dataset(state)
1670 lines = ["import scanpath_studio as sps"]
1671 if other is not None and other.canvas:
1672 lines.append(
1673 "from scanpath_studio.experimental_setup import Provenance, SetupSnapshot"
1674 )
1675 lines.append("")
1676 lines += loader(source)
1677 # A raw-gaze-only source loaded its samples as its data half already.
1678 if _draws_primary_raw_gaze(state) and source.kind != SOURCE_RAW_GAZE:
1679 lines.append(_raw_gaze_python(source))
1680 if other is not None:
1681 lines += _second_dataset_python(other)
1682 raw_gaze_b = _second_raw_gaze(state)
1683 if raw_gaze_b is not None:
1684 lines.append(f"raw_gaze_b = sps.load_raw_gaze({_py(_one_or_list(raw_gaze_b))})")
1685 lines.append("")
1687 func = _API_FUNCTION[state.kind]
1688 participant, trial = _py(state.participant), _py(state.trial)
1689 if not (state.participant and state.trial):
1690 # EXP-14: an unnamed trial is `render`'s "first available" — so the
1691 # Python half picks that same one rather than quoting `participant=''`,
1692 # which matches no trial and raised on the snippet's first run.
1693 lines.append(
1694 "trials = sps.list_trials(words, fixations, raw_gaze=raw_gaze)"
1695 if _passes_raw_gaze(source, state)
1696 else "trials = sps.list_trials(words, fixations)"
1697 )
1698 # DATA-66: `list_trials` names its two columns as the dataset does
1699 # (participant, then trial), so they are picked by position.
1700 for position, value in ((0, state.participant), (1, state.trial)):
1701 if value:
1702 lines.append(
1703 f"trials = trials[trials.iloc[:, {position}] == {_py(value)}]"
1704 )
1705 lines += ["participant, trial = trials.iloc[0]", ""]
1706 participant, trial = "participant", "trial"
1707 args = ["words", "fixations"]
1708 if state.kind == "comparison":
1709 compare = state.compare or CompareTarget()
1710 args.append(f"({participant}, {trial})")
1711 args.append(f"({_py(compare.participant)}, {_py(compare.trial)})")
1712 else:
1713 args.append(f"participant={participant}")
1714 args.append(f"trial={trial}")
1715 # BUG-85: a co-animation names B the way `compare_scanpaths` does — in
1716 # B's own frames when it comes from a second dataset (EXP-21), which
1717 # `trial_b=` alone would look up in this corpus.
1718 compare = state.compare
1719 if state.kind == "animation" and compare is not None and compare.trial:
1720 args.append(f"trial_b=({_py(compare.participant)}, {_py(compare.trial)})")
1721 if other is not None:
1722 args += _second_dataset_kwargs(other)
1723 if _passes_raw_gaze(source, state):
1724 args.append("raw_gaze=raw_gaze")
1725 if raw_gaze_b is not None:
1726 args.append("raw_gaze_b=raw_gaze_b")
1727 if _raw_gaze_layer_off(source, state):
1728 args.append("show_raw_gaze=False")
1730 call = [f"fig = sps.{func}("]
1731 call += [f" {arg}," for arg in args]
1732 if state.kind == "comparison":
1733 compare = state.compare or CompareTarget()
1734 if explicit or compare.layout != "overlay":
1735 call.append(f" layout={_py(compare.layout)},")
1736 if explicit or compare.compare_stimulus != "both":
1737 call.append(f" compare_stimulus={_py(compare.compare_stimulus)},")
1738 # Emitted only when set, `explicit` included: the default is `None`,
1739 # and the labels it stands for are composed by the builder from the
1740 # trial ids. Writing `labels=None` would be accurate but useless, and
1741 # writing the auto pair would freeze a derived value into the recipe.
1742 if compare.labels:
1743 call.append(f" labels={_py(tuple(compare.labels))},")
1744 for name, value in _call_kwargs(state, explicit=explicit):
1745 call.append(f" {name}={_py(value)},")
1746 for name, value in figure_kwargs(
1747 state.settings, state.kind, explicit=explicit
1748 ).items():
1749 if name == "critical_span_style" and value == "None":
1750 value = None # #374 F29: no marking is Python's None, not 'None'
1751 call.append(f" {name}={_py(value)},")
1752 call.append(")")
1753 lines += call
1755 if output:
1756 extra = "".join(
1757 f", {name}={_py(value)}" for name, value in (save_kwargs or {}).items()
1758 )
1759 lines += ["", f"sps.save_figure(fig, {_py(output)}{extra})"]
1760 if source.note:
1761 lines = [*_comment_lines(source.note), ""] + lines
1762 return "\n".join(lines)
1765def _comment_lines(note: str, width: int = 79) -> list[str]:
1766 """``note`` as wrapped ``#`` lines, without the Markdown the app renders it
1767 with — in a ``.py`` file ``**`` and backticks would show literally (#374)."""
1768 import textwrap
1770 plain = note.replace("**", "").replace("`", "")
1771 return [f"# {line}" for line in textwrap.wrap(plain, width - 2)]
1774def cli_snippet(
1775 source: SnippetSource,
1776 state: FigureState,
1777 *,
1778 explicit: bool = False,
1779 output: str = "scanpath.png",
1780 save_kwargs: dict | None = None,
1781) -> tuple[str, list[str]]:
1782 """The ``scanpath-studio render`` invocation that rebuilds ``state``'s figure.
1784 Returns ``(command, unsupported)``. ``unsupported`` names the settings this
1785 figure needs that ``render`` has no flag for — reported, never dropped, so a
1786 snippet can't quietly promise a figure the CLI won't produce.
1787 """
1788 _, source_cli = _SOURCE_WRITERS.get(source.kind, _SOURCE_WRITERS[SOURCE_UNKNOWN])
1789 source = _with_kept_columns(source, state)
1790 other = second_dataset(state)
1791 argv: list[str] = ["scanpath-studio", "render"]
1792 if source_cli is None:
1793 argv += _unknown_cli(source)
1794 else:
1795 argv += source_cli(source)
1797 if state.participant:
1798 argv += ["-p", str(state.participant)]
1799 if state.trial:
1800 argv += ["-t", str(state.trial)]
1801 if state.screen:
1802 argv += ["--screen", str(state.screen)]
1803 if _names_screen_b(state):
1804 argv += ["--compare-screen", str(state.compare.screen)]
1805 if state.canvas:
1806 argv += ["--canvas", f"{int(state.canvas[0])}x{int(state.canvas[1])}"]
1807 if explicit or state.base_font_size != 16:
1808 argv += ["--font-size", str(int(state.base_font_size))]
1809 if state.font_family and (explicit or _non_default_font(state.font_family)):
1810 argv += ["--font-family", str(state.font_family)]
1811 # `render`'s compare branch passes neither to `compare_scanpaths` (which has
1812 # no parameter for either), so emitting them beside a Python form that
1813 # correctly omits them would be the two flavours contradicting each other.
1814 if state.kind != "comparison" and (
1815 explicit or str(state.illustration_label).lower() != "auto"
1816 ):
1817 argv += ["--illustration-label", str(state.illustration_label).lower()]
1818 if state.title:
1819 argv += ["--title", str(state.title)]
1820 if state.caption:
1821 argv += ["--caption", str(state.caption)]
1823 unsupported: list[str] = []
1824 env_prefix = ""
1825 if state.kind == "animation":
1826 argv.append("--animate")
1827 if explicit or state.playback_speed != 1.0:
1828 argv += ["--playback-speed", _num(state.playback_speed)]
1829 if not state.autoplay:
1830 argv.append("--no-autoplay")
1831 # EXP-20: CMP-11's two-reading replay. `render --animate --compare-with`
1832 # draws it, and the replay's B-side options (`--compare-stimulus`, the
1833 # labels, the legend) are refused without it. EXP-21: a second
1834 # dataset's B is named in that dataset's own tables, as in Python.
1835 if state.compare is not None and state.compare.trial:
1836 argv += [
1837 "--compare-with",
1838 f"{state.compare.participant}:{state.compare.trial}",
1839 ]
1840 if other is not None:
1841 argv += _second_dataset_cli(other, explicit=explicit)
1842 else:
1843 if state.drift_correction:
1844 # PRE-21 gates both flags behind SCANPATH_EXPERIMENTAL=1, so
1845 # `_render_parser` only grows them when it is set. Checking the gate
1846 # *here* would be no check at all — `_collect_viz_settings` already
1847 # forces `align_algorithm` to "Off" unless it is open, so a state
1848 # that carries a correction can only have come from a process where
1849 # it was. The command is copied into *another* terminal, though, so
1850 # it has to carry the variable with it or die on `unrecognized
1851 # arguments` there.
1852 env_prefix = "SCANPATH_EXPERIMENTAL=1"
1853 argv += ["--drift-correction", str(state.drift_correction).lower()]
1854 # Static-only, exactly as `_call_kwargs` has it: `render`'s compare
1855 # branch never forwards it, so emitting it here would be the two
1856 # flavours of one recipe disagreeing.
1857 if state.drift_connectors and state.kind == "static":
1858 argv.append("--drift-connectors")
1859 # VIZ-7's fixation-index window is a `plot_scanpath` / `animate_scanpath`
1860 # parameter rather than a figure keyword, so it is written by hand like the
1861 # drift pair above rather than through `_CLI_EMITTERS`. Both builders take
1862 # it, so it sits outside the static/animation split.
1863 if state.fix_index_range:
1864 lo, hi = state.fix_index_range
1865 argv += ["--fix-index-range", f"{int(lo)}:{int(hi)}"]
1866 if state.fix_index_range_b and state.compare is not None:
1867 lo, hi = state.fix_index_range_b
1868 argv += ["--compare-fix-index-range", f"{int(lo)}:{int(hi)}"]
1869 # A raw-gaze-only source's input flags *are* its --raw-gaze (VIZ-45).
1870 if _draws_primary_raw_gaze(state) and source.kind != SOURCE_RAW_GAZE:
1871 argv += _raw_gaze_cli(source)
1872 if _raw_gaze_layer_off(source, state):
1873 argv.append("--no-raw-gaze")
1874 if state.kind == "comparison":
1875 compare = state.compare or CompareTarget()
1876 argv += ["--compare-with", f"{compare.participant}:{compare.trial}"]
1877 if other is not None:
1878 argv += _second_dataset_cli(other, explicit=explicit)
1879 raw_gaze_b = _second_raw_gaze(state)
1880 if raw_gaze_b is not None:
1881 argv += ["--compare-raw-gaze", *raw_gaze_b]
1882 argv += ["--compare-layout", _CLI_COMPARE_LAYOUT.get(compare.layout, "overlay")]
1883 if explicit or compare.compare_stimulus != "both":
1884 argv += ["--compare-stimulus", str(compare.compare_stimulus).lower()]
1885 # Both or neither, matching `render`'s own rule: a lone label would
1886 # leave the other side to the builder's auto composition, which is not
1887 # a pair `compare_scanpaths` can be given.
1888 if compare.labels:
1889 label_a, label_b = compare.labels
1890 argv += ["--label-a", str(label_a), "--label-b", str(label_b)]
1892 palette, palette_colors = _matching_palette(state.settings, state.kind)
1893 kwargs = figure_kwargs(state.settings, state.kind, explicit=explicit)
1894 if palette:
1895 from .plots import palette_slug
1897 argv += ["--palette", palette_slug(palette)] # no shell quotes
1898 kwargs = _restate_against_palette(
1899 kwargs, state.settings, state.kind, palette_colors
1900 )
1901 for key, value in kwargs.items():
1902 # EXP-12: never emit class colours the figure isn't drawing —
1903 # `--saccade-type-color` implies By type, so emitting them into a
1904 # Uniform figure switched its saccades to the five-way split.
1905 if key == "saccade_class_colors":
1906 if _classes_coloured(state.settings):
1907 argv += _saccade_class_colors(
1908 value, palette_colors.get("saccade_class_colors")
1909 )
1910 continue
1911 if key in _COMPARE_ONLY_SETTINGS and (
1912 state.kind == "animation" and state.compare is None
1913 ):
1914 continue # a single replay takes these and draws nothing with them
1915 emit = _CLI_EMITTERS.get(key)
1916 if emit is None:
1917 unsupported.append(key)
1918 continue
1919 argv += emit(value)
1921 # The raster geometry `save_figure` would be given. `render` has the same
1922 # three flags, so a translated invocation has to carry them or write a
1923 # differently-sized file than the Python form beside it.
1924 # #374 F28: the print width + dpi `save_figure` takes, as `render`'s flags.
1925 for name in ("width", "height", "scale", "width_mm", "width_in", "dpi"):
1926 value = (save_kwargs or {}).get(name)
1927 if value is not None:
1928 argv += [f"--{name.replace('_', '-')}", _num(value)]
1930 argv += ["-o", output]
1931 unsupported.extend(source.cli_unsupported)
1932 if source_cli is None:
1933 unsupported.append(f"the {source.label or source.kind} dataset")
1934 return _wrap_command(argv, env_prefix=env_prefix), sorted(
1935 dict.fromkeys(unsupported)
1936 )
1939#: Settings-vocabulary layout → the `--compare-layout` choice.
1940_CLI_COMPARE_LAYOUT = {
1941 "overlay": "overlay",
1942 "side_by_side": "side-by-side",
1943 "side-by-side": "side-by-side",
1944 "stacked": "stacked",
1945}
1948def _wrap_command(argv: list[str], width: int = 76, *, env_prefix: str = "") -> str:
1949 """One shell command, wrapped with backslash continuations.
1951 Wrapped on flag boundaries (a token starting ``-`` opens a new group) so a
1952 flag never ends up on a different line from its value — that is the one way
1953 a wrapped command can be pasted and silently mean something else.
1955 ``env_prefix`` is prepended verbatim (already shell-safe, never user text):
1956 a flag the parser only grows under an environment variable has to be pasted
1957 together with it."""
1958 groups: list[list[str]] = []
1959 for token in argv:
1960 if token.startswith("-") or not groups:
1961 groups.append([token])
1962 else:
1963 groups[-1].append(token)
1964 if env_prefix and groups:
1965 groups[0].insert(0, env_prefix)
1966 lines: list[str] = []
1967 current = ""
1968 for group in groups:
1969 # `env_prefix and` matters: without it an *empty* token compares equal to
1970 # the empty default prefix and is emitted verbatim — so
1971 # `--highlight-column ''` (mark nothing) printed as a flag with no value,
1972 # which the parser would then read as taking the next flag as its value.
1973 piece = " ".join(
1974 token if (env_prefix and token == env_prefix) else shlex.quote(token)
1975 for token in group
1976 )
1977 if not current:
1978 current = piece
1979 elif len(current) + len(piece) + 1 <= width:
1980 current = f"{current} {piece}"
1981 else:
1982 lines.append(current)
1983 current = f" {piece}"
1984 if current:
1985 lines.append(current)
1986 return " \\\n".join(lines)
1989@dataclass(frozen=True)
1990class ReproductionCode:
1991 """Both flavors of one figure's recipe, plus what neither can promise.
1993 ``cli_unsupported`` names settings the ``render`` parser has no flag for;
1994 ``caveats`` are the human-readable notes that apply to **both** snippets —
1995 data the code can't name, a layer that needs a frame rather than a keyword.
1996 """
1998 python: str
1999 cli: str
2000 cli_unsupported: tuple[str, ...] = ()
2001 caveats: tuple[str, ...] = ()
2004def state_caveats(source: SnippetSource, state: FigureState) -> list[str]:
2005 """What the snippets can't promise about ``state``, in the user's terms."""
2006 notes = []
2007 if source.note:
2008 notes.append(source.note)
2009 if _draws_primary_raw_gaze(state) and not _raw_gaze_named(source):
2010 notes.append(
2011 "The raw gaze was loaded into the app, so the snippet can't name the "
2012 f"file it came from and loads `{_RAW_GAZE_PLACEHOLDER}` instead — "
2013 "point it at your own table."
2014 )
2015 if _second_raw_gaze(state) == [B_RAW_GAZE_PLACEHOLDER]:
2016 notes.append(
2017 "Scanpath B's raw gaze was loaded into the app, so the snippet can't "
2018 f"name its file and loads `{B_RAW_GAZE_PLACEHOLDER}` instead — point "
2019 "it at your own table."
2020 )
2021 if any(
2022 _is_data_uri(state.settings.get(key))
2023 for key in ("background_image", "background_image_b")
2024 ):
2025 notes.append(
2026 "The stimulus image was uploaded into the app, so the snippet "
2027 f"names `{_IMAGE_PLACEHOLDER}` instead — point it at your own file."
2028 )
2029 # CMP-8 / EXP-21: scanpath B can come from a *second* dataset, and its
2030 # participant id is that corpus's own. Both halves load B's tables and name
2031 # B in them (`words_b=` / `--compare-words`); when the snippet can't name
2032 # those tables it writes placeholders, and this says whose they are.
2033 other = second_dataset(state)
2034 if other is not None:
2035 note = (
2036 f"Scanpath B comes from a second dataset (`{other.dataset}`), so "
2037 f"`{other.participant}` is a participant of that dataset, not this one."
2038 )
2039 if not (other.words or other.fixations):
2040 note += (
2041 f" The snippet loads it from `{B_WORDS_PLACEHOLDER}` / "
2042 f"`{B_FIXATIONS_PLACEHOLDER}` (`--compare-words` / "
2043 "`--compare-fixations` on the CLI): point those at its tables."
2044 )
2045 if state.kind == "animation" and other.canvas is None:
2046 # CMP-21: with `dataset_b=`, `animate_scanpath` checks the two screens
2047 # as the app did before drawing this — and a screen nobody states is
2048 # read off that trial's data, which rarely matches, so B's is named.
2049 note += (
2050 " A co-animation needs both scanpaths on one screen, so state B's "
2051 "too, as `setup_b=` (`--compare-canvas` on the CLI): one read off "
2052 "B's data rarely matches."
2053 )
2054 notes.append(note)
2055 if state.kind == "comparison" and str(state.illustration_label).lower() != "auto":
2056 notes.append(
2057 "`compare_scanpaths` has no `illustration_label` parameter, so the "
2058 "snippet leaves out your Illustration label (**"
2059 f"{str(state.illustration_label).capitalize()}**); the comparison "
2060 "sets it itself."
2061 )
2062 return notes
2065def reproduction_code(
2066 source: SnippetSource,
2067 state: FigureState,
2068 *,
2069 explicit: bool = False,
2070 output: str = "scanpath.png",
2071 save_kwargs: dict | None = None,
2072 extra_caveats: tuple[str, ...] = (),
2073) -> ReproductionCode:
2074 """Both snippets for one figure — the pair the Share subtab shows."""
2075 command, unsupported = cli_snippet(
2076 source, state, explicit=explicit, output=output, save_kwargs=save_kwargs
2077 )
2078 return ReproductionCode(
2079 python=python_snippet(
2080 source,
2081 state,
2082 explicit=explicit,
2083 output=output,
2084 save_kwargs=save_kwargs,
2085 ),
2086 cli=command,
2087 cli_unsupported=tuple(unsupported),
2088 caveats=tuple(state_caveats(source, state)) + tuple(extra_caveats),
2089 )