Last updated: 2026-03-26
GramFrame is a JavaScript component for interactive spectrogram analysis. It transforms HTML configuration tables into interactive SVG-based overlays for sonar training materials.
┌──────────────────────────────────────────────┐
│ HTML Page │
│ ┌──────────────────────────────────┐ │
│ │ <table class="gram-config"> │ │
│ │ <img src="spectrogram.png"> │ │
│ │ time-start, time-end, ... │ │
│ └──────────────┬───────────────────┘ │
└─────────────────┼───────────────────────────┘
│ DOMContentLoaded
▼
┌──────────────────────────────────────────────┐
│ GramFrameAPI (src/api/) │
│ init() → detectAndReplaceConfigTables() │
│ addStateListener() / removeStateListener() │
└──────────────────────┬───────────────────────┘
│ creates
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ GramFrame Instance (src/main.js) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ State │ │ Events │ │ Rendering │ │ Configuration │ │
│ │ (core/ │ │ (core/ │ │ (rendering/ │ │ (core/ │ │
│ │ state.js) │ │ events.js) │ │ cursors.js) │ │ configuration │ │
│ │ │ │ │ │ │ │ .js) │ │
│ └──────┬──────┘ └──────┬───────┘ └──────┬───────┘ └───────┬────────┘ │
│ │ │ │ │ │
│ │ ┌─────┴──────┐ ┌─────┴──────┐ │ │
│ │ │ Coordinate │ │ Feature │ │ │
│ │ │ Transforms │ │ Renderer │ │ │
│ │ │ (utils/ │ │ (core/ │ │ │
│ │ │ coordinates│ │ Feature │ │ │
│ │ │ .js) │ │ Renderer │ │ │
│ │ └────────────┘ │ .js) │ │ │
│ │ └─────┬──────┘ │ │
│ │ │ │ │
│ ┌──────┴──────────────────────────────────┴───────────────────┘──────┐ │
│ │ Mode System (src/modes/) │ │
│ │ ┌───────────────────────────────────────────────────────────┐ │ │
│ │ │ BaseMode (BaseMode.js) │ │ │
│ │ │ activate() | deactivate() | handleMouseMove/Down/Up() │ │ │
│ │ │ renderPersistentFeatures() | renderCursor() | updateLEDs()│ │ │
│ │ └───────────┬───────────┬──────────────┬──────────┬──────────┘ │ │
│ │ │ │ │ │ │ │
│ │ ┌─────────┴┐ ┌──────┴───┐ ┌───────┴──┐ ┌───┴──────┐ │ │
│ │ │ Analysis │ │Harmonics │ │ Doppler │ │ Pan │ │ │
│ │ │ Mode │ │ Mode │ │ Mode │ │ Mode │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ │ │
│ │ ModeFactory.createMode(name, instance) → BaseMode subclass │ │
│ └───────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
GramFrame uses a modular mode architecture where each interaction mode extends a common BaseMode class. See ADR-008 for the rationale.
Every mode must extend BaseMode and may override these lifecycle methods:
| Method | Purpose | Called When |
|---|---|---|
activate() |
Mode-specific initialization | Switching to this mode |
deactivate() |
Mode-specific teardown | Switching away from this mode |
cleanup() |
Clear transient state (drag flags, temp data) | Before deactivate() |
handleMouseMove(event, dataCoords) |
Process mouse movement | Mouse moves over SVG |
handleMouseDown(event, dataCoords) |
Process click/drag start | Mouse button pressed |
handleMouseUp(event, dataCoords) |
Process click/drag end | Mouse button released |
handleMouseLeave() |
Process cursor exit | Mouse leaves SVG area |
renderPersistentFeatures() |
Draw saved features (markers, curves) | Every render cycle |
renderCursor() |
Draw live cursor indicators | Every render cycle |
updateLEDs(coords) |
Update LED readout values | Cursor position changes |
getGuidanceText() |
Return help text for this mode | Mode activated |
getCommandButtons() |
Return mode-specific buttons | Mode UI setup |
isEnabled() |
Check if mode can be activated | Mode button rendering |
resetState() |
Clear mode-specific state | User reset action |
createUI(readoutPanel) |
Build mode-specific UI | Mode activated |
destroyUI() |
Remove mode-specific UI | Mode deactivated |
static getInitialState() |
Return initial state fields | State initialization |
ModeFactory.createMode(modeName, instance) instantiates modes by name. Valid modes: analysis, harmonics, doppler, pan.
In production, an invalid mode name falls back to BaseMode to prevent crashes. In development (localhost), it throws to fail fast.
Each mode lives in its own directory under src/modes/:
src/modes/
├── BaseMode.js
├── ModeFactory.js
├── analysis/
│ └── AnalysisMode.js # Persistent draggable markers
├── harmonics/
│ └── HarmonicsMode.js # Real-time harmonic calculation
├── doppler/
│ └── DopplerMode.js # Doppler speed calculation
└── pan/
└── PanMode.js # Zoom panning
The FeatureRenderer coordinates cross-mode feature visibility. See ADR-011.
When rendering is triggered (mouse move, mode switch, zoom), FeatureRenderer.renderAllPersistentFeatures():
- Clears the SVG cursor group (
cursorGroup.innerHTML = '') - Checks each mode for existing features (markers, harmonic sets, Doppler curves)
- Delegates to each mode's
renderPersistentFeatures()method
This ensures analysis markers remain visible while in harmonics mode, and vice versa.
GramFrame uses SVG for all interactive overlays. See ADR-001.
The component creates an SVG element overlaying the spectrogram image. Key SVG groups:
spectrogramImage—<image>element displaying the spectrogram PNGaxesGroup— Axis tick marks and labels (time on Y-axis, frequency on X-axis)cursorGroup— All interactive features: markers, harmonic lines, Doppler curves, cursor indicators
Axes are rendered by renderAxes(instance) in src/components/table.js:
- Time axis (vertical, left side): 5 evenly-spaced ticks with formatted time labels
- Frequency axis (horizontal, bottom): Uses a "nice numbers" algorithm (
calculateAxisTicks) for major/minor tick intervals, applies rate scaling to displayed frequencies
updateSVGLayout(instance) sets the SVG width, height, and viewBox to the image's natural dimensions plus margins, and positions the <image> element at (margins.left, margins.top).
updateCursorIndicators(instance) is the main render entry point:
- Clears
cursorGroup - Calls
featureRenderer.renderAllPersistentFeatures()to redraw all saved features
Modes add SVG elements to cursorGroup using utilities from src/utils/svg.js:
createSVGLine(x1, y1, x2, y2, className)— Creates<line>elementscreateSVGText(x, y, text, className, anchor)— Creates<text>elementscreateSVGCircle(cx, cy, r, className)— Creates<circle>elements
Four coordinate systems are used. See ADR-002.
Screen Coords ──→ SVG Coords ──→ Image Coords ──→ Data Coords
(browser px) (viewBox) (natural px) (time/freq)
Functions (in src/utils/coordinates.js):
| Function | Input | Output | Notes |
|---|---|---|---|
screenToSVGCoordinates(screenX, screenY, svg, imageDetails) |
Browser-relative px | SVG viewBox coordinates | Uses viewBox scale factors |
imageToDataCoordinates(imageX, imageY, config, imageDetails, rate) |
Image-relative px | {freq, time} |
Rate acts as frequency divider |
The intermediate screen-to-image conversion happens in events.js → screenToDataWithZoom(), which accounts for zoom level and margins when converting SVG coordinates to image-relative coordinates.
Coordinate axes:
- X-axis = Frequency (horizontal, left to right)
- Y-axis = Time (vertical, Y=0 at top, time increases upward in data space)
See ADR-004.
All runtime data lives in a single state object on each GramFrame instance. Key fields:
{
version: '0.0.1',
instanceId: '',
mode: 'analysis', // Current active mode
previousMode: null, // For mode switching history
rate: 1, // Frequency divider
selectedColor: '#ff6b6b', // Active color for new features
cursorPosition: null, // Current cursor {x, y, svgX, svgY, freq, time}
imageDetails: { url, naturalWidth, naturalHeight },
config: { timeMin, timeMax, freqMin, freqMax },
displayDimensions: { width, height },
margins: { left: 60, bottom: 50, right: 15, top: 15 },
zoom: { level: 1.0, centerX: 0.5, centerY: 0.5 },
selection: { selectedType, selectedId, selectedIndex },
// ...plus mode-specific fields from each mode's getInitialState()
}State changes are broadcast to registered listeners via notifyStateListeners(state, listeners):
- State is deep-copied via
JSON.parse(JSON.stringify(state))before passing to listeners — this prevents external code from mutating internal state - Each listener is called inside a try/catch to isolate failures
- Listeners are registered per-instance and globally (via
addGlobalStateListener)
createInitialState() returns a fresh deep copy of the template state (including mode-specific fields from each mode's static getInitialState()). Each GramFrame instance gets its own independent state copy.
setupEventListeners(instance) binds these handlers to the SVG element:
| Event | Handler | Purpose |
|---|---|---|
mousemove |
handleMouseMove |
Update cursor position, delegate to mode, update LEDs |
mousedown |
handleMouseDown |
Set focus, delegate to mode for click/drag |
mouseup |
handleMouseUp |
Delegate to mode for drag end |
mouseleave |
handleMouseLeave |
Clear cursor, notify listeners |
contextmenu |
handleContextMenu |
Right-click delegation to mode |
All mouse handlers convert screen coordinates to data coordinates via screenToDataWithZoom(), then delegate to the current mode's handler. This is the mode-specific event delegation pattern — the event system doesn't know mode details, it just passes data coordinates to currentMode.handleMouseMove(event, dataCoords).
setupResizeObserver(instance) uses ResizeObserver to monitor the container element. On resize, it triggers instance._handleResize() which recalculates SVG viewBox, axis positions, and feature positions. See ADR-003.
A window.resize listener provides fallback coverage.
See ADR-005.
extractConfigData(instance) parses the HTML config table:
- Finds the
<img>element in the first row → storesimageDetails.url - Iterates remaining rows, looking for 2-cell rows:
parameter | value - Parses numeric values for:
time-start,time-end,freq-start,freq-end - Validates: both time and frequency ranges must be present and valid (start < end)
- Throws on missing/invalid config, which the API catches and displays as an error indicator
On DOMContentLoaded, GramFrameAPI.init() calls detectAndReplaceConfigTables(document) which finds all <table class="gram-config"> elements and replaces each with a GramFrame instance.
The public API is exposed via window.GramFrame (see src/api/GramFrameAPI.js):
| Method | Purpose |
|---|---|
init() |
Initialize all config tables on the page |
detectAndReplaceConfigTables(container) |
Scan a container for config tables |
addStateListener(callback) |
Register for state change notifications |
removeStateListener(callback) |
Unregister a state listener |
Vite Hot Module Replacement preserves state listeners across code changes during development. See ADR-006. The import.meta.hot.accept() handler in src/main.js:
- Saves existing global state listeners
- Clears the listener registry
- Re-initializes GramFrame instances
- Restores saved listeners
- ADR-001: SVG-Based Rendering
- ADR-002: Multiple Coordinate Systems
- ADR-003: Responsive Design with ResizeObserver
- ADR-004: Centralized State Management
- ADR-005: HTML Table Configuration
- ADR-006: Hot Module Reload Support
- ADR-007: JSDoc TypeScript Integration
- ADR-008: Modular Mode System
- ADR-010: Unminified Production Build
- ADR-011: Feature Renderer Cross-Mode Coordination