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
112 changes: 112 additions & 0 deletions e2e_tests/specs/multiLineplot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
});
});
});
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

239 changes: 238 additions & 1 deletion src/model/line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, Array<{
pointIndex: number;
x: number;
y: number;
intersectingLines: number[];
}>> = new Map();

public constructor(layer: MaidrLayer) {
super(layer);

Expand Down Expand Up @@ -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<number>;
}> = [];

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<number>();
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[] {
Expand Down Expand Up @@ -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;
}

Expand Down
3 changes: 1 addition & 2 deletions src/state/viewModel/goToExtremaViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,7 @@ export class GoToExtremaViewModel extends AbstractViewModel<GoToExtremaState> {
* @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}`;
}

/**
Expand Down
Loading