diff --git a/examples/scatter_plot.html b/examples/scatter_plot.html index 427395b7..c5739e5b 100644 --- a/examples/scatter_plot.html +++ b/examples/scatter_plot.html @@ -12,7 +12,642 @@
+ maidr-data='{ + "id": "575f90cd-a446-4e97-b86e-bfadd82850bc", + "subplots": [ + [ + { + "id": "110d3c24-64ef-4dcb-932a-7586928e29df", + "layers": [ + { + "id": "e3dbc5a7-b907-42bf-aa72-b6c6cf2b34ff", + "type": "point", + "title": "Scatter Plot: Iris Sepal Length vs Sepal Width", + "axes": { + "x": { + "label": "Sepal Length", + "min": 4.3, + "max": 7.9, + "tickStep": 0.7 + }, + "y": { + "label": "Sepal Width", + "min": 2, + "max": 4.4, + "tickStep": 0.5 + } + }, + "data": [ + { + "x": 5.1, + "y": 3.5 + }, + { + "x": 4.9, + "y": 3 + }, + { + "x": 4.7, + "y": 3.2 + }, + { + "x": 4.6, + "y": 3.1 + }, + { + "x": 5, + "y": 3.6 + }, + { + "x": 5.4, + "y": 3.9 + }, + { + "x": 4.6, + "y": 3.4 + }, + { + "x": 5, + "y": 3.4 + }, + { + "x": 4.4, + "y": 2.9 + }, + { + "x": 4.9, + "y": 3.1 + }, + { + "x": 5.4, + "y": 3.7 + }, + { + "x": 4.8, + "y": 3.4 + }, + { + "x": 4.8, + "y": 3 + }, + { + "x": 4.3, + "y": 3 + }, + { + "x": 5.8, + "y": 4 + }, + { + "x": 5.7, + "y": 4.4 + }, + { + "x": 5.4, + "y": 3.9 + }, + { + "x": 5.1, + "y": 3.5 + }, + { + "x": 5.7, + "y": 3.8 + }, + { + "x": 5.1, + "y": 3.8 + }, + { + "x": 5.4, + "y": 3.4 + }, + { + "x": 5.1, + "y": 3.7 + }, + { + "x": 4.6, + "y": 3.6 + }, + { + "x": 5.1, + "y": 3.3 + }, + { + "x": 4.8, + "y": 3.4 + }, + { + "x": 5, + "y": 3 + }, + { + "x": 5, + "y": 3.4 + }, + { + "x": 5.2, + "y": 3.5 + }, + { + "x": 5.2, + "y": 3.4 + }, + { + "x": 4.7, + "y": 3.2 + }, + { + "x": 4.8, + "y": 3.1 + }, + { + "x": 5.4, + "y": 3.4 + }, + { + "x": 5.2, + "y": 4.1 + }, + { + "x": 5.5, + "y": 4.2 + }, + { + "x": 4.9, + "y": 3.1 + }, + { + "x": 5, + "y": 3.2 + }, + { + "x": 5.5, + "y": 3.5 + }, + { + "x": 4.9, + "y": 3.6 + }, + { + "x": 4.4, + "y": 3 + }, + { + "x": 5.1, + "y": 3.4 + }, + { + "x": 5, + "y": 3.5 + }, + { + "x": 4.5, + "y": 2.3 + }, + { + "x": 4.4, + "y": 3.2 + }, + { + "x": 5, + "y": 3.5 + }, + { + "x": 5.1, + "y": 3.8 + }, + { + "x": 4.8, + "y": 3 + }, + { + "x": 5.1, + "y": 3.8 + }, + { + "x": 4.6, + "y": 3.2 + }, + { + "x": 5.3, + "y": 3.7 + }, + { + "x": 5, + "y": 3.3 + }, + { + "x": 7, + "y": 3.2 + }, + { + "x": 6.4, + "y": 3.2 + }, + { + "x": 6.9, + "y": 3.1 + }, + { + "x": 5.5, + "y": 2.3 + }, + { + "x": 6.5, + "y": 2.8 + }, + { + "x": 5.7, + "y": 2.8 + }, + { + "x": 6.3, + "y": 3.3 + }, + { + "x": 4.9, + "y": 2.4 + }, + { + "x": 6.6, + "y": 2.9 + }, + { + "x": 5.2, + "y": 2.7 + }, + { + "x": 5, + "y": 2 + }, + { + "x": 5.9, + "y": 3 + }, + { + "x": 6, + "y": 2.2 + }, + { + "x": 6.1, + "y": 2.9 + }, + { + "x": 5.6, + "y": 2.9 + }, + { + "x": 6.7, + "y": 3.1 + }, + { + "x": 5.6, + "y": 3 + }, + { + "x": 5.8, + "y": 2.7 + }, + { + "x": 6.2, + "y": 2.2 + }, + { + "x": 5.6, + "y": 2.5 + }, + { + "x": 5.9, + "y": 3.2 + }, + { + "x": 6.1, + "y": 2.8 + }, + { + "x": 6.3, + "y": 2.5 + }, + { + "x": 6.1, + "y": 2.8 + }, + { + "x": 6.4, + "y": 2.9 + }, + { + "x": 6.6, + "y": 3 + }, + { + "x": 6.8, + "y": 2.8 + }, + { + "x": 6.7, + "y": 3 + }, + { + "x": 6, + "y": 2.9 + }, + { + "x": 5.7, + "y": 2.6 + }, + { + "x": 5.5, + "y": 2.4 + }, + { + "x": 5.5, + "y": 2.4 + }, + { + "x": 5.8, + "y": 2.7 + }, + { + "x": 6, + "y": 2.7 + }, + { + "x": 5.4, + "y": 3 + }, + { + "x": 6, + "y": 3.4 + }, + { + "x": 6.7, + "y": 3.1 + }, + { + "x": 6.3, + "y": 2.3 + }, + { + "x": 5.6, + "y": 3 + }, + { + "x": 5.5, + "y": 2.5 + }, + { + "x": 5.5, + "y": 2.6 + }, + { + "x": 6.1, + "y": 3 + }, + { + "x": 5.8, + "y": 2.6 + }, + { + "x": 5, + "y": 2.3 + }, + { + "x": 5.6, + "y": 2.7 + }, + { + "x": 5.7, + "y": 3 + }, + { + "x": 5.7, + "y": 2.9 + }, + { + "x": 6.2, + "y": 2.9 + }, + { + "x": 5.1, + "y": 2.5 + }, + { + "x": 5.7, + "y": 2.8 + }, + { + "x": 6.3, + "y": 3.3 + }, + { + "x": 5.8, + "y": 2.7 + }, + { + "x": 7.1, + "y": 3 + }, + { + "x": 6.3, + "y": 2.9 + }, + { + "x": 6.5, + "y": 3 + }, + { + "x": 7.6, + "y": 3 + }, + { + "x": 4.9, + "y": 2.5 + }, + { + "x": 7.3, + "y": 2.9 + }, + { + "x": 6.7, + "y": 2.5 + }, + { + "x": 7.2, + "y": 3.6 + }, + { + "x": 6.5, + "y": 3.2 + }, + { + "x": 6.4, + "y": 2.7 + }, + { + "x": 6.8, + "y": 3 + }, + { + "x": 5.7, + "y": 2.5 + }, + { + "x": 5.8, + "y": 2.8 + }, + { + "x": 6.4, + "y": 3.2 + }, + { + "x": 6.5, + "y": 3 + }, + { + "x": 7.7, + "y": 3.8 + }, + { + "x": 7.7, + "y": 2.6 + }, + { + "x": 6, + "y": 2.2 + }, + { + "x": 6.9, + "y": 3.2 + }, + { + "x": 5.6, + "y": 2.8 + }, + { + "x": 7.7, + "y": 2.8 + }, + { + "x": 6.3, + "y": 2.7 + }, + { + "x": 6.7, + "y": 3.3 + }, + { + "x": 7.2, + "y": 3.2 + }, + { + "x": 6.2, + "y": 2.8 + }, + { + "x": 6.1, + "y": 3 + }, + { + "x": 6.4, + "y": 2.8 + }, + { + "x": 7.2, + "y": 3 + }, + { + "x": 7.4, + "y": 2.8 + }, + { + "x": 7.9, + "y": 3.8 + }, + { + "x": 6.4, + "y": 2.8 + }, + { + "x": 6.3, + "y": 2.8 + }, + { + "x": 6.1, + "y": 2.6 + }, + { + "x": 7.7, + "y": 3 + }, + { + "x": 6.3, + "y": 3.4 + }, + { + "x": 6.4, + "y": 3.1 + }, + { + "x": 6, + "y": 3 + }, + { + "x": 6.9, + "y": 3.1 + }, + { + "x": 6.7, + "y": 3.1 + }, + { + "x": 6.9, + "y": 3.1 + }, + { + "x": 5.8, + "y": 2.7 + }, + { + "x": 6.8, + "y": 3.2 + }, + { + "x": 6.7, + "y": 3.3 + }, + { + "x": 6.7, + "y": 3 + }, + { + "x": 6.3, + "y": 2.5 + }, + { + "x": 6.5, + "y": 3 + }, + { + "x": 6.2, + "y": 3.4 + }, + { + "x": 5.9, + "y": 3 + } + ], + "selectors": [ + "g[maidr='563b0e34-f9d4-498f-bf99-2ca4c2e497bc'] > g > use" + ] + } + ] + } + ] + ] +}'> diff --git a/src/command/describe.ts b/src/command/describe.ts index b7cd8d3c..2f308338 100644 --- a/src/command/describe.ts +++ b/src/command/describe.ts @@ -6,8 +6,8 @@ import type { BrailleViewModel } from '@state/viewModel/brailleViewModel'; import type { TextViewModel } from '@state/viewModel/textViewModel'; import type { BoxBrailleState, LineBrailleState, NonEmptyTraceState } from '@type/state'; import type { Command } from './command'; -import { TraceType } from '@type/grammar'; import { Scope } from '@type/event'; +import { TraceType } from '@type/grammar'; /** * Abstract base class for describe commands. @@ -417,6 +417,12 @@ export class AnnouncePositionCommand extends DescribeCommand { return; } + // Grid mode: announce axis ranges without points + if (state.text.gridPoints !== undefined && state.text.range && state.text.crossRange) { + this.announceGridPosition(state); + return; + } + // Get position from audio.panning (contains x, y, rows, cols) const { panning } = state.audio; const { x, y, rows, cols } = panning; @@ -491,6 +497,28 @@ export class AnnouncePositionCommand extends DescribeCommand { } } + /** + * Announces position for grid navigation mode. + * Shows axis ranges without the points list. + */ + private announceGridPosition(state: NonEmptyTraceState): void { + const { text } = state; + const xRange = text.range!; + const yRange = text.crossRange!; + + if (this.textService.isTerse()) { + this.textViewModel.update( + `${xRange.min} through ${xRange.max}, ${yRange.min} through ${yRange.max}`, + ); + } else { + const xLabel = text.main.label || 'x'; + const yLabel = text.cross.label || 'y'; + this.textViewModel.update( + `${xLabel} is ${xRange.min} through ${xRange.max}, ${yLabel} is ${yRange.min} through ${yRange.max}`, + ); + } + } + /** * Announces position for boxplots with section information. * Uses braille state which normalizes box position regardless of orientation. diff --git a/src/model/abstract.ts b/src/model/abstract.ts index 86525449..2b6a8b15 100644 --- a/src/model/abstract.ts +++ b/src/model/abstract.ts @@ -15,6 +15,7 @@ import type { import type { Trace } from './plot'; import { NavigationService } from '@service/navigation'; import { TraceType } from '@type/grammar'; +import { Constant } from '@util/constant'; const DEFAULT_SUBPLOT_TITLE = 'unavailable'; @@ -253,6 +254,68 @@ export abstract class AbstractPlot implements Movable, Observable, public moveToPoint(_x: number, _y: number): void { // implement basic stuff, assuming something like highlightValues that holds the points and boxes } + + /** + * Sets whether grid navigation mode is active. Override in traces that support grid navigation. + */ + /** + * Returns true if this trace supports compare (lower/higher value) navigation. + * Override to false for trace types that don't use compare modes (e.g., scatter). + */ + public supportsCompareMode(): boolean { + return true; + } + + /** + * Returns the display name for the default data navigation mode. + * Override to provide a trace-specific name (e.g., "ROW AND COLUMN NAVIGATION" for scatter). + */ + public dataModeName(): string { + return Constant.DATA_MODE; + } + + public setGridMode(_enabled: boolean): void { + // No-op for traces that don't support grid navigation. + } + + /** + * Returns true if this trace supports grid navigation. + */ + public supportsGridMode(): boolean { + return false; + } + + /** + * Grid navigation: move up one cell. Returns false if at boundary. + */ + public moveGridUp(): boolean { + this.notifyRotorBounds(); + return false; + } + + /** + * Grid navigation: move down one cell. Returns false if at boundary. + */ + public moveGridDown(): boolean { + this.notifyRotorBounds(); + return false; + } + + /** + * Grid navigation: move left one cell. Returns false if at boundary. + */ + public moveGridLeft(): boolean { + this.notifyRotorBounds(); + return false; + } + + /** + * Grid navigation: move right one cell. Returns false if at boundary. + */ + public moveGridRight(): boolean { + this.notifyRotorBounds(); + return false; + } } export abstract class AbstractTrace extends AbstractPlot implements Trace { @@ -276,8 +339,10 @@ export abstract class AbstractTrace extends AbstractPlot implements this.type = layer.type; this.title = layer.title ?? DEFAULT_SUBPLOT_TITLE; - this.xAxis = layer.axes?.x ?? DEFAULT_X_AXIS; - this.yAxis = layer.axes?.y ?? DEFAULT_Y_AXIS; + const axisX = layer.axes?.x; + const axisY = layer.axes?.y; + this.xAxis = (typeof axisX === 'object' ? axisX.label : axisX) ?? DEFAULT_X_AXIS; + this.yAxis = (typeof axisY === 'object' ? axisY.label : axisY) ?? DEFAULT_Y_AXIS; this.fill = layer.axes?.fill ?? DEFAULT_FILL_AXIS; } diff --git a/src/model/scatter.ts b/src/model/scatter.ts index 24c29372..f04e5c45 100644 --- a/src/model/scatter.ts +++ b/src/model/scatter.ts @@ -1,7 +1,8 @@ -import type { MaidrLayer, ScatterPoint } from '@type/grammar'; +import type { AxisConfig, MaidrLayer, ScatterPoint } from '@type/grammar'; import type { MovableDirection } from '@type/movable'; import type { AudioState, BrailleState, HighlightState, TextState } from '@type/state'; import type { Dimension } from './abstract'; +import { Constant } from '@util/constant'; import { MathUtil } from '@util/math'; import { Svg } from '@util/svg'; import { AbstractTrace } from './abstract'; @@ -22,6 +23,19 @@ interface ScatterYPoint { x: number[]; y: number; } + +/** + * Represents a single cell in the grid navigation overlay. + */ +interface GridCell { + points: ScatterPoint[]; + yValues: number[]; + xValues: number[]; + svgElements: SVGElement[]; + xRange: { min: number; max: number }; + yRange: { min: number; max: number }; +} + enum NavMode { COL = 'col', ROW = 'row', @@ -49,6 +63,14 @@ export class ScatterTrace extends AbstractTrace { private readonly minY: number; private readonly maxY: number; + // Grid navigation state + private readonly gridCells: GridCell[][] | null; + private readonly numGridRows: number; + private readonly numGridCols: number; + private gridRow: number; + private gridCol: number; + private isInGridMode: boolean; + /** * Creates a new scatter trace instance and organizes data by X and Y coordinates. * @param layer - The MAIDR layer containing scatter plot data @@ -89,11 +111,30 @@ export class ScatterTrace extends AbstractTrace { this.minY = MathUtil.safeMin(this.yValues); this.maxY = MathUtil.safeMax(this.yValues); - [this.highlightXValues, this.highlightYValues] = this.mapToSvgElements( - layer.selectors as string, - ); + // Select SVG elements once, then share for COL/ROW grouping and grid cell mapping + const selector = layer.selectors as string; + const allSvgClones = selector ? Svg.selectAllElements(selector) : []; + + [this.highlightXValues, this.highlightYValues] = this.groupSvgElements(allSvgClones); this.highlightCenters = this.mapSvgElementsToCenters(); this.movable = new MovablePlane(this.xPoints, this.yPoints); + + // Build grid if config is provided (supports both axes.x.min and axes.min.x formats) + this.isInGridMode = false; + this.gridRow = 0; + this.gridCol = 0; + const gridConfig = this.resolveGridConfig(layer); + if (gridConfig) { + const xSteps = this.computeGridSteps(gridConfig.xMin, gridConfig.xMax, gridConfig.xTickStep); + const ySteps = this.computeGridSteps(gridConfig.yMin, gridConfig.yMax, gridConfig.yTickStep); + this.numGridCols = xSteps.length; + this.numGridRows = ySteps.length; + this.gridCells = this.buildGridCells(data, xSteps, ySteps, allSvgClones); + } else { + this.gridCells = null; + this.numGridRows = 0; + this.numGridCols = 0; + } } /** @@ -172,6 +213,19 @@ export class ScatterTrace extends AbstractTrace { } protected get braille(): BrailleState { + if (this.isInGridMode && this.gridCells) { + const cell = this.gridCells[this.gridRow][this.gridCol]; + return { + empty: false, + id: this.id, + values: cell.yValues.length > 0 ? [cell.yValues] : [[]], + min: 0, + max: 0, + row: this.gridRow, + col: this.gridCol, + }; + } + return { empty: false, id: this.id, @@ -184,6 +238,23 @@ export class ScatterTrace extends AbstractTrace { } protected get audio(): AudioState { + if (this.isInGridMode && this.gridCells) { + const cell = this.gridCells[this.gridRow][this.gridCol]; + return { + freq: { + raw: cell.yValues, + min: this.minY, + max: this.maxY, + }, + panning: { + y: this.gridRow, + x: this.gridCol, + rows: this.numGridRows, + cols: this.numGridCols, + }, + }; + } + if (this.mode === NavMode.COL) { const current = this.xPoints[this.col]; return { @@ -218,6 +289,17 @@ export class ScatterTrace extends AbstractTrace { } protected get text(): TextState { + if (this.isInGridMode && this.gridCells) { + const cell = this.gridCells[this.gridRow][this.gridCol]; + return { + main: { label: this.xAxis, value: '' }, + cross: { label: this.yAxis, value: '' }, + range: { min: cell.xRange.min, max: cell.xRange.max }, + crossRange: { min: cell.yRange.min, max: cell.yRange.max }, + gridPoints: cell.points, + }; + } + if (this.mode === NavMode.COL) { const current = this.xPoints[this.col]; return { @@ -234,6 +316,12 @@ export class ScatterTrace extends AbstractTrace { } protected get dimension(): Dimension { + if (this.isInGridMode) { + return { + rows: this.numGridRows, + cols: this.numGridCols, + }; + } return { rows: this.yPoints.length, cols: this.xPoints.length, @@ -241,6 +329,17 @@ export class ScatterTrace extends AbstractTrace { } protected get highlight(): HighlightState { + if (this.isInGridMode && this.gridCells) { + const cell = this.gridCells[this.gridRow][this.gridCol]; + if (cell.svgElements.length === 0) { + return this.outOfBoundsState as HighlightState; + } + return { + empty: false, + elements: cell.svgElements, + }; + } + if (this.highlightValues === null) { return this.outOfBoundsState as HighlightState; } @@ -312,6 +411,9 @@ export class ScatterTrace extends AbstractTrace { } public moveOnce(direction: MovableDirection): boolean { + // Exit grid mode when normal navigation resumes + this.isInGridMode = false; + if (this.isInitialEntry) { this.handleInitialEntry(); this.notifyStateUpdate(); @@ -462,19 +564,215 @@ export class ScatterTrace extends AbstractTrace { } } + // ── Grid navigation methods ─────────────────────────────────────────── + + public override setGridMode(enabled: boolean): void { + if (!this.gridCells) { + this.isInGridMode = false; + return; + } + this.isInGridMode = enabled; + if (enabled) { + this.gridRow = 0; + this.gridCol = 0; + this.notifyStateUpdate(); + } + } + + public override supportsCompareMode(): boolean { + return false; + } + + public override dataModeName(): string { + return Constant.ROW_COL_MODE; + } + + public override supportsGridMode(): boolean { + return this.gridCells !== null; + } + + public override moveGridUp(): boolean { + if (!this.gridCells) + return false; + if (this.gridRow >= this.numGridRows - 1) { + this.notifyRotorBounds(); + return false; + } + this.gridRow++; + this.notifyStateUpdate(); + return true; + } + + public override moveGridDown(): boolean { + if (!this.gridCells) + return false; + if (this.gridRow <= 0) { + this.notifyRotorBounds(); + return false; + } + this.gridRow--; + this.notifyStateUpdate(); + return true; + } + + public override moveGridLeft(): boolean { + if (!this.gridCells) + return false; + if (this.gridCol <= 0) { + this.notifyRotorBounds(); + return false; + } + this.gridCol--; + this.notifyStateUpdate(); + return true; + } + + public override moveGridRight(): boolean { + if (!this.gridCells) + return false; + if (this.gridCol >= this.numGridCols - 1) { + this.notifyRotorBounds(); + return false; + } + this.gridCol++; + this.notifyStateUpdate(); + return true; + } + + // ── Grid construction helpers ───────────────────────────────────────── + + /** + * Resolves grid configuration from the layer's axes, supporting two formats: + * - Format A (per-axis): `axes.x = { min, max, tickStep }` and `axes.y = { min, max, tickStep }` + * - Format B (grouped): `axes.min = { x, y }`, `axes.max = { x, y }`, `axes.tickStep = { x, y }` + * Both formats can coexist; per-axis values take precedence. + * Returns null if no grid config is found. + */ + private resolveGridConfig( + layer: MaidrLayer, + ): { xMin: number; xMax: number; xTickStep: number; yMin: number; yMax: number; yTickStep: number } | null { + const axes = layer.axes; + if (!axes) return null; + + const axisX = typeof axes.x === 'object' ? axes.x as AxisConfig : null; + const axisY = typeof axes.y === 'object' ? axes.y as AxisConfig : null; + + // Per-axis (Format A) takes precedence, then grouped (Format B) + const xMin = axisX?.min ?? axes.min?.x; + const xMax = axisX?.max ?? axes.max?.x; + const xTickStep = axisX?.tickStep ?? axes.tickStep?.x; + const yMin = axisY?.min ?? axes.min?.y; + const yMax = axisY?.max ?? axes.max?.y; + const yTickStep = axisY?.tickStep ?? axes.tickStep?.y; + + // All six values must be present for a valid grid config + if (xMin == null || xMax == null || xTickStep == null || yMin == null || yMax == null || yTickStep == null) { + return null; + } + + return { xMin, xMax, xTickStep, yMin, yMax, yTickStep }; + } + + /** + * Computes bin boundaries for one axis. + * @returns Array of { min, max } ranges. Last bin extends to axisMax. + */ + private computeGridSteps( + axisMin: number, + axisMax: number, + tick: number, + ): { min: number; max: number }[] { + const steps: { min: number; max: number }[] = []; + const numBins = Math.round((axisMax - axisMin) / tick); + for (let i = 0; i < numBins; i++) { + const binMin = axisMin + i * tick; + const binMax = i === numBins - 1 ? axisMax : axisMin + (i + 1) * tick; + steps.push({ min: Math.round(binMin * 1000) / 1000, max: Math.round(binMax * 1000) / 1000 }); + } + return steps; + } + + /** + * Finds which bin index a value belongs to. + * Uses half-open intervals [min, max) except the last bin which is [min, max]. + */ + private findGridBin( + value: number, + bins: { min: number; max: number }[], + ): number { + for (let i = 0; i < bins.length; i++) { + if (i === bins.length - 1) { + if (value >= bins[i].min && value <= bins[i].max) + return i; + } else { + if (value >= bins[i].min && value < bins[i].max) + return i; + } + } + return -1; + } + /** - * Maps scatter points to SVG elements grouped by X and Y coordinates. - * @param selector - CSS selector for SVG elements - * @returns Tuple of SVG element arrays grouped by X and Y, or null arrays if unavailable + * Builds the 2D grid of cells and bins data points into them. + * Also maps SVG elements to grid cells by data point index correspondence. + * @param data - The original (unsorted) scatter point data array + * @param xSteps - X-axis bin boundaries + * @param ySteps - Y-axis bin boundaries + * @param svgClones - Pre-selected SVG element clones (index-matched to data array) */ - private mapToSvgElements( - selector?: string, - ): [SVGElement[][], SVGElement[][]] | [null, null] { - if (!selector) { - return [null, null]; + private buildGridCells( + data: ScatterPoint[], + xSteps: { min: number; max: number }[], + ySteps: { min: number; max: number }[], + svgClones: SVGElement[], + ): GridCell[][] { + // Initialize empty grid: gridCells[row][col] + // Row 0 = lowest Y range, row N = highest Y range + const grid: GridCell[][] = []; + for (let r = 0; r < ySteps.length; r++) { + grid[r] = []; + for (let c = 0; c < xSteps.length; c++) { + grid[r][c] = { + points: [], + yValues: [], + xValues: [], + svgElements: [], + xRange: xSteps[c], + yRange: ySteps[r], + }; + } + } + + const hasElements = svgClones.length === data.length; + + // Bin each data point into the appropriate cell + for (let i = 0; i < data.length; i++) { + const point = data[i]; + const colIdx = this.findGridBin(point.x, xSteps); + const rowIdx = this.findGridBin(point.y, ySteps); + if (rowIdx !== -1 && colIdx !== -1) { + grid[rowIdx][colIdx].points.push(point); + grid[rowIdx][colIdx].yValues.push(point.y); + grid[rowIdx][colIdx].xValues.push(point.x); + if (hasElements) { + grid[rowIdx][colIdx].svgElements.push(svgClones[i]); + } + } } - const elements = Svg.selectAllElements(selector); + return grid; + } + + // ── Existing private methods ────────────────────────────────────────── + + /** + * Groups pre-selected SVG elements by their X and Y coordinates. + * @param elements - Array of SVG element clones (already selected from the DOM) + * @returns Tuple of SVG element arrays grouped by X and Y, or null arrays if empty + */ + private groupSvgElements( + elements: SVGElement[], + ): [SVGElement[][], SVGElement[][]] | [null, null] { if (elements.length === 0) { return [null, null]; } diff --git a/src/service/rotor.ts b/src/service/rotor.ts index fa221ee4..26a00c89 100644 --- a/src/service/rotor.ts +++ b/src/service/rotor.ts @@ -3,44 +3,33 @@ import type { TextService } from './text'; import { AbstractTrace } from '@model/abstract'; import { Constant } from '@util/constant'; -/** - * Current rotor modes: data point navigation, lower value and higher value navigation - */ -const ROTOR_MODES: Record = { - 0: Constant.DATA_MODE, - 1: Constant.LOWER_VALUE_MODE, - 2: Constant.HIGHER_VALUE_MODE, -}; /** * Manages rotor-based navigation for the active trace via alt+shift+up and alt+shift+down * * Purpose: - * - Provide modal navigation over a trace by rotating through three modes. + * - Provide modal navigation over a trace by rotating through available modes. * - * Navigation modes: - * - DATA_MODE: Default data browsing. Focus remains in the trace scope; no compare behavior. - * - LOWER_VALUE_MODE: Navigate to the next/previous data point with a lower y-value relative - * to the current point (supports left/right and, when available, up/down semantics). - * - HIGHER_VALUE_MODE: Navigate to the next/previous data point with a higher y-value relative - * to the current point (supports left/right and, when available, up/down semantics). + * Available modes vary by trace type: + * - Non-scatter traces: DATA_MODE → LOWER_VALUE_MODE → HIGHER_VALUE_MODE + * - Scatter traces (with grid): ROW_COL_MODE → GRID_MODE + * - Scatter traces (no grid): ROW_COL_MODE only + * + * Mode descriptions: + * - DATA_MODE / ROW_COL_MODE: Default data browsing. The display name is trace-specific. + * - LOWER_VALUE_MODE: Navigate to data points with lower y-values (non-scatter only). + * - HIGHER_VALUE_MODE: Navigate to data points with higher y-values (non-scatter only). + * - GRID_MODE: Navigate by grid cells in scatter plots (scatter with grid config only). * * Responsibilities: * - Track the current rotor mode and expose helpers to cycle forward/backward across modes. * - Coordinate scope focus: entering a compare mode (LOWER/HIGHER) may switch focus to - * the rotor scope; returning to DATA_MODE restores focus to the trace scope. + * the rotor scope; returning to data mode restores focus to the trace scope. * - Delegate directional movement to the active {@link AbstractTrace} implementation using * rotor-aware APIs, with a fallback to compare-based traversal when rotor methods are * unavailable. * - * Mode management: - * - getMode(): Returns the symbolic mode string for the current index. - * - setMode(): Applies mode side-effects (e.g., restore trace scope in DATA_MODE). - * - getCompareType(): Maps the current mode to 'lower' or 'higher' for compare operations - * (DATA_MODE falls back to 'lower'). - * * Dependencies: * - Context: Provides the active trace and current scope. - * - DisplayService: Toggles UI focus between scopes (trace vs. rotor). * - TextService: Reserved for user-facing feedback/messages and parity with other services. * * Notes: @@ -68,7 +57,8 @@ export class RotorNavigationService { * @returns The name of the new rotor mode */ public moveToNextRotorUnit(): string { - this.rotorIndex = (this.rotorIndex + 1) % Constant.NO_OF_ROTOR_NAV_MODES; + const modes = this.getAvailableModes(); + this.rotorIndex = (this.rotorIndex + 1) % modes.length; this.setMode(); return this.getMode(); @@ -79,7 +69,8 @@ export class RotorNavigationService { * @returns The name of the new rotor mode */ public moveToPrevRotorUnit(): string { - this.rotorIndex = (this.rotorIndex - 1 + Constant.NO_OF_ROTOR_NAV_MODES) % Constant.NO_OF_ROTOR_NAV_MODES; + const modes = this.getAvailableModes(); + this.rotorIndex = (this.rotorIndex - 1 + modes.length) % modes.length; this.setMode(); return this.getMode(); @@ -87,7 +78,7 @@ export class RotorNavigationService { /** * Gets the current rotor mode index. - * @returns The current rotor index (0-2) + * @returns The current rotor index */ public getCurrentUnit(): number { return this.rotorIndex; @@ -126,10 +117,15 @@ export class RotorNavigationService { } /** - * Moves up to a data point with lower/higher value based on rotor mode. + * Moves up to a data point with lower/higher value based on rotor mode, + * or moves up one grid cell in grid mode. * @returns Error message if move failed, null otherwise */ public moveUp(): string | null { + if (this.isGridMode()) { + return this.moveGrid('up'); + } + const activeTrace = this.context.active; try { if (activeTrace instanceof AbstractTrace) { @@ -148,10 +144,15 @@ export class RotorNavigationService { } /** - * Moves down to a data point with lower/higher value based on rotor mode. + * Moves down to a data point with lower/higher value based on rotor mode, + * or moves down one grid cell in grid mode. * @returns Error message if move failed, null otherwise */ public moveDown(): string | null { + if (this.isGridMode()) { + return this.moveGrid('down'); + } + const activeTrace = this.context.active; try { if (activeTrace instanceof AbstractTrace) { @@ -170,10 +171,15 @@ export class RotorNavigationService { } /** - * Moves left to a data point with lower/higher value based on rotor mode. + * Moves left to a data point with lower/higher value based on rotor mode, + * or moves left one grid cell in grid mode. * @returns Error message if move failed, null otherwise */ public moveLeft(): string | null { + if (this.isGridMode()) { + return this.moveGrid('left'); + } + const activeTrace = this.context.active; try { if (activeTrace instanceof AbstractTrace) { @@ -192,10 +198,15 @@ export class RotorNavigationService { } /** - * Moves right to a data point with lower/higher value based on rotor mode. + * Moves right to a data point with lower/higher value based on rotor mode, + * or moves right one grid cell in grid mode. * @returns Error message if move failed, null otherwise */ public moveRight(): string | null { + if (this.isGridMode()) { + return this.moveGrid('right'); + } + const activeTrace = this.context.active; try { if (activeTrace instanceof AbstractTrace) { @@ -217,21 +228,25 @@ export class RotorNavigationService { * Sets the rotor mode based on the current index and updates context state. */ public setMode(): void { - const curr_mode = ROTOR_MODES[this.rotorIndex]; - if (curr_mode === Constant.DATA_MODE) { + const curr_mode = this.getMode(); + if (this.isDataMode(curr_mode)) { this.context.setRotorEnabled(false); + this.notifyGridMode(false); return; } this.context.setRotorEnabled(true); + this.notifyGridMode(curr_mode === Constant.GRID_MODE); } /** * Gets the current rotor mode name. - * @returns The name of the current rotor mode (e.g., 'DATA_MODE', 'LOWER_VALUE_MODE') + * @returns The display name of the current rotor mode */ public getMode(): string { - const curr_mode = ROTOR_MODES[this.rotorIndex]; - return curr_mode; + const modes = this.getAvailableModes(); + // Clamp index in case modes list changed between cycles + const idx = this.rotorIndex % modes.length; + return modes[idx]; } /** @@ -258,4 +273,93 @@ export class RotorNavigationService { const position = direction === 'above' || direction === 'below' ? '' : `to the ${direction} of`; return `No ${nav_type} value found ${position} the current value.`; } + + /** + * Builds the list of available rotor modes based on active trace capabilities. + * - Always includes the trace's data mode name (DATA_MODE or ROW_COL_MODE) + * - Includes LOWER/HIGHER value modes if trace supports compare + * - Includes GRID_MODE if trace supports grid navigation + */ + private getAvailableModes(): string[] { + const activeTrace = this.context.active; + const modes: string[] = []; + + if (activeTrace instanceof AbstractTrace) { + modes.push(activeTrace.dataModeName()); + + if (activeTrace.supportsCompareMode()) { + modes.push(Constant.LOWER_VALUE_MODE); + modes.push(Constant.HIGHER_VALUE_MODE); + } + + if (activeTrace.supportsGridMode()) { + modes.push(Constant.GRID_MODE); + } + } else { + modes.push(Constant.DATA_MODE); + } + + return modes; + } + + /** + * Checks if the given mode name is a data mode (either DATA_MODE or ROW_COL_MODE). + */ + private isDataMode(mode: string): boolean { + return mode === Constant.DATA_MODE || mode === Constant.ROW_COL_MODE; + } + + /** + * Checks if the current rotor mode is GRID_MODE. + */ + private isGridMode(): boolean { + return this.getMode() === Constant.GRID_MODE; + } + + /** + * Notifies the active trace to enter or exit grid mode. + */ + private notifyGridMode(enabled: boolean): void { + const activeTrace = this.context.active; + if (activeTrace instanceof AbstractTrace) { + activeTrace.setGridMode(enabled); + } + } + + /** + * Handles grid navigation in the specified direction. + * @returns Error message if move failed or grid not supported, null otherwise + */ + private moveGrid(direction: 'up' | 'down' | 'left' | 'right'): string | null { + const activeTrace = this.context.active; + if (!(activeTrace instanceof AbstractTrace)) { + return null; + } + + if (!activeTrace.supportsGridMode()) { + return this.getMessage('grid', direction); + } + + let moved: boolean; + switch (direction) { + case 'up': + moved = activeTrace.moveGridUp(); + break; + case 'down': + moved = activeTrace.moveGridDown(); + break; + case 'left': + moved = activeTrace.moveGridLeft(); + break; + case 'right': + moved = activeTrace.moveGridRight(); + break; + } + + if (!moved) { + const dirLabel = direction === 'up' ? 'above' : direction === 'down' ? 'below' : direction; + return this.getMessage('grid', dirLabel); + } + return null; + } } diff --git a/src/service/text.ts b/src/service/text.ts index 3a724913..4e4e98d3 100644 --- a/src/service/text.ts +++ b/src/service/text.ts @@ -339,6 +339,11 @@ export class TextService implements Observer, Disposable { * @returns Verbose formatted text with complete coordinate information */ private formatVerboseTraceText(state: TextState): string { + // Grid cell format: "{xLabel} is {xMin} through {xMax}, {yLabel} is {yMin} through {yMax}, points are: ..." + if (state.gridPoints !== undefined && state.range && state.crossRange) { + return this.formatVerboseGridText(state); + } + const verbose = new Array(); // Use axis identity from TextState, fallback to default mapping @@ -433,6 +438,11 @@ export class TextService implements Observer, Disposable { * @returns Terse formatted text with compact coordinate representation */ private formatTerseTraceText(state: TextState): string { + // Grid cell format: "{xMin} through {xMax}, {yMin} through {yMax}, points: ..." + if (state.gridPoints !== undefined && state.range && state.crossRange) { + return this.formatTerseGridText(state); + } + const terse = new Array(); // Use axis identity from state (supports orientation-aware formatting) @@ -512,6 +522,84 @@ export class TextService implements Observer, Disposable { return terse.join(Constant.EMPTY); } + /** + * Formats grid cell text in verbose mode. + * Output: "{xLabel} is {xMin} through {xMax}, {yLabel} is {yMin} through {yMax}, points are: (x1, y1), ..." + */ + private formatVerboseGridText(state: TextState): string { + const mainAxisType = state.mainAxis ?? 'x'; + const crossAxisType = state.crossAxis ?? 'y'; + const parts: string[] = []; + + // X range + parts.push( + state.main.label, Constant.IS, + this.formatSingleValue(state.range!.min, mainAxisType), + Constant.THROUGH, + this.formatSingleValue(state.range!.max, mainAxisType), + ); + + // Y range + parts.push( + Constant.COMMA_SPACE, state.cross.label, Constant.IS, + this.formatSingleValue(state.crossRange!.min, crossAxisType), + Constant.THROUGH, + this.formatSingleValue(state.crossRange!.max, crossAxisType), + ); + + // Points + const points = state.gridPoints!; + if (points.length === 0) { + parts.push(Constant.COMMA_SPACE, 'no points'); + } else { + const pointStrs = points.map( + p => `(${this.formatSingleValue(p.x, mainAxisType)}, ${this.formatSingleValue(p.y, crossAxisType)})`, + ); + const verb = points.length === 1 ? ' is' : 's are'; + parts.push(Constant.COMMA_SPACE, `point${verb}: `, pointStrs.join(Constant.COMMA_SPACE)); + } + + return parts.join(Constant.EMPTY); + } + + /** + * Formats grid cell text in terse mode. + * Output: "{xMin} through {xMax}, {yMin} through {yMax}, points: (x1, y1), ..." + */ + private formatTerseGridText(state: TextState): string { + const mainAxisType = state.mainAxis ?? 'x'; + const crossAxisType = state.crossAxis ?? 'y'; + const parts: string[] = []; + + // X range + parts.push( + this.formatSingleValue(state.range!.min, mainAxisType), + Constant.THROUGH, + this.formatSingleValue(state.range!.max, mainAxisType), + ); + + // Y range + parts.push( + Constant.COMMA_SPACE, + this.formatSingleValue(state.crossRange!.min, crossAxisType), + Constant.THROUGH, + this.formatSingleValue(state.crossRange!.max, crossAxisType), + ); + + // Points + const points = state.gridPoints!; + if (points.length === 0) { + parts.push(Constant.COMMA_SPACE, 'no points'); + } else { + const pointStrs = points.map( + p => `(${this.formatSingleValue(p.x, mainAxisType)}, ${this.formatSingleValue(p.y, crossAxisType)})`, + ); + parts.push(Constant.COMMA_SPACE, 'points: ', pointStrs.join(Constant.COMMA_SPACE)); + } + + return parts.join(Constant.EMPTY); + } + /** * Updates the service with new plot state and emits appropriate events. * @param state - The new plot state to process diff --git a/src/type/grammar.ts b/src/type/grammar.ts index 85c5118b..34f1c27d 100644 --- a/src/type/grammar.ts +++ b/src/type/grammar.ts @@ -263,6 +263,33 @@ export interface SmoothPoint { svg_y: number; } +/** + * Extended axis configuration that includes an optional label and grid navigation properties. + * Used when an axis needs both a label and grid config (min, max, tickStep). + * + * @example + * // axes.x as an object with grid config + * axes: { x: { label: "Sepal Length", min: 4.3, max: 7.9, tickStep: 0.7 } } + */ +export interface AxisConfig { + label?: string; + min?: number; + max?: number; + tickStep?: number; +} + +/** + * Alternate grid configuration shape where grid properties are grouped by property name. + * Supports `axes.min.x`, `axes.max.x`, `axes.tickStep.x` etc. + * + * @example + * axes: { x: "Sepal Length", min: { x: 4.3, y: 2 }, max: { x: 7.9, y: 4.4 }, tickStep: { x: 0.7, y: 0.5 } } + */ +export interface AxisGridProperty { + x?: number; + y?: number; +} + /** * Chart orientation for bar and box plots. */ @@ -317,26 +344,40 @@ export interface MaidrLayer { iqrDirection?: 'forward' | 'reverse'; }; /** - * Axis configuration including labels and optional formatting. + * Axis configuration including labels, optional formatting, and grid navigation properties. + * + * Supports two shapes for grid config (both can coexist): + * + * **Format A** – per-axis objects (`axes.x.min`): + * ```json + * { "axes": { "x": { "label": "Sepal Length", "min": 4.3, "max": 7.9, "tickStep": 0.7 } } } + * ``` + * + * **Format B** – grouped by property (`axes.min.x`): + * ```json + * { "axes": { "x": "Sepal Length", "min": { "x": 4.3, "y": 2 }, "tickStep": { "x": 0.7, "y": 0.5 } } } + * ``` * * @example - * // Basic axis labels + * // Basic axis labels (no grid) * axes: { x: "Date", y: "Price" } * * @example * // With formatting - * axes: { - * x: "Date", - * y: "Price", - * format: { - * y: { type: "currency", decimals: 2 } - * } - * } + * axes: { x: "Date", y: "Price", format: { y: { type: "currency", decimals: 2 } } } */ axes?: { - x?: string; - y?: string; + /** Axis label (string) or axis config object with label + grid properties. */ + x?: string | AxisConfig; + /** Axis label (string) or axis config object with label + grid properties. */ + y?: string | AxisConfig; fill?: string; + /** Grouped grid property: `min: { x: 4.3, y: 2 }` */ + min?: AxisGridProperty; + /** Grouped grid property: `max: { x: 7.9, y: 4.4 }` */ + max?: AxisGridProperty; + /** Grouped grid property: `tickStep: { x: 0.7, y: 0.5 }` */ + tickStep?: AxisGridProperty; /** * Optional formatting configuration for axis values. * When provided, values displayed in text descriptions will be formatted. diff --git a/src/type/state.ts b/src/type/state.ts index 824cae97..973eb6af 100644 --- a/src/type/state.ts +++ b/src/type/state.ts @@ -269,6 +269,14 @@ export interface TextState { * Used to apply correct formatter regardless of orientation. */ crossAxis?: AxisType; + /** + * Range for the cross axis, used in grid navigation to show both axis ranges. + */ + crossRange?: { min: number; max: number }; + /** + * Points in the current grid cell, listed as coordinate pairs. + */ + gridPoints?: { x: number; y: number }[]; } /** diff --git a/src/util/constant.ts b/src/util/constant.ts index 82549962..718c207f 100644 --- a/src/util/constant.ts +++ b/src/util/constant.ts @@ -148,6 +148,7 @@ export abstract class Constant { static readonly LOWER_VALUE_MODE = 'LOWER VALUE NAVIGATION'; /** Rotor mode for navigating data points */ static readonly DATA_MODE = 'DATA POINT NAVIGATION'; - /** Total number of rotor navigation modes */ - static readonly NO_OF_ROTOR_NAV_MODES = 3; + static readonly ROW_COL_MODE = 'ROW AND COLUMN NAVIGATION'; + /** Rotor mode for navigating grid cells in scatter plots */ + static readonly GRID_MODE = 'GRID NAVIGATION'; }