From 88958bad128d05d81f746bd44440ee94a51aa005 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 13:12:30 +0000 Subject: [PATCH] Drop the label below an upward-pointing triangle (#242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An up-pointing triangle is drawn to point AT the gram above it, so the label stacked over its apex covered exactly the data the analyst had aimed it at. For that one symbol the label now hangs underneath instead — over ink the feature already spends — and no toggle or extra click is needed to read what is beneath it. One shared predicate, `labelSitsBelowSymbol()` in rendering/symbols.js, decides this for every labelled feature: - analysis markers, via `markerLabelPlacement()`: the baseline drops a whole line of text past the symbol's bottom edge, so the glyphs clear the mark; - harmonic and sideband pins, via `PinSetMode.labelStackPositions()`: the symbol still caps the line, only the number changes sides, and the top-edge clamp now measures from whichever part leads the stack. The grab region follows the digits rather than staying where they used to be: `labelStackBounds()` inverts with the layout, and reports the symbol's underside separately so a hidden-pin set's mini-pin stub keeps hanging from the symbol regardless of where the label went. The marker label's font size moves into utils/markerLabel.js, which needs it to size the drop, and rendering/labels.js imports it back. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SpLe4XCjhvgj7fEaYhghxZ --- CLAUDE.md | 3 +- docs/Gram-Modes.md | 8 ++++- src/modes/shared/PinSetMode.js | 56 +++++++++++++++++++++++---------- src/rendering/labels.js | 15 +++------ src/rendering/symbols.js | 24 ++++++++++++++ src/utils/markerLabel.js | 26 +++++++++++++-- tests/harmonic-hotspot.spec.js | 41 ++++++++++++++++++++++++ tests/harmonic-labels.spec.js | 49 +++++++++++++++++++++++++++++ tests/marker-labels.spec.js | 22 +++++++++++++ tests/unit/marker-label.test.js | 48 ++++++++++++++++++++++++++-- 10 files changed, 260 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 448ccf6..8409624 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -263,7 +263,8 @@ 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) + haloed 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 — with members spread each side of it and labelled by signed offset diff --git a/docs/Gram-Modes.md b/docs/Gram-Modes.md index 99883e3..fedc7c1 100644 --- a/docs/Gram-Modes.md +++ b/docs/Gram-Modes.md @@ -58,7 +58,9 @@ events, broadband pulses or ambient shifts in sonar data. 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, - or centred above a marker that carries a shaped symbol. + 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. - The table's **Label** column shows labels of five characters or fewer in full, and abbreviates anything longer to its first three characters plus `..`. The full text stays on the gram and in the dialog. @@ -85,6 +87,10 @@ multiples of a spacing. are drawn within the visible span, at a regular "nice" interval, so a small spacing over a wide span stays legible. The pins themselves are not thinned, so the unlabelled harmonics still show against the data. +- Each harmonic number sits above its symbol — except for a set drawn with the + upward-pointing triangle, whose apex points at the gram above it. There the + number hangs below the symbol instead, leaving that data clear; the grab region + follows the digits, so the set is still grabbable wherever it is drawn. - Sets are listed in the harmonics panel; selecting a row enables arrow-key adjustment and in-place restyling, as for markers. diff --git a/src/modes/shared/PinSetMode.js b/src/modes/shared/PinSetMode.js index 0db73cf..448f908 100644 --- a/src/modes/shared/PinSetMode.js +++ b/src/modes/shared/PinSetMode.js @@ -32,7 +32,7 @@ import { import { BaseDragHandler } from './BaseDragHandler.js' import { getUniformTolerance } from '../../utils/tolerance.js' import { sampledHarmonics } from '../../utils/harmonicSampling.js' -import { createSymbolMark, resolveSymbolScale } from '../../rendering/symbols.js' +import { createSymbolMark, labelSitsBelowSymbol, resolveSymbolScale } from '../../rendering/symbols.js' import { applyTextHalo } from '../../utils/svg.js' /** @@ -592,9 +592,10 @@ export class PinSetMode extends BaseMode { // Only the thinned subset is labelled, so only those pins carry a stack. const labelled = this.labelledIndices(minIndex, maxIndex) // A hidden pin draws mini-pins instead of full lines, so its line grab - // region is the mini-pin stub hanging from the stack's underside. + // region is the mini-pin stub hanging from the symbol's underside — which + // is where the renderer hangs it, label placement notwithstanding. const pinDrawn = set.showPin !== false - const lineFrom = pinDrawn ? lineTop : stack.bottom + const lineFrom = pinDrawn ? lineTop : stack.symbolBottom const lineTo = lineFrom + (pinDrawn ? lineHeight : PinSetMode.MINI_PIN_HEIGHT) const tolerance = getUniformTolerance(this.getViewport(), this.instance.ui.spectrogramImage) @@ -720,6 +721,12 @@ export class PinSetMode extends BaseMode { * (label + symbol) is nudged down by the overflow so it stays legible * (spec 159, FR-011). * + * An upward-pointing triangle inverts the label (issue #242): its apex points + * at the gram above the pin, so a number stacked over it hides exactly the + * data the set was placed against. That label drops to the symbol's underside + * instead, over the pin line's top — ink the set already spends there. The + * symbol keeps capping the line either way, so the pin's anchor never moves. + * * @param {number} lineTop - Top Y position of the pin lines (SVG coords) * @param {number} imageTop - Top edge of the spectrogram image in SVG coords * @param {PinSet} set - Set being laid out (its symbol size drives the stack) @@ -729,16 +736,20 @@ export class PinSetMode extends BaseMode { const r = this.symbolSize(set) / 2 const gap = PinSetMode.LABEL_GAP const fontSize = PinSetMode.LABEL_FONT_SIZE + const below = labelSitsBelowSymbol(set.symbol) - // Symbol caps the line; label baseline sits just above the 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. let symbolCy = lineTop - r - let labelY = symbolCy - r - gap + let labelY = below ? symbolCy + r + gap + fontSize : symbolCy - r - gap - // Keep the top of the label (approx one ascent above its baseline) on-screen. - const labelTop = labelY - fontSize + // 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 const minTop = imageTop + PinSetMode.STACK_TOP_PAD - if (labelTop < minTop) { - const shift = minTop - labelTop + if (stackTop < minTop) { + const shift = minTop - stackTop symbolCy += shift labelY += shift } @@ -751,23 +762,34 @@ export class PinSetMode extends BaseMode { * * Derived from the same {@link PinSetMode#labelStackPositions} layout the * renderer uses, so the grab region tracks the drawn stack — including the - * downward nudge applied near the image's top edge. The bottom is clamped to - * the pin line's top so the stack region and the line region always meet with - * no dead gap between them. + * downward nudge applied near the image's top edge, and the label's drop to + * the underside of an up-pointing triangle (issue #242): move the text and + * the hotspot moves with it. The bottom is clamped to the pin line's top so + * the stack region and the line region always meet with no dead gap between + * them. + * + * `symbolBottom` is reported separately because it, not the region's bottom, + * is where a mini-pin hangs from — a label drawn below the symbol pushes the + * region past the stub it would otherwise anchor. * * @param {number} lineTop - Top Y position of the pin lines (SVG coords) * @param {PinSet} set - Set being hit-tested - * @returns {{top: number, bottom: number}} Top and bottom Y of the stack region + * @returns {{top: number, bottom: number, symbolBottom: number}} Stack region and the symbol's underside */ labelStackBounds(lineTop, set) { const imageTop = getImageBounds(this.getViewport(), this.instance.ui.spectrogramImage).top const { symbolCy, labelY } = this.labelStackPositions(lineTop, imageTop, set) const r = this.symbolSize(set) / 2 + const below = labelSitsBelowSymbol(set.symbol) + const symbolBottom = symbolCy + r return { - // One ascent above the label's baseline is the top of the characters. - top: labelY - PinSetMode.LABEL_FONT_SIZE, - bottom: Math.max(lineTop, symbolCy + r) + // 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), + symbolBottom } } @@ -847,6 +869,8 @@ export class PinSetMode extends BaseMode { * 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 diff --git a/src/rendering/labels.js b/src/rendering/labels.js index a604e6a..0bd12b2 100644 --- a/src/rendering/labels.js +++ b/src/rendering/labels.js @@ -7,25 +7,20 @@ * 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). This module - * only builds the element. + * `utils/markerLabel.js`, where it stays pure and unit-testable) — including + * the drop below an upward-pointing triangle, whose apex points at the data + * above it (issue #242). This module only builds the element, at the font size + * that rule sizes its gaps from. */ /// import { applyTextHalo } from '../utils/svg.js' -import { markerLabelPlacement } from '../utils/markerLabel.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' -/** - * Label font size in px. Matches the harmonic-number labels so all in-gram text - * reads as one family. - * @type {number} - */ -const MARKER_LABEL_FONT_SIZE = 12 - /** * Build a marker's label as a detached SVG text element. * diff --git a/src/rendering/symbols.js b/src/rendering/symbols.js index d3d0534..f12acc0 100644 --- a/src/rendering/symbols.js +++ b/src/rendering/symbols.js @@ -92,6 +92,30 @@ export function resolveSymbolType(symbolType) { : DEFAULT_SYMBOL } +/** + * Whether a symbol's text label belongs BELOW the mark rather than above it. + * + * Every other symbol is symmetric about its centre, or points downwards, so the + * space above it is free and the label goes there. An upward-pointing triangle + * is the exception (issue #242): it is drawn to point AT something above it, so + * a label stacked over its apex sits exactly on the data the analyst placed it + * against. For that one symbol the label drops to the underside, where the + * triangle's own base — and, on a pin, the line hanging from it — already + * covers the gram. + * + * Callers that lay out a label MUST also move the label's grab region to match, + * or the hotspot parts company with the drawn text. + * + * Pure: no DOM, no state. Unknown/absent values resolve through + * {@link resolveSymbolType} first, so they follow the default (`cross`, above). + * + * @param {SymbolType|string|null|undefined} symbolType - Candidate symbol id + * @returns {boolean} True when the label is drawn beneath the symbol + */ +export function labelSitsBelowSymbol(symbolType) { + return resolveSymbolType(symbolType) === 'triangle' +} + /** * Build a `points` attribute string from an array of [x, y] pairs. * @param {Array<[number, number]>} pts - Point pairs diff --git a/src/utils/markerLabel.js b/src/utils/markerLabel.js index f07e729..3a28dc2 100644 --- a/src/utils/markerLabel.js +++ b/src/utils/markerLabel.js @@ -15,7 +15,7 @@ /// -import { resolveSymbolType } from '../rendering/symbols.js' +import { labelSitsBelowSymbol, resolveSymbolType } from '../rendering/symbols.js' /** * Longest label accepted. Long enough for a ship name or a contact @@ -100,6 +100,17 @@ const QUADRANT_GAP = 5 */ const ABOVE_SYMBOL_GAP = 4 +/** + * Label font size in px. Doubles as the approximate ascent when the label hangs + * BELOW a symbol, where the baseline has to clear the symbol by a whole line of + * text rather than sit just above it. + * + * Lives here rather than in `rendering/labels.js` so the placement rule and the + * element that obeys it read the same number; the renderer imports it back. + * @type {number} + */ +export const MARKER_LABEL_FONT_SIZE = 12 + /** * Where a marker's label goes, given what the marker draws. * @@ -108,7 +119,10 @@ const ABOVE_SYMBOL_GAP = 4 * empty quadrants — the label goes in the upper-right one, clear of both * arms; * - a marker with a shaped symbol has no free quadrant, so the label is - * centred above the symbol. + * centred above the symbol; + * - except an upward-pointing triangle, which is drawn to point at whatever + * sits above it (issue #242). A label there covers the very data the + * analyst aimed the apex at, so it is centred BELOW the symbol instead. * * Pure: takes numbers, returns numbers. * @@ -125,6 +139,14 @@ export function markerLabelPlacement(symbol, cx, cy, symbolSize) { return { x: cx + QUADRANT_GAP, y: cy - QUADRANT_GAP, textAnchor: 'start' } } + if (labelSitsBelowSymbol(symbol)) { + // Centred below the symbol. The baseline drops a whole line of text past + // the symbol's bottom edge, so the glyphs — which hang above their baseline + // — start clear of it rather than overlapping the mark. + const y = cy + symbolSize / 2 + ABOVE_SYMBOL_GAP + MARKER_LABEL_FONT_SIZE + return { x: cx, y, textAnchor: 'middle' } + } + // Centred above the symbol, baseline clear of its top edge. return { x: cx, y: cy - symbolSize / 2 - ABOVE_SYMBOL_GAP, textAnchor: 'middle' } } diff --git a/tests/harmonic-hotspot.spec.js b/tests/harmonic-hotspot.spec.js index 7f6fc1a..cdb146d 100644 --- a/tests/harmonic-hotspot.spec.js +++ b/tests/harmonic-hotspot.spec.js @@ -165,4 +165,45 @@ test.describe('Harmonic set hotspot', () => { expect(await probeAt(gramFramePage, labelSelector(setId, CENTRE_HARMONIC), -60)).toBeNull() }) + + // Issue #242: an up-pointing triangle carries its label underneath, so the + // hotspot has to follow the digits rather than stay where they used to be. + test.describe('up-pointing triangle', () => { + const symbolSelector = (setId) => `.gram-frame-harmonic-symbol[data-harmonic-set-id="${setId}"]` + + test('the label under the triangle still grabs its set', async ({ gramFramePage }) => { + const setId = await gramFramePage.addHarmonicSet(ANCHOR_TIME, SPACING) + await updateSet(gramFramePage, setId, { symbol: 'triangle' }) + + expect(await probeAt(gramFramePage, labelSelector(setId, CENTRE_HARMONIC))).toBe(setId) + }) + + test('the triangle itself still grabs its set', async ({ gramFramePage }) => { + const setId = await gramFramePage.addHarmonicSet(ANCHOR_TIME, SPACING) + await updateSet(gramFramePage, setId, { symbol: 'triangle' }) + + expect(await probeAt(gramFramePage, symbolSelector(setId))).toBe(setId) + }) + + test('the space above the apex, which the label vacated, grabs nothing', async ({ gramFramePage }) => { + const setId = await gramFramePage.addHarmonicSet(ANCHOR_TIME, SPACING) + + // Just above the symbol is live while the label sits there... + await updateSet(gramFramePage, setId, { symbol: 'circle' }) + expect(await probeAt(gramFramePage, symbolSelector(setId), -10)).toBe(setId) + + // ...and goes dead once the label drops below an up-pointing triangle, + // because nothing is drawn there any more. + await updateSet(gramFramePage, setId, { symbol: 'triangle' }) + expect(await probeAt(gramFramePage, symbolSelector(setId), -10)).toBeNull() + }) + + test('with the pin hidden, the mini-pin still hangs from the symbol and grabs the set', async ({ gramFramePage }) => { + const setId = await gramFramePage.addHarmonicSet(ANCHOR_TIME, SPACING) + await updateSet(gramFramePage, setId, { symbol: 'triangle', showPin: false }) + + const miniPin = `.gram-frame-harmonic-mini-pin[data-harmonic-set-id="${setId}"][data-harmonic-number="${CENTRE_HARMONIC}"]` + expect(await probeAt(gramFramePage, miniPin)).toBe(setId) + }) + }) }) diff --git a/tests/harmonic-labels.spec.js b/tests/harmonic-labels.spec.js index 6e973dc..ed15d72 100644 --- a/tests/harmonic-labels.spec.js +++ b/tests/harmonic-labels.spec.js @@ -200,6 +200,55 @@ test.describe('Harmonic Pin Labels (feature 159)', () => { } }) + // ──────────────────────────────────────────────────────────── + // Issue #242 — an up-pointing triangle carries its label underneath + // ──────────────────────────────────────────────────────────── + test('an up-triangle set stacks symbol -> label, keeping the gram above it clear', async ({ gramFramePage }) => { + await gramFramePage.selectSymbol('triangle') + const setId = await gramFramePage.addHarmonicSet(30, 20) + + const { byNum } = await readStackGeometry(gramFramePage, setId) + const entries = Object.values(byNum) + expect(entries.length).toBeGreaterThan(0) + for (const g of entries) { + // Still centred on its pin, and the symbol still caps the line — only + // the label has changed sides. + expect(Math.abs(g.labelCx - g.lineCx)).toBeLessThanOrEqual(3) + expect(g.symbolBottom).toBeLessThanOrEqual(g.lineTop + 2) + // The label now hangs under the symbol, over the pin line's own ink, + // leaving the gram above the apex unobscured. + expect(g.labelTop).toBeGreaterThanOrEqual(g.symbolBottom - 2) + } + }) + + test('a down-pointing triangle keeps its label above the symbol', async ({ gramFramePage }) => { + // Only the UP triangle points at the space a label would occupy. + await gramFramePage.selectSymbol('triangle-down') + const setId = await gramFramePage.addHarmonicSet(30, 20) + + const entries = Object.values((await readStackGeometry(gramFramePage, setId)).byNum) + expect(entries.length).toBeGreaterThan(0) + for (const g of entries) { + expect(g.labelBottom).toBeLessThanOrEqual(g.symbolTop + 2) + } + }) + + test('an up-triangle stack near the top edge stays within the image', async ({ gramFramePage }) => { + // The clamp now measures from the symbol, which leads the stack when the + // label hangs below it (FR-011 still holds). + await gramFramePage.selectSymbol('triangle') + for (const anchorTime of [0, 60]) { + const setId = await gramFramePage.addHarmonicSet(anchorTime, 20) + + const { image, byNum } = await readStackGeometry(gramFramePage, setId) + const entries = Object.values(byNum) + expect(entries.length).toBeGreaterThan(0) + for (const g of entries) { + expect(g.symbolTop).toBeGreaterThanOrEqual(image.top - 1) + } + } + }) + test('a label/symbol stack near the top edge stays within the image', async ({ gramFramePage }) => { // Place the pin at each time extreme; whichever puts the pin near the top // must still keep the label within the image's top edge (FR-011). diff --git a/tests/marker-labels.spec.js b/tests/marker-labels.spec.js index 13775f2..4969625 100644 --- a/tests/marker-labels.spec.js +++ b/tests/marker-labels.spec.js @@ -287,6 +287,28 @@ test.describe('Marker labels', () => { expect(overlay.y).toBeLessThan(symbol.top) }) + // Issue #242: an up-pointing triangle is aimed at the gram above it, so its + // label goes underneath rather than over the data being marked. + test('an up-triangle marker draws its label centred BELOW the symbol', async ({ gramFramePage }) => { + await gramFramePage.selectSymbol('triangle') + const markerId = await placeMarker(gramFramePage, 220, 160) + await gramFramePage.setMarkerLabel(markerId, 'Below') + + const symbol = await gramFramePage.page.evaluate((id) => { + const el = document.querySelector(`.gram-frame-marker-symbol[data-marker-id="${id}"]`) + if (!el) return null + const box = /** @type {SVGGraphicsElement} */ (el).getBBox() + return { centreX: box.x + box.width / 2, bottom: box.y + box.height } + }, markerId) + + const overlay = await gramFramePage.getMarkerLabelOverlay(markerId) + expect(symbol).not.toBeNull() + expect(overlay.textAnchor).toBe('middle') + expect(overlay.x).toBeCloseTo(symbol.centreX, 1) + // The baseline clears the symbol's underside, so the glyphs sit below it. + expect(overlay.y).toBeGreaterThan(symbol.bottom) + }) + test('the label is drawn black inside a white halo, painted behind the glyphs', async ({ gramFramePage }) => { const markerId = await placeMarker(gramFramePage, 220, 160) await gramFramePage.setMarkerLabel(markerId, 'Halo') diff --git a/tests/unit/marker-label.test.js b/tests/unit/marker-label.test.js index 8566b9b..80de1a6 100644 --- a/tests/unit/marker-label.test.js +++ b/tests/unit/marker-label.test.js @@ -5,6 +5,7 @@ import { formatMarkerLabelForTable, markerLabelPlacement } from '../../src/utils/markerLabel.js' +import { labelSitsBelowSymbol, SYMBOL_CATALOG } from '../../src/rendering/symbols.js' /** * @fileoverview Unit coverage for feature 231 — cross-cursor labels. @@ -98,8 +99,8 @@ describe('markerLabelPlacement', () => { expect(placement.textAnchor).toBe('middle') }) - it('places every shaped symbol the same way', () => { - const shaped = ['circle', 'square', 'diamond', 'triangle', 'triangle-down', 'star'] + it('places every shaped symbol above, bar the up-pointing triangle', () => { + const shaped = ['circle', 'square', 'diamond', 'triangle-down', 'star'] const expected = markerLabelPlacement('circle', CX, CY, SYMBOL_SIZE) for (const symbol of shaped) { @@ -107,6 +108,33 @@ describe('markerLabelPlacement', () => { } }) + // Issue #242: an up-pointing triangle is aimed at the gram above it, so a + // label there covers the data the analyst was marking. + it('centres an up-triangle marker\'s label BELOW the symbol', () => { + const placement = markerLabelPlacement('triangle', CX, CY, SYMBOL_SIZE) + + expect(placement.x).toBe(CX) + expect(placement.textAnchor).toBe('middle') + // A whole line of text below the symbol's bottom edge, so the glyphs — + // which hang above their baseline — start clear of the mark. + expect(placement.y).toBeGreaterThan(CY + SYMBOL_SIZE / 2 + 12) + }) + + it('leaves the down-pointing triangle\'s label above it', () => { + // Only the UP triangle points at the space a label would occupy. + const down = markerLabelPlacement('triangle-down', CX, CY, SYMBOL_SIZE) + + expect(down.y).toBeLessThan(CY - SYMBOL_SIZE / 2) + }) + + it('drops the up-triangle\'s label further for a larger symbol', () => { + const small = markerLabelPlacement('triangle', CX, CY, 14) + const large = markerLabelPlacement('triangle', CX, CY, 28) + + expect(large.y).toBeGreaterThan(small.y) + expect(large.y - small.y).toBe((28 - 14) / 2) + }) + it('lifts the label further for a larger symbol', () => { const small = markerLabelPlacement('square', CX, CY, 14) const large = markerLabelPlacement('square', CX, CY, 28) @@ -136,3 +164,19 @@ describe('markerLabelPlacement', () => { expect(moved.y - base.y).toBe(-10) }) }) + +describe('labelSitsBelowSymbol', () => { + it('is true for the up-pointing triangle alone', () => { + // The rule the pin stack and the marker label share (issue #242): one + // symbol points at the space above it, so only that one gives it up. + const below = SYMBOL_CATALOG.filter(labelSitsBelowSymbol) + + expect(below).toEqual(['triangle']) + }) + + it('treats an unknown, null or absent symbol as the default (above)', () => { + expect(labelSitsBelowSymbol('pentagon')).toBe(false) + expect(labelSitsBelowSymbol(null)).toBe(false) + expect(labelSitsBelowSymbol(undefined)).toBe(false) + }) +})