Coverage for scanpath_studio/cli.py: 91%

1394 statements  

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

1"""Command-line interface for scanpath-studio. 

2 

3Subcommands: 

4 scanpath-studio # launch the Streamlit app (default) 

5 scanpath-studio run [args…] # same, forwarding extra args to streamlit 

6 scanpath-studio render … # headless: render one trial to a file 

7 

8Anything that isn't a known subcommand is forwarded to ``streamlit run`` so 

9pre-existing invocations like ``scanpath-studio --server.port 8502`` keep 

10working. 

11""" 

12 

13from __future__ import annotations 

14 

15import argparse 

16import json 

17import os 

18import re 

19import shlex 

20import sys 

21from dataclasses import replace 

22from importlib import resources 

23from pathlib import Path 

24 

25import pandas as pd 

26 

27from . import __version__ 

28from . import column_names as _cn 

29from .code_snippet import ( 

30 SOURCE_AUTHOR, 

31 SOURCE_DEMO, 

32 SOURCE_MULTIPLEYE, 

33 SOURCE_ONESTOP, 

34 SOURCE_POTEC, 

35 SnippetSource, 

36 source_canvas, 

37) 

38from .constants import ( 

39 DEFAULT_FIXATION_COLOR, 

40 DEFAULT_FIXATION_COLORSCALE, 

41 DEFAULT_FIXATION_SYMBOL, 

42 DEFAULT_HEATMAP_COLORSCALE, 

43 DEFAULT_SACCADE_WIDTH, 

44 FIXATION_SYMBOLS, 

45 FONT_FAMILY, 

46 SACCADE_CLASS_COLORS, 

47 SACCADE_CLASS_EDITABLE, 

48 SACCADE_CLASS_ORDER, 

49 SACCADE_COLOR, 

50 SACCADE_DASH_OPTIONS, 

51 SACCADE_WIDTH_BOUNDS, 

52 UNIFORM_COLOR_FIELD, 

53 benchmark_corpora_enabled, 

54 drift_correction_enabled, 

55 multipleye_enabled, 

56 palette_settings, 

57) 

58 

59 

60def _spell_color(argv: list[str]) -> list[str]: 

61 """Every ``--…colour…`` flag as its ``--…color…`` name, so the British 

62 spelling works too.""" 

63 out = [] 

64 for token in argv: 

65 if isinstance(token, str) and token.startswith("--") and "colour" in token: 

66 flag, sep, value = token.partition("=") 

67 token = flag.replace("colour", "color") + sep + value 

68 out.append(token) 

69 return out 

70 

71 

72class _ShortErrorParser(argparse.ArgumentParser): 

73 """#374 F21: a parser whose error is three lines at most — the error, a 

74 "did you mean" for a misspelled flag, and where every option is listed — 

75 instead of the usage block (``render``'s alone runs to ~125 lines). The 

76 "did you mean" is our own difflib pass, so it works on 3.11–3.13 too.""" 

77 

78 def parse_known_args(self, args=None, namespace=None): 

79 args = sys.argv[1:] if args is None else list(args) 

80 return super().parse_known_args(_spell_color(args), namespace) 

81 

82 def _suggestions(self, message: str) -> str: 

83 import difflib 

84 

85 match = re.match(r"unrecognized arguments: (.*)", message) 

86 if not match: 

87 return "" 

88 known = [ 

89 flag 

90 for action in self._actions 

91 for flag in action.option_strings 

92 if flag.startswith("--") 

93 ] 

94 hints = [] 

95 for token in match.group(1).split(): 

96 if not token.startswith("--"): 

97 continue 

98 close = difflib.get_close_matches(token.split("=")[0], known, n=1) 

99 if close: 

100 hints.append(close[0]) 

101 return f"Did you mean {', '.join(hints)}?" if hints else "" 

102 

103 def error(self, message: str): 

104 lines = [f"{self.prog}: error: {message}"] 

105 hint = self._suggestions(message) 

106 if hint: 

107 lines.append(hint) 

108 lines.append(f"`{self.prog} --help` lists every option.") 

109 self.exit(2, "\n".join(lines) + "\n") 

110 

111 

112def _drift_algorithm(value: str) -> str: 

113 """Validate ``--drift-correction`` against :data:`alignment.ALGORITHMS`. 

114 

115 Used as an argparse ``type=`` callable so the alignment import (which pulls 

116 in scipy, ~0.7 s) is paid only when the flag is actually passed — plain 

117 ``render`` and ``render --help`` stay instant. An unknown name raises 

118 ``ArgumentTypeError``, so argparse exits non-zero listing the valid ones 

119 instead of forwarding the string to the API. 

120 """ 

121 from .alignment import ALGORITHMS 

122 

123 name = str(value).strip().lower() 

124 if name not in ALGORITHMS: 

125 raise argparse.ArgumentTypeError( 

126 f"unknown algorithm {value!r}; choose one of {', '.join(ALGORITHMS)}." 

127 ) 

128 return name 

129 

130 

131def _palette_name(value: str) -> str: 

132 """``--palette``: a short name (``print``) or the app's (#374).""" 

133 from .api import resolve_palette 

134 

135 try: 

136 return resolve_palette(value) 

137 except ValueError as exc: 

138 raise argparse.ArgumentTypeError(str(exc)) from None 

139 

140 

141def _colorscale_name(value: str) -> str: 

142 """Validate ``--heatmap-colorscale`` / ``--fixation-colorscale`` (EXP-13). 

143 

144 An argparse ``type=`` like :func:`_drift_algorithm`: a name Plotly doesn't 

145 know otherwise surfaced as a ``PlotlyError`` traceback from deep inside the 

146 builder, after the data had already loaded.""" 

147 import difflib 

148 

149 from plotly.colors import get_colorscale, named_colorscales 

150 from plotly.exceptions import PlotlyError 

151 

152 try: 

153 get_colorscale(str(value)) 

154 except PlotlyError: 

155 names = named_colorscales() 

156 close = difflib.get_close_matches(str(value).lower(), names, n=3, cutoff=0.6) 

157 hint = f" Closest: {', '.join(close)}." if close else "" 

158 raise argparse.ArgumentTypeError( 

159 f"unknown colorscale {value!r}.{hint} Any Plotly named colorscale " 

160 "works, e.g. Viridis, Greens, Blues, Cividis; append _r to reverse one." 

161 ) 

162 return str(value) 

163 

164 

165def _fill_color(value: str) -> str: 

166 """Validate ``--word-box-fill-color`` — an argparse ``type=`` like 

167 :func:`_colorscale_name`. The fill is drawn at its own opacity, so it needs 

168 a color with RGB channels to put that alpha on; a name such as ``red`` 

169 would otherwise fail only once the figure was being built.""" 

170 from .plots import color_with_alpha 

171 

172 try: 

173 color_with_alpha(value, 1.0) 

174 except ValueError as exc: 

175 raise argparse.ArgumentTypeError(str(exc)) 

176 return str(value).strip() 

177 

178 

179#: The API keyword each schema flag stands for (EXP-13; the raw-gaze one EXP-20). 

180_SCHEMA_FLAGS = { 

181 "word_schema": "--word-schema", 

182 "fix_schema": "--fix-schema", 

183 "raw_gaze_schema": "--raw-gaze-schema", 

184} 

185 

186 

187def _add_schema_flags(group) -> None: 

188 """``--word-schema`` / ``--fix-schema`` / ``--keep-columns`` on ``render`` 

189 and ``analyze`` — the loader's own options.""" 

190 for flag, table in (("--word-schema", "--words"), ("--fix-schema", "--fixations")): 

191 group.add_argument( 

192 flag, 

193 metavar="JSON", 

194 help=f"Column mapping for the {table} table, replacing auto-detection: " 

195 "a JSON object (or a path to a .json file holding one) from each " 

196 'field to a column name, e.g. \'{"trial": "TRIAL_INDEX", ' 

197 '"word_id": "IA_ID", ...}\' — the same dict ' 

198 "api.load_scanpath_data takes. Needed only when a column isn't " 

199 "recognized; the error then prints a mapping to start from.", 

200 ) 

201 group.add_argument( 

202 "--keep-columns", 

203 nargs="+", 

204 metavar="COLUMN", 

205 default=None, 

206 help="Further columns of your own to carry through loading under their " 

207 "own names (e.g. a pupil size), from whichever table has them — " 

208 "otherwise loading keeps only the mapped and recognized fields. " 

209 "A kept fixation column can then be --color-by, an axis or a hover " 

210 "field. The keep_columns= of api.load_scanpath_data.", 

211 ) 

212 

213 

214def _parse_schema_arg(value: str | None, flag: str) -> dict | None: 

215 """``--word-schema`` / ``--fix-schema`` → the mapping dict (EXP-13). 

216 

217 Inline JSON when it starts with ``{``, a path to a ``.json`` file otherwise, 

218 so a mapping too long for a shell line can live next to the data.""" 

219 if value is None: 

220 return None 

221 text = str(value).strip() 

222 if not text.startswith("{"): 

223 try: 

224 text = Path(text).expanduser().read_text(encoding="utf-8") 

225 except OSError as exc: 

226 raise SystemExit( 

227 f"{flag}: {value!r} is neither a JSON object nor a readable file " 

228 f"({exc.strerror or exc})." 

229 ) 

230 try: 

231 mapping = json.loads(text) 

232 except json.JSONDecodeError as exc: 

233 raise SystemExit( 

234 f"{flag}: not valid JSON ({exc.msg}, line {exc.lineno} column " 

235 f'{exc.colno}). Expected an object such as \'{{"trial": "TRIAL_INDEX"}}\'.' 

236 ) 

237 

238 def is_column(item) -> bool: 

239 return item is None or isinstance(item, str) 

240 

241 if not isinstance(mapping, dict) or not all( 

242 isinstance(key, str) 

243 and ( 

244 is_column(column) 

245 or (isinstance(column, list) and all(isinstance(c, str) for c in column)) 

246 ) 

247 for key, column in mapping.items() 

248 ): 

249 raise SystemExit( 

250 f"{flag} expects a JSON object from each field to a column name (a " 

251 "list of names for a composite id, or null to leave a field unmapped)." 

252 ) 

253 return mapping 

254 

255 

256def _load_error_message(exc: Exception, *, schema_flags: bool = True) -> str: 

257 """A load failure as the command line should say it (EXP-13). 

258 

259 A :class:`api.SchemaError` ends with the API's "pass ``word_schema={…}``" 

260 hint, which a shell user has no way to act on; this swaps it for the 

261 ``--word-schema`` / ``--fix-schema`` form. ``schema_flags=False`` is for the 

262 second comparison dataset, which has no mapping flag of its own.""" 

263 from .api import SchemaError 

264 from .data import StimulusJoinError 

265 

266 if isinstance(exc, StimulusJoinError): 

267 # DATA-49: the fix is a mapping, and here a mapping is a flag. 

268 message = str(exc).replace("`", "'") 

269 if not schema_flags: 

270 return message 

271 return ( 

272 f"{message}\nOn the command line, map it with --word-schema and " 

273 "--fix-schema: each takes that table's full mapping as JSON (or a " 

274 'path to a .json file), with "text_id" naming its text column.' 

275 ) 

276 if not isinstance(exc, SchemaError): 

277 return str(exc) 

278 flag = _SCHEMA_FLAGS.get(exc.param) 

279 if flag is None or not schema_flags: 

280 return ( 

281 f"{exc.detail}\nThis table's columns have to be auto-detected here — " 

282 "rename them, or build the figure in Python, where " 

283 f"api.load_scanpath_data takes {exc.param}=." 

284 ) 

285 detail = exc.detail.replace(exc.param, flag) 

286 if exc.mapping is None: 

287 return f"{detail}\nCorrect the column names in {flag}, or drop it to use auto-detection." 

288 example = json.dumps(exc.mapping) 

289 return ( 

290 f"{detail}\nTo map the columns yourself, pass the full mapping as JSON — it " 

291 "replaces auto-detection, so it needs every required key:\n" 

292 f" {flag} {shlex.quote(example)}\n(or a path to a .json file holding it)." 

293 ) 

294 

295 

296def _theme_cli_flags() -> list[str]: 

297 """The branded theme as ``--theme.*`` CLI flags (BUG-6). 

298 

299 Streamlit resolves ``.streamlit/config.toml`` relative to the launch 

300 directory, so ``python -m scanpath_studio`` from outside ``app/`` (or a 

301 ``pip``-installed console script) misses the bundled config and renders the 

302 default red accent. Passing the theme explicitly makes every launch path 

303 match. Values live in ``constants`` (kept in sync with the config file).""" 

304 from .constants import APP_THEME, APP_THEME_DARK 

305 

306 flags = [f"--theme.{key}={value}" for key, value in APP_THEME.items()] 

307 flags += [f"--theme.dark.{key}={value}" for key, value in APP_THEME_DARK.items()] 

308 return flags 

309 

310 

311def _max_upload_cli_flags(extra_args) -> list[str]: 

312 """Raise the per-file upload cap past Streamlit's 200 MB default. 

313 

314 Same reason as the theme above: ``.streamlit/config.toml`` is resolved 

315 against the *launch* directory, so a pip-installed ``scanpath-studio`` run 

316 from anywhere but the repo root never saw the bundled 

317 ``server.maxUploadSize`` and rejected any table over 200 MB — which is a 

318 normal size for a real fixation report. An explicit ``--server.*`` flag from 

319 the caller still wins. 

320 

321 ENG-68: a deployment's own ``SCANPATH_MAX_UPLOAD_MB``, when lower, is 

322 passed to the server as well, so the cap the upload boxes show is one the 

323 server enforces rather than only the browser. 

324 """ 

325 from .constants import UPLOAD_MAX_SIZE_MB, configured_upload_limit_mb 

326 

327 if any(str(arg).startswith("--server.maxUploadSize") for arg in extra_args): 

328 return [] 

329 limit = min(configured_upload_limit_mb() or UPLOAD_MAX_SIZE_MB, UPLOAD_MAX_SIZE_MB) 

330 return [f"--server.maxUploadSize={limit}"] 

331 

332 

333#: Where ``scanpath-studio`` listens unless told otherwise (ENG-55). 

334LOOPBACK_ADDRESS = "127.0.0.1" 

335 

336 

337def _configured_server_address() -> str | None: 

338 """``server.address`` from a ``config.toml`` that ``streamlit run`` reads. 

339 

340 Read here with ``tomllib`` rather than through ``streamlit.config``, whose 

341 parse is cached: parsing once now and again (with the flags) at launch makes 

342 Streamlit log that the ``[server]`` section changed and must be restarted.""" 

343 import tomllib 

344 

345 from streamlit import config as st_config 

346 

347 for path in st_config.get_config_files("config.toml"): 

348 try: 

349 with open(path, "rb") as handle: 

350 address = (tomllib.load(handle).get("server") or {}).get("address") 

351 except (OSError, tomllib.TOMLDecodeError): 

352 continue 

353 if address: 

354 return str(address) 

355 return None 

356 

357 

358def _bind_cli_flags(extra_args) -> list[str]: 

359 """Bind to loopback unless the caller chose an address (ENG-55). 

360 

361 Streamlit's own default is every interface (``0.0.0.0``), and the app has 

362 no login: a local run on a campus or café network served the loaded corpus 

363 — and the on-device recovery cache — to anyone who could reach the port. 

364 The desktop launcher already bound loopback (S1); every other launch now 

365 does too. An address the user set anywhere Streamlit reads one — a 

366 ``--server.address`` flag, ``STREAMLIT_SERVER_ADDRESS``, or a 

367 ``config.toml`` — wins, so serving on a network stays one flag away.""" 

368 if any(str(arg).startswith("--server.address") for arg in extra_args): 

369 return [] 

370 if os.environ.get("STREAMLIT_SERVER_ADDRESS"): 

371 return [] 

372 if _configured_server_address() is not None: 

373 return [] 

374 return [f"--server.address={LOOPBACK_ADDRESS}"] 

375 

376 

377def _consume_download_dir(extra_args: list[str]) -> list[str]: 

378 """Strip ``--download-dir DIR`` / ``--download-dir=DIR`` into its env var.""" 

379 from .constants import DOWNLOAD_DIR_ENV 

380 

381 rest: list[str] = [] 

382 args = iter(extra_args) 

383 for arg in args: 

384 if arg == "--download-dir": 

385 value = next(args, None) 

386 if value is None: 

387 raise SystemExit("--download-dir needs a folder") 

388 os.environ[DOWNLOAD_DIR_ENV] = value 

389 elif str(arg).startswith("--download-dir="): 

390 os.environ[DOWNLOAD_DIR_ENV] = str(arg).split("=", 1)[1] 

391 else: 

392 rest.append(arg) 

393 return rest 

394 

395 

396def launch_app(extra_args: list[str]) -> None: 

397 """Launch the Streamlit app via ``streamlit run``, forwarding extra args.""" 

398 from streamlit.web import cli as stcli 

399 

400 # ENG-30: `--no-persist` is ours, not Streamlit's — consume it here (it would 

401 # otherwise reach `streamlit run` as an unknown flag) and set the env var the 

402 # app reads, so one launch runs without the on-device recovery cache. 

403 if "--no-persist" in extra_args: 

404 from .persistence import PERSIST_ENV_VAR 

405 

406 extra_args = [arg for arg in extra_args if arg != "--no-persist"] 

407 os.environ[PERSIST_ENV_VAR] = "0" 

408 

409 # UX-184: `--download-dir DIR` (or `=DIR`) is ours too — where ⬇ Download 

410 # saves the public corpora when the Data Management page's Download folder is blank. 

411 extra_args = _consume_download_dir(extra_args) 

412 

413 # Inject the branded theme unless the caller passes their own ``--theme.*`` 

414 # (explicit flags win), so the app looks the same regardless of where it was 

415 # launched from (BUG-6). 

416 theme_args = ( 

417 [] 

418 if any(str(arg).startswith("--theme") for arg in extra_args) 

419 else _theme_cli_flags() 

420 ) 

421 # Streamlit's usage stats default to ON, and `.streamlit/config.toml` is 

422 # resolved against the *launch* directory — which for a pip-installed 

423 # `scanpath-studio` is wherever the user happened to be. Opt out explicitly, 

424 # same override rule as the theme: an explicit flag from the caller wins 

425 # (DATA-12). The desktop launcher already passes this. 

426 stats_args = ( 

427 [] 

428 if any(str(arg).startswith("--browser.gatherUsageStats") for arg in extra_args) 

429 else ["--browser.gatherUsageStats=false"] 

430 ) 

431 # UX-183: Streamlit's toolbar resolves to "developer" on localhost, which 

432 # puts a Deploy button (to Streamlit Community Cloud) in the header. The 

433 # "viewer" toolbar drops it and the other developer-only items but keeps 

434 # the ⋮ menu with its System / Light / Dark switch. Same override 

435 # rule: `--client.toolbarMode=developer` brings the full toolbar back. 

436 toolbar_args = ( 

437 [] 

438 if any(str(arg).startswith("--client.toolbarMode") for arg in extra_args) 

439 else ["--client.toolbarMode=viewer"] 

440 ) 

441 # The entry shim, not app.py itself: it runs the app inside crash_report's 

442 # guard, which an import-time error in app.py would otherwise escape. 

443 app_resource = resources.files(__package__).joinpath("streamlit_entry.py") 

444 with resources.as_file(app_resource) as app_path: 

445 sys.argv = [ 

446 "streamlit", 

447 "run", 

448 str(app_path), 

449 *theme_args, 

450 *stats_args, 

451 *toolbar_args, 

452 *_max_upload_cli_flags(extra_args), 

453 *_bind_cli_flags(extra_args), 

454 *extra_args, 

455 ] 

456 sys.exit(stcli.main()) 

457 

458 

459def _render_parser() -> argparse.ArgumentParser: 

460 from .alignment import ALGORITHMS 

461 

462 parser = _ShortErrorParser( 

463 prog="scanpath-studio render", 

464 description=( 

465 "Render one trial's scanpath to a file without launching the app. " 

466 "HTML output is interactive and needs no browser; PNG/SVG/PDF need " 

467 "Chrome, Chromium or Edge (or run `plotly_get_chrome -y` once)." 

468 ), 

469 ) 

470 src = parser.add_argument_group("input (bundled demo, or words and/or fixations)") 

471 src.add_argument( 

472 "--sample", 

473 action="store_true", 

474 help="Use the bundled OneStop demo: 2 participants, 12 trials each " 

475 "(--list-trials shows them).", 

476 ) 

477 src.add_argument( 

478 "--authoring", 

479 metavar="PATH", 

480 help="An authoring file from the app's Author a scanpath screen.", 

481 ) 

482 src.add_argument( 

483 "--words", 

484 metavar="PATH", 

485 nargs="+", 

486 help="Words table(s) (csv/tsv/txt/tab/parquet/feather/xlsx/xls, or a .zip of them); columns are " 

487 "auto-detected from EyeLink, Gazepoint, Tobii, SMI, Pupil Labs and " 

488 "snake_case names. Multiple paths or a quoted glob pattern concatenate " 

489 "multi-file datasets.", 

490 ) 

491 src.add_argument( 

492 "--fixations", 

493 metavar="PATH", 

494 nargs="+", 

495 help="Fixations table(s) (csv/tsv/txt/tab/parquet/feather/xlsx/xls, or a .zip of them), auto-detected like " 

496 "--words. Multiple paths or a quoted glob pattern concatenate " 

497 "multi-file datasets (e.g. one file per participant).", 

498 ) 

499 src.add_argument( 

500 "--image-root", 

501 metavar="DIR", 

502 help="Local stimulus-image folder. Files are matched per row using " 

503 "--image-pattern.", 

504 ) 

505 src.add_argument( 

506 "--image-pattern", 

507 default="{text_id}.png", 

508 metavar="PATTERN", 

509 help="Relative filename pattern with row placeholders, for example " 

510 "'{text_id}.png' or '{participant_id}/{trial_id}.png'.", 

511 ) 

512 src.add_argument( 

513 "--trial-parts-manifest", 

514 metavar="PATH", 

515 help="JSON manifest that assigns arbitrary source rows to ordered screens " 

516 "inside each logical trial. Use with --words/--fixations when the source " 

517 "tables have no explicit screen columns.", 

518 ) 

519 _add_schema_flags(src) 

520 src.add_argument( 

521 "--potec", 

522 metavar="DIR", 

523 help="Load the PoTeC corpus (DiLi-Lab/PoTeC) from DIR, downloading " 

524 "the needed files (~45 MB) on first use. Participants are the corpus's " 

525 "75 ids (sparse within 0–105; --list-trials shows them); a trial is " 

526 "one participant reading one text, <participant>_<text> (0_b0), " 

527 "with texts b0–b5 and p0–p5.", 

528 ) 

529 

530 # DATA-55: the harmonised benchmark corpora are held back from the beta, the 

531 # same way DATA-54 holds back MultiplEYE's flags below: they still parse and 

532 # work, but `--help` (and the generated CLI reference) doesn't list them. 

533 def benchmark_help(text: str) -> str: 

534 return text if benchmark_corpora_enabled() else argparse.SUPPRESS 

535 

536 src.add_argument( 

537 "--eyegenbench", 

538 metavar="DIR", 

539 help=benchmark_help( 

540 "EyeGenBench bundle directory (built by " 

541 "scripts/prepare_eyegenbench.py). Pick the corpus with " 

542 "--eyegenbench-dataset." 

543 ), 

544 ) 

545 src.add_argument( 

546 "--eyegenbench-dataset", 

547 metavar="NAME", 

548 help=benchmark_help("Which EyeGenBench corpus to render, e.g. PoTeC."), 

549 ) 

550 src.add_argument( 

551 "--onestop", 

552 metavar="DIR", 

553 help="Load the OneStop corpus from DIR. For the public variant the " 

554 "chosen regime + parts' reports are downloaded from OSF on first use " 

555 "(tens–hundreds MB each); the lacclab variant reads a local export. " 

556 "Tune with --onestop-regime / --onestop-part / --onestop-variant.", 

557 ) 

558 src.add_argument( 

559 "--onestop-regime", 

560 metavar="REGIME", 

561 choices=[ 

562 "ordinary", 

563 "information_seeking", 

564 "repeated", 

565 "information_seeking_repeated", 

566 ], 

567 default="ordinary", 

568 help="OneStop reading regime for --onestop (default: ordinary).", 

569 ) 

570 src.add_argument( 

571 "--onestop-part", 

572 metavar="PART", 

573 action="append", 

574 choices=[ 

575 "Title", 

576 "Question_Preview", 

577 "Paragraph", 

578 "Questions", 

579 "Answers", 

580 "QA", 

581 "Feedback", 

582 ], 

583 help="OneStop trial part(s) for --onestop; repeatable (default: " 

584 "Paragraph). Loading several makes each part its own trial.", 

585 ) 

586 src.add_argument( 

587 "--onestop-variant", 

588 metavar="VARIANT", 

589 choices=["public", "lacclab"], 

590 default="public", 

591 help="OneStop source variant for --onestop: 'public' (OSF download) or " 

592 "'lacclab' (a local lab-processed export; no download).", 

593 ) 

594 

595 # DATA-54: MultiplEYE is held back from the beta. Its flags still parse and 

596 # work, so a script that already uses them keeps running (PRE-22's rule), but 

597 # `--help` — and the docs' reference, generated from it — don't list them. 

598 def mpe_help(text: str) -> str: 

599 return text if multipleye_enabled() else argparse.SUPPRESS 

600 

601 src.add_argument( 

602 "--source", 

603 metavar="NAME", 

604 choices=["multipleye"], 

605 help=mpe_help( 

606 "Load a native server-bundle corpus from its RAW export instead of " 

607 "raw words/fixations tables. Currently only 'multipleye' — pair with " 

608 "--export DIR. Renders through the same native loader (correct word " 

609 "boxes/text/page layout, 1920x1080 monitor) as the interactive viewer." 

610 ), 

611 ) 

612 src.add_argument( 

613 "--export", 

614 metavar="DIR", 

615 help=mpe_help( 

616 "Raw export root for --source (e.g. a MultiplEYE_*_* export dir with " 

617 "per-session scanpaths/ subfolders). Defaults to $MULTIPLEYE_DATA_DIR " 

618 "for --source multipleye." 

619 ), 

620 ) 

621 src.add_argument( 

622 "--no-question-screens", 

623 action="store_true", 

624 help=mpe_help( 

625 "--source multipleye: load the reading pages only, leaving out the " 

626 "comprehension-question screens (they are included by default, as " 

627 "screens of the same trial)." 

628 ), 

629 ) 

630 

631 src.add_argument( 

632 "--participant-metadata", 

633 metavar="FILE", 

634 help="Participant-level metadata table: one row per participant, an " 

635 "id column plus anything known about them. The join is validated and " 

636 "reported against the loaded participants, and the fields are added to " 

637 "--list-trials output.", 

638 ) 

639 

640 src.add_argument( 

641 "--trial-metadata", 

642 metavar="FILE", 

643 help="Trial-level metadata table: one row per trial, a " 

644 "trial-id column plus anything known about it. Validated and reported " 

645 "the same way, and its fields are added to --list-trials output.", 

646 ) 

647 src.add_argument( 

648 "--trial-metadata-participant-column", 

649 "--trial-metadata-reader-column", 

650 dest="trial_metadata_reader_column", 

651 metavar="COLUMN", 

652 help="Key the --trial-metadata table by participant AND trial, using " 

653 "this column as the participant id. Without it the table is keyed by " 

654 "trial id alone: a row describes a text, and every trial of it " 

655 "inherits that row. Never inferred: nothing in the file says which " 

656 "of the two a corpus means.", 

657 ) 

658 src.add_argument( 

659 "--text-metadata", 

660 metavar="FILE", 

661 help="Text-level metadata table: one row per text, a text-id column " 

662 "plus anything known about it. Validated and reported the same way, " 

663 "and its fields are added to --list-trials output. Never keyed by " 

664 "participant: a text is a stimulus, not something one participant owns.", 

665 ) 

666 

667 parser.add_argument( 

668 "-p", "--participant", help="Participant id (default: first available)." 

669 ) 

670 parser.add_argument( 

671 "-t", "--trial", help="Trial id (default: first for the participant)." 

672 ) 

673 parser.add_argument( 

674 "--screen", 

675 help="Screen/part id inside a multipart trial (default: first screen).", 

676 ) 

677 parser.add_argument( 

678 "--list-trials", 

679 action="store_true", 

680 help="Print every trial (participant, trial and text id) and exit; " 

681 "pass the trial id to -t.", 

682 ) 

683 parser.add_argument( 

684 "--list-parts", 

685 action="store_true", 

686 help="Print ordered multipart screens, optionally narrowed by -p/-t, and exit.", 

687 ) 

688 parser.add_argument( 

689 "--all-screens", 

690 action="store_true", 

691 help="Render every screen of the selected parent trial. Screen ids are " 

692 "inserted before the output extension.", 

693 ) 

694 parser.add_argument( 

695 "--screens", 

696 metavar="ID[,ID...]", 

697 help="Like --all-screens, but only these screens of the parent trial " 

698 "(comma-separated screen ids, e.g. Title,Paragraph); see --list-parts.", 

699 ) 

700 parser.add_argument( 

701 "--screen-transition", 

702 choices=["instant", "recorded"], 

703 default="instant", 

704 help="For --all-screens --animate, record zero or observed inter-screen " 

705 "delay in each output's metadata (default: instant).", 

706 ) 

707 parser.add_argument( 

708 "-o", 

709 "--output", 

710 metavar="PATH", 

711 help="Output file; format from extension (.html/.png/.svg/.pdf).", 

712 ) 

713 parser.add_argument( 

714 "--animate", 

715 action="store_true", 

716 help="Render the animated replay instead of the static figure (HTML only).", 

717 ) 

718 

719 viz = parser.add_argument_group( 

720 "visualization (draws the app's Scanpath design: fixations, saccades " 

721 "and the text; add --word-boxes, --heatmap or --fixation-index, or " 

722 "hide a layer with its --no-* flag)" 

723 ) 

724 # #374 F21: every layer switch defaults to None — "not given" — so only a 

725 # flag on the line overrides the API's default (the app's Scanpath design). 

726 viz.add_argument( 

727 "--word-boxes", 

728 dest="show_words", 

729 action="store_true", 

730 default=None, 

731 help="Draw the word boxes.", 

732 ) 

733 viz.add_argument( 

734 "--no-word-boxes", 

735 "--no-words", 

736 dest="show_words", 

737 action="store_false", 

738 default=None, 

739 help="Hide the word boxes (the default).", 

740 ) 

741 viz.add_argument( 

742 "--no-text", 

743 "--no-labels", 

744 dest="show_word_labels", 

745 action="store_false", 

746 default=None, 

747 help="Hide the reading text.", 

748 ) 

749 viz.add_argument( 

750 "--no-fixations", 

751 dest="show_fixations", 

752 action="store_false", 

753 default=None, 

754 help="Hide fixation markers.", 

755 ) 

756 viz.add_argument( 

757 "--fixation-index", 

758 dest="show_order", 

759 action="store_true", 

760 default=None, 

761 help="Number the fixations in reading order.", 

762 ) 

763 viz.add_argument( 

764 "--no-fixation-index", 

765 "--no-order", 

766 dest="show_order", 

767 action="store_false", 

768 default=None, 

769 help="Hide the fixation numbers (the default).", 

770 ) 

771 viz.add_argument( 

772 "--word-hover-fields", 

773 metavar="FIELDS", 

774 help="Comma-separated word columns shown on hover (e.g. " 

775 "text,word_id,gpt2_surprisal).", 

776 ) 

777 viz.add_argument( 

778 "--fixation-hover-fields", 

779 metavar="FIELDS", 

780 help="Comma-separated fixation columns shown on hover (e.g. " 

781 "order_in_trial,duration_ms,eye).", 

782 ) 

783 viz.add_argument( 

784 "--no-saccades", 

785 dest="show_saccades", 

786 action="store_false", 

787 default=None, 

788 help="Hide saccade lines.", 

789 ) 

790 viz.add_argument( 

791 "--heatmap", 

792 dest="show_heatmap", 

793 action="store_true", 

794 default=None, 

795 help="Draw the heatmap.", 

796 ) 

797 viz.add_argument( 

798 "--no-heatmap", 

799 dest="show_heatmap", 

800 action="store_false", 

801 default=None, 

802 help="Hide the heatmap (the default).", 

803 ) 

804 viz.add_argument( 

805 "--saccade-arrows", 

806 dest="show_saccade_arrows", 

807 action="store_true", 

808 default=None, 

809 help="Draw saccade direction arrowheads.", 

810 ) 

811 viz.add_argument( 

812 "--saccade-color", 

813 metavar="COLOR", 

814 help=f"Saccade line/arrow color, hex or CSS name (default: {SACCADE_COLOR}).", 

815 ) 

816 viz.add_argument( 

817 "--saccade-style", 

818 choices=list(SACCADE_DASH_OPTIONS.values()), 

819 help="Saccade line dash style (default: solid).", 

820 ) 

821 viz.add_argument( 

822 "--saccade-width", 

823 type=float, 

824 metavar="PX", 

825 help=f"Saccade line width in px, " 

826 f"{SACCADE_WIDTH_BOUNDS[0]:g}–{SACCADE_WIDTH_BOUNDS[1]:g} " 

827 f"(default: {DEFAULT_SACCADE_WIDTH:g}).", 

828 ) 

829 viz.add_argument( 

830 "--saccade-color-by-type", 

831 dest="saccade_color_by_type", 

832 action="store_true", 

833 help="Color each saccade by its reading type (forward / skip / " 

834 "refixation / return sweep / regression) instead of one uniform color.", 

835 ) 

836 viz.add_argument( 

837 "--saccade-color-by-direction", 

838 dest="saccade_color_by_direction", 

839 action="store_true", 

840 help="Color saccades forward vs. regression only — the two-way split " 

841 "between one uniform color and the full --saccade-color-by-type " 

842 "breakdown.", 

843 ) 

844 viz.add_argument( 

845 "--saccade-type-color", 

846 dest="saccade_type_colors", 

847 metavar="CLASS=COLOR", 

848 action="append", 

849 help="Override a reading-type color, e.g. --saccade-type-color " 

850 "regression=#000000 (repeatable; classes: forward, skip, refixation, " 

851 "return_sweep, regression). Implies --saccade-color-by-type, unless " 

852 "--saccade-color-by-direction is given — then it recolors that two-way " 

853 "split (its forward and regression colors).", 

854 ) 

855 viz.add_argument( 

856 "--no-saccade-type-legend", 

857 dest="saccade_type_legend", 

858 action="store_false", 

859 help="With --saccade-color-by-type: hide the saccade-type color key on " 

860 "the figure (the colored lines still draw). Legend shows by default.", 

861 ) 

862 viz.add_argument( 

863 "--fix-index-range", 

864 dest="fix_index_range", 

865 metavar="START:END", 

866 help="Draw only fixations START through END of the trial " 

867 "(1-based, both inclusive), e.g. --fix-index-range 1:40. Honored by " 

868 "--animate too, which then replays only that window, and by " 

869 "--compare-with, which windows both scanpaths (unless " 

870 "--compare-fix-index-range gives B its own).", 

871 ) 

872 viz.add_argument( 

873 "--highlight-column", 

874 dest="highlight_column", 

875 metavar="COLUMN", 

876 help="Boolean words column marking the text to highlight — the " 

877 "critical span (default: is_in_aspan, OneStop's answer span). Pass " 

878 "--highlight-column '' to highlight nothing. How it is drawn is " 

879 "--critical-span-style.", 

880 ) 

881 viz.add_argument( 

882 "--critical-span-style", 

883 dest="critical_span_style", 

884 choices=("mark-text", "mark-border", "none"), 

885 help="How the --highlight-column words are marked: mark-text recolors " 

886 "them, mark-border outlines their boxes, none draws neither " 

887 "(default: mark-text).", 

888 ) 

889 viz.add_argument( 

890 "--fixation-flag", 

891 dest="fixation_flags", 

892 action="append", 

893 metavar="SPEC", 

894 help="The app's Filters & highlights for fixations, repeatable. SPEC is " 

895 "CATEGORY=MODE[,threshold_ms=N][,symbol=S][,color=#RRGGBB] with " 

896 "CATEGORY one of short, long, oob (outside every word box), blink and " 

897 "MODE one of off, highlight, discard — e.g. --fixation-flag " 

898 "short=discard,threshold_ms=80. discard drops those fixations from the " 

899 "drawing only; measures and exports are untouched. threshold_ms applies " 

900 "to short/long only.", 

901 ) 

902 viz.add_argument( 

903 "--legend", 

904 dest="legend_layout", 

905 action="append", 

906 metavar="SPEC", 

907 help="Place one legend, repeatable. SPEC is KIND=POSITION[,ARRANGEMENT]" 

908 "[,SIZE] with KIND one of compare, saccades, colors (the fixation " 

909 "colour categories), size-key; POSITION one of auto, above, below, " 

910 "left, right, top-left, top-right, bottom-left, bottom-right (the last " 

911 "four inside the plot); ARRANGEMENT stacked or side-by-side; SIZE the " 

912 "text size in px — e.g. --legend saccades=right,stacked,14. Whether a " 

913 "legend is drawn at all is still its own switch.", 

914 ) 

915 viz.add_argument( 

916 "--saccade-classes", 

917 dest="saccade_classes", 

918 metavar="CLASSES", 

919 help="Draw only these reading classes, comma-separated, e.g. " 

920 "--saccade-classes regression,return_sweep (classes: forward, skip, " 

921 "refixation, return_sweep, regression, other). Hidden classes lose " 

922 "their line and their direction arrow. Default: all.", 

923 ) 

924 viz.add_argument( 

925 "--saccade-arcs", 

926 dest="saccade_arcs", 

927 action="store_true", 

928 help="Draw saccades as upward arcs (the linear-reading diagram) instead " 

929 "of straight connectors.", 

930 ) 

931 viz.add_argument( 

932 "--snap-fixations", 

933 dest="snap_fixations", 

934 action="store_true", 

935 help="Snap each fixation above the word it lands on instead of its raw " 

936 "gaze point.", 

937 ) 

938 viz.add_argument( 

939 "--illustration", 

940 action="store_true", 

941 help="Apply the clean schematic preset: snapped fixations, arced " 

942 "saccades, uniform colors, and no analytical overlays.", 

943 ) 

944 viz.add_argument( 

945 "--illustration-label", 

946 choices=["auto", "show", "hide"], 

947 default="auto", 

948 help="Auto-label transformed/schematic figures, force the label, or " 

949 "explicitly hide it (default: auto).", 

950 ) 

951 viz.add_argument( 

952 "--illustration-text", 

953 metavar="TEXT", 

954 help='The Illustration label\'s text (default: "Illustration · <reasons>").', 

955 ) 

956 # PRE-3: vertical drift correction. The algorithm list below is spelled out 

957 # for `--help`; `alignment.ALGORITHMS` stays the source of truth (the flag 

958 # validates against it via _drift_algorithm, and a test pins the two lists 

959 # together). 

960 # 

961 # PRE-21: the flags are not *added* while the feature is gated off, so 

962 # `--help` doesn't advertise something that would then refuse, and passing 

963 # one is an ordinary argparse "unrecognized arguments" error. `args` still 

964 # carries the attributes below via `set_defaults`, so no downstream branch 

965 # needs to know whether the flag exists. 

966 if drift_correction_enabled(): 

967 viz.add_argument( 

968 "--drift-correction", 

969 metavar="ALGORITHM", 

970 type=_drift_algorithm, 

971 default=None, 

972 help="Correct vertical drift before plotting: snap each " 

973 "fixation to its assigned text line and color the fixations by " 

974 "line, exactly like the app's 👁️ Fixations ▾ → Drift correction. " 

975 f"ALGORITHM is one of: {', '.join(ALGORITHMS)} " 

976 "(default: no correction). Static figures only — not honored with " 

977 "--animate.", 

978 ) 

979 viz.add_argument( 

980 "--drift-connectors", 

981 dest="drift_connectors", 

982 action="store_true", 

983 help="With --drift-correction: draw a faint line from each " 

984 "fixation's original y to its corrected one, so the size of the " 

985 "shift stays visible.", 

986 ) 

987 else: 

988 viz.set_defaults(drift_correction=None, drift_connectors=False) 

989 viz.add_argument( 

990 "--palette", 

991 type=_palette_name, 

992 metavar="{default,print,high-contrast}", 

993 help="Color palette for the marks (the app's Palette): default " 

994 "(colorblind-safe, Okabe–Ito), print (grayscale, survives a B&W print) " 

995 "or high-contrast. The app's own names work too. Individual " 

996 "--*-color flags override it.", 

997 ) 

998 viz.add_argument( 

999 "--color-by", 

1000 metavar="FIELD", 

1001 help=f"Fixation column to color by, e.g. duration_ms, or 'line' to " 

1002 f"color each fixation by its text line (same as --color-by-line). " 

1003 f"Default: '{UNIFORM_COLOR_FIELD}', one flat color, since marker size " 

1004 f"already shows duration.", 

1005 ) 

1006 viz.add_argument( 

1007 "--fixation-color", 

1008 metavar="COLOR", 

1009 help=f"Flat fixation marker color used when --color-by is " 

1010 f"{UNIFORM_COLOR_FIELD} (default: {DEFAULT_FIXATION_COLOR}).", 

1011 ) 

1012 viz.add_argument( 

1013 "--fixation-symbol", 

1014 choices=list(FIXATION_SYMBOLS), 

1015 help="Fixation marker shape. Unlike color, shape survives a " 

1016 f"grayscale print (default: {DEFAULT_FIXATION_SYMBOL}).", 

1017 ) 

1018 viz.add_argument( 

1019 "--heatmap-metric", 

1020 metavar="COLUMN", 

1021 help="Heatmap weighting: the fixation duration column — under your " 

1022 "file's name or as duration_ms (the default) — or counts.", 

1023 ) 

1024 viz.add_argument( 

1025 "--heatmap-style", 

1026 choices=["word-boxes", "interpolated"], 

1027 help="Heatmap geometry (default: word-boxes).", 

1028 ) 

1029 viz.add_argument( 

1030 "--heatmap-sigma", 

1031 type=float, 

1032 metavar="PX", 

1033 help="Gaussian σ in px for --heatmap-style interpolated (default: 2%% of " 

1034 "the data's larger span, at least 8 px).", 

1035 ) 

1036 viz.add_argument( 

1037 "--heatmap-colorscale", 

1038 metavar="NAME", 

1039 type=_colorscale_name, 

1040 help=f"Heatmap color scale, e.g. Greens (default: {DEFAULT_HEATMAP_COLORSCALE}).", 

1041 ) 

1042 viz.add_argument( 

1043 "--heatmap-norm", 

1044 choices=["linear", "log"], 

1045 help="Heatmap color scaling: linear (default) or log — log compresses " 

1046 "heavy-tailed dwell times so a few hot words don't wash out the rest.", 

1047 ) 

1048 viz.add_argument( 

1049 "--fixation-colorscale", 

1050 metavar="NAME", 

1051 type=_colorscale_name, 

1052 help=f"Color scale for --color-by, e.g. Viridis (default: " 

1053 f"{DEFAULT_FIXATION_COLORSCALE}).", 

1054 ) 

1055 viz.add_argument( 

1056 "--marker-size-range", 

1057 nargs=2, 

1058 type=int, 

1059 metavar=("MIN", "MAX"), 

1060 help="Min/max fixation marker size in px, e.g. 4 12 (default: 8 24). " 

1061 "Smaller ranges suit small thumbnails.", 

1062 ) 

1063 viz.add_argument( 

1064 "--marker-size-scale", 

1065 choices=("sqrt", "linear", "log", "relative"), 

1066 help="How duration sets marker size (default: sqrt). sqrt / linear / log " 

1067 "map --marker-duration-range onto --marker-size-range the same way for " 

1068 "every figure, so one duration is one size across trials, comparisons " 

1069 "and replays; sqrt makes marker area grow with duration. relative " 

1070 "stretches each figure from its own shortest to longest fixation.", 

1071 ) 

1072 viz.add_argument( 

1073 "--marker-duration-range", 

1074 nargs=2, 

1075 type=float, 

1076 metavar=("LO", "HI"), 

1077 help="Durations in ms given the smallest and largest marker on a fixed " 

1078 "scale (default: 50 600). Shorter and longer fixations clamp to them.", 

1079 ) 

1080 viz.add_argument( 

1081 "--no-duration-size-legend", 

1082 dest="duration_size_legend", 

1083 action="store_false", 

1084 help="Hide the duration-size key (reference circles labelled in ms) " 

1085 "drawn on a fixed --marker-size-scale.", 

1086 ) 

1087 viz.add_argument( 

1088 "--canvas", 

1089 metavar="WxH", 

1090 help="Monitor size in px, e.g. 2560x1440 (default: the source's screen — " 

1091 "2560x1440 for --sample/--onestop, 1680x1050 for --potec — else " 

1092 "estimated from the data).", 

1093 ) 

1094 viz.add_argument( 

1095 "--coordinate-grid", 

1096 action="store_true", 

1097 help="Overlay a monitor-pixel X/Y grid on the scanpath.", 

1098 ) 

1099 viz.add_argument( 

1100 "--coordinate-grid-spacing", 

1101 type=float, 

1102 metavar="PX", 

1103 help="Pin the major coordinate-grid interval in pixels. Implies " 

1104 "--coordinate-grid; omit for automatic 1/2/5×10ⁿ spacing.", 

1105 ) 

1106 # VIZ-4: overlay an image stimulus (a screenshot of the reading screen) under 

1107 # the scanpath. The API already supports background_image*; these expose it on 

1108 # the CLI. Works with --animate too. 

1109 viz.add_argument( 

1110 "--stimulus-image", 

1111 metavar="PATH", 

1112 help="Draw an image (PNG/JPG) as the stimulus background under the " 

1113 "scanpath. By default it's stretched to the image's own pixel " 

1114 "size (PNG) or the canvas; set --stimulus-image-size / -origin to place " 

1115 "a crop precisely in fixation coordinates.", 

1116 ) 

1117 viz.add_argument( 

1118 "--stimulus-image-size", 

1119 metavar="WxH", 

1120 help="Stimulus-image size in px, e.g. 1310x991 (default: the PNG's own " 

1121 "pixel size, else the canvas). Use with --stimulus-image.", 

1122 ) 

1123 viz.add_argument( 

1124 "--stimulus-image-origin", 

1125 metavar="X,Y", 

1126 help="Top-left of the stimulus image in monitor px, e.g. 305,44 (default: " 

1127 "0,0). Use with --stimulus-image to align a centered crop to the " 

1128 "fixation coordinates.", 

1129 ) 

1130 viz.add_argument( 

1131 "--stimulus-image-opacity", 

1132 type=float, 

1133 metavar="O", 

1134 help="Stimulus-image opacity 0.1–1.0 (default: 1.0 = opaque). Lower it to " 

1135 "dim a busy image so the fixations / saccades / word boxes read over it.", 

1136 ) 

1137 # EXP-20 — a flag for every figure option `render` could not say before, so 

1138 # the command the Share subtab prints (`code_snippet._CLI_EMITTERS`) draws 

1139 # the figure rather than naming what it left out. Each is spelled after its 

1140 # figure option and takes that option's own value; `_DIRECT_OPTION_FLAGS` / 

1141 # `_SWITCH_OPTION_FLAGS` below hand them to the builder. 

1142 viz.add_argument( 

1143 "--fixation-opacity", 

1144 type=float, 

1145 metavar="O", 

1146 help="Fixation marker opacity, 0.1–1.0 (default: 0.7, so overlapping " 

1147 "fixations show through).", 

1148 ) 

1149 viz.add_argument( 

1150 "--hollow-fixations", 

1151 action="store_true", 

1152 help="Draw the fixations as outlines instead of filled markers.", 

1153 ) 

1154 viz.add_argument( 

1155 "--color-by-line", 

1156 action="store_true", 

1157 help="Color each fixation by the text line it lands on (lines inferred " 

1158 "from the word boxes); overrides --color-by. Same as --color-by line.", 

1159 ) 

1160 viz.add_argument( 

1161 "--fixation-color-range", 

1162 nargs=2, 

1163 type=float, 

1164 metavar=("LO", "HI"), 

1165 help="Pin the --color-by color scale to LO..HI instead of the trial's " 

1166 "own range, so several figures share one scale.", 

1167 ) 

1168 viz.add_argument( 

1169 "--heatmap-range", 

1170 nargs=2, 

1171 type=float, 

1172 metavar=("LO", "HI"), 

1173 help="Pin the heatmap's color scale to LO..HI instead of the trial's " 

1174 "own range.", 

1175 ) 

1176 viz.add_argument( 

1177 "--order-font-size", 

1178 type=int, 

1179 metavar="PX", 

1180 help="Fixation index label size (default: 10).", 

1181 ) 

1182 viz.add_argument( 

1183 "--order-font-color", 

1184 metavar="COLOR", 

1185 help="Fixation index label color (default: #111111).", 

1186 ) 

1187 viz.add_argument( 

1188 "--text-color", 

1189 metavar="COLOR", 

1190 help="Reading-text color (default: #000000).", 

1191 ) 

1192 viz.add_argument( 

1193 "--highlight-text-color", 

1194 metavar="COLOR", 

1195 help="Color of the --highlight-column words under --critical-span-style " 

1196 "mark-text (default: #D55E00).", 

1197 ) 

1198 viz.add_argument( 

1199 "--span-border-color", 

1200 metavar="COLOR", 

1201 help="Box color under --critical-span-style mark-border (default: #000000).", 

1202 ) 

1203 viz.add_argument( 

1204 "--background-color", 

1205 metavar="COLOR", 

1206 help="Plot background color (default: #ffffff).", 

1207 ) 

1208 viz.add_argument( 

1209 "--line-spacing", 

1210 type=float, 

1211 metavar="N", 

1212 help="Line slots each word box stands for, which sizes the reading text " 

1213 "(default: 3 — OneStop's one blank line above and below).", 

1214 ) 

1215 viz.add_argument( 

1216 "--no-scale-text-to-boxes", 

1217 dest="scale_text_to_boxes", 

1218 action="store_false", 

1219 help="Draw the reading text at --font-size instead of sizing it from the " 

1220 "word boxes.", 

1221 ) 

1222 viz.add_argument( 

1223 "--word-hover-measure", 

1224 metavar="FIELD", 

1225 help="The reading measure a word's hover shows (default: " 

1226 "total_fixation_duration_ms; '' for none).", 

1227 ) 

1228 viz.add_argument( 

1229 "--word-heatmap-col", 

1230 metavar="COLUMN", 

1231 help="For a words-only dataset (no fixations): tint each word box by this " 

1232 "numeric words column — e.g. gpt2_surprisal — instead of its dwell time.", 

1233 ) 

1234 viz.add_argument( 

1235 "--word-heatmap-title", 

1236 metavar="TEXT", 

1237 help="Color-bar title for --word-heatmap-col (default: Value).", 

1238 ) 

1239 viz.add_argument( 

1240 "--x-field", 

1241 metavar="FIELD", 

1242 help="Fixation column on the x axis (default: x). A non-spatial one " 

1243 "draws a chart of the fixations instead of the scanpath.", 

1244 ) 

1245 viz.add_argument( 

1246 "--y-field", 

1247 metavar="FIELD", 

1248 help="Fixation column on the y axis (default: y).", 

1249 ) 

1250 viz.add_argument( 

1251 "--crop-to-data", 

1252 "--no-full-monitor", 

1253 dest="fit_to_monitor", 

1254 action="store_false", 

1255 help="Frame the axes on the data instead of the whole --canvas monitor " 

1256 "(the app's Crop to data).", 

1257 ) 

1258 viz.add_argument( 

1259 "--no-fixation-colorbar", 

1260 dest="show_fixation_colorbar", 

1261 action="store_false", 

1262 help="Leave out --color-by's color bar.", 

1263 ) 

1264 viz.add_argument( 

1265 "--fixation-colorbar-orientation", 

1266 choices=["vertical", "horizontal"], 

1267 help="Fixation color bar: beside the plot (vertical, default) or below it.", 

1268 ) 

1269 viz.add_argument( 

1270 "--fixation-colorbar-tickangle", 

1271 type=int, 

1272 metavar="DEG", 

1273 help="Fixation color bar: tick-label angle, -90–90 (default: 0).", 

1274 ) 

1275 viz.add_argument( 

1276 "--fixation-colorbar-tickfont-size", 

1277 type=int, 

1278 metavar="PX", 

1279 help="Fixation color bar: tick-label size (default: 12).", 

1280 ) 

1281 viz.add_argument( 

1282 "--no-heatmap-colorbar", 

1283 dest="show_heatmap_colorbar", 

1284 action="store_false", 

1285 help="Leave out the heatmap's color bar.", 

1286 ) 

1287 viz.add_argument( 

1288 "--heatmap-colorbar-orientation", 

1289 choices=["vertical", "horizontal"], 

1290 help="Heatmap color bar: beside the plot (vertical, default) or below it.", 

1291 ) 

1292 viz.add_argument( 

1293 "--heatmap-colorbar-tickangle", 

1294 type=int, 

1295 metavar="DEG", 

1296 help="Heatmap color bar: tick-label angle, -90–90 (default: 0).", 

1297 ) 

1298 viz.add_argument( 

1299 "--heatmap-colorbar-tickfont-size", 

1300 type=int, 

1301 metavar="PX", 

1302 help="Heatmap color bar: tick-label size (default: 12).", 

1303 ) 

1304 # v0.33.0's shared colour-bar flags, kept so a script written for it still 

1305 # runs (round 9) but not listed: `--colorbars` asked for what is now the 

1306 # default, and each `--colorbar-*` sets both bars unless the bar's own flag 

1307 # is given too (`_apply_shared_colorbar_flags`). 

1308 viz.add_argument( 

1309 "--colorbars", 

1310 dest="shared_colorbars", 

1311 action="store_true", 

1312 help=argparse.SUPPRESS, 

1313 ) 

1314 viz.add_argument( 

1315 "--colorbar-orientation", 

1316 dest="shared_colorbar_orientation", 

1317 choices=["vertical", "horizontal"], 

1318 help=argparse.SUPPRESS, 

1319 ) 

1320 for setting in ("tickangle", "tickfont_size"): 

1321 viz.add_argument( 

1322 f"--colorbar-{setting.replace('_', '-')}", 

1323 dest=f"shared_colorbar_{setting}", 

1324 type=int, 

1325 help=argparse.SUPPRESS, 

1326 ) 

1327 viz.add_argument( 

1328 "--raw-gaze", 

1329 metavar="PATH", 

1330 nargs="+", 

1331 help="Raw (sample-level) gaze table(s) to draw under the fixations, " 

1332 "columns auto-detected like --fixations (same formats; several " 

1333 "paths or a quoted glob concatenate). Static figures and --compare-with " 

1334 "comparisons, where each scanpath's samples take its color " 

1335 "(not --animate). On its own " 

1336 "(no other input) it is the dataset: its trials are listed and drawn " 

1337 "as recorded — no fixations are detected from the samples.", 

1338 ) 

1339 viz.add_argument( 

1340 "--no-raw-gaze", 

1341 dest="show_raw_gaze", 

1342 action="store_const", 

1343 const=False, 

1344 default=None, 

1345 help="Load the --raw-gaze table but hide its layer — the app's 🔵 Raw " 

1346 "gaze switch turned off. With raw gaze as the only input the figure then " 

1347 "draws no gaze.", 

1348 ) 

1349 viz.add_argument( 

1350 "--sample-raw-gaze", 

1351 action="store_true", 

1352 help="With --sample: draw the bundled demo's raw gaze (synthesized, for " 

1353 "one trial — the one the app overlays it on).", 

1354 ) 

1355 viz.add_argument( 

1356 "--raw-gaze-schema", 

1357 metavar="JSON", 

1358 help="Column mapping for the --raw-gaze table, replacing auto-detection " 

1359 "(same shape as --fix-schema); needed only when a column isn't " 

1360 "recognized.", 

1361 ) 

1362 viz.add_argument( 

1363 "--word-box-color", 

1364 metavar="COLOR", 

1365 help="Word-box outline color (default: #6c757d). A comparison outlines " 

1366 "each scanpath's boxes in its own color instead.", 

1367 ) 

1368 viz.add_argument( 

1369 "--word-box-line-opacity", 

1370 type=float, 

1371 metavar="O", 

1372 help="Word-box outline opacity, 0–1; 0 draws the fill only (default: 1). " 

1373 "Below 1 the outline color must be #rrggbb, #rgb or rgb(r, g, b).", 

1374 ) 

1375 viz.add_argument( 

1376 "--word-box-fill-color", 

1377 metavar="COLOR", 

1378 type=_fill_color, 

1379 help="Word-box fill color, drawn at --word-box-fill-opacity: #rrggbb, " 

1380 "#rgb or rgb(r, g, b) (default: #646464).", 

1381 ) 

1382 viz.add_argument( 

1383 "--word-box-fill-opacity", 

1384 type=float, 

1385 metavar="O", 

1386 help="Word-box fill opacity, 0–1; 0 draws outlines only (default: 0.05).", 

1387 ) 

1388 viz.add_argument( 

1389 "--raw-gaze-color", 

1390 metavar="COLOR", 

1391 help="Raw-gaze sample color (default: #888888). A comparison draws " 

1392 "each scanpath's samples in its own color instead.", 

1393 ) 

1394 viz.add_argument( 

1395 "--raw-gaze-marker-size", 

1396 type=float, 

1397 metavar="PX", 

1398 help="Raw-gaze sample size, 1–12 (default: 4).", 

1399 ) 

1400 viz.add_argument( 

1401 "--raw-gaze-opacity", 

1402 type=float, 

1403 metavar="O", 

1404 help="Raw-gaze sample opacity, 0.1–1.0 (default: 0.6).", 

1405 ) 

1406 viz.add_argument( 

1407 "--width", 

1408 type=int, 

1409 metavar="PX", 

1410 help="Image width in px for PNG/SVG/PDF (default: the figure's own " 

1411 "size). Use with --height for fixed-size thumbnails.", 

1412 ) 

1413 viz.add_argument( 

1414 "--height", 

1415 type=int, 

1416 metavar="PX", 

1417 help="Image height in px for PNG/SVG/PDF (default: the figure's own size).", 

1418 ) 

1419 viz.add_argument( 

1420 "--scale", 

1421 type=float, 

1422 default=2.0, 

1423 metavar="X", 

1424 help="Raster pixel-density multiplier (PNG/SVG/PDF; default: 2.0).", 

1425 ) 

1426 # #374 F28 — the app's Export → Current figure Width + DPI. 

1427 print_width = viz.add_mutually_exclusive_group() 

1428 print_width.add_argument( 

1429 "--width-mm", 

1430 type=float, 

1431 metavar="MM", 

1432 help="Print width of a PNG in mm, drawn at --dpi (replaces --scale).", 

1433 ) 

1434 print_width.add_argument( 

1435 "--width-in", 

1436 type=float, 

1437 metavar="IN", 

1438 help="Print width of a PNG in inches, drawn at --dpi.", 

1439 ) 

1440 viz.add_argument( 

1441 "--dpi", 

1442 type=int, 

1443 metavar="N", 

1444 help="Resolution of --width-mm / --width-in (default: 300).", 

1445 ) 

1446 viz.add_argument( 

1447 "--font-size", 

1448 type=int, 

1449 default=16, 

1450 metavar="PX", 

1451 help="Base figure font size (default: 16).", 

1452 ) 

1453 viz.add_argument( 

1454 "--font-family", 

1455 default=None, 

1456 metavar="NAME", 

1457 help=f"Font for all figure text (default: {FONT_FAMILY}).", 

1458 ) 

1459 viz.add_argument( 

1460 "--title", 

1461 default=None, 

1462 metavar="TEXT", 

1463 help="Title band stamped on the figure; off by default. The " 

1464 "figure grows to make room rather than shrinking the plot.", 

1465 ) 

1466 viz.add_argument( 

1467 "--caption", 

1468 default=None, 

1469 metavar="TEXT", 

1470 help="Caption band stamped on the figure; off by default.", 

1471 ) 

1472 viz.add_argument( 

1473 "--separable-layers", 

1474 action="store_true", 

1475 help="Also write the figure split into one file per layer (word boxes / " 

1476 "fixations / saccades / heatmap / labels / stimulus image) in a " 

1477 "`<output>_layers/` folder, so each can be restyled in Illustrator / " 

1478 "Inkscape. Static image output only (.svg/.pdf/.png); the layers register " 

1479 "when stacked.", 

1480 ) 

1481 viz.add_argument( 

1482 "--playback-speed", 

1483 type=float, 

1484 default=1.0, 

1485 metavar="X", 

1486 help="Animation speed multiplier for --animate (default: 1.0 = real time).", 

1487 ) 

1488 viz.add_argument( 

1489 "--no-autoplay", 

1490 dest="autoplay", 

1491 action="store_false", 

1492 help="With --animate: start the replay paused (press ▶ Play to run it). " 

1493 "By default the saved HTML autoplays on load at the playback speed.", 

1494 ) 

1495 viz.add_argument( 

1496 "--anim-grid-step-ms", 

1497 type=float, 

1498 default=None, 

1499 metavar="MS", 

1500 help="With --animate: emit a frame every MS of reading time (default: " 

1501 "100). Smaller is smoother and larger to export.", 

1502 ) 

1503 viz.add_argument( 

1504 "--anim-max-frames", 

1505 type=int, 

1506 default=None, 

1507 metavar="N", 

1508 help="With --animate: cap the frame count at N (default: 360). A long " 

1509 "trial coarsens the grid to stay under it.", 

1510 ) 

1511 # EXP-7: the same reproduction snippet the app's 🔗 Share subtab shows, 

1512 # for the invocation you just typed. Chiefly a *translation*: "I have this 

1513 # render command, give me the Python for my notebook." 

1514 viz.add_argument( 

1515 "--print-code", 

1516 choices=["python", "cli", "both"], 

1517 default=None, 

1518 metavar="FLAVOR", 

1519 help="Print the API / CLI code that reproduces this figure to stdout " 

1520 "(python | cli | both), then render as usual. Only the options " 

1521 "that differ from the defaults are written.", 

1522 ) 

1523 viz.add_argument( 

1524 "--print-code-explicit", 

1525 action="store_true", 

1526 help="With --print-code: write every figure option at its current " 

1527 "value instead of only the non-defaults.", 

1528 ) 

1529 

1530 # CMP-9 — compare mode's CLI surface. B comes either from the dataset 

1531 # already loaded (--compare-with alone) or from a second pair of tables. 

1532 # Deliberately files-only for the second dataset: twinning every source flag 

1533 # (--compare-potec, --compare-onestop + its regime/part/variant, …) would 

1534 # roughly double this parser for a narrow case, and `api.compare_scanpaths` 

1535 # takes B's frames directly, so a Python caller has no such limit. 

1536 cmp_group = parser.add_argument_group( 

1537 "comparison: draw a second scanpath beside or over the first" 

1538 ) 

1539 cmp_group.add_argument( 

1540 "--compare-with", 

1541 metavar="PARTICIPANT:TRIAL", 

1542 help="Compare against a second scanpath, named as participant:trial. " 

1543 "Taken from the loaded dataset unless --compare-words/--compare-fixations " 

1544 "name a second one.", 

1545 ) 

1546 cmp_group.add_argument( 

1547 "--compare-screen", 

1548 metavar="SCREEN_ID", 

1549 help="Screen of the second scanpath's multipart trial (default: its first " 

1550 "screen), looked up in its own trial. --screen picks the first " 

1551 "scanpath's. Each scanpath is drawn from one screen.", 

1552 ) 

1553 cmp_group.add_argument( 

1554 "--compare-layout", 

1555 choices=["overlay", "side-by-side", "stacked"], 

1556 default="overlay", 

1557 help="How the two scanpaths are arranged (default: overlay). Across two " 

1558 "datasets, overlay needs both canvases to be the same size — two " 

1559 "different canvases are refused rather than silently split, so pass " 

1560 "side-by-side or stacked for them. Matching canvases that a dataset " 

1561 "never recorded still overlay, with a warning.", 

1562 ) 

1563 cmp_group.add_argument( 

1564 "--compare-stimulus", 

1565 choices=["both", "a", "b"], 

1566 default="both", 

1567 help="On an overlay, whose word boxes and text to draw (default: both). " 

1568 "Two datasets' word boxes coincide only when the text is identical.", 

1569 ) 

1570 # EXP-8 §1. Named `-a` / `-b` after the `style_a` / `style_b` pair rather 

1571 # than `--label`, which would read as a sibling of `--no-labels` (the word 

1572 # labels on the stimulus) and mean something else entirely. 

1573 cmp_group.add_argument( 

1574 "--label-a", 

1575 metavar="TEXT", 

1576 help="Name for the FIRST scanpath in the legend and hover, instead of " 

1577 "the default. Applies to the comparison figure and the --animate " 

1578 "co-animation. Requires --label-b.", 

1579 ) 

1580 cmp_group.add_argument( 

1581 "--label-b", 

1582 metavar="TEXT", 

1583 help="Name for the SECOND scanpath in the legend and hover, instead " 

1584 "of the default. Requires --label-a.", 

1585 ) 

1586 cmp_group.add_argument( 

1587 "--compare-legend", 

1588 dest="show_legend", 

1589 action=argparse.BooleanOptionalAction, 

1590 default=None, 

1591 help="Draw the legend naming the two scanpaths (the app's A/B legend; " 

1592 "on by default, as in the app). Applies to the --animate co-animation " 

1593 "too.", 

1594 ) 

1595 # EXP-20. Named after `compare_scanpaths`'s `style_a` / `style_b`, like the 

1596 # `--label-a` / `--label-b` pair above. 

1597 for side, which in (("a", "FIRST"), ("b", "SECOND")): 

1598 cmp_group.add_argument( 

1599 f"--style-{side}", 

1600 dest=f"style_{side}", 

1601 action="append", 

1602 metavar="SPEC", 

1603 help=f"Styling for the {which} scanpath, repeatable: KEY=VALUE[,...]. " 

1604 "Colors (#RRGGBB): fix_color, saccade_color, box_color (word-box " 

1605 "outline; default fix_color), box_fill_color (default " 

1606 "--word-box-fill-color), raw_gaze_color (default fix_color). Also " 

1607 "heatmap_colorscale (default --heatmap-colorscale, on the shared " 

1608 f"range), saccade_style ({'|'.join(SACCADE_DASH_OPTIONS.values())}), " 

1609 "saccade_width (px), marker_size_range (MIN:MAX), opacity (0.1–1), " 

1610 f"hollow (true|false). E.g. --style-{side} fix_color=#D55E00,opacity=0.5. " 

1611 "--animate uses them too, except box_color, box_fill_color and " 

1612 "raw_gaze_color.", 

1613 ) 

1614 # CMP-24: scanpath B's own filters — the app's "· B" blocks under 🧹 Filter. 

1615 # A's are the ordinary --fixation-flag / --saccade-classes / 

1616 # --fix-index-range, which on their own filter both scanpaths. 

1617 cmp_group.add_argument( 

1618 "--compare-fixation-flag", 

1619 dest="compare_fixation_flags", 

1620 action="append", 

1621 metavar="SPEC", 

1622 help="Filters & highlights for the SECOND scanpath only, repeatable; " 

1623 "same SPEC as --fixation-flag, e.g. --compare-fixation-flag " 

1624 "short=discard,threshold_ms=80. Replaces --fixation-flag for B.", 

1625 ) 

1626 cmp_group.add_argument( 

1627 "--compare-saccade-classes", 

1628 dest="compare_saccade_classes", 

1629 metavar="CLASSES", 

1630 help="The reading classes the SECOND scanpath draws, comma-separated " 

1631 "(same names as --saccade-classes). Replaces --saccade-classes for B. " 

1632 "Not with --animate, which draws every class.", 

1633 ) 

1634 cmp_group.add_argument( 

1635 "--compare-fix-index-range", 

1636 dest="compare_fix_index_range", 

1637 metavar="START:END", 

1638 help="Draw only fixations START through END of the SECOND scanpath " 

1639 "(1-based, inclusive). Replaces --fix-index-range for B.", 

1640 ) 

1641 cmp_group.add_argument( 

1642 "--stimulus-image-b", 

1643 metavar="PATH", 

1644 help="The SECOND scanpath's stimulus image, for a side-by-side or stacked " 

1645 "comparison across two datasets (each panel draws its own page). Sized " 

1646 "and placed like --stimulus-image.", 

1647 ) 

1648 cmp_group.add_argument( 

1649 "--stimulus-image-size-b", 

1650 metavar="WxH", 

1651 help="Size of --stimulus-image-b in px (default: the PNG's own size, " 

1652 "else --compare-canvas, else --canvas).", 

1653 ) 

1654 cmp_group.add_argument( 

1655 "--stimulus-image-origin-b", 

1656 metavar="X,Y", 

1657 help="Top-left of --stimulus-image-b in the second screen's px (default: 0,0).", 

1658 ) 

1659 cmp_group.add_argument( 

1660 "--compare-words", 

1661 metavar="PATH", 

1662 nargs="+", 

1663 help="Words table(s) for the SECOND dataset. Same formats and " 

1664 "globbing as --words.", 

1665 ) 

1666 cmp_group.add_argument( 

1667 "--compare-fixations", 

1668 metavar="PATH", 

1669 nargs="+", 

1670 help="Fixations table(s) for the SECOND dataset. Same formats and " 

1671 "globbing as --fixations.", 

1672 ) 

1673 cmp_group.add_argument( 

1674 "--compare-raw-gaze", 

1675 metavar="PATH", 

1676 nargs="+", 

1677 help="Raw gaze table(s) for the SECOND dataset, drawn under B's scanpath. " 

1678 "Same formats as --raw-gaze; with no second dataset, " 

1679 "--raw-gaze already covers both scanpaths.", 

1680 ) 

1681 cmp_group.add_argument( 

1682 "--compare-dataset-name", 

1683 metavar="NAME", 

1684 default="Dataset B", 

1685 help="Label for the second dataset, used in the trace names (default: " 

1686 "'Dataset B').", 

1687 ) 

1688 cmp_group.add_argument( 

1689 "--compare-canvas", 

1690 metavar="WxH", 

1691 help="Second dataset's monitor size in px, e.g. 1680x1050. Read off its " 

1692 "data when omitted. An overlay, or an --animate co-animation, compares " 

1693 "this against --canvas.", 

1694 ) 

1695 # BUG-85 removed --monitor-mm / --viewing-distance and their --compare-* 

1696 # twins: they were recorded on the setup snapshots and read by nothing — 

1697 # CMP-11 is a gate on pixels, not a rescaling, so no figure used them. 

1698 return parser 

1699 

1700 

1701#: EXP-20 — flags whose value *is* the figure option's value, each named after 

1702#: the option (`--fixation-opacity` → `fixation_opacity`), so they reach the 

1703#: builder unchanged whenever given. 

1704_DIRECT_OPTION_FLAGS = ( 

1705 "marker_size_scale", 

1706 "fixation_opacity", 

1707 "order_font_size", 

1708 "order_font_color", 

1709 "text_color", 

1710 "highlight_text_color", 

1711 "span_border_color", 

1712 "background_color", 

1713 "line_spacing", 

1714 "word_hover_measure", 

1715 "x_field", 

1716 "y_field", 

1717 "fixation_colorbar_tickangle", 

1718 "fixation_colorbar_tickfont_size", 

1719 "heatmap_colorbar_tickangle", 

1720 "heatmap_colorbar_tickfont_size", 

1721 "illustration_text", 

1722 "word_box_color", 

1723 "word_box_line_opacity", 

1724 "word_box_fill_color", 

1725 "word_box_fill_opacity", 

1726 "raw_gaze_color", 

1727 "raw_gaze_marker_size", 

1728 "raw_gaze_opacity", 

1729 "word_heatmap_col", 

1730 "word_heatmap_title", 

1731) 

1732 

1733#: The direct options whose ``None`` is a choice, written ``''`` on the command 

1734#: line (`code_snippet._optional_valued`). 

1735_NONE_WHEN_EMPTY = frozenset( 

1736 {"word_hover_measure", "word_heatmap_col", "word_heatmap_title"} 

1737) 

1738 

1739#: …and the switches, as ``option → the value the flag sets``. Passed only when 

1740#: flipped, so a bare `render` keeps handing the builder its own defaults. 

1741_SWITCH_OPTION_FLAGS = { 

1742 "hollow_fixations": True, 

1743 "color_by_line": True, 

1744 "show_fixation_colorbar": False, 

1745 "show_heatmap_colorbar": False, 

1746 "scale_text_to_boxes": False, 

1747 "fit_to_monitor": False, 

1748 "duration_size_legend": False, 

1749} 

1750 

1751#: The keys `--style-a` / `--style-b` take, each with its value parser. 

1752_STYLE_KEYS = ( 

1753 "fix_color", 

1754 "saccade_color", 

1755 "box_color", 

1756 "box_fill_color", 

1757 "raw_gaze_color", 

1758 "heatmap_colorscale", 

1759 "saccade_style", 

1760 "saccade_width", 

1761 "marker_size_range", 

1762 "opacity", 

1763 "hollow", 

1764) 

1765 

1766 

1767def _parse_style_spec(specs: list[str] | None, flag: str) -> dict | None: 

1768 """``["fix_color=#aa0000,opacity=0.5"]`` → ``compare_scanpaths``'s style dict. 

1769 

1770 The inverse of `code_snippet._style_spec`. Colors are ``#RRGGBB`` only — 

1771 the value is split on commas, so a CSS ``rgb(…)`` could never arrive whole — 

1772 and every value is checked here rather than left to fail inside the builder. 

1773 """ 

1774 if not specs: 

1775 return None 

1776 dashes = tuple(SACCADE_DASH_OPTIONS.values()) 

1777 style: dict = {} 

1778 for spec in specs: 

1779 for option in (part.strip() for part in spec.split(",") if part.strip()): 

1780 name, sep, raw = option.partition("=") 

1781 name, raw = name.strip(), raw.strip() 

1782 try: 

1783 if not sep or name not in _STYLE_KEYS: 

1784 raise ValueError 

1785 if name in ( 

1786 "fix_color", 

1787 "saccade_color", 

1788 "box_color", 

1789 "box_fill_color", 

1790 "raw_gaze_color", 

1791 ): 

1792 if not re.fullmatch(r"#[0-9A-Fa-f]{6}", raw): 

1793 raise ValueError 

1794 style[name] = raw 

1795 elif name == "heatmap_colorscale": 

1796 try: 

1797 style[name] = _colorscale_name(raw) 

1798 except argparse.ArgumentTypeError: 

1799 raise ValueError from None 

1800 elif name == "saccade_style": 

1801 if raw not in dashes: 

1802 raise ValueError 

1803 style[name] = raw 

1804 elif name == "marker_size_range": 

1805 lo, hi = (int(part) for part in raw.split(":")) 

1806 style[name] = (min(lo, hi), max(lo, hi)) 

1807 elif name == "hollow": 

1808 if raw.lower() not in ("1", "0", "true", "false", "yes", "no"): 

1809 raise ValueError 

1810 style[name] = raw.lower() in ("1", "true", "yes") 

1811 else: # saccade_width, opacity 

1812 style[name] = float(raw) 

1813 except ValueError: 

1814 raise SystemExit( 

1815 f"{flag}: can't read {option!r}. Expected KEY=VALUE with KEY " 

1816 f"one of {', '.join(_STYLE_KEYS)} — colors as #RRGGBB, " 

1817 "heatmap_colorscale a Plotly color scale, " 

1818 f"saccade_style one of {', '.join(dashes)}, marker_size_range " 

1819 "as MIN:MAX, hollow as true/false." 

1820 ) 

1821 return style 

1822 

1823 

1824def _compare_labels(args) -> tuple[str, str] | None: 

1825 """The `--label-a` / `--label-b` pair, or None for the composed default. 

1826 

1827 Validated both-or-neither in `render`'s argument checks, so by the time 

1828 this runs, one flag being set means both are. 

1829 """ 

1830 if args.label_a is None: 

1831 return None 

1832 return (str(args.label_a), str(args.label_b)) 

1833 

1834 

1835def _parse_compare_with(value: str) -> tuple: 

1836 """``"p01:t03"`` → ``("p01", "t03")``, or a clear SystemExit. 

1837 

1838 Split on the LAST colon: a participant id may legitimately contain one 

1839 (MultiplEYE's ``001_ZH_CH_1_ET1`` style ids do not, but composite trial ids 

1840 joined with ``_`` sit next to corpora that use colons), while a trial id 

1841 naming a screen never trails one. 

1842 """ 

1843 text = str(value or "") 

1844 participant, sep, trial = text.rpartition(":") 

1845 if not sep or not participant.strip() or not trial.strip(): 

1846 raise SystemExit( 

1847 f"--compare-with expects PARTICIPANT:TRIAL, got {value!r}. " 

1848 "Use --list-trials to see the available pairs." 

1849 ) 

1850 return participant.strip(), trial.strip() 

1851 

1852 

1853def _compare_second_dataset(api, args, words, fixations): 

1854 """``(words_b, fixations_b)`` for the comparison — A's frames unless given. 

1855 

1856 Returns the *whole* second dataset, not one trial; both callers slice it. 

1857 """ 

1858 if not (args.compare_words or args.compare_fixations): 

1859 return words, fixations, False 

1860 try: 

1861 return ( 

1862 *api.load_scanpath_data( 

1863 args.compare_words, args.compare_fixations, names="canonical" 

1864 ), 

1865 True, 

1866 ) 

1867 except (ValueError, FileNotFoundError, OSError) as exc: 

1868 raise SystemExit( 

1869 "--compare-words/--compare-fixations: " 

1870 + _load_error_message(exc, schema_flags=False) 

1871 ) 

1872 

1873 

1874def _listed(table: pd.DataFrame, column_names: dict) -> pd.DataFrame: 

1875 """A trial or screen listing with its ids under the dataset's own names 

1876 (DATA-66) — what ``--list-trials`` / ``--list-parts`` print.""" 

1877 if not column_names: 

1878 return table 

1879 return _cn.as_written(table, _cn.across_tables(column_names).identity()) 

1880 

1881 

1882def _compare_animation_frames(api, args, words, fixations, canvas) -> dict: 

1883 """`animate_scanpath`'s keywords for scanpath B of a dual co-animation. 

1884 

1885 B's single-trial frames and, when they come from a second dataset, that 

1886 dataset's name and whatever screens the flags state. A co-animation draws 

1887 both readings on one clock in one coordinate space — an overlay — so the API 

1888 refuses two different screens on exactly the terms `compare_scanpaths` 

1889 refuses ``layout="overlay"``, reading a screen the flags don't state off its 

1890 data (CMP-21). This used to check only when ``--compare-canvas`` was given, 

1891 and co-animated without looking otherwise. 

1892 """ 

1893 from .data import respell_reading, trial_keys 

1894 from .utils import extract_trial 

1895 

1896 participant_b, trial_b = _parse_compare_with(args.compare_with) 

1897 words_b, fixations_b, cross_dataset = _compare_second_dataset( 

1898 api, args, words, fixations 

1899 ) 

1900 # An id spelled before composite ids escaped a `_` in a part still finds B. 

1901 participant_b, trial_b = respell_reading( 

1902 participant_b, trial_b, trial_keys(fixations_b) 

1903 ) 

1904 trial_words_b = extract_trial(words_b, participant_b, trial_b) 

1905 trial_fix_b = extract_trial(fixations_b, participant_b, trial_b) 

1906 if trial_fix_b.empty: 

1907 raise SystemExit( 

1908 f"No fixations for the compared scanpath participant={participant_b!r}, " 

1909 f"trial={trial_b!r}. Use --list-trials to see the available pairs." 

1910 ) 

1911 frames = {"words_b": trial_words_b, "fixations_b": trial_fix_b} 

1912 if args.compare_screen is not None: 

1913 frames["screen_b"] = args.compare_screen 

1914 if cross_dataset: 

1915 frames.update( 

1916 dataset_b=args.compare_dataset_name, 

1917 setup=_compare_setup_snapshot(canvas), 

1918 setup_b=_compare_setup_snapshot( 

1919 _parse_canvas(args.compare_canvas, "--compare-canvas") 

1920 ), 

1921 ) 

1922 return frames 

1923 

1924 

1925def _inferred_screen_hint(args, canvas: tuple | None) -> str: 

1926 """The flag that states a screen a refusal only read off the data (CMP-21). 

1927 

1928 `setups_comparable` says the readings were *recorded* on different screens, 

1929 but a screen no flag gives is the extent of that trial's data — rarely the 

1930 whole display — so `render` names the flag that states it. 

1931 """ 

1932 a_inferred, b_inferred = canvas is None, args.compare_canvas is None 

1933 if a_inferred and b_inferred: 

1934 return ( 

1935 " Neither screen was stated, so both were read off the data, which " 

1936 "rarely spans the whole screen; if they were shown on one, state it " 

1937 "with --canvas and --compare-canvas." 

1938 ) 

1939 if b_inferred: 

1940 return ( 

1941 " The second dataset's screen was read off its data, which rarely " 

1942 "spans the whole screen; if both were shown on one, state it with " 

1943 "--compare-canvas." 

1944 ) 

1945 if a_inferred: 

1946 return ( 

1947 " The first dataset's screen was read off its data, which rarely " 

1948 "spans the whole screen; if both were shown on one, state it with " 

1949 "--canvas." 

1950 ) 

1951 return "" 

1952 

1953 

1954def _compare_setup_snapshot(canvas: tuple | None): 

1955 """A `SetupSnapshot` for a canvas the caller stated, or ``None`` if silent. 

1956 

1957 ``None`` lets `api.compare_scanpaths` and `api.animate_scanpath` infer the 

1958 screen from the data, which is the right default — inventing a canvas here 

1959 would be a claim the caller never made. A stated canvas is a known screen: 

1960 ``MEASURED``. 

1961 """ 

1962 from .experimental_setup import Provenance, SetupSnapshot 

1963 

1964 if canvas is None: 

1965 return None 

1966 return SetupSnapshot( 

1967 canvas_width=int(canvas[0]), 

1968 canvas_height=int(canvas[1]), 

1969 screen_provenance=Provenance.MEASURED, 

1970 ) 

1971 

1972 

1973def _print_combined_rows(report) -> None: 

1974 """Say how many duplicate rows a metadata table folded together.""" 

1975 combined = int(getattr(report, "combined_rows", 0) or 0) 

1976 if combined: 

1977 print( 

1978 f" combined {combined} compatible duplicate row" 

1979 f"{'s' if combined != 1 else ''} (each field keeps the one value " 

1980 "they hold)", 

1981 file=sys.stderr, 

1982 ) 

1983 

1984 

1985def _format_trial_key(key) -> str: 

1986 """One trial-metadata report key as text (DATA-29). 

1987 

1988 A table keyed by reader *and* trial reports ``(participant, trial)`` pairs 

1989 while one keyed by trial alone reports bare ids, so the two shapes are 

1990 printed by the same helper rather than by two branches at each call site. 

1991 """ 

1992 if isinstance(key, tuple): 

1993 return "/".join(str(part) for part in key) 

1994 return str(key) 

1995 

1996 

1997def _parse_canvas(value: str | None, flag: str = "--canvas") -> tuple | None: 

1998 if not value: 

1999 return None 

2000 try: 

2001 w, h = (int(part) for part in value.lower().split("x")) 

2002 except ValueError: 

2003 raise SystemExit(f"{flag} expects WxH (e.g. 2560x1440), got {value!r}") 

2004 if w <= 0 or h <= 0: 

2005 raise SystemExit(f"{flag} dimensions must be positive, got {value!r}") 

2006 return (w, h) 

2007 

2008 

2009#: Figure options whose flag is not spelled after them (#374). 

2010_OPTION_FLAG_NAMES = { 

2011 "background_image": "--stimulus-image", 

2012 "background_image_size": "--stimulus-image-size", 

2013 "background_image_origin": "--stimulus-image-origin", 

2014 "background_image_opacity": "--stimulus-image-opacity", 

2015 "saccade_render_mode": "--saccade-arcs", 

2016 "fixation_snap_to_word": "--snap-fixations", 

2017 "heatmap_sigma_px": "--heatmap-sigma", 

2018 "saccade_class_colors": "--saccade-type-color", 

2019 "saccade_color_mode": "--saccade-color-by-type", 

2020 "show_connectors": "--drift-connectors", 

2021 "connector_y": "--drift-connectors", 

2022 "illustration_reasons": "--illustration", 

2023} 

2024 

2025 

2026def _flags_for(keys, overrides: dict) -> list[str]: 

2027 """The ``render`` flags that set figure options ``keys`` — the names a 

2028 warning should use, since those are what the user typed (#374).""" 

2029 by_dest: dict[str, list] = {} 

2030 for action in _render_parser()._actions: 

2031 by_dest.setdefault(action.dest, []).append(action) 

2032 names = [] 

2033 for key in keys: 

2034 flag = _OPTION_FLAG_NAMES.get(key) 

2035 if flag is None: 

2036 value = overrides.get(key) 

2037 actions = by_dest.get(key, []) 

2038 match = [ 

2039 a for a in actions if getattr(a, "const", None) is value 

2040 ] or actions 

2041 longs = [ 

2042 o for a in match[:1] for o in a.option_strings if o.startswith("--") 

2043 ] 

2044 flag = longs[0] if longs else key 

2045 names.append(flag) 

2046 return sorted(set(names)) 

2047 

2048 

2049def _require_image(path: str, flag: str) -> None: 

2050 """#374: a mistyped image path drew the figure without its stimulus.""" 

2051 if not str(path).startswith("data:") and not Path(path).is_file(): 

2052 raise SystemExit(f"{flag}: image not found: {path}. Nothing was written.") 

2053 

2054 

2055def _parse_saccade_classes_arg(value: str, flag: str) -> list[str]: 

2056 """A ``--saccade-classes``-style list → the classes in canonical order (VIZ-31).""" 

2057 names = [p.strip() for p in value.split(",") if p.strip()] 

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

2059 if unknown or not names: 

2060 raise SystemExit( 

2061 f"{flag} expects a comma-separated subset of " 

2062 f"{', '.join(SACCADE_CLASS_ORDER)}; got {value!r}." 

2063 ) 

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

2065 

2066 

2067def _parse_fix_index_range(value: str | None) -> tuple | None: 

2068 """``"1:40"`` → ``(1, 40)`` — VIZ-7's fixation-index window (both inclusive).""" 

2069 if not value: 

2070 return None 

2071 try: 

2072 lo, hi = (int(part) for part in value.replace("-", ":").split(":")) 

2073 except ValueError: 

2074 raise SystemExit( 

2075 f"--fix-index-range expects START:END (e.g. 1:40), got {value!r}" 

2076 ) 

2077 if lo < 1 or hi < lo: 

2078 raise SystemExit( 

2079 f"--fix-index-range needs 1 <= START <= END (1-based, both " 

2080 f"inclusive), got {value!r}" 

2081 ) 

2082 return (lo, hi) 

2083 

2084 

2085#: `--critical-span-style` choice → the settings vocabulary's own spelling. 

2086_CRITICAL_SPAN_STYLES = { 

2087 "mark-text": "Mark text", 

2088 "mark-border": "Mark border", 

2089 "none": "None", 

2090} 

2091 

2092#: PRE-2 category → whether it takes a `threshold_ms`. `oob` and `blink` are 

2093#: classified from geometry / the recording, not from a duration. 

2094_FIXCLASS_CATEGORIES = {"short": True, "long": True, "oob": False, "blink": False} 

2095 

2096 

2097def _parse_legend_layout(specs: list[str]) -> dict: 

2098 """``["saccades=right,stacked,14"]`` → the ``legend_layout`` dict. 

2099 

2100 One ``KIND=SPEC`` per flag; SPEC is ``plots.parse_legend_spec``'s spelling, 

2101 the one the ``legend_<kind>`` link parameters use too. 

2102 """ 

2103 from .plots import normalize_legend_layout, parse_legend_spec 

2104 

2105 layout: dict = {} 

2106 for spec in specs: 

2107 kind, _, text = spec.partition("=") 

2108 kind = kind.strip().lower().replace("-", "_") 

2109 try: 

2110 layout[kind] = parse_legend_spec(text) 

2111 normalize_legend_layout(layout) 

2112 except ValueError as exc: 

2113 raise SystemExit(f"--legend {spec!r}: {exc}") from None 

2114 return layout 

2115 

2116 

2117def _parse_fixation_flags(specs: list[str]) -> dict: 

2118 """``["short=discard,threshold_ms=80"]`` → the ``fixation_flags`` dict. 

2119 

2120 The app builds the same dict from its ``global_fixclass_*`` keys 

2121 (``controls._collect_fixation_flags``) and a saved config carries it whole, 

2122 so the CLI's job is only to spell one category per flag. Unspecified 

2123 categories are left out entirely, which the builder reads as *Off*. 

2124 """ 

2125 from .controls import _FIXCLASS_MODES, _OUT_OF_TEXT_MARKERS 

2126 

2127 modes = {mode.lower(): mode for mode in _FIXCLASS_MODES} 

2128 flags: dict = {} 

2129 for spec in specs: 

2130 head, _, rest = spec.partition(",") 

2131 category, _, mode = head.partition("=") 

2132 category, mode = category.strip().lower(), mode.strip().lower() 

2133 if category not in _FIXCLASS_CATEGORIES or mode not in modes: 

2134 raise SystemExit( 

2135 f"--fixation-flag expects CATEGORY=MODE with CATEGORY one of " 

2136 f"{', '.join(_FIXCLASS_CATEGORIES)} and MODE one of " 

2137 f"{', '.join(modes)}; got {spec!r}." 

2138 ) 

2139 entry: dict = {"mode": modes[mode]} 

2140 for option in (part.strip() for part in rest.split(",") if part.strip()): 

2141 name, _, raw = option.partition("=") 

2142 name, raw = name.strip(), raw.strip() 

2143 if name == "threshold_ms" and _FIXCLASS_CATEGORIES[category]: 

2144 try: 

2145 entry["threshold_ms"] = float(raw) 

2146 except ValueError: 

2147 raise SystemExit( 

2148 f"--fixation-flag threshold_ms expects a number, got {raw!r}." 

2149 ) 

2150 elif name == "symbol" and raw in _OUT_OF_TEXT_MARKERS: 

2151 entry["symbol"] = raw 

2152 elif name == "color" and re.fullmatch(r"#[0-9A-Fa-f]{6}", raw): 

2153 entry["color"] = raw 

2154 else: 

2155 raise SystemExit( 

2156 f"--fixation-flag: unknown or invalid option {option!r} for " 

2157 f"category {category!r}. Valid: " 

2158 + ("threshold_ms=N, " if _FIXCLASS_CATEGORIES[category] else "") 

2159 + f"symbol=<{'|'.join(_OUT_OF_TEXT_MARKERS)}>, color=#RRGGBB." 

2160 ) 

2161 flags[category] = entry 

2162 return flags 

2163 

2164 

2165def _snippet_source_from_args(args) -> SnippetSource: 

2166 """Which ``SnippetSource`` the render command's own input flags describe. 

2167 

2168 The inverse of the ``--sample`` / ``--words`` / ``--potec`` / … group, so 

2169 ``--print-code python`` hands back a loader call that reads the same corpus 

2170 this invocation just read.""" 

2171 from . import code_snippet as cs 

2172 

2173 if args.authoring: 

2174 return cs.SnippetSource( 

2175 kind=cs.SOURCE_AUTHOR, label="authored", options={"path": args.authoring} 

2176 ) 

2177 if args.potec: 

2178 return cs.SnippetSource( 

2179 kind=cs.SOURCE_POTEC, label="PoTeC", options={"root": args.potec} 

2180 ) 

2181 if args.eyegenbench: 

2182 return cs.SnippetSource( 

2183 kind=cs.SOURCE_BENCHMARK, 

2184 label=args.eyegenbench_dataset or "benchmark", 

2185 options={ 

2186 "root": args.eyegenbench, 

2187 "dataset": args.eyegenbench_dataset or "", 

2188 }, 

2189 ) 

2190 if args.onestop: 

2191 return cs.SnippetSource( 

2192 kind=cs.SOURCE_ONESTOP, 

2193 label="OneStop", 

2194 options={ 

2195 "root": args.onestop, 

2196 "regime": args.onestop_regime, 

2197 "variant": args.onestop_variant, 

2198 "parts": list(args.onestop_part or ["Paragraph"]), 

2199 }, 

2200 ) 

2201 if args.source == "multipleye": 

2202 return cs.SnippetSource( 

2203 kind=cs.SOURCE_MULTIPLEYE, 

2204 label="MultiplEYE", 

2205 options={"root": args.export or "data/MultiplEYE"}, 

2206 ) 

2207 if args.words or args.fixations: 

2208 options = { 

2209 "words": list(args.words or []), 

2210 "fixations": list(args.fixations or []), 

2211 } 

2212 # The mapping the files were read with, or the snippet's loader 

2213 # auto-detects columns this command was told to read otherwise. 

2214 for option, flag in ( 

2215 ("word_schema", "--word-schema"), 

2216 ("fix_schema", "--fix-schema"), 

2217 ): 

2218 schema = _parse_schema_arg(getattr(args, option, None), flag) 

2219 if schema: 

2220 options[option] = schema 

2221 return cs.SnippetSource(kind=cs.SOURCE_FILES, label="files", options=options) 

2222 if args.raw_gaze and not args.sample: 

2223 # VIZ-45: --raw-gaze as the only input — the samples are the dataset. 

2224 return cs.SnippetSource( 

2225 kind=cs.SOURCE_RAW_GAZE, 

2226 label="raw gaze", 

2227 options={"raw_gaze": list(args.raw_gaze)}, 

2228 ) 

2229 return cs.SnippetSource(kind=cs.SOURCE_DEMO, label="Bundled Demo") 

2230 

2231 

2232def _print_reproduction_code( 

2233 api, args, overrides: dict, canvas, participant, trial, *, raw_gaze: bool = False 

2234): 

2235 """EXP-7: print the snippet that rebuilds the figure this invocation renders. 

2236 

2237 Built from ``overrides`` — the very dict handed to the builder a few lines 

2238 below — merged onto the kind's effective defaults, so the printed recipe is 

2239 a serializer over the figure's own input rather than a second reading of 

2240 ``args``. ``--palette`` is expanded first, exactly as ``api`` expands it, or 

2241 the snippet would name a preset the figure had already resolved away. 

2242 """ 

2243 from . import code_snippet as cs 

2244 

2245 kind = ( 

2246 "animation" 

2247 if args.animate 

2248 else "comparison" 

2249 if args.compare_with is not None 

2250 else "static" 

2251 ) 

2252 settings = {**api.figure_options(kind), **api._expand_palette(dict(overrides))} 

2253 # EXP-20: `plot_scanpath` turns the raw-gaze layer on for the frame it is 

2254 # handed, so the flag never reaches `overrides`; the snippet reads it here. 

2255 # VIZ-48: `compare_scanpaths` too — whose samples may all be B's. 

2256 draws_b = kind == "comparison" and bool(args.compare_raw_gaze) 

2257 if (raw_gaze or draws_b) and kind in {"static", "comparison"}: 

2258 settings["show_raw_gaze"] = args.show_raw_gaze is not False 

2259 # These two are passed to `animate_scanpath` beside the overrides rather 

2260 # than through them, so they never reached `settings` — a straight silent 

2261 # drop of two real `figure_options("animation")` keys. 

2262 if kind == "animation": 

2263 for name, value in ( 

2264 ("anim_grid_step_ms", args.anim_grid_step_ms), 

2265 ("anim_max_frames", args.anim_max_frames), 

2266 ): 

2267 if value is not None: 

2268 settings[name] = value 

2269 # EXP-20: the co-animation's B-side keywords ride `anim_kwargs`, not 

2270 # `overrides`, for the same reason — so a printed recipe for `--animate 

2271 # --compare-with … --label-a …` quietly lost its labels and stimulus. 

2272 if args.compare_with is not None: 

2273 settings["compare_stimulus"] = args.compare_stimulus 

2274 labels = _compare_labels(args) 

2275 if labels is not None: 

2276 settings["label_a"], settings["label_b"] = labels 

2277 compare = None 

2278 if args.compare_with is not None: 

2279 compare_participant, compare_trial = _parse_compare_with(args.compare_with) 

2280 second = bool(args.compare_words or args.compare_fixations) 

2281 compare = cs.CompareTarget( 

2282 participant=compare_participant, 

2283 trial=compare_trial, 

2284 screen=args.compare_screen, 

2285 layout=args.compare_layout, 

2286 compare_stimulus=args.compare_stimulus, 

2287 labels=_compare_labels(args), 

2288 # CMP-8: B came from a second corpus exactly when its own frames 

2289 # were given, so B's ids are that corpus's — the same caveat the 

2290 # app's Share panel raises. 

2291 dataset=(str(args.compare_dataset_name or "Dataset B") if second else ""), 

2292 # EXP-21: the tables and screen this invocation read B from, so the 

2293 # printed recipe loads the same B rather than placeholders. 

2294 canvas=_parse_canvas(args.compare_canvas, "--compare-canvas") 

2295 if second 

2296 else None, 

2297 words=tuple(args.compare_words or ()) if second else (), 

2298 fixations=tuple(args.compare_fixations or ()) if second else (), 

2299 # VIZ-48: B's own samples, drawn only when this run loads them. 

2300 raw_gaze=( 

2301 tuple(args.compare_raw_gaze) 

2302 if second and args.compare_raw_gaze 

2303 else None 

2304 ), 

2305 primary_raw_gaze=raw_gaze, 

2306 ) 

2307 state = cs.FigureState( 

2308 kind=kind, 

2309 settings=settings, 

2310 participant=str(participant), 

2311 trial=str(trial), 

2312 screen=args.screen, 

2313 canvas=canvas, 

2314 base_font_size=args.font_size, 

2315 font_family=args.font_family or FONT_FAMILY, 

2316 title=args.title or "", 

2317 caption=args.caption or "", 

2318 illustration_label=str(args.illustration_label or "auto").lower(), 

2319 drift_correction=args.drift_correction, 

2320 drift_connectors=bool(args.drift_connectors), 

2321 # A builder *parameter* rather than a figure keyword, like the drift 

2322 # pair above — so it has to be named here or the printed recipe would 

2323 # silently widen the window back to the whole trial. 

2324 fix_index_range=_parse_fix_index_range(args.fix_index_range), 

2325 playback_speed=args.playback_speed, 

2326 autoplay=args.autoplay, 

2327 compare=compare, 

2328 ) 

2329 # `save_figure`'s own defaults, so a plain invocation writes a plain save 

2330 # line and only a deliberate `--width` / `--scale` shows up. 

2331 save_kwargs = { 

2332 name: value 

2333 for name, value in _save_kwargs(args).items() 

2334 if value is not None and not (name == "scale" and value == 2.0) 

2335 } 

2336 if "width_mm" in save_kwargs or "width_in" in save_kwargs: 

2337 save_kwargs.pop("scale", None) # the print width replaces it 

2338 caveats = [] 

2339 if args.screens: 

2340 caveats.append( 

2341 "--screens renders the screens named; the snippet rebuilds one " 

2342 "screen. Use api.render_parent_trial(..., screens=[...]) for the set." 

2343 ) 

2344 elif args.all_screens: 

2345 caveats.append( 

2346 "--all-screens renders every screen of the parent trial; the " 

2347 "snippet rebuilds one screen. Use api.render_parent_trial(...) for " 

2348 "the whole set." 

2349 ) 

2350 # A `render` input that changes the figure but has no `FigureState` field is 

2351 # named, never dropped — the same rule `cli_unsupported` applies in the other 

2352 # direction. Translating a command into a notebook cell has to be honest 

2353 # about the parts of the command that didn't come along. 

2354 if args.image_root: 

2355 caveats.append( 

2356 "--image-root / --image-pattern resolve one stimulus image per row; " 

2357 "the snippet names no image. Pass the resolved file as " 

2358 "`background_image=`." 

2359 ) 

2360 if args.all_screens and args.screen_transition != "instant": 

2361 caveats.append( 

2362 f"--screen-transition {args.screen_transition} only affects the " 

2363 "--all-screens / --screens metadata, which the single-figure " 

2364 "snippet omits." 

2365 ) 

2366 source = _snippet_source_from_args(args) 

2367 if args.raw_gaze: 

2368 # EXP-20: the raw-gaze table is part of the data half — the snippet's 

2369 # loader reads it beside the corpus, under the same mapping. 

2370 extra = {"raw_gaze": list(args.raw_gaze)} 

2371 schema = _parse_schema_arg(args.raw_gaze_schema, "--raw-gaze-schema") 

2372 if schema is not None: 

2373 extra["raw_gaze_schema"] = schema 

2374 source = replace(source, options={**source.options, **extra}) 

2375 code = cs.reproduction_code( 

2376 source, 

2377 state, 

2378 explicit=bool(args.print_code_explicit), 

2379 output=args.output or "scanpath.png", 

2380 save_kwargs=save_kwargs, 

2381 extra_caveats=tuple(caveats), 

2382 ) 

2383 blocks = [] 

2384 if args.print_code in ("python", "both"): 

2385 blocks.append(code.python) 

2386 if args.print_code in ("cli", "both"): 

2387 cli = code.cli 

2388 if code.cli_unsupported: 

2389 cli += "\n# No `render` flag for: " + ", ".join(code.cli_unsupported) 

2390 blocks.append(cli) 

2391 # stdout, not stderr: this is the requested output, and it has to survive a 

2392 # `> recipe.py` redirect while the progress lines stay on the terminal. 

2393 print("\n\n".join(blocks)) 

2394 for note in code.caveats: 

2395 print(f"Note: {note}", file=sys.stderr) 

2396 

2397 

2398def _parse_xy(value: str | None, flag: str = "--stimulus-image-origin") -> tuple | None: 

2399 """Parse an ``X,Y`` origin (VIZ-4 --stimulus-image-origin) to floats.""" 

2400 if not value: 

2401 return None 

2402 try: 

2403 x, y = (float(part) for part in value.split(",")) 

2404 except ValueError: 

2405 raise SystemExit(f"{flag} expects X,Y (e.g. 305,44), got {value!r}") 

2406 return (x, y) 

2407 

2408 

2409def _load_multipleye_render( 

2410 export: str | None, 

2411 participant: str | None, 

2412 trial: str | None, 

2413 *, 

2414 list_only: bool = False, 

2415 include_question_screens: bool = True, 

2416): 

2417 """Native MultiplEYE load for `render --source multipleye`. 

2418 

2419 Loads the same normalized frames as the interactive viewer's MultiplEYE 

2420 server-bundle source (correct word boxes/text + image-origin coordinate 

2421 offsets), straight from the RAW export — never the review app's reshaped 

2422 per-pid parquet. 

2423 

2424 ``export`` is the raw export root (defaults to ``$MULTIPLEYE_DATA_DIR``). 

2425 ``participant`` is the session label, resolved case-insensitively (the review 

2426 app passes it lowercased, e.g. ``001_zh_ch_1_et2``; export ids are uppercase). 

2427 

2428 ``trial`` selection (DATA-24: a trial is one *reading of a stimulus*, and its 

2429 pages / question screens are screens inside it, picked with ``--screen``): 

2430 * a literal trial id — the stimulus, ``Lit_Alchemist_4`` — is used as-is; 

2431 * an integer N (the review app's trial number) selects the stimulus whose 

2432 ``trial_num == N``. With no ``--screen`` that renders its first screen, 

2433 i.e. reading page 1 — the single representative page a thumbnail shows. 

2434 

2435 ``include_question_screens`` mirrors the loader kwarg (``--no-question-screens``). 

2436 

2437 Returns ``(words, fixations, participant_id, trial_id)`` — the resolved ids 

2438 are passed straight to ``api.plot_scanpath``. With ``list_only`` the trial is 

2439 left unresolved (the caller just prints the combos). 

2440 """ 

2441 import os 

2442 

2443 from .datasets import multipleye_inventory 

2444 

2445 root = (export or os.environ.get("MULTIPLEYE_DATA_DIR", "")).strip() 

2446 if not root: 

2447 raise SystemExit( 

2448 "--source multipleye needs --export DIR (or $MULTIPLEYE_DATA_DIR) " 

2449 "pointing at a MultiplEYE raw export root." 

2450 ) 

2451 

2452 # Resolve the (possibly lowercased) pid to the export's canonical session id. 

2453 sessions, _ = multipleye_inventory(root, fixation_source="scanpaths") 

2454 if not sessions: 

2455 raise FileNotFoundError( 

2456 f"No MultiplEYE sessions under {root} — expected a raw export root " 

2457 "with per-session subfolders under scanpaths/." 

2458 ) 

2459 session = None 

2460 if participant is not None: 

2461 want = str(participant).strip().lower() 

2462 session = next((s for s in sessions if s.lower() == want), None) 

2463 if session is None: 

2464 raise ValueError( 

2465 f"No MultiplEYE session matching participant {participant!r} " 

2466 f"(available: {', '.join(sessions)})." 

2467 ) 

2468 

2469 from .datasets import load_multipleye 

2470 

2471 # Load only the requested session (sub-second); all sessions for --list-trials 

2472 # without a -p. Normalized frames, exactly like the viewer. 

2473 words, fixations = load_multipleye( 

2474 root, 

2475 sessions=[session] if session else None, 

2476 fixation_source="scanpaths", 

2477 include_question_screens=include_question_screens, 

2478 names="canonical", 

2479 ) 

2480 if list_only: 

2481 return words, fixations, session, None 

2482 

2483 pid = session 

2484 tid = trial 

2485 known = set(fixations["trial_id"].astype(str)) if not fixations.empty else set() 

2486 if trial is not None and str(trial) not in known: 

2487 # An integer trial number (the review app's id): map trial_num → the 

2488 # stimulus trial it names. Its screens are selected with --screen / 

2489 # --all-screens; with neither, the first screen (reading page 1) renders, 

2490 # which is the single representative page a thumbnail shows. 

2491 try: 

2492 trial_num = int(str(trial)) 

2493 except (TypeError, ValueError): 

2494 raise ValueError( 

2495 f"--trial {trial!r} is neither a MultiplEYE trial id (the " 

2496 "stimulus, e.g. Lit_Alchemist_4) nor an integer trial number." 

2497 ) 

2498 if "trial_num" not in fixations.columns: 

2499 raise ValueError( 

2500 "MultiplEYE fixations carry no 'trial_num' column — pass the " 

2501 "stimulus trial id to --trial instead." 

2502 ) 

2503 match = fixations[fixations["trial_num"].astype("Int64") == trial_num] 

2504 if match.empty: 

2505 avail = sorted( 

2506 fixations["trial_num"].dropna().astype(int).unique().tolist() 

2507 ) 

2508 raise ValueError( 

2509 f"No MultiplEYE trial_num={trial_num} for session {session!r} " 

2510 f"(available: {avail})." 

2511 ) 

2512 tid = min(match["trial_id"].astype(str).unique()) 

2513 

2514 return words, fixations, pid, tid 

2515 

2516 

2517def _apply_shared_colorbar_flags(args: argparse.Namespace) -> None: 

2518 """Hand v0.33.0's shared ``--colorbar-*`` values to each bar that was not 

2519 given its own; a bar's own flag wins, wherever it sits on the line. 

2520 ``--colorbars`` needs nothing: both bars are shown unless a ``--no-*`` 

2521 leaves one out, which it still does beside ``--colorbars``.""" 

2522 for setting in ("orientation", "tickangle", "tickfont_size"): 

2523 shared = getattr(args, f"shared_colorbar_{setting}") 

2524 if shared is None: 

2525 continue 

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

2527 if getattr(args, f"{bar}_colorbar_{setting}") is None: 

2528 setattr(args, f"{bar}_colorbar_{setting}", shared) 

2529 

2530 

2531def _save_kwargs(args) -> dict: 

2532 """`api.save_figure`'s size keywords from the render flags; the print 

2533 width only when one was given (#374, F28).""" 

2534 kwargs = {"scale": args.scale, "width": args.width, "height": args.height} 

2535 if args.width_mm is not None: 

2536 kwargs["width_mm"] = args.width_mm 

2537 if args.width_in is not None: 

2538 kwargs["width_in"] = args.width_in 

2539 if args.dpi is not None: 

2540 kwargs["dpi"] = args.dpi 

2541 return kwargs 

2542 

2543 

2544def _parse_screen_list(value: str | None) -> tuple[str, ...] | None: 

2545 """``--screens``' comma-separated ids, or ``None`` when not given.""" 

2546 if value is None: 

2547 return None 

2548 screens = tuple(part.strip() for part in value.split(",") if part.strip()) 

2549 if not screens: 

2550 raise SystemExit("--screens names no screen; pass e.g. --screens Paragraph.") 

2551 return screens 

2552 

2553 

2554def render(argv: list[str]) -> None: 

2555 # Bound, not inlined: DATA-27's --eyegenbench branch calls 

2556 # `parser.error(...)` further down to reject a missing --eyegenbench-dataset. 

2557 parser = _render_parser() 

2558 args = parser.parse_args(argv) 

2559 _apply_shared_colorbar_flags(args) 

2560 # #374 F28: a print width sizes a PNG; say so before the data loads. 

2561 printed = args.width_mm is not None or args.width_in is not None 

2562 if args.dpi is not None and not printed: 

2563 parser.error("--dpi is the resolution of --width-mm / --width-in; add one.") 

2564 if printed and args.output and not str(args.output).lower().endswith(".png"): 

2565 parser.error("--width-mm / --width-in size a PNG; write to a .png file.") 

2566 # Validate everything derivable from argv before the (possibly minutes-long 

2567 # on full corpora) data load. 

2568 corpus_inputs = [ 

2569 args.sample, 

2570 bool(args.authoring), 

2571 bool(args.potec), 

2572 bool(args.eyegenbench), 

2573 bool(args.onestop), 

2574 bool(args.source), 

2575 ] 

2576 # VIZ-45: your own tables are words and/or fixations — or raw gaze alone, 

2577 # for a dataset recorded as samples only. Beside any other input, 

2578 # --raw-gaze stays what it always was: a layer drawn over that input. 

2579 own_tables = bool(args.words or args.fixations) or ( 

2580 bool(args.raw_gaze) and not any(corpus_inputs) 

2581 ) 

2582 if sum([*corpus_inputs, own_tables]) != 1: 

2583 # Only the inputs `--help` lists: the DATA-54/55 held-back sources still 

2584 # count towards the guard, but the message doesn't advertise them. 

2585 inputs = ["--sample", "--authoring PATH", "--potec DIR"] 

2586 if benchmark_corpora_enabled(): 

2587 inputs.append("--eyegenbench DIR --eyegenbench-dataset NAME") 

2588 inputs.append("--onestop DIR") 

2589 if multipleye_enabled(): 

2590 inputs.append("--source NAME [--export DIR]") 

2591 raise SystemExit( 

2592 f"Provide exactly one input: {', '.join(inputs)}, or your own tables " 

2593 "(--words and/or --fixations; one of them is enough for " 

2594 "single-report datasets; --raw-gaze alone for raw gaze only)." 

2595 ) 

2596 if not (args.list_trials or args.list_parts) and not args.output: 

2597 raise SystemExit("Missing -o/--output (or use --list-trials/--list-parts).") 

2598 if args.trial_parts_manifest and not (args.words or args.fixations): 

2599 raise SystemExit("--trial-parts-manifest requires --words and/or --fixations.") 

2600 # EXP-13: a mapping describes one of *your* tables, so it needs that table. 

2601 if args.word_schema is not None and not args.words: 

2602 raise SystemExit("--word-schema maps the --words table; pass --words too.") 

2603 if args.fix_schema is not None and not args.fixations: 

2604 raise SystemExit( 

2605 "--fix-schema maps the --fixations table; pass --fixations too." 

2606 ) 

2607 word_schema = _parse_schema_arg(args.word_schema, "--word-schema") 

2608 fix_schema = _parse_schema_arg(args.fix_schema, "--fix-schema") 

2609 # EXP-20: raw gaze is a third table, from a file or — for the demo — the 

2610 # bundled one. Checked before the load, like the two schemas above. 

2611 if args.sample_raw_gaze and not args.sample: 

2612 raise SystemExit( 

2613 "--sample-raw-gaze draws the bundled demo's raw gaze; it needs " 

2614 "--sample. Pass your own table with --raw-gaze PATH." 

2615 ) 

2616 if args.sample_raw_gaze and args.raw_gaze: 

2617 raise SystemExit("Pass --raw-gaze PATH or --sample-raw-gaze, not both.") 

2618 if args.show_raw_gaze is False and not (args.raw_gaze or args.sample_raw_gaze): 

2619 print( 

2620 "Warning: --no-raw-gaze hides the raw-gaze layer, and no --raw-gaze " 

2621 "table was given; ignoring it.", 

2622 file=sys.stderr, 

2623 ) 

2624 if args.compare_raw_gaze and args.compare_with is None: 

2625 raise SystemExit( 

2626 "--compare-raw-gaze is scanpath B's raw gaze; pass --compare-with " 

2627 "PARTICIPANT:TRIAL too." 

2628 ) 

2629 if args.raw_gaze_schema is not None and not args.raw_gaze: 

2630 raise SystemExit( 

2631 "--raw-gaze-schema maps the --raw-gaze table; pass --raw-gaze too." 

2632 ) 

2633 raw_gaze_schema = _parse_schema_arg(args.raw_gaze_schema, "--raw-gaze-schema") 

2634 # EXP-20: these describe the second scanpath of a comparison, so on their 

2635 # own there is nothing for them to style — refused, like a lone --label-a. 

2636 compare_only = [ 

2637 flag 

2638 for flag, given in ( 

2639 ("--compare-legend", args.show_legend is not None), 

2640 ("--style-a", args.style_a), 

2641 ("--style-b", args.style_b), 

2642 ("--stimulus-image-b", args.stimulus_image_b), 

2643 ("--stimulus-image-size-b", args.stimulus_image_size_b), 

2644 ("--stimulus-image-origin-b", args.stimulus_image_origin_b), 

2645 ("--compare-fixation-flag", args.compare_fixation_flags), 

2646 ("--compare-saccade-classes", args.compare_saccade_classes), 

2647 ("--compare-fix-index-range", args.compare_fix_index_range), 

2648 ("--compare-screen", args.compare_screen), 

2649 ) 

2650 if given 

2651 ] 

2652 if compare_only and args.compare_with is None: 

2653 raise SystemExit( 

2654 f"{', '.join(compare_only)} style a comparison of two scanpaths; " 

2655 "pass --compare-with PARTICIPANT:TRIAL too." 

2656 ) 

2657 # A comparison is one figure of two readings; --all-screens writes one figure 

2658 # per child screen of a multipart trial. There is no defined pairing between 

2659 # the two, and without this guard the compare branch left `figures` unbound 

2660 # and the run died on an UnboundLocalError instead of saying so. 

2661 # --screens is --all-screens cut to the screens named. 

2662 chosen_screens = _parse_screen_list(args.screens) 

2663 if chosen_screens is not None: 

2664 if args.screen is not None: 

2665 raise SystemExit( 

2666 "--screen renders one screen and --screens several; pass one of them." 

2667 ) 

2668 args.all_screens = True 

2669 if args.compare_with is not None and args.all_screens: 

2670 raise SystemExit( 

2671 "--compare-with cannot be combined with --all-screens or --screens: a comparison " 

2672 "is a single figure of two trials. Render one screen at a time with " 

2673 "--screen SCREEN_ID." 

2674 ) 

2675 # `compare_scanpaths` takes `labels` as a pair or not at all — there is no 

2676 # way to name one side and leave the builder to compose the other — so a 

2677 # lone flag is refused rather than silently dropped. 

2678 if (args.label_a is None) != (args.label_b is None): 

2679 raise SystemExit( 

2680 "--label-a and --label-b go together: name both scanpaths, or neither." 

2681 ) 

2682 # ENG-53: each panel of a split layout draws its own reading's stimulus, so 

2683 # there is no shared set of word boxes to pick from — the builder ignores 

2684 # the choice there. Say so, rather than letting the docs' old "side by side, 

2685 # showing only B's word boxes" example quietly draw both. 

2686 if ( 

2687 args.compare_with is not None 

2688 and not args.animate 

2689 and args.compare_layout != "overlay" 

2690 and args.compare_stimulus != "both" 

2691 ): 

2692 print( 

2693 f"Warning: --compare-stimulus {args.compare_stimulus} only applies to " 

2694 f"--compare-layout overlay; each {args.compare_layout} panel draws its " 

2695 "own trial's stimulus. Ignoring it.", 

2696 file=sys.stderr, 

2697 ) 

2698 if args.label_a is not None and args.compare_with is None: 

2699 raise SystemExit( 

2700 "--label-a/--label-b label the two scanpaths of a comparison or of " 

2701 "an --animate co-animation; both need --compare-with." 

2702 ) 

2703 canvas = _parse_canvas(args.canvas) 

2704 if args.coordinate_grid_spacing is not None and args.coordinate_grid_spacing <= 0: 

2705 raise SystemExit("--coordinate-grid-spacing must be a positive number.") 

2706 if args.animate and args.output and not args.output.lower().endswith(".html"): 

2707 raise SystemExit( 

2708 "--animate writes interactive HTML — use a .html output. For GIF or " 

2709 "MP4, use the app's Export, or " 

2710 "scanpath_studio.animation_export.export_animation in Python." 

2711 ) 

2712 # PRE-3: the connectors draw *between* the original and corrected y, so on 

2713 # their own there is nothing to connect. Warn rather than fail — the render 

2714 # is still valid, just uncorrected. 

2715 if args.drift_connectors and not args.drift_correction: 

2716 print( 

2717 "Warning: --drift-connectors has no effect without " 

2718 "--drift-correction ALGORITHM; ignoring it.", 

2719 file=sys.stderr, 

2720 ) 

2721 

2722 from . import api 

2723 

2724 # DATA-66: `render` works in the internal names, which the metadata joins, 

2725 # the trial checks and every option below are written against, and keeps 

2726 # each loader's map of the dataset's own names (`ScanpathData.column_names`) 

2727 # for what it prints, writes and draws. 

2728 column_names: dict = {} 

2729 # Each fixed-screen source's monitor is `code_snippet.source_canvas`, the 

2730 # table `api.figure_code` reads too, so both flavours of a recipe agree. 

2731 if args.sample: 

2732 data = api.load_sample_data(names="canonical") 

2733 words, fixations = data 

2734 column_names = dict(data.column_names) 

2735 canvas = canvas or source_canvas(SOURCE_DEMO) 

2736 elif args.authoring: 

2737 try: 

2738 words, fixations = api.load_authored_scanpath(args.authoring) 

2739 except FileNotFoundError: 

2740 raise SystemExit(f"--authoring: file not found: {args.authoring}") from None 

2741 except (ValueError, OSError) as exc: 

2742 raise SystemExit(str(exc)) from exc 

2743 canvas = canvas or source_canvas(SOURCE_AUTHOR) 

2744 elif args.potec: 

2745 from .data import split_composite_id 

2746 from .datasets import load_potec 

2747 

2748 try: 

2749 data = load_potec( 

2750 args.potec, 

2751 names="canonical", 

2752 # Narrow the 900-file load when the trial is known — its 

2753 # text is the part after the reader (`0_b0` → `b0`); reader 

2754 # ids always need the full reader list for --list-trials so 

2755 # only narrow with an explicit -p. 

2756 readers=[args.participant] if args.participant else None, 

2757 texts=[split_composite_id(args.trial)[-1]] if args.trial else None, 

2758 download=True, 

2759 ) 

2760 except (ValueError, FileNotFoundError, OSError) as exc: 

2761 raise SystemExit(str(exc)) 

2762 words, fixations = data 

2763 column_names = dict(data.column_names) 

2764 canvas = canvas or source_canvas(SOURCE_POTEC) 

2765 elif args.eyegenbench: 

2766 if not args.eyegenbench_dataset: 

2767 parser.error("--eyegenbench requires --eyegenbench-dataset NAME") 

2768 from .eyegenbench import eyegenbench_monitor, load_eyegenbench 

2769 

2770 try: 

2771 data = load_eyegenbench( 

2772 args.eyegenbench, dataset=args.eyegenbench_dataset, names="canonical" 

2773 ) 

2774 words, fixations = data 

2775 column_names = dict(data.column_names) 

2776 # `eyegenbench_monitor` answers None for a corpus whose manifest 

2777 # only carries the invented default screen, so `render` falls back 

2778 # to the data's own extents there — the same call the app's picker 

2779 # entry makes (I3). Before this the CLI drew those corpora at 

2780 # 1920x1080 while the app drew them at data extents. 

2781 canvas = canvas or eyegenbench_monitor( 

2782 args.eyegenbench, args.eyegenbench_dataset 

2783 ) 

2784 except (ValueError, FileNotFoundError, OSError) as exc: 

2785 raise SystemExit(str(exc)) 

2786 elif args.onestop: 

2787 from .datasets import load_onestop 

2788 

2789 try: 

2790 data = load_onestop( 

2791 args.onestop, 

2792 regime=args.onestop_regime, 

2793 parts=args.onestop_part, # None → Paragraph default 

2794 variant=args.onestop_variant, 

2795 # The lacclab variant is local (no download); the public one 

2796 # fetches the chosen regime + parts from OSF on first use. 

2797 download=args.onestop_variant == "public", 

2798 names="canonical", 

2799 ) 

2800 except (ValueError, FileNotFoundError, OSError) as exc: 

2801 raise SystemExit(str(exc)) 

2802 words, fixations = data 

2803 column_names = dict(data.column_names) 

2804 canvas = canvas or source_canvas(SOURCE_ONESTOP) 

2805 elif args.source == "multipleye": 

2806 try: 

2807 words, fixations, args.participant, args.trial = _load_multipleye_render( 

2808 args.export, 

2809 args.participant, 

2810 args.trial, 

2811 list_only=args.list_trials, 

2812 include_question_screens=not args.no_question_screens, 

2813 ) 

2814 except (ValueError, FileNotFoundError, OSError) as exc: 

2815 raise SystemExit(str(exc)) 

2816 # Same authoritative monitor the viewer's MultiplEYE bundle source snaps 

2817 # to — coords are offset onto the centered stimulus on the real screen. 

2818 canvas = canvas or source_canvas(SOURCE_MULTIPLEYE) 

2819 else: 

2820 manifest = None 

2821 if args.trial_parts_manifest: 

2822 try: 

2823 manifest = json.loads( 

2824 Path(args.trial_parts_manifest).read_text(encoding="utf-8") 

2825 ) 

2826 except (OSError, json.JSONDecodeError) as exc: 

2827 raise SystemExit(f"Could not read trial-parts manifest: {exc}") from exc 

2828 # EXP-13: the one input branch that had no guard, so a missing file or 

2829 # an unrecognised column ended the run in a traceback — whose hint 

2830 # named a `word_schema=` argument the command line could not pass. 

2831 if not (args.words or args.fixations): 

2832 # VIZ-45: raw gaze alone. The two frames are the empty canonical 

2833 # ones `load_scanpath_data` returns for a table it is not given; 

2834 # the samples are loaded below with every other raw-gaze input. 

2835 from .data import empty_fixations_frame, empty_words_frame 

2836 

2837 words, fixations = empty_words_frame(), empty_fixations_frame() 

2838 else: 

2839 try: 

2840 data = api.load_scanpath_data( 

2841 args.words, 

2842 args.fixations, 

2843 word_schema=word_schema, 

2844 fix_schema=fix_schema, 

2845 image_root=args.image_root, 

2846 image_pattern=args.image_pattern, 

2847 trial_parts_manifest=manifest, 

2848 keep_columns=args.keep_columns, 

2849 names="canonical", 

2850 ) 

2851 except (ValueError, OSError) as exc: 

2852 raise SystemExit(_load_error_message(exc)) from exc 

2853 words, fixations = data 

2854 column_names = dict(data.column_names) 

2855 

2856 if args.image_root and not (args.words or args.fixations): 

2857 from .data import resolve_stimulus_image_paths 

2858 

2859 try: 

2860 words = resolve_stimulus_image_paths( 

2861 words, args.image_root, args.image_pattern 

2862 ) 

2863 fixations = resolve_stimulus_image_paths( 

2864 fixations, args.image_root, args.image_pattern 

2865 ) 

2866 except ValueError as exc: 

2867 raise SystemExit(str(exc)) from exc 

2868 

2869 # EXP-20: raw gaze is a third table. Loaded before the metadata joins and 

2870 # --list-trials, so a raw-gaze-only input joins and lists its own trials 

2871 # (VIZ-45). 

2872 raw_gaze = None 

2873 if args.raw_gaze or args.sample_raw_gaze: 

2874 try: 

2875 raw_gaze = ( 

2876 api.load_sample_raw_gaze() 

2877 if args.sample_raw_gaze 

2878 else api.load_raw_gaze(args.raw_gaze, raw_gaze_schema=raw_gaze_schema) 

2879 ) 

2880 except (ValueError, OSError) as exc: 

2881 raise SystemExit("--raw-gaze: " + _load_error_message(exc)) from exc 

2882 # DATA-66: its own names kept beside it, the frame itself canonical. 

2883 if (found := _cn.frame_names(raw_gaze)) is not None: 

2884 column_names[found[0]] = found[1] 

2885 raw_gaze = _cn.to_canonical_frame(raw_gaze) 

2886 

2887 # VIZ-45: what the metadata tables are joined against — the samples, when 

2888 # they are the only table, or every join would report "0 matched". 

2889 join_frame = ( 

2890 raw_gaze 

2891 if raw_gaze is not None and fixations.empty and words.empty 

2892 else fixations 

2893 ) 

2894 

2895 # DATA-20: attaching a participant table headlessly is worth doing for the 

2896 # join report alone — a mistyped id column or a cohort file from the wrong 

2897 # study is exactly what you want to hear about before rendering 300 figures. 

2898 if args.participant_metadata: 

2899 try: 

2900 attached = api.load_participant_metadata( 

2901 args.participant_metadata, participants=join_frame 

2902 ) 

2903 except (ValueError, FileNotFoundError, OSError) as exc: 

2904 raise SystemExit(str(exc)) 

2905 report = attached.report 

2906 print( 

2907 f"Participant metadata: {_count(len(attached.fields), 'field')} " 

2908 f"({', '.join(attached.names)}) for " 

2909 f"{_count(len(report.matched), 'participant')}.", 

2910 file=sys.stderr, 

2911 ) 

2912 for label, ids in ( 

2913 ("in the data, not the table", report.only_in_data), 

2914 ("in the table, not the data", report.only_in_table), 

2915 ("rows that disagree (left empty)", report.conflicting), 

2916 ): 

2917 if ids: 

2918 print(f" {len(ids)} {label}: {', '.join(ids)}", file=sys.stderr) 

2919 _print_combined_rows(report) 

2920 

2921 # DATA-29: the same, one grain down. A trial table's report is worth more 

2922 # than the participant one, not less: getting the *key* wrong is silent 

2923 # (a table keyed by trial alone still joins, it just means something else), 

2924 # and "0 matched" here is the one thing that says so out loud. 

2925 attached_trials = None 

2926 if args.trial_metadata: 

2927 try: 

2928 attached_trials = api.load_trial_metadata( 

2929 args.trial_metadata, 

2930 participant_column=args.trial_metadata_reader_column, 

2931 trials=join_frame, 

2932 ) 

2933 except (ValueError, FileNotFoundError, OSError) as exc: 

2934 raise SystemExit(str(exc)) 

2935 report = attached_trials.report 

2936 keyed = ( 

2937 "participant + trial" 

2938 if attached_trials.keyed_by_participant 

2939 else "trial id" 

2940 ) 

2941 print( 

2942 f"Trial metadata: {_count(len(attached_trials.fields), 'field')} " 

2943 f"({', '.join(attached_trials.names)}) for " 

2944 f"{_count(len(report.matched), 'trial')}, keyed by {keyed}.", 

2945 file=sys.stderr, 

2946 ) 

2947 for label, keys in ( 

2948 ("in the data, not the table", report.only_in_data), 

2949 ("in the table, not the data", report.only_in_table), 

2950 ("rows that disagree (left empty)", report.conflicting), 

2951 ): 

2952 if keys: 

2953 shown = ", ".join(_format_trial_key(key) for key in keys[:20]) 

2954 # Plain "..." rather than an ellipsis character: this line 

2955 # goes to stderr, and a Windows console in its default 

2956 # code page prints the single glyph as a replacement mark. 

2957 more = "" if len(keys) <= 20 else f", ... (+{len(keys) - 20})" 

2958 print(f" {len(keys)} {label}: {shown}{more}", file=sys.stderr) 

2959 _print_combined_rows(report) 

2960 elif args.trial_metadata_reader_column: 

2961 raise SystemExit( 

2962 "--trial-metadata-participant-column needs --trial-metadata: it names a " 

2963 "column in that table." 

2964 ) 

2965 

2966 # The third grain, flat like the participant table — a text is a 

2967 # stimulus, so it always joins on text id alone. 

2968 attached_texts = None 

2969 if args.text_metadata: 

2970 try: 

2971 attached_texts = api.load_text_metadata( 

2972 args.text_metadata, texts=words if not words.empty else join_frame 

2973 ) 

2974 except (ValueError, FileNotFoundError, OSError) as exc: 

2975 raise SystemExit(str(exc)) 

2976 report = attached_texts.report 

2977 print( 

2978 f"Text metadata: {_count(len(attached_texts.fields), 'field')} " 

2979 f"({', '.join(attached_texts.names)}) for " 

2980 f"{_count(len(report.matched), 'text')}.", 

2981 file=sys.stderr, 

2982 ) 

2983 for label, ids in ( 

2984 ("in the data, not the table", report.only_in_data), 

2985 ("in the table, not the data", report.only_in_table), 

2986 ("rows that disagree (left empty)", report.conflicting), 

2987 ): 

2988 if ids: 

2989 print(f" {len(ids)} {label}: {', '.join(ids)}", file=sys.stderr) 

2990 _print_combined_rows(report) 

2991 

2992 if args.list_trials: 

2993 combos = api.list_trials(words, fixations, raw_gaze=raw_gaze) 

2994 if ( 

2995 args.participant_metadata 

2996 or attached_trials is not None 

2997 or attached_texts is not None 

2998 ): 

2999 from scanpath_studio import metadata as _metadata 

3000 

3001 if args.participant_metadata: 

3002 combos = _metadata.project(attached, combos) 

3003 if attached_trials is not None: 

3004 combos = _metadata.project_trials(attached_trials, combos) 

3005 if attached_texts is not None: 

3006 # `list_trials`'s combos is deliberately just 

3007 # (participant_id, trial_id) — text_id isn't part of its 

3008 # public contract — so bring it in here, from whichever 

3009 # frame has it, before projecting the text table onto it. 

3010 source = ( 

3011 fixations 

3012 if not fixations.empty 

3013 else words 

3014 if not words.empty 

3015 else join_frame 

3016 ) 

3017 if "text_id" in source.columns: 

3018 combos = combos.merge( 

3019 source[ 

3020 ["participant_id", "trial_id", "text_id"] 

3021 ].drop_duplicates(), 

3022 on=["participant_id", "trial_id"], 

3023 how="left", 

3024 ) 

3025 combos = _metadata.project_texts(attached_texts, combos) 

3026 # #374 F21: the text id too — the id the app shows a trial by. 

3027 source = fixations if not fixations.empty else words 

3028 if ( 

3029 "text_id" not in combos.columns 

3030 and source is not None 

3031 and {"participant_id", "trial_id", "text_id"} <= set(source.columns) 

3032 ): 

3033 combos = combos.merge( 

3034 source[["participant_id", "trial_id", "text_id"]].drop_duplicates( 

3035 ["participant_id", "trial_id"] 

3036 ), 

3037 on=["participant_id", "trial_id"], 

3038 how="left", 

3039 ) 

3040 print( 

3041 f"{len(combos)} trial{'' if len(combos) == 1 else 's'}. Pass the " 

3042 "participant as -p and the trial as -t; the app shows a trial by " 

3043 "its participant and text.", 

3044 file=sys.stderr, 

3045 ) 

3046 # DATA-66: the ids under the dataset's own names. 

3047 print(_listed(combos, column_names).to_string(index=False)) 

3048 return 

3049 if args.list_parts: 

3050 parts = api.list_parts( 

3051 words, fixations, args.participant, args.trial, raw_gaze=raw_gaze 

3052 ) 

3053 if parts.empty: 

3054 print("No multipart screens (the selected data is single-screen).") 

3055 else: 

3056 print(_listed(parts, column_names).to_string(index=False)) 

3057 return 

3058 

3059 try: 

3060 # A given -p/-t must match exactly (mistyped ids are errors, never 

3061 # silently swapped for another trial); only genuinely unspecified 

3062 # parts default to the first available combo, like the app. VIZ-45: 

3063 # a trial only the raw gaze has is one of them. 

3064 participant, trial = api._resolve_trial( 

3065 words, 

3066 fixations, 

3067 args.participant, 

3068 args.trial, 

3069 default_first=True, 

3070 raw_gaze=raw_gaze, 

3071 ) 

3072 except ValueError as exc: 

3073 raise SystemExit(str(exc)) 

3074 trial_fixations_missing = raw_gaze is not None and ( 

3075 fixations.empty 

3076 or not ( 

3077 (fixations["participant_id"].astype(str) == str(participant)) 

3078 & (fixations["trial_id"].astype(str) == str(trial)) 

3079 ).any() 

3080 ) 

3081 if trial_fixations_missing: 

3082 # VIZ-45: say it in the command's own terms before the API says it in 

3083 # Python's. Both modes are made of fixations, and none are detected 

3084 # from the samples. Decided for this trial, not the dataset. 

3085 for flag, given in ( 

3086 ("--animate", args.animate), 

3087 ("--compare-with", args.compare_with is not None), 

3088 ): 

3089 if given: 

3090 raise SystemExit( 

3091 f"{flag} draws fixations, and this trial has none — only " 

3092 "raw gaze samples, which Scanpath Studio does not turn into " 

3093 f"fixations. Drop {flag} to draw the samples." 

3094 ) 

3095 against = f", compared with {args.compare_with}" if args.compare_with else "" 

3096 print( 

3097 f"Rendering participant={participant} trial={trial}{against}", 

3098 file=sys.stderr, 

3099 ) 

3100 

3101 overrides = { 

3102 key: getattr(args, key) 

3103 for key in ( 

3104 "show_words", 

3105 "show_word_labels", 

3106 "show_fixations", 

3107 "show_order", 

3108 "show_saccades", 

3109 "show_heatmap", 

3110 "show_saccade_arrows", 

3111 ) 

3112 # Only a flag on the line overrides the API's default (#374, F21). 

3113 if getattr(args, key) is not None 

3114 } 

3115 # VIZ-45: only when given — `plot_scanpath` turns the layer on for the 

3116 # frame it is handed, and an override is the one thing that says off. 

3117 if args.show_raw_gaze is False: 

3118 overrides["show_raw_gaze"] = False 

3119 if args.coordinate_grid or args.coordinate_grid_spacing is not None: 

3120 overrides["show_coordinate_grid"] = True 

3121 overrides["coordinate_grid_spacing"] = args.coordinate_grid_spacing 

3122 if args.word_hover_fields is not None: 

3123 overrides["word_hover_fields"] = [ 

3124 field.strip() 

3125 for field in args.word_hover_fields.split(",") 

3126 if field.strip() 

3127 ] 

3128 if args.fixation_hover_fields is not None: 

3129 overrides["fixation_hover_fields"] = [ 

3130 field.strip() 

3131 for field in args.fixation_hover_fields.split(",") 

3132 if field.strip() 

3133 ] 

3134 # VIZ-18: the palette rides along as an override; api._expand_palette turns 

3135 # it into colour kwargs and lets any explicit --*-color below win. 

3136 if args.palette: 

3137 overrides["palette"] = args.palette 

3138 if args.color_by: 

3139 overrides["color_by"] = args.color_by 

3140 if args.fixation_color: # VIZ-17 flat fixation colour 

3141 overrides["fixation_color"] = args.fixation_color 

3142 if args.fixation_symbol: # VIZ-15 marker shape 

3143 overrides["fixation_symbol"] = args.fixation_symbol 

3144 if args.heatmap_metric: 

3145 overrides["heatmap_metric"] = args.heatmap_metric 

3146 if args.heatmap_style: 

3147 overrides["heatmap_style"] = { 

3148 "word-boxes": "Word boxes", 

3149 "interpolated": "Interpolated", 

3150 }[args.heatmap_style] 

3151 if args.heatmap_sigma is not None: 

3152 overrides["heatmap_sigma_px"] = args.heatmap_sigma 

3153 if args.heatmap_colorscale: 

3154 overrides["heatmap_colorscale"] = args.heatmap_colorscale 

3155 if args.heatmap_norm: 

3156 overrides["heatmap_norm"] = args.heatmap_norm.capitalize() # linear→Linear 

3157 if args.fixation_colorscale: 

3158 overrides["fixation_colorscale"] = args.fixation_colorscale 

3159 if args.marker_size_range: 

3160 overrides["marker_size_range"] = tuple(args.marker_size_range) 

3161 if args.saccade_color: 

3162 overrides["saccade_color"] = args.saccade_color 

3163 if args.saccade_style: 

3164 overrides["saccade_style"] = args.saccade_style 

3165 if args.saccade_width is not None: 

3166 overrides["saccade_width"] = args.saccade_width 

3167 # VIZ-8: colour saccades by reading type. Either flag turns the mode on; each 

3168 # CLASS=COLOR pair overrides one class colour; --no-saccade-type-legend hides 

3169 # the colour key. 

3170 # VIZ-19: --saccade-color-by-direction is the two-way fold; the full five-way 

3171 # split wins if both are given (it's the more specific request). 

3172 if args.saccade_color_by_direction: 

3173 overrides["saccade_color_mode"] = "Forward / regression" 

3174 # EXP-20: a class colour beside --saccade-color-by-direction recolours the 

3175 # two-way split rather than overriding the mode the user asked for, so the 

3176 # fold's own colours have a flag too. 

3177 if args.saccade_color_by_type or ( 

3178 args.saccade_type_colors and not args.saccade_color_by_direction 

3179 ): 

3180 overrides["saccade_color_mode"] = "By type" 

3181 if not args.saccade_type_legend: 

3182 overrides["saccade_type_legend"] = False 

3183 if args.saccade_type_colors: 

3184 # Over the palette's class colours when one is named: the explicit dict 

3185 # wins over `--palette` wholesale in `api._expand_palette`, so starting 

3186 # from the stock set would put back every class the flags left alone — 

3187 # and a printed recipe restates only the classes the palette got wrong. 

3188 class_colors = dict( 

3189 palette_settings(args.palette)["saccade_class_colors"] 

3190 if args.palette 

3191 else SACCADE_CLASS_COLORS 

3192 ) 

3193 for pair in args.saccade_type_colors: 

3194 cls_name, _, color = pair.partition("=") 

3195 cls_name = cls_name.strip() 

3196 if not color or cls_name not in SACCADE_CLASS_EDITABLE: 

3197 raise SystemExit( 

3198 f"--saccade-type-color expects CLASS=COLOR with CLASS one of " 

3199 f"{', '.join(SACCADE_CLASS_EDITABLE)}; got {pair!r}." 

3200 ) 

3201 class_colors[cls_name] = color.strip() 

3202 overrides["saccade_class_colors"] = class_colors 

3203 # The critical-span pair. `--highlight-column ''` is a real request (mark 

3204 # nothing) and must survive the truthiness test the other options use, so it 

3205 # is compared against None. 

3206 if args.highlight_column is not None: 

3207 overrides["highlight_column"] = args.highlight_column or None 

3208 if args.critical_span_style: 

3209 overrides["critical_span_style"] = _CRITICAL_SPAN_STYLES[ 

3210 args.critical_span_style 

3211 ] 

3212 if args.fixation_flags: 

3213 overrides["fixation_flags"] = _parse_fixation_flags(args.fixation_flags) 

3214 if args.legend_layout: 

3215 overrides["legend_layout"] = _parse_legend_layout(args.legend_layout) 

3216 # VIZ-31: the reading-class filter. Independent of the colour mode above — 

3217 # "only the regressions, in one colour" is as valid as "all of them, coloured 

3218 # by type" — so it is its own flag rather than a mode. 

3219 if args.saccade_classes: 

3220 overrides["saccade_classes"] = _parse_saccade_classes_arg( 

3221 args.saccade_classes, "--saccade-classes" 

3222 ) 

3223 # VIZ-9: linear-reading mode. 

3224 if args.saccade_arcs: 

3225 overrides["saccade_render_mode"] = "Arc" 

3226 if args.snap_fixations: 

3227 overrides["fixation_snap_to_word"] = True 

3228 if args.illustration: 

3229 preset = dict( 

3230 show_words=False, 

3231 show_word_labels=True, 

3232 show_fixations=True, 

3233 show_order=False, 

3234 show_saccades=True, 

3235 show_saccade_arrows=False, 

3236 show_heatmap=False, 

3237 color_by=UNIFORM_COLOR_FIELD, 

3238 saccade_color_mode="Uniform", 

3239 saccade_render_mode="Arc", 

3240 fixation_snap_to_word=True, 

3241 fixation_opacity=1.0, 

3242 ) 

3243 # BUG-85 review: an explicit flag wins over the preset, as it does over 

3244 # `plot_scanpath(illustration=True, …)` — the preset used to overwrite 

3245 # `--color-by`, `--no-labels` and the rest set above. A layer switch is 

3246 # in `overrides` only when its flag was given. 

3247 stated = {key for key in preset if key in overrides} 

3248 overrides.update({k: v for k, v in preset.items() if k not in stated}) 

3249 # VIZ-4: image stimulus background. make_scanpath_figure only draws the image 

3250 # when a size is known, so default to the PNG's own pixel size, then the 

3251 # canvas. 

3252 if args.stimulus_image: 

3253 from .plots import _png_pixel_size 

3254 

3255 _require_image(args.stimulus_image, "--stimulus-image") 

3256 overrides["background_image"] = args.stimulus_image 

3257 overrides["background_image_size"] = ( 

3258 _parse_canvas(args.stimulus_image_size, "--stimulus-image-size") 

3259 or _png_pixel_size(args.stimulus_image) 

3260 or canvas 

3261 ) 

3262 overrides["background_image_origin"] = _parse_xy( 

3263 args.stimulus_image_origin 

3264 ) or (0.0, 0.0) 

3265 if args.stimulus_image_opacity is not None: 

3266 overrides["background_image_opacity"] = args.stimulus_image_opacity 

3267 # EXP-20: the rest of the figure options. After `--illustration` on purpose, 

3268 # so an explicit flag wins over the preset — the order `plot_scanpath`'s own 

3269 # `illustration=True` applies them in. 

3270 for key in _DIRECT_OPTION_FLAGS: 

3271 value = getattr(args, key) 

3272 if value is not None: 

3273 # `--word-hover-measure ''` is the real request "no measure on 

3274 # hover", the option's own `None` — the `--highlight-column ''` rule. 

3275 overrides[key] = None if value == "" and key in _NONE_WHEN_EMPTY else value 

3276 for key, flipped in _SWITCH_OPTION_FLAGS.items(): 

3277 if getattr(args, key) == flipped: 

3278 overrides[key] = flipped 

3279 if args.marker_duration_range: 

3280 overrides["marker_duration_range"] = tuple(args.marker_duration_range) 

3281 if args.fixation_color_range: 

3282 overrides["fixation_color_range"] = tuple(args.fixation_color_range) 

3283 if args.heatmap_range: 

3284 overrides["heatmap_range"] = tuple(args.heatmap_range) 

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

3286 orientation = getattr(args, f"{bar}_colorbar_orientation") 

3287 if orientation: 

3288 overrides[f"{bar}_colorbar_orientation"] = orientation.capitalize() 

3289 if args.compare_with is not None: 

3290 if args.show_legend is not None: 

3291 overrides["show_legend"] = args.show_legend 

3292 for side in ("a", "b"): 

3293 style = _parse_style_spec(getattr(args, f"style_{side}"), f"--style-{side}") 

3294 if style: 

3295 overrides[f"style_{side}"] = style 

3296 # CMP-24: B's own filters. A comparison reads them off B's style; the 

3297 # co-animation takes B's flags as a setting and draws every class. 

3298 b_flags = ( 

3299 _parse_fixation_flags(args.compare_fixation_flags) 

3300 if args.compare_fixation_flags 

3301 else None 

3302 ) 

3303 b_classes = ( 

3304 _parse_saccade_classes_arg( 

3305 args.compare_saccade_classes, "--compare-saccade-classes" 

3306 ) 

3307 if args.compare_saccade_classes 

3308 else None 

3309 ) 

3310 if args.animate: 

3311 if b_classes is not None: 

3312 raise SystemExit( 

3313 "--compare-saccade-classes filters a comparison figure; the " 

3314 "--animate co-animation has no saccade-class filter." 

3315 ) 

3316 if b_flags is not None: 

3317 overrides["fixation_flags_b"] = b_flags 

3318 elif b_flags is not None or b_classes is not None: 

3319 style_b = dict(overrides.get("style_b") or {}) 

3320 if b_flags is not None: 

3321 style_b["fixation_flags"] = b_flags 

3322 if b_classes is not None: 

3323 style_b["saccade_classes"] = b_classes 

3324 overrides["style_b"] = style_b 

3325 if args.stimulus_image_b: 

3326 from .plots import _png_pixel_size 

3327 

3328 _require_image(args.stimulus_image_b, "--stimulus-image-b") 

3329 overrides["background_image_b"] = args.stimulus_image_b 

3330 overrides["background_image_size_b"] = ( 

3331 _parse_canvas(args.stimulus_image_size_b, "--stimulus-image-size-b") 

3332 or _png_pixel_size(args.stimulus_image_b) 

3333 or _parse_canvas(args.compare_canvas, "--compare-canvas") 

3334 or canvas 

3335 ) 

3336 overrides["background_image_origin_b"] = _parse_xy( 

3337 args.stimulus_image_origin_b, "--stimulus-image-origin-b" 

3338 ) or (0.0, 0.0) 

3339 

3340 common = dict( 

3341 canvas_size=canvas, 

3342 base_font_size=args.font_size, 

3343 font_family=args.font_family or FONT_FAMILY, 

3344 title=args.title or "", 

3345 caption=args.caption or "", 

3346 # DATA-66: the figure's text and the column flags in the dataset's own 

3347 # names — the frames themselves stay canonical. 

3348 column_names=column_names or None, 

3349 ) 

3350 if args.print_code: 

3351 _print_reproduction_code( 

3352 api, 

3353 args, 

3354 overrides, 

3355 canvas, 

3356 participant, 

3357 trial, 

3358 raw_gaze=raw_gaze is not None, 

3359 ) 

3360 try: 

3361 if args.animate: 

3362 # EXP-10: which options the replay takes is `figure_options 

3363 # ("animation")` — the set `animate_scanpath` validates against and 

3364 # the snippet serializer diffs against. A hand-kept list here drifted 

3365 # from it twice over: `--fixation-symbol` / `--fixation-color` / 

3366 # `--palette` were dropped without a word, and `--color-by` / 

3367 # `--marker-size-range` / `--fixation-colorscale` were refused as 

3368 # unsupported though the builder honours them — so an animation 

3369 # snippet copied from the app drew a different figure. `palette` is 

3370 # not an option but `animate_scanpath` expands it, keeping only the 

3371 # colours the replay can draw. 

3372 animation_options = api.figure_options("animation") 

3373 anim_kwargs = { 

3374 key: value 

3375 for key, value in overrides.items() 

3376 if key in animation_options or key == "palette" 

3377 } 

3378 # A key the replay can't take is only worth a warning when it moves 

3379 # the static figure off its default — `--heatmap`, not the bare run. 

3380 static_defaults = api.figure_options("static") 

3381 ignored = [ 

3382 key 

3383 for key, value in overrides.items() 

3384 if key not in anim_kwargs and value != static_defaults.get(key) 

3385 ] 

3386 # PRE-3 drift correction is a plot_scanpath-only parameter (the 

3387 # animation builder has no line-snapping path), so name it here too. 

3388 if args.drift_correction: 

3389 ignored.append("drift_correction") 

3390 if args.drift_connectors: 

3391 ignored.append("drift_connectors") 

3392 # Raw gaze is a `plot_scanpath` frame; the replay draws none. 

3393 if raw_gaze is not None: 

3394 ignored.append("raw_gaze") 

3395 if args.compare_raw_gaze: 

3396 ignored.append("compare_raw_gaze") 

3397 if ignored: 

3398 print( 

3399 f"Warning: --animate cannot draw these, ignoring them: " 

3400 f"{', '.join(_flags_for(ignored, overrides))}", 

3401 file=sys.stderr, 

3402 ) 

3403 # CMP-9/CMP-11: `--animate --compare-with` is the *dual* co-animation 

3404 # the app renders when both modes are on — both readings on one clock. 

3405 # That is an overlay, so it needs one coordinate space, and it is gated 

3406 # on the same `setups_comparable` predicate the static overlay uses. 

3407 if args.compare_with is not None: 

3408 anim_kwargs.update( 

3409 _compare_animation_frames(api, args, words, fixations, canvas) 

3410 ) 

3411 anim_kwargs["compare_stimulus"] = args.compare_stimulus 

3412 # EXP-8 §1. `animate_scanpath` names the co-animation's two 

3413 # trace labels `label_a` / `label_b` where `compare_scanpaths` 

3414 # takes one `labels=` pair, but to the user they are the same 

3415 # thing, so one flag pair serves both. They had no flag at all 

3416 # before, and an animation snippet reported them unsupported. 

3417 labels = _compare_labels(args) 

3418 if labels is not None: 

3419 anim_kwargs["label_a"], anim_kwargs["label_b"] = labels 

3420 # CMP-24: B's own window. 

3421 if args.compare_fix_index_range: 

3422 anim_kwargs["fix_index_range_b"] = _parse_fix_index_range( 

3423 args.compare_fix_index_range 

3424 ) 

3425 animation_options = dict( 

3426 playback_speed=args.playback_speed, 

3427 autoplay=args.autoplay, 

3428 # VIZ-7's window replays too — same parameter, same semantics. 

3429 fix_index_range=_parse_fix_index_range(args.fix_index_range), 

3430 illustration_label=args.illustration_label, 

3431 anim_grid_step_ms=args.anim_grid_step_ms, 

3432 anim_max_frames=args.anim_max_frames, 

3433 **anim_kwargs, 

3434 **common, 

3435 ) 

3436 if args.all_screens: 

3437 figures = api.render_parent_trial( 

3438 words, 

3439 fixations, 

3440 participant, 

3441 trial, 

3442 animate=True, 

3443 transition_mode=args.screen_transition, 

3444 screens=chosen_screens, 

3445 **animation_options, 

3446 ) 

3447 fig = next(iter(figures.values())) 

3448 else: 

3449 from .experimental_setup import IncomparableScreensError 

3450 

3451 try: 

3452 fig = api.animate_scanpath( 

3453 words, 

3454 fixations, 

3455 participant, 

3456 trial, 

3457 screen=args.screen, 

3458 **animation_options, 

3459 ) 

3460 except IncomparableScreensError as exc: 

3461 # CMP-21: the API's way out is Python. BUG-85: dropping 

3462 # --animate alone lands on the default overlay, refused on 

3463 # the same terms — so this names the layout flag too. 

3464 raise SystemExit( 

3465 f"{exc.reason} An animated comparison replays both " 

3466 "scanpaths on one clock in one coordinate space, so it " 

3467 "needs one screen too. Drop --animate and pass " 

3468 "--compare-layout side-by-side (or stacked) to compare " 

3469 "them in separate panels." + _inferred_screen_hint(args, canvas) 

3470 ) from None 

3471 elif args.compare_with is not None: 

3472 # `is not None`, not truthiness: `--compare-with ""` is a malformed 

3473 # request, and falling through here would silently render an ordinary 

3474 # single-trial figure for someone who asked for a comparison. 

3475 # CMP-9. Compare owns the whole figure, so it is a peer of the 

3476 # animate/static branches rather than an option on one of them: the 

3477 # comparison builder takes neither `--animate`'s playback settings 

3478 # nor the static path's per-layer extras. 

3479 from .experimental_setup import IncomparableScreensError 

3480 

3481 compare_participant, compare_trial = _parse_compare_with(args.compare_with) 

3482 loaded_b, loaded_fix_b, cross_dataset = _compare_second_dataset( 

3483 api, args, words, fixations 

3484 ) 

3485 raw_gaze_b = None 

3486 if args.compare_raw_gaze: 

3487 if not cross_dataset: 

3488 raise SystemExit( 

3489 "--compare-raw-gaze is the second dataset's raw gaze; pass " 

3490 "--compare-words/--compare-fixations too, or use " 

3491 "--raw-gaze, which covers both scanpaths of one dataset." 

3492 ) 

3493 try: 

3494 raw_gaze_b = api.load_raw_gaze( 

3495 args.compare_raw_gaze, names="canonical" 

3496 ) 

3497 except (ValueError, FileNotFoundError, OSError) as exc: 

3498 raise SystemExit( 

3499 "--compare-raw-gaze: " 

3500 + _load_error_message(exc, schema_flags=False) 

3501 ) 

3502 # None keeps `compare_scanpaths` on its same-dataset path, which is 

3503 # what skips the namespacing. 

3504 words_b = loaded_b if cross_dataset else None 

3505 fixations_b = loaded_fix_b if cross_dataset else None 

3506 try: 

3507 fig = api.compare_scanpaths( 

3508 words, 

3509 fixations, 

3510 (participant, trial), 

3511 (compare_participant, compare_trial), 

3512 # One screen per scanpath, each picked in its own trial. 

3513 screen=args.screen, 

3514 screen_b=args.compare_screen, 

3515 words_b=words_b, 

3516 fixations_b=fixations_b, 

3517 dataset_b=args.compare_dataset_name, 

3518 layout=args.compare_layout, 

3519 compare_stimulus=args.compare_stimulus, 

3520 labels=_compare_labels(args), 

3521 setup=_compare_setup_snapshot(canvas), 

3522 setup_b=_compare_setup_snapshot( 

3523 _parse_canvas(args.compare_canvas, "--compare-canvas") 

3524 ), 

3525 drift_correction=args.drift_correction, 

3526 # EXP-11: a builder parameter, like the drift correction 

3527 # beside it, so it has to be named here — it is not in 

3528 # `overrides`. Left out, a windowed comparison drew both 

3529 # whole trials while the `--print-code` recipe said otherwise. 

3530 fix_index_range=_parse_fix_index_range(args.fix_index_range), 

3531 # CMP-24: B's own window, when given. 

3532 fix_index_range_b=_parse_fix_index_range( 

3533 args.compare_fix_index_range 

3534 ), 

3535 # VIZ-48: each reading's samples under its scanpath. 

3536 raw_gaze=raw_gaze, 

3537 raw_gaze_b=raw_gaze_b, 

3538 **overrides, 

3539 **common, # carries canvas_size / fonts / title / caption 

3540 ) 

3541 except IncomparableScreensError as exc: 

3542 # BUG-85: the API's way out is Python (`layout='side_by_side'`), 

3543 # which this used to print verbatim to someone at a shell. 

3544 raise SystemExit( 

3545 f"{exc.reason} So no overlay was drawn; pass --compare-layout " 

3546 "side-by-side (or stacked) to compare them in separate panels, " 

3547 "each drawn to its own screen." 

3548 + _inferred_screen_hint(args, canvas) 

3549 ) from None 

3550 else: 

3551 static_options = dict( 

3552 raw_gaze=raw_gaze, 

3553 drift_correction=args.drift_correction, 

3554 drift_connectors=args.drift_connectors, 

3555 # VIZ-7's fixation-index window is a `plot_scanpath` parameter 

3556 # rather than a figure keyword, so it is named here alongside 

3557 # drift correction rather than folded into `overrides`. 

3558 fix_index_range=_parse_fix_index_range(args.fix_index_range), 

3559 illustration_label=args.illustration_label, 

3560 **overrides, 

3561 **common, 

3562 ) 

3563 if args.all_screens: 

3564 figures = api.render_parent_trial( 

3565 words, 

3566 fixations, 

3567 participant, 

3568 trial, 

3569 screens=chosen_screens, 

3570 **static_options, 

3571 ) 

3572 fig = next(iter(figures.values())) 

3573 else: 

3574 fig = api.plot_scanpath( 

3575 words, 

3576 fixations, 

3577 participant, 

3578 trial, 

3579 screen=args.screen, 

3580 **static_options, 

3581 ) 

3582 if args.all_screens: 

3583 target = Path(args.output) 

3584 written = [] 

3585 for position, (screen_id, screen_figure) in enumerate( 

3586 figures.items(), start=1 

3587 ): 

3588 # The screen's place in its trial, which --screens can skip past. 

3589 meta = screen_figure.layout.meta 

3590 if isinstance(meta, dict) and meta.get("screen_index"): 

3591 position = int(meta["screen_index"]) 

3592 safe_screen = "".join( 

3593 char if char.isalnum() or char in "-_" else "_" 

3594 for char in str(screen_id) 

3595 ) 

3596 screen_path = target.with_name( 

3597 f"{target.stem}__screen-{position:03d}-{safe_screen}{target.suffix}" 

3598 ) 

3599 written.append( 

3600 api.save_figure(screen_figure, screen_path, **_save_kwargs(args)) 

3601 ) 

3602 print( 

3603 f"Wrote {_count(len(written), 'screen figure')}: " 

3604 + ", ".join(str(path) for path in written), 

3605 file=sys.stderr, 

3606 ) 

3607 return 

3608 out = api.save_figure(fig, args.output, **_save_kwargs(args)) 

3609 # VIZ-5: also drop a per-layer breakdown next to the output. 

3610 layer_paths = None 

3611 if args.separable_layers: 

3612 suffix = os.path.splitext(args.output)[1].lower().lstrip(".") 

3613 if args.animate or suffix not in ("svg", "pdf", "png"): 

3614 print( 

3615 "Warning: --separable-layers needs a static image output " 

3616 "(.svg/.pdf/.png) and no --animate; skipping the layer split.", 

3617 file=sys.stderr, 

3618 ) 

3619 else: 

3620 layer_dir = f"{os.path.splitext(args.output)[0]}_layers" 

3621 layer_paths = api.save_figure_layers( 

3622 fig, 

3623 layer_dir, 

3624 fmt=suffix, 

3625 scale=int(args.scale), 

3626 width=args.width, 

3627 height=args.height, 

3628 ) 

3629 except (ValueError, RuntimeError, OSError) as exc: 

3630 raise SystemExit(str(exc)) 

3631 print(f"Wrote {out}", file=sys.stderr) 

3632 if layer_paths: 

3633 print( 

3634 f"Wrote {len(layer_paths)} layer files to {os.path.splitext(args.output)[0]}" 

3635 "_layers/", 

3636 file=sys.stderr, 

3637 ) 

3638 

3639 

3640def _analyze_parser() -> argparse.ArgumentParser: 

3641 """The `analyze` parser — its own function so the docs' CLI reference is 

3642 generated from it rather than restated (ENG-79).""" 

3643 parser = _ShortErrorParser( 

3644 prog="scanpath-studio analyze", 

3645 description="Write fixation, saccade, word, sentence, trial, participant, " 

3646 "character, and cleaning-QA tables without launching the app.", 

3647 ) 

3648 parser.add_argument( 

3649 "--words", nargs="+", required=True, help="Words table(s), as for render." 

3650 ) 

3651 parser.add_argument( 

3652 "--fixations", 

3653 nargs="+", 

3654 required=True, 

3655 help="Fixations table(s), as for render.", 

3656 ) 

3657 parser.add_argument( 

3658 "--trial-parts-manifest", 

3659 help="JSON manifest assigning source rows to ordered screens.", 

3660 ) 

3661 parser.add_argument( 

3662 "--output-dir", 

3663 required=True, 

3664 help="Folder for the CSV tables and run_config.json (created if missing).", 

3665 ) 

3666 parser.add_argument( 

3667 "--short-policy", 

3668 choices=["off", "merge", "merge-then-discard", "discard"], 

3669 default="off", 

3670 help="Fixations shorter than --short-threshold-ms: merge folds each " 

3671 "into its nearer neighbour within --merge-distance-chars (a short last " 

3672 "fixation that cannot merge is excluded); merge-then-discard also " 

3673 "excludes every other one that cannot merge; discard excludes them " 

3674 "all. Excluded rows are marked, never dropped (default: off).", 

3675 ) 

3676 parser.add_argument( 

3677 "--short-threshold-ms", 

3678 type=float, 

3679 default=80.0, 

3680 help="What counts as a short fixation, in ms (default: 80).", 

3681 ) 

3682 parser.add_argument( 

3683 "--merge-distance-chars", 

3684 type=float, 

3685 default=1.0, 

3686 help="How close, in character widths, a neighbour must be for a short " 

3687 "fixation to merge into it (default: 1.0).", 

3688 ) 

3689 parser.add_argument( 

3690 "--discard-blink-adjacent", 

3691 action="store_true", 

3692 help="Exclude blinks and the fixations either side of one.", 

3693 ) 

3694 parser.add_argument( 

3695 "--pixels-per-degree", 

3696 type=float, 

3697 help="Screen pixels per degree of visual angle; adds degree-valued " 

3698 "amplitudes to the saccade table.", 

3699 ) 

3700 _add_schema_flags(parser) 

3701 return parser 

3702 

3703 

3704def analyze(argv: list[str]) -> None: 

3705 """Preprocess data and export the complete EXP-3 analysis family.""" 

3706 args = _analyze_parser().parse_args(argv) 

3707 word_schema = _parse_schema_arg(args.word_schema, "--word-schema") 

3708 fix_schema = _parse_schema_arg(args.fix_schema, "--fix-schema") 

3709 

3710 from . import api 

3711 

3712 manifest = None 

3713 if args.trial_parts_manifest: 

3714 try: 

3715 manifest = json.loads( 

3716 Path(args.trial_parts_manifest).read_text(encoding="utf-8") 

3717 ) 

3718 except (OSError, json.JSONDecodeError) as exc: 

3719 raise SystemExit(f"Could not read trial-parts manifest: {exc}") from exc 

3720 try: 

3721 words, fixations = api.load_scanpath_data( 

3722 args.words, 

3723 args.fixations, 

3724 word_schema=word_schema, 

3725 fix_schema=fix_schema, 

3726 trial_parts_manifest=manifest, 

3727 keep_columns=args.keep_columns, 

3728 ) 

3729 except (ValueError, OSError) as exc: 

3730 raise SystemExit(_load_error_message(exc)) from exc 

3731 policy = { 

3732 "off": "Off", 

3733 "merge": "Merge", 

3734 "merge-then-discard": "Merge then discard", 

3735 "discard": "Discard", 

3736 }[args.short_policy] 

3737 words, fixations, qa = api.preprocess_data( 

3738 words, 

3739 fixations, 

3740 enabled=policy != "Off" or args.discard_blink_adjacent, 

3741 short_policy=policy, 

3742 short_threshold_ms=args.short_threshold_ms, 

3743 merge_distance_chars=args.merge_distance_chars, 

3744 discard_blink_adjacent=args.discard_blink_adjacent, 

3745 ) 

3746 tables = api.analysis_tables( 

3747 words, fixations, pixels_per_degree=args.pixels_per_degree 

3748 ) 

3749 # EXP-16: with preprocessing off `preprocess_data` returns an empty report, 

3750 # and writing it over the family's own one left `cleaning_qa.csv` a single 

3751 # newline that `pd.read_csv` refuses. The family's report is the per-trial 

3752 # "nothing excluded, policy Off" table the export bundle writes, so it 

3753 # stands unless preprocessing actually ran. 

3754 if not qa.empty: 

3755 tables["cleaning_qa"] = qa 

3756 from .data import shareable_frame 

3757 from .export import strip_local_paths 

3758 

3759 destination = Path(args.output_dir) 

3760 destination.mkdir(parents=True, exist_ok=True) 

3761 # DATA-66: the tables come back under the dataset's own names (the loader's 

3762 # default); `columns.json` maps the two that are the dataset's own tables 

3763 # back to the internal names, as an export bundle's does. 

3764 written: dict[str, list] = {} 

3765 for name, table in tables.items(): 

3766 found = _cn.frame_names(table) 

3767 if found is not None and name in ("fixations", "word_measures"): 

3768 written[name] = _cn.written_columns( 

3769 shareable_frame(_cn.to_canonical_frame(table)), found[1] 

3770 ) 

3771 # The paths a stimulus image was found at are this machine's, not data. 

3772 table = strip_local_paths(shareable_frame(table)) 

3773 table.to_csv(destination / f"{name}.csv", index=False) 

3774 if any(written.values()): 

3775 (destination / "columns.json").write_text( 

3776 json.dumps(_cn.columns_manifest(written), indent=2), encoding="utf-8" 

3777 ) 

3778 config = { 

3779 "short_policy": policy, 

3780 "short_threshold_ms": args.short_threshold_ms, 

3781 "merge_distance_chars": args.merge_distance_chars, 

3782 "discard_blink_adjacent": args.discard_blink_adjacent, 

3783 "pixels_per_degree": args.pixels_per_degree, 

3784 } 

3785 (destination / "run_config.json").write_text( 

3786 json.dumps(config, indent=2), encoding="utf-8" 

3787 ) 

3788 extras = ( 

3789 "columns.json + run_config.json" 

3790 if any(written.values()) 

3791 else ("run_config.json") 

3792 ) 

3793 print(f"Wrote {len(tables)} tables + {extras} to {destination}") 

3794 if "word_measures" not in tables: 

3795 # AN-32 / EXP-23: measures are the dataset's own; none are computed. 

3796 print( 

3797 "No word_measures.csv: the words table brings no reading measures, " 

3798 "and none are computed. EyeLink IA_* columns are found on their own; " 

3799 "map others with --word-schema's measure_* keys, e.g. " 

3800 '\'{"measure_tfd": "dwell_ms"}\'.' 

3801 ) 

3802 

3803 

3804def _corpus_parser() -> argparse.ArgumentParser: 

3805 """The `corpus` parser (see `_analyze_parser`).""" 

3806 parser = _ShortErrorParser( 

3807 prog="scanpath-studio corpus", 

3808 description="Render a styled corpus figure from a tidy CSV you already " 

3809 "have (api.plot_corpus_figure).", 

3810 ) 

3811 parser.add_argument( 

3812 "--input", 

3813 required=True, 

3814 metavar="PATH", 

3815 help="The CSV. profile reads word_id plus the value column (and " 

3816 "optional lo / hi), distribution the value column, difference word_id " 

3817 "and diff.", 

3818 ) 

3819 parser.add_argument( 

3820 "--kind", 

3821 choices=["profile", "distribution", "difference"], 

3822 required=True, 

3823 help="A per-word profile, a distribution, or a difference profile.", 

3824 ) 

3825 parser.add_argument( 

3826 "-o", 

3827 "--output", 

3828 required=True, 

3829 metavar="PATH", 

3830 help="Output file; any extension save_figure writes (.html, .png, .svg, .pdf).", 

3831 ) 

3832 parser.add_argument( 

3833 "--measure-label", 

3834 default="Value", 

3835 help="Axis / legend label for the value (default: Value).", 

3836 ) 

3837 parser.add_argument( 

3838 "--series-col", 

3839 default="series", 

3840 help="Column naming the overlaid series, when present (default: series).", 

3841 ) 

3842 parser.add_argument( 

3843 "--value-col", 

3844 default="value", 

3845 help="The value column (default: value).", 

3846 ) 

3847 parser.add_argument( 

3848 "--primary-color", 

3849 default="#1f77b4", 

3850 help="First series color (default: #1f77b4).", 

3851 ) 

3852 parser.add_argument( 

3853 "--secondary-color", 

3854 default="#e45756", 

3855 help="Second series color (default: #e45756).", 

3856 ) 

3857 return parser 

3858 

3859 

3860def corpus(argv: list[str]) -> None: 

3861 """Render a styled corpus figure from a tidy CSV (AN-29).""" 

3862 args = _corpus_parser().parse_args(argv) 

3863 from . import api 

3864 

3865 # EXP-13: each of these ended in a traceback — a missing or unparseable 

3866 # --input, a table without the columns --kind reads, an output extension 

3867 # save_figure doesn't write. 

3868 try: 

3869 data = pd.read_csv(args.input) 

3870 except (OSError, ValueError) as exc: 

3871 raise SystemExit(f"--input: could not read {args.input!r}: {exc}") from exc 

3872 try: 

3873 fig = api.plot_corpus_figure( 

3874 data, 

3875 kind=args.kind, 

3876 measure_label=args.measure_label, 

3877 series_col=args.series_col, 

3878 value_col=args.value_col, 

3879 colors=(args.primary_color, args.secondary_color), 

3880 ) 

3881 out = api.save_figure(fig, args.output) 

3882 except (ValueError, RuntimeError, OSError) as exc: 

3883 raise SystemExit(str(exc)) from exc 

3884 print(f"Wrote {out}") 

3885 

3886 

3887def _cache_parser() -> argparse.ArgumentParser: 

3888 """The `cache` parser (see `_analyze_parser`).""" 

3889 parser = _ShortErrorParser( 

3890 prog="scanpath-studio cache", 

3891 description="Show what a local run has stored on this computer " 

3892 "(uploaded datasets, mappings, view settings, saved designs, " 

3893 "metadata tables, annotations), where it lives, and delete it. The hosted app stores " 

3894 "nothing. Set SCANPATH_STUDIO_STATE_DIR to keep it somewhere else.", 

3895 ) 

3896 parser.add_argument( 

3897 "--path", action="store_true", help="Print the cache folder and exit." 

3898 ) 

3899 parser.add_argument("--json", action="store_true", help="Print the status as JSON.") 

3900 parser.add_argument( 

3901 "--clear", action="store_true", help="Delete the recovery cache." 

3902 ) 

3903 return parser 

3904 

3905 

3906def _count(n: int, noun: str) -> str: 

3907 """``"1 setting"``, ``"205 settings"``, ``"no settings"`` (#374, F38).""" 

3908 if not n: 

3909 return f"no {noun}s" 

3910 return f"{n:,} {noun}" + ("" if n == 1 else "s") 

3911 

3912 

3913def cache(argv: list[str]) -> None: 

3914 """Inspect or clear the on-device recovery cache (ENG-30). 

3915 

3916 The terminal counterpart of the app's 🗂️ Data Management → *Saved on this computer* 

3917 section, so the storage a local run creates can be found, measured and deleted without 

3918 launching the app (or after closing it). 

3919 """ 

3920 args = _cache_parser().parse_args(argv) 

3921 # `api` quiets Streamlit's bare-mode "No runtime found" cache warnings at 

3922 # import, and must be imported before `persistence` pulls in `data`, whose 

3923 # decorators fire them. 

3924 from . import api # noqa: F401 

3925 from .persistence import PERSIST_ENV_VAR, cache_status, clear_local_state 

3926 from .persistence import human_size as _human_size 

3927 

3928 # A local run is what this cache belongs to, so report enablement for one 

3929 # (the env override still wins) rather than for the CLI process itself. 

3930 status = cache_status(url="http://localhost") 

3931 if args.path: 

3932 print(status["directory"]) 

3933 return 

3934 if args.clear: 

3935 existed = status["exists"] 

3936 clear_local_state() 

3937 print( 

3938 f"Cleared {status['directory']}" 

3939 if existed 

3940 else f"Nothing stored in {status['directory']}" 

3941 ) 

3942 return 

3943 if args.json: 

3944 print(json.dumps(status, indent=2)) 

3945 return 

3946 

3947 print(f"Folder: {status['directory']}") 

3948 print( 

3949 "Saving: " 

3950 + ("enabled" if status["enabled"] else "disabled") 

3951 + (f" ({PERSIST_ENV_VAR}={status['override']})" if status["override"] else "") 

3952 + " for local runs" 

3953 ) 

3954 if not status["exists"]: 

3955 print("Stored: nothing") 

3956 return 

3957 if not status["readable"]: 

3958 print( 

3959 "Stored: unreadable (wrong schema or incomplete) — the app opens " 

3960 "without it and leaves it as it is" 

3961 ) 

3962 print(f"Size: {_human_size(status['bytes'])}") 

3963 return 

3964 datasets = [entry["name"] for entry in status["datasets"]] 

3965 print( 

3966 f"Stored: {_count(len(datasets), 'dataset')}: {', '.join(datasets)}" 

3967 if datasets 

3968 else "Stored: no datasets" 

3969 ) 

3970 # rows is None for a cache written before the manifest carried row counts 

3971 # (the app backfills it on its next save) — don't print a false 0. 

3972 rows = ( 

3973 _count(status["rows"], "row") if status["rows"] is not None else "rows unknown" 

3974 ) 

3975 print( 

3976 f" {rows} · {_count(status['annotations'], 'annotated trial')} · " 

3977 f"{_count(status['designs'], 'saved design')} · " 

3978 # DATA-38 — the attached metadata tables, the panel's own count. 

3979 f"{_count(status.get('metadata', 0), 'metadata table')} · " 

3980 f"{_count(status['settings'], 'setting')}" 

3981 ) 

3982 # A stored dataset the app cannot restore (a file gone, an entry damaged): 

3983 # the app holds it back, restores the rest, and keeps it in the cache. 

3984 for entry in status.get("damaged") or []: 

3985 print(f"Damaged: {entry['name']} — {entry['reason']} (kept; not restored)") 

3986 if status.get("damaged_metadata"): 

3987 print( 

3988 f"Damaged: metadata tables — {status['damaged_metadata']} " 

3989 "(kept; not restored)" 

3990 ) 

3991 print(f"Size: {_human_size(status['bytes'])}") 

3992 print(f"Written: {status['saved_at']}") 

3993 print("Delete with `scanpath-studio cache --clear`.") 

3994 

3995 

3996def _version_parser() -> argparse.ArgumentParser: 

3997 """The `version` parser (see `_analyze_parser`).""" 

3998 parser = _ShortErrorParser( 

3999 prog="scanpath-studio version", 

4000 description="Show which build of Scanpath Studio this is and how it was " 

4001 "installed. With --check, also ask GitHub whether a newer release is out " 

4002 "and how to update — the only time this command uses the network.", 

4003 ) 

4004 parser.add_argument( 

4005 "--check", 

4006 action="store_true", 

4007 help="Ask GitHub for the latest release and say how to update this install.", 

4008 ) 

4009 parser.add_argument( 

4010 "--timeout", 

4011 type=float, 

4012 default=5.0, 

4013 metavar="SECONDS", 

4014 help="How long to wait for GitHub (default 5).", 

4015 ) 

4016 return parser 

4017 

4018 

4019def version(argv: list[str]) -> None: 

4020 """Print which build this is, and with ``--check`` whether a newer release is out (#139). 

4021 

4022 The terminal counterpart of Help → About and ``api.check_for_updates``. 

4023 Exits 1 only when the check itself could not be made. 

4024 """ 

4025 args = _version_parser().parse_args(argv) 

4026 from .build_info import INSTALL_KINDS, build_info, install_kind 

4027 

4028 info = build_info() 

4029 print(f"scanpath-studio {info.version}") 

4030 print(f"Build: {info.describe()}") 

4031 print(f"Installed: {INSTALL_KINDS[install_kind(info)]}") 

4032 if not args.check: 

4033 return 

4034 from .updates import check_for_updates 

4035 

4036 result = check_for_updates(args.timeout) 

4037 if result.status == "error": 

4038 print(result.message, file=sys.stderr) 

4039 raise SystemExit(1) 

4040 print() 

4041 print(result.message) 

4042 if result.command: 

4043 print(f"Update: {result.command}") 

4044 if result.download is not None: 

4045 print(f"Download: {result.download.url}") 

4046 if result.status == "update_available" and result.latest is not None: 

4047 print(f"What's new: {result.latest.url}") 

4048 

4049 

4050def _check_parser() -> argparse.ArgumentParser: 

4051 """The `check` parser (see `_analyze_parser`).""" 

4052 parser = _ShortErrorParser( 

4053 prog="scanpath-studio check", 

4054 description="Run the Data Management page's Data checks on your tables without " 

4055 "launching the app: fixations lasting 0 ms or less or with an infinite " 

4056 "duration or onset, fixations and raw-gaze samples with no finite " 

4057 "position, word boxes with no area or no finite position, and per-screen " 

4058 "screen sizes that are not finite and positive. Reports what it finds " 

4059 "and changes nothing; the exit status is 0 whatever it finds (an " 

4060 "unreadable table is an error).", 

4061 ) 

4062 parser.add_argument( 

4063 "--sample", 

4064 action="store_true", 

4065 help="Check the bundled OneStop demo instead of your own tables.", 

4066 ) 

4067 parser.add_argument( 

4068 "--words", nargs="+", metavar="PATH", help="Words table(s), as for render." 

4069 ) 

4070 parser.add_argument( 

4071 "--fixations", 

4072 nargs="+", 

4073 metavar="PATH", 

4074 help="Fixations table(s), as for render.", 

4075 ) 

4076 parser.add_argument( 

4077 "--raw-gaze", 

4078 nargs="+", 

4079 metavar="PATH", 

4080 help="Raw (sample-level) gaze table(s), as for render.", 

4081 ) 

4082 parser.add_argument( 

4083 "--raw-gaze-schema", 

4084 metavar="JSON", 

4085 help="Column mapping for the --raw-gaze table, replacing auto-detection " 

4086 "(same shape as --fix-schema).", 

4087 ) 

4088 parser.add_argument( 

4089 "--trial-parts-manifest", 

4090 metavar="PATH", 

4091 help="JSON manifest assigning source rows to ordered screens.", 

4092 ) 

4093 parser.add_argument( 

4094 "--json", 

4095 action="store_true", 

4096 help="Print the findings as JSON (the rows api.check_data_health returns).", 

4097 ) 

4098 _add_schema_flags(parser) 

4099 return parser 

4100 

4101 

4102def _health_report(findings, counts: dict[str, int]) -> str: 

4103 """The data-check findings as plain text for a terminal.""" 

4104 checked = ", ".join(f"{name} {n:,} rows" for name, n in counts.items()) 

4105 if not findings: 

4106 return f"Data checks: every check passed ({checked})." 

4107 lines = [f"Data checks: {_count(len(findings), 'finding')} ({checked})."] 

4108 for f in findings: 

4109 trials = f" in {_count(f.trials, 'trial')}" if f.trials else "" 

4110 label = "note" if f.severity == "note" else "warning" 

4111 lines += [ 

4112 "", 

4113 f"[{label}] {f.title} — {f.table}.{'/'.join(f.columns)}: " 

4114 f"{f.rows:,} of {f.total_rows:,} rows{trials}", 

4115 ] 

4116 if f.breakdown: 

4117 kinds = ", ".join(f"{n:,} {k}" for k, n in f.breakdown.items()) 

4118 lines.append(f" by kind: {kinds}") 

4119 for example in f.examples: 

4120 row = ", ".join(f"{k}={v}" for k, v in example.items()) 

4121 lines.append(f" e.g. {row}") 

4122 lines.append(f" in the app: {f.consequence}") 

4123 return "\n".join(lines) 

4124 

4125 

4126def check(argv: list[str]) -> None: 

4127 """Run the data-health checks on loaded tables (the Data page's *Data checks*). 

4128 

4129 Loads the tables the way ``render`` / ``analyze`` do and prints 

4130 ``api.check_data_health``'s findings. Findings are information, not a 

4131 failure: nothing is dropped from the tables, so the exit status stays 0. 

4132 """ 

4133 args = _check_parser().parse_args(argv) 

4134 if args.sample and (args.words or args.fixations or args.raw_gaze): 

4135 raise SystemExit("Pass --sample or your own tables, not both.") 

4136 if not (args.sample or args.words or args.fixations or args.raw_gaze): 

4137 raise SystemExit( 

4138 "Nothing to check: pass --words and/or --fixations (and/or " 

4139 "--raw-gaze), or --sample." 

4140 ) 

4141 if args.raw_gaze_schema and not args.raw_gaze: 

4142 raise SystemExit( 

4143 "--raw-gaze-schema maps the --raw-gaze table; pass --raw-gaze too." 

4144 ) 

4145 word_schema = _parse_schema_arg(args.word_schema, "--word-schema") 

4146 fix_schema = _parse_schema_arg(args.fix_schema, "--fix-schema") 

4147 raw_gaze_schema = _parse_schema_arg(args.raw_gaze_schema, "--raw-gaze-schema") 

4148 

4149 from . import api 

4150 

4151 words = fixations = raw_gaze = None 

4152 if args.sample: 

4153 words, fixations = api.load_sample_data() 

4154 elif args.words or args.fixations: 

4155 manifest = None 

4156 if args.trial_parts_manifest: 

4157 try: 

4158 manifest = json.loads( 

4159 Path(args.trial_parts_manifest).read_text(encoding="utf-8") 

4160 ) 

4161 except (OSError, json.JSONDecodeError) as exc: 

4162 raise SystemExit(f"Could not read trial-parts manifest: {exc}") from exc 

4163 try: 

4164 words, fixations = api.load_scanpath_data( 

4165 args.words, 

4166 args.fixations, 

4167 word_schema=word_schema, 

4168 fix_schema=fix_schema, 

4169 trial_parts_manifest=manifest, 

4170 keep_columns=args.keep_columns, 

4171 ) 

4172 except (ValueError, OSError) as exc: 

4173 raise SystemExit(_load_error_message(exc)) from exc 

4174 # A table that was not given loads as an empty canonical frame; it was 

4175 # not checked, so it is not reported as checked. 

4176 if not args.words: 

4177 words = None 

4178 if not args.fixations: 

4179 fixations = None 

4180 if args.raw_gaze: 

4181 try: 

4182 raw_gaze = api.load_raw_gaze(args.raw_gaze, raw_gaze_schema=raw_gaze_schema) 

4183 except (ValueError, OSError) as exc: 

4184 raise SystemExit("--raw-gaze: " + _load_error_message(exc)) from exc 

4185 

4186 findings = api._health_findings(words, fixations, raw_gaze) 

4187 if args.json: 

4188 from .data_health import findings_frame 

4189 

4190 records = findings_frame(findings).to_dict("records") 

4191 print(json.dumps(records, indent=2, default=str)) 

4192 return 

4193 counts = { 

4194 name: len(frame) 

4195 for name, frame in ( 

4196 ("words", words), 

4197 ("fixations", fixations), 

4198 ("raw_gaze", raw_gaze), 

4199 ) 

4200 if frame is not None 

4201 } 

4202 print(_health_report(findings, counts)) 

4203 

4204 

4205_HELP = f"""scanpath-studio {__version__} — visualize eye-tracking-while-reading scanpaths 

4206 

4207usage: 

4208 scanpath-studio launch the interactive app (Streamlit) 

4209 scanpath-studio run [args…] same, forwarding args to `streamlit run` 

4210 (its --help lists Streamlit's options only) 

4211 scanpath-studio [run] --no-persist 

4212 launch without the on-device recovery cache 

4213 (this run only; see `cache` below) 

4214 scanpath-studio [run] --download-dir DIR 

4215 where Download saves public datasets when 

4216 the Data Management page's Download folder is blank 

4217 scanpath-studio render … render one trial to .html/.png/.svg/.pdf 

4218 (see `scanpath-studio render --help`) 

4219 scanpath-studio corpus … render a styled corpus-analysis figure 

4220 scanpath-studio check … run the Data checks on your tables 

4221 scanpath-studio cache … show / clear the on-device recovery cache 

4222 scanpath-studio version [--check] 

4223 show this build and how it was installed; 

4224 --check asks GitHub whether a newer 

4225 release is out 

4226 scanpath-studio --version print the version 

4227 

4228Unrecognized flags are forwarded to `streamlit run` (e.g. 

4229`scanpath-studio --server.port 8502`); an unknown command word is an error. 

4230The app listens on this computer only; `--server.address 0.0.0.0` serves it 

4231on your network (it has no login) with local folder access off, unless 

4232SCANPATH_LOCAL_FS=1.""" 

4233 

4234 

4235#: The subcommands `main` dispatches, for the did-you-mean below. 

4236_COMMANDS = ("run", "render", "analyze", "corpus", "check", "cache", "version") 

4237 

4238 

4239def _commands() -> tuple[str, ...]: 

4240 """The commands this build offers: `analyze` is held back with the other 

4241 computed measures (`constants.computed_measures_enabled`).""" 

4242 from .constants import computed_measures_enabled 

4243 

4244 if computed_measures_enabled(): 

4245 return _COMMANDS 

4246 return tuple(c for c in _COMMANDS if c != "analyze") 

4247 

4248 

4249def _help_text() -> str: 

4250 from .constants import computed_measures_enabled 

4251 

4252 if not computed_measures_enabled(): 

4253 return _HELP 

4254 return _HELP.replace( 

4255 " scanpath-studio corpus …", 

4256 " scanpath-studio analyze … export preprocessing + the full measure " 

4257 "family\n scanpath-studio corpus …", 

4258 ) 

4259 

4260 

4261def _refuse_unknown_command(word: str) -> None: 

4262 """ENG-54: a mistyped subcommand is an error, not a Streamlit argument. 

4263 

4264 Everything unrecognized is forwarded to ``streamlit run`` so bare Streamlit 

4265 flags keep working — but a bare *word* was forwarded too, so 

4266 ``scanpath-studio rendr --sample`` reached Streamlit as a script argument 

4267 and died on "No such option: --sample" (or, with no flags, quietly launched 

4268 the app). Only a word is refused: a leading ``-`` is a Streamlit flag, and a 

4269 ``.py`` path is left to Streamlit as before.""" 

4270 import difflib 

4271 

4272 close = difflib.get_close_matches(word, _commands(), n=1, cutoff=0.6) 

4273 hint = f" — did you mean {close[0]!r}?" if close else "." 

4274 raise SystemExit( 

4275 f"scanpath-studio: unknown command {word!r}{hint} Commands: " 

4276 f"{', '.join(_commands())}; `scanpath-studio --help` lists them. " 

4277 "Streamlit flags (starting with --) still launch the app." 

4278 ) 

4279 

4280 

4281#: Flags of ours that `launch_app` takes before the Streamlit ones. 

4282_LAUNCH_FLAGS = ("--no-persist", "--download-dir") 

4283 

4284 

4285def _refuse_misplaced_options(argv: list[str]) -> None: 

4286 """#374 F21: ``scanpath-studio --sample render …`` (or a ``render`` flag 

4287 with no command) used to reach Streamlit and die on "No such option: 

4288 --sample". Say where the options go instead. A Streamlit flag 

4289 (``--server.port``) or one of ours is left alone.""" 

4290 command = next((word for word in argv if word in _commands()), None) 

4291 if command is None: 

4292 flag = argv[0].split("=")[0] 

4293 if "." in flag or flag in _LAUNCH_FLAGS: 

4294 return 

4295 known = { 

4296 option 

4297 for action in _render_parser()._actions 

4298 for option in action.option_strings 

4299 } 

4300 if flag not in known: 

4301 return 

4302 command = "render" 

4303 rest = [word for word in argv if word != command] 

4304 raise SystemExit( 

4305 "scanpath-studio: put options after the command: " 

4306 f"scanpath-studio {command} {shlex.join(rest)}" 

4307 ) 

4308 

4309 

4310def main(argv: list[str] | None = None) -> None: 

4311 argv = list(argv) if argv is not None else sys.argv[1:] 

4312 if ( 

4313 argv 

4314 and argv[0].startswith("-") 

4315 and argv[0] 

4316 not in ( 

4317 "-h", 

4318 "--help", 

4319 "-V", 

4320 "--version", 

4321 ) 

4322 ): 

4323 _refuse_misplaced_options(argv) 

4324 if not argv: 

4325 launch_app([]) 

4326 elif argv[0] == "run": 

4327 launch_app(argv[1:]) 

4328 elif argv[0] == "render": 

4329 render(argv[1:]) 

4330 elif argv[0] == "analyze": 

4331 from .constants import EXPERIMENTAL_ENV_VAR, computed_measures_enabled 

4332 

4333 if not computed_measures_enabled(): 

4334 raise SystemExit( 

4335 "scanpath-studio: `analyze` is not available in this release: the " 

4336 "tables it writes are computed by Scanpath Studio and have not " 

4337 f"been validated yet. Set {EXPERIMENTAL_ENV_VAR}=1 to use it anyway." 

4338 ) 

4339 analyze(argv[1:]) 

4340 elif argv[0] == "corpus": 

4341 corpus(argv[1:]) 

4342 elif argv[0] == "check": 

4343 check(argv[1:]) 

4344 elif argv[0] == "cache": 

4345 cache(argv[1:]) 

4346 elif argv[0] == "version": 

4347 version(argv[1:]) 

4348 elif argv[0] in ("-h", "--help"): 

4349 print(_help_text()) 

4350 elif argv[0] in ("-V", "--version"): 

4351 print(__version__) 

4352 elif not argv[0].startswith("-") and not argv[0].endswith(".py"): 

4353 _refuse_unknown_command(argv[0]) 

4354 else: 

4355 # Backward compatibility: bare streamlit flags launch the app. 

4356 launch_app(argv) 

4357 

4358 

4359if __name__ == "__main__": 

4360 main()