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

1"""EXP-7 — the API / CLI code that reproduces the figure currently on screen. 

2 

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. 

7 

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. 

17 

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. 

22 

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""" 

31 

32from __future__ import annotations 

33 

34import json 

35import shlex 

36from dataclasses import dataclass, field, replace 

37from typing import Any 

38 

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" 

44 

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" 

52 

53#: The figure kinds a snippet can reproduce, matching ``api.figure_options``. 

54KINDS = ("static", "animation", "comparison") 

55 

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} 

62 

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" 

86 

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) 

94 

95 

96@dataclass(frozen=True) 

97class SnippetSource: 

98 """How the snippet's data half is written. 

99 

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 """ 

105 

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, ...] = () 

113 

114 

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} 

125 

126 

127def source_canvas(kind: str) -> tuple[int, int] | None: 

128 """The screen ``render`` assumes for a source when ``--canvas`` is omitted. 

129 

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 

139 

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 

148 

149 return tuple(MULTIPLEYE_MONITOR) 

150 return None 

151 

152 

153def _root(source: SnippetSource, fallback: str) -> str: 

154 return str(source.options.get("root") or fallback) 

155 

156 

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}) 

160 

161#: The figure options that name a fixation column. 

162_FIXATION_COLUMN_OPTIONS = ("color_by", "x_field", "y_field", "fixation_hover_fields") 

163 

164 

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 

171 

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 ) 

185 

186 

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)) 

197 

198 

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}) 

208 

209 

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 [] 

213 

214 

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 [] 

218 

219 

220def _demo_python(source: SnippetSource) -> list[str]: 

221 return ["words, fixations = sps.load_sample_data()"] 

222 

223 

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 ] 

230 

231 

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 ] 

243 

244 

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 

256 

257 

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))})"] 

261 

262 

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 ] 

269 

270 

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 

290 

291 

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 

302 

303 

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 ] 

313 

314 

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 ] 

329 

330 

331def _demo_cli(source: SnippetSource) -> list[str]: 

332 return ["--sample"] 

333 

334 

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) 

346 

347 

348def _author_cli(source: SnippetSource) -> list[str]: 

349 return ["--authoring", str(source.options.get("path") or "scanpath.json")] 

350 

351 

352def _potec_cli(source: SnippetSource) -> list[str]: 

353 return ["--potec", _root(source, "data/PoTeC")] 

354 

355 

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 

363 

364 

365def _multipleye_cli(source: SnippetSource) -> list[str]: 

366 return ["--source", "multipleye", "--export", _root(source, "data/MultiplEYE")] 

367 

368 

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 ] 

376 

377 

378def _unknown_cli(source: SnippetSource) -> list[str]: 

379 return ["--words", "words.csv", "--fixations", "fixations.csv", *_keep_cli(source)] 

380 

381 

382#: The placeholder paths an uploaded dataset's snippet loads its tables from. 

383UPLOAD_WORDS_PLACEHOLDER = "words.csv" 

384UPLOAD_FIXATIONS_PLACEHOLDER = "fixations.csv" 

385 

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} 

400 

401 

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). 

410 

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 ) 

484 

485 

486def _compact_schema(schema: dict) -> dict: 

487 """A mapping without its unmapped fields, so the snippet stays readable. 

488 

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 } 

498 

499 

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 

509 

510 

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) 

523 

524 

525def _raw_gaze_only_python(source: SnippetSource) -> list[str]: 

526 return [_raw_gaze_python(source), "words, fixations = None, None"] 

527 

528 

529def _raw_gaze_only_cli(source: SnippetSource) -> list[str]: 

530 return _raw_gaze_cli(source) 

531 

532 

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} 

549 

550 

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).""" 

557 

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 

589 

590 

591@dataclass(frozen=True) 

592class FigureState: 

593 """Everything a snippet needs about the figure that is on screen. 

594 

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 """ 

599 

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 

620 

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}.") 

624 

625 

626def _comparable(value): 

627 """Normalize a setting for equality against its default. 

628 

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 

641 

642 

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) 

659 

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"}) 

669 

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" 

674 

675 

676def _is_data_uri(value) -> bool: 

677 return isinstance(value, str) and value.startswith("data:") 

678 

679 

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. 

684 

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 

691 

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 

727 

728 

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) 

774 

775 

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 

781 

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 

802 

803 

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 

808 

809 return ( 

810 kind != "comparison" 

811 and settings.get("heatmap_style") in SELF_SCALED_HEATMAP_STYLES 

812 ) 

813 

814 

815#: The per-scanpath style options → which scanpath each styles. 

816_COMPARE_STYLE_SIDES = {"style_a": 0, "style_b": 1} 

817 

818 

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 

832 

833 

834def _same_flags(a, b) -> bool: 

835 return _active_flags(a) == _active_flags(b) 

836 

837 

838def _visible_classes(classes) -> frozenset | None: 

839 from .constants import SACCADE_CLASS_ORDER 

840 

841 if not classes or set(classes) >= set(SACCADE_CLASS_ORDER): 

842 return None 

843 return frozenset(classes) 

844 

845 

846def _drop_inherited_filters(style, settings: dict): 

847 """A per-scanpath style without the filters it merely repeats (CMP-24). 

848 

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 

864 

865 

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. 

868 

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 

878 

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 

889 

890 

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``.""" 

896 

897 def emit(value): 

898 return [flag] if _comparable(value) == _comparable(wanted) else [] 

899 

900 return emit 

901 

902 

903def _switch(on: str, off: str) -> Any: 

904 """A layer with a flag each way: ``on`` when drawn, ``off`` when not.""" 

905 

906 def emit(value): 

907 return [on] if value else [off] 

908 

909 return emit 

910 

911 

912def _valued(flag: str) -> Any: 

913 def emit(value): 

914 return [] if value is None else [flag, str(value)] 

915 

916 return emit 

917 

918 

919def _int_valued(flag: str) -> Any: 

920 """A ``type=int`` flag: ``12.0`` from a settings dict would be refused.""" 

921 

922 def emit(value): 

923 return [] if value is None else [flag, str(int(value))] 

924 

925 return emit 

926 

927 

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.""" 

931 

932 def emit(value): 

933 return [flag, "" if value is None else str(value)] 

934 

935 return emit 

936 

937 

938def _mapped(flag: str, table: dict) -> Any: 

939 """A flag whose CLI vocabulary differs from the settings vocabulary.""" 

940 

941 def emit(value): 

942 token = table.get(value) 

943 return [] if token is None else [flag, token] 

944 

945 return emit 

946 

947 

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)] 

953 

954 return emit 

955 

956 

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)] 

962 

963 

964def _fixation_flags(value, flag: str = "--fixation-flag"): 

965 """The PRE-2 classification dict → one ``--fixation-flag`` per category. 

966 

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 

989 

990 

991def _legend_layout(value): 

992 """The legend placements → one ``--legend`` per legend that was moved. 

993 

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 

997 

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 

1003 

1004 

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"] 

1010 

1011 

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)] 

1016 

1017 

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 [] 

1024 

1025 

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 

1033 

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 

1041 

1042 

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 

1048 

1049 return dict(SACCADE_CLASS_COLORS) 

1050 return value 

1051 

1052 

1053def _palette_colors(name: str) -> dict: 

1054 """The figure keywords palette ``name`` writes, as `api` expands them.""" 

1055 from . import api 

1056 

1057 return api._expand_palette({"palette": name}) 

1058 

1059 

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") 

1064 

1065 

1066def _matching_palette(settings: dict, kind: str) -> tuple[str | None, dict]: 

1067 """The ``--palette`` a CLI snippet can name instead of spelling it out. 

1068 

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. 

1079 

1080 Returns ``(name, colors)`` — the colors the named palette supplies — or 

1081 ``(None, {})``.""" 

1082 from . import api 

1083 from .constants import PALETTES 

1084 

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 

1106 

1107 

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 

1115 

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 

1127 

1128 

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))] 

1134 

1135 

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] 

1145 

1146 return emit 

1147 

1148 

1149def _two_numbers(flag: str) -> Any: 

1150 """A ``nargs=2`` flag (`--fixation-color-range LO HI`).""" 

1151 

1152 def emit(value): 

1153 if not value: 

1154 return [] 

1155 lo, hi = value 

1156 return [flag, _num(lo), _num(hi)] 

1157 

1158 return emit 

1159 

1160 

1161def _lowercase(flag: str) -> Any: 

1162 """A choice the CLI spells in lower case (`--compare-stimulus b`).""" 

1163 

1164 def emit(value): 

1165 return [] if value is None else [flag, str(value).lower()] 

1166 

1167 return emit 

1168 

1169 

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) 

1184 

1185 

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.""" 

1189 

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 

1218 

1219 return emit 

1220 

1221 

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) 

1226 

1227 

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} 

1356 

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) 

1372 

1373 

1374# --------------------------------------------------------------------------- 

1375# Emitters 

1376# --------------------------------------------------------------------------- 

1377def _py(value) -> str: 

1378 """A Python literal for a settings value. 

1379 

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) 

1391 

1392 

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 

1397 

1398 

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 ) 

1407 

1408 

1409def _call_kwargs(state: FigureState, *, explicit: bool) -> list[tuple[str, Any]]: 

1410 """The named (non-figure-keyword) arguments the API call carries. 

1411 

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 

1468 

1469 

1470def _non_default_font(name: str) -> bool: 

1471 from .constants import FONT_FAMILY 

1472 

1473 return str(name) != FONT_FAMILY 

1474 

1475 

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" 

1479 

1480 

1481def draws_raw_gaze(state: FigureState) -> bool: 

1482 """Whether the figure on screen draws a raw-gaze layer (EXP-20). 

1483 

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 ) 

1490 

1491 

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 ) 

1500 

1501 

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" 

1504 

1505 

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] 

1513 

1514 

1515def _passes_raw_gaze(source: SnippetSource, state: FigureState) -> bool: 

1516 """Whether the builder call names ``raw_gaze=raw_gaze``. 

1517 

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 ) 

1523 

1524 

1525def _raw_gaze_layer_off(source: SnippetSource, state: FigureState) -> bool: 

1526 """A raw-gaze-only figure with the layer switched off (VIZ-45). 

1527 

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 ) 

1537 

1538 

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] 

1544 

1545 

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 

1549 

1550 

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})" 

1559 

1560 

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 

1570 

1571 

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" 

1579 

1580 

1581def second_dataset(state: FigureState) -> CompareTarget | None: 

1582 """Scanpath B's target when it comes from a second dataset, else ``None``. 

1583 

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 

1595 

1596 

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] 

1603 

1604 

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 

1626 

1627 

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 

1637 

1638 

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 

1650 

1651 

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. 

1661 

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("") 

1686 

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") 

1729 

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 

1754 

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) 

1763 

1764 

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 

1769 

1770 plain = note.replace("**", "").replace("`", "") 

1771 return [f"# {line}" for line in textwrap.wrap(plain, width - 2)] 

1772 

1773 

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. 

1783 

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) 

1796 

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)] 

1822 

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)] 

1891 

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 

1896 

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) 

1920 

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)] 

1929 

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 ) 

1937 

1938 

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} 

1946 

1947 

1948def _wrap_command(argv: list[str], width: int = 76, *, env_prefix: str = "") -> str: 

1949 """One shell command, wrapped with backslash continuations. 

1950 

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. 

1954 

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) 

1987 

1988 

1989@dataclass(frozen=True) 

1990class ReproductionCode: 

1991 """Both flavors of one figure's recipe, plus what neither can promise. 

1992 

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 """ 

1997 

1998 python: str 

1999 cli: str 

2000 cli_unsupported: tuple[str, ...] = () 

2001 caveats: tuple[str, ...] = () 

2002 

2003 

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 

2063 

2064 

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 )