diff --git a/.gitignore b/.gitignore index ef7d4990..b8fac6a3 100644 --- a/.gitignore +++ b/.gitignore @@ -165,3 +165,5 @@ vscode/.vscode-test/ # The blog playground, built by scripts/build_playground.sh site/docs/play/ +# The web demo beside it, made from the playground's page by vscode/scripts/build-play.mjs +site/docs/demo/ diff --git a/docs/delivery-plan.md b/docs/delivery-plan.md index 8847a154..077ce0f1 100644 --- a/docs/delivery-plan.md +++ b/docs/delivery-plan.md @@ -107,8 +107,8 @@ Phase 3 delivers the capabilities every community needs, ending in a pilot-ready | Narrative | Word and PDF import in legacy dialects; combined Zulu time axis. Narrative entry types, which the Phase 2 .dpf import lists as not imported. Part A, done 15 Oct: narrative entries as features of their own (time, source, type, text, the track they belong to; schema 0.0.19), each added, changed or deleted as one step; REP `;NARRATIVE:` and `;NARRATIVE2:` lines read as legacy Debrief read them, a REP file's narrative also read into an open plot as one step with its source; a .dpf plot's narrative entries with their types, so no longer listed as not imported; the sample's log moved from notes to narrative entries; every source on one Zulu time axis in one order (time, source, the source's own order), held by shared vectors; read in the Timeline (decision, product owner, 15 Oct 2026), now a webview in VS Code and the playground: a quiet table that follows current time without fighting the reader, chosen entries moving current time, filter by kind, source and type, search, a form to add and change entries, the keyboard and touch, virtualised for 60,000 entries (scrolling at 60 frames a second); the entry current time is at beside its track on the map; `debrief/narrative` and the four changing methods for agents. Part B, done 15 Oct: Word (`.docx`, `.doc`) and PDF narrative in legacy Debrief's four dialects (narrative log, Rider's narrative, ASW data as a track, dated narrative), ported with their tests' cases on synthetic documents and held to legacy Debrief's own expectations on its sample documents; read into the open plot as one step with the document as its source, its dialect and the analyst's choices recorded, the analyst asked first what legacy Debrief asked (types with counts, only within the plot's period, which track an unknown platform is), or imported as a plot of its own; the same in the playground, under Pyodide; `debrief/preview_narrative` and choices on `debrief/import_narrative` for agents; and the Timeline's extras, the reader's own, in one small menu: highlighted phrases, an off switch for choosing an entry moving current time, text size, wrapping and the columns shown. Follow-ups, done 15 Oct: a line starting with a number whose time would be out of order is text of the entry before, listed in the step's record; a file read into the plot before (by its content hash, under any name), or with entries already in it, is said to be, with the choice to skip those entries or read nothing (`duplicates` for agents). | CAP-05, CAP-19, CAP-20, EXT-04, EXT-14, NFR-06 | | Collation | Coverage view, source preference, new-data marking, merging new data, upstream-revision flags. Done 16 Oct, in one pull request (product owner, 16 Oct 2026): a source is a catalogue item, an imported file or any plot; each item's tracks and the periods each has data for (first fix to last, broken at a gap of more than ten times the median time between fixes and more than five minutes; none without a median), kept in its STAC item at each change (`debrief:coverage`, with `debrief:revision`, the plot's content hash, and `debrief:drawn_from`; schema 0.0.21), so a catalogue's coverage is read from its items alone; in VS Code's Catalogues, each item's tracks with their periods, items marked *New data* (sparkle and words, the reason as tooltip and for screen readers) or *May need review*, and their menus; a **Coverage** view below it and the playground's **Catalogue** and **Coverage** panels, the same component: each of the open plot's tracks with a bar for every source of data for its platform, the periods it supplies filled, and a list choosing the item that supplies each period two items have data for; tracks of the same name (case and spaces ignored) proposed as one platform, confirmed, refused or linked by hand, each a step; the choice of source per period one step naming the items and periods (`supplies` on the track, stitched from the chosen periods); new data only through Debrief's import, marked when a file's import came in after the plot within the folder tree it draws from, sharing a platform with it and with data within its period (never a product, an item made by hand or one made from the plot; a colleague's copy only once it adds data), taken as one step naming the item (matched tracks gain fixes where they have none, periods another item supplies kept, unmatched tracks added; the analyst's moves, shears, stretches, smoothing and dragged fixes carried to new fixes by the offsets of the fixes either side; DR course and speed changes carried, positions reckoned again; marks, frequencies and annotations kept) or set aside (**Not now**, the island); any recorded change to an item a revision, the analyst told once (VS Code's notification, the playground's note) and every plot that drew from an earlier revision flagged until **Reviewed** (the island); the Timeline lists each track's data coverage by the item supplying it (CAP-05); `debrief/coverage`, `debrief/take_new_data`, `debrief/set_source_preference`, `debrief/link_tracks`, `debrief/dismiss_new_data`, `debrief/clear_review_flag` and `debrief/review_flags` for agents. Coverage of a 50-item study of 400,000 fixes in 150 to 230 ms when the plot is opened and 35 to 45 ms after, and of fifty gappy sources of a hundred spans each in 250 to 370 ms and 15 to 30 ms (budget 500 ms and 100 ms). Revised after an expert panel's review (16 Oct): revisions per period, new data as the product owner then decided, edits carried to courses and speeds, no step for data already taken. | CAP-05, CAP-08 to CAP-12 | | Export | Images carrying an embedded GUID. Done 16 Oct, in one pull request (decisions, product owner, 16 Oct 2026): **Export image…** in both hosts draws the map as the analyst sees it (labels where the map placed them, symbols and arrows, annotations, current time's markers, the narrative box, the north arrow and the scale bar) as PNG or SVG, as on screen, a 1600 × 900 slide or A4 landscape, in the editor's colours or light ones for print; each image carries an ID naming the plot, the exact revision exported and the export (`DBF1-…` in PNG iTXt and XMP, SVG metadata), and a small footer with its short form (`DBF-PTET-KG8R-B9EM-69`, two exports of one revision told apart), on for every export, in the corner it covers least of; each export logged in the plot's island (schema 0.0.22), never a step, or in the analyst's own export log for a read-only catalogue. **Find source of image…** reads an image (or the pictures in a Word or PowerPoint file) or a typed footer ID, finds the plot in the analyst's catalogues and shows it as it was then, rebuilt by replay, read-only, at the view and time exported, with **Go to latest**. `debrief/figure_id`, `debrief/export_image` (an agent's drawn by the open map), `debrief/find_image_source` and `debrief/open_revision` for agents. Revised the same day after an expert panel's review and the product owner's answers (below). The ID's format is held by Python and TypeScript to shared vectors. | SUB-25, EXT-05, OUT-04 | -| Onboarding | Guided walkthroughs on the Phase 1 sample data; onboarding trial against the one-hour target. | DEP-08, CAP-25, NFR-09 | -| Web demo | Standalone web build with Python tools under Pyodide, fixed sample data, no upload. Interactive blog examples start here. | CON-09, CON-10, DEP-06, DEP-07, DEV-06 | +| Onboarding | Guided walkthroughs on the Phase 1 sample data; onboarding trial against the one-hour target. Done 17 Oct, with the web demo in one pull request (decisions, product owner, 17 Oct 2026): four walkthroughs (*Open and explore a serial*, *Import and collate*, *Tidy a track*, *Annotate and export a figure*), each on a fresh copy of the sample's serial 1 titled after it, never the sample (`debrief_sample.walkthrough`: *Tidy a track*'s with GPS glitches in RED ONE as a step of their own, *Import and collate*'s with BLUE ONE's navigation log to import); native in each host from one source of words (`vscode/walkthroughs/tours.json`): VS Code's Get Started page (`contributes.walkthroughs`, written from it), each step with a picture drawn from the sample in theme colours, a keyboard equivalent and a button that opens the copy or brings the command to hand, ticked by a context key set when the analyst does the step (the tool service's request succeeding, or a time, selection, Tote, Timeline entry or Coverage shown by hand), never by the button; the playground's *Walkthroughs* panel, the same steps and pictures, folding like every panel, from the keyboard, at 210 to 390 px. The trial, of what exists against a provisional 40 minutes, is written as a protocol (`docs/notes/onboarding-trial.md`) for the product owner to run, and is pending (formative, its pass rule pre-registered; REP and .dpf imported, a catalogue already added); the playground's test does its tasks as a speed run, 32 actions, failing above 36. Revised after an independent panel's review (17 Oct): each step acts and ticks only on its walkthrough's copy, starting again unticks it, and the *Walkthroughs* panel is folded outside the web demo. | DEP-08, CAP-25, NFR-09 | +| Web demo | Standalone web build with Python tools under Pyodide, fixed sample data, files the analyst opens read in the browser only (DEP-07 as amended, 17 Oct 2026). Interactive blog examples start here. Done 17 Oct: the web demo full screen at `/demo/` on the documentation site, linked from its home page: the playground's page with every panel and the walkthroughs, opening the fixed sample serials. A file the analyst chooses opens there, read in the browser only, never sent or stored (product owner, 17 Oct 2026, amending DEP-07): tested by watching every request while a file is imported (none carries it; every request only fetches) and by browser storage, IndexedDB and the Cache API holding nothing of it, before and after the tab is closed. The blog's demos are unchanged. Revised after the panel's review (17 Oct): a Content-Security-Policy on every playground page, Pyodide from jsDelivr with no integrity check, an accepted risk; the web demo keeps nothing derived from a file, its Timeline filter included, tested across every store, a Word narrative, an image found by its file, WebSockets and the tab closing. | CON-09, CON-10, DEP-06, DEP-07, DEV-06 | **Exit:** a pilot-ready core on Windows 11, and on Windows 10 while VS Code supports it, installable as a VSIX file (NFR-12, DEP-01, DEP-02). @@ -186,6 +186,34 @@ The schema freeze may come earlier, even before pilots, if the model settles. **Decisions taken during Phase 3** +- [x] 17 Oct 2026, product owner: the onboarding trial trials what exists now (import, tidy, annotate and export a traceable figure) against a **provisional 40-minute target**; the full one-hour NFR-09 trial, storyboard included, reruns in Phase 4. A human trial cannot be run by the team building it: it is written as a protocol (`docs/notes/onboarding-trial.md`: participant profile, set-up, script, tasks, what to time, success criteria, observation sheet, how results are recorded) for the product owner to run, and an automated speed run in the playground's tests does the trial's tasks and records the actions they take. **The human trial is pending.** (17 Oct) +- [x] 17 Oct 2026, product owner: all four walkthroughs: (a) *Open and explore a serial* (time slider, Outline, Tote, Timeline); (b) *Import and collate* (REP import, coverage, a source chosen for a period, new data taken; .dpf named as imported the same way); (c) *Tidy a track* (jump removal, smoothing, a marked and labelled fix, shear as a TMA example); (d) *Annotate and export a figure* (an annotation drawn, labels at intervals, a traceable image exported, its source found). Each works on a fresh copy of serial 1, titled after it; the sample is never changed. (17 Oct) +- [x] 17 Oct 2026, product owner: walkthroughs are native in each host. In VS Code, the Walkthroughs API (`contributes.walkthroughs`, the Get Started page), each step ticked by `completionEvents` on a context key the extension sets when the analyst actually does the step, each with a picture drawn from the sample (SVG, theme colours) and a button that opens the right copy of the sample; in the playground, a matching step-by-step panel, its step text from the same source (`vscode/walkthroughs/tours.json`) so the two cannot drift, its steps ticked by real actions, from the keyboard, folding like other panels, working at 210, 280 and 390 px in a wide font. (17 Oct) +- [x] 17 Oct 2026, product owner: the web demo is a standalone full-screen page at `/demo/` on the documentation site with every panel and the walkthroughs and fixed sample serials, linked from the home page; the blog's embedded demos keep working. First decided with file upload removed, then, the same day, superseded: (17 Oct) +- [x] 17 Oct 2026, product owner, superseding the "no upload" wording of DEP-07: the web demo **allows a file the analyst chooses** to be opened, read only in the browser (under Pyodide), never sent over the network and never stored: nothing of it persists after the tab closes (no IndexedDB, local storage or Cache API storage of it). No warning banner. The fixed sample serials stay the default content. DEP-07 now reads: "The public instance shall ship with fixed sample data. It may open files the analyst chooses, read only in the browser, never sent or stored." Tested: every request is watched while a file of the reader's own is imported, and none carries it (in its address or body) or sends anything at all; storage holds none of it, before or after the tab is closed. (17 Oct) +- [x] 17 Oct 2026, product owner, answering the onboarding pull request's questions: the trial imports **both** a REP file (serial 2's `red-one.rep`) **and** a legacy .dpf plot, and the speed run does too. It starts with a catalogue already added, since otherwise task 1 starts by asking where plots go. A walkthrough's ticks are not remembered across a reload. In VS Code, **Start again** deletes the old copy. The file picker in the web demo has no label and no banner of its own, as before. (17 Oct) +- [x] 17 Oct 2026, product owner: the *Walkthroughs* panel is at the top of the side column everywhere: open in the web demo, folded in the playground and the blog's demos, whose readers came for something else. (17 Oct; on screens 1200 px wide or more, a column on the right instead, 22 Oct below) +- [x] 17 Oct 2026, product owner: Pyodide stays on jsDelivr, and the web demo and the playground's pages (the page, each frame, the geodesic demo) carry a strict Content-Security-Policy in a meta element: `default-src 'self'; connect-src 'self' https://cdn.jsdelivr.net; worker-src 'self' blob:; script-src 'self' 'wasm-unsafe-eval' https://cdn.jsdelivr.net; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; form-action 'none'; base-uri 'self'`. It is `base-uri 'self'`, not `'none'`, because the web demo's `` points at the playground's files. A meta element's policy does not cover the Python worker, and GitHub Pages cannot send a policy as a header. **jsDelivr's files are loaded with no integrity check: the product owner accepts this risk.** Tested: the policy is in the page and every frame, and the whole playground suite runs with nothing refused by it. (17 Oct) +- [x] 17 Oct 2026, after an independent panel's review of the onboarding pull request: + - **The web demo keeps nothing that came from a file.** In the web demo, the Timeline's remembered filter keeps only the kinds of entry shown and the view's own settings (text size, wrapping, columns, choosing moving time). It never keeps sources, types or highlighted phrases, which are words from the plot (`contentFree`). Every other value the demo keeps in the browser is a panel id, a walkthrough id or an export's size and colours. Tested after a REP file, a Word narrative, an exported image found again by its file, and the Timeline filtered and highlighted: local storage, session storage after the tab closes, IndexedDB, the Cache API and the origin's private file system hold nothing of them. No request carries them, nothing is sent at all (also as the tab closes), and no WebSocket opens. + - **A walkthrough acts and ticks only on its own copy.** Every step after the first opens the walkthrough's copy before it acts if another plot is open (VS Code: `debrief.walkthrough.do`; the playground: the panel), and refuses, saying why, before there is a copy. A step ticks only when what was done was done to that copy, in the walkthrough shown: + - not the same change to another plot; + - not the analyst's own file imported (only the walkthrough's log); + - not Coverage before the log is imported; + - not a stretch for the shear; + - not one track selected, nor a selection Debrief makes by command (VS Code's `BY_HAND` no longer includes `command`). + Serial 1 itself gains no step: tested from its history. + - **Starting a walkthrough again** unticks its steps (on VS Code's Get Started page too, `welcome.markStepIncomplete`) and replaces its copy, so ticks are per copy. A second press while a copy is being made makes none. + - **The context key stays the documented way a step ticks.** `welcome.markStepComplete` and `welcome.markStepIncomplete` are VS Code's own commands, not its API; they are used as well, because the Get Started page ignores a context key already set when its service starts. Their failures are logged, and the integration test checks that VS Code still has them. + - **The trial protocol is formative**, with a pass rule to pre-register: at least 80% of participants complete the tasks without help above the first rung of a fixed help ladder, the median *silent* run takes 40 minutes or less, and no one takes over 60. It also adds a neutral observer, tasks that do not mirror the walkthroughs, a stated tolerance for the area (within 2 nmi of RED ONE at first contact), a pilot participant, fixed hardware and VS Code version, and a reason for every failure. + - **The speed run** is labelled playground-only, imports the .dpf, smooths over 5 fixes, and fails above 36 actions. + - **Smaller fixes:** + - VS Code's Outline adds to a selection with `Ctrl+Shift+Enter`, not `Ctrl+Space`, in the walkthrough and the keyboard guide. + - The time controller's window wraps in a 210 px column, and Import's file boxes stay inside it. + - `package.json` no longer lists the walkthrough commands inside its menus. A unit test now holds every menu entry to a contributed command, by id and condition. + (17 Oct) +- [x] 22 Oct 2026, product owner, amending the 17 Oct placement of the *Walkthroughs* panel: in the browser (playground and web demo), on a screen **1200 px wide or more**, the walkthroughs are a **column of their own on the right of the map**, the side panels staying on the left, so a step's words stay in sight while the analyst does it in the side panels and on the map; at the top of the side column, a step meant scrolling between its words and the panel used. The column scrolls on its own; it folds as before (folded, a narrow tab with its heading on its side, about 25 px, and the map widens), by the pointer or the keyboard, and its status line still speaks to screen readers. Open in the web demo, folded in the playground and the blog's demos, as before. Below 1200 px (phones, the blog's embedded demos, the 601 to 900 px layouts) it stays the side column's first panel, unchanged. 1200 px because from 901 px a tool's form opens beside the map: at 1200 px the map keeps about 670 px beside both columns and about 290 px beside the form too, while at 1024 px it would keep about 150 px with the form open. The column is 20% of the window, 240 to 300 px (256, 273 and 288 px at 1280, 1366 and 1440). With the next step's button always in sight, it can be pressed while the step before is still being saved: removing jumps and applying smoothing now close only their own panel beside the map, never the fix list the next step opened. (22 Oct) + - [x] 16 Oct 2026, product owner: collation (CAP-08 to CAP-12) in one pull request, with data coverage in the Timeline (CAP-05). (16 Oct) - [x] 16 Oct 2026, product owner: a source is a catalogue item: an imported file, which Debrief keeps as an item of its own, or any other plot in a catalogue. Coverage shows, for each item and each of its tracks, the periods it has data for: in the catalogue browser (VS Code's Catalogues tree, a Coverage view below it, and the playground's Catalogue and Coverage panels) and in the Timeline (CAP-05), where each track's data coverage, by the item supplying each period, joins the narrative, notes, periods and fix comments. (16 Oct) - [x] 16 Oct 2026, product owner: source preference is per period. Where two items have data for one platform over overlapping periods, the analyst chooses which item supplies each overlapping period, with coverage visible (CAP-09); the plotted track is stitched from the chosen periods. Each choice is one undoable step in the plot's history, its provenance naming the items, the periods and the revisions taken (`TrackProperties.supplies`, `ProvRecord.drawn_from`; schema 0.0.21). (16 Oct) diff --git a/docs/notes/onboarding-trial.md b/docs/notes/onboarding-trial.md new file mode 100644 index 00000000..249155f0 --- /dev/null +++ b/docs/notes/onboarding-trial.md @@ -0,0 +1,121 @@ +# Onboarding trial: protocol + +Oct 17, 2026 · for the product owner to run · **formative** · status: **pending** (no participant has taken it yet) + +## Why, and what kind of trial + +NFR-09 asks that an analyst with domain knowledge but no Debrief training import a sample serial, compose a storyboard and export it within an hour (OUT-02, OUT-05). The storyboard is Phase 4, so the product owner decided on 17 Oct 2026 to trial now what exists: **import, tidy, annotate and export a traceable figure**, against a **provisional target of 40 minutes**. The full one-hour trial, storyboard included, runs again in Phase 4 with this protocol extended. + +This trial is **formative**. With five to eight participants, it can find the problems newcomers meet and give a rough idea of the time the work takes. It cannot show statistically that a population of analysts meets the target. Its results guide what is fixed next. NFR-09 itself is confirmed at pilot (SRD section 8). + +## Pass rule (pre-registered) + +Write this down, dated, before the first participant (pilot excepted) takes the trial, and do not change it afterwards: + +- **Completion.** At least 80% of participants complete all four tasks unaided, that is, without any help above rung 1 of the help ladder (below). With 5 participants that is at least 4; with 6, at least 5; with 7, at least 6; with 8, at least 7. +- **Time.** The **median** time of the *silent* runs (below) is at most **40 minutes**, and **no participant** takes over **60 minutes** for the four tasks. +- **Recurring problems.** Any stuck point seen in two or more participants is a finding to fix, whatever the times. + +The trial passes when the completion and time rules both hold. Otherwise it fails, and the findings say why. + +## Participants + +- **Who:** analysts with domain knowledge of the community's work (O, N or D) who have **never used Debrief v4** and have had no training on it. Some may have used legacy Debrief; record which. +- **How many:** one **pilot** participant first, then five to eight, one at a time. The pilot's results are not counted. It checks the set-up, the task card and the timings, and anything it shows wrong is fixed before the next participant. +- **Not:** anyone on the development team, or anyone who has read the blog posts or guides closely. +- **Consent:** participants agree to be observed and timed, and to their words being noted without their name. Results are reported by participant number only. + +## Set-up (fixed for every participant) + +- **Hardware:** one laptop for every session, of the kind analysts use. Record its make, model, memory and screen size here before the pilot: ______. Use a mouse; a touchpad also works, but use the same for all participants. +- **Software:** Windows 11. VS Code at version ______, recorded before the pilot and unchanged throughout. The Debrief extension from one VSIX, its version ______ recorded. Python with Debrief's packages installed, **including `debrief-sample` and its `pyarrow`**, which the walkthroughs use to make their copies. The tool service running. +- **A catalogue already added** (product owner, 17 Oct 2026): an empty catalogue named *Trial*, added with **Add catalogue folder…** before the participant arrives, so the first task does not begin by asking where plots go. No other catalogue. +- **On the desktop, two files only:** + - `red-one.rep`: serial 2's RED ONE from the sample exercise (`python -m debrief_sample build`, under `serial-2/sources/`); + - a legacy Debrief plot, `legacy.dpf`: an unclassified .dpf plot the product owner provides. The playground's speed run uses `shapes.dpf` from debrief-io's tests in its place. +- The Debrief sidebar open, VS Code's Get Started page closed, notifications cleared. Screen recording on, if the participant agrees. +- A clock the observer can read; the observation sheet below, printed or open on another machine. +- Reset between participants: remove the *Trial* catalogue's contents, the walkthroughs' copies (**Start again** in each, or VS Code's extension storage), and VS Code's walkthrough progress; then check the set-up again. + +## The observer + +- **Neutral:** someone who did not build the part being tested, who reads the script as written and does not react to what the participant does. They do not demonstrate, hint, nod or say whether a step is right. +- **The help ladder.** When the participant asks for help, or makes no progress for 3 minutes, the observer climbs one rung at a time, and notes the rung and the time: + 1. "What are you trying to do?" (no help with Debrief) + 2. "Where have you looked? Is there anywhere else in Debrief that might help?" + 3. Points to where to look: "Try the walkthroughs" or "Look in the Outline's menu". + 4. Shows the step. **The task then fails**, and the participant carries on with the next task. + +## Two runs: silent, then think-aloud + +Thinking aloud slows people down. So each participant does the tasks **silently, timed**, and then, in a short second pass, **thinks aloud** about where they hesitated. Only silent runs are judged against the 40 minutes. The think-aloud pass is never timed against the target; it is for findings. + +## Script + +Read this aloud, as written: + +> "Thank you for helping. We are testing Debrief, not you: if something is hard, that is what we need to know. There are four tasks on this card. Please work quietly; I will time you. I can't help you with Debrief, but Debrief has walkthroughs for newcomers. Use them as you wish. Tell me when you think each task is done. You can stop at any time. Afterwards I'll ask you to go back over the hard parts and tell me what you were thinking." + +Then hand over the task card. Start the clock when the participant first touches the mouse or keyboard. + +## Tasks (the task card) + +The card says what to achieve, not how, and does not follow the walkthroughs step for step. + +1. **Import.** Two files on the desktop came from an exercise: `red-one.rep` and `legacy.dpf`. Bring both into the *Trial* catalogue in Debrief. Plot the REP file's track as logged. +2. **Tidy.** Debrief's walkthroughs include a copy of serial 1 for practising tidying a track. Open that copy. Its RED ONE looks wrong in places: make it trustworthy, and then make it less noisy. +3. **Annotate.** On that copy, mark the area where BLUE ONE first holds contact on RED ONE, and give it a name. Make the times of BLUE ONE and RED ONE readable along their tracks, at intervals. +4. **Export a traceable figure.** Produce an image of the map for a slide that someone could later trace back to the plot. Then use the image to find the plot again in Debrief. + +## What to time and note + +- **Time to each task's end**, from the clock's start: when the participant says it is done, and whether it is done (see the success criteria). +- **Total time** to the end of task 4 (silent run). +- **Each stuck point** (no progress for 60 seconds, or a request for help): what they were trying to do, where they looked, what got them going again (a walkthrough, a tooltip, chance), and the help-ladder rung if any. +- **Walkthroughs used:** which, which steps, and whether a step ticked itself off when they did it. +- **Errors:** anything done wrongly and undone, or not noticed. +- **Words:** any term the participant did not understand or used differently (for the glossary and the language guide). +- **For every task that fails, the reason**, in one line: help at rung 4, time out (the 60-minute limit reached), wrong result, gave up, or a fault in Debrief (note what it was). + +## Success criteria + +| Task | Done when | +| --- | --- | +| Import | Both files are plots in *Trial*: RED ONE from `red-one.rep`, plotted over the ground (its import note says so), and the legacy plot from `legacy.dpf` (its import listing what was not imported, if anything) | +| Tidy | In *Serial 1: Tidy a track*, History shows *RED ONE: 3 jumps removed* and a smoothing of RED ONE (any number of fixes); no fix of RED ONE is more than 1 nmi from its neighbours' line | +| Annotate | An area (or circle or ellipse) named by the participant whose edge is within **2 nmi** of RED ONE's position at BLUE ONE's first sonar contact (the Timeline's first *Sonar* entry, 08:29Z), or contains that position; and BLUE ONE and RED ONE carry time labels at one interval between 5 and 30 minutes, set as one step or two | +| Export | A PNG of 1600 × 900 with a footer ID exists, and **Find source of image…** has opened the plot read-only from that image or its ID | + +## Observation sheet + +Copy one per participant. + +| Participant | P__ (pilot: P0) | Community (O, N, D) | | Used legacy Debrief? | yes / no | +| --- | --- | --- | --- | --- | --- | + +| Task | Start (mm:ss) | Said done (mm:ss) | Done? (Y/N) | If not, why | Stuck points (time: what, where, rung, how resolved) | Walkthrough steps used | +| --- | --- | --- | --- | --- | --- | --- | +| 1 Import | | | | | | | +| 2 Tidy | | | | | | | +| 3 Annotate | | | | | | | +| 4 Export and trace | | | | | | | + +Silent-run total time: ______ Highest help rung reached: ______ + +Words not understood: ______ + +From the think-aloud pass ("What was hardest? What would have helped?"): ______ + +## Recording results + +- Keep each sheet, and any recording, where the product owner keeps pilot records; not in this repository. +- Summarise in a new dated section of this note, **Results**: the pre-registered rule as written; then, by participant number, each task's time, success and reason for any failure, the silent total and the highest help rung; then the stuck points that recurred, and the words. State whether the pass rule held. Record the outcome as a dated decision in `docs/delivery-plan.md` (pass, or the findings to fix) and update NFR-09's note in the SRD. +- Findings become issues against the walkthroughs (`vscode/walkthroughs/tours.json`), the language guide or the feature concerned. + +## The automated speed run (playground only) + +`vscode/test/play/onboarding.mjs` (`speedRun`) does the four tasks in the playground, not in VS Code. It imports serial 2's `red-one.rep` and debrief-io's `shapes.dpf`, and smooths over 5 fixes. It uses the walkthroughs' own buttons where a step has one, so it is quicker than a newcomer could be. It counts each click, key press and field filled in, prints the count and the time, and **fails if the tasks come to take more than 36 actions**, so a change that makes them ask more is caught. It runs with the playground's tests in CI. On 17 Oct 2026: **32 actions in about 12 seconds** (import 5, tidy 10, annotate 12, export and trace 5). It proves the tasks are achievable and shows how much they ask; it says nothing about how long a person new to Debrief takes, which is what this trial is for. + +## Results + +*None yet: the trial is pending (17 Oct 2026). Pre-register the pass rule above before the first counted participant.* diff --git a/docs/srd.md b/docs/srd.md index 25a5d6e6..ac8a7ee6 100644 --- a/docs/srd.md +++ b/docs/srd.md @@ -289,7 +289,7 @@ Capabilities are replaceable and are where feature requests land. Each shall be - **CAP-23** Analysts should be able to ask for an operation in plain English. Debrief shall show the proposed tool and its parameters, and require confirmation before applying them. The language model shall run locally, so the feature works offline (PRN-06). Until a local model is available, OUT-05 shall be met by CAP-24 and CAP-25 alone. Analyst machines are laptops without GPUs, so a local model is unlikely soon (proxy; section 12.3). - **CAP-24** Each tool's declaration (TL-06) shall carry a short user-facing explanation of what it does and any judgement it involves, shown on its parameter form. This applies to core tools and domain packs; scientist libraries should follow it. -- **CAP-25** Debrief shall provide guided first-use walkthroughs for common tasks, using the demonstration sample data. They depend on convincing sample data replacing the 1995 serial (DEP-07). +- **CAP-25** Debrief shall provide guided first-use walkthroughs for common tasks, using the demonstration sample data. They depend on convincing sample data replacing the 1995 serial (DEP-07). *Note (product owner, 17 Oct 2026):* four walkthroughs (open and explore a serial; import and collate; tidy a track; annotate and export a figure), each on a fresh copy of the sample, never the sample itself; native in each host (VS Code's walkthroughs on its Get Started page, a matching panel in the web build) from one source of words, each step ticked when the analyst does it. ### 7.10 Cross-plot analysis @@ -370,7 +370,7 @@ Analysts accept a visible lag on busy plots, but not a stall. Requirements are s ### 10.5 Onboarding -- **NFR-09** An analyst with domain knowledge but no Debrief training should import a sample serial, compose a storyboard and export it within an hour (OUT-02, OUT-05). +- **NFR-09** An analyst with domain knowledge but no Debrief training should import a sample serial, compose a storyboard and export it within an hour (OUT-02, OUT-05). *Note (product owner, 17 Oct 2026):* until the storyboard exists (Phase 4), a trial of what exists (import, tidy, annotate and export a traceable figure) against a provisional 40 minutes; the full one-hour trial reruns in Phase 4. The trial is formative, its pass rule pre-registered (at least 80% complete unaided, a median silent run within 40 minutes, none over 60); it imports a REP file and a legacy .dpf plot; it is pending (docs/notes/onboarding-trial.md). ### 10.6 Data integrity and recovery @@ -412,8 +412,8 @@ Legacy Debrief is already accredited on the target network, so the path is a kno ### 11.4 Public demonstration instance -- **DEP-06** An unclassified web-hosted instance shall be available to anyone during development, for review and demonstration. -- **DEP-07** The public instance shall never hold real data. It shall ship with fixed sample data and have no upload capability. +- **DEP-06** An unclassified web-hosted instance shall be available to anyone during development, for review and demonstration. *Note (17 Oct 2026):* the web demo, full screen at /demo/ on the documentation site, with every panel and the walkthroughs. +- **DEP-07** The public instance shall ship with fixed sample data. It may open files the analyst chooses, read only in the browser, never sent or stored. *(Amended by the product owner, 17 Oct 2026; it said the instance shall never hold real data and have no upload capability. Nothing the analyst opens leaves their browser or outlives the tab, which is tested. Its pages carry a strict Content-Security-Policy; Pyodide is loaded from jsDelivr with no integrity check, a risk the product owner accepts, 17 Oct 2026.)* - **DEP-08** Sample data shall be a hybrid: open AIS data for the background picture, with a synthesised blue, red and sensor layer and a scripted narrative on top, covering several serials. It replaces the 1995 single-serial sample. It shall be built before the first graphical milestone, since the demonstration instance (DEP-06), the walkthroughs (CAP-25) and blog examples (DEV-06) depend on it, and shall carry its own provenance as a worked example of SUB-18. ### 11.5 Development practice diff --git a/python/debrief-sample/src/debrief_sample/__init__.py b/python/debrief-sample/src/debrief_sample/__init__.py index 87dcde58..2c5074a7 100644 --- a/python/debrief-sample/src/debrief_sample/__init__.py +++ b/python/debrief-sample/src/debrief_sample/__init__.py @@ -1,5 +1,22 @@ -"""Debrief sample data: open AIS with a synthesised exercise on top (DEP-08).""" +"""Debrief sample data: open AIS with a synthesised exercise on top (DEP-08). -from debrief_sample.builder import DEFAULT_SEED, RECORDED_AT, BuildReport, build +The builder is imported only when asked for, so debrief_sample.walkthrough runs +in the browser (CON-09), where the builder's pyarrow is not installed. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from debrief_sample.builder import DEFAULT_SEED, RECORDED_AT, BuildReport, build __all__ = ["DEFAULT_SEED", "RECORDED_AT", "BuildReport", "build"] + + +def __getattr__(name: str) -> Any: + if name in __all__: + from debrief_sample import builder + + return getattr(builder, name) + raise AttributeError(f"module 'debrief_sample' has no attribute {name!r}") diff --git a/python/debrief-sample/src/debrief_sample/__main__.py b/python/debrief-sample/src/debrief_sample/__main__.py index 7d1df24e..74e1915e 100644 --- a/python/debrief-sample/src/debrief_sample/__main__.py +++ b/python/debrief-sample/src/debrief_sample/__main__.py @@ -2,16 +2,21 @@ python -m debrief_sample build OUT_DIR [--seed N] [--recorded-at TIME] python -m debrief_sample reduce-ais AIS_2023_06_15.zip OUT.csv +python -m debrief_sample walkthrough OUT_DIR TOUR +python -m debrief_sample media OUT_DIR [--check] """ from __future__ import annotations import argparse +import json import sys from pathlib import Path from debrief_sample.ais import reduce_marinecadastre from debrief_sample.builder import DEFAULT_SEED, RECORDED_AT, build +from debrief_sample.media import write_media +from debrief_sample.walkthrough import TOURS, prepare def main(argv: list[str] | None = None) -> int: @@ -30,8 +35,28 @@ def main(argv: list[str] | None = None) -> int: ) reduce_cmd.add_argument("archive", type=Path) reduce_cmd.add_argument("output", type=Path) + tour_cmd = commands.add_parser( + "walkthrough", help="a fresh copy of the sample for a walkthrough, in a folder of its own" + ) + tour_cmd.add_argument("out_dir", type=Path, help="an empty folder for the walkthrough") + tour_cmd.add_argument("tour", choices=list(TOURS), help="which walkthrough") + media_cmd = commands.add_parser( + "media", help="the walkthroughs' pictures, drawn from the sample" + ) + media_cmd.add_argument("out_dir", type=Path, help="folder for the SVG files") + media_cmd.add_argument("--check", action="store_true", help="fail if the files differ") args = parser.parse_args(argv) + if args.command == "walkthrough": + print(json.dumps(prepare(args.out_dir, args.tour))) + return 0 + if args.command == "media": + stale = write_media(args.out_dir, check=args.check) + if stale: + print("Out of date: " + ", ".join(stale) + ". Run python -m debrief_sample media.") + return 1 + return 0 + if args.command == "reduce-ais": count = reduce_marinecadastre(args.archive, args.output) print(f"Wrote {args.output}: {count} vessels.") diff --git a/python/debrief-sample/src/debrief_sample/builder.py b/python/debrief-sample/src/debrief_sample/builder.py index 56dd3587..31b16d61 100644 --- a/python/debrief-sample/src/debrief_sample/builder.py +++ b/python/debrief-sample/src/debrief_sample/builder.py @@ -457,10 +457,18 @@ def _build_serial( } -def build(root: Path, *, seed: int = DEFAULT_SEED, recorded_at: str = RECORDED_AT) -> BuildReport: - """Build the sample catalogue at root, which must not already hold one.""" +def build( + root: Path, + *, + seed: int = DEFAULT_SEED, + recorded_at: str = RECORDED_AT, + catalogue_id: str = CATALOGUE_ID, + title: str = CATALOGUE_TITLE, +) -> BuildReport: + """Build the sample catalogue at root, which must not already hold one. A walkthrough's + copy has an id and title of its own, so it is never taken for the sample.""" root = Path(root) - catalogue = Catalogue.create(root, CATALOGUE_ID, CATALOGUE_TITLE, CATALOGUE_DESCRIPTION) + catalogue = Catalogue.create(root, catalogue_id, title, CATALOGUE_DESCRIPTION) clock = FixedClock(recorded_at) vessels = ais.read_extract(extract_path()) report = BuildReport(root, seed) diff --git a/python/debrief-sample/src/debrief_sample/media.py b/python/debrief-sample/src/debrief_sample/media.py new file mode 100644 index 00000000..ec726b94 --- /dev/null +++ b/python/debrief-sample/src/debrief_sample/media.py @@ -0,0 +1,519 @@ +"""The walkthroughs' pictures (CAP-25), drawn from the sample's serial 1. + +One SVG a step, shown beside it on VS Code's Get Started page and in the playground's +Walkthroughs panel. Each is the serial's tracks, where the sample has them, with what the +step is about drawn over them. Interface colours are VS Code theme variables, so a picture +follows the editor's theme (light, dark or high contrast); tracks keep their data colours +(DEV-07). The pictures are schematic: they show what to look for, not the screen exactly. + +python -m debrief_sample media vscode/media/walkthroughs [--check] +""" + +from __future__ import annotations + +import math +import tempfile +from collections.abc import Callable +from html import escape +from pathlib import Path + +from debrief_kernel.formatting import format_bearing, format_range +from debrief_kernel.geodesy import Position, TrackPath, range_bearing +from debrief_kernel.time import format_utc, parse_utc +from debrief_sample.walkthrough import ( + LOG_TAIL_MS, + PLOT, + glitch_indices, + glitched_positions, + log_period, +) +from debrief_stac import Catalogue + +W, H = 480, 270 +FG = "var(--vscode-foreground)" +MUTED = "var(--vscode-descriptionForeground)" +BG = "var(--vscode-editor-background)" +PANEL = "var(--vscode-editorWidget-background)" +BORDER = "var(--vscode-panel-border)" +FOCUS = "var(--vscode-focusBorder)" +WARN = "var(--vscode-editorWarning-foreground)" +FONT = "var(--vscode-font-family)" +MONO = "var(--vscode-editor-font-family)" +ANNOTATION = "#c7820a" # the annotation amber: a data colour (theme guide) +MARK = "#00800b" # a fix's own colour: legacy green, a data colour + + +class Map: + """Serial 1 projected onto the picture: the exercise (BLUE ONE and RED ONE) fills it.""" + + def __init__(self, tracks: dict[str, dict]): + self.tracks = tracks + focus = [p for n in ("BLUE ONE", "RED ONE") for p in tracks[n]["coordinates"]] + lons, lats = [p[0] for p in focus], [p[1] for p in focus] + self.lat0 = math.radians((min(lats) + max(lats)) / 2) + x0, x1 = min(lons), max(lons) + y0, y1 = min(lats), max(lats) + span_x, span_y = (x1 - x0) * math.cos(self.lat0), y1 - y0 + self.scale = min((W - 80) / span_x, (H - 70) / span_y) + self.cx, self.cy = (x0 + x1) / 2, (y0 + y1) / 2 + + def xy(self, lon: float, lat: float) -> tuple[float, float]: + x = W / 2 + (lon - self.cx) * math.cos(self.lat0) * self.scale + y = H / 2 - (lat - self.cy) * self.scale + return round(x, 1), round(y, 1) + + def path(self, coordinates: list, **attributes: str) -> str: + points = " ".join(f"{x},{y}" for x, y in (self.xy(p[0], p[1]) for p in coordinates)) + return _el("polyline", points=points, fill="none", **attributes) + + +def _el(name: str, text: str | None = None, **attributes: object) -> str: + attrs = " ".join( + f'{k.rstrip("_").replace("_", "-")}="{escape(str(v))}"' for k, v in attributes.items() + ) + return f"<{name} {attrs}/>" if text is None else f"<{name} {attrs}>{escape(text)}" + + +def _text(x: float, y: float, words: str, size: int = 11, fill: str = FG, **more: object) -> str: + return _el( + "text", + words, + **{"x": x, "y": y, "font_size": size, "fill": fill, "font_family": FONT, **more}, + ) + + +def _svg(parts: list[str]) -> str: + body = "\n".join(parts) + return ( + f'\n' + f'\n{body}\n\n' + ) + + +def _background(m: Map, *, dim: bool = False, skip: tuple[str, ...] = ()) -> list[str]: + parts = [] + for name, t in m.tracks.items(): + if name in skip: + continue + exercise = name in ("BLUE ONE", "RED ONE") + opacity = 0.25 if dim and exercise is False else (0.35 if dim else (1 if exercise else 0.6)) + parts.append( + m.path( + t["coordinates"], + stroke=t["colour"], + stroke_width=2 if exercise else 1, + opacity=opacity, + ) + ) + if exercise and not dim: + x, y = m.xy(*t["coordinates"][-1][:2]) + parts.append(_text(x + 4, y + 4, name, 10, t["colour"], font_weight="bold")) + return parts + + +def _at(m: Map, name: str, ms: int) -> tuple[float, float]: + p = m.tracks[name]["path"].position_at(ms) + return m.xy(p.longitude, p.latitude) + + +def _square(x: float, y: float, colour: str) -> str: + return _el( + "rect", x=x - 4, y=y - 4, width=8, height=8, fill=colour, stroke=FG, stroke_width=0.5 + ) + + +def _panel(x: float, y: float, w: float, h: float) -> str: + return _el("rect", x=x, y=y, width=w, height=h, rx=3, fill=PANEL, stroke=BORDER) + + +def draw(tracks: dict[str, dict], narrative: list[dict]) -> dict[str, str]: + """Every walkthrough picture, by file name.""" + m = Map(tracks) + blue, red = tracks["BLUE ONE"], tracks["RED ONE"] + start, end = blue["path"].start_ms, blue["path"].end_ms + now = start + (end - start) * 5 // 8 // 60_000 * 60_000 + pictures: dict[str, Callable[[], list[str]]] = {} + + def at_now() -> list[str]: + return [_square(*_at(m, n, now), m.tracks[n]["colour"]) for n in ("BLUE ONE", "RED ONE")] + + pictures["explore-open.svg"] = lambda: _background(m) + + def time() -> list[str]: + y = H - 22 + x = 20 + (W - 40) * (now - start) / (end - start) + return [ + *_background(m), + *at_now(), + _panel(10, y - 14, W - 20, 30), + _el("line", x1=20, y1=y + 4, x2=W - 20, y2=y + 4, stroke=MUTED, stroke_width=2), + _el("circle", cx=x, cy=y + 4, r=5, fill=FOCUS), + _text(20, y - 2, "Current time", 10, MUTED), + _text(W - 20, y - 2, format_utc(now)[11:19] + "Z", 10, FG, text_anchor="end"), + ] + + pictures["explore-time.svg"] = time + + def select() -> list[str]: + parts = _background(m, dim=True) + for t in (blue, red): + parts.append(m.path(t["coordinates"], stroke=FOCUS, stroke_width=6, opacity=0.5)) + parts.append(m.path(t["coordinates"], stroke=t["colour"], stroke_width=2)) + return parts + + pictures["explore-select.svg"] = select + + def tote() -> list[str]: + (bx, by), (rx, ry) = _at(m, "BLUE ONE", now), _at(m, "RED ONE", now) + a, b = blue["path"].position_at(now), red["path"].position_at(now) + distance, bearing = range_bearing(a, b) + rows = [("Range nmi", format_range(distance)), ("Brg °T", format_bearing(bearing))] + parts = [ + *_background(m), + *at_now(), + _el("line", x1=bx, y1=by, x2=rx, y2=ry, stroke=MUTED, stroke_dasharray="4 3"), + ] + parts += [ + _panel(W - 170, 10, 160, 66), + _text(W - 162, 26, "Tote", 11, FG, font_weight="bold"), + ] + parts.append(_text(W - 70, 26, "RED ONE", 10, red["colour"], text_anchor="end")) + for i, (label, value) in enumerate(rows): + parts.append(_text(W - 162, 44 + i * 16, label, 10, MUTED)) + parts.append( + _text(W - 18, 44 + i * 16, value, 10, FG, text_anchor="end", font_family=MONO) + ) + return parts + + pictures["explore-tote.svg"] = tote + + def timeline() -> list[str]: + shown = [e for e in narrative if parse_utc(e["time"]) <= now][-3:] + parts = [*_background(m), *at_now(), _panel(W - 210, 10, 200, 70)] + for i, e in enumerate(shown): + current = i == len(shown) - 1 + y = 28 + i * 20 + if current: + parts.append( + _el("rect", x=W - 206, y=y - 12, width=192, height=17, fill=FOCUS, opacity=0.25) + ) + parts.append(_text(W - 202, y, e["time"][11:16] + "Z", 9, MUTED, font_family=MONO)) + parts.append(_text(W - 168, y, (e["source"] + ": " + e["text"])[:30], 9, FG)) + return parts + + pictures["explore-timeline.svg"] = timeline + + first, last = log_period(start, end) + + def emphasise_blue() -> list[str]: + return [ + *_background(m, dim=True), + m.path(blue["coordinates"], stroke=blue["colour"], stroke_width=3), + ] + + pictures["collate-open.svg"] = emphasise_blue + + def imported() -> list[str]: + lines = [ + ";; A navigation log", + '230615 104000.000 "Blue One" @A …', + '230615 104010.000 "Blue One" @A …', + '230615 104020.000 "Blue One" @A …', + ] + parts = [*_background(m, dim=True), _panel(20, 20, 250, 82)] + for i, line in enumerate(lines): + parts.append(_text(30, 40 + i * 16, line, 9, MUTED if i == 0 else FG, font_family=MONO)) + parts += [ + _el("line", x1=270, y1=60, x2=320, y2=60, stroke=FOCUS, stroke_width=2), + _panel(320, 44, 140, 34), + ] + parts.append(_text(330, 65, "✦ New data", 11, WARN, font_weight="bold")) + return parts + + pictures["collate-import.svg"] = imported + + def bars(chosen: bool) -> list[str]: + x0, x1 = 110, W - 20 + tail = end + LOG_TAIL_MS + + def x(ms: int) -> float: + return round(x0 + (x1 - x0) * (ms - start) / (tail - start), 1) + + parts = [ + _panel(10, 10, W - 20, H - 20), + _text(20, 34, "Coverage", 12, FG, font_weight="bold"), + ] + rows = [ + ("BLUE ONE", "Serial 1", start, end, blue["colour"]), + ("", "Navigation log", first, tail, blue["colour"]), + ("RED ONE", "Serial 1", start, end, red["colour"]), + ] + for i, (track, source, a, b, colour) in enumerate(rows): + y = 70 + i * 44 + if track: + parts.append(_text(20, y - 14, track, 11, colour, font_weight="bold")) + parts.append(_text(20, y + 6, source, 10, MUTED)) + parts.append( + _el("rect", x=x0, y=y - 4, width=x1 - x0, height=14, fill="none", stroke=BORDER) + ) + parts.append( + _el("rect", x=x(a), y=y - 4, width=x(b) - x(a), height=14, fill=colour, opacity=0.6) + ) + if chosen: + parts.append( + _el( + "rect", + x=x(first), + y=66 + 44 - 4, + width=x(last) - x(first), + height=22, + fill="none", + stroke=FOCUS, + stroke_width=2, + ) + ) + parts.append( + _text( + x(first), + 140, + f"{format_utc(first)[11:16]}Z to {format_utc(last)[11:16]}Z: Navigation log", + 10, + FG, + ) + ) + for ms in range(start, tail + 1, 3_600_000): + parts.append( + _text(x(ms), H - 26, format_utc(ms)[11:16] + "Z", 9, MUTED, text_anchor="middle") + ) + return parts + + pictures["collate-coverage.svg"] = lambda: bars(False) + pictures["collate-prefer.svg"] = lambda: bars(True) + + def take() -> list[str]: + p = blue["path"] + a, b = p.positions[-2], p.positions[-1] + rng, brg = range_bearing(a, b) + speed = rng.value / ((p.times_ms[-1] - p.times_ms[-2]) / 1000) + from debrief_kernel.geodesy import destination + from debrief_kernel.units import Quantity + + on = [b] + for i in range(1, 21): + on.append(destination(b, brg, Quantity(speed * 60 * i, "m"))) + extended = [[q.longitude, q.latitude] for q in on] + return [ + *_background(m), + m.path(extended, stroke=blue["colour"], stroke_width=2, stroke_dasharray="2 3"), + ] + + pictures["collate-take.svg"] = take + + glitched = glitched_positions(red["coordinates"]) + spikes = glitch_indices(len(glitched)) + + def tidy(rings: bool = False, smooth: bool = False) -> list[str]: + parts = [ + *_background(m, skip=("RED ONE",)), + m.path(glitched, stroke=red["colour"], stroke_width=2), + ] + if rings: + for i in spikes: + x, y = m.xy(*glitched[i][:2]) + parts.append( + _el("circle", cx=x, cy=y, r=7, fill="none", stroke=WARN, stroke_width=2) + ) + if smooth: + parts = [ + *_background(m, skip=("RED ONE",)), + m.path(red["coordinates"], stroke=red["colour"], stroke_width=2), + ] + moved = [[lon + 0.004, lat - 0.003] for lon, lat in (p[:2] for p in red["coordinates"])] + parts.append( + m.path(moved, stroke=red["colour"], stroke_width=1.5, stroke_dasharray="5 3") + ) + return parts + + pictures["tidy-open.svg"] = tidy + pictures["tidy-jumps.svg"] = lambda: tidy(rings=True) + pictures["tidy-smooth.svg"] = lambda: tidy(smooth=True) + + def mark() -> list[str]: + coords = red["coordinates"] + x, y = m.xy(*coords[len(coords) // 2][:2]) + return [ + *_background(m), + _el("circle", cx=x, cy=y, r=5, fill=MARK, stroke=FG, stroke_width=0.5), + _text(x + 8, y - 6, "Turned away", 11, FG, font_weight="bold"), + ] + + pictures["tidy-mark.svg"] = mark + + def shear() -> list[str]: + coords = blue["coordinates"] + held = m.xy(*coords[0][:2]) + x, y = m.xy(*coords[-1][:2]) + moved = (x + 30, y - 24) + n = len(coords) + sheared = [] + for i, p in enumerate(coords): + px, py = m.xy(*p[:2]) + f = i / (n - 1) + sheared.append(f"{round(px + 30 * f, 1)},{round(py - 24 * f, 1)}") + diamond = " ".join( + f"{moved[0] + dx},{moved[1] + dy}" for dx, dy in ((0, -6), (6, 0), (0, 6), (-6, 0)) + ) + return [ + *_background(m), + _el( + "polyline", + points=" ".join(sheared), + fill="none", + stroke=blue["colour"], + stroke_width=2, + stroke_dasharray="5 3", + ), + _el("polygon", points=diamond, fill=FOCUS, stroke=FG, stroke_width=0.5), + _el("circle", cx=held[0], cy=held[1], r=4, fill="none", stroke=FG), + _text(moved[0] + 10, moved[1] - 8, "New course and speed", 10, FG), + ] + + pictures["tidy-shear.svg"] = shear + pictures["annotate-open.svg"] = lambda: _background(m) + + def area() -> list[str]: + cx, cy = _at(m, "RED ONE", start + (end - start) // 6) + corners = [(cx - 40, cy - 25), (cx + 45, cy - 30), (cx + 50, cy + 28), (cx - 35, cy + 30)] + points = " ".join(f"{a},{b}" for a, b in corners) + return [ + *_background(m), + _el( + "polygon", + points=points, + fill=ANNOTATION, + fill_opacity=0.05, + stroke=ANNOTATION, + stroke_width=1.5, + stroke_dasharray="6 3", + ), + _text(cx - 36, cy - 30, "Search area", 11, ANNOTATION, font_weight="bold"), + ] + + pictures["annotate-area.svg"] = area + + def frequencies() -> list[str]: + parts = _background(m) + for t in (blue, red): + for ms in range(start, end + 1, 1_800_000): + x, y = _at(m, t["name"], ms) + parts.append(_el("circle", cx=x, cy=y, r=2, fill=t["colour"])) + parts.append( + _text(x + 4, y - 3, format_utc(ms)[11:13] + format_utc(ms)[14:16], 8, FG) + ) + return parts + + pictures["annotate-frequencies.svg"] = frequencies + + def export() -> list[str]: + return [ + *_background(m), + _el( + "rect", + x=4, + y=4, + width=W - 8, + height=H - 8, + fill="none", + stroke=FOCUS, + stroke_width=2, + stroke_dasharray="8 4", + ), + _text(14, H - 14, "DBF-PTET-KG8R-B9EM-69", 9, MUTED, font_family=MONO), + ] + + pictures["annotate-export.svg"] = export + + def trace() -> list[str]: + steps = [ + "Step 7 Labels every 10 min", + "Step 6 Search area added", + "Step 5 Shown read-only", + ] + parts = [ + *_background(m, dim=True), + _panel(20, 20, 200, 28), + _text(30, 38, "DBF-PTET-KG8R-B9EM-69", 11, FG, font_family=MONO), + ] + parts += [ + _el("line", x1=220, y1=34, x2=260, y2=34, stroke=FOCUS, stroke_width=2), + _panel(260, 14, 200, 72), + ] + for i, line in enumerate(steps): + y = 32 + i * 20 + if i == 2: + parts.append( + _el( + "rect", + x=264, + y=y - 12, + width=192, + height=17, + fill="none", + stroke=WARN, + stroke_dasharray="3 2", + ) + ) + parts.append(_text(270, y, line, 10, FG if i == 2 else MUTED)) + return parts + + pictures["annotate-trace.svg"] = trace + return {name: _svg(make()) for name, make in sorted(pictures.items())} + + +def sample_tracks() -> tuple[dict[str, dict], list[dict]]: + """Serial 1's tracks and narrative, from a fresh build of the sample.""" + from debrief_sample.builder import build + + with tempfile.TemporaryDirectory() as work: + root = Path(work) / "sample" + build(root) + plot = Catalogue.open(root).read_plot(PLOT) + tracks = {} + for f in plot.features: + if f.properties.kind == "track": + colour = f.properties.style.colour if f.properties.style else "#808080" + tracks[f.properties.name] = { + "name": f.properties.name, + "coordinates": [list(p[:2]) for p in f.geometry.coordinates], + "colour": colour, + "path": TrackPath.from_feature(f), + } + narrative = sorted( + ( + {"time": f.properties.time, "source": f.properties.source, "text": f.properties.text} + for f in plot.features + if f.properties.kind == "narrative" + ), + key=lambda e: e["time"], + ) + return tracks, narrative + + +def write_media(out: Path, *, check: bool = False) -> list[str]: + """Write every picture into out; with check, write nothing and list those that differ.""" + out = Path(out) + pictures = draw(*sample_tracks()) + stale = [ + n + for n, svg in pictures.items() + if not (out / n).exists() or (out / n).read_text(encoding="utf-8") != svg + ] + if not check: + out.mkdir(parents=True, exist_ok=True) + for name in stale: + (out / name).write_text(pictures[name], encoding="utf-8") + return [] + return stale + + +__all__ = ["Position", "draw", "write_media"] diff --git a/python/debrief-sample/src/debrief_sample/walkthrough.py b/python/debrief-sample/src/debrief_sample/walkthrough.py new file mode 100644 index 00000000..ead9ea9b --- /dev/null +++ b/python/debrief-sample/src/debrief_sample/walkthrough.py @@ -0,0 +1,295 @@ +"""Walkthroughs on the sample data (CAP-25): a fresh copy of serial 1 for each one. + +Every walkthrough works on a copy of the sample's serial 1, never on the sample +itself (decision, product owner, 17 Oct 2026): the copy is a plot of its own in +the catalogue, titled after the walkthrough, whose first step holds the sample's +whole history and one more record saying it is a copy. Its sources, payloads and +supporting files are copied with it, so it is complete on its own. + +What a walkthrough needs beyond the copy is made here too, so VS Code and the +playground get the same: for *Tidy a track*, three GPS glitches in RED ONE as a +step of their own; for *Import and collate*, BLUE ONE's navigation log as a REP +file to import. The playground runs this module under Pyodide, so it imports +nothing the browser lacks (no pyarrow: the package's builder is not imported). +""" + +from __future__ import annotations + +import time +from pathlib import Path + +from debrief_kernel.geodesy import Position, TrackPath, destination, range_bearing +from debrief_kernel.time import format_utc +from debrief_kernel.units import Quantity +from debrief_schemas import models +from debrief_schemas.island import plot_feature +from debrief_stac import Catalogue, Changes + +#: The walkthroughs, in the order they are offered, and their titles (the same as the guide's). +TOURS: dict[str, str] = { + "explore": "Open and explore a serial", + "collate": "Import and collate", + "tidy": "Tidy a track", + "annotate": "Annotate and export a figure", +} +#: The serial every walkthrough copies. +PLOT = "serial-1" +#: The navigation log Import and collate brings in, beside the catalogue. +LOG_FILE = "blue-one-nav-log.rep" +#: Who makes the copies and the glitches, as provenance names it. +WALKTHROUGH = models.AgentRef(kind="software_agent", name="Debrief walkthrough") + + +def _now() -> str: + return format_utc(int(time.time() * 1000)) + + +def _unique(catalogue: Catalogue, stem: str) -> str: + taken, item_id, n = set(catalogue.item_ids()), stem, 2 + while item_id in taken: + item_id, n = f"{stem}-{n}", n + 1 + return item_id + + +def catalogue_title(tour: str) -> str: + """A walkthrough's own catalogue, in VS Code: named so the analyst knows it for what it is.""" + return f"Walkthrough: {TOURS[tour]}" + + +def copy_title(source_title: str, tour: str) -> str: + """The copy's title: the serial's, then the walkthrough's, so the two are never confused.""" + return f"{source_title}: {TOURS[tour]}" + + +def copy_for_tour( + target: Catalogue, + source_root: Path, + tour: str, + *, + agent: models.AgentRef = WALKTHROUGH, +) -> str: + """Copy serial 1 from the catalogue at source_root into target for a walkthrough, + as a new plot titled after it; for *Tidy a track*, put GPS glitches into RED ONE. + The source is only read. Returns the copy's item id.""" + if tour not in TOURS: + raise ValueError(f"There is no walkthrough {tour!r}: choose one of {', '.join(TOURS)}.") + source = Catalogue.open(source_root) + plot = source.read_plot(PLOT) + details = plot_feature(plot) + serial_title = (details.properties.title if details is not None else None) or "Serial 1" + title = copy_title(serial_title, tour) + now = _now() + record = models.ProvRecord( + id=f"walkthrough-copy-{tour}-{int(time.time() * 1000)}", + kind="edit", + started_at=now, + ended_at=now, + agent=agent, + notes=[ + f"Copied from {serial_title} of the sample for the walkthrough {TOURS[tour]}", + "The sample itself is unchanged", + ], + ) + features = [ + f.model_copy(update={"properties": f.properties.model_copy(update={"title": title})}) + if f is details + else f + for f in plot.features + ] + content = plot.model_copy( + update={"features": features, "provenance": [*(plot.provenance or []), record]} + ) + item_id = _unique(target, f"{PLOT}-{tour}") + target.create_plot(item_id, title, plot=content) + try: + # Sources, payloads and supporting files come too: the copy is complete on its own. + for key, asset in source.assets(PLOT).items(): + path = source_root / PLOT / asset.href + kind = asset.debrief_asset_kind + media_type = asset.type or "application/octet-stream" + if kind == "source": + target.add_source(item_id, path, key=key, media_type=media_type) + elif kind == "payload": + target.add_payload(item_id, path, key=key, media_type=media_type) + elif kind == "supporting_file": + target.add_supporting_file(item_id, path, key=key, media_type=media_type) + if tour == "tidy": + red = next( + f + for f in target.read_plot(item_id).features + if f.properties.kind == "track" and f.properties.name == "RED ONE" + ) + put_glitches(target, item_id, red.id, agent, reason="for the walkthrough") + except BaseException: + target.discard_plot(item_id) + raise + return item_id + + +def glitch_indices(n: int) -> list[int]: + """Which of a track's n fixes the glitches move.""" + return [n // 3, n // 3 + 1, 2 * n // 3] + + +def glitched_positions(coordinates: list) -> list[list[float]]: + """A track's positions with three GPS glitches: two fixes in a row three miles off to + the north, and one four miles off to the south.""" + positions = [list(p) for p in coordinates] + moves = ((0, 3 * 1852), (0, 3 * 1852), (180, 4 * 1852)) + for index, (bearing, metres) in zip(glitch_indices(len(positions)), moves, strict=True): + lon, lat = positions[index][:2] + to = destination(Position(lon, lat), Quantity(bearing, "deg"), Quantity(metres, "m")) + positions[index] = [to.longitude, to.latitude] + return positions + + +def put_glitches( + catalogue: Catalogue, + item: str, + feature: str, + agent: models.AgentRef, + *, + reason: str = "to try jump removal on", +) -> None: + """Put three GPS glitches into a track: two fixes in a row three miles off to the + north, and one four miles off to the south. Recorded as a step of its own, by the + agent given, so it is never mistaken for real data and Undo takes it out.""" + plot = catalogue.read_plot(item) + track = next((f for f in plot.features if f.id == feature), None) + if track is None or track.properties.kind != "track" or len(track.properties.times) < 12: + raise ValueError("Select a track of at least 12 fixes to put glitches in.") + positions = glitched_positions(track.geometry.coordinates) + glitched = track.model_copy( + update={"geometry": track.geometry.model_copy(update={"coordinates": positions})} + ) + at = _now() + record = models.ProvRecord( + id=f"edit-glitches-{at}", + kind="edit", + started_at=at, + agent=agent, + notes=[ + f"GPS glitches put into {track.properties.name}", + f"Three fixes moved off the track, {reason}", + ], + ) + catalogue.commit(item, [record], Changes(modified=[glitched])) + + +# --- A navigation log, as new data arrives (CAP-10) --------------------------------------- + + +def _dms(value: float, positive: str, negative: str, width: int) -> str: + hemisphere = positive if value >= 0 else negative + hundredths = round(abs(value) * 360_000) + degrees, rest = divmod(hundredths, 360_000) + minutes, rest = divmod(rest, 6_000) + return f"{degrees:0{width}d} {minutes:02d} {rest / 100:05.2f} {hemisphere}" + + +def _rep(name: str, ms: int, position: Position, course: float, knots: float) -> str: + when = format_utc(ms) + stamp = f"{when[2:4]}{when[5:7]}{when[8:10]} {when[11:13]}{when[14:16]}{when[17:19]}.000" + quoted = f'"{name}"' if " " in name else name + lat = _dms(position.latitude, "N", "S", 2) + lon = _dms(position.longitude, "E", "W", 3) + return f"{stamp} {quoted} @A {lat} {lon} {course:05.1f} {knots:.1f} 0" + + +def _leg(track_path: TrackPath, ms: int) -> tuple[Position, Position, float]: + leg = track_path.leg_at(ms) + i = min(leg[0] if leg else 0, len(track_path.positions) - 2) + seconds = max((track_path.times_ms[i + 1] - track_path.times_ms[i]) / 1000, 1) + return track_path.positions[i], track_path.positions[i + 1], seconds + + +def _course(track_path: TrackPath, ms: int) -> float: + a, b, _ = _leg(track_path, ms) + return range_bearing(a, b)[1].value + + +def _knots(track_path: TrackPath, ms: int) -> float: + a, b, seconds = _leg(track_path, ms) + return range_bearing(a, b)[0].value / seconds * 3600 / 1852 + + +#: How long the log runs on after the track ends, and how often it has a fix. +LOG_TAIL_MS = 20 * 60_000 +LOG_STEP_MS = 10_000 + + +def log_period(start: int, end: int) -> tuple[int, int]: + """The part of a track's period its navigation log overlaps: from a third of the way in, + for a quarter of it, on whole minutes.""" + first = start + (end - start) // 3 // 60_000 * 60_000 + return first, first + (end - start) // 4 // 60_000 * 60_000 + + +def navigation_log(track: models.TrackFeature) -> str: + """A track's own navigation log as a REP file, as new data would arrive: a fix every + 10 s over part of its period (from a third of the way in, for a quarter of it) and on + for 20 minutes after it ends, 120 m from where the plot has it, under its name in + other letters (*Blue One*); and a contact the plot has never seen, WATCHER. Fiction, + like the sample; the same track always gives the same file.""" + path_of = TrackPath.from_feature(track) + start, end = path_of.start_ms, path_of.end_ms + name = track.properties.name.title() + lines = [ + ";; A navigation log, made up for Debrief's sample: not a record of any real platform." + ] + first, last = log_period(start, end) + north = Quantity(120, "m") + for ms in range(first, last + 1, LOG_STEP_MS): + at = destination(path_of.position_at(ms), Quantity(0, "deg"), north) + lines.append(_rep(name, ms, at, _course(path_of, ms), _knots(path_of, ms))) + course, knots = _course(path_of, end), _knots(path_of, end) + tail = path_of.position_at(end) + for i in range(1, LOG_TAIL_MS // LOG_STEP_MS + 1): + metres = knots * 1852 / 3600 * i * LOG_STEP_MS / 1000 + at = destination(tail, Quantity(course, "deg"), Quantity(metres, "m")) + lines.append(_rep(name, end + i * LOG_STEP_MS, at, course, knots)) + origin = destination(path_of.position_at(first), Quantity(225, "deg"), Quantity(6000, "m")) + for i in range(31): + at = destination(origin, Quantity(80, "deg"), Quantity(i * 300, "m")) + lines.append(_rep("WATCHER", first + i * 60_000, at, 80.0, 9.7)) + return "\n".join(lines) + "\n" + + +def log_track(catalogue: Catalogue, item: str) -> models.TrackFeature: + """The track a navigation log is made for: the plot's first track of more than two fixes.""" + plot = catalogue.read_plot(item) + tracks = [ + f for f in plot.features if f.properties.kind == "track" and len(f.properties.times) > 2 + ] + if not tracks: + raise ValueError("The open plot has no track to bring new data for.") + return tracks[0] + + +# --- VS Code: a catalogue of its own for each walkthrough --------------------------------- + + +def prepare(folder: Path, tour: str) -> dict[str, str]: + """Build the sample catalogue into folder/catalogue and copy serial 1 for the + walkthrough there; for *Import and collate*, write the navigation log beside the + catalogue (folder/blue-one-nav-log.rep), outside it, as a file waiting to be + imported. Returns where things are, for the host to open.""" + from debrief_sample.builder import build # needs pyarrow: never imported in the browser + + if tour not in TOURS: + raise ValueError(f"There is no walkthrough {tour!r}: choose one of {', '.join(TOURS)}.") + root = Path(folder) / "catalogue" + build(root, catalogue_id=f"debrief-walkthrough-{tour}", title=catalogue_title(tour)) + catalogue = Catalogue.open(root) + item = copy_for_tour(catalogue, root, tour) + out = { + "catalogue": str(root), + "catalogue_title": catalogue_title(tour), + "item": item, + "title": catalogue.read_item(item).properties.title or item, + } + if tour == "collate": + log = Path(folder) / LOG_FILE + log.write_text(navigation_log(log_track(catalogue, item)), encoding="utf-8") + out["log"] = str(log) + return out diff --git a/python/debrief-sample/tests/test_walkthrough.py b/python/debrief-sample/tests/test_walkthrough.py new file mode 100644 index 00000000..6ef3cfef --- /dev/null +++ b/python/debrief-sample/tests/test_walkthrough.py @@ -0,0 +1,132 @@ +"""Walkthrough copies (CAP-25): fresh, complete, titled, and never touching the sample.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from debrief_io import import_rep +from debrief_sample import build +from debrief_sample.walkthrough import ( + LOG_FILE, + PLOT, + TOURS, + copy_for_tour, + log_track, + navigation_log, + prepare, +) +from debrief_stac import Catalogue + + +def _files(root: Path) -> dict[str, bytes]: + return {p.relative_to(root).as_posix(): p.read_bytes() for p in root.rglob("*") if p.is_file()} + + +@pytest.fixture(scope="module") +def sample(tmp_path_factory: pytest.TempPathFactory) -> Path: + root = tmp_path_factory.mktemp("sample") / "catalogue" + build(root) + return root + + +def test_a_copy_leaves_the_sample_alone(sample: Path, tmp_path: Path) -> None: + before = _files(sample) + target = Catalogue.create(tmp_path / "work", "work", "Work", "A catalogue to work in") + for tour in TOURS: + copy_for_tour(target, sample, tour) + assert _files(sample) == before + + +def test_each_copy_is_serial_1_titled_after_its_walkthrough(sample: Path, tmp_path: Path) -> None: + target = Catalogue.create(tmp_path / "work", "work", "Work", "A catalogue to work in") + original = Catalogue.open(sample).read_plot(PLOT) + for tour, title in TOURS.items(): + item = copy_for_tour(target, sample, tour) + assert item == f"{PLOT}-{tour}" + assert target.read_item(item).properties.title == f"Serial 1: {title}" + copy = target.read_plot(item) + tracks = {f.properties.name for f in copy.features if f.properties.kind == "track"} + assert tracks == { + f.properties.name for f in original.features if f.properties.kind == "track" + } + # Its sources, payloads and supporting files came with it. + kinds = sorted(a.debrief_asset_kind for a in target.assets(item).values()) + assert kinds.count("source") == 3 and "payload" in kinds and "supporting_file" in kinds + notes = [n for r in copy.provenance for n in (r.notes or [])] + assert f"Copied from Serial 1 of the sample for the walkthrough {title}" in notes + # Another copy of the same walkthrough is another plot, never over the first. + assert copy_for_tour(target, sample, "explore") == f"{PLOT}-explore-2" + + +def test_tidy_a_track_has_three_glitches_to_remove(sample: Path, tmp_path: Path) -> None: + target = Catalogue.create(tmp_path / "work", "work", "Work", "A catalogue to work in") + item = copy_for_tour(target, sample, "tidy") + red = next(f for f in target.read_plot(item).features if f.properties.name == "RED ONE") + _, _, jumps = target.find_jumps(item, red.id) + assert len(jumps) == 3 + last = target.read_plot(item).provenance[-1] + assert last.notes == [ + "GPS glitches put into RED ONE", + "Three fixes moved off the track, for the walkthrough", + ] + # The sample's own RED ONE has none. + assert Catalogue.open(sample).find_jumps(PLOT, red.id)[2] == [] + + +def test_the_navigation_log_is_new_data_for_the_copy(tmp_path: Path) -> None: + made = prepare(tmp_path / "collate", "collate") + assert made["item"] == f"{PLOT}-collate" and Path(made["log"]).name == LOG_FILE + # Outside the catalogue: a file waiting to be imported, not an item. + assert Path(made["log"]).parent == Path(made["catalogue"]).parent + catalogue = Catalogue.open(Path(made["catalogue"])) + text = Path(made["log"]).read_text(encoding="utf-8") + assert text == navigation_log(log_track(catalogue, made["item"])) + assert "Blue One" in text and "WATCHER" in text + import_rep(catalogue, Path(made["log"]), item_id="blue-one-nav-log", modes="otg") + report = catalogue.coverage(made["item"]) + marked = [i for i in report.items if i.new_data] + assert [i.item for i in marked] == ["blue-one-nav-log"] + + +def test_the_command_line_prepares_a_walkthrough(tmp_path: Path) -> None: + out = subprocess.run( + [sys.executable, "-m", "debrief_sample", "walkthrough", str(tmp_path / "w"), "explore"], + check=True, + capture_output=True, + text=True, + ) + made = json.loads(out.stdout) + assert ( + made["item"] == "serial-1-explore" + and made["title"] == "Serial 1: Open and explore a serial" + ) + assert "log" not in made + + +def test_the_walkthrough_module_runs_without_pyarrow() -> None: + # The playground imports it under Pyodide, where pyarrow is not installed. + code = ( + "import sys; sys.modules['pyarrow'] = None; import debrief_sample.walkthrough; print('ok')" + ) + out = subprocess.run([sys.executable, "-c", code], check=True, capture_output=True, text=True) + assert out.stdout.strip() == "ok" + + +def test_the_walkthrough_pictures_are_drawn_from_the_sample() -> None: + # VS Code's Get Started page and the playground show these: made by + # python -m debrief_sample media vscode/media/walkthroughs, and kept up to date. + from debrief_sample.media import write_media + + media = Path(__file__).resolve().parents[3] / "vscode" / "media" / "walkthroughs" + assert write_media(media, check=True) == [] + tours = json.loads((media.parents[1] / "walkthroughs" / "tours.json").read_text("utf-8")) + named = {s["media"] for t in tours["tours"] for s in t["steps"]} + assert named == {p.name for p in media.glob("*.svg")} + for picture in media.glob("*.svg"): + text = picture.read_text("utf-8") + assert " + +*The pictures come from the playground's test, which plays every walkthrough start to finish in a browser, and from VS Code's own test, on the sample exercise, which is made up.* + +## Four walkthroughs + +| Walkthrough | What it teaches | +| --- | --- | +| Open and explore a serial | Current time and the time window; selecting tracks in the Outline; comparing them in the Tote; reading the narrative in the Timeline | +| Import and collate | Importing a REP file; seeing which source has data when, in Coverage; taking new data; choosing which source supplies a period | +| Tidy a track | Removing GPS glitches; smoothing; marking and labelling a fix; shearing a leg, the drag analysts use most in TMA | +| Annotate and export a figure | Drawing an annotation; labelling tracks with their times; exporting an image that traces back to its plot; finding that plot from the image | + +Each takes about ten minutes and works on **a fresh copy** of the sample's serial 1, titled after it (*Serial 1: Tidy a track*), so the sample itself never changes. A walkthrough can always start again clean: its copy is replaced and its steps unticked. *Tidy a track*'s copy comes with three GPS glitches in RED ONE, put in as a step of their own in its history so they are never mistaken for real data; *Import and collate*'s comes with BLUE ONE's navigation log, a REP file waiting to be imported. + +## Ticked by doing, not by clicking + +Every step says what to do, where it is, and how to do it from the keyboard, with a picture drawn from the sample. Its button only takes you there: it opens the copy, or brings the panel or the command to hand. The step ticks itself off when you have actually done it, however you did it: from the form, on the map, from the keyboard. Finding the jumps ticks nothing; removing them does. A preview of smoothing is not smoothing; moving or stretching a track is not shearing it. And only the walkthrough's own copy counts: the same change to another of your plots ticks nothing, and with another plot open a step's button opens the walkthrough's copy before it does anything. + +![The playground's Walkthroughs in their column on the right of the map: Tidy a track chosen, all five steps ticked, and the line Done: Shear a leg, as in TMA. That is the whole of Tidy a track; the map shows Serial 1: Tidy a track, RED ONE's fix labelled Turned away and BLUE ONE sheared](images/phase-3/onboarding-tidy-done.png) + +## In VS Code: the Get Started page + +In VS Code the walkthroughs are on the **Get Started** page, as any extension's are: **Debrief: Walkthroughs** in the Command Palette, or **Start a walkthrough** in Catalogues before any catalogue is added. **Open a copy of serial 1** builds the sample into a catalogue of the walkthrough's own, *Walkthrough: Tidy a track*, never touching the analyst's catalogues; pressed again, it asks whether to start again with a fresh copy or carry on. + +![VS Code's Get Started page: Open and explore a serial, every step ticked, beside a picture of serial 1's tracks drawn in the editor's colours](images/phase-3/onboarding-vscode-explore.png) + +The words of every step live in one file, which VS Code's walkthroughs are written from and the browser's panel reads, so the two never say different things. + +## The web demo + +The web demo is Debrief full screen in your browser: every panel, the walkthroughs open in a column of their own on the right of the map, and the three serials of the sample exercise. With the steps beside the map, a step's words stay in sight while you do it in the side panels and on the map; fold the column and the map takes its place. On a phone the walkthroughs are the first panel instead. The Python tools run in the page, under Pyodide, as in the blog's demos. You can open a file of your own there too, a REP file, a legacy plot or a Word narrative: it is read in your browser and nowhere else. The demo's test opens such files, filters and highlights them in the Timeline, and exports and traces an image, watching every request the page makes. None carries the files, nothing is sent at all, not even as the tab closes, and nothing derived from them is kept in any of the browser's stores, so they are gone when you close the tab. The page also carries a strict Content-Security-Policy. One thing it cannot vouch for: Python itself, Pyodide, comes from the jsDelivr CDN without an integrity check, a risk we have accepted. + +![The web demo, full screen: the side panels on the left, the sample's Serial 1 on the map, the Walkthroughs open in their column on the right](images/phase-3/onboarding-demo.png) + +## Is an hour enough? + +The SRD's target (NFR-09) is an hour to import a serial, compose a storyboard and export it. The storyboard comes in Phase 4, so for now the product owner will trial what exists: import a REP file and a legacy plot, tidy a track, annotate, and export a figure traced back to its plot, against a provisional 40 minutes, with analysts who know the domain but have never used Debrief. It is a formative trial, its pass rule written down before it starts: four in five finish without help, the median quiet run within 40 minutes, no one over an hour. The protocol is written; the trial itself is still to be run. Meanwhile the playground's test does the trial's four tasks as a speed run, as fast as the page answers: 32 actions in all. + +## Try it + +Open **Walkthroughs**, at the top of the side column (in the web demo, on the right), choose one and press its first button: + +
+ +## Not yet + +- **The trial.** The onboarding trial with analysts is pending; its results will be posted. +- **The storyboard.** NFR-09's full hour, storyboard included, is trialled again when the storyboard arrives in Phase 4. diff --git a/site/docs/blog/posts/images/phase-3/onboarding-demo.png b/site/docs/blog/posts/images/phase-3/onboarding-demo.png new file mode 100644 index 00000000..185543c0 Binary files /dev/null and b/site/docs/blog/posts/images/phase-3/onboarding-demo.png differ diff --git a/site/docs/blog/posts/images/phase-3/onboarding-tidy-done.png b/site/docs/blog/posts/images/phase-3/onboarding-tidy-done.png new file mode 100644 index 00000000..d6a64512 Binary files /dev/null and b/site/docs/blog/posts/images/phase-3/onboarding-tidy-done.png differ diff --git a/site/docs/blog/posts/images/phase-3/onboarding-vscode-explore.png b/site/docs/blog/posts/images/phase-3/onboarding-vscode-explore.png new file mode 100644 index 00000000..ab69035f Binary files /dev/null and b/site/docs/blog/posts/images/phase-3/onboarding-vscode-explore.png differ diff --git a/site/docs/guides/interaction-models/index.md b/site/docs/guides/interaction-models/index.md index d554afa7..1f1b278d 100644 --- a/site/docs/guides/interaction-models/index.md +++ b/site/docs/guides/interaction-models/index.md @@ -21,6 +21,7 @@ Worked patterns applied the same way across hosts, tools and agents (DEV-08). Ea | [Timeline and narrative](timeline.md) | Phase 3 | Written (part A: narrative entries, REP and .dpf import, the Timeline following current time, filter, search, editing, scale) | | [Collation](collation.md) | Phase 3 | Written (coverage, platforms across items, source preference per period, new data, taking it, review flags) | | [Exported images](export.md) | Phase 3 | Written (the map as an image with an ID naming the plot and the revision, the footer, the export log; finding an image's source, read-only) | +| [Walkthroughs](walkthroughs.md) | Phase 3 | Written (four walkthroughs on fresh copies of the sample, each step ticked by doing it; the same words in VS Code's Get Started page and the playground; the web demo) | | [Keyboard equivalents](keyboard-equivalents.md) | Phase 3 | Partly written (annotations, tracks, the Tote, the Timeline, collation, exported images) | Analysts review the Phase 1 models before they are built. diff --git a/site/docs/guides/interaction-models/keyboard-equivalents.md b/site/docs/guides/interaction-models/keyboard-equivalents.md index c8f4ec5a..83ea7f60 100644 --- a/site/docs/guides/interaction-models/keyboard-equivalents.md +++ b/site/docs/guides/interaction-models/keyboard-equivalents.md @@ -26,13 +26,14 @@ How every map action can be done without a mouse. | Split or merge tracks | **Split track at current time…** and **Merge selected tracks** on the outline's context menu (**Split…** and **Merge** under *Track*, below the outline, in the playground); there is no map gesture for either. | | Right-click a fix (or press and hold it on a touch screen) to colour, label or comment on it | **Fixes: colour, label or comment…** on the outline's context menu: the fix list, a list to type in (a time finds a fix), then the action. In the playground, **Fixes…** under *Track*: `Up` and `Down` move along the list, `Enter` or `Space` chooses ([track operations](track-operations.md#cleaning-tracks), item 3). | | Move a single fix | Zoomed in far enough that its fixes have rings, `Tab` to a fix's ring; the arrow keys move it 5 pixels (25 with `Shift`), one step for the run. Or **Move a fix…** and type the position ([track operations](track-operations.md#single-fixes-and-dead-reckoning), items 5 and 6). | -| Set how often tracks are labelled, marked with a symbol and given a direction arrow; a fix always showing its label or symbol | Select the tracks in the outline (`Space`, `Ctrl+Space` to add), then **Frequencies: labels, symbols and arrows…** on the outline's menu or the Command Palette: three lists, one after the other, an interval typed in any (**Frequencies…** under *Track* in the playground: a form of three choosers, `Tab` between them). A fix's own: **Fixes: colour, label or comment…**, then **Always show its time label** or **Always show its symbol** (**Time label** and **Symbol** in the playground's fix list) ([labels, symbols and arrows](labels-symbols-arrows.md)). There is no map gesture. | +| Set how often tracks are labelled, marked with a symbol and given a direction arrow; a fix always showing its label or symbol | Select the tracks in the outline (`Enter`, `Ctrl+Shift+Enter` to add), then **Frequencies: labels, symbols and arrows…** on the outline's menu or the Command Palette: three lists, one after the other, an interval typed in any (**Frequencies…** under *Track* in the playground: a form of three choosers, `Tab` between them). A fix's own: **Fixes: colour, label or comment…**, then **Always show its time label** or **Always show its symbol** (**Time label** and **Symbol** in the playground's fix list) ([labels, symbols and arrows](labels-symbols-arrows.md)). There is no map gesture. | | Plot a track over the ground or by dead reckoning | Choosing as it is imported, from a list; afterwards **Plot over the ground or by dead reckoning…** on the outline's menu (**OTG/DR…** in the playground); there is no map gesture. | | Check jumps on the map, or a smoothing preview | **Find and remove jumps…** lists each jump, ticked, in a list read by screen readers; **Smooth track…** says how many fixes would move and the most any would, in words, before Apply ([track operations](track-operations.md#cleaning-tracks), items 1 and 2). | | Choose the Tote's tracks | **Primary** and **Add** in the Tote are lists; `Delete` on a secondary removes it. A track's menu in the outline (`Shift+F10` or the menu key; press and hold on a touch screen) has **Set as Tote primary**, **Add to Tote** and **Remove from Tote** ([Tote](tote.md#keyboard-and-touch)). There is no map gesture. | | Choose a narrative entry, a note or a period to move current time to it; read along while playing | The Timeline is a list: `Up`, `Down`, `Page Up`, `Page Down`, `Home` and `End` move through it, `Enter` or `Space` moves current time to the entry, `F2` changes a narrative entry, `Delete` deletes it, `N` adds one, `/` searches; `Ctrl+Z` undoes the plot's last step from the Timeline except in its text boxes (the search box and the form), where it undoes the typing ([Timeline and narrative](timeline.md#keyboard-and-touch)). The entry on the map beside its track needs no gesture. | | Choose which catalogue item supplies a period of a track; take new data; confirm or refuse a platform | Coverage: `Tab` to a period's list, pick with `↑` `↓` (no step yet) and press `Enter` or **Set**, the keyboard's way for clicking a source's bar; **Take**, **Not now**, **Same platform**, **Not this platform**, **Unlink** and **Reviewed** are buttons. In VS Code's Catalogues, an item's or a track's menu (`Shift+F10` or the menu key) has the same. There is no map gesture ([Collation](collation.md#keyboard-and-touch)). | | Export the map as an image; find the plot an image came from; leave an earlier revision shown read-only | **Export image…** (the Command Palette, the map's title bar, the Outline's menu): a list of sizes, two tick boxes (`Space`), then the save dialog. **Find source of image…**: a list, then the open dialog or the ID typed and `Enter`. On the map, `Tab` to **Go to latest** and `Enter`. In the playground, the *Export image* panel's lists, tick box and buttons, `Enter` in its ID box. There is no map gesture ([Exported images](export.md#keyboard-and-touch)). | +| Follow a walkthrough | VS Code's Get Started page, its own keyboard; in the playground, the *Walkthroughs* panel: a list to choose one, each step a button that opens it, its button the step's own. Every step says its keyboard equivalent ([Walkthroughs](walkthroughs.md)). | | Clear the selection | `Escape` on the map; or the outline, a native tree. | | Pan, zoom | Arrow keys, and `+` and `-`, with the map focused. | diff --git a/site/docs/guides/interaction-models/walkthroughs.md b/site/docs/guides/interaction-models/walkthroughs.md new file mode 100644 index 00000000..b7766481 --- /dev/null +++ b/site/docs/guides/interaction-models/walkthroughs.md @@ -0,0 +1,92 @@ +# Walkthroughs + +How a newcomer learns Debrief on the sample exercise: four short walkthroughs, each step ticked off when the analyst actually does it, the same in VS Code and in the browser. + +## Requirements cited + +| SRD | Requirement | +| --- | --- | +| CAP-25 | Guided first-use walkthroughs for common tasks, using the demonstration sample data. | +| NFR-09 | An analyst with domain knowledge but no Debrief training imports a sample serial, composes a storyboard and exports it within an hour. | +| OUT-05 | A new analyst productive quickly: first useful output within an hour of first use. | +| DEP-06, DEP-07 | A public web demo with fixed sample data; files the analyst chooses are read in the browser only. | +| DEP-08 | The hybrid sample data the walkthroughs use. | +| NFR-13 | Everything works from the keyboard. | + +## Model + +1. **Four walkthroughs** (decision, product owner, 17 Oct 2026), each about 10 minutes, each a handful of steps: + + | Walkthrough | Steps | + | --- | --- | + | Open and explore a serial | Open a copy of serial 1; move current time; select tracks in the Outline; compare tracks in the Tote; read the narrative in the Timeline | + | Import and collate | Open a copy; import BLUE ONE's navigation log (a REP file); see where each source has data (Catalogue, Coverage); take the new data; choose a source for a period | + | Tidy a track | Open a copy with GPS glitches; remove the jumps; smooth the track; mark and label a fix; shear a leg, as in TMA | + | Annotate and export a figure | Open a copy; draw an annotation; label the times on tracks; export a traceable image; find the image's source | + +2. **Each works on a fresh copy, never the sample** (decision, product owner, 17 Oct 2026). The first step of each makes a copy of the sample's serial 1, titled after the walkthrough (*Serial 1: Tidy a track*), whose first step holds the sample's whole history and one more record saying it is a copy; its sources, payloads and supporting files come with it. *Tidy a track*'s copy has three GPS glitches in RED ONE, put in as a step of its own so they are never mistaken for real data; *Import and collate*'s comes with BLUE ONE's navigation log as a REP file to import. `debrief_sample.walkthrough` makes them, the same for both hosts. **Starting a walkthrough again** makes a fresh copy, deletes the old one and unticks the walkthrough's steps, so ticks belong to a copy; pressing the button again while a copy is being made makes none (panel review, 17 Oct 2026). +3. **A step ticks when it is done, never when its button is pressed.** Each step names an event; the host sends it when the change it asks for succeeds, however it was made (a form, the map, the keyboard, an agent): a jump removal saved, an area added, an image exported. Looking is not doing: finding jumps, previewing smoothing or moving a track ticks nothing. A few steps are about seeing: moving current time or selecting two tracks by hand (not a plot opening, a tool or a command, which is how Debrief selects what it has just added), a Tote with a primary and a secondary, an entry chosen in the Timeline, and Coverage on screen once the log is imported. + **Only on the walkthrough's own copy, in the walkthrough shown** (panel review, 17 Oct 2026). The same change made to any other plot ticks nothing, as does the analyst's own file imported (only the walkthrough's log counts) or a stretch made where the step asks for a shear. +4. **Each step's button only takes the analyst there**: it opens the copy, or brings the panel or the command that does the step to hand (in VS Code a command, which asks what it needs; in the playground the panel, opened and scrolled to, or the button clicked). The analyst does the step. With another plot open, the button opens the walkthrough's copy first, and says so; before there is a copy, it refuses and says to open one. A button never acts on the analyst's own plot. +5. **One source of words.** `vscode/walkthroughs/tours.json` holds every step's title, what to do, where and the keyboard, with what differs between hosts beside it. VS Code's `contributes.walkthroughs` is written from it (`npm run walkthroughs`; the unit tests fail if `package.json` differs), and the playground's panel reads it, so the two cannot drift. Each step's picture is an SVG drawn from serial 1 by `python -m debrief_sample media`, in theme colours, the same file in both hosts. +6. **The keyboard, in every step.** Each says how to do it from the keyboard, as the [keyboard equivalents](keyboard-equivalents.md) give it. + +### In VS Code + +The walkthroughs are on VS Code's **Get Started** page, under Debrief: **Debrief: Walkthroughs: learn Debrief on the sample exercise** from the Command Palette, the Catalogues view's menu, or **Start a walkthrough** while no catalogue is added. Each step's button is a command; every step after the first runs through `debrief.walkthrough.do` (the walkthrough, then the command and its arguments), which opens the copy first. A step ticks when its context key, `debrief.walkthrough.`, is set (`completionEvents: ["onContext:…"]`), VS Code's documented way. The Get Started page counts a context key only if it changes after the page's service has started, which may be long after. So the extension also marks the step with VS Code's own `welcome.markStepComplete` (and `welcome.markStepIncomplete` when a walkthrough starts again). These commands are not part of VS Code's API: a failure is written to Debrief's log, and the integration test checks that VS Code still has them. + +**Open a copy of serial 1** (`debrief.walkthrough.open` with the walkthrough's id) builds the sample with the analyst's Python (`python -m debrief_sample walkthrough`) into the extension's own storage, one folder for each walkthrough, and adds it as a catalogue of its own, *Walkthrough: Tidy a track*. Pressed again, it asks: **Start again with a fresh copy** (the old copy and its catalogue go; the sample never changes) or **Carry on with my copy**. Agents pass `{fresh}`. **Import the navigation log** (`debrief.walkthrough.importLog`) imports the log waiting beside *Walkthrough: Import and collate*'s catalogue, as **Import plot file…** imports any REP file. + +### In the playground and the web demo + +A **Walkthroughs** panel, folding like every panel (open in the web demo, folded in the playground and the blog's demos; product owner, 17 Oct 2026). **On a screen 1200 pixels wide or more it is a column of its own on the right of the map** (decision, product owner, 22 Oct 2026): the side panels stay on the left and the map in the middle, so a step's words stay in sight while the analyst does it in the side panels and on the map. The column scrolls on its own, never with the side column or the page. Folded, it is a narrow tab holding its heading on its side, and the map takes the rest; the heading is the same button, from the pointer or the keyboard (`Enter` or `Space`), and the column is a landmark named *Walkthroughs*. Narrower, on a phone and in the blog's demos, it is the side column's first panel, above the time controller, as before. 1200 pixels keeps the map about 670 pixels wide beside both columns, and about 290 beside a tool's form too, which from 901 pixels opens beside the map; at 1280, 1366 and 1440 pixels the column is 256, 273 and 288 wide. It holds a list to choose the walkthrough, its steps (each a button that opens it, its number or a tick, *done* said to screen readers), the step to do next open, and a line saying what was done and what comes next. The copy is made in the browser from the sample as the site shipped it, kept apart from the reader's own changes, so a walkthrough always starts clean. The chosen walkthrough is remembered in the browser; ticks are not, since the copies go when the page closes (product owner, 17 Oct 2026). + +The **web demo** at `/demo/` is the playground's page full screen, every panel and the walkthroughs, opening the fixed sample serials. A file the analyst chooses opens there too, read in the browser only (decision, product owner, 17 Oct 2026, amending DEP-07). No request carries it, nothing is sent at all (nor as the tab closes), and no WebSocket opens. Nothing derived from it is stored either: in the web demo the Timeline keeps only the kinds of entry shown and its view's own settings, never sources, types or highlighted phrases. It is gone when the tab closes. + +**Content-Security-Policy.** The web demo and the playground's pages (the page, each frame, the geodesic demo) carry a strict policy in a meta element (`vscode/play/csp.mjs`): only the site itself, and jsDelivr for Pyodide; no forms, plugins or other origins; `base-uri 'self'` for the demo's ``. A meta element's policy does not reach the Python worker, and GitHub Pages cannot send one as a header. **Pyodide's files come from jsDelivr with no integrity check, a risk the product owner accepts** (17 Oct 2026). + +### Keyboard and touch + +| Where | Key or gesture | Does | +| --- | --- | --- | +| Get Started (VS Code) | `Tab` to a step, `Enter` opens it; `Tab` to its button, `Enter` | VS Code's own page | +| The playground's panel | `Tab` to the list, `↓` chooses a walkthrough; `Tab` to a step, `Enter` or `Space` opens it; `Tab` to its button, `Enter` | Every part a button or a list; a tap does the same | + +The panel never scrolls sideways, nor the page, at 210, 280 or 390 pixels, in the side column of a 650 to 750 pixel window, or in its column at 1200 to 1440 pixels, in a wider font too. + +```mermaid +sequenceDiagram + actor Analyst + participant Page as Get Started / Walkthroughs panel + participant Host as Extension host / playground page + participant Sample as debrief_sample.walkthrough + participant Service as Tool service / Python + Analyst->>Page: Open a copy of serial 1 (Tidy a track) + Page->>Host: debrief.walkthrough.open tidy + Host->>Sample: a fresh copy of serial 1, glitches in RED ONE + Sample-->>Host: Serial 1: Tidy a track + Host-->>Page: event tidyOpened: step ticked + Analyst->>Page: Find jumps in RED ONE + Page->>Host: Find and remove jumps… (RED ONE) + Host->>Service: debrief/find_jumps + Note over Page: nothing ticks: finding changes nothing + Analyst->>Host: Remove 3 jumps + Host->>Service: debrief/remove_jumps + Service-->>Host: one step + Host-->>Page: event jumpsRemoved: step ticked, the next opened +``` + +## The onboarding trial + +NFR-09's trial, with analysts, is the product owner's to run. It is formative. `docs/notes/onboarding-trial.md` gives: +- the pass rule to pre-register: at least 80% complete without help above the first rung, the median silent run within 40 minutes, and no one over 60; +- the participants (a pilot first) and the fixed set-up (a catalogue already added); +- the neutral observer and the help ladder; +- the four tasks (import a REP file and a legacy .dpf plot; tidy; annotate; export a traceable figure), worded so they do not mirror the walkthroughs; +- what to time, the success criteria and their tolerances, the observation sheet, and how results are recorded. + +The trial is pending. The playground's test does the same tasks as a speed run, in the playground only, counting each action (32) and failing above 36, to show they can be done. + +## Reference implementation + +`vscode/walkthroughs/tours.json` (the words) and `contribute.mjs` (how VS Code shows them); `vscode/src/walkthroughs.ts` (VS Code's events and copies); `vscode/play/tours.ts` (the playground's panel and its events); `python/debrief-sample/src/debrief_sample/walkthrough.py` (the copies) and `media.py` (the pictures); `vscode/play/demo.mjs` (the web demo's page). Tests: `vscode/test/unit/walkthroughs.test.ts`, `vscode/test/integration/walkthroughs.ts` (every walkthrough in VS Code, each event on its real action), `vscode/test/play/onboarding.mjs` (each walkthrough start to finish in the playground, the speed run, the web demo), `python/debrief-sample/tests/test_walkthrough.py`. diff --git a/site/docs/guides/sample-data.md b/site/docs/guides/sample-data.md index 390892d1..15533227 100644 --- a/site/docs/guides/sample-data.md +++ b/site/docs/guides/sample-data.md @@ -56,6 +56,10 @@ To see the history of a serial, open its `plot.geojson` and read `provenance`. E The background traffic comes from the US national AIS archive, published by NOAA's Office for Coastal Management on MarineCadastre.gov from the US Coast Guard's receiver network. It is a US Government work, in the public domain in the United States. One day was reduced to a small area, the serial windows and 16 vessels, thinned to one fix a minute, with military, law-enforcement, pilot and search-and-rescue vessels left out. Every identifier was removed. The reduction and the licence are described beside the extract, in `python/debrief-sample/data/README.md`. +## For walkthroughs + +Debrief's walkthroughs (CAP-25) never change the sample: each works on a copy of serial 1, titled after it, made by `debrief_sample.walkthrough`. VS Code builds a fresh sample for each walkthrough into a catalogue of its own, `python -m debrief_sample walkthrough FOLDER TOUR`; the playground copies the sample as the site shipped it. *Tidy a track*'s copy has three GPS glitches in RED ONE, as a step of their own; *Import and collate*'s comes with BLUE ONE's navigation log, a REP file of fiction like the rest. The walkthroughs' pictures are drawn from serial 1 by `python -m debrief_sample media vscode/media/walkthroughs`. See [Walkthroughs](interaction-models/walkthroughs.md). + ## Not yet in it - **Ambiguous bearings** from a towed array (CAP-22): the sonar is a bow sonar, so its bearings are not ambiguous. diff --git a/site/docs/index.md b/site/docs/index.md index ec29747a..de7c4bbf 100644 --- a/site/docs/index.md +++ b/site/docs/index.md @@ -4,6 +4,10 @@ Debrief helps analysts turn the record of an exercise or deployment into underst Debrief v4 replaces the 25-year-old Eclipse application with one shared core that every community uses: a VS Code extension, analysis tools written in Python, and plots stored as plain GeoJSON in STAC catalogues with their provenance inside. +

Open the web demo

+ +The **web demo** is Debrief running full screen in your browser, on the sample exercise, with four walkthroughs to start you off. Files of your own open too: they are read in your browser only, never sent anywhere, and gone when you close the tab. + - **[Blog](blog/index.md)**: short posts as each capability lands. - **[Guides](guides/index.md)**: the style guide, interaction models and tool-author guide that keep Debrief consistent as it grows. diff --git a/site/docs/try.md b/site/docs/try.md index 5d0b86c7..61156f5a 100644 --- a/site/docs/try.md +++ b/site/docs/try.md @@ -4,6 +4,8 @@ This is Debrief v4 running in your browser: the same map, tool forms and results
+New to Debrief? Open **Walkthroughs**, folded at the top of the side column (on a wide screen, a column of its own on the right of the map): four short walkthroughs, each on a fresh copy of the sample, each step ticked as you do it. The same page runs full screen as the web demo. + ## Things to try - **Select and run a tool.** Click **BLUE ONE** in the outline, then Ctrl-click **RED ONE**. **Closest approach** is now on offer. Run it: the line appears on the map, the result opens beside it, and current time moves to the moment it found. diff --git a/site/mkdocs.yml b/site/mkdocs.yml index bf504b42..9e439ad1 100644 --- a/site/mkdocs.yml +++ b/site/mkdocs.yml @@ -83,6 +83,7 @@ nav: - Timeline and narrative: guides/interaction-models/timeline.md - Collation: guides/interaction-models/collation.md - Exported images: guides/interaction-models/export.md + - Walkthroughs: guides/interaction-models/walkthroughs.md - Keyboard equivalents: guides/interaction-models/keyboard-equivalents.md - Tool-author guide: guides/tool-author/index.md - Sample data: guides/sample-data.md diff --git a/vscode/README.md b/vscode/README.md index 4f6f4b3a..4b4575f9 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -31,7 +31,7 @@ How these behave is set out in the [interaction models](../site/docs/guides/inte ## Running it -Needs Node 22, and a Python with Debrief's packages installed. From the repository root, `uv sync` provides one. +Needs Node 22, and a Python with Debrief's packages installed. From the repository root, `uv sync` provides one. The walkthroughs build their copies of the sample with that Python, so it needs `debrief-sample` and its `pyarrow` too (both are in `uv sync`); without them, **Open a copy of serial 1** says so. ```bash for p in schemas kernel config; do npm ci --prefix ts/$p && npm run --prefix ts/$p build; done diff --git a/vscode/media/walkthroughs/annotate-area.svg b/vscode/media/walkthroughs/annotate-area.svg new file mode 100644 index 00000000..1c914509 --- /dev/null +++ b/vscode/media/walkthroughs/annotate-area.svg @@ -0,0 +1,17 @@ + + + +BLUE ONE + +RED ONE + + + + + + + + + +Search area + diff --git a/vscode/media/walkthroughs/annotate-export.svg b/vscode/media/walkthroughs/annotate-export.svg new file mode 100644 index 00000000..ad842988 --- /dev/null +++ b/vscode/media/walkthroughs/annotate-export.svg @@ -0,0 +1,17 @@ + + + +BLUE ONE + +RED ONE + + + + + + + + + +DBF-PTET-KG8R-B9EM-69 + diff --git a/vscode/media/walkthroughs/annotate-frequencies.svg b/vscode/media/walkthroughs/annotate-frequencies.svg new file mode 100644 index 00000000..50450470 --- /dev/null +++ b/vscode/media/walkthroughs/annotate-frequencies.svg @@ -0,0 +1,51 @@ + + + +BLUE ONE + +RED ONE + + + + + + + + + +0800 + +0830 + +0900 + +0930 + +1000 + +1030 + +1100 + +1130 + +1200 + +0800 + +0830 + +0900 + +0930 + +1000 + +1030 + +1100 + +1130 + +1200 + diff --git a/vscode/media/walkthroughs/annotate-open.svg b/vscode/media/walkthroughs/annotate-open.svg new file mode 100644 index 00000000..d9e3ec8c --- /dev/null +++ b/vscode/media/walkthroughs/annotate-open.svg @@ -0,0 +1,15 @@ + + + +BLUE ONE + +RED ONE + + + + + + + + + diff --git a/vscode/media/walkthroughs/annotate-trace.svg b/vscode/media/walkthroughs/annotate-trace.svg new file mode 100644 index 00000000..042fcacb --- /dev/null +++ b/vscode/media/walkthroughs/annotate-trace.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + +DBF-PTET-KG8R-B9EM-69 + + +Step 7 Labels every 10 min +Step 6 Search area added + +Step 5 Shown read-only + diff --git a/vscode/media/walkthroughs/collate-coverage.svg b/vscode/media/walkthroughs/collate-coverage.svg new file mode 100644 index 00000000..d326a0de --- /dev/null +++ b/vscode/media/walkthroughs/collate-coverage.svg @@ -0,0 +1,21 @@ + + + +Coverage +BLUE ONE +Serial 1 + + +Navigation log + + +RED ONE +Serial 1 + + +08:00Z +09:00Z +10:00Z +11:00Z +12:00Z + diff --git a/vscode/media/walkthroughs/collate-import.svg b/vscode/media/walkthroughs/collate-import.svg new file mode 100644 index 00000000..f85ee0a0 --- /dev/null +++ b/vscode/media/walkthroughs/collate-import.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + +;; A navigation log +230615 104000.000 "Blue One" @A … +230615 104010.000 "Blue One" @A … +230615 104020.000 "Blue One" @A … + + +✦ New data + diff --git a/vscode/media/walkthroughs/collate-open.svg b/vscode/media/walkthroughs/collate-open.svg new file mode 100644 index 00000000..a42837fe --- /dev/null +++ b/vscode/media/walkthroughs/collate-open.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/vscode/media/walkthroughs/collate-prefer.svg b/vscode/media/walkthroughs/collate-prefer.svg new file mode 100644 index 00000000..3f9e5721 --- /dev/null +++ b/vscode/media/walkthroughs/collate-prefer.svg @@ -0,0 +1,23 @@ + + + +Coverage +BLUE ONE +Serial 1 + + +Navigation log + + +RED ONE +Serial 1 + + + +09:20Z to 10:20Z: Navigation log +08:00Z +09:00Z +10:00Z +11:00Z +12:00Z + diff --git a/vscode/media/walkthroughs/collate-take.svg b/vscode/media/walkthroughs/collate-take.svg new file mode 100644 index 00000000..7fe42a3a --- /dev/null +++ b/vscode/media/walkthroughs/collate-take.svg @@ -0,0 +1,16 @@ + + + +BLUE ONE + +RED ONE + + + + + + + + + + diff --git a/vscode/media/walkthroughs/explore-open.svg b/vscode/media/walkthroughs/explore-open.svg new file mode 100644 index 00000000..d9e3ec8c --- /dev/null +++ b/vscode/media/walkthroughs/explore-open.svg @@ -0,0 +1,15 @@ + + + +BLUE ONE + +RED ONE + + + + + + + + + diff --git a/vscode/media/walkthroughs/explore-select.svg b/vscode/media/walkthroughs/explore-select.svg new file mode 100644 index 00000000..9088cdea --- /dev/null +++ b/vscode/media/walkthroughs/explore-select.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/vscode/media/walkthroughs/explore-time.svg b/vscode/media/walkthroughs/explore-time.svg new file mode 100644 index 00000000..db0116c6 --- /dev/null +++ b/vscode/media/walkthroughs/explore-time.svg @@ -0,0 +1,22 @@ + + + +BLUE ONE + +RED ONE + + + + + + + + + + + + + +Current time +10:30:00Z + diff --git a/vscode/media/walkthroughs/explore-timeline.svg b/vscode/media/walkthroughs/explore-timeline.svg new file mode 100644 index 00000000..c57bb062 --- /dev/null +++ b/vscode/media/walkthroughs/explore-timeline.svg @@ -0,0 +1,25 @@ + + + +BLUE ONE + +RED ONE + + + + + + + + + + + +10:30Z +RED ONE: Communications window +10:30Z +RED ONE: Came to 200.0°T, 5.0 + +10:30Z +BLUE ONE: RED ONE bearing 275. + diff --git a/vscode/media/walkthroughs/explore-tote.svg b/vscode/media/walkthroughs/explore-tote.svg new file mode 100644 index 00000000..a64481df --- /dev/null +++ b/vscode/media/walkthroughs/explore-tote.svg @@ -0,0 +1,25 @@ + + + +BLUE ONE + +RED ONE + + + + + + + + + + + + +Tote +RED ONE +Range nmi +3.26 nmi +Brg °T +274.3°T + diff --git a/vscode/media/walkthroughs/tidy-jumps.svg b/vscode/media/walkthroughs/tidy-jumps.svg new file mode 100644 index 00000000..a66154ef --- /dev/null +++ b/vscode/media/walkthroughs/tidy-jumps.svg @@ -0,0 +1,17 @@ + + + +BLUE ONE + + + + + + + + + + + + + diff --git a/vscode/media/walkthroughs/tidy-mark.svg b/vscode/media/walkthroughs/tidy-mark.svg new file mode 100644 index 00000000..137ff7f3 --- /dev/null +++ b/vscode/media/walkthroughs/tidy-mark.svg @@ -0,0 +1,17 @@ + + + +BLUE ONE + +RED ONE + + + + + + + + + +Turned away + diff --git a/vscode/media/walkthroughs/tidy-open.svg b/vscode/media/walkthroughs/tidy-open.svg new file mode 100644 index 00000000..a0cf8d37 --- /dev/null +++ b/vscode/media/walkthroughs/tidy-open.svg @@ -0,0 +1,14 @@ + + + +BLUE ONE + + + + + + + + + + diff --git a/vscode/media/walkthroughs/tidy-shear.svg b/vscode/media/walkthroughs/tidy-shear.svg new file mode 100644 index 00000000..070347ca --- /dev/null +++ b/vscode/media/walkthroughs/tidy-shear.svg @@ -0,0 +1,19 @@ + + + +BLUE ONE + +RED ONE + + + + + + + + + + + +New course and speed + diff --git a/vscode/media/walkthroughs/tidy-smooth.svg b/vscode/media/walkthroughs/tidy-smooth.svg new file mode 100644 index 00000000..2db3d91d --- /dev/null +++ b/vscode/media/walkthroughs/tidy-smooth.svg @@ -0,0 +1,15 @@ + + + +BLUE ONE + + + + + + + + + + + diff --git a/vscode/package.json b/vscode/package.json index 0e046c49..7d564164 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -33,7 +33,7 @@ "viewsWelcome": [ { "view": "debrief.catalogues", - "contents": "Debrief keeps plots in catalogues: folders you choose.\n[Add catalogue folder](command:debrief.addCatalogue)\n[Create catalogue](command:debrief.createCatalogue)" + "contents": "Debrief keeps plots in catalogues: folders you choose.\n[Add catalogue folder](command:debrief.addCatalogue)\n[Create catalogue](command:debrief.createCatalogue)\nNew to Debrief? Four short walkthroughs work on a copy of the sample exercise.\n[Start a walkthrough](command:debrief.walkthroughs)" }, { "view": "debrief.outline", @@ -44,6 +44,276 @@ "contents": "Every change to the open plot appears here. Undo returns the plot to an earlier step; nothing is lost." } ], + "walkthroughs": [ + { + "id": "explore", + "title": "Open and explore a serial", + "description": "Serial 1 of the sample exercise: move through time, select tracks, compare them in the Tote and read the narrative in the Timeline. About 8 minutes.", + "steps": [ + { + "id": "explore.open", + "title": "Open a copy of serial 1", + "description": "Serial 1 of the sample exercise, 15 Jun 2023 from 08:00Z to 12:00Z: BLUE ONE gains RED ONE, a submarine, among merchant traffic east of Chesapeake Bay. You work on a copy, titled Serial 1: Open and explore a serial, so the sample itself never changes.\n\nThe copy goes into a catalogue of its own, Walkthrough: Open and explore a serial, in Catalogues.\n\n**Keyboard:** Tab to the button and press Enter.\n\n[Open a copy of serial 1](command:debrief.walkthrough.open?%5B%22explore%22%5D)", + "media": { + "svg": "media/walkthroughs/explore-open.svg", + "altText": "The map of serial 1: BLUE ONE in blue and RED ONE in red among grey merchant tracks." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.exploreOpened" + ] + }, + { + "id": "explore.time", + "title": "Move current time", + "description": "Drag **Current time** in the time controller. Each track's position at that time is marked on the map with a square in its colour. **▶** plays; at 60× a minute of the exercise passes each second. The two handles of **Time window** narrow what is drawn in full.\n\nThe time controller is **Time**, at the top of the Debrief sidebar.\n\n**Keyboard:** Tab to **Current time**; ← and → step it, Page Up and Page Down step further, Home and End go to the window's ends. Space plays and pauses.\n\n[Show the time controller](command:debrief.walkthrough.do?%5B%22explore%22%2C%22debrief.time.focus%22%5D)", + "media": { + "svg": "media/walkthroughs/explore-time.svg", + "altText": "The time controller under the map, with each track's position at current time marked by a square." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.timeMoved" + ] + }, + { + "id": "explore.select", + "title": "Select tracks in the Outline", + "description": "Click **BLUE ONE** in the Outline, then Ctrl-click **RED ONE** to add it. The map outlines what is selected, and the tools offered change with the selection: with two tracks, **Closest approach** is on offer.\n\n**Outline** is in the Debrief sidebar.\n\n**Keyboard:** ↑ and ↓ move through the Outline; Enter selects, Ctrl+Shift+Enter adds to the selection (Cmd+Shift+Enter on macOS).\n\n[Show the Outline](command:debrief.walkthrough.do?%5B%22explore%22%2C%22debrief.outline.focus%22%5D)", + "media": { + "svg": "media/walkthroughs/explore-select.svg", + "altText": "BLUE ONE and RED ONE selected on the map, the other tracks dimmed." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.selected" + ] + }, + { + "id": "explore.tote", + "title": "Compare tracks in the Tote", + "description": "In the Tote, choose **BLUE ONE** as the primary and add **RED ONE**. Each row is a measure, worked out at current time: range, true bearing, relative bearing (R to port, G to starboard), angle on the bow, bearing rate, and each track's course, speed and depth. Move current time and they follow.\n\n**Tote** is below the time controller; it starts folded.\n\n**Keyboard:** **Primary** and **Add** are lists: Tab to one, ↓ to choose. Or, on a track in the Outline, Shift+F10 opens its menu: **Set as Tote primary**, **Add to Tote**.\n\n[Show the Tote](command:debrief.walkthrough.do?%5B%22explore%22%2C%22debrief.tote.focus%22%5D)", + "media": { + "svg": "media/walkthroughs/explore-tote.svg", + "altText": "The Tote: a row for each measure, a column for BLUE ONE and one for RED ONE, with a thin line on the map between them." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.toteChosen" + ] + }, + { + "id": "explore.timeline", + "title": "Read the narrative in the Timeline", + "description": "The Timeline lists BLUE ONE's, RED ONE's and exercise control's logs in one time order. Choose an entry: current time moves to it, and the entry is shown beside its track on the map. While playing, the list follows current time.\n\n**Timeline** is in the Debrief sidebar.\n\n**Keyboard:** ↑ and ↓ move through the list; Enter chooses an entry; / searches.\n\n[Show the Timeline](command:debrief.walkthrough.do?%5B%22explore%22%2C%22debrief.timeline.focus%22%5D)", + "media": { + "svg": "media/walkthroughs/explore-timeline.svg", + "altText": "The Timeline's entries in time order, the one at current time marked, and its text beside RED ONE on the map." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.timelineChosen" + ] + } + ] + }, + { + "id": "collate", + "title": "Import and collate", + "description": "Import BLUE ONE's navigation log, see where each source has data, take the new data and choose which source supplies a period. About 8 minutes.", + "steps": [ + { + "id": "collate.open", + "title": "Open a copy of serial 1", + "description": "A fresh copy of serial 1, titled Serial 1: Import and collate, with BLUE ONE's navigation log ready to import beside it: a fix every 10 s for part of the serial, as new data would arrive after an exercise. The sample itself never changes.\n\nThe copy goes into a catalogue of its own, Walkthrough: Import and collate.\n\n**Keyboard:** Tab to the button and press Enter.\n\n[Open a copy of serial 1](command:debrief.walkthrough.open?%5B%22collate%22%5D)", + "media": { + "svg": "media/walkthroughs/collate-open.svg", + "altText": "The map of serial 1, BLUE ONE's track highlighted." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.collateOpened" + ] + }, + { + "id": "collate.import", + "title": "Import the navigation log", + "description": "Import blue-one-nav-log.rep, a REP file. Debrief asks, for each track, whether it is plotted over the ground (OTG), each fix where it was logged, or by dead reckoning (DR); choose **Over the ground (OTG)**. The file is kept unchanged as a source, and the import is a plot of its own in the catalogue. A legacy Debrief .dpf plot is imported the same way.\n\nYour own files: **Import plot file (REP, .dpf, Word, PDF)…** on Catalogues' title bar.\n\n**Keyboard:** In each question, ↓ chooses and Enter imports.\n\n[Import the navigation log](command:debrief.walkthrough.do?%5B%22collate%22%2C%22debrief.walkthrough.importLog%22%5D)", + "media": { + "svg": "media/walkthroughs/collate-import.svg", + "altText": "A REP file of fixes becoming a new item in the catalogue." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.imported" + ] + }, + { + "id": "collate.coverage", + "title": "See where each source has data", + "description": "In the catalogue, the log is marked **✦ New data**: imported after the plot, sharing a platform with it and with data in its period. **Coverage** shows each of the plot's tracks with a bar for every source of its data, the periods each covers filled in.\n\n**Coverage** is below Catalogues; it starts folded.\n\n**Keyboard:** Tab moves through Coverage's tracks, bars and lists.\n\n[Show Coverage](command:debrief.walkthrough.do?%5B%22collate%22%2C%22debrief.showCoverage%22%5D)", + "media": { + "svg": "media/walkthroughs/collate-coverage.svg", + "altText": "Coverage: BLUE ONE with two bars, the plot's own data and the navigation log's, overlapping for part of the serial." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.coverageShown" + ] + }, + { + "id": "collate.take", + "title": "Take the new data", + "description": "**Take** brings the log's data into the plot as one step: BLUE ONE gains fixes where it had none, and WATCHER, a contact the plot had not seen, is added. Undo takes it back.\n\n**Take** is in Coverage; or **Take data into the open plot…** on the log's menu in Catalogues.\n\n**Keyboard:** Tab to **Take** and press Enter.\n\n[Take the new data](command:debrief.walkthrough.do?%5B%22collate%22%2C%22debrief.takeNewData%22%5D)", + "media": { + "svg": "media/walkthroughs/collate-take.svg", + "altText": "BLUE ONE's track extended by the log's fixes, and WATCHER added." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.newDataTaken" + ] + }, + { + "id": "collate.prefer", + "title": "Choose a source for a period", + "description": "Where the plot and the log both have data for BLUE ONE, choose which supplies that period: the log's fixes are every 10 s, the plot's every minute. Pick the log in the period's list and press **Set**, or click its bar. One step, naming the item and the period.\n\nIn **Coverage**, under BLUE ONE.\n\n**Keyboard:** Tab to the period's list, ↑ and ↓ to pick (nothing changes yet), then Enter or **Set**.\n\n[Show Coverage](command:debrief.walkthrough.do?%5B%22collate%22%2C%22debrief.coverage.focus%22%5D)", + "media": { + "svg": "media/walkthroughs/collate-prefer.svg", + "altText": "Coverage with the log chosen to supply the period where the two overlap." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.sourceChosen" + ] + } + ] + }, + { + "id": "tidy", + "title": "Tidy a track", + "description": "Remove GPS glitches, smooth a track, mark and label a fix, and shear a leg as in TMA. About 10 minutes.", + "steps": [ + { + "id": "tidy.open", + "title": "Open a copy of serial 1, with glitches", + "description": "A fresh copy of serial 1, titled Serial 1: Tidy a track. Three of RED ONE's fixes have been moved miles off its track, as GPS glitches would put them; the step that did it is in History, so it is never mistaken for real data. The sample itself never changes.\n\nThe copy goes into a catalogue of its own, Walkthrough: Tidy a track.\n\n**Keyboard:** Tab to the button and press Enter.\n\n[Open a copy of serial 1](command:debrief.walkthrough.open?%5B%22tidy%22%5D)", + "media": { + "svg": "media/walkthroughs/tidy-open.svg", + "altText": "RED ONE's track with three spikes where its glitched fixes are." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.tidyOpened" + ] + }, + { + "id": "tidy.jumps", + "title": "Remove the jumps", + "description": "A jump is a fix RED ONE could not have reached in time: faster than three times its median speed, and at least 10 kn. Select RED ONE and find its jumps: they are ringed on the map and listed, each ticked. Untick any that are real, then remove the rest, as one step.\n\n**Find and remove jumps…** is on RED ONE's menu in the Outline.\n\n**Keyboard:** Enter takes the threshold offered; in the list of jumps, Space ticks and unticks and Enter removes those ticked.\n\n[Find jumps in RED ONE](command:debrief.walkthrough.do?%5B%22tidy%22%2C%22debrief.removeJumps%22%2C%22track-red-one%22%5D)", + "media": { + "svg": "media/walkthroughs/tidy-jumps.svg", + "altText": "RED ONE with its three jumps ringed." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.jumpsRemoved" + ] + }, + { + "id": "tidy.smooth", + "title": "Smooth the track", + "description": "Smoothing moves each fix to the average of itself and the fixes either side, over an odd number of fixes; the first and last stay where they are, and no time changes. Debrief shows the track as it would be, dashed, and how far fixes would move, before you apply it as one step.\n\n**Smooth track…** is on the track's menu in the Outline.\n\n**Keyboard:** Type the number of fixes and press Enter; then **Apply**.\n\n[Smooth RED ONE](command:debrief.walkthrough.do?%5B%22tidy%22%2C%22debrief.smoothTrack%22%2C%22track-red-one%22%5D)", + "media": { + "svg": "media/walkthroughs/tidy-smooth.svg", + "altText": "RED ONE and, dashed beside it, the track as smoothing would leave it." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.smoothed" + ] + }, + { + "id": "tidy.mark", + "title": "Mark and label a fix", + "description": "Give one fix a colour, a label or a comment of its own: the moment RED ONE turned, say. The fix list shows every fix with its time, position, course and speed; choose one, then its colour or **Label…**. Each change is one step, and the mark stays with its fix whatever else changes.\n\n**Fixes: colour, label or comment…** is on the track's menu in the Outline; or right-click a fix on the map.\n\n**Keyboard:** ↑ and ↓ move through the fix list; Enter chooses a fix.\n\n[List RED ONE's fixes](command:debrief.walkthrough.do?%5B%22tidy%22%2C%22debrief.markFix%22%2C%22track-red-one%22%5D)", + "media": { + "svg": "media/walkthroughs/tidy-mark.svg", + "altText": "One of RED ONE's fixes drawn in its own colour with a label beside it." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.fixMarked" + ] + }, + { + "id": "tidy.shear", + "title": "Shear a leg, as in TMA", + "description": "Shear holds one end of a track and moves the other: every fix moves in proportion to its time from the held end, so a straight leg stays straight with a new course and speed. It is the drag analysts use most on TMA legs. Select BLUE ONE and drag the diamond beside its last fix; the map gives the new course and speed. Letting go is one step.\n\n**Shear track…** is on the track's menu in the Outline.\n\n**Keyboard:** Tab to the diamond and use the arrow keys. Or shear by typing the course and speed made good.\n\n[Shear BLUE ONE](command:debrief.walkthrough.do?%5B%22tidy%22%2C%22debrief.shearTrack%22%2C%22track-blue-one%22%5D)", + "media": { + "svg": "media/walkthroughs/tidy-shear.svg", + "altText": "BLUE ONE's leg sheared: the start held, the end dragged by its diamond to a new position." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.sheared" + ] + } + ] + }, + { + "id": "annotate", + "title": "Annotate and export a figure", + "description": "Draw an annotation, label the times on tracks, export the map as an image that traces to its plot, and find the plot again from the image. About 10 minutes.", + "steps": [ + { + "id": "annotate.open", + "title": "Open a copy of serial 1", + "description": "A fresh copy of serial 1, titled Serial 1: Annotate and export a figure. The sample itself never changes.\n\nThe copy goes into a catalogue of its own, Walkthrough: Annotate and export a figure.\n\n**Keyboard:** Tab to the button and press Enter.\n\n[Open a copy of serial 1](command:debrief.walkthrough.open?%5B%22annotate%22%5D)", + "media": { + "svg": "media/walkthroughs/annotate-open.svg", + "altText": "The map of serial 1, ready to annotate." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.annotateOpened" + ] + }, + { + "id": "annotate.annotation", + "title": "Draw an annotation", + "description": "On the map's toolbar choose **Area**, click each corner and double-click the last: the map gives ranges, bearings and the area as you draw. Name it in the form, such as Search area, and press **Add**. It is one step; drag a corner later to change it.\n\nThe toolbar is on the map; **Add annotation…** is on the Outline's title bar.\n\n**Keyboard:** Tab to the map's toolbar and press Enter on **Area**; the arrow keys pan the map under the cross, Space places a corner, Enter finishes. Or add it by typing the positions.\n\n[Add an annotation](command:debrief.walkthrough.do?%5B%22annotate%22%2C%22debrief.addAnnotation%22%5D)", + "media": { + "svg": "media/walkthroughs/annotate-area.svg", + "altText": "An area drawn on the map in amber, dashed and labelled Search area." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.annotationAdded" + ] + }, + { + "id": "annotate.frequencies", + "title": "Label the times on tracks", + "description": "Select BLUE ONE and RED ONE, then set their frequencies: labels every 10 min and arrows every 5 min, one step for both. The times sit beside each track on the clock's tens; zoomed out, they thin to round times, and the map says so.\n\n**Frequencies: labels, symbols and arrows…** is on a track's menu in the Outline.\n\n**Keyboard:** Each of labels, symbols and arrows is a list: ↓ to choose, Enter for the next; or type an interval such as 90 s.\n\n[Set BLUE ONE's and RED ONE's frequencies](command:debrief.walkthrough.do?%5B%22annotate%22%2C%22debrief.setTrackFrequencies%22%2C%5B%22track-blue-one%22%2C%22track-red-one%22%5D%5D)", + "media": { + "svg": "media/walkthroughs/annotate-frequencies.svg", + "altText": "BLUE ONE and RED ONE labelled with their times every 10 minutes, with direction arrows." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.frequenciesSet" + ] + }, + { + "id": "annotate.export", + "title": "Export a traceable image", + "description": "Export the map as a PNG, as a 1600 × 900 slide. It is the map as you see it, labels where the map put them. The image carries an ID naming this plot and the exact step it shows: in its metadata, and in a small footer, such as DBF-PTET-KG8R-B9EM-69, for when Word or PowerPoint drops the metadata. Exporting is never a step.\n\n**Export image…** is the camera on the map's title bar.\n\n**Keyboard:** Each choice is a list or a box to tick: ↓, Space, Enter.\n\n[Export image](command:debrief.walkthrough.do?%5B%22annotate%22%2C%22debrief.exportImage%22%5D)", + "media": { + "svg": "media/walkthroughs/annotate-export.svg", + "altText": "The exported image: the map with the image's ID in its least busy corner." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.imageExported" + ] + }, + { + "id": "annotate.trace", + "title": "Find the image's source", + "description": "Change the plot, then find the image's source: type the ID from its corner (or choose the image file). Debrief opens the plot as it was when the image was exported, read-only, at the view and time exported. **Go to latest** returns to the plot as it is now.\n\n**Find source of image…** is on Catalogues' title bar menu.\n\n**Keyboard:** Type the ID and press Enter; on the map, Tab to **Go to latest** and press Enter.\n\n[Find source of image](command:debrief.walkthrough.do?%5B%22annotate%22%2C%22debrief.findImageSource%22%5D)", + "media": { + "svg": "media/walkthroughs/annotate-trace.svg", + "altText": "The image's ID leading back to the plot, shown read-only at the step it was exported." + }, + "completionEvents": [ + "onContext:debrief.walkthrough.imageTraced" + ] + } + ] + } + ], "commands": [ {"command": "debrief.addCatalogue", "title": "Add catalogue folder…", "category": "Debrief", "icon": "$(folder-opened)"}, {"command": "debrief.createCatalogue", "title": "Create catalogue…", "category": "Debrief", "icon": "$(new-folder)"}, @@ -103,6 +373,10 @@ {"command": "debrief.contextTracks.hidden", "title": "Context tracks: Hidden", "category": "Debrief", "enablement": "debrief.contextTracks", "toggled": {"condition": "debrief.contextTracks == hidden"}}, {"command": "debrief.exportImage", "title": "Export image…", "category": "Debrief", "icon": "$(device-camera)", "enablement": "debrief.plotOpen"}, {"command": "debrief.findImageSource", "title": "Find source of image…", "category": "Debrief", "icon": "$(search)"}, + {"command": "debrief.walkthroughs", "title": "Walkthroughs: learn Debrief on the sample exercise", "category": "Debrief", "icon": "$(mortar-board)"}, + {"command": "debrief.walkthrough.open", "title": "Open a fresh copy of the sample for a walkthrough…", "category": "Debrief"}, + {"command": "debrief.walkthrough.importLog", "title": "Import the walkthrough's navigation log", "category": "Debrief"}, + {"command": "debrief.walkthrough.do", "title": "Do a walkthrough's step on its copy", "category": "Debrief"}, {"command": "debrief.openLatestRevision", "title": "Go to latest revision of the plot", "category": "Debrief", "icon": "$(arrow-right)", "enablement": "debrief.revisionShown"} ], "menus": { @@ -125,6 +399,7 @@ {"command": "debrief.redo", "when": "view == debrief.history", "group": "navigation@2"}, {"command": "debrief.exportImage", "when": "view == debrief.outline && debrief.plotOpen", "group": "3_export@1"}, {"command": "debrief.findImageSource", "when": "view == debrief.catalogues", "group": "2_trace@1"}, + {"command": "debrief.walkthroughs", "when": "view == debrief.catalogues", "group": "3_learn@1"}, {"command": "debrief.openLatestRevision", "when": "view == debrief.history && debrief.revisionShown", "group": "navigation@0"} ], "editor/title": [ @@ -179,7 +454,9 @@ {"command": "debrief.dismissNewData", "when": "false"}, {"command": "debrief.linkTrack", "when": "false"}, {"command": "debrief.unlinkTrack", "when": "false"}, - {"command": "debrief.openLatestRevision", "when": "debrief.revisionShown"} + {"command": "debrief.openLatestRevision", "when": "debrief.revisionShown"}, + {"command": "debrief.walkthrough.importLog", "when": "false"}, + {"command": "debrief.walkthrough.do", "when": "false"} ] }, "keybindings": [ @@ -231,7 +508,8 @@ "measure:startup": "node scripts/build.mjs --production && node dist-test/startup-run.js", "test:play": "node test/play/play.mjs ../site/docs/play ../site/site", "check:theme": "node scripts/check-theme.mjs", - "check:language": "node scripts/check-language.mjs" + "check:language": "node scripts/check-language.mjs", + "walkthroughs": "node scripts/walkthroughs.mjs" }, "dependencies": { "@debrief/config": "file:../ts/config", diff --git a/vscode/play/csp.d.mts b/vscode/play/csp.d.mts new file mode 100644 index 00000000..73357460 --- /dev/null +++ b/vscode/play/csp.d.mts @@ -0,0 +1,4 @@ +// Types for csp.mjs, which node runs as it is. +export declare const CSP: string; +export declare const CSP_META: string; +export declare const CSP_PAGES: string[]; diff --git a/vscode/play/csp.mjs b/vscode/play/csp.mjs new file mode 100644 index 00000000..ae4e6df9 --- /dev/null +++ b/vscode/play/csp.mjs @@ -0,0 +1,25 @@ +// The playground's and the web demo's Content-Security-Policy (product owner, 17 Oct 2026), in a +// meta element at the top of every page (index.html, each frame, geodesic.html); a unit test +// holds every page to this text. The page fetches only its own site, and Pyodide from jsDelivr, +// which Python's worker loads (the worker is not under a meta element's policy). jsDelivr's +// files are taken as they come, with no integrity check: a risk the product owner accepts. +// 'wasm-unsafe-eval' lets Pyodide compile WebAssembly; inline style attributes are the map's. +export const CSP = [ + "default-src 'self'", + "connect-src 'self' https://cdn.jsdelivr.net", + "worker-src 'self' blob:", + "script-src 'self' 'wasm-unsafe-eval' https://cdn.jsdelivr.net", + "img-src 'self' data: blob:", + "style-src 'self' 'unsafe-inline'", + "font-src 'self' data:", + "object-src 'none'", + "form-action 'none'", + // 'self', not 'none': the web demo's points at the playground's files beside it. + "base-uri 'self'", +].join("; "); + +/** The meta element, as each page has it. */ +export const CSP_META = ``; + +/** The pages that carry it. */ +export const CSP_PAGES = ["index.html", "frame-map.html", "frame-form.html", "frame-annotation.html", "frame-results.html", "geodesic.html"]; diff --git a/vscode/play/demo.d.mts b/vscode/play/demo.d.mts new file mode 100644 index 00000000..3d3508fd --- /dev/null +++ b/vscode/play/demo.d.mts @@ -0,0 +1,3 @@ +// Types for demo.mjs, which node runs as it is (scripts/build-play.mjs). +export declare const DEMO_META: string; +export declare function demoPage(index: string, base?: string): string; diff --git a/vscode/play/demo.mjs b/vscode/play/demo.mjs new file mode 100644 index 00000000..171ef637 --- /dev/null +++ b/vscode/play/demo.mjs @@ -0,0 +1,14 @@ +// The web demo (DEP-06, DEP-07): the playground's page, every panel and the walkthroughs, +// served full screen at /demo/ beside the playground at /play/, whose files it uses (the +// below). It opens the fixed sample serials; a file the reader chooses is read in the +// browser only, under Pyodide, never sent anywhere or stored (product owner, 17 Oct 2026). +import { CSP_META } from "./csp.mjs"; + +/** What tells host.ts this is the web demo: the walkthroughs open, nothing kept that came from a plot. */ +export const DEMO_META = ''; + +/** The demo's page, made from the playground's index.html: its policy first, then the , which the policy allows. */ +export function demoPage(index, base = "../play/") { + if (!index.includes(CSP_META) || !/[^<]*<\/title>/.test(index)) throw new Error("index.html does not start as the web demo expects"); + return index.replace(CSP_META, `${CSP_META}\n<base href="${base}">\n${DEMO_META}`).replace(/<title>[^<]*<\/title>/, "<title>Debrief web demo"); +} diff --git a/vscode/play/frame-annotation.html b/vscode/play/frame-annotation.html index 38b9fef8..20284938 100644 --- a/vscode/play/frame-annotation.html +++ b/vscode/play/frame-annotation.html @@ -2,6 +2,7 @@ + Debrief annotation diff --git a/vscode/play/frame-form.html b/vscode/play/frame-form.html index 8420d965..902de14c 100644 --- a/vscode/play/frame-form.html +++ b/vscode/play/frame-form.html @@ -2,6 +2,7 @@ + Debrief form diff --git a/vscode/play/frame-map.html b/vscode/play/frame-map.html index 5423acfa..514f97e8 100644 --- a/vscode/play/frame-map.html +++ b/vscode/play/frame-map.html @@ -2,6 +2,7 @@ + Debrief map diff --git a/vscode/play/frame-results.html b/vscode/play/frame-results.html index 1a16b00c..b780dee5 100644 --- a/vscode/play/frame-results.html +++ b/vscode/play/frame-results.html @@ -2,6 +2,7 @@ + Debrief results diff --git a/vscode/play/geodesic.html b/vscode/play/geodesic.html index b6fcf3dc..56ac2a05 100644 --- a/vscode/play/geodesic.html +++ b/vscode/play/geodesic.html @@ -2,6 +2,7 @@ + Range and bearing: v4 and legacy diff --git a/vscode/play/host.css b/vscode/play/host.css index 8274e996..c02898c2 100644 --- a/vscode/play/host.css +++ b/vscode/play/host.css @@ -2,7 +2,7 @@ html, body { height: 100%; margin: 0; } [hidden] { display: none !important; } body { background: var(--vscode-editor-background); color: var(--vscode-foreground); font-family: var(--vscode-font-family); font-size: var(--vscode-font-size); } -.app { display: grid; grid-template-columns: clamp(210px, 28vw, 290px) 1fr; grid-template-rows: 1fr auto; height: 100%; } +.app { display: grid; grid-template-columns: clamp(210px, 28vw, 290px) minmax(0, 1fr) auto; grid-template-rows: 1fr auto; height: 100%; } .side { grid-row: 1; overflow-y: auto; background: var(--vscode-sideBar-background); border-right: 1px solid var(--vscode-panel-border); } .side section { padding: 6px 10px 10px; border-bottom: 1px solid var(--vscode-sideBarSectionHeader-border); } .side h2 { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; margin: 4px 0 6px; display: flex; justify-content: space-between; align-items: center; color: var(--vscode-sideBarSectionHeader-foreground); } @@ -45,7 +45,8 @@ button:focus-visible, li:focus-visible { outline: 1px solid var(--vscode-focusBo .item-actions { display: flex; flex-wrap: wrap; gap: 2px 4px; } .item-actions button { margin: 2px 0 0; padding: 1px 6px; } .tools li { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; } -.file input { display: block; margin: 4px 0; color: var(--vscode-foreground); font: inherit; } +.file input { display: block; margin: 4px 0; color: var(--vscode-foreground); font: inherit; max-width: 100%; min-width: 0; } +.file { display: block; overflow-wrap: anywhere; } .omitted-panel { padding: 10px 14px; overflow-y: auto; font-size: 13px; } .omitted-panel h2 { font-size: 15px; margin: 4px 0 8px; } .omitted-panel ul { padding-left: 18px; } @@ -61,7 +62,7 @@ main { grid-row: 1; display: flex; min-width: 0; min-height: 0; position: relati .side-bar button { text-transform: none; letter-spacing: 0; } .unavailable h2 { color: var(--vscode-disabledForeground); } .status.problem { color: var(--vscode-errorForeground); } -footer { grid-column: 1 / span 2; padding: 3px 10px; background: var(--vscode-editorWidget-background); border-top: 1px solid var(--vscode-panel-border); font-size: 12px; } +footer { grid-column: 1 / -1; padding: 3px 10px; background: var(--vscode-editorWidget-background); border-top: 1px solid var(--vscode-panel-border); font-size: 12px; } .status.busy::before { content: "⟳ "; display: inline-block; animation: spin 1.2s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .toasts { position: fixed; right: 12px; bottom: 34px; display: flex; flex-direction: column; gap: 6px; max-width: 380px; z-index: 10; } @@ -165,3 +166,38 @@ dialog::backdrop { background: #0006; } #history li.shown { outline: 1px dashed var(--vscode-editorWarning-foreground, var(--vscode-focusBorder)); outline-offset: -1px; } #body-export input[type="file"] { max-width: 100%; min-width: 0; } .export-form { grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr)); } +/* Walkthroughs (CAP-25): the same steps as VS Code's Get Started page, each ticked when it is done. + Each step is a button that opens it, its tick said in words too; nothing scrolls sideways, down to 210 px. */ +.tour-pick { display: flex; flex-direction: column; gap: 2px; font-size: 12px; } +.tour-summary { margin: 6px 0; overflow-wrap: anywhere; } +.tour-steps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; } +.tour-step { min-width: 0; } +.side .tour-head { display: flex; align-items: baseline; gap: 6px; width: 100%; margin: 0; padding: 3px 2px; text-align: left; background: none; border: 0; border-radius: 2px; color: inherit; white-space: normal; overflow-wrap: anywhere; } +.side .tour-head:hover:not(:disabled) { background: var(--vscode-list-hoverBackground); } +.tour-mark { flex: none; display: inline-flex; justify-content: center; align-items: center; min-width: 1.4em; height: 1.4em; border-radius: 50%; border: 1px solid var(--vscode-descriptionForeground); font-size: 11px; } +.tour-step.done .tour-mark { background: var(--vscode-button-background); color: var(--vscode-button-foreground); border-color: var(--vscode-button-background); } +.tour-step.next .tour-title { font-weight: 600; } +.tour-step.done .tour-title { color: var(--vscode-descriptionForeground); } +.tour-body { padding: 2px 2px 8px calc(1.4em + 8px); overflow-wrap: anywhere; min-width: 0; } +.tour-body p { margin: 4px 0; } +.tour-body button { max-width: 100%; white-space: normal; text-align: left; } +.tour-media { display: block; margin: 4px 0; max-width: 100%; } +.tour-media svg { display: block; width: 100%; height: auto; } +.tour-body kbd { font-family: var(--vscode-editor-font-family); font-size: 0.92em; padding: 0 3px; border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border)); border-radius: 3px; } +.tour-said { margin: 6px 0 0; min-height: 1em; overflow-wrap: anywhere; } +.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } +/* On a wide screen the walkthroughs have a column of their own, on the right of the map: a step's + words stay in sight, whatever the side column is scrolled to, while it is done there and on the map. + It scrolls on its own. Folded, it is its heading on its side, a narrow tab, and the map takes the + rest (product owner, 22 Oct 2026). Narrower than 1200 px it is not shown: the walkthroughs are the + side column's first panel, as before (host.ts moves them). */ +.side.guide { grid-row: 1; grid-column: 3; min-height: 0; border-right: 0; border-left: 1px solid var(--vscode-panel-border); } +.guide > .panel { width: clamp(240px, 20vw, 300px); box-sizing: border-box; padding-left: 14px; border-bottom: 0; } +.guide > .panel.folded { width: auto; padding: 8px 5px; } +.guide > .panel.folded h2 { margin: 0; } +.guide > .panel.folded h2 button.fold { writing-mode: vertical-rl; padding: 2px 0; white-space: nowrap; } +.guide > .panel.folded h2 button.fold .arrow { position: static; display: inline-block; width: auto; margin-bottom: 6px; text-orientation: upright; } +.guide > .panel.folded h2 button.fold .arrow::before { content: "◂"; } +/* A note shows over the map, not over the walkthroughs' column. */ +body:has(#guide:not([hidden]) > .panel:not(.folded)) .toasts { right: calc(clamp(240px, 20vw, 300px) + 12px); } +body:has(#guide:not([hidden]) > .panel.folded) .toasts { right: 44px; } diff --git a/vscode/play/host.ts b/vscode/play/host.ts index be33a741..5dd17dd0 100644 --- a/vscode/play/host.ts +++ b/vscode/play/host.ts @@ -10,8 +10,13 @@ * * Query parameters choose what an embedded demo shows: * plot=serial-1 the sample serial to open - * panels=tote,timeline,tools,history,export,try,import the side panels, in this order (all by default; - * the Tote, when listed, is always under the time controller) + * panels=tour,tote,timeline,tools,history,export,try,import the side panels, in this order (all by default; + * the Tote, when listed, is always under the time controller, and the + * walkthroughs always above it, or, on a wide screen, in a column of + * their own on the right of the map) + * + * The web demo (demo.mjs) is this page, full screen at /demo/, using the playground's files. + * Files the reader opens, there and here, are read in the browser only (DEP-07). * * Every side panel folds away by its heading (fold.ts). The playground remembers which are * folded in this browser; a demo that chooses its panels starts with them all open, and @@ -45,6 +50,8 @@ import { formatQuantity, figureIdProblem } from "@debrief/kernel"; import type { FigureSource, ImageExport, RenderedImage, RevisionShown } from "@debrief/schemas"; import { fileNameFor, PRESETS, type ImageFormat, type ImagePreset, type ImageTheme } from "../webview/exportlogic.js"; import { describeExported, describeSource, READ_ONLY, rememberedOf, revisionOf, startingChoice, type ExportChoice } from "../src/figures.js"; +import { callEvent, counts, TourPanel, tourOfEvent, type TourAction } from "./tours.js"; +import { contentFree } from "../webview/timelinelogic.js"; type Feature = PlotFeature; interface Step { @@ -72,7 +79,9 @@ interface Opened { } const params = new URLSearchParams(location.search); -const panelOrder = (params.get("panels") ?? "tote,timeline,catalogue,coverage,tools,history,export,try,import").split(","); +/** The web demo (demo.mjs): this page full screen at /demo/. It keeps nothing derived from a plot's content (panel review, 17 Oct 2026). */ +const demo = !!document.querySelector('meta[name="debrief-demo"]'); +const panelOrder = (params.get("panels") ?? "tour,tote,timeline,catalogue,coverage,tools,history,export,try,import").split(","); const panels = new Set(panelOrder); const startPlot = params.get("plot") ?? "serial-1"; /** Every side panel folds by its heading; remembered in this browser, except in a demo that chooses its panels. */ @@ -80,9 +89,12 @@ const folds = new Folds(Array.from(document.querySelectorAll("#side const theme = matchMedia("(prefers-color-scheme: light)").matches || params.get("theme") === "light" ? "light" : "dark"; if (theme === "light") document.documentElement.classList.add("light"); const $ = (id: string) => document.getElementById(id) as T; +/** An element that may not be on the page, or undefined. */ +const optional = (id: string) => (document.getElementById(id) as T | null) ?? undefined; // --- Python, in a worker ---------------------------------------------------------- -const worker = new Worker(new URL("python.worker.js", location.href), { type: "module" }); +// From the page's base: the web demo at /demo/ runs the playground's files (its ). +const worker = new Worker(new URL("python.worker.js", document.baseURI), { type: "module" }); let nextId = 0; const waiting = new Map void; reject: (e: Error) => void }>(); worker.onmessage = ({ data }) => { @@ -97,8 +109,13 @@ const CHANGES_PLOT = new Set(["run", "move", "restyle", "frequencies", "annotate function python(call: string, ...args: unknown[]): Promise { if (opened?.revision && CHANGES_PLOT.has(call)) return Promise.resolve(JSON.stringify({ error: READ_ONLY })); const id = nextId++; - worker.postMessage({ id, call, args, base: new URL(".", location.href).href }); - return new Promise((resolve, reject) => waiting.set(id, { resolve, reject })); + worker.postMessage({ id, call, args, base: new URL(".", document.baseURI).href }); + return new Promise((resolve, reject) => waiting.set(id, { resolve, reject })).then((result) => { + // What the reader did, for the walkthroughs: a change that succeeded ticks its step (CAP-25), + // if it was to the walkthrough's copy: the plot named, or for the log's import the plot open. + tourEvent(callEvent(call, args, result), typeof args[0] === "string" && call !== "import_tour_log" ? args[0] : opened?.item.id); + return result; + }); } const pythonJson = async (call: string, ...args: unknown[]) => JSON.parse(await python(call, ...args)) as T; /** Whether Python has started: until then, only the map and outline work. */ @@ -134,6 +151,7 @@ let timePending = false; const timeControl = new TimeControl($("timebar"), { moved: (moved) => { time = moved; + tourEvent("timeMoved", opened?.item.id); refreshTote(); // asked at once: the Tote keeps pace by never queueing (NFR-04) timelineControl.setTime(time); // the Timeline follows; it draws at most once a frame coverageControl.setTime(Date.parse(moved.current)); // a line across Coverage's bars @@ -221,6 +239,7 @@ async function chooseTote(view: ToteView): Promise { toteToMap(); refreshTote(); await saveView({ tote: view }); + if (view.primary && view.secondaries?.length && sameTote(toteOf(opened?.plot), view)) tourEvent("toteChosen", opened?.item.id); // Saved (the island has it now) or not (what is saved is shown again); unless the reader // has chosen again meanwhile, which is still being saved and stays on screen. if (toteChoosing !== view) return; @@ -384,8 +403,15 @@ window.addEventListener("message", (event) => { return; } if (name === "map") { - if (message.kind === "selection_changed") void select((message.selection as { feature_ids: string[] }).feature_ids, true); - if (message.kind === "time_changed") showTime(message.time as TimeState); + if (message.kind === "selection_changed") { + const ids = (message.selection as { feature_ids: string[] }).feature_ids; + selectedByHand(ids); + void select(ids, true); + } + if (message.kind === "time_changed") { + tourEvent("timeMoved", opened?.item.id); + showTime(message.time as TimeState); + } if (message.kind === "viewport_changed") viewport = (message.viewport as { bbox: number[] }).bbox; if (message.kind === "show_hidden" && opened) void restyle(hiddenIds(opened.plot), { hidden: false }); if (message.kind === "edit_annotation" && message.annotation) { @@ -1207,7 +1233,8 @@ $("find-jumps").onclick = async () => { remove.disabled = false; return toast(outcome.error, "warning"); } - closeSide(); + // Only this panel: the next walkthrough step's button, in sight beside the map, may have opened another meanwhile. + if ($("side-frame").contains(remove)) closeSide(); }; }; @@ -1252,7 +1279,8 @@ $("smooth-track").onclick = async () => { apply.disabled = false; return toast(outcome.error, "warning"); } - closeSide(); + // Only this panel: the next walkthrough step's button, in sight beside the map, may have opened another meanwhile. + if ($("side-frame").contains(apply)) closeSide(); }; }; @@ -1420,7 +1448,9 @@ const timelineControl = new TimelineControl( history: (how) => void move(how), }, undefined, - params.has("panels") ? undefined : timelineMemory, + // The web demo keeps the kinds shown and the view's own settings, never sources, types or + // phrases, which come from what was opened (panel review, 17 Oct 2026); an embedded demo, nothing. + params.has("panels") ? undefined : demo ? { load: timelineMemory.load, save: (value: unknown) => timelineMemory.save(contentFree(value)) } : timelineMemory, ); (window as unknown as { debriefTimeline: () => unknown }).debriefTimeline = () => ({ ...timelineControl.state, drawTimes: timelineControl.drawTimes.slice(-20) }); let narrativeOnMap = new NarrativeOnMap([]); @@ -1450,6 +1480,7 @@ function goToTime(id: string): void { const range = time.range; const span = { start: at < range.start ? at : range.start, end: at > range.end ? at : range.end }; showToolTime({ time: { current: at, range: span } }, opened.item.id, false); + tourEvent("timelineChosen", opened.item.id); } /** Add, change or delete narrative entries, or read a REP file's narrative in, as debrief/add_narrative and the rest do. */ async function narrate(change: Record): Promise<{ error?: string; feature?: string; entries?: number; omitted?: string[] }> { @@ -1523,8 +1554,11 @@ function renderOutline(): void { const set = describeSetFrequencies(f.properties); if (set) row.append(Object.assign(document.createElement("span"), { className: "detail freq-detail", textContent: set })); } - const choose = (add: boolean) => - void select(add ? (selection.includes(f.id) ? selection.filter((i) => i !== f.id) : [...selection, f.id]) : [f.id]); + const choose = (add: boolean) => { + const ids = add ? (selection.includes(f.id) ? selection.filter((i) => i !== f.id) : [...selection, f.id]) : [f.id]; + selectedByHand(ids); + void select(ids); + }; row.onclick = (e) => choose(e.ctrlKey || e.metaKey || e.shiftKey); if (f.properties.kind === "track") { // Its menu: right-click, press and hold on a touch screen, or Shift+F10 (the Tote, CAP-06). @@ -1778,6 +1812,7 @@ async function refreshCollation(): Promise { reviewFlags = answer.flags ?? []; coverageControl.show(coverage); coverageControl.setTime(time ? Date.parse(time.current) : undefined); + coverageSeen(); renderItems(); const fresh = reviewFlags.filter((f) => !flagsSaid.has(`${f.item}:${f.revised.map((r) => r.revision).join()}`)); for (const f of fresh) flagsSaid.add(`${f.item}:${f.revised.map((r) => r.revision).join()}`); @@ -2054,17 +2089,39 @@ function setUpExport(): void { setUpExport(); // --- Wiring ----------------------------------------------------------------------- -const OPTIONAL = ["tote", "timeline", "catalogue", "coverage", "tools", "history", "export", "try", "import"]; +const OPTIONAL = ["tour", "tote", "timeline", "catalogue", "coverage", "tools", "history", "export", "try", "import"]; for (const panel of OPTIONAL) $(`panel-${panel}`).hidden = !panels.has(panel); // The panels in the order the demo lists them, after the plot and its outline; the Tote stays -// under the time controller, since both follow current time. -for (const panel of panelOrder) if (OPTIONAL.includes(panel) && panel !== "tote") $("side").append($(`panel-${panel}`)); +// under the time controller, since both follow current time, and the walkthroughs above them. +for (const panel of panelOrder) if (OPTIONAL.includes(panel) && panel !== "tote" && panel !== "tour") $("side").append($(`panel-${panel}`)); /** History, Try and Import need Python: off until it is ready. */ const NEEDS_PYTHON = ["export-image", "find-file", "find-id", "find-id-go", "new-data", "revise", "undo", "redo", "track-frequencies", "move-track", "rotate-track", "shear-track", "stretch-track", "split-track", "merge-tracks", "find-jumps", "smooth-track", "fix-list", "plotting-mode", "ask-again", "glitches", "crash", "colleague", "colleague-window", "colleague-during", "file", "narrative-file", "colour", "hide", "show", "add-annotation", "edit-annotation", "delete-annotation", "north-arrow"]; function needsPython(on: boolean): void { for (const id of NEEDS_PYTHON) $(id).disabled = !on; } needsPython(false); +// The walkthroughs: where a newcomer starts. On a wide screen, a column of their own on the right +// of the map, so a step's words stay in sight while it is done in the side panels and on the map; +// folded, the column narrows to its heading, and the map widens (product owner, 22 Oct 2026). +// Narrower, first in the side column, above the time controller, as before. Open in the web +// demo; folded in the playground and the blog's demos, whose readers came for something else +// (product owner, 17 Oct 2026). +/** Wide enough for the walkthroughs' column: the map keeps about 290 px beside it with a tool's form open too. */ +const GUIDE_WIDE = matchMedia("(min-width: 1200px)"); +function placeTour(): void { + if (!panels.has("tour")) return; + const tour = $("panel-tour"); + const into = GUIDE_WIDE.matches ? $("guide") : $("side"); + $("guide").hidden = !GUIDE_WIDE.matches; + if (tour.parentElement === into) return; + // Moved with the keyboard on it, the keyboard stays on it. + const focused = tour.contains(document.activeElement) ? (document.activeElement as HTMLElement) : undefined; + into.prepend(tour); + focused?.focus({ preventScroll: true }); +} +placeTour(); +GUIDE_WIDE.addEventListener("change", placeTour); +if (!demo) folds.setOpen("panel-tour", false); $("undo").onclick = () => void move("undo"); $("redo").onclick = () => void move("redo"); $("plots").onchange = (e) => void openPlot((e.target as HTMLSelectElement).value); @@ -2144,8 +2201,7 @@ $("ask-again").onclick = async () => { toast("Debrief will ask how each track you import is plotted."); }; /** How each track of a REP file is plotted: asked, one track at a time, unless a choice is remembered. Undefined if the reader stopped. */ -async function askModes(name: string, data: Uint8Array): Promise<{ modes: Record; remembered: boolean } | undefined> { - const tracks = await pythonJson("rep_tracks", name, data); +async function askModes(tracks: string[]): Promise<{ modes: Record; remembered: boolean } | undefined> { const remembered = (await python("import_mode")) as "ask" | PlottingMode; // As legacy Debrief asked: over the ground or by dead reckoning, and apart from that a box to tick. return chooseModes( @@ -2266,7 +2322,7 @@ $("file").onchange = async (e) => { trackName = values.name; } } - const asked = await askModes(file.name, data); + const asked = await askModes(await pythonJson("rep_tracks", file.name, data)); if (!asked) return toast("Nothing was imported."); status(`Importing ${file.name}…`, true); const report = await pythonJson<{ @@ -2289,6 +2345,11 @@ $("file").onchange = async (e) => { // A Debrief plot file: its whole history came with it (decision 13 Oct). return toast(`Added ${file.name} with its history: ${count(report.steps ?? 0, "step")}, undo and redo included.`); } + showImported(file.name, report); +}; + +/** What an import brought in, beside the map; nothing is dropped silently (EXT-15). */ +function showImported(name: string, report: { tracks: string[]; fixes: number; annotations?: number; narrative_entries?: number; omitted: string[]; notes?: string[] }): void { const parts = [count(report.tracks.length, "track"), count(report.fixes, "fix", "fixes")]; if (report.annotations) parts.push(count(report.annotations, "annotation")); if (report.narrative_entries) parts.push(count(report.narrative_entries, "narrative entry", "narrative entries")); @@ -2296,7 +2357,7 @@ $("file").onchange = async (e) => { const panel = document.createElement("div"); panel.className = "omitted-panel"; const heading = document.createElement("h2"); - heading.textContent = `${file.name}: imported ${parts.join(", ")}`; + heading.textContent = `${name}: imported ${parts.join(", ")}`; const lead = document.createElement("p"); lead.textContent = report.omitted.length ? `Not imported (${report.omitted.length}), and recorded in the plot's provenance:` @@ -2309,7 +2370,144 @@ $("file").onchange = async (e) => { modes.replaceChildren(...(report.notes ?? []).map((line) => Object.assign(document.createElement("li"), { textContent: line }))); panel.append(heading, modes, lead, list); showSide("Import", panel); -}; +} + +// --- Walkthroughs (CAP-25): VS Code's Get Started page, as a panel; the same words ----------- +// Each step ticks when the reader does it (tourEvent, from what happened). Its button takes +// the reader to the right place, or opens a fresh copy of serial 1 for the walkthrough. +const READY = "Ready. Everything runs in your browser; nothing is sent anywhere."; +const TOUR_KEY = "debrief.play.tour"; +const tourPanel = panels.has("tour") + ? new TourPanel($("tour"), (actions) => tourAct(actions), params.has("panels") ? undefined : { + load: () => { + try { + return window.localStorage.getItem(TOUR_KEY) ?? undefined; + } catch { + return undefined; + } + }, + save: (tour) => { + try { + window.localStorage.setItem(TOUR_KEY, tour); + } catch { + // Not remembered: the walkthrough is still shown on this visit. + } + }, + }, (file) => fetch(new URL(`walkthroughs/${file}`, document.baseURI)).then((r) => (r.ok ? r.text() : Promise.reject(new Error(file))))) + : undefined; +/** Each walkthrough's copy of serial 1, by walkthrough: made here, gone when the page closes. */ +const tourCopies = new Map(); +/** Something the reader did, to a plot: it ticks its step only in the walkthrough shown, on its copy (counts). */ +function tourEvent(event: string | undefined, item: string | undefined): void { + if (!event || !tourPanel) return; + const tour = tourOfEvent(event); + if (tour && counts(event, { active: tourPanel.current, copy: tourCopies.get(tour), item, done: new Set(tourPanel.events) })) tourPanel.doneWith(event); +} +/** Tracks selected by hand: the step asks for two, so a click that leaves one, or none, is not it. */ +function selectedByHand(ids: string[]): void { + if (ids.filter((id) => features().find((f) => f.id === id)?.properties.kind === "track").length >= 2) tourEvent("selected", opened?.item.id); +} +(window as unknown as { debriefTours: () => unknown }).debriefTours = () => ({ tour: tourPanel?.current, events: tourPanel?.events ?? [], copies: Object.fromEntries(tourCopies) }); +/** For the tests: how many steps a plot's history has, read from Python. */ +(window as unknown as { debriefStepsOf: (item: string) => Promise }).debriefStepsOf = async (item) => (await pythonJson("open_plot", item)).steps.length; +/** Coverage on screen, showing the open plot's: a walkthrough's step is to look at it. */ +let coverageOnScreen = false; +function coverageSeen(): void { + if (coverageOnScreen && coverage && folds.isOpen("panel-coverage") && !$("panel-coverage").hidden) tourEvent("coverageShown", opened?.item.id); +} +new IntersectionObserver((entries) => { + // Enough of it in sight to read a track or two: a study's Coverage can be far taller than the screen. + coverageOnScreen = entries.some((e) => e.isIntersecting && e.intersectionRect.height >= Math.min(120, e.boundingClientRect.height)); + coverageSeen(); +}, { threshold: [0, 0.02, 0.05, 0.1, 0.25, 0.5, 1] }).observe($("coverage")); +$("panel-coverage").addEventListener("fold", () => coverageSeen()); + +/** Bring a panel or a control into view, opening its panel; click it, or put the keyboard on it. */ +function reveal(id: string, click: boolean): void { + const element = optional(id); + if (!element) return toast("That is not on this page.", "warning"); + const section = element.matches("section.panel") ? element : element.closest("section.panel"); + if (section) { + section.hidden = false; + folds.setOpen(section.id, true); + } + // A panel: its heading; a list: its first row; otherwise the control itself. + const target = + element.matches("section.panel") + ? (element.querySelector("button.fold") ?? element) + : element.matches("button, input, select, textarea, [tabindex]") + ? element + : (element.querySelector("button, input, select, [tabindex]") ?? element); + // A panel shown from its top, so what it holds is in sight; a control just brought into sight. + if (element === section) section.scrollIntoView({ block: "start" }); + else target.scrollIntoView({ block: "nearest" }); + if (click) target.click(); + else target.focus({ preventScroll: true }); +} +/** What a step's button does, in order: on the walkthrough's copy, which is opened first if another plot is open. */ +async function tourAct(actions: TourAction[]): Promise { + if (actions[0]?.verb !== "open" && tourPanel) { + const copy = tourCopies.get(tourPanel.current); + if (!copy) return toast("Open a copy of serial 1 first: this walkthrough's first step.", "warning"); + if (opened?.item.id !== copy) { + await openPlot(copy); + toast(`${opened?.item.properties.title ?? copy} is open again: the walkthrough works on its copy, not on the plot that was open.`); + } + } + for (const action of actions) { + if (action.verb === "open") await openTourCopy(action.target); + else if (action.verb === "focus") reveal(action.target, false); + else if (action.verb === "click") reveal(action.target, true); + else if (action.verb === "select") await select(action.target.split(",").filter((id) => features().some((f) => f.id === id))); + else if (action.verb === "import-log") await importTourLog(); + } +} +/** + * A fresh copy of serial 1 for a walkthrough, made from the sample as shipped, opened. Again, + * it starts the walkthrough again: the old copy goes and its steps are unticked (panel review, + * 17 Oct 2026). One at a time, however often the button is pressed. + */ +let copying = false; +async function openTourCopy(tour: string): Promise { + if (!pythonReady) return toast("The walkthrough's copy can be made once Python is ready.", "warning"); + if (copying) return; + copying = true; + try { + status("Making a copy of serial 1 for the walkthrough…", true); + const made = await pythonJson<{ item?: string; title?: string; error?: string }>("tour_copy", tour, tourCopies.get(tour) ?? null); + status(READY); + if (made.error || !made.item) return toast(made.error ?? "The walkthrough's copy could not be made.", "warning"); + tourCopies.set(tour, made.item); + tourPanel?.restart(tour); + await refreshPlots(); + await openPlot(made.item); + toast(`${made.title}: a fresh copy of serial 1 for this walkthrough, under Plot. The sample itself is unchanged.`); + tourEvent(`${tour}Opened`, made.item); + } finally { + copying = false; + } +} +/** Import and collate: the navigation log that came with the walkthrough's copy, imported as a REP file is, into the catalogue. */ +async function importTourLog(): Promise { + if (!pythonReady || !opened) return; + const log = await pythonJson<{ name?: string; tracks?: string[]; error?: string }>("tour_log_tracks"); + if (log.error || !log.tracks) return toast(log.error ?? "The navigation log could not be read.", "warning"); + const asked = await askModes(log.tracks); + if (!asked) return toast("Nothing was imported."); + status(`Importing ${log.name}…`, true); + const report = await pythonJson<{ item?: string; tracks: string[]; fixes: number; omitted: string[]; notes?: string[]; error?: string }>( + "import_tour_log", + Object.keys(asked.modes).length ? JSON.stringify(asked.modes) : null, + asked.remembered ? REMEMBERED_IN_PLAYGROUND : null, + ); + status(READY); + if (report.error || !report.item) return toast(report.error ?? "The navigation log could not be imported.", "warning"); + // The plot stays open: the log is new data for it, in the catalogue. + await refreshPlots(); + await refreshCollation(); + folds.setOpen("panel-catalogue", true); + showImported(log.name ?? "The navigation log", report); +} /** Show the sample plot straight from the site while Python starts, so the map is never empty. */ async function preview(item: string): Promise { diff --git a/vscode/play/index.html b/vscode/play/index.html index 3feee68b..551ef4d5 100644 --- a/vscode/play/index.html +++ b/vscode/play/index.html @@ -2,6 +2,7 @@ + Debrief in your browser @@ -15,6 +16,12 @@