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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ Every path below exists; keep this list in step with `src/` when adding modules.
- `cursors.js` - The pointer's cursors. Feature drags use hollow corner
brackets, never the opaque `grab`/`grabbing` hands, so the marker and the
gram under the hotspot stay visible; panning keeps the hand
- `svg.js` - SVG text halo styling
- `labelPlate.js` - The white rounded plate every in-gram text label is drawn
on, and the geometry the placement rules leave room for it with (issue #243)
- `secureHTML.js` - Guidance-panel rendering without innerHTML
- `timeFormatter.js` - Time formatting utilities
- `wheelGuidance.js` - Wheel navigation guidance text
Expand Down Expand Up @@ -263,7 +264,7 @@ There is no visual/screenshot regression testing — see
### Mode-Specific Features
- **Pan Mode**: The default mode; drag to pan when zoomed in, so a first click never places anything
- **Analysis Mode**: Persistent draggable markers with cross-mode visibility and optional
haloed text labels (upper-right of a crosshair, centred above a shaped symbol —
plated text labels (upper-right of a crosshair, centred above a shaped symbol —
below an upward-pointing triangle, whose apex points at the data above it)
- **Harmonics Mode**: Real-time harmonic calculation and display
- **Sidebands Mode**: A pin set with a user-placed origin — the fundamental —
Expand Down
5 changes: 3 additions & 2 deletions docs/Adding-Graphical-Features.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ When adding a new graphical feature, you will likely need to work with these fil
| `src/core/FeatureRenderer.js` | Main render entry point; clears and redraws all features | Adding a new visual element type |
| `src/components/table.js` | SVG layout, axis rendering, zoom transforms | Changing axes or SVG structure |
| `src/utils/coordinateTransformations.js` | Zoom-aware transforms, `dataToSVG()` | Positioning elements when zoomed |
| `src/utils/svg.js` | SVG element creation helpers (`createSVGLine`, `createSVGText`, `createSVGCircle`) | Creating new SVG shapes |
| `src/rendering/symbols.js` | Marker and pin symbol shapes (`createSymbolMark`) | Drawing a shaped mark |
| `src/utils/labelPlate.js` | The white rounded plate behind on-gram text (`plateLabel`) | Drawing a text label over the gram |
| `src/utils/coordinates.js` | Coordinate transforms (screen → SVG → image → data) | Positioning elements on the spectrogram |
| `src/core/FeatureRenderer.js` | Cross-mode feature visibility coordinator | Feature needs to persist across mode switches |
| `src/core/events.js` | Mouse event handling and coordinate conversion | Feature responds to mouse interactions |
Expand Down Expand Up @@ -72,7 +73,7 @@ If your feature belongs to an existing mode (e.g., a new annotation type in Anal
1. **Store state** — Add fields to the mode's `static getInitialState()` method
2. **Handle interaction** — Override `handleMouseDown`/`handleMouseMove`/`handleMouseUp` to capture user input
3. **Render** — Add drawing logic to `renderPersistentFeatures()` (for saved features) or `renderCursor()` (for live indicators)
4. **Create SVG elements** using utilities from `src/utils/svg.js`, and append them to `instance.cursorGroup`
4. **Create SVG elements** — `createSymbolMark()` from `src/rendering/symbols.js` for shapes, `plateLabel()` from `src/utils/labelPlate.js` for text — and append them to `instance.cursorGroup`

### Example Pattern (from Doppler mode)

Expand Down
4 changes: 2 additions & 2 deletions docs/Gram-Modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ events, broadband pulses or ambient shifts in sonar data.
- Each row's **label button** (the tag icon, above the delete ×) opens a dialog
for the marker's label. Labels are optional — a marker has none until one is
entered — and clearing the field removes the label again.
- A label is drawn on the gram in black inside a white halo, so it reads over
both dark and light pixels: in the upper-right quadrant of a crosshair marker,
- A label is drawn on the gram in black on a white rounded plate, so it reads
over both dark and light pixels: in the upper-right quadrant of a crosshair marker,
or centred above a marker that carries a shaped symbol. The one exception is
the upward-pointing triangle, which is aimed at the gram above it — its label
is centred *below* the symbol so the data being marked stays visible.
Expand Down
3 changes: 2 additions & 1 deletion docs/Rendering-Troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ Understanding which files handle what is the first step in narrowing down a rend
|-----------|------|---------|
| Render entry point | `src/core/FeatureRenderer.js` | `renderAllPersistentFeatures()` — clears and redraws all features |
| Feature coordination | `src/core/FeatureRenderer.js` | Cross-mode visibility; delegates to each mode's renderer |
| SVG element creation | `src/utils/svg.js` | `createSVGLine`, `createSVGText`, `createSVGCircle` |
| Symbols and labels | `src/rendering/symbols.js`, `src/rendering/labels.js` | `createSymbolMark`, `createMarkerLabel` |
| On-gram text legibility | `src/utils/labelPlate.js` | `plateLabel` — the white rounded plate behind every label |
| Coordinate transforms | `src/utils/coordinates.js` | `screenToSVGCoordinates`, `imageToDataCoordinates` |
| Zoom-aware conversion | `src/core/events.js` | `screenToDataWithZoom()` — full pipeline with zoom |
| SVG layout and axes | `src/components/table.js` | `updateSVGLayout()`, `renderAxes()`, `applyZoomTransform()` |
Expand Down
10 changes: 6 additions & 4 deletions docs/Tech-Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,13 @@ Axes are rendered by `renderAxes(instance)` in `src/components/table.js`:
1. Clears `cursorGroup`
2. Calls `featureRenderer.renderAllPersistentFeatures()` to redraw all saved features

Modes add SVG elements to `cursorGroup` using utilities from `src/utils/svg.js`:
Modes create their own SVG elements and append them to `cursorGroup`, drawing on
the shared rendering helpers:

- `createSVGLine(x1, y1, x2, y2, className)` — Creates `<line>` elements
- `createSVGText(x, y, text, className, anchor)` — Creates `<text>` elements
- `createSVGCircle(cx, cy, r, className)` — Creates `<circle>` elements
- `src/rendering/symbols.js` — `createSymbolMark()` for a marker or pin's shape
- `src/rendering/labels.js` — `createMarkerLabel()` for a marker's on-gram label
- `src/utils/labelPlate.js` — `plateLabel()`, which puts any on-gram text on the
white rounded plate that keeps it legible over the gram

### Coordinate Transform Chain

Expand Down
23 changes: 17 additions & 6 deletions src/gramframe.css
Original file line number Diff line number Diff line change
Expand Up @@ -908,9 +908,10 @@ table.gram-frame-table {
}

/*
* A marker's on-gram label. Legibility comes from the halo (black glyphs in a
* white outline) set as presentation attributes by applyTextHalo() — see
* src/utils/svg.js. Never a click target: the marker underneath is.
* A marker's on-gram label. Legibility comes from the white rounded plate drawn
* behind it (issue #243) — the geometry and colours are presentation attributes
* set by plateLabel(), see src/utils/labelPlate.js. Never a click target: the
* marker underneath is.
*/
.gram-frame-marker-label {
font-family: Arial, sans-serif;
Expand All @@ -920,6 +921,16 @@ table.gram-frame-table {
user-select: none;
}

/*
* The white plate behind any on-gram label, and the group holding the two. Both
* are transparent to the pointer so the plate never intercepts a click meant
* for the feature it annotates, or for the gram beneath it.
*/
.gram-frame-label-plate,
.gram-frame-label-plated {
pointer-events: none;
}

/* Military-style mode selection header */
.gram-frame-mode-header {
background: linear-gradient(180deg, #444 0%, #2a2a2a 50%, #1a1a1a 100%);
Expand Down Expand Up @@ -1276,9 +1287,9 @@ table.gram-frame-table {
font-weight: bold;
pointer-events: none;
/*
* Legibility comes from the halo (black glyphs inside a white outline) set as
* presentation attributes by applyTextHalo() in src/utils/svg.js — see the
* fill/stroke/paint-order there. No drop-shadow: it only blurred the outline.
* Legibility comes from the white rounded plate drawn behind the digits
* (issue #243), set as presentation attributes by plateLabel() in
* src/utils/labelPlate.js. No drop-shadow: it only blurs the plate's edge.
*/
}

Expand Down
82 changes: 43 additions & 39 deletions src/modes/shared/PinSetMode.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { BaseDragHandler } from './BaseDragHandler.js'
import { getUniformTolerance } from '../../utils/tolerance.js'
import { sampledHarmonics } from '../../utils/harmonicSampling.js'
import { createSymbolMark, labelSitsBelowSymbol, resolveSymbolScale } from '../../rendering/symbols.js'
import { applyTextHalo } from '../../utils/svg.js'
import { labelPlateExtents, labelPlateRect, measureLabelWidth, plateLabel } from '../../utils/labelPlate.js'

/**
* Minimum spacing (Hz) any pin set may be dragged or nudged to.
Expand Down Expand Up @@ -92,22 +92,15 @@ export class PinSetMode extends BaseMode {
static MAX_PIN_LINES = 1000

/**
* Font size (px) of a pin's number label; also used as its approximate ascent
* when clamping the label/symbol stack to the image's top edge.
* Font size (px) of a pin's number label. The plate the label sits on is
* sized from it too, so it also fixes how much room the stack leaves above
* and below the text (see `utils/labelPlate.js`).
* @type {number}
*/
static LABEL_FONT_SIZE = 12

/**
* Approximate width of one label character as a fraction of the label font
* size, used to size the label's grab region (bold Arial digits are ~0.6 em
* wide).
* @type {number}
*/
static LABEL_CHAR_WIDTH_RATIO = 0.6

/**
* Vertical gap (px) between the pin's number label and its symbol.
* Vertical gap (px) between the edge of the pin label's plate and its symbol.
* @type {number}
*/
static LABEL_GAP = 3
Expand Down Expand Up @@ -735,18 +728,19 @@ export class PinSetMode extends BaseMode {
labelStackPositions(lineTop, imageTop, set) {
const r = this.symbolSize(set) / 2
const gap = PinSetMode.LABEL_GAP
const fontSize = PinSetMode.LABEL_FONT_SIZE
const plate = labelPlateExtents(PinSetMode.LABEL_FONT_SIZE)
const below = labelSitsBelowSymbol(set.symbol)

// Symbol caps the line; the label baseline sits just above the symbol, or —
// for an up-pointing triangle — a whole line of text below it, so the
// glyphs (which hang above their baseline) clear the mark.
// Symbol caps the line; the label sits just above the symbol, or — for an
// up-pointing triangle — just below it. The gap is measured from the edge
// of the label's plate rather than from its baseline (issue #243), so the
// white rectangle clears the mark by as much as the bare glyphs used to.
let symbolCy = lineTop - r
let labelY = below ? symbolCy + r + gap + fontSize : symbolCy - r - gap
let labelY = below ? symbolCy + r + gap + plate.above : symbolCy - r - gap - plate.below

// Keep the top of the stack on-screen: the label's approximate ascent when
// it leads the stack, the symbol's top edge when the label hangs below.
const stackTop = below ? symbolCy - r : labelY - fontSize
// Keep the top of the stack on-screen: the top of the label's plate when it
// leads the stack, the symbol's top edge when the label hangs below.
const stackTop = below ? symbolCy - r : labelY - plate.above
const minTop = imageTop + PinSetMode.STACK_TOP_PAD
if (stackTop < minTop) {
const shift = minTop - stackTop
Expand Down Expand Up @@ -782,13 +776,14 @@ export class PinSetMode extends BaseMode {
const r = this.symbolSize(set) / 2
const below = labelSitsBelowSymbol(set.symbol)
const symbolBottom = symbolCy + r
const plate = labelPlateExtents(PinSetMode.LABEL_FONT_SIZE)

return {
// One ascent above the label's baseline is the top of the characters —
// unless the label hangs below, in which case the symbol leads the stack.
top: below ? symbolCy - r : labelY - PinSetMode.LABEL_FONT_SIZE,
// The baseline is the underside of the characters when they trail.
bottom: Math.max(lineTop, below ? labelY : symbolBottom),
// The top of the label's plate — unless the label hangs below, in which
// case the symbol leads the stack.
top: below ? symbolCy - r : labelY - plate.above,
// The plate's underside is the bottom of the stack when the label trails.
bottom: Math.max(lineTop, below ? labelY + plate.below : symbolBottom),
symbolBottom
}
}
Expand All @@ -798,18 +793,25 @@ export class PinSetMode extends BaseMode {
*
* The wider of the symbol mark and the number label, so both are grabbable:
* a `cross` set has no symbol but still shows its label, and a "Large
* symbols" set's mark is wider than its text. Label width is estimated from
* the character count rather than measured, which is ample for a grab region.
* symbols" set's mark is wider than its text. The label's half-width is the
* plate's, measured the same way the renderer sizes it, so the grab region
* covers exactly the white rectangle the analyst is aiming at.
*
* @param {PinSet} set - Set being hit-tested
* @param {number} index - Member index whose label is drawn
* @returns {number} Half-width in SVG pixels
*/
labelStackHalfWidth(set, index) {
const characters = this.labelTextFor(index).length
const labelHalfWidth = characters * PinSetMode.LABEL_FONT_SIZE * PinSetMode.LABEL_CHAR_WIDTH_RATIO / 2
const fontSize = PinSetMode.LABEL_FONT_SIZE
const plate = labelPlateRect({
x: 0,
y: 0,
textAnchor: 'middle',
width: measureLabelWidth(this.labelTextFor(index), fontSize),
fontSize
})

return Math.max(this.symbolSize(set) / 2, labelHalfWidth)
return Math.max(this.symbolSize(set) / 2, plate.width / 2)
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -864,40 +866,42 @@ export class PinSetMode extends BaseMode {
}

/**
* Create the SVG text label for a member.
* Create the plated text label for a member.
*
* Centred horizontally on the pin's line (`text-anchor: middle` at `lineX`) and
* positioned above the pin's symbol (baseline at `labelY`), so the vertical
* stack over a pin reads label -> symbol -> line (spec 159, FR-009/FR-010).
* {@link PinSetMode#labelStackPositions} owns that baseline, so a set whose
* symbol carries its label underneath needs nothing special here.
*
* The characters are drawn black inside a white halo rather than in the set's
* colour: a single colour is only legible over part of a gram, whereas the
* halo reads over both dark and light backgrounds. Set identity is still
* carried by the pin's line and symbol colour.
* The characters are drawn black on a white rounded plate rather than in the
* set's colour: a single colour is only legible over part of a gram, whereas
* the plate reads over both dark and light backgrounds (issue #243). Set
* identity is still carried by the pin's line and symbol colour.
*
* @param {number} index - Member index
* @param {PinSet} set - The set
* @param {number} lineX - X position of the pin line (label is centred on it)
* @param {number} labelY - Baseline Y position for the label text
* @returns {SVGTextElement} SVG text element
* @returns {SVGGElement} Group holding the plate and its text
*/
createPinLabel(index, set, lineX, labelY) {
const names = this.pinNames
const label = document.createElementNS('http://www.w3.org/2000/svg', 'text')
const label = /** @type {SVGTextElement} */ (
document.createElementNS('http://www.w3.org/2000/svg', 'text')
)
label.setAttribute('class', names.labelClass)
label.setAttribute(names.setIdAttribute, set.id)
label.setAttribute(names.indexAttribute, String(index))
label.setAttribute('x', String(lineX)) // centred on the pin line
label.setAttribute('y', String(labelY)) // above the symbol
label.setAttribute('text-anchor', 'middle')
applyTextHalo(/** @type {SVGTextElement} */ (label))
label.setAttribute('font-size', String(PinSetMode.LABEL_FONT_SIZE))
label.setAttribute('font-weight', 'bold')
label.setAttribute('font-family', 'Arial, sans-serif')
label.textContent = this.labelTextFor(index)
return label
// Plated last, once the text carries everything the plate is sized from.
return plateLabel(label)
}

/**
Expand Down
19 changes: 10 additions & 9 deletions src/rendering/labels.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* In-gram text labels for analysis markers (feature 231).
*
* A marker's label is drawn as black glyphs inside a white halo — the same
* treatment the harmonic numbers use — so it reads over both dark and light
* spectrogram pixels. Marker identity is still carried by the crosshair or
* symbol colour; the label text deliberately is not colour-coded.
* A marker's label is drawn as black glyphs on a white rounded plate — the same
* treatment the harmonic numbers use (issue #243) — so it reads over both dark
* and light spectrogram pixels. Marker identity is still carried by the
* crosshair or symbol colour; the label text deliberately is not colour-coded.
*
* Where the label goes is `markerLabelPlacement`'s decision (see
* `utils/markerLabel.js`, where it stays pure and unit-testable) — including
Expand All @@ -15,14 +15,15 @@

/// <reference path="../types.js" />

import { applyTextHalo } from '../utils/svg.js'
import { plateLabel } from '../utils/labelPlate.js'
import { MARKER_LABEL_FONT_SIZE, markerLabelPlacement } from '../utils/markerLabel.js'

/** SVG namespace for element creation */
const SVG_NS = 'http://www.w3.org/2000/svg'

/**
* Build a marker's label as a detached SVG text element.
* Build a marker's label as a detached SVG group: the white plate and the
* text drawn on it.
*
* Returns `null` when the marker carries no label, so the caller draws nothing
* — labels are absent by default. Callers MUST handle a `null` return.
Expand All @@ -31,7 +32,7 @@ const SVG_NS = 'http://www.w3.org/2000/svg'
* @param {number} cx - Marker centre X in SVG overlay space
* @param {number} cy - Marker centre Y in SVG overlay space
* @param {number} symbolSize - Drawn diameter of the marker's symbol in px
* @returns {SVGTextElement|null} Detached label element, or `null` when unlabelled
* @returns {SVGGElement|null} Detached plate-and-text group, or `null` when unlabelled
*/
export function createMarkerLabel(marker, cx, cy, symbolSize) {
if (!marker.label) {
Expand All @@ -49,7 +50,7 @@ export function createMarkerLabel(marker, cx, cy, symbolSize) {
text.setAttribute('font-size', String(MARKER_LABEL_FONT_SIZE))
text.setAttribute('font-weight', 'bold')
text.setAttribute('font-family', 'Arial, sans-serif')
applyTextHalo(text)
text.textContent = marker.label
return text
// Plated last, once the text carries everything the plate is sized from.
return plateLabel(text)
}
9 changes: 5 additions & 4 deletions src/utils/cursors.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,11 @@ const DRAG_BRACKETS = [
/**
* Build the cursor artwork.
*
* Each shape is stroked twice — black underneath, white on top — the halo idiom
* already used for marker text (`applyTextHalo` in `svg.js`). It is what keeps
* the cursor legible over the blue field and over a saturated yellow tonal
* alike, neither of which a single-colour cursor survives.
* Each shape is stroked twice — black underneath, white on top — the halo idiom.
* A cursor cannot carry the plate on-gram text labels use (`labelPlate.js`), so
* it keeps the two-tone outline instead; it is what keeps the cursor legible
* over the blue field and over a saturated yellow tonal alike, neither of which
* a single-colour cursor survives.
* @param {string[]} shapes - Path data for the brackets
* @param {number} coreWidth - Stroke width of the white core
* @param {number} haloWidth - Stroke width of the black halo beneath it
Expand Down
Loading
Loading