diff --git a/e2e_tests/specs/multiLineplot.spec.ts b/e2e_tests/specs/multiLineplot.spec.ts index 463070f9c..497c35ea1 100644 --- a/e2e_tests/specs/multiLineplot.spec.ts +++ b/e2e_tests/specs/multiLineplot.spec.ts @@ -378,4 +378,116 @@ test.describe('Multi Lineplot', () => { } }); }); + + test.describe('Go To Navigation', () => { + test('should open Go To dialog with g key', async ({ page }) => { + await setupMultiLineplotPage(page); + + // Press 'g' to open the Go To dialog + await page.keyboard.press('g'); + + // Verify the dialog is visible + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Verify the dialog title + const title = dialog.locator('h3'); + await expect(title).toHaveText('Go To'); + }); + + test('should show intersection targets in Go To dialog', async ({ page }) => { + await setupMultiLineplotPage(page); + + // Press 'g' to open the Go To dialog + await page.keyboard.press('g'); + + // Wait for the dialog to be visible + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Get all targets in the listbox + const targets = dialog.locator('[role="option"]'); + const targetCount = await targets.count(); + + // Should have at least min, max, and potentially intersection targets + expect(targetCount).toBeGreaterThanOrEqual(2); + + // Check for intersection targets - they should contain "Intersection with" + const allTargetTexts = await targets.allTextContents(); + const intersectionTargets = allTargetTexts.filter(text => text.includes('Intersection')); + + // The multi-lineplot example has 3 lines that should have intersections + // At least some intersections should be found + expect(intersectionTargets.length).toBeGreaterThan(0); + }); + + test('should navigate to intersection point', async ({ page }) => { + await setupMultiLineplotPage(page); + + // Press 'g' to open the Go To dialog + await page.keyboard.press('g'); + + // Wait for the dialog to be visible + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Find an intersection target + const intersectionTarget = dialog.locator('[role="option"]').filter({ + hasText: /Intersection/, + }).first(); + + // Ensure intersection targets exist in the test data + const intersectionCount = await intersectionTarget.count(); + expect(intersectionCount).toBeGreaterThan(0); + + // Click the intersection target + await intersectionTarget.click(); + + // Dialog should close after selection + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + }); + + test('should close Go To dialog with Escape', async ({ page }) => { + await setupMultiLineplotPage(page); + + // Press 'g' to open the Go To dialog + await page.keyboard.press('g'); + + // Wait for the dialog to be visible + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Press Escape to close + await page.keyboard.press('Escape'); + + // Dialog should be closed + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + }); + + test('should show only intersections involving current line', async ({ page }) => { + await setupMultiLineplotPage(page); + + // Move to a specific line first (e.g., navigate up/down) + await page.keyboard.press('ArrowUp'); + + // Press 'g' to open the Go To dialog + await page.keyboard.press('g'); + + // Wait for the dialog to be visible + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Get intersection targets + const intersectionTargets = dialog.locator('[role="option"]').filter({ + hasText: /Intersection/, + }); + + // All intersection targets should mention other lines (not just current) + const targetTexts = await intersectionTargets.allTextContents(); + for (const text of targetTexts) { + // Each intersection should mention "with" followed by line names + expect(text).toContain('with'); + } + }); + }); }); diff --git a/package-lock.json b/package-lock.json index 3be79682a..cdf9911a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,6 @@ { "name": "maidr", + "version": "3.53.0", "lockfileVersion": 3, "requires": true, diff --git a/src/model/line.ts b/src/model/line.ts index 9300e270f..643a54514 100644 --- a/src/model/line.ts +++ b/src/model/line.ts @@ -39,6 +39,15 @@ export class LineTrace extends AbstractTrace { // Track previous row for intersection label ordering private previousRow: number | null = null; + // Cache for intersection results, keyed by row index + // Invalidated when active row changes + private intersectionCache: Map> = new Map(); + public constructor(layer: MaidrLayer) { super(layer); @@ -581,9 +590,214 @@ export class LineTrace extends AbstractTrace { }; } + /** + * Check if two line segments intersect and return the intersection point + * Uses parametric line intersection formula + * @param p1 Start point of first segment + * @param p1.x X coordinate of start point of first segment + * @param p1.y Y coordinate of start point of first segment + * @param p2 End point of first segment + * @param p2.x X coordinate of end point of first segment + * @param p2.y Y coordinate of end point of first segment + * @param p3 Start point of second segment + * @param p3.x X coordinate of start point of second segment + * @param p3.y Y coordinate of start point of second segment + * @param p4 End point of second segment + * @param p4.x X coordinate of end point of second segment + * @param p4.y Y coordinate of end point of second segment + * @returns Intersection point {x, y} if segments intersect, null otherwise + */ + private getSegmentIntersection( + p1: { x: number; y: number }, + p2: { x: number; y: number }, + p3: { x: number; y: number }, + p4: { x: number; y: number }, + ): { x: number; y: number } | null { + const x1 = p1.x; + const y1 = p1.y; + const x2 = p2.x; + const y2 = p2.y; + const x3 = p3.x; + const y3 = p3.y; + const x4 = p4.x; + const y4 = p4.y; + + const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4); + + // Lines are parallel (no intersection or infinite intersections) + if (Math.abs(denom) < 1e-10) { + return null; + } + + const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom; + const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom; + + // Check if intersection is within both segments (t and u must be in [0, 1]) + if (t >= 0 && t <= 1 && u >= 0 && u <= 1) { + const intersectX = x1 + t * (x2 - x1); + const intersectY = y1 + t * (y2 - y1); + return { x: intersectX, y: intersectY }; + } + + return null; + } + + /** Tolerance for comparing intersection coordinates (for deduplication) */ + private static readonly INTERSECTION_EPSILON = 1e-6; + + /** + * Find all points where the current line intersects with other lines + * Uses line segment intersection algorithm to find crossings between data points + * Results are cached per row and invalidated when the active row changes + * @returns Array of intersection info containing nearest point index and intersecting line indices + */ + private findAllIntersectionsForCurrentLine(): Array<{ + pointIndex: number; + x: number; + y: number; + intersectingLines: number[]; + }> { + const currentGroup = this.row; + + // Return cached results if available + if (this.intersectionCache.has(currentGroup)) { + return this.intersectionCache.get(currentGroup)!; + } + + if (currentGroup < 0 || currentGroup >= this.points.length) { + return []; + } + + // Only check for intersections if there are multiple lines + if (this.points.length <= 1) { + return []; + } + + const currentLinePoints = this.points[currentGroup]; + if (currentLinePoints.length < 2) { + return []; + } + + // Collect all raw intersections first + const rawIntersections: Array<{ + pointIndex: number; + x: number; + y: number; + otherLine: number; + }> = []; + + // For each segment on the current line + for (let segIndex = 0; segIndex < currentLinePoints.length - 1; segIndex++) { + const p1 = currentLinePoints[segIndex]; + const p2 = currentLinePoints[segIndex + 1]; + + // Convert to numeric for calculation + const seg1Start = { x: Number(p1.x), y: Number(p1.y) }; + const seg1End = { x: Number(p2.x), y: Number(p2.y) }; + + // Check against all segments from other lines + for (let otherLine = 0; otherLine < this.points.length; otherLine++) { + if (otherLine === currentGroup) { + continue; + } + + const otherLinePoints = this.points[otherLine]; + if (otherLinePoints.length < 2) { + continue; + } + + for (let otherSegIndex = 0; otherSegIndex < otherLinePoints.length - 1; otherSegIndex++) { + const p3 = otherLinePoints[otherSegIndex]; + const p4 = otherLinePoints[otherSegIndex + 1]; + + const seg2Start = { x: Number(p3.x), y: Number(p3.y) }; + const seg2End = { x: Number(p4.x), y: Number(p4.y) }; + + const intersection = this.getSegmentIntersection(seg1Start, seg1End, seg2Start, seg2End); + + if (intersection) { + // Find the nearest point on the current line to navigate to + // Use Euclidean distance for accuracy with nearly vertical segments + const distToStart = Math.hypot(intersection.x - seg1Start.x, intersection.y - seg1Start.y); + const distToEnd = Math.hypot(intersection.x - seg1End.x, intersection.y - seg1End.y); + const nearestPointIndex = distToStart <= distToEnd ? segIndex : segIndex + 1; + + rawIntersections.push({ + pointIndex: nearestPointIndex, + x: intersection.x, + y: intersection.y, + otherLine, + }); + } + } + } + } + + // Group intersections using tolerance-based deduplication + const groupedIntersections: Array<{ + pointIndex: number; + x: number; + y: number; + intersectingLines: Set; + }> = []; + + for (const raw of rawIntersections) { + // Find existing group within tolerance + const existingGroup = groupedIntersections.find( + g => Math.abs(g.x - raw.x) < LineTrace.INTERSECTION_EPSILON + && Math.abs(g.y - raw.y) < LineTrace.INTERSECTION_EPSILON, + ); + + if (existingGroup) { + existingGroup.intersectingLines.add(raw.otherLine); + } else { + const intersectingLines = new Set(); + intersectingLines.add(raw.otherLine); + groupedIntersections.push({ + pointIndex: raw.pointIndex, + x: raw.x, + y: raw.y, + intersectingLines, + }); + } + } + + // Convert to final format and sort by x coordinate + // Note: intersectingLines excludes currentGroup (only other lines) + const result = groupedIntersections + .map(entry => ({ + pointIndex: entry.pointIndex, + x: entry.x, + y: entry.y, + intersectingLines: Array.from(entry.intersectingLines).sort((a, b) => a - b), + })) + .sort((a, b) => a.x - b.x); + + // Cache the result + this.intersectionCache.set(currentGroup, result); + + return result; + } + + /** + * Get a formatted label for intersecting lines + * Note: intersectingLines should only contain OTHER lines (not the current line) + * since the user is already on the current line. + * @param intersectingLines Array of line indices that intersect (excluding current line) + * @returns Formatted string of line names (e.g., "Line A, Line B") + */ + private getIntersectionLabel(intersectingLines: number[]): string { + return intersectingLines.map((lineIndex) => { + // Access first point to get the line's fill/name + // Falls back to "Line N" if fill is not defined + const firstPoint = this.points[lineIndex][0]; + return firstPoint?.fill || `Line ${lineIndex + 1}`; + }).join(', '); + } + /** * Get extrema targets for the current line plot - * Returns min and max values within the current group + * Returns min, max values, and intersection points within the current group * @returns Array of extrema targets for navigation */ public override getExtremaTargets(): ExtremaTarget[] { @@ -637,6 +851,29 @@ export class LineTrace extends AbstractTrace { }); } + // Add intersection targets for multiline plots + const intersections = this.findAllIntersectionsForCurrentLine(); + for (const intersection of intersections) { + // intersectingLines only contains OTHER lines (not current line) + const otherLineNames = this.getIntersectionLabel(intersection.intersectingLines); + // Format the intersection coordinates for display + const coordsDisplay = `x=${intersection.x.toFixed(2)}, y=${intersection.y.toFixed(2)}`; + + targets.push({ + label: `Intersection at ${coordsDisplay}`, + value: intersection.y, + pointIndex: intersection.pointIndex, + segment: 'intersection', + type: 'intersection', + navigationType: 'point', + intersectingLines: intersection.intersectingLines, + display: { + coords: coordsDisplay, + otherLines: otherLineNames, + }, + }); + } + return targets; } diff --git a/src/state/viewModel/goToExtremaViewModel.ts b/src/state/viewModel/goToExtremaViewModel.ts index f9dca4464..df3453967 100644 --- a/src/state/viewModel/goToExtremaViewModel.ts +++ b/src/state/viewModel/goToExtremaViewModel.ts @@ -175,8 +175,7 @@ export class GoToExtremaViewModel extends AbstractViewModel { * @returns A description appropriate for the plot type */ private generateDescription(traceType: TraceType): string { - // Simple string replacement, no case statements - return `Navigate to statistical extremes within the current ${traceType}`; + return `Navigate to points of interest within the current ${traceType}`; } /** diff --git a/src/type/extrema.ts b/src/type/extrema.ts index 878a37292..3ca40c917 100644 --- a/src/type/extrema.ts +++ b/src/type/extrema.ts @@ -32,8 +32,8 @@ export interface ExtremaTarget { /** Identifier for the segment/group this extrema belongs to */ segment: string; - /** Type of extrema - maximum or minimum */ - type: 'max' | 'min'; + /** Type of extrema - maximum, minimum, or intersection */ + type: 'max' | 'min' | 'intersection'; /** Index of the group this extrema belongs to (for group-based plots) */ groupIndex?: number; @@ -43,4 +43,21 @@ export interface ExtremaTarget { /** Type of navigation this extrema requires */ navigationType: 'point' | 'group'; + + /** + * For intersection targets: indices of all lines that intersect at this point + * Used for multiline plots to track which lines are involved in the intersection + */ + intersectingLines?: number[]; + + /** + * Structured display data for UI rendering. + * Avoids string parsing in UI components. + */ + display?: { + /** Pre-formatted coordinates string (e.g., "x=1.50, y=2.50") */ + coords?: string; + /** Names of other lines involved (for intersections, excludes current line) */ + otherLines?: string; + }; } diff --git a/src/ui/components/GoToExtrema.tsx b/src/ui/components/GoToExtrema.tsx index 1e44058d2..815937e6b 100644 --- a/src/ui/components/GoToExtrema.tsx +++ b/src/ui/components/GoToExtrema.tsx @@ -327,7 +327,7 @@ export const GoToExtrema: React.FC = () => { > - Go To Extrema + Go To @@ -336,28 +336,49 @@ export const GoToExtrema: React.FC = () => { - {state.description || 'Navigate to statistical extremes'} + {state.description || 'Navigate to points of interest'} - - {state.targets.map((target: ExtremaTarget, index: number) => ( - handleTargetSelect(target)} - role="option" - aria-selected={state.selectedIndex === index} - aria-label={`${target.label.split(' at ')[0]} Value: ${target.value.toFixed(2)} at ${target.label.split(' at ')[1]}`} - tabIndex={0} - sx={getTargetBoxSx(state.selectedIndex === index)} - > - - {`${target.label.split(' at ')[0]} Value: ${target.value.toFixed(2)} at ${target.label.split(' at ')[1]}`} - - - ))} + + {state.targets.map((target: ExtremaTarget, index: number) => { + // Format display based on target type using structured display fields when available + const isIntersection = target.type === 'intersection'; + let displayLabel: string; + + if (isIntersection && target.display) { + // Use structured display fields for intersections + displayLabel = `Intersection with ${target.display.otherLines} at ${target.display.coords}`; + } else if (isIntersection) { + // Fallback for intersection without display fields + displayLabel = target.label; + } else { + // For min/max, show: "Max point Value: 8.00 at X" + const labelParts = target.label.split(' at '); + // Guard against labels without " at " separator + displayLabel = labelParts[1] + ? `${labelParts[0]} Value: ${target.value.toFixed(2)} at ${labelParts[1]}` + : `${labelParts[0]} Value: ${target.value.toFixed(2)}`; + } + + return ( + handleTargetSelect(target)} + role="option" + aria-selected={state.selectedIndex === index} + aria-label={displayLabel} + tabIndex={0} + sx={getTargetBoxSx(state.selectedIndex === index)} + > + + {displayLabel} + + + ); + })} {/* 4th option: Searchable combobox */} {availableXValues.length > 0 && (