Repository navigation
Play the architecture's history between two commits on the web map #121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
190730d
5e816f7
ea42844
93d5cfa
8b40874
d9e1622
f07909c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| # Play architecture history | ||
|
|
||
| In the live web map, choose a commit, open **compare 2 revisions**, and choose | ||
| an older start commit. **Play** walks the range from oldest to newest. The speed | ||
| selector offers 0.5×, 1×, 2×, and 4×; **Stop** leaves the current step visible. | ||
| The caption shows that step's short commit ID, subject, and local date and time. | ||
| The two revision fields keep the chosen range. Its `from` and `revision` URL | ||
| parameters reopen the range without starting playback automatically. | ||
|
|
||
| Only commits readable by the current architecture reader are visited, in the | ||
| same Git order as the revision picker. Both endpoints must be readable. The | ||
| working tree is not a playback endpoint. Playback is available only in the live | ||
| web map; static exports and the terminal viewer keep their existing behavior. | ||
|
|
||
| The first frame shows the start snapshot. Each later frame uses the comparison | ||
| from the previous readable commit to the next, including retained removed | ||
| elements and their parent context. The map's existing keyed geometry transition | ||
| moves shared buildings and routes and grows new ones. Reduced motion applies | ||
| each frame immediately. When playback finishes, the original start-to-end | ||
| comparison returns with its changes bar and stepper. A person can pan and zoom | ||
| while playback continues. | ||
|
|
||
| [`revision/control.ts`](../../../src/viewers/web/revision/control.ts) owns the | ||
| selected revision and connects the playback controls to the browser session. | ||
| [`revision/playback.ts`](../../../src/viewers/web/revision/playback.ts) owns the | ||
| clock, cancellation, speed, caption, range, and a cache of requested frames. It | ||
| loads the next two frames ahead. Stopping invalidates pending frame applications | ||
| without discarding their cached data. | ||
|
|
||
| The live [`map-session.ts`](../../../src/viewers/web/map-session.ts) serves | ||
| `/playback.json?from=<commit>&revision=<commit>` to identify readable frames. | ||
| [`revision/history.ts`](../../../src/viewers/web/revision/history.ts) shares | ||
| in-flight historical reads for that server session: each commit's architecture, | ||
| layout, and owned source text are loaded once. Comparisons reuse those snapshots; | ||
| source files owned only on the other side are read and cached when needed. | ||
| Compatibility checks populate the same cache used by playback. Working-tree | ||
| comparisons continue to read current files. | ||
|
|
||
| Playback is transient browser state, not stored architecture knowledge. It adds | ||
| no OKF metadata or C4 element type. Git commits and the existing architecture | ||
| documents remain the portable history; the browser revision selector and web | ||
| host own its playback interpretation. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ import type { WebDataSource } from '../data.ts' | |
| import { bindPopover } from '../atoms/popover.ts' | ||
| import type { WebBootPayload, WebPayload, WebWorkPayload } from '../payload.ts' | ||
| import { NARROW_HEADER, pendingPairFields, revisionFields, revisionOptions, snapshotNotice, type RevisionField } from './view.ts' | ||
| import { createRevisionPlayback } from './playback.ts' | ||
|
|
||
| interface RevisionControlOptions { | ||
| box: HTMLElement | ||
|
|
@@ -100,6 +101,16 @@ export function createRevisionControl(options: RevisionControlOptions) { | |
| let navigating = false | ||
| let pendingWorld: WebPayload | undefined | ||
| let appliedWork = boot.workGeneration | ||
| const playback = createRevisionPlayback({ | ||
| root, data, current: () => current, repaint: paint, | ||
| present(payload, first) { | ||
| editing = undefined | ||
| setRevision(payload) | ||
| if (first) applyRevision(payload) | ||
| else applyWorld(payload) | ||
| }, | ||
| error: showError, | ||
| }) | ||
|
|
||
| const selected = () => current.revision?.id | ||
| const from = () => current.comparison === undefined ? undefined : current.comparison.from?.id ?? '' | ||
|
|
@@ -151,17 +162,23 @@ export function createRevisionControl(options: RevisionControlOptions) { | |
| if (editing === undefined || singleSnapshot) delete box.dataset.editing | ||
| else box.dataset.editing = editing | ||
| // Live refreshes repaint often; untouched fields keep their focus and do not replay their entrance. | ||
| const nextFields = starting() ? pendingPairFields(current.revision) : revisionFields(current) | ||
| const range = playback.range | ||
| const displayed = range === undefined ? current : { | ||
| ...current, revision: range.to, | ||
| comparison: { components: {}, relationships: {}, ...current.comparison, from: range.from }, | ||
| } | ||
| const nextFields = starting() ? pendingPairFields(current.revision) : revisionFields(displayed) | ||
| if (nextFields !== paintedFields) { fields.innerHTML = nextFields; paintedFields = nextFields } | ||
| search.hidden = !opened || singleSnapshot | ||
| search.placeholder = placeholders[editing ?? 'revision'] | ||
| search.setAttribute('aria-label', searchLabels[editing ?? 'revision']) | ||
| paintCompareEntry() | ||
| end.hidden = current.comparison === undefined && !starting() | ||
| end.hidden = current.comparison === undefined && !starting() && range === undefined | ||
| end.title = starting() ? 'Cancel comparison' : 'End comparison' | ||
| end.setAttribute('aria-label', end.title) | ||
| menu.hidden = !opened | ||
| placeMenu() | ||
| playback.paint() | ||
| } | ||
|
|
||
| function close(): void { | ||
|
|
@@ -219,6 +236,7 @@ export function createRevisionControl(options: RevisionControlOptions) { | |
| } | ||
|
|
||
| function open(field: RevisionField): void { | ||
| playback.cancel() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When playback is stopped on an intermediate frame—or while it is running—the header still displays the original start/end range, but clicking either endpoint calls AGENTS.md reference: AGENTS.md:L265-L267 Useful? React with 👍 / 👎. |
||
| // Widths are read from the closed layout, also when another field is the search right now. | ||
| close() | ||
| const width = (name: RevisionField) => fields.querySelector(`[data-field="${name}"]`)?.getBoundingClientRect().width | ||
|
|
@@ -233,6 +251,7 @@ export function createRevisionControl(options: RevisionControlOptions) { | |
|
|
||
| function setRevision(payload: WebPayload): void { | ||
| current = payload | ||
| playback.remember(payload) | ||
| body.toggleAttribute('data-revision', !live()) | ||
| body.toggleAttribute('data-comparison', payload.comparison !== undefined) | ||
| paint() | ||
|
|
@@ -250,6 +269,7 @@ export function createRevisionControl(options: RevisionControlOptions) { | |
| } | ||
|
|
||
| async function load(revision?: string, starting?: string, reset = true): Promise<void> { | ||
| playback.cancel() | ||
| const loading = ++request | ||
| navigating = true | ||
| error.hidden = true | ||
|
|
@@ -312,7 +332,12 @@ export function createRevisionControl(options: RevisionControlOptions) { | |
| const option = event.target.closest<HTMLButtonElement>('.revision-option') | ||
| if (option !== null && !option.disabled) chooseRevision(option.dataset.revision!) | ||
| }) | ||
| end.addEventListener('click', () => { if (starting()) close(); else void load(selected()) }) | ||
| end.addEventListener('click', () => { | ||
| const destination = playback.range?.to.id ?? selected() | ||
| playback.cancel() | ||
| if (starting()) close() | ||
| else void load(destination) | ||
| }) | ||
| // The list follows its field whenever the header reflows the box. | ||
| new ResizeObserver(placeMenu).observe(box) | ||
|
|
||
|
|
@@ -340,6 +365,8 @@ export function createRevisionControl(options: RevisionControlOptions) { | |
| return { | ||
| get selected() { return selected() }, | ||
| get from() { return from() }, | ||
| get urlRevision() { return playback.range?.to.id ?? selected() }, | ||
| get urlFrom() { return playback.range?.from.id ?? from() }, | ||
| get comparison() { return current.comparison }, | ||
| get live() { return live() }, | ||
| paintProjectEdit(root: ParentNode) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The task is moved to
Donewhile every acceptance criterion and Definition of Done items #1 and #2 remain unchecked, and its own notes state that browser playback and the required fifty-commit behavior are unverified. This makes Backlog report completion despite missing objective evidence and confirmed functional findings in the supported flow; return it to a non-terminal status until those criteria are satisfied.AGENTS.md reference: AGENTS.md:L281-L282
Useful? React with 👍 / 👎.