Born Twice: Specimen and Record at the Moment of Collection
Premise. Record it in the field, where the knowledge is. Like a tattoo, the record should go everywhere you do and outlast whatever held it first.
Methods and Results. The Independent File (IF) is a single HTML document that starts the voucher record at collection. A UUID and QR code tie specimen, photographs, collector, locality, and date together before the plant goes in the press. Fields, validation, Darwin Core mappings, and export rules live in one place: semantics by construction. IF runs in a browser with no installation, account, server, or network. The demonstration produced a documented voucher in under five minutes. IF prints labels; exports HTML, JSON, CSV, and XLSX; writes Symbiota, Specify, and DiSSCo packages; and pushes to GitHub. A take-home bundle reads on its own and imports back into IF. Downstream systems can receive the record without becoming conditions for making or keeping it. Nothing leaves the device unless the collector sends it.
Conclusions. A voucher record does not need a permanent digital home to keep its identity. Open formats, inspectable source, and no single host point toward decades of use, though no service life has been measured. The aim is a field book that follows a collecting career and still makes sense to the next person who opens it. The specimen goes to a collection; the record can stay with the collector.
1.Introduction
The broken chain
Herbarium specimens underpin systematics, floristics, conservation biology, and climate research only as far as the attached record can be trusted (Liu et al., 2022; Chen et al., 2022). Trust starts with provenance: who collected it, where, when, and under what name. Strip those and a mounted specimen proves that a plant once existed somewhere. That is not nothing, but it is close.
Yet voucher information is still assembled piecemeal as the specimen moves through pressing, drying, freezing, imaging, identification, and data entry. That chain takes months or years to close (Bebber et al., 2010; pers. obs.). Each handoff is a chance to omit, mistype, or diverge. We spent decades improving the databases at the end of the chain and almost nothing on the collecting event, the one moment when everything is known.
The system starts the record at that moment and keeps identifiers, fields, Darwin Core mappings (Wieczorek et al., 2012), images, validation, and export rules in one file. The design principle is semantics by construction: the record's structure and behavior carry its meaning, so nobody has to rebuild it later across several systems.
A phone can already photograph a specimen, record where it stood, store structured data, and move the record along. The herbarium then receives a record instead of rebuilding one. The remaining job is accessioning, not data rescue.
The record begins too late
Digitization has never lacked ambition. Large programs have spent decades databasing, imaging, georeferencing, and mobilizing collections (Bebber et al., 2010; Barkworth and Murrell, 2012; Brilhante et al., 2026). The Muséum national d'Histoire naturelle has imaged all 5.4 million of its vascular-plant sheets, yet holds field-collection information for only 16% (Le Bras et al., 2017). About 2.5% of the 5.3 million records it serves through GBIF carry coordinates (MNHN and Chagnoux, 2025). Smaller herbaria run short of money, labor, training, and hardware (Harris and Marsico, 2017; Powell et al., 2021). Effort is not the problem. Timing is: where, when, and by whom a specimen first becomes a computational record.
Recent tools attack the backlog with real ingenuity. VARP recovers barcodes from specimen images (Powell and Shaw, 2021). pyzbar decodes barcodes and QR in Python (Hudson, 2022). mvh builds virtual herbaria from open images in R (Vasconcelos and Boyko, 2025). Hespi reads sheet labels with OCR, HTR, and multimodal models (Turnbull et al., 2025). iNaturalist tools print labels from observations (Rockefeller, 2026). Unvouchered observations now outnumber specimens and misrepresent global patterns (Daru and Rodriguez, 2023). Each solves a real problem. Nearly all begin after collection, a handoff or two too late.
The gap follows specimens into the molecular repositories. Of 1.36 million flowering-plant records in GenBank, 52.9% carried a specimen-voucher identifier (Nakazato and Jinbo, 2022). In Dendrobium, 31.6% of records gave a country of origin and 4.8% named a collector or assessor (Wu et al., 2021). An author audit of COI records across six animal genera found the same holes: voucher identity, collection date, collector, coordinates, and identifier (Table 4; procedure in Appendix D; see also Carné et al., 2025). A sequence is digital from birth. Its provenance is not.
None of this argues for rebuilding historical collections. Existing barcodes can stay. Sheets need not be relabeled, and institutional databases need not be replaced (Heil et al., 2026). That horse has left the barn. The useful question is what happens to the next specimen collected.
The answer here is to assign Darwin Core terms when the record is made. Exports then present that record in whatever form a destination needs, photographs embedded or linked. The QR code can point to the fuller record or carry a minimal Darwin Core record itself. Either way, identity, taxon, place, datum, and date outlast any server or link. In that narrow sense the record resembles a digital paratype (Jones, 2025), kin to the sequence-based types proposed by Thiele et al. (2023), minus the four-letter code and its IUPAC embellishments. Here it is plain JSON.
Symbiota (Gilbert et al., 2026; Gries et al., 2014), Specify (Specify Collections Consortium, 2026), GBIF, and many other systems remain the destinations. The aim is to feed them from the field and plug the holes in the data dike before information leaks out.
2.Materials & Methods
2.1The Zero-Dependency Primitive
The result is an Independent File (IF): a document that computes. One HTML file of about 750 KB holds the interface, code, styles, and record rules. No external libraries, framework, packages, build step, or required service. TiddlyWiki has shipped as a single HTML file since 2004 (Ruston, 2026). The median webpage in 2025 weighed 2.9 MB (Barret et al., 2026). The reference deployment ships inside the Carex key (Section 9), though the voucher needs no taxon data. Appendix A lists every job the file does.
| System | Primary store | Intake path | Format out | Reads JSON |
|---|---|---|---|---|
| Specify 7 | MySQL / MariaDB | Data entry and batch import by collection staff | DwC-A | ✓︎ |
| Symbiota | MySQL / MariaDB | Data entry, CSV or DwC-A import | DwC-A; JSON API | ✓︎ |
| iDigBio | PostgreSQL (JSONB) | Harvest of DwC-A, usually via IPT | JSON API | ✓︎ |
| GBIF | Hadoop (Avro / Parquet) | Crawl of DwC-A, usually via IPT | JSON API; DwC-A and CSV downloads | ✓︎ |
| DiSSCo | PostgreSQL (JSONB) | Mapping from source systems and aggregators | openDS JSON | ✓︎ |
| IF | None; single HTML file | Collector, at time of collection | JSON, CSV, XLSX; Specify, Symbiota, and openDS exports | ✓︎ |
2-1aTwo Workflows: Preprint Vs Cowboy Methods
Pre-printing prevents poor performance. Bring labels when you can.
- Print as many QR labels as you expect to need with IF's print function. Keep them in the press, a folder, wherever is handy.
- Unused labels stay usable. Discard a damaged, soaked, or torn one; you lose only paper and ink.
- Hit Scan and frame that label's QR code with your phone or tablet. IF adopts the identifier. Fill out your fields.
- Put the specimen and its preprinted label together in the press.
No labels? Go Cowboy. Possible, but not recommended.
- Use the QR code IF generates. After imaging, a pop-up gives the occurrence identifier's last four characters. Write them down with the specimen.
- Back at the shop, QR tags · Reconcile Cowboy prints labels for those records.
- Each label shows the last four characters and the binomial. Use them to rebuild the day and reunite the digital orphans with their specimens.
This study treats the QR record as a field-to-voucher workflow. Testing turned up other uses (specimen reminders, prepublication sharing, provisional identification, trip planning, record recovery), none evaluated here. The system is left open for workflows this paper did not think of.
IF nags once, and about the record, not the user: after three days of work living only in the browser, or ten unbundled vouchers, it asks to be copied out. One refusal and it shuts up.
2.2Quick Start
How to use the field voucher
- Open the IF file. To work offline, save a local copy first. It runs in the browser and stores records locally. Your device may ask permission for the camera and GPS. Annoying, and necessary for those features.
- The file generates a unique QR code and voucher identifier when it opens.
- Photograph the specimen in situ, with habit and habitat when useful. Existing images and photographed field notes can be added from other folders.
- Select Use Current Location (GPS). Coordinates can be corrected later.
- Add the useful photographs; the collector decides what earns its keep. Email attachments usually cap around 25 MB, so one canonical image may do for a day trip; plan storage for a long one. A no-photo option exists.
- Select Save Voucher. The record is stored in this browser. Email or export it when you are ready to move it.
Cost
$40 monthly in subscriptions over 90 days, no API billing or coupons used.
IF: build at a glance
Build 1008 · figures checked 29 September 2026 (freeze)
?selftest.IF is built to be taken apart. Every named function carries a comment stating what it does, and each is listed in the function manifest. A reader can trace any behavior to its source without running the file. Comments make up ~30% of the JavaScript by character count.
The interactive wiring diagram (Figure 1) follows build 1008 from printed identifiers and field entry through validation, local storage, and export. It is an explanatory map, not a live execution trace. For the trace, and for something to fly, go to Appendix F. The Happy Path atlas plays recorded calls through the application. The small-world flight deck lets you fly the architecture.
IF: interactive wiring diagram
Select a node to inspect its functions and connections, or choose Light the fuse to follow a voucher. Search for a function, drag to pan, and use Fit to see the whole map. Expand opens a larger view; Save SVG exports the figure.
Figure 1. Architecture map for build 1008. Self-test coverage and disclosed limits are available inside the diagram.
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> <title>IF1008 wiring</title> <meta name="description" content="The Independent File (IF), build IF1008, drawn as a live wiring map: every subsystem, the wires between them, and the main path from a preprinted QR sheet to the take-home bundle."> <meta name="theme-color" content="#1a78a8"> <style> :root{ --bg:#eef2f6; --surface:#ffffff; --panel:#f8fafc; --ink:#16202e; --muted:#56637a; --line:#c7d0dc; --grid:#d3dae4; --edge:#66758d; --focus:#2c68c9; --c-entry:#5b6b82; --c-paper:#7a869a; --c-ident:#0e8584; --c-draft:#2c68c9; --c-save:#5a4ccc; --c-store:#a8760f; --c-bus:#c0357f; --c-render:#2e8a52; --c-addenda:#c23e36; --c-photos:#cf6423; --c-io:#8248c0; --c-home:#1a78a8; --warn:#b8321f; --lit:#d9480f; --sans:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; --cond:"Arial Narrow","Roboto Condensed","Helvetica Neue",system-ui,-apple-system,"Segoe UI",sans-serif; --mono:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace; } @media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){ --bg:#0d131b; --surface:#151d29; --panel:#121a25; --ink:#e3e9f2; --muted:#93a0b5; --line:#2a3647; --grid:#1d2733; --edge:#8392a8; --focus:#6aa0f2; --c-entry:#8b9ab0; --c-paper:#a3aec0; --c-ident:#3cc1bd; --c-draft:#6aa0f2; --c-save:#9a8ff0; --c-store:#e0ad45; --c-bus:#ee6fb5; --c-render:#5cc486; --c-addenda:#f07a6f; --c-photos:#f39a5c; --c-io:#b58af0; --c-home:#5ab8e6; --warn:#ff7a5c; --lit:#ffa24a; } } :root[data-theme="dark"]{ --bg:#0d131b; --surface:#151d29; --panel:#121a25; --ink:#e3e9f2; --muted:#93a0b5; --line:#2a3647; --grid:#1d2733; --edge:#8392a8; --focus:#6aa0f2; --c-entry:#8b9ab0; --c-paper:#a3aec0; --c-ident:#3cc1bd; --c-draft:#6aa0f2; --c-save:#9a8ff0; --c-store:#e0ad45; --c-bus:#ee6fb5; --c-render:#5cc486; --c-addenda:#f07a6f; --c-photos:#f39a5c; --c-io:#b58af0; --c-home:#5ab8e6; --warn:#ff7a5c; --lit:#ffa24a; } html,body{height:100%} body{background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:15px;display:flex;flex-direction:column;overflow:hidden} button,input{font:inherit;color:inherit} :focus-visible{outline:2px solid var(--focus);outline-offset:2px} /* header */ .top{display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)} .brand{display:flex;flex-direction:column;gap:2px;min-width:0} .brand h1{margin:0;font-family:var(--cond);font-weight:700;font-size:22px;letter-spacing:.01em} .brand p{margin:0;color:var(--muted);font-size:13.5px} .tools{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-left:auto} .search{position:relative} .search input{width:220px;max-width:60vw;padding:7px 10px 7px 30px;border:1px solid var(--line);border-radius:7px;background:var(--surface);font-family:var(--mono);font-size:14px} .search svg{position:absolute;left:9px;top:50%;transform:translateY(-50%);width:14px;height:14px;fill:none;stroke:var(--muted);stroke-width:2} .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 11px;border:1px solid var(--line);border-radius:7px;background:var(--surface);cursor:pointer;font-size:14px;line-height:1.2;white-space:nowrap} .btn:hover{border-color:var(--edge)} .btn[aria-pressed="true"]{background:var(--ink);color:var(--bg);border-color:var(--ink)} .btn.primary{background:var(--lit);border-color:var(--lit);color:#fff;font-weight:600} .btn.primary[aria-pressed="true"]{background:var(--ink);border-color:var(--ink);color:var(--bg)} .seg{display:inline-flex} .seg .btn{border-radius:0;margin-left:-1px;min-width:38px;justify-content:center} .seg .btn:first-child{border-radius:7px 0 0 7px;margin-left:0} .seg .btn:last-child{border-radius:0 7px 7px 0} .zoomread{font-family:var(--mono);font-size:13px;color:var(--muted);min-width:46px;text-align:right;font-variant-numeric:tabular-nums} /* stage */ .stage{position:relative;flex:1;min-height:0;overflow:hidden} #map{width:100%;height:100%;display:block;touch-action:none;cursor:grab;user-select:none;-webkit-user-select:none} #map.dragging{cursor:grabbing} /* svg content */ .colband{fill:var(--ink);opacity:.025} .colhead{font-family:var(--cond);font-weight:700;font-size:26px;fill:var(--muted);letter-spacing:.04em} .colsub{font-family:var(--sans);font-size:15px;fill:var(--muted)} .gridpat circle{fill:var(--grid)} .node{cursor:pointer;transition:opacity .2s} .node .body{fill:color-mix(in oklab,var(--g) 9%,var(--surface));stroke:var(--g);stroke-width:1.75} .node.st-C .body{stroke-dasharray:7 5} .node .eyebrow{font-family:var(--sans);font-weight:600;font-size:13.5px;letter-spacing:.09em;fill:var(--g)} .node .title{font-family:var(--cond);font-weight:600;font-size:23px;fill:var(--ink)} .node .sub{font-family:var(--sans);font-size:15.5px;fill:var(--muted)} .node .fn{font-family:var(--mono);font-size:16px;fill:var(--ink)} .node .fnrule{stroke:var(--g);stroke-opacity:.35;stroke-width:1} .node .fartitle{font-family:var(--cond);font-weight:700;font-size:34px;fill:var(--ink);display:none} .node:hover .body,.node.sel .body{stroke-width:3} .node.sel .body{stroke-dasharray:none} .node.ev .body{fill:var(--g);stroke:var(--g)} .node.ev .evtext{font-family:var(--mono);font-weight:600;font-size:17px;fill:var(--surface)} .node.ev .evtag{font-family:var(--sans);font-weight:600;font-size:12.5px;letter-spacing:.09em;fill:var(--g)} .node.pp .body{fill:var(--surface);stroke:var(--g);stroke-dasharray:none} .node.pp .ear{fill:color-mix(in oklab,var(--g) 22%,var(--surface));stroke:var(--g);stroke-width:1.5} .chip rect{stroke-width:1.5} .chip.T rect{fill:var(--ink);stroke:var(--ink)} .chip.T text{fill:var(--surface)} .chip.C rect{fill:var(--surface);stroke:var(--ink)} .chip.C text{fill:var(--ink)} .chip text{font-family:var(--mono);font-weight:600;font-size:14px} .chip.L path{fill:var(--warn)} .chip.L text{fill:#fff;font-size:13px} .step circle{fill:var(--ink)} .step text{fill:var(--surface);font-family:var(--sans);font-weight:700;font-size:21px} .startflag rect{fill:var(--lit)} .startflag text{fill:#fff;font-family:var(--cond);font-weight:700;font-size:28px;letter-spacing:.08em} .startflag path{fill:var(--lit)} .pulse{fill:none;stroke:var(--lit);stroke-width:3;transform-box:fill-box;transform-origin:center;animation:pulse 2.2s ease-out infinite} @keyframes pulse{0%{opacity:.9;transform:scale(1)}100%{opacity:0;transform:scale(1.18)}} .edge{fill:none;transition:opacity .2s} .edge.call{stroke:var(--edge);stroke-width:2} .edge.event{stroke:var(--c-bus);stroke-width:2.4;stroke-dasharray:1.5 6;stroke-linecap:round} .edge.data{stroke:var(--c-store);stroke-width:2.2;stroke-dasharray:10 5} .edge.paper{stroke:var(--c-paper);stroke-width:2.6;stroke-dasharray:16 5 3 5} .edge.test{stroke:var(--muted);stroke-width:1.6;stroke-dasharray:2 6;stroke-linecap:round;display:none} .edge.main{stroke-width:3.4} .show-tests .edge.test{display:inline} .elabel{font-family:var(--sans);font-size:15px;fill:var(--muted);paint-order:stroke;stroke:var(--bg);stroke-width:5px;stroke-linejoin:round;pointer-events:none;transition:opacity .2s} .elabel.event{fill:var(--c-bus);font-family:var(--mono);font-size:14.5px} .elabel.data{fill:var(--c-store)} .elabel.test{display:none} .show-tests .elabel.test{display:inline} /* level of detail */ .far .node .near{display:none} .far .node .fartitle{display:inline} .far .elabel{display:none} .far .node.ev .evtext{font-size:25px} .far .node.ev .evtext{dominant-baseline:auto} .far .colsub{display:none} /* focus / isolate / play */ .focusing .node:not(.hot):not(.nb),.isolating .node:not(.inset),.searching .node:not(.match),.playing .node:not(.lit){opacity:.18} .focusing .edge:not(.hot),.isolating .edge:not(.inset),.searching .edge,.playing .edge:not(.lit){opacity:.1} .focusing .elabel:not(.hot),.isolating .elabel:not(.inset),.searching .elabel,.playing .elabel:not(.lit){opacity:0} .edge.hot{stroke-width:3.6} .edge.lit{stroke:var(--lit);stroke-width:4.2;stroke-dasharray:12 8;animation:flow .8s linear infinite} .node.lit .body{stroke:var(--lit);stroke-width:3.5} .node.match .body{stroke-width:3.5} @keyframes flow{to{stroke-dashoffset:-20}} @media (prefers-reduced-motion: reduce){.pulse{animation:none}.edge.lit{animation:none}} /* legend */ .legend{position:absolute;left:16px;bottom:16px;width:290px;max-height:calc(100% - 32px);overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 6px 24px rgba(10,20,40,.12);font-size:14px} .legend summary{cursor:pointer;padding:10px 14px;font-family:var(--cond);font-weight:700;font-size:16px;letter-spacing:.04em;list-style:none;display:flex;justify-content:space-between} .legend summary::-webkit-details-marker{display:none} .legend summary::after{content:"Open";align-self:center;font-size:12px;line-height:1;letter-spacing:.08em;text-transform:uppercase;padding:6px 12px;border:1px solid var(--line);border-radius:999px;color:var(--ink)} .legend[open] summary::after{content:"Close";align-self:center;font-size:12px;line-height:1;letter-spacing:.08em;text-transform:uppercase;padding:6px 12px;border:1px solid var(--line);border-radius:999px;color:var(--ink)} .legend .inner{padding:0 14px 14px;display:grid;gap:12px} .legend h3{margin:0 0 6px;font-size:12.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);font-weight:600} .groups{display:grid;grid-template-columns:1fr 1fr;gap:4px} .gchip{display:flex;align-items:center;gap:7px;padding:4px 6px;border:1px solid transparent;border-radius:6px;background:none;cursor:pointer;text-align:left;font-size:13.5px} .gchip i{width:13px;height:13px;border-radius:3px;background:var(--g);flex:none} .gchip:hover{border-color:var(--line)} .gchip[aria-pressed="true"]{border-color:var(--g);background:color-mix(in oklab,var(--g) 12%,transparent)} .keyrow{display:grid;grid-template-columns:54px 1fr;align-items:center;gap:4px 10px;font-size:13.5px} .keyrow svg{width:54px;height:16px;overflow:visible} .hint{color:var(--muted);font-size:13px;line-height:1.45} /* minimap */ .mini{position:absolute;right:16px;bottom:16px;width:220px;background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden;box-shadow:0 6px 24px rgba(10,20,40,.12);cursor:pointer} .mini svg{display:block;width:100%;height:auto} .mini .vp{fill:color-mix(in oklab,var(--lit) 12%,transparent);stroke:var(--lit);stroke-width:14} .mini .far .node .fartitle{display:none} /* detail panel */ .panel{position:absolute;top:0;right:0;bottom:0;width:380px;max-width:100%;background:var(--panel);border-left:1px solid var(--line);box-shadow:-8px 0 30px rgba(10,20,40,.12);overflow:auto;padding:18px 20px 24px} .panel .x{position:absolute;top:12px;right:12px} .panel .eb{font-size:12.5px;letter-spacing:.09em;text-transform:uppercase;font-weight:600;color:var(--g)} .panel h2{margin:4px 40px 6px 0;font-family:var(--cond);font-size:26px;line-height:1.15;text-wrap:balance} .panel p{margin:0 0 10px;line-height:1.5;max-width:62ch} .panel .sts{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 12px} .pill{display:inline-flex;align-items:center;gap:6px;padding:3px 9px;border-radius:999px;font-size:13px;border:1px solid var(--line)} .pill b{font-family:var(--mono)} .pill.T{background:var(--ink);color:var(--bg);border-color:var(--ink)} .pill.L{background:var(--warn);color:#fff;border-color:var(--warn)} .panel h4{margin:16px 0 6px;font-size:12.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);font-weight:600} .panel code{font-family:var(--mono);font-size:14px} .panel ul{margin:0;padding:0;list-style:none;display:grid;gap:4px} .panel li code{display:block;padding:5px 8px;background:var(--surface);border:1px solid var(--line);border-radius:6px} .panel .links button{display:flex;width:100%;justify-content:space-between;gap:8px;text-align:left;padding:6px 9px;border:1px solid var(--line);border-radius:6px;background:var(--surface);cursor:pointer;font-size:14px} .panel .links button:hover{border-color:var(--edge)} .panel .links small{color:var(--muted);font-size:12.5px} .panel .ref{color:var(--muted);font-size:13.5px;font-family:var(--mono)} /* info drawer */ .info{position:absolute;inset:16px;margin:auto;max-width:860px;max-height:calc(100% - 32px);height:fit-content;overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:0 20px 60px rgba(10,20,40,.25);padding:20px 22px} .info h2{margin:0 0 4px;font-family:var(--cond);font-size:24px} .info h3{margin:18px 0 8px;font-family:var(--cond);font-size:19px} .info .x{position:absolute;top:14px;right:14px} .tblwrap{overflow-x:auto} .info table{border-collapse:collapse;width:100%;font-size:14px} .info th,.info td{text-align:left;padding:7px 9px;border-bottom:1px solid var(--line);vertical-align:top} .info th{font-size:12.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);font-weight:600} .info td code{font-family:var(--mono);font-size:13.5px} .info ul{margin:0;padding-left:18px;line-height:1.55} .info li{margin-bottom:4px} /* the fuse */ .fuse-cord{fill:none;stroke:var(--ink);stroke-opacity:.38;stroke-width:3;stroke-dasharray:1.5 6;stroke-linecap:round} .fuse-burn{fill:none;stroke:var(--lit);stroke-width:5;stroke-linecap:round} .fuse-head{fill:var(--lit);stroke:var(--surface);stroke-width:2.5} .fusepop{position:absolute;z-index:6;width:320px;max-width:calc(100% - 24px);background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 10px 36px rgba(10,20,40,.2);padding:12px 14px 14px} .fusepop .fp-top{display:flex;justify-content:space-between;align-items:center} .fusepop .fp-step{font-family:var(--mono);font-size:12.5px;color:var(--muted);font-variant-numeric:tabular-nums} .fusepop .fp-x{border:0;background:none;font-size:22px;line-height:1;min-width:36px;min-height:36px;cursor:pointer;color:var(--muted);border-radius:6px} .fusepop .fp-x:hover{color:var(--ink);background:var(--surface)} .fusepop .fp-bar{height:3px;background:var(--line);border-radius:2px;margin:4px 0 10px;overflow:hidden} .fusepop .fp-bar i{display:block;height:100%;background:var(--lit)} .fusepop .fp-eb{font-size:12px;letter-spacing:.09em;text-transform:uppercase;font-weight:600;color:var(--g)} .fusepop h3{margin:2px 0 6px;font-family:var(--cond);font-size:21px;line-height:1.2} .fusepop p{margin:0 0 10px;line-height:1.5;font-size:14.5px} .fusepop .fp-at{display:flex;flex-direction:column;gap:2px;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:var(--surface);margin-bottom:10px} .fusepop .fp-at span{font-size:11.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:600} .fusepop .fp-at b{font-family:var(--mono);font-size:14px;font-weight:600} .fusepop .fp-actions{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap} .exporting *{transition:none!important;animation:none!important} @media (prefers-reduced-motion: reduce){.fuse-burn{transition:none}} @media (max-width:760px){ .fusepop{left:12px!important;right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px));top:auto!important;width:auto;max-width:none} .brand p{display:none} .tools{margin-left:0} .legend{width:auto;right:16px;max-height:55%} .mini{display:none} .panel{top:auto;height:62%;width:100%;border-left:0;border-top:1px solid var(--line);border-radius:14px 14px 0 0;padding-bottom:calc(24px + env(safe-area-inset-bottom,0px))} .zoomread{display:none} } </style> </head> <body style="margin:0"> <header class="top"> <div class="brand"> <h1>IF1008 wiring</h1> <p>The Independent File as a live map. Checked against ironfist1008.html, the freeze build: self test 73/73, main path run end to end.</p> </div> <div class="tools"> <label class="search" for="q"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="M20 20l-4-4"></path></svg><input id="q" type="search" placeholder="find a function or key" autocomplete="off" spellcheck="false" aria-label="Find a function, key or node"></label> <button class="btn primary" id="play" type="button" aria-pressed="false">▶ Light the fuse</button> <button class="btn" id="tests" type="button" aria-pressed="false">Self-test coverage</button> <button class="btn" id="infoBtn" type="button">IDs and open items</button> <button class="btn" id="svgBtn" type="button" title="The whole map as a standalone SVG, light theme, for a figure">Save SVG</button> <div class="seg" role="group" aria-label="Zoom"> <button class="btn" id="zout" type="button" aria-label="Zoom out">−</button> <button class="btn" id="zin" type="button" aria-label="Zoom in">+</button> <button class="btn" id="zfit" type="button">Fit</button> <button class="btn" id="zstart" type="button">Start</button> </div> <span class="zoomread" id="zr">100%</span> </div> </header> <main class="stage" id="stage"> <svg id="map" role="img" aria-label="Wiring diagram of IF1008, the Independent File"> <defs> <pattern id="dots" class="gridpat" width="28" height="28" patternUnits="userSpaceOnUse"><circle cx="2" cy="2" r="1.6"></circle></pattern> <marker id="a-call" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="11" markerHeight="11" markerUnits="userSpaceOnUse" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" style="fill:var(--edge)"></path></marker> <marker id="a-event" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="12" markerHeight="12" markerUnits="userSpaceOnUse" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" style="fill:var(--c-bus)"></path></marker> <marker id="a-data" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="12" markerHeight="12" markerUnits="userSpaceOnUse" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" style="fill:var(--c-store)"></path></marker> <marker id="a-paper" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="13" markerHeight="13" markerUnits="userSpaceOnUse" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" style="fill:var(--c-paper)"></path></marker> <marker id="a-test" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="10" markerHeight="10" markerUnits="userSpaceOnUse" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" style="fill:var(--muted)"></path></marker> <marker id="a-lit" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="13" markerHeight="13" markerUnits="userSpaceOnUse" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" style="fill:var(--lit)"></path></marker> </defs> <g id="vp"><g id="world"></g></g> </svg> <details class="legend" id="legend"> <summary>Legend</summary> <div class="inner"> <div> <h3>Subsystem (click to isolate)</h3> <div class="groups" id="groups"></div> </div> <div> <h3>Wires</h3> <div class="keyrow"> <svg viewBox="0 0 54 16"><path class="edge call" d="M2 8H46" marker-end="url(#a-call)"></path></svg><span>Direct call</span> <svg viewBox="0 0 54 16"><path class="edge event" d="M2 8H46" marker-end="url(#a-event)"></path></svg><span>Event bus emit / listen</span> <svg viewBox="0 0 54 16"><path class="edge data" d="M2 8H46" marker-end="url(#a-data)"></path></svg><span>Storage read / write</span> <svg viewBox="0 0 54 16"><path class="edge paper" d="M2 8H46" marker-end="url(#a-paper)"></path></svg><span>Paper or file handoff</span> <svg viewBox="0 0 54 16"><path class="edge call main" d="M2 8H46" marker-end="url(#a-call)"></path></svg><span>Main path (heavier)</span> <svg viewBox="0 0 54 16"><path class="fuse-burn" d="M2 8H40"></path><circle class="fuse-head" cx="42" cy="8" r="5"></circle></svg><span>The fuse, burning toward Take it home</span> </div> </div> <div> <h3>Status</h3> <div class="keyrow"> <svg viewBox="0 0 54 16"><rect x="14" y="0" width="22" height="16" rx="4" style="fill:var(--ink)"></rect><text x="25" y="12.5" text-anchor="middle" style="fill:var(--surface);font:600 12px var(--mono)">T</text></svg><span>Run on IF1008 (self test or the scripted session); solid border</span> <svg viewBox="0 0 54 16"><rect x="14" y="0" width="22" height="16" rx="4" style="fill:var(--surface);stroke:var(--ink);stroke-width:1.5"></rect><text x="25" y="12.5" text-anchor="middle" style="fill:var(--ink);font:600 12px var(--mono)">C</text></svg><span>Wired in code, not run on this build; dashed border</span> <svg viewBox="0 0 54 16"><path d="M25 0L36 16H14z" style="fill:var(--warn)"></path></svg><span>Known limit, disclosed</span> </div> </div> <p class="hint">Drag to pan. Wheel or pinch to zoom. Hover a box to trace its wires; click for detail. Light the fuse to follow one voucher from the QR sheet to Take it home. Keys: + − 0 (fit) s (start) f (fuse) space (pause) / (search) Esc.</p> </div> </details> <div class="mini" id="mini" aria-hidden="true"> <svg id="minisvg"><use href="#world"></use><rect class="vp" id="minivp"></rect></svg> </div> <aside class="panel" id="panel" hidden aria-live="polite"></aside> <div class="fusepop" id="fusepop" hidden role="status" aria-live="polite"></div> <section class="info" id="info" hidden role="dialog" aria-label="Identifiers and open items"> <button class="btn x" type="button" id="infoClose" aria-label="Close">Close</button> <h2>The handle: identifiers</h2> <p class="hint">Every path ends in one record shape, keyed by these IDs.</p> <div class="tblwrap"><table> <thead><tr><th>ID</th><th>Scope</th><th>Minted by</th><th>Changes?</th><th>Status</th></tr></thead> <tbody> <tr><td>Voucher ID <code>id</code></td><td>per specimen, the QR</td><td><code>_mintSheetIds()</code> on the preprint job, claimed by <code>_assignPreprintId()</code>; or <code>uid()</code> on a new draft</td><td>Yes, before save (scan, type, New ID)</td><td>T</td></tr> <tr><td><code>voucherIdSource</code></td><td>per specimen</td><td>set when the ID is claimed: <code>sheet</code> or <code>generated</code></td><td>With the Voucher ID</td><td>T</td></tr> <tr><td><code>occurrenceID</code></td><td>per specimen, GBIF</td><td><code>newDraftOccurrenceUUID()</code> at draft start</td><td>Never</td><td>T</td></tr> <tr><td><code>sessionId</code></td><td>per browser install</td><td><code>sessionId()</code>, stamped at save and on addenda</td><td>Never</td><td>T</td></tr> <tr><td>addendum <code>id</code> + <code>parentOccurrenceID</code></td><td>per correction</td><td><code>createAddendum()</code></td><td>Never</td><td>T</td></tr> <tr><td>bundle <code>id</code> + part</td><td>per take-home part</td><td><code>openTakeHomeModal()</code>; logged as a <code>bundle</code> record with every photo's SHA-256</td><td>Never; marked handed off on Save or Email</td><td>T</td></tr> <tr><td><code>compileBatchId</code></td><td>per Compiler merge</td><td><code>withCompileBatchId()</code></td><td>New each compile</td><td>C</td></tr> <tr><td><code>createdWith</code></td><td>per record</td><td><code>BUILD</code>, at save and on addenda</td><td>Never; blank on older records</td><td>T</td></tr> <tr><td><code>exportedWith</code></td><td>per export</td><td><code>BUILD</code>, on the JSON envelope and the collection-system exports</td><td>n/a</td><td>T</td></tr> </tbody> </table></div> <h3>Open items</h3> <ul> <li>Safari and Firefox can't scan by camera. Type ID fallback only. The fix is a bundled QR decoder, about 100 KB or more.</li> <li>Safari storage behaviour and the keyboard guard on iOS are unverified on real devices.</li> <li>The Email share sheet in Take it home has not been tried on a real phone; its save-and-mail fallback has.</li> <li>openDS: every key IF writes is checked against the 0.4.0 schemas. The registry fields (dcterms:identifier, ods:version, ods:midsLevel, ods:sourceSystemID and the rest) are left for DiSSCo to assign, as the export's README says.</li> <li>The JSON-LD <code>isBasedOn</code> DOIs are placeholders: <code>DOI-TBD-MULE</code>, <code>DOI-TBD-FLORILEGIUM</code>.</li> <li>Edges marked C are read from the code. Confirm them from a <code>?trace</code> run and <code>_traceDump()</code>.</li> </ul> </section> </main> <script> "use strict"; /* Yo Jones - data: groups, nodes and wires for ironfist1008. Every function name below was checked against the build's own function manifest (ironfist1008-fx-manifest.json), and the main path was run end to end in a scripted browser session on ironfist1008: mint sheet IDs, claim one, fill and save, see the card, build the take-home bundle, save it. */ const BUILD_NAME = "ironfist1008"; const GROUPS = { entry:{name:"Entry and test"}, paper:{name:"Paper and files"}, ident:{name:"Identify"}, draft:{name:"Draft"}, save:{name:"Save"}, store:{name:"Storage"}, bus:{name:"Event bus"}, render:{name:"Render"}, addenda:{name:"Addenda"}, photos:{name:"Photos"}, io:{name:"Canonical I/O"}, home:{name:"Take it home"} }; const W = 340, GAP = 440, X0 = 40; const cx = c => X0 + c*GAP; const COLS = [ ["0 Entry","before and at load"],["1 Identify","where the Voucher ID comes from"],["2 Draft","the record in progress"], ["3 Save","gate, build, reset"],["4 Persist","localStorage and IndexedDB"],["5 Normalize and notify","bus and FIELD_DEFS"], ["6 Render and export","what the collector sees and hands on"],["7 Take it home","off the device, records and photos together"], ["8 Merge","back from other devices"] ]; const NODES = [ /* ---- 0 entry ---- */ {id:"start",c:0,y:150,g:"entry",s:"T",t:"Page load",sub:["every session enters here","?ws=name picks the workspace"],fns:["_traceInstall()","_kernelMigrateLegacyIfNeeded()","initKeyboardGuard()","checkStorageResilience()"],ref:"1.1, 1.6, 3.17, 7.1, 8.2", note:"Init installs the tracer, folds any legacy keys into the Store kernel once, starts a fresh generated draft, offers an autosaved draft back, wires the keyboard guard on touch devices, and asks for persistent storage."}, {id:"qrsheets",c:0,y:560,g:"ident",s:"T",step:1,start:true,t:"QR sheets",sub:["preprint job, before the trip"],fns:["openQrSheetModal()","_mintSheetIds(n)","qrSheetDoc()","printQrSheets()"],ref:"5.9", note:"Mints lowercase v4 UUIDs whose last four characters are unique within the batch and against vouchers already in this browser. One letter page per UUID: write-in lines, a 2 in QR, the full UUID, the last four in bold. Minting and the sheet document ran in the scripted session; the print dialog was not opened.", where:"Nothing exists yet but a UUID on paper. The sheets are printed before the trip.", at:"paper, not yet a record"}, {id:"paper",c:0,y:900,g:"paper",kind:"paper",step:2,t:"Printed sheet",sub:["one sheet per UUID","2 in QR + full UUID","last four in bold"],ref:"5.9", note:"The physical handoff. The sheet goes in the press with the plant, then gets scanned (or typed) to claim its UUID as the Voucher ID.", where:"The UUID travels with the plant, in the press. Paper is the first carrier and the longest-lived one.", at:"in the press, with the plant"}, {id:"selftest",c:0,y:1200,g:"entry",s:"T",t:"?selftest",sub:["73 checks, 73/73 pass"],fns:["runSelfTest()"],ref:"7.4", note:"A fixture voucher through canonicalRecord, a Store write and read, every file export, flat-file reimport, the addendum chain, the reconciler, Symbiota, Specify and openDS builds, JSON-LD and filename against BUILD, and the keyboard viewport. Turn on Self-test coverage to see what it touches."}, {id:"trace",c:0,y:1400,g:"entry",s:"C",t:"?trace",sub:["runtime tracer"],fns:["_traceDump()"],ref:"7.3", note:"Work one voucher through, then call _traceDump() in the console. It lists the functions a real session walks, including handlers static reading misses. Edges marked C here should be confirmed from its output."}, /* ---- 1 identify ---- */ {id:"scan",c:1,y:560,g:"ident",s:"C",lim:"Safari and Firefox have no BarcodeDetector.",step:3,t:"Scan sheet",sub:["camera + native BarcodeDetector"],fns:["openQRModal('scan')","startVoucherScan()","_doScanFrame()"],ref:"2.4", note:"The default capture path. The camera loop hands each decoded value to _assignPreprintId. Browsers without BarcodeDetector fall back to Type ID automatically.", where:"The camera reads the sheet. Still no record, only a value in hand.", at:"between paper and draft"}, {id:"assign",c:1,y:810,g:"ident",s:"T",step:4,t:"Claim the sheet ID",sub:["label: Voucher ID (from sheet)"],fns:["_assignPreprintId(raw)","_voucherQrMode('preprint')","migratePhotosVoucherId()"],ref:"2.4, 3.2", note:"Rejects URL-shaped IDs and IDs already in this report. Switches the draft label to \"Voucher ID (from sheet)\" and moves any draft photos to the new ID. Ran in the scripted session.", where:"The draft takes the sheet's UUID as its Voucher ID. The occurrenceID was minted when the draft began and never changes.", at:"the draft, in memory"}, {id:"typeid",c:1,y:1090,g:"ident",s:"L",lim:"Typing the full UUID by hand.",t:"Type ID fallback",sub:["no camera decoder","type the full UUID"],ref:"2.4", note:"Safari and Firefox lack BarcodeDetector, so the collector types the UUID. The fix is a bundled QR decoder of about 100 KB or more."}, {id:"gen",c:1,y:1290,g:"ident",s:"T",t:"Generated ID",sub:["no sheet"],fns:["uid()","newDraftOccurrenceUUID()"],ref:"1.5, 2.4", note:"The generated Voucher ID stands and the label reads \"Voucher ID (generated, no sheet)\". It is matched to paper later with QR tags, or written on the newspaper. The occurrenceID is minted here and never changes."}, {id:"qrtags",c:1,y:1530,g:"ident",s:"C",t:"QR tags",sub:["reconciliation print","generated IDs meet paper"],fns:["openQrTagModal()"],ref:"5.10", note:"For vouchers saved without a sheet: prints tags for their generated IDs so they can be matched to the specimens at the office."}, {id:"dup",c:1,y:1760,g:"draft",s:"C",t:"Duplicate for this site",sub:["fresh draft, same site"],fns:["duplicateForThisSite()"],ref:"3.10", note:"Starts a fresh draft and carries over the site snapshot taken by resetVoucherForm()."}, /* ---- 2 draft ---- */ {id:"draft",c:2,y:700,g:"draft",s:"T",step:5,t:"Draft",sub:["Voucher ID + occurrenceID"],fns:["_voucherIdTail()","_setVoucherIdLabel()"],ref:"3.11 to 3.14", note:"The in-progress record. The last four of the Voucher ID show bold and are searchable. The Voucher ID can still change here (scan, type, New ID); the occurrenceID cannot.", where:"In memory, mirrored to the draft autosave. Not saved. A reload offers it back.", at:"memory + draft autosave"}, {id:"kb",c:2,y:430,g:"draft",s:"T",lim:"No iPhone to test on.",t:"Keyboard guard",sub:["field stays above the keyboard","Prev / Next / Done (not iOS)"],fns:["kbKeepInView()","kbFocusStep(dir)"],ref:"3.17", note:"On a phone the page shrinks above the keyboard (Android), the header folds away while typing, the focused field scrolls into the visible band, and Enter moves to the next field. Run on an Android phone and in an emulated one; Safari behaviour is from the spec only."}, {id:"timer",c:2,y:960,g:"draft",s:"C",t:"Voucher timer",sub:["arms on first real action","never shown on screen"],fns:["_armVoucherTimerOnFirstAction()","beginVoucherTimer()"],ref:"3.4", note:"Arms silently on the first real action. An ID reassignment does not reset it. Exported as startedAt and durationSeconds; the capture-time study reads these."}, {id:"photos",c:2,y:1220,g:"photos",s:"C",lim:"HEIC isn't parsed for GPS.",t:"Photo input",sub:["camera and picker inputs"],fns:["handlePhotoInput()","_jpegWithVoucherExif()","extractExifGPS()","reconcileExifGPS()"],ref:"3.2, 3.3", note:"Normalizes to JPEG, writes the Voucher ID into EXIF, and saves to IndexedDB. EXIF GPS on a JPEG is compared with the form and can offer coordinates."}, {id:"restore",c:2,y:1520,g:"draft",s:"C",t:"Restore banner",sub:["offers the draft back on load"],fns:["checkForDraftAutosave()","restoreDraftAutosave()"],ref:"3.12, 3.13", note:"On load a banner offers the autosaved draft. Accepting emits draft:restore; applyDraftSnapshot brings back the Voucher ID, occurrenceID and photos."}, {id:"autosave",c:2,y:1750,g:"draft",s:"C",t:"Draft autosave",sub:["debounced write"],fns:["scheduleDraftAutosave()","_draftAutosaveNow()"],ref:"3.12", note:"Debounced write of the in-progress draft, including Voucher ID, occurrenceID and coordinate source."}, /* ---- 3 save ---- */ {id:"spill",c:3,y:430,g:"save",s:"T",t:"Autofill spill guard",sub:["undoes browser autofill writes"],fns:["_installAutofillSpillGuard()","spillGuardedIds()"],ref:"3.16", note:"Browser autofill can write a name into the wrong box. The guard reverts writes the collector did not type. Confirmed the hard way: it reverted the scripted session's own untyped writes until they were typed key by key."}, {id:"gate",c:3,y:700,g:"save",s:"T",step:6,t:"Save gate",sub:["required fields and formats","blank notes: \"Not recorded\"","a photo, or a reason for none"],fns:["saveVoucher()","validateRequiredFields()","fillNotRecorded()"],ref:"3.15", note:"Form submit runs the gate. Nothing is written until it passes. Optional notes left blank are written as \"Not recorded\" so an empty field never reads two ways. Ran in the scripted session.", where:"Checked before anything is written. If a field is missing, the record stays a draft and the first gap is shown.", at:"still the draft"}, {id:"build",c:3,y:1010,g:"save",s:"T",step:7,t:"voucherFromForm()",sub:["builds the record, stamps it"],fns:["sessionId()","createdWith = BUILD"],ref:"2.1, 3.14", note:"Builds the record from the form and stamps sessionId and createdWith. The scripted save came back stamped ironfist1008 with a sessionId.", where:"The record now exists as one object, stamped with the device install and the build that made it.", at:"a record, in memory"}, {id:"reset",c:3,y:1250,g:"save",s:"T",t:"resetVoucherForm()",sub:["new Voucher ID + occurrenceID","takes the site snapshot"],ref:"3.15, 3.10", note:"After a save, starts a new draft with new IDs and keeps a site snapshot for Duplicate for this site."}, {id:"addendum",c:3,y:1480,g:"addenda",s:"T",t:"Addenda",sub:["+ Georeference fix","+ Redetermination"],fns:["showAddAddendumModal()","createAddendum()"],ref:"2.1, 4.2", note:"Post-save corrections from a card. The original is never overwritten. createAddendum mints the addendum id and parentOccurrenceID, stamps createdWith, writes it, then emits addenda:changed. The chain is in the self test; the card buttons are not."}, {id:"ev_d",c:3,y:1760,g:"bus",kind:"event",t:"draft:restore",ref:"3.12", note:"Emitted by the draft restore. One listener: applyDraftSnapshot."}, /* ---- 4 persist ---- */ {id:"legacy",c:4,y:430,g:"store",s:"C",t:"Legacy keys",sub:["old fieldVoucher* keys","read once at init"],ref:"1.6", note:"Read once by _kernelMigrateLegacyIfNeeded() and folded into the Store kernel."}, {id:"store",c:4,y:700,g:"store",s:"T",step:8,t:"Store kernel",sub:["localStorage, typed records"],fns:["fieldVoucherRecords_v1","voucher addendum bundle uiState"],ref:"1.3", note:"RECORD_KEY, one per workspace. Typed records: voucher, addendum, bundle (what each take-home part held), list, uiState. A refused write keeps a banner up until one succeeds.", where:"Saved. The record lives in this browser's localStorage, its photos in IndexedDB. One device, one browser.", at:"this browser only"}, {id:"reconcile",c:4,y:990,g:"store",s:"T",t:"reconcileStores()",sub:["vouchers vs photos","reports, never repairs"],ref:"7.2", note:"Compares vouchers against photos and reports disagreement. It never repairs anything. In the self test."}, {id:"idb",c:4,y:1220,g:"photos",s:"C",t:"Photo store",sub:["IndexedDB, indexed by Voucher ID"],fns:["fieldVoucherPhotos_v1","store: photos"],ref:"3.2", note:"Image blobs, one database per workspace, indexed by Voucher ID."}, {id:"guards",c:4,y:1480,g:"store",s:"C",lim:"No Safari hardware for testing.",t:"Storage guards",sub:["refused-write banner","WebKit 7-day warning"],fns:["reportStorageWriteFailure()","requestPersistNow()"],ref:"1.4, 7.1", note:"A refused write keeps a banner up until a write succeeds. Storage resilience asks for persistent storage and warns about WebKit's 7-day eviction."}, {id:"draftkey",c:4,y:1730,g:"store",s:"C",t:"Draft store",sub:["localStorage"],fns:["fieldVoucherDraftAutosave_v1"],ref:"3.12", note:"DRAFT_AUTOSAVE_KEY, one per workspace. Holds the in-progress draft."}, /* ---- 5 normalize and notify ---- */ {id:"ev_v",c:5,y:640,g:"bus",kind:"event",step:9,t:"vouchers:changed",ref:"1.3, 8.1", note:"Emitted by save, import, delete, bulk delete, bundle import and init. Listeners: renderVouchers, renderPhotoGallery, renderAddendumProblems, scheduleTakeHomeNote, renderWorkspaceBar, invalidateWorkspaceIndex.", where:"The bus tells every listener at once. Nothing calls a redraw directly.", at:"this browser only"}, {id:"ev_a",c:5,y:860,g:"bus",kind:"event",t:"addenda:changed",ref:"2.1, 8.1", note:"Emitted by createAddendum and by addendum delete or ingest. Listeners: renderVouchers, renderAddendumProblems."}, {id:"canon",c:5,y:1080,g:"io",s:"T",t:"canonicalRecord()",sub:["the single FIELD_DEFS","choke point"],ref:"2.3", note:"Every import passes through it and every export reads from FIELD_DEFS. In the self test."}, {id:"import",c:5,y:1350,g:"io",s:"T",t:"Import JSON / CSV",sub:["duplicate occurrenceIDs merge","URL-shaped IDs swapped out"],fns:["importJSON()","importCSV()","addRecords()"],ref:"2.3, 5.2, 5.3", note:"Duplicate occurrenceIDs merge instead of doubling. URL-shaped Voucher IDs are swapped out, with the old value kept in legacyVoucherId. Round trip in the self test."}, /* ---- 6 render and export ---- */ {id:"rv",c:6,y:430,g:"render",s:"T",step:10,t:"renderVouchers()",sub:["cards, bold last four","current determination"],fns:["decorateAllWithCurrentState()","_voucherIdTail()"],ref:"4.1, 2.1", note:"Draws the card list with the current determination. Cards carry the addendum buttons. The scripted save produced its card.", where:"The card appears in the saved list with its bold last four. The collector can see it, and so can Take it home.", at:"this browser only"}, {id:"rpg",c:6,y:720,g:"render",s:"C",t:"renderPhotoGallery()",sub:["reads the photo store"],ref:"3.2", note:"Listener on vouchers:changed."}, {id:"rap",c:6,y:900,g:"render",s:"C",t:"renderAddendumProblems()",sub:["flags broken addenda chains"],ref:"4.3", note:"Listener on vouchers:changed and addenda:changed."}, {id:"exports",c:6,y:1090,g:"io",s:"T",t:"File exports",sub:["per card, bulk, or all"],fns:["exportCSV()","exportJSON()","exportXLSX()","exportHTMLTable()","printLabels()","exportAllPhotosZip()"],ref:"5.1 to 5.8", note:"All read from FIELD_DEFS. JSON carries exportedWith on its envelope. In the self test."}, {id:"coll",c:6,y:1480,g:"io",s:"T",t:"Collection systems",sub:["Symbiota DwC-A, Specify WorkBench","openDS 0.4.0","qualifier + verification status"],fns:["_csPrepare()","exportSymbiota()","exportSpecify()","exportOpenDS()"],ref:"5.11", note:"Built against each system's published format. A confident determination goes out with a blank identificationQualifier and identificationVerificationStatus beside it, never a lone blank. In the self test. Every openDS key was checked against the 0.4.0 schemas; openDS has no verification-status key, so there the status rides in identificationRemarks."}, /* ---- 7 take it home ---- */ {id:"snapshot",c:7,y:180,g:"home",s:"C",t:"Snapshot offer",sub:["the only nag","10 unbundled, or 3 days"],fns:["scheduleTakeHomeNote()","maybeOfferSnapshot()"],ref:"5.5c", note:"The one unprompted request the tool makes, and it concerns the record: when work has lived only in this browser for three days, or ten vouchers are in no bundle, it asks once to be copied out. Nothing saves without a tap."}, {id:"takehome",c:7,y:500,g:"home",s:"T",step:11,t:"Take it home",sub:["builds on open","Save or Email, nothing else"],fns:["openTakeHomeModal()","_bundlePlan()","_takeHomePrepare(k)"],ref:"5.5b", note:"One line of counts, then Save or Email per part. The part is built the moment the dialog opens, so the first tap works. Ran in the scripted session.", where:"The bundle is being built the moment the dialog opens, so the first tap sends it.", at:"this browser, leaving"}, {id:"bundle",c:7,y:800,g:"home",kind:"paper",s:"T",step:12,t:"The bundle",sub:["open-me.html","vouchers.json, vouchers.csv","photos/, manifest.csv (SHA-256)","README.txt"],fns:["_bundleBuildPart()"],ref:"5.5b", note:"One ZIP per part, complete for its vouchers. open-me.html shows every voucher with its photos, offline. manifest.csv lists every file with its SHA-256. The scripted run produced exactly these files.", where:"One ZIP holds the page, the records as JSON and CSV, the photos, and a checksum for every file. Records and photos travel tied.", at:"a ZIP, not yet off the device"}, {id:"leave",c:7,y:1150,g:"home",s:"T",step:13,lim:"Share sheet not yet tried on a real phone.",t:"Leaves the device",sub:["Save: a download","Email: share sheet, else save + mail"],fns:["_takeHomeSave(k)","_takeHomeEmail(k)","_takeHomeHandedOff()"],ref:"5.5b", note:"Save downloads the part. Email uses the share sheet with the ZIP attached, and falls back to save plus a mail window by itself. Either way the part is logged as handed off, which is what the free-space step reads. Save ran in the scripted session.", where:"Home. The records and photos are off the device, together. Clearing this browser can no longer lose them.", at:"off the device"}, {id:"free",c:7,y:1460,g:"home",s:"C",t:"Free up space",sub:["only photos in a handed-off bundle","and unchanged since (SHA-256)"],fns:["clearBundledPhotos()"],ref:"5.5b", note:"Folded away under Take it home. A voucher's photos go only if every one is in a saved or sent bundle and still hashes to what the bundle recorded. The record keeps the list of its photos and which bundle has them, written and re-read before anything is deleted."}, {id:"github",c:7,y:1720,g:"home",s:"C",t:"Send to GitHub",sub:["its own dialog, from Export files","the ZIP as it is, no extracting"],fns:["openGithubExportModal()","_githubUploadSketch()"],ref:"5.5b", note:"The same bundle cut to GitHub's upload limits (24 MB, 90 photos a part). No login in the tool: save the ZIP, open the repo's upload page, tap \"choose your files\" (drawn in the dialog, circled), pick the ZIP, Commit changes."}, /* ---- 8 merge ---- */ {id:"files",c:8,y:760,g:"paper",kind:"paper",s:"C",t:"Files from other devices",sub:["take-home bundles",".json .csv .html .xlsx .zip"],ref:"9.5", note:"What comes back: each device's bundles and exports, gathered on one machine."}, {id:"bringback",c:8,y:1040,g:"io",s:"C",t:"Bring it back",sub:["a bundle into this browser"],fns:["importBundleFiles()","_importOneBundle()"],ref:"5.5c", note:"Imports a take-home bundle, records and photos, into this browser through the same canonical door as every other import."}, {id:"compile",c:8,y:1280,g:"io",s:"C",t:"Compiler Drawer",sub:["merge by occurrenceID","merge addenda chains","in memory; local report untouched"],fns:["window.openCompilerDrawer()","withCompileBatchId()","mergeAddendaInto()"],ref:"9", note:"Works in memory. The local report isn't changed unless you choose Add this report as an input. Every compiled record carries a new compileBatchId."}, {id:"compiled",c:8,y:1640,g:"io",s:"C",t:"Compiled output",sub:["JSON, CSV, XLSX, HTML, photos ZIP"],fns:["compileBatchId"],ref:"9.6", note:"One merged set from many devices, with the batch that made it stamped on every record."} ]; /* [from, to, kind, label, opts] */ const EDGES = [ ["start","legacy","call","read once",{bend:-30,lt:.3}], ["legacy","store","data","migrate"], ["start","kb","call","touch only",{lt:.55}], ["qrsheets","paper","paper","print",{main:1}], ["paper","scan","paper","to the field",{main:1}], ["paper","typeid","paper","no camera decoder"], ["scan","assign","call","raw value",{main:1}], ["typeid","assign","call","typed UUID"], ["assign","draft","call","",{main:1}], ["assign","idb","data","migratePhotosVoucherId",{lt:.7}], ["gen","draft","call","generated ID",{lt:.4}], ["gen","qrtags","call","match later"], ["dup","draft","call","site snapshot",{lt:.3}], ["kb","draft","call","",{}], ["draft","gate","call","submit",{main:1}], ["draft","timer","call","first action"], ["timer","build","data","startedAt, durationSeconds",{lt:.45}], ["photos","idb","data","JPEG + EXIF"], ["photos","draft","call","GPS offer",{lt:.3}], ["draft","autosave","call","debounced",{bend:-60,lt:.85}], ["autosave","draftkey","data","write"], ["draftkey","restore","data","on load",{lt:.35}], ["restore","ev_d","event","emit"], ["ev_d","draft","event","applyDraftSnapshot",{lt:.25}], ["spill","gate","call","guards the form"], ["gate","build","call","",{main:1}], ["build","store","data","Store voucher",{main:1}], ["gate","reset","call","after save",{bend:-70,lt:.55}], ["reset","dup","call","site snapshot",{lt:.4}], ["store","ev_v","event","emit",{main:1}], ["ev_v","rv","event","",{main:1}], ["ev_v","rpg","event",""], ["ev_v","rap","event",""], ["ev_v","snapshot","event","unbundled count",{lt:.6}], ["rv","addendum","call","card buttons",{lt:.55}], ["addendum","store","data","Store addendum",{lt:.5}], ["addendum","ev_a","event","emit",{lt:.4}], ["ev_a","rv","event",""], ["ev_a","rap","event",""], ["store","reconcile","data",""], ["idb","reconcile","data",""], ["store","guards","data","refused write",{bend:60,lt:.6}], ["idb","rpg","data","blobs",{lt:.4}], ["store","canon","data","read",{lt:.4}], ["import","canon","call","addRecords"], ["canon","exports","call","FIELD_DEFS"], ["canon","coll","call","FIELD_DEFS",{lt:.6}], ["idb","exports","data","photos ZIP",{lt:.55}], ["rv","takehome","call","Take it home",{main:1}], ["snapshot","takehome","call","Save a snapshot"], ["takehome","bundle","call","",{main:1}], ["idb","bundle","data","photos, SHA-256",{bend:90,lt:.7}], ["bundle","leave","paper","Save or Email",{main:1}], ["leave","free","call","handed off"], ["free","idb","data","delete, after the record is written",{bend:120,lt:.55}], ["github","bundle","call","same builder, GitHub-size parts",{bend:-150,lt:.5}], ["leave","files","paper","arrives elsewhere"], ["exports","files","paper","files",{lt:.45}], ["files","bringback","paper",""], ["bringback","canon","call","addRecords",{bend:-40,lt:.2}], ["files","compile","paper","",{bend:130,lt:.5}], ["compile","compiled","call",""], ["selftest","canon","test",""], ["selftest","store","test",""], ["selftest","build","test",""], ["selftest","exports","test",""], ["selftest","coll","test",""], ["selftest","import","test",""], ["selftest","reconcile","test",""], ["selftest","addendum","test",""] ]; /* The fuse: node ids in order. It burns along the wire between each pair. */ const FUSE = ["qrsheets","paper","scan","assign","draft","gate","build","store","ev_v","rv","takehome","bundle","leave"]; /* Yo Jones - tiny SVG element factory */ const NS = "http://www.w3.org/2000/svg"; function el(tag, attrs, parent, text){ const e = document.createElementNS(NS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); if (text != null) e.textContent = text; if (parent) parent.appendChild(e); return e; } /* Yo Jones - measures each node box from its line counts */ function sizeNode(n){ n.x = cx(n.c); if (n.kind === "event"){ n.w = 300; n.x += (W - n.w)/2; n.h = 54; return; } n.w = W; let h = 18 + 14 + 8 + 26; h += (n.sub||[]).length * 21; if (n.fns && n.fns.length) h += 12 + n.fns.length * 23; n.h = h + 16; } /* Yo Jones - wraps a far-zoom title into short lines */ function wrap(t, max){ const words = t.split(" "), out = []; let cur = ""; for (const w of words){ if ((cur + " " + w).trim().length > max && cur){ out.push(cur); cur = w; } else cur = (cur + " " + w).trim(); } if (cur) out.push(cur); return out; } const byId = {}; NODES.forEach(n => { sizeNode(n); byId[n.id] = n; }); const edges = EDGES.map(([f,t,k,l,o]) => Object.assign({f,t,k,l},o||{})); const world = document.getElementById("world"); const svg = document.getElementById("map"); /* Yo Jones - world bounds, used by fit and the minimap */ const B = (() => { let x1=Infinity,y1=Infinity,x2=-Infinity,y2=-Infinity; NODES.forEach(n => { x1=Math.min(x1,n.x); y1=Math.min(y1,n.y); x2=Math.max(x2,n.x+n.w); y2=Math.max(y2,n.y+n.h); }); return {x:0, y:0, w:x2+40, h:y2+60}; })(); /* Yo Jones - draws column bands, headers and the dot grid */ function drawBackdrop(){ el("rect",{x:-2000,y:-2000,width:B.w+4000,height:B.h+4000,fill:"url(#dots)"},world); COLS.forEach(([h,s],i) => { if (i % 2 === 0) el("rect",{class:"colband",x:cx(i)-50,y:0,width:GAP,height:B.h,rx:18},world); el("text",{class:"colhead",x:cx(i),y:52},world,h); el("text",{class:"colsub",x:cx(i),y:78},world,s); }); } /* Yo Jones - assigns each edge end a side and spreads ends that share a side */ function route(){ const ends = {}; const push = (id, side, e, which, other) => { const k = id+"|"+side; (ends[k] ||= []).push({e, which, other}); }; edges.forEach(e => { const s = byId[e.f], t = byId[e.t]; const scx = s.x+s.w/2, scy = s.y+s.h/2, tcx = t.x+t.w/2, tcy = t.y+t.h/2; const dx = tcx-scx, dy = tcy-scy; if (Math.abs(dx) > (s.w+t.w)/2*0.9){ e.hz = true; e.ss = dx>0?"r":"l"; e.ts = dx>0?"l":"r"; } else { e.hz = false; e.ss = dy>0?"b":"t"; e.ts = dy>0?"t":"b"; } push(e.f, e.ss, e, "s", e.hz ? tcy : tcx); push(e.t, e.ts, e, "t", e.hz ? scy : scx); }); for (const k in ends){ const [id, side] = k.split("|"), n = byId[id], list = ends[k]; list.sort((a,b) => a.other - b.other); const span = (side==="l"||side==="r") ? n.h*0.64 : n.w*0.6; list.forEach((it,i) => { const off = list.length === 1 ? 0 : -span/2 + span*i/(list.length-1); let px, py; if (side==="l"){ px=n.x; py=n.y+n.h/2+off; } else if (side==="r"){ px=n.x+n.w; py=n.y+n.h/2+off; } else if (side==="t"){ px=n.x+n.w/2+off; py=n.y; } else { px=n.x+n.w/2+off; py=n.y+n.h; } it.e[it.which+"p"] = [px,py]; }); } edges.forEach(e => { const [sx,sy] = e.sp, [tx,ty] = e.tp, bend = e.bend||0; let c1, c2; if (e.hz){ const d = Math.max(60, Math.abs(tx-sx)*0.45), sg = e.ss==="r"?1:-1; c1 = [sx+sg*d, sy+bend]; c2 = [tx-sg*d, ty+bend]; } else { const d = Math.max(50, Math.abs(ty-sy)*0.45), sg = e.ss==="b"?1:-1; c1 = [sx+bend, sy+sg*d]; c2 = [tx+bend, ty-sg*d]; } e.pts = [[sx,sy],c1,c2,[tx,ty]]; e.d = `M${sx},${sy} C${c1[0]},${c1[1]} ${c2[0]},${c2[1]} ${tx},${ty}`; }); } /* Yo Jones - point on a cubic bezier, for edge labels */ function bez(p, t){ const u = 1-t; return [0,1].map(i => u*u*u*p[0][i] + 3*u*u*t*p[1][i] + 3*u*t*t*p[2][i] + t*t*t*p[3][i]); } const edgeLayer = el("g",{},null), labelLayer = el("g",{},null), nodeLayer = el("g",{},null); /* Yo Jones - draws wires and their labels */ function drawEdges(){ edges.forEach(e => { const cls = `edge ${e.k}${e.main?" main":""}`; e.el = el("path",{class:cls,d:e.d,"marker-end":`url(#a-${e.k})`},edgeLayer); if (e.l){ const [lx,ly] = bez(e.pts, e.lt ?? .5); e.lab = el("text",{class:`elabel ${e.k}`,x:lx,y:ly+4,"text-anchor":"middle"},labelLayer,e.l); } }); } /* Yo Jones - draws one node box with eyebrow, title, subs, functions, chips and step badge */ function drawNode(n){ const g = el("g",{class:`node st-${n.s||"x"}${n.kind==="event"?" ev":""}${n.kind==="paper"?" pp":""}`,style:`--g:var(--c-${n.g})`,tabindex:0,role:"button","aria-label":`${n.t}. ${GROUPS[n.g].name}. Open detail.`},nodeLayer); g.dataset.id = n.id; n.el = g; if (n.kind === "event"){ el("rect",{class:"body",x:n.x,y:n.y,width:n.w,height:n.h,rx:27},g); el("text",{class:"evtext",x:n.x+n.w/2,y:n.y+n.h/2,"text-anchor":"middle","dominant-baseline":"central"},g,n.t); el("text",{class:"evtag near",x:n.x+n.w/2,y:n.y-10,"text-anchor":"middle"},g,"EVENT BUS"); } else if (n.kind === "paper"){ const {x,y,w,h} = n, f = 26; el("path",{class:"body",d:`M${x},${y} H${x+w-f} L${x+w},${y+f} V${y+h} H${x} Z`},g); el("path",{class:"ear",d:`M${x+w-f},${y} V${y+f} H${x+w} Z`},g); } else { el("rect",{class:"body",x:n.x,y:n.y,width:n.w,height:n.h,rx:10},g); } if (n.kind !== "event"){ const near = el("g",{class:"near"},g); let y = n.y + 18 + 13; el("text",{class:"eyebrow",x:n.x+16,y},near,GROUPS[n.g].name.toUpperCase()); y += 8 + 25; el("text",Object.assign({class:"title",x:n.x+16,y}, n.t.length > 20 ? {style:"font-size:20px"} : {}),near,n.t); (n.sub||[]).forEach(s => { y += 21; el("text",{class:"sub",x:n.x+16,y},near,s); }); if (n.fns && n.fns.length){ y += 10; el("line",{class:"fnrule",x1:n.x+16,x2:n.x+n.w-16,y1:y,y2:y},near); y += 2; n.fns.forEach(f => { y += 23; el("text",{class:"fn",x:n.x+16,y},near,f); }); } /* long single words (function names) shrink to fit the box instead of spilling out */ const lines = wrap(n.t, 16).slice(0,3), longest = Math.max(...lines.map(l => l.length)); const fs = Math.min(34, Math.floor((n.w - 28) / (longest * 0.66))), lh = Math.round(fs * 1.12); const top = n.y + n.h/2 - (lines.length-1)*lh/2 + fs*0.32; lines.forEach((ln,i) => el("text",Object.assign({class:"fartitle",x:n.x+n.w/2,y:top+i*lh,"text-anchor":"middle"}, fs < 34 ? {style:`font-size:${fs}px`} : {}),g,ln)); /* status chips, top right */ let rx = n.x + n.w - 12 - (n.kind === "paper" ? 26 : 0); if (n.s === "L" || n.lim){ const c = el("g",{class:"chip L"},g); el("path",{d:`M${rx-11},${n.y+10} L${rx},${n.y+30} L${rx-22},${n.y+30} Z`},c); el("text",{x:rx-11,y:n.y+28,"text-anchor":"middle"},c,"!"); rx -= 30; } if (n.s === "T" || n.s === "C"){ const c = el("g",{class:`chip ${n.s}`},g); el("rect",{x:rx-24,y:n.y+10,width:24,height:20,rx:5},c); el("text",{x:rx-12,y:n.y+25,"text-anchor":"middle"},c,n.s); } } if (n.step){ const s = el("g",{class:"step"},g); el("circle",{cx:n.x,cy:n.y,r:22},s); el("text",{x:n.x,y:n.y+7.5,"text-anchor":"middle"},s,n.step); } if (n.start){ const f = el("g",{class:"startflag"},g); el("rect",{class:"pulse",x:n.x-8,y:n.y-8,width:n.w+16,height:n.h+16,rx:16},g); el("rect",{x:n.x+n.w/2-105,y:n.y-78,width:210,height:48,rx:9},f); el("path",{d:`M${n.x+n.w/2-13},${n.y-31} L${n.x+n.w/2+13},${n.y-31} L${n.x+n.w/2},${n.y-16} Z`},f); el("text",{x:n.x+n.w/2,y:n.y-44,"text-anchor":"middle"},f,"START HERE"); } } drawBackdrop(); route(); world.appendChild(edgeLayer); world.appendChild(labelLayer); world.appendChild(nodeLayer); drawEdges(); NODES.forEach(drawNode); /* Yo Jones - neighbor index for hover tracing and the detail panel */ const outs = {}, ins = {}; edges.forEach(e => { (outs[e.f] ||= []).push(e); (ins[e.t] ||= []).push(e); }); /* ---------- pan and zoom ---------- */ const vp = document.getElementById("vp"); const view = {k:1, x:0, y:0}; const zr = document.getElementById("zr"); const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; /* Yo Jones - writes the view transform, level-of-detail class and minimap box */ function apply(){ vp.setAttribute("transform", `translate(${view.x},${view.y}) scale(${view.k})`); svg.classList.toggle("far", view.k < 0.8); zr.textContent = Math.round(view.k*100) + "%"; const r = svg.getBoundingClientRect(); const m = document.getElementById("minivp"); m.setAttribute("x", -view.x/view.k); m.setAttribute("y", -view.y/view.k); m.setAttribute("width", r.width/view.k); m.setAttribute("height", r.height/view.k); if (typeof placePop === "function") placePop(); } const clampK = k => Math.min(3, Math.max(0.12, k)); /* Yo Jones - zoom by a factor about a screen point */ function zoomAt(px, py, f){ const k = clampK(view.k*f); f = k/view.k; view.x = px - (px - view.x)*f; view.y = py - (py - view.y)*f; view.k = k; apply(); } let anim = 0; /* Yo Jones - eases the view to a target transform */ function animateTo(t){ cancelAnimationFrame(anim); if (reduce){ Object.assign(view,t); apply(); return; } const s = {...view}, t0 = performance.now(), D = 420; const step = now => { const p = Math.min(1,(now-t0)/D), e = 1-Math.pow(1-p,3); view.k = s.k+(t.k-s.k)*e; view.x = s.x+(t.x-s.x)*e; view.y = s.y+(t.y-s.y)*e; apply(); if (p < 1) anim = requestAnimationFrame(step); }; anim = requestAnimationFrame(step); } /* Yo Jones - computes the transform that frames a world box */ function frame(b, pad=40, maxK=1.2){ const r = svg.getBoundingClientRect(); const k = clampK(Math.min((r.width-2*pad)/b.w, (r.height-2*pad)/b.h, maxK)); return {k, x: r.width/2 - (b.x+b.w/2)*k, y: r.height/2 - (b.y+b.h/2)*k}; } /* Yo Jones - bounding box of a set of node ids */ function boxOf(ids, extraTop=0){ let x1=Infinity,y1=Infinity,x2=-Infinity,y2=-Infinity; ids.forEach(id => { const n = byId[id]; x1=Math.min(x1,n.x); y1=Math.min(y1,n.y); x2=Math.max(x2,n.x+n.w); y2=Math.max(y2,n.y+n.h); }); return {x:x1-30, y:y1-30-extraTop, w:x2-x1+60, h:y2-y1+60+extraTop}; } const fitAll = () => animateTo(frame(B, 24)); const fitStart = () => animateTo(frame(boxOf(["qrsheets","paper","scan","assign","draft","gate"], 60), 30, 1)); /* wheel */ svg.addEventListener("wheel", e => { e.preventDefault(); const r = svg.getBoundingClientRect(); const f = Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0016)); zoomAt(e.clientX - r.left, e.clientY - r.top, f); }, {passive:false}); /* Yo Jones - pointer pan and two-finger pinch */ const ptrs = new Map(); let moved = 0, pinch0 = null; svg.addEventListener("pointerdown", e => { svg.setPointerCapture(e.pointerId); ptrs.set(e.pointerId, {x:e.clientX, y:e.clientY}); moved = 0; if (ptrs.size === 2){ const [a,b] = [...ptrs.values()]; pinch0 = {d:Math.hypot(a.x-b.x,a.y-b.y), k:view.k}; } cancelAnimationFrame(anim); }); svg.addEventListener("pointermove", e => { if (!ptrs.has(e.pointerId)) return; const p = ptrs.get(e.pointerId), dx = e.clientX-p.x, dy = e.clientY-p.y; p.x = e.clientX; p.y = e.clientY; if (ptrs.size === 1){ moved += Math.abs(dx)+Math.abs(dy); if (moved > 4) svg.classList.add("dragging"); view.x += dx; view.y += dy; apply(); } else if (ptrs.size === 2 && pinch0){ const [a,b] = [...ptrs.values()], r = svg.getBoundingClientRect(); const d = Math.hypot(a.x-b.x,a.y-b.y); moved += 10; zoomAt((a.x+b.x)/2 - r.left, (a.y+b.y)/2 - r.top, (pinch0.k*d/pinch0.d)/view.k); } }); const up = e => { ptrs.delete(e.pointerId); if (ptrs.size < 2) pinch0 = null; if (!ptrs.size) svg.classList.remove("dragging"); }; svg.addEventListener("pointerup", up); svg.addEventListener("pointercancel", up); /* buttons */ const center = () => { const r = svg.getBoundingClientRect(); return [r.width/2, r.height/2]; }; document.getElementById("zin").onclick = () => { const [x,y] = center(); zoomAt(x,y,1.3); }; document.getElementById("zout").onclick = () => { const [x,y] = center(); zoomAt(x,y,1/1.3); }; document.getElementById("zfit").onclick = fitAll; document.getElementById("zstart").onclick = fitStart; /* minimap */ const minisvg = document.getElementById("minisvg"); minisvg.setAttribute("viewBox", `0 0 ${B.w} ${B.h}`); /* Yo Jones - click or drag on the minimap to recenter */ function miniGo(e){ const r = minisvg.getBoundingClientRect(), s = svg.getBoundingClientRect(); const wx = (e.clientX - r.left)/r.width*B.w, wy = (e.clientY - r.top)/r.height*B.h; view.x = s.width/2 - wx*view.k; view.y = s.height/2 - wy*view.k; apply(); } let miniDown = false; minisvg.addEventListener("pointerdown", e => { miniDown = true; minisvg.setPointerCapture(e.pointerId); miniGo(e); }); minisvg.addEventListener("pointermove", e => { if (miniDown) miniGo(e); }); minisvg.addEventListener("pointerup", () => miniDown = false); /* ---------- hover trace, selection, detail ---------- */ let selected = null; /* Yo Jones - lights a node, its neighbors and its wires */ function trace(id){ clearTrace(); if (!id) return; svg.classList.add("focusing"); byId[id].el.classList.add("hot"); [...(outs[id]||[]), ...(ins[id]||[])].forEach(e => { if (e.k === "test" && !svg.classList.contains("show-tests")) return; e.el.classList.add("hot"); e.lab && e.lab.classList.add("hot"); byId[e.f].el.classList.add("nb"); byId[e.t].el.classList.add("nb"); }); } function clearTrace(){ svg.classList.remove("focusing"); svg.querySelectorAll(".hot,.nb").forEach(x => x.classList.remove("hot","nb")); } nodeLayer.addEventListener("pointerover", e => { const g = e.target.closest(".node"); if (g && !playing) trace(g.dataset.id); }); nodeLayer.addEventListener("pointerout", e => { const g = e.target.closest(".node"); if (g && !g.contains(e.relatedTarget) && !playing) trace(selected); }); nodeLayer.addEventListener("click", e => { const g = e.target.closest(".node"); if (!g || moved > 4) return; select(g.dataset.id); }); nodeLayer.addEventListener("keydown", e => { const g = e.target.closest(".node"); if (g && (e.key === "Enter" || e.key === " ")){ e.preventDefault(); select(g.dataset.id, true); } }); svg.addEventListener("click", e => { if (!e.target.closest(".node") && moved <= 4) closePanel(); }); const panel = document.getElementById("panel"); const esc = s => String(s == null ? "" : s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c])); const KIND = {call:"call", event:"event", data:"storage", paper:"handoff", test:"self test"}; const STATUS = {T:"run and checked in this build", C:"wired in code, not run in this build", L:"known limit, disclosed"}; /* Yo Jones - opens the detail panel for a node and optionally flies to it */ function select(id, fly){ selected = id; svg.querySelectorAll(".node.sel").forEach(x => x.classList.remove("sel")); const n = byId[id]; n.el.classList.add("sel"); trace(id); const link = (e, dir) => { const o = byId[dir === "out" ? e.t : e.f]; return `<button type="button" data-go="${o.id}"><span>${esc(o.t)}</span><small>${KIND[e.k]}${e.l ? ": "+esc(e.l) : ""}</small></button>`; }; const sts = []; if (n.kind === "event") sts.push(`<span class="pill">event on the bus</span>`); if (n.s && n.s !== "L") sts.push(`<span class="pill ${n.s}"><b>${n.s}</b> ${STATUS[n.s]}</span>`); if (n.s === "L" || n.lim) sts.push(`<span class="pill L"><b>L</b> ${esc(n.lim || STATUS.L)}</span>`); const inl = (ins[id]||[]).filter(e => e.k !== "test" || svg.classList.contains("show-tests")); const outl = (outs[id]||[]).filter(e => e.k !== "test" || svg.classList.contains("show-tests")); panel.style.setProperty("--g", `var(--c-${n.g})`); panel.innerHTML = ` <button class="btn x" type="button" data-close aria-label="Close detail">Close</button> <div class="eb">${esc(GROUPS[n.g].name)}${n.step ? " · main path step "+n.step : ""}</div> <h2>${esc(n.t)}</h2> <div class="sts">${sts.join("")}</div> ${n.sub ? `<p>${n.sub.map(esc).join(". ")}.</p>` : ""} <p>${esc(n.note||"")}</p> ${n.fns && n.fns.length ? `<h4>Functions and keys</h4><ul>${n.fns.map(f=>`<li><code>${esc(f)}</code></li>`).join("")}</ul>` : ""} ${inl.length ? `<h4>Fed by</h4><div class="links"><ul>${inl.map(e=>`<li>${link(e,"in")}</li>`).join("")}</ul></div>` : ""} ${outl.length ? `<h4>Feeds</h4><div class="links"><ul>${outl.map(e=>`<li>${link(e,"out")}</li>`).join("")}</ul></div>` : ""} <h4>Section in ${BUILD_NAME}.html</h4><p class="ref">${esc(n.ref||"")}</p>`; panel.hidden = false; if (fly) flyTo(id); } /* Yo Jones - centers a node at a readable zoom */ function flyTo(id){ const n = byId[id], r = svg.getBoundingClientRect(), k = Math.max(view.k, 0.8); const offX = (!panel.hidden && r.width > 760) ? -190 : 0; const offY = (!panel.hidden && r.width <= 760) ? -r.height*0.28 : 0; animateTo({k, x: r.width/2 + offX - (n.x+n.w/2)*k, y: r.height/2 + offY - (n.y+n.h/2)*k}); } function closePanel(){ panel.hidden = true; selected = null; svg.querySelectorAll(".node.sel").forEach(x => x.classList.remove("sel")); clearTrace(); } panel.addEventListener("click", e => { if (e.target.closest("[data-close]")) return closePanel(); const b = e.target.closest("[data-go]"); if (b) select(b.dataset.go, true); }); /* ---------- legend groups: isolate ---------- */ const iso = new Set(); const gbox = document.getElementById("groups"); Object.entries(GROUPS).forEach(([k,g]) => { const b = document.createElement("button"); b.type = "button"; b.className = "gchip"; b.style.setProperty("--g", `var(--c-${k})`); b.setAttribute("aria-pressed","false"); b.innerHTML = `<i></i>${g.name}`; b.onclick = () => { iso.has(k) ? iso.delete(k) : iso.add(k); b.setAttribute("aria-pressed", iso.has(k)); applyIso(); }; gbox.appendChild(b); }); /* Yo Jones - dims everything outside the chosen subsystems */ function applyIso(){ svg.classList.toggle("isolating", iso.size > 0); NODES.forEach(n => n.el.classList.toggle("inset", iso.has(n.g))); edges.forEach(e => { const on = iso.has(byId[e.f].g) || iso.has(byId[e.t].g); e.el.classList.toggle("inset", on); e.lab && e.lab.classList.toggle("inset", on); }); } /* ---------- search ---------- */ const q = document.getElementById("q"); /* Yo Jones - matches query text against titles, functions and notes */ function search(){ const s = q.value.trim().toLowerCase(); svg.classList.toggle("searching", !!s); const hits = []; NODES.forEach(n => { const hay = [n.t, ...(n.sub||[]), ...(n.fns||[]), n.note||"", GROUPS[n.g].name].join(" ").toLowerCase(); const m = !!s && hay.includes(s); n.el.classList.toggle("match", m); if (m) hits.push(n.id); }); return hits; } q.addEventListener("input", search); q.addEventListener("keydown", e => { if (e.key === "Enter"){ const h = search(); if (h.length === 1) select(h[0], true); else if (h.length) animateTo(frame(boxOf(h), 40, 1)); } if (e.key === "Escape"){ q.value = ""; search(); q.blur(); } }); /* ---------- test coverage toggle ---------- */ const tests = document.getElementById("tests"); tests.onclick = () => { const on = !svg.classList.contains("show-tests"); svg.classList.toggle("show-tests", on); tests.setAttribute("aria-pressed", on); if (selected) select(selected); }; /* ---------- the fuse: a slow burn along the main path, to Take it home ---------- */ /* Yo Jones - the fuse replaces "walk the main path". A cord is laid along the main path, then burns wire by wire, no sparks. Each time it reaches a box it stops, the box lights, and a card says where the voucher is at that moment. Pause, Next and Stop live on the card. Reduced motion gets the same stops with no burn animation. */ const playBtn = document.getElementById("play"); const pop = document.getElementById("fusepop"); const fuseLayer = el("g",{class:"fuse-layer"},null); world.insertBefore(fuseLayer, labelLayer); const FUSE_DWELL = 2600; // ms a stop stays up before the fuse moves on const FUSE_SPEED = 0.75; // world px per ms of burn let playing = false, fuse = null; /* Yo Jones - the wire between fuse stop k and k+1. */ const fuseEdge = k => edges.find(x => x.f === FUSE[k] && x.t === FUSE[k+1]); /* Yo Jones - lays the cord and starts at stop 0. */ function play(){ stop(); closePanel(); playing = true; svg.classList.add("playing"); playBtn.setAttribute("aria-pressed","true"); playBtn.textContent = "■ Stop the fuse"; const segs = []; for (let k = 0; k < FUSE.length - 1; k++){ const e = fuseEdge(k); el("path",{class:"fuse-cord",d:e.d},fuseLayer); const burn = el("path",{class:"fuse-burn",d:e.d},fuseLayer); const L = burn.getTotalLength(); burn.style.strokeDasharray = L; burn.style.strokeDashoffset = L; segs.push({e, burn, L}); } const head = el("circle",{class:"fuse-head",r:7,cx:-99,cy:-99},fuseLayer); fuse = {k:0, segs, head, paused:false, timer:0, raf:0, burning:false, p:0}; arrive(0); } /* Yo Jones - the fuse reaches stop k: light the box, fly to it, show the card, wait. */ function arrive(k){ if (!fuse) return; fuse.k = k; fuse.burning = false; byId[FUSE[k]].el.classList.add("lit"); const n = byId[FUSE[k]], r = svg.getBoundingClientRect(); const kz = Math.max(Math.min(view.k, 1), 0.85); const offX = r.width > 760 ? -170 : 0, offY = r.width > 760 ? 0 : -r.height*0.22; animateTo({k:kz, x:r.width/2 + offX - (n.x+n.w/2)*kz, y:r.height/2 + offY - (n.y+n.h/2)*kz}); showPop(k); if (k === FUSE.length - 1){ playBtn.textContent = "↺ Clear the fuse"; return; } if (!fuse.paused) fuse.timer = setTimeout(() => burn(k), FUSE_DWELL); } /* Yo Jones - burns the wire from stop k to k+1, carrying on from wherever a pause left it. */ function burn(k){ if (!fuse) return; clearTimeout(fuse.timer); const s = fuse.segs[k]; fuse.burning = true; hidePop(); const D = reduce ? 0 : Math.min(2400, Math.max(700, s.L / FUSE_SPEED)); const t0 = performance.now() - fuse.p*D; const tick = now => { if (!fuse || fuse.paused) return; const p = D ? Math.min(1, (now - t0)/D) : 1; fuse.p = p; s.burn.style.strokeDashoffset = s.L*(1-p); const pt = s.burn.getPointAtLength(s.L*p); fuse.head.setAttribute("cx", pt.x); fuse.head.setAttribute("cy", pt.y); if (p < 1){ fuse.raf = requestAnimationFrame(tick); return; } fuse.p = 0; s.e.el.classList.add("lit"); s.e.lab && s.e.lab.classList.add("lit"); arrive(k + 1); }; fuse.raf = requestAnimationFrame(tick); } /* Yo Jones - Pause holds the fuse where it is, mid-wire or at a stop; Resume carries on. */ function togglePause(){ if (!fuse) return; fuse.paused = !fuse.paused; clearTimeout(fuse.timer); cancelAnimationFrame(fuse.raf); if (!fuse.paused){ if (fuse.burning) burn(fuse.k); else if (fuse.k < FUSE.length - 1) fuse.timer = setTimeout(() => burn(fuse.k), 900); } const b = pop.querySelector("[data-pause]"); if (b) b.textContent = fuse.paused ? "Resume" : "Pause"; } /* Yo Jones - Next skips the wait at a stop, or finishes the wire now. Next also un-pauses. */ function fuseNext(){ if (!fuse || fuse.k >= FUSE.length - 1) return; clearTimeout(fuse.timer); cancelAnimationFrame(fuse.raf); fuse.paused = false; if (fuse.burning) fuse.p = 0.999; burn(fuse.k); } /* Yo Jones - puts out the fuse and clears the lit path. */ function stop(){ if (fuse){ clearTimeout(fuse.timer); cancelAnimationFrame(fuse.raf); } fuse = null; playing = false; fuseLayer.textContent = ""; hidePop(); playBtn.setAttribute("aria-pressed","false"); playBtn.textContent = "▶ Light the fuse"; svg.classList.remove("playing"); svg.querySelectorAll(".lit").forEach(x => x.classList.remove("lit")); } playBtn.onclick = () => playing ? stop() : play(); /* Yo Jones - the card at a stop: which step, what happened, where the voucher is now. */ function showPop(k){ const n = byId[FUSE[k]], last = k === FUSE.length - 1; pop.style.setProperty("--g", `var(--c-${n.g})`); pop.innerHTML = ` <div class="fp-top"><span class="fp-step">Step ${k+1} of ${FUSE.length}</span> <button type="button" class="fp-x" data-stop aria-label="Put out the fuse">×</button></div> <div class="fp-bar"><i style="width:${Math.round((k+1)/FUSE.length*100)}%"></i></div> <div class="fp-eb">${esc(GROUPS[n.g].name)}</div> <h3>${esc(n.t)}</h3> <p>${esc(n.where || n.note || "")}</p> <div class="fp-at"><span>Where the voucher is</span><b>${esc(n.at || "")}</b></div> <div class="fp-actions"> <button type="button" class="btn" data-detail>Detail</button> ${last ? `<button type="button" class="btn primary" data-stop>Clear</button>` : `<button type="button" class="btn" data-pause>${fuse && fuse.paused ? "Resume" : "Pause"}</button><button type="button" class="btn primary" data-next>Next ›</button>`} </div>`; pop.dataset.node = n.id; pop.hidden = false; placePop(); } function hidePop(){ pop.hidden = true; delete pop.dataset.node; } /* Yo Jones - keeps the card beside its box as the view pans and zooms; a bottom sheet on a phone. */ function placePop(){ if (pop.hidden || !pop.dataset.node) return; const r = svg.getBoundingClientRect(); if (r.width <= 760){ pop.style.left = ""; pop.style.top = ""; return; } const n = byId[pop.dataset.node]; const sx = view.x + n.x*view.k, sy = view.y + n.y*view.k, sw = n.w*view.k; const pw = pop.offsetWidth || 320, ph = pop.offsetHeight || 240; let left = sx + sw + 24; if (left + pw > r.width - 12) left = sx - pw - 24; left = Math.max(12, Math.min(left, r.width - pw - 12)); const top = Math.max(12, Math.min(sy, r.height - ph - 12)); pop.style.left = left + "px"; pop.style.top = top + "px"; } pop.addEventListener("click", e => { if (e.target.closest("[data-stop]")) return stop(); if (e.target.closest("[data-pause]")) return togglePause(); if (e.target.closest("[data-next]")) return fuseNext(); if (e.target.closest("[data-detail]")){ if (fuse && !fuse.paused) togglePause(); select(pop.dataset.node); } }); /* ---------- figure export ---------- */ /* Yo Jones - saves the whole map as a standalone SVG for the paper: light theme, full detail, every style written onto its element so it opens the same in Inkscape, Illustrator or a browser with no stylesheet. The dot grid, the pulse and the fuse are left out; the background is white. */ function saveSVG(){ const root = document.documentElement, prevTheme = root.getAttribute("data-theme"); const had = ["far","focusing","isolating","searching","playing"].filter(c => svg.classList.contains(c)); root.setAttribute("data-theme","light"); root.classList.add("exporting"); had.forEach(c => svg.classList.remove(c)); void svg.getBoundingClientRect(); const clone = svg.cloneNode(true); const live = [svg, ...svg.querySelectorAll("*")], copy = [clone, ...clone.querySelectorAll("*")]; const P = ["fill","fill-opacity","stroke","stroke-width","stroke-dasharray","stroke-opacity","stroke-linecap","stroke-linejoin","opacity","font-family","font-size","font-weight","letter-spacing","paint-order","text-anchor","dominant-baseline"]; const drop = []; live.forEach((n, i) => { const c = copy[i], cs = getComputedStyle(n); if (cs.display === "none" || n.classList.contains("pulse") || n.classList.contains("fuse-layer") || (n.getAttribute && n.getAttribute("fill") === "url(#dots)")){ drop.push(c); return; } if (i === 0) return; let st = ""; P.forEach(p => { const v = cs.getPropertyValue(p); if (v !== "") st += p + ":" + v + ";"; }); c.setAttribute("style", st); ["class","tabindex","role","aria-label","data-id"].forEach(a => c.removeAttribute(a)); }); drop.forEach(c => c.remove()); clone.querySelectorAll("pattern").forEach(x => x.remove()); clone.querySelector("#vp").removeAttribute("transform"); clone.setAttribute("xmlns","http://www.w3.org/2000/svg"); clone.setAttribute("viewBox", `0 0 ${B.w} ${B.h}`); clone.setAttribute("width", B.w); clone.setAttribute("height", B.h); clone.removeAttribute("class"); clone.removeAttribute("style"); clone.removeAttribute("aria-label"); clone.removeAttribute("role"); const bg = document.createElementNS(NS, "rect"); bg.setAttribute("width", B.w); bg.setAttribute("height", B.h); bg.setAttribute("fill", "#ffffff"); clone.insertBefore(bg, clone.querySelector("#vp")); const t = document.createElementNS(NS, "title"); t.textContent = "IF1008 wiring"; clone.insertBefore(t, clone.firstChild); const text = '<?xml version="1.0" encoding="UTF-8"?>\n' + new XMLSerializer().serializeToString(clone); if (prevTheme == null) root.removeAttribute("data-theme"); else root.setAttribute("data-theme", prevTheme); root.classList.remove("exporting"); had.forEach(c => svg.classList.add(c)); apply(); const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([text], {type:"image/svg+xml"})); a.download = BUILD_NAME + "-wiring.svg"; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 4000); } document.getElementById("svgBtn").onclick = saveSVG; /* ---------- info drawer ---------- */ const info = document.getElementById("info"); document.getElementById("infoBtn").onclick = () => { info.hidden = !info.hidden; }; document.getElementById("infoClose").onclick = () => { info.hidden = true; }; /* ---------- keyboard ---------- */ document.addEventListener("keydown", e => { if (e.target.matches("input")) return; const [x,y] = center(); if (e.key === "+" || e.key === "=") zoomAt(x,y,1.25); else if (e.key === "-" || e.key === "_") zoomAt(x,y,0.8); else if (e.key === "0") fitAll(); else if (e.key === "s") fitStart(); else if (e.key === "/"){ e.preventDefault(); q.focus(); } else if (e.key === "Escape"){ if (!info.hidden) info.hidden = true; else if (!panel.hidden) closePanel(); else if (playing) stop(); } else if (e.key === "f"){ playing ? stop() : play(); } else if (e.key === " " && playing && !e.target.closest("button")){ e.preventDefault(); togglePause(); } else if (e.key.startsWith("Arrow") && !e.target.closest(".node")){ const d = 60; view.x += e.key==="ArrowLeft"?d:e.key==="ArrowRight"?-d:0; view.y += e.key==="ArrowUp"?d:e.key==="ArrowDown"?-d:0; apply(); } }); /* ---------- initial view ---------- */ /* Yo Jones - opens on the whole map, or on the start region when the screen is too small to read it */ function initialView(){ const f = frame(B, 24); const t = f.k < 0.3 ? frame(boxOf(["qrsheets","paper","scan","assign"], 60), 20, 1) : f; Object.assign(view, t); apply(); } try { if (window.innerWidth < 760) document.getElementById("legend").open = false; } catch(e){} initialView(); </script> <script>window.addEventListener('keydown',function(e){if(e.key==='Escape')parent.postMessage({type:'w704-escape'},'*');});</script></body> </html>2.3Development and Testing Environment
Development proceeded by iterative triage across several tools, operating systems, browsers, and devices (Table 2). The workflow was tool-assisted but tied to no single environment.
Tools, environments, and their role in development
| Category | Tools / environment | Role in development |
|---|---|---|
| Paper Notebooks | Prep / Dailies /Versioning | Design decisions, workflow diagrams, data relationships, failure states, and sanity checks, kept by hand as a parallel analog record. |
| AI-assisted development | Six LLMs (Anthropic, OpenAI, DeepSeek); two agentic systems (Claude Cowork, Claude Code) | Iterative code generation, debugging, review, restructuring, and prose assistance. |
| Data handling | Microsoft Excel | Inspection, organization, checking, and manipulation of tabular data. |
| Writing and editing | LibreOffice Writer & Microsoft Word | Manuscript preparation and editing. |
| Web browsers | Spidermonkey, V8, Nitro engines | Development, compatibility testing, and functional validation. Safari remains a problem-child. |
| Code editors | Sublime Text; Visual Studio Code | Direct inspection and editing of HTML, CSS, JavaScript, Python, and related files. |
| Programming and analysis | Python; Biopython | Data retrieval, processing, auditing, and analysis. |
| Operating systems | MX Linux; Windows 10 | Development and cross-platform testing. |
| Documentation and Figures | Blender, Graph-viz | Documentation, comparison, troubleshooting, and figure preparation. |
| Hardware testing | Mobile and desktop | Cross-device testing of layout, storage, input, export, and browser behavior. |
2.4Repository Audit Procedure
Each audit froze its sampling frame, then audited and scored the frozen records from preserved local evidence. Procedures are in Appendices B.4 and D; checks that they match the reported results are still outstanding. The author ran the NCBI audits interactively with ChatGPT, inspecting outputs step by step, and that work set the procedure for SERNEC. For SERNEC, the author set the design and constraints, ChatGPT turned them into a frozen manifest and agent instructions, and Claude Cowork ran the retrieval (Appendix B.4).
2.5Options
2.6Export and Archiving
Export does two jobs: it hands a record on, and it gives the collector a copy to bring home. Formats differ in what they carry and how they handle photographs (Section 5.4). The take-home HTML record opens on its own and loads back into IF. Browser storage is no place to leave anything; keep exports and photographs elsewhere, plus a second copy on another device or service. A receiving institution may apply its own mapping and accession checks. The collector keeps the source record either way.
2.7Failure Modes Addressed
The failure addressed is a specimen separated from its documentation anywhere between collection and databasing. It happens the ordinary ways: notes never transferred; notes lost, wet, or illegible; the label written weeks later when the locality has gone soft; sheet and field report parted; the collector dead. Happens.
The shared identifier reconnects a separated sheet and record through the label, standalone HTML voucher, or bulk export table. If that sounds like a storage burden, all of GBIF's specimen records, imagery excluded, fit on a jump drive in this format. Failing everything, copy the UUID by hand with a pencil.
3.Results
The results address two questions: can the file produce a field voucher in minutes, and how often do existing repository records keep the provenance fields a voucher is meant to preserve?
3.1Field Demonstration
The demonstration shows a working capture-and-save workflow, not downstream adoption. Completion time was not measured under controlled conditions. To watch one save move through the code, play the Save chapter of the Happy Path atlas (Appendix F).
The author has run the file end to end more than 200 times, about half on real plants and half on synthetic records stuffed with placeholder text and autocomplete to push it toward failure. Those are stress tests, not a benchmark. With practice a record takes the author about 90 seconds, an anecdote, not a result. Outside collectors invited to test the precursor build, tmjv8 (Jones, 2026a), sent no data back. Completion time across collectors, devices, and conditions remains untested.
-
Florilegium Cariciscarex_na29Single-file key to 466 North American Carex taxa. The reference deployment: IF runs as its voucher tab and carries the key's filter path (Jones, 2026b; DOI: 10.5281/zenodo.19138828).tjid3.org/CAREXAI/carex_na29
-
Mulemule-kicks6Carex Atelier Mule, a guided field workbench. Built in reverse: interface first, matrix added afterward (Jones, in prep.).tjid3.org/mule/mule-kicks6
-
tmjv8field voucher v8Standalone precursor build of the voucher, the version sent to outside testers (Jones, 2026a).tjid3.org/voucher/tmjv8
Repository provenance audits
The audits ask how often repository records carry the provenance fields a voucher should guarantee. Table 3A: the SERNEC random survey, 21,110 herbarium records (SERNEC Data Portal, 2026). Table 3B: the plant barcode stress test, identified by the author as NCBI matK and rbcL records. Table 4: 600 GenBank COI records across six animal genera. All count field presence, not accuracy.
3.2Herbarium and plant barcode records
SERNEC herbarium records: random survey
Pooled presence across 21,110 records in ten genera drawn at random from the BONAP top-100 list (Kartesz, 2015; Appendix I). The lowest genus is given under each value. Full table in Appendix H.
NCBI plant barcode records: matK and rbcL stress test
Values are percentages of records with each field present. N is the number of records. Collection date, collector, and coordinates are emphasized.
| Taxon | N | Collection date | Collector | Coordinates | Identified by |
|---|---|---|---|---|---|
| Carex | 19,834 | 84.3% | 90.8% | 32.3% | 43.6% |
| Cyperus | 5,185 | 84.2% | 88.7% | 34.2% | 45.0% |
| Panicum | 2,054 | 87.3% | 91.3% | 34.7% | 37.3% |
| Solidago | 7,105 | 74.4% | 77.8% | 24.3% | 37.0% |
| Total | 34,178 | 82.4% | 87.8% | 31.1% | 42.1% |
The total row gives pooled percentages across the displayed records. Appendix E: matK and rbcL audit BioPython.
3.3Animal COI records
COI voucher and provenance completeness across six animal genera
Presence of voucher and provenance fields in 100 GenBank cytochrome c oxidase subunit I (COI) records per genus: fish, beetles, birds, selected as super-genera. Values are percentages of each sample. Presence measures documentation, not accuracy. Cards give pooled presence across all 600 records, with the lowest genus under each value.
| Group | Taxon | N | Voucher | INST:number | Collection date | Collector | Coordinates | Identified by |
|---|---|---|---|---|---|---|---|---|
| Fish | Labeo COI | 100 | 58% | 5% | 46% | 45% | 29% | 21% |
| Fish | Oreochromis COI | 100 | 45% | 7% | 42% | 28% | 41% | 19% |
| Fish | Rhinogobius COI | 100 | 60% | 9% | 43% | 19% | 19% | 11% |
| Beetle | Bembidion COI | 100 | 94% | 34% | 62% | 56% | 87% | 74% |
| Beetle | Agrilus COI | 100 | 76% | 0% | 63% | 48% | 48% | 36% |
| Bird | Zosterops COI | 100 | 38% | 24% | 70% | 27% | 35% | 6% |
The tables show uneven retention of collection date, collector, coordinates, and identification. A record can keep one part of its provenance and lose another. The audits establish the documentation problem in the examined records; they do not test whether populated fields are true, or measure any improvement from IF. They identify what is worth securing at collection, before later workflows have to recover it.
The Protein Data Bank has a related gap. Its PDBx/mmCIF dictionary separates natural-tissue from genetically manipulated sources, and gene-source organism from expression host (wwPDB, n.d.-a, n.d.-b). It also offers a free-text field for natural-source details (wwPDB, n.d.-c), so there is room for a fuller account. But a taxon name, tissue, or supplier does not establish which organism supplied the material, where and when it was collected, or whether a specimen survives to be re-examined. We did not audit how often PDB records make those links. The point is narrower: precision about a structure is not provenance for its source.
4.The Inertia Straitjacket
The backlog is not rhetorical. As of 31 December 2025, Index Herbariorum listed 4,035 active herbaria holding a reported 406,426,591 specimens (Thiers, 2026). Every one needs a name, a place, a date, a collector, and a durable route into a data system. A collection that size will not be rescued by meetings or the next platform. It needs small tools that cut friction at the moment the record is born.
Pressed plants are the obvious beachhead: flat, large, label-driven, tied to locality, already a sheet plus a text record. Not the only target, just the cleanest first one. Fish, birds, and arthropods share the core problem: a physical object must stay bound to a record through time.
- Human-readable ID. The survival layer. Read, copied, photographed, typed, or recovered after the machines are gone.
- QR code. The field and phone layer. Binds plant, photograph, bag, label, and record before the institutional workflow begins.
- One-dimensional barcode. The legacy accession layer, scanned at the workstation once the specimen enters a collection.
- RFID. The cabinet-scale audit layer for types and high-value specimens: thousands read without line-of-sight handling.
The layered view prevents identifier warfare. The identifier is the specimen; the machine-readable forms are doors into the same record. None needs to kill the others.
Table 5 compares the carriers. The paper label is the only one with a demonstrated multi-century record, and nothing here replaces it. Code 39 adds machine reading at the cost of a dedicated scanner. QR adds it on hardware the collector already carries (Statista, 2026), and resolves without a network when the data travel in the code (up to about 4,000 characters). NFC is convenient but mortal. Visual search is powerful but infrastructural. GS1 standardizes meaning without guaranteeing permanence.
Comparison of specimen-identification technologies
Cost is the marginal cost per object. Program-level costs appear in note c.
| Technology | Read requirement | Resolves offline | Practical lifespan | Information capacity | Cost per object |
|---|---|---|---|---|---|
| Human-readable labelPrinted or written text | Unaided eye. | Yes. | Centuries on archival stock. The demonstrated incumbent. a | Whatever fits legibly. Machine parsing requires transcription. | None beyond the label already produced for the specimen. |
| 1-D barcode, Code 39Linear symbology | Laser or imaging scanner. | Yes. | Life of the label. a | Low capacity for its size. More characters need a wider barcode. | Incremental printing only. |
| 2-D QR CodeMatrix symbology | Smartphone camera or imaging scanner. | Yes when the data are encoded directly in the symbol. No when the code contains only a URI. | Life of the label. a Error correction can recover a partially damaged symbol. b | Up to 7,089 numeric or 4,296 alphanumeric characters at error-correction level L. b Specimen codes should be far shorter so modules stay large and readable. | Incremental printing only. |
| NFC tagRadio-frequency tag | NFC-capable device and operating-system permission. | Yes. | Rated data retention 10 to 60 years or more, depending on the chip. Antenna, adhesive, or encapsulation may fail first. a | Common NXP NTAG chips provide 144, 504, or 888 bytes. Other tags reach several kilobytes. | Approximately USD 0.10 to 1.00 in quantity. Ruggedized or archival assemblies cost more. c |
| Visual searchRecognition method | Camera, trained model, and reference collection. | Only when the model and reference collection are stored locally. | No physical limit. Lasts only while its models, references, and infrastructure are maintained. | No encoded limit because the specimen itself is the query. Accuracy depends on image quality, reference coverage, and the taxonomic resolution requested. | No physical tag cost. Program-level costs can be substantial. c |
| GS1 Digital Link and GS1 DataMatrixIdentifier syntax and carrierd | Determined by the carrier in which the identifier is encoded. | A DataMatrix symbol carrying data can resolve offline. A Digital Link requires access to its domain and resolver. | Life of the label, a together with continued maintenance of the domain and resolver. | GS1 DataMatrix holds approximately 3,114 numeric or 2,334 alphanumeric characters after inclusion of the GS1 function character. A Digital Link carried in a QR Code follows QR capacity limits, less URI overhead. | Printing cost is negligible. Governance and hosting are not. c |
Notes
- No symbology has a lifespan rating; longevity belongs to substrate, ink, and storage. Fused toner on archival stock persists; dye inkjet fades. Deep-freezing at accession, routine for pests, stresses adhesives and encapsulation; chip-retention figures do not cover it.
- Capacity maxima are quoted at error-correction level L, the weakest. Raising error correction improves damage recovery and lowers capacity; the two maxima cannot be had at once.
- Program costs are excluded from the per-object comparison: for NFC, encoding and tag verification; for visual search, cameras, image storage, annotation, model development, inference hardware, validation, and maintenance; for GS1, licensing, identifier governance, resolver hosting, database integration, and scanner upgrades.
- GS1 Digital Link is an identifier syntax carried by a QR Code or DataMatrix, not a physical carrier. It is included because institutions may evaluate it, not because it is a peer of the symbologies above.
This is why the system is deliberately primitive. The advanced solution is the institutional stack: server, database, grant, account, scanner, support queue, policy meeting, migration plan. Here the collector finishes the documentation while specimen and context are still at hand, and the institutional workflow can arrive later without making the field event wait. The competition is paper, and paper lasts hundreds of years.
5.System Description
5.1Technical Architecture
The file contains HTML, CSS, and vanilla JavaScript, roughly 12K lines in the version documented in Appendix B. All fx's are labeled for searchability. QR generation uses Kazuhiko Arase's QR Code Generator for JavaScript, MIT licensed and embedded (Arase, 2009). A pure-JavaScript parser reads GPS coordinates straight from image bytes (Section 5.5).
Records, addenda, and interface state live in LocalStorage; photographs in IndexedDB (Appendix B.1). Both survive restarts within the browser's storage limits, unless the browser clears them (Section 8). To see what a refused storage write does downstream, run Failure in the small-world flight deck (Appendix F). The file follows the 20-Year File / Digital Paratype architecture (Jones, 2025): installing it means copying it. Twenty years is a design goal, not a measured lifespan. Exports follow Lots of Copies Keep Stuff Safe (Reich and Rosenthal, 2001): more copies in more places. Copying alone does not check or repair copies the way a managed preservation system does.
5.2The Voucher Record
Each saved voucher record in IF is built from the form fields in Table 6. CSV and XLSX exports use the same saved keys through FIELD_DEFS; JSON carries the same record plus a photo manifest when photographs are present.
| Field | Darwin Core / role | IF saved key and behavior |
|---|---|---|
| Voucher ID / QR | system identifier | id; generated in the browser or reassigned from a preprinted/scanned label. |
| Occurrence UUID | occurrenceID | occurrenceID; stable GBIF-ready UUID, generated at draft start and not overwritten by QR reassignment. |
| Genus + species | scientificName | IF uses separate form inputs for genus and species, then saves and exports the joined value as scientificName. |
| Qualifier | identificationQualifier | qualifier; determined, cf., aff., near, or uncertain ID. |
| Identified by, first | identifiedBy | identifiedByFirst; exported separately as identified_by_first. |
| Identified by, last | identifiedBy | identifiedByLast; exported separately as identified_by_last. |
| Date identified | dateIdentified | dateIdentified; HTML date input, YYYY-MM-DD. |
| Kingdom | kingdom | kingdom; selectable cross-kingdom value. |
| Basis of record | basisOfRecord | basisOfRecord; PreservedSpecimen is the default. |
| Family | family | family; free text. |
| Collection date | eventDate | date; HTML date input, defaults to the day of save. |
| Collector, first | recordedBy | collectorFirst; persists across consecutive saves. |
| Collector, last | recordedBy | collectorLast; persists across consecutive saves. |
| Collector ORCID iD | recordedByID | collectorOrcid; optional identifier for the collector. |
| Collector number | recordNumber | collectorNumber; manual entry, not auto-incremented. |
| Institution | institutionCode / institutionID | institution; accepts herbarium code, organization, or institution text. |
| Country | country | country; free text. |
| State / Province | stateProvince | stateProvince. |
| County / Parish | county | county. |
| Locality | verbatimLocality | localityText; collector-supplied locality narrative. |
| Latitude | decimalLatitude | lat; EXIF GPS, device GPS, or manual entry. |
| Longitude | decimalLongitude | lon; EXIF GPS, device GPS, or manual entry. |
| Geodetic datum | geodeticDatum | geodeticDatum; WGS84 is the default. |
| Accuracy | coordinateUncertaintyInMeters | accuracy; EXIF-reported, device-reported, or manual uncertainty. |
| Coordinate source | georeferenceProtocol | coordinateSource; exported as georeference_protocol, e.g., EXIF, device GPS, or manual. |
| ID confidence | identificationVerificationStatus | confidence; low, medium, high, or expert-confirmed. |
| Habitat | habitat | habitat; free text. |
| Associate species | associatedTaxa | associates; nearby taxa observed by the collector. |
| Description / notes | occurrenceRemarks | description; morphology, abundance, population notes, or field description. |
| Additional notes | identificationRemarks / occurrenceRemarks | notes; follow-up, uncertainty flags, field conditions, or other remarks. |
| Created at | dcterms:created / system metadata | createdAt; timestamp created at save. |
| Voucher started | system metadata | startedAt; start time for the local timer subsystem. |
| Duration | system metadata | durationSeconds; elapsed seconds from blank draft to save. |
| Photographs | associatedMedia, by export linkage | Stored in IndexedDB under the voucher ID; JSON carries a photo manifest, and image files export separately as ZIP. |
5.3QR Code Implementation
Each record gets a unique identifier when the form opens, before save, shown as a QR code and carried through every export. HTML voucher exports embed the QR with a generous white margin; the bulk HTML table puts one in the first column of each row; printed labels place it flush right with a 0.18-inch border. That border is the quiet zone scanners need; without it labels scan poorly in low light and on low-resolution phone cameras.
5.4Export Formats
- DwC JSON A complete Simple Darwin Core occurrence export, fields mapped per Table 6, importable to most herbarium databases.
- Take Home A portable, independently readable record that can be imported back into IF.
- HTML Table All vouchers in one landscape HTML file, a QR image per row. Opens in any browser; drags into most spreadsheets.
- CSV / XLSX One row per voucher, all Table 6 fields, as plain CSV or a formatted XLSX workbook, for institutional import or bulk editing before re-import.
- Label Print Labels open in a browser print window; a blocked window is reported. No separate application needed. Two across at 4 × 3 inches on legal paper, for adhesive stock cut to size or card stock cut by hand. Matches no pre-perforated Avery template; test-print on plain paper first.
Optional connections
IF can work alone, sit inside an identification key, or feed a receiving workflow through an adapter. The same record serves all three. Git and GitHub give copies a place to land and keep version history (GitHub, Inc., 2026). Specify and Symbiota are the institutional collection workflows (Specify Collections Consortium, 2026; Gries et al., 2014). All of these connections are optional.
DiSSCo is one more destination. It ingests registered institutional source systems, fed by a Darwin Core Archive or a BioCASe endpoint, and turns their data into openDS Digital Specimens (DiSSCo, n.d.; DiSSCo, 2026a). IF writes the first, and writes openDS directly. I checked that export against the openDS 0.4.0 JSON Schema: everything IF writes passed. Seven required keys were missing, all assigned by DiSSCo at ingest, such as the specimen DOI and source-system ID. A Meise Botanic Garden record from the DiSSCo Sandbox passed the same check cleanly (DiSSCo, 2026b). The shapes match. Acceptance is a separate step; no IF record has been through DiSSCo ingestion yet.
5.5EXIF Extraction
When a photograph is attached, IF parses its EXIF in the browser for GPS coordinates and reported error. Empty coordinate fields are filled, with EXIF as source; existing coordinates are kept, and the distance between the two is reported. A separate writer embeds the voucher ID in the resized JPEG IF stores, beside its visible ID stamp (Appendix A); the source photograph is untouched. Capture date is not extracted; eventDate is typed or defaults to the day of save. A photo with no GPS tag (older camera, scan, screenshot) falls back to device geolocation or manual entry, and the coordinate source field records which path was used (limits in Section 8). One caveat: hand-entered GPS is GPS, and phone-assigned GPS is GPS plus, usually, Wi-Fi positioning, maybe A-GPS, cell-tower triangulation, and other stuff as well. For the sake of sanity it is not examined here.
6.Unresolved Records as Positive Data
The architecture buys resolution, not information.
The same system yields a second data type that usually becomes a ghost: the observation made, documented, and given up on. Databases treat a missing determination as silence, and silence reads as absence. That error compounds across decades of range modeling and conservation assessment. A collector who meets twenty Carex of section Ovales on a Rocky Mountain summit at 3000 m, measures perigynia, notes scale color and beak length, and writes "unresolved; complex difficult here; characters noted" has produced a difficulty signal: place, date, character states, and a flag that the taxonomy hit morphological compression right here. A useful fail.
Aggregated across collectors and years, such records show where a complex's hard boundaries run, which records range modelers should handle with kid gloves, and where the next specialist should look. A database that demands a name gets one, and a forced determination that goes quietly wrong corrupts everything downstream until someone catches it. Expert bypass is the limit case: when Tony Reznicek says Carex frankii in Ohio, the assertion is the chain. The system takes that as readily as the first-year student at Cedar Bog who loses the determination on a perigynium and says so. Neither is nothing.
My two cents. Messy biological reality → conceptual model → RDF ontology → mappings from legacy systems → mappings between ontology versions → application interpretation. That is not a service to anyone. It formalizes an unstable ontology without stabilizing the knowledge under it; it mummifies today's reading of that knowledge. And the mummy always comes back in the movies.
7.The Fifty Cabinets
The backlog includes collectors now infirm or dead, their localities incomplete or gone. The fifty-cabinet figure is informal: herbaria of every size, nearly three decades of field, curatorial, and computational work in Cyperaceae.
These specimens are not recoverable. The collectors are gone. The mental index, the memory of which road, which bog, which August, died with them. They survive in drawers as pressed plant material with no coordinates in space or time.
No tool recovers a broken chain, and this paper does not claim to. What the system does is prevent the next fifty cabinets. The chain breaks at a predictable moment, the end of a hard field day, when documentation gets deferred one more time. Five minutes on a phone the collector already carries removes the friction behind the deferral.
The author's own cabinets, several hundred specimens, are the same failure, recoverable only because the author is still alive and the mental index persists, barely. This system exists in part because those cabinets do.
8.Advantages and Limitations
Keeping the field record on the device assigns custody and backup to the collector. The costs are listed here rather than left in the source.
- Custody. The collector holds the only copy until export. Clearing browser data, or losing the device, loses unexported vouchers. No server-side redundancy, by design; backup is the user's job.
- EXIF coverage. HEIC originals from some iOS capture flows are not parsed for GPS, and capture date is not extracted;
eventDateis entered by hand or defaults to the day of save. Storage persistence is requested throughnavigator.storage.persist()where available; this is a request, not a guarantee. The separate Safari advisory is described in Appendix B.3 and remains unverified on Apple hardware. - Georeferencing. Precision depends on the device, the canopy, and the conditions at collection. The recorded uncertainty is whatever the device reports.
- Label stock. No pre-perforated template matches the 4 × 3 inch layout (Section 5.4). Test-print first.
- Browser dependency. The file uses ES2025 and IndexedDB. Browsers predating those features will not run it.
Designing for decades
I cannot give IF an expiry date, and I don't try. The argument is about recovery. The Library of Congress weighs disclosure, adoption, transparency, self-documentation, and external dependencies in judging whether a format lasts (Library of Congress, n.d.). IF meets each plainly: open source, common formats, documented fields, no remote service. That is my reading, not a preservation certification. The W3C Technical Architecture Group puts compatibility with existing content first when web features change (W3C TAG, 2026), which favors IF. Three things have to survive: the bytes, the meaning, and the software. Backups and integrity checks cover the bytes. Identifiers, field definitions, and exports carry the meaning. Browser compatibility decides the software. If the interface breaks someday, readable exports and the source still leave a way back. Keeping the tool running is a harder bar than keeping the evidence readable. The defensible claim is a tool designed for decades of use, with no service life proven yet.
9.Origins and Scope
The voucher began with the matrix of the Carex Interactive Identification Key of North America (CIIK), built in DELTA, moved to Lucid, and developed over twenty years. That matrix went into Florilegium Caricis (Jones, 2026b; DOI: 10.5281/zenodo.19138828), a single-file key to 466 North American Carex taxa built on Flora of North America Volume 23. There the voucher has its own tab and talks to the key: a record can carry the current filter path, so the character data behind an identification travel with the locality. Florilegium Caricis tested function. A second work, Mule (Jones, in prep.), went in reverse: UI first, matrix parachuted in afterward. The voucher is described separately because it works alone, and its scope runs past Carex and past keys.
I built IF as one field botanist with a lot of help from AI systems, through round after round of iterative triage (Section 2.3). What it gets wrong is mine to answer for.
A whole career is the hope, kept together well enough to hand on. A generational scrapbook with identifiers: places revisited, names corrected, observations somebody else can still use. If it lets someone leave a fuller account than I can piece together from my own cabinets, it has done its bit.
10.Conclusion
Semantics by construction starts the record in the field, while plant, collector, place, and circumstances are still in one spot. Required fields, identifiers, corrections, and export mappings are built in, so whatever handles the record next has something to work from. The record can leave the field, enter another system, and come back to the collector. No single host has to last forever.
IF is a file with no home, so it lives everywhere. A copy on a phone, laptop, thumb drive, GitHub repository, or email attachment is the whole system, not a pointer to one. Nothing has to stay running for it to open. A page from the web's first year still opens in any browser (Noyes, 2013; W3C, 1992), about as good a longevity guarantee as software gets. The whole program is under a megabyte, smaller than one photograph from the phone that carries it. No computational liturgy required.
A herbarium keeps the specimens. A portable field record can keep the years spent finding them. The fifty cabinets are a sunk cost of past practice. The next fifty are preventable.
Data and Code Availability
The complete HTML file is available at tjid3.org/if/if.html. The voucher component is also preserved within the archived Florilegium Caricis release at Zenodo (DOI 10.5281/zenodo.19138828). Summary voucher-chain audit results are in Table 3A, Table 3B, and Table 4; the SERNEC retrieval doctrine and sampling script/.md are in Appendix B.4; the reconstructed COI audit script is in Appendix D; the field demonstration is in Section 3.1.
Author Contributions
T.M.J. conceived the system and directed its development throughout. He built and reviewed the software with AI assistance, ran the demonstration, interpreted the results, and wrote and revised the manuscript. AI systems helped with code generation, debugging, review, and prose. The author checked and accepted every design decision, every line that shipped, and every claim in this paper, and takes full responsibility for them (Section 2.3).
Funding
No external funding. The work was carried out independently under TJID3 Research.
Conflict of Interest
The author wrote the software described here and maintains its deployment. No commercial interest, license, subscription, or revenue is attached to the tool, which is released CC BY 4.0.
References
- Anthropic. (2026). Claude Sonnet 5 [Large language model]. https://www.anthropic.com/claude
- Anthropic. (2026). Claude Cowork [Agentic software environment]. https://claude.com/product/cowork
- Anthropic. (2026). Claude Code, version 2.1.283 [Agentic coding tool]. https://www.anthropic.com/claude-code (accessed 26 September 2026).
- Anthropic. (2026). Claude Opus 5 [Large language model]. https://www.anthropic.com/claude/opus
- Anthropic. (2026). Claude Fable 5.1 [Large language model]. https://www.anthropic.com/claude/fable
- OpenAI. (2026). ChatGPT, GPT-5.6 Sol [Large language model]. https://chatgpt.com/
- OpenAI. (2026). ChatGPT, GPT-6 Astra [Large language model]. https://chatgpt.com/
- DeepSeek. (2026). DeepSeek-V4-Flash / Dan [Large language model]. https://www.deepseek.com/
- Arase, K. (2009). QR Code Generator for JavaScript [Computer software]. MIT License. GitHub. https://github.com/kazuhikoarase/qrcode-generator
- Biopython Contributors. (2026). Biopython, version 1.88 [Computer software]. https://biopython.org/
- Blender Online Community. (2026). Blender: A 3D modelling and rendering package [Computer software]. Blender Foundation, Amsterdam. https://www.blender.org/
- GitHub, Inc. (2026). GitHub [Software development and version control platform]. https://github.com/ (accessed 26 September 2026).
- Graphviz Authors. (2026). Graphviz: Open source graph visualization software [Computer software]. https://graphviz.org/
- Microsoft Corporation. Microsoft Excel [Computer software]. https://www.microsoft.com/microsoft-365/excel
- Microsoft Corporation. Microsoft Word [Computer software]. https://www.microsoft.com/microsoft-365/word
- Microsoft Corporation. PowerShell [Computer software]. https://learn.microsoft.com/powershell/
- Microsoft Corporation. Visual Studio Code [Computer software]. https://code.visualstudio.com/
- Python Software Foundation. (2026). Python, version 3.14.3 [Computer software]. https://www.python.org/downloads/release/python-3143/
- Specify Collections Consortium. (2026). Specify 7, including WorkBench [Computer software]. https://www.specifysoftware.org/ (accessed 26 September 2026).
- Sublime HQ Pty Ltd. Sublime Text 4 [Computer software]. https://www.sublimetext.com/
- Barkworth, M. E., and Murrell, Z. E. (2012). The US Virtual Herbarium: Working with individual herbaria to build a national resource. ZooKeys, 209, 55–73. https://doi.org/10.3897/zookeys.209.3205
- Barret, R., Indigo, J., Smart, D., and Cano, M. (2026). Page Weight. In The 2025 Web Almanac, Chapter 14. HTTP Archive. https://doi.org/10.5281/zenodo.18246723
- Bebber, D. P., Carine, M. A., Wood, J. R. I., Wortley, A. H., Harris, D. J., Prance, G. T., Davidse, G., Paige, J., Pennington, T. D., Robson, N. K. B., and Scotland, R. W. (2010). Herbaria are a major frontier for species discovery. Proceedings of the National Academy of Sciences, 107(51), 22169–22171. https://doi.org/10.1073/pnas.1011841108
- Brilhante, M., Darbyshire, I., Duarte, M. C., Moldão, M., Bandeira, S., and Romeiras, M. M. (2026). Mapping Biodiversity Through Time and Space: Patterns and Drivers of Fabaceae Collection in Mozambique. Ecology and Evolution, 16(2), e72854. https://doi.org/10.1002/ece3.72854
- Carné, A., Vieites, D. R., and van den Burg, M. P. (2025). In Vouchers We (Hope to) Trust: Unveiling Hidden Errors in GenBank's Tetrapod Taxonomic Foundations. Molecular Ecology, 34(13), e17812. https://doi.org/10.1111/mec.17812
- Chen, T., Liu, H., Han, G., Bao, B., Li, M., Xu, Z., and Qin, H. (2022). Curating and administrating the Plant Specimen Sub-Platform and CVH: activities, experience and lessons learned. Guihaia, 42(S1), 18–28. https://doi.org/10.11931/guihaia.gxzw202209068
- Daru, B. H., and Rodriguez, J. (2023). Mass production of unvouchered records fails to represent global biodiversity patterns. Nature Ecology & Evolution, 7(6), 816–831. https://doi.org/10.1038/s41559-023-02047-3
- DiSSCo. (n.d.). Services: DiSSCo Orchestration Service and Open DS Specification. https://www.dissco.eu/services/ (accessed 26 September 2026).
- DiSSCo. (2026a). open Digital Specimen (openDS) specification, version 0.4.0 [Data standard, JSON Schema]. Apache-2.0. https://github.com/DiSSCo/openDS (accessed 26 September 2026).
- DiSSCo. (2026b). DiSSCo Sandbox environment [Web service]. https://sandbox.dissco.tech/ (accessed 26 September 2026).
- GBIF Germany. (2025). Digitisation of German Herbaria. https://herbarium.gbif.de/digitisation/
- Gilbert, E., et al. (2026). Symbiota [Computer software]. Symbiota Support Hub. https://github.com/Symbiota/Symbiota
- Gries, C., Gilbert, E. E., and Franz, N. M. (2014). Symbiota: A virtual platform for creating voucher-based biodiversity information communities. Biodiversity Data Journal, 2, e1114. https://doi.org/10.3897/BDJ.2.e1114
- Harris, K. M., and Marsico, T. D. (2017). Digitizing specimens in a small herbarium: A viable workflow for collections working with limited resources. Applications in Plant Sciences, 5(4), 1600125. https://doi.org/10.3732/apps.1600125
- Heil, K. F., Alioto, T. S., Böhne, A., Brown, T., Chadwick, E., Chua, P., de Guttry, C., De Panis, D., Goble, C., Gut, I., Gut, M., Juty, N., McTaggart, S., Najera-Cortazar, L., Paupério, J., Rewicz, T., Shaw, F., Soiland-Reyes, S., Waterhouse, R. M., Woollard, P., and Vos, R. A. (2026). Integrating the biodiversity genomics continuum: harmonising data from barcodes to reference genomes. Research Ideas and Outcomes, 12, e187033. https://doi.org/10.3897/rio.12.e187033
- Hudson, L. (2022). pyzbar: Read one-dimensional barcodes and QR codes from Python 2 and 3 (Version 0.1.9) [Computer software]. PyPI. https://pypi.org/project/pyzbar/
- iDigBio. (2026). Integrated Digitized Biocollections (iDigBio) Portal [Web portal]. University of Florida. https://portal.idigbio.org/ (accessed 1 October 2026).
- Jones, T. M. (2025). The 20-Year File: A zero-dependency architecture for digital paratypes. TJID3 Research. Zenodo. https://doi.org/10.5281/zenodo.18054789. Current edition: https://tjid3.org/tech
- Jones, T. M. (2026a). Field Voucher Record, tmjv8 [Computer software]. TJID3 Research. https://tjid3.org/voucher/tmjv8. Archived within the Florilegium Caricis release, https://doi.org/10.5281/zenodo.19138828
- Jones, T. M. (2026b). Florilegium Caricis, North America [Computer software]. TJID3 Research. Zenodo. https://doi.org/10.5281/zenodo.19138828. Live build: https://tjid3.org/CAREXAI/carex_na29
- Jones, T. M. (in prep.). Carex Atelier Mule: Guided field workbench [Computer software]. TJID3 Research. https://tjid3.org/mule/mule-kicks6
- Kartesz, J. T., and The Biota of North America Program (BONAP). (2015). North American Plant Atlas. Chapel Hill, North Carolina. http://bonap.net/napa. Maps generated from Kartesz, J. T. (2015), Floristic Synthesis of North America, Version 1.0, Biota of North America Program (BONAP).
- Le Bras, G., Pignal, M., Jeanson, M. L., et al. (2017). The French Muséum national d’histoire naturelle vascular plant herbarium collection dataset. Scientific Data, 4, 170016. https://doi.org/10.1038/sdata.2017.16
- Library of Congress. (n.d.). Sustainability of Digital Formats: Sustainability Factors. https://www.loc.gov/preservation/digital/formats/sustain/sustain.shtml (accessed 26 September 2026).
- Liu, H. Y., Chen, T. X., Han, G. X., Li, Y., Li, R. G., Li, M., Xu, Z. P., and Qin, H. N. (2022). On the history and function of the Plant Specimen Sub-Platform and Chinese Virtual Herbarium (CVH). Guihaia, 42(S1), 3–17. https://doi.org/10.11931/guihaia.gxzw202209069
- MNHN, and Chagnoux, S. (2025). The vascular plants collection (P) at the Herbarium of the Muséum national d'Histoire Naturelle (MNHN - Paris), version 69.422 [Occurrence dataset]. Muséum national d'Histoire naturelle. https://doi.org/10.15468/nc6rxy (accessed via GBIF.org 27 September 2026).
- Nakazato, T., and Jinbo, U. (2022). Cross-sectional use of Barcode of Life Data System and GenBank as DNA barcoding databases for the advancement of museomics. Frontiers in Ecology and Evolution, 10, 966605. https://doi.org/10.3389/fevo.2022.966605
- Noyes, D. (2013, April 3). Restoring the first website. W3C Web History Community Group. https://www.w3.org/community/webhistory/2013/04/03/restoring-the-first-website/
- Powell, C., Krakowiak, A., Fuller, R., Rylander, E., Gillespie, E. B., Krosnick, S., Ruhfel, B., Morris, A. B., and Shaw, J. (2021). Estimating herbarium specimen digitization rates: Accounting for human experience. Applications in Plant Sciences, 9(4), e11415. https://doi.org/10.1002/aps3.11415
- Powell, C., and Shaw, J. (2021). Performant barcode decoding for herbarium specimen images using vector-assisted region proposals (VARP). Applications in Plant Sciences, 9(5), e11436. https://doi.org/10.1002/aps3.11436
- Reich, V., and Rosenthal, D. S. H. (2001). LOCKSS: A Permanent Web Publishing and Access System. D-Lib Magazine, 7(6). https://doi.org/10.1045/june2001-reich
- Rockefeller, A. (2026). iNaturalist Herbarium Label Generator [Computer software]. GitHub. https://github.com/AlanRockefeller/inat.label.py
- Ruston, J. (2026). TiddlyWiki: A non-linear personal web notebook [Computer software, 2004 to present]. TiddlyWiki community. https://tiddlywiki.com/
- SERNEC Data Portal. (2026). Southeast Regional Network of Expertise and Collections (SERNEC). Accessed September 24, 2026. https://sernecportal.org/
- Statista. (2026). Smartphone sales worldwide. Worldwide smartphone sales to end users, including 2025 annual unit sales. Accessed August 9, 2026. https://www.statista.com/statistics/263437/global-smartphone-sales-to-end-users-since-2007/
- Thiele, K. R., Groom, Q., Renner, S. S., Applequist, W. L., and Lehtonen, S. (2023). (329–338) Proposals to permit DNA sequences to serve as types of names in prescribed circumstances. Taxon, 72(5), 1143–1145. https://doi.org/10.1002/tax.13031
- Thiers, B. M. (2026). Index Herbariorum: A Global Directory of Public Herbaria and Associated Staff. New York Botanical Garden's Virtual Herbarium. Updated continuously; accessed June 24, 2026. https://sweetgum.nybg.org/science/ih/
- Turnbull, R., Fitzgerald, E., Thompson, K. M., and Birch, J. L. (2025). Hespi: A pipeline for automatically detecting information from herbarium specimen sheets. BioScience, 75(8), 637–648. https://doi.org/10.1093/biosci/biaf042
- Vasconcelos, T., and Boyko, J. D. (2025). mvh: An R tool to assemble and organize virtual herbaria from openly available specimen images. Applications in Plant Sciences, 13(2), e11631. https://doi.org/10.1002/aps3.11631
- W3C. (1992). World Wide Web. Preserved November 1992 website snapshot. https://www.w3.org/History/19921103-hypertext/hypertext/WWW/TheProject.html (accessed 26 September 2026).
- W3C Technical Architecture Group (W3C TAG). (2026, September 14). Web Platform Design Principles. Section 1.8, Prioritize compatibility when changing or removing features. W3C Group Note. https://www.w3.org/TR/2026/NOTE-design-principles-20260914/
- Wieczorek, J., Bloom, D., Guralnick, R., Blum, S., Döring, M., Giovanni, R., Robertson, T., and Vieglais, D. (2012). Darwin Core: An evolving community-developed biodiversity data standard. PLOS ONE, 7(1), e29715. https://doi.org/10.1371/journal.pone.0029715
- Worldwide Protein Data Bank (wwPDB). (n.d.-a). Data category entity_src_nat. PDBx/mmCIF Dictionary, Version 5. Accessed September 19, 2026. https://mmcif.wwpdb.org/dictionaries/mmcif_pdbx_v50.dic/Categories/entity_src_nat.html
- Worldwide Protein Data Bank (wwPDB). (n.d.-b). Data category entity_src_gen. PDBx/mmCIF Dictionary, Version 5. Accessed September 19, 2026. https://mmcif.wwpdb.org/dictionaries/mmcif_pdbx_v50.dic/Categories/entity_src_gen.html
- Worldwide Protein Data Bank (wwPDB). (n.d.-c). Data item _entity_src_nat.details. PDBx/mmCIF Dictionary, Version 5. Accessed September 19, 2026. https://mmcif.wwpdb.org/dictionaries/mmcif_pdbx_v50.dic/Items/_entity_src_nat.details.html
- Wu, H.-Y., Chan, K.-T., But, G. W.-C., and Shaw, P.-C. (2021). Assessing the reliability of medicinal Dendrobium sequences in GenBank for botanical species identification. Scientific Reports, 11, 3439. https://doi.org/10.1038/s41598-021-82385-z
Appendices
Appendix A What the file does, item by item
Every job the file performs, in plain language, for a reader who uses a phone and a spreadsheet and has never looked at code. Items are numbered so they can be cited.
The file itself
1. One file, and the file is the whole program. Open it in any browser and it runs: no install, account, server, or internet once it is open. Even the QR codes are made on your device.
2. Everything stays on your device. Records live in browser storage; photographs in a larger browser store built for files. Nothing leaves unless you export or share it. Save the file and it is yours.
3. It remembers how you left it: light or dark, open sections, and field values you reuse.
4. It carries its own version number. Every record and export is stamped with the format version, so a future reader can tell which generation of the file made it.
Identity: how a specimen gets a name that never changes
5. Every specimen gets a permanent identifier when a draft begins: the occurrence UUID, a long random string never reused or changed. Photos, corrections, and exports all point back to it.
6. Every specimen also gets a voucher ID, shown as a QR code. This is the number for the label and sticker. Each record gets a fresh one.
7. Preprinted QR stickers work too. Scan one with the camera (Chrome or Edge) or type its ID, and the record adopts it. Attached photos move over.
8. It refuses bad IDs. An ID that looks like a web address is blocked, because a QR that points to a website dies with the website. An ID already used in this file is blocked too.
9. It cleans up its own past mistakes. Earlier versions allowed web-address IDs. On first run, after backing up the whole record set, any such record gets a proper ID and keeps the old one as legacy voucher ID.
10. It knows which device made each record. Each browser install gets a session UUID, stamped on everything it saves and shown in the corner badge (tap to copy). Merged exports from several people or machines still say where each record came from.
11. You can download the QR code as an image for a notebook page or another document.
Filling in a record
12. The fields follow Darwin Core, the shared vocabulary of GBIF and herbarium databases. Every field exports under its Darwin Core name, so the file goes to a collection database without re-entry.
13. You can add Darwin Core fields the form does not show, from a searchable picker grouped the way the official reference groups them. They save, export, and re-import like any other field.
14. Required fields are marked; the record will not save without them. A box lists what is missing and highlights it. The gate checks presence, not truth: it knows a field is blank, not that it is wrong.
15. It checks formats. Dates must be real year-month-day dates and are corrected as you type. Latitude and longitude must be in range.
16. One button fills coordinates from the device GPS, with reported accuracy, and notes GPS as the source.
17. It records where every coordinate came from (GPS button, photo metadata, or typed) under the Darwin Core georeference protocol term.
18. It handles several collectors. Add, reorder, remove. The first is primary; the rest export as additional collectors.
19. The collector is assumed to be the identifier until you say otherwise. The primary collector's name is copied into Identified by until you type a different one.
20. It suggests recent values, so a field day with one collector and one institution does not mean typing them fifty times.
21. Some fields carry over on purpose. ORCID, institution, country, and kingdom belong to the sitting, not the specimen, and stay filled after a save. Kingdom survives a reload, so a morning of insects is not filed under Plantae.
22. Duplicate for this site. Several specimens from one stop is the normal case. This starts a new record with a new ID, keeping locality, coordinates, habitat, and collectors.
23. A one-line trip summary sits above the form: collectors, institution, country.
24. It times each record, silently, from the first keystroke, selection, GPS fix, or photo to save. Export, print, and delete clicks do not count.
25. It saves the draft as you type. If the browser closes mid-record, a banner offers to restore it, IDs included, so it rejoins its photos. A restored draft is the same specimen, not a new one wearing the old words.
26. Deleting is reversible for a moment. Deleting a record shows an undo prompt. Clearing everything asks first.
Photographs
27. Photos attach from the camera or camera roll, are stored beside the record, and show in a small gallery on the saved card.
28. Photos are shrunk to 1920 pixels on the longest edge and re-saved as JPEG. Plenty for identification, and fifty vouchers do not fill the device.
29. The voucher ID is written onto the photo twice. Once visibly in a corner, once invisibly in the JPEG's EXIF block in three places. A photo separated from its record still carries its ID.
30. It reads location from a photo. Empty coordinate fields are filled, with the photo as source. If coordinates exist, it reports the distance between the two, so a photo taken at the truck and a specimen up the slope do not silently disagree.
31. "No photo" and "photo attached" cannot both be true. Ticking the no-photo box on a record with photos is refused. Adding a photo unticks the box. The save gate checks again.
32. Photos follow the record. If the ID changes, photos are re-keyed and re-stamped to match. Deleting a record deletes its photos rather than leaving orphans.
Corrections after the fact
33. The original record is never overwritten. A redetermination or coordinate correction is saved as a separate addendum pointing to the occurrence UUID. The original stays as first written.
34. Two kinds of addenda exist: redetermination and georeference correction, each with its own short form.
35. Exports show original and current state: the original fields, matching "current" columns from the latest addendum, and a count of addenda.
36. Correction history survives a spreadsheet round trip. JSON nests the chain under each record; CSV, XLSX, and HTML carry it in one extra column, and re-import rebuilds it instead of quietly reverting a redetermination.
37. It watches for broken chains. An addendum whose parent record is missing is reported, on the main page and in the compiler.
The saved list
38. Search, sort, and select. Every export and print action works on the selection, or on everything if nothing is selected.
39. Each card shows its addenda as badges and a panel.
Getting data out
40. CSV. Darwin Core column names. Opens in any spreadsheet.
41. JSON. Keeps numbers as numbers and true/false as true/false, nests the addenda, and lists each record's photos by name and size without embedding them.
42. XLSX. A real Excel workbook written by code inside this file. Opens in Excel, LibreOffice, Numbers, and Google Sheets.
43. HTML table. A self-contained page of the records, readable in any browser, printable in landscape.
44. Printed labels. 4 × 3 inches, two across on legal paper: QR, name, family, locality, coordinates, habitat, associates, collector, number, date. A redetermined specimen prints the current name with "Orig. det." underneath, redeterminer and date included, which is what an annotation slip would say anyway.
45. Photos as a ZIP, built by code inside this file, each photo still carrying its stamped ID.
46. Email or share. On a phone, the share sheet with the JSON attached. On a desktop, a download plus a blank email with instructions to attach it.
47. Exports are spreadsheet-safe. A cell a spreadsheet would read as a formula is neutralized, so a pasted locality note cannot run as a command. Real numbers, negative longitudes included, are left alone.
Getting data in
48. Import CSV or JSON produced by this tool, or by another copy of it on another device.
49. It refuses to overwrite by accident. An incoming record whose voucher ID matches a saved one is blocked and the clash reported.
50. It flags what it cannot trust. Malformed or incomplete records are imported anyway and marked in an import review column, so nothing is lost and nothing passes as clean.
51. Typed fields are restored on import. Spreadsheets turn everything into text; numbers and true/false values come back per the schema, and anything unrecognized stays text for review, not guessed at.
The compiler drawer
52. It merges many exports into one set: JSON, CSV, HTML, XLSX, and photo ZIPs from any number of devices or seasons. A specimen in several files is matched by occurrence UUID, kept once, and its addenda merged.
53. It counts what it did: records, source files, collectors, photo references, photo files, duplicates skipped, addenda merged, problem children.
54. It exports the merged set in every format, every row stamped with a compile batch UUID naming this particular merge.
55. It is deliberately temporary. The compiler empties on reload: a workbench, not a second database. "Add this report" pulls in this device's own records alongside the loaded files.
Self-checks and honesty about failure
56. It tells you when the browser refused to save. Browsers refuse writes when storage is full or the window is private. A banner says the record is on screen but not on disk, export now, and stays until a save succeeds. A fading message is how people come to believe a problem went away.
57. It asks the browser to keep the data, where supported, so storage is spared from automatic cleanup. The result shows as a status line.
58. It warns Safari users specifically as the problem child. Safari deletes a site's data after seven days without a visit; the file advises adding the page to the Home Screen, Apple's documented exemption. Tested on Apple hardware only at cellphone kiosks; appears functional; shims are built in.
59. It migrates its own old storage layouts, once, on first run.
60. Dates are local. The date on a record is the calendar date where you are standing, not the date in Greenwich, so an evening collection in Louisiana is not filed under tomorrow.
Appendix B Technical reference
Line numbers refer to ironfist911.html as distributed (11,943 lines). Function names are as they appear in the source. A leading underscore marks a helper not meant to be called from markup. The section numbers (1.1, 5.11, and so on) are the file's own; its module map at line 1182 lists them, and searching the source for a number jumps to that section.
B.1 Architecture
File layout. One HTML document, no external requests. Lines 1 to 23 are document metadata (Dublin Core, citation, canonical DOI). Lines 24 to 26 are a JSON-LD block typing the file as SoftwareSourceCode, WebApplication, and ScholarlyArticle. Lines 27 to 634 are CSS, light and dark themes as CSS custom properties. Lines 636 to 1132 are markup: the three panes (field form, saved-record list, office tools) and the compiler drawer. Lines 1133 to 1173 hold the QR generator as its own plain script. Lines 1174 to 10706 are the main program, Parts 1 to 8. Lines 10707 to 10936 are the dialogs. Lines 10937 to 11689 are the compiler drawer (Part 9), a second script in its own closure. Lines 11691 to 11938 are the pane shell (Part 10), a third.
Runtime layers. Four things carry the state; everything else reads through them.
FIELD_DEFS (line 1312) is the schema. Each entry maps an internal key to a CSV/Darwin Core column name and a human label, with an optional type of boolean or number. Every exporter, importer, form reader, and the HTML table iterate this one array. _coerceFieldValue() (line 1461) is the only place typed values are converted on the way in from flat formats.
Store (line 1599) is the persistence kernel: a single localStorage key (fieldVoucherRecords_v1, line 1573) holding an object of typed lists (voucher, addendum, uiState, list, bundle). Operations are all, get, put (upsert by id), remove, replaceAll, mutate, filter, lastWriteOk, and invalidateCache. Every write re-reads disk first, so two tabs on one workspace do not overwrite each other; mutate (line 1715) is the read-modify-write form. A refused write sets a flag and routes to the storage failure reporter. Upsert-by-id is the property the merge model rests on: two devices generating different UUIDs can union their exports without collision, and re-importing the same file is idempotent.
Bus (line 1797) is a minimal publish/subscribe with on and emit. The list, gallery, problems note, and workspace bar redraw on vouchers:changed; addenda:changed and draft:restore work the same way. Listeners are wrapped so one failing listener cannot stop the rest.
Draft (line 5074) is a plain object mirroring the form. Delegated input and change listeners update it as the user types; bulk writers (reset, restore, duplicate, GPS, EXIF) call _draftSyncFromDOM() (line 5076) immediately after setting values, because programmatic .value assignment fires no input event. voucherFromForm() (line 5384), the save gate, and the autosave all read Draft rather than the DOM.
Workspaces. One URL parameter, ?ws=name (line 1296), turns the file into separate data sets. Every storage key gets the workspace name appended through _wsKey(); the default workspace keeps the bare keys, so data from earlier builds is where it always was. The list of known workspaces and the install's device tag are shared on purpose.
Identifiers. Three UUIDs with three lifespans, all minted by uid() (line 1873, RFC 4122 v4 via crypto.randomUUID, falling back to getRandomValues, then to Math.random). occurrenceID is one per specimen, immutable, minted at draft start by voucherOccurrenceUUID() (line 2777), and is the foreign key for addenda and the dedupe key for merges. The Voucher ID (id) is the label and QR number; it is generated per draft but may be replaced by a preprinted sheet, and photos are re-keyed when it changes. sessionId() (line 2016) is one per browser install, stored under the legacy name deviceTag for backward compatibility, and stamped on every voucher and addendum at save. compileBatchId is one per compiler clear, stamped on compiled exports only.
Storage keys. Records: fieldVoucherRecords_v1 (line 1573). Draft autosave: fieldVoucherDraftAutosave_v1 (line 5115). Pre-migration backup of URL-shaped IDs: fieldVoucherreport_redirect_backup_v1 (line 2896). Photos: IndexedDB database fieldVoucherPhotos_v1, store photos (lines 3299 to 3300), holding Blobs keyed by photo id with a voucherId index. All four take the workspace suffix. Shared across workspaces: fieldVoucherWorkspaces_v1 (line 1303) and fieldVoucherDeviceTag_v1 (line 2006). Legacy keys read once by migration and then ignored: fieldVoucherreport_v1 (line 1304), fieldVoucherCollectors_v1 (line 4159), fieldVoucherDwcExtraFields_v1 (line 4318). Record schema version constant: RECORD_SCHEMA_VERSION = "v2" (line 1579).
Addenda model. Corrections are their own Store type (addendum), each with its own UUID, a parentOccurrenceID, a parentVoucherId, a type of georeference or redetermination, a payload, an author, a device tag, and a timestamp. The voucher row is never rewritten. computeCurrentStateFields() (line 2159) derives the current_* columns: the standing redetermination, and the standing georeference by supersession rather than array order (_geoHeads(), line 2259). Decoration happens at export and render time, never at write time. DERIVED_STATE_KEYS (line 2219) is computed from the same function so the two cannot drift.
One door in. canonicalRecord() (line 2522) is the single entry for every saved and imported record: FIELD_DEFS keys plus four named extras, so an unrecognized column cannot ride into the store. addRecords() (line 2631) is the single import path for JSON, CSV, and take-home bundles. The compiler drawer uses the same canonicalRecord().
Export and import symmetry. JSON nests the addenda array. CSV, XLSX, and HTML serialize the same array into the addenda_json column. parseAddendaField() (line 2137) accepts either shape, so a flat round trip is chain-safe.
Ways out. Files: CSV, JSON, XLSX, photo ZIP, HTML table, email, labels, QR sheets, and QR tags (Part 5). Take it home (section 5.5b, line 7131) writes one ZIP per part holding a viewer page, the JSON and CSV exports, the photos, a manifest with a SHA-256 for every file, and a README; a GitHub route cuts parts under GitHub's browser limits (line 7171). Import bundle (section 5.5c, line 7713) brings a bundle back and checks every photo against its manifest hash before reattaching it. Collection exports for Symbiota, Specify, and DiSSCo share one reader, _csPrepare() (line 8820).
Init sequence (lines 10625 to 10705). Tracer install, kernel migration from legacy keys, theme, dates, kingdom, pristine snapshot, form listeners, Voucher ID and occurrenceID, Darwin Core fields, collectors, autofill guards, section state, autosave check, trip strip, timer arming listeners (click, input, change, capture phase), legacy URL-ID migration followed by vouchers:changed, store reconciliation and the snapshot offer, storage resilience check, the self test when asked for, session badge, top stamp, workspace bar, two-tab presence, and the associated-voucher readout.
Third-party code. One item: Kazuhiko Arase's QR Code Generator for JavaScript, MIT, vendored at lines 1133 to 1173. It runs as its own script and sets window.QR; _ensureQR() (line 2747) only confirms it is there. Everything else, ZIP reader and writer, XLSX writer, EXIF reader and writer, JPEG re-encoder, SHA-256 through the browser's crypto.subtle, is in-file.
B.2 Function reference
All 488 functions in the file, in source order, grouped by the file's own sections. Each description is the one-line comment above the function in the source.
1.1 Workspaces (lines 1278 to 1306)
_wsSanitize(v), line 1295. Folds any workspace name to lowercase letters, digits and hyphens, 24 characters max.
_wsKey(k), line 1300. Storage key for this workspace; the default workspace keeps the bare key.
_wsFileName(n), line 1302. Download filename for this workspace: pests_field_vouchers_2026-09-19.csv.
1.2 Field definitions (lines 1307 to 1526)
_coerceFieldValue(def, value), line 1461. Turns a raw cell value into the type its FIELD_DEFS entry declares (boolean, number, text).
_boolTrue(v), line 1481. True for true, "true", 1 or "1"; everything else is false.
_mergeAddendumCopies(kept, incoming), line 1493. Two copies of the SAME addendum (same id) that disagree on isCurrentIdentification.
1.3 Kernel: Store and Bus (lines 1527 to 1756)
_load(fresh), line 1621. Reads the whole record store from localStorage, using the cache when nothing has changed.
_save(), line 1648. Writes the cache to localStorage and reports a refused write instead of swallowing it.
all(type), line 1664. Every record of one type (a copy, safe to change).
get(type, id), line 1666. One record by type and id, or null.
put(type, id, data), line 1671. Adds or replaces one record, then saves.
remove(type, id), line 1683. Removes one record by type and id, then saves.
replaceAll(type, records), line 1704. Swaps out every record of one type in a single write. 801: this re-reads disk and then throws the re-read away.
mutate(type, fn), line 1715. Read, modify, write against what is on disk NOW. fn receives the current list (a copy, safe to mutate) and returns the list to store; returning nothing keeps the copy it was handed.
filter(pred, type), line 1726. Every record that passes a test, across one type or all of them.
lastWriteOk(), line 1737. Did the last write to localStorage succeed?
invalidateCache(), line 1743. Drops the parsed copy so the next read goes back to disk.
1.4 Storage failure reporting (lines 1757 to 1811)
storageWriteFailed(), line 1768. True while localStorage is refusing writes.
reportStorageWriteFailure(err, what), line 1770. Records a failed write and warns the user once.
noteStorageWriteRecovered(), line 1789. Clears the storage warning once writes succeed again.
on(evt, fn), line 1800. Subscribes a function to a named event.
emit(evt), line 1802. Calls every function subscribed to an event; one failing listener never stops the rest.
1.5 Util (lines 1812 to 1889)
esc(s), line 1816. Escapes text for safe use inside HTML.
_forceLowercase(el), line 1822. Species epithets are never capitalized in botanical nomenclature, but autocapitalize="off" alone is not honoured by every mobile keyboard, so this forces the value itself lowercase as it's typed rather than trusting the attribute.
todayISO(), line 1841. Today's date from local calendar parts, not UTC, so an evening record is not dated tomorrow.
fullName(first, last), line 1849. First and last name joined into one display name.
_isoDateInputFilter(el), line 1857. Keeps a typed date in YYYY-MM-DD form as the user types digits.
uid(), line 1873. A random v4 UUID, with fallbacks for older browsers.
1.6 Legacy to Store migration (lines 1890 to 1948)
_kernelMigrateLegacyIfNeeded(), line 1897. One-time move of old per-feature storage keys into the Store.
2.1 Addenda (lines 1949 to 2320)
_sharedDeviceTag(), line 2008. The install-wide copy of the tag, or "" if none or unreadable.
_rememberSharedDeviceTag(tag), line 2012. Writes the install-wide copy of the tag, only if it is still empty.
sessionId(), line 2016. The per-install session tag: reads it, borrows the shared copy, or mints one on first call.
createAddendum(parentOccurrenceID, parentVoucherId, addendumType, payload, authorFirst, authorLast), line 2038. Files a new addendum (redetermination, georeference, note) against a saved voucher.
getAddendaFor(occurrenceID), line 2081. Every addendum for one occurrenceID, oldest first.
deleteAddendaForOccurrences(occurrenceIDs), line 2094. Deletes the addenda of vouchers being deleted, so none are left pointing at a missing occurrenceID; returns the count.
deleteAllAddenda(), line 2103. Removes every addendum in this workspace; returns how many went.
countAddendaForOccurrences(occurrenceIDs), line 2110. Counts the addenda that point at any of these occurrenceIDs.
deleteAddendum(id), line 2116. Deletes one addendum after a confirm; the voucher itself is untouched.
_normalizeAddendum(a), line 2130. Pulls the addenda chain off an incoming record whatever format carried it in: JSON nests the real array under .addenda, the flat formats carry the same array serialized in the addenda_json column.
parseAddendaField(src), line 2137. Reads the addenda chain from an imported record, whether nested JSON or an addenda_json cell.
computeCurrentStateFields(rec, addenda), line 2159. Works out the current name, determiner and coordinates from a voucher and its addenda.
decorateWithCurrentState(rec), line 2223. A saved voucher plus its live addenda and current-state fields.
decorateAllWithCurrentState(recs), line 2228. decorateWithCurrentState() over a list.
decorateWithNestedAddenda(rec), line 2234. Current-state fields from addenda already on the record (no Store lookup).
decorateAllWithNestedAddenda(recs), line 2239. decorateWithNestedAddenda() over a list.
_geoHeads(addenda), line 2259. The standing georeferences for one specimen, the ones nothing supersedes. One head is current; more than one is a conflict.
_geoLineage(head, addenda), line 2278. The standing georeference followed by everything it was made on top of, newest first, using the same supersession rule as _geoHeads().
findAddendumProblems(vouchers, addenda), line 2295. Finds orphaned addenda and specimens with zero or several current determinations.
findLocalAddendumProblems(), line 2317. findAddendumProblems() over this workspace's own saved data.
2.2 Saved list and import checks (lines 2321 to 2486)
load(), line 2325. Every saved voucher in this workspace.
persist(arr), line 2327. Replaces the saved voucher list.
persistUpdate(fn), line 2332. The read-modify-write form of persist(). Use this for anything that edits the current set (add one, delete one, delete these); persist() stays for a genuine wholesale replacement. fn gets the list as it is on disk at this instant, not as it was when the screen last drew.
_uniqueList(arr), line 2334. Distinct non-empty values, sorted.
_shortIdList(arr, max), line 2336. A short comma list of IDs for messages, with "+N more" when long.
_existingVoucherIdSet(records), line 2341. The set of Voucher IDs already in a record list.
_voucherIdInUse(id, records), line 2347. Is this Voucher ID already taken?
_freshUniqueId(used), line 2349. A new UUID not in the given set; adds it to the set.
_findIncomingIdClashes(items, existingRecords), line 2356. Lists incoming Voucher IDs that clash with saved ones or repeat inside the import.
_blockDuplicateImportIfNeeded(items, existingRecords), line 2369. Stops an import with a message if any Voucher ID would clash.
ingestAddendaFor(item, rec), line 2400. The import half of the addenda fix, and the actual bug: export nested the chain correctly, import ran every record through FIELD_DEFS and threw the nested array on the floor.
_malformedAddendaCell(src), line 2424. True when an addenda_json cell is present but not a readable list.
_addendaIntegrityIssues(src), line 2436. The dialog runs _addendumFormatProblems() on every addendum typed here; an addendum arriving in a file never did, so a georeference with latitude 999 imported clean and became current.
_recordIntegrityIssues(src), line 2448. Format problems in one record (bad coordinates, bad dates) for the import flag.
_joinImportIssues(existing, added), line 2480. Merges two lists of import issues into one "; " string without repeats.
2.3 Canonical record (lines 2487 to 2742)
canonicalRecord(raw, opts), line 2522. Builds one clean record from any input, field by field through FIELD_DEFS.
_fingerprintSource(rec), line 2591. The exact string every fingerprint is taken over.
_recordFingerprint(rec), line 2602. A content fingerprint of one record, used to spot exact duplicates on import.
_recordFingerprintLegacy(rec), line 2615. The pre-801 low-byte hash, kept so old stamps still verify.
_fingerprintMatches(rec, stamp), line 2625. True when a stored fingerprint still matches, old format or new.
addRecords(items), line 2631. Adds imported records to the saved list, merging, de-duplicating and ingesting addenda.
2.4 Voucher identity and QR engine (lines 2743 to 3135)
_ensureQR(cb), line 2747. The QR library runs as its own plain script at page load and sets window.QR; this just confirms it is there, then runs the callback.
voucherId(), line 2756. The draft's current Voucher ID (the QR value).
generateVoucherQR(optId), line 2758. Sets the draft's Voucher ID and draws its QR code.
voucherOccurrenceUUID(), line 2777. The draft's occurrenceID, created on first use.
_draftIdentityKey(), line 2784. Which draft is on screen. The occurrenceID is minted fresh for every new draft and restored with a restored one, and a relabel (new Voucher ID) leaves it alone, so it changes exactly when the specimen changes.
newDraftOccurrenceUUID(), line 2786. Starts a fresh occurrenceID for a new draft and shows it.
queuePhotoIdMigration(oldId, newId), line 2808. Moves the draft's photos to a new Voucher ID, one move at a time.
refreshVoucherQR(), line 2824. Mints a fresh Voucher ID for the draft and redraws its QR.
downloadVoucherQR(), line 2833. Saves the draft's QR code as a PNG.
makeCardQR(id, px), line 2845. A QR code as a PNG data URL, for cards and printouts.
_voucherQrMode(mode), line 2854. Switches the QR panel between a generated ID and a pre-printed sheet ID.
_cleanVoucherId(id), line 2868. Strips control characters and spaces from a Voucher ID.
_voucherIdIsUrl(id), line 2891. True when a Voucher ID is a web address or other resolvable link (not allowed).
async migrateLegacyRedirectVoucherIds(), line 2898. One-time repair: replaces URL-style Voucher IDs from old builds with UUIDs, keeping a backup.
_assignPreprintId(id), line 2966. Takes a scanned or typed pre-printed ID as the draft's Voucher ID.
async startVoucherScan(), line 2997. Opens the camera and starts looking for a QR code.
_doScanFrame(), line 3019. Checks one video frame for a QR code, then schedules the next.
_stopScanStream(), line 3036. Stops the camera and frame loop.
_qrModalSetTarget(t), line 3054. Sets the scan dialog's wording for its job: pre-printed sheet or associated voucher.
openQRModal(mode, target), line 3063. Opens the scan / type ID dialog.
closeQRModal(), line 3074. Closes the scan / type ID dialog and stops the camera.
_qrModalKeydown(e), line 3090. Escape closes the scan dialog.
_qrModalBackdropClick(e), line 3092. A click outside the box closes the scan dialog.
qrModalTab(mode), line 3094. Switches the scan dialog between Scan and Type tabs.
voucherManualIdPreview(), line 3114. Previews a typed ID as a QR code while typing.
voucherAssignManualId(), line 3121. Accepts the typed ID, for the draft or for an associated voucher link.
3.1 Coordinates (lines 3136 to 3260)
_setNoFixFromGPS(reason, displayReason), line 3140. Records a failed GPS attempt without wiping coordinates already entered.
useCurrentLocation(), line 3180. GPS button: the location prompt appears here, on the press, and nothing else prompts.
markManualCoordEntry(), line 3246. Marks coordinates as typed by hand and notes the source.
_coordNoFixClean(), line 3256. True when GPS failed and the coordinate boxes are still empty.
3.2 Photo store (lines 3261 to 3924)
_openPhotoDB(), line 3305. Opens (or creates) the photo database.
_photoId(), line 3335. A unique id for one stored photo.
async resizeImageFile(file, maxDim, quality, stampId), line 3349. Shrinks and re-encodes a photo to JPEG, stamping the Voucher ID into it.
_stampPhotoId(ctx, w, h, id, coverId), line 3400. Burns the Voucher ID into the photo's pixels, sized so a long ID fits on a small photo.
async _restampPhotoBlob(blob, oldId, newId), line 3431. Re-draws a photo's stamped ID after the voucher's ID changes.
sanitizeFilename(s), line 3461. Makes text safe to use as a file name.
_asciiBytes(s), line 3475. Text as null-terminated ASCII bytes, for EXIF fields.
_buildVoucherExifSegment(id), line 3483. Builds an EXIF block that carries the Voucher ID.
entry(tag, type, count, value), line 3507. Writes one EXIF directory entry.
_isExifApp1(bytes, off), line 3533. True when these bytes start an EXIF APP1 segment.
_stripExifApp1(bytes), line 3538. Removes any existing EXIF segments from a JPEG.
async _jpegWithVoucherExif(blob, id), line 3558. Puts the Voucher ID EXIF block into a JPEG.
async _jpegHasVoucherExif(blob, id), line 3569. Does this JPEG already carry this Voucher ID in its EXIF?
_photoExt(p), line 3600. The file extension that matches what is actually stored.
photoFilename(idVal, index, p), line 3611. <voucherId>_NN.<ext> for one photo in an export.
stablePhotoNames(voucherIdVal, photos, rec), line 3625. Photo id to filename for a voucher's photos. A photo keeps the first name it went out under; a new photo takes the lowest unused number.
async addPhotosToVoucher(voucherIdVal, files), line 3647. Saves photos to the database under one Voucher ID.
async getPhotosForVoucher(voucherIdVal), line 3687. Every stored photo for one Voucher ID.
async photoVoucherIdCounts(), line 3745. Every voucherId that has at least one photo row against it, with a count.
async deletePhoto(photoId), line 3766. Deletes one stored photo.
async deletePhotosForVoucher(voucherIdVal), line 3775. Deletes every photo for one Voucher ID.
async clearAllPhotos(), line 3787. Deletes every stored photo in this workspace.
async migratePhotosVoucherId(oldId, newId), line 3799. Moves photos from an old Voucher ID to a new one, re-stamping each.
async renderPhotoGallery(), line 3829. Draws the thumbnail strip under the Photos field.
async deletePhotoUI(photoId), line 3853. Removes one photo after a confirm.
onPhotoSkipToggle(), line 3859. Shows or hides the "no photo" reason box.
openPhotoInput(which), line 3888. Opens the camera or the picker from a real user press.
async handlePhotoInput(inputEl), line 3897. Handles photos picked in the form: store, stamp, check GPS.
3.3 EXIF GPS (lines 3925 to 4078)
async extractExifGPS(file), line 3944. Reads GPS coordinates from a JPEG's EXIF, or null.
_parseExifGPS(view, tiffBase), line 3967. Walks the EXIF directories to the GPS tags and returns decimal lat/lon.
_haversineMeters(lat1, lon1, lat2, lon2), line 4027. Distance in meters between two lat/lon points.
async reconcileExifGPS(files, draftKey), line 4034. Compares photo GPS with the form's coordinates; fills or flags as needed.
3.4 Voucher timer (lines 4079 to 4146)
beginVoucherTimer(), line 4113. Starts the draft's timer.
disarmVoucherTimer(), line 4117. Clears the draft's timer.
_isDraftSurface(el), line 4126. True when an element is part of the draft (form or QR block).
_armVoucherTimerOnFirstAction(ev), line 4131. Starts the timer on the first real edit to the draft.
3.5 Collectors (lines 4147 to 4305)
_collectorsLoad(), line 4165. The saved collector list.
_collectorsPersist(), line 4170. Saves the collector list.
_collectorsSyncFromDOM(), line 4175. Copies typed collector names from the form into memory.
_collectorsSyncAndSave(), line 4183. Reads, saves and passes collector changes on to the trip strip and Identified-by.
_syncDetFromCollector(), line 4196. Mirrors the primary collector's name into the Identified-by (det) fields: the collector is the default determiner at time of discovery.
renderCollectorRows(), line 4209. Draws the collector rows.
addCollectorRow(), line 4241. Adds an empty collector row.
removeCollectorRow(id), line 4250. Removes a collector row, with Undo.
moveCollectorRow(id, dir), line 4268. Moves a collector up or down the list.
getCollectorNamesList(), line 4281. Every collector's full name, primary first.
getPrimaryCollectorFirst(), line 4286. The primary collector's first name.
getPrimaryCollectorLast(), line 4288. The primary collector's last name.
getAdditionalCollectorsFromForm(), line 4290. The other collectors, joined with " | ".
_restoreExtraCollectorValues(), line 4294. Redraws the collector rows from memory.
initCollectors(), line 4298. Loads saved collectors and draws their rows at startup.
3.6 Additional Darwin Core fields (lines 4306 to 4572)
_dwcLoadSchema(), line 4321. The saved list of added Darwin Core terms.
_dwcSaveSchema(), line 4326. Saves the list of added Darwin Core terms.
_dwcSafeId(term), line 4330. A Darwin Core term made safe for use in an element id.
_dwcCategoryFor(term), line 4332. The picklist category a Darwin Core term belongs to.
_dwcAllTermsFlat(), line 4337. Every Darwin Core term in the picklist, with its category.
filterDwcTerms(), line 4344. Filters the Darwin Core picklist as the user types.
hideDwcTermDropdown(), line 4365. Hides the Darwin Core picklist.
selectDwcTerm(term), line 4367. Adds the chosen Darwin Core term to the form.
_dwcAddRow(term, value), line 4379. Draws one added Darwin Core row, with an optional value.
removeDwcExtraField(term), line 4395. Removes an added Darwin Core row, with Undo.
getDwcExtraObjectFromForm(), line 4412. The added Darwin Core values as {term: value}.
packDwc(obj), line 4422. {term: value} packed as "term: value | term: value".
getDwcExtraFieldsFromForm(), line 4426. The added Darwin Core values, packed.
_parsePackedDwc(str), line 4453. The packed extraDwcFields string as a term/value object.
dwcShapeConflicts(rec), line 4462. Terms where a record's dwc object and its packed string disagree.
parseExtraDwc(rec), line 4473. A record's added Darwin Core terms, from either shape or both.
dwcColumnsFor(recs, opts), line 4499. The added Darwin Core columns for an export, in a stable order: this workspace's configured terms first, then any others the records carry.
exportFieldDefs(recs, opts), line 4520. The export column list: FIELD_DEFS with added Darwin Core columns in place.
flattenDwc(rec), line 4528. One record with its added Darwin Core values spread into their own columns.
flattenAllDwc(recs), line 4535. flattenDwc() over a list.
dwcTermForHeader(h), line 4541. Matches an import column header to a Darwin Core term, any case.
dwcFromColumns(headers, cells, taken), line 4549. Collects Darwin Core values from import columns nothing else claimed.
attachDwc(rec, src, fromCols), line 4561. Puts the Darwin Core values on a record, both as an object and packed.
initDwcExtraFields(), line 4568. Restores the added Darwin Core rows at startup.
3.7 Associated voucher (lines 4573 to 4773)
_dwcEnsureTerm(term), line 4600. Adds a Darwin Core row to the form if it isn't there; returns its input.
_assocParts(v), line 4605. Splits a " | " list into parts.
_assocSet(el, parts), line 4607. Writes a DwC row's value the way typing would, so Draft and the autosave see it.
invalidateWorkspaceIndex(), line 4636. Drops the cross-workspace index so the next lookup rebuilds it.
_workspaceIndex(), line 4638. The parsed index of every other workspace's vouchers, built once and reused.
unreadableWorkspaces(), line 4667. The workspaces whose record store could not be read just now.
findVoucherAnywhere(idOrOcc), line 4669. Finds a voucher by Voucher ID or occurrenceID in any workspace here.
_assocRel(), line 4677. The relationship, tidied: no parentheses or bars, which would break the DwC string.
linkAssociatedVoucher(raw), line 4683. The link itself: look the scanned ID up, write the DwC rows, show it.
unlinkAssociatedVoucher(i), line 4709. Removes one link, and its associatedTaxa entry when there is one.
renderAssocReadout(), line 4727. One line per link: relationship, name, and where it lives.
toggleOtherTaxa(force), line 4756. The Other taxa bar under Additional Darwin Core field: opens and closes the associated-voucher block.
initAssociatedVoucher(), line 4765. Restores the associated-voucher controls at startup.
3.8 Suggestions (lines 4774 to 4936)
renderOptionDropdown(box, items, rowHtml), line 4784. Fills a suggestion box with rows, or hides it when empty.
hideDropdown(boxId), line 4791. Hides a suggestion box.
_recentValuesFor(key, max), line 4804. Recent distinct values of one field across saved vouchers.
showRecentSuggestions(fieldId, boxId, key), line 4813. Shows recent values under a field.
hideRecentSuggestions(boxId), line 4826. Hides recent values.
_recentPick(fieldId, idx), line 4828. Puts a picked recent value into its field.
_familyCandidates(), line 4862. Family names to suggest: this workspace's own first, then the seed list.
showFamilySuggestions(), line 4875. Shows family suggestions as the user types.
_familyPick(i), line 4889. Puts the picked family into the field.
_familyKey(e), line 4899. Arrow keys walk the list, Enter or Tab takes the highlighted row (Tab takes the top row when none is highlighted), Escape closes it.
updateRecentCollectorNames(), line 4919. Refreshes the collector name suggestions from saved vouchers.
3.9 Trip strip (lines 4937 to 4955)
renderTripStrip(), line 4943. Draws the trip strip: collectors and institution for this sitting.
3.10 Duplicate for this site (lines 4956 to 5059)
_legacyAdminKeys(o), line 4982. Maps the old State/Province and County/Parish draft keys onto their current names.
_siteFromForm(), line 4992. The site fields (place, coordinates, habitat) from the form.
_siteHasContent(site), line 5004. True when a site has a locality or coordinates.
rememberSite(site), line 5009. Remembers the last site for Duplicate for this site.
lastRememberedSite(), line 5013. The last remembered site, or null.
_applySite(site), line 5018. Writes a site back into the form.
_draftHasSpecimenContent(), line 5030. True when the draft has any specimen-level content.
async duplicateForThisSite(), line 5034. Starts a new draft at the same site as the last one.
3.11 Draft state (lines 5060 to 5086)
_draftSyncFromDOM(), line 5076. Copies every form field into Draft.
draftVal(id), line 5085. One draft value: from Draft, else from the form.
3.12 Draft autosave (lines 5087 to 5294)
_draftIdentitySnapshot(), line 5121. The draft's IDs, photo choice and added fields, for the autosave.
_draftAutosaveNow(), line 5142. Writes the draft autosave now.
scheduleDraftAutosave(), line 5161. Autosaves the draft shortly after typing stops.
clearDraftAutosave(), line 5166. Deletes the draft autosave.
capturePristineDraft(), line 5184. Notes what a blank form looks like, so the autosave can tell real edits.
_draftHasContent(data), line 5189. True when a draft differs from a blank form.
formHasUnsavedContent(), line 5215. True when the form holds unsaved work.
checkForDraftAutosave(), line 5230. Offers to restore a saved draft at startup.
_showDraftRestoreBanner(data, photoCount), line 5249. Shows the restore banner for a saved draft; photoCount > 0 says photos are the reason.
restoreDraftAutosave(), line 5278. OWNERSHIP, stated once so the next person reading this doesn't have to infer it from call sites: Store owns record state.
discardDraftAutosave(), line 5285. Throws away the saved draft and hides the banner.
dismissDraftRestoreBanner(), line 5290. Hides the restore-draft banner.
3.13 Apply a restored draft (lines 5295 to 5381)
applyDraftSnapshot(data), line 5301. Writes a restored draft back into the form.
3.14 Form (lines 5382 to 5441)
voucherFromForm(), line 5384. Builds a voucher record from the form.
3.15 Save gate (lines 5442 to 5844)
isNotRecorded(v), line 5487. True when a value is the "Not recorded" placeholder.
recordedOrBlank(v), line 5489. A value, or blank if it is "Not recorded".
fillNotRecorded(v), line 5491. Fills empty optional fields with "Not recorded"; returns which.
clearFieldHighlights(), line 5500. Removes red highlights from form fields.
_isRealISODate(s), line 5514. The gate tested non-empty and stopped there, so latitude=banana, longitude=x and 2026-99-99 all sailed through and landed in the record, the export, and GBIF's lap.
_coordProblem(raw, limit, name), line 5524. A message if a coordinate is not a valid decimal number in range, else "".
validateFieldFormats(), line 5533. Checks coordinate and date formats before a save.
validateRequiredFields(), line 5559. Lists required fields that are empty.
async validatePhotoRequirement(voucherIdVal), line 5599. The photo rule at save: a photo, or No photo ticked, never both.
showIncompleteVoucherModal(missing), line 5615. Shows the incomplete-voucher dialog and opens the sections at fault.
async saveVoucher(ev), line 5660. The form submit handler. Does one thing of its own, which is to refuse a second save while the first is still running, then hands off to _saveVoucherBody.
async _saveVoucherBody(), line 5671. The save itself, run one at a time by saveVoucher above.
rememberKingdom(v), line 5784. Kingdom belongs to the sitting, not to the specimen.
lastKingdom(), line 5788. The last kingdom used, or Plantae.
resetVoucherForm(), line 5793. Clears the form for the next voucher, keeping sitting-level values.
async clearVoucherDraft(skipConfirm), line 5830. Discards the draft and its photos, after a confirm if needed.
3.16 Autofill spill guard (lines 5845 to 5982)
_suppressBrowserAutofill(root), line 5860. Stops the browser's own address autofill from cross-wiring the form.
spillGuardedIds(), line 5897. The field ids the autofill spill guard watches, as a live list.
_spillSnapshot(), line 5912. The guarded fields' values before autofill can touch them.
_installAutofillSpillGuard(), line 5918. Installs the guard that undoes browser autofill spilling into other fields.
4.1 Render list, search, sort, bulk select (lines 5983 to 6189)
onVoucherSearchInput(), line 5990. Search box typed: filter the saved list.
onVoucherSortChange(), line 5996. Sort menu changed: re-sort the saved list.
toggleSelectAllVouchers(checked), line 6002. Ticks or clears every voucher currently shown.
toggleVoucherSelected(id, checked), line 6009. Ticks or clears one voucher.
updateBulkControls(), line 6014. Enables the bulk buttons and shows how many are ticked.
bulkDeleteSelected(), line 6029. Deletes every ticked voucher, with their photos and addenda.
renderVouchers(), line 6047. Schedules a list redraw 80 ms out, so a burst of changes draws once.
_renderVouchersNow(), line 6052. Draws the saved-voucher list now: filter, sort, cards.
async renderCardPhotos(voucherIdVal, slot), line 6159. Fills one card's photo strip.
4.2 Addendum UI (lines 6190 to 6438)
_addendaBadgeHTML(a), line 6197. The badge that says which device filed an addendum.
_addendumSummaryHTML(a), line 6206. One line describing an addendum's content.
renderAddendaPanelHTML(occurrenceID), line 6225. The addenda panel shown on a saved voucher's card.
_addendumPersonDefault(voucherId), line 6256. Who an addendum should be attributed to before anyone edits it, in the order of preference set out above.
_rememberAddendumPerson(first, last, orcid), line 6268. Remembers who filed the last addendum, for next time.
showAddAddendumModal(voucherId, occurrenceID, type), line 6274. Opens the add-addendum dialog for one voucher.
closeAddendumModal(), line 6298. Closes the add-addendum dialog.
_addendumFormatProblems(type, payload), line 6310. The save gate learned to check coordinates and dates in 108; this door never did, and it's the worse of the two.
submitAddendumModal(), line 6325. Checks and saves the addendum from the dialog.
deleteVoucher(id), line 6387. Deletes one voucher, its photos and addenda, with Undo.
openClearAllDialog(), line 6400. Opens the Clear all dialog.
closeClearAllDialog(), line 6402. Closes the Clear all dialog.
async confirmClearAll(), line 6412. Clears every voucher, addendum and photo, and redraws only after the photo sweep has finished.
4.3 Addendum problems note (lines 6439 to 6471)
renderAddendumProblems(), line 6450. findLocalAddendumProblems() has existed since the addenda went in and nothing ever called it outside the Compiler Drawer, so the local report could hold an orphan or two disagreeing redeterminations and show no sign of either.
5.1 Download helpers (lines 6472 to 6496)
downloadBlob(name, content, mime), line 6474. Saves content to a file with the workspace-prefixed name.
downloadCSV(name, csv), line 6484. Saves CSV text (with a BOM so Excel reads UTF-8).
downloadJSON(name, obj), line 6486. Saves an object as pretty-printed JSON.
downloadHTMLFile(name, html), line 6488. Saves an HTML string as a file.
recordsFor(ids), line 6491. The saved vouchers with these IDs, or all of them.
5.2 CSV export and import (lines 6497 to 6598)
_csvFormulaSafe(v), line 6515. A cell that starts with = + @ or a tab is a FORMULA to Excel, LibreOffice and Sheets, not text: they evaluate it on open, and =cmd|'/c ...'!A1 is a live command.
csvUnescapeFormulaGuard(v), line 6522. Removes the formula guard quote added on export.
csvEscape(v), line 6527. One CSV cell: formula-guarded and quoted when needed.
toCSV(records), line 6533. Records as CSV text.
exportCSV(ids), line 6540. Exports vouchers as CSV.
parseCSV(text), line 6549. Parses CSV text into rows of cells.
importCSV(ev), line 6569. Imports vouchers from a CSV file.
5.3 JSON export and import (lines 6599 to 6710)
async recordsWithPhotoManifest(recs), line 6606. Photo files travel separately via Export All Photos (ZIP); this JSON carries a manifest only.
async exportJSON(ids), line 6692. Exports vouchers as JSON, with a photo manifest.
importJSON(ev), line 6699. Imports vouchers from a JSON file.
5.4 ZIP and XLSX export (lines 6711 to 6936)
crc32(bytes), line 6717. CRC-32 checksum of bytes, for ZIP entries.
_u16(n), line 6727. A 16-bit number as two little-endian bytes.
_u32(n), line 6729. A 32-bit number as four little-endian bytes.
strToBytes(s), line 6731. Text as UTF-8 bytes.
base64ToBytes(b64), line 6733. Base64 text as bytes.
colLetter(n), line 6740. A column number as a spreadsheet letter (1 = A, 27 = AA).
xmlEsc(s), line 6773. Escapes text for XML, keeping line breaks.
makeZip(files), line 6782. Packs files into an uncompressed ZIP (used for XLSX and photo exports).
buildXLSX(records), line 6824. Builds an XLSX workbook (with QR images) from records.
exportXLSX(ids), line 6923. Exports vouchers as XLSX.
5.5 Photo ZIP export (lines 6937 to 7009)
async exportAllPhotosZip(ids), line 6953. Exports every photo for these vouchers as one ZIP, with a manifest.
5.5b Keep a copy of this file (lines 7010 to 7130)
_downloadFolderHint(), line 7041. Names this device's usual download folder, for the notice.
saveThisFileToDevice(), line 7059. Saves a copy of this report file itself to the device.
async _finishSaveToDevice(bytes, picked, name), line 7085. Waits for the bytes and the folder choice, then writes the copy.
5.5b Take it home (lines 7131 to 7712)
async _sha256Hex(bytes), line 7177. SHA-256 of some bytes as lowercase hex, or "" where the browser offers no crypto.subtle.
makeZipBlob(entries), line 7189. A stored (uncompressed) ZIP as a Blob. Same layout and UTF-8 name flag as makeZip(), but each entry's data may be a Blob that is never copied: the photo's own Blob goes into the archive by reference, so a 200 MB part never needs 200 MB of bytes in memory at once.
async _bundleTextEntry(name, text), line 7210. One text file as a ZIP entry, with its crc, size and SHA-256.
_bundledPhotoMap(), line 7216. Photo id to the name of a saved or shared bundle that holds it.
async _bundlePlan(recs, limits, opts), line 7223. Reads which photos each voucher has and cuts the vouchers into parts.
_bundleCard(rec, photoPaths), line 7253. One voucher as the viewer page shows it. Names come through _csPrepare() and _odsNameHTML(), the same reader every other export uses, so the page and the files beside it cannot disagree on the current name.
_bundleViewerHTML(meta, cards), line 7276. The open-me.html page. Everything it shows is embedded, so it opens from a folder with no server and no network.
_bundleReadme(meta, fileCount), line 7339. README.txt for one part: how to open it and how to check it.
async _bundleBuildPart(plan, k, bundle), line 7375. Builds one part: reads each photo once (for its crc and SHA-256), then hands the photo's own Blob to the ZIP.
_takeHomeSettings(), line 7442. Where bundles are going, remembered per workspace: route ("device" or "github"), and for GitHub the owner/repo and branch.
_githubRepoClean(v), line 7447. Owner/repo as GitHub allows it, or "" when it is not one.
_githubUploadURL(repo, branch), line 7452. The repo's upload page, pointed at the take-home folder on that branch.
async openTakeHomeModal(ids, opts), line 7457. Opens the Take it home dialog for these vouchers, or all of them.
async _takeHomeSetNewOnly(on), line 7470. The "only photos not taken home yet" box.
async _takeHomeReplan(), line 7477. Cuts the parts for the chosen route and redraws.
async _takeHomeSetting(key, value), line 7485. Saves a change to the route, repo or branch and replans.
_takeHomeGithubNote(), line 7495. The GitHub line: the upload link once the repo is valid, and what to do there.
_takeHomeRender(), line 7507. Draws the dialog from _takeHome.
closeTakeHomeModal(), line 7555. Closes the Take it home dialog and lets go of any built ZIPs.
async _takeHomePrepare(k), line 7562. Builds one part, logs what went into it, then offers Share and Save.
_takeHomeHandedOff(k, how), line 7589. Marks a part as having left the device, in the log the Remove step reads.
async _takeHomeShare(k), line 7595. Sends a prepared part through the device's share sheet.
_takeHomeSave(k), line 7610. Downloads a prepared part.
async clearBundledPhotos(), line 7629. The Remove step. Walks every saved voucher; a voucher's photos go only if every one of them is in a bundle that was saved or shared and still hashes to what that bundle recorded.
5.5c Bring it back, and what is not safe yet (lines 7713 to 7960)
async _putPhotoRecord(rec), line 7727. Writes one photo row straight into the photo store, keeping its original id.
async importBundleFiles(ev), line 7736. The Import bundle button: every picked ZIP, in name order.
async _importOneBundle(file, total), line 7758. One take-home ZIP: records in through addRecords(), then each photo checked and re-attached to the voucher as saved here.
scheduleTakeHomeNote(), line 7849. Schedules a refresh of the not-yet-taken-home line.
async renderTakeHomeNote(), line 7854. Counts vouchers with photos in no handed-off bundle, and draws the line.
_anyDialogOpen(), line 7908. True when some other dialog is already up, so the offer waits its turn.
async maybeOfferSnapshot(), line 7913. Decides whether to offer a snapshot now, and offers it.
_snapshotSnooze(hours), line 7947. Closes the offer and holds off for this many hours.
_snapshotYes(), line 7953. Yes: close the offer, open the snapshot. The short snooze keeps the offer from coming straight back if the snapshot is then abandoned.
5.6 Email (lines 7961 to 7997)
async emailreport(ids), line 7969. Shares or emails the JSON export.
5.7 HTML table export (lines 7998 to 8030)
plainDoc(title, inner, extraCSS), line 8000. A plain printable HTML page around some content.
exportHTMLTable(ids), line 8018. Exports vouchers as a printable HTML table.
5.8 Print labels (lines 8031 to 8178)
_taxonHTML(name, qualifier), line 8041. A determination as label HTML: name words italic, qualifier and rank connectors roman.
labelInner(v), line 8058. The inside of one herbarium label.
labelDoc(records), line 8107. A page of herbarium labels, four by three inches.
printHTMLNow(html), line 8139. Opens HTML in a new window and prints it.
printLabels(ids), line 8173. Opens herbarium labels for printing: the current determination on top, the original kept as Orig. det.
5.9 QR sheets (preprint) (lines 8179 to 8371)
_voucherIdTail(id), line 8197. Last four of a Voucher ID, lowercase; "" if too short.
_setVoucherIdLabel(el, id), line 8202. Writes a Voucher ID into the draft QR panel with its last four in bold.
_qrSheetDefaultRows(), line 8209. The sheet's lines as [{label, value}], remembered in uiState.
_qrSheetRows(), line 8217. The saved QR sheet lines, or the defaults.
_qrSheetRowsFromDOM(), line 8225. Reads the row editor back out of the dialog; empty rows drop.
_qrSheetSave(), line 8232. Saves as you type, so Cancel still remembers.
_qrSheetRenderRows(rows), line 8234. Draws the row editor: label, value, remove.
_qrSheetAddRow(), line 8244. Adds an empty line and puts the cursor in its label.
_mintSheetIds(n), line 8255. Mints n lowercase v4 UUIDs whose last four are unique within the batch and don't repeat any last four already on a voucher in this browser (or the current draft).
qrSheetDoc(ids, fields), line 8273. Builds the print window: one letter page per UUID.
openQrSheetModal(), line 8310. Opens the QR sheets dialog, building it on first use.
closeQrSheetModal(), line 8344. Closes the QR sheets dialog.
_qrSheetKeydown(e), line 8351. Escape closes the dialog.
_qrSheetSetCount(n), line 8353. Picks 10/20/50/100 and lights the matching button.
_qrSheetResetFields(), line 8358. Back to the default six lines, names from the primary collector.
printQrSheets(), line 8363. Saves the lines, mints the batch, opens the print window.
5.10 QR tags (reconciliation print) (lines 8372 to 8624)
_qrTagPerPage(), line 8412. The saved tags-per-page choice, or 12.
_qrTagSetPerPage(n), line 8418. Picks 20/12/6 per page, lights the button, remembers it.
_qrTagNameHTML(v), line 8425. The name line for a tag: qualifier upright, binomial in italics.
_qrTagTime(v), line 8434. 24-hour local time the draft was started (falls back to save time).
_qrTagDates(), line 8440. Distinct collection dates with counts, newest first.
_qrTagRecords(scope), line 8446. The records a scope names, oldest saved first (field order).
_qrTagRenderScopes(), line 8453. Fills the scope picker: selected (if any), each day, all.
_qrTagRenderList(), line 8464. Draws the checklist for the current scope; sheet-ID vouchers start unticked.
_qrTagToggle(cb), line 8480. One checkbox in or out of the print.
_qrTagSetAll(on), line 8485. All / None for the checklist.
_qrTagCount(), line 8489. Keeps the Print button honest about how many tags it makes.
_qrTagSetScope(v), line 8495. Scope picker changed.
qrTagDoc(recs, perPage), line 8498. Builds the print window: a letter-page grid of cut-apart tags, grouped under a collector and date heading.
openQrTagModal(), line 8560. Opens the QR tags dialog, building it on first use.
closeQrTagModal(), line 8598. Closes the QR tags dialog.
_qrTagKeydown(e), line 8605. Escape closes the dialog.
printQrTags(), line 8608. Prints the ticked vouchers in save order. The print window is opened inside the click so a pop-up blocker doesn't eat it.
5.11 Collection systems: Symbiota, Specify and DiSSCo (lines 8625 to 9518)
_csTermURI(name), line 8703. The term URI for a column name: Dublin Core, Symbiota, or Darwin Core.
_csCell(v), line 8709. One CSV cell for a machine file: control characters dropped, RFC 4180 quoting, nothing else.
_csCSV(headers, rows), line 8715. A header and rows of objects as CSV text, LF line ends, trailing newline.
_csTidy(v), line 8721. Trims and collapses runs of whitespace.
_csFull(first, last), line 8723. "First Last" from two parts, skipping blanks.
_csParseName(raw), line 8729. Splits a scientific name into genus, epithet, rank, infraspecific epithet and authorship.
_csMeters(raw), line 8764. Reads "±4365 m", "10m", "0.5 km", "30 ft", "0.5 mi" as whole metres.
_csQualifier(raw), line 8778. Ironfist's qualifier as a collection system writes it. determined is no qualifier.
_csUSState(raw), line 8788. A U.S. state or territory by postal code or full name. { value, matched }.
_csOrcid(raw), line 8800. A bare ORCID iD as its https://orcid.org/ URI; anything else passes through.
_csSplitCollectors(raw), line 8807. additionalCollectors ("A B | C D") as [{ first, last, guess }].
_csPrepare(recIn), line 8820. The one reader. Every derived value both exports use, worked out once from a decorated record (addenda and current_* fields on it), plus the review flags.
_csReviewRows(P, system), line 8970. review.csv rows for one prepared record, only the flags that apply to this system.
_csReview(rows), line 8976. review.csv text, sorted fix, then check, then info; with the counts.
_csSymbiotaOcc(P), line 8988. The occurrence core row for one prepared record, plus any added-term conflicts as flags.
_csSymbiotaDets(P), line 9021. Identification extension rows for one prepared record, oldest first.
_csMetaXml(occCols, detCols), line 9030. meta.xml for the archive: core and extension, one field element per column after the key.
buildSymbiotaArchive(recs), line 9045. The Symbiota ZIP entries ([{ name, text }]) for these decorated records, and the review counts.
_csSpecifyTaxonCols(n, h, family), line 9096. One determination's WorkBench columns as [header, value] pairs. n is 1 for the current one (no prefix, and Family), 2 and up for earlier ones ("Det 2 ...").
_csSpecifyRow(P, nColl, nDet, addedTerms), line 9117. One WorkBench row as [header, value] pairs, sized to the most collectors and determinations any exported record has, so every row has the same columns.
_csSpecifyTarget(header), line 9145. Where a WorkBench column goes in Specify's stock schema: { table, field, note }.
buildSpecifyWorkbench(recs), line 9208. The Specify ZIP entries ([{ name, text }]) for these decorated records, and the review counts.
_odsClean(v), line 9301. Drops empty strings, nulls, empty arrays and objects left holding nothing but their @type, so the file carries no hollow keys.
_odsNum(s), line 9313. A decimal string as a number, or undefined when it isn't one.
_odsAgent(first, last, orcid, role, position), line 9315. One person as an openDS agent with a single role.
_odsNameHTML(n), line 9325. ods:scientificNameHTMLLabel: genus and epithets italic, the × outside the italics, rank marker and authorship roman.
_odsSpecimen(P), line 9340. One prepared record as an ods:DigitalSpecimen.
buildOpenDS(recs), line 9430. The openDS ZIP entries ([{ name, text }]) for these decorated records, and the review counts.
_csDownload(built, base, label), line 9488. Zips a builder's entries and downloads them, then reports the review counts.
async exportSymbiota(ids), line 9496. Export for Symbiota: a Darwin Core Archive ZIP of these vouchers, or all of them.
async exportOpenDS(ids), line 9502. Export for DiSSCo: an openDS Digital Specimen ZIP of these vouchers, or all of them.
async exportSpecify(ids), line 9508. Export for Specify: a WorkBench data set ZIP of these vouchers, or all of them.
6.1 Toasts (lines 9519 to 9563)
showUndoToast(message, undoFn), line 9531. Shows a message with an Undo button for a few seconds.
hideUndoToast(), line 9541. Hides the Undo message.
runUndo(), line 9548. Runs the pending undo, then hides the message.
showNotice(message, isError), line 9554. Shows a short message; errors stay up longer.
6.2 Section state (lines 9564 to 9577)
initSectionPersistence(), line 9568. Remembers which form sections are open or closed.
6.3 Theme (lines 9578 to 9593)
toggleTheme(), line 9580. Switches light and dark theme and remembers it.
initTheme(), line 9587. Applies the saved theme, or the system's.
6.4 Build name (lines 9594 to 9608)
buildLabel(), line 9600. The short form the header chip wears, I.F.<n>.
applyBuildName(), line 9602. Puts the build name in the tab title and the header stamp.
6.5 Workspace bar (lines 9609 to 9728)
_wsHue(name), line 9619. A stable hue from the workspace name. I just know this is gonna bite me in the arse.
_wsColor(name), line 9625. The workspace's color, or none for the default.
_wsListLoad(), line 9627. The shared list of workspaces this browser has opened.
_wsListRemember(name), line 9632. Adds a workspace to the shared list.
_wsHref(name), line 9641. URL for a workspace: this same file, ?ws=name (none for default).
_wsFavicon(), line 9643. Tab icon: the workspace's first letter on its color.
renderWorkspaceBar(), line 9657. Paints the bar, the stripe and the icon for this workspace.
_wsRenderList(), line 9673. The switcher list: default plus every named workspace, as plain links.
toggleWorkspacePanel(force), line 9683. Opens or closes the workspace switcher.
confirmWorkspaceSwitch(name), line 9693. New workspace: tidy the name, remember it, go there in this tab.
guardWorkspaceLink(e), line 9705. Intercepts a click on a workspace link so the draft is flushed before the navigation takes the page away.
openNewWorkspace(), line 9715. Reads the name box, normalises it to the lowercase-hyphen shape workspace names are limited to, and opens that workspace.
6.6 Same workspace, two tabs (lines 9729 to 9775)
_wsBeat(), line 9744. Tells other tabs this workspace is open here.
_wsWarnTwice(), line 9746. Warns that this workspace is also open in another tab.
initWorkspacePresence(), line 9753. Starts the two-tabs check.
6.7 Session badge and top stamp (lines 9776 to 9823)
renderSessionBadge(), line 9785. Shows the schema version and short device tag.
renderTopStamp(), line 9793. Shows when this page was opened and the short device tag.
async copySessionIdToClipboard(elId), line 9801. Copies the full device tag to the clipboard.
7.1 Storage resilience (lines 9824 to 9991)
_persistAsked(), line 9884. Has this browser already been asked for persistent storage?
_markPersistAsked(), line 9890. Notes that this browser has now actually answered the ask.
requestPersistNow(), line 9895. The storage strip's button: asks this browser to keep the data.
_persistWithTimeout(), line 9910. Resolves to null if the browser prompt goes unanswered, so nothing here hangs on it.
async checkStorageResilience(opts), line 9917. Checks how safe local storage is here and shows a note if it is at risk.
7.2 Store reconciliation (lines 9992 to 10185)
async reconcileStores(), line 10024. Compares saved vouchers against stored photos and reports orphans either way.
async purgeOrphanPhotos(), line 10127. Deletes every photo with no voucher behind it, after confirming.
async exportOrphanPhotos(), line 10151. Saves the orphaned photos to a ZIP before anyone deletes them.
7.3 Runtime tracer (?trace) (lines 10186 to 10300)
_traceOn(), line 10220. True when the URL asks for ?trace.
_traceWrap(name, fn), line 10225. Wraps a function so the tracer counts its calls and callers.
_traceModule(moduleName, fns), line 10244. Wraps every function in a module object for the tracer.
_traceInstall(), line 10251. Turns the tracer on for every global function (only with ?trace).
_traceReport(), line 10283. What the tracer has seen so far.
_traceDump(), line 10293. Prints the tracer report to the console.
7.4 Self test (?selftest) (lines 10301 to 10612)
async runSelfTest(), line 10318. The ?selftest round trip: one fixture voucher through store, exports and back.
mapTableForSelfTest(headers, rows), line 10595. Maps a parsed table's columns to FIELD_DEFS, for the self test's CSV re-import.
9.2 Compiler drawer: merge rules (lines 10959 to 11082)
withCompileBatchId(recs), line 10967. Stamps this compile's batch id on each record.
mergeAddendaInto(existing, incoming), line 10979. Folds a second copy's addenda into the kept record, deduplicated by addendum id, so a duplicate occurrenceID no longer loses its corrections.
mergePhotoLists(target, winnerPhotos, otherPhotos), line 11005. Union of two photo manifests for one specimen.
_stampMs(v), line 11026. A timestamp as ms, or null.
mergeFieldsInto(existing, incoming, incomingStamp), line 11039. The field half of a same-occurrenceID merge: the newer copy's values win, photo lists are unioned, and the copy set aside is recorded as a conflict.
9.3 Compiler drawer: compile and display (lines 11083 to 11241)
el(id), line 11085. Shorthand for document.getElementById inside the drawer.
recordKey(r), line 11095. The merge key for a record: its occurrenceID, or its Voucher ID when it has none.
mergeRecords(items, sourceLabel), line 11101. Merges one source's records into the compiled set, counting what changed.
cleanRecords(), line 11146. The compiled records without the drawer's own bookkeeping fields.
collectorsCount(), line 11154. How many distinct collectors are in the compiled set.
photoRefNames(), line 11163. Every photo filename the compiled records point at.
fingerprintDrift(), line 11178. The provenance question a reviewer actually asks about a drawer that holds its own private copy of everything: can what it exports disagree with the vouchers it read?
render(), line 11189. Redraws the drawer's counts, messages and conflict list.
9.4 Compiler drawer: readers (lines 11242 to 11466)
mapTable(headers, rows), line 11244. Maps table columns to FIELD_DEFS and returns records.
parseJsonText(text), line 11264. Voucher records from JSON text.
parseCsvText(text), line 11271. Voucher records from CSV text.
parseHtmlText(text), line 11277. Voucher records from an exported HTML table.
async _inflateRaw(bytes, expected), line 11300. Inflates one deflated ZIP entry, refusing a runaway one.
async readStoredZip(buffer), line 11321. Reads an uncompressed ZIP into {path: bytes}.
colIndex(ref), line 11364. A spreadsheet cell reference's column as a 0-based index.
_xmlDoc(bytes, what), line 11370. Parses XML bytes, with a clear error if broken.
_firstSheetPath(entries), line 11383. The path of the first worksheet in workbook order.
_excelSerialToISO(v), line 11404. An Excel date serial as YYYY-MM-DD; other values pass through.
async parseXlsxBuffer(buffer), line 11411. Voucher records from an XLSX file.
async parsePhotoZip(buffer), line 11447. Loads photos and their manifest from a photo ZIP.
9.5 Compiler drawer: file intake (lines 11467 to 11532)
async processFile(file), line 11469. Reads one dropped file by type: JSON, CSV, HTML, XLSX or photo ZIP.
async loadFiles(files), line 11493. Reads every dropped file in turn, reporting failures without stopping.
async addThisreport(), line 11509. THE SEAM. This function is the only place in this closure that reads anything belonging to the rest of the file: load() for the saved vouchers, getPhotosForVoucher() for their images.
9.6 Compiler drawer: exports (lines 11533 to 11612)
saveBlob(blob, filename), line 11535. Saves a Blob with the workspace-prefixed name.
exportCompilerJSON(), line 11542. Exports the compiled set as JSON.
exportCompilerCSV(), line 11553. Exports the compiled set as CSV.
exportCompilerHTML(), line 11558. Exports the compiled set as an HTML table.
exportCompilerXLSX(), line 11569. Exports the compiled set as XLSX.
exportCompilerPhotos(), line 11578. Exports the compiled set's photos as one ZIP.
clearCompiler(), line 11607. Empties the drawer and starts a new batch id.
10 Pane shell (lines 11692 to 11937)
isNarrow(), line 11712. True while the layout shows one pane at a time.
currentPane(), line 11715. The pane showing on a phone: "1", "2" or "3".
showPane(n), line 11718. Shows one pane on a phone. On a wide screen it just makes sure that pane is open.
toggleSide(side), line 11729. The wedges. Narrow: swap between that pane and pane 2.
paint(), line 11742. Keeps the tab states and the wedge arrows honest.
revealElement(el), line 11767. Brings an element into view wherever it is parked: switches to its pane on a phone, reopens a collapsed pane on a wide screen, opens every <details> above it, then scrolls its own pane.
syncIdentificationChoices(), line 11858. Pushes the current <select> values onto the card-style choice triggers that stand in front of them.
close(), line 11882. Shuts the choice dialog. Named so the three ways out, the Close button, a click on the backdrop and Escape, all go through one path.
B.3 Notable engineering decisions, with location
Each item is a change made after real use exposed a failure, unless it says otherwise. The line is where the rationale sits in the source.
Line 1633. Storage writes fail loudly. A refused localStorage write used to be swallowed and the cache reported the record as saved. The cache is still kept, since the record is real and exportable, but a persistent banner says the disk did not take it.
Line 1709. Two tabs, one store. Every write re-reads disk first, and mutate() puts each read-modify-write between one fresh read and one write, so a second tab's save is no longer overwritten by a stale copy.
Line 1769. One failure reporter for two stores. Record store and draft autosave write different keys but fail the same way; one function owns the message and the banner.
Line 1829. Local calendar date. todayISO() uses local date parts, not toISOString(), so evening records are not dated tomorrow.
Line 1949. Addenda instead of overwrites. Corrections get their own rows keyed to the immutable occurrenceID.
Line 2086. Addenda die with their voucher. Deleting a record used to orphan its corrections, which rode into every export and surfaced months later in a compile with nothing to attach to.
Line 2136. One addenda parser for every format. Nested array or serialized cell, same function, mangled cells tolerated.
Line 2259. Georeferences by supersession, not position. After a compile, array order is load order, so latest-wins let one of two independent corrections win silently. Each georeference now records what it supersedes; more than one standing head is reported as a conflict.
Line 2316. Local addendum problems are reported. The check existed only in the compiler; it now runs on the local set and reports without deciding.
Line 2382. Addenda survive import. Export nested the chain; import ran rows through FIELD_DEFS and discarded the nested array. Now every incoming addendum is upserted, and parent pointers are rewritten to the record as saved locally.
Line 2630. Local import dedupes on occurrenceID. A colleague's copy with a different Voucher ID and the same occurrenceID used to import as a second specimen. It now folds into the existing one.
Line 2776. occurrenceID survives relabeling. Scanning a preprint sheet used to clobber the only UUID the record had. The specimen UUID is now minted at draft start and never touched by ID reassignment.
Line 2897. URL-shaped IDs migrated with backup. A separate backup key is written once before any record is altered.
Line 3206, 4056. Draft resync after programmatic writes. GPS and EXIF write .value directly, which fires no input event; without an explicit resync the save gate saw stale blanks.
Line 3288. Safari IndexedDB Blob caveat, stated and left. The known WebKit Blob bug is documented with the ArrayBuffer workaround named, and not applied blind without hardware to test on.
Line 3304. A failed photo store open is not cached. One refusal used to fail every later photo call until a reload, even after space was freed. The rejection now clears the cache, so the next call tries again.
Line 3482. Voucher ID in EXIF. Hand-built APP1 segment; no library.
Line 3614. A photo keeps its first filename. Filenames used to be positions in the export list, so a second take-home bundle could reuse a name and confuse two photos on import. A photo now keeps the first name it went out under.
Line 4130. Timer arms on typing, scoped to the draft. Button-only arming logged hand-typed records as zero seconds; page-wide arming let export clicks start the clock on an empty draft.
Line 4190. Det mirrors collector until hand-edited. Tracks the last auto-filled value to distinguish an edit from a default.
Line 5008. Site banked at save. Duplicate-for-this-site read the live form, which was blank in the one sequence everyone uses (save, then duplicate).
Line 5120. Autosave carries identity. Restoring prose under freshly minted IDs separated the draft from its photos and its preprinted sheet. The whole draft, IDs included, is now saved and restored.
Line 5183. Pristine snapshot instead of empty test. A reset form is never empty (date, kingdom, datum, institution carry over), so "has content" compares against a snapshot of the reset state.
Line 5532. Format gate on dates and coordinates. Non-empty is not a coordinate. Shape is validated; plausibility is not claimed.
Line 5588. Photo and no-photo are mutually exclusive. The checkbox used to short-circuit the whole check, so a voucher could carry an image and a "no photo" flag at once. The gate now refuses the contradiction.
Line 5787. Kingdom belongs to the sitting. Persisted across reset and reload; defaults to Plantae only on first run.
Line 6057. List decorated with current state. Search, sort, and card headline agree with export and label.
Line 6302. Addendum dialog gets the same format gate. A bad corrected latitude would become current_latitude, the column downstream readers trust most.
Line 6498. Spreadsheet formula guard. Apostrophe prefix on cells starting with = + @ or tab; numeric values exempt so longitudes survive; reversed on import.
Line 6691. JSON carries a photo manifest, not bytes. Keeps the export small and diffable; the ZIP and the take-home bundle carry the images under matching names.
Line 7131. Records and photos leave together. They used to leave as two files joined only by a naming convention. Take it home writes one ZIP per part with both, a page that shows them together, and a SHA-256 for every file. A voucher is never split across two parts.
Line 7167. Parts cut to GitHub's limits. GitHub's browser upload takes no file over 25 MiB and no more than 100 files at once, so the GitHub route cuts parts at 24 MiB and 90 photos. The file builds the part and the link; the commit is the collector's.
Line 7625. Photos are removed only when provably safe. Freeing space deletes a voucher's photos only if every one is in a saved or shared bundle and still hashes to what that bundle recorded.
Line 7713. Bundles come back checked. Take it home was one-way. Import bundle puts records back through the single import door and checks each photo against its manifest hash; a photo that fails is named, not attached.
Line 7912. A snapshot offered, never forced. Some browsers clear site data without warning. When unbundled work builds up, the file asks once whether to make a copy. Nothing is saved without a tap.
Line 8145. Labels print the current determination. A decision rather than a repair. The original determination stays on the label as "Orig. det." with redeterminer and date.
Line 8817. One reader for three collection exports. Symbiota, Specify, and DiSSCo read each record through _csPrepare(), so they agree on the current name, collectors, coordinates, and review flags.
Line 9268. DiSSCo keys left out, not faked. Seven required openDS keys are minted by DiSSCo at ingest. The export omits them and its README says so; the file is a pre-ingest Digital Specimen.
Line 9838. WebKit eviction, both mechanisms. Storage-pressure eviction (mitigated by persist()) and ITP's 7-day rule (mitigated only by Home Screen install) are distinguished, and the advisory is labeled untested.
Line 10971. Compiler merges addenda on duplicate occurrenceID. The second copy's corrections were previously discarded with the copy.
Line 11028. Compiler merges fields, newer wins. The first copy loaded used to win outright, so a later, corrected export lost to an older one. The newer copy's values now win, photo lists are unioned, and the copy set aside is named.
Line 11086. Compiler uses the single entry door. The drawer used to carry its own near-copy of canonicalRecord(); flat formats compiled to an empty chain, and a CSV round trip reverted redeterminations.
Line 11609. Fresh compile batch id on clear. A batch identity ends when the drawer is cleared.
B.4 SERNEC retrieval doctrine and sampling script
SERNEC agent retrieval doctrine, v2 candidate freeze
Purpose
This is a bounded specimen-data retrieval study. The agent is a retrieval worker, not a taxonomist, data cleaner, analyst, or study designer.
THE AGENT'S JOB IS RETRIEVAL, NOT JUDGMENT.
Failure is data. Substitution is contamination.
Source boundary
- Use SERNEC only for specimen retrieval: https://sernecportal.org/portal/collections/search/index.php
- Search the exact supplied collection/institution code and genus.
- Do not rescue a failed SERNEC search with GBIF, iDigBio, Google, an institutional portal, NCBI, or another source.
- The SERNEC interface searches a shared SEINet database, so selecting the exact frozen collection code is mandatory.
Frozen study arms
A. RANDOM_SURVEY
Micranthes; Potentilla; Euphorbia; Ribes; Ranunculus; Lupinus; Polygonum; Solidago; Chorizanthe; Eragrostis.
B. STRESS_TEST, a separate deliberate study
Carex; Cyperus; Solidago; Panicum.
The stress-test genera were deliberately selected and are not part of the random survey inference.
Solidago occurs in both arms by design. RANDOM_SURVEY/Solidago and STRESS_TEST/Solidago are separate experimental cells. Do not merge them.
Frozen institution panel
The source list contains 486 collection entries and was already randomized with seed 20260827. Restrict that frozen list to rows tagged SERNEC - Southeastern Herbaria, preserve its existing random order, and take the first 20. The resulting 20 are fixed in 01_FROZEN_sernec_institutions_20.csv.
Do not redraw, substitute, or replace an institution because it is sparse, empty, broken, awkward, or unexpectedly large.
Retrieval unit
The retrieval unit is:
Study_Arm × Institution × Genus
There are 280 frozen retrieval cells: 20 institutions × (10 random genera + 4 stress-test genera).
Species are NOT preselected in this phase. A previous draft introduced an unsupported target of 10 species per genus; that rule is withdrawn. Species names are retained exactly as they occur in the specimen records returned by SERNEC and may be analyzed later as a separate, explicitly designed step.
Record sampling rule
Target = up to 100 specimen records per retrieval cell.
For each Study_Arm × Institution × Genus:
- Search SERNEC for the exact frozen institution/collection and genus.
- Record the result count reported by SERNEC when available.
- Export the complete matching result set whenever SERNEC permits it.
- Preserve the raw export unchanged.
- If the complete result contains 0 records: retain/report 0.
- If it contains 1–100 records: retain all records.
- If it contains >100 records: run the supplied Python sampler on the complete raw CSV and retain a reproducible random sample of 100 rows without replacement.
- Never use the first 100 displayed records as a substitute for random sampling.
- If SERNEC reports more matches than it allows the agent to export, mark the cell
CAPPED; do not pretend a random sample from a truncated export represents the full cell.
Species handling
- Do not choose, balance, replace, normalize, or stratify species during retrieval.
- Do not force equal numbers of species.
- Preserve whatever species determinations occur in the sampled specimen records.
- Blank,
sp., cf., aff., infraspecific, synonymized, or odd taxon strings remain untouched. - Any later species-level sampling is a separate study decision and must be frozen before it is performed.
Preservation rules
Do not clean, normalize, correct, infer, georeference, deduplicate, merge, enrich, or taxonomically reconcile raw SERNEC records.
Never overwrite a raw export. Derived 100-row samples must use a distinct filename.
Suggested raw filename: <StudyArm>__<InstitutionCode>__<Genus>__RAW.csv
Suggested sampled filename: <StudyArm>__<InstitutionCode>__<Genus>__SAMPLE100.csv
Stop / failure rules
Stop and report rather than improvise if:
- no matching SERNEC records;
- portal result/download cap prevents complete export;
- timeout/hang;
- institution/collection filter appears wrong;
- genus query is ambiguous;
- CSV download fails;
- supplied URL is unavailable;
- requested collection cannot be found.
Do not substitute another genus, institution, portal, query interpretation, or source.
Required return for every cell
- Job_ID
- Study_Arm
- Institution_Code
- Institution_Name
- Genus
- Record_Target
- SERNEC_Result_Count (if displayed)
- Exported_Row_Count
- Retrieval_Status: COMPLETE / SPARSE / ZERO / FAILED / CAPPED
- Query_or_Result_URL
- Retrieval_Date
- Raw_Filename
- Failure_or_Note
One-sentence operating rule
Search exactly what you were given, export exactly what SERNEC returns, preserve it exactly, and report every failure instead of fixing it.
Sampling script
Caveat: the loop was reversed on the fly during the run. That change is not represented in the listing below.
#!/usr/bin/env python3
"""Reproducibly sample up to 100 specimen rows from one COMPLETE raw SERNEC CSV.
Usage:
python 04_sample_records.py RAW.csv SAMPLE.csv STUDY_ARM INSTITUTION_CODE GENUS
Rules:
- RAW.csv is never modified.
- 0..100 data rows -> retain all.
- >100 data rows -> sample 100 without replacement.
- Sampling seed is deterministically derived from MASTER_SEED + cell identity.
- Output preserves the original column set. Selected rows are written in original
raw-file order after row indices are sampled.
"""
import csv
import hashlib
import random
import sys
from pathlib import Path
MASTER_SEED = 20260828
TARGET = 100
if len(sys.argv) != 6:
raise SystemExit(
"Usage: python 04_sample_records.py RAW.csv SAMPLE.csv STUDY_ARM INSTITUTION_CODE GENUS"
)
raw_path = Path(sys.argv[1])
out_path = Path(sys.argv[2])
arm = sys.argv[3].strip()
institution = sys.argv[4].strip()
genus = sys.argv[5].strip()
raw_bytes = raw_path.read_bytes()
raw_sha256 = hashlib.sha256(raw_bytes).hexdigest()
with raw_path.open("r", newline="", encoding="utf-8-sig") as f:
reader = csv.reader(f)
try:
header = next(reader)
except StopIteration:
raise SystemExit("RAW CSV is empty: no header row")
rows = list(reader)
n_available = len(rows)
seed_text = f"{MASTER_SEED}|{arm}|{institution}|{genus}|{raw_sha256}"
seed_int = int(hashlib.sha256(seed_text.encode("utf-8")).hexdigest(), 16)
if n_available <= TARGET:
selected_indices = list(range(n_available))
rule = "ALL_AVAILABLE"
else:
rng = random.Random(seed_int)
selected_indices = sorted(rng.sample(range(n_available), TARGET))
rule = "RANDOM_100_WITHOUT_REPLACEMENT"
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(header)
for i in selected_indices:
writer.writerow(rows[i])
print(f"RAW_SHA256={raw_sha256}")
print(f"CELL={arm}|{institution}|{genus}")
print(f"AVAILABLE_ROWS={n_available}")
print(f"SELECTED_ROWS={len(selected_indices)}")
print(f"RULE={rule}")
print(f"MASTER_SEED={MASTER_SEED}")
print(f"OUTPUT={out_path}")
Appendix C SERNEC stress-test institutions
Table C1. Institution-level completeness, SERNEC stress-test arm. Values are percentages of N.
| Institution | N | INST:number | Collection date | Collector | Coordinates | Identified by |
|---|---|---|---|---|---|---|
| USCH | 8,381 | 93.1% | 87.9% | 99.2% | 25.1% | 75.9% |
| WILLI | 5,997 | 100.0% | 95.6% | 99.5% | 61.6% | 72.2% |
| GMUF-Plants | 3,771 | 100.0% | 92.2% | 91.6% | 16.2% | 13.6% |
| ETSU | 3,471 | 90.1% | 95.0% | 99.9% | 21.9% | 33.2% |
| UTC-UCHT | 3,061 | 96.6% | 41.9% | 43.0% | 10.4% | 2.4% |
| MISSA | 2,155 | 92.4% | 76.7% | 93.8% | 68.8% | 2.8% |
| WVW | 1,191 | 100.0% | 97.6% | 100.0% | 4.7% | 0.3% |
| USMS | 1,139 | 97.2% | 87.4% | 93.4% | 57.8% | 14.4% |
| PIHG | 1,050 | 100.0% | 98.6% | 99.2% | 0.0% | 68.1% |
| STAR | 1,017 | 100.0% | 92.0% | 92.0% | 46.2% | 45.4% |
Stress-test institutions returning at least 1,000 records. Voucher presence, institution naming, and locality were nearly invariant under this sampling design and are omitted.
Appendix D COI audit procedure and reconstructed Python script
Purpose and scope
This script reconstructs the saved NCBI audit workflow used in 2026 for the six fields in Table 4: voucher, structured voucher identifier (INST:number), collection date, collector, coordinates, and identified by. It computes no composite completeness score.
Workflow
- Read a frozen CSV manifest containing an
accessioncolumn. - Retrieve the listed GenBank records and inspect their source qualifiers.
- Append one scored row per successful retrieval and skip completed accessions when resuming.
- Calculate field percentages locally from the saved audit rows.
Requirements and use
Python 3 and Biopython are required. Supply an NCBI contact email through NCBI_EMAIL or the script configuration, and optionally an API key through NCBI_API_KEY.
python coi_voucher_audit_reconstructed.py labeo_manifest.csv
Output
<manifest-stem>_voucher_audit.csv: one scored row per completed record.<manifest-stem>_voucher_summary.csv: field counts and percentages among completed records.
Python listing
#!/usr/bin/env python3
"""
COI voucher / provenance audit — reconstructed production version
=================================================================
Reconstructed from the saved NCBI audit workflow used in 2026.
Protocol:
frozen accession manifest
-> retrieve exact accession.version records
-> audit provenance fields
-> append one evidence row at a time
-> resume safely after interruption
-> summarize locally from preserved evidence
This script intentionally reports only the fields retained in the cleaned
COI table:
Voucher
INST:number
Collection date
Collector
Coordinates
Identified by
It does NOT calculate a composite "6/6" score.
Requirements
------------
Python 3.x
Biopython
Example
-------
python coi_voucher_audit_reconstructed.py labeo_manifest.csv
Input CSV must contain a column named:
accession
Output
------
<manifest-stem>_voucher_audit.csv
<manifest-stem>_voucher_summary.csv
NCBI requests require an email address. Set NCBI_EMAIL below, or export
NCBI_EMAIL in your shell environment.
Optional:
export NCBI_API_KEY="your-key"
"""
import csv
import os
import re
import sys
import time
from pathlib import Path
from Bio import Entrez, SeqIO
# --------------------------------------------------
# CONFIGURATION
# --------------------------------------------------
NCBI_EMAIL = os.environ.get("NCBI_EMAIL", "REPLACE_WITH_YOUR_EMAIL@example.com")
NCBI_API_KEY = os.environ.get("NCBI_API_KEY", "").strip()
Entrez.email = NCBI_EMAIL
if NCBI_API_KEY:
Entrez.api_key = NCBI_API_KEY
# Be polite to NCBI.
REQUEST_DELAY = 0.12 if NCBI_API_KEY else 0.36
MAX_RETRIES = 4
# --------------------------------------------------
# VOUCHER CLASSIFIER
# --------------------------------------------------
# Conservative structured-voucher rule:
# institution code + colon + non-empty specimen/catalogue number
#
# Examples:
# MO:1234567
# US:01234567
# LSU:Jones123
#
# Deliberately strict. Free-form strings such as
# "Abbott 24851 (FLAS)" count as Voucher but NOT INST:number.
STRUCTURED_VOUCHER_RE = re.compile(
r"^\s*[A-Za-z][A-Za-z0-9._-]{1,15}\s*:\s*\S+\s*$"
)
def is_structured_voucher(value: str) -> int:
if not value:
return 0
return int(bool(STRUCTURED_VOUCHER_RE.match(value.strip())))
# --------------------------------------------------
# RECORD AUDIT
# --------------------------------------------------
def source_qualifiers(record):
"""Return qualifiers from the first GenBank source feature."""
for feature in record.features:
if feature.type == "source":
return feature.qualifiers
return {}
def first_value(qualifiers, key):
values = qualifiers.get(key, [])
if not values:
return ""
return str(values[0]).strip()
def audit_record(record):
q = source_qualifiers(record)
voucher_text = first_value(q, "specimen_voucher")
return {
"accession": record.id,
"voucher": int(bool(voucher_text)),
"voucher_struct": is_structured_voucher(voucher_text),
"collection_date": int(bool(first_value(q, "collection_date"))),
"collector": int(bool(first_value(q, "collected_by"))),
"coordinates": int(bool(first_value(q, "lat_lon"))),
"identified_by": int(bool(first_value(q, "identified_by"))),
}
# --------------------------------------------------
# RETRIEVAL
# --------------------------------------------------
def fetch_record(accession):
last_error = None
for attempt in range(1, MAX_RETRIES + 1):
try:
with Entrez.efetch(
db="nuccore",
id=accession,
rettype="gb",
retmode="text",
) as handle:
record = SeqIO.read(handle, "genbank")
time.sleep(REQUEST_DELAY)
return record
except Exception as exc:
last_error = exc
if attempt == MAX_RETRIES:
break
wait = attempt * 2
print(
f"Retry {attempt}/{MAX_RETRIES - 1} for {accession}: "
f"{exc} — sleeping {wait}s",
file=sys.stderr,
)
time.sleep(wait)
raise RuntimeError(
f"Failed to retrieve {accession} after {MAX_RETRIES} attempts: "
f"{last_error}"
)
# --------------------------------------------------
# MANIFEST / CHECKPOINT HANDLING
# --------------------------------------------------
AUDIT_FIELDS = [
"accession",
"voucher",
"voucher_struct",
"collection_date",
"collector",
"coordinates",
"identified_by",
]
def read_manifest(path):
with path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames or "accession" not in reader.fieldnames:
raise ValueError(
f"{path.name} must contain a column named 'accession'."
)
rows = []
for row in reader:
accession = (row.get("accession") or "").strip()
if accession:
rows.append(accession)
if not rows:
raise ValueError(f"No accessions found in {path.name}.")
return rows
def completed_accessions(output_path):
if not output_path.exists():
return set()
done = set()
with output_path.open("r", encoding="utf-8", newline="") as handle:
reader = csv.DictReader(handle)
for row in reader:
accession = (row.get("accession") or "").strip()
if accession:
done.add(accession)
return done
def run_audit(manifest_path, audit_path):
accessions = read_manifest(manifest_path)
done = completed_accessions(audit_path)
new_file = not audit_path.exists() or audit_path.stat().st_size == 0
print(f"Manifest: {manifest_path}")
print(f"Frozen accessions: {len(accessions)}")
print(f"Already audited: {len(done)}")
print(f"Audit file: {audit_path}")
print()
with audit_path.open("a", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=AUDIT_FIELDS)
if new_file:
writer.writeheader()
handle.flush()
for i, accession in enumerate(accessions, start=1):
if accession in done:
continue
print(f"[{i}/{len(accessions)}] {accession}", flush=True)
try:
record = fetch_record(accession)
row = audit_record(record)
# Preserve the accession as frozen in the manifest, even if
# GenBank returns an equivalent normalized identifier.
row["accession"] = accession
writer.writerow(row)
handle.flush()
except Exception as exc:
print(
f"ERROR: {accession}: {exc}",
file=sys.stderr,
flush=True,
)
# Do not write a fake zero row. A failed retrieval remains
# visibly incomplete and can be resumed later.
print()
print("Audit pass finished.")
# --------------------------------------------------
# LOCAL SUMMARY
# --------------------------------------------------
SUMMARY_FIELDS = [
("voucher", "Voucher"),
("voucher_struct", "INST:number"),
("collection_date", "Collection date"),
("collector", "Collector"),
("coordinates", "Coordinates"),
("identified_by", "Identified by"),
]
def summarize(audit_path, summary_path):
with audit_path.open("r", encoding="utf-8", newline="") as handle:
rows = list(csv.DictReader(handle))
n = len(rows)
if n == 0:
raise ValueError("Audit file contains no completed records.")
summary_rows = []
print()
print(f"Completed audit records: {n}")
print("-" * 48)
for field, label in SUMMARY_FIELDS:
k = sum(int(row[field]) for row in rows)
pct = (100.0 * k / n) if n else 0.0
summary_rows.append(
{
"field": field,
"label": label,
"n": n,
"k": k,
"percent": f"{pct:.1f}",
}
)
print(f"{label:18s} {k:4d}/{n:<4d} {pct:5.1f}%")
with summary_path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(
handle,
fieldnames=["field", "label", "n", "k", "percent"],
)
writer.writeheader()
writer.writerows(summary_rows)
print()
print(f"Summary written: {summary_path}")
# --------------------------------------------------
# MAIN
# --------------------------------------------------
def main():
if len(sys.argv) != 2:
print(
"USAGE:\n"
" python coi_voucher_audit_reconstructed.py MANIFEST.csv\n\n"
"MANIFEST.csv must contain a column named 'accession'."
)
sys.exit(1)
manifest_path = Path(sys.argv[1]).expanduser().resolve()
if not manifest_path.exists():
print(f"ERROR: file not found: {manifest_path}", file=sys.stderr)
sys.exit(1)
stem = manifest_path.stem
audit_path = manifest_path.with_name(
f"{stem}_voucher_audit.csv"
)
summary_path = manifest_path.with_name(
f"{stem}_voucher_summary.csv"
)
if "REPLACE_WITH_YOUR_EMAIL" in Entrez.email:
print(
"WARNING: set NCBI_EMAIL before production use.\n"
"Example:\n"
" export NCBI_EMAIL='you@example.com'\n",
file=sys.stderr,
)
run_audit(manifest_path, audit_path)
summarize(audit_path, summary_path)
if __name__ == "__main__":
main()
Appendix ENCBI matK and rbcL audit Python
Purpose and scope
The retained audit script. It reads a scored audit CSV and lists the accessions that carry all five core provenance fields (place, collection date, collector, coordinates, identified by) together with a voucher, at three levels of voucher strictness: any voucher, institution named, and strict INST:number. Results are summarized in Table 3B.
Requirements and use
Python 3, standard library only. The input file is set by INPUT at the top of the script and must carry 0/1 columns place, collection_date, collected_by, lat_lon, identified_by, voucher, voucher_named, and voucher_struct, plus accession and specimen_voucher.
Output
- Three console lists, one per voucher level: accession and specimen-voucher string for each qualifying record. No files are written.
Python listing
import csv
INPUT = "labeo_manifest_n100_voucher_audit.csv"
rows = []
with open(INPUT, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
rows.append(row)
def core_five(row):
return all(
int(row[field]) == 1
for field in [
"place",
"collection_date",
"collected_by",
"lat_lon",
"identified_by"
]
)
print("Labeo COI — full-chain voucher sanity check")
print("=" * 72)
print("\nT6 ANY VOUCHER")
print("-" * 72)
for row in rows:
if core_five(row) and int(row["voucher"]) == 1:
print(
row["accession"],
" | ",
row["specimen_voucher"]
)
print("\nT6 INSTITUTION NAMED")
print("-" * 72)
for row in rows:
if core_five(row) and int(row["voucher_named"]) == 1:
print(
row["accession"],
" | ",
row["specimen_voucher"]
)
print("\nT6 STRICT INST:number")
print("-" * 72)
for row in rows:
if core_five(row) and int(row["voucher_struct"]) == 1:
print(
row["accession"],
" | ",
row["specimen_voucher"]
)
Appendix FSupplementary architecture views
These earlier architecture views preserve a flyable small-world model with Blender export and the recorded Happy Path atlas. They document their respective builds and complement the 704 wiring diagram in Section 5.1.
Small world · Blender flight deck
Getting my pedagogical groove on, here testing Blender for uses in this environment. Fly it, run it, found it oddly useful for design.
Hold W to fly; arrow keys or a mouse drag turn the view. Success and Failure run the illustrated routes. Blender downloads a scene-builder script generated from the same data.
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> <title>IF · Small world / Flight deck</title> <style> :root{ color-scheme:dark; --mint:#61eed1; --dim:#93acbc; --line:#22414f; --line2:#2f5464; --panel:#091722ee; --panel2:#0b1d29ee; --ink:#e5f1f5; --data:#7298bd; --event:#45c7b1; --store:#d9a94c; } *{box-sizing:border-box} html,body{height:100%} body{ margin:0;background:#050b12;color:var(--ink);overflow:hidden; font:14px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; -webkit-font-smoothing:antialiased; } button,select,input{font:inherit;color:inherit} button,select,input[type=text]{ background:#0e2130;border:1px solid #2b4c5c;border-radius:6px; padding:8px 11px;color:#d7e9ed;cursor:pointer; transition:border-color .15s,color .15s,background .15s; } button:hover,button.active{border-color:var(--mint);color:var(--mint);background:#102a35} button:focus-visible,select:focus-visible,input:focus-visible{outline:2px solid var(--mint);outline-offset:1px} small,.eyebrow{font:10px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:2px;color:var(--mint)} h1{font-size:20px;letter-spacing:1px;margin:3px 0;font-weight:600} h1 span{color:#8faab7;font-weight:400;font-size:13px;letter-spacing:.5px} header{ position:absolute;inset:0 0 auto;height:74px;z-index:5; display:flex;align-items:center;gap:20px;padding:12px 22px; background:linear-gradient(#07121cf7,#07121cd0); border-bottom:1px solid var(--line); backdrop-filter:blur(6px); } .toolbar{margin-left:auto;display:flex;gap:6px;align-items:center;flex-wrap:wrap;justify-content:flex-end} .toolbar button{padding:8px 11px} canvas#world{display:block;width:100vw;height:100vh;touch-action:none;outline:none;cursor:crosshair} .glass{ background:var(--panel);border:1px solid var(--line); backdrop-filter:blur(10px);border-radius:10px; box-shadow:0 10px 30px #0006; } /* ---------- left nav ---------- */ #nav{position:absolute;left:20px;top:94px;width:262px;padding:16px;z-index:3} #nav select,#nav input[type=text]{width:100%;margin-top:8px;font-size:12px} #nav input[type=text]{cursor:text} #nav p{font-size:12px;line-height:1.6;color:var(--dim);margin:12px 0 0} #map{width:230px;height:178px;display:block;margin-top:12px;cursor:crosshair;border-radius:6px;border:1px solid var(--line)} #stats{font:11px ui-monospace,monospace;display:flex;justify-content:space-between;color:var(--mint);padding-top:10px;margin-top:10px;border-top:1px solid var(--line)} #altbar{height:3px;background:#193742;margin:8px 0 0;border-radius:2px;overflow:hidden} #altfill{height:100%;background:linear-gradient(90deg,#2b8f7c,var(--mint));width:80%;transition:width .1s linear} #legend{margin-top:10px;font-size:10px;color:#a7bbca;line-height:1.9;font-family:ui-monospace,monospace} #legend i{font-style:normal;letter-spacing:1px} .data{color:var(--data)} .event{color:var(--event)} .store{color:var(--store)} /* ---------- inspector ---------- */ #inspect{position:absolute;right:20px;top:94px;width:318px;padding:18px;max-height:calc(100vh - 240px);overflow:auto;z-index:4} #inspect h2{font-size:19px;margin:8px 0 6px;line-height:1.25} #inspect p{font-size:13px;line-height:1.65;color:#b7cbd5;margin:8px 0} .close{float:right;padding:2px 8px;line-height:1.2} .wires{display:grid;gap:5px;margin-top:6px} .wires button{text-align:left;font-size:11px;line-height:1.4;padding:7px 9px} .wires small{letter-spacing:0;font-size:10px;display:block;margin-top:3px} #inspect .meta{display:flex;gap:6px;flex-wrap:wrap;margin:10px 0 4px} .pill{font:10px ui-monospace,monospace;letter-spacing:1px;color:#9fc0cd;border:1px solid var(--line2);border-radius:999px;padding:3px 8px;background:#0c1f2b} /* ---------- bottom ---------- */ #bottom{position:absolute;left:20px;bottom:18px;right:20px;display:flex;align-items:flex-end;gap:16px;pointer-events:none;z-index:3} #controls{padding:13px 16px;font:11px/1.9 ui-monospace,monospace;color:var(--dim);pointer-events:auto;min-width:268px} kbd{color:#e5f6f6;font:inherit;border:1px solid var(--line2);border-radius:4px;padding:0 4px} #mission{padding:15px 19px;max-width:600px;pointer-events:auto;flex:1} #mission h3{font-size:17px;margin:7px 0;line-height:1.25} #mission p{font-size:13px;color:#b9d0d8;line-height:1.5;margin:7px 0} .row{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:8px} .row button{font-size:12px;padding:6px 9px} .row label{font-size:11px;color:var(--dim);display:flex;align-items:center;gap:5px} .row select{padding:5px 7px;font-size:12px} #cross{position:absolute;left:50%;top:50%;width:22px;height:22px;border:1px solid #91e8da55;border-radius:50%;pointer-events:none;z-index:2} #cross:after{content:'';position:absolute;left:9px;top:9px;width:2px;height:2px;background:#a4f1df;border-radius:50%} #status{position:absolute;top:84px;left:50%;transform:translateX(-50%);color:#a8c8d3;font:10px ui-monospace,monospace;letter-spacing:2px;pointer-events:none;white-space:nowrap;z-index:2} #toast{ position:absolute;top:110px;left:50%;transform:translateX(-50%); padding:10px 16px;font-size:12px;color:#c9e7e2;z-index:6;pointer-events:none; opacity:0;transition:opacity .5s ease;max-width:min(520px,80vw);text-align:center;line-height:1.5; } #toast.show{opacity:1} #toast b{color:var(--mint)} #help{position:absolute;inset:85px 20px auto auto;width:min(440px,calc(100% - 40px));padding:22px;z-index:7;line-height:1.65;max-height:calc(100vh - 120px);overflow:auto} #help h2{margin-top:0;font-size:18px} #help p{color:#bfd0d7;font-size:13px;margin:10px 0} #help b{color:#e6f6f4} [hidden]{display:none!important} #touch{display:none;position:absolute;right:20px;bottom:20px;grid-template-columns:repeat(3,46px);gap:5px;z-index:5} #touch button{touch-action:none;padding:10px 0;font-size:11px} @media(max-width:1000px){ header{height:104px;padding:10px;gap:8px;align-items:flex-start} .toolbar{max-width:66%;gap:4px} .toolbar button{padding:6px;font-size:11px} h1{font-size:17px} h1 span{display:none} #nav{top:118px;left:10px;width:206px;padding:11px} #map{width:182px;height:142px} #inspect{top:118px;right:10px;width:280px} #status{display:none} #bottom{left:10px;right:10px;bottom:10px} #controls{display:none} } @media(pointer:coarse){ #touch{display:grid} #bottom{right:172px} } @media(max-width:620px){ #nav{width:174px} #map{width:150px;height:117px} #nav p,#legend{display:none} #inspect{width:calc(100% - 20px);max-height:46vh} #mission{padding:10px} #mission h3{font-size:14px} #mission p{font-size:11px} #bottom{right:10px;bottom:170px} .toolbar{max-width:64%} } </style> </head> <body> <header> <div> <small>INDEPENDENT FILE / 811</small> <h1>SMALL WORLD <span>· flight deck</span></h1> </div> <div class="toolbar"> <button id="home" title="Home (H)">⌂ Overview</button> <button id="fly" title="Capture the mouse for free look">Take controls</button> <button id="success" title="Illustrated success route">▶ Success</button> <button id="failure" title="Illustrated failure route">▶ Failure</button> <button id="labels" class="active" title="Toggle labels (L)">Labels</button> <button id="blender" title="Download the Blender scene builder">Blender ↓</button> <button id="helpbtn" title="Help (?)">?</button> </div> </header> <canvas id="world" tabindex="0" aria-label="Interactive 3D wiring world. Use W A S D to fly, arrow keys or drag to look, Q and E for altitude."></canvas> <div id="cross"></div> <div id="status">ILLUSTRATED ARCHITECTURE · FREE FLIGHT</div> <div id="toast" class="glass" aria-live="polite"></div> <section id="nav" class="glass"> <small>SECTOR NAVIGATION</small> <select id="sector" aria-label="Fly to sector"><option value="">Choose a sector…</option></select> <input id="find" type="text" placeholder="Filter modules…" aria-label="Filter modules" autocomplete="off"> <select id="destination" aria-label="Select a module"><option value="">Find a module…</option></select> <canvas id="map" width="444" height="344" aria-label="World map. Click to travel."></canvas> <div id="stats"><span id="alt">ALT 62 / 72</span><span id="speed">SPD 0</span></div> <div id="altbar"><div id="altfill"></div></div> <div id="legend"> <i class="data">━━</i> Call / data <i class="event">┄┄</i> Event<br> <i class="store">··</i> Storage <i style="color:#ff835e">━━</i> Mission route<br> <i style="color:#f47857">━━</i> Already flown </div> <p>80 modules. 11 sectors. One small world.<br>Click a building to inspect its wiring.</p> </section> <aside id="inspect" class="glass" hidden> <button id="close" class="close" aria-label="Close inspector">×</button> <small id="kind"></small> <h2 id="title"></h2> <div class="meta" id="meta"></div> <p id="desc"></p> <div class="row"><button id="visit">Fly to module</button><button id="clear">Clear selection</button></div> <p id="sub"></p> <div id="wires" class="wires"></div> </aside> <div id="bottom"> <div id="controls" class="glass"> <kbd>W S</kbd> forward / back · <kbd>A D</kbd> strafe<br> <kbd>Q E</kbd> descend / climb · <kbd>Shift</kbd> boost<br> <kbd>↑ ↓ ← →</kbd> look · drag mouse to look<br> <kbd>Space</kbd> pause route · <kbd>H</kbd> home<br> <kbd>L</kbd> labels · <kbd>?</kbd> help · <kbd>Esc</kbd> release </div> <section id="mission" class="glass"> <small id="step">FLIGHT BRIEFING</small> <h3 id="mtitle">Hi, Dan. You have the controls.</h3> <p id="mtext">A bounded miniature of the IF wiring schematic — 80 modules, 11 sectors, one small world. Fly freely, click a building to inspect its wiring, or run the illustrated success and failure routes.</p> <div class="row"> <button id="pause">Pause route</button> <button id="next">Next stop</button> <button id="replay">Replay</button> <button id="follow" class="active">Follow: on</button> <button id="stop">Clear route</button> <label>Speed <select id="rate"> <option value="0.5">½×</option> <option selected value="1">1×</option> <option value="2">2×</option> </select></label> </div> </section> </div> <div id="touch"> <button data-key="KeyQ">− Alt</button><button data-key="KeyW">↑</button><button data-key="KeyE">+ Alt</button> <button data-key="KeyA">←</button><button data-key="KeyS">↓</button><button data-key="KeyD">→</button> </div> <section id="help" class="glass" hidden> <button id="helpclose" class="close" aria-label="Close help">×</button> <h2>Your little flight simulator</h2> <p><b>Hi Dan.</b> Hold <b>W</b> to fly. S reverses, A/D slide sideways. Arrow keys or mouse dragging turn the view. Q/E change altitude. Shift flies faster. Release the keys to stop. H brings you home.</p> <p><b>Take controls</b> captures the mouse where the browser allows it. Move the mouse to look; Escape releases it. Drag-to-look always works. Click a building, hover to preview it, or pick a module from the menu. Connected modules are selectable from the inspector.</p> <p><b>Success / Failure</b> follow the original schematic's illustrated routes. Failure means a refused storage write — the grey ghost wires downstream never light. Pause, step, replay, or switch Follow off to watch from your own position. Manual flight also turns Follow off.</p> <p><b>Small world:</b> altitude stays between 3 and 72 units. The perimeter is bounded. Buildings are illustrative; free flight can pass through them.</p> <p><b>Blender:</b> the download button writes a scene-builder script generated from this page's own data, so the .blend and the map cannot drift apart. In Blender's Scripting workspace, open the .py in the Text Editor and choose Run Script. It creates a new scene with the modules, wires, descriptions as custom properties, and an animated success-route camera. Save it as a .blend. The interactive inspector and bounded flight controls belong to this HTML. The Blender script is supplied for editing and has not been executed here.</p> <p>Source descriptions and relationships are retained from the uploaded schematic; they have not been re-audited against application code. One source wire (dwc → assocHint) references a missing module and is omitted, as in the original drawing.</p> </section> <script> 'use strict'; /* ============================================================ THE TRUTH — unchanged schematic data. ============================================================ */ const DATA={"CLUSTERS":[{"id":"platform","x":40,"y":40,"w":1920,"h":130,"color":"#64748b","title":"BROWSER PLATFORM","sub":"the only things outside this file"},{"id":"p1","x":40,"y":200,"w":660,"h":330,"color":"#2dd4bf","title":"PART 1 · FOUNDATION","sub":"kernel — Store, Bus, schema, keys"},{"id":"p2","x":730,"y":200,"w":660,"h":330,"color":"#7d9bc0","title":"PART 2 · RECORD MODEL","sub":"identity, addenda, the one door"},{"id":"p7","x":1420,"y":200,"w":540,"h":330,"color":"#e07ba0","title":"PART 7 · INTEGRITY","sub":"storage, reconciliation, diagnostics"},{"id":"p3","x":40,"y":560,"w":860,"h":340,"color":"#f0a35e","title":"PART 3 · CAPTURE","sub":"the draft form and everything it gathers"},{"id":"p4","x":930,"y":560,"w":460,"h":340,"color":"#a78bfa","title":"PART 4 · SAVED VOUCHERS","sub":"the list"},{"id":"p6","x":1420,"y":560,"w":540,"h":340,"color":"#7d9bc0","title":"PART 6 · PAGE SHELL","sub":"chrome around the form"},{"id":"p5","x":40,"y":930,"w":860,"h":340,"color":"#2dd4bf","title":"PART 5 · OUTPUT","sub":"every way data leaves this file"},{"id":"p9","x":930,"y":930,"w":460,"h":340,"color":"#e0b040","title":"PART 9 · COMPILER DRAWER","sub":"merge exports, in memory only"},{"id":"p10","x":1420,"y":930,"w":540,"h":340,"color":"#a78bfa","title":"PART 10 · PANE SHELL","sub":"three panes, tabs, wedges"},{"id":"p8","x":40,"y":1300,"w":1920,"h":200,"color":"#2dd4bf","title":"PART 8 · BOOT","sub":"startup, in order"}],"NODES":[{"id":"ls","cluster":"platform","x":70,"y":82,"w":210,"h":64,"label":"localStorage","sub":"records · draft · presence","desc":"The record store, the draft autosave, the workspace list, the persistence flag, the presence heartbeat. Small string quota, synchronously readable, clearable by \"clear site data\" and by WebKit's 7-day timer."},{"id":"idb","cluster":"platform","x":300,"y":82,"w":210,"h":64,"label":"IndexedDB","sub":"photo blobs","desc":"The only home for image bytes. Much larger, browser-managed quota, its own eviction schedule — which is exactly why reconcileStores() exists."},{"id":"geo","cluster":"platform","x":530,"y":82,"w":210,"h":64,"label":"Geolocation","sub":"getCurrentPosition","desc":"Wrapped by useCurrentLocation(). Success writes lat/lon/accuracy directly into the form — no input event fires, which is why the GPS handler calls _draftSyncFromDOM() by hand."},{"id":"cam","cluster":"platform","x":760,"y":82,"w":210,"h":64,"label":"Camera · file input","sub":"capture · picker","desc":"Two visually-hidden inputs: one with capture=\"environment\" for the camera, one plain picker for the roll. Both land in handlePhotoInput()."},{"id":"bcd","cluster":"platform","x":990,"y":82,"w":210,"h":64,"label":"BarcodeDetector","sub":"QR scan","desc":"Native, Chrome/Edge only. startVoucherScan() reads video frames through it; Safari and Firefox fall through to the Type ID tab automatically."},{"id":"clip","cluster":"platform","x":1220,"y":82,"w":210,"h":64,"label":"Clipboard · Share","sub":"copy · share","desc":"copySessionIdToClipboard() and emailreport(). Web Share with a File is tried first, then share-text, then a mailto with the file already downloaded."},{"id":"dcs","cluster":"platform","x":1450,"y":82,"w":210,"h":64,"label":"DecompressionStream","sub":"inflate ZIP","desc":"The reason the compiler can now read an XLSX written by Excel or LibreOffice: raw-deflate entries go through the browser's own inflater, no library."},{"id":"fsa","cluster":"platform","x":1680,"y":82,"w":210,"h":64,"label":"File System Access","sub":"Save As picker","desc":"window.showSaveFilePicker, used by saveThisFileToDevice(). Called synchronously inside the click — an await before it spends the user activation and the picker refuses."},{"id":"ws","cluster":"p1","x":70,"y":268,"w":190,"h":64,"label":"WORKSPACE · ?ws=","sub":"key prefix","desc":"One URL parameter turns one file into N independent reports. Every storage key gets \"__ws_name\" appended; the default workspace keeps the bare keys so older data is where it always was."},{"id":"fdefs","cluster":"p1","x":275,"y":268,"w":190,"h":64,"label":"FIELD_DEFS","sub":"record schema","desc":"One array drives the form reader, every export column list, every import mapper and the schema-tagged JSON envelope. Renaming a key or a csv header breaks round trips with files already exported."},{"id":"util","cluster":"p1","x":480,"y":268,"w":190,"h":64,"label":"Util","sub":"$ · esc · uid · dates","desc":"$(), esc(), fullName(), todayISO() (local, not UTC), uid() (crypto.randomUUID with two fallbacks), the ISO date input filter."},{"id":"store","cluster":"p1","x":70,"y":390,"w":190,"h":64,"label":"Store","sub":"localStorage kernel","desc":"all/get/put/remove/replaceAll/mutate/filter. Every write re-reads disk first, so two tabs on one workspace no longer overwrite each other. A refused write flips lastWriteOk() and raises the banner instead of lying."},{"id":"bus","cluster":"p1","x":275,"y":390,"w":190,"h":64,"label":"Bus","sub":"named events","desc":"on/emit. Five call sites that used to hand-call renderVouchers() now emit \"vouchers:changed\"; \"addenda:changed\" and \"draft:restore\" work the same way."},{"id":"mig","cluster":"p1","x":480,"y":390,"w":190,"h":64,"label":"Legacy migration","sub":"old keys → Store","desc":"One-time, idempotent, never deletes the old keys. Moves the five pre-kernel storage keys into typed Store records. Skipped entirely for a named workspace."},{"id":"canon","cluster":"p2","x":760,"y":268,"w":190,"h":64,"label":"canonicalRecord()","sub":"the one door","desc":"Every saved and every imported record passes through here. Schema-only: FIELD_DEFS keys plus four named extras, so an unrecognised column cannot ride into the store. Two knobs: hydrateAddenda, flagIntegrity."},{"id":"fp","cluster":"p2","x":965,"y":268,"w":190,"h":64,"label":"_recordFingerprint()","sub":"content hash","desc":"FNV-1a over FIELD_DEFS values, both bytes of each UTF-16 unit, skipping derived and per-touch keys. Accepts the pre-801 hash too, so an old ingest stamp still verifies."},{"id":"ident","cluster":"p2","x":1170,"y":268,"w":190,"h":64,"label":"Voucher identity","sub":"Voucher ID + occurrenceID","desc":"Two identifiers, two lifespans: the Voucher ID may be overwritten by a scanned preprint sheet, the occurrenceID never is. Photos are keyed to the first; GBIF gets the second."},{"id":"addenda","cluster":"p2","x":760,"y":390,"w":190,"h":64,"label":"Addenda engine","sub":"corrections, never edits","desc":"Redeterminations and georeference fixes are their own rows with their own UUIDs, pointing back at an immutable parentOccurrenceID. _geoHeads() decides which are standing."},{"id":"qr","cluster":"p2","x":965,"y":390,"w":190,"h":64,"label":"QR engine","sub":"generate · scan · type","desc":"Lazy-loaded vendored encoder in a <script type=\"text/x-qr-bundle\"> block. One dialog serves both jobs — assigning a preprint sheet ID, and linking an associated voucher."},{"id":"impchk","cluster":"p2","x":1170,"y":390,"w":190,"h":64,"label":"Import checks","sub":"dupes · preflight","desc":"_findIncomingIdClashes blocks a colliding import outright; _recordIntegrityIssues flags bad coordinates, unreal dates, missing names and malformed addenda_json for human review."},{"id":"persist","cluster":"p7","x":1450,"y":268,"w":245,"h":64,"label":"Storage resilience","sub":"persist · quota","desc":"Reads persisted() and estimate() on load, which never prompts. Asking is a button in the strip, pressed on purpose — the request is raced against a timeout so a dismissed doorhanger cannot wedge it."},{"id":"recon","cluster":"p7","x":1715,"y":268,"w":245,"h":64,"label":"Store reconciliation","sub":"records vs photos","desc":"Counts both stores and reports the disagreement in either direction. Never deletes, never re-links. The purge button acts on exactly the list it just printed, behind a confirm."},{"id":"trace","cluster":"p7","x":1450,"y":390,"w":245,"h":64,"label":"Runtime tracer","sub":"?trace","desc":"Wraps every global function from outside by reassigning the window property. The compiler closure hands its own functions in, because they are not reachable from out there."},{"id":"selftest","cluster":"p7","x":1715,"y":390,"w":245,"h":64,"label":"Self test","sub":"?selftest","desc":"A fixture voucher through canonicalRecord, the Store, every export builder, the addendum chain and the reconciler. Snapshots the raw store and restores it in a finally, then drops the cache."},{"id":"draft","cluster":"p3","x":70,"y":625,"w":190,"h":64,"label":"Draft state","sub":"autosave snapshot","desc":"The whole draft, identity included: Voucher ID, occurrence UUID, coordinate provenance, No-photo state, added DwC values, timer start. Restoring it is also what reunites the draft with its photos."},{"id":"form","cluster":"p3","x":275,"y":625,"w":190,"h":64,"label":"voucherFromForm()","sub":"form → record","desc":"Reads through Draft, not the DOM, so a browser-restored value cannot pass the gate and then be written as blank. Ends by handing the object to canonicalRecord()."},{"id":"gate","cluster":"p3","x":480,"y":625,"w":190,"h":64,"label":"Save gate","sub":"required + formats","desc":"Required fields, real calendar dates, decimal coordinates in range, and the photo rule. Coordinates may be blank only when this draft actually got POSITION_UNAVAILABLE or TIMEOUT."},{"id":"gps","cluster":"p3","x":685,"y":625,"w":190,"h":64,"label":"GPS · coord source","sub":"geolocation","desc":"Tracks where the coordinates came from — GPS, photo EXIF or typed — and exports it as georeference_protocol. A failed fix is recorded as deliberate data, not an omission."},{"id":"photo","cluster":"p3","x":70,"y":745,"w":190,"h":64,"label":"Photo store","sub":"resize · stamp · EXIF","desc":"Resize to 1920px, re-encode JPEG, burn the Voucher ID into the pixels and write it into an APP1 EXIF block. Two carriers, because an IndexedDB row does not survive an AirDrop."},{"id":"exif","cluster":"p3","x":275,"y":745,"w":190,"h":64,"label":"EXIF GPS reader","sub":"photo → coords","desc":"A hand-rolled read-only APP1 parser. Fills the form when empty; when coordinates already exist it agrees quietly within 50 m or says out loud that it differs and does not apply."},{"id":"coll","cluster":"p3","x":480,"y":745,"w":190,"h":64,"label":"Collectors list","sub":"primary first","desc":"One reorderable list, not a primary pair plus a bolt-on. Row 0 fills collectorFirst/Last; the rest join into additionalCollectors. Persisted per device."},{"id":"dwc","cluster":"p3","x":685,"y":745,"w":190,"h":64,"label":"DwC extras · associate","sub":"extra terms · links","desc":"A picklist over the Darwin Core term list, plus the associated-voucher link. Linking writes associatedOccurrences and associatedTaxa through the ordinary added-field rows."},{"id":"list","cluster":"p4","x":960,"y":620,"w":205,"h":62,"label":"renderVouchers()","sub":"cards · search · sort","desc":"Decorates every record with its current state before filtering, so searching a redetermined name finds it. Cards draw synchronously; photo strips arrive after, through an IntersectionObserver."},{"id":"select","cluster":"p4","x":1185,"y":620,"w":205,"h":62,"label":"Bulk select","sub":"export · delete","desc":"A Set of ticked Voucher IDs, pruned on every render against the records that still exist. Drives the bulk CSV/JSON/XLSX/delete buttons."},{"id":"addUI","cluster":"p4","x":960,"y":720,"w":205,"h":62,"label":"Addendum UI","sub":"add · show · delete","desc":"One shared modal toggled between georeference and redetermination shapes. Defaults the author from the last addendum filed here, then the primary collector."},{"id":"prob","cluster":"p4","x":1185,"y":720,"w":205,"h":62,"label":"Problems note","sub":"orphans · conflicts","desc":"findLocalAddendumProblems() finally has a caller outside the compiler: two disagreeing redeterminations, competing georeferences, orphans, preflight flags. Reports, never repairs."},{"id":"cards","cluster":"p4","x":960,"y":820,"w":205,"h":62,"label":"Card photo strips","sub":"lazy thumbnails","desc":"Reads the photo store per visible card only. Each thumbnail downloads as <voucherId>_NN.ext — the filename carries the link a second time, next to the burned-in stamp."},{"id":"del","cluster":"p4","x":1185,"y":820,"w":205,"h":62,"label":"Delete · Clear all","sub":"records + photos","desc":"Deleting a voucher takes its addenda and its photos with it. Clear All awaits the photo sweep before emitting vouchers:changed, so a redraw cannot resurrect cards on a screen that just said \"cleared\"."},{"id":"theme","cluster":"p6","x":1450,"y":620,"w":245,"h":62,"label":"Theme toggle","sub":"light / dark","desc":"One attribute on <html>; the whole palette hangs off CSS custom properties. Stored in uiState, seeded from prefers-color-scheme on a first visit."},{"id":"toast","cluster":"p6","x":1715,"y":620,"w":245,"h":62,"label":"Toasts","sub":"undo · notice","desc":"showUndoToast for every \"Remove …\" (collector row, DwC term), showNotice for everything that does not need a yes/no. Genuinely destructive actions still use confirm()."},{"id":"sect","cluster":"p6","x":1450,"y":720,"w":245,"h":62,"label":"Section persistence","sub":"open / closed","desc":"Native <details> plus its toggle event, one uiState record per data-sec-id. No custom click handling anywhere."},{"id":"wsbar","cluster":"p6","x":1715,"y":720,"w":245,"h":62,"label":"Workspace bar","sub":"colour · switcher","desc":"Colour derived from the name hash, so pests is the same colour in every browser. The bar, the tab title, the favicon and a 5px stripe all say which workspace this tab is."},{"id":"badge","cluster":"p6","x":1450,"y":820,"w":245,"h":62,"label":"Session badge","sub":"UUID · click to copy","desc":"The per-install tag, shown short in two places. Not an identity system — it answers \"did I write this, or did it arrive from someone else's copy of the file\"."},{"id":"twotab","cluster":"p6","x":1715,"y":820,"w":245,"h":62,"label":"Two-tab presence","sub":"heartbeat","desc":"A stamped localStorage key every 3s. Store handles the data safely; this strip exists because two live views of one report are still confusing."},{"id":"dl","cluster":"p5","x":70,"y":990,"w":190,"h":62,"label":"downloadBlob()","sub":"file writers","desc":"CSV, JSON, HTML, XLSX and ZIP all funnel through here, each with the workspace name prefixed onto the filename."},{"id":"csv","cluster":"p5","x":275,"y":990,"w":190,"h":62,"label":"toCSV · parseCSV","sub":"flat text","desc":"One column per FIELD_DEFS entry, with added Darwin Core terms spread into their own columns. Cells starting = + @ or a tab get an apostrophe, stripped again on import."},{"id":"json","cluster":"p5","x":480,"y":990,"w":190,"h":62,"label":"JSON export · import","sub":"nested chain","desc":"The only format that nests the real addenda array and the real photo manifest. addendaJson is deleted from the payload so the chain is not carried twice."},{"id":"xlsx","cluster":"p5","x":685,"y":990,"w":190,"h":62,"label":"XLSX builder","sub":"OOXML + ZIP writer","desc":"A hand-rolled stored-ZIP writer plus minimal OOXML, one QR PNG per row through a drawing anchor. No library, no CDN. Control characters are dropped; line breaks survive as with wrapText."},{"id":"zip","cluster":"p5","x":70,"y":1110,"w":190,"h":62,"label":"Photo ZIP export","sub":"manifest.csv","desc":"Every image named <voucherId>_NN.ext, with a manifest cross-referencing each filename back to its voucher. A photo that will not read is listed in photos_not_exported.csv instead of killing the archive."},{"id":"html","cluster":"p5","x":275,"y":1110,"w":190,"h":62,"label":"HTML table export","sub":"print-ready","desc":"A plain printable document around the same column set. Drag it into a spreadsheet and the columns import directly."},{"id":"labels","cluster":"p5","x":480,"y":1110,"w":190,"h":62,"label":"Print labels","sub":"4×3 in herbarium","desc":"Twelve labels to a legal sheet. Prints the CURRENT determination with the original underneath as \"Orig. det.\" — both on the paper, in the right order."},{"id":"qrsheets","cluster":"p5","x":685,"y":1110,"w":190,"h":62,"label":"QR sheets · QR tags","sub":"preprint · reconcile","desc":"Sheets mint fresh UUIDs with unique last-fours for the newspaper method. Tags reconcile what came home: last four large, save order, time, current name."},{"id":"cstate","cluster":"p9","x":960,"y":990,"w":205,"h":62,"label":"Drawer state C","sub":"in-memory only","desc":"A scratch workspace that holds its own copy of everything it ingests. It never writes to the Store except through addThisreport(), which reads."},{"id":"cmerge","cluster":"p9","x":1185,"y":990,"w":205,"h":62,"label":"mergeRecords()","sub":"fields + photos","desc":"Keys on occurrenceID. Same specimen from two devices: the newer copy's field values win, both photo manifests are unioned, and the copy set aside is named in the conflicts list."},{"id":"cadd","cluster":"p9","x":960,"y":1090,"w":205,"h":62,"label":"mergeAddendaInto()","sub":"dedupe by id","desc":"The fix for the broken chain: a second copy of one occurrenceID folds its addenda in instead of being discarded whole. Re-loading the same file is a no-op."},{"id":"cread","cluster":"p9","x":1185,"y":1090,"w":205,"h":62,"label":"Readers","sub":"JSON CSV HTML XLSX ZIP","desc":"XLSX goes through the central directory and DecompressionStream, so a workbook written by Excel opens. Zip64 and encrypted entries are named and refused rather than misread."},{"id":"cexp","cluster":"p9","x":960,"y":1190,"w":205,"h":62,"label":"Exporters","sub":"JSON CSV XLSX HTML ZIP","desc":"Every compiled row is stamped with the batch UUID. The photo ZIP filters on every Voucher ID the set still answers to, not just the one left standing after a merge."},{"id":"cdrift","cluster":"p9","x":1185,"y":1190,"w":205,"h":62,"label":"Fingerprint drift","sub":"values changed?","desc":"Recomputes each row's ingest fingerprint and compares. A compiled row whose values no longer match what was taken in is reported, not silently exported."},{"id":"tabs","cluster":"p10","x":1450,"y":990,"w":245,"h":62,"label":"Book tabs","sub":"1 human · 2 plant · 3 office","desc":"Overlapping numbered tabs. On a phone they show one pane at a time; on a wide screen they still show and focus the pane you pick."},{"id":"wedges","cluster":"p10","x":1715,"y":990,"w":245,"h":62,"label":"Corner wedges","sub":"open / close panes","desc":"Two right triangles in the bottom corners. They used to step through sections; with a fixed shell they open and close pane 1 and pane 3."},{"id":"showpane","cluster":"p10","x":1450,"y":1090,"w":245,"h":62,"label":"showPane() · paint()","sub":"layout + scroll","desc":"Sets data-pane, keeps the collapsed rails honest, and on a wide screen gives the selected pane three of five grid shares."},{"id":"reveal","cluster":"p10","x":1715,"y":1090,"w":245,"h":62,"label":"tjRevealElement()","sub":"jump to a field","desc":"Switches to the pane, reopens a collapsed rail, opens every <details> above the target, then scrolls its own pane. A MutationObserver fires it on the save gate's red flags."},{"id":"choice","cluster":"p10","x":1450,"y":1190,"w":245,"h":62,"label":"Identification choice","sub":"Kingdom · Basis dialog","desc":"Presentation only: the native <select> elements stay in the DOM holding the real values, so Draft, validation and every export are untouched."},{"id":"keys","cluster":"p10","x":1715,"y":1190,"w":245,"h":62,"label":"Field conveniences","sub":"Ctrl+Enter saves","desc":"Enter in a single-line field is swallowed rather than submitting; Ctrl/Cmd+Enter submits deliberately. Enter in a textarea still starts a new line."},{"id":"boot1","cluster":"p8","x":70,"y":1350,"w":210,"h":58,"label":"_kernelMigrateLegacy","sub":"","desc":"Runs before anything reads the Store. A named workspace marks itself done and skips."},{"id":"boot2","cluster":"p8","x":305,"y":1350,"w":210,"h":58,"label":"initTheme · dates","sub":"","desc":"Theme from uiState or the system; both date fields open on todayISO()."},{"id":"boot3","cluster":"p8","x":540,"y":1350,"w":210,"h":58,"label":"draft sync · pristine","sub":"","desc":"Captures every HTML default into Draft, then snapshots what an untouched form looks like."},{"id":"boot4","cluster":"p8","x":775,"y":1350,"w":210,"h":58,"label":"generateVoucherQR","sub":"","desc":"Mints the draft Voucher ID and its own immutable occurrenceID."},{"id":"boot5","cluster":"p8","x":1010,"y":1350,"w":210,"h":58,"label":"initCollectors · DwC","sub":"","desc":"Restores the saved collector roster and the added Darwin Core rows."},{"id":"boot6","cluster":"p8","x":1245,"y":1350,"w":210,"h":58,"label":"autofill guards","sub":"","desc":"Random field names, an empty datalist, and the spill guard that catches what those miss."},{"id":"boot7","cluster":"p8","x":1480,"y":1350,"w":210,"h":58,"label":"checkForDraftAutosave","sub":"","desc":"Offers a saved draft back; never restores it silently over a form."},{"id":"boot8","cluster":"p8","x":1715,"y":1350,"w":210,"h":58,"label":"Bus.on wiring","sub":"","desc":"vouchers:changed → list + gallery + problems + workspace bar; draft:restore → form."},{"id":"boot9","cluster":"p8","x":70,"y":1430,"w":210,"h":58,"label":"migrateLegacyRedirect","sub":"","desc":"Rewrites URL-shaped Voucher IDs, re-tags their photos, keeps the originals in legacyVoucherId."},{"id":"boot10","cluster":"p8","x":305,"y":1430,"w":210,"h":58,"label":"reconcileStores","sub":"","desc":"Chained behind the migration, never alongside it — a half-renamed store reads as all orphans."},{"id":"boot11","cluster":"p8","x":540,"y":1430,"w":210,"h":58,"label":"checkStorageResilience","sub":"","desc":"Reads persistence state only. The one call that can prompt lives behind a button."},{"id":"boot12","cluster":"p8","x":775,"y":1430,"w":210,"h":58,"label":"?selftest runner","sub":"","desc":"Last, and only when asked for: it writes a fixture into the real record store."},{"id":"boot13","cluster":"p8","x":1010,"y":1430,"w":210,"h":58,"label":"renderWorkspaceBar","sub":"","desc":"Colour, stripe, favicon and switcher list for the workspace this tab is in."},{"id":"boot14","cluster":"p8","x":1245,"y":1430,"w":210,"h":58,"label":"initWorkspacePresence","sub":"","desc":"Heartbeat key plus a storage listener that repaints when the other tab saves."},{"id":"boot15","cluster":"p8","x":1480,"y":1430,"w":210,"h":58,"label":"initAssociatedVoucher","sub":"","desc":"Restores the relationship default and wires the readout to the associatedOccurrences box."},{"id":"boot16","cluster":"p8","x":1715,"y":1430,"w":210,"h":58,"label":"pane shell init","sub":"","desc":"Book tabs, wedges, the field-invalid observer, and the initial paint."}],"EDGES":[{"from":"ls","to":"store","kind":"store","label":"getItem / setItem"},{"from":"idb","to":"photo","kind":"store","label":"Blobs"},{"from":"idb","to":"recon","kind":"store","label":"count photos"},{"from":"geo","to":"gps","kind":"data","label":"fix"},{"from":"cam","to":"photo","kind":"data","label":"File"},{"from":"bcd","to":"qr","kind":"data","label":"detect()"},{"from":"clip","to":"dl","kind":"data","label":"share"},{"from":"dcs","to":"cread","kind":"data","label":"inflate"},{"from":"fsa","to":"dl","kind":"data","label":"picker"},{"from":"ws","to":"store","kind":"data","label":"key prefix"},{"from":"ws","to":"wsbar","kind":"data","label":"?ws="},{"from":"fdefs","to":"canon","kind":"data","label":"schema"},{"from":"fdefs","to":"csv","kind":"data","label":"columns"},{"from":"fdefs","to":"xlsx","kind":"data","label":"columns"},{"from":"fdefs","to":"cread","kind":"data","label":"column map"},{"from":"util","to":"canon","kind":"data","label":""},{"from":"util","to":"ident","kind":"data","label":"uid()"},{"from":"util","to":"badge","kind":"data","label":"sessionId"},{"from":"store","to":"bus","kind":"event","label":"changed"},{"from":"store","to":"list","kind":"data","label":"load()"},{"from":"store","to":"cstate","kind":"data","label":"Add this report"},{"from":"store","to":"recon","kind":"data","label":"voucher IDs"},{"from":"store","to":"wsbar","kind":"data","label":"uiState"},{"from":"store","to":"sect","kind":"store","label":"uiState"},{"from":"store","to":"theme","kind":"store","label":"uiState"},{"from":"mig","to":"store","kind":"store","label":"one-time"},{"from":"canon","to":"addenda","kind":"data","label":"hydrate chain"},{"from":"canon","to":"fp","kind":"data","label":""},{"from":"canon","to":"impchk","kind":"data","label":"preflight"},{"from":"canon","to":"cmerge","kind":"data","label":"one door"},{"from":"addenda","to":"ident","kind":"data","label":"parentOccurrenceID"},{"from":"addenda","to":"labels","kind":"data","label":"current name"},{"from":"addenda","to":"addUI","kind":"data","label":"createAddendum"},{"from":"addenda","to":"prob","kind":"data","label":"report only"},{"from":"qr","to":"ident","kind":"data","label":"assign ID"},{"from":"qr","to":"qrsheets","kind":"data","label":"mint · render"},{"from":"qr","to":"labels","kind":"data","label":"makeCardQR"},{"from":"qr","to":"xlsx","kind":"data","label":"makeCardQR"},{"from":"impchk","to":"store","kind":"data","label":"block dupes"},{"from":"fp","to":"cdrift","kind":"data","label":"verify"},{"from":"fp","to":"impchk","kind":"data","label":""},{"from":"ident","to":"gate","kind":"data","label":""},{"from":"ident","to":"dwc","kind":"data","label":"linked voucher"},{"from":"draft","to":"form","kind":"data","label":""},{"from":"form","to":"canon","kind":"data","label":"one door"},{"from":"gate","to":"form","kind":"data","label":"validate"},{"from":"gate","to":"photo","kind":"data","label":"photo required"},{"from":"gate","to":"store","kind":"data","label":"save voucher"},{"from":"gate","to":"reveal","kind":"event","label":"field-invalid"},{"from":"gps","to":"draft","kind":"data","label":"resync"},{"from":"exif","to":"gps","kind":"data","label":"fill / flag"},{"from":"photo","to":"draft","kind":"data","label":"tag by voucherId"},{"from":"coll","to":"form","kind":"data","label":""},{"from":"dwc","to":"form","kind":"data","label":""},{"from":"dwc","to":"assocHint","kind":"data","label":""},{"from":"bus","to":"list","kind":"event","label":"vouchers:changed"},{"from":"bus","to":"addUI","kind":"event","label":"addenda:changed"},{"from":"bus","to":"prob","kind":"event","label":""},{"from":"bus","to":"draft","kind":"event","label":"draft:restore"},{"from":"list","to":"cards","kind":"data","label":"lazy"},{"from":"list","to":"select","kind":"data","label":""},{"from":"list","to":"dl","kind":"data","label":"recordsFor(ids)"},{"from":"cards","to":"photo","kind":"data","label":""},{"from":"del","to":"store","kind":"data","label":""},{"from":"del","to":"photo","kind":"data","label":""},{"from":"del","to":"addenda","kind":"data","label":""},{"from":"select","to":"dl","kind":"data","label":""},{"from":"dl","to":"csv","kind":"data","label":""},{"from":"dl","to":"json","kind":"data","label":""},{"from":"dl","to":"xlsx","kind":"data","label":""},{"from":"dl","to":"html","kind":"data","label":""},{"from":"dl","to":"zip","kind":"data","label":""},{"from":"dl","to":"qrsheets","kind":"data","label":""},{"from":"json","to":"photo","kind":"data","label":"manifest"},{"from":"zip","to":"photo","kind":"data","label":"blobs"},{"from":"csv","to":"fdefs","kind":"data","label":""},{"from":"xlsx","to":"fdefs","kind":"data","label":""},{"from":"toast","to":"store","kind":"data","label":"notices"},{"from":"twotab","to":"ls","kind":"store","label":"presence key"},{"from":"persist","to":"ls","kind":"store","label":"persist()"},{"from":"recon","to":"idb","kind":"store","label":"photo counts"},{"from":"recon","to":"photo","kind":"data","label":""},{"from":"cstate","to":"cmerge","kind":"data","label":""},{"from":"cmerge","to":"cadd","kind":"data","label":"fold addenda"},{"from":"cmerge","to":"canon","kind":"data","label":"canonicalRecord"},{"from":"cstate","to":"cread","kind":"data","label":""},{"from":"cread","to":"csv","kind":"data","label":"parseCSV"},{"from":"cread","to":"xlsx","kind":"data","label":"buildXLSX parts"},{"from":"cread","to":"json","kind":"data","label":""},{"from":"cread","to":"html","kind":"data","label":""},{"from":"cexp","to":"dl","kind":"data","label":"saveBlob"},{"from":"cexp","to":"xlsx","kind":"data","label":"buildXLSX"},{"from":"cexp","to":"zip","kind":"data","label":"makeZip"},{"from":"cdrift","to":"fp","kind":"data","label":""},{"from":"cdrift","to":"cmerge","kind":"data","label":"recompute"},{"from":"tabs","to":"showpane","kind":"event","label":"click"},{"from":"wedges","to":"showpane","kind":"event","label":"click"},{"from":"showpane","to":"reveal","kind":"event","label":""},{"from":"choice","to":"form","kind":"data","label":"select value"},{"from":"keys","to":"form","kind":"data","label":"Ctrl+Enter"},{"from":"boot1","to":"mig","kind":"data","label":""},{"from":"boot2","to":"theme","kind":"data","label":""},{"from":"boot3","to":"draft","kind":"data","label":""},{"from":"boot4","to":"qr","kind":"data","label":""},{"from":"boot5","to":"coll","kind":"data","label":""},{"from":"boot5","to":"dwc","kind":"data","label":""},{"from":"boot6","to":"form","kind":"data","label":"guard"},{"from":"boot7","to":"draft","kind":"data","label":"offer restore"},{"from":"boot8","to":"bus","kind":"event","label":"Bus.on"},{"from":"boot9","to":"ident","kind":"data","label":""},{"from":"boot10","to":"recon","kind":"data","label":""},{"from":"boot11","to":"persist","kind":"data","label":""},{"from":"boot12","to":"selftest","kind":"data","label":""},{"from":"boot13","to":"wsbar","kind":"data","label":""},{"from":"boot14","to":"twotab","kind":"data","label":""},{"from":"boot15","to":"dwc","kind":"data","label":""},{"from":"boot16","to":"showpane","kind":"data","label":""}],"RUNS":{"success":{"tone":"ok","steps":[["boot4","qr","Boot mints the draft Voucher ID."],["qr","ident","The ID lands. An occurrenceID is minted beside it and never changes."],["ident","gate","The draft identity reaches the save gate."],["gate","form","Required fields, real dates, coordinates in range. All pass."],["form","canon","voucherFromForm() reads through Draft and hands off to the one door."],["canon","impchk","Schema-only record. Preflight finds no ID clash and no flags."],["impchk","store","Store.put() re-reads disk before it writes."],["store","bus","Write confirmed. Store emits changed."],["bus","list","vouchers:changed. The saved list redraws."],["list","dl","recordsFor(ids) goes to the file writers."],["dl","xlsx","The XLSX builder writes the workbook, one QR per row."]],"kind":"Run complete","title":"Saved and exported","text":"The voucher cleared the gate and the one door, was written to the Store, redrew the list, and left as an XLSX workbook."},"failure":{"tone":"bad","steps":[["boot4","qr","Boot mints the draft Voucher ID."],["qr","ident","The ID lands. An occurrenceID is minted beside it and never changes."],["ident","gate","The draft identity reaches the save gate."],["gate","form","Required fields, real dates, coordinates in range. All pass."],["form","canon","voucherFromForm() reads through Draft and hands off to the one door."],["canon","impchk","Schema-only record. Preflight finds no ID clash and no flags."],["impchk","store","Store.put() re-reads disk before it writes."],["store","ls","setItem throws QuotaExceededError. The fuse is dying."]],"ghost":[["store","bus"],["bus","list"],["list","dl"],["dl","xlsx"]],"kind":"Run failed","title":"Write refused","text":"localStorage is full. lastWriteOk() flips false and the banner goes up instead of claiming a save. Nothing downstream fires: the grey wires never lit."}}}; /* ============================================================ Setup ============================================================ */ const $ = id => document.getElementById(id); const C = $('world'), ctx = C.getContext('2d'); const MC = $('map'), mc = MC.getContext('2d'); const { NODES, CLUSTERS, EDGES: RAW_EDGES, RUNS } = DATA; const by = Object.fromEntries(NODES.map(n => [n.id, n])); const clusters = Object.fromEntries(CLUSTERS.map(c => [c.id, c])); /* the one source wire with a missing endpoint (dwc → assocHint) is dropped, as in the drawing */ const EDGES = RAW_EDGES.filter(e => by[e.from] && by[e.to]); const clamp = (n, a, b) => Math.max(a, Math.min(b, n)); const lerp = (a, b, t) => a + (b - a) * t; const colors = { data: '#7298bd', event: '#45c7b1', store: '#d9a94c' }; NODES.forEach((n, i) => { n.p = [(n.x + n.w / 2) / 10 - 100, 3 + (i % 4) * 1.25, (n.y + n.h / 2) / 10 - 76]; n.color = clusters[n.cluster].color; n.bw = n.w / 10 * .88; n.bd = n.h / 10 * .82; }); let W = innerWidth, H = innerHeight, F = H * .88; const cam = { x: 0, y: 62, z: 145, yaw: 0, pitch: -.43 }; let keys = {}, selection = null, hover = null, hits = [], labels = true; let transition = null, route = null, follow = true, last = 0, time = 0, moveSpeed = 0; let statusCache = ''; /* ---------- colour helpers ---------- */ const rgbCache = new Map(); function rgbOf(hex) { if (!rgbCache.has(hex)) { let h = hex.replace('#', ''); if (h.length === 3) h = h.split('').map(c => c + c).join(''); const n = parseInt(h, 16); rgbCache.set(hex, [(n >> 16) & 255, (n >> 8) & 255, n & 255]); } return rgbCache.get(hex); } function mixHex(a, b, t) { const A = rgbOf(a), B = rgbOf(b); return `rgb(${Math.round(A[0] + (B[0] - A[0]) * t)},${Math.round(A[1] + (B[1] - A[1]) * t)},${Math.round(A[2] + (B[2] - A[2]) * t)})`; } function rgba(hex, a) { const c = rgbOf(hex); return `rgba(${c[0]},${c[1]},${c[2]},${a})`; } /* ---------- projection ---------- */ function resize() { W = innerWidth; H = innerHeight; F = Math.min(W, H) * .75; const d = Math.min(devicePixelRatio || 1, 2); C.width = W * d; C.height = H * d; ctx.setTransform(d, 0, 0, d, 0, 0); } addEventListener('resize', resize); resize(); function view(p) { const x = p[0] - cam.x, y = p[1] - cam.y, z = p[2] - cam.z; const s = Math.sin(cam.yaw), c = Math.cos(cam.yaw); const sp = Math.sin(cam.pitch), cp = Math.cos(cam.pitch); const depth = x * s - z * c; return [x * c + z * s, y * cp - depth * sp, y * sp + depth * cp]; } function project(v) { return [W / 2 + v[0] * F / v[2], H / 2 - v[1] * F / v[2], v[2]]; } function point(p) { const v = view(p); return v[2] > .3 ? project(v) : null; } function clipPoly(v) { const out = []; for (let i = 0; i < v.length; i++) { const a = v[i], b = v[(i + 1) % v.length]; const ai = a[2] >= .3, bi = b[2] >= .3; if (ai) out.push(a); if (ai !== bi) { const t = (.3 - a[2]) / (b[2] - a[2]); out.push(a.map((q, j) => lerp(q, b[j], t))); } } return out; } function poly(ps, fill, stroke, width = 1) { const v = clipPoly(ps.map(view)); if (v.length < 3) return; const s = v.map(project); ctx.beginPath(); s.forEach((p, i) => i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])); ctx.closePath(); if (fill) { ctx.fillStyle = fill; ctx.fill(); } if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = width; ctx.stroke(); } } function line(a, b, color, width = 1, dash = []) { a = view(a); b = view(b); if (a[2] < .3 && b[2] < .3) return; if (a[2] < .3) { const t = (.3 - a[2]) / (b[2] - a[2]); a = a.map((q, j) => lerp(q, b[j], t)); } if (b[2] < .3) { const t = (.3 - b[2]) / (a[2] - b[2]); b = b.map((q, j) => lerp(q, a[j], t)); } a = project(a); b = project(b); ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.setLineDash(dash); ctx.stroke(); ctx.setLineDash([]); } function pathPoint(a, b, t) { return [ lerp(a[0], b[0], t), lerp(a[1], b[1], t) + Math.sin(t * Math.PI) * Math.min(12, Math.hypot(a[0] - b[0], a[2] - b[2]) * .09), lerp(a[2], b[2], t) ]; } /* a wire is 14 little segments so it can be lit part-way along */ function wire(a, b, color, width, kind = 'data', end = 1) { const count = 14; for (let i = 0; i < count * end; i++) { const t = i / count, u = Math.min(end, (i + 1) / count); line(pathPoint(a, b, t), pathPoint(a, b, u), color, width, kind === 'store' ? [1, 5] : kind === 'event' ? [5, 4] : []); } const p = pathPoint(a, b, .76), q = pathPoint(a, b, .73); const pp = point(p), qq = point(q); if (pp && qq && end > .76) { const ang = Math.atan2(pp[1] - qq[1], pp[0] - qq[0]); ctx.beginPath(); ctx.moveTo(pp[0] - 6 * Math.cos(ang - .5), pp[1] - 6 * Math.sin(ang - .5)); ctx.lineTo(pp[0], pp[1]); ctx.lineTo(pp[0] - 6 * Math.cos(ang + .5), pp[1] - 6 * Math.sin(ang + .5)); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.stroke(); } } function connected(id) { return !selection || selection === id || EDGES.some(e => (e.from === selection && e.to === id) || (e.to === selection && e.from === id)); } let labelQueue = []; function pushLabel(p, text, color, size) { labelQueue.push({ p, text, color, size }); } function box(n) { const [x, y, z] = n.p, a = n.bw / 2, b = n.bd / 2; const v = [ [x - a, 0, z - b], [x + a, 0, z - b], [x + a, 0, z + b], [x - a, 0, z + b], [x - a, y, z - b], [x + a, y, z - b], [x + a, y, z + b], [x - a, y, z + b] ]; const faces = [[0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7], [4, 5, 6, 7]] .map((ids, i) => ({ ps: ids.map(j => v[j]), i, d: view(ids.map(j => v[j]).reduce((s, p) => s.map((q, k) => q + p[k] / 4), [0, 0, 0]))[2] })) .sort((p, q) => q.d - p.d); const isSel = selection === n.id; const isHov = hover === n.id && !isSel; const visited = route && route.def.steps.slice(0, route.index).some(s => s[0] === n.id || s[1] === n.id); const col = isSel ? '#b8fff0' : visited ? '#f47857' : n.color; const sideFill = mixHex(col, '#081620', .80); const topFill = mixHex(col, '#0c2130', .58); ctx.globalAlpha = (connected(n.id) ? 1 : .26) * (isHov ? 1 : .96); faces.forEach(f => { const top = f.i === 4; poly(f.ps, top ? topFill : sideFill, rgba(col, isSel ? .95 : .55), isSel ? 2 : 1); }); /* bright top edge */ line([x - a, y + .08, z + b], [x + a, y + .08, z + b], col, isSel ? 3 : isHov ? 2.4 : 2); ctx.globalAlpha = 1; /* selection beacon */ if (isSel) { ctx.globalAlpha = .5 + Math.sin(time * 3) * .18; poly([[x - a - .6, .12, z - b - .6], [x + a + .6, .12, z - b - .6], [x + a + .6, .12, z + b + .6], [x - a - .6, .12, z + b + .6]], null, '#b8fff0', 2); line([x, y + .2, z], [x, y + 14, z], rgba('#b8fff0', .5), 1.4, [4, 4]); ctx.globalAlpha = 1; } const p = point([x, y, z]); if (p) { const near = clamp(F / p[2], .2, 9); hits.push({ id: n.id, x: p[0], y: p[1], w: Math.max(18, n.bw * near / 2), h: Math.max(14, (n.bd + y) * near / 2), depth: p[2] }); if ((labels && connected(n.id) && p[2] < 300) || isSel || isHov) { pushLabel([x, y + 2, z], n.label, isSel ? '#d9fff5' : col, clamp(15 - p[2] / 50, 10, 14), p[2]); } } } function drawLabel(item) { const q = point(item.p); if (!q || q[0] < -160 || q[0] > W + 160 || q[1] < 78 || q[1] > H + 30) return; ctx.font = `500 ${item.size}px ui-monospace,SFMono-Regular,Menlo,monospace`; ctx.textAlign = 'center'; ctx.lineWidth = 4; ctx.strokeStyle = '#04101a'; ctx.strokeText(item.text, q[0], q[1]); ctx.fillStyle = item.color; ctx.fillText(item.text, q[0], q[1]); } /* ---------- minimap ---------- */ let mapHover = null; function drawMap() { mc.clearRect(0, 0, 444, 344); mc.fillStyle = '#061019'; mc.fillRect(0, 0, 444, 344); const mp = (x, z) => [22 + (x + 100) * 2, 20 + (z + 76) * 2]; CLUSTERS.forEach(c => { mc.fillStyle = c.color + '0d'; mc.fillRect(22 + c.x / 5, 20 + c.y / 5, c.w / 5, c.h / 5); mc.strokeStyle = c.color + '66'; mc.lineWidth = 1; mc.strokeRect(22 + c.x / 5, 20 + c.y / 5, c.w / 5, c.h / 5); }); NODES.forEach(n => { const p = mp(n.p[0], n.p[2]); const active = n.id === selection || n.id === mapHover; mc.fillStyle = active ? '#ffffff' : n.color; const s = active ? 4 : 3; mc.fillRect(p[0] - s / 2, p[1] - s / 2, s, s); }); if (route) { mc.lineWidth = 2; route.def.steps.forEach((s, i) => { const A = by[s[0]], B = by[s[1]]; if (!A || !B) return; const a = mp(A.p[0], A.p[2]), b = mp(B.p[0], B.p[2]); mc.strokeStyle = i < route.index ? '#d5594c' : '#ff835e'; mc.globalAlpha = i === route.index ? 1 : .55; mc.beginPath(); mc.moveTo(a[0], a[1]); mc.lineTo(b[0], b[1]); mc.stroke(); }); mc.globalAlpha = 1; } const p = mp(cam.x, cam.z); mc.save(); mc.translate(clamp(p[0], 8, 436), clamp(p[1], 8, 336)); mc.rotate(cam.yaw); mc.beginPath(); mc.moveTo(0, -9); mc.lineTo(6, 6); mc.lineTo(0, 3); mc.lineTo(-6, 6); mc.closePath(); mc.fillStyle = '#dcfff7'; mc.fill(); mc.restore(); } /* ---------- selection / inspector ---------- */ function select(id) { selection = (id && by[id]) ? id : null; const n = selection ? by[selection] : null; $('inspect').hidden = !n; if (!n) { $('destination').value = ''; return; } $('destination').value = n.id; $('kind').textContent = clusters[n.cluster].title; $('title').textContent = n.label; $('desc').textContent = n.desc; const conn = EDGES.filter(e => e.from === n.id || e.to === n.id); $('meta').replaceChildren(); const pills = [ clusters[n.cluster].sub, conn.length + (conn.length === 1 ? ' connection' : ' connections'), n.sub ].filter(Boolean); pills.forEach(t => { const s = document.createElement('span'); s.className = 'pill'; s.textContent = t; $('meta').append(s); }); $('sub').textContent = 'Select a connected module:'; $('wires').replaceChildren(); const out = conn.filter(e => e.from === n.id); const inc = conn.filter(e => e.to === n.id); [...out, ...inc].forEach(e => { const isOut = e.from === n.id; const other = by[isOut ? e.to : e.from]; if (!other) return; const btn = document.createElement('button'); btn.textContent = (isOut ? '→ ' : '← ') + other.label; const small = document.createElement('small'); small.textContent = e.kind + (e.label ? ' · ' + e.label : ''); small.style.color = colors[e.kind]; btn.append(small); btn.onclick = () => { select(other.id); }; btn.ondblclick = () => { select(other.id); visit(by[other.id]); }; $('wires').append(btn); }); } /* ---------- navigation ---------- */ function setFollow(on) { follow = on; $('follow').classList.toggle('active', on); $('follow').textContent = 'Follow: ' + (on ? 'on' : 'off'); } function manual() { transition = null; if (route && follow) setFollow(false); } function moveTo(p) { transition = { ...p }; keys = {}; } function home() { manual(); moveTo({ x: 0, y: 62, z: 145, yaw: 0, pitch: -.43 }); } function visit(n) { manual(); moveTo({ x: n.p[0], y: Math.min(40, n.p[1] + 20), z: n.p[2] + 32, yaw: 0, pitch: -.47 }); } /* ---------- menus ---------- */ CLUSTERS.forEach(c => $('sector').add(new Option(c.title, c.id))); function fillDestinations(filter = '') { const q = filter.trim().toLowerCase(); const sel = $('destination'); const keep = sel.value; sel.replaceChildren(new Option(q ? 'Matches…' : 'Find a module…', '')); const list = [...NODES] .filter(n => !q || n.label.toLowerCase().includes(q) || (n.sub || '').toLowerCase().includes(q)) .sort((a, b) => a.label.localeCompare(b.label)); list.forEach(n => sel.add(new Option(n.label, n.id))); if (list.some(n => n.id === keep)) sel.value = keep; return list; } fillDestinations(); $('find').addEventListener('input', e => fillDestinations(e.target.value)); $('find').addEventListener('keydown', e => { if (e.key === 'Enter') { const first = $('destination').options[1]; if (first && first.value) { select(first.value); visit(by[first.value]); } e.preventDefault(); } }); $('destination').onchange = e => { if (e.target.value) { select(e.target.value); visit(by[e.target.value]); } }; $('sector').onchange = e => { const c = clusters[e.target.value]; if (!c) return; manual(); moveTo({ x: (c.x + c.w / 2) / 10 - 100, y: 40, z: (c.y + c.h / 2) / 10 - 76 + 48, yaw: 0, pitch: -.58 }); }; $('visit').onclick = () => { if (selection) visit(by[selection]); }; $('clear').onclick = () => select(null); $('close').onclick = () => select(null); $('home').onclick = home; $('labels').onclick = () => { labels = !labels; $('labels').classList.toggle('active', labels); }; /* ---------- routes ---------- */ function caption() { if (!route) return; const r = route; if (r.index >= r.def.steps.length) { $('step').textContent = 'MISSION COMPLETE · ILLUSTRATED SCENARIO'; $('mtitle').textContent = r.def.title; $('mtext').textContent = r.def.text; return; } const s = r.def.steps[r.index]; const A = by[s[0]], B = by[s[1]]; $('step').textContent = `${r.kind.toUpperCase()} · ${r.index + 1} / ${r.def.steps.length} · ILLUSTRATED SCENARIO`; $('mtitle').textContent = (A ? A.label : s[0]) + ' → ' + (B ? B.label : s[1]); $('mtext').textContent = s[2]; } function start(kind) { select(null); transition = null; route = { kind, def: RUNS[kind], index: 0, t: 0, paused: false }; setFollow(true); $('pause').textContent = 'Pause route'; caption(); } $('success').onclick = () => start('success'); $('failure').onclick = () => start('failure'); $('pause').onclick = () => { if (!route) return; route.paused = !route.paused; $('pause').textContent = route.paused ? 'Resume route' : 'Pause route'; }; $('next').onclick = () => { if (!route) start('success'); route.index = Math.min(route.index + 1, route.def.steps.length); route.t = 0; caption(); }; $('replay').onclick = () => start(route ? route.kind : 'success'); $('follow').onclick = () => setFollow(!follow); $('stop').onclick = () => { route = null; $('pause').textContent = 'Pause route'; $('step').textContent = 'FLIGHT BRIEFING'; $('mtitle').textContent = 'You have the controls.'; $('mtext').textContent = 'Select a module or explore the world.'; }; /* ---------- help ---------- */ $('helpbtn').onclick = () => { $('help').hidden = !$('help').hidden; }; $('helpclose').onclick = () => { $('help').hidden = true; }; /* ---------- toast ---------- */ let toastTimer = null; function toast(html, ms = 5200) { const el = $('toast'); el.innerHTML = html; el.hidden = false; requestAnimationFrame(() => el.classList.add('show')); clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.classList.remove('show'); setTimeout(() => { el.hidden = true; }, 600); }, ms); } /* ---------- pointer capture ---------- */ $('fly').onclick = async () => { C.focus(); try { if (C.requestPointerLock) await C.requestPointerLock(); else $('status').textContent = 'DRAG TO LOOK · WASD TO FLY'; } catch (err) { $('status').textContent = 'DRAG TO LOOK · WASD TO FLY'; } }; /* ---------- input ---------- */ let drag = null; function pickAt(x, y) { let best = null; for (const h of hits) { if (Math.abs(x - h.x) < h.w && Math.abs(y - h.y) < h.h) { if (!best || h.depth < best.depth) best = h; } } return best ? best.id : null; } C.onpointerdown = e => { C.focus(); drag = { x: e.clientX, y: e.clientY, moved: 0 }; C.setPointerCapture(e.pointerId); }; C.onpointermove = e => { if (document.pointerLockElement === C) { hover = null; manual(); cam.yaw += e.movementX * .0028; cam.pitch = clamp(cam.pitch - e.movementY * .0028, -1.35, 1.1); return; } if (drag) { const dx = e.clientX - drag.x, dy = e.clientY - drag.y; drag.moved += Math.abs(dx) + Math.abs(dy); drag.x = e.clientX; drag.y = e.clientY; if (drag.moved > 3) { manual(); cam.yaw += dx * .005; cam.pitch = clamp(cam.pitch - dy * .005, -1.35, 1.1); } } else { const id = pickAt(e.clientX, e.clientY); if (id !== hover) { hover = id; C.style.cursor = id ? 'pointer' : 'crosshair'; } } }; C.onpointerleave = () => { hover = null; }; C.onpointerup = e => { if (drag && drag.moved < 6) { const locked = document.pointerLockElement === C; const x = locked ? W / 2 : e.clientX; const y = locked ? H / 2 : e.clientY; select(pickAt(x, y)); } drag = null; }; C.onpointercancel = () => { drag = null; }; C.ondblclick = e => { const id = pickAt(e.clientX, e.clientY); if (id) { select(id); visit(by[id]); } }; C.onwheel = e => { e.preventDefault(); manual(); cam.y = clamp(cam.y + e.deltaY * .02, 3, 72); }; const flightKeys = ['KeyW', 'KeyS', 'KeyA', 'KeyD', 'KeyQ', 'KeyE', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ShiftLeft', 'ShiftRight']; addEventListener('keydown', e => { if (['INPUT', 'SELECT', 'TEXTAREA'].includes(e.target.tagName)) return; if (flightKeys.includes(e.code)) { e.preventDefault(); manual(); keys[e.code] = true; return; } if (e.code === 'KeyH') { home(); return; } if (e.code === 'KeyL') { $('labels').click(); return; } if (e.key === '?') { $('help').hidden = !$('help').hidden; return; } if (e.code === 'Space' && e.target.tagName !== 'BUTTON') { e.preventDefault(); if (!e.repeat) $('pause').click(); return; } if (e.code === 'Escape') { keys = {}; drag = null; if (document.pointerLockElement !== C && selection) select(null); } }); addEventListener('keyup', e => { delete keys[e.code]; }); addEventListener('blur', () => { keys = {}; drag = null; }); document.addEventListener('visibilitychange', () => { keys = {}; last = 0; }); document.addEventListener('pointerlockchange', () => { keys = {}; $('fly').classList.toggle('active', document.pointerLockElement === C); }); document.querySelectorAll('[data-key]').forEach(b => { b.onpointerdown = e => { e.preventDefault(); manual(); keys[b.dataset.key] = true; b.setPointerCapture(e.pointerId); }; b.onpointerup = b.onpointercancel = () => { delete keys[b.dataset.key]; }; }); /* ---------- minimap interaction ---------- */ function mapPick(e) { const r = MC.getBoundingClientRect(); const x = (e.clientX - r.left) / r.width * 444; const z = (e.clientY - r.top) / r.height * 344; let best = null, bd = 14; NODES.forEach(n => { const p = [22 + (n.p[0] + 100) * 2, 20 + (n.p[2] + 76) * 2]; const d = Math.hypot(p[0] - x, p[1] - z); if (d < bd) { bd = d; best = n.id; } }); return best; } MC.onmousemove = e => { mapHover = mapPick(e); MC.style.cursor = mapHover ? 'pointer' : 'crosshair'; }; MC.onmouseleave = () => { mapHover = null; }; MC.onclick = e => { const id = mapPick(e); if (id) { select(id); visit(by[id]); return; } const r = MC.getBoundingClientRect(); const x = (e.clientX - r.left) / r.width * 444; const z = (e.clientY - r.top) / r.height * 344; manual(); moveTo({ x: clamp((x - 22) / 2 - 100, -110, 110), y: 36, z: clamp((z - 20) / 2 - 76 + 22, -90, 145), yaw: 0, pitch: -.65 }); }; /* ---------- status line ---------- */ function nearestCluster() { let best = null, bd = Infinity; CLUSTERS.forEach(c => { const cx = (c.x + c.w / 2) / 10 - 100, cz = (c.y + c.h / 2) / 10 - 76; const d = Math.hypot(cam.x - cx, cam.z - cz); if (d < bd) { bd = d; best = c; } }); return best; } function updateStatus() { let s; if (route) { const total = route.def.steps.length; s = (route.kind === 'failure' ? 'FAILURE ROUTE' : 'SUCCESS ROUTE') + ' · STEP ' + Math.min(route.index + 1, total) + ' / ' + total + (route.paused ? ' · PAUSED' : '') + ' · ILLUSTRATED SCENARIO'; } else { const c = nearestCluster(); s = (c ? c.title : 'OPEN GROUND') + ' · FREE FLIGHT'; } if (s !== statusCache) { statusCache = s; $('status').textContent = s; } } /* ============================================================ Frame ============================================================ */ function frame(now) { const dt = last ? Math.min((now - last) / 1000, .05) : 0; last = now; time += dt; /* ---- flight ---- */ const boost = keys.ShiftLeft || keys.ShiftRight; const speed = boost ? 52 : 20; let forward = (keys.KeyW ? 1 : 0) - (keys.KeyS ? 1 : 0); let strafe = (keys.KeyD ? 1 : 0) - (keys.KeyA ? 1 : 0); let up = (keys.KeyE ? 1 : 0) - (keys.KeyQ ? 1 : 0); const norm = Math.max(1, Math.hypot(forward, strafe, up)); moveSpeed = Math.hypot(forward, strafe, up) ? speed : 0; forward /= norm; strafe /= norm; up /= norm; cam.yaw += ((keys.ArrowRight ? 1 : 0) - (keys.ArrowLeft ? 1 : 0)) * dt * 1.2; cam.pitch = clamp(cam.pitch + ((keys.ArrowUp ? 1 : 0) - (keys.ArrowDown ? 1 : 0)) * dt * .9, -1.35, 1.1); cam.x += (Math.sin(cam.yaw) * Math.cos(cam.pitch) * forward + Math.cos(cam.yaw) * strafe) * speed * dt; cam.z += (-Math.cos(cam.yaw) * Math.cos(cam.pitch) * forward + Math.sin(cam.yaw) * strafe) * speed * dt; cam.y += (Math.sin(cam.pitch) * forward + up) * speed * dt; if (transition) { const a = 1 - Math.exp(-dt * 3); for (const k of ['x', 'y', 'z', 'pitch']) cam[k] = lerp(cam[k], transition[k], a); cam.yaw += Math.atan2(Math.sin(transition.yaw - cam.yaw), Math.cos(transition.yaw - cam.yaw)) * a; if (Math.hypot(cam.x - transition.x, cam.y - transition.y, cam.z - transition.z) < .1) transition = null; } /* ---- route advance ---- */ let spark = null; if (route && route.index < route.def.steps.length) { const r = route, s = r.def.steps[r.index]; const A = by[s[0]], B = by[s[1]]; if (A && B) { const a = A.p, b = B.p; const duration = 2.4 + Math.hypot(a[0] - b[0], a[2] - b[2]) / 35; if (!r.paused) r.t += dt * Number($('rate').value) / duration; spark = pathPoint(a, b, clamp(r.t, 0, 1)); if (follow) { const blend = 1 - Math.exp(-dt * 2.4); cam.x = lerp(cam.x, spark[0], blend); cam.z = lerp(cam.z, spark[2] + 32, blend); cam.y = lerp(cam.y, Math.min(65, spark[1] + 24), blend); cam.yaw += Math.atan2(Math.sin(-cam.yaw), Math.cos(-cam.yaw)) * blend; cam.pitch = lerp(cam.pitch, -.57, blend); } if (r.t >= 1) { r.index++; r.t = 0; caption(); } } else { r.index++; r.t = 0; caption(); } } cam.x = clamp(cam.x, -118, 118); cam.y = clamp(cam.y, 3, 72); cam.z = clamp(cam.z, -98, 150); /* ================= RENDER ================= */ ctx.clearRect(0, 0, W, H); /* sky */ const sky = ctx.createLinearGradient(0, 0, 0, H); sky.addColorStop(0, '#03080f'); sky.addColorStop(.42, '#0a222e'); sky.addColorStop(.60, '#123a47'); sky.addColorStop(1, '#050c13'); ctx.fillStyle = sky; ctx.fillRect(0, 0, W, H); /* horizon glow */ const glow = ctx.createRadialGradient(W / 2, H * .57, 0, W / 2, H * .57, Math.max(W, H) * .72); glow.addColorStop(0, 'rgba(97,238,209,.11)'); glow.addColorStop(.45, 'rgba(97,238,209,.035)'); glow.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = glow; ctx.fillRect(0, 0, W, H); /* ground */ poly([[-120, 0, -100], [120, 0, -100], [120, 0, 155], [-120, 0, 155]], '#07131c', null); for (let x = -120; x <= 120; x += 10) line([x, 0, -100], [x, 0, 155], '#12303d', .6); for (let z = -100; z <= 155; z += 10) line([-120, 0, z], [120, 0, z], '#12303d', .6); /* sector plates */ CLUSTERS.forEach(c => { const x = c.x / 10 - 100, z = c.y / 10 - 76, w = c.w / 10, d = c.h / 10; poly([[x, .1, z], [x + w, .1, z], [x + w, .1, z + d], [x, .1, z + d]], c.color + '09', c.color + '5e', 1); line([x, .22, z], [x + w, .22, z], c.color, 2); if (labels) pushLabel([x + w / 2, .45, z + 2], c.title, c.color, 10, 400); }); /* wires */ EDGES.forEach(e => { const lit = selection && (e.from === selection || e.to === selection); ctx.globalAlpha = selection ? (lit ? .95 : .05) : .18; wire(by[e.from].p, by[e.to].p, colors[e.kind], lit ? 1.7 : .85, e.kind); }); ctx.globalAlpha = 1; /* buildings — far to near */ hits = []; labelQueue = []; [...NODES].sort((a, b) => view(b.p)[2] - view(a.p)[2]).forEach(box); /* labels on top of the geometry */ labelQueue.sort((a, b) => b[4] - a[4]); labelQueue.forEach(drawLabel); /* highlighted wires over the geometry */ if (selection) { EDGES.filter(e => e.from === selection || e.to === selection) .forEach(e => wire(by[e.from].p, by[e.to].p, colors[e.kind], 1.5, e.kind)); } /* route */ if (route) { route.def.steps.forEach((s, i) => { const A = by[s[0]], B = by[s[1]]; if (!A || !B) return; wire(A.p, B.p, i < route.index ? '#d5594c' : '#7a6047', i < route.index ? 2.2 : 1); if (i === route.index) wire(A.p, B.p, '#ffb35c', 3, 'data', Math.min(route.t, 1)); }); /* the failure route names the wires that never light */ if (route.def.ghost && route.kind === 'failure') { ctx.globalAlpha = .28; route.def.ghost.forEach(g => { const A = by[g[0]], B = by[g[1]]; if (A && B) wire(A.p, B.p, '#6b7f88', 1.2, 'store'); }); ctx.globalAlpha = 1; } if (spark) { const p = point(spark); if (p) { const rad = clamp(F / p[2] * 1.6, 4, 24); const g = ctx.createRadialGradient(p[0], p[1], 0, p[0], p[1], rad * 3); g.addColorStop(0, '#fff4cfFF'); g.addColorStop(.2, '#ffb459cc'); g.addColorStop(1, '#ff562000'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(p[0], p[1], rad * 3, 0, 7); ctx.fill(); ctx.fillStyle = '#fff8dc'; ctx.beginPath(); ctx.arc(p[0], p[1], 3, 0, 7); ctx.fill(); } } } /* vignette */ const vg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * .34, W / 2, H / 2, Math.max(W, H) * .78); vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(0,0,0,.55)'); ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H); /* HUD */ drawMap(); updateStatus(); $('alt').textContent = 'ALT ' + Math.round(cam.y) + ' / 72'; $('speed').textContent = 'SPD ' + Math.round(moveSpeed); $('altfill').style.width = (cam.y / 72 * 100) + '%'; requestAnimationFrame(frame); } /* ============================================================ Blender export — generated from the same DATA, so it can't drift ============================================================ */ function buildBlenderScript() { const lines = [ '# IF Small World — Blender scene builder', '# Generated from the flight deck\'s own schematic data, so the .blend and the map cannot drift.', '# Open this file in Blender\'s Text Editor (Scripting workspace), then Run Script.', '# It creates a NEW scene; existing scenes are kept. Save As a .blend afterwards.', '# The script is supplied for editing and has not been executed by the authoring environment.', '', 'import bpy, json, math', 'from mathutils import Vector', '', 'DATA_JSON = ' + JSON.stringify(JSON.stringify(DATA)), 'DATA = json.loads(DATA_JSON)', '', "scene = bpy.data.scenes.new('IF Small World')", 'if bpy.context.window:', ' bpy.context.window.scene = scene', "scene.world = bpy.data.worlds.new('IF Night')", 'scene.world.use_nodes = True', "scene.world.node_tree.nodes['Background'].inputs[0].default_value = (0.012,0.023,0.038,1)", "scene.world.node_tree.nodes['Background'].inputs[1].default_value = 0.4", '', 'def material(name, color, emission=0):', ' m=bpy.data.materials.new(name)', ' rgb=tuple(int(color[i:i+2],16)/255 for i in (1,3,5))', ' m.diffuse_color=(*rgb,1)', ' m.use_nodes=True', " b=m.node_tree.nodes.get('Principled BSDF')", " b.inputs['Base Color'].default_value=(*rgb,1)", " b.inputs['Metallic'].default_value=.35", " b.inputs['Roughness'].default_value=.38", " emission_color=b.inputs.get('Emission Color')", " if emission_color is None: emission_color=b.inputs.get('Emission')", ' if emission_color is not None: emission_color.default_value=(*rgb,1)', " strength=b.inputs.get('Emission Strength')", ' if strength is not None: strength.default_value=emission', ' return m', '', 'def link(name,data):', ' obj=bpy.data.objects.new(name,data)', ' scene.collection.objects.link(obj)', ' return obj', '', 'def cube(name,loc,size,mat):', ' x,y,z=(v/2 for v in size)', ' verts=[(-x,-y,-z),(x,-y,-z),(x,y,-z),(-x,y,-z),(-x,-y,z),(x,-y,z),(x,y,z),(-x,y,z)]', ' mesh=bpy.data.meshes.new(name)', ' mesh.from_pydata(verts,[],[(3,2,1,0),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7),(4,5,6,7)])', ' mesh.update()', ' obj=link(name,mesh);obj.location=loc;obj.data.materials.append(mat)', " bevel=obj.modifiers.new('Small edge bevel','BEVEL');bevel.width=.15;bevel.segments=2", ' return obj', '', 'def curve(name,coords,mat,radius=.06):', " data=bpy.data.curves.new(name,'CURVE');data.dimensions='3D';data.bevel_depth=radius;data.bevel_resolution=2", " sp=data.splines.new('POLY');sp.points.add(len(coords)-1)", ' for p,co in zip(sp.points,coords):p.co=(*co,1)', ' obj=link(name,data);data.materials.append(mat);return obj', '', 'def text(name,body,pos,size,mat):', " data=bpy.data.curves.new(name,'FONT');data.body=body;data.size=size;data.align_x='CENTER'", ' obj=link(name,data);obj.location=pos;data.materials.append(mat)', ' return obj', '', "floor=material('Dark ground','#08131d');building=material('Module body','#163241')", "white=material('Type','#c5eee5',.6)", "colors={c['id']:material(c['title'],c['color'],1.5) for c in DATA['CLUSTERS']}", "wires={k:material(k,c,1.6) for k,c in {'data':'#7298bd','event':'#45c7b1','store':'#d9a94c'}.items()}", "route_mat=material('Success route','#ff835e',2.5)", "cube('World',(0,-27,-.6),(240,255,1),floor)", "for c in DATA['CLUSTERS']:", " x=c['x']/10-100;y=76-c['y']/10;w=c['w']/10;h=c['h']/10", " curve(c['title'],[(x,y,.12),(x+w,y,.12),(x+w,y-h,.12),(x,y-h,.12),(x,y,.12)],colors[c['id']],.1)", " text(c['title']+' label',c['title'],(x+w/2,y-2,.2),1.1,colors[c['id']])", 'positions={}', "for i,n in enumerate(DATA['NODES']):", " x=(n['x']+n['w']/2)/10-100;y=76-(n['y']+n['h']/2)/10;h=3+(i%4)*1.25", " positions[n['id']]=(x,y,h)", " obj=cube(n['label'],(x,y,h/2),(n['w']/10*.88,n['h']/10*.82,h),building)", " obj['module_id']=n['id'];obj['description']=n['desc'];obj['subtitle']=n['sub'];obj['sector']=n['cluster']", " cube(n['label']+' roof',(x,y,h),(n['w']/10*.88,n['h']/10*.82,.15),colors[n['cluster']])", " text(n['label']+' label',n['label'],(x,y,h+.2),.65,white)", '', 'def arc(a,b):', ' height=min(12,math.hypot(a[0]-b[0],a[1]-b[1])*.09)', ' return [(a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t+math.sin(t*math.pi)*height) for t in [i/24 for i in range(25)]]', "for e in DATA['EDGES']:", " if e['from'] not in positions or e['to'] not in positions: continue", " obj=curve(e['from']+' -> '+e['to'],arc(positions[e['from']],positions[e['to']]),wires[e['kind']])", " obj['kind']=e['kind'];obj['label']=e['label']", "for s in DATA['RUNS']['success']['steps']:", " obj=curve('MISSION '+s[0]+' -> '+s[1],arc(positions[s[0]],positions[s[1]]),route_mat,.13)", " obj['explanation']=s[2]", "light_data=bpy.data.lights.new('Area light','AREA');light_data.energy=90000;light_data.shape='DISK';light_data.size=160", "light=link('Area light',light_data);light.location=(0,0,95)", "sun_data=bpy.data.lights.new('Fill','SUN');sun_data.energy=1.5", 'sun=link(\'Fill\',sun_data);sun.rotation_euler=(.3,-.4,.2)', "cam_data=bpy.data.cameras.new('Mission camera');camera=link('Mission camera',cam_data);scene.camera=camera;cam_data.lens=26;cam_data.clip_end=1000", '', 'def aim(obj,position,target):', " obj.location=position;obj.rotation_euler=(Vector(target)-obj.location).to_track_quat('-Z','Y').to_euler()", 'frame=1', "for s in DATA['RUNS']['success']['steps']:", ' for target in arc(positions[s[0]],positions[s[1]])[::4]:', ' aim(camera,(target[0],target[1]-32,target[2]+24),target)', " camera.keyframe_insert(data_path='location',frame=frame)", " camera.keyframe_insert(data_path='rotation_euler',frame=frame)", ' frame+=12', 'scene.frame_start=1;scene.frame_end=frame-12;scene.render.fps=24', 'scene.render.resolution_x=1920;scene.render.resolution_y=1080;scene.render.resolution_percentage=100', "scene['source']='ClaudeDKschematic(2).html; illustrated wiring map, not a fresh code audit'", "scene['navigation']='HTML contains bounded flight and inspector. Blender scene has editable geometry and an animated camera.'", "scene['generated_by']='IF Small World flight deck — data and scene share one source'", 'scene.frame_set(1)', 'for screen in bpy.data.screens:', ' for area in screen.areas:', " if area.type=='VIEW_3D':", ' area.spaces.active.region_3d.view_distance=180', ' area.spaces.active.region_3d.view_location=(0,0,0)', "print('IF Small World created. Save As a .blend; play the timeline for the success-route camera.')", '' ]; return lines.join('\n'); } function download(name, text, type) { const url = URL.createObjectURL(new Blob([text], { type })); const a = document.createElement('a'); a.href = url; a.download = name; document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } $('blender').onclick = () => { download('IF-small-world-blender.py', buildBlenderScript(), 'text/x-python'); toast('<b>Scene builder downloaded.</b> Open it in Blender\'s Text Editor and Run Script. It is generated from this page\'s data, so the .blend and this map agree.'); $('help').hidden = false; }; /* ============================================================ Public API + boot ============================================================ */ window.IFFlight = { get camera() { return { ...cam }; }, get route() { return route && { kind: route.kind, index: route.index, paused: route.paused }; }, get counts() { return { nodes: NODES.length, edges: EDGES.length, sectors: CLUSTERS.length }; }, get selection() { return selection; }, select, home, start, data: DATA }; select(null); $('inspect').hidden = true; toast('<b>Hi, Dan.</b> Hold <b>W</b> to fly. Click a building to inspect its wiring — or run Success / Failure to watch a route.'); requestAnimationFrame(frame); </script> </body> </html>Ironfist Happy Path · animated call atlas
Watch the recorded calls move through the application. Open the atlas, choose a chapter such as Save, then press Play chapter. Drag to orbit; pinch or scroll to zoom. On a small screen, tap Controls to choose a chapter.
<!DOCTYPE html> <!--Atelier--> <html lang="en"> <!--Messing with my comments--> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="theme-color" content="#0a0f1a" id="themeColorMeta"> <meta name="description" content="Ironfist happy-path atlas: two recorded runs of ironfist501-thumbnav.html under its own ?trace tracer (one voucher filled, photographed, saved and exported, and the built-in self-test), played back call by call on a 4D force-directed map where W is time. Zero dependencies, one file, opens offline."> <title>Ironfist Happy Path Atlas</title> <script type="application/ld+json"> { "@context": "https://schema.org", "@type": "CreativeWork", "name": "Ironfist Happy Path Atlas", "alternateName": "kg-ironfist-happy-path", "description": "A force-directed 4D map of two recorded runs of the Ironfist field voucher app under its own ?trace tracer: one voucher filled, photographed, saved and exported, and the built-in self-test. Every call plays back in recorded order; W is time.", "author": { "@type": "Person", "name": "T.M. Jones", "identifier": "https://orcid.org/0000-0001-7372-6345" }, "publisher": { "@type": "Organization", "name": "TJID3 Research" }, "url": "https://tjid3.org/", "isAccessibleForFree": true, "encodingFormat": "text/html", "dateModified": "2026-09-16" } </script> <style> :root{ --bg:#0a0f1a; --bg2:#0b1424; --panel:rgba(17,26,44,.72); --panel-solid:rgba(17,26,44,.94); --panel-brd:rgba(120,150,200,.16); --ink:#e9eff8; --mut:#8aa0bd; --accent:#5b9dff; --accent-soft:rgba(91,157,255,.16); --shadow:0 10px 40px rgba(0,0,0,.45); --font-ui:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; --font-body:Georgia,"Times New Roman",serif; --font-mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace; } html[data-theme="light"]{ --bg:#eef2f8; --bg2:#e3e9f3; --panel:rgba(255,255,255,.82); --panel-solid:rgba(255,255,255,.97); --panel-brd:rgba(60,90,140,.18); --ink:#16233a; --mut:#4d627e; --accent:#2f6fe0; --accent-soft:rgba(47,111,224,.12); --shadow:0 10px 34px rgba(30,50,90,.16); } *{box-sizing:border-box} html,body{height:100%;margin:0} body{ background:var(--bg); color:var(--ink); font-family:var(--font-ui); overflow:hidden; -webkit-text-size-adjust:100%; } #stage{position:fixed;inset:0} canvas{display:block;width:100%;height:100%;touch-action:none;cursor:grab} canvas.grabbing{cursor:grabbing} :focus-visible{outline:2px solid var(--accent);outline-offset:2px} /* Masthead */ .mast{ position:fixed;left:18px;top:16px;z-index:5;max-width:min(46ch,64vw); pointer-events:none; } .eyebrow{ font-family:var(--font-mono); font-size:12.4px;letter-spacing:.14em; text-transform:uppercase;color:var(--mut);font-weight:600;margin:0 0 6px; } .eyebrow .mast-home{ color:inherit;text-decoration:none;pointer-events:auto; border-bottom:1px solid transparent;transition:color .15s,border-color .15s; } .eyebrow .mast-home:hover,.eyebrow .mast-home:focus{ color:var(--accent);border-color:var(--accent);outline:none; } h1{ font-family:var(--font-ui);font-weight:640;letter-spacing:-.015em; font-size:clamp(20.3px,3.8vw,29.4px);margin:0;line-height:1.08; } .lede{ font-family:var(--font-body);color:var(--mut); font-size:clamp(14.1px,2.7vw,15.8px);margin:8px 0 0;line-height:1.45; } /* Control dock */ .dock{ position:fixed;right:14px;top:14px;z-index:6;display:flex;flex-direction:column; gap:8px;align-items:stretch;width:226px;max-width:56vw; } .card{ background:var(--panel);border:1px solid var(--panel-brd);border-radius:12px; box-shadow:var(--shadow);backdrop-filter:blur(9px);-webkit-backdrop-filter:blur(9px); padding:11px 12px; } .row{display:flex;gap:8px;flex-wrap:wrap} button.pill,a.pill{ font-family:var(--font-mono);font-size:12.4px;letter-spacing:.05em;font-weight:600; color:var(--ink);background:transparent;border:1px solid var(--panel-brd); border-radius:999px;padding:10px 13px;min-height:40px;cursor:pointer;flex:1 1 auto;min-width:0; text-align:center;text-decoration:none;display:inline-flex;align-items:center;justify-content:center; transition:background .15s,border-color .15s,color .15s; } button.pill:hover,a.pill:hover{background:var(--accent-soft);border-color:var(--accent)} button.pill[aria-pressed="true"]{background:var(--accent-soft);border-color:var(--accent);color:var(--accent)} button.pill.wide{flex:1 1 100%} .ctl-lab{ font-family:var(--font-mono);font-size:11.9px;letter-spacing:.12em;text-transform:uppercase; color:var(--mut);font-weight:600;display:flex;justify-content:space-between;gap:10px;margin:0 0 6px; } .ctl-lab span:last-child{text-align:right} input[type=range]{ -webkit-appearance:none;appearance:none;width:100%;height:4px;border-radius:4px; background:linear-gradient(90deg,var(--accent),rgba(120,150,200,.25));outline:none;margin:2px 0 0; } input[type=range]::-webkit-slider-thumb{ -webkit-appearance:none;appearance:none;width:16px;height:16px;border-radius:50%; background:var(--ink);border:2px solid var(--accent);cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.4); } input[type=range]::-moz-range-thumb{ width:16px;height:16px;border-radius:50%;background:var(--ink);border:2px solid var(--accent);cursor:pointer; } /* Legend */ .legend{display:flex;flex-direction:column;gap:4px} .leg{ display:flex;align-items:center;gap:8px;font-family:var(--font-mono); font-size:12.4px;letter-spacing:.03em;color:var(--mut);cursor:pointer;user-select:none; padding:4px 4px;border-radius:6px;transition:color .15s,background .15s; background:none;border:0;width:100%;text-align:left; } .leg:hover{color:var(--ink);background:var(--accent-soft)} .leg.off{opacity:.34;text-decoration:line-through} .leg .lcount{margin-left:auto;font-size:10.6px;opacity:.75} .dot{width:9px;height:9px;border-radius:50%;flex:none;box-shadow:0 0 8px currentColor} /* Dock collapse handles, only earn their keep on small screens */ .dock-quick{display:none} .dock-toggle{display:none} /* Detail panel */ .detail{ position:fixed;left:18px;bottom:18px;z-index:7;width:min(348px,80vw); opacity:0;transform:translateY(8px);pointer-events:none;transition:opacity .2s,transform .2s; } .detail.show{opacity:1;transform:none;pointer-events:auto} .detail .kick{ font-family:var(--font-mono);font-size:11.3px;letter-spacing:.13em;text-transform:uppercase; font-weight:600;margin:0 0 5px; } .detail h2{font-family:var(--font-ui);font-weight:640;font-size:18.1px;letter-spacing:-.01em;margin:0;padding-right:22px} .detail p{font-family:var(--font-body);font-size:14.7px;line-height:1.5;color:var(--mut);margin:8px 0 0} .detail .acts{display:flex;flex-wrap:wrap;gap:7px;margin:11px 0 0} .detail a.go{ font-family:var(--font-mono);font-size:12.4px;font-weight:600;letter-spacing:.04em; color:var(--bg);background:var(--accent);border:1px solid var(--accent); border-radius:999px;padding:8px 14px;text-decoration:none;min-height:36px; display:inline-flex;align-items:center;gap:7px; } html[data-theme="light"] .detail a.go{color:#fff} .detail a.go:hover{filter:brightness(1.08)} .detail a.doi{ font-family:var(--font-mono);font-size:12.4px;font-weight:600;letter-spacing:.04em; color:var(--accent);text-decoration:none;border:1px solid var(--panel-brd); border-radius:999px;padding:8px 12px;min-height:36px;display:inline-flex;align-items:center; } .detail a.doi:hover{border-color:var(--accent);background:var(--accent-soft)} .detail .dlinks{margin:11px 0 0;display:flex;flex-wrap:wrap;gap:6px;max-height:168px;overflow-y:auto} .detail .dlinks .lbl{ width:100%;font-family:var(--font-mono);font-size:11.3px;letter-spacing:.13em; text-transform:uppercase;font-weight:600;color:var(--mut);margin:0 0 3px; } .detail button.dlink{ font-family:var(--font-mono);font-size:12.4px;cursor:pointer;line-height:1.3; color:var(--accent);text-decoration:none; border:1px solid var(--accent-soft);border-radius:999px;background:var(--accent-soft); padding:5px 11px;display:inline-flex;align-items:center;gap:6px; } .detail button.dlink:hover,.detail button.dlink:focus{border-color:var(--accent);background:transparent;outline:none} .detail button.dlink .cdot{width:8px;height:8px;border-radius:50%;flex:0 0 auto} .detail .close{ position:absolute;right:9px;top:7px;background:none;border:none;color:var(--mut); font-size:19.2px;line-height:1;cursor:pointer;padding:4px 6px; } .detail .close:hover{color:var(--ink)} /* Bottom-left utility rail */ .rail{position:fixed;left:18px;bottom:18px;z-index:5;display:flex;gap:7px;align-items:center} .rail .rpill{ font-family:var(--font-mono);font-size:11.9px;letter-spacing:.06em;font-weight:600; color:var(--mut);background:var(--panel);border:1px solid var(--panel-brd); border-radius:999px;padding:8px 13px;min-height:36px;cursor:pointer; display:inline-flex;align-items:center;gap:7px; backdrop-filter:blur(9px);-webkit-backdrop-filter:blur(9px); transition:color .15s,border-color .15s,background .15s; } .rail .rpill:hover{color:var(--accent);border-color:var(--accent)} .rail .stat{pointer-events:none;cursor:default} /* Help card */ .help{ position:fixed;left:18px;bottom:66px;z-index:9;width:min(330px,86vw); display:none; } .help.show{display:block} .help h3{font-family:var(--font-ui);font-weight:640;font-size:15.4px;margin:0 0 8px;letter-spacing:-.01em} .help dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:5px 12px;align-items:baseline} .help dt{font-family:var(--font-mono);font-size:11.3px;letter-spacing:.05em;color:var(--accent);font-weight:600} .help dd{margin:0;font-family:var(--font-body);font-size:13.4px;line-height:1.35;color:var(--mut)} .help .hsplit{grid-column:1 / -1;height:1px;background:var(--panel-brd);margin:6px 0 2px} .help .opt{ grid-column:1 / -1;display:flex;align-items:center;gap:9px;margin:8px 0 0; font-family:var(--font-mono);font-size:11.9px;color:var(--mut);cursor:pointer; } .help .opt input{width:16px;height:16px;accent-color:var(--accent);cursor:pointer} .help .tier{grid-column:1 / -1;font-family:var(--font-mono);font-size:10.6px;letter-spacing:.09em; text-transform:uppercase;color:var(--mut);opacity:.7;margin:9px 0 0} .hint{ position:fixed;left:50%;bottom:12px;transform:translateX(-50%);z-index:4; font-family:var(--font-mono);font-size:11.9px;letter-spacing:.06em;color:var(--mut); background:var(--panel);border:1px solid var(--panel-brd);border-radius:999px; padding:6px 13px;box-shadow:var(--shadow);opacity:.9;pointer-events:none; transition:opacity .5s; } /* Chapter pills and the context bar along the bottom */ .chapters button.pill{padding:7px 9px;min-height:34px;font-size:11.6px;flex:1 1 30%} .ctxbar{ position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:4; width:min(760px,calc(100vw - 32px)); background:var(--panel);border:1px solid var(--panel-brd);border-radius:12px;box-shadow:var(--shadow); backdrop-filter:blur(9px);-webkit-backdrop-filter:blur(9px); padding:9px 14px;opacity:0;pointer-events:none;transition:opacity .4s; } .ctxbar.show{opacity:1} .ctxbar p{margin:0;line-height:1.4} #ctxHead{font-family:var(--font-body);font-size:15px;color:var(--ink)} #ctxTail{font-family:var(--font-mono);font-size:12.2px;color:var(--mut);margin-top:3px} @media (max-width:760px){ .ctxbar{bottom:62px;padding:7px 11px} #ctxHead{font-size:13.6px} #ctxTail{font-size:11.4px} } /* Index drawer: every function in the graph, as a list */ .papers{ position:fixed;left:0;top:0;bottom:0;z-index:8;display:flex; width:min(356px,92vw); transform:translateX(-100%); transition:transform .3s cubic-bezier(.22,.61,.36,1); will-change:transform; } .papers.open{transform:none} .papers .pnl{ flex:1;min-width:0;display:flex;flex-direction:column; background:var(--panel);border-right:1px solid var(--panel-brd); box-shadow:var(--shadow); backdrop-filter:blur(11px);-webkit-backdrop-filter:blur(11px); } .papers .phead{padding:16px 15px 12px;border-bottom:1px solid var(--panel-brd);position:relative} .papers .ptitle{font-family:var(--font-ui);font-weight:640;font-size:16px;margin:0 0 3px;letter-spacing:-.01em} .papers .psub{font-family:var(--font-mono);font-size:10.6px;letter-spacing:.1em;text-transform:uppercase;color:var(--mut);margin:0} .papers .pclose{ position:absolute;right:10px;top:10px;background:none;border:none;color:var(--mut); font-size:20px;line-height:1;cursor:pointer;padding:4px 7px; } .papers .pclose:hover{color:var(--ink)} .papers .psearch{ margin:12px 0 0;width:100%;font-family:var(--font-ui);font-size:15px;color:var(--ink); background:var(--accent-soft);border:1px solid var(--panel-brd);border-radius:9px; padding:9px 11px;outline:none; } .papers .psearch::placeholder{color:var(--mut)} .papers .psearch:focus{border-color:var(--accent)} .papers .plist{flex:1;overflow-y:auto;overflow-x:hidden;padding:4px 8px 20px;-webkit-overflow-scrolling:touch} .papers .pgrp{ font-family:var(--font-mono);font-size:10.4px;letter-spacing:.14em;text-transform:uppercase; color:var(--mut);font-weight:600;padding:13px 7px 5px;position:sticky;top:0; background:var(--panel-solid);z-index:1; } .papers .pitem{ display:flex;align-items:flex-start;gap:9px;padding:8px;border-radius:8px;cursor:pointer; transition:background .13s;color:inherit;text-decoration:none; } .papers .pitem:hover{background:var(--accent-soft)} .papers .pitem.sel{background:var(--accent-soft);box-shadow:inset 2px 0 0 var(--accent)} .papers .pdot{width:8px;height:8px;border-radius:50%;flex:none;margin-top:6px;box-shadow:0 0 7px currentColor} .papers .pmeta{min-width:0;flex:1} .papers .pname{font-family:var(--font-body);font-size:14.2px;line-height:1.3;color:var(--ink);display:block} .papers .pyr{font-family:var(--font-mono);font-size:10.4px;letter-spacing:.06em;color:var(--mut);margin-top:2px;display:block} .papers .popen{ flex:none;font-family:var(--font-mono);font-size:14px;text-decoration:none;color:var(--mut); border:1px solid transparent;border-radius:7px;padding:5px 8px;line-height:1;align-self:center; } .papers .popen:hover,.papers .popen:focus{color:var(--accent);border-color:var(--accent);outline:none} .papers .pempty{font-family:var(--font-body);font-size:13.5px;color:var(--mut);padding:18px 10px;font-style:italic} @media (max-width:760px){ .dock{width:min(238px,66vw);gap:7px;right:10px;top:10px} .dock.folded .card,.dock.folded .dock-papers{display:none} .dock-quick{display:flex;gap:6px;justify-content:flex-end} .dock-toggle{display:block;flex:1 1 auto;min-width:0; font-family:var(--font-mono);font-size:11.9px;letter-spacing:.05em; font-weight:600;color:var(--ink);background:var(--panel);border:1px solid var(--panel-brd); border-radius:12px;padding:10px 8px;min-height:42px;cursor:pointer; backdrop-filter:blur(9px);-webkit-backdrop-filter:blur(9px);box-shadow:var(--shadow)} .dock-toggle:hover{border-color:var(--accent);color:var(--accent)} .mast{top:12px;left:12px;max-width:46vw} .mast .lede{display:none} .detail{left:10px;right:10px;width:auto;bottom:64px} .rail{left:10px;bottom:12px} .rail .stat{display:none} .help{left:10px;right:10px;width:auto;bottom:60px} .hint{bottom:62px;left:auto;right:10px;transform:none;max-width:62vw;text-align:center} .papers{width:min(340px,94vw)} } @media (max-height:520px){ .mast .lede{display:none} } /* Weak-machine path: keep the glass shape, drop the costly live blur. */ html[data-perf="lite"] .card, html[data-perf="lite"] .dock-papers, html[data-perf="lite"] .dock-toggle, html[data-perf="lite"] .rail .rpill, html[data-perf="lite"] .papers .pnl{ backdrop-filter:none;-webkit-backdrop-filter:none; box-shadow:0 6px 22px rgba(0,0,0,.28); } html[data-perf="lite"] .card, html[data-perf="lite"] .dock-papers, html[data-perf="lite"] .dock-toggle, html[data-perf="lite"] .rail .rpill{background:rgba(17,26,44,.93)} html[data-theme="light"][data-perf="lite"] .card, html[data-theme="light"][data-perf="lite"] .dock-papers, html[data-theme="light"][data-perf="lite"] .dock-toggle, html[data-theme="light"][data-perf="lite"] .rail .rpill{background:rgba(255,255,255,.96)} @media (prefers-reduced-motion:reduce){ .papers,.detail,button.pill,.leg,.dock-papers,.rail .rpill{transition-duration:.01ms!important} } </style> <style> /* Larger embedded-atlas controls; original graph and trace data retained. */ button.pill,a.pill,.dock-toggle,.rail .rpill,.detail .close,.papers .pclose,.papers .popen { min-height:48px; min-width:48px; font-size:14px; } .dock { width:280px; max-width:75vw; max-height:calc(100vh - 28px); overflow-y:auto; overscroll-behavior:contain; padding:2px; } .dock-quick { position:sticky; top:0; z-index:2; } .dock .card { flex-shrink:0; } .papers .pitem,.detail .dlinks a { min-height:48px; } .papers .psearch { min-height:48px; font-size:16px; } #wAxis { min-height:48px; } #btnRun { background:var(--accent); color:#fff; } @media(max-width:760px){ .dock { top:84px; max-height:calc(100vh - 155px); } .mast { max-width:calc(100vw - 24px); } .mast h1 { font-size:22px; } } </style> </head> <body> <div id="stage"><canvas id="c"></canvas></div> <header class="mast"> <p class="eyebrow"><a class="mast-home" href="https://tjid3.org/" rel="home">TJID3 Research</a> · kg lineage</p> <h1>Ironfist Happy Path</h1> <p class="lede">Two recorded runs of ironfist501-thumbnav.html under its own ?trace tracer: one voucher filled, photographed, saved and exported, and the built-in self-test. Pick a chapter, press Play chapter, and every call plays back in the order it fired.</p> </header> <div class="dock" id="dock"> <div class="dock-quick"> <button class="dock-toggle" id="btnFitMini" title="Fit the whole cloud on screen">Fit</button> <button class="dock-toggle" id="btnIndexMini" title="Open the index">Index</button> <button class="dock-toggle" id="dockToggle" aria-expanded="true" aria-controls="dock">···</button> </div> <div class="card"> <div class="row" style="margin-bottom:8px"> <button class="pill" id="btnFit" title="Fit the whole cloud on screen (F)">Fit All</button> <button class="pill" id="btnReset" title="Reset the camera (R)">Reset</button> </div> <div class="row" style="margin-bottom:8px"> <button class="pill" id="btnSpin" aria-pressed="true" title="Idle rotation (S)">Spin</button> <button class="pill" id="btnDir" title="Flip rotation direction">↻</button> <button class="pill" id="btnTheme" title="Toggle light and dark (T)">Light</button> </div> <div class="row" style="margin-bottom:8px"> <button class="pill" id="btnBang" title="Collapse to a seed, then bloom again (B)">Collapse</button> <button class="pill" id="btnLabels" title="Cycle label density (L)">Labels: hubs</button> </div> <div class="row chapters" style="margin-bottom:6px" role="group" aria-label="Recorded chapter"> <button class="pill scn-btn" data-scn="boot" aria-pressed="true" title="Page load with ?trace: the boot script and its async tails">Boot</button> <button class="pill scn-btn" data-scn="fill" aria-pressed="false" title="Form fields entered">Fill</button> <button class="pill scn-btn" data-scn="gps" aria-pressed="false" title="Use Current Location pressed">GPS</button> <button class="pill scn-btn" data-scn="photo" aria-pressed="false" title="A JPEG with GPS EXIF chosen">Photo</button> <button class="pill scn-btn" data-scn="save" aria-pressed="false" title="Save voucher pressed">Save</button> <button class="pill scn-btn" data-scn="compile" aria-pressed="false" title="Compile drawer: Add this report">Compile</button> <button class="pill scn-btn" data-scn="export" aria-pressed="false" title="CSV, XLSX and HTML exports">Export</button> <button class="pill scn-btn" data-scn="selftest" aria-pressed="false" title="A separate ?trace&selftest page load: runSelfTest()">Self-test</button> </div> <div class="row" style="margin-bottom:8px"> <button class="pill" id="btnRun" style="flex:2 1 auto" title="Play the selected chapter as it was recorded; press again to pause (V)">► Play chapter</button> <button class="pill" id="btnSpeed" title="Playback speed (X)">Speed 1×</button> </div> <div class="ctl-lab"><span>Trace</span><span id="runStat">idle</span></div> <div class="ctl-lab"><span title="W is time: when each function first fired in the chapter">W‑axis · time</span><span id="wVal">0.00</span></div> <input type="range" id="wAxis" min="0" max="100" value="0" aria-label="W axis rotation"> </div> <div class="card legend" id="legend"></div> <button class="pill wide dock-papers card" id="papersTab" aria-expanded="false" aria-controls="papers" style="padding:10px 12px">Index · all functions</button> </div> <aside class="detail card" id="detail" role="dialog" aria-label="Node detail"> <button class="close" id="detailClose" aria-label="Close">×</button> <p class="kick" id="dKick"></p> <h2 id="dTitle"></h2> <p id="dNote"></p> <div class="acts"> <a class="go" id="dUrl" rel="noopener">Open page →</a> <a class="doi" id="dDoi" rel="noopener"></a> </div> <!-- Yo Jones - the edges stay walkable, but they move the camera instead of throwing you off the page. --> <div class="dlinks" id="dLinks"></div> </aside> <div class="papers" id="papers"> <div class="pnl"> <div class="phead"> <button class="pclose" id="papersClose" aria-label="Close index">×</button> <p class="ptitle">The Corpus</p> <p class="psub" id="papersSub">Index</p> <input class="psearch" id="papersSearch" type="search" placeholder="Find a function" aria-label="Find a function"> </div> <div class="plist" id="papersList"></div> </div> </div> <div class="rail"> <button class="rpill" id="helpBtn" aria-expanded="false" aria-controls="help">? How this works</button> <span class="rpill stat" id="statPill"></span> </div> <aside class="help card" id="help" role="dialog" aria-label="How this works"> <h3>Driving the atlas</h3> <dl> <dt>drag</dt><dd>orbit the cloud</dd> <dt>scroll / pinch</dt><dd>zoom in and out</dd> <dt>right-drag<br>2-finger drag</dt><dd>pan</dd> <dt>click</dt><dd>show a function's card and connections</dd> <dt>shift-click</dt><dd>on a legend row: solo that domain</dd> <div class="hsplit"></div> <dt>F</dt><dd>fit everything on screen</dd> <dt>R</dt><dd>reset the camera</dd> <dt>S / B / L</dt><dd>spin, collapse, label density</dd> <dt>V</dt><dd>instantiate the selected chapter; press again to pause or resume</dd> <dt>X</dt><dd>playback speed: 0.5×, 1×, 2×, 4×</dd> <dt>W-axis</dt><dd>W is time: the slider separates functions by when they first fired in the chapter</dd> <dt>I or /</dt><dd>open the index and search</dd> <dt>Esc</dt><dd>close whatever is open</dd> <p class="tier" id="perfTier"></p> </dl> </aside> <div class="ctxbar" id="ctxBar" aria-live="polite"><p id="ctxHead"></p><p id="ctxTail"></p></div> <div class="hint" id="hint">drag to orbit · pinch or scroll to zoom · Fit All shows everything</div> <script> "use strict"; /* Yo Jones - this whole block is the atlas: data, physics, projection, render, and controls. */ /* ---- palette (cool blue-slate; one warm accent reserved for focus) ---- */ const GROUPS = { boot:{c:"#5b9dff", w:0, label:"Boot & kernel"}, record:{c:"#2dd4bf", w:0, label:"Voucher record"}, media:{c:"#818cf8", w:0, label:"Photo capture"}, identity:{c:"#a78bfa", w:0, label:"QR & identifiers"}, collectors:{c:"#94a3b8", w:0, label:"Collectors & DwC fields"}, compiler:{c:"#38bdf8", w:0, label:"Compiler & export"}, data:{c:"#fb923c", w:0, label:"Store & bus"}, }; const FOCUS_C = "#ffb454"; // the single warm highlight /* ---- nodes: every function that actually ran in the two recorded sessions ---- */ // cap = what the function does, written from its body. hid = ran, but ?trace could not see it (recovered from the call stack). const N = [ {id:"_kernelMigrateLegacyIfNeeded",t:"_kernelMigrateLegacyIfNeeded",g:"boot",url:null,doi:null,yr:null,tier:null,cap:"checks the one-time flag and copies any pre-kernel localStorage data into Store"}, {id:"Store.get",t:"Store.get",g:"data",url:null,doi:null,yr:null,tier:null,cap:"finds one record by type and id"}, {id:"Store.put",t:"Store.put",g:"data",url:null,doi:null,yr:null,tier:null,cap:"inserts or replaces one record, then writes to localStorage"}, {id:"Store._save",t:"Store._save",g:"data",url:null,doi:null,yr:null,tier:null,cap:"writes the record cache to localStorage",hid:1}, {id:"noteStorageWriteRecovered",t:"noteStorageWriteRecovered",g:"data",url:null,doi:null,yr:null,tier:null,cap:"clears the storage warning if one is showing"}, {id:"initTheme",t:"initTheme",g:"boot",url:null,doi:null,yr:null,tier:null,cap:"applies the saved theme, or the system light/dark preference"}, {id:"todayISO",t:"todayISO",g:"boot",url:null,doi:null,yr:null,tier:null,cap:"builds today's local date as YYYY-MM-DD"}, {id:"lastKingdom",t:"lastKingdom",g:"record",url:null,doi:null,yr:null,tier:null,cap:"reads the last kingdom used, falling back to Plantae"}, {id:"_draftSyncFromDOM",t:"_draftSyncFromDOM",g:"record",url:null,doi:null,yr:null,tier:null,cap:"copies every f_ form field into the in-memory Draft"}, {id:"capturePristineDraft",t:"capturePristineDraft",g:"record",url:null,doi:null,yr:null,tier:null,cap:"snapshots the untouched form so later edits can be told apart"}, {id:"draftVal",t:"draftVal",g:"record",url:null,doi:null,yr:null,tier:null,cap:"reads one field from Draft, or from the page if Draft lacks it"}, {id:"generateVoucherQR",t:"generateVoucherQR",g:"identity",url:null,doi:null,yr:null,tier:null,cap:"picks this draft's Voucher ID and draws its QR code"}, {id:"uid",t:"uid",g:"identity",url:null,doi:null,yr:null,tier:null,cap:"mints a random UUID with crypto.randomUUID"}, {id:"_ensureQR",t:"_ensureQR",g:"identity",url:null,doi:null,yr:null,tier:null,cap:"loads the embedded QR library if needed, then runs the callback"}, {id:"newDraftOccurrenceUUID",t:"newDraftOccurrenceUUID",g:"identity",url:null,doi:null,yr:null,tier:null,cap:"mints a fresh occurrenceID for the new draft and shows it"}, {id:"initDwcExtraFields",t:"initDwcExtraFields",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"restores the saved extra Darwin Core fields"}, {id:"_dwcLoadSchema",t:"_dwcLoadSchema",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"reads the saved list of extra Darwin Core terms"}, {id:"initCollectors",t:"initCollectors",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"loads saved collectors and draws their rows"}, {id:"_collectorsLoad",t:"_collectorsLoad",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"reads the saved collector list"}, {id:"renderCollectorRows",t:"renderCollectorRows",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"draws a name row for each collector"}, {id:"_syncDetFromCollector",t:"_syncDetFromCollector",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"copies the primary collector into Identified by, unless it was hand-edited"}, {id:"getPrimaryCollectorFirst",t:"getPrimaryCollectorFirst",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"reads the primary collector's first name"}, {id:"_collectorsSyncFromDOM",t:"_collectorsSyncFromDOM",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"reads the collector name boxes back into memory"}, {id:"getPrimaryCollectorLast",t:"getPrimaryCollectorLast",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"reads the primary collector's last name"}, {id:"initSectionPersistence",t:"initSectionPersistence",g:"record",url:null,doi:null,yr:null,tier:null,cap:"reopens or closes each form section as last left and listens for toggles"}, {id:"checkForDraftAutosave",t:"checkForDraftAutosave",g:"record",url:null,doi:null,yr:null,tier:null,cap:"looks for an unsaved draft from an earlier visit"}, {id:"renderTripStrip",t:"renderTripStrip",g:"record",url:null,doi:null,yr:null,tier:null,cap:"updates the one-line summary of collectors, institution and country"}, {id:"getCollectorNamesList",t:"getCollectorNamesList",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"lists every collector's full name"}, {id:"fullName",t:"fullName",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"joins a first and last name"}, {id:"migrateLegacyRedirectVoucherIds",t:"migrateLegacyRedirectVoucherIds",g:"identity",url:null,doi:null,yr:null,tier:null,cap:"looks for saved vouchers whose ID is a URL, and fixes any it finds"}, {id:"load",t:"load",g:"data",url:null,doi:null,yr:null,tier:null,cap:"reads every saved voucher from Store"}, {id:"Store.all",t:"Store.all",g:"data",url:null,doi:null,yr:null,tier:null,cap:"returns a copy of every record of one type"}, {id:"checkStorageResilience",t:"checkStorageResilience",g:"data",url:null,doi:null,yr:null,tier:null,cap:"asks the browser for persistent storage and a quota estimate"}, {id:"renderSessionBadge",t:"renderSessionBadge",g:"boot",url:null,doi:null,yr:null,tier:null,cap:"writes the schema version and device tag into the header badge"}, {id:"sessionId",t:"sessionId",g:"identity",url:null,doi:null,yr:null,tier:null,cap:"reads this install's device tag, creating it on first use"}, {id:"renderTopStamp",t:"renderTopStamp",g:"boot",url:null,doi:null,yr:null,tier:null,cap:"writes the \"Opened\" time and device tag at the top"}, {id:"Bus.emit",t:"Bus.emit",g:"data",url:null,doi:null,yr:null,tier:null,cap:"calls every listener registered for this event"}, {id:"renderVouchers",t:"renderVouchers",g:"record",url:null,doi:null,yr:null,tier:null,cap:"redraws the saved voucher list",hid:1}, {id:"decorateAllWithCurrentState",t:"decorateAllWithCurrentState",g:"record",url:null,doi:null,yr:null,tier:null,cap:"adds current-state columns to every record"}, {id:"updateBulkControls",t:"updateBulkControls",g:"record",url:null,doi:null,yr:null,tier:null,cap:"updates the selected count and enables or disables the bulk buttons"}, {id:"renderPhotoGallery",t:"renderPhotoGallery",g:"media",url:null,doi:null,yr:null,tier:null,cap:"redraws the draft's photo thumbnails"}, {id:"voucherId",t:"voucherId",g:"identity",url:null,doi:null,yr:null,tier:null,cap:"returns the current Voucher ID"}, {id:"getPhotosForVoucher",t:"getPhotosForVoucher",g:"media",url:null,doi:null,yr:null,tier:null,cap:"reads this voucher's photos from IndexedDB, oldest first"}, {id:"_openPhotoDB",t:"_openPhotoDB",g:"media",url:null,doi:null,yr:null,tier:null,cap:"opens the IndexedDB photo database, once per page"}, {id:"renderAddendumProblems",t:"renderAddendumProblems",g:"record",url:null,doi:null,yr:null,tier:null,cap:"shows or hides the addenda warning",hid:1}, {id:"findLocalAddendumProblems",t:"findLocalAddendumProblems",g:"record",url:null,doi:null,yr:null,tier:null,cap:"runs the addenda problem check on this device's records"}, {id:"findAddendumProblems",t:"findAddendumProblems",g:"record",url:null,doi:null,yr:null,tier:null,cap:"looks for orphaned addenda, ambiguous determinations and flagged imports"}, {id:"updateRecentCollectorNames",t:"updateRecentCollectorNames",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"fills the name suggestion lists from saved vouchers"}, {id:"reconcileStores",t:"reconcileStores",g:"data",url:null,doi:null,yr:null,tier:null,cap:"cross-checks saved vouchers against stored photos"}, {id:"photoVoucherIdCounts",t:"photoVoucherIdCounts",g:"media",url:null,doi:null,yr:null,tier:null,cap:"counts photos per Voucher ID without reading the images"}, {id:"compiler.render",t:"compiler.render",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"redraws the compiler's counts, warnings and table"}, {id:"compiler.fingerprintDrift",t:"compiler.fingerprintDrift",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"re-hashes each record to catch edits since it was added"}, {id:"storageWriteFailed",t:"storageWriteFailed",g:"data",url:null,doi:null,yr:null,tier:null,cap:"reports whether the last storage write was refused"}, {id:"esc",t:"esc",g:"data",url:null,doi:null,yr:null,tier:null,cap:"escapes &, <, > and quotes for HTML"}, {id:"_cleanVoucherId",t:"_cleanVoucherId",g:"identity",url:null,doi:null,yr:null,tier:null,cap:"strips control characters and trims a Voucher ID"}, {id:"_armVoucherTimerOnFirstAction",t:"_armVoucherTimerOnFirstAction",g:"record",url:null,doi:null,yr:null,tier:null,cap:"starts the voucher clock on the first real edit to the draft"}, {id:"_isDraftSurface",t:"_isDraftSurface",g:"record",url:null,doi:null,yr:null,tier:null,cap:"checks whether the event happened inside the draft form"}, {id:"beginVoucherTimer",t:"beginVoucherTimer",g:"record",url:null,doi:null,yr:null,tier:null,cap:"stamps the time work on this voucher began"}, {id:"_collectorsSyncAndSave",t:"_collectorsSyncAndSave",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"a collector name was typed: sync, save, refresh the strip, update Identified by"}, {id:"_collectorsPersist",t:"_collectorsPersist",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"saves the collector list to Store"}, {id:"scheduleDraftAutosave",t:"scheduleDraftAutosave",g:"record",url:null,doi:null,yr:null,tier:null,cap:"restarts the 800 ms draft autosave timer"}, {id:"_isoDateInputFilter",t:"_isoDateInputFilter",g:"record",url:null,doi:null,yr:null,tier:null,cap:"reshapes typed digits into YYYY-MM-DD"}, {id:"showRecentSuggestions",t:"showRecentSuggestions",g:"record",url:null,doi:null,yr:null,tier:null,cap:"offers matching values from saved vouchers"}, {id:"_recentValuesFor",t:"_recentValuesFor",g:"record",url:null,doi:null,yr:null,tier:null,cap:"lists distinct recent values for one field"}, {id:"useCurrentLocation",t:"useCurrentLocation",g:"record",url:null,doi:null,yr:null,tier:null,cap:"asks the browser for a GPS fix"}, {id:"hideRecentSuggestions",t:"hideRecentSuggestions",g:"record",url:null,doi:null,yr:null,tier:null,cap:"hides the suggestion box after the field loses focus"}, {id:"hideDropdown",t:"hideDropdown",g:"record",url:null,doi:null,yr:null,tier:null,cap:"hides a dropdown box"}, {id:"_draftAutosaveNow",t:"_draftAutosaveNow",g:"record",url:null,doi:null,yr:null,tier:null,cap:"writes the draft fields and identity to localStorage"}, {id:"_draftIdentitySnapshot",t:"_draftIdentitySnapshot",g:"record",url:null,doi:null,yr:null,tier:null,cap:"gathers the draft's Voucher ID, occurrenceID, QR mode and coordinate source"}, {id:"handlePhotoInput",t:"handlePhotoInput",g:"media",url:null,doi:null,yr:null,tier:null,cap:"takes the chosen photo files and adds them to this voucher"}, {id:"addPhotosToVoucher",t:"addPhotosToVoucher",g:"media",url:null,doi:null,yr:null,tier:null,cap:"resizes, stamps and stores each photo in IndexedDB"}, {id:"resizeImageFile",t:"resizeImageFile",g:"media",url:null,doi:null,yr:null,tier:null,cap:"scales the image down and redraws it on a canvas"}, {id:"_stampPhotoId",t:"_stampPhotoId",g:"media",url:null,doi:null,yr:null,tier:null,cap:"burns the Voucher ID into the photo's bottom-left corner"}, {id:"_jpegWithVoucherExif",t:"_jpegWithVoucherExif",g:"media",url:null,doi:null,yr:null,tier:null,cap:"writes the Voucher ID into the JPEG's EXIF block"}, {id:"_stripExifApp1",t:"_stripExifApp1",g:"media",url:null,doi:null,yr:null,tier:null,cap:"removes the old EXIF block so the photo carries one ID"}, {id:"_isExifApp1",t:"_isExifApp1",g:"media",url:null,doi:null,yr:null,tier:null,cap:"checks whether a JPEG segment is an EXIF block"}, {id:"_buildVoucherExifSegment",t:"_buildVoucherExifSegment",g:"media",url:null,doi:null,yr:null,tier:null,cap:"builds an EXIF block holding the Voucher ID"}, {id:"_asciiBytes",t:"_asciiBytes",g:"media",url:null,doi:null,yr:null,tier:null,cap:"encodes text as null-terminated ASCII"}, {id:"_photoId",t:"_photoId",g:"media",url:null,doi:null,yr:null,tier:null,cap:"mints a photo id from the time and a random suffix"}, {id:"reconcileExifGPS",t:"reconcileExifGPS",g:"media",url:null,doi:null,yr:null,tier:null,cap:"compares the photo's GPS with the form coordinates"}, {id:"extractExifGPS",t:"extractExifGPS",g:"media",url:null,doi:null,yr:null,tier:null,cap:"reads the GPS tags from the JPEG"}, {id:"_parseExifGPS",t:"_parseExifGPS",g:"media",url:null,doi:null,yr:null,tier:null,cap:"decodes the EXIF GPS latitude and longitude"}, {id:"_haversineMeters",t:"_haversineMeters",g:"media",url:null,doi:null,yr:null,tier:null,cap:"measures the distance between the two positions in meters"}, {id:"_jpegHasVoucherExif",t:"_jpegHasVoucherExif",g:"media",url:null,doi:null,yr:null,tier:null,cap:"checks the stored JPEG already carries this Voucher ID"}, {id:"sanitizeFilename",t:"sanitizeFilename",g:"media",url:null,doi:null,yr:null,tier:null,cap:"makes the Voucher ID safe to use in a filename"}, {id:"saveVoucher",t:"saveVoucher",g:"record",url:null,doi:null,yr:null,tier:null,cap:"the Save button: validate, build the record, write it, reset the form"}, {id:"clearFieldHighlights",t:"clearFieldHighlights",g:"record",url:null,doi:null,yr:null,tier:null,cap:"removes red highlights from earlier failed saves"}, {id:"validateRequiredFields",t:"validateRequiredFields",g:"record",url:null,doi:null,yr:null,tier:null,cap:"checks required fields"}, {id:"_coordNoFixClean",t:"_coordNoFixClean",g:"record",url:null,doi:null,yr:null,tier:null,cap:"checks whether a GPS no-fix result excuses blank coordinates"}, {id:"validateFieldFormats",t:"validateFieldFormats",g:"record",url:null,doi:null,yr:null,tier:null,cap:"checks coordinates are decimal degrees and dates are real, past dates"}, {id:"_coordProblem",t:"_coordProblem",g:"record",url:null,doi:null,yr:null,tier:null,cap:"checks one coordinate is a number within range"}, {id:"_isRealISODate",t:"_isRealISODate",g:"record",url:null,doi:null,yr:null,tier:null,cap:"checks a YYYY-MM-DD string is a real calendar date"}, {id:"validatePhotoRequirement",t:"validatePhotoRequirement",g:"record",url:null,doi:null,yr:null,tier:null,cap:"checks a photo is attached or No photo is ticked"}, {id:"voucherFromForm",t:"voucherFromForm",g:"record",url:null,doi:null,yr:null,tier:null,cap:"builds the voucher object from the form"}, {id:"voucherOccurrenceUUID",t:"voucherOccurrenceUUID",g:"identity",url:null,doi:null,yr:null,tier:null,cap:"returns this draft's occurrenceID, minting one if missing"}, {id:"getAdditionalCollectorsFromForm",t:"getAdditionalCollectorsFromForm",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"joins every collector after the first"}, {id:"getDwcExtraObjectFromForm",t:"getDwcExtraObjectFromForm",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"reads the extra Darwin Core boxes"}, {id:"getDwcExtraFieldsFromForm",t:"getDwcExtraFieldsFromForm",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"packs the extra Darwin Core values into one string"}, {id:"packDwc",t:"packDwc",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"packs Darwin Core terms as \"term: value | ...\""}, {id:"canonicalRecord",t:"canonicalRecord",g:"record",url:null,doi:null,yr:null,tier:null,cap:"reshapes a record to the one schema every save and import shares"}, {id:"_coerceFieldValue",t:"_coerceFieldValue",g:"record",url:null,doi:null,yr:null,tier:null,cap:"types one field value by its schema entry"}, {id:"attachDwc",t:"attachDwc",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"attaches the Darwin Core object and its packed string to the record"}, {id:"parseExtraDwc",t:"parseExtraDwc",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"unpacks a record's Darwin Core terms into an object"}, {id:"fillNotRecorded",t:"fillNotRecorded",g:"record",url:null,doi:null,yr:null,tier:null,cap:"fills blank habitat and notes fields with \"Not recorded\""}, {id:"_voucherIdIsUrl",t:"_voucherIdIsUrl",g:"identity",url:null,doi:null,yr:null,tier:null,cap:"rejects a Voucher ID shaped like a web link"}, {id:"_voucherIdInUse",t:"_voucherIdInUse",g:"identity",url:null,doi:null,yr:null,tier:null,cap:"checks the new ID against every saved Voucher ID"}, {id:"_existingVoucherIdSet",t:"_existingVoucherIdSet",g:"identity",url:null,doi:null,yr:null,tier:null,cap:"collects every saved Voucher ID into a set"}, {id:"persist",t:"persist",g:"data",url:null,doi:null,yr:null,tier:null,cap:"writes the whole voucher list back through Store.replaceAll"}, {id:"Store.replaceAll",t:"Store.replaceAll",g:"data",url:null,doi:null,yr:null,tier:null,cap:"swaps in a new list for one record type and writes it"}, {id:"resetVoucherForm",t:"resetVoucherForm",g:"record",url:null,doi:null,yr:null,tier:null,cap:"clears the form for the next voucher, keeping trip-level values"}, {id:"_siteFromForm",t:"_siteFromForm",g:"record",url:null,doi:null,yr:null,tier:null,cap:"reads locality, coordinates and date for Duplicate for this site"}, {id:"rememberSite",t:"rememberSite",g:"record",url:null,doi:null,yr:null,tier:null,cap:"saves this site for Duplicate for this site"}, {id:"_siteHasContent",t:"_siteHasContent",g:"record",url:null,doi:null,yr:null,tier:null,cap:"checks the site has a locality or coordinates"}, {id:"rememberKingdom",t:"rememberKingdom",g:"record",url:null,doi:null,yr:null,tier:null,cap:"saves the kingdom for the next voucher"}, {id:"_restoreExtraCollectorValues",t:"_restoreExtraCollectorValues",g:"collectors",url:null,doi:null,yr:null,tier:null,cap:"redraws the collector rows after the form reset"}, {id:"disarmVoucherTimer",t:"disarmVoucherTimer",g:"record",url:null,doi:null,yr:null,tier:null,cap:"clears the voucher clock for the next voucher"}, {id:"clearDraftAutosave",t:"clearDraftAutosave",g:"record",url:null,doi:null,yr:null,tier:null,cap:"removes the saved draft now that the voucher is saved"}, {id:"_voucherQrMode",t:"_voucherQrMode",g:"identity",url:null,doi:null,yr:null,tier:null,cap:"sets the QR block to generated mode and updates its labels"}, {id:"decorateWithCurrentState",t:"decorateWithCurrentState",g:"record",url:null,doi:null,yr:null,tier:null,cap:"adds current determination and georeference columns from this device's addenda"}, {id:"getAddendaFor",t:"getAddendaFor",g:"record",url:null,doi:null,yr:null,tier:null,cap:"finds this voucher's addenda, oldest first"}, {id:"Store.filter",t:"Store.filter",g:"data",url:null,doi:null,yr:null,tier:null,cap:"returns the records of a type that pass a test"}, {id:"computeCurrentStateFields",t:"computeCurrentStateFields",g:"record",url:null,doi:null,yr:null,tier:null,cap:"works out the current name and coordinates from the addenda chain"}, {id:"renderAddendaPanelHTML",t:"renderAddendaPanelHTML",g:"record",url:null,doi:null,yr:null,tier:null,cap:"builds the addenda panel for a saved voucher card"}, {id:"renderCardPhotos",t:"renderCardPhotos",g:"media",url:null,doi:null,yr:null,tier:null,cap:"draws the photo strip on a saved voucher card"}, {id:"Store.lastWriteOk",t:"Store.lastWriteOk",g:"data",url:null,doi:null,yr:null,tier:null,cap:"confirms the last write reached localStorage before saying \"Saved\""}, {id:"_boolTrue",t:"_boolTrue",g:"data",url:null,doi:null,yr:null,tier:null,cap:"reads true, \"true\", 1 or \"1\" as yes"}, {id:"compiler.addThisreport",t:"compiler.addThisreport",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"Add this report: pulls this device's vouchers and photos into the compiler"}, {id:"recordsWithPhotoManifest",t:"recordsWithPhotoManifest",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"attaches a photo list to each record"}, {id:"compiler.canonicalIngest",t:"compiler.mergeRecords",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"merges records in by occurrenceID, skipping duplicates (source name mergeRecords)"}, {id:"parseAddendaField",t:"parseAddendaField",g:"record",url:null,doi:null,yr:null,tier:null,cap:"reads a record's addenda chain"}, {id:"_recordIntegrityIssues",t:"_recordIntegrityIssues",g:"record",url:null,doi:null,yr:null,tier:null,cap:"checks an incoming record's coordinates and dates"}, {id:"_malformedAddendaCell",t:"_malformedAddendaCell",g:"record",url:null,doi:null,yr:null,tier:null,cap:"checks whether an addenda_json cell is unreadable"}, {id:"_uniqueList",t:"_uniqueList",g:"data",url:null,doi:null,yr:null,tier:null,cap:"de-duplicates and sorts a list"}, {id:"_joinImportIssues",t:"_joinImportIssues",g:"record",url:null,doi:null,yr:null,tier:null,cap:"merges import problems into one list"}, {id:"compiler.recordKey",t:"compiler.recordKey",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"keys a record by occurrenceID, or by Voucher ID"}, {id:"_recordFingerprint",t:"_recordFingerprint",g:"record",url:null,doi:null,yr:null,tier:null,cap:"hashes a record's values (FNV-1a) to detect later changes"}, {id:"decorateAllWithNestedAddenda",t:"decorateAllWithNestedAddenda",g:"record",url:null,doi:null,yr:null,tier:null,cap:"adds current-state columns to every compiled record"}, {id:"decorateWithNestedAddenda",t:"decorateWithNestedAddenda",g:"record",url:null,doi:null,yr:null,tier:null,cap:"adds current-state columns from the addenda already on the record"}, {id:"compiler.exportCompilerCSV",t:"compiler.exportCompilerCSV",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"the CSV button: builds and downloads the compiled CSV"}, {id:"compiler.cleanRecords",t:"compiler.cleanRecords",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"copies the compiled records without internal fields"}, {id:"compiler.withCompileBatchId",t:"compiler.withCompileBatchId",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"stamps each copy with this compile batch's UUID"}, {id:"toCSV",t:"toCSV",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"writes the header and rows as CSV"}, {id:"exportFieldDefs",t:"exportFieldDefs",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"lists the export columns, one per Darwin Core term"}, {id:"dwcColumnsFor",t:"dwcColumnsFor",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"finds which Darwin Core terms need their own columns"}, {id:"flattenAllDwc",t:"flattenAllDwc",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"spreads Darwin Core terms into columns for every record"}, {id:"flattenDwc",t:"flattenDwc",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"spreads one record's Darwin Core terms into columns"}, {id:"csvEscape",t:"csvEscape",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"guards and quotes one CSV cell"}, {id:"_csvFormulaSafe",t:"_csvFormulaSafe",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"prefixes an apostrophe to values a spreadsheet would run as formulas"}, {id:"compiler.exportCompilerXLSX",t:"compiler.exportCompilerXLSX",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"the XLSX button: builds and downloads the compiled workbook"}, {id:"buildXLSX",t:"buildXLSX",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"builds the workbook XML with one QR image per row"}, {id:"colLetter",t:"colLetter",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"turns a column number into its letter"}, {id:"xmlEsc",t:"xmlEsc",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"escapes text for XML"}, {id:"makeCardQR",t:"makeCardQR",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"draws a voucher's QR code as a PNG"}, {id:"base64ToBytes",t:"base64ToBytes",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"decodes the PNG from base64"}, {id:"strToBytes",t:"strToBytes",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"encodes text as UTF-8 bytes"}, {id:"makeZip",t:"makeZip",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"packs the workbook parts into an uncompressed ZIP"}, {id:"crc32",t:"crc32",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"computes a file's CRC-32 checksum for the ZIP"}, {id:"_u32",t:"_u32",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"writes a 4-byte little-endian number"}, {id:"_u16",t:"_u16",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"writes a 2-byte little-endian number"}, {id:"compiler.exportCompilerHTML",t:"compiler.exportCompilerHTML",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"the HTML button: builds and downloads the compiled table"}, {id:"plainDoc",t:"plainDoc",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"wraps the table in a printable HTML page"}, {id:"runSelfTest",t:"runSelfTest",g:"boot",url:null,doi:null,yr:null,tier:null,cap:"round-trips a fixture voucher through Store, every exporter and the reconciler"}, {id:"parseCSV",t:"parseCSV",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"parses the CSV text back into rows"}, {id:"csvUnescapeFormulaGuard",t:"csvUnescapeFormulaGuard",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"removes the formula-guard apostrophe on import"}, {id:"mapTableForSelfTest",t:"mapTableForSelfTest",g:"compiler",url:null,doi:null,yr:null,tier:null,cap:"maps CSV columns back to record fields"}, {id:"createAddendum",t:"createAddendum",g:"record",url:null,doi:null,yr:null,tier:null,cap:"saves an addendum; a new current determination supersedes this device's earlier one"}, {id:"_addendaBadgeHTML",t:"_addendaBadgeHTML",g:"record",url:null,doi:null,yr:null,tier:null,cap:"builds the device and current-determination badges for an addendum"}, {id:"_addendumSummaryHTML",t:"_addendumSummaryHTML",g:"record",url:null,doi:null,yr:null,tier:null,cap:"builds the one-line summary of an addendum"}, {id:"_normalizeAddendum",t:"_normalizeAddendum",g:"record",url:null,doi:null,yr:null,tier:null,cap:"types an addendum's current-determination flag"}, {id:"Store.remove",t:"Store.remove",g:"data",url:null,doi:null,yr:null,tier:null,cap:"deletes one record by id and writes the change"}, {id:"_shortIdList",t:"_shortIdList",g:"data",url:null,doi:null,yr:null,tier:null,cap:"shortens a list of IDs for a warning message"}, {id:"deletePhotosForVoucher",t:"deletePhotosForVoucher",g:"media",url:null,doi:null,yr:null,tier:null,cap:"deletes a voucher's photos from IndexedDB"}, ]; /* ---- edges: caller -> callee pairs observed at runtime ---- */ // tp: call = synchronous call seen by ?trace; resume = call made after an await; hidden = through a function ?trace could not see const E = [ {s:"_kernelMigrateLegacyIfNeeded",t:"Store.get",tp:"call"}, {s:"_kernelMigrateLegacyIfNeeded",t:"Store.put",tp:"call"}, {s:"Store.put",t:"Store._save",tp:"hidden"}, {s:"Store._save",t:"noteStorageWriteRecovered",tp:"call"}, {s:"initTheme",t:"Store.get",tp:"call"}, {s:"lastKingdom",t:"Store.get",tp:"call"}, {s:"capturePristineDraft",t:"draftVal",tp:"call"}, {s:"generateVoucherQR",t:"uid",tp:"call"}, {s:"generateVoucherQR",t:"_ensureQR",tp:"call"}, {s:"newDraftOccurrenceUUID",t:"uid",tp:"call"}, {s:"initDwcExtraFields",t:"_dwcLoadSchema",tp:"call"}, {s:"_dwcLoadSchema",t:"Store.get",tp:"call"}, {s:"initCollectors",t:"_collectorsLoad",tp:"call"}, {s:"_collectorsLoad",t:"Store.get",tp:"call"}, {s:"initCollectors",t:"renderCollectorRows",tp:"call"}, {s:"initCollectors",t:"_syncDetFromCollector",tp:"call"}, {s:"_syncDetFromCollector",t:"getPrimaryCollectorFirst",tp:"call"}, {s:"getPrimaryCollectorFirst",t:"_collectorsSyncFromDOM",tp:"call"}, {s:"_syncDetFromCollector",t:"getPrimaryCollectorLast",tp:"call"}, {s:"getPrimaryCollectorLast",t:"_collectorsSyncFromDOM",tp:"call"}, {s:"_syncDetFromCollector",t:"_draftSyncFromDOM",tp:"call"}, {s:"initSectionPersistence",t:"Store.get",tp:"call"}, {s:"renderTripStrip",t:"getCollectorNamesList",tp:"call"}, {s:"getCollectorNamesList",t:"_collectorsSyncFromDOM",tp:"call"}, {s:"getCollectorNamesList",t:"fullName",tp:"call"}, {s:"migrateLegacyRedirectVoucherIds",t:"load",tp:"call"}, {s:"load",t:"Store.all",tp:"call"}, {s:"renderSessionBadge",t:"sessionId",tp:"call"}, {s:"sessionId",t:"Store.get",tp:"call"}, {s:"sessionId",t:"uid",tp:"call"}, {s:"sessionId",t:"Store.put",tp:"call"}, {s:"renderTopStamp",t:"sessionId",tp:"call"}, {s:"Bus.emit",t:"renderVouchers",tp:"hidden"}, {s:"renderVouchers",t:"load",tp:"call"}, {s:"renderVouchers",t:"decorateAllWithCurrentState",tp:"call"}, {s:"renderVouchers",t:"updateBulkControls",tp:"call"}, {s:"Bus.emit",t:"renderPhotoGallery",tp:"hidden"}, {s:"renderPhotoGallery",t:"voucherId",tp:"call"}, {s:"renderPhotoGallery",t:"getPhotosForVoucher",tp:"call"}, {s:"getPhotosForVoucher",t:"_openPhotoDB",tp:"call"}, {s:"Bus.emit",t:"renderAddendumProblems",tp:"hidden"}, {s:"renderAddendumProblems",t:"findLocalAddendumProblems",tp:"call"}, {s:"findLocalAddendumProblems",t:"load",tp:"call"}, {s:"findLocalAddendumProblems",t:"Store.all",tp:"call"}, {s:"findLocalAddendumProblems",t:"findAddendumProblems",tp:"call"}, {s:"updateRecentCollectorNames",t:"load",tp:"call"}, {s:"reconcileStores",t:"photoVoucherIdCounts",tp:"call"}, {s:"photoVoucherIdCounts",t:"_openPhotoDB",tp:"call"}, {s:"compiler.render",t:"findAddendumProblems",tp:"call"}, {s:"compiler.render",t:"compiler.fingerprintDrift",tp:"call"}, {s:"checkStorageResilience",t:"storageWriteFailed",tp:"call"}, {s:"checkStorageResilience",t:"esc",tp:"call"}, {s:"reconcileStores",t:"load",tp:"call"}, {s:"reconcileStores",t:"voucherId",tp:"call"}, {s:"reconcileStores",t:"_cleanVoucherId",tp:"call"}, {s:"reconcileStores",t:"storageWriteFailed",tp:"call"}, {s:"_armVoucherTimerOnFirstAction",t:"_isDraftSurface",tp:"call"}, {s:"_armVoucherTimerOnFirstAction",t:"beginVoucherTimer",tp:"call"}, {s:"_collectorsSyncAndSave",t:"_collectorsSyncFromDOM",tp:"call"}, {s:"_collectorsSyncAndSave",t:"_collectorsPersist",tp:"call"}, {s:"_collectorsPersist",t:"Store.put",tp:"call"}, {s:"_collectorsSyncAndSave",t:"renderTripStrip",tp:"call"}, {s:"_collectorsSyncAndSave",t:"_syncDetFromCollector",tp:"call"}, {s:"showRecentSuggestions",t:"_recentValuesFor",tp:"call"}, {s:"_recentValuesFor",t:"load",tp:"call"}, {s:"hideRecentSuggestions",t:"hideDropdown",tp:"call"}, {s:"_draftAutosaveNow",t:"draftVal",tp:"call"}, {s:"_draftAutosaveNow",t:"_draftIdentitySnapshot",tp:"call"}, {s:"handlePhotoInput",t:"voucherId",tp:"call"}, {s:"handlePhotoInput",t:"addPhotosToVoucher",tp:"call"}, {s:"addPhotosToVoucher",t:"_openPhotoDB",tp:"call"}, {s:"addPhotosToVoucher",t:"resizeImageFile",tp:"call"}, {s:"resizeImageFile",t:"_stampPhotoId",tp:"call"}, {s:"addPhotosToVoucher",t:"_jpegWithVoucherExif",tp:"call"}, {s:"_jpegWithVoucherExif",t:"_stripExifApp1",tp:"call"}, {s:"_stripExifApp1",t:"_isExifApp1",tp:"call"}, {s:"_jpegWithVoucherExif",t:"_buildVoucherExifSegment",tp:"call"}, {s:"_buildVoucherExifSegment",t:"_asciiBytes",tp:"call"}, {s:"addPhotosToVoucher",t:"_photoId",tp:"call"}, {s:"handlePhotoInput",t:"renderPhotoGallery",tp:"call"}, {s:"handlePhotoInput",t:"reconcileExifGPS",tp:"call"}, {s:"reconcileExifGPS",t:"extractExifGPS",tp:"call"}, {s:"extractExifGPS",t:"_parseExifGPS",tp:"call"}, {s:"reconcileExifGPS",t:"_haversineMeters",tp:"call"}, {s:"reconcileExifGPS",t:"esc",tp:"call"}, {s:"getPhotosForVoucher",t:"_jpegHasVoucherExif",tp:"call"}, {s:"renderPhotoGallery",t:"sanitizeFilename",tp:"call"}, {s:"renderPhotoGallery",t:"esc",tp:"call"}, {s:"saveVoucher",t:"clearFieldHighlights",tp:"call"}, {s:"saveVoucher",t:"validateRequiredFields",tp:"call"}, {s:"validateRequiredFields",t:"_collectorsSyncFromDOM",tp:"call"}, {s:"validateRequiredFields",t:"_coordNoFixClean",tp:"call"}, {s:"validateRequiredFields",t:"draftVal",tp:"call"}, {s:"validateRequiredFields",t:"validateFieldFormats",tp:"call"}, {s:"validateFieldFormats",t:"draftVal",tp:"call"}, {s:"validateFieldFormats",t:"_coordProblem",tp:"call"}, {s:"validateFieldFormats",t:"_isRealISODate",tp:"call"}, {s:"validateFieldFormats",t:"todayISO",tp:"call"}, {s:"saveVoucher",t:"voucherId",tp:"call"}, {s:"saveVoucher",t:"validatePhotoRequirement",tp:"call"}, {s:"validatePhotoRequirement",t:"getPhotosForVoucher",tp:"call"}, {s:"saveVoucher",t:"voucherFromForm",tp:"call"}, {s:"voucherFromForm",t:"_coordNoFixClean",tp:"call"}, {s:"voucherFromForm",t:"voucherId",tp:"call"}, {s:"voucherFromForm",t:"voucherOccurrenceUUID",tp:"call"}, {s:"voucherFromForm",t:"draftVal",tp:"call"}, {s:"voucherFromForm",t:"getPrimaryCollectorFirst",tp:"call"}, {s:"voucherFromForm",t:"getPrimaryCollectorLast",tp:"call"}, {s:"voucherFromForm",t:"getAdditionalCollectorsFromForm",tp:"call"}, {s:"getAdditionalCollectorsFromForm",t:"getCollectorNamesList",tp:"call"}, {s:"voucherFromForm",t:"getDwcExtraObjectFromForm",tp:"call"}, {s:"voucherFromForm",t:"getDwcExtraFieldsFromForm",tp:"call"}, {s:"getDwcExtraFieldsFromForm",t:"getDwcExtraObjectFromForm",tp:"call"}, {s:"getDwcExtraFieldsFromForm",t:"packDwc",tp:"call"}, {s:"voucherFromForm",t:"sessionId",tp:"call"}, {s:"saveVoucher",t:"canonicalRecord",tp:"call"}, {s:"canonicalRecord",t:"_coerceFieldValue",tp:"call"}, {s:"canonicalRecord",t:"attachDwc",tp:"call"}, {s:"attachDwc",t:"parseExtraDwc",tp:"call"}, {s:"attachDwc",t:"packDwc",tp:"call"}, {s:"saveVoucher",t:"fillNotRecorded",tp:"call"}, {s:"saveVoucher",t:"_cleanVoucherId",tp:"call"}, {s:"saveVoucher",t:"_voucherIdIsUrl",tp:"call"}, {s:"_voucherIdIsUrl",t:"_cleanVoucherId",tp:"call"}, {s:"saveVoucher",t:"load",tp:"call"}, {s:"saveVoucher",t:"_voucherIdInUse",tp:"call"}, {s:"_voucherIdInUse",t:"_existingVoucherIdSet",tp:"call"}, {s:"_voucherIdInUse",t:"_cleanVoucherId",tp:"call"}, {s:"saveVoucher",t:"persist",tp:"call"}, {s:"persist",t:"Store.replaceAll",tp:"call"}, {s:"Store.replaceAll",t:"Store._save",tp:"hidden"}, {s:"saveVoucher",t:"resetVoucherForm",tp:"call"}, {s:"resetVoucherForm",t:"_siteFromForm",tp:"call"}, {s:"resetVoucherForm",t:"rememberSite",tp:"call"}, {s:"rememberSite",t:"_siteHasContent",tp:"call"}, {s:"rememberSite",t:"Store.put",tp:"call"}, {s:"resetVoucherForm",t:"rememberKingdom",tp:"call"}, {s:"rememberKingdom",t:"Store.put",tp:"call"}, {s:"resetVoucherForm",t:"todayISO",tp:"call"}, {s:"resetVoucherForm",t:"_restoreExtraCollectorValues",tp:"call"}, {s:"_restoreExtraCollectorValues",t:"renderCollectorRows",tp:"call"}, {s:"resetVoucherForm",t:"_syncDetFromCollector",tp:"call"}, {s:"resetVoucherForm",t:"clearFieldHighlights",tp:"call"}, {s:"resetVoucherForm",t:"disarmVoucherTimer",tp:"call"}, {s:"resetVoucherForm",t:"clearDraftAutosave",tp:"call"}, {s:"resetVoucherForm",t:"_draftSyncFromDOM",tp:"call"}, {s:"resetVoucherForm",t:"capturePristineDraft",tp:"call"}, {s:"resetVoucherForm",t:"renderTripStrip",tp:"call"}, {s:"saveVoucher",t:"generateVoucherQR",tp:"call"}, {s:"saveVoucher",t:"_voucherQrMode",tp:"call"}, {s:"saveVoucher",t:"newDraftOccurrenceUUID",tp:"call"}, {s:"saveVoucher",t:"Bus.emit",tp:"call"}, {s:"decorateAllWithCurrentState",t:"decorateWithCurrentState",tp:"call"}, {s:"decorateWithCurrentState",t:"getAddendaFor",tp:"call"}, {s:"getAddendaFor",t:"Store.filter",tp:"call"}, {s:"decorateWithCurrentState",t:"computeCurrentStateFields",tp:"call"}, {s:"renderVouchers",t:"esc",tp:"call"}, {s:"renderVouchers",t:"renderAddendaPanelHTML",tp:"call"}, {s:"renderAddendaPanelHTML",t:"getAddendaFor",tp:"call"}, {s:"renderVouchers",t:"renderCardPhotos",tp:"call"}, {s:"renderCardPhotos",t:"getPhotosForVoucher",tp:"call"}, {s:"renderVouchers",t:"updateRecentCollectorNames",tp:"call"}, {s:"updateRecentCollectorNames",t:"esc",tp:"call"}, {s:"saveVoucher",t:"reconcileStores",tp:"call"}, {s:"saveVoucher",t:"Store.lastWriteOk",tp:"call"}, {s:"renderCardPhotos",t:"sanitizeFilename",tp:"call"}, {s:"renderCardPhotos",t:"esc",tp:"call"}, {s:"reconcileStores",t:"_boolTrue",tp:"call"}, {s:"compiler.addThisreport",t:"load",tp:"call"}, {s:"compiler.addThisreport",t:"recordsWithPhotoManifest",tp:"call"}, {s:"recordsWithPhotoManifest",t:"decorateAllWithCurrentState",tp:"call"}, {s:"recordsWithPhotoManifest",t:"getPhotosForVoucher",tp:"call"}, {s:"recordsWithPhotoManifest",t:"sanitizeFilename",tp:"call"}, {s:"compiler.addThisreport",t:"compiler.canonicalIngest",tp:"call"}, {s:"compiler.canonicalIngest",t:"canonicalRecord",tp:"call"}, {s:"canonicalRecord",t:"parseAddendaField",tp:"call"}, {s:"canonicalRecord",t:"_recordIntegrityIssues",tp:"call"}, {s:"_recordIntegrityIssues",t:"_coordProblem",tp:"call"}, {s:"_recordIntegrityIssues",t:"_isRealISODate",tp:"call"}, {s:"_recordIntegrityIssues",t:"todayISO",tp:"call"}, {s:"_recordIntegrityIssues",t:"_cleanVoucherId",tp:"call"}, {s:"_recordIntegrityIssues",t:"_malformedAddendaCell",tp:"call"}, {s:"_recordIntegrityIssues",t:"_uniqueList",tp:"call"}, {s:"canonicalRecord",t:"_joinImportIssues",tp:"call"}, {s:"_joinImportIssues",t:"_uniqueList",tp:"call"}, {s:"compiler.canonicalIngest",t:"compiler.recordKey",tp:"call"}, {s:"compiler.canonicalIngest",t:"_recordFingerprint",tp:"call"}, {s:"compiler.canonicalIngest",t:"compiler.render",tp:"call"}, {s:"compiler.render",t:"fullName",tp:"call"}, {s:"compiler.fingerprintDrift",t:"_recordFingerprint",tp:"call"}, {s:"compiler.render",t:"decorateAllWithNestedAddenda",tp:"call"}, {s:"decorateAllWithNestedAddenda",t:"decorateWithNestedAddenda",tp:"call"}, {s:"decorateWithNestedAddenda",t:"computeCurrentStateFields",tp:"call"}, {s:"compiler.render",t:"esc",tp:"call"}, {s:"compiler.addThisreport",t:"getPhotosForVoucher",tp:"call"}, {s:"compiler.addThisreport",t:"sanitizeFilename",tp:"call"}, {s:"compiler.addThisreport",t:"compiler.render",tp:"call"}, {s:"compiler.exportCompilerCSV",t:"compiler.cleanRecords",tp:"call"}, {s:"compiler.exportCompilerCSV",t:"compiler.withCompileBatchId",tp:"call"}, {s:"compiler.exportCompilerCSV",t:"decorateAllWithNestedAddenda",tp:"call"}, {s:"compiler.exportCompilerCSV",t:"toCSV",tp:"call"}, {s:"toCSV",t:"exportFieldDefs",tp:"call"}, {s:"exportFieldDefs",t:"dwcColumnsFor",tp:"call"}, {s:"dwcColumnsFor",t:"parseExtraDwc",tp:"call"}, {s:"toCSV",t:"flattenAllDwc",tp:"call"}, {s:"flattenAllDwc",t:"flattenDwc",tp:"call"}, {s:"flattenDwc",t:"parseExtraDwc",tp:"call"}, {s:"toCSV",t:"csvEscape",tp:"call"}, {s:"csvEscape",t:"_csvFormulaSafe",tp:"call"}, {s:"compiler.exportCompilerCSV",t:"todayISO",tp:"call"}, {s:"compiler.exportCompilerCSV",t:"_armVoucherTimerOnFirstAction",tp:"call"}, {s:"compiler.exportCompilerXLSX",t:"compiler.cleanRecords",tp:"call"}, {s:"compiler.exportCompilerXLSX",t:"compiler.withCompileBatchId",tp:"call"}, {s:"compiler.exportCompilerXLSX",t:"decorateAllWithNestedAddenda",tp:"call"}, {s:"compiler.exportCompilerXLSX",t:"_ensureQR",tp:"call"}, {s:"_ensureQR",t:"buildXLSX",tp:"call"}, {s:"buildXLSX",t:"exportFieldDefs",tp:"call"}, {s:"buildXLSX",t:"flattenAllDwc",tp:"call"}, {s:"buildXLSX",t:"colLetter",tp:"call"}, {s:"buildXLSX",t:"xmlEsc",tp:"call"}, {s:"buildXLSX",t:"makeCardQR",tp:"call"}, {s:"buildXLSX",t:"base64ToBytes",tp:"call"}, {s:"buildXLSX",t:"strToBytes",tp:"call"}, {s:"buildXLSX",t:"makeZip",tp:"call"}, {s:"makeZip",t:"strToBytes",tp:"call"}, {s:"makeZip",t:"crc32",tp:"call"}, {s:"makeZip",t:"_u32",tp:"call"}, {s:"makeZip",t:"_u16",tp:"call"}, {s:"_ensureQR",t:"todayISO",tp:"call"}, {s:"_ensureQR",t:"_armVoucherTimerOnFirstAction",tp:"call"}, {s:"compiler.exportCompilerHTML",t:"compiler.cleanRecords",tp:"call"}, {s:"compiler.exportCompilerHTML",t:"compiler.withCompileBatchId",tp:"call"}, {s:"compiler.exportCompilerHTML",t:"decorateAllWithNestedAddenda",tp:"call"}, {s:"compiler.exportCompilerHTML",t:"exportFieldDefs",tp:"call"}, {s:"compiler.exportCompilerHTML",t:"esc",tp:"call"}, {s:"compiler.exportCompilerHTML",t:"flattenAllDwc",tp:"call"}, {s:"compiler.exportCompilerHTML",t:"plainDoc",tp:"call"}, {s:"plainDoc",t:"esc",tp:"call"}, {s:"compiler.exportCompilerHTML",t:"todayISO",tp:"call"}, {s:"compiler.exportCompilerHTML",t:"_armVoucherTimerOnFirstAction",tp:"call"}, {s:"runSelfTest",t:"canonicalRecord",tp:"call"}, {s:"runSelfTest",t:"_recordFingerprint",tp:"call"}, {s:"runSelfTest",t:"Store.put",tp:"call"}, {s:"runSelfTest",t:"load",tp:"call"}, {s:"runSelfTest",t:"decorateAllWithCurrentState",tp:"call"}, {s:"runSelfTest",t:"exportFieldDefs",tp:"call"}, {s:"runSelfTest",t:"toCSV",tp:"call"}, {s:"runSelfTest",t:"parseCSV",tp:"call"}, {s:"runSelfTest",t:"csvUnescapeFormulaGuard",tp:"call"}, {s:"runSelfTest",t:"mapTableForSelfTest",tp:"call"}, {s:"mapTableForSelfTest",t:"csvUnescapeFormulaGuard",tp:"call"}, {s:"runSelfTest",t:"buildXLSX",tp:"call"}, {s:"runSelfTest",t:"plainDoc",tp:"call"}, {s:"runSelfTest",t:"createAddendum",tp:"call"}, {s:"createAddendum",t:"uid",tp:"call"}, {s:"createAddendum",t:"sessionId",tp:"call"}, {s:"createAddendum",t:"_boolTrue",tp:"call"}, {s:"createAddendum",t:"Store.filter",tp:"call"}, {s:"createAddendum",t:"Store.put",tp:"call"}, {s:"createAddendum",t:"Bus.emit",tp:"call"}, {s:"computeCurrentStateFields",t:"_boolTrue",tp:"call"}, {s:"renderAddendaPanelHTML",t:"fullName",tp:"call"}, {s:"renderAddendaPanelHTML",t:"esc",tp:"call"}, {s:"renderAddendaPanelHTML",t:"_addendaBadgeHTML",tp:"call"}, {s:"_addendaBadgeHTML",t:"sessionId",tp:"call"}, {s:"_addendaBadgeHTML",t:"esc",tp:"call"}, {s:"renderAddendaPanelHTML",t:"_addendumSummaryHTML",tp:"call"}, {s:"_addendumSummaryHTML",t:"fullName",tp:"call"}, {s:"_addendumSummaryHTML",t:"esc",tp:"call"}, {s:"findAddendumProblems",t:"_boolTrue",tp:"call"}, {s:"parseAddendaField",t:"_normalizeAddendum",tp:"call"}, {s:"_normalizeAddendum",t:"_boolTrue",tp:"call"}, {s:"runSelfTest",t:"Store.remove",tp:"call"}, {s:"Store.remove",t:"Store._save",tp:"hidden"}, {s:"runSelfTest",t:"reconcileStores",tp:"call"}, {s:"runSelfTest",t:"addPhotosToVoucher",tp:"call"}, {s:"reconcileStores",t:"_shortIdList",tp:"call"}, {s:"_shortIdList",t:"_uniqueList",tp:"call"}, {s:"reconcileStores",t:"esc",tp:"call"}, {s:"runSelfTest",t:"deletePhotosForVoucher",tp:"call"}, {s:"deletePhotosForVoucher",t:"getPhotosForVoucher",tp:"call"}, {s:"getPhotosForVoucher",t:"_jpegWithVoucherExif",tp:"call"}, {s:"deletePhotosForVoucher",t:"_openPhotoDB",tp:"call"}, ]; /* ================= build graph ================= */ const idx = new Map(N.map((n,i)=>[n.id,i])); N.forEach(n=>{ // Yo Jones - builds the info-panel note from the real bucket this function belongs to. n.n = n.cap+". "+(n.hid ? "Ran, but ?trace cannot see it: recovered from the call stack." : "Seen by ?trace in the recorded run."); n.deg=0; }); // Yo Jones - builds the edge list and shouts about any edge pointing at a node that no longer exists. const dangling=new Set(); const edges = E.map(e=>{ const a = Array.isArray(e) ? e[0] : e.s; const b = Array.isArray(e) ? e[1] : e.t; const s=idx.get(a), t=idx.get(b); if(s==null) dangling.add(a); if(t==null) dangling.add(b); if(s==null || t==null) return null; N[s].deg++; N[t].deg++; return {s,t,tp:Array.isArray(e)?null:e.tp}; }).filter(Boolean); if(dangling.size){ // These edges survived a node that got removed somewhere along the lineage. console.warn("Atlas: edges point at "+dangling.size+" missing node id(s): "+[...dangling].join(", ")); } const NL=N.length; // initial positions on a sphere shell, w seeded by group N.forEach(n=>{ const u=Math.random()*2-1, th=Math.random()*Math.PI*2, r=Math.cbrt(Math.random())*2.4; const s=Math.sqrt(1-u*u); n.x=r*s*Math.cos(th); n.y=r*s*Math.sin(th); n.z=r*u; n.vx=n.vy=n.vz=0; n.w=GROUPS[n.g].w + (Math.random()-0.5)*0.12; n.rad=(3.2+Math.sqrt(n.deg)*1.7)*1.33; n.col=GROUPS[n.g].c; // Stable little reveal phase from the ID. No random reshuffle between frames. let mh=2166136261; for(let k=0;k<n.id.length;k++){ mh^=n.id.charCodeAt(k); mh=Math.imul(mh,16777619); } n.magic=((mh>>>0)%1000)/1000; n.hay=(n.t+" "+GROUPS[n.g].label+" "+n.cap).toLowerCase(); }); // adjacency for hover neighborhoods const adj = N.map(()=>new Set()); edges.forEach(e=>{ adj[e.s].add(e.t); adj[e.t].add(e.s); }); /* ================= recorded playback ================= */ // Yo Jones - two real sessions of ironfist501-thumbnav.html, recorded 2026-09-16, headless Chromium (Playwright), ?trace with ordered enter/exit logging added to _traceWrap. // Item: [kind, from, to, step, t_ms, took_ms, repeats, block, repeat_span_ms, flags, trigger] // kind 0 call, 1 return, 2 root (started by the event loop), 3 resume after an await, 4 root finished. // Node fields are indexes into N. Consecutive identical calls (same callee, same subtree) are folded into one step with a count. const PB_WHY = ["boot script, line 5389","boot script, line 5390","boot script, line 5391","boot script, line 5392","boot script, line 5393","boot script, line 5394","boot script, line 5402","boot script, line 5403","boot script, line 5404","boot script, line 5405","boot script, line 5406","boot script, line 5407","boot script, line 5408","boot script, line 5412","boot: after the legacy-ID migration settles","boot script, line 5429","boot script, line 5430","section toggle listener (set up by initSectionPersistence)","compiler drawer script runs","resumes after an await","document input, click or change listener","inline oninput handler","form input listener (set up at boot)","browser event","inline onfocus handler","inline onclick handler","GPS fix arrives (callback in useCurrentLocation)","timer set by an inline handler","800 ms autosave timer fires","inline onchange handler","form submit (Save voucher)","click: Add this report","click: CSV","click: XLSX","click: HTML","400 ms self-test timer (?selftest)"]; const PB_CHAPTERS = [ {key:"boot",label:"Boot",desc:"Page load with ?trace. The boot script runs top to bottom, then its async tails finish.",steps:88,real:119, q:[[2,-1,0,1,115.9,1.4],[0,0,1,2,116.1,0.5],[1,1,0],[0,0,2,3,117.1,0.2],[0,2,3,4,117.3,null,0,0,null,2],[0,3,4,5,117.3],[1,4,3],[1,3,2],[1,2,0],[4,0,-1],[2,-1,5,6,117.3,0,0,0,null,0,1],[0,5,1,7,117.3],[1,1,5],[4,5,-1],[2,-1,6,8,117.4,0,0,0,null,0,2],[4,6,-1],[2,-1,7,9,117.6,0,0,0,null,0,3],[0,7,1,10,117.6],[1,1,7],[4,7,-1],[2,-1,8,11,117.7,0.2,0,0,null,0,4],[4,8,-1],[2,-1,9,12,117.9,0.1,0,0,null,0,5],[0,9,10,13,117.9,null,24,0,0.1],[1,10,9],[4,9,-1],[2,-1,11,14,118,16,0,0,null,0,6],[0,11,12,15,118.1,0.1],[1,12,11],[0,11,13,16,118.2,15.8],[1,13,11],[4,11,-1],[2,-1,14,17,134,0,0,0,null,0,7],[0,14,12,18,134],[1,12,14],[4,14,-1],[2,-1,15,19,134,0.1,0,0,null,0,8],[0,15,16,20,134,0.1],[0,16,1,21,134.1],[1,1,16],[1,16,15],[4,15,-1],[2,-1,17,22,134.1,0.7,0,0,null,0,9],[0,17,18,23,134.2],[0,18,1,24,134.2],[1,1,18],[1,18,17],[0,17,19,25,134.2,0.4],[1,19,17],[0,17,20,26,134.6,0.2],[0,20,21,27,134.6,0.1],[0,21,22,28,134.7],[1,22,21],[1,21,20],[0,20,23,29,134.7],[0,23,22,30,134.7],[1,22,23],[1,23,20],[0,20,8,31,134.7,0.1],[1,8,20],[1,20,17],[4,17,-1],[2,-1,24,32,134.8,0.1,0,0,null,0,10],[0,24,1,33,134.9,null,7],[1,1,24],[4,24,-1],[2,-1,25,34,134.9,0.1,0,0,null,0,11],[4,25,-1],[2,-1,26,35,135,0,0,0,null,0,12],[0,26,27,36,135],[0,27,22,37,135],[1,22,27],[0,27,28,38,135],[1,28,27],[1,27,26],[4,26,-1],[2,-1,29,39,135,0.2,0,0,null,1,13],[0,29,30,40,135.2],[0,30,31,41,135.2],[1,31,30],[1,30,29],[4,29,-1],[2,-1,32,42,135.2,0.5,0,0,null,1,14],[4,32,-1],[2,-1,33,43,135.7,0.3,0,0,null,0,15],[0,33,34,44,135.7,0.2],[0,34,1,45,135.8],[1,1,34],[0,34,12,46,135.8],[1,12,34],[0,34,2,47,135.8,0.1],[0,2,3,48,135.9,null,0,0,null,2],[0,3,4,49,135.9],[1,4,3],[1,3,2],[1,2,34],[1,34,33],[4,33,-1],[2,-1,35,50,136,17.2,0,0,null,0,16],[0,35,34,51,136],[0,34,1,52,136],[1,1,34],[1,34,35],[4,35,-1],[2,-1,36,53,153.3,1.6,0,0,null,0,14],[0,36,37,54,153.6,null,0,0,null,2],[0,37,30,55,153.6],[0,30,31,56,153.6],[1,31,30],[1,30,37],[0,37,38,57,153.6],[1,38,37],[0,37,39,58,153.7,0.2],[1,39,37],[1,37,36],[0,36,40,59,153.9,null,0,0,null,2],[0,40,41,60,153.9],[1,41,40],[0,40,42,61,153.9,0.7,0,0,null,1],[0,42,43,62,154,0.6,0,0,null,1],[1,43,42],[1,42,40],[1,40,36],[0,36,44,63,154.8,null,0,0,null,2],[0,44,45,64,154.8,0.1],[0,45,30,65,154.8],[0,30,31,66,154.8],[1,31,30],[1,30,45],[0,45,31,67,154.8],[1,31,45],[0,45,46,68,154.8,0.1],[1,46,45],[1,45,44],[1,44,36],[4,36,-1],[2,-1,47,69,154.9,0.1,0,0,null,0,14],[0,47,30,70,155],[0,30,31,71,155],[1,31,30],[1,30,47],[4,47,-1],[2,-1,48,72,155,0.3,0,0,null,1,14],[0,48,49,73,155.2,0.1,0,0,null,1],[0,49,43,74,155.3,0,0,0,null,1],[1,43,49],[1,49,48],[4,48,-1],[2,-1,2,75,167.5,null,5,0,0.3,0,17],[0,2,3,76,167.6,null,0,0,null,2],[0,3,4,77,167.6],[1,4,3],[1,3,2],[4,2,-1],[2,-1,12,78,170.5,0.1,0,0,null,0,18],[4,12,-1],[2,-1,50,79,170.7,0.4,0,0,null,0,18],[0,50,46,80,170.9],[1,46,50],[0,50,51,81,170.9],[1,51,50],[3,-1,32,0,188.6,null,0,0,null,0,19],[0,32,52,82,188.6],[1,52,32],[0,32,53,83,188.6,0.1],[1,53,32],[3,-1,48,0,189.4,null,0,0,null,0,19],[0,48,30,84,189.4],[0,30,31,85,189.4],[1,31,30],[1,30,48],[0,48,41,86,189.4],[1,41,48],[0,48,54,87,189.4,0.2],[1,54,48],[0,48,52,88,189.6],[1,52,48],[4,48,-1]]}, {key:"fill",label:"Fill",desc:"Collector, family, genus, species, date identified, institution, state, parish and locality entered, one input event per field.",steps:77,real:110, q:[[2,-1,55,1,2738.1,0.1,0,0,null,0,20],[0,55,56,2,2738.2],[1,56,55],[0,55,57,3,2738.2],[1,57,55],[4,55,-1],[2,-1,58,4,2738.3,0.6,0,0,null,0,21],[0,58,22,5,2738.3,0.1],[1,22,58],[0,58,59,6,2738.4,0.3],[0,59,2,7,2738.4,0.3],[0,2,3,8,2738.7,null,0,0,null,2],[0,3,4,9,2738.7],[1,4,3],[1,3,2],[1,2,59],[1,59,58],[0,58,26,10,2738.7,0.1],[0,26,27,11,2738.7],[0,27,22,12,2738.7],[1,22,27],[0,27,28,13,2738.7],[1,28,27],[1,27,26],[1,26,58],[0,58,20,14,2738.8,0.1],[0,20,21,15,2738.8],[0,21,22,16,2738.8],[1,22,21],[1,21,20],[0,20,23,17,2738.8],[0,23,22,18,2738.8],[1,22,23],[1,23,20],[0,20,8,19,2738.9],[1,8,20],[1,20,58],[4,58,-1],[2,-1,60,20,2739,0.1,0,0,null,0,22],[4,60,-1],[2,-1,26,21,2739.1,0,0,0,null,0,22],[0,26,27,22,2739.1],[0,27,22,23,2739.1],[1,22,27],[0,27,28,24,2739.1],[1,28,27],[1,27,26],[4,26,-1],[2,-1,55,25,3012.7,0,0,0,null,0,23],[4,55,-1],[2,-1,55,26,3015.1,0,0,0,null,0,20],[4,55,-1],[2,-1,58,27,3015.3,0.2,0,0,null,0,21],[0,58,22,28,3015.3],[1,22,58],[0,58,59,29,3015.3,0.1],[0,59,2,30,3015.3,0.1],[0,2,3,31,3015.4,null,0,0,null,2],[0,3,4,32,3015.4],[1,4,3],[1,3,2],[1,2,59],[1,59,58],[0,58,26,33,3015.4],[0,26,27,34,3015.4],[0,27,22,35,3015.4],[1,22,27],[0,27,28,36,3015.4],[1,28,27],[1,27,26],[1,26,58],[0,58,20,37,3015.5],[0,20,21,38,3015.5],[0,21,22,39,3015.5],[1,22,21],[1,21,20],[0,20,23,40,3015.5],[0,23,22,41,3015.5],[1,22,23],[1,23,20],[0,20,8,42,3015.5],[1,8,20],[1,20,58],[4,58,-1],[2,-1,60,43,3015.6,null,4,4,1065.8,0,22],[4,60,-1],[2,-1,26,44,3015.6,null,4,4,1065.8,0,22],[0,26,27,45,3015.6],[0,27,22,46,3015.6],[1,22,27],[0,27,28,47,3015.6],[1,28,27],[1,27,26],[4,26,-1],[2,-1,55,48,3276.9,null,4,4,1065.8,0,23],[4,55,-1],[2,-1,55,49,3281.2,null,4,4,1065.8,0,20],[4,55,-1],[2,-1,61,50,4081.5,0.1,0,0,null,0,21],[4,61,-1],[2,-1,60,51,4081.7,null,3,4,794.4,0,22],[4,60,-1],[2,-1,26,52,4081.7,null,3,4,794.4,0,22],[0,26,27,53,4081.7,0.1],[0,27,22,54,4081.7,0.1],[1,22,27],[0,27,28,55,4081.8],[1,28,27],[1,27,26],[4,26,-1],[2,-1,55,56,4344.9,null,3,4,794.4,0,23],[4,55,-1],[2,-1,55,57,4347.1,null,3,4,794.4,0,20],[4,55,-1],[2,-1,60,58,4876.2,0,0,0,null,0,22],[4,60,-1],[2,-1,26,59,4876.2,0.2,0,0,null,0,22],[0,26,27,60,4876.2,0.1],[0,27,22,61,4876.2,0.1],[1,22,27],[0,27,28,62,4876.3],[1,28,27],[1,27,26],[4,26,-1],[2,-1,55,63,5138.6,0,0,0,null,0,23],[4,55,-1],[2,-1,62,64,5139.1,0.2,0,0,null,0,24],[0,62,63,65,5139.2,0.1],[0,63,30,66,5139.3],[0,30,31,67,5139.3],[1,31,30],[1,30,63],[1,63,62],[4,62,-1],[2,-1,55,68,5141.2,0,0,0,null,0,20],[4,55,-1],[2,-1,62,69,5141.3,0,0,0,null,0,21],[0,62,63,70,5141.3],[0,63,30,71,5141.3],[0,30,31,72,5141.3],[1,31,30],[1,30,63],[1,63,62],[4,62,-1],[2,-1,60,73,5141.3,0.1,0,0,null,0,22],[4,60,-1],[2,-1,26,74,5141.4,0.1,0,0,null,0,22],[0,26,27,75,5141.4],[0,27,22,76,5141.4],[1,22,27],[0,27,28,77,5141.4],[1,28,27],[1,27,26],[4,26,-1]]}, {key:"gps",label:"GPS",desc:"Use Current Location pressed. The browser returns 30.800057, -90.374143, accuracy 6 m.",steps:9,real:33, q:[[2,-1,55,1,5422.8,null,2,0,0.6,0,20],[4,55,-1],[2,-1,64,2,5423.5,1.8,0,0,null,0,25],[4,64,-1],[2,-1,8,3,5433.8,0.1,0,0,null,0,26],[4,8,-1],[2,-1,60,4,5434.2,0,0,0,null,0,26],[4,60,-1],[2,-1,65,5,5573.2,0.1,0,0,null,0,27],[0,65,66,6,5573.2,0.1],[1,66,65],[4,65,-1],[2,-1,67,7,6234.5,0.6,0,0,null,0,28],[0,67,10,8,6234.7,null,24,0,0.1],[1,10,67],[0,67,68,9,6234.8,0.1],[1,68,67],[4,67,-1]]}, {key:"photo",label:"Photo",desc:"One JPEG carrying GPS EXIF chosen in the photo picker.",steps:34,real:68, q:[[2,-1,55,1,6946.9,0,0,0,null,0,20],[4,55,-1],[2,-1,60,2,6946.9,0.1,0,0,null,0,22],[4,60,-1],[2,-1,26,3,6947,0,0,0,null,0,22],[0,26,27,4,6947],[0,27,22,5,6947],[1,22,27],[0,27,28,6,6947],[1,28,27],[1,27,26],[4,26,-1],[2,-1,55,7,6947,0,0,0,null,0,20],[4,55,-1],[2,-1,69,8,6947.4,0.3,0,0,null,1,29],[0,69,41,9,6947.6],[1,41,69],[0,69,70,10,6947.6,0.1,0,0,null,1],[0,70,43,11,6947.7,0,0,0,null,1],[1,43,70],[1,70,69],[3,-1,70,0,6947.7,null,0,0,null,0,19],[0,70,71,12,6947.7,0.3,0,0,null,1],[1,71,70],[3,-1,71,0,7020.9,null,0,0,null,0,19],[0,71,72,13,7020.9,3.7],[1,72,71],[3,-1,70,0,7046.7,null,0,0,null,0,19],[0,70,73,14,7046.7,0.4,0,0,null,1],[1,73,70],[3,-1,73,0,7047.8,null,0,0,null,0,19],[0,73,74,15,7047.8,0.5],[0,74,75,16,7048,null,9],[1,75,74],[1,74,73],[0,73,76,17,7048.3,0.3],[0,76,77,18,7048.4,null,3,0,0.2],[1,77,76],[1,76,73],[3,-1,70,0,7050,null,0,0,null,0,19],[0,70,78,19,7050],[1,78,70],[3,-1,69,0,7051.5,null,0,0,null,0,19],[0,69,40,20,7051.5,0,0,0,null,1],[0,40,41,21,7051.5],[1,41,40],[0,40,42,22,7051.5,0,0,0,null,1],[0,42,43,23,7051.5,0,0,0,null,1],[1,43,42],[1,42,40],[1,40,69],[0,69,79,24,7051.5,1,0,0,null,1],[0,79,80,25,7051.7,0.8,0,0,null,1],[1,80,79],[1,79,69],[3,-1,80,0,7055.2,null,0,0,null,0,19],[0,80,81,26,7055.2,0.3],[1,81,80],[3,-1,79,0,7055.5,null,0,0,null,0,19],[0,79,82,27,7055.5],[1,82,79],[0,79,53,28,7055.5],[1,53,79],[3,-1,42,0,7055.7,null,0,0,null,0,19],[0,42,83,29,7055.7,0.7,0,0,null,1],[1,83,42],[3,-1,40,0,7060.6,null,0,0,null,0,19],[0,40,84,30,7060.6,0.1],[1,84,40],[0,40,53,31,7060.7,null,2],[1,53,40],[4,40,-1],[2,-1,67,32,7747.3,0.4,0,0,null,0,28],[0,67,10,33,7747.3,null,24],[1,10,67],[0,67,68,34,7747.3,0.1],[1,68,67],[4,67,-1]]}, {key:"save",label:"Save",desc:"Save voucher pressed.",steps:141,real:277, q:[[2,-1,55,1,9973.1,0,0,0,null,0,20],[4,55,-1],[2,-1,85,2,9973.8,0.7,0,0,null,1,30],[0,85,86,3,9973.9,0.1],[1,86,85],[0,85,87,4,9974,0.5],[0,87,22,5,9974.1],[1,22,87],[0,87,88,6,9974.1,0.1],[1,88,87],[0,87,10,7,9974.2,null,18],[1,10,87],[0,87,89,8,9974.2,0.3],[0,89,10,9,9974.2,null,2,2,0.1],[1,10,89],[0,89,90,10,9974.2,null,2,2,0.1],[1,90,89],[0,89,10,11,9974.4,null,2,3,0.1],[1,10,89],[0,89,91,12,9974.4,null,2,3,0.1],[1,91,89],[0,89,6,13,9974.4,null,2,3,0.1],[1,6,89],[1,89,87],[1,87,85],[0,85,41,14,9974.5],[1,41,85],[0,85,92,15,9974.5,0,0,0,null,1],[0,92,42,16,9974.5,0,0,0,null,1],[0,42,43,17,9974.5,0,0,0,null,1],[1,43,42],[1,42,92],[1,92,85],[3,-1,42,0,9983.7,null,0,0,null,0,19],[0,42,83,18,9983.7,0.3,0,0,null,1],[1,83,42],[3,-1,85,0,9986.2,null,0,0,null,0,19],[0,85,93,19,9986.2,0.5],[0,93,88,20,9986.4],[1,88,93],[0,93,41,21,9986.4],[1,41,93],[0,93,94,22,9986.4,0.1],[1,94,93],[0,93,10,23,9986.5,null,10],[1,10,93],[0,93,21,24,9986.5,0.1],[0,21,22,25,9986.6],[1,22,21],[1,21,93],[0,93,23,26,9986.6],[0,23,22,27,9986.6],[1,22,23],[1,23,93],[0,93,10,28,9986.6,null,2],[1,10,93],[0,93,95,29,9986.6],[0,95,27,30,9986.6],[0,27,22,31,9986.6],[1,22,27],[0,27,28,32,9986.6],[1,28,27],[1,27,95],[1,95,93],[0,93,10,33,9986.6,null,5],[1,10,93],[0,93,96,34,9986.6],[1,96,93],[0,93,97,35,9986.6],[0,97,96,36,9986.6],[1,96,97],[0,97,98,37,9986.6],[1,98,97],[1,97,93],[0,93,10,38,9986.6,null,9],[1,10,93],[0,93,34,39,9986.7],[0,34,1,40,9986.7],[1,1,34],[1,34,93],[1,93,85],[0,85,99,41,9986.7,0.4],[0,99,100,42,9986.8,null,57,0,0.2],[1,100,99],[0,99,101,43,9987,0.1],[0,101,102,44,9987],[1,102,101],[0,101,98,45,9987,0.1],[1,98,101],[1,101,99],[1,99,85],[0,85,103,46,9987.1],[1,103,85],[0,85,54,47,9987.1,0.1],[1,54,85],[0,85,104,48,9987.2],[0,104,54,49,9987.2],[1,54,104],[1,104,85],[0,85,30,50,9987.2],[0,30,31,51,9987.2],[1,31,30],[1,30,85],[0,85,105,52,9987.2],[0,105,106,53,9987.2],[1,106,105],[0,105,54,54,9987.2],[1,54,105],[1,105,85],[0,85,107,55,9987.2,0.1],[0,107,108,56,9987.2,0.1],[0,108,3,57,9987.3,null,0,0,null,2],[0,3,4,58,9987.3],[1,4,3],[1,3,108],[1,108,107],[1,107,85],[0,85,109,59,9987.3,1.6],[0,109,110,60,9987.5,0.1],[1,110,109],[0,109,111,61,9987.6,0.1],[0,111,112,62,9987.6,0.1],[1,112,111],[0,111,2,63,9987.7],[0,2,3,64,9987.7,null,0,0,null,2],[0,3,4,65,9987.7],[1,4,3],[1,3,2],[1,2,111],[1,111,109],[0,109,113,66,9987.7,0.1],[0,113,2,67,9987.7,0.1],[0,2,3,68,9987.8,null,0,0,null,2],[0,3,4,69,9987.8],[1,4,3],[1,3,2],[1,2,113],[1,113,109],[0,109,6,70,9988],[1,6,109],[0,109,114,71,9988.1,0.1],[0,114,19,72,9988.1,0.1],[1,19,114],[1,114,109],[0,109,20,73,9988.2,0.2],[0,20,21,74,9988.3],[0,21,22,75,9988.3],[1,22,21],[1,21,20],[0,20,23,76,9988.3],[0,23,22,77,9988.3],[1,22,23],[1,23,20],[0,20,8,78,9988.3,0.1],[1,8,20],[1,20,109],[0,109,86,79,9988.6,0.1],[1,86,109],[0,109,115,80,9988.7],[1,115,109],[0,109,116,81,9988.7,0.1],[1,116,109],[0,109,8,82,9988.8,0.1],[1,8,109],[0,109,9,83,9988.9],[0,9,10,84,9988.9,null,24],[1,10,9],[1,9,109],[0,109,26,85,9988.9],[0,26,27,86,9988.9],[0,27,22,87,9988.9],[1,22,27],[0,27,28,88,9988.9],[1,28,27],[1,27,26],[1,26,109],[1,109,85],[0,85,11,89,9988.9,8],[0,11,12,90,9988.9],[1,12,11],[0,11,13,91,9988.9,8],[1,13,11],[1,11,85],[0,85,117,92,9996.9,0.2],[1,117,85],[0,85,14,93,9997.1],[0,14,12,94,9997.1],[1,12,14],[1,14,85],[0,85,36,95,9997.1,1.8],[0,36,37,96,9997.1,null,0,0,null,2],[0,37,30,97,9997.1,0.2],[0,30,31,98,9997.1,0.1],[1,31,30],[1,30,37],[0,37,38,99,9997.3,0.1],[0,38,118,100,9997.3,0.1],[0,118,119,101,9997.3],[0,119,120,102,9997.3],[1,120,119],[1,119,118],[0,118,121,103,9997.4],[1,121,118],[1,118,38],[1,38,37],[0,37,53,104,9997.5,null,9,0,0.2],[1,53,37],[0,37,122,105,9997.7],[0,122,119,106,9997.7],[0,119,120,107,9997.7],[1,120,119],[1,119,122],[1,122,37],[0,37,53,108,9997.7,null,9],[1,53,37],[0,37,123,109,9998.2,0.1,0,0,null,1],[0,123,42,110,9998.3,0,0,0,null,1],[0,42,43,111,9998.3,0,0,0,null,1],[1,43,42],[1,42,123],[1,123,37],[0,37,39,112,9998.3,0.1],[1,39,37],[0,37,47,113,9998.4,0.3],[0,47,30,114,9998.4],[0,30,31,115,9998.4],[1,31,30],[1,30,47],[0,47,53,116,9998.4,null,2,0,0.2],[1,53,47],[1,47,37],[1,37,36],[0,36,40,117,9998.8,null,0,0,null,2],[0,40,41,118,9998.8],[1,41,40],[0,40,42,119,9998.8,0,0,0,null,1],[0,42,43,120,9998.8,0,0,0,null,1],[1,43,42],[1,42,40],[1,40,36],[0,36,44,121,9998.8,null,0,0,null,2],[0,44,45,122,9998.8,0.1],[0,45,30,123,9998.8],[0,30,31,124,9998.8],[1,31,30],[1,30,45],[0,45,31,125,9998.8],[1,31,45],[0,45,46,126,9998.8,0.1],[1,46,45],[1,45,44],[1,44,36],[1,36,85],[0,85,48,127,9998.9,0,0,0,null,1],[0,48,49,128,9998.9,0,0,0,null,1],[0,49,43,129,9998.9,0,0,0,null,1],[1,43,49],[1,49,48],[1,48,85],[0,85,124,130,9998.9],[1,124,85],[3,-1,42,0,10011.1,null,0,0,null,0,19],[0,42,83,131,10011.1,0.8,0,0,null,1],[1,83,42],[3,-1,123,0,10016.9,null,0,0,null,0,19],[0,123,84,132,10016.9],[1,84,123],[0,123,53,133,10016.9,null,2],[1,53,123],[3,-1,48,0,10022.9,null,0,0,null,0,19],[0,48,30,134,10022.9],[0,30,31,135,10022.9],[1,31,30],[1,30,48],[0,48,54,136,10022.9],[1,54,48],[0,48,41,137,10022.9],[1,41,48],[0,48,54,138,10022.9,null,2,0,0.1],[1,54,48],[0,48,125,139,10023],[1,125,48],[0,48,54,140,10023],[1,54,48],[0,48,52,141,10023],[1,52,48],[4,48,-1]]}, {key:"compile",label:"Compile",desc:"Compile drawer opened, Add this report pressed.",steps:57,real:130, q:[[2,-1,55,1,13004.3,null,2,0,633.9,0,20],[0,55,56,2,13004.3,0.1],[1,56,55],[4,55,-1],[2,-1,126,3,13638.3,0.4,0,0,null,1,31],[0,126,30,4,13638.5],[0,30,31,5,13638.5],[1,31,30],[1,30,126],[0,126,127,6,13638.5,0.2,0,0,null,1],[0,127,38,7,13638.6,0.1],[0,38,118,8,13638.6,0.1],[0,118,119,9,13638.6],[0,119,120,10,13638.6],[1,120,119],[1,119,118],[0,118,121,11,13638.7],[1,121,118],[1,118,38],[1,38,127],[0,127,42,12,13638.7,0,0,0,null,1],[0,42,43,13,13638.7,0,0,0,null,1],[1,43,42],[1,42,127],[1,127,126],[3,-1,42,0,13649.7,null,0,0,null,0,19],[0,42,83,14,13649.7,0.7,0,0,null,1],[1,83,42],[3,-1,127,0,13651.1,null,0,0,null,0,19],[0,127,84,15,13651.1],[1,84,127],[3,-1,126,0,13651.1,null,0,0,null,0,19],[0,126,128,16,13651.1,1.9],[0,128,99,17,13651.3,0.5],[0,99,100,18,13651.3,null,57,0,0.1],[1,100,99],[0,99,101,19,13651.4],[0,101,102,20,13651.4],[1,102,101],[0,101,98,21,13651.4],[1,98,101],[1,101,99],[0,99,129,22,13651.4,0.1],[1,129,99],[0,99,130,23,13651.5,0.3],[0,130,90,24,13651.5,null,2,0,0.1],[1,90,130],[0,130,91,25,13651.6,null,2,2,0.1],[1,91,130],[0,130,6,26,13651.6,null,2,2,0.1],[1,6,130],[0,130,54,27,13651.7],[1,54,130],[0,130,131,28,13651.7,0.1],[1,131,130],[0,130,132,29,13651.8],[1,132,130],[1,130,99],[0,99,133,30,13651.8],[0,133,132,31,13651.8],[1,132,133],[1,133,99],[1,99,128],[0,128,134,32,13651.8,0.1],[1,134,128],[0,128,135,33,13651.9,0.2],[1,135,128],[0,128,50,34,13652.1,0.9],[0,50,28,35,13652.3],[1,28,50],[0,50,46,36,13652.5],[1,46,50],[0,50,51,37,13652.5,0.2],[0,51,135,38,13652.5,0.2],[1,135,51],[1,51,50],[0,50,136,39,13652.7,0.1],[0,136,137,40,13652.7,0.1],[0,137,121,41,13652.7,0.1],[1,121,137],[1,137,136],[1,136,50],[0,50,28,42,13652.8],[1,28,50],[0,50,53,43,13652.8,null,7],[1,53,50],[1,50,128],[1,128,126],[0,126,42,44,13653,0,0,0,null,1],[0,42,43,45,13653,0,0,0,null,1],[1,43,42],[1,42,126],[3,-1,42,0,13653.3,null,0,0,null,0,19],[0,42,83,46,13653.3,0.3,0,0,null,1],[1,83,42],[3,-1,126,0,13654.1,null,0,0,null,0,19],[0,126,84,47,13654.1,0.1],[1,84,126],[0,126,50,48,13655.1,0.4],[0,50,28,49,13655.1],[1,28,50],[0,50,46,50,13655.1],[1,46,50],[0,50,51,51,13655.1,0.3],[0,51,135,52,13655.1,0.3],[1,135,51],[1,51,50],[0,50,136,53,13655.4],[0,136,137,54,13655.4],[0,137,121,55,13655.4],[1,121,137],[1,137,136],[1,136,50],[0,50,28,56,13655.4],[1,28,50],[0,50,53,57,13655.4,null,7],[1,53,50],[1,50,126],[4,126,-1]]}, {key:"export",label:"Export",desc:"CSV, XLSX and HTML buttons pressed in the compile drawer.",steps:179,real:828, q:[[2,-1,55,1,15170.4,0,0,0,null,0,20],[0,55,56,2,15170.4],[1,56,55],[4,55,-1],[2,-1,138,3,15170.4,1.8,0,0,null,0,32],[0,138,139,4,15170.5,0.2],[1,139,138],[0,138,140,5,15170.7],[1,140,138],[0,138,136,6,15170.7],[0,136,137,7,15170.7],[0,137,121,8,15170.7],[1,121,137],[1,137,136],[1,136,138],[0,138,141,9,15170.7,0.5],[0,141,142,10,15170.7,0.1],[0,142,143,11,15170.8],[0,143,102,12,15170.8],[1,102,143],[1,143,142],[1,142,141],[0,141,144,13,15170.9],[0,144,145,14,15170.9],[0,145,102,15,15170.9],[1,102,145],[1,145,144],[1,144,141],[0,141,146,16,15170.9,null,56,0,0.3],[0,146,147,17,15171],[1,147,146],[1,146,141],[1,141,138],[0,138,6,18,15171.5],[1,6,138],[0,138,55,19,15172,0.1],[0,55,56,20,15172.1],[1,56,55],[1,55,138],[4,138,-1],[2,-1,55,21,16203.9,0,0,0,null,0,20],[0,55,56,22,16203.9],[1,56,55],[4,55,-1],[2,-1,148,23,16203.9,10.6,0,0,null,0,33],[0,148,139,24,16204,0.1],[1,139,148],[0,148,140,25,16204.1,0.1],[1,140,148],[0,148,136,26,16204.2],[0,136,137,27,16204.2],[0,137,121,28,16204.2],[1,121,137],[1,137,136],[1,136,148],[0,148,13,29,16204.2,10.3],[0,13,149,30,16204.2,8.9],[0,149,142,31,16204.4,0.1],[0,142,143,32,16204.4,0.1],[0,143,102,33,16204.5],[1,102,143],[1,143,142],[1,142,149],[0,149,144,34,16204.5],[0,144,145,35,16204.5],[0,145,102,36,16204.5],[1,102,145],[1,145,144],[1,144,149],[0,149,150,37,16204.6,null,113,2,0.5],[1,150,149],[0,149,151,38,16204.6,null,113,2,0.5],[1,151,149],[0,149,152,39,16205.1,5.4],[1,152,149],[0,149,153,40,16210.6,0.3],[1,153,149],[0,149,154,41,16210.9,null,9,0,0.2],[1,154,149],[0,149,155,42,16211.1,2],[0,155,154,43,16211.2],[1,154,155],[0,155,156,44,16211.2,0.5],[1,156,155],[0,155,157,45,16211.7],[1,157,155],[0,155,158,46,16211.7,null,5],[1,158,155],[0,155,157,47,16211.7,null,3],[1,157,155],[0,155,158,48,16211.7,null,2],[1,158,155],[0,155,157,49,16211.7],[1,157,155],[0,155,158,50,16211.7,null,6],[1,158,155],[0,155,157,51,16211.7,null,3],[1,157,155],[0,155,158,52,16211.7,null,5],[1,158,155],[0,155,157,53,16211.7,null,2],[1,157,155],[0,155,154,54,16211.8],[1,154,155],[0,155,156,55,16211.8],[1,156,155],[0,155,157,56,16211.8],[1,157,155],[0,155,158,57,16211.8,null,5],[1,158,155],[0,155,157,58,16211.8,null,3],[1,157,155],[0,155,158,59,16211.8,null,2],[1,158,155],[0,155,157,60,16211.8],[1,157,155],[0,155,158,61,16211.8,null,6],[1,158,155],[0,155,157,62,16211.8,null,3],[1,157,155],[0,155,158,63,16211.8,null,5],[1,158,155],[0,155,157,64,16211.8,null,2],[1,157,155],[0,155,154,65,16211.8],[1,154,155],[0,155,156,66,16211.8],[1,156,155],[0,155,157,67,16211.8],[1,157,155],[0,155,158,68,16211.8,null,5],[1,158,155],[0,155,157,69,16211.8,null,3],[1,157,155],[0,155,158,70,16211.8,null,2],[1,158,155],[0,155,157,71,16211.8],[1,157,155],[0,155,158,72,16211.8,null,6],[1,158,155],[0,155,157,73,16211.8,null,3],[1,157,155],[0,155,158,74,16211.8,null,5],[1,158,155],[0,155,157,75,16211.8,null,2],[1,157,155],[0,155,154,76,16211.8],[1,154,155],[0,155,156,77,16211.8,0.1],[1,156,155],[0,155,157,78,16211.9],[1,157,155],[0,155,158,79,16211.9,null,5],[1,158,155],[0,155,157,80,16211.9,null,3],[1,157,155],[0,155,158,81,16211.9,null,2],[1,158,155],[0,155,157,82,16211.9],[1,157,155],[0,155,158,83,16211.9,null,6],[1,158,155],[0,155,157,84,16211.9,null,3],[1,157,155],[0,155,158,85,16211.9,null,5],[1,158,155],[0,155,157,86,16211.9,null,2],[1,157,155],[0,155,154,87,16211.9],[1,154,155],[0,155,156,88,16211.9],[1,156,155],[0,155,157,89,16211.9],[1,157,155],[0,155,158,90,16211.9,null,5,0,0.1],[1,158,155],[0,155,157,91,16212,null,3],[1,157,155],[0,155,158,92,16212,null,2],[1,158,155],[0,155,157,93,16212],[1,157,155],[0,155,158,94,16212,null,6],[1,158,155],[0,155,157,95,16212,null,3],[1,157,155],[0,155,158,96,16212,null,5],[1,158,155],[0,155,157,97,16212,null,2],[1,157,155],[0,155,154,98,16212],[1,154,155],[0,155,156,99,16212,0.6],[1,156,155],[0,155,157,100,16212.6],[1,157,155],[0,155,158,101,16212.6,null,5],[1,158,155],[0,155,157,102,16212.6,null,3],[1,157,155],[0,155,158,103,16212.6,null,2],[1,158,155],[0,155,157,104,16212.6],[1,157,155],[0,155,158,105,16212.6,null,6],[1,158,155],[0,155,157,106,16212.6,null,3],[1,157,155],[0,155,158,107,16212.6,null,5],[1,158,155],[0,155,157,108,16212.6,null,2],[1,157,155],[0,155,154,109,16212.6],[1,154,155],[0,155,156,110,16212.6],[1,156,155],[0,155,157,111,16212.6],[1,157,155],[0,155,158,112,16212.6,null,5,0,0.1],[1,158,155],[0,155,157,113,16212.7,null,3],[1,157,155],[0,155,158,114,16212.7,null,2],[1,158,155],[0,155,157,115,16212.7],[1,157,155],[0,155,158,116,16212.8,null,6],[1,158,155],[0,155,157,117,16212.8,null,3],[1,157,155],[0,155,158,118,16212.8,null,5],[1,158,155],[0,155,157,119,16212.8,null,2],[1,157,155],[0,155,154,120,16212.8],[1,154,155],[0,155,156,121,16212.8],[1,156,155],[0,155,157,122,16212.8],[1,157,155],[0,155,158,123,16212.8,null,5],[1,158,155],[0,155,157,124,16212.8,null,3],[1,157,155],[0,155,158,125,16212.8,null,2],[1,158,155],[0,155,157,126,16212.8],[1,157,155],[0,155,158,127,16212.8,null,6],[1,158,155],[0,155,157,128,16212.8,null,3],[1,157,155],[0,155,158,129,16212.8,null,5],[1,158,155],[0,155,157,130,16212.8,null,2],[1,157,155],[0,155,154,131,16212.8],[1,154,155],[0,155,156,132,16212.8],[1,156,155],[0,155,157,133,16212.8],[1,157,155],[0,155,158,134,16212.8,null,5],[1,158,155],[0,155,157,135,16212.8,null,3],[1,157,155],[0,155,158,136,16212.8,null,2],[1,158,155],[0,155,157,137,16212.8],[1,157,155],[0,155,158,138,16212.8,null,6],[1,158,155],[0,155,157,139,16212.8,null,3,0,0.1],[1,157,155],[0,155,158,140,16212.9,null,5],[1,158,155],[0,155,157,141,16212.9,null,2],[1,157,155],[0,155,154,142,16212.9],[1,154,155],[0,155,156,143,16212.9,0.1],[1,156,155],[0,155,157,144,16213],[1,157,155],[0,155,158,145,16213,null,5],[1,158,155],[0,155,157,146,16213,null,3],[1,157,155],[0,155,158,147,16213,null,2],[1,158,155],[0,155,157,148,16213],[1,157,155],[0,155,158,149,16213,null,6],[1,158,155],[0,155,157,150,16213,null,3],[1,157,155],[0,155,158,151,16213,null,5],[1,158,155],[0,155,157,152,16213,null,3,0,0.1],[1,157,155],[0,155,158,153,16213.1,null,4],[1,158,155],[0,155,157,154,16213.1,null,2],[1,157,155],[0,155,158,155,16213.1],[1,158,155],[1,155,149],[1,149,13],[0,13,6,156,16213.7,0.1],[1,6,13],[0,13,55,157,16214.1],[0,55,56,158,16214.1],[1,56,55],[1,55,13],[1,13,148],[4,148,-1],[2,-1,55,159,17736.5,0,0,0,null,0,20],[0,55,56,160,17736.5],[1,56,55],[4,55,-1],[2,-1,159,161,17736.5,1.6,0,0,null,0,34],[0,159,139,162,17736.7],[1,139,159],[0,159,140,163,17736.7,0.1],[1,140,159],[0,159,136,164,17736.8],[0,136,137,165,17736.8],[0,137,121,166,17736.8],[1,121,137],[1,137,136],[1,136,159],[0,159,142,167,17736.8,0.1],[0,142,143,168,17736.8,0.1],[0,143,102,169,17736.9],[1,102,143],[1,143,142],[1,142,159],[0,159,53,170,17736.9,null,56,0,0.1],[1,53,159],[0,159,144,171,17737],[0,144,145,172,17737],[0,145,102,173,17737],[1,102,145],[1,145,144],[1,144,159],[0,159,53,174,17737,null,58,0,0.1],[1,53,159],[0,159,160,175,17737.1],[0,160,53,176,17737.1],[1,53,160],[1,160,159],[0,159,6,177,17737.5,0.1],[1,6,159],[0,159,55,178,17737.8],[0,55,56,179,17737.8],[1,56,55],[1,55,159],[4,159,-1]]}, {key:"selftest",label:"Self-test",desc:"A second page load with ?trace&selftest. runSelfTest() starts 400 ms after boot and passed 31 of 31 checks.",steps:313,real:1398, q:[[2,-1,161,1,503.4,22.8,0,0,null,1,35],[0,161,99,2,503.9,0.5],[0,99,100,3,504,null,57,0,0.2],[1,100,99],[0,99,101,4,504.2,0.2],[0,101,102,5,504.2,0.1],[1,102,101],[0,101,98,6,504.3,0.1],[1,98,101],[1,101,99],[1,99,161],[0,161,99,7,504.4,null,3,0,0.4],[0,99,100,8,504.4,null,57,0,0.1],[1,100,99],[0,99,101,9,504.5],[0,101,102,10,504.5],[1,102,101],[0,101,98,11,504.5],[1,98,101],[1,101,99],[0,99,129,12,504.5,0.1],[1,129,99],[1,99,161],[0,161,99,13,504.8,0.3],[0,99,100,14,504.8,null,57],[1,100,99],[0,99,101,15,504.9],[0,101,102,16,504.9],[1,102,101],[0,101,98,17,504.9],[1,98,101],[1,101,99],[0,99,129,18,504.9],[1,129,99],[0,99,130,19,504.9,0.2],[0,130,90,20,504.9,null,2,0,0.1],[1,90,130],[0,130,54,21,505],[1,54,130],[0,130,131,22,505],[1,131,130],[0,130,132,23,505.1],[1,132,130],[1,130,99],[0,99,133,24,505.1],[0,133,132,25,505.1],[1,132,133],[1,133,99],[1,99,161],[0,161,135,26,505.1,0.3],[1,135,161],[0,161,99,27,505.4,0.1],[0,99,100,28,505.4,null,57,0,0.1],[1,100,99],[0,99,101,29,505.5],[0,101,102,30,505.5],[1,102,101],[0,101,98,31,505.5],[1,98,101],[1,101,99],[1,99,161],[0,161,135,32,505.5,null,5,0,1.6],[1,135,161],[0,161,2,33,507.1,0.2],[0,2,3,34,507.3,null,0,0,null,2],[0,3,4,35,507.3],[1,4,3],[1,3,2],[1,2,161],[0,161,30,36,507.3],[0,30,31,37,507.3],[1,31,30],[1,30,161],[0,161,38,38,507.4,0.1],[0,38,118,39,507.4,0.1],[0,118,119,40,507.4,0.1],[0,119,120,41,507.4,0.1],[1,120,119],[1,119,118],[0,118,121,42,507.5],[1,121,118],[1,118,38],[1,38,161],[0,161,142,43,507.5,0.1],[0,142,143,44,507.6],[0,143,102,45,507.6],[1,102,143],[1,143,142],[1,142,161],[0,161,141,46,507.6,0.5],[0,141,142,47,507.6],[0,142,143,48,507.6],[0,143,102,49,507.6],[1,102,143],[1,143,142],[1,142,141],[0,141,144,50,507.6,0.1],[0,144,145,51,507.6,0.1],[0,145,102,52,507.7],[1,102,145],[1,145,144],[1,144,141],[0,141,146,53,507.7,null,57,0,0.4],[0,146,147,54,507.8],[1,147,146],[1,146,141],[1,141,161],[0,161,162,55,508.1,0.2],[1,162,161],[0,161,163,56,508.3],[1,163,161],[0,161,164,57,508.3,0.4],[0,164,163,58,508.7,null,56],[1,163,164],[1,164,161],[0,161,99,59,508.7,0.1],[0,99,100,60,508.7,null,57,0,0.1],[1,100,99],[0,99,101,61,508.8],[0,101,102,62,508.8],[1,102,101],[0,101,98,63,508.8],[1,98,101],[1,101,99],[0,99,129,64,508.8],[1,129,99],[1,99,161],[0,161,149,65,508.8,14.8],[0,149,142,66,509],[0,142,143,67,509],[0,143,102,68,509],[1,102,143],[1,143,142],[1,142,149],[0,149,144,69,509],[0,144,145,70,509],[0,145,102,71,509],[1,102,145],[1,145,144],[1,144,149],[0,149,150,72,509,null,115,2,0.4],[1,150,149],[0,149,151,73,509,null,115,2,0.4],[1,151,149],[0,149,152,74,509.5,6.6],[1,152,149],[0,149,153,75,516.2,0.6],[1,153,149],[0,149,154,76,517,null,9,0,0.3],[1,154,149],[0,149,155,77,517.3,6.3],[0,155,154,78,517.4],[1,154,155],[0,155,156,79,517.4,0.4],[1,156,155],[0,155,157,80,517.9],[1,157,155],[0,155,158,81,517.9,null,5],[1,158,155],[0,155,157,82,517.9,null,3],[1,157,155],[0,155,158,83,517.9,null,2],[1,158,155],[0,155,157,84,517.9],[1,157,155],[0,155,158,85,517.9,null,6],[1,158,155],[0,155,157,86,517.9,null,3],[1,157,155],[0,155,158,87,517.9,null,5],[1,158,155],[0,155,157,88,517.9,null,2],[1,157,155],[0,155,154,89,518],[1,154,155],[0,155,156,90,518],[1,156,155],[0,155,157,91,518],[1,157,155],[0,155,158,92,518,null,5],[1,158,155],[0,155,157,93,518,null,3],[1,157,155],[0,155,158,94,518,null,2],[1,158,155],[0,155,157,95,518],[1,157,155],[0,155,158,96,518,null,6],[1,158,155],[0,155,157,97,518,null,3],[1,157,155],[0,155,158,98,518,null,5],[1,158,155],[0,155,157,99,518,null,2],[1,157,155],[0,155,154,100,518],[1,154,155],[0,155,156,101,518,0.1],[1,156,155],[0,155,157,102,518.1],[1,157,155],[0,155,158,103,518.1,null,5],[1,158,155],[0,155,157,104,518.1,null,3],[1,157,155],[0,155,158,105,518.1,null,2],[1,158,155],[0,155,157,106,518.1],[1,157,155],[0,155,158,107,518.1,null,6],[1,158,155],[0,155,157,108,518.1,null,3],[1,157,155],[0,155,158,109,518.1,null,5],[1,158,155],[0,155,157,110,518.1,null,2],[1,157,155],[0,155,154,111,518.1],[1,154,155],[0,155,156,112,518.1,0.2],[1,156,155],[0,155,157,113,518.3],[1,157,155],[0,155,158,114,518.3,null,5],[1,158,155],[0,155,157,115,518.3,null,3],[1,157,155],[0,155,158,116,518.3,null,2],[1,158,155],[0,155,157,117,518.3],[1,157,155],[0,155,158,118,518.3,null,6,0,0.1],[1,158,155],[0,155,157,119,518.4,null,3],[1,157,155],[0,155,158,120,518.4,null,5],[1,158,155],[0,155,157,121,518.4,null,2],[1,157,155],[0,155,154,122,518.4],[1,154,155],[0,155,156,123,518.4,0.1],[1,156,155],[0,155,157,124,518.5],[1,157,155],[0,155,158,125,518.5,null,5],[1,158,155],[0,155,157,126,518.5,null,3],[1,157,155],[0,155,158,127,518.5,null,2,0,0.1],[1,158,155],[0,155,157,128,518.6],[1,157,155],[0,155,158,129,518.6,null,6],[1,158,155],[0,155,157,130,518.6,null,3],[1,157,155],[0,155,158,131,518.6,null,5],[1,158,155],[0,155,157,132,518.6,null,2],[1,157,155],[0,155,154,133,518.6],[1,154,155],[0,155,156,134,518.6,2.2],[1,156,155],[0,155,157,135,520.8],[1,157,155],[0,155,158,136,520.8,null,5],[1,158,155],[0,155,157,137,520.8,null,3],[1,157,155],[0,155,158,138,520.8,null,2],[1,158,155],[0,155,157,139,520.8],[1,157,155],[0,155,158,140,520.8,null,6],[1,158,155],[0,155,157,141,520.8,null,3],[1,157,155],[0,155,158,142,520.8,null,5],[1,158,155],[0,155,157,143,520.8,null,2],[1,157,155],[0,155,154,144,520.8],[1,154,155],[0,155,156,145,520.8,0.1],[1,156,155],[0,155,157,146,520.9],[1,157,155],[0,155,158,147,520.9,null,5],[1,158,155],[0,155,157,148,520.9,null,3],[1,157,155],[0,155,158,149,520.9,null,2],[1,158,155],[0,155,157,150,520.9],[1,157,155],[0,155,158,151,520.9,null,6],[1,158,155],[0,155,157,152,520.9,null,3],[1,157,155],[0,155,158,153,520.9,null,5],[1,158,155],[0,155,157,154,520.9,null,2],[1,157,155],[0,155,154,155,520.9],[1,154,155],[0,155,156,156,520.9,0.4],[1,156,155],[0,155,157,157,521.3],[1,157,155],[0,155,158,158,521.3,null,5],[1,158,155],[0,155,157,159,521.3,null,3],[1,157,155],[0,155,158,160,521.3,null,2],[1,158,155],[0,155,157,161,521.3],[1,157,155],[0,155,158,162,521.3,null,6],[1,158,155],[0,155,157,163,521.3,null,3],[1,157,155],[0,155,158,164,521.3,null,5],[1,158,155],[0,155,157,165,521.3,null,2],[1,157,155],[0,155,154,166,521.3],[1,154,155],[0,155,156,167,521.3,0.1],[1,156,155],[0,155,157,168,521.4],[1,157,155],[0,155,158,169,521.4,null,5],[1,158,155],[0,155,157,170,521.4,null,3],[1,157,155],[0,155,158,171,521.4,null,2],[1,158,155],[0,155,157,172,521.4],[1,157,155],[0,155,158,173,521.4,null,6],[1,158,155],[0,155,157,174,521.4,null,3],[1,157,155],[0,155,158,175,521.4,null,5],[1,158,155],[0,155,157,176,521.4,null,2],[1,157,155],[0,155,154,177,521.5],[1,154,155],[0,155,156,178,521.5,1],[1,156,155],[0,155,157,179,522.5],[1,157,155],[0,155,158,180,522.5,null,5],[1,158,155],[0,155,157,181,522.5,null,3],[1,157,155],[0,155,158,182,522.5,null,2],[1,158,155],[0,155,157,183,522.5],[1,157,155],[0,155,158,184,522.5,null,6],[1,158,155],[0,155,157,185,522.5,null,3],[1,157,155],[0,155,158,186,522.5,null,5],[1,158,155],[0,155,157,187,522.5,null,3,0,1],[1,157,155],[0,155,158,188,523.5,null,4],[1,158,155],[0,155,157,189,523.5,null,2],[1,157,155],[0,155,158,190,523.5],[1,158,155],[1,155,149],[1,149,161],[0,161,160,191,523.6],[0,160,53,192,523.6],[1,53,160],[1,160,161],[0,161,165,193,523.8,1.6],[0,165,12,194,523.9],[1,12,165],[0,165,34,195,523.9],[0,34,1,196,523.9],[1,1,34],[1,34,165],[0,165,125,197,523.9],[1,125,165],[0,165,120,198,523.9],[1,120,165],[0,165,2,199,523.9,0.3],[0,2,3,200,524.2,null,0,0,null,2],[0,3,4,201,524.2],[1,4,3],[1,3,2],[1,2,165],[0,165,36,202,524.2,1.2],[0,36,37,203,524.2,null,0,0,null,2],[0,37,30,204,524.2],[0,30,31,205,524.2],[1,31,30],[1,30,37],[0,37,38,206,524.2],[0,38,118,207,524.2],[0,118,119,208,524.2],[0,119,120,209,524.2],[1,120,119],[1,119,118],[0,118,121,210,524.2],[0,121,125,211,524.2],[1,125,121],[1,121,118],[1,118,38],[1,38,37],[0,37,53,212,524.4,null,10],[1,53,37],[0,37,122,213,524.4,0.3],[0,122,119,214,524.5],[0,119,120,215,524.5],[1,120,119],[1,119,122],[0,122,28,216,524.5],[1,28,122],[0,122,53,217,524.5],[1,53,122],[0,122,166,218,524.5],[0,166,34,219,524.5],[0,34,1,220,524.5],[1,1,34],[1,34,166],[0,166,53,221,524.5,null,2],[1,53,166],[1,166,122],[0,122,53,222,524.5],[1,53,122],[0,122,167,223,524.6,0.1],[0,167,28,224,524.7],[1,28,167],[0,167,53,225,524.7,null,3],[1,53,167],[1,167,122],[0,122,53,226,524.7,null,2],[1,53,122],[1,122,37],[0,37,53,227,524.7,null,9],[1,53,37],[0,37,123,228,524.9,0.1,0,0,null,1],[0,123,42,229,525,0,0,0,null,1],[0,42,43,230,525,0,0,0,null,1],[1,43,42],[1,42,123],[1,123,37],[0,37,39,231,525,0.1],[1,39,37],[0,37,47,232,525.1,0.1],[0,47,30,233,525.1],[0,30,31,234,525.1],[1,31,30],[1,30,47],[0,47,53,235,525.1,null,2,0,0.1],[1,53,47],[1,47,37],[1,37,36],[0,36,44,236,525.2,null,0,0,null,2],[0,44,45,237,525.2,0.2],[0,45,30,238,525.2],[0,30,31,239,525.2],[1,31,30],[1,30,45],[0,45,31,240,525.2],[1,31,45],[0,45,46,241,525.2,0.2],[0,46,125,242,525.4],[1,125,46],[1,46,45],[1,45,44],[1,44,36],[1,36,165],[1,165,161],[0,161,38,243,525.4,0.1],[0,38,118,244,525.4,0.1],[0,118,119,245,525.5],[0,119,120,246,525.5],[1,120,119],[1,119,118],[0,118,121,247,525.5],[0,121,125,248,525.5],[1,125,121],[1,121,118],[1,118,38],[1,38,161],[0,161,99,249,525.5,0.6],[0,99,100,250,525.5,null,57,0,0.1],[1,100,99],[0,99,101,251,525.6],[0,101,102,252,525.6],[1,102,101],[0,101,98,253,525.6],[1,98,101],[1,101,99],[0,99,129,254,525.6,0.1],[0,129,168,255,525.6,0.1],[0,168,125,256,525.7],[1,125,168],[1,168,129],[1,129,99],[0,99,130,257,525.8,0.1],[0,130,90,258,525.8,null,2],[1,90,130],[0,130,54,259,525.8],[1,54,130],[0,130,131,260,525.8,0.1],[1,131,130],[0,130,132,261,525.9],[1,132,130],[1,130,99],[0,99,133,262,525.9,0.2],[0,133,132,263,526.1],[1,132,133],[1,133,99],[1,99,161],[0,161,169,264,526.1,0.1],[0,169,3,265,526.2,null,0,0,null,2],[0,3,4,266,526.2],[1,4,3],[1,3,169],[1,169,161],[0,161,48,267,526.2,0,0,0,null,1],[0,48,49,268,526.2,0,0,0,null,1],[0,49,43,269,526.2,0,0,0,null,1],[1,43,49],[1,49,48],[1,48,161],[3,-1,48,0,540.7,null,0,0,null,0,19],[0,48,30,270,540.7],[0,30,31,271,540.7],[1,31,30],[1,30,48],[0,48,54,272,540.7],[1,54,48],[0,48,41,273,540.7],[1,41,48],[0,48,54,274,540.7],[1,54,48],[0,48,125,275,540.7],[1,125,48],[0,48,52,276,540.8],[1,52,48],[3,-1,161,0,541.5,null,0,0,null,0,19],[0,161,70,277,541.5,0.2,0,0,null,1],[0,70,43,278,541.7,0,0,0,null,1],[1,43,70],[1,70,161],[3,-1,70,0,541.7,null,0,0,null,0,19],[0,70,71,279,541.7,0.5,0,0,null,1],[1,71,70],[0,70,73,280,549.2,0.3,0,0,null,1],[1,73,70],[0,70,78,281,549.9,0.1],[1,78,70],[3,-1,161,0,551.7,null,0,0,null,0,19],[0,161,48,282,551.7,0,0,0,null,1],[0,48,49,283,551.7,0,0,0,null,1],[0,49,43,284,551.7,0,0,0,null,1],[1,43,49],[1,49,48],[1,48,161],[3,-1,48,0,553.3,null,0,0,null,0,19],[0,48,30,285,553.3],[0,30,31,286,553.3],[1,31,30],[1,30,48],[0,48,54,287,553.3],[1,54,48],[0,48,41,288,553.4],[1,41,48],[0,48,54,289,553.4,null,2],[1,54,48],[0,48,125,290,553.4],[1,125,48],[0,48,52,291,553.4],[1,52,48],[0,48,170,292,553.4,0.1],[0,170,132,293,553.4],[1,132,170],[1,170,48],[0,48,53,294,553.5],[1,53,48],[3,-1,161,0,553.6,null,0,0,null,0,19],[0,161,171,295,553.6,0,0,0,null,1],[0,171,42,296,553.6,0,0,0,null,1],[0,42,43,297,553.6,0,0,0,null,1],[1,43,42],[1,42,171],[1,171,161],[3,-1,42,0,554.1,null,0,0,null,0,19],[0,42,83,298,554.1,0.3,0,0,null,1],[1,83,42],[0,42,73,299,554.8,0.2,0,0,null,1],[1,73,42],[3,-1,171,0,555.3,null,0,0,null,0,19],[0,171,43,300,555.3,0,0,0,null,1],[1,43,171],[3,-1,161,0,555.8,null,0,0,null,0,19],[0,161,48,301,555.8,0.1,0,0,null,1],[0,48,49,302,555.9,0,0,0,null,1],[0,49,43,303,555.9,0,0,0,null,1],[1,43,49],[1,49,48],[1,48,161],[3,-1,48,0,556.1,null,0,0,null,0,19],[0,48,30,304,556.1],[0,30,31,305,556.1],[1,31,30],[1,30,48],[0,48,54,306,556.2],[1,54,48],[0,48,41,307,556.2],[1,41,48],[0,48,54,308,556.2],[1,54,48],[0,48,125,309,556.2],[1,125,48],[0,48,52,310,556.2],[1,52,48],[3,-1,161,0,556.3,null,0,0,null,0,19],[0,161,171,311,556.3,null,2,0,0.3,1],[0,171,42,312,556.3,0,0,0,null,1],[0,42,43,313,556.3,0,0,0,null,1],[1,43,42],[1,42,171],[1,171,161],[4,161,-1]]} ]; /* ================= playback engine ================= */ // Yo Jones - plays one recorded chapter as the program fired it. Nothing here is invented: every call, return, // await resume and event-loop entry comes from the recording above, in its recorded order. Only the pace is ours. // Durations are seconds at 1x. A call travels, then the reached node opens a popup and holds about three // seconds so it can be read. Returns are quick and quiet. const PB_DUR={0:{travel:0.9, total:4.2}, 1:{travel:0.6, total:0.6}, 2:{travel:0.5, total:4.6}, 3:{travel:0.7, total:3.0}, 4:{travel:0.6, total:0.6}}; const PB_FADE=0.45; // popup ease in and ease out, seconds at 1x const PB_SPEEDS=[0.5,1,2,4]; const RUN_SPARK="#39ff7a", RUN_OK="#34d399"; const RUN_BURN={dark:"#ff4a3d", light:"#d42a1f"}; const RUN_FAINT_EDGE=0.32, RUN_FAINT_NODE=0.26; // how far the untouched graph fades while a chapter is shown const BURN_TAU=30, BURN_FLOOR=0.16; // red trail: fades with age toward a floor that stays visible let CHAPTER_KEY="boot", pbSpeedIdx=1; let run=null; // Yo Jones - decodes one chapter's compact items and lays them out on a 1x clock. function buildSchedule(ch){ const items=[]; let t=0, pos=-1; for(const a of ch.q){ const it={k:a[0], a:a[1]==null?-1:a[1], b:a[2]==null?-1:a[2], s:a[3]||0, t:a[4]==null?null:a[4], ms:a[5]==null?null:a[5], rep:a[6]||0, blk:a[7]||0, span:a[8]==null?null:a[8], as:!!((a[9]||0)&1), hid:!!((a[9]||0)&2), why:(a[10]!=null && a[10]>=0)?PB_WHY[a[10]]:null}; it.from = it.k===0||it.k===1 ? it.a : (it.k===3 ? pos : -1); it.to = it.k===4 ? it.a : it.b; it.start=t; it.dur=PB_DUR[it.k].total; it.travel=PB_DUR[it.k].travel; it.arrive=t+it.travel; t+=it.dur; pos = it.k===4 ? -1 : it.to; items.push(it); } // each popup (call, root, resume) holds until the next item starts, then eases out items.forEach((it,i)=>{ it.popEnd = (i+1<items.length ? items[i+1].start : it.start+it.dur) + PB_FADE; }); // the step the dock reports: the latest call or root at or before each item let lastStep=0; items.forEach(it=>{ if(it.s) lastStep=it.s; it.stepNow=lastStep; }); return {items, total:t}; } // Yo Jones - W is time. Each node's fourth coordinate becomes when it first fired in this chapter, so turning the // W slider pulls early and late functions apart. Nodes that never fire in the chapter sit at the middle. function applyTimeAxis(sched){ const first=new Float32Array(NL).fill(-1); for(const it of sched.items){ if(it.to>=0 && first[it.to]<0) first[it.to]=it.arrive; } for(let i=0;i<NL;i++) N[i].wT = first[i]<0 ? 0 : -1.2 + 2.4*(first[i]/Math.max(1e-6,sched.total)); wAnimating=true; markDirty(); } let wAnimating=false; // Yo Jones - eases every node's W toward its time target; returns true while anything is still moving. function stepTimeAxis(){ if(!wAnimating) return false; let moving=false; for(const n of N){ const d=(n.wT||0)-n.w; if(Math.abs(d)>1e-3){ n.w+=d*0.08; moving=true; } else n.w=n.wT||0; } wAnimating=moving; return moving; } // Yo Jones - reads the little status line under the Instantiate button. function setRunStat(text){ const el=document.getElementById("runStat"); if(el) el.textContent=text; } // Yo Jones - the Instantiate button doubles as pause and resume while a chapter plays. function syncRunButton(){ const b=document.getElementById("btnRun"); if(!b) return; if(run && !run.done) b.innerHTML = run.paused ? "► Resume" : "❚❚ Pause"; else b.innerHTML = "► Play chapter"; } // Yo Jones - writes the chapter, its trigger and the recording clock into the bar along the bottom. function setCtx(ch, it){ const bar=document.getElementById("ctxBar"); if(!bar) return; document.getElementById("ctxHead").textContent = ch.label+" · "+ch.desc; let tail = ch.real+" real calls, "+ch.steps+" steps shown (back-to-back repeats folded)"; if(it){ const bits=[]; if(it.k===2 && run) run.why="trigger: "+it.why; if(it.k===3 && run) run.why=N[it.to].t+" resumes after an await"; if(run && run.why) bits.push(run.why); if(it.t!=null) bits.push("t = "+(it.t/1000).toFixed(3)+" s after page load"); if(bits.length) tail = bits.join(" · "); } document.getElementById("ctxTail").textContent = tail; bar.classList.add("show"); } // Yo Jones - plays the selected chapter from the top. Rotation is held still for the whole playback and handed back // afterwards unless the user changed it meanwhile. The layout is not reheated, so nodes stay put while you read. function startRun(){ const ch=PB_CHAPTERS.find(c=>c.key===CHAPTER_KEY); if(!ch) return; if(run && !run.done){ run.paused=!run.paused; run.lastTs=0; syncRunButton(); markDirty(); return; } const sched=buildSchedule(ch); const spinWas = lowPowerSpinUntil ? false : spin; run={ch, items:sched.items, total:sched.total, clock:0, lastTs:0, idx:0, applied:-1, paused:false, done:false, doneClock:0, touchAt:new Float32Array(NL).fill(Infinity), burns:new Map(), spinWas, spinTouched:false}; applyTimeAxis(sched); setSpin(false, true); clearDetail(); hideHint(); syncRunButton(); setCtx(ch, null); markDirty(); } // Yo Jones - drops the held trace (another chapter picked) and gives rotation back if playback was cut short. function clearRun(){ if(!run) return; if(!run.done && run.spinWas && !run.spinTouched) setSpin(true, true); run=null; syncRunButton(); markDirty(); } // Yo Jones - marks an edge as just fired; the red trail fades from this moment. function burnEdge(a,b,at){ if(a<0||b<0||a===b) return; const key=a<b ? a*NL+b : b*NL+a; const e=run.burns.get(key); if(e) e.at=at; else run.burns.set(key,{a,b,at}); } // Yo Jones - advances the playback clock and applies every arrival it has passed, in recorded order. function runAdvance(ts){ if(!run) return null; const dt = run.lastTs ? Math.min(0.1,(ts-run.lastTs)/1000) : 0; run.lastTs=ts; if(!run.paused) run.clock += dt*PB_SPEEDS[pbSpeedIdx]; const items=run.items, n=items.length; while(run.idx<n-1 && items[run.idx+1].start<=run.clock) run.idx++; while(run.applied+1<n && items[run.applied+1].arrive<=run.clock){ const it=items[++run.applied]; if(it.k===0){ run.touchAt[it.a]=Math.min(run.touchAt[it.a],it.start); run.touchAt[it.b]=Math.min(run.touchAt[it.b],it.arrive); burnEdge(it.a,it.b,it.arrive); } else if(it.k===1){ burnEdge(it.a,it.b,it.arrive); } else if(it.k===2||it.k===3){ run.touchAt[it.b]=Math.min(run.touchAt[it.b],it.arrive); } if(it.k!==1 && it.k!==4) setCtx(run.ch, it); } const it=items[run.idx]; if(!run.done && run.clock>=run.total){ run.done=true; run.doneClock=run.total; if(run.spinWas && !run.spinTouched) setSpin(true, true); setRunStat("done · "+run.ch.steps+" steps"); syncRunButton(); } else if(!run.done){ setRunStat((run.paused?"paused · ":"")+"step "+it.stepNow+"/"+run.ch.steps+" · "+PB_SPEEDS[pbSpeedIdx]+"×"); } const local=Math.max(0,run.clock-it.start); const u=it.travel>0 ? Math.min(1,local/it.travel) : 1; return {it, travel:u*u*(3-2*u), done:run.done}; } // Yo Jones - is the playback still producing motion (travel, popups, or the trail still fading)? function runLive(){ if(!run) return false; if(!run.done) return !run.paused; return run.clock-run.doneClock < BURN_TAU*4; } // Yo Jones - switches the armed chapter, refreshes the selector, and points the W axis at that chapter's timeline. function setChapter(key){ if(key!==CHAPTER_KEY) clearRun(); CHAPTER_KEY=key; document.querySelectorAll(".scn-btn").forEach(b=>b.setAttribute("aria-pressed", b.dataset.scn===key?"true":"false")); const ch=PB_CHAPTERS.find(c=>c.key===key); if(!run){ setRunStat("idle"); setCtx(ch,null); applyTimeAxis(buildSchedule(ch)); } } // Yo Jones - cycles playback speed; the clock picks it up on the next frame without jumping. function cycleSpeed(){ pbSpeedIdx=(pbSpeedIdx+1)%PB_SPEEDS.length; const b=document.getElementById("btnSpeed"); if(b) b.textContent="Speed "+PB_SPEEDS[pbSpeedIdx]+"×"; markDirty(); } // Yo Jones - the popup text for one reached node: name, step and caption, then what the recording says about it. function popupLines(it){ const n=N[it.to]; const head = it.s ? "Step "+it.s+" · " : ""; const l2 = head + (it.k===3 ? "resumes after an await" : n.cap); const meta=[]; if(it.k===3) meta.push("continues: "+n.cap); if(it.hid) meta.push("not seen by ?trace, recovered from the call stack"); if(it.rep){ const what = it.blk>1 ? "this run of "+it.blk+" calls repeats" : "called"; meta.push(what+" ×"+it.rep+" back to back, shown once"+(it.span!=null?" · "+fmtMs(it.span)+" in all":"")); } else if(it.ms!=null){ meta.push((it.as?"returned its promise after ":"took ")+fmtMs(it.ms)); } if(it.k===2 && it.why) meta.push("trigger: "+it.why); if(it.k===0 && it.from>=0) meta.push("called by "+N[it.from].t); return {title:n.t, l2, l3:meta.join(" · ")}; } // Yo Jones - milliseconds as the recording's clock can honestly state them (Chromium reports to 0.1 ms here). function fmtMs(ms){ return ms<0.05 ? "under 0.1 ms" : (ms<10 ? ms.toFixed(1) : Math.round(ms))+" ms"; } // Yo Jones - breaks a line into pieces no wider than max pixels, at spaces. function wrapText(text,max){ const words=String(text).split(" "), out=[]; let line=""; for(const w of words){ const t=line?line+" "+w:w; if(ctx.measureText(t).width>max && line){ out.push(line); line=w; } else line=t; } if(line) out.push(line); return out; } /* ================= canvas + camera ================= */ const cv=document.getElementById("c"), ctx=cv.getContext("2d",{alpha:false,desynchronized:true}); let W=0,H=0,DPR=1, cx=0, cy=0, dprCap=2; const bgLayer=document.createElement("canvas"), bgx=bgLayer.getContext("2d",{alpha:false}); let vignGrad=null, gradDirty=true, dirty=true; const MIN_DIST=2.4, MAX_DIST=64; const cam={ yaw:0.5, pitch:-0.28, dist:6.4, wAngle:0, panX:0, panY:0, fly:null }; const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; let spin = !reduceMotion, spinDir = 1; const introStarted=performance.now(); let lowPowerSpinUntil=0; // Yo Jones - render-on-demand plumbing. Nothing repaints unless something actually changed. let rafPending=false; function scheduleFrame(){ if(!rafPending && !document.hidden){ rafPending=true; requestAnimationFrame(frame); } } function markDirty(){ dirty=true; scheduleFrame(); } document.addEventListener("visibilitychange",()=>{ if(!document.hidden) markDirty(); }); // Yo Jones - resizes the canvas to the device pixel ratio the current quality tier allows. function resize(){ DPR=Math.min(window.devicePixelRatio||1, dprCap); W=cv.clientWidth; H=cv.clientHeight; cv.width=Math.round(W*DPR); cv.height=Math.round(H*DPR); ctx.setTransform(DPR,0,0,DPR,0,0); cx=W/2; cy=H/2; gradDirty=true; markDirty(); } window.addEventListener("resize",resize); resize(); /* ---- adaptive quality: give weak phones a way out ---- 0 = full, 1 = no additive edge glow + fewer halos, 2 = lower DPR + thinned stars, 3 = leanest. The governor only ever steps DOWN in cost, so it never oscillates. */ const QMAX=3; let QLEVEL=0, emaDT=16, lastTS=0, lastPaintTS=0, slowFrames=0, fcount=0; let introActive=true; // cinematic push-in on load cam.dist = reduceMotion ? 6.4 : 11.5; // start pulled back, ease in if(reduceMotion) introActive=false; // Yo Jones - writes the current quality tier onto <html> so the CSS can drop live blur on weak machines. function applyPerfClass(){ document.documentElement.setAttribute("data-perf",QLEVEL>=2?"lite":"full"); const tp=document.getElementById("perfTier"); if(tp) tp.textContent="Render tier "+QLEVEL+" of 3 \u00b7 "+(QLEVEL>=2?"lean":"full"); } // Yo Jones - reads the device once at boot and picks a starting quality tier. (function initQuality(){ const cores=navigator.hardwareConcurrency||8; const memory=navigator.deviceMemory||8; const saveData=!!(navigator.connection&&navigator.connection.saveData); const small=Math.min(screen.width,screen.height)<=480; const coarse=window.matchMedia("(pointer:coarse)").matches; const megapixels=(innerWidth*innerHeight*(window.devicePixelRatio||1)**2)/1e6; if(saveData || cores<=2 || memory<=2) QLEVEL=3; else if(cores<=4 || memory<=4 || megapixels>5.5) QLEVEL=2; else if((coarse&&small) || cores<=6) QLEVEL=1; dprCap = QLEVEL>=3?1.15 : QLEVEL===2?1.35 : QLEVEL===1?1.7 : 2; if(QLEVEL>=2 && spin) lowPowerSpinUntil=performance.now()+7000; applyPerfClass(); resize(); })(); // Yo Jones - sheds one step of rendering cost when frames stay slow. Never steps back up. function stepQualityDown(){ QLEVEL=Math.min(QMAX,QLEVEL+1); const cap = QLEVEL>=3?1.15 : QLEVEL===2?1.35 : QLEVEL===1?1.7 : 2; applyPerfClass(); gradDirty=true; dirty=true; if(cap!==dprCap){ dprCap=cap; resize(); } scheduleFrame(); } /* ---- theme ---- */ const glowCache=new Map(); const themes={ dark:{bg:"#0a0f1a", bg2:"#0b1424", edge:"rgba(150,180,225,", edgeBase:0.22, star:"rgba(150,180,225,", label:"#c6d4ea", vign:0.55, glow:1, dim:0.20, lw:1.0}, // Light-mode edges are a much darker slate and drawn thicker so the lines actually read. light:{bg:"#eef2f8", bg2:"#e3e9f3", edge:"rgba(34,54,88,", edgeBase:0.52, star:"rgba(70,100,150,", label:"#22344f", vign:0.16, glow:0.55, dim:0.42, lw:1.3} }; let TH = themes.dark; // Yo Jones - swaps the palette, keeps the theme-color meta in sync, and remembers the choice. function applyTheme(name){ document.documentElement.setAttribute("data-theme", name==="light"?"light":"dark"); TH = name==="light"?themes.light:themes.dark; document.getElementById("themeColorMeta").setAttribute("content", TH.bg); document.getElementById("btnTheme").textContent = name==="light"?"Dark":"Light"; try{ localStorage.setItem("atlas-theme", name); }catch(e){} glowCache.clear(); gradDirty=true; markDirty(); } // Yo Jones - paints the background once per resize or theme change into a cheap cached layer. function buildGradients(){ const qscale=QLEVEL>=3?0.42:QLEVEL===2?0.55:0.78; const bw=Math.max(2,Math.round(W*qscale)), bh=Math.max(2,Math.round(H*qscale)); bgLayer.width=bw; bgLayer.height=bh; const bx=bw/2, by=bh/2, br=Math.max(bw,bh); const base=bgx.createRadialGradient(bx,by*0.82,0,bx,by,br*0.75); base.addColorStop(0,TH.bg2); base.addColorStop(1,TH.bg); bgx.fillStyle=base; bgx.fillRect(0,0,bw,bh); const auraAlpha=TH===themes.dark?(QLEVEL>=2?0.055:0.085):(QLEVEL>=2?0.025:0.04); // Yo Jones - paints one soft aurora wash into the cached background. const aura=(x,y,r,c)=>{ const g=bgx.createRadialGradient(x,y,0,x,y,r); g.addColorStop(0,hexA(c,auraAlpha)); g.addColorStop(0.48,hexA(c,auraAlpha*0.42)); g.addColorStop(1,hexA(c,0)); bgx.fillStyle=g; bgx.fillRect(x-r,y-r,r*2,r*2); }; aura(bw*0.22,bh*0.28,br*0.48,GROUPS.record.c); aura(bw*0.78,bh*0.30,br*0.44,GROUPS.media.c); aura(bw*0.54,bh*0.80,br*0.50,GROUPS.compiler.c); vignGrad=ctx.createRadialGradient(cx,cy*0.94,Math.min(W,H)*0.30,cx,cy,Math.max(W,H)*0.74); vignGrad.addColorStop(0,"rgba(0,0,0,0)"); vignGrad.addColorStop(1,(TH===themes.dark?"rgba(3,6,14,":"rgba(24,36,60,")+TH.vign+")"); gradDirty=false; } (function initTheme(){ let t="dark"; try{ t=localStorage.getItem("atlas-theme")||"dark"; }catch(e){} applyTheme(t); })(); /* ---- starfield (far parallax dust) ---- */ const STARS=[]; for(let i=0;i<160;i++){ const u=Math.random()*2-1, th=Math.random()*Math.PI*2, r=7+Math.random()*6; const s=Math.sqrt(1-u*u); STARS.push({x:r*s*Math.cos(th), y:r*s*Math.sin(th), z:r*u, m:Math.random()}); } // Yo Jones - caches one radial-glow sprite per colour so bloom is a drawImage, not a gradient rebuild. function glowSprite(color){ let g=glowCache.get(color); if(g) return g; const s=64, off=document.createElement("canvas"); off.width=off.height=s; const o=off.getContext("2d"); const grd=o.createRadialGradient(s/2,s/2,0,s/2,s/2,s/2); grd.addColorStop(0, hexA(color, TH===themes.light?0.5:0.9)); grd.addColorStop(0.4, hexA(color, TH===themes.light?0.18:0.34)); grd.addColorStop(1, hexA(color,0)); o.fillStyle=grd; o.fillRect(0,0,s,s); glowCache.set(color,off); return off; } // Yo Jones - turns a hex colour plus an alpha into an rgba string. function hexA(hex,a){ const h=hex.replace("#",""); const r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16); return "rgba("+r+","+g+","+b+","+a+")"; } /* ================= projection: 4D -> 3D -> 2D ================= Split in two on purpose. The rotation half does not depend on camera distance, so Fit can binary-search a distance without redoing any trigonometry, and the per-frame path computes eight sines and cosines instead of eight per node. */ const RX=new Float32Array(NL), RY=new Float32Array(NL), RZ=new Float32Array(NL); const PX=new Float32Array(NL), PY=new Float32Array(NL), PD=new Float32Array(NL); const PS=new Float32Array(NL), PB=new Float32Array(NL); const PJ={ca:1,sa:0,cb:1,sb:0,cyaw:1,syaw:0,cp:1,sp:0,sc:1}; const FOV=3.0, WCAM=3.4; // Yo Jones - recomputes the eight camera trig terms once per frame. function syncProjection(){ const a=cam.wAngle; PJ.ca=Math.cos(a); PJ.sa=Math.sin(a); const b=a*0.55; PJ.cb=Math.cos(b); PJ.sb=Math.sin(b); PJ.cyaw=Math.cos(cam.yaw); PJ.syaw=Math.sin(cam.yaw); PJ.cp=Math.cos(cam.pitch); PJ.sp=Math.sin(cam.pitch); PJ.sc=Math.min(W,H)*0.19; } // Yo Jones - rotates every node through the W axis and the camera, with no distance applied yet. function rotateAll(){ syncProjection(); const {ca,sa,cb,sb,cyaw,syaw,cp,sp}=PJ; for(let i=0;i<NL;i++){ const n=N[i]; const z1 = n.z*ca - n.w*sa; const w1 = n.z*sa + n.w*ca; const x1 = n.x*cb - w1*sb; const w2 = n.x*sb + w1*cb; const pw = WCAM/(WCAM - w2); const X=x1*pw, Y=n.y*pw, Z=z1*pw; const rx=X*cyaw - Z*syaw, rz=X*syaw + Z*cyaw; RX[i]=rx; RY[i]=Y*cp - rz*sp; RZ[i]=Y*sp + rz*cp; } } // Yo Jones - applies camera distance and pan to the rotated points, filling the flat screen arrays. function projectAll(){ const sc=PJ.sc, d=cam.dist, ox=cx+cam.panX, oy=cy+cam.panY; for(let i=0;i<NL;i++){ const ps=FOV/Math.max(0.35, d+RZ[i]); PX[i]=ox+RX[i]*ps*sc; PY[i]=oy-RY[i]*ps*sc; PD[i]=RZ[i]; PS[i]=ps; PB[i]=1; } } // Yo Jones - the perspective scale for one node, used by the drag maths. function nodePS(i){ return FOV/Math.max(0.35, cam.dist+RZ[i]); } // Yo Jones - projects a starfield point. No W axis, no pan, just camera and perspective. function projectPoint(x,y,z){ const {cyaw,syaw,cp,sp,sc}=PJ; const rx=x*cyaw - z*syaw, rz=x*syaw + z*cyaw; const ry=y*cp - rz*sp, rz2=y*sp + rz*cp; const denom=cam.dist+rz2; if(denom<=0.35) return null; const ps=FOV/denom; return {sx:cx+cam.panX+rx*ps*sc, sy:cy+cam.panY-ry*ps*sc, ps}; } /* ================= physics: Coulomb-Hooke ================= */ let temp=0.4565, settled=false, simSteps=0; const REPULSE=0.55, SPRING=0.0166, REST=1.75, CENTER=0.004, DAMP=0.82; let dragNode=null; // Yo Jones - one integration step of the Coulomb-Hooke layout, sampled on weak tiers. function step(){ if(settled) return; const nn=NL; const fullRepulse=nn<=180; const sampleCount=fullRepulse?nn-1:Math.min(nn-1,QLEVEL>=3?24:QLEVEL===2?36:QLEVEL===1?56:76); const sampleScale=fullRepulse?1:(nn-1)/sampleCount; const stride=97; for(let i=0;i<nn;i++){ const a=N[i]; let fx=0,fy=0,fz=0; if(fullRepulse){ for(let j=0;j<nn;j++){ if(i===j) continue; const b=N[j]; const dx=a.x-b.x, dy=a.y-b.y, dz=a.z-b.z; const d2=dx*dx+dy*dy+dz*dz+0.04; const inv=REPULSE/d2, d=Math.sqrt(d2); fx+=dx/d*inv; fy+=dy/d*inv; fz+=dz/d*inv; } }else{ let j=(i*37+1)%nn; for(let k=0;k<sampleCount;k++,j=(j+stride)%nn){ if(j===i){ j=(j+1)%nn; } const b=N[j]; const dx=a.x-b.x, dy=a.y-b.y, dz=a.z-b.z; const d2=dx*dx+dy*dy+dz*dz+0.04; const inv=REPULSE*sampleScale/d2, d=Math.sqrt(d2); fx+=dx/d*inv; fy+=dy/d*inv; fz+=dz/d*inv; } } fx-=a.x*CENTER; fy-=a.y*CENTER; fz-=a.z*CENTER; a.fx=fx; a.fy=fy; a.fz=fz; } for(const e of edges){ const a=N[e.s], b=N[e.t]; const dx=b.x-a.x, dy=b.y-a.y, dz=b.z-a.z; const d=Math.sqrt(dx*dx+dy*dy+dz*dz)+1e-4; const f=SPRING*(d-REST)/d; a.fx+=dx*f; a.fy+=dy*f; a.fz+=dz*f; b.fx-=dx*f; b.fy-=dy*f; b.fz-=dz*f; } let energy=0; for(const a of N){ if(a===dragNode) continue; a.vx=(a.vx+a.fx)*DAMP; a.vy=(a.vy+a.fy)*DAMP; a.vz=(a.vz+a.fz)*DAMP; const cap=temp*0.1162; const sp=Math.sqrt(a.vx*a.vx+a.vy*a.vy+a.vz*a.vz); if(sp>cap){ const k=cap/sp; a.vx*=k; a.vy*=k; a.vz*=k; } a.x+=a.vx; a.y+=a.vy; a.z+=a.vz; energy+=sp; } temp*=0.993; if(temp<0.08) temp=0.08; // Settle when calm, with earlier hard stops on weak tiers so a stubborn layout // cannot keep a small machine chewing after the composition is already good. const maxSteps=QLEVEL>=3?180:QLEVEL===2?280:QLEVEL===1?700:1400; if((energy/nn<0.0016 && !dragNode) || ++simSteps>maxSteps){ settled=true; autoFitOnce(); } } // Yo Jones - reheats the layout after the user pokes it. function wake(){ settled=false; temp=Math.max(temp,0.2324); } /* ================= visibility filter ================= */ const legendOff=new Set(); let query=""; // the live search string, shared by the index and the canvas const HIT=new Uint8Array(NL).fill(1); let hitCount=NL; // Yo Jones - recomputes which nodes survive the legend toggles and the search box. function refreshVisibility(){ const f=query.trim().toLowerCase(); hitCount=0; for(let i=0;i<NL;i++){ const ok = !legendOff.has(N[i].g) && (!f || N[i].hay.includes(f)); HIT[i]=ok?1:0; if(ok) hitCount++; } updateStat(); markDirty(); } // Yo Jones - keeps the little bottom-left counter honest. function updateStat(){ const el=document.getElementById("statPill"); if(!el) return; el.textContent = (query.trim()||legendOff.size) ? hitCount+" of "+NL+" shown" : NL+" functions \u00b7 "+edges.length+" call pairs"; } /* ================= fit: put the whole cloud on screen ================= */ // Yo Jones - the rectangle the cloud is allowed to occupy, kept clear of the masthead and the dock. function safeBox(){ let l=18, r=W-18, t=18, b=H-78; const dock=document.getElementById("dock"); if(dock){ const q=dock.getBoundingClientRect(); if(q.width>0 && q.right>W*0.5) r=Math.min(r,q.left-16); } const mast=document.querySelector(".mast"); if(mast){ const q=mast.getBoundingClientRect(); if(q.height>0) t=Math.max(t,q.bottom+16); } const papers=document.getElementById("papers"); if(papers && papers.classList.contains("open")){ const q=papers.getBoundingClientRect(); l=Math.max(l,q.right+16); } if(r-l<120){ l=18; r=W-18; } if(b-t<120){ t=18; b=H-18; } return {l,r,t,b,w:r-l,h:b-t,mx:(l+r)/2,my:(t+b)/2}; } // Yo Jones - measures the projected bounding box of the visible cloud at a given camera distance. const _ext={minX:0,maxX:0,minY:0,maxY:0,ok:false}; function extentAt(d){ const sc=PJ.sc; let minX=1e9,maxX=-1e9,minY=1e9,maxY=-1e9,any=false; for(let i=0;i<NL;i++){ if(!HIT[i]) continue; const ps=FOV/Math.max(0.35,d+RZ[i]); const x=RX[i]*ps*sc, y=-RY[i]*ps*sc, pad=N[i].rad*ps+3; if(x-pad<minX)minX=x-pad; if(x+pad>maxX)maxX=x+pad; if(y-pad<minY)minY=y-pad; if(y+pad>maxY)maxY=y+pad; any=true; } _ext.minX=minX; _ext.maxX=maxX; _ext.minY=minY; _ext.maxY=maxY; _ext.ok=any; return _ext; } // Yo Jones - binary-searches the nearest camera distance that still shows everything, then centres it. function fitAll(animate){ rotateAll(); const box=safeBox(); let lo=MIN_DIST, hi=MAX_DIST; if(!extentAt(hi).ok) return; for(let k=0;k<26;k++){ const mid=(lo+hi)/2; const e=extentAt(mid); if((e.maxX-e.minX)<=box.w && (e.maxY-e.minY)<=box.h) hi=mid; else lo=mid; } const d=Math.min(MAX_DIST,hi*1.02); const e=extentAt(d); const panX=box.mx-cx-(e.minX+e.maxX)/2; const panY=box.my-cy-(e.minY+e.maxY)/2; if(animate && !reduceMotion){ cam.fly={yaw:cam.yaw,pitch:cam.pitch,dist:d,panX:panX,panY:panY,n:34}; }else{ cam.dist=d; cam.panX=panX; cam.panY=panY; cam.fly=null; } introActive=false; markDirty(); } let autoFitDone=false, userTouched=false, wantAutoFit=false; // Yo Jones - flags the one free fit, handed out when the layout first settles. function autoFitOnce(){ if(!autoFitDone && !userTouched) wantAutoFit=true; } /* ================= Big Bang ================= */ // Slower than it used to be, on purpose. The old one was over before you saw it. const BANG_HOLD=520, BANG_OUT=2600, BANG_IN=1500, BANG_STAGGER=0.42; let bangFx=null, bangCollapsed=false; // Yo Jones - the three easing helpers the bloom and the collapse run on. const clamp01=v=>v<0?0:v>1?1:v; const easeOutCubic=t=>1-Math.pow(1-t,3); const easeInCubic=t=>t*t*t; // Yo Jones - reads the Collapse and Big Bang button labels off the current state. function syncBangButton(){ const b=document.getElementById("btnBang"); if(b) b.textContent = bangCollapsed ? "Big Bang" : "Collapse"; } // Yo Jones - runs the slow bloom out of the seed. function startBigBang(){ if(reduceMotion){ bangCollapsed=false; bangFx=null; syncBangButton(); markDirty(); return; } bangCollapsed=false; bangFx={mode:"big",t0:performance.now(),hold:BANG_HOLD,dur:BANG_OUT}; clearDetail(); hideHint(); syncBangButton(); markDirty(); } // Yo Jones - runs the slow collapse back down to a single seed. function startReverseBang(){ if(reduceMotion){ bangCollapsed=true; bangFx=null; syncBangButton(); markDirty(); return; } bangCollapsed=false; bangFx={mode:"reverse",t0:performance.now(),hold:0,dur:BANG_IN}; hideHint(); markDirty(); } // Yo Jones - one button for the whole effect: collapse when open, bloom when collapsed. function toggleBang(){ if(bangCollapsed || (bangFx&&bangFx.mode==="reverse")) startBigBang(); else startReverseBang(); } // Yo Jones - how far along the bloom a single node is, staggered by its stable hash. function bangProgress(n,ts){ if(!bangFx) return bangCollapsed?0:1; const elapsed=ts-bangFx.t0-(bangFx.hold||0); const g=clamp01(elapsed/bangFx.dur); const local=clamp01((g-n.magic*BANG_STAGGER)/(1-BANG_STAGGER)); return bangFx.mode==="big" ? easeOutCubic(local) : 1-easeInCubic(local); } // Yo Jones - swirls the projected points toward or away from the seed while the effect runs. function applyBangTransform(ts){ if(!bangFx && !bangCollapsed) return; const ox=cx+cam.panX, oy=cy+cam.panY; for(let i=0;i<NL;i++){ const q=bangProgress(N[i],ts); const dx=PX[i]-ox, dy=PY[i]-oy; const swirl=((bangFx&&bangFx.mode==="reverse")?-1:1)*(1-q)*(0.95+N[i].magic*1.8)*(spinDir||1); const cs=Math.cos(swirl), sn=Math.sin(swirl); PX[i]=ox+(dx*cs-dy*sn)*q; PY[i]=oy+(dx*sn+dy*cs)*q; PB[i]=q; } if(bangFx && ts-bangFx.t0>=(bangFx.hold||0)+bangFx.dur){ bangCollapsed = bangFx.mode==="reverse"; bangFx=null; syncBangButton(); } } /* ================= interaction ================= */ let hoverNode=null, focusNode=null; let homeSeedHover=false; const pointers=new Map(); let gesture=null; // "orbit" | "pan" | "drag" | "pinch" | "seed" let downX=0, downY=0, lastX=0, lastY=0, moved=false, downHit=-1; let pinch=null; const TAP_SLOP = window.matchMedia("(pointer:coarse)").matches ? 11 : 6; // Yo Jones - true when the pointer is on the collapsed home seed at the centre. function seedHit(mx,my){ if(!bangCollapsed) return false; const dx=mx-(cx+cam.panX), dy=my-(cy+cam.panY); return dx*dx+dy*dy<=32*32; } // Yo Jones - finds the nearest visible node under the pointer, front-most wins. function pick(mx,my){ if(bangCollapsed || bangFx) return -1; let best=-1,bd=1e9; for(let i=0;i<NL;i++){ if(!HIT[i]) continue; const r=Math.max(9, N[i].rad*PS[i]*1.4); const dx=mx-PX[i], dy=my-PY[i], d=dx*dx+dy*dy; if(d<r*r && PD[i]<bd){ bd=PD[i]; best=i; } } return best; } // Yo Jones - pointer coordinates relative to the canvas. function evtXY(e){ const r=cv.getBoundingClientRect(); return {x:e.clientX-r.left, y:e.clientY-r.top}; } cv.addEventListener("contextmenu",e=>e.preventDefault()); cv.addEventListener("pointerdown",e=>{ try{ cv.setPointerCapture(e.pointerId); }catch(_){} const q=evtXY(e); pointers.set(e.pointerId,q); userTouched=true; hideHint(); if(pointers.size===2){ const [a,b]=[...pointers.values()]; pinch={d:Math.hypot(a.x-b.x,a.y-b.y)||1, mx:(a.x+b.x)/2, my:(a.y+b.y)/2, dist:cam.dist, panX:cam.panX, panY:cam.panY}; gesture="pinch"; dragNode=null; return; } if(pointers.size>2) return; downX=lastX=q.x; downY=lastY=q.y; moved=false; if(seedHit(q.x,q.y)){ gesture="seed"; downHit=-1; return; } downHit=pick(q.x,q.y); if(e.button===2 || e.shiftKey){ gesture="pan"; } else if(downHit>=0){ gesture="drag"; dragNode=N[downHit]; wake(); } else{ gesture="orbit"; } cv.classList.add("grabbing"); }); cv.addEventListener("pointermove",e=>{ const q=evtXY(e); if(pointers.has(e.pointerId)) pointers.set(e.pointerId,q); if(gesture==="pinch" && pointers.size>=2 && pinch){ const [a,b]=[...pointers.values()]; const d=Math.hypot(a.x-b.x,a.y-b.y)||1; const mx=(a.x+b.x)/2, my=(a.y+b.y)/2; cam.dist=Math.max(MIN_DIST,Math.min(MAX_DIST,pinch.dist*(pinch.d/d))); const k=pinch.dist/cam.dist; cam.panX = mx - k*(pinch.mx-(cx+pinch.panX)) - cx; cam.panY = my - k*(pinch.my-(cy+pinch.panY)) - cy; cam.fly=null; markDirty(); return; } if(!gesture){ homeSeedHover=seedHit(q.x,q.y); const hit=homeSeedHover?-1:pick(q.x,q.y); const nh = hit>=0? N[hit] : null; if(nh!==hoverNode){ hoverNode=nh; markDirty(); } cv.style.cursor=(homeSeedHover||hoverNode)?"pointer":"grab"; return; } const tdx=q.x-downX, tdy=q.y-downY; if(!moved && tdx*tdx+tdy*tdy>TAP_SLOP*TAP_SLOP) moved=true; const dx=q.x-lastX, dy=q.y-lastY; if(moved){ if(gesture==="pan"){ cam.panX+=dx; cam.panY+=dy; cam.fly=null; }else if(gesture==="drag" && dragNode){ const i=idx.get(dragNode.id); const ps=nodePS(i), sc=PJ.sc; const wx=dx/(sc*ps), wy=-dy/(sc*ps); const cyaw=Math.cos(-cam.yaw), syaw=Math.sin(-cam.yaw); dragNode.x+=wx*cyaw; dragNode.z+=wx*syaw; dragNode.y+=wy; dragNode.vx=dragNode.vy=dragNode.vz=0; wake(); }else if(gesture==="orbit"){ cam.yaw+=dx*0.006; cam.pitch+=dy*0.006; cam.pitch=Math.max(-1.45,Math.min(1.45,cam.pitch)); cam.fly=null; } markDirty(); } lastX=q.x; lastY=q.y; }); // Yo Jones - drops a finger and rebuilds whatever gesture the remaining fingers still describe. function releasePointer(id){ pointers.delete(id); if(pointers.size<2) pinch=null; if(pointers.size===0){ gesture=null; dragNode=null; cv.classList.remove("grabbing"); }else if(gesture==="pinch"){ const q=[...pointers.values()][0]; downX=lastX=q.x; downY=lastY=q.y; moved=true; gesture="orbit"; } } cv.addEventListener("pointerup",e=>{ const wasGesture=gesture, wasMoved=moved, hit=downHit; releasePointer(e.pointerId); if(wasMoved) return; if(wasGesture==="seed"){ toggleBang(); return; } if(wasGesture==="pinch") return; if(hit>=0){ selectNode(N[hit]); } else{ clearDetail(); } }); cv.addEventListener("pointercancel",e=>releasePointer(e.pointerId)); cv.addEventListener("lostpointercapture",e=>releasePointer(e.pointerId)); cv.addEventListener("pointerleave",()=>{ if(!gesture){ homeSeedHover=false; hoverNode=null; cv.style.cursor="grab"; markDirty(); } }); cv.addEventListener("wheel",e=>{ e.preventDefault(); userTouched=true; const q=evtXY(e); const k=e.ctrlKey?0.16:0.08; // trackpad pinch arrives as ctrl+wheel const d0=cam.dist; cam.dist=Math.max(MIN_DIST,Math.min(MAX_DIST,d0*(1+(e.deltaY>0?k:-k)))); zoomAnchor(q.x,q.y,d0/cam.dist); cam.fly=null; markDirty(); },{passive:false}); // Yo Jones - keeps the point under the pointer nailed in place while the zoom changes around it. function zoomAnchor(ax,ay,k){ cam.panX = ax - k*(ax-(cx+cam.panX)) - cx; cam.panY = ay - k*(ay-(cy+cam.panY)) - cy; } /* ---- keyboard ---- */ // Yo Jones - keyboard shortcuts, ignored while the user is typing in a field. window.addEventListener("keydown",e=>{ const t=e.target; const typing = t && (t.tagName==="INPUT" || t.tagName==="TEXTAREA" || t.isContentEditable); if(e.key==="Escape"){ if(document.getElementById("help").classList.contains("show")) setHelp(false); else if(document.getElementById("papers").classList.contains("open")) setPapers(false); else clearDetail(); if(typing) t.blur(); return; } if(typing) return; if(e.metaKey||e.ctrlKey||e.altKey) return; const k=e.key.toLowerCase(); if(k==="f"){ fitAll(true); } else if(k==="r"){ resetCamera(); } else if(k==="s"){ setSpin(!spin); } else if(k==="b"){ toggleBang(); } else if(k==="v"){ startRun(); } else if(k==="x"){ cycleSpeed(); } else if(k==="l"){ cycleLabels(); } else if(k==="t"){ applyTheme(document.documentElement.getAttribute("data-theme")==="light"?"dark":"light"); } else if(k==="i"||k==="/"){ setPapers(true); document.getElementById("papersSearch").focus(); e.preventDefault(); } else if(k==="?"){ setHelp(!document.getElementById("help").classList.contains("show")); } else if(k==="+"||k==="="){ cam.dist=Math.max(MIN_DIST,cam.dist*0.88); cam.fly=null; markDirty(); } else if(k==="-"||k==="_"){ cam.dist=Math.min(MAX_DIST,cam.dist*1.14); cam.fly=null; markDirty(); } else if(e.key==="ArrowLeft"){ cam.yaw-=0.09; cam.fly=null; markDirty(); } else if(e.key==="ArrowRight"){ cam.yaw+=0.09; cam.fly=null; markDirty(); } else if(e.key==="ArrowUp"){ cam.pitch=Math.max(-1.45,cam.pitch-0.07); cam.fly=null; markDirty(); } else if(e.key==="ArrowDown"){ cam.pitch=Math.min(1.45,cam.pitch+0.07); cam.fly=null; markDirty(); } else return; userTouched=true; }); /* ---- detail panel ---- */ const dEl=document.getElementById("detail"); // Yo Jones - fills the detail card for one node, including its walkable neighbour list. function selectNode(n){ focusNode=n; const G=GROUPS[n.g]; const k=document.getElementById("dKick"); k.textContent=G.label; k.style.color=n.col; document.getElementById("dTitle").textContent=n.t; document.getElementById("dNote").textContent=n.n||n.desc||""; const u=document.getElementById("dUrl"); if(n.url){ u.style.display="inline-flex"; u.href=n.url; } else u.style.display="none"; const a=document.getElementById("dDoi"); if(n.doi){ a.style.display="inline-flex"; a.textContent="DOI "+n.doi; a.href="https://doi.org/"+n.doi; } else a.style.display="none"; const L=document.getElementById("dLinks"); L.innerHTML=""; const ni=idx.get(n.id); const nbrs = ni!=null ? [...adj[ni]] : []; if(nbrs.length){ nbrs.sort((x,y)=> N[x].g===N[y].g ? N[x].t.localeCompare(N[y].t) : N[x].g.localeCompare(N[y].g)); const lbl=document.createElement("p"); lbl.className="lbl"; lbl.textContent="Connected \u00B7 "+nbrs.length; L.appendChild(lbl); for(const j of nbrs){ const m=N[j]; const el=document.createElement("button"); el.type="button"; el.className="dlink"; el.title=GROUPS[m.g].label+" \u00B7 "+m.t; el.innerHTML='<span class="cdot" style="background:'+m.col+'"></span>'; el.appendChild(document.createTextNode(m.t)); el.addEventListener("click",()=>{ selectNode(m); flyTo(m); }); L.appendChild(el); } } dEl.classList.add("show"); markDirty(); } // Yo Jones - closes the detail card and drops the focus highlight. function clearDetail(){ focusNode=null; dEl.classList.remove("show"); markDirty(); } document.getElementById("detailClose").onclick=clearDetail; /* ---- camera moves ---- */ // Yo Jones - shortest angular difference, so the camera never takes the long way round. function angDiff(a,b){ let d=(b-a)%(Math.PI*2); if(d>Math.PI)d-=Math.PI*2; if(d<-Math.PI)d+=Math.PI*2; return d; } // Yo Jones - eases the camera around to face a node without throwing away the current zoom. function flyTo(n){ const horiz=Math.hypot(n.x,n.z)||1e-3; const yaw=Math.atan2(n.x,n.z); let pitch=Math.atan2(n.y,horiz); pitch=Math.max(-1.3,Math.min(1.3,pitch)); const dist=cam.dist>9?7.2:cam.dist; cam.fly={yaw,pitch,dist,panX:0,panY:0,n:40}; userTouched=true; markDirty(); } // Yo Jones - puts the camera back where it started. function resetCamera(){ cam.yaw=0.5; cam.pitch=-0.28; cam.dist=6.4; cam.wAngle=0; cam.panX=0; cam.panY=0; cam.fly=null; bangCollapsed=false; bangFx=null; spinDir=1; document.getElementById("wAxis").value=0; document.getElementById("wVal").textContent="0.00"; clearDetail(); wake(); syncBangButton(); markDirty(); } /* ---- controls ---- */ const btnSpin=document.getElementById("btnSpin"); // Yo Jones - turns idle rotation on or off and keeps the button state honest. // Playback holds rotation itself (internal=true); any other change during playback counts as the user's choice. function setSpin(on, internal){ spin=!!on; lowPowerSpinUntil=0; if(!internal && run && !run.done) run.spinTouched=true; btnSpin.setAttribute("aria-pressed",spin?"true":"false"); markDirty(); } setSpin(spin); btnSpin.onclick=()=>{ userTouched=true; setSpin(!spin); }; document.getElementById("btnDir").onclick=()=>{ spinDir=-spinDir; setSpin(true); }; // Yo Jones - one handler behind both the desktop Fit pill and the small-screen Fit button. function doFit(){ userTouched=true; setSpin(false); fitAll(true); } document.getElementById("btnFit").onclick=doFit; document.getElementById("btnFitMini").onclick=doFit; document.getElementById("btnIndexMini").onclick=()=>setPapers(true); document.getElementById("btnReset").onclick=()=>{ userTouched=true; resetCamera(); }; document.getElementById("btnBang").onclick=()=>{ userTouched=true; toggleBang(); }; document.getElementById("btnRun").onclick=()=>{ userTouched=true; startRun(); }; document.getElementById("btnSpeed").onclick=()=>{ userTouched=true; cycleSpeed(); }; document.querySelectorAll(".scn-btn").forEach(b=>{ b.onclick=()=>{ userTouched=true; setChapter(b.dataset.scn); }; }); setChapter("boot"); document.getElementById("btnTheme").onclick=()=>{ applyTheme(document.documentElement.getAttribute("data-theme")==="light"?"dark":"light"); }; syncBangButton(); const LABEL_MODES=["hubs","all","none"]; let labelMode=0; try{ const lm=localStorage.getItem("atlas-labels"); if(lm && LABEL_MODES.includes(lm)) labelMode=LABEL_MODES.indexOf(lm); }catch(e){} // Yo Jones - cycles label density between hubs, everything, and nothing. function cycleLabels(){ labelMode=(labelMode+1)%LABEL_MODES.length; try{ localStorage.setItem("atlas-labels",LABEL_MODES[labelMode]); }catch(e){} syncLabelButton(); markDirty(); } // Yo Jones - writes the current label mode onto its button. function syncLabelButton(){ document.getElementById("btnLabels").textContent="Labels: "+LABEL_MODES[labelMode]; } syncLabelButton(); document.getElementById("btnLabels").onclick=cycleLabels; const wAxis=document.getElementById("wAxis"); wAxis.oninput=()=>{ cam.wAngle=(wAxis.value/100)*Math.PI*0.85; document.getElementById("wVal").textContent=(wAxis.value/100).toFixed(2); userTouched=true; markDirty(); }; // Yo Jones - the small-screen fold that gets the control dock out of the way. (function dockFold(){ const dock=document.getElementById("dock"), btn=document.getElementById("dockToggle"); const small=()=>window.matchMedia("(max-width:760px)").matches; if(small()) dock.classList.add("folded"); // Yo Jones - keeps the fold button label and aria state matching the dock. const sync=()=>{ const open=!dock.classList.contains("folded"); btn.setAttribute("aria-expanded",open?"true":"false"); btn.textContent=open?"Hide":"Controls"; }; sync(); btn.addEventListener("click",()=>{ dock.classList.toggle("folded"); sync(); }); })(); /* ---- legend ---- */ const legEl=document.getElementById("legend"); Object.keys(GROUPS).forEach(g=>{ const G=GROUPS[g]; const count=N.reduce((a,n)=>a+(n.g===g?1:0),0); if(!count) return; const row=document.createElement("button"); row.type="button"; row.className="leg"; row.dataset.g=g; row.title=G.label+" \u00b7 shift-click to solo"; row.innerHTML='<span class="dot" style="color:'+G.c+';background:'+G.c+'"></span>'+G.label+ '<span class="lcount">'+count+'</span>'; row.addEventListener("click",ev=>{ if(ev.shiftKey){ // Solo: everything except this domain goes dark, or comes back if it already was soloed. const soloed = legendOff.size===Object.keys(GROUPS).length-1 && !legendOff.has(g); legendOff.clear(); if(!soloed) Object.keys(GROUPS).forEach(k=>{ if(k!==g) legendOff.add(k); }); }else{ if(legendOff.has(g)) legendOff.delete(g); else legendOff.add(g); } legEl.querySelectorAll(".leg").forEach(r=>r.classList.toggle("off",legendOff.has(r.dataset.g))); refreshVisibility(); }); legEl.appendChild(row); }); /* ---- index drawer: the full corpus as a list ---- */ const papersEl=document.getElementById("papers"); // Yo Jones - placeholder until the index builds and hands over the real one. // Yo Jones - opens or closes the index drawer. function setPapers(o){ papersEl.classList.toggle("open",o); const tab=document.getElementById("papersTab"); tab.setAttribute("aria-expanded",o?"true":"false"); tab.textContent=o?"Close index":"Index \u00b7 all pages"; markDirty(); } // Yo Jones - builds the searchable index. Every linked row goes straight to its page. (function buildPapers(){ const listEl=document.getElementById("papersList"); const searchEl=document.getElementById("papersSearch"); const subEl=document.getElementById("papersSub"); const GORDER=Object.keys(GROUPS); subEl.textContent=NL+" functions"; // headline work first within each domain, then alphabetical const tierRank={core:0,ref:1,prior:2,companion:3,model:4,lang:5}; const items=N.map((n,i)=>({n,i})).sort((a,b)=>{ const ta=tierRank[a.n.tier]??9, tb=tierRank[b.n.tier]??9; return ta!==tb ? ta-tb : a.n.t.localeCompare(b.n.t); }); // Yo Jones - redraws the index against the current search string. function render(){ const f=query.trim().toLowerCase(); const frag=document.createDocumentFragment(); let shown=0; for(const g of GORDER){ const inGrp=items.filter(o=>o.n.g===g && (!f || o.n.hay.includes(f))); if(!inGrp.length) continue; const h=document.createElement("div"); h.className="pgrp"; h.textContent=GROUPS[g].label+" \u00b7 "+inGrp.length; frag.appendChild(h); for(const o of inGrp){ const row=document.createElement("div"); row.className="pitem"; row.dataset.i=o.i; row.title="Focus "+o.n.t; const dot=document.createElement("span"); dot.className="pdot"; dot.style.color=o.n.col; dot.style.background=o.n.col; const meta=document.createElement("span"); meta.className="pmeta"; const name=document.createElement("span"); name.className="pname"; name.textContent=o.n.t; meta.appendChild(name); { const sub=document.createElement("span"); sub.className="pyr"; sub.textContent=GROUPS[o.n.g].label; meta.appendChild(sub); } row.appendChild(dot); row.appendChild(meta); row.setAttribute("aria-label","Focus "+o.n.t); // Yo Jones - the index never navigates; it flies the camera to the picked function and shows its card. row.addEventListener("click",()=>{ selectNode(o.n); flyTo(o.n); setPapers(false); }); frag.appendChild(row); shown++; } } listEl.innerHTML=""; if(!shown){ const e=document.createElement("div"); e.className="pempty"; e.textContent="Nothing matches that."; listEl.appendChild(e); } else listEl.appendChild(frag); } document.getElementById("papersTab").addEventListener("click",()=>setPapers(!papersEl.classList.contains("open"))); document.getElementById("papersClose").addEventListener("click",()=>setPapers(false)); searchEl.addEventListener("input",()=>{ query=searchEl.value; render(); refreshVisibility(); }); render(); })(); /* ---- help card ---- */ // Yo Jones - shows or hides the shortcut card. function setHelp(o){ document.getElementById("help").classList.toggle("show",o); document.getElementById("helpBtn").setAttribute("aria-expanded",o?"true":"false"); } document.getElementById("helpBtn").addEventListener("click",()=>{ setHelp(!document.getElementById("help").classList.contains("show")); }); let hintTimer=setTimeout(hideHint,7000); // Yo Jones - fades the first-run hint away for good. function hideHint(){ const h=document.getElementById("hint"); if(h) h.style.opacity="0"; clearTimeout(hintTimer); } /* ================= render ================= */ const MONO = (getComputedStyle(document.body).getPropertyValue("--font-mono")||"monospace").trim(); // Reusable fog-band buffers: edges are binned by depth and each band strokes in one path, // turning hundreds of individual line strokes into a couple dozen. const EBANDS=12; const eband=Array.from({length:EBANDS},()=>[]); const bandBang=new Float32Array(EBANDS), bandCount=new Float32Array(EBANDS); const onE=[]; // reused, never reallocated const order=[]; // reused draw order const labels=[]; // reused label queue const lboxes=[]; // reused label collision boxes for(let i=0;i<NL;i++) order.push(i); const UIF = (getComputedStyle(document.body).getPropertyValue("--font-ui")||"sans-serif").trim(); const cardBlockers=[document.querySelector(".mast"), document.getElementById("dock"), document.getElementById("ctxBar"), document.querySelector(".rail")]; // Yo Jones - picks a spot for a popup card beside its node: right, left, above, then below, skipping any spot that // runs off screen or lands under the masthead, the dock, the context bar or the bottom rail. function placeCard(nx,ny,w,h,off){ const rects=cardBlockers.map(el=>el&&el.getBoundingClientRect()).filter(r=>r&&r.width>0&&(r.height>0)); const live=rects.filter((r,k)=>k!==2 || document.getElementById("ctxBar").classList.contains("show")); const tries=[[nx+off,ny-h/2],[nx-off-w,ny-h/2],[nx-w/2,ny-off-h],[nx-w/2,ny+off]]; for(const [x,y] of tries){ if(x<8 || x+w>W-8 || y<8 || y+h>H-8) continue; if(live.some(q=>x<q.right+6 && x+w>q.left-6 && y<q.bottom+6 && y+h>q.top-6)) continue; return {x,y}; } return {x:Math.max(8,Math.min(W-8-w,tries[0][0])), y:Math.max(8,Math.min(H-8-h,tries[0][1]))}; } // Yo Jones - traces a rounded rectangle path for the popup cards. function roundRect(x,y,w,h,r){ ctx.beginPath(); ctx.moveTo(x+r,y); ctx.lineTo(x+w-r,y); ctx.quadraticCurveTo(x+w,y,x+w,y+r); ctx.lineTo(x+w,y+h-r); ctx.quadraticCurveTo(x+w,y+h,x+w-r,y+h); ctx.lineTo(x+r,y+h); ctx.quadraticCurveTo(x,y+h,x,y+h-r); ctx.lineTo(x,y+r); ctx.quadraticCurveTo(x,y,x+r,y); ctx.closePath(); } // Yo Jones - the whole paint. Runs only when something moved or something changed. function frame(ts){ rafPending=false; if(document.hidden) return; // Weak tiers render at a calmer cadence. The graph still feels fluid, but the fan stays quiet. const minFrameMs=QLEVEL>=3?40:QLEVEL===2?30:0; if(minFrameMs && lastPaintTS && ts-lastPaintTS<minFrameMs){ scheduleFrame(); return; } lastPaintTS=ts; if(lastTS){ emaDT = emaDT*0.9 + (ts-lastTS)*0.1; } lastTS=ts; // cinematic push-in on load if(introActive){ cam.dist += (6.4-cam.dist)*0.045; if(Math.abs(cam.dist-6.4)<0.05){ cam.dist=6.4; introActive=false; } } // ease the camera toward a selected node or a fit if(cam.fly){ cam.yaw += angDiff(cam.yaw,cam.fly.yaw)*0.12; cam.pitch += (cam.fly.pitch-cam.pitch)*0.12; cam.dist += (cam.fly.dist -cam.dist )*0.10; cam.panX += ((cam.fly.panX||0)-cam.panX)*0.12; cam.panY += ((cam.fly.panY||0)-cam.panY)*0.12; if(--cam.fly.n<=0){ cam.dist=cam.fly.dist; cam.panX=cam.fly.panX||0; cam.panY=cam.fly.panY||0; cam.fly=null; } } // The one free fit: fires once the layout has settled and the entrance is over. if(wantAutoFit && !introActive && !cam.fly && !gesture && !userTouched){ wantAutoFit=false; autoFitDone=true; fitAll(true); } // Let weak machines show the entrance, then rest instead of spinning forever. if(lowPowerSpinUntil && ts>lowPowerSpinUntil){ lowPowerSpinUntil=0; setSpin(false, true); } // idle spin, paused while dragging, flying, or pushing in if(spin && !gesture && !cam.fly && !introActive){ cam.yaw+=0.000581*spinDir; } const hoverMagic=!!hoverNode && QLEVEL<=1 && !reduceMotion; const pbLive = runLive(); const wMoving = stepTimeAxis(); const animating = introActive || wantAutoFit || !!bangFx || !!cam.fly || !!gesture || !settled || (spin && !gesture) || hoverMagic || pbLive || wMoving; if(!animating && !dirty) return; dirty=false; // physics: full-rate normally, half-rate on the leanest tiers while still settling if(!settled){ if(QLEVEL>=2){ if((fcount&1)===0) step(); } else step(); } fcount++; // if frames are consistently slow, shed cost a step at a time (never the reverse) if(!introActive && QLEVEL<QMAX){ if(emaDT>30){ if(++slowFrames>=36){ stepQualityDown(); slowFrames=0; } } else if(slowFrames>0) slowFrames--; } const edgeGlow = (QLEVEL===0) && (TH===themes.dark); const haloMinR = QLEVEL>=3?9 : QLEVEL===2?6 : QLEVEL===1?4 : 0; const starCount = QLEVEL>=3?40 : QLEVEL===2?70 : QLEVEL===1?110 : 160; const pulse = 1 + 0.16*Math.sin(ts*0.005); const revealT=reduceMotion?1:Math.max(0,Math.min(1,(ts-introStarted)/2100)); const sceneReveal=revealT*revealT*(3-2*revealT); if(gradDirty) buildGradients(); ctx.drawImage(bgLayer,0,0,bgLayer.width,bgLayer.height,0,0,W,H); rotateAll(); projectAll(); applyBangTransform(ts); // starfield (count scales down on weaker devices) ctx.globalCompositeOperation="source-over"; for(let si=0; si<starCount; si++){ const s=STARS[si]; const p=projectPoint(s.x,s.y,s.z); if(!p) continue; const a=(0.10+s.m*0.22)*Math.min(1,p.ps*0.6); ctx.fillStyle=TH.star+a+")"; const r=(0.5+s.m*1.1)*p.ps; ctx.fillRect(p.sx-r/2,p.sy-r/2,r,r); } // depth extent for fog let zmin=1e9,zmax=-1e9; for(let i=0;i<NL;i++){ const d=PD[i]; if(d<zmin)zmin=d; if(d>zmax)zmax=d; } const zr=Math.max(0.001,zmax-zmin); // While a trace is shown (playing or held), everything the spark has not reached yet stays faint. const prog = run ? runAdvance(ts) : null; const active = hoverNode || focusNode; const activeI = active? idx.get(active.id) : -1; const neigh = active? adj[activeI] : null; // edges, binned into fog bands so the whole mass draws in a handful of strokes ctx.lineCap="round"; for(let bi=0; bi<EBANDS; bi++){ eband[bi].length=0; bandBang[bi]=0; bandCount[bi]=0; } onE.length=0; for(const e of edges){ if(!HIT[e.s]||!HIT[e.t]) continue; const ax=PX[e.s], ay=PY[e.s], bx=PX[e.t], by=PY[e.t]; const edgeBang=Math.min(PB[e.s],PB[e.t]); if(edgeBang<=0.02) continue; if(active && (e.s===activeI||e.t===activeI)){ onE.push(ax,ay,bx,by,edgeBang,(PD[e.s]+PD[e.t])/2); continue; } const dep=(PD[e.s]+PD[e.t])/2; let fog=1-(dep-zmin)/zr; if(fog<0)fog=0; else if(fog>1)fog=1; let bi=(fog*EBANDS)|0; if(bi>=EBANDS)bi=EBANDS-1; const arr=eband[bi]; arr.push(ax,ay,bx,by); bandBang[bi]+=edgeBang; bandCount[bi]++; } for(let bi=0; bi<EBANDS; bi++){ const arr=eband[bi]; if(!arr.length) continue; const fog=(bi+0.5)/EBANDS; const bangMul=bandCount[bi]?(bandBang[bi]/bandCount[bi]):1; let base=TH.edgeBase*(0.35+fog*0.65)*(.12+.88*sceneReveal)*(0.04+0.96*bangMul); if(active) base*=TH.dim; if(run) base*=RUN_FAINT_EDGE; if(edgeGlow){ ctx.globalCompositeOperation="lighter"; ctx.strokeStyle=TH.edge+(base*0.5*TH.glow)+")"; ctx.lineWidth=1.4*(0.6+fog*0.7); ctx.beginPath(); for(let k=0;k<arr.length;k+=4){ ctx.moveTo(arr[k],arr[k+1]); ctx.lineTo(arr[k+2],arr[k+3]); } ctx.stroke(); } ctx.globalCompositeOperation="source-over"; ctx.strokeStyle=TH.edge+base+")"; ctx.lineWidth=0.9*(0.6+fog*0.7)*TH.lw; ctx.beginPath(); for(let k=0;k<arr.length;k+=4){ ctx.moveTo(arr[k],arr[k+1]); ctx.lineTo(arr[k+2],arr[k+3]); } ctx.stroke(); } // highlighted neighbourhood edges at full strength, always a small set for(let k=0;k<onE.length;k+=6){ const ax=onE[k],ay=onE[k+1],bx=onE[k+2],by=onE[k+3],edgeBang=onE[k+4],dep=onE[k+5]; let fog=1-(dep-zmin)/zr; if(fog<0)fog=0; else if(fog>1)fog=1; if(edgeGlow){ ctx.globalCompositeOperation="lighter"; ctx.strokeStyle=TH.edge+((0.45*TH.glow)*(0.08+0.92*edgeBang))+")"; ctx.lineWidth=2.4*(0.6+fog*0.7); ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke(); } ctx.globalCompositeOperation="source-over"; ctx.strokeStyle=TH.edge+(0.9*(0.08+0.92*edgeBang))+")"; ctx.lineWidth=1.5*(0.6+fog*0.7)*TH.lw; ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke(); } // A few traveling sparks trace the active constellation. They disappear on lite tiers. if(onE.length && QLEVEL<=1 && !reduceMotion){ const sparks=Math.min(10,Math.floor(onE.length/6)); ctx.globalCompositeOperation=TH===themes.dark?"lighter":"source-over"; for(let k=0;k<sparks;k++){ const ax=onE[k*6],ay=onE[k*6+1],bx=onE[k*6+2],by=onE[k*6+3],edgeBang=onE[k*6+4]; if(edgeBang<=0.25) continue; const t=(ts*0.00036+k*0.173)%1; const x=ax+(bx-ax)*t, y=ay+(by-ay)*t; const rr=(1.15+0.65*Math.sin(Math.PI*t))*(0.45+0.55*edgeBang); ctx.fillStyle=hexA(active.col,(TH===themes.dark?0.86:0.68)*(0.3+0.7*edgeBang)); ctx.beginPath(); ctx.arc(x,y,rr,0,7); ctx.fill(); } } // node draw order: far -> near, sorted in place on a reused array order.sort((i,j)=>PD[j]-PD[i]); labels.length=0; for(let oi=0; oi<NL; oi++){ const i=order[oi]; if(!HIT[i]) continue; const n=N[i]; const px=PX[i], py=PY[i]; const fog=1-(PD[i]-zmin)/zr; const isActive = active && (i===activeI || (neigh&&neigh.has(i))); const dim = active && !isActive; let r=Math.max(1.6, n.rad*PS[i]); const isFocus = focusNode===n; const col = isFocus? FOCUS_C : n.col; const nr=reduceMotion?1:Math.max(0,Math.min(1,(revealT-n.magic*.34)/.42)); const nodeReveal=nr*nr*(3-2*nr); const bangReveal=PB[i]; r*=(.76+.24*nodeReveal)*(.24+.76*bangReveal); let alpha=(0.35+fog*0.65)*(.08+.92*nodeReveal)*(.12+.88*bangReveal); if(dim) alpha*=0.28; const untouched = !!run && !(run.touchAt[i]<=run.clock); if(untouched) alpha*=RUN_FAINT_NODE; // bloom halo: always for focus/active, otherwise only when the node is big enough to matter if((isFocus || isActive || (r>=haloMinR && !untouched)) && bangReveal>0.14){ ctx.globalCompositeOperation = TH===themes.dark? "lighter":"source-over"; let hmul = isFocus?4.6 : isActive?4.2 : 3.0; let ga = (isFocus?0.95 : isActive?0.8 : 0.5)*alpha*(TH===themes.dark?1:0.7); if(isFocus){ hmul*=pulse; ga*=(0.9+0.12*(pulse-1)); } const halo=r*hmul*(0.7+fog*0.5); ctx.globalAlpha=ga; ctx.drawImage(glowSprite(col), px-halo, py-halo, halo*2, halo*2); ctx.globalAlpha=1; } // solid core ctx.globalCompositeOperation="source-over"; ctx.fillStyle=col; ctx.globalAlpha=Math.min(1,alpha+0.15); ctx.beginPath(); ctx.arc(px,py,r,0,7); ctx.fill(); // bright rim ctx.globalAlpha=Math.min(1,alpha*0.9); ctx.lineWidth=Math.max(0.6,r*0.22); ctx.strokeStyle=hexA("#ffffff",0.55*alpha); ctx.beginPath(); ctx.arc(px,py,r*0.62,0,7); ctx.stroke(); ctx.globalAlpha=1; // One restrained broken orbit around the active node, not a field of fireworks. if(i===activeI && QLEVEL<=1 && bangReveal>0.55){ ctx.save(); ctx.globalCompositeOperation=TH===themes.dark?"lighter":"source-over"; ctx.setLineDash([2,4,8,4]); ctx.lineDashOffset=-ts*0.012; ctx.lineWidth=1.15; ctx.strokeStyle=hexA(col,TH===themes.dark?0.68:0.52); ctx.beginPath(); ctx.arc(px,py,r*(2.0+.08*Math.sin(ts*.004)),0,7); ctx.stroke(); ctx.restore(); } const mode=LABEL_MODES[labelMode]; let showLabel=false; if(bangReveal>0.62 && mode!=="none"){ if(isFocus) showLabel=true; else if(active) showLabel=!!isActive; else if(mode==="all") showLabel=true; else showLabel = n.deg>=5 || fog>0.86; } if(showLabel) labels.push(i,px,py,r,fog,isFocus?1:(isActive?2:(untouched?3:0))); } // labels on top, near ones first so the front of the cloud wins any collision ctx.globalCompositeOperation="source-over"; ctx.textAlign="left"; ctx.textBaseline="middle"; lboxes.length=0; for(let k=labels.length-6;k>=0;k-=6){ const i=labels[k], px=labels[k+1], py=labels[k+2], r=labels[k+3], fog=labels[k+4], kind=labels[k+5]; const n=N[i]; const fs = kind===1?14.7 : kind===2?14.1 : 12.4; ctx.font='600 '+fs+'px '+MONO; let wpx; if(n._lwFS===fs){ wpx=n._lwVal; } else { wpx=ctx.measureText(n.t).width; n._lwFS=fs; n._lwVal=wpx; } const tx=px+r+6, ty=py; // collision rejection, so a fitted view reads as a map instead of a smear if((kind===0||kind===3) && LABEL_MODES[labelMode]==="all"){ let clash=false; for(let b=0;b<lboxes.length;b+=4){ if(tx-3<lboxes[b+2] && tx+wpx+3>lboxes[b] && ty-9<lboxes[b+3] && ty+9>lboxes[b+1]){ clash=true; break; } } if(clash) continue; lboxes.push(tx-3,ty-9,tx+wpx+3,ty+9); } const a = kind===1?1 : kind===2?0.95 : Math.min(0.85,0.35+fog*0.7)*(kind===3?0.32:1); if(TH===themes.dark){ ctx.fillStyle="rgba(8,12,22,"+(0.5*a)+")"; ctx.fillRect(tx-3,ty-9,wpx+6,18); } ctx.fillStyle = kind===1? FOCUS_C : TH.label; ctx.globalAlpha=a; ctx.fillText(n.t, tx, ty); ctx.globalAlpha=1; } // cinematic vignette (cached gradient; dropped on the leanest tier) if(QLEVEL<3 && TH.vign>0 && vignGrad){ ctx.globalCompositeOperation="source-over"; ctx.fillStyle=vignGrad; ctx.fillRect(0,0,W,H); } // Yo Jones - draws the recorded chapter over the faint graph. Every edge that carried a call or its return is // burned red and fades with age toward a floor that stays visible; a bright green spark leads; a reached node // grows and opens a popup that eases in, holds while it can be read, and eases out as the next hop begins. // Returns get a small, dim spark and no popup. Await resumes travel on a dashed hairline, never burned. if(run && prog){ const dark=TH===themes.dark; const burn=dark?RUN_BURN.dark:RUN_BURN.light; const it=prog.it; ctx.globalCompositeOperation="source-over"; ctx.lineCap="round"; // burned trail for(const e of run.burns.values()){ if(!HIT[e.a]||!HIT[e.b]) continue; const eb=Math.min(PB[e.a],PB[e.b]); if(eb<=0.02) continue; const age=Math.max(0,run.clock-e.at); const heat=BURN_FLOOR+(1-BURN_FLOOR)*Math.exp(-age/BURN_TAU); if(dark && heat>0.3){ ctx.strokeStyle=hexA(burn,0.22*heat*eb); ctx.lineWidth=5.2; ctx.beginPath(); ctx.moveTo(PX[e.a],PY[e.a]); ctx.lineTo(PX[e.b],PY[e.b]); ctx.stroke(); } ctx.strokeStyle=hexA(burn,(dark?0.95:0.9)*heat*eb); ctx.lineWidth=1.2+1.2*heat; ctx.beginPath(); ctx.moveTo(PX[e.a],PY[e.a]); ctx.lineTo(PX[e.b],PY[e.b]); ctx.stroke(); } // the edge under a traveling call burns up to the spark if(!prog.done && it.k===0 && it.a>=0 && it.b>=0 && HIT[it.a] && HIT[it.b] && prog.travel<1){ const ax=PX[it.a], ay=PY[it.a]; const bx=ax+(PX[it.b]-ax)*prog.travel, by=ay+(PY[it.b]-ay)*prog.travel; ctx.strokeStyle=hexA(burn,0.95); ctx.lineWidth=2.4; ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke(); } // popups: every call, root or resume whose window is open right now const pops=[]; const lo=Math.max(0,run.idx-3); for(let i=lo;i<=run.idx;i++){ const p=run.items[i]; if(p.k===1||p.k===4||p.to<0||!HIT[p.to]) continue; const tIn=(run.clock-p.arrive)/PB_FADE, tOut=(p.popEnd-run.clock)/PB_FADE; let op=Math.min(1,Math.max(0,Math.min(tIn,tOut))); if(prog.done && i===run.items.length-1) op=0; op=op*op*(3-2*op); if(op>0.002) pops.push({p,op}); } // enlarged reached nodes, under the spark and the cards for(const {p,op} of pops){ const i=p.to, n=N[i]; const r0=Math.max(1.6, n.rad*PS[i])*(.24+.76*PB[i]); const r=r0*(1+1.5*op); ctx.globalCompositeOperation=dark?"lighter":"source-over"; ctx.globalAlpha=0.85*op; ctx.drawImage(glowSprite(n.col), PX[i]-r*3.6, PY[i]-r*3.6, r*7.2, r*7.2); ctx.globalCompositeOperation="source-over"; ctx.globalAlpha=Math.min(1,0.25+op); ctx.fillStyle=n.col; ctx.beginPath(); ctx.arc(PX[i],PY[i],r,0,7); ctx.fill(); ctx.lineWidth=1.6; ctx.strokeStyle=hexA(p.k===0?RUN_SPARK:"#ffffff",0.85*op); ctx.beginPath(); ctx.arc(PX[i],PY[i],r+3,0,7); ctx.stroke(); ctx.globalAlpha=1; p._r=r; } // leading spark if(!prog.done && it.to>=0 && HIT[it.to]){ const bx=PX[it.to], by=PY[it.to]; const hasFrom = it.from>=0 && HIT[it.from]; const ax=hasFrom?PX[it.from]:bx, ay=hasFrom?PY[it.from]:by; const hx=ax+(bx-ax)*prog.travel, hy=ay+(by-ay)*prog.travel; if(it.k===3 && hasFrom && prog.travel<1){ ctx.save(); ctx.setLineDash([3,5]); ctx.lineWidth=1; ctx.strokeStyle=hexA(RUN_SPARK,dark?0.45:0.7); ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke(); ctx.restore(); } let a=1, big=true; if(it.k===1){ big=false; a=dark?0.5:0.65; } if(it.k===2 || (it.k===3 && !hasFrom)) a=prog.travel; // appears in place if(it.k===4) a=1-prog.travel; // root finished: fades where it stopped if(a>0.01){ ctx.globalCompositeOperation=dark?"lighter":"source-over"; const g=big?16:7; ctx.globalAlpha=a*(dark?1:0.8); ctx.drawImage(glowSprite(RUN_SPARK), hx-g, hy-g, g*2, g*2); ctx.globalCompositeOperation="source-over"; ctx.globalAlpha=a; ctx.fillStyle=RUN_SPARK; ctx.beginPath(); ctx.arc(hx,hy,big?4.2:2.2,0,7); ctx.fill(); if(big){ ctx.fillStyle="rgba(255,255,255,.9)"; ctx.beginPath(); ctx.arc(hx,hy,1.7,0,7); ctx.fill(); } ctx.globalAlpha=1; } } // popup cards, oldest first so the newest sits on top for(const {p,op} of pops){ const i=p.to; const L=popupLines(p); const padX=14, padY=11, maxW=Math.min(440, W-48); ctx.font='650 19px '+MONO; const tw=ctx.measureText(L.title).width; ctx.font='500 15px '+UIF; const l2=wrapText(L.l2, maxW-padX*2); ctx.font='500 12.4px '+MONO; const l3=L.l3 ? wrapText(L.l3, maxW-padX*2) : []; let w=tw; ctx.font='500 15px '+UIF; l2.forEach(s=>{ w=Math.max(w,ctx.measureText(s).width); }); ctx.font='500 12.4px '+MONO; l3.forEach(s=>{ w=Math.max(w,ctx.measureText(s).width); }); w=Math.min(maxW, w+padX*2); const h=padY*2+24+l2.length*20+(l3.length?6+l3.length*17:0); const off=(p._r||6)+16; const pos=placeCard(PX[i],PY[i],w,h,off); const sc=0.94+0.06*op; ctx.save(); ctx.globalAlpha=op; ctx.translate(pos.x+w/2,pos.y+h/2); ctx.scale(sc,sc); ctx.translate(-w/2,-h/2); ctx.fillStyle=dark?"rgba(8,13,26,.93)":"rgba(255,255,255,.96)"; ctx.strokeStyle=p.k===0?hexA(RUN_SPARK,dark?0.7:0.9):(dark?"rgba(150,180,225,.45)":"rgba(34,54,88,.35)"); ctx.lineWidth=1.2; roundRect(0,0,w,h,10); ctx.fill(); ctx.stroke(); ctx.fillStyle=N[i].col; ctx.fillRect(0,12,3,h-24); ctx.textAlign="left"; ctx.textBaseline="top"; ctx.font='650 19px '+MONO; ctx.fillStyle=dark?"#eef4ff":"#10213a"; ctx.fillText(L.title, padX, padY); ctx.font='500 15px '+UIF; ctx.fillStyle=dark?"#d7e3f5":"#22344f"; l2.forEach((s,k)=>ctx.fillText(s, padX, padY+26+k*20)); if(l3.length){ ctx.font='500 12.4px '+MONO; ctx.fillStyle=dark?"#8fa6c4":"#4d627e"; l3.forEach((s,k)=>ctx.fillText(s, padX, padY+26+l2.length*20+6+k*17)); } ctx.restore(); } ctx.globalAlpha=1; ctx.globalCompositeOperation="source-over"; } // The collapse holds this single seed, and leaves it behind as a door home. const holdingBigSeed=!!bangFx && bangFx.mode==="big" && ts-bangFx.t0<(bangFx.hold||0); if(bangCollapsed || holdingBigSeed){ const sx=cx+cam.panX, sy=cy+cam.panY; const seedHover=bangCollapsed && homeSeedHover; const seedR=seedHover?9.3:7; ctx.globalCompositeOperation=TH===themes.dark?"lighter":"source-over"; ctx.globalAlpha=0.72; ctx.drawImage(glowSprite(FOCUS_C),sx-seedR*4.4,sy-seedR*4.4,seedR*8.8,seedR*8.8); ctx.globalCompositeOperation="source-over"; ctx.globalAlpha=1; ctx.fillStyle=FOCUS_C; ctx.beginPath(); ctx.arc(sx,sy,seedR,0,7); ctx.fill(); ctx.lineWidth=1.5; ctx.strokeStyle="rgba(255,255,255,.72)"; ctx.beginPath(); ctx.arc(sx,sy,seedR*.62,0,7); ctx.stroke(); if(bangCollapsed){ const seedFont=seedHover?17.3:13; ctx.font='600 '+seedFont+'px '+MONO; ctx.textAlign="left"; ctx.textBaseline="middle"; const label="Big Bang"; const tx=sx+seedR+8, ty=sy; const tw=ctx.measureText(label).width; if(TH===themes.dark){ ctx.fillStyle="rgba(8,12,22,.72)"; ctx.fillRect(tx-4,ty-seedFont*.72,tw+8,seedFont*1.44); } ctx.fillStyle=FOCUS_C; ctx.fillText(label,tx,ty); } } if(animating || dirty) scheduleFrame(); } /* ================= boot ================= */ // Small graphs get a fuller pre-compose. Large graphs do less synchronous work and // finish settling after first paint, avoiding the old main-thread wall on weak machines. const prewarmSteps=NL>360?10:NL>220?18:NL>160?34:72; for(let i=0;i<prewarmSteps && !settled;i++) step(); refreshVisibility(); rotateAll(); projectAll(); scheduleFrame(); </script> <script> window.addEventListener('keydown',function(e){if(e.key==='Escape')parent.postMessage({type:'ironfist-atlas-escape'},'*');}); </script></body> <!--Damn this thing - always wants attention--> </html>Appendix GIF fields across Specify, Symbiota and openDS
Source: one record from ironfist1008, exported 2026-09-29 through all three collection-system builders. Every cell was read from what those builders wrote, not from memory. Specify column: WorkBench header → table.field. Symbiota: the Darwin Core Archive, occurrence.csv unless noted. openDS: the 0.4.0 Digital Specimen, every key checked against its schema.
| IF field | Sample value | Specify 6/7 WorkBench | Symbiota / DwC-A | openDS 0.4.0 | Fit | Note |
|---|---|---|---|---|---|---|
| Identifiers | ||||||
id (Voucher ID) | 4f3c9a2e-… | Alt Cat Number → CollectionObject.altCatalogNumber | otherCatalogNumbers | ods:hasIdentifiers, "Ironfist Voucher ID", Alternative, on the label | direct | The QR on the sheet. Not the catalog number. |
occurrenceID | b8e2d4c1-… | GUID → CollectionObject.guid | occurrenceID (also the row id) | ods:physicalSpecimenID; identifier, Preferred | direct | Each system mints its own if this is not carried in. |
legacyVoucherId, sourceVoucherId | (empty) | not written | otherCatalogNumbers, joined with "; " | ods:hasIdentifiers, Superseded / Alternative | reshape | Several IDs in one delimited string on the Symbiota side. |
| catalog number | (blank on purpose) | Catalog Number, blank | catalogNumber, blank | registry fields left for DiSSCo: dcterms:identifier, ods:midsLevel, … | theirs | The receiving system assigns these. A placeholder would collide or be imported as real. |
voucherIdSource | sheet | Ironfist Provenance (JSON, unmapped) | dynamicProperties | dwc:dynamicProperties | IF only | Carried, with no native field. |
institution | LSU | Institution (unmapped; set by the collection) | institutionCode | ods:organisationName, or ods:organisationID when it is a ROR or Wikidata ID | reshape | |
basisOfRecord | PreservedSpecimen | not stored; implied by the collection | basisOfRecord | dwc:basisOfRecord | reshape | |
| Determination | ||||||
currentScientificName | Carex crinita | Genus, Species → taxon tree; Full Name for checking | scientificName, genus, specificEpithet, taxonRank | ods:TaxonIdentification: name, rank, HTML labels | reshape | Specify needs the name in its taxon tree; free text is refused. |
scientificName (original) | Carex crinita | Det 2 … columns when it differs | identification.csv row, identificationIsCurrent 0 | a second ods:Identification, not verified | reshape | A separate row only when a redetermination exists. |
qualifier | determined | Qualifier, blank for determined | identificationQualifier, blank | dwc:identificationQualifier, blank | reshape | Darwin Core keeps this field for doubt: cf., aff., nr., ?. "determined" is a status and goes out as the next row. |
| verification status | determined | Verification Status → Determination.confidence (varies) | identificationVerificationStatus, occurrence and identification rows | dwc:identificationRemarks: "Verification status: determined" | varies | Never blank, so a blank qualifier never travels alone. openDS 0.4.0 has no key for it and refuses unknown keys. |
identifiedByFirst, identifiedByLast | Timothy / Jones | Determiner First/Last Name → Agent | identifiedBy, joined | agent, role "identifier" | reshape | Specify keeps the split; the others join. |
identifiedByOrcid | (empty) | Determiner ORCID (version dependent) | identifiedByID | agent identifier | varies | |
dateIdentified | 2026-09-29 | Determined Date | dateIdentified | dwc:dateIdentified | direct | |
identificationRemarks (current) | (empty) | Determination Remarks | identificationRemarks | dwc:identificationRemarks | direct | |
family, kingdom | Cyperaceae / Plantae | Family; kingdom from the tree | family, kingdom | on the ods:TaxonIdentification | reshape | Specify derives these from the tree. |
confidence | High | Ironfist Provenance | dynamicProperties idConfidence | dwc:dynamicProperties | IF only | How sure the determiner was. No native field anywhere. |
| Collecting event | ||||||
date | 2026-09-29 | Start Date | eventDate | dwc:eventDate + year, month, day | direct | |
collectorFirst, collectorLast | Timothy / Jones | Collector 1 First/Last Name, primary | recordedBy | event agent, role "collector", position 1 | reshape | |
collectorOrcid | (empty) | Collector 1 ORCID (version dependent) | recordedByID | agent identifier | varies | |
collectorNumber | 4127 | Field Number → CollectionObject.fieldNumber | recordNumber | identifier "dwc:recordNumber", on the label | varies | Herbaria differ on fieldNumber versus stationFieldNumber. |
additionalCollectors | Mary Smith | Collector 2 … columns, name split | associatedCollectors | event agents, positions 2 on | reshape | Specify needs each name split; guessed splits are flagged for review. |
| Locality and georeference | ||||||
country, stateProvince, county | USA / Louisiana / Tangipahoa | Country, State, County → geography tree | country, stateProvince, county | ods:Location | reshape | "USA" goes out as "United States"; a misspelled state is flagged, never passed through. |
localityText | Roadside ditch 1.2 km N of Tickfaw… | Locality Name | locality | dwc:locality | direct | |
lat, lon | 30.574810 / -90.482130 | Latitude1, Longitude1 + verbatim | decimalLatitude, decimalLongitude, verbatimCoordinates | ods:Georeference, as numbers, + verbatim | direct | Current coordinates go out as decimals; the original field reading rides as verbatim. |
geodeticDatum | WGS84 | Datum | geodeticDatum | dwc:geodeticDatum | direct | |
accuracy | ±8 m | Max Uncertainty Est 8 + unit m | coordinateUncertaintyInMeters 8 | dwc:coordinateUncertaintyInMeters 8 | reshape | "±" and the unit stripped; km converted. Free text is refused and flagged. |
coordinateSource | GPS (browser geolocation) | Georef Protocol | georeferenceProtocol | dwc:georeferenceProtocol | direct | |
| georeference fix (addendum) | (none) | Georef … columns: sources, remarks, determiner, date, status | georeference… columns | ods:Georeference sources and remarks | direct | Current values after any correction; the original stays as verbatim. |
| Specimen description | ||||||
habitat | Wet roadside ditch, full sun… | Habitat (varies) | habitat | dwc:habitat on the event | varies | Specify: often collecting-event remarks or an attribute field. |
associates | Juncus effusus, Scirpus cyperinus | Associated Taxa (varies) | associatedTaxa | event assertion, dwc:associatedTaxa | varies | |
description | Fruiting; perigynia mature | Description → CollectionObject.description | verbatimAttributes | dwc:organismRemarks | direct | |
notes | Common along the ditch for 50 m | Remarks → CollectionObject.remarks | occurrenceRemarks | dwc:fieldNotes on the event | direct | |
| added Darwin Core terms | verbatimElevation: 5 m | DwC: term columns, unmapped | the named column, else dynamicProperties | event assertions | varies | A term that disagrees with an IF field is flagged; the IF field wins. |
| Media | ||||||
photos[] filenames | (none on this voucher) | Photo Files (unmapped) | dynamicProperties photoFiles | ods:isKnownToContainMedia; filenames in dwc:dynamicProperties | reshape | Images travel in the take-home bundle and the photo ZIP, not in these files. The names match. |
photos[].idInImage, photoSkipped, photoSkipReason | false / (none) | Ironfist Provenance | dynamicProperties | dwc:dynamicProperties | IF only | |
| History and provenance | ||||||
addenda[] redeterminations | (none) | Det 2 … columns | identification.csv rows | further ods:Identification objects | reshape | Each status is "redetermined". Exactly one is current; a conflict is flagged, never guessed. |
createdAt, updatedAt | 2026-09-29T15:05:43Z | Ironfist Provenance | modified (updatedAt); both in dynamicProperties | dcterms:created, dcterms:modified | reshape | Specify and Symbiota stamp their own import time unless mapped. |
startedAt, durationSeconds, sessionId, workspace, createdWith, exportedWith, compileBatchId, ingestFingerprint | 212 s, ironfist1008, … | Ironfist Provenance | dynamicProperties | dwc:dynamicProperties | IF only | Capture provenance. It travels with every record, and no system has a field for it. |
Specify table names are from the stock schema. Each institution can relabel fields and choose where habitat, associates and collector number live, so check against the target herbarium's schema before building an importer. openDS registry fields (the specimen DOI, version, MIDS level, source system, organisation ID) are assigned by DiSSCo on ingestion and are left out on purpose.
Appendix HSERNEC herbarium records: full random-survey table
SERNEC herbarium records: random survey
Ten genera drawn at random from the BONAP top-100 list (Kartesz, 2015; Appendix I). Records were retained as returned by SERNEC, including synonymized and infraspecific names; no taxonomic normalization or correction was applied after retrieval. Values are percentages of records with each field present. N is the number of records. Collection date, collector, and coordinates are emphasized.
| Taxon | N | Collection date | Collector | Coordinates | Identified by |
|---|---|---|---|---|---|
| Micranthes | 442 | 66.1% | 70.1% | 17.4% | 37.8% |
| Potentilla | 1,827 | 74.7% | 77.4% | 24.5% | 34.6% |
| Euphorbia | 3,329 | 81.0% | 84.4% | 28.6% | 42.8% |
| Ribes | 417 | 77.7% | 82.0% | 21.1% | 32.1% |
| Ranunculus | 4,126 | 77.5% | 80.4% | 20.8% | 34.8% |
| Lupinus | 704 | 85.1% | 89.1% | 35.7% | 39.3% |
| Polygonum | 1,031 | 80.9% | 85.7% | 23.3% | 36.6% |
| Solidago | 7,105 | 74.4% | 77.8% | 24.3% | 37.0% |
| Chorizanthe | 9 | 100.0% | 100.0% | 11.1% | 44.4% |
| Eragrostis | 2,120 | 86.5% | 90.8% | 30.0% | 46.9% |
| Total | 21,110 | 77.8% | 81.3% | 25.0% | 38.3% |
The total row gives pooled percentages across the displayed records. Appendix B.4: SERNEC Python sampling script.
Appendix IBONAP random genus draw for the SERNEC survey
The ten genera of the SERNEC random survey (Table 3A; Appendix H) were drawn without replacement from a list of the top 100 North American genera compiled from the BONAP North American Plant Atlas (Kartesz, 2015), using seed 2026082702. Only the drawn genera are shown. The full list is BONAP data and is not reproduced here.
BONAP genera drawn for the random survey
Draw order is the order returned by the seeded draw. Rank is the genus's position in the BONAP top-100 list. Max spp. is the largest number of species in the genus recorded for a single county, with the county or counties where that peak occurs. Source: Kartesz (2015).
| Draw | Genus | Rank | Max spp. | Peak county |
|---|---|---|---|---|
| 1 | Micranthes | 62 | 13 | Siskiyou County, CA; Clackamas County, OR; Jackson County, OR |
| 2 | Potentilla | 26 | 22 | Park County, WY |
| 3 | Euphorbia | 9 | 39 | Cochise County, AZ; Pima County, AZ |
| 4 | Ribes | 57 | 17 | Los Angeles County, CA |
| 5 | Ranunculus | 35 | 23 | Park County, WY |
| 6 | Lupinus | 8 | 40 | Inyo County, CA |
| 7 | Polygonum | 67 | 13 | Plumas County, CA |
| 8 | Solidago | 21 | 23 | Macon County, NC; Yancey County, NC |
| 9 | Chorizanthe | 96 | 20 | San Luis Obispo County, CA |
| 10 | Eragrostis | 98 | 16 | Brazos County, TX |
Seed 2026082702. Rows reproduced with attribution to Kartesz, J. T., and the Biota of North America Program (BONAP), North American Plant Atlas (2015).