diff --git a/src/model/heatmap.ts b/src/model/heatmap.ts index aba84eb1..9bb17c93 100644 --- a/src/model/heatmap.ts +++ b/src/model/heatmap.ts @@ -1,3 +1,4 @@ +import type { ExtremaTarget } from '@type/extrema'; import type { HeatmapData, MaidrLayer } from '@type/grammar'; import type { Movable } from '@type/movable'; import type { AudioState, BrailleState, TextState } from '@type/state'; @@ -12,7 +13,7 @@ export class Heatmap extends AbstractTrace { return this.heatmapValues; } - protected readonly supportsExtrema = false; + protected readonly supportsExtrema = true; protected readonly movable: Movable; private readonly heatmapValues: number[][]; protected readonly highlightValues: SVGElement[][] | null; @@ -329,4 +330,204 @@ export class Heatmap extends AbstractTrace { col: this.highlightCenters[nearestIndex].col, }; } + + /** + * Gets extrema targets for the heatmap trace + * @returns Array of extrema targets for navigation + */ + public override getExtremaTargets(): ExtremaTarget[] { + const targets: ExtremaTarget[] = []; + const currentRow = this.row; + const currentCol = this.col; + + // 1. Global Maximum + const globalMax = this.findGlobalExtrema('max'); + if (globalMax) { + targets.push({ + label: `Global Maximum: ${globalMax.value} at ${this.x[globalMax.col]}, ${this.y[globalMax.row]}`, + value: globalMax.value, + pointIndex: globalMax.row * this.heatmapValues[0].length + globalMax.col, + segment: 'global', + type: 'max', + navigationType: 'group', + groupIndex: globalMax.row, + categoryIndex: globalMax.col, + }); + } + + // 2. Global Minimum + const globalMin = this.findGlobalExtrema('min'); + if (globalMin) { + targets.push({ + label: `Global Minimum: ${globalMin.value} at ${this.x[globalMin.col]}, ${this.y[globalMin.row]}`, + value: globalMin.value, + pointIndex: globalMin.row * this.heatmapValues[0].length + globalMin.col, + segment: 'global', + type: 'min', + navigationType: 'group', + groupIndex: globalMin.row, + categoryIndex: globalMin.col, + }); + } + + // 3. Maximum on current row + const rowMax = this.findRowExtrema(currentRow, 'max'); + if (rowMax) { + targets.push({ + label: `Row Maximum: ${rowMax.value} at ${this.x[rowMax.col]}, ${this.y[currentRow]}`, + value: rowMax.value, + pointIndex: currentRow * this.heatmapValues[0].length + rowMax.col, + segment: `row-${currentRow}`, + type: 'max', + navigationType: 'group', + groupIndex: currentRow, + categoryIndex: rowMax.col, + }); + } + + // 4. Minimum on current row + const rowMin = this.findRowExtrema(currentRow, 'min'); + if (rowMin) { + targets.push({ + label: `Row Minimum: ${rowMin.value} at ${this.x[rowMin.col]}, ${this.y[currentRow]}`, + value: rowMin.value, + pointIndex: currentRow * this.heatmapValues[0].length + rowMin.col, + segment: `row-${currentRow}`, + type: 'min', + navigationType: 'group', + groupIndex: currentRow, + categoryIndex: rowMin.col, + }); + } + + // 5. Maximum on current column + const colMax = this.findColExtrema(currentCol, 'max'); + if (colMax) { + targets.push({ + label: `Column Maximum: ${colMax.value} at ${this.x[currentCol]}, ${this.y[colMax.row]}`, + value: colMax.value, + pointIndex: colMax.row * this.heatmapValues[0].length + currentCol, + segment: `col-${currentCol}`, + type: 'max', + navigationType: 'group', + groupIndex: colMax.row, + categoryIndex: currentCol, + }); + } + + // 6. Minimum on current column + const colMin = this.findColExtrema(currentCol, 'min'); + if (colMin) { + targets.push({ + label: `Column Minimum: ${colMin.value} at ${this.x[currentCol]}, ${this.y[colMin.row]}`, + value: colMin.value, + pointIndex: colMin.row * this.heatmapValues[0].length + currentCol, + segment: `col-${currentCol}`, + type: 'min', + navigationType: 'group', + groupIndex: colMin.row, + categoryIndex: currentCol, + }); + } + + return targets; + } + + /** + * Finds the global maximum or minimum in the heatmap + * @param type - Whether to find 'max' or 'min' + * @returns Object with row, col, and value of the extrema + */ + private findGlobalExtrema(type: 'max' | 'min'): { row: number; col: number; value: number } | null { + if (this.heatmapValues.length === 0) { + return null; + } + + let extremaRow = 0; + let extremaCol = 0; + let extremaValue = this.heatmapValues[0][0]; + + for (let r = 0; r < this.heatmapValues.length; r++) { + for (let c = 0; c < this.heatmapValues[r].length; c++) { + const value = this.heatmapValues[r][c]; + if (type === 'max' ? value > extremaValue : value < extremaValue) { + extremaValue = value; + extremaRow = r; + extremaCol = c; + } + } + } + + return { row: extremaRow, col: extremaCol, value: extremaValue }; + } + + /** + * Finds the maximum or minimum in a specific row + * @param rowIndex - The row index to search + * @param type - Whether to find 'max' or 'min' + * @returns Object with col and value of the extrema + */ + private findRowExtrema(rowIndex: number, type: 'max' | 'min'): { col: number; value: number } | null { + if (rowIndex < 0 || rowIndex >= this.heatmapValues.length) { + return null; + } + + const row = this.heatmapValues[rowIndex]; + if (row.length === 0) { + return null; + } + + let extremaCol = 0; + let extremaValue = row[0]; + + for (let c = 1; c < row.length; c++) { + const value = row[c]; + if (type === 'max' ? value > extremaValue : value < extremaValue) { + extremaValue = value; + extremaCol = c; + } + } + + return { col: extremaCol, value: extremaValue }; + } + + /** + * Finds the maximum or minimum in a specific column + * @param colIndex - The column index to search + * @param type - Whether to find 'max' or 'min' + * @returns Object with row and value of the extrema + */ + private findColExtrema(colIndex: number, type: 'max' | 'min'): { row: number; value: number } | null { + if (this.heatmapValues.length === 0 || colIndex < 0 || colIndex >= this.heatmapValues[0].length) { + return null; + } + + let extremaRow = 0; + let extremaValue = this.heatmapValues[0][colIndex]; + + for (let r = 1; r < this.heatmapValues.length; r++) { + const value = this.heatmapValues[r][colIndex]; + if (type === 'max' ? value > extremaValue : value < extremaValue) { + extremaValue = value; + extremaRow = r; + } + } + + return { row: extremaRow, value: extremaValue }; + } + + /** + * Navigates to a specific extrema target in the heatmap + * @param target - The extrema target to navigate to + */ + public override navigateToExtrema(target: ExtremaTarget): void { + if (target.groupIndex !== undefined && target.categoryIndex !== undefined) { + // Navigate to the specified row and column + this.row = target.groupIndex; + this.col = target.categoryIndex; + + // Use common finalization method + this.finalizeExtremaNavigation(); + } + } }