Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,12 @@ There is no visual/screenshot regression testing — see
- **Harmonics Mode**: Real-time harmonic calculation and display
- **Sidebands Mode**: A pin set with a user-placed origin — the fundamental —
with members spread each side of it and labelled by signed offset
- **Doppler Mode**: Speed calculation from f+/f-/f₀ markers
- **Doppler Mode**: Speed calculation from f+/f-/f₀ markers. One curve per gram:
a drag that starts on a marker moves it, and a drag that starts anywhere else
draws a new curve in place of the old one. `isCompleteCurve` in
`utils/doppler.js` is the one rule for "there is a curve" — the renderer,
the mode's capability predicate and storage all read it, so a lone marker
can neither draw, persist nor block a placement

## Active Technologies
- Markdown documentation (no code changes) + N/A (documentation-only feature) (154-enrich-docs)
Expand Down
4 changes: 4 additions & 0 deletions docs/Gram-Modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,10 @@ on a spectrogram.

- Click and drag to place the f+ and f− markers in one gesture; the curve
previews during the drag. On release, f+ is the later of the two in time.
- There is one curve per gram. A drag that starts clear of the markers draws a
new curve in place of the old one; a drag that starts on a marker moves it.
A press released without moving places nothing and leaves the curve as it
was.
- f₀ is placed automatically at the midpoint and can then be dragged
independently — dragging f+ or f− leaves it where it is.
- An S-curve is drawn between f+ and f−, with vertical extensions clipped to the
Expand Down
2 changes: 1 addition & 1 deletion hygiene-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"specTypeErrors": 503,
"waitForTimeoutOccurrences": 1,
"_comment_instanceSurface": "specs/167-structural-refactor Story 5. instanceStateReachIns counts lines containing `instance.state` under src/; instanceFields counts class-field declarations between `export class GramFrame` and its constructor in src/main.js.",
"instanceStateReachIns": 141,
"instanceStateReachIns": 139,
"_comment_instanceFields_168": "Raised 11 -> 12 by spec 168: `player` is a fifth grouped sub-object (the audio element, its controller and follow loop share one lifetime), not a flat field returning to the surface.",
"instanceFields": 12
}
14 changes: 7 additions & 7 deletions src/core/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@ function handleWheel(instance, event) {
*/
export function setupEventListeners(instance) {
// Every listener is kept as a bound reference so cleanupEventListeners can
// actually remove it (GF-14). Anonymous inline handlers were unremovable, so
// "cleanup" relied entirely on the SVG being dropped from the DOM.
// actually remove it (GF-14); anonymous inline handlers were unremovable.
/** @type {Array<{target: EventTarget, type: string, handler: EventListener, options?: AddEventListenerOptions}>} */
const registered = []

Expand Down Expand Up @@ -104,9 +103,8 @@ export function setupEventListeners(instance) {
handleContextMenu(instance, /** @type {MouseEvent} */ (event))
})

// Mouse wheel for global zoom (Ctrl+scroll) and horizontal pan (scroll).
// passive:false so the handler can preventDefault() to stop the host page
// from scrolling during a zoom/pan gesture.
// Mouse wheel for global zoom (Ctrl+scroll) and horizontal pan (scroll);
// passive:false so preventDefault() can stop the host page scrolling.
listen(instance.ui.svg, 'wheel', (event) => {
handleWheel(instance, /** @type {WheelEvent} */ (event))
}, { passive: false })
Expand All @@ -129,11 +127,13 @@ export function setupEventListeners(instance) {
}
})

// Frequency-rate input UI events removed - the backend frequencyRate is preserved

// Window resize event
listen(window, 'resize', instance.viewport._boundHandleResize)

// A release over another window fires neither mouseup nor mouseleave; a drag
// left registered would block every later press in every mode.
listen(window, 'blur', () => cancelActiveDrag(instance))

instance.interaction._registeredListeners = registered
}

Expand Down
8 changes: 4 additions & 4 deletions src/core/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
/// <reference path="../types.js" />

import { normalizeMarkerLabel } from '../utils/markerLabel.js'
import { isCompleteCurve } from '../utils/doppler.js'

/** @type {number} */
const SCHEMA_VERSION = 1
Expand Down Expand Up @@ -237,17 +238,16 @@ export function buildStorageKey(instanceIndex) {
* failed write is worth telling the analyst about: with nothing annotated,
* there is nothing to lose yet.
*
* Checks all three doppler markers, matching the mode's own
* `hasPersistentFeatures` predicate: an fZero-only state renders, so it must
* also persist rather than deleting the storage key on save (BH-32).
* A doppler curve counts only when complete (`isCompleteCurve`), matching the
* mode's own `hasPersistentFeatures` and what renders (BH-32, corrected).
* @param {GramFrameState} state - Current component state
* @returns {boolean} True when at least one annotation exists
*/
export function hasPersistableAnnotations(state) {
const hasMarkers = !!(state.analysis && state.analysis.markers && state.analysis.markers.length > 0)
const hasHarmonics = !!(state.harmonics && state.harmonics.harmonicSets && state.harmonics.harmonicSets.length > 0)
const hasSidebands = !!(state.sidebands && state.sidebands.sidebandSets && state.sidebands.sidebandSets.length > 0)
const hasDoppler = !!(state.doppler && (state.doppler.fPlus !== null || state.doppler.fMinus !== null || state.doppler.fZero !== null))
const hasDoppler = isCompleteCurve(state.doppler)
return hasMarkers || hasHarmonics || hasSidebands || hasDoppler
}

Expand Down
11 changes: 6 additions & 5 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { createGramFrameAPI } from './api/GramFrameAPI.js'
import {
cleanupEventListeners
} from './core/events.js'
import { cancelActiveDrag } from './modes/shared/BaseDragHandler.js'

import {
saveAnnotations,
Expand Down Expand Up @@ -364,6 +365,8 @@ export class GramFrame {
* of the drag record. Writing `state.drag` directly instead left the engine
* saying *dragging* while the projection said *idle*, and the next publish
* resurrected the stale drag (M4). One place now, not two (issue #268).
* Ends with the engine's own cancellation point, which reaches the region
* and wheel-pan handlers a mode switch could not.
* @returns {void}
*/
_cancelAllDrags() {
Expand All @@ -372,18 +375,16 @@ export class GramFrame {
modeInstance.dragHandler.cancelDrag()
}
})
cancelActiveDrag(this)
}

/**
* Clear all annotations from state and storage
*/
_clearGram() {
// Clearing the gram also cancels a wheel pan, which a mode switch does not:
// the shared helper covers the feature drags both paths cancel.
// Every drag, the wheel pan included: the shared helper ends with the
// engine's own cancellation point, which reaches whichever handler owns it.
this._cancelAllDrags()
if (this.interaction._wheelPanHandler) {
this.interaction._wheelPanHandler.cancelDrag()
}

// Clear the selection through the selection seam rather than replacing the
// selection object: this also re-syncs the style controls, so they stop
Expand Down
89 changes: 45 additions & 44 deletions src/modes/doppler/DopplerMode.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@ import { setLEDValue } from '../../components/LEDDisplay.js'
import { updateLEDDisplays } from '../../components/UIComponents.js'
import { dispatch, markAnnotationsChanged, recordDopplerDeletion } from '../../core/state.js'
// Rendering imports removed - no display element
import { calculateDopplerSpeed, calculateMidpoint, MS_TO_KNOTS } from '../../utils/doppler.js'
import { calculateDopplerSpeed, calculateMidpoint, isCompleteCurve, snapshotCurve, replacedCurveOf, MS_TO_KNOTS } from '../../utils/doppler.js'
import { dataToSVG } from '../../utils/coordinates.js'
import { BaseDragHandler } from '../shared/BaseDragHandler.js'
import { getUniformTolerance, isWithinDataTolerance, findClosestTarget } from '../../utils/tolerance.js'
import { IDLE_CURSOR } from '../../utils/cursors.js'
/** @typedef {import('../../utils/doppler.js').DopplerCurveSnapshot} DopplerCurveSnapshot */

// Doppler marker types
/** @type {Record<DopplerDraggedMarker, DopplerDraggedMarker>} */
const DopplerDraggedMarker = {
fPlus: 'fPlus',
fMinus: 'fMinus',
fMinus: 'fMinus',
fZero: 'fZero'
}

Expand Down Expand Up @@ -124,7 +125,7 @@ export class DopplerMode extends BaseMode {
*/
onMarkerDragEnd(target, _position) {
if (target && target.kind === 'place') {
this.completeMarkerPlacement()
this.completeMarkerPlacement(replacedCurveOf(target))
}
// Nothing else to unwind: the engine clears the drag record itself.
}
Expand All @@ -135,81 +136,89 @@ export class DopplerMode extends BaseMode {
* Cancel and end used to share one callback, so a cancelled placement —
* mode switch or Escape mid-gesture — *committed* the half-placed f⁺/f⁻
* curve the user thought was discarded (BH-9). A cancelled placement now
* discards the markers it seeded; a cancelled move leaves the marker at its
* last position, like the other modes.
* puts back the curve it was replacing (or clears what it seeded); a
* cancelled move leaves the marker at its last position, like the other modes.
* @param {DragTarget} target - Drag target from the engine
*/
onMarkerDragCancel(target) {
if (target && target.kind === 'place') {
const doppler = this.instance.state.doppler
doppler.fPlus = null
doppler.fMinus = null
doppler.fZero = null
doppler.speed = null
doppler.tempFirst = null
doppler.previewEnd = null
this.updateSpeedLED()
this.renderDopplerFeatures()
dispatch(this.instance, { frame: true })
this.restoreCurve(replacedCurveOf(target))
}
}

/**
* Resolve what a mousedown in doppler mode starts: moving one of the placed
* markers, or — with nothing placed yet — laying down f+ and dragging out f-.
* Resolve what a mousedown in doppler mode starts: moving the placed marker
* under the pointer, or — anywhere else — laying down f+ and dragging out f-,
* replacing whatever curve there was. Placement used to be offered only while
* *no* marker existed, so once one did every drag clear of it was ignored.
* @param {DataCoordinates} position - Position of the mousedown
* @returns {DragTarget|null} A move- or place-kind target
* @returns {DragTarget} A move- or place-kind target
*/
resolveDopplerDrag(position) {
const doppler = this.instance.state.doppler

if (doppler.fPlus || doppler.fMinus || doppler.fZero) {
return this.findDopplerMarkerAtPosition(position)
}

return this.startMarkerPlacement(position)
return this.findDopplerMarkerAtPosition(position) || this.startMarkerPlacement(position)
}

/**
* Seed f+ at the mousedown position and return a `place`-kind target, so the
* rest of the placement is an ordinary drag with f- following the pointer.
*
* `tempFirst` and `previewEnd` stay on state.doppler: they are placement
* geometry the renderer needs, not drag bookkeeping (data-model.md §2).
* geometry the renderer needs, not drag bookkeeping (data-model.md §2). The
* curve being replaced rides on the target, so a cancelled or moveless
* placement can put it back.
* @param {DataCoordinates} dataCoords - Data coordinates {freq, time}
* @returns {DragTarget} A place-kind target
*/
startMarkerPlacement(dataCoords) {
const doppler = this.instance.state.doppler
const replaced = snapshotCurve(doppler)

// Immediately set f+ at the current position
// Immediately set f+ at the current position. The old curve's colour goes
// with it: this is a new curve, and takes the colour currently selected.
doppler.fPlus = { time: dataCoords.time, freq: dataCoords.freq }
Object.assign(doppler, { fMinus: null, fZero: null, speed: null, color: null })

// f- will follow the mouse from here
doppler.tempFirst = doppler.fPlus
doppler.previewEnd = { time: dataCoords.time, freq: dataCoords.freq }

// Render initial curve preview
this.updateSpeedLED()
this.renderDopplerFeatures()

return {
kind: 'place',
id: DopplerDraggedMarker.fMinus,
type: 'dopplerMarker',
position: dataCoords,
data: { markerType: DopplerDraggedMarker.fMinus }
data: { markerType: DopplerDraggedMarker.fMinus, replaced }
}
}

/**
* Put back the curve a placement was replacing, or with none to put back
* clear what it seeded. The state is what it was before the press either
* way, so nothing is marked changed.
* @param {DopplerCurveSnapshot|null} replaced - The curve to restore, or null
*/
restoreCurve(replaced) {
const empty = { fPlus: null, fMinus: null, fZero: null, speed: null, color: null }
Object.assign(this.instance.state.doppler, replaced || empty, { tempFirst: null, previewEnd: null })
this.updateSpeedLED()
this.renderDopplerFeatures()
dispatch(this.instance, { frame: true })
}

/**
* Finalise a placement drag: order the markers, derive f₀, and clear the
* placement geometry.
* placement geometry. A release before any movement is a click, not a curve,
* and restores what was there: it used to leave an invisible f+ behind.
* @param {DopplerCurveSnapshot|null} replaced - The curve the placement replaced
*/
completeMarkerPlacement() {
completeMarkerPlacement(replaced) {
const doppler = this.instance.state.doppler
if (!doppler.tempFirst || !doppler.fPlus || !doppler.fMinus) {
doppler.tempFirst = null
doppler.previewEnd = null
this.restoreCurve(replaced)
return
}

Expand Down Expand Up @@ -246,7 +255,7 @@ export class DopplerMode extends BaseMode {
getGuidanceText() {
return {
items: [
{ trigger: 'Click & drag', outcome: 'to place f+ and f\u2212 in one gesture; the curve previews during the drag' },
{ trigger: 'Click & drag', outcome: 'to place f+ and f\u2212 in one gesture; the curve previews during the drag. A drag that starts clear of the markers draws a new curve in place of the old one' },
{ trigger: 'Drag f+ or f\u2212', outcome: 'to adjust; f\u2080 can be dragged independently' },
{ trigger: 'f\u2080 marker', outcome: 'is placed automatically at the midpoint' },
{ trigger: 'Right-click', outcome: 'to reset all doppler markers' }
Expand Down Expand Up @@ -395,14 +404,7 @@ export class DopplerMode extends BaseMode {
* Reset doppler-specific state
*/
resetState() {
const doppler = this.instance.state.doppler
doppler.fPlus = null
doppler.fMinus = null
doppler.fZero = null
doppler.speed = null
doppler.color = null
doppler.tempFirst = null
doppler.previewEnd = null
this.restoreCurve(null)
this.dragHandler.reset()
// Deleting the curve is an annotation mutation: masked today by the
// signature's doppler identity fields, but the mark is the contract (BH-24).
Expand Down Expand Up @@ -710,11 +712,10 @@ export class DopplerMode extends BaseMode {
* Half of the `PersistentFeatureProvider` capability. Lived on
* `FeatureRenderer` as `hasDopplerFeatures()` until spec 167 moved it onto
* the mode that owns the state it reads.
* @returns {boolean} True if any doppler marker has been placed
* @returns {boolean} True if a complete doppler curve has been placed
*/
hasPersistentFeatures() {
const doppler = this.instance.state.doppler
return !!(doppler && (doppler.fPlus || doppler.fMinus || doppler.fZero))
return isCompleteCurve(this.instance.state.doppler)
}

/**
Expand Down
49 changes: 49 additions & 0 deletions src/utils/doppler.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,52 @@ export function calculateDopplerSpeed(fPlus, fMinus, fZero = null, speedOfSound
return Math.abs(speed) // Return absolute value for speed
}

/**
* Whether all three markers are placed: the one rule for "there is a curve",
* shared by the renderer, the mode's capability predicate and storage. Anything
* less draws nothing, so it must count as nothing too. A lone f+ used to count
* as an annotation and block every later placement.
* @param {DopplerState|null|undefined} doppler - Doppler state
* @returns {boolean} True when f+, f- and f₀ are all set
*/
export function isCompleteCurve(doppler) {
return !!(doppler && doppler.fPlus && doppler.fMinus && doppler.fZero)
}

/**
* A complete curve, as it stood when a placement began to replace it.
* @typedef {Object} DopplerCurveSnapshot
* @property {DataCoordinates} fPlus - The f+ marker
* @property {DataCoordinates} fMinus - The f- marker
* @property {DataCoordinates} fZero - The f₀ marker
* @property {number|null} speed - The speed it gave, in m/s
* @property {string|null} color - The colour it was drawn in
*/

/**
* The curve a placement is about to replace, or null when there is not a
* complete one to come back to.
* @param {DopplerState} doppler - Doppler state
* @returns {DopplerCurveSnapshot|null} A copy of the curve, or null
*/
export function snapshotCurve(doppler) {
if (!isCompleteCurve(doppler)) {
return null
}
return {
fPlus: { .../** @type {DataCoordinates} */ (doppler.fPlus) },
fMinus: { .../** @type {DataCoordinates} */ (doppler.fMinus) },
fZero: { .../** @type {DataCoordinates} */ (doppler.fZero) },
speed: doppler.speed,
color: doppler.color
}
}

/**
* The snapshot a `place` target carries, if any.
* @param {DragTarget} target - Drag target from the engine
* @returns {DopplerCurveSnapshot|null} The replaced curve, or null
*/
export function replacedCurveOf(target) {
return (target.data && target.data.replaced) || null
}
Loading
Loading